按下取消之后,各层怎么收手
工具失败回给模型,用户按 Esc 才停 turn。取消令牌从任务传到采样再传到工具。已完成的结果留在历史里,100 毫秒之后的硬拆不可逆。
本页解决的问题
先给结论「按下取消之后,各层怎么收手」要解决的关键问题是什么?
工具失败回给模型,用户按 Esc 才停 turn。取消令牌从任务传到采样再传到工具。已完成的结果留在历史里,100 毫秒之后的硬拆不可逆。
跟着交接处走,不要只看 Demo。 系统是否可靠,往往取决于模型、工具、状态、权限和人的交接处。把每次交接都当成可以观察、测试和恢复的地方。
为一个自动化步骤写清输入、负责人、审批和恢复动作。
一次运行成功了,却说不清发生了什么,也无法安全重放。
- 协议入口是 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 还在跑,重答一遍。哪一步是不可逆的,为什么已经落盘的文件不会跟着中止事件一起消失。
「先玩一遍 · 按下 Esc,看谁先停」的能力藏在每次交接里
「你让 Codex 改一个测试文件。模型先跑 cargo test ,编译器吐了两屏 rustc 报错,退出码是 1。下一秒对话停了,界面弹出 turn aborted 。很多人第一次写 agent 会这么干:工具返回 Err ,整轮跟着死。模型还没看见 stderr,会话已经结束」说明,Agent 的表现不只由模型决定。模型、上下文、工具、状态、权限和人之间的每次交接,都会改变任务能否继续以及出了问题能否恢复。
先写清状态,再增加能力
从「最浅一层:进程已经跑过。退出码非零、命令超时、沙箱拒绝,都收成工具回执。」出发,可以把流程拆成起始状态、下一步动作、工具返回、状态更新和停止条件。这样调试时找的是第一处丢失信息或权限的位置,而不是笼统地说“模型变笨了”。
- 协议入口是 Interrupt,不杀后台 terminal protocol.rs L546
- 任务令牌先 cancel,这是信号还不是硬拆 tasks/mod.rs L887
- 采样用子令牌盯着流,or_cancel 变成 TurnAborted turn.rs L2273
成功路径不能代表系统可靠
用「再把时刻改成 handler 还在跑,重答一遍。哪一步是不可逆的,为什么已经落盘的文件不会跟着中止事件一起消失」重放一次成功和一次失败,记录每一轮真正传入的上下文、工具结果和负责人;只要第二个人能复述这条链,系统才有可维护性。
从「先玩一遍 · 按下 Esc,看谁先停」走到「思路一 · 工具失败回给模型,引擎失败才停 turn」
「先玩一遍 · 按下 Esc,看谁先停」先把问题落在「同一轮对话,用户中途按 Esc 播放 单步 重置 按 Esc 时 工具还在跑 工具已经跑完 拨到另一档,看历史里成功回执会不会被覆盖,以及哪一步标了不可逆。 收手顺序 等待开始 1 协议入口 Op::Interrupt 到达 2 任务令牌 cancellation_token.cancel 3 采样收手 or_cancel 变成 TurnAborted 4 工具收手 子令牌取消,看 handler 走没走完 5 硬拆…」上;到了「思路一 · 工具失败回给模型,引擎失败才停 turn」,讨论继续推进到「你让 Codex 改一个测试文件。模型先跑 cargo test ,编译器吐了两屏 rustc 报错,退出码是 1。下一秒对话停了,界面弹出 turn aborted 。很多人第一次写 agent 会这么干:工具返回 Err ,整轮跟着死。模型还没看见 stderr,会话已经结束」。两段连起来,重点就不只是记住一个结论,而是看清它成立所依赖的条件。
把这条判断带到下一个场景
分析 Agent 时,沿着状态、动作、工具结果和下一步的顺序走一遍;每次交接都要能说明信息从哪里来、由谁确认、失败时停在哪里。
- 「先玩一遍 · 按下 Esc,看谁先停」:同一轮对话,用户中途按 Esc 播放 单步 重置 按 Esc 时 工具还在跑 工具已经跑完 拨到另一档,看历史里成功回执会不会被覆盖,以及哪一步标了不可逆。 收手顺序 等待开始 1 协议入口 Op::Interrupt 到达 2 任务令牌 cancellation_token.cancel 3 采样收手 or_cancel 变成 TurnAborted 4 工具收手 子令牌取消,看 handler 走没走完 5 硬拆…
- 「思路一 · 工具失败回给模型,引擎失败才停 turn」:你让 Codex 改一个测试文件。模型先跑 cargo test ,编译器吐了两屏 rustc 报错,退出码是 1。下一秒对话停了,界面弹出 turn aborted 。很多人第一次写 agent 会这么干:工具返回 Err ,整轮跟着死。模型还没看见 stderr,会话已经结束
- 「最后的要点」:handler 已走完就保留真实结果,没走完才 abort parallel.rs L182
最后的「最后的要点」把讨论落到「handler 已走完就保留真实结果,没走完才 abort parallel.rs L182」。回看这条线索时,最值得保留的是:当输入、规模或风险改变,哪些判断需要重新做一遍。
我把这篇文章里的一个判断改写成了今天可以验证的小实验。比记住结论更有用的是,知道下一步要观察什么。
读完以后我先回头找它成立的条件,而不是直接把方法搬进项目。这个顺序让后面的取舍清楚很多。
如果把这个判断放到真实工作里,最先需要补的约束是什么?我想知道从阅读到第一次实践之间,哪一步最值得先做。
还没有这篇文章的讨论。