1use std::fmt::Display;
8use std::sync::Arc;
9
10use codex_protocol::models::AdditionalPermissionProfile;
11use codex_protocol::models::ResponseInputItem;
12use codex_protocol::models::SandboxPermissions;
13use codex_protocol::models::SearchToolCallParams;
14use serde::Serialize;
15use serde_json::Value as JsonValue;
16use serde_json::json;
17use tracing::warn;
18
19use crate::model::AgentThreadId;
20use crate::model::CodeModeRuntimeToolId;
21use crate::model::CodexTurnId;
22use crate::model::ExecutionStatus;
23use crate::model::ModelVisibleCallId;
24use crate::model::ToolCallId;
25use crate::model::ToolCallKind;
26use crate::model::ToolCallSummary;
27use crate::payload::RawPayloadKind;
28use crate::payload::RawPayloadRef;
29use crate::raw_event::RawToolCallRequester;
30use crate::raw_event::RawTraceEventContext;
31use crate::raw_event::RawTraceEventPayload;
32use crate::writer::TraceWriter;
33
34#[derive(Clone, Debug)]
36pub struct ToolDispatchTraceContext {
37 state: ToolDispatchTraceContextState,
38}
39
40#[derive(Clone, Debug)]
41enum ToolDispatchTraceContextState {
42 Disabled,
43 Enabled(EnabledToolDispatchTraceContext),
44}
45
46#[derive(Clone, Debug)]
47struct EnabledToolDispatchTraceContext {
48 writer: Arc<TraceWriter>,
49 thread_id: AgentThreadId,
50 codex_turn_id: CodexTurnId,
51 tool_call_id: ToolCallId,
52}
53
54pub struct ToolDispatchInvocation {
56 pub thread_id: AgentThreadId,
57 pub codex_turn_id: CodexTurnId,
58 pub tool_call_id: ToolCallId,
59 pub tool_name: String,
60 pub tool_namespace: Option<String>,
61 pub requester: ToolDispatchRequester,
62 pub payload: ToolDispatchPayload,
63}
64
65pub enum ToolDispatchRequester {
67 Model {
68 model_visible_call_id: ModelVisibleCallId,
69 },
70 CodeCell {
71 runtime_cell_id: String,
72 runtime_tool_call_id: CodeModeRuntimeToolId,
73 },
74}
75
76pub enum ToolDispatchPayload {
78 Function {
79 arguments: String,
80 },
81 ToolSearch {
82 arguments: SearchToolCallParams,
83 },
84 Custom {
85 input: String,
86 },
87 LocalShell {
88 command: Vec<String>,
89 workdir: Option<String>,
90 timeout_ms: Option<u64>,
91 sandbox_permissions: Option<SandboxPermissions>,
92 prefix_rule: Option<Vec<String>>,
93 additional_permissions: Option<AdditionalPermissionProfile>,
94 justification: Option<String>,
95 },
96}
97
98#[derive(Serialize)]
100#[serde(rename_all = "snake_case", tag = "type")]
101pub enum ToolDispatchResult {
102 DirectResponse { response_item: ResponseInputItem },
103 CodeModeResponse { value: JsonValue },
104}
105
106#[derive(Serialize)]
108struct DispatchedToolTraceRequest<'a> {
109 tool_name: &'a str,
110 tool_namespace: Option<&'a str>,
111 payload: &'a JsonValue,
112}
113
114#[derive(Serialize)]
116#[serde(rename_all = "snake_case", tag = "type")]
117enum DispatchedToolTraceResponse<'a> {
118 DirectResponse {
119 response_item: &'a ResponseInputItem,
120 },
121 CodeModeResponse {
122 value: &'a JsonValue,
123 },
124 Error {
125 error: String,
126 },
127}
128
129impl ToolDispatchTraceContext {
130 pub(crate) fn disabled() -> Self {
132 Self {
133 state: ToolDispatchTraceContextState::Disabled,
134 }
135 }
136
137 pub fn is_enabled(&self) -> bool {
142 matches!(self.state, ToolDispatchTraceContextState::Enabled(_))
143 }
144
145 pub(crate) fn start(writer: Arc<TraceWriter>, invocation: ToolDispatchInvocation) -> Self {
147 if suppresses_tool_dispatch_trace(&invocation) {
148 return Self::disabled();
149 }
150
151 let context = EnabledToolDispatchTraceContext {
152 writer,
153 thread_id: invocation.thread_id.clone(),
154 codex_turn_id: invocation.codex_turn_id.clone(),
155 tool_call_id: invocation.tool_call_id.clone(),
156 };
157 record_started(&context, invocation);
158 Self {
159 state: ToolDispatchTraceContextState::Enabled(context),
160 }
161 }
162
163 pub fn record_completed(&self, status: ExecutionStatus, result: ToolDispatchResult) {
165 let ToolDispatchTraceContextState::Enabled(context) = &self.state else {
166 return;
167 };
168 let response = match &result {
169 ToolDispatchResult::DirectResponse { response_item } => {
170 DispatchedToolTraceResponse::DirectResponse { response_item }
171 }
172 ToolDispatchResult::CodeModeResponse { value } => {
173 DispatchedToolTraceResponse::CodeModeResponse { value }
174 }
175 };
176 append_tool_call_ended(context, status, &response);
177 }
178
179 pub fn record_failed(&self, error: impl Display) {
181 let ToolDispatchTraceContextState::Enabled(context) = &self.state else {
182 return;
183 };
184 append_tool_call_ended(
185 context,
186 ExecutionStatus::Failed,
187 &DispatchedToolTraceResponse::Error {
188 error: error.to_string(),
189 },
190 );
191 }
192}
193
194fn suppresses_tool_dispatch_trace(invocation: &ToolDispatchInvocation) -> bool {
195 matches!(invocation.payload, ToolDispatchPayload::Custom { .. })
196 && invocation.tool_namespace.is_none()
197 && invocation.tool_name == codex_code_mode::PUBLIC_TOOL_NAME
198}
199
200fn record_started(context: &EnabledToolDispatchTraceContext, invocation: ToolDispatchInvocation) {
201 let tool_name = invocation.tool_name;
202 let tool_namespace = invocation.tool_namespace;
203 let kind = dispatched_tool_kind(&tool_name, &invocation.payload);
204 let label = dispatched_tool_label(&tool_name, tool_namespace.as_deref(), &invocation.payload);
205 let input_preview = Some(invocation.payload.log_payload_preview());
206 let payload = invocation.payload.into_json_payload();
207 let request = DispatchedToolTraceRequest {
208 tool_name: tool_name.as_str(),
209 tool_namespace: tool_namespace.as_deref(),
210 payload: &payload,
211 };
212 let request_payload =
213 write_json_payload_best_effort(&context.writer, RawPayloadKind::ToolInvocation, &request);
214 let (model_visible_call_id, code_mode_runtime_tool_id, requester) =
215 requester_fields(invocation.requester);
216
217 append_with_context_best_effort(
218 context,
219 RawTraceEventPayload::ToolCallStarted {
220 tool_call_id: context.tool_call_id.clone(),
221 model_visible_call_id,
222 code_mode_runtime_tool_id,
223 requester,
224 kind,
225 summary: ToolCallSummary::Generic {
226 label,
227 input_preview,
228 output_preview: None,
229 },
230 invocation_payload: request_payload,
231 },
232 );
233}
234
235fn requester_fields(
236 requester: ToolDispatchRequester,
237) -> (
238 Option<ModelVisibleCallId>,
239 Option<CodeModeRuntimeToolId>,
240 RawToolCallRequester,
241) {
242 match requester {
243 ToolDispatchRequester::Model {
244 model_visible_call_id,
245 } => (
246 Some(model_visible_call_id),
247 None,
248 RawToolCallRequester::Model,
249 ),
250 ToolDispatchRequester::CodeCell {
251 runtime_cell_id,
252 runtime_tool_call_id,
253 } => (
254 None,
255 Some(runtime_tool_call_id),
256 RawToolCallRequester::CodeCell { runtime_cell_id },
257 ),
258 }
259}
260
261fn dispatched_tool_kind(tool_name: &str, _payload: &ToolDispatchPayload) -> ToolCallKind {
262 match tool_name {
263 "exec_command" | "local_shell" | "shell" | "shell_command" => ToolCallKind::ExecCommand,
264 "write_stdin" => ToolCallKind::WriteStdin,
265 "apply_patch" => ToolCallKind::ApplyPatch,
266 "web_search" | "web_search_preview" => ToolCallKind::Web,
267 "image_generation" | "image_query" | "imagegen" => ToolCallKind::ImageGeneration,
268 "spawn_agent" => ToolCallKind::SpawnAgent,
269 "send_message" => ToolCallKind::SendMessage,
270 "followup_task" | "assign_task" => ToolCallKind::AssignAgentTask,
271 "wait_agent" => ToolCallKind::WaitAgent,
272 "close_agent" | "interrupt_agent" => ToolCallKind::CloseAgent,
273 other => ToolCallKind::Other {
274 name: other.to_string(),
275 },
276 }
277}
278
279fn dispatched_tool_label(
280 tool_name: &str,
281 tool_namespace: Option<&str>,
282 _payload: &ToolDispatchPayload,
283) -> String {
284 match tool_namespace {
285 Some(namespace) => format!("{namespace}.{tool_name}"),
286 None => tool_name.to_string(),
287 }
288}
289
290impl ToolDispatchPayload {
291 fn log_payload_preview(&self) -> String {
292 match self {
293 ToolDispatchPayload::Function { arguments } => truncate_preview(arguments),
294 ToolDispatchPayload::ToolSearch { arguments } => truncate_preview(&arguments.query),
295 ToolDispatchPayload::Custom { input } => truncate_preview(input),
296 ToolDispatchPayload::LocalShell { command, .. } => truncate_preview(&command.join(" ")),
297 }
298 }
299
300 fn into_json_payload(self) -> JsonValue {
301 match self {
302 ToolDispatchPayload::Function { arguments } => json!({
303 "type": "function",
304 "arguments": arguments,
305 }),
306 ToolDispatchPayload::ToolSearch { arguments } => json!({
307 "type": "tool_search",
308 "arguments": arguments,
309 }),
310 ToolDispatchPayload::Custom { input } => json!({
311 "type": "custom",
312 "input": input,
313 }),
314 ToolDispatchPayload::LocalShell {
315 command,
316 workdir,
317 timeout_ms,
318 sandbox_permissions,
319 prefix_rule,
320 additional_permissions,
321 justification,
322 } => json!({
323 "type": "local_shell",
324 "command": command,
325 "workdir": workdir,
326 "timeout_ms": timeout_ms,
327 "sandbox_permissions": sandbox_permissions,
328 "prefix_rule": prefix_rule,
329 "additional_permissions": additional_permissions,
330 "justification": justification,
331 }),
332 }
333 }
334}
335
336fn truncate_preview(value: &str) -> String {
337 const MAX_PREVIEW_CHARS: usize = 160;
338 let mut chars = value.chars();
339 let mut preview = chars.by_ref().take(MAX_PREVIEW_CHARS).collect::<String>();
340 if chars.next().is_some() {
341 preview.push_str("...");
342 }
343 preview
344}
345
346fn append_tool_call_ended(
347 context: &EnabledToolDispatchTraceContext,
348 status: ExecutionStatus,
349 response: &DispatchedToolTraceResponse<'_>,
350) {
351 let response_payload =
352 write_json_payload_best_effort(&context.writer, RawPayloadKind::ToolResult, response);
353 append_with_context_best_effort(
354 context,
355 RawTraceEventPayload::ToolCallEnded {
356 tool_call_id: context.tool_call_id.clone(),
357 status,
358 result_payload: response_payload,
359 },
360 );
361}
362
363fn write_json_payload_best_effort(
364 writer: &TraceWriter,
365 kind: RawPayloadKind,
366 payload: &impl Serialize,
367) -> Option<RawPayloadRef> {
368 match writer.write_json_payload(kind, payload) {
369 Ok(payload_ref) => Some(payload_ref),
370 Err(err) => {
371 warn!("failed to write rollout trace payload: {err:#}");
372 None
373 }
374 }
375}
376
377fn append_with_context_best_effort(
378 context: &EnabledToolDispatchTraceContext,
379 payload: RawTraceEventPayload,
380) {
381 let event_context = RawTraceEventContext {
382 thread_id: Some(context.thread_id.clone()),
383 codex_turn_id: Some(context.codex_turn_id.clone()),
384 };
385 if let Err(err) = context.writer.append_with_context(event_context, payload) {
386 warn!("failed to append rollout trace event: {err:#}");
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 #[test]
395 fn suppresses_only_noncanonical_dispatch_boundaries() {
396 assert!(suppresses_tool_dispatch_trace(&invocation(
397 codex_code_mode::PUBLIC_TOOL_NAME,
398 None,
399 ToolDispatchRequester::Model {
400 model_visible_call_id: "call-exec".to_string(),
401 },
402 ToolDispatchPayload::Custom {
403 input: "1 + 1".to_string(),
404 },
405 )));
406 assert!(!suppresses_tool_dispatch_trace(&invocation(
407 "custom_tool",
408 None,
409 ToolDispatchRequester::Model {
410 model_visible_call_id: "call-custom".to_string(),
411 },
412 ToolDispatchPayload::Custom {
413 input: "payload".to_string(),
414 },
415 )));
416 assert!(!suppresses_tool_dispatch_trace(&invocation(
417 codex_code_mode::PUBLIC_TOOL_NAME,
418 Some("mcp__server".to_string()),
419 ToolDispatchRequester::Model {
420 model_visible_call_id: "call-namespaced".to_string(),
421 },
422 ToolDispatchPayload::Custom {
423 input: "payload".to_string(),
424 },
425 )));
426 }
427
428 #[test]
429 fn classifies_interrupt_agent_as_close_agent() {
430 assert_eq!(
431 dispatched_tool_kind(
432 "interrupt_agent",
433 &ToolDispatchPayload::Function {
434 arguments: r#"{"target":"/root/child"}"#.to_string(),
435 },
436 ),
437 ToolCallKind::CloseAgent
438 );
439 }
440
441 #[test]
442 fn classifies_imagegen_as_image_generation() {
443 assert_eq!(
444 dispatched_tool_kind(
445 "imagegen",
446 &ToolDispatchPayload::Function {
447 arguments: String::new(),
448 },
449 ),
450 ToolCallKind::ImageGeneration
451 );
452 }
453
454 fn invocation(
455 tool_name: &str,
456 tool_namespace: Option<String>,
457 requester: ToolDispatchRequester,
458 payload: ToolDispatchPayload,
459 ) -> ToolDispatchInvocation {
460 ToolDispatchInvocation {
461 thread_id: "thread-1".to_string(),
462 codex_turn_id: "turn-1".to_string(),
463 tool_call_id: "tool-call-1".to_string(),
464 tool_name: tool_name.to_string(),
465 tool_namespace,
466 requester,
467 payload,
468 }
469 }
470}