| 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 |