Skip to main content

codex_rollout_trace/
tool_dispatch.rs

1//! Hot-path helpers for recording canonical tool dispatch boundaries.
2//!
3//! Core owns tool routing and result conversion. The trace crate owns the raw
4//! event schema, payload shape, and no-op behavior, so core only adapts its
5//! domain objects into the small request/result structs defined here.
6
7use 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/// No-op capable trace handle for one resolved tool dispatch.
35#[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
54/// Core-facing request data for the canonical Codex tool boundary.
55pub 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
65/// Runtime source that caused a dispatch-level tool call.
66pub 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
76/// Tool input observed at the registry boundary.
77pub 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/// Result data returned from a dispatch-level tool call.
99#[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/// Raw invocation payload for the canonical Codex tool boundary.
107#[derive(Serialize)]
108struct DispatchedToolTraceRequest<'a> {
109    tool_name: &'a str,
110    tool_namespace: Option<&'a str>,
111    payload: &'a JsonValue,
112}
113
114/// Raw response payload for dispatch-level tool trace events.
115#[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    /// Builds a context that accepts trace calls and records nothing.
131    pub(crate) fn disabled() -> Self {
132        Self {
133            state: ToolDispatchTraceContextState::Disabled,
134        }
135    }
136
137    /// Returns whether caller-side result conversion would be recorded.
138    ///
139    /// Core uses this to avoid formatting or cloning tool outputs when the
140    /// dispatch lifecycle is suppressed or tracing is disabled.
141    pub fn is_enabled(&self) -> bool {
142        matches!(self.state, ToolDispatchTraceContextState::Enabled(_))
143    }
144
145    /// Starts one dispatch-level lifecycle and returns the handle for its result.
146    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    /// Records the caller-facing successful or failed tool result.
164    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    /// Records a dispatch failure before the tool produced a normal result payload.
180    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            /*tool_namespace*/ 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            /*tool_namespace*/ 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}