Skip to content

CodeMode工具调用桥接

从工具定义快照、V8 Promise、CellActor 回调走到 Core 工具运行时,解释 Code Mode 嵌套工具调用的标识、传输、取消与结果回填。

基于rust-v0.150.0
CodexRustExecutionCodeMode

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

rust
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

rust
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

rust
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::ToolCall
  • codex-rs/code-mode-runtime/src/cell_actor/callbacks.rs :: spawn_tool
  • codex-rs/code-mode-runtime/src/cell_actor/callbacks.rs :: finish_callbacks
rust
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

rust
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

rust
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,
    )
    .await

ProtocolDelegate 再把 runtime 内部类型转换为公开协议类型。它不路由具体工具,也不决定 Function/Freeform 的 Core payload,只保留名称、kind、输入和两个关联字段。

源码位置:codex-rs/code-mode-runtime/src/service.rs :: ProtocolDelegate::invoke_tool

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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_idCode Mode host 启动 cell 时logical session;重连后含 generation关联执行、等待、关闭与 callback 所有权
tool-NV8 tool_callback单个 runtime/cell查找 PromiseResolver,并写入 runtime_tool_call_id
transport invocation IDV1 driver 或 gRPC host单条连接/会话匹配跨进程 delegate request 与 completion
Core UUID call IDcall_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

rust
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

rust
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

rust
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

rust
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::ToolError
  • codex-rs/code-mode-runtime/src/runtime/module_loader.rs :: resolve_tool_response
rust
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

rust
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,并在各自拥有的资源边界执行撤销:

层级取消对象取消后的约束
CellActornotification/tool callback child token停止 host callback,并排空 JoinSet
Core brokergate wait 与 oneshot response未 ready 的 cell 不得进入当前 Turn
ToolCallRuntime普通工具 future工具按现有 Core 取消语义收尾
V1 driverdelegate task 与 completion eventcell close 后不发送迟到 response
gRPC clientinvocation token 与 completion RPC已取消 callback 不发送 completion
V8 runtimepending resolver map只接受一次匹配 ID 的完成

CellActor 的收尾顺序体现了通知与工具的差异。正常完成可以先排空通知;取消时立即撤销 callback token。无论哪种模式,工具 task drain 前都会确保 token 已取消。

源码位置:codex-rs/code-mode-runtime/src/cell_actor/callbacks.rs :: finish_callbacks

rust
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 状态机:

text
cd codex-rs
cargo test -p codex-code-mode --lib -- --nocapture --test-threads=1

invocation_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

rust
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

rust
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 资源。