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