返回 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.
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
100 lines RUST