agcodex_core/
models.rs

1use std::collections::HashMap;
2
3use agcodex_mcp_types::CallToolResult;
4use base64::Engine;
5use serde::Deserialize;
6use serde::Deserializer;
7use serde::Serialize;
8use serde::ser::Serializer;
9
10use crate::protocol::InputItem;
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum ResponseInputItem {
15    Message {
16        role: String,
17        content: Vec<ContentItem>,
18    },
19    FunctionCallOutput {
20        call_id: String,
21        output: FunctionCallOutputPayload,
22    },
23    McpToolCallOutput {
24        call_id: String,
25        result: Result<CallToolResult, String>,
26    },
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
30#[serde(tag = "type", rename_all = "snake_case")]
31pub enum ContentItem {
32    InputText { text: String },
33    InputImage { image_url: String },
34    OutputText { text: String },
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38#[serde(tag = "type", rename_all = "snake_case")]
39pub enum ResponseItem {
40    Message {
41        id: Option<String>,
42        role: String,
43        content: Vec<ContentItem>,
44    },
45    Reasoning {
46        id: String,
47        summary: Vec<ReasoningItemReasoningSummary>,
48        #[serde(default, skip_serializing_if = "should_serialize_reasoning_content")]
49        content: Option<Vec<ReasoningItemContent>>,
50        encrypted_content: Option<String>,
51    },
52    LocalShellCall {
53        /// Set when using the chat completions API.
54        id: Option<String>,
55        /// Set when using the Responses API.
56        call_id: Option<String>,
57        status: LocalShellStatus,
58        action: LocalShellAction,
59    },
60    FunctionCall {
61        id: Option<String>,
62        name: String,
63        // The Responses API returns the function call arguments as a *string* that contains
64        // JSON, not as an already‑parsed object. We keep it as a raw string here and let
65        // Session::handle_function_call parse it into a Value. This exactly matches the
66        // Chat Completions + Responses API behavior.
67        arguments: String,
68        call_id: String,
69    },
70    // NOTE: The input schema for `function_call_output` objects that clients send to the
71    // OpenAI /v1/responses endpoint is NOT the same shape as the objects the server returns on the
72    // SSE stream. When *sending* we must wrap the string output inside an object that includes a
73    // required `success` boolean. The upstream TypeScript CLI does this implicitly. To ensure we
74    // serialize exactly the expected shape we introduce a dedicated payload struct and flatten it
75    // here.
76    FunctionCallOutput {
77        call_id: String,
78        output: FunctionCallOutputPayload,
79    },
80    #[serde(other)]
81    Other,
82}
83
84fn should_serialize_reasoning_content(content: &Option<Vec<ReasoningItemContent>>) -> bool {
85    match content {
86        Some(content) => !content
87            .iter()
88            .any(|c| matches!(c, ReasoningItemContent::ReasoningText { .. })),
89        None => false,
90    }
91}
92
93impl From<ResponseInputItem> for ResponseItem {
94    fn from(item: ResponseInputItem) -> Self {
95        match item {
96            ResponseInputItem::Message { role, content } => Self::Message {
97                role,
98                content,
99                id: None,
100            },
101            ResponseInputItem::FunctionCallOutput { call_id, output } => {
102                Self::FunctionCallOutput { call_id, output }
103            }
104            ResponseInputItem::McpToolCallOutput { call_id, result } => Self::FunctionCallOutput {
105                call_id,
106                output: FunctionCallOutputPayload {
107                    success: Some(result.is_ok()),
108                    content: result.map_or_else(
109                        |tool_call_err| format!("err: {tool_call_err:?}"),
110                        |result| {
111                            serde_json::to_string(&result)
112                                .unwrap_or_else(|e| format!("JSON serialization error: {e}"))
113                        },
114                    ),
115                },
116            },
117        }
118    }
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
122#[serde(rename_all = "snake_case")]
123pub enum LocalShellStatus {
124    Completed,
125    InProgress,
126    Incomplete,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
130#[serde(tag = "type", rename_all = "snake_case")]
131pub enum LocalShellAction {
132    Exec(LocalShellExecAction),
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136pub struct LocalShellExecAction {
137    pub command: Vec<String>,
138    pub timeout_ms: Option<u64>,
139    pub working_directory: Option<String>,
140    pub env: Option<HashMap<String, String>>,
141    pub user: Option<String>,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
145#[serde(tag = "type", rename_all = "snake_case")]
146pub enum ReasoningItemReasoningSummary {
147    SummaryText { text: String },
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
151#[serde(tag = "type", rename_all = "snake_case")]
152pub enum ReasoningItemContent {
153    ReasoningText { text: String },
154    Text { text: String },
155}
156
157impl From<Vec<InputItem>> for ResponseInputItem {
158    fn from(items: Vec<InputItem>) -> Self {
159        Self::Message {
160            role: "user".to_string(),
161            content: items
162                .into_iter()
163                .filter_map(|c| match c {
164                    InputItem::Text { text } => Some(ContentItem::InputText { text }),
165                    InputItem::Image { image_url } => Some(ContentItem::InputImage { image_url }),
166                    InputItem::LocalImage { path } => match std::fs::read(&path) {
167                        Ok(bytes) => {
168                            let mime = mime_guess::from_path(&path)
169                                .first()
170                                .map(|m| m.essence_str().to_owned())
171                                .unwrap_or_else(|| "application/octet-stream".to_string());
172                            let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
173                            Some(ContentItem::InputImage {
174                                image_url: format!("data:{mime};base64,{encoded}"),
175                            })
176                        }
177                        Err(err) => {
178                            tracing::warn!(
179                                "Skipping image {} – could not read file: {}",
180                                path.display(),
181                                err
182                            );
183                            None
184                        }
185                    },
186                    _ => None,
187                })
188                .collect::<Vec<ContentItem>>(),
189        }
190    }
191}
192
193/// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec`
194/// or shell`, the `arguments` field should deserialize to this struct.
195#[derive(Deserialize, Debug, Clone, PartialEq)]
196pub struct ShellToolCallParams {
197    pub command: Vec<String>,
198    pub workdir: Option<String>,
199
200    /// This is the maximum time in seconds that the command is allowed to run.
201    #[serde(rename = "timeout")]
202    // The wire format uses `timeout`, which has ambiguous units, so we use
203    // `timeout_ms` as the field name so it is clear in code.
204    pub timeout_ms: Option<u64>,
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub with_escalated_permissions: Option<bool>,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub justification: Option<String>,
209}
210
211#[derive(Debug, Clone, PartialEq)]
212pub struct FunctionCallOutputPayload {
213    pub content: String,
214    pub success: Option<bool>,
215}
216
217// The Responses API expects two *different* shapes depending on success vs failure:
218//   • success → output is a plain string (no nested object)
219//   • failure → output is an object { content, success:false }
220// The upstream TypeScript CLI implements this by special‑casing the serialize path.
221// We replicate that behavior with a manual Serialize impl.
222
223impl Serialize for FunctionCallOutputPayload {
224    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
225    where
226        S: Serializer,
227    {
228        // The upstream TypeScript CLI always serializes `output` as a *plain string* regardless
229        // of whether the function call succeeded or failed. The boolean is purely informational
230        // for local bookkeeping and is NOT sent to the OpenAI endpoint. Sending the nested object
231        // form `{ content, success:false }` triggers the 400 we are still seeing. Mirror the JS CLI
232        // exactly: always emit a bare string.
233
234        serializer.serialize_str(&self.content)
235    }
236}
237
238impl<'de> Deserialize<'de> for FunctionCallOutputPayload {
239    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
240    where
241        D: Deserializer<'de>,
242    {
243        let s = String::deserialize(deserializer)?;
244        Ok(FunctionCallOutputPayload {
245            content: s,
246            success: None,
247        })
248    }
249}
250
251// Implement Display so callers can treat the payload like a plain string when logging or doing
252// trivial substring checks in tests (existing tests call `.contains()` on the output). Display
253// returns the raw `content` field.
254
255impl std::fmt::Display for FunctionCallOutputPayload {
256    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257        f.write_str(&self.content)
258    }
259}
260
261impl std::ops::Deref for FunctionCallOutputPayload {
262    type Target = str;
263    fn deref(&self) -> &Self::Target {
264        &self.content
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn serializes_success_as_plain_string() {
274        let item = ResponseInputItem::FunctionCallOutput {
275            call_id: "call1".into(),
276            output: FunctionCallOutputPayload {
277                content: "ok".into(),
278                success: None,
279            },
280        };
281
282        let json = serde_json::to_string(&item).unwrap();
283        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
284
285        // Success case -> output should be a plain string
286        assert_eq!(v.get("output").unwrap().as_str().unwrap(), "ok");
287    }
288
289    #[test]
290    fn serializes_failure_as_string() {
291        let item = ResponseInputItem::FunctionCallOutput {
292            call_id: "call1".into(),
293            output: FunctionCallOutputPayload {
294                content: "bad".into(),
295                success: Some(false),
296            },
297        };
298
299        let json = serde_json::to_string(&item).unwrap();
300        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
301
302        assert_eq!(v.get("output").unwrap().as_str().unwrap(), "bad");
303    }
304
305    #[test]
306    fn deserialize_shell_tool_call_params() {
307        let json = r#"{
308            "command": ["ls", "-l"],
309            "workdir": "/tmp",
310            "timeout": 1000
311        }"#;
312
313        let params: ShellToolCallParams = serde_json::from_str(json).unwrap();
314        assert_eq!(
315            ShellToolCallParams {
316                command: vec!["ls".to_string(), "-l".to_string()],
317                workdir: Some("/tmp".to_string()),
318                timeout_ms: Some(1000),
319                with_escalated_permissions: None,
320                justification: None,
321            },
322            params
323        );
324    }
325}