mirror of
https://github.com/shareAI-lab/analysis_claude_code.git
synced 2026-09-20 12:13:38 +08:00
308 lines
466 KiB
JSON
308 lines
466 KiB
JSON
[
|
||
{
|
||
"version": "s01",
|
||
"locale": "en",
|
||
"title": "s01: The Agent Loop — One Loop Is All You Need",
|
||
"content": "# s01: The Agent Loop — One Loop Is All You Need\n\n`s01` → [s02](/en/s02) → s03 → s04 → ... → s16 → s17\n> *\"One loop & Bash is all you need\"* — One tool + one loop = one Agent.\n>\n> **Harness Layer**: The Loop — the first bridge between the model and the real world.\n\n---\n\n## The Problem\n\nYou ask the model: \"List the files in my directory and run XXX.py.\"\n\nThe 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.\n\nYou 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.\n\nEvery round-trip, you're the middle layer. Automating that is what this chapter is about.\n\n---\n\n## The Solution\n\n\n\nA `while True` loop: keep going when the model calls a tool, stop when it doesn't. The loop checks the response content blocks directly:\n\n| Signal | Meaning | Loop Action |\n|--------|---------|-------------|\n| Contains a `tool_use` block | Model requests a tool call | Execute → feed result back → continue |\n| Contains no `tool_use` block | Model did not call a tool | Exit loop |\n\n---\n\n## How It Works\n\nLet's translate this process into code. Step by step:\n\n**Step 1**: Start with the user's question as the first message.\n\n```python\nmessages = [{\"role\": \"user\", \"content\": query}]\n```\n\n**Step 2**: Send the messages and tool definitions to the LLM.\n\n```python\nresponse = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n)\n```\n\n**Step 3**: Append the model's response and check whether it called a tool. No tool call → done.\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\ntool_calls = [\n block for block in response.content if block.type == \"tool_use\"\n]\nif not tool_calls:\n return\n```\n\nOnly concrete `tool_use` blocks enter the execution stage, so the loop never appends an empty tool-result message.\n\n**Step 4**: Execute the tool the model requested and collect the results.\n\n```python\nresults = []\nfor block in tool_calls:\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\n**Step 5**: Append the tool results as a new message and go back to Step 2.\n\n```python\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\nAssembled into a complete function:\n\n```python\ndef agent_loop(messages):\n while True:\n response = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n tool_calls = [\n block for block in response.content if block.type == \"tool_use\"\n ]\n if not tool_calls:\n return\n\n results = []\n for block in tool_calls:\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n messages.append({\"role\": \"user\", \"content\": results})\n```\n\nJust over 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 (calls the tool and appends the result as a new message). The next 16 chapters all add mechanisms on top of this loop. The loop itself never changes.\n\n---\n\n## Try It\n\n> **Safety notice**: The code executes shell commands generated by the model. Run it in a temporary test directory to avoid affecting your project files. s03 adds permission controls.\n\n**Setup** (first run):\n\n```sh\npip install -r requirements.txt\ncp .env.example .env\n# Edit .env, fill in ANTHROPIC_API_KEY and MODEL_ID\n```\n\n**Run**:\n\n```sh\npython s01_agent_loop/code.py\n```\n\nTry these prompts:\n\n1. `Create a file called hello.py that prints \"Hello, World!\"`\n2. `List all Python files in this directory`\n3. `What is the current git branch?`\n\nWhat to watch for: When does the model call a tool (loop continues), and when does it not (loop ends)?\n\n---\n\n## What's Next\n\nRight now the model only has bash — reading files requires `cat`, writing files requires `echo ... >`, finding files requires `find`. Ugly and error-prone.\n\n→ 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?\n\n\n<!-- translation-sync: zh@v2, en@v2, ja@v2 -->\n"
|
||
},
|
||
{
|
||
"version": "s01",
|
||
"locale": "zh",
|
||
"title": "s01: Agent Loop — 一个循环就够了",
|
||
"content": "# s01: Agent Loop — 一个循环就够了\n\n`s01` → [s02](/zh/s02) → s03 → s04 → ... → s16 → s17\n> *\"One loop & Bash is all you need\"* — 一个工具 + 一个循环 = 一个 Agent。\n>\n> **Harness 层**: 循环 — 模型与真实世界的第一道连接。\n\n---\n\n## 问题\n\n你提出了一个问题给大模型:“帮我读取下我的目录下有哪些文件,并且执行XXX.py”。\n\n模型能输出一条 bash 命令,但输出完了就停了,它不会自己跑,也不会看到结果后继续推理。\n\n你可以手动跑一遍,把输出粘贴回对话框,让它接着干。下一个命令出来,你再跑一遍、再贴回去。\n\n每一个来回,你都在做中间层。而把它自动化,就是这一章要做的事。\n\n---\n\n## 解决方案\n\n\n\n一个 `while True` 循环,模型调用工具就继续,不调用就停。循环直接检查响应里的内容块:\n\n| 信号 | 含义 | 循环动作 |\n|------|------|---------|\n| 包含 `tool_use` block | 模型要求调用工具 | 执行 → 结果喂回去 → 继续 |\n| 不包含 `tool_use` block | 模型没有调用工具 | 退出循环 |\n\n---\n\n## 工作原理\n\n将这个过程翻译成代码。分步来看:\n\n**第 1 步**:把用户的问题作为第一条消息。\n\n```python\nmessages = [{\"role\": \"user\", \"content\": query}]\n```\n\n**第 2 步**:将消息和工具定义一起发给 LLM。\n\n```python\nresponse = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n)\n```\n\n**第 3 步**:追加模型回答,检查它是否调了工具。没调 → 结束。\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\ntool_calls = [\n block for block in response.content if block.type == \"tool_use\"\n]\nif not tool_calls:\n return\n```\n\n只有实际存在的 `tool_use` block 才会进入执行阶段,因此不会追加空的工具结果消息。\n\n**第 4 步**:执行模型要求的工具,收集结果。\n\n```python\nresults = []\nfor block in tool_calls:\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\n**第 5 步**:把工具结果作为新消息追加,回到第 2 步。\n\n```python\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\n组装为一个完整函数:\n\n```python\ndef agent_loop(messages):\n while True:\n response = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n tool_calls = [\n block for block in response.content if block.type == \"tool_use\"\n ]\n if not tool_calls:\n return\n\n results = []\n for block in tool_calls:\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n messages.append({\"role\": \"user\", \"content\": results})\n```\n\n三十多行,这就是最小可运行的 agent harness 内核。它为模型提供持续行动的最小运行框架:模型负责决策(要不要调工具、调哪个),harness 负责执行(调用工具,把结果作为新消息追加)。后面 16 个章节都在这个循环上叠加机制,循环本身始终不变。\n\n---\n\n## 试一下\n\n> **安全提示**:代码会执行模型生成的 shell 命令。建议在一个临时测试目录中运行,避免影响你的项目文件。s03 会加入权限控制。\n\n**准备**(首次运行):\n\n```sh\npip install -r requirements.txt\ncp .env.example .env\n# 编辑 .env,填入 ANTHROPIC_API_KEY 和 MODEL_ID\n```\n\n**运行**:\n\n```sh\npython s01_agent_loop/code.py\n```\n\n试试这些 prompt:\n\n1. `Create a file called hello.py that prints \"Hello, World!\"`\n2. `List all Python files in this directory`\n3. `What is the current git branch?`\n\n观察重点:模型什么时候调用工具(循环继续),什么时候不调用(循环结束)?\n\n---\n\n## 接下来\n\n现在模型手里只有 bash 一个工具,读文件要 `cat`,写文件要 `echo ... >`,找个文件要 `find`,又丑又容易出错。\n\ns02 Tool Use → 给它 5 个真正的工具,会发生什么?模型会不会一次调用多个工具?几个工具同时跑会不会互相踩?\n\n\n<!-- translation-sync: zh@v1, en@v0, ja@v0 -->\n"
|
||
},
|
||
{
|
||
"version": "s01",
|
||
"locale": "ja",
|
||
"title": "s01: Agent Loop — ループ一つで十分",
|
||
"content": "# s01: Agent Loop — ループ一つで十分\n\n`s01` → [s02](/ja/s02) → s03 → s04 → ... → s16 → s17\n> *\"One loop & Bash is all you need\"* — ツール一つ + ループ一つ = 一つの Agent。\n>\n> **Harness レイヤー**: ループ — モデルと現実世界をつなぐ最初の架け橋。\n\n---\n\n## 課題\n\nモデルにこう頼んだとする:「ディレクトリ内のファイル一覧を取得して、XXX.py を実行して」。\n\nモデルは bash コマンドを出力できるが、出力が終わると止まってしまう — 自分で実行することも、結果を見て推論を続けることもない。\n\n手動で実行し、出力をチャットに貼り付ければ、モデルは続きを生成できる。次のコマンドが出たら、また実行して貼り付ける。\n\n毎回の往復で、あなたが中間層になっている。これを自動化するのが、この章の目的だ。\n\n---\n\n## ソリューション\n\n\n\n一つの `while True` ループ — モデルがツールを呼べば続き、呼ばなければ停止。ループは response の content block を直接確認する:\n\n| シグナル | 意味 | ループの動作 |\n|----------|------|-------------|\n| `tool_use` block を含む | モデルがツール呼び出しを要求 | 実行 → 結果を戻す → 続行 |\n| `tool_use` block を含まない | モデルがツールを呼ばなかった | ループ終了 |\n\n---\n\n## 仕組み\n\nこのプロセスをコードに変換してみよう。ステップごとに:\n\n**ステップ 1**:ユーザーの質問を最初のメッセージとして設定する。\n\n```python\nmessages = [{\"role\": \"user\", \"content\": query}]\n```\n\n**ステップ 2**:メッセージとツール定義を一緒に LLM に送信する。\n\n```python\nresponse = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n)\n```\n\n**ステップ 3**:モデルの応答を追加し、ツールを呼び出したか確認する。呼び出しなし → 終了。\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\ntool_calls = [\n block for block in response.content if block.type == \"tool_use\"\n]\nif not tool_calls:\n return\n```\n\n実際の `tool_use` block だけが実行段階に進むため、空の tool result メッセージは追加されない。\n\n**ステップ 4**:モデルが要求したツールを実行し、結果を収集する。\n\n```python\nresults = []\nfor block in tool_calls:\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\n**ステップ 5**:ツールの結果を新しいメッセージとして追加し、ステップ 2 に戻る。\n\n```python\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\n完全な関数に組み立てる:\n\n```python\ndef agent_loop(messages):\n while True:\n response = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n tool_calls = [\n block for block in response.content if block.type == \"tool_use\"\n ]\n if not tool_calls:\n return\n\n results = []\n for block in tool_calls:\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n messages.append({\"role\": \"user\", \"content\": results})\n```\n\n30 行あまり — これが最小実行可能な agent harness のカーネルだ。これは知能そのものではなく、モデルが継続的に行動できるための最小ランタイムフレームワーク。モデルが決定し(ツールを呼ぶか、どれを呼ぶか)、harness が実行を担う(ツールを呼び出し、結果を新しいメッセージとして追加する)。次の 16 章はすべてこのループの上に仕組みを積み重ねていく。ループ自体は永遠に変わらない。\n\n---\n\n## 試してみよう\n\n> **安全上の注意**: このコードはモデルが生成したシェルコマンドを実行します。プロジェクトファイルへの影響を避けるため、一時テストディレクトリで実行してください。s03 で権限制御を追加します。\n\n**準備**(初回のみ):\n\n```sh\npip install -r requirements.txt\ncp .env.example .env\n# .env を編集し、ANTHROPIC_API_KEY と MODEL_ID を入力\n```\n\n**実行**:\n\n```sh\npython s01_agent_loop/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `Create a file called hello.py that prints \"Hello, World!\"`\n2. `List all Python files in this directory`\n3. `What is the current git branch?`\n\n観察のポイント:モデルがツールを呼び出すとき(ループ継続)、呼び出さないとき(ループ終了)の違い。\n\n---\n\n## 次へ\n\n現在、モデルが持っているのは bash だけだ — ファイルを読むには `cat`、書くには `echo ... >`、探すには `find`。不便でエラーも起きやすい。\n\n→ s02 Tool Use:5 つの本格的なツールを与えたらどうなる? モデルは複数のツールを同時に呼び出すか? 並列実行で競合は起きないか?\n\n\n<!-- translation-sync: zh@v2, en@v2, ja@v2 -->\n"
|
||
},
|
||
{
|
||
"version": "s02",
|
||
"locale": "en",
|
||
"title": "s02: Tool Use — Add a Tool, Add Just One Line",
|
||
"content": "# s02: Tool Use — Add a Tool, Add Just One Line\n\ns01 → `s02` → [s03](/en/s03) → s04 → ... → s16 → s17\n> *\"Add a tool, add just one handler\"* — The loop stays the same. Register the new tool in the dispatch map and you're done.\n>\n> **Harness Layer**: Tool Dispatch — Expanding the model's reach.\n\n---\n\n## Only One Tool: Bash\n\nThe s01 Agent has only one tool: bash. To read a file, `cat`; to write, `echo \"...\" > file.py`; to edit, `sed`.\n\nThe model thinks \"read this file\" but has to spell out `cat path/to/file`. An extra layer of translation that wastes tokens and invites errors.\n\n---\n\n## Overview: Tool Dispatch\n\n\n\nThe s01 loop is fully preserved (LLM call, `tool_use` block check, message append — not a single word changed). The only change is in that one line of tool execution: `run_bash()` is replaced with `TOOL_HANDLERS[block.name]()` dispatch lookup.\n\nAdding a tool to the Agent requires just two things:\n\n1. **Define the tool**: Add one entry to the `TOOLS` array\n2. **Register the handler**: Add one mapping in the `TOOL_HANDLERS` dict\n\n---\n\n## From 1 Tool to 5 Tools\n\ns01 had only bash:\n\n```python\nTOOLS = [{\"name\": \"bash\", ...}]\n\ndef run_bash(command): ...\n```\n\ns02 expands to 5 tools, each independently defined:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\", ...},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\", ...},\n {\"name\": \"write_file\", \"description\": \"Write content to file.\", ...},\n {\"name\": \"edit_file\", \"description\": \"Replace text in file once.\", ...},\n {\"name\": \"glob\", \"description\": \"Find files by pattern.\", ...},\n]\n```\n\nEach tool has its own implementation function:\n\n```python\ndef run_read(path, limit=None):\n lines = safe_path(path).read_text().splitlines()\n if limit:\n lines = lines[:limit]\n return \"\\n\".join(lines)\n\ndef run_write(path, content):\n safe_path(path).write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n\ndef run_edit(path, old_text, new_text):\n text = safe_path(path).read_text()\n if old_text not in text:\n return \"Error: text not found\"\n safe_path(path).write_text(text.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n\ndef run_glob(pattern):\n import glob as g\n return \"\\n\".join(g.glob(pattern, root_dir=WORKDIR))\n```\n\n---\n\n## Tool Dispatch\n\n```python\nTOOL_HANDLERS = {\n \"bash\": run_bash,\n \"read_file\": run_read,\n \"write_file\": run_write,\n \"edit_file\": run_edit,\n \"glob\": run_glob,\n}\n\n# Only one line changed in the loop — from hardcoded run_bash to dispatch lookup:\nfor block in tool_calls:\n handler = TOOL_HANDLERS[block.name] # lookup\n output = handler(**block.input) # call\n results.append(...)\n```\n\nAdding a tool = one entry in `TOOLS` array + one line in `TOOL_HANDLERS` dict. The loop stays the same.\n\n---\n\n## Multiple Tool Calls\n\nThe model often returns multiple tool_use calls at once — \"read a.py and b.py, then list all .py files\".\n\nCalls are executed one by one in their original `response.content` order.\n\n---\n\n## Quick Reference\n\n| Concept | One-Liner |\n|---------|-----------|\n| TOOL_HANDLERS | Tool name → handler function dict. Add a tool = add one mapping line |\n| Tool Definition | JSON schema telling the model \"what I can do\" |\n| Multiple tool calls | Model may return multiple tool_use at once; calls execute in their original order |\n| Loop Unchanged | s01's `while True` loop — not a single line changed |\n\n---\n\n## Changes from s01\n\n| Component | Before (s01) | After (s02) |\n|-----------|-------------|-------------|\n| Tool count | 1 (bash) | 5 (+read, write, edit, glob) |\n| Tool execution | Hardcoded `run_bash()` | TOOL_HANDLERS dispatch lookup |\n| Path safety | None | safe_path validation (file tools only) |\n| Loop | `while True` + `tool_use` block | Identical to s01 |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s02_tool_use/code.py\n```\n\nTry these prompts:\n\n1. `Read the file README.md and tell me what this project is about`\n2. `Create a file called test.py that prints \"hello\", then read it back`\n3. `Find all Python files in this directory`\n4. `Read both README.md and requirements.txt, then create a summary file`\n\nWhat to watch for: When does the model call just one tool, and when does it call multiple at once? Are multiple tool calls executed in the correct order?\n\n---\n\n## What's Next\n\nThe Agent now has 5 specialized tools. File tools are protected by `safe_path`, but bash is unrestricted — `rm -rf /` still runs.\n\n→ s03 Permission: Add a gate before tool execution — is this operation safe? Does it need user approval?\n\n\n<!-- translation-sync: zh@v1, en@v1, ja@v1 -->\n"
|
||
},
|
||
{
|
||
"version": "s02",
|
||
"locale": "zh",
|
||
"title": "s02: Tool Use — 多加一个工具,只加一行",
|
||
"content": "# s02: Tool Use — 多加一个工具,只加一行\n\ns01 → `s02` → [s03](/zh/s03) → s04 → ... → s16 → s17\n> *\"加一个工具, 只加一个 handler\"* — 循环不用动, 新工具注册进 dispatch map 就行。\n>\n> **Harness 层**: 工具分发 — 扩展模型能触达的边界。\n\n---\n\n## 只有 bash 一个工具\n\ns01 的 Agent 只有一个 bash 工具。读文件要 `cat`,写文件要 `echo \"...\" > file.py`,改文件要 `sed`。\n\n模型想的是\"读这个文件\",却要拼出 `cat path/to/file`。多了一层翻译,浪费 token,还容易拼错。\n\n---\n\n## 全局视角:工具分发\n\n\n\ns01 的循环完全保留(LLM 调用、`tool_use` block 判断、消息追加)。唯一的变动在工具执行那 1 行:`run_bash()` 替换为 `TOOL_HANDLERS[block.name]()` 查表分发。\n\n给 Agent 加一个工具只需要做两件事:\n\n1. **定义工具**:在 `TOOLS` 数组里加一条描述\n2. **注册处理函数**:在 `TOOL_HANDLERS` 字典里加一个映射\n\n---\n\n## 从 1 个工具到 5 个工具\n\ns01 只有一个 bash:\n\n```python\nTOOLS = [{\"name\": \"bash\", ...}]\n\ndef run_bash(command): ...\n```\n\ns02 加到 5 个,每个工具都是独立定义:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\", ...},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\", ...},\n {\"name\": \"write_file\", \"description\": \"Write content to file.\", ...},\n {\"name\": \"edit_file\", \"description\": \"Replace text in file once.\", ...},\n {\"name\": \"glob\", \"description\": \"Find files by pattern.\", ...},\n]\n```\n\n每个工具有自己的实现函数:\n\n```python\ndef run_read(path, limit=None):\n lines = safe_path(path).read_text().splitlines()\n if limit:\n lines = lines[:limit]\n return \"\\n\".join(lines)\n\ndef run_write(path, content):\n safe_path(path).write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n\ndef run_edit(path, old_text, new_text):\n text = safe_path(path).read_text()\n if old_text not in text:\n return \"Error: text not found\"\n safe_path(path).write_text(text.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n\ndef run_glob(pattern):\n import glob as g\n return \"\\n\".join(g.glob(pattern, root_dir=WORKDIR))\n```\n\n---\n\n## 工具分发\n\n```python\nTOOL_HANDLERS = {\n \"bash\": run_bash,\n \"read_file\": run_read,\n \"write_file\": run_write,\n \"edit_file\": run_edit,\n \"glob\": run_glob,\n}\n\n# 循环里只改了一行——从硬编码 run_bash 变成查表:\nfor block in tool_calls:\n handler = TOOL_HANDLERS[block.name] # 查表\n output = handler(**block.input) # 调用\n results.append(...)\n```\n\n加一个工具 = 在 `TOOLS` 数组加一条 + 在 `TOOL_HANDLERS` 字典加一行。循环不变。\n\n---\n\n## 多个工具调用\n\n模型经常一次返回多个 tool_use:\"读一下 a.py 和 b.py,然后列出所有 .py 文件\"。\n\n这些调用按照 `response.content` 中的原始顺序逐个执行。\n\n---\n\n## 速查\n\n| 概念 | 一句话 |\n|------|--------|\n| TOOL_HANDLERS | 工具名 → 处理函数的字典。加工具 = 加一行映射 |\n| 工具定义 | 告诉模型\"我能做什么\"的 JSON schema |\n| 多工具调用 | 模型可一次返回多个 tool_use,并按原始顺序逐个执行 |\n| 循环不变 | s01 的 `while True` 循环一行都没改 |\n\n---\n\n## 相对 s01 的变更\n\n| 组件 | 之前 (s01) | 之后 (s02) |\n|------|-----------|-----------|\n| 工具数量 | 1 (bash) | 5 (+read, write, edit, glob) |\n| 工具执行 | 硬编码 `run_bash()` | TOOL_HANDLERS 查表分发 |\n| 路径安全 | 无 | safe_path 校验(仅 file tools) |\n| 循环 | `while True` + `tool_use` block | 与 s01 完全一致 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s02_tool_use/code.py\n```\n\n试试这些 prompt:\n\n1. `Read the file README.md and tell me what this project is about`\n2. `Create a file called test.py that prints \"hello\", then read it back`\n3. `Find all Python files in this directory`\n4. `Read both README.md and requirements.txt, then create a summary file`\n\n观察重点:模型什么时候只调一个工具,什么时候一次调多个?多个工具调用的顺序和结果是否正确?\n\n---\n\n## 接下来\n\n现在 Agent 有 5 个专用工具。file tools 受 `safe_path` 保护,但 bash 不受限制,`rm -rf /` 还是能跑。\n\ns03 Permission → 在工具执行之前加一道门:这个操作安全吗?需要用户批准吗?\n\n\n<!-- translation-sync: zh@v1, en@v0, ja@v0 -->\n"
|
||
},
|
||
{
|
||
"version": "s02",
|
||
"locale": "ja",
|
||
"title": "s02: Tool Use — ツール一つ追加、一行追加だけ",
|
||
"content": "# s02: Tool Use — ツール一つ追加、一行追加だけ\n\ns01 → `s02` → [s03](/ja/s03) → s04 → ... → s16 → s17\n> *\"ツールを一つ追加、ハンドラを一つ追加\"* — ループはそのまま。新しいツールをディスパッチマップに登録するだけ。\n>\n> **Harness レイヤー**: ツールディスパッチ — モデルが触れる範囲を拡張。\n\n---\n\n## ツールは bash 一つだけ\n\ns01 の Agent には bash 一つのツールしかない。ファイルを読むには `cat`、書くには `echo \"...\" > file.py`、編集するには `sed`。\n\nモデルは「このファイルを読みたい」と考えながら、`cat path/to/file` と組み立てなければならない。翻訳の層が一つ増え、トークンを無駄にし、エラーも起きやすい。\n\n---\n\n## 概要:ツールディスパッチ\n\n\n\ns01 のループは完全に保持される(LLM 呼び出し、`tool_use` block 判定、メッセージ追加 — 一文字も変更なし)。唯一の変更点はツール実行の 1 行:`run_bash()` が `TOOL_HANDLERS[block.name]()` の検索ディスパッチに置き換わる。\n\nAgent にツールを追加するには、たった二つ:\n\n1. **ツールを定義**:`TOOLS` 配列に一条を追加\n2. **ハンドラを登録**:`TOOL_HANDLERS` 辞書に一つのマッピングを追加\n\n---\n\n## 1 つのツールから 5 つのツールへ\n\ns01 には bash だけだった:\n\n```python\nTOOLS = [{\"name\": \"bash\", ...}]\n\ndef run_bash(command): ...\n```\n\ns02 では 5 つに増え、各ツールは独立して定義される:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\", ...},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\", ...},\n {\"name\": \"write_file\", \"description\": \"Write content to file.\", ...},\n {\"name\": \"edit_file\", \"description\": \"Replace text in file once.\", ...},\n {\"name\": \"glob\", \"description\": \"Find files by pattern.\", ...},\n]\n```\n\n各ツールには専用の実装関数がある:\n\n```python\ndef run_read(path, limit=None):\n lines = safe_path(path).read_text().splitlines()\n if limit:\n lines = lines[:limit]\n return \"\\n\".join(lines)\n\ndef run_write(path, content):\n safe_path(path).write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n\ndef run_edit(path, old_text, new_text):\n text = safe_path(path).read_text()\n if old_text not in text:\n return \"Error: text not found\"\n safe_path(path).write_text(text.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n\ndef run_glob(pattern):\n import glob as g\n return \"\\n\".join(g.glob(pattern, root_dir=WORKDIR))\n```\n\n---\n\n## ツールディスパッチ\n\n```python\nTOOL_HANDLERS = {\n \"bash\": run_bash,\n \"read_file\": run_read,\n \"write_file\": run_write,\n \"edit_file\": run_edit,\n \"glob\": run_glob,\n}\n\n# ループ内で変更されたのは一行だけ — ハードコードの run_bash から検索ディスパッチへ:\nfor block in tool_calls:\n handler = TOOL_HANDLERS[block.name] # 検索\n output = handler(**block.input) # 呼び出し\n results.append(...)\n```\n\nツールの追加 = `TOOLS` 配列に一条 + `TOOL_HANDLERS` 辞書に一行。ループは変わらない。\n\n---\n\n## 複数のツール呼び出し\n\nモデルはよく一度に複数の tool_use を返す — 「a.py と b.py を読んで、全 .py ファイルを列挙して」。\n\nこれらの呼び出しは、`response.content` に現れる元の順序で一つずつ実行する。\n\n---\n\n## 速查\n\n| 概念 | 一言で |\n|------|--------|\n| TOOL_HANDLERS | ツール名 → ハンドラ関数の辞書。ツール追加 = マッピング一行追加 |\n| ツール定義 | モデルに「何ができるか」を伝える JSON schema |\n| 複数ツール呼び出し | モデルは一度に複数の tool_use を返す可能性があり、元の順序で一つずつ実行する |\n| ループ不変 | s01 の `while True` ループ — 一行も変更なし |\n\n---\n\n## s01 からの変更\n\n| コンポーネント | 変更前 (s01) | 変更後 (s02) |\n|--------------|-------------|-------------|\n| ツール数 | 1 (bash) | 5 (+read, write, edit, glob) |\n| ツール実行 | ハードコード `run_bash()` | TOOL_HANDLERS 検索ディスパッチ |\n| パス安全性 | なし | safe_path 検証(file tools のみ) |\n| ループ | `while True` + `tool_use` block | s01 と完全に同一 |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s02_tool_use/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `Read the file README.md and tell me what this project is about`\n2. `Create a file called test.py that prints \"hello\", then read it back`\n3. `Find all Python files in this directory`\n4. `Read both README.md and requirements.txt, then create a summary file`\n\n観察のポイント:モデルがツールを一つだけ呼び出すときと、複数同時に呼び出すときの違い。複数のツール呼び出しは正しい順序で実行されているか?\n\n---\n\n## 次へ\n\nAgent は 5 つの専用ツールを持つようになった。file tools は `safe_path` で保護されるが、bash は制限なし — `rm -rf /` はまだ実行できる。\n\n→ s03 Permission:ツール実行前にゲートを追加 — この操作は安全か? ユーザーの承認が必要か?\n\n\n<!-- translation-sync: zh@v1, en@v1, ja@v1 -->\n"
|
||
},
|
||
{
|
||
"version": "s03",
|
||
"locale": "en",
|
||
"title": "s03: Permission — Check Permissions Before Execution",
|
||
"content": "# s03: Permission — Check Permissions Before Execution\n\ns01 → s02 → `s03` → [s04](/en/s04) → s05 → ... → s16 → s17\n> *\"Check permissions before executing\"* — The permission pipeline decides which operations need approval.\n>\n> **Harness Layer**: Permission — a gate before tool execution.\n\n---\n\n## The Problem\n\ns02's Agent has 5 tools. File tools are protected by `safe_path`, but bash is unrestricted. Ask it to \"clean up the project,\" and it might run `rm -rf /`.\n\nSafety can't rely on trusting the model — it needs code: a check before every tool execution.\n\n---\n\n## The Solution\n\n\n\ns02's loop is fully preserved. The only change is inserting `check_permission()` before tool execution — each tool call passes through three gates in a fixed order: hard deny first, then soft ask, and if neither matches, allow.\n\nThe three gates correspond to three decisions:\n\n| Gate | Purpose | On Match |\n|------|---------|----------|\n| 1. Deny List | Permanently forbidden operations (`rm -rf /`, `sudo`) | Denied immediately, not executed |\n| 2. Rule Matching | Context-dependent operations (reading/writing outside workspace, `rm` files) | Passed to Gate 3 |\n| 3. User Approval | After Gate 2 matches, pauses for user confirmation | User decides allow or deny |\n\nNone of the three gates match → execute directly. Most routine operations take this path.\n\n---\n\n## How It Works\n\n\n\n**Gate 1**: A hard deny list. Check first; if matched, return a block message. This list uses simple string matching to show where the permission gate sits; it is not a complete security boundary.\n\n```python\nDENY_LIST = [\n \"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\",\n \"mkfs\", \"dd if=\", \"> /dev/sda\",\n]\n\ndef check_deny_list(command: str) -> str | None:\n for pattern in DENY_LIST:\n if pattern in command:\n return f\"Blocked: '{pattern}' is on the deny list\"\n return None\n```\n\n**Gate 2**: Rule matching — describes \"when to ask the user.\" Each rule specifies a tool and a check condition.\n\n```python\nPERMISSION_RULES = [\n {\n \"tools\": [\"read_file\", \"write_file\", \"edit_file\"],\n \"check\": lambda args: not (WORKDIR / args.get(\"path\", \"\")).resolve().is_relative_to(WORKDIR),\n \"message\": \"Access outside workspace\",\n },\n {\n \"tools\": [\"bash\"],\n \"check\": lambda args: any(kw in args.get(\"command\", \"\") for kw in [\"rm \", \"> /etc/\", \"chmod 777\"]),\n \"message\": \"Potentially destructive command\",\n },\n]\n\ndef check_rules(tool_name: str, args: dict) -> str | None:\n for rule in PERMISSION_RULES:\n if tool_name in rule[\"tools\"] and rule[\"check\"](args):\n return rule[\"message\"]\n return None\n```\n\n**Gate 3**: After a rule matches, pause for user input.\n\n```python\ndef ask_user(tool_name: str, args: dict, reason: str) -> str:\n print(f\"\\n⚠ {reason}\")\n print(f\" Tool: {tool_name}({args})\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n return \"allow\" if choice in (\"y\", \"yes\") else \"deny\"\n```\n\n**All three gates chained together**, inserted before tool execution:\n\n```python\ndef check_permission(block) -> bool:\n # Gate 1: Hard deny\n if block.name == \"bash\":\n reason = check_deny_list(block.input.get(\"command\", \"\"))\n if reason:\n print(f\"\\n⛔ {reason}\")\n return False\n\n # Gate 2 + 3: Rule matching → User approval\n reason = check_rules(block.name, block.input)\n if reason:\n decision = ask_user(block.name, block.input, reason)\n if decision == \"deny\":\n return False\n\n return True\n\n# In agent_loop — s02's loop with just one line added:\nfor block in tool_calls:\n if not check_permission(block): # ← NEW\n results.append({... \"content\": \"Permission denied.\"})\n continue\n output = TOOL_HANDLERS[block.name](**block.input) # s02 original\n results.append(...)\n```\n\n---\n\n## Changes from s02\n\n| Component | Before (s02) | After (s03) |\n|-----------|-------------|-------------|\n| Security model | None (trust the model) | Three-gate permission pipeline |\n| New functions | — | check_deny_list, check_rules, ask_user, check_permission |\n| Loop | Executes all tools directly | Inserts check_permission() before execution |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s03_permission/code.py\n```\n\nTry these prompts:\n\n1. `Create a file called test.txt in the current directory` (should pass through)\n2. `Delete the file test.txt` (bash + rm triggers Gate 2)\n3. `What files are in the current directory?` (read-only, all pass)\n4. `Try to write a file to /etc/something` (writing outside workspace triggers Gate 2)\n\nWhat to watch for: Which operations pass through? Which need your confirmation? Which are denied outright?\n\n---\n\n## What's Next\n\nPermission checks are in place — but every check is hardcoded as `check_permission()` inside the loop. What if you want to add logging before and after each tool execution? What if you want to auto-trigger a git commit after certain operations? Scattering this extension logic throughout the loop makes it bloat.\n\n→ s04 Hooks: Add hooks to the loop. Extension logic hangs on hooks; the loop stays clean.\n\n\n<!-- translation-sync: zh@v1, en@v1, ja@v1 -->\n"
|
||
},
|
||
{
|
||
"version": "s03",
|
||
"locale": "zh",
|
||
"title": "s03: Permission — 执行前做权限判断",
|
||
"content": "# s03: Permission — 执行前做权限判断\n\ns01 → s02 → `s03` → [s04](/zh/s04) → s05 → ... → s16 → s17\n> *\"工具执行前先做权限判断\"* — 权限管线决定哪些操作需要审批。\n>\n> **Harness 层**: 权限 — 在工具执行前加一道门。\n\n---\n\n## 问题\n\ns02 的 Agent 有 5 个工具。file tools 受 `safe_path` 保护,但 bash 不受限制。让它\"清理一下项目\",可能执行 `rm -rf /`。\n\n安全边界由代码负责,判断发生在工具执行之前。\n\n---\n\n## 解决方案\n\n\n\ns02 的循环完全保留。唯一的变动是在工具执行前插入 `check_permission()`。每个工具调用依次经过三道闸门:硬拒绝优先,软询问次之,都没命中就放行。\n\n三道闸门对应三种决策:\n\n| 闸门 | 作用 | 命中后 |\n|------|------|--------|\n| 1. 拒绝列表 | 永远禁止的操作(`rm -rf /`、`sudo`) | 直接拒绝,不执行 |\n| 2. 规则匹配 | 取决于上下文的操作(读/写工作区外、`rm` 文件) | 交给闸门 3 |\n| 3. 用户审批 | 闸门 2 命中后,暂停等用户确认 | 用户决定允许或拒绝 |\n\n三道都没命中 → 直接执行。大部分日常操作走这条路。\n\n---\n\n## 工作原理\n\n\n\n**闸门 1**:一张硬拒绝表,先查,命中就返回阻止信息。这张表使用简单字符串匹配来说明权限闸门的位置,不能视为完整的安全边界。\n\n```python\nDENY_LIST = [\n \"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\",\n \"mkfs\", \"dd if=\", \"> /dev/sda\",\n]\n\ndef check_deny_list(command: str) -> str | None:\n for pattern in DENY_LIST:\n if pattern in command:\n return f\"Blocked: '{pattern}' is on the deny list\"\n return None\n```\n\n**闸门 2**负责规则匹配,用来描述\"什么时候需要问用户\"。每条规则指定工具和检查条件。\n\n```python\nPERMISSION_RULES = [\n {\n \"tools\": [\"read_file\", \"write_file\", \"edit_file\"],\n \"check\": lambda args: not (WORKDIR / args.get(\"path\", \"\")).resolve().is_relative_to(WORKDIR),\n \"message\": \"Access outside workspace\",\n },\n {\n \"tools\": [\"bash\"],\n \"check\": lambda args: any(kw in args.get(\"command\", \"\") for kw in [\"rm \", \"> /etc/\", \"chmod 777\"]),\n \"message\": \"Potentially destructive command\",\n },\n]\n\ndef check_rules(tool_name: str, args: dict) -> str | None:\n for rule in PERMISSION_RULES:\n if tool_name in rule[\"tools\"] and rule[\"check\"](args):\n return rule[\"message\"]\n return None\n```\n\n**闸门 3**:规则命中后,暂停等用户输入。\n\n```python\ndef ask_user(tool_name: str, args: dict, reason: str) -> str:\n print(f\"\\n⚠ {reason}\")\n print(f\" Tool: {tool_name}({args})\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n return \"allow\" if choice in (\"y\", \"yes\") else \"deny\"\n```\n\n**三道闸门串在一起**,插在工具执行之前:\n\n```python\ndef check_permission(block) -> bool:\n # 闸门 1: 硬拒绝\n if block.name == \"bash\":\n reason = check_deny_list(block.input.get(\"command\", \"\"))\n if reason:\n print(f\"\\n⛔ {reason}\")\n return False\n\n # 闸门 2 + 3: 规则匹配 → 用户审批\n reason = check_rules(block.name, block.input)\n if reason:\n decision = ask_user(block.name, block.input, reason)\n if decision == \"deny\":\n return False\n\n return True\n\n# 在 agent_loop 中——s02 的循环只加了一行:\nfor block in tool_calls:\n if not check_permission(block): # ← 新增\n results.append({... \"content\": \"Permission denied.\"})\n continue\n output = TOOL_HANDLERS[block.name](**block.input) # s02 原有\n results.append(...)\n```\n\n---\n\n## 相对 s02 的变更\n\n| 组件 | 之前 (s02) | 之后 (s03) |\n|------|-----------|-----------|\n| 安全模型 | 无(信任模型) | 三道闸门权限管线 |\n| 新函数 | — | check_deny_list, check_rules, ask_user, check_permission |\n| 循环 | 直接执行所有工具 | 执行前插入 check_permission() |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s03_permission/code.py\n```\n\n试试这些 prompt:\n\n1. `Create a file called test.txt in the current directory`(应该直接通过)\n2. `Delete the file test.txt`(bash + rm 会触发闸门 2)\n3. `What files are in the current directory?`(只读,全部通过)\n4. `Try to write a file to /etc/something`(写工作区外,触发闸门 2)\n\n观察重点:哪些操作直接通过?哪些需要你确认?哪些被直接拒绝?\n\n---\n\n## 接下来\n\n当前权限检查每次都在循环里硬编码 `check_permission()`。如果我想在每次工具执行前后加日志?如果想在某些操作后自动触发 git commit?这些扩展逻辑散落在 loop 里,循环很快就会膨胀。\n\ns04 Hooks → 给循环加钩子,扩展逻辑挂在钩子上,循环保持干净。\n\n\n<!-- translation-sync: zh@v1, en@v1, ja@v1 -->\n"
|
||
},
|
||
{
|
||
"version": "s03",
|
||
"locale": "ja",
|
||
"title": "s03: Permission — 実行前に権限を判断する",
|
||
"content": "# s03: Permission — 実行前に権限を判断する\n\ns01 → s02 → `s03` → [s04](/ja/s04) → s05 → ... → s16 → s17\n> *\"ツール実行前に権限を判断\"* — 権限パイプラインは、どの操作に承認が必要かを決める。\n>\n> **Harness レイヤー**: 権限 — ツール実行前に一つのゲートを追加。\n\n---\n\n## 課題\n\ns02 の Agent は 5 つのツールを持つ。file tools は `safe_path` で保護されるが、bash は制限なし。「プロジェクトを掃除して」と頼むと、`rm -rf /` を実行しかねない。\n\n安全性はモデルを信頼することではなく、コードに頼る — ツール実行前に判断を挟む。\n\n---\n\n## ソリューション\n\n\n\ns02 のループは完全に維持される。唯一の変更は、ツール実行前に `check_permission()` を挿入すること — 各ツール呼び出しは 3 つのゲートを固定順序で通過する:ハード拒否が最優先、次にソフト確認、どちらも一致しなければ許可。\n\n3 つのゲートは 3 つの決定に対応する:\n\n| ゲート | 役割 | 一致時 |\n|--------|------|--------|\n| 1. 拒否リスト | 常に禁止される操作(`rm -rf /`、`sudo`) | 即座に拒否、実行しない |\n| 2. ルールマッチング | コンテキスト依存の操作(作業ディレクトリ外への読み書き、`rm` ファイル) | ゲート 3 へ |\n| 3. ユーザー承認 | ゲート 2 が一致した場合、ユーザー確認を待機 | ユーザーが許可または拒否を決定 |\n\n3 つのゲートのどれにも一致しない → 直接実行。日常の操作の大部分はこの経路を通る。\n\n---\n\n## 仕組み\n\n\n\n**ゲート 1**:ハード拒否リスト。最初に確認し、一致すればブロックメッセージを返す。このリストは権限ゲートの位置を示すための単純な文字列照合であり、完全なセキュリティ境界ではない。\n\n```python\nDENY_LIST = [\n \"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\",\n \"mkfs\", \"dd if=\", \"> /dev/sda\",\n]\n\ndef check_deny_list(command: str) -> str | None:\n for pattern in DENY_LIST:\n if pattern in command:\n return f\"Blocked: '{pattern}' is on the deny list\"\n return None\n```\n\n**ゲート 2**:ルールマッチング — 「いつユーザーに聞くべきか」を記述する。各ルールはツールとチェック条件を指定する。\n\n```python\nPERMISSION_RULES = [\n {\n \"tools\": [\"read_file\", \"write_file\", \"edit_file\"],\n \"check\": lambda args: not (WORKDIR / args.get(\"path\", \"\")).resolve().is_relative_to(WORKDIR),\n \"message\": \"Access outside workspace\",\n },\n {\n \"tools\": [\"bash\"],\n \"check\": lambda args: any(kw in args.get(\"command\", \"\") for kw in [\"rm \", \"> /etc/\", \"chmod 777\"]),\n \"message\": \"Potentially destructive command\",\n },\n]\n\ndef check_rules(tool_name: str, args: dict) -> str | None:\n for rule in PERMISSION_RULES:\n if tool_name in rule[\"tools\"] and rule[\"check\"](args):\n return rule[\"message\"]\n return None\n```\n\n**ゲート 3**:ルールが一致した後、ユーザー入力を待機。\n\n```python\ndef ask_user(tool_name: str, args: dict, reason: str) -> str:\n print(f\"\\n⚠ {reason}\")\n print(f\" Tool: {tool_name}({args})\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n return \"allow\" if choice in (\"y\", \"yes\") else \"deny\"\n```\n\n**3 つのゲートを直列に接続**、ツール実行前に挿入する:\n\n```python\ndef check_permission(block) -> bool:\n # ゲート 1: ハード拒否\n if block.name == \"bash\":\n reason = check_deny_list(block.input.get(\"command\", \"\"))\n if reason:\n print(f\"\\n⛔ {reason}\")\n return False\n\n # ゲート 2 + 3: ルールマッチング → ユーザー承認\n reason = check_rules(block.name, block.input)\n if reason:\n decision = ask_user(block.name, block.input, reason)\n if decision == \"deny\":\n return False\n\n return True\n\n# agent_loop で — s02 のループに 1 行追加するだけ:\nfor block in tool_calls:\n if not check_permission(block): # ← 新規\n results.append({... \"content\": \"Permission denied.\"})\n continue\n output = TOOL_HANDLERS[block.name](**block.input) # s02 既存\n results.append(...)\n```\n\n---\n\n## s02 からの変更点\n\n| コンポーネント | 変更前 (s02) | 変更後 (s03) |\n|---------------|-------------|-------------|\n| セキュリティモデル | なし(モデルを信頼) | 3 ゲート権限パイプライン |\n| 新規関数 | — | check_deny_list, check_rules, ask_user, check_permission |\n| ループ | すべてのツールを直接実行 | 実行前に check_permission() を挿入 |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s03_permission/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `Create a file called test.txt in the current directory`(そのまま通過するはず)\n2. `Delete the file test.txt`(bash + rm でゲート 2 が発動)\n3. `What files are in the current directory?`(読み取り専用、すべて通過)\n4. `Try to write a file to /etc/something`(作業ディレクトリ外への書き込みでゲート 2 が発動)\n\n観察のポイント:どの操作がそのまま通過するか? どれに確認が必要か? どれが即座に拒否されるか?\n\n---\n\n## 次へ\n\n権限チェックは実装された — しかし、毎回ループ内に `check_permission()` をハードコードしている。ツール実行の前後にログを追加したい場合は? 特定の操作後に自動的に git commit をトリガーしたい場合は? このような拡張ロジックがループ内に散らばると、ループはすぐに膨張する。\n\n→ s04 Hooks:ループにフックを追加する。拡張ロジックはフックにぶら下げ、ループはクリーンに保つ。\n\n\n<!-- translation-sync: zh@v1, en@v1, ja@v1 -->\n"
|
||
},
|
||
{
|
||
"version": "s04",
|
||
"locale": "en",
|
||
"title": "s04: Hooks — Hang on the Loop, Don't Write into It",
|
||
"content": "# s04: Hooks — Hang on the Loop, Don't Write into It\n\ns01 → s02 → s03 → `s04` → [s05](/en/s05) → s06 → ... → s16 → s17\n\n> *\"Hang on the loop, don't write into it\"* — Hooks inject extension logic before and after tool execution.\n>\n> **Harness Layer**: Hooks — Extension points that don't invade the loop.\n\n---\n\n## The Problem\n\nThe s03 Agent has permission checks. But every new check, \"log every bash call\", \"auto git add after writes\", requires modifying the `agent_loop` function.\n\nThe loop quickly becomes this:\n\n```python\ndef agent_loop(messages):\n while True:\n # ... LLM call ...\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n log_to_file(block) # added a line\n check_permission(block) # added a line\n notify_slack(block) # added another line\n output = execute(block)\n auto_git_add(block) # yet another line\n # ... the loop is unrecognizable\n```\n\nWhat you want to extend is the Agent's behavior, but what you're modifying is the loop itself. The loop should be a stable core; extensions should hang on the outside.\n\n---\n\n## The Solution\n\n\n\nThe s03 loop and permission logic are fully preserved. The only change is moving `check_permission()` from inside the loop body onto a hook. The loop no longer directly calls any check function. Instead it calls `trigger_hooks(\"PreToolUse\", block)`, and the registry decides what to run.\n\nFour events, covering a complete agent cycle:\n\n| Event | Trigger Timing | Typical Use |\n|-------|---------------|-------------|\n| UserPromptSubmit | After user input, before entering LLM | Input validation, context injection |\n| PreToolUse | Before tool execution | Permission checks, logging |\n| PostToolUse | After tool execution | Side effects (auto git add etc.), output checking |\n| Stop | When the loop is about to exit | Cleanup, decide whether the loop continues |\n\nExtensions are added via `register_hook()`. The loop only calls `trigger_hooks()`.\n\n---\n\n## How It Works\n\n**Hook registry**: a dict mapping event names to callback lists.\n\n```python\nHOOKS = {\n \"UserPromptSubmit\": [],\n \"PreToolUse\": [],\n \"PostToolUse\": [],\n \"Stop\": [],\n}\n\ndef register_hook(event: str, callback):\n HOOKS[event].append(callback)\n\ndef trigger_hooks(event: str, *args):\n for callback in HOOKS[event]:\n result = callback(*args)\n if result is not None: # return value ≠ None → hook says \"stop\"\n return result\n return None\n```\n\nWhen `PreToolUse` returns non-None, the current tool execution is blocked. When `Stop` returns non-None, the loop continues. Return values from `UserPromptSubmit` and `PostToolUse` do not affect control flow.\n\n**UserPromptSubmit** triggers after user input and before entering the LLM. The following hook records the current working directory:\n\n```python\ndef context_inject_hook(query: str) -> str | None:\n \"\"\"Inject current working directory info into every prompt.\"\"\"\n print(f\"\\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\\033[0m\")\n return None # return None = no modification, let prompt through\n\nregister_hook(\"UserPromptSubmit\", context_inject_hook)\n```\n\nIn the main loop, triggered right after user input:\n\n```python\nquery = input(\"s04 >> \")\ntrigger_hooks(\"UserPromptSubmit\", query) # ← before entering LLM\nhistory.append({\"role\": \"user\", \"content\": query})\nagent_loop(history)\n```\n\n**PreToolUse / PostToolUse**, hooks before and after tool execution. s03's permission check logic is now wrapped as a PreToolUse hook, plus a logging hook and a large-output reminder:\n\n```python\n# PreToolUse: permission check (s03 logic, moved from loop to hook)\ndef permission_hook(block):\n if block.name == \"bash\":\n for pattern in DENY_LIST:\n if pattern in block.input.get(\"command\", \"\"):\n return \"Permission denied by deny list\"\n if block.name in (\"read_file\", \"write_file\", \"edit_file\"):\n path = block.input.get(\"path\", \"\")\n if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n return None\n\n# PreToolUse: logging\ndef log_hook(block):\n print(f\"[HOOK] {block.name}(...)\")\n\n# PostToolUse: large output reminder\ndef large_output_hook(block, output):\n if len(str(output)) > 100000:\n print(f\"[HOOK] ⚠ Large output from {block.name}\")\n\nregister_hook(\"PreToolUse\", permission_hook)\nregister_hook(\"PreToolUse\", log_hook)\nregister_hook(\"PostToolUse\", large_output_hook)\n```\n\n**Stop** triggers when the loop is about to exit. The following hook prints a cleanup summary:\n\n```python\ndef summary_hook(messages: list) -> str | None:\n \"\"\"Print a summary when the loop is about to stop.\"\"\"\n tool_count = sum(1 for m in messages\n for b in (m.get(\"content\") if isinstance(m.get(\"content\"), list) else [])\n if isinstance(b, dict) and b.get(\"type\") == \"tool_result\")\n print(f\"\\033[90m[HOOK] Stop: session used {tool_count} tool calls\\033[0m\")\n return None # return None = allow stop, return string = force continuation\n\nregister_hook(\"Stop\", summary_hook)\n```\n\nIn agent_loop, triggered before exit:\n\n```python\ntool_calls = [\n block for block in response.content if block.type == \"tool_use\"\n]\nif not tool_calls:\n force = trigger_hooks(\"Stop\", messages) # ← before exiting\n if force:\n # hook returned a message → inject it and continue\n messages.append({\"role\": \"user\", \"content\": force})\n continue\n return\n```\n\n**Only one change in the loop**: s03 directly called `check_permission(block)`, s04 replaces it with `trigger_hooks(\"PreToolUse\", block)`:\n\n```python\nfor block in tool_calls:\n # s03: if not check_permission(block): ...\n # s04: hooks replace hardcoding\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": str(blocked)})\n continue\n\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n\n trigger_hooks(\"PostToolUse\", block, output)\n\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": output})\n```\n\nFour hooks cover the critical nodes of the agent cycle: input → before execution → after execution → exit. The loop only calls trigger_hooks(); all logic lives in hook callbacks.\n\n---\n\n## Changes from s03\n\n| Component | Before (s03) | After (s04) |\n|-----------|-------------|-------------|\n| Extension method | check_permission() hardcoded in the loop | HOOKS registry + trigger_hooks() |\n| New functions | — | register_hook, trigger_hooks |\n| Hook callbacks | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook |\n| Loop | Directly calls check_permission() | Calls trigger_hooks(\"PreToolUse\", ...) |\n| Exit control | None | trigger_hooks(\"Stop\", ...) can prevent exit |\n| Input interception | None | trigger_hooks(\"UserPromptSubmit\", ...) can inject context |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s04_hooks/code.py\n```\n\nTry these prompts:\n\n1. `Read the file README.md` (should pass directly, observe hook logs)\n2. `Create a file called test.txt` (after creation, observe if PostToolUse fires)\n3. `Delete all temporary files in /tmp` (bash + rm triggers permission hook)\n\nWhat to watch for: Before each tool execution, does the `[HOOK]` log appear? When permission is denied, was it intercepted by a hook or hardcoded in the loop?\n\n---\n\n## What's Next\n\nThe Agent can now safely execute operations. But does it ever stop to think \"what should I do first, and what next?\" Given a complex task, does it jump straight in, or plan first?\n\n→ s05 TodoWrite: Give the Agent a planning tool. Make a list first, then execute.\n\n\n<!-- translation-sync: zh@v1, en@v1, ja@v1 -->\n"
|
||
},
|
||
{
|
||
"version": "s04",
|
||
"locale": "zh",
|
||
"title": "s04: Hooks — 挂在循环上,不写进循环里",
|
||
"content": "# s04: Hooks — 挂在循环上,不写进循环里\n\ns01 → s02 → s03 → `s04` → [s05](/zh/s05) → s06 → ... → s16 → s17\n\n> *\"挂在循环上, 不写进循环里\"* — hook 在工具执行前后注入扩展逻辑。\n>\n> **Harness 层**: hook — 扩展点不侵入循环。\n\n---\n\n## 问题\n\ns03 的 Agent 有权限检查了。但每次加一个新检查,比如\"记录每次 bash 调用\"、\"操作后自动 git add\",都要修改 `agent_loop` 函数。\n\n循环很快就变成了这样:\n\n```python\ndef agent_loop(messages):\n while True:\n # ... LLM call ...\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n log_to_file(block) # 加一行\n check_permission(block) # 加一行\n notify_slack(block) # 又加一行\n output = execute(block)\n auto_git_add(block) # 再加一行\n # ... 很快循环就认不出来了\n```\n\n你想扩展的是 Agent 的行为,但你改的却是循环本身。循环应该是一个稳定的核心,扩展应该挂在外面。\n\n---\n\n## 解决方案\n\n\n\ns03 的循环和权限逻辑完全保留。唯一的变动是把 `check_permission()` 从循环体内移到了 hook 上,循环不再直接调用任何检查函数,改为 `trigger_hooks(\"PreToolUse\", block)`,由注册表决定跑什么。\n\n四个事件,覆盖一个完整的 agent cycle:\n\n| 事件 | 触发时机 | 典型用途 |\n|------|---------|---------|\n| UserPromptSubmit | 用户输入提交后、进入 LLM 前 | 输入验证、注入上下文 |\n| PreToolUse | 工具执行前 | 权限检查、日志记录 |\n| PostToolUse | 工具执行后 | 副作用(自动 git add 等)、输出检查 |\n| Stop | 循环即将退出时 | 收尾清理、决定是否继续循环 |\n\n扩展通过 `register_hook()` 添加,循环只调用 `trigger_hooks()`。\n\n---\n\n## 工作原理\n\n**hook 注册表**:一个字典,事件名映射到回调列表。\n\n```python\nHOOKS = {\n \"UserPromptSubmit\": [],\n \"PreToolUse\": [],\n \"PostToolUse\": [],\n \"Stop\": [],\n}\n\ndef register_hook(event: str, callback):\n HOOKS[event].append(callback)\n\ndef trigger_hooks(event: str, *args):\n for callback in HOOKS[event]:\n result = callback(*args)\n if result is not None: # 返回值 ≠ None → hook 说\"停\"\n return result\n return None\n```\n\n`PreToolUse` 返回非 `None` 时,本次工具执行被阻止;`Stop` 返回非 `None` 时,循环继续。`UserPromptSubmit` 和 `PostToolUse` 的返回值不参与控制流。\n\n**UserPromptSubmit** 在用户输入提交后、进入 LLM 前触发。以下 hook 记录当前工作目录:\n\n```python\ndef context_inject_hook(query: str) -> str | None:\n \"\"\"Inject current working directory info into every prompt.\"\"\"\n print(f\"\\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\\033[0m\")\n return None # return None = no modification, let prompt through\n\nregister_hook(\"UserPromptSubmit\", context_inject_hook)\n```\n\n在主循环中,用户输入后立即触发:\n\n```python\nquery = input(\"s04 >> \")\ntrigger_hooks(\"UserPromptSubmit\", query) # ← 进入 LLM 之前\nhistory.append({\"role\": \"user\", \"content\": query})\nagent_loop(history)\n```\n\n**PreToolUse / PostToolUse**,工具执行前后的 hook。s03 的权限检查逻辑现在包装成 PreToolUse hook,再加一个日志 hook 和一个大输出提醒:\n\n```python\n# PreToolUse: 权限检查(s03 的逻辑,从循环移到 hook)\ndef permission_hook(block):\n if block.name == \"bash\":\n for pattern in DENY_LIST:\n if pattern in block.input.get(\"command\", \"\"):\n return \"Permission denied by deny list\"\n if block.name in (\"read_file\", \"write_file\", \"edit_file\"):\n path = block.input.get(\"path\", \"\")\n if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n return None\n\n# PreToolUse: 日志\ndef log_hook(block):\n print(f\"[HOOK] {block.name}(...)\")\n\n# PostToolUse: 大文件提醒\ndef large_output_hook(block, output):\n if len(str(output)) > 100000:\n print(f\"[HOOK] ⚠ Large output from {block.name}\")\n\nregister_hook(\"PreToolUse\", permission_hook)\nregister_hook(\"PreToolUse\", log_hook)\nregister_hook(\"PostToolUse\", large_output_hook)\n```\n\n**Stop** 在循环即将退出时触发。以下 hook 打印收尾统计:\n\n```python\ndef summary_hook(messages: list) -> str | None:\n \"\"\"Print a summary when the loop is about to stop.\"\"\"\n tool_count = sum(1 for m in messages\n for b in (m.get(\"content\") if isinstance(m.get(\"content\"), list) else [])\n if isinstance(b, dict) and b.get(\"type\") == \"tool_result\")\n print(f\"\\033[90m[HOOK] Stop: session used {tool_count} tool calls\\033[0m\")\n return None # return None = allow stop, return string = force continuation\n\nregister_hook(\"Stop\", summary_hook)\n```\n\n在 agent_loop 中,退出前触发:\n\n```python\ntool_calls = [\n block for block in response.content if block.type == \"tool_use\"\n]\nif not tool_calls:\n force = trigger_hooks(\"Stop\", messages) # ← 退出之前\n if force:\n # hook returned a message → inject it and continue\n messages.append({\"role\": \"user\", \"content\": force})\n continue\n return\n```\n\n**循环里只改了一处**:s03 直接调用 `check_permission(block)`,s04 改为 `trigger_hooks(\"PreToolUse\", block)`:\n\n```python\nfor block in tool_calls:\n # s03: if not check_permission(block): ...\n # s04: hook 替代硬编码\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": str(blocked)})\n continue\n\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n\n trigger_hooks(\"PostToolUse\", block, output)\n\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": output})\n```\n\n四个 hook 覆盖了 agent cycle 的关键节点:输入→执行前→执行后→退出。循环只负责调用 trigger_hooks(),具体逻辑全在 hook 回调里。\n\n---\n\n## 相对 s03 的变更\n\n| 组件 | 之前 (s03) | 之后 (s04) |\n|------|-----------|-----------|\n| 扩展方式 | check_permission() 硬编码在循环里 | HOOKS 注册表 + trigger_hooks() |\n| 新函数 | — | register_hook, trigger_hooks |\n| hook 回调 | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook |\n| 循环 | 直接调用 check_permission() | 调用 trigger_hooks(\"PreToolUse\", ...) |\n| 退出控制 | 无 | trigger_hooks(\"Stop\", ...) 可阻止退出 |\n| 输入拦截 | 无 | trigger_hooks(\"UserPromptSubmit\", ...) 可注入上下文 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s04_hooks/code.py\n```\n\n试试这些 prompt:\n\n1. `Read the file README.md`(应该直接通过,观察 hook 日志)\n2. `Create a file called test.txt`(通过后观察 PostToolUse 是否触发)\n3. `Delete all temporary files in /tmp`(bash + rm 触发权限 hook)\n\n观察重点:每次工具执行前,是否出现了 `[HOOK]` 日志?权限被拒时,是 hook 拦截的还是循环里硬编码的?\n\n---\n\n## 接下来\n\nAgent 现在能安全执行操作了。但它有没有停下来想过\"我应该先做什么,再做什么\"?给它一个复杂任务,它是一上来就动手,还是先列个计划?\n\ns05 TodoWrite → 给 Agent 一个计划工具。先列清单,再做。\n\n\n<!-- translation-sync: zh@v1, en@v0, ja@v0 -->\n"
|
||
},
|
||
{
|
||
"version": "s04",
|
||
"locale": "ja",
|
||
"title": "s04: Hooks — ループに掛ける、ループには書き込まない",
|
||
"content": "# s04: Hooks — ループに掛ける、ループには書き込まない\n\ns01 → s02 → s03 → `s04` → [s05](/ja/s05) → s06 → ... → s16 → s17\n\n> *\"ループに掛ける、ループには書き込まない\"* — フックがツール実行の前後に拡張ロジックを注入する。\n>\n> **Harness レイヤー**: フック — ループを侵襲しない拡張ポイント。\n\n---\n\n## 課題\n\ns03 の Agent には権限チェックがある。しかし新しいチェックを追加するたび、「bash 呼び出しを毎回ログに記録」「操作後に自動 git add」、`agent_loop` 関数を修正する必要がある。\n\nループはすぐにこうなる:\n\n```python\ndef agent_loop(messages):\n while True:\n # ... LLM call ...\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n log_to_file(block) # 一行追加\n check_permission(block) # 一行追加\n notify_slack(block) # さらに一行追加\n output = execute(block)\n auto_git_add(block) # さらに一行追加\n # ... もうループが見えない\n```\n\n拡張したいのは Agent の振る舞いなのに、変更しているのはループそのもの。ループは安定した核心であるべき。拡張は外側に掛ける。\n\n---\n\n## ソリューション\n\n\n\ns03 のループと権限ロジックは完全に保持される。唯一の変更点は `check_permission()` をループ本体内からフックに移動したこと。ループはもうチェック関数を直接呼び出さず、代わりに `trigger_hooks(\"PreToolUse\", block)` を呼び、登録済みのフックが何を実行するかを決める。\n\n4 つのイベントで、完全な agent cycle をカバー:\n\n| イベント | 発火タイミング | 典型的な用途 |\n|----------|--------------|-------------|\n| UserPromptSubmit | ユーザー入力後、LLM に入る前 | 入力バリデーション、コンテキスト注入 |\n| PreToolUse | ツール実行前 | 権限チェック、ログ記録 |\n| PostToolUse | ツール実行後 | 副作用(自動 git add など)、出力チェック |\n| Stop | ループが終了する直前 | 後処理、ループを続行するかの判断 |\n\n拡張は `register_hook()` で追加する。ループは `trigger_hooks()` を呼ぶだけ。\n\n---\n\n## 仕組み\n\n**フック登録簿**:イベント名をコールバックリストにマッピングする辞書。\n\n```python\nHOOKS = {\n \"UserPromptSubmit\": [],\n \"PreToolUse\": [],\n \"PostToolUse\": [],\n \"Stop\": [],\n}\n\ndef register_hook(event: str, callback):\n HOOKS[event].append(callback)\n\ndef trigger_hooks(event: str, *args):\n for callback in HOOKS[event]:\n result = callback(*args)\n if result is not None: # 戻り値 ≠ None → フックが「止め」と指示\n return result\n return None\n```\n\n`PreToolUse` が `None` 以外を返すと、現在のツール実行は中止される。`Stop` が `None` 以外を返すと、ループは続行する。`UserPromptSubmit` と `PostToolUse` の戻り値は制御フローに影響しない。\n\n**UserPromptSubmit** はユーザー入力後、LLM に入る前に発火する。以下の hook は現在の作業ディレクトリを記録する:\n\n```python\ndef context_inject_hook(query: str) -> str | None:\n \"\"\"Inject current working directory info into every prompt.\"\"\"\n print(f\"\\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\\033[0m\")\n return None # return None = 変更なし、プロンプトを通す\n\nregister_hook(\"UserPromptSubmit\", context_inject_hook)\n```\n\nメインループでは、ユーザー入力直後に発火:\n\n```python\nquery = input(\"s04 >> \")\ntrigger_hooks(\"UserPromptSubmit\", query) # ← LLM に入る前\nhistory.append({\"role\": \"user\", \"content\": query})\nagent_loop(history)\n```\n\n**PreToolUse / PostToolUse**、ツール実行の前後のフック。s03 の権限チェックロジックは PreToolUse フックに包まれ、さらにログフックと大出力リマインダーが追加される:\n\n```python\n# PreToolUse: 権限チェック(s03 のロジック、ループからフックに移動)\ndef permission_hook(block):\n if block.name == \"bash\":\n for pattern in DENY_LIST:\n if pattern in block.input.get(\"command\", \"\"):\n return \"Permission denied by deny list\"\n if block.name in (\"read_file\", \"write_file\", \"edit_file\"):\n path = block.input.get(\"path\", \"\")\n if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n return None\n\n# PreToolUse: ログ\ndef log_hook(block):\n print(f\"[HOOK] {block.name}(...)\")\n\n# PostToolUse: 大ファイルリマインダー\ndef large_output_hook(block, output):\n if len(str(output)) > 100000:\n print(f\"[HOOK] ⚠ Large output from {block.name}\")\n\nregister_hook(\"PreToolUse\", permission_hook)\nregister_hook(\"PreToolUse\", log_hook)\nregister_hook(\"PostToolUse\", large_output_hook)\n```\n\n**Stop** はループが終了する直前に発火する。以下の hook は終了時の統計を出力する:\n\n```python\ndef summary_hook(messages: list) -> str | None:\n \"\"\"Print a summary when the loop is about to stop.\"\"\"\n tool_count = sum(1 for m in messages\n for b in (m.get(\"content\") if isinstance(m.get(\"content\"), list) else [])\n if isinstance(b, dict) and b.get(\"type\") == \"tool_result\")\n print(f\"\\033[90m[HOOK] Stop: session used {tool_count} tool calls\\033[0m\")\n return None # return None = 終了を許可、return 文字列 = 強制続行\n\nregister_hook(\"Stop\", summary_hook)\n```\n\nagent_loop 内では、終了前に発火:\n\n```python\ntool_calls = [\n block for block in response.content if block.type == \"tool_use\"\n]\nif not tool_calls:\n force = trigger_hooks(\"Stop\", messages) # ← 終了する前に\n if force:\n # フックがメッセージを返した → 注入して続行\n messages.append({\"role\": \"user\", \"content\": force})\n continue\n return\n```\n\n**ループ内で変更されたのは一箇所だけ**:s03 は直接 `check_permission(block)` を呼び出していたが、s04 は `trigger_hooks(\"PreToolUse\", block)` に置き換えた:\n\n```python\nfor block in tool_calls:\n # s03: if not check_permission(block): ...\n # s04: フックがハードコードを代替\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": str(blocked)})\n continue\n\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n\n trigger_hooks(\"PostToolUse\", block, output)\n\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": output})\n```\n\n4 つのフックが agent cycle の重要ノードをカバー:入力→実行前→実行後→終了。ループは trigger_hooks() を呼ぶだけで、具体的なロジックは全てフックコールバックにある。\n\n---\n\n## s03 からの変更\n\n| コンポーネント | 変更前 (s03) | 変更後 (s04) |\n|--------------|-------------|-------------|\n| 拡張方式 | check_permission() をループ内にハードコード | HOOKS 登録簿 + trigger_hooks() |\n| 新規関数 | — | register_hook, trigger_hooks |\n| フックコールバック | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook |\n| ループ | check_permission() を直接呼び出し | trigger_hooks(\"PreToolUse\", ...) を呼び出し |\n| 終了制御 | なし | trigger_hooks(\"Stop\", ...) が終了を阻止可能 |\n| 入力横取り | なし | trigger_hooks(\"UserPromptSubmit\", ...) がコンテキスト注入可能 |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s04_hooks/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `Read the file README.md`(そのまま通過するはず、フックログを観察)\n2. `Create a file called test.txt`(作成後、PostToolUse が発火するか観察)\n3. `Delete all temporary files in /tmp`(bash + rm で権限フックが発動)\n\n観察のポイント:各ツール実行前に `[HOOK]` ログが表示されるか? 権限が拒否されたとき、フックが拦截したのか、ループ内のハードコードが拦截したのか?\n\n---\n\n## 次へ\n\nAgent は安全に操作を実行できるようになった。しかし「まず何をして、次に何をすべきか」を立ち止まって考えたことはあるか? 複雑なタスクを与えたとき、すぐに取り掛かるのか、まず計画を立てるのか?\n\n→ s05 TodoWrite:Agent に計画ツールを与える。まずリストを作り、それから実行。\n\n\n<!-- translation-sync: zh@v1, en@v1, ja@v1 -->\n"
|
||
},
|
||
{
|
||
"version": "s05",
|
||
"locale": "en",
|
||
"title": "s05: TodoWrite — An Agent Without a Plan Drifts Off Course",
|
||
"content": "# s05: TodoWrite — An Agent Without a Plan Drifts Off Course\n\ns01 → s02 → s03 → s04 → `s05` → [s06](/en/s06) → s07 → ... → s16 → s17\n\n> *\"An agent without a plan goes wherever the wind blows\"* — List the steps first, then execute. Complex tasks are less likely to miss steps.\n>\n> **Harness Layer**: Planning — Let the Agent think before it acts.\n\n---\n\n## The Problem\n\nGive the Agent a complex task: \"Rename all Python files to snake_case, run tests, and fix failures.\"\n\nThe Agent starts working, renames 3 files, runs a test, finds 2 failures, starts fixing. While fixing, it forgets the original goal was \"rename to snake_case\", the test failures have consumed all its attention.\n\nThe longer the conversation, the worse it gets: tool results keep filling the context, diluting the system prompt's influence. A 10-step refactoring: after steps 1-3, the Agent starts improvising because steps 4-10 have been pushed out of its attention.\n\n---\n\n## The Solution\n\n\n\nS05 keeps the tool dispatch, permissions, and hooks from S04, then adds `todo_write` and a reminder counter. `todo_write` only updates planning state; the existing tools still perform the work.\n\nThe new tool uses the same `TOOL_HANDLERS[block.name]` dispatch path. After three consecutive tool-use rounds without `todo_write`, the harness adds a reminder to that round's tool results.\n\n---\n\n## How It Works\n\n**TodoManager** owns the in-memory list, validates updates, and renders the state returned to the model. `run_todo_write` also prints that state in the terminal:\n\n```python\nclass TodoManager:\n def __init__(self):\n self.items = []\n\n def update(self, todos: list | str) -> str:\n # Parse and validate before replacing the current list.\n validated = []\n ...\n self.items = validated\n return self.render()\n\n def render(self) -> str:\n # [ ] pending, [>] in progress, [x] completed\n ...\n\n\nTODO = TodoManager()\n\ndef run_todo_write(todos: list | str) -> str:\n output = TODO.update(todos)\n print(output)\n return output\n```\n\nAn update may contain at most 20 items, each item needs non-empty `content`, and only one item may be `in_progress`. The string input path accepts JSON or a Python list representation without using `eval`.\n\nThe tool definition joins the other 5 in the dispatch map:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n # s05: new entry\n {\"name\": \"todo_write\", \"description\": \"Create and manage a task list ...\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"content\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"completed\"]},\n },\n },\n },\n },\n },\n },\n]\n\nTOOL_HANDLERS[\"todo_write\"] = run_todo_write\n```\n\n**Reminder**: after three tool-use rounds without `todo_write`, the reminder is appended to the third round's results and the counter resets:\n\n```python\nrounds_since_todo = 0 if used_todo else rounds_since_todo + 1\nif rounds_since_todo >= 3:\n results.append({\n \"type\": \"text\",\n \"text\": \"<reminder>Update your todos.</reminder>\",\n })\n rounds_since_todo = 0\n```\n\nTypical flow when the Agent receives a task: first call `todo_write` to list all steps (all `pending`) → pick one step, set it to `in_progress` → complete it, set to `completed` → look at the next `pending` → continue.\n\n**Key insight**: todo_write doesn't give the Agent any additional **execution capability**. What it adds is **planning capability**.\n\n---\n\n## Changes from s04\n\n| Component | Before (s04) | After (s05) |\n|-----------|-------------|-------------|\n| Tool count | 5 (bash, read, write, edit, glob) | 6 (+todo_write) |\n| Planning | None | Stateful TODO list + reminder |\n| SYSTEM prompt | Generic prompt | Added \"plan before executing\" guidance |\n| Loop | Tool dispatch and hooks | Same dispatch path, plus rounds_since_todo and reminder injection |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s05_todo_write/code.py\n```\n\nTry these prompts:\n\n1. `Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard` (should list 3 steps first, then execute)\n2. `Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.py`\n3. `Review Python files under s05_todo_write/example and fix any style issues`\n\nWhat to watch for: Was the first tool call `todo_write`? How many TODO steps were listed? Did statuses move from `pending` to `in_progress` / `completed` during execution?\n\n---\n\n## What's Next\n\nThe Agent can plan now. But if a task is too large, say \"refactor the entire auth module\", a TODO list alone isn't enough. That task is itself a collection of dozens of subtasks that would drown in a single conversation's context.\n\n→ s06 Subagent: Break large tasks into subtasks, each handled by an independent Agent with its own clean context, no cross-contamination.\n\n\n<!-- translation-sync: zh@v1, en@v1, ja@v1 -->\n"
|
||
},
|
||
{
|
||
"version": "s05",
|
||
"locale": "zh",
|
||
"title": "s05: TodoWrite — 没有计划的 Agent,做着做着就偏了",
|
||
"content": "# s05: TodoWrite — 没有计划的 Agent,做着做着就偏了\n\ns01 → s02 → s03 → s04 → `s05` → [s06](/zh/s06) → s07 → ... → s16 → s17\n\n> *\"没有计划的 agent 走哪算哪\"* — 先列步骤再动手,长任务更不容易漏项。\n>\n> **Harness 层**: 规划 — 让 Agent 在动手之前先想清楚。\n\n---\n\n## 问题\n\n给 Agent 一个复杂任务:\"把所有 Python 文件改成 snake_case 命名,然后跑测试,修好失败。\"\n\nAgent 开始干活,改了 3 个文件,跑了个测试,发现 2 个失败,开始修。修着修着,它忘了最初是\"改成 snake_case\",测试失败把注意力全吸走了。\n\n对话越长越严重:工具结果不断填满上下文,系统提示的影响力被稀释。一个 10 步重构,做完 1-3 步就开始即兴发挥,因为 4-10 步已经被挤出注意力了。\n\n---\n\n## 解决方案\n\n\n\nS05 保留 S04 的工具分发、权限检查和 Hooks,再加入 `todo_write` 与 reminder 计数器。`todo_write` 只更新计划状态,实际工作仍由原有工具完成。\n\n新工具仍通过 `TOOL_HANDLERS[block.name]` 分发。连续三个工具调用轮次没有使用 `todo_write` 时,Harness 会把 reminder 追加到第三轮的工具结果中。\n\n---\n\n## 工作原理\n\n**TodoManager** 持有内存中的任务列表,负责校验更新,并把渲染结果返回给模型。`run_todo_write` 同时把这份状态打印到终端:\n\n```python\nclass TodoManager:\n def __init__(self):\n self.items = []\n\n def update(self, todos: list | str) -> str:\n # Parse and validate before replacing the current list.\n validated = []\n ...\n self.items = validated\n return self.render()\n\n def render(self) -> str:\n # [ ] pending, [>] in progress, [x] completed\n ...\n\n\nTODO = TodoManager()\n\ndef run_todo_write(todos: list | str) -> str:\n output = TODO.update(todos)\n print(output)\n return output\n```\n\n一次更新最多包含 20 项;每项都必须有非空的 `content`;同一时间只能有一个 `in_progress`。字符串输入可以是 JSON,也可以是 Python 列表表示,解析过程不使用 `eval`。\n\n工具定义和其他 5 个工具一起加入 dispatch map:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n # s05: 新增一条\n {\"name\": \"todo_write\", \"description\": \"Create and manage a task list ...\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"content\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"completed\"]},\n },\n },\n },\n },\n },\n },\n]\n\nTOOL_HANDLERS[\"todo_write\"] = run_todo_write\n```\n\n**Reminder**:连续三个工具调用轮次没有使用 `todo_write` 时,reminder 会追加到第三轮的结果中,随后计数器清零:\n\n```python\nrounds_since_todo = 0 if used_todo else rounds_since_todo + 1\nif rounds_since_todo >= 3:\n results.append({\n \"type\": \"text\",\n \"text\": \"<reminder>Update your todos.</reminder>\",\n })\n rounds_since_todo = 0\n```\n\nAgent 收到任务后的典型流程:先调 `todo_write` 列出所有步骤(全 `pending`)→ 做一个步骤,改成 `in_progress` → 做完改成 `completed` → 看下一个 `pending` → 继续。\n\n**关键洞察**:todo_write 不给 Agent 增加任何**执行能力**。它增加的是**规划能力**。\n\n---\n\n## 相对 s04 的变更\n\n| 组件 | 之前 (s04) | 之后 (s05) |\n|------|-----------|-----------|\n| 工具数量 | 5 (bash, read, write, edit, glob) | 6 (+todo_write) |\n| 规划能力 | 无 | 带状态的 TODO 列表 + reminder |\n| SYSTEM 提示 | 通用提示 | 加入 \"先计划再执行\" 引导 |\n| 循环 | 工具分发与 Hooks | 保留分发路径,加入 rounds_since_todo 和 reminder 注入 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s05_todo_write/code.py\n```\n\n试试这些 prompt:\n\n1. `Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard`(先列 3 步再执行)\n2. `Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.py`\n3. `Review Python files under s05_todo_write/example and fix any style issues`\n\n观察重点:第一次工具调用是不是 `todo_write`?TODO 列了几步?执行过程中状态有没有从 `pending` 变成 `in_progress` / `completed`?\n\n---\n\n## 接下来\n\nAgent 能计划了。但如果一个任务太大,比如\"重构整个认证模块\",光靠 TODO 列表不够。这个任务本身就是几十个小任务的集合,放在同一个对话里会被上下文淹没。\n\ns06 Subagent → 把大任务拆成子任务,每个子任务派一个独立的 Agent。它们有自己的干净上下文,不会互相污染。\n\n\n<!-- translation-sync: zh@v1, en@v1, ja@v1 -->\n"
|
||
},
|
||
{
|
||
"version": "s05",
|
||
"locale": "ja",
|
||
"title": "s05: TodoWrite — 計画なき Agent は途中で道を外れる",
|
||
"content": "# s05: TodoWrite — 計画なき Agent は途中で道を外れる\n\ns01 → s02 → s03 → s04 → `s05` → [s06](/ja/s06) → s07 → ... → s16 → s17\n\n> *\"計画なき agent は風の向くままに\"* — まず手順を列挙してから実行。長いタスクで見落としが減る。\n>\n> **Harness レイヤー**: 計画 — Agent が行動する前に考えさせる。\n\n---\n\n## 課題\n\nAgent に複雑なタスクを与える:「全 Python ファイルを snake_case にリネームし、テストを実行し、失敗を修正して。」\n\nAgent は作業を開始する。3 つのファイルをリネーム、テストを実行、2 つの失敗を発見、修正を開始。修正しているうちに、本来の目的が「snake_case にリネーム」だったことを忘れる。テストの失敗に注意を全て持っていかれる。\n\n会話が長くなるほど悪化する:ツールの結果がコンテキストを埋め続け、システムプロンプトの影響力が希釈される。10 ステップのリファクタリング:ステップ 1-3 を終えた時点で Agent は即興で動き始める。ステップ 4-10 は既に注意の外に追い出されているから。\n\n---\n\n## ソリューション\n\n\n\nS05 は S04 のツールディスパッチ、権限チェック、Hooks を保持し、`todo_write` とリマインダーカウンターを追加する。`todo_write` は計画状態だけを更新し、実際の作業は既存のツールが行う。\n\n新しいツールも `TOOL_HANDLERS[block.name]` を経由する。3 回連続のツール使用ラウンドで `todo_write` が呼ばれなければ、Harness は 3 回目のツール結果にリマインダーを追加する。\n\n---\n\n## 仕組み\n\n**TodoManager** はメモリ上のタスクリストを保持し、更新を検証して、描画結果をモデルへ返す。`run_todo_write` は同じ状態を端末にも表示する:\n\n```python\nclass TodoManager:\n def __init__(self):\n self.items = []\n\n def update(self, todos: list | str) -> str:\n # Parse and validate before replacing the current list.\n validated = []\n ...\n self.items = validated\n return self.render()\n\n def render(self) -> str:\n # [ ] pending, [>] in progress, [x] completed\n ...\n\n\nTODO = TodoManager()\n\ndef run_todo_write(todos: list | str) -> str:\n output = TODO.update(todos)\n print(output)\n return output\n```\n\n1 回の更新は最大 20 項目で、各項目には空でない `content` が必要となり、`in_progress` にできる項目は同時に 1 つだけ。文字列入力は JSON または Python のリスト表現として、`eval` を使わずに解析する。\n\nツール定義は他の 5 つと一緒にディスパッチマップに追加される:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n # s05: 新規追加\n {\"name\": \"todo_write\", \"description\": \"Create and manage a task list ...\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"content\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"completed\"]},\n },\n },\n },\n },\n },\n },\n]\n\nTOOL_HANDLERS[\"todo_write\"] = run_todo_write\n```\n\n**リマインダー**:3 回連続のツール使用ラウンドで `todo_write` が呼ばれなければ、リマインダーを 3 回目の結果に追加し、カウンターをリセットする:\n\n```python\nrounds_since_todo = 0 if used_todo else rounds_since_todo + 1\nif rounds_since_todo >= 3:\n results.append({\n \"type\": \"text\",\n \"text\": \"<reminder>Update your todos.</reminder>\",\n })\n rounds_since_todo = 0\n```\n\nAgent がタスクを受け取った後の典型的な流れ:まず `todo_write` を呼び出して全手順を列挙(全て `pending`)→ 一つの手順に取り掛かり、`in_progress` に変更 → 完了したら `completed` に変更 → 次の `pending` を見る → 続行。\n\n**重要な洞察**:todo_write は Agent に**実行能力**を何も追加しない。追加するのは**計画能力**だ。\n\n---\n\n## s04 からの変更\n\n| コンポーネント | 変更前 (s04) | 変更後 (s05) |\n|--------------|-------------|-------------|\n| ツール数 | 5 (bash, read, write, edit, glob) | 6 (+todo_write) |\n| 計画能力 | なし | ステータス付き TODO リスト + リマインダー |\n| SYSTEM プロンプト | 汎用プロンプト | 「先に計画してから実行」のガイダンスを追加 |\n| ループ | ツールディスパッチと Hooks | 同じ分配経路に rounds_since_todo とリマインダー注入を追加 |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s05_todo_write/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard`(まず 3 手順を列挙してから実行するはず)\n2. `Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.py`\n3. `Review Python files under s05_todo_write/example and fix any style issues`\n\n観察のポイント:最初のツール呼び出しは `todo_write` か? TODO は何手順列挙されたか? 実行中にステータスが `pending` から `in_progress` / `completed` に変わったか?\n\n---\n\n## 次へ\n\nAgent は計画できるようになった。しかしタスクが大きすぎる場合、例えば「認証モジュール全体をリファクタリング」、TODO リストだけでは不十分。そのタスク自体が数十のサブタスクの集合体で、同じ会話のコンテキストに押し込めると溢れてしまう。\n\n→ s06 Subagent:大きなタスクをサブタスクに分割し、それぞれを独立した Agent に任せる。それぞれが独自のクリーンなコンテキストを持ち、相互汚染がない。\n\n\n<!-- translation-sync: zh@v1, en@v1, ja@v1 -->\n"
|
||
},
|
||
{
|
||
"version": "s06",
|
||
"locale": "en",
|
||
"title": "s06: Subagent — Give a Subtask Its Own Context",
|
||
"content": "# s06: Subagent — Give a Subtask Its Own Context\n\ns01 → s02 → s03 → s04 → s05 → `s06` → [s07](/en/s07) → s08 → ... → s16 → s17\n\n> A subagent starts with a fresh `messages[]`. Its final text returns to the parent; its intermediate conversation does not.\n>\n> **Harness Layer**: Delegation — Run a focused task in a separate conversation context.\n\n---\n\n## The Problem\n\nThe Agent is fixing a bug. It reads many files to trace the call chain, and every tool call and result stays in the parent's `messages[]`. Once the call chain is understood, most of those intermediate details are no longer needed, but they still occupy context.\n\n---\n\n## The Solution\n\n\n\nCalling `task` synchronously runs a nested agent loop with a fresh `messages[]`. When that loop finishes, its final text becomes the tool result in the parent conversation.\n\nThis is message isolation, not process or filesystem isolation. Parent and subagent run in the same Python process and share `WORKDIR`, so writes and commands still affect the same workspace. The subagent has the five base tools but no `task`, and its tool calls use the same permission and lifecycle hooks as the parent.\n\n---\n\n## How It Works\n\n**run_subagent** creates the fresh message list, runs the nested loop, and returns the final text:\n\n```python\nSUB_TOOLS = list(BASE_TOOLS) # no task tool\n\ndef run_subagent(prompt: str) -> str:\n messages = [{\"role\": \"user\", \"content\": prompt}]\n\n for _ in range(30):\n response = client.messages.create(\n model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=SUB_TOOLS, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n tool_calls = [\n block for block in response.content if block.type == \"tool_use\"\n ]\n if not tool_calls:\n return extract_text(response.content) or \"(no summary)\"\n\n results = []\n for block in tool_calls:\n output = execute_tool(block, SUB_HANDLERS)\n results.append({... \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n return \"Subagent stopped after 30 turns without a final answer.\"\n```\n\nThe main Agent calls it just like any other tool:\n\n```python\nTASK_TOOL = {\n \"name\": \"task\",\n \"description\": \"Run a subagent with fresh conversation context and return its final text.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\"prompt\": {\"type\": \"string\"}},\n \"required\": [\"prompt\"],\n },\n}\n\nTOOLS = [*BASE_TOOLS, TASK_TOOL]\nTOOL_HANDLERS = {**BASE_HANDLERS, \"task\": run_subagent}\n```\n\nThe boundary is:\n\n| Decision | Choice | Reason |\n|----------|--------|--------|\n| Conversation | Fresh `messages[]` | Parent history is not copied into the subagent |\n| Execution | Same process and `WORKDIR` | Filesystem changes remain visible to both loops |\n| Return value | Final text only | Child tool calls and results are not copied into parent messages |\n| Delegation depth | No `task` in `SUB_TOOLS` | This lesson permits one delegation level |\n| Tool policy | Shared Hooks | Parent and subagent use the same permission checks |\n\nThe parent dispatches `task` through the same handler map as its other tools. The subagent uses `SUB_SYSTEM`, `SUB_TOOLS`, and its own local `messages` list.\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s06_subagent/code.py\n```\n\nTry these prompts:\n\n1. `Use a subtask to find what testing framework this project uses` (sub-Agent reads files, main Agent receives only the conclusion)\n2. `Delegate: read all .py files in agents/ and summarize what each one does`\n3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent`\n\nWhat to watch for: Do `[Subagent started]` / `[Subagent done]` appear? Do subagent tool calls print as `[sub] ...`? Does the parent continue with only the final text returned by `task`?\n\n---\n\n## What's Next\n\nThe Agent can now break tasks apart. But different tasks require different knowledge: editing frontend components needs React conventions, writing SQL needs table schemas. Stuffing all this knowledge into the system prompt would blow up the context.\n\n→ s07 Skill Loading: Inject skills on demand instead of piling documents into the system prompt. Load only when needed, as natural as reading a file.\n\n\n<!-- translation-sync: zh@v2, en@v2, ja@v2 -->\n"
|
||
},
|
||
{
|
||
"version": "s06",
|
||
"locale": "zh",
|
||
"title": "s06: Subagent — 给子任务一段独立上下文",
|
||
"content": "# s06: Subagent — 给子任务一段独立上下文\n\ns01 → s02 → s03 → s04 → s05 → `s06` → [s07](/zh/s07) → s08 → ... → s16 → s17\n\n> Subagent 从全新的 `messages[]` 开始。最终文本返回父循环,中间对话不会进入父上下文。\n>\n> **Harness 层**: 委派 — 在另一段对话上下文中处理一个明确的子任务。\n\n---\n\n## 问题\n\nAgent 在修一个 bug。为了追踪调用链,它读取了许多文件;每次工具调用和结果都会留在父循环的 `messages[]` 中。调用链已经弄清以后,多数中间细节不再需要,却仍然占用上下文。\n\n---\n\n## 解决方案\n\n\n\n调用 `task` 时,会同步运行一个使用全新 `messages[]` 的嵌套 Agent Loop。循环结束后,它的最终文本会成为父对话中的工具结果。\n\n这里隔离的是消息,不是进程或文件系统。父 Agent 与子 Agent 共享 `WORKDIR`,写文件和命令仍会影响同一个工作区。子 Agent 拥有五个基础工具,但没有 `task`;它的工具调用与父 Agent 使用同一组权限和生命周期 Hooks。\n\n---\n\n## 工作原理\n\n**run_subagent** 创建新的消息列表,运行嵌套循环,并返回最终文本:\n\n```python\nSUB_TOOLS = list(BASE_TOOLS) # no task tool\n\ndef run_subagent(prompt: str) -> str:\n messages = [{\"role\": \"user\", \"content\": prompt}]\n\n for _ in range(30):\n response = client.messages.create(\n model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=SUB_TOOLS, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n tool_calls = [\n block for block in response.content if block.type == \"tool_use\"\n ]\n if not tool_calls:\n return extract_text(response.content) or \"(no summary)\"\n\n results = []\n for block in tool_calls:\n output = execute_tool(block, SUB_HANDLERS)\n results.append({... \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n return \"Subagent stopped after 30 turns without a final answer.\"\n```\n\n主 Agent 调用时,跟调其他工具一样:\n\n```python\nTASK_TOOL = {\n \"name\": \"task\",\n \"description\": \"Run a subagent with fresh conversation context and return its final text.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\"prompt\": {\"type\": \"string\"}},\n \"required\": [\"prompt\"],\n },\n}\n\nTOOLS = [*BASE_TOOLS, TASK_TOOL]\nTOOL_HANDLERS = {**BASE_HANDLERS, \"task\": run_subagent}\n```\n\n实际边界如下:\n\n| 决策 | 选择 | 原因 |\n|------|------|------|\n| 对话 | 全新的 `messages[]` | 不把父对话复制给子 Agent |\n| 执行 | 同一进程和 `WORKDIR` | 两个循环都能看到文件系统修改 |\n| 返回值 | 只返回最终文本 | 子 Agent 的工具调用和结果不进入父消息列表 |\n| 委派深度 | `SUB_TOOLS` 中没有 `task` | 本章只允许一层委派 |\n| 工具策略 | 共享 Hooks | 父子循环使用相同的权限检查 |\n\n父 Agent 与其他工具一样,通过 handler map 分发 `task`。子 Agent 使用 `SUB_SYSTEM`、`SUB_TOOLS` 和自己的局部 `messages` 列表。\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s06_subagent/code.py\n```\n\n试试这些 prompt:\n\n1. `Use a subtask to find what testing framework this project uses`(子 Agent 去读文件,主 Agent 只收结论)\n2. `Delegate: read all .py files in agents/ and summarize what each one does`\n3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent`\n\n观察重点:是否出现 `[Subagent started]` / `[Subagent done]`?子 Agent 的工具调用是否以 `[sub] ...` 输出?父 Agent 是否只接收到 `task` 返回的最终文本?\n\n---\n\n## 接下来\n\nAgent 现在能拆任务了。但每个任务需要的知识不一样:改前端组件需要知道 React 规范,写 SQL 需要知道表结构。这些知识全塞进 system prompt,上下文直接爆了。\n\ns07 Skill Loading → 技能按需注入,不在 system prompt 里堆文档。用到的时候才加载,和读文件一样自然。\n\n\n<!-- translation-sync: zh@v2, en@v2, ja@v2 -->\n"
|
||
},
|
||
{
|
||
"version": "s06",
|
||
"locale": "ja",
|
||
"title": "s06: Subagent — サブタスクに独立したコンテキストを与える",
|
||
"content": "# s06: Subagent — サブタスクに独立したコンテキストを与える\n\ns01 → s02 → s03 → s04 → s05 → `s06` → [s07](/ja/s07) → s08 → ... → s16 → s17\n\n> Subagent は新しい `messages[]` から始まる。最終テキストだけが親ループへ戻り、中間会話は親コンテキストへ入らない。\n>\n> **Harness レイヤー**: 委任 — 明確なサブタスクを別の会話コンテキストで処理する。\n\n---\n\n## 課題\n\nAgent がバグを修正している。呼び出しチェーンを追うために多くのファイルを読み、すべてのツール呼び出しと結果が親の `messages[]` に残る。チェーンを把握した後は不要になる中間情報も、コンテキストを使い続ける。\n\n---\n\n## ソリューション\n\n\n\n`task` を呼ぶと、新しい `messages[]` を使う入れ子の Agent Loop が同期実行される。ループが終了すると、最終テキストが親会話の tool result になる。\n\nここで分離するのはメッセージであり、プロセスやファイルシステムではない。親 Agent とサブエージェントは `WORKDIR` を共有するため、書き込みやコマンドは同じワークスペースへ作用する。サブエージェントは 5 つの基本ツールを持つが `task` はなく、親と同じ権限 Hooks とライフサイクル Hooks を使う。\n\n---\n\n## 仕組み\n\n**run_subagent** は新しいメッセージリストを作り、入れ子のループを実行して、最終テキストを返す:\n\n```python\nSUB_TOOLS = list(BASE_TOOLS) # no task tool\n\ndef run_subagent(prompt: str) -> str:\n messages = [{\"role\": \"user\", \"content\": prompt}]\n\n for _ in range(30):\n response = client.messages.create(\n model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=SUB_TOOLS, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n tool_calls = [\n block for block in response.content if block.type == \"tool_use\"\n ]\n if not tool_calls:\n return extract_text(response.content) or \"(no summary)\"\n\n results = []\n for block in tool_calls:\n output = execute_tool(block, SUB_HANDLERS)\n results.append({... \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n return \"Subagent stopped after 30 turns without a final answer.\"\n```\n\nメイン Agent の呼び出しは、他のツールと同じ:\n\n```python\nTASK_TOOL = {\n \"name\": \"task\",\n \"description\": \"Run a subagent with fresh conversation context and return its final text.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\"prompt\": {\"type\": \"string\"}},\n \"required\": [\"prompt\"],\n },\n}\n\nTOOLS = [*BASE_TOOLS, TASK_TOOL]\nTOOL_HANDLERS = {**BASE_HANDLERS, \"task\": run_subagent}\n```\n\n実際の境界は次のとおり:\n\n| 決定 | 選択 | 理由 |\n|------|------|------|\n| 会話 | 新しい `messages[]` | 親の会話をサブエージェントへコピーしない |\n| 実行 | 同じプロセスと `WORKDIR` | どちらのループからもファイル変更が見える |\n| 戻り値 | 最終テキストのみ | 子のツール呼び出しと結果を親 messages へコピーしない |\n| 委任の深さ | `SUB_TOOLS` に `task` なし | 本章では 1 階層の委任だけを許可 |\n| ツールポリシー | Hooks を共有 | 親子で同じ権限チェックを使う |\n\n親 Agent は他のツールと同じ handler map から `task` を実行する。サブエージェントは `SUB_SYSTEM`、`SUB_TOOLS`、ローカルな `messages` リストを使う。\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s06_subagent/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `Use a subtask to find what testing framework this project uses`(サブエージェントがファイルを読み、メイン Agent は結論のみ受け取る)\n2. `Delegate: read all .py files in agents/ and summarize what each one does`\n3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent`\n\n観察のポイント:`[Subagent started]` / `[Subagent done]` が表示されるか? サブエージェントのツール呼び出しが `[sub] ...` と表示されるか? 親 Agent は `task` が返した最終テキストだけを受け取るか?\n\n---\n\n## 次へ\n\nAgent はタスクを分割できるようになった。しかし各タスクに必要な知識は異なる。フロントエンドコンポーネントの変更には React 規約が必要で、SQL を書くにはテーブル構造を知る必要がある。これらの知識をすべて system prompt に詰め込むと、コンテキストが溢れてしまう。\n\n→ s07 Skill Loading:スキルをオンデマンドで注入する。system prompt にドキュメントを積み上げるのではなく、必要なときだけ読み込む。ファイルを読むのと同じくらい自然に。\n\n\n<!-- translation-sync: zh@v2, en@v2, ja@v2 -->\n"
|
||
},
|
||
{
|
||
"version": "s07",
|
||
"locale": "en",
|
||
"title": "s07: Skill Loading — Load Skills When Needed",
|
||
"content": "# s07: Skill Loading — Load Skills When Needed\n\ns01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](/en/s08) → s09 → ... → s16 → s17\n\n> The system prompt contains the skill catalog; `load_skill` returns the full `SKILL.md`.\n>\n> **Harness Layer**: Knowledge loading — show the model which skills exist, then load one by name.\n\n---\n\n## The Problem\n\nSuppose a project has a React component specification, a SQL style guide, and an API design document. We want the Agent to follow these rules during development, so the most direct approach is to put all of them into the system prompt:\n\n```python\nSYSTEM = (\n f\"You are a coding agent. \"\n + open(\"docs/react-style.md\").read()\n + open(\"docs/sql-style.md\").read()\n + open(\"docs/api-design.md\").read()\n)\n```\n\nThis approach lets the Agent read every specification, but it fixes all three documents in the system prompt instead of selecting only the one needed for the current task. Every LLM call sends the full text of all three documents to the model. When the task only changes React components, only the React specification is relevant; the SQL style guide and API design document still consume input tokens and context-window space that could hold code, conversation, and tool results.\n\n---\n\n## The Solution\n\n\n\nAt startup, `SkillLoader` scans `skills/*/SKILL.md`, reads `name` and `description` from YAML frontmatter, and adds that catalog to the system prompt. When the model needs the full instructions, it calls `load_skill(name)`; the returned `SKILL.md` is appended to the message list as a `tool_result`.\n\n| Content | Model input | Added |\n|---------|-------------|-------|\n| Skill name and description | system prompt | At startup |\n| Full `SKILL.md` | `tool_result` | When `load_skill` is called |\n\n---\n\n## How It Works\n\nEach skill is a directory containing `SKILL.md`:\n\n```text\nskills/\n agent-builder/SKILL.md\n code-review/SKILL.md\n mcp-builder/SKILL.md\n pdf/SKILL.md\n```\n\n### Scan Skills\n\n```python\nclass SkillLoader:\n def scan(self):\n self.skills.clear()\n skills_root = self.skills_dir.resolve()\n for manifest in sorted(self.skills_dir.glob(\"*/SKILL.md\")):\n if (not manifest.is_file()\n or not manifest.resolve().is_relative_to(skills_root)):\n continue\n content = manifest.read_text()\n metadata, body = self.parse_frontmatter(content)\n raw_name = metadata.get(\"name\")\n name = raw_name.strip() if isinstance(raw_name, str) else \"\"\n name = name or manifest.parent.name\n raw_description = metadata.get(\"description\")\n description = (raw_description.strip()\n if isinstance(raw_description, str) else \"\")\n description = description or body.split(\"\\n\", 1)[0]\n description = \" \".join(str(description).lstrip(\"# \").split())\n self.skills[name] = {\n \"name\": name,\n \"description\": description,\n \"content\": content,\n }\n```\n\n`catalog()` returns only names and descriptions:\n\n```text\n- code-review: Perform thorough code reviews...\n- pdf: Process PDF files...\n```\n\n### Build the System Prompt\n\n```python\ndef build_system_prompt() -> str:\n return (\n f\"You are a coding agent at {WORKDIR}. Use tools to solve tasks. \"\n \"Act, don't explain.\\n\\n\"\n f\"Skills available:\\n{SKILL_LOADER.catalog()}\\n\\n\"\n \"Use load_skill to read the full instructions when a skill applies.\"\n )\n```\n\nThis function combines the fixed Agent instructions with the catalog found at startup.\n\n### Load Full Content\n\n```python\ndef load(self, name: str) -> str:\n skill = self.skills.get(name)\n if skill:\n return skill[\"content\"]\n available = \", \".join(self.skills) or \"none\"\n return f\"Error: Unknown skill '{name}'. Available: {available}\"\n```\n\n`name` looks up the startup registry; it is not interpreted as a file path. After the tool returns, the existing Agent Loop appends its content as a new `tool_result` message.\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s07_skill_loading/code.py\n```\n\nTry these prompts:\n\n1. `What skills are available?`\n2. `Load the code-review skill and follow its instructions`\n3. `Review README.md and load the relevant skill first`\n\nCheck that the system prompt contains only the catalog and that the full `SKILL.md` appears after `load_skill` is called.\n\n---\n\n## What's Next\n\nAs tool calls accumulate, `messages[]` retains earlier file contents and tool results.\n\n→ s08 Context Compact: shorten earlier messages and keep context available for later calls.\n\n\n<!-- translation-sync: zh@v6, en@v6, ja@v6 -->\n"
|
||
},
|
||
{
|
||
"version": "s07",
|
||
"locale": "zh",
|
||
"title": "s07: Skill Loading — 用到时再加载",
|
||
"content": "# s07: Skill Loading — 用到时再加载\n\ns01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](/zh/s08) → s09 → ... → s16 → s17\n\n> system prompt 保存技能目录;`load_skill` 返回完整的 `SKILL.md`。\n>\n> **Harness 层**:知识加载 — 让模型先知道有哪些技能,再按名称读取内容。\n\n---\n\n## 问题\n\n假设某个项目有一套 React 组件规范、一份 SQL 风格指南和一份 API 设计文档。我们希望 Agent 在开发过程中遵守这些规范,最直接的做法就是把它们全部放进 system prompt:\n\n```python\nSYSTEM = (\n f\"You are a coding agent. \"\n + open(\"docs/react-style.md\").read()\n + open(\"docs/sql-style.md\").read()\n + open(\"docs/api-design.md\").read()\n)\n```\n\n这种做法能让 Agent 读到所有规范,但问题在于,三份文档被固定放进了 system prompt,无法根据当前任务只选择需要的那一份。每次调用 LLM 时,三份文档的全文都会一起发送给模型。当前任务只修改 React 组件时,实际需要的只有 React 组件规范;SQL 风格指南和 API 设计文档与任务无关,却仍然占用输入 token 和上下文窗口,留给代码、对话和工具结果的空间也会变少。\n\n---\n\n## 解决方案\n\n\n\n启动时,`SkillLoader` 扫描 `skills/*/SKILL.md`,读取 YAML frontmatter 中的 `name` 和 `description`,并把这份目录加入 system prompt。模型需要完整说明时,调用 `load_skill(name)`;返回的 `SKILL.md` 作为 `tool_result` 追加到消息列表。\n\n| 内容 | 进入模型的位置 | 何时加入 |\n|------|----------------|----------|\n| 技能名称和描述 | system prompt | 启动时 |\n| 完整 `SKILL.md` | `tool_result` | 调用 `load_skill` 时 |\n\n---\n\n## 工作原理\n\n每个技能是一个包含 `SKILL.md` 的目录:\n\n```text\nskills/\n agent-builder/SKILL.md\n code-review/SKILL.md\n mcp-builder/SKILL.md\n pdf/SKILL.md\n```\n\n### 扫描技能\n\n```python\nclass SkillLoader:\n def scan(self):\n self.skills.clear()\n skills_root = self.skills_dir.resolve()\n for manifest in sorted(self.skills_dir.glob(\"*/SKILL.md\")):\n if (not manifest.is_file()\n or not manifest.resolve().is_relative_to(skills_root)):\n continue\n content = manifest.read_text()\n metadata, body = self.parse_frontmatter(content)\n raw_name = metadata.get(\"name\")\n name = raw_name.strip() if isinstance(raw_name, str) else \"\"\n name = name or manifest.parent.name\n raw_description = metadata.get(\"description\")\n description = (raw_description.strip()\n if isinstance(raw_description, str) else \"\")\n description = description or body.split(\"\\n\", 1)[0]\n description = \" \".join(str(description).lstrip(\"# \").split())\n self.skills[name] = {\n \"name\": name,\n \"description\": description,\n \"content\": content,\n }\n```\n\n`catalog()` 只输出名称和描述:\n\n```text\n- code-review: Perform thorough code reviews...\n- pdf: Process PDF files...\n```\n\n### 组装 system prompt\n\n```python\ndef build_system_prompt() -> str:\n return (\n f\"You are a coding agent at {WORKDIR}. Use tools to solve tasks. \"\n \"Act, don't explain.\\n\\n\"\n f\"Skills available:\\n{SKILL_LOADER.catalog()}\\n\\n\"\n \"Use load_skill to read the full instructions when a skill applies.\"\n )\n```\n\n固定的 Agent 指令和扫描得到的技能目录在这里组成实际传给模型的 system prompt。\n\n### 加载完整内容\n\n```python\ndef load(self, name: str) -> str:\n skill = self.skills.get(name)\n if skill:\n return skill[\"content\"]\n available = \", \".join(self.skills) or \"none\"\n return f\"Error: Unknown skill '{name}'. Available: {available}\"\n```\n\n`name` 用于查询启动时建立的注册表,不会被当作文件路径。工具返回后,原有 Agent Loop 会把内容作为新的 `tool_result` 消息追加。\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s07_skill_loading/code.py\n```\n\n试试这些 prompt:\n\n1. `What skills are available?`\n2. `Load the code-review skill and follow its instructions`\n3. `Review README.md and load the relevant skill first`\n\n观察 system prompt 中是否只有技能目录,以及调用 `load_skill` 后是否出现完整的 `SKILL.md` 内容。\n\n---\n\n## 接下来\n\n随着工具调用增加,`messages[]` 会积累较早的文件内容和工具结果。\n\ns08 Context Compact → 缩短较早的消息,为后续调用保留上下文空间。\n\n\n<!-- translation-sync: zh@v6, en@v6, ja@v6 -->\n"
|
||
},
|
||
{
|
||
"version": "s07",
|
||
"locale": "ja",
|
||
"title": "s07: Skill Loading — 必要なときにスキルを読み込む",
|
||
"content": "# s07: Skill Loading — 必要なときにスキルを読み込む\n\ns01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](/ja/s08) → s09 → ... → s16 → s17\n\n> system prompt にはスキルカタログを入れ、`load_skill` は完全な `SKILL.md` を返す。\n>\n> **Harness レイヤー**:知識の読み込み — 利用可能なスキルをモデルに示し、名前で内容を読み込む。\n\n---\n\n## 課題\n\nあるプロジェクトに React コンポーネント仕様、SQL スタイルガイド、API 設計ドキュメントがあるとする。開発中に Agent へこれらの規約を守らせたい場合、最も直接的な方法は、すべてを system prompt に入れることだ:\n\n```python\nSYSTEM = (\n f\"You are a coding agent. \"\n + open(\"docs/react-style.md\").read()\n + open(\"docs/sql-style.md\").read()\n + open(\"docs/api-design.md\").read()\n)\n```\n\nこの方法で Agent はすべての規約を読めるが、3 つの文書すべてが system prompt に固定され、現在のタスクに必要な文書だけを選べない。LLM を呼び出すたびに、3 つの文書の全文がモデルへ送られる。タスクが React コンポーネントの変更だけなら、必要なのは React コンポーネント仕様だけである。無関係な SQL スタイルガイドと API 設計ドキュメントも入力 token とコンテキストウィンドウを使うため、コード、会話、tool result に使える領域が減る。\n\n---\n\n## ソリューション\n\n\n\n起動時に `SkillLoader` が `skills/*/SKILL.md` を走査し、YAML frontmatter の `name` と `description` を読み取って、カタログを system prompt に追加する。完全な指示が必要になると、モデルは `load_skill(name)` を呼ぶ。返された `SKILL.md` は `tool_result` としてメッセージリストへ追加される。\n\n| 内容 | モデル入力での位置 | 追加時点 |\n|------|--------------------|----------|\n| スキル名と説明 | system prompt | 起動時 |\n| 完全な `SKILL.md` | `tool_result` | `load_skill` 呼び出し時 |\n\n---\n\n## 仕組み\n\n各スキルは `SKILL.md` を持つディレクトリである:\n\n```text\nskills/\n agent-builder/SKILL.md\n code-review/SKILL.md\n mcp-builder/SKILL.md\n pdf/SKILL.md\n```\n\n### スキルを走査する\n\n```python\nclass SkillLoader:\n def scan(self):\n self.skills.clear()\n skills_root = self.skills_dir.resolve()\n for manifest in sorted(self.skills_dir.glob(\"*/SKILL.md\")):\n if (not manifest.is_file()\n or not manifest.resolve().is_relative_to(skills_root)):\n continue\n content = manifest.read_text()\n metadata, body = self.parse_frontmatter(content)\n raw_name = metadata.get(\"name\")\n name = raw_name.strip() if isinstance(raw_name, str) else \"\"\n name = name or manifest.parent.name\n raw_description = metadata.get(\"description\")\n description = (raw_description.strip()\n if isinstance(raw_description, str) else \"\")\n description = description or body.split(\"\\n\", 1)[0]\n description = \" \".join(str(description).lstrip(\"# \").split())\n self.skills[name] = {\n \"name\": name,\n \"description\": description,\n \"content\": content,\n }\n```\n\n`catalog()` は名前と説明だけを返す:\n\n```text\n- code-review: Perform thorough code reviews...\n- pdf: Process PDF files...\n```\n\n### system prompt を組み立てる\n\n```python\ndef build_system_prompt() -> str:\n return (\n f\"You are a coding agent at {WORKDIR}. Use tools to solve tasks. \"\n \"Act, don't explain.\\n\\n\"\n f\"Skills available:\\n{SKILL_LOADER.catalog()}\\n\\n\"\n \"Use load_skill to read the full instructions when a skill applies.\"\n )\n```\n\n固定された Agent の指示と、起動時に見つかったスキルカタログをこの関数で組み合わせる。\n\n### 完全な内容を読み込む\n\n```python\ndef load(self, name: str) -> str:\n skill = self.skills.get(name)\n if skill:\n return skill[\"content\"]\n available = \", \".join(self.skills) or \"none\"\n return f\"Error: Unknown skill '{name}'. Available: {available}\"\n```\n\n`name` は起動時に作られたレジストリの検索に使われ、ファイルパスとして解釈されない。ツールが返ると、既存の Agent Loop が内容を新しい `tool_result` メッセージとして追加する。\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s07_skill_loading/code.py\n```\n\n以下の prompt を試す:\n\n1. `What skills are available?`\n2. `Load the code-review skill and follow its instructions`\n3. `Review README.md and load the relevant skill first`\n\nsystem prompt にカタログだけが入り、`load_skill` の呼び出し後に完全な `SKILL.md` が現れることを確認する。\n\n---\n\n## 次へ\n\nツール呼び出しが増えると、`messages[]` には以前のファイル内容やツール結果が残る。\n\ns08 Context Compact → 過去のメッセージを短くし、後続の呼び出しで使えるコンテキストを確保する。\n\n\n<!-- translation-sync: zh@v6, en@v6, ja@v6 -->\n"
|
||
},
|
||
{
|
||
"version": "s08",
|
||
"locale": "en",
|
||
"title": "s08: Context Compact: Make Room Before the Context Fills Up",
|
||
"content": "# s08: Context Compact: Make Room Before the Context Fills Up\n\ns01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](/en/s09) → s10 → ... → s16 → s17\n\n> *\"Context will fill up, so the Harness needs a way to make room.\"* Four steps run from lower cost to higher cost.\n>\n> **Harness layer**: Compaction keeps a limited context useful throughout a long task.\n\n\nAs the Agent works, every file read, command result, and model response remains in `messages`. The history eventually exceeds the model's context window.\n\nThis lesson adds a four-step compaction pipeline. It first reduces recoverable tool output and summarizes history only when those reductions are not enough.\n\n\n\n\n## Understanding Context\n\nThink of the context window as the model's current scratchpad. User messages, model responses, `tool_use`, and `tool_result` blocks are written onto it in order. The model reads that material again whenever it continues the task.\n\nThe scratchpad has a fixed size. When a request exceeds it, the API rejects the call with `prompt_too_long`. Tool results usually consume most of the space in coding tasks:\n\n- Reading a long file puts its contents into the context.\n- Test and build logs can add tens of kilobytes at once.\n- Searching many files keeps appending more results.\n\nAs a task continues, `messages` keeps growing. Compaction controls that growth while preserving the current goal, user constraints, and active work.\n\n\n## Why Tool Results Come First\n\nSummarizing the whole history can shrink it quickly, but every summary loses some detail and requires another model call.\n\nTool results are better first targets:\n\n1. A large file result can be stored on disk and read again later.\n2. An old command can be run again.\n3. The latest results are usually more relevant to the current step.\n4. Text trimming and structural edits do not call the model.\n\nThe pipeline therefore follows increasing information loss and cost: persist, trim, replace old results, and summarize last.\n\n\n\n\n## Step 1: tool_result_budget\n\nA model response may request several tools at once. Their completed `tool_result` blocks are written into the final user message together. When their combined content exceeds `200_000` characters, `tool_result_budget` processes the largest results first.\n\nEach result above `LARGE_RESULT_CHAR_LIMIT = 30000` is written in full to:\n\n```text\n.task_outputs/tool-results/<tool_use_id>.txt\n```\n\nThe context keeps the file path and a 2,000-character preview:\n\n\n\nThe core loop persists results in descending size order:\n\n```python\nblocks = [block for block in content\n if isinstance(block, dict)\n and block.get(\"type\") == \"tool_result\"]\ntotal = sum(len(str(block.get(\"content\", \"\"))) for block in blocks)\n\nranked = sorted(\n blocks,\n key=lambda block: len(str(block.get(\"content\", \"\"))),\n reverse=True,\n)\nfor block in ranked:\n if total <= max_chars:\n break\n content = str(block.get(\"content\", \"\"))\n if len(content) <= self.LARGE_RESULT_CHAR_LIMIT:\n continue\n block[\"content\"] = self.persist_large_output(\n block.get(\"tool_use_id\", \"unknown\"), content)\n total = sum(len(str(item.get(\"content\", \"\"))) for item in blocks)\n```\n\nThis step examines only the latest batch of tool results. The complete output remains available at the saved path, so persistence is the safest operation to run first.\n\n\n## Step 2: snip_compact\n\nOnce the history exceeds 50 messages, `snip_compact` writes the complete history to `.transcripts/`, then keeps the first 3 and latest 47 messages. The marker records how many messages were removed and where to find the complete transcript.\n\n```python\nhead_end = 3\ntail_start = len(messages) - (max_messages - head_end)\n\nif self.has_tool_use(messages[head_end - 1]):\n while (head_end < tail_start\n and self.is_tool_result(messages[head_end])):\n head_end += 1\n\nif (tail_start > 0\n and self.is_tool_result(messages[tail_start])\n and self.has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n\ntranscript = self.write_transcript(messages)\nmarker = {\"role\": \"user\", \"content\":\n f\"[{tail_start - head_end} messages archived at {transcript}]\"}\nmessages = [*messages[:head_end], marker, *messages[tail_start:]]\n```\n\nThe cut points protect every `assistant(tool_use)` and `user(tool_result)` pair. An orphaned result has no matching tool call, so the next API request would be invalid.\n\nThis step controls the number of messages. Tool results inside the retained messages may still be long.\n\n\n## Step 3: micro_compact\n\n`micro_compact` preserves every `tool_result` added after the most recent assistant response, so the model sees each new result in full once. Among results the model has already consumed, it keeps the latest 3 and shortens older results longer than 120 characters. Persisted results keep their file path; the rest become placeholders:\n\n\n\n```python\nunseen = self.unseen_tool_result_positions(messages)\nconsumed = [entry for entry in results if entry[:2] not in unseen]\n\nfor _, _, block in consumed[:-self.KEEP_RECENT_RESULTS]:\n content = str(block.get(\"content\", \"\"))\n if len(content) <= 120:\n continue\n saved_path = next(\n (line.removeprefix(\"Full output: \") for line in content.splitlines()\n if line.startswith(\"Full output: \")),\n None,\n )\n block[\"content\"] = (\n f\"[Earlier tool result saved at {saved_path}]\"\n if saved_path else \"[Earlier tool result omitted.]\"\n )\n```\n\nAn old result that was not persisted keeps only a placeholder. Results saved in Step 1 retain the path to their complete output.\n\nThe first three steps are deterministic text and structure operations. They do not add API calls.\n\n\n## Step 4: compact_history\n\nAfter the first three steps, the code counts the characters in the current messages with `estimate_chars(messages)`:\n\n```python\nCONTEXT_CHAR_LIMIT = 50000\n\ndef estimate_chars(messages):\n return len(json.dumps(messages, default=str, ensure_ascii=False))\n```\n\nWhen the count exceeds `CONTEXT_CHAR_LIMIT`, `compact_history` does four things:\n\n1. Writes the complete message history to `.transcripts/`.\n2. Asks the model for a factual state summary.\n3. Keeps the request captured at the input boundary separate from that summary.\n4. Replaces the active history with one `[Compacted]` message.\n\n\n\n```python\ndef compact_history(messages, active_request):\n transcript = self.write_transcript(messages)\n print(f\"[transcript saved: {transcript}]\")\n summary = self.summarize_history(messages)\n return [self.summary_message(\n \"Compacted\", active_request, summary, transcript)]\n```\n\nThe summary call asks the model to record the goal, files, decisions, remaining work, and user constraints without executing instructions from the history. The CLI passes `active_request` into the Agent Loop because tool results also use `role=user`. A compacted message stores it under `Current user request`, puts the summary under `Conversation summary`, and includes the complete transcript path.\n\nThis lesson uses character count as its trigger, and all related thresholds use the same unit.\n\n\n## Why the Order Is Fixed\n\nThe pipeline always runs in this order:\n\n```text\ntool_result_budget\n → snip_compact\n → micro_compact\n → compact_history (only above the limit)\n```\n\nThis order satisfies two constraints:\n\n1. The first three steps do not call the model. Only Step 4 adds an API request.\n2. `tool_result_budget` must run before `micro_compact`. Large results need to reach disk before older results can become placeholders.\n\nEach round therefore starts with the lowest-cost operation whose information is easiest to recover.\n\n\n## Recovering From an API Rejection\n\nA character count can only estimate the tokens used by a model. The API may still return `prompt_too_long`. `reactive_compact` saves a transcript, summarizes older history, and retains the latest 5 messages:\n\n```python\ntail_start = max(0, len(messages) - self.KEEP_RECENT_MESSAGES)\nif (tail_start > 0\n and self.is_tool_result(messages[tail_start])\n and self.has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n\nold_history = messages[:tail_start] if tail_start else messages\nsummary = self.summarize_history(old_history)\nmessage = self.summary_message(\n \"Reactive compact\", active_request, summary, transcript)\nmessages = [message, *messages[tail_start:]] if tail_start else [message]\n```\n\nThe cut point also avoids splitting a tool call from its result, while `active_request` carries the current user request explicitly. `MAX_REACTIVE_RETRIES = 1` permits one recovery attempt. A second context-length error is raised to the caller.\n\n\n## Putting It Into the Agent Loop\n\n```python\ndef agent_loop(messages, active_request):\n while True:\n messages[:] = COMPACTOR.prepare(messages, active_request)\n\n try:\n response = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000)\n reactive_retries = 0\n except Exception as error:\n message = str(error).lower()\n too_long = (\"prompt_too_long\" in message\n or \"too many tokens\" in message)\n if too_long and reactive_retries < MAX_REACTIVE_RETRIES:\n messages[:] = COMPACTOR.reactive_compact(\n messages, active_request)\n reactive_retries += 1\n continue\n raise\n```\n\nEvery model call enters through the same pipeline. After appending `query`, the CLI calls `agent_loop(history, query)`, so repeated compaction cannot lose the current request. The code asks for a summary only when the first three steps leave the context above the limit or when the API rejects it.\n\n\n## The compact Tool\n\nAn automatic threshold knows only how large the context is. The model can also call `compact` after completing a stage when the next stage needs only a summary:\n\n```python\n{\"name\": \"compact\",\n \"description\": \"Summarize earlier conversation to free context space.\"}\n```\n\nA response may request several tools at once, such as writing a file and then compacting. The Harness first executes the complete batch and appends one `tool_result` for every `tool_use`. It summarizes only after that turn is complete:\n\n```python\ntool_calls = [\n block for block in response.content if block.type == \"tool_use\"\n]\nresults = []\ncompact_requested = False\n\nfor block in tool_calls:\n if block.name == \"compact\":\n output = \"Compaction requested after this tool batch.\"\n compact_requested = True\n else:\n output = execute_tool(block)\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": output})\n\nmessages.append({\"role\": \"user\", \"content\": results})\n\nif compact_requested:\n messages[:] = COMPACTOR.compact_history(messages, active_request)\n```\n\nThis leaves no orphaned tool result. It also preserves the record of a file write or another side effect before compaction, so the model does not repeat it.\n\n\n## What This Lesson Adds\n\n| Component | Shared execution loop | Added in s08 |\n| --- | --- | --- |\n| Agent Loop | Calls the model, runs tools, appends results | Runs `COMPACTOR.prepare()` before each model call |\n| Hooks | Permission checks, tool logging, result handling | Keeps the same tool execution entry point |\n| Context | Appends to `messages` | Persists large results, archives old history, summarizes, and retries once after a length error |\n| Tools | 5 base tools | Adds `compact`, for 6 total |\n\n> **Boundary with s09:** s08 manages the limited context of the current session and may discard recoverable details. s09 stores information that must survive compaction and future sessions.\n\n\n## Try It\n\n```bash\ncd learn-claude-code\npython s08_context_compact/code.py\n```\n\n### Experiment 1: Replace Earlier Results\n\n```text\nRead the README.md files from s01_agent_loop through s05_todo_write.\nCompare their top-level headings and summarize the naming pattern.\n```\n\nThis task produces at least 5 file results. Every result remains complete until the model sees it once. On later turns, the latest 3 consumed results remain complete while older long results become `[Earlier tool result omitted.]`. A persisted result retains its saved path.\n\n### Experiment 2: Persist a Large Result\n\n```text\nAnalyze the structure of web/src/data/generated/docs.json\nand explain the main fields in one lesson record.\n```\n\nWhen the file exceeds the per-turn budget, the task can still finish and the complete result appears under `.task_outputs/tool-results/`.\n\n### Experiment 3: Trigger an Automatic Summary\n\n```text\nCompare s08_context_compact/code.py with s09_memory/code.py.\nExplain how they manage current context and persistent memory.\n```\n\nWhen the file results push `estimate_chars(messages)` above 50000, the terminal prints `[auto compact]` and a transcript path. The next call continues from the `[Compacted]` summary.\n\nInspect `.transcripts/` and `.task_outputs/tool-results/` to see history archives and persisted large outputs.\n\n\n## What's Next\n\nContext compaction lets an Agent continue a long task within a limited window. Information that must survive compaction and future sessions needs a separate persistent memory system.\n\ns09 Memory adds memory writing, retrieval, and consolidation.\n\n<!-- translation-sync: zh@v8, en@v8, ja@v8 -->\n"
|
||
},
|
||
{
|
||
"version": "s08",
|
||
"locale": "zh",
|
||
"title": "s08: Context Compact:上下文总会满,先整理,再总结",
|
||
"content": "# s08: Context Compact:上下文总会满,先整理,再总结\n\ns01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](/zh/s09) → s10 → ... → s16 → s17\n\n> *\"上下文总会满,要有办法腾地方。\"* 四步压缩,低成本的操作优先执行。\n>\n> **Harness 层**:压缩让有限的上下文持续服务于长任务。\n\n\nAgent 持续工作时,读过的文件、执行过的命令和模型回复都会留在 `messages` 中。消息越积越多,最终会超过模型能够接收的上下文长度。\n\n本节将实现一条四步压缩管线。它先整理可以恢复的工具结果,空间仍然不足时再总结历史。\n\n\n\n\n## 先理解上下文\n\n可以把上下文窗口看作模型当前使用的一张草稿纸。用户消息、模型回复、`tool_use` 和 `tool_result` 都会按顺序写在这张纸上。模型每次继续工作时,都要重新读取这些内容。\n\n草稿纸的大小固定。内容超过上限后,API 会拒绝请求并返回 `prompt_too_long`。在代码任务里,工具结果通常占据最多空间:\n\n- 读取一个长文件会把文件内容放进上下文;\n- 测试和构建日志可能一次产生几十 KB 文本;\n- 搜索多个文件会持续追加结果。\n\n任务持续得越久,`messages` 就越大。压缩的目标是控制其中的信息量,同时尽可能保留当前目标、用户约束和正在进行的工作。\n\n\n## 为什么先整理工具结果\n\n直接让模型总结整段历史可以明显缩短上下文,但摘要一定会遗漏部分细节,而且还会多产生一次模型调用。\n\n工具结果具有更适合优先处理的特点:\n\n1. 大文件可以保存到磁盘,需要时重新读取。\n2. 旧命令可以重新执行。\n3. 最新几条结果通常比早期结果更接近当前工作。\n4. 文本裁剪和结构调整不需要调用模型。\n\n因此压缩顺序按照信息损失和调用成本排列:先转存,再裁剪,再替换旧结果,最后才生成摘要。\n\n\n\n\n## 第一步:tool_result_budget\n\n一次模型回复可能同时调用多个工具。执行完成后,这些 `tool_result` 会一起写进最后一条 user 消息。它们的总大小超过 `200_000` 字符时,`tool_result_budget` 从最大的结果开始处理。\n\n超过 `LARGE_RESULT_CHAR_LIMIT = 30000` 的结果会完整写入:\n\n```text\n.task_outputs/tool-results/<tool_use_id>.txt\n```\n\n上下文中保留文件路径和前 2000 个字符的预览:\n\n\n\n核心循环按照结果大小依次转存:\n\n```python\nblocks = [block for block in content\n if isinstance(block, dict)\n and block.get(\"type\") == \"tool_result\"]\ntotal = sum(len(str(block.get(\"content\", \"\"))) for block in blocks)\n\nranked = sorted(\n blocks,\n key=lambda block: len(str(block.get(\"content\", \"\"))),\n reverse=True,\n)\nfor block in ranked:\n if total <= max_chars:\n break\n content = str(block.get(\"content\", \"\"))\n if len(content) <= self.LARGE_RESULT_CHAR_LIMIT:\n continue\n block[\"content\"] = self.persist_large_output(\n block.get(\"tool_use_id\", \"unknown\"), content)\n total = sum(len(str(item.get(\"content\", \"\"))) for item in blocks)\n```\n\n这一步只处理最新一批工具结果。完整内容仍然可以从路径中取回,因此适合最先执行。\n\n\n## 第二步:snip_compact\n\n消息数量超过 50 条后,`snip_compact` 先把完整历史写入 `.transcripts/`,再保留最初 3 条和最近 47 条。中间的标记会写明删去了多少条消息,以及完整记录保存在哪里。\n\n```python\nhead_end = 3\ntail_start = len(messages) - (max_messages - head_end)\n\nif self.has_tool_use(messages[head_end - 1]):\n while (head_end < tail_start\n and self.is_tool_result(messages[head_end])):\n head_end += 1\n\nif (tail_start > 0\n and self.is_tool_result(messages[tail_start])\n and self.has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n\ntranscript = self.write_transcript(messages)\nmarker = {\"role\": \"user\", \"content\":\n f\"[{tail_start - head_end} messages archived at {transcript}]\"}\nmessages = [*messages[:head_end], marker, *messages[tail_start:]]\n```\n\n切点需要保护 `assistant(tool_use)` 和 `user(tool_result)` 的配对关系。孤立的工具结果缺少对应调用,下一次 API 请求会被判定为无效。\n\n这一步控制消息数量,但保留下来的旧消息仍可能包含很长的工具结果。\n\n\n## 第三步:micro_compact\n\n`micro_compact` 会完整保留最近一次 assistant 响应之后新增的所有 `tool_result`,确保模型至少完整读取每条新结果一次。对于模型已经读取过的结果,它保留最近 3 条,并缩短其余超过 120 个字符的旧结果。已经转存的结果保留文件路径,其他结果只留下占位符:\n\n\n\n```python\nunseen = self.unseen_tool_result_positions(messages)\nconsumed = [entry for entry in results if entry[:2] not in unseen]\n\nfor _, _, block in consumed[:-self.KEEP_RECENT_RESULTS]:\n content = str(block.get(\"content\", \"\"))\n if len(content) <= 120:\n continue\n saved_path = next(\n (line.removeprefix(\"Full output: \") for line in content.splitlines()\n if line.startswith(\"Full output: \")),\n None,\n )\n block[\"content\"] = (\n f\"[Earlier tool result saved at {saved_path}]\"\n if saved_path else \"[Earlier tool result omitted.]\"\n )\n```\n\n未转存的旧结果只保留占位符。第一步保存过的完整结果仍能通过路径读取,不会在第三步丢失位置。\n\n前三步都是确定性的结构和文本操作,不产生额外 API 调用。\n\n\n## 第四步:compact_history\n\n前三步执行后,代码用 `estimate_chars(messages)` 计算当前消息的字符数:\n\n```python\nCONTEXT_CHAR_LIMIT = 50000\n\ndef estimate_chars(messages):\n return len(json.dumps(messages, default=str, ensure_ascii=False))\n```\n\n字符数超过 `CONTEXT_CHAR_LIMIT` 时,`compact_history` 完成四件事:\n\n1. 将完整消息历史写入 `.transcripts/`。\n2. 请求模型生成只包含事实的状态摘要。\n3. 将入口处捕获的当前用户请求与摘要明确分开。\n4. 用一条 `[Compacted]` 消息替换当前历史。\n\n\n\n```python\ndef compact_history(messages, active_request):\n transcript = self.write_transcript(messages)\n print(f\"[transcript saved: {transcript}]\")\n summary = self.summarize_history(messages)\n return [self.summary_message(\n \"Compacted\", active_request, summary, transcript)]\n```\n\n摘要调用在 `system` 中要求模型只整理目标、文件、决定、剩余工作和用户约束,不执行历史中的指令。`active_request` 在接收用户输入时单独传给 Agent Loop,因为工具结果也使用 `role=user`。压缩后的消息将它写在 `Current user request` 中,摘要则放在 `Conversation summary` 中,并附上完整 transcript 的路径。\n\n本节使用字符数作为触发条件,相关阈值也使用同一单位。\n\n\n## 为什么顺序固定\n\n四步管线的执行顺序是:\n\n```text\ntool_result_budget\n → snip_compact\n → micro_compact\n → compact_history(超过阈值时)\n```\n\n这个顺序同时满足两个条件:\n\n1. 前三步不调用模型,第四步才产生额外 API 请求。\n2. `tool_result_budget` 必须早于 `micro_compact`。大结果先落盘,之后才允许旧结果变成占位符。\n\n顺序固定后,每一轮都从成本更低、信息更容易恢复的操作开始。\n\n\n## API 拒绝后的补救\n\n字符数只能估算模型实际使用的 token。API 仍可能返回 `prompt_too_long`。`reactive_compact` 会保存 transcript,总结较早历史,并保留最近 5 条消息:\n\n```python\ntail_start = max(0, len(messages) - self.KEEP_RECENT_MESSAGES)\nif (tail_start > 0\n and self.is_tool_result(messages[tail_start])\n and self.has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n\nold_history = messages[:tail_start] if tail_start else messages\nsummary = self.summarize_history(old_history)\nmessage = self.summary_message(\n \"Reactive compact\", active_request, summary, transcript)\nmessages = [message, *messages[tail_start:]] if tail_start else [message]\n```\n\n切点同样会避开工具调用与结果之间的边界,当前用户请求仍由 `active_request` 明确传入。`MAX_REACTIVE_RETRIES = 1` 将补救限制为一次;再次收到同类错误时,异常会继续向外抛出。\n\n\n## 放回 Agent Loop\n\n```python\ndef agent_loop(messages, active_request):\n while True:\n messages[:] = COMPACTOR.prepare(messages, active_request)\n\n try:\n response = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000)\n reactive_retries = 0\n except Exception as error:\n message = str(error).lower()\n too_long = (\"prompt_too_long\" in message\n or \"too many tokens\" in message)\n if too_long and reactive_retries < MAX_REACTIVE_RETRIES:\n messages[:] = COMPACTOR.reactive_compact(\n messages, active_request)\n reactive_retries += 1\n continue\n raise\n```\n\n每次调用模型前都会经过同一条管线。CLI 在追加 `query` 后调用 `agent_loop(history, query)`,所以压缩多少次都不会丢失本轮请求。前三步处理后仍超过阈值,或者 API 明确拒绝上下文时,代码才会请求模型生成摘要。\n\n\n## compact 工具\n\n自动阈值只知道上下文有多大。模型还可以在一个阶段结束后主动调用 `compact`,表示后续工作只需要保留当前阶段的摘要:\n\n```python\n{\"name\": \"compact\",\n \"description\": \"Summarize earlier conversation to free context space.\"}\n```\n\n一次响应可以同时包含多个工具调用,例如先写文件再请求压缩。Harness 必须先执行完整批次,并为每个 `tool_use` 追加对应的 `tool_result`,然后再摘要这个已经闭合的回合:\n\n```python\ntool_calls = [\n block for block in response.content if block.type == \"tool_use\"\n]\nresults = []\ncompact_requested = False\n\nfor block in tool_calls:\n if block.name == \"compact\":\n output = \"Compaction requested after this tool batch.\"\n compact_requested = True\n else:\n output = execute_tool(block)\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": output})\n\nmessages.append({\"role\": \"user\", \"content\": results})\n\nif compact_requested:\n messages[:] = COMPACTOR.compact_history(messages, active_request)\n```\n\n这样既不会留下孤立的工具结果,也不会在已经发生文件写入后丢失执行记录,导致模型重复同一个副作用。\n\n\n## 本节代码\n\n| 组件 | 共同执行骨架 | s08 新增 |\n| --- | --- | --- |\n| Agent Loop | 调用模型、执行工具、追加结果 | 每次调用模型前运行 `COMPACTOR.prepare()` |\n| Hooks | 权限检查、工具日志、结果处理 | 保持相同的工具执行入口 |\n| 上下文 | `messages` 持续追加 | 大结果转存、旧历史归档、摘要和一次错误补救 |\n| 工具 | 5 个基础工具 | 新增 `compact`,共 6 个 |\n\n> **与 s09 的边界:** s08 管理当前会话的有限上下文,压缩时允许舍弃可恢复的细节;s09 保存需要跨压缩、跨会话继续存在的信息。\n\n\n## 试一下\n\n```bash\ncd learn-claude-code\npython s08_context_compact/code.py\n```\n\n### 实验一:较早的结果被替换\n\n```text\n请读取 s01_agent_loop 到 s05_todo_write 五节课程的 README.md,\n比较它们的一级标题,并总结这些标题的命名规律。\n```\n\n任务会产生至少 5 条文件读取结果。每条新结果在模型首次读取前都会保持完整;后续轮次只保留最近 3 条已读取结果,更早且较长的结果会变成 `[Earlier tool result omitted.]`。已经转存的结果会保留保存路径。\n\n### 实验二:大结果转存\n\n```text\n请分析 web/src/data/generated/docs.json 的数据结构,\n并说明一条课程记录包含哪些主要字段。\n```\n\n文件内容超过单轮预算时,终端仍能完成任务,同时 `.task_outputs/tool-results/` 中会出现完整结果文件。\n\n### 实验三:自动摘要\n\n```text\n请比较 s08_context_compact/code.py 和 s09_memory/code.py,\n说明它们分别怎样管理当前上下文和持久记忆。\n```\n\n当读取结果使 `estimate_chars(messages)` 超过 50000 时,终端会打印 `[auto compact]` 和 transcript 路径。后续调用使用 `[Compacted]` 摘要继续完成比较。\n\n观察 `.transcripts/` 和 `.task_outputs/tool-results/`,可以分别看到历史留档与大结果转存。\n\n\n## 接下来\n\n上下文压缩让 Agent 可以在有限窗口中继续长任务。需要跨压缩、跨会话保留的信息,还要进入独立的持久记忆系统。\n\ns09 Memory 将实现记忆写入、检索与整理。\n\n<!-- translation-sync: zh@v8, en@v8, ja@v8 -->\n"
|
||
},
|
||
{
|
||
"version": "s08",
|
||
"locale": "ja",
|
||
"title": "s08: Context Compact:コンテキストが満杯になる前に整理する",
|
||
"content": "# s08: Context Compact:コンテキストが満杯になる前に整理する\n\ns01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](/ja/s09) → s10 → ... → s16 → s17\n\n> *「コンテキストには上限があるため、空きを作る仕組みが必要になる。」* 4 つの処理を低コストな順に実行します。\n>\n> **Harness レイヤー**:圧縮によって、限られたコンテキストを長いタスクでも使い続けられます。\n\n\nAgent が作業を続けると、読み込んだファイル、コマンド結果、モデルの応答がすべて `messages` に残ります。履歴はやがてモデルのコンテキスト上限を超えます。\n\nこのレッスンでは、4 ステップの圧縮パイプラインを実装します。まず再取得できるツール結果を整理し、それでも足りない場合にだけ履歴を要約します。\n\n\n\n\n## コンテキストを理解する\n\nコンテキストウィンドウは、モデルが現在使っている下書き用紙と考えられます。ユーザーメッセージ、モデルの応答、`tool_use`、`tool_result` が順番に書き込まれます。モデルはタスクを続けるたびに、その内容を読み直します。\n\n下書き用紙の大きさは固定です。上限を超えると API はリクエストを拒否し、`prompt_too_long` を返します。コーディングタスクでは、ツール結果が多くの領域を占めます。\n\n- 長いファイルを読むと、その内容がコンテキストに入ります。\n- テストやビルドのログは、一度に数十 KB 追加されることがあります。\n- 多数のファイルを検索すると、結果が次々に追加されます。\n\nタスクが続くほど `messages` は大きくなります。圧縮は、その増加を抑えながら、現在の目標、ユーザーの制約、進行中の作業をできるだけ保持します。\n\n\n## ツール結果から整理する理由\n\n履歴全体の要約はコンテキストを大きく縮められますが、細部が失われ、モデル呼び出しも 1 回増えます。\n\nツール結果には、先に処理しやすい性質があります。\n\n1. 大きなファイル結果はディスクに保存し、必要なときに読み直せます。\n2. 古いコマンドは再実行できます。\n3. 最新の結果ほど現在の作業に近い傾向があります。\n4. テキストの切り詰めと構造の調整にはモデル呼び出しが不要です。\n\nそのため、情報損失とコストが小さい順に、保存、切り詰め、古い結果の置換、履歴の要約を行います。\n\n\n\n\n## ステップ 1:tool_result_budget\n\n1 回のモデル応答が複数のツールを要求することがあります。実行後の `tool_result` は、最後の user メッセージにまとめて書き込まれます。合計が `200_000` 文字を超えると、`tool_result_budget` は大きな結果から順に処理します。\n\n`LARGE_RESULT_CHAR_LIMIT = 30000` を超える結果は、次の場所に完全な形で保存されます。\n\n```text\n.task_outputs/tool-results/<tool_use_id>.txt\n```\n\nコンテキストには、ファイルパスと先頭 2000 文字のプレビューを残します。\n\n\n\n中心となるループは、結果を大きい順に保存します。\n\n```python\nblocks = [block for block in content\n if isinstance(block, dict)\n and block.get(\"type\") == \"tool_result\"]\ntotal = sum(len(str(block.get(\"content\", \"\"))) for block in blocks)\n\nranked = sorted(\n blocks,\n key=lambda block: len(str(block.get(\"content\", \"\"))),\n reverse=True,\n)\nfor block in ranked:\n if total <= max_chars:\n break\n content = str(block.get(\"content\", \"\"))\n if len(content) <= self.LARGE_RESULT_CHAR_LIMIT:\n continue\n block[\"content\"] = self.persist_large_output(\n block.get(\"tool_use_id\", \"unknown\"), content)\n total = sum(len(str(item.get(\"content\", \"\"))) for item in blocks)\n```\n\nこのステップが対象にするのは、最新のツール結果だけです。完全な出力は保存先から再取得できるため、最初に実行する処理に適しています。\n\n\n## ステップ 2:snip_compact\n\n履歴が 50 メッセージを超えると、`snip_compact` は完全な履歴を `.transcripts/` に保存してから、先頭 3 件と最新 47 件を保持します。中間のマーカーには、削除した件数と transcript の保存先を記録します。\n\n```python\nhead_end = 3\ntail_start = len(messages) - (max_messages - head_end)\n\nif self.has_tool_use(messages[head_end - 1]):\n while (head_end < tail_start\n and self.is_tool_result(messages[head_end])):\n head_end += 1\n\nif (tail_start > 0\n and self.is_tool_result(messages[tail_start])\n and self.has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n\ntranscript = self.write_transcript(messages)\nmarker = {\"role\": \"user\", \"content\":\n f\"[{tail_start - head_end} messages archived at {transcript}]\"}\nmessages = [*messages[:head_end], marker, *messages[tail_start:]]\n```\n\n切断位置では、`assistant(tool_use)` と `user(tool_result)` の組を保護します。対応するツール呼び出しがない孤立した結果を含むと、次の API リクエストは無効になります。\n\nこのステップはメッセージ数を抑えます。保持されたメッセージ内のツール結果は、まだ長い可能性があります。\n\n\n## ステップ 3:micro_compact\n\n`micro_compact` は直近の assistant 応答より後に追加されたすべての `tool_result` を完全に保持し、モデルが各結果を少なくとも 1 回は完全な形で読めるようにします。モデルがすでに読んだ結果については最新 3 件を残し、それより古く 120 文字を超える結果を短くします。保存済みの結果にはファイルパスを残し、それ以外はプレースホルダーに置き換えます。\n\n\n\n```python\nunseen = self.unseen_tool_result_positions(messages)\nconsumed = [entry for entry in results if entry[:2] not in unseen]\n\nfor _, _, block in consumed[:-self.KEEP_RECENT_RESULTS]:\n content = str(block.get(\"content\", \"\"))\n if len(content) <= 120:\n continue\n saved_path = next(\n (line.removeprefix(\"Full output: \") for line in content.splitlines()\n if line.startswith(\"Full output: \")),\n None,\n )\n block[\"content\"] = (\n f\"[Earlier tool result saved at {saved_path}]\"\n if saved_path else \"[Earlier tool result omitted.]\"\n )\n```\n\n保存していない古い結果にはプレースホルダーだけが残ります。ステップ 1 で保存した結果には、完全な出力を読み直すためのパスが残ります。\n\n最初の 3 ステップは、決定的なテキスト処理と構造操作です。追加の API 呼び出しは発生しません。\n\n\n## ステップ 4:compact_history\n\n最初の 3 ステップの後、コードは `estimate_chars(messages)` で現在のメッセージに含まれる文字数を数えます。\n\n```python\nCONTEXT_CHAR_LIMIT = 50000\n\ndef estimate_chars(messages):\n return len(json.dumps(messages, default=str, ensure_ascii=False))\n```\n\n文字数が `CONTEXT_CHAR_LIMIT` を超えると、`compact_history` は 4 つの処理を行います。\n\n1. 完全なメッセージ履歴を `.transcripts/` に書き込みます。\n2. モデルに事実だけの状態要約を依頼します。\n3. 入力時に取得した現在の要求を要約と明確に分けます。\n4. 現在の履歴を 1 件の `[Compacted]` メッセージに置き換えます。\n\n\n\n```python\ndef compact_history(messages, active_request):\n transcript = self.write_transcript(messages)\n print(f\"[transcript saved: {transcript}]\")\n summary = self.summarize_history(messages)\n return [self.summary_message(\n \"Compacted\", active_request, summary, transcript)]\n```\n\n要約呼び出しは、履歴内の指示を実行せず、目標、ファイル、判断、残作業、ユーザー制約を整理するようモデルに求めます。ツール結果も `role=user` を使うため、CLI は `active_request` を Agent Loop に直接渡します。圧縮後のメッセージでは、現在の要求を `Current user request`、要約を `Conversation summary` に分け、完全な transcript のパスも残します。\n\nこのレッスンでは文字数を発火条件として使い、関連するしきい値も同じ単位で扱います。\n\n\n## 順序を固定する理由\n\nパイプラインは常に次の順序で実行されます。\n\n```text\ntool_result_budget\n → snip_compact\n → micro_compact\n → compact_history(上限を超えた場合)\n```\n\nこの順序には 2 つの条件があります。\n\n1. 最初の 3 ステップはモデルを呼び出しません。ステップ 4 だけが API リクエストを追加します。\n2. `tool_result_budget` は `micro_compact` より先に動く必要があります。古い結果をプレースホルダーにする前に、大きな結果をディスクへ保存します。\n\n各ラウンドは、コストが低く情報を再取得しやすい処理から始まります。\n\n\n## API に拒否された後の回復\n\n文字数はモデルが使う token 数の推定値です。そのため API が `prompt_too_long` を返す可能性は残ります。`reactive_compact` は transcript を保存し、古い履歴を要約して、最新 5 メッセージを保持します。\n\n```python\ntail_start = max(0, len(messages) - self.KEEP_RECENT_MESSAGES)\nif (tail_start > 0\n and self.is_tool_result(messages[tail_start])\n and self.has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n\nold_history = messages[:tail_start] if tail_start else messages\nsummary = self.summarize_history(old_history)\nmessage = self.summary_message(\n \"Reactive compact\", active_request, summary, transcript)\nmessages = [message, *messages[tail_start:]] if tail_start else [message]\n```\n\nこの切断位置でもツール呼び出しと結果の組を分割せず、現在のユーザー要求は `active_request` で明示的に渡されます。`MAX_REACTIVE_RETRIES = 1` により、回復処理は 1 回だけ許可されます。もう一度コンテキスト長のエラーを受けた場合は、例外を呼び出し元へ返します。\n\n\n## Agent Loop に組み込む\n\n```python\ndef agent_loop(messages, active_request):\n while True:\n messages[:] = COMPACTOR.prepare(messages, active_request)\n\n try:\n response = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000)\n reactive_retries = 0\n except Exception as error:\n message = str(error).lower()\n too_long = (\"prompt_too_long\" in message\n or \"too many tokens\" in message)\n if too_long and reactive_retries < MAX_REACTIVE_RETRIES:\n messages[:] = COMPACTOR.reactive_compact(\n messages, active_request)\n reactive_retries += 1\n continue\n raise\n```\n\nすべてのモデル呼び出しが同じパイプラインを通ります。CLI は `query` を追加した後に `agent_loop(history, query)` を呼ぶため、圧縮を繰り返しても現在の要求は失われません。最初の 3 ステップ後も上限を超える場合、または API が拒否した場合にだけ、コードはモデルへ要約を依頼します。\n\n\n## compact ツール\n\n自動しきい値が判断できるのは、コンテキストの大きさだけです。ある段階を終え、次の段階に要約だけを引き継げばよいとモデルが判断したとき、`compact` を呼び出せます。\n\n```python\n{\"name\": \"compact\",\n \"description\": \"Summarize earlier conversation to free context space.\"}\n```\n\n1 回の応答には、ファイル書き込みと圧縮のように複数のツール呼び出しが含まれることがあります。Harness はまず一括処理をすべて実行し、各 `tool_use` に対応する `tool_result` を追加します。そのターンが完結してから要約します。\n\n```python\ntool_calls = [\n block for block in response.content if block.type == \"tool_use\"\n]\nresults = []\ncompact_requested = False\n\nfor block in tool_calls:\n if block.name == \"compact\":\n output = \"Compaction requested after this tool batch.\"\n compact_requested = True\n else:\n output = execute_tool(block)\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": output})\n\nmessages.append({\"role\": \"user\", \"content\": results})\n\nif compact_requested:\n messages[:] = COMPACTOR.compact_history(messages, active_request)\n```\n\nこれにより孤立したツール結果が残りません。また、圧縮前に実行したファイル書き込みなどの記録も保持されるため、モデルが同じ副作用を繰り返すことを防げます。\n\n\n## このレッスンで追加するもの\n\n| コンポーネント | 共通の実行ループ | s08 で追加 |\n| --- | --- | --- |\n| Agent Loop | モデルを呼び出し、ツールを実行し、結果を追加 | 各モデル呼び出しの前に `COMPACTOR.prepare()` を実行 |\n| Hooks | 権限確認、ツールログ、結果処理 | 同じツール実行入口を維持 |\n| コンテキスト | `messages` に追加 | 大きな結果の保存、古い履歴のアーカイブ、要約、長さエラー後の 1 回の再試行 |\n| ツール | 5 個の基本ツール | `compact` を追加し、合計 6 個 |\n\n> **s09 との境界:** s08 は現在のセッションにある有限のコンテキストを管理し、再取得できる詳細を圧縮できます。s09 は、圧縮後や次のセッションにも残す情報を保存します。\n\n\n## 試してみる\n\n```bash\ncd learn-claude-code\npython s08_context_compact/code.py\n```\n\n### 実験 1:古い結果を置き換える\n\n```text\ns01_agent_loop から s05_todo_write までの README.md を読み、\n各ファイルの最上位見出しを比較して、命名の規則をまとめてください。\n```\n\nこのタスクでは少なくとも 5 件のファイル結果が生成されます。各新規結果はモデルが初めて読むまで完全に保持されます。以降のターンでは、すでに読まれた最新 3 件を残し、それより前の長い結果は `[Earlier tool result omitted.]` に変わります。保存済みの結果には保存先のパスが残ります。\n\n### 実験 2:大きな結果を保存する\n\n```text\nweb/src/data/generated/docs.json のデータ構造を調べ、\n1 件のレッスン記録に含まれる主なフィールドを説明してください。\n```\n\nファイルが 1 ラウンドの予算を超える場合でもタスクは続行でき、完全な結果が `.task_outputs/tool-results/` に保存されます。\n\n### 実験 3:自動要約を発火させる\n\n```text\ns08_context_compact/code.py と s09_memory/code.py を比較し、\n現在のコンテキストと永続メモリの管理方法を説明してください。\n```\n\nファイル結果によって `estimate_chars(messages)` が 50000 を超えると、ターミナルに `[auto compact]` と transcript のパスが表示されます。次の呼び出しは `[Compacted]` の要約から続行します。\n\n`.transcripts/` と `.task_outputs/tool-results/` を確認すると、履歴の保存と大きな結果の転送をそれぞれ観察できます。\n\n\n## 次へ\n\nコンテキスト圧縮により、Agent は限られたウィンドウでも長いタスクを続けられます。圧縮後や次のセッションにも残す情報には、独立した永続メモリが必要です。\n\ns09 Memory では、メモリの書き込み、検索、整理を実装します。\n\n<!-- translation-sync: zh@v8, en@v8, ja@v8 -->\n"
|
||
},
|
||
{
|
||
"version": "s09",
|
||
"locale": "en",
|
||
"title": "s09: Memory — Keep Useful Knowledge Across Sessions",
|
||
"content": "# s09: Memory — Keep Useful Knowledge Across Sessions\n\ns01 → ... → s07 → s08 → `s09` → [s10](/en/s10) → s11 → ... → s16 → s17\n> *\"Keep information that later tasks will need.\"* File storage + an index + relevance selection + on-demand recall.\n>\n> **Harness layer**: Memory stores reusable knowledge outside the conversation and recalls it for related tasks.\n\n---\n\n## The Problem\n\nAn Agent starts a new session without the previous conversation in `messages`. A coding preference, project fact, or debugging clue from an earlier session may still matter. Without persistent storage, the user has to provide it again.\n\nA complete transcript works as an archive, but sending it with every request does not scale. The conversation keeps growing, useful information becomes hard to locate, and old facts may no longer be true. Memory must decide what is worth keeping across sessions and which records belong in the current task.\n\n\n\n---\n\n## Why Not Put Everything in the System Prompt?\n\nThe direct approach is to write preferences and project facts into one file, then put the entire file in the system prompt. It remembers the information, but every LLM call must resend all of it. As the store grows, more unrelated material consumes input tokens and context space.\n\ns07 showed a better reading pattern: keep a short index available and load full content only when needed. Skills are human-authored and read-only. Memory lets the Agent extract information from conversation and reuse it in later work.\n\nThis chapter therefore needs four parts: storage, recall, extraction, and consolidation.\n\n\n\n---\n\n## Storage: One File per Record\n\nEach memory is a Markdown file under `.memory/`. YAML frontmatter stores its `name`, `description`, and `type`:\n\n```markdown\n---\nname: user-preference-tabs\ndescription: User prefers tabs for indentation\ntype: user\n---\n\nUser prefers using tabs, not spaces, for indentation.\n```\n\nThere are four memory types:\n\n| Type | What it stores | Example |\n|------|----------------|---------|\n| user | A durable user preference | \"Use tabs for indentation\" |\n| feedback | Guidance that remains useful | \"Do not mock the database\" |\n| project | A stable project fact | \"The authentication rewrite is compliance-driven\" |\n| reference | An external pointer or lookup clue | \"The pipeline issue is tracked in Linear INGEST\" |\n\n`MEMORY.md` is the index, with one line per memory file. After a write, `rebuild_memory_index()` regenerates it from the files:\n\n```python\ndef write_memory_file(name, mem_type, description, body):\n path = MEMORY_DIR / f\"{memory_slug(name)}.md\"\n path.write_text(memory_document(name, mem_type, description, body))\n rebuild_memory_index()\n return path\n```\n\nThe index supports selection while full content stays in the individual files.\n\n---\n\n## Recall: Select First, Then Load Full Records\n\nAt the start of a user request, `select_relevant_memories()` sends the recent user text and memory catalog to a lightweight model call. It selects at most five relevant records:\n\n```python\nprompt = (\n \"Select memory records that are relevant to the current user request. \"\n \"Return only a JSON array of catalog indices, such as [0, 2]. \"\n \"Return [] when none are relevant.\"\n)\n```\n\nIf the model call or JSON parsing fails, the code falls back to keyword matching. Only after selection does `load_memories()` read the corresponding files, with a limit on the total recalled text.\n\n```python\nrelevant_memories = load_memories(messages)\nsystem = build_system(relevant_memories)\n```\n\n`build_system()` states that recalled content is background knowledge, not a new user command. The current request wins when it conflicts with memory. This lets the Agent use old information without letting old records issue instructions on the user's behalf.\n\n---\n\n## Extraction: Save Reusable Information After the Turn\n\nUsers do not always say \"remember this.\" After the Agent finishes the current response, `extract_memories()` inspects the conversation and keeps only information likely to help later:\n\n```python\ntool_calls = [\n block for block in response.content if block.type == \"tool_use\"\n]\nif not tool_calls:\n force = trigger_hooks(\"Stop\", messages)\n if force:\n messages.append({\"role\": \"user\", \"content\": force})\n continue\n if extract_memories(messages):\n consolidate_memories()\n return\n```\n\nThe model returns candidates, not records that are automatically allowed onto disk. Each candidate carries a `scope`: only `persistent` means that the information should survive into later sessions. `current_task` covers one-off commands, temporary paths, and temporary restrictions.\n\n`should_store_memory()` performs the final admission check. It rejects incomplete candidates, phrases that refer to the current session or task, and duplicates of existing records. For example, \"do not create files in this session\" constrains the current work; it must not remain active in the next session.\n\n---\n\n## Consolidation: Merge Duplicate and Stale Records\n\nAs memory files accumulate, some become duplicate, contradictory, or stale. The teaching implementation calls `consolidate_memories()` after the store reaches ten records and asks the model for a cleaned list.\n\nThe code parses and validates the new list before replacing old files. It snapshots the current records first; if deletion or writing fails, it restores the originals and rebuilds the index:\n\n```python\nsnapshot = {\n path.name: path.read_text()\n for path in MEMORY_DIR.glob(\"*.md\")\n if path.name != MEMORY_INDEX.name\n}\n\ntry:\n for path in MEMORY_DIR.glob(\"*.md\"):\n if path.name != MEMORY_INDEX.name:\n path.unlink()\n for record in consolidated:\n path = MEMORY_DIR / f\"{memory_slug(record['name'])}.md\"\n path.write_text(memory_document(\n record[\"name\"], record[\"type\"],\n record[\"description\"], record[\"body\"],\n ))\n rebuild_memory_index()\nexcept Exception:\n for path in MEMORY_DIR.glob(\"*.md\"):\n if path.name != MEMORY_INDEX.name:\n path.unlink()\n for filename, content in snapshot.items():\n (MEMORY_DIR / filename).write_text(content)\n rebuild_memory_index()\n raise\n```\n\nThe course uses a simple count threshold. A real application must also choose a schedule that fits its data volume and prevent concurrent processes from rewriting the same store.\n\n---\n\n## This Lesson's Code\n\n| Part | Implementation |\n|------|----------------|\n| Agent Loop | Keeps messages, tool calls, tool results, and hook trigger points |\n| Base tools | `bash`, `read_file`, `write_file`, `edit_file`, `glob` |\n| Storage | `.memory/MEMORY.md` index + `.memory/*.md` records |\n| Recall | Catalog selection + keyword fallback + a body-size limit |\n| Writing | End-of-turn extraction + persistence checks + duplicate filtering |\n| Consolidation | Merge at the threshold; restore old files after replacement failure |\n\n> **Boundary with s08:** s08 manages the active session's context budget. s09 manages reusable knowledge outside the conversation. Memory is selective storage, not a lossless transcript backup, and it does not replace context compaction.\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s09_memory/code.py\n```\n\n1. Enter `I prefer using tabs for indentation. Remember that.` After the turn, check that `.memory/` contains a new record and `MEMORY.md` contains its index entry.\n2. Enter `q`, restart the program, and ask `What indentation style do I prefer?` Confirm that a new session can recall the preference.\n3. Store another preference unrelated to code formatting, then ask about indentation. Observe that the current request loads only relevant records.\n4. Enter `Do not create files in this session.` Confirm that this temporary requirement does not become a persistent rule for the next session.\n\nExact wording and extraction counts can vary by model. Check what was written to `.memory/` and whether a later session recalls only relevant information.\n\n---\n\n## What's Next\n\nMemory preserves information across sessions, but a complex task also needs durable status and dependency tracking. A TODO kept only in the conversation cannot carry progress across process restarts.\n\ns10 Task System → Persist tasks, statuses, and dependencies to disk.\n\n<!-- translation-sync: zh@v3, en@v3, ja@v3 -->\n"
|
||
},
|
||
{
|
||
"version": "s09",
|
||
"locale": "zh",
|
||
"title": "s09: Memory — 让重要信息跨会话保留下来",
|
||
"content": "# s09: Memory — 让重要信息跨会话保留下来\n\ns01 → ... → s07 → s08 → `s09` → [s10](/zh/s10) → s11 → ... → s16 → s17\n> *\"把以后还会用到的信息留下来。\"* 文件存储 + 索引 + 相关性选择 + 按需召回。\n>\n> **Harness 层**:Memory 在会话之外保存可复用知识,并在相关任务中取回。\n\n---\n\n## 问题\n\nAgent 开始新会话时,`messages` 里没有上一次的对话。用户之前说过的编码偏好、项目背景和排查线索,下次任务还可能用到。没有持久存储,这些信息只能由用户重新说一遍。\n\n把完整 transcript 留下来适合归档,却不适合每次都发给模型。对话会越来越长,当前任务需要的信息很难定位,旧事实也可能已经过期。Memory 要解决的是两个问题:哪些信息值得跨会话保存,以及当前任务应该取回哪几条。\n\n\n\n---\n\n## 全部写进 system prompt,为什么不合适\n\n最直接的做法,是把用户偏好和项目事实写进一个固定文件,启动时全部放进 system prompt。这样确实能够记住信息,但每次调用 LLM 都要重新发送全部内容。记忆越多,与当前任务无关的内容就越多,输入 token 和上下文窗口也会被持续占用。\n\ns07 已经展示过一种更合适的读取方式:保留简短索引,只在需要时加载正文。Skill 由人编写并保持只读;Memory 则允许 Agent 从对话中提取内容,并在后续任务中再次使用。\n\n因此,本章需要处理四件事:存储、召回、提取和整理。\n\n\n\n---\n\n## 存储:一个记忆一个文件\n\n每条记忆是 `.memory/` 下的一个 Markdown 文件,YAML frontmatter 记录 `name`、`description` 和 `type`:\n\n```markdown\n---\nname: user-preference-tabs\ndescription: User prefers tabs for indentation\ntype: user\n---\n\nUser prefers using tabs, not spaces, for indentation.\n```\n\n`type` 有四类:\n\n| 类型 | 保存什么 | 示例 |\n|------|---------|------|\n| user | 用户的长期偏好 | “使用 tab 缩进” |\n| feedback | 以后仍适用的工作反馈 | “不要 mock 数据库” |\n| project | 稳定的项目事实 | “认证重写由合规要求驱动” |\n| reference | 外部资料或查找线索 | “流水线问题记录在 Linear INGEST” |\n\n`MEMORY.md` 是索引,每行对应一个记忆文件。写入完成后,`rebuild_memory_index()` 根据文件重新生成索引:\n\n```python\ndef write_memory_file(name, mem_type, description, body):\n path = MEMORY_DIR / f\"{memory_slug(name)}.md\"\n path.write_text(memory_document(name, mem_type, description, body))\n rebuild_memory_index()\n return path\n```\n\n索引用于选择相关记忆,正文仍然保存在各自的文件中。\n\n---\n\n## 召回:先选择,再加载正文\n\n每次用户发起请求时,`select_relevant_memories()` 读取最近的用户消息和记忆目录,让一次轻量模型调用选择最多五条相关记录:\n\n```python\nprompt = (\n \"Select memory records that are relevant to the current user request. \"\n \"Return only a JSON array of catalog indices, such as [0, 2]. \"\n \"Return [] when none are relevant.\"\n)\n```\n\n如果模型调用或 JSON 解析失败,代码会退回关键词匹配。选择完成后,`load_memories()` 才读取对应文件,并限制召回正文的总长度。\n\n```python\nrelevant_memories = load_memories(messages)\nsystem = build_system(relevant_memories)\n```\n\n`build_system()` 会明确说明:召回内容只是背景知识,不是新的用户命令;如果记忆与当前请求冲突,以当前请求为准。这样既能使用旧信息,也不会让旧记忆替用户发号施令。\n\n---\n\n## 提取:回合结束后保存可复用信息\n\n用户不一定会明确说“请记住”。`extract_memories()` 在 Agent 完成本轮回答后检查当前对话,只提取以后仍可能有用的信息:\n\n```python\ntool_calls = [\n block for block in response.content if block.type == \"tool_use\"\n]\nif not tool_calls:\n force = trigger_hooks(\"Stop\", messages)\n if force:\n messages.append({\"role\": \"user\", \"content\": force})\n continue\n if extract_memories(messages):\n consolidate_memories()\n return\n```\n\n模型返回的内容只是候选,不会直接写盘。候选必须带有 `scope`:只有 `persistent` 才表示它应当跨会话保留;`current_task` 表示本次任务的命令、临时路径和临时限制。\n\n`should_store_memory()` 负责最后的检查。字段不完整、带有“本次会话”或“当前任务”等临时含义、或者与已有记忆重复的候选都会被拒绝。比如“这次不要创建文件”只约束当前任务,不应该在下次会话中继续生效。\n\n---\n\n## 整理:合并重复和过期内容\n\n记忆文件积累到一定数量后,内容可能重复、矛盾或过期。教学实现达到 10 条时调用 `consolidate_memories()`,让模型生成一份整理后的记录列表。\n\n整理过程先解析并校验新列表,再替换旧文件。替换前会保存快照;删除或写入失败时,代码恢复原文件并重建索引:\n\n```python\nsnapshot = {\n path.name: path.read_text()\n for path in MEMORY_DIR.glob(\"*.md\")\n if path.name != MEMORY_INDEX.name\n}\n\ntry:\n for path in MEMORY_DIR.glob(\"*.md\"):\n if path.name != MEMORY_INDEX.name:\n path.unlink()\n for record in consolidated:\n path = MEMORY_DIR / f\"{memory_slug(record['name'])}.md\"\n path.write_text(memory_document(\n record[\"name\"], record[\"type\"],\n record[\"description\"], record[\"body\"],\n ))\n rebuild_memory_index()\nexcept Exception:\n for path in MEMORY_DIR.glob(\"*.md\"):\n if path.name != MEMORY_INDEX.name:\n path.unlink()\n for filename, content in snapshot.items():\n (MEMORY_DIR / filename).write_text(content)\n rebuild_memory_index()\n raise\n```\n\n课程代码把整理触发条件简化为数量阈值。真实应用还需要根据数据规模和并发方式,决定何时整理以及如何避免多个进程同时改写同一份存储。\n\n---\n\n## 本节代码\n\n| 组成 | 本节实现 |\n|------|---------|\n| Agent Loop | 保留消息、工具调用、工具结果和 hooks 触发点 |\n| 基础工具 | `bash`、`read_file`、`write_file`、`edit_file`、`glob` |\n| 存储 | `.memory/MEMORY.md` 索引 + `.memory/*.md` 文件 |\n| 召回 | 目录选择 + 关键词降级 + 正文长度上限 |\n| 写入 | 回合结束后提取 + 持久性检查 + 重复过滤 |\n| 整理 | 达到阈值后合并,失败时恢复原文件 |\n\n> **与 s08 的边界:** s08 管理当前会话的上下文预算,s09 管理会话之外的可复用知识。Memory 是选择性存储,不是 transcript 的无损备份,也不会取代上下文压缩。\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s09_memory/code.py\n```\n\n1. 输入 `I prefer using tabs for indentation. Remember that.`,结束后检查 `.memory/` 是否新增记忆文件,`MEMORY.md` 是否出现对应索引;\n2. 输入 `q` 退出并重新运行程序,再问 `What indentation style do I prefer?`,确认新会话能够召回这条偏好;\n3. 再保存一条与代码格式无关的偏好,然后询问缩进问题,观察当前请求只加载相关记忆;\n4. 输入 `Do not create files in this session.`,确认这条临时要求不会成为下一次会话的持久规则。\n\n模型的具体措辞和提取数量可能变化,判断重点是 `.memory/` 中保存了什么,以及新会话是否只取回相关内容。\n\n---\n\n## 接下来\n\nMemory 解决了跨会话保留信息的问题,但复杂任务还需要记录每一步的状态和依赖关系。仅靠对话中的 TODO,程序退出后就无法继续追踪进度。\n\ns10 Task System → 把任务、状态和依赖关系保存到磁盘。\n\n<!-- translation-sync: zh@v3, en@v3, ja@v3 -->\n"
|
||
},
|
||
{
|
||
"version": "s09",
|
||
"locale": "ja",
|
||
"title": "s09: Memory — 重要な情報をセッションを越えて残す",
|
||
"content": "# s09: Memory — 重要な情報をセッションを越えて残す\n\ns01 → ... → s07 → s08 → `s09` → [s10](/ja/s10) → s11 → ... → s16 → s17\n> *「後のタスクでも使う情報を残す。」* ファイル保存 + index + 関連性の選択 + 必要時の recall。\n>\n> **Harness レイヤー**:Memory は会話の外に再利用できる知識を保存し、関係するタスクで取り出す。\n\n---\n\n## 問題\n\nAgent が新しい session を始めると、`messages` に前回の会話はない。以前に伝えられた coding preference、project の背景、調査の手がかりは、次のタスクでも必要になることがある。永続的な保存先がなければ、ユーザーは同じ情報をもう一度伝えなければならない。\n\n完全な transcript は記録には向いているが、毎回モデルへ送る方法は長続きしない。会話は増え続け、必要な情報を見つけにくくなり、古い事実が現在も正しいとは限らない。Memory が判断するのは、どの情報を session を越えて保存するか、現在のタスクでどの記録を取り出すかだ。\n\n\n\n---\n\n## すべて system prompt に入れる方法が適さない理由\n\n最も直接的な方法は、ユーザーの好みや project の事実を一つのファイルへ書き、起動時に全文を system prompt へ入れることだ。情報は残るが、LLM を呼ぶたびに全量を送り直す必要がある。記憶が増えるほど、現在のタスクと関係ない内容が input token と context を占有する。\n\ns07 は別の読み方を示した。短い index を置き、必要なときだけ本文を読む。Skill は人が書く read-only の知識であり、Memory は Agent が会話から情報を抽出し、後のタスクで再利用できるようにする。\n\nこの章で扱うのは、保存、recall、抽出、整理の四つだ。\n\n\n\n---\n\n## 保存:一つの記憶を一つのファイルへ\n\n各 memory は `.memory/` の Markdown ファイルで、YAML frontmatter に `name`、`description`、`type` を持つ。\n\n```markdown\n---\nname: user-preference-tabs\ndescription: User prefers tabs for indentation\ntype: user\n---\n\nUser prefers using tabs, not spaces, for indentation.\n```\n\nmemory type は四種類ある。\n\n| type | 保存する内容 | 例 |\n|------|-------------|----|\n| user | 長く使うユーザーの好み | 「indent には tab を使う」 |\n| feedback | 今後も使える作業上の feedback | 「database を mock しない」 |\n| project | 安定した project の事実 | 「認証の書き直しは compliance 要件による」 |\n| reference | 外部資料や検索の手がかり | 「pipeline の問題は Linear INGEST にある」 |\n\n`MEMORY.md` は index で、一行が一つの memory ファイルに対応する。書き込み後、`rebuild_memory_index()` がファイルから index を作り直す。\n\n```python\ndef write_memory_file(name, mem_type, description, body):\n path = MEMORY_DIR / f\"{memory_slug(name)}.md\"\n path.write_text(memory_document(name, mem_type, description, body))\n rebuild_memory_index()\n return path\n```\n\nindex は関連する記憶を選ぶために使い、本文は個別ファイルに残す。\n\n---\n\n## Recall:先に選び、その後で本文を読む\n\nユーザーの request が始まると、`select_relevant_memories()` は最近のユーザー発言と memory catalog を軽量なモデル呼び出しへ渡し、関係する記録を最大五件選ぶ。\n\n```python\nprompt = (\n \"Select memory records that are relevant to the current user request. \"\n \"Return only a JSON array of catalog indices, such as [0, 2]. \"\n \"Return [] when none are relevant.\"\n)\n```\n\nモデル呼び出しまたは JSON parse に失敗したら、keyword matching へ fallback する。選択後にだけ `load_memories()` が対応するファイルを読み、recall する本文の合計長も制限する。\n\n```python\nrelevant_memories = load_memories(messages)\nsystem = build_system(relevant_memories)\n```\n\n`build_system()` は、recall した内容が背景知識であり、新しいユーザー command ではないことを明示する。memory と現在の request が矛盾した場合は現在の request を優先する。これにより古い情報は利用できるが、古い記録がユーザーの代わりに命令することはない。\n\n---\n\n## 抽出:turn の終了後に再利用できる情報を保存する\n\nユーザーが毎回「覚えて」と言うとは限らない。Agent が現在の返答を終えた後、`extract_memories()` は会話を確認し、今後も役立つ可能性がある情報だけを取り出す。\n\n```python\ntool_calls = [\n block for block in response.content if block.type == \"tool_use\"\n]\nif not tool_calls:\n force = trigger_hooks(\"Stop\", messages)\n if force:\n messages.append({\"role\": \"user\", \"content\": force})\n continue\n if extract_memories(messages):\n consolidate_memories()\n return\n```\n\nモデルの返答は候補であり、そのまま disk へ書く記録ではない。各候補には `scope` があり、`persistent` だけが後の session に残す内容を表す。`current_task` は一回だけの command、一時 path、現在のタスクだけの制約に使う。\n\n最後の判定は `should_store_memory()` が行う。field が足りない候補、「この session」「現在の task」のような一時性を含む候補、既存 memory と重複する候補は拒否する。例えば「この session ではファイルを作らない」は現在の作業だけの制約であり、次の session まで有効にしてはいけない。\n\n---\n\n## 整理:重複した内容と古い内容をまとめる\n\nmemory ファイルが増えると、重複、矛盾、古い情報が混ざる。学習用実装は 10 件に達すると `consolidate_memories()` を呼び、整理後の記録一覧をモデルに生成させる。\n\n新しい一覧を parse して検証してから旧ファイルを置き換える。置き換え前には現在の記録を snapshot し、削除や書き込みに失敗したら元のファイルを戻して index を再構築する。\n\n```python\nsnapshot = {\n path.name: path.read_text()\n for path in MEMORY_DIR.glob(\"*.md\")\n if path.name != MEMORY_INDEX.name\n}\n\ntry:\n for path in MEMORY_DIR.glob(\"*.md\"):\n if path.name != MEMORY_INDEX.name:\n path.unlink()\n for record in consolidated:\n path = MEMORY_DIR / f\"{memory_slug(record['name'])}.md\"\n path.write_text(memory_document(\n record[\"name\"], record[\"type\"],\n record[\"description\"], record[\"body\"],\n ))\n rebuild_memory_index()\nexcept Exception:\n for path in MEMORY_DIR.glob(\"*.md\"):\n if path.name != MEMORY_INDEX.name:\n path.unlink()\n for filename, content in snapshot.items():\n (MEMORY_DIR / filename).write_text(content)\n rebuild_memory_index()\n raise\n```\n\n学習用コードでは件数だけを threshold にする。実際の application では data 量に合う実行時期を選び、複数 process が同じ store を同時に書き換えないようにする必要がある。\n\n---\n\n## この章のコード\n\n| 部分 | 実装 |\n|------|------|\n| Agent Loop | messages、tool call、tool result、hook の trigger point を維持 |\n| 基本 tools | `bash`、`read_file`、`write_file`、`edit_file`、`glob` |\n| 保存 | `.memory/MEMORY.md` index + `.memory/*.md` records |\n| Recall | catalog の選択 + keyword fallback + 本文サイズ上限 |\n| 書き込み | turn 終了後の抽出 + 永続性チェック + 重複除外 |\n| 整理 | threshold 到達後に統合し、置き換え失敗時は旧ファイルを復元 |\n\n> **s08 との境界:** s08 は現在の session の context budget を管理し、s09 は会話の外にある再利用可能な知識を管理する。Memory は選択的な保存であり、transcript の lossless backup ではなく、context compaction の代わりにもならない。\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s09_memory/code.py\n```\n\n1. `I prefer using tabs for indentation. Remember that.` と入力し、turn の後に `.memory/` へ新しい record が増え、`MEMORY.md` に index entry が作られたか確認する。\n2. `q` で終了し、program を再起動して `What indentation style do I prefer?` と聞く。新しい session でも preference を recall できることを確認する。\n3. code formatting と関係ない別の preference を保存してから indentation を質問し、現在の request に関係する memory だけが読み込まれるか確認する。\n4. `Do not create files in this session.` と入力し、この一時的な条件が次の session の永続ルールにならないことを確認する。\n\nモデルによって表現や抽出件数は変わる。確認するのは `.memory/` に何が保存されたか、後の session が関係する情報だけを recall したかだ。\n\n---\n\n## 次へ\n\nMemory は情報をセッション間で保持する。しかし複雑なタスクには、各作業の状態と依存関係も永続的に記録する必要がある。会話内の TODO だけでは、プロセス終了後に進捗を追跡できない。\n\ns10 Task System → タスク、状態、依存関係をディスクへ保存する。\n\n<!-- translation-sync: zh@v3, en@v3, ja@v3 -->\n"
|
||
},
|
||
{
|
||
"version": "s10",
|
||
"locale": "en",
|
||
"title": "s10: Task System — From an Execution Checklist to Coordinated Task State",
|
||
"content": "# s10: Task System — From an Execution Checklist to Coordinated Task State\n\ns01 → ... → s08 → s09 → `s10` → [s11](/en/s11) → s12 → ... → s16 → s17\n\n> *\"Break big goals into small tasks, order them, persist\"* — File-persisted task graph, the foundation for multi-agent collaboration.\n>\n> **Harness Layer**: Tasks — Persisted goals, recoverable progress.\n\n---\n\n## The Problem\n\ns05's TodoWrite lets an agent record the steps of its current task. Each checklist item has content and a status, helping the agent keep track of what remains.\n\nWhen a project is split into three tasks—creating database tables, writing an API, and adding tests—the Harness also needs to know how they relate: the API must wait for the database tables, and the tests must wait for a stable API. It also needs to record who is responsible for each task.\n\nTodoWrite does not record these dependencies or assignments. It can show that \"write the API\" is unfinished, but the Harness cannot use that information to decide whether the task is ready to start.\n\nThis chapter adds a Task System. Each task has its own ID and status; `blockedBy` records prerequisites, and `owner` records the agent responsible for the task.\n\n---\n\n## The Solution\n\n\n\nThe code keeps S04's five base tools, Permission, Hooks, and shared `execute_tool`, then adds 6 task tools, persistence in the `.tasks/` directory, and `blockedBy` dependency checks.\n\nTodoWrite vs Task System:\n\n| | TodoWrite (s05) | Task System (s10) |\n|---|---|---|\n| Role | Execution checklist for the current task | Recoverable task system |\n| Storage | In-process / session state | `.tasks/{id}.json` |\n| Dependencies | None | `blockedBy` dependency graph |\n| Lifecycle | Current session / current task | Cross-session |\n| Coordination | No task claiming | `owner` / claim |\n| Status | pending / in_progress / completed | pending / in_progress / completed |\n| Granularity | The agent's own steps | Tasks that can be claimed, tracked, and unblocked |\n| Update contract | Replace the whole checklist | Create/get/update/list individual records |\n\n---\n\n## How It Works\n\n\n\n### Task: Data Structure\n\nEach task is a JSON file, stored in the `.tasks/` directory:\n\n```python\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str # pending | in_progress | completed\n owner: str | None # Agent responsible for this task\n blockedBy: list[str] # List of dependency task IDs\n```\n\nIDs use the `task_` prefix followed by 8 random hexadecimal characters. Files are created exclusively; an existing ID is discarded and regenerated.\n\n`TaskStore` validates task IDs and reads and writes the JSON files. `TASKS = TaskStore(TASKS_DIR)` is the store used by this chapter.\n\n### create_task: Create Tasks\n\n```python\ndef create_task(subject: str, description: str = \"\") -> Task:\n return TASKS.create(subject, description)\n```\n\n`TaskStore.create` checks the subject, allocates a random ID, and writes `.tasks/{id}.json`. A new task always starts with an empty `blockedBy` list. The tool result returns the runtime-generated ID to the model.\n\n### update_task: Add Dependencies with Returned IDs\n\n```python\ndef update_task(task_id: str, addBlockedBy: list[str]) -> Task:\n return TASKS.update_dependencies(task_id, addBlockedBy)\n```\n\nTask graph construction uses two phases: create every node first, then call `update_task` with the IDs returned by `create_task` to add edges. This matters when the model emits several tool calls in one response: sibling calls are formed before any tool result exists, so one `create_task` call cannot consume another call's newly generated ID.\n\n`update_task` validates the entire change before saving it. The target and dependencies must exist, the target must still be pending and unowned, and the new edges must not introduce self-dependencies or cycles. Repeating an existing edge is safe and does not duplicate it.\n\n### can_start: Dependency Check\n\nA task can only start after all its `blockedBy` dependencies are **completed**:\n\n```python\ndef can_start(task_id: str) -> bool:\n return not incomplete_dependencies(load_task(task_id))\n```\n\n`incomplete_dependencies` loads each prerequisite. A task cannot be claimed if any prerequisite is not completed or its file no longer exists.\n\n### claim_task: Claim a Task\n\nWhen the agent starts working on a task, it calls `claim_task`: sets `owner`, changes status from `pending` → `in_progress`. The `owner` field records who claimed the task:\n\n```python\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n dependencies = incomplete_dependencies(task)\n if dependencies:\n return f\"Blocked by: {dependencies}\"\n task.owner = owner\n task.status = \"in_progress\"\n TASKS.save(task)\n return f\"Claimed {task_id} ({task.subject})\"\n```\n\nThe claim is rejected if the task is not pending or its dependencies are incomplete. S10 only updates task state sequentially.\n\n### complete_task: Complete and Unblock\n\nWhen a task is done, set it to `completed`. Simultaneously scan all other tasks to find downstream tasks that were **just unblocked**:\n\n```python\ndef complete_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"in_progress\":\n return f\"Task {task_id} is {task.status}, cannot complete\"\n if task.owner != owner:\n return f\"Task {task_id} is owned by {task.owner}, not {owner}\"\n ready_before = {t.id for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy\n and can_start(t.id)}\n task.status = \"completed\"\n TASKS.save(task)\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy\n and t.id not in ready_before\n and can_start(t.id)]\n msg = f\"Completed {task_id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n return msg\n```\n\nAfter completing \"schema\", `can_start` returns True for \"endpoints\" and \"docs\"; they can begin.\n\n### get_task: View Full Details\n\n`list_tasks` only shows a one-line summary. `get_task` returns the full task JSON, including description and dependency details. When recovering across sessions, the agent needs to read the full description to continue work:\n\n```python\ndef get_task(task_id: str) -> str:\n task = load_task(task_id)\n return json.dumps(asdict(task), indent=2)\n```\n\n### State Machine: Two Actions, Three States\n\n```\npending ──claim──→ in_progress ──complete──→ completed\n```\n\nHere `claim` / `complete` are actions, while `pending` / `in_progress` / `completed` are states:\n\n- **claim_task**: `pending` → `in_progress`. Sets owner, begins work.\n- **complete_task**: `in_progress` → `completed`. Marks the task done and unblocks downstream.\n\n### Putting It Together\n\n```python\n# Phase 1: create every node and receive its runtime ID\nschema = create_task(\"setup database schema\")\nendpoints = create_task(\"create API endpoints\")\ntests = create_task(\"write tests\")\ndocs = create_task(\"write docs\")\n\n# Phase 2: add edges using those returned IDs\nupdate_task(endpoints.id, addBlockedBy=[schema.id])\nupdate_task(tests.id, addBlockedBy=[endpoints.id])\nupdate_task(docs.id, addBlockedBy=[schema.id])\n\n# Agent claims the first available task\nclaim_task(schema.id) # ✓ Claimed (no dependencies)\ncomplete_task(schema.id) # ✓ Completed → unblocks endpoints, docs\n\nclaim_task(endpoints.id) # ✓ Claimed (schema completed)\ncomplete_task(endpoints.id) # ✓ Completed → unblocks tests\n\nclaim_task(docs.id) # ✓ Claimed (schema completed)\ncomplete_task(docs.id) # ✓ Completed\n\nclaim_task(tests.id) # ✓ Claimed (endpoints completed)\ncomplete_task(tests.id) # ✓ Completed\n```\n\nEach `create_task` writes a JSON file; `update_task`, `claim_task`, and `complete_task` update it. Across sessions, the `.tasks/` directory persists — the agent reads the files to recover progress.\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s10_task_system/code.py\n```\n\nTry these prompts:\n\n1. `Create tasks: setup database schema, create API endpoints (depends on schema), write tests (depends on endpoints), write docs (depends on schema)`\n2. `List all tasks and their statuses`\n3. `Claim the first unblocked task and complete it`\n4. `List tasks again — which ones are now unblocked?`\n\nWhat to observe: Are JSON files generated in the `.tasks/` directory? After completing a task, are the blocked tasks unblocked?\n\n---\n\n## What's Next\n\nThe task graph is in place, but full test suites, dependency installation, and deployment commands can take a long time. When these commands run synchronously, the Agent Loop remains blocked in the current tool call and cannot continue until the command finishes.\n\ns11 Background Tasks → Slow operations run in the background. The Agent Loop can continue processing other tasks and receives a notification when the background work finishes.\n\n\n<!-- translation-sync: zh@v5, en@v5, ja@v5 -->\n"
|
||
},
|
||
{
|
||
"version": "s10",
|
||
"locale": "zh",
|
||
"title": "s10: Task System — 从执行清单到可协调的任务状态",
|
||
"content": "# s10: Task System — 从执行清单到可协调的任务状态\n\ns01 → ... → s08 → s09 → `s10` → [s11](/zh/s11) → s12 → ... → s16 → s17\n\n> *\"大目标拆成小任务, 排好序, 持久化\"* — 文件持久化的任务图, 多 agent 协作的基础。\n>\n> **Harness 层**: 任务 — 持久化的目标, 可恢复的进度。\n\n---\n\n## 问题\n\ns05 的 TodoWrite 让 Agent 记录当前任务的执行步骤。清单中的每一项只有内容和状态,用来提醒 Agent 接下来还要做什么。\n\n当项目被拆成创建数据库表、编写 API 和添加测试三个任务时,Harness 还需要知道它们之间的关系:数据库表完成后才能编写 API,API 接口确定后才能添加测试。每个任务还要记录由谁负责。\n\nTodoWrite 没有记录这些依赖和分工。它可以显示“编写 API”仍未完成,但 Harness 无法据此判断这个任务是否可以开始。\n\n本章加入 Task System。每个任务都有独立的 ID 和状态,`blockedBy` 记录前置任务,`owner` 记录负责执行的 Agent。\n\n---\n\n## 解决方案\n\n\n\n代码保留 S04 的五个基础工具、Permission、Hooks 和统一 `execute_tool`,再加入 6 个任务工具、`.tasks/` 目录持久化和 `blockedBy` 依赖检查。\n\nTodoWrite vs Task System:\n\n| | TodoWrite (s05) | Task System (s10) |\n|---|---|---|\n| 定位 | 当前任务的执行清单 | 可恢复的任务系统 |\n| 存储 | 进程内 / 会话状态 | `.tasks/{id}.json` |\n| 依赖 | 无 | `blockedBy` 依赖图 |\n| 生命周期 | 当前会话 / 当前任务 | 跨会话保留 |\n| 分工 | 不负责任务认领 | `owner` / claim |\n| 状态 | pending / in_progress / completed | pending / in_progress / completed |\n| 粒度 | Agent 自己的步骤 | 可被认领、追踪、解锁的任务 |\n| 更新契约 | 整表替换 | 对单条记录执行创建、读取、更新、列举 |\n\n---\n\n## 工作原理\n\n\n\n### Task: 数据结构\n\n每个任务是一个 JSON 文件,存于 `.tasks/` 目录:\n\n```python\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str # pending | in_progress | completed\n owner: str | None # 负责当前任务的 Agent\n blockedBy: list[str] # 依赖的任务 ID 列表\n```\n\nID 使用 `task_` 加 8 位随机十六进制字符生成。创建文件时使用排他写入;如果 ID 已存在,就重新生成。\n\n`TaskStore` 负责校验任务 ID 和读写 JSON 文件,`TASKS = TaskStore(TASKS_DIR)` 是本章使用的任务存储。\n\n### create_task: 创建任务\n\n```python\ndef create_task(subject: str, description: str = \"\") -> Task:\n return TASKS.create(subject, description)\n```\n\n`TaskStore.create` 检查 subject,分配随机 ID,再把任务写入 `.tasks/{id}.json`。新任务的 `blockedBy` 固定为空,工具结果会把运行时生成的 ID 返回给模型。\n\n### update_task: 使用返回的 ID 添加依赖\n\n```python\ndef update_task(task_id: str, addBlockedBy: list[str]) -> Task:\n return TASKS.update_dependencies(task_id, addBlockedBy)\n```\n\n任务图采用两阶段构建:先创建所有节点,再使用 `create_task` 返回的 ID 调用 `update_task` 添加边。模型可能在一条回复里同时发出多个工具调用,而这些同级调用在任何工具结果产生前就已经确定,因此某个 `create_task` 无法直接使用另一个调用刚生成的 ID。\n\n`update_task` 会先校验整次修改,再统一保存。目标任务和依赖必须存在,目标必须仍为 pending 且无人认领,并且不能形成自依赖或环。重复添加已有依赖是安全的,不会产生重复边。\n\n### can_start: 依赖检查\n\n一个任务只能在它的 `blockedBy` **全部 completed** 之后才能开始:\n\n```python\ndef can_start(task_id: str) -> bool:\n return not incomplete_dependencies(load_task(task_id))\n```\n\n`incomplete_dependencies` 读取每个前置任务。只要有一个不是 completed,或者对应文件已经不存在,任务就不能认领。\n\n### claim_task: 认领任务\n\nAgent 开始做一个任务时,调用 `claim_task`:设置 `owner`,状态从 `pending` → `in_progress`。`owner` 字段记录谁认领了这个任务:\n\n```python\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n dependencies = incomplete_dependencies(task)\n if dependencies:\n return f\"Blocked by: {dependencies}\"\n task.owner = owner\n task.status = \"in_progress\"\n TASKS.save(task)\n return f\"Claimed {task_id} ({task.subject})\"\n```\n\n如果任务不是 pending,或者依赖没有完成,就拒绝认领。S10 只处理顺序执行的状态更新。\n\n### complete_task: 完成与解锁\n\n任务做完后,设为 `completed`。同时扫描所有其他任务,找出**刚刚被解锁**的下游任务:\n\n```python\ndef complete_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"in_progress\":\n return f\"Task {task_id} is {task.status}, cannot complete\"\n if task.owner != owner:\n return f\"Task {task_id} is owned by {task.owner}, not {owner}\"\n ready_before = {t.id for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy\n and can_start(t.id)}\n task.status = \"completed\"\n TASKS.save(task)\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy\n and t.id not in ready_before\n and can_start(t.id)]\n msg = f\"Completed {task_id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n return msg\n```\n\n完成 \"schema\" 后,\"endpoints\" 和 \"docs\" 的 `can_start` 返回 True,它们可以开始。\n\n### get_task: 查看完整细节\n\n`list_tasks` 只显示一行摘要。`get_task` 返回完整的任务 JSON,包括 description 和依赖细节。跨会话恢复时,Agent 需要读取完整描述才能继续工作:\n\n```python\ndef get_task(task_id: str) -> str:\n task = load_task(task_id)\n return json.dumps(asdict(task), indent=2)\n```\n\n### 状态机: 两个动作,三个状态\n\n```\npending ──claim──→ in_progress ──complete──→ completed\n```\n\n这里的 `claim` / `complete` 是动作,`pending` / `in_progress` / `completed` 是状态:\n\n- **claim_task**: `pending` → `in_progress`。设置 owner,开始工作。\n- **complete_task**: `in_progress` → `completed`。把任务标记为完成,并解锁下游。\n\n### 合起来跑\n\n```python\n# 第一阶段:创建所有节点并取得运行时 ID\nschema = create_task(\"setup database schema\")\nendpoints = create_task(\"create API endpoints\")\ntests = create_task(\"write tests\")\ndocs = create_task(\"write docs\")\n\n# 第二阶段:使用返回的 ID 建立依赖边\nupdate_task(endpoints.id, addBlockedBy=[schema.id])\nupdate_task(tests.id, addBlockedBy=[endpoints.id])\nupdate_task(docs.id, addBlockedBy=[schema.id])\n\n# Agent 认领第一个可做的任务\nclaim_task(schema.id) # ✓ Claimed (无依赖)\ncomplete_task(schema.id) # ✓ Completed → 解锁 endpoints, docs\n\nclaim_task(endpoints.id) # ✓ Claimed (schema 已完成)\ncomplete_task(endpoints.id) # ✓ Completed → 解锁 tests\n\nclaim_task(docs.id) # ✓ Claimed (schema 已完成)\ncomplete_task(docs.id) # ✓ Completed\n\nclaim_task(tests.id) # ✓ Claimed (endpoints 已完成)\ncomplete_task(tests.id) # ✓ Completed\n```\n\n每个 `create_task` 写一个 JSON 文件,`update_task`、`claim_task` 和 `complete_task` 更新文件。跨会话时,`.tasks/` 目录还在,Agent 读文件就能恢复进度。\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s10_task_system/code.py\n```\n\n试试这些 prompt:\n\n1. `Create tasks: setup database schema, create API endpoints (depends on schema), write tests (depends on endpoints), write docs (depends on schema)`\n2. `List all tasks and their statuses`\n3. `Claim the first unblocked task and complete it`\n4. `List tasks again — which ones are now unblocked?`\n\n观察重点:`.tasks/` 目录下是否生成了 JSON 文件?完成任务后,被阻塞的任务是否解锁?\n\n---\n\n## 接下来\n\n任务图有了,但全量测试、安装依赖和部署等命令可能需要很长时间。同步执行这些命令时,Agent Loop 会一直停在当前工具调用上,只有命令结束后才能继续处理其他工作。\n\ns11 Background Tasks → 把慢操作放到后台。Agent 可以继续处理其他任务,后台执行完成后再接收通知。\n\n\n<!-- translation-sync: zh@v5, en@v5, ja@v5 -->\n"
|
||
},
|
||
{
|
||
"version": "s10",
|
||
"locale": "ja",
|
||
"title": "s10: Task System — 実行チェックリストから協調できるタスク状態へ",
|
||
"content": "# s10: Task System — 実行チェックリストから協調できるタスク状態へ\n\ns01 → ... → s08 → s09 → `s10` → [s11](/ja/s11) → s12 → ... → s16 → s17\n\n> *\"大きな目標を小さなタスクに分け、順序付け、永続化\"* — ファイル永続化タスクグラフ、マルチ Agent 協調の基盤。\n>\n> **Harness 層**: タスク — 永続化された目標、復旧可能な進捗。\n\n---\n\n## 課題\n\ns05 の TodoWrite は、Agent が現在のタスクの実行手順を記録するためのものだ。各項目には内容と状態があり、次に何をするべきかを確認できる。\n\nプロジェクトをデータベーステーブルの作成、API の実装、テストの追加という 3 つのタスクに分ける場合、Harness はそれらの関係も把握する必要がある。API はデータベーステーブルの完成を待ち、テストは API の仕様が確定するまで待たなければならない。各タスクの担当者も記録する必要がある。\n\nTodoWrite は、こうした依存関係や担当を記録しない。「API を実装する」が未完了であることは示せても、そのタスクを開始できるかどうかを Harness が判断することはできない。\n\nこの章では Task System を追加する。各タスクは個別の ID と状態を持ち、`blockedBy` が前提タスクを、`owner` が担当する Agent を記録する。\n\n---\n\n## ソリューション\n\n\n\nコードは S04 の 5 つの基本ツール、Permission、Hooks、共通の `execute_tool` を保ち、そこへ 6 つのタスクツール、`.tasks/` ディレクトリへの永続化、`blockedBy` の依存チェックを追加する。\n\nTodoWrite vs Task System:\n\n| | TodoWrite (s05) | Task System (s10) |\n|---|---|---|\n| 位置づけ | 現在のタスクの実行チェックリスト | 復旧可能なタスクシステム |\n| ストレージ | プロセス内 / セッション状態 | `.tasks/{id}.json` |\n| 依存関係 | なし | `blockedBy` 依存グラフ |\n| ライフサイクル | 現在のセッション / 現在のタスク | セッション横断 |\n| 分担 | タスクの引き受けなし | `owner` / claim |\n| ステータス | pending / in_progress / completed | pending / in_progress / completed |\n| 粒度 | Agent 自身の手順 | 引き受け・追跡・アンロックできるタスク |\n| 更新契約 | リスト全体を置換 | 個別レコードを作成・取得・更新・一覧 |\n\n---\n\n## 仕組み\n\n\n\n### Task: データ構造\n\n各タスクは JSON ファイル、`.tasks/` ディレクトリに保存:\n\n```python\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str # pending | in_progress | completed\n owner: str | None # このタスクを担当する Agent\n blockedBy: list[str] # 依存タスク ID のリスト\n```\n\nID は `task_` と 8 桁のランダムな 16 進文字で生成する。ファイルは排他的に作成し、同じ ID が存在する場合は生成し直す。\n\n`TaskStore` はタスク ID を検証し、JSON ファイルを読み書きする。`TASKS = TaskStore(TASKS_DIR)` がこの章で使うタスクストアである。\n\n### create_task: タスク作成\n\n```python\ndef create_task(subject: str, description: str = \"\") -> Task:\n return TASKS.create(subject, description)\n```\n\n`TaskStore.create` は subject を確認し、ランダム ID を割り当てて `.tasks/{id}.json` に書き込む。新しいタスクの `blockedBy` は常に空で、ツール結果が実行時に生成された ID をモデルへ返す。\n\n### update_task: 返された ID で依存を追加\n\n```python\ndef update_task(task_id: str, addBlockedBy: list[str]) -> Task:\n return TASKS.update_dependencies(task_id, addBlockedBy)\n```\n\nタスクグラフは 2 段階で構築する。まず全ノードを作成し、その後 `create_task` が返した ID を使って `update_task` で辺を追加する。モデルが 1 回の応答で複数のツール呼び出しを出す場合、同じ階層の呼び出しはツール結果が返る前にすべて確定するため、ある `create_task` は別の呼び出しで生成されたばかりの ID を利用できない。\n\n`update_task` は変更全体を検証してから保存する。対象と依存タスクは存在し、対象は pending かつ未所有でなければならず、自己依存や循環も禁止する。既存の辺を再度追加しても重複しない。\n\n### can_start: 依存チェック\n\nタスクは `blockedBy` が**すべて completed** になってからでないと開始できない:\n\n```python\ndef can_start(task_id: str) -> bool:\n return not incomplete_dependencies(load_task(task_id))\n```\n\n`incomplete_dependencies` は各前提タスクを読み込む。completed でないタスクや、ファイルが存在しないタスクが一つでもあれば引き受けられない。\n\n### claim_task: タスクを引き受ける\n\nAgent がタスクに取り掛かる時、`claim_task` を呼び出し、`owner` を設定してステータスを `pending` → `in_progress` に変更する。`owner` フィールドは誰がタスクを引き受けたかを記録する:\n\n```python\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n dependencies = incomplete_dependencies(task)\n if dependencies:\n return f\"Blocked by: {dependencies}\"\n task.owner = owner\n task.status = \"in_progress\"\n TASKS.save(task)\n return f\"Claimed {task_id} ({task.subject})\"\n```\n\nタスクが pending でない場合や、依存が未完了の場合は引き受けを拒否する。S10 はタスクの状態を順番に更新する。\n\n### complete_task: 完了とアンロック\n\nタスク完了後、`completed` に設定。同時に他の全タスクを走査し、**直前にアンロックされた**下流タスクを特定:\n\n```python\ndef complete_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"in_progress\":\n return f\"Task {task_id} is {task.status}, cannot complete\"\n if task.owner != owner:\n return f\"Task {task_id} is owned by {task.owner}, not {owner}\"\n ready_before = {t.id for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy\n and can_start(t.id)}\n task.status = \"completed\"\n TASKS.save(task)\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy\n and t.id not in ready_before\n and can_start(t.id)]\n msg = f\"Completed {task_id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n return msg\n```\n\n\"schema\" 完了後、\"endpoints\" と \"docs\" の `can_start` が True を返し、開始可能になる。\n\n### get_task: 完全な詳細を確認\n\n`list_tasks` は 1 行サマリのみ表示。`get_task` は description と依存関係の詳細を含む完全なタスク JSON を返す。セッションをまたいで復旧する際、Agent は完全な説明を読んで作業を継続する必要がある:\n\n```python\ndef get_task(task_id: str) -> str:\n task = load_task(task_id)\n return json.dumps(asdict(task), indent=2)\n```\n\n### 状態マシン: 2 つのアクション、3 つの状態\n\n```\npending ──claim──→ in_progress ──complete──→ completed\n```\n\nここで `claim` / `complete` はアクション、`pending` / `in_progress` / `completed` は状態:\n\n- **claim_task**: `pending` → `in_progress`。owner を設定し、作業を開始。\n- **complete_task**: `in_progress` → `completed`。タスクを完了済みにし、下流をアンロック。\n\n### 組み合わせて実行\n\n```python\n# 第 1 段階:全ノードを作成して実行時 ID を受け取る\nschema = create_task(\"setup database schema\")\nendpoints = create_task(\"create API endpoints\")\ntests = create_task(\"write tests\")\ndocs = create_task(\"write docs\")\n\n# 第 2 段階:返された ID で依存の辺を追加する\nupdate_task(endpoints.id, addBlockedBy=[schema.id])\nupdate_task(tests.id, addBlockedBy=[endpoints.id])\nupdate_task(docs.id, addBlockedBy=[schema.id])\n\n# Agent が最初に実行可能なタスクを引き受ける\nclaim_task(schema.id) # ✓ Claimed(依存なし)\ncomplete_task(schema.id) # ✓ Completed → endpoints, docs をアンロック\n\nclaim_task(endpoints.id) # ✓ Claimed(schema 完了済み)\ncomplete_task(endpoints.id) # ✓ Completed → tests をアンロック\n\nclaim_task(docs.id) # ✓ Claimed(schema 完了済み)\ncomplete_task(docs.id) # ✓ Completed\n\nclaim_task(tests.id) # ✓ Claimed(endpoints 完了済み)\ncomplete_task(tests.id) # ✓ Completed\n```\n\n各 `create_task` が JSON ファイルを書き込み、`update_task`、`claim_task`、`complete_task` がファイルを更新する。セッションをまたいでも `.tasks/` ディレクトリが残り、Agent はファイルを読んで進捗を復旧できる。\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s10_task_system/code.py\n```\n\n以下のプロンプトを試してください:\n\n1. `Create tasks: setup database schema, create API endpoints (depends on schema), write tests (depends on endpoints), write docs (depends on schema)`\n2. `List all tasks and their statuses`\n3. `Claim the first unblocked task and complete it`\n4. `List tasks again — which ones are now unblocked?`\n\n観察ポイント:`.tasks/` ディレクトリに JSON ファイルが生成されているか?タスク完了後、ブロックされていたタスクがアンロックされているか?\n\n---\n\n## 次の章\n\nタスクグラフができても、全テストの実行、依存関係のインストール、デプロイなどのコマンドには長い時間がかかることがある。これらのコマンドを同期実行すると、Agent Loop は現在のツール呼び出しでブロックされ、コマンドが終了するまで他の処理を続けられない。\n\ns11 Background Tasks → 遅い操作をバックグラウンドで実行する。Agent は他のタスクの処理を続け、バックグラウンド処理の完了後に通知を受け取る。\n\n\n<!-- translation-sync: zh@v5, en@v5, ja@v5 -->\n"
|
||
},
|
||
{
|
||
"version": "s11",
|
||
"locale": "en",
|
||
"title": "s11: Background Tasks — Slow Operations Go to the Background",
|
||
"content": "# s11: Background Tasks — Slow Operations Go to the Background\n\ns01 → ... → s09 → s10 → `s11` → [s12](/en/s12) → s13 → ... → s16 → s17\n\n> *\"Slow operations go to the background, the Agent Loop continues\"* — Background threads run commands, and later turns collect completed results.\n>\n> **Harness Layer**: Background — Async execution, doesn't block the main loop.\n\n---\n\n## The Problem\n\nReading a file or running `git status` usually returns quickly, so synchronous execution causes little noticeable delay. Installing dependencies, running a full test suite, or building a project can take several minutes. Until the command returns, the Harness cannot process the next tool call in the current response or start the next model turn.\n\nIf later work does not depend on that command, there is no need to block it. For example, after starting a full test suite, the Agent could inspect documentation or organize other files while the tests run.\n\nS11 addresses this by running slow Bash commands in the background, allowing the Agent Loop to continue and collect completed results on a later turn.\n\n---\n\n## The Solution\n\n\n\nThis chapter sends slow operations to background threads. The current tool call first returns a placeholder `tool_result`, allowing the Agent Loop to continue. At the start of a later turn, completed results are collected and added to the conversation as notifications.\n\nSync vs Background:\n\n| | Sync (s04) | Background (s11) |\n|---|---|---|\n| Slow operations | Current tool call blocks | Background thread executes |\n| Agent Loop | Waits for the command to return | Continues after the placeholder result |\n| Result | Returned after the command finishes | Returns `bg_id` first; collects the result on a later turn |\n| Decision criteria | — | bash `run_in_background` parameter |\n\n---\n\n## How It Works\n\n### should_run_background: Explicit Request\n\nThe model requests background execution through the bash tool's `run_in_background` parameter. Only bash calls with the parameter explicitly set to `true` enter this path. Other calls still run synchronously.\n\n```python\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n return (\n tool_name == \"bash\"\n and tool_input.get(\"run_in_background\") is True\n )\n```\n\nThe Harness no longer guesses from keywords such as `install`, `build`, or `test`. The tool call chooses the execution mode explicitly.\n\n### BackgroundManager: Background Execution and Lifecycle\n\n`BackgroundManager` owns task state and the completion queue. `start()` registers a task, starts a daemon thread, and returns `bg_id` immediately:\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self.results = {}\n self._ready = []\n self._lock = threading.Lock()\n\n def start(self, block) -> str:\n # Register task, then run _run() in a daemon thread.\n ...\n\n def _run(self, task_id: str, command: str):\n output, exit_code = _run_bash_process(command)\n status = \"completed\" if exit_code == 0 else \"failed\"\n with self._lock:\n self.tasks[task_id][\"status\"] = status\n self.results[task_id] = _format_bash_result(output, exit_code)\n self._ready.append(task_id)\n```\n\nA non-zero exit code or worker exception becomes `failed`. The shell starts in its own process group. When the command finishes, times out, or the Agent exits through the normal or `SIGTERM` path, the runtime stops that original group. This is lifecycle cleanup, not a sandbox: a process that creates another session can leave the group.\n\n### collect_background_results: Notification Collection\n\nAt the start of a later turn, `collect()` removes completed results from the queue and formats them as `<task_notification>` messages:\n\n```python\ndef collect_background_results() -> list[str]:\n return BACKGROUND.collect()\n```\n\nNotifications don't reuse the original `tool_use_id`. The original tool call was already answered with a placeholder `tool_result`; when the completed result is collected, it is added as an independent event in `task_notification` format. One `tool_use` still gets exactly one `tool_result`.\n\n### Loop Integration\n\nBefore each LLM call, the Agent Loop collects completed background results. `execute_tool()` still runs `PreToolUse` on the main thread before choosing synchronous or background execution:\n\n```python\nwhile True:\n inject_background_results(messages)\n response = client.messages.create(...)\n\ndef execute_tool(block) -> str:\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked is not None:\n return str(blocked)\n if should_run_background(block.name, block.input):\n task_id = start_background_task(block)\n output = f\"[Background task {task_id} started]\"\n else:\n output = call_tool(block)\n trigger_hooks(\"PostToolUse\", block, output)\n return output\n```\n\nSlow operations first return a placeholder tool_result with `bg_id`. A completed task does not wake the Agent by itself; `inject_background_results()` collects it the next time the Agent Loop runs.\n\n### Putting It Together\n\n```\nTurn 1:\n LLM → bash \"npm install\" (run_in_background=true)\n → start_background_task → bg_0001\n → tool_result: \"[Background task bg_0001 started]...\"\n → LLM: \"OK, I'll check later. Let me also read the config.\"\n\nTurn 2:\n LLM → read_file \"package.json\" (fast, sync)\n → tool_result: file content\n\nTurn 3:\n → collect bg_0001 as <task_notification>\n → LLM sees: config file + install notification in one message\n```\n\nWhile npm install ran in the background, the Agent Loop continued with read_file.\n\n---\n\n## What s11 Adds\n\n| Component | s04 Kernel | s11 |\n|-----------|-------------|-------------|\n| Execution model | All synchronous | Slow ops to background thread + notification injection |\n| bash schema | `command` | `command` + `run_in_background` |\n| New functions | — | `should_run_background`, `start_background_task`, `collect_background_results`, `inject_background_results` |\n| New types | — | `BackgroundManager` |\n| Notification format | — | `<task_notification>` (doesn't reuse tool_use_id) |\n| Loop behavior | Tools execute synchronously | Explicit background execution, completed results collected on later turns |\n| Tools | 5 | 5 (one parameter added to the bash schema) |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s11_background_tasks/code.py\n```\n\nTry these prompts:\n\n1. `Run pip list in the background and find all Python files in this directory`\n2. `Run npm install (use run_in_background) and while waiting, read package.json`\n3. `Run a short sleep in the background, then list all Markdown files`\n\nWhat to observe: After explicitly setting `run_in_background`, is the command dispatched to the background? Is a `bg_id` returned? Are completed results collected in `<task_notification>` format on a later turn?\n\n---\n\n## What's Next\n\nBackground tasks solved \"slow operations don't block.\" But what if you want to do something on a schedule? Like \"run tests every morning at 9am\" or \"check server status every 5 minutes.\"\n\ns12 Cron Scheduler → Give the agent an alarm clock.\n\n\n<!-- translation-sync: zh@v7, en@v7, ja@v7 -->\n"
|
||
},
|
||
{
|
||
"version": "s11",
|
||
"locale": "zh",
|
||
"title": "s11: Background Tasks — 慢操作放后台",
|
||
"content": "# s11: Background Tasks — 慢操作放后台\n\ns01 → ... → s09 → s10 → `s11` → [s12](/zh/s12) → s13 → ... → s16 → s17\n\n> *\"慢操作放后台,Agent Loop 继续运行\"* — 后台线程执行命令,后续轮次收集完成结果。\n>\n> **Harness 层**: 后台 — 异步执行, 不阻塞主循环。\n\n---\n\n## 问题\n\n读取文件或运行 `git status` 通常很快,同步执行时等待并不明显。但安装依赖、执行完整测试或构建项目可能持续几分钟。在命令返回前,Harness 无法处理当前响应中的下一个工具调用,也不能进入下一轮。\n\n如果后续工作并不依赖这个命令,继续等待就没有必要。例如,Agent 启动完整测试后,本来还可以检查文档或整理其他文件,但同步执行会让整个 Agent Loop 停在这次 Bash 调用上。\n\nS11 要解决的问题是:让耗时的 Bash 命令在后台执行,使 Agent Loop 可以继续处理其他工作,并在后续轮次收集完成结果。\n\n---\n\n## 解决方案\n\n\n\n本章把慢操作放入后台线程。当前工具调用先返回一个占位 `tool_result`,Agent Loop 可以继续运行;后续轮次开始时再收集已经完成的结果,以通知形式加入对话。\n\n同步 vs 后台:\n\n| | 同步 (s04) | 后台 (s11) |\n|---|---|---|\n| 慢操作 | 当前工具调用被阻塞 | 后台线程执行 |\n| Agent Loop | 等待命令返回 | 收到占位结果后继续运行 |\n| 结果 | 命令结束后返回 | 先返回 `bg_id`,后续轮次收集结果 |\n| 判断标准 | — | bash 的 `run_in_background` 参数 |\n\n---\n\n## 工作原理\n\n### should_run_background: 显式请求\n\n模型通过 bash 工具的 `run_in_background` 参数请求后台执行。只有参数明确为 `true`,并且工具是 bash 时,才会进入后台执行路径。其他调用仍然同步执行。\n\n```python\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n return (\n tool_name == \"bash\"\n and tool_input.get(\"run_in_background\") is True\n )\n```\n\n不再根据 `install`、`build` 或 `test` 等关键词猜测。是否进入后台由工具调用明确决定。\n\n### BackgroundManager: 后台执行与生命周期\n\n`BackgroundManager` 保存任务状态和完成队列。`start()` 先登记任务,再启动 daemon 线程,并立即返回 `bg_id`:\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self.results = {}\n self._ready = []\n self._lock = threading.Lock()\n\n def start(self, block) -> str:\n # Register task, then run _run() in a daemon thread.\n ...\n\n def _run(self, task_id: str, command: str):\n output, exit_code = _run_bash_process(command)\n status = \"completed\" if exit_code == 0 else \"failed\"\n with self._lock:\n self.tasks[task_id][\"status\"] = status\n self.results[task_id] = _format_bash_result(output, exit_code)\n self._ready.append(task_id)\n```\n\n命令以非零状态退出或 worker 抛出异常时,任务会进入 `failed`。Shell 会在独立的进程组中启动;命令完成、超时,或 Agent 经正常路径、`SIGTERM` 退出时,运行时会停止原进程组。这只是生命周期清理,并不是沙箱;另建 session 的进程仍可能离开该进程组。\n\n### collect_background_results: 通知收集\n\n后续轮次开始时,`collect()` 从完成队列中取出结果,并格式化为 `<task_notification>` 通知:\n\n```python\ndef collect_background_results() -> list[str]:\n return BACKGROUND.collect()\n```\n\n通知不复用原始 `tool_use_id`。原始 tool call 已经用占位 `tool_result` 回复了;后续收集完成结果时,会用 `task_notification` 格式把它作为独立事件加入对话。一个 `tool_use` 仍然只对应一个 `tool_result`。\n\n### 循环中的集成\n\n每次调用 LLM 前,Agent Loop 先收集已经完成的后台结果。`execute_tool()` 仍然在主线程执行 `PreToolUse`,然后再选择同步或后台执行:\n\n```python\nwhile True:\n inject_background_results(messages)\n response = client.messages.create(...)\n\ndef execute_tool(block) -> str:\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked is not None:\n return str(blocked)\n if should_run_background(block.name, block.input):\n task_id = start_background_task(block)\n output = f\"[Background task {task_id} started]\"\n else:\n output = call_tool(block)\n trigger_hooks(\"PostToolUse\", block, output)\n return output\n```\n\n慢操作先返回一个带 `bg_id` 的占位 tool_result。后台结果不会主动唤醒 Agent;下一次进入 Agent Loop 时,`inject_background_results()` 才会收集已经完成的结果。\n\n### 合起来跑\n\n```\nTurn 1:\n LLM → bash \"npm install\" (run_in_background=true)\n → start_background_task → bg_0001\n → tool_result: \"[Background task bg_0001 started]...\"\n → LLM: \"OK, I'll check later. Let me also read the config.\"\n\nTurn 2:\n LLM → read_file \"package.json\" (fast, sync)\n → tool_result: file content\n\nTurn 3:\n → collect bg_0001 as <task_notification>\n → LLM sees: config file + install notification in one message\n```\n\nnpm install 在后台运行时,Agent Loop 继续执行了 read_file。\n\n---\n\n## 本章新增了什么\n\n| 组件 | S04 Kernel | S11 |\n|------|-----------|-----------|\n| 执行模型 | 全部同步 | 慢操作后台线程 + 通知注入 |\n| bash schema | `command` | `command` + `run_in_background` |\n| 新函数 | — | `should_run_background`, `start_background_task`, `collect_background_results`, `inject_background_results` |\n| 新类型 | — | `BackgroundManager` |\n| 通知格式 | — | `<task_notification>`(不复用 tool_use_id) |\n| 循环行为 | 工具同步执行 | 显式后台执行,后续轮次收集完成结果 |\n| 工具 | 5 | 5(bash schema 增加一个参数) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s11_background_tasks/code.py\n```\n\n试试这些 prompt:\n\n1. `Run pip list in the background and find all Python files in this directory`\n2. `Run npm install (use run_in_background) and while waiting, read package.json`\n3. `Run a short sleep in the background, then list all Markdown files`\n\n观察重点:显式设置 `run_in_background` 后,命令有没有被送到后台?`bg_id` 是否返回?后续轮次有没有以 `<task_notification>` 格式收集完成结果?\n\n---\n\n## 接下来\n\n后台任务解决了\"慢操作不阻塞\"。但如果想定时做某件事呢?比如\"每天早上 9 点跑测试\"、\"每 5 分钟检查一次服务器状态\"。\n\ns12 Cron Scheduler → 给 Agent 装一个闹钟。\n\n\n<!-- translation-sync: zh@v7, en@v7, ja@v7 -->\n"
|
||
},
|
||
{
|
||
"version": "s11",
|
||
"locale": "ja",
|
||
"title": "s11: Background Tasks — 遅い操作はバックグラウンドへ",
|
||
"content": "# s11: Background Tasks — 遅い操作はバックグラウンドへ\n\ns01 → ... → s09 → s10 → `s11` → [s12](/ja/s12) → s13 → ... → s16 → s17\n\n> *\"遅い操作はバックグラウンドへ、Agent Loop は処理を継続\"* — バックグラウンドスレッドでコマンドを実行し、後続のターンで完了結果を収集する。\n>\n> **Harness 層**: バックグラウンド — 非同期実行、メインループをブロックしない。\n\n---\n\n## 課題\n\nファイルの読み込みや `git status` は通常すぐに返るため、同期実行でも待ち時間はほとんど気にならない。しかし、依存関係のインストール、全テストの実行、プロジェクトのビルドには数分かかることがある。コマンドが返るまで、Harness は現在のレスポンスに含まれる次のツール呼び出しを処理できず、次のターンにも進めない。\n\n後続の作業がそのコマンドに依存しないなら、終了まで待つ必要はない。例えば全テストを開始した後も、テストの実行中にドキュメントを確認したり、別のファイルを整理したりできる。\n\nS11 では、時間のかかる Bash コマンドをバックグラウンドで実行し、Agent Loop が他の作業を続けられるようにする。完了結果は後続のターンで収集する。\n\n---\n\n## ソリューション\n\n\n\nこの章では、時間のかかる操作をバックグラウンドスレッドに送る。現在のツール呼び出しはまずプレースホルダー `tool_result` を返すため、Agent Loop は処理を続けられる。後続のターンの開始時に完了済みの結果を収集し、通知として会話に追加する。\n\n同期 vs バックグラウンド:\n\n| | 同期 (s04) | バックグラウンド (s11) |\n|---|---|---|\n| 遅い操作 | 現在のツール呼び出しがブロックされる | バックグラウンドスレッドで実行 |\n| Agent Loop | コマンドの返却を待つ | プレースホルダー結果を受け取って続行 |\n| 結果 | コマンド終了後に返す | 先に `bg_id` を返し、後続のターンで結果を収集 |\n| 判断基準 | — | bash の `run_in_background` パラメータ |\n\n---\n\n## 仕組み\n\n### should_run_background: 明示的リクエスト\n\nモデルは bash ツールの `run_in_background` パラメータでバックグラウンド実行をリクエストする。ツールが bash で、パラメータが明示的に `true` の場合だけ、この経路に入る。他の呼び出しは同期実行を続ける:\n\n```python\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n return (\n tool_name == \"bash\"\n and tool_input.get(\"run_in_background\") is True\n )\n```\n\n`install`、`build`、`test` などのキーワードから推測しない。実行方法はツール呼び出しが明示的に選ぶ。\n\n### BackgroundManager: バックグラウンド実行とライフサイクル\n\n`BackgroundManager` がタスク状態と完了キューを保持する。`start()` はタスクを登録して daemon スレッドを起動し、すぐに `bg_id` を返す:\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self.results = {}\n self._ready = []\n self._lock = threading.Lock()\n\n def start(self, block) -> str:\n # Register task, then run _run() in a daemon thread.\n ...\n\n def _run(self, task_id: str, command: str):\n output, exit_code = _run_bash_process(command)\n status = \"completed\" if exit_code == 0 else \"failed\"\n with self._lock:\n self.tasks[task_id][\"status\"] = status\n self.results[task_id] = _format_bash_result(output, exit_code)\n self._ready.append(task_id)\n```\n\ncommand が非ゼロで終了した場合や worker で例外が起きた場合は `failed` となる。Shell は独立した process group で起動し、command の完了、timeout、または Agent が通常経路や `SIGTERM` で終了する時に元の group を停止する。これは lifecycle cleanup であって sandbox ではなく、別の session を作った process は group から離れられる。\n\n### collect_background_results: 通知収集\n\n後続のターンの開始時に、`collect()` が完了キューから結果を取り出し、`<task_notification>` メッセージとしてフォーマットする:\n\n```python\ndef collect_background_results() -> list[str]:\n return BACKGROUND.collect()\n```\n\n通知は元の `tool_use_id` を再利用しない。元のツール呼び出しはプレースホルダー `tool_result` で応答済みであり、完了結果を収集した時点で `task_notification` 形式の独立したイベントとして会話に追加する。1 つの `tool_use` に対応する `tool_result` は 1 つのままである。\n\n### ループ統合\n\n各 LLM 呼び出しの前に、Agent Loop は完了済みのバックグラウンド結果を収集する。`execute_tool()` は引き続きメインスレッドで `PreToolUse` を実行し、その後で同期実行かバックグラウンド実行かを選ぶ:\n\n```python\nwhile True:\n inject_background_results(messages)\n response = client.messages.create(...)\n\ndef execute_tool(block) -> str:\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked is not None:\n return str(blocked)\n if should_run_background(block.name, block.input):\n task_id = start_background_task(block)\n output = f\"[Background task {task_id} started]\"\n else:\n output = call_tool(block)\n trigger_hooks(\"PostToolUse\", block, output)\n return output\n```\n\n遅い操作はまず `bg_id` 付きプレースホルダー tool_result を返す。バックグラウンドタスクの完了だけでは Agent は起動せず、次に Agent Loop が動く時に `inject_background_results()` が結果を収集する。\n\n### 組み合わせて実行\n\n```\nTurn 1:\n LLM → bash \"npm install\" (run_in_background=true)\n → start_background_task → bg_0001\n → tool_result: \"[Background task bg_0001 started]...\"\n → LLM: \"OK, I'll check later. Let me also read the config.\"\n\nTurn 2:\n LLM → read_file \"package.json\" (fast, sync)\n → tool_result: file content\n\nTurn 3:\n → collect bg_0001 as <task_notification>\n → LLM sees: config file + install notification in one message\n```\n\nnpm install がバックグラウンドで実行されている間、Agent Loop は read_file を続けて実行した。\n\n---\n\n## s11 で追加するもの\n\n| コンポーネント | S04 Kernel | S11 |\n|--------------|------------|------------|\n| 実行モデル | すべて同期 | 遅い操作はバックグラウンドスレッド + 通知注入 |\n| bash スキーマ | `command` | `command` + `run_in_background` |\n| 新規関数 | — | `should_run_background`, `start_background_task`, `collect_background_results`, `inject_background_results` |\n| 新規型 | — | `BackgroundManager` |\n| 通知形式 | — | `<task_notification>`(tool_use_id を再利用しない) |\n| ループ動作 | ツールを同期実行 | 明示的なバックグラウンド実行、後続のターンで完了結果を収集 |\n| ツール | 5 | 5(bash スキーマにパラメータを 1 つ追加) |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s11_background_tasks/code.py\n```\n\n以下のプロンプトを試してください:\n\n1. `Run pip list in the background and find all Python files in this directory`\n2. `Run npm install (use run_in_background) and while waiting, read package.json`\n3. `Run a short sleep in the background, then list all Markdown files`\n\n観察ポイント:`run_in_background` を明示的に設定すると、コマンドがバックグラウンドに送られるか?`bg_id` は返されるか?後続のターンで完了結果が `<task_notification>` 形式で収集されるか?\n\n---\n\n## 次の章\n\nバックグラウンドタスクは「遅い操作がブロックしない」を解決した。しかし、定期的に何かをしたい場合は?例えば「毎朝 9 時にテストを実行」「5 分ごとにサーバーステータスを確認」。\n\ns12 Cron Scheduler → Agent にアラームクロックを付ける。\n\n\n<!-- translation-sync: zh@v7, en@v7, ja@v7 -->\n"
|
||
},
|
||
{
|
||
"version": "s12",
|
||
"locale": "en",
|
||
"title": "s12: Cron Scheduler — Start Work on a Schedule",
|
||
"content": "# s12: Cron Scheduler — Start Work on a Schedule\n\ns01 → ... → s10 → s11 → `s12` → [s13](/en/s13) → ... → s17\n\n---\n\n## The Problem\n\nS11 changes how a command runs after it starts: a long Bash command can run in the background. It does not record when future work should start, and no component keeps checking the current time.\n\nFor requests such as \"run tests every morning at 9am\" or \"check CI status every 30 minutes,\" the user would still have to submit the prompt again at each scheduled time. The Harness needs to store the schedule, put the corresponding prompt into a pending queue when it becomes due, and deliver it to the Agent Loop when the Agent is idle.\n\n---\n\n## The Solution\n\n\n\nSuppose the Agent registers this job:\n\n```text\ncron: 0 9 * * *\nprompt: run tests\n```\n\nAt 09:00 local time, the scheduler thread matches the job and puts `[Scheduled] run tests` into `cron_queue`. The queue processor waits until the Agent is idle, then starts an Agent Loop turn. The model can then call Bash to run the tests.\n\nThe S12 code keeps the five base tools and Hooks from S04, then adds `schedule_cron`, `list_crons`, and `cancel_cron`. It does not include S11 background commands because this chapter delivers a prompt to start work, not the result of a command that is already running.\n\n---\n\n## How It Works\n\n### What CronJob stores\n\n```python\n@dataclass\nclass CronJob:\n id: str\n cron: str\n prompt: str\n recurring: bool\n durable: bool\n pending_delivery: bool = False\n last_fired: str | None = None\n```\n\n`cron` controls when the job becomes due. `prompt` is the task sent to the Agent. `pending_delivery` marks a due job that the model has not accepted, while `last_fired` prevents another enqueue in the same minute.\n\n### Five-field cron expressions\n\n```text\nminute hour day month weekday\n * * * * * every minute\n 0 9 * * * every day at 09:00\n */5 * * * * every 5 minutes\n 0 9 * * 1-5 weekdays at 09:00\n```\n\nThis chapter supports `*`, `*/N`, `N`, `N-M`, and `N,M,...`. Before saving a job, `schedule_job()` calls `validate_cron()` and rejects expressions with the wrong number of fields or out-of-range values.\n\n### Enqueue when due\n\nThe scheduler thread reads local time once per second. When an expression matches and the job has not fired in the current minute, `_enqueue_due_job()` saves `pending_delivery` and `last_fired` before adding the job to the in-memory queue:\n\n```python\ndef poll_due_jobs(moment: datetime):\n minute_marker = moment.strftime(\"%Y-%m-%d %H:%M\")\n with cron_lock:\n for job in list(scheduled_jobs.values()):\n if job.pending_delivery or job.last_fired == minute_marker:\n continue\n if cron_matches(job.cron, moment):\n _enqueue_due_job(job, minute_marker)\n```\n\nIf persistence fails, `_enqueue_due_job()` restores the previous state and does not expose a memory-only delivery to the queue processor.\n\n### Deliver when the Agent is idle\n\n`queue_processor_loop()` does not check the time. It checks the queue, and `agent_lock` prevents a scheduled turn from changing the session while a user turn is running:\n\n```python\ndef queue_processor_loop(stop_event=RUNTIME_STOP):\n while not stop_event.wait(0.2):\n if not has_cron_queue() or not agent_lock.acquire(blocking=False):\n continue\n try:\n if has_cron_queue():\n run_agent_turn_locked()\n finally:\n agent_lock.release()\n```\n\nThe Agent Loop takes due jobs from the queue and appends each one as a new user message:\n\n```python\nfired = consume_cron_queue()\nfor job in fired:\n messages.append({\"role\": \"user\", \"content\": f\"[Scheduled] {job.prompt}\"})\n```\n\nIf the model call fails, those messages are removed from the current session and the jobs return to the queue. Once the model accepts the call, one-shot jobs are removed and recurring jobs clear `pending_delivery` until the next match.\n\n### Persistence boundary\n\n| Mode | Stored in | After a process restart |\n|---|---|---|\n| `durable=True` | `.scheduled_tasks.json` | Loaded again |\n| `durable=False` | Memory | Gone |\n\nThe code updates `.scheduled_tasks.json` through a temporary file and `os.replace()`. If the file is corrupt, startup reports the error instead of ignoring it.\n\nDelivery is at least once. If the process exits after the model accepts a prompt but before the acknowledgement reaches disk, the same job may be delivered again after restart.\n\n### Runtime boundary\n\n- The scheduler uses the Agent process's local time.\n- The scheduler stops when the Agent process exits. `durable` preserves the job definition only.\n- Restart loads saved jobs but does not replay schedule times missed while the process was down.\n- Scheduled turns run in the queue processor thread. A tool call that needs interactive approval is denied instead of competing with the main terminal for input.\n- Scheduler and queue processor threads start only in the CLI. Importing `code.py` starts no background thread.\n\nUse crontab, a systemd timer, or an external scheduler when jobs must run while the Agent is closed.\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s12_cron_scheduler/code.py\n```\n\nEnter these prompts in order:\n\n1. `Schedule \"run date\" every 2 minutes and keep it after restart.`\n2. `List all cron jobs.`\n3. `Cancel the cron job you just created.`\n\nYou can inspect `.scheduled_tasks.json` and watch for the `[Scheduled] run date` message when the job becomes due. Keep the Agent process running while testing a minute-level schedule.\n\n---\n\n## What's Next\n\nThe scheduler can start an Agent Loop turn at a specified time, but one Agent still handles that turn. When a task requires parallel investigation, changes across multiple modules, and a combined result, the Harness also needs to assign work to multiple Agents and collect what each one produces.\n\ns13 Agent Teams → A Lead assigns tasks, teammates run independently, and results return through inboxes.\n\n<!-- translation-sync: zh@v9, en@v9, ja@v9 -->\n"
|
||
},
|
||
{
|
||
"version": "s12",
|
||
"locale": "zh",
|
||
"title": "s12: Cron Scheduler — 按时间启动任务",
|
||
"content": "# s12: Cron Scheduler — 按时间启动任务\n\ns01 → ... → s10 → s11 → `s12` → [s13](/zh/s13) → ... → s17\n\n---\n\n## 问题\n\nS11 解决的是命令开始后的执行方式:耗时的 Bash 命令可以在后台运行。但它不会记录某项工作应该在什么时间开始,也没有组件持续检查当前时间。\n\n对于“每天早上 9 点跑测试”或“每 30 分钟检查 CI 状态”这样的请求,如果只依靠当前的 Agent Loop,用户仍要在每次到点后重新发送 prompt。Harness 需要保存执行时间,到点后把对应的 prompt 加入待执行队列,再在 Agent 空闲时交给 Agent Loop。\n\n---\n\n## 解决方案\n\n\n\n假设 Agent 注册了下面这项任务:\n\n```text\ncron: 0 9 * * *\nprompt: run tests\n```\n\n调度线程在本地时间 09:00 匹配到这项任务,把 `[Scheduled] run tests` 放进 `cron_queue`。队列处理线程等到 Agent 空闲后启动一轮 Agent Loop,模型随后可以调用 Bash 执行测试。\n\nS12 的代码保留 S04 的五个基础工具和 Hooks,再增加 `schedule_cron`、`list_crons`、`cancel_cron`。它不包含 S11 的后台命令,因为这里传递的是一条待执行的 prompt,而不是某个后台命令的执行结果。\n\n---\n\n## 工作原理\n\n### CronJob 保存什么\n\n```python\n@dataclass\nclass CronJob:\n id: str\n cron: str\n prompt: str\n recurring: bool\n durable: bool\n pending_delivery: bool = False\n last_fired: str | None = None\n```\n\n`cron` 决定何时触发,`prompt` 是触发后交给 Agent 的任务。`pending_delivery` 表示任务已经到期但尚未被模型接收,`last_fired` 防止同一分钟重复入队。\n\n### 五段式 Cron 表达式\n\n```text\n分钟 小时 日 月 星期\n * * * * * 每分钟\n 0 9 * * * 每天 09:00\n */5 * * * * 每 5 分钟\n 0 9 * * 1-5 工作日 09:00\n```\n\n本章支持 `*`、`*/N`、`N`、`N-M` 和 `N,M,...`。`schedule_job()` 会在保存任务前调用 `validate_cron()`,拒绝字段数量或取值范围不正确的表达式。\n\n### 到期后先入队\n\n调度线程每秒读取一次本地时间。表达式匹配且任务在当前分钟尚未触发时,`_enqueue_due_job()` 先保存 `pending_delivery` 和 `last_fired`,再把任务放进内存队列:\n\n```python\ndef poll_due_jobs(moment: datetime):\n minute_marker = moment.strftime(\"%Y-%m-%d %H:%M\")\n with cron_lock:\n for job in list(scheduled_jobs.values()):\n if job.pending_delivery or job.last_fired == minute_marker:\n continue\n if cron_matches(job.cron, moment):\n _enqueue_due_job(job, minute_marker)\n```\n\n持久化失败时,`_enqueue_due_job()` 会恢复原来的状态,不会把只存在于内存中的任务暴露给队列处理线程。\n\n### Agent 空闲后再交付\n\n`queue_processor_loop()` 不负责判断时间。它只检查队列,并用 `agent_lock` 避免定时任务与用户正在进行的回合同时修改会话:\n\n```python\ndef queue_processor_loop(stop_event=RUNTIME_STOP):\n while not stop_event.wait(0.2):\n if not has_cron_queue() or not agent_lock.acquire(blocking=False):\n continue\n try:\n if has_cron_queue():\n run_agent_turn_locked()\n finally:\n agent_lock.release()\n```\n\nAgent Loop 从队列取出到期任务,并把它们作为新的用户消息追加:\n\n```python\nfired = consume_cron_queue()\nfor job in fired:\n messages.append({\"role\": \"user\", \"content\": f\"[Scheduled] {job.prompt}\"})\n```\n\n模型调用失败时,这些消息会从当前会话中移除,任务重新放回队列。模型成功接收后,一次性任务会被删除,周期任务则清除 `pending_delivery`,等待下一次匹配。\n\n### 持久化边界\n\n| 模式 | 保存位置 | 进程重启后 |\n|---|---|---|\n| `durable=True` | `.scheduled_tasks.json` | 重新加载 |\n| `durable=False` | 内存 | 消失 |\n\n`.scheduled_tasks.json` 使用临时文件和 `os.replace()` 更新。文件损坏时,启动日志会报告错误,不会静默忽略。\n\n这里采用至少一次交付:进程若在模型接收 prompt 后、确认状态写回前退出,同一任务可能在重启后再次交付。\n\n### 运行边界\n\n- 调度器使用 Agent 进程的本地时间。\n- Agent 进程关闭后,调度线程也会停止;`durable` 只保留任务定义。\n- 重启时只恢复任务,不补跑停机期间错过的时间点。\n- 定时回合运行在队列处理线程中。需要交互确认的工具调用会被拒绝,不会与主终端同时读取输入。\n- 调度线程和队列处理线程只在运行 CLI 时启动,导入 `code.py` 不会启动后台线程。\n\n需要在 Agent 关闭时仍按时执行任务,应使用系统的 crontab、systemd timer 或其他外部调度服务。\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s12_cron_scheduler/code.py\n```\n\n可以依次输入:\n\n1. `Schedule \"run date\" every 2 minutes and keep it after restart.`\n2. `List all cron jobs.`\n3. `Cancel the cron job you just created.`\n\n运行时可以查看 `.scheduled_tasks.json`,并观察到期后出现的 `[Scheduled] run date` 消息。测试一分钟级任务时,Agent 进程需要保持运行。\n\n---\n\n## 接下来\n\n调度器可以在指定时间启动一轮 Agent Loop,但这一轮仍由一个 Agent 处理。面对需要同时调查多个模块、并行修改并汇总结果的任务,Harness 还需要把工作分给多个 Agent,并收集各自的执行结果。\n\ns13 Agent Teams → Lead 分配任务,队友独立执行,再通过收件箱返回结果。\n\n<!-- translation-sync: zh@v9, en@v9, ja@v9 -->\n"
|
||
},
|
||
{
|
||
"version": "s12",
|
||
"locale": "ja",
|
||
"title": "s12: Cron Scheduler — 時刻に合わせて作業を開始する",
|
||
"content": "# s12: Cron Scheduler — 時刻に合わせて作業を開始する\n\ns01 → ... → s10 → s11 → `s12` → [s13](/ja/s13) → ... → s17\n\n---\n\n## 課題\n\nS11 が扱うのは、コマンド開始後の実行方法である。時間のかかる Bash コマンドはバックグラウンドで実行できるが、将来の作業をいつ開始するかは記録せず、現在時刻を継続的に確認するコンポーネントもない。\n\n「毎朝 9 時にテストを実行する」「30 分ごとに CI の状態を確認する」といった依頼を現在の Agent Loop だけで扱う場合、ユーザーは時刻が来るたびに prompt を送り直す必要がある。Harness は実行時刻を保存し、時刻が来たら対応する prompt を待機キューへ入れ、Agent がアイドルの時に Agent Loop へ渡す必要がある。\n\n---\n\n## 解決方法\n\n\n\nAgent が次のジョブを登録したとする。\n\n```text\ncron: 0 9 * * *\nprompt: run tests\n```\n\nローカル時刻の 09:00 に scheduler thread がジョブを検出し、`[Scheduled] run tests` を `cron_queue` に入れる。queue processor は Agent がアイドルになるまで待ち、Agent Loop の 1 ターンを開始する。モデルはその後 Bash を呼び出してテストを実行できる。\n\nS12 のコードは S04 の 5 つの基本ツールと Hooks を残し、`schedule_cron`、`list_crons`、`cancel_cron` を追加する。ここで渡すのは新しい作業を開始する prompt であり、実行中のコマンド結果ではないため、S11 の background command は含めない。\n\n---\n\n## 仕組み\n\n### CronJob が保存する内容\n\n```python\n@dataclass\nclass CronJob:\n id: str\n cron: str\n prompt: str\n recurring: bool\n durable: bool\n pending_delivery: bool = False\n last_fired: str | None = None\n```\n\n`cron` は発火時刻を決め、`prompt` は Agent に渡す作業を表す。`pending_delivery` は期限に達したがモデルに受け取られていないジョブを示し、`last_fired` は同じ分での重複投入を防ぐ。\n\n### 5 フィールドの cron 式\n\n```text\n分 時 日 月 曜日\n * * * * * 毎分\n 0 9 * * * 毎日 09:00\n*/5 * * * * 5 分ごと\n 0 9 * * 1-5 平日 09:00\n```\n\nこの章では `*`、`*/N`、`N`、`N-M`、`N,M,...` を扱う。`schedule_job()` は保存前に `validate_cron()` を呼び、フィールド数や値の範囲が正しくない式を拒否する。\n\n### 期限に達したらキューへ入れる\n\nscheduler thread は 1 秒ごとにローカル時刻を読む。式が一致し、現在の分にまだ発火していない場合、`_enqueue_due_job()` は `pending_delivery` と `last_fired` を保存してからメモリ上のキューへ追加する。\n\n```python\ndef poll_due_jobs(moment: datetime):\n minute_marker = moment.strftime(\"%Y-%m-%d %H:%M\")\n with cron_lock:\n for job in list(scheduled_jobs.values()):\n if job.pending_delivery or job.last_fired == minute_marker:\n continue\n if cron_matches(job.cron, moment):\n _enqueue_due_job(job, minute_marker)\n```\n\n永続化に失敗すると、`_enqueue_due_job()` は元の状態へ戻し、メモリにしか存在しない配信を queue processor に渡さない。\n\n### Agent がアイドルになってから配信する\n\n`queue_processor_loop()` は時刻を確認しない。キューだけを確認し、`agent_lock` によってユーザーのターンと定時ターンが同時に session を変更するのを防ぐ。\n\n```python\ndef queue_processor_loop(stop_event=RUNTIME_STOP):\n while not stop_event.wait(0.2):\n if not has_cron_queue() or not agent_lock.acquire(blocking=False):\n continue\n try:\n if has_cron_queue():\n run_agent_turn_locked()\n finally:\n agent_lock.release()\n```\n\nAgent Loop は期限に達したジョブをキューから取り出し、それぞれを新しい user message として追加する。\n\n```python\nfired = consume_cron_queue()\nfor job in fired:\n messages.append({\"role\": \"user\", \"content\": f\"[Scheduled] {job.prompt}\"})\n```\n\nモデル呼び出しに失敗すると、これらの message を現在の session から削除し、ジョブをキューへ戻す。モデルが受け取った後、一回限りのジョブは削除し、定期ジョブは `pending_delivery` を解除して次の一致を待つ。\n\n### 永続化の境界\n\n| モード | 保存先 | プロセス再起動後 |\n|---|---|---|\n| `durable=True` | `.scheduled_tasks.json` | 再読み込み |\n| `durable=False` | メモリ | 消失 |\n\n`.scheduled_tasks.json` は一時ファイルと `os.replace()` で更新する。ファイルが壊れている場合、起動時にエラーを表示し、黙って無視しない。\n\n配信保証は at-least-once である。モデルが prompt を受け取った後、確認状態をディスクへ書く前にプロセスが終了すると、再起動後に同じジョブを再配信する場合がある。\n\n### 実行境界\n\n- scheduler は Agent プロセスのローカル時刻を使う。\n- Agent プロセスが終了すると scheduler thread も停止する。`durable` が保持するのはジョブ定義だけである。\n- 再起動時にジョブを復元するが、停止中に過ぎた実行時刻は補わない。\n- 定時ターンは queue processor thread で動く。対話的な許可が必要な tool call は拒否し、main terminal から同時に入力を読まない。\n- scheduler と queue processor の thread は CLI 実行時だけ開始する。`code.py` の import では background thread を起動しない。\n\nAgent が閉じている間も実行する必要がある場合は、crontab、systemd timer、外部 scheduler を使う。\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s12_cron_scheduler/code.py\n```\n\n次の prompt を順に入力できる。\n\n1. `Schedule \"run date\" every 2 minutes and keep it after restart.`\n2. `List all cron jobs.`\n3. `Cancel the cron job you just created.`\n\n`.scheduled_tasks.json` の内容と、期限に達した後の `[Scheduled] run date` message を確認する。分単位のジョブを試す間は Agent プロセスを起動したままにする。\n\n---\n\n## 次の章\n\nスケジューラは指定した時刻に Agent Loop の 1 ターンを開始できるが、そのターンを処理するのは一つの Agent である。複数のモジュールを同時に調査、変更し、結果をまとめるタスクでは、Harness が複数の Agent へ作業を割り当て、それぞれの実行結果を集める必要がある。\n\ns13 Agent Teams → Lead がタスクを割り当て、teammate が個別に実行し、inbox を通じて結果を返す。\n\n<!-- translation-sync: zh@v9, en@v9, ja@v9 -->\n"
|
||
},
|
||
{
|
||
"version": "s13",
|
||
"locale": "en",
|
||
"title": "s13: Agent Teams — Runtime and Coordination Protocols",
|
||
"content": "# s13: Agent Teams — Runtime and Coordination Protocols\n\ns01 → ... → [s10](/en/s10) → `s13` → [s14](/en/s14) → s15 → s16 → s17\n\n> *\"When one agent cannot hold the whole job, let teammates divide the work.\"* — Persistent teammates, shared task selection, optional worktrees, and coordination protocols.\n>\n> **Harness layer**: Team — how multiple agents divide work, share state, and stay under Lead's control.\n\n---\n\n## The Problem\n\nSuppose we ask an agent to refactor an entire backend. The work may cover configuration loading, authentication, and tests. One agent can process those areas sequentially, but it takes longer and earlier details gradually leave its context.\n\nThis is a good candidate for parallel work, yet users normally describe the goal rather than design the team:\n\n```text\nRefactor this sample backend. Clean up configuration loading,\nauthentication, and tests, preserve the existing interfaces,\nand make sure the tests pass.\n```\n\nThe harness has to answer a connected set of questions:\n\n1. Who decides that parallel work is useful, and who confirms the extra agents?\n2. How does each teammate keep its identity and context across assignments?\n3. How do results return to Lead without asking the model to poll an inbox?\n4. Can an idle teammate pick up ready work without waiting for another assignment?\n5. Which directory should a task use when parallel edits may conflict?\n6. How do shutdown and plan approval become traceable, enforceable protocols?\n\n---\n\n## The Solution\n\n\n\ns13 reuses s10's base tools, hooks, permission checks, and Task System, then adds a Lead-managed team runtime:\n\n- **Lead** owns the user conversation, proposes a division of work, and waits for confirmation.\n- **Teammates** run independent agent loops and alternate between WORK and IDLE.\n- **MessageBus** carries ordinary messages, results, and control events through file-backed mailboxes.\n- **Runtime delivery** consumes Lead's mailbox and injects team events into the next turn.\n- **The shared task board** lets idle teammates find ready work and claim it under a lock.\n- **Optional worktrees** bind a task to another working directory when the work needs it. Unbound tasks use the normal repository directory.\n- **Typed protocols and a plan gate** make shutdown and approval state explicit and block mutating tools until a required plan is approved.\n\nTask graph authoring keeps s10's two-phase contract. The Lead first calls `create_task` for every node, then uses the returned runtime IDs with `update_task(addBlockedBy=...)` before assigning ready work. Only the Lead receives `update_task`; teammates can list, claim, and complete tasks but cannot rewrite graph structure while the team is running.\n\ns11 background tasks and s12 scheduled tasks are not carried into this chapter. Neither mechanism is required for teammate communication, task claiming, or plan approval.\n\nThese are all parts of the Team harness layer. Teammates do not need a separate loop for task discovery, and a worktree does not create a new kind of agent.\n\n---\n\n## How It Works\n\n### 1. Lead proposes a team and waits for user confirmation\n\nStarting teammates changes cost, concurrency, and the set of actors that may edit the workspace. Lead's system prompt keeps that boundary visible:\n\n```python\n\"When parallel work would help, first propose a small team with clear \"\n\"responsibilities and wait for the user's confirmation. Do not call \"\n\"spawn_teammate before the user confirms.\"\n```\n\nFor the first request, Lead only proposes a split:\n\n```text\nI suggest three parallel areas:\n- config: clean up configuration loading\n- auth: refactor authentication\n- tests: add regression coverage\n\nI will start the teammates after you confirm.\n```\n\nAfter the user says \"Go ahead,\" Lead can call `spawn_teammate`. Lead creates the Task first and passes its initial `task_id` to the teammate. The user states the goal, Lead designs the team, and the user confirms the execution boundary.\n\n### 2. Every teammate owns an independent loop\n\nAn s06 subagent is a one-shot call. A teammate is a persistent execution unit:\n\n| | s06 Subagent | s13 Teammate |\n|---|---|---|\n| Lifecycle | Ends after one call | `WORK → IDLE → WORK` until shutdown |\n| Context | Exists for one task | Persists across assignments |\n| Communication | Returns one result | Receives messages and emits events |\n| Coordination | One-way delegation | Two-way collaboration with Lead |\n\n`TeammateRuntime` gives each teammate its own system prompt, messages, tools, and current Task, then runs its WORK / IDLE loop in a daemon thread. Lead can keep coordinating while teammates work. The names `lead` and `agent` are reserved for runtime identities, while `MessageBus` still accepts `lead` as the coordinator mailbox.\n\n`spawn_teammate` claims the initial Task before the thread starts. A failed claim prevents the teammate from starting. Without a Task, workspace and Shell tools ask the teammate to claim one instead of falling back to the repository directory.\n\n### 3. MessageBus keeps communication outside model context\n\nLead and teammates cannot share one messages array. Otherwise one teammate's tool results would leak into another teammate's reasoning. `MessageBus` gives each agent a `.mailboxes/<name>.jsonl` inbox:\n\n```python\nclass MessageBus:\n def send(self, from_agent, to_agent, content,\n msg_type=\"message\", metadata=None):\n msg = {\n \"from\": from_agent,\n \"to\": to_agent,\n \"content\": content,\n \"type\": msg_type,\n \"metadata\": metadata or {},\n }\n with self._changed:\n MAILBOX_DIR.mkdir(parents=True, exist_ok=True)\n with self._path(to_agent).open(\"a\", encoding=\"utf-8\") as handle:\n handle.write(json.dumps(msg, ensure_ascii=True) + \"\\n\")\n self._changed.notify_all()\n\n def wait_for_messages(self, agent, timeout=None):\n deadline = None if timeout is None else time.monotonic() + timeout\n with self._changed:\n while not self.peek(agent):\n remaining = (None if deadline is None\n else deadline - time.monotonic())\n if remaining is not None and remaining <= 0:\n return []\n self._changed.wait(remaining)\n return self._read_unlocked(agent)\n```\n\nA lock protects mailbox files from concurrent access. A `Condition` lets the runtime wake a teammate for a message and also supports the short timeout used while IDLE.\n\n### 4. The runtime delivers inbox events\n\n`read_inbox()` consumes messages by reading and deleting the mailbox file, so Lead keeps a single consumer, `consume_lead_inbox()`:\n\n```python\ndef consume_lead_inbox():\n messages = BUS.read_inbox(\"lead\")\n for message in messages:\n if message[\"type\"].endswith(\"_response\"):\n match_response(...)\n return messages\n```\n\nThe CLI loop waits for terminal input and Lead's mailbox at the same time. When a message arrives, it consumes the mailbox before starting another Lead turn:\n\n```text\nMessageBus → consume_lead_inbox\n → update protocol state\n → inject [Team events] into history\n → start another Lead turn\n```\n\nAfter spawning a teammate, Lead ends the current turn instead of repeatedly calling `list_teammates` or `get_task`. The runtime starts the next turn when a team event arrives.\n\n`check_inbox` is not a model tool. Message arrival belongs to the runtime; the model handles events after the runtime has delivered them into its context.\n\n### 5. Result and IDLE are separate events\n\nWhen a teammate finishes one assignment, the runtime sends two events in order:\n\n```text\nresult: \"Authentication refactored; related tests pass.\"\nidle_notification: \"Waiting for more work.\"\n```\n\n`result` answers \"What did this assignment produce?\" `idle_notification` answers \"Can this teammate accept more work?\" One vague \"done\" cannot represent both facts.\n\nAn idle teammate does not exit. A direct message or a ready task returns it to WORK; a `shutdown_request` starts a graceful shutdown handshake.\n\n### 6. IDLE checks the mailbox before looking for ready tasks\n\nIDLE gives messages priority, then checks the shared task board:\n\n```python\nwhile True:\n inbox = BUS.wait_for_messages(name, IDLE_SCAN_INTERVAL)\n if inbox:\n should_stop = handle_messages(inbox)\n if should_stop or messages[-1][\"role\"] == \"user\":\n break\n continue\n\n task = claim_next_task(name)\n if task:\n messages.append({\n \"role\": \"user\",\n \"content\": f\"[Auto-claimed task {task.id}] {task.subject}\",\n })\n break\n```\n\nShutdown, plan approval, and direct instructions from Lead should arrive before opportunistic work. If there is no message and no ready task, the teammate remains IDLE. A blocked task may become ready after another teammate completes its prerequisite.\n\n### 7. Discovery and claim are separate, and claim is atomic\n\nScanning only finds candidates:\n\n```python\ndef scan_unclaimed_tasks() -> list[Task]:\n return [\n task for task in list_tasks()\n if task.status == \"pending\"\n and task.owner is None\n and can_start(task.id)\n ]\n```\n\nThe list is a snapshot. Another teammate, or another harness process using the same task directory, may see the same task. Ownership changes therefore happen inside `claim_task()` under `task_store_lock()`, which combines the in-process lock with a file lock:\n\n```python\ndef claim_task(task_id: str, owner: str) -> str:\n with task_store_lock():\n task = load_task(task_id)\n if task.status != \"pending\" or task.owner is not None:\n return \"Task is no longer available\"\n if _owner_in_progress(owner):\n return \"Owner must complete its current task first\"\n if not can_start(task_id):\n return \"Task is blocked\"\n cwd, error = task_worktree_cwd(task)\n if error:\n return f\"Cannot claim {task_id}: {error}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n teammate_assignments[owner] = {\"task_id\": task.id, \"cwd\": cwd}\n return f\"Claimed {task.id}\"\n```\n\nMany teammates may discover the same candidate, but only one claim can move it to `in_progress`. Task files are written through a temporary file and atomically replaced while the same store lock is held. A teammate must also finish its current task before claiming another, and a broken worktree binding fails closed rather than falling back to the repository directory.\n\n### 8. Claimed work reuses the same WORK loop\n\nAfter a successful claim, the runtime injects the task ID, subject, and description into the teammate's messages:\n\n```text\nready task appears\n → IDLE teammate discovers it\n → claim_task writes owner and in_progress\n → task enters teammate messages\n → WORK\n → complete_task\n → result + idle_notification\n → IDLE\n```\n\nThe teammate uses the same model call, file tools, Shell, plan gate, result reporting, and shutdown protocol as a direct Lead assignment. Task discovery is another entry into the existing WORK loop.\n\n### 9. The task selects the tools' working directory\n\n`Task.worktree` is optional:\n\n```python\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str\n owner: str | None\n blockedBy: list[str]\n worktree: str | None = None\n```\n\nLead can create and bind a worktree when separate directories will help:\n\n```python\ncreate_worktree(name=\"auth-refactor\", task_id=\"task_1a2b3c4d\")\n```\n\n`create_worktree` is a Lead-only tool. It accepts a pending, unowned, unbound task, validates the name, path, branch, and Git registry, creates the checkout, then writes the task binding. If Git reports failure after leaving a branch or registered checkout, the runtime reports a partial operation, leaves the task unbound, and preserves those artifacts for manual recovery. Teammates only see task and file tools.\n\nClaiming the task stores its resolved directory in `teammate_assignments`; that teammate's `bash`, `read_file`, `write_file`, `edit_file`, and `glob` wrappers read the directory from the assignment. A task with no worktree resolves to `WORKDIR`; a teammate without a claimed Task cannot use those workspace tools:\n\n```python\ncwd, error = task_worktree_cwd(task)\nif not error:\n teammate_assignments[owner] = {\n \"task_id\": task.id,\n \"cwd\": cwd,\n }\n```\n\n`complete_task(task_id, owner)` checks that the caller owns the in-progress task. Successful completion records the result but keeps the assignment directory selected until that model turn ends. This lets later tool calls in the same response stay in the task's worktree. The runtime releases the assignment when the teammate returns to IDLE; a failed completion keeps it so the teammate can fix the task and try again.\n\nAfter a restart, `assignment_cwd()` can rebuild an in-progress assignment from the durable task owner and worktree binding. It also replaces a stale local lease when the same owner has moved to another task. A missing or invalid binding fails closed instead of silently routing work to the repository directory.\n\n> A worktree separates Git working directories and branches. It is not a sandbox: Shell commands can still access paths and resources allowed to the parent process.\n\n### 10. Worktree removal belongs to the host\n\nThe model can create a task-bound worktree, but it cannot remove one. Cleanup remains a host helper so the user or host can first inspect task ownership, the assignment lease, and Git status. The helper refuses pending or in-progress task bindings and current-turn leases. Without an explicit destructive choice, tracked, untracked, and ignored files all block removal.\n\n`remove_worktree(name, discard_changes=True)` is reserved for host code that has already obtained explicit user confirmation. Either removal path retains the `wt/<name>` branch, including clean local commits with no upstream. A successful removal clears the task binding because the checkout no longer exists.\n\n```text\nclean worktree → host may remove directory and retain wt/<name> branch\nchanged worktree → user decides how to preserve or discard it\npending/running task → refuse removal\n```\n\nTask completion also stays separate from worktree cleanup. `complete_task` records the task result; after the teammate reaches IDLE, the user or host can inspect, merge, keep, or remove the worktree.\n\n### 11. Control messages use types and request IDs\n\nFree-form text works for ordinary collaboration, but shutdown and approval should not depend on guessing intent. They use structured messages:\n\n\n\n```python\n@dataclass\nclass ProtocolState:\n request_id: str\n type: str\n sender: str\n target: str\n status: str\n payload: str\n work_version: int | None = None\n task_id: str | None = None\n\n\npending_requests: dict[str, ProtocolState] = {}\n```\n\nThe shutdown path is:\n\n```text\nLead creates a pending shutdown request\n → shutdown_request(request_id) enters the teammate inbox\n → the teammate finishes its current step\n → shutdown_response(request_id) returns to Lead\n → request_id locates the original request\n → pending becomes approved and the teammate loop exits\n```\n\nThe ID correlates one reply with one request, the type prevents a mismatched reply from changing state, and the status prevents duplicate responses from being applied twice.\n\n### 12. Plan approval constrains execution\n\nThe plan protocol runs in the opposite direction:\n\n```text\nLead → plan_request\nteammate → plan_approval_request(request_id, plan)\nLead → plan_approval_response(request_id, approve, feedback)\n```\n\nWhen Lead already knows that a teammate must plan first, `spawn_teammate(..., task_id=task.id, require_plan=True)` claims the Task and activates the gate before the teammate thread starts. `request_plan` can also require a plan from a teammate that is already running.\n\nTool dispatch enforces the gate:\n\n```python\ndef _run_teammate_tool(name, block, handlers):\n gate = plan_gates.get(name, \"not_required\")\n if block.name in {\"bash\", \"write_file\", \"edit_file\"} and gate not in {\n \"not_required\", \"approved\"\n }:\n return f\"Blocked: plan status is {gate}.\"\n try:\n return handlers[block.name](**block.input)\n except Exception as error:\n return f\"Error: {type(error).__name__}: {error}\"\n```\n\nWhile the state is `required`, `pending`, or `rejected`, the teammate can read files and submit or revise a plan, but it cannot run Shell commands, write files, or edit files. A submitted plan records the teammate's current task and work version. Claiming or releasing a Task changes that version and invalidates the old approval; an ordinary message changes neither the task identity nor the approval state.\n\nTeammates do not read user input from their background threads. A dangerous command or path outside the workspace returns a permission error so Lead can handle the decision with the user.\n\n---\n\n## One Complete Run\n\n```text\ns13 >> Put the backend refactor on a shared task board. Clean up\n configuration, authentication, and tests in parallel where possible.\n Use a worktree for authentication, preserve existing interfaces,\n and make sure the tests pass.\n\nLead: I suggest config, auth, and tests as three areas.\n Shall I start the team?\n\ns13 >> Go ahead.\n\n[task] config created\n[task] auth created → worktree auth-refactor\n[task] tests created\n[claim] alice → config (cwd: repository)\n[claim] bob → auth (cwd: .worktrees/auth-refactor)\n[teammate] alice spawned\n[teammate] bob spawned\n[complete] auth\n[bus] bob → lead (result) ...\n[bus] bob → lead (idle_notification) ...\n[wake: 2 team events → new turn]\nLead: I received the authentication result and will coordinate the rest.\n```\n\nThe terminal exposes the user request, Lead's proposal, task state, claims, selected directories, results, IDLE transitions, and control events. The user does not have to name a Lead or ask it to check an inbox.\n\n---\n\n## What Changed from s10\n\n| Component | s10 | s13 |\n|---|---|---|\n| Agents | One agent | One Lead plus persistent teammates |\n| User flow | Execute the request | Propose a team, then confirm startup |\n| Communication | None | File mailboxes plus runtime delivery |\n| Lifecycle | One loop | Teammate `WORK / IDLE / shutdown` |\n| Shared work | One agent uses task tools | IDLE scan plus atomic teammate claims |\n| Working directory | Repository `WORKDIR` | A claimed Task, with an optional worktree |\n| Reporting | Current agent output | Separate `result` and `idle_notification` |\n| Control | None | Typed shutdown and plan approval protocols |\n| Enforcement | No team constraint | Required plans gate mutating tools |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s13_agent_teams/code.py\n```\n\nStart with an ordinary request:\n\n```text\nPut the backend refactor on a shared task board. Complete configuration,\nauthentication, and tests in parallel where dependencies allow. Use a\nworktree for authentication, preserve existing interfaces, and summarize\nthe result.\n```\n\nAfter Lead proposes the team, reply:\n\n```text\nGo ahead.\n```\n\nWatch `.tasks/` move from `pending` to `in_progress` and `completed`, `.mailboxes/` deliver `result` and `idle_notification`, and `.worktrees/` appear only for the bound task. Also check that direct messages beat task-board scans and that a failed `complete_task` does not reset the teammate's working directory.\n\n---\n\n## What's Next\n\nThe Lead and its teammates can only call tools defined directly in `code.py`. Connecting Jira, a deployment platform, or a knowledge base still requires separate tool schemas and handlers for each external system. Changes to those external tools also require changes to the course code.\n\ns14 MCP Tools → Connect external services at runtime through one discovery and invocation protocol, then add their tools to the tool pool.\n\n<!-- translation-sync: zh@v12, en@v12, ja@v12 -->\n"
|
||
},
|
||
{
|
||
"version": "s13",
|
||
"locale": "zh",
|
||
"title": "s13: Agent Teams — 团队运行时与协作协议",
|
||
"content": "# s13: Agent Teams — 团队运行时与协作协议\n\ns01 → ... → [s10](/zh/s10) → `s13` → [s14](/zh/s14) → s15 → s16 → s17\n\n> *“一个 Agent 装不下整项工作时,就让队友分头完成。”* — 持久队友、共享任务认领、可选 worktree 与协作协议。\n>\n> **Harness 层**:Team(团队)— 多个 Agent 如何分工、共享状态,同时接受 Lead 控制。\n\n---\n\n## 问题\n\n假设我们让 Agent 重构整个后端,工作涉及配置加载、认证和测试。一个 Agent 可以依次处理,但总耗时更长,早期细节也会逐渐离开上下文。\n\n这类工作适合并行,可用户通常只描述目标,不会替运行时设计团队:\n\n```text\n重构这个示例后端。清理配置加载、认证和测试,\n保持现有接口,并确保测试通过。\n```\n\nHarness 需要回答一组相互关联的问题:\n\n1. 谁判断并行是否有用,新增 Agent 又由谁确认?\n2. 每个队友如何跨任务保留身份和上下文?\n3. 结果如何自动返回 Lead,而不是让模型轮询收件箱?\n4. 空闲队友能否直接接手 ready task,不再等待 Lead 逐项派发?\n5. 并行修改可能冲突时,任务应该使用哪个工作目录?\n6. 关机和计划审批如何成为可追踪、可执行的协议?\n\n---\n\n## 解决方案\n\n\n\ns13 复用 s10 的基础工具、Hooks、Permission 和 Task System,并增加一套由 Lead 管理的团队运行时:\n\n- **Lead** 负责用户对话,提出分工方案并等待确认。\n- **队友** 运行独立 Agent Loop,在 WORK 和 IDLE 之间切换。\n- **MessageBus** 通过文件收件箱传递普通消息、结果和控制事件。\n- **运行时投递** 消费 Lead 的收件箱,把团队事件注入下一轮对话。\n- **共享任务板** 让空闲队友发现 ready task,并在锁内完成认领。\n- **可选 worktree** 在需要时把任务绑定到另一个工作目录;未绑定任务仍使用仓库目录。\n- **类型化协议和计划闸门** 显式记录关机与审批状态,并在计划获批前阻止修改型工具。\n\n任务图继续采用 s10 的两阶段契约。Lead 先为所有节点调用 `create_task`,再使用返回的运行时 ID 调用 `update_task(addBlockedBy=...)`,最后才分配 ready task。只有 Lead 能使用 `update_task`;队友只能列举、认领和完成任务,团队运行期间不能改写任务图结构。\n\ns11 的后台任务和 s12 的定时任务没有被带入本章。它们不参与队友通信、任务认领或计划审批。\n\n这些机制都属于 Team 这一层。任务发现不需要另一套 Agent Loop,worktree 也不会产生另一种 Agent。\n\n---\n\n## 工作原理\n\n### 1. Lead 先提出团队,再等待用户确认\n\n启动队友会改变成本、并发度和可以修改工作区的角色集合。Lead 的系统提示词会把这条边界明确写出来:\n\n```python\n\"When parallel work would help, first propose a small team with clear \"\n\"responsibilities and wait for the user's confirmation. Do not call \"\n\"spawn_teammate before the user confirms.\"\n```\n\n收到第一条需求后,Lead 只提出分工:\n\n```text\n我建议并行处理三个方向:\n- config:清理配置加载\n- auth:重构认证\n- tests:补充回归测试\n\n你确认后我再启动队友。\n```\n\n用户回复“开始吧”后,Lead 才能调用 `spawn_teammate`。Lead 会先创建任务,再把初始 `task_id` 传给队友。用户给出目标,Lead 设计团队,用户确认执行边界。\n\n### 2. 每个队友拥有独立循环\n\ns06 的 subagent 是一次性调用,队友则是持久执行单元:\n\n| | s06 Subagent | s13 队友 |\n|---|---|---|\n| 生命周期 | 一次调用后结束 | `WORK → IDLE → WORK`,直到关机 |\n| 上下文 | 只服务一个任务 | 跨任务保留 |\n| 通信 | 返回一次结果 | 接收消息并发出事件 |\n| 协作 | 单向委派 | 与 Lead 双向协作 |\n\n`TeammateRuntime` 为每个队友保存独立的系统提示词、messages、工具和当前任务,再在线程中运行 WORK / IDLE 循环。队友工作时,Lead 可以继续协调其他任务。`lead` 和 `agent` 保留给运行时身份,但 `MessageBus` 仍允许把 `lead` 作为协调者收件箱。\n\n`spawn_teammate` 在线程启动前认领初始任务。认领失败时不会启动队友。队友没有任务时,文件和 Shell 工具会要求它先认领任务,而不是回退到仓库目录。\n\n### 3. MessageBus 把通信放在模型上下文之外\n\nLead 和队友不能共享同一个 messages 数组,否则一个队友的工具结果会进入另一个队友的推理上下文。`MessageBus` 为每个 Agent 提供 `.mailboxes/<name>.jsonl` 收件箱:\n\n```python\nclass MessageBus:\n def send(self, from_agent, to_agent, content,\n msg_type=\"message\", metadata=None):\n msg = {\n \"from\": from_agent,\n \"to\": to_agent,\n \"content\": content,\n \"type\": msg_type,\n \"metadata\": metadata or {},\n }\n with self._changed:\n MAILBOX_DIR.mkdir(parents=True, exist_ok=True)\n with self._path(to_agent).open(\"a\", encoding=\"utf-8\") as handle:\n handle.write(json.dumps(msg, ensure_ascii=True) + \"\\n\")\n self._changed.notify_all()\n\n def wait_for_messages(self, agent, timeout=None):\n deadline = None if timeout is None else time.monotonic() + timeout\n with self._changed:\n while not self.peek(agent):\n remaining = (None if deadline is None\n else deadline - time.monotonic())\n if remaining is not None and remaining <= 0:\n return []\n self._changed.wait(remaining)\n return self._read_unlocked(agent)\n```\n\n锁会保护收件箱文件,避免队友并发读写。`Condition` 既能在消息到达时唤醒队友,也能支持 IDLE 状态下的短时等待。\n\n### 4. 收件箱事件由运行时投递\n\n`read_inbox()` 会读取并删除收件箱文件,因此 Lead 只保留一个消费者 `consume_lead_inbox()`:\n\n```python\ndef consume_lead_inbox():\n messages = BUS.read_inbox(\"lead\")\n for message in messages:\n if message[\"type\"].endswith(\"_response\"):\n match_response(...)\n return messages\n```\n\nCLI 主循环同时等待终端输入和 Lead 收件箱。新消息到达时,它会先消费收件箱,再发起一轮 Lead 调用:\n\n```text\nMessageBus → consume_lead_inbox\n → 更新协议状态\n → 把 [Team events] 注入 history\n → 启动新一轮 Lead 调用\n```\n\nLead 启动队友后会结束当前轮次,不用反复调用 `list_teammates` 或 `get_task` 等待结果。队友事件到达时,运行时会自动唤醒下一轮。\n\n`check_inbox` 不是模型工具。消息到达和消费属于运行时,模型只处理已经投递到上下文里的事件。\n\n### 5. 结果与 IDLE 是两个事件\n\n队友完成一项任务后,运行时按顺序发送两个事件:\n\n```text\nresult: \"认证已重构,相关测试通过。\"\nidle_notification: \"Waiting for more work.\"\n```\n\n`result` 回答“这项任务产出了什么”,`idle_notification` 回答“这个队友能否继续接任务”。一个含糊的“完成了”无法同时表达这两种状态。\n\n空闲队友不会退出。直接消息或 ready task 会让它回到 WORK,`shutdown_request` 则会启动平滑关机握手。\n\n### 6. IDLE 先看收件箱,再找 ready task\n\n队友进入 IDLE 后优先处理消息,然后检查共享任务板:\n\n```python\nwhile True:\n inbox = BUS.wait_for_messages(name, IDLE_SCAN_INTERVAL)\n if inbox:\n should_stop = handle_messages(inbox)\n if should_stop or messages[-1][\"role\"] == \"user\":\n break\n continue\n\n task = claim_next_task(name)\n if task:\n messages.append({\n \"role\": \"user\",\n \"content\": f\"[Auto-claimed task {task.id}] {task.subject}\",\n })\n break\n```\n\n关机、计划审批和 Lead 的直接指令应该先于临时发现的工作。如果没有消息,也没有 ready task,队友会保持 IDLE。前置任务完成后,当前受阻的任务可能变为 ready。\n\n### 7. 发现和认领分成两步,认领必须原子执行\n\n扫描只负责找候选任务:\n\n```python\ndef scan_unclaimed_tasks() -> list[Task]:\n return [\n task for task in list_tasks()\n if task.status == \"pending\"\n and task.owner is None\n and can_start(task.id)\n ]\n```\n\n候选列表只是某一时刻的快照。其他队友,甚至另一个使用同一任务目录的 Harness 进程,也可能看到同一任务。因此所有权变更必须放进 `claim_task()`,并由 `task_store_lock()` 同时取得进程内锁和文件锁:\n\n```python\ndef claim_task(task_id: str, owner: str) -> str:\n with task_store_lock():\n task = load_task(task_id)\n if task.status != \"pending\" or task.owner is not None:\n return \"Task is no longer available\"\n if _owner_in_progress(owner):\n return \"Owner must complete its current task first\"\n if not can_start(task_id):\n return \"Task is blocked\"\n cwd, error = task_worktree_cwd(task)\n if error:\n return f\"Cannot claim {task_id}: {error}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n teammate_assignments[owner] = {\"task_id\": task.id, \"cwd\": cwd}\n return f\"Claimed {task.id}\"\n```\n\n多个队友可以同时发现同一候选,但只有一个 claim 能把它推进到 `in_progress`。持有同一存储锁时,任务内容会先写入临时文件,再原子替换正式文件。队友完成当前任务后才能再认领下一项;worktree 绑定损坏时,认领会直接失败,不会回退到仓库目录。\n\n### 8. 认领后的工作复用同一个 WORK 循环\n\n认领成功后,运行时把任务 ID、标题和描述放进队友的 messages:\n\n```text\n任务板出现 ready task\n → IDLE 队友发现候选\n → claim_task 写入 owner 和 in_progress\n → 任务进入队友 messages\n → WORK\n → complete_task\n → result + idle_notification\n → IDLE\n```\n\n队友继续使用直接派发任务时的模型调用、文件工具、Shell、计划闸门、结果上报和关机协议。任务发现只是现有 WORK 循环的另一个入口。\n\n### 9. 由任务选择工具的工作目录\n\n`Task.worktree` 是可选字段:\n\n```python\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str\n owner: str | None\n blockedBy: list[str]\n worktree: str | None = None\n```\n\n并行修改需要分开目录时,Lead 可以创建并绑定 worktree:\n\n```python\ncreate_worktree(name=\"auth-refactor\", task_id=\"task_1a2b3c4d\")\n```\n\n`create_worktree` 只提供给 Lead。它要求任务处于 pending、无人认领且尚未绑定,随后检查名称、路径、分支和 Git 注册信息,创建 checkout,最后才写入任务绑定。如果 Git 报告失败却已经留下分支或已注册的 checkout,运行时会报告 partial operation,让任务保持未绑定,并保留这些内容供人工恢复。队友只使用任务工具和文件工具。\n\n认领任务时,运行时会把解析后的目录写入 `teammate_assignments`。该队友的 `bash`、`read_file`、`write_file`、`edit_file` 和 `glob` 都从 assignment 读取目录。没有绑定 worktree 的任务解析到 `WORKDIR`;没有认领任务的队友不能使用这些工作区工具:\n\n```python\ncwd, error = task_worktree_cwd(task)\nif not error:\n teammate_assignments[owner] = {\n \"task_id\": task.id,\n \"cwd\": cwd,\n }\n```\n\n`complete_task(task_id, owner)` 会检查调用者是否拥有这个进行中的任务。成功完成只记录结果,不会马上清除 assignment;直到当前模型轮次结束,后续工具调用仍使用这个任务目录。队友回到 IDLE 时,运行时才释放 assignment。完成失败时也会保留目录,方便修正后重试。\n\n进程重启后,`assignment_cwd()` 可以根据持久化任务中的 owner 和 worktree 绑定恢复进行中的 assignment。同一 owner 已转到新任务时,它也会替换本地的旧 lease。若绑定丢失或无效,它会直接失败,不会把操作悄悄切回仓库目录。\n\n> Worktree 只分开 Git 工作目录和分支,不是安全沙箱。Shell 命令仍能访问父进程有权访问的路径和资源。\n\n### 10. Worktree 移除由宿主负责\n\n模型可以创建任务绑定的 worktree,但不能移除它。清理保留为宿主函数,让用户或宿主先检查任务所有权、assignment lease 和 Git 状态。这个函数会拒绝 pending 或 in-progress 绑定以及当前轮次仍在使用的 lease。未明确选择破坏性移除时,已跟踪、未跟踪和已忽略文件都会阻止清理。\n\n`remove_worktree(name, discard_changes=True)` 只供已经另行取得用户明确确认的宿主调用。两种移除路径都会保留仓库里的 `wt/<name>` 分支,包括没有 upstream 的干净本地提交。移除成功后,任务绑定会被清空。\n\n```text\n干净 worktree → 宿主可移除目录,保留 wt/<name> 分支\n有改动 worktree → 由用户决定保留还是丢弃\n待办/进行中任务 → 拒绝移除\n```\n\n任务完成与 worktree 清理也互相独立。`complete_task` 记录任务结果;队友回到 IDLE 后,用户或宿主才检查、合并、保留或移除 worktree。\n\n### 11. 控制消息使用类型和 request_id\n\n普通协作可以使用自由文本,关机和审批则不能依靠猜测消息意图。它们使用结构化消息:\n\n\n\n```python\n@dataclass\nclass ProtocolState:\n request_id: str\n type: str\n sender: str\n target: str\n status: str\n payload: str\n work_version: int | None = None\n task_id: str | None = None\n\n\npending_requests: dict[str, ProtocolState] = {}\n```\n\n关机路径如下:\n\n```text\nLead 创建 pending 状态的关机请求\n → shutdown_request(request_id) 进入队友收件箱\n → 队友完成当前步骤\n → shutdown_response(request_id) 返回 Lead\n → request_id 找到原始请求\n → pending 变为 approved,队友循环退出\n```\n\nID 把回复关联到请求,类型阻止不匹配的回复修改状态,状态则阻止同一回复重复生效。\n\n### 12. 计划审批会约束执行\n\n计划协议的方向相反:\n\n```text\nLead → plan_request\n队友 → plan_approval_request(request_id, plan)\nLead → plan_approval_response(request_id, approve, feedback)\n```\n\n如果 Lead 在启动队友前就知道必须先看计划,可以调用 `spawn_teammate(..., task_id=task.id, require_plan=True)`;运行时会先认领任务并打开闸门,再启动线程。对于已经运行的队友,也可以再用 `request_plan` 要求其提交计划。\n\n工具分发层负责执行闸门:\n\n```python\ndef _run_teammate_tool(name, block, handlers):\n gate = plan_gates.get(name, \"not_required\")\n if block.name in {\"bash\", \"write_file\", \"edit_file\"} and gate not in {\n \"not_required\", \"approved\"\n }:\n return f\"Blocked: plan status is {gate}.\"\n try:\n return handlers[block.name](**block.input)\n except Exception as error:\n return f\"Error: {type(error).__name__}: {error}\"\n```\n\n状态是 `required`、`pending` 或 `rejected` 时,队友可以读取文件、提交或修改计划,但不能运行 Shell 命令、写文件或编辑文件。提交计划时会记录队友当前的 task 和 work version;审批返回时两者仍然一致才会生效。认领或释放任务会改变 work version,使旧审批失效;普通消息不会改变任务身份或审批状态。\n\n队友不会直接从后台线程读取用户输入。遇到需要用户确认的危险命令或工作区外路径时,工具会返回 permission 错误,由 Lead 与用户处理。\n\n---\n\n## 一次完整运行\n\n```text\ns13 >> 把后端重构拆到共享任务板,尽量并行完成配置、认证和测试。\n 认证任务使用 worktree,保持现有接口,并确保测试通过。\n\nLead:我建议按 config、auth 和 tests 三个方向分工。\n 是否启动团队?\n\ns13 >> 开始吧\n\n[task] config created\n[task] auth created → worktree auth-refactor\n[task] tests created\n[claim] alice → config (cwd: repository)\n[claim] bob → auth (cwd: .worktrees/auth-refactor)\n[teammate] alice spawned\n[teammate] bob spawned\n[complete] auth\n[bus] bob → lead (result) ...\n[bus] bob → lead (idle_notification) ...\n[wake: 2 team events → new turn]\nLead:我已收到认证任务的结果,接下来继续协调其余工作。\n```\n\n终端会显示用户请求、Lead 的团队方案、任务状态、认领结果、所选目录、结果、IDLE 切换和控制事件。用户不需要指定谁是 Lead,也不必提醒它检查收件箱。\n\n---\n\n## 相对 s10 的变化\n\n| 组件 | s10 | s13 |\n|---|---|---|\n| Agent | 单个 Agent | 一个 Lead 加持久队友 |\n| 用户流程 | 直接执行请求 | 先提团队方案,再确认启动 |\n| 通信 | 无 | 文件收件箱加运行时投递 |\n| 生命周期 | 一个循环 | 队友 `WORK / IDLE / shutdown` |\n| 共享工作 | 单 Agent 使用任务工具 | IDLE 扫描加队友原子认领 |\n| 工作目录 | 仓库 `WORKDIR` | 必须认领任务;任务可选 worktree |\n| 结果上报 | 当前 Agent 输出 | 分开的 `result` 与 `idle_notification` |\n| 控制 | 无 | 类型化关机与计划审批协议 |\n| 执行约束 | 无团队约束 | 必需计划会锁住修改型工具 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s13_agent_teams/code.py\n```\n\n输入一个自然需求:\n\n```text\n把后端重构拆到共享任务板,在依赖允许时并行完成配置、认证和测试。\n认证任务使用 worktree,保持现有接口,并在最后汇总结果。\n```\n\nLead 提出团队方案后回复:\n\n```text\n开始吧\n```\n\n观察 `.tasks/` 如何从 `pending` 进入 `in_progress` 和 `completed`,`.mailboxes/` 如何投递 `result` 与 `idle_notification`,以及 `.worktrees/` 是否只为绑定的任务创建。还可以检查直接消息是否先于任务板扫描,以及 `complete_task` 失败后队友的工作目录是否保持不变。\n\n---\n\n## 接下来\n\nLead 和队友目前只能调用直接写在 `code.py` 里的工具。接入 Jira、部署平台或知识库时,Harness 还要为每个外部系统分别编写工具定义和调用逻辑;外部系统增加或修改工具,也要跟着修改课程代码。\n\ns14 MCP Tools → 通过统一的发现与调用协议,在运行时连接外部服务并把它们的工具加入工具池。\n\n<!-- translation-sync: zh@v12, en@v12, ja@v12 -->\n"
|
||
},
|
||
{
|
||
"version": "s13",
|
||
"locale": "ja",
|
||
"title": "s13: Agent Teams — チームランタイムと協調プロトコル",
|
||
"content": "# s13: Agent Teams — チームランタイムと協調プロトコル\n\ns01 → ... → [s10](/ja/s10) → `s13` → [s14](/ja/s14) → s15 → s16 → s17\n\n> *「1 つの Agent で仕事全体を抱えきれないなら、チームメイトで分担する。」* — 永続チームメイト、共有タスクの Claim、任意の worktree、協調プロトコル。\n>\n> **Harness レイヤー**:Team — 複数の Agent が Lead の管理下で仕事を分担し、状態を共有する仕組み。\n\n---\n\n## 問題\n\nAgent にバックエンド全体のリファクタリングを依頼するとする。作業範囲は設定の読み込み、認証、テストにまたがる。1 つの Agent でも順番に処理できるが、時間がかかり、初期の詳細は少しずつコンテキストから抜けていく。\n\nこの仕事は並列化に向いている。ただし、ユーザーは通常、チーム構成ではなく目標を伝える:\n\n```text\nこのサンプルバックエンドをリファクタリングしてください。\n設定の読み込み、認証、テストを整理し、既存インターフェースを保ち、\nテストが通ることを確認してください。\n```\n\nHarness は、つながった 6 つの問題を扱う必要がある:\n\n1. 並列作業が有効だと誰が判断し、追加の Agent を誰が承認するのか。\n2. 各チームメイトは、複数の割り当てをまたいで識別子とコンテキストをどう保つのか。\n3. モデルに受信箱をポーリングさせず、結果を Lead へどう返すのか。\n4. IDLE のチームメイトは、次の指示を待たずに ready task を引き受けられるか。\n5. 並列編集が衝突し得る時、タスクはどの作業ディレクトリを使うのか。\n6. shutdown と計画承認を、追跡できて実際に制約をかけるプロトコルにするにはどうするか。\n\n---\n\n## 解決策\n\n\n\ns13 は s10 の基本ツール、Hooks、Permission、Task System を再利用し、Lead 管理のチームランタイムを加える:\n\n- **Lead** はユーザーとの会話を担当し、分担案を示して確認を待つ。\n- **チームメイト** は独立した Agent Loop を実行し、WORK と IDLE を行き来する。\n- **MessageBus** は、ファイルベースの受信箱で通常メッセージ、結果、制御イベントを運ぶ。\n- **ランタイム配信** は Lead の受信箱を消費し、チームイベントを次のターンへ追加する。\n- **共有タスクボード** により、IDLE のチームメイトは ready task を探し、ロック下で Claim できる。\n- **任意の worktree** は、必要なタスクだけを別の作業ディレクトリへ紐付ける。紐付けのないタスクは通常のリポジトリディレクトリを使う。\n- **型付きプロトコルと計画ゲート** は shutdown と承認状態を明示し、必要な計画が承認されるまで変更系ツールを止める。\n\nタスクグラフの作成は s10 の 2 段階契約を維持する。Lead はまず全ノードに `create_task` を呼び、返された実行時 ID で `update_task(addBlockedBy=...)` を実行してから ready task を割り当てる。`update_task` を使えるのは Lead だけであり、チームメイトは一覧・Claim・完了はできるが、チーム実行中にグラフ構造を変更できない。\n\ns11 の background task と s12 の scheduled task は本章へ持ち込まない。どちらも teammate communication、task claim、plan approval には必要ない。\n\nこれらはすべて Team Harness レイヤーの一部である。タスク発見のために別の Agent Loop は要らず、worktree が別種の Agent を作るわけでもない。\n\n---\n\n## 仕組み\n\n### 1. Lead はチーム案を示し、ユーザーの確認を待つ\n\nチームメイトを起動すると、コスト、並行度、ワークスペースを編集できる主体が変わる。Lead のシステムプロンプトは、その境界を明示する:\n\n```python\n\"When parallel work would help, first propose a small team with clear \"\n\"responsibilities and wait for the user's confirmation. Do not call \"\n\"spawn_teammate before the user confirms.\"\n```\n\n最初の要求に対して、Lead は分担案だけを示す:\n\n```text\n3 つの領域を並行して進めることを提案します:\n- config:設定の読み込みを整理\n- auth:認証をリファクタリング\n- tests:回帰テストを追加\n\n確認後にチームメイトを起動します。\n```\n\nユーザーが「始めてください」と返した後、Lead は `spawn_teammate` を呼べる。Lead は先に Task を作り、初期 `task_id` をチームメイトへ渡す。ユーザーが目標を示し、Lead がチームを設計し、ユーザーが実行境界を確認する。\n\n### 2. 各チームメイトは独立したループを持つ\n\ns06 の subagent は 1 回限りの呼び出しである。チームメイトは永続する実行単位だ:\n\n| | s06 Subagent | s13 Teammate |\n|---|---|---|\n| ライフサイクル | 1 回の呼び出し後に終了 | shutdown まで `WORK → IDLE → WORK` |\n| コンテキスト | 1 つのタスクにだけ存在 | 割り当てをまたいで保持 |\n| 通信 | 1 回だけ結果を返す | メッセージを受け取りイベントを送る |\n| 協調 | 一方向の委譲 | Lead との双方向協調 |\n\n`TeammateRuntime` は、各チームメイト専用のシステムプロンプト、messages、ツール、現在の Task を保持し、daemon thread で WORK / IDLE loop を実行する。チームメイトの作業中も Lead は調整を続けられる。`lead` と `agent` はランタイム識別子として予約されるが、`MessageBus` はコーディネーターの受信箱として `lead` を引き続き受け付ける。\n\n`spawn_teammate` は thread を開始する前に初期 Task を Claim する。Claim に失敗した場合、チームメイトは起動しない。Task がない状態では workspace tool と Shell tool は repository directory へ戻らず、先に Task を Claim するよう求める。\n\n### 3. MessageBus は通信をモデルのコンテキスト外に置く\n\nLead とチームメイトは同じ messages 配列を共有できない。共有すると、あるチームメイトのツール結果が別のチームメイトの推論へ混ざる。`MessageBus` は Agent ごとに `.mailboxes/<name>.jsonl` 受信箱を用意する:\n\n```python\nclass MessageBus:\n def send(self, from_agent, to_agent, content,\n msg_type=\"message\", metadata=None):\n msg = {\n \"from\": from_agent,\n \"to\": to_agent,\n \"content\": content,\n \"type\": msg_type,\n \"metadata\": metadata or {},\n }\n with self._changed:\n MAILBOX_DIR.mkdir(parents=True, exist_ok=True)\n with self._path(to_agent).open(\"a\", encoding=\"utf-8\") as handle:\n handle.write(json.dumps(msg, ensure_ascii=True) + \"\\n\")\n self._changed.notify_all()\n\n def wait_for_messages(self, agent, timeout=None):\n deadline = None if timeout is None else time.monotonic() + timeout\n with self._changed:\n while not self.peek(agent):\n remaining = (None if deadline is None\n else deadline - time.monotonic())\n if remaining is not None and remaining <= 0:\n return []\n self._changed.wait(remaining)\n return self._read_unlocked(agent)\n```\n\nロックは、チームメイトによる受信箱ファイルの並行アクセスを保護する。`Condition` はメッセージ到着時にチームメイトを起こし、IDLE 中の短い timeout にも使える。\n\n### 4. 受信イベントはランタイムが配信する\n\n`read_inbox()` は受信箱ファイルを読み取って削除するため、Lead 側の消費処理は `consume_lead_inbox()` だけにする:\n\n```python\ndef consume_lead_inbox():\n messages = BUS.read_inbox(\"lead\")\n for message in messages:\n if message[\"type\"].endswith(\"_response\"):\n match_response(...)\n return messages\n```\n\nCLI のメインループは terminal input と Lead の受信箱を同時に待つ。新しいメッセージが届くと、受信箱を消費してから Lead の次ターンを始める:\n\n```text\nMessageBus → consume_lead_inbox\n → プロトコル状態を更新\n → [Team events] を history に追加\n → Lead の次ターンを開始\n```\n\nLead は teammate を起動した後、`list_teammates` や `get_task` を繰り返して待たず、現在の turn を終了する。team event が届くと runtime が次の turn を開始する。\n\n`check_inbox` はモデルのツールではない。メッセージの到着と消費はランタイムが担当し、モデルはコンテキストへ配信済みのイベントを処理する。\n\n### 5. 結果と IDLE は別のイベントである\n\nチームメイトが 1 つの割り当てを終えると、ランタイムは 2 つのイベントを順に送る:\n\n```text\nresult: \"認証をリファクタリングし、関連テストが通りました。\"\nidle_notification: \"Waiting for more work.\"\n```\n\n`result` は「この割り当てで何ができたか」、`idle_notification` は「このチームメイトが次の仕事を受けられるか」を表す。曖昧な「完了」だけでは、両方の状態を表せない。\n\nIDLE のチームメイトは終了しない。直接メッセージか ready task を受けると WORK に戻り、`shutdown_request` を受けると段階的な shutdown handshake を始める。\n\n### 6. IDLE は受信箱を先に確認し、その後 ready task を探す\n\nIDLE ではメッセージを優先し、その後に共有タスクボードを確認する:\n\n```python\nwhile True:\n inbox = BUS.wait_for_messages(name, IDLE_SCAN_INTERVAL)\n if inbox:\n should_stop = handle_messages(inbox)\n if should_stop or messages[-1][\"role\"] == \"user\":\n break\n continue\n\n task = claim_next_task(name)\n if task:\n messages.append({\n \"role\": \"user\",\n \"content\": f\"[Auto-claimed task {task.id}] {task.subject}\",\n })\n break\n```\n\nshutdown、計画承認、Lead からの直接指示は、空き時間に見つけた仕事より先に扱う。メッセージも ready task もなければ、チームメイトは IDLE を続ける。別のチームメイトが前提タスクを完了すると、blocked task が ready になることもある。\n\n### 7. 発見と Claim を分け、Claim はアトミックに行う\n\n走査は候補を探すだけで、状態を変更しない:\n\n```python\ndef scan_unclaimed_tasks() -> list[Task]:\n return [\n task for task in list_tasks()\n if task.status == \"pending\"\n and task.owner is None\n and can_start(task.id)\n ]\n```\n\n候補一覧は一時点の snapshot にすぎない。別のチームメイトだけでなく、同じ task directory を使う別の Harness process も同じ task を見る可能性がある。そのため、所有権の変更は process 内 lock と file lock を組み合わせた `task_store_lock()` の下で `claim_task()` が行う:\n\n```python\ndef claim_task(task_id: str, owner: str) -> str:\n with task_store_lock():\n task = load_task(task_id)\n if task.status != \"pending\" or task.owner is not None:\n return \"Task is no longer available\"\n if _owner_in_progress(owner):\n return \"Owner must complete its current task first\"\n if not can_start(task_id):\n return \"Task is blocked\"\n cwd, error = task_worktree_cwd(task)\n if error:\n return f\"Cannot claim {task_id}: {error}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n teammate_assignments[owner] = {\"task_id\": task.id, \"cwd\": cwd}\n return f\"Claimed {task.id}\"\n```\n\n複数のチームメイトが同じ候補を発見しても、`in_progress` へ進められる Claim は 1 つだけである。同じ store lock を保持したまま temporary file へ書き、正式な task file を atomic に置き換える。現在のタスクを完了するまで、チームメイトは次のタスクを Claim できない。worktree の紐付けが壊れている場合、リポジトリディレクトリへ戻さず Claim を失敗させる。\n\n### 8. Claim した仕事は同じ WORK ループを再利用する\n\nClaim に成功すると、ランタイムはタスク ID、件名、説明をチームメイトの messages へ追加する:\n\n```text\nready task が現れる\n → IDLE のチームメイトが発見\n → claim_task が owner と in_progress を記録\n → タスクがチームメイトの messages に入る\n → WORK\n → complete_task\n → result + idle_notification\n → IDLE\n```\n\nチームメイトは、Lead が直接割り当てた時と同じモデル呼び出し、ファイルツール、Shell、計画ゲート、結果通知、shutdown protocol を使う。タスク発見は、既存の WORK ループへの別の入口である。\n\n### 9. タスクがツールの作業ディレクトリを選ぶ\n\n`Task.worktree` は任意フィールドである:\n\n```python\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str\n owner: str | None\n blockedBy: list[str]\n worktree: str | None = None\n```\n\n並列編集を別ディレクトリに分けたい時、Lead は worktree を作成してタスクへ紐付けられる:\n\n```python\ncreate_worktree(name=\"auth-refactor\", task_id=\"task_1a2b3c4d\")\n```\n\n`create_worktree` は Lead 専用ツールである。pending、owner なし、worktree 未設定のタスクを受け取り、名前、パス、ブランチ、Git registry を確認する。checkout の作成後にだけタスクへ紐付ける。Git が失敗を返しても branch や登録済み checkout が残った場合は partial operation を報告し、task は未紐付けのまま、それらを manual recovery 用に保持する。チームメイトが使うのはタスクツールとファイルツールである。\n\nClaim 時に、解決済みのディレクトリを `teammate_assignments` へ保存する。チームメイトの `bash`、`read_file`、`write_file`、`edit_file`、`glob` wrapper は assignment からディレクトリを読む。worktree のないタスクは `WORKDIR` に解決されるが、Task を Claim していないチームメイトはこれらの workspace tool を使えない:\n\n```python\ncwd, error = task_worktree_cwd(task)\nif not error:\n teammate_assignments[owner] = {\n \"task_id\": task.id,\n \"cwd\": cwd,\n }\n```\n\n`complete_task(task_id, owner)` は、呼び出し元が進行中タスクの owner か確認する。成功時は結果を記録するが assignment をすぐには解除せず、同じ model turn の後続 tool call もそのタスクの directory を使う。チームメイトが IDLE に戻る時にランタイムが assignment を解除する。失敗時も directory を維持し、修正して再試行できるようにする。\n\nprocess 再起動後、`assignment_cwd()` は永続化された task owner と worktree binding から進行中の assignment を復元できる。同じ owner が別の task へ移った場合は、local の古い lease も置き換える。binding が見つからない、または無効な場合は repository directory へ戻さず失敗する。\n\n> Worktree が分離するのは Git の作業ディレクトリとブランチであり、sandbox ではない。Shell コマンドは親プロセスに許可されたパスやリソースへアクセスできる。\n\n### 10. Worktree の削除は host が担う\n\nモデルは task-bound worktree を作成できるが、削除はできない。cleanup は host helper として残し、user または host が task ownership、assignment lease、Git status を先に確認する。helper は pending または in-progress の binding と current turn の lease を拒否する。明示的に破壊的削除を選ばない限り、tracked、untracked、ignored file はすべて cleanup を止める。\n\n`remove_worktree(name, discard_changes=True)` は、user の明示的な確認を別途得た host からのみ呼び出す。どちらの削除経路でも `wt/<name>` ブランチはリポジトリに残り、upstream のない clean な local commit も保持される。削除成功後は task binding を解除する。\n\n```text\nclean worktree → host が directory を削除し、wt/<name> branch を保持できる\nchanged worktree → 保持か破棄かを user が決める\npending/running task → 削除を拒否\n```\n\nタスク完了と worktree cleanup も分かれている。`complete_task` はタスク結果を記録し、teammate が IDLE に戻った後で user または host が worktree を確認、merge、keep、remove できる。\n\n### 11. 制御メッセージには型と request_id を使う\n\n通常の協調には自由形式のテキストを使えるが、shutdown と承認を意図の推測に任せるべきではない。これらは構造化メッセージを使う:\n\n\n\n```python\n@dataclass\nclass ProtocolState:\n request_id: str\n type: str\n sender: str\n target: str\n status: str\n payload: str\n work_version: int | None = None\n task_id: str | None = None\n\n\npending_requests: dict[str, ProtocolState] = {}\n```\n\nshutdown の流れは次の通り:\n\n```text\nLead が pending の shutdown request を作る\n → shutdown_request(request_id) がチームメイトの受信箱に入る\n → チームメイトが現在のステップを終える\n → shutdown_response(request_id) が Lead へ戻る\n → request_id で元の request を特定する\n → pending が approved になり、チームメイトの loop が終了する\n```\n\nID は応答を 1 つの request に対応付け、型は不一致の応答による状態変更を防ぎ、status は同じ応答の二重適用を防ぐ。\n\n### 12. 計画承認は実行も制約する\n\n計画プロトコルは逆方向に進む:\n\n```text\nLead → plan_request\nチームメイト → plan_approval_request(request_id, plan)\nLead → plan_approval_response(request_id, approve, feedback)\n```\n\nLead が起動前から plan を必須にしたい場合は、`spawn_teammate(..., task_id=task.id, require_plan=True)` を使う。runtime は Task を Claim し、gate を有効にしてから teammate thread を開始する。すでに動いている teammate には `request_plan` で plan を要求できる。\n\nツール dispatch がゲートを強制する:\n\n```python\ndef _run_teammate_tool(name, block, handlers):\n gate = plan_gates.get(name, \"not_required\")\n if block.name in {\"bash\", \"write_file\", \"edit_file\"} and gate not in {\n \"not_required\", \"approved\"\n }:\n return f\"Blocked: plan status is {gate}.\"\n try:\n return handlers[block.name](**block.input)\n except Exception as error:\n return f\"Error: {type(error).__name__}: {error}\"\n```\n\n状態が `required`、`pending`、`rejected` の間、チームメイトはファイルを読み、計画を提出または修正できるが、Shell コマンドの実行、ファイルの書き込み、編集はできない。提出時には current task と work version を記録し、承認時に両方が一致する場合だけ有効になる。Task の Claim または release は work version を変えて古い承認を無効にするが、通常の message は task identity も approval state も変えない。\n\nチームメイトは background thread から user input を直接読まない。危険な command や workspace 外の path は permission error を返し、Lead が user と判断する。\n\n---\n\n## 一連の実行例\n\n```text\ns13 >> バックエンドのリファクタリングを共有タスクボードに分解し、\n 設定、認証、テストを可能な範囲で並行実行してください。\n 認証には worktree を使い、既存インターフェースを保ち、\n テストが通ることを確認してください。\n\nLead:config、auth、tests の 3 領域に分けることを提案します。\n チームを起動しますか?\n\ns13 >> 始めてください\n\n[task] config created\n[task] auth created → worktree auth-refactor\n[task] tests created\n[claim] alice → config (cwd: repository)\n[claim] bob → auth (cwd: .worktrees/auth-refactor)\n[teammate] alice spawned\n[teammate] bob spawned\n[complete] auth\n[bus] bob → lead (result) ...\n[bus] bob → lead (idle_notification) ...\n[wake: 2 team events → new turn]\nLead:認証タスクの結果を受け取りました。残りの作業を調整します。\n```\n\nターミナルには、ユーザーの要求、Lead の提案、タスク状態、Claim、選択されたディレクトリ、結果、IDLE 遷移、制御イベントが表示される。ユーザーが Lead を指定したり、受信箱の確認を依頼したりする必要はない。\n\n---\n\n## s10 からの変更\n\n| コンポーネント | s10 | s13 |\n|---|---|---|\n| Agent | 1 つの Agent | 1 つの Lead と永続チームメイト |\n| ユーザーフロー | 要求を実行 | チーム案を示してから起動確認 |\n| 通信 | なし | ファイル受信箱とランタイム配信 |\n| ライフサイクル | 1 つのループ | チームメイトの `WORK / IDLE / shutdown` |\n| 共有作業 | 1 つの Agent がタスクツールを使用 | IDLE 走査とチームメイトのアトミックな Claim |\n| 作業ディレクトリ | リポジトリの `WORKDIR` | Claim 済み Task、必要に応じて worktree |\n| 結果通知 | 現在の Agent の出力 | `result` と `idle_notification` を分離 |\n| 制御 | なし | 型付き shutdown と計画承認プロトコル |\n| 強制 | チーム向け制約なし | 必須計画が変更系ツールをゲート |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s13_agent_teams/code.py\n```\n\n通常の要求を入力する:\n\n```text\nバックエンドのリファクタリングを共有タスクボードへ分解し、依存関係が\n許す範囲で設定、認証、テストを並行実行してください。認証には worktree\nを使い、既存インターフェースを維持して、最後に結果をまとめてください。\n```\n\nLead がチーム案を示したら、次のように返す:\n\n```text\n始めてください\n```\n\n`.tasks/` が `pending`、`in_progress`、`completed` と変化する様子、`.mailboxes/` が `result` と `idle_notification` を配信する様子、紐付けたタスクにだけ `.worktrees/` が作られることを確認する。直接メッセージがタスクボード走査より優先されることと、`complete_task` の失敗後もチームメイトの作業ディレクトリが変わらないことも確認できる。\n\n---\n\n## 次の章\n\nLead と teammate が呼び出せるのは、`code.py` に直接定義したツールだけである。Jira、デプロイ基盤、ナレッジベースへ接続するには、外部システムごとに tool schema と handler を書く必要があり、外部ツールの追加や変更に合わせてコースコードも修正しなければならない。\n\ns14 MCP Tools → 共通の発見・呼び出しプロトコルで実行時に外部サービスへ接続し、そのツールを tool pool に追加する。\n\n<!-- translation-sync: zh@v12, en@v12, ja@v12 -->\n"
|
||
},
|
||
{
|
||
"version": "s14",
|
||
"locale": "en",
|
||
"title": "s14: MCP Tools — Discover and Invoke External Tools",
|
||
"content": "# s14: MCP Tools — Discover and Invoke External Tools\n\n[s04](/en/s04) → `s14` → [s15](/en/s15) → s16 → s17\n\n> **Harness layer**: MCP Tools — connect to services, discover tools, and add them to the agent loop.\n\n---\n\n## The Problem\n\nThe base tools in earlier chapters are written directly in `code.py`. We could integrate a documentation system and deployment platform by adding `search_docs`, `deploy_status`, and `trigger_deploy`, but every service would require another set of tool definitions, parameter schemas, and call handlers.\n\nMCP separates those responsibilities. A server provides a tool list and invocation endpoint. The harness connects to it, assigns model-facing names, applies permission checks, and gives the discovered tools to the model.\n\n---\n\n## The Solution\n\n\n\nThis chapter starts from s04's five base tools and hooks, then adds three parts:\n\n- `MCPClient` stores the tool definitions and call handlers returned by a server.\n- `connect_mcp` connects to one server and obtains its tool list.\n- `assemble_tool_pool` combines the base tools with tools from every connected server.\n\nThe `docs` and `deploy` servers are in-process stand-ins for `tools/list`, `tools/call`, and a dynamic tool pool. This chapter does not implement a real MCP transport.\n\n---\n\n## How It Works\n\n### 1. The base agent loop stays the same\n\nBefore each model call, the harness assembles the current tool pool:\n\n```python\ndef agent_loop(messages: list):\n while True:\n tools, handlers = assemble_tool_pool()\n response = client.messages.create(\n model=MODEL,\n system=assemble_system_prompt(),\n messages=messages,\n tools=tools,\n max_tokens=8000,\n )\n ...\n```\n\nAfter a new server connects, the next `assemble_tool_pool()` call adds its tools to the model input. Tool results are still appended to messages as `tool_result` blocks.\n\n### 2. MCPClient stores discovery results and call handlers\n\n```python\nclass MCPClient:\n def register(self, tool_defs, handlers):\n self.tools = list(tool_defs)\n self._handlers = dict(handlers)\n\n def call_tool(self, tool_name, args):\n handler = self._handlers.get(tool_name)\n if not handler:\n return f\"MCP error: unknown tool '{tool_name}'\"\n try:\n return str(handler(**args))\n except Exception as error:\n return f\"MCP error: {type(error).__name__}: {error}\"\n```\n\n`register()` represents the discovered tool list. `call_tool()` represents the invocation boundary. Errors return to the model instead of terminating the agent loop.\n\n### 3. connect_mcp only connects and discovers\n\n```python\ndef connect_mcp(name: str) -> str:\n if name in mcp_clients:\n return f\"MCP server '{name}' already connected\"\n factory = MOCK_SERVERS.get(name)\n if not factory:\n return f\"Unknown server '{name}'\"\n server = factory()\n mcp_clients[name] = server\n ...\n```\n\nInitially, the model sees the five base tools and `connect_mcp`. After `connect_mcp(name=\"docs\")`, the harness stores the docs client. The next model call also sees:\n\n```text\nmcp__docs__search\nmcp__docs__get_version\n```\n\n### 4. Prefixes separate tools from different servers\n\nSeveral servers may expose `search` or `status`. The harness uses:\n\n```text\nmcp__{server}__{tool}\n```\n\n`normalize_mcp_name()` replaces characters outside the model tool-name alphabet with underscores. Tool-pool assembly also checks normalized-name collisions and the 64-character limit:\n\n```python\nprefixed = f\"mcp__{safe_server}__{safe_tool}\"\nif prefixed in origins:\n raise ValueError(\"MCP tool name collision after normalization\")\n```\n\nAs a result, `docs.one/get.version` and `docs_one/get_version` cannot silently map to the same name.\n\n### 5. Tool definitions and handlers enter the pool together\n\n```python\ntools.append({\n \"name\": prefixed,\n \"description\": tool_def.get(\"description\", \"\"),\n \"input_schema\": schema,\n})\nhandlers[prefixed] = (\n lambda *, client=server, tool=raw_name, **kwargs:\n client.call_tool(tool, kwargs)\n)\n```\n\nThe model sees the prefixed name. The handler calls `MCPClient` with the server's original tool name. Default arguments capture the current client and tool so every lambda does not point to the last item in the loop.\n\n### 6. The host decides permissions\n\nAn MCP server may provide `readOnlyHint` or `destructiveHint`, but those hints come from the server and are not authorization. This chapter uses a host-side policy:\n\n```python\nMCP_HOST_POLICY = {\n (\"docs\", \"search\"): \"allow\",\n (\"docs\", \"get_version\"): \"allow\",\n (\"deploy\", \"status\"): \"allow\",\n (\"deploy\", \"trigger\"): \"confirm\",\n}\n```\n\n`permission_hook()` looks up this policy using the normalized tool name. An unconfigured external tool requires confirmation by default. A description containing `readOnly` does not make a tool trusted.\n\n### 7. Input errors stay at the tool boundary\n\nThe model may omit a required argument or send a field the server does not accept. Both `execute_tool()` and `MCPClient.call_tool()` catch those errors and return an error `tool_result`:\n\n```text\nMCP error: TypeError: <lambda>() missing 1 required argument: 'query'\n```\n\nThe model can correct its arguments on the next turn without terminating the lesson script.\n\n---\n\n## What Changed from s04\n\n| Component | s04 | s14 |\n|---|---|---|\n| Base tools | Five fixed tools | Unchanged |\n| Tool source | Definitions in `code.py` | Base tools plus discovered MCP tools |\n| Tool pool | Fixed `TOOLS` | Built each turn by `assemble_tool_pool()` |\n| External tool names | None | `mcp__{server}__{tool}` |\n| Permission | Shell and path checks | Adds a host-side MCP policy |\n| MCP transport | None | In-process server stand-ins demonstrate the boundary |\n\nThis chapter does not carry Task, Background, Cron, Team, or Worktree. They join MCP in the s15 Integrated Harness.\n\n---\n\n## Try It Out\n\n```sh\ncd learn-claude-code\npython s14_mcp_plugin/code.py\n```\n\nEnter:\n\n```text\nConnect to the docs server, search for agent hooks, and tell me the current documentation API version.\n```\n\nA typical tool trace is:\n\n```text\nconnect_mcp(name=\"docs\")\nmcp__docs__search(query=\"agent hooks\")\nmcp__docs__get_version()\n```\n\nThen enter:\n\n```text\nConnect to the deploy server and check the web service status. Do not trigger a deployment.\n```\n\n`status` runs under the host policy. `trigger` requires user confirmation.\n\n---\n\n## What's Next\n\nMCP is still an independent course branch here. s15 Integrated Harness combines the base tools, hooks, skills, context, memory, tasks, background work, cron, teams, and MCP in one runtime.\n\n<!-- translation-sync: zh@v9, en@v9, ja@v9 -->\n"
|
||
},
|
||
{
|
||
"version": "s14",
|
||
"locale": "zh",
|
||
"title": "s14: MCP Tools — 发现并调用外部工具",
|
||
"content": "# s14: MCP Tools — 发现并调用外部工具\n\n[s04](/zh/s04) → `s14` → [s15](/zh/s15) → s16 → s17\n\n> **Harness 层**:MCP Tools — 连接服务、发现工具,并把它们加入 Agent 的工具循环。\n\n---\n\n## 问题\n\n前面的基础工具都直接写在 `code.py` 里。接入文档系统和部署平台时,我们还可以继续手写 `search_docs`、`deploy_status` 和 `trigger_deploy`,但每增加一个服务,都要重新维护工具定义、参数格式和调用代码。\n\nMCP 把这部分拆成两个角色:server 提供工具列表和调用入口,Harness 负责连接、命名、权限检查,并把发现的工具交给模型。\n\n---\n\n## 解决方案\n\n\n\n本章从 s04 的五个基础工具和 Hooks 出发,增加三个部分:\n\n- `MCPClient` 保存 server 返回的工具定义和调用入口。\n- `connect_mcp` 连接一个 server,并取得它的工具列表。\n- `assemble_tool_pool` 把基础工具与已经连接的 MCP 工具组装到同一个工具池。\n\n课程里的 `docs` 和 `deploy` 是进程内模拟 server,用来展示 `tools/list`、`tools/call` 和动态工具池。真实 MCP transport 不在本章实现。\n\n---\n\n## 工作原理\n\n### 1. 基础 Agent Loop 不需要改变\n\n每轮调用模型前,Harness 组装当前工具池:\n\n```python\ndef agent_loop(messages: list):\n while True:\n tools, handlers = assemble_tool_pool()\n response = client.messages.create(\n model=MODEL,\n system=assemble_system_prompt(),\n messages=messages,\n tools=tools,\n max_tokens=8000,\n )\n ...\n```\n\n连接新 server 后,下一轮 `assemble_tool_pool()` 会把新工具加入模型输入。工具执行后,结果仍作为 `tool_result` 追加到 messages。\n\n### 2. MCPClient 保存发现结果和调用入口\n\n```python\nclass MCPClient:\n def register(self, tool_defs, handlers):\n self.tools = list(tool_defs)\n self._handlers = dict(handlers)\n\n def call_tool(self, tool_name, args):\n handler = self._handlers.get(tool_name)\n if not handler:\n return f\"MCP error: unknown tool '{tool_name}'\"\n try:\n return str(handler(**args))\n except Exception as error:\n return f\"MCP error: {type(error).__name__}: {error}\"\n```\n\n`register()` 对应课程里的工具发现结果,`call_tool()` 对应调用入口。错误会返回给模型,不会直接结束 Agent Loop。\n\n### 3. connect_mcp 只负责连接和发现\n\n```python\ndef connect_mcp(name: str) -> str:\n if name in mcp_clients:\n return f\"MCP server '{name}' already connected\"\n factory = MOCK_SERVERS.get(name)\n if not factory:\n return f\"Unknown server '{name}'\"\n server = factory()\n mcp_clients[name] = server\n ...\n```\n\n开始时,模型只看到五个基础工具和 `connect_mcp`。调用 `connect_mcp(name=\"docs\")` 后,Harness 保存 docs client。下一轮模型调用会看到:\n\n```text\nmcp__docs__search\nmcp__docs__get_version\n```\n\n### 4. 前缀区分不同 server 的同名工具\n\n多个 server 都可能提供 `search` 或 `status`。Harness 使用:\n\n```text\nmcp__{server}__{tool}\n```\n\n`normalize_mcp_name()` 把不适合模型工具名的字符替换为下划线。组装工具池时还会检查规范化后的名称冲突和 64 字符长度限制:\n\n```python\nprefixed = f\"mcp__{safe_server}__{safe_tool}\"\nif prefixed in origins:\n raise ValueError(\"MCP tool name collision after normalization\")\n```\n\n因此 `docs.one/get.version` 和 `docs_one/get_version` 不会悄悄映射到同一个名字。\n\n### 5. 工具定义和 handler 一起加入工具池\n\n```python\ntools.append({\n \"name\": prefixed,\n \"description\": tool_def.get(\"description\", \"\"),\n \"input_schema\": schema,\n})\nhandlers[prefixed] = (\n lambda *, client=server, tool=raw_name, **kwargs:\n client.call_tool(tool, kwargs)\n)\n```\n\n模型看到带前缀的名字;handler 仍使用 server 原始工具名调用 `MCPClient`。默认参数保存当前 client 和 tool,避免循环里的 lambda 全部指向最后一个工具。\n\n### 6. 权限由宿主配置决定\n\nMCP server 可以提供 `readOnlyHint` 或 `destructiveHint`,但这些信息来自 server,不能直接作为授权依据。本章使用宿主侧策略:\n\n```python\nMCP_HOST_POLICY = {\n (\"docs\", \"search\"): \"allow\",\n (\"docs\", \"get_version\"): \"allow\",\n (\"deploy\", \"status\"): \"allow\",\n (\"deploy\", \"trigger\"): \"confirm\",\n}\n```\n\n`permission_hook()` 根据规范化后的工具名查询这份策略。未配置的外部工具默认需要用户确认;即使 description 写着 `readOnly`,也不会自动放行。\n\n### 7. 工具输入错误留在工具边界内\n\n模型可能漏传参数,也可能传入 server 不接受的字段。`execute_tool()` 和 `MCPClient.call_tool()` 都会捕获异常,并返回错误 `tool_result`:\n\n```text\nMCP error: TypeError: <lambda>() missing 1 required argument: 'query'\n```\n\n模型可以在下一轮修正参数,而不是让课程脚本直接退出。\n\n---\n\n## 相对 s04 的变化\n\n| 组件 | s04 | s14 |\n|---|---|---|\n| 基础工具 | 五个固定工具 | 保持不变 |\n| 工具来源 | `code.py` 中的定义 | 基础工具加动态发现的 MCP 工具 |\n| 工具池 | 固定 `TOOLS` | 每轮由 `assemble_tool_pool()` 组装 |\n| 外部工具名 | 无 | `mcp__{server}__{tool}` |\n| 权限 | Shell 和路径检查 | 增加宿主侧 MCP 策略 |\n| MCP transport | 无 | 使用进程内模拟 server 展示协议边界 |\n\n本章不带入 Task、Background、Cron、Team 或 Worktree。它们会在 s15 的 Integrated Harness 中与 MCP 合并。\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s14_mcp_plugin/code.py\n```\n\n输入:\n\n```text\n连接 docs server,搜索 agent hooks,并告诉我当前文档 API 版本。\n```\n\n一次典型工具轨迹是:\n\n```text\nconnect_mcp(name=\"docs\")\nmcp__docs__search(query=\"agent hooks\")\nmcp__docs__get_version()\n```\n\n再输入:\n\n```text\n连接 deploy server,查看 web 服务状态,不要触发部署。\n```\n\n`status` 会按宿主策略直接执行;`trigger` 需要用户确认。\n\n---\n\n## 接下来\n\n目前,MCP 还是一条独立的课程分支。s15 Integrated Harness 会把基础工具、Hooks、Skills、Context、Memory、Task、Background、Cron、Teams 和 MCP 放进同一个运行时。\n\n<!-- translation-sync: zh@v9, en@v9, ja@v9 -->\n"
|
||
},
|
||
{
|
||
"version": "s14",
|
||
"locale": "ja",
|
||
"title": "s14: MCP Tools — 外部ツールの発見と呼び出し",
|
||
"content": "# s14: MCP Tools — 外部ツールの発見と呼び出し\n\n[s04](/ja/s04) → `s14` → [s15](/ja/s15) → s16 → s17\n\n> **Harness レイヤー**:MCP Tools — service に接続し、tool を発見して Agent Loop に追加する。\n\n---\n\n## 課題\n\nこれまでの基本ツールは `code.py` に直接書かれている。documentation system と deployment platform を接続するために `search_docs`、`deploy_status`、`trigger_deploy` を追加することはできるが、service が増えるたびに tool definition、parameter schema、call handler を追加する必要がある。\n\nMCP はこの責務を分ける。server は tool list と invocation endpoint を提供する。Harness は接続、model-facing name、permission check を担当し、発見した tool を model に渡す。\n\n---\n\n## ソリューション\n\n\n\n本章は s04 の 5 つの基本ツールと Hooks から始め、次の 3 つを追加する:\n\n- `MCPClient` は server が返した tool definition と call handler を保持する。\n- `connect_mcp` は 1 つの server に接続して tool list を取得する。\n- `assemble_tool_pool` は基本ツールと接続済み server の MCP tool を 1 つの tool pool にまとめる。\n\n`docs` と `deploy` は、`tools/list`、`tools/call`、dynamic tool pool を示すための in-process mock server である。本章では実際の MCP transport は実装しない。\n\n---\n\n## 仕組み\n\n### 1. 基本の Agent Loop は変わらない\n\n各 model call の前に現在の tool pool を組み立てる:\n\n```python\ndef agent_loop(messages: list):\n while True:\n tools, handlers = assemble_tool_pool()\n response = client.messages.create(\n model=MODEL,\n system=assemble_system_prompt(),\n messages=messages,\n tools=tools,\n max_tokens=8000,\n )\n ...\n```\n\n新しい server を接続すると、次の `assemble_tool_pool()` がその tool を model input に追加する。実行結果は従来通り `tool_result` として messages に追加される。\n\n### 2. MCPClient は発見結果と呼び出し入口を保持する\n\n```python\nclass MCPClient:\n def register(self, tool_defs, handlers):\n self.tools = list(tool_defs)\n self._handlers = dict(handlers)\n\n def call_tool(self, tool_name, args):\n handler = self._handlers.get(tool_name)\n if not handler:\n return f\"MCP error: unknown tool '{tool_name}'\"\n try:\n return str(handler(**args))\n except Exception as error:\n return f\"MCP error: {type(error).__name__}: {error}\"\n```\n\n`register()` は発見した tool list、`call_tool()` は invocation boundary を表す。error は Agent Loop を終了させず model へ返す。\n\n### 3. connect_mcp は接続と発見だけを行う\n\n```python\ndef connect_mcp(name: str) -> str:\n if name in mcp_clients:\n return f\"MCP server '{name}' already connected\"\n factory = MOCK_SERVERS.get(name)\n if not factory:\n return f\"Unknown server '{name}'\"\n server = factory()\n mcp_clients[name] = server\n ...\n```\n\n開始時、model が見るのは 5 つの基本ツールと `connect_mcp` だけである。`connect_mcp(name=\"docs\")` の後、Harness は docs client を保持し、次の model call に次の tool が加わる:\n\n```text\nmcp__docs__search\nmcp__docs__get_version\n```\n\n### 4. prefix で別 server の同名 tool を区別する\n\n複数の server が `search` や `status` を提供することがある。Harness は次の名前を使う:\n\n```text\nmcp__{server}__{tool}\n```\n\n`normalize_mcp_name()` は model tool name に使えない文字を underscore に置き換える。tool pool の組み立て時には、正規化後の名前衝突と 64 文字制限も確認する:\n\n```python\nprefixed = f\"mcp__{safe_server}__{safe_tool}\"\nif prefixed in origins:\n raise ValueError(\"MCP tool name collision after normalization\")\n```\n\nそのため `docs.one/get.version` と `docs_one/get_version` が同じ名前へ暗黙に変換されることはない。\n\n### 5. tool definition と handler を同時に追加する\n\n```python\ntools.append({\n \"name\": prefixed,\n \"description\": tool_def.get(\"description\", \"\"),\n \"input_schema\": schema,\n})\nhandlers[prefixed] = (\n lambda *, client=server, tool=raw_name, **kwargs:\n client.call_tool(tool, kwargs)\n)\n```\n\nmodel は prefix 付きの名前を見る。handler は server の元の tool name で `MCPClient` を呼ぶ。default argument が現在の client と tool を保持するため、loop 内の lambda がすべて最後の tool を参照することはない。\n\n### 6. permission は host が決める\n\nMCP server は `readOnlyHint` や `destructiveHint` を返せるが、それらは server 由来の hint であり authorization ではない。本章では host-side policy を使う:\n\n```python\nMCP_HOST_POLICY = {\n (\"docs\", \"search\"): \"allow\",\n (\"docs\", \"get_version\"): \"allow\",\n (\"deploy\", \"status\"): \"allow\",\n (\"deploy\", \"trigger\"): \"confirm\",\n}\n```\n\n`permission_hook()` は正規化された tool name からこの policy を調べる。設定されていない外部ツールは、default で user confirmation を必要とする。description に `readOnly` と書かれていても自動許可されない。\n\n### 7. 入力 error は tool boundary 内に留める\n\nmodel は required argument を省略したり、server が受け付けない field を送ることがある。`execute_tool()` と `MCPClient.call_tool()` は error を捕捉し、error `tool_result` を返す:\n\n```text\nMCP error: TypeError: <lambda>() missing 1 required argument: 'query'\n```\n\nlesson script を終了せず、model は次の turn で argument を修正できる。\n\n---\n\n## s04 からの変更\n\n| コンポーネント | s04 | s14 |\n|---|---|---|\n| 基本ツール | 5 つの固定ツール | 変更なし |\n| ツールソース | `code.py` 内の定義 | 基本ツールと発見した MCP tool |\n| ツールプール | 固定 `TOOLS` | 各 turn に `assemble_tool_pool()` で組み立て |\n| 外部ツール名 | なし | `mcp__{server}__{tool}` |\n| Permission | Shell と path check | host-side MCP policy を追加 |\n| MCP transport | なし | in-process mock server で boundary を示す |\n\n本章には Task、Background、Cron、Team、Worktree を持ち込まない。これらは s15 Integrated Harness で MCP と合流する。\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s14_mcp_plugin/code.py\n```\n\n入力:\n\n```text\ndocs server に接続し、agent hooks を検索して、現在の documentation API version を教えてください。\n```\n\n典型的な tool trace:\n\n```text\nconnect_mcp(name=\"docs\")\nmcp__docs__search(query=\"agent hooks\")\nmcp__docs__get_version()\n```\n\n続けて入力:\n\n```text\ndeploy server に接続して web service の status を確認してください。deployment は trigger しないでください。\n```\n\n`status` は host policy によりそのまま実行され、`trigger` は user confirmation を必要とする。\n\n---\n\n## 次の章\n\nここでは MCP は独立した course branch である。s15 Integrated Harness は基本ツール、Hooks、Skills、Context、Memory、Task、Background、Cron、Teams、MCP を 1 つの runtime にまとめる。\n\n<!-- translation-sync: zh@v9, en@v9, ja@v9 -->\n"
|
||
},
|
||
{
|
||
"version": "s15",
|
||
"locale": "en",
|
||
"title": "s15: Integrated Harness — Many Mechanisms, One Loop",
|
||
"content": "# s15: Integrated Harness — Many Mechanisms, One Loop\n\ns01 → ... → s13 → [s14](/en/s14) → `s15` → [s16](/en/s16) → s17\n\n> *\"Many mechanisms, one loop\"* — tools, permissions, memory, tasks, teams, and plugins all hang off the same `while True`.\n>\n> **Harness layer**: Integration — put the mechanisms used by this example into one runnable system.\n\n---\n\n## Problem\n\nThe earlier chapters keep separate mechanisms in separate runnable examples. This chapter connects the mechanisms needed by the integrated runtime.\n\nA long-running coding agent needs all of these at once:\n\n- tool dispatch and permission boundaries\n- hook extension points\n- todo planning and task graphs\n- skills, memory, and runtime system prompt assembly\n- compaction and error recovery\n- background tasks and cron scheduling\n- teams, protocols, and IDLE task claiming\n- task-bound worktrees\n- MCP external tool integration\n\nS15 does not introduce another isolated mechanism. It shows where the existing mechanisms enter the model loop and how their events return to the same conversation.\n\n---\n\n## Solution\n\n\n\nS15 does not introduce a new mechanism. It connects the components from the earlier chapters in one integrated harness:\n\n```text\nuser input\n → UserPromptSubmit hooks\n → cron/background notification injection\n → context compact\n → memory + skills + MCP state assemble the system prompt\n → LLM\n → has tool_use block?\n no → Stop hooks → return\n yes → PreToolUse hooks + permission\n → TOOL_HANDLERS / MCP handlers / background dispatch\n → PostToolUse hooks\n → tool_result / task_notification back to messages\n → next round\n```\n\nThe loop keeps the same structure: call the model, check whether the response contains a `tool_use` block, execute tools, and append results to `messages`. The presence of a `tool_use` block decides whether tool execution continues.\n\n---\n\n## Where Each Component Sits\n\n| Position | Component | Role |\n|----------|-----------|------|\n| Around user input | `UserPromptSubmit` hooks | Log, inject, or audit user input |\n| Before LLM | cron queue | Inject scheduled prompts into `messages` |\n| Before LLM | background notifications | Inject completed background work as `<task_notification>` |\n| Before LLM | compaction pipeline | Budget large outputs, trim history, compact old tool results, summarize when needed |\n| Before LLM | memory / skills / MCP state | Assemble the system prompt so the model sees current capabilities and long-term context |\n| LLM call | error recovery | Retry 429/529, escalate `max_tokens`, compact on prompt-too-long |\n| Before tool execution | `PreToolUse` hooks + permission | Block dangerous commands, out-of-bounds writes, destructive MCP tools |\n| Tool dispatch | `assemble_tool_pool` | Assemble built-in tools and dynamic MCP tools |\n| During tool execution | background dispatch | Move explicitly marked bash work into a daemon thread and return a placeholder result |\n| After tool execution | `PostToolUse` hooks | Large-output warnings, logs, post-processing |\n| Back to loop | tool_result | One `tool_result` per `tool_use`, then the next model round |\n| No tool_use this round / on stop | `Stop` hooks | Stats, cleanup, audit |\n\n---\n\n## What code.py Contains\n\n### Tools and Dispatch\n\nThe built-in tool pool contains 26 tools:\n\n```text\nbash, read_file, write_file, edit_file, glob\ntodo_write, task, load_skill, compact\ncreate_task, update_task, list_tasks, get_task, claim_task, complete_task\nschedule_cron, list_crons, cancel_cron\nspawn_teammate, list_teammates, send_message\nrequest_shutdown, request_plan, review_plan\ncreate_worktree\nconnect_mcp\n```\n\n`assemble_tool_pool()` assembles these every round:\n\n```text\nBUILTIN_TOOLS + connected MCP tools\nBUILTIN_HANDLERS + mcp__server__tool handlers\n```\n\nAfter `connect_mcp(\"docs\")`, the next round exposes tools like `mcp__docs__search`.\n\n### Permissions and Hooks\n\nPermission is not hardcoded into the tool execution line. It is a `PreToolUse` hook:\n\n```python\nblocked = trigger_hooks(\"PreToolUse\", block)\nif blocked:\n results.append(tool_result(block.id, blocked))\n continue\n```\n\nThat means permission, logging, and audit logic all attach to the same hook point. Lead tools, one-shot subagent tools, and teammate tools all pass through `PreToolUse`; an allowed call then runs `PostToolUse` after its handler.\n\nThe policy does not trust an MCP server's own description as authorization. The host owns a small exact allowlist for known read-only calls; every other MCP tool asks the user. File tools are denied outside `WORKDIR`, and every bash command asks before execution. Only the foreground user turn may open an interactive approval prompt; asynchronous turns fail closed instead of competing with the main CLI for stdin.\n\n### Planning and Tasks\n\nS15 keeps two planning layers:\n\n- `todo_write`: lightweight plan for the current session, kept in memory\n- task graph: cross-session, dependency-aware, claimable task files under `.tasks/task_*.json`\n\nThe first keeps a single agent from drifting. The second supports team coordination.\n\nThey share an intent, not an implementation: `todo_write` replaces one session checklist, while task records have stable IDs and individual lifecycle updates. The separate `task` tool below means \"dispatch one isolated subagent\"; it is not the Task System.\n\nTask graph construction remains two-phase in the integrated host: the Lead creates all task nodes first, then calls `update_task` with the runtime IDs returned by `create_task`. Teammates receive only list, claim, and complete operations, so dependency structure is fixed by the Lead before work is distributed.\n\n### Subagents and Teams\n\nS15 has two kinds of delegation:\n\n- `task`: one-shot subagent. It uses an isolated `messages[]`, discards intermediate context, and returns only a final summary.\n- `spawn_teammate`: persistent teammate thread. When given a ready `task_id`, the runtime claims it before the thread starts; without one, the teammate can wait in IDLE for later work. A teammate without an assignment cannot use file or Shell tools. It follows `WORK → result → IDLE` without a fixed tool-round cap; model or dispatch failures emit an `error`, and thread cleanup releases an unfinished assignment back to the task board. It drains its inbox before every model call, so direct messages and shutdown requests cannot wait behind an unbroken tool-use sequence. While idle it waits for `MessageBus` delivery first, then scans ready tasks only after the wait times out and atomically claims at most one.\n\nAfter spawning a teammate, Lead ends the current turn instead of repeatedly querying its status inside the model loop. A team event in Lead's mailbox makes the runtime start the next turn.\n\nOne-shot subagents solve context isolation. Persistent teammates solve long-running parallel collaboration.\n\n### Memory, Skills, and Prompt\n\nS15 reuses the s09 memory runtime directly. Before each model call, it reads the `.memory/MEMORY.md` catalog, selects records relevant to the current request, and passes their contents to `assemble_system_prompt(context)`. At the end of the turn, `extract_memories()` keeps information that can help in later sessions; when new records are stored, `consolidate_memories()` runs next.\n\nThe same system prompt also includes identity, tool guidance, the workspace, the skills catalog, and connected MCP servers. Skills contribute only their catalog; `load_skill(name)` loads full content on demand.\n\n### Compaction and Recovery\n\nBefore the LLM call, S15 runs the compaction pipeline:\n\n```text\ntool_result_budget → snip_compact → micro_compact → compact_history\n```\n\nThe model call is wrapped with recovery:\n\n- 429: exponential backoff retry\n- 529: exponential backoff, optionally switch to fallback model after repeated failures\n- `max_tokens`: raise max tokens, then request continuation\n- prompt too long: reactive compact and retry\n\n### Background and Cron\n\nWhen a bash call sets `run_in_background=true`, the main loop returns a placeholder without waiting for the command:\n\n```text\nshould_run_background → start_background_task → placeholder tool_result\nbackground done → task_notification → next round injects messages\n```\n\nOnly explicitly marked bash calls enter the background path. A non-zero exit or worker exception produces a `failed` notification. Each shell runs in its own process group, which the runtime stops when the command or Agent process ends through the normal or `SIGTERM` path. A process that creates another session can leave that group.\n\nThe cron scheduler runs as a daemon thread and checks once per second. A durable one-shot job is persisted as `pending_delivery` before entering the queue and remains there until the model call containing its prompt succeeds; a failed call restores it to the queue, and a restart queues it again. Delivery is therefore at-least-once. The CLI watches `cron_queue`, Lead's inbox, and terminal background work; any of them can wake one automatic agent turn.\n\n### Worktree and MCP\n\nThe task-scoped worktree behavior inherited from s13 manages working directories:\n\n- a pending, unowned task may remain in the main workspace or be bound by `create_worktree(name, task_id)` to a separate branch and directory\n- creation prevalidates the task, name, path, branch, and Git registry; a failed Git command is reconciled against the registry and branch state, and any partial checkout remains unbound and preserved for manual recovery\n- an idle teammate atomically claims one ready task; the assignment records both `task_id` and its effective `cwd`\n- Lead can also pass a ready `task_id` to `spawn_teammate`; the thread starts only after the claim succeeds\n- all teammate file tools use that `cwd`; only the owning teammate can complete the task, and the assignment stays selected until that model turn ends\n- removal stays in the host-side `remove_worktree()` helper. The model cannot call it. The user or host first checks task ownership, assignment leases, background work, and Git state; destructive removal requires separate user confirmation\n\nThe worktree changes tool default directories. It separates working copies; it is not a sandbox, and process-group cleanup does not contain a process that starts another session. This is why deletion remains host-owned.\n\nClaiming or releasing a Task changes the assignment version and invalidates an old plan approval. An ordinary `send_message` only delivers text; it changes neither the Task identity nor the plan state.\n\nMCP owns external capability:\n\n- `connect_mcp(name)` connects a mock server\n- `assemble_tool_pool()` assembles MCP tools and rejects normalized name collisions\n- tool names use `mcp__server__tool`\n\n---\n\n## Changes from s14\n\n| Scope | s14 MCP | s15 Integrated Harness |\n|-------|---------|-------------------------|\n| built-in tools | 6 | 25 |\n| external tools | connected MCP tools | the same dynamic MCP path and host policy |\n| local mechanisms | S04 tools, hooks, permission, MCP | todo, subagent, skills, compaction, memory, task graph, background bash, cron, teams, and worktrees |\n| event sources | user input and tool results | user input, tool results, cron prompts, background notifications, and team events |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s15_integrated_harness/code.py\n```\n\nTry:\n\n1. `Inspect this repository and tell me which Python files matter most.`\n2. `Search the connected documentation for agent loop guidance.`\n3. `Refactor the authentication module and login page in parallel in separate worktrees. Show me each plan before editing.`\n4. `Remind me about the meeting in 3 minutes.`\n5. `Install the dependencies in the background while you read README.md.`\n\nWatch for:\n\n- whether each tool call passes through hooks/permission\n- whether MCP tools appear on the next round after `connect_mcp`\n- whether a bash call with `run_in_background=true` returns a background placeholder\n- whether cron automatically reminds you when the time arrives\n- whether teammates submit plans and pause before approval\n- whether an idle teammate atomically claims only one ready task\n- whether every teammate file tool switches to the claimed task's `cwd`\n- whether completion keeps the task `cwd` through the rest of the turn and releases it at IDLE\n\n---\n\n## Next\n\n[s16 Workflow Runtime](/en/s16) adds a `Workflow` tool to this host. A workflow keeps a fixed orchestration path in code and records progress so the same run can resume.\n\n<!-- translation-sync: zh@v14, en@v14, ja@v14 -->\n"
|
||
},
|
||
{
|
||
"version": "s15",
|
||
"locale": "zh",
|
||
"title": "s15: Agent Harness 集成 — 多种机制,一个循环",
|
||
"content": "# s15: Agent Harness 集成 — 多种机制,一个循环\n\ns01 → ... → s13 → [s14](/zh/s14) → `s15` → [s16](/zh/s16) → s17\n\n> *\"多种机制,一个循环\"* — 工具、权限、记忆、任务、团队、插件都挂在同一个 while True 上。\n>\n> **Harness 层**: 集成 — 把本章示例实际使用的机制放进同一个可运行系统。\n\n---\n\n## 问题\n\n前面的章节把不同机制放在各自独立的示例中。本章把集成运行时需要的机制接到一起。\n\n一个能长期工作的 coding agent 需要同时拥有:\n\n- 工具分发和权限边界\n- hooks 扩展点\n- todo 计划和任务图\n- 技能、记忆、系统 prompt 组装\n- 压缩和错误恢复\n- 后台任务和 cron 调度\n- 团队、协议和 idle 任务认领\n- 任务绑定的 worktree\n- MCP 外部工具接入\n\nS15 不再引入一个独立机制,而是展示现有机制从哪里进入模型循环,以及它们产生的事件如何回到同一段对话。\n\n---\n\n## 解决方案\n\n\n\nS15 不再引入新机制,而是把前面各章的组件集成到同一个 harness:\n\n```text\n用户输入\n → UserPromptSubmit hooks\n → cron/background 通知注入\n → context compact\n → memory + skills + MCP 状态组装 system prompt\n → LLM\n → has tool_use block?\n 否 → Stop hooks → 返回\n 是 → PreToolUse hooks + permission\n → TOOL_HANDLERS / MCP handlers / background dispatch\n → PostToolUse hooks\n → tool_result / task_notification 回 messages\n → 下一轮\n```\n\n循环仍是同一个结构:调用模型,检查响应里是否出现 `tool_use` block,执行工具,再把结果追加回 `messages`。是否继续工具轮,由响应中有没有实际的 `tool_use` block 决定。\n\n---\n\n## 组件在循环中的位置\n\n| 位置 | 组件 | 作用 |\n|------|------|------|\n| 用户输入前后 | `UserPromptSubmit` hooks | 记录、注入、审计用户输入 |\n| LLM 前 | cron queue | 把定时触发的 prompt 注入 `messages` |\n| LLM 前 | background notifications | 后台任务完成后以 `<task_notification>` 注入 |\n| LLM 前 | compaction pipeline | 先压大输出,再裁历史,再压旧 tool_result,必要时摘要 |\n| LLM 前 | memory / skills / MCP state | 组装 system prompt,让模型看到当前能力和长期上下文 |\n| LLM 调用 | error recovery | 429/529 重试,`max_tokens` 升级,prompt too long 触发 reactive compact |\n| 工具执行前 | `PreToolUse` hooks + permission | 拦截危险命令、写越界、破坏性 MCP 工具 |\n| 工具分发 | `assemble_tool_pool` | 组装内置工具和 MCP 动态工具 |\n| 工具执行时 | background dispatch | 显式标记的 bash 操作放入 daemon thread,主循环先返回占位结果 |\n| 工具执行后 | `PostToolUse` hooks | 大输出告警、日志等后处理 |\n| 返回循环 | tool_result | 每个 `tool_use` 对应一个 `tool_result`,再回到下一轮 |\n| 本轮没有 tool_use / 停止时 | `Stop` hooks | 统计、清理、审计 |\n\n---\n\n## code.py 包含什么\n\n### 工具与分发\n\n内置工具池包含 26 个工具:\n\n```text\nbash, read_file, write_file, edit_file, glob\ntodo_write, task, load_skill, compact\ncreate_task, update_task, list_tasks, get_task, claim_task, complete_task\nschedule_cron, list_crons, cancel_cron\nspawn_teammate, list_teammates, send_message\nrequest_shutdown, request_plan, review_plan\ncreate_worktree\nconnect_mcp\n```\n\n`assemble_tool_pool()` 每轮组装:\n\n```text\nBUILTIN_TOOLS + connected MCP tools\nBUILTIN_HANDLERS + mcp__server__tool handlers\n```\n\n所以 `connect_mcp(\"docs\")` 后,下一轮工具池里会出现 `mcp__docs__search`。\n\n### 权限和 hooks\n\n权限不写死在工具执行行里,而是作为 `PreToolUse` hook:\n\n```python\nblocked = trigger_hooks(\"PreToolUse\", block)\nif blocked:\n results.append(tool_result(block.id, blocked))\n continue\n```\n\n这样 permission、log、审计都可以挂在同一个 hook 点上。Lead、一次性 subagent 和队友的工具都会先经过 `PreToolUse`;允许执行的调用会在 handler 返回后触发 `PostToolUse`。\n\n权限判断不会把 MCP server 自己写的 description 当成授权依据。宿主维护一组精确的已知只读工具名单,其他 MCP 工具都要询问用户。文件工具越过 `WORKDIR` 会直接拒绝,每条 bash 命令执行前都会询问。只有前台用户轮次可以弹出交互确认;异步轮次直接拒绝需要确认的操作,不和主 CLI 争抢输入。\n\n### 计划与任务\n\nS15 同时保留两层计划:\n\n- `todo_write`:当前会话内的轻量计划,保存在内存中\n- task graph:跨会话、可依赖、可认领的任务文件,写入 `.tasks/task_*.json`\n\n前者帮助单个 Agent 不漂移;后者支撑团队协作。\n\n两者目标相近,但实现不同:`todo_write` 整表替换当前会话清单,task record 则有稳定 ID 和单条生命周期更新。下面单独出现的 `task` 工具表示“一次性派发隔离 subagent”,不是 Task System。\n\n集成宿主中的任务图仍采用两阶段构建:Lead 先创建所有任务节点,再使用 `create_task` 返回的运行时 ID 调用 `update_task`。队友只能列举、认领和完成任务,因此依赖结构由 Lead 在分发工作前确定。\n\n### 子 agent 与团队\n\nS15 有两种 delegation:\n\n- `task`:一次性 subagent。独立 `messages[]`,中间过程丢弃,只返回最终摘要。\n- `spawn_teammate`:持久队友线程。传入 ready `task_id` 时,运行时会在线程启动前完成认领;不传时,队友可以在 IDLE 中等待后续任务。没有 assignment 的队友不能使用文件或 Shell 工具。它按 `WORK → result → IDLE` 运行,不设固定的工具轮数上限;模型或分发失败会发出 `error`,线程清理会把未完成 assignment 释放回任务板。每次调用模型前都会先读取收件箱,因此直接消息和关机请求不会被连续的 tool-use 轮次饿死。idle 时先等待 `MessageBus` 消息,只在超时后扫描就绪 task,并以原子操作最多认领一个。\n\nLead 启动队友后结束当前轮次,不在模型循环里反复查询状态。队友事件进入 Lead 收件箱后,运行时会自动唤醒下一轮。\n\n一次性 subagent 解决“上下文隔离”;持久队友解决“长期并行协作”。\n\n### 记忆、技能和 prompt\n\nS15 直接复用 s09 的 Memory runtime。每轮调用模型前,它读取 `.memory/MEMORY.md` 目录,根据当前请求选择相关记录,再把选中的正文交给 `assemble_system_prompt(context)`。本轮结束后,`extract_memories()` 提取可跨会话使用的信息;有新增记录时再运行 `consolidate_memories()`。\n\n同一份 system prompt 还会加入身份、工具说明、workspace、skills catalog 和已连接的 MCP server。技能只放目录,完整内容通过 `load_skill(name)` 按需加载。\n\n### 压缩和恢复\n\nLLM 前先跑压缩管线:\n\n```text\ntool_result_budget → snip_compact → micro_compact → compact_history\n```\n\n调用模型时再包一层恢复:\n\n- 429:指数退避重试\n- 529:指数退避,连续失败可切 fallback model\n- `max_tokens`:先提高 max_tokens,再要求 continuation\n- prompt too long:reactive compact 后重试\n\n### 后台和 cron\n\nbash 调用设置 `run_in_background=true` 后,主循环不再等待命令结束,而是先返回占位结果:\n\n```text\nshould_run_background → start_background_task → placeholder tool_result\n后台完成 → task_notification → 下一轮注入 messages\n```\n\n只有显式标记的 bash 调用会进入后台路径。命令非零退出或 worker 抛出异常时会发出 `failed` 通知。每条 Shell 命令都在独立进程组中运行;命令结束,或 Agent 经正常路径、`SIGTERM` 退出时,运行时会停止原进程组。另建 session 的进程可以离开这个进程组。\n\ncron 调度器独立 daemon thread 每秒检查一次。durable 的一次性任务会先持久化为 `pending_delivery`,再进入队列,并保留到包含该 prompt 的模型调用成功;调用失败会放回队列,重启后也会再次入队,因此交付语义是至少一次。CLI 同时监听 `cron_queue`、Lead 收件箱和已经结束的后台任务,任一事件都能自动唤醒一轮 Agent。\n\n### worktree 与 MCP\n\n从 s13 继承的任务级 worktree 机制负责管理任务工作目录:\n\n- pending 且未被认领的 task 可以留在主工作区,也可以通过 `create_worktree(name, task_id)` 绑定独立分支和目录\n- 创建前会校验 task、名称、路径、分支和 Git registry;Git 命令失败后还会核对 registry 和分支状态,任何部分创建的 checkout 都保持未绑定并保留供人工恢复\n- idle 队友以原子操作认领一个就绪 task,assignment 同时记录 `task_id` 和有效 `cwd`\n- Lead 也可以把 ready `task_id` 直接传给 `spawn_teammate`,认领成功后才启动线程\n- 队友所有文件工具都使用该 `cwd`;只有 task owner 能完成任务,assignment 会保留到当前模型轮次结束\n- 移除保留在宿主侧的 `remove_worktree()` 函数中,模型不能调用。用户或宿主先检查任务所有权、assignment lease、后台工作和 Git 状态;破坏性移除需要另行取得用户确认\n\nworktree 只改变工具的默认工作目录,用于分离 working copy,并不是安全沙箱。进程组清理也无法约束另建 session 的进程,因此删除保留为宿主操作。\n\n认领或释放 task 会改变 assignment version,使旧的 plan approval 失效;普通 `send_message` 只传递消息,不会改变 task identity 或 plan 状态。\n\nMCP 负责外部能力:\n\n- `connect_mcp(name)` 连接 mock server\n- `assemble_tool_pool()` 把 MCP 工具组装进工具池,并拒绝规范化后的名称冲突\n- 工具名统一为 `mcp__server__tool`\n\n---\n\n## 相对 s14 的变化\n\n| 范围 | s14 MCP | s15 Integrated Harness |\n|------|---------|-------------------------|\n| 内置工具 | 6 个 | 25 个 |\n| 外部工具 | 已连接的 MCP 工具 | 沿用同一套动态 MCP 路径和宿主策略 |\n| 本地机制 | S04 工具、hooks、权限和 MCP | todo、subagent、skills、compaction、memory、task graph、后台 bash、cron、teams 和 worktrees |\n| 事件来源 | 用户输入和工具结果 | 用户输入、工具结果、cron prompt、后台通知和 team events |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s15_integrated_harness/code.py\n```\n\n可以试:\n\n1. `检查这个仓库,告诉我哪些 Python 文件最重要。`\n2. `从已连接的文档中查一下 agent loop 的相关说明。`\n3. `请在独立的 worktree 中并行重构认证模块和登录页,修改前先把各自的计划给我看。`\n4. `3 分钟后提醒我开会。`\n5. `在后台安装依赖,同时继续阅读 README.md。`\n\n观察重点:\n\n- 工具调用前是否经过 hooks/permission\n- `connect_mcp` 后下一轮是否出现 MCP 工具\n- 设置 `run_in_background=true` 的 bash 调用是否返回 background placeholder\n- 到点是不是自动提醒开会\n- 队友是否提交 plan,并在 approval 前暂停\n- idle 队友是否只原子认领一个就绪 task\n- 队友所有文件工具是否都切换到已认领 task 的 `cwd`\n- 完成任务后是否在本轮剩余工具调用中保持 task `cwd`,并在 IDLE 时释放\n\n---\n\n## 接下来\n\n[s16 Workflow Runtime](/zh/s16) 会在这个 host 中加入 `Workflow` 工具。Workflow 把固定的编排路径写在代码中,并记录运行进度,使同一次运行可以继续执行。\n\n<!-- translation-sync: zh@v14, en@v14, ja@v14 -->\n"
|
||
},
|
||
{
|
||
"version": "s15",
|
||
"locale": "ja",
|
||
"title": "s15: Integrated Harness — 多くの仕組みを 1 つのループへ",
|
||
"content": "# s15: Integrated Harness — 多くの仕組みを 1 つのループへ\n\ns01 → ... → s13 → [s14](/ja/s14) → `s15` → [s16](/ja/s16) → s17\n\n> *\"仕組みは多い、ループは 1 つ\"* — tools、permissions、memory、tasks、teams、plugins はすべて同じ `while True` に接続される。\n>\n> **Harness レイヤー**: 統合 — この例で実際に使う仕組みを 1 つの実行可能なシステムへまとめる。\n\n---\n\n## 問題\n\n前の章では、異なる仕組みをそれぞれ独立した実行例に置いた。本章では、統合ランタイムに必要な仕組みを接続する。\n\n長時間動く coding agent には、同時に次のものが必要になる:\n\n- tool dispatch と permission boundary\n- hook extension point\n- todo plan と task graph\n- skill、memory、runtime system prompt assembly\n- compaction と error recovery\n- background task と cron scheduling\n- team、protocol、IDLE task claiming\n- task-bound worktree\n- MCP external tool integration\n\nS15 は新しい独立 mechanism を追加する章ではない。既存の mechanism が model loop のどこに入り、そこで生じた event が同じ conversation にどう戻るかを示す。\n\n---\n\n## 解決策\n\n\n\nS15 は新しい mechanism を追加せず、前章までの component を同じ harness に統合する:\n\n```text\nuser input\n → UserPromptSubmit hooks\n → cron/background notification injection\n → context compact\n → memory + skills + MCP state で system prompt を組み立てる\n → LLM\n → has tool_use block?\n no → Stop hooks → return\n yes → PreToolUse hooks + permission\n → TOOL_HANDLERS / MCP handlers / background dispatch\n → PostToolUse hooks\n → tool_result / task_notification を messages へ戻す\n → next round\n```\n\nloop 自体は同じ構造のままだ。model を呼び、response に `tool_use` block があるかを見て、tool を実行し、結果を `messages` に戻す。tool 実行を続けるかどうかは、実際の `tool_use` block の有無で決まる。\n\n---\n\n## 各 Component の位置\n\n| 位置 | Component | 役割 |\n|------|-----------|------|\n| user input 周辺 | `UserPromptSubmit` hooks | user input の記録、注入、監査 |\n| LLM 前 | cron queue | scheduled prompt を `messages` へ注入 |\n| LLM 前 | background notifications | 完了した background work を `<task_notification>` として注入 |\n| LLM 前 | compaction pipeline | 大きな出力を予算化し、履歴を切り、古い tool_result を圧縮し、必要なら要約 |\n| LLM 前 | memory / skills / MCP state | current capabilities と long-term context を system prompt に組み込む |\n| LLM call | error recovery | 429/529 retry、`max_tokens` escalation、prompt-too-long compact |\n| tool 実行前 | `PreToolUse` hooks + permission | 危険な command、範囲外 write、destructive MCP tool を止める |\n| tool dispatch | `assemble_tool_pool` | built-in tools と dynamic MCP tools を組み立てる |\n| tool 実行中 | background dispatch | 明示指定された bash work を daemon thread に移し、placeholder result を返す |\n| tool 実行後 | `PostToolUse` hooks | large-output warning、log、後処理 |\n| loop へ戻る | tool_result | 1 つの `tool_use` に 1 つの `tool_result`、そして次の model round |\n| tool_use がない round / stop 時 | `Stop` hooks | 統計、cleanup、audit |\n\n---\n\n## code.py に含まれるもの\n\n### Tools と Dispatch\n\nbuilt-in tool pool には 26 個の tool がある:\n\n```text\nbash, read_file, write_file, edit_file, glob\ntodo_write, task, load_skill, compact\ncreate_task, update_task, list_tasks, get_task, claim_task, complete_task\nschedule_cron, list_crons, cancel_cron\nspawn_teammate, list_teammates, send_message\nrequest_shutdown, request_plan, review_plan\ncreate_worktree\nconnect_mcp\n```\n\n`assemble_tool_pool()` は毎 round で次を組み立てる:\n\n```text\nBUILTIN_TOOLS + connected MCP tools\nBUILTIN_HANDLERS + mcp__server__tool handlers\n```\n\n`connect_mcp(\"docs\")` のあと、次の round では `mcp__docs__search` のような tool が出現する。\n\n### Permission と Hooks\n\npermission は tool 実行行に直接埋め込まない。`PreToolUse` hook として扱う:\n\n```python\nblocked = trigger_hooks(\"PreToolUse\", block)\nif blocked:\n results.append(tool_result(block.id, blocked))\n continue\n```\n\nこれにより permission、logging、audit が同じ hook point に接続できる。Lead、one-shot subagent、teammate の tool はすべて先に `PreToolUse` を通り、許可された call は handler 実行後に `PostToolUse` を通る。\n\npermission 判定では、MCP server 自身の description を authorization の根拠にしない。host が既知の read-only call の exact allowlist を持ち、それ以外の MCP tool は user に確認する。file tool が `WORKDIR` の外へ出る場合は拒否し、すべての bash command は実行前に確認する。interactive approval を開けるのは foreground user turn だけで、asynchronous turn は main CLI と stdin を奪い合わず fail closed する。\n\n### Plan と Task\n\nS15 には 2 層の plan がある:\n\n- `todo_write`: current session 用の軽量 plan。メモリに保持。\n- task graph: cross-session、dependency-aware、claimable な task file。`.tasks/task_*.json` に保存。\n\n前者は単独 agent の drift を防ぐ。後者は team coordination の土台になる。\n\n目的は近いが実装は別である。`todo_write` は現在のセッションのチェックリスト全体を置き換え、task record は安定 ID と個別のライフサイクル更新を持つ。次節の独立した `task` ツールは「隔離 subagent を一度派遣する」意味であり、Task System ではない。\n\n統合 host でもタスクグラフは 2 段階で構築する。Lead はまず全タスクノードを作成し、`create_task` が返した実行時 ID で `update_task` を呼ぶ。チームメイトが使えるのは一覧・Claim・完了だけなので、依存構造は仕事を配る前に Lead が確定する。\n\n### Subagent と Team\n\nS15 には 2 種類の delegation がある:\n\n- `task`: one-shot subagent。独立した `messages[]` を使い、中間 context を捨て、final summary だけ返す。\n- `spawn_teammate`: persistent teammate thread。ready `task_id` を渡すと、runtime は thread 開始前に Claim する。省略した場合、teammate は IDLE で後続 Task を待てる。assignment がない teammate は file tool と Shell tool を使えない。固定の tool round 上限なしで `WORK → result → IDLE` を続け、model または dispatch の失敗は `error` を送り、thread cleanup は未完了 assignment を task board へ戻す。model call の前には毎回 inbox を読み、direct message や shutdown request が連続する tool-use round の後ろで待ち続けないようにする。idle 中はまず `MessageBus` を待ち、timeout 後だけ ready task を scan して最大 1 件を atomic に claim する。\n\nLead は teammate を起動した後、model loop 内で status を繰り返し確認せず、現在の turn を終了する。Lead の受信箱に team event が入ると runtime が次の turn を開始する。\n\none-shot subagent は context isolation を解決する。persistent teammate は長期並列協作を解決する。\n\n### Memory、Skills、Prompt\n\nS15 は s09 の Memory runtime をそのまま再利用する。model call の前に `.memory/MEMORY.md` catalog を読み、現在の request に関係する record を選び、その本文を `assemble_system_prompt(context)` へ渡す。turn の終了後は `extract_memories()` が後の session でも使える情報を保存し、新しい record が増えた場合は `consolidate_memories()` を続けて実行する。\n\n同じ system prompt には identity、tool guidance、workspace、skills catalog、connected MCP servers も入る。skills は catalog だけを置き、全文は `load_skill(name)` で必要な時に読む。\n\n### Compaction と Recovery\n\nLLM call の前に compaction pipeline を走らせる:\n\n```text\ntool_result_budget → snip_compact → micro_compact → compact_history\n```\n\nmodel call は recovery で包む:\n\n- 429: exponential backoff retry\n- 529: exponential backoff、連続失敗時は fallback model へ切替可能\n- `max_tokens`: max tokens を上げ、その後 continuation を要求\n- prompt too long: reactive compact 後に retry\n\n### Background と Cron\n\nbash call が `run_in_background=true` を指定すると、main loop は command の終了を待たず placeholder を返す:\n\n```text\nshould_run_background → start_background_task → placeholder tool_result\nbackground done → task_notification → next round injects messages\n```\n\nbackground path に入るのは明示的に指定された bash call だけである。command の非ゼロ終了や worker の例外は `failed` notification になる。各 Shell command は独立した process group で動き、command の終了、または Agent が通常経路や `SIGTERM` で終了する時に元の group を停止する。別の session を作った process はその group から離れられる。\n\ncron scheduler は daemon thread として動き、1 秒ごとに確認する。durable な一回限り job は、先に `pending_delivery` として永続化してから queue へ入れ、その prompt を含む model call が成功するまで保持する。呼び出し失敗時と restart 後には再び queue に入るため、配信は at-least-once である。CLI は `cron_queue`、Lead inbox、終了した background work を監視し、どの event からでも Agent を 1 turn 自動で起動する。\n\n### Worktree と MCP\n\ns13 から継承した task-scoped worktree は working directory を管理する:\n\n- pending かつ unowned の task は main workspace のままでもよく、`create_worktree(name, task_id)` で別々の branch と directory に紐付けることもできる\n- 作成前に task、name、path、branch、Git registry を検証する。Git command が失敗した後も registry と branch state を照合し、部分的に作成された checkout は未紐付けのまま manual recovery 用に保持する\n- idle teammate は ready task を 1 つ atomic に claim し、assignment は `task_id` と effective `cwd` の両方を保持する\n- Lead は ready `task_id` を `spawn_teammate` に直接渡すこともでき、Claim 成功後にだけ thread が開始する\n- teammate のすべての file tool はその `cwd` を使い、task owner だけが complete できる。assignment は current model turn の終了まで保持する\n- 削除は host 側の `remove_worktree()` helper に残し、モデルからは呼べない。user または host が task ownership、assignment lease、background work、Git state を先に確認し、破壊的な削除には別途 user confirmation を必要とする\n\nworktree は tool の default working directory を変更して working copy を分離するだけで、sandbox ではない。process group cleanup は別の session を作った process を封じ込められないため、削除は host-owned のままにする。\n\nTask の Claim または release は assignment version を変え、古い plan approval を無効にする。通常の `send_message` は text を配信するだけで、Task identity も plan state も変えない。\n\nMCP は external capability を担当する:\n\n- `connect_mcp(name)` が mock server に接続する\n- `assemble_tool_pool()` が MCP tools を tool pool に組み立て、正規化後の名前衝突を拒否する\n- tool name は `mcp__server__tool` 形式に統一する\n\n---\n\n## s14 からの変化\n\n| Scope | s14 MCP | s15 Integrated Harness |\n|-------|---------|-------------------------|\n| built-in tools | 6 | 25 |\n| external tools | 接続済み MCP tools | 同じ dynamic MCP path と host policy |\n| local mechanisms | S04 tools、hooks、permission、MCP | todo、subagent、skills、compaction、memory、task graph、background bash、cron、teams、worktrees |\n| event sources | user input と tool results | user input、tool results、cron prompts、background notifications、team events |\n\n---\n\n## 試す\n\n```sh\ncd learn-claude-code\npython s15_integrated_harness/code.py\n```\n\n試す prompt:\n\n1. `このリポジトリを調べ、重要な Python ファイルを教えてください。`\n2. `接続済みのドキュメントから agent loop の説明を探してください。`\n3. `認証モジュールとログインページを隔離した worktree で並行してリファクタリングし、編集前にそれぞれのプランを見せてください。`\n4. `3 分後に会議を知らせてください。`\n5. `依存関係をバックグラウンドでインストールしながら README.md を読んでください。`\n\n見るポイント:\n\n- tool call の前に hooks/permission を通るか\n- `connect_mcp` 後の次 round で MCP tool が出るか\n- `run_in_background=true` の bash call が background placeholder を返すか\n- cron が時刻到達時に自動で reminder を返すか\n- teammate が plan を提出し、approval 前に停止するか\n- idle teammate が ready task を 1 つだけ atomic に claim するか\n- teammate のすべての file tool が claimed task の `cwd` へ切り替わるか\n- complete 後も同じ turn の間は task `cwd` を保ち、IDLE で assignment を解除するか\n\n---\n\n## 次へ\n\n[s16 Workflow Runtime](/ja/s16) は、この host に `Workflow` tool を追加する。Workflow は固定された orchestration path を code に置き、進行状況を記録して同じ run を再開できるようにする。\n\n<!-- translation-sync: zh@v14, en@v14, ja@v14 -->\n"
|
||
},
|
||
{
|
||
"version": "s16",
|
||
"locale": "en",
|
||
"title": "s16: Workflow Runtime — The Model Decides Each Step; a Script Decides the Orchestration",
|
||
"content": "# s16: Workflow Runtime — The Model Decides Each Step; a Script Decides the Orchestration\n\ns01 → ... → s14 → [s15](/en/s15) → `s16` → [s17](/en/s17)\n\n> *\"One tool_use runs an entire orchestration\"* — The `Workflow` tool starts a recoverable script runtime that coordinates many agent calls.\n>\n> **Harness layer**: Orchestration — run saved multi-agent scripts above the single-agent loop.\n\n---\n\nFrom s01 through s15, the model decides which tools to call in each round. Their results enter `messages[]`, and the model decides the next step from the updated context. This works well when the path depends on what the previous step discovers.\n\nSome tasks repeat a fixed sequence. A code review may inspect several dimensions concurrently, verify each finding, combine duplicates, and sort the result. The sequence and dependencies are known before execution. Here the host needs three things:\n\n- **Parallelism**, rather than waiting for one item at a time;\n- **A stable result structure**, even when individual agent answers vary;\n- **Recoverability**, so an interruption does not rerun work that is already complete.\n\nIf this orchestration exists only in conversation history, its ordering and checkpoints also exist only in that history. A saved workflow puts the fixed sequence in code and records completed calls in a journal.\n\n## Put the Plan in Code, Not in a Sequence of Chat Turns\n\nAdd a `Workflow` tool to the harness tool pool. The host registers trusted scripts built from `agent()`, `parallel()`, `pipeline()`, and `phase()`. The model supplies only a saved workflow name, arguments, and an optional run ID to resume; it does not send executable code or metadata.\n\nThe workflow enters the main loop as one `tool_use`. As the script runs, the runtime emits lifecycle and progress events and records every step in a journal on disk. When the script finishes, the call returns the launch envelope, result, and task state. Intermediate script results live in variables instead of taking space in conversation history. When restarted with `resume_from_run_id`, unchanged `agent()` calls hit the journal cache and reuse previous results.\n\n\n\n```python\nSAMPLE_META = {\"name\": \"review-changes\", \"description\": \"Review code changes\", \"phases\": [\"Review\", \"Verify\"]}\n\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n results = await ctx.pipeline(DIMENSIONS, audit, verify) # Each dimension independently runs audit → verify\n confirmed = [f for r in results if r for f in r[\"confirmed\"]]\n ctx.log(f\"Confirmed {len(confirmed)} real issues\")\n return {\"confirmed\": confirmed}\n```\n\n## The Workflow Tool: One Call, One Complete Run\n\n`Workflow` is added to the s15 host's existing tool pool. The user can request a saved workflow, or the model can select it when a task matches a known orchestration. The adapter resolves the name through the host-owned `WORKFLOWS` registry, then passes its trusted metadata and function to the runtime. The other s15 tools remain available in the same loop.\n\nThe model-facing schema accepts `name`, `args`, and `resume_from_run_id`. Unknown names and malformed arguments become an error tool result instead of ending the host loop. The runtime then validates the registered metadata, checks permissions, registers a local workflow task, and emits `async_launched` before running the script. Progress events follow, then the final `task_notification`; the call returns JSON-safe launch information, result, and task state.\n\n```python\nWORKFLOW_TOOL = {\n \"name\": \"Workflow\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\"},\n \"args\": {\"type\": \"object\"},\n \"resume_from_run_id\": {\"type\": \"string\"},\n },\n \"required\": [\"name\"],\n \"additionalProperties\": False,\n },\n}\n\nasync def run_workflow(name, args=None, resume_from_run_id=None):\n meta, script_fn = WORKFLOWS[name]\n out = await WorkflowTool().call(\n meta, script_fn,\n args=args,\n resume_from_run_id=resume_from_run_id,\n )\n return {\"launched\": out[\"launched\"], \"result\": out[\"result\"],\n \"task\": serialize_task(out[\"task\"])}\n```\n\n## Workflow Metadata: Validate Before Launch\n\nEach saved workflow registers trusted metadata with `name`, `description`, and optional `phases`. The runtime validates it before executing workflow code. `name` and `description` identify the task in the UI, while `phases` names groups in the progress display. These fields belong to the host registry, not to model input.\n\nInvalid registration raises `WorkflowInputError` before launch. This is the same idea as validating cron expressions in s12: do not wait until execution to discover a bad saved workflow.\n\nBecause the runtime uses `meta.name` in local artifact filenames, it also requires a 1-64 character safe slug containing letters, numbers, `.`, `_`, or `-`.\n\n```python\ndef validate_meta(meta):\n if not isinstance(meta, dict):\n raise WorkflowInputError(\"meta must be an object literal\")\n if not meta.get(\"name\") or not meta.get(\"description\"):\n raise WorkflowInputError(\"meta requires name and description\")\n if not isinstance(meta[\"name\"], str) or not WORKFLOW_NAME_RE.fullmatch(meta[\"name\"]):\n raise WorkflowInputError(\"meta.name must be a safe 1-64 character slug\")\n if \"phases\" in meta and (\n not isinstance(meta[\"phases\"], list)\n or not all(isinstance(p, str) and p for p in meta[\"phases\"])\n ):\n raise WorkflowInputError(\"meta.phases must contain non-empty strings\")\n return meta\n```\n\n## Orchestration Primitives\n\nA script receives an `ExecutionState` exposing a small set of orchestration primitives. It does not read files or run shell commands directly. The default interactive mode connects `agent()` to the same real API client as the host, and each workflow agent reads only the content supplied through workflow arguments. `demo` and unit tests use `MockAgentRunner` so events and journal replay are repeatable.\n\n| Primitive | Purpose |\n|------|------|\n| `agent(prompt, {schema, label, phase})` | Dispatch one subagent |\n| `parallel(thunks)` | **Barrier**: run every task concurrently and wait until all results return |\n| `pipeline(items, *stages)` | Run each item through stages **without a barrier**; finished items proceed immediately |\n| `phase(title)` | Mark the current progress phase and update the progress display |\n| `log(message)` | Emit a progress log line |\n| `workflow(name, args)` | Run a nested sub-workflow, one level only |\n\nUse `pipeline` when each item independently crosses the same stages. Item A may reach stage three while item B is still in stage one. Use `parallel` when the next step needs every result from the preceding group.\n\n```python\nasync def pipeline(self, items, *stages):\n async def run_item(item, idx):\n value = item\n for stage in stages: # Each item independently completes every stage\n value = await stage(value, item, idx)\n return value\n return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])\n```\n\n## Structured Output: Do Not Let Subagents Return Essays\n\n`agent({schema})` asks a workflow agent to return only a JSON object matching the schema. The runtime parses and validates the result, then retries once if it does not match. Downstream code receives an object instead of extracting fields from prose.\n\ns05 warned that tool arguments cannot be trusted completely. This is the same lesson in reverse: subagent output cannot be trusted completely either. Validate at the orchestration boundary, give one retry, and keep uncertainty out of the rest of the flow.\n\n```python\nrun = await asyncio.to_thread(self.runner.run, prompt, schema, label)\nresult = run.value\nif schema is not None:\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok: # Retry once with a reminder, then fail\n retry = await asyncio.to_thread(\n self.runner.run, prompt + \"\\n\\nReturn valid JSON.\", schema, label\n )\n result = retry.value\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok:\n raise WorkflowInputError(f\"agent({{schema}}) returned invalid output: {err}\")\n```\n\n## Task State and Progress Events\n\n`LocalWorkflowTask` maintains status and token usage and emits an SDK-style event stream: `task_started` → a sequence of `task_progress` events containing phase changes, subagent starts, and log batches → one final `task_notification` reporting completion or failure, plus the output file and agent and token counts.\n\nThe demo prints these events in order and returns the task state after the final notification.\n\n```python\nclass LocalWorkflowTask:\n def progress_event(self, ptype, **data): # Phase/subagent/log\n self.progress.append({\"type\": ptype, **data})\n print(f\" progress {ptype} ...\")\n```\n\n## Storage: Snapshot + Journal for Resuming after Interruptions\n\nThe runtime stores each run under `s16_workflow_runtime/.runtime/`: a `<runId>.json` snapshot, `<runId>.output.json` output, `<runId>.journal.jsonl` journal, and `<runId>.lock` coordination file. Every fresh run reserves a new `runId` with exclusive file creation before opening its journal. The run lock stays held through execution and final persistence, so another process cannot resume the same run at the same time. Its snapshot records the workflow name, arguments, and task state; resume validates the saved snapshot and journal before changing either successful artifact.\n\nThe journal is the core of checkpointed resume. It records every `agent()` result one line at a time:\n\n```python\nclass WorkflowJournal:\n def record(self, key, value):\n self._f.write(json.dumps({\"key\": key, \"value\": value}) + \"\\n\")\n self._f.flush()\n self.cache[key] = value\n```\n\n## Resume: Continue by runId and Reuse Everything Unchanged\n\nCalling the workflow again with `resume_from_run_id` reruns the script, but every `agent()` computes a deterministic semantic key. If that key is present in the journal, it returns the cached result without executing again. Every unchanged call hits the cache; only a changed call and the downstream steps that depend on it actually rerun.\n\nThe key detail is that keys cannot depend on concurrency order. Agents in `parallel` and `pipeline` finish in nondeterministic order. If \"the nth completion\" became the key, cache entries would map to the wrong calls on the next run. A key therefore uses a stable hash of call content, including type, label, prompt, and schema, rather than a shared counter:\n\n```python\ndef key(self, kind, label, prompt, schema):\n basis = f\"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}\"\n return f\"{kind}-{_stable_hash(basis) % 10**10:010d}\"\n\n# Inside agent():\ncached = self.journal.cached(key)\nif cached is not MISS:\n self.task.progress_event(\"workflow_agent\", label=label, status=\"cached\")\n return cached\n```\n\n## Stable Call Keys\n\nOn resume, the runtime must match each current `agent()` call with its earlier journal record. A stable hash gives unchanged workflow code and arguments the same call key. Real model output may vary; when the call content has not changed, resume uses the result already saved in the journal.\n\n## See It Run\n\nThe sample `review-changes` workflow uses `pipeline` to send each review dimension independently through audit → verify. Interactive mode uses the real API and reads the material to review from `args.changes`. `demo` uses fixed runner data to show pipeline, validation, journal, and resume behavior.\n\n```python\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n changes = args.get(\"changes\", \"\")\n\n async def audit(_v, dimension, _i):\n out = await ctx.agent(f\"Inspect this change for {dimension} issues:\\n{changes}\",\n schema=FINDINGS_SCHEMA, label=f\"audit:{dimension}\", phase=\"Review\")\n return {\"dimension\": dimension, \"findings\": out[\"findings\"]}\n\n async def verify(audited, dimension, _i):\n ctx.phase(\"Verify\")\n verdicts = await ctx.parallel([ # Verify every finding independently\n (lambda f=f: ctx.agent(f\"Verify this finding against the change:\\n{changes}\\n\\n{f}\",\n schema=VERDICT_SCHEMA, label=f\"verify:{dimension}:{f['title']}\"))\n for f in audited[\"findings\"]])\n return {\"dimension\": dimension,\n \"confirmed\": [f for f, v in zip(audited[\"findings\"], verdicts) if v and v[\"isReal\"]]}\n\n results = await ctx.pipeline(DIMENSIONS, audit, verify)\n ...\n```\n\n## Changes from s15\n\n| | s15 Integrated Harness | s16 Workflow Runtime |\n|--|-----------|---------------------|\n| Loop | One model-driven loop | Main loop unchanged; a tool runs scripted orchestration |\n| Who decides the next step | Model decides each round | Script declares the orchestration in advance |\n| Multiple agents | One-shot s06 subagents | Scripted, resumable calls through an agent-runner boundary |\n| New mechanisms | — | Script primitives, host registry and tool adapter, task lifecycle, progress events, journal/resume, structured output |\n\ns16 does not replace the main loop. It exposes `Workflow` at the tool layer and starts a local workflow runtime behind it: one saved script coordinates N calls through an agent-runner boundary. An s06 subagent is dispatched once at the model's discretion; s16 turns the orchestration into resumable host code.\n\n## Try It\n\n```bash\npython s16_workflow_runtime/code.py # Both the main model and Workflow agents use the real API\npython s16_workflow_runtime/code.py demo # Deterministic review-changes fixture and event stream\npython s16_workflow_runtime/code.py resume # Resume by the last runId; every agent() hits the journal cache\n```\n\nIn the default command, ask the model to read the changes, place that text in `args.changes`, and run the saved `review-changes` workflow. Both the main model and workflow agents use the real API. The `demo` command uses fixed runner data so lifecycle and resume behavior can be observed repeatedly. A resumed demo reports `agents=0 tokens=0` when every call hits the cache.\n\n## Next\n\n[s17 Goal Loop](/en/s17) uses a smaller, independent loop to check whether a stated goal has been reached and decide whether another turn is needed.\n\n<!-- translation-sync: zh@v10, en@v10, ja@v10 -->\n"
|
||
},
|
||
{
|
||
"version": "s16",
|
||
"locale": "zh",
|
||
"title": "s16: Workflow Runtime — 模型决定单步,脚本决定编排",
|
||
"content": "# s16: Workflow Runtime — 模型决定单步,脚本决定编排\n\ns01 → ... → s14 → [s15](/zh/s15) → `s16` → [s17](/zh/s17)\n\n> *\"一次 tool_use,跑完一整套编排\"* — `Workflow` 工具启动一个可恢复的脚本运行时,协调多次 agent 调用。\n>\n> **Harness 层**: 编排 — 在单 agent 循环之上,执行保存好的多 agent 脚本。\n\n---\n\n从 s01 到 s15,每一轮都由模型决定调用哪些工具。工具结果进入 `messages[]` 后,模型再根据更新后的上下文决定下一步。当后续路径取决于上一步发现了什么时,这种方式很合适。\n\n有些任务会重复一套固定流程。例如代码审查可以同时检查多个维度,再逐条验证发现、合并重复项并按严重程度排序。执行前已经知道步骤及其先后关系,这时宿主需要三样东西:\n\n- **并行**,别一个一个串着等;\n- **稳定的结果结构**,即使每个 agent 的回答会变化;\n- **可恢复**,跑到一半断了,已经做完的部分别从头再来。\n\n如果这套编排只存在于对话历史里,步骤顺序和检查点也只存在于历史里。保存好的 workflow 把固定流程写进代码,并在 journal 中记录已经完成的调用。\n\n## 计划写在代码里,不是靠聊天一轮轮凑\n\n在 harness 的工具池里加入一个 `Workflow` 工具。宿主注册由 `agent() / parallel() / pipeline() / phase()` 组成的可信脚本。模型只提供保存好的 workflow 名称、参数和可选的续跑 run ID,不会提交可执行代码或元数据。\n\nworkflow 以一次 `tool_use` 进入主循环。脚本运行时,runtime 会发出生命周期和进度事件,并把每一步写进磁盘上的 journal。脚本结束后,这次调用返回启动信息、结果和任务状态。脚本里的中间结果存在变量里,不会塞进对话历史。下次用 `resume_from_run_id` 重启时,没改过的 `agent()` 会直接使用 journal 中的结果。\n\n\n\n```python\nSAMPLE_META = {\"name\": \"review-changes\", \"description\": \"审查代码改动\", \"phases\": [\"Review\", \"Verify\"]}\n\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n results = await ctx.pipeline(DIMENSIONS, audit, verify) # 每个维度独立走 审计 → 验证\n confirmed = [f for r in results if r for f in r[\"confirmed\"]]\n ctx.log(f\"确认了 {len(confirmed)} 个真实问题\")\n return {\"confirmed\": confirmed}\n```\n\n## Workflow 工具:一次调用,完成整次运行\n\n`Workflow` 会加入 s15 宿主已有的工具池。用户可以要求运行一个保存好的 workflow,模型也可以在任务匹配已知编排时选择这个工具。适配器会用名称查询宿主管理的 `WORKFLOWS` registry,再把可信的元数据和函数交给运行时;s15 的其他工具仍在同一个循环里可用。\n\n模型可见的 schema 只接受 `name`、`args` 和 `resume_from_run_id`。名称未知或参数格式错误时,适配器会返回错误工具结果,不会让宿主循环退出。随后运行时校验已经注册的元数据、经过权限检查、注册本地 workflow 任务,并在执行脚本前发出 `async_launched`。进度事件和最终的 `task_notification` 随后到达;调用返回可写入 JSON 的启动信息、结果和任务状态。\n\n```python\nWORKFLOW_TOOL = {\n \"name\": \"Workflow\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\"},\n \"args\": {\"type\": \"object\"},\n \"resume_from_run_id\": {\"type\": \"string\"},\n },\n \"required\": [\"name\"],\n \"additionalProperties\": False,\n },\n}\n\nasync def run_workflow(name, args=None, resume_from_run_id=None):\n meta, script_fn = WORKFLOWS[name]\n out = await WorkflowTool().call(\n meta, script_fn,\n args=args,\n resume_from_run_id=resume_from_run_id,\n )\n return {\"launched\": out[\"launched\"], \"result\": out[\"result\"],\n \"task\": serialize_task(out[\"task\"])}\n```\n\n## Workflow 元数据:启动前先校验\n\n每个保存好的 workflow 都会注册一份可信元数据,包含 `name`、`description` 和可选的 `phases`。运行时会在执行 workflow 代码前校验它:`name` 和 `description` 用来标识任务,`phases` 给进度显示分组命名。这些字段属于宿主 registry,不是模型输入。\n\n注册内容不合法时,运行时会在启动前抛出 `WorkflowInputError`。这和 s12 校验 cron 表达式是一个思路:保存好的 workflow 有问题,就不要等到执行时才发现。\n\n运行时会把 `meta.name` 用在本地产物文件名中,因此还要求它是 1-64 个字符的安全 slug,只能包含字母、数字、`.`、`_`、`-`。\n\n```python\ndef validate_meta(meta):\n if not isinstance(meta, dict):\n raise WorkflowInputError(\"meta 必须是对象字面量\")\n if not meta.get(\"name\") or not meta.get(\"description\"):\n raise WorkflowInputError(\"meta 必须包含 name 和 description\")\n if not isinstance(meta[\"name\"], str) or not WORKFLOW_NAME_RE.fullmatch(meta[\"name\"]):\n raise WorkflowInputError(\"meta.name 必须是 1-64 字符的安全 slug\")\n if \"phases\" in meta and (\n not isinstance(meta[\"phases\"], list)\n or not all(isinstance(p, str) and p for p in meta[\"phases\"])\n ):\n raise WorkflowInputError(\"meta.phases 必须包含非空字符串\")\n return meta\n```\n\n## 编排原语\n\n脚本收到一个只暴露少量编排原语的 `ExecutionState`,本身不直接读写文件,也不运行 shell。默认交互模式把 `agent()` 接到与宿主相同的真实 API client;每个子 agent 只读取 workflow 参数中提供的内容。`demo` 和单元测试使用 `MockAgentRunner`,便于重复观察事件和 journal。\n\n| 原语 | 作用 |\n|------|------|\n| `agent(prompt, {schema, label, phase})` | 派一个子 agent 干活 |\n| `parallel(thunks)` | **等齐屏障**:所有任务并行跑完,一起等结果回来 |\n| `pipeline(items, *stages)` | 每个 item 分阶段跑,**不等齐**,跑完一个往下走一个 |\n| `phase(title)` | 标记当前进度阶段(更新进度条) |\n| `log(message)` | 打一行进度日志 |\n| `workflow(name, args)` | 嵌套子工作流(只支持一层) |\n\n每个 item 都要独立经过相同步骤时,可以使用 `pipeline`。item A 跑到第 3 阶段时,item B 可能还在第 1 阶段;下一步必须同时使用上一阶段全部结果时,再使用 `parallel` 等待所有调用完成。\n\n```python\nasync def pipeline(self, items, *stages):\n async def run_item(item, idx):\n value = item\n for stage in stages: # 每个 item 独立跑完所有 stage\n value = await stage(value, item, idx)\n return value\n return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])\n```\n\n## 结构化输出:别让子 agent 回来写散文\n\n`agent({schema})` 会要求子 agent 只返回匹配 schema 的 JSON 对象。运行时解析并校验结果,不符合时重试一次。这样下游代码拿到的是对象,不必再从自然语言中提取字段。\n\ns05 就说过,工具的参数不能全信;这里是同一个道理反过来:子 agent 的输出也不能全信。加一层校验,不对就给一次机会重试,把不确定性挡在编排层外面。\n\n```python\nrun = await asyncio.to_thread(self.runner.run, prompt, schema, label)\nresult = run.value\nif schema is not None:\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok: # 提醒一次重试,再不对就报错\n retry = await asyncio.to_thread(\n self.runner.run, prompt + \"\\n\\n返回合法的 JSON。\", schema, label\n )\n result = retry.value\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok:\n raise WorkflowInputError(f\"agent({{schema}}) 输出不合法: {err}\")\n```\n\n## 任务状态和进度事件\n\n`LocalWorkflowTask` 维护状态和 token 用量,向外发一条 SDK 风格的事件流:`task_started` → 一串 `task_progress`(包含阶段切换、子 agent 启动和日志输出)→ 最后一个 `task_notification`(完成或失败,带输出文件、agent 数和 token 数)。\n\n演示会按顺序打印这些事件,并在最终通知后返回任务状态。\n\n```python\nclass LocalWorkflowTask:\n def progress_event(self, ptype, **data): # 阶段/子agent/日志\n self.progress.append({\"type\": ptype, **data})\n print(f\" 进度 {ptype} ...\")\n```\n\n## 存储:快照 + journal,断了能续\n\n运行时把每次运行的数据存在 `s16_workflow_runtime/.runtime/`:快照 `<runId>.json`、输出 `<runId>.output.json`、journal `<runId>.journal.jsonl` 和协调文件 `<runId>.lock`。每次新运行都会在打开 journal 前,用排他式文件创建预留新的 `runId`。整次执行和最终持久化期间都持有 run lock,另一个进程不能同时 resume 同一次运行。快照记录 workflow 名称、参数和任务状态;resume 会先验证已保存的快照和 journal,再改动原有的成功产物。\n\njournal 是断点续跑的核心,它一条一条记下来每个 `agent()` 的结果:\n\n```python\nclass WorkflowJournal:\n def record(self, key, value):\n self._f.write(json.dumps({\"key\": key, \"value\": value}) + \"\\n\")\n self._f.flush()\n self.cache[key] = value\n```\n\n## resume:用 runId 续跑,没改的直接用缓存\n\n带着 `resume_from_run_id` 再次调用 workflow 时,脚本会重新执行,但每个 `agent()` 都会计算一个确定的语义 key:key 在 journal 里有记录,就直接返回缓存结果;只有改过的调用以及依赖它的后续步骤才会真的运行。\n\n这里有个关键点:key 不能依赖并发顺序。`parallel` 和 `pipeline` 里 agent 完成的顺序是不确定的,用\"第几个完成\"当 key,两次跑缓存就对错位了。所以 key 是根据调用内容(类型、标签、prompt、schema)算的稳定哈希,不是一个会竞争的计数器:\n\n```python\ndef key(self, kind, label, prompt, schema):\n basis = f\"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}\"\n return f\"{kind}-{_stable_hash(basis) % 10**10:010d}\"\n\n# agent() 内部:\ncached = self.journal.cached(key)\nif cached is not MISS:\n self.task.progress_event(\"workflow_agent\", label=label, status=\"cached\")\n return cached\n```\n\n## 稳定调用键\n\n续跑时,运行时需要把当前 `agent()` 与 journal 中的旧调用对应起来。稳定哈希让同一份 workflow 和同样的参数产生相同的调用 key。真实模型的回答可以变化;只要调用内容没有变化,resume 就直接使用 journal 中已经保存的结果。\n\n## 跑起来看看\n\n示例 workflow `review-changes` 用 `pipeline` 让每个审查维度独立走“审计 → 验证”。默认交互模式使用真实 API,并从 `args.changes` 读取待审查内容;`demo` 使用固定 runner 数据来展示 pipeline、结构校验、journal 和续跑。\n\n```python\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n changes = args.get(\"changes\", \"\")\n\n async def audit(_v, dimension, _i):\n out = await ctx.agent(f\"检查这段变更里有没有{dimension}相关的问题:\\n{changes}\",\n schema=FINDINGS_SCHEMA, label=f\"audit:{dimension}\", phase=\"Review\")\n return {\"dimension\": dimension, \"findings\": out[\"findings\"]}\n\n async def verify(audited, dimension, _i):\n ctx.phase(\"Verify\")\n verdicts = await ctx.parallel([ # 每条发现独立做对抗性验证\n (lambda f=f: ctx.agent(f\"根据变更内容验证这条 finding:\\n{changes}\\n\\n{f}\",\n schema=VERDICT_SCHEMA, label=f\"verify:{dimension}:{f['title']}\"))\n for f in audited[\"findings\"]])\n return {\"dimension\": dimension,\n \"confirmed\": [f for f, v in zip(audited[\"findings\"], verdicts) if v and v[\"isReal\"]]}\n\n results = await ctx.pipeline(DIMENSIONS, audit, verify)\n ...\n```\n\n## 相对 s15 的变更\n\n| | s15 Agent Harness 集成 | s16 Workflow Runtime |\n|--|-----------|---------------------|\n| 循环 | 单个、模型驱动 | 主循环不变;工具背后执行脚本编排 |\n| 谁决定下一步 | 模型逐轮决定 | 脚本预先写好编排流程 |\n| 多 agent | s06 子 agent,一次性派出去 | 通过 agent-runner 边界执行脚本化、可续跑的调用 |\n| 新增机制 | — | 编排原语、宿主 registry 与工具适配器、任务生命周期、进度事件、journal/续跑、结构化输出 |\n\ns16 不替换主循环,它只是在工具层暴露 `Workflow`,背后启动一个本地 workflow 运行时:一份保存好的脚本通过 agent-runner 边界协调 N 次调用。s06 的子 agent 是模型临场派一次;s16 把编排写成可续跑的宿主代码。\n\n## 试一下\n\n```bash\npython s16_workflow_runtime/code.py # 主模型和 Workflow 子 agent 都使用真实 API\npython s16_workflow_runtime/code.py demo # 运行确定性的 review-changes 测试数据并观察事件流\npython s16_workflow_runtime/code.py resume # 用上次的 runId 续跑,每个 agent() 都命中 journal 缓存\n```\n\n默认命令里,可以先让模型读取改动,再把内容放进 `args.changes` 并运行保存好的 `review-changes` workflow。主模型和 workflow 子 agent 都使用真实 API。`demo` 命令使用固定 runner 数据,便于重复观察生命周期和续跑;续跑命中全部缓存时显示 `agents=0 tokens=0`。\n\n## 接下来\n\n[s17 Goal Loop](/zh/s17) 会使用一个更小、独立的循环检查既定目标是否已经达成,并据此决定是否还需要下一轮。\n\n<!-- translation-sync: zh@v10, en@v10, ja@v10 -->\n"
|
||
},
|
||
{
|
||
"version": "s16",
|
||
"locale": "ja",
|
||
"title": "s16: Workflow Runtime — モデルが単一 step を決め、script が orchestration を決める",
|
||
"content": "# s16: Workflow Runtime — モデルが単一 step を決め、script が orchestration を決める\n\ns01 → ... → s14 → [s15](/ja/s15) → `s16` → [s17](/ja/s17)\n\n> *「1 回の tool_use で、一式の orchestration を実行する」* — `Workflow` ツールが復元可能な script runtime を起動し、多数の agent call を協調させます。\n>\n> **Harness 層**: Orchestration — single-agent loop の上で保存済み multi-agent script を実行します。\n\n---\n\ns01 から s15 まで、各 round で model が呼び出す tools を決めます。tool results が `messages[]` に入ると、model は更新された context から次の step を決めます。次の経路が前の step の発見に依存する task に向いています。\n\n一方、固定された流れを繰り返す task もあります。code review なら、複数の観点を同時に調べ、各 finding を検証し、重複をまとめて severity 順に並べます。実行前に step と順序が分かっている場合、host には次の 3 つが必要です。\n\n- **並行性**: 1 件ずつ順番に待たないこと。\n- **安定した結果構造**: 個々の agent answer が変わっても構造を保つこと。\n- **復元可能性**: 途中で止まっても、完了済みの部分を最初からやり直さないこと。\n\nこの orchestration が conversation history にしか存在しなければ、順序と checkpoint も history にしか残りません。saved workflow は固定 flow を code に置き、完了した call を journal に記録します。\n\n## 計画は chat のラウンドを重ねず、コードに書く\n\nharness の tool pool に `Workflow` ツールを追加します。host は `agent() / parallel() / pipeline() / phase()` で構成した trusted script を登録します。model が渡すのは saved workflow name、argument、任意の resume run ID だけで、実行可能 code や metadata は渡しません。\n\nworkflow は 1 回の `tool_use` として main loop に入ります。script の実行中、runtime は lifecycle event と progress event を出し、各 step を disk journal へ記録します。script が終わると、この call は launch 情報、result、task state を返します。script の中間結果は変数に保存され、conversation history を使いません。`resume_from_run_id` で再開すると、変更されていない `agent()` は journal の結果を再利用します。\n\n\n\n```python\nSAMPLE_META = {\"name\": \"review-changes\", \"description\": \"コード変更を review\", \"phases\": [\"Review\", \"Verify\"]}\n\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n results = await ctx.pipeline(DIMENSIONS, audit, verify) # 各 dimension が独立して audit → verify を通る\n confirmed = [f for r in results if r for f in r[\"confirmed\"]]\n ctx.log(f\"{len(confirmed)} 件の実在する問題を確認\")\n return {\"confirmed\": confirmed}\n```\n\n## Workflow ツール: 1 回の call で run 全体を実行する\n\n`Workflow` は s15 host の既存 tool pool に追加されます。ユーザーが保存済み workflow の実行を求めるか、タスクが既知の orchestration に一致したときにモデルがこのツールを選びます。adapter は name を host-owned `WORKFLOWS` registry で解決し、trusted metadata と function を runtime へ渡します。s15 の他の tools も同じ loop で利用できます。\n\nmodel-facing schema が受け取るのは `name`、`args`、`resume_from_run_id` です。unknown name や不正 argument は error tool result として返し、host loop を終了させません。その後 runtime が登録済み metadata を検証し、permission check を通し、local workflow task を登録して、script の実行前に `async_launched` を出します。progress event と最後の `task_notification` が続き、call は JSON-safe な launch 情報、result、task state を返します。\n\n```python\nWORKFLOW_TOOL = {\n \"name\": \"Workflow\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\"},\n \"args\": {\"type\": \"object\"},\n \"resume_from_run_id\": {\"type\": \"string\"},\n },\n \"required\": [\"name\"],\n \"additionalProperties\": False,\n },\n}\n\nasync def run_workflow(name, args=None, resume_from_run_id=None):\n meta, script_fn = WORKFLOWS[name]\n out = await WorkflowTool().call(\n meta, script_fn,\n args=args,\n resume_from_run_id=resume_from_run_id,\n )\n return {\"launched\": out[\"launched\"], \"result\": out[\"result\"],\n \"task\": serialize_task(out[\"task\"])}\n```\n\n## Workflow metadata: 起動前に検証する\n\n各 saved workflow は `name`、`description`、任意の `phases` を持つ trusted metadata を登録します。runtime は workflow code を実行する前に検証します。`name` と `description` は task と UI の表示に使い、`phases` は progress 表示の group 名を定義します。これらは model input ではなく host registry に属します。\n\n不正な登録内容は launch 前に `WorkflowInputError` になります。s12 の cron 式検証と同じ考えです。不正な saved workflow が実行時まで進んでから壊れないようにします。\n\nruntime は `meta.name` をローカル artifact のファイル名に使うため、英数字で始まり、英数字、`.`、`_`、`-` のみからなる 1-64 文字の安全な slug も要求する。\n\n```python\ndef validate_meta(meta):\n if not isinstance(meta, dict):\n raise WorkflowInputError(\"meta は object literal でなければなりません\")\n if not meta.get(\"name\") or not meta.get(\"description\"):\n raise WorkflowInputError(\"meta には name と description が必要です\")\n if not isinstance(meta[\"name\"], str) or not WORKFLOW_NAME_RE.fullmatch(meta[\"name\"]):\n raise WorkflowInputError(\"meta.name は安全な 1-64 文字の slug が必要です\")\n if \"phases\" in meta and (\n not isinstance(meta[\"phases\"], list)\n or not all(isinstance(p, str) and p for p in meta[\"phases\"])\n ):\n raise WorkflowInputError(\"meta.phases は空でない文字列だけを含む必要があります\")\n return meta\n```\n\n## Orchestration primitive\n\nscript は少数の orchestration primitive だけを公開する `ExecutionState` を受け取り、ファイルを直接読み書きせず、shell も実行しません。default の interactive mode では `agent()` を host と同じ real API client に接続し、各 workflow agent は arguments で渡された内容だけを読みます。`demo` と unit test は `MockAgentRunner` を使い、event と journal replay を繰り返し確認できるようにします。\n\n| Primitive | 役割 |\n|------|------|\n| `agent(prompt, {schema, label, phase})` | 1 つの subagent を派遣 |\n| `parallel(thunks)` | **barrier**: すべての task を並行実行し、全結果が戻るまで待つ |\n| `pipeline(items, *stages)` | 各 item を **barrier なし**で stage ごとに実行し、終わった item から先へ進める |\n| `phase(title)` | 現在の progress phase を記録し、progress bar を更新 |\n| `log(message)` | progress log を 1 行出力 |\n| `workflow(name, args)` | nested sub-workflow(1 階層だけ) |\n\n各 item が同じ stage を独立して通る場合は `pipeline` を使えます。item A が stage 3 にいる間、item B はまだ stage 1 かもしれません。次の処理が前の group の全結果を必要とする場合は `parallel` を使います。\n\n```python\nasync def pipeline(self, items, *stages):\n async def run_item(item, idx):\n value = item\n for stage in stages: # 各 item がすべての stage を独立して完走\n value = await stage(value, item, idx)\n return value\n return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])\n```\n\n## 構造化出力: Subagent に散文を返させない\n\n`agent({schema})` は、schema に一致する JSON object だけを返すよう workflow agent に要求します。runtime は結果を parse、validate し、不一致なら 1 回 retry します。下流コードは prose から field を取り出さず、object を受け取れます。\n\ns05 では tool argument を全面的に信頼できないと説明しました。ここでは同じ教訓を逆向きに使います。subagent の出力も全面的には信頼できません。orchestration boundary で検証し、1 回 retry の機会を与え、不確実性を後続 flow の外へ止めます。\n\n```python\nrun = await asyncio.to_thread(self.runner.run, prompt, schema, label)\nresult = run.value\nif schema is not None:\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok: # 1 回だけ注意して retry、それでも不正なら error\n retry = await asyncio.to_thread(\n self.runner.run, prompt + \"\\n\\n有効な JSON を返してください。\", schema, label\n )\n result = retry.value\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok:\n raise WorkflowInputError(f\"agent({{schema}}) の出力が不正です: {err}\")\n```\n\n## Task state と progress event\n\n`LocalWorkflowTask` は status と token usage を管理し、SDK style の event stream を外へ出します。`task_started` → phase change、subagent start、log を含む一連の `task_progress` → 完了または失敗に加え、output file、agent 数、token 数を含む最後の `task_notification` です。\n\ndemo はこれらの event を順番に表示し、最後の notification の後で task state を返します。\n\n```python\nclass LocalWorkflowTask:\n def progress_event(self, ptype, **data): # phase/subagent/log\n self.progress.append({\"type\": ptype, **data})\n print(f\" progress {ptype} ...\")\n```\n\n## 保存: Snapshot + journal で中断から再開する\n\nruntime は各 run を `s16_workflow_runtime/.runtime/` に保存します。`<runId>.json` snapshot、`<runId>.output.json` output、`<runId>.journal.jsonl` journal、`<runId>.lock` coordination file です。fresh run は journal を開く前に exclusive file creation で新しい `runId` を予約します。run lock は実行と最終永続化が終わるまで保持するため、別 process は同じ run を同時に resume できません。snapshot に workflow name、arguments、task state を記録し、resume は保存済み snapshot と journal を先に検証してから、成功済み artifact を変更します。\n\njournal は checkpoint resume の中心で、各 `agent()` の結果を 1 行ずつ記録します。\n\n```python\nclass WorkflowJournal:\n def record(self, key, value):\n self._f.write(json.dumps({\"key\": key, \"value\": value}) + \"\\n\")\n self._f.flush()\n self.cache[key] = value\n```\n\n## Resume: runId から続行し、変更のないものを再利用する\n\n`resume_from_run_id` を渡して workflow を再度呼ぶと script を再実行しますが、各 `agent()` は決定的な semantic key を計算します。journal に key があれば、再実行せず cached result を返します。変更された call と、それに依存する後続 step だけが本当に動きます。\n\nkey は concurrency の完了順に依存してはいけません。`parallel` と `pipeline` の Agent は不定の順番で完了します。「何番目に完了したか」を key にすると、次回の cache が別の call へ対応してしまいます。そのため key は競合する counter ではなく、call の内容、つまり type、label、prompt、schema の stable hash です。\n\n```python\ndef key(self, kind, label, prompt, schema):\n basis = f\"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}\"\n return f\"{kind}-{_stable_hash(basis) % 10**10:010d}\"\n\n# agent() の内部:\ncached = self.journal.cached(key)\nif cached is not MISS:\n self.task.progress_event(\"workflow_agent\", label=label, status=\"cached\")\n return cached\n```\n\n## Stable call key\n\nresume では、現在の各 `agent()` call を以前の journal record と対応付ける必要があります。stable hash は変更されていない workflow code と arguments に同じ call key を与えます。real model の出力は変化しても、call 内容が同じなら journal に保存済みの result を使います。\n\n## 実際に動かす\n\nsample workflow `review-changes` は `pipeline` を使い、各 review dimension を独立して audit → verify へ通します。interactive mode は real API を使い、`args.changes` から review 対象を読みます。`demo` は固定 runner data で pipeline、validation、journal、resume を示します。\n\n```python\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n changes = args.get(\"changes\", \"\")\n\n async def audit(_v, dimension, _i):\n out = await ctx.agent(f\"この変更に {dimension} 関連の問題がないか確認してください:\\n{changes}\",\n schema=FINDINGS_SCHEMA, label=f\"audit:{dimension}\", phase=\"Review\")\n return {\"dimension\": dimension, \"findings\": out[\"findings\"]}\n\n async def verify(audited, dimension, _i):\n ctx.phase(\"Verify\")\n verdicts = await ctx.parallel([ # 各 finding を独立して verify\n (lambda f=f: ctx.agent(f\"変更内容に照らして finding を検証してください:\\n{changes}\\n\\n{f}\",\n schema=VERDICT_SCHEMA, label=f\"verify:{dimension}:{f['title']}\"))\n for f in audited[\"findings\"]])\n return {\"dimension\": dimension,\n \"confirmed\": [f for f, v in zip(audited[\"findings\"], verdicts) if v and v[\"isReal\"]]}\n\n results = await ctx.pipeline(DIMENSIONS, audit, verify)\n ...\n```\n\n## s15 からの変更点\n\n| | s15 Integrated Harness | s16 Workflow Runtime |\n|--|-----------|---------------------|\n| loop | 1 つ、モデル駆動 | main loop は不変。tool の背後で script orchestration を実行 |\n| 次の step を決めるもの | モデルが毎ラウンド判断 | script が orchestration flow を事前に定義 |\n| multi-agent | s06 subagent を一度だけ派遣 | agent-runner boundary を通る scripted、resumable call |\n| 新しい仕組み | — | orchestration primitive、host registry と tool adapter、task lifecycle、progress event、journal/resume、structured output |\n\ns16 は main loop を置き換えません。tool layer に `Workflow` を公開し、背後で local workflow runtime を起動します。saved script が agent-runner boundary を通じて N 回の call を協調させます。s06 の subagent はモデルがその場で 1 回派遣し、s16 は orchestration を resumable な host code にします。\n\n## 試してみる\n\n```bash\npython s16_workflow_runtime/code.py # main model と Workflow agent の両方が real API を使う\npython s16_workflow_runtime/code.py demo # deterministic fixture と event stream を確認\npython s16_workflow_runtime/code.py resume # 前回の runId から resume。すべての agent() が journal cache に当たる\n```\n\ndefault command では、model に changes を読ませ、その text を `args.changes` に入れて保存済み `review-changes` workflow を実行させます。main model と workflow agent の両方が real API を使います。`demo` は固定 runner data で lifecycle と resume を繰り返し観察でき、すべて cache hit した resume は `agents=0 tokens=0` と表示されます。\n\n## 次へ\n\n[s17 Goal Loop](/ja/s17) は、より小さな独立 loop で goal が達成されたかを確認し、次の round が必要かを判断します。\n\n<!-- translation-sync: zh@v10, en@v10, ja@v10 -->\n"
|
||
},
|
||
{
|
||
"version": "s17",
|
||
"locale": "en",
|
||
"title": "s17: Goal Loop: The Model Proposes a Stop; an Independent Evaluator Decides Whether to Continue",
|
||
"content": "# s17: Goal Loop: The Model Proposes a Stop; an Independent Evaluator Decides Whether to Continue\n\ns01 → ... → s15 → [s16](/en/s16) → `s17`\n\n> *\"The model making no more tool calls means that one turn wants to stop. A separate evaluator decides whether the whole goal is complete.\"*\n>\n> **Harness layer: continued execution.** Check a completion condition at the end of every turn, and start another turn when work remains.\n\n---\n\n\n\nSince s01, the agent loop has had one simple exit condition: when the model stops calling tools, the program returns.\n\nThat is enough for ordinary conversations, but not always for tasks such as \"keep fixing until every test passes\" or \"finish every acceptance criterion.\" The model may believe the work is done after only part of it. No new `tool_use` means only that the current turn ended; it does not prove that the whole goal was achieved.\n\n`/goal` adds one independent decision before the real return.\n\n## /goal is a session-scoped Stop hook\n\nEnter:\n\n```text\n/goal pytest tests/auth exits with code 0 and lint reports no errors\n```\n\nThe program stores the completion condition and immediately gives it to the main model as the current task. You do not need to send a second \"start working\" prompt.\n\nWhen the main model stops calling tools, the loop runs the Goal Stop hook before returning:\n\n```python\nif tool_results:\n messages.append({\"role\": \"user\", \"content\": tool_results})\n continue\n\ndecision = await self.goal.evaluate_after_turn(self.messages)\nif decision.action == \"block\":\n self.messages.append({\n \"role\": \"user\",\n \"content\": decision.reason,\n })\n continue\n\nreturn SessionResult(text=text, status=decision.action)\n```\n\nWith no active goal, the hook allows the stop immediately, so the return condition is the same as in s01.\n\n## The evaluator is separate from the worker\n\nThe main model edits code, runs commands, and solves the task. The Goal evaluator is a separate model call with one job: judge the completion condition.\n\n`GoalController` owns the evaluator as an internal dependency of the Goal gate. It is not a second return path beside the main loop.\n\nThis lesson has no separate `CommandQueue`: when evaluation blocks the stop, the controller appends the reason to the same `messages[]` and starts the next turn. A larger host may use a shared queue to carry user input, background results, and continuation commands back into the session, but that queue is transport for the whole host, not a component owned by the Goal gate. Putting it inside the gate would blur the decision with the path used to deliver that decision.\n\nThe evaluator sees:\n\n- the active Goal condition;\n- the conversation so far;\n- tool results that the worker placed in that conversation.\n\nIt has no tools. It cannot read a file or rerun a test on its own. It can only judge what is already present in the conversation:\n\n```json\n{\n \"ok\": false,\n \"reason\": \"The conversation does not contain pytest's exit code yet.\",\n \"impossible\": false\n}\n```\n\n`ok=true` means the condition is satisfied. `ok=false` means another turn is needed. If the task can no longer be completed, the evaluator can return `impossible=true`.\n\n## The conversation is the evaluator's input\n\nThe evaluator reads the current conversation. Tool results, worker explanations, and background-task notifications all enter it as messages, and the decision depends on what those messages actually say.\n\nThe evaluator input keeps the most recent complete messages. If the newest message alone is too large, it keeps that message's beginning and end so one tool result cannot fill the whole evaluator request.\n\nThat does not mean a bare \"tests passed\" claim must be accepted. The evaluator prompt explicitly requires concrete results from the conversation and tells the model not to assume an unreported command succeeded.\n\nIt is still a model reading text, so reliability depends on whether important results were surfaced clearly. The worker's system prompt therefore says:\n\n> After running a verification command, report the command and its result clearly enough for an independent evaluator to inspect.\n\nGoal Loop is not a test framework. Tools still perform the real verification. The Goal evaluator only decides whether those verification results are present in the current work record.\n\n## A good completion condition is checkable\n\n\"Make the code good\" is too vague. The evaluator cannot know what \"good\" means.\n\nA useful condition states three things:\n\n1. **End state:** what must be true when work is done;\n2. **Check:** which command or output proves it;\n3. **Constraints:** what must not be broken along the way.\n\nFor example:\n\n```text\n/goal finish the authentication migration until pytest tests/auth exits 0,\nwithout modifying test files outside tests/auth\n```\n\nIf you need to bound unattended work, use the main loop's global turn limit instead of hiding a fixed budget inside Goal:\n\n```bash\nMAX_TURNS=20 python s17_goal_loop/code.py \\\n \"/goal fix the type errors until npm run typecheck exits 0\"\n```\n\n## Unfinished work returns to the same loop\n\nWhen the evaluator says the condition is not met, it returns a short reason:\n\n```text\nThe conversation has no complete test result. Run pytest tests/auth and report its exit code.\n```\n\nThe program appends that reason to `messages[]` and executes `continue` in the current `while` loop. The main model starts another turn without waiting for the user to type \"continue.\"\n\nThere is no separate continuation queue. Goal evaluation happens at the loop's return boundary, and unfinished work returns through that same boundary.\n\n## Wait before judging unfinished background work\n\nA Workflow, background command, or other asynchronous task may still be running when the main model ends its current turn.\n\nEvaluating immediately would be premature because the important result has not returned to the conversation. The Goal Stop hook returns `defer`, keeps the Goal active, and skips the evaluator. When the task finishes, the host passes its completion message to `submit_background_result()`; that message enters the same `messages[]`, and the loop resumes.\n\nA Workflow notification has no mechanical privilege. It enters the conversation like other messages, and the evaluator judges the actual result it contains.\n\n## Automatic continuation still needs an exit\n\nGoal has no hidden default budget of twenty turns. The evaluator judges the condition again after each completed turn.\n\nNo automatic mechanism should monopolize one request forever, however. This lesson keeps two general exits outside the goal itself:\n\n- the main loop's global `max_turns`;\n- a cap on consecutive Stop-hook blocks.\n\nWhen a limit is reached, the program returns control to the user. It does not mark the goal complete and does not silently clear it. The user can inspect status, provide more information, continue, or clear the goal.\n\nAn evaluator error follows the same rule: stop automatic continuation, leave the goal active, and surface the error instead of claiming success when completion could not be judged.\n\n## Inspect, replace, and clear\n\nOne session has at most one active Goal.\n\n```text\n/goal\n```\n\nShows the condition, elapsed time, evaluation count, main Agent token spend, and the latest evaluator reason.\n\n```text\n/goal a new completion condition\n```\n\nReplaces the previous Goal and begins work under the new condition immediately.\n\n```text\n/goal clear\n```\n\nClears the active Goal. `stop`, `off`, `reset`, `none`, and `cancel` are accepted aliases.\n\n`GoalController.restore()` can restore a still-active Goal from `goal_status` events persisted by the host; this lesson's CLI does not persist a whole session. A completed, failed, or cleared Goal does not restart. The condition carries over, while turn count, elapsed time, and token baseline start fresh.\n\n## What the code adds\n\nThis is an independent mechanism example built on the S04 kernel. It keeps the five base tools and the four hook points, then adds four Goal-specific pieces:\n\n| Piece | Responsibility |\n|---|---|\n| `GoalState` | Store the condition, evaluation count, start time, and latest reason |\n| `PromptGoalEvaluator` | Use a separate model call to judge the conversation |\n| `GoalController` | Set, inspect, clear, and run the Goal Stop hook |\n| `AgentSession` | Connect the Stop hook to the original return boundary |\n\nThe integration point is only a few lines:\n\n```python\ndecision = await self.goal.evaluate_after_turn(self.messages)\nif decision.action == \"block\":\n continue\nreturn SessionResult(text=text, status=decision.action)\n```\n\n## Try it\n\nInstall dependencies and prepare `.env`:\n\n```bash\npip install -r requirements.txt\n\n# .env\nANTHROPIC_API_KEY=...\nMODEL_ID=...\n\n# Optional: use a smaller model for Goal evaluation\nGOAL_EVALUATOR_MODEL_ID=...\n```\n\nStart the interactive session:\n\n```bash\npython s17_goal_loop/code.py\n```\n\nThen enter:\n\n```text\n/goal python -m pytest exits with code 0\n```\n\nYou can also set a Goal directly from the command line:\n\n```bash\npython s17_goal_loop/code.py \"/goal python -m pytest exits with code 0\"\n```\n\n## Relationship to s16\n\ns16 answers how a batch of work should run: which steps are concurrent, how results are verified, and how an interrupted run resumes.\n\ns17 answers whether the entire task is complete. A Workflow may finish successfully while the user's final requirements are still unmet. Once the Workflow result enters the conversation, the Goal evaluator decides whether the session should stop or continue.\n\nYou can use either mechanism on its own. When one host connects them, the Workflow completion message enters the conversation and Goal Loop decides whether the overall task needs another turn.\n\n<!-- translation-sync: zh@v6, en@v6, ja@v6 -->\n"
|
||
},
|
||
{
|
||
"version": "s17",
|
||
"locale": "zh",
|
||
"title": "s17: Goal Loop:模型提出停止,独立判断器决定是否继续",
|
||
"content": "# s17: Goal Loop:模型提出停止,独立判断器决定是否继续\n\ns01 → ... → s15 → [s16](/zh/s16) → `s17`\n\n> *“模型不再调用工具,只代表这一轮想停;目标是否完成,再交给一个独立判断器。”*\n>\n> **Harness 层:持续执行。** 在每轮结束处检查完成条件,没有完成就继续下一轮。\n\n---\n\n\n\n从 s01 开始,Agent Loop 的退出条件一直很简单:模型不再调用工具,程序就返回。\n\n这对普通对话足够,但对“修到测试全部通过”“完成所有验收项”这样的任务还不够。模型可能认为已经做完,也可能只完成了一部分。没有新的 `tool_use`,只能说明当前轮次结束了,不能直接证明整个目标已经达成。\n\n`/goal` 在真正返回之前,再加一次独立判断。\n\n## /goal 是一个会话级 Stop hook\n\n输入:\n\n```text\n/goal pytest tests/auth 退出码为 0,并且 lint 没有错误\n```\n\n程序保存完成条件,并立即把这段条件作为本轮任务交给主模型。用户不需要再输入一条“开始执行”。\n\n当主模型不再调用工具时,主循环不会立刻 `return`,而是先运行 Goal Stop hook:\n\n```python\nif tool_results:\n messages.append({\"role\": \"user\", \"content\": tool_results})\n continue\n\ndecision = await self.goal.evaluate_after_turn(self.messages)\nif decision.action == \"block\":\n self.messages.append({\n \"role\": \"user\",\n \"content\": decision.reason,\n })\n continue\n\nreturn SessionResult(text=text, status=decision.action)\n```\n\n没有活跃目标时,这个 hook 直接放行,退出条件仍然和 s01 一样。\n\n## 判断器和干活的模型分开\n\n主模型负责修改代码、运行命令和解决问题。Goal 判断器是另一次独立的模型调用,只负责判断完成条件。\n\n判断器由 `GoalController` 持有,是 Goal Gate 的内部依赖,不是主循环之外的另一条退出路径。\n\n本课没有单独的 `CommandQueue`:判断未通过时,controller 把理由直接追加到同一份 `messages[]`,然后进入下一轮。更大的宿主可以用共享队列把用户输入、后台结果和继续命令送回会话,但那条队列服务的是整个宿主,只负责传递,不归 Goal Gate 所有。把它画进 Gate,会把\"谁做决定\"和\"决定从哪条路送回来\"混成一件事。\n\n判断器会看到:\n\n- 当前 Goal 的完成条件;\n- 到目前为止的对话记录;\n- 主模型运行工具后写回来的结果。\n\n判断器没有工具,不能自己读取文件,也不能重新运行测试。它只能根据对话中已经出现的内容做判断:\n\n```json\n{\n \"ok\": false,\n \"reason\": \"对话中还没有出现 pytest 的退出码\",\n \"impossible\": false\n}\n```\n\n`ok=true` 表示条件已经满足;`ok=false` 表示还要继续;如果目标已经无法完成,则返回 `impossible=true`。\n\n## 对话记录就是判断依据\n\n判断器读取当前对话。工具结果、主模型的说明和后台任务通知都会作为消息进入其中,最终判断取决于这些消息实际写了什么。\n\n送给判断器的内容会保留最近的完整消息。如果最新一条消息本身过长,就只保留它的开头和结尾,避免一条工具结果占满整次判断请求。\n\n这并不表示模型说一句“测试通过了”就一定会被接受。判断器的提示明确要求根据对话中的具体结果判断,不能把没有结果支撑的宣称当成完成。\n\n但它终究只是一个只读对话的模型,可靠性取决于对话里有没有把关键结果说清楚。因此主模型的 system prompt 会要求:\n\n> 运行验证命令后,把命令和结果明确写进对话,让独立判断器能够检查。\n\nGoal Loop 不是测试框架。真正的验证仍然由工具执行,它只负责判断验证结果是否已经出现在当前工作记录中。\n\n## 好的完成条件要能检查\n\n“把代码弄好”太模糊,判断器不知道什么算好。\n\n更合适的条件会写清三件事:\n\n1. **结束状态**:最终要达到什么结果;\n2. **验证方式**:用什么命令或输出证明;\n3. **限制条件**:完成过程中不能破坏什么。\n\n例如:\n\n```text\n/goal 完成登录模块迁移,直到 pytest tests/auth 退出码为 0,\n并且没有修改 tests/auth 之外的测试文件\n```\n\n如果想限制自动执行轮数,使用主循环的全局限制,而不是给 Goal 偷偷加一个固定预算:\n\n```bash\nMAX_TURNS=20 python s17_goal_loop/code.py \\\n \"/goal 修复类型错误,直到 npm run typecheck 退出码为 0\"\n```\n\n## 没完成,就回到同一个循环\n\n判断器认为条件尚未满足时,会给出简短原因:\n\n```text\n对话中还没有出现完整测试结果,请运行 pytest tests/auth 并报告退出码。\n```\n\n程序把原因加入 `messages[]`,然后在当前 `while` 循环里直接 `continue`。主模型立即开始下一轮,不需要用户再次输入“继续”。\n\n这里没有单独的 continuation queue。Goal 检查就在主循环的结束位置,未满足时也从这里回到主循环。\n\n## 后台任务没有结束时,先不要判断\n\nWorkflow、后台命令和其他异步任务可能在主模型结束当前轮时仍在运行。\n\n这时立即判断通常没有意义,因为关键结果还没有回到对话。Goal Stop hook 返回 `defer`,保留当前 Goal,也不调用判断器。后台任务结束后,宿主把完成通知交给 `submit_background_result()`;通知进入同一个 `messages[]`,主循环再继续。\n\nWorkflow 完成通知没有机械上的特殊权限。它和其他消息一样进入对话,判断器根据其中的实际结果判断条件是否满足。\n\n## 自动继续也必须有出口\n\nGoal 本身没有一个默认的“最多 20 轮”。是否满足完成条件,由判断器每轮重新判断。\n\n但任何自动机制都不能无限占住一次请求。本课在 Stop hook 外保留两道通用出口:\n\n- 主循环的全局 `max_turns`;\n- Stop hook 连续阻止结束的次数上限。\n\n达到上限时,程序把控制权还给用户,但不会把目标伪装成完成,也不会自动清除目标。用户可以查看状态、补充信息后继续,或者主动清除。\n\n判断器调用失败时也采用同样原则:停止自动续轮,保留目标,并把错误交给用户,而不是在无法判断时宣称成功。\n\n## 查看、替换和清除\n\n每个会话同时只有一个活跃 Goal。\n\n```text\n/goal\n```\n\n查看当前条件、已经判断的次数、经过时间、主 Agent 的 token 使用量和最近一次判断原因。\n\n```text\n/goal 新的完成条件\n```\n\n直接替换旧 Goal,并立即按新条件开始工作。\n\n```text\n/goal clear\n```\n\n清除当前 Goal。`stop`、`off`、`reset`、`none` 和 `cancel` 也可以作为清除别名。\n\n`GoalController.restore()` 可以从宿主保存的 `goal_status` 事件中恢复仍然活跃的 Goal;本课的命令行入口不负责持久化整个会话。已经完成、失败或主动清除的 Goal 不会重新启动。恢复后保留完成条件,但重新计算轮数、时间和 token 使用量。\n\n## 代码里新增了什么\n\n这是一个以 S04 Kernel 为基础的独立机制示例。代码保留五个基础工具和四类 hook,再加入四个 Goal 相关部件:\n\n| 部件 | 作用 |\n|---|---|\n| `GoalState` | 保存条件、判断次数、开始时间和最近原因 |\n| `PromptGoalEvaluator` | 用一次独立模型调用读取对话并返回判断 |\n| `GoalController` | 设置、查看、清除 Goal,并实现 Stop hook |\n| `AgentSession` | 在原来的退出位置接入 Goal 判断 |\n\n接入点只有几行:\n\n```python\ndecision = await self.goal.evaluate_after_turn(self.messages)\nif decision.action == \"block\":\n continue\nreturn SessionResult(text=text, status=decision.action)\n```\n\n## 跑起来看看\n\n先安装依赖并准备 `.env`:\n\n```bash\npip install -r requirements.txt\n\n# .env\nANTHROPIC_API_KEY=...\nMODEL_ID=...\n\n# 可选:给 Goal 判断器使用更小的模型\nGOAL_EVALUATOR_MODEL_ID=...\n```\n\n进入交互模式:\n\n```bash\npython s17_goal_loop/code.py\n```\n\n然后输入:\n\n```text\n/goal python -m pytest 退出码为 0\n```\n\n也可以直接从命令行设置 Goal:\n\n```bash\npython s17_goal_loop/code.py \"/goal python -m pytest 退出码为 0\"\n```\n\n## 与 s16 的关系\n\ns16 解决“一批工作怎样执行”:哪些步骤并行,结果怎样验证,失败后怎样恢复。\n\ns17 解决“整件事情是否已经完成”:即使 Workflow 已经结束,结果也可能还没有满足用户的最终要求。Workflow 的结果回到对话后,Goal 判断器再决定是结束还是继续工作。\n\n两个机制可以单独使用。接到同一个宿主时,Workflow 的完成通知进入会话,Goal Loop 再决定整个任务是否还要继续。\n\n<!-- translation-sync: zh@v6, en@v6, ja@v6 -->\n"
|
||
},
|
||
{
|
||
"version": "s17",
|
||
"locale": "ja",
|
||
"title": "s17: Goal Loop:モデルが停止を提案し、独立した evaluator が継続するかを決める",
|
||
"content": "# s17: Goal Loop:モデルが停止を提案し、独立した evaluator が継続するかを決める\n\ns01 → ... → s15 → [s16](/ja/s16) → `s17`\n\n> *「モデルが tool call をやめたのは、一つの turn を止めたいという意味にすぎない。goal 全体が完了したかは別の evaluator が判断する。」*\n>\n> **Harness layer:継続実行。** 各 turn の終わりで完了条件を確認し、未完了なら次の turn を始めます。\n\n---\n\n\n\ns01 から、agent loop の終了条件は単純でした。モデルが tool を呼ばなくなったら、program は return します。\n\n通常の会話には十分ですが、「すべての test が通るまで直す」「acceptance criteria をすべて満たす」といった task では足りないことがあります。モデルは一部を終えただけで、作業全体が完了したと考えるかもしれません。新しい `tool_use` がないことは、現在の turn が終わったことを示すだけで、goal 全体の達成までは証明しません。\n\n`/goal` は本当に return する前に、独立した判断を一つ追加します。\n\n## /goal は session-scoped Stop hook\n\n次のように入力します。\n\n```text\n/goal pytest tests/auth が exit code 0 で終了し、lint error もない\n```\n\nprogram は完了条件を保存し、その条件を現在の task としてすぐ main model に渡します。「作業を開始して」と別の prompt を送る必要はありません。\n\nmain model が tool call をやめると、loop は return の前に Goal Stop hook を実行します。\n\n```python\nif tool_results:\n messages.append({\"role\": \"user\", \"content\": tool_results})\n continue\n\ndecision = await self.goal.evaluate_after_turn(self.messages)\nif decision.action == \"block\":\n self.messages.append({\n \"role\": \"user\",\n \"content\": decision.reason,\n })\n continue\n\nreturn SessionResult(text=text, status=decision.action)\n```\n\nactive Goal がなければ hook はそのまま stop を許可し、return 条件は s01 と同じです。\n\n## evaluator と作業モデルを分ける\n\nmain model はコードを変更し、command を実行し、問題を解決します。Goal evaluator は別の model call であり、完了条件の判断だけを担当します。\n\nevaluator は `GoalController` が持つ Goal Gate 内部の依存です。main loop の外にある別の終了経路ではありません。\n\nこの章には独立した `CommandQueue` がありません。評価が停止を block すると、controller は理由を同じ `messages[]` へ直接追加し、次の turn を始めます。より大きな host では user input、background result、continuation command を session へ戻す共有 queue を使えますが、それは host 全体の transport であり、Goal Gate が所有する部品ではありません。Gate の中へ描くと、「誰が判断するか」と「判断をどの経路で戻すか」が混ざります。\n\nevaluator が見るものは次の三つです。\n\n- active Goal の条件;\n- 現在までの conversation;\n- worker が conversation に書き戻した tool result。\n\nevaluator は tool を持ちません。file を読んだり、test を再実行したりはできません。conversation にすでに現れた内容だけで判断します。\n\n```json\n{\n \"ok\": false,\n \"reason\": \"conversation に pytest の exit code がまだありません\",\n \"impossible\": false\n}\n```\n\n`ok=true` は条件を満たしたことを表します。`ok=false` なら次の turn が必要です。task を完了できない状況なら `impossible=true` を返せます。\n\n## conversation が判断材料になる\n\nevaluator は現在の conversation を読みます。tool result、worker の説明、background task notification はすべて message として入り、判断はそれらに実際に何が書かれているかで決まります。\n\nevaluator への入力は直近の完全な message を残します。最新の 1 message だけで長すぎる場合は、その先頭と末尾を残し、1 件の tool result が判断 request 全体を埋めないようにします。\n\nだからといって、根拠のない「tests passed」を必ず受け入れるわけではありません。evaluator prompt は conversation にある具体的な結果に基づくよう求め、報告されていない command の成功を仮定しないよう指示します。\n\nそれでも text を読むモデルであるため、重要な結果が conversation に明確に現れているかが reliability を左右します。worker の system prompt には次の方針を入れます。\n\n> verification command を実行したら、独立した evaluator が確認できるよう、command と result を明確に報告する。\n\nGoal Loop は test framework ではありません。実際の verification は tool が行います。Goal evaluator は、その結果が現在の作業記録に現れているかを判断するだけです。\n\n## 良い完了条件は確認できる\n\n「コードを良くする」だけでは曖昧で、evaluator は何をもって良いとするか判断できません。\n\n有用な条件には三つの情報があります。\n\n1. **End state:** 完了時に何が成立しているべきか;\n2. **Check:** どの command や output がそれを証明するか;\n3. **Constraints:** 作業中に壊してはいけないものは何か。\n\n例えば:\n\n```text\n/goal authentication migration を完了し、pytest tests/auth が exit code 0 になり、\ntests/auth 以外の test file は変更しない\n```\n\n自動実行の turn 数を制限したい場合は、Goal の内部に固定 budget を隠さず、main loop の global turn limit を使います。\n\n```bash\nMAX_TURNS=20 python s17_goal_loop/code.py \\\n \"/goal npm run typecheck が exit code 0 になるまで type error を修正する\"\n```\n\n## 未完了なら同じ loop に戻る\n\n条件が未達の場合、evaluator は短い理由を返します。\n\n```text\n完全な test result がありません。pytest tests/auth を実行し、exit code を報告してください。\n```\n\nprogram はその理由を `messages[]` に追加し、現在の `while` loop で `continue` します。user が「続けて」と入力しなくても、main model は次の turn を始めます。\n\n別の continuation queue はありません。Goal evaluation は loop の return 境界で行われ、未完了の作業も同じ場所から loop に戻ります。\n\n## background work が終わる前には判断しない\n\nWorkflow、background command、その他の async task は、main model の turn が終わっても実行中かもしれません。\n\n重要な結果が conversation に戻っていない状態で判断するのは早すぎます。Goal Stop hook は `defer` を返し、Goal を active のまま残して evaluator call を省きます。task が完了すると、host は completion message を `submit_background_result()` に渡します。その message が同じ `messages[]` に入り、loop が再開します。\n\nWorkflow notification に機械的な特権はありません。他の message と同じように conversation に入り、evaluator が中身の実際の結果を確認します。\n\n## 自動継続にも出口が必要\n\nGoal には隠れた「default 20 turn budget」はありません。完了条件は各 turn のあとに evaluator が改めて判断します。\n\nただし、一つの request を永久に占有する仕組みにはできません。この章では Goal の外側に二つの共通出口を残します。\n\n- main loop の global `max_turns`;\n- Stop hook が連続で stop を拒否できる回数の上限。\n\n上限に達したら user に control を返します。goal を完了扱いにはせず、勝手に clear もしません。user は status を確認し、情報を追加して続けるか、goal を clear できます。\n\nevaluator call が失敗した場合も同じです。自動継続を止め、goal を active のまま残し、判断できないのに成功と報告せず error を返します。\n\n## 確認、置換、clear\n\n一つの session に active Goal は一つだけです。\n\n```text\n/goal\n```\n\n現在の条件、経過時間、evaluation 回数、main Agent の token 使用量、直近の evaluator reason を表示します。\n\n```text\n/goal 新しい完了条件\n```\n\n以前の Goal を置き換え、新しい条件ですぐ作業を始めます。\n\n```text\n/goal clear\n```\n\nactive Goal を clear します。`stop`、`off`、`reset`、`none`、`cancel` も alias として利用できます。\n\n`GoalController.restore()` は、host が保存した `goal_status` event から active Goal を復元できます。この章の CLI は session 全体を永続化しません。完了、失敗、clear 済みの Goal は再起動しません。条件は引き継ぎますが、turn count、経過時間、token baseline は新しく計算します。\n\n## コードに追加したもの\n\nこれは S04 Kernel を土台にした独立 mechanism の例です。5 つの base tools と 4 種類の hooks を保ち、Goal 用の 4 部品を追加します。\n\n| 部品 | 役割 |\n|---|---|\n| `GoalState` | 条件、evaluation 回数、開始時刻、直近の理由を保存する |\n| `PromptGoalEvaluator` | 独立した model call で conversation を判断する |\n| `GoalController` | Goal の設定、確認、clear と Stop hook を担当する |\n| `AgentSession` | 元の return 境界へ Goal 判断を接続する |\n\n接続箇所は数行です。\n\n```python\ndecision = await self.goal.evaluate_after_turn(self.messages)\nif decision.action == \"block\":\n continue\nreturn SessionResult(text=text, status=decision.action)\n```\n\n## 実行してみる\n\ndependency を install し、`.env` を準備します。\n\n```bash\npip install -r requirements.txt\n\n# .env\nANTHROPIC_API_KEY=...\nMODEL_ID=...\n\n# optional: Goal evaluator に小さな model を使う\nGOAL_EVALUATOR_MODEL_ID=...\n```\n\ninteractive session を開始します。\n\n```bash\npython s17_goal_loop/code.py\n```\n\n次に入力します。\n\n```text\n/goal python -m pytest が exit code 0 で終了する\n```\n\ncommand line から直接 Goal を設定することもできます。\n\n```bash\npython s17_goal_loop/code.py \"/goal python -m pytest が exit code 0 で終了する\"\n```\n\n## s16 との関係\n\ns16 は「複数の仕事をどう実行するか」を扱いました。どの step を並列化し、結果をどう検証し、中断後にどう resume するかを決めます。\n\ns17 は「task 全体が完了したか」を扱います。Workflow が正常に終了しても、user の最終要件をまだ満たしていないかもしれません。Workflow result が conversation に入ったあと、Goal evaluator が session を止めるか続けるかを決めます。\n\nどちらも単独で利用できます。同じ host に接続すると、Workflow の completion message が conversation に入り、Goal Loop が task 全体を続けるか判断します。\n\n<!-- translation-sync: zh@v6, en@v6, ja@v6 -->\n"
|
||
}
|
||
] |