codex_tools/
tool_output.rs1use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
2use codex_protocol::models::FunctionCallOutputBody;
3use codex_protocol::models::FunctionCallOutputContentItem;
4use codex_protocol::models::FunctionCallOutputPayload;
5use codex_protocol::models::ResponseInputItem;
6use codex_utils_string::take_bytes_at_char_boundary;
7use serde_json::Value as JsonValue;
8
9use crate::ToolPayload;
10
11const TELEMETRY_PREVIEW_MAX_BYTES: usize = 2 * 1024;
12const TELEMETRY_PREVIEW_MAX_LINES: usize = 64;
13const TELEMETRY_PREVIEW_TRUNCATION_NOTICE: &str = "[... telemetry preview truncated ...]";
14
15pub trait ToolOutput: Send {
17 fn log_preview(&self) -> String;
18
19 fn success_for_logging(&self) -> bool;
20
21 fn contains_external_context(&self) -> bool {
24 false
25 }
26
27 fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem;
28
29 fn post_tool_use_id(&self, call_id: &str) -> String {
31 call_id.to_string()
32 }
33
34 fn post_tool_use_input(&self, _payload: &ToolPayload) -> Option<JsonValue> {
36 None
37 }
38
39 fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
47 None
48 }
49
50 fn code_mode_result(&self, payload: &ToolPayload) -> JsonValue {
51 response_input_to_code_mode_result(self.to_response_item("", payload))
52 }
53}
54
55impl<T> ToolOutput for Box<T>
56where
57 T: ToolOutput + ?Sized,
58{
59 fn log_preview(&self) -> String {
60 (**self).log_preview()
61 }
62
63 fn success_for_logging(&self) -> bool {
64 (**self).success_for_logging()
65 }
66
67 fn contains_external_context(&self) -> bool {
68 (**self).contains_external_context()
69 }
70
71 fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
72 (**self).to_response_item(call_id, payload)
73 }
74
75 fn post_tool_use_id(&self, call_id: &str) -> String {
76 (**self).post_tool_use_id(call_id)
77 }
78
79 fn post_tool_use_input(&self, payload: &ToolPayload) -> Option<JsonValue> {
80 (**self).post_tool_use_input(payload)
81 }
82
83 fn post_tool_use_response(&self, call_id: &str, payload: &ToolPayload) -> Option<JsonValue> {
84 (**self).post_tool_use_response(call_id, payload)
85 }
86
87 fn code_mode_result(&self, payload: &ToolPayload) -> JsonValue {
88 (**self).code_mode_result(payload)
89 }
90}
91
92#[derive(Clone, Debug, PartialEq)]
93pub struct JsonToolOutput {
94 value: JsonValue,
95 success: Option<bool>,
96 contains_external_context: bool,
97}
98
99impl JsonToolOutput {
100 pub fn new(value: JsonValue) -> Self {
101 Self {
102 value,
103 success: Some(true),
104 contains_external_context: false,
105 }
106 }
107
108 pub fn with_success(value: JsonValue, success: Option<bool>) -> Self {
109 Self {
110 value,
111 success,
112 contains_external_context: false,
113 }
114 }
115
116 pub fn with_external_context(mut self) -> Self {
117 self.contains_external_context = true;
118 self
119 }
120}
121
122impl ToolOutput for JsonToolOutput {
123 fn log_preview(&self) -> String {
124 telemetry_preview(&self.value.to_string())
125 }
126
127 fn success_for_logging(&self) -> bool {
128 self.success.unwrap_or(true)
129 }
130
131 fn contains_external_context(&self) -> bool {
132 self.contains_external_context
133 }
134
135 fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
136 let output = FunctionCallOutputPayload {
137 body: FunctionCallOutputBody::Text(self.value.to_string()),
138 success: self.success,
139 };
140
141 if matches!(payload, ToolPayload::Custom { .. }) {
142 return ResponseInputItem::CustomToolCallOutput {
143 call_id: call_id.to_string(),
144 name: None,
145 output,
146 };
147 }
148
149 ResponseInputItem::FunctionCallOutput {
150 call_id: call_id.to_string(),
151 output,
152 }
153 }
154
155 fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
156 Some(self.value.clone())
157 }
158
159 fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
160 self.value.clone()
161 }
162}
163
164impl ToolOutput for codex_protocol::mcp::CallToolResult {
165 fn log_preview(&self) -> String {
166 let output = self.as_function_call_output_payload();
167 let preview = output.body.to_text().unwrap_or_else(|| output.to_string());
168 telemetry_preview(&preview)
169 }
170
171 fn success_for_logging(&self) -> bool {
172 self.success()
173 }
174
175 fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
176 ResponseInputItem::McpToolCallOutput {
177 call_id: call_id.to_string(),
178 output: self.clone(),
179 }
180 }
181
182 fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
183 serde_json::to_value(self).unwrap_or_else(|err| {
184 JsonValue::String(format!("failed to serialize mcp result: {err}"))
185 })
186 }
187}
188
189fn response_input_to_code_mode_result(response: ResponseInputItem) -> JsonValue {
190 match response {
191 ResponseInputItem::Message { content, .. } => content_items_to_code_mode_result(
192 &content
193 .into_iter()
194 .map(|item| match item {
195 codex_protocol::models::ContentItem::InputText { text }
196 | codex_protocol::models::ContentItem::OutputText { text } => {
197 FunctionCallOutputContentItem::InputText { text }
198 }
199 codex_protocol::models::ContentItem::InputImage { image_url, detail } => {
200 FunctionCallOutputContentItem::InputImage {
201 image_url,
202 detail: detail.or(Some(DEFAULT_IMAGE_DETAIL)),
203 }
204 }
205 codex_protocol::models::ContentItem::InputAudio { audio_url } => {
206 FunctionCallOutputContentItem::InputAudio { audio_url }
207 }
208 })
209 .collect::<Vec<_>>(),
210 ),
211 ResponseInputItem::FunctionCallOutput { output, .. }
212 | ResponseInputItem::CustomToolCallOutput { output, .. } => match output.body {
213 FunctionCallOutputBody::Text(text) => JsonValue::String(text),
214 FunctionCallOutputBody::ContentItems(items) => {
215 content_items_to_code_mode_result(&items)
216 }
217 },
218 ResponseInputItem::ToolSearchOutput { tools, .. } => JsonValue::Array(tools),
219 ResponseInputItem::McpToolCallOutput { output, .. } => serde_json::to_value(output)
220 .unwrap_or_else(|err| {
221 JsonValue::String(format!("failed to serialize mcp result: {err}"))
222 }),
223 }
224}
225
226fn content_items_to_code_mode_result(items: &[FunctionCallOutputContentItem]) -> JsonValue {
227 JsonValue::String(
228 items
229 .iter()
230 .filter_map(|item| match item {
231 FunctionCallOutputContentItem::InputText { text } if !text.trim().is_empty() => {
232 Some(text.clone())
233 }
234 FunctionCallOutputContentItem::InputImage { image_url, .. }
235 if !image_url.trim().is_empty() =>
236 {
237 Some(image_url.clone())
238 }
239 FunctionCallOutputContentItem::InputAudio { audio_url }
240 if !audio_url.trim().is_empty() =>
241 {
242 Some(audio_url.clone())
243 }
244 FunctionCallOutputContentItem::InputText { .. }
245 | FunctionCallOutputContentItem::InputImage { .. }
246 | FunctionCallOutputContentItem::InputAudio { .. }
247 | FunctionCallOutputContentItem::EncryptedContent { .. } => None,
248 })
249 .collect::<Vec<_>>()
250 .join("\n"),
251 )
252}
253
254fn telemetry_preview(content: &str) -> String {
255 let truncated_slice = take_bytes_at_char_boundary(content, TELEMETRY_PREVIEW_MAX_BYTES);
256 let truncated_by_bytes = truncated_slice.len() < content.len();
257
258 let mut preview = String::new();
259 let mut lines_iter = truncated_slice.lines();
260 for idx in 0..TELEMETRY_PREVIEW_MAX_LINES {
261 match lines_iter.next() {
262 Some(line) => {
263 if idx > 0 {
264 preview.push('\n');
265 }
266 preview.push_str(line);
267 }
268 None => break,
269 }
270 }
271 let truncated_by_lines = lines_iter.next().is_some();
272
273 if !truncated_by_bytes && !truncated_by_lines {
274 return content.to_string();
275 }
276
277 if preview.len() < truncated_slice.len()
278 && truncated_slice
279 .as_bytes()
280 .get(preview.len())
281 .is_some_and(|byte| *byte == b'\n')
282 {
283 preview.push('\n');
284 }
285
286 if !preview.is_empty() && !preview.ends_with('\n') {
287 preview.push('\n');
288 }
289 preview.push_str(TELEMETRY_PREVIEW_TRUNCATION_NOTICE);
290
291 preview
292}