Skip to main content

ag_protocol/
model.rs

1//! Structured response protocol data model and display helpers.
2
3use std::fmt;
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use super::question::QuestionItem;
9use super::subtask::SubtaskItem;
10use super::verification::VerificationVerdictItem;
11
12/// Hard cap on the number of clarification questions extracted from one agent
13/// response. Prevents runaway output from flooding the question UI even when
14/// the agent ignores the prompt-level limit.
15///
16/// This constant is also injected into the protocol instruction prompt
17/// templates so the prompt-level guidance and the server-side cap stay in
18/// sync automatically.
19pub(crate) const MAX_QUESTIONS: usize = 5;
20/// Hard cap on the number of subtasks accepted from one orchestrator planning
21/// turn. Bounds how many child sessions, worktrees, and agent CLI processes a
22/// single approved plan can create even when the agent ignores the
23/// prompt-level limit.
24pub(crate) const MAX_SUBTASKS: usize = 8;
25const QUESTIONS_FIELD_DESCRIPTION_TEMPLATE: &str =
26    include_str!("template/questions_field_description.md");
27const SUBTASKS_FIELD_DESCRIPTION_TEMPLATE: &str =
28    include_str!("template/subtasks_field_description.md");
29
30/// Returns the canonical JSON Schema description for the `questions` field.
31///
32/// This is the single source of truth for the runtime-injected schema
33/// description and the matching test expectation. The static `schemars`
34/// metadata on `AgentResponse::questions` is overwritten by
35/// `inject_dynamic_schema_guidance` before any consumer observes the schema,
36/// so all schema-facing call sites must route through this helper to stay in
37/// sync.
38pub(crate) fn questions_field_description() -> String {
39    render_field_description_template(
40        QUESTIONS_FIELD_DESCRIPTION_TEMPLATE,
41        "{{ max_questions }}",
42        MAX_QUESTIONS,
43    )
44}
45
46/// Returns the canonical JSON Schema description for the `subtasks` field.
47///
48/// This mirrors [`questions_field_description`]: the static `schemars`
49/// metadata on `AgentResponse::subtasks` carries only the field title, and
50/// `inject_dynamic_schema_guidance` overwrites the description with this
51/// helper's output before any consumer observes the schema.
52pub(crate) fn subtasks_field_description() -> String {
53    render_field_description_template(
54        SUBTASKS_FIELD_DESCRIPTION_TEMPLATE,
55        "{{ max_subtasks }}",
56        MAX_SUBTASKS,
57    )
58}
59
60/// Substitutes one `{{ name }}` placeholder with a runtime cap value.
61///
62/// The placeholder is matched after collapsing whitespace runs inside every
63/// `{{ ... }}` span, because `mdformat` reflows these templates at a fixed
64/// column width and will break a line in the middle of a placeholder. Matching
65/// the literal text alone silently left the raw `{{ ... }}` in the description
66/// shown to models, so normalization keeps the templates safe to reformat.
67fn render_field_description_template(template: &str, placeholder: &str, value: usize) -> String {
68    let mut rendered = String::with_capacity(template.len());
69    let mut remaining = template.trim_end();
70
71    while let Some(open_index) = remaining.find("{{") {
72        let after_open = &remaining[open_index..];
73        let Some(close_end) = after_open.find("}}").map(|index| index + "}}".len()) else {
74            break;
75        };
76
77        rendered.push_str(&remaining[..open_index]);
78        rendered.push_str(&collapse_whitespace(&after_open[..close_end]));
79        remaining = &after_open[close_end..];
80    }
81    rendered.push_str(remaining);
82
83    rendered.replace(placeholder, &value.to_string())
84}
85
86/// Collapses every whitespace run in `text` to one space.
87fn collapse_whitespace(text: &str) -> String {
88    text.split_whitespace().collect::<Vec<_>>().join(" ")
89}
90
91/// Protocol-owned request family preserved across prompt submission and repair
92/// retries.
93///
94/// Session discussion turns and isolated utility prompts share the same
95/// top-level [`AgentResponse`] schema. Agentty still carries the request
96/// family through transport boundaries so call sites can keep one consistent
97/// protocol contract even when some callers ignore parts of the response, such
98/// as the optional top-level `summary`.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100#[non_exhaustive]
101pub enum ProtocolRequestProfile {
102    /// Interactive session turn.
103    SessionTurn,
104    /// Isolated utility prompt.
105    UtilityPrompt,
106}
107
108/// Structured session summary block emitted alongside protocol messages.
109///
110/// Session-discussion turns use this object instead of embedding the change
111/// summary inside `answer` message text. One-shot prompts set the top-level
112/// `summary` field to `null`.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
114#[schemars(
115    title = "AgentResponseSummary",
116    description = "Structured session summary block emitted alongside protocol messages instead \
117                   of embedding the change summary inside `answer` markdown on session-discussion \
118                   turns."
119)]
120pub struct AgentResponseSummary {
121    /// Cumulative summary of active changes on the current session branch.
122    #[schemars(
123        title = "session",
124        description = "Cumulative summary of active changes on the current session branch."
125    )]
126    pub session: String,
127    /// Concise summary of only the work completed in the current turn.
128    #[schemars(
129        title = "turn",
130        description = "Concise summary of only the work completed in the current turn."
131    )]
132    pub turn: String,
133}
134
135/// Agent-reported disposition for one forge review thread.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
137#[serde(rename_all = "snake_case")]
138#[schemars(
139    title = "ReviewCommentResolution",
140    description = "Disposition reported for one targeted forge review thread."
141)]
142pub enum ReviewCommentResolution {
143    /// The agent completed the requested action and the thread can be
144    /// resolved after its reply is posted.
145    Fixed,
146    /// The agent determined that no code or documentation change was needed;
147    /// its explanatory reply is posted while the thread remains open.
148    NoChangeNeeded,
149}
150
151/// Structured outcome for one forge review thread targeted by the turn.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
153#[schemars(
154    title = "ReviewCommentOutcome",
155    description = "Structured outcome for one forge review thread explicitly included in the turn \
156                   prompt."
157)]
158pub struct ReviewCommentOutcome {
159    /// Concise forge reply explaining what changed or why no change was needed.
160    #[schemars(
161        title = "reply",
162        description = "Concise reply suitable for posting to the forge review thread."
163    )]
164    pub reply: String,
165    /// Whether the thread was fixed or did not require a change.
166    #[schemars(
167        title = "resolution",
168        description = "Whether the targeted thread was fixed or required no change."
169    )]
170    pub resolution: ReviewCommentResolution,
171    /// Opaque forge thread identifier copied exactly from the turn prompt.
172    #[schemars(
173        title = "thread_id",
174        description = "Opaque forge thread identifier copied exactly from the turn prompt."
175    )]
176    pub thread_id: String,
177}
178
179/// Wire-format protocol payload used for schema-driven provider output.
180///
181/// Providers that support output schemas (for example, Codex app-server) are
182/// asked to emit this object as the entire assistant response payload.
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
184#[schemars(
185    title = "AgentResponse",
186    description = "Wire-format protocol payload used for schema-driven provider output. Return \
187                   this object as the entire assistant response payload. Providers that support \
188                   output schemas (for example, Codex app-server) are asked to emit this object \
189                   directly."
190)]
191pub struct AgentResponse {
192    /// Markdown answer text emitted for this turn.
193    #[serde(default)]
194    #[schemars(
195        title = "answer",
196        description = "Markdown answer text for delivered work, status updates, or concise \
197                       completion notes. Keep clarification requests out of this field and emit \
198                       them through `questions` instead."
199    )]
200    pub answer: String,
201    /// Ordered clarification questions emitted for this turn.
202    ///
203    /// The canonical JSON Schema description for this field is produced by
204    /// [`questions_field_description`] and injected at schema generation time
205    /// by `inject_dynamic_schema_guidance`. The static `schemars` metadata
206    /// here only sets the field title; the description is intentionally
207    /// omitted so the helper is the single source of truth.
208    #[serde(default)]
209    #[schemars(title = "questions")]
210    pub questions: Vec<QuestionItem>,
211    /// Per-thread outcomes for an agent-driven forge comment-resolution turn.
212    ///
213    /// Ordinary session and utility turns leave this empty. Resolution
214    /// workflows accept only identifiers explicitly allowlisted in the turn
215    /// prompt before applying any forge-side effect.
216    #[serde(default)]
217    #[schemars(
218        title = "review_comment_outcomes",
219        description = "Per-thread outcomes for an agent-driven forge comment-resolution turn. \
220                       Emit an empty array unless the prompt explicitly supplies forge thread \
221                       IDs. Copy each reported `thread_id` exactly from the prompt."
222    )]
223    pub review_comment_outcomes: Vec<ReviewCommentOutcome>,
224    /// Proposed child-session subtasks for an orchestrator planning turn.
225    ///
226    /// The canonical JSON Schema description is produced by
227    /// [`subtasks_field_description`] and injected at schema generation time,
228    /// so the static `schemars` metadata here only sets the field title.
229    /// Ordinary session and utility turns leave this empty, and orchestration
230    /// consumers ignore it unless the turn prompt asked for a plan.
231    #[serde(default)]
232    #[schemars(title = "subtasks")]
233    pub subtasks: Vec<SubtaskItem>,
234    /// Structured summary for session-discussion turns, or `None` for legacy
235    /// payloads and one-shot prompts.
236    #[serde(default)]
237    #[schemars(
238        title = "summary",
239        description = "Structured summary for session-discussion turns, kept outside `answer` \
240                       markdown. Use `null` for one-shot prompts and legacy payloads."
241    )]
242    pub summary: Option<AgentResponseSummary>,
243    /// Per-task decisions emitted for an orchestration verification turn.
244    ///
245    /// Ordinary turns leave this empty. The controller must copy task keys
246    /// from the coordinator envelope so only explicit passes can proceed to
247    /// integration.
248    #[serde(default)]
249    #[schemars(
250        title = "verification_verdicts",
251        description = "Per-task decisions for an orchestration verification turn. Emit one item \
252                       for every task in the verification envelope, and use an empty array for \
253                       ordinary turns."
254    )]
255    pub verification_verdicts: Vec<VerificationVerdictItem>,
256}
257
258impl AgentResponse {
259    /// Creates a plain response from raw text as one `answer` string.
260    pub fn plain(text: impl Into<String>) -> Self {
261        Self {
262            answer: text.into(),
263            questions: Vec::new(),
264            review_comment_outcomes: Vec::new(),
265            subtasks: Vec::new(),
266            summary: None,
267            verification_verdicts: Vec::new(),
268        }
269    }
270
271    /// Returns display text by joining non-empty answer and question text with
272    /// blank lines.
273    pub fn to_display_text(&self) -> String {
274        let mut display_messages = Vec::new();
275        push_display_message(&mut display_messages, &self.answer);
276        push_question_display_messages(&mut display_messages, &self.questions);
277
278        display_messages.join("\n\n")
279    }
280
281    /// Returns transcript text for session output by joining non-empty
282    /// `answer` content with blank lines.
283    pub fn to_answer_display_text(&self) -> String {
284        let mut display_messages = Vec::new();
285        push_display_message(&mut display_messages, &self.answer);
286
287        display_messages.join("\n\n")
288    }
289
290    /// Returns the answer as one single-item vector when it is non-empty.
291    pub fn answers(&self) -> Vec<String> {
292        let answer = self.to_answer_display_text();
293        if answer.is_empty() {
294            return Vec::new();
295        }
296
297        vec![answer]
298    }
299
300    /// Returns up to [`MAX_QUESTIONS`] clarification questions in response
301    /// order.
302    pub fn question_items(&self) -> Vec<QuestionItem> {
303        self.questions.iter().take(MAX_QUESTIONS).cloned().collect()
304    }
305
306    /// Returns up to [`MAX_SUBTASKS`] proposed subtasks in response order.
307    ///
308    /// Callers must still reject plans whose keys collide; this only bounds how
309    /// much of a runaway plan is considered.
310    pub fn subtask_items(&self) -> Vec<SubtaskItem> {
311        self.subtasks.iter().take(MAX_SUBTASKS).cloned().collect()
312    }
313
314    /// Returns up to [`MAX_SUBTASKS`] verification decisions in response
315    /// order.
316    pub fn verification_verdict_items(&self) -> Vec<VerificationVerdictItem> {
317        self.verification_verdicts
318            .iter()
319            .take(MAX_SUBTASKS)
320            .cloned()
321            .collect()
322    }
323}
324
325/// Structured response parsing failure details.
326#[derive(Debug, Clone, PartialEq, Eq)]
327pub enum AgentResponseParseError {
328    /// Response was empty or whitespace-only.
329    Empty,
330    /// Response was JSON, but it did not satisfy the structured protocol
331    /// contract.
332    InvalidFormat {
333        /// Explanation of the protocol contract violation.
334        reason: String,
335    },
336}
337
338impl fmt::Display for AgentResponseParseError {
339    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
340        match self {
341            Self::Empty => write!(formatter, "response is empty"),
342            Self::InvalidFormat { reason } => {
343                write!(formatter, "response is not valid protocol JSON: {reason}")
344            }
345        }
346    }
347}
348
349impl std::error::Error for AgentResponseParseError {}
350
351/// Appends one non-empty display message.
352fn push_display_message(display_messages: &mut Vec<String>, text: &str) {
353    if text.trim().is_empty() {
354        return;
355    }
356
357    display_messages.push(text.to_string());
358}
359
360/// Appends non-empty clarification question text in order.
361fn push_question_display_messages(display_messages: &mut Vec<String>, questions: &[QuestionItem]) {
362    for question in questions {
363        push_display_message(display_messages, &question.text);
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    /// Ensures the dynamic `questions` field description renders from the
373    /// checked-in prompt-schema template.
374    fn test_questions_field_description_renders_template_limit() {
375        // Arrange
376        let expected_limit = format!("Emit at most {MAX_QUESTIONS} items");
377
378        // Act
379        let description = questions_field_description();
380        let normalized_description = description.split_whitespace().collect::<Vec<_>>().join(" ");
381
382        // Assert
383        assert!(normalized_description.contains(&expected_limit));
384        assert!(normalized_description.contains("Emit an empty array when no input is required"));
385        assert!(normalized_description.contains("field defaults to an empty array when omitted"));
386        assert!(normalized_description.contains("genuinely ambiguous requirement"));
387        assert!(normalized_description.contains("Never request permission for agreed work"));
388        assert!(normalized_description.contains("ask for satisfaction or sign-off"));
389        assert!(normalized_description.contains("Execute agreed work"));
390        assert!(!description.contains("{{ max_questions }}"));
391    }
392
393    #[test]
394    /// Ensures display text includes the answer and clarification questions in
395    /// order.
396    fn test_agent_response_to_display_text_joins_answer_and_questions() {
397        // Arrange
398        let response = AgentResponse {
399            answer: "Primary answer".to_string(),
400            questions: vec![QuestionItem::new("Need one clarification.")],
401            review_comment_outcomes: Vec::new(),
402            subtasks: Vec::new(),
403            summary: None,
404            verification_verdicts: Vec::new(),
405        };
406
407        // Act
408        let display_text = response.to_display_text();
409
410        // Assert
411        assert_eq!(display_text, "Primary answer\n\nNeed one clarification.");
412    }
413
414    #[test]
415    /// Preserves review-comment outcomes through the wire JSON contract.
416    fn test_agent_response_review_comment_outcomes_round_trip() {
417        // Arrange
418        let response = AgentResponse {
419            answer: "Addressed the comment.".to_string(),
420            questions: Vec::new(),
421            review_comment_outcomes: vec![ReviewCommentOutcome {
422                reply: "Added the missing validation.".to_string(),
423                resolution: ReviewCommentResolution::Fixed,
424                thread_id: "thread-42".to_string(),
425            }],
426            subtasks: Vec::new(),
427            verification_verdicts: Vec::new(),
428            summary: None,
429        };
430
431        // Act
432        let serialized = serde_json::to_string(&response).expect("response should serialize");
433        let deserialized = serde_json::from_str::<AgentResponse>(&serialized)
434            .expect("response should deserialize");
435
436        // Assert
437        assert_eq!(deserialized, response);
438        assert!(serialized.contains(r#""resolution":"fixed""#));
439    }
440
441    #[test]
442    /// Ensures question extraction respects the protocol question cap.
443    fn test_agent_response_question_items_applies_question_cap() {
444        // Arrange
445        let response = AgentResponse {
446            answer: String::new(),
447            questions: (0..=MAX_QUESTIONS)
448                .map(|index| QuestionItem::new(format!("Question {index}")))
449                .collect(),
450            review_comment_outcomes: Vec::new(),
451            subtasks: Vec::new(),
452            verification_verdicts: Vec::new(),
453            summary: None,
454        };
455
456        // Act
457        let questions = response.question_items();
458
459        // Assert
460        assert_eq!(questions.len(), MAX_QUESTIONS);
461    }
462
463    #[test]
464    /// Ensures subtask extraction respects the protocol subtask cap so a
465    /// runaway plan cannot fan out past the bounded child-session budget.
466    fn test_agent_response_subtask_items_applies_subtask_cap() {
467        // Arrange
468        let response = AgentResponse {
469            answer: String::new(),
470            questions: Vec::new(),
471            review_comment_outcomes: Vec::new(),
472            subtasks: (0..=MAX_SUBTASKS).map(test_subtask).collect(),
473            verification_verdicts: Vec::new(),
474            summary: None,
475        };
476
477        // Act
478        let subtasks = response.subtask_items();
479
480        // Assert
481        assert_eq!(subtasks.len(), MAX_SUBTASKS);
482        assert_eq!(subtasks[0].task_key, "task-0");
483    }
484
485    #[test]
486    /// Preserves proposed subtasks through the wire JSON contract and keeps
487    /// them absent from ordinary responses.
488    fn test_agent_response_subtasks_round_trip() {
489        // Arrange
490        let response = AgentResponse {
491            answer: "Proposed a plan.".to_string(),
492            questions: Vec::new(),
493            review_comment_outcomes: Vec::new(),
494            subtasks: vec![test_subtask(1)],
495            verification_verdicts: Vec::new(),
496            summary: None,
497        };
498
499        // Act
500        let serialized = serde_json::to_string(&response).expect("response should serialize");
501        let deserialized = serde_json::from_str::<AgentResponse>(&serialized)
502            .expect("response should deserialize");
503
504        // Assert
505        assert_eq!(deserialized, response);
506        assert!(serialized.contains(r#""task_key":"task-1""#));
507        assert_eq!(
508            AgentResponse::plain("no plan").subtask_items(),
509            [] as [crate::subtask::SubtaskItem; 0]
510        );
511    }
512
513    #[test]
514    /// Preserves typed verification decisions through JSON and applies the
515    /// same bounded task count as orchestration plans.
516    fn test_agent_response_verification_verdicts_round_trip_and_cap() {
517        // Arrange
518        let response = AgentResponse {
519            answer: "Verified the settled tasks.".to_string(),
520            questions: Vec::new(),
521            review_comment_outcomes: Vec::new(),
522            subtasks: Vec::new(),
523            summary: None,
524            verification_verdicts: (0..=MAX_SUBTASKS)
525                .map(|index| VerificationVerdictItem {
526                    reason: format!("Evidence {index}"),
527                    task_key: format!("task-{index}"),
528                    verdict: crate::VerificationVerdict::Pass,
529                })
530                .collect(),
531        };
532
533        // Act
534        let serialized = serde_json::to_string(&response).expect("response should serialize");
535        let deserialized = serde_json::from_str::<AgentResponse>(&serialized)
536            .expect("response should deserialize");
537        let verdicts = deserialized.verification_verdict_items();
538
539        // Assert
540        assert_eq!(verdicts.len(), MAX_SUBTASKS);
541        assert_eq!(verdicts[0].task_key, "task-0");
542        assert!(serialized.contains(r#""verdict":"pass""#));
543    }
544
545    #[test]
546    /// Ensures optional `touched_areas` planning guidance defaults to an empty
547    /// list instead of failing the whole turn.
548    fn test_subtask_item_defaults_touched_areas() {
549        // Arrange
550        let raw = r#"{"prompt":"Do the work","task_key":"task-1","title":"Work"}"#;
551
552        // Act
553        let subtask =
554            serde_json::from_str::<SubtaskItem>(raw).expect("subtask should parse without areas");
555
556        // Assert
557        assert_eq!(subtask.kind, crate::SubtaskKind::Implementation);
558        assert_eq!(subtask.touched_areas, [] as [std::string::String; 0]);
559    }
560
561    #[test]
562    /// Keeps the injected `subtasks` schema description in sync with the
563    /// server-side cap the parser enforces.
564    fn test_subtasks_field_description_reports_the_subtask_cap() {
565        // Arrange
566        let expected_limit = format!("at most {MAX_SUBTASKS} items");
567
568        // Act
569        let description = subtasks_field_description();
570        let normalized_description = description.split_whitespace().collect::<Vec<_>>().join(" ");
571
572        // Assert
573        assert!(description.contains(&expected_limit));
574        assert!(
575            normalized_description
576                .contains("Emit an empty array when no decomposition was requested")
577        );
578        assert!(normalized_description.contains("field defaults to an empty array when omitted"));
579        assert!(normalized_description.contains("Ordinary session and utility turns"));
580        assert!(normalized_description.contains("unattended in its own worktree"));
581        assert!(normalized_description.contains("independently completable"));
582        assert!(normalized_description.contains("without wildcards"));
583        assert!(description.contains("Areas may overlap"));
584        assert!(normalized_description.contains("fewer than two independent subtasks"));
585        assert!(!description.contains("{{"));
586    }
587
588    #[test]
589    /// Substitutes a cap placeholder that markdown reflowing wrapped across a
590    /// line break, so reformatting a template cannot leak raw `{{ ... }}` text
591    /// into the schema description models read.
592    fn test_field_description_template_survives_a_wrapped_placeholder() {
593        // Arrange
594        let template = "Emit at most {{\nmax_items }} items, and no more.\n";
595
596        // Act
597        let rendered = render_field_description_template(template, "{{ max_items }}", 4);
598
599        // Assert
600        assert_eq!(rendered, "Emit at most 4 items, and no more.");
601    }
602
603    #[test]
604    /// Leaves an unterminated placeholder untouched instead of truncating the
605    /// remaining guidance text.
606    fn test_field_description_template_keeps_unterminated_placeholder_text() {
607        // Arrange
608        let template = "Emit at most {{ max_items items.";
609
610        // Act
611        let rendered = render_field_description_template(template, "{{ max_items }}", 4);
612
613        // Assert
614        assert_eq!(rendered, "Emit at most {{ max_items items.");
615    }
616
617    /// Builds one deterministic subtask with a touched-area planning hint.
618    fn test_subtask(index: usize) -> SubtaskItem {
619        SubtaskItem {
620            acceptance_criteria: vec![format!("Work item {index} is complete")],
621            kind: crate::SubtaskKind::Implementation,
622            prompt: format!("Complete work item {index}"),
623            task_key: format!("task-{index}"),
624            title: format!("Work item {index}"),
625            touched_areas: vec![format!("crates/area-{index}/")],
626        }
627    }
628}