OpenAI Codex · Cancel and errors

After You Hit Cancel, How Each Layer Stands Down

Tool failures go back to the model; only Esc stops the turn. A cancel token travels from the task to sampling to tools. Finished results stay in history; a hard abort after 100 ms cannot be undone.

Course goalAfter this lesson, you can explain three things: why a tool failure keeps the conversation going; the order in which the task, sampling, and tools stand down after Esc; and which layer, once it fires, cannot be undone.
Play first · Hit Esc and watch who stops first
Same turn, user hits Esc halfway
When Esc lands
Flip the other setting: does a finished receipt get overwritten, and which step is marked irreversible?
Stand-down orderWaiting to start
1
Protocol entryOp::Interrupt arrives
2
Task tokencancellation_token.cancel
3
Sampling stands downor_cancel becomes TurnAborted
4
Tool stands downChild token cancels; check if the handler already finished
5
Hard aborttask.handle.abort after 100 msIrreversible
6
Flush and eventWrite the fragment first, then emit TurnAborted
History and side effects0 items
Logic trail · each animation step maps to a source span
  1. Protocol entry is Interrupt; do not kill the background terminalprotocol.rs L546
  2. Cancel the task token first — a signal, not a hard aborttasks/mod.rs L887
  3. Sampling watches the stream with a child token; or_cancel becomes TurnAbortedturn.rs L2273
  4. Tool dispatch child()s once more; cancel the parent and the child cancels toostream_events_utils.rs L319
  5. If the handler already finished, keep the real result; abort only if it has notparallel.rs L182
  6. Wait 100 ms; if it has not wound down, task.handle.aborttasks/mod.rs L913
  7. Append a turn_aborted fragment and flush_rollout at oncetasks/mod.rs L927
  8. Only then emit EventMsg::TurnAbortedtasks/mod.rs L955
Hit Play. Watch the stand-down order after Esc, and which layer is irreversible.
Stand-down orderThe token signals first. Sampling and tools wind down on their own. Only then come the hard abort, the fragment, and the event. The UI abort appears after this chain finishes.
The irreversible layertask.handle.abort after 100 ms has no undo. A finished tool receipt is not rewritten as aborted. The background terminal keeps running; killing it is a different op.
Flip the other settingIf the tool already finished, history keeps the success receipt plus an abort mark. If it is still running, it keeps aborted by user plus the abort mark. Both are appends, not rollbacks.
Teaching sketch:Step timing follows the source call chain; durations are stretched into clickable steps. Line numbers on the logic trail match openai/codex commit 4f39251a01.
Idea 1 · Tool failures go back to the model; only engine failures stop the turn
What problem it solves

You ask Codex to edit a test file. The model runs cargo test; rustc dumps two screens of errors; the exit code is 1. A second later the conversation stops and the UI shows turn aborted. Plenty of first-time agent authors do this: the tool returns Err, and the whole turn dies with it. The model never sees stderr. The session is already over.

What the idea is

Triage happens at the door where the tool comes back into the conversation. Three thresholds.

Shallowest: the process already ran. Non-zero exit, command timeout, sandbox denial — all become a tool receipt. success is true: the handler finished, and the receipt can be fed to the model. Whether the command itself succeeded lives in the body.

Source: codex-rs/core/src/tools/context.rs lines 344–353

Middle: the call never happened. Bad args, process never started, apply_patch context mismatch — these go RespondToModel. Only then is receipt success false. The model reads the copy, edits, and tries again.

Deepest: payload mismatch or a task join failure writes Fatal, promotes to CodexErr, and stops the turn.

The tool layer’s own enum has two rungs. RespondToModel feeds a string back to the model. Only Fatal becomes an engine error. Anything not Fatal folds to Ok by default. To stop the conversation, you have to write Fatal or CodexErr.

codex-rs/tools/src/function_call_error.rslines 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),
}
Source snapshot note:Based on the local openai/codex repo; checked against codex-rs/tools/src/function_call_error.rs, commit 4f39251a01, checked on 2026-08-22. The enum itself is the triage contract: two rungs, no third-rung warning or retry.

A cargo test exit code of 1 stops at the shallowest layer — it never even crosses the error-enum threshold. A sandbox denial also walks Ok. When a proxy blocks a request and returns HTTP 403 to the command process, that stays shallow too. A 403 from the model API is the engine error. Do not collapse the two 403s into one rung.

Source: codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs lines 384–411; codex-rs/network-proxy/src/responses.rs lines 76–83

Shallow · process already ran cargo test exit code 1 Tool receipt success true Win or lose lives in the body Conversation continues Middle · call never happened Patch context mismatch RespondToModel Receipt success false Conversation continues Deep · engine accident payload mismatch / Esc Fatal or TurnAborted Promoted to CodexErr Stop the turn
Teaching diagram:Failure walks back from the tool into the conversation through three doors. Default stop is the shallowest one.
Why it lasts

If an IO error in a handler bubbles straight into the turn loop, the whole conversation dies with it. Default is feed-back; stopping has to be written explicitly. The same line holds if you rewrite it in another language. Building an internal agent? Copy this rung first: tool failures feed back, engine failures stop the loop.

Idea 2 · Build cancel as a token tree
What problem it solves

When the user hits Esc, more than the current sample has to stop. Sampling may still be reading the stream; a tool may be writing a file; a background terminal may already be up. Kill only sampling, and the tool keeps editing disk. Kill every process in one cut, and unified exec’s background jobs die by mistake.

What the idea is

A cancel token is minted when the task starts. The sample request uses child_token(); tool dispatch child()s once more. Cancel the parent and every child cancels. A child that cancels itself does not touch the parent. That is the cancel tree.

Source: codex-rs/core/src/session/turn.rs line 1399; codex-rs/core/src/stream_events_utils.rs lines 319–324

or_cancel writes “who arrived first” into a fixed shape. Any future plus one token. Cancel arrives first: return CancelErr, always become TurnAborted.

Source: codex-rs/async-utils/src/lib.rs lines 4–31; codex-rs/protocol/src/error.rs lines 269–273

User hits Esc. Protocol entry is Op::Interrupt. The contract is fixed: abort the current task, do not kill the background terminal. handle_task_abort cancel()s first, then waits 100 ms. On timeout, task.handle.abort(). The token signals first so the task can wind down. If it cannot, hard abort. That last cut cannot be undone.

Source: codex-rs/protocol/src/protocol.rs lines 546–548; codex-rs/core/src/tasks/mod.rs line 66, lines 880–913

User Esc Op::Interrupt Task token cancel Signal; still time to wind down Sampling or_cancel child token Tool child token child() once more handle.abort After 100ms, irreversible turn_aborted Flush first, then emit Event The background terminal is not on this tree. Interrupt’s contract is not to kill it.
Teaching diagram:One user intent travels down. Hard abort is the last cut — and the irreversible one.
The token signals first; only the hard abort is irreversible.
Why it lasts

One user intent; each layer winds down on its own. Cooperative cancel plus a hard deadline is the usual shape of a concurrent system. In Python, asyncio.Event is enough for a minimal version. You do not need to copy 37 flavors of CodexErr.

Idea 3 · Abort only appends; it does not roll back
What problem it solves

Cancel lands after the tool already edited a file — how does history record that? Rewrite a finished output as aborted by user, and next turn the model thinks the write never happened and may apply the patch again.

What the idea is

The tool future waits on both the dispatch result and the token. If the token arrives first, still check whether the handler reached a terminal state. Finished: keep the real result. Not finished: abort, then mint an AbortedToolOutput whose body is aborted by user after Xs.

Source: codex-rs/core/src/tools/parallel.rs lines 177–206, lines 243–260

Then append a model-visible mark wrapped in <turn_aborted>. The copy admits two things: unified exec may still be running in the background, and an aborted tool may already have run partway. After the mark is written into history, flush_rollout() runs at once. Some clients reread the rollout when they see TurnAborted — the mark has to hit disk first.

Source: codex-rs/core/src/context/turn_aborted.rs lines 1–35; codex-rs/core/src/tasks/mod.rs lines 920–962

Why it lasts

History only appends; it does not rewrite. Abort only adds a later fragment. Tool output already on disk stays. Next turn the model can see the finished output, the aborted tool’s receipt, and the abort mark together. That shape is the first rule of context governance.

Source: AGENTS.md lines 91–100

Side-by-side · Another answer to the same question

DSH: a throw folds into isError; only a loop throw stops the turn

DSH mints a new AbortController each round. A throw in a tool body does not cross the loop. The executor folds it into an isError: true receipt and the round continues. bash’s comment is the product contract: Non-zero exits are reported, not errored. Only the loop’s own failure stops the turn. User cancel is written as turn/end aborted.

DSH skips the two-rung enum because the executor’s catch already splits “model can see it” from “engine must stop.” The cost is a convention: a throw that slips past dispatchToolBody still becomes turn/end error. Codex narrows that path with types.

Source checked on both sides · 2026-08-22 · DSH agent.ts / tools/index.ts / tool-bash/render.ts

Grok: the whole ToolError goes back to the model; the engine stops on SamplingError

Grok’s ToolError module header writes detail as copy that must go back to the model. Cancelled, Timeout, and Execution are the same feed-back. No Fatal rung at the tool layer. The session layer folds an execution failure into a tool_result and the turn continues.

The engine stops on a different type. SamplingError stops sampling only when it is not retryable. A CancellationToken on the Actor is shutdown; a single tool cancel walks CancelRegistry. The token does not carry Codex-style tool triage.

Source checked on both sides · 2026-08-22 · xai-tool-runtime / xai-grok-sampling-types / xai-grok-shell
Classroom Exercise
01

Esc at two moments: what does history keep?

apply_patch already wrote the file; the success receipt is still in flight. The user hits Esc. Next turn, what does the model see in history: the success receipt, aborted by user, the <turn_aborted> fragment — which of the three appear? Is the background terminal still there?

Then flip the moment to “handler still running” and answer again. Which step is irreversible, and why a file already on disk does not vanish with the abort event.

Takeaway:Tool failures go back to the model; the default stop is the shallowest layer. User hits Esc, the token travels downward, finished results stay, and a hard abort after 100 ms cannot be undone. Abort only appends a fragment; it does not roll history back.