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/// Codex rejects schemas that use `oneOf` for enum-like constants. Schemars
149/// can emit this shape for simple Rust enums, so this normalizer rewrites
150/// those fragments to string `enum` definitions.
151fn normalize_schema_for_transport(value: &mut Value) {
152    match value {
153        Value::Object(object) => {
154            for nested_value in object.values_mut() {
155                normalize_schema_for_transport(nested_value);
156            }
157
158            normalize_ref_object_for_codex(object);
159            normalize_required_for_codex(object);
160
161            let one_of_values = object
162                .get("oneOf")
163                .and_then(Value::as_array)
164                .map(|items| {
165                    items
166                        .iter()
167                        .filter_map(Value::as_object)
168                        .map(|item| item.get("const").and_then(Value::as_str))
169                        .collect::<Option<Vec<_>>>()
170                })
171                .map(|option| {
172                    option.map(|values| {
173                        values
174                            .into_iter()
175                            .map(ToString::to_string)
176                            .collect::<Vec<_>>()
177                    })
178                });
179
180            if let Some(Some(enum_variants)) = one_of_values {
181                object.remove("oneOf");
182                object.insert("type".to_string(), Value::String("string".to_string()));
183                object.insert(
184                    "enum".to_string(),
185                    Value::Array(enum_variants.into_iter().map(Value::String).collect()),
186                );
187            }
188        }
189        Value::Array(array) => {
190            for nested_value in array {
191                normalize_schema_for_transport(nested_value);
192            }
193        }
194        _ => {}
195    }
196}
197
198/// Ensures all `properties` keys appear in `required` for Codex compatibility.
199///
200/// Codex rejects schemas where `properties` contains keys not listed in
201/// `required`. Schemars omits optional fields from `required`, so this
202/// normalizer adds any missing property keys.
203fn normalize_required_for_codex(object: &mut serde_json::Map<String, Value>) {
204    let Some(properties) = object.get("properties").and_then(Value::as_object) else {
205        return;
206    };
207
208    let property_keys: Vec<String> = properties.keys().cloned().collect();
209    if property_keys.is_empty() {
210        return;
211    }
212
213    let required = object
214        .entry("required")
215        .or_insert_with(|| Value::Array(Vec::new()));
216
217    let Some(required_array) = required.as_array_mut() else {
218        return;
219    };
220
221    for key in &property_keys {
222        let already_listed = required_array
223            .iter()
224            .any(|value| value.as_str() == Some(key));
225
226        if !already_listed {
227            required_array.push(Value::String(key.clone()));
228        }
229    }
230}
231
232/// Rewrites one `$ref` schema object to Codex-compatible form.
233///
234/// Codex rejects sibling keywords alongside `$ref` (for example
235/// `{ "$ref": "...", "description": "..." }`), so this keeps only the
236/// reference key when present.
237fn normalize_ref_object_for_codex(object: &mut serde_json::Map<String, Value>) {
238    let Some(reference) = object.get("$ref").cloned() else {
239        return;
240    };
241
242    object.clear();
243    object.insert("$ref".to_string(), reference);
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    /// Builds a schema object with required top-level response fields.
252    fn test_agent_response_output_schema_contains_required_fields() {
253        // Arrange / Act
254        let schema = agent_response_output_schema();
255        let required_fields = schema
256            .get("required")
257            .and_then(Value::as_array)
258            .expect("schema required fields should exist");
259        let properties = schema
260            .get("properties")
261            .and_then(Value::as_object)
262            .expect("schema properties should exist");
263
264        // Assert
265        assert!(
266            required_fields
267                .iter()
268                .any(|value| value.as_str() == Some("answer"))
269        );
270        assert!(
271            required_fields
272                .iter()
273                .any(|value| value.as_str() == Some("questions"))
274        );
275        assert!(
276            required_fields
277                .iter()
278                .any(|value| value.as_str() == Some("summary"))
279        );
280        assert!(properties.contains_key("answer"));
281        assert!(properties.contains_key("questions"));
282        assert!(properties.contains_key("summary"));
283    }
284
285    #[test]
286    /// Ensures all transport schema object properties are listed in
287    /// `required`.
288    fn test_agent_response_output_schema_all_properties_are_required() {
289        // Arrange / Act
290        let schema = agent_response_output_schema();
291
292        // Assert
293        assert!(
294            all_properties_in_required(&schema),
295            "every object with `properties` should list all keys in `required`"
296        );
297    }
298
299    #[test]
300    /// Ensures generated schema avoids `oneOf` so Codex `outputSchema`
301    /// validation accepts the payload.
302    fn test_agent_response_output_schema_does_not_contain_one_of() {
303        // Arrange / Act
304        let schema = agent_response_output_schema();
305
306        // Assert
307        assert!(!contains_schema_key(&schema, "oneOf"));
308    }
309
310    #[test]
311    /// Ensures the prompt schema requires `answer` so empty objects are
312    /// rejected by schema validation the same way the parser rejects them.
313    fn test_agent_response_json_schema_requires_answer_key() {
314        // Arrange / Act
315        let schema = agent_response_json_schema();
316        let required_fields = schema
317            .get("required")
318            .and_then(Value::as_array)
319            .expect("schema required fields should exist");
320
321        // Assert
322        assert!(
323            required_fields
324                .iter()
325                .any(|value| value.as_str() == Some("answer")),
326            "prompt schema should require `answer` to align with parser key-presence check"
327        );
328    }
329
330    #[test]
331    /// Ensures every schema object with `properties` declares
332    /// `additionalProperties: false` so prompt guidance and transport
333    /// enforcement tell models not to add extra fields.
334    fn test_agent_response_json_schema_sets_additional_properties_false() {
335        // Arrange / Act
336        let schema = agent_response_json_schema();
337
338        // Assert
339        assert!(
340            all_properties_objects_deny_additional(&schema),
341            "every object with `properties` should set `additionalProperties: false`"
342        );
343    }
344
345    #[test]
346    /// `inject_additional_properties_false` preserves a pre-existing
347    /// `additionalProperties` value instead of overwriting it.
348    fn test_inject_additional_properties_false_preserves_existing_value() {
349        // Arrange
350        let mut schema = serde_json::json!({
351            "type": "object",
352            "properties": {
353                "extra": { "type": "object", "additionalProperties": { "type": "string" } }
354            }
355        });
356
357        // Act
358        inject_additional_properties_false(&mut schema);
359
360        // Assert - top-level gets injected (was absent)
361        assert_eq!(schema["additionalProperties"], Value::Bool(false));
362        // Assert - nested keeps its original map-type constraint (was present)
363        assert_eq!(
364            schema["properties"]["extra"]["additionalProperties"],
365            serde_json::json!({ "type": "string" })
366        );
367    }
368
369    #[test]
370    /// Ensures no schema object uses `$ref` with sibling keys.
371    fn test_agent_response_output_schema_ref_objects_have_no_sibling_keywords() {
372        // Arrange / Act
373        let schema = agent_response_output_schema();
374
375        // Assert
376        assert!(!contains_ref_with_sibling_keywords(&schema));
377    }
378
379    #[test]
380    /// Exposes a parseable pretty JSON schema string for prompt templating.
381    fn test_agent_response_json_schema_json_is_parseable_value() {
382        // Arrange / Act
383        let schema_json = agent_response_json_schema_json();
384        let parsed_schema: Value =
385            serde_json::from_str(&schema_json).expect("schema string should parse as JSON");
386        let schema_value = agent_response_json_schema();
387
388        // Assert
389        assert_eq!(parsed_schema, schema_value);
390    }
391
392    #[test]
393    /// Keeps response schemas self-descriptive so inline schema docs include
394    /// explicit top-level `schemars` metadata.
395    fn test_agent_response_json_schema_preserves_explicit_payload_metadata() {
396        // Arrange / Act
397        let schema = agent_response_json_schema();
398
399        // Assert
400        assert_eq!(
401            schema.get("title").and_then(Value::as_str),
402            Some("AgentResponse")
403        );
404        assert_eq!(
405            schema.get("description").and_then(Value::as_str),
406            Some(
407                "Wire-format protocol payload used for schema-driven provider output. Return this \
408                 object as the entire assistant response payload. Providers that support output \
409                 schemas (for example, Codex app-server) are asked to emit this object directly."
410            )
411        );
412    }
413
414    #[test]
415    /// Keeps nested response-schema models self-descriptive for inline docs.
416    fn test_agent_response_json_schema_preserves_nested_metadata() {
417        // Arrange / Act
418        let schema = agent_response_json_schema();
419        let question_definition = schema
420            .get("$defs")
421            .and_then(|value| value.get("QuestionItem"))
422            .and_then(Value::as_object)
423            .expect("question definition should exist");
424        let summary_definition = schema
425            .get("$defs")
426            .and_then(|value| value.get("AgentResponseSummary"))
427            .and_then(Value::as_object)
428            .expect("summary definition should exist");
429
430        // Assert
431        assert_eq!(
432            question_definition.get("title").and_then(Value::as_str),
433            Some("QuestionItem")
434        );
435        assert_eq!(
436            summary_definition.get("title").and_then(Value::as_str),
437            Some("AgentResponseSummary")
438        );
439    }
440
441    #[test]
442    /// Keeps response-schema fields self-descriptive for inline schema docs.
443    fn test_agent_response_json_schema_preserves_field_metadata() {
444        // Arrange / Act
445        let schema = agent_response_json_schema();
446        let response_properties = schema
447            .get("properties")
448            .and_then(Value::as_object)
449            .expect("response properties should exist");
450        let question_definition = schema
451            .get("$defs")
452            .and_then(|value| value.get("QuestionItem"))
453            .and_then(Value::as_object)
454            .expect("question definition should exist");
455        let question_properties = question_definition
456            .get("properties")
457            .and_then(Value::as_object)
458            .expect("question properties should exist");
459        let summary_definition = schema
460            .get("$defs")
461            .and_then(|value| value.get("AgentResponseSummary"))
462            .and_then(Value::as_object)
463            .expect("summary definition should exist");
464        let summary_properties = summary_definition
465            .get("properties")
466            .and_then(Value::as_object)
467            .expect("summary properties should exist");
468        let expected_questions_description = questions_field_description();
469
470        // Assert
471        assert_schema_property_title_and_description(
472            response_properties,
473            "answer",
474            "answer",
475            "Markdown answer text for delivered work, status updates, or concise completion \
476             notes. Keep clarification requests out of this field and emit them through \
477             `questions` instead.",
478        );
479        assert_eq!(
480            response_properties
481                .get("questions")
482                .and_then(|value| value.get("description"))
483                .and_then(Value::as_str),
484            Some(expected_questions_description.as_str())
485        );
486        assert_schema_property_title_and_description(
487            response_properties,
488            "summary",
489            "summary",
490            "Structured summary for session-discussion turns, kept outside `answer` markdown. Use \
491             `null` for one-shot prompts and legacy payloads.",
492        );
493        assert_schema_property_title_and_description(
494            question_properties,
495            "text",
496            "text",
497            "Human-readable markdown text for this question. Ask one specific actionable question \
498             instead of bundling multiple decisions into one item.",
499        );
500        assert_schema_property_title(question_properties, "options", "options");
501        assert_schema_property_title_and_description(
502            summary_properties,
503            "turn",
504            "turn",
505            "Concise summary of only the work completed in the current turn.",
506        );
507        assert_schema_property_title_and_description(
508            summary_properties,
509            "session",
510            "session",
511            "Cumulative summary of active changes on the current session branch.",
512        );
513    }
514
515    #[test]
516    /// Preserves optional prompt fields in the raw schema instead of forcing
517    /// transport-only requirements into prompt docs.
518    fn test_agent_response_json_schema_keeps_optional_summary_field() {
519        // Arrange / Act
520        let schema = agent_response_json_schema();
521        let response_required_fields = schema
522            .get("required")
523            .and_then(Value::as_array)
524            .cloned()
525            .unwrap_or_default();
526        let question_definition = schema
527            .get("$defs")
528            .and_then(|value| value.get("QuestionItem"))
529            .and_then(Value::as_object)
530            .expect("question definition should exist");
531        let question_required_fields = question_definition
532            .get("required")
533            .and_then(Value::as_array)
534            .cloned()
535            .unwrap_or_default();
536
537        // Assert
538        assert!(
539            response_required_fields
540                .iter()
541                .all(|field| field.as_str() != Some("summary")),
542            "raw prompt schema should keep optional summary fields optional"
543        );
544        assert!(
545            question_required_fields
546                .iter()
547                .all(|field| field.as_str() != Some("options")),
548            "question schema should keep `options` optional for omitted empty lists"
549        );
550    }
551
552    #[test]
553    /// Exposes a parseable pretty JSON schema string for transport-level
554    /// schema enforcement.
555    fn test_agent_response_output_schema_json_is_parseable_value() {
556        // Arrange / Act
557        let schema_json = agent_response_output_schema_json();
558        let parsed_schema: Value =
559            serde_json::from_str(&schema_json).expect("schema string should parse as JSON");
560        let schema_value = agent_response_output_schema();
561
562        // Assert
563        assert_eq!(parsed_schema, schema_value);
564    }
565
566    /// Recursively checks whether one JSON value tree contains a schema key.
567    fn contains_schema_key(value: &Value, key: &str) -> bool {
568        match value {
569            Value::Object(object) => {
570                if object.contains_key(key) {
571                    return true;
572                }
573
574                object
575                    .values()
576                    .any(|nested_value| contains_schema_key(nested_value, key))
577            }
578            Value::Array(array) => array
579                .iter()
580                .any(|nested_value| contains_schema_key(nested_value, key)),
581            _ => false,
582        }
583    }
584
585    /// Recursively checks whether any `$ref` object has extra sibling keys.
586    fn contains_ref_with_sibling_keywords(value: &Value) -> bool {
587        match value {
588            Value::Object(object) => {
589                if object.contains_key("$ref") && object.len() > 1 {
590                    return true;
591                }
592
593                object.values().any(contains_ref_with_sibling_keywords)
594            }
595            Value::Array(array) => array.iter().any(contains_ref_with_sibling_keywords),
596            _ => false,
597        }
598    }
599
600    /// Recursively checks that every object with `properties` lists all
601    /// property keys in `required`.
602    fn all_properties_in_required(value: &Value) -> bool {
603        match value {
604            Value::Object(object) => {
605                if let Some(properties) = object.get("properties").and_then(Value::as_object) {
606                    let required_keys: Vec<&str> = object
607                        .get("required")
608                        .and_then(Value::as_array)
609                        .map(|array| array.iter().filter_map(Value::as_str).collect())
610                        .unwrap_or_default();
611
612                    for key in properties.keys() {
613                        if !required_keys.contains(&key.as_str()) {
614                            return false;
615                        }
616                    }
617                }
618
619                object.values().all(all_properties_in_required)
620            }
621            Value::Array(array) => array.iter().all(all_properties_in_required),
622            _ => true,
623        }
624    }
625
626    /// Recursively checks that every object with `properties` sets
627    /// `additionalProperties: false`.
628    fn all_properties_objects_deny_additional(value: &Value) -> bool {
629        match value {
630            Value::Object(object) => {
631                if object.contains_key("properties")
632                    && object.get("additionalProperties") != Some(&Value::Bool(false))
633                {
634                    return false;
635                }
636
637                object.values().all(all_properties_objects_deny_additional)
638            }
639            Value::Array(array) => array.iter().all(all_properties_objects_deny_additional),
640            _ => true,
641        }
642    }
643
644    /// Asserts one property schema has the expected `title`.
645    fn assert_schema_property_title(
646        properties: &serde_json::Map<String, Value>,
647        property_name: &str,
648        expected_title: &str,
649    ) {
650        assert_eq!(
651            properties
652                .get(property_name)
653                .and_then(|value| value.get("title"))
654                .and_then(Value::as_str),
655            Some(expected_title)
656        );
657    }
658
659    /// Asserts one property schema has the expected `title` and
660    /// `description`.
661    fn assert_schema_property_title_and_description(
662        properties: &serde_json::Map<String, Value>,
663        property_name: &str,
664        expected_title: &str,
665        expected_description: &str,
666    ) {
667        assert_schema_property_title(properties, property_name, expected_title);
668        assert_eq!(
669            properties
670                .get(property_name)
671                .and_then(|value| value.get("description"))
672                .and_then(Value::as_str),
673            Some(expected_description)
674        );
675    }
676}