返回 CodeWhale
schema.rs
根目录 / crates / workflow-js / src / schema.rs
1 //! `responseSchema` decoding: parse the subagent's reply as JSON and validate
2 //! it against the caller-supplied schema, with bounded repair when it fails.
3 //!
4 //! A reply that is not valid JSON, or that fails the schema, throws on the
5 //! awaiting `task()` call. Before that terminal throw the VM tries a bounded
6 //! repair (#5583): one re-ask (by default; `schemaRepairAttempts` up to 3) of
7 //! the same route with the schema, the invalid reply, and the decode error,
8 //! so a child that wrapped its JSON in prose does not abort a whole run. A
9 //! failed repair stays a schema failure — never a null slot or a degraded
10 //! success — and both attempts are reported through the driver as receipts.
11
12 /// Upper bound on `schemaRepairAttempts`. Repair is a bounded recovery, not a
13 /// retry loop: more attempts than this is a prompt or model-route problem that
14 /// re-asking cannot fix.
15 pub const SCHEMA_REPAIR_MAX_ATTEMPTS: u32 = 3;
16
17 /// Preview of a raw reply carried in receipts and reports. The full raw text
18 /// goes to a durable artifact when it is larger (the host writes it; the VM
19 /// only carries the bytes it was given).
20 pub const SCHEMA_RAW_PREVIEW_CHARS: usize = 2_000;
21
22 /// Hard cap on raw reply text carried in-memory through events. Child replies
23 /// beyond this are pathological; the carried text is capped with an explicit
24 /// marker so the receipt never pretends to be the whole reply.
25 pub const SCHEMA_RAW_CARRY_CHARS: usize = 64 * 1_024;
26
27 /// Compile the caller's schema. Called before spawning so a malformed schema
28 /// fails fast instead of burning a subagent.
29 pub(crate) fn compile_schema(schema: &serde_json::Value) -> Result<jsonschema::Validator, String> {
30 jsonschema::validator_for(schema)
31 .map_err(|err| format!("task(): invalid responseSchema: {err}"))
32 }
33
34 /// Why a reply failed `responseSchema` decoding.
35 ///
36 /// The distinction is receipt material (#5583): a parse failure means the
37 /// model wrapped or broke the JSON (a repair usually fixes it); a validation
38 /// failure means the JSON parsed but said the wrong thing (a schema or prompt
39 /// problem the operator needs to see named).
40 #[derive(Debug, Clone, PartialEq, Eq)]
41 pub(crate) enum ReplyDecodeError {
42 /// The reply was not parseable as JSON at all.
43 Parse(String),
44 /// The reply parsed but violated the caller's schema.
45 Validate(String),
46 }
47
48 impl ReplyDecodeError {
49 /// Stable machine kind for receipts: `json_parse` or `schema_validation`.
50 pub(crate) fn kind(&self) -> &'static str {
51 match self {
52 Self::Parse(_) => "json_parse",
53 Self::Validate(_) => "schema_validation",
54 }
55 }
56
57 /// The operator-facing message (byte-identical to the pre-repair strings,
58 /// so existing envelopes and tests keep classifying).
59 pub(crate) fn message(&self) -> &str {
60 match self {
61 Self::Parse(message) | Self::Validate(message) => message,
62 }
63 }
64 }
65
66 /// Parse `text` as JSON (tolerating a single Markdown code fence around the
67 /// payload) and validate it against `validator`.
68 pub(crate) fn decode_reply(
69 text: &str,
70 validator: &jsonschema::Validator,
71 ) -> Result<serde_json::Value, ReplyDecodeError> {
72 let candidate = strip_code_fence(text);
73 let parsed: serde_json::Value = serde_json::from_str(candidate).map_err(|err| {
74 ReplyDecodeError::Parse(format!(
75 "task(): responseSchema was set but the reply is not valid JSON: {err}"
76 ))
77 })?;
78 let errors = validator
79 .iter_errors(&parsed)
80 .map(|err| err.to_string())
81 .collect::<Vec<_>>();
82 if !errors.is_empty() {
83 return Err(ReplyDecodeError::Validate(format!(
84 "task(): reply failed responseSchema validation: {}",
85 errors.join("; ")
86 )));
87 }
88 Ok(parsed)
89 }
90
91 /// The raw reply text carried alongside a decode failure, capped at
92 /// [`SCHEMA_RAW_CARRY_CHARS`] on a char boundary with an explicit marker.
93 /// Returns `(carried, was_truncated)`.
94 pub(crate) fn carried_raw(text: &str) -> (String, bool) {
95 if text.chars().count() <= SCHEMA_RAW_CARRY_CHARS {
96 return (text.to_string(), false);
97 }
98 let kept: String = text.chars().take(SCHEMA_RAW_CARRY_CHARS).collect();
99 let dropped = text.chars().count() - SCHEMA_RAW_CARRY_CHARS;
100 (
101 format!("{kept}\n…[raw reply truncated: {dropped} chars not carried]"),
102 true,
103 )
104 }
105
106 /// Build the repair prompt for the next attempt: the same task, the schema it
107 /// must satisfy, the reply that failed, and why. The repair child is a fresh
108 /// agent — it has no memory of the failed attempt, so everything it needs to
109 /// correct the reply travels in this prompt.
110 pub(crate) fn repair_prompt(
111 original_prompt: &str,
112 schema: &serde_json::Value,
113 failed_raw: &str,
114 error: &ReplyDecodeError,
115 ) -> String {
116 let schema_text = serde_json::to_string_pretty(schema).unwrap_or_else(|_| schema.to_string());
117 format!(
118 "Your previous reply for this task failed its responseSchema and is being repaired.\n\
119 Return ONLY the corrected JSON. No prose, no explanation, no Markdown code fence.\n\n\
120 ## Original task\n\n{original_prompt}\n\n\
121 ## responseSchema the reply must satisfy\n\n```json\n{schema_text}\n```\n\n\
122 ## Your previous reply (it failed)\n\n```\n{failed_raw}\n```\n\n\
123 ## Why it failed\n\n{}\n\n\
124 Resend the reply as corrected JSON only.",
125 error.message(),
126 )
127 }
128
129 /// If the whole reply is wrapped in one Markdown code fence (``` or ```json),
130 /// return the fenced body; otherwise return the trimmed reply unchanged.
131 fn strip_code_fence(text: &str) -> &str {
132 let trimmed = text.trim();
133 let Some(rest) = trimmed.strip_prefix("```") else {
134 return trimmed;
135 };
136 let Some(body) = rest.strip_suffix("```") else {
137 return trimmed;
138 };
139 // Drop an optional language tag on the opening fence line.
140 match body.split_once('\n') {
141 Some((first_line, tail)) if !first_line.trim().is_empty() => tail.trim(),
142 _ => body.trim(),
143 }
144 }
145
146 #[cfg(test)]
147 mod tests {
148 use super::*;
149 use serde_json::json;
150
151 fn validator() -> jsonschema::Validator {
152 compile_schema(&json!({
153 "type": "object",
154 "properties": { "refuted": { "type": "boolean" } },
155 "required": ["refuted"],
156 }))
157 .expect("schema compiles")
158 }
159
160 #[test]
161 fn decodes_plain_json() {
162 let value = decode_reply(r#"{"refuted": true}"#, &validator()).unwrap();
163 assert_eq!(value, json!({"refuted": true}));
164 }
165
166 #[test]
167 fn decodes_fenced_json() {
168 let text = "```json\n{\"refuted\": false}\n```";
169 let value = decode_reply(text, &validator()).unwrap();
170 assert_eq!(value, json!({"refuted": false}));
171 }
172
173 #[test]
174 fn rejects_non_json_with_the_parse_kind() {
175 let err = decode_reply("definitely not json", &validator()).unwrap_err();
176 assert_eq!(err.kind(), "json_parse");
177 assert!(
178 err.message().contains("not valid JSON"),
179 "{}",
180 err.message()
181 );
182 }
183
184 #[test]
185 fn rejects_schema_violation_with_the_validation_kind() {
186 let err = decode_reply(r#"{"refuted": "yes"}"#, &validator()).unwrap_err();
187 assert_eq!(err.kind(), "schema_validation");
188 assert!(
189 err.message().contains("responseSchema validation"),
190 "{}",
191 err.message()
192 );
193 }
194
195 #[test]
196 fn rejects_invalid_schema_before_spawn() {
197 let err = compile_schema(&json!({"type": "not-a-type"})).unwrap_err();
198 assert!(err.contains("invalid responseSchema"), "{err}");
199 }
200
201 #[test]
202 fn carried_raw_passes_short_text_through() {
203 let (carried, truncated) = carried_raw("short");
204 assert_eq!(carried, "short");
205 assert!(!truncated);
206 }
207
208 #[test]
209 fn carried_raw_caps_long_text_on_a_char_boundary() {
210 let text = "é".repeat(SCHEMA_RAW_CARRY_CHARS + 10);
211 let (carried, truncated) = carried_raw(&text);
212 assert!(truncated);
213 assert!(carried.ends_with("chars not carried]"));
214 // The cap is honored in chars: exactly SCHEMA_RAW_CARRY_CHARS kept,
215 // with the marker appended as its own line.
216 let kept = carried.lines().next().unwrap_or_default();
217 assert_eq!(kept.chars().count(), SCHEMA_RAW_CARRY_CHARS);
218 }
219
220 #[test]
221 fn repair_prompt_carries_task_schema_reply_and_reason() {
222 let error = ReplyDecodeError::Parse(
223 "task(): responseSchema was set but the reply is not valid JSON: expected value"
224 .to_string(),
225 );
226 let prompt = repair_prompt(
227 "Score the diff.",
228 &json!({"type": "object"}),
229 "Sure! Here it is: {...}",
230 &error,
231 );
232 assert!(prompt.contains("Score the diff."));
233 assert!(prompt.contains("\"type\": \"object\""));
234 assert!(prompt.contains("Sure! Here it is: {...}"));
235 assert!(prompt.contains("not valid JSON"));
236 assert!(prompt.contains("corrected JSON only"));
237 }
238 }
239
239 lines RUST