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 crate::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/// Wire-format protocol payload used for schema-driven provider output.
80///
81/// Providers that support output schemas (for example, Codex app-server) are
82/// asked to emit this object as the entire assistant response payload.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
84#[schemars(
85    title = "AgentResponse",
86    description = "Wire-format protocol payload used for schema-driven provider output. Return \
87                   this object as the entire assistant response payload. Providers that support \
88                   output schemas (for example, Codex app-server) are asked to emit this object \
89                   directly."
90)]
91pub struct AgentResponse {
92    /// Markdown answer text emitted for this turn.
93    #[serde(default)]
94    #[schemars(
95        title = "answer",
96        description = "Markdown answer text for delivered work, status updates, or concise \
97                       completion notes. Keep clarification requests out of this field and emit \
98                       them through `questions` instead."
99    )]
100    pub answer: String,
101    /// Ordered clarification questions emitted for this turn.
102    ///
103    /// The canonical JSON Schema description for this field is produced by
104    /// [`questions_field_description`] and injected at schema generation time
105    /// by `inject_dynamic_schema_guidance`. The static `schemars` metadata
106    /// here only sets the field title; the description is intentionally
107    /// omitted so the helper is the single source of truth.
108    #[serde(default)]
109    #[schemars(title = "questions")]
110    pub questions: Vec<QuestionItem>,
111    /// Structured summary for session-discussion turns, or `None` for legacy
112    /// payloads and one-shot prompts.
113    #[serde(default)]
114    #[schemars(
115        title = "summary",
116        description = "Structured summary for session-discussion turns, kept outside `answer` \
117                       markdown. Use `null` for one-shot prompts and legacy payloads."
118    )]
119    pub summary: Option<AgentResponseSummary>,
120}
121
122/// Structured response parsing failure details.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum AgentResponseParseError {
125    /// Response was empty or whitespace-only.
126    Empty,
127    /// Response was JSON, but it did not satisfy the structured protocol
128    /// contract.
129    InvalidFormat { reason: String },
130}
131
132impl fmt::Display for AgentResponseParseError {
133    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
134        match self {
135            Self::Empty => write!(formatter, "response is empty"),
136            Self::InvalidFormat { reason } => {
137                write!(formatter, "response is not valid protocol JSON: {reason}")
138            }
139        }
140    }
141}
142
143impl std::error::Error for AgentResponseParseError {}
144
145impl AgentResponse {
146    /// Creates a plain response from raw text as one `answer` string.
147    pub fn plain(text: impl Into<String>) -> Self {
148        Self {
149            answer: text.into(),
150            questions: Vec::new(),
151            summary: None,
152        }
153    }
154
155    /// Returns display text by joining non-empty answer and question text with
156    /// blank lines.
157    pub fn to_display_text(&self) -> String {
158        let mut display_messages = Vec::new();
159        push_display_message(&mut display_messages, &self.answer);
160        push_question_display_messages(&mut display_messages, &self.questions);
161
162        display_messages.join("\n\n")
163    }
164
165    /// Returns transcript text for session output by joining non-empty
166    /// `answer` content with blank lines.
167    pub fn to_answer_display_text(&self) -> String {
168        let mut display_messages = Vec::new();
169        push_display_message(&mut display_messages, &self.answer);
170
171        display_messages.join("\n\n")
172    }
173
174    /// Returns the answer as one single-item vector when it is non-empty.
175    pub fn answers(&self) -> Vec<String> {
176        let answer = self.to_answer_display_text();
177        if answer.is_empty() {
178            return Vec::new();
179        }
180
181        vec![answer]
182    }
183
184    /// Returns up to [`MAX_QUESTIONS`] clarification questions in response
185    /// order.
186    pub fn question_items(&self) -> Vec<QuestionItem> {
187        self.questions.iter().take(MAX_QUESTIONS).cloned().collect()
188    }
189}
190
191/// Appends non-empty clarification question text in order.
192fn push_question_display_messages(display_messages: &mut Vec<String>, questions: &[QuestionItem]) {
193    for question in questions {
194        push_display_message(display_messages, &question.text);
195    }
196}
197
198/// Appends one non-empty display message.
199fn push_display_message(display_messages: &mut Vec<String>, text: &str) {
200    if text.trim().is_empty() {
201        return;
202    }
203
204    display_messages.push(text.to_string());
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    /// Ensures display text includes the answer and clarification questions in
213    /// order.
214    fn test_agent_response_to_display_text_joins_answer_and_questions() {
215        // Arrange
216        let response = AgentResponse {
217            answer: "Primary answer".to_string(),
218            questions: vec![QuestionItem::new("Need one clarification.")],
219            summary: None,
220        };
221
222        // Act
223        let display_text = response.to_display_text();
224
225        // Assert
226        assert_eq!(display_text, "Primary answer\n\nNeed one clarification.");
227    }
228
229    #[test]
230    /// Ensures question extraction respects the protocol question cap.
231    fn test_agent_response_question_items_applies_question_cap() {
232        // Arrange
233        let response = AgentResponse {
234            answer: String::new(),
235            questions: (0..=MAX_QUESTIONS)
236                .map(|index| QuestionItem::new(format!("Question {index}")))
237                .collect(),
238            summary: None,
239        };
240
241        // Act
242        let questions = response.question_items();
243
244        // Assert
245        assert_eq!(questions.len(), MAX_QUESTIONS);
246    }
247
248    #[test]
249    /// Ensures the dynamic `questions` field description renders from the
250    /// checked-in prompt-schema template.
251    fn test_questions_field_description_renders_template_limit() {
252        // Arrange, Act
253        let description = questions_field_description();
254        let normalized_description = description.split_whitespace().collect::<Vec<_>>().join(" ");
255
256        // Assert
257        assert!(normalized_description.contains("Emit at most 5 items"));
258        assert!(normalized_description.contains("Execute the agreed work"));
259        assert!(!description.contains("{{ max_questions }}"));
260    }
261}