跳转到内容

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.

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]

The most common stop condition. The model’s response contains no tool_use blocks, meaning it has finished its work.

src/query.ts
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.

Configured via maxTurns parameter (SDK) or --max-turns CLI flag. Checked after each tool result collection:

src/query.ts
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:

src/QueryEngine.ts
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;
}

Enforced in the outer QueryEngine, not inside the loop:

// src/QueryEngine.ts — after each yielded message
if (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.

User interrupts (Ctrl+C, ESC, or submit-interrupt) trigger the abortController.abort() signal. The loop checks this at two points:

// src/query.ts — after the stream loop
if (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' };
}
// src/query.ts — after tool results collected
if (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.

When auto-compact is disabled and the context window is nearly full, a synthetic error is generated:

// src/query.ts — before the API call
if (
!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

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)
src/query.ts
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.

A PostToolUse hook can signal preventContinuation, which stops the loop after tool execution:

// src/query.ts — inside tool result collection
if (
update.message.type === 'attachment' &&
update.message.attachment.type === 'hook_stopped_continuation'
) {
shouldPreventContinuation = true;
}
// After all tools complete
if (shouldPreventContinuation) {
return { reason: 'hook_stopped' };
}

Several error conditions terminate the loop after recovery attempts fail:

// After collapse drain and reactive compact both fail
yield lastMessage; // surface the withheld error
void executeStopFailureHooks(lastMessage, toolUseContext);
return { reason: 'prompt_too_long' };
} catch (error) {
yield* yieldMissingToolResultBlocks(assistantMessages, errorMessage);
yield createAssistantAPIErrorMessage({ content: errorMessage });
return { reason: 'model_error', error };
}
if (error instanceof ImageSizeError || error instanceof ImageResizeError) {
yield createAssistantAPIErrorMessage({ content: error.message });
return { reason: 'image_error' };
}

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 continuation
9. Max turns (checked at continue site)
10. Max budget USD (checked in QueryEngine after each yield)
11. Completed (lowest priority — natural end)
ConditionTypeTool ResultsInterruption Message
completedGracefulAll collectedNone
max_turnsGracefulAll collectedAttachment message
max_budget_usdImmediateMay be partialSDK result message
aborted_streamingImmediateSynthetic errorsIf not submit-interrupt
aborted_toolsImmediatePartial + syntheticIf not submit-interrupt
blocking_limitPreemptiveNone (before API)Error message
model_errorImmediateSynthetic errorsError 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:

  1. Auto-compact (proactive, at a configurable threshold)
  2. Context collapse drain (reactive, preserves granular context)
  3. Reactive compact (reactive, full summarization)
  4. Blocking limit (preemptive, user-facing error)