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 completed the requested action and the thread can be
88    /// resolved after its reply is posted.
89    Fixed,
90    /// The agent determined that no code or documentation change was needed;
91    /// its explanatory reply is posted while the thread remains open.
92    NoChangeNeeded,
93}
94
95/// Structured outcome for one forge review thread targeted by the turn.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
97#[schemars(
98    title = "ReviewCommentOutcome",
99    description = "Structured outcome for one forge review thread explicitly included in the turn \
100                   prompt."
101)]
102pub struct ReviewCommentOutcome {
103    /// Concise forge reply explaining what changed or why no change was needed.
104    #[schemars(
105        title = "reply",
106        description = "Concise reply suitable for posting to the forge review thread."
107    )]
108    pub reply: String,
109    /// Whether the thread was fixed or did not require a change.
110    #[schemars(
111        title = "resolution",
112        description = "Whether the targeted thread was fixed or required no change."
113    )]
114    pub resolution: ReviewCommentResolution,
115    /// Opaque forge thread identifier copied exactly from the turn prompt.
116    #[schemars(
117        title = "thread_id",
118        description = "Opaque forge thread identifier copied exactly from the turn prompt."
119    )]
120    pub thread_id: String,
121}
122
123/// Wire-format protocol payload used for schema-driven provider output.
124///
125/// Providers that support output schemas (for example, Codex app-server) are
126/// asked to emit this object as the entire assistant response payload.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
128#[schemars(
129    title = "AgentResponse",
130    description = "Wire-format protocol payload used for schema-driven provider output. Return \
131                   this object as the entire assistant response payload. Providers that support \
132                   output schemas (for example, Codex app-server) are asked to emit this object \
133                   directly."
134)]
135pub struct AgentResponse {
136    /// Markdown answer text emitted for this turn.
137    #[serde(default)]
138    #[schemars(
139        title = "answer",
140        description = "Markdown answer text for delivered work, status updates, or concise \
141                       completion notes. Keep clarification requests out of this field and emit \
142                       them through `questions` instead."
143    )]
144    pub answer: String,
145    /// Ordered clarification questions emitted for this turn.
146    ///
147    /// The canonical JSON Schema description for this field is produced by
148    /// [`questions_field_description`] and injected at schema generation time
149    /// by `inject_dynamic_schema_guidance`. The static `schemars` metadata
150    /// here only sets the field title; the description is intentionally
151    /// omitted so the helper is the single source of truth.
152    #[serde(default)]
153    #[schemars(title = "questions")]
154    pub questions: Vec<QuestionItem>,
155    /// Per-thread outcomes for an agent-driven forge comment-resolution turn.
156    ///
157    /// Ordinary session and utility turns leave this empty. Resolution
158    /// workflows accept only identifiers explicitly allowlisted in the turn
159    /// prompt before applying any forge-side effect.
160    #[serde(default)]
161    #[schemars(
162        title = "review_comment_outcomes",
163        description = "Per-thread outcomes for an agent-driven forge comment-resolution turn. \
164                       Emit an empty array unless the prompt explicitly supplies forge thread \
165                       IDs. Copy each reported `thread_id` exactly from the prompt."
166    )]
167    pub review_comment_outcomes: Vec<ReviewCommentOutcome>,
168    /// Structured summary for session-discussion turns, or `None` for legacy
169    /// payloads and one-shot prompts.
170    #[serde(default)]
171    #[schemars(
172        title = "summary",
173        description = "Structured summary for session-discussion turns, kept outside `answer` \
174                       markdown. Use `null` for one-shot prompts and legacy payloads."
175    )]
176    pub summary: Option<AgentResponseSummary>,
177}
178
179impl AgentResponse {
180    /// Creates a plain response from raw text as one `answer` string.
181    pub fn plain(text: impl Into<String>) -> Self {
182        Self {
183            answer: text.into(),
184            questions: Vec::new(),
185            review_comment_outcomes: Vec::new(),
186            summary: None,
187        }
188    }
189
190    /// Returns display text by joining non-empty answer and question text with
191    /// blank lines.
192    pub fn to_display_text(&self) -> String {
193        let mut display_messages = Vec::new();
194        push_display_message(&mut display_messages, &self.answer);
195        push_question_display_messages(&mut display_messages, &self.questions);
196
197        display_messages.join("\n\n")
198    }
199
200    /// Returns transcript text for session output by joining non-empty
201    /// `answer` content with blank lines.
202    pub fn to_answer_display_text(&self) -> String {
203        let mut display_messages = Vec::new();
204        push_display_message(&mut display_messages, &self.answer);
205
206        display_messages.join("\n\n")
207    }
208
209    /// Returns the answer as one single-item vector when it is non-empty.
210    pub fn answers(&self) -> Vec<String> {
211        let answer = self.to_answer_display_text();
212        if answer.is_empty() {
213            return Vec::new();
214        }
215
216        vec![answer]
217    }
218
219    /// Returns up to [`MAX_QUESTIONS`] clarification questions in response
220    /// order.
221    pub fn question_items(&self) -> Vec<QuestionItem> {
222        self.questions.iter().take(MAX_QUESTIONS).cloned().collect()
223    }
224}
225
226/// Structured response parsing failure details.
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub enum AgentResponseParseError {
229    /// Response was empty or whitespace-only.
230    Empty,
231    /// Response was JSON, but it did not satisfy the structured protocol
232    /// contract.
233    InvalidFormat {
234        /// Explanation of the protocol contract violation.
235        reason: String,
236    },
237}
238
239impl fmt::Display for AgentResponseParseError {
240    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
241        match self {
242            Self::Empty => write!(formatter, "response is empty"),
243            Self::InvalidFormat { reason } => {
244                write!(formatter, "response is not valid protocol JSON: {reason}")
245            }
246        }
247    }
248}
249
250impl std::error::Error for AgentResponseParseError {}
251
252/// Appends one non-empty display message.
253fn push_display_message(display_messages: &mut Vec<String>, text: &str) {
254    if text.trim().is_empty() {
255        return;
256    }
257
258    display_messages.push(text.to_string());
259}
260
261/// Appends non-empty clarification question text in order.
262fn push_question_display_messages(display_messages: &mut Vec<String>, questions: &[QuestionItem]) {
263    for question in questions {
264        push_display_message(display_messages, &question.text);
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    /// Ensures the dynamic `questions` field description renders from the
274    /// checked-in prompt-schema template.
275    fn test_questions_field_description_renders_template_limit() {
276        // Arrange, Act
277        let description = questions_field_description();
278        let normalized_description = description.split_whitespace().collect::<Vec<_>>().join(" ");
279
280        // Assert
281        assert!(normalized_description.contains("Emit at most 5 items"));
282        assert!(normalized_description.contains("Execute the agreed work"));
283        assert!(!description.contains("{{ max_questions }}"));
284    }
285
286    #[test]
287    /// Ensures display text includes the answer and clarification questions in
288    /// order.
289    fn test_agent_response_to_display_text_joins_answer_and_questions() {
290        // Arrange
291        let response = AgentResponse {
292            answer: "Primary answer".to_string(),
293            questions: vec![QuestionItem::new("Need one clarification.")],
294            review_comment_outcomes: Vec::new(),
295            summary: None,
296        };
297
298        // Act
299        let display_text = response.to_display_text();
300
301        // Assert
302        assert_eq!(display_text, "Primary answer\n\nNeed one clarification.");
303    }
304
305    #[test]
306    /// Preserves review-comment outcomes through the wire JSON contract.
307    fn test_agent_response_review_comment_outcomes_round_trip() {
308        // Arrange
309        let response = AgentResponse {
310            answer: "Addressed the comment.".to_string(),
311            questions: Vec::new(),
312            review_comment_outcomes: vec![ReviewCommentOutcome {
313                reply: "Added the missing validation.".to_string(),
314                resolution: ReviewCommentResolution::Fixed,
315                thread_id: "thread-42".to_string(),
316            }],
317            summary: None,
318        };
319
320        // Act
321        let serialized = serde_json::to_string(&response).expect("response should serialize");
322        let deserialized = serde_json::from_str::<AgentResponse>(&serialized)
323            .expect("response should deserialize");
324
325        // Assert
326        assert_eq!(deserialized, response);
327        assert!(serialized.contains(r#""resolution":"fixed""#));
328    }
329
330    #[test]
331    /// Ensures question extraction respects the protocol question cap.
332    fn test_agent_response_question_items_applies_question_cap() {
333        // Arrange
334        let response = AgentResponse {
335            answer: String::new(),
336            questions: (0..=MAX_QUESTIONS)
337                .map(|index| QuestionItem::new(format!("Question {index}")))
338                .collect(),
339            review_comment_outcomes: Vec::new(),
340            summary: None,
341        };
342
343        // Act
344        let questions = response.question_items();
345
346        // Assert
347        assert_eq!(questions.len(), MAX_QUESTIONS);
348    }
349}