1use serde_json::Value;
5
6use super::model::{AgentResponse, questions_field_description};
7
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub enum SchemaRequiredPolicy {
18 AllProperties,
20 MinimumProtocolKeys,
22}
23
24pub 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
37pub fn agent_response_json_schema_json() -> String {
43 let schema = agent_response_json_schema();
44
45 stringify_schema_json(&schema)
46}
47
48pub 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
61fn 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
77fn 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
96fn 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
125fn 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
155fn 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
163fn 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
222fn 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
256fn 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 fn test_agent_response_output_schema_contains_required_fields() {
277 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!(
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 fn test_agent_response_output_schema_all_properties_are_required() {
313 let schema = agent_response_output_schema(SchemaRequiredPolicy::AllProperties);
315
316 assert!(
318 all_properties_in_required(&schema),
319 "every object with `properties` should list all keys in `required`"
320 );
321 }
322
323 #[test]
324 fn test_agent_response_output_schema_minimum_policy_requires_only_answer() {
328 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_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 fn test_agent_response_output_schema_does_not_contain_one_of() {
348 let schema = agent_response_output_schema(SchemaRequiredPolicy::AllProperties);
350
351 assert!(!contains_schema_key(&schema, "oneOf"));
353 }
354
355 #[test]
356 fn test_agent_response_output_schema_does_not_contain_schema_metadata() {
359 let schema = agent_response_output_schema(SchemaRequiredPolicy::MinimumProtocolKeys);
361
362 assert!(!contains_schema_key(&schema, "$schema"));
364 }
365
366 #[test]
367 fn test_agent_response_json_schema_requires_answer_key() {
370 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!(
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 fn test_agent_response_json_schema_sets_additional_properties_false() {
391 let schema = agent_response_json_schema();
393
394 assert!(
396 all_properties_objects_deny_additional(&schema),
397 "every object with `properties` should set `additionalProperties: false`"
398 );
399 }
400
401 #[test]
402 fn test_inject_additional_properties_false_preserves_existing_value() {
405 let mut schema = serde_json::json!({
407 "type": "object",
408 "properties": {
409 "extra": { "type": "object", "additionalProperties": { "type": "string" } }
410 }
411 });
412
413 inject_additional_properties_false(&mut schema);
415
416 assert_eq!(schema["additionalProperties"], Value::Bool(false));
418 assert_eq!(
420 schema["properties"]["extra"]["additionalProperties"],
421 serde_json::json!({ "type": "string" })
422 );
423 }
424
425 #[test]
426 fn test_agent_response_output_schema_ref_objects_have_no_sibling_keywords() {
428 let schema = agent_response_output_schema(SchemaRequiredPolicy::AllProperties);
430
431 assert!(!contains_ref_with_sibling_keywords(&schema));
433 }
434
435 #[test]
436 fn test_agent_response_json_schema_json_is_parseable_value() {
438 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_eq!(parsed_schema, schema_value);
446 }
447
448 #[test]
449 fn test_agent_response_json_schema_preserves_explicit_payload_metadata() {
452 let schema = agent_response_json_schema();
454
455 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 fn test_agent_response_json_schema_preserves_nested_metadata() {
473 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_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 fn test_agent_response_json_schema_preserves_field_metadata() {
500 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_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 fn test_agent_response_json_schema_keeps_optional_summary_field() {
575 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!(
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 fn test_agent_response_output_schema_json_is_parseable_value() {
612 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_eq!(parsed_schema, schema_value);
621 }
622
623 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 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 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 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 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 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}