工具调度追踪模型
Codex 的工具追踪不是把日志字符串写进一个文件,而是把一次调度拆成“调用边界”和“运行时观察”两层,再通过 tool_call_id、模型可见 call_id、Code Mode runtime id 和 terminal operation id 逐步连接。调用边界记录“谁请求了哪个 工具、携带什么 payload、最终返回什么”;运行时观察记录“进程、补丁、MCP 或协作 runtime 实际发生了什么”。最后, reducer 按 raw event 的 seq 重放这些事件,生成可以查询的 RolloutTrace 图。
本文面向已经读过ToolOrchestrator执行流程、ToolLifecycle事件 和并行结果排序的读者。前文解释工具如何进入 handler、lifecycle 如何计算终态以及并行结果 如何回到历史;本文只研究诊断追踪,不把 trace event 当成模型历史,也不展开 terminal 或 MCP handler 的完整实现。读完后, 读者应能定位一个 trace bundle 中的调用起点、区分 canonical result 与 runtime payload,并解释为什么事件到达顺序可以不同于 模型调用顺序而仍然被 reducer 正确还原。
1. 追踪边界
1.1 两层事实
一条工具调用至少有两个时间窗口。第一个是 Core Registry 接受 ToolInvocation 到产生 caller-facing result 的 dispatch 窗口;第二个是 runtime 发出 begin/end protocol event 的执行窗口。对于 exec_command,前者的结果是给模型的 response item, 后者包含进程、cwd、stdout、stderr 和 exit code。对于 Code Mode nested tool,前者的结果是返回 JavaScript 的 JSON 值,后者仍 可以继续关联到 cell 或具体 runtime。
| 层 | 事件 | 主要回答 | 不能替代 |
|---|---|---|---|
| 调用边界 | ToolCallStarted / ToolCallEnded | 谁请求、请求什么、返回给谁 | OS 进程细节 |
| 运行时观察 | ToolCallRuntimeStarted / ToolCallRuntimeEnded | Core 怎样执行、运行多久、产生什么 runtime payload | 原始模型调用身份 |
| 还原结果 | RolloutTrace.tool_calls 与 terminal/code cell 对象 | 如何查询完整因果关系 | 原始事件的逐行顺序 |
这张图的关键是 ToolCallEnded 与 RuntimeEnded 不一定是同一个结束点。Reducer 会把二者都保存到同一个 ToolCall,但 只有 runtime 观察足够丰富时,才进一步生成 terminal operation 或 agent interaction 等领域对象。
1.2 可选开关
生产 Session 通过 CODEX_ROLLOUT_TRACE_ROOT 决定是否建立根 trace。没有环境变量,或创建 bundle 失败时,返回 Disabled context;诊断失败不会让工具调用失败。这不是 feature flag 的“关闭后少写几条日志”,而是让热路径拥有 no-op handle,调用者 仍可无条件调用追踪方法。
源码位置:codex-rs/rollout-trace/src/thread.rs :: ThreadTraceContext::start_root_or_disabled
pub fn start_root_or_disabled(metadata: ThreadStartedTraceMetadata) -> Self {
let Some(root) = std::env::var_os(CODEX_ROLLOUT_TRACE_ROOT_ENV) else {
return Self::disabled();
};
let root = PathBuf::from(root);
match start_root_in_root(root.as_path(), metadata) {
Ok(context) => context,
Err(err) => {
warn!("failed to initialize rollout trace bundle: {err:#}");
Self::disabled()
}
}
}Session 初始化把根线程或子线程 trace 放入 SessionServices。子线程复用父 bundle 的 writer;如果父线程没有 trace,子线程 不会自行创建一个看似独立的 bundle。
源码位置:codex-rs/core/src/session/session.rs :: Session::new
let rollout_thread_trace = if matches!(
session_configuration.session_source,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })
) {
parent_rollout_thread_trace.start_child_thread_trace_or_disabled(trace_metadata)
} else {
ThreadTraceContext::start_root_or_disabled(trace_metadata)
};2. Bundle写入
2.1 文件布局
启用追踪后,一个 bundle 由 manifest、append-only trace.jsonl 和 payloads/ 目录组成。事件只保存轻量的 RawPayloadRef,完整 JSON 放在 payload 文件中;这样 reducer 可以先读事件结构,再按需打开某个请求、结果或 runtime 文件。
源码位置:codex-rs/rollout-trace/src/writer.rs :: TraceWriter::create
pub fn create(
bundle_dir: impl AsRef<Path>,
trace_id: String,
rollout_id: String,
root_thread_id: AgentThreadId,
) -> Result<Self> {
let bundle_dir = bundle_dir.as_ref().to_path_buf();
let payloads_dir = bundle_dir.join(PAYLOADS_DIR_NAME);
std::fs::create_dir_all(&payloads_dir)
.with_context(|| format!("create trace payload dir {}", payloads_dir.display()))?;
let started_at_unix_ms = unix_time_ms();
let manifest =
TraceBundleManifest::new(trace_id, rollout_id, root_thread_id, started_at_unix_ms);
write_json_file(&bundle_dir.join(MANIFEST_FILE_NAME), &manifest)?;
let event_log_path = bundle_dir.join(RAW_EVENT_LOG_FILE_NAME);
let event_log = OpenOptions::new()
.create(true)
.append(true)
.open(&event_log_path)
.with_context(|| format!("open trace event log {}", event_log_path.display()))?;
Ok(Self {
inner: Mutex::new(TraceWriterInner {
manifest,
payloads_dir,
event_log: BufWriter::new(event_log),
next_seq: 1,
next_payload_ordinal: 1,
}),
})
}2.2 写入顺序
写 payload 时,writer 先创建 JSON 文件,再返回引用;追加 event 时,writer 在同一 mutex 下分配递增 seq、填充时间和上下文, 写入一行 JSON 并 flush。于是一个已写入事件不会指向“计划创建但尚未落盘”的 payload 文件。
源码位置:codex-rs/rollout-trace/src/writer.rs :: TraceWriter::write_json_payload
pub fn write_json_payload(
&self,
kind: RawPayloadKind,
value: &impl Serialize,
) -> Result<RawPayloadRef> {
let mut inner = self.lock_inner();
let ordinal = inner.next_payload_ordinal;
inner.next_payload_ordinal += 1;
let raw_payload_id = format!("raw_payload:{ordinal}");
let relative_path = format!("{PAYLOADS_DIR_NAME}/{ordinal}.json");
let absolute_path = inner.payloads_dir.join(format!("{ordinal}.json"));
write_json_file(&absolute_path, value)?;
Ok(RawPayloadRef {
raw_payload_id,
kind,
path: relative_path,
})
}源码位置:codex-rs/rollout-trace/src/writer.rs :: TraceWriter::append_with_context
pub fn append_with_context(
&self,
context: RawTraceEventContext,
payload: RawTraceEventPayload,
) -> Result<RawTraceEvent> {
let mut inner = self.lock_inner();
let event = RawTraceEvent {
schema_version: RAW_TRACE_EVENT_SCHEMA_VERSION,
seq: inner.next_seq,
wall_time_unix_ms: unix_time_ms(),
rollout_id: inner.manifest.rollout_id.clone(),
thread_id: context.thread_id,
codex_turn_id: context.codex_turn_id,
payload,
};
inner.next_seq += 1;
serde_json::to_writer(&mut inner.event_log, &event)?;
inner.event_log.write_all(b"\n")?;
inner.event_log.flush()?;
Ok(event)
}seq 是因果排序主键,wall_time_unix_ms 只适合展示延迟和同一事件的时间窗口。两个事件可能拥有相同毫秒时间,但不会 拥有相同的 writer sequence。
3. 调度入口
3.1 Core适配器
Core 不直接构造 raw event。ToolDispatchTrace 只保存一个 ToolDispatchTraceContext,启动时把 Core 的 invocation 转换为 trace crate 的 ToolDispatchInvocation;结束时把 ToolOutput 转为 DirectResponse 或 CodeModeResponse。
源码位置:codex-rs/core/src/tools/tool_dispatch_trace.rs :: ToolDispatchTrace
pub(crate) struct ToolDispatchTrace {
context: ToolDispatchTraceContext,
}
impl ToolDispatchTrace {
pub(crate) fn start(invocation: &ToolInvocation) -> Self {
let context = invocation
.session
.services
.rollout_thread_trace
.start_tool_dispatch_trace(|| tool_dispatch_invocation(invocation));
Self { context }
}
pub(crate) fn record_completed(
&self,
invocation: &ToolInvocation,
call_id: &str,
payload: &ToolPayload,
result: &dyn ToolOutput,
) {
if !self.context.is_enabled() {
return;
}
let Some(result_payload) = tool_dispatch_result(
invocation,
call_id,
payload,
result,
) else {
return;
};
let status = if result.success_for_logging() {
ExecutionStatus::Completed
} else {
ExecutionStatus::Failed
};
self.context.record_completed(status, result_payload);
}
pub(crate) fn record_failed(&self, error: &FunctionCallError) {
self.context.record_failed(error);
}
}启动 trace 的位置在 Registry 已经增加 active-turn tool count 后、查找 runtime 前。因此 unsupported tool 和 payload kind mismatch 也能形成完整的 started/failed 调度记录。
源码位置:codex-rs/core/src/tools/registry.rs :: ToolRegistry::dispatch_any_with_terminal_outcome
{
let mut active = invocation.session.active_turn.lock().await;
if let Some(active_turn) = active.as_mut() {
let mut turn_state = active_turn.turn_state.lock().await;
turn_state.tool_calls = turn_state.tool_calls.saturating_add(1);
}
}
let dispatch_trace = ToolDispatchTrace::start(&invocation);
let tool = match self.tool(&tool_name) {
Some(tool) => tool,
None => {
let err = FunctionCallError::RespondToModel(message);
dispatch_trace.record_failed(&err);
return Err(err);
}
};3.2 正常结束
handler 成功返回后,Registry 先计算 lifecycle outcome,再处理 PostToolUse 的反馈或 block,最后将 caller-facing result 交给 trace。于是 trace 的 ToolCallEnded 记录最终返回给调用者的 payload;PostToolUse block 会让 dispatch 返回错误,并以 Error response 结束 trace,但不会把已完成的 handler 副作用倒回去。
源码位置:codex-rs/core/src/tools/registry.rs :: ToolRegistry::dispatch_any_with_terminal_outcome
if let Some(outcome) = post_tool_use_outcome {
if outcome.should_block {
let message = outcome.feedback_message.unwrap_or_else(|| {
"PostToolUse hook blocked the tool result".to_string()
});
let err = FunctionCallError::RespondToModel(message);
dispatch_trace.record_failed(&err);
return Err(err);
}
if let Some(feedback_message) = outcome.feedback_message {
result.result = Box::new(PostToolUseFeedbackOutput {
original: result.result,
model_visible: FunctionToolOutput::from_text(
feedback_message,
/*success*/ None,
),
});
}
}
dispatch_trace.record_completed(
&invocation,
&result.call_id,
&result.payload,
result.result.as_ref(),
);
Ok(result)4. 身份投影
4.1 请求者
Core 的 ToolCallSource 有两套身份:Direct/DirectPlaintextMessage 代表模型直接请求,CodeMode 代表模型写的 JavaScript 通过 cell 发起 nested tool。trace 不把这两者强行压成一个 call id,而是保存 model_visible_call_id 或 code_mode_runtime_tool_id,让 reducer 能把运行时对象连接回不同的父节点。
源码位置:codex-rs/core/src/tools/tool_dispatch_trace.rs :: tool_dispatch_invocation
let requester = match &invocation.source {
ToolCallSource::Direct | ToolCallSource::DirectPlaintextMessage => {
ToolDispatchRequester::Model {
model_visible_call_id: invocation.call_id.clone(),
}
}
ToolCallSource::CodeMode {
cell_id,
runtime_tool_call_id,
} => ToolDispatchRequester::CodeCell {
runtime_cell_id: cell_id.clone(),
runtime_tool_call_id: runtime_tool_call_id.clone(),
},
};4.2 命名空间
默认 namespace 不写入 tool_namespace,namespaced tool 则保留 namespace。这样 reducer 的工具标签可以显示 namespace.tool_name,同时保持默认工具的简洁形式。请求 payload 另存 function、tool search 和 custom 的原始形状, 不会把不同 payload 变体拼成一个字符串。
源码位置:codex-rs/core/src/tools/tool_dispatch_trace.rs :: tool_dispatch_invocation
Some(ToolDispatchInvocation {
thread_id: invocation.session.thread_id.to_string(),
codex_turn_id: invocation.turn.sub_id.clone(),
tool_call_id: invocation.call_id.clone(),
tool_name: invocation.tool_name.name.clone(),
tool_namespace: invocation
.tool_name
.namespace
.as_ref()
.filter(|_| !invocation.tool_name.is_default_namespace())
.cloned(),
requester,
payload: tool_dispatch_payload(&invocation.payload),
})4.3 结果形状
结束结果与请求者一致:Direct 结果调用 ToolOutput::to_response_item,得到模型可见 response item;Code Mode 结果调用 ToolOutput::code_mode_result,得到 JavaScript 立即消费的 JSON 值。两者都写入同一个 ToolCallEnded,但 raw result 的 语义不同。
源码位置:codex-rs/core/src/tools/tool_dispatch_trace.rs :: tool_dispatch_result
match invocation.source {
ToolCallSource::Direct | ToolCallSource::DirectPlaintextMessage => {
Some(ToolDispatchResult::DirectResponse {
response_item: result.to_response_item(call_id, payload),
})
}
ToolCallSource::CodeMode { .. } => {
Some(ToolDispatchResult::CodeModeResponse {
value: result.code_mode_result(payload),
})
}
}5. Trace事件
5.1 禁用与抑制
ThreadTraceContext 禁用时,start_tool_dispatch_trace 甚至不会执行传入的 closure,因此 Core 不会为 disabled tracing 克隆大参数。即便 tracing 已启用,裸的默认 namespace exec custom call 也会被 trace crate 抑制,因为它是 Code Mode 的公共外壳,不是一次 canonical nested dispatch。
源码位置:codex-rs/rollout-trace/src/thread.rs :: ThreadTraceContext::start_tool_dispatch_trace
pub fn start_tool_dispatch_trace(
&self,
invocation: impl FnOnce() -> Option<ToolDispatchInvocation>,
) -> ToolDispatchTraceContext {
let ThreadTraceContextState::Enabled(context) = &self.state else {
return ToolDispatchTraceContext::disabled();
};
let Some(invocation) = invocation() else {
return ToolDispatchTraceContext::disabled();
};
ToolDispatchTraceContext::start(Arc::clone(&context.writer), invocation)
}源码位置:codex-rs/rollout-trace/src/tool_dispatch.rs :: suppresses_tool_dispatch_trace
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
}5.2 开始事件
ToolDispatchTraceContext::start 先判断抑制条件,再保存 thread/turn/call 三个稳定身份,最后写 ToolCallStarted。事件还 会保存 kind、短输入预览和完整 invocation payload 的 RawPayloadRef。预览最多 160 个字符,只服务列表和诊断摘要,不能 替代 payload 文件。
源码位置:codex-rs/rollout-trace/src/tool_dispatch.rs :: ToolDispatchTraceContext::start
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),
}
}源码位置:codex-rs/rollout-trace/src/tool_dispatch.rs :: record_started
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,
);5.3 结束事件
成功返回和错误返回都写 ToolCallEnded。正常结果中的 status 由 success_for_logging 决定;没有正常 result payload 的 early return 则使用 Error { error }。写 payload 或 append event 失败只发 warning,不能反过来改变工具调用的业务结果。
源码位置:codex-rs/rollout-trace/src/tool_dispatch.rs :: ToolDispatchTraceContext::record_completed
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);
}源码位置:codex-rs/rollout-trace/src/tool_dispatch.rs :: ToolDispatchTraceContext::record_failed
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(),
},
);
}6. 运行时观察
6.1 Protocol映射
ThreadTraceContext::record_tool_call_event 接收已有的 EventMsg,只挑选真正代表工具 runtime 的变体。UserShell 的 ExecCommandBegin/End 被排除,避免把用户在终端直接输入的命令混进模型工具调用。exec、patch、MCP 和协作事件分别映射 为 Started/Ended;结束状态由协议结果转换为 ExecutionStatus。
源码位置:codex-rs/rollout-trace/src/protocol_event.rs :: tool_runtime_trace_event
match event {
EventMsg::ExecCommandBegin(event)
if event.source != ExecCommandSource::UserShell =>
{
Some(ToolRuntimeTraceEvent::Started {
tool_call_id: &event.call_id,
payload: ToolRuntimePayload::ExecCommandBegin(event),
})
}
EventMsg::ExecCommandEnd(event)
if event.source != ExecCommandSource::UserShell =>
{
Some(ToolRuntimeTraceEvent::Ended {
tool_call_id: &event.call_id,
status: event.status.trace_execution_status(),
payload: ToolRuntimePayload::ExecCommandEnd(event),
})
}
EventMsg::McpToolCallEnd(event) => Some(ToolRuntimeTraceEvent::Ended {
tool_call_id: &event.call_id,
status: if event.result.is_ok() {
ExecutionStatus::Completed
} else {
ExecutionStatus::Failed
},
payload: ToolRuntimePayload::McpToolCallEnd(event),
}),
_ => None,
}6.2 Runtime payload
运行时事件先写入 RawPayloadKind::ToolRuntimeEvent,再追加 ToolCallRuntimeStarted/Ended。Reducer 看到这些事件后, 不会覆盖 canonical invocation/result,而是把 payload id 放进 raw_runtime_payload_ids,再按工具 kind 创建 terminal 或 agent interaction 对象。
源码位置:codex-rs/rollout-trace/src/thread.rs :: EnabledThreadTraceContext::raw_tool_runtime_payload
match trace_event {
ToolRuntimeTraceEvent::Started {
tool_call_id,
payload,
} => {
let runtime_payload = self.write_json_payload_best_effort(
RawPayloadKind::ToolRuntimeEvent,
&payload,
)?;
Some(RawTraceEventPayload::ToolCallRuntimeStarted {
tool_call_id: tool_call_id.to_string(),
runtime_payload,
})
}
ToolRuntimeTraceEvent::Ended {
tool_call_id,
status,
payload,
} => {
let runtime_payload = self.write_json_payload_best_effort(
RawPayloadKind::ToolRuntimeEvent,
&payload,
)?;
Some(RawTraceEventPayload::ToolCallRuntimeEnded {
tool_call_id: tool_call_id.to_string(),
status,
runtime_payload,
})
}
}6.3 关联时机
运行时开始事件可能先于模型 response item 被 reducer 看到。例如 Code Mode cell 很快返回,CodeCellStarted、nested tool 和 CodeCellEnded 可以在 inference response item 入图之前抵达。reducer 因此维护 pending code-cell starts 和 lifecycle events,而不是要求生产者人为延迟事件。
7. 图形还原
7.1 Raw envelope
每行 raw event 都有 schema version、连续 seq、wall time、rollout id、thread/turn context 和 tagged payload。这个 envelope 让 replay 可以在理解具体 payload 之前检查顺序和 payload refs。
源码位置:codex-rs/rollout-trace/src/raw_event.rs :: RawTraceEvent
pub struct RawTraceEvent {
pub schema_version: u32,
pub seq: RawEventSeq,
pub wall_time_unix_ms: i64,
pub rollout_id: String,
pub thread_id: Option<AgentThreadId>,
pub codex_turn_id: Option<CodexTurnId>,
pub payload: RawTraceEventPayload,
}7.2 调用对象
reducer 首次遇到 ToolCallStarted 时创建一个 ToolCall。它同时保存模型可见 call/output item ids、canonical invocation payload、caller-facing result payload 和 runtime payload ids。这个对象不是聊天历史的一行,而是连接多个观察对象的索引。
源码位置:codex-rs/rollout-trace/src/model/runtime.rs :: ToolCall
pub struct ToolCall {
pub tool_call_id: ToolCallId,
pub mcp_call_id: Option<McpCallId>,
pub model_visible_call_id: Option<ModelVisibleCallId>,
pub code_mode_runtime_tool_id: Option<CodeModeRuntimeToolId>,
pub thread_id: AgentThreadId,
pub started_by_codex_turn_id: Option<CodexTurnId>,
pub execution: ExecutionWindow,
pub requester: ToolCallRequester,
pub kind: ToolCallKind,
pub model_visible_call_item_ids: Vec<ConversationItemId>,
pub model_visible_output_item_ids: Vec<ConversationItemId>,
pub terminal_operation_id: Option<TerminalOperationId>,
pub summary: ToolCallSummary,
pub raw_invocation_payload_id: Option<RawPayloadId>,
pub raw_result_payload_id: Option<RawPayloadId>,
pub raw_runtime_payload_ids: Vec<RawPayloadId>,
}7.3 结束与扩展
end_tool_call 更新 execution 的结束时间、seq、status 和 result payload。若已有 runtime payload,terminal operation 会由 runtime end 负责结束;若没有 runtime payload,direct result 可以直接结束 terminal operation。这一条件避免同一个 exec 既被 dispatch result 结束一次,又被 runtime end 结束第二次。
源码位置:codex-rs/rollout-trace/src/reducer/tool.rs :: TraceReducer::end_tool_call
let (terminal_operation_id, thread_id, end_terminal_from_result) = {
let Some(tool_call) = self.rollout.tool_calls.get_mut(&tool_call_id) else {
bail!("tool call end referenced unknown call {tool_call_id}");
};
tool_call.execution.ended_at_unix_ms = Some(wall_time_unix_ms);
tool_call.execution.ended_seq = Some(seq);
tool_call.execution.status = status.clone();
tool_call.raw_result_payload_id = result_payload
.as_ref()
.map(|payload| payload.raw_payload_id.clone());
(
tool_call.terminal_operation_id.clone(),
tool_call.thread_id.clone(),
tool_call.raw_runtime_payload_ids.is_empty(),
)
};
if end_terminal_from_result && let Some(operation_id) = terminal_operation_id {
self.end_terminal_operation(
seq,
wall_time_unix_ms,
&thread_id,
&operation_id,
status,
result_payload.as_ref(),
)?;
}8. 非正常路径
8.1 Early return
unknown tool 和 payload kind mismatch 都在 handler 前返回,但因为 ToolDispatchTrace::start 已经执行,reducer 会得到 ToolCallStarted → ToolCallEnded(Failed)。结果 payload 是 Error,而不是 DirectResponse;这使诊断者能区分“没有找到工具”与 “handler 执行后失败”。
8.2 Trace写入失败
trace crate 的 write_json_payload_best_effort 和 append_with_context_best_effort 都把 IO 错误转成 warning。于是可能出现 调用已经成功、但 invocation/result payload ref 为 None,或者 raw event 缺少某条观察。trace 是诊断材料,不是工具业务事务; 不能因为 trace bundle 缺一行,就推断 handler 没有执行。
源码位置:codex-rs/rollout-trace/src/tool_dispatch.rs :: write_json_payload_best_effort
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
}
}
}8.3 Replay错误
reducer 对未知 tool call、重复 start、重复 MCP correlation、缺失 payload 和不一致 terminal operation 会返回错误。严格失败 的目的是不生成一个看似完整但关系错误的 RolloutTrace。这与生产写入的 best effort 是两个阶段:写入阶段尽量不影响用户, 读取阶段拒绝静默修复关键身份关系。
源码位置:codex-rs/rollout-trace/src/reducer/mod.rs :: replay_bundle
for (line_index, line) in BufReader::new(event_log).lines().enumerate() {
let line = line.with_context(|| format!("read trace event line {}", line_index + 1))?;
if line.trim().is_empty() {
continue;
}
let event: RawTraceEvent = serde_json::from_str(&line)
.with_context(|| format!("parse trace event line {}", line_index + 1))?;
reducer.apply_event(event)?;
}
reducer.resolve_pending_spawn_edge_fallbacks()?;
Ok(reducer.rollout)9. 测试验证
9.1 来源身份
dispatch_lifecycle_trace_records_direct_and_code_mode_requesters 构造一个 Direct invocation 和一个 Code Mode invocation, 把两者写入同一个临时 bundle,然后 replay。断言 Direct 调用保留 model-visible id 和 Model requester;Code Mode 调用不保留 model-visible id,而保留 runtime tool id,并通过 code_cell:call-code 连接到 cell。
源码位置:codex-rs/core/src/tools/tool_dispatch_trace_tests.rs :: dispatch_lifecycle_trace_records_direct_and_code_mode_requesters
assert_eq!(
replayed.tool_calls["direct-call"].requester,
ToolCallRequester::Model,
);
assert_eq!(
replayed.tool_calls["code-mode-call"].model_visible_call_id,
None,
);
assert_eq!(
replayed.tool_calls["code-mode-call"].code_mode_runtime_tool_id,
Some("tool-1".to_string()),
);
assert_eq!(
replayed.tool_calls["code-mode-call"].requester,
ToolCallRequester::CodeCell {
code_cell_id: "code_cell:call-code".to_string(),
},
);这个测试证明两套身份投影和 result payload 都能通过 replay 找回,不证明真实 Code Mode host 的调度时序,也不证明模型 可见 transcript 一定与 runtime event 同时到达。
9.2 失败配对
dispatch_lifecycle_trace_records_unsupported_tool_failures 使用空 Registry 调用不存在的工具。测试输入是 Direct、空参数和 missing_tool;断言 dispatch 返回 RespondToModel,replay 后对应 ToolCall.execution.status 为 Failed,且仍有 raw result payload。这验证了 early return 也拥有完整 trace 终态。
源码位置:codex-rs/core/src/tools/tool_dispatch_trace_tests.rs :: dispatch_lifecycle_trace_records_unsupported_tool_failures
assert!(matches!(result, Err(FunctionCallError::RespondToModel(_))));
let replayed = codex_rollout_trace::replay_bundle(single_bundle_dir(temp.path())?)?;
let tool_call = &replayed.tool_calls["unsupported-call"];
assert_eq!(tool_call.execution.status, ExecutionStatus::Failed);
assert!(tool_call.raw_result_payload_id.is_some());9.3 运行时关系
rollout-trace 的 terminal reducer 测试手工写入 canonical start、runtime start、runtime end 和 dispatch result,断言一个 ToolCall 同时拥有 invocation/result/runtime payload,并只生成一个 terminal operation;write_stdin 测试进一步验证 后续操作复用已有 terminal session,而不是为每次输入重新创建 session。
源码位置:codex-rs/rollout-trace/src/reducer/tool/terminal_tests.rs :: write_stdin_operation_reuses_existing_terminal_session
assert_eq!(
rollout.terminal_operations[&stdin_operation_id]
.terminal_id,
Some("pty-1".to_string()),
);
assert_eq!(
rollout.terminal_sessions["pty-1"].operation_ids,
vec![startup_operation_id, stdin_operation_id],
);这个测试证明 reducer 的 terminal join key 和 session lifetime;不证明 OS 进程仍然存活,也不证明 model-visible output 中的 字节与 runtime stdout 完全相同,因为两者之间还可能存在格式化和截断。
10. 阅读实践
在源码仓库根目录运行下面的测试,可以先验证 Core 适配器的身份和失败配对,再验证 rollout-trace 的 runtime reducer:
RUST_MIN_STACK=16777216 cargo test -p codex-core tool_dispatch_trace
RUST_MIN_STACK=16777216 cargo test -p codex-rollout-trace tool_dispatch
RUST_MIN_STACK=16777216 cargo test -p codex-rollout-trace exec_tool_reduces_to_terminal_operation_and_session
RUST_MIN_STACK=16777216 cargo test -p codex-rollout-trace write_stdin_operation_reuses_existing_terminal_session然后用三个场景复述调用链:
- 一个不存在的 Direct tool:它为什么仍有
ToolCallStarted,错误结果保存在哪个 payload kind,最终 status 如何进入RolloutTrace.tool_calls? - 一个
exec_command:dispatch result、ExecCommandBegin/End和 terminal operation 各自拥有什么身份,哪个时间窗口能回答 “进程何时启动”? - 一个 Code Mode nested call:为什么
model_visible_call_id可以为空,code_mode_runtime_tool_id与 reducer 的CodeCellId又分别属于哪一层?
如果能从 ToolRegistry::dispatch_any_with_terminal_outcome 走到 ToolDispatchTrace::start、再走到 TraceWriter::write_json_payload/append_with_context,最后说明 TraceReducer::start_tool_call 与 end_tool_runtime_observation 如何合并对象,就已经掌握了这套追踪模型。接下来可以回看已执行调用追踪, 对比“给下一次模型请求的 attempted metadata”和“供诊断重放的 rollout trace”在所有者、消费者与可靠性上的差异。
