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, Act
376        let description = questions_field_description();
377        let normalized_description = description.split_whitespace().collect::<Vec<_>>().join(" ");
378
379        // Assert
380        assert!(normalized_description.contains("Emit at most 5 items"));
381        assert!(normalized_description.contains("Execute the agreed work"));
382        assert!(!description.contains("{{ max_questions }}"));
383    }
384
385    #[test]
386    /// Ensures display text includes the answer and clarification questions in
387    /// order.
388    fn test_agent_response_to_display_text_joins_answer_and_questions() {
389        // Arrange
390        let response = AgentResponse {
391            answer: "Primary answer".to_string(),
392            questions: vec![QuestionItem::new("Need one clarification.")],
393            review_comment_outcomes: Vec::new(),
394            subtasks: Vec::new(),
395            summary: None,
396            verification_verdicts: Vec::new(),
397        };
398
399        // Act
400        let display_text = response.to_display_text();
401
402        // Assert
403        assert_eq!(display_text, "Primary answer\n\nNeed one clarification.");
404    }
405
406    #[test]
407    /// Preserves review-comment outcomes through the wire JSON contract.
408    fn test_agent_response_review_comment_outcomes_round_trip() {
409        // Arrange
410        let response = AgentResponse {
411            answer: "Addressed the comment.".to_string(),
412            questions: Vec::new(),
413            review_comment_outcomes: vec![ReviewCommentOutcome {
414                reply: "Added the missing validation.".to_string(),
415                resolution: ReviewCommentResolution::Fixed,
416                thread_id: "thread-42".to_string(),
417            }],
418            subtasks: Vec::new(),
419            verification_verdicts: Vec::new(),
420            summary: None,
421        };
422
423        // Act
424        let serialized = serde_json::to_string(&response).expect("response should serialize");
425        let deserialized = serde_json::from_str::<AgentResponse>(&serialized)
426            .expect("response should deserialize");
427
428        // Assert
429        assert_eq!(deserialized, response);
430        assert!(serialized.contains(r#""resolution":"fixed""#));
431    }
432
433    #[test]
434    /// Ensures question extraction respects the protocol question cap.
435    fn test_agent_response_question_items_applies_question_cap() {
436        // Arrange
437        let response = AgentResponse {
438            answer: String::new(),
439            questions: (0..=MAX_QUESTIONS)
440                .map(|index| QuestionItem::new(format!("Question {index}")))
441                .collect(),
442            review_comment_outcomes: Vec::new(),
443            subtasks: Vec::new(),
444            verification_verdicts: Vec::new(),
445            summary: None,
446        };
447
448        // Act
449        let questions = response.question_items();
450
451        // Assert
452        assert_eq!(questions.len(), MAX_QUESTIONS);
453    }
454
455    #[test]
456    /// Ensures subtask extraction respects the protocol subtask cap so a
457    /// runaway plan cannot fan out past the bounded child-session budget.
458    fn test_agent_response_subtask_items_applies_subtask_cap() {
459        // Arrange
460        let response = AgentResponse {
461            answer: String::new(),
462            questions: Vec::new(),
463            review_comment_outcomes: Vec::new(),
464            subtasks: (0..=MAX_SUBTASKS).map(test_subtask).collect(),
465            verification_verdicts: Vec::new(),
466            summary: None,
467        };
468
469        // Act
470        let subtasks = response.subtask_items();
471
472        // Assert
473        assert_eq!(subtasks.len(), MAX_SUBTASKS);
474        assert_eq!(subtasks[0].task_key, "task-0");
475    }
476
477    #[test]
478    /// Preserves proposed subtasks through the wire JSON contract and keeps
479    /// them absent from ordinary responses.
480    fn test_agent_response_subtasks_round_trip() {
481        // Arrange
482        let response = AgentResponse {
483            answer: "Proposed a plan.".to_string(),
484            questions: Vec::new(),
485            review_comment_outcomes: Vec::new(),
486            subtasks: vec![test_subtask(1)],
487            verification_verdicts: Vec::new(),
488            summary: None,
489        };
490
491        // Act
492        let serialized = serde_json::to_string(&response).expect("response should serialize");
493        let deserialized = serde_json::from_str::<AgentResponse>(&serialized)
494            .expect("response should deserialize");
495
496        // Assert
497        assert_eq!(deserialized, response);
498        assert!(serialized.contains(r#""task_key":"task-1""#));
499        assert!(AgentResponse::plain("no plan").subtask_items().is_empty());
500    }
501
502    #[test]
503    /// Preserves typed verification decisions through JSON and applies the
504    /// same bounded task count as orchestration plans.
505    fn test_agent_response_verification_verdicts_round_trip_and_cap() {
506        // Arrange
507        let response = AgentResponse {
508            answer: "Verified the settled tasks.".to_string(),
509            questions: Vec::new(),
510            review_comment_outcomes: Vec::new(),
511            subtasks: Vec::new(),
512            summary: None,
513            verification_verdicts: (0..=MAX_SUBTASKS)
514                .map(|index| VerificationVerdictItem {
515                    reason: format!("Evidence {index}"),
516                    task_key: format!("task-{index}"),
517                    verdict: crate::VerificationVerdict::Pass,
518                })
519                .collect(),
520        };
521
522        // Act
523        let serialized = serde_json::to_string(&response).expect("response should serialize");
524        let deserialized = serde_json::from_str::<AgentResponse>(&serialized)
525            .expect("response should deserialize");
526        let verdicts = deserialized.verification_verdict_items();
527
528        // Assert
529        assert_eq!(verdicts.len(), MAX_SUBTASKS);
530        assert_eq!(verdicts[0].task_key, "task-0");
531        assert!(serialized.contains(r#""verdict":"pass""#));
532    }
533
534    #[test]
535    /// Ensures optional `touched_areas` planning guidance defaults to an empty
536    /// list instead of failing the whole turn.
537    fn test_subtask_item_defaults_touched_areas() {
538        // Arrange
539        let raw = r#"{"prompt":"Do the work","task_key":"task-1","title":"Work"}"#;
540
541        // Act
542        let subtask =
543            serde_json::from_str::<SubtaskItem>(raw).expect("subtask should parse without areas");
544
545        // Assert
546        assert!(subtask.touched_areas.is_empty());
547    }
548
549    #[test]
550    /// Keeps the injected `subtasks` schema description in sync with the
551    /// server-side cap the parser enforces.
552    fn test_subtasks_field_description_reports_the_subtask_cap() {
553        // Arrange, Act
554        let description = subtasks_field_description();
555        let normalized_description = description.split_whitespace().collect::<Vec<_>>().join(" ");
556
557        // Assert
558        assert!(description.contains(&format!("at most {MAX_SUBTASKS} items")));
559        assert!(normalized_description.contains("without wildcard patterns"));
560        assert!(description.contains("Areas may overlap"));
561        assert!(!description.contains("{{"));
562    }
563
564    #[test]
565    /// Substitutes a cap placeholder that markdown reflowing wrapped across a
566    /// line break, so reformatting a template cannot leak raw `{{ ... }}` text
567    /// into the schema description models read.
568    fn test_field_description_template_survives_a_wrapped_placeholder() {
569        // Arrange
570        let template = "Emit at most {{\nmax_items }} items, and no more.\n";
571
572        // Act
573        let rendered = render_field_description_template(template, "{{ max_items }}", 4);
574
575        // Assert
576        assert_eq!(rendered, "Emit at most 4 items, and no more.");
577    }
578
579    #[test]
580    /// Leaves an unterminated placeholder untouched instead of truncating the
581    /// remaining guidance text.
582    fn test_field_description_template_keeps_unterminated_placeholder_text() {
583        // Arrange
584        let template = "Emit at most {{ max_items items.";
585
586        // Act
587        let rendered = render_field_description_template(template, "{{ max_items }}", 4);
588
589        // Assert
590        assert_eq!(rendered, "Emit at most {{ max_items items.");
591    }
592
593    /// Builds one deterministic subtask with a touched-area planning hint.
594    fn test_subtask(index: usize) -> SubtaskItem {
595        SubtaskItem {
596            acceptance_criteria: vec![format!("Work item {index} is complete")],
597            prompt: format!("Complete work item {index}"),
598            task_key: format!("task-{index}"),
599            title: format!("Work item {index}"),
600            touched_areas: vec![format!("crates/area-{index}/")],
601        }
602    }
603}