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.
- Protocol entry is Interrupt; do not kill the background terminalprotocol.rs L546
- Cancel the task token first — a signal, not a hard aborttasks/mod.rs L887
- Sampling watches the stream with a child token; or_cancel becomes TurnAbortedturn.rs L2273
- Tool dispatch child()s once more; cancel the parent and the child cancels toostream_events_utils.rs L319
- If the handler already finished, keep the real result; abort only if it has notparallel.rs L182
- Wait 100 ms; if it has not wound down, task.handle.aborttasks/mod.rs L913
- Append a turn_aborted fragment and flush_rollout at oncetasks/mod.rs L927
- Only then emit EventMsg::TurnAbortedtasks/mod.rs L955
task.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.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.
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.
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 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
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.
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.
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
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.
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.
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
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
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.
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.
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.