Stop Conditions
The while(true) loop in src/query.ts has no natural termination condition. Instead, it relies on explicit return statements scattered throughout the loop body. Understanding these stop conditions is critical for understanding Claude Code’s behavior.
Stop Condition Taxonomy
Section titled “Stop Condition Taxonomy”graph TD A[Loop Iteration] --> B{Stop Conditions} B --> C[completed - model finished] B --> D[max_turns - turn limit hit] B --> E[max_budget_usd - cost limit hit] B --> F[aborted_streaming - user interrupt during stream] B --> G[aborted_tools - user interrupt during tools] B --> H[blocking_limit - context window full] B --> I[stop_hook_prevented - hook stopped execution] B --> J[hook_stopped - PostToolUse hook stopped] B --> K[prompt_too_long - irrecoverable 413] B --> L[model_error - API/runtime error] B --> M[image_error - media size error]1. Completed (end_turn)
Section titled “1. Completed (end_turn)”The most common stop condition. The model’s response contains no tool_use blocks, meaning it has finished its work.
if (!needsFollowUp) { // ... handle recoverable errors, stop hooks ... return { reason: 'completed' };}In the outer QueryEngine, this produces a success result:
yield { type: 'result', subtype: 'success', is_error: false, duration_ms: Date.now() - startTime, result: textResult, stop_reason: lastStopReason, total_cost_usd: getTotalCost(), usage: this.totalUsage,};The stop_reason comes from the API’s message_delta event, typically "end_turn" when the model naturally completes.
2. Max Turns
Section titled “2. Max Turns”Configured via maxTurns parameter (SDK) or --max-turns CLI flag. Checked after each tool result collection:
const nextTurnCount = turnCount + 1;if (maxTurns && nextTurnCount > maxTurns) { yield createAttachmentMessage({ type: 'max_turns_reached', maxTurns, turnCount: nextTurnCount, }); return { reason: 'max_turns', turnCount: nextTurnCount };}The attachment message is picked up by QueryEngine and converted to an SDK error result:
case 'attachment': if (message.attachment.type === 'max_turns_reached') { yield { type: 'result', subtype: 'error_max_turns', is_error: true, errors: [`Reached maximum number of turns (${message.attachment.maxTurns})`], }; return; }3. Cost Budget (max_budget_usd)
Section titled “3. Cost Budget (max_budget_usd)”Enforced in the outer QueryEngine, not inside the loop:
// src/QueryEngine.ts — after each yielded messageif (maxBudgetUsd !== undefined && getTotalCost() >= maxBudgetUsd) { yield { type: 'result', subtype: 'error_max_budget_usd', is_error: true, errors: [`Reached maximum budget ($${maxBudgetUsd})`], }; return;}This check runs after EVERY message yielded from the inner loop — including stream events, assistant messages, and tool results. This means the budget can be exceeded by the cost of one API call before being caught.
4. User Interrupt (Abort)
Section titled “4. User Interrupt (Abort)”User interrupts (Ctrl+C, ESC, or submit-interrupt) trigger the abortController.abort() signal. The loop checks this at two points:
During Streaming
Section titled “During Streaming”// src/query.ts — after the stream loopif (toolUseContext.abortController.signal.aborted) { if (streamingToolExecutor) { for await (const update of streamingToolExecutor.getRemainingResults()) { if (update.message) yield update.message; } } else { yield* yieldMissingToolResultBlocks(assistantMessages, 'Interrupted by user'); }
if (toolUseContext.abortController.signal.reason !== 'interrupt') { yield createUserInterruptionMessage({ toolUse: false }); } return { reason: 'aborted_streaming' };}During Tool Execution
Section titled “During Tool Execution”// src/query.ts — after tool results collectedif (toolUseContext.abortController.signal.aborted) { if (toolUseContext.abortController.signal.reason !== 'interrupt') { yield createUserInterruptionMessage({ toolUse: true }); } return { reason: 'aborted_tools' };}The 'interrupt' reason is special — it means the user submitted a new message while tools were running. In this case, the interruption message is skipped because the new user message provides sufficient context.
5. Blocking Token Limit
Section titled “5. Blocking Token Limit”When auto-compact is disabled and the context window is nearly full, a synthetic error is generated:
// src/query.ts — before the API callif ( !compactionResult && querySource !== 'compact' && !(reactiveCompact?.isReactiveCompactEnabled() && isAutoCompactEnabled())) { const { isAtBlockingLimit } = calculateTokenWarningState( tokenCountWithEstimation(messagesForQuery) - snipTokensFreed, toolUseContext.options.mainLoopModel, ); if (isAtBlockingLimit) { yield createAssistantAPIErrorMessage({ content: PROMPT_TOO_LONG_ERROR_MESSAGE, }); return { reason: 'blocking_limit' }; }}This is a preemptive check — it fires BEFORE the API call, saving the round-trip cost of a guaranteed 413 error. It only applies when:
- Auto-compact is OFF (user explicitly disabled it)
- Reactive compact is not available as a fallback
- Context collapse is not available as a fallback
6. Stop Hook Prevention
Section titled “6. Stop Hook Prevention”Stop hooks run when the model produces a response with no tool calls. They can:
- Block the response (inject an error, force retry)
- Prevent continuation (stop the turn entirely)
const stopHookResult = yield* handleStopHooks( messagesForQuery, assistantMessages, systemPrompt, userContext, systemContext, toolUseContext, querySource, stopHookActive,);
if (stopHookResult.preventContinuation) { return { reason: 'stop_hook_prevented' };}
if (stopHookResult.blockingErrors.length > 0) { state = { messages: [...messagesForQuery, ...assistantMessages, ...stopHookResult.blockingErrors], stopHookActive: true, transition: { reason: 'stop_hook_blocking' }, // ... }; continue;}The stopHookActive flag prevents infinite loops: once a stop hook has blocked, it’s recorded so subsequent iterations know a hook-retry is in progress.
7. PostToolUse Hook Stopped
Section titled “7. PostToolUse Hook Stopped”A PostToolUse hook can signal preventContinuation, which stops the loop after tool execution:
// src/query.ts — inside tool result collectionif ( update.message.type === 'attachment' && update.message.attachment.type === 'hook_stopped_continuation') { shouldPreventContinuation = true;}
// After all tools completeif (shouldPreventContinuation) { return { reason: 'hook_stopped' };}8. Irrecoverable Errors
Section titled “8. Irrecoverable Errors”Several error conditions terminate the loop after recovery attempts fail:
Prompt Too Long (413)
Section titled “Prompt Too Long (413)”// After collapse drain and reactive compact both failyield lastMessage; // surface the withheld errorvoid executeStopFailureHooks(lastMessage, toolUseContext);return { reason: 'prompt_too_long' };Model Error
Section titled “Model Error”} catch (error) { yield* yieldMissingToolResultBlocks(assistantMessages, errorMessage); yield createAssistantAPIErrorMessage({ content: errorMessage }); return { reason: 'model_error', error };}Image/Media Error
Section titled “Image/Media Error”if (error instanceof ImageSizeError || error instanceof ImageResizeError) { yield createAssistantAPIErrorMessage({ content: error.message }); return { reason: 'image_error' };}Priority and Recovery Order
Section titled “Priority and Recovery Order”When multiple conditions could apply, the check order determines which fires:
1. Abort signal (highest priority — checked first)2. Streaming error (catch block)3. Image error (catch block)4. Prompt-too-long recovery (collapse → reactive compact → surface)5. Max output tokens recovery (escalate → inject resume → surface)6. API error message (skip stop hooks)7. Stop hooks (blocking → prevent → pass)8. Token budget continuation9. Max turns (checked at continue site)10. Max budget USD (checked in QueryEngine after each yield)11. Completed (lowest priority — natural end)Graceful vs. Immediate Termination
Section titled “Graceful vs. Immediate Termination”| Condition | Type | Tool Results | Interruption Message |
|---|---|---|---|
completed | Graceful | All collected | None |
max_turns | Graceful | All collected | Attachment message |
max_budget_usd | Immediate | May be partial | SDK result message |
aborted_streaming | Immediate | Synthetic errors | If not submit-interrupt |
aborted_tools | Immediate | Partial + synthetic | If not submit-interrupt |
blocking_limit | Preemptive | None (before API) | Error message |
model_error | Immediate | Synthetic errors | Error message |
What Happens When the Context Window is Full
Section titled “What Happens When the Context Window is Full”When the context approaches the model’s window limit, a cascade of mechanisms activate:
graph TD A[Context growing] --> B{Auto-compact enabled?} B -- yes --> C[Auto-compact fires] C --> D[Summarize older messages] D --> E[Continue with smaller context] B -- no --> F{At blocking limit?} F -- yes --> G[Preemptive PROMPT_TOO_LONG_ERROR] F -- no --> H[API call proceeds] H --> I{API returns 413?} I -- yes --> J{Context collapse available?} J -- yes --> K[Drain staged collapses] K --> L{Still 413?} L -- yes --> M{Reactive compact available?} L -- no --> N[Continue with collapsed context] M -- yes --> O[Full compaction] O --> P{Still 413?} P -- yes --> Q[Surface error, stop] P -- no --> R[Continue with compacted context] M -- no --> Q J -- no --> M I -- no --> S[Normal processing]The key insight: the system has four layers of defense against context overflow, each progressively more aggressive:
- Auto-compact (proactive, at a configurable threshold)
- Context collapse drain (reactive, preserves granular context)
- Reactive compact (reactive, full summarization)
- Blocking limit (preemptive, user-facing error)