OpenAI Codex · 取消与错误

按下取消之后,各层怎么收手

工具失败回给模型,用户按 Esc 才停 turn。取消令牌从任务传到采样再传到工具。已完成的结果留在历史里,100 毫秒之后的硬拆不可逆。

课程目标读完能说清三件事。工具失败为什么继续对话。用户按 Esc 之后,任务、采样、工具按什么顺序收手。哪一层的收手一旦落下就回不去。
先玩一遍 · 按下 Esc,看谁先停
同一轮对话,用户中途按 Esc
按 Esc 时
拨到另一档,看历史里成功回执会不会被覆盖,以及哪一步标了不可逆。
收手顺序等待开始
1
协议入口Op::Interrupt 到达
2
任务令牌cancellation_token.cancel
3
采样收手or_cancel 变成 TurnAborted
4
工具收手子令牌取消,看 handler 走没走完
5
硬拆100 毫秒后 task.handle.abort不可逆
6
落盘与事件先写片段,再发 TurnAborted
历史与副作用0 条
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 协议入口是 Interrupt,不杀后台 terminalprotocol.rs L546
  2. 任务令牌先 cancel,这是信号还不是硬拆tasks/mod.rs L887
  3. 采样用子令牌盯着流,or_cancel 变成 TurnAbortedturn.rs L2273
  4. 工具派发再 child 一次,父令牌取消则一起取消stream_events_utils.rs L319
  5. handler 已走完就保留真实结果,没走完才 abortparallel.rs L182
  6. 等 100 毫秒,没收完就 task.handle.aborttasks/mod.rs L913
  7. 追加 turn_aborted 片段,立刻 flush_rollouttasks/mod.rs L927
  8. 最后才发 EventMsg::TurnAbortedtasks/mod.rs L955
点播放,看 Esc 之后各层按什么顺序收手,哪一层不可逆。
收手顺序令牌先发信号,采样和工具各自收尾,最后才硬拆、写片段、发事件。界面上的中止,是这条链走完之后才出现的。
不可逆的那一层100 毫秒之后的 task.handle.abort 没有回切。已经完成的工具回执也不会被改写成 aborted。后台 terminal 继续跑,要杀它走另一条操作。
拨到另一档工具已经跑完时,历史里留下成功回执加中止标记。工具还在跑时,留下 aborted by user 加中止标记。两种都是追加,都不是回滚。
教学示意:步骤时序按源码调用链编排,耗时被拉成可点的单步。逻辑轨迹右侧行号对应 openai/codex 仓库 commit 4f39251a01。
思路一 · 工具失败回给模型,引擎失败才停 turn
它解决什么问题

你让 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。想停对话,得写出 FatalCodexErr

codex-rs/tools/src/function_call_error.rs第 1 至 10 行
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 行

最浅 · 进程已经跑过 cargo test 退出码 1 工具回执 success true 命令成败写在正文里 对话继续 中间 · 调用没做成 patch 上下文对不上 RespondToModel 回执 success false 对话继续 最深 · 引擎事故 payload 对不上 / Esc Fatal 或 TurnAborted 升成 CodexErr 停 turn
教学化结构图:失败从工具回到对话,先过三道门。默认停在最浅一层。
为什么长期成立

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_abortcancel(),再等 100 毫秒。超时就 task.handle.abort()。令牌先发信号,任务有机会自己收尾。收不完,再硬拆。硬拆这一步不可逆。

出处:codex-rs/protocol/src/protocol.rs 第 546 至 548 行;codex-rs/core/src/tasks/mod.rs 第 66 行、第 880 至 913 行

用户 Esc Op::Interrupt 任务令牌 cancel 信号,还可收尾 采样 or_cancel child token 工具子令牌 再 child 一次 handle.abort 100ms 后,不可逆 turn_aborted 先落盘再发事件 事件 后台 terminal 不在这棵树上。Interrupt 的合同就是不杀它。
教学化结构图:一份用户意图往下传,硬拆是最后一刀,也是不可逆的那一刀。
令牌先发信号,硬拆才不可逆。
为什么长期成立

一份用户意图,多层各自收尾。合作式取消加硬期限,是并发系统的通用形状。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 用类型把这条路收窄。

两侧均已核对源码 · 2026-08-22 · DSH agent.ts / tools/index.ts / tool-bash/render.ts

Grok:整份 ToolError 回模型,引擎停靠 SamplingError

Grok 的 ToolError 模块头把 detail 写成必须回给模型的说明。CancelledTimeoutExecution 都是同一种回灌。工具层没有 Fatal 档。会话层把执行失败收成 tool_result,turn 继续。

引擎停靠另一套类型。SamplingError 不可重试才停采样。Actor 上的 CancellationToken 触发是关机,单次工具取消走 CancelRegistry。令牌不承担 Codex 那种工具分诊。

两侧均已核对源码 · 2026-08-22 · xai-tool-runtime / xai-grok-sampling-types / xai-grok-shell
课堂练习
01

Esc 落在两种时刻,历史里各留下什么

apply_patch 已经写入文件,成功回执还在飞。这时用户按 Esc。下一轮模型会在历史里看见什么:成功回执、aborted by user<turn_aborted> 片段,这三样各会不会出现?后台 terminal 还在不在?

再把时刻改成 handler 还在跑,重答一遍。哪一步是不可逆的,为什么已经落盘的文件不会跟着中止事件一起消失。

Takeaway:工具失败回给模型,默认停在最浅一层。用户按 Esc,令牌从上往下传,已完成的结果留下,100 毫秒之后硬拆不可逆。中止只追加片段,不回滚历史。