mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
ed5944ba1d
## Why The MCP tool path had accumulated a few core-owned special cases: a dedicated payload variant, resolver plumbing, a legacy `AfterToolUse` translation path, and a side channel for parallel-call metadata. That made `ToolRegistry` and the spec builder know more about MCP than they needed to. This change moves MCP-specific execution details back onto `ToolInfo` and `McpHandler` so `codex-core` can treat MCP calls like normal function calls while still preserving MCP-specific dispatch and telemetry behavior where it belongs. ## What changed - removed `resolve_mcp_tool_info`, `ToolPayload::Mcp`, `ToolKind`, and the remaining registry-side MCP resolver path - stored MCP routing metadata directly on `McpHandler` and `ToolInfo`, including `supports_parallel_tool_calls` - deleted the legacy `AfterToolUse` consumer in `core`, which removes the need for handler-specific `after_tool_use_payload` implementations - switched tool-result telemetry to handler-provided tags and kept MCP-specific dispatch payload construction inside the handler - simplified tool spec planning/building by passing `ToolInfo` directly and dropping the direct/deferred MCP wrapper structs and the parallel-server side table ## Testing - `cargo check -p codex-core -p codex-mcp -p codex-otel` - `cargo test -p codex-core mcp_parallel_support_uses_exact_payload_server` - `cargo test -p codex-core direct_mcp_tools_register_namespaced_handlers` - `cargo test -p codex-core search_tool_description_lists_each_mcp_source_once` - `cargo test -p codex-mcp list_all_tools_uses_startup_snapshot_while_client_is_pending` - `just fix -p codex-core -p codex-mcp -p codex-otel`
445 lines
14 KiB
Rust
445 lines
14 KiB
Rust
//! Hot-path helpers for recording canonical tool dispatch boundaries.
|
|
//!
|
|
//! Core owns tool routing and result conversion. The trace crate owns the raw
|
|
//! event schema, payload shape, and no-op behavior, so core only adapts its
|
|
//! domain objects into the small request/result structs defined here.
|
|
|
|
use std::fmt::Display;
|
|
use std::sync::Arc;
|
|
|
|
use codex_protocol::models::AdditionalPermissionProfile;
|
|
use codex_protocol::models::ResponseInputItem;
|
|
use codex_protocol::models::SandboxPermissions;
|
|
use codex_protocol::models::SearchToolCallParams;
|
|
use serde::Serialize;
|
|
use serde_json::Value as JsonValue;
|
|
use serde_json::json;
|
|
use tracing::warn;
|
|
|
|
use crate::model::AgentThreadId;
|
|
use crate::model::CodeModeRuntimeToolId;
|
|
use crate::model::CodexTurnId;
|
|
use crate::model::ExecutionStatus;
|
|
use crate::model::ModelVisibleCallId;
|
|
use crate::model::ToolCallId;
|
|
use crate::model::ToolCallKind;
|
|
use crate::model::ToolCallSummary;
|
|
use crate::payload::RawPayloadKind;
|
|
use crate::payload::RawPayloadRef;
|
|
use crate::raw_event::RawToolCallRequester;
|
|
use crate::raw_event::RawTraceEventContext;
|
|
use crate::raw_event::RawTraceEventPayload;
|
|
use crate::writer::TraceWriter;
|
|
|
|
/// No-op capable trace handle for one resolved tool dispatch.
|
|
#[derive(Clone, Debug)]
|
|
pub struct ToolDispatchTraceContext {
|
|
state: ToolDispatchTraceContextState,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
enum ToolDispatchTraceContextState {
|
|
Disabled,
|
|
Enabled(EnabledToolDispatchTraceContext),
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct EnabledToolDispatchTraceContext {
|
|
writer: Arc<TraceWriter>,
|
|
thread_id: AgentThreadId,
|
|
codex_turn_id: CodexTurnId,
|
|
tool_call_id: ToolCallId,
|
|
}
|
|
|
|
/// Core-facing request data for the canonical Codex tool boundary.
|
|
pub struct ToolDispatchInvocation {
|
|
pub thread_id: AgentThreadId,
|
|
pub codex_turn_id: CodexTurnId,
|
|
pub tool_call_id: ToolCallId,
|
|
pub tool_name: String,
|
|
pub tool_namespace: Option<String>,
|
|
pub requester: ToolDispatchRequester,
|
|
pub payload: ToolDispatchPayload,
|
|
}
|
|
|
|
/// Runtime source that caused a dispatch-level tool call.
|
|
pub enum ToolDispatchRequester {
|
|
Model {
|
|
model_visible_call_id: ModelVisibleCallId,
|
|
},
|
|
CodeCell {
|
|
runtime_cell_id: String,
|
|
runtime_tool_call_id: CodeModeRuntimeToolId,
|
|
},
|
|
}
|
|
|
|
/// Tool input observed at the registry boundary.
|
|
pub enum ToolDispatchPayload {
|
|
Function {
|
|
arguments: String,
|
|
},
|
|
ToolSearch {
|
|
arguments: SearchToolCallParams,
|
|
},
|
|
Custom {
|
|
input: String,
|
|
},
|
|
LocalShell {
|
|
command: Vec<String>,
|
|
workdir: Option<String>,
|
|
timeout_ms: Option<u64>,
|
|
sandbox_permissions: Option<SandboxPermissions>,
|
|
prefix_rule: Option<Vec<String>>,
|
|
additional_permissions: Option<AdditionalPermissionProfile>,
|
|
justification: Option<String>,
|
|
},
|
|
}
|
|
|
|
/// Result data returned from a dispatch-level tool call.
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "snake_case", tag = "type")]
|
|
pub enum ToolDispatchResult {
|
|
DirectResponse { response_item: ResponseInputItem },
|
|
CodeModeResponse { value: JsonValue },
|
|
}
|
|
|
|
/// Raw invocation payload for the canonical Codex tool boundary.
|
|
#[derive(Serialize)]
|
|
struct DispatchedToolTraceRequest<'a> {
|
|
tool_name: &'a str,
|
|
tool_namespace: Option<&'a str>,
|
|
payload: &'a JsonValue,
|
|
}
|
|
|
|
/// Raw response payload for dispatch-level tool trace events.
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "snake_case", tag = "type")]
|
|
enum DispatchedToolTraceResponse<'a> {
|
|
DirectResponse {
|
|
response_item: &'a ResponseInputItem,
|
|
},
|
|
CodeModeResponse {
|
|
value: &'a JsonValue,
|
|
},
|
|
Error {
|
|
error: String,
|
|
},
|
|
}
|
|
|
|
impl ToolDispatchTraceContext {
|
|
/// Builds a context that accepts trace calls and records nothing.
|
|
pub(crate) fn disabled() -> Self {
|
|
Self {
|
|
state: ToolDispatchTraceContextState::Disabled,
|
|
}
|
|
}
|
|
|
|
/// Returns whether caller-side result conversion would be recorded.
|
|
///
|
|
/// Core uses this to avoid formatting or cloning tool outputs when the
|
|
/// dispatch lifecycle is suppressed or tracing is disabled.
|
|
pub fn is_enabled(&self) -> bool {
|
|
matches!(self.state, ToolDispatchTraceContextState::Enabled(_))
|
|
}
|
|
|
|
/// Starts one dispatch-level lifecycle and returns the handle for its result.
|
|
pub(crate) fn start(writer: Arc<TraceWriter>, invocation: ToolDispatchInvocation) -> Self {
|
|
if suppresses_tool_dispatch_trace(&invocation) {
|
|
return Self::disabled();
|
|
}
|
|
|
|
let context = EnabledToolDispatchTraceContext {
|
|
writer,
|
|
thread_id: invocation.thread_id.clone(),
|
|
codex_turn_id: invocation.codex_turn_id.clone(),
|
|
tool_call_id: invocation.tool_call_id.clone(),
|
|
};
|
|
record_started(&context, invocation);
|
|
Self {
|
|
state: ToolDispatchTraceContextState::Enabled(context),
|
|
}
|
|
}
|
|
|
|
/// Records the caller-facing successful or failed tool result.
|
|
pub fn record_completed(&self, status: ExecutionStatus, result: ToolDispatchResult) {
|
|
let ToolDispatchTraceContextState::Enabled(context) = &self.state else {
|
|
return;
|
|
};
|
|
let response = match &result {
|
|
ToolDispatchResult::DirectResponse { response_item } => {
|
|
DispatchedToolTraceResponse::DirectResponse { response_item }
|
|
}
|
|
ToolDispatchResult::CodeModeResponse { value } => {
|
|
DispatchedToolTraceResponse::CodeModeResponse { value }
|
|
}
|
|
};
|
|
append_tool_call_ended(context, status, &response);
|
|
}
|
|
|
|
/// Records a dispatch failure before the tool produced a normal result payload.
|
|
pub fn record_failed(&self, error: impl Display) {
|
|
let ToolDispatchTraceContextState::Enabled(context) = &self.state else {
|
|
return;
|
|
};
|
|
append_tool_call_ended(
|
|
context,
|
|
ExecutionStatus::Failed,
|
|
&DispatchedToolTraceResponse::Error {
|
|
error: error.to_string(),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
fn suppresses_tool_dispatch_trace(invocation: &ToolDispatchInvocation) -> bool {
|
|
matches!(invocation.payload, ToolDispatchPayload::Custom { .. })
|
|
&& invocation.tool_namespace.is_none()
|
|
&& invocation.tool_name == codex_code_mode::PUBLIC_TOOL_NAME
|
|
}
|
|
|
|
fn record_started(context: &EnabledToolDispatchTraceContext, invocation: ToolDispatchInvocation) {
|
|
let tool_name = invocation.tool_name;
|
|
let tool_namespace = invocation.tool_namespace;
|
|
let kind = dispatched_tool_kind(&tool_name, &invocation.payload);
|
|
let label = dispatched_tool_label(&tool_name, tool_namespace.as_deref(), &invocation.payload);
|
|
let input_preview = Some(invocation.payload.log_payload_preview());
|
|
let payload = invocation.payload.into_json_payload();
|
|
let request = DispatchedToolTraceRequest {
|
|
tool_name: tool_name.as_str(),
|
|
tool_namespace: tool_namespace.as_deref(),
|
|
payload: &payload,
|
|
};
|
|
let request_payload =
|
|
write_json_payload_best_effort(&context.writer, RawPayloadKind::ToolInvocation, &request);
|
|
let (model_visible_call_id, code_mode_runtime_tool_id, requester) =
|
|
requester_fields(invocation.requester);
|
|
|
|
append_with_context_best_effort(
|
|
context,
|
|
RawTraceEventPayload::ToolCallStarted {
|
|
tool_call_id: context.tool_call_id.clone(),
|
|
model_visible_call_id,
|
|
code_mode_runtime_tool_id,
|
|
requester,
|
|
kind,
|
|
summary: ToolCallSummary::Generic {
|
|
label,
|
|
input_preview,
|
|
output_preview: None,
|
|
},
|
|
invocation_payload: request_payload,
|
|
},
|
|
);
|
|
}
|
|
|
|
fn requester_fields(
|
|
requester: ToolDispatchRequester,
|
|
) -> (
|
|
Option<ModelVisibleCallId>,
|
|
Option<CodeModeRuntimeToolId>,
|
|
RawToolCallRequester,
|
|
) {
|
|
match requester {
|
|
ToolDispatchRequester::Model {
|
|
model_visible_call_id,
|
|
} => (
|
|
Some(model_visible_call_id),
|
|
None,
|
|
RawToolCallRequester::Model,
|
|
),
|
|
ToolDispatchRequester::CodeCell {
|
|
runtime_cell_id,
|
|
runtime_tool_call_id,
|
|
} => (
|
|
None,
|
|
Some(runtime_tool_call_id),
|
|
RawToolCallRequester::CodeCell { runtime_cell_id },
|
|
),
|
|
}
|
|
}
|
|
|
|
fn dispatched_tool_kind(tool_name: &str, _payload: &ToolDispatchPayload) -> ToolCallKind {
|
|
match tool_name {
|
|
"exec_command" | "local_shell" | "shell" | "shell_command" => ToolCallKind::ExecCommand,
|
|
"write_stdin" => ToolCallKind::WriteStdin,
|
|
"apply_patch" => ToolCallKind::ApplyPatch,
|
|
"web_search" | "web_search_preview" => ToolCallKind::Web,
|
|
"image_generation" | "image_query" => ToolCallKind::ImageGeneration,
|
|
"spawn_agent" => ToolCallKind::SpawnAgent,
|
|
"send_message" => ToolCallKind::SendMessage,
|
|
"followup_task" => ToolCallKind::AssignAgentTask,
|
|
"wait_agent" => ToolCallKind::WaitAgent,
|
|
"close_agent" => ToolCallKind::CloseAgent,
|
|
other => ToolCallKind::Other {
|
|
name: other.to_string(),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn dispatched_tool_label(
|
|
tool_name: &str,
|
|
tool_namespace: Option<&str>,
|
|
_payload: &ToolDispatchPayload,
|
|
) -> String {
|
|
match tool_namespace {
|
|
Some(namespace) => format!("{namespace}.{tool_name}"),
|
|
None => tool_name.to_string(),
|
|
}
|
|
}
|
|
|
|
impl ToolDispatchPayload {
|
|
fn log_payload_preview(&self) -> String {
|
|
match self {
|
|
ToolDispatchPayload::Function { arguments } => truncate_preview(arguments),
|
|
ToolDispatchPayload::ToolSearch { arguments } => truncate_preview(&arguments.query),
|
|
ToolDispatchPayload::Custom { input } => truncate_preview(input),
|
|
ToolDispatchPayload::LocalShell { command, .. } => truncate_preview(&command.join(" ")),
|
|
}
|
|
}
|
|
|
|
fn into_json_payload(self) -> JsonValue {
|
|
match self {
|
|
ToolDispatchPayload::Function { arguments } => json!({
|
|
"type": "function",
|
|
"arguments": arguments,
|
|
}),
|
|
ToolDispatchPayload::ToolSearch { arguments } => json!({
|
|
"type": "tool_search",
|
|
"arguments": arguments,
|
|
}),
|
|
ToolDispatchPayload::Custom { input } => json!({
|
|
"type": "custom",
|
|
"input": input,
|
|
}),
|
|
ToolDispatchPayload::LocalShell {
|
|
command,
|
|
workdir,
|
|
timeout_ms,
|
|
sandbox_permissions,
|
|
prefix_rule,
|
|
additional_permissions,
|
|
justification,
|
|
} => json!({
|
|
"type": "local_shell",
|
|
"command": command,
|
|
"workdir": workdir,
|
|
"timeout_ms": timeout_ms,
|
|
"sandbox_permissions": sandbox_permissions,
|
|
"prefix_rule": prefix_rule,
|
|
"additional_permissions": additional_permissions,
|
|
"justification": justification,
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn truncate_preview(value: &str) -> String {
|
|
const MAX_PREVIEW_CHARS: usize = 160;
|
|
let mut chars = value.chars();
|
|
let mut preview = chars.by_ref().take(MAX_PREVIEW_CHARS).collect::<String>();
|
|
if chars.next().is_some() {
|
|
preview.push_str("...");
|
|
}
|
|
preview
|
|
}
|
|
|
|
fn append_tool_call_ended(
|
|
context: &EnabledToolDispatchTraceContext,
|
|
status: ExecutionStatus,
|
|
response: &DispatchedToolTraceResponse<'_>,
|
|
) {
|
|
let response_payload =
|
|
write_json_payload_best_effort(&context.writer, RawPayloadKind::ToolResult, response);
|
|
append_with_context_best_effort(
|
|
context,
|
|
RawTraceEventPayload::ToolCallEnded {
|
|
tool_call_id: context.tool_call_id.clone(),
|
|
status,
|
|
result_payload: response_payload,
|
|
},
|
|
);
|
|
}
|
|
|
|
fn write_json_payload_best_effort(
|
|
writer: &TraceWriter,
|
|
kind: RawPayloadKind,
|
|
payload: &impl Serialize,
|
|
) -> Option<RawPayloadRef> {
|
|
match writer.write_json_payload(kind, payload) {
|
|
Ok(payload_ref) => Some(payload_ref),
|
|
Err(err) => {
|
|
warn!("failed to write rollout trace payload: {err:#}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn append_with_context_best_effort(
|
|
context: &EnabledToolDispatchTraceContext,
|
|
payload: RawTraceEventPayload,
|
|
) {
|
|
let event_context = RawTraceEventContext {
|
|
thread_id: Some(context.thread_id.clone()),
|
|
codex_turn_id: Some(context.codex_turn_id.clone()),
|
|
};
|
|
if let Err(err) = context.writer.append_with_context(event_context, payload) {
|
|
warn!("failed to append rollout trace event: {err:#}");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn suppresses_only_noncanonical_dispatch_boundaries() {
|
|
assert!(suppresses_tool_dispatch_trace(&invocation(
|
|
codex_code_mode::PUBLIC_TOOL_NAME,
|
|
/*tool_namespace*/ None,
|
|
ToolDispatchRequester::Model {
|
|
model_visible_call_id: "call-exec".to_string(),
|
|
},
|
|
ToolDispatchPayload::Custom {
|
|
input: "1 + 1".to_string(),
|
|
},
|
|
)));
|
|
assert!(!suppresses_tool_dispatch_trace(&invocation(
|
|
"custom_tool",
|
|
/*tool_namespace*/ None,
|
|
ToolDispatchRequester::Model {
|
|
model_visible_call_id: "call-custom".to_string(),
|
|
},
|
|
ToolDispatchPayload::Custom {
|
|
input: "payload".to_string(),
|
|
},
|
|
)));
|
|
assert!(!suppresses_tool_dispatch_trace(&invocation(
|
|
codex_code_mode::PUBLIC_TOOL_NAME,
|
|
Some("mcp__server".to_string()),
|
|
ToolDispatchRequester::Model {
|
|
model_visible_call_id: "call-namespaced".to_string(),
|
|
},
|
|
ToolDispatchPayload::Custom {
|
|
input: "payload".to_string(),
|
|
},
|
|
)));
|
|
}
|
|
|
|
fn invocation(
|
|
tool_name: &str,
|
|
tool_namespace: Option<String>,
|
|
requester: ToolDispatchRequester,
|
|
payload: ToolDispatchPayload,
|
|
) -> ToolDispatchInvocation {
|
|
ToolDispatchInvocation {
|
|
thread_id: "thread-1".to_string(),
|
|
codex_turn_id: "turn-1".to_string(),
|
|
tool_call_id: "tool-call-1".to_string(),
|
|
tool_name: tool_name.to_string(),
|
|
tool_namespace,
|
|
requester,
|
|
payload,
|
|
}
|
|
}
|
|
}
|