feat: consolidate course into 21 lessons

This commit is contained in:
Haoran
2026-07-31 03:15:58 +08:00
parent 4bc33ec858
commit 2d69019342
200 changed files with 7338 additions and 10829 deletions
+37 -49
View File
@@ -106,7 +106,7 @@ Claude Code = 一つの agent loop
これがすべてだ。これが全アーキテクチャ。すべてのコンポーネントは Harness メカニズム -- Agent が住む世界の一部。Agent そのものは? Claude だ。モデル。Anthropic が人類の推論とコードの全幅で訓練した。Harness が Claude を賢くしたのではない。Claude は元々賢い。Harness が Claude に手と目とワークスペースを与えた。
これが Claude Code が理想的な教材である理由だ:**モデルを信頼し、工学的努力を Harness に集中させるとどうなるかを示している。** このリポジトリの各セッション(s01-s22)は Claude Code アーキテクチャの Harness メカニズムを段階的に分解し、最後に組み直す。終了時には、Claude Code の仕組みだけでなく、あらゆるドメインのあらゆる Agent に適用される Harness 工学の普遍的原則を理解している。
これが Claude Code を教材として扱う理由だ:**モデルを信頼し、工学的努力を Harness に集中させるとどうなるかを示している。** このリポジトリの各セッション(s01-s21)は Harness メカニズムを段階的に分解し、最後に組み直す。終了時には、一つの coding agent の仕組みだけでなく、さまざまな領域に適用できる Harness 工学の原則を理解できる。
教訓は「Claude Code をコピーせよ」ではない。教訓は:**最高の Agent プロダクトは、自分の仕事が Harness であって Intelligence ではないと理解しているエンジニアが作る。**
@@ -159,7 +159,7 @@ Claude Code = 一つの agent loop
Agent を特定ドメインで効果的にする Harness -- の作り方を教える。
```
**22 の段階的セッション、シンプルなループから目標を閉じる Harness まで。**
**21 の段階的セッション、シンプルなループから目標を閉じる Harness まで。**
**各セッションは 1 つの Harness メカニズムを追加する。各メカニズムには 1 つのモットーがある。**
> **s01**   *"One loop & Bash is all you need"* — 1つのツール + 1つのループ = エージェント
@@ -190,21 +190,19 @@ Claude Code = 一つの agent loop
>
> **s14**   *"スケジュールで発火、人間の起動は不要"* — 時間になったら自動でタスクを動かす
>
> **s15**   *"一人で終わらないなら、チームメイトに任せる"* — Agent Teams ランタイム実験:永続チームメイト + 非同期メールボックス
> **s15**   *"一人で扱いきれないなら、チームメイトで分担する"* — 永続チームメイト、自動メッセージ配信、型付き協調プロトコル
>
> **s16**   *"チームメイト間には統一の通信ルールが必要"* — Agent Teams プロトコル実験:型付きリクエスト-返信
> **s16**   *"待機中のチームメイトがボードを見て仕事を取る"* — 実行可能なタスクを発見し、原子的に認領する
>
> **s17**   *"チームメイトが自らボードを見て、仕事を取る"* — リーダーが逐一割り振る必要はない
> **s17**   *"各自のディレクトリで作業し、互いに干渉しない"* — タスクは目標を管理、worktree はディレクトリを管理、IDで紐付け
>
> **s18**   *"各自のディレクトリで作業し、互いに干渉しない"* — タスクは目標を管理、worktree はディレクトリを管理、IDで紐付け
> **s18**   *"能力不足? MCP でプラグイン"* — 外部ツールを同じツールプールに接続する
>
> **s19**   *"能力不足? MCP でプラグイン"* — 外部ツールを同じツールプールに接続する
> **s19**   *"仕組みは多く、ループは一つ"* — すべての仕組みを 1 つの Harness に戻す
>
> **s20**   *"仕組みは多く、ループは一つ"* — すべての仕組みを 1 つの Harness に戻す
> **s20**   *"編成の形が固定なら、コードにする"* — 再開可能なジャーナルを持つ決定的 Workflow
>
> **s21**   *"編成の形が固定なら、コードにする"* — 再開可能なジャーナルを持つ決定的 Workflow
>
> **s22**   *"本当に終われる時を目標が決める"* — 信頼できる証拠が目標を満たすまで継続する
> **s21**   *"本当に終われる時を目標が決める"* — 信頼できる証拠が目標を満たすまで継続する
---
@@ -241,16 +239,16 @@ def agent_loop(messages):
このリポジトリには現在、2 つのチュートリアルトラックが共存している:
- **現行トラック:ルート直下の `s01-s22`**
ルート直下の `s01_*` から `s22_*` までが新しい正規版であり、現在推奨する読書経路。各セッションには既定の英語 README、中国語/日本語訳、実行可能な `code.py`、必要に応じた図が含まれる。
- **現行トラック:ルート直下の `s01-s21`**
ルート直下の `s01_*` から `s21_*` までが新しい正規版であり、現在推奨する読書経路。各セッションには既定の英語 README、中国語/日本語訳、実行可能な `code.py`、必要に応じた図が含まれる。
- **旧版移行トラック:`docs/``agents/`**
これらは旧 12 セッション版を保持している。既存読者と旧リンクのために移行期間中は一時的に残している。
新しく読む場合は、ルート直下の `s01_agent_loop/` から `s22_goal_loop/` までを読む。旧版と現行版のセッション番号は常に一致しないため、番号を混同しないこと。
新しく読む場合は、ルート直下の `s01_agent_loop/` から `s21_goal_loop/` までを読む。旧版と現行版のセッション番号は常に一致しないため、番号を混同しないこと。
### 旧版から現行版への対応
| 旧 12 セッション版 | 現行 22 セッション版 | トピック |
| 旧 12 セッション版 | 現行 21 セッション版 | トピック |
|---|---|---|
| 旧 s01 | 現行 s01 | Agent Loop |
| 旧 s02 | 現行 s02 | Tool Use |
@@ -261,27 +259,18 @@ def agent_loop(messages):
| 旧 s07 | 現行 s12 | Task System |
| 旧 s08 | 現行 s13 | Background Tasks |
| 旧 s09 | 現行 s15 | Agent Teams |
| 旧 s10 | 現行 s16 | Team Protocols |
| 旧 s11 | 現行 s17 | Autonomous Agents |
| 旧 s12 | 現行 s18 | Worktree Isolation |
| 現行版のみ | s03、s04、s09、s10、s11、s14、s19、s20、s21、s22 | Permission、Hooks、Memory、Context Assembly、Error Recovery、Cron、MCP、Comprehensive Agent、Workflow Runtime、Goal Loop |
| 旧 s10 | 現行 s15 | Team Protocols |
| 旧 s11 | 現行 s16 | Autonomous Agents |
| 旧 s12 | 現行 s17 | Worktree Isolation |
| 現行版のみ | s03、s04、s09、s10、s11、s14、s18、s19、s20、s21 | Permission、Hooks、Memory、Context Assembly、Error Recovery、Cron、MCP、Comprehensive Agent、Workflow Runtime、Goal Loop |
## コープ (重要)
## コースの範囲
のリポジトリは Harness 工学 0->1 学習プロジェクト -- Agent モデルを囲む環境の構築を学ぶ
学習を優先するため、以下の本番メカニズムは意図的に簡略化または省略している:
- 完全なイベント / Hook バス (例: PreToolUse, SessionStart/End, ConfigChange)。
s12 では教材用に最小の追記型ライフサイクルイベントのみ実装。
- ルールベースの権限ガバナンスと信頼フロー
- セッションライフサイクル制御 (resume/fork) と高度な worktree ライフサイクル制御
- MCP ランタイムの詳細 (transport/OAuth/リソース購読/ポーリング)
このリポジトリの JSONL メールボックス方式は教材用の実装であり、特定の本番内部実装を主張するものではない。
は Harness 工学 0 から組み立てるコースである。各セッションで一つの仕組みを分けて扱い、s19 で一つの Agent loop に戻す。チームランタイムは JSONL メールボックスを使い、その後のセッションで Workflow 編成と目標による継続ループを追加する
## クイックスタート
### 現行 22 セッション版
### 現行 21 セッション版
```sh
git clone https://github.com/shareAI-lab/learn-claude-code
@@ -291,7 +280,7 @@ cp .env.example .env # .env を編集して ANTHROPIC_API_KEY を入力
python s01_agent_loop/code.py # ここから開始 — 1ループ + bash
python s08_context_compact/code.py # コンテキスト圧縮(複雑章)
python s22_goal_loop/code.py # 終点: 目標でループを閉じる
python s21_goal_loop/code.py # 終点: 目標でループを閉じる
```
### 旧 12 セッション移行版
@@ -304,7 +293,7 @@ python agents/s_full.py
### Web プラットフォーム
Web プラットフォームはルート直下のコースから内容を生成する。s21 と s22 は読解、ソース、シミュレーター、アーキテクチャの各 view を提供し、専用 hero visualization だけを最小限に保つ。
Web プラットフォームはルート直下のコースから内容を生成する。s20 と s21 は読解、ソース、シミュレーター、アーキテクチャの各 view を提供し、専用 hero visualization だけを最小限に保つ。
```sh
cd web && npm install && npm run dev # http://localhost:3000
@@ -344,9 +333,9 @@ flowchart TD
direction LR
S4["<b>第4段階:長く動くタスク</b><br/>━━━━━━━━━━━━━<br/><b>s12 Task System</b><br/>└─ タスクと依存関係を保存<br/><br/><b>s13 Background Tasks</b><br/>└─ 遅い作業をバックグラウンドへ<br/><br/><b>s14 Cron Scheduler</b><br/>└─ 時間で自動実行"]:::stage4
S5["<b>第5段階:複数 Agent の協作</b><br/>━━━━━━━━━━━━━<br/><b>Agent Teams モジュール</b><br/>s15 ランタイム実験:チームメイト + メールボックス<br/>└─ s16 プロトコル実験:型付きリクエスト-返信<br/><br/><b>s17 Autonomous Agents</b><br/>└─ ボードを見て仕事を取る<br/><br/><b>s18 Worktree Isolation</b><br/>└─ 別ディレクトリで作業"]:::stage5
S5["<b>第5段階:複数 Agent の協作</b><br/>━━━━━━━━━━━━━<br/><b>s15 Agent Teams</b><br/>チームメイト + 配信 + プロトコル<br/><br/><b>s16 Autonomous Agents</b><br/>└─ 実行可能な仕事をボードから認領<br/><br/><b>s17 Worktree Isolation</b><br/>└─ 別ディレクトリで作業"]:::stage5
S6["<b>第6段階:外部能力と統合</b><br/>━━━━━━━━━━━━━<br/><b>s07 Skill Loading</b><br/>└─ スキルを必要時に展開<br/><br/><b>s19 MCP Plugin</b><br/>└─ 外部ツールを同じプールへ<br/><br/><b>s20 Comprehensive Agent</b><br/>└─ すべてを1つのループへ"]:::stage6
S6["<b>第6段階:外部能力と統合</b><br/>━━━━━━━━━━━━━<br/><b>s07 Skill Loading</b><br/>└─ スキルを必要時に展開<br/><br/><b>s18 MCP Plugin</b><br/>└─ 外部ツールを同じプールへ<br/><br/><b>s19 Comprehensive Agent</b><br/>└─ すべてを1つのループへ"]:::stage6
S4 ==> S5 ==> S6
end
@@ -354,7 +343,7 @@ flowchart TD
%% 第3層:編成と目標の完了
subgraph Phase3 ["第7段階:編成と目標の完了"]
direction LR
S7["<b>第7段階:編成して完了する</b><br/>━━━━━━━━━━━━━<br/><b>s21 Workflow Runtime</b><br/>└─ 固定編成はスクリプトが担う<br/><br/><b>s22 Goal Loop</b><br/>└─ 信頼できる証拠で停止を決める"]:::stage1
S7["<b>第7段階:編成して完了する</b><br/>━━━━━━━━━━━━━<br/><b>s20 Workflow Runtime</b><br/>└─ 固定編成はスクリプトが担う<br/><br/><b>s21 Goal Loop</b><br/>└─ 信頼できる証拠で停止を決める"]:::stage1
S6 ==> S7
end
@@ -382,14 +371,13 @@ flowchart TD
| [s12](./s12_task_system/) | Task System | `TaskRecord` / `blockedBy` / ディスク永続化 |
| [s13](./s13_background_tasks/) | Background Tasks | スレッド実行 / 通知キュー |
| [s14](./s14_cron_scheduler/) | Cron Scheduler | 永続スケジューリング / セッション限定トリガー |
| [s15](./s15_agent_teams/) | Agent Teams:ランタイム実験 | `MessageBus` / 受信箱 / 権限バブリング |
| [s16](./s16_team_protocols/) | Agent Teams:プロトコル実験 | 型付きメッセージ / シャットダウンハンドシェイク / プラン承認 |
| [s17](./s17_autonomous_agents/) | Autonomous Agents | アイドルサイクル / 自動クレーム |
| [s18](./s18_worktree_isolation/) | Worktree Isolation | `WorktreeRecord` / タスク-ディレクトリ紐付け |
| [s19](./s19_mcp_plugin/) | MCP Plugin | マルチトランスポート / チャネルルーティング / ツールプール組み立て |
| [s20](./s20_comprehensive/) | Comprehensive Agent | すべての仕組みを 1 つのループへ |
| [s21](./s21_workflow_runtime/) | Workflow Runtime | スクリプト編成 / バックグラウンド実行 / ジャーナル再開 |
| [s22](./s22_goal_loop/) | Goal Loop | 目標ゲート / 信頼できる証拠 / 自動継続 |
| [s15](./s15_agent_teams/) | Agent Teams | 永続チームメイト / 自動配信 / 型付きプロトコル / プランゲート |
| [s16](./s16_autonomous_agents/) | Autonomous Agents | タスクボード走査 / 原子的認領 / 自己組織化 |
| [s17](./s17_worktree_isolation/) | Worktree Isolation | `WorktreeRecord` / タスク-ディレクトリ紐付け |
| [s18](./s18_mcp_plugin/) | MCP Plugin | ツール発見 / 名前空間 / ツールプール組み立て |
| [s19](./s19_comprehensive/) | Comprehensive Agent | すべての仕組みを 1 つのループへ |
| [s20](./s20_workflow_runtime/) | Workflow Runtime | スクリプト編成 / バックグラウンド実行 / ジャーナル再開 |
| [s21](./s21_goal_loop/) | Goal Loop | 目標ゲート / 信頼できる証拠 / 自動継続 |
## プロジェクト構成
@@ -403,10 +391,10 @@ learn-claude-code/
images/ # SVG ダイアグラム
s02_tool_use/
...
s19_mcp_plugin/
s20_comprehensive/
s21_workflow_runtime/
s22_goal_loop/ # 終点セッション
s18_mcp_plugin/
s19_comprehensive/
s20_workflow_runtime/
s21_goal_loop/ # 終点セッション
agents/ # 旧 12 セッションの実行可能コピー + s_full.py
skills/ # s07 で使用するスキルファイル
docs/ # 旧 12 セッション文書、移行期間中は保持
@@ -416,7 +404,7 @@ learn-claude-code/
## 次のステップ -- 理解から出荷へ
22 セッションを終えれば、Harness 工学の内部構造を完全に理解している。その知識を活かす 2 つの方法:
21 セッションを終えれば、Harness 工学の内部構造を理解できる。その知識を活かす 2 つの方法:
### Kode Agent CLI -- オープンソース Coding Agent CLI
+37 -49
View File
@@ -106,7 +106,7 @@ Claude Code = 一个 agent loop
就这些。这就是全部架构。每一个组件都是 harness 机制 -- 为 agent 构建的栖居世界的一部分。Agent 本身呢?是 Claude。一个模型。由 Anthropic 在人类推理和代码的全部广度上训练而成。Harness 没有让 Claude 变聪明。Claude 本来就聪明。Harness 给了 Claude 双手、双眼和一个工作空间。
这就是 Claude Code 作为教学标本的意义:**它展示了当你信任模型、把工程精力集中在 harness 上时会发生什么。** 本仓库的课程(s01-s22)逐步拆解并重组 Claude Code 架构中的 harness 机制。学完之后,你理解的不只是 Claude Code 怎么工作,而是适用于任何领域、任何 agent 的 harness 工程通用原则。
这就是 Claude Code 作为教学标本的意义:**它展示了当你信任模型、把工程精力集中在 harness 上时会发生什么。** 本仓库的课程(s01-s21)逐步拆解并重组 harness 机制。学完之后,你理解的不只是一个 coding agent 怎么工作,而是适用于不同领域的 harness 工程原则。
启示不是 "复制 Claude Code"。启示是:**最好的 agent 产品,出自那些明白自己的工作是 harness 而非 intelligence 的工程师之手。**
@@ -159,7 +159,7 @@ Claude Code = 一个 agent loop
让 agent 在特定领域高效工作的 harness。
```
**22 个递进式课程, 从简单循环到目标闭环。**
**21 个递进式课程, 从简单循环到目标闭环。**
**每个课程添加一个 harness 机制。每个机制有一句格言。**
> **s01** &nbsp; *"One loop & Bash is all you need"* &mdash; 一个工具 + 一个循环 = 一个 Agent
@@ -190,21 +190,19 @@ Claude Code = 一个 agent loop
>
> **s14** &nbsp; *"定时触发, 不需要人推"* &mdash; 按时间自动触发任务
>
> **s15** &nbsp; *"一个搞不定, 组队来"* &mdash; Agent Teams 运行时实验:持久队友 + 异步邮箱
> **s15** &nbsp; *"一个 Agent 顾不过来,就让队友分工协作"* &mdash; 持久队友、自动消息投递与类型化协作协议
>
> **s16** &nbsp; *"队友之间要有约定"* &mdash; Agent Teams 协议实验:带类型的请求-回复
> **s16** &nbsp; *"队友空闲时自己看板,有活就认领"* &mdash; 发现就绪任务并原子认领,不需要逐个派发
>
> **s17** &nbsp; *"队友自己看板, 有活就认领"* &mdash; 不需要领导逐个分配, 自组织
> **s17** &nbsp; *"各干各的目录, 互不干扰"* &mdash; 任务管目标, worktree 管目录, 按 ID 绑定
>
> **s18** &nbsp; *"各干各的目录, 互不干扰"* &mdash; 任务管目标, worktree 管目录, 按 ID 绑定
> **s18** &nbsp; *"能力不够? 插上 MCP"* &mdash; 把外部工具接进同一个工具池
>
> **s19** &nbsp; *"能力不够? 插上 MCP"* &mdash; 把外部工具接进同一个工具池
> **s19** &nbsp; *"机制很多,循环一个"* &mdash; 前面所有机制回到一个完整 harness
>
> **s20** &nbsp; *"机制很多,循环一个"* &mdash; 前面所有机制回到一个完整 harness
> **s20** &nbsp; *"编排形状固定时,就把它写进代码"* &mdash; 可恢复 journal 支撑确定性 workflow
>
> **s21** &nbsp; *"编排形状固定时,就把它写进代码"* &mdash; 可恢复 journal 支撑确定性 workflow
>
> **s22** &nbsp; *"目标决定循环什么时候真正结束"* &mdash; 持续工作,直到可信证据满足目标
> **s21** &nbsp; *"目标决定循环什么时候真正结束"* &mdash; 持续工作,直到可信证据满足目标
---
@@ -241,16 +239,16 @@ def agent_loop(messages):
本仓库现在同时保留两条教程线:
- **新版主线:根目录 `s01-s22`**
根目录下的 `s01_*``s22_*` 是新的主版本,也是当前推荐阅读路径。每章包含默认英文 README、中文/日文译本、可运行的 `code.py`,以及必要的图示。
- **新版主线:根目录 `s01-s21`**
根目录下的 `s01_*``s21_*` 是新的主版本,也是当前推荐阅读路径。每章包含默认英文 README、中文/日文译本、可运行的 `code.py`,以及必要的图示。
- **旧版过渡:`docs/``agents/`**
这些仍保留旧 12 章体系,暂时用于已有读者和旧链接过渡。
新读者请从根目录 `s01_agent_loop/` 读到 `s22_goal_loop/`。旧版章节号和新版不完全一致,不要混用章节号。
新读者请从根目录 `s01_agent_loop/` 读到 `s21_goal_loop/`。旧版章节号和新版不完全一致,不要混用章节号。
### 旧版到新版的对应关系
| 旧 12 章版本 | 新 22 章版本 | 主题 |
| 旧 12 章版本 | 新 21 章版本 | 主题 |
|---|---|---|
| 旧 s01 | 新 s01 | Agent Loop |
| 旧 s02 | 新 s02 | Tool Use |
@@ -261,27 +259,18 @@ def agent_loop(messages):
| 旧 s07 | 新 s12 | Task System |
| 旧 s08 | 新 s13 | Background Tasks |
| 旧 s09 | 新 s15 | Agent Teams |
| 旧 s10 | 新 s16 | Team Protocols |
| 旧 s11 | 新 s17 | Autonomous Agents |
| 旧 s12 | 新 s18 | Worktree Isolation |
| 新版新增 | s03、s04、s09、s10、s11、s14、s19、s20、s21、s22 | Permission、Hooks、Memory、Context Assembly、Error Recovery、Cron、MCP、Comprehensive Agent、Workflow Runtime、Goal Loop |
| 旧 s10 | 新 s15 | Team Protocols |
| 旧 s11 | 新 s16 | Autonomous Agents |
| 旧 s12 | 新 s17 | Worktree Isolation |
| 新版新增 | s03、s04、s09、s10、s11、s14、s18、s19、s20、s21 | Permission、Hooks、Memory、Context Assembly、Error Recovery、Cron、MCP、Comprehensive Agent、Workflow Runtime、Goal Loop |
## 范围说明 (重要)
## 课程边界
本仓库是一个 0->1 的 harness 工程学习项目 -- 构建围绕 agent 模型的工作环境
为保证学习路径清晰,仓库有意简化或省略了部分生产机制:
- 完整事件 / Hook 总线 (例如 PreToolUse、SessionStart/End、ConfigChange)。
s12 仅提供教学用途的最小 append-only 生命周期事件流。
- 基于规则的权限治理与信任流程
- 会话生命周期控制 (resume/fork) 与更完整的 worktree 生命周期控制
- 完整 MCP 运行时细节 (transport/OAuth/资源订阅/轮询)
仓库中的团队 JSONL 邮箱协议是教学实现,不是对任何特定生产内部实现的声明。
是一个 01 的 harness 工程课程。每章先单独展开一个机制,s19 再把它们接回完整的 Agent 循环。团队运行时使用 JSONL 邮箱,后续章节继续加入 workflow 编排和由目标控制的持续循环
## 快速开始
### 新版 22 章主线
### 新版 21 章主线
```sh
git clone https://github.com/shareAI-lab/learn-claude-code
@@ -291,7 +280,7 @@ cp .env.example .env # 编辑 .env 填入你的 ANTHROPIC_API_KEY
python s01_agent_loop/code.py # 起点 — 一个循环 + bash
python s08_context_compact/code.py # 上下文压缩(复杂章)
python s22_goal_loop/code.py # 终点章:用目标闭合循环
python s21_goal_loop/code.py # 终点章:用目标闭合循环
```
### 旧版 12 章过渡线
@@ -304,7 +293,7 @@ python agents/s_full.py
### Web 平台
Web 平台从根目录课程生成内容。s21、s22 提供阅读、源码、模拟和架构视图;仅专用首屏可视化保持精简。
Web 平台从根目录课程生成内容。s20、s21 提供阅读、源码、模拟和架构视图;仅专用首屏可视化保持精简。
```sh
cd web && npm install && npm run dev # http://localhost:3000
@@ -344,9 +333,9 @@ flowchart TD
direction LR
S4["<b>第四阶段:让任务长期运行</b><br/>━━━━━━━━━━━━━<br/><b>s12 Task System</b><br/>└─ 任务落盘记依赖<br/><br/><b>s13 Background Tasks</b><br/>└─ 慢操作丢后台<br/><br/><b>s14 Cron Scheduler</b><br/>└─ 按时自动触发"]:::stage4
S5["<b>第五阶段:让多个 Agent 协作</b><br/>━━━━━━━━━━━━━<br/><b>Agent Teams 模块</b><br/>s15 运行时实验:队友 + 邮箱<br/>└─ s16 协议实验:带类型的请求-回复<br/><br/><b>s17 Autonomous Agents</b><br/>└─ 自己看板认领<br/><br/><b>s18 Worktree Isolation</b><br/>└─ 隔离目录"]:::stage5
S5["<b>第五阶段:让多个 Agent 协作</b><br/>━━━━━━━━━━━━━<br/><b>s15 Agent Teams</b><br/>队友 + 消息投递 + 协作协议<br/><br/><b>s16 Autonomous Agents</b><br/>└─ 自己看板认领就绪任务<br/><br/><b>s17 Worktree Isolation</b><br/>└─ 隔离目录"]:::stage5
S6["<b>第六阶段:接外部能力合体</b><br/>━━━━━━━━━━━━━<br/><b>s07 Skill Loading</b><br/>└─ 技能按需展开<br/><br/><b>s19 MCP Plugin</b><br/>└─ 外部接进工具池<br/><br/><b>s20 Comprehensive Agent</b><br/>└─ 全机制回单循环"]:::stage6
S6["<b>第六阶段:接外部能力合体</b><br/>━━━━━━━━━━━━━<br/><b>s07 Skill Loading</b><br/>└─ 技能按需展开<br/><br/><b>s18 MCP Plugin</b><br/>└─ 外部接进工具池<br/><br/><b>s19 Comprehensive Agent</b><br/>└─ 全机制回单循环"]:::stage6
S4 ==> S5 ==> S6
end
@@ -354,7 +343,7 @@ flowchart TD
%% 第三层:编排与目标闭环
subgraph Phase3 ["🎯 第七阶段:编排与目标闭环"]
direction LR
S7["<b>第七阶段:编排并完成</b><br/>━━━━━━━━━━━━━<br/><b>s21 Workflow Runtime</b><br/>└─ 脚本拥有固定编排<br/><br/><b>s22 Goal Loop</b><br/>└─ 可信证据决定何时停止"]:::stage1
S7["<b>第七阶段:编排并完成</b><br/>━━━━━━━━━━━━━<br/><b>s20 Workflow Runtime</b><br/>└─ 脚本拥有固定编排<br/><br/><b>s21 Goal Loop</b><br/>└─ 可信证据决定何时停止"]:::stage1
S6 ==> S7
end
@@ -383,14 +372,13 @@ flowchart TD
| [s12](./s12_task_system/) | Task System | `TaskRecord` / `blockedBy` / 磁盘持久化 |
| [s13](./s13_background_tasks/) | Background Tasks | 线程执行 / 通知队列 |
| [s14](./s14_cron_scheduler/) | Cron Scheduler | 持久化调度 / 会话级触发 |
| [s15](./s15_agent_teams/) | Agent Teams:运行时实验 | `MessageBus` / 收件箱 / 权限冒泡 |
| [s16](./s16_team_protocols/) | Agent Teams:协议实验 | 类型消息 / 关机握手 / 计划审批 |
| [s17](./s17_autonomous_agents/) | Autonomous Agents | 空闲循环 / 自动认领 |
| [s18](./s18_worktree_isolation/) | Worktree Isolation | `WorktreeRecord` / 任务-目录绑定 |
| [s19](./s19_mcp_plugin/) | MCP Plugin | 多传输 / 通道路由 / 工具池组装 |
| [s20](./s20_comprehensive/) | Comprehensive Agent | 全部机制归到一个循环 |
| [s21](./s21_workflow_runtime/) | Workflow Runtime | 脚本编排 / 后台运行 / journal 续跑 |
| [s22](./s22_goal_loop/) | Goal Loop | 目标闸门 / 可信证据 / 自动续轮 |
| [s15](./s15_agent_teams/) | Agent Teams | 持久队友 / 自动消息投递 / 类型协议 / 计划闸门 |
| [s16](./s16_autonomous_agents/) | Autonomous Agents | 扫描任务板 / 原子认领 / 自组织 |
| [s17](./s17_worktree_isolation/) | Worktree Isolation | `WorktreeRecord` / 任务-目录绑定 |
| [s18](./s18_mcp_plugin/) | MCP Plugin | 工具发现 / 命名空间 / 工具池组装 |
| [s19](./s19_comprehensive/) | Comprehensive Agent | 全部机制归到一个循环 |
| [s20](./s20_workflow_runtime/) | Workflow Runtime | 脚本编排 / 后台运行 / journal 续跑 |
| [s21](./s21_goal_loop/) | Goal Loop | 目标闸门 / 可信证据 / 自动续轮 |
## 项目结构
@@ -404,10 +392,10 @@ learn-claude-code/
images/ # SVG 流程图
s02_tool_use/
...
s19_mcp_plugin/
s20_comprehensive/
s21_workflow_runtime/
s22_goal_loop/ # 终点章
s18_mcp_plugin/
s19_comprehensive/
s20_workflow_runtime/
s21_goal_loop/ # 终点章
agents/ # 旧 12 章可运行副本 + s_full.py
skills/ # s07 使用的 skill 文件
docs/ # 旧 12 章文档,过渡期保留
@@ -417,7 +405,7 @@ learn-claude-code/
## 学完之后 -- 从理解到落地
22 个课程走完, 你已经从内到外理解了 harness 工程的运作原理。两种方式把知识变成产品:
21 个课程走完, 你已经从内到外理解了 harness 工程的运作原理。两种方式把知识变成产品:
### Kode Agent CLI -- 开源 Coding Agent CLI
+37 -48
View File
@@ -167,16 +167,16 @@ The loop is constant. Tools, knowledge, and permissions change. Agent = Model (L
This repository currently contains two tutorial tracks:
- **Current track: root-level `s01-s22`**
The root-level `s01_*` ... `s22_*` folders are the canonical version. Each chapter contains an English default README, Chinese/Japanese translations, runnable `code.py`, and diagrams where needed.
- **Current track: root-level `s01-s21`**
The root-level `s01_*` ... `s21_*` folders are the canonical version. Each chapter contains an English default README, Chinese/Japanese translations, runnable `code.py`, and diagrams where needed.
- **Legacy transition track: `docs/` and `agents/`**
These preserve the older 12-lesson version for existing readers and old links during migration.
If you are starting now, read the root-level `s01_agent_loop/` through `s22_goal_loop/` chapters. The legacy and current chapter numbers do not always match, so avoid mixing chapter numbers across tracks.
If you are starting now, read the root-level `s01_agent_loop/` through `s21_goal_loop/` chapters. The legacy and current chapter numbers do not always match, so avoid mixing chapter numbers across tracks.
### Legacy-to-Current Mapping
| Legacy 12-lesson track | Current 22-lesson track | Topic |
| Legacy 12-lesson track | Current 21-lesson track | Topic |
|---|---|---|
| old s01 | new s01 | Agent Loop |
| old s02 | new s02 | Tool Use |
@@ -187,28 +187,20 @@ If you are starting now, read the root-level `s01_agent_loop/` through `s22_goal
| old s07 | new s12 | Task System |
| old s08 | new s13 | Background Tasks |
| old s09 | new s15 | Agent Teams |
| old s10 | new s16 | Team Protocols |
| old s11 | new s17 | Autonomous Agents |
| old s12 | new s18 | Worktree Isolation |
| new only | s03, s04, s09, s10, s11, s14, s19, s20, s21, s22 | Permission, Hooks, Memory, Context Assembly, Error Recovery, Cron, MCP, Comprehensive Agent, Workflow Runtime, Goal Loop |
| old s10 | new s15 | Team Protocols |
| old s11 | new s16 | Autonomous Agents |
| old s12 | new s17 | Worktree Isolation |
| new only | s03, s04, s09, s10, s11, s14, s18, s19, s20, s21 | Permission, Hooks, Memory, Context Assembly, Error Recovery, Cron, MCP, Comprehensive Agent, Workflow Runtime, Goal Loop |
---
## Scope
## Course Boundary
This repository is a 0-to-1 harness engineering learning project: it teaches how to build the working environment around an agent model. To keep the learning path clear, some production mechanisms are intentionally simplified or omitted:
- Full event / hook bus behavior, such as `PreToolUse`, `SessionStart/End`, and `ConfigChange`.
The teaching code uses minimal lifecycle events where needed.
- Rule-based permission governance and full trust workflows.
- Session lifecycle controls such as resume/fork, plus more complete worktree lifecycle handling.
- Full MCP runtime details such as transport, OAuth, resource subscription, and polling.
The JSONL mailbox protocol in this repository is a teaching implementation, not a claim about any specific production internal implementation.
This is a 0-to-1 harness engineering course. Each chapter isolates one mechanism, then s19 reconnects them in a complete agent loop. The team runtime uses a JSONL mailbox, and later chapters add workflow orchestration and a goal-controlled continuation loop.
---
## 22 Progressive Lessons
## 21 Progressive Lessons
**Each lesson adds one harness mechanism. Each mechanism has a motto.**
@@ -240,21 +232,19 @@ The JSONL mailbox protocol in this repository is a teaching implementation, not
>
> **s14** &nbsp; *"Fire on schedule, no human kick needed"* &mdash; trigger tasks automatically by time
>
> **s15** &nbsp; *"Too big for one agent -- delegate to teammates"* &mdash; Agent Teams runtime lab: persistent teammates + async mailboxes
> **s15** &nbsp; *"Too big for one agent -- let teammates divide the work"* &mdash; persistent teammates, automatic message delivery, and typed coordination protocols
>
> **s16** &nbsp; *"Teammates need shared communication rules"* &mdash; Agent Teams protocol lab: typed request-reply coordination
> **s16** &nbsp; *"Idle teammates check the board and claim ready work"* &mdash; task discovery and atomic claiming without one-by-one dispatch
>
> **s17** &nbsp; *"Teammates check the board, claim work themselves"* &mdash; no leader assigning one by one; self-organizing
> **s17** &nbsp; *"Each works in its own directory, no interference"* &mdash; tasks own goals, worktrees own directories, bound by ID
>
> **s18** &nbsp; *"Each works in its own directory, no interference"* &mdash; tasks own goals, worktrees own directories, bound by ID
> **s18** &nbsp; *"Not enough capability? Plug in more via MCP"* &mdash; connect external tools into the same tool pool
>
> **s19** &nbsp; *"Not enough capability? Plug in more via MCP"* &mdash; connect external tools into the same tool pool
> **s19** &nbsp; *"Many mechanisms, one loop"* &mdash; all previous mechanisms return to one complete harness
>
> **s20** &nbsp; *"Many mechanisms, one loop"* &mdash; all previous mechanisms return to one complete harness
> **s20** &nbsp; *"When the orchestration shape is fixed, put it in code"* &mdash; deterministic workflows with resumable journals
>
> **s21** &nbsp; *"When the orchestration shape is fixed, put it in code"* &mdash; deterministic workflows with resumable journals
>
> **s22** &nbsp; *"A goal decides when the loop may stop"* &mdash; continue until trusted evidence satisfies the goal
> **s21** &nbsp; *"A goal decides when the loop may stop"* &mdash; continue until trusted evidence satisfies the goal
---
@@ -292,9 +282,9 @@ flowchart TD
direction LR
S4["<b>4. Run long tasks</b><br/>━━━━━━━━━━━━━<br/><b>s12 Task System</b><br/>└─ persist tasks and deps<br/><br/><b>s13 Background Tasks</b><br/>└─ send slow work background<br/><br/><b>s14 Cron Scheduler</b><br/>└─ trigger by time"]:::stage4
S5["<b>5. Coordinate many Agents</b><br/>━━━━━━━━━━━━━<br/><b>Agent Teams module</b><br/>s15 Runtime Lab: teammates + mailboxes<br/>└─ s16 Protocol Lab: typed request-reply<br/><br/><b>s17 Autonomous Agents</b><br/>└─ claim work from the board<br/><br/><b>s18 Worktree Isolation</b><br/>└─ separate directories"]:::stage5
S5["<b>5. Coordinate many Agents</b><br/>━━━━━━━━━━━━━<br/><b>s15 Agent Teams</b><br/>teammates + delivery + protocols<br/><br/><b>s16 Autonomous Agents</b><br/>└─ claim ready work from the board<br/><br/><b>s17 Worktree Isolation</b><br/>└─ separate directories"]:::stage5
S6["<b>6. Extend and assemble</b><br/>━━━━━━━━━━━━━<br/><b>s07 Skill Loading</b><br/>└─ expand skills on demand<br/><br/><b>s19 MCP Plugin</b><br/>└─ external tools, one pool<br/><br/><b>s20 Comprehensive Agent</b><br/>└─ all mechanisms, one loop"]:::stage6
S6["<b>6. Extend and assemble</b><br/>━━━━━━━━━━━━━<br/><b>s07 Skill Loading</b><br/>└─ expand skills on demand<br/><br/><b>s18 MCP Plugin</b><br/>└─ external tools, one pool<br/><br/><b>s19 Comprehensive Agent</b><br/>└─ all mechanisms, one loop"]:::stage6
S4 ==> S5 ==> S6
end
@@ -302,7 +292,7 @@ flowchart TD
%% Layer 3: orchestration and goal closure
subgraph Phase3 ["🎯 Stage 7: Orchestration and goal closure"]
direction LR
S7["<b>7. Orchestrate and finish</b><br/>━━━━━━━━━━━━━<br/><b>s21 Workflow Runtime</b><br/>└─ scripts own fixed orchestration<br/><br/><b>s22 Goal Loop</b><br/>└─ trusted evidence decides when to stop"]:::stage1
S7["<b>7. Orchestrate and finish</b><br/>━━━━━━━━━━━━━<br/><b>s20 Workflow Runtime</b><br/>└─ scripts own fixed orchestration<br/><br/><b>s21 Goal Loop</b><br/>└─ trusted evidence decides when to stop"]:::stage1
S6 ==> S7
end
@@ -332,14 +322,13 @@ flowchart TD
| [s12](./s12_task_system/) | Task System | `TaskRecord` / `blockedBy` / disk persistence |
| [s13](./s13_background_tasks/) | Background Tasks | threaded execution / notification queue |
| [s14](./s14_cron_scheduler/) | Cron Scheduler | durable scheduling / session-scoped triggers |
| [s15](./s15_agent_teams/) | Agent Teams: Runtime Lab | `MessageBus` / inbox / permission bubbling |
| [s16](./s16_team_protocols/) | Agent Teams: Protocol Lab | typed messages / shutdown handshake / plan approval |
| [s17](./s17_autonomous_agents/) | Autonomous Agents | idle cycle / auto-claim / self-organization |
| [s18](./s18_worktree_isolation/) | Worktree Isolation | `WorktreeRecord` / task-directory binding |
| [s19](./s19_mcp_plugin/) | MCP Plugin | multi-transport / channel routing / tool pool assembly |
| [s20](./s20_comprehensive/) | Comprehensive Agent | all mechanisms around one loop |
| [s21](./s21_workflow_runtime/) | Workflow Runtime | script orchestration / background execution / journal resume |
| [s22](./s22_goal_loop/) | Goal Loop | goal gate / trusted evidence / automatic continuation |
| [s15](./s15_agent_teams/) | Agent Teams | persistent teammates / automatic delivery / typed protocols / plan gate |
| [s16](./s16_autonomous_agents/) | Autonomous Agents | task-board scan / atomic claim / self-organization |
| [s17](./s17_worktree_isolation/) | Worktree Isolation | `WorktreeRecord` / task-directory binding |
| [s18](./s18_mcp_plugin/) | MCP Plugin | tool discovery / namespaced tools / tool pool assembly |
| [s19](./s19_comprehensive/) | Comprehensive Agent | all mechanisms around one loop |
| [s20](./s20_workflow_runtime/) | Workflow Runtime | script orchestration / background execution / journal resume |
| [s21](./s21_goal_loop/) | Goal Loop | goal gate / trusted evidence / automatic continuation |
---
@@ -358,13 +347,13 @@ s08_context_compact/
Read the `README.md` for the core idea and work through the code. Complex chapters have `<details>` folds for deep dives -- open them when you want to go deeper. Simple chapters have 0-1 diagrams, complex chapters have more.
Read from s01 through s22 in order. Each chapter assumes you've read the previous ones and ends with a hook into the next.
Read from s01 through s21 in order. Each chapter assumes you've read the previous ones and ends with a hook into the next.
---
## Quick Start
### Current 22-Lesson Track
### Current 21-Lesson Track
```sh
git clone https://github.com/shareAI-lab/learn-claude-code
@@ -374,7 +363,7 @@ cp .env.example .env # configure ANTHROPIC_API_KEY
python s01_agent_loop/code.py # Start here -- one loop + bash
python s08_context_compact/code.py # Context compaction (complex)
python s22_goal_loop/code.py # Endpoint: close the loop with a durable goal
python s21_goal_loop/code.py # Endpoint: close the loop with a durable goal
```
### Legacy 12-Lesson Track
@@ -387,7 +376,7 @@ python agents/s_full.py
### Web Platform
The web app extracts the root-level course. Lessons s21 and s22 include reading, source, simulator, and architecture views; only their dedicated hero visualizations remain intentionally minimal.
The web app extracts the root-level course. Lessons s20 and s21 include reading, source, simulator, and architecture views; only their dedicated hero visualizations remain intentionally minimal.
```sh
cd web && npm install && npm run dev # http://localhost:3000
@@ -407,10 +396,10 @@ learn-claude-code/
images/ # SVG diagrams
s02_tool_use/
...
s19_mcp_plugin/
s20_comprehensive/
s21_workflow_runtime/
s22_goal_loop/ # endpoint chapter
s18_mcp_plugin/
s19_comprehensive/
s20_workflow_runtime/
s21_goal_loop/ # endpoint chapter
agents/ # legacy 12 runnable copies + s_full.py
skills/ # skill files used by s07
docs/ # legacy 12-lesson docs, kept during transition
@@ -422,7 +411,7 @@ learn-claude-code/
## What's Next
After 22 lessons, you understand harness engineering from the inside out. Two paths to turn that knowledge into product:
After 21 lessons, you understand harness engineering from the inside out. Two paths to turn that knowledge into product:
### Kode Agent CLI -- Open-Source Coding Agent CLI
+2 -60
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20 → s21 → s22
`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20 → s21
> *"One loop & Bash is all you need"* — ツール一つ + ループ一つ = 一つの Agent。
>
> **Harness レイヤー**: ループ — モデルと現実世界をつなぐ最初の架け橋。
@@ -113,7 +113,7 @@ def agent_loop(messages):
## 試してみよう
> **教育デモの注意**: このコードはモデルが生成したシェルコマンドを実行します。プロジェクトファイルへの影響を避けるため、一時テストディレクトリで実行してください。s03 で本格的な権限システムを説明します。
> **安全上の注意**: このコードはモデルが生成したシェルコマンドを実行します。プロジェクトファイルへの影響を避けるため、一時テストディレクトリで実行してください。s03 で権限制御を追加します。
**準備**(初回のみ):
@@ -145,63 +145,5 @@ python s01_agent_loop/code.py
→ s02 Tool Use:5 つの本格的なツールを与えたらどうなる? モデルは複数のツールを同時に呼び出すか? 並列実行で競合は起きないか?
<details>
<summary>CC ソースコードを深掘り</summary>
> 以下は CC ソースコード `src/query.ts`(1729 行)の検証に基づく。核心的な違いは二つ:CC はループ継続の判断に `stop_reason` フィールドを頼らず、コンテンツに `tool_use` ブロックが含まれるかをチェックする(ストリーミングレスポンスでは `stop_reason` が信頼できないため)。CC には本番環境向けのより多くの終了パスとリカバリ戦略がある。
**教育版の 30 行 `while True` が CC の 1729 行の核心。** 以下の各項目は、すべてその核心の上に積み重ねられた保護機構である。
<details>
<summary>一、ループ構造の違い</summary>
教育版は `response.stop_reason` をチェックする。CC はこれをループ継続の唯一の根拠として使わない — ストリーミングレスポンスでは、`stop_reason` がまだ更新されていなくても、コンテンツに既に `tool_use` ブロックが含まれている可能性がある。CC は `needsFollowUp` フラグを使用する:ストリーミングメッセージの受信時(`query.ts:830-834`)に、`tool_use` ブロックが検出されると `true` に設定される。`QueryEngine.ts``message_delta` から実際の `stop_reason` を取得して他の処理に利用するが、query loop 自体は `needsFollowUp` に依存する。
```typescript
// query.ts:554-558
// stop_reason === 'tool_use' is unreliable.
// Set during streaming whenever a tool_use block arrives.
let needsFollowUp = false
```
</details>
<details>
<summary>二、State オブジェクト 10 フィールド(教育版は messages のみ使用)</summary>
| # | フィールド | 用途 | 対応章 |
|---|-----------|------|--------|
| 1 | `messages` | 現在のイテレーションのメッセージ配列 | s01 |
| 2 | `toolUseContext` | ツール、シグナル、権限コンテキスト | s02 |
| 3 | `autoCompactTracking` | 圧縮状態の追跡 | s08 |
| 4 | `maxOutputTokensRecoveryCount` | トークンリカバリ試行回数(上限 3) | s11 |
| 5 | `hasAttemptedReactiveCompact` | 今回のラウンドでリアクティブ圧縮を試みたか | s08 |
| 6 | `maxOutputTokensOverride` | 8K→64K へのアップグレード上書き | s11 |
| 7 | `pendingToolUseSummary` | バックグラウンド Haiku 生成のツール使用要約 | s08 |
| 8 | `stopHookActive` | 停止フックがブロッキングエラーを発生させたか | s04 |
| 9 | `turnCount` | ターン数(maxTurns チェック用) | s01 |
| 10 | `transition` | 前回の継続理由 | s11 |
> 注:`taskBudgetRemaining``query.ts:291`)は loop-local のローカル変数であり、State には含まれない。ソースコメントには明確に "Loop-local (not on State)" と書かれている。
</details>
<details>
<summary>三、複数の終了パスと継続パス</summary>
教育版には 1 つの終了パスしかない(モデルがツールを呼ばなければ終了)。本番版には複数の終了・継続パスがあり、blocking limit、prompt too long、model error、abort、hook stop、max turns、token budget continuation、reactive compact retry など多くのシナリオをカバーしている。各シナリオには対応するリカバリまたは終了戦略がある。
</details>
<details>
<summary>四、ストリーミングツール実行と QueryEngine</summary>
CC の `StreamingToolExecutor``query.ts:561`)は、モデルがまだ生成中にツールの実行を開始できる(concurrency-safe なツールは並列、それ以外は排他実行)。`QueryEngine.ts` はさらに、コスト超過や構造化出力の検証失敗などの保護を追加する。教育版はこれらを実装しない — 目標は概念の明確さであり、極限のパフォーマンスではない。
</details>
**一言で**: query.ts の 1729 行の核心は 30 行の `while True`。複雑なフィールドや終了パスはすべて保護機構だ。まず核心のループを理解すれば、その後のすべては自然に理解できる。
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+2 -60
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20 → s21 → s22
`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20 → s21
> *"One loop & Bash is all you need"* — One tool + one loop = one Agent.
>
> **Harness Layer**: The Loop — the first bridge between the model and the real world.
@@ -113,7 +113,7 @@ Under 30 lines — that's the minimal runnable agent harness kernel. It's not in
## Try It
> **Teaching demo notice**: The code executes shell commands generated by the model. Run it in a temporary test directory to avoid affecting your project files. s03 covers the real permission system.
> **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.
**Setup** (first run):
@@ -145,63 +145,5 @@ Right now the model only has bash — reading files requires `cat`, writing file
→ s02 Tool Use: What happens when we give it 5 proper tools? Will the model call multiple tools at once? Will parallel tool executions step on each other?
<details>
<summary>Dive into CC Source Code</summary>
> The following is based on a review of CC source code `src/query.ts` (1729 lines). The core differences are twofold: CC doesn't rely on the `stop_reason` field to decide whether to continue the loop — instead it checks whether the content contains `tool_use` blocks (because `stop_reason` is unreliable in streaming responses); CC has more exit paths and recovery strategies for production-grade protection.
**The 30-line `while True` from the teaching version IS the core of CC's 1729 lines.** Everything below is a protection mechanism layered on top of that core.
<details>
<summary>1. Loop Structure Differences</summary>
The teaching version checks `response.stop_reason`. CC doesn't use it as the sole signal for loop continuation — in streaming responses, `stop_reason` may not have updated yet even though `tool_use` blocks are already present. CC uses a `needsFollowUp` flag: during streaming message reception (`query.ts:830-834`), it's set to `true` whenever a `tool_use` block is detected. `QueryEngine.ts` captures the real `stop_reason` from `message_delta` for other logic, but the query loop itself relies on `needsFollowUp`.
```typescript
// query.ts:554-558
// stop_reason === 'tool_use' is unreliable.
// Set during streaming whenever a tool_use block arrives.
let needsFollowUp = false
```
</details>
<details>
<summary>2. State Object — 10 Fields (Teaching Version Only Uses messages)</summary>
| # | Field | Purpose | Chapter |
|---|-------|---------|---------|
| 1 | `messages` | Message array for the current iteration | s01 |
| 2 | `toolUseContext` | Tool, signal, and permission context | s02 |
| 3 | `autoCompactTracking` | Compaction state tracking | s08 |
| 4 | `maxOutputTokensRecoveryCount` | Token recovery attempt count (max 3) | s11 |
| 5 | `hasAttemptedReactiveCompact` | Whether reactive compaction was attempted this round | s08 |
| 6 | `maxOutputTokensOverride` | 8K→64K upgrade override | s11 |
| 7 | `pendingToolUseSummary` | Background Haiku-generated tool use summary | s08 |
| 8 | `stopHookActive` | Whether the stop hook produced a blocking error | s04 |
| 9 | `turnCount` | Turn count (for maxTurns check) | s01 |
| 10 | `transition` | Last continue reason | s11 |
> Note: `taskBudgetRemaining` (`query.ts:291`) is a loop-local variable, not on State. The source comment explicitly says "Loop-local (not on State)".
</details>
<details>
<summary>3. Multiple Exit and Continue Paths</summary>
The teaching version has only 1 exit path (model doesn't call a tool → done). The production version has multiple exit and continue paths, covering blocking limit, prompt too long, model error, abort, hook stop, max turns, token budget continuation, reactive compact retry, and more. Each scenario has a corresponding recovery or exit strategy.
</details>
<details>
<summary>4. Streaming Tool Execution and QueryEngine</summary>
CC's `StreamingToolExecutor` (`query.ts:561`) allows tools to begin parallel execution while the model is still generating (concurrency-safe tools run in parallel, others run exclusively). `QueryEngine.ts` adds additional protections for cost overruns, structured output validation failures, and more. The teaching version doesn't implement these — the goal is conceptual clarity, not peak performance.
</details>
**In one sentence**: The core of query.ts's 1729 lines is a 30-line `while True`. All the complex fields and exit paths are protection mechanisms. Understand the core loop first, and everything that follows unfolds naturally.
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+2 -60
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20 → s21 → s22
`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20 → s21
> *"One loop & Bash is all you need"* — 一个工具 + 一个循环 = 一个 Agent。
>
> **Harness 层**: 循环 — 模型与真实世界的第一道连接。
@@ -113,7 +113,7 @@ def agent_loop(messages):
## 试一下
> **教学 demo 提示**:代码会执行模型生成的 shell 命令。建议在一个临时测试目录中运行,避免影响你的项目文件。s03 会讲真正的权限系统
> **安全提示**:代码会执行模型生成的 shell 命令。建议在一个临时测试目录中运行,避免影响你的项目文件。s03 会加入权限控制
**准备**(首次运行):
@@ -145,63 +145,5 @@ python s01_agent_loop/code.py
s02 Tool Use → 给它 5 个真正的工具,会发生什么?模型会不会一次调用多个工具?几个工具同时跑会不会互相踩?
<details>
<summary>深入 CC 源码</summary>
> 以下内容基于 CC 源码 `src/query.ts`1729 行)的核查。核心差异就两个:CC 不看 `stop_reason` 字段而是检查内容里有没有 tool_use 块(因为流式响应中 stop_reason 不可靠);CC 有更多的退出路径和恢复策略做生产级保护。
**教学版的 30 行 `while True` 就是 CC 1729 行的核心。** 下面每一项都是在这个核心上叠加的保护机制。
<details>
<summary>一、循环结构差异</summary>
教学版检查 `response.stop_reason`。CC 不把它作为循环继续的唯一依据——流式响应中 `stop_reason` 可能还没更新但内容里已经有 `tool_use` 块了。CC 用 `needsFollowUp` 标志:接收到流式消息时(`query.ts:830-834`),只要检测到 `tool_use` 块就设为 `true``QueryEngine.ts` 会从 `message_delta` 捕获真实 `stop_reason` 用于其他逻辑,但 query loop 本身靠 `needsFollowUp` 决定是否继续。
```typescript
// query.ts:554-558
// stop_reason === 'tool_use' is unreliable.
// Set during streaming whenever a tool_use block arrives.
let needsFollowUp = false
```
</details>
<details>
<summary>二、State 对象 10 字段(教学版只用 messages</summary>
| # | 字段 | 用途 | 对应章节 |
|---|------|------|---------|
| 1 | `messages` | 当前迭代的消息数组 | s01 |
| 2 | `toolUseContext` | 工具、信号、权限上下文 | s02 |
| 3 | `autoCompactTracking` | 压缩状态追踪 | s08 |
| 4 | `maxOutputTokensRecoveryCount` | token 恢复尝试次数(上限 3 | s11 |
| 5 | `hasAttemptedReactiveCompact` | 本轮是否已尝试响应式压缩 | s08 |
| 6 | `maxOutputTokensOverride` | 8K→64K 的升级覆盖 | s11 |
| 7 | `pendingToolUseSummary` | 后台 Haiku 生成的 tool use 摘要 | s08 |
| 8 | `stopHookActive` | 停止钩子是否产生阻塞错误 | s04 |
| 9 | `turnCount` | 轮次计数(maxTurns 检查) | s01 |
| 10 | `transition` | 上一次继续原因 | s11 |
> 注:`taskBudgetRemaining``query.ts:291`)是 loop-local 局部变量,不在 State 上。源码注释明确写了 "Loop-local (not on State)"。
</details>
<details>
<summary>三、多条退出和继续路径</summary>
教学版只有 1 条退出路径(模型不调工具就结束)。生产版有多条退出和继续路径,覆盖 blocking limit、prompt too long、model error、abort、hook stop、max turns、token budget continuation、reactive compact retry 等场景。每种场景都有对应的恢复或退出策略。
</details>
<details>
<summary>四、流式工具执行和 QueryEngine</summary>
CC 的 `StreamingToolExecutor``query.ts:561`)让工具在模型还在生成时就开始并行执行(根据工具是否 concurrency-safe 决定并发或独占)。`QueryEngine.ts` 额外加了费用超限、结构化输出验证失败等保护。教学版不实现这些——目标是概念清晰,不是性能极致。
</details>
**一句话**1729 行的 query.ts 核心就是 30 行 `while True`。所有复杂字段和退出路径都是保护机制。先理解核心循环,后面的一切自然展开。
</details>
<!-- translation-sync: zh@v1, en@v0, ja@v0 -->
+2 -2
View File
@@ -19,8 +19,8 @@ The entire secret of an AI coding agent in one pattern:
(loop continues)
This is the core loop: feed tool results back to the model
until the model decides to stop. Production agents layer
policy, hooks, and lifecycle controls on top.
until the model decides to stop. Later chapters add policy,
hooks, and lifecycle controls around it.
Usage:
pip install anthropic python-dotenv
+3 -65
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20 → s21 → s22
s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20 → s21
> *"ツールを一つ追加、ハンドラを一つ追加"* — ループはそのまま。新しいツールをディスパッチマップに登録するだけ。
>
> **Harness レイヤー**: ツールディスパッチ — モデルが触れる範囲を拡張。
@@ -106,7 +106,7 @@ for block in response.content:
モデルはよく一度に複数の tool_use を返す — 「a.py と b.py を読んで、全 .py ファイルを列挙して」。
教育版は `response.content` 元の順序で一つずつ実行する。CC のやり方はより複雑:元の順序を保ったまま連続バッチに分割し、バッチ内の並列安全なツールを並行実行し、バッチ間は厳密に順次(付録を参照)。
これらの呼び出しは、`response.content` に現れる元の順序で一つずつ実行する。
---
@@ -116,7 +116,7 @@ for block in response.content:
|------|--------|
| TOOL_HANDLERS | ツール名 → ハンドラ関数の辞書。ツール追加 = マッピング一行追加 |
| ツール定義 | モデルに「何ができるか」を伝える JSON schema |
| 複数ツール呼び出し | モデルは一度に複数の tool_use を返す可能性がある。教育版は元の順序で一つずつ実行 |
| 複数ツール呼び出し | モデルは一度に複数の tool_use を返す可能性があり、元の順序で一つずつ実行する |
| ループ不変 | s01 の `while True` ループ — 一行も変更なし |
---
@@ -156,67 +156,5 @@ Agent は 5 つの専用ツールを持つようになった。file tools は `s
→ s03 Permission:ツール実行前にゲートを追加 — この操作は安全か? ユーザーの承認が必要か?
<details>
<summary>CC ソースコードを深掘り</summary>
> 以下は CC ソースコード `Tool.ts`、`tools.ts`、`toolOrchestration.ts`、`toolExecution.ts`、`StreamingToolExecutor.ts` の検証に基づく。
### 一、ツール定義方式
**教育版**`TOOLS` 配列 + `TOOL_HANDLERS` 辞書。定義と実装が分離。
**CC**:各ツールは `buildTool()` で作成された独立オブジェクトで、schema、バリデーション、権限、実行を含む。`getAllBaseTools()` が全ツールを集約。
教育版の分離方式は教学に適している — 読者は「ツール追加 = 二つの定義」と一目で分かる。
### 二、並列安全性:isConcurrencySafe()
![Tool Concurrency](images/concurrency-comparison.ja.svg)
教育版は元の順序で一つずつ実行し、並列処理は行わない。CC は `isConcurrencySafe(input)` で並列可否を判断する — これは単なる「読み取り専用 vs 書き込み」ではなく、具体的な入力で判断する:
| | isReadOnly | isConcurrencySafe |
|---|---|---|
| FileRead | true | true |
| Glob | true | true |
| Bash `ls` | true | **true** ← 重要な違い |
| Bash `rm` | false | false |
| TaskCreate | false | **true** ← 状態変更するが並列可能(s12 で紹介) |
CC の Bash ツールの `isConcurrencySafe``isReadOnly` と同じ — 読み取り専用コマンドは並列可能、書き込みコマンドは不可。TaskCreate はタスクファイルを変更するが、毎回異なるファイルに書き込むため並列可能。
### 三、パーティションアルゴリズム
CC の `partitionToolCalls()``toolOrchestration.ts:91-115`)は二つのグループに分けるのではなく、ツール呼び出しを**連続ブロックごとにバッチ化**する:
```
[read A, read B, glob *.py, bash "rm x", read C]
→ batch1(並列): [read A, read B, glob *.py]
→ batch2(直列): [bash "rm x"]
→ batch3(並列): [read C]
```
連続する並列安全な呼び出しを同じバッチにまとめ、真の並列実行を行う(`toolOrchestration.ts:152-176`、並列数上限あり)。非並列安全な呼び出しに遭遇すると新しいバッチを開始して直列実行。バッチ間は厳密に順次。
### 四、バリデーションパイプライン
CC の各ツール呼び出しは厳格な 5 段階のバリデーションを経る(`toolExecution.ts`):
1. **Zod schema バリデーション**`614-680`、教育版は JSON Schema で代替):パラメータの型/構造チェック
2. **ツールレベル validateInput()**`682-733`):パラメータ値の検証(例:パスが作業ディレクトリ内か)
3. **PreToolUse フック**`800-862`、s04 で詳解):フックはメッセージの返却、入力の変更、実行のブロックが可能
4. **権限チェック**`921-931`、s03 の核心):canUseTool + checkPermissions → allow/deny/ask
5. **tool.call() の実行**`1207-1222`
教育版は Zod を省略(JSON Schema を使用)、validateInput を省略(安全関数を使用)、権限チェックとフック概念は保持。
### 五、ストリーミングツール実行
CC の `StreamingToolExecutor``StreamingToolExecutor.ts`)はモデルがまだ生成中にツールを起動する — モデルの完了を待たない。`read_file` はモデルが「分析します」と出力中に完了するかもしれない。教育版はこれを実装しない。s01 と同じ目標 — 概念の明確さ、極限のパフォーマンスではない。
### 六、ツール結果の永続化
各ツールには `maxResultSizeChars` フィールドがある。この閾値を超える結果はディスクに保存され、モデルにはプレビュー + ファイルパスが表示される。FileRead は特殊 — `Infinity` に設定され、ファイル読み出し結果の再永続化を防ぐ。具体的には、FileRead の結果が閾値を超えて永続化されると、モデルがその永続化ファイルを次に読むときにまた永続化がトリガーされ → 無限ループ(ファイル読む → 永続化 → 再読み → 再永続化 → ...)になる。
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+3 -65
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20 → s21 → s22
s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20 → s21
> *"Add a tool, add just one handler"* — The loop stays the same. Register the new tool in the dispatch map and you're done.
>
> **Harness Layer**: Tool Dispatch — Expanding the model's reach.
@@ -106,7 +106,7 @@ Adding a tool = one entry in `TOOLS` array + one line in `TOOL_HANDLERS` dict. T
The model often returns multiple tool_use calls at once — "read a.py and b.py, then list all .py files".
The teaching version executes them one by one in the original `response.content` order. CC's approach is more complex: it slices the original order into consecutive batches, where concurrency-safe tools within a batch run in parallel, and batches are strictly sequential (see appendix).
Calls are executed one by one in their original `response.content` order.
---
@@ -116,7 +116,7 @@ The teaching version executes them one by one in the original `response.content`
|---------|-----------|
| TOOL_HANDLERS | Tool name → handler function dict. Add a tool = add one mapping line |
| Tool Definition | JSON schema telling the model "what I can do" |
| Multiple tool calls | Model may return multiple tool_use at once; teaching version executes them in original order |
| Multiple tool calls | Model may return multiple tool_use at once; calls execute in their original order |
| Loop Unchanged | s01's `while True` loop — not a single line changed |
---
@@ -156,67 +156,5 @@ The Agent now has 5 specialized tools. File tools are protected by `safe_path`,
→ s03 Permission: Add a gate before tool execution — is this operation safe? Does it need user approval?
<details>
<summary>Dive into CC Source Code</summary>
> The following is based on a review of CC source code `Tool.ts`, `tools.ts`, `toolOrchestration.ts`, `toolExecution.ts`, and `StreamingToolExecutor.ts`.
### 1. Tool Definition Approach
**Teaching version**: `TOOLS` array + `TOOL_HANDLERS` dict. Definition and implementation are separate.
**CC**: Each tool is an independent object created by `buildTool()`, containing schema, validation, permissions, and execution. `getAllBaseTools()` aggregates all tools.
The teaching version's separation is clearer for teaching — readers immediately see "add a tool = two definitions".
### 2. Concurrency Safety: isConcurrencySafe()
![Tool Concurrency](images/concurrency-comparison.en.svg)
The teaching version executes tools one by one in original order, without concurrency. CC uses `isConcurrencySafe(input)` to determine concurrency — note this isn't simply "read-only vs write", but judges by specific input:
| | isReadOnly | isConcurrencySafe |
|---|---|---|
| FileRead | true | true |
| Glob | true | true |
| Bash `ls` | true | **true** ← key difference |
| Bash `rm` | false | false |
| TaskCreate | false | **true** ← modifies state but can be concurrent (introduced in s12) |
CC's Bash tool's `isConcurrencySafe` equals `isReadOnly` — read-only commands can be concurrent, write commands cannot. TaskCreate modifies task files, but each writes a different file, so it can be concurrent.
### 3. Partition Algorithm
CC's `partitionToolCalls()` (`toolOrchestration.ts:91-115`) doesn't split into two groups — it batches tool calls **by consecutive blocks**:
```
[read A, read B, glob *.py, bash "rm x", read C]
→ batch1(concurrent): [read A, read B, glob *.py]
→ batch2(serial): [bash "rm x"]
→ batch3(concurrent): [read C]
```
Consecutive concurrency-safe calls are grouped into the same batch for truly concurrent execution (`toolOrchestration.ts:152-176`, with a concurrency limit). When a non-concurrency-safe call is encountered, a new batch starts for serial execution. Batches are strictly sequential.
### 4. Validation Pipeline
Each tool call in CC goes through a strict 5-step validation (`toolExecution.ts`):
1. **Zod schema validation** (`614-680`, teaching version uses JSON Schema): parameter type/structure check
2. **Tool-level validateInput()** (`682-733`): parameter value validation (e.g., is the path within the working directory)
3. **PreToolUse hooks** (`800-862`, covered in s04): hooks can return messages, modify input, or block execution
4. **Permission check** (`921-931`, core topic of s03): canUseTool + checkPermissions → allow/deny/ask
5. **Execute tool.call()** (`1207-1222`)
The teaching version omits Zod (uses JSON Schema), omits validateInput (uses safety functions), but preserves the permission check and hook concepts.
### 5. Streaming Tool Execution
CC's `StreamingToolExecutor` (`StreamingToolExecutor.ts`) starts tools while the model is still generating — no waiting for the model to finish. `read_file` might complete while the model is still outputting "Let me analyze". The teaching version doesn't implement this, consistent with s01's goal — conceptual clarity, not peak performance.
### 6. Tool Result Persistence
Each tool has a `maxResultSizeChars` field. Results exceeding this threshold are persisted to disk, and the model sees a preview + file path. FileRead is special — set to `Infinity`, preventing file read output from being persisted again. Specifically, if FileRead's result exceeds the threshold and gets persisted, the model's next read of that persisted file would trigger another persistence → infinite loop (read file → persist → re-read → re-persist → ...).
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+3 -65
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20 → s21 → s22
s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20 → s21
> *"加一个工具, 只加一个 handler"* — 循环不用动, 新工具注册进 dispatch map 就行。
>
> **Harness 层**: 工具分发 — 扩展模型能触达的边界。
@@ -106,7 +106,7 @@ for block in response.content:
模型经常一次返回多个 tool_use"读一下 a.py 和 b.py,然后列出所有 .py 文件"。
教学版按 `response.content` 原始顺序逐个执行。CC 的做法更复杂:按原始顺序切成连续 batch,batch 内并发安全的工具并行执行,batch 间严格顺序(见附录)。
这些调用按照 `response.content` 中的原始顺序逐个执行。
---
@@ -116,7 +116,7 @@ for block in response.content:
|------|--------|
| TOOL_HANDLERS | 工具名 → 处理函数的字典。加工具 = 加一行映射 |
| 工具定义 | 告诉模型"我能做什么"的 JSON schema |
| 多工具调用 | 模型可一次返回多个 tool_use,教学版按原始顺序逐个执行 |
| 多工具调用 | 模型可一次返回多个 tool_use,按原始顺序逐个执行 |
| 循环不变 | s01 的 `while True` 循环一行都没改 |
---
@@ -156,67 +156,5 @@ python s02_tool_use/code.py
s03 Permission → 在工具执行之前加一道门:这个操作安全吗?需要用户批准吗?
<details>
<summary>深入 CC 源码</summary>
> 以下基于 CC 源码 `Tool.ts`、`tools.ts`、`toolOrchestration.ts`、`toolExecution.ts`、`StreamingToolExecutor.ts` 的核查。
### 一、工具定义方式
**教学版**`TOOLS` 数组 + `TOOL_HANDLERS` 字典。定义和实现分开。
**CC**:每个工具是 `buildTool()` 创建的独立对象,包含 schema、验证、权限、执行。`getAllBaseTools()` 汇总所有工具。
教学版的分离方式对教学更清晰——读者一眼看到"加一个工具 = 两条定义"。
### 二、并发安全判断:isConcurrencySafe()
![Tool Concurrency](images/concurrency-comparison.svg)
教学版按原始顺序逐个执行,不做并发。CC 用 `isConcurrencySafe(input)` 判断能否并发——注意这不是简单的"只读 vs 写",而是按具体输入判断:
| | isReadOnly | isConcurrencySafe |
|---|---|---|
| FileRead | true | true |
| Glob | true | true |
| Bash `ls` | true | **true** ← 关键差异 |
| Bash `rm` | false | false |
| TaskCreate | false | **true** ← 改状态但可并发(TaskCreate 在 s12 介绍) |
CC 的 Bash tool 的 `isConcurrencySafe` 等于 `isReadOnly`——只读命令可并发,写命令不可。TaskCreate 虽然改了任务文件,但每次都写不同的文件,所以可以并发。
### 三、分区算法
CC 的 `partitionToolCalls()``toolOrchestration.ts:91-115`)不是分两组,而是把工具调用**按连续块分批**:
```
[read A, read B, glob *.py, bash "rm x", read C]
→ batch1(并发): [read A, read B, glob *.py]
→ batch2(串行): [bash "rm x"]
→ batch3(并发): [read C]
```
并发安全的连续块编入同一个 batch,batch 内真正并发执行(`toolOrchestration.ts:152-176`,有并发上限)。遇到非并发安全的就开新 batch 串行执行。batch 之间严格顺序。
### 四、验证管线
CC 的每个工具调用经过严格的 5 步验证(`toolExecution.ts`):
1. **Zod schema 验证**`614-680`,教学版用 JSON Schema 替代):参数类型/结构检查
2. **工具级 validateInput()**`682-733`):参数值验证(如路径是否在工作区内)
3. **PreToolUse hooks**`800-862`,s04 详细介绍):钩子可以返回消息、修改输入、阻止执行
4. **权限检查**`921-931`s03 的核心内容):canUseTool + checkPermissions → allow/deny/ask
5. **执行 tool.call()**`1207-1222`
教学版省略了 Zod(用 JSON Schema)、省略了 validateInput(用安全函数)、保留了权限检查和钩子概念。
### 五、流式工具执行
CC 的 `StreamingToolExecutor``StreamingToolExecutor.ts`)让工具在模型还在生成时就启动——不等模型说完。`read_file` 可能在模型还在输出"我来分析"的时候就跑完了。教学版不实现这个,目标和 s01 一致——概念清晰,不追求性能极致。
### 六、工具结果持久化
每个工具有一个 `maxResultSizeChars` 字段。结果超过这个值就落盘,模型看到的是预览 + 文件路径。FileRead 特殊——设为 `Infinity`,防止读文件的输出又被当成文件落盘。具体来说,如果 FileRead 的结果超过阈值被落盘,模型下次读那个落盘文件时又会触发落盘 → 无限循环(读文件 → 落盘 → 再读 → 再落盘 → ...)。
</details>
<!-- translation-sync: zh@v1, en@v0, ja@v0 -->
@@ -1,108 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 500" font-family="system-ui, -apple-system, sans-serif">
<defs>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/><stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
<linearGradient id="teach" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fef3c7"/><stop offset="100%" stop-color="#fde68a"/>
</linearGradient>
<linearGradient id="cc" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#dcfce7"/><stop offset="100%" stop-color="#bbf7d0"/>
</linearGradient>
<marker id="arrow-g" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
</defs>
<rect width="760" height="500" fill="#fafbfc" rx="8"/>
<rect x="0" y="0" width="760" height="36" fill="url(#header)" rx="8"/>
<rect x="0" y="28" width="760" height="8" fill="url(#header)"/>
<text x="380" y="24" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">Tool Concurrency — Teaching Version vs Claude Code</text>
<!-- Input tool blocks -->
<rect x="180" y="52" width="400" height="28" rx="14" fill="#f1f5f9" stroke="#94a3b8" stroke-width="1"/>
<text x="380" y="71" fill="#475569" font-size="11" font-weight="600" text-anchor="middle">Model returns 5 tool calls at once</text>
<rect x="38" y="92" width="100" height="36" rx="4" fill="#dbeafe" stroke="#93c5fd" stroke-width="1"/>
<text x="88" y="114" fill="#1e40af" font-size="10" font-weight="600" text-anchor="middle">read A.py</text>
<rect x="148" y="92" width="100" height="36" rx="4" fill="#dbeafe" stroke="#93c5fd" stroke-width="1"/>
<text x="198" y="114" fill="#1e40af" font-size="10" font-weight="600" text-anchor="middle">glob *.py</text>
<rect x="258" y="92" width="110" height="36" rx="4" fill="#fef3c7" stroke="#fbbf24" stroke-width="1"/>
<text x="313" y="114" fill="#92400e" font-size="10" font-weight="600" text-anchor="middle">bash "ls -la"</text>
<rect x="378" y="92" width="100" height="36" rx="4" fill="#fee2e2" stroke="#fca5a5" stroke-width="1"/>
<text x="428" y="114" fill="#991b1b" font-size="10" font-weight="600" text-anchor="middle">write B.py</text>
<rect x="488" y="92" width="100" height="36" rx="4" fill="#dbeafe" stroke="#93c5fd" stroke-width="1"/>
<text x="538" y="114" fill="#1e40af" font-size="10" font-weight="600" text-anchor="middle">read C.py</text>
<!-- LEFT: Teaching Version -->
<rect x="20" y="156" width="350" height="230" rx="8" fill="url(#teach)" stroke="#d97706" stroke-width="1.5"/>
<text x="195" y="180" fill="#92400e" font-size="13" font-weight="700" text-anchor="middle">Teaching: Original Order, One by One</text>
<rect x="35" y="192" width="320" height="46" rx="4" fill="#fff" stroke="#fbbf24" stroke-width="0.5"/>
<text x="46" y="209" fill="#92400e" font-size="9" font-family="monospace">for block in response.content:</text>
<text x="46" y="224" fill="#92400e" font-size="9" font-family="monospace"> TOOL_HANDLERS[name](**input)</text>
<text x="195" y="258" fill="#92400e" font-size="10" font-weight="600" text-anchor="middle">Result: 5 serial calls, no batches</text>
<rect x="45" y="270" width="300" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="284" fill="#92400e" font-size="8" font-weight="600">1. read A.py</text>
<rect x="45" y="294" width="300" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="308" fill="#92400e" font-size="8" font-weight="600">2. glob *.py</text>
<rect x="45" y="318" width="300" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="332" fill="#92400e" font-size="8" font-weight="600">3. bash "ls -la"</text>
<rect x="45" y="342" width="145" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="356" fill="#92400e" font-size="8" font-weight="600">4. write B.py</text>
<rect x="200" y="342" width="145" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="215" y="356" fill="#92400e" font-size="8" font-weight="600">5. read C.py</text>
<text x="195" y="378" fill="#dc2626" font-size="8" font-weight="600" text-anchor="middle">Teaching focus: tool dispatch first; concurrency omitted</text>
<!-- RIGHT: Claude Code -->
<rect x="390" y="156" width="350" height="230" rx="8" fill="url(#cc)" stroke="#16a34a" stroke-width="1.5"/>
<text x="565" y="180" fill="#166534" font-size="13" font-weight="700" text-anchor="middle">Claude Code: isConcurrencySafe(input)</text>
<rect x="405" y="192" width="320" height="38" rx="4" fill="#fff" stroke="#86efac" stroke-width="0.5"/>
<text x="416" y="207" fill="#166534" font-size="9" font-family="monospace">Each tool call judged individually:</text>
<text x="416" y="222" fill="#166534" font-size="9" font-family="monospace">tool.isConcurrencySafe(parsedInput) → bool</text>
<text x="565" y="250" fill="#166534" font-size="10" font-weight="600" text-anchor="middle">Result: 3 batches (by consecutive blocks)</text>
<rect x="400" y="258" width="155" height="50" rx="4" fill="#dcfce7" stroke="#86efac" stroke-width="1"/>
<text x="477" y="276" fill="#166534" font-size="8" font-weight="600" text-anchor="middle">Batch 1</text>
<text x="477" y="289" fill="#166534" font-size="8" text-anchor="middle">Concurrent</text>
<text x="477" y="302" fill="#166534" font-size="7" text-anchor="middle">read A · glob · bash "ls"</text>
<line x1="560" y1="283" x2="575" y2="283" stroke="#16a34a" stroke-width="1" marker-end="url(#arrow-g)"/>
<rect x="580" y="258" width="65" height="50" rx="4" fill="#fee2e2" stroke="#fca5a5" stroke-width="1"/>
<text x="612" y="276" fill="#991b1b" font-size="8" font-weight="600" text-anchor="middle">Batch 2</text>
<text x="612" y="289" fill="#991b1b" font-size="8" text-anchor="middle">Serial</text>
<text x="612" y="302" fill="#991b1b" font-size="7" text-anchor="middle">write B</text>
<line x1="650" y1="283" x2="665" y2="283" stroke="#16a34a" stroke-width="1" marker-end="url(#arrow-g)"/>
<rect x="670" y="258" width="55" height="50" rx="4" fill="#dcfce7" stroke="#86efac" stroke-width="1"/>
<text x="697" y="276" fill="#166534" font-size="8" font-weight="600" text-anchor="middle">Batch 3</text>
<text x="697" y="289" fill="#166534" font-size="8" text-anchor="middle">Concurrent</text>
<text x="697" y="302" fill="#166534" font-size="7" text-anchor="middle">read C</text>
<text x="565" y="332" fill="#16a34a" font-size="8" font-weight="600" text-anchor="middle">bash "ls" is safe and consecutive, so it stays in Batch 1</text>
<text x="565" y="366" fill="#16a34a" font-size="9" font-weight="600" text-anchor="middle">✓ Input-dependent safety, not tool-name hardcoding</text>
<text x="565" y="380" fill="#16a34a" font-size="9" font-weight="600" text-anchor="middle">✓ Original order preserved; only safe consecutive calls run together</text>
<!-- Bottom Summary -->
<rect x="20" y="402" width="720" height="82" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
<text x="40" y="424" fill="#1e3a5f" font-size="12" font-weight="600">Key Difference</text>
<text x="40" y="444" fill="#475569" font-size="10">• Teaching: executes response.content in original order, one tool call at a time; no concurrency or batching</text>
<text x="40" y="460" fill="#475569" font-size="10">• CC: checks isConcurrencySafe(input), then groups consecutive safe calls into one batch</text>
<text x="40" y="476" fill="#475569" font-size="10">• Key difference: teaching focuses on dispatch; CC optimizes safe concurrency while preserving order semantics</text>
</svg>

Before

Width:  |  Height:  |  Size: 7.7 KiB

@@ -1,108 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 500" font-family="system-ui, -apple-system, sans-serif">
<defs>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/><stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
<linearGradient id="teach" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fef3c7"/><stop offset="100%" stop-color="#fde68a"/>
</linearGradient>
<linearGradient id="cc" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#dcfce7"/><stop offset="100%" stop-color="#bbf7d0"/>
</linearGradient>
<marker id="arrow-g" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
</defs>
<rect width="760" height="500" fill="#fafbfc" rx="8"/>
<rect x="0" y="0" width="760" height="36" fill="url(#header)" rx="8"/>
<rect x="0" y="28" width="760" height="8" fill="url(#header)"/>
<text x="380" y="24" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">ツール並列実行 — 教育版 vs Claude Code</text>
<!-- 入力ツールブロック -->
<rect x="180" y="52" width="400" height="28" rx="14" fill="#f1f5f9" stroke="#94a3b8" stroke-width="1"/>
<text x="380" y="71" fill="#475569" font-size="11" font-weight="600" text-anchor="middle">モデルが一度に 5 つのツール呼び出しを返す</text>
<rect x="38" y="92" width="100" height="36" rx="4" fill="#dbeafe" stroke="#93c5fd" stroke-width="1"/>
<text x="88" y="114" fill="#1e40af" font-size="10" font-weight="600" text-anchor="middle">read A.py</text>
<rect x="148" y="92" width="100" height="36" rx="4" fill="#dbeafe" stroke="#93c5fd" stroke-width="1"/>
<text x="198" y="114" fill="#1e40af" font-size="10" font-weight="600" text-anchor="middle">glob *.py</text>
<rect x="258" y="92" width="110" height="36" rx="4" fill="#fef3c7" stroke="#fbbf24" stroke-width="1"/>
<text x="313" y="114" fill="#92400e" font-size="10" font-weight="600" text-anchor="middle">bash "ls -la"</text>
<rect x="378" y="92" width="100" height="36" rx="4" fill="#fee2e2" stroke="#fca5a5" stroke-width="1"/>
<text x="428" y="114" fill="#991b1b" font-size="10" font-weight="600" text-anchor="middle">write B.py</text>
<rect x="488" y="92" width="100" height="36" rx="4" fill="#dbeafe" stroke="#93c5fd" stroke-width="1"/>
<text x="538" y="114" fill="#1e40af" font-size="10" font-weight="600" text-anchor="middle">read C.py</text>
<!-- 左:教育版 -->
<rect x="20" y="156" width="350" height="230" rx="8" fill="url(#teach)" stroke="#d97706" stroke-width="1.5"/>
<text x="195" y="180" fill="#92400e" font-size="13" font-weight="700" text-anchor="middle">教育版:元の順序で一つずつ実行</text>
<rect x="35" y="192" width="320" height="46" rx="4" fill="#fff" stroke="#fbbf24" stroke-width="0.5"/>
<text x="46" y="209" fill="#92400e" font-size="9" font-family="monospace">for block in response.content:</text>
<text x="46" y="224" fill="#92400e" font-size="9" font-family="monospace"> TOOL_HANDLERS[name](**input)</text>
<text x="195" y="258" fill="#92400e" font-size="10" font-weight="600" text-anchor="middle">結果:5 回の直列呼び出し、batch なし</text>
<rect x="45" y="270" width="300" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="284" fill="#92400e" font-size="8" font-weight="600">1. read A.py</text>
<rect x="45" y="294" width="300" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="308" fill="#92400e" font-size="8" font-weight="600">2. glob *.py</text>
<rect x="45" y="318" width="300" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="332" fill="#92400e" font-size="8" font-weight="600">3. bash "ls -la"</text>
<rect x="45" y="342" width="145" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="356" fill="#92400e" font-size="8" font-weight="600">4. write B.py</text>
<rect x="200" y="342" width="145" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="215" y="356" fill="#92400e" font-size="8" font-weight="600">5. read C.py</text>
<text x="195" y="378" fill="#dc2626" font-size="8" font-weight="600" text-anchor="middle">教育の焦点:まず tool_use 分配を理解し、並列は省略</text>
<!-- 右:Claude Code -->
<rect x="390" y="156" width="350" height="230" rx="8" fill="url(#cc)" stroke="#16a34a" stroke-width="1.5"/>
<text x="565" y="180" fill="#166534" font-size="13" font-weight="700" text-anchor="middle">Claude CodeisConcurrencySafe(input)</text>
<rect x="405" y="192" width="320" height="38" rx="4" fill="#fff" stroke="#86efac" stroke-width="0.5"/>
<text x="416" y="207" fill="#166534" font-size="9" font-family="monospace">各ツール呼び出しを個別に判定:</text>
<text x="416" y="222" fill="#166534" font-size="9" font-family="monospace">tool.isConcurrencySafe(parsedInput) → bool</text>
<text x="565" y="250" fill="#166534" font-size="10" font-weight="600" text-anchor="middle">結果:3 バッチ(連続ブロックごと)</text>
<rect x="400" y="258" width="155" height="50" rx="4" fill="#dcfce7" stroke="#86efac" stroke-width="1"/>
<text x="477" y="276" fill="#166534" font-size="8" font-weight="600" text-anchor="middle">Batch 1</text>
<text x="477" y="289" fill="#166534" font-size="8" text-anchor="middle">並列</text>
<text x="477" y="302" fill="#166534" font-size="7" text-anchor="middle">read A · glob · bash "ls"</text>
<line x1="560" y1="283" x2="575" y2="283" stroke="#16a34a" stroke-width="1" marker-end="url(#arrow-g)"/>
<rect x="580" y="258" width="65" height="50" rx="4" fill="#fee2e2" stroke="#fca5a5" stroke-width="1"/>
<text x="612" y="276" fill="#991b1b" font-size="8" font-weight="600" text-anchor="middle">Batch 2</text>
<text x="612" y="289" fill="#991b1b" font-size="8" text-anchor="middle">直列</text>
<text x="612" y="302" fill="#991b1b" font-size="7" text-anchor="middle">write B</text>
<line x1="650" y1="283" x2="665" y2="283" stroke="#16a34a" stroke-width="1" marker-end="url(#arrow-g)"/>
<rect x="670" y="258" width="55" height="50" rx="4" fill="#dcfce7" stroke="#86efac" stroke-width="1"/>
<text x="697" y="276" fill="#166534" font-size="8" font-weight="600" text-anchor="middle">Batch 3</text>
<text x="697" y="289" fill="#166534" font-size="8" text-anchor="middle">並列</text>
<text x="697" y="302" fill="#166534" font-size="7" text-anchor="middle">read C</text>
<text x="565" y="332" fill="#16a34a" font-size="8" font-weight="600" text-anchor="middle">bash "ls" は安全かつ連続しているため Batch 1 に入る</text>
<text x="565" y="366" fill="#16a34a" font-size="9" font-weight="600" text-anchor="middle">✓ 入力に基づく安全判定、ツール名ハードコードではない</text>
<text x="565" y="380" fill="#16a34a" font-size="9" font-weight="600" text-anchor="middle">✓ 元の順序を保ち、連続する安全呼び出しだけ並列化</text>
<!-- 下部まとめ -->
<rect x="20" y="402" width="720" height="82" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
<text x="40" y="424" fill="#1e3a5f" font-size="12" font-weight="600">核心的な違い</text>
<text x="40" y="444" fill="#475569" font-size="10">• 教育版:response.content の元の順序で一つずつ実行し、並列処理も batch 化もしない</text>
<text x="40" y="460" fill="#475569" font-size="10">• CCisConcurrencySafe(input) で判定し、連続する安全呼び出しを同じ batch にまとめる</text>
<text x="40" y="476" fill="#475569" font-size="10">• 差分の要点:教育版は分配に集中し、CC は順序意味を保ったまま安全な並列を最適化する</text>
</svg>

Before

Width:  |  Height:  |  Size: 7.8 KiB

@@ -1,108 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 500" font-family="system-ui, -apple-system, sans-serif">
<defs>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/><stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
<linearGradient id="teach" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fef3c7"/><stop offset="100%" stop-color="#fde68a"/>
</linearGradient>
<linearGradient id="cc" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#dcfce7"/><stop offset="100%" stop-color="#bbf7d0"/>
</linearGradient>
<marker id="arrow-g" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
</defs>
<rect width="760" height="500" fill="#fafbfc" rx="8"/>
<rect x="0" y="0" width="760" height="36" fill="url(#header)" rx="8"/>
<rect x="0" y="28" width="760" height="8" fill="url(#header)"/>
<text x="380" y="24" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">Tool Concurrency — 教学版 vs Claude Code</text>
<!-- Input tool blocks -->
<rect x="180" y="52" width="400" height="28" rx="14" fill="#f1f5f9" stroke="#94a3b8" stroke-width="1"/>
<text x="380" y="71" fill="#475569" font-size="11" font-weight="600" text-anchor="middle">模型一次返回 5 个工具调用</text>
<rect x="38" y="92" width="100" height="36" rx="4" fill="#dbeafe" stroke="#93c5fd" stroke-width="1"/>
<text x="88" y="114" fill="#1e40af" font-size="10" font-weight="600" text-anchor="middle">read A.py</text>
<rect x="148" y="92" width="100" height="36" rx="4" fill="#dbeafe" stroke="#93c5fd" stroke-width="1"/>
<text x="198" y="114" fill="#1e40af" font-size="10" font-weight="600" text-anchor="middle">glob *.py</text>
<rect x="258" y="92" width="110" height="36" rx="4" fill="#fef3c7" stroke="#fbbf24" stroke-width="1"/>
<text x="313" y="114" fill="#92400e" font-size="10" font-weight="600" text-anchor="middle">bash "ls -la"</text>
<rect x="378" y="92" width="100" height="36" rx="4" fill="#fee2e2" stroke="#fca5a5" stroke-width="1"/>
<text x="428" y="114" fill="#991b1b" font-size="10" font-weight="600" text-anchor="middle">write B.py</text>
<rect x="488" y="92" width="100" height="36" rx="4" fill="#dbeafe" stroke="#93c5fd" stroke-width="1"/>
<text x="538" y="114" fill="#1e40af" font-size="10" font-weight="600" text-anchor="middle">read C.py</text>
<!-- LEFT: Teaching Version -->
<rect x="20" y="156" width="350" height="230" rx="8" fill="url(#teach)" stroke="#d97706" stroke-width="1.5"/>
<text x="195" y="180" fill="#92400e" font-size="13" font-weight="700" text-anchor="middle">教学版:按原始顺序逐个执行</text>
<rect x="35" y="192" width="320" height="46" rx="4" fill="#fff" stroke="#fbbf24" stroke-width="0.5"/>
<text x="46" y="209" fill="#92400e" font-size="9" font-family="monospace">for block in response.content:</text>
<text x="46" y="224" fill="#92400e" font-size="9" font-family="monospace"> TOOL_HANDLERS[name](**input)</text>
<text x="195" y="258" fill="#92400e" font-size="10" font-weight="600" text-anchor="middle">结果:5 次串行调用,不做 batch</text>
<rect x="45" y="270" width="300" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="284" fill="#92400e" font-size="8" font-weight="600">1. read A.py</text>
<rect x="45" y="294" width="300" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="308" fill="#92400e" font-size="8" font-weight="600">2. glob *.py</text>
<rect x="45" y="318" width="300" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="332" fill="#92400e" font-size="8" font-weight="600">3. bash "ls -la"</text>
<rect x="45" y="342" width="145" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="356" fill="#92400e" font-size="8" font-weight="600">4. write B.py</text>
<rect x="200" y="342" width="145" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="215" y="356" fill="#92400e" font-size="8" font-weight="600">5. read C.py</text>
<text x="195" y="378" fill="#dc2626" font-size="8" font-weight="600" text-anchor="middle">教学重点:先理解 tool_use 分发,暂不引入并发执行</text>
<!-- RIGHT: Claude Code -->
<rect x="390" y="156" width="350" height="230" rx="8" fill="url(#cc)" stroke="#16a34a" stroke-width="1.5"/>
<text x="565" y="180" fill="#166534" font-size="13" font-weight="700" text-anchor="middle">Claude CodeisConcurrencySafe(input)</text>
<rect x="405" y="192" width="320" height="38" rx="4" fill="#fff" stroke="#86efac" stroke-width="0.5"/>
<text x="416" y="207" fill="#166534" font-size="9" font-family="monospace">每个工具调用单独判断:</text>
<text x="416" y="222" fill="#166534" font-size="9" font-family="monospace">tool.isConcurrencySafe(parsedInput) → bool</text>
<text x="565" y="250" fill="#166534" font-size="10" font-weight="600" text-anchor="middle">结果:3 个 batch(按连续块分批)</text>
<rect x="400" y="258" width="155" height="50" rx="4" fill="#dcfce7" stroke="#86efac" stroke-width="1"/>
<text x="477" y="276" fill="#166534" font-size="8" font-weight="600" text-anchor="middle">Batch 1</text>
<text x="477" y="289" fill="#166534" font-size="8" text-anchor="middle">并发</text>
<text x="477" y="302" fill="#166534" font-size="7" text-anchor="middle">read A · glob · bash "ls"</text>
<line x1="560" y1="283" x2="575" y2="283" stroke="#16a34a" stroke-width="1" marker-end="url(#arrow-g)"/>
<rect x="580" y="258" width="65" height="50" rx="4" fill="#fee2e2" stroke="#fca5a5" stroke-width="1"/>
<text x="612" y="276" fill="#991b1b" font-size="8" font-weight="600" text-anchor="middle">Batch 2</text>
<text x="612" y="289" fill="#991b1b" font-size="8" text-anchor="middle">串行</text>
<text x="612" y="302" fill="#991b1b" font-size="7" text-anchor="middle">write B</text>
<line x1="650" y1="283" x2="665" y2="283" stroke="#16a34a" stroke-width="1" marker-end="url(#arrow-g)"/>
<rect x="670" y="258" width="55" height="50" rx="4" fill="#dcfce7" stroke="#86efac" stroke-width="1"/>
<text x="697" y="276" fill="#166534" font-size="8" font-weight="600" text-anchor="middle">Batch 3</text>
<text x="697" y="289" fill="#166534" font-size="8" text-anchor="middle">并发</text>
<text x="697" y="302" fill="#166534" font-size="7" text-anchor="middle">read C</text>
<text x="565" y="332" fill="#16a34a" font-size="8" font-weight="600" text-anchor="middle">bash "ls" 是并发安全调用,且和 read/glob 连续,所以留在 Batch 1</text>
<text x="565" y="366" fill="#16a34a" font-size="9" font-weight="600" text-anchor="middle">✓ 按输入判断并发安全,不按工具名硬编码</text>
<text x="565" y="380" fill="#16a34a" font-size="9" font-weight="600" text-anchor="middle">✓ 保留原始顺序,只在连续安全块内部并发</text>
<!-- Bottom Summary -->
<rect x="20" y="402" width="720" height="82" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
<text x="40" y="424" fill="#1e3a5f" font-size="12" font-weight="600">核心差异</text>
<text x="40" y="444" fill="#475569" font-size="10">• 教学版:按 response.content 原始顺序逐个执行,不做并发,也不分 batch</text>
<text x="40" y="460" fill="#475569" font-size="10">• CC:按 isConcurrencySafe(input) 判断,并把连续的并发安全调用合成同一个 batch</text>
<text x="40" y="476" fill="#475569" font-size="10">• 差异重点:教学版聚焦工具分发;CC 在保持顺序语义的同时优化安全并发</text>
</svg>

Before

Width:  |  Height:  |  Size: 7.7 KiB

+2 -77
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20 → s21 → s22
s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20 → s21
> *"ツール実行前に権限を判断"* — 権限パイプラインは、どの操作に承認が必要かを決める。
>
> **Harness レイヤー**: 権限 — ツール実行前に一つのゲートを追加。
@@ -39,7 +39,7 @@ s02 のループは完全に維持される。唯一の変更は、ツール実
![Permission Pipeline](images/permission-pipeline.ja.svg)
**ゲート 1**:ハード拒否リスト。最初に確認し、一致すればブロックメッセージを返す。(教育デモ:単純な文字列マッチングは信頼できるセキュリティ機構ではない — コマンドの変種やシェル展開で回避される可能性がある。CC のアプローチは付録を参照。)
**ゲート 1**:ハード拒否リスト。最初に確認し、一致すればブロックメッセージを返す。このリストは権限ゲートの位置を示すための単純な文字列照合であり、完全なセキュリティ境界ではない
```python
DENY_LIST = [
@@ -153,80 +153,5 @@ python s03_permission/code.py
→ s04 Hooks:ループにフックを追加する。拡張ロジックはフックにぶら下げ、ループはクリーンに保つ。
<details>
<summary>CC ソースコードを深掘り</summary>
> 以下は CC ソースコード `types/permissions.ts`、`utils/permissions/permissions.ts`、`toolExecution.ts`、`utils/permissions/yoloClassifier.ts`、`tools/AgentTool/forkSubagent.ts` の検証に基づく。
### 一、PermissionResult3 種ではなく、4 種
教育版の 3 つのゲート(deny → ask → allow)は CC と完全には対応しない。CC の `PermissionResult` には 4 つの behavior がある(`types/permissions.ts:241-266`):
| behavior | 意味 | 教育版の対応 |
|----------|------|-------------|
| `allow` | 直接許可 | ゲート 3 通過 |
| `deny` | 直接拒否 | ゲート 1 一致 |
| `ask` | ユーザーにダイアログを表示 | ゲート 2 一致 |
| `passthrough` | ツールが意見を表明せず、汎用パイプラインに委ねる | 教育版にはなし |
### 二、本番環境の検証段階
CC のツール呼び出しは 3 つのゲートを通るのではなく、`checkPermissionsAndCallTool()``toolExecution.ts:599-1745`)、hooks、`hasPermissionsToUseToolInner()``utils/permissions/permissions.ts:1158-1310`)、classifier ロジックに分散する複数の段階を経る:
1. **Zod schema 検証**`toolExecution.ts:614-680`)— パラメータの型チェック
2. **validateInput()**`toolExecution.ts:682-733`)— ツールレベルの意味的検証
3. **backfillObservableInput()**`toolExecution.ts:784`)— レガシーフィールドの補完
4. **PreToolUse hooks**`toolExecution.ts:800-862`)— フックが allow/deny/ask を返す
5. **resolveHookPermissionDecision()**`toolExecution.ts:921-931`)— フック + パイプラインの決定を調整
6. **hasPermissionsToUseToolInner()**`permissions.ts:1158-1310`)— 多層ルールチェック:
- ツール全体が deny rule で無効 → `deny`
- ツール全体が ask rule でマーク → `ask`
- `tool.checkPermissions()` ツール自身の判断
- ツール自身が deny を返す → `deny`
- `requiresUserInteraction()``ask`
- コンテンツ関連の ask ルール → `ask`(バイパス不可)
- セキュリティチェック違反 → `ask`(バイパス不可)
- bypassPermissions モード → `allow`
- ツール全体が allow rule で許可 → `allow`
- passthrough → `ask` に変換
### 三、拒否リスト:1 つのファイルではなく、8 つのソース
CC には単一の deny list はない。権限ルールは 8 つのソースから来る(`types/permissions.ts:54-62`):
| ソース | 設定場所 |
|--------|---------|
| `userSettings` | `~/.claude/settings.json` |
| `projectSettings` | `.claude/settings.json` |
| `localSettings` | `settings.local.json` |
| `flagSettings` | フィーチャーフラグ |
| `policySettings` | 企業管理ポリシー |
| `cliArg` | `--allowedTools` / `--deniedTools` |
| `command` | インラインコマンド |
| `session` | セッション内一時承認 |
各ルールの形式:`{ toolName: "Bash", ruleBehavior: "deny", ruleContent: "npm publish:*" }`。複数ソースのルールは統合され、高優先度ソースが低優先度を上書きする(低→高:user < project < local < flag < policy、さらに cliArg、command、session)。
### 四、isDestructive() とは
CC では `isDestructive``Tool.ts:405-406`)は**純粋に UI 表示用** — ツール一覧に `[destructive]` ラベルを表示するだけ。権限決定には参加しない。デフォルトではすべてのツールが `false` を返す。ExitWorktreeremove 時)と MCP ツール(`annotations.destructiveHint` に依存)のみがオーバーライドする。
### 五、YoloClassifier(自動承認)
CC の auto モードでは、毎回ダイアログを表示するわけではない。`classifyYoloAction``utils/permissions/yoloClassifier.ts:1012`)はツール呼び出し + 会話コンテキストを分類器 LLM に送って安全性を判断する。まず acceptEdits モードのシミュレーションを試み(`permissions.ts:620-656`、acceptEdits が許可すれば → 自動承認)、次にセーフツールホワイトリストを確認し(`permissions.ts:658-686`)、最後に分類器を呼び出す。分類器が連続して拒否しすぎた場合 → 手動承認にフォールバック。
### 六、権限バブリング
サブ AgentAgentTool 経由でフォークされたもの)の `permissionMode``'bubble'` に設定される(`forkSubagent.ts:50`)。これは権限ダイアログが**親 Agent のターミナルにバブルアップ**することを意味する。サブ Agent で黙って拒否されるのではない。Bash 分類器はこの過程で引き続き実行され — 権限ダイアログを表示しつつ、バックグラウンドで自動承認可能か判断する。
### 教育版の単純化は意図的
- 多段階パイプライン → 3 ゲート:理解のハードルが大幅に下がる
- 8 ルールソース → 1 つのローカル DENY_LIST:概念量を制御可能
- isDestructive → 省略(教育版には UI レイヤーがなく、CC でも権限決定には参加しない)
- YoloClassifier → 省略(追加の LLM 呼び出しとテレメトリに依存)
- 権限バブリング → 省略(s15 でマルチ Agent を扱う)
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+2 -77
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20 → s21 → s22
s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20 → s21
> *"Check permissions before executing"* — The permission pipeline decides which operations need approval.
>
> **Harness Layer**: Permission — a gate before tool execution.
@@ -39,7 +39,7 @@ None of the three gates match → execute directly. Most routine operations take
![Permission Pipeline](images/permission-pipeline.en.svg)
**Gate 1**: A hard deny list. Check first; if matched, return a block message. (Teaching demo: simple string matching is not a reliable security mechanism — command variants and shell expansion can bypass it. CC's approach is in the appendix.)
**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.
```python
DENY_LIST = [
@@ -153,80 +153,5 @@ Permission checks are in place — but every check is hardcoded as `check_permis
→ s04 Hooks: Add hooks to the loop. Extension logic hangs on hooks; the loop stays clean.
<details>
<summary>Dive into CC Source Code</summary>
> The following is based on a review of CC source code `types/permissions.ts`, `utils/permissions/permissions.ts`, `toolExecution.ts`, `utils/permissions/yoloClassifier.ts`, `tools/AgentTool/forkSubagent.ts`.
### 1. PermissionResult: Not 3, but 4
The teaching version's three gates (deny → ask → allow) don't fully correspond to CC. CC's `PermissionResult` has 4 behaviors (`types/permissions.ts:241-266`):
| behavior | Meaning | Teaching Version Equivalent |
|----------|---------|---------------------------|
| `allow` | Allow directly | Gate 3 passes |
| `deny` | Deny directly | Gate 1 matches |
| `ask` | Show dialog to user | Gate 2 matches |
| `passthrough` | Tool doesn't express opinion, passes to generic pipeline | Not in teaching version |
### 2. Production Verification Stages
CC's tool calls don't go through three gates — they go through multiple stages distributed across `checkPermissionsAndCallTool()` (`toolExecution.ts:599-1745`), hooks, `hasPermissionsToUseToolInner()` (`utils/permissions/permissions.ts:1158-1310`), and classifier logic:
1. **Zod schema validation** (`toolExecution.ts:614-680`) — parameter type checking
2. **validateInput()** (`toolExecution.ts:682-733`) — tool-level semantic validation
3. **backfillObservableInput()** (`toolExecution.ts:784`) — backfill legacy fields
4. **PreToolUse hooks** (`toolExecution.ts:800-862`) — hooks can return allow/deny/ask
5. **resolveHookPermissionDecision()** (`toolExecution.ts:921-931`) — coordinate hook + pipeline decisions
6. **hasPermissionsToUseToolInner()** (`permissions.ts:1158-1310`) — multi-layer rule check:
- Entire tool disabled by deny rule → `deny`
- Entire tool flagged by ask rule → `ask`
- `tool.checkPermissions()` tool's own judgment
- Tool itself returns deny → `deny`
- `requiresUserInteraction()``ask`
- Content-related ask rules → `ask` (not bypassable)
- Security check violation → `ask` (not bypassable)
- bypassPermissions mode → `allow`
- Entire tool allowed by allow rule → `allow`
- passthrough → converted to `ask`
### 3. Deny List: Not One File, but 8 Sources
CC doesn't have a single deny list. Permission rules come from 8 sources (`types/permissions.ts:54-62`):
| Source | Configuration Location |
|--------|----------------------|
| `userSettings` | `~/.claude/settings.json` |
| `projectSettings` | `.claude/settings.json` |
| `localSettings` | `settings.local.json` |
| `flagSettings` | Feature flags |
| `policySettings` | Enterprise management policy |
| `cliArg` | `--allowedTools` / `--deniedTools` |
| `command` | Inline command |
| `session` | In-session temporary authorization |
Each rule format: `{ toolName: "Bash", ruleBehavior: "deny", ruleContent: "npm publish:*" }`. Rules from multiple sources are merged, with higher-priority sources overriding lower ones (low to high: user < project < local < flag < policy, plus cliArg, command, session).
### 4. What is isDestructive()
In CC, `isDestructive` (`Tool.ts:405-406`) is **purely for UI display** — showing a `[destructive]` label in the tool list. It doesn't participate in permission decisions. All tools return `false` by default. Only ExitWorktree (on remove) and MCP tools (depending on `annotations.destructiveHint`) override it.
### 5. YoloClassifier (Auto-Approval)
In CC's auto mode, it doesn't pop a dialog every time. `classifyYoloAction` (`utils/permissions/yoloClassifier.ts:1012`) sends the tool call + conversation context to a classifier LLM to judge safety. It first tries acceptEdits mode simulation (`permissions.ts:620-656`, if acceptEdits allows → auto-approve), then checks the safe tool whitelist (`permissions.ts:658-686`), and finally calls the classifier. If the classifier rejects too many times in a row → falls back to manual approval.
### 6. Permission Bubbling
A sub-Agent's (forked via AgentTool) `permissionMode` is set to `'bubble'` (`forkSubagent.ts:50`). This means permission dialogs **bubble up to the parent Agent's terminal**, rather than being silently denied in the sub-Agent. The Bash classifier continues running during this process — displaying the permission dialog while judging in the background whether auto-approval is possible.
### The Teaching Version's Simplification Is Intentional
- Multi-stage pipeline → 3 gates: dramatically lower barrier to understanding
- 8 rule sources → 1 local DENY_LIST: manageable concept count
- isDestructive → omitted (teaching version has no UI layer, and it doesn't participate in permission decisions in CC either)
- YoloClassifier → omitted (depends on additional LLM calls and telemetry)
- Permission bubbling → omitted (s15 covers multi-Agent)
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+2 -77
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20 → s21 → s22
s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20 → s21
> *"工具执行前先做权限判断"* — 权限管线决定哪些操作需要审批。
>
> **Harness 层**: 权限 — 在工具执行前加一道门。
@@ -39,7 +39,7 @@ s02 的循环完全保留。唯一的变动在工具执行前插入 `check_permi
![Permission Pipeline](images/permission-pipeline.svg)
**闸门 1**:一张硬拒绝表,先查,命中就返回阻止信息。(教学示意:简单字符串匹配不是可靠安全机制,命令变体和 shell 展开可能绕过。CC 的做法见附录。)
**闸门 1**:一张硬拒绝表,先查,命中就返回阻止信息。这张表使用简单字符串匹配来说明权限闸门的位置,不能视为完整的安全边界。
```python
DENY_LIST = [
@@ -153,80 +153,5 @@ python s03_permission/code.py
s04 Hooks → 给循环加钩子,扩展逻辑挂在钩子上,循环保持干净。
<details>
<summary>深入 CC 源码</summary>
> 以下基于 CC 源码 `types/permissions.ts`、`utils/permissions/permissions.ts`、`toolExecution.ts`、`utils/permissions/yoloClassifier.ts`、`tools/AgentTool/forkSubagent.ts` 的核查。
### 一、PermissionResult:不是 3 种,是 4 种
教学版的三道闸门(deny → ask → allow)和 CC 不完全对应。CC 的 `PermissionResult` 有 4 个 behavior`types/permissions.ts:241-266`):
| behavior | 含义 | 教学版对应 |
|----------|------|-----------|
| `allow` | 直接允许 | 闸门 3 通过 |
| `deny` | 直接拒绝 | 闸门 1 命中 |
| `ask` | 弹出对话框问用户 | 闸门 2 命中 |
| `passthrough` | 工具不表态,交给通用管线决定 | 教学版无 |
### 二、生产版的验证阶段
CC 的工具调用不是经过三道闸门,而是经过多个阶段,分布在 `checkPermissionsAndCallTool()``toolExecution.ts:599-1745`)、hooks、`hasPermissionsToUseToolInner()``utils/permissions/permissions.ts:1158-1310`)和 classifier 逻辑里:
1. **Zod schema 验证**`toolExecution.ts:614-680`)— 参数类型检查
2. **validateInput()**`toolExecution.ts:682-733`)— 工具级语义验证
3. **backfillObservableInput()**`toolExecution.ts:784`)— 补全遗留字段
4. **PreToolUse hooks**`toolExecution.ts:800-862`)— 钩子可以返回 allow/deny/ask
5. **resolveHookPermissionDecision()**`toolExecution.ts:921-931`)— 协调钩子+管线决策
6. **hasPermissionsToUseToolInner()**`permissions.ts:1158-1310`)— 多层规则检查:
- 整个工具被 deny rule 禁用 → `deny`
- 整个工具被 ask rule 标记 → `ask`
- `tool.checkPermissions()` 工具自己的判断
- 工具自己返回 deny → `deny`
- `requiresUserInteraction()``ask`
- 内容相关的 ask 规则 → `ask`(不可绕过)
- 安全检查违规 → `ask`(不可绕过)
- bypassPermissions 模式 → `allow`
- 整个工具被 allow rule 放行 → `allow`
- passthrough → 转为 `ask`
### 三、拒绝列表:不是一个文件,是 8 个来源
CC 没有单一的 deny list。权限规则来自 8 个来源(`types/permissions.ts:54-62`):
| 来源 | 配置位置 |
|------|---------|
| `userSettings` | `~/.claude/settings.json` |
| `projectSettings` | `.claude/settings.json` |
| `localSettings` | `settings.local.json` |
| `flagSettings` | Feature flags |
| `policySettings` | 企业管理策略 |
| `cliArg` | `--allowedTools` / `--deniedTools` |
| `command` | 内联命令 |
| `session` | 会话内临时授权 |
每条规则格式:`{ toolName: "Bash", ruleBehavior: "deny", ruleContent: "npm publish:*" }`。多个来源的规则合并,高优先级来源覆盖低优先级(从低到高:user < project < local < flag < policy,加上 cliArg、command、session)。
### 四、isDestructive() 是什么
CC 中 `isDestructive``Tool.ts:405-406`)**纯粹是 UI 展示用的**——在工具列表里显示 `[destructive]` 标签。它不参与权限决策。默认所有工具都返回 `false`。只有 ExitWorktreeremove 时)和 MCP 工具(依赖 `annotations.destructiveHint`)覆写了它。
### 五、YoloClassifier(自动审批)
CC 的 auto 模式下,不会每次都弹对话框。`classifyYoloAction``utils/permissions/yoloClassifier.ts:1012`)把工具调用 + 对话上下文发给一个分类器 LLM 判断是否安全。先尝试 acceptEdits 模式模拟(`permissions.ts:620-656`,如果 acceptEdits 允许 → 直接批准),再查安全工具白名单(`permissions.ts:658-686`),最后才调分类器。分类器连续拒绝太多次 → 回退到人工审批。
### 六、权限冒泡
子 Agent(通过 AgentTool fork 出来的)的 `permissionMode` 设为 `'bubble'``forkSubagent.ts:50`)。意思是权限弹窗**冒泡到父 Agent 的终端**,而不是在子 Agent 里静默拒绝。Bash 分类器在这个过程中继续跑——给权限对话框显示的同时在后台判断是否可以自动批准。
### 教学版的简化是刻意的
- 多阶段管线 → 3 道闸门:理解门槛大幅降低
- 8 个规则来源 → 1 个本地 DENY_LIST:概念量可控
- isDestructive → 忽略(教学版没有 UI 层,CC 里它也不参与权限决策)
- YoloClassifier → 省略(依赖于额外的 LLM 调用和遥测系统)
- 权限冒泡 → 省略(s15 才涉及多 Agent)
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+5 -64
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s20 → s21 → s22
s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s20 → s21
> *"ループに掛ける、ループには書き込まない"* — フックがツール実行の前後に拡張ロジックを注入する。
>
@@ -48,7 +48,7 @@ s03 のループと権限ロジックは完全に保持される。唯一の変
| UserPromptSubmit | ユーザー入力後、LLM に入る前 | 入力バリデーション、コンテキスト注入 |
| PreToolUse | ツール実行前 | 権限チェック、ログ記録 |
| PostToolUse | ツール実行後 | 副作用(自動 git add など)、出力チェック |
| Stop | ループが終了する直前 | クリーンアップ(CC は強制続行もサポート) |
| Stop | ループが終了する直前 | 後処理、ループを続行するかの判断 |
拡張は `register_hook()` で追加する。ループは `trigger_hooks()` を呼ぶだけ。
@@ -77,9 +77,9 @@ def trigger_hooks(event: str, *args):
return None
```
教学版では、PreToolUse の非 None 戻り値は実行阻止を意味し、Stop の非 None 戻り値は強制続行を意味する。UserPromptSubmit と PostToolUse の戻り値は未使用
`PreToolUse``None` 以外を返すと、現在のツール実行は中止される。`Stop``None` 以外を返すと、ループは続行する。`UserPromptSubmit``PostToolUse` の戻り値は制御フローに影響しない
**UserPromptSubmit**ユーザー入力後、LLM に入る前に発火。CC では入力の横取りや変更が可能、教学版はログ出力のみ
**UserPromptSubmit**ユーザー入力後、LLM に入る前に発火する。以下の hook は現在の作業ディレクトリを記録する
```python
def context_inject_hook(query: str) -> str | None:
@@ -130,7 +130,7 @@ register_hook("PreToolUse", log_hook)
register_hook("PostToolUse", large_output_hook)
```
**Stop**ループが終了する直前に発火(`stop_reason != "tool_use"`)。教学版ではクリーンアップ統計を印刷
**Stop**ループが終了する直前に発火する`stop_reason != "tool_use"`)。以下の hook は終了時の統計を出力する
```python
def summary_hook(messages: list) -> str | None:
@@ -220,64 +220,5 @@ Agent は安全に操作を実行できるようになった。しかし「ま
→ s05 TodoWriteAgent に計画ツールを与える。まずリストを作り、それから実行。
<details>
<summary>CC ソースコードを深掘り</summary>
> 以下は CC ソースコード `toolHooks.ts`650 行)、`hooks.ts`、`stopHooks.ts`、`coreTypes.ts` の完全分析に基づく。
### 一、Hook イベント:4 つではなく 27 個
教育版は PreToolUse と PostToolUse のみを取り上げる。CC には実際に 27 のフックイベントがある(`coreTypes.ts:25-53`):
| カテゴリ | イベント |
|----------|---------|
| ツール関連 | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` |
| セッション関連 | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` |
| ユーザー対話 | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` |
| サブエージェント | `SubagentStart`, `SubagentStop` |
| 圧縮関連 | `PreCompact`, `PostCompact` |
| チーム関連 | `TeammateIdle`, `TaskCreated`, `TaskCompleted` |
| その他 | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` |
教育版は 4 つのコアイベント(UserPromptSubmit、PreToolUse、PostToolUse、Stop)のみを取り上げる。これらで agent cycle の重要ノードを全てカバーできる。残り 23 個は同じパターン。
### 二、HookResult よく使うフィールド抜粋
CC の `HookResult``types/hooks.ts:260-275`)には 14 のフィールドがある。よく使うもの:
| フィールド | 型 | 用途 |
|-----------|-----|------|
| `message` | Message | オプションの UI メッセージ |
| `blockingError` | HookBlockingError | ブロッキングエラー → 会話に注入してモデルが自己修正 |
| `outcome` | success/blocking/non_blocking_error/cancelled | 実行結果 |
| `preventContinuation` | boolean | 後続実行を阻止 |
| `stopReason` | string | 停止理由の説明 |
| `permissionBehavior` | allow/deny/ask/passthrough | フックが権限決定を返す |
| `updatedInput` | Record | ツール入力の変更 |
| `additionalContext` | string | 追加コンテキスト |
| `updatedMCPToolOutput` | unknown | MCP ツール出力の変更 |
### 三、重要な不変条件:Hook 'allow' は deny/ask ルールをバイパスできない
これは CC 権限システムで最も重要なセキュリティ設計(`toolHooks.ts:325-331`):**フックが allow を返しても、settings.json の deny/ask ルールをチェックする。** ユーザーのフックスクリプトが「許可」と言っても、settings.json でそのツールが無効になっていれば、操作は阻止される。
教育版にはこの階層がない。フックが非 None を返せば直接中断。教育目的では十分だが、本番環境ではセキュリティホールになる。
### 四、stopHookActive 機構
CC の Stop フックには無限ループ防止機構がある(`query.ts:212,1300`):`stopHookActive` 状態フィールド。Stop フックが blockingError を発生させると、ループは `stopHookActive: true` で次のラウンドに再入する。後続のイテレーションではこのフラグを見て Stop フックを再トリガーしない。これで「永久に止まらない」バグを防ぐ:モデルが自己修正 → Stop フックが再度エラー → モデルが再修正 → Stop フックが再度エラー... を防止。
### 五、hook_stopped_continuation
PostToolUse フックが `preventContinuation: true` を返すと、`hook_stopped_continuation` アタッチメントが生成される(`toolHooks.ts:117-130`)。query.tsL1388-1393)はそれを検出して `shouldPreventContinuation = true` を設定し、ループが終了する。これは「フックが Agent を優雅に停止させる」機構 — クラッシュではなく、完了。
### 教育版の簡略化は意図的
- 27 イベント → 4UserPromptSubmit/PreToolUse/PostToolUse/Stop):agent cycle の重要ノードをカバー
- 14 フィールド → 単純な戻り値(None = 続行、非 None = 中断/続行):認知負荷を最小限に
- Hook allow vs deny/ask の不変条件 → 省略:教育版に settings.json 層はない
- stopHookActive → 省略:教育版の Stop フックは単純な続行のみ、無限ループ防止は不要
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+5 -64
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s20 → s21 → s22
s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s20 → s21
> *"Hang on the loop, don't write into it"* — Hooks inject extension logic before and after tool execution.
>
@@ -48,7 +48,7 @@ Four events, covering a complete agent cycle:
| UserPromptSubmit | After user input, before entering LLM | Input validation, context injection |
| PreToolUse | Before tool execution | Permission checks, logging |
| PostToolUse | After tool execution | Side effects (auto git add etc.), output checking |
| Stop | When the loop is about to exit | Cleanup (CC also supports force continuation) |
| Stop | When the loop is about to exit | Cleanup, decide whether the loop continues |
Extensions are added via `register_hook()`. The loop only calls `trigger_hooks()`.
@@ -77,9 +77,9 @@ def trigger_hooks(event: str, *args):
return None
```
In the teaching version, PreToolUse returning non-None means block execution; Stop returning non-None means force continuation. UserPromptSubmit and PostToolUse return values are unused.
When `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.
**UserPromptSubmit**, triggers after user input, before entering the LLM. CC can intercept or modify input; the teaching version only logs:
**UserPromptSubmit** triggers after user input and before entering the LLM. The following hook records the current working directory:
```python
def context_inject_hook(query: str) -> str | None:
@@ -130,7 +130,7 @@ register_hook("PreToolUse", log_hook)
register_hook("PostToolUse", large_output_hook)
```
**Stop**, triggers when the loop is about to exit (`stop_reason != "tool_use"`). The teaching version prints a cleanup summary:
**Stop** triggers when the loop is about to exit (`stop_reason != "tool_use"`). The following hook prints a cleanup summary:
```python
def summary_hook(messages: list) -> str | None:
@@ -220,64 +220,5 @@ The Agent can now safely execute operations. But does it ever stop to think "wha
→ s05 TodoWrite: Give the Agent a planning tool. Make a list first, then execute.
<details>
<summary>Dive into CC Source Code</summary>
> The following is based on a complete analysis of CC source code `toolHooks.ts` (650 lines), `hooks.ts`, `stopHooks.ts`, and `coreTypes.ts`.
### 1. Hook Events: Not Just 4, but 27
The teaching version covers only PreToolUse and PostToolUse. CC actually has 27 hook events (`coreTypes.ts:25-53`):
| Category | Events |
|----------|--------|
| Tool-related | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` |
| Session-related | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` |
| User interaction | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` |
| Sub-agents | `SubagentStart`, `SubagentStop` |
| Compaction-related | `PreCompact`, `PostCompact` |
| Team-related | `TeammateIdle`, `TaskCreated`, `TaskCompleted` |
| Other | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` |
The teaching version covers only 4 core events (UserPromptSubmit, PreToolUse, PostToolUse, Stop) because they cover every critical node of a complete agent cycle. The other 23 follow the same pattern.
### 2. HookResult Common Fields
CC's `HookResult` (`types/hooks.ts:260-275`) has 14 fields. Common ones:
| Field | Type | Purpose |
|-------|------|---------|
| `message` | Message | Optional UI message |
| `blockingError` | HookBlockingError | Blocking error → injected into conversation for model self-correction |
| `outcome` | success/blocking/non_blocking_error/cancelled | Execution result |
| `preventContinuation` | boolean | Prevent subsequent execution |
| `stopReason` | string | Stop reason description |
| `permissionBehavior` | allow/deny/ask/passthrough | Hook returns permission decision |
| `updatedInput` | Record | Modify tool input |
| `additionalContext` | string | Additional context |
| `updatedMCPToolOutput` | unknown | MCP tool output modification |
### 3. Key Invariant: Hook 'allow' Cannot Bypass deny/ask Rules
This is the most important security design in CC's permission system (`toolHooks.ts:325-331`): **when a hook returns allow, it still checks settings.json deny/ask rules.** Even if the user's hook script says "allow", if the tool is disabled in settings.json, the operation is still blocked.
The teaching version doesn't have this layer; hooks returning non-None directly interrupt. This is sufficient for teaching, but would create a security vulnerability in production.
### 4. stopHookActive Mechanism
CC's Stop hooks have an infinite-loop prevention mechanism (`query.ts:212,1300`): the `stopHookActive` state field. When stop hooks produce a blockingError, the loop re-enters with `stopHookActive: true`. Subsequent iterations see this flag and don't trigger stop hooks again. This prevents a never-stopping bug: model self-corrects → stop hook errors again → model self-corrects again → stop hook errors again...
### 5. hook_stopped_continuation
When PostToolUse hooks return `preventContinuation: true`, a `hook_stopped_continuation` attachment is produced (`toolHooks.ts:117-130`). query.ts (L1388-1393) detects it and sets `shouldPreventContinuation = true`, causing the loop to exit. This is the mechanism for "hooks gracefully shut down the Agent" — not a crash, but a completion.
### Teaching Version Simplifications Are Intentional
- 27 events → 4 (UserPromptSubmit/PreToolUse/PostToolUse/Stop): covers agent cycle critical nodes
- 14 fields → simple return values (None = continue, non-None = interrupt/continue): minimal cognitive load
- Hook allow vs deny/ask invariant → omitted: teaching version has no settings.json layer
- stopHookActive → omitted: teaching version Stop hook only does simple continuation, no infinite-loop prevention needed
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+5 -64
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s20 → s21 → s22
s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s20 → s21
> *"挂在循环上, 不写进循环里"* — hook 在工具执行前后注入扩展逻辑。
>
@@ -48,7 +48,7 @@ s03 的循环和权限逻辑完全保留。唯一的变动是把 `check_permissi
| UserPromptSubmit | 用户输入提交后、进入 LLM 前 | 输入验证、注入上下文 |
| PreToolUse | 工具执行前 | 权限检查、日志记录 |
| PostToolUse | 工具执行后 | 副作用(自动 git add 等)、输出检查 |
| Stop | 循环即将退出时 | 收尾清理CC 还支持强制续跑) |
| Stop | 循环即将退出时 | 收尾清理、决定是否继续循环 |
扩展通过 `register_hook()` 添加,循环只调用 `trigger_hooks()`
@@ -77,9 +77,9 @@ def trigger_hooks(event: str, *args):
return None
```
教学版中,PreToolUse 的非 None 返回值会阻止本次工具执行,Stop 的非 None 返回值会强制续跑。UserPromptSubmit 和 PostToolUse 的返回值未被使用
`PreToolUse` 返回非 `None` 时,本次工具执行被阻止;`Stop` 返回非 `None` 时,循环继续。`UserPromptSubmit``PostToolUse` 的返回值不参与控制流
**UserPromptSubmit**用户输入提交后、进入 LLM 前触发。CC 中可以拦截或修改输入,教学版只做日志演示
**UserPromptSubmit**用户输入提交后、进入 LLM 前触发。以下 hook 记录当前工作目录
```python
def context_inject_hook(query: str) -> str | None:
@@ -130,7 +130,7 @@ register_hook("PreToolUse", log_hook)
register_hook("PostToolUse", large_output_hook)
```
**Stop**循环即将退出时触发(`stop_reason != "tool_use"`)。教学版用于打印收尾统计:
**Stop**循环即将退出时触发(`stop_reason != "tool_use"`)。以下 hook 打印收尾统计:
```python
def summary_hook(messages: list) -> str | None:
@@ -220,64 +220,5 @@ Agent 现在能安全执行操作了。但它有没有停下来想过"我应该
s05 TodoWrite → 给 Agent 一个计划工具。先列清单,再做。
<details>
<summary>深入 CC 源码</summary>
> 以下基于 CC 源码 `toolHooks.ts`650 行)、`hooks.ts`、`stopHooks.ts`、`coreTypes.ts` 的完整分析。
### 一、Hook 事件:不止这 4 个,而是 27 个
教学版只讲了 PreToolUse 和 PostToolUse。CC 实际有 27 个 hook 事件(`coreTypes.ts:25-53`):
| 类别 | 事件 |
|------|------|
| 工具相关 | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` |
| 会话相关 | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` |
| 用户交互 | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` |
| 子 Agent | `SubagentStart`, `SubagentStop` |
| 压缩相关 | `PreCompact`, `PostCompact` |
| 团队相关 | `TeammateIdle`, `TaskCreated`, `TaskCompleted` |
| 其他 | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` |
教学版只讲 4 个核心事件(UserPromptSubmit、PreToolUse、PostToolUse、Stop),因为它们覆盖了一个完整 agent cycle 的关键节点。其他 23 个都是同样的模式。
### 二、HookResult 常用字段摘录
CC 的 `HookResult``types/hooks.ts:260-275`)有 14 个字段,以下是常用字段:
| 字段 | 类型 | 用途 |
|------|------|------|
| `message` | Message | 可选 UI 消息 |
| `blockingError` | HookBlockingError | 阻塞错误 → 注入对话让模型自纠 |
| `outcome` | success/blocking/non_blocking_error/cancelled | 执行结果 |
| `preventContinuation` | boolean | 阻止后续执行 |
| `stopReason` | string | 停止原因描述 |
| `permissionBehavior` | allow/deny/ask/passthrough | hook 返回权限决策 |
| `updatedInput` | Record | 修改工具输入 |
| `additionalContext` | string | 附加上下文 |
| `updatedMCPToolOutput` | unknown | MCP 工具输出修改 |
### 三、关键不变式:Hook 'allow' 不能绕过 deny/ask 规则
这是 CC 权限系统最重要的安全设计(`toolHooks.ts:325-331`):**hook 返回 allow 时,仍然要检查 settings.json 的 deny/ask 规则**。即使用户的 hook 脚本说"允许",如果在 settings.json 中禁用了这个工具,操作仍然会被阻止。
教学版没有这个层次,只把 PreToolUse 的非 None 返回值解释为阻止本次工具执行。这在教学场景中够了,但在生产环境中会形成安全漏洞。
### 四、stopHookActive 机制
CC 的 Stop hooks 有一个防无限循环机制(`query.ts:212,1300`):`stopHookActive` 状态字段。当 stop hooks 产生 blockingError 时,循环带 `stopHookActive: true` 重入下一轮。后续迭代中 stop hooks 看到这个标志就不会再次触发。这防止了一个永不停机的 bug:模型自纠后 stop hook 再次报错 → 模型再自纠 → stop hook 再报错...
### 五、hook_stopped_continuation
PostToolUse hooks 返回 `preventContinuation: true` 时,会产生一个 `hook_stopped_continuation` 附件(`toolHooks.ts:117-130`)。query.tsL1388-1393)检测到后设置 `shouldPreventContinuation = true`,循环退出。这是 "hook 优雅地让 Agent 停机" 的机制,不是崩溃,是完成。
### 教学版的简化是刻意的
- 27 个事件 → 4 个(UserPromptSubmit/PreToolUse/PostToolUse/Stop):覆盖 agent cycle 关键节点
- 14 个字段 → 简单的返回值(None = 继续,非 None = 阻止/续跑):心智负担降到最低
- Hook allow vs deny/ask 不变式 → 省略:教学版没有 settings.json 层
- stopHookActive → 省略:教学版 Stop hook 只做简单续跑,不涉及防无限循环机制
</details>
<!-- translation-sync: zh@v1, en@v0, ja@v0 -->
+1 -1
View File
@@ -159,7 +159,7 @@ def register_hook(event: str, callback):
def trigger_hooks(event: str, *args):
for callback in HOOKS[event]:
result = callback(*args)
if result is not None: # teaching shortcut: block this tool call
if result is not None: # A hook result blocks this tool call.
return result
return None
+1 -1
View File
@@ -57,7 +57,7 @@
<text x="460" y="132" fill="#166534" font-size="9" font-weight="600" text-anchor="middle">PreToolUse</text>
<rect x="396" y="140" width="128" height="18" rx="3" fill="#dcfce7" stroke="#16a34a" stroke-width="0.8"/>
<text x="460" y="153" fill="#166534" font-size="8" text-anchor="middle">permission_hook · log_hook</text>
<text x="460" y="176" fill="#64748b" font-size="8" text-anchor="middle">Teaching: non-None → block</text>
<text x="460" y="176" fill="#64748b" font-size="8" text-anchor="middle">non-None → block</text>
<!-- PreToolUse Block → branch down -->
<line x1="460" y1="184" x2="460" y2="218" stroke="#dc2626" stroke-width="2" marker-end="url(#arrow-red)"/>

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

+1 -1
View File
@@ -57,7 +57,7 @@
<text x="460" y="132" fill="#166534" font-size="9" font-weight="600" text-anchor="middle">PreToolUse</text>
<rect x="396" y="140" width="128" height="18" rx="3" fill="#dcfce7" stroke="#16a34a" stroke-width="0.8"/>
<text x="460" y="153" fill="#166534" font-size="8" text-anchor="middle">permission_hook · log_hook</text>
<text x="460" y="176" fill="#64748b" font-size="8" text-anchor="middle">教育版: 非 None → ブロック</text>
<text x="460" y="176" fill="#64748b" font-size="8" text-anchor="middle">非 None → ブロック</text>
<!-- PreToolUse 中断 → 下に分岐 -->
<line x1="460" y1="184" x2="460" y2="218" stroke="#dc2626" stroke-width="2" marker-end="url(#arrow-red)"/>

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

+1 -1
View File
@@ -57,7 +57,7 @@
<text x="460" y="132" fill="#166534" font-size="9" font-weight="600" text-anchor="middle">PreToolUse</text>
<rect x="396" y="140" width="128" height="18" rx="3" fill="#dcfce7" stroke="#16a34a" stroke-width="0.8"/>
<text x="460" y="153" fill="#166534" font-size="8" text-anchor="middle">permission_hook · log_hook</text>
<text x="460" y="176" fill="#64748b" font-size="8" text-anchor="middle">教学版:非 None → 阻止</text>
<text x="460" y="176" fill="#64748b" font-size="8" text-anchor="middle">非 None → 阻止</text>
<!-- PreToolUse 阻止 → 向下引出 -->
<line x1="460" y1="184" x2="460" y2="218" stroke="#dc2626" stroke-width="2" marker-end="url(#arrow-red)"/>

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

+2 -24
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s20 → s21 → s22
s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s20 → s21
> *"計画なき agent は風の向くままに"* — まず手順を列挙してから実行。長いタスクで見落としが減る。
>
@@ -81,7 +81,7 @@ TOOLS = [
TOOL_HANDLERS["todo_write"] = run_todo_write
```
**Nag リマインダー**モデルが連続 3 ラウンド `todo_write` を呼び出さないとき、リマインダーが自動的に注入される(教育用機構、CC ソースコードに固定ラウンド数のロジックはない)
**Nag リマインダー**モデルが 3 ラウンド連続で `todo_write` を呼び出さなかった場合、リマインダーが自動的に注入される:
```python
if rounds_since_todo >= 3 and messages:
@@ -132,27 +132,5 @@ Agent は計画できるようになった。しかしタスクが大きすぎ
→ s06 Subagent:大きなタスクをサブタスクに分割し、それぞれを独立した Agent に任せる。それぞれが独自のクリーンなコンテキストを持ち、相互汚染がない。
<details>
<summary>CC ソースコードを深掘り</summary>
Claude Code には、目的は近いがストレージとツール契約が独立した二つの計画機構がある:
- **TodoWrite**:現在のセッション向けの軽量チェックリスト。呼び出しごとにリスト全体を置き換え、教育版もプロセスメモリに保持して終了時に消える
- **Task ツール(s12)**:安定 ID を持つ個別タスクレコードで、依存関係、ownership、永続化を扱う
現在の対話型セッションは構造化 Task ツールを既定で使い、TodoWrite は非対話型や Agent SDK などの互換サーフェスに残る。公開範囲はリリースや設定で変わり得る。同じ schema のインプレース更新ではなく独立した機構であり、s05 は軽量なチェックリスト契約だけを扱う。
教育版は実際のソースコードにある `activeForm` フィールドを省略している(`utils/todo/types.ts:8-15`)。CC は UI スピナーに「何をしているか」を表示するために使用するが、教育版は端末出力のみでこのフィールドは不要。
教育版の Nag リマインダー(3 ラウンド未更新で注入)は教育用機構。CC ソースコードに固定「3 ラウンド」のロジックはなく、最も近いのは `TodoWriteTool.ts:72-107` で 3 つ以上の todo が全て完了しているのに verification 項目がない場合に verification nudge を追加する処理。
Task System の TodoWrite に対する核心的な増分:
- メモリリストではなくファイル永続化(Claude 設定ディレクトリ下 `tasks/{taskListId}/{taskId}.json`
- 平坦なリストではなく `blockedBy` 依存グラフ
- ロックなしではなく `proper-lockfile` による並行安全性
- 一つのツールではなく四つの独立ツール(Create/Get/Update/List
- TaskCreated / TaskCompleted フック(`TaskCreateTool.ts:80-129``TaskUpdateTool.ts:231-260`)による外部システム統合
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+2 -24
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s20 → s21 → s22
s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s20 → s21
> *"An agent without a plan goes wherever the wind blows"* — List the steps first, then execute. Complex tasks are less likely to miss steps.
>
@@ -81,7 +81,7 @@ TOOLS = [
TOOL_HANDLERS["todo_write"] = run_todo_write
```
**Nag reminder**, when the model hasn't called `todo_write` for 3 consecutive rounds, a reminder is automatically injected (teaching mechanism; CC source has no fixed round-count logic):
**Nag reminder**: when the model has not called `todo_write` for 3 consecutive rounds, a reminder is automatically injected:
```python
if rounds_since_todo >= 3 and messages:
@@ -132,27 +132,5 @@ The Agent can plan now. But if a task is too large, say "refactor the entire aut
→ s06 Subagent: Break large tasks into subtasks, each handled by an independent Agent with its own clean context, no cross-contamination.
<details>
<summary>Dive into CC Source Code</summary>
Claude Code has two planning surfaces with a shared intent but independent storage and tool contracts:
- **TodoWrite**: A session checklist. Each call replaces the whole list, and the teaching version likewise keeps it in process memory and clears it on exit.
- **Task tools (covered in s12)**: Individually addressable task records with stable IDs, dependency fields, ownership, and persistence.
Current interactive sessions use the structured Task tools by default, while TodoWrite remains available on compatibility surfaces such as non-interactive and Agent SDK usage. Exact exposure can vary by release and configuration. Do not model this as one schema being upgraded in place: they are separate mechanisms, and s05 teaches the lighter checklist contract.
The teaching version omits the `activeForm` field from the real source (`utils/todo/types.ts:8-15`). CC uses it for the UI spinner to show "what's being done"; the teaching version only has terminal output and doesn't need this field.
The teaching version's nag reminder (3 rounds without update triggers injection) is an educational mechanism. The CC source has no fixed "3 rounds" logic; the closest is `TodoWriteTool.ts:72-107` which appends a verification nudge when 3+ todos are all completed without a verification item.
Core increments of the Task System over TodoWrite:
- File persistence (Claude config directory `tasks/{taskListId}/{taskId}.json`) instead of in-memory list
- `blockedBy` dependency graph instead of flat list
- `proper-lockfile` concurrency safety instead of no locking
- Four separate tools (Create/Get/Update/List) instead of one
- TaskCreated / TaskCompleted hooks (`TaskCreateTool.ts:80-129`, `TaskUpdateTool.ts:231-260`) for external system integration
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+2 -24
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s20 → s21 → s22
s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s20 → s21
> *"没有计划的 agent 走哪算哪"* — 先列步骤再动手,长任务更不容易漏项。
>
@@ -81,7 +81,7 @@ TOOLS = [
TOOL_HANDLERS["todo_write"] = run_todo_write
```
**Nag reminder**模型连续 3 轮没调 `todo_write` 时,自动注入一条提醒(教学版机制,CC 源码中没有这个固定轮数逻辑)
**Nag reminder**模型连续 3 轮未调用 `todo_write` 时,自动注入提醒
```python
if rounds_since_todo >= 3 and messages:
@@ -132,27 +132,5 @@ Agent 能计划了。但如果一个任务太大,比如"重构整个认证模
s06 Subagent → 把大任务拆成子任务,每个子任务派一个独立的 Agent。它们有自己的干净上下文,不会互相污染。
<details>
<summary>深入 CC 源码</summary>
Claude Code 有两种目标相近、但存储与工具契约相互独立的规划机制:
- **TodoWrite**:当前会话的轻量清单。每次调用替换整个列表;教学版同样保存在进程内存,退出后清空
- **Task 工具(s12)**:带稳定 ID 的独立任务记录,支持依赖、ownership 与持久化
当前交互式会话默认使用结构化 Task 工具;TodoWrite 仍保留在非交互式、Agent SDK 等兼容表面。具体暴露方式会随版本与配置变化。不要把它理解成同一个 schema 原地升级:两者是独立机制,s05 只教授较轻的清单契约。
教学版省略了真实源码中的 `activeForm` 字段(`utils/todo/types.ts:8-15`)。CC 用它给 UI spinner 展示"正在做什么",教学版只有终端输出,不需要这个字段。
教学版的 nag reminder(3 轮未更新就注入提醒)是教学机制。CC 源码中没有固定的"3 轮"逻辑,更接近的是 `TodoWriteTool.ts:72-107` 中当 3 个以上 todo 全部完成但没有 verification 项时,追加 verification nudge。
Task System 相比 TodoWrite 的核心增量:
- 文件持久化(Claude 配置目录下 `tasks/{taskListId}/{taskId}.json`)而非内存列表
- `blockedBy` 依赖图而非平铺列表
- `proper-lockfile` 并发安全而非无锁
- 四个独立工具(Create/Get/Update/List)而非一个
- TaskCreated / TaskCompleted hooks`TaskCreateTool.ts:80-129``TaskUpdateTool.ts:231-260`)供外部系统集成
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+1 -55
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s20 → s21 → s22
s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s20 → s21
> *"大きなタスクは小さく、小さなタスクごとにクリーンなコンテキスト"* — Subagent は独立した messages[] を使い、メイン会話を汚染しない。
>
@@ -131,59 +131,5 @@ Agent はタスクを分割できるようになった。しかし各タスク
→ s07 Skill Loading:スキルをオンデマンドで注入する。system prompt にドキュメントを積み上げるのではなく、必要なときだけ読み込む。ファイルを読むのと同じくらい自然に。
<details>
<summary>CC ソースコードを深掘り</summary>
> 以下は CC ソースコード `AgentTool.tsx`、`runAgent.ts`、`forkSubagent.ts`、`forkedAgent.ts` の完全分析に基づく。
### 一、一つのパターンではなく三つ
教育版は「新規 messages[]」のみを取り上げる。CC には実際に三つの実行モードがある:
| モード | トリガー | コンテキスト |
|--------|---------|-------------|
| **Normal Subagent** | `subagent_type` 指定時(normal path | 新規 messages[]、プロンプトのみ |
| **Fork Subagent** | `subagent_type` 未指定、fork gate 有効時 | `buildForkedMessages()` でキャッシュフレンドリーなプレフィックスを構築、プロンプトキャッシュを共有 |
| **General-Purpose** | `subagent_type` 未指定、fork gate 無効時 | Normal と同じ |
### 二、Fork モード:プロンプトキャッシュの共有のため
これは教育版にはない核心概念。Fork モード(`forkSubagent.ts:60-71`)は新規コンテキストを作成せず、`buildForkedMessages()``forkSubagent.ts:107-168`)でキャッシュフレンドリーなメッセージプレフィックスを構築する。親の assistant message を保持し、placeholder tool results を生成する。目的は隔離ではなく、Anthropic API のプロンプトキャッシュをヒットさせること:親子 Agent の system prompt、tools、messages プレフィックスがバイトレベルで一致するため、API 側で再計算が不要になる。
キャッシュヒットの五つの重要コンポーネント(`forkedAgent.ts:57-68`):system prompt、tools、model、messages プレフィックス、thinking config、バイトレベルで一致する必要がある。
### 三、コンテキスト隔離の精密な粒度
`createSubagentContext()``forkedAgent.ts:345-462`)はサブエージェントの `ToolUseContext` を作成:
| フィールド | 挙動 |
|-----------|------|
| `abortController` | 新しい子コントローラ、親の abort は下に伝播 |
| `setAppState` | デフォルトは no-op、ただし sync agent は `shareSetAppState` で共有(`runAgent.ts:697-714` |
| `readFileState` | **親からクローン**(同じファイルの再読み込みを回避) |
| `queryTracking` | 新しい chainId、`depth = parentDepth + 1` |
サブエージェントは完全に隔離されているわけではない。ファイル読み取り状態は共有される。UI と通知の隔離度は実行パスにより異なる(sync/async/fork/teammate でそれぞれ異なる)。
### 四、再帰 Fork 防護
教育版は「サブエージェントに task ツールなし」で再帰防止を表現する。実際の実装はより精密:`isInForkChild()``forkSubagent.ts:78-89`)が会話履歴内の `FORK_BOILERPLATE_TAG` をチェックする。しかし `constants/tools.ts:36-46` では `Agent` ツールが全エージェントの無効セットにデフォルト設定(`USER_TYPE === 'ant'` 時は例外)、`forkSubagent.ts:73-89` は fork child 向けの専用再帰保護があり、`agentToolUtils.ts:100-110` は teammate シナリオで特別な許可がある。単純な「サブエージェントの再 spawn 禁止」ではない。
### 五、Permission Bubbling
Fork Agent の `permissionMode: 'bubble'``forkSubagent.ts:67`)は、サブエージェントの権限プロンプトが親ターミナルにバブルアップすることを意味する。ユーザーはメインターミナルでサブエージェントの操作を承認する。
### 六、Async vs Sync
教育版は同期サブエージェントのみ(親が子の完了を待つ)を示す。CC は非同期パスもサポート(`AgentTool.tsx:686-764`):`run_in_background: true` の場合、サブエージェントは非同期で起動し、`{ status: 'async_launched' }` を直ちに親に返し、完了時に通知機構で親に知らせる。実際のトリガーは `run_in_background` だけでなく、auto-background、assistant force async、coordinator/proactive パスもある。
### 教育版の簡略化は意図的
- 三つのモード → 一つ(新規 messages):概念的に明確
- プロンプトキャッシュ共有 → 省略:教育版は API 層の最適化を扱わない
- 再帰 fork 防護 → 「サブエージェントに task ツールなし」に簡略化
- Async → 省略(s13 に委ねる):s06 はまず同期モデルを理解する
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+1 -55
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s20 → s21 → s22
s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s20 → s21
> *"Break large tasks small, each with clean context"* — Subagent uses an independent messages[], no pollution in the main conversation.
>
@@ -131,59 +131,5 @@ The Agent can now break tasks apart. But different tasks require different knowl
→ 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.
<details>
<summary>Dive into CC Source Code</summary>
> The following is based on a complete analysis of CC source code `AgentTool.tsx`, `runAgent.ts`, `forkSubagent.ts`, and `forkedAgent.ts`.
### 1. Not One Pattern, but Three
The teaching version covers only "fresh messages[]". CC actually has three execution modes:
| Mode | Trigger | Context |
|------|---------|---------|
| **Normal Subagent** | `subagent_type` specified (normal path) | Truly fresh messages[], only the prompt |
| **Fork Subagent** | No `subagent_type`, fork gate enabled | Constructs cache-friendly prefix via `buildForkedMessages()`, shares prompt cache |
| **General-Purpose** | No `subagent_type`, fork gate disabled | Same as Normal |
### 2. Fork Mode: Sharing Prompt Cache
This is a core concept the teaching version omits. Fork mode (`forkSubagent.ts:60-71`) doesn't create a fresh context. Instead, it constructs a cache-friendly message prefix via `buildForkedMessages()` (`forkSubagent.ts:107-168`), preserving the parent assistant message and generating placeholder tool results. The goal isn't isolation, but making the Anthropic API's prompt cache hit: parent and child Agent's system prompt, tools, and message prefix are byte-identical, so the API doesn't need to recompute.
Five key components for cache hit (`forkedAgent.ts:57-68`): system prompt, tools, model, message prefix, thinking config, must be byte-identical.
### 3. Context Isolation's Precise Granularity
`createSubagentContext()` (`forkedAgent.ts:345-462`) creates the sub-Agent's `ToolUseContext`:
| Field | Behavior |
|-------|----------|
| `abortController` | New child controller; parent abort propagates down |
| `setAppState` | Default no-op; but sync agents share via `shareSetAppState` (`runAgent.ts:697-714`) |
| `readFileState` | **Cloned from parent** (avoids re-reading same files) |
| `queryTracking` | New chainId, `depth = parentDepth + 1` |
The sub-Agent isn't fully isolated: file read state is shared. The degree of UI and notification isolation varies by execution path (sync/async/fork/teammate differ).
### 4. Recursive Fork Protection
The teaching version uses "sub-Agent has no task tool" for recursion protection. The real implementation is more nuanced: `isInForkChild()` (`forkSubagent.ts:78-89`) checks for `FORK_BOILERPLATE_TAG` in history. But `constants/tools.ts:36-46` defaults `Agent` to all agents' disabled set (with `USER_TYPE === 'ant'` exception); `forkSubagent.ts:73-89` has fork-child-specific recursion protection; `agentToolUtils.ts:100-110` has special allowances in teammate scenarios. Not simply "no further sub-Agents."
### 5. Permission Bubbling
Fork Agent's `permissionMode: 'bubble'` (`forkSubagent.ts:67`) means the sub-Agent's permission prompts bubble up to the parent terminal: the user approves sub-Agent operations in the main terminal.
### 6. Async vs Sync
The teaching version only shows synchronous sub-Agents (parent waits for child to finish). CC also supports async paths (`AgentTool.tsx:686-764`): when `run_in_background: true`, the sub-Agent launches asynchronously, returning `{ status: 'async_launched' }` immediately to the parent, and notifies the parent when complete. Actual triggers go beyond `run_in_background`, including auto-background, assistant force async, and coordinator/proactive paths.
### Teaching Version Simplifications Are Intentional
- Three modes → one (fresh messages): conceptually clear
- Prompt cache sharing → omitted: teaching version doesn't involve API-layer optimization
- Recursive fork protection → simplified to "sub-Agent has no task tool"
- Async → omitted (left for s13): s06 focuses on the synchronous model first
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+1 -55
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s20 → s21 → s22
s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s20 → s21
> *"大任务拆小, 每个小任务干净的上下文"* — Subagent 用独立 messages[], 不污染主对话。
>
@@ -135,59 +135,5 @@ Agent 现在能拆任务了。但每个任务需要的知识不一样:改前
s07 Skill Loading → 技能按需注入,不在 system prompt 里堆文档。用到的时候才加载,和读文件一样自然。
<details>
<summary>深入 CC 源码</summary>
> 以下基于 CC 源码 `AgentTool.tsx`、`runAgent.ts`、`forkSubagent.ts`、`forkedAgent.ts` 的完整分析。
### 一、不是一种模式,是三种
教学版只讲了"全新的 messages[]"。CC 实际有三种执行模式:
| 模式 | 触发条件 | 上下文 |
|------|---------|--------|
| **Normal Subagent** | 指定了 `subagent_type`normal path | 全新 messages[],只有 prompt |
| **Fork Subagent** | 没指定 `subagent_type`fork gate 开启 | 通过 `buildForkedMessages()` 构造 cache-friendly 前缀,共享 prompt cache |
| **General-Purpose** | 没指定 `subagent_type`fork gate 关闭 | 同 Normal |
### 二、Fork 模式:为了共享 Prompt Cache
这是教学版没有的核心概念。Fork 模式(`forkSubagent.ts:60-71`)不创建全新上下文,而是通过 `buildForkedMessages()``forkSubagent.ts:107-168`)构造 cache-friendly 消息前缀,保留父 assistant message 并生成 placeholder tool results。目的不是隔离,而是让 Anthropic API 的 prompt cache 命中:父子 Agent 的 system prompt、tools、messages 前缀完全一致,API 端不需要重算。
缓存命中的五个关键组件(`forkedAgent.ts:57-68`):system prompt、tools、model、messages 前缀、thinking config,必须字节级一致。
### 三、Context Isolation 的精确粒度
`createSubagentContext()``forkedAgent.ts:345-462`)创建子 Agent 的 `ToolUseContext`
| 字段 | 行为 |
|------|------|
| `abortController` | 新的 child controller,父 abort 向下传播 |
| `setAppState` | 默认 no-op;但 sync agent 通过 `shareSetAppState` 共享(`runAgent.ts:697-714` |
| `readFileState` | **从父克隆**(避免重复读相同文件) |
| `queryTracking` | 新 chainId`depth = parentDepth + 1` |
子 Agent 不是完全隔离的:文件读取状态是共享的。UI 和通知的隔离程度取决于执行路径(sync/async/fork/teammate 各不同)。
### 四、递归 Fork 防护
教学版用"子 Agent 不给 task 工具"表达递归保护。真实实现更精细:`isInForkChild()``forkSubagent.ts:78-89`)检查对话历史中是否有 `FORK_BOILERPLATE_TAG`,有就拒绝。但 `constants/tools.ts:36-46``Agent` 工具默认在所有 agent 的禁用集合里,`USER_TYPE === 'ant'` 时例外;`forkSubagent.ts:73-89` 针对 fork child 有专门的递归保护;`agentToolUtils.ts:100-110` 在 teammate 场景下有特殊放行。不是简单的"禁止新的子 Agent"。
### 五、Permission Bubbling
Fork Agent 的 `permissionMode: 'bubble'``forkSubagent.ts:67`)意味着子 Agent 的权限弹窗冒泡到父终端,用户在主终端里审批子 Agent 的操作。
### 六、Async vs Sync
教学版只展示了同步子 Agent(父等着子跑完)。CC 还支持异步路径(`AgentTool.tsx:686-764`):`run_in_background: true` 时异步启动,返回 `{ status: 'async_launched' }` 立即给父 Agent,子 Agent 完成后通过通知机制告知父 Agent。实际触发条件不止 `run_in_background`,还有 auto-background、assistant force async、coordinator/proactive 等路径。
### 教学版的简化是刻意的
- 三种模式 → 一种(fresh messages):概念清晰
- Prompt cache 共享 → 省略:教学版不涉及 API 层优化
- 递归 fork 防护 → 简化为"子 Agent 无 task 工具"
- Async → 省略(留给 s13):s06 先理解同步模型
</details>
<!-- translation-sync: zh@v1, en@v0, ja@v0 -->
+1 -40
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s20 → s21 → s22
s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s20 → s21
> *"Load when needed, don't stuff the prompt"* — tool_result で注入、system prompt には詰め込まない。
>
> **Harness レイヤー**: 知識 — 必要に応じて読み込み、コンテキストに詰め込まない。
@@ -139,44 +139,5 @@ python s07_skill_loading/code.py
→ s08 Context Compact:4 層圧縮戦略。安価な層を先に実行、高価な層を後に実行。
<details>
<summary>CC ソースコードを深掘り</summary>
> 以下は CC ソースコード `loadSkillsDir.ts`、`SkillTool.ts`、`bundledSkills.ts`、`commands.ts` の分析に基づく。
### 一、スキルソース:skills/ ディレクトリだけではない
教育版はすべてのスキルが `skills/` ディレクトリにあると想定している。CC は実際に複数のファイルに分散したソースから読み込む:`loadSkillsDir.ts` は user/project/`--add-dir` ディレクトリと legacy commands`.claude/commands/`)を担当、`bundledSkills.ts` は組み込みスキル、`SkillTool.ts` は MCP リモートスキル、`commands.ts` はコマンド集約を担当。タイプには managed/policy skills、user skills`~/.claude/skills/`)、project skills`.claude/skills/`)、`--add-dir` skills、legacy commands、dynamic skills、conditional skills`paths` frontmatter を持ち、ファイルパスでアクティベート)、bundled skills、plugin skills、MCP skills が含まれる。
### 二、SKILL.md Frontmatter の一般的なフィールド
CC の SKILL.md YAML frontmatter は `parseSkillFrontmatterFields()``loadSkillsDir.ts`)で解析される。一般的なフィールド:
| フィールド | 用途 |
|-----------|------|
| `name` / `description` | 表示名と説明 |
| `when_to_use` | モデルにいつ呼び出すかを指導 |
| `allowed-tools` | スキルが使用可能なツールの自動許可リスト |
| `context` | `inline`(デフォルト)または `fork`(サブ Agent として実行) |
| `model` | モデルオーバーライド(haiku/sonnet/opus/inherit |
| `hooks` | スキルレベルのフック設定 |
| `paths` | 条件付きアクティベーションの glob パターン |
| `user-invocable` | ユーザーが `/name` で呼び出し可能 |
完全なフィールドリストはバージョンによって変動する。上記は教育版に関連するコアフィールドのみ。
### 三、2 層読み込みの正確な実装
1. **カタログ(起動時)**`getSkillDirCommands()` がディレクトリをスキャン → メタデータのみを含む `Command` オブジェクトとして登録。`getSkillListingAttachments()` がスキルリストを添付ファイルとしてフォーマット、コンテキストウィンドウの ~1% を予算とする(上限 8000 文字)。
2. **読み込み(呼び出し時)**:モデルが `Skill` ツールを呼び出す(入力フィールドは `skill` + オプションの `args`、教育版は `name` を使用)→ `getPromptForCommand()` が完全な SKILL.md 内容を展開 → `SkillTool` が返す tool_result の表示テキストは `"Launching skill: {name}"` のみ、実際のスキル内容は `newMessages` を通じて注入される。教育版では両者を「tool_result を通じて注入」として簡略化している。読み込まれた SKILL.md は、モデルが後続で既存の file/bash ツールから関連リソースへアクセスする際の手がかりにもなる。
### 教育版の単純化は意図的
- 複数ファイル・複数ソース → 1 つの `skills/` ディレクトリ:2 層読み込みの核心概念を示すのに十分
- 複数の frontmatter フィールド → name/description のみ解析:解析の複雑さを削減
- forked skills`context: 'fork'`)→ 省略:教育版では inline skill loading のみ展開する
- `Skill` ツールの入力 `skill`+`args` → 教育版は `name` を使用:追加の引数解析の複雑さを回避
</details>
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
+1 -40
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s20 → s21 → s22
s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s20 → s21
> *"Load when needed, don't stuff the prompt"* — Inject via tool_result, not system prompt.
>
> **Harness Layer**: Knowledge — load on demand, don't fill the context.
@@ -139,44 +139,5 @@ On-demand loading solved "don't carry what you shouldn't." But another problem l
→ s08 Context Compact: A four-layer compaction strategy. Cheap layers run first, expensive layers run last.
<details>
<summary>Dive into CC Source Code</summary>
> The following is based on analysis of CC source code `loadSkillsDir.ts`, `SkillTool.ts`, `bundledSkills.ts`, `commands.ts`.
### 1. Skill Sources: Not Just One skills/ Directory
The teaching version assumes all skills live in a `skills/` directory. CC loads from multiple sources spread across multiple files: `loadSkillsDir.ts` handles user/project/`--add-dir` directories and legacy commands (`.claude/commands/`); `bundledSkills.ts` handles built-in skills; `SkillTool.ts` handles MCP remote skills; `commands.ts` handles command aggregation. Types include managed/policy skills, user skills (`~/.claude/skills/`), project skills (`.claude/skills/`), `--add-dir` skills, legacy commands, dynamic skills, conditional skills (with `paths` frontmatter, activated by file path), bundled skills, plugin skills, MCP skills.
### 2. SKILL.md Frontmatter — Common Fields
CC's SKILL.md YAML frontmatter is parsed by `parseSkillFrontmatterFields()` in `loadSkillsDir.ts`. Common fields include:
| Field | Purpose |
|-------|---------|
| `name` / `description` | Display name and description |
| `when_to_use` | Guides the model on when to invoke |
| `allowed-tools` | Auto-allow list of tools available to the skill |
| `context` | `inline` (default) or `fork` (run as sub-Agent) |
| `model` | Model override (haiku/sonnet/opus/inherit) |
| `hooks` | Skill-level hook configuration |
| `paths` | Glob patterns for conditional activation |
| `user-invocable` | Users can invoke via `/name` |
The complete field list changes across versions; above are the core fields relevant to the teaching version.
### 3. Precise Implementation of Two-Level Loading
1. **Catalog (at startup)**: `getSkillDirCommands()` scans directory → registers as `Command` objects containing only metadata. `getSkillListingAttachments()` formats the skill list as attachments, budgeted at ~1% of the context window (cap 8000 characters).
2. **Load (on invocation)**: Model calls `Skill` tool (input fields are `skill` + optional `args`; teaching version uses `name`) → `getPromptForCommand()` expands full SKILL.md content → `SkillTool` returns a tool_result with display text `"Launching skill: {name}"`, while the actual skill content is injected via `newMessages`. The teaching version merges both into "injected via tool_result" as a simplification; the loaded SKILL.md can still guide later access to referenced resources through existing file/bash tools.
### The Teaching Version's Simplification Is Intentional
- Multiple files and sources → 1 `skills/` directory: sufficient to demonstrate the core concept of two-level loading
- Multiple frontmatter fields → only parse name/description: reduces parsing complexity
- Forked skills (`context: 'fork'`) → omitted: the teaching version only expands inline skill loading
- `Skill` tool input `skill`+`args` → teaching version uses `name`: avoids extra argument parsing complexity
</details>
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
+1 -40
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s20 → s21 → s22
s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s20 → s21
> *"用到时再加载, 别全塞 prompt 里"* — 通过 tool_result 注入, 不塞 system prompt。
>
> **Harness 层**: 知识 — 按需加载, 不堆满上下文。
@@ -139,44 +139,5 @@ python s07_skill_loading/code.py
s08 Context Compact → 四层压缩策略。便宜的先跑,贵的后跑。
<details>
<summary>深入 CC 源码</summary>
> 以下基于 CC 源码 `loadSkillsDir.ts`、`SkillTool.ts`、`bundledSkills.ts`、`commands.ts` 的分析。
### 一、技能来源:不是只有一个 skills/ 目录
教学版假设所有技能在 `skills/` 目录下。CC 实际从多个来源加载,分布在多个文件中:`loadSkillsDir.ts` 负责从 user/project/`--add-dir` 目录和 legacy commands`.claude/commands/`)加载;`bundledSkills.ts` 负责内置技能;`SkillTool.ts` 处理 MCP 远程技能;`commands.ts` 负责命令聚合。类型包括 managed/policy skills、user skills`~/.claude/skills/`)、project skills`.claude/skills/`)、`--add-dir` skills、legacy commands、dynamic skills、conditional skills(带 `paths` frontmatter,按文件路径激活)、bundled skills、plugin skills、MCP skills。
### 二、SKILL.md Frontmatter 常见字段
CC 的 SKILL.md YAML frontmatter 由 `parseSkillFrontmatterFields()` 解析(`loadSkillsDir.ts`),常见字段包括:
| 字段 | 用途 |
|------|------|
| `name` / `description` | 显示名称和描述 |
| `when_to_use` | 指导模型何时调用 |
| `allowed-tools` | 技能可用工具的自动允许列表 |
| `context` | `inline`(默认)或 `fork`(作为子 Agent 运行) |
| `model` | 模型覆盖(haiku/sonnet/opus/inherit |
| `hooks` | 技能级别的 hook 配置 |
| `paths` | 条件激活的 glob 模式 |
| `user-invocable` | 用户可以通过 `/name` 调用 |
完整字段列表随版本迭代会变化,以上仅列出教学版涉及的核心字段。
### 三、两级加载的精确实现
1. **Catalog(启动时)**`getSkillDirCommands()` 扫描目录 → 注册为 `Command` 对象,只包含元数据。`getSkillListingAttachments()` 把技能列表格式化为附件,预算为上下文窗口的 ~1%(上限 8000 字符)。
2. **Load(调用时)**:模型调 `Skill` 工具(输入字段是 `skill` + 可选 `args`,教学版用 `name`)→ `getPromptForCommand()` 展开完整 SKILL.md 内容 → `SkillTool` 返回的 tool_result 展示文本只是 `"Launching skill: {name}"`,真正的技能内容通过 `newMessages` 注入对话。教学版把两者合并为"通过 tool_result 注入"是一种简化;加载后的 SKILL.md 仍可作为指引,帮助模型后续通过现有 file/bash 工具访问相关资源。
### 教学版的简化是刻意的
- 多文件多来源 → 1 个 `skills/` 目录:足以展示两级加载的核心概念
- 多个 frontmatter 字段 → 只解析 name/description:减少解析复杂度
- forked skills`context: 'fork'`)→ 省略:教学版只展开 inline 技能加载
- `Skill` 工具输入 `skill`+`args` → 教学版用 `name`:避免参数解析的额外复杂度
</details>
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
+4 -84
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s20 → s21 → s22
s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s20 → s21
> *"Context will fill up — have a way to make room"* — 4層圧縮戦略、安価なものを先に、高価なものを後に実行。
>
> **Harness レイヤー**: 圧縮 — クリーンな記憶、無限のセッション。
@@ -120,9 +120,9 @@ def tool_result_budget(messages, max_bytes=200_000):
3 ステップのフロー:
1. **transcript を保存**:完全な会話を `.transcripts/` に JSONL 形式で書き出す。transcript は回復可能な記録として保存されるが、モデルのアクティブなコンテキストには要約しか残らない。モデルの現在の推論にとって、詳細はすでにコンテキストにない。教学コードは transcript 検索ツールを提供しない。
1. **transcript を保存**:完全な会話を `.transcripts/` に JSONL 形式で書き出す。transcript は完全な記録を保持する。メッセージリストには要約だけが残り、元の詳細は以降のモデル呼び出しに入らない。
2. **LLM で要約を生成**:会話履歴を LLM に送り、現在の目標、重要な発見、変更済みファイル、残りの作業、ユーザーの制約などの重要な情報を保持するよう指示。
3. **メッセージリストを置換**:すべての古いメッセージ 1 件の要約に置き換えられる。教学版は要約のみを保持する。実際の Claude Code は compact 後に直近のファイル、計画、agent/skill/tool などのコンテキストを再付加する。
3. **メッセージリストを置換**:すべての古いメッセージ 1 件の要約に置き換える。
```python
def compact_history(messages):
@@ -189,7 +189,7 @@ def agent_loop(messages):
break # 現在のターンを終了し、圧縮後のコンテキストで新しく開始
```
**順序は変えられない。** L3budget L2microの前に実行される理由:micro 古い大きな tool_result を 1 行のプレースホルダに置換するため、budget はその前に完全な内容を退避させる必要がある。CC ソースが `applyToolResultBudget` を最初に配置する理由も同じ
**順序は変えられない。** L3budget L2microより先に実行する。micro 古い大きな `tool_result` を 1 行のプレースホルダに置き換える前に、budget 完全な内容を保存する必要があるためだ
---
@@ -228,85 +228,5 @@ python s08_context_compact/code.py
s09 Memory → 3 つのサブシステム:何を記憶するかの選択、重要情報の抽出、整理と統合。圧縮を越え、セッションを越えて。
<details>
<summary>CC ソースコードの詳細</summary>
> 以下は CC ソースコード `compact.ts`、`autoCompact.ts`、`microCompact.ts`、`query.ts` の分析に基づく。
### 実行順序の対応
教学版は説明の便宜上 L1/L2/L3/L4 と番号を振っているが、実際の実行順序は番号と完全には一致しない:
| 項目 | 教学版 | Claude Code |
|------|--------|-------------|
| 実行順序 | budget → snip → micro → auto | budget → snip → micro → collapse → auto`query.ts:379-468` |
| snip_compact | 先頭 3 + 末尾 47 を保持 | CC はメインスレッドのみ有効;実装はオープンソースリポジトリにない(`HISTORY_SNIP` feature gate)、インターフェースは確認可能:`snipCompactIfNeeded(messages)``{ messages, tokensFreed, boundaryMessage? }``SnipTool` もモデルが能動的に呼び出し可能。教学版の 3/47 は簡略パラメータ |
| micro_compact | テキストプレースホルダで置換 | 2 つのパス:time-based は直接内容をクリア、cached は API の `cache_edits` を使用(legacy パスは削除済み) |
| micro_compact ホワイトリスト | 位置による(直近 3 件) | time-based は時間閾値でトリガー、cached はカウントでトリガー(`microCompact.ts` |
| tool_result_budget | 200KB 文字 | 200,000 文字(`toolLimits.ts:49` |
| compact_history 閾値 | 文字数で推定 | 精密な token 数:`contextWindow - maxOutputTokens - 13_000` |
| 要約の要求 | 5 種類の情報 | 9 つのセクション + `<analysis>`/`<summary>` デュアルタグ |
| 圧縮プロンプト | シンプルなプロンプト | 先頭と末尾に二重の安全ガードでツール呼び出しを禁止 |
| PTL retry | あり(簡略版) | `truncateHeadForPTLRetry()` がメッセージグループ単位でロールバック(`compact.ts:243-290` |
| 圧縮後のリカバリ | なし(教学版は要約のみ保持) | 直近のファイル、計画、agent/skill/tool などの自動再付加 |
| サーキットブレーカー | 3 回 | 3 回(`autoCompact.ts:70` |
| reactive リトライ | 1 回 | CC にはより精緻な段階別リトライがある |
### 実行順序の詳細
CC ソース `query.ts` での実際の順序:
1. `applyToolResultBudget`(L379):まず大きな結果を処理し、完全な内容を退避
2. `snipCompact`(L403):中間メッセージを切り捨て
3. `microcompact`(L414):古い結果のプレースホルダ化
4. `contextCollapse`(L441):独立したコンテキスト管理システム(教学版にはなし)
5. `autoCompact`L454):LLM 全量要約
教学版の budget → snip → micro の順序はこれと一致する。教学版には contextCollapse メカニズムがない。
### read_file のトレードオフ
教学版の `micro_compact` は、古い `tool_result` を一律にプレースホルダへ置き換える。`read_file` も例外ではない。これは通常、機能的な正しさには影響しない。後でファイル内容が必要になれば、モデルはもう一度そのファイルを読めばよい。代償は、追加のツール呼び出しが発生し得ることと、prompt cache のヒット率が下がり得ること。
Claude Code は、この問題を教学版のような単純なルールでは処理していない。`Read` も microcompact 可能なツール集合に入れる一方で、別途 `readFileState` を維持している。変更されていないファイルの再読込では `FILE_UNCHANGED_STUB` を返し、compact 後には予算内で直近に読んだファイル内容を復元する(例:最大 5 ファイル、1 ファイル 5K token、合計 50K token)。これは本番実装向けのキャッシュと復元メカニズムである。教学版ではそこまで展開せず、「古い結果を圧縮し、必要なら再読込する」という単純な trade-off を残している。
### 完全な定数リファレンス
| 定数 | 値 | ソースファイル |
|------|-----|--------|
| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` |
| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` |
| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` |
| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` |
| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` |
| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` |
| 時間ベース micro_compact 間隔 | 60 分 | `timeBasedMCConfig.ts` |
| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` |
### contextCollapse と sessionMemoryCompact
CC ソースコードには、この教学版では展開していない 2 つのメカニズムが存在する:
- **contextCollapse**:独立したコンテキスト管理システム。有効時には proactive autocompact を抑制し(`autoCompact.ts:215-222`)、collapse の commit/blocking フローがコンテキスト管理を引き継ぐ。ただし manual `/compact` と reactive fallback は独立パスのままで、contextCollapse の影響を受けない。
- **sessionMemoryCompact**compact_history の前に、CC は既存の session memory(s09 で解説)を使った軽量要約を先に試みる。LLM を呼び出さない。このメカニズムは s09 を学んだ後に振り返るとより理解しやすい。
### 圧縮プロンプトの中身
CC の圧縮プロンプトには 2 つの厳格な要件がある:
1. **ツール呼び出しの絶対禁止**:冒頭が `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.` で、末尾にも再度 REMINDER がある
2. **先に分析してから要約**:モデルはまず `<analysis>` タグで思考を整理し、その後 `<summary>` タグで正式な要約を出力する。analysis はフォーマット時に除去される
### 教学版の簡略化は意図的
- micro_compact でテキストプレースホルダを使用 → API 層の `cache_edits` 権限がないため
- read_file は特別扱いしない → 教学版では必要時の再読込を受け入れ、readFileState と圧縮後復元の仕組みを導入しない
- token を文字数で推定 → 精密な tokenizer は教学の対象外
- 圧縮後のリカバリを省略 → 教学版は要約のみを保持し、ファイルの自動再付加を行わない
- 2 つの補助メカニズムを展開しない → 10% の細部に属する
コア設計思想、安価なものを先に高価なものを後に、は完全に保持されている。
</details>
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
+4 -84
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s20 → s21 → s22
s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s20 → s21
> *"Context will fill up — have a way to make room"* — Four-layer compression pipeline: cheap first, expensive last.
>
> **Harness Layer**: Compression — clean memory, unlimited sessions.
@@ -120,9 +120,9 @@ All three previous layers have run, but after 30 minutes of continuous work on a
Three-step process:
1. **Save transcript**: Write the full conversation to `.transcripts/` in JSONL format. The transcript preserves a recoverable record, but the model's active context only contains the summary. For the model's current reasoning, the details are no longer in context. The teaching code does not provide a transcript retrieval tool.
1. **Save transcript**: Write the full conversation to `.transcripts/` in JSONL format. The transcript keeps a complete record; the message list keeps only the summary, so the original details no longer enter later model calls.
2. **LLM generates summary**: Send conversation history to the LLM, asking it to preserve key information: current goals, important findings, modified files, remaining work, user constraints, etc.
3. **Replace message list**: All old messages are replaced with a single summary. The teaching version only keeps the summary; the real Claude Code re-attaches some recent files, plans, agent/skill/tool context after compaction.
3. **Replace message list**: All old messages are replaced with a single summary.
```python
def compact_history(messages):
@@ -189,7 +189,7 @@ def agent_loop(messages):
break # end current turn, start fresh with compacted context
```
**The order must not be swapped.** L3 (budget) runs before L2 (micro) because micro replaces old large tool_results with one-line placeholders budget must persist the full content before that happens. This is why CC source puts `applyToolResultBudget` first.
**The order must not be swapped.** L3 (budget) runs before L2 (micro) because micro replaces old large tool_results with one-line placeholders, so budget must persist the full content first.
---
@@ -228,85 +228,5 @@ Context compression lets an agent run for a long time without crashing. But afte
s09 Memory → three subsystems: choosing what to remember, extracting key information, consolidating and organizing. Across compressions, across sessions.
<details>
<summary>Deep Dive Into CC Source Code</summary>
> The following is based on analysis of CC source code `compact.ts`, `autoCompact.ts`, `microCompact.ts`, and `query.ts`.
### Execution Order Comparison
The teaching version labels layers L1/L2/L3/L4 for pedagogical clarity, but actual execution order does not match the numbering:
| Dimension | Teaching Version | Claude Code |
|-----------|-----------------|-------------|
| Execution order | budget → snip → micro → auto | budget → snip → micro → collapse → auto (`query.ts:379-468`) |
| snip_compact | Keep head 3 + tail 47 | CC only enables on main thread; implementation not in open-source repo (`HISTORY_SNIP` feature gate), but interface is visible: `snipCompactIfNeeded(messages)``{ messages, tokensFreed, boundaryMessage? }`, also exposes `SnipTool` for model-initiated snipping. Teaching version's 3/47 are simplified parameters |
| micro_compact | Text placeholder replacement | Two paths: time-based clears content directly, cached uses API `cache_edits` (legacy path removed) |
| micro_compact whitelist | By position (most recent 3) | time-based triggers by time threshold; cached triggers by count (`microCompact.ts`) |
| tool_result_budget | 200KB characters | 200,000 characters (`toolLimits.ts:49`) |
| compact_history threshold | Character count estimate | Precise tokens: `contextWindow - maxOutputTokens - 13_000` |
| Summary requirements | 5 categories of info | 9 sections + `<analysis>`/`<summary>` dual tags |
| Compression prompt | Simple prompt | Double-ended hard guardrails forbidding tool calls |
| PTL retry | Yes (simplified) | `truncateHeadForPTLRetry()` retreats by message groups (`compact.ts:243-290`) |
| Post-compaction recovery | None (teaching version only keeps summary) | Auto re-read recent files, plans, agent/skill/tool context |
| Circuit breaker | 3 times | 3 times (`autoCompact.ts:70`) |
| Reactive retry | 1 time | CC has more granular tiered retries |
### Execution Order Details
The real order in CC source `query.ts`:
1. `applyToolResultBudget` (L379): persist large results first, ensuring full content is saved
2. `snipCompact` (L403): trim middle messages
3. `microcompact` (L414): old result placeholders
4. `contextCollapse` (L441): independent context management system (not in teaching version)
5. `autoCompact` (L454): LLM full summary
The teaching version's budget → snip → micro order matches this. The teaching version does not have the contextCollapse mechanism.
### read_file Trade-off
The teaching version's `micro_compact` replaces old `tool_result` blocks with placeholders uniformly, including `read_file`. This usually does not affect functional correctness: if the model needs the file contents later, it can read the file again. The cost is an extra tool call and potentially lower prompt cache hit rates.
Claude Code does not solve this with the teaching version's simple rule. It also puts `Read` in the microcompactable tool set, but maintains a separate `readFileState`: repeated reads of unchanged files return `FILE_UNCHANGED_STUB`, and after compaction it restores recently read file contents within a budget (for example, up to 5 files, 5K tokens per file, 50K tokens total). That is a production-level cache and recovery mechanism. The teaching version does not expand into that machinery; it keeps the simpler trade-off of compacting old results and re-reading when needed.
### Full Constant Reference
| Constant | Value | Source File |
|----------|-------|-------------|
| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` |
| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` |
| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` |
| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` |
| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` |
| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` |
| Time micro_compact interval | 60 minutes | `timeBasedMCConfig.ts` |
| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` |
### contextCollapse and sessionMemoryCompact
CC source code has two additional mechanisms not covered in this teaching version:
- **contextCollapse**: An independent context management system that, when enabled, suppresses proactive autocompact (`autoCompact.ts:215-222`), with collapse's commit/blocking flow taking over context management. Manual `/compact` and reactive fallback remain independent paths, unaffected by contextCollapse.
- **sessionMemoryCompact**: Before compact_history, CC first attempts a lightweight summary using existing session memory (covered in s09) without calling the LLM. This mechanism becomes clearer after learning s09.
### What Does the Compression Prompt Look Like?
CC's compression prompt has two hard requirements:
1. **Absolutely no tool calls**: It begins with `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.`, and appends another REMINDER at the end
2. **Analyze first, then summarize**: The model must first reason in an `<analysis>` tag, then output the formal summary in a `<summary>` tag. The analysis is stripped during formatting
### Teaching Version Simplifications Are Intentional
- micro_compact uses text placeholders → we don't have API-level `cache_edits` access
- read_file is not special-cased → the teaching version accepts re-reading when needed instead of introducing readFileState and post-compaction recovery
- Tokens estimated via character count → precise tokenizers are out of scope
- Post-compaction recovery omitted → teaching version only keeps summary, does not auto re-attach files
- Two auxiliary mechanisms not covered → they fall in the 10% detail category
The core design principle, cheap first, expensive last, is fully preserved.
</details>
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
+4 -84
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s20 → s21 → s22
s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s20 → s21
> *"上下文总会满, 要有办法腾地方"* — 四层压缩策略, 便宜的先跑贵的后跑。
>
> **Harness 层**: 压缩 — 干净的记忆, 无限的会话。
@@ -120,9 +120,9 @@ def tool_result_budget(messages, max_bytes=200_000):
三步流程:
1. **保存 transcript**:完整对话写入 `.transcripts/`JSONL 格式。transcript 保留了可恢复记录,但模型的活跃上下文里只剩摘要。对模型当下推理来说,细节已经不在上下文中了。教学代码没有提供 transcript 检索工具
1. **保存 transcript**:完整对话写入 `.transcripts/`JSONL 格式。transcript 保留完整记录;消息列表只保留摘要,原始细节不再进入后续模型调用
2. **LLM 生成摘要**:把对话历史发给 LLM,要求保留当前目标、重要发现、已改文件、剩余工作、用户约束等关键信息。
3. **替换消息列表**:所有旧消息被替换为一条摘要。教学版只保留摘要;真实 Claude Code 会在 compact 后重新附加部分最近文件、计划、agent/skill/tool 等上下文。
3. **替换消息列表**:所有旧消息被替换为一条摘要。
```python
def compact_history(messages):
@@ -189,7 +189,7 @@ def agent_loop(messages):
break # 结束当前 turn,用压缩后的上下文开始新一轮
```
**顺序不能换。** L3budget)在 L2micro)前面,因为 micro 会把旧的大 tool_result 替换成一行占位符,budget 必须在那之前完整内容落盘。这也是为什么 CC 源码把 `applyToolResultBudget` 放在最前面
**顺序不能换。** L3budget)在 L2micro)前面,因为 micro 会把旧的大 `tool_result` 替换成一行占位符,budget 必须在那之前保存完整内容。
---
@@ -228,85 +228,5 @@ python s08_context_compact/code.py
s09 Memory → 三个子系统:选择记什么、提取关键信息、整理巩固。跨压缩、跨会话。
<details>
<summary>深入 CC 源码</summary>
> 以下基于 CC 源码 `compact.ts`、`autoCompact.ts`、`microCompact.ts`、`query.ts` 的分析。
### 执行顺序对照
教学版为了讲解方便按 L1/L2/L3/L4 编号,但实际执行顺序和编号不完全对应:
| 维度 | 教学版 | Claude Code |
|------|--------|-------------|
| 执行顺序 | budget → snip → micro → auto | budget → snip → micro → collapse → auto`query.ts:379-468` |
| snip_compact | 保留头 3 + 尾 47 | CC 仅主线程启用;实现不在开源仓库中(`HISTORY_SNIP` feature gate),但接口可见:`snipCompactIfNeeded(messages)``{ messages, tokensFreed, boundaryMessage? }`,还暴露了 `SnipTool` 工具让模型主动调用。教学版的 3/47 是简化参数 |
| micro_compact | 文本占位符替换 | 两条路径:time-based 直接清内容,cached 走 API `cache_edits`legacy path 已移除) |
| micro_compact 白名单 | 按位置(最近 3 条) | time-based 按时间阈值触发;cached 按计数触发(`microCompact.ts` |
| tool_result_budget | 200KB 字符 | 200,000 字符(`toolLimits.ts:49` |
| compact_history 阈值 | 字符数估算 | 精确 token:`contextWindow - maxOutputTokens - 13_000` |
| 摘要要求 | 5 类信息 | 9 个部分 + `<analysis>`/`<summary>` 双标签 |
| 压缩 prompt | 简单 prompt | 首尾双重防呆禁止调工具 |
| PTL retry | 有(简化) | `truncateHeadForPTLRetry()` 按消息组回退(`compact.ts:243-290` |
| 后压缩恢复 | 无(教学版只保留摘要) | 自动重新读取最近文件、计划、agent/skill/tool 等 |
| 熔断器 | 3 次 | 3 次(`autoCompact.ts:70` |
| reactive 重试 | 1 次 | CC 有更精细的分级重试 |
### 执行顺序详解
CC 源码 `query.ts` 中的真实顺序:
1. `applyToolResultBudget`(L379):先处理大结果,确保完整内容落盘
2. `snipCompact`L403):裁中间消息
3. `microcompact`L414):旧结果占位
4. `contextCollapse`(L441):独立的上下文管理系统(教学版无)
5. `autoCompact`L454):LLM 全量摘要
教学版的 budget → snip → micro 顺序与此一致。教学版没有 contextCollapse 机制。
### read_file 的取舍
教学版的 `micro_compact` 会把旧 `tool_result` 统一替换成占位符,包括 `read_file`。这通常不影响功能正确性:如果后续还需要文件内容,模型可以重新读一次。代价是可能多一次工具调用,也可能降低 prompt cache 命中率。
Claude Code 没有用教学版这种简单规则解决这个问题。它把 `Read` 也放进可 microcompact 的工具集合,但同时维护 `readFileState`:重复读取未变化文件时返回 `FILE_UNCHANGED_STUB`,compact 后再按预算恢复最近读过的文件内容(例如最多 5 个文件、每个 5K token、总预算 50K token)。这是生产级实现里的缓存和恢复机制,教学版不展开,保留“压缩旧结果,必要时重新读取”的简单 trade-off。
### 完整常量参考
| 常量 | 值 | 源文件 |
|------|-----|--------|
| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` |
| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` |
| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` |
| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` |
| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` |
| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` |
| 时间 micro_compact 间隔 | 60 分钟 | `timeBasedMCConfig.ts` |
| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` |
### contextCollapse 和 sessionMemoryCompact
CC 源码中还有两个机制本教学版没有展开:
- **contextCollapse**:独立的上下文管理系统,启用时抑制 proactive autocompact`autoCompact.ts:215-222`),由 collapse 的 commit/blocking 流程接管上下文管理。但 manual `/compact` 和 reactive fallback 仍是独立路径,不受 contextCollapse 影响。
- **sessionMemoryCompact**compact_history 之前,CC 会先尝试用已有的 session memory(s09 会讲到)做轻量摘要,不调 LLM。这个机制等学完 s09 之后回头看会更清楚。
### 压缩 prompt 长什么样?
CC 的压缩 prompt 有两个硬性要求:
1. **绝对禁止调用工具**:开头就是 `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.`,末尾还会再 REMINDER 一次
2. **先分析再总结**:模型需要先在 `<analysis>` 标签里理清思路,然后在 `<summary>` 标签里输出正式摘要。analysis 在格式化时被剥离
### 教学版的简化是刻意的
- micro_compact 用文本占位 → 我们没有 API 层的 `cache_edits` 权限
- read_file 不特殊处理 → 教学版接受必要时重新读取,避免引入 readFileState 和后压缩恢复机制
- token 用字符数估算 → 精确 tokenizer 不在教学范围内
- 后压缩恢复省略 → 教学版只保留摘要,不自动重新附加文件
- 两个辅助机制不展开 → 属于 10% 的细节
核心设计思想,便宜的先跑贵的后跑,完整保留。
</details>
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
@@ -69,7 +69,7 @@
<text x="135" y="320" fill="#1e40af" font-size="13" font-weight="700">microCompact</text>
<text x="260" y="320" fill="#1e40af" font-size="11">old tool_result → placeholder (keep latest 3)</text>
<text x="650" y="320" fill="#1e40af" font-size="10" text-anchor="end">compact old</text>
<text x="135" y="338" fill="#2563eb" font-size="9">Trigger: every turn automatically; tutorial uses text placeholder</text>
<text x="135" y="338" fill="#2563eb" font-size="9">Trigger: every turn; replace older results with text placeholders</text>
<!-- ===== Auto-compact title ===== -->
<rect x="20" y="358" width="720" height="24" rx="4" fill="#f1f5f9"/>

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

@@ -69,7 +69,7 @@
<text x="135" y="320" fill="#1e40af" font-size="13" font-weight="700">microCompact</text>
<text x="260" y="320" fill="#1e40af" font-size="11">古い tool_result → プレースホルダー(最新 3 件保持)</text>
<text x="650" y="320" fill="#1e40af" font-size="10" text-anchor="end">旧結果を圧縮</text>
<text x="135" y="338" fill="#2563eb" font-size="9">トリガー:毎ターン自動実行、チュートリアル版はテキストプレースホルダーで模擬</text>
<text x="135" y="338" fill="#2563eb" font-size="9">トリガー:毎ターン、古い結果をテキストプレースホルダーに置換</text>
<!-- ===== 自動圧縮タイトル ===== -->
<rect x="20" y="358" width="720" height="24" rx="4" fill="#f1f5f9"/>

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

@@ -69,7 +69,7 @@
<text x="135" y="320" fill="#1e40af" font-size="13" font-weight="700">microCompact</text>
<text x="260" y="320" fill="#1e40af" font-size="11">旧 tool_result → 占位符(保留最近 3 条)</text>
<text x="650" y="320" fill="#1e40af" font-size="10" text-anchor="end">压旧结果</text>
<text x="135" y="338" fill="#2563eb" font-size="9">触发:每轮自动,教学版用文本占位符模拟</text>
<text x="135" y="338" fill="#2563eb" font-size="9">触发:每轮自动,把较旧结果替换为文本占位符</text>
<!-- ===== 自动压缩标题 ===== -->
<rect x="20" y="358" width="720" height="24" rx="4" fill="#f1f5f9"/>

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

@@ -50,9 +50,9 @@
<!-- How -->
<rect x="20" y="228" width="680" height="62" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
<text x="35" y="248" fill="#1e3a5f" font-size="11" font-weight="600">How (teaching version)</text>
<text x="155" y="248" fill="#475569" font-size="10">Iterate through tool_result, keep only latest 3 complete, replace older ones with placeholders.</text>
<text x="35" y="264" fill="#1e3a5f" font-size="11" font-weight="600">Real CC</text>
<text x="95" y="264" fill="#475569" font-size="10">Clears old results via API cache_edits (without breaking prompt cache prefix), only for COMPACTABLE_TOOLS:</text>
<text x="95" y="280" fill="#94a3b8" font-size="9">Read, Bash, Grep, Glob, WebSearch, WebFetch, Edit, Write. Teaching version uses text placeholders to simulate the same effect.</text>
<text x="35" y="248" fill="#1e3a5f" font-size="11" font-weight="600">Rule</text>
<text x="75" y="248" fill="#475569" font-size="10">Keep the latest 3 tool results complete and replace older results with placeholders.</text>
<text x="35" y="264" fill="#1e3a5f" font-size="11" font-weight="600">Placeholder</text>
<text x="105" y="264" fill="#475569" font-size="10">Explain that the result was compacted and that the tool can be run again if needed.</text>
<text x="105" y="280" fill="#94a3b8" font-size="9">The message structure remains valid for the next loop iteration.</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -50,9 +50,9 @@
<!-- 原理 -->
<rect x="20" y="228" width="680" height="62" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
<text x="35" y="248" fill="#1e3a5f" font-size="11" font-weight="600">方法(教学版)</text>
<text x="130" y="248" fill="#475569" font-size="10">tool_result を走査し、最新 3 件のみ完全保持、古いものはプレースホルダーに置換。</text>
<text x="35" y="264" fill="#1e3a5f" font-size="11" font-weight="600">実際の CC</text>
<text x="110" y="264" fill="#475569" font-size="10">API cache_edits で旧結果をクリア(prompt cache プレフィックスを破壊しない)、COMPACTABLE_TOOLS のみ対象:</text>
<text x="110" y="280" fill="#94a3b8" font-size="9">Read, Bash, Grep, Glob, WebSearch, WebFetch, Edit, Write。教学版はテキストプレースホルダーで同様の効果を模擬</text>
<text x="35" y="248" fill="#1e3a5f" font-size="11" font-weight="600">処理規則</text>
<text x="95" y="248" fill="#475569" font-size="10">最新 3 件の tool_result を完全保持、古い結果をプレースホルダーに置換する</text>
<text x="35" y="264" fill="#1e3a5f" font-size="11" font-weight="600">プレースホルダー</text>
<text x="125" y="264" fill="#475569" font-size="10">結果が圧縮済みで、必要ならツールを再実行できることを示す。</text>
<text x="125" y="280" fill="#94a3b8" font-size="9">メッセージ構造を保ったまま次のループへ進める</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

+5 -5
View File
@@ -49,9 +49,9 @@
<!-- 原理 -->
<rect x="20" y="228" width="680" height="62" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
<text x="35" y="248" fill="#1e3a5f" font-size="11" font-weight="600">怎么做(教学版)</text>
<text x="115" y="248" fill="#475569" font-size="10">遍历 tool_result,只保留最近 3 条完整,更旧的替换为占位符。</text>
<text x="35" y="264" fill="#1e3a5f" font-size="11" font-weight="600">真实 CC</text>
<text x="95" y="264" fill="#475569" font-size="10">通过 API cache_edits 清除旧结果(不破坏 prompt cache 前缀),仅对 COMPACTABLE_TOOLS 生效:</text>
<text x="95" y="280" fill="#94a3b8" font-size="9">Read, Bash, Grep, Glob, WebSearch, WebFetch, Edit, Write。教学版用文本占位模拟同样效果</text>
<text x="35" y="248" fill="#1e3a5f" font-size="11" font-weight="600">处理规则</text>
<text x="95" y="248" fill="#475569" font-size="10">遍历 tool_result,只保留最近 3 条完整,更旧的替换为占位符。</text>
<text x="35" y="264" fill="#1e3a5f" font-size="11" font-weight="600">占位内容</text>
<text x="95" y="264" fill="#475569" font-size="10">说明原结果已压缩,并提示需要时重新运行对应工具。</text>
<text x="95" y="280" fill="#94a3b8" font-size="9">消息结构保持不变,后续循环仍可继续处理</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

+1 -88
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s07 → s08 → `s09` → [s10](../s10_system_prompt/) → s11 → ... → s20 → s21 → s22
s01 → ... → s07 → s08 → `s09` → [s10](../s10_system_prompt/) → s11 → ... → s20 → s21
> *"圧縮は詳細を失う、失わない層が必要"* — ファイルストア + インデックス + オンデマンド読み込み。圧縮を越え、セッションを越えて。
>
> **Harness レイヤー**: 記憶 — 圧縮とセッションを越える知識の蓄積。
@@ -146,8 +146,6 @@ def consolidate_memories():
# Replace all files with consolidated results
```
CC はこのプロセスを **Dream** と呼び、実際には 4 層のゲートがある:時間間隔、スキャンスロットル、セッション数、ファイルロック。教学版はファイル数閾値に簡略化。
### Memory に保存するもの
Memory はセッションを越えて有用な情報を保存する:ユーザーの好み、繰り返し出るフィードバック、プロジェクト背景、よく使う入口、調査の手がかりなど。「あとでまた使うもの」を対象にし、インデックス + オンデマンド読み込みで現在の会話に戻す。
@@ -192,90 +190,5 @@ python s09_memory/code.py
s10 System Prompt → セグメント + 実行時組み立て。異なるプロジェクト、異なるツール、異なるプロンプト。
<details>
<summary>CC ソースコードの詳細</summary>
> 以下は CC ソースコード `src/` 下の `memdir/`、`services/`、`utils/`、`query/` の分析に基づく。行番号はソースコードと照合済み。
### ソースコードパス
| ファイル | 行数 | 職責 |
|------|------|------|
| `memdir/memdir.ts` | 507 | 核心:MEMORY.md 定義(`34-38`)、記憶動作指示で memory/plan/tasks を区別(`199-266`)、`loadMemoryPrompt()` 3 パス(`419-490` |
| `memdir/findRelevantMemories.ts` | 141 | Sonnet side-query で記憶選択(`18-24` システムプロンプト、`97-122` 呼び出しロジック) |
| `memdir/memoryTypes.ts` | 271 | 型定義、frontmatter フィールド |
| `memdir/memoryScan.ts` | — | .md ファイルをスキャン、MEMORY.md を除外、frontmatter を読み取り、最大 200 ファイル、mtime 降順(`35-94` |
| `services/extractMemories/extractMemories.ts` | 615 | forked agent で記憶を抽出、制限付き権限、`skipTranscript: true``maxTurns: 5``371-427` |
| `services/autoDream/autoDream.ts` | 324 | Dream 整理、4 層ゲート(`63-66` デフォルト値、`130-190` ゲート、`224-233` forked agent |
| `services/SessionMemory/sessionMemory.ts` | 495 | セッションレベルの記憶管理 |
| `services/compact/sessionMemoryCompact.ts` | — | session memory 軽量サマリ、閾値 10K/5/40K`56-61` |
| `utils/attachments.ts` | — | 注入予算:200 行 / 4096 バイト/ファイル、60KB/セッション(`269-288`);query で関連記憶を検索(`2196-2241` |
| `query.ts` | — | memory prefetch を毎ターン開始時に起動(`301-304`)、非ブロッキング収集(`1592-1614` |
| `query/stopHooks.ts` | — | stop hook fire-and-forget で抽出と Dream をトリガー(`141-155` |
### 記憶選択:embedding ではなく LLM
CC は **Sonnet 自身で選択**`findRelevantMemories.ts`)、embedding ベクトル類似度ではない:
1. `memoryScan.ts``.memory/` 下のすべての `.md` ファイルをスキャン(MEMORY.md を除外)、最大 200 ファイル、mtime 降順
2. `name` + `description` をカタログとしてリスト化
3. Sonnet side-query に送信:「名前と説明から本当に有用な記憶を選択(最大 5 件)。不明ならスキップ。」
4. Sonnet が `{ selected_memories: ["file1.md", ...] }` を返却
5. 選択されたファイルの完全な内容を読み込み(≤ 200 行 / 4096 バイト/ファイル)、注入。セッション総予算:60KB
毎ターンのユーザー turn 開始時、`query.ts:301-304` が memory prefetch を起動(非同期);ツール実行後、`1592-1614` が非ブロッキングで結果を収集。
### 抽出タイミング:stop hook、autoCompact 後ではない
トリガー位置(`stopHooks.ts:141-155`):`handleStopHooks()` 内で、fire-and-forget で抽出と Dream をトリガー。教学版は `stop_reason != "tool_use"` 分岐に抽出を配置、方向は一致。
CC の抽出は forked agent で実行(`extractMemories.ts:371-427`):制限付き権限、`skipTranscript: true``maxTurns: 5`。重複保護もある:メイン Agent が既に記憶ファイルを書き込んだ場合、抽出をスキップ。
### 記憶ファイル形式
CC は Markdown + YAML frontmatter を使用、教学版と一致。4 種類:`user``feedback``project``reference`
`memdir.ts:34-38` がインデックス制約を定義:`MEMORY.md` 最大 200 行 / 25KB。`memdir.ts:199-266` が記憶動作指示を構築、memory と plan と tasks を明確に区別。保存場所:`~/.claude/projects/<sanitized-git-root>/memory/`
### Dream4 層ゲート
「アイドル時にトリガー」や「数が足りたら統合」ではなく、4 層のゲート(`autoDream.ts`、デフォルト値 `63-66`、ゲートロジック `130-190`):
1. **時間ゲート**:前回の統合から ≥ 24 時間
2. **スキャンスロットル**:頻繁なファイルシステムスキャンを回避
3. **セッションゲート**:前回の統合以降 ≥ 5 セッションの transcript が変更された
4. **ロックゲート**:他のプロセスが統合中でない(`.consolidate-lock` ファイル)
統合自体は forked agent で実行(`224-233`):定位 → 直近のシグナル収集 → 統合してファイル書き込み → 剪定してインデックス更新。ロックファイルの mtime が lastConsolidatedAt。クラッシュリカバリ:1 時間後にロックが自動期限切れ。
### User Memory vs Session Memory
| | User Memory | Session Memory |
|---|---|---|
| 永続性 | セッション間 | 単一セッション |
| ストレージ | `memory/` 下の複数 .md ファイル | `session-memory/<id>/memory.md` |
| 注入先 | system prompt | compact サマリ |
| 目的 | セッション間の知識蓄積 | compact を越えたコンテキストの連続性 |
sessionMemoryCompacts08 で触れた仕組み)は Session Memory を活用:autoCompact の前に session memory ファイルを読み込み、内容が十分であれば(≥ 10K token、≥ 5 テキストメッセージ、≤ 40K token、`sessionMemoryCompact.ts:56-61`)、LLM を呼び出さずにサマリとして使用。
### 実際の実装が教学版より複雑な点
- **Feature flags**:記憶関連機能には複数の feature gate 層がある
- **Team memory**:チーム共有記憶、`loadMemoryPrompt()` に専用パスあり(教学版では未カバー)
- **KAIROS**:タイミング認識型の記憶抽出戦略、`loadMemoryPrompt()` の daily-log モード
- **Prompt cache**:記憶注入は prompt cache の TTL を考慮する必要があり、毎ターン system prompt の大部分を書き直すことを避ける
- **ファイルロック**:マルチプロセス時の並行制御
- **Memory prefetch**:非同期プレフェッチ、メインフローをブロックしない
### 教学版の簡略化は意図的
- LLM side-query → LLM side-query + キーワードフォールバック:教学版は LLM 選択を維持し、フォールバックパスを追加
- 記憶 JSON → Markdown + frontmatter:教学版は CC と一致
- stop hook トリガー → `stop_reason != "tool_use"` 分岐:方向は一致
- 4 層ゲート → ファイル数閾値:教学版には transcript システムやマルチセッションの概念がない
- forked agent + 制限付き権限 → 直接呼び出し:教学版にはサブプロセス分離がない
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+1 -88
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s07 → s08 → `s09` → [s10](../s10_system_prompt/) → s11 → ... → s20 → s21 → s22
s01 → ... → s07 → s08 → `s09` → [s10](../s10_system_prompt/) → s11 → ... → s20 → s21
> *"Compression loses details, keep a layer that doesn't"* — File store + index + on-demand loading, across compactions, across sessions.
>
> **Harness Layer**: Memory — knowledge that survives compaction and sessions.
@@ -146,8 +146,6 @@ def consolidate_memories():
# Replace all files with consolidated results
```
CC calls this process **Dream**, with four gates in practice: time interval, scan throttle, session count, file lock. The teaching version simplifies to a file-count threshold.
### What Memory Stores
Memory stores information that remains useful across sessions: user preferences, recurring feedback, project background, common entry points, and investigation clues. It focuses on "what will be useful later" and brings that information back through an index plus on-demand loading.
@@ -192,90 +190,5 @@ Memory, compression, and tools are all in place. But the system prompt is still
s10 System Prompt → segments + runtime assembly. Different projects, different tools, different prompts.
<details>
<summary>Deep Dive Into CC Source Code</summary>
> The following is based on analysis of CC source code under `src/` in `memdir/`, `services/`, `utils/`, `query/`. Line numbers verified against source.
### Source Code Paths
| File | Lines | Responsibility |
|------|-------|---------------|
| `memdir/memdir.ts` | 507 | Core: MEMORY.md definition (`34-38`), memory behavior instructions distinguishing memory/plan/tasks (`199-266`), `loadMemoryPrompt()` three paths (`419-490`) |
| `memdir/findRelevantMemories.ts` | 141 | Sonnet side-query memory selection (`18-24` system prompt, `97-122` call logic) |
| `memdir/memoryTypes.ts` | 271 | Type definitions, frontmatter fields |
| `memdir/memoryScan.ts` | — | Scan .md files, exclude MEMORY.md, read frontmatter, max 200 files, sorted by mtime desc (`35-94`) |
| `services/extractMemories/extractMemories.ts` | 615 | Forked agent extraction, restricted permissions, `skipTranscript: true`, `maxTurns: 5` (`371-427`) |
| `services/autoDream/autoDream.ts` | 324 | Dream consolidation, four-layer gating (`63-66` defaults, `130-190` gating, `224-233` forked agent) |
| `services/SessionMemory/sessionMemory.ts` | 495 | Session-level memory management |
| `services/compact/sessionMemoryCompact.ts` | — | Session memory lightweight summary, thresholds 10K/5/40K (`56-61`) |
| `utils/attachments.ts` | — | Injection budget: 200 lines / 4096 bytes per file, 60KB per session (`269-288`); find relevant memory by query (`2196-2241`) |
| `query.ts` | — | Memory prefetch at start of each user turn (`301-304`), non-blocking collection (`1592-1614`) |
| `query/stopHooks.ts` | — | Stop hook fire-and-forget triggers extraction and Dream (`141-155`) |
### Memory Selection: LLM, Not Embedding
CC uses **Sonnet itself to select** (`findRelevantMemories.ts`), not embedding vector similarity:
1. `memoryScan.ts` scans all `.md` files in `.memory/` (excluding MEMORY.md), max 200 files, sorted by mtime descending
2. Lists all memory files' `name` + `description` as a catalog
3. Sends to Sonnet side-query: "Select truly useful memories by name and description (max 5). Skip if unsure."
4. Sonnet returns `{ selected_memories: ["file1.md", ...] }`
5. Selected files' full contents are read (≤ 200 lines / 4096 bytes per file) and injected. Total session budget: 60KB
At the start of each user turn, `query.ts:301-304` starts memory prefetch (async); after tool execution, `1592-1614` collects completed results non-blocking.
### Extraction Timing: Stop Hook, Not After autoCompact
Trigger location (`stopHooks.ts:141-155`): inside `handleStopHooks()`, fire-and-forget triggers extraction and Dream. The teaching version places extraction in the `stop_reason != "tool_use"` branch, matching the direction.
CC's extraction runs via forked agent (`extractMemories.ts:371-427`): restricted permissions, `skipTranscript: true`, `maxTurns: 5`. Also has overlap protection: if the main Agent already wrote memory files, extraction is skipped.
### Memory File Format
CC uses Markdown + YAML frontmatter, consistent with the teaching version. Four types: `user`, `feedback`, `project`, `reference`.
`memdir.ts:34-38` defines index constraints: `MEMORY.md` max 200 lines / 25KB. `memdir.ts:199-266` builds memory behavior instructions, explicitly distinguishing memory from plan and tasks. Storage location: `~/.claude/projects/<sanitized-git-root>/memory/`.
### Dream: Four-Layer Gating
Not "triggered when idle" or "consolidate when count is enough", but four gates (`autoDream.ts`, defaults `63-66`, gating logic `130-190`):
1. **Time gate**: ≥ 24 hours since last consolidation
2. **Scan throttle**: Avoid frequent filesystem scans
3. **Session gate**: ≥ 5 session transcripts modified since last consolidation
4. **Lock gate**: No other process currently consolidating (`.consolidate-lock` file)
The merge itself runs via forked agent (`224-233`): locate → collect recent signals → merge and write files → prune and update index. Lock file mtime serves as lastConsolidatedAt. Crash recovery: lock auto-expires after 1 hour.
### User Memory vs Session Memory
| | User Memory | Session Memory |
|---|---|---|
| Persistence | Cross-session | Single session |
| Storage | Multiple .md files in `memory/` | `session-memory/<id>/memory.md` |
| Loaded into | system prompt | compact summary |
| Purpose | Cross-session knowledge accumulation | Cross-compact context continuity |
sessionMemoryCompact (mentioned in s08) uses Session Memory: before autoCompact, it reads the session memory file and, if sufficient (≥ 10K tokens, ≥ 5 text messages, ≤ 40K tokens, `sessionMemoryCompact.ts:56-61`), uses it as a summary without calling the LLM.
### Where the Real Implementation Is More Complex
- **Feature flags**: Memory features have multiple feature gate layers
- **Team memory**: Shared team memories, `loadMemoryPrompt()` has a dedicated path (not covered in teaching version)
- **KAIROS**: Timing-aware memory extraction strategy, daily-log mode in `loadMemoryPrompt()`
- **Prompt cache**: Memory injection must account for prompt cache TTL, avoiding full system prompt rewrites each turn
- **File locks**: Concurrency control for multi-process scenarios
- **Memory prefetch**: Async prefetch, non-blocking main flow
### Teaching Version Simplifications Are Intentional
- LLM side-query → LLM side-query + keyword fallback: teaching version keeps LLM selection, adds fallback path
- Memory JSON → Markdown + frontmatter: teaching version matches CC
- Stop hook trigger → `stop_reason != "tool_use"` branch: same direction
- Four-layer gating → file-count threshold: teaching version lacks transcript system and multi-session concepts
- Forked agent + restricted permissions → direct call: teaching version has no subprocess isolation
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+1 -88
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s07 → s08 → `s09` → [s10](../s10_system_prompt/) → s11 → ... → s20 → s21 → s22
s01 → ... → s07 → s08 → `s09` → [s10](../s10_system_prompt/) → s11 → ... → s20 → s21
> *"压缩会丢细节, 要有一层不丢的"* — 文件仓库 + 索引 + 按需加载,跨压缩、跨会话。
>
> **Harness 层**: 记忆 — 跨压缩、跨会话的知识积累。
@@ -147,8 +147,6 @@ def consolidate_memories():
# Replace all files with consolidated results
```
CC 把这个过程叫 Dream,实际有四层门控:时间间隔、扫描节流、会话数、文件锁。教学版简化为文件数阈值。
### Memory 适合保存什么
Memory 保存跨会话仍然有用的信息:用户偏好、反复出现的反馈、项目背景、常用入口和排查线索。它关注“以后还会用到什么”,并通过索引 + 按需加载把这些信息带回当前对话。
@@ -193,90 +191,5 @@ python s09_memory/code.py
s10 System Prompt → 分段 + 运行时组装。不同项目、不同工具,拼出不同的 prompt。
<details>
<summary>深入 CC 源码</summary>
> 以下基于 CC 源码 `src/` 下 `memdir/`、`services/`、`utils/`、`query/` 的分析,行号已对照核实。
### 源码路径
| 文件 | 行数 | 职责 |
|------|------|------|
| `memdir/memdir.ts` | 507 | 核心:MEMORY.md 定义(`34-38`)、记忆行为指令区分 memory/plan/tasks`199-266`)、`loadMemoryPrompt()` 三条路径(`419-490` |
| `memdir/findRelevantMemories.ts` | 141 | Sonnet side-query 选记忆(`18-24` 系统提示、`97-122` 调用逻辑) |
| `memdir/memoryTypes.ts` | 271 | 类型定义,frontmatter 字段 |
| `memdir/memoryScan.ts` | — | 扫描 .md 文件,排除 MEMORY.md,读 frontmatter,最多 200 个,按 mtime 降序(`35-94` |
| `services/extractMemories/extractMemories.ts` | 615 | forked agent 提取记忆,受限权限,`skipTranscript: true``maxTurns: 5``371-427` |
| `services/autoDream/autoDream.ts` | 324 | Dream 整理,四层门控(`63-66` 默认值、`130-190` 门控、`224-233` forked agent |
| `services/SessionMemory/sessionMemory.ts` | 495 | 会话级记忆管理 |
| `services/compact/sessionMemoryCompact.ts` | — | session memory 轻量摘要,阈值 10K/5/40K`56-61` |
| `utils/attachments.ts` | — | 注入预算:200 行 / 4096 字节每文件,60KB 每 session`269-288`);按 query 找相关 memory`2196-2241` |
| `query.ts` | — | memory prefetch 每轮启动(`301-304`),非阻塞收集(`1592-1614` |
| `query/stopHooks.ts` | — | stop hook fire-and-forget 触发提取和 Dream`141-155` |
### 记忆选择:LLM 选,不是 embedding
CC 用 **Sonnet 本身来选**`findRelevantMemories.ts`),不是 embedding 向量相似度:
1. `memoryScan.ts` 扫描 `.memory/` 下所有 `.md` 文件(排除 MEMORY.md),最多 200 个,按 mtime 降序
2.`name` + `description` 列成清单
3. 发给 Sonnet side-query"根据名称和描述选出真正有用的记忆(最多 5 个)。不确定就不要选。"
4. Sonnet 返回 `{ selected_memories: ["file1.md", ...] }`
5. 选中文件读取完整内容(每文件 ≤ 200 行 / 4096 字节),注入上下文。单 session 总预算 60KB
每轮用户 turn 开始时,`query.ts:301-304` 启动 memory prefetch(异步);工具执行后 `1592-1614` 非阻塞收集结果,不卡主流程。
### 提取时机:stop hook,不是 autoCompact 后
触发位置(`stopHooks.ts:141-155`):在 `handleStopHooks()` 中,fire-and-forget 触发提取和 Dream。教学版把提取放在 `stop_reason != "tool_use"` 分支里,方向一致。
CC 的提取通过 forked agent 执行(`extractMemories.ts:371-427`):受限权限、`skipTranscript: true``maxTurns: 5`。还有重叠保护:如果主 Agent 已经写入了记忆文件,跳过提取。
### 记忆文件格式
CC 用 Markdown + YAML frontmatter,和教学版一致。四种类型:`user``feedback``project``reference`
`memdir.ts:34-38` 定义索引约束:`MEMORY.md` 最多 200 行 / 25KB。`memdir.ts:199-266` 构建记忆行为指令,明确区分 memory、plan、tasks。存储位置:`~/.claude/projects/<sanitized-git-root>/memory/`
### Dream:四层门控
不是"空闲时触发"或"数量够了就合并",而是四层门控(`autoDream.ts`,默认值 `63-66`,门控逻辑 `130-190`):
1. **时间门控**:距上次合并 ≥ 24 小时
2. **扫描节流**:避免频繁扫描文件系统
3. **会话门控**:自上次合并以来修改了 ≥ 5 个会话 transcript
4. **锁门控**:没有其他进程正在合并(`.consolidate-lock` 文件)
合并本身通过 forked agent 执行(`224-233`):定位 → 收集近期信号 → 合并写文件 → 剪枝更新索引。锁文件 mtime 就是 lastConsolidatedAt。崩溃恢复:1 小时后锁自动过期。
### User Memory vs Session Memory
| | User Memory | Session Memory |
|---|---|---|
| 持久性 | 跨会话 | 单会话 |
| 存储 | `memory/` 下多个 .md 文件 | `session-memory/<id>/memory.md` |
| 加载到 | system prompt | compact 摘要 |
| 用途 | 跨会话的知识积累 | 跨 compact 的上下文连续性 |
sessionMemoryCompacts08 中提到的机制)正是使用了 Session MemoryautoCompact 前先读 session memory 文件,如果内容足够(≥ 10K token、≥ 5 条文本消息、≤ 40K token`sessionMemoryCompact.ts:56-61`),就用它做摘要,不调 LLM。
### 真实实现比教学版复杂的地方
- **Feature flags**:记忆相关功能有多层 feature gate 控制
- **Team memory**:团队共享记忆,`loadMemoryPrompt()` 有专门路径(教学版未涉及)
- **KAIROS**:时机感知的记忆提取策略,`loadMemoryPrompt()` 中 daily-log 模式
- **Prompt cache**:记忆注入需要考虑 prompt cache 的 TTL,避免每次都重写 system prompt 的大段内容
- **文件锁**:多进程并发时的锁机制
- **Memory prefetch**:异步预取,不阻塞主流程
### 教学版的简化是刻意的
- LLM side-query → LLM side-query + 关键词降级:教学版保留了 LLM 选择,加了降级路径
- 记忆 JSON → Markdown + frontmatter:教学版与 CC 一致
- stop hook 触发 → `stop_reason != "tool_use"` 分支:方向一致
- 四层门控 → 文件数阈值:教学版没有 transcript 系统和多会话概念
- forked agent + 受限权限 → 直接调用:教学版没有子进程隔离
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+2 -4
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s20 → s21 → s22
s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s20 → s21
> *"モデル入力は組み立てるもの、固定するものではない"* — 安定セクション + 実行時状態 + キャッシュ。
>
> **Harness レイヤー**: コンテキスト組み立て — 安定した指示と動的状態をモデル入力にまとめる。
@@ -119,8 +119,6 @@ def get_system_prompt(context: dict) -> str:
`hash()` ではなく `json.dumps` を使用:Python 組み込みの `hash()` にはプロセスランダム化があり(安定したキャッシュキーに不適切)、list/dict で `unhashable type` エラーになる。
注意:このキャッシュは「プロセス内での文字列再組み立ての回避」のみ。CC の API prompt cache とは別物。CC の prompt cache は `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` で静的/動的部分を分離し、静的部分が global cache に命中する。動的内容が変化しても静的部分は無効化されない。
### context: 実際の状態、キーワード推測ではない
context は現在の実行時状態の実際の状態を反映:
@@ -178,7 +176,7 @@ cd learn-claude-code
python s10_system_prompt/code.py
```
安全上の注意:この集中教材はモデルが渡す `bash` 文字列を `shell=True` で実行し、s03 の permission gate を省略している。破棄可能な workspace だけで実行し、本番 harness では permission と sandbox の境界を戻すこと。
**安全上の注意**:このスクリプトはモデルが生成した `bash` 文字列を `shell=True` で実行し、s03 の permission gate を含まない。破棄可能な workspace でのみ実行すること。
観察のポイント:
+2 -4
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s20 → s21 → s22
s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s20 → s21
> *"Model input is assembled, not hardcoded"* — Stable sections + runtime state + caching.
>
> **Harness Layer**: Context assembly — turn stable instructions and dynamic state into model input.
@@ -119,8 +119,6 @@ def get_system_prompt(context: dict) -> str:
`json.dumps` instead of `hash()`: Python's built-in `hash()` has process randomization (unsuitable for stable cache keys) and throws `unhashable type` on nested dicts/lists.
Note: this cache only avoids redundant string assembly within a process. It's not the same as CC's API prompt cache, which uses `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` to separate static and dynamic parts — the static parts hit global cache and don't invalidate when dynamic content changes.
### context: Real State, Not Keyword Guessing
Context reflects the actual runtime state:
@@ -178,7 +176,7 @@ cd learn-claude-code
python s10_system_prompt/code.py
```
Safety note: this focused teaching script still executes the model's `bash` string with `shell=True` and omits s03's permission gate. Run it only in a disposable workspace; production harnesses must restore permission and sandbox boundaries.
**Safety note**: The script executes model-generated `bash` strings with `shell=True` and does not include the permission gate from s03. Run it only in a disposable workspace.
What to watch for:
+2 -4
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s20 → s21 → s22
s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s20 → s21
> *"模型输入是组装出来的,不是写死的"* — 稳定分段 + 运行时状态 + 缓存。
>
> **Harness 层**: 上下文组装 — 把稳定指令和动态状态组成模型输入。
@@ -119,8 +119,6 @@ def get_system_prompt(context: dict) -> str:
`json.dumps` 而不是 `hash()`Python 内置 `hash()` 有进程随机化,不适合做稳定 cache key,而且遇到 list/dict 会报 `unhashable type`
注意:这里的缓存只是"避免重复拼接字符串",和 CC 的 API prompt cache 不是一回事。CC 的 prompt cache 通过 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 分隔静态和动态部分,静态部分命中 global cache,不因动态内容变化而失效。
### context: 真实状态,不是关键词猜测
context 反映当前运行态的真实状态:
@@ -178,7 +176,7 @@ cd learn-claude-code
python s10_system_prompt/code.py
```
安全说明:这份聚焦教学脚本`shell=True` 执行模型给出`bash` 字符串,并省略了 s03 的权限闸门。只在可丢弃工作区运行;生产 harness 必须恢复权限与沙箱边界
**安全说明**:脚本使`shell=True` 执行模型生成`bash` 字符串,并未接入 s03 的权限闸门。只在可丢弃工作区运行。
观察重点:
+1 -3
View File
@@ -74,9 +74,7 @@ def get_system_prompt(context: dict) -> str:
Uses json.dumps for deterministic serialization, not Python's hash()
which has process randomization and fails on nested dicts/lists.
This cache only avoids redundant string assembly within a process.
Real Claude Code additionally protects API-level prompt cache via
stable section ordering and SYSTEM_PROMPT_DYNAMIC_BOUNDARY.
Stable serialization lets identical context reuse the assembled prompt.
"""
global _last_context_key, _last_prompt
key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)
+4 -81
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s09 → s10 → `s11` → [s12](../s12_task_system/) → s13 → ... → s20 → s21 → s22
s01 → ... → s09 → s10 → `s11` → [s12](../s12_task_system/) → s13 → ... → s20 → s21
> *"エラーは終わりではなく、リトライの始まり"* — トークン拡張、コンテキスト圧縮、モデル切り替え。
>
> **Harness 層**: 耐障害性 — メインループのエラーを分類し復旧。
@@ -19,7 +19,7 @@ Error: 529 overloaded
Agent がクラッシュした。リトライもしない、モデルも切り替えない、コンテキストも減らさない——そのままクラッシュ。
本番環境では API エラーが日常茶飯事。最も一般的な 3 つの障害パターン:**出力の切り詰め**(モデルが途中まで出力して token が尽きた)、**コンテキスト超過**(圧縮後も長すぎる)、**一時的障害**429 レート制限 / 529 過負荷)。エラーを処理しない Agent は、一度触れただけで止まる車のようなものだ
LLM API の呼び出しは失敗することがある。この章では、出力の切り詰め、コンテキスト超過、一時的障害(429/529)の 3 つを扱う
---
@@ -29,7 +29,7 @@ Agent がクラッシュした。リトライもしない、モデルも切り
s10 のループ、prompt 組み立てはすべてそのまま。唯一の変更点:LLM 呼び出しを try/except で包み、エラータイプに応じて異なる復旧パスに振り分ける。復旧後は `continue` でループ先頭に戻り、再度 LLM を呼び出す。
最も一般的な 3 つの復旧パターン(教学版は 429/529 のみ対応;実際のシステムは接続エラー、タイムアウト、クラウドベンダーの認証キャッシュ等もカバー。CC には実際 13 以上の reason code があるが、残りは Deep dive で解説)
この章では 3 つの復旧パターンを実装する
| パターン | トリガー | 復旧アクション |
|----------|----------|---------------|
@@ -73,7 +73,7 @@ messages.append({"role": "assistant", "content": response.content})
LLM が「コンテキストが長すぎる」と返す(`prompt_too_long`)。s08 の 4 層圧縮をすべて実行したのに、まだ超えている。
reactive compact をトリガー——auto compact よりも積極的。教学版は最後の 5 メッセージだけを残して圧縮をシミュレート;実際の CC は LLM で compact サマリを生成してからリトライする。圧縮後にリトライ。ただし、一度圧縮してもまだ超過している場合は終了するしかない——再度圧縮しても小さくはならない
reactive compact を実行し、最後の 5 メッセージを残して 1 回だけ再試行する。それでも上限を超える場合は終了する
```python
except PromptTooLongError:
@@ -196,82 +196,5 @@ Agent に**タスクリスト**を管理させられないだろうか——依
s12 Task System → タスクとは依存関係があり、状態があり、永続化されたグラフだ。これはマルチ Agent 協調の基盤となる。
<details>
<summary>CC ソースコード深掘り</summary>
> 以下は CC ソースコード `query.ts`1729 行)、`services/api/withRetry.ts`822 行)、`query/tokenBudget.ts`93 行)、`utils/tokenBudget.ts`73 行)の分析に基づく。
### 一、十数種の reason/transition3 つだけではない)
教学版では最も一般的な 3 つの復旧パターンを解説した。CC には実際十数種の reason/transition があり、毎回の LLM 呼び出し後に判定される:
| reason/transition | 教学版の対応 | CC の動作 |
|---|---|---|
| `completed` | 正常終了 | 結果を返す |
| `next_turn` | 通常のツール呼び出し | 次のツール実行ラウンドへ |
| `max_output_tokens_escalate` | パス 1 | 8K→64K に拡張 |
| `max_output_tokens_recovery` | パス 1 続き出力 | 続きのプロンプト注入(最大 3 回) |
| `reactive_compact_retry` | パス 2 | reactive compact → リトライ |
| `prompt_too_long` | パス 2 | 同上 |
| `collapse_drain_retry` | 未展開 | context collapse 時にまず保留中の内容をコミット |
| `model_error` | 未展開 | リトライ |
| `image_error` | 未展開 | `ImageSizeError` / `ImageResizeError` の専用処理 |
| `aborted_streaming` | 未展開 | ストリーミング中断の復旧 |
| `aborted_tools` | 未展開 | ツール中断 |
| `stop_hook_blocking` | 未展開 | blocking error を注入 → モデルが自己修正 |
| `stop_hook_prevented` | 未展開 | hooks によるブロック |
| `hook_stopped` | 未展開 | hook による実行停止 |
| `token_budget_continuation` | 未展開 | token 使用量 < 90% の時に継続 |
| `blocking_limit` | 未展開 | ブロック制限 |
| `max_turns` | 未展開 | 最大ターン数に到達 |
教学版では最初の 5 つ(最も一般的なもの)だけを展開した。残りはそれぞれ専用の処理ロジックを持つ。
### 二、指数バックオフの正確な公式
CC のバックオフ遅延(`withRetry.ts:530-548`):
```
delay = min(500 × 2^(attempt-1), 32000) + random(0~25%)
```
| 試行 | 基本遅延 | + ジッター |
|------|---------|-----------|
| 1 | 500ms | 0-125ms |
| 2 | 1000ms | 0-250ms |
| 4 | 4000ms | 0-1000ms |
| 7+ | 32000ms(上限) | 0-8000ms |
サーバーが `Retry-After` ヘッダーを返した場合、その値を優先して使用する。
### 三、CONTINUATION プロンプト原文
CC の続き出力プロンプト(`query.ts:1225-1227`):
```
Output token limit hit. Resume directly — no apology, no recap of what
you were doing. Pick up mid-thought if that is where the cut happened.
Break remaining work into smaller pieces.
```
Token budget のナッジプロンプト(`tokenBudget.ts:72`):
```
Stopped at {pct}% of token target. Keep working — do not summarize.
```
### 四、ストリーミングエラー処理
CC のストリーミングパスでは、復旧可能なエラー(413、max_tokens、media error)はストリーミング中**表示を保留される**(`query.ts:788-822`)——SDK コンシューマーには見えず、復旧ロジックだけが認識できる。ストリーミング終了後に復旧が必要かどうかを判断する。
### 五、529 → フォールバックモデル切り替え
3 回連続で 529 過負荷エラーが発生した後(`MAX_529_RETRIES = 3`)、CC は自動的にフォールバックモデルに切り替える(例:Opus → Sonnet)。切り替え時にすべての保留中のメッセージと tool 結果をクリアし、ユーザーに "Switched to {model} due to high demand" と表示する。
### 六、収穫逓減の検出
Token budget の「継続」は無限ではない。連続 3 回の continuation で token 増分が 500 未満の場合、システムは「続けても実質的な出力は得られない」と判断し、continuation を停止する(`tokenBudget.ts:60-62`)。
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+4 -81
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s09 → s10 → `s11` → [s12](../s12_task_system/) → s13 → ... → s20 → s21 → s22
s01 → ... → s09 → s10 → `s11` → [s12](../s12_task_system/) → s13 → ... → s20 → s21
> *"Errors aren't the end, they're the start of a retry"* — escalate tokens, compact context, switch models.
>
> **Harness layer**: Resilience — classify and recover when the main loop hits errors.
@@ -19,7 +19,7 @@ Error: 529 overloaded
The Agent crashes. It doesn't retry, doesn't switch models, doesn't reduce context — it just crashes.
In production, API errors are the norm. The three most common failure modes: **truncated output** (the model runs out of tokens mid-sentence), **context overflow** (still too long even after compaction), and **transient failures** (429 rate limiting / 529 overload). An Agent that doesn't handle errors is like a car that stalls at the slightest touch.
LLM API calls can fail. This chapter handles three cases: truncated output, context overflow, and transient failures (429/529).
---
@@ -29,7 +29,7 @@ In production, API errors are the norm. The three most common failure modes: **t
The loop and prompt assembly from s10 are fully preserved. The only change: the LLM call is wrapped in try/except, with different recovery paths based on error type. After recovery, `continue` loops back to the top to call the LLM again.
The three most common recovery patterns (the teaching version only handles 429/529; real systems also cover connection errors, timeouts, cloud vendor credential caches, etc. CC actually has 13+ reason codes; see the Deep Dive for the rest):
This chapter implements three recovery patterns:
| Pattern | Trigger | Recovery Action |
|----------|---------|-----------------|
@@ -73,7 +73,7 @@ Escalation gets one chance; continuation gets up to 3. After that, exit — furt
The LLM says "your context is too long" (`prompt_too_long`). All four compaction layers from s08 have already run, and it's still over the limit.
Trigger reactive compact — more aggressive than auto compact. The teaching version keeps only the last 5 messages to simulate compaction; real CC generates a compact summary via LLM, then retries with the compacted message list. Retry after compacting. But if it's still over the limit after one compaction, the only option is to exit — compacting again won't make it any smaller:
Trigger reactive compact: keep the last 5 messages and retry once. If the context is still over the limit, exit:
```python
except PromptTooLongError:
@@ -196,82 +196,5 @@ What if the Agent could manage a **task list** — with dependencies, persisted
s12 Task System → Tasks form a dependency graph with state and persistence. This is the foundation for multi-Agent collaboration.
<details>
<summary>Deep Dive into CC Source</summary>
> The following is based on CC source code: `query.ts` (1729 lines), `services/api/withRetry.ts` (822 lines), `query/tokenBudget.ts` (93 lines), and `utils/tokenBudget.ts` (73 lines).
### 1. A Dozen-Plus Reason/Transition Codes (Not Just 3)
The teaching version covers 3 of the most common recovery patterns. CC actually has a dozen-plus reason/transition codes, evaluated after every LLM call:
| Reason/Transition | Teaching Version | CC Behavior |
|---|---|---|
| `completed` | Normal completion | Return result |
| `next_turn` | Normal tool call | Continue to next tool execution round |
| `max_output_tokens_escalate` | Path 1 | 8K→64K escalation |
| `max_output_tokens_recovery` | Path 1 continuation | Continuation prompt (up to 3 times) |
| `reactive_compact_retry` | Path 2 | Reactive compact → retry |
| `prompt_too_long` | Path 2 | Same as above |
| `collapse_drain_retry` | Not covered | Context collapse — commit staged content first |
| `model_error` | Not covered | Retry |
| `image_error` | Not covered | `ImageSizeError` / `ImageResizeError` handled specifically |
| `aborted_streaming` | Not covered | Streaming abort recovery |
| `aborted_tools` | Not covered | Tool abort |
| `stop_hook_blocking` | Not covered | Inject blocking error → model self-corrects |
| `stop_hook_prevented` | Not covered | Hooks prevent execution |
| `hook_stopped` | Not covered | Hook stopped execution |
| `token_budget_continuation` | Not covered | Continue when token usage < 90% |
| `blocking_limit` | Not covered | Blocking limit reached |
| `max_turns` | Not covered | Maximum turns reached |
The teaching version only expands on the first 5 (most common); each of the rest has its own dedicated handling logic.
### 2. Precise Exponential Backoff Formula
CC's backoff delay (`withRetry.ts:530-548`):
```
delay = min(500 × 2^(attempt-1), 32000) + random(0~25%)
```
| Attempt | Base Delay | + Jitter |
|---------|-----------|----------|
| 1 | 500ms | 0-125ms |
| 2 | 1000ms | 0-250ms |
| 4 | 4000ms | 0-1000ms |
| 7+ | 32000ms (cap) | 0-8000ms |
If the server returns a `Retry-After` header, that value takes priority.
### 3. Original CONTINUATION Prompt
CC's continuation prompt (`query.ts:1225-1227`):
```
Output token limit hit. Resume directly — no apology, no recap of what
you were doing. Pick up mid-thought if that is where the cut happened.
Break remaining work into smaller pieces.
```
Token budget nudge prompt (`tokenBudget.ts:72`):
```
Stopped at {pct}% of token target. Keep working — do not summarize.
```
### 4. Streaming Error Handling
In CC's streaming path, recoverable errors (413, max_tokens, media errors) are **withheld from display** during streaming (`query.ts:788-822`) — SDK consumers don't see them, only the recovery logic does. After streaming ends, the system determines whether recovery is needed.
### 5. 529 → Fallback Model Switch
After 3 consecutive 529 overload errors (`MAX_529_RETRIES = 3`), CC automatically switches to the fallback model (e.g., Opus → Sonnet). On switch, all pending messages and tool results are cleared, and the user sees "Switched to {model} due to high demand".
### 6. Diminishing Returns Detection
Token budget "continuations" aren't unlimited. When there are 3 consecutive continuations with a token increment < 500, the system determines "continuing won't produce meaningful output" and stops continuation (`tokenBudget.ts:60-62`).
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+4 -81
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s09 → s10 → `s11` → [s12](../s12_task_system/) → s13 → ... → s20 → s21 → s22
s01 → ... → s09 → s10 → `s11` → [s12](../s12_task_system/) → s13 → ... → s20 → s21
> *"错误不是终点, 是重试的起点"* — 升级 token、压缩上下文、切换模型。
>
> **Harness 层**: 韧性 — 主循环遇到错误时分类并恢复。
@@ -19,7 +19,7 @@ Error: 529 overloaded
Agent 崩溃了。它没有重试,没有换模型,没有减少上下文——直接崩溃。
生产环境中 API 错误是常态。三种最常见的故障模式:**输出截断**(模型话说一半 token 用完了)、**上下文超限**(压缩后还是太长)、**临时故障**429 限流 / 529 过载)。一个不处理错误的 Agent 就像一个一碰就熄火的车
LLM API 调用可能失败。本章处理三种情况:输出截断、上下文超限和临时故障(429/529
---
@@ -29,7 +29,7 @@ Agent 崩溃了。它没有重试,没有换模型,没有减少上下文—
s10 的循环、prompt 组装全部保留。唯一的变动:LLM 调用包裹在 try/except 里,根据错误类型走不同的恢复路径。恢复后 `continue` 回到循环开头重新调用 LLM。
三种最常见的恢复模式(教学版只处理 429/529;真实系统还覆盖连接错误、超时、云厂商认证缓存等。CC 实际有 13+ reason code,其余见 Deep dive
本章实现三种恢复模式
| 模式 | 触发 | 恢复动作 |
|------|------|---------|
@@ -73,7 +73,7 @@ messages.append({"role": "assistant", "content": response.content})
LLM 说"你的上下文太长了"(`prompt_too_long`)。s08 的四层压缩全跑过了,还是超。
触发 reactive compact——比 auto compact 更激进。教学版只保留最后 5 条消息模拟压缩效果;真实实现会调用 LLM 生成 compact 摘要再重试。压缩后重试。但如果压缩过一次还是超限,只能退出——再压缩也不会变小
触发 reactive compact:保留最后 5 条消息后重试一次。如果仍然超限,则退出
```python
except PromptTooLongError:
@@ -196,82 +196,5 @@ Agent 现在能在错误中自动恢复了。但它处理的任务仍然是"一
s12 Task System → 任务是有依赖、有状态、持久化的图。这是多 Agent 协作的基础。
<details>
<summary>深入 CC 源码</summary>
> 以下基于 CC 源码 `query.ts`1729 行)、`services/api/withRetry.ts`822 行)、`query/tokenBudget.ts`93 行)、`utils/tokenBudget.ts`73 行)的分析。
### 一、十几种 reason/transition(不只是 3 条)
教学版讲了 3 种最常见的恢复模式。CC 实际有十几种 reason/transition,每轮 LLM 调用后都会判断:
| reason/transition | 教学版对应 | CC 行为 |
|---|---|---|
| `completed` | 正常完成 | 返回结果 |
| `next_turn` | 正常工具调用 | 继续下一轮工具执行 |
| `max_output_tokens_escalate` | 路径 1 | 8K→64K 升级 |
| `max_output_tokens_recovery` | 路径 1 续写 | 续写提示(最多 3 次) |
| `reactive_compact_retry` | 路径 2 | reactive compact → 重试 |
| `prompt_too_long` | 路径 2 | 同上 |
| `collapse_drain_retry` | 未展开 | context collapse 先提交暂存 |
| `model_error` | 未展开 | 重试 |
| `image_error` | 未展开 | `ImageSizeError` / `ImageResizeError` 专门处理 |
| `aborted_streaming` | 未展开 | 流式中止恢复 |
| `aborted_tools` | 未展开 | 工具中止 |
| `stop_hook_blocking` | 未展开 | 注入 blocking error → 模型自纠 |
| `stop_hook_prevented` | 未展开 | hooks 阻止 |
| `hook_stopped` | 未展开 | hook 停止执行 |
| `token_budget_continuation` | 未展开 | token 用量 < 90% 时继续 |
| `blocking_limit` | 未展开 | 阻塞限制 |
| `max_turns` | 未展开 | 达到最大轮次 |
教学版只展开了前 5 种(最常见的),其余各有专门处理逻辑。
### 二、指数退避的精确公式
CC 的退避延迟(`withRetry.ts:530-548`):
```
delay = min(500 × 2^(attempt-1), 32000) + random(0~25%)
```
| 尝试 | 基础延迟 | + 抖动 |
|------|---------|--------|
| 1 | 500ms | 0-125ms |
| 2 | 1000ms | 0-250ms |
| 4 | 4000ms | 0-1000ms |
| 7+ | 32000ms(上限) | 0-8000ms |
如果服务器返回 `Retry-After` header,优先用那个值。
### 三、CONTINUATION 提示原文
CC 的续写提示(`query.ts:1225-1227`):
```
Output token limit hit. Resume directly — no apology, no recap of what
you were doing. Pick up mid-thought if that is where the cut happened.
Break remaining work into smaller pieces.
```
Token budget 的 nudge 提示(`tokenBudget.ts:72`):
```
Stopped at {pct}% of token target. Keep working — do not summarize.
```
### 四、流式错误处理
CC 的流式路径中,可恢复的错误(413、max_tokens、media error)在 streaming 期间**被暂扣不展示**`query.ts:788-822`)——SDK 消费者看不到,只有恢复逻辑能看到。等 streaming 结束后才判断是否需要恢复。
### 五、529 → Fallback Model 切换
连续 3 次 529 过载错误后(`MAX_529_RETRIES = 3`),CC 自动切换到 fallback model(如 Opus → Sonnet)。切换时清除所有 pending 消息和 tool 结果,给用户展示 "Switched to {model} due to high demand"。
### 六、Diminishing Returns 检测
Token budget 的"继续"不是无限的。当连续 3 次 continuation 且 token 增量 < 500 时,系统判断"继续也没有实质性产出",停止 continuation`tokenBudget.ts:60-62`)。
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+1 -4
View File
@@ -233,10 +233,7 @@ def is_prompt_too_long_error(e: Exception) -> bool:
def reactive_compact(messages: list) -> list:
"""Emergency compact — teaching version keeps last N messages.
Real CC generates a compact summary via LLM, then retries with
the compacted message list. Teaching version simplifies to tail
retention since s08/s09 already cover LLM-based compact."""
"""Emergency compact: keep the latest messages, then retry."""
print(" \033[31m[reactive compact] trimming to last 5 messages\033[0m")
tail = messages[-5:]
return [{"role": "user",
+4 -61
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s14 → ... → s20 → s21 → s22
s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s14 → ... → s20 → s21
> *"大きな目標を小さなタスクに分け、順序付け、永続化"* — ファイル永続化タスクグラフ、マルチ Agent 協調の基盤。
>
@@ -14,7 +14,7 @@ s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s1
Agent がプロジェクトを受けた:データベース構築、API 実装、テスト追加。s05 の TodoWrite でリストを作り、まず API を書き始め、途中でデータベーステーブルがないことに気づいて戻る。テスト追加時に API インターフェースのシグネチャがまた変わっている...
屋根を先に建てて基礎を後から打つことはできない。タスクには順序がある。タスク依存関係は有向非巡回グラフ(DAGを形成すべき;教学版`blockedBy` チェックのみをデモし、循環検出は実装していない
屋根を先に建てて基礎を後から打つことはできない。タスクには順序がある。タスク間の前提依存関係は有向非巡回グラフ(DAGとして表現でき、この章で`blockedBy` でそれらを記録する
s05 の TodoWrite は現在のタスクの実行チェックリストで、セッションメモリに保持される。ここで必要なのは**タスクシステム**:各タスクは JSON ファイル、タスク間に `blockedBy` 依存関係、ディスク上でセッションをまたいで永続化。
@@ -24,7 +24,7 @@ s05 の TodoWrite は現在のタスクの実行チェックリストで、セ
![Task System Overview](images/task-system-overview.ja.svg)
教育版は基本 agent loop を維持し、タスクシステムに集中するため S11 の完全なエラーリカバリ(RecoveryState、バックオフ、エスカレーション、reactive compact、フォールバックモデル)を省略。追加:5 つの教育用ツール + `.tasks/` ディレクトリによる永続化 + `blockedBy` 依存チェック。タスクシステムとエラーリカバリは独立したレイヤーで、タスクモジュールは状態を、query recovery はモデル呼び出し失敗を扱う
この章では、5 つのタスクツール、`.tasks/` ディレクトリへの永続化、`blockedBy` 依存チェックを追加する
TodoWrite vs Task System
@@ -39,8 +39,6 @@ TodoWrite vs Task System
| 粒度 | Agent 自身の手順 | 認識・追跡・アンロックできるタスク |
| 更新契約 | リスト全体を置換 | 個別レコードを作成・取得・更新・一覧 |
教育用 API は `create_task``list_tasks``get_task``claim_task``complete_task` としてライフサイクルを明示する。Claude Code の製品サーフェスはこれらを `TaskCreate``TaskGet``TaskUpdate``TaskList` の 4 ツールにまとめ、認識と完了は独立した公式ツールではなく更新操作として扱う。
---
## 仕組み
@@ -62,7 +60,7 @@ class Task:
blockedBy: list[str] # 依存タスク ID のリスト
```
ID は `timestamp + random hex` で生成、シンプルだが十分。CC は順次 ID + highwatermark ファイルで ID 再利用を防止する、より厳密な設計
ID は `timestamp + random hex` で生成する
### create_task: タスク作成
@@ -162,8 +160,6 @@ pending ──claim──→ in_progress ──complete──→ completed
- **claim_task**: `pending``in_progress`。owner を設定し、作業を開始。
- **complete_task**: `in_progress``completed`。タスクを完了済みにし、下流をアンロック。
CC には `in_progress → pending` の release パスがない。teammate が終了または shutdown した場合、CC は未完了タスクの owner をクリアし、status を `pending` にリセットし、他の agent が再認識できるようにする。教学版はこの復旧パスを省略。
### 組み合わせて実行
```python
@@ -228,58 +224,5 @@ python s12_task_system/code.py
s13 Background Tasks → 遅い操作はバックグラウンドへ。Agent は他のタスクの処理を続け、バックグラウンドの完了を通知で受け取る。
<details>
<summary>CC ソースコード深掘り</summary>
> 以下は CC ソースコード `utils/tasks.ts`862 行)、`tools/TaskCreateTool/TaskCreateTool.ts`138 行)、`tools/TaskUpdateTool/TaskUpdateTool.ts`406 行)、`tools/TaskGetTool/TaskGetTool.ts`128 行)、`tools/TaskListTool/TaskListTool.ts`116 行)、`hooks/useTaskListWatcher.ts`221 行)の完全分析に基づく。
### 一、TaskRecord の完全フィールド
チュートリアルでは id、subject、status、owner、blockedBy のみ解説。CC は実際に 9 フィールドを持つ(`utils/tasks.ts:76-89`):
| フィールド | 型 | 用途 |
|------|------|------|
| `id` | string | 昇順整数 ID |
| `subject` | string | 短いタイトル |
| `description` | string | 自由形式の説明 |
| `activeForm` | string? | 現在進行形、in_progress 時にスピナーに表示 |
| `owner` | string? | 割り当てられた agent ID |
| `status` | pending/in_progress/completed | ライフサイクル |
| `blocks` | string[] | このタスクがブロックするタスク ID(下流) |
| `blockedBy` | string[] | このタスクをブロックするタスク ID(上流) |
| `metadata` | Record? | 任意の拡張キーバリューペア |
保存場所:`~/.claude/tasks/{taskListId}/{id}.json`。タスクごとに 1 ファイル。
### 二、目的は近いが、機構は独立
Task ツールと TodoWrite は共存できるが、同じストレージモデルを共有しない。現在の対話型セッションは構造化 Task ツールを既定で使い、TodoWrite は非対話型や Agent SDK などの互換サーフェスに残る。公開範囲はリリースや設定で変わり得る。Task レコードはファイルロック、依存関係、ownership、リアクティブ監視、ライフサイクルフックを追加し、TodoWrite はリスト全体を置換するセッションチェックリストである。
### 三、並行認識のロック機構
`claimTask()``utils/tasks.ts:541-612`)は二重ロックで競合を防止:
**タスクファイルロック**`proper-lockfile``{taskId}.json` をロック(最大 30 リトライ、指数バックオフ 5-100ms)。ロック内:
1. タスクを再読込(TOCTOU 防止)
2. 既に他者が認識済み → `already_claimed`
3. 既に完了済み → `already_resolved`
4. 上流が未完了 → `blocked`
5. owner を設定
**リストレベルロック**(agent busy チェック時):`.lock` ファイル、全タスクを原子的に走査し該当 agent が他の open task を持つか確認。
注意:教学版は認識と作業開始を 1 ステップに統合(claim = owner 設定 + in_progress);実際の CC の `claimTask` は主に owner 競合を解決し、owner のみを設定して status は変更しない。status の更新は `TaskUpdate` が担当。
### 四、高水位標による ID 再利用防止
`.highwatermark` ファイルが過去に割り当てられた最大タスク ID を記録。タスクが削除されても ID は再利用されない。
### 五、4 つの Task ツール
CC のタスクシステムは 4 つのツールを持つ(チュートリアルの汎用 Task ツールとは異なる):`TaskCreate``TaskGet``TaskUpdate``TaskList`。すべて `isConcurrencySafe: true``shouldDefer: true` が設定(ツールスキーマは初期プロンプトに含まれず、ToolSearch 後にのみ可視)。
教学版の `create_task(blockedBy=...)` は作成時に直接依存を宣言する合理な簡略化。実際の CC の `TaskCreate` は subject/description/activeForm/metadata のみを受け付け、依存関係は `TaskUpdate``addBlocks/addBlockedBy` で管理される。
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+4 -61
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s14 → ... → s20 → s21 → s22
s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s14 → ... → s20 → s21
> *"Break big goals into small tasks, order them, persist"* — File-persisted task graph, the foundation for multi-agent collaboration.
>
@@ -14,7 +14,7 @@ s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s1
The agent receives a project: set up a database, write APIs, add tests. It uses s05's TodoWrite to create a checklist, then starts writing the API first, gets halfway through and realizes there are no database tables, goes back to fix them; when adding tests, discovers the API interface signatures have changed again...
You can't build the roof before laying the foundation. Tasks have ordering. Task dependencies should form a Directed Acyclic Graph (DAG); the teaching version only demonstrates `blockedBy` checking, without cycle detection.
You can't build the roof before laying the foundation. Tasks have ordering. Task prerequisites can be represented as a Directed Acyclic Graph (DAG); this chapter records them with `blockedBy`.
s05's TodoWrite is an execution checklist for the current task, kept in session memory. What you need here is a **task system**: each task is a JSON file, tasks have `blockedBy` dependencies, and they persist across sessions on disk.
@@ -24,7 +24,7 @@ s05's TodoWrite is an execution checklist for the current task, kept in session
![Task System Overview](images/task-system-overview.en.svg)
Teaching code keeps a basic agent loop, omitting S11's full error recovery (RecoveryState, backoff, escalation, reactive compact, fallback model) to stay focused on the task system. Added: 5 teaching tools + `.tasks/` directory for persistence + `blockedBy` dependency checking. The task system and error recovery are independent layers: in CC source, `utils/tasks.ts` handles task state while query recovery handles model-call failures.
This chapter adds 5 task tools, persistence in the `.tasks/` directory, and `blockedBy` dependency checks.
TodoWrite vs Task System:
@@ -39,8 +39,6 @@ TodoWrite vs Task System:
| Granularity | The agent's own steps | Tasks that can be claimed, tracked, and unblocked |
| Update contract | Replace the whole checklist | Create/get/update/list individual records |
The teaching API spells the lifecycle out as `create_task`, `list_tasks`, `get_task`, `claim_task`, and `complete_task`. Claude Code's product surface groups those operations into four tools: `TaskCreate`, `TaskGet`, `TaskUpdate`, and `TaskList`; claiming and completion are updates, not separate official tools.
---
## How It Works
@@ -62,7 +60,7 @@ class Task:
blockedBy: list[str] # List of dependency task IDs
```
IDs are generated with `timestamp + random hex`, simple but sufficient. CC uses sequential IDs + a highwatermark file to prevent ID reuse, which is a more rigorous design.
IDs are generated with `timestamp + random hex`.
### create_task: Create Tasks
@@ -162,8 +160,6 @@ Here `claim` / `complete` are actions, while `pending` / `in_progress` / `comple
- **claim_task**: `pending``in_progress`. Sets owner, begins work.
- **complete_task**: `in_progress``completed`. Marks the task done and unblocks downstream.
CC has no `in_progress → pending` release path. If a teammate terminates or shuts down, CC unassigns its unfinished tasks (clears owner) and resets status to `pending`, allowing other agents to reclaim them. The teaching version omits this recovery path.
### Putting It Together
```python
@@ -228,58 +224,5 @@ The task graph is in place. But some tasks take a long time — like running ful
s13 Background Tasks → Slow operations go to the background. The agent continues processing other tasks, and gets notified when the background work is done.
<details>
<summary>Deep Dive into CC Source</summary>
> The following is a complete analysis based on CC source code `utils/tasks.ts` (862 lines), `tools/TaskCreateTool/TaskCreateTool.ts` (138 lines), `tools/TaskUpdateTool/TaskUpdateTool.ts` (406 lines), `tools/TaskGetTool/TaskGetTool.ts` (128 lines), `tools/TaskListTool/TaskListTool.ts` (116 lines), `hooks/useTaskListWatcher.ts` (221 lines).
### 1. TaskRecord's Full Fields
The tutorial only covers id, subject, status, owner, blockedBy. CC actually has 9 fields (`utils/tasks.ts:76-89`):
| Field | Type | Purpose |
|------|------|---------|
| `id` | string | Incrementing integer ID |
| `subject` | string | Short title |
| `description` | string | Free-form description |
| `activeForm` | string? | Present tense form, shown in spinner when in_progress |
| `owner` | string? | Assigned agent ID |
| `status` | pending/in_progress/completed | Lifecycle |
| `blocks` | string[] | Task IDs blocked by this task (downstream) |
| `blockedBy` | string[] | Task IDs blocking this task (upstream) |
| `metadata` | Record? | Arbitrary extension key-value pairs |
Storage location: `~/.claude/tasks/{taskListId}/{id}.json`. One file per task.
### 2. Same Intent, Independent Mechanisms
Task tools and TodoWrite can coexist, but they do not share one storage model. Current interactive sessions default to structured Task tools, while TodoWrite remains on compatibility surfaces such as non-interactive and Agent SDK usage; exact exposure varies by release and configuration. Task records add file-lock concurrency protection, dependency enforcement, ownership, reactive monitoring, and lifecycle hooks. TodoWrite remains a whole-list session checklist.
### 3. Concurrent Claim Locking
`claimTask()` (`utils/tasks.ts:541-612`) uses dual locking to prevent races:
**Task file lock**: `proper-lockfile` locks `{taskId}.json` (up to 30 retries, exponential backoff 5-100ms). Inside the lock:
1. Re-read task (prevent TOCTOU)
2. Check already claimed by another → `already_claimed`
3. Check already completed → `already_resolved`
4. Check upstream not completed → `blocked`
5. Set owner
**List-level lock** (agent busy check): `.lock` file, atomic scan of all tasks to check if the agent already has other open tasks.
Note: The teaching version combines claiming and starting work into one step (claim = set owner + in_progress); real CC's `claimTask` primarily resolves owner competition — it only sets owner without changing status. Status updates are handled by `TaskUpdate`.
### 4. High-Water Mark to Prevent ID Reuse
The `.highwatermark` file records the highest task ID ever assigned. Even if a task is deleted, its ID won't be reused.
### 5. Four Task Tools
CC's task system has four tools (not the tutorial's single generic Task tool): `TaskCreate`, `TaskGet`, `TaskUpdate`, `TaskList`. All set `isConcurrencySafe: true` and `shouldDefer: true` (tool schemas aren't in the initial prompt; only visible after ToolSearch).
The teaching version's `create_task(blockedBy=...)` declares dependencies at creation time, which is a reasonable simplification. Real CC's `TaskCreate` only accepts subject/description/activeForm/metadata — dependencies are maintained via `TaskUpdate`'s `addBlocks/addBlockedBy`.
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+4 -61
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s14 → ... → s20 → s21 → s22
s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s14 → ... → s20 → s21
> *"大目标拆成小任务, 排好序, 持久化"* — 文件持久化的任务图, 多 agent 协作的基础。
>
@@ -14,7 +14,7 @@ s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s1
Agent 接到一个项目:搭数据库、写 API、加测试。它用 s05 的 TodoWrite 列了一张清单,然后开始写 API,写到一半发现没数据库表,回头补;加测试时发现 API 接口签名又变了...
盖房子不能先盖屋顶再打地基。任务之间有先后。任务依赖应该形成有向无环图(DAG;教学版只演示 `blockedBy` 检查,没有实现环检测
盖房子不能先盖屋顶再打地基。任务之间有先后。任务之间的前置依赖可以表示为有向无环图(DAG,本章用 `blockedBy` 记录这些依赖
s05 的 TodoWrite 是当前任务的执行清单,保存在会话内存中。这里需要的是**任务系统**:每个任务是一个 JSON 文件,任务之间有 `blockedBy` 依赖,跨会话持久化在磁盘上。
@@ -24,7 +24,7 @@ s05 的 TodoWrite 是当前任务的执行清单,保存在会话内存中。
![Task System Overview](images/task-system-overview.svg)
教学代码保留基础 agent loop,为聚焦任务系统省略了 S11 的完整错误恢复(RecoveryState、退避、升级、reactive compact、fallback model)。新增 5 个教学工具 + `.tasks/` 目录持久化 + `blockedBy` 依赖检查。任务系统与错误恢复是独立层:CC 源码中的任务模块管理任务状态,query recovery 管理模型调用失败。
本章新增 5 个任务工具、`.tasks/` 目录持久化 `blockedBy` 依赖检查。
TodoWrite vs Task System
@@ -39,8 +39,6 @@ TodoWrite vs Task System
| 粒度 | Agent 自己的步骤 | 可被认领、追踪、解锁的任务 |
| 更新契约 | 整表替换 | 对单条记录执行创建、读取、更新、列举 |
教学 API 用 `create_task``list_tasks``get_task``claim_task``complete_task` 把生命周期展开。Claude Code 的产品表面把这些操作归入四个工具:`TaskCreate``TaskGet``TaskUpdate``TaskList`;认领与完成属于更新,不是独立的官方工具。
---
## 工作原理
@@ -62,7 +60,7 @@ class Task:
blockedBy: list[str] # 依赖的任务 ID 列表
```
ID 用 `timestamp + random hex` 生成,简单但够用。CC 用顺序 ID + highwatermark 文件防止 ID 重用,是更严谨的设计
ID 使`timestamp + random hex` 生成。
### create_task: 创建任务
@@ -162,8 +160,6 @@ pending ──claim──→ in_progress ──complete──→ completed
- **claim_task**: `pending``in_progress`。设置 owner,开始工作。
- **complete_task**: `in_progress``completed`。把任务标记为完成,并解锁下游。
CC 没有 `in_progress → pending` 的 release 路径。如果 teammate 终止或 shutdownCC 会把它未完成的任务 unassign(清除 owner),并将 status 重置为 `pending`,方便其他 agent 重新认领。教学版省略了这一恢复路径。
### 合起来跑
```python
@@ -228,58 +224,5 @@ python s12_task_system/code.py
s13 Background Tasks → 慢操作放后台。Agent 继续处理其他任务,后台跑完了通知它。
<details>
<summary>深入 CC 源码</summary>
> 以下基于 CC 源码 `utils/tasks.ts`862 行)、`tools/TaskCreateTool/TaskCreateTool.ts`138 行)、`tools/TaskUpdateTool/TaskUpdateTool.ts`406 行)、`tools/TaskGetTool/TaskGetTool.ts`128 行)、`tools/TaskListTool/TaskListTool.ts`116 行)、`hooks/useTaskListWatcher.ts`221 行)的分析。
### 一、TaskRecord 的完整字段
教学版只讲了 id、subject、status、owner、blockedBy。CC 实际有 9 个字段(`utils/tasks.ts:76-89`):
| 字段 | 类型 | 用途 |
|------|------|------|
| `id` | string | 递增整数 ID |
| `subject` | string | 简短标题 |
| `description` | string | 自由格式描述 |
| `activeForm` | string? | 进行时态,in_progress 时在 spinner 显示 |
| `owner` | string? | 分配的 agent ID |
| `status` | pending/in_progress/completed | 生命周期 |
| `blocks` | string[] | 此任务阻塞的任务 ID(下游) |
| `blockedBy` | string[] | 阻塞此任务的任务 ID(上游) |
| `metadata` | Record? | 任意扩展键值对 |
存储位置:`~/.claude/tasks/{taskListId}/{id}.json`。每个任务一个文件。
### 二、目标相近,机制独立
Task 工具与 TodoWrite 可以同时存在,但不共享一套存储模型。当前交互式会话默认使用结构化 Task 工具;TodoWrite 仍位于非交互式、Agent SDK 等兼容表面,具体暴露方式会随版本和配置变化。Task 记录增加了文件锁并发保护、依赖强制执行、ownership、响应式监听和生命周期 hooks;TodoWrite 仍是整表替换的会话清单。
### 三、并发认领的锁机制
`claimTask()``utils/tasks.ts:541-612`)用双重锁防竞争:
**任务文件锁**`proper-lockfile` 锁住 `{taskId}.json`(最多重试 30 次,指数退避 5-100ms)。锁内:
1. 重新读取任务(防 TOCTOU
2. 检查已被他人认领 → `already_claimed`
3. 检查已完成 → `already_resolved`
4. 检查上游未完成 → `blocked`
5. 设置 owner
**列表级锁**agent busy 检查时):`.lock` 文件,原子性扫描所有任务并检查该 agent 是否已有其他 open task。
注意:教学版把 claim 和开始工作合成一步(claim = set owner + in_progress);真实 CC 的 `claimTask` 主要解决 owner 竞争,只设 owner 不改 status,状态更新由 `TaskUpdate` 完成。
### 四、高水位标防 ID 重用
`.highwatermark` 文件记录曾分配过的最高任务 ID。即使任务被删除,ID 也不会被重用。
### 五、四个 Task 工具
CC 的任务系统有四个工具(不是教学版的一个通用 Task 工具):`TaskCreate``TaskGet``TaskUpdate``TaskList`。全部设置 `isConcurrencySafe: true``shouldDefer: true`(工具 schema 不在初始 prompt 中,需 ToolSearch 后才可见)。
教学版的 `create_task(blockedBy=...)` 在创建时直接声明依赖,是合理简化。真实 CC 的 `TaskCreate` 只接受 subject/description/activeForm/metadata,依赖关系由 `TaskUpdate``addBlocks/addBlockedBy` 维护。
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+2 -4
View File
@@ -14,10 +14,8 @@ Changes from s11:
- complete_task: set completed + report unblocked downstream
- 5 new tools: create_task, list_tasks, get_task, claim_task, complete_task
Note: Teaching code keeps a basic agent loop to stay focused on the task
system. S11's full error recovery (RecoveryState, backoff, escalation,
reactive compact, fallback model) is omitted in real CC, tasks.ts and
withRetry are independent layers that compose naturally.
This chapter keeps the agent loop focused on the task system. Error recovery
remains the independent layer introduced in s11.
"""
import os, subprocess, json, time, random
@@ -57,7 +57,7 @@
<text x="195" y="205" fill="#134e4a" font-size="11" font-weight="700" text-anchor="middle">.tasks/ — Cross-session Persistence</text>
<text x="60" y="222" fill="#0d9488" font-size="9">task_xxx.json · task_yyy.json · task_zzz.json</text>
<text x="60" y="238" fill="#6b7280" font-size="8">{id, subject, description, status, owner, blockedBy}</text>
<text x="60" y="252" fill="#6b7280" font-size="8">Tutorial ID: timestamp + random | CC: sequential ID + highwatermark</text>
<text x="60" y="252" fill="#6b7280" font-size="8">ID: timestamp + random</text>
<!-- Arrow: tools → .tasks/ -->
<path d="M 440 144 L 440 165 L 250 165 L 250 185" fill="none" stroke="#0d9488" stroke-width="1.5" marker-end="url(#arrow-teal)"/>

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

@@ -57,7 +57,7 @@
<text x="195" y="205" fill="#134e4a" font-size="11" font-weight="700" text-anchor="middle">.tasks/ — セッション横断永続化</text>
<text x="60" y="222" fill="#0d9488" font-size="9">task_xxx.json · task_yyy.json · task_zzz.json</text>
<text x="60" y="238" fill="#6b7280" font-size="8">{id, subject, description, status, owner, blockedBy}</text>
<text x="60" y="252" fill="#6b7280" font-size="8">チュートリアル ID: timestamp + random | CC: 順次 ID + highwatermark</text>
<text x="60" y="252" fill="#6b7280" font-size="8">ID: timestamp + random</text>
<!-- 矢印: tools → .tasks/ -->
<path d="M 440 144 L 440 165 L 250 165 L 250 185" fill="none" stroke="#0d9488" stroke-width="1.5" marker-end="url(#arrow-teal)"/>

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

@@ -57,7 +57,7 @@
<text x="195" y="205" fill="#134e4a" font-size="11" font-weight="700" text-anchor="middle">.tasks/ — 跨会话持久化</text>
<text x="60" y="222" fill="#0d9488" font-size="9">task_xxx.json · task_yyy.json · task_zzz.json</text>
<text x="60" y="238" fill="#6b7280" font-size="8">{id, subject, description, status, owner, blockedBy}</text>
<text x="60" y="252" fill="#6b7280" font-size="8">教学版 ID: timestamp + random | CC: 顺序 ID + highwatermark</text>
<text x="60" y="252" fill="#6b7280" font-size="8">ID: timestamp + random</text>
<!-- Arrow: tools → .tasks/ -->
<path d="M 440 144 L 440 165 L 250 165 L 250 185" fill="none" stroke="#0d9488" stroke-width="1.5" marker-end="url(#arrow-teal)"/>

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

+4 -49
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s11 → s12 → `s13` → [s14](../s14_cron_scheduler/) → s15 → ... → s20 → s21 → s22
s01 → ... → s11 → s12 → `s13` → [s14](../s14_cron_scheduler/) → s15 → ... → s20 → s21
> *"遅い操作はバックグラウンドへ、agent は処理を継続"* — バックグラウンドスレッドでコマンドを実行、完了時に通知を注入。
>
@@ -24,7 +24,7 @@ Agent の bash ツールも同じ。`pip install torch` は 10 分、`npm run bu
![Background Tasks Overview](images/background-tasks-overview.ja.svg)
教学版は S12 の簡易タスクシステムとプロンプト組み立てを踏襲。バックグラウンドタスクに集中するため、完全なエラーリカバリ、メモリ、スキルシステムは省略。唯一の変更:遅い操作をバックグラウンドスレッドに投げ、Agent はループを継続、バックグラウンド完了時に通知を注入
この章では、時間のかかる操作をバックグラウンドスレッドに送り、Agent はループを続行する。タスクが完了すると、結果が通知として会話に注入される
同期 vs バックグラウンド:
@@ -41,7 +41,7 @@ Agent の bash ツールも同じ。`pip install torch` は 10 分、`npm run bu
### should_run_background: 明示的リクエスト優先、ヒューリスティックフォールバック
モデルは bash ツールの `run_in_background` パラメータで明示的にバックグラウンド実行をリクエストする。モデルが指定ない場合、教学版はキーワードヒューリスティックにフォールバック
モデルは bash ツールの `run_in_background` パラメータで明示的にバックグラウンド実行をリクエストする。指定ない場合はキーワードヒューリスティックで判断する
```python
def is_slow_operation(tool_name: str, tool_input: dict) -> bool:
@@ -61,8 +61,6 @@ def should_run_background(tool_name: str, tool_input: dict) -> bool:
return is_slow_operation(tool_name, tool_input)
```
CC の bash ツールスキーマには `run_in_background: boolean` パラメータがある(`BashTool.tsx:241`)。モデルがどのコマンドをバックグラウンドにするかを決定、キーワード推測ではない。教学版はヒューリスティックをフォールバックとして残すが、主パスはモデルの明示的リクエスト。
### start_background_task: バックグラウンド実行とライフサイクル
ツール呼び出しをワーカー関数にラップし、daemon スレッドにディスパッチ。各バックグラウンドタスクは一意 ID を持ち、`background_tasks` 辞書で状態を追跡:
@@ -96,7 +94,7 @@ def start_background_task(block) -> str:
return bg_id
```
`[Running in background...]` ではなく `bg_id` を返す。`daemon=True` Agent プロセス終了時にスレッドも終了。教学版はメモリ内辞書で追跡。実際の CC は `LocalShellTaskState` を持ち、出力をファイルにリダイレクト、タスク停止や継続出力読み取りを含む完全なライフサイクルを備える。
`start_background_task()` `bg_id` を返す。`daemon=True` により、Agent プロセス終了時にスレッドも終了る。
### collect_background_results: 通知収集
@@ -157,8 +155,6 @@ messages.append({"role": "user", "content": user_content})
遅い操作は `bg_id` 付きプレースホルダー tool_result を返し、LLM はコマンドがまだ実行中だと知り、先に他のことをできる。バックグラウンド完了時、通知は独立した text block として現在のターンの tool_result と一緒に 1 つの user メッセージを構成する。
教学版は agent loop が継続実行中にバックグラウンド結果をポーリングする。実際の CC は通知キュー(`messageQueueManager.ts`)でバックグラウンド完了イベントを後続ターンに配信、ツールループを待つ必要はない。
### 組み合わせて実行
```
@@ -216,46 +212,5 @@ python s13_background_tasks/code.py
s14 Cron Scheduler → Agent にアラームクロックを付ける。
<details>
<summary>CC ソースコード深掘り</summary>
> 以下は CC ソースコード `query.ts`211, 1054-1060, 1411-1482 行)、`services/toolUseSummary/toolUseSummaryGenerator.ts`L15 プロンプトテキスト)、`LocalShellTask.tsx`L24-25 定数, L59-98 ウォッチドッグロジック)、`messageQueueManager.ts`(通知キュー)、`utils/task/framework.ts`L267 `enqueueTaskNotification`)の完全分析に基づく。
### 一、pendingToolUseSummaryHaiku バックグラウンド生成
CC は各ツール実行バッチの後、Haiku サイドクエリを開始してツール使用サマリを生成。開始コードは `query.ts:1411-1482`、プロンプトテキストは `services/toolUseSummary/toolUseSummaryGenerator.ts:15`(変数 `TOOL_USE_SUMMARY_SYSTEM_PROMPT`)。プロンプトは "Write a short summary label... think git-commit-subject, not sentence"、過去形、約 30 文字。
Haiku サマリ(~1s)はメインモデルのストリーミング出力(5-30s)中に完了。次のターン開始前にサマリを yield。SDK コンシューマーはこれらのサマリをモバイル進捗表示に使用。
### 二、スレッドモデル:本当のスレッドはない
CC は Node.js/Bun のシングルスレッドイベントループで動作。「バックグラウンド」は単に「await しない」こと。`ShellCommand.background(taskId)` は stdout/stderr をファイルにリダイレクトし、プロセスを独立実行。
### 三、7 種のバックグラウンドタスク型
CC は 7 種のバックグラウンドタスク型を定義(`Task.ts:7-13`):`local_bash``local_agent``remote_agent``in_process_teammate``local_workflow``monitor_mcp``dream`。それぞれ独自の登録、ライフサイクル、通知メカニズムを持つ。
### 四、通知注入:コマンドキュー
バックグラウンドタスク完了時、`enqueueTaskNotification``utils/task/framework.ts:267`)または `enqueuePendingNotification``messageQueueManager.ts`)で共有コマンドキューにエンキュー。通知形式は構造化 XML:
```xml
<task_notification>
<status>completed</status>
<summary>Background command "npm test" completed (exit code 0)</summary>
</task_notification>
```
優先度は `next` > `later``messageQueueManager.ts`)。バックグラウンドタスクはデフォルト `later`(ユーザー入力をブロックしない)。消費点は `query.ts:1566-1593`
### 五、停滞ウォッチドッグ
バックグラウンド bash タスクにはウォッチドッグがある(`LocalShellTask.tsx` L24-25 定数, L59-98 ロジック)。出力の停滞を定期チェックし、45 秒間増加がない場合にインタラクティブプロンプト(`(y/n)` 等)を検出、バックグラウンドタスクが無応答のインタラクティブダイアログでスタックするのを防ぐ。
### 六、同時実行制限
フォアグラウンドツール呼び出し:`CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY`(デフォルト 10 同時実行安全ツール)。バックグラウンド bash タスク:ハードリミットなし、独立したサブプロセス。
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+4 -49
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s11 → s12 → `s13` → [s14](../s14_cron_scheduler/) → s15 → ... → s20 → s21 → s22
s01 → ... → s11 → s12 → `s13` → [s14](../s14_cron_scheduler/) → s15 → ... → s20 → s21
> *"Slow operations go to the background, agent continues processing"* — Background threads run commands, inject notifications when done.
>
@@ -24,7 +24,7 @@ Reading files is milliseconds, no wait. `git status` returns in under a second,
![Background Tasks Overview](images/background-tasks-overview.en.svg)
Teaching code carries forward S12's simplified task system and prompt assembly; to stay focused on background tasks, it omits full error recovery, memory, and skill systems. The only change: slow operations go to background threads, the agent continues running the loop, and background results are injected as notifications.
This chapter sends slow operations to background threads so the agent can keep running the loop. When a task finishes, its result is injected as a notification.
Sync vs Background:
@@ -41,7 +41,7 @@ Sync vs Background:
### should_run_background: Explicit Request First, Heuristic Fallback
The model explicitly requests background execution via the bash tool's `run_in_background` parameter. If the model doesn't specify, the teaching version falls back to keyword heuristics:
The model explicitly requests background execution via the bash tool's `run_in_background` parameter. If the model does not specify it, keyword heuristics decide:
```python
def is_slow_operation(tool_name: str, tool_input: dict) -> bool:
@@ -61,8 +61,6 @@ def should_run_background(tool_name: str, tool_input: dict) -> bool:
return is_slow_operation(tool_name, tool_input)
```
CC's bash tool schema has a `run_in_background: boolean` parameter (`BashTool.tsx:241`). The model decides which commands go to background, no keyword guessing. The teaching version keeps heuristics as fallback, but the primary path is explicit model request.
### start_background_task: Background Execution and Lifecycle
Wraps the tool call in a worker function, dispatches to a daemon thread. Each background task gets a unique ID, with state tracked in the `background_tasks` dict:
@@ -96,7 +94,7 @@ def start_background_task(block) -> str:
return bg_id
```
Returns `bg_id` instead of just `[Running in background...]`. `daemon=True` ensures threads exit when the agent process exits. The teaching version uses in-memory dicts for tracking; real CC has `LocalShellTaskState`, output redirected to files, with full lifecycle including stopping tasks and reading subsequent output.
`start_background_task()` returns `bg_id`. `daemon=True` ensures the thread exits with the agent process.
### collect_background_results: Notification Collection
@@ -157,8 +155,6 @@ messages.append({"role": "user", "content": user_content})
Slow operations get a placeholder tool_result with `bg_id`, so the LLM knows this command is still running and can do other things first. When background completes, the notification is injected as an independent text block alongside the current turn's tool_results in one user message.
The teaching version polls background results while the agent loop continues running. Real CC uses a notification queue (`messageQueueManager.ts`) to deliver background completion events to subsequent turns, without waiting for the tool loop.
### Putting It Together
```
@@ -216,46 +212,5 @@ Background tasks solved "slow operations don't block." But what if you want to d
s14 Cron Scheduler → Give the agent an alarm clock.
<details>
<summary>Deep Dive into CC Source</summary>
> The following is a complete analysis based on CC source code `query.ts` (lines 211, 1054-1060, 1411-1482), `services/toolUseSummary/toolUseSummaryGenerator.ts` (L15 prompt text), `LocalShellTask.tsx` (L24-25 constants, L59-98 watchdog logic), `messageQueueManager.ts` (notification queue), `utils/task/framework.ts` (L267 `enqueueTaskNotification`).
### 1. pendingToolUseSummary: Haiku Background Generation
CC starts a Haiku side-query after each batch of tool executions to generate a tool use summary. Initiated at `query.ts:1411-1482`, prompt text defined at `services/toolUseSummary/toolUseSummaryGenerator.ts:15` (variable `TOOL_USE_SUMMARY_SYSTEM_PROMPT`). The prompt is "Write a short summary label... think git-commit-subject, not sentence", past tense, ~30 characters.
Haiku summary (~1s) completes during the main model's streaming output (5-30s). Before the next turn starts, the summary is yielded. SDK consumers use these summaries for mobile progress display.
### 2. Thread Model: No Real Threads
CC runs on Node.js/Bun's single-threaded event loop. "Background" just means "don't await". `ShellCommand.background(taskId)` redirects stdout/stderr to files, letting the process run independently.
### 3. Seven Background Task Types
CC defines 7 background task types (`Task.ts:7-13`): `local_bash`, `local_agent`, `remote_agent`, `in_process_teammate`, `local_workflow`, `monitor_mcp`, `dream`. Each has its own registration, lifecycle, and notification mechanism.
### 4. Notification Injection: Command Queue
When a background task completes, it's enqueued via `enqueueTaskNotification` (`utils/task/framework.ts:267`) or `enqueuePendingNotification` (`messageQueueManager.ts`) into a shared command queue. The notification format is structured XML:
```xml
<task_notification>
<status>completed</status>
<summary>Background command "npm test" completed (exit code 0)</summary>
</task_notification>
```
Priority is `next` > `later` (`messageQueueManager.ts`). Background tasks default to `later` (don't block user input). Consumption point at `query.ts:1566-1593`.
### 5. Stall Watchdog
Background bash tasks have a watchdog (`LocalShellTask.tsx` L24-25 constants, L59-98 logic) that periodically checks if output has stalled. After 45 seconds with no growth, it detects interactive prompts (`(y/n)` etc.), preventing background tasks from getting stuck on unanswered interactive dialogs.
### 6. Concurrency Limits
Foreground tool calls: `CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY` (default 10 concurrent safe tools). Background bash tasks: no hard limit, they're independent subprocesses.
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+4 -49
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s11 → s12 → `s13` → [s14](../s14_cron_scheduler/) → s15 → ... → s20 → s21 → s22
s01 → ... → s11 → s12 → `s13` → [s14](../s14_cron_scheduler/) → s15 → ... → s20 → s21
> *"慢操作丢后台, agent 继续处理"* — 后台线程跑命令, 完成后注入通知。
>
@@ -24,7 +24,7 @@ Agent 的 bash 工具也一样。`pip install torch` 要 10 分钟,`npm run bu
![Background Tasks Overview](images/background-tasks-overview.svg)
教学代码沿用 S12 的简化任务系统和 prompt 组装;为了聚焦后台任务,省略完整错误恢复、记忆和技能系统。唯一的变动:慢操作扔到后台线程,Agent 继续跑循环,后台完成后把通知注入对话
本章把慢操作放入后台线程,Agent 继续运行循环;任务完成后,结果以通知形式注入对话。
同步 vs 后台:
@@ -41,7 +41,7 @@ Agent 的 bash 工具也一样。`pip install torch` 要 10 分钟,`npm run bu
### should_run_background: 显式请求优先,启发式兜底
模型通过 bash 工具的 `run_in_background` 参数显式请求后台执行。如果模型没指定,教学版用关键词启发式兜底
模型通过 bash 工具的 `run_in_background` 参数显式请求后台执行。如果模型没指定,则使用关键词启发式判断
```python
def is_slow_operation(tool_name: str, tool_input: dict) -> bool:
@@ -61,8 +61,6 @@ def should_run_background(tool_name: str, tool_input: dict) -> bool:
return is_slow_operation(tool_name, tool_input)
```
CC 的 bash 工具 schema 里有 `run_in_background: boolean` 参数(`BashTool.tsx:241`)。模型自己决定哪些命令丢后台,不靠关键词猜。教学版保留启发式作为兜底,但主路径是模型显式请求。
### start_background_task: 后台执行与生命周期
把工具调用包装成 worker 函数,扔到 daemon 线程里执行。每个后台任务有唯一 ID,状态存在 `background_tasks` 字典里:
@@ -96,7 +94,7 @@ def start_background_task(block) -> str:
return bg_id
```
返回 `bg_id` 而不是只返回 `[Running in background...]``daemon=True` 确保 Agent 进程退出时线程跟着退出。教学版用内存字典追踪状态;真实 CC 有 `LocalShellTaskState`,输出重定向到文件,支持停止任务、读取后续输出等完整生命周期。
`start_background_task()` 返回 `bg_id``daemon=True` 确保 Agent 进程退出时线程一起退出。
### collect_background_results: 通知收集
@@ -157,8 +155,6 @@ messages.append({"role": "user", "content": user_content})
慢操作先回一个带 `bg_id` 的占位 tool_result,LLM 知道这个命令还在跑,可以先做别的事。后台完成后,通知作为独立 text block 和当前轮的 tool_result 一起组成 user 消息。
教学版在 agent loop 继续运行时轮询后台结果。真实 CC 通过通知队列(`messageQueueManager.ts`)把后台完成事件送入后续 turn,不需要等工具循环。
### 合起来跑
```
@@ -216,46 +212,5 @@ python s13_background_tasks/code.py
s14 Cron Scheduler → 给 Agent 装一个闹钟。
<details>
<summary>深入 CC 源码</summary>
> 以下基于 CC 源码 `query.ts`211, 1054-1060, 1411-1482 行)、`services/toolUseSummary/toolUseSummaryGenerator.ts`L15 prompt 文本)、`LocalShellTask.tsx`L24-25 常量, L59-98 看门狗逻辑)、`messageQueueManager.ts`(通知队列)、`utils/task/framework.ts`L267 `enqueueTaskNotification`)的完整分析。
### 一、pendingToolUseSummaryHaiku 后台生成
CC 在每批工具执行完后,启动一个 Haiku side-query 生成工具使用摘要。发起代码在 `query.ts:1411-1482`prompt 文本定义在 `services/toolUseSummary/toolUseSummaryGenerator.ts:15`(变量名 `TOOL_USE_SUMMARY_SYSTEM_PROMPT`)。提示是 "Write a short summary label... think git-commit-subject, not sentence",过去时态,约 30 字符。
Haiku 摘要(~1s)在主模型流式生成(5-30s)期间完成。下一轮开始前,把摘要 yield 出去。SDK 消费这些摘要做移动端进度展示。
### 二、线程模型:没有真正的线程
CC 运行在 Node.js/Bun 单线程事件循环中。"后台"只是 "不 await"。`ShellCommand.background(taskId)` 把 stdout/stderr 重定向到文件,让进程独立运行。
### 三、七种后台任务类型
CC 定义了 7 种后台任务(`Task.ts:7-13`):`local_bash``local_agent``remote_agent``in_process_teammate``local_workflow``monitor_mcp``dream`。每种有自己的注册、生命周期和通知机制。
### 四、通知注入:命令队列
后台任务完成后通过 `enqueueTaskNotification``utils/task/framework.ts:267`)或 `enqueuePendingNotification``messageQueueManager.ts`)入队到共享命令队列。通知格式是结构化的 XML:
```xml
<task_notification>
<status>completed</status>
<summary>Background command "npm test" completed (exit code 0)</summary>
</task_notification>
```
优先级分 `next` > `later``messageQueueManager.ts`)。后台任务默认 `later`(不阻塞用户输入)。消费点在 `query.ts:1566-1593`
### 五、停滞看门狗
后台 bash 任务有一个看门狗(`LocalShellTask.tsx` L24-25 常量, L59-98 逻辑),定期检查输出是否停滞,45 秒无增长后检测交互式提示(`(y/n)` 等),防止后台任务卡在无人响应的交互式对话框。
### 六、并发限制
前台工具调用:`CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY`(默认 10 个并发安全工具)。后台 bash 任务:没有硬性限制,它们是独立的子进程。
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+2 -3
View File
@@ -16,9 +16,8 @@ Changes from s12:
- agent_loop: slow ops background + placeholder, inject notifications
- Notifications use <task_notification> format, not reused tool_use_id
Note: Teaching code keeps a basic agent loop to stay focused on background
tasks. S11's full error recovery (RecoveryState, backoff, escalation,
reactive compact, fallback model) is omitted.
This chapter keeps the agent loop focused on background tasks. Error recovery
remains the independent layer introduced in s11.
"""
import os, subprocess, json, time, random, threading
+2 -52
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s20 → s21 → s22
s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s20 → s21
> *"スケジュールに従って作業を生産、スケジューリングと実行を分離"* — cron スケジューリング、永続またはセッションレベル。
>
> **Harness 層**: スケジューリング — 独立スレッドが時刻を判定、キューがトリガーを配信。
@@ -21,7 +21,7 @@ s13 で Agent は遅い操作をバックグラウンドで実行できるよう
![Cron Scheduler Overview](images/cron-scheduler-overview.ja.svg)
教学版は S13 の簡易タスクシステム、バックグラウンド実行、プロンプト組み立てを踏襲。スケジューラに集中するため、完全なエラーリカバリ、メモリ、スキルシステムは省略。追加:独立した cron スケジューラスレッド、1 秒ごとにポーリング、時間が来たらタスク`cron_queue`投入し、queue processor が Agent のアイドル時に自動配信。
この章では独立した cron スケジューラスレッドを追加する。1 秒ごとに確認し、期限に達したジョブ`cron_queue`書き込み、queue processor が Agent のアイドル時に自動配信する
手動 vs スケジュール:
@@ -45,8 +45,6 @@ cron スケジューリングは 4 層に分かれる:
3. **Queue Processor**:キューが空でなく Agent がアイドルなら、一回の agent_loop を開始
4. **Consumer**agent_loop がキューから消費、messages に注入
教学版は最小の queue processor を実装する。`agent_lock` で Agent がアイドルかを判定し、キューに入った cron 作業を自動配信する。実際の CC の `useQueueProcessor.ts` はさらに UI ブロック、キュープライオリティ、メッセージモードを扱う。
### CronJob: データ構造
各 cron タスクは `CronJob` オブジェクト:
@@ -253,53 +251,5 @@ python s14_cron_scheduler/code.py
s15 Agent Teams → 一人の Agent では足りない、チームを組もう。永続的なチームメイト + 非同期受信箱。
<details>
<summary>CC ソースコード深掘り</summary>
> 以下は CC ソースコード `CronCreateTool.ts``cronScheduler.ts``cron.ts``cronTasks.ts``cronTasksLock.ts``useScheduledTasks.ts`139 行)の完全分析に基づく。
### 一、3 つの Cron ツール
CC はモデルに 3 つの cron ツールを公開:`CronCreate``CronDelete``CronList`。すべてコンパイル時ゲート `feature('AGENT_TRIGGERS')` とランタイム GrowthBook フラグ `tengu_kairos_cron` で制御。`CLAUDE_CODE_DISABLE_CRON` 環境変数でローカル上書きも可能。
### 二、ストレージ:`.claude/scheduled_tasks.json`
```json
{ "tasks": [{ "id": "abc12345", "cron": "0 9 * * *", "prompt": "...", "recurring": true, "durable": true, "createdAt": 1714567890000 }] }
```
durable タスクはディスクに書き込み。session-only タスクは `STATE.sessionCronTasks` メモリ配列に格納(プロセス再起動で消失)。`.scheduled_tasks.lock` ファイルで同じプロジェクトの複数セッション間の重複発火を防止。
### 三、スケジューラ:1 秒ポーリング
`cronScheduler.ts` は毎秒チェック(`CHECK_INTERVAL_MS = 1000`)。ロックを保持しているセッションがファイルタスクをトリガー。すべてのセッションが session-only タスクをトリガー。`chokidar` ファイルウォッチャーが `scheduled_tasks.json` の変更を監視。
### 四、cron 式:標準 5 フィールド
分 時 日 月 曜日。`*``*/N``N``N-M``N-M/S``N,M,...` をサポート。`L``W``?` は非サポート。すべての時間はローカルタイムゾーンで解釈。day-of-month と day-of-week が両方制約されている場合は OR セマンティクス。
### 五、ジッター(サンダリングハード防止)
- 定期タスク:トリガー遅延は期間の最大 10%(上限 15 分)、タスク ID ベースの決定的ハッシュ
- 一回限りタスク:発火時刻が `:00` または `:30` の場合、最大 90 秒早く発火
- ジッター設定は GrowthBook でリアルタイム調整可能、60 秒ごとにリフレッシュ
### 六、自動期限切れ
定期タスクは 7 日後に自動期限切れ(設定可能、上限 30 日)。期限切れ前に最後の一回を発火、その後自動削除。
### 七、ジョブ数上限
`MAX_JOBS = 50``CronCreateTool.ts:25`)。超過時はエラーを返す:"Too many scheduled jobs (max 50). Cancel one first."
### 八、トリガー注入
発火後、`enqueuePendingNotification()``priority: 'later'` としてコマンドキューにエンキュー。`workload: WORKLOAD_CRON` タグ付き、API は容量が逼迫している時に cron 発信リクエストを低い QoS で処理。
### 九、Queue Processor:自動配信
実際の CC は `useQueueProcessor.ts:48-60` により、アクティブな query がなく、UI がブロックされておらず、キューが空でない場合に自動的に処理をトリガーする。`queueProcessor.ts:52-87` がキュープライオリティに従ってコマンドを `handlePromptSubmit()` にディスパッチ。教学版は `queue_processor_loop` で核心動作を保つ:キューに作業があり Agent がアイドルなら、自動的に一回の agent_loop を開始する。
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+2 -52
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s20 → s21 → s22
s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s20 → s21
> *"Produce work on a schedule, decouple scheduling from execution"* — Cron scheduling, durable or session-level.
>
> **Harness Layer**: Scheduling — Independent thread checks time, queue delivers triggers.
@@ -21,7 +21,7 @@ s13 lets the agent run slow operations in the background, but every operation is
![Cron Scheduler Overview](images/cron-scheduler-overview.en.svg)
Teaching code carries forward S13's simplified task system, background execution, and prompt assembly; to stay focused on the scheduler, it omits full error recovery, memory, and skill systems. Added: an independent cron scheduler thread that polls every second, queues matching jobs into `cron_queue`, and a queue processor that delivers them when the agent is idle.
This chapter adds an independent cron scheduler thread: it checks once per second, writes due jobs to `cron_queue`, and a queue processor delivers them when the agent is idle.
Manual vs Scheduled:
@@ -45,8 +45,6 @@ Cron scheduling has four layers:
3. **Queue Processor**: sees non-empty queue and idle agent, starts one agent_loop turn
4. **Consumer**: agent_loop consumes queue and injects into messages
The teaching version implements a minimal queue processor: `agent_lock` tells whether the agent is idle, and queued cron work is delivered automatically. Real CC's `useQueueProcessor.ts` also handles UI blocking, queue priority, and different message modes.
### CronJob: Data Structure
Each cron task is a `CronJob` object:
@@ -253,53 +251,5 @@ One agent can do a lot now: plan, compress, background, schedule. But some tasks
s15 Agent Teams → One agent isn't enough, form a team. Persistent teammates + async inboxes.
<details>
<summary>Deep Dive into CC Source</summary>
> The following is a complete analysis based on CC source code `CronCreateTool.ts`, `cronScheduler.ts`, `cron.ts`, `cronTasks.ts`, `cronTasksLock.ts`, `useScheduledTasks.ts` (139 lines).
### 1. Three Cron Tools
CC exposes three cron tools to the model: `CronCreate`, `CronDelete`, `CronList`. All controlled by compile-time gate `feature('AGENT_TRIGGERS')` and runtime GrowthBook flag `tengu_kairos_cron`. There's also a `CLAUDE_CODE_DISABLE_CRON` env var for local override.
### 2. Storage: `.claude/scheduled_tasks.json`
```json
{ "tasks": [{ "id": "abc12345", "cron": "0 9 * * *", "prompt": "...", "recurring": true, "durable": true, "createdAt": 1714567890000 }] }
```
Durable tasks write to disk; session-only tasks live in `STATE.sessionCronTasks` memory array (lost on process restart). A `.scheduled_tasks.lock` file prevents duplicate firing across multiple sessions of the same project.
### 3. Scheduler: 1-Second Polling
`cronScheduler.ts` checks every second (`CHECK_INTERVAL_MS = 1000`). Whoever holds the lock triggers file tasks; all sessions trigger session-only tasks. A `chokidar` file watcher monitors `scheduled_tasks.json` changes.
### 4. Cron Expression: Standard 5 Fields
Minute hour day month weekday. Supports `*`, `*/N`, `N`, `N-M`, `N-M/S`, `N,M,...`. Doesn't support `L`, `W`, `?`. All times interpreted in local timezone. Day-of-month and day-of-week use OR semantics when both are constrained.
### 5. Jitter (Thundering Herd Prevention)
- Recurring tasks: trigger delay up to 10% of period (max 15 min), deterministic hash based on task ID
- One-shot tasks: up to 90s early when firing time falls on `:00` or `:30`
- Jitter config adjustable via GrowthBook, refreshed every 60 seconds
### 6. Auto-Expiration
Recurring tasks auto-expire after 7 days (configurable, max 30 days). Fire one last time before expiry, then auto-delete.
### 7. Job Limit
`MAX_JOBS = 50` (`CronCreateTool.ts:25`). Returns error when exceeded: "Too many scheduled jobs (max 50). Cancel one first."
### 8. Trigger Injection
After firing, enqueued via `enqueuePendingNotification()` with `priority: 'later'` into the command queue. Tagged `workload: WORKLOAD_CRON` — API serves cron-initiated requests at lower QoS when capacity is tight.
### 9. Queue Processor: Automatic Delivery
Real CC auto-triggers processing through `useQueueProcessor.ts:48-60` when no query is active, UI isn't blocked, and queue is non-empty. `queueProcessor.ts:52-87` dispatches commands to `handlePromptSubmit()` by queue priority. The teaching version keeps the core behavior with `queue_processor_loop`: when queued work exists and the agent is idle, it starts one agent_loop turn automatically.
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+2 -52
View File
@@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s20 → s21 → s22
s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s20 → s21
> *"按时间表生产工作, 调度与执行解耦"* — cron 调度, 持久化或会话级。
>
> **Harness 层**: 调度 — 独立线程判断时间, 队列传递触发。
@@ -21,7 +21,7 @@ s13 让 Agent 能后台执行慢操作,但所有操作仍然是你手动触发
![Cron Scheduler Overview](images/cron-scheduler-overview.svg)
教学代码沿用 S13 的简化任务系统、后台执行和 prompt 组装;为了聚焦调度器,省略完整错误恢复、记忆和技能系统。新增独立的 cron 调度线程每秒检查一次,时间到了把任务塞进 `cron_queue`再由 queue processor 在 Agent 空闲时自动交付。
本章新增独立的 cron 调度线程每秒检查一次,把到期任务写入 `cron_queue`再由 queue processor 在 Agent 空闲时自动交付。
手动 vs 定时:
@@ -45,8 +45,6 @@ Cron 调度分四层:
3. **Queue Processor**:发现队列非空且 Agent 空闲,启动一轮 agent_loop
4. **Consumer**agent_loop 从队列消费,注入到 messages
教学版实现的是最小 queue processor:用 `agent_lock` 判断 Agent 是否空闲,空闲时自动交付定时任务。真实 CC 的 `useQueueProcessor.ts` 还会处理 UI 阻塞、队列优先级和不同消息模式。
### CronJob: 数据结构
每个 cron 任务是一个 `CronJob` 对象:
@@ -253,53 +251,5 @@ python s14_cron_scheduler/code.py
s15 Agent Teams → 一个 Agent 不够,组队吧。持久队友 + 异步收件箱。
<details>
<summary>深入 CC 源码</summary>
> 以下基于 CC 源码 `CronCreateTool.ts``cronScheduler.ts``cron.ts``cronTasks.ts``cronTasksLock.ts``useScheduledTasks.ts`139 行)的完整分析。
### 一、三个 Cron 工具
CC 暴露了三个 cron 工具给模型:`CronCreate``CronDelete``CronList`。全部由编译时门控 `feature('AGENT_TRIGGERS')` 和运行时 GrowthBook 标志 `tengu_kairos_cron` 控制。还有一个 `CLAUDE_CODE_DISABLE_CRON` 环境变量做本地覆盖。
### 二、存储:`.claude/scheduled_tasks.json`
```json
{ "tasks": [{ "id": "abc12345", "cron": "0 9 * * *", "prompt": "...", "recurring": true, "durable": true, "createdAt": 1714567890000 }] }
```
Durable 任务写磁盘;session-only 任务存于 `STATE.sessionCronTasks` 内存数组(进程重启丢失)。还有一个 `.scheduled_tasks.lock` 文件防止同项目的多个 session 重复触发。
### 三、调度器:1 秒轮询
`cronScheduler.ts` 每秒检查一次(`CHECK_INTERVAL_MS = 1000`)。谁持有锁谁触发文件任务;所有 session 都触发仅 session 任务。还有一个 `chokidar` 文件观察者监视 `scheduled_tasks.json` 变更。
### 四、Cron 表达式:标准 5 字段
分钟 小时 日 月 星期。支持 `*``*/N``N``N-M``N-M/S``N,M,...`。不支持 `L``W``?`。所有时间以本地时区解释。Day-of-month 和 day-of-week 同时约束时用 OR 语义。
### 五、抖动(防惊群效应)
- 重复性任务:触发延迟最多可达期间的 10%(上限 15 分钟),基于任务 ID 的确定性哈希
- 一次性任务:当触发时间落在 `:00``:30` 时,最多提前 90 秒触发
- 抖动配置可通过 GrowthBook 实时调整,60 秒刷新一次
### 六、自动过期
重复性任务 7 天后自动过期(可配置,上限 30 天)。过期前最后一次触发,触发后自动删除。
### 七、作业数上限
`MAX_JOBS = 50``CronCreateTool.ts:25`)。超限时返回错误:"Too many scheduled jobs (max 50). Cancel one first."
### 八、触发注入
触发后通过 `enqueuePendingNotification()``priority: 'later'` 入队命令队列。标记 `workload: WORKLOAD_CRON`,API 在容量紧张时以更低的 QoS 为 cron 发起的请求服务。
### 九、Queue Processor:自动交付
真实 CC 通过 `useQueueProcessor.ts:48-60` 在无 query、无阻塞 UI、队列非空时自动触发处理。`queueProcessor.ts:52-87` 按队列优先级把命令交给 `handlePromptSubmit()`。教学版用 `queue_processor_loop` 保留核心行为:队列有任务且 Agent 空闲时,自动启动一轮 agent_loop。
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+2 -2
View File
@@ -678,8 +678,8 @@ def update_context(context: dict, messages: list) -> dict:
}
# ── Agent Loop (simplified, focused on cron scheduler) ──
# Teaching code keeps a basic agent loop. S11's full error recovery is omitted.
# ── Agent Loop (focused on cron scheduling) ──
# Error recovery remains the independent layer introduced in s11.
# cron_scheduler_loop produces work; queue_processor_loop wakes this loop when
# queued work exists and no other agent turn is running.
+215 -188
View File
@@ -1,155 +1,251 @@
# s15: Agent Teams — ランタイム実験:永続チームメイト
# s15: Agent Teams — チームランタイムと協調プロトコル
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s13 → s14 → `s15` → [s16](../s16_team_protocols/) → s17 → s18 → s19 → s20 → s21 → s22
> *"一人では無理、チームを組もう"* — ファイル受信箱 + チームメイトスレッド。
s01 → ... → s13 → s14 → `s15` → [s16](../s16_autonomous_agents/) → s17 → s18 → s19 → s20 → s21
> *「1 つの Agent だけでは扱いきれないなら、チームメイトで分担する。」* — 永続チームメイト、メッセージ配信、協調プロトコル。
>
> **Harness **: チーム — マルチ Agent 協調、メッセージバス
> **モジュール 1/2** s15 と s16 は一つの Agent Teams モジュールに含まれる二つの集中実験。この章でランタイムを構築し、s16 はランタイムを繰り返さず型付き協調プロトコルを追加する。
> **Harness レイヤー**チーム — 複数 Agent を並行動作させながら制御を保つ
---
##
##
バックエンド全体リファクタリング」は認証モジュール、データベース層、API ルート、テストに及ぶ。一つの Agent が API ルートを修正中、認証モジュールの詳細はコンテキストから外れている。コンテキストウィンドウには限界があり、単一 Agent の注意は全モジュールをカバーできない
Agent にバックエンド全体リファクタリングを頼む場合、設定読み込み、認証、テストを同時に扱うことになる。1 つの Agent が順番に処理することもできるが、時間がかかり、初期の詳細は徐々にコンテキストから抜けていく
s06 のサブ Agent は臨時スタッフ、一つの仕事を終えたら去る。だが、通信でき、協力できるチームメイトが必要なタスクもある。
このような仕事は並列化に向いている。しかし、通常のユーザーはチーム構成ではなく目的だけを伝える:
```text
このサンプルバックエンドをリファクタリングしてください。
設定読み込み、認証ロジック、テストを整理し、
既存インターフェースを保ったままテストを通してください。
```
そのため Harness は、単に Agent を増やすだけでなく、次の 4 点を解決する必要がある:
1. 並列化が有効かを誰が判断し、追加 Agent の起動を誰が確認するか。
2. チームメイトが複数の依頼にまたがって、どう身元とコンテキストを保つか。
3. モデルに受信箱を繰り返し確認させず、結果をどう Lead へ戻すか。
4. 終了と計画承認を、どう追跡可能で強制可能なプロトコルにするか。
---
## ソリューション
## 解決策
![Agent Teams Overview](images/agent-teams-overview.ja.svg)
教学版は S14 の能力(プロンプト組み立て、タスクシステム、バックグラウンド実行、cron スケジューリング)を踏襲。チーム機構に集中するため、完全なエラーリカバリ、メモリ、スキルシステムは省略。追加:**MessageBus**(ファイル受信箱)、**spawn_teammate_thread**(チームメイトスレッド起動)、**inbox 注入**(Lead がチームメイトメッセージを受信し history に注入)。
s15 は単一 Agent の Harness の外側に、Lead が管理するチームランタイムを追加する:
サブ Agent vs チームメイト:
- **Lead** はユーザーとの会話を維持し、分担案を提示して確認を待つ。
- **チームメイト** は独立した Agent Loop をバックグラウンドスレッドで実行し、作業後は IDLE になる。
- **MessageBus** はファイル受信箱を通して、通常メッセージ、結果、制御イベントを運ぶ。
- **ランタイム配信** は Lead の受信箱を消費し、チームイベントを次のターンへ注入する。
- **協調プロトコル**`type``request_id`、状態遷移で終了と計画承認を扱う。
- **計画ゲート** は、必要な計画が承認されるまで `bash``write_file` を遮断する。
| | s06 サブ Agent | s15 チームメイト |
|---|---|---|
| ライフサイクル | 一回きり、終了後に破棄 | マルチターン(教学版は 10 ラウンド制限、真实 CC は idle loop |
| 通信 | 結果のみ返却 | 非同期受信箱、いつでも通信可能 |
| コンテキスト | 完全に隔離 | メッセージで情報共有 |
| 数 | メイン Agent + たまにサブ Agent | 1 Lead + 複数チームメイト |
モデルはタスクを理解して分担を決める。コードは配信、ライフサイクル、プロトコル制約を担う。
---
## 仕組み
![Team Topology](images/team-topology.ja.svg)
### 1. Lead はチーム案を示し、確認を待つ
### MessageBus: ファイル受信箱
チームメイトの起動は、コスト、並行度、ワークスペースを書き換える主体を変える。この境界を通常のツール呼び出しの中に隠してはいけない。Lead の system prompt は次のように定める:
各 Agent(Lead とチームメイトを含む)には `.jsonl` 受信箱がある。メッセージ送信 = 相手のファイルに 1 行 JSON を append。メッセージ読み取り = ファイル読み込み + 削除(消費式):
```python
"When parallel work would help, first propose a small team with clear "
"responsibilities and wait for the user's confirmation. Do not call "
"spawn_teammate before the user confirms."
```
最初の依頼に対して、Lead はまず分担案だけを返す:
```text
次の 3 方向で並行処理することを提案します。
- config:設定読み込みの整理
- auth:認証ロジックのリファクタリング
- tests:回帰テストの追加
確認後にチームメイトを起動します。
```
ユーザーが「始めてください」と返した後で、Lead は `spawn_teammate` を呼ぶ。ユーザーが目的を示し、Lead がチームを設計し、ユーザーが実行境界を確認する。
### 2. 各チームメイトは独立したループを持つ
s06 の Subagent は 1 回限りの呼び出しだが、チームメイトは永続する実行単位である:
| | s06 Subagent | s15 チームメイト |
|---|---|---|
| ライフサイクル | 1 回の呼び出し後に終了 | 終了要求まで `WORK → IDLE → WORK` |
| コンテキスト | 1 つのタスクだけ | 複数の依頼をまたいで保持 |
| 通信 | 1 回だけ結果を返す | メッセージを受け取り、イベントを送る |
| 協調 | 一方向の委任 | Lead との双方向協調 |
`spawn_teammate_thread()` はチームメイトごとに system prompt、messages、ツールを作り、daemon thread でループを実行する。Lead はチームメイトの終了を待たずに、別の依頼や結果を調整できる。
### 3. MessageBus は通信をモデルのコンテキスト外に置く
Lead とチームメイトが同じ messages 配列を共有すると、あるチームメイトのツール結果が別のチームメイトの推論へ混ざる。`MessageBus` は各 Agent に `.mailboxes/<name>.jsonl` 受信箱を与える:
```python
class MessageBus:
def send(self, from_agent: str, to_agent: str,
content: str, msg_type: str = "message"):
msg = {"from": from_agent, "to": to_agent,
"content": content, "type": msg_type,
"ts": time.time()}
inbox = MAILBOX_DIR / f"{to_agent}.jsonl"
with open(inbox, "a") as f:
f.write(json.dumps(msg) + "\n")
def send(self, from_agent, to_agent, content,
msg_type="message", metadata=None):
msg = {
"from": from_agent,
"to": to_agent,
"content": content,
"type": msg_type,
"metadata": metadata or {},
}
with self._changed:
append_jsonl(self._path(to_agent), msg)
self._changed.notify_all()
def read_inbox(self, agent: str) -> list[dict]:
inbox = MAILBOX_DIR / f"{agent}.jsonl"
if not inbox.exists():
return []
msgs = [json.loads(line) for line in inbox.read_text().splitlines()]
inbox.unlink() # 消費式:読んだら削除
return msgs
def wait_for_messages(self, agent):
with self._changed:
while not self.peek(agent):
self._changed.wait()
return self._read_unlocked(agent)
```
なぜファイルか、メモリキューではなく?教学版がファイルを選ぶ理由は、直感的でスレッドをまたいで観察可能だから。真实 CC もファイル受信箱(`~/.claude/teams/{team}/inboxes/`)を使うが、`proper-lockfile` で並行書き込みの安全性を確保。教学版の `read_inbox` には read + unlink の競合状態があり、マルチスレッド同時読みでメッセージを損失する可能性があるが、教学目的には許容範囲
ロックは複数スレッドによる受信箱ファイルの破損を防ぐ。`Condition` により、IDLE のチームメイトはポーリングせずイベント到着まで待機できる
### spawn_teammate_thread: チームメイト起動
### 4. 受信イベントはランタイムが自動配信する
Lead が `spawn_teammate` ツールを呼び出してチームメイトを起動。チームメイトは独自の daemon スレッドで動作、独自の system prompt、messages、簡易ツールセットを持つ
`read_inbox()` はメッセージを読み、受信箱ファイルを削除する。そのため Lead の消費入口は `consume_lead_inbox()` だけにする
```python
def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
system = f"You are '{name}', a {role}. Use tools to complete tasks."
def run():
messages = [{"role": "user", "content": prompt}]
sub_tools = [bash, read_file, write_file, send_message]
for _ in range(10): # 最大 10 ラウンド
inbox = BUS.read_inbox(name)
if inbox:
messages.append({"role": "user",
"content": f"<inbox>{json.dumps(inbox)}</inbox>"})
response = client.messages.create(
model=MODEL, system=system, messages=messages[-20:],
tools=sub_tools, max_tokens=8000)
# ... ツール実行、結果処理
# 完了後 summary を Lead に送信
BUS.send(name, "lead", summary, "result")
threading.Thread(target=run, daemon=True).start()
def consume_lead_inbox():
messages = BUS.read_inbox("lead")
for message in messages:
if message["type"].endswith("_response"):
match_response(...)
return messages
```
重要な設計
- **チームメイトの簡易ツールセット**bash、read、write、send_message。教学版は通信機構に集中するためタスクと cron を省略。真实 CC のチームメイトには TaskCreate、TaskUpdate 等のツールもあり、タスクシステムはチーム全体で共有
- **教学版は 10 ラウンド制限**:無限ループを防止。真实 CC は idle loop:1 ラウンド終了後に `idle_notification` を送信、inbox メッセージを待機、到着後に再開、`shutdown_request` でのみ終了
- **完了時自動報告**`BUS.send(name, "lead", summary)` で最終結果を Lead の受信箱に送信
メインループのイベントスレッドは、新しいメッセージが届くと Lead を起こす
### Lead の inbox 注入
```text
MessageBus → consume_lead_inbox
→ プロトコル状態を更新
→ [Team events] を history へ注入
→ Lead の次ターンを開始
```
Lead はメインループの各反復後に受信箱を確認。チームメイトからのメッセージを history に注入し、LLM が確認して反応できるようにする
`check_inbox` はモデルのツールではない。メッセージの到着はランタイムの責務であり、モデルはコンテキストへ配信済みのイベントだけを処理する
### 5. 結果と IDLE は別のイベント
チームメイトが 1 件の作業を終えると、ランタイムは次の順序で 2 つのイベントを送る:
```text
result: "認証をリファクタリングし、関連テストが通りました。"
idle_notification: "Waiting for more work."
```
`result` は「今回の作業で何が得られたか」、`idle_notification` は「新しい仕事を受けられるか」を表す。1 つの曖昧な「done」では両者を区別できない。
IDLE になったチームメイトは終了しない。通常メッセージで WORK に戻り、`shutdown_request` で終了ハンドシェイクを始める。
### 6. 制御メッセージには型と request_id を使う
通常の協調は自由文でよいが、終了と承認を意図の推測に任せてはいけない。制御イベントは構造化する:
![Team Protocols](images/team-protocols-overview.ja.svg)
```python
# メインループ反復後
inbox = BUS.read_inbox("lead")
if inbox:
inbox_text = "\n".join(
f"From {m['from']}: {m['content'][:200]}" for m in inbox)
history.append({"role": "user",
"content": f"[Inbox]\n{inbox_text}"})
@dataclass
class ProtocolState:
request_id: str
type: str
sender: str
target: str
status: str
payload: str
pending_requests: dict[str, ProtocolState] = {}
```
教学版はユーザー入力ループ内で注入。真实 CC はより精密、Lead の `useInboxPoller` が毎秒チェックし、ユーザー入力を待たずにメッセージを新しい turn として送信。
終了プロトコルは次の経路を通る:
### 権限バブリング
教学版は権限バブリングを省略。真实 CC のフロー(`permissionSync.ts``useSwarmPermissionPoller.ts`):
1. チームメイトが承認が必要な操作に遭遇 → `permission_request` を Lead の受信箱に送信
2. Lead の `useInboxPoller` がリクエストを検出 → 承認キューにルーティング
3. ユーザーが承認 → Lead が `permission_response` をチームメイトに返信
4. チームメイトの `useSwarmPermissionPoller`(500ms ごとにポーリング)が返信を受信 → 続行または拒否
### 組み合わせて実行
```
1. Lead: "バックエンド構築:一人では無理、チームを組もう"
2. Lead → spawn_teammate("alice", "backend dev", "データベーススキーマを作成")
3. Lead → spawn_teammate("bob", "frontend dev", "API クライアントを作成")
4. alice スレッド起動 → 独自の LLM 呼び出し → bash "python manage.py migrate"
5. bob スレッド起動 → 独自の LLM 呼び出し → write_file("client.ts", ...)
6. alice 完了 → BUS.send("alice", "lead", "Schema done: users, orders tables")
7. bob 完了 → BUS.send("bob", "lead", "Client written with types")
8. Lead 次回反復 → inbox を history に注入 → LLM が alice と bob の結果を確認
```text
Lead が pending の shutdown request を作る
→ shutdown_request(request_id) をチームメイトへ送る
→ チームメイトが現在の手順を終える
→ shutdown_response(request_id) を Lead へ返す
→ request_id で元の要求を特定する
→ pending が approved になり、チームメイトループが終了する
```
2 人のチームメイトが並行作業
ID は要求と応答を対応付け、型は誤った応答による状態変更を防ぎ、状態は重複応答の再適用を防ぐ
### 7. 計画承認は実行も制約する
計画プロトコルは逆方向に流れる:
```text
Lead → plan_request
チームメイト → plan_approval_request(request_id, plan)
Lead → plan_approval_response(request_id, approve, feedback)
```
「承認まで待つ」と伝えるだけでは確実なゲートにならない。そこでツール dispatch が計画状態を検査する:
```python
def _run_teammate_tool(name, block, handlers):
gate = plan_gates.get(name, "not_required")
if block.name in {"bash", "write_file"} and gate not in {
"not_required", "approved"
}:
return f"Blocked: plan status is {gate}."
return handlers[block.name](**block.input)
```
状態が `required``pending``rejected` の間、チームメイトはファイルを読み、計画を提出または修正できるが、Shell 実行やファイル書き込みはできない。承認応答で `approved` になった後にだけツールが解放される。
---
## 一連の実行例
```text
s15 >> このサンプルバックエンドをリファクタリングしてください。
設定読み込み、認証、テストを整理し、
既存インターフェースを保ってテストを通してください。
Lead: config、auth、tests の 3 方向で並行処理することを提案します。
チームを開始しますか?
s15 >> 始めてください
[teammate] config spawned
[teammate] auth spawned
[teammate] tests spawned
[bus] auth → lead (result) ...
[bus] auth → lead (idle_notification) ...
[wake: 2 team events → new turn]
Lead: 認証の結果を受け取りました。残りの作業も調整します。
```
端末には、ユーザー要求、Lead の分担、起動、メッセージ、結果、IDLE、終了イベントが表示される。ユーザーが Lead を指名したり、受信箱の確認を頼んだりする必要はない。
---
## s14 からの変更
| コンポーネント | 変更前 (s14) | 変更後 (s15) |
|--------------|------------|------------|
| Agent | 1 | 1 Lead + N チームメイトスレッド |
| 通信 | なし | MessageBus + .mailboxes/*.jsonl |
| 新規クラス | | MessageBus, active_teammates dict |
| 新規関数 | — | spawn_teammate_thread, run_send_message, run_check_inbox |
| Lead ツール | 11 (s14) | + spawn_teammate, send_message, check_inbox (14) |
| チームメイトツール | | bash, read_file, write_file, send_message (4) |
| 権限 | ローカル判断 | 教学版は省略(真实 CC はバブリング機構あり) |
| コンポーネント | s14 | s15 |
|---|---|---|
| Agent | 1 | 1 つの Lead + 永続チームメイト |
| ユーザーフロー | 依頼を直接実行 | チーム案を提示してから起動を確認 |
| 通信 | なし | ファイル受信箱 + 自動イベント配信 |
| ライフサイクル | 1 つのループ | チームメイトの `WORK / IDLE / shutdown` |
| 結果通知 | 現在の Agent の出力 | `result``idle_notification` を分離 |
| 制御 | なし | 終了と計画承認プロトコル |
| 強制 | チーム制約なし | 必須計画が変更系ツールをゲート |
---
@@ -160,97 +256,28 @@ cd learn-claude-code
python s15_agent_teams/code.py
```
以下のプロンプトを試してください
まず通常の依頼を入力する
1. `Spawn alice as a backend developer. Ask her to create a file called schema.sql with a users table.`
2. `Check your inbox for alice's result.`
3. `Spawn bob as a tester. Ask him to check if schema.sql exists and list its contents.`
```text
このサンプルバックエンドをリファクタリングしてください。
設定読み込み、認証ロジック、テストを整理し、
既存インターフェースを保ったままテストを通してください。
```
観察ポイント:Lead チームメイトをどう起動するか?`.mailboxes/` ディレクトリの JSONL ファイルの中身は?チームメイト完了後、Lead の inbox は history に注入されているか?
Lead チーム案を示したら、次のように返す:
```text
始めてください
```
`spawned``result``idle_notification``plan_approval_*``shutdown_*` の各イベントと、`.mailboxes/` のファイルが生成・消費される流れを確認する。
---
## 次の章
## 次
チームメイトは仕事をし、通信できる。しかし、Lead が Alice にシャットダウンを頼む場合、スレッドを強制終了すると書きかけのファイルが残る。丁寧なシャットダウンプロトコルが必要:Lead が shutdown_request を送信、チームメイトは收尾後に終了
s15 では、Lead が各チームメイトへ明示的に仕事を割り当てる。次のセッションでは共有タスクボードを IDLE のチームメイトに公開し、実行可能な仕事を自ら見つけて claim できるようにする
s16 Agent Teams プロトコル実験 → このランタイムにシャットダウンハンドシェイク、計画承認、型付きリクエスト-返信を追加する
次へ:[s16 Autonomous Agents](../s16_autonomous_agents/)
<details>
<summary>CC ソースコード深掘り</summary>
> 以下は CC ソースコード `spawnMultiAgent.ts``useInboxPoller.ts`969 行)、`useSwarmPermissionPoller.ts`330 行)、`teammateMailbox.ts``teamHelpers.ts` の完全分析に基づく。
### 一、中央メッセージバスはない、ファイルシステム
教学版は `MessageBus` クラスでメッセージを送受信。真实 CC はもっと直接的、各 Agent が他の Agent の受信箱ファイルに直接書き込む。
受信箱パス:`~/.claude/teams/{teamName}/inboxes/{agentName}.json`
書き込み時は `proper-lockfile` で並行安全性を確保(最大 10 回リトライ)。各ファイルは JSON 配列、append 時に読み取り→追加→書き戻し。
### 二、15 種のメッセージ型
CC のチーム通信には 15 種の構造化メッセージ(`teammateMailbox.ts`)がある:
| 型 | 方向 | 用途 |
|------|------|------|
| `plain text` | 双方向 | 通常のチームメイト間通信 |
| `idle_notification` | チームメイト→Lead | チームメイトが 1 ターン完了、アイドル状態に |
| `permission_request` | チームメイト→Lead | 操作承認が必要 |
| `permission_response` | Lead→チームメイト | Lead の承認結果 |
| `plan_approval_request` | チームメイト→Lead | 計画提出、審査待ち |
| `plan_approval_response` | Lead→チームメイト | Lead の計画審査 |
| `shutdown_request` | Lead→チームメイト | 丁寧なシャットダウン要求 |
| `shutdown_approved` | チームメイト→Lead | シャットダウン確認 |
| `shutdown_rejected` | チームメイト→Lead | シャットダウン拒否(理由付き) |
| `task_assignment` | Lead→チームメイト | タスク割り当て |
| `team_permission_update` | Lead→チームメイト | 権限変更のブロードキャスト |
| `mode_set_request` | Lead→チームメイト | チームメイトの権限モード変更 |
| `sandbox_permission_*` | 双方向 | ネットワーク権限リクエスト/返信 |
| `teammate_terminated` | システム | チームメイト削除通知 |
テキストメッセージは `<teammate-message>` XML タグでラップされモデルに配信。
### 三、権限バブリング:双方向ポーリング
教学版は権限バブリングを省略。真实 CC のフロー(`permissionSync.ts`):
1. **チームメイト**が承認が必要な操作に遭遇 → `permission_request` を Lead の受信箱に送信
2. **Lead**`useInboxPoller`(1 秒ごとにポーリング)がリクエストを検出 → `ToolUseConfirmQueue` にルーティング
3. Lead の UI にチームメイト名と色付きの承認ダイアログを表示
4. ユーザー承認後 → Lead が `permission_response` をチームメイトの受信箱に返信
5. **チームメイト**の `useSwarmPermissionPoller`(500ms ごとにポーリング)が返信を受信 → 続行または拒否
### 四、チームメイトライフサイクル
CC のチームメイトは `spawnTeammate()``spawnMultiAgent.ts`)で作成:
1. **Spawn**:tmux ペイン(またはプロセス内)を作成、色を割り当て、team config に書き込み
2. **Work**`useInboxPoller` が毎秒受信箱をチェック → メッセージ到着時に新しい turn として送信
3. **Idle**Stop hook 発火 → `idle_notification` を Lead に送信
4. **Shutdown**Lead が `shutdown_request` を送信 → チームメイトが `shutdown_approved` で返信 → Lead がクリーンアップ
### 五、Team Config
チーム登録は `~/.claude/teams/{teamName}/config.json``teamHelpers.ts`):
```json
{
"name": "my-team",
"leadAgentId": "lead@my-team",
"members": [{
"agentId": "researcher@my-team",
"name": "researcher",
"agentType": "general-purpose",
"color": "blue",
"isActive": true
}]
}
```
チームメイトのネストは禁止(`AgentTool.tsx:273` で "teammates spawning other teammates" を明示的に禁止)。
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
+214 -187
View File
@@ -1,21 +1,33 @@
# s15: Agent Teams — Runtime Lab: Persistent Teammates
# s15: Agent Teams — Runtime and Coordination Protocols
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s13 → s14 → `s15` → [s16](../s16_team_protocols/) → s17 → s18 → s19 → s20 → s21 → s22
> *"One agent isn't enough, form a team"* — File-based inboxes + teammate threads.
>
> **Harness Layer**: Teams — Multi-agent collaboration, message bus.
s01 → ... → s13 → s14 → `s15` → [s16](../s16_autonomous_agents/) → s17 → s18 → s19 → s20 → s21
> **Module 1 of 2:** s15 and s16 are two focused labs in one Agent Teams module. This lab builds the runtime; s16 adds typed coordination protocols without repeating the runtime.
> *"When one agent cannot hold the whole job, let teammates divide the work."* — Persistent teammates, message delivery, and coordination protocols.
>
> **Harness layer**: Team — how multiple agents work in parallel without losing control.
---
## The Problem
"Refactor the entire backend" touches auth, database layer, API routes, and tests. One agent working on API routes no longer has auth module details in context. The context window is limited, a single agent can't cover every module.
Suppose 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.
s06's sub-agents are temps, called in for one job, then gone. Some tasks need teammates that can communicate and collaborate.
This is a good candidate for parallel work, yet users normally describe the goal rather than design the team:
```text
Refactor this sample backend. Clean up configuration loading,
authentication, and tests, preserve the existing interfaces,
and make sure the tests pass.
```
The harness therefore has to solve four connected problems:
1. Who decides that parallel work is useful, and who confirms the extra agents?
2. How does each teammate keep its identity and context across assignments?
3. How do results return to Lead automatically, without asking the model to poll an inbox?
4. How do shutdown and plan approval become traceable, enforceable protocols?
---
@@ -23,133 +35,217 @@ s06's sub-agents are temps, called in for one job, then gone. Some tasks need te
![Agent Teams Overview](images/agent-teams-overview.en.svg)
Teaching code carries forward S14's capabilities (prompt assembly, task system, background execution, cron scheduling). To stay focused on the team mechanism, it omits full error recovery, memory, and skill systems. Added: **MessageBus** (file-based inboxes), **spawn_teammate_thread** (launch teammate threads), **inbox injection** (Lead receives teammate messages and injects into history).
s15 adds a Lead-managed team runtime around the single-agent harness:
Sub-agent vs Teammate:
- **Lead** owns the user conversation, proposes a division of work, and waits for confirmation.
- **Teammates** run independent agent loops in background threads and become idle after an assignment.
- **MessageBus** carries ordinary messages, results, and control events through file-backed mailboxes.
- **Runtime delivery** consumes Lead's mailbox and injects team events into the next turn.
- **Coordination protocols** use `type`, `request_id`, and state transitions for shutdown and plan approval.
- **A plan gate** blocks teammate `bash` and `write_file` calls until a required plan is approved.
| | s06 Sub-agent | s15 Teammate |
|---|---|---|
| Lifetime | One-shot, destroyed after use | Multi-turn (teaching: 10 rounds; real CC: idle loop) |
| Communication | Only returns conclusion | Async inbox, communicate anytime |
| Context | Fully isolated | Shared via messages |
| Count | One lead + occasional sub-agent | One Lead + multiple teammates |
The model understands tasks and chooses a useful division of work. Code owns delivery, lifecycle, and protocol constraints.
---
## How It Works
![Team Topology](images/team-topology.en.svg)
### 1. Lead proposes a team and waits for confirmation
### MessageBus: File-Based Inboxes
Starting teammates changes cost, concurrency, and the set of actors that may edit the workspace. That boundary should not be hidden inside an ordinary tool call. Lead's system prompt says:
Each agent (including Lead and teammates) has a `.jsonl` inbox. Send = append a JSON line to the target's file. Read = read file + delete (consumption):
```python
"When parallel work would help, first propose a small team with clear "
"responsibilities and wait for the user's confirmation. Do not call "
"spawn_teammate before the user confirms."
```
For the first request, Lead only proposes a split:
```text
I suggest three parallel areas:
- config: clean up configuration loading
- auth: refactor authentication
- tests: add regression coverage
I will start the teammates after you confirm.
```
After the user says "Go ahead," Lead can call `spawn_teammate`. The user states the goal, Lead designs the team, and the user confirms the execution boundary.
### 2. Every teammate owns an independent loop
An s06 subagent is a one-shot call. A teammate is a persistent execution unit:
| | s06 Subagent | s15 Teammate |
|---|---|---|
| Lifecycle | Ends after one call | `WORK → IDLE → WORK` until shutdown |
| Context | Exists for one task | Persists across assignments |
| Communication | Returns one result | Receives messages and emits events |
| Coordination | One-way delegation | Two-way collaboration with Lead |
`spawn_teammate_thread()` gives each teammate its own system prompt, messages, and tools, then runs its loop in a daemon thread. Lead can keep coordinating while teammates work.
### 3. MessageBus keeps communication outside model context
Lead 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:
```python
class MessageBus:
def send(self, from_agent: str, to_agent: str,
content: str, msg_type: str = "message"):
msg = {"from": from_agent, "to": to_agent,
"content": content, "type": msg_type,
"ts": time.time()}
inbox = MAILBOX_DIR / f"{to_agent}.jsonl"
with open(inbox, "a") as f:
f.write(json.dumps(msg) + "\n")
def send(self, from_agent, to_agent, content,
msg_type="message", metadata=None):
msg = {
"from": from_agent,
"to": to_agent,
"content": content,
"type": msg_type,
"metadata": metadata or {},
}
with self._changed:
append_jsonl(self._path(to_agent), msg)
self._changed.notify_all()
def read_inbox(self, agent: str) -> list[dict]:
inbox = MAILBOX_DIR / f"{agent}.jsonl"
if not inbox.exists():
return []
msgs = [json.loads(line) for line in inbox.read_text().splitlines()]
inbox.unlink() # consume: read + delete
return msgs
def wait_for_messages(self, agent):
with self._changed:
while not self.peek(agent):
self._changed.wait()
return self._read_unlocked(agent)
```
Why files instead of in-memory queues? Teaching code uses files because they're intuitive and observable across threads. Real CC also uses file inboxes (`~/.claude/teams/{team}/inboxes/`) but adds `proper-lockfile` for concurrent write safety. The teaching version's `read_inbox` has a read + unlink race, concurrent reads could lose messages, acceptable for teaching purposes.
A lock protects mailbox files from concurrent teammate access. A `Condition` lets idle teammates sleep until an event arrives instead of polling continuously.
### spawn_teammate_thread: Launching a Teammate
### 4. The runtime delivers inbox events automatically
Lead calls the `spawn_teammate` tool to start a teammate. The teammate runs in its own daemon thread with its own system prompt, messages, and simplified tool set:
`read_inbox()` consumes messages by reading and deleting the mailbox file, so Lead keeps a single consumer, `consume_lead_inbox()`:
```python
def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
system = f"You are '{name}', a {role}. Use tools to complete tasks."
def run():
messages = [{"role": "user", "content": prompt}]
sub_tools = [bash, read_file, write_file, send_message]
for _ in range(10): # max 10 rounds
inbox = BUS.read_inbox(name)
if inbox:
messages.append({"role": "user",
"content": f"<inbox>{json.dumps(inbox)}</inbox>"})
response = client.messages.create(
model=MODEL, system=system, messages=messages[-20:],
tools=sub_tools, max_tokens=8000)
# ... execute tools, process results
# Send final summary to Lead
BUS.send(name, "lead", summary, "result")
threading.Thread(target=run, daemon=True).start()
def consume_lead_inbox():
messages = BUS.read_inbox("lead")
for message in messages:
if message["type"].endswith("_response"):
match_response(...)
return messages
```
Key design:
- **Simplified tool set**: bash, read, write, send_message. Teaching code omits tasks and cron to focus on communication. Real CC teammates also have TaskCreate, TaskUpdate, etc., the task system is shared across the team
- **Teaching: 10 rounds max**: prevents infinite loops. Real CC uses idle loop: after each round, send `idle_notification`, wait for inbox messages, resume on arrival, exit only on `shutdown_request`
- **Auto-report on completion**: `BUS.send(name, "lead", summary)` sends the final result to Lead's inbox
An event thread beside the main loop wakes Lead when a new message arrives:
### Lead's Inbox Injection
```text
MessageBus → consume_lead_inbox
→ update protocol state
→ inject [Team events] into history
→ start another Lead turn
```
Lead checks inbox after each main loop iteration. Teammate messages are injected into history so the LLM can see and react to them:
`check_inbox` is not a model tool. Message arrival belongs to the runtime; the model only handles events that have already been delivered into its context.
### 5. Result and idle are separate events
When a teammate finishes one assignment, the runtime sends two events in order:
```text
result: "Authentication refactored; related tests pass."
idle_notification: "Waiting for more work."
```
`result` answers "What did this assignment produce?" `idle_notification` answers "Can this teammate accept more work?" A single vague "done" cannot represent both facts.
An idle teammate does not exit. An ordinary message returns it to WORK; a `shutdown_request` starts a graceful shutdown handshake.
### 6. Control messages use types and request IDs
Free-form text is fine for ordinary collaboration, but shutdown and approval should not depend on guessing intent. They use structured messages:
![Team Protocols](images/team-protocols-overview.en.svg)
```python
# After main loop iteration
inbox = BUS.read_inbox("lead")
if inbox:
inbox_text = "\n".join(
f"From {m['from']}: {m['content'][:200]}" for m in inbox)
history.append({"role": "user",
"content": f"[Inbox]\n{inbox_text}"})
@dataclass
class ProtocolState:
request_id: str
type: str
sender: str
target: str
status: str
payload: str
pending_requests: dict[str, ProtocolState] = {}
```
Teaching code injects in the user input loop. Real CC is more refined, Lead's `useInboxPoller` checks every 1 second, submitting messages as new turns without waiting for user input.
The shutdown path is:
### Permission Bubbling
Teaching code omits permission bubbling. Real CC's flow (`permissionSync.ts`, `useSwarmPermissionPoller.ts`):
1. Teammate encounters an operation needing approval → sends `permission_request` to Lead's inbox
2. Lead's `useInboxPoller` detects the request → routes to approval queue
3. User approves → Lead sends `permission_response` back to teammate
4. Teammate's `useSwarmPermissionPoller` (polls every 500ms) receives reply → continue or reject
### Putting It Together
```
1. Lead: "Build the backend: one agent isn't enough, form a team"
2. Lead → spawn_teammate("alice", "backend dev", "Create database schema")
3. Lead → spawn_teammate("bob", "frontend dev", "Write API client")
4. Alice thread starts → her own LLM call → bash "python manage.py migrate"
5. Bob thread starts → his own LLM call → write_file("client.ts", ...)
6. Alice done → BUS.send("alice", "lead", "Schema done: users, orders tables")
7. Bob done → BUS.send("bob", "lead", "Client written with types")
8. Lead next iteration → inbox injected into history → LLM sees both results
```text
Lead creates a pending shutdown request
→ shutdown_request(request_id) enters the teammate inbox
→ the teammate finishes its current step
→ shutdown_response(request_id) returns to Lead
→ request_id locates the original request
→ pending becomes approved and the teammate loop exits
```
Two teammates work in parallel.
The 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.
### 7. Plan approval constrains execution
The plan protocol runs in the opposite direction:
```text
Lead → plan_request
teammate → plan_approval_request(request_id, plan)
Lead → plan_approval_response(request_id, approve, feedback)
```
Merely telling a teammate to wait is not a reliable gate, so tool dispatch checks the plan state:
```python
def _run_teammate_tool(name, block, handlers):
gate = plan_gates.get(name, "not_required")
if block.name in {"bash", "write_file"} and gate not in {
"not_required", "approved"
}:
return f"Blocked: plan status is {gate}."
return handlers[block.name](**block.input)
```
While the state is `required`, `pending`, or `rejected`, the teammate can read files and submit or revise a plan, but it cannot run Shell commands or write files. The tools are released only after an approval response changes the state to `approved`.
---
## Changes from s14
## One Complete Run
| Component | Before (s14) | After (s15) |
|-----------|-------------|-------------|
| Agent count | 1 | 1 Lead + N teammate threads |
| Communication | None | MessageBus + .mailboxes/*.jsonl |
| New classes | — | MessageBus, active_teammates dict |
| New functions | — | spawn_teammate_thread, run_send_message, run_check_inbox |
| Lead tools | 11 (s14) | + spawn_teammate, send_message, check_inbox (14) |
| Teammate tools | — | bash, read_file, write_file, send_message (4) |
| Permissions | Local decisions | Teaching code omits (real CC has bubbling) |
```text
s15 >> Refactor this sample backend. Clean up configuration loading,
authentication, and tests, preserve existing interfaces,
and make sure the tests pass.
Lead: I suggest config, auth, and tests as three parallel areas.
Shall I start the team?
s15 >> Go ahead.
[teammate] config spawned
[teammate] auth spawned
[teammate] tests spawned
[bus] auth → lead (result) ...
[bus] auth → lead (idle_notification) ...
[wake: 2 team events → new turn]
Lead: I received the authentication result and will coordinate the rest.
```
The terminal exposes the user request, Lead's split, teammate startup, messages, results, idle transitions, and shutdown events. The user does not have to name a Lead or ask it to check an inbox.
---
## What Changed from s14
| Component | s14 | s15 |
|---|---|---|
| Agents | One agent | One Lead plus persistent teammates |
| User flow | Execute the request | Propose a team, then confirm startup |
| Communication | None | File mailboxes plus automatic delivery |
| Lifecycle | One loop | Teammate `WORK / IDLE / shutdown` |
| Reporting | Current agent output | Separate `result` and `idle_notification` |
| Control | None | Shutdown and plan approval protocols |
| Enforcement | No team constraint | Required plans gate mutating tools |
---
@@ -160,97 +256,28 @@ cd learn-claude-code
python s15_agent_teams/code.py
```
Try these prompts:
Start with an ordinary request:
1. `Spawn alice as a backend developer. Ask her to create a file called schema.sql with a users table.`
2. `Check your inbox for alice's result.`
3. `Spawn bob as a tester. Ask him to check if schema.sql exists and list its contents.`
```text
Refactor this sample backend. Clean up configuration loading,
authentication, and tests, preserve the existing interfaces,
and make sure the tests pass.
```
What to observe: How does Lead spawn teammates? What do the `.mailboxes/` JSONL files look like? After teammates finish, is Lead's inbox injected into history?
After Lead proposes the team, reply:
```text
Go ahead.
```
Watch for `spawned`, `result`, `idle_notification`, `plan_approval_*`, and `shutdown_*` events, along with mailbox files appearing and being consumed under `.mailboxes/`.
---
## What's Next
## Next
Teammates can work and communicate. But if Lead wants Alice to shut down, killing the thread outright could leave half-written files. A graceful shutdown protocol is needed: Lead sends shutdown_request, teammate wraps up and exits.
In s15, Lead still assigns each teammate explicitly. The next lesson gives idle teammates access to the shared task board so they can discover and claim ready work themselves.
s16 Agent Teams Protocol Lab → keep this runtime and add shutdown handshakes, plan approval, and typed request-reply messages.
Next: [s16 Autonomous Agents](../s16_autonomous_agents/).
<details>
<summary>Deep Dive into CC Source</summary>
> The following is a complete analysis based on CC source code `spawnMultiAgent.ts`, `useInboxPoller.ts` (969 lines), `useSwarmPermissionPoller.ts` (330 lines), `teammateMailbox.ts`, `teamHelpers.ts`.
### 1. No Central Message Bus, It's the Filesystem
Teaching code uses a `MessageBus` class to send and receive messages. Real CC is more direct, each agent writes directly to other agents' inbox files.
Inbox path: `~/.claude/teams/{teamName}/inboxes/{agentName}.json`
Writes use `proper-lockfile` for concurrent write safety (up to 10 retries). Each file is a JSON array; appending reads → appends → writes back.
### 2. 15 Message Types
CC team communication has 15 structured message types (`teammateMailbox.ts`):
| Type | Direction | Purpose |
|------|-----------|---------|
| `plain text` | Both ways | Normal inter-teammate communication |
| `idle_notification` | Teammate→Lead | Teammate finished a turn, now idle |
| `permission_request` | Teammate→Lead | Teammate needs operation approval |
| `permission_response` | Lead→Teammate | Lead's approval result |
| `plan_approval_request` | Teammate→Lead | Teammate submits plan for review |
| `plan_approval_response` | Lead→Teammate | Lead's plan review |
| `shutdown_request` | Lead→Teammate | Request graceful shutdown |
| `shutdown_approved` | Teammate→Lead | Confirm shutdown |
| `shutdown_rejected` | Teammate→Lead | Reject shutdown (with reason) |
| `task_assignment` | Lead→Teammate | Assign a task |
| `team_permission_update` | Lead→Teammate | Broadcast permission changes |
| `mode_set_request` | Lead→Teammate | Change teammate's permission mode |
| `sandbox_permission_*` | Both ways | Network permission request/reply |
| `teammate_terminated` | System | Teammate removed notification |
Text messages are wrapped in `<teammate-message>` XML tags for delivery to the model.
### 3. Permission Bubbling: Bidirectional Polling
Teaching code omits permission bubbling. Real CC's flow (`permissionSync.ts`):
1. **Teammate** encounters operation needing approval → sends `permission_request` to Lead's inbox
2. **Lead's** `useInboxPoller` (polls every 1s) detects request → routes to `ToolUseConfirmQueue`
3. Lead's UI shows approval dialog with teammate name and color
4. User approves → Lead sends `permission_response` back to teammate's inbox
5. **Teammate's** `useSwarmPermissionPoller` (polls every 500ms) receives reply → continue or reject
### 4. Teammate Lifecycle
CC teammates are created by `spawnTeammate()` (`spawnMultiAgent.ts`):
1. **Spawn**: Create tmux pane (or in-process), assign color, write team config
2. **Work**: `useInboxPoller` checks inbox every 1s → submit as new turn when messages arrive
3. **Idle**: Stop hook fires → send `idle_notification` to Lead
4. **Shutdown**: Lead sends `shutdown_request` → teammate replies `shutdown_approved` → Lead cleans up
### 5. Team Config
Team registry at `~/.claude/teams/{teamName}/config.json` (`teamHelpers.ts`):
```json
{
"name": "my-team",
"leadAgentId": "lead@my-team",
"members": [{
"agentId": "researcher@my-team",
"name": "researcher",
"agentType": "general-purpose",
"color": "blue",
"isActive": true
}]
}
```
Teammates cannot be nested (`AgentTool.tsx:273` explicitly forbids "teammates spawning other teammates").
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
+208 -186
View File
@@ -1,21 +1,32 @@
# s15: Agent Teams — 运行时实验:持久队友
# s15: Agent Teams — 团队运行时与协作协议
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s13 → s14 → `s15` → [s16](../s16_team_protocols/) → s17 → s18 → s19 → s20 → s21 → s22
> *"一个搞不定, 组队来"* — 文件收件箱 + 队友线程。
>
> **Harness 层**: 团队 — 多 Agent 协作, 消息总线。
s01 → ... → s13 → s14 → `s15` → [s16](../s16_autonomous_agents/) → s17 → s18 → s19 → s20 → s21
> **模块 1/2** s15 与 s16 是同一个 Agent Teams 模块中的两次聚焦实验。本章搭建运行时;s16 在不重复运行时的前提下增加带类型的协作协议。
> *"一个 Agent 顾不过来,就让队友分工协作。"* — 持久队友、消息投递与协作协议。
>
> **Harness 层**:团队 — 多个 Agent 如何并行工作,又如何保持可控。
---
## 问题
"重构整个后端"涉及认证模块、数据库层、API 路由、测试。一个 Agent 在修 API 路由时,认证模块的细节已经不在上下文里了。上下文窗口就那么大,单个 Agent 的注意力覆盖不了所有模块
当我们需要 Agent 帮助我们重构整个后端时,任务可能同时涉及配置加载、认证逻辑和测试。一个 Agent 依次处理所有模块,不但耗时更长,早期细节也会逐渐退出上下文
s06 的子 Agent 是临时工,叫来干一件事就走了。但有些任务需要能通信、能协作的队友。
这类任务适合拆给多个 Agent,但用户通常只会描述需求,不会先设计一套团队:
```text
请重构这个示例后端,分别整理配置加载、认证逻辑和测试,
保持现有接口兼容,并确保测试通过。
```
因此,Harness 需要解决的不只是“再启动几个 Agent”,而是四个连续问题:
1. 谁判断任务是否值得并行,以及如何征得用户确认?
2. 队友如何保留自己的身份和上下文,持续接收工作?
3. 队友的结果如何自动回到 Lead,而不是依赖模型反复检查邮箱?
4. 关机与计划审批如何变成可追踪、可执行的协议?
---
@@ -23,133 +34,214 @@ s06 的子 Agent 是临时工,叫来干一件事就走了。但有些任务需
![Agent Teams Overview](images/agent-teams-overview.svg)
教学代码沿用 S14 的能力(prompt 组装、任务系统、后台执行、cron 调度)。为了聚焦团队机制,省略了完整错误恢复、记忆和技能系统。新增三样:**MessageBus**(文件收件箱)、**spawn_teammate_thread**(启动队友线程)、**inbox 注入**(Lead 接收队友消息并注入 history)。
s15 在单 Agent Harness 外增加一个由 Lead 管理的团队运行时:
子 Agent vs 队友:
- **Lead** 保持用户对话,判断是否需要团队,提出分工并等待确认。
- **队友** 在独立线程中运行自己的 Agent Loop,完成工作后进入空闲。
- **MessageBus** 用文件邮箱传递普通消息、结果和控制事件。
- **运行时投递** 自动消费 Lead 的邮箱,把团队事件注入下一轮上下文。
- **协作协议**`type``request_id` 和状态机处理关机与计划审批。
- **计划闸门** 在计划未批准时拦截队友的 `bash``write_file`
| | s06 子 Agent | s15 队友 |
|---|---|---|
| 生命周期 | 一次性,用完销毁 | 多轮(教学版限 10 轮,真实 CC 用 idle loop |
| 通信 | 只回传结论 | 异步收件箱,随时通信 |
| 上下文 | 完全隔离 | 通过消息共享信息 |
| 数量 | 一个主 Agent + 偶尔子 Agent | 一个 Lead + 多个队友 |
模型负责理解任务与分工,代码负责消息投递、生命周期和协议约束。
---
## 工作原理
![Team Topology](images/team-topology.svg)
### 1. Lead 先提出团队,再等待用户确认
### MessageBus: 文件收件箱
是否创建团队会改变成本、并发度和可写入范围,不应该被隐藏在一次普通工具调用里。Lead 的 system prompt 明确规定:
每个 Agent(包括 Lead 和队友)有一个 `.jsonl` 邮箱。发消息 = 往对方的文件里 append 一行 JSON。读消息 = 读文件 + 删除(消费式):
```python
"When parallel work would help, first propose a small team with clear "
"responsibilities and wait for the user's confirmation. Do not call "
"spawn_teammate before the user confirms."
```
第一次输入需求时,Lead 只需要说明建议的拆分:
```text
我建议分成三个方向并行处理:
- config:整理配置加载
- auth:重构认证逻辑
- tests:补齐回归测试
确认后我会启动队友并协调结果。
```
用户回复“开始吧”后,Lead 才调用 `spawn_teammate`。用户表达目标,Lead 设计团队,用户确认执行边界;三者的职责不会混在一起。
### 2. 每个队友拥有独立循环
s06 的子 Agent 是一次性调用,返回结果后就结束。队友则是持久执行单元:
| | s06 子 Agent | s15 队友 |
|---|---|---|
| 生命周期 | 完成一次调用后结束 | `WORK → IDLE → WORK`,直到收到关机请求 |
| 上下文 | 只服务当前任务 | 在多轮协作中保留 |
| 通信 | 返回一次结果 | 持续接收消息并上报事件 |
| 协调 | 主 Agent 单向委派 | Lead 与队友双向协作 |
`spawn_teammate_thread()` 为队友创建独立的 system prompt、messages 和工具集,并把循环放入 daemon 线程。Lead 不必等待某个队友结束,仍可继续派发任务或处理其他结果。
### 3. MessageBus 把通信放在上下文之外
Lead 和队友不能共享同一份 messages,否则一个队友的工具结果会混入另一个队友的推理。`MessageBus` 为每个 Agent 建立 `.mailboxes/<name>.jsonl`
```python
class MessageBus:
def send(self, from_agent: str, to_agent: str,
content: str, msg_type: str = "message"):
msg = {"from": from_agent, "to": to_agent,
"content": content, "type": msg_type,
"ts": time.time()}
inbox = MAILBOX_DIR / f"{to_agent}.jsonl"
with open(inbox, "a") as f:
f.write(json.dumps(msg) + "\n")
def send(self, from_agent, to_agent, content,
msg_type="message", metadata=None):
msg = {
"from": from_agent,
"to": to_agent,
"content": content,
"type": msg_type,
"metadata": metadata or {},
}
with self._changed:
append_jsonl(self._path(to_agent), msg)
self._changed.notify_all()
def read_inbox(self, agent: str) -> list[dict]:
inbox = MAILBOX_DIR / f"{agent}.jsonl"
if not inbox.exists():
return []
msgs = [json.loads(line) for line in inbox.read_text().splitlines()]
inbox.unlink() # 消费式:读完删除
return msgs
def wait_for_messages(self, agent):
with self._changed:
while not self.peek(agent):
self._changed.wait()
return self._read_unlocked(agent)
```
为什么用文件而不是内存队列?教学版选文件是因为直观、跨线程可观察。真实 CC 也用文件收件箱(`~/.claude/teams/{team}/inboxes/`),但加了 `proper-lockfile` 防并发写冲突。教学版的 `read_inbox` 有 read + unlink 竞态,多线程同时读可能丢消息,对教学场景可以接受
锁保证同一进程中的多个队友不会同时破坏邮箱文件,`Condition` 让空闲队友等待事件,而不是持续轮询
### spawn_teammate_thread: 启动队友
### 4. 收件箱由运行时自动投递
Lead 调用 `spawn_teammate` 工具启动一个队友。队友跑在自己的 daemon 线程里,有自己的 system prompt、自己的 messages、自己的简化工具集
`read_inbox()` 是消费式读取:读出后删除邮箱文件。因此,Lead 只保留一个消费入口 `consume_lead_inbox()`
```python
def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
system = f"You are '{name}', a {role}. Use tools to complete tasks."
def run():
messages = [{"role": "user", "content": prompt}]
sub_tools = [bash, read_file, write_file, send_message]
for _ in range(10): # 最多 10 轮
inbox = BUS.read_inbox(name)
if inbox:
messages.append({"role": "user",
"content": f"<inbox>{json.dumps(inbox)}</inbox>"})
response = client.messages.create(
model=MODEL, system=system, messages=messages[-20:],
tools=sub_tools, max_tokens=8000)
# ... 执行工具、处理结果
# 完成后发 summary 给 Lead
BUS.send(name, "lead", summary, "result")
threading.Thread(target=run, daemon=True).start()
def consume_lead_inbox():
messages = BUS.read_inbox("lead")
for message in messages:
if message["type"].endswith("_response"):
match_response(...)
return messages
```
关键设计
- **队友有简化工具集**bash、read、write、send_message。教学版省略了任务和 cron,聚焦通信机制。真实 CC 的队友也有 TaskCreate、TaskUpdate 等工具,任务系统是团队共享的
- **教学版限 10 轮**:防止队友无限循环。真实 CC 用 idle loop:跑完一轮后发 `idle_notification`,等 inbox 消息,收到后继续,直到 `shutdown_request` 才退出
- **完成后自动汇报**`BUS.send(name, "lead", summary)` 把最终结果发到 Lead 的收件箱
主循环旁的事件线程发现新消息后,会唤醒 Lead
### Lead 的 inbox 注入
```text
MessageBus → consume_lead_inbox
→ 更新协议状态
→ [Team events] 注入 history
→ Lead 开始新一轮
```
Lead 在每轮主循环结束后检查收件箱。队友发来的消息注入到 history 里,让 LLM 能看到并做出反应:
`check_inbox` 不再是模型工具。消息何时到达属于运行时职责;模型只需要处理已经送入上下文的事件。
### 5. 结果与空闲是两个不同事件
队友完成一项工作时,运行时依次发送:
```text
result: "认证逻辑已重构,相关测试通过。"
idle_notification: "Waiting for more work."
```
`result` 回答“这次工作产出了什么”,`idle_notification` 表示“这个队友现在可以接新任务”。如果把两者合成一个模糊的“done”,Lead 就无法区分任务结果和资源状态。
队友进入 IDLE 后不会退出。新普通消息会让它回到 WORK;`shutdown_request` 则让它完成关机握手并结束线程。
### 6. 控制消息使用类型和 request_id
普通消息可以交给模型理解,关机和审批不能依赖自由文本猜测。它们使用结构化消息:
![Team Protocols](images/team-protocols-overview.svg)
```python
# 主循环结束后
inbox = BUS.read_inbox("lead")
if inbox:
inbox_text = "\n".join(
f"From {m['from']}: {m['content'][:200]}" for m in inbox)
history.append({"role": "user",
"content": f"[Inbox]\n{inbox_text}"})
@dataclass
class ProtocolState:
request_id: str
type: str
sender: str
target: str
status: str
payload: str
pending_requests: dict[str, ProtocolState] = {}
```
教学版在用户输入循环外注入。CC 更精细,Lead 的 `useInboxPoller` 每 1 秒检查一次,有消息就提交为新的 turn,不需要等用户输入。
关机协议的完整路径是:
### 权限冒泡
教学版省略了权限冒泡。真实 CC 的流程(`permissionSync.ts``useSwarmPermissionPoller.ts`):
1. 队友遇到需要审批的操作 → 发 `permission_request` 到 Lead 收件箱
2. Lead 的 `useInboxPoller` 检测到请求 → 路由到审批队列
3. 用户审批后 → Lead 发 `permission_response` 回队友
4. 队友的 `useSwarmPermissionPoller`(每 500ms 轮询)收到回复 → 继续或拒绝
### 合起来跑
```
1. Lead: "搭建后端:一个人搞不定,组队吧"
2. Lead → spawn_teammate("alice", "backend dev", "创建数据库 schema")
3. Lead → spawn_teammate("bob", "frontend dev", "写 API 客户端")
4. alice 线程启动 → 自己的 LLM 调用 → bash "python manage.py migrate"
5. bob 线程启动 → 自己的 LLM 调用 → write_file("client.ts", ...)
6. alice 完成 → BUS.send("alice", "lead", "Schema done: users, orders tables")
7. bob 完成 → BUS.send("bob", "lead", "Client written with types")
8. Lead 下次循环 → inbox 注入 history → LLM 看到 alice 和 bob 的结果
```text
Lead 创建 shutdown 请求,状态为 pending
→ shutdown_request(request_id) 发给队友
→ 队友完成当前步骤并回复 shutdown_response(request_id)
→ Lead 用 request_id 找到原请求
→ pending 变为 approved,队友线程退出
```
两个队友并行工作
`request_id` 负责关联请求与回复,`type` 防止错误类型的回复修改状态,`status` 防止重复响应被再次处理
### 7. 计划审批不仅传消息,还约束执行
计划协议沿相反方向流动:
```text
Lead → plan_request
队友 → plan_approval_request(request_id, plan)
Lead → plan_approval_response(request_id, approve, feedback)
```
只告诉队友“请等待批准”并不可靠,所以工具分发器检查计划状态:
```python
def _run_teammate_tool(name, block, handlers):
gate = plan_gates.get(name, "not_required")
if block.name in {"bash", "write_file"} and gate not in {
"not_required", "approved"
}:
return f"Blocked: plan status is {gate}."
return handlers[block.name](**block.input)
```
当状态为 `required``pending``rejected` 时,队友仍可读取文件、提交或修改计划,但不能执行 Shell 或写文件。批准消息到达后,状态变为 `approved`,工具才会放行。
---
## 相对 s14 的变更
## 一次完整运行
| 组件 | 之前 (s14) | 之后 (s15) |
|------|-----------|-----------|
| Agent 数量 | 1 | 1 Lead + N 队友线程 |
| 通信 | 无 | MessageBus + .mailboxes/*.jsonl |
| 新类 | — | MessageBus, active_teammates dict |
| 新函数 | — | spawn_teammate_thread, run_send_message, run_check_inbox |
| Lead 工具 | 11 (s14) | + spawn_teammate, send_message, check_inbox (14) |
| 队友工具 | — | bash, read_file, write_file, send_message (4) |
| 权限 | 本地决策 | 教学版省略(真实 CC 有冒泡机制) |
```text
s15 >> 请重构这个示例后端,分别整理配置加载、认证逻辑和测试,
保持现有接口兼容,并确保测试通过。
Lead: 建议由 config、auth、tests 三个方向并行处理,是否开始?
s15 >> 开始吧
[teammate] config spawned
[teammate] auth spawned
[teammate] tests spawned
[bus] auth → lead (result) ...
[bus] auth → lead (idle_notification) ...
[wake: 2 team events → new turn]
Lead: 已收到认证部分结果,继续等待并协调其他队友。
```
终端中显示的是用户需求、Lead 分工、队友启动、消息流、结果、空闲和关机事件。用户不需要在提示词里指定谁是 Lead,也不需要手动要求检查邮箱。
---
## 相对 s14 的变化
| 组件 | s14 | s15 |
|---|---|---|
| Agent 数量 | 一个 Agent | 一个 Lead + 多个持久队友 |
| 用户交互 | 直接执行任务 | 先提出团队方案,再确认启动 |
| 通信 | 无 | 文件邮箱 + 自动事件投递 |
| 生命周期 | 单循环 | 队友 `WORK / IDLE / shutdown` |
| 结果上报 | 当前 Agent 输出 | `result``idle_notification` 分离 |
| 控制协议 | 无 | 关机与计划审批 |
| 执行约束 | 无团队约束 | 未批准计划会拦截写入类工具 |
---
@@ -160,97 +252,27 @@ cd learn-claude-code
python s15_agent_teams/code.py
```
试试这些 prompt
先输入一个自然需求
1. `Spawn alice as a backend developer. Ask her to create a file called schema.sql with a users table.`
2. `Check your inbox for alice's result.`
3. `Spawn bob as a tester. Ask him to check if schema.sql exists and list its contents.`
```text
请重构这个示例后端,分别整理配置加载、认证逻辑和测试,
保持现有接口兼容,并确保测试通过。
```
观察重点:Lead 如何启动队友?`.mailboxes/` 目录下的 JSONL 文件长什么样?队友完成后 Lead 的 inbox 有没有注入到 history
看到 Lead 给出分工后,再回复:
```text
开始吧
```
观察终端中的 `spawned``result``idle_notification``plan_approval_*``shutdown_*` 事件,以及 `.mailboxes/` 中消息写入和消费的过程。
---
## 接下来
队友能干活、能通信。但如果 Lead 想让 Alice 关机,直接杀线程会留下写到一半的文件。需要一个体面的关机协议:Lead 发 shutdown_request,队友收尾后退出
s15 中,Lead 仍然要明确告诉每个队友做什么。下一章把共享任务看板交给空闲队友,让它们自己发现并认领可执行任务
s16 Agent Teams 协议实验 → 沿用本章运行时,加入关机握手、计划审批与带类型的请求-回复消息
下一章:[s16 Autonomous Agents](../s16_autonomous_agents/)
<details>
<summary>深入 CC 源码</summary>
> 以下基于 CC 源码 `spawnMultiAgent.ts``useInboxPoller.ts`969 行)、`useSwarmPermissionPoller.ts`330 行)、`teammateMailbox.ts``teamHelpers.ts` 的完整分析。
### 一、没有中央消息总线,是文件系统
教学版用 `MessageBus` 类收发消息。CC 的做法更直接,每个 Agent 直接写其他 Agent 的收件箱文件。
收件箱路径:`~/.claude/teams/{teamName}/inboxes/{agentName}.json`
写入时用 `proper-lockfile` 文件锁保证并发安全(最多重试 10 次)。每个文件是一个 JSON 数组,append 新消息时读→追加→写回。
### 二、15 种消息类型
CC 的团队通信有 15 种结构化消息(`teammateMailbox.ts`):
| 类型 | 方向 | 用途 |
|------|------|------|
| `plain text` | 双向 | 普通队友间通信 |
| `idle_notification` | 队友→Lead | 队友完成一轮工作,进入空闲 |
| `permission_request` | 队友→Lead | 队友需要操作审批 |
| `permission_response` | Lead→队友 | Lead 审批结果 |
| `plan_approval_request` | 队友→Lead | 队友提交计划待审 |
| `plan_approval_response` | Lead→队友 | Lead 审批计划 |
| `shutdown_request` | Lead→队友 | 请求体面关机 |
| `shutdown_approved` | 队友→Lead | 确认关机 |
| `shutdown_rejected` | 队友→Lead | 拒绝关机(附原因) |
| `task_assignment` | Lead→队友 | 分配任务 |
| `team_permission_update` | Lead→队友 | 广播权限变更 |
| `mode_set_request` | Lead→队友 | 修改队友的权限模式 |
| `sandbox_permission_*` | 双向 | 网络权限请求/回复 |
| `teammate_terminated` | 系统 | 队友被移除通知 |
文本消息被包装在 `<teammate-message>` XML 标签中交付给模型。
### 三、权限冒泡:双向轮询
教学版省略了权限冒泡。CC 的实际流程(`permissionSync.ts`):
1. **队友**遇到需要审批的操作 → 发 `permission_request` 到 Lead 的收件箱
2. **Lead**`useInboxPoller`(每 1 秒轮询)检测到请求 → 路由到 `ToolUseConfirmQueue`
3. Lead 的 UI 显示审批对话框,带队友名字和颜色
4. 用户审批后 → Lead 发 `permission_response` 回队友的收件箱
5. **队友**的 `useSwarmPermissionPoller`(每 500ms 轮询)收到回复 → 继续或拒绝执行
### 四、队友生命周期
CC 的队友由 `spawnTeammate()``spawnMultiAgent.ts`)创建:
1. **Spawn**:创建 tmux 窗格(或进程内),分配颜色,写入 team config
2. **Work**`useInboxPoller` 每 1 秒检查收件箱 → 有消息就提交为新的 turn
3. **Idle**Stop hook 触发 → 发 `idle_notification` 给 Lead
4. **Shutdown**Lead 发 `shutdown_request` → 队友回复 `shutdown_approved` → Lead 清理
### 五、Team Config
团队注册表在 `~/.claude/teams/{teamName}/config.json``teamHelpers.ts`):
```json
{
"name": "my-team",
"leadAgentId": "lead@my-team",
"members": [{
"agentId": "researcher@my-team",
"name": "researcher",
"agentType": "general-purpose",
"color": "blue",
"isActive": true
}]
}
```
队友之间不能嵌套(`AgentTool.tsx:273` 明确禁止 "teammates spawning other teammates")。
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
+428 -103
View File
@@ -1,29 +1,27 @@
#!/usr/bin/env python3
"""
s15: Agent Teams MessageBus + spawn_teammate_thread + inbox injection.
s15: Agent Teams persistent teammates, mailboxes, and typed protocols.
Run: python s15_agent_teams/code.py
Need: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY
Changes from s14:
- MessageBus class: file-based mailboxes (.mailboxes/*.jsonl)
- spawn_teammate_thread: creates teammate in background thread
- Teammate runs own simplified agent_loop (bash, read, write, send_message)
- Lead tools: spawn_teammate, send_message, check_inbox (3 new)
- Lead inbox: teammate messages injected into history (not just printed)
- Teaching version: teammates limited to 10 rounds (real CC uses idle loop)
- MessageBus: thread-safe, file-backed mailboxes (.mailboxes/*.jsonl)
- Persistent teammate loops with WORK and IDLE states
- Runtime delivery of teammate results and idle notifications to Lead
- Typed shutdown and plan-approval protocols with request_id matching
- Plan approval gates bash and write_file until Lead approves
ASCII flow:
Lead: cron_queue messages prompt LLM TOOLS loop
|
inbox MessageBus teammate.send_message
Teammate: inbox LLM bash/read/write/send loop (max 10 turns)
User Lead spawn_teammate teammate WORK result IDLE
|
MessageBus + typed protocol
"""
import os, subprocess, json, time, random, threading, queue
import os, subprocess, json, time, random, threading, queue, re
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass, asdict
from dataclasses import dataclass, asdict, field
try:
import readline
@@ -145,7 +143,15 @@ PROMPT_SECTIONS = {
"tools": "Available tools: bash, read_file, write_file, "
"get_task, create_task, list_tasks, claim_task, complete_task, "
"schedule_cron, list_crons, cancel_cron, "
"spawn_teammate, send_message, check_inbox.",
"spawn_teammate, send_message, request_shutdown, "
"request_plan, review_plan.",
"teams": (
"When parallel work would help, first propose a small team with clear "
"responsibilities and wait for the user's confirmation. Do not call "
"spawn_teammate before the user confirms. After confirmation, delegate "
"independent work, react to team events delivered by the runtime, and "
"shut teammates down when coordination is complete."
),
"workspace": f"Working directory: {WORKDIR}",
"memory": "Relevant memories are injected below when available.",
}
@@ -154,6 +160,7 @@ PROMPT_SECTIONS = {
def assemble_system_prompt(context: dict) -> str:
sections = [PROMPT_SECTIONS["identity"],
PROMPT_SECTIONS["tools"],
PROMPT_SECTIONS["teams"],
PROMPT_SECTIONS["workspace"]]
memories = context.get("memories", "")
if memories:
@@ -290,7 +297,10 @@ def execute_tool(block) -> str:
"schedule_cron": run_schedule_cron, "list_crons": run_list_crons,
"cancel_cron": run_cancel_cron,
"spawn_teammate": run_spawn_teammate,
"send_message": run_send_message, "check_inbox": run_check_inbox,
"send_message": run_send_message,
"request_shutdown": run_request_shutdown,
"request_plan": run_request_plan,
"review_plan": run_review_plan,
}.get(block.name)
if handler:
return handler(**block.input)
@@ -591,66 +601,275 @@ def run_cancel_cron(job_id: str) -> str:
return cancel_job(job_id)
# ── MessageBus (s15 new) ──
# Teaching version uses simple file append + unlink.
# Real CC uses proper-lockfile for concurrent write safety.
# ── MessageBus + Team Protocols (s15 new) ──
MAILBOX_DIR = WORKDIR / ".mailboxes"
MAILBOX_DIR.mkdir(exist_ok=True)
MAILBOX_ROOT = MAILBOX_DIR.resolve()
VALID_AGENT_NAME = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
def is_valid_agent_name(name: str) -> bool:
return bool(VALID_AGENT_NAME.fullmatch(name))
class MessageBus:
"""File-based message bus. Each agent has a .jsonl inbox.
Read is destructive: read_text + unlink (consumes messages).
Teaching version: no file locking; real CC uses proper-lockfile."""
"""Thread-safe file mailboxes with destructive reads."""
def send(self, from_agent: str, to_agent: str, content: str,
msg_type: str = "message"):
msg = {"from": from_agent, "to": to_agent,
"content": content, "type": msg_type,
"ts": time.time()}
inbox = MAILBOX_DIR / f"{to_agent}.jsonl"
with open(inbox, "a") as f:
f.write(json.dumps(msg) + "\n")
print(f" \033[33m[bus] {from_agent}{to_agent}: "
f"{content[:50]}\033[0m")
def __init__(self):
self._lock = threading.RLock()
self._changed = threading.Condition(self._lock)
def read_inbox(self, agent: str) -> list[dict]:
inbox = MAILBOX_DIR / f"{agent}.jsonl"
def _path(self, agent: str) -> Path:
if not is_valid_agent_name(agent):
raise ValueError(f"Invalid mailbox recipient: {agent!r}")
path = (MAILBOX_DIR / f"{agent}.jsonl").resolve()
if not path.is_relative_to(MAILBOX_ROOT):
raise ValueError(f"Mailbox path escapes directory: {agent!r}")
return path
def _read_unlocked(self, agent: str) -> list[dict]:
inbox = self._path(agent)
if not inbox.exists():
return []
msgs = [json.loads(line) for line in inbox.read_text().splitlines()
if line.strip()]
inbox.unlink() # consume: read + delete
inbox.unlink()
return msgs
def send(self, from_agent: str, to_agent: str, content: str,
msg_type: str = "message", metadata: dict | None = None):
msg = {"from": from_agent, "to": to_agent,
"content": content, "type": msg_type,
"ts": time.time(), "metadata": metadata or {}}
with self._changed:
with open(self._path(to_agent), "a") as f:
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
self._changed.notify_all()
print(f" \033[33m[bus] {from_agent}{to_agent}: "
f"({msg_type}) {content[:50]}\033[0m")
def read_inbox(self, agent: str) -> list[dict]:
with self._lock:
return self._read_unlocked(agent)
def peek(self, agent: str) -> bool:
"""Non-destructive: True if the agent has unread inbox messages.
The Lead's inbox poller uses this to decide whether to wake a turn
without consuming the mailbox."""
inbox = MAILBOX_DIR / f"{agent}.jsonl"
return inbox.exists() and inbox.stat().st_size > 0
with self._lock:
inbox = self._path(agent)
return inbox.exists() and inbox.stat().st_size > 0
def wait_for_messages(self, agent: str,
timeout: float | None = None) -> list[dict]:
"""Block until the agent has messages or timeout expires."""
deadline = None if timeout is None else time.monotonic() + timeout
with self._changed:
while not self.peek(agent):
remaining = (None if deadline is None
else deadline - time.monotonic())
if remaining is not None and remaining <= 0:
return []
self._changed.wait(remaining)
return self._read_unlocked(agent)
BUS = MessageBus()
# Track spawned teammates
active_teammates: dict[str, bool] = {}
# working | waiting_approval | idle | stopping
active_teammates: dict[str, str] = {}
plan_gates: dict[str, str] = {}
plan_request_ids: dict[str, str] = {}
team_lock = threading.RLock()
# ── Teammate Thread (s15 new) ──
@dataclass
class ProtocolState:
request_id: str
type: str
sender: str
target: str
status: str
payload: str
created_at: float = field(default_factory=time.time)
pending_requests: dict[str, ProtocolState] = {}
def new_request_id() -> str:
while True:
request_id = f"req_{random.randint(0, 999999):06d}"
if request_id not in pending_requests:
return request_id
def match_response(response_type: str, request_id: str, approve: bool,
from_agent: str, to_agent: str) -> bool:
"""Match one protocol response to one pending request."""
with team_lock:
state = pending_requests.get(request_id)
if not state:
print(f" \033[31m[protocol] unknown request_id: {request_id}\033[0m")
return False
expected = {
"shutdown": "shutdown_response",
"plan_approval": "plan_approval_response",
}[state.type]
if response_type != expected:
print(f" \033[31m[protocol] expected {expected}, "
f"got {response_type}\033[0m")
return False
if from_agent != state.target or to_agent != state.sender:
print(f" \033[31m[protocol] {request_id} responder mismatch\033[0m")
return False
if state.status != "pending":
print(f" \033[33m[protocol] {request_id} already "
f"{state.status}\033[0m")
return False
state.status = "approved" if approve else "rejected"
print(f" \033[35m[protocol] {request_id}{state.status}\033[0m")
return True
def consume_lead_inbox() -> list[dict]:
"""Consume Lead events and update protocol state before model delivery."""
msgs = BUS.read_inbox("lead")
for msg in msgs:
metadata = msg.get("metadata", {})
request_id = metadata.get("request_id", "")
if request_id and msg.get("type", "").endswith("_response"):
match_response(msg["type"], request_id,
metadata.get("approve", False),
msg.get("from", ""), msg.get("to", ""))
return msgs
def format_team_events(msgs: list[dict]) -> str:
lines = []
for msg in msgs:
metadata = msg.get("metadata", {})
request_id = metadata.get("request_id")
suffix = f" request_id={request_id}" if request_id else ""
lines.append(
f"[{msg['type']}{suffix}] {msg['from']}: {msg['content']}"
)
return "[Team events]\n" + "\n".join(lines)
def _last_assistant_text(content) -> str:
for block in content:
if getattr(block, "type", None) == "text":
return block.text.strip()
if isinstance(block, dict) and block.get("type") == "text":
return str(block.get("text", "")).strip()
return ""
def _teammate_submit_plan(from_name: str, plan: str) -> str:
with team_lock:
if plan_gates.get(from_name) == "pending":
return "A plan is already waiting for review."
request_id = new_request_id()
pending_requests[request_id] = ProtocolState(
request_id=request_id,
type="plan_approval",
sender=from_name,
target="lead",
status="pending",
payload=plan,
)
plan_gates[from_name] = "pending"
plan_request_ids[from_name] = request_id
active_teammates[from_name] = "waiting_approval"
BUS.send(from_name, "lead", plan, "plan_approval_request",
{"request_id": request_id})
return f"Plan submitted ({request_id}). Wait for Lead's decision."
def _run_teammate_tool(name: str, block, handlers: dict) -> str:
gate = plan_gates.get(name, "not_required")
if block.name in {"bash", "write_file"} and gate != "not_required":
if gate != "approved":
return (f"Blocked: plan status is {gate}. Submit or revise the "
"plan and wait for approval before changing the workspace.")
handler = handlers.get(block.name)
return str(handler(**block.input)) if handler else f"Unknown tool: {block.name}"
def apply_plan_response(name: str, msg: dict) -> tuple[bool, str]:
"""Apply only the Lead response for this teammate's current plan."""
metadata = msg.get("metadata", {})
request_id = metadata.get("request_id", "")
with team_lock:
state = pending_requests.get(request_id)
expected_id = plan_request_ids.get(name)
valid = (
msg.get("from") == "lead"
and msg.get("to") == name
and request_id == expected_id
and state is not None
and state.type == "plan_approval"
and state.sender == name
and state.target == "lead"
and state.status in {"approved", "rejected"}
and metadata.get("approve", False)
== (state.status == "approved")
)
if not valid:
return False, "[Ignored plan response: request mismatch]"
plan_gates[name] = state.status
active_teammates[name] = "working"
plan_request_ids.pop(name, None)
outcome = state.status
return True, f"[Plan {outcome}] {msg['content']}"
def apply_shutdown_request(name: str, msg: dict) -> tuple[bool, str]:
"""Accept only a pending shutdown request sent by Lead to this teammate."""
request_id = msg.get("metadata", {}).get("request_id", "")
with team_lock:
state = pending_requests.get(request_id)
valid = (
msg.get("from") == "lead"
and msg.get("to") == name
and state is not None
and state.type == "shutdown"
and state.sender == "lead"
and state.target == name
and state.status == "pending"
and active_teammates.get(name) != "stopping"
)
if not valid:
return False, "[Ignored shutdown request: request mismatch]"
active_teammates[name] = "stopping"
return True, request_id
def _teammate_send_message(from_name: str, to: str, content: str) -> str:
with team_lock:
if to != "lead" and to not in active_teammates:
return f"Agent '{to}' is not active"
BUS.send(from_name, to, content)
return f"Sent to {to}"
# ── Teammate Thread ──
def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
"""Spawn a teammate agent in a background thread.
Teaching version: max 10 rounds per teammate.
Real CC: teammates use idle loop (wait for inbox, work, repeat)
until shutdown_request."""
if name in active_teammates:
return f"Teammate '{name}' already exists"
"""Spawn a persistent teammate that alternates between WORK and IDLE."""
if not is_valid_agent_name(name):
return ("Invalid teammate name: use 1-64 letters, digits, "
"underscores, or dashes")
with team_lock:
if name in active_teammates:
return f"Teammate '{name}' already exists"
active_teammates[name] = "working"
plan_gates[name] = "not_required"
system = (f"You are '{name}', a {role}. "
f"Use tools to complete tasks. "
f"Send results via send_message to 'lead'.")
"Use tools to complete assigned work. "
"When asked for a plan, call submit_plan before bash or "
"write_file and wait for approval. End each assignment with a "
"concise result; the runtime delivers it to Lead.")
def run():
messages = [{"role": "user", "content": prompt}]
@@ -674,77 +893,166 @@ def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
"properties": {"to": {"type": "string"},
"content": {"type": "string"}},
"required": ["to", "content"]}},
{"name": "submit_plan",
"description": "Submit a work plan for Lead approval.",
"input_schema": {"type": "object",
"properties": {"plan": {"type": "string"}},
"required": ["plan"]}},
]
sub_handlers = {
"bash": run_bash, "read_file": run_read, "write_file": run_write,
"send_message": lambda to, content: (BUS.send(name, to, content),
"Sent")[1],
"send_message": lambda to, content: _teammate_send_message(
name, to, content),
"submit_plan": lambda plan: _teammate_submit_plan(name, plan),
}
for _ in range(10):
inbox = BUS.read_inbox(name)
if inbox:
def handle_messages(inbox: list[dict]) -> bool:
"""Return True when a shutdown request ends the teammate."""
work_messages = []
for msg in inbox:
msg_type = msg.get("type", "message")
metadata = msg.get("metadata", {})
request_id = metadata.get("request_id", "")
if msg_type == "shutdown_request":
accepted, notice = apply_shutdown_request(name, msg)
if not accepted:
work_messages.append(notice)
continue
request_id = notice
BUS.send(name, "lead", "Shutdown acknowledged.",
"shutdown_response",
{"request_id": request_id, "approve": True})
return True
if msg_type == "plan_approval_response":
_, notice = apply_plan_response(name, msg)
work_messages.append(notice)
continue
if msg_type == "plan_request":
work_messages.append(
f"[Plan required] {msg['content']}"
)
continue
work_messages.append(
f"[Message from {msg['from']}] {msg['content']}"
)
if work_messages:
messages.append({"role": "user",
"content": f"<inbox>{json.dumps(inbox)}</inbox>"})
"content": "\n".join(work_messages)})
return False
should_stop = False
while not should_stop:
with team_lock:
active_teammates[name] = "working"
try:
response = client.messages.create(
model=MODEL, system=system, messages=messages[-20:],
tools=sub_tools, max_tokens=8000)
except Exception:
except Exception as exc:
BUS.send(name, "lead",
f"{type(exc).__name__}: {exc}", "error")
break
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type == "tool_use":
handler = sub_handlers.get(block.name)
output = handler(**block.input) if handler else "Unknown"
if response.stop_reason == "tool_use":
results = []
for block in response.content:
if block.type != "tool_use":
continue
output = _run_teammate_tool(name, block, sub_handlers)
results.append({"type": "tool_result",
"tool_use_id": block.id,
"content": str(output)})
messages.append({"role": "user", "content": results})
"content": output})
messages.append({"role": "user", "content": results})
continue
# Send final summary to Lead
summary = "Done."
for msg in reversed(messages):
if msg["role"] == "assistant" and isinstance(msg["content"], list):
for b in msg["content"]:
if getattr(b, "type", None) == "text":
summary = b.text
break
else:
continue
break
BUS.send(name, "lead", summary, "result")
active_teammates.pop(name, None)
summary = _last_assistant_text(response.content)
gate = plan_gates.get(name, "not_required")
if gate != "pending" and summary:
BUS.send(name, "lead", summary, "result")
if gate == "pending":
with team_lock:
active_teammates[name] = "waiting_approval"
else:
with team_lock:
active_teammates[name] = "idle"
BUS.send(name, "lead", "Waiting for more work.",
"idle_notification")
while True:
inbox = BUS.wait_for_messages(name)
should_stop = handle_messages(inbox)
if should_stop or messages[-1]["role"] == "user":
break
with team_lock:
active_teammates.pop(name, None)
plan_gates.pop(name, None)
plan_request_ids.pop(name, None)
print(f" \033[32m[teammate] {name} finished\033[0m")
active_teammates[name] = True
threading.Thread(target=run, daemon=True).start()
print(f" \033[36m[teammate] {name} spawned as {role}\033[0m")
return f"Teammate '{name}' spawned as {role}"
# ── Team Tool Handlers (s15 new) ──
# ── Lead Team Tools ──
def run_spawn_teammate(name: str, role: str, prompt: str) -> str:
return spawn_teammate_thread(name, role, prompt)
def run_send_message(to: str, content: str) -> str:
if to not in active_teammates:
return f"Teammate '{to}' is not active"
BUS.send("lead", to, content)
return f"Sent to {to}"
def run_check_inbox() -> str:
msgs = BUS.read_inbox("lead")
if not msgs:
return "(inbox empty)"
lines = []
for m in msgs:
lines.append(f" [{m['from']}] {m['content'][:200]}")
return "\n".join(lines)
def run_request_shutdown(teammate: str) -> str:
if teammate not in active_teammates:
return f"Teammate '{teammate}' is not active"
with team_lock:
request_id = new_request_id()
pending_requests[request_id] = ProtocolState(
request_id=request_id,
type="shutdown",
sender="lead",
target=teammate,
status="pending",
payload="",
)
BUS.send("lead", teammate, "Finish the current step and shut down.",
"shutdown_request", {"request_id": request_id})
return f"Shutdown requested from {teammate} ({request_id})"
def run_request_plan(teammate: str, task: str) -> str:
if teammate not in active_teammates:
return f"Teammate '{teammate}' is not active"
with team_lock:
plan_gates[teammate] = "required"
BUS.send("lead", teammate, task, "plan_request")
return f"Plan requested from {teammate}"
def run_review_plan(request_id: str, approve: bool,
feedback: str = "") -> str:
with team_lock:
state = pending_requests.get(request_id)
if not state:
return f"Request {request_id} not found"
if state.type != "plan_approval":
return f"Request {request_id} is not a plan"
if state.status != "pending":
return f"Request {request_id} already {state.status}"
if plan_request_ids.get(state.sender) != request_id:
return f"Request {request_id} is not the current plan"
state.status = "approved" if approve else "rejected"
content = feedback or ("Plan approved." if approve
else "Revise the plan and submit it again.")
BUS.send("lead", state.sender, content, "plan_approval_response",
{"request_id": request_id, "approve": approve})
return f"Plan {state.status} ({request_id})"
# ── Tool Definitions ──
@@ -820,7 +1128,10 @@ TOOLS = [
"description": "Spawn a teammate agent in a background thread.",
"input_schema": {"type": "object",
"properties": {
"name": {"type": "string"},
"name": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{1,64}$",
},
"role": {"type": "string"},
"prompt": {"type": "string"}},
"required": ["name", "role", "prompt"]}},
@@ -830,10 +1141,25 @@ TOOLS = [
"properties": {"to": {"type": "string"},
"content": {"type": "string"}},
"required": ["to", "content"]}},
{"name": "check_inbox",
"description": "Check Lead's inbox for teammate messages.",
"input_schema": {"type": "object", "properties": {},
"required": []}},
{"name": "request_shutdown",
"description": "Ask an active teammate to shut down gracefully.",
"input_schema": {"type": "object",
"properties": {"teammate": {"type": "string"}},
"required": ["teammate"]}},
{"name": "request_plan",
"description": "Require a teammate to submit a plan before changing files.",
"input_schema": {"type": "object",
"properties": {"teammate": {"type": "string"},
"task": {"type": "string"}},
"required": ["teammate", "task"]}},
{"name": "review_plan",
"description": "Approve or reject a submitted plan by request_id.",
"input_schema": {"type": "object",
"properties": {
"request_id": {"type": "string"},
"approve": {"type": "boolean"},
"feedback": {"type": "string"}},
"required": ["request_id", "approve"]}},
]
@@ -854,9 +1180,8 @@ def update_context(context: dict, messages: list) -> dict:
# ── Agent Loop ──
# Teaching code keeps a basic agent loop. S11's full error recovery is omitted.
# Cron queue is consumed when agent_loop is called; real CC auto-wakes via
# queue processor (useQueueProcessor.ts) when items arrive.
# Keep the loop focused on the mechanisms introduced in this chapter.
# Fired cron entries are injected at the start of each model turn.
def agent_loop(messages: list, context: dict):
system = get_system_prompt(context)
@@ -955,16 +1280,16 @@ if __name__ == "__main__":
history.append({"role": "user", "content": payload})
else: # "wake": teammate inbox or background results are ready
parts = []
inbox = BUS.read_inbox("lead")
inbox = consume_lead_inbox()
if inbox:
parts.append("[Inbox]\n" + "\n".join(
f"From {m['from']}: {m['content'][:200]}" for m in inbox))
parts.append(format_team_events(inbox))
bg = collect_background_results()
parts.extend(bg)
if not parts:
continue # already drained by an earlier wake (idempotent)
history.append({"role": "user", "content": "\n".join(parts)})
print(f"\n\033[33m[wake: {len(inbox)} inbox + {len(bg)} background "
print(f"\n\033[33m[wake: {len(inbox)} team events + "
f"{len(bg)} background "
f"-> new turn]\033[0m")
# One turn for whichever source woke us.
@@ -976,10 +1301,10 @@ if __name__ == "__main__":
elif isinstance(block, dict) and block.get("type") == "text":
print(block.get("text", ""))
# Announce once when every teammate has finished and its output drained.
# Announce once after all requested shutdowns have completed.
if active_teammates:
had_teammates = True
elif had_teammates and not BUS.peek("lead") and not has_pending_background():
print("\033[32m[all teammates done]\033[0m")
print("\033[32m[all teammates shut down]\033[0m")
had_teammates = False
print()
@@ -32,7 +32,7 @@
<rect x="280" y="56" width="12" height="10" rx="2" fill="#f0fdf4" stroke="#16a34a" stroke-width="1"/>
<text x="298" y="66" fill="#16a34a" font-size="10" font-weight="600">Teammate</text>
<rect x="395" y="56" width="12" height="10" rx="2" fill="#fffbeb" stroke="#d97706" stroke-width="1"/>
<text x="413" y="66" fill="#d97706" font-size="10" font-weight="600">Real CC detail</text>
<text x="413" y="66" fill="#d97706" font-size="10" font-weight="600">Protocol gate</text>
<!-- ===== Row 1: Lead Agent Loop ===== -->
<rect x="28" y="90" width="70" height="40" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="1.5"/>
@@ -58,7 +58,7 @@
<rect x="398" y="80" width="336" height="60" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="566" y="98" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCH</text>
<text x="414" y="114" fill="#2563eb" font-size="8">bash · read · write · task(4) · cron(3)</text>
<text x="414" y="128" fill="#0891b2" font-size="8" font-weight="700">★ spawn_teammate · send_message · check_inbox</text>
<text x="414" y="128" fill="#0891b2" font-size="8" font-weight="700">★ spawn · send · shutdown · plan review</text>
<!-- Loop back -->
<path d="M 734 110 L 748 110 L 748 150 L 63 150 L 63 130" fill="none" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)" stroke-dasharray="6,3"/>
@@ -90,7 +90,7 @@
<rect x="60" y="248" width="220" height="66" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
<text x="170" y="268" fill="#166534" font-size="10" font-weight="700" text-anchor="middle">Teammate: alice (Backend)</text>
<text x="75" y="284" fill="#16a34a" font-size="8">inbox → LLM → bash/read/write/send</text>
<text x="75" y="298" fill="#6b7280" font-size="8">Max 10 rounds → summary → BUS.send</text>
<text x="75" y="298" fill="#6b7280" font-size="8">WORK → result → IDLE → next message</text>
<rect x="270" y="248" width="220" height="66" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
<text x="380" y="268" fill="#166534" font-size="10" font-weight="700" text-anchor="middle">Teammate: bob (Frontend)</text>
@@ -100,21 +100,21 @@
<rect x="480" y="248" width="220" height="66" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
<text x="590" y="268" fill="#166534" font-size="10" font-weight="700" text-anchor="middle">Teammate: charlie (QA)</text>
<text x="495" y="284" fill="#16a34a" font-size="8">Cannot spawn other teammates</text>
<text x="495" y="298" fill="#6b7280" font-size="8">spawn → work → summary</text>
<text x="495" y="298" fill="#6b7280" font-size="8">spawn → work → result → idle</text>
<!-- ===== Row 4: Permission bubbling (real CC detail) ===== -->
<!-- ===== Row 4: Plan approval gate ===== -->
<path d="M 60 360 L 10 360 L 10 195 L 60 195" fill="none" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow-amber)" stroke-dasharray="5,3"/>
<rect x="20" y="318" width="126" height="18" rx="4" fill="#fffbeb" stroke="#f59e0b" stroke-width="1"/>
<text x="83" y="331" fill="#d97706" font-size="10" font-weight="700" text-anchor="middle">permission_request</text>
<text x="83" y="331" fill="#d97706" font-size="10" font-weight="700" text-anchor="middle">plan_request</text>
<rect x="60" y="340" width="640" height="50" rx="6" fill="#fffbeb" stroke="#d97706" stroke-width="1.5"/>
<text x="380" y="360" fill="#92400e" font-size="11" font-weight="700" text-anchor="middle">Permission Bubbling (real CC; omitted in teaching code)</text>
<text x="80" y="378" fill="#78716c" font-size="9">① Teammate needs approval → MessageBus sends permission_request ② Lead receives → user approval → approve/deny</text>
<text x="380" y="360" fill="#92400e" font-size="11" font-weight="700" text-anchor="middle">Plan Approval Gate</text>
<text x="80" y="378" fill="#78716c" font-size="9">① Teammate submits plan ② Lead approves or rejects ③ bash / write_file stay blocked until approved</text>
<!-- ===== Row 5: Bottom notes ===== -->
<rect x="60" y="410" width="640" height="44" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<rect x="80" y="424" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="100" y="434" fill="#475569" font-size="10">s10-s14: prompt assembly, error recovery, task graph, background threads, cron scheduling</text>
<rect x="80" y="440" width="12" height="10" rx="2" fill="#ecfeff" stroke="#0891b2" stroke-width="1"/>
<text x="100" y="450" fill="#475569" font-size="10">s15: MessageBus + spawn_teammate_thread + send_message + check_inbox (permission bubbling is a real CC detail)</text>
<text x="100" y="450" fill="#475569" font-size="10">s15: MessageBus + persistent teammates + event delivery + typed protocols + plan gate</text>
</svg>

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

@@ -32,7 +32,7 @@
<rect x="260" y="56" width="12" height="10" rx="2" fill="#f0fdf4" stroke="#16a34a" stroke-width="1"/>
<text x="278" y="66" fill="#16a34a" font-size="10" font-weight="600">チームメイト</text>
<rect x="390" y="56" width="12" height="10" rx="2" fill="#fffbeb" stroke="#d97706" stroke-width="1"/>
<text x="408" y="66" fill="#d97706" font-size="10" font-weight="600">真实 CC 補足</text>
<text x="408" y="66" fill="#d97706" font-size="10" font-weight="600">プロトコルゲート</text>
<!-- ===== 行 1: Lead Agent ループ ===== -->
<rect x="28" y="90" width="70" height="40" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="1.5"/>
@@ -58,7 +58,7 @@
<rect x="398" y="80" width="336" height="60" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="566" y="98" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCH</text>
<text x="414" y="114" fill="#2563eb" font-size="8">bash · read · write · task(4) · cron(3)</text>
<text x="414" y="128" fill="#0891b2" font-size="8" font-weight="700">★ spawn_teammate · send_message · check_inbox</text>
<text x="414" y="128" fill="#0891b2" font-size="8" font-weight="700">★ spawn · send · shutdown · plan review</text>
<!-- ループバック -->
<path d="M 734 110 L 748 110 L 748 150 L 63 150 L 63 130" fill="none" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)" stroke-dasharray="6,3"/>
@@ -90,7 +90,7 @@
<rect x="60" y="248" width="220" height="66" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
<text x="170" y="268" fill="#166534" font-size="10" font-weight="700" text-anchor="middle">チームメイト: alice (Backend)</text>
<text x="75" y="284" fill="#16a34a" font-size="8">inbox → LLM → bash/read/write/send</text>
<text x="75" y="298" fill="#6b7280" font-size="8">最大 10 ラウンド → summary → BUS.send</text>
<text x="75" y="298" fill="#6b7280" font-size="8">WORK → result → IDLE → next message</text>
<rect x="270" y="248" width="220" height="66" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
<text x="380" y="268" fill="#166534" font-size="10" font-weight="700" text-anchor="middle">チームメイト: bob (Frontend)</text>
@@ -100,21 +100,21 @@
<rect x="480" y="248" width="220" height="66" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
<text x="590" y="268" fill="#166534" font-size="10" font-weight="700" text-anchor="middle">チームメイト: charlie (QA)</text>
<text x="495" y="284" fill="#16a34a" font-size="8">他のチームメイトを spawn 不可</text>
<text x="495" y="298" fill="#6b7280" font-size="8">spawn → work → summary</text>
<text x="495" y="298" fill="#6b7280" font-size="8">spawn → work → result → idle</text>
<!-- ===== 行 4: 権限バブリング(real CC detail ===== -->
<!-- ===== 行 4: プラン承認ゲート ===== -->
<path d="M 60 360 L 10 360 L 10 195 L 60 195" fill="none" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow-amber)" stroke-dasharray="5,3"/>
<rect x="20" y="318" width="126" height="18" rx="4" fill="#fffbeb" stroke="#f59e0b" stroke-width="1"/>
<text x="83" y="331" fill="#d97706" font-size="10" font-weight="700" text-anchor="middle">permission_request</text>
<text x="83" y="331" fill="#d97706" font-size="10" font-weight="700" text-anchor="middle">plan_request</text>
<rect x="60" y="340" width="640" height="50" rx="6" fill="#fffbeb" stroke="#d97706" stroke-width="1.5"/>
<text x="380" y="360" fill="#92400e" font-size="11" font-weight="700" text-anchor="middle">権限バブリング(真实 CC、教学版は省略)</text>
<text x="80" y="378" fill="#78716c" font-size="9">承認が必要 → MessageBus が permission_request 送信 ② Lead が受信 → ユーザー承認 → approve/deny</text>
<text x="380" y="360" fill="#92400e" font-size="11" font-weight="700" text-anchor="middle">プラン承認ゲート</text>
<text x="80" y="378" fill="#78716c" font-size="9">プラン提出 ② Lead が承認または却下 ③ approved まで bash / write_file を遮断</text>
<!-- ===== 行 5: 下部ノート ===== -->
<rect x="60" y="410" width="640" height="44" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<rect x="80" y="424" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="100" y="434" fill="#475569" font-size="10">s10-s14:プロンプト組み立て、エラーリカバリ、タスクグラフ、バックグラウンドスレッド、cron</text>
<rect x="80" y="440" width="12" height="10" rx="2" fill="#ecfeff" stroke="#0891b2" stroke-width="1"/>
<text x="100" y="450" fill="#475569" font-size="10">s15MessageBus + spawn_teammate_thread + send_message + check_inbox(権限バブリングは真实 CC 補足)</text>
<text x="100" y="450" fill="#475569" font-size="10">s15MessageBus + 永続チームメイト + イベント配信 + 型付きプロトコル + プランゲート</text>
</svg>

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

@@ -32,7 +32,7 @@
<rect x="270" y="56" width="12" height="10" rx="2" fill="#f0fdf4" stroke="#16a34a" stroke-width="1"/>
<text x="288" y="66" fill="#16a34a" font-size="10" font-weight="600">Teammate</text>
<rect x="390" y="56" width="12" height="10" rx="2" fill="#fffbeb" stroke="#d97706" stroke-width="1"/>
<text x="408" y="66" fill="#d97706" font-size="10" font-weight="600">真实 CC 补充</text>
<text x="408" y="66" fill="#d97706" font-size="10" font-weight="600">协议闸门</text>
<!-- ===== Row 1: Lead Agent Loop ===== -->
<!-- Boxes at y=90..130 (h=40), prompt/LLM at y=86..134 (h=48), TOOLS at y=80..140 (h=60) -->
@@ -60,7 +60,7 @@
<rect x="398" y="80" width="336" height="60" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="566" y="98" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCH</text>
<text x="414" y="114" fill="#2563eb" font-size="8">bash · read · write · task(4) · cron(3)</text>
<text x="414" y="128" fill="#0891b2" font-size="8" font-weight="700">★ spawn_teammate · send_message · check_inbox</text>
<text x="414" y="128" fill="#0891b2" font-size="8" font-weight="700">★ spawn · send · shutdown · plan review</text>
<!-- Loop back -->
<path d="M 734 110 L 748 110 L 748 150 L 63 150 L 63 130" fill="none" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)" stroke-dasharray="6,3"/>
@@ -99,7 +99,7 @@
<rect x="60" y="248" width="220" height="66" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
<text x="170" y="268" fill="#166534" font-size="10" font-weight="700" text-anchor="middle">Teammate: alice (Backend)</text>
<text x="75" y="284" fill="#16a34a" font-size="8">inbox → LLM → bash/read/write/send</text>
<text x="75" y="298" fill="#6b7280" font-size="8">最多 10 轮 → summary → BUS.send</text>
<text x="75" y="298" fill="#6b7280" font-size="8">WORK → result → IDLE → next message</text>
<!-- bob: x=270..490, y=248..314 -->
<rect x="270" y="248" width="220" height="66" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
@@ -111,22 +111,21 @@
<rect x="480" y="248" width="220" height="66" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
<text x="590" y="268" fill="#166534" font-size="10" font-weight="700" text-anchor="middle">Teammate: charlie (QA)</text>
<text x="495" y="284" fill="#16a34a" font-size="8">不能 spawn 其他 teammate</text>
<text x="495" y="298" fill="#6b7280" font-size="8">spawn → work → summary</text>
<text x="495" y="298" fill="#6b7280" font-size="8">spawn → work → result → idle</text>
<!-- ===== Row 4: Permission bubbling (real CC detail) ===== -->
<!-- Permission request goes through MessageBus, then Lead check_inbox handles it. -->
<!-- ===== Row 4: Plan approval gate ===== -->
<path d="M 60 360 L 10 360 L 10 195 L 60 195" fill="none" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow-amber)" stroke-dasharray="5,3"/>
<rect x="20" y="318" width="126" height="18" rx="4" fill="#fffbeb" stroke="#f59e0b" stroke-width="1"/>
<text x="83" y="331" fill="#d97706" font-size="10" font-weight="700" text-anchor="middle">permission_request</text>
<text x="83" y="331" fill="#d97706" font-size="10" font-weight="700" text-anchor="middle">plan_request</text>
<rect x="60" y="340" width="640" height="50" rx="6" fill="#fffbeb" stroke="#d97706" stroke-width="1.5"/>
<text x="380" y="360" fill="#92400e" font-size="11" font-weight="700" text-anchor="middle">权限冒泡(真实 CC,教学版省略)</text>
<text x="80" y="378" fill="#78716c" font-size="9">① 队友需审批 → MessageBus 发送 permission_request ② Lead 收到 → 用户审批 → 回复 approve/deny</text>
<text x="380" y="360" fill="#92400e" font-size="11" font-weight="700" text-anchor="middle">计划审批闸门</text>
<text x="80" y="378" fill="#78716c" font-size="9">① 队友提交计划 ② Lead 批准或拒绝 ③ approved 前 bash / write_file 被拦截</text>
<!-- ===== Row 5: Bottom notes ===== -->
<rect x="60" y="410" width="640" height="44" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<rect x="80" y="424" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="100" y="434" fill="#475569" font-size="10">s10-s14: prompt 组装、错误恢复、任务图、后台线程、cron 调度</text>
<rect x="80" y="440" width="12" height="10" rx="2" fill="#ecfeff" stroke="#0891b2" stroke-width="1"/>
<text x="100" y="450" fill="#475569" font-size="10">s15: MessageBus + spawn_teammate_thread + send_message + check_inbox(权限冒泡见真实 CC 补充)</text>
<text x="100" y="450" fill="#475569" font-size="10">s15: MessageBus + 持久队友 + 自动事件投递 + 类型化协议 + 计划闸门</text>
</svg>

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

@@ -24,9 +24,9 @@
<text x="380" y="28" fill="#fff" font-size="15" font-weight="700" text-anchor="middle">Team Protocols — Request-Response + request_id Correlation + State Machine</text>
<rect x="40" y="56" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="58" y="66" fill="#2563eb" font-size="10" font-weight="600">s15 Preserved</text>
<text x="58" y="66" fill="#2563eb" font-size="10" font-weight="600">Team runtime</text>
<rect x="160" y="56" width="12" height="10" rx="2" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1"/>
<text x="178" y="66" fill="#7c3aed" font-size="10" font-weight="600">s16 New</text>
<text x="178" y="66" fill="#7c3aed" font-size="10" font-weight="600">Protocols</text>
<!-- Row 1: Lead Loop -->
<rect x="20" y="90" width="70" height="40" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="1.5"/>
@@ -135,7 +135,7 @@
<!-- Bottom notes -->
<rect x="30" y="622" width="700" height="28" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<rect x="50" y="632" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="70" y="642" fill="#475569" font-size="10">s15: MessageBus + spawn_teammate + inbox</text>
<text x="70" y="642" fill="#475569" font-size="10">Runtime: MessageBus + persistent teammates + automatic delivery</text>
<rect x="310" y="632" width="12" height="10" rx="2" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1"/>
<text x="330" y="642" fill="#475569" font-size="10">s16: request_id protocol + dispatch + pending_requests + state machine</text>
<text x="330" y="642" fill="#475569" font-size="10">Protocol: request_id + dispatch + pending_requests + plan gate</text>
</svg>

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

@@ -24,9 +24,9 @@
<text x="380" y="28" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">Team Protocols — リクエスト・レスポンス + request_id 紐付け + 状態機械</text>
<rect x="40" y="56" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="58" y="66" fill="#2563eb" font-size="10" font-weight="600">s15 維持</text>
<text x="58" y="66" fill="#2563eb" font-size="10" font-weight="600">チームランタイム</text>
<rect x="140" y="56" width="12" height="10" rx="2" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1"/>
<text x="158" y="66" fill="#7c3aed" font-size="10" font-weight="600">s16 新規</text>
<text x="158" y="66" fill="#7c3aed" font-size="10" font-weight="600">協調プロトコル</text>
<!-- Row 1: Lead Loop -->
<rect x="20" y="90" width="70" height="40" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="1.5"/>
@@ -135,7 +135,7 @@
<!-- Bottom notes -->
<rect x="30" y="622" width="700" height="28" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<rect x="50" y="632" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="70" y="642" fill="#475569" font-size="10">s15: MessageBus + spawn_teammate + inbox</text>
<text x="70" y="642" fill="#475569" font-size="10">Runtime: MessageBus + 永続チームメイト + 自動イベント配信</text>
<rect x="310" y="632" width="12" height="10" rx="2" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1"/>
<text x="330" y="642" fill="#475569" font-size="10">s16: request_id プロトコル + dispatch + pending_requests + 状態機械</text>
<text x="330" y="642" fill="#475569" font-size="10">Protocol: request_id + dispatch + pending_requests + プランゲート</text>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

@@ -24,9 +24,9 @@
<text x="380" y="28" fill="#fff" font-size="15" font-weight="700" text-anchor="middle">Team Protocols — 请求-响应协议 + request_id 关联 + 状态机</text>
<rect x="40" y="56" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="58" y="66" fill="#2563eb" font-size="10" font-weight="600">s15 保留</text>
<text x="58" y="66" fill="#2563eb" font-size="10" font-weight="600">团队运行时</text>
<rect x="140" y="56" width="12" height="10" rx="2" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1"/>
<text x="158" y="66" fill="#7c3aed" font-size="10" font-weight="600">s16 新增</text>
<text x="158" y="66" fill="#7c3aed" font-size="10" font-weight="600">协作协议</text>
<!-- Row 1: Lead Loop -->
<rect x="20" y="90" width="70" height="40" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="1.5"/>
@@ -135,7 +135,7 @@
<!-- Bottom notes -->
<rect x="30" y="622" width="700" height="28" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<rect x="50" y="632" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="70" y="642" fill="#475569" font-size="10">s15: MessageBus + spawn_teammate + inbox</text>
<text x="70" y="642" fill="#475569" font-size="10">运行时: MessageBus + 持久队友 + 自动事件投递</text>
<rect x="310" y="632" width="12" height="10" rx="2" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1"/>
<text x="330" y="642" fill="#475569" font-size="10">s16: request_id 协议 + dispatch + pending_requests + 状态机</text>
<text x="330" y="642" fill="#475569" font-size="10">协议: request_id + dispatch + pending_requests + 计划闸门</text>
</svg>

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+1 -1
View File
@@ -23,7 +23,7 @@
<rect x="260" y="58" width="200" height="68" rx="8" fill="#ecfeff" stroke="#0891b2" stroke-width="2"/>
<text x="360" y="82" fill="#0e7490" font-size="13" font-weight="700" text-anchor="middle">Lead Agent</text>
<text x="360" y="100" fill="#0e7490" font-size="10" text-anchor="middle">Main loop + spawn + inbox handling</text>
<text x="360" y="116" fill="#0e7490" font-size="10" text-anchor="middle">check_inbox receives teammate messages</text>
<text x="360" y="116" fill="#0e7490" font-size="10" text-anchor="middle">runtime delivers team events automatically</text>
<!-- Message Bus -->
<rect x="80" y="150" width="560" height="26" rx="13" fill="#fef3c7" stroke="#d97706" stroke-width="1.5"/>

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

+1 -1
View File
@@ -23,7 +23,7 @@
<rect x="260" y="58" width="200" height="68" rx="8" fill="#ecfeff" stroke="#0891b2" stroke-width="2"/>
<text x="360" y="82" fill="#0e7490" font-size="13" font-weight="700" text-anchor="middle">Lead Agent</text>
<text x="360" y="100" fill="#0e7490" font-size="10" text-anchor="middle">メインループ + spawn + inbox 処理</text>
<text x="360" y="116" fill="#0e7490" font-size="10" text-anchor="middle">check_inbox でチームメイトのメッセージ受</text>
<text x="360" y="116" fill="#0e7490" font-size="10" text-anchor="middle">ランタイムがチームイベントを自動配</text>
<!-- Message Bus -->
<rect x="80" y="150" width="560" height="26" rx="13" fill="#fef3c7" stroke="#d97706" stroke-width="1.5"/>

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

+1 -1
View File
@@ -26,7 +26,7 @@
<rect x="260" y="58" width="200" height="68" rx="8" fill="#ecfeff" stroke="#0891b2" stroke-width="2"/>
<text x="360" y="82" fill="#0e7490" font-size="13" font-weight="700" text-anchor="middle">Lead Agent</text>
<text x="360" y="100" fill="#0e7490" font-size="10" text-anchor="middle">主循环 + spawn + inbox 处理</text>
<text x="360" y="116" fill="#0e7490" font-size="10" text-anchor="middle">check_inbox 接收队友消息</text>
<text x="360" y="116" fill="#0e7490" font-size="10" text-anchor="middle">运行时自动投递团队事件</text>
<!-- Message Bus: x=80..640, y=150..176 (26px tall) -->
<rect x="80" y="150" width="560" height="26" rx="13" fill="#fef3c7" stroke="#d97706" stroke-width="1.5"/>

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

+204
View File
@@ -0,0 +1,204 @@
# s16: Autonomous Agents — ボードを見て、自分で Claim する
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s14 → s15 → `s16` → [s17](../s17_worktree_isolation/) → s18 → s19 → s20 → s21
> *「IDLE はメッセージを待つだけでなく、開始可能な仕事を探す時間でもある。」* — 共有タスクボード、自動発見、原子的な Claim。
>
> **Harness レイヤー**:自律 — Lead は目標を管理し、チームメイトは共有状態から次の仕事を見つける。
---
## 問題
s15 のチームメイトは作業後に IDLE へ入り、Lead から次の依頼を待つ。タスクボードに 10 件の pending task があっても、Lead はチームメイトを選び、メッセージを送り、結果を待つ操作を 10 回繰り返す必要がある。
仕事がすでに分解され、依存関係もタスクボードに記録されているなら、次の ready task の割り当てに毎回モデル判断は要らない。IDLE のチームメイト自身が共有状態を読み、前提条件を満たした仕事を Claim できる。
---
## 解決策
![Autonomous Agents Overview](images/autonomous-agents-overview.ja.svg)
s16 は s15 のチームライフサイクルを変えず、IDLE の動作だけを拡張する:
```text
s15: WORK → result → IDLE → メッセージを待つ
s16: WORK → result → IDLE → メッセージを待つ
└→ ボード走査 → Claim → WORK
```
追加する関数は 2 つ:
- `scan_unclaimed_tasks()`:現在開始できるタスクを探す。
- `claim_next_task(name)`:候補の 1 件を原子的に Claim する。
チームメイトのツールにも `list_tasks``claim_task``complete_task` を加え、同じループ内で作業を完了できるようにする。
---
## 仕組み
### 1. 発見と所有権を分離する
走査は状態を変更せず、読み取りだけを行う:
```python
def scan_unclaimed_tasks() -> list[Task]:
return [
task for task in list_tasks()
if (
task.status == "pending"
and task.owner is None
and can_start(task.id)
)
]
```
候補は `pending` で、owner がなく、すべての `blockedBy` が完了していなければならない。
ただし候補一覧は一時点のスナップショットにすぎない。直後に別のチームメイトが同じタスクを Claim する可能性があるため、「発見した」と「所有した」を同じ意味にしてはいけない。
### 2. Claim はロック内で読み取り、確認、書き込みを行う
`claim_task()` は状態遷移全体を `task_lock` で保護する:
```python
def claim_task(task_id: str, owner: str) -> str:
with task_lock:
task = load_task(task_id)
if task.status != "pending" or task.owner:
return "Task is no longer available"
if not can_start(task_id):
return "Task is blocked"
task.owner = owner
task.status = "in_progress"
save_task(task)
return f"Claimed {task.id}"
```
`claim_next_task()` は成功する候補が見つかるまで順に試す:
```python
def claim_next_task(name: str) -> Task | None:
for task in scan_unclaimed_tasks():
result = claim_task(task.id, owner=name)
if result.startswith("Claimed "):
return load_task(task.id)
return None
```
複数のチームメイトが同時にボードを観察しても、最終的な owner は Claim 関数によって 1 人に決まる。
### 3. メッセージを優先し、その後にタスクを探す
IDLE に入ったチームメイトは、まず短時間だけ受信イベントを待つ:
```python
while True:
inbox = BUS.wait_for_messages(name, IDLE_SCAN_INTERVAL)
if inbox:
handle_messages(inbox)
break
task = claim_next_task(name)
if task:
messages.append({
"role": "user",
"content": (
f"[Auto-claimed task {task.id}] "
f"{task.subject}\n{task.description}"
),
})
break
```
この順序にする理由は明確だ:
- shutdown、計画承認、Lead からの直接メッセージにはすぐ応答する。
- メッセージがない IDLE 時間だけを、共有タスクの探索に使う。
メッセージも ready task もなければ IDLE を続ける。候補が空なのは、依存タスクがまだ完了していないだけかもしれない。
### 4. Claim 後は同じ WORK ループを再利用する
Claim に成功すると、ランタイムはタスク ID、件名、説明をチームメイトの messages へ追加する。ファイルツール、Shell、計画ゲート、結果通知、終了プロトコルはすべて s15 の仕組みをそのまま使う。
```text
ready task が現れる
→ IDLE のチームメイトが発見
→ claim_task が owner と in_progress を記録
→ タスクが messages に入る
→ WORK
→ complete_task
→ result + idle_notification
→ 再び走査
```
自律のために別の Agent Loop を作る必要はない。既存ループへ共有状態から入る入口を追加すればよい。
---
## この設計を選ぶ理由
**Lead が毎回割り当てないのはなぜか。**
`status``owner``blockedBy` が実行可能性をすでに表している。同じ状態を Lead に毎回解釈させても、調整ターンが増えるだけである。
**走査時に owner を設定しないのはなぜか。**
走査は並行実行され得る。所有権変更を 1 つのロック付き関数に集めれば、すべての呼び出し元が同じ規則に従う。
**ready task がない時に終了しないのはなぜか。**
依存タスクが完了すれば、後続タスクが ready になる。IDLE を維持すれば、その瞬間に次の仕事を引き継げる。
---
## s15 からの変更
| コンポーネント | s15 | s16 |
|---|---|---|
| IDLE | チームメッセージを待つ | メッセージ待機後にボードを走査 |
| 割り当て | Lead が明示的に送る | チームメイトが自動 Claim 可能 |
| 所有権 | 呼び出し元が Claim | `task_lock` で Claim を原子的にする |
| チームメイトツール | ファイル、Shell、メッセージ、計画 | list / claim / complete task を追加 |
| 結果と終了 | `result``idle_notification`、shutdown protocol | 変更なし |
---
## 試してみる
```sh
cd learn-claude-code
python s16_autonomous_agents/code.py
```
通常の要求を入力する:
```text
バックエンド改修を共有タスクボードへ分解し、依存関係が許す範囲で
設定、認証、テストを並行実行してください。既存インターフェースを
維持し、最後に結果をまとめてください。
```
Lead がチーム案を示したら、次のように返す:
```text
始めてください
```
`.tasks/` のタスクが `pending``in_progress``completed` と変化する様子を確認する。2 人の IDLE チームメイトは別々のタスクを Claim し、`blockedBy` のあるタスクは前提完了後にだけ候補になるはずだ。
---
## 次へ
チームメイトは仕事を自分で見つけられるようになったが、まだ同じディレクトリでファイルを変更する。次のセッションではタスク所有権を分離された作業ディレクトリへ結び付ける。
次へ:[s17 Worktree Isolation](../s17_worktree_isolation/)。
<!-- translation-sync: zh@v3, en@v3, ja@v3 -->
+204
View File
@@ -0,0 +1,204 @@
# s16: Autonomous Agents — Check the Board, Claim the Work
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s14 → s15 → `s16` → [s17](../s17_worktree_isolation/) → s18 → s19 → s20 → s21
> *"Idle does not only mean waiting for a message; it can also mean looking for ready work."* — Shared task board, automatic discovery, and atomic claims.
>
> **Harness layer**: Autonomy — Lead owns the goal while teammates discover the next step from shared state.
---
## The Problem
In s15, a teammate enters IDLE after finishing an assignment and waits for Lead to send more work. If the task board already contains ten pending tasks, Lead still has to choose a teammate, send a message, and wait for a result ten times.
Once work has been decomposed and dependencies are recorded on the task board, assigning the next ready task does not always need another model decision. An idle teammate can read shared state and claim work whose prerequisites are complete.
---
## The Solution
![Autonomous Agents Overview](images/autonomous-agents-overview.en.svg)
s16 keeps the s15 team lifecycle and extends only the IDLE state:
```text
s15: WORK → result → IDLE → wait for a message
s16: WORK → result → IDLE → wait for a message
└→ scan board → claim → WORK
```
It adds two functions:
- `scan_unclaimed_tasks()` finds tasks that can start now.
- `claim_next_task(name)` attempts to claim one candidate atomically.
Teammates also receive `list_tasks`, `claim_task`, and `complete_task`, allowing the claimed work to close inside the same loop.
---
## How It Works
### 1. Discovery and ownership are separate steps
Scanning reads state without changing it:
```python
def scan_unclaimed_tasks() -> list[Task]:
return [
task for task in list_tasks()
if (
task.status == "pending"
and task.owner is None
and can_start(task.id)
)
]
```
A candidate must be `pending`, have no owner, and have every `blockedBy` dependency completed.
The resulting list is only a snapshot. Another teammate may claim the same task immediately afterward, so "discovered" must never mean "owned."
### 2. Claim performs read, validation, and write under one lock
`claim_task()` protects the full state transition with `task_lock`:
```python
def claim_task(task_id: str, owner: str) -> str:
with task_lock:
task = load_task(task_id)
if task.status != "pending" or task.owner:
return "Task is no longer available"
if not can_start(task_id):
return "Task is blocked"
task.owner = owner
task.status = "in_progress"
save_task(task)
return f"Claimed {task.id}"
```
`claim_next_task()` tries candidates until one claim succeeds:
```python
def claim_next_task(name: str) -> Task | None:
for task in scan_unclaimed_tasks():
result = claim_task(task.id, owner=name)
if result.startswith("Claimed "):
return load_task(task.id)
return None
```
Many teammates may observe the board at once, but the claim function gives each task one final owner.
### 3. Messages take priority over board scans
In IDLE, a teammate first waits briefly for mailbox events:
```python
while True:
inbox = BUS.wait_for_messages(name, IDLE_SCAN_INTERVAL)
if inbox:
handle_messages(inbox)
break
task = claim_next_task(name)
if task:
messages.append({
"role": "user",
"content": (
f"[Auto-claimed task {task.id}] "
f"{task.subject}\n{task.description}"
),
})
break
```
This ordering matters:
- Shutdown, plan approval, and direct Lead messages should be handled promptly.
- Only otherwise idle time is used to look for shared work.
If there is neither a message nor a ready task, the teammate stays idle. An empty scan is not a reason to exit because a blocked task may become ready later.
### 4. A claimed task reuses the same WORK loop
After a successful claim, the runtime injects the task ID, subject, and description into the teammate's messages. The existing file tools, Shell, plan gate, result reporting, and shutdown protocol all remain unchanged.
```text
ready task appears
→ idle teammate discovers it
→ claim_task writes owner and in_progress
→ task enters teammate messages
→ WORK
→ complete_task
→ result + idle_notification
→ scan again
```
Autonomy does not require another agent loop. It adds a shared-state entry point to the loop that already exists.
---
## Why This Design
**Why not ask Lead to assign every task?**
The task's `status`, `owner`, and `blockedBy` already encode whether it can run. Reinterpreting that same state through Lead adds coordination turns without adding judgment.
**Why not set the owner during scanning?**
Scans may overlap. Keeping ownership changes in one locked function gives every caller the same rule.
**Why keep teammates alive when no task is ready?**
An empty candidate list may only mean that prerequisites are still running. IDLE teammates can pick up downstream work as soon as it becomes ready.
---
## What Changed from s15
| Component | s15 | s16 |
|---|---|---|
| IDLE behavior | Wait for team messages | Wait for messages, then scan the board |
| Assignment | Lead sends work explicitly | Teammates may auto-claim |
| Ownership | Caller initiates claim | `task_lock` makes claim atomic |
| Teammate tools | Files, Shell, messages, plans | Adds list / claim / complete task |
| Result and shutdown | `result`, `idle_notification`, shutdown protocol | Unchanged |
---
## Try It
```sh
cd learn-claude-code
python s16_autonomous_agents/code.py
```
Enter an ordinary request:
```text
Put the backend refactor on a shared task board. Complete configuration,
authentication, and tests in parallel where dependencies allow, preserve
existing interfaces, and summarize the result.
```
After Lead proposes a team, reply:
```text
Go ahead.
```
Watch tasks move from `pending` to `in_progress` and `completed` under `.tasks/`. Two idle teammates should claim different tasks, and a task with `blockedBy` should become a candidate only after its prerequisites finish.
---
## Next
Teammates can now discover work, but they still edit files in the same directory. The next lesson binds task ownership to isolated working directories.
Next: [s17 Worktree Isolation](../s17_worktree_isolation/).
<!-- translation-sync: zh@v3, en@v3, ja@v3 -->
+207
View File
@@ -0,0 +1,207 @@
# s16: Autonomous Agents — 自己看板,自己认领
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s14 → s15 → `s16` → [s17](../s17_worktree_isolation/) → s18 → s19 → s20 → s21
> *"空闲时不只等消息,也主动寻找可以开始的工作。"* — 共享任务板、自动发现与原子认领。
>
> **Harness 层**:自治 — Lead 管目标,队友从任务状态中发现下一步。
---
## 问题
s15 的队友会在完成一项工作后进入 IDLE,等待 Lead 继续派发。如果任务板上已经有十个待办任务,Lead 仍然要逐个选择队友、发送消息,再等待结果。
当任务已经被拆分,并且依赖关系也写进了任务板,谁来执行下一项工作不一定需要 Lead 再做一次模型决策。空闲队友可以直接读取共享状态,找到已经满足条件的任务并认领它。
---
## 解决方案
![Autonomous Agents Overview](images/autonomous-agents-overview.svg)
s16 不改变 s15 的团队生命周期,只扩展 IDLE 状态:
```text
s15: WORK → result → IDLE → 等待消息
s16: WORK → result → IDLE → 等待消息
└→ 扫描任务板 → 认领 → WORK
```
新增两个函数:
- `scan_unclaimed_tasks()`:找出当前可以开始的任务。
- `claim_next_task(name)`:尝试原子认领其中一个任务。
队友工具集同时增加 `list_tasks``claim_task``complete_task`,让认领后的工作能在同一个循环中闭合。
---
## 工作原理
### 1. 发现任务和认领任务是两步
扫描只读取状态,不修改任务:
```python
def scan_unclaimed_tasks() -> list[Task]:
return [
task for task in list_tasks()
if (
task.status == "pending"
and task.owner is None
and can_start(task.id)
)
]
```
一个任务必须同时满足三个条件:
- 状态是 `pending`
- 还没有 `owner`
- `blockedBy` 中的任务都已经完成。
扫描得到的只是候选列表。另一个队友可能在下一瞬间认领同一任务,因此不能把“扫描到”当成“已经拥有”。
### 2. claim 在锁内完成读、检查和写入
`claim_task()` 使用同一把 `task_lock` 包住完整的读改写过程:
```python
def claim_task(task_id: str, owner: str) -> str:
with task_lock:
task = load_task(task_id)
if task.status != "pending" or task.owner:
return "Task is no longer available"
if not can_start(task_id):
return "Task is blocked"
task.owner = owner
task.status = "in_progress"
save_task(task)
return f"Claimed {task.id}"
```
`claim_next_task()` 依次尝试候选任务。某次认领失败时,它会继续尝试下一个,而不是把失败误当成成功:
```python
def claim_next_task(name: str) -> Task | None:
for task in scan_unclaimed_tasks():
result = claim_task(task.id, owner=name)
if result.startswith("Claimed "):
return load_task(task.id)
return None
```
扫描负责发现,claim 负责所有权。把两者分开后,多个队友可以同时观察任务板,但每个任务只能有一个最终 owner。
### 3. 消息优先,任务扫描其次
队友进入 IDLE 后,先等待一小段时间的收件箱事件:
```python
while True:
inbox = BUS.wait_for_messages(name, IDLE_SCAN_INTERVAL)
if inbox:
handle_messages(inbox)
break
task = claim_next_task(name)
if task:
messages.append({
"role": "user",
"content": (
f"[Auto-claimed task {task.id}] "
f"{task.subject}\n{task.description}"
),
})
break
```
这样安排有两个原因:
- 关机、计划审批和 Lead 的直接消息应该尽快响应;
- 没有消息时,空闲时间才用于寻找共享任务。
如果既没有消息也没有可认领任务,队友继续保持 IDLE,不会因为一次扫描为空就退出。
### 4. 自动认领后复用同一个 WORK 循环
认领成功后,运行时把任务 ID、标题和描述写入队友 messages。对模型来说,它只是收到了一项新工作;文件、Shell、计划闸门、结果上报都继续使用 s15 的机制。
```text
任务板出现 ready task
→ 空闲队友扫描到候选
→ claim_task 写入 owner 和 in_progress
→ 任务进入队友 messages
→ WORK
→ complete_task
→ result + idle_notification
→ 再次扫描
```
自治不是再造一个 Agent Loop,而是给既有循环增加一个由共享状态触发的入口。
---
## 为什么这样设计
**为什么不是 Lead 每次分配?**
任务依赖已经编码在 `status``owner``blockedBy` 中。让 Lead 反复解释同一状态,只会增加协调轮次。
**为什么不是扫描时直接改 owner?**
扫描可能并发发生。把认领集中到带锁的函数中,所有调用方共享同一个所有权规则。
**为什么不在没有任务时关闭队友?**
暂时没有 ready task 可能只是因为依赖尚未完成。保持 IDLE 后,前置任务完成时队友可以自动接上后续工作。
---
## 相对 s15 的变化
| 组件 | s15 | s16 |
|---|---|---|
| IDLE 行为 | 等待团队消息 | 先等消息,再扫描任务板 |
| 任务分配 | Lead 明确派发 | 队友可自动认领 |
| 任务所有权 | 调用方发起 claim | `task_lock` 保证认领原子性 |
| 队友工具 | 文件、Shell、消息、计划 | 增加 list / claim / complete task |
| 结果与关机 | `result``idle_notification`、shutdown 协议 | 保持不变 |
---
## 试一下
```sh
cd learn-claude-code
python s16_autonomous_agents/code.py
```
输入一个自然需求:
```text
请把后端改造拆到共享任务板,按依赖关系并行完成配置、认证和测试,
保持现有接口兼容,并在最后汇总结果。
```
Lead 提出团队方案后回复:
```text
开始吧
```
观察 `.tasks/` 中任务如何从 `pending` 进入 `in_progress``completed`,以及两个空闲队友是否会认领不同任务。带 `blockedBy` 的任务应该只在前置任务完成后出现为候选。
---
## 接下来
队友已经能自己找到任务,但仍然在同一个工作目录里修改文件。下一章把任务所有权和工作目录绑定起来,让并行工作彼此隔离。
下一章:[s17 Worktree Isolation](../s17_worktree_isolation/)。
<!-- translation-sync: zh@v3, en@v3, ja@v3 -->
File diff suppressed because it is too large Load Diff
@@ -19,15 +19,15 @@
<!-- Title -->
<rect x="0" y="0" width="760" height="44" fill="url(#header)" rx="8"/>
<rect x="0" y="36" width="760" height="8" fill="url(#header)"/>
<text x="380" y="28" fill="#fff" font-size="15" font-weight="700" text-anchor="middle">Autonomous Agents — Idle Loop + Auto-Claim + WORK/IDLE Lifecycle</text>
<text x="380" y="28" fill="#fff" font-size="15" font-weight="700" text-anchor="middle">Autonomous Agents — Idle Task Discovery + Atomic Claim</text>
<!-- Legend -->
<rect x="40" y="56" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="58" y="66" fill="#2563eb" font-size="10" font-weight="600">s16 Preserved</text>
<text x="58" y="66" fill="#2563eb" font-size="10" font-weight="600">s15 Preserved</text>
<rect x="160" y="56" width="12" height="10" rx="2" fill="#ecfdf5" stroke="#059669" stroke-width="1"/>
<text x="178" y="66" fill="#059669" font-size="10" font-weight="600">s17 New</text>
<text x="178" y="66" fill="#059669" font-size="10" font-weight="600">s16 New</text>
<!-- ===== Row 1: Lead Loop (s16 preserved) ===== -->
<!-- ===== Row 1: Lead Loop (s15 preserved) ===== -->
<rect x="20" y="90" width="70" height="40" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="1.5"/>
<text x="55" y="114" fill="#4f46e5" font-size="8" font-weight="600" text-anchor="middle">turn</text>
@@ -49,28 +49,28 @@
<line x1="361" y1="110" x2="375" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="378" y="80" width="356" height="60" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="556" y="98" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCH (all s16 preserved)</text>
<text x="394" y="114" fill="#2563eb" font-size="8">bash · read · write · task(4) · send · inbox</text>
<text x="556" y="98" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCH (all s15 preserved)</text>
<text x="394" y="114" fill="#2563eb" font-size="8">bash · read · write · task tools · send · protocols</text>
<text x="394" y="128" fill="#7c3aed" font-size="8" font-weight="700">★ request_shutdown · request_plan · review_plan</text>
<!-- Loop back -->
<path d="M 734 110 L 748 110 L 748 150 L 55 150 L 55 130" fill="none" stroke="#94a3b8" stroke-width="1" marker-end="url(#arrow)" stroke-dasharray="5,4"/>
<!-- Connector: preserved inner loop is wrapped by the s17 lifecycle -->
<!-- Connector: s16 extends the existing idle state with task discovery -->
<path d="M 326 134 L 326 160 L 170 160 L 170 210" fill="none" stroke="#059669" stroke-width="1.5" marker-end="url(#arrow-green)" stroke-dasharray="5,3"/>
<text x="248" y="156" fill="#059669" font-size="8" font-weight="600" text-anchor="middle">same inner LLM/tool loop inside WORK</text>
<text x="248" y="156" fill="#059669" font-size="8" font-weight="600" text-anchor="middle">idle teammates look for ready work</text>
<!-- ===== Row 2: Teammate Lifecycle (s17 new) ===== -->
<!-- ===== Row 2: Task discovery added to the s15 lifecycle ===== -->
<rect x="30" y="172" width="700" height="195" rx="8" fill="#ecfdf5" stroke="#059669" stroke-width="2"/>
<text x="380" y="194" fill="#065f46" font-size="11" font-weight="700" text-anchor="middle">Teammate Lifecycle (s17 new: WORK → IDLE → SHUTDOWN)</text>
<text x="380" y="194" fill="#065f46" font-size="11" font-weight="700" text-anchor="middle">s15 Lifecycle + s16 Task-Board Entry</text>
<!-- WORK box -->
<rect x="55" y="210" width="230" height="100" rx="6" fill="#fff" stroke="#059669" stroke-width="1.5"/>
<text x="170" y="230" fill="#059669" font-size="10" font-weight="700" text-anchor="middle">WORK Phase</text>
<text x="70" y="248" fill="#374151" font-size="8">inner loop: inbox → LLM → bash / read / write</text>
<text x="70" y="248" fill="#374151" font-size="8">runtime-delivered messages → LLM → tool calls</text>
<text x="70" y="262" fill="#374151" font-size="8">stop_reason == tool_use → loop</text>
<text x="70" y="276" fill="#374151" font-size="8">stop_reason != tool_use → IDLE</text>
<text x="70" y="298" fill="#6b7280" font-size="7">Max 10 rounds / interruptible by shutdown_request</text>
<text x="70" y="298" fill="#6b7280" font-size="7">send result, then enter IDLE</text>
<!-- Arrow: WORK → IDLE -->
<line x1="285" y1="260" x2="415" y2="260" stroke="#059669" stroke-width="1.5" marker-end="url(#arrow-green)"/>
@@ -82,11 +82,11 @@
<!-- IDLE box -->
<rect x="418" y="210" width="295" height="100" rx="6" fill="#fff" stroke="#059669" stroke-width="1.5"/>
<text x="565" y="230" fill="#059669" font-size="10" font-weight="700" text-anchor="middle">IDLE Phase (poll every 5s)</text>
<text x="433" y="248" fill="#374151" font-size="8">Check inbox → has message → back to WORK</text>
<text x="565" y="230" fill="#059669" font-size="10" font-weight="700" text-anchor="middle">IDLE Phase</text>
<text x="433" y="248" fill="#374151" font-size="8">Wait for runtime delivery → back to WORK</text>
<text x="433" y="264" fill="#374151" font-size="8">├ scan_unclaimed_tasks → claim → back to WORK</text>
<text x="433" y="280" fill="#374151" font-size="8">60s timeout → SHUTDOWN ↓</text>
<text x="433" y="298" fill="#6b7280" font-size="7">idle_poll() + claim_task()</text>
<text x="433" y="280" fill="#374151" font-size="8">No ready task → remain IDLE</text>
<text x="433" y="298" fill="#6b7280" font-size="7">wait_for_messages() + claim_next_task()</text>
<!-- SHUTDOWN box -->
<rect x="515" y="335" width="130" height="24" rx="6" fill="#fef2f2" stroke="#dc2626" stroke-width="1.5"/>
@@ -94,16 +94,16 @@
<!-- Arrow: IDLE → SHUTDOWN -->
<line x1="580" y1="310" x2="580" y2="335" stroke="#dc2626" stroke-width="1.5" marker-end="url(#arrow-red)"/>
<text x="598" y="326" fill="#dc2626" font-size="7">60s timeout</text>
<text x="598" y="326" fill="#dc2626" font-size="7">shutdown_request</text>
<!-- ===== Row 3: Bottom notes ===== -->
<rect x="30" y="388" width="700" height="42" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<rect x="50" y="400" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="70" y="410" fill="#475569" font-size="10">s16: MessageBus + protocols + request_shutdown + plan approval</text>
<text x="70" y="410" fill="#475569" font-size="10">s15: MessageBus + protocols + request_shutdown + plan approval</text>
<rect x="50" y="414" width="12" height="10" rx="2" fill="#ecfdf5" stroke="#059669" stroke-width="1"/>
<text x="70" y="424" fill="#475569" font-size="10">s17: idle_poll + scan_unclaimed_tasks + auto_claim + identity re-injection</text>
<text x="70" y="424" fill="#475569" font-size="10">s16: scan_unclaimed_tasks + claim_next_task + task_lock</text>
<!-- ===== Row 4: Autonomous note ===== -->
<rect x="30" y="442" width="700" height="22" rx="4" fill="#ecfdf5" stroke="#059669" stroke-width="1"/>
<text x="380" y="457" fill="#065f46" font-size="9" text-anchor="middle">Lead tools unchanged (14) · Teammate tools 5 → 8 (+3 task tools) · Teammates self-claim, Lead only creates tasks</text>
<text x="380" y="457" fill="#065f46" font-size="9" text-anchor="middle">Lead creates the task graph · teammates gain 3 task tools and atomically claim ready work</text>
</svg>

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

@@ -19,13 +19,13 @@
<!-- Title -->
<rect x="0" y="0" width="760" height="44" fill="url(#header)" rx="8"/>
<rect x="0" y="36" width="760" height="8" fill="url(#header)"/>
<text x="380" y="28" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">Autonomous Agents — アイドルポーリング + 自動認領 + WORK/IDLE ライフサイクル</text>
<text x="380" y="28" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">Autonomous Agents — アイドル時のタスク発見 + 原子的な認領</text>
<!-- Legend -->
<rect x="40" y="56" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="58" y="66" fill="#2563eb" font-size="10" font-weight="600">s16 保持</text>
<text x="58" y="66" fill="#2563eb" font-size="10" font-weight="600">s15 保持</text>
<rect x="130" y="56" width="12" height="10" rx="2" fill="#ecfdf5" stroke="#059669" stroke-width="1"/>
<text x="148" y="66" fill="#059669" font-size="10" font-weight="600">s17 新規</text>
<text x="148" y="66" fill="#059669" font-size="10" font-weight="600">s16 新規</text>
<!-- ===== Row 1: Lead Loop ===== -->
<rect x="20" y="90" width="70" height="40" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="1.5"/>
@@ -49,28 +49,28 @@
<line x1="361" y1="110" x2="375" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="378" y="80" width="356" height="60" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="556" y="98" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCHs16 全保持)</text>
<text x="394" y="114" fill="#2563eb" font-size="8">bash · read · write · task(4) · send · inbox</text>
<text x="556" y="98" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCHs15 全保持)</text>
<text x="394" y="114" fill="#2563eb" font-size="8">bash · read · write · task tools · send · protocols</text>
<text x="394" y="128" fill="#7c3aed" font-size="8" font-weight="700">★ request_shutdown · request_plan · review_plan</text>
<!-- Loop back -->
<path d="M 734 110 L 748 110 L 748 150 L 55 150 L 55 130" fill="none" stroke="#94a3b8" stroke-width="1" marker-end="url(#arrow)" stroke-dasharray="5,4"/>
<!-- Connector: preserved inner loop is wrapped by the s17 lifecycle -->
<!-- Connector: s16 extends the existing idle state with task discovery -->
<path d="M 326 134 L 326 160 L 170 160 L 170 210" fill="none" stroke="#059669" stroke-width="1.5" marker-end="url(#arrow-green)" stroke-dasharray="5,3"/>
<text x="248" y="156" fill="#059669" font-size="8" font-weight="600" text-anchor="middle">同じ内側 LLM/tool loop を WORK に入れる</text>
<text x="248" y="156" fill="#059669" font-size="8" font-weight="600" text-anchor="middle">待機中に実行可能な仕事を探す</text>
<!-- ===== Row 2: Teammate Lifecycle ===== -->
<!-- ===== Row 2: Task discovery added to the s15 lifecycle ===== -->
<rect x="30" y="172" width="700" height="195" rx="8" fill="#ecfdf5" stroke="#059669" stroke-width="2"/>
<text x="380" y="194" fill="#065f46" font-size="11" font-weight="700" text-anchor="middle">チームメイトライフサイクルs17 新規:WORK → IDLE → SHUTDOWN</text>
<text x="380" y="194" fill="#065f46" font-size="11" font-weight="700" text-anchor="middle">s15 ライフサイクル + s16 タスクボード入口</text>
<!-- WORK box -->
<rect x="55" y="210" width="230" height="100" rx="6" fill="#fff" stroke="#059669" stroke-width="1.5"/>
<text x="170" y="230" fill="#059669" font-size="10" font-weight="700" text-anchor="middle">WORK フェーズ</text>
<text x="70" y="248" fill="#374151" font-size="8">内側ループ:inbox → LLM → bash / read / write</text>
<text x="70" y="248" fill="#374151" font-size="8">ランタイム配信メッセージ → LLM → ツール呼び出し</text>
<text x="70" y="262" fill="#374151" font-size="8">stop_reason == tool_use → ループ</text>
<text x="70" y="276" fill="#374151" font-size="8">stop_reason != tool_use → IDLE</text>
<text x="70" y="298" fill="#6b7280" font-size="7">最大 10 ラウンド / shutdown_request で中断可能</text>
<text x="70" y="298" fill="#6b7280" font-size="7">result を送り、IDLE に入る</text>
<!-- Arrow: WORK → IDLE -->
<line x1="285" y1="260" x2="415" y2="260" stroke="#059669" stroke-width="1.5" marker-end="url(#arrow-green)"/>
@@ -82,11 +82,11 @@
<!-- IDLE box -->
<rect x="418" y="210" width="295" height="100" rx="6" fill="#fff" stroke="#059669" stroke-width="1.5"/>
<text x="565" y="230" fill="#059669" font-size="10" font-weight="700" text-anchor="middle">IDLE フェーズ5 秒ごとにポーリング)</text>
<text x="433" y="248" fill="#374151" font-size="8">inbox チェック → メッセージあり → WORK に戻る</text>
<text x="565" y="230" fill="#059669" font-size="10" font-weight="700" text-anchor="middle">IDLE フェーズ</text>
<text x="433" y="248" fill="#374151" font-size="8">ランタイム配信を待つ → WORK に戻る</text>
<text x="433" y="264" fill="#374151" font-size="8">├ scan_unclaimed_tasks → 認領 → WORK に戻る</text>
<text x="433" y="280" fill="#374151" font-size="8">60 秒タイムアウト → SHUTDOWN ↓</text>
<text x="433" y="298" fill="#6b7280" font-size="7">idle_poll() + claim_task()</text>
<text x="433" y="280" fill="#374151" font-size="8">実行可能なタスクなし → IDLE を維持</text>
<text x="433" y="298" fill="#6b7280" font-size="7">wait_for_messages() + claim_next_task()</text>
<!-- SHUTDOWN box -->
<rect x="515" y="335" width="130" height="24" rx="6" fill="#fef2f2" stroke="#dc2626" stroke-width="1.5"/>
@@ -94,16 +94,16 @@
<!-- Arrow: IDLE → SHUTDOWN -->
<line x1="580" y1="310" x2="580" y2="335" stroke="#dc2626" stroke-width="1.5" marker-end="url(#arrow-red)"/>
<text x="598" y="326" fill="#dc2626" font-size="7">60 秒タイムアウト</text>
<text x="598" y="326" fill="#dc2626" font-size="7">shutdown_request</text>
<!-- ===== Row 3: Bottom notes ===== -->
<rect x="30" y="388" width="700" height="42" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<rect x="50" y="400" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="70" y="410" fill="#475569" font-size="10">s16: MessageBus + protocols + request_shutdown + plan approval</text>
<text x="70" y="410" fill="#475569" font-size="10">s15: MessageBus + protocols + request_shutdown + plan approval</text>
<rect x="50" y="414" width="12" height="10" rx="2" fill="#ecfdf5" stroke="#059669" stroke-width="1"/>
<text x="70" y="424" fill="#475569" font-size="10">s17: idle_poll + scan_unclaimed_tasks + auto_claim + identity re-injection</text>
<text x="70" y="424" fill="#475569" font-size="10">s16: scan_unclaimed_tasks + claim_next_task + task_lock</text>
<!-- ===== Row 4 ===== -->
<rect x="30" y="442" width="700" height="22" rx="4" fill="#ecfdf5" stroke="#059669" stroke-width="1"/>
<text x="380" y="457" fill="#065f46" font-size="9" text-anchor="middle">Lead ツール不変(14) · チームメイトツール 5 → 8(+3 task tools · チームメイトが自己認領、Lead はタスク作成のみ</text>
<text x="380" y="457" fill="#065f46" font-size="9" text-anchor="middle">Lead が依存グラフを作成 · チームメイトは 3 つのタスクツールで仕事を原子的に認領</text>
</svg>

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

@@ -19,15 +19,15 @@
<!-- Title -->
<rect x="0" y="0" width="760" height="44" fill="url(#header)" rx="8"/>
<rect x="0" y="36" width="760" height="8" fill="url(#header)"/>
<text x="380" y="28" fill="#fff" font-size="15" font-weight="700" text-anchor="middle">Autonomous Agents — 空闲循环 + 自动认领 + WORK/IDLE 生命周期</text>
<text x="380" y="28" fill="#fff" font-size="15" font-weight="700" text-anchor="middle">Autonomous Agents — 空闲任务发现 + 原子认领</text>
<!-- Legend -->
<rect x="40" y="56" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="58" y="66" fill="#2563eb" font-size="10" font-weight="600">s16 保留</text>
<text x="58" y="66" fill="#2563eb" font-size="10" font-weight="600">s15 保留</text>
<rect x="140" y="56" width="12" height="10" rx="2" fill="#ecfdf5" stroke="#059669" stroke-width="1"/>
<text x="158" y="66" fill="#059669" font-size="10" font-weight="600">s17 新增</text>
<text x="158" y="66" fill="#059669" font-size="10" font-weight="600">s16 新增</text>
<!-- ===== Row 1: Lead Loop (s16 preserved) ===== -->
<!-- ===== Row 1: Lead Loop (s15 preserved) ===== -->
<rect x="20" y="90" width="70" height="40" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="1.5"/>
<text x="55" y="114" fill="#4f46e5" font-size="8" font-weight="600" text-anchor="middle">turn</text>
@@ -49,28 +49,28 @@
<line x1="361" y1="110" x2="375" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="378" y="80" width="356" height="60" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="556" y="98" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCH (s16 全保留)</text>
<text x="394" y="114" fill="#2563eb" font-size="8">bash · read · write · task(4) · send · inbox</text>
<text x="556" y="98" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCH (s15 全保留)</text>
<text x="394" y="114" fill="#2563eb" font-size="8">bash · read · write · task tools · send · protocols</text>
<text x="394" y="128" fill="#7c3aed" font-size="8" font-weight="700">★ request_shutdown · request_plan · review_plan</text>
<!-- Loop back -->
<path d="M 734 110 L 748 110 L 748 150 L 55 150 L 55 130" fill="none" stroke="#94a3b8" stroke-width="1" marker-end="url(#arrow)" stroke-dasharray="5,4"/>
<!-- Connector: preserved inner loop is wrapped by the s17 lifecycle -->
<!-- Connector: s16 extends the existing idle state with task discovery -->
<path d="M 326 134 L 326 160 L 170 160 L 170 210" fill="none" stroke="#059669" stroke-width="1.5" marker-end="url(#arrow-green)" stroke-dasharray="5,3"/>
<text x="248" y="156" fill="#059669" font-size="8" font-weight="600" text-anchor="middle">同一个内层 LLM/tool loop 放进 WORK</text>
<text x="248" y="156" fill="#059669" font-size="8" font-weight="600" text-anchor="middle">空闲时从任务板寻找可执行工作</text>
<!-- ===== Row 2: Teammate Lifecycle (s17 new) ===== -->
<!-- ===== Row 2: Task discovery added to the s15 lifecycle ===== -->
<rect x="30" y="172" width="700" height="195" rx="8" fill="#ecfdf5" stroke="#059669" stroke-width="2"/>
<text x="380" y="194" fill="#065f46" font-size="11" font-weight="700" text-anchor="middle">队友生命周期(s17 新增:WORK → IDLE → SHUTDOWN</text>
<text x="380" y="194" fill="#065f46" font-size="11" font-weight="700" text-anchor="middle">s15 生命周期 + s16 任务板入口</text>
<!-- WORK box -->
<rect x="55" y="210" width="230" height="100" rx="6" fill="#fff" stroke="#059669" stroke-width="1.5"/>
<text x="170" y="230" fill="#059669" font-size="10" font-weight="700" text-anchor="middle">WORK 阶段</text>
<text x="70" y="248" fill="#374151" font-size="8">内层循环:inbox → LLM → bash / read / write</text>
<text x="70" y="248" fill="#374151" font-size="8">消息自动进入上下文 → LLM → 工具调用</text>
<text x="70" y="262" fill="#374151" font-size="8">stop_reason == tool_use → loop</text>
<text x="70" y="276" fill="#374151" font-size="8">stop_reason != tool_use → IDLE</text>
<text x="70" y="298" fill="#6b7280" font-size="7">最多 10 轮 / 可被 shutdown_request 中断</text>
<text x="70" y="298" fill="#6b7280" font-size="7">完成后发送 result,再进入 IDLE</text>
<!-- Arrow: WORK → IDLE -->
<line x1="285" y1="260" x2="415" y2="260" stroke="#059669" stroke-width="1.5" marker-end="url(#arrow-green)"/>
@@ -82,11 +82,11 @@
<!-- IDLE box -->
<rect x="418" y="210" width="295" height="100" rx="6" fill="#fff" stroke="#059669" stroke-width="1.5"/>
<text x="565" y="230" fill="#059669" font-size="10" font-weight="700" text-anchor="middle">IDLE 阶段(每 5s 轮询)</text>
<text x="433" y="248" fill="#374151" font-size="8">检查 inbox → 有消息 → 回 WORK</text>
<text x="565" y="230" fill="#059669" font-size="10" font-weight="700" text-anchor="middle">IDLE 阶段</text>
<text x="433" y="248" fill="#374151" font-size="8">等待运行时投递消息 → 回 WORK</text>
<text x="433" y="264" fill="#374151" font-size="8">├ scan_unclaimed_tasks → 认领 → 回 WORK</text>
<text x="433" y="280" fill="#374151" font-size="8">60s 超时 → SHUTDOWN ↓</text>
<text x="433" y="298" fill="#6b7280" font-size="7">idle_poll() + claim_task()</text>
<text x="433" y="280" fill="#374151" font-size="8">没有就绪任务 → 保持 IDLE</text>
<text x="433" y="298" fill="#6b7280" font-size="7">wait_for_messages() + claim_next_task()</text>
<!-- SHUTDOWN box -->
<rect x="515" y="335" width="130" height="24" rx="6" fill="#fef2f2" stroke="#dc2626" stroke-width="1.5"/>
@@ -94,16 +94,16 @@
<!-- Arrow: IDLE → SHUTDOWN -->
<line x1="580" y1="310" x2="580" y2="335" stroke="#dc2626" stroke-width="1.5" marker-end="url(#arrow-red)"/>
<text x="598" y="326" fill="#dc2626" font-size="7">60s 超时</text>
<text x="598" y="326" fill="#dc2626" font-size="7">shutdown_request</text>
<!-- ===== Row 3: Bottom notes ===== -->
<rect x="30" y="388" width="700" height="42" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<rect x="50" y="400" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="70" y="410" fill="#475569" font-size="10">s16: MessageBus + protocols + request_shutdown + plan approval</text>
<text x="70" y="410" fill="#475569" font-size="10">s15: MessageBus + protocols + request_shutdown + plan approval</text>
<rect x="50" y="414" width="12" height="10" rx="2" fill="#ecfdf5" stroke="#059669" stroke-width="1"/>
<text x="70" y="424" fill="#475569" font-size="10">s17: idle_poll + scan_unclaimed_tasks + auto_claim + identity re-injection</text>
<text x="70" y="424" fill="#475569" font-size="10">s16: scan_unclaimed_tasks + claim_next_task + task_lock</text>
<!-- ===== Row 4: Autonomous note ===== -->
<rect x="30" y="442" width="700" height="22" rx="4" fill="#ecfdf5" stroke="#059669" stroke-width="1"/>
<text x="380" y="457" fill="#065f46" font-size="9" text-anchor="middle">Lead 工具不变(14) · 队友工具 5 → 8(+3 task tools · 队友自主认领,Lead 只创建任务</text>
<text x="380" y="457" fill="#065f46" font-size="9" text-anchor="middle">Lead 创建任务依赖图 · 队友获得 3 个任务工具并原子认领就绪工作</text>
</svg>

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

-243
View File
@@ -1,243 +0,0 @@
# s16: Agent Teams — プロトコル実験:型付き協調
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s14 → s15 → `s16` → [s17](../s17_autonomous_agents/) → s18 → s19 → s20 → s21 → s22
> *"チームメイト間には取り決めが必要"* — request-response パターンが全てのネゴシエーションを駆動。
>
> **Harness 層**: プロトコル — Agent 間の構造化ハンドシェイク。
> **モジュール 2/2** s15 でチームメイトランタイムとメールボックスを構築した。この章で変えるのは協調契約だけであり、リクエスト、返信、相関 ID、状態遷移を扱う。
---
## 課題
s15 のチームメイトは仕事ができるが、連携は緩い:Lead がメッセージを送り、チームメイトが返信するだけで、構造化されたプロトコルがない。2 つのシナリオで問題が露呈する:
**シャットダウン**:Lead が Alice にシャットダウンを頼む。スレッドを強制終了すると、書きかけのファイルがディスクに残る。ハンドシェイクが必要:Lead がリクエストを送信、Alice が收尾後に確認。
**計画承認**:Bob が認証モジュールのリファクタリングを提案、高リスク操作。Lead が Bob の計画を確認し、承認後に実行すべき。
これら 2 つのシナリオは同じ構造:一方がリクエストを送信、もう一方が返信、両者は同じ ID で関連付けられる。状態機械が追跡:pending → approved / rejected。
---
## ソリューション
![Team Protocols Overview](images/team-protocols-overview.ja.svg)
教学版は前章までの Agent 能力の流れを受け継ぎ、S15 のチーム通信の上に構造化プロトコルを追加する。プロトコル機構に集中するため、完全なエラーリカバリ、メモリ、スキルシステムは省略。追加:**ProtocolState**(リクエスト状態追跡)、**dispatch_message**(メッセージタイプ別ルーティング)、**match_response**request_id でリクエストとレスポンスを関連付け、型検証付き)。
2 つのプロトコル、1 つの仕組み:
| プロトコル | 方向 | 用途 |
|-----------|------|------|
| shutdown_request / response | Lead → チームメイト | 丁寧なシャットダウンハンドシェイク |
| plan_approval_request / response | チームメイト → Lead | 計画承認プロトコルの例 |
> 教学版は計画承認の request-response メッセージフローをデモするが、実行ゲーティング(未承認時の bash/write_file 拦截)は未実装。真实 CC にはチームメイト向けの permission gating 機構がある。
---
## 仕組み
### ProtocolState: リクエスト状態
各プロトコルリクエストは、送信者、受信者、現在の状態、ペイロードを記録する状態レコードを作成:
```python
@dataclass
class ProtocolState:
request_id: str # 一意 ID、例 "req_004281"
type: str # "shutdown" | "plan_approval"
sender: str # 送信者
target: str # 受信者
status: str # pending | approved | rejected
payload: str # 計画テキストまたはシャットダウン理由
created_at: float # タイムスタンプ
pending_requests: dict[str, ProtocolState] = {}
```
リクエスト送信時にレコードを作成、レスポンス受信時に `request_id` で該当レコードを見つけて状態を更新。
### 4 ステッププロトコルフロー
シャットダウンを例にした完全な流れ:
```
1. Lead がリクエスト送信
req_id = new_request_id() # "req_004281"
pending_requests[req_id] = ProtocolState(type="shutdown", status="pending", ...)
BUS.send("lead", "alice", "shutdown_request", metadata={"request_id": req_id})
2. チームメイト受信 → dispatch
inbox = BUS.read_inbox("alice")
msg_type = msg["type"] # "shutdown_request"
→ handle_shutdown_request() にルーティング
3. チームメイト返信
BUS.send("alice", "lead", "shutdown_response",
metadata={"request_id": req_id, "approve": True})
4. Lead がレスポンス受信 → match
match_response("shutdown_response", req_id, approve=True)
pending_requests[req_id].status = "approved"
```
`request_id` はチェーン全体を貫く関連キー、リクエストが持ち出し、レスポンスが持ち帰る。
### dispatch_message: タイプ別ルーティング
チームメイトの inbox は通常メッセージとプロトコルメッセージの両方を受信。`handle_inbox_message` がメッセージタイプで振り分け:
```python
def handle_inbox_message(name, msg, messages):
msg_type = msg.get("type", "message")
req_id = msg.get("metadata", {}).get("request_id", "")
if msg_type == "shutdown_request":
BUS.send(name, "lead", "Shutting down.", "shutdown_response",
{"request_id": req_id, "approve": True})
return True # ループ停止
if msg_type == "plan_approval_response":
approve = msg["metadata"].get("approve", False)
messages.append({"role": "user",
"content": "[Plan approved]" if approve else "[Plan rejected]"})
return False # 継続
```
新しいプロトコルタイプの追加は新しい `if` 分岐を追加するだけ。
### match_response: 型検証
`match_response``request_id` で状態を見つけるだけでなく、レスポンスタイプがリクエストタイプと一致するか検証:
```python
def match_response(response_type, request_id, approve):
state = pending_requests.get(request_id)
if not state:
return
if state.type == "shutdown" and response_type != "shutdown_response":
return # タイプ不一致、スキップ
if state.type == "plan_approval" and response_type != "plan_approval_response":
return
if state.status != "pending":
return # 既に解決済み、重複をスキップ
state.status = "approved" if approve else "rejected"
```
shutdown_response が誤って plan_approval リクエストを承認することはない。
### 統一 inbox コンシューマ:consume_lead_inbox
`check_inbox` ツールとメインループ末尾の両方が同じ `consume_lead_inbox()` 関数を呼び出す。プロトコルメッセージを先にルーティングしてから残りの内容を返す。メッセージが消費されてもプロトコル状態が更新されない問題を防ぐ:
```python
def consume_lead_inbox(route_protocol=True) -> list[dict]:
msgs = BUS.read_inbox("lead")
if route_protocol:
for msg in msgs:
meta = msg.get("metadata", {})
req_id = meta.get("request_id", "")
msg_type = msg.get("type", "")
if req_id and msg_type.endswith("_response"):
match_response(msg_type, req_id, meta.get("approve", False))
return msgs
```
メインループは inbox メッセージを `history` に注入し、LLM が確認して反応できるようにする。
### チームメイト idle loop:終了ではなく待機
s15 のチームメイトは 10 ラウンドで終了。s16 のチームメイトは LLM が非 tool_use を返した後 idle 待機に入る:inbox をポーリング、shutdown_request に応答して終了、または新メッセージで作業継続。
```
LLM が非 tool_use を返す
→ idle: 毎秒 inbox をポーリング
→ shutdown_request 受信 → shutdown_response 返信 → 終了
→ 新メッセージ受信 → messages に注入 → LLM ターン継続
```
教学版は Lead への idle_notification を省略。真实 CC は idle 時に `idle_notification` を送信、Lead はチームメイトが空いていることを知り、新しいタスクを割り当て可能。
### 組み合わせて実行
```
1. Lead: "Alice にファイルを作成させ、その後シャットダウン"
2. Lead → spawn_teammate("alice", "backend", "config.py を作成")
3. alice スレッド起動 → write_file("config.py", "...") → 完了 → idle
4. Lead → request_shutdown("alice")
→ BUS.send("shutdown_request", {request_id: "req_000142"})
5. alice idle ポーリング受信 → handle_shutdown_request
→ BUS.send("shutdown_response", {request_id: "req_000142", approve: True})
6. Lead consume_lead_inbox → match_response("req_000142", approve=True)
→ pending_requests["req_000142"].status = "approved"
→ inbox メッセージが history に注入、LLM がシャットダウン結果を確認
```
シャットダウンハンドシェイク完了:リクエスト → 確認 → シャットダウン。各ステップは `request_id` で追跡。
---
## s15 からの変更
| コンポーネント | 変更前 (s15) | 変更後 (s16) |
|--------------|------------|------------|
| 連携方法 | 緩いテキストメッセージ | 構造化 request-response プロトコル |
| リクエスト追跡 | なし | ProtocolState + pending_requests dict |
| メッセージルーティング | 全てテキストとして処理 | dispatch_message がタイプ別にルーティング |
| シャットダウン | 自然終了またはスレッド強制終了 | request_id ハンドシェイク機構 |
| 計画承認 | なし | メッセージフローの例(実行ゲーティングなし) |
| 新規メッセージ型 | message, result | + shutdown_request/response, plan_approval_request/response |
| チームメイトライフサイクル | 最大 10 ラウンド | idle loopinbox メッセージを待機) |
| Lead inbox | check_inbox とメインループが別々に読み取り | 統一 consume_lead_inbox |
| Lead ツール | 14 (s15) | 14(コアツールセットに request_shutdown、request_plan、review_plan を追加) |
| チームメイトツール | 4 (s15) | + submit_plan (5) |
---
## 試してみる
```sh
cd learn-claude-code
python s16_team_protocols/code.py
```
以下のプロンプトを試してください:
1. `Spawn alice as a backend dev. Ask her to create a file. Then request her shutdown.`
2. `Spawn bob with a refactoring task. Have him submit a plan first. Then review and approve it.`
観察ポイント:シャットダウンハンドシェイクは完了しているか(リクエスト → 確認 → シャットダウン)?`pending_requests` の状態は正しく遷移しているか?`request_id` はリクエストとレスポンス間で一貫しているか?idle チームメイトは shutdown_request を受信できるか?
---
## 次の章
s15-s16 では、Lead が各チームメイトにタスクを割り当てる必要がある。"Alice はこれ、Bob はあれ"。ボードに 10 個の未認領タスクがあれば、Lead が手動で assign しなければならない。
チームメイトが自分でボードを見て認領できたらどうか?Lead はタスクを作成するだけで、チームメイトが自分で発見、認領、完了する。
s17 Autonomous Agents → チームメイトの自己組織化、リーダーの割り当て不要。
<details>
<summary>CC ソースコード深掘り</summary>
CC のチームプロトコル実装(`teammateMailbox.ts`、1184 行)は教学版と同じコア構造:request_id + approve/reject の request-response パターン。違いは以下の通り:
**シャットダウンプロトコル**:CC のシャットダウンは三方向通信(`teammateMailbox.ts:720-763``SendMessageTool.ts:268-430`)。Lead が `shutdown_request` を送信、チームメイトが `shutdown_approved`(または理由付き `shutdown_rejected`)で返信、システムが `teammate_terminated` で全関係者に通知。確認後、システムが自動的に pane(tmux/iTerm2)をクリーンアップ、タスクを unassign、team config からメンバーを削除(`useInboxPoller.ts:677-800`)。教学版は `shutdown_response` で統一命名、真实源码は `shutdown_approved``shutdown_rejected` の 2 つの独立したメッセージ型に分割。
**計画承認**:真实源码では plan approval request は `ExitPlanModeV2Tool.ts:263-312` で plan-mode-required チームメイトが plan mode を終了する際に生成される。`useInboxPoller.ts:599-661` は現在自動的に approval を書き戻し、リクエストを Lead にコンテキスト(regular message)として渡す。`SendMessageTool.ts:434-518` は明示的な approve/reject response 能力を保持、承認時に同時に `permissionMode` を設定可能(例:"承認するが plan mode で実行")、レスポンスにはチームメイトが修正して再提出するための `feedback` 文字列を含めることができる。単純な「Lead が手動で review_plan ツールを使う」フローではない。
**メッセージ形式**:CC のプロトコルメッセージは構造化 JSON(Zod schema 検証付き)、教学版はシンプルな type + metadata dict。フィールド名も統一されていない:permission は `request_id``teammateMailbox.ts:453-462`)、shutdown と plan approval は `requestId``teammateMailbox.ts:684-763`)。
**実行ゲーティング**:CC のチームメイトには完全な permission gating がある。未承認の高リスク操作は拦截され、オプションではない。教学版はメッセージフローのみをデモ。
**汎用性**:教学版の 1 つの FSMpending → approved | rejected)が 2 つのプロトコルに対応する簡略化は正しい。CC の全プロトコルメッセージは同じ request id 関連機構を共有。
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
-243
View File
@@ -1,243 +0,0 @@
# s16: Agent Teams — Protocol Lab: Typed Coordination
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s14 → s15 → `s16` → [s17](../s17_autonomous_agents/) → s18 → s19 → s20 → s21 → s22
> *"Teammates need agreements"* — request-response pattern drives all negotiation.
>
> **Harness Layer**: Protocols — Structured handshakes between agents.
> **Module 2 of 2:** s15 built the teammate runtime and mailbox. This lab changes only the coordination contract: requests, replies, correlation IDs, and state transitions.
---
## The Problem
s15's teammates can work, but coordination is loose: Lead sends a message, teammate replies, no structured protocol. Two scenarios expose the gap:
**Shutdown**: Lead wants Alice to shut down. Killing the thread outright leaves half-written files on disk. A handshake is needed: Lead sends a request, Alice confirms after wrapping up.
**Plan approval**: Bob wants to refactor the auth module, a high-risk operation. Lead should review Bob's plan first, approve before Bob proceeds.
Both scenarios share the same structure: one side sends a request, the other replies, both linked by the same ID. A state machine tracks: pending → approved / rejected.
---
## The Solution
![Team Protocols Overview](images/team-protocols-overview.en.svg)
Teaching code continues the agent capability arc from earlier chapters and adds structured protocols on top of S15's team communication. To stay focused on the protocol mechanism, it omits full error recovery, memory, and skill systems. Added: **ProtocolState** (request state tracking), **dispatch_message** (routes incoming messages by type to handlers), **match_response** (correlates response to request via request_id, with type validation).
Two protocols, one mechanism:
| Protocol | Direction | Purpose |
|----------|-----------|---------|
| shutdown_request / response | Lead → Teammate | Graceful shutdown handshake |
| plan_approval_request / response | Teammate → Lead | Plan approval protocol example |
> Teaching version demonstrates the request-response message flow for plan approval, but does not implement execution gating (intercepting bash/write_file when not approved). Real CC has a permission gating mechanism for teammates.
---
## How It Works
### ProtocolState: Request State
Each protocol request creates a state record tracking who sent it, to whom, current status, and payload:
```python
@dataclass
class ProtocolState:
request_id: str # Unique ID, e.g. "req_004281"
type: str # "shutdown" | "plan_approval"
sender: str # Sender
target: str # Recipient
status: str # pending | approved | rejected
payload: str # Plan text or shutdown reason
created_at: float # Timestamp
pending_requests: dict[str, ProtocolState] = {}
```
A record is created when sending a request, found via `request_id` when receiving a response, and its status updated.
### Four-Step Protocol Flow
Using shutdown as an example, the full chain:
```
1. Lead sends request
req_id = new_request_id() # "req_004281"
pending_requests[req_id] = ProtocolState(type="shutdown", status="pending", ...)
BUS.send("lead", "alice", "shutdown_request", metadata={"request_id": req_id})
2. Teammate receives → dispatch
inbox = BUS.read_inbox("alice")
msg_type = msg["type"] # "shutdown_request"
→ routed to handle_shutdown_request()
3. Teammate replies
BUS.send("alice", "lead", "shutdown_response",
metadata={"request_id": req_id, "approve": True})
4. Lead receives response → match
match_response("shutdown_response", req_id, approve=True)
pending_requests[req_id].status = "approved"
```
`request_id` is the correlation key across the entire chain: the request carries it out, the response carries it back.
### dispatch_message: Route by Type
A teammate's inbox receives both plain messages and protocol messages. `handle_inbox_message` dispatches by message type:
```python
def handle_inbox_message(name, msg, messages):
msg_type = msg.get("type", "message")
req_id = msg.get("metadata", {}).get("request_id", "")
if msg_type == "shutdown_request":
BUS.send(name, "lead", "Shutting down.", "shutdown_response",
{"request_id": req_id, "approve": True})
return True # Stop the loop
if msg_type == "plan_approval_response":
approve = msg["metadata"].get("approve", False)
messages.append({"role": "user",
"content": "[Plan approved]" if approve else "[Plan rejected]"})
return False # Continue
```
Adding a new protocol type means adding a new `if` branch.
### match_response: Type Validation
`match_response` doesn't just find state by `request_id`, it also validates that the response type matches the request type:
```python
def match_response(response_type, request_id, approve):
state = pending_requests.get(request_id)
if not state:
return
if state.type == "shutdown" and response_type != "shutdown_response":
return # type mismatch, skip
if state.type == "plan_approval" and response_type != "plan_approval_response":
return
if state.status != "pending":
return # already resolved, skip duplicate
state.status = "approved" if approve else "rejected"
```
A shutdown_response cannot accidentally approve a plan_approval request.
### Unified Inbox Consumer: consume_lead_inbox
Both the `check_inbox` tool and the main loop call the same `consume_lead_inbox()` function, routing protocol messages before returning remaining content. This prevents messages from being consumed without protocol state updates:
```python
def consume_lead_inbox(route_protocol=True) -> list[dict]:
msgs = BUS.read_inbox("lead")
if route_protocol:
for msg in msgs:
meta = msg.get("metadata", {})
req_id = meta.get("request_id", "")
msg_type = msg.get("type", "")
if req_id and msg_type.endswith("_response"):
match_response(msg_type, req_id, meta.get("approve", False))
return msgs
```
The main loop also injects inbox messages into `history` so the LLM can see and react to them.
### Teammate Idle Loop: Wait Instead of Exit
s15's teammates exit after 10 rounds. s16's teammates enter idle waiting after the LLM returns a non-tool_use response: poll inbox, respond to shutdown_request and exit, or continue working on new messages.
```
LLM returns non-tool_use
→ idle: poll inbox every second
→ receives shutdown_request → reply shutdown_response → exit
→ receives new message → inject into messages → continue LLM turn
```
Teaching version omits idle_notification to Lead. Real CC sends `idle_notification` when idle, so Lead knows the teammate is free for new tasks.
### Putting It Together
```
1. Lead: "Have Alice create a file, then shut her down"
2. Lead → spawn_teammate("alice", "backend", "Create config.py")
3. alice thread starts → write_file("config.py", "...") → done → idle
4. Lead → request_shutdown("alice")
→ BUS.send("shutdown_request", {request_id: "req_000142"})
5. alice idle poll receives → handle_shutdown_request
→ BUS.send("shutdown_response", {request_id: "req_000142", approve: True})
6. Lead consume_lead_inbox → match_response("req_000142", approve=True)
→ pending_requests["req_000142"].status = "approved"
→ inbox message injected into history, LLM sees shutdown result
```
Shutdown handshake complete: request → confirm → shutdown. Every step tracked by `request_id`.
---
## Changes from s15
| Component | Before (s15) | After (s16) |
|-----------|-------------|-------------|
| Coordination | Loose text messages | Structured request-response protocol |
| Request tracking | None | ProtocolState + pending_requests dict |
| Message routing | All treated as text | dispatch_message routes by type |
| Shutdown | Natural exit or kill thread | request_id handshake mechanism |
| Plan approval | None | Message flow example (no execution gating) |
| New message types | message, result | + shutdown_request/response, plan_approval_request/response |
| Teammate lifecycle | Max 10 rounds | Idle loop (waits for inbox messages) |
| Lead inbox | check_inbox and main loop read separately | Unified consume_lead_inbox |
| Lead tools | 14 (s15) | 14 (core tool set plus request_shutdown, request_plan, review_plan) |
| Teammate tools | 4 (s15) | + submit_plan (5) |
---
## Try It
```sh
cd learn-claude-code
python s16_team_protocols/code.py
```
Try these prompts:
1. `Spawn alice as a backend dev. Ask her to create a file. Then request her shutdown.`
2. `Spawn bob with a refactoring task. Have him submit a plan first. Then review and approve it.`
What to observe: Is the shutdown handshake complete (request → confirm → shutdown)? Does `pending_requests` state transition correctly? Is `request_id` consistent between request and response? Can the idle teammate receive shutdown_request?
---
## What's Next
In s15-s16, Lead must assign tasks to each teammate. "Alice does this, Bob does that." With 10 unclaimed tasks on the board, Lead has to manually assign each one.
What if teammates could check the board and claim tasks themselves? Lead only needs to create tasks; teammates discover, claim, and complete them on their own.
s17 Autonomous Agents → Self-organizing teammates, no leader assignment needed.
<details>
<summary>Deep Dive into CC Source</summary>
CC's team protocol implementation (`teammateMailbox.ts`, 1184 lines) shares the same core structure as the teaching version: request_id + approve/reject request-response pattern. Differences:
**Shutdown protocol**: CC's shutdown is three-way communication (`teammateMailbox.ts:720-763`, `SendMessageTool.ts:268-430`). Lead sends `shutdown_request`, teammate replies `shutdown_approved` (or `shutdown_rejected` with reason), system sends `teammate_terminated` to notify all parties. After confirmation, system cleans up pane (tmux/iTerm2), unassigns tasks, removes member from team config (`useInboxPoller.ts:677-800`). Teaching version uses `shutdown_response` as a unified name; real source splits into `shutdown_approved` and `shutdown_rejected` as two separate message types.
**Plan approval**: In the real source, plan approval request is generated by `ExitPlanModeV2Tool.ts:263-312` when a plan-mode-required teammate exits plan mode. `useInboxPoller.ts:599-661` currently auto-writes approval and passes the request to Lead as context (regular message). `SendMessageTool.ts:434-518` retains explicit approve/reject response capability — approval can simultaneously set `permissionMode` (e.g. "approved but run in plan mode"), response can include `feedback` string for teammate to revise and resubmit. Not a simple "Lead manually uses review_plan tool" flow.
**Message format**: CC's protocol messages are structured JSON (with Zod schema validation), teaching version uses simple type + metadata dict. Field names are also inconsistent: permission uses `request_id` (`teammateMailbox.ts:453-462`), shutdown and plan approval use `requestId` (`teammateMailbox.ts:684-763`).
**Execution gating**: CC's teammates have full permission gating. Unapproved high-risk operations are intercepted, not optional. Teaching version only demonstrates the message flow without execution interception.
**Generality**: Teaching version's single FSM (pending → approved | rejected) maps to two protocols. This simplification is correct. CC's protocol messages all share the same request id correlation mechanism.
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
-245
View File
@@ -1,245 +0,0 @@
# s16: Agent Teams — 协议实验:带类型的协作
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s14 → s15 → `s16` → [s17](../s17_autonomous_agents/) → s18 → s19 → s20 → s21 → s22
> *"队友之间要有约定"* — request-response 模式驱动协商。
>
> **Harness 层**: 协议 — Agent 之间的结构化握手。
> **模块 2/2** s15 已经搭好队友运行时与邮箱。本章只改变协作契约:请求、回复、关联 ID 和状态迁移。
---
## 问题
s15 的队友能干活了,但协调是松散的:Lead 发消息,队友回复,没有结构化的协议。两个场景暴露了问题:
**关机**Lead 想让 Alice 关机。直接杀线程,Alice 写了一半的文件留在磁盘上。需要握手:Lead 发请求,Alice 确认收尾后关机。
**计划审批**:Bob 想重构认证模块,属于高风险操作。应该先让 Lead 看 Bob 的计划,审批通过后再动手。
这两个场景结构完全一样:一方发请求,另一方给回复,请求和回复通过同一个 ID 关联。有状态机追踪:pending → approved / rejected。
---
## 解决方案
![Team Protocols Overview](images/team-protocols-overview.svg)
教学代码承接前面章节的 Agent 能力脉络,在 S15 团队通信基础上加入结构化协议。为了聚焦协议机制,省略了完整错误恢复、记忆和技能系统。新增三样:**ProtocolState**(请求状态追踪)、**dispatch_message**(按消息类型路由到处理器)、**match_response**(通过 request_id 关联回复与请求,含类型校验)。
两种协议,一套机制:
| 协议 | 方向 | 用途 |
|------|------|------|
| shutdown_request / response | Lead → 队友 | 体面关机握手 |
| plan_approval_request / response | 队友 → Lead | 计划审批协议示例 |
> 教学版演示了计划审批的请求-响应消息流程,没有实现执行门控(未 approved 时拦截 bash/write_file)。真实 CC 的队友有 permission gating 机制。
---
## 工作原理
### ProtocolState: 请求状态
每个协议请求创建一条状态记录,记录谁发的、发给谁、当前状态、附带内容:
```python
@dataclass
class ProtocolState:
request_id: str # 唯一 ID,如 "req_004281"
type: str # "shutdown" | "plan_approval"
sender: str # 发起方
target: str # 接收方
status: str # pending | approved | rejected
payload: str # 计划文本或关机原因
created_at: float # 时间戳
pending_requests: dict[str, ProtocolState] = {}
```
发请求时创建记录,收回复时通过 `request_id` 找到对应记录,更新状态。
### 四步协议流程
以关机为例,完整链路:
```
① Lead 发请求
req_id = new_request_id() # "req_004281"
pending_requests[req_id] = ProtocolState(type="shutdown", status="pending", ...)
BUS.send("lead", "alice", "shutdown_request", metadata={"request_id": req_id})
② 队友收到 → dispatch
inbox = BUS.read_inbox("alice")
msg_type = msg["type"] # "shutdown_request"
→ 路由到 handle_shutdown_request()
③ 队友回复
BUS.send("alice", "lead", "shutdown_response",
metadata={"request_id": req_id, "approve": True})
④ Lead 收响应 → match
match_response("shutdown_response", req_id, approve=True)
pending_requests[req_id].status = "approved"
```
`request_id` 是贯穿全链路的关联键,请求带着它出去,回复带着它回来。
> 教学版用 `shutdown_response` 统一命名(approve 字段区分同意/拒绝)。真实源码拆成 `shutdown_approved``shutdown_rejected` 两种独立消息类型(`teammateMailbox.ts:720-763`)。
### dispatch_message: 按类型路由
队友的 inbox 不只收普通消息,还收协议消息。`handle_inbox_message` 按消息类型分发:
```python
def handle_inbox_message(name, msg, messages):
msg_type = msg.get("type", "message")
req_id = msg.get("metadata", {}).get("request_id", "")
if msg_type == "shutdown_request":
BUS.send(name, "lead", "Shutting down.", "shutdown_response",
{"request_id": req_id, "approve": True})
return True # 停止循环
if msg_type == "plan_approval_response":
approve = msg["metadata"].get("approve", False)
messages.append({"role": "user",
"content": "[Plan approved]" if approve else "[Plan rejected]"})
return False # 继续循环
```
新增协议类型只需加新的 `if` 分支。
### match_response: 类型校验
`match_response` 不只按 `request_id` 找状态,还会校验响应类型是否匹配请求类型:
```python
def match_response(response_type, request_id, approve):
state = pending_requests.get(request_id)
if not state:
return
if state.type == "shutdown" and response_type != "shutdown_response":
return # type mismatch, skip
if state.type == "plan_approval" and response_type != "plan_approval_response":
return
if state.status != "pending":
return # already resolved, skip duplicate
state.status = "approved" if approve else "rejected"
```
一个 shutdown_response 不会意外 approve 一个 plan_approval 请求。
### 统一 inbox 消费:consume_lead_inbox
`check_inbox` 工具和主循环末尾都调用同一个 `consume_lead_inbox()` 函数,先路由协议消息再返回剩余内容,避免消息被读走但协议状态没更新:
```python
def consume_lead_inbox(route_protocol=True) -> list[dict]:
msgs = BUS.read_inbox("lead")
if route_protocol:
for msg in msgs:
meta = msg.get("metadata", {})
req_id = meta.get("request_id", "")
msg_type = msg.get("type", "")
if req_id and msg_type.endswith("_response"):
match_response(msg_type, req_id, meta.get("approve", False))
return msgs
```
主循环末尾还会把 inbox 消息注入到 `history`,让 LLM 能看到并做出反应。
### 队友 idle loop:等待而不是退出
s15 的队友跑完 10 轮就退出。s16 的队友在 LLM 返回非 tool_use 后进入 idle 等待:轮询 inbox,收到 shutdown_request 就响应退出,收到新消息就继续工作。
```
LLM 返回非 tool_use
→ idle: 每秒轮询 inbox
→ 收到 shutdown_request → 回复 shutdown_response → 退出
→ 收到新消息 → 注入 messages → 继续 LLM turn
```
教学版省略了 idle_notification 给 Lead 的通知。真实 CC 在 idle 时发 `idle_notification`,Lead 收到后知道队友空闲,可以分配新任务。
### 合起来跑
```
1. Lead: "让 Alice 创建一个文件,然后关机"
2. Lead → spawn_teammate("alice", "backend", "创建 config.py")
3. alice 线程启动 → write_file("config.py", "...") → 完成 → idle
4. Lead → request_shutdown("alice")
→ BUS.send("shutdown_request", {request_id: "req_000142"})
5. alice idle 轮询收到 → handle_shutdown_request
→ BUS.send("shutdown_response", {request_id: "req_000142", approve: True})
6. Lead consume_lead_inbox → match_response("req_000142", approve=True)
→ pending_requests["req_000142"].status = "approved"
→ inbox 消息注入 historyLLM 看到关机结果
```
关机握手完整:请求 → 确认 → 关机。每一步有 `request_id` 追溯。
---
## 相对 s15 的变更
| 组件 | 之前 (s15) | 之后 (s16) |
|------|-----------|-----------|
| 协调方式 | 松散文本消息 | 结构化请求-响应协议 |
| 请求追踪 | 无 | ProtocolState + pending_requests dict |
| 消息路由 | 全部当文本处理 | dispatch_message 按类型分发 |
| 关机 | 自然退出或杀线程 | request_id 握手机制 |
| 计划审批 | 无 | 消息流程示例(未实现执行门控) |
| 新消息类型 | message, result | + shutdown_request/response, plan_approval_request/response |
| 队友生命周期 | 最多 10 轮 | idle loop(等待 inbox 消息) |
| Lead inbox | check_inbox 和主循环分别读 | 统一 consume_lead_inbox |
| Lead 工具 | 14 (s15) | 14(核心工具集加入 request_shutdown, request_plan, review_plan |
| 队友工具 | 4 (s15) | + submit_plan (5) |
---
## 试一下
```sh
cd learn-claude-code
python s16_team_protocols/code.py
```
试试这些 prompt
1. `Spawn alice as a backend dev. Ask her to create a file. Then request her shutdown.`
2. `Spawn bob with a refactoring task. Have him submit a plan first. Then review and approve it.`
观察重点:关机握手是否完整(请求 → 确认 → 关机)?`pending_requests` 的状态是否正确转换?`request_id` 是否在请求和响应之间保持一致?队友 idle 后是否能收到 shutdown_request
---
## 接下来
s15-s16 中,Lead 必须给每个队友分配任务。"Alice 做这个,Bob 做那个"。任务看板上有 10 个未认领的任务,Lead 得手动 assign。
能不能让队友自己看板、自己认领?Lead 只需要创建任务,队友自己发现、自己认领、自己完成。
s17 Autonomous Agents → 队友自组织,不需要领导分配。
<details>
<summary>深入 CC 源码</summary>
CC 的团队协议实现(`teammateMailbox.ts`,1184 行)和教学版在核心结构上一致:request_id + approve/reject 的请求-响应模式。差异在于:
**关机协议**CC 的 shutdown 是三向通信(`teammateMailbox.ts:720-763``SendMessageTool.ts:268-430`)。Lead 发 `shutdown_request`,队友回复 `shutdown_approved`(或 `shutdown_rejected` 附原因),系统发送 `teammate_terminated` 通知所有相关方。关机确认后系统自动清理 panetmux/iTerm2)、unassign 任务、从 team config 移除成员(`useInboxPoller.ts:677-800`)。教学版用 `shutdown_response` 统一命名,真实源码拆成 approved/rejected 两种独立消息。
**计划审批**:真实源码里 plan approval request 由 `ExitPlanModeV2Tool.ts:263-312` 在 plan-mode-required 队友退出 plan mode 时产生。`useInboxPoller.ts:599-661` 当前会自动回写 approval,并把请求交给 Lead 作为上下文(regular message)。`SendMessageTool.ts:434-518` 仍保留显式 approve/reject response 能力,审批时可同时设置 `permissionMode`(如"批准但以 plan mode 运行"),响应中可包含 `feedback` 字符串供队友修正后重新提交。不是简单的"Lead 手动 review_plan 工具"流程。
**消息格式**:CC 的协议消息是结构化的 JSON(有 Zod schema 验证),教学版用简单的 type + metadata 字典。字段名也不统一:permission 用 `request_id``teammateMailbox.ts:453-462`),shutdown 和 plan approval 用 `requestId``teammateMailbox.ts:684-763`)。
**执行门控**CC 的队友有完整的 permission gating。未获批准的高风险操作会被拦截,不是可选的。教学版只演示了消息流程,没有实现执行拦截。
**通用性**:教学版的一个 FSMpending → approved | rejected)对应两种协议,这个简化完全正确。CC 的所有协议消息共用同一个 request id 关联机制。
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
-882
View File
@@ -1,882 +0,0 @@
#!/usr/bin/env python3
"""
s16: Team Protocols request-response protocol + request_id + dispatch + state machine.
Run: python s16_team_protocols/code.py
Need: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY
Changes from s15:
- ProtocolState dataclass (request_id, type, sender, status, created_at)
- pending_requests dict: tracks in-flight protocol requests
- dispatch_message: routes incoming messages by type to handlers
- request_shutdown: Lead sends shutdown protocol request
- request_plan: Lead asks teammate to submit plan
- handle_shutdown_request / handle_plan_response: teammate receives & responds
- match_response: Lead correlates response to request via request_id (with type validation)
- Teammate idle loop: waits for inbox messages instead of exiting after 10 rounds
- Unified consume_lead_inbox: protocol routing + injection into history
- 3 new Lead tools: request_shutdown, request_plan, review_plan
- 1 new teammate tool: submit_plan
ASCII flow:
Lead: BUS.send("shutdown_request", {request_id}) teammate inbox
Teammate: dispatch handler BUS.send("shutdown_response", {request_id}) Lead inbox
Lead: consume_lead_inbox match_response(request_id) pending_requests[req_id].status = approved
"""
import os, subprocess, json, time, random, threading
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass, asdict, field
try:
import readline
readline.parse_and_bind('set bind-tty-special-chars off')
except ImportError:
pass
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
MEMORY_DIR = WORKDIR / ".memory"
MEMORY_INDEX = MEMORY_DIR / "MEMORY.md"
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
# ── Task System (from s12, synced) ──
TASKS_DIR = WORKDIR / ".tasks"
TASKS_DIR.mkdir(exist_ok=True)
@dataclass
class Task:
id: str
subject: str
description: str
status: str # pending | in_progress | completed
owner: str | None
blockedBy: list[str]
def _task_path(task_id: str) -> Path:
return TASKS_DIR / f"{task_id}.json"
def create_task(subject: str, description: str = "",
blockedBy: list[str] | None = None) -> Task:
task = Task(
id=f"task_{int(time.time())}_{random.randint(0, 9999):04d}",
subject=subject, description=description,
status="pending", owner=None,
blockedBy=blockedBy or [],
)
save_task(task)
return task
def save_task(task: Task):
_task_path(task.id).write_text(json.dumps(asdict(task), indent=2))
def load_task(task_id: str) -> Task:
return Task(**json.loads(_task_path(task_id).read_text()))
def list_tasks() -> list[Task]:
return [Task(**json.loads(p.read_text()))
for p in sorted(TASKS_DIR.glob("task_*.json"))]
def get_task(task_id: str) -> str:
"""Return full task details as JSON."""
task = load_task(task_id)
return json.dumps(asdict(task), indent=2)
def can_start(task_id: str) -> bool:
"""Check if all blockedBy dependencies are completed.
Missing dependencies are treated as blocked."""
task = load_task(task_id)
for dep_id in task.blockedBy:
if not _task_path(dep_id).exists():
return False
if load_task(dep_id).status != "completed":
return False
return True
def claim_task(task_id: str, owner: str = "agent") -> str:
task = load_task(task_id)
if task.status != "pending":
return f"Task {task_id} is {task.status}, cannot claim"
if not can_start(task_id):
deps = [d for d in task.blockedBy
if not _task_path(d).exists() or load_task(d).status != "completed"]
return f"Blocked by: {deps}"
task.owner = owner
task.status = "in_progress"
save_task(task)
print(f" \033[36m[claim] {task.subject} → in_progress (owner: {owner})\033[0m")
return f"Claimed {task.id} ({task.subject})"
def complete_task(task_id: str) -> str:
task = load_task(task_id)
if task.status != "in_progress":
return f"Task {task_id} is {task.status}, cannot complete"
task.status = "completed"
save_task(task)
unblocked = [t.subject for t in list_tasks()
if t.status == "pending" and t.blockedBy and can_start(t.id)]
print(f" \033[32m[complete] {task.subject}\033[0m")
msg = f"Completed {task.id} ({task.subject})"
if unblocked:
msg += f"\nUnblocked: {', '.join(unblocked)}"
print(f" \033[33m[unblocked] {', '.join(unblocked)}\033[0m")
return msg
# ── Prompt Assembly (from s10, synced) ──
PROMPT_SECTIONS = {
"identity": "You are a coding agent. Act, don't explain.",
"tools": "Available tools: bash, read_file, write_file, "
"get_task, create_task, list_tasks, claim_task, complete_task, "
"spawn_teammate, send_message, check_inbox, "
"request_shutdown, request_plan, review_plan.",
"workspace": f"Working directory: {WORKDIR}",
"memory": "Relevant memories are injected below when available.",
}
def assemble_system_prompt(context: dict) -> str:
sections = [PROMPT_SECTIONS["identity"],
PROMPT_SECTIONS["tools"],
PROMPT_SECTIONS["workspace"]]
memories = context.get("memories", "")
if memories:
sections.append(f"Relevant memories:\n{memories}")
return "\n\n".join(sections)
_last_context_key, _last_prompt = None, None
def get_system_prompt(context: dict) -> str:
global _last_context_key, _last_prompt
key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)
if key == _last_context_key and _last_prompt:
return _last_prompt
_last_context_key = key
_last_prompt = assemble_system_prompt(context)
return _last_prompt
# ── Tools ──
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str, run_in_background: bool = False) -> str:
# run_in_background is handled by agent_loop dispatch, not here
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int | None = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
return "\n".join(lines)
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes to {path}"
except Exception as e:
return f"Error: {e}"
# Task tools
def run_create_task(subject: str, description: str = "",
blockedBy: list[str] | None = None) -> str:
task = create_task(subject, description, blockedBy)
deps = f" (blockedBy: {', '.join(blockedBy)})" if blockedBy else ""
print(f" \033[34m[create] {task.subject}{deps}\033[0m")
return f"Created {task.id}: {task.subject}{deps}"
def run_list_tasks() -> str:
tasks = list_tasks()
if not tasks:
return "No tasks. Use create_task to add some."
lines = []
for t in tasks:
icon = {"pending": "", "in_progress": "",
"completed": ""}.get(t.status, "?")
deps = f" (blockedBy: {', '.join(t.blockedBy)})" if t.blockedBy else ""
owner = f" [{t.owner}]" if t.owner else ""
lines.append(f" {icon} {t.id}: {t.subject} "
f"[{t.status}]{owner}{deps}")
return "\n".join(lines)
def run_get_task(task_id: str) -> str:
try:
return get_task(task_id)
except FileNotFoundError:
return f"Error: Task {task_id} not found"
def run_claim_task(task_id: str) -> str:
return claim_task(task_id, owner="agent")
def run_complete_task(task_id: str) -> str:
return complete_task(task_id)
# ── Background Tasks (from s13, synced) ──
_bg_counter = 0
background_tasks: dict[str, dict] = {}
background_results: dict[str, str] = {}
background_lock = threading.Lock()
def is_slow_operation(tool_name: str, tool_input: dict) -> bool:
"""Fallback heuristic: commands likely to take > 30s."""
if tool_name != "bash":
return False
cmd = tool_input.get("command", "").lower()
slow_keywords = ["install", "build", "test", "deploy", "compile",
"docker build", "pip install", "npm install",
"cargo build", "pytest", "make"]
return any(kw in cmd for kw in slow_keywords)
def should_run_background(tool_name: str, tool_input: dict) -> bool:
"""Model explicit request takes priority; fallback to heuristic."""
if tool_input.get("run_in_background"):
return True
return is_slow_operation(tool_name, tool_input)
def start_background_task(block) -> str:
"""Run tool in a daemon thread. Returns background task ID."""
global _bg_counter
_bg_counter += 1
bg_id = f"bg_{_bg_counter:04d}"
cmd = block.input.get("command", block.name)
def worker():
result = execute_tool(block)
with background_lock:
background_tasks[bg_id]["status"] = "completed"
background_results[bg_id] = result
with background_lock:
background_tasks[bg_id] = {
"tool_use_id": block.id,
"command": cmd,
"status": "running",
}
threading.Thread(target=worker, daemon=True).start()
print(f" \033[33m[background] dispatched {bg_id}: {cmd[:40]}\033[0m")
return bg_id
def collect_background_results() -> list[str]:
"""Collect completed background results as task_notification messages."""
with background_lock:
ready_ids = [bid for bid, task in background_tasks.items()
if task["status"] == "completed"]
notifications = []
for bg_id in ready_ids:
with background_lock:
task = background_tasks.pop(bg_id)
output = background_results.pop(bg_id, "")
summary = output[:200] if len(output) > 200 else output
notifications.append(
f"<task_notification>\n"
f" <task_id>{bg_id}</task_id>\n"
f" <status>completed</status>\n"
f" <command>{task['command']}</command>\n"
f" <summary>{summary}</summary>\n"
f"</task_notification>")
print(f" \033[32m[background done] {bg_id}: "
f"{task['command'][:40]} ({len(output)} chars)\033[0m")
return notifications
# ── MessageBus (from s15) ──
MAILBOX_DIR = WORKDIR / ".mailboxes"
MAILBOX_DIR.mkdir(exist_ok=True)
class MessageBus:
"""File-based message bus. Each agent has a .jsonl inbox.
Read is destructive: read_text + unlink (consumes messages).
Teaching version: no file locking; real CC uses proper-lockfile."""
def send(self, from_agent: str, to_agent: str, content: str,
msg_type: str = "message", metadata: dict = None):
msg = {"from": from_agent, "to": to_agent,
"content": content, "type": msg_type,
"ts": time.time(), "metadata": metadata or {}}
inbox = MAILBOX_DIR / f"{to_agent}.jsonl"
with open(inbox, "a") as f:
f.write(json.dumps(msg) + "\n")
print(f" \033[33m[bus] {from_agent}{to_agent}: "
f"({msg_type}) {content[:50]}\033[0m")
def read_inbox(self, agent: str) -> list[dict]:
inbox = MAILBOX_DIR / f"{agent}.jsonl"
if not inbox.exists():
return []
msgs = [json.loads(line) for line in inbox.read_text().splitlines()
if line.strip()]
inbox.unlink() # consume: read + delete
return msgs
BUS = MessageBus()
active_teammates: dict[str, bool] = {}
# ── Protocol State (s16 new) ──
@dataclass
class ProtocolState:
request_id: str
type: str # "shutdown" | "plan_approval"
sender: str
target: str
status: str # pending | approved | rejected
payload: str # plan text or shutdown reason
created_at: float = field(default_factory=time.time)
pending_requests: dict[str, ProtocolState] = {}
def new_request_id() -> str:
return f"req_{random.randint(0, 999999):06d}"
def match_response(response_type: str, request_id: str, approve: bool):
"""Correlate a response to the original request via request_id.
Validates that response_type matches the request type."""
state = pending_requests.get(request_id)
if not state:
print(f" \033[31m[protocol] unknown request_id: {request_id}\033[0m")
return
# Validate response type matches request type
if state.type == "shutdown" and response_type != "shutdown_response":
print(f" \033[31m[protocol] type mismatch: expected shutdown_response, "
f"got {response_type}\033[0m")
return
if state.type == "plan_approval" and response_type != "plan_approval_response":
print(f" \033[31m[protocol] type mismatch: expected plan_approval_response, "
f"got {response_type}\033[0m")
return
if state.status != "pending":
print(f" \033[33m[protocol] {request_id} already {state.status}, "
f"ignoring duplicate\033[0m")
return
state.status = "approved" if approve else "rejected"
icon = "" if approve else ""
color = "32" if approve else "31"
print(f" \033[{color}m[protocol] {state.type} {icon} "
f"({request_id}: {state.status})\033[0m")
# ── Unified Lead Inbox Consumer (s16 fix) ──
# Both check_inbox tool and main loop call this function.
# Protocol responses are routed via match_response before returning.
def consume_lead_inbox(route_protocol: bool = True) -> list[dict]:
"""Read Lead's inbox. Route protocol responses, return all messages.
Called by both run_check_inbox() and main loop to avoid
messages being consumed without protocol routing."""
msgs = BUS.read_inbox("lead")
if not msgs:
return []
if route_protocol:
for msg in msgs:
meta = msg.get("metadata", {})
req_id = meta.get("request_id", "")
msg_type = msg.get("type", "")
if req_id and msg_type.endswith("_response"):
approve = meta.get("approve", False)
match_response(msg_type, req_id, approve)
return msgs
# ── Teammate Thread (s16: idle loop + dispatch) ──
def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
"""Spawn a teammate agent in a background thread.
Uses idle loop: after each LLM turn, waits for inbox messages
(shutdown_request, new task) instead of exiting."""
if name in active_teammates:
return f"Teammate '{name}' already exists"
system = (f"You are '{name}', a {role}. "
f"Use tools to complete tasks. "
f"Check inbox for protocol messages (shutdown_request, etc).")
def handle_inbox_message(name: str, msg: dict, messages: list) -> bool:
"""Dispatch incoming protocol messages by type.
Returns True if teammate should stop."""
msg_type = msg.get("type", "message")
meta = msg.get("metadata", {})
req_id = meta.get("request_id", "")
if msg_type == "shutdown_request":
BUS.send(name, "lead", "Shutting down gracefully.",
"shutdown_response",
{"request_id": req_id, "approve": True})
print(f" \033[35m[protocol] {name} approved shutdown "
f"({req_id})\033[0m")
return True # stop the loop
if msg_type == "plan_approval_response":
approve = meta.get("approve", False)
if approve:
messages.append({"role": "user",
"content": f"[Plan approved] Proceed with the task."})
else:
messages.append({"role": "user",
"content": f"[Plan rejected] Feedback: {msg['content']}"})
return False # continue
def run():
messages = [{"role": "user", "content": prompt}]
sub_tools = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"]}},
{"name": "read_file", "description": "Read file.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"]}},
{"name": "write_file", "description": "Write file.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"},
"content": {"type": "string"}},
"required": ["path", "content"]}},
{"name": "send_message",
"description": "Send message to another agent.",
"input_schema": {"type": "object",
"properties": {"to": {"type": "string"},
"content": {"type": "string"}},
"required": ["to", "content"]}},
{"name": "submit_plan",
"description": "Submit a plan for Lead approval.",
"input_schema": {"type": "object",
"properties": {"plan": {"type": "string"}},
"required": ["plan"]}},
]
sub_handlers = {
"bash": run_bash, "read_file": run_read, "write_file": run_write,
"send_message": lambda to, content: (BUS.send(name, to, content),
"Sent")[1],
"submit_plan": lambda plan: _teammate_submit_plan(name, plan),
}
shutdown_requested = False
while not shutdown_requested:
# Check inbox for protocol messages
inbox = BUS.read_inbox(name)
should_stop = False
non_protocol = []
for msg in inbox:
if msg.get("type") in ("shutdown_request", "plan_approval_response"):
should_stop = handle_inbox_message(name, msg, messages)
if should_stop:
break
else:
non_protocol.append(msg)
if should_stop:
shutdown_requested = True
break
if non_protocol:
inbox_json = json.dumps(non_protocol)
messages.append({"role": "user",
"content": "<inbox>" + inbox_json + "</inbox>"})
# LLM turn
try:
response = client.messages.create(
model=MODEL, system=system, messages=messages[-20:],
tools=sub_tools, max_tokens=8000)
except Exception:
break
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
# Idle: wait for inbox messages instead of exiting
# Real CC sends idle_notification to Lead here
while not shutdown_requested:
time.sleep(1)
inbox = BUS.read_inbox(name)
if not inbox:
continue
for msg in inbox:
if msg.get("type") in ("shutdown_request", "plan_approval_response"):
should_stop = handle_inbox_message(name, msg, messages)
if should_stop:
shutdown_requested = True
break
else:
non_protocol.append(msg)
if shutdown_requested:
break
if non_protocol:
inbox_json = json.dumps(non_protocol)
messages.append({"role": "user",
"content": "<inbox>" + inbox_json + "</inbox>"})
break # back to LLM turn with new messages
# Execute tool calls
results = []
for block in response.content:
if block.type == "tool_use":
handler = sub_handlers.get(block.name)
output = handler(**block.input) if handler else "Unknown"
results.append({"type": "tool_result",
"tool_use_id": block.id,
"content": str(output)})
messages.append({"role": "user", "content": results})
# Send final summary to Lead
summary = "Done."
for msg in reversed(messages):
if msg["role"] == "assistant" and isinstance(msg["content"], list):
for b in msg["content"]:
if getattr(b, "type", None) == "text":
summary = b.text
break
else:
continue
break
BUS.send(name, "lead", summary, "result")
active_teammates.pop(name, None)
print(f" \033[32m[teammate] {name} finished\033[0m")
active_teammates[name] = True
threading.Thread(target=run, daemon=True).start()
print(f" \033[36m[teammate] {name} spawned as {role}\033[0m")
return f"Teammate '{name}' spawned as {role}"
def _teammate_submit_plan(from_name: str, plan: str) -> str:
"""Teammate submits a plan to Lead for approval.
Note: This is a protocol-level request, not a code-level gate.
After submitting, the teammate's thread continues running — it can
still call bash/write/etc. Real enforcement relies on the model
waiting for the approval response before acting. Code-level tool
gating would require blocking the teammate's tool dispatch until
approval arrives.
"""
req_id = new_request_id()
pending_requests[req_id] = ProtocolState(
request_id=req_id, type="plan_approval",
sender=from_name, target="lead",
status="pending", payload=plan)
BUS.send(from_name, "lead", plan,
"plan_approval_request",
{"request_id": req_id})
return f"Plan submitted ({req_id}). Waiting for approval..."
# ── Lead Protocol Tools (s16 new) ──
def run_request_shutdown(teammate: str) -> str:
req_id = new_request_id()
pending_requests[req_id] = ProtocolState(
request_id=req_id, type="shutdown",
sender="lead", target=teammate,
status="pending", payload="")
BUS.send("lead", teammate, "Please shut down gracefully.",
"shutdown_request",
{"request_id": req_id})
print(f" \033[35m[protocol] shutdown_request → {teammate} "
f"({req_id})\033[0m")
return f"Shutdown request sent to {teammate} (req: {req_id})"
def run_request_plan(teammate: str, task: str) -> str:
"""Lead asks a teammate to submit a plan for a task."""
BUS.send("lead", teammate, f"Please submit a plan for: {task}",
"message")
return f"Asked {teammate} to submit a plan"
def run_review_plan(request_id: str, approve: bool, feedback: str = "") -> str:
state = pending_requests.get(request_id)
if not state:
return f"Request {request_id} not found"
if state.status != "pending":
return f"Request {request_id} already {state.status}"
state.status = "approved" if approve else "rejected"
BUS.send("lead", state.sender, feedback or ("Approved" if approve else "Rejected"),
"plan_approval_response",
{"request_id": request_id, "approve": approve})
icon = "" if approve else ""
print(f" \033[32m[protocol] plan {icon} ({request_id})\033[0m")
return f"Plan {'approved' if approve else 'rejected'} ({request_id})"
# ── Other Lead Tool Handlers ──
def run_spawn_teammate(name: str, role: str, prompt: str) -> str:
return spawn_teammate_thread(name, role, prompt)
def run_send_message(to: str, content: str) -> str:
BUS.send("lead", to, content)
return f"Sent to {to}"
def run_check_inbox() -> str:
"""Check Lead's inbox. Routes protocol responses via match_response."""
msgs = consume_lead_inbox(route_protocol=True)
if not msgs:
return "(inbox empty)"
lines = []
for m in msgs:
meta = m.get("metadata", {})
req_id = meta.get("request_id", "")
tag = f" [{m['type']} req:{req_id}]" if req_id else f" [{m['type']}]"
lines.append(f" [{m['from']}]{tag} {m['content'][:200]}")
return "\n".join(lines)
# ── Tool Dispatch ──
def execute_tool(block) -> str:
"""Execute a tool call block, return output."""
handler = {
"bash": run_bash, "read_file": run_read, "write_file": run_write,
"create_task": run_create_task, "list_tasks": run_list_tasks,
"get_task": run_get_task, "claim_task": run_claim_task,
"complete_task": run_complete_task,
"spawn_teammate": run_spawn_teammate,
"send_message": run_send_message, "check_inbox": run_check_inbox,
"request_shutdown": run_request_shutdown,
"request_plan": run_request_plan, "review_plan": run_review_plan,
}.get(block.name)
if handler:
return handler(**block.input)
return f"Unknown tool: {block.name}"
# ── Tool Definitions ──
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object",
"properties": {
"command": {"type": "string"},
"run_in_background": {"type": "boolean"}},
"required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"},
"limit": {"type": "integer"}},
"required": ["path"]}},
{"name": "write_file", "description": "Write content to a file.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"},
"content": {"type": "string"}},
"required": ["path", "content"]}},
{"name": "create_task",
"description": "Create a new task with optional blockedBy dependencies.",
"input_schema": {"type": "object",
"properties": {
"subject": {"type": "string"},
"description": {"type": "string"},
"blockedBy": {"type": "array",
"items": {"type": "string"}}},
"required": ["subject"]}},
{"name": "list_tasks",
"description": "List all tasks with status, owner, and dependencies.",
"input_schema": {"type": "object", "properties": {},
"required": []}},
{"name": "get_task",
"description": "Get full details of a specific task by ID.",
"input_schema": {"type": "object",
"properties": {"task_id": {"type": "string"}},
"required": ["task_id"]}},
{"name": "claim_task",
"description": "Claim a pending task. Sets owner, changes status to in_progress.",
"input_schema": {"type": "object",
"properties": {"task_id": {"type": "string"}},
"required": ["task_id"]}},
{"name": "complete_task",
"description": "Complete an in-progress task. Reports unblocked downstream tasks.",
"input_schema": {"type": "object",
"properties": {"task_id": {"type": "string"}},
"required": ["task_id"]}},
{"name": "spawn_teammate",
"description": "Spawn a teammate agent in a background thread.",
"input_schema": {"type": "object",
"properties": {
"name": {"type": "string"},
"role": {"type": "string"},
"prompt": {"type": "string"}},
"required": ["name", "role", "prompt"]}},
{"name": "send_message",
"description": "Send message to a teammate via MessageBus.",
"input_schema": {"type": "object",
"properties": {"to": {"type": "string"},
"content": {"type": "string"}},
"required": ["to", "content"]}},
{"name": "check_inbox",
"description": "Check Lead's inbox. Routes protocol responses automatically.",
"input_schema": {"type": "object", "properties": {},
"required": []}},
{"name": "request_shutdown",
"description": "Request a teammate to shut down gracefully.",
"input_schema": {"type": "object",
"properties": {"teammate": {"type": "string"}},
"required": ["teammate"]}},
{"name": "request_plan",
"description": "Ask a teammate to submit a plan for review.",
"input_schema": {"type": "object",
"properties": {"teammate": {"type": "string"},
"task": {"type": "string"}},
"required": ["teammate", "task"]}},
{"name": "review_plan",
"description": "Approve or reject a submitted plan by request_id.",
"input_schema": {"type": "object",
"properties": {
"request_id": {"type": "string"},
"approve": {"type": "boolean"},
"feedback": {"type": "string"}},
"required": ["request_id", "approve"]}},
]
# ── Context ──
def update_context(context: dict, messages: list) -> dict:
"""Derive context from real state."""
memories = ""
if MEMORY_INDEX.exists():
content = MEMORY_INDEX.read_text().strip()
if content:
memories = content
return {
"enabled_tools": [t["name"] for t in TOOLS],
"workspace": str(WORKDIR),
"memories": memories,
}
# ── Agent Loop ──
def agent_loop(messages: list, context: dict):
system = get_system_prompt(context)
while True:
try:
response = client.messages.create(
model=MODEL, system=system, messages=messages,
tools=TOOLS, max_tokens=8000)
except Exception as e:
messages.append({"role": "assistant", "content": [
{"type": "text",
"text": f"[Error] {type(e).__name__}: {e}"}]})
return
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type != "tool_use":
continue
print(f"\033[36m> {block.name}\033[0m")
if should_run_background(block.name, block.input):
bg_id = start_background_task(block)
results.append({"type": "tool_result",
"tool_use_id": block.id,
"content": f"[Background task {bg_id} started] "
f"Result will be available when complete."})
else:
output = execute_tool(block)
print(str(output)[:300])
results.append({"type": "tool_result",
"tool_use_id": block.id,
"content": output})
# Merge background tool results + notifications into one user message
user_content = list(results)
bg_notifications = collect_background_results()
if bg_notifications:
for notif in bg_notifications:
user_content.append({"type": "text", "text": notif})
messages.append({"role": "user", "content": user_content})
context = update_context(context, messages)
system = get_system_prompt(context)
if __name__ == "__main__":
print("s16: team protocols")
print("Enter a question, press Enter to send. Type q to quit.\n")
history = []
context = update_context({}, [])
while True:
try:
query = input("\033[36ms16 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history, context)
context = update_context(context, history)
for block in history[-1]["content"]:
if getattr(block, "type", None) == "text":
print(block.text)
elif isinstance(block, dict) and block.get("type") == "text":
print(block.get("text", ""))
# Check inbox → route protocol + inject into history
inbox_msgs = consume_lead_inbox(route_protocol=True)
if inbox_msgs:
inbox_text = "\n".join(
f"From {m['from']}: {m['content'][:200]}" for m in inbox_msgs)
history.append({"role": "user",
"content": f"[Inbox]\n{inbox_text}"})
print(f"\n\033[33m[Inbox: {len(inbox_msgs)} messages injected]\033[0m")
print()
-273
View File
@@ -1,273 +0,0 @@
# s17: Autonomous Agents — ボードを見て、自分で認領
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20 → s21 → s22
> *"ボードを見て、自分で認領"* — 空き時にポーリング、仕事があれば開始。
>
> **Harness 層**: 自治 — チームメイトが自己組織化、リーダーの割り当て不要。
> **コアと任意項目:** アイドル時の仕事発見と原子的な claim が本章の中心。アイデンティティ再注入は教育版の高度な補助で、初回は読み飛ばしてよい。
---
## 課題
s16 のチームメイトは通信でき、シャットダウンハンドシェイクもできる。しかし各チームメイトは Lead がタスクを割り当てるのを待つ——ボードに 10 個の未認領タスクがあれば、Lead は 10 回手動で assign しなければならない。これはスケールしない。チームメイトは自分でタスクボードを見て、未認領のタスクを見つけて認領し、終わったら次を探すべき。
---
## ソリューション
![Autonomous Agents Overview](images/autonomous-agents-overview.ja.svg)
S16 の教学版 MessageBus とプロトコルツールを踏襲。本章の追加:**idle_poll**(空き時に 5 秒ごとにポーリング)、**scan_unclaimed_tasks**(ボード上の認領可能なタスクをスキャン)、**自動認領**(見つけたら即座に claim、Lead 不要)。
チームメイトのライフサイクルは 2 フェーズから 3 フェーズに:
| フェーズ | 動作 | 終了条件 |
|----------|------|---------|
| WORK | inbox → LLM → ツールループ | `stop_reason != tool_use` |
| IDLE | 5s ポーリング inbox + タスクボード | 60s タイムアウト |
| SHUTDOWN | summary を送信、終了 | — |
---
## 仕組み
### idle_poll: 空き時ポーリング
チームメイトはタスク完了後も終了せず、IDLE フェーズに入る——5 秒ごとに新しい仕事がないか確認:
```python
IDLE_POLL_INTERVAL = 5 # seconds
IDLE_TIMEOUT = 60 # seconds
def idle_poll(name, messages, role) -> str:
"""Return 'work', 'shutdown', or 'timeout'."""
for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):
time.sleep(IDLE_POLL_INTERVAL)
# ① 受信箱確認(優先)
inbox = BUS.read_inbox(name)
if inbox:
# shutdown_request は即座に処理
for msg in inbox:
if msg.get("type") == "shutdown_request":
# ... shutdown_response 返信
return "shutdown"
# 通常メッセージ:コンテキストに注入、WORK に戻る
messages.append(...)
return "work"
# ② タスクボードスキャン
unclaimed = scan_unclaimed_tasks()
if unclaimed:
task = unclaimed[0]
result = claim_task(task["id"], name)
if "Claimed" in result:
messages.append(...)
return "work"
return "timeout"
```
inbox を優先(shutdown_request 等のプロトコルメッセージの可能性)、タスクボードが次。IDLE フェーズで shutdown_request を受信すると即座に返信して終了し、次の WORK を待つ必要がない。
### scan_unclaimed_tasks: タスクボードスキャン
pending 状態、owner なし、全依存関係完了(`can_start`)のタスクを検索:
```python
def scan_unclaimed_tasks() -> list[dict]:
unclaimed = []
for f in sorted(TASKS_DIR.glob("task_*.json")):
task = json.loads(f.read_text())
if (task.get("status") == "pending"
and not task.get("owner")
and can_start(task["id"])):
unclaimed.append(task)
return unclaimed
```
3 つの条件:pending であること、owner がないこと、全 blockedBy 依存が完了していること。`can_start` は依存タスクの状態を確認——依存があるからといってタスクを開始できないわけではなく、未解決の依存のみがブロックする。教学版はファイル名順で最初のものを選択、CC はファイルロックで複数チームメイトの同時認領を防止。
### claim_task: owner チェック
自動認領時に claim 結果を確認し、失敗を成功として扱わない:
```python
def claim_task(task_id: str, owner: str = "agent") -> str:
task = load_task(task_id)
if task.status != "pending":
return f"Task {task_id} is {task.status}, cannot claim"
if task.owner:
return f"Task {task_id} already owned by {task.owner}"
if not can_start(task_id):
return f"Blocked by: {deps}"
task.owner = owner
task.status = "in_progress"
save_task(task)
return f"Claimed {task.id} ({task.subject})"
```
教学版にはファイルロックがないため、並行認領で競合する可能性がある。しかし `task.owner` チェックで最も明白な「後書き上書き」問題を回避。CC は `proper-lockfile` でタスクファイルを保護、`claimTask` はファイルロック内で read-modify-write を実行(`utils/tasks.ts:541-612`)。
### チームメイトライフサイクル: WORK → IDLE → SHUTDOWN
s16 のチームメイトはタスク完了後に終了。s17 は IDLE フェーズを追加——外側ループで WORK → IDLE を繰り返す:
```python
# 外側ループ: WORK → IDLE サイクル
while True:
# WORK フェーズ: 内側ループ(最大 10 ラウンド LLM 呼び出し)
for _ in range(10):
# inbox 確認、プロトコルメッセージ処理、LLM 呼び出し、ツール実行
...
if response.stop_reason != "tool_use":
break # WORK フェーズ終了
# IDLE フェーズ
idle_result = idle_poll(name, messages, role)
if idle_result == "shutdown":
break
if idle_result == "timeout":
break # 60s タイムアウト → SHUTDOWN
# SHUTDOWN: summary を Lead に送信
BUS.send(name, "lead", summary, "result")
```
主要設計:
- **外側 while True**:WORK と IDLE がタイムアウトまたはシャットダウン要求まで交互に続く
- **内側 for 10**:WORK フェーズは最大 10 ラウンドの LLM 呼び出し(無限ループ防止)
- **IDLE タイムアウト 60 秒**:12 回ポーリング × 5 秒 = 60 秒。タイムアウト後 summary を送信して終了
- **shutdown_request は両フェーズで応答**WORK フェーズは `handle_inbox_message` でディスパッチ、IDLE フェーズは `idle_poll` が直接確認して返信
### 発展(任意):アイデンティティ再注入
autoCompacts08)後、チームメイトの messages リストが要約に圧縮される可能性がある。新しい WORK フェーズに入るたびに確認:
```python
if len(messages) <= 3:
messages.insert(0, {"role": "user",
"content": f"<identity>You are '{name}', role: {role}. "
f"Continue your work.</identity>"})
```
メッセージが短い場合、圧縮が発生したことを示す——身份情報を再注入。真实 CC では context compaction が system prompt を保持、教学版の簡略実装は手動処理が必要。
### consume_lead_inbox: 統一 inbox コンシューマ
`check_inbox` ツールとメインループ末尾の両方が同じ `consume_lead_inbox()` 関数を呼び出す:プロトコル response を先にルーティングして状態を更新し、全メッセージを Lead の会話履歴に注入。チームメイトからの summary/result は端末に表示されるだけでなく、Lead の LLM も確認して次のステップを調整可能。
### 組み合わせて実行
```
1. Lead: "バックエンド構築——タスクが多すぎる、チームメイトに自己認領させる"
2. Lead → create_task("データベーススキーマを作成")
3. Lead → create_task("API ルートを書く")
4. Lead → create_task("ユニットテストを書く")
5. Lead → spawn_teammate("alice", "backend", "あなたはバックエンド開発者")
6. Lead → spawn_teammate("bob", "backend", "あなたはバックエンド開発者")
7. alice スレッド起動 → WORK: 初期 inbox なし → 空転 → IDLE
8. bob スレッド起動 → WORK: 初期 inbox なし → 空転 → IDLE
9. alice IDLE ポーリング 1 回目 → scan_unclaimed → "データベーススキーマを作成" を発見
10. alice → claim_task → "データベーススキーマを作成" → WORK に戻る
11. bob IDLE ポーリング 1 回目 → scan_unclaimed → "API ルートを書く" を発見
12. bob → claim_task → "API ルートを書く" → WORK に戻る
13. alice WORK: write_file("schema.sql", ...) → complete_task → WORK 終了
14. alice IDLE → scan → "ユニットテストを書く" → claim → WORK
15. alice WORK: write_file("test_api.py", ...) → complete_task → WORK 終了
16. alice IDLE → 60s 新しいタスクなし → SHUTDOWN
17. bob も同様のフロー → 完了 → SHUTDOWN
18. Lead consume_lead_inbox → alice と bob の summary を確認
```
2 人のチームメイトが並行して認領・作業。Lead はタスクを作成してチームメイトを起動するだけで、手動割り当て不要。
---
## s16 からの変更
| コンポーネント | 変更前 (s16) | 変更後 (s17) |
|--------------|------------|------------|
| タスク割り当て | Lead が手動 assign | チームメイトが自動認領(can_start で依存確認) |
| チームメイト状態 | WORK → IDLE(1s 間隔で inbox をポーリング)→ WORK / SHUTDOWN | WORK → IDLE5s 間隔で inbox + タスクボードをポーリング、60s タイムアウト)→ WORK / SHUTDOWN |
| claim_task | owner チェックなし | 既に owner があるタスクを拒否 |
| IDLE フェーズシャットダウン | shutdown_request を受信後に終了 | 即座にシャットダウンをディスパッチして終了 |
| Lead inbox | consume_lead_inbox がプロトコル応答をルーティングしコンテキストに注入 | consume_lead_inbox 機構を踏襲 |
| 新規関数 | consume_lead_inbox は既存 | idle_poll, scan_unclaimed_tasksconsume_lead_inbox を踏襲) |
| 身份保持 | system prompt のみ | 圧縮後に自動再注入 |
| Lead ツール | 14 | 14(変更なし) |
| チームメイトツール | 5 | 8+ list_tasks, claim_task, complete_task |
| チームメイト終了条件 | WORK 完了後 IDLE に入り、shutdown_request を待って終了(タイムアウトなし) | 60s アイドルタイムアウトまたは shutdown_request 受信で終了 |
---
## 試してみる
```sh
cd learn-claude-code
python s17_autonomous_agents/code.py
```
以下のプロンプトを試してください:
`Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim and work.`
観察ポイント:チームメイトは未割り当てのタスクを自動認領したか?blockedBy 依存のあるタスクは依存完了後に正しく認領されたか?アイドルタイムアウトでシャットダウンしたか?IDLE フェーズで shutdown_request に即座に応答したか?`.tasks/` ディレクトリのタスク状態はどう変化したか?
---
## 次の章
チームメイトが自己組織化した。しかし Alice も Bob も同じディレクトリで作業——Alice が `config.py` を編集し、Bob も `config.py` を編集して互いに上書きしてしまう。
s18 Worktree Isolation → 各タスクに専用の作業ディレクトリ、競合なし。
<details>
<summary>CC ソースコード深掘り</summary>
> 教学注記:本章の idle_poll + auto-claim 機構は教学設計であり、統一ポーリング関数で「空き時に仕事を探す」をデモ。CC の実際の実装は複数機構の組み合わせだが、目標は同じ——Lead の手動割り当て負担を軽減。
### 一、CC の空き機構:組み合わせ路径、単一ポーリングではない
教学版は 1 つの `idle_poll()` で空き時の inbox 確認とタスク認領を統一処理。CC の実際の実装は 4 つの機構の組み合わせ:
**idle_notification**:チームメイトが 1 ラウンドの作業を完了後、`sendIdleNotification()``inProcessRunner.ts:569-589`)が Lead に空き通知を送信。Lead はチームメイトが利用可能であることを知り、新しいタスクを割り当てたりシャットダウンを要求可能。
**mailbox ポーリング**`waitForNextPromptOrShutdown()``inProcessRunner.ts:689-868`)は **500ms ポーリングループ**で、3 つのソースを継続チェック:pending user messages、mailbox ファイルメッセージ、task list。shutdown_request は優先処理(`inProcessRunner.ts:768-804`)、通常メッセージによる飢餓を防止。
**task watcher**`useTaskListWatcher``hooks/useTaskListWatcher.ts:34-189`)が `fs.watch()``.claude/tasks/` ディレクトリの変化を監視、1 秒 debounce で新タスク作成や依存アンロック時にチェックをトリガー。依存判断(`L197-207`)は「blockedBy に未完了タスクがない」で、「blockedBy が空」ではない。
**能動 claim**:ポーリングループ内でも `tryClaimNextTask()``inProcessRunner.ts:853-860`)を呼び出し——待機中に task list から能動的にタスクを認領。したがって「チームメイトは能動的にタスクをポーリングしない」は不正確、CC は受動通知と能動認領の両方を持つ。
### 二、タスク認領:ファイルロック + 原子操作
`claimTask()``utils/tasks.ts:541-612`)は `proper-lockfile` のタスクファイルロックを使用、ロック内で read-check-modify-write を実行。チェック項目:owner が既に存在(`L575-576`)、完了済み(`L580-581`)、blockedBy に未完了タスクがあるか(`L585-594`)。`claimTaskWithBusyCheck()``utils/tasks.ts:614-692`)はタスクリストレベルロックを使用、busy check と claim を原子操作にして TOCTOU を回避。
`findAvailableTask()``inProcessRunner.ts:595-604`)の依存判断も「全 blockedBy 完了」で、`task.blockedBy.every(id => !unresolvedTaskIds.has(id))` で実装。`tryClaimNextTask()``inProcessRunner.ts:624-657`)は認領後 status を `in_progress` に更新、UI に即座に反映。
### 三、教学版 vs CC 対比
| 次元 | 教学版 (s17) | CC |
|------|-------------|-----|
| 空き機構 | idle_poll 統一ポーリング(5s | idle_notification + 500ms mailbox ポーリング + task watcher |
| タスク発見 | scan_unclaimed_tasks(ポーリング) | useTaskListWatcher(ファイル監視)+ tryClaimNextTask(能動ポーリング) |
| 依存チェック | can_start(全 blockedBy 完了) | findAvailableTask(同じセマンティクス) |
| 並行安全性 | owner チェック(ファイルロックなし) | proper-lockfile タスクロック + タスクリストロック |
| shutdown 処理 | IDLE 直接ディスパッチ、WORK は handle_inbox_message | 500ms ポーリングループで shutdown_request を優先 |
| タイムアウト終了 | 60s 新しいタスクなし | 固定タイムアウトなし、Lead 手動 shutdown |
| 身份保持 | messages 長さ検出 | context compaction が system prompt を保持 |
| claim 失敗処理 | 戻り値を確認、失敗時はスキップ | ファイルロックで原子性を保証 |
教学版の `idle_poll()` は CC の 4 つの機構を 1 つのポーリング関数に統合——核心セマンティクス(空き時に仕事を探す、依存アンロック後に認領、shutdown 優先)が一致するため、合理的な簡略化。
</details>
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
-273
View File
@@ -1,273 +0,0 @@
# s17: Autonomous Agents — Check the Board, Claim the Task
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20 → s21 → s22
> *"Check the board, claim the task"* — poll when idle, work when found.
>
> **Harness Layer**: Autonomy — Self-organizing teammates, no leader assignment needed.
> **Core vs optional:** idle work discovery and atomic claiming are the lesson's core. Identity re-injection is an advanced teaching workaround and may be skipped on a first pass.
---
## The Problem
s16's teammates can communicate and handshake shutdown. But each teammate waits for Lead to assign tasks — with 10 unclaimed tasks on the board, Lead has to manually assign 10 times. This doesn't scale. Teammates should check the task board themselves, claim unowned tasks, and look for the next one when done.
---
## The Solution
![Autonomous Agents Overview](images/autonomous-agents-overview.en.svg)
Carries forward S16's teaching-version MessageBus and protocol tools. This chapter adds: **idle_poll** (poll every 5 seconds when idle), **scan_unclaimed_tasks** (scan the board for claimable tasks), **auto-claim** (claim on sight, no Lead needed).
Teammate lifecycle expands from two phases to three:
| Phase | Behavior | Exit condition |
|-------|----------|----------------|
| WORK | inbox → LLM → tool loop | `stop_reason != tool_use` |
| IDLE | 5s poll inbox + task board | 60s timeout |
| SHUTDOWN | Send summary, exit | — |
---
## How It Works
### idle_poll: Idle Polling
After completing a task, the teammate doesn't exit. It enters the IDLE phase — checking every 5 seconds for new work:
```python
IDLE_POLL_INTERVAL = 5 # seconds
IDLE_TIMEOUT = 60 # seconds
def idle_poll(name, messages, role) -> str:
"""Return 'work', 'shutdown', or 'timeout'."""
for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):
time.sleep(IDLE_POLL_INTERVAL)
# ① Check inbox (priority)
inbox = BUS.read_inbox(name)
if inbox:
# shutdown_request handled immediately
for msg in inbox:
if msg.get("type") == "shutdown_request":
# ... reply shutdown_response
return "shutdown"
# Regular messages: inject into context, return to WORK
messages.append(...)
return "work"
# ② Scan task board
unclaimed = scan_unclaimed_tasks()
if unclaimed:
task = unclaimed[0]
result = claim_task(task["id"], name)
if "Claimed" in result:
messages.append(...)
return "work"
return "timeout"
```
Inbox takes priority (may contain protocol messages like shutdown_request), task board second. A shutdown_request received during IDLE is dispatched immediately — no need to wait for the next WORK phase.
### scan_unclaimed_tasks: Scan the Task Board
Find tasks that are pending, unowned, with all dependencies completed (`can_start`):
```python
def scan_unclaimed_tasks() -> list[dict]:
unclaimed = []
for f in sorted(TASKS_DIR.glob("task_*.json")):
task = json.loads(f.read_text())
if (task.get("status") == "pending"
and not task.get("owner")
and can_start(task["id"])):
unclaimed.append(task)
return unclaimed
```
Three conditions: must be pending, no owner, all blockedBy dependencies completed. `can_start` checks dependency task status — having dependencies doesn't mean the task can't start, only unresolved dependencies block it. Teaching version picks the first by filename; CC uses file locks to prevent multiple teammates from claiming the same task.
### claim_task: Owner Check
Auto-claim checks the claim result, not treating failure as success:
```python
def claim_task(task_id: str, owner: str = "agent") -> str:
task = load_task(task_id)
if task.status != "pending":
return f"Task {task_id} is {task.status}, cannot claim"
if task.owner:
return f"Task {task_id} already owned by {task.owner}"
if not can_start(task_id):
return f"Blocked by: {deps}"
task.owner = owner
task.status = "in_progress"
save_task(task)
return f"Claimed {task.id} ({task.subject})"
```
Teaching version has no file locks, so concurrent claims may still race. But the `task.owner` check avoids the most obvious "last writer wins" problem. CC uses `proper-lockfile` to protect task files, with `claimTask` doing read-modify-write inside a file lock (`utils/tasks.ts:541-612`).
### Teammate Lifecycle: WORK → IDLE → SHUTDOWN
s16's teammates exit after finishing. s17 adds the IDLE phase — teammates cycle through WORK → IDLE in an outer loop:
```python
# Outer loop: WORK → IDLE cycle
while True:
# WORK phase: inner loop (max 10 LLM rounds)
for _ in range(10):
# Check inbox, dispatch protocol, call LLM, execute tools
...
if response.stop_reason != "tool_use":
break # WORK phase ends
# IDLE phase
idle_result = idle_poll(name, messages, role)
if idle_result == "shutdown":
break
if idle_result == "timeout":
break # 60s timeout → SHUTDOWN
# SHUTDOWN: send summary to Lead
BUS.send(name, "lead", summary, "result")
```
Key design:
- **Outer while True**: WORK and IDLE alternate until timeout or shutdown request
- **Inner for 10**: WORK phase caps at 10 LLM rounds (prevents infinite loops)
- **IDLE timeout 60s**: 12 polls × 5s = 60s. Timeout sends summary and exits
- **shutdown_request works in both phases**: WORK phase dispatches via `handle_inbox_message`; IDLE phase's `idle_poll` checks and replies directly
### Advanced (Optional): Identity Re-injection
After autoCompact (s08), a teammate's messages list may be compressed into a summary. On each new WORK phase entry, check:
```python
if len(messages) <= 3:
messages.insert(0, {"role": "user",
"content": f"<identity>You are '{name}', role: {role}. "
f"Continue your work.</identity>"})
```
Short messages suggest compression happened — re-inject identity. In real CC, context compaction preserves the system prompt; the teaching version's simplified implementation needs manual handling.
### consume_lead_inbox: Unified Inbox Consumer
Both the `check_inbox` tool and the main loop call the same `consume_lead_inbox()` function: route protocol responses to update state first, then inject all messages into Lead's conversation history. Teammates' summaries and results don't just print to terminal — Lead's LLM can see them and coordinate next steps.
### Putting It Together
```
1. Lead: "Build the backend — too many tasks, let teammates self-claim"
2. Lead → create_task("Create database schema")
3. Lead → create_task("Write API routes")
4. Lead → create_task("Write unit tests")
5. Lead → spawn_teammate("alice", "backend", "You are a backend developer")
6. Lead → spawn_teammate("bob", "backend", "You are a backend developer")
7. alice thread starts → WORK: no initial inbox → spins → IDLE
8. bob thread starts → WORK: no initial inbox → spins → IDLE
9. alice IDLE poll 1 → scan_unclaimed → finds "Create database schema"
10. alice → claim_task → "Create database schema" → back to WORK
11. bob IDLE poll 1 → scan_unclaimed → finds "Write API routes"
12. bob → claim_task → "Write API routes" → back to WORK
13. alice WORK: write_file("schema.sql", ...) → complete_task → WORK ends
14. alice IDLE → scan → "Write unit tests" → claim → WORK
15. alice WORK: write_file("test_api.py", ...) → complete_task → WORK ends
16. alice IDLE → 60s no new tasks → SHUTDOWN
17. bob similar flow → done → SHUTDOWN
18. Lead consume_lead_inbox → sees alice and bob's summaries
```
Two teammates claim and work in parallel. Lead only creates tasks and spawns teammates — no manual assignment needed.
---
## Changes from s16
| Component | Before (s16) | After (s17) |
|-----------|-------------|-------------|
| Task assignment | Lead manually assigns | Teammates auto-claim (can_start checks deps) |
| Teammate state | WORK → IDLE (1s inbox poll) → WORK / SHUTDOWN | WORK → IDLE (5s inbox + task board poll, 60s timeout) → WORK / SHUTDOWN |
| claim_task | No owner check | Rejects tasks that already have an owner |
| IDLE phase shutdown | Exits after receiving shutdown_request | Dispatches shutdown immediately and exits |
| Lead inbox | consume_lead_inbox routes protocol responses and injects into context | Reuses consume_lead_inbox mechanism |
| New functions | consume_lead_inbox already exists | idle_poll, scan_unclaimed_tasks (reuses consume_lead_inbox) |
| Identity persistence | System prompt only | Auto re-inject after compression |
| Lead tools | 14 | 14 (unchanged) |
| Teammate tools | 5 | 8 (+ list_tasks, claim_task, complete_task) |
| Teammate exit | WORK ends → enters IDLE, waits for shutdown_request (no timeout) | Exits after 60s idle timeout or receiving shutdown_request |
---
## Try It
```sh
cd learn-claude-code
python s17_autonomous_agents/code.py
```
Try this prompt:
`Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim and work.`
What to observe: Do teammates auto-claim unassigned tasks? Are tasks with blockedBy dependencies claimed only after their dependencies complete? Does idle timeout trigger shutdown? Does a shutdown_request in IDLE phase get an immediate response? How do task states change in `.tasks/`?
---
## What's Next
Teammates self-organize now. But Alice and Bob both work in the same directory — Alice edits `config.py`, Bob also edits `config.py`, overwriting each other.
s18 Worktree Isolation → Each task gets its own working directory, no conflicts.
<details>
<summary>Deep Dive into CC Source</summary>
> Teaching note: This chapter's idle_poll + auto-claim mechanism is a teaching design, using a unified polling function to demonstrate "find work when idle." CC's actual implementation combines multiple mechanisms, but shares the same goal — reducing Lead's manual assignment burden.
### 1. CC's Idle Mechanism: Combined Approach, Not Single Polling
Teaching version uses a single `idle_poll()` to handle both inbox checking and task claiming during idle. CC's actual implementation combines four mechanisms:
**idle_notification**: After completing a round of work, `sendIdleNotification()` (`inProcessRunner.ts:569-589`) sends an idle notification to Lead. Lead knows the teammate is available and can assign new tasks or request shutdown.
**mailbox polling**: `waitForNextPromptOrShutdown()` (`inProcessRunner.ts:689-868`) is a **500ms polling loop** that continuously checks three sources: pending user messages, mailbox file messages, and task list. Shutdown requests are prioritized (`inProcessRunner.ts:768-804`), preventing starvation by regular messages.
**task watcher**: `useTaskListWatcher` (`hooks/useTaskListWatcher.ts:34-189`) uses `fs.watch()` to monitor the `.claude/tasks/` directory with 1-second debounce, triggering checks when new tasks are created or dependencies unblock. The dependency check (`L197-207`) verifies "no incomplete tasks in blockedBy", not "blockedBy is empty".
**active claiming**: The polling loop also calls `tryClaimNextTask()` (`inProcessRunner.ts:853-860`) — actively claiming tasks from the task list while waiting. So "teammates don't actively poll for tasks" is inaccurate; CC has both passive notification and active claiming.
### 2. Task Claiming: File Locks + Atomic Operations
`claimTask()` (`utils/tasks.ts:541-612`) uses `proper-lockfile` task-level locks, performing read-check-modify-write within the lock. Checks: owner already exists (`L575-576`), already completed (`L580-581`), unresolved blockers in blockedBy (`L585-594`). `claimTaskWithBusyCheck()` (`utils/tasks.ts:614-692`) uses task-list level locks, making busy check and claim atomic to avoid TOCTOU.
`findAvailableTask()` (`inProcessRunner.ts:595-604`) checks "all blockedBy completed" using `task.blockedBy.every(id => !unresolvedTaskIds.has(id))`. `tryClaimNextTask()` (`inProcessRunner.ts:624-657`) updates status to `in_progress` after claiming, so the UI immediately reflects the change.
### 3. Teaching Version vs CC Comparison
| Dimension | Teaching (s17) | CC |
|-----------|----------------|-----|
| Idle mechanism | idle_poll unified polling (5s) | idle_notification + 500ms mailbox polling + task watcher |
| Task discovery | scan_unclaimed_tasks (polling) | useTaskListWatcher (file watching) + tryClaimNextTask (active polling) |
| Dependency check | can_start (all blockedBy completed) | findAvailableTask (same semantics) |
| Concurrency safety | Owner check (no file lock) | proper-lockfile task lock + task-list lock |
| Shutdown handling | IDLE dispatches directly, WORK via handle_inbox_message | 500ms polling loop prioritizes shutdown_request |
| Timeout exit | 60s with no new tasks | No fixed timeout, Lead manual shutdown |
| Identity persistence | Messages length detection | Context compaction preserves system prompt |
| Claim failure handling | Check return value, skip on failure | File locks guarantee atomicity |
Teaching version's `idle_poll()` merges CC's four mechanisms into one polling function — a reasonable simplification since the core semantics (find work when idle, claim after deps resolve, prioritize shutdown) are consistent.
</details>
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
-273
View File
@@ -1,273 +0,0 @@
# s17: Autonomous Agents — 自己看板,自己认领
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20 → s21 → s22
> *"自己看板,自己认领"* — 空闲时轮询,有活就干。
>
> **Harness 层**: 自治 — 队友自组织,不依赖 Lead 分配。
> **核心与选学:** 空闲发现工作与原子认领是本章核心;身份重注入只是教学版的进阶补丁,第一次学习可以跳过。
---
## 问题
s16 的队友能通信、能握手关机。但每个队友等 Lead 分配任务——如果任务看板上有 10 个未认领任务,Lead 得手动 assign 10 次。这不能扩展。队友应该自己看任务看板,发现没人做的任务就认领,做完再找下一个。
---
## 解决方案
![Autonomous Agents Overview](images/autonomous-agents-overview.svg)
沿用 S16 的教学版 MessageBus 和协议工具。本章新增:**idle_poll**(空闲时每 5 秒轮询一次)、**scan_unclaimed_tasks**(扫描看板上可认领的任务)、**自动认领**(找到任务就 claim,不用 Lead 操心)。
队友生命周期从两阶段变成三阶段:
| 阶段 | 行为 | 退出条件 |
|------|------|---------|
| WORK | inbox → LLM → 工具循环 | `stop_reason != tool_use` |
| IDLE | 每 5s 轮询 inbox + 任务板 | 60s 超时 |
| SHUTDOWN | 发 summary,退出 | — |
---
## 工作原理
### idle_poll: 空闲轮询
队友完成当前任务后不退出,进入 IDLE 阶段——每 5 秒检查一次有没有新工作:
```python
IDLE_POLL_INTERVAL = 5 # seconds
IDLE_TIMEOUT = 60 # seconds
def idle_poll(name, messages, role) -> str:
"""Return 'work', 'shutdown', or 'timeout'."""
for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):
time.sleep(IDLE_POLL_INTERVAL)
# ① 检查收件箱(优先)
inbox = BUS.read_inbox(name)
if inbox:
# shutdown_request 立即处理
for msg in inbox:
if msg.get("type") == "shutdown_request":
# ... 回复 shutdown_response
return "shutdown"
# 普通消息注入上下文,回到 WORK
messages.append(...)
return "work"
# ② 扫描任务看板
unclaimed = scan_unclaimed_tasks()
if unclaimed:
task = unclaimed[0]
result = claim_task(task["id"], name)
if "Claimed" in result:
messages.append(...)
return "work"
return "timeout"
```
inbox 优先(可能包含 shutdown_request 等协议消息),任务板其次。IDLE 阶段收到 shutdown_request 会直接回复并退出,不等到下一轮 WORK。
### scan_unclaimed_tasks: 扫描任务看板
找 pending 状态、无 owner、所有依赖已完成(`can_start`)的任务:
```python
def scan_unclaimed_tasks() -> list[dict]:
unclaimed = []
for f in sorted(TASKS_DIR.glob("task_*.json")):
task = json.loads(f.read_text())
if (task.get("status") == "pending"
and not task.get("owner")
and can_start(task["id"])):
unclaimed.append(task)
return unclaimed
```
三个条件:必须是 pending、没有 owner、所有 blockedBy 依赖已完成。`can_start` 检查依赖任务的状态——有依赖不代表不能做,只有被未完成的任务阻塞才不能做。教学版按文件名排序取第一个;CC 用文件锁防止多个队友同时认领同一个任务。
### claim_task: owner 检查
自动认领时检查 claim 结果,不把失败当成功:
```python
def claim_task(task_id: str, owner: str = "agent") -> str:
task = load_task(task_id)
if task.status != "pending":
return f"Task {task_id} is {task.status}, cannot claim"
if task.owner:
return f"Task {task_id} already owned by {task.owner}"
if not can_start(task_id):
return f"Blocked by: {deps}"
task.owner = owner
task.status = "in_progress"
save_task(task)
return f"Claimed {task.id} ({task.subject})"
```
教学版没有文件锁,并发认领可能出现竞争。但至少 `task.owner` 检查避免了最明显的"后写覆盖"问题。CC 用 `proper-lockfile` 保护任务文件,`claimTask` 在文件锁内完成读-改-写(`utils/tasks.ts:541-612`)。
### 队友生命周期: WORK → IDLE → SHUTDOWN
s16 的队友做完任务就退出。s17 加了 IDLE 阶段,队友在外层循环中反复 WORK → IDLE:
```python
# Outer loop: WORK → IDLE cycle
while True:
# WORK phase: 内层循环(最多 10 轮 LLM 调用)
for _ in range(10):
# 检查 inbox、处理协议消息、调 LLM、执行工具
...
if response.stop_reason != "tool_use":
break # WORK 阶段结束
# IDLE phase
idle_result = idle_poll(name, messages, role)
if idle_result == "shutdown":
break
if idle_result == "timeout":
break # 60s 超时 → SHUTDOWN
# SHUTDOWN: 发 summary 给 Lead
BUS.send(name, "lead", summary, "result")
```
关键设计:
- **外层 while True**:WORK 和 IDLE 交替进行,直到超时或收到关机请求
- **内层 for 10**WORK 阶段最多 10 轮 LLM 调用(防止无限循环)
- **IDLE 超时 60 秒**:12 次轮询 × 5 秒 = 60 秒。超时后发送 summary 并退出
- **shutdown_request 两阶段都能响应**WORK 阶段通过 `handle_inbox_message` 分发;IDLE 阶段 `idle_poll` 直接检查并回复
### 进阶(选学):身份重注入
autoCompacts08)之后,队友的 messages 列表可能被压缩成一段摘要。每次进入新的 WORK 阶段时检查:
```python
if len(messages) <= 3:
messages.insert(0, {"role": "user",
"content": f"<identity>You are '{name}', role: {role}. "
f"Continue your work.</identity>"})
```
消息过短说明发生了压缩,此时重新注入身份信息。真实 CC 中 context compaction 会保留 system prompt,教学版的简化实现需要手动处理。
### consume_lead_inbox: 统一 inbox 消费
`check_inbox` 工具和主循环末尾都调用同一个 `consume_lead_inbox()` 函数:先路由协议 response 更新状态,再把所有消息注入 Lead 的对话历史。队友发来的 summary/result 不会只打印在终端,Lead 的 LLM 能看到并协调下一步。
### 合起来跑
```
1. Lead: "搭建后端——任务太多,让队友自己认领"
2. Lead → create_task("创建数据库 schema")
3. Lead → create_task("写 API 路由")
4. Lead → create_task("写单元测试")
5. Lead → spawn_teammate("alice", "backend", "你是后端开发者")
6. Lead → spawn_teammate("bob", "backend", "你是后端开发者")
7. alice 线程启动 → WORK: 没有初始 inbox → 空转 → IDLE
8. bob 线程启动 → WORK: 没有初始 inbox → 空转 → IDLE
9. alice IDLE 第 1 次轮询 → scan_unclaimed → 发现"创建数据库 schema"
10. alice → claim_task → "创建数据库 schema" → 回到 WORK
11. bob IDLE 第 1 次轮询 → scan_unclaimed → 发现"写 API 路由"
12. bob → claim_task → "写 API 路由" → 回到 WORK
13. alice WORK: write_file("schema.sql", ...) → complete_task → WORK 结束
14. alice IDLE → scan → "写单元测试" → claim → WORK
15. alice WORK: write_file("test_api.py", ...) → complete_task → WORK 结束
16. alice IDLE → 60s 无新任务 → SHUTDOWN
17. bob 类似流程 → 做完 → SHUTDOWN
18. Lead consume_lead_inbox → 看到 alice 和 bob 的 summary
```
两个队友并行认领、并行工作。Lead 只需要创建任务和启动队友,不需要手动分配。
---
## 相对 s16 的变更
| 组件 | 之前 (s16) | 之后 (s17) |
|------|-----------|-----------|
| 任务分配 | Lead 手动 assign | 队友自动认领(can_start 检查依赖) |
| 队友状态 | WORK → IDLE(每 1s 轮询 inbox)→ WORK / SHUTDOWN | WORK → IDLE(每 5s 轮询 inbox + 任务板,60s 超时)→ WORK / SHUTDOWN |
| claim_task | 无 owner 检查 | 拒绝已有 owner 的任务 |
| IDLE 阶段关机 | 收到 shutdown_request 后退出 | 直接 dispatch shutdown 并退出 |
| Lead inbox | consume_lead_inbox 路由协议响应并注入上下文 | 沿用 consume_lead_inbox 机制 |
| 新函数 | 已有 consume_lead_inbox | idle_poll, scan_unclaimed_tasks(沿用 consume_lead_inbox |
| 身份保持 | 仅 system prompt | 压缩后自动重注入 |
| Lead 工具 | 14 | 14(不变) |
| 队友工具 | 5 | 8+ list_tasks, claim_task, complete_task |
| 队友退出条件 | WORK 完进入 IDLE,等待 shutdown_request 后退出(无超时) | 60s 无新任务或收到 shutdown_request 后退出 |
---
## 试一下
```sh
cd learn-claude-code
python s17_autonomous_agents/code.py
```
试试这个 prompt
`Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim and work.`
观察重点:队友是否自动认领了未分配的任务?有 blockedBy 依赖的任务是否在前置完成后被正确认领?空闲超时后是否自动关机?IDLE 阶段收到 shutdown_request 是否立即响应?`.tasks/` 目录下的任务状态如何变化?
---
## 接下来
队友自组织了。但 Alice 和 Bob 都在同一个目录下工作——Alice 改 `config.py`Bob 也改 `config.py`,互相覆盖。
s18 Worktree Isolation → 每个任务有自己的工作目录,互不干扰。
<details>
<summary>深入 CC 源码</summary>
> 教学说明:本章的 idle_poll + auto-claim 机制是教学设计,用统一的轮询函数演示"空闲后找活干"。CC 的实际实现是多个机制的组合,但目标一致——减少 Lead 的手动分配负担。
### 一、CC 的空闲机制:组合路径,不是单一轮询
教学版用一个 `idle_poll()` 统一处理空闲时的 inbox 检查和任务认领。CC 的实际实现是四个机制的组合:
**idle_notification**:队友完成一轮工作后,`sendIdleNotification()``inProcessRunner.ts:569-589`)向 Lead 发送空闲通知。Lead 知道队友可用了,可以分配新任务或请求关机。
**mailbox 轮询**`waitForNextPromptOrShutdown()``inProcessRunner.ts:689-868`)是一个 **500ms 轮询循环**,持续检查三类来源:pending user messages、mailbox 文件消息、task list。shutdown_request 被优先处理(`inProcessRunner.ts:768-804`),不会被普通消息饿死。
**task watcher**`useTaskListWatcher``hooks/useTaskListWatcher.ts:34-189`)用 `fs.watch()` 监听 `.claude/tasks/` 目录变化,1 秒 debounce,当新任务创建或依赖解锁时触发检查。依赖判断(`L197-207`)是"blockedBy 中没有未完成的任务",不是"blockedBy 为空"。
**主动 claim**:轮询循环内部也会调用 `tryClaimNextTask()``inProcessRunner.ts:853-860`)——在等待期间主动从 task list 领取任务。所以"队友不主动轮询任务"不准确,CC 同时有被动通知和主动认领。
### 二、任务认领:文件锁 + 原子操作
`claimTask()``utils/tasks.ts:541-612`)用 `proper-lockfile` 的任务文件锁,在锁内完成读-检查-改-写。检查项:owner 是否已存在(`L575-576`)、是否已完成(`L580-581`)、blockedBy 中是否有未完成任务(`L585-594`)。`claimTaskWithBusyCheck()``utils/tasks.ts:614-692`)用 task-list 级别锁,把 busy check 和 claim 做成原子操作,避免 TOCTOU。
`findAvailableTask()``inProcessRunner.ts:595-604`)的依赖判断也是"所有 blockedBy 已完成",用 `task.blockedBy.every(id => !unresolvedTaskIds.has(id))` 实现。`tryClaimNextTask()``inProcessRunner.ts:624-657`)在认领后把状态更新为 `in_progress`,让 UI 立即反映变化。
### 三、教学版 vs CC 对比
| 维度 | 教学版 (s17) | CC |
|------|-------------|-----|
| 空闲机制 | idle_poll 统一轮询(5s | idle_notification + 500ms mailbox 轮询 + task watcher |
| 任务发现 | scan_unclaimed_tasks(轮询) | useTaskListWatcher(文件监听)+ tryClaimNextTask(主动轮询) |
| 依赖判断 | can_start(所有 blockedBy 已完成) | findAvailableTask(同样语义) |
| 并发安全 | owner 检查(无文件锁) | proper-lockfile 任务锁 + task-list 锁 |
| shutdown 处理 | IDLE 直接分发,WORK 通过 handle_inbox_message | 500ms 轮询中优先处理 shutdown_request |
| 超时退出 | 60s 无新任务 | 无固定超时,Lead 手动 shutdown |
| 身份保持 | messages 长度检测 | context compaction 保留 system prompt |
| claim 失败处理 | 检查返回值,失败不注入 | 文件锁保证原子性 |
教学版的 `idle_poll()` 把 CC 的四个机制合并成一个轮询函数——简化合理,因为核心语义(空闲时找活干、依赖解锁后可认领、shutdown 优先)是一致的。
</details>
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
-813
View File
@@ -1,813 +0,0 @@
#!/usr/bin/env python3
"""
s17: Autonomous Agents idle poll + auto-claim + WORK/IDLE lifecycle.
Run: python s17_autonomous_agents/code.py
Need: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY
Changes from s16:
- scan_unclaimed_tasks: find pending, unowned tasks with deps completed
- idle_poll: 60s polling loop (inbox + task board), dispatches shutdown in IDLE
- claim_task: owner check + return value verification
- Teammate lifecycle: WORK IDLE SHUTDOWN
- Teammate tools: + list_tasks, claim_task, complete_task (58)
- consume_lead_inbox: unified inbox consumer for protocol + context injection
- Identity re-injection after context compression
ASCII lifecycle:
WORK: inbox LLM tools (tool_use? loop) (done? IDLE)
IDLE: 5s poll inbox? WORK / unclaimed? claim WORK / 60s? SHUTDOWN
"""
import os, subprocess, json, time, random, threading
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass, asdict, field
try:
import readline
readline.parse_and_bind('set bind-tty-special-chars off')
except ImportError:
pass
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
# ── Task System (from s12) ──
TASKS_DIR = WORKDIR / ".tasks"
TASKS_DIR.mkdir(exist_ok=True)
@dataclass
class Task:
id: str
subject: str
description: str
status: str
owner: str | None
blockedBy: list[str]
def _task_path(task_id: str) -> Path:
return TASKS_DIR / f"{task_id}.json"
def create_task(subject: str, description: str = "",
blockedBy: list[str] | None = None) -> Task:
task = Task(
id=f"task_{int(time.time())}_{random.randint(0, 9999):04d}",
subject=subject, description=description,
status="pending", owner=None,
blockedBy=blockedBy or [],
)
save_task(task)
return task
def save_task(task: Task):
_task_path(task.id).write_text(json.dumps(asdict(task), indent=2))
def load_task(task_id: str) -> Task:
return Task(**json.loads(_task_path(task_id).read_text()))
def list_tasks() -> list[Task]:
return [Task(**json.loads(p.read_text()))
for p in sorted(TASKS_DIR.glob("task_*.json"))]
def get_task(task_id: str) -> str:
task = load_task(task_id)
return json.dumps(asdict(task), indent=2)
def can_start(task_id: str) -> bool:
task = load_task(task_id)
for dep_id in task.blockedBy:
if not _task_path(dep_id).exists():
return False
if load_task(dep_id).status != "completed":
return False
return True
def claim_task(task_id: str, owner: str = "agent") -> str:
task = load_task(task_id)
if task.status != "pending":
return f"Task {task_id} is {task.status}, cannot claim"
if task.owner:
return f"Task {task_id} already owned by {task.owner}"
if not can_start(task_id):
deps = [d for d in task.blockedBy
if _task_path(d).exists() and load_task(d).status != "completed"]
missing = [d for d in task.blockedBy if not _task_path(d).exists()]
parts = []
if deps: parts.append(f"blocked by: {deps}")
if missing: parts.append(f"missing deps: {missing}")
return "Cannot start — " + ", ".join(parts)
task.owner = owner
task.status = "in_progress"
save_task(task)
print(f" \033[36m[claim] {task.subject} → in_progress\033[0m")
return f"Claimed {task.id} ({task.subject})"
def complete_task(task_id: str) -> str:
task = load_task(task_id)
if task.status != "in_progress":
return f"Task {task_id} is {task.status}, cannot complete"
task.status = "completed"
save_task(task)
unblocked = [t.subject for t in list_tasks()
if t.status == "pending" and t.blockedBy and can_start(t.id)]
print(f" \033[32m[complete] {task.subject}\033[0m")
msg = f"Completed {task.id} ({task.subject})"
if unblocked:
msg += f"\nUnblocked: {', '.join(unblocked)}"
return msg
# ── Prompt Assembly (from s10) ──
PROMPT_SECTIONS = {
"identity": "You are a coding agent. Act, don't explain.",
"tools": "Available tools: bash, read_file, write_file, "
"create_task, list_tasks, get_task, claim_task, complete_task, "
"spawn_teammate, send_message, check_inbox, "
"request_shutdown, request_plan, review_plan.",
"workspace": f"Working directory: {WORKDIR}",
"memory": "Relevant memories are injected below when available.",
}
def assemble_system_prompt(context: dict) -> str:
sections = [PROMPT_SECTIONS["identity"],
PROMPT_SECTIONS["tools"],
PROMPT_SECTIONS["workspace"]]
if context.get("memories"):
sections.append(f"Relevant memories:\n{context['memories']}")
return "\n\n".join(sections)
_last_context_hash, _last_prompt = None, None
def get_system_prompt(context: dict) -> str:
global _last_context_hash, _last_prompt
h = json.dumps(context, sort_keys=True)
if h == _last_context_hash and _last_prompt:
return _last_prompt
_last_context_hash, _last_prompt = h, assemble_system_prompt(context)
return _last_prompt
# ── Tools (from s15) ──
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int | None = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
return "\n".join(lines)
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes to {path}"
except Exception as e:
return f"Error: {e}"
# ── MessageBus (from s15) ──
MAILBOX_DIR = WORKDIR / ".mailboxes"
MAILBOX_DIR.mkdir(exist_ok=True)
class MessageBus:
def send(self, from_agent: str, to_agent: str, content: str,
msg_type: str = "message", metadata: dict = None):
msg = {"from": from_agent, "to": to_agent,
"content": content, "type": msg_type,
"ts": time.time(), "metadata": metadata or {}}
inbox = MAILBOX_DIR / f"{to_agent}.jsonl"
with open(inbox, "a") as f:
f.write(json.dumps(msg) + "\n")
print(f" \033[33m[bus] {from_agent}{to_agent}: "
f"({msg_type}) {content[:50]}\033[0m")
def read_inbox(self, agent: str) -> list[dict]:
inbox = MAILBOX_DIR / f"{agent}.jsonl"
if not inbox.exists():
return []
msgs = [json.loads(line) for line in inbox.read_text().splitlines()
if line.strip()]
inbox.unlink()
return msgs
BUS = MessageBus()
active_teammates: dict[str, bool] = {}
# ── Protocol State (from s16) ──
@dataclass
class ProtocolState:
request_id: str
type: str
sender: str
target: str
status: str
payload: str
created_at: float = field(default_factory=time.time)
pending_requests: dict[str, ProtocolState] = {}
def new_request_id() -> str:
return f"req_{random.randint(0, 999999):06d}"
def match_response(response_type: str, request_id: str, approve: bool):
"""Correlate a response to the original request via request_id."""
state = pending_requests.get(request_id)
if not state:
print(f" \033[31m[protocol] unknown request_id: {request_id}\033[0m")
return
if state.type == "shutdown" and response_type != "shutdown_response":
print(f" \033[31m[protocol] type mismatch: expected shutdown_response, "
f"got {response_type}\033[0m")
return
if state.type == "plan_approval" and response_type != "plan_approval_response":
print(f" \033[31m[protocol] type mismatch: expected plan_approval_response, "
f"got {response_type}\033[0m")
return
state.status = "approved" if approve else "rejected"
icon = "" if approve else ""
color = "32" if approve else "31"
print(f" \033[{color}m[protocol] {state.type} {icon} "
f"({request_id}: {state.status})\033[0m")
# ── Autonomous Agent (s17 new) ──
IDLE_POLL_INTERVAL = 5 # seconds
IDLE_TIMEOUT = 60 # seconds
def scan_unclaimed_tasks() -> list[dict]:
"""Find pending, unowned tasks with all dependencies completed."""
unclaimed = []
for f in sorted(TASKS_DIR.glob("task_*.json")):
task = json.loads(f.read_text())
if (task.get("status") == "pending"
and not task.get("owner")
and can_start(task["id"])):
unclaimed.append(task)
return unclaimed
def idle_poll(name: str, messages: list, role: str) -> str:
"""Poll for 60s. Return 'work', 'shutdown', or 'timeout'."""
for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):
time.sleep(IDLE_POLL_INTERVAL)
# Check inbox — dispatch protocol messages first
inbox = BUS.read_inbox(name)
if inbox:
# Check for shutdown_request
for msg in inbox:
if msg.get("type") == "shutdown_request":
req_id = msg.get("metadata", {}).get("request_id", "")
BUS.send(name, "lead", "Shutting down gracefully.",
"shutdown_response",
{"request_id": req_id, "approve": True})
print(f" \033[35m[protocol] {name} approved shutdown "
f"in idle ({req_id})\033[0m")
return "shutdown"
# Non-protocol inbox: inject and resume work
messages.append({"role": "user",
"content": "<inbox>" + json.dumps(inbox) + "</inbox>"})
print(f" \033[36m[idle] {name} found inbox messages\033[0m")
return "work"
# Scan task board
unclaimed = scan_unclaimed_tasks()
if unclaimed:
task = unclaimed[0]
result = claim_task(task["id"], name)
if "Claimed" in result:
messages.append({"role": "user",
"content": f"<auto-claimed>Task {task['id']}: "
f"{task['subject']}</auto-claimed>"})
print(f" \033[32m[idle] {name} auto-claimed: "
f"{task['subject']}\033[0m")
return "work"
print(f" \033[33m[idle] {name} claim failed: "
f"{result}\033[0m")
print(f" \033[31m[idle] {name} timeout ({IDLE_TIMEOUT}s)\033[0m")
return "timeout"
# ── Teammate Thread (from s15 + s16 + s17) ──
def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
if name in active_teammates:
return f"Teammate '{name}' already exists"
system = (f"You are '{name}', a {role}. "
f"Use tools to complete tasks. "
f"You can list and claim tasks from the board. "
f"Check inbox for protocol messages.")
def handle_inbox_message(name: str, msg: dict, messages: list):
"""Dispatch incoming protocol messages by type."""
msg_type = msg.get("type", "message")
meta = msg.get("metadata", {})
req_id = meta.get("request_id", "")
if msg_type == "shutdown_request":
BUS.send(name, "lead", "Shutting down gracefully.",
"shutdown_response",
{"request_id": req_id, "approve": True})
print(f" \033[35m[protocol] {name} approved shutdown "
f"({req_id})\033[0m")
return True
if msg_type == "plan_approval_response":
approve = meta.get("approve", False)
if approve:
messages.append({"role": "user",
"content": "[Plan approved] Proceed with the task."})
else:
messages.append({"role": "user",
"content": f"[Plan rejected] Feedback: {msg['content']}"})
return False
def run():
messages = [{"role": "user", "content": prompt}]
sub_tools = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"]}},
{"name": "read_file", "description": "Read file.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"]}},
{"name": "write_file", "description": "Write file.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"},
"content": {"type": "string"}},
"required": ["path", "content"]}},
{"name": "send_message",
"description": "Send message to another agent.",
"input_schema": {"type": "object",
"properties": {"to": {"type": "string"},
"content": {"type": "string"}},
"required": ["to", "content"]}},
{"name": "submit_plan",
"description": "Submit a plan for Lead approval.",
"input_schema": {"type": "object",
"properties": {"plan": {"type": "string"}},
"required": ["plan"]}},
# s17 new: teammates can list, claim, and complete tasks
{"name": "list_tasks",
"description": "List all tasks on the board.",
"input_schema": {"type": "object", "properties": {},
"required": []}},
{"name": "claim_task",
"description": "Claim a pending task.",
"input_schema": {"type": "object",
"properties": {"task_id": {"type": "string"}},
"required": ["task_id"]}},
{"name": "complete_task",
"description": "Mark an in-progress task as completed.",
"input_schema": {"type": "object",
"properties": {"task_id": {"type": "string"}},
"required": ["task_id"]}},
]
def _run_list_tasks():
tasks = list_tasks()
if not tasks:
return "No tasks."
return "\n".join(
f" {t.id}: {t.subject} [{t.status}]"
for t in tasks)
def _run_claim_task(task_id: str):
return claim_task(task_id, owner=name)
def _run_complete_task(task_id: str):
return complete_task(task_id)
sub_handlers = {
"bash": run_bash, "read_file": run_read, "write_file": run_write,
"send_message": lambda to, content: (BUS.send(name, to, content),
"Sent")[1],
"submit_plan": lambda plan: _teammate_submit_plan(name, plan),
"list_tasks": _run_list_tasks,
"claim_task": _run_claim_task,
"complete_task": _run_complete_task,
}
# Outer loop: WORK → IDLE cycle
while True:
# Identity re-injection (s17)
if len(messages) <= 3:
messages.insert(0, {"role": "user",
"content": f"<identity>You are '{name}', role: {role}. "
f"Continue your work.</identity>"})
# WORK phase
should_shutdown = False
for _ in range(10):
inbox = BUS.read_inbox(name)
for msg in inbox:
stopped = handle_inbox_message(name, msg, messages)
if stopped:
should_shutdown = True
break
if should_shutdown:
break
if inbox and not should_shutdown:
non_protocol = [m for m in inbox
if m.get("type") == "message"]
if non_protocol:
messages.append({"role": "user",
"content": f"<inbox>{json.dumps(non_protocol)}</inbox>"})
try:
response = client.messages.create(
model=MODEL, system=system, messages=messages[-20:],
tools=sub_tools, max_tokens=8000)
except Exception:
break
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type == "tool_use":
handler = sub_handlers.get(block.name)
output = handler(**block.input) if handler else "Unknown"
results.append({"type": "tool_result",
"tool_use_id": block.id,
"content": str(output)})
messages.append({"role": "user", "content": results})
if should_shutdown:
break
# IDLE phase (s17 new)
idle_result = idle_poll(name, messages, role)
if idle_result == "shutdown":
break
if idle_result == "timeout":
break
# Summary
summary = "Done."
for msg in reversed(messages):
if msg["role"] == "assistant" and isinstance(msg["content"], list):
for b in msg["content"]:
if getattr(b, "type", None) == "text":
summary = b.text
break
else:
continue
break
BUS.send(name, "lead", summary, "result")
active_teammates.pop(name, None)
print(f" \033[32m[teammate] {name} finished\033[0m")
active_teammates[name] = True
threading.Thread(target=run, daemon=True).start()
print(f" \033[36m[teammate] {name} spawned as {role}\033[0m")
return f"Teammate '{name}' spawned as {role} (autonomous)"
def _teammate_submit_plan(from_name: str, plan: str) -> str:
"""Teammate submits a plan to Lead for approval."""
req_id = new_request_id()
pending_requests[req_id] = ProtocolState(
request_id=req_id, type="plan_approval",
sender=from_name, target="lead",
status="pending", payload=plan)
BUS.send(from_name, "lead", plan,
"plan_approval_request",
{"request_id": req_id})
return f"Plan submitted ({req_id}). Waiting for approval..."
# ── Lead Protocol Tools (from s16) ──
def run_request_shutdown(teammate: str) -> str:
req_id = new_request_id()
pending_requests[req_id] = ProtocolState(
request_id=req_id, type="shutdown",
sender="lead", target=teammate,
status="pending", payload="")
BUS.send("lead", teammate, "Please shut down gracefully.",
"shutdown_request",
{"request_id": req_id})
print(f" \033[35m[protocol] shutdown_request → {teammate} "
f"({req_id})\033[0m")
return f"Shutdown request sent to {teammate} (req: {req_id})"
def run_request_plan(teammate: str, task: str) -> str:
"""Lead asks a teammate to submit a plan."""
BUS.send("lead", teammate, f"Please submit a plan for: {task}",
"message")
return f"Asked {teammate} to submit a plan"
def run_review_plan(request_id: str, approve: bool,
feedback: str = "") -> str:
state = pending_requests.get(request_id)
if not state:
return f"Request {request_id} not found"
if state.status != "pending":
return f"Request {request_id} already {state.status}"
state.status = "approved" if approve else "rejected"
BUS.send("lead", state.sender,
feedback or ("Approved" if approve else "Rejected"),
"plan_approval_response",
{"request_id": request_id, "approve": approve})
icon = "" if approve else ""
print(f" \033[32m[protocol] plan {icon} ({request_id})\033[0m")
return f"Plan {'approved' if approve else 'rejected'} ({request_id})"
# ── Basic tool handlers ──
def run_create_task(subject: str, description: str = "",
blockedBy: list[str] | None = None) -> str:
task = create_task(subject, description, blockedBy)
deps = f" (blockedBy: {', '.join(blockedBy)})" if blockedBy else ""
print(f" \033[34m[create] {task.subject}{deps}\033[0m")
return f"Created {task.id}: {task.subject}{deps}"
def run_list_tasks() -> str:
tasks = list_tasks()
if not tasks:
return "No tasks."
return "\n".join(
f" {t.id}: {t.subject} [{t.status}]"
for t in tasks)
def run_get_task(task_id: str) -> str:
return get_task(task_id)
def run_claim_task(task_id: str) -> str:
return claim_task(task_id, owner="agent")
def run_complete_task(task_id: str) -> str:
return complete_task(task_id)
def run_spawn_teammate(name: str, role: str, prompt: str) -> str:
return spawn_teammate_thread(name, role, prompt)
def run_send_message(to: str, content: str) -> str:
BUS.send("lead", to, content)
return f"Sent to {to}"
def consume_lead_inbox(route_protocol=True) -> list[dict]:
"""Read Lead inbox: route protocol responses, return all messages."""
msgs = BUS.read_inbox("lead")
if route_protocol:
for msg in msgs:
meta = msg.get("metadata", {})
req_id = meta.get("request_id", "")
msg_type = msg.get("type", "")
if req_id and msg_type.endswith("_response"):
match_response(msg_type, req_id, meta.get("approve", False))
return msgs
def run_check_inbox() -> str:
msgs = consume_lead_inbox(route_protocol=True)
if not msgs:
return "(inbox empty)"
lines = []
for m in msgs:
meta = m.get("metadata", {})
req_id = meta.get("request_id", "")
tag = f" [{m['type']} req:{req_id}]" if req_id else f" [{m['type']}]"
lines.append(f" [{m['from']}]{tag} {m['content'][:200]}")
return "\n".join(lines)
# ── Tool Definitions ──
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"},
"limit": {"type": "integer"}},
"required": ["path"]}},
{"name": "write_file", "description": "Write content to a file.",
"input_schema": {"type": "object",
"properties": {"path": {"type": "string"},
"content": {"type": "string"}},
"required": ["path", "content"]}},
{"name": "create_task",
"description": "Create a task.",
"input_schema": {"type": "object",
"properties": {"subject": {"type": "string"},
"description": {"type": "string"},
"blockedBy": {"type": "array",
"items": {"type": "string"}}},
"required": ["subject"]}},
{"name": "list_tasks",
"description": "List all tasks.",
"input_schema": {"type": "object", "properties": {}, "required": []}},
{"name": "get_task",
"description": "Get full details of a specific task.",
"input_schema": {"type": "object",
"properties": {"task_id": {"type": "string"}},
"required": ["task_id"]}},
{"name": "claim_task",
"description": "Claim a pending task.",
"input_schema": {"type": "object",
"properties": {"task_id": {"type": "string"}},
"required": ["task_id"]}},
{"name": "complete_task",
"description": "Complete an in-progress task.",
"input_schema": {"type": "object",
"properties": {"task_id": {"type": "string"}},
"required": ["task_id"]}},
{"name": "spawn_teammate",
"description": "Spawn an autonomous teammate agent.",
"input_schema": {"type": "object",
"properties": {"name": {"type": "string"},
"role": {"type": "string"},
"prompt": {"type": "string"}},
"required": ["name", "role", "prompt"]}},
{"name": "send_message",
"description": "Send message to a teammate.",
"input_schema": {"type": "object",
"properties": {"to": {"type": "string"},
"content": {"type": "string"}},
"required": ["to", "content"]}},
{"name": "check_inbox",
"description": "Check inbox for messages and protocol responses.",
"input_schema": {"type": "object", "properties": {}, "required": []}},
{"name": "request_shutdown",
"description": "Request a teammate to shut down gracefully.",
"input_schema": {"type": "object",
"properties": {"teammate": {"type": "string"}},
"required": ["teammate"]}},
{"name": "request_plan",
"description": "Ask a teammate to submit a plan for review.",
"input_schema": {"type": "object",
"properties": {"teammate": {"type": "string"},
"task": {"type": "string"}},
"required": ["teammate", "task"]}},
{"name": "review_plan",
"description": "Approve or reject a submitted plan.",
"input_schema": {"type": "object",
"properties": {
"request_id": {"type": "string"},
"approve": {"type": "boolean"},
"feedback": {"type": "string"}},
"required": ["request_id", "approve"]}},
]
TOOL_HANDLERS = {
"bash": run_bash, "read_file": run_read, "write_file": run_write,
"create_task": run_create_task, "list_tasks": run_list_tasks,
"get_task": run_get_task,
"claim_task": run_claim_task, "complete_task": run_complete_task,
"spawn_teammate": run_spawn_teammate,
"send_message": run_send_message, "check_inbox": run_check_inbox,
"request_shutdown": run_request_shutdown,
"request_plan": run_request_plan, "review_plan": run_review_plan,
}
# ── Context ──
MEMORY_DIR = WORKDIR / ".memory"
MEMORY_INDEX = MEMORY_DIR / "MEMORY.md"
def update_context(context: dict, messages: list) -> dict:
memories = ""
if MEMORY_INDEX.exists():
memories = MEMORY_INDEX.read_text()[:2000]
return {"memories": memories}
# ── Agent Loop ──
def agent_loop(messages: list, context: dict):
system = get_system_prompt(context)
while True:
try:
response = client.messages.create(
model=MODEL, system=system, messages=messages,
tools=TOOLS, max_tokens=8000)
except Exception as e:
messages.append({"role": "assistant", "content": [
{"type": "text", "text": f"[Error] {type(e).__name__}: {e}"}]})
return
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type != "tool_use":
continue
print(f"\033[36m> {block.name}\033[0m")
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else "Unknown"
print(str(output)[:300])
results.append({"type": "tool_result",
"tool_use_id": block.id, "content": output})
messages.append({"role": "user", "content": results})
context = update_context(context, messages)
system = get_system_prompt(context)
if __name__ == "__main__":
print("s17: autonomous agents")
print("Enter a question, press Enter to send. Type q to quit.\n")
history = []
context = {"memories": ""}
while True:
try:
query = input("\033[36ms17 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history, context)
context = update_context(context, history)
for block in history[-1]["content"]:
if getattr(block, "type", None) == "text":
print(block.text)
elif isinstance(block, dict) and block.get("type") == "text":
print(block.get("text", ""))
# Consume lead inbox: route protocol + inject into history
inbox = consume_lead_inbox(route_protocol=True)
if inbox:
inbox_text = "\n".join(
f"From {m['from']} [{m.get('type', 'message')}]: "
f"{m['content'][:200]}" for m in inbox)
history.append({"role": "user",
"content": f"[Inbox]\n{inbox_text}"})
print()
@@ -1,8 +1,8 @@
# s18: Worktree Isolation — それぞれのディレクトリ、互いに干渉しない
# s17: Worktree Isolation — それぞれのディレクトリ、互いに干渉しない
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s16 → s17`s18` → [s19](../s19_mcp_plugin/) → s20 → s21 → s22
s01 → ... → s15 → s16`s17` → [s18](../s18_mcp_plugin/) → s19 → s20 → s21
> *"それぞれのディレクトリ、互いに干渉しない"* — タスクは目標を管理、worktree はディレクトリを管理、ID で紐付け。
>
@@ -12,11 +12,11 @@ s01 → ... → s16 → s17 → `s18` → [s19](../s19_mcp_plugin/) → s20 →
## 課題
s17 では、Alice も Bob も同じディレクトリで作業。Alice のタスクは「認証モジュールのリファクタリング」、Bob のタスクは「UI ログインページのリファクタリング」。
s16 では、Alice も Bob も同じディレクトリで作業。Alice のタスクは「認証モジュールのリファクタリング」、Bob のタスクは「UI ログインページのリファクタリング」。
Alice が `write_file("config.py", ...)` を呼び出し、Bob も `write_file("config.py", ...)` を呼び出す。両者が同じファイルを編集し、互いに上書き。クリーンなロールバックもできない——どの変更が誰のものか区別できない。
s15-s17 は「誰が何をするか」(タスクシステム)と「どう通信するか」(メッセージバス)を解決したが、「どこで作業するか」は未解決。
s15-s16 は「誰が何をするか」(タスクシステム)と「どう通信するか」(メッセージバス)を解決したが、「どこで作業するか」は未解決。
---
@@ -26,7 +26,7 @@ s15-s17 は「誰が何をするか」(タスクシステム)と「どう通
Git worktree を使うと、同じリポジトリ内に複数の独立した作業ディレクトリを作成でき、それぞれが独自のブランチを持つ。Alice は `.worktrees/auth-refactor/` で作業、Bob は `.worktrees/ui-login/` で作業——互いに干渉しない。
S17 の教学版 MessageBus、プロトコル、自認領機構を踏襲。本章の追加
s16 の MessageBus、プロトコル、自認領を引き継ぐ。本章では次を追加する
| 機能 | 目的 |
|------|------|
@@ -63,7 +63,7 @@ def bind_task_to_worktree(task_id: str, worktree_name: str):
### チームメイトツールの cwd 切り替え
教学版は各チームメイト`wt_ctx` 辞書を維持し、現在の worktree パスを追跡。チームメイトが worktree 紐付タスクを認領すると、`wt_ctx` が自動的に worktree パスに設定され、チームメイトの `bash``read_file``write_file` は worktree ディレクトリで実行される:
各チームメイトは、現在の worktree パスを記録する `wt_ctx` 辞書を持つ。worktree 紐付いたタスクを認領すると、ランタイムが `wt_ctx` を更新し、そのチームメイトの `bash``read_file``write_file`対応する worktree ディレクトリで実行される:
```python
# チームメイトスレッド内部
@@ -81,8 +81,6 @@ def _run_bash(command):
return run_bash(command, cwd=wt_ctx["path"]) # worktree で実行
```
これは教学簡略化。真实 CC の EnterWorktree は `process.chdir()` でプロセス全体のディレクトリを切り替え、AgentTool isolation は `cwdOverride` でサブエージェント実行をラップする。
### クリーンアップ:Keep または Remove
タスク完了後、2 つの選択肢:
@@ -118,7 +116,7 @@ def log_event(event_type: str, worktree_name: str, task_id: str = ""):
# .worktrees/events.jsonl に append
```
イベントタイプ`create``remove``keep`教学版はイベントを記録するだけで手動監査用。完全な復元には index または `git worktree list` スキャンが必要
イベントタイプ`create``remove``keep`ログは手動監査に使い、復元時は `git worktree list` から現在の worktree 一覧を再構築できる
### run_git:成功/失敗を返す
@@ -132,9 +130,9 @@ def run_git(args: list[str]) -> tuple[bool, str]:
---
## s17 からの変更
## s16 からの変更
| コンポーネント | 変更前 (s17) | 変更後 (s18) |
| コンポーネント | 変更前 (s16) | 変更後 (s17) |
|--------------|------------|------------|
| 作業ディレクトリ | 全 Agent が WORKDIR を共有 | 各タスクが git worktree に紐付け可能 |
| タスクデータ | id/subject/status/owner/blockedBy | + worktree フィールド |
@@ -142,8 +140,8 @@ def run_git(args: list[str]) -> tuple[bool, str]:
| 新規関数 | — | create_worktree, bind_task_to_worktree, remove_worktree, keep_worktree, validate_worktree_name |
| worktree 安全性 | なし | name 検証 + 変更ありの場合削除拒否 |
| イベントログ | なし | events.jsonl ライフサイクル監査 |
| Lead ツール | 14 (s17) | + create_worktree, remove_worktree, keep_worktree (17) |
| チームメイトツール | 8 (s17) | 8bash/read/write worktree cwd で実行) |
| Lead ツール | チーム・タスクツール | + create_worktreeremove_worktreekeep_worktree |
| チームメイトツール | タスク・ファイルツール | ツールは同じ。bash/read/write は認領した worktree cwd を使う |
---
@@ -151,12 +149,12 @@ def run_git(args: list[str]) -> tuple[bool, str]:
```sh
cd learn-claude-code
python s18_worktree_isolation/code.py
python s17_worktree_isolation/code.py
```
以下のプロンプトを試してください:
`Create two tasks, then create worktrees for each (bind with task_id). Spawn alice and bob. Watch them auto-claim and work in isolated directories.`
`認証モジュールとログインページを並行してリファクタリングし、変更が互いに干渉しないようにしてください。`
観察ポイント:2 つの worktree の `git status` 出力は異なるブランチを表示しているか?チームメイトが worktree 紐付けタスクを認領後、bash コマンドは worktree ディレクトリで実行されているか?`remove_worktree` は変更がある場合に拒否するか?紐付け後のタスク状態は `pending` のままか?
@@ -168,41 +166,7 @@ Agent チームが隔離されたワークスペースで自己組織化でき
もしユーザーが独自のツールを持っていたら?例えば社内 Jira API や独自デプロイシステム?
s19 MCP Plugin → Agent にプラグインシステムを追加。外部ツールが標準プロトコルで接続、Agent は誰が書いたか知る必要がない。
s18 MCP Plugin → Agent にプラグインシステムを追加。外部ツールが標準プロトコルで接続、Agent は誰が書いたか知る必要がない。
<details>
<summary>CC ソースコード深掘り</summary>
CC の worktree システムには 2 つのパスがある:**EnterWorktree**(現在のセッションが切り替え)と **AgentTool isolation**(サブエージェント隔離)。
### EnterWorktree:現在のセッション切り替え
`EnterWorktreeTool.ts:92-97` worktree 作成後、直ちに `process.chdir(worktreePath)``setCwd()``setOriginalCwd()``saveWorktreeState()` を呼び出し。現在のセッションの作業ディレクトリが直接 worktree に切り替わる——プロンプトのヒントではなく、プロセスレベルのディレクトリ変更。
`ExitWorktreeTool.ts:261-320` keep/remove どちらも `restoreSessionToOriginalCwd()` で元のディレクトリに復元。Remove は未コミット変更をチェック(`ExitWorktreeTool.ts:190-220`)、`discard_changes: true` なしでは拒否。
### AgentTool Isolation:サブエージェント隔離
`AgentTool.tsx:590-641` `isolation: "worktree"` の場合、`createAgentWorktree()` を呼び出して worktree を作成し、`cwdOverridePath` でサブエージェント実行をラップ。サブエージェントの全操作が自動的に worktree ディレクトリで実行される。`AgentTool/prompt.ts:272` はモデルに伝える:これは一時的な worktree、変更なしで自動クリーンアップ、変更ありの場合はパスとブランチを返す。
`worktree.ts:902-951` `createAgentWorktree()` はグローバル session cwd を変更せず、サブエージェント専用。`worktree.ts:961-1020` `removeAgentWorktree()` はメインリポジトリルートから削除。
### name 検証
`worktree.ts:76-84` slug を検証:`.`/`..` を拒否、`[a-zA-Z0-9._-]` を許可。`worktree.ts:48``VALID_WORKTREE_SLUG_SEGMENT` を定義。教学版の `validate_worktree_name` も同じルールを使用。
### パスとブランチ命名
実際のパスは `.claude/worktrees/`、ブランチ名は `worktree-{slug}``worktree.ts:204-227`、スラッシュは `+` に置換)。教学版は `.worktrees/``wt/{name}` で簡略化。
作成時は `git worktree add -B``worktree.ts:326-328`)を使用し、現在の HEAD より `origin/<defaultBranch>` を優先。
### 状態管理
CC にはタスク-worktree 紐付けがない。Worktree 状態は `PersistedWorktreeSession``worktree.ts:756-768`)で管理、フィールドは `originalCwd``worktreePath``worktreeName``worktreeBranch``originalBranch``originalHeadCommit``sessionId` 等を含む——taskId フィールドはない。`saveWorktreeState()``sessionStorage.ts:2883-2920`)は `type: 'worktree-state'` で session transcript に書き込み。
教学版はタスクの `worktree` フィールドで紐付けを行う教学簡略化。CC は worktree とタスクを 2 つの独立システムとして扱い、Agent のコンテキスト理解で関連付ける。
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
@@ -1,8 +1,8 @@
# s18: Worktree Isolation — Separate Directories, No Conflicts
# s17: Worktree Isolation — Separate Directories, No Conflicts
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s16 → s17`s18` → [s19](../s19_mcp_plugin/) → s20 → s21 → s22
s01 → ... → s15 → s16`s17` → [s18](../s18_mcp_plugin/) → s19 → s20 → s21
> *"Separate directories, no conflicts"* — Tasks own the goal, worktrees own the directory, bound by ID.
>
@@ -12,11 +12,11 @@ s01 → ... → s16 → s17 → `s18` → [s19](../s19_mcp_plugin/) → s20 →
## The Problem
In s17, Alice and Bob both work in the same directory. Alice's task is "refactor auth module", Bob's task is "refactor UI login page".
In s16, Alice and Bob both work in the same directory. Alice's task is "refactor auth module", Bob's task is "refactor UI login page".
Alice calls `write_file("config.py", ...)`. Bob also calls `write_file("config.py", ...)`. Both edit the same file, overwriting each other. And there's no clean rollback — you can't tell whose changes are whose.
s15-s17 solved "who does what" (task system) and "how to communicate" (message bus), but not "where to work".
s15-s16 solved "who does what" (task system) and "how to communicate" (message bus), but not "where to work".
---
@@ -26,7 +26,7 @@ s15-s17 solved "who does what" (task system) and "how to communicate" (message b
Git worktree lets you create multiple independent working directories in the same repo, each with its own branch. Alice works in `.worktrees/auth-refactor/`, Bob in `.worktrees/ui-login/` — no conflicts.
Carries forward S17's teaching-version MessageBus, protocols, and autonomous claiming. This chapter adds:
Carries forward s16's MessageBus, protocols, and autonomous claiming. This chapter adds:
| Capability | Purpose |
|------------|---------|
@@ -63,7 +63,7 @@ Binding rule: one task binds to one worktree. Binding does NOT change task statu
### Teammate Tool Cwd Switching
Teaching version maintains a `wt_ctx` dict per teammate, tracking the current worktree path. When a teammate claims a task with a worktree, `wt_ctx` is automatically set to the worktree path; the teammate's `bash`, `read_file`, `write_file` execute in the worktree directory:
Each teammate keeps a `wt_ctx` dictionary with its current worktree path. When a teammate claims a task bound to a worktree, the runtime updates `wt_ctx`; that teammate's `bash`, `read_file`, and `write_file` calls then run in the worktree directory:
```python
# Inside teammate thread
@@ -81,8 +81,6 @@ def _run_bash(command):
return run_bash(command, cwd=wt_ctx["path"]) # Execute in worktree
```
This is a teaching simplification. Real CC's EnterWorktree uses `process.chdir()` to switch the entire process directory, and AgentTool isolation uses `cwdOverride` to wrap sub-agent execution.
### Cleanup: Keep or Remove
After task completion, two choices:
@@ -118,7 +116,7 @@ def log_event(event_type: str, worktree_name: str, task_id: str = ""):
# append to .worktrees/events.jsonl
```
Event types: `create`, `remove`, `keep`. Teaching version logs events for manual auditing; full recovery would need an index or `git worktree list` scanning.
Event types are `create`, `remove`, and `keep`. The log supports manual auditing; a recovery flow can rebuild the current set from `git worktree list`.
### run_git: Returns Success/Failure
@@ -132,9 +130,9 @@ def run_git(args: list[str]) -> tuple[bool, str]:
---
## Changes from s17
## Changes from s16
| Component | Before (s17) | After (s18) |
| Component | Before (s16) | After (s17) |
|-----------|-------------|-------------|
| Working directory | All agents share WORKDIR | Each task can bind to a git worktree |
| Task data | id/subject/status/owner/blockedBy | + worktree field |
@@ -142,8 +140,8 @@ def run_git(args: list[str]) -> tuple[bool, str]:
| New functions | — | create_worktree, bind_task_to_worktree, remove_worktree, keep_worktree, validate_worktree_name |
| Worktree safety | None | Name validation + refuse removal with changes |
| Event log | None | events.jsonl lifecycle auditing |
| Lead tools | 14 (s17) | + create_worktree, remove_worktree, keep_worktree (17) |
| Teammate tools | 8 (s17) | 8 (bash/read/write execute in worktree cwd) |
| Lead tools | Team and task tools | + create_worktree, remove_worktree, keep_worktree |
| Teammate tools | Task and file tools | Same tools; bash/read/write use the claimed worktree cwd |
---
@@ -151,12 +149,12 @@ def run_git(args: list[str]) -> tuple[bool, str]:
```sh
cd learn-claude-code
python s18_worktree_isolation/code.py
python s17_worktree_isolation/code.py
```
Try this prompt:
`Create two tasks, then create worktrees for each (bind with task_id). Spawn alice and bob. Watch them auto-claim and work in isolated directories.`
`Refactor the authentication module and the login page in parallel without letting the changes interfere with each other.`
What to observe: Do both worktrees show different branches in `git status`? After claiming a worktree-bound task, does the teammate's bash run in the worktree directory? Does `remove_worktree` refuse when there are changes? Is task status still `pending` after binding?
@@ -168,41 +166,7 @@ Agent teams can now self-organize in isolated workspaces. But Agent capabilities
What if users already have their own tools? Like an internal Jira API, or a custom deployment system?
s19 MCP Plugin → Give Agent a plugin system. External tools connect via standard protocol; Agent doesn't need to know who wrote them.
s18 MCP Plugin → Give Agent a plugin system. External tools connect via standard protocol; Agent doesn't need to know who wrote them.
<details>
<summary>Deep Dive into CC Source</summary>
CC's worktree system has two paths: **EnterWorktree** (current session switches in) and **AgentTool isolation** (sub-agent isolation).
### EnterWorktree: Current Session Switch
`EnterWorktreeTool.ts:92-97` after creating the worktree, immediately calls `process.chdir(worktreePath)`, `setCwd()`, `setOriginalCwd()`, `saveWorktreeState()`. The current session's working directory switches directly to the worktree — not a prompt hint, but a process-level directory change.
`ExitWorktreeTool.ts:261-320` both keep and remove call `restoreSessionToOriginalCwd()` to restore the original directory. Remove checks for uncommitted changes (`ExitWorktreeTool.ts:190-220`), refusing without `discard_changes: true`.
### AgentTool Isolation: Sub-Agent Isolation
`AgentTool.tsx:590-641` when `isolation: "worktree"`, calls `createAgentWorktree()` to create a worktree, uses `cwdOverridePath` to wrap sub-agent execution. All sub-agent operations automatically run in the worktree directory. `AgentTool/prompt.ts:272` tells the model: this is a temporary worktree, auto-cleanup if no changes, return path and branch if changes exist.
`worktree.ts:902-951` `createAgentWorktree()` does NOT modify global session cwd, only for sub-agent use. `worktree.ts:961-1020` `removeAgentWorktree()` deletes from the main repo root.
### Name Validation
`worktree.ts:76-84` validates slug: rejects `.`/`..`, allows `[a-zA-Z0-9._-]`. `worktree.ts:48` defines `VALID_WORKTREE_SLUG_SEGMENT`. Teaching version's `validate_worktree_name` uses the same rule.
### Path and Branch Naming
Real path is `.claude/worktrees/`, branch name `worktree-{slug}` (`worktree.ts:204-227`, slashes replaced with `+`). Teaching version uses `.worktrees/` and `wt/{name}` for simplicity.
Creation uses `git worktree add -B` (`worktree.ts:326-328`), preferring `origin/<defaultBranch>` over current HEAD.
### State Management
CC has no task-worktree binding. Worktree state is managed through `PersistedWorktreeSession` (`worktree.ts:756-768`), with fields including `originalCwd`, `worktreePath`, `worktreeName`, `worktreeBranch`, `originalBranch`, `originalHeadCommit`, `sessionId`, etc. — no taskId field. `saveWorktreeState()` (`sessionStorage.ts:2883-2920`) writes to session transcript with `type: 'worktree-state'`.
Teaching version uses the task's `worktree` field for binding, a teaching simplification. CC treats worktree and task as two independent systems, connected through the Agent's context understanding.
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v0 -->
@@ -1,8 +1,8 @@
# s18: Worktree Isolation — 各干各的,互不干扰
# s17: Worktree Isolation — 各干各的,互不干扰
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s16 → s17`s18` → [s19](../s19_mcp_plugin/) → s20 → s21 → s22
s01 → ... → s15 → s16`s17` → [s18](../s18_mcp_plugin/) → s19 → s20 → s21
> *"各干各的目录, 互不干扰"* — 任务管目标, worktree 管目录, 按 ID 绑定。
>
@@ -12,11 +12,11 @@ s01 → ... → s16 → s17 → `s18` → [s19](../s19_mcp_plugin/) → s20 →
## 问题
s17 中,Alice 和 Bob 都在同一个目录下工作。Alice 的任务是"重构认证模块"Bob 的任务是"重构 UI 登录页"。
s16 中,Alice 和 Bob 都在同一个目录下工作。Alice 的任务是"重构认证模块"Bob 的任务是"重构 UI 登录页"。
Alice `write_file("config.py", ...)`。Bob 也 `write_file("config.py", ...)`。两个人改同一个文件,互相覆盖。而且无法干净地回滚——分不清哪些改动是谁的。
s15-s17 解决了"谁干什么"(任务系统)和"怎么通信"(消息总线),但没解决"在哪干"。
s15-s16 解决了"谁干什么"(任务系统)和"怎么通信"(消息总线),但没解决"在哪干"。
---
@@ -26,7 +26,7 @@ s15-s17 解决了"谁干什么"(任务系统)和"怎么通信"(消息总
Git worktree 让你在同一仓库中创建多个独立的工作目录,每个有自己的分支。Alice 在 `.worktrees/auth-refactor/` 下工作,Bob 在 `.worktrees/ui-login/` 下工作——互不干扰。
沿用 S17 的教学版 MessageBus、协议和自治认领机制。本章新增:
沿用 s16 的 MessageBus、协议和自治认领机制。本章新增:
| 能力 | 作用 |
|------|------|
@@ -63,7 +63,7 @@ def bind_task_to_worktree(task_id: str, worktree_name: str):
### 队友工具的 cwd 切换
教学版给每个队友维护一个 `wt_ctx` 字典,记录当前 worktree 路径。队友认领 worktree 的任务时,`wt_ctx` 自动设置为 worktree 路径;队友的 `bash``read_file``write_file` worktree 目录下执行:
每个队友都有一个 `wt_ctx` 字典,用来记录当前 worktree 路径。队友认领绑定了 worktree 的任务后,运行时会更新 `wt_ctx`队友的 `bash``read_file``write_file` 随后都在对应的 worktree 目录下执行:
```python
# 队友线程内部
@@ -81,8 +81,6 @@ def _run_bash(command):
return run_bash(command, cwd=wt_ctx["path"]) # 在 worktree 下执行
```
这是教学简化。真实 CC 的 EnterWorktree 用 `process.chdir()` 切换整个进程目录,AgentTool isolation 用 `cwdOverride` 包住子 agent 执行。
### 收尾:Keep 还是 Remove
任务完成后,两个选择:
@@ -118,7 +116,7 @@ def log_event(event_type: str, worktree_name: str, task_id: str = ""):
# append to .worktrees/events.jsonl
```
事件类型`create`(创建)、`remove`(删除)`keep`(保留)。教学版只记录事件用于人工排查;完整恢复还需要 index 或 `git worktree list` 扫描
事件类型包括 `create`(创建)、`remove`(删除)`keep`(保留)。日志用于人工排查;恢复流程可以通过 `git worktree list` 重建当前 worktree 集合
### run_git:返回成功/失败
@@ -132,9 +130,9 @@ def run_git(args: list[str]) -> tuple[bool, str]:
---
## 相对 s17 的变更
## 相对 s16 的变更
| 组件 | 之前 (s17) | 之后 (s18) |
| 组件 | 之前 (s16) | 之后 (s17) |
|------|-----------|-----------|
| 工作目录 | 所有 Agent 共享 WORKDIR | 每个任务可绑定独立 git worktree |
| Task 数据 | id/subject/status/owner/blockedBy | + worktree 字段 |
@@ -142,8 +140,8 @@ def run_git(args: list[str]) -> tuple[bool, str]:
| 新函数 | — | create_worktree, bind_task_to_worktree, remove_worktree, keep_worktree, validate_worktree_name |
| worktree 安全 | 无 | name 校验 + 有改动时拒绝删除 |
| 事件日志 | 无 | events.jsonl 生命周期审计 |
| Lead 工具 | 14 (s17) | + create_worktree, remove_worktree, keep_worktree (17) |
| 队友工具 | 8 (s17) | 8bash/read/write worktree cwd 执行) |
| Lead 工具 | 团队与任务工具 | + create_worktreeremove_worktreekeep_worktree |
| 队友工具 | 任务与文件工具 | 工具不变,bash/read/write 使用已认领任务的 worktree cwd |
---
@@ -151,12 +149,12 @@ def run_git(args: list[str]) -> tuple[bool, str]:
```sh
cd learn-claude-code
python s18_worktree_isolation/code.py
python s17_worktree_isolation/code.py
```
试试这个 prompt
`Create two tasks, then create worktrees for each (bind with task_id). Spawn alice and bob. Watch them auto-claim and work in isolated directories.`
`请并行重构认证模块和登录页面,确保两部分改动不会互相干扰。`
观察重点:两个 worktree 的 `git status` 输出是否显示不同的分支?队友认领带 worktree 的任务后,bash 命令是否在 worktree 目录下执行?`remove_worktree` 对有改动的 worktree 是否拒绝?`.tasks/` 中的任务在绑定后状态是否仍为 `pending`
@@ -168,41 +166,7 @@ Agent 团队能在隔离的工作空间中自组织了。但 Agent 的能力受
如果用户已经有了自己的工具怎么办?比如一个公司内部的 Jira API、一个自建的部署系统?
s19 MCP Plugin → 给 Agent 装一个插件系统。外部工具通过标准协议接入,Agent 不需要知道它们是谁写的。
s18 MCP Plugin → 给 Agent 装一个插件系统。外部工具通过标准协议接入,Agent 不需要知道它们是谁写的。
<details>
<summary>深入 CC 源码</summary>
CC 的 worktree 系统有两条路径:**EnterWorktree**(当前会话切入)和 **AgentTool isolation**(子 agent 隔离)。
### EnterWorktree:当前会话切换
`EnterWorktreeTool.ts:92-97` 创建 worktree 后立即 `process.chdir(worktreePath)``setCwd()``setOriginalCwd()``saveWorktreeState()`。当前会话的工作目录直接切换到 worktree——不是 prompt 提醒,而是进程级目录变更。
`ExitWorktreeTool.ts:261-320` 的 keep/remove 都会 `restoreSessionToOriginalCwd()` 恢复原目录。Remove 时检查未提交改动(`ExitWorktreeTool.ts:190-220`),没有 `discard_changes: true` 就拒绝删除。
### AgentTool isolation:子 agent 隔离
`AgentTool.tsx:590-641``isolation: "worktree"` 时调用 `createAgentWorktree()` 创建 worktree,用 `cwdOverridePath` 包住子 agent 执行。子 agent 的所有操作自动在 worktree 目录下进行。`AgentTool/prompt.ts:272` 告诉模型:这是临时 worktree,无改动自动清理,有改动返回路径和分支。
`worktree.ts:902-951``createAgentWorktree()` 不修改全局 session cwd,只给子 agent 用。`worktree.ts:961-1020``removeAgentWorktree()` 从主 repo root 删除。
### name 校验
`worktree.ts:76-84` 校验 slug:拒绝 `.`/`..`,允许 `[a-zA-Z0-9._-]``worktree.ts:48` 定义 `VALID_WORKTREE_SLUG_SEGMENT`。教学版的 `validate_worktree_name` 用同样的规则。
### 路径和分支命名
真实路径是 `.claude/worktrees/`,分支名 `worktree-{slug}``worktree.ts:204-227`,斜杠用 `+` 替代)。教学版用 `.worktrees/``wt/{name}` 简化。
创建时用 `git worktree add -B``worktree.ts:326-328`),优先基于 `origin/<defaultBranch>` 而非当前 HEAD。
### 状态管理
CC 没有 task-worktree 绑定。Worktree 状态通过 `PersistedWorktreeSession``worktree.ts:756-768`)管理,字段包括 `originalCwd``worktreePath``worktreeName``worktreeBranch``originalBranch``originalHeadCommit``sessionId` 等——没有 taskId。`saveWorktreeState()``sessionStorage.ts:2883-2920`)以 `type: 'worktree-state'` 写入 session transcript。
教学版用 task 的 `worktree` 字段做绑定,是教学简化。CC 把 worktree 和 task 作为两个独立系统,通过 Agent 理解上下文来关联。
</details>
<!-- translation-sync: zh@v1, en@v0, ja@v0 -->
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
"""
s18: Worktree Isolation git worktree + task-directory binding + event log.
s17: Worktree Isolation git worktree + task-directory binding + event log.
Run: python s18_worktree_isolation/code.py
Run: python s17_worktree_isolation/code.py
Need: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY
Changes from s17:
Changes from s16:
- Task dataclass gains worktree field (str | None)
- validate_worktree_name: reject path traversal and illegal chars
- create_worktree: validate name, git worktree add, optional task binding
@@ -14,7 +14,7 @@ Changes from s17:
- run_git returns (ok, output), events only on success
- Teammate tools: + complete_task, run in worktree cwd when bound
- scan_unclaimed_tasks: uses can_start() for dependency checking
- idle_poll: checks claim result, dispatches shutdown in IDLE
- Idle teammates wait for messages, then scan and claim ready tasks
- consume_lead_inbox: unified inbox consumer
- 3 new Lead tools: create_worktree, remove_worktree, keep_worktree
@@ -26,7 +26,7 @@ ASCII topology:
.worktrees/events.jsonl
"""
import os, subprocess, json, time, random, threading, re
import os, subprocess, json, time, random, threading, queue, re
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass, asdict, field
@@ -48,10 +48,11 @@ WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
# ── Task System (from s12 + s18 worktree field) ──
# ── Task System (from s12 + s17 worktree field) ──
TASKS_DIR = WORKDIR / ".tasks"
TASKS_DIR.mkdir(exist_ok=True)
task_lock = threading.RLock()
@dataclass
@@ -62,7 +63,7 @@ class Task:
status: str
owner: str | None
blockedBy: list[str]
worktree: str | None = None # s18: bound worktree name
worktree: str | None = None # s17: bound worktree name
def _task_path(task_id: str) -> Path:
@@ -110,22 +111,27 @@ def can_start(task_id: str) -> bool:
def claim_task(task_id: str, owner: str = "agent") -> str:
task = load_task(task_id)
if task.status != "pending":
return f"Task {task_id} is {task.status}, cannot claim"
if task.owner:
return f"Task {task_id} already owned by {task.owner}"
if not can_start(task_id):
deps = [d for d in task.blockedBy
if _task_path(d).exists() and load_task(d).status != "completed"]
missing = [d for d in task.blockedBy if not _task_path(d).exists()]
parts = []
if deps: parts.append(f"blocked by: {deps}")
if missing: parts.append(f"missing deps: {missing}")
return "Cannot start — " + ", ".join(parts)
task.owner = owner
task.status = "in_progress"
save_task(task)
with task_lock:
task = load_task(task_id)
if task.status != "pending":
return f"Task {task_id} is {task.status}, cannot claim"
if task.owner:
return f"Task {task_id} already owned by {task.owner}"
if not can_start(task_id):
deps = [d for d in task.blockedBy
if (_task_path(d).exists()
and load_task(d).status != "completed")]
missing = [d for d in task.blockedBy
if not _task_path(d).exists()]
parts = []
if deps:
parts.append(f"blocked by: {deps}")
if missing:
parts.append(f"missing deps: {missing}")
return "Cannot start: " + ", ".join(parts)
task.owner = owner
task.status = "in_progress"
save_task(task)
print(f" \033[36m[claim] {task.subject} → in_progress\033[0m")
return f"Claimed {task.id} ({task.subject})"
@@ -145,7 +151,7 @@ def complete_task(task_id: str) -> str:
return msg
# ── Worktree System (s18 new) ──
# ── Worktree System (s17 new) ──
WORKTREES_DIR = WORKDIR / ".worktrees"
WORKTREES_DIR.mkdir(exist_ok=True)
@@ -269,9 +275,14 @@ PROMPT_SECTIONS = {
"identity": "You are a coding agent. Act, don't explain.",
"tools": "Available tools: bash, read_file, write_file, "
"create_task, list_tasks, get_task, claim_task, complete_task, "
"spawn_teammate, send_message, check_inbox, "
"spawn_teammate, send_message, "
"request_shutdown, request_plan, review_plan, "
"create_worktree, remove_worktree, keep_worktree.",
"teams": (
"When parallel work would help, first propose a small team with clear "
"responsibilities and wait for the user's confirmation. Do not call "
"spawn_teammate before the user confirms."
),
"workspace": f"Working directory: {WORKDIR}",
"memory": "Relevant memories are injected below when available.",
}
@@ -280,6 +291,7 @@ PROMPT_SECTIONS = {
def assemble_system_prompt(context: dict) -> str:
sections = [PROMPT_SECTIONS["identity"],
PROMPT_SECTIONS["tools"],
PROMPT_SECTIONS["teams"],
PROMPT_SECTIONS["workspace"]]
if context.get("memories"):
sections.append(f"Relevant memories:\n{context['memories']}")
@@ -342,22 +354,29 @@ def run_write(path: str, content: str, cwd: Path = None) -> str:
MAILBOX_DIR = WORKDIR / ".mailboxes"
MAILBOX_DIR.mkdir(exist_ok=True)
MAILBOX_ROOT = MAILBOX_DIR.resolve()
VALID_AGENT_NAME = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
def is_valid_agent_name(name: str) -> bool:
return bool(VALID_AGENT_NAME.fullmatch(name))
class MessageBus:
def send(self, from_agent: str, to_agent: str, content: str,
msg_type: str = "message", metadata: dict = None):
msg = {"from": from_agent, "to": to_agent,
"content": content, "type": msg_type,
"ts": time.time(), "metadata": metadata or {}}
inbox = MAILBOX_DIR / f"{to_agent}.jsonl"
with open(inbox, "a") as f:
f.write(json.dumps(msg) + "\n")
print(f" \033[33m[bus] {from_agent}{to_agent}: "
f"({msg_type}) {content[:50]}\033[0m")
def __init__(self):
self._lock = threading.RLock()
self._changed = threading.Condition(self._lock)
def read_inbox(self, agent: str) -> list[dict]:
inbox = MAILBOX_DIR / f"{agent}.jsonl"
def _path(self, agent: str) -> Path:
if not is_valid_agent_name(agent):
raise ValueError(f"Invalid mailbox recipient: {agent!r}")
path = (MAILBOX_DIR / f"{agent}.jsonl").resolve()
if not path.is_relative_to(MAILBOX_ROOT):
raise ValueError(f"Mailbox path escapes directory: {agent!r}")
return path
def _read_unlocked(self, agent: str) -> list[dict]:
inbox = self._path(agent)
if not inbox.exists():
return []
msgs = [json.loads(line) for line in inbox.read_text().splitlines()
@@ -365,11 +384,47 @@ class MessageBus:
inbox.unlink()
return msgs
def send(self, from_agent: str, to_agent: str, content: str,
msg_type: str = "message", metadata: dict | None = None):
msg = {"from": from_agent, "to": to_agent,
"content": content, "type": msg_type,
"ts": time.time(), "metadata": metadata or {}}
with self._changed:
with open(self._path(to_agent), "a") as f:
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
self._changed.notify_all()
print(f" \033[33m[bus] {from_agent}{to_agent}: "
f"({msg_type}) {content[:50]}\033[0m")
def read_inbox(self, agent: str) -> list[dict]:
with self._lock:
return self._read_unlocked(agent)
def peek(self, agent: str) -> bool:
with self._lock:
inbox = self._path(agent)
return inbox.exists() and inbox.stat().st_size > 0
def wait_for_messages(self, agent: str,
timeout: float | None = None) -> list[dict]:
deadline = None if timeout is None else time.monotonic() + timeout
with self._changed:
while not self.peek(agent):
remaining = (None if deadline is None
else deadline - time.monotonic())
if remaining is not None and remaining <= 0:
return []
self._changed.wait(remaining)
return self._read_unlocked(agent)
BUS = MessageBus()
active_teammates: dict[str, bool] = {}
active_teammates: dict[str, str] = {}
plan_gates: dict[str, str] = {}
plan_request_ids: dict[str, str] = {}
team_lock = threading.RLock()
# ── Protocol State (from s16) ──
# ── Protocol State (from s15) ──
@dataclass
class ProtocolState:
@@ -386,27 +441,38 @@ pending_requests: dict[str, ProtocolState] = {}
def new_request_id() -> str:
return f"req_{random.randint(0, 999999):06d}"
while True:
request_id = f"req_{random.randint(0, 999999):06d}"
if request_id not in pending_requests:
return request_id
def match_response(response_type: str, request_id: str, approve: bool):
state = pending_requests.get(request_id)
if not state:
print(f" \033[31m[protocol] unknown request_id: {request_id}\033[0m")
return
if state.type == "shutdown" and response_type != "shutdown_response":
print(f" \033[31m[protocol] type mismatch: expected shutdown_response, "
f"got {response_type}\033[0m")
return
if state.type == "plan_approval" and response_type != "plan_approval_response":
print(f" \033[31m[protocol] type mismatch: expected plan_approval_response, "
f"got {response_type}\033[0m")
return
state.status = "approved" if approve else "rejected"
def match_response(response_type: str, request_id: str, approve: bool,
from_agent: str, to_agent: str) -> bool:
with team_lock:
state = pending_requests.get(request_id)
if not state:
print(f" \033[31m[protocol] unknown request_id: {request_id}\033[0m")
return False
expected = {
"shutdown": "shutdown_response",
"plan_approval": "plan_approval_response",
}[state.type]
if response_type != expected:
print(f" \033[31m[protocol] expected {expected}, "
f"got {response_type}\033[0m")
return False
if from_agent != state.target or to_agent != state.sender:
print(f" \033[31m[protocol] {request_id} responder mismatch\033[0m")
return False
if state.status != "pending":
return False
state.status = "approved" if approve else "rejected"
icon = "" if approve else ""
color = "32" if approve else "31"
print(f" \033[{color}m[protocol] {state.type} {icon} "
f"({request_id}: {state.status})\033[0m")
return True
def consume_lead_inbox(route_protocol=True) -> list[dict]:
@@ -417,83 +483,137 @@ def consume_lead_inbox(route_protocol=True) -> list[dict]:
req_id = meta.get("request_id", "")
msg_type = msg.get("type", "")
if req_id and msg_type.endswith("_response"):
match_response(msg_type, req_id, meta.get("approve", False))
match_response(msg_type, req_id, meta.get("approve", False),
msg.get("from", ""), msg.get("to", ""))
return msgs
# ── Autonomous Agent (from s17, + worktree cwd) ──
IDLE_POLL_INTERVAL = 5
IDLE_TIMEOUT = 60
def format_team_events(msgs: list[dict]) -> str:
lines = []
for msg in msgs:
request_id = msg.get("metadata", {}).get("request_id")
suffix = f" request_id={request_id}" if request_id else ""
lines.append(
f"[{msg['type']}{suffix}] {msg['from']}: {msg['content']}"
)
return "[Team events]\n" + "\n".join(lines)
def scan_unclaimed_tasks() -> list[dict]:
"""Find pending, unowned tasks with all dependencies completed."""
unclaimed = []
for f in sorted(TASKS_DIR.glob("task_*.json")):
task = json.loads(f.read_text())
if (task.get("status") == "pending"
and not task.get("owner")
and can_start(task["id"])):
unclaimed.append(task)
return unclaimed
# ── Autonomous Agent (from s16, + worktree cwd) ──
IDLE_SCAN_INTERVAL = 2.0
def idle_poll(agent_name: str, messages: list,
name: str, role: str) -> tuple[str, str | None]:
"""Poll for 60s. Return (result, auto_claimed_task_id)."""
for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):
time.sleep(IDLE_POLL_INTERVAL)
inbox = BUS.read_inbox(agent_name)
if inbox:
for msg in inbox:
if msg.get("type") == "shutdown_request":
req_id = msg.get("metadata", {}).get("request_id", "")
BUS.send(name, "lead", "Shutting down gracefully.",
"shutdown_response",
{"request_id": req_id, "approve": True})
print(f" \033[35m[protocol] {name} approved shutdown "
f"in idle ({req_id})\033[0m")
return "shutdown", None
messages.append({"role": "user",
"content": "<inbox>" + json.dumps(inbox) + "</inbox>"})
print(f" \033[36m[idle] {name} found inbox messages\033[0m")
return "work", None
unclaimed = scan_unclaimed_tasks()
if unclaimed:
task_data = unclaimed[0]
result = claim_task(task_data["id"], agent_name)
if "Claimed" in result:
wt_info = ""
if task_data.get("worktree"):
wt_path = WORKTREES_DIR / task_data["worktree"]
wt_info = f"\nWork directory: {wt_path}"
messages.append({"role": "user",
"content": f"<auto-claimed>Task {task_data['id']}: "
f"{task_data['subject']}{wt_info}</auto-claimed>"})
print(f" \033[32m[idle] {name} auto-claimed: "
f"{task_data['subject']}\033[0m")
return "work", task_data["id"]
print(f" \033[33m[idle] {name} claim failed: "
f"{result}\033[0m")
print(f" \033[31m[idle] {name} timeout ({IDLE_TIMEOUT}s)\033[0m")
return "timeout", None
def scan_unclaimed_tasks() -> list[Task]:
return [
task for task in list_tasks()
if (task.status == "pending"
and task.owner is None
and can_start(task.id))
]
# ── Teammate Thread (from s15 + s16 + s17 + s18) ──
def claim_next_task(name: str) -> Task | None:
for task in scan_unclaimed_tasks():
result = claim_task(task.id, owner=name)
if result.startswith("Claimed "):
return load_task(task.id)
return None
def _last_assistant_text(content) -> str:
for block in content:
if getattr(block, "type", None) == "text":
return block.text.strip()
if isinstance(block, dict) and block.get("type") == "text":
return str(block.get("text", "")).strip()
return ""
def _run_teammate_tool(name: str, block, handlers: dict) -> str:
gate = plan_gates.get(name, "not_required")
if (block.name in {"bash", "write_file"}
and gate not in {"not_required", "approved"}):
return f"Blocked: plan status is {gate}."
handler = handlers.get(block.name)
return str(handler(**block.input)) if handler else f"Unknown tool: {block.name}"
def apply_plan_response(name: str, msg: dict) -> tuple[bool, str]:
"""Apply only the Lead response for this teammate's current plan."""
metadata = msg.get("metadata", {})
request_id = metadata.get("request_id", "")
with team_lock:
state = pending_requests.get(request_id)
expected_id = plan_request_ids.get(name)
valid = (
msg.get("from") == "lead"
and msg.get("to") == name
and request_id == expected_id
and state is not None
and state.type == "plan_approval"
and state.sender == name
and state.target == "lead"
and state.status in {"approved", "rejected"}
and metadata.get("approve", False)
== (state.status == "approved")
)
if not valid:
return False, "[Ignored plan response: request mismatch]"
plan_gates[name] = state.status
active_teammates[name] = "working"
plan_request_ids.pop(name, None)
outcome = state.status
return True, f"[Plan {outcome}] {msg['content']}"
def apply_shutdown_request(name: str, msg: dict) -> tuple[bool, str]:
"""Accept only a pending shutdown request sent by Lead to this teammate."""
request_id = msg.get("metadata", {}).get("request_id", "")
with team_lock:
state = pending_requests.get(request_id)
valid = (
msg.get("from") == "lead"
and msg.get("to") == name
and state is not None
and state.type == "shutdown"
and state.sender == "lead"
and state.target == name
and state.status == "pending"
and active_teammates.get(name) != "stopping"
)
if not valid:
return False, "[Ignored shutdown request: request mismatch]"
active_teammates[name] = "stopping"
return True, request_id
def _teammate_send_message(from_name: str, to: str, content: str) -> str:
with team_lock:
if to != "lead" and to not in active_teammates:
return f"Agent '{to}' is not active"
BUS.send(from_name, to, content)
return f"Sent to {to}"
# ── Teammate Thread ──
def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
if name in active_teammates:
return f"Teammate '{name}' already exists"
if not is_valid_agent_name(name):
return ("Invalid teammate name: use 1-64 letters, digits, "
"underscores, or dashes")
with team_lock:
if name in active_teammates:
return f"Teammate '{name}' already exists"
active_teammates[name] = "working"
plan_gates[name] = "not_required"
system = (f"You are '{name}', a {role}. "
f"Use tools to complete tasks. "
f"You can list and claim tasks from the board. "
f"If a task has a worktree, work in that directory.")
"Use tools to complete tasks. "
"You can list and claim tasks from the board. "
"If a task has a worktree, work in that directory. "
"When asked for a plan, submit it before bash or write_file "
"and wait for approval.")
def handle_inbox_message(name: str, msg: dict, messages: list):
msg_type = msg.get("type", "message")
@@ -501,6 +621,11 @@ def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
req_id = meta.get("request_id", "")
if msg_type == "shutdown_request":
accepted, notice = apply_shutdown_request(name, msg)
if not accepted:
messages.append({"role": "user", "content": notice})
return False
req_id = notice
BUS.send(name, "lead", "Shutting down gracefully.",
"shutdown_response",
{"request_id": req_id, "approve": True})
@@ -509,13 +634,15 @@ def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
return True
if msg_type == "plan_approval_response":
approve = meta.get("approve", False)
if approve:
messages.append({"role": "user",
"content": "[Plan approved] Proceed with the task."})
else:
messages.append({"role": "user",
"content": f"[Plan rejected] Feedback: {msg['content']}"})
_, notice = apply_plan_response(name, msg)
messages.append({"role": "user",
"content": notice})
elif msg_type == "plan_request":
messages.append({"role": "user",
"content": f"[Plan required] {msg['content']}"})
elif msg_type == "message":
messages.append({"role": "user",
"content": f"[Message from {msg['from']}] {msg['content']}"})
return False
def run():
@@ -605,8 +732,8 @@ def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
sub_handlers = {
"bash": _run_bash, "read_file": _run_read,
"write_file": _run_write,
"send_message": lambda to, content: (BUS.send(name, to, content),
"Sent")[1],
"send_message": lambda to, content: _teammate_send_message(
name, to, content),
"submit_plan": lambda plan: _teammate_submit_plan(name, plan),
"list_tasks": _run_list_tasks,
"claim_task": _run_claim_task,
@@ -631,13 +758,6 @@ def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
break
if should_shutdown:
break
if inbox and not should_shutdown:
non_protocol = [m for m in inbox
if m.get("type") == "message"]
if non_protocol:
messages.append({"role": "user",
"content": "<inbox>" + json.dumps(non_protocol) + "</inbox>"})
try:
response = client.messages.create(
model=MODEL, system=system, messages=messages[-20:],
@@ -646,12 +766,23 @@ def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
break
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
summary = _last_assistant_text(response.content)
gate = plan_gates.get(name, "not_required")
if gate != "pending" and summary:
BUS.send(name, "lead", summary, "result")
if gate == "pending":
with team_lock:
active_teammates[name] = "waiting_approval"
else:
with team_lock:
active_teammates[name] = "idle"
BUS.send(name, "lead", "Waiting for more work.",
"idle_notification")
break
results = []
for block in response.content:
if block.type == "tool_use":
handler = sub_handlers.get(block.name)
output = handler(**block.input) if handler else "Unknown"
output = _run_teammate_tool(name, block, sub_handlers)
results.append({"type": "tool_result",
"tool_use_id": block.id,
"content": str(output)})
@@ -660,60 +791,78 @@ def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
if should_shutdown:
break
# IDLE phase
idle_result, claimed_task_id = idle_poll(name, messages, name, role)
if idle_result == "shutdown":
break
if idle_result == "timeout":
break
if idle_result == "work" and claimed_task_id:
task = load_task(claimed_task_id)
if task.get("worktree"):
wt_ctx["path"] = str(WORKTREES_DIR / task["worktree"])
else:
wt_ctx["path"] = None
# Summary
summary = "Done."
for msg in reversed(messages):
if msg["role"] == "assistant" and isinstance(msg["content"], list):
for b in msg["content"]:
if getattr(b, "type", None) == "text":
summary = b.text
# IDLE phase: messages take priority, then scan the task board.
while True:
inbox = BUS.wait_for_messages(name, IDLE_SCAN_INTERVAL)
if inbox:
for msg in inbox:
if handle_inbox_message(name, msg, messages):
should_shutdown = True
break
if should_shutdown or messages[-1]["role"] == "user":
break
else:
continue
task = claim_next_task(name)
if not task:
continue
wt_ctx["path"] = (str(WORKTREES_DIR / task.worktree)
if task.worktree else None)
workdir = (f"\nWork directory: {wt_ctx['path']}"
if wt_ctx["path"] else "")
messages.append({
"role": "user",
"content": (
f"[Auto-claimed task {task.id}] "
f"{task.subject}\n{task.description}{workdir}"
),
})
print(f" \033[32m[idle] {name} claimed "
f"{task.id}: {task.subject}\033[0m")
break
BUS.send(name, "lead", summary, "result")
active_teammates.pop(name, None)
if should_shutdown:
break
with team_lock:
active_teammates.pop(name, None)
plan_gates.pop(name, None)
plan_request_ids.pop(name, None)
print(f" \033[32m[teammate] {name} finished\033[0m")
active_teammates[name] = True
threading.Thread(target=run, daemon=True).start()
print(f" \033[36m[teammate] {name} spawned as {role}\033[0m")
return f"Teammate '{name}' spawned as {role} (autonomous)"
def _teammate_submit_plan(from_name: str, plan: str) -> str:
req_id = new_request_id()
pending_requests[req_id] = ProtocolState(
request_id=req_id, type="plan_approval",
sender=from_name, target="lead",
status="pending", payload=plan)
with team_lock:
if plan_gates.get(from_name) == "pending":
return "A plan is already waiting for review."
req_id = new_request_id()
pending_requests[req_id] = ProtocolState(
request_id=req_id, type="plan_approval",
sender=from_name, target="lead",
status="pending", payload=plan)
plan_gates[from_name] = "pending"
plan_request_ids[from_name] = req_id
active_teammates[from_name] = "waiting_approval"
BUS.send(from_name, "lead", plan,
"plan_approval_request",
{"request_id": req_id})
return f"Plan submitted ({req_id}). Waiting for approval..."
# ── Lead Protocol Tools (from s16) ──
# ── Lead Protocol Tools (from s15) ──
def run_request_shutdown(teammate: str) -> str:
req_id = new_request_id()
pending_requests[req_id] = ProtocolState(
request_id=req_id, type="shutdown",
sender="lead", target=teammate,
status="pending", payload="")
if teammate not in active_teammates:
return f"Teammate '{teammate}' is not active"
with team_lock:
req_id = new_request_id()
pending_requests[req_id] = ProtocolState(
request_id=req_id, type="shutdown",
sender="lead", target=teammate,
status="pending", payload="")
BUS.send("lead", teammate, "Please shut down gracefully.",
"shutdown_request",
{"request_id": req_id})
@@ -723,19 +872,27 @@ def run_request_shutdown(teammate: str) -> str:
def run_request_plan(teammate: str, task: str) -> str:
BUS.send("lead", teammate, f"Please submit a plan for: {task}",
"message")
if teammate not in active_teammates:
return f"Teammate '{teammate}' is not active"
with team_lock:
plan_gates[teammate] = "required"
BUS.send("lead", teammate, task, "plan_request")
return f"Asked {teammate} to submit a plan"
def run_review_plan(request_id: str, approve: bool,
feedback: str = "") -> str:
state = pending_requests.get(request_id)
if not state:
return f"Request {request_id} not found"
if state.status != "pending":
return f"Request {request_id} already {state.status}"
state.status = "approved" if approve else "rejected"
with team_lock:
state = pending_requests.get(request_id)
if not state:
return f"Request {request_id} not found"
if state.type != "plan_approval":
return f"Request {request_id} is not a plan"
if state.status != "pending":
return f"Request {request_id} already {state.status}"
if plan_request_ids.get(state.sender) != request_id:
return f"Request {request_id} is not the current plan"
state.status = "approved" if approve else "rejected"
BUS.send("lead", state.sender,
feedback or ("Approved" if approve else "Rejected"),
"plan_approval_response",
@@ -745,7 +902,7 @@ def run_review_plan(request_id: str, approve: bool,
return f"Plan {'approved' if approve else 'rejected'} ({request_id})"
# ── Lead Worktree Tools (s18 new) ──
# ── Lead Worktree Tools (s17 new) ──
def run_create_worktree(name: str, task_id: str = "") -> str:
return create_worktree(name, task_id)
@@ -796,23 +953,12 @@ def run_spawn_teammate(name: str, role: str, prompt: str) -> str:
def run_send_message(to: str, content: str) -> str:
if to not in active_teammates:
return f"Teammate '{to}' is not active"
BUS.send("lead", to, content)
return f"Sent to {to}"
def run_check_inbox() -> str:
msgs = consume_lead_inbox(route_protocol=True)
if not msgs:
return "(inbox empty)"
lines = []
for m in msgs:
meta = m.get("metadata", {})
req_id = meta.get("request_id", "")
tag = f" [{m['type']} req:{req_id}]" if req_id else f" [{m['type']}]"
lines.append(f" [{m['from']}]{tag} {m['content'][:200]}")
return "\n".join(lines)
# ── Tool Definitions ──
TOOLS = [
@@ -859,7 +1005,10 @@ TOOLS = [
{"name": "spawn_teammate",
"description": "Spawn an autonomous teammate agent.",
"input_schema": {"type": "object",
"properties": {"name": {"type": "string"},
"properties": {"name": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{1,64}$",
},
"role": {"type": "string"},
"prompt": {"type": "string"}},
"required": ["name", "role", "prompt"]}},
@@ -869,9 +1018,6 @@ TOOLS = [
"properties": {"to": {"type": "string"},
"content": {"type": "string"}},
"required": ["to", "content"]}},
{"name": "check_inbox",
"description": "Check inbox for messages and protocol responses.",
"input_schema": {"type": "object", "properties": {}, "required": []}},
{"name": "request_shutdown",
"description": "Request a teammate to shut down gracefully.",
"input_schema": {"type": "object",
@@ -891,7 +1037,7 @@ TOOLS = [
"approve": {"type": "boolean"},
"feedback": {"type": "string"}},
"required": ["request_id", "approve"]}},
# s18 new: worktree tools
# s17 new: worktree tools
{"name": "create_worktree",
"description": "Create an isolated git worktree with its own branch.",
"input_schema": {"type": "object",
@@ -917,7 +1063,7 @@ TOOL_HANDLERS = {
"get_task": run_get_task,
"claim_task": run_claim_task, "complete_task": run_complete_task,
"spawn_teammate": run_spawn_teammate,
"send_message": run_send_message, "check_inbox": run_check_inbox,
"send_message": run_send_message,
"request_shutdown": run_request_shutdown,
"request_plan": run_request_plan, "review_plan": run_review_plan,
"create_worktree": run_create_worktree,
@@ -973,18 +1119,49 @@ def agent_loop(messages: list, context: dict):
if __name__ == "__main__":
print("s18: worktree isolation")
print("s17: worktree isolation")
print("Enter a question, press Enter to send. Type q to quit.\n")
history = []
context = {"memories": ""}
events = queue.Queue()
def input_reader():
while True:
try:
line = input("\033[36ms17 >> \033[0m")
except (EOFError, KeyboardInterrupt):
events.put(("quit", None))
return
events.put(("user", line))
def inbox_poller():
while True:
time.sleep(1)
if BUS.peek("lead"):
events.put(("wake", None))
threading.Thread(target=input_reader, daemon=True).start()
threading.Thread(target=inbox_poller, daemon=True).start()
had_teammates = False
while True:
try:
query = input("\033[36ms18 >> \033[0m")
except (EOFError, KeyboardInterrupt):
kind, payload = events.get()
if kind == "quit":
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
if kind == "user":
if payload.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": payload})
else:
inbox = consume_lead_inbox(route_protocol=True)
if not inbox:
continue
history.append({"role": "user",
"content": format_team_events(inbox)})
print(f"\n\033[33m[wake: {len(inbox)} team events "
f"-> new turn]\033[0m")
agent_loop(history, context)
context = update_context(context, history)
for block in history[-1]["content"]:
@@ -993,12 +1170,9 @@ if __name__ == "__main__":
elif isinstance(block, dict) and block.get("type") == "text":
print(block.get("text", ""))
# Consume lead inbox: route protocol + inject into history
inbox = consume_lead_inbox(route_protocol=True)
if inbox:
inbox_text = "\n".join(
f"From {m['from']} [{m.get('type', 'message')}]: "
f"{m['content'][:200]}" for m in inbox)
history.append({"role": "user",
"content": f"[Inbox]\n{inbox_text}"})
if active_teammates:
had_teammates = True
elif had_teammates and not BUS.peek("lead"):
print("\033[32m[all teammates shut down]\033[0m")
had_teammates = False
print()
@@ -23,11 +23,11 @@
<!-- Legend -->
<rect x="40" y="56" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="58" y="66" fill="#2563eb" font-size="10" font-weight="600">s17 Preserved</text>
<text x="58" y="66" fill="#2563eb" font-size="10" font-weight="600">s16 Preserved</text>
<rect x="160" y="56" width="12" height="10" rx="2" fill="#fffbeb" stroke="#b45309" stroke-width="1"/>
<text x="178" y="66" fill="#b45309" font-size="10" font-weight="600">s18 New</text>
<text x="178" y="66" fill="#b45309" font-size="10" font-weight="600">s17 New</text>
<!-- ===== Row 1: Lead Loop (s17 preserved) ===== -->
<!-- ===== Row 1: Lead Loop (s16 preserved) ===== -->
<rect x="20" y="90" width="70" height="40" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="1.5"/>
<text x="55" y="114" fill="#4f46e5" font-size="8" font-weight="600" text-anchor="middle">turn</text>
@@ -49,7 +49,7 @@
<line x1="361" y1="110" x2="375" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="378" y="76" width="356" height="70" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="556" y="94" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCH (s17 + s18)</text>
<text x="556" y="94" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCH (s16 + s17)</text>
<text x="394" y="110" fill="#2563eb" font-size="8">bash · read · write · task(4) · send · inbox</text>
<text x="394" y="123" fill="#7c3aed" font-size="8" font-weight="700">request_shutdown · request_plan · review_plan</text>
<text x="394" y="136" fill="#b45309" font-size="8" font-weight="700">★ create_worktree · remove_worktree · keep_worktree</text>
@@ -57,9 +57,9 @@
<!-- Loop back -->
<path d="M 734 110 L 748 110 L 748 150 L 55 150 L 55 130" fill="none" stroke="#94a3b8" stroke-width="1" marker-end="url(#arrow)" stroke-dasharray="5,4"/>
<!-- ===== Row 2: Worktree Topology (s18 new) ===== -->
<!-- ===== Row 2: Worktree Topology (s17 new) ===== -->
<rect x="30" y="172" width="700" height="215" rx="8" fill="#fffbeb" stroke="#b45309" stroke-width="2"/>
<text x="380" y="194" fill="#78350f" font-size="11" font-weight="700" text-anchor="middle">Worktree Isolation (s18 new: each task gets its own directory + branch)</text>
<text x="380" y="194" fill="#78350f" font-size="11" font-weight="700" text-anchor="middle">Worktree Isolation (s17 new: each task gets its own directory + branch)</text>
<!-- Main repo box -->
<rect x="230" y="208" width="300" height="36" rx="6" fill="#fff" stroke="#b45309" stroke-width="1.5"/>
@@ -92,12 +92,12 @@
<text x="205" y="376" fill="#92400e" font-size="8" text-anchor="middle">Event log: .worktrees/events.jsonl → create / remove / keep</text>
<rect x="400" y="362" width="310" height="20" rx="4" fill="#fef3c7" stroke="#d97706" stroke-width="1"/>
<text x="555" y="376" fill="#92400e" font-size="8" text-anchor="middle">Cleanup: keep (preserve branch for review) / remove (delete + mark done)</text>
<text x="555" y="376" fill="#92400e" font-size="8" text-anchor="middle">Cleanup: keep (preserve for review) / remove (delete worktree)</text>
<!-- ===== Row 3: Bottom notes ===== -->
<rect x="30" y="400" width="700" height="42" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<rect x="50" y="412" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="70" y="422" fill="#475569" font-size="10">s17: idle_poll + auto_claim + protocols + WORK/IDLE lifecycle</text>
<text x="70" y="422" fill="#475569" font-size="10">s16: scan_unclaimed_tasks + claim_next_task + task_lock</text>
<rect x="50" y="426" width="12" height="10" rx="2" fill="#fffbeb" stroke="#b45309" stroke-width="1"/>
<text x="70" y="436" fill="#475569" font-size="10">s18: create_worktree + bind_task + remove/keep + events.jsonl (Lead 14→17)</text>
<text x="70" y="436" fill="#475569" font-size="10">s17: create_worktree + bind_task + remove/keep + events.jsonl</text>
</svg>

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Some files were not shown because too many files have changed in this diff Show More