按下取消之后,各层怎么收手
工具失败回给模型,用户按 Esc 才停 turn。取消令牌从任务传到采样再传到工具。已完成的结果留在历史里,100 毫秒之后的硬拆不可逆。
- 协议入口是 Interrupt,不杀后台 terminalprotocol.rs L546
- 任务令牌先 cancel,这是信号还不是硬拆tasks/mod.rs L887
- 采样用子令牌盯着流,or_cancel 变成 TurnAbortedturn.rs L2273
- 工具派发再 child 一次,父令牌取消则一起取消stream_events_utils.rs L319
- handler 已走完就保留真实结果,没走完才 abortparallel.rs L182
- 等 100 毫秒,没收完就 task.handle.aborttasks/mod.rs L913
- 追加 turn_aborted 片段,立刻 flush_rollouttasks/mod.rs L927
- 最后才发 EventMsg::TurnAbortedtasks/mod.rs L955
task.handle.abort 没有回切。已经完成的工具回执也不会被改写成 aborted。后台 terminal 继续跑,要杀它走另一条操作。你让 Codex 改一个测试文件。模型先跑 cargo test,编译器吐了两屏 rustc 报错,退出码是 1。下一秒对话停了,界面弹出 turn aborted。很多人第一次写 agent 会这么干:工具返回 Err,整轮跟着死。模型还没看见 stderr,会话已经结束。
分诊发生在工具回到对话的那道门上,一共三层门槛。
最浅一层:进程已经跑过。退出码非零、命令超时、沙箱拒绝,都收成工具回执。success 写成 true,意思是 handler 跑完了,回执可以喂给模型。命令成不成功写在正文里。
出处:codex-rs/core/src/tools/context.rs 第 344 至 353 行
中间一层:调用没做成。参数坏了、进程没拉起来、apply_patch 上下文对不上,走 RespondToModel。回执 success 才是 false。模型读到文案,自己改再试。
最深一层:payload 对不上、任务 join 失败,才写 Fatal,升成 CodexErr,停 turn。
工具层自己的枚举只有两档。RespondToModel 把字符串喂回模型。Fatal 才升成引擎错误。默认把非 Fatal 折成 Ok。想停对话,得写出 Fatal 或 CodexErr。
use thiserror::Error;
/// Error returned while executing a model-visible tool invocation.
#[derive(Debug, Error, PartialEq)]
pub enum FunctionCallError {
#[error("{0}")]
RespondToModel(String),
#[error("Fatal error: {0}")]
Fatal(String),
}
openai/codex,核对文件 codex-rs/tools/src/function_call_error.rs,commit 4f39251a01,核对日期 2026-08-22。这段枚举本身就是分诊合同:两档,没有第三档警告或重试。cargo test 退出码 1 停在最浅一层,连错误枚举的门槛都没迈过。沙箱拒绝同样走 Ok。代理拦请求时给命令进程回 HTTP 403,也停在最浅一层。模型 API 的 403 才是引擎错误。两处 403 不要并成一档。
出处:codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs 第 384 至 411 行;codex-rs/network-proxy/src/responses.rs 第 76 至 83 行
handler 里一个 IO 错误如果直接冒到 turn 循环,整轮对话跟着死。默认回灌,想停必须显式写出。这条线换语言重写也用得上。自己做内部 agent,先抄这一档:工具失败回灌,引擎失败停循环。
用户按 Esc,要停的不只是当前那次采样。采样可能还在读流,工具可能正在写文件,后台 terminal 可能已经拉起来了。只杀采样,工具会继续改磁盘。一刀切杀掉所有进程,unified exec 的后台任务也会被误杀。
任务启动时现造一张取消令牌。采样请求用 child_token(),工具派发再 child 一次。父令牌一取消,子令牌一起取消。子令牌自己取消,不影响父令牌。这就是取消树。
出处:codex-rs/core/src/session/turn.rs 第 1399 行;codex-rs/core/src/stream_events_utils.rs 第 319 至 324 行
or_cancel 把谁先到写成固定形状。任意 future 配一张令牌。取消先到,返回 CancelErr,一律变成 TurnAborted。
出处:codex-rs/async-utils/src/lib.rs 第 4 至 31 行;codex-rs/protocol/src/error.rs 第 269 至 273 行
用户按 Esc,协议入口是 Op::Interrupt。合同写死:中止当前任务,不杀后台 terminal。handle_task_abort 先 cancel(),再等 100 毫秒。超时就 task.handle.abort()。令牌先发信号,任务有机会自己收尾。收不完,再硬拆。硬拆这一步不可逆。
出处:codex-rs/protocol/src/protocol.rs 第 546 至 548 行;codex-rs/core/src/tasks/mod.rs 第 66 行、第 880 至 913 行
一份用户意图,多层各自收尾。合作式取消加硬期限,是并发系统的通用形状。Python 里用 asyncio.Event 就能做最小版。不必抄 37 个变体的 CodexErr。
取消发生在工具已经改了文件之后,历史里怎么记。如果把已经完成的输出改写成 aborted by user,模型下一轮会以为写入没做成,可能再打一遍补丁。
工具 future 同时等派发结果和令牌。令牌先到,还要看 handler 是否已经走到终态。已经走完,就保留真实结果。没走完,才 abort,再造一条 AbortedToolOutput,正文是 aborted by user after Xs。
出处:codex-rs/core/src/tools/parallel.rs 第 177 至 206 行、第 243 至 260 行
然后追加一段模型可见标记,包在 <turn_aborted> 里。文案承认两件事:unified exec 可能还在后台跑,被中止的工具可能已经执行了一部分。标记写进历史之后立刻 flush_rollout()。有的客户端收到 TurnAborted 会同步重读 rollout,标记必须先落盘。
出处:codex-rs/core/src/context/turn_aborted.rs 第 1 至 35 行;codex-rs/core/src/tasks/mod.rs 第 920 至 962 行
历史只追加、不改写。中止只往后面加片段,已落盘的工具输出不动。下一轮模型能同时看见完成的输出、被中止工具的回执、以及中止标记。上下文治理的第一条就是这个形状。
出处:AGENTS.md 第 91 至 100 行
DSH:throw 折成 isError,循环 throw 才停 turn
DSH 每轮新建一个 AbortController。工具 body 的 throw 不会穿过循环。执行器把它收成 isError: true 回执,轮次继续。bash 的注释写成产品合同:Non-zero exits are reported, not errored。循环自己的失败才停 turn。用户取消写成 turn/end aborted。
DSH 省掉两档枚举,因为执行器 catch 已经分了模型能看见和引擎必须终止。代价是约定:漏过 dispatchToolBody 的 throw 仍会变成 turn/end error。Codex 用类型把这条路收窄。
Grok:整份 ToolError 回模型,引擎停靠 SamplingError
Grok 的 ToolError 模块头把 detail 写成必须回给模型的说明。Cancelled、Timeout、Execution 都是同一种回灌。工具层没有 Fatal 档。会话层把执行失败收成 tool_result,turn 继续。
引擎停靠另一套类型。SamplingError 不可重试才停采样。Actor 上的 CancellationToken 触发是关机,单次工具取消走 CancelRegistry。令牌不承担 Codex 那种工具分诊。
Esc 落在两种时刻,历史里各留下什么
apply_patch 已经写入文件,成功回执还在飞。这时用户按 Esc。下一轮模型会在历史里看见什么:成功回执、aborted by user、<turn_aborted> 片段,这三样各会不会出现?后台 terminal 还在不在?
再把时刻改成 handler 还在跑,重答一遍。哪一步是不可逆的,为什么已经落盘的文件不会跟着中止事件一起消失。