CodeMode工具调用桥接
Code Mode 允许 JavaScript 写出 await tools.read_file(...) 一类调用,但 V8 并不直接拥有 Codex 工具。V8 只负责把参数转换成 JSON、创建 Promise,并发出一个带运行时 ID 的事件;真正的工具仍由当前 Turn 的 ToolCallRuntime 执行。执行结果再沿相反方向返回,最终找到 V8 中保存的 PromiseResolver。
这条链横跨五个边界:工具定义快照、V8 runtime、CellActor、host transport、Codex Core。理解它的关键不是记住某个 callback,而是分清每一层负责哪种所有权:V8 拥有 Promise,CellActor 拥有 callback task,transport 拥有跨进程关联,Core Turn 拥有工具执行上下文。
本文承接CellActor与执行队列和SessionRuntime与状态保持。前者解释 cell 的事件循环与收尾顺序,后者解释跨 cell 状态;本文只追踪嵌套工具与 notify(),不展开普通工具 handler 的业务实现。
下面先看一次工具调用的完整往返。图中的“transport”可能是进程/WebSocket 上的 V1 协议,也可能是 gRPC tool subscription;两条路线最终都调用同一个 CodeModeSessionDelegate。
往返并不是一条同步栈。每次跨层都通过 event、channel、future 或 RPC 解除调用栈绑定,因此取消也必须在每一层拥有自己的撤销点。
1. 定义先于调用
JavaScript 能调用哪些工具,在 cell 启动前就已经固定。CodeModeExecuteHandler 从当前注册表拿到允许嵌套调用的工具,并构造 ExecuteRequest.enabled_tools。运行时随后依据这份定义创建 tools 全局对象,所以 callback 中的工具索引不是动态查询 Core 注册表得到的。
源码位置:codex-rs/core/src/tools/code_mode/execute_handler.rs :: CodeModeExecuteHandler::execute
let mut enabled_tools = Vec::with_capacity(self.nested_tool_specs.len());
for (spec, cached_runtime) in &self.nested_tool_specs {
if let Some(cached_definitions) = cached_runtime
.as_ref()
.and_then(|runtime| runtime.cached_code_mode_definitions())
{
enabled_tools.extend_from_slice(cached_definitions);
continue;
}
let definitions =
codex_tools::collect_code_mode_tool_definitions(std::iter::once(spec.as_ref()));
enabled_tools.extend(definitions.into_iter().map(|mut definition| {
definition.input_schema = None;
definition.output_schema = None;
definition
}));
}
enabled_tools.sort_by(|left, right| left.name.cmp(&right.name));
enabled_tools.dedup_by(|left, right| left.name == right.name);这里有三个容易忽略的事实。
第一,提供 cached_code_mode_definitions() 的 runtime 可以直接复用缓存定义;没有缓存的普通 ToolSpec 会重新转换,并删除输入、输出 schema。因而不能假设传入 runtime 的每个定义都携带 schema。
第二,排序和去重都按导出名称进行。运行时最终只看到每个名称的一份定义;如果同名定义的 kind 或说明不同,这段代码并没有做语义合并,注册阶段就应避免制造这种冲突。
第三,这份列表属于一次 ExecuteRequest。gRPC 客户端还会把它记录到 execution 状态中,后续只接受名称与 kind 都出现在快照里的回调。也就是说,“V8 暴露了什么”和“远端回调被允许调用什么”来自同一份执行级输入。
2. Promise只占位
每个导出的工具函数都绑定到 tool_callback。callback data 保存工具在 enabled_tools 中的索引;真正的调用参数仍来自 JavaScript 的第一个实参。
源码位置:codex-rs/code-mode-runtime/src/runtime/callbacks.rs :: tool_callback
let input = if args.length() == 0 {
Ok(None)
} else {
v8_value_to_json(scope, args.get(0))
};
let input = match input {
Ok(input) => input,
Err(error_text) => {
throw_type_error(scope, &error_text);
return;
}
};
let Some(resolver) = v8::PromiseResolver::new(scope) else {
throw_type_error(scope, "failed to create tool promise");
return;
};
let promise = resolver.get_promise(scope);参数转换在 Promise 创建之前完成。循环引用、无法表示为 JSON 的值或其他转换错误会同步抛出 TypeError,不会留下 pending resolver,也不会触发 host 调用。这一顺序避免了“请求根本没发出,但 Promise 永远等待”的悬挂状态。
callback 随后读取工具元数据,分配 tool-N,保存 resolver,再向 runtime event channel 发送请求。
源码位置:codex-rs/code-mode-runtime/src/runtime/callbacks.rs :: tool_callback
let (tool_name, tool_kind) = {
let Some(state) = scope.get_slot::<RuntimeState>() else {
throw_type_error(scope, "runtime state unavailable");
return;
};
let Some(tool) = state.enabled_tools.get(tool_index) else {
throw_type_error(scope, "tool callback data is out of range");
return;
};
(tool.tool_name.clone(), tool.kind)
};
let Some(state) = scope.get_slot_mut::<RuntimeState>() else {
throw_type_error(scope, "runtime state unavailable");
return;
};
let id = format!("tool-{}", state.next_tool_call_id);
state.next_tool_call_id = state.next_tool_call_id.saturating_add(1);
let event_tx = state.event_tx.clone();
state.pending_tool_calls.insert(id.clone(), resolver);
let _ = event_tx.send(RuntimeEvent::ToolCall {
id,
name: tool_name,
kind: tool_kind,
input,
});
retval.set(promise.into());pending_tool_calls 保存的是 V8 Global<PromiseResolver>,只能由拥有 isolate 的 runtime 线程访问。异步 Rust 任务不会直接触碰 resolver,它们只把 ToolResponse 或 ToolError 送回 runtime 命令通道。这是 V8 线程亲和性与 Tokio 并发之间最重要的隔离线。
3. Actor回调任务
CellActor 收到 RuntimeEvent::ToolCall 后,先登记 pending ID,再为本次调用派生 child cancellation token。工具 callback 与通知 callback 使用两个 JoinSet,因为终止时对它们的收尾策略不同:正常完成允许通知排空,工具调用则最终必须取消并排空。
相关源码:
codex-rs/code-mode-runtime/src/cell_actor/mod.rs :: RuntimeEvent::ToolCallcodex-rs/code-mode-runtime/src/cell_actor/callbacks.rs :: spawn_toolcodex-rs/code-mode-runtime/src/cell_actor/callbacks.rs :: finish_callbacks
RuntimeEvent::ToolCall { id, name, kind, input } => {
pending_tool_call_ids.push(id.clone());
spawn_tool(
&mut tool_tasks,
Arc::clone(&host),
CellToolCall {
id,
name: CellToolName {
name: name.name,
namespace: name.namespace,
},
kind: cell_tool_kind(kind),
input,
},
runtime_tx.clone(),
callback_cancellation_token.child_token(),
task_failure_handler.clone(),
);
}spawn_tool 捕获 delegate future 的 panic,并把三种结果压缩成两种 runtime 命令。无论工具成功、返回错误还是 callback task panic,V8 最终都能得到针对同一个 tool-N 的完成信号。
源码位置:codex-rs/code-mode-runtime/src/cell_actor/callbacks.rs :: spawn_tool
let callback =
AssertUnwindSafe(async move { host.invoke_tool(invocation, cancellation_token).await })
.catch_unwind()
.await;
let (command, failure_reason) = match callback {
Ok(Ok(result)) => (RuntimeCommand::ToolResponse { id, result }, None),
Ok(Err(error_text)) => (RuntimeCommand::ToolError { id, error_text }, None),
Err(_) => {
let failure_reason = "code mode tool task panicked".to_string();
(
RuntimeCommand::ToolError {
id,
error_text: failure_reason.clone(),
},
Some(failure_reason),
)
}
};
let _ = runtime_tx.send(command);panic 被转换为 JavaScript 可观察的 rejection,同时仍报告 task failure;这两个动作不能互相替代。只报告 panic 会让 Promise 悬挂,只 reject Promise 又会掩盖 runtime callback 自身的实现错误。
4. Delegate保留语义
RuntimeCellHost 把 cell 所有权补到工具请求中。V8 只生成 tool-N,它不知道自己的 cell ID;CellActor 的 host 才同时持有 cell ID 与 runtime tool call ID。
源码位置:codex-rs/code-mode-runtime/src/session_runtime/mod.rs :: RuntimeCellHost::invoke_tool
self.inner
.delegate
.invoke_tool(
NestedToolCall {
cell_id: self.cell_id.clone(),
runtime_tool_call_id: invocation.id,
tool_name: invocation.name,
tool_kind: invocation.kind,
input: invocation.input,
},
cancellation_token,
)
.awaitProtocolDelegate 再把 runtime 内部类型转换为公开协议类型。它不路由具体工具,也不决定 Function/Freeform 的 Core payload,只保留名称、kind、输入和两个关联字段。
源码位置:codex-rs/code-mode-runtime/src/service.rs :: ProtocolDelegate::invoke_tool
self.delegate
.invoke_tool(
CodeModeNestedToolCall {
cell_id: protocol_cell_id(&invocation.cell_id),
runtime_tool_call_id: invocation.runtime_tool_call_id,
tool_name: codex_protocol::ToolName {
name: invocation.tool_name.name,
namespace: invocation.tool_name.namespace,
},
tool_kind: match invocation.tool_kind {
runtime::ToolKind::Function => CodeModeToolKind::Function,
runtime::ToolKind::Freeform => CodeModeToolKind::Freeform,
},
input: invocation.input,
},
cancellation_token,
)
.await这种窄接口让 host 内部 runtime、V1 client driver 与 gRPC client 都能复用同一个 Core delegate。transport 可以改变关联方式和消息格式,但不能丢失 cell_id、runtime_tool_call_id、工具 kind 与取消 token。
5. Turn工具运行时
到达 Core 后,请求先进入 CodeModeDispatchBroker,而不是直接调用全局工具注册表。每个 Code Mode Turn 启动一个 worker,并用该 Turn 的 StepContext 与 diff tracker 构造独立 ToolCallRuntime。
源码位置:codex-rs/core/src/tools/code_mode/delegate.rs :: CodeModeDispatchBroker::start_turn_worker
let tool_runtime = ToolCallRuntime::new(Arc::clone(&exec.session), step_context, tracker);
let host = Arc::new(CoreTurnHost { exec, tool_runtime });
let dispatch_rx = self.dispatch_rx.clone();
let dispatch_gates = Arc::clone(&self.dispatch_gates);
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();ToolCallRuntime 不能做成整个 Session 共享的单例,因为它携带当前 Step 的 turn、取消域、工具环境和 diff tracker。嵌套工具虽然由另一个进程里的 JavaScript 发起,语义上仍属于启动该 code cell 的当前 Turn。
worker 接收两类消息。通知在 worker 中顺序注入当前 active Turn;工具调用越过 gate 后另起 Tokio task,所以多个嵌套工具可以并发执行,不会用一个慢工具阻塞整个 broker 接收循环。
源码位置:codex-rs/core/src/tools/code_mode/delegate.rs :: DispatchMessage handling
match message {
DispatchMessage::Notify {
call_id,
cell_id,
text,
cancellation_token,
response_tx,
} => {
let response = if wait_until_cell_ready_for_dispatch(
&dispatch_gates,
&cell_id,
&cancellation_token,
)
.await
{
host.notify(call_id, cell_id, text).await
} else {
remove_dispatch_gate(&dispatch_gates, &cell_id);
Err("code mode notification cancelled".to_string())
};
let _ = response_tx.send(response);
}
DispatchMessage::InvokeTool {
invocation,
cancellation_token,
response_tx,
} => {
let cell_id = invocation.cell_id.clone();
if !wait_until_cell_ready_for_dispatch(
&dispatch_gates,
&cell_id,
&cancellation_token,
)
.await
{
remove_dispatch_gate(&dispatch_gates, &cell_id);
continue;
}
let host = Arc::clone(&host);
tokio::spawn(async move {
let invocation = host.invoke_tool(invocation, cancellation_token.clone());
tokio::pin!(invocation);
let response = tokio::select! {
biased;
_ = cancellation_token.cancelled() => invocation.await,
response = &mut invocation => response,
};
let _ = response_tx.send(response);
});
}
}这里的 gate 解决的是一个启动竞态:host 可能已经开始运行 cell,并很快发出 callback,但 Core 还没有完成 cell 注册、trace 和执行状态发布。mark_cell_ready_for_dispatch 把对应 watch 值改为 true 后,callback 才能进入当前 Turn。
源码位置:codex-rs/core/src/tools/code_mode/delegate.rs :: wait_until_cell_ready_for_dispatch
let mut ready_rx = dispatch_gate(dispatch_gates, cell_id).subscribe();
loop {
if *ready_rx.borrow_and_update() {
return true;
}
tokio::select! {
changed = ready_rx.changed() => {
if changed.is_err() {
return false;
}
}
_ = cancellation_token.cancelled() => return false,
}
}gate 是按 cell 建立的,而 worker 是按 Turn 建立的。cell 关闭或等待期间取消时,gate 会被移除;否则旧 cell 的 ready 状态可能错误放行后来到达的迟到 callback。
6. 通知没有返回值
notify(value) 与工具调用共用 CellActor 和 delegate 边界,但它不是一个返回 JSON 的工具。V8 callback 把值序列化成非空文本,发送 RuntimeEvent::Notify,然后立即返回 undefined。
源码位置:codex-rs/code-mode-runtime/src/runtime/callbacks.rs :: notify_callback
let text = match serialize_output_text(scope, value) {
Ok(text) => text,
Err(error_text) => {
throw_type_error(scope, &error_text);
return;
}
};
if text.trim().is_empty() {
throw_type_error(scope, "notify expects non-empty text");
return;
}
if let Some(state) = scope.get_slot::<RuntimeState>() {
let _ = state.event_tx.send(RuntimeEvent::Notify {
call_id: state.tool_call_id.clone(),
text,
});
}
retval.set(v8::undefined(scope).into());Core 使用外层 Code Mode exec 的 call_id,把文本注入当前仍在运行的 Turn,形成 CustomToolCallOutput。它不会生成新的 Core 工具 call ID,也不会回填 V8 resolver。
源码位置:codex-rs/core/src/tools/code_mode/delegate.rs :: CoreTurnHost::notify
self.exec
.session
.inject_if_running(vec![ResponseItem::CustomToolCallOutput {
id: None,
call_id,
name: Some(PUBLIC_TOOL_NAME.to_string()),
output: FunctionCallOutputPayload::from_text(text),
internal_chat_message_metadata_passthrough: None,
}])
.await
.map_err(|_| {
format!("failed to inject exec notify message for cell {cell_id}: no active turn")
})因此两条路径的完成语义不同:工具调用必须把 JSON 或错误送回 Promise;通知只确认文本是否交付到 active Turn。Turn 已结束时,通知失败不能伪装成 JavaScript 工具结果。
7. Core重新建模
call_nested_tool 是桥接进入普通工具系统的最后一步。它首先拒绝 Code Mode exec 自调用,防止脚本通过嵌套调用再次创建 Code Mode cell,形成无法控制的递归执行树。
源码位置:codex-rs/core/src/tools/code_mode/mod.rs :: call_nested_tool
let CodeModeNestedToolCall {
cell_id,
runtime_tool_call_id,
tool_name,
tool_kind,
input,
} = invocation;
if is_exec_tool_name(&tool_name) {
return Err(FunctionCallError::RespondToModel(format!(
"{PUBLIC_TOOL_NAME} cannot invoke itself"
)));
}
let payload = match build_nested_tool_payload(tool_kind, &tool_name, input) {
Ok(payload) => payload,
Err(error) => return Err(FunctionCallError::RespondToModel(error)),
};Function 与 Freeform 不是两种序列化风格,而是两种 Core ToolPayload。Function 缺省输入会变成 {},且非对象 JSON 会被拒绝;Freeform 只接受 JSON string。
源码位置:codex-rs/core/src/tools/code_mode/mod.rs :: build_nested_tool_payload
fn serialize_function_tool_arguments(
tool_name: &ToolName,
input: Option<JsonValue>,
) -> Result<String, String> {
match input {
None => Ok("{}".to_string()),
Some(JsonValue::Object(map)) => serde_json::to_string(&JsonValue::Object(map))
.map_err(|err| format!("failed to serialize tool `{tool_name}` arguments: {err}")),
Some(_) => Err(format!(
"tool `{tool_name}` expects a JSON object for arguments"
)),
}
}
fn build_freeform_tool_payload(
tool_name: &ToolName,
input: Option<JsonValue>,
) -> Result<ToolPayload, String> {
match input {
Some(JsonValue::String(input)) => Ok(ToolPayload::Custom { input }),
_ => Err(format!("tool `{tool_name}` expects a string input")),
}
}payload 合法后,Core 创建全新的 UUID call ID,并把 V8 的 tool-N 放入 ToolCallSource::CodeMode。普通工具生命周期、Hook、审批、事件与输出记录使用 Core call ID;调试桥接关系时再从 source 找回 cell 与 runtime ID。
源码位置:codex-rs/core/src/tools/code_mode/mod.rs :: call_nested_tool
let call = ToolCall {
tool_name: tool_name.with_default_namespace(),
call_id: format!("{PUBLIC_TOOL_NAME}-{}", uuid::Uuid::new_v4()),
payload,
encrypted_function_args: None,
};
let result = tool_runtime
.handle_tool_call_with_source(
call,
ToolCallSource::CodeMode {
cell_id: cell_id.to_string(),
runtime_tool_call_id,
},
cancellation_token,
)
.await?;
Ok(result.code_mode_result())返回值调用 code_mode_result(),而不是直接复用模型可见的 FunctionCallOutput。这是一个工具级投影接口:例如 Unified Exec 可以返回 session_id、exit_code、wall_time_seconds 与输出文本组成的 JSON 对象;其他工具可以提供适合 JavaScript 继续计算的结构。脚本得到的是工具定义的 Code Mode 结果,不应假设它等于普通对话历史中的文本。
8. 五类标识
一次看似简单的 await tools.name() 至少涉及五类关联值。混淆它们是阅读日志与排查迟到结果时最常见的错误。
| 标识 | 产生位置 | 作用域 | 用途 |
|---|---|---|---|
外层 call_id | 模型调用 Code Mode exec 时 | 当前 Turn | 关联 notify() 注入的 CustomToolCallOutput |
cell_id | Code Mode host 启动 cell 时 | logical session;重连后含 generation | 关联执行、等待、关闭与 callback 所有权 |
tool-N | V8 tool_callback | 单个 runtime/cell | 查找 PromiseResolver,并写入 runtime_tool_call_id |
| transport invocation ID | V1 driver 或 gRPC host | 单条连接/会话 | 匹配跨进程 delegate request 与 completion |
| Core UUID call ID | call_nested_tool | 当前 Turn 工具生命周期 | 驱动普通工具事件、Hook、审批与记录 |
Core UUID 不会替换 tool-N:前者用于 Core 工具系统,后者必须原样回到 V8。transport ID 也不能充当 cell ID,因为同一 cell 可以并发发出多个调用。
9. 两套传输
9.1 V1回调驱动
V1 进程/WebSocket 路径通过 DelegateRequest 与 DelegateResponse 往返。client driver 先用 session 与 wire cell ID 找到 delegate target,再为工具或通知创建独立 task。工具成功转换为 ToolResult,通知成功只转换为 NotificationDelivered。
源码位置:codex-rs/code-mode/src/remote_session/connection/driver/delegate_runtime.rs :: DelegateRuntime::start
let task_request = match request {
DelegateRequest::InvokeTool { invocation } => {
let mut invocation: CodeModeNestedToolCall = invocation.into();
invocation.cell_id = target.cell_id.clone();
DelegateTask::InvokeTool(invocation)
}
DelegateRequest::Notify {
call_id,
cell_id: _,
text,
} => DelegateTask::Notify {
call_id,
cell_id: target.cell_id.clone(),
text,
},
};
let delegate_task = tokio::spawn(async move {
match task_request {
DelegateTask::InvokeTool(invocation) => delegate
.invoke_tool(invocation, task_cancellation)
.await
.map(|result| DelegateResponse::ToolResult { result }),
DelegateTask::Notify { call_id, cell_id, text } => delegate
.notify(call_id, cell_id, text, task_cancellation)
.await
.map(|()| DelegateResponse::NotificationDelivered),
}
});每个 active delegate call 同时保存业务 cancellation 与 completion_stop。cell 关闭时 revoke() 会取消 delegate future,并阻止已经迟到的 task completion 再向 host 发送 response。取消的目标不只是“尽量让工具停下”,还包括“禁止失效结果重新进入已关闭 cell”。
9.2 gRPC回调订阅
gRPC 路径把工具请求放在独立 subscription stream 中,completion 则通过 CompleteToolCall RPC 返回。收到请求后,client 先经过 SessionState::admit_invocation,再启动 delegate callback。
源码位置:codex-rs/code-mode/src/grpc_session/state.rs :: SessionState::admit_invocation
let invocation_id = Uuid::parse_str(&call.invocation_id)
.map_err(|_| "code-mode tool invocation ID must be a UUID".to_string())?;
if self.invocations.contains_key(&call.invocation_id)
|| self.seen_invocations.contains(&invocation_id)
{
return Err("code-mode tool invocation ID was reused".to_string());
}
self.check_cell_ownership(&call.execution_id, &call.cell_id)?;
let Some(execution) = self.executions.get_mut(&call.execution_id) else {
self.seen_invocations.remember(invocation_id);
self.cancelled_invocations.remove(&invocation_id);
return Ok(CallbackAdmission::Closed);
};
execution.accept_cell(&call.cell_id)?;
let execution_closed = execution.closed;
self.seen_invocations.remember(invocation_id);
let invocation_cancelled = self.cancelled_invocations.remove(&invocation_id);
if execution_closed {
return Ok(CallbackAdmission::Closed);
}
if invocation_cancelled {
return Ok(CallbackAdmission::Cancelled);
}
let Some(name) = call.tool_name.as_ref() else {
return Ok(CallbackAdmission::Rejected(
"code-mode tool invocation omitted its tool name".to_string(),
));
};
let tool_name =
ToolName::new(name.namespace.clone(), name.name.clone()).with_default_namespace();
if execution.enabled_tools.get(&tool_name) != Some(&call.tool_kind) {
return Ok(CallbackAdmission::Rejected(format!(
"code-mode tool {tool_name} is not enabled for this execution"
)));
}
if self.invocations.len() + self.notifications >= MAX_PENDING_DELEGATE_CALLS {
return Ok(CallbackAdmission::Rejected(
"code-mode host exceeded its pending delegate callback limit".to_string(),
));
}
let cancellation = CancellationToken::new();
self.invocations.insert(
call.invocation_id.clone(),
ActiveCallback {
execution_id: call.execution_id.clone(),
cancellation: cancellation.clone(),
},
);
Ok(CallbackAdmission::Active(cancellation))admission 同时检查 invocation UUID 是否复用、cell 是否属于对应 execution、工具名称和 kind 是否匹配执行快照,以及全局 pending callback 上限。通过后才创建 cancellation token。远端 host 不能只凭一个合法工具名绕过当前 execution 的可用工具集合。
回调 completion 也受取消保护。若 ToolCallCancelled、cell close 或 session stop 已撤销 token,迟到的 delegate 结果不会发出 CompleteToolCall。
源码位置:codex-rs/code-mode/src/grpc_session/callbacks.rs :: SessionInner::complete_tool_call
let request = completion::request(&self.id, &invocation_id, result);
let mut client = self.client();
tokio::select! {
biased;
_ = cancellation.cancelled() => {}
result = deadline::request(
self,
"tool invocation completion",
Duration::ZERO,
client.complete_tool_call(request),
) => {
if let Err(error) = result
&& !cancellation.is_cancelled()
&& !self.stopped.is_cancelled()
{
self.fail(error);
}
}
}
self.state
.lock()
.unwrap_or_else(PoisonError::into_inner)
.finish_invocation(&invocation_id);gRPC 重连还引入 generation。第二代及之后的 binding 会把公开 cell ID 改为 gN:<wire-id>;工具回调、通知、关闭事件与 wait 结果都经过相同映射。把旧 generation 的 cell ID 传给新 binding 会被拒绝,而不是误操作一个恰好复用了相同 wire ID 的新 cell。
源码位置:codex-rs/code-mode/src/grpc_session/generation.rs :: GenerationDelegate, remote_cell_id
fn invoke_tool<'a>(
&'a self,
mut invocation: CodeModeNestedToolCall,
cancellation_token: CancellationToken,
) -> ToolInvocationFuture<'a> {
invocation.cell_id = public_cell_id(self.generation, &invocation.cell_id);
self.delegate.invoke_tool(invocation, cancellation_token)
}
fn public_cell_id(generation: u64, cell_id: &CellId) -> CellId {
if generation == 1 {
cell_id.clone()
} else {
CellId::new(format!("g{generation}:{cell_id}"))
}
}
pub(super) fn remote_cell_id(generation: u64, cell_id: &CellId) -> Result<CellId, String> {
if generation == 1 {
return Ok(cell_id.clone());
}
let prefix = format!("g{generation}:");
cell_id
.as_str()
.strip_prefix(&prefix)
.map(|cell_id| CellId::new(cell_id.to_string()))
.ok_or_else(|| "cell belongs to a stale code-mode host generation".to_string())
}10. 结果回到V8
Core 返回的 JSON 经过 transport 与 CellActor 后,被包装为 RuntimeCommand::ToolResponse;任一层返回的错误文本则成为 ToolError。runtime 线程消费命令后,才重新进入 V8 isolate。
相关源码:
codex-rs/code-mode-runtime/src/runtime/mod.rs :: RuntimeCommand::ToolResponse, RuntimeCommand::ToolErrorcodex-rs/code-mode-runtime/src/runtime/module_loader.rs :: resolve_tool_response
RuntimeCommand::ToolResponse { id, result } => {
resolve_tool_response(&mut isolate, &id, Ok(result))?;
}
RuntimeCommand::ToolError { id, error_text } => {
resolve_tool_response(&mut isolate, &id, Err(error_text))?;
}resolve_tool_response 先从 map 移除 resolver,再 resolve 或 reject。先移除意味着同一个 ID 只能完成一次;未知 ID 会升级为 runtime error,而不是吞掉重复或乱序 completion。
源码位置:codex-rs/code-mode-runtime/src/runtime/module_loader.rs :: resolve_tool_response
let resolver = {
let state = scope
.get_slot_mut::<RuntimeState>()
.ok_or_else(|| "runtime state unavailable".to_string())?;
state.pending_tool_calls.remove(id)
.ok_or_else(|| format!("unknown tool call `{id}`"))?
};
match response {
Ok(result) => {
let value = json_to_v8(&mut tc, &result)
.ok_or_else(|| "failed to serialize tool response".to_string())?;
resolver.resolve(&tc, value);
}
Err(error_text) => {
let value = v8::String::new(&tc, &error_text)
.ok_or_else(|| "failed to allocate tool error".to_string())?;
resolver.reject(&tc, value.into());
}
}即使 Core 工具已经成功,JSON 到 V8 的转换仍可能失败;这种失败不能 resolve 一个残缺值。相反,resolver 已被取出,runtime 将进入错误收尾,避免脚本继续使用不可信的结果。
11. 取消逐层收口
这条桥接没有单一“总取消开关”,而是层层派生 token,并在各自拥有的资源边界执行撤销:
| 层级 | 取消对象 | 取消后的约束 |
|---|---|---|
| CellActor | notification/tool callback child token | 停止 host callback,并排空 JoinSet |
| Core broker | gate wait 与 oneshot response | 未 ready 的 cell 不得进入当前 Turn |
| ToolCallRuntime | 普通工具 future | 工具按现有 Core 取消语义收尾 |
| V1 driver | delegate task 与 completion event | cell close 后不发送迟到 response |
| gRPC client | invocation token 与 completion RPC | 已取消 callback 不发送 completion |
| V8 runtime | pending resolver map | 只接受一次匹配 ID 的完成 |
CellActor 的收尾顺序体现了通知与工具的差异。正常完成可以先排空通知;取消时立即撤销 callback token。无论哪种模式,工具 task drain 前都会确保 token 已取消。
源码位置:codex-rs/code-mode-runtime/src/cell_actor/callbacks.rs :: finish_callbacks
if matches!(completion, CallbackCompletion::Cancel) {
cancellation_token.cancel();
}
drain_tasks(notification_tasks, "notification", task_failure_handler).await;
cancellation_token.cancel();
drain_tasks(tool_tasks, "tool", task_failure_handler).await;这解释了为什么“工具 handler 最终返回了”不等于“结果一定会回到 JavaScript”。一旦 cell、execution 或 transport correlation 已撤销,迟到结果必须被丢弃;否则旧执行可能污染新 Turn 或重连后的新 generation。
12. 用测试反推不变量
纯 client/provider 测试可以在不启动 V8 isolate 的情况下验证 transport、generation 与 callback 状态机:
cd codex-rs
cargo test -p codex-code-mode --lib -- --nocapture --test-threads=1invocation_cancellation_revokes_delegate_and_late_completion 先注册 execution 与 cell,再接纳一个工具 callback,随后发送 cancellation。关键断言是 callback token 已取消;之后即使调用 finish_invocation,cell 仍只能按关闭条件完成,迟到 completion 不会恢复已撤销的 invocation。
源码位置:codex-rs/code-mode/src/grpc_session/state_tests.rs :: invocation_cancellation_revokes_delegate_and_late_completion
let CallbackAdmission::Active(cancellation) = state
.admit_invocation(&invocation)
.expect("accept invocation")
else {
panic!("invocation was not admitted");
};
state
.cancel_invocation(&invocation.invocation_id)
.expect("cancel invocation");
assert!(cancellation.is_cancelled());
state.finish_invocation(&invocation.invocation_id);这项测试证明 gRPC client 状态机会撤销 delegate 并容忍迟到的本地收尾;它不验证具体 Core 工具是否会立刻停止,也不覆盖 V8 Promise 的 reject 行为。
reconnect_maps_every_delegate_callback_to_its_generation 用 generation 2 包装 recording delegate,然后依次发出工具调用、通知和 cell close。三个断言都要求底层 delegate 收到 g2:42,说明 generation 不是只应用于 wait() 的显示层,而是覆盖每一种 delegate callback。
源码位置:codex-rs/code-mode/src/grpc_session/generation_tests.rs :: reconnect_maps_every_delegate_callback_to_its_generation
let delegate = GenerationDelegate {
delegate: recording.clone(),
generation: 2,
};
let wire_id = CellId::new("42".to_string());
let public_id = CellId::new("g2:42".to_string());
let invocation = CodeModeNestedToolCall {
cell_id: wire_id.clone(),
runtime_tool_call_id: "runtime-call".to_string(),
tool_name: ToolName::plain("echo"),
tool_kind: CodeModeToolKind::Function,
input: Some(json!({ "value": true })),
};
assert_eq!(
delegate
.invoke_tool(invocation.clone(), CancellationToken::new())
.await,
Ok(json!({ "ok": true }))
);
assert_eq!(
delegate
.notify(
"outer-call".to_string(),
wire_id.clone(),
"notice".to_string(),
CancellationToken::new(),
)
.await,
Ok(())
);
delegate.cell_closed(&wire_id);
assert_eq!(
*recording.calls.lock().expect("calls lock"),
vec![CodeModeNestedToolCall {
cell_id: public_id.clone(),
..invocation
}]
);
assert_eq!(
*recording.notifications.lock().expect("notifications lock"),
vec![(
"outer-call".to_string(),
public_id.clone(),
"notice".to_string()
)]
);
assert_eq!(
*recording.closed.lock().expect("closed cells lock"),
vec![public_id]
);这项测试证明 generation 映射覆盖 callback 入口;配套的 stale_generation_ids_are_rejected_after_reconnection 则断言缺少当前 gN: 前缀的 ID 被拒绝。它们仍不验证网络断连时机或远端 host 的 V8 行为。
沿源码排查问题时,可以用现象反推层级:同步 TypeError 先看 V8 参数转换;Promise 永久 pending 先查 tool-N 是否经过 CellActor 与 transport;工具执行了但脚本没继续,查 completion 是否被 cancellation 丢弃以及 resolver ID 是否匹配;通知没有出现在 Turn 中,则查 outer call_id、cell gate 与 inject_if_running。这样能避免把所有失败都归结为“工具调用失败”。
下一篇CodeMode等待取消与遥测将从 yielded cell 出发,继续解释 wait、terminate、Turn 中断和 telemetry 如何共同关闭这些 callback 资源。
