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;
9
10/// Hard cap on the number of clarification questions extracted from one agent
11/// response. Prevents runaway output from flooding the question UI even when
12/// the agent ignores the prompt-level limit.
13///
14/// This constant is also injected into the protocol instruction prompt
15/// templates so the prompt-level guidance and the server-side cap stay in
16/// sync automatically.
17pub(crate) const MAX_QUESTIONS: usize = 5;
18const QUESTIONS_FIELD_DESCRIPTION_TEMPLATE: &str =
19    include_str!("template/questions_field_description.md");
20
21/// Returns the canonical JSON Schema description for the `questions` field.
22///
23/// This is the single source of truth for the runtime-injected schema
24/// description and the matching test expectation. The static `schemars`
25/// metadata on `AgentResponse::questions` is overwritten by
26/// `inject_dynamic_schema_guidance` before any consumer observes the schema,
27/// so all schema-facing call sites must route through this helper to stay in
28/// sync.
29pub(crate) fn questions_field_description() -> String {
30    QUESTIONS_FIELD_DESCRIPTION_TEMPLATE
31        .trim_end()
32        .replace("{{ max_questions }}", &MAX_QUESTIONS.to_string())
33}
34
35/// Protocol-owned request family preserved across prompt submission and repair
36/// retries.
37///
38/// Session discussion turns and isolated utility prompts share the same
39/// top-level [`AgentResponse`] schema. Agentty still carries the request
40/// family through transport boundaries so call sites can keep one consistent
41/// protocol contract even when some callers ignore parts of the response, such
42/// as the optional top-level `summary`.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44#[non_exhaustive]
45pub enum ProtocolRequestProfile {
46    /// Interactive session turn.
47    SessionTurn,
48    /// Isolated utility prompt.
49    UtilityPrompt,
50}
51
52/// Structured session summary block emitted alongside protocol messages.
53///
54/// Session-discussion turns use this object instead of embedding the change
55/// summary inside `answer` message text. One-shot prompts set the top-level
56/// `summary` field to `null`.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
58#[schemars(
59    title = "AgentResponseSummary",
60    description = "Structured session summary block emitted alongside protocol messages instead \
61                   of embedding the change summary inside `answer` markdown on session-discussion \
62                   turns."
63)]
64pub struct AgentResponseSummary {
65    /// Cumulative summary of active changes on the current session branch.
66    #[schemars(
67        title = "session",
68        description = "Cumulative summary of active changes on the current session branch."
69    )]
70    pub session: String,
71    /// Concise summary of only the work completed in the current turn.
72    #[schemars(
73        title = "turn",
74        description = "Concise summary of only the work completed in the current turn."
75    )]
76    pub turn: String,
77}
78
79/// Agent-reported disposition for one forge review thread.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
81#[serde(rename_all = "snake_case")]
82#[schemars(
83    title = "ReviewCommentResolution",
84    description = "Disposition reported for one targeted forge review thread."
85)]
86pub enum ReviewCommentResolution {
87    /// The agent addressed the review thread in the worktree.
88    Fixed,
89    /// The agent determined that no code or documentation change was needed.
90    NoChangeNeeded,
91}
92
93/// Structured outcome for one forge review thread targeted by the turn.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
95#[schemars(
96    title = "ReviewCommentOutcome",
97    description = "Structured outcome for one forge review thread explicitly included in the turn \
98                   prompt."
99)]
100pub struct ReviewCommentOutcome {
101    /// Concise forge reply explaining what changed or why no change was needed.
102    #[schemars(
103        title = "reply",
104        description = "Concise reply suitable for posting to the forge review thread."
105    )]
106    pub reply: String,
107    /// Whether the thread was fixed or did not require a change.
108    #[schemars(
109        title = "resolution",
110        description = "Whether the targeted thread was fixed or required no change."
111    )]
112    pub resolution: ReviewCommentResolution,
113    /// Opaque forge thread identifier copied exactly from the turn prompt.
114    #[schemars(
115        title = "thread_id",
116        description = "Opaque forge thread identifier copied exactly from the turn prompt."
117    )]
118    pub thread_id: String,
119}
120
121/// Wire-format protocol payload used for schema-driven provider output.
122///
123/// Providers that support output schemas (for example, Codex app-server) are
124/// asked to emit this object as the entire assistant response payload.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
126#[schemars(
127    title = "AgentResponse",
128    description = "Wire-format protocol payload used for schema-driven provider output. Return \
129                   this object as the entire assistant response payload. Providers that support \
130                   output schemas (for example, Codex app-server) are asked to emit this object \
131                   directly."
132)]
133pub struct AgentResponse {
134    /// Markdown answer text emitted for this turn.
135    #[serde(default)]
136    #[schemars(
137        title = "answer",
138        description = "Markdown answer text for delivered work, status updates, or concise \
139                       completion notes. Keep clarification requests out of this field and emit \
140                       them through `questions` instead."
141    )]
142    pub answer: String,
143    /// Ordered clarification questions emitted for this turn.
144    ///
145    /// The canonical JSON Schema description for this field is produced by
146    /// [`questions_field_description`] and injected at schema generation time
147    /// by `inject_dynamic_schema_guidance`. The static `schemars` metadata
148    /// here only sets the field title; the description is intentionally
149    /// omitted so the helper is the single source of truth.
150    #[serde(default)]
151    #[schemars(title = "questions")]
152    pub questions: Vec<QuestionItem>,
153    /// Per-thread outcomes for an agent-driven forge comment-resolution turn.
154    ///
155    /// Ordinary session and utility turns leave this empty. Resolution
156    /// workflows accept only identifiers explicitly allowlisted in the turn
157    /// prompt before applying any forge-side effect.
158    #[serde(default)]
159    #[schemars(
160        title = "review_comment_outcomes",
161        description = "Per-thread outcomes for an agent-driven forge comment-resolution turn. \
162                       Emit an empty array unless the prompt explicitly supplies forge thread \
163                       IDs. Copy each reported `thread_id` exactly from the prompt."
164    )]
165    pub review_comment_outcomes: Vec<ReviewCommentOutcome>,
166    /// Structured summary for session-discussion turns, or `None` for legacy
167    /// payloads and one-shot prompts.
168    #[serde(default)]
169    #[schemars(
170        title = "summary",
171        description = "Structured summary for session-discussion turns, kept outside `answer` \
172                       markdown. Use `null` for one-shot prompts and legacy payloads."
173    )]
174    pub summary: Option<AgentResponseSummary>,
175}
176
177impl AgentResponse {
178    /// Creates a plain response from raw text as one `answer` string.
179    pub fn plain(text: impl Into<String>) -> Self {
180        Self {
181            answer: text.into(),
182            questions: Vec::new(),
183            review_comment_outcomes: Vec::new(),
184            summary: None,
185        }
186    }
187
188    /// Returns display text by joining non-empty answer and question text with
189    /// blank lines.
190    pub fn to_display_text(&self) -> String {
191        let mut display_messages = Vec::new();
192        push_display_message(&mut display_messages, &self.answer);
193        push_question_display_messages(&mut display_messages, &self.questions);
194
195        display_messages.join("\n\n")
196    }
197
198    /// Returns transcript text for session output by joining non-empty
199    /// `answer` content with blank lines.
200    pub fn to_answer_display_text(&self) -> String {
201        let mut display_messages = Vec::new();
202        push_display_message(&mut display_messages, &self.answer);
203
204        display_messages.join("\n\n")
205    }
206
207    /// Returns the answer as one single-item vector when it is non-empty.
208    pub fn answers(&self) -> Vec<String> {
209        let answer = self.to_answer_display_text();
210        if answer.is_empty() {
211            return Vec::new();
212        }
213
214        vec![answer]
215    }
216
217    /// Returns up to [`MAX_QUESTIONS`] clarification questions in response
218    /// order.
219    pub fn question_items(&self) -> Vec<QuestionItem> {
220        self.questions.iter().take(MAX_QUESTIONS).cloned().collect()
221    }
222}
223
224/// Structured response parsing failure details.
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub enum AgentResponseParseError {
227    /// Response was empty or whitespace-only.
228    Empty,
229    /// Response was JSON, but it did not satisfy the structured protocol
230    /// contract.
231    InvalidFormat {
232        /// Explanation of the protocol contract violation.
233        reason: String,
234    },
235}
236
237impl fmt::Display for AgentResponseParseError {
238    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
239        match self {
240            Self::Empty => write!(formatter, "response is empty"),
241            Self::InvalidFormat { reason } => {
242                write!(formatter, "response is not valid protocol JSON: {reason}")
243            }
244        }
245    }
246}
247
248impl std::error::Error for AgentResponseParseError {}
249
250/// Appends one non-empty display message.
251fn push_display_message(display_messages: &mut Vec<String>, text: &str) {
252    if text.trim().is_empty() {
253        return;
254    }
255
256    display_messages.push(text.to_string());
257}
258
259/// Appends non-empty clarification question text in order.
260fn push_question_display_messages(display_messages: &mut Vec<String>, questions: &[QuestionItem]) {
261    for question in questions {
262        push_display_message(display_messages, &question.text);
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    /// Ensures the dynamic `questions` field description renders from the
272    /// checked-in prompt-schema template.
273    fn test_questions_field_description_renders_template_limit() {
274        // Arrange, Act
275        let description = questions_field_description();
276        let normalized_description = description.split_whitespace().collect::<Vec<_>>().join(" ");
277
278        // Assert
279        assert!(normalized_description.contains("Emit at most 5 items"));
280        assert!(normalized_description.contains("Execute the agreed work"));
281        assert!(!description.contains("{{ max_questions }}"));
282    }
283
284    #[test]
285    /// Ensures display text includes the answer and clarification questions in
286    /// order.
287    fn test_agent_response_to_display_text_joins_answer_and_questions() {
288        // Arrange
289        let response = AgentResponse {
290            answer: "Primary answer".to_string(),
291            questions: vec![QuestionItem::new("Need one clarification.")],
292            review_comment_outcomes: Vec::new(),
293            summary: None,
294        };
295
296        // Act
297        let display_text = response.to_display_text();
298
299        // Assert
300        assert_eq!(display_text, "Primary answer\n\nNeed one clarification.");
301    }
302
303    #[test]
304    /// Preserves review-comment outcomes through the wire JSON contract.
305    fn test_agent_response_review_comment_outcomes_round_trip() {
306        // Arrange
307        let response = AgentResponse {
308            answer: "Addressed the comment.".to_string(),
309            questions: Vec::new(),
310            review_comment_outcomes: vec![ReviewCommentOutcome {
311                reply: "Added the missing validation.".to_string(),
312                resolution: ReviewCommentResolution::Fixed,
313                thread_id: "thread-42".to_string(),
314            }],
315            summary: None,
316        };
317
318        // Act
319        let serialized = serde_json::to_string(&response).expect("response should serialize");
320        let deserialized = serde_json::from_str::<AgentResponse>(&serialized)
321            .expect("response should deserialize");
322
323        // Assert
324        assert_eq!(deserialized, response);
325        assert!(serialized.contains(r#""resolution":"fixed""#));
326    }
327
328    #[test]
329    /// Ensures question extraction respects the protocol question cap.
330    fn test_agent_response_question_items_applies_question_cap() {
331        // Arrange
332        let response = AgentResponse {
333            answer: String::new(),
334            questions: (0..=MAX_QUESTIONS)
335                .map(|index| QuestionItem::new(format!("Question {index}")))
336                .collect(),
337            review_comment_outcomes: Vec::new(),
338            summary: None,
339        };
340
341        // Act
342        let questions = response.question_items();
343
344        // Assert
345        assert_eq!(questions.len(), MAX_QUESTIONS);
346    }
347}