ToolLifecycle事件
工具 lifecycle 事件不是“收到模型调用就必然 start,结束时必然 success”的简单二元回调。当前实现只有在 Registry 找到 runtime 且 payload kind 匹配后才发 start;PreToolUse block、输入改写失败、handler 错误、业务 success:false 和取消分别对应不同 outcome。取消还可能在 dispatch admission 前获胜,因此 Aborted 明确允许 没有匹配 start。
本文面向已经读过ToolOrchestrator执行流程和 ToolRouter解析与分派的读者。本文只研究 extension-facing tool lifecycle, 不展开 Turn/Thread lifecycle,也不把 telemetry trace 当成同一种事件。读完后,读者应能从一个失败现象判断是否 应该出现 start、finish 的 outcome 是什么,以及为什么 PostToolUse feedback 不会回滚已经完成的 handler。
1. 事件契约
1.1 Outcome枚举
extension API 将终态分成四类。Completed.success 来自 ToolOutput 自己的 logging success;Failed 额外记录是否 已经进入 handler;Blocked 专指 handler 前的 host policy block;Aborted 表示 host 取消正常执行。
源码位置:codex-rs/ext/extension-api/src/contributors/tool_lifecycle.rs :: ToolCallOutcome
pub enum ToolCallOutcome {
Completed {
success: bool,
},
Blocked,
Failed {
handler_executed: bool,
},
Aborted,
}这四类不是 Rust Result 的直接翻译。handler 可以正常返回一个 success:false 的 ToolOutput,此时 lifecycle 仍是 Completed { success:false };只有 handler 没有产生正常 output 才是 Failed。
1.2 事件输入
start 和 finish 都携带 session/thread/turn 三层 extension store、turn id、call id、canonical tool name 和 source; finish 额外携带 outcome。Contributor 因而可以按调用身份维护自己的状态,而不必从日志文本反推。
相关源码:
codex-rs/ext/extension-api/src/contributors/tool_lifecycle.rs :: ToolStartInputcodex-rs/ext/extension-api/src/contributors/tool_lifecycle.rs :: ToolFinishInput
pub struct ToolStartInput<'a> {
pub session_store: &'a ExtensionData,
pub thread_store: &'a ExtensionData,
pub turn_store: &'a ExtensionData,
pub turn_id: &'a str,
pub call_id: &'a str,
pub tool_name: &'a ToolName,
pub source: ToolCallSource,
}
pub struct ToolFinishInput<'a> {
pub session_store: &'a ExtensionData,
pub thread_store: &'a ExtensionData,
pub turn_store: &'a ExtensionData,
pub turn_id: &'a str,
pub call_id: &'a str,
pub tool_name: &'a ToolName,
pub source: ToolCallSource,
pub outcome: ToolCallOutcome,
}2. Start门槛
Registry 先增加调用计数,然后查 runtime、检查 payload kind。未知工具返回 RespondToModel,kind mismatch 返回 Fatal;这两个分支都位于 notify_tool_start 之前,因此 lifecycle contributor 不会收到 start/finish。
源码位置:codex-rs/core/src/tools/registry.rs :: dispatch_any_with_terminal_outcome
let tool = match self.tool(&tool_name) {
Some(tool) => tool,
None => {
let message = unsupported_tool_call_message(&invocation.payload, &tool_name);
return Err(FunctionCallError::RespondToModel(message));
}
};
if !tool.matches_kind(&invocation.payload) {
let message = format!("tool {tool_name} invoked with incompatible payload");
return Err(FunctionCallError::Fatal(message));
}
notify_tool_start(&invocation).await;这条门槛让 start 表示“Registry 已接受该调用并准备进入 host policy/handler 流程”,而不是“模型曾经发出某个名字”。
3. Outcome映射
3.1 PreToolUse阻止
PreToolUse 返回 Blocked 时,handler 没有执行,Registry 记录 trace failure,并通过唯一终态 helper 发出 ToolCallOutcome::Blocked。
源码位置:codex-rs/core/src/tools/registry.rs :: dispatch_any_with_terminal_outcome
PreToolUseHookResult::Blocked(message) => {
let err = FunctionCallError::RespondToModel(message);
dispatch_trace.record_failed(&err);
notify_tool_finish_if_unclaimed(
&invocation,
terminal_outcome_reached.as_deref(),
ToolCallOutcome::Blocked,
)
.await;
return Err(err);
}3.2 输入改写失败
Hook 允许继续但返回的 updated_input 无法还原成 ToolInvocation 时,handler 同样没有执行,但这不是 policy block, 所以 outcome 是 Failed { handler_executed:false }。
源码位置:codex-rs/core/src/tools/registry.rs :: dispatch_any_with_terminal_outcome
Err(err) => {
dispatch_trace.record_failed(&err);
notify_tool_finish_if_unclaimed(
&invocation,
terminal_outcome_reached.as_deref(),
ToolCallOutcome::Failed {
handler_executed: false,
},
)
.await;
return Err(err);
}3.3 Handler结果
handler 调用完成后,Registry 根据 result 和 output 的 success_for_logging() 计算 lifecycle outcome:正常 output 始终是 Completed,success 字段可以为 false;handler 返回 Err 或内部没有存入 output,则是 Failed { handler_executed:true }。
源码位置:codex-rs/core/src/tools/registry.rs :: dispatch_any_with_terminal_outcome
let lifecycle_outcome = match &result {
Ok(_) => {
let guard = response_cell.lock().await;
match guard.as_ref() {
Some(result) => ToolCallOutcome::Completed {
success: result.result.success_for_logging(),
},
None => ToolCallOutcome::Failed {
handler_executed: true,
},
}
}
Err(_) => ToolCallOutcome::Failed {
handler_executed: true,
},
};4. 事件投影
4.1 Contributor调用
notify_tool_start 和 notify_tool_finish_parts 顺序遍历 session extension registry 中的 lifecycle contributors,并 await 每个 callback。事件不是 fire-and-forget;慢 contributor 会延长对应 start/finish 阶段。
相关源码:
codex-rs/core/src/tools/lifecycle.rs :: notify_tool_startcodex-rs/core/src/tools/lifecycle.rs :: notify_tool_finish_parts
for contributor in invocation
.session
.services
.extensions
.tool_lifecycle_contributors()
{
contributor
.on_tool_start(ToolStartInput {
session_store: &invocation.session.services.session_extension_data,
thread_store: &invocation.session.services.thread_extension_data,
turn_store: invocation.turn.extension_data.as_ref(),
turn_id: invocation.turn.sub_id.as_str(),
call_id: invocation.call_id.as_str(),
tool_name: &invocation.tool_name,
source: extension_tool_call_source(invocation.source.clone()),
})
.await;
}4.2 Source转换
Core 的 DirectPlaintextMessage 在 extension API 中投影为 Direct,因为明文标记只服务 Core 日志脱敏;Code Mode 则保留 cell id 与 runtime tool call id。
源码位置:codex-rs/core/src/tools/lifecycle.rs :: extension_tool_call_source
match source {
ToolCallSource::Direct | ToolCallSource::DirectPlaintextMessage => {
ExtensionToolCallSource::Direct
}
ToolCallSource::CodeMode {
cell_id,
runtime_tool_call_id,
} => ExtensionToolCallSource::CodeMode {
cell_id,
runtime_tool_call_id,
},
}5. PostToolUse边界
Registry 在 handler 结果返回后运行 PostToolUse,但 lifecycle outcome 在应用 hook feedback/block 之前计算和通知。 因此:
- PostToolUse feedback 可以替换模型可见结果;
- PostToolUse
should_block可以让最终 dispatch 返回RespondToModel; - lifecycle 仍描述 handler 的实际完成/失败,不会改写成
Blocked; - 已发生的外部副作用不会因为 feedback 被回滚。
源码位置:codex-rs/core/src/tools/registry.rs :: dispatch_any_with_terminal_outcome
notify_tool_finish_if_unclaimed(
&invocation,
terminal_outcome_reached.as_deref(),
lifecycle_outcome,
)
.await;
match result {
Ok(_) => {
let mut result = response_cell
.lock()
.await
.take()
.ok_or_else(|| FunctionCallError::Fatal("tool produced no output".to_string()))?;
if let Some(outcome) = post_tool_use_outcome {
if outcome.should_block {
return Err(FunctionCallError::RespondToModel(
outcome.feedback_message.unwrap_or_else(|| {
"PostToolUse hook blocked the tool result".to_string()
}),
));
}
}
Ok(result)
}
Err(err) => Err(err),
}6. 取消配对
6.1 无Start的Aborted
extension API 注释明确指出:取消可能在 dispatch 接受调用前获胜,contributors 不能假设 Aborted 一定有匹配 start。 例如工具仍在 readiness 或 RwLock admission 等待时,取消分支可以 abort task 并直接调用 notify_tool_aborted。
源码位置:codex-rs/ext/extension-api/src/contributors/tool_lifecycle.rs :: ToolCallOutcome::Aborted
/// The host cancelled the tool before normal completion. Cancellation can
/// win before the dispatch path accepts the call, so contributors should not
/// assume a matching start callback exists.
Aborted,6.2 唯一终态
Registry finish 与取消分支共享 terminal_outcome_reached。notify_tool_finish_if_unclaimed 使用 atomic swap 抢占 终态;取消分支也先 load/swap。只有 winner 发送 finish 或 aborted,避免同一 call id 收到两个终态。
源码位置:codex-rs/core/src/tools/registry.rs :: notify_tool_finish_if_unclaimed
if terminal_outcome_reached.is_some_and(|reached| reached.swap(true, Ordering::AcqRel)) {
return false;
}
notify_tool_finish(invocation, outcome).await;
true7. 测试路径
7.1 完成与失败
dispatch_uses_canonical_tool_names_for_lifecycle_contributors 构造两个 runtime:一个正常返回 success:false output,另一个返回 handler error。测试断言前者是 Completed { success:false },后者是 Failed { handler_executed:true },并验证 default namespace 使用 canonical name。
7.2 取消竞争
cancellation_after_handler_finishes_preserves_completed_lifecycle 阻塞 finish callback,在 handler 已完成后触发 取消,最终只记录 Completed { success:true }。cleanup-aware runtime 的 teardown 分支由 handle_tool_call_with_source 中的 wait_for_runtime_cancellation 条件实现,当前没有独立同名测试。
相关测试:
codex-rs/core/src/tools/registry_tests.rs :: dispatch_uses_canonical_tool_names_for_lifecycle_contributorscodex-rs/core/src/tools/parallel.rs :: cancellation_after_handler_finishes_preserves_completed_lifecycle
这些测试证明 canonical identity、Completed/Failed 区分和唯一取消终态;当前没有独立单元测试直接覆盖 Blocked 与 handler_executed:false 两个分支,它们的结论来自显式源码分支。
8. 阅读练习
在 Codex 源码 workspace 中运行:
cargo test -p codex-core dispatch_uses_canonical_tool_names_for_lifecycle_contributors
cargo test -p codex-core cancellation_after_handler_finishes_preserves_completed_lifecycle然后尝试回答:
- 未知工具为什么不会产生 start?payload kind mismatch 又为什么相同?
Completed { success:false }与Failed { handler_executed:true }分别表示什么?- PreToolUse block 与输入 rewrite 失败为什么使用不同 outcome?
- PostToolUse block 为什么不映射成 lifecycle
Blocked? - 为什么 lifecycle consumer 必须接受“Aborted 但没有 start”的序列?
9. 边界
Tool lifecycle 事件描述 host 观察到的调用阶段,不负责:
- ToolOutput 如何写入模型历史或 Code Mode;
- telemetry/rollout trace 的完整字段和持久化;
- Turn/Thread lifecycle、进程 spawn lifecycle 或审批事件;
- handler 外部事务是否真正回滚。
这些事件不证明 provider、远端 MCP server 或 OS 进程已经完成自己的资源清理,也不能证明业务副作用具备事务性; 它们只表达 Codex host 在当前调用链中观察到的阶段和终态。
实现 contributor 时,应以 (turn_id, call_id, canonical tool_name, source) 建立关联,并把 Aborted 视为允许无 start 的 终态。不要把 success:false 自动等同于 Failed,也不要依据最终模型 feedback 倒推 handler 是否执行。
