📨 The Big Picture - Four Layers
Quando uma mensagem e enviada ao Claude Code, ela passa por quatro camadas distintas antes de chegar ao terminal do usuario:
1. QueryEngine.submitMessage()
Valida o prompt, constroi o system prompt, resolve o modelo, grava o transcript e passa para query().
2. query() / queryLoop()
Um async function* que faz loop ate o modelo parar de chamar tools. Cada iteracao representa uma chamada ao modelo.
3. queryModel / callModel
Chama a API Anthropic via interface streaming do SDK, envelopada em withRetry().
4. Stop hooks & token budget
Apos cada turno, hooks externos rodam; o token budget determina se injeta um nudge e continua o loop.
💡 Insight-chave
"Every public surface of Claude Code -- the REPL, the SDK, remote Claude Code -- funnels through the same query() generator." Isso significa que entender o query loop e entender o core de todo o sistema.
💬 QueryEngine - One Engine Per Conversation
QueryEngine e uma classe stateful instanciada uma vez por conversa. Ela mantem historico de mensagens mutavel, uso total de tokens, denials de permissao e o abort controller.
export class QueryEngine {
private mutableMessages: Message[]
private abortController: AbortController
private totalUsage: NonNullableUsage
private permissionDenials: SDKPermissionDenial[]
// Turn-scoped: cleared at start of each submitMessage() call
private discoveredSkillNames = new Set<string>()
async *submitMessage(
prompt: string | ContentBlockParam[],
options?: { uuid?: string; isMeta?: boolean },
): AsyncGenerator<SDKMessage> {
// 1. Build system prompt (fetchSystemPromptParts)
// 2. processUserInput -- handles slash commands
// 3. recordTranscript -- persists BEFORE the API call
// 4. yield* query({ messages, ... })
// 5. yield final result SDKMessage
}
}
📊 Por que o Transcript e Escrito Antes da API Call
A mensagem do usuario e persistida em disco antes de query() ser chamado. Isso torna a sessao resumivel mesmo se o processo for killed antes da resposta do modelo. "Writing now makes the transcript resumable from the point the user message was accepted, even if no API response ever arrives."
🔄 queryLoop() - The While(true) Core
queryLoop() em query.ts e um while(true) carregando um objeto State tipado entre iteracoes:
type State = {
messages: Message[]
toolUseContext: ToolUseContext
autoCompactTracking: AutoCompactTrackingState | undefined
maxOutputTokensRecoveryCount: number
hasAttemptedReactiveCompact: boolean
maxOutputTokensOverride: number | undefined
pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined
stopHookActive: boolean | undefined
turnCount: number
transition: Continue | undefined // WHY we looped again
}
Continue Transitions -- Sete Razoes para Continuar o Loop
| transition.reason | Significado |
|---|---|
| max_output_tokens_escalate | Primeiro hit do cap de 8k; retry com 64k max_tokens |
| max_output_tokens_recovery | Modelo atingiu limite de output; injeta recovery nudge (ate 3x) |
| reactive_compact_retry | Prompt-too-long -> compacted history -> retry |
| collapse_drain_retry | Prompt-too-long -> drained context-collapse stages -> retry |
| stop_hook_blocking | Stop hook retornou erro blocking; re-query com erro como user message |
| token_budget_continuation | Token budget diz que trabalho nao esta feito; injeta nudge |
| (needs follow-up) | Normal: modelo retornou tool_use blocks -> run tools -> loop |
⚠️ Condicoes de Terminacao
O loop sai via um Terminal em: completed, blocking_limit, model_error, prompt_too_long, aborted_streaming, stop_hook_prevented, image_error.
🌐 Streaming & the API Layer
queryModel em claude.ts e um async function* que chama o endpoint de mensagens beta da Anthropic e re-yields cada evento de stream como um AssistantMessage ou StreamEvent interno.
for await (const message of deps.callModel({
messages: prependUserContext(messagesForQuery, userContext),
systemPrompt: fullSystemPrompt,
thinkingConfig: toolUseContext.options.thinkingConfig,
tools: toolUseContext.options.tools,
signal: toolUseContext.abortController.signal,
options: { model: currentModel, fallbackModel, ... },
})) {
if (message.type === 'assistant') {
assistantMessages.push(message)
const toolBlocks = message.message.content
.filter(b => b.type === 'tool_use')
if (toolBlocks.length > 0) needsFollowUp = true
}
yield yieldMessage // surfaces to SDK caller / REPL
}
Streaming Tool Execution
Quando config.gates.streamingToolExecution esta habilitado, um StreamingToolExecutor inicia ferramentas enquanto o stream ainda esta aberto. Tools com inputs disponiveis comecam a executar em paralelo com o modelo ainda gerando texto, reduzindo latencia em turns com multiplas tools.
💡 Tombstone Messages
Se um streaming fallback e acionado mid-stream, objetos AssistantMessage parcialmente recebidos sao "tombstoned" -- yielded como { type: 'tombstone', message } para que a UI e o transcript possam remove-los. Isso previne erros de "thinking blocks cannot be modified" na API ao fazer retry.
🛡️ withRetry() - Smart Exponential Backoff
Toda API call passa por withRetry() em services/api/withRetry.ts. A funcao e um async function* com ate DEFAULT_MAX_RETRIES = 10 tentativas, yielding um SystemAPIErrorMessage antes de cada sleep para que usuarios vejam status updates em tempo real.
export function getRetryDelay(
attempt: number,
retryAfterHeader?: string | null,
maxDelayMs = 32000,
): number {
if (retryAfterHeader) {
const seconds = parseInt(retryAfterHeader, 10)
if (!isNaN(seconds)) return seconds * 1000
}
const baseDelay = Math.min(
BASE_DELAY_MS * Math.pow(2, attempt - 1),
maxDelayMs,
)
const jitter = Math.random() * 0.25 * baseDelay
return baseDelay + jitter
}
Regras de Decisao de Retry
- 529 (overloaded): Apenas sources foreground fazem retry (usuario esta esperando). Sources background abortam imediatamente para evitar amplificar cascatas de capacidade.
- Opus fallback: Apos 3 erros 529 consecutivos em modelo Opus nao-custom, lanca
FallbackTriggeredErrorquequeryLoopcaptura e muda parafallbackModel. - OAuth 401: Forca token refresh via
handleOAuth401Error()antes da proxima tentativa. - Context overflow 400: Parseia contagens de tokens da mensagem de erro e calcula novo
maxTokensOverride. - Persistent mode (UNATTENDED_RETRY): Retenta indefinidamente com cap de 30 minutos, yielding heartbeat messages a cada 30s.
- ECONNRESET/EPIPE: Socket keep-alive stale detectado;
disableKeepAlive()chamado antes do retry.
💾 Context Management & Autocompact
Antes de cada API call, queryLoop executa um pipeline de estrategias de reducao de contexto em ordem fixa de prioridade:
// task_budget carryover across compaction (query.ts ~508)
if (params.taskBudget) {
const preCompactContext =
finalContextTokensFromLastResponse(messagesForQuery)
taskBudgetRemaining = Math.max(
0,
(taskBudgetRemaining ?? params.taskBudget.total) - preCompactContext,
)
}
🔔 Stop Hooks - Post-Turn Lifecycle
Apos o modelo terminar (sem tool calls, sem recovery), o engine chama handleStopHooks() que executa tres categorias em ordem:
1. Stop Hooks (sempre)
Registrados via hooks em settings.json. Rodam em paralelo; podem produzir blocking errors, prevent continuation ou success.
2. TaskCompleted Hooks (teammate mode)
Disparam para cada task in_progress owned por este agente.
3. TeammateIdle Hooks (teammate mode)
Disparam quando este teammate transiciona para idle.
Fire-and-Forget Background Tasks
Ignorados em bare mode (-p). Disparados sem await em modo interativo:
if (!isBareMode()) {
void executePromptSuggestion(stopHookContext)
if (feature('EXTRACT_MEMORIES') && isExtractModeActive()) {
void extractMemoriesModule!.executeExtractMemories(...)
}
if (!toolUseContext.agentId) {
void executeAutoDream(...)
}
}
💰 Token Budget - Auto-Continue
query/tokenBudget.ts implementa auto-continue para o SDK path. Quando um budget por turno e configurado, o engine verifica apos cada stop limpo se o modelo "usou" budget suficiente.
const COMPLETION_THRESHOLD = 0.9 // 90% usado = feito
const DIMINISHING_THRESHOLD = 500 // <500 novos tokens = sem progresso
export function checkTokenBudget(
tracker: BudgetTracker,
agentId: string | undefined,
budget: number | null,
globalTurnTokens: number,
): TokenBudgetDecision {
if (agentId || budget === null || budget <= 0) {
return { action: 'stop', completionEvent: null }
}
const pct = Math.round((globalTurnTokens / budget) * 100)
const isDiminishing =
tracker.continuationCount >= 3 &&
deltaSinceLastCheck < DIMINISHING_THRESHOLD &&
tracker.lastDeltaTokens < DIMINISHING_THRESHOLD
if (!isDiminishing && turnTokens < budget * COMPLETION_THRESHOLD) {
return { action: 'continue', nudgeMessage: ... }
}
return { action: 'stop', ... }
}
✅ Continua quando
- Tokens usados < 90% do budget
- Nao esta em diminishing returns
❌ Para quando
- Budget exaurido (>= 90%)
- Apos 3+ continuacoes, ambos deltas < 500 tokens
🗺️ Diagrama de Sequencia - Fluxo Completo
sequenceDiagram
participant User
participant QE as QueryEngine
participant Q as queryLoop()
participant QM as queryModel
participant API as Anthropic API
participant Tools as Tool Executor
participant SH as stopHooks
User->>QE: submitMessage(prompt)
QE->>QE: fetchSystemPromptParts()
QE->>Q: query({ messages, systemPrompt })
Q->>Q: applyToolResultBudget / microcompact / snip
loop queryLoop iteration
Q->>QM: callModel({ messages, tools })
QM->>API: POST /v1/messages (streaming)
API-->>QM: content_block_delta events
QM-->>Q: yield AssistantMessage
alt tool_use blocks present
Q->>Tools: runTools(toolUseBlocks)
Tools-->>Q: tool_result messages
Note over Q: loop continues
else no tool calls
Q->>SH: handleStopHooks()
alt hook blocking error
Q->>Q: append error, loop again
else clean stop
Q->>Q: checkTokenBudget()
Q-->>QE: Terminal { reason }
end
end
end
QE-->>User: yield SDKMessage stream