一个 Agent 任务失败时,最容易写出的代码是:
try:
await run_agent()
except Exception as error:
await retry()
这段代码对 Coding Agent 来说很危险。如果失败只是模型 stream 在完成前断开,重试合理; 如果 shell 已经写入半个文件后 Handler task panic,重放可能执行第二次副作用;如果用户按了取消, 重试则直接违反用户意图。
正确的问题不是“这是不是一个 error”,而是:
- 这个失败由哪一层拥有?
- 该层能否判定重放是安全的?
- 失败应该对模型可见,还是只对 Runtime/用户可见?
- 它应该启动下一次模型采样,还是终止当前 Turn?
- 失败前的真实副作用是否已经发生?
Codex 的错误体系并不是完美无缺,源码还有明确 TODO,但它已经建立了三条必须区分的主路:
- 模型/Responses 运输失败:在 sampling request 边界用有限预算重试或降级运输;
- 可恢复工具失败:变成与 Call 配对的 Tool Output,让模型决定下一步;
- Runtime 合同失败:不伪装成普通环境观察,向 Turn 错误收尾传播。
错误类型只是语义,恢复策略属于调用点
Codex 用 CodexErr 包装语义错误 CodexErrorDetails 和可选 retry_delay。详细分支很多,
可以按责任初步分组:
| 组 | 例子 | 典型所有者 |
|---|---|---|
| Turn 控制 | TurnAborted、Interrupted | Task/Agent Loop |
| Model 运输 | Stream、RequestTimeout、ConnectionFailed、ResponseStreamFailed | Sampling request |
| Provider 终态 | UsageLimitReached、ServerOverloaded、CyberPolicy、InvalidRequest | Turn 错误收尾/UI |
| Context/预算 | ContextWindowExceeded、SessionBudgetExceeded | Context manager/Turn |
| 工具环境 | Sandbox、Spawn、Timeout | Tool orchestrator/handler |
| Runtime 合同 | Fatal、TokioJoin、Json、InternalAgentDied | 发生它的具体 Runtime 边界 |
| 多 Agent/管理 | ThreadNotFound、AgentLimitReached、UnsupportedOperation | Agent control/Tool Output 适配 |
CodexErr::is_retryable() 对某些类型返回 true,例如 Stream、RequestTimeout、UnexpectedStatus、
ConnectionFailed、InternalServerError、Io、Json、TokioJoin。它对 TurnAborted、Fatal、InvalidRequest、
ContextWindowExceeded、UsageLimitReached、Sandbox、RetryLimit、ServerOverloaded 等返回 false。
不能把这个方法理解成“错误对象一创建就会自动重试”。它只是语义标签,必须有具体恢复循环 主动查询才有效。
这条规则解释了一个看似矛盾的现象:TokioJoin 在 CodexErr 通用分类中是 retryable,但工具 Handler
task 的 JoinError 会先被 ToolCallRuntime 转成 FunctionCallError::Fatal。后者不重放工具。因为工具 task
失联时,Runtime 无法安全断定副作用是否已完成;而某个纯运输边界的 JoinError 可能具有不同
幂等性条件。
def may_retry(error, recovery_boundary):
return (
error.is_retryable
and recovery_boundary.has_retry_budget
and recovery_boundary.can_replay_safely(error)
)
只看错误名字也会误判。Codex 的测试明确断言:
- 一个还处在预算内的
UnexpectedStatus(429)可 retry; RetryLimit(429)已经表示预算耗尽,不再 retry;ServerOverloaded当前是非 retryable 终态。
这些行为应该由当前提交的代码和测试决定,不能根据日常语义想当然地改写。
模型运输错误在 sampling request 边界重试
Codex 的 run_sampling_request 不是每次只调一次 Provider。它在同一 Turn-scoped ModelClientSession
上维护 retry count,并复用当前 Step 的 ToolCallRuntime。首次使用传入的 Prompt input,后续尝试
会从当前 Context History 重新生成模型可见快照。
async def run_sampling_request(step, initial_input):
retries = 0
input_for_attempt = initial_input
original_input = None
while True:
prompt = build_prompt(input_for_attempt, step)
try:
result = await try_one_provider_stream(prompt, step)
return result, original_input or prompt.input
except ContextWindowExceeded as error:
mark_session_tokens_full()
raise error
except UsageLimitReached as error:
update_rate_limit_snapshot_if_present(error)
raise error
except CodexError as error:
original_input = original_input or prompt.input
if not error.is_retryable:
raise
await retry_or_fallback_transport(error, retries)
retries += 1
record_sampling_retry_timing()
input_for_attempt = history.for_prompt()
ContextWindowExceeded 和 UsageLimitReached 在这里先做 Session 状态更新,然后直接交回外层。 它们不是“等 500 ms 再发相同请求”能解决的运输瞬态错误。
真正 retryable 的 stream/request 错误会进入有预算恢复。延迟优先使用 Provider 错误上的
retry_delay,没有时才用本地 backoff。在还有预算时,Runtime 可向 UI 发 Reconnecting;为了减少
瞬时 WebSocket 抖动的噪声,release 模式可以隐藏第一次 WebSocket retry 通知。
当 WebSocket 预算耗尽时,ModelClientSession 如果支持切换备用运输,Runtime 会:
- 尝试从 WebSocket 切换到 HTTPS;
- 发送带原错误的 Warning;
- 把 retry count 清零;
- 在新运输上继续当前 sampling request。
只有备用运输也不可用且预算耗尽,错误才离开 sampling request 恢复边界。
async def retry_or_fallback_transport(error, state):
if state.retries >= state.max_retries:
if state.client_session.try_switch_websocket_to_https():
emit_warning(f"Falling back to HTTPS: {error}")
state.retries = 0
return
raise error
state.retries += 1
delay = error.retry_delay or exponential_backoff(state.retries)
maybe_notify_ui_reconnecting(state.retries, state.max_retries)
await sleep(delay)
这个边界为什么可以重试?因为它拥有完整 Prompt、重试预算、客户连接状态和历史重建能力。 但它仍然需要警惕部分 stream 已交付 Item 的情况:Codex 重试时从 History 重建 Prompt,而不是盲目 完全重发最初快照并忽略已记录事实。
工具错误首先分“可作为观察”与“合同已破坏”
工具层的中心错误不是 CodexErr 的所有分支,而是只有两个分支的 FunctionCallError:
class RespondToModel(Exception):
message: str
class FatalToolContractError(Exception):
message: str
RespondToModel 的意思不是“这次工具调用可算成功”,而是“这个失败可以被压缩为一条安全的
模型观察”。ToolCallRuntime 会把它转成与原 Call 类型匹配、保留 call_id、success=false
的 Output。模型在下一次采样里可以换参数、换工具、解释限制或停止。
Fatal 表示问题不再是模型能通过重写 Tool Call 解决的环境反馈。当前典型场景:
- Registry 找到了同名 Handler,但 payload kind 与 Handler 合同不兼容;
- Handler 成功路径没有产生任何 Output;
- 工具异步 task join 失败或 panic,Runtime 无法确认执行点。
async def tool_call_boundary(call):
try:
result = await dispatch(call)
return result.into_response()
except RespondToModel as error:
return failure_output(
payload_kind=call.payload.kind,
call_id=call.call_id,
message=str(error),
success=False,
)
except FatalToolContractError as error:
raise RuntimeFatal(str(error))
这个边界防止两种相反错误:不会因为 shell exit code 非 0 就杀掉整个 Agent,也不会因为想让 Agent 显得“坚强”就把内部不变式破坏伪装成可继续工具错误。
工具生命周期中,错误发生时间比错误文字更重要
同样一句“blocked”,发生在 PreToolUse Hook 与 PostToolUse Hook 有完全不同的含义。
PreToolUse block
Registry 已经识别工具和 payload,但 Handler 还没运行。Pre Hook 可以阻止或改写工具输入。 如果阻止,Codex 生成 RespondToModel,并在 lifecycle 中记录 Handler 未执行。这时可以合理说 “没有产生工具副作用”。
Handler 失败
Handler 真正运行时,可能出现命令非零退出、超时、沙箱拒绝或审批拒绝。Exec/Apply Patch 事件适配层 会尽量保留真实 output 和 metadata,把它格式化为 RespondToModel。这些失败可能完全没有改变 工作区,也可能有部分已知副作用。例如 apply_patch 被沙箱拒绝时,注释明确考虑了已经应用的 已知前缀:UI lifecycle 和 Turn diff 仍要表达它,不能因最终结果是 failure 就假装没有任何改变。
PostToolUse block
Post Hook 只在 Handler 成功且存在 post-tool payload 后运行。此时命令、补丁或远程调用的真实 副作用已经发生。源码注释明确说,PostToolUse block 拒绝的是“结果”,不是已完成的工具 执行。
因此这条路可以把模型可见 Output 换成失败反馈或 Hook feedback,但不能声称自动回滚了环境。
命令失败不是 Runtime 崩溃
Shell 返回 exit code 1 是常见环境观察,并不说明 Tool Runtime 已损坏。比如 pytest 返回 1,
模型正需要其 stdout/stderr 才能知道哪个测试失败。如果将非零退出直接升格为 Turn Error,Coding Agent
将在第一个有价值的诊断观察前停止。
Codex 的 Exec 事件适配层先把输出格式化成模型可读内容:
- exit code 0:正常 Tool Output;
- exit code 非 0:用同一份格式化内容构造 RespondToModel;
- Sandbox Timeout/Denied:保留 output 并构造 RespondToModel;
- ToolError::Rejected:把常见 shell/apply-patch 拒绝语句规范化和截断,再反馈模型。
def convert_exec_result(result, call_id):
if isinstance(result, ExecCompleted):
observation = format_exec_output(result.output)
if result.output.exit_code == 0:
return ToolSuccess(call_id, observation)
raise RespondToModel(observation)
if isinstance(result, SandboxDenied):
raise RespondToModel(format_exec_output(result.output))
if isinstance(result, SandboxTimeout):
raise RespondToModel(format_exec_output(result.output))
if isinstance(result, ApprovalRejected):
raise RespondToModel(normalize_rejection(result.message))
这里还有一个当前实现局限。源码 TODO 说明 ToolError::Rejected 同时承载用户拒绝和部分
运行时/setup 拒绝,当前事件层可能将一小部分非用户错误报为 Declined。设计上更细的做法是
将 UserDeclinedApproval 与 OperationalRejection 拆成两种语义类型,但写本节时不能把这个尚未完成的
改进说成当前事实。
沙箱内部重试不是重做模型采样
工具在沙箱中被拒绝后,Codex 还有一种局部恢复:在同一 Tool Call 内用更高权限或不同沙箱 策略运行第二次 attempt。这与模型看到 failure Output 后产生新 Call 是两件事。
局部升级不是无条件的“沙箱失败就去掉沙箱重跑”。Tool Orchestrator 会同时检查:
- 该工具是否允许失败后升级;
- 当前 approval policy 与 filesystem sandbox policy;
- 拒绝是文件系统还是 managed network policy;
- 是否允许 unsandboxed execution;
- 是否要请求用户或 Guardian 对 retry 单独审批;
- strict auto-review 是否只覆盖原沙箱 attempt。
async def run_tool_with_sandbox_recovery(request, policy):
first = await run_attempt(request, policy.initial_sandbox)
if first.ok:
return first
if not is_sandbox_denial(first.error):
raise first.error
if not request.tool.escalate_on_failure:
raise first.error
if not policy.permits_escalated_attempt(first.error):
raise first.error
approval = await resolve_retry_approval(
call_id=request.call_id,
reason=denial_reason(first.error),
reviewer=policy.retry_reviewer,
)
approval.raise_if_denied()
return await run_attempt(request, policy.retry_sandbox)
如果升级不允许、审批拒绝或第二次仍失败,错误再经 ToolEmitter 转成模型可见观察。 因此三类“重试”必须用不同名字理解:
| 机制 | 重放什么 | 决策者 | 身份是否变化 |
|---|---|---|---|
| Responses transport retry | 当前 sampling request | Runtime retry policy | 仍是同一 sampling step |
| Sandbox escalation attempt | 同一 Tool Call 的环境执行 | Policy + Approval + Orchestrator | 仍保留原 call_id |
| Model corrective call | 模型看到 failure Output 后新生成的行动 | Model,受新 Prompt 约束 | 是新 Tool Call/call_id |
把三者都写成 retry() 会彻底混淆责任与幂等性。
Cancellation 不是错误恢复机会
取消在类型上可以用 Error 传播,但它的语义是控制信号。TurnAborted 明确不可 retry。
run_turn 遇到它时不发一条普通“请稍后再试” Error,而是将其交给 Task wrapper 转成 TurnAborted。
工具被取消时可能还是会生成 aborted Tool Output。这两个事实并不矛盾:
- aborted Output 修复“模型已经发出 Call,历史不应永久留下未配对项”;
- TurnAborted 表达“用户或系统已终止当前控制流,不再自动启动新采样”。
外部 interrupt 也不是立刻强杀所有任务。Session 先取消 Turn token,等待一小段宽限时间, 再 abort 未退出的 task,调用 Task-specific abort。Interrupted 还要先把模型可见标记写入 Rollout 并 flush, 然后才发 TurnAborted 终态。这个顺序使恢复后的下一 Turn 知道前一 Turn 是在中途被打断,而不是 模型自然完成。
Runtime 错误的判定标准是“循环是否还有真实且安全的观察”
“Runtime 错误”不能只用“这个错误由 Rust 代码产生”定义,因为工具超时和沙箱拒绝同样由代码 产生,却可成为模型观察。更精确的标准是:
- Runtime 是否还能构造一份不欺骗模型的、与原 Call 配对的结果?
- Runtime 是否知道副作用的终点?
- 再执行一次是否可能重复修改环境?
- 还有没有受控的局部降级或重试边界?
如果答案是“可以构造真实失败观察”,应优先 RespondToModel。如果答案是“合同已破坏,我不知道 执行到哪里”,应 Fatal。如果有明确运输 fallback 或沙箱策略恢复,应在该局部边界使用受控预算, 而不是把错误抛给一个全局 catch-all retry。
def classify_failure(failure, boundary):
if failure.is_cancellation:
return ABORT_TURN
if boundary.can_make_truthful_model_observation(failure):
return RESPOND_TO_MODEL
if boundary.has_safe_bounded_recovery(failure):
return RETRY_INSIDE_BOUNDARY
return FATAL_TO_TURN
内部错误、客户端错误和 Turn 终态是三层协议
CodexErrorDetails 是内部语义联合,包含诊断载荷。客户端不需要也不适合稳定依赖所有内部分支,
所以 to_codex_protocol_error 将它们投影成更粗粒度的 CodexErrorInfo:
| 内部组 | 客户端投影示例 |
|---|---|
| UsageLimitReached / QuotaExceeded / UsageNotIncluded | UsageLimitExceeded |
| RetryLimit | ResponseTooManyFailedAttempts + HTTP status |
| ConnectionFailed | HttpConnectionFailed + HTTP status |
| ResponseStreamFailed | ResponseStreamConnectionFailed + HTTP status |
| RefreshTokenFailed | Unauthorized |
| ThreadNotFound / AgentLimitReached / UnsupportedOperation | BadRequest |
| Sandbox | SandboxError |
| 部分内部失败 | InternalServerError |
| 其他 | Other |
ErrorEvent 还有人类可读 message。Session 发送该事件时,如果 CodexErrorInfo 表示它影响 Turn
状态,会把 Error 记入 turn_context.terminal_error。Task wrapper 最后发的 TurnComplete 可携带该错误。
这意味着:
TurnComplete是“这个 Task 的 Turn 生命周期已收尾”的信封,不总是“业务成功”;- 客户端应同时检查 TurnComplete 内的 error;
- TurnAborted 则专门表达取消/替换类控制终止,不与普通 ErrorEvent 混用。
设计错误协议时应明确保留四层:内部诊断、恢复策略、客户端类别、终态信封。把它们缩成 一个字符串会让 UI、重试器和持久化各自重新猜测。
Mini Codex 的错误边界伪代码
下面的伪代码将三个主边界分开:
@dataclass
class RetryPolicy:
max_attempts: int
fallback_transport: str | None
async def sample_with_transport_recovery(prompt, turn, policy):
state = RetryState()
while True:
try:
return await model.sample(prompt, turn.client_session)
except CodexError as error:
if error.is_context_or_usage_terminal:
update_session_error_state(error)
raise
if not may_retry(error, TRANSPORT_BOUNDARY):
raise
if state.at_limit(policy):
if state.switch_to(policy.fallback_transport):
emit_warning("transport fallback")
continue
raise
await cancellable_backoff(error.retry_delay, state.attempt)
state.attempt += 1
async def execute_tool_as_model_observation(call, invocation):
try:
result = await tool_orchestrator.execute(invocation)
return result.to_response_item(call.call_id, call.payload)
except RecoverableToolFailure as error:
return failure_output_for(
call_id=call.call_id,
payload=call.payload,
message=error.truthful_model_observation,
success=False,
)
except ToolTaskLost as error:
# 不重放未知执行点的副作用。
raise RuntimeFatal(str(error))
async def close_turn_after_error(turn, error):
if error.is_cancellation:
await persist_interrupted_marker_if_needed(turn)
await emit_turn_aborted(turn, error.reason)
return
info = map_internal_error_to_protocol(error)
event = ErrorEvent(message=safe_message(error), info=info)
await turn.history_or_rollout.record(event)
if info.affects_turn_status:
turn.terminal_error = event
await emit_turn_complete_envelope(turn, error=turn.terminal_error)
这份代码不应再加一个外层 except Exception: retry_all()。每个子边界已经在自己能证明安全性的地方
恢复;逃出的错误就是本地恢复已经耗尽或不安全。
应该测试错误路由,不只测试文案
错误测试最容易只做 assert "failed" in message,但这不能保护重试次数、Output 类型、call_id、
副作用次数与终态。更有效的测试是:
async def test_transport_failure_retries_without_new_tool_call():
model.fail_stream_once(StreamDisconnected(retry_delay=0))
model.then_return(final("ok"))
await agent.run("answer")
assert model.attempt_count == 2
assert tool.execution_count == 0
assert terminal_event().kind == "TurnComplete"
async def test_nonzero_exit_is_model_observation_not_turn_error():
shell.return_exit(code=1, stderr="test failed")
model.then_return(final("I found the failing test"))
await agent.run("run tests")
output = model.second_request.output_for("call_1")
assert output.success is False
assert "test failed" in output.text
assert terminal_event().error is None
async def test_tool_task_panic_is_not_replayed():
tool.panic_after_possible_side_effect()
await agent.run("modify")
assert tool.execution_count == 1
assert terminal_event().error is not None
async def test_post_hook_block_does_not_claim_rollback():
tool.modify_file_successfully()
post_hook.block("result rejected")
await agent.run("modify")
assert workspace.was_modified
assert model.next_request.output_for("call_1").success is False
async def test_cancel_never_enters_retry_loop():
model.block_stream()
task = spawn(agent.run("wait"))
task.cancel()
await task
assert model.attempt_count == 1
assert terminal_event().kind == "TurnAborted"
Codex 当前测试已经保护了多个关键切面:retryable 分支区分,Provider retry delay 传递, stream retry 日志与计数,沙箱原始 output 回灌,超时元数据,提权拒绝后模型生成新 Call, 工具取消 lifecycle,以及 TurnComplete/TurnAborted 的 flush 顺序。
小结:错误边界的本质是防止不安全重放和虚假观察
模型错误、工具错误和 Runtime 错误不是按“严重程度”排成三档,而是按恢复所有权分层:
- Sampling boundary 拥有 Prompt、client session、retry budget 和 transport fallback,可恢复瞬时运输错误;
- Tool boundary 拥有 call_id、payload 与真实 output,可把非零退出、拒绝、超时等转成诚实观察;
- Tool Orchestrator 拥有策略、审批和沙箱 attempt,可在同一 Call 内做受控升级;
- 当副作用终点不明、payload 合同破坏或 task 失联时,Fatal 阻止无证据重放;
- Cancellation 表达停止意图,只做清理与持久化收尾,不进自动重试。
这些机制也刚好暴露了 Coding Agent 与普通聊天 Agent 的根本差异:它不只要管理模型的文本生成, 还必须管理可产生持久副作用的工具、权限、工作区状态、取消、验证与恢复。1.7 将对这个增量 做一次精确的系统边界对比。
评论
登录后即可评论