Claude Code Agent Loop Deep Dive (8): From Ctrl-C to a Synthetic tool_result

The previous article described how the loop recovers from infrastructure failures. There is one case it can never solve by itself: the user changes their mind.

Perhaps the agent is reading an irrelevant file, heading down the wrong path, or simply needs new information from the user. The user must be able to interrupt it.

The product interaction looks simple—press Ctrl-C. The loop consequences are not. At the time of interruption, the system may be streaming an LLM response, executing several tools in parallel, or waiting for an approval promise. Each can leave the message history structurally incomplete, especially when a tool_use has no matching tool_result.

This post explains how Claude Code stops work without corrupting its history, and why an interruption sometimes creates an error result while an interrupt-and-send-new-message action does not.

One AbortController spans the whole loop

Each loop run creates a fresh Node.js AbortController. It provides two things:

  • signal, passed to downstream work so it can observe cancellation.
  • abort(), which marks the signal aborted.

Claude Code keeps that controller on QueryEngine and shares it across the active loop:

QueryEngine
    ├── this.abortController = new AbortController()
    ├── interrupt() → this.abortController.abort()
    └── toolUseContext passes the controller signal through the loop

Ctrl-C in the UI calls QueryEngine.interrupt(). At once, every operation holding the shared signal can observe the cancellation.

Using one controller is important because a loop can have several concurrent operations: a streaming API request, parallel tool invocations, an approval promise, or hook subprocesses. One abort signal reaches all of them; multiple controllers would require manually cancelling each one and make omissions likely.

Three cancellation checkpoints

The loop checks signal.aborted at three important boundaries.

1. The HTTP request

The model request passes the signal to fetch:

fetch(url, { signal: toolUseContext.abortController.signal })

fetch understands abort signals. Once cancellation occurs, the request rejects and the streaming connection is physically stopped; no later SSE events arrive.

2. Between streaming and tool execution

After the response has produced a full assistant message, possibly with tool_use blocks, the loop checks the signal before it starts tools. If interrupted, it skips starting new tool work and moves to cleanup.

3. Between tool batches

Parallel tools run in batches. After one batch finishes and before the next starts, the loop checks again. A cancellation prevents the next batch from beginning.

This placement reflects cooperative cancellation. Signal-aware I/O such as fetch can stop immediately, but pure computation has to check the signal itself. The checkpoints sit at each moment where the loop is about to begin more work.

Synthesizing missing tool_result blocks

An interruption can leave history in this state:

user message
assistant message: tool_use A, B, C
user message: tool_result A, tool_result B
                 ↑ C was interrupted before it got a result

That history is invalid. The orphaned tool_use C has no corresponding tool_result, so sending the conversation to the model again would produce a 400 error.

Claude Code repairs this by scanning in-flight tool uses after cancellation and creating a synthetic result:

{
  type: 'tool_result',
  tool_use_id: 'toolu_C',
  content: 'Interrupted by user',
  is_error: true
}

The synthetic result restores the structural pairing invariant. It also has useful semantics: the model can understand that the operation did not finish because the user stopped it.

The source calls this repair yieldMissingToolResultBlocks. It began as interruption handling and shares the same philosophy as the pairing repair discussed in Part 3: From Reading Files to Parallel Scheduling: pairing is a hard constraint, so any break must be repaired.

Two meanings of interruption

In practice, Ctrl-C can mean two different things.

A. Stop and think

The user wants the loop to stop so they can review the current progress. They may or may not send another message later.

B. Interrupt to submit a correction

The user wants to stop the agent and immediately provide a correction, such as “You are looking in the wrong place; inspect auth_v2.py instead.”

Both cases abort the same active work, but their desired follow-up is different:

  • In case A, Claude Code appends the synthetic Interrupted by user result and ends the loop.
  • In case B, the typed user message becomes the next loop input immediately after the interruption.

Claude Code distinguishes them through signal.reason:

  • A normal Ctrl-C-style reason follows the first path and synthesizes the interruption result.
  • signal.reason === 'interrupt' carries the “interrupt to submit” meaning and does not synthesize a redundant interruption message.

The second path does not need the extra text because the user's new message already explains why the prior work stopped. One cancellation mechanism therefore supports two different user experiences with a small semantic flag.

Interrupting while the response is streaming

What if Ctrl-C arrives before the model has finished streaming?

AbortController.abort() stops the underlying fetch, but any SSE deltas already received are kept. Claude Code appends the partial assistant message to history: it may contain the first few text fragments but no final stop reason or completed tool use.

The terminal outcome is aborted_streaming. On a later turn, the model can see the incomplete response it had begun.

Keeping partial output is intentional. Users often interrupt because they have seen the agent say something wrong and want to correct it. Preserving those words lets them refer to the mistake in the next message. Dropping them would make the conversation look cleaner, but would remove useful context.

MCP cleanup belongs only to a main-agent interruption

Some MCP servers, including Anthropic-internal services such as Chicago, need connection cleanup when a loop ends. Claude Code performs that cleanup only for an interrupted main-agent loop, not an interrupted sub-agent.

The reason is shared ownership. A sub-agent can share an MCP connection with its main loop; cancelling the sub-agent must not tear down a connection the main loop still needs. This is one of the main-thread-only branches guarded by if (!toolUseContext.agentId).

The second exception to unattended looping

Part 1: From Tool Declarations to Pre-Execution Approval described approval as the first exception to an otherwise unattended loop: the loop pauses and asks the user to decide about a risky action.

Interrupt is the second exception: the user can step into the loop at any time. Together with the maxTurns circuit breaker from Part 4: From a Finished Answer to the Seven Meanings of stop_reason, they cover complementary risks:

  • Approval: the loop proactively waits for a decision at a sensitive point.
  • Interrupt: the user proactively stops the loop whenever needed.
  • maxTurns: the system stops automatically even if nobody intervenes.

These three mechanisms ensure autonomous execution remains controllable under every condition.

Takeaways

  • One AbortController covers the entire loop, so a single cancellation reaches all concurrent operations.
  • The loop checks for cancellation at the HTTP request, before tool execution, and between tool batches.
  • Missing results are repaired with a synthetic is_error: true tool result to preserve the tool_use / tool_result invariant.
  • signal.reason distinguishes “stop and think” from “interrupt and submit a correction.”
  • A streaming interruption preserves already received partial output for later conversational context.
  • Main-agent-only cleanup prevents a cancelled sub-agent from closing shared MCP connections.
  • Approval, interrupt, and maxTurns are complementary controls over an otherwise unattended loop.

The next article examines Claude Code's final large mechanism: sub-agents. They reuse the same queryLoop implementation, while identity-based branches, isolated JSONL transcripts, and agentId routing keep their work distinct.

References

Primary source locations (v2.1.220):

  • src/QueryEngine.ts: abortController and interrupt().
  • src/query.ts: yieldMissingToolResultBlocks and synthetic results for in-flight tool use.
  • src/query.ts: routing on signal.reason === 'interrupt'.
  • src/hooks/useCancelRequest.ts: Ctrl-C and Esc event capture.
  • src/hooks/useCancelRequest.ts: chat:cancel and app:interrupt priority.

Related reading:

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论