玻璃房里那位顾问,上一程只能隔着玻璃说话——他判断得再准,也碰不到外面半样东西。
这一程,你在那面玻璃上开了一扇小小的递物窗,一巴掌宽,刚够递进递出。你和他约法三章。
第一,你把委托写在纸条上,从窗口塞进去。他读完,要么直接给结论,要么在纸角写一句「去把三号柜的账本取来」。
第二,只要他末尾还挂着这样一句吩咐,你就绝不走开——你照着去办,把取回的账本、量好的尺寸,原封不动从窗口递回去。
第三,你从不清空之前的往来。每一次,你把先前所有的纸条、连同刚取回的东西,整摞一起递进去,让他始终看得见全程。
于是就成了一段来回:塞纸条 → 他读、他吩咐 → 你跑腿 → 把结果添回那一摞、再塞进去 → 他接着读……一圈又一圈。什么时候停?就等他哪一回读完,末尾不再挂任何吩咐,只说了句「齐了」——你便知道这趟到头,把成稿交出去。
一扇递物窗,加上「有吩咐就办、办完添回去、直到他说齐了」这一条铁规,就把一个只会说话的玻璃房,变成了一个能查、能试、能自己一步步往下走的帮手。少了这条铁规,他喊一句「取账本」也是白喊——没人跑腿,话到此为止。
门外这套一圈接一圈、永不主动收手的来回,就是智能体的心脏:the agent loop——一个 while 循环,只要模型这一轮的 stop_reason 还是 tool_use,就把它点名的工具跑掉、把 tool_result 添回消息摞里,再问下一轮,直到它说 end_turn。
先猜揭晓前,先押一个猜测 · 你觉得这讲的是?
谜底 · The Concept
Stage 1 — the agent loop: while stop_reason is tool_use, run tools and feed results back
Build From Scratch · 从零构建
中文速览 · Quick read
Stage 0 was a single messages.create call: it talks, it cannot act. Stage 1 makes the smallest change that turns talks into acts — it wraps that one-shot call in a loop. Each pass: hand the model a tool, read its stop_reason, run whatever tool it asked for, append the tool_result back onto the transcript, and call again — repeating until the model stops asking (stop_reason: end_turn). About 40 lines. Deliberately nothing else: no permission gate, no real filesystem hands beyond one toy tool, no compaction — those are stages 2–4. This stage isolates the one idea every agent is built on: the feedback loop that splices a tool's output back into the conversation that produced the request.
Walk tutorial/01-loop/agent.ts top to bottom. The transcript is a plain array, seeded with the user's task — it will grow as the loop runs:
const messages: Anthropic.MessageParam[] = [
{ role: 'user', content: process.argv[2] ?? 'What files are in this project? Look before you answer.' },
];
One toy tool, declared as a JSON schema so the model knows it exists (real hands arrive in stage 2):
const tools: Anthropic.Tool[] = [
{
name: 'list_files',
description: 'List the files in a directory of the current project.',
input_schema: {
type: 'object',
properties: { path: { type: 'string', description: 'directory to list, e.g. "."' } },
required: ['path'],
},
},
];
Then the whole engine — a bare while (true):
while (true) {
const res = await client.messages.create({ model: MODEL, max_tokens: 1024, messages, tools });
messages.push({ role: 'assistant', content: res.content });
for (const b of res.content) if (b.type === 'text') console.log(b.text);
if (res.stop_reason !== 'tool_use') break; // the model stopped asking — we're done
const results: Anthropic.ToolResultBlockParam[] = res.content
.filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use')
.map((tu) => {
console.log(` ⚙ turn ${++turn}: ${tu.name}(${JSON.stringify(tu.input)})`);
return { type: 'tool_result', tool_use_id: tu.id, content: runTool(tu.name, tu.input as Record<string, unknown>) };
});
messages.push({ role: 'user', content: results });
}
Five phases per pass: ① call the model with the full transcript; ② push the assistant reply onto the transcript (print any text); ③ read stop_reason — if it is not tool_use, break (the model gave a final answer); ④ run each tool_use block through runTool, wrapping the output as a tool_result that carries the matching tool_use_id; ⑤ push those results back as a user-role message and loop. Two details are load-bearing: the tool_result must reference the request's tool_use_id, and results re-enter as a user turn — that is literally the hand passing the fetched item back through the window. Every tool round adds exactly two messages (one assistant, one user), which is why the closing line prints messages.length.
ANTHROPIC_API_KEY=… MODEL=claude-sonnet-5 bun run tutorial/01-loop/agent.ts "what files are here?"
A one-round transcript looks like:
I'll look before I answer.
⚙ turn 1: list_files({"path":"."})
Here are the files in this project: agent.ts, package.json, README.md, tsconfig.json.
[4 messages in the transcript — every tool round added 2]
Read the message count: seed user (1) → model asks for the tool, assistant pushed (2) → tool_result fed back, user pushed (3) → model answers with stop_reason: end_turn, assistant pushed (4) → break. One tool round, four messages.
Each line of the toy loop has a direct counterpart in Claude Code's queryLoop:
- Our
while (true)↔ the real barewhile (true)(claude-code/src/query.ts:307). Both are infinite loops exited only from inside. - Our
client.messages.create({ messages, tools })↔ the streamed model calldeps.callModel({ ... })(claude-code/src/query.ts:659). - Our
res.content.filter(b => b.type === 'tool_use')↔ the collection step that pushes assistant messages and flipsneedsFollowUp = truethe instant anytool_useblock appears (claude-code/src/query.ts:826-844). - Our
if (res.stop_reason !== 'tool_use') break↔ the branchif (!needsFollowUp)(claude-code/src/query.ts:1062) leading toreturn { reason: 'completed' }(claude-code/src/query.ts:1357). Note the difference: the real loop returns a Terminal rather thanbreaking, and it branches on a boolean accumulated during streaming rather than readingstop_reasondirectly. - Our
runTool(tu.name, tu.input)for each block ↔runTools(toolUseBlocks, …)(or a concurrent streaming executor) (claude-code/src/query.ts:1380-1382). - Our
{ type: 'tool_result', tool_use_id, content }collection ↔ the loop that normalizes each update and pushes it intotoolResults(claude-code/src/query.ts:1384-1400). - Our
messages.push({ role: 'user', content: results })— the feed-back — ↔ rebuilding the next state's messages as[...messagesForQuery, ...assistantMessages, ...toolResults](claude-code/src/query.ts:1716).
Where our loop has exactly one path back to the top, the real one has seven (prompt-too-long recovery, reactive compaction, max-output-token escalation, stop-hooks, token-budget nudges…). The full turn cycle is dissected in 故事 · loop/1; those seven re-entry paths and the control machinery around them in 故事 · loop/2.
Stage 0 (tutorial/00-bare-model/agent.ts) is the same messages.create call with no loop and no tools. Ask it "fix the failing test" and it answers from imagination — one assistant message, stop_reason: end_turn, conversation over. It never sees your repo because there is no phase that runs a tool and folds the result back in. The loop is the single addition that converts a monologue into an investigation. Crucially, without the loop it is pointless to even give the model a tool: a stop_reason: tool_use would be a dead end — the model would ask list_files(".") and nobody would ever run it, so the result never returns and the model stays exactly as blind as before. The while is what makes tool_use mean something.
classDiagram
class AgentLoop {
<<while(true)>>
+callModel()
+readStopReason()
+breakOn end_turn
}
class messages {
<<MessageParam[]>>
+seed user task
+push assistant
+push tool_result
}
class tools {
<<Anthropic.Tool[]>>
+list_files
+input_schema
}
class runTool {
+name string
+input Record
+returns string
}
class ToolResult {
<<tool_result>>
+tool_use_id
+content
}
AgentLoop ..> messages : reads + grows
AgentLoop ..> tools : offers each turn
AgentLoop ..> runTool : dispatch tool_use
runTool ..> ToolResult : wraps output
ToolResult ..> messages : push as user turn 读法:tutorial/01-loop/agent.ts 的 while(true) 每一轮调用模型(callModel)、
读 stop_reason——非 tool_use 就 break。否则把每个 tool_use 交给
runTool 跑掉,包成 tool_result(带 tool_use_id)当 user 轮推回
messages,再问下一轮。对照真身:while(query.ts:307)、模型调用
(query.ts:659)、needsFollowUp 判分(query.ts:826-844,1062)、跑工具
(query.ts:1380-1400)、拼回下一轮消息(query.ts:1716)。
这就是你那 40 行循环,一阶段一阶段走。初始委托已经压进 messages;
每按一次「下一阶段」,就推进一步:①调用模型 → ②读 stop_reason → ③跑工具 → ④把 tool_result 喂回,
然后回到循环顶。右边那摞 messages 会一点点长高——每个工具回合恰好 +2。
走到某一轮模型说 end_turn,循环 break,这趟结束。
按「下一阶段」开始。第一步:把整摞 messages 连同 tools 发给模型。
这正是智能体循环:一个 while(true),每轮调用模型 → 读 stop_reason——
非 tool_use 就收工;否则跑工具、把 tool_result 当 user 轮喂回 messages,再问下一轮。
工具的输出被拼回产生这次请求的同一段对话,正是这一步把「只会说话」变成「能查、能试、能自己往下走」。
The goal
Stage 0 was a single messages.create call: it talks, it cannot act. Stage 1 makes the smallest change that turns talks into acts — it wraps that one-shot call in a loop. Each pass: hand the model a tool, read its stop_reason, run whatever tool it asked for, append the tool_result back onto the transcript, and call again — repeating until the model stops asking (stop_reason: end_turn). About 40 lines. Deliberately nothing else: no permission gate, no real filesystem hands beyond one toy tool, no compaction — those are stages 2–4. This stage isolates the one idea every agent is built on: the feedback loop that splices a tool's output back into the conversation that produced the request.
Build it
Walk tutorial/01-loop/agent.ts top to bottom. The transcript is a plain array, seeded with the user's task — it will grow as the loop runs:
const messages: Anthropic.MessageParam[] = [
{ role: 'user', content: process.argv[2] ?? 'What files are in this project? Look before you answer.' },
];
One toy tool, declared as a JSON schema so the model knows it exists (real hands arrive in stage 2):
const tools: Anthropic.Tool[] = [
{
name: 'list_files',
description: 'List the files in a directory of the current project.',
input_schema: {
type: 'object',
properties: { path: { type: 'string', description: 'directory to list, e.g. "."' } },
required: ['path'],
},
},
];
Then the whole engine — a bare while (true):
while (true) {
const res = await client.messages.create({ model: MODEL, max_tokens: 1024, messages, tools });
messages.push({ role: 'assistant', content: res.content });
for (const b of res.content) if (b.type === 'text') console.log(b.text);
if (res.stop_reason !== 'tool_use') break; // the model stopped asking — we're done
const results: Anthropic.ToolResultBlockParam[] = res.content
.filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use')
.map((tu) => {
console.log(` ⚙ turn ${++turn}: ${tu.name}(${JSON.stringify(tu.input)})`);
return { type: 'tool_result', tool_use_id: tu.id, content: runTool(tu.name, tu.input as Record<string, unknown>) };
});
messages.push({ role: 'user', content: results });
}
Five phases per pass: ① call the model with the full transcript; ② push the assistant reply onto the transcript (print any text); ③ read stop_reason — if it is not tool_use, break (the model gave a final answer); ④ run each tool_use block through runTool, wrapping the output as a tool_result that carries the matching tool_use_id; ⑤ push those results back as a user-role message and loop. Two details are load-bearing: the tool_result must reference the request's tool_use_id, and results re-enter as a user turn — that is literally the hand passing the fetched item back through the window. Every tool round adds exactly two messages (one assistant, one user), which is why the closing line prints messages.length.
Run it
ANTHROPIC_API_KEY=… MODEL=claude-sonnet-5 bun run tutorial/01-loop/agent.ts "what files are here?"
A one-round transcript looks like:
I'll look before I answer.
⚙ turn 1: list_files({"path":"."})
Here are the files in this project: agent.ts, package.json, README.md, tsconfig.json.
[4 messages in the transcript — every tool round added 2]
Read the message count: seed user (1) → model asks for the tool, assistant pushed (2) → tool_result fed back, user pushed (3) → model answers with stop_reason: end_turn, assistant pushed (4) → break. One tool round, four messages.
对照 the real one
Each line of the toy loop has a direct counterpart in Claude Code's queryLoop:
- Our
while (true)↔ the real barewhile (true)(claude-code/src/query.ts:307). Both are infinite loops exited only from inside. - Our
client.messages.create({ messages, tools })↔ the streamed model calldeps.callModel({ ... })(claude-code/src/query.ts:659). - Our
res.content.filter(b => b.type === 'tool_use')↔ the collection step that pushes assistant messages and flipsneedsFollowUp = truethe instant anytool_useblock appears (claude-code/src/query.ts:826-844). - Our
if (res.stop_reason !== 'tool_use') break↔ the branchif (!needsFollowUp)(claude-code/src/query.ts:1062) leading toreturn { reason: 'completed' }(claude-code/src/query.ts:1357). Note the difference: the real loop returns a Terminal rather thanbreaking, and it branches on a boolean accumulated during streaming rather than readingstop_reasondirectly. - Our
runTool(tu.name, tu.input)for each block ↔runTools(toolUseBlocks, …)(or a concurrent streaming executor) (claude-code/src/query.ts:1380-1382). - Our
{ type: 'tool_result', tool_use_id, content }collection ↔ the loop that normalizes each update and pushes it intotoolResults(claude-code/src/query.ts:1384-1400). - Our
messages.push({ role: 'user', content: results })— the feed-back — ↔ rebuilding the next state's messages as[...messagesForQuery, ...assistantMessages, ...toolResults](claude-code/src/query.ts:1716).
Where our loop has exactly one path back to the top, the real one has seven (prompt-too-long recovery, reactive compaction, max-output-token escalation, stop-hooks, token-budget nudges…). The full turn cycle is dissected in 故事 · loop/1; those seven re-entry paths and the control machinery around them in 故事 · loop/2.
What breaks without it
Stage 0 (tutorial/00-bare-model/agent.ts) is the same messages.create call with no loop and no tools. Ask it "fix the failing test" and it answers from imagination — one assistant message, stop_reason: end_turn, conversation over. It never sees your repo because there is no phase that runs a tool and folds the result back in. The loop is the single addition that converts a monologue into an investigation. Crucially, without the loop it is pointless to even give the model a tool: a stop_reason: tool_use would be a dead end — the model would ask list_files(".") and nobody would ever run it, so the result never returns and the model stays exactly as blind as before. The while is what makes tool_use mean something.
来源 · Source citations
- [1]
claude-code/src/query.ts:307 - [2]
claude-code/src/query.ts:659 - [3]
claude-code/src/query.ts:826-844 - [4]
claude-code/src/query.ts:1062 - [5]
claude-code/src/query.ts:1357 - [6]
claude-code/src/query.ts:1380-1382 - [7]
claude-code/src/query.ts:1384-1400 - [8]
claude-code/src/query.ts:1716