Skip to main content

ag_protocol/
schema.rs

1//! JSON Schema generation and transport-compatibility normalization for the
2//! structured response protocol.
3
4use serde_json::Value;
5
6use super::model::{AgentResponse, questions_field_description};
7
8/// Selects how a provider transport lists `required` schema properties.
9///
10/// Providers disagree on what a valid schema looks like. Codex rejects schemas
11/// whose `properties` contain keys missing from `required`, so it needs every
12/// key listed. Validators that enforce `required` literally, such as Claude,
13/// must only demand `answer`; listing optional keys there rejects ordinary
14/// replies that omit `questions` or `summary`, even though the parser accepts
15/// them through `#[serde(default)]`.
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub enum SchemaRequiredPolicy {
18    /// Lists every `properties` key in `required` for Codex compatibility.
19    AllProperties,
20    /// Lists only the minimum protocol keys the parser insists on.
21    MinimumProtocolKeys,
22}
23
24/// Returns the JSON Schema used for structured assistant output.
25///
26/// The returned value is passed directly to providers that support enforced
27/// output schemas. It starts from the self-descriptive response schema and then
28/// applies compatibility normalization required by schema-enforcing agents.
29/// `required_policy` selects how strictly optional protocol keys are demanded.
30pub fn agent_response_output_schema(required_policy: SchemaRequiredPolicy) -> Value {
31    let mut value = agent_response_json_schema();
32    normalize_schema_for_transport(&mut value, required_policy);
33
34    value
35}
36
37/// Returns a pretty-printed JSON Schema string for prompt instruction
38/// templating.
39///
40/// This keeps the raw `schemars` metadata intact so inline prompt guidance can
41/// show a fully self-descriptive schema document.
42pub fn agent_response_json_schema_json() -> String {
43    let schema = agent_response_json_schema();
44
45    stringify_schema_json(&schema)
46}
47
48/// Returns a pretty-printed JSON Schema string for prompt instruction
49/// templating.
50///
51/// This is used by prompt builders for providers that cannot enforce
52/// `outputSchema` at transport level and must be guided by in-prompt schema
53/// text instead, or by native schema-validation flags that accept a serialized
54/// schema document.
55pub fn agent_response_output_schema_json(required_policy: SchemaRequiredPolicy) -> String {
56    let schema = agent_response_output_schema(required_policy);
57
58    stringify_schema_json(&schema)
59}
60
61/// Returns the self-descriptive JSON Schema for the response payload.
62///
63/// This preserves the raw `schemars` output, including metadata such as
64/// `title` and `description`, so prompt templates can show models the richest
65/// possible schema contract.
66fn agent_response_json_schema() -> Value {
67    let schema = schemars::schema_for!(AgentResponse);
68    let mut schema_value = serde_json::to_value(schema).unwrap_or(Value::Null);
69
70    inject_dynamic_schema_guidance(&mut schema_value);
71    inject_additional_properties_false(&mut schema_value);
72    inject_minimum_required_protocol_key(&mut schema_value);
73
74    schema_value
75}
76
77/// Injects dynamic prompt guidance that depends on runtime constants into the
78/// schema metadata shown to providers.
79fn inject_dynamic_schema_guidance(schema: &mut Value) {
80    let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else {
81        return;
82    };
83    let Some(questions_property) = properties
84        .get_mut("questions")
85        .and_then(Value::as_object_mut)
86    else {
87        return;
88    };
89
90    questions_property.insert(
91        "description".to_string(),
92        Value::String(questions_field_description()),
93    );
94}
95
96/// Recursively injects `additionalProperties: false` into every schema object
97/// that declares `properties` and does not already set `additionalProperties`.
98///
99/// The wire-format structs omit `#[serde(deny_unknown_fields)]` so
100/// deserialization tolerates extra fields that LLM providers sometimes add.
101/// This function restores the `additionalProperties: false` constraint in the
102/// generated JSON Schema so prompt-level guidance still tells models not to
103/// add extra fields. Pre-existing `additionalProperties` values (e.g. on
104/// map-like schema fields) are preserved.
105fn inject_additional_properties_false(value: &mut Value) {
106    match value {
107        Value::Object(object) => {
108            if object.contains_key("properties") && !object.contains_key("additionalProperties") {
109                object.insert("additionalProperties".to_string(), Value::Bool(false));
110            }
111
112            for nested_value in object.values_mut() {
113                inject_additional_properties_false(nested_value);
114            }
115        }
116        Value::Array(array) => {
117            for nested_value in array {
118                inject_additional_properties_false(nested_value);
119            }
120        }
121        _ => {}
122    }
123}
124
125/// Ensures the top-level `required` array includes `answer` so the prompt
126/// schema rejects `{}` the same way the parser does.
127///
128/// The parser requires at least one recognized protocol key (`answer`,
129/// `questions`, or `summary`). The schema is intentionally stricter: it
130/// requires `answer` specifically, because models should always include it.
131/// If a model omits `answer` but includes another recognized key, the
132/// parser still accepts the payload gracefully thanks to `#[serde(default)]`.
133fn inject_minimum_required_protocol_key(schema: &mut Value) {
134    let Some(object) = schema.as_object_mut() else {
135        return;
136    };
137
138    let required = object
139        .entry("required")
140        .or_insert_with(|| Value::Array(Vec::new()));
141
142    let Some(required_array) = required.as_array_mut() else {
143        return;
144    };
145
146    let already_listed = required_array
147        .iter()
148        .any(|value| value.as_str() == Some("answer"));
149
150    if !already_listed {
151        required_array.push(Value::String("answer".to_string()));
152    }
153}
154
155/// Pretty-prints one schema document for prompt or transport wiring.
156fn stringify_schema_json(schema: &Value) -> String {
157    match serde_json::to_string_pretty(schema) {
158        Ok(schema_json) => schema_json,
159        Err(_) => "null".to_string(),
160    }
161}
162
163/// Normalizes one schema tree for transport-level provider compatibility.
164///
165/// Claude rejects schemas with a top-level `$schema` URI when its validator
166/// cannot resolve that meta-schema. Codex rejects schemas that use `oneOf` for
167/// enum-like constants. Schemars can emit both shapes, so this normalizer
168/// strips transport-only metadata and rewrites enum fragments to string `enum`
169/// definitions. `required_policy` decides whether optional properties are also
170/// forced into `required`.
171fn normalize_schema_for_transport(value: &mut Value, required_policy: SchemaRequiredPolicy) {
172    match value {
173        Value::Object(object) => {
174            object.remove("$schema");
175
176            for nested_value in object.values_mut() {
177                normalize_schema_for_transport(nested_value, required_policy);
178            }
179
180            normalize_ref_object_for_codex(object);
181            if required_policy == SchemaRequiredPolicy::AllProperties {
182                normalize_required_for_codex(object);
183            }
184
185            let one_of_values = object
186                .get("oneOf")
187                .and_then(Value::as_array)
188                .map(|items| {
189                    items
190                        .iter()
191                        .filter_map(Value::as_object)
192                        .map(|item| item.get("const").and_then(Value::as_str))
193                        .collect::<Option<Vec<_>>>()
194                })
195                .map(|option| {
196                    option.map(|values| {
197                        values
198                            .into_iter()
199                            .map(ToString::to_string)
200                            .collect::<Vec<_>>()
201                    })
202                });
203
204            if let Some(Some(enum_variants)) = one_of_values {
205                object.remove("oneOf");
206                object.insert("type".to_string(), Value::String("string".to_string()));
207                object.insert(
208                    "enum".to_string(),
209                    Value::Array(enum_variants.into_iter().map(Value::String).collect()),
210                );
211            }
212        }
213        Value::Array(array) => {
214            for nested_value in array {
215                normalize_schema_for_transport(nested_value, required_policy);
216            }
217        }
218        _ => {}
219    }
220}
221
222/// Ensures all `properties` keys appear in `required` for Codex compatibility.
223///
224/// Codex rejects schemas where `properties` contains keys not listed in
225/// `required`. Schemars omits optional fields from `required`, so this
226/// normalizer adds any missing property keys.
227fn normalize_required_for_codex(object: &mut serde_json::Map<String, Value>) {
228    let Some(properties) = object.get("properties").and_then(Value::as_object) else {
229        return;
230    };
231
232    let property_keys: Vec<String> = properties.keys().cloned().collect();
233    if property_keys.is_empty() {
234        return;
235    }
236
237    let required = object
238        .entry("required")
239        .or_insert_with(|| Value::Array(Vec::new()));
240
241    let Some(required_array) = required.as_array_mut() else {
242        return;
243    };
244
245    for key in &property_keys {
246        let already_listed = required_array
247            .iter()
248            .any(|value| value.as_str() == Some(key));
249
250        if !already_listed {
251            required_array.push(Value::String(key.clone()));
252        }
253    }
254}
255
256/// Rewrites one `$ref` schema object to Codex-compatible form.
257///
258/// Codex rejects sibling keywords alongside `$ref` (for example
259/// `{ "$ref": "...", "description": "..." }`), so this keeps only the
260/// reference key when present.
261fn normalize_ref_object_for_codex(object: &mut serde_json::Map<String, Value>) {
262    let Some(reference) = object.get("$ref").cloned() else {
263        return;
264    };
265
266    object.clear();
267    object.insert("$ref".to_string(), reference);
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    /// Builds a schema object with required top-level response fields.
276    fn test_agent_response_output_schema_contains_required_fields() {
277        // Arrange / Act
278        let schema = agent_response_output_schema(SchemaRequiredPolicy::AllProperties);
279        let required_fields = schema
280            .get("required")
281            .and_then(Value::as_array)
282            .expect("schema required fields should exist");
283        let properties = schema
284            .get("properties")
285            .and_then(Value::as_object)
286            .expect("schema properties should exist");
287
288        // Assert
289        assert!(
290            required_fields
291                .iter()
292                .any(|value| value.as_str() == Some("answer"))
293        );
294        assert!(
295            required_fields
296                .iter()
297                .any(|value| value.as_str() == Some("questions"))
298        );
299        assert!(
300            required_fields
301                .iter()
302                .any(|value| value.as_str() == Some("summary"))
303        );
304        assert!(properties.contains_key("answer"));
305        assert!(properties.contains_key("questions"));
306        assert!(properties.contains_key("summary"));
307    }
308
309    #[test]
310    /// Ensures all transport schema object properties are listed in
311    /// `required`.
312    fn test_agent_response_output_schema_all_properties_are_required() {
313        // Arrange / Act
314        let schema = agent_response_output_schema(SchemaRequiredPolicy::AllProperties);
315
316        // Assert
317        assert!(
318            all_properties_in_required(&schema),
319            "every object with `properties` should list all keys in `required`"
320        );
321    }
322
323    #[test]
324    /// Ensures the minimum-key policy demands only `answer`, so validators that
325    /// enforce `required` literally still accept replies that omit optional
326    /// protocol keys.
327    fn test_agent_response_output_schema_minimum_policy_requires_only_answer() {
328        // Arrange / Act
329        let schema = agent_response_output_schema(SchemaRequiredPolicy::MinimumProtocolKeys);
330        let required_fields = schema
331            .get("required")
332            .and_then(Value::as_array)
333            .expect("schema required fields should exist");
334
335        // Assert
336        assert_eq!(
337            required_fields,
338            &vec![Value::String("answer".to_string())],
339            "only `answer` should be required; demanding `questions` or `summary` rejects \
340             ordinary replies that omit them"
341        );
342    }
343
344    #[test]
345    /// Ensures generated schema avoids `oneOf` so Codex `outputSchema`
346    /// validation accepts the payload.
347    fn test_agent_response_output_schema_does_not_contain_one_of() {
348        // Arrange / Act
349        let schema = agent_response_output_schema(SchemaRequiredPolicy::AllProperties);
350
351        // Assert
352        assert!(!contains_schema_key(&schema, "oneOf"));
353    }
354
355    #[test]
356    /// Ensures generated transport schemas omit `$schema` metadata so Claude
357    /// native schema validation does not need a bundled meta-schema resolver.
358    fn test_agent_response_output_schema_does_not_contain_schema_metadata() {
359        // Arrange / Act
360        let schema = agent_response_output_schema(SchemaRequiredPolicy::MinimumProtocolKeys);
361
362        // Assert
363        assert!(!contains_schema_key(&schema, "$schema"));
364    }
365
366    #[test]
367    /// Ensures the prompt schema requires `answer` so empty objects are
368    /// rejected by schema validation the same way the parser rejects them.
369    fn test_agent_response_json_schema_requires_answer_key() {
370        // Arrange / Act
371        let schema = agent_response_json_schema();
372        let required_fields = schema
373            .get("required")
374            .and_then(Value::as_array)
375            .expect("schema required fields should exist");
376
377        // Assert
378        assert!(
379            required_fields
380                .iter()
381                .any(|value| value.as_str() == Some("answer")),
382            "prompt schema should require `answer` to align with parser key-presence check"
383        );
384    }
385
386    #[test]
387    /// Ensures every schema object with `properties` declares
388    /// `additionalProperties: false` so prompt guidance and transport
389    /// enforcement tell models not to add extra fields.
390    fn test_agent_response_json_schema_sets_additional_properties_false() {
391        // Arrange / Act
392        let schema = agent_response_json_schema();
393
394        // Assert
395        assert!(
396            all_properties_objects_deny_additional(&schema),
397            "every object with `properties` should set `additionalProperties: false`"
398        );
399    }
400
401    #[test]
402    /// `inject_additional_properties_false` preserves a pre-existing
403    /// `additionalProperties` value instead of overwriting it.
404    fn test_inject_additional_properties_false_preserves_existing_value() {
405        // Arrange
406        let mut schema = serde_json::json!({
407            "type": "object",
408            "properties": {
409                "extra": { "type": "object", "additionalProperties": { "type": "string" } }
410            }
411        });
412
413        // Act
414        inject_additional_properties_false(&mut schema);
415
416        // Assert - top-level gets injected (was absent)
417        assert_eq!(schema["additionalProperties"], Value::Bool(false));
418        // Assert - nested keeps its original map-type constraint (was present)
419        assert_eq!(
420            schema["properties"]["extra"]["additionalProperties"],
421            serde_json::json!({ "type": "string" })
422        );
423    }
424
425    #[test]
426    /// Ensures no schema object uses `$ref` with sibling keys.
427    fn test_agent_response_output_schema_ref_objects_have_no_sibling_keywords() {
428        // Arrange / Act
429        let schema = agent_response_output_schema(SchemaRequiredPolicy::AllProperties);
430
431        // Assert
432        assert!(!contains_ref_with_sibling_keywords(&schema));
433    }
434
435    #[test]
436    /// Exposes a parseable pretty JSON schema string for prompt templating.
437    fn test_agent_response_json_schema_json_is_parseable_value() {
438        // Arrange / Act
439        let schema_json = agent_response_json_schema_json();
440        let parsed_schema: Value =
441            serde_json::from_str(&schema_json).expect("schema string should parse as JSON");
442        let schema_value = agent_response_json_schema();
443
444        // Assert
445        assert_eq!(parsed_schema, schema_value);
446    }
447
448    #[test]
449    /// Keeps response schemas self-descriptive so inline schema docs include
450    /// explicit top-level `schemars` metadata.
451    fn test_agent_response_json_schema_preserves_explicit_payload_metadata() {
452        // Arrange / Act
453        let schema = agent_response_json_schema();
454
455        // Assert
456        assert_eq!(
457            schema.get("title").and_then(Value::as_str),
458            Some("AgentResponse")
459        );
460        assert_eq!(
461            schema.get("description").and_then(Value::as_str),
462            Some(
463                "Wire-format protocol payload used for schema-driven provider output. Return this \
464                 object as the entire assistant response payload. Providers that support output \
465                 schemas (for example, Codex app-server) are asked to emit this object directly."
466            )
467        );
468    }
469
470    #[test]
471    /// Keeps nested response-schema models self-descriptive for inline docs.
472    fn test_agent_response_json_schema_preserves_nested_metadata() {
473        // Arrange / Act
474        let schema = agent_response_json_schema();
475        let question_definition = schema
476            .get("$defs")
477            .and_then(|value| value.get("QuestionItem"))
478            .and_then(Value::as_object)
479            .expect("question definition should exist");
480        let summary_definition = schema
481            .get("$defs")
482            .and_then(|value| value.get("AgentResponseSummary"))
483            .and_then(Value::as_object)
484            .expect("summary definition should exist");
485
486        // Assert
487        assert_eq!(
488            question_definition.get("title").and_then(Value::as_str),
489            Some("QuestionItem")
490        );
491        assert_eq!(
492            summary_definition.get("title").and_then(Value::as_str),
493            Some("AgentResponseSummary")
494        );
495    }
496
497    #[test]
498    /// Keeps response-schema fields self-descriptive for inline schema docs.
499    fn test_agent_response_json_schema_preserves_field_metadata() {
500        // Arrange / Act
501        let schema = agent_response_json_schema();
502        let response_properties = schema
503            .get("properties")
504            .and_then(Value::as_object)
505            .expect("response properties should exist");
506        let question_definition = schema
507            .get("$defs")
508            .and_then(|value| value.get("QuestionItem"))
509            .and_then(Value::as_object)
510            .expect("question definition should exist");
511        let question_properties = question_definition
512            .get("properties")
513            .and_then(Value::as_object)
514            .expect("question properties should exist");
515        let summary_definition = schema
516            .get("$defs")
517            .and_then(|value| value.get("AgentResponseSummary"))
518            .and_then(Value::as_object)
519            .expect("summary definition should exist");
520        let summary_properties = summary_definition
521            .get("properties")
522            .and_then(Value::as_object)
523            .expect("summary properties should exist");
524        let expected_questions_description = questions_field_description();
525
526        // Assert
527        assert_schema_property_title_and_description(
528            response_properties,
529            "answer",
530            "answer",
531            "Markdown answer text for delivered work, status updates, or concise completion \
532             notes. Keep clarification requests out of this field and emit them through \
533             `questions` instead.",
534        );
535        assert_eq!(
536            response_properties
537                .get("questions")
538                .and_then(|value| value.get("description"))
539                .and_then(Value::as_str),
540            Some(expected_questions_description.as_str())
541        );
542        assert_schema_property_title_and_description(
543            response_properties,
544            "summary",
545            "summary",
546            "Structured summary for session-discussion turns, kept outside `answer` markdown. Use \
547             `null` for one-shot prompts and legacy payloads.",
548        );
549        assert_schema_property_title_and_description(
550            question_properties,
551            "text",
552            "text",
553            "Human-readable markdown text for this question. Ask one specific actionable question \
554             instead of bundling multiple decisions into one item.",
555        );
556        assert_schema_property_title(question_properties, "options", "options");
557        assert_schema_property_title_and_description(
558            summary_properties,
559            "turn",
560            "turn",
561            "Concise summary of only the work completed in the current turn.",
562        );
563        assert_schema_property_title_and_description(
564            summary_properties,
565            "session",
566            "session",
567            "Cumulative summary of active changes on the current session branch.",
568        );
569    }
570
571    #[test]
572    /// Preserves optional prompt fields in the raw schema instead of forcing
573    /// transport-only requirements into prompt docs.
574    fn test_agent_response_json_schema_keeps_optional_summary_field() {
575        // Arrange / Act
576        let schema = agent_response_json_schema();
577        let response_required_fields = schema
578            .get("required")
579            .and_then(Value::as_array)
580            .cloned()
581            .unwrap_or_default();
582        let question_definition = schema
583            .get("$defs")
584            .and_then(|value| value.get("QuestionItem"))
585            .and_then(Value::as_object)
586            .expect("question definition should exist");
587        let question_required_fields = question_definition
588            .get("required")
589            .and_then(Value::as_array)
590            .cloned()
591            .unwrap_or_default();
592
593        // Assert
594        assert!(
595            response_required_fields
596                .iter()
597                .all(|field| field.as_str() != Some("summary")),
598            "raw prompt schema should keep optional summary fields optional"
599        );
600        assert!(
601            question_required_fields
602                .iter()
603                .all(|field| field.as_str() != Some("options")),
604            "question schema should keep `options` optional for omitted empty lists"
605        );
606    }
607
608    #[test]
609    /// Exposes a parseable pretty JSON schema string for transport-level
610    /// schema enforcement.
611    fn test_agent_response_output_schema_json_is_parseable_value() {
612        // Arrange / Act
613        let schema_json =
614            agent_response_output_schema_json(SchemaRequiredPolicy::MinimumProtocolKeys);
615        let parsed_schema: Value =
616            serde_json::from_str(&schema_json).expect("schema string should parse as JSON");
617        let schema_value = agent_response_output_schema(SchemaRequiredPolicy::MinimumProtocolKeys);
618
619        // Assert
620        assert_eq!(parsed_schema, schema_value);
621    }
622
623    /// Recursively checks whether one JSON value tree contains a schema key.
624    fn contains_schema_key(value: &Value, key: &str) -> bool {
625        match value {
626            Value::Object(object) => {
627                if object.contains_key(key) {
628                    return true;
629                }
630
631                object
632                    .values()
633                    .any(|nested_value| contains_schema_key(nested_value, key))
634            }
635            Value::Array(array) => array
636                .iter()
637                .any(|nested_value| contains_schema_key(nested_value, key)),
638            _ => false,
639        }
640    }
641
642    /// Recursively checks whether any `$ref` object has extra sibling keys.
643    fn contains_ref_with_sibling_keywords(value: &Value) -> bool {
644        match value {
645            Value::Object(object) => {
646                if object.contains_key("$ref") && object.len() > 1 {
647                    return true;
648                }
649
650                object.values().any(contains_ref_with_sibling_keywords)
651            }
652            Value::Array(array) => array.iter().any(contains_ref_with_sibling_keywords),
653            _ => false,
654        }
655    }
656
657    /// Recursively checks that every object with `properties` lists all
658    /// property keys in `required`.
659    fn all_properties_in_required(value: &Value) -> bool {
660        match value {
661            Value::Object(object) => {
662                if let Some(properties) = object.get("properties").and_then(Value::as_object) {
663                    let required_keys: Vec<&str> = object
664                        .get("required")
665                        .and_then(Value::as_array)
666                        .map(|array| array.iter().filter_map(Value::as_str).collect())
667                        .unwrap_or_default();
668
669                    for key in properties.keys() {
670                        if !required_keys.contains(&key.as_str()) {
671                            return false;
672                        }
673                    }
674                }
675
676                object.values().all(all_properties_in_required)
677            }
678            Value::Array(array) => array.iter().all(all_properties_in_required),
679            _ => true,
680        }
681    }
682
683    /// Recursively checks that every object with `properties` sets
684    /// `additionalProperties: false`.
685    fn all_properties_objects_deny_additional(value: &Value) -> bool {
686        match value {
687            Value::Object(object) => {
688                if object.contains_key("properties")
689                    && object.get("additionalProperties") != Some(&Value::Bool(false))
690                {
691                    return false;
692                }
693
694                object.values().all(all_properties_objects_deny_additional)
695            }
696            Value::Array(array) => array.iter().all(all_properties_objects_deny_additional),
697            _ => true,
698        }
699    }
700
701    /// Asserts one property schema has the expected `title`.
702    fn assert_schema_property_title(
703        properties: &serde_json::Map<String, Value>,
704        property_name: &str,
705        expected_title: &str,
706    ) {
707        assert_eq!(
708            properties
709                .get(property_name)
710                .and_then(|value| value.get("title"))
711                .and_then(Value::as_str),
712            Some(expected_title)
713        );
714    }
715
716    /// Asserts one property schema has the expected `title` and
717    /// `description`.
718    fn assert_schema_property_title_and_description(
719        properties: &serde_json::Map<String, Value>,
720        property_name: &str,
721        expected_title: &str,
722        expected_description: &str,
723    ) {
724        assert_schema_property_title(properties, property_name, expected_title);
725        assert_eq!(
726            properties
727                .get(property_name)
728                .and_then(|value| value.get("description"))
729                .and_then(Value::as_str),
730            Some(expected_description)
731        );
732    }
733}