Follow up PR #265: refine chapters, diagrams, and add S20 (#283)

* feat: s01-s14 docs quality overhaul — tool pipeline, single-agent, knowledge & resilience

Rewrite code.py and README (zh/en/ja) for s01-s14, each chapter building
incrementally on the previous. Key fixes across chapters:

- s01-s04: agent loop, tool dispatch, permission pipeline, hooks
- s05-s08: todo write, subagent, skill loading, context compact
- s09-s11: memory system, system prompt assembly, error recovery
- s12-s14: task graph, background tasks, cron scheduler

All chapters CC source-verified. Code inherits fixes forward (PROMPT_SECTIONS,
json.dumps cache, real-state context, can_start dep protection, etc.).

* feat: s15-s19 docs quality overhaul — multi-agent platform: teams, protocols, autonomy, worktree, MCP tools

Rewrite code.py and README (zh/en/ja) for s15-s19, the multi-agent platform
chapters. Each chapter inherits all previous fixes and adds one mechanism:

- s15: agent teams (TeamCreate, teammate threads, shared task list)
- s16: team protocols (plan approval, shutdown handshake, consume_inbox)
- s17: autonomous agents (idle polling, auto-claim, consume_lead_inbox)
- s18: worktree isolation (git worktree, bind_task, cwd switching, safety)
- s19: MCP tools (MCPClient, normalize_mcp_name, assemble_tool_pool, no cache)

All appendix source code references verified against CC source. Config priority
corrected: claude.ai < plugin < user < project < local.

* fix: 5 regressions across s05-s19 — glob safety, todo validation, memory extraction, protocol types, dep crash

- s05-s09: glob results now filter with is_relative_to(WORKDIR) (inherited from s02)
- s06-s08: todo_write validates content/status required fields (inherited from s05)
- s09: extract_memories uses pre-compression snapshot instead of compacted messages
- s16: submit_plan docstring clarifies protocol-only (not code-level gate)
- s17-s19: match_response restores type mismatch validation (from s16)
- s17-s19: claim_task deps list handles missing dep files without crashing

* fix: s12 Todo V2 logic reversal, s14/s15 cron range validation, s18/s19 worktree name validation

- s12 README (zh/en/ja): fix Todo V2 direction — interactive defaults to Task,
  non-interactive/SDK defaults to TodoWrite. Fix env var name to
  CLAUDE_CODE_ENABLE_TASKS (not TODO_V2).
- s14/s15: add _validate_cron_field with per-field range checks (minute 0-59,
  hour 0-23, dom 1-31, month 1-12, dow 0-6), step > 0, range lo <= hi.
  Replace old try/except validation that only caught exceptions.
- s18/s19: add validate_worktree_name() to remove_worktree and keep_worktree,
  not just create_worktree.

* fix: align s16-s19 teaching tool consistency

* fix pr265 chapter diagrams

* Add comprehensive s20 harness chapter

* Fix chapter smoke test regressions

* Clarify README tutorial track transition

---------

Co-authored-by: Haoran <bill-billion@outlook.com>
This commit is contained in:
gui-yue
2026-05-20 21:45:38 +08:00
committed by GitHub
parent c354cf7721
commit 1baf1aca5a
174 changed files with 35833 additions and 353 deletions

207
s01_agent_loop/README.en.md Normal file
View File

@@ -0,0 +1,207 @@
# s01: The Agent Loop — One Loop Is All You Need
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20
> *"One loop & Bash is all you need"* — One tool + one loop = one Agent.
>
> **Harness Layer**: The Loop — the first bridge between the model and the real world.
---
## The Problem
You ask the model: "List the files in my directory and run XXX.py."
The model can output a bash command, but once it's done outputting, it stops — it won't execute the command on its own, and it won't keep reasoning based on the result.
You could run it manually, paste the output back into the chat, and let it continue. Next command comes out, you run it again, paste it back.
Every round-trip, you're the middle layer. Automating that is what this chapter is about.
---
## The Solution
![Agent Loop](images/agent-loop.en.svg)
A `while True` loop: keep going when the model calls a tool, stop when it doesn't. The entire process hinges on two signals:
| Signal | Meaning | Loop Action |
|--------|---------|-------------|
| `stop_reason == "tool_use"` | Model raises hand: "I need a tool" | Execute → feed result back → continue |
| `stop_reason != "tool_use"` | Model says: "I'm done" | Exit loop |
---
## How It Works
Let's translate this process into code. Step by step:
**Step 1**: Start with the user's question as the first message.
```python
messages = [{"role": "user", "content": query}]
```
**Step 2**: Send the messages and tool definitions to the LLM.
```python
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
```
**Step 3**: Append the model's response and check whether it called a tool. No tool call → done.
```python
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
```
**Step 4**: Execute the tool the model requested and collect the results.
```python
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
```
**Step 5**: Append the tool results as a new message and go back to Step 2.
```python
messages.append({"role": "user", "content": results})
```
Assembled into a complete function:
```python
def agent_loop(messages):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
Under 30 lines — that's the minimal runnable agent harness kernel. It's not intelligence itself, but the smallest runtime framework that lets the model keep acting. The model decides (whether to call a tool, which one), the harness executes (if called, run it, feed the result back). The next 18 chapters all add mechanisms on top of this loop. The loop itself never changes.
---
## Try It
> **Teaching demo notice**: The code executes shell commands generated by the model. Run it in a temporary test directory to avoid affecting your project files. s03 covers the real permission system.
**Setup** (first run):
```sh
pip install -r requirements.txt
cp .env.example .env
# Edit .env, fill in ANTHROPIC_API_KEY and MODEL_ID
```
**Run**:
```sh
python s01_agent_loop/code.py
```
Try these prompts:
1. `Create a file called hello.py that prints "Hello, World!"`
2. `List all Python files in this directory`
3. `What is the current git branch?`
What to watch for: When does the model call a tool (loop continues), and when does it not (loop ends)?
---
## What's Next
Right now the model only has bash — reading files requires `cat`, writing files requires `echo ... >`, finding files requires `find`. Ugly and error-prone.
→ s02 Tool Use: What happens when we give it 5 proper tools? Will the model call multiple tools at once? Will parallel tool executions step on each other?
<details>
<summary>Dive into CC Source Code</summary>
> The following is based on a review of CC source code `src/query.ts` (1729 lines). The core differences are twofold: CC doesn't rely on the `stop_reason` field to decide whether to continue the loop — instead it checks whether the content contains `tool_use` blocks (because `stop_reason` is unreliable in streaming responses); CC has more exit paths and recovery strategies for production-grade protection.
**The 30-line `while True` from the teaching version IS the core of CC's 1729 lines.** Everything below is a protection mechanism layered on top of that core.
<details>
<summary>1. Loop Structure Differences</summary>
The teaching version checks `response.stop_reason`. CC doesn't use it as the sole signal for loop continuation — in streaming responses, `stop_reason` may not have updated yet even though `tool_use` blocks are already present. CC uses a `needsFollowUp` flag: during streaming message reception (`query.ts:830-834`), it's set to `true` whenever a `tool_use` block is detected. `QueryEngine.ts` captures the real `stop_reason` from `message_delta` for other logic, but the query loop itself relies on `needsFollowUp`.
```typescript
// query.ts:554-558
// stop_reason === 'tool_use' is unreliable.
// Set during streaming whenever a tool_use block arrives.
let needsFollowUp = false
```
</details>
<details>
<summary>2. State Object — 10 Fields (Teaching Version Only Uses messages)</summary>
| # | Field | Purpose | Chapter |
|---|-------|---------|---------|
| 1 | `messages` | Message array for the current iteration | s01 |
| 2 | `toolUseContext` | Tool, signal, and permission context | s02 |
| 3 | `autoCompactTracking` | Compaction state tracking | s08 |
| 4 | `maxOutputTokensRecoveryCount` | Token recovery attempt count (max 3) | s11 |
| 5 | `hasAttemptedReactiveCompact` | Whether reactive compaction was attempted this round | s08 |
| 6 | `maxOutputTokensOverride` | 8K→64K upgrade override | s11 |
| 7 | `pendingToolUseSummary` | Background Haiku-generated tool use summary | s08 |
| 8 | `stopHookActive` | Whether the stop hook produced a blocking error | s04 |
| 9 | `turnCount` | Turn count (for maxTurns check) | s01 |
| 10 | `transition` | Last continue reason | s11 |
> Note: `taskBudgetRemaining` (`query.ts:291`) is a loop-local variable, not on State. The source comment explicitly says "Loop-local (not on State)".
</details>
<details>
<summary>3. Multiple Exit and Continue Paths</summary>
The teaching version has only 1 exit path (model doesn't call a tool → done). The production version has multiple exit and continue paths, covering blocking limit, prompt too long, model error, abort, hook stop, max turns, token budget continuation, reactive compact retry, and more. Each scenario has a corresponding recovery or exit strategy.
</details>
<details>
<summary>4. Streaming Tool Execution and QueryEngine</summary>
CC's `StreamingToolExecutor` (`query.ts:561`) allows tools to begin parallel execution while the model is still generating (concurrency-safe tools run in parallel, others run exclusively). `QueryEngine.ts` adds additional protections for cost overruns, structured output validation failures, and more. The teaching version doesn't implement these — the goal is conceptual clarity, not peak performance.
</details>
**In one sentence**: The core of query.ts's 1729 lines is a 30-line `while True`. All the complex fields and exit paths are protection mechanisms. Understand the core loop first, and everything that follows unfolds naturally.
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->

207
s01_agent_loop/README.ja.md Normal file
View File

@@ -0,0 +1,207 @@
# s01: Agent Loop — ループ一つで十分
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20
> *"One loop & Bash is all you need"* — ツール一つ + ループ一つ = 一つの Agent。
>
> **Harness レイヤー**: ループ — モデルと現実世界をつなぐ最初の架け橋。
---
## 課題
モデルにこう頼んだとする「ディレクトリ内のファイル一覧を取得して、XXX.py を実行して」。
モデルは bash コマンドを出力できるが、出力が終わると止まってしまう — 自分で実行することも、結果を見て推論を続けることもない。
手動で実行し、出力をチャットに貼り付ければ、モデルは続きを生成できる。次のコマンドが出たら、また実行して貼り付ける。
毎回の往復で、あなたが中間層になっている。これを自動化するのが、この章の目的だ。
---
## ソリューション
![Agent Loop](images/agent-loop.ja.svg)
一つの `while True` ループ — モデルがツールを呼べば続き、呼ばなければ停止。全体でたった 2 つのシグナル:
| シグナル | 意味 | ループの動作 |
|----------|------|-------------|
| `stop_reason == "tool_use"` | モデルが「ツールが必要」と挙手 | 実行 → 結果を戻す → 続行 |
| `stop_reason != "tool_use"` | モデルが「完了」と宣言 | ループ終了 |
---
## 仕組み
このプロセスをコードに変換してみよう。ステップごとに:
**ステップ 1**:ユーザーの質問を最初のメッセージとして設定する。
```python
messages = [{"role": "user", "content": query}]
```
**ステップ 2**:メッセージとツール定義を一緒に LLM に送信する。
```python
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
```
**ステップ 3**:モデルの応答を追加し、ツールを呼び出したか確認する。呼び出しなし → 終了。
```python
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
```
**ステップ 4**:モデルが要求したツールを実行し、結果を収集する。
```python
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
```
**ステップ 5**:ツールの結果を新しいメッセージとして追加し、ステップ 2 に戻る。
```python
messages.append({"role": "user", "content": results})
```
完全な関数に組み立てる:
```python
def agent_loop(messages):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
30 行未満 — これが最小実行可能な agent harness のカーネルだ。これは知能そのものではなく、モデルが継続的に行動できるための最小ランタイムフレームワーク。モデルが決定しツールを呼ぶか、どれを呼ぶか、harness が実行する(呼ばれたら実行し、結果を戻す)。次の 18 章はすべてこのループの上に仕組みを積み重ねていく。ループ自体は永遠に変わらない。
---
## 試してみよう
> **教育デモの注意**: このコードはモデルが生成したシェルコマンドを実行します。プロジェクトファイルへの影響を避けるため、一時テストディレクトリで実行してください。s03 で本格的な権限システムを説明します。
**準備**(初回のみ):
```sh
pip install -r requirements.txt
cp .env.example .env
# .env を編集し、ANTHROPIC_API_KEY と MODEL_ID を入力
```
**実行**
```sh
python s01_agent_loop/code.py
```
以下のプロンプトを試してみよう:
1. `Create a file called hello.py that prints "Hello, World!"`
2. `List all Python files in this directory`
3. `What is the current git branch?`
観察のポイント:モデルがツールを呼び出すとき(ループ継続)、呼び出さないとき(ループ終了)の違い。
---
## 次へ
現在、モデルが持っているのは bash だけだ — ファイルを読むには `cat`、書くには `echo ... >`、探すには `find`。不便でエラーも起きやすい。
→ s02 Tool Use5 つの本格的なツールを与えたらどうなる? モデルは複数のツールを同時に呼び出すか? 並列実行で競合は起きないか?
<details>
<summary>CC ソースコードを深掘り</summary>
> 以下は CC ソースコード `src/query.ts`1729 行の検証に基づく。核心的な違いは二つCC はループ継続の判断に `stop_reason` フィールドを頼らず、コンテンツに `tool_use` ブロックが含まれるかをチェックする(ストリーミングレスポンスでは `stop_reason` が信頼できないため。CC には本番環境向けのより多くの終了パスとリカバリ戦略がある。
**教育版の 30 行 `while True` が CC の 1729 行の核心。** 以下の各項目は、すべてその核心の上に積み重ねられた保護機構である。
<details>
<summary>一、ループ構造の違い</summary>
教育版は `response.stop_reason` をチェックする。CC はこれをループ継続の唯一の根拠として使わない — ストリーミングレスポンスでは、`stop_reason` がまだ更新されていなくても、コンテンツに既に `tool_use` ブロックが含まれている可能性がある。CC は `needsFollowUp` フラグを使用する:ストリーミングメッセージの受信時(`query.ts:830-834`)に、`tool_use` ブロックが検出されると `true` に設定される。`QueryEngine.ts``message_delta` から実際の `stop_reason` を取得して他の処理に利用するが、query loop 自体は `needsFollowUp` に依存する。
```typescript
// query.ts:554-558
// stop_reason === 'tool_use' is unreliable.
// Set during streaming whenever a tool_use block arrives.
let needsFollowUp = false
```
</details>
<details>
<summary>二、State オブジェクト 10 フィールド(教育版は messages のみ使用)</summary>
| # | フィールド | 用途 | 対応章 |
|---|-----------|------|--------|
| 1 | `messages` | 現在のイテレーションのメッセージ配列 | s01 |
| 2 | `toolUseContext` | ツール、シグナル、権限コンテキスト | s02 |
| 3 | `autoCompactTracking` | 圧縮状態の追跡 | s08 |
| 4 | `maxOutputTokensRecoveryCount` | トークンリカバリ試行回数(上限 3 | s11 |
| 5 | `hasAttemptedReactiveCompact` | 今回のラウンドでリアクティブ圧縮を試みたか | s08 |
| 6 | `maxOutputTokensOverride` | 8K→64K へのアップグレード上書き | s11 |
| 7 | `pendingToolUseSummary` | バックグラウンド Haiku 生成のツール使用要約 | s08 |
| 8 | `stopHookActive` | 停止フックがブロッキングエラーを発生させたか | s04 |
| 9 | `turnCount` | ターン数maxTurns チェック用) | s01 |
| 10 | `transition` | 前回の継続理由 | s11 |
> 注:`taskBudgetRemaining``query.ts:291`)は loop-local のローカル変数であり、State には含まれない。ソースコメントには明確に "Loop-local (not on State)" と書かれている。
</details>
<details>
<summary>三、複数の終了パスと継続パス</summary>
教育版には 1 つの終了パスしかないモデルがツールを呼ばなければ終了。本番版には複数の終了・継続パスがあり、blocking limit、prompt too long、model error、abort、hook stop、max turns、token budget continuation、reactive compact retry など多くのシナリオをカバーしている。各シナリオには対応するリカバリまたは終了戦略がある。
</details>
<details>
<summary>四、ストリーミングツール実行と QueryEngine</summary>
CC の `StreamingToolExecutor``query.ts:561`は、モデルがまだ生成中にツールの実行を開始できるconcurrency-safe なツールは並列、それ以外は排他実行)。`QueryEngine.ts` はさらに、コスト超過や構造化出力の検証失敗などの保護を追加する。教育版はこれらを実装しない — 目標は概念の明確さであり、極限のパフォーマンスではない。
</details>
**一言で**: query.ts の 1729 行の核心は 30 行の `while True`。複雑なフィールドや終了パスはすべて保護機構だ。まず核心のループを理解すれば、その後のすべては自然に理解できる。
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->

207
s01_agent_loop/README.md Normal file
View File

@@ -0,0 +1,207 @@
# s01: Agent Loop — 一个循环就够了
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20
> *"One loop & Bash is all you need"* — 一个工具 + 一个循环 = 一个 Agent。
>
> **Harness 层**: 循环 — 模型与真实世界的第一道连接。
---
## 问题
你提出了一个问题给大模型“帮我读取下我的目录下有哪些文件并且执行XXX.py”。
模型能输出一条 bash 命令,但输出完了就停了,它不会自己跑,也不会看到结果后继续推理。
你可以手动跑一遍,把输出粘贴回对话框,让它接着干。下一个命令出来,你再跑一遍、再贴回去。
每一个来回,你都在做中间层。而把它自动化,就是这一章要做的事。
---
## 解决方案
![Agent Loop](images/agent-loop.svg)
一个 `while True` 循环,模型调用工具就继续,不调用就停。整个过程只有两个信号:
| 信号 | 含义 | 循环动作 |
|------|------|---------|
| `stop_reason == "tool_use"` | 模型举手说"我要用工具" | 执行 → 结果喂回去 → 继续 |
| `stop_reason != "tool_use"` | 模型说"我做完了" | 退出循环 |
---
## 工作原理
将这个过程翻译成代码。分步来看:
**第 1 步**:把用户的问题作为第一条消息。
```python
messages = [{"role": "user", "content": query}]
```
**第 2 步**:将消息和工具定义一起发给 LLM。
```python
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
```
**第 3 步**:追加模型回答,检查它是否调了工具。没调 → 结束。
```python
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
```
**第 4 步**:执行模型要求的工具,收集结果。
```python
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
```
**第 5 步**:把工具结果作为新消息追加,回到第 2 步。
```python
messages.append({"role": "user", "content": results})
```
组装为一个完整函数:
```python
def agent_loop(messages):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
不到 30 行,这就是最小可运行的 agent harness 内核。它不是智能本身而是让模型能持续行动的最小运行框架模型负责决策要不要调工具、调哪个harness 负责执行(调了就跑、结果喂回去)。后面 18 个章节都在这个循环上叠加机制,循环本身始终不变。
---
## 试一下
> **教学 demo 提示**:代码会执行模型生成的 shell 命令。建议在一个临时测试目录中运行避免影响你的项目文件。s03 会讲真正的权限系统。
**准备**(首次运行):
```sh
pip install -r requirements.txt
cp .env.example .env
# 编辑 .env填入 ANTHROPIC_API_KEY 和 MODEL_ID
```
**运行**
```sh
python s01_agent_loop/code.py
```
试试这些 prompt
1. `Create a file called hello.py that prints "Hello, World!"`
2. `List all Python files in this directory`
3. `What is the current git branch?`
观察重点:模型什么时候调用工具(循环继续),什么时候不调用(循环结束)?
---
## 接下来
现在模型手里只有 bash 一个工具,读文件要 `cat`,写文件要 `echo ... >`,找个文件要 `find`,又丑又容易出错。
s02 Tool Use → 给它 5 个真正的工具,会发生什么?模型会不会一次调用多个工具?几个工具同时跑会不会互相踩?
<details>
<summary>深入 CC 源码</summary>
> 以下内容基于 CC 源码 `src/query.ts`1729 行的核查。核心差异就两个CC 不看 `stop_reason` 字段而是检查内容里有没有 tool_use 块(因为流式响应中 stop_reason 不可靠CC 有更多的退出路径和恢复策略做生产级保护。
**教学版的 30 行 `while True` 就是 CC 1729 行的核心。** 下面每一项都是在这个核心上叠加的保护机制。
<details>
<summary>一、循环结构差异</summary>
教学版检查 `response.stop_reason`。CC 不把它作为循环继续的唯一依据——流式响应中 `stop_reason` 可能还没更新但内容里已经有 `tool_use` 块了。CC 用 `needsFollowUp` 标志:接收到流式消息时(`query.ts:830-834`),只要检测到 `tool_use` 块就设为 `true``QueryEngine.ts` 会从 `message_delta` 捕获真实 `stop_reason` 用于其他逻辑,但 query loop 本身靠 `needsFollowUp` 决定是否继续。
```typescript
// query.ts:554-558
// stop_reason === 'tool_use' is unreliable.
// Set during streaming whenever a tool_use block arrives.
let needsFollowUp = false
```
</details>
<details>
<summary>二、State 对象 10 字段(教学版只用 messages</summary>
| # | 字段 | 用途 | 对应章节 |
|---|------|------|---------|
| 1 | `messages` | 当前迭代的消息数组 | s01 |
| 2 | `toolUseContext` | 工具、信号、权限上下文 | s02 |
| 3 | `autoCompactTracking` | 压缩状态追踪 | s08 |
| 4 | `maxOutputTokensRecoveryCount` | token 恢复尝试次数(上限 3 | s11 |
| 5 | `hasAttemptedReactiveCompact` | 本轮是否已尝试响应式压缩 | s08 |
| 6 | `maxOutputTokensOverride` | 8K→64K 的升级覆盖 | s11 |
| 7 | `pendingToolUseSummary` | 后台 Haiku 生成的 tool use 摘要 | s08 |
| 8 | `stopHookActive` | 停止钩子是否产生阻塞错误 | s04 |
| 9 | `turnCount` | 轮次计数maxTurns 检查) | s01 |
| 10 | `transition` | 上一次继续原因 | s11 |
> 注:`taskBudgetRemaining``query.ts:291`)是 loop-local 局部变量,不在 State 上。源码注释明确写了 "Loop-local (not on State)"。
</details>
<details>
<summary>三、多条退出和继续路径</summary>
教学版只有 1 条退出路径(模型不调工具就结束)。生产版有多条退出和继续路径,覆盖 blocking limit、prompt too long、model error、abort、hook stop、max turns、token budget continuation、reactive compact retry 等场景。每种场景都有对应的恢复或退出策略。
</details>
<details>
<summary>四、流式工具执行和 QueryEngine</summary>
CC 的 `StreamingToolExecutor``query.ts:561`)让工具在模型还在生成时就开始并行执行(根据工具是否 concurrency-safe 决定并发或独占)。`QueryEngine.ts` 额外加了费用超限、结构化输出验证失败等保护。教学版不实现这些——目标是概念清晰,不是性能极致。
</details>
**一句话**1729 行的 query.ts 核心就是 30 行 `while True`。所有复杂字段和退出路径都是保护机制。先理解核心循环,后面的一切自然展开。
</details>
<!-- translation-sync: zh@v1, en@v0, ja@v0 -->

137
s01_agent_loop/code.py Normal file
View File

@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""
s01_agent_loop.py - The Agent Loop
The entire secret of an AI coding agent in one pattern:
while stop_reason == "tool_use":
response = LLM(messages, tools)
execute tools
append results
+----------+ +-------+ +---------+
| User | ---> | LLM | ---> | Tool |
| prompt | | | | execute |
+----------+ +---+---+ +----+----+
^ |
| tool_result |
+---------------+
(loop continues)
This is the core loop: feed tool results back to the model
until the model decides to stop. Production agents layer
policy, hooks, and lifecycle controls on top.
Usage:
pip install anthropic python-dotenv
ANTHROPIC_API_KEY=... python s01_agent_loop/code.py
"""
import os
import subprocess
try:
import readline
# macOS 的 libedit 在处理中文输入时有退格问题,这四行修复它
readline.parse_and_bind('set bind-tty-special-chars off')
readline.parse_and_bind('set input-meta on')
readline.parse_and_bind('set output-meta on')
readline.parse_and_bind('set convert-meta off')
except ImportError:
pass
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
SYSTEM = f"You are a coding agent at {os.getcwd()}. Use bash to solve tasks. Act, don't explain."
# ── Tool definition: just bash ────────────────────────────
TOOLS = [{
"name": "bash",
"description": "Run a shell command.",
"input_schema": {
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"],
},
}]
# ── Tool execution ────────────────────────────────────────
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=os.getcwd(),
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
except (FileNotFoundError, OSError) as e:
return f"Error: {e}"
# ── The core pattern: a while loop that calls tools until the model stops ──
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
# Append assistant turn
messages.append({"role": "assistant", "content": response.content})
# If the model didn't call a tool, we're done
if response.stop_reason != "tool_use":
return
# Execute each tool call, collect results
results = []
for block in response.content:
if block.type == "tool_use":
print(f"\033[33m$ {block.input['command']}\033[0m")
output = run_bash(block.input["command"])
print(output[:200])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
# Feed tool results back, loop continues
messages.append({"role": "user", "content": results})
# ── Entry point ──────────────────────────────────────────
if __name__ == "__main__":
print("s01: Agent Loop")
print("输入问题,回车发送。输入 q 退出。\n")
history = []
while True:
try:
query = input("\033[36ms01 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
# Print the model's final text response
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if getattr(block, "type", None) == "text":
print(block.text)
print()

View File

@@ -0,0 +1,86 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 530" font-family="system-ui, -apple-system, sans-serif">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
<marker id="arrow-blue" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#2563eb"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="720" height="530" fill="#fafbfc" rx="8"/>
<!-- Title -->
<rect x="0" y="0" width="720" height="48" fill="url(#header)" rx="8"/>
<rect x="0" y="40" width="720" height="8" fill="url(#header)"/>
<text x="360" y="31" fill="#fff" font-size="16" font-weight="700" text-anchor="middle">Agent Loop — A while Loop Drives the Entire Agent</text>
<!-- ===== User Input ===== -->
<rect x="60" y="80" width="160" height="52" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="2"/>
<text x="140" y="102" fill="#1e3a5f" font-size="13" font-weight="600" text-anchor="middle">User Query</text>
<text x="140" y="120" fill="#64748b" font-size="11" text-anchor="middle">"Create hello.py for me"</text>
<!-- Arrow: User → Messages -->
<line x1="140" y1="132" x2="140" y2="162" stroke="#2563eb" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
<!-- ===== Messages ===== -->
<rect x="60" y="164" width="160" height="48" rx="8" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.5" stroke-dasharray="4,2"/>
<text x="140" y="185" fill="#334155" font-size="12" font-weight="500" text-anchor="middle">messages[]</text>
<text x="140" y="201" fill="#94a3b8" font-size="10" text-anchor="middle">Accumulated message list</text>
<!-- Arrow: Messages → LLM -->
<line x1="220" y1="188" x2="288" y2="188" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- ===== LLM ===== -->
<rect x="290" y="140" width="180" height="96" rx="8" fill="#fff" stroke="#2563eb" stroke-width="2"/>
<text x="380" y="166" fill="#1e3a5f" font-size="14" font-weight="700" text-anchor="middle">LLM</text>
<line x1="310" y1="176" x2="450" y2="176" stroke="#e2e8f0" stroke-width="1"/>
<text x="380" y="194" fill="#475569" font-size="11" text-anchor="middle">Model reads message history</text>
<text x="380" y="210" fill="#475569" font-size="11" text-anchor="middle">Decision: Need a tool?</text>
<text x="380" y="228" fill="#64748b" font-size="10" text-anchor="middle">Returns stop_reason signal</text>
<!-- Arrow: LLM → Decision (down) -->
<line x1="380" y1="236" x2="380" y2="276" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- ===== Decision Diamond ===== -->
<polygon points="380,280 470,316 380,352 290,316" fill="#fff8f0" stroke="#d97706" stroke-width="2"/>
<text x="380" y="312" fill="#92400e" font-size="12" font-weight="600" text-anchor="middle">stop_reason</text>
<text x="380" y="326" fill="#92400e" font-size="10" text-anchor="middle">== "tool_use"?</text>
<!-- Arrow: No → End (right) -->
<line x1="470" y1="316" x2="540" y2="316" stroke="#16a34a" stroke-width="2" marker-end="url(#arrow-green)"/>
<text x="505" y="308" fill="#16a34a" font-size="10" font-weight="600" text-anchor="middle">No</text>
<!-- End Node -->
<rect x="542" y="294" width="130" height="44" rx="22" fill="#dcfce7" stroke="#16a34a" stroke-width="2"/>
<text x="607" y="313" fill="#166534" font-size="12" font-weight="600" text-anchor="middle">Return Result</text>
<text x="607" y="329" fill="#166534" font-size="10" text-anchor="middle">Loop Ends</text>
<!-- Arrow: Yes → Tool Execution (down) -->
<line x1="380" y1="352" x2="380" y2="392" stroke="#d97706" stroke-width="2" marker-end="url(#arrow)"/>
<text x="395" y="376" fill="#d97706" font-size="10" font-weight="600">Yes</text>
<!-- ===== Tool Execution ===== -->
<rect x="290" y="394" width="180" height="48" rx="8" fill="#fff7ed" stroke="#d97706" stroke-width="1.5"/>
<text x="380" y="415" fill="#92400e" font-size="12" font-weight="600" text-anchor="middle">Execute Tool Call</text>
<text x="380" y="432" fill="#92400e" font-size="10" text-anchor="middle">run_bash(command)</text>
<!-- Arrow: Tool result → Append to messages (loop back) -->
<path d="M 290 418 L 40 418 L 40 188 L 58 188" fill="none" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow)" stroke-dasharray="6,3"/>
<text x="18" y="375" fill="#92400e" font-size="10" font-weight="500" transform="rotate(-90, 18, 375)">Append tool_result to messages</text>
<!-- Legend -->
<rect x="60" y="462" width="600" height="48" rx="6" fill="#f1f5f9"/>
<text x="80" y="482" fill="#334155" font-size="10">Core: a</text>
<text x="113" y="482" fill="#1e3a5f" font-size="10" font-weight="700" font-family="monospace">while True</text>
<text x="186" y="482" fill="#334155" font-size="10">loop. Model calls tool → Execute → Feed back → Ask again. No tool call → Stop.</text>
<text x="80" y="500" fill="#64748b" font-size="10">All subsequent chapters layer mechanisms on top of this loop.</text>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -0,0 +1,86 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 530" font-family="system-ui, -apple-system, sans-serif">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
<marker id="arrow-blue" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#2563eb"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
</defs>
<!-- 背景 -->
<rect width="720" height="530" fill="#fafbfc" rx="8"/>
<!-- タイトル -->
<rect x="0" y="0" width="720" height="48" fill="url(#header)" rx="8"/>
<rect x="0" y="40" width="720" height="8" fill="url(#header)"/>
<text x="360" y="31" fill="#fff" font-size="15" font-weight="700" text-anchor="middle">Agent Loop — 一つの while ループで Agent 全体を駆動</text>
<!-- ===== ユーザー入力 ===== -->
<rect x="60" y="80" width="160" height="52" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="2"/>
<text x="140" y="102" fill="#1e3a5f" font-size="13" font-weight="600" text-anchor="middle">ユーザーの質問</text>
<text x="140" y="120" fill="#64748b" font-size="11" text-anchor="middle">"hello.py を作って"</text>
<!-- 矢印:ユーザー → メッセージリスト -->
<line x1="140" y1="132" x2="140" y2="162" stroke="#2563eb" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
<!-- ===== メッセージリスト ===== -->
<rect x="60" y="164" width="160" height="48" rx="8" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.5" stroke-dasharray="4,2"/>
<text x="140" y="185" fill="#334155" font-size="12" font-weight="500" text-anchor="middle">messages[]</text>
<text x="140" y="201" fill="#94a3b8" font-size="10" text-anchor="middle">累積メッセージリスト</text>
<!-- 矢印:メッセージ → LLM -->
<line x1="220" y1="188" x2="288" y2="188" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- ===== LLM ===== -->
<rect x="290" y="140" width="180" height="96" rx="8" fill="#fff" stroke="#2563eb" stroke-width="2"/>
<text x="380" y="166" fill="#1e3a5f" font-size="14" font-weight="700" text-anchor="middle">大規模言語モデル (LLM)</text>
<line x1="310" y1="176" x2="450" y2="176" stroke="#e2e8f0" stroke-width="1"/>
<text x="380" y="194" fill="#475569" font-size="11" text-anchor="middle">モデルがメッセージ履歴を読む</text>
<text x="380" y="210" fill="#475569" font-size="11" text-anchor="middle">判断:ツールが必要か?</text>
<text x="380" y="228" fill="#64748b" font-size="10" text-anchor="middle">stop_reason シグナルを返す</text>
<!-- 矢印LLM → 判定(下) -->
<line x1="380" y1="236" x2="380" y2="276" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- ===== 判定ダイヤモンド ===== -->
<polygon points="380,280 470,316 380,352 290,316" fill="#fff8f0" stroke="#d97706" stroke-width="2"/>
<text x="380" y="312" fill="#92400e" font-size="12" font-weight="600" text-anchor="middle">stop_reason</text>
<text x="380" y="326" fill="#92400e" font-size="10" text-anchor="middle">== "tool_use"?</text>
<!-- 矢印:いいえ → 終了(右) -->
<line x1="470" y1="316" x2="540" y2="316" stroke="#16a34a" stroke-width="2" marker-end="url(#arrow-green)"/>
<text x="505" y="308" fill="#16a34a" font-size="10" font-weight="600" text-anchor="middle">No</text>
<!-- 終了ノード -->
<rect x="542" y="294" width="130" height="44" rx="22" fill="#dcfce7" stroke="#16a34a" stroke-width="2"/>
<text x="607" y="313" fill="#166534" font-size="12" font-weight="600" text-anchor="middle">結果を返す</text>
<text x="607" y="329" fill="#166534" font-size="10" text-anchor="middle">ループ終了</text>
<!-- 矢印:はい → ツール実行(下) -->
<line x1="380" y1="352" x2="380" y2="392" stroke="#d97706" stroke-width="2" marker-end="url(#arrow)"/>
<text x="395" y="376" fill="#d97706" font-size="10" font-weight="600">Yes</text>
<!-- ===== ツール実行 ===== -->
<rect x="290" y="394" width="180" height="48" rx="8" fill="#fff7ed" stroke="#d97706" stroke-width="1.5"/>
<text x="380" y="415" fill="#92400e" font-size="12" font-weight="600" text-anchor="middle">ツール呼び出しを実行</text>
<text x="380" y="432" fill="#92400e" font-size="10" text-anchor="middle">run_bash(command)</text>
<!-- 矢印:ツール結果 → メッセージに追加(ループバック) -->
<path d="M 290 418 L 40 418 L 40 188 L 58 188" fill="none" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow)" stroke-dasharray="6,3"/>
<text x="18" y="375" fill="#92400e" font-size="10" font-weight="500" transform="rotate(-90, 18, 375)">tool_result を messages に追加</text>
<!-- 凡例 -->
<rect x="60" y="462" width="600" height="48" rx="6" fill="#f1f5f9"/>
<text x="80" y="482" fill="#334155" font-size="10">核心:一つの</text>
<text x="138" y="482" fill="#1e3a5f" font-size="10" font-weight="700" font-family="monospace">while True</text>
<text x="210" y="482" fill="#334155" font-size="10">ループ。ツール呼出 → 実行 → 結果を戻す → 再度問う。ツールなし → 停止。</text>
<text x="80" y="500" fill="#64748b" font-size="10">以降の全章がこのループの上に仕組みを積み重ねる。</text>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -0,0 +1,86 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 530" font-family="system-ui, -apple-system, sans-serif">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
<marker id="arrow-blue" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#2563eb"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
</defs>
<!-- 背景 -->
<rect width="720" height="530" fill="#fafbfc" rx="8"/>
<!-- 标题 -->
<rect x="0" y="0" width="720" height="48" fill="url(#header)" rx="8"/>
<rect x="0" y="40" width="720" height="8" fill="url(#header)"/>
<text x="360" y="31" fill="#fff" font-size="16" font-weight="700" text-anchor="middle">Agent Loop — 一个 while 循环驱动整个 Agent</text>
<!-- ===== 用户输入 ===== -->
<rect x="60" y="80" width="160" height="52" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="2"/>
<text x="140" y="102" fill="#1e3a5f" font-size="13" font-weight="600" text-anchor="middle">用户提问</text>
<text x="140" y="120" fill="#64748b" font-size="11" text-anchor="middle">"帮我创建 hello.py"</text>
<!-- 箭头:用户 → 消息列表 -->
<line x1="140" y1="132" x2="140" y2="162" stroke="#2563eb" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
<!-- ===== 消息列表 ===== -->
<rect x="60" y="164" width="160" height="48" rx="8" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.5" stroke-dasharray="4,2"/>
<text x="140" y="185" fill="#334155" font-size="12" font-weight="500" text-anchor="middle">messages[]</text>
<text x="140" y="201" fill="#94a3b8" font-size="10" text-anchor="middle">累积式消息列表</text>
<!-- 箭头:消息列表 → LLM -->
<line x1="220" y1="188" x2="288" y2="188" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- ===== LLM ===== -->
<rect x="290" y="140" width="180" height="96" rx="8" fill="#fff" stroke="#2563eb" stroke-width="2"/>
<text x="380" y="166" fill="#1e3a5f" font-size="14" font-weight="700" text-anchor="middle">大模型 (LLM)</text>
<line x1="310" y1="176" x2="450" y2="176" stroke="#e2e8f0" stroke-width="1"/>
<text x="380" y="194" fill="#475569" font-size="11" text-anchor="middle">模型阅读消息历史</text>
<text x="380" y="210" fill="#475569" font-size="11" text-anchor="middle">判断:需要工具吗?</text>
<text x="380" y="228" fill="#64748b" font-size="10" text-anchor="middle">返回 stop_reason 信号</text>
<!-- 箭头LLM → 判断(向下) -->
<line x1="380" y1="236" x2="380" y2="276" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- ===== 判断菱形 ===== -->
<polygon points="380,280 470,316 380,352 290,316" fill="#fff8f0" stroke="#d97706" stroke-width="2"/>
<text x="380" y="312" fill="#92400e" font-size="12" font-weight="600" text-anchor="middle">stop_reason</text>
<text x="380" y="326" fill="#92400e" font-size="10" text-anchor="middle">== "tool_use"?</text>
<!-- 箭头:否 → 结束(向右) -->
<line x1="470" y1="316" x2="540" y2="316" stroke="#16a34a" stroke-width="2" marker-end="url(#arrow-green)"/>
<text x="505" y="308" fill="#16a34a" font-size="10" font-weight="600" text-anchor="middle"></text>
<!-- 结束节点 -->
<rect x="542" y="294" width="130" height="44" rx="22" fill="#dcfce7" stroke="#16a34a" stroke-width="2"/>
<text x="607" y="313" fill="#166534" font-size="12" font-weight="600" text-anchor="middle">返回结果</text>
<text x="607" y="329" fill="#166534" font-size="10" text-anchor="middle">循环结束</text>
<!-- 箭头:是 → 工具执行(向下) -->
<line x1="380" y1="352" x2="380" y2="392" stroke="#d97706" stroke-width="2" marker-end="url(#arrow)"/>
<text x="395" y="376" fill="#d97706" font-size="10" font-weight="600"></text>
<!-- ===== 工具执行 ===== -->
<rect x="290" y="394" width="180" height="48" rx="8" fill="#fff7ed" stroke="#d97706" stroke-width="1.5"/>
<text x="380" y="415" fill="#92400e" font-size="12" font-weight="600" text-anchor="middle">执行工具调用</text>
<text x="380" y="432" fill="#92400e" font-size="10" text-anchor="middle">run_bash(command)</text>
<!-- 箭头:工具结果 → 追加到消息列表(向左弯回去) -->
<path d="M 290 418 L 40 418 L 40 188 L 58 188" fill="none" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow)" stroke-dasharray="6,3"/>
<text x="18" y="375" fill="#92400e" font-size="10" font-weight="500" transform="rotate(-90, 18, 375)">追加 tool_result 到 messages</text>
<!-- 图例 -->
<rect x="60" y="462" width="600" height="48" rx="6" fill="#f1f5f9"/>
<text x="80" y="482" fill="#334155" font-size="10">核心:一个</text>
<text x="148" y="482" fill="#1e3a5f" font-size="10" font-weight="700" font-family="monospace">while True</text>
<text x="220" y="482" fill="#334155" font-size="10">循环。模型调工具 → 执行 → 喂回 → 再问。不调工具就停。</text>
<text x="80" y="500" fill="#64748b" font-size="10">后续所有章节都在这个循环上叠加机制。</text>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB