| 1 | //! `responseSchema` decoding: parse the subagent's reply as JSON and validate |
| 2 | //! it against the caller-supplied schema. |
| 3 | //! |
| 4 | //! Retry semantics live on the driver side (it owns the child and its |
| 5 | //! prompt); the VM only parses and validates — a reply that is not valid |
| 6 | //! JSON, or that fails the schema, throws on the awaiting `task()` call. |
| 7 | |
| 8 | /// Compile the caller's schema. Called before spawning so a malformed schema |
| 9 | /// fails fast instead of burning a subagent. |
| 10 | pub(crate) fn compile_schema(schema: &serde_json::Value) -> Result<jsonschema::Validator, String> { |
| 11 | jsonschema::validator_for(schema) |
| 12 | .map_err(|err| format!("task(): invalid responseSchema: {err}")) |
| 13 | } |
| 14 | |
| 15 | /// Parse `text` as JSON (tolerating a single Markdown code fence around the |
| 16 | /// payload) and validate it against `validator`. |
| 17 | pub(crate) fn decode_reply( |
| 18 | text: &str, |
| 19 | validator: &jsonschema::Validator, |
| 20 | ) -> Result<serde_json::Value, String> { |
| 21 | let candidate = strip_code_fence(text); |
| 22 | let parsed: serde_json::Value = serde_json::from_str(candidate).map_err(|err| { |
| 23 | format!("task(): responseSchema was set but the reply is not valid JSON: {err}") |
| 24 | })?; |
| 25 | let errors = validator |
| 26 | .iter_errors(&parsed) |
| 27 | .map(|err| err.to_string()) |
| 28 | .collect::<Vec<_>>(); |
| 29 | if !errors.is_empty() { |
| 30 | return Err(format!( |
| 31 | "task(): reply failed responseSchema validation: {}", |
| 32 | errors.join("; ") |
| 33 | )); |
| 34 | } |
| 35 | Ok(parsed) |
| 36 | } |
| 37 | |
| 38 | /// If the whole reply is wrapped in one Markdown code fence (``` or ```json), |
| 39 | /// return the fenced body; otherwise return the trimmed reply unchanged. |
| 40 | fn strip_code_fence(text: &str) -> &str { |
| 41 | let trimmed = text.trim(); |
| 42 | let Some(rest) = trimmed.strip_prefix("```") else { |
| 43 | return trimmed; |
| 44 | }; |
| 45 | let Some(body) = rest.strip_suffix("```") else { |
| 46 | return trimmed; |
| 47 | }; |
| 48 | // Drop an optional language tag on the opening fence line. |
| 49 | match body.split_once('\n') { |
| 50 | Some((first_line, tail)) if !first_line.trim().is_empty() => tail.trim(), |
| 51 | _ => body.trim(), |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | #[cfg(test)] |
| 56 | mod tests { |
| 57 | use super::*; |
| 58 | use serde_json::json; |
| 59 | |
| 60 | fn validator() -> jsonschema::Validator { |
| 61 | compile_schema(&json!({ |
| 62 | "type": "object", |
| 63 | "properties": { "refuted": { "type": "boolean" } }, |
| 64 | "required": ["refuted"], |
| 65 | })) |
| 66 | .expect("schema compiles") |
| 67 | } |
| 68 | |
| 69 | #[test] |
| 70 | fn decodes_plain_json() { |
| 71 | let value = decode_reply(r#"{"refuted": true}"#, &validator()).unwrap(); |
| 72 | assert_eq!(value, json!({"refuted": true})); |
| 73 | } |
| 74 | |
| 75 | #[test] |
| 76 | fn decodes_fenced_json() { |
| 77 | let text = "```json\n{\"refuted\": false}\n```"; |
| 78 | let value = decode_reply(text, &validator()).unwrap(); |
| 79 | assert_eq!(value, json!({"refuted": false})); |
| 80 | } |
| 81 | |
| 82 | #[test] |
| 83 | fn rejects_non_json() { |
| 84 | let err = decode_reply("definitely not json", &validator()).unwrap_err(); |
| 85 | assert!(err.contains("not valid JSON"), "{err}"); |
| 86 | } |
| 87 | |
| 88 | #[test] |
| 89 | fn rejects_schema_violation() { |
| 90 | let err = decode_reply(r#"{"refuted": "yes"}"#, &validator()).unwrap_err(); |
| 91 | assert!(err.contains("responseSchema validation"), "{err}"); |
| 92 | } |
| 93 | |
| 94 | #[test] |
| 95 | fn rejects_invalid_schema_before_spawn() { |
| 96 | let err = compile_schema(&json!({"type": "not-a-type"})).unwrap_err(); |
| 97 | assert!(err.contains("invalid responseSchema"), "{err}"); |
| 98 | } |
| 99 | } |
| 100 |