入冬前最后一趟远海。船主把一整天的柴油灌满了舱,临行撂下一句话:"油钱我付足了,今天就得把这舱柴油烧到见底再回——别撒两网觉得差不多了,就急着掉头盘点收获。"
天还没黑,掌舵的后生就想回港了。鱼舱里已经压了几筐,他觉得够本了,嘴里念叨着"收工、收工"。可这条船上,"收工"两个字,从来不是掌舵的一句话就算数。
船头蹲着个跟了三十年海的老把式。后生说要回,他先不应声,慢悠悠起身,照着这条船祖传的规矩一项项验:网收齐了没?锚链理顺了没?该补的窟窿补了没?——验下来只有两种结果。一种是他点头:"行,今天到此,回港。"这一锤定了音,谁也别想再多撒一网。另一种,是他把活计往后生怀里一推:"这张网底下还挂着半舱没拢上来,回去,拢干净了再说。"——这不是放行,是打回去返工;船哪儿也不去,接着干。
老把式点过了头、手里的活也没被打回,这才轮到看油表。船主的铁律摆在那儿:油没烧到九成,不准提前收工,更不准这会儿就忙着盘点今天打了多少斤。于是后生只得重新调转船头,再往深处去撒一网。
可这规矩也不是一根筋。倘若一连几网下去,网网都空,捞上来的还不够塞牙缝——再烧油也是白烧。老把式心里有数:到这光景,纵使油还没见底,也该收了。
而船舱角落那本航海日志,把每一次"为什么没回港"都记得清清楚楚:这一回是因为老把式打回去补网,那一回是因为油还没烧够。哪一桨是被什么逼着多划出去的,翻开本子,一目了然。
让这条船一趟趟都不空着舱回来的,从来不是后生想不想回家,而是这套章程:先过老把式那道关,再看油表,烧不动了才收,且每一次"再来一网"都记下缘由。
先猜揭晓前,先押一个猜测 · 你觉得这讲的是?
谜底 · The Concept
Loop control — stop conditions, the token budget, and state transitions (when a turn really ends)
The Agent Loop · 智能体循环
中文速览 · Quick read
The agent loop runs until the model stops asking for tools — but "the model emitted no tool call" is not the same as "the work is done." This page covers the three-gate decision that sits at the bottom of queryLoop (claude-code/src/query.ts:241) and decides, each time the model falls silent, whether the turn truly ends or the loop runs one more time.
Gate one is Stop hooks: user-configured rules that run after the model finishes and can either hard-veto termination or send the work back for rework (claude-code/src/query.ts:1267). Gate two is a token budget: a self-pacing meter that nudges the model to keep working until it has spent roughly 90% of an allocated output-token target, stopping earlier only if it detects diminishing returns (claude-code/src/query/tokenBudget.ts:45-93). Threading through both is a state transition label — a field on the loop's mutable State that records why the previous iteration continued (claude-code/src/query.ts:216), so the reason for every extra lap is auditable rather than implicit in the message stream.
Trace the bottom of one loop iteration. After the streaming API call (claude-code/src/query.ts:659) returns an assistant message that requests no further tools, control reaches handleStopHooks (claude-code/src/query.ts:1267) — stop conditions are evaluated after streaming completes but before any token-budget decision.
handleStopHooks runs the configured Stop hooks, and, when the session is a teammate, the TaskCompleted and TeammateIdle hooks in sequence (claude-code/src/query/stopHooks.ts:180-189, claude-code/src/query/stopHooks.ts:335-453). Their results split into two distinct outcomes:
- If any hook asks to prevent continuation, the handler short-circuits and returns
{ preventContinuation: true }(claude-code/src/query/stopHooks.ts:269-270, claude-code/src/query/stopHooks.ts:326-327); the loop returns immediately withreason: 'stop_hook_prevented'(claude-code/src/query.ts:1278). This is the hard "go home" — the turn ends. - If a hook instead emits blocking errors, those are injected back into the conversation as messages,
stopHookActiveis set totrue, the transition is labelled'stop_hook_blocking', and the loop continues so the model must address the complaint (claude-code/src/query.ts:1282-1305). This is "redo it," not "stop."
Only if stop hooks neither veto nor block does control reach checkTokenBudget (claude-code/src/query.ts:1309). The budget machinery is set up once per session: a BudgetTracker holding continuationCount, lastDeltaTokens, lastGlobalTurnTokens, and startedAt is created at loop entry (claude-code/src/query.ts:280, claude-code/src/query/tokenBudget.ts:6-11, claude-code/src/query/tokenBudget.ts:13-20). What it measures is the model's own output this turn: at turn start snapshotOutputTokensForTurn records outputTokensAtTurnStart and the turn's budget (claude-code/src/bootstrap/state.ts:724, claude-code/src/bootstrap/state.ts:733-736), and each iteration getTurnOutputTokens() returns the delta above that baseline (claude-code/src/bootstrap/state.ts:726-727).
checkTokenBudget then decides (claude-code/src/query/tokenBudget.ts:45-93):
- Subagents are exempt. If an
agentIdis set (or the budget is null/non-positive), it returnsstopimmediately — only the main session paces itself (claude-code/src/query/tokenBudget.ts:51-52). - Diminishing returns. With thresholds
COMPLETION_THRESHOLD = 0.9andDIMINISHING_THRESHOLD = 500(claude-code/src/query/tokenBudget.ts:3-4), it computesisDiminishing— true when there have already been 3+ continuations and both the delta since the last check and the previous delta are under 500 tokens (claude-code/src/query/tokenBudget.ts:59-62). That is the "several empty nets" case: still pushing, producing almost nothing. - Continue. If not diminishing and the turn's output is still under 90% of the budget, it returns
action: 'continue'with anudgeMessage, incrementingcontinuationCountand recording the deltas (claude-code/src/query/tokenBudget.ts:64-76). The nudge text isStopped at X% of token target (Y / Z). Keep working — do not summarize.(claude-code/src/utils/tokenBudget.ts:66-72) — note it explicitly forbids wrapping up early with a summary.
On a continue decision the loop increments the session continuation counter (claude-code/src/bootstrap/state.ts:741-742), appends the nudge as a meta user message, labels the transition 'token_budget_continuation', and loops back (claude-code/src/query.ts:1316-1340). Otherwise the budget returns stop and the loop ends with reason: 'completed' (claude-code/src/query.ts:1357). (The ordinary tool-using path is different: when the model did request tools, the loop executes them and re-laps with transition 'next_turn' — claude-code/src/query.ts:1384, claude-code/src/query.ts:1725 — without consulting stop conditions at all.)
These three gates separate three things a naïve loop conflates: the model thinks it's finished, policy says it may finish, and the budget says it's worth finishing.
The token budget exists because models tend to declare victory and summarize too early. By measuring output tokens produced this turn against a target and injecting a "keep working — do not summarize" nudge until ~90% is spent (claude-code/src/query/tokenBudget.ts:64-76, claude-code/src/utils/tokenBudget.ts:66-72), the harness converts an allocated effort into actually-delivered work. The diminishing-returns escape hatch (claude-code/src/query/tokenBudget.ts:59-62) keeps that from becoming wasteful: once the model is clearly spinning — three-plus continuations yielding sub-500-token bursts — the loop stops even with budget left, rather than burning the remainder on empty laps.
Stop hooks matter because they give external policy two genuinely different verbs. preventContinuation is a clean veto that ends the turn (claude-code/src/query.ts:1278); blocking errors are a bounce-back that re-runs the model with the objection in context and stopHookActive: true (claude-code/src/query.ts:1282-1305). The stopHookActive flag and the preserved reactive-compact guard in that branch are deliberate loop-safety: the source comment there records that naïvely resetting state on a stop-hook bounce produced an infinite compact→error→bounce cycle.
The transition label is the cheap part that makes the rest legible. Because every continuation site writes a reason — 'stop_hook_blocking', 'token_budget_continuation', 'next_turn' (claude-code/src/query.ts:1302, claude-code/src/query.ts:1338, claude-code/src/query.ts:1725) — onto the State (claude-code/src/query.ts:216), the why of each lap is first-class data, which (per the field's own comment) lets tests assert that a recovery path fired without inspecting message contents.
- The loop and its mutable
State.transitionfield: claude-code/src/query.ts:241, claude-code/src/query.ts:216. - Stop conditions, evaluated after streaming, before budget: claude-code/src/query.ts:1267; the two outcomes — veto (claude-code/src/query.ts:1278, claude-code/src/query/stopHooks.ts:326-327) and bounce-back (claude-code/src/query.ts:1282-1305).
- The hook types that run, in order: claude-code/src/query/stopHooks.ts:180-189, claude-code/src/query/stopHooks.ts:335-453; the preventContinuation signal: claude-code/src/query/stopHooks.ts:269-270.
- The budget tracker, baseline, and delta: claude-code/src/query.ts:280, claude-code/src/query/tokenBudget.ts:6-11, claude-code/src/bootstrap/state.ts:733-736, claude-code/src/bootstrap/state.ts:726-727.
- The continue/stop decision, 90% threshold, and diminishing returns: claude-code/src/query/tokenBudget.ts:45-93, claude-code/src/query/tokenBudget.ts:64-76, claude-code/src/query/tokenBudget.ts:59-62, claude-code/src/query/tokenBudget.ts:3-4.
- The nudge message and its injection + transition: claude-code/src/utils/tokenBudget.ts:66-72, claude-code/src/query.ts:1316-1340.
classDiagram
class handleStopHooks {
<<after streaming>>
+runStopHooks()
+preventContinuation bool
+blockingErrors
}
class checkTokenBudget {
+COMPLETION_THRESHOLD 90pct
+DIMINISHING_THRESHOLD 500tok
+decide() action
}
class BudgetTracker {
+continuationCount number
+lastDeltaTokens number
+lastGlobalTurnTokens number
+startedAt number
}
class Decision {
<<enum>>
stop
continue
}
handleStopHooks ..> checkTokenBudget : only if no veto or block
checkTokenBudget *-- BudgetTracker : reads and updates
checkTokenBudget ..> Decision : returns 读法:流式结束后先评 stop 条件(q.ts:1267),再到预算。Stop 钩子若
preventContinuation→ 立即结束 stop_hook_prevented(q.ts:1278);若发回
blockingErrors→ 注入消息、续跑 stop_hook_blocking(q.ts:1282-1305)。
checkTokenBudget(tokenBudget.ts:45-93):子代理豁免(:51);未到 90% 且非
diminishing(3+ 续跑且增量<500)则 continue 带 nudge「Keep working—do not summarize」
(utils/tokenBudget.ts:66)。
你来当这一回合里"快写完了"的模型。每点一次下面的按钮,就是模型在这一趟里
只吐了文字、没有发起 tool_use——一次循环迭代。可一回合不是模型觉得够了就算完:
模型停笔后,harness 先过 handleStopHooks 那道关,再看 token 预算;只要这趟产出
还没烧到 6,000 的九成(5,400),它就把同一句话塞回来——Keep working — do not summarize
——逼你再走一趟。试试看你能不能"收工"。
getTurnOutputTokens() 0 / 6,000 count≥3 && Δprev<500 && Δnow<500 Δprev — · Δnow — isDiminishing: false state.transition per pass
回合刚开始(snapshotOutputTokensForTurn(6000) 已把基线、tracker、continuationCount 归零)。
让模型吐一段产出,看 harness 放不放你收工。
停止条件在预算之前评估:preventContinuation 一票否决直接回港、根本不看油表;
blockingErrors 是把活打回返工(续作,不是停);只有两关都过,才轮到
checkTokenBudget——子代理(agentId)在它第一行就短路收工,从不被催。
这就是"模型觉得做完了"≠"该停了"。
data flow: snapshotOutputTokensForTurn(budget) · state.ts:733-736 →
getTurnOutputTokens() = total − baseline · state.ts:726-727 →
BudgetTracker · query.ts:280 / tokenBudget.ts:6-11
What it is
The agent loop runs until the model stops asking for tools — but "the model emitted no tool call" is not the same as "the work is done." This page covers the three-gate decision that sits at the bottom of queryLoop (claude-code/src/query.ts:241) and decides, each time the model falls silent, whether the turn truly ends or the loop runs one more time.
Gate one is Stop hooks: user-configured rules that run after the model finishes and can either hard-veto termination or send the work back for rework (claude-code/src/query.ts:1267). Gate two is a token budget: a self-pacing meter that nudges the model to keep working until it has spent roughly 90% of an allocated output-token target, stopping earlier only if it detects diminishing returns (claude-code/src/query/tokenBudget.ts:45-93). Threading through both is a state transition label — a field on the loop's mutable State that records why the previous iteration continued (claude-code/src/query.ts:216), so the reason for every extra lap is auditable rather than implicit in the message stream.
How it works
Trace the bottom of one loop iteration. After the streaming API call (claude-code/src/query.ts:659) returns an assistant message that requests no further tools, control reaches handleStopHooks (claude-code/src/query.ts:1267) — stop conditions are evaluated after streaming completes but before any token-budget decision.
handleStopHooks runs the configured Stop hooks, and, when the session is a teammate, the TaskCompleted and TeammateIdle hooks in sequence (claude-code/src/query/stopHooks.ts:180-189, claude-code/src/query/stopHooks.ts:335-453). Their results split into two distinct outcomes:
- If any hook asks to prevent continuation, the handler short-circuits and returns
{ preventContinuation: true }(claude-code/src/query/stopHooks.ts:269-270, claude-code/src/query/stopHooks.ts:326-327); the loop returns immediately withreason: 'stop_hook_prevented'(claude-code/src/query.ts:1278). This is the hard "go home" — the turn ends. - If a hook instead emits blocking errors, those are injected back into the conversation as messages,
stopHookActiveis set totrue, the transition is labelled'stop_hook_blocking', and the loop continues so the model must address the complaint (claude-code/src/query.ts:1282-1305). This is "redo it," not "stop."
Only if stop hooks neither veto nor block does control reach checkTokenBudget (claude-code/src/query.ts:1309). The budget machinery is set up once per session: a BudgetTracker holding continuationCount, lastDeltaTokens, lastGlobalTurnTokens, and startedAt is created at loop entry (claude-code/src/query.ts:280, claude-code/src/query/tokenBudget.ts:6-11, claude-code/src/query/tokenBudget.ts:13-20). What it measures is the model's own output this turn: at turn start snapshotOutputTokensForTurn records outputTokensAtTurnStart and the turn's budget (claude-code/src/bootstrap/state.ts:724, claude-code/src/bootstrap/state.ts:733-736), and each iteration getTurnOutputTokens() returns the delta above that baseline (claude-code/src/bootstrap/state.ts:726-727).
checkTokenBudget then decides (claude-code/src/query/tokenBudget.ts:45-93):
- Subagents are exempt. If an
agentIdis set (or the budget is null/non-positive), it returnsstopimmediately — only the main session paces itself (claude-code/src/query/tokenBudget.ts:51-52). - Diminishing returns. With thresholds
COMPLETION_THRESHOLD = 0.9andDIMINISHING_THRESHOLD = 500(claude-code/src/query/tokenBudget.ts:3-4), it computesisDiminishing— true when there have already been 3+ continuations and both the delta since the last check and the previous delta are under 500 tokens (claude-code/src/query/tokenBudget.ts:59-62). That is the "several empty nets" case: still pushing, producing almost nothing. - Continue. If not diminishing and the turn's output is still under 90% of the budget, it returns
action: 'continue'with anudgeMessage, incrementingcontinuationCountand recording the deltas (claude-code/src/query/tokenBudget.ts:64-76). The nudge text isStopped at X% of token target (Y / Z). Keep working — do not summarize.(claude-code/src/utils/tokenBudget.ts:66-72) — note it explicitly forbids wrapping up early with a summary.
On a continue decision the loop increments the session continuation counter (claude-code/src/bootstrap/state.ts:741-742), appends the nudge as a meta user message, labels the transition 'token_budget_continuation', and loops back (claude-code/src/query.ts:1316-1340). Otherwise the budget returns stop and the loop ends with reason: 'completed' (claude-code/src/query.ts:1357). (The ordinary tool-using path is different: when the model did request tools, the loop executes them and re-laps with transition 'next_turn' — claude-code/src/query.ts:1384, claude-code/src/query.ts:1725 — without consulting stop conditions at all.)
Why it matters
These three gates separate three things a naïve loop conflates: the model thinks it's finished, policy says it may finish, and the budget says it's worth finishing.
The token budget exists because models tend to declare victory and summarize too early. By measuring output tokens produced this turn against a target and injecting a "keep working — do not summarize" nudge until ~90% is spent (claude-code/src/query/tokenBudget.ts:64-76, claude-code/src/utils/tokenBudget.ts:66-72), the harness converts an allocated effort into actually-delivered work. The diminishing-returns escape hatch (claude-code/src/query/tokenBudget.ts:59-62) keeps that from becoming wasteful: once the model is clearly spinning — three-plus continuations yielding sub-500-token bursts — the loop stops even with budget left, rather than burning the remainder on empty laps.
Stop hooks matter because they give external policy two genuinely different verbs. preventContinuation is a clean veto that ends the turn (claude-code/src/query.ts:1278); blocking errors are a bounce-back that re-runs the model with the objection in context and stopHookActive: true (claude-code/src/query.ts:1282-1305). The stopHookActive flag and the preserved reactive-compact guard in that branch are deliberate loop-safety: the source comment there records that naïvely resetting state on a stop-hook bounce produced an infinite compact→error→bounce cycle.
The transition label is the cheap part that makes the rest legible. Because every continuation site writes a reason — 'stop_hook_blocking', 'token_budget_continuation', 'next_turn' (claude-code/src/query.ts:1302, claude-code/src/query.ts:1338, claude-code/src/query.ts:1725) — onto the State (claude-code/src/query.ts:216), the why of each lap is first-class data, which (per the field's own comment) lets tests assert that a recovery path fired without inspecting message contents.
Read the source
- The loop and its mutable
State.transitionfield: claude-code/src/query.ts:241, claude-code/src/query.ts:216. - Stop conditions, evaluated after streaming, before budget: claude-code/src/query.ts:1267; the two outcomes — veto (claude-code/src/query.ts:1278, claude-code/src/query/stopHooks.ts:326-327) and bounce-back (claude-code/src/query.ts:1282-1305).
- The hook types that run, in order: claude-code/src/query/stopHooks.ts:180-189, claude-code/src/query/stopHooks.ts:335-453; the preventContinuation signal: claude-code/src/query/stopHooks.ts:269-270.
- The budget tracker, baseline, and delta: claude-code/src/query.ts:280, claude-code/src/query/tokenBudget.ts:6-11, claude-code/src/bootstrap/state.ts:733-736, claude-code/src/bootstrap/state.ts:726-727.
- The continue/stop decision, 90% threshold, and diminishing returns: claude-code/src/query/tokenBudget.ts:45-93, claude-code/src/query/tokenBudget.ts:64-76, claude-code/src/query/tokenBudget.ts:59-62, claude-code/src/query/tokenBudget.ts:3-4.
- The nudge message and its injection + transition: claude-code/src/utils/tokenBudget.ts:66-72, claude-code/src/query.ts:1316-1340.
来源 · Source citations
- [1]
claude-code/src/query.ts:216 - [2]
claude-code/src/query.ts:241 - [3]
claude-code/src/query.ts:280 - [4]
claude-code/src/query.ts:659 - [5]
claude-code/src/query.ts:1267 - [6]
claude-code/src/query.ts:1278 - [7]
claude-code/src/query.ts:1282-1305 - [8]
claude-code/src/query.ts:1302 - [9]
claude-code/src/query.ts:1309 - [10]
claude-code/src/query.ts:1316-1340 - [11]
claude-code/src/query.ts:1338 - [12]
claude-code/src/query.ts:1357 - [13]
claude-code/src/query.ts:1384 - [14]
claude-code/src/query.ts:1725 - [15]
claude-code/src/query/stopHooks.ts:65-81 - [16]
claude-code/src/query/stopHooks.ts:180-189 - [17]
claude-code/src/query/stopHooks.ts:269-270 - [18]
claude-code/src/query/stopHooks.ts:326-327 - [19]
claude-code/src/query/stopHooks.ts:335-453 - [20]
claude-code/src/query/tokenBudget.ts:3-4 - [21]
claude-code/src/query/tokenBudget.ts:6-11 - [22]
claude-code/src/query/tokenBudget.ts:13-20 - [23]
claude-code/src/query/tokenBudget.ts:22-43 - [24]
claude-code/src/query/tokenBudget.ts:45-93 - [25]
claude-code/src/query/tokenBudget.ts:51-52 - [26]
claude-code/src/query/tokenBudget.ts:59-62 - [27]
claude-code/src/query/tokenBudget.ts:64-76 - [28]
claude-code/src/bootstrap/state.ts:724 - [29]
claude-code/src/bootstrap/state.ts:726-727 - [30]
claude-code/src/bootstrap/state.ts:733-736 - [31]
claude-code/src/bootstrap/state.ts:741-742 - [32]
claude-code/src/utils/tokenBudget.ts:66-72