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 {
54 let schema = agent_response_output_schema(required_policy);
55
56 stringify_schema_json(&schema)
57}
58
59fn agent_response_json_schema() -> Value {
65 let schema = schemars::schema_for!(AgentResponse);
66 let mut schema_value = serde_json::to_value(schema).unwrap_or(Value::Null);
67
68 inject_dynamic_schema_guidance(&mut schema_value);
69 inject_additional_properties_false(&mut schema_value);
70 inject_minimum_required_protocol_key(&mut schema_value);
71
72 schema_value
73}
74
75fn inject_dynamic_schema_guidance(schema: &mut Value) {
78 let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else {
79 return;
80 };
81 let Some(questions_property) = properties
82 .get_mut("questions")
83 .and_then(Value::as_object_mut)
84 else {
85 return;
86 };
87
88 questions_property.insert(
89 "description".to_string(),
90 Value::String(questions_field_description()),
91 );
92}
93
94fn inject_additional_properties_false(value: &mut Value) {
104 match value {
105 Value::Object(object) => {
106 if object.contains_key("properties") && !object.contains_key("additionalProperties") {
107 object.insert("additionalProperties".to_string(), Value::Bool(false));
108 }
109
110 for nested_value in object.values_mut() {
111 inject_additional_properties_false(nested_value);
112 }
113 }
114 Value::Array(array) => {
115 for nested_value in array {
116 inject_additional_properties_false(nested_value);
117 }
118 }
119 _ => {}
120 }
121}
122
123fn inject_minimum_required_protocol_key(schema: &mut Value) {
132 let Some(object) = schema.as_object_mut() else {
133 return;
134 };
135
136 let required = object
137 .entry("required")
138 .or_insert_with(|| Value::Array(Vec::new()));
139
140 let Some(required_array) = required.as_array_mut() else {
141 return;
142 };
143
144 let already_listed = required_array
145 .iter()
146 .any(|value| value.as_str() == Some("answer"));
147
148 if !already_listed {
149 required_array.push(Value::String("answer".to_string()));
150 }
151}
152
153fn normalize_schema_for_transport(value: &mut Value, required_policy: SchemaRequiredPolicy) {
162 match value {
163 Value::Object(object) => {
164 object.remove("$schema");
165
166 for nested_value in object.values_mut() {
167 normalize_schema_for_transport(nested_value, required_policy);
168 }
169
170 normalize_ref_object_for_codex(object);
171 if required_policy == SchemaRequiredPolicy::AllProperties {
172 normalize_required_for_codex(object);
173 }
174
175 let one_of_values = object
176 .get("oneOf")
177 .and_then(Value::as_array)
178 .map(|items| {
179 items
180 .iter()
181 .filter_map(Value::as_object)
182 .map(|item| item.get("const").and_then(Value::as_str))
183 .collect::<Option<Vec<_>>>()
184 })
185 .map(|option| {
186 option.map(|values| {
187 values
188 .into_iter()
189 .map(ToString::to_string)
190 .collect::<Vec<_>>()
191 })
192 });
193
194 if let Some(Some(enum_variants)) = one_of_values {
195 object.remove("oneOf");
196 object.insert("type".to_string(), Value::String("string".to_string()));
197 object.insert(
198 "enum".to_string(),
199 Value::Array(enum_variants.into_iter().map(Value::String).collect()),
200 );
201 }
202 }
203 Value::Array(array) => {
204 for nested_value in array {
205 normalize_schema_for_transport(nested_value, required_policy);
206 }
207 }
208 _ => {}
209 }
210}
211
212fn normalize_ref_object_for_codex(object: &mut serde_json::Map<String, Value>) {
218 let Some(reference) = object.get("$ref").cloned() else {
219 return;
220 };
221
222 object.clear();
223 object.insert("$ref".to_string(), reference);
224}
225
226fn normalize_required_for_codex(object: &mut serde_json::Map<String, Value>) {
232 let Some(properties) = object.get("properties").and_then(Value::as_object) else {
233 return;
234 };
235
236 let property_keys: Vec<String> = properties.keys().cloned().collect();
237 if property_keys.is_empty() {
238 return;
239 }
240
241 let required = object
242 .entry("required")
243 .or_insert_with(|| Value::Array(Vec::new()));
244
245 let Some(required_array) = required.as_array_mut() else {
246 return;
247 };
248
249 for key in &property_keys {
250 let already_listed = required_array
251 .iter()
252 .any(|value| value.as_str() == Some(key));
253
254 if !already_listed {
255 required_array.push(Value::String(key.clone()));
256 }
257 }
258}
259
260fn stringify_schema_json(schema: &Value) -> String {
262 serde_json::to_string_pretty(schema).unwrap_or("null".to_string())
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn test_agent_response_output_schema_contains_required_fields() {
272 let schema = agent_response_output_schema(SchemaRequiredPolicy::AllProperties);
274 let required_fields = schema
275 .get("required")
276 .and_then(Value::as_array)
277 .expect("schema required fields should exist");
278 let properties = schema
279 .get("properties")
280 .and_then(Value::as_object)
281 .expect("schema properties should exist");
282
283 assert!(
285 required_fields
286 .iter()
287 .any(|value| value.as_str() == Some("answer"))
288 );
289 assert!(
290 required_fields
291 .iter()
292 .any(|value| value.as_str() == Some("questions"))
293 );
294 assert!(
295 required_fields
296 .iter()
297 .any(|value| value.as_str() == Some("review_comment_outcomes"))
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("review_comment_outcomes"));
307 assert!(properties.contains_key("summary"));
308 }
309
310 #[test]
311 fn test_agent_response_output_schema_all_properties_are_required() {
314 let schema = agent_response_output_schema(SchemaRequiredPolicy::AllProperties);
316
317 assert!(
319 all_properties_in_required(&schema),
320 "every object with `properties` should list all keys in `required`"
321 );
322 }
323
324 #[test]
325 fn test_agent_response_output_schema_minimum_policy_requires_only_answer() {
329 let schema = agent_response_output_schema(SchemaRequiredPolicy::MinimumProtocolKeys);
331 let required_fields = schema
332 .get("required")
333 .and_then(Value::as_array)
334 .expect("schema required fields should exist");
335
336 assert_eq!(
338 required_fields,
339 &vec![Value::String("answer".to_string())],
340 "only `answer` should be required; demanding optional response fields rejects \
341 ordinary replies that omit them"
342 );
343 }
344
345 #[test]
346 fn test_agent_response_output_schema_does_not_contain_one_of() {
349 let schema = agent_response_output_schema(SchemaRequiredPolicy::AllProperties);
351
352 assert!(!contains_schema_key(&schema, "oneOf"));
354 }
355
356 #[test]
357 fn test_agent_response_output_schema_does_not_contain_schema_metadata() {
360 let schema = agent_response_output_schema(SchemaRequiredPolicy::MinimumProtocolKeys);
362
363 assert!(!contains_schema_key(&schema, "$schema"));
365 }
366
367 #[test]
368 fn test_agent_response_json_schema_requires_answer_key() {
371 let schema = agent_response_json_schema();
373 let required_fields = schema
374 .get("required")
375 .and_then(Value::as_array)
376 .expect("schema required fields should exist");
377
378 assert!(
380 required_fields
381 .iter()
382 .any(|value| value.as_str() == Some("answer")),
383 "prompt schema should require `answer` to align with parser key-presence check"
384 );
385 }
386
387 #[test]
388 fn test_agent_response_json_schema_sets_additional_properties_false() {
392 let schema = agent_response_json_schema();
394
395 assert!(
397 all_properties_objects_deny_additional(&schema),
398 "every object with `properties` should set `additionalProperties: false`"
399 );
400 }
401
402 #[test]
403 fn test_inject_additional_properties_false_preserves_existing_value() {
406 let mut schema = serde_json::json!({
408 "type": "object",
409 "properties": {
410 "extra": { "type": "object", "additionalProperties": { "type": "string" } }
411 }
412 });
413
414 inject_additional_properties_false(&mut schema);
416
417 assert_eq!(schema["additionalProperties"], Value::Bool(false));
419 assert_eq!(
421 schema["properties"]["extra"]["additionalProperties"],
422 serde_json::json!({ "type": "string" })
423 );
424 }
425
426 #[test]
427 fn test_agent_response_output_schema_ref_objects_have_no_sibling_keywords() {
429 let schema = agent_response_output_schema(SchemaRequiredPolicy::AllProperties);
431
432 assert!(!contains_ref_with_sibling_keywords(&schema));
434 }
435
436 #[test]
437 fn test_agent_response_json_schema_json_is_parseable_value() {
439 let schema_json = agent_response_json_schema_json();
441 let parsed_schema: Value =
442 serde_json::from_str(&schema_json).expect("schema string should parse as JSON");
443 let schema_value = agent_response_json_schema();
444
445 assert_eq!(parsed_schema, schema_value);
447 }
448
449 #[test]
450 fn test_agent_response_json_schema_preserves_explicit_payload_metadata() {
453 let schema = agent_response_json_schema();
455
456 assert_eq!(
458 schema.get("title").and_then(Value::as_str),
459 Some("AgentResponse")
460 );
461 assert_eq!(
462 schema.get("description").and_then(Value::as_str),
463 Some(
464 "Wire-format protocol payload used for schema-driven provider output. Return this \
465 object as the entire assistant response payload. Providers that support output \
466 schemas (for example, Codex app-server) are asked to emit this object directly."
467 )
468 );
469 }
470
471 #[test]
472 fn test_agent_response_json_schema_preserves_nested_metadata() {
474 let schema = agent_response_json_schema();
476 let question_definition = schema
477 .get("$defs")
478 .and_then(|value| value.get("QuestionItem"))
479 .and_then(Value::as_object)
480 .expect("question definition should exist");
481 let review_comment_outcome_definition = schema
482 .get("$defs")
483 .and_then(|value| value.get("ReviewCommentOutcome"))
484 .and_then(Value::as_object)
485 .expect("review comment outcome definition should exist");
486 let review_comment_resolution_definition = schema
487 .get("$defs")
488 .and_then(|value| value.get("ReviewCommentResolution"))
489 .and_then(Value::as_object)
490 .expect("review comment resolution definition should exist");
491 let summary_definition = schema
492 .get("$defs")
493 .and_then(|value| value.get("AgentResponseSummary"))
494 .and_then(Value::as_object)
495 .expect("summary definition should exist");
496
497 assert_eq!(
499 question_definition.get("title").and_then(Value::as_str),
500 Some("QuestionItem")
501 );
502 assert_eq!(
503 review_comment_outcome_definition
504 .get("title")
505 .and_then(Value::as_str),
506 Some("ReviewCommentOutcome")
507 );
508 assert_eq!(
509 review_comment_resolution_definition
510 .get("title")
511 .and_then(Value::as_str),
512 Some("ReviewCommentResolution")
513 );
514 assert_eq!(
515 summary_definition.get("title").and_then(Value::as_str),
516 Some("AgentResponseSummary")
517 );
518 }
519
520 #[test]
521 fn test_agent_response_json_schema_preserves_field_metadata() {
523 let schema = agent_response_json_schema();
525 let response_properties = schema
526 .get("properties")
527 .and_then(Value::as_object)
528 .expect("response properties should exist");
529 let question_properties = schema_definition_properties(&schema, "QuestionItem");
530 let review_comment_outcome_properties =
531 schema_definition_properties(&schema, "ReviewCommentOutcome");
532 let summary_properties = schema_definition_properties(&schema, "AgentResponseSummary");
533 let expected_questions_description = questions_field_description();
534
535 assert_schema_property_title_and_description(
537 response_properties,
538 "answer",
539 "answer",
540 "Markdown answer text for delivered work, status updates, or concise completion \
541 notes. Keep clarification requests out of this field and emit them through \
542 `questions` instead.",
543 );
544 assert_eq!(
545 response_properties
546 .get("questions")
547 .and_then(|value| value.get("description"))
548 .and_then(Value::as_str),
549 Some(expected_questions_description.as_str())
550 );
551 assert_schema_property_title_and_description(
552 response_properties,
553 "review_comment_outcomes",
554 "review_comment_outcomes",
555 "Per-thread outcomes for an agent-driven forge comment-resolution turn. Emit an empty \
556 array unless the prompt explicitly supplies forge thread IDs. Copy each reported \
557 `thread_id` exactly from the prompt.",
558 );
559 assert_schema_property_title_and_description(
560 response_properties,
561 "summary",
562 "summary",
563 "Structured summary for session-discussion turns, kept outside `answer` markdown. Use \
564 `null` for one-shot prompts and legacy payloads.",
565 );
566 assert_schema_property_title_and_description(
567 question_properties,
568 "text",
569 "text",
570 "Human-readable markdown text for this question. Ask one specific actionable question \
571 instead of bundling multiple decisions into one item.",
572 );
573 assert_schema_property_title(question_properties, "options", "options");
574 assert_schema_property_title_and_description(
575 review_comment_outcome_properties,
576 "reply",
577 "reply",
578 "Concise reply suitable for posting to the forge review thread.",
579 );
580 assert_schema_property_title_and_description(
581 review_comment_outcome_properties,
582 "resolution",
583 "resolution",
584 "Whether the targeted thread was fixed or required no change.",
585 );
586 assert_schema_property_title_and_description(
587 review_comment_outcome_properties,
588 "thread_id",
589 "thread_id",
590 "Opaque forge thread identifier copied exactly from the turn prompt.",
591 );
592 assert_schema_property_title_and_description(
593 summary_properties,
594 "turn",
595 "turn",
596 "Concise summary of only the work completed in the current turn.",
597 );
598 assert_schema_property_title_and_description(
599 summary_properties,
600 "session",
601 "session",
602 "Cumulative summary of active changes on the current session branch.",
603 );
604 }
605
606 #[test]
607 fn test_agent_response_json_schema_keeps_optional_summary_field() {
610 let schema = agent_response_json_schema();
612 let response_required_fields = schema
613 .get("required")
614 .and_then(Value::as_array)
615 .cloned()
616 .unwrap_or_default();
617 let question_definition = schema
618 .get("$defs")
619 .and_then(|value| value.get("QuestionItem"))
620 .and_then(Value::as_object)
621 .expect("question definition should exist");
622 let question_required_fields = question_definition
623 .get("required")
624 .and_then(Value::as_array)
625 .cloned()
626 .unwrap_or_default();
627
628 assert!(
630 response_required_fields
631 .iter()
632 .all(|field| field.as_str() != Some("summary")),
633 "raw prompt schema should keep optional summary fields optional"
634 );
635 assert!(
636 question_required_fields
637 .iter()
638 .all(|field| field.as_str() != Some("options")),
639 "question schema should keep `options` optional for omitted empty lists"
640 );
641 }
642
643 #[test]
644 fn test_agent_response_output_schema_json_is_parseable_value() {
647 let schema_json =
649 agent_response_output_schema_json(SchemaRequiredPolicy::MinimumProtocolKeys);
650 let parsed_schema: Value =
651 serde_json::from_str(&schema_json).expect("schema string should parse as JSON");
652 let schema_value = agent_response_output_schema(SchemaRequiredPolicy::MinimumProtocolKeys);
653
654 assert_eq!(parsed_schema, schema_value);
656 }
657
658 fn contains_schema_key(value: &Value, key: &str) -> bool {
660 match value {
661 Value::Object(object) => {
662 if object.contains_key(key) {
663 return true;
664 }
665
666 object
667 .values()
668 .any(|nested_value| contains_schema_key(nested_value, key))
669 }
670 Value::Array(array) => array
671 .iter()
672 .any(|nested_value| contains_schema_key(nested_value, key)),
673 _ => false,
674 }
675 }
676
677 fn contains_ref_with_sibling_keywords(value: &Value) -> bool {
679 match value {
680 Value::Object(object) => {
681 if object.contains_key("$ref") && object.len() > 1 {
682 return true;
683 }
684
685 object.values().any(contains_ref_with_sibling_keywords)
686 }
687 Value::Array(array) => array.iter().any(contains_ref_with_sibling_keywords),
688 _ => false,
689 }
690 }
691
692 fn all_properties_in_required(value: &Value) -> bool {
695 match value {
696 Value::Object(object) => {
697 if let Some(properties) = object.get("properties").and_then(Value::as_object) {
698 let required_keys: Vec<&str> = object
699 .get("required")
700 .and_then(Value::as_array)
701 .map(|array| array.iter().filter_map(Value::as_str).collect())
702 .unwrap_or_default();
703
704 for key in properties.keys() {
705 if !required_keys.contains(&key.as_str()) {
706 return false;
707 }
708 }
709 }
710
711 object.values().all(all_properties_in_required)
712 }
713 Value::Array(array) => array.iter().all(all_properties_in_required),
714 _ => true,
715 }
716 }
717
718 fn all_properties_objects_deny_additional(value: &Value) -> bool {
721 match value {
722 Value::Object(object) => {
723 if object.contains_key("properties")
724 && object.get("additionalProperties") != Some(&Value::Bool(false))
725 {
726 return false;
727 }
728
729 object.values().all(all_properties_objects_deny_additional)
730 }
731 Value::Array(array) => array.iter().all(all_properties_objects_deny_additional),
732 _ => true,
733 }
734 }
735
736 fn schema_definition_properties<'a>(
738 schema: &'a Value,
739 definition_name: &str,
740 ) -> &'a serde_json::Map<String, Value> {
741 schema
742 .get("$defs")
743 .and_then(|value| value.get(definition_name))
744 .and_then(|value| value.get("properties"))
745 .and_then(Value::as_object)
746 .expect("schema definition properties should exist")
747 }
748
749 fn assert_schema_property_title(
751 properties: &serde_json::Map<String, Value>,
752 property_name: &str,
753 expected_title: &str,
754 ) {
755 assert_eq!(
756 properties
757 .get(property_name)
758 .and_then(|value| value.get("title"))
759 .and_then(Value::as_str),
760 Some(expected_title)
761 );
762 }
763
764 fn assert_schema_property_title_and_description(
767 properties: &serde_json::Map<String, Value>,
768 property_name: &str,
769 expected_title: &str,
770 expected_description: &str,
771 ) {
772 assert_schema_property_title(properties, property_name, expected_title);
773 assert_eq!(
774 properties
775 .get(property_name)
776 .and_then(|value| value.get("description"))
777 .and_then(Value::as_str),
778 Some(expected_description)
779 );
780 }
781}