雨天小六

读懂 Codex(1.6):模型错误、工具错误与 Runtime 错误的边界

· 更新于 2026-08-01 · 专栏:读懂 Codex

#Codex#Agent Runtime#错误处理#Tool Calling#可靠性

一个 Agent 任务失败时,最容易写出的代码是:

try:
    await run_agent()
except Exception as error:
    await retry()

这段代码对 Coding Agent 来说很危险。如果失败只是模型 stream 在完成前断开,重试合理; 如果 shell 已经写入半个文件后 Handler task panic,重放可能执行第二次副作用;如果用户按了取消, 重试则直接违反用户意图。

正确的问题不是“这是不是一个 error”,而是:

  1. 这个失败由哪一层拥有?
  2. 该层能否判定重放是安全的?
  3. 失败应该对模型可见,还是只对 Runtime/用户可见?
  4. 它应该启动下一次模型采样,还是终止当前 Turn?
  5. 失败前的真实副作用是否已经发生?

Codex 的错误体系并不是完美无缺,源码还有明确 TODO,但它已经建立了三条必须区分的主路:

  • 模型/Responses 运输失败:在 sampling request 边界用有限预算重试或降级运输;
  • 可恢复工具失败:变成与 Call 配对的 Tool Output,让模型决定下一步;
  • Runtime 合同失败:不伪装成普通环境观察,向 Turn 错误收尾传播。

错误类型只是语义,恢复策略属于调用点

Codex 用 CodexErr 包装语义错误 CodexErrorDetails 和可选 retry_delay。详细分支很多, 可以按责任初步分组:

例子典型所有者
Turn 控制TurnAborted、InterruptedTask/Agent Loop
Model 运输Stream、RequestTimeout、ConnectionFailed、ResponseStreamFailedSampling request
Provider 终态UsageLimitReached、ServerOverloaded、CyberPolicy、InvalidRequestTurn 错误收尾/UI
Context/预算ContextWindowExceeded、SessionBudgetExceededContext manager/Turn
工具环境Sandbox、Spawn、TimeoutTool orchestrator/handler
Runtime 合同Fatal、TokioJoin、Json、InternalAgentDied发生它的具体 Runtime 边界
多 Agent/管理ThreadNotFound、AgentLimitReached、UnsupportedOperationAgent 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 终态。

这些行为应该由当前提交的代码和测试决定,不能根据日常语义想当然地改写。

Responses 模型错误、Tool RespondToModel 与 Fatal、Cancellation 和 Runtime 错误分别进入重试、运输降级、工具输出、TurnAborted 或 Turn 错误收尾的路由图
图 1.6-1:错误的第一个分类维度是“谁拥有恢复边界”。Responses 重试、工具失败回灌、沙箱局部升级和 Turn 取消是四种不同控制流。

模型运输错误在 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 会:

  1. 尝试从 WebSocket 切换到 HTTPS;
  2. 发送带原错误的 Warning;
  3. 把 retry count 清零;
  4. 在新运输上继续当前 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,但不能声称自动回滚了环境。

Tool Call 经 Registry、PreToolUse Hook、Handler、Policy 与 Sandbox、PostToolUse Hook 后在不同时间点形成失败或成功 Output 的时序图
图 1.6-2:Pre Hook 阻止发生在 Handler 执行前;Post Hook 阻止发生在副作用之后。两者都可产生模型可见失败 Output,却不能对“环境是否已改变”得出同一结论。

命令失败不是 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 requestRuntime 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 / UsageNotIncludedUsageLimitExceeded
RetryLimitResponseTooManyFailedAttempts + HTTP status
ConnectionFailedHttpConnectionFailed + HTTP status
ResponseStreamFailedResponseStreamConnectionFailed + HTTP status
RefreshTokenFailedUnauthorized
ThreadNotFound / AgentLimitReached / UnsupportedOperationBadRequest
SandboxSandboxError
部分内部失败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 错误不是按“严重程度”排成三档,而是按恢复所有权分层:

  1. Sampling boundary 拥有 Prompt、client session、retry budget 和 transport fallback,可恢复瞬时运输错误;
  2. Tool boundary 拥有 call_id、payload 与真实 output,可把非零退出、拒绝、超时等转成诚实观察;
  3. Tool Orchestrator 拥有策略、审批和沙箱 attempt,可在同一 Call 内做受控升级;
  4. 当副作用终点不明、payload 合同破坏或 task 失联时,Fatal 阻止无证据重放;
  5. Cancellation 表达停止意图,只做清理与持久化收尾,不进自动重试。

这些机制也刚好暴露了 Coding Agent 与普通聊天 Agent 的根本差异:它不只要管理模型的文本生成, 还必须管理可产生持久副作用的工具、权限、工作区状态、取消、验证与恢复。1.7 将对这个增量 做一次精确的系统边界对比。

评论


← 返回文章列表