Consolidate agent harness course into 19 lessons

This commit is contained in:
Haoran 2026-08-04 02:25:40 +08:00
parent 2ad77cee19
commit b36dbcd84f
168 changed files with 6544 additions and 10400 deletions

View file

@ -100,13 +100,13 @@ Claude Code = 一つの agent loop
+ サブ Agent スポーン
+ 依存グラフ付きタスクシステム
+ 非同期メールボックスによるチーム協調
+ worktree 分離による並列実行
+ タスクに紐付く worktree での並列実行
+ 権限ガバナンス
```
これがすべてだ。これが全アーキテクチャ。すべてのコンポーネントは Harness メカニズム -- Agent が住む世界の一部。Agent そのものは? Claude だ。モデル。Anthropic が人類の推論とコードの全幅で訓練した。Harness が Claude を賢くしたのではない。Claude は元々賢い。Harness が Claude に手と目とワークスペースを与えた。
これが Claude Code を教材として扱う理由だ:**モデルを信頼し、工学的努力を Harness に集中させるとどうなるかを示している。** このリポジトリの各セッションs01-s21は Harness メカニズムを段階的に分解し、最後に組み直す。終了時には、一つの coding agent の仕組みだけでなく、さまざまな領域に適用できる Harness 工学の原則を理解できる。
これが Claude Code を教材として扱う理由だ:**モデルを信頼し、工学的努力を Harness に集中させるとどうなるかを示している。** このリポジトリの各セッションs01-s19)は Harness メカニズムを段階的に分解し、最後に組み直す。終了時には、一つの coding agent の仕組みだけでなく、さまざまな領域に適用できる Harness 工学の原則を理解できる。
教訓は「Claude Code をコピーせよ」ではない。教訓は:**最高の Agent プロダクトは、自分の仕事が Harness であって Intelligence ではないと理解しているエンジニアが作る。**
@ -159,7 +159,7 @@ Claude Code = 一つの agent loop
Agent を特定ドメインで効果的にする Harness -- の作り方を教える。
```
**21 の段階的セッション、シンプルなループから目標を閉じる Harness まで。**
**19 の段階的セッション、シンプルなループから目標を閉じる Harness まで。**
**各セッションは 1 つの Harness メカニズムを追加する。各メカニズムには 1 つのモットーがある。**
> **s01**   *"One loop & Bash is all you need"* — 1つのツール + 1つのループ = エージェント
@ -190,19 +190,15 @@ Claude Code = 一つの agent loop
>
> **s14**   *"スケジュールで発火、人間の起動は不要"* — 時間になったら自動でタスクを動かす
>
> **s15**   *"一人で扱いきれないなら、チームメイトで分担する"* — 永続チームメイト、自動メッセージ配信、型付き協調プロトコル
> **s15**   *"一人で扱いきれないなら、チームメイトで分担する"* — 永続チームメイトが協調し、実行可能なタスクを認領して、タスクに紐付いた作業ディレクトリを使う
>
> **s16**   *"待機中のチームメイトがボードを見て仕事を取る"* — 実行可能なタスクを発見し、原子的に認領する
> **s16**   *"能力不足? MCP でプラグイン"* — 外部ツールを同じツールプールに接続する
>
> **s17**   *"各自のディレクトリで作業し、互いに干渉しない"* — タスクは目標を管理、worktree はディレクトリを管理、IDで紐付け
> **s17**   *"仕組みは多く、ループは一つ"* — すべての仕組みを 1 つの Harness に戻す
>
> **s18**   *"能力不足? MCP でプラグイン"* — 外部ツールを同じツールプールに接続する
> **s18**   *"編成の形が固定なら、コードにする"* — 再開可能なジャーナルを持つ決定的 Workflow
>
> **s19**   *"仕組みは多く、ループは一つ"* — すべての仕組みを 1 つの Harness に戻す
>
> **s20**   *"編成の形が固定なら、コードにする"* — 再開可能なジャーナルを持つ決定的 Workflow
>
> **s21**   *"本当に終われる時を目標が決める"* — 信頼できる証拠が目標を満たすまで継続する
> **s19**   *"本当に終われる時を目標が決める"* — 独立した evaluator が conversation から目標達成を確認するまで継続する
---
@ -239,16 +235,16 @@ def agent_loop(messages):
このリポジトリには現在、2 つのチュートリアルトラックが共存している:
- **現行トラック:ルート直下の `s01-s21`**
ルート直下の `s01_*` から `s21_*` までが新しい正規版であり、現在推奨する読書経路。各セッションには既定の英語 README、中国語/日本語訳、実行可能な `code.py`、必要に応じた図が含まれる。
- **現行トラック:ルート直下の `s01-s19`**
ルート直下の `s01_*` から `s19_*` までが新しい正規版であり、現在推奨する読書経路。各セッションには既定の英語 README、中国語/日本語訳、実行可能な `code.py`、必要に応じた図が含まれる。
- **旧版移行トラック:`docs/``agents/`**
これらは旧 12 セッション版を保持している。既存読者と旧リンクのために移行期間中は一時的に残している。
新しく読む場合は、ルート直下の `s01_agent_loop/` から `s21_goal_loop/` までを読む。旧版と現行版のセッション番号は常に一致しないため、番号を混同しないこと。
新しく読む場合は、ルート直下の `s01_agent_loop/` から `s19_goal_loop/` までを読む。旧版と現行版のセッション番号は常に一致しないため、番号を混同しないこと。
### 旧版から現行版への対応
| 旧 12 セッション版 | 現行 21 セッション版 | トピック |
| 旧 12 セッション版 | 現行 19 セッション版 | トピック |
|---|---|---|
| 旧 s01 | 現行 s01 | Agent Loop |
| 旧 s02 | 現行 s02 | Tool Use |
@ -260,17 +256,17 @@ def agent_loop(messages):
| 旧 s08 | 現行 s13 | Background Tasks |
| 旧 s09 | 現行 s15 | Agent Teams |
| 旧 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、Integrated Harness、Workflow Runtime、Goal Loop |
| 旧 s11 | 現行 s15 | 自律的なタスク認領 |
| 旧 s12 | 現行 s15 | タスクに紐付く Worktree |
| 現行版のみ | s03、s04、s09、s10、s11、s14、s16、s17、s18、s19 | Permission、Hooks、Memory、Context Assembly、Error Recovery、Cron、MCP、Integrated Harness、Workflow Runtime、Goal Loop |
## コースの範囲
これは Harness 工学を 0 から組み立てるコースである。各セッションで一つの仕組みを分けて扱い、s19 で一つの Agent loop に戻す。チームランタイムは JSONL メールボックスを使い、その後のセッションで Workflow 編成と目標による継続ループを追加する。
これは Harness 工学を 0 から組み立てるコースである。各セッションで一つの仕組みを分けて扱い、s17 で一つの Agent loop に戻す。チームランタイムは JSONL メールボックスを使い、その後のセッションで Workflow 編成と目標による継続ループを追加する。
## クイックスタート
### 現行 21 セッション版
### 現行 19 セッション版
```sh
git clone https://github.com/shareAI-lab/learn-claude-code
@ -280,7 +276,7 @@ cp .env.example .env # .env を編集して ANTHROPIC_API_KEY を入力
python s01_agent_loop/code.py # ここから開始 — 1ループ + bash
python s08_context_compact/code.py # コンテキスト圧縮(複雑章)
python s21_goal_loop/code.py # 終点: 目標でループを閉じる
python s19_goal_loop/code.py # 終点: 目標でループを閉じる
```
### 旧 12 セッション移行版
@ -293,7 +289,7 @@ python agents/s_full.py
### Web プラットフォーム
Web プラットフォームはルート直下のコースから内容を生成する。s20 と s21 は読解、ソース、シミュレーター、アーキテクチャの各 view を提供し、専用 hero visualization だけを最小限に保つ。
Web プラットフォームはルート直下のコースから内容を生成する。s18 と s19 は読解、ソース、シミュレーター、アーキテクチャの各 view を提供し、専用 hero visualization だけを最小限に保つ。
```sh
cd web && npm install && npm run dev # http://localhost:3000
@ -333,9 +329,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>s15 Agent Teams</b><br/>└─ チームメイト + 配信 + プロトコル<br/><br/><b>s16 Autonomous Agents</b><br/>└─ 実行可能な仕事をボードから認領<br/><br/><b>s17 Worktree Isolation</b><br/>└─ 別ディレクトリで作業"]:::stage5
S5["<b>第5段階複数 Agent の協作</b><br/>━━━━━━━━━━━━━<br/><b>s15 Agent Teams</b><br/>└─ チームメイト + 配信 + プロトコル<br/>└─ 実行可能なタスクを原子的に認領<br/>└─ タスクに紐付く Worktree"]:::stage5
S6["<b>第6段階外部能力と統合</b><br/>━━━━━━━━━━━━━<br/><b>s07 Skill Loading</b><br/>└─ スキルを必要時に展開<br/><br/><b>s18 MCP Plugin</b><br/>└─ 外部ツールを同じプールへ<br/><br/><b>s19 Integrated Harness</b><br/>└─ すべてを1つのループへ"]:::stage6
S6["<b>第6段階外部能力と統合</b><br/>━━━━━━━━━━━━━<br/><b>s07 Skill Loading</b><br/>└─ スキルを必要時に展開<br/><br/><b>s16 MCP Plugin</b><br/>└─ 外部ツールを同じプールへ<br/><br/><b>s17 Integrated Harness</b><br/>└─ すべてを1つのループへ"]:::stage6
S4 ==> S5 ==> S6
end
@ -343,7 +339,7 @@ flowchart TD
%% 第3層編成と目標の完了
subgraph Phase3 ["第7段階編成と目標の完了"]
direction LR
S7["<b>第7段階編成して完了する</b><br/>━━━━━━━━━━━━━<br/><b>s20 Workflow Runtime</b><br/>└─ 固定編成はスクリプトが担う<br/><br/><b>s21 Goal Loop</b><br/>└─ 信頼できる証拠で停止を決める"]:::stage1
S7["<b>第7段階編成して完了する</b><br/>━━━━━━━━━━━━━<br/><b>s18 Workflow Runtime</b><br/>└─ 固定編成はスクリプトが担う<br/><br/><b>s19 Goal Loop</b><br/>└─ 独立した評価で停止を決める"]:::stage1
S6 ==> S7
end
@ -371,13 +367,11 @@ 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 | 永続チームメイト / 自動配信 / 型付きプロトコル / プランゲート |
| [s16](./s16_autonomous_agents/) | Autonomous Agents | タスクボード走査 / 原子的認領 / 自己組織化 |
| [s17](./s17_worktree_isolation/) | Worktree Isolation | `WorktreeRecord` / タスク-ディレクトリ紐付け |
| [s18](./s18_mcp_plugin/) | MCP Plugin | ツール発見 / 名前空間 / ツールプール組み立て |
| [s19](./s19_comprehensive/) | Integrated Harness | すべての仕組みを 1 つのループへ |
| [s20](./s20_workflow_runtime/) | Workflow Runtime | スクリプト編成 / バックグラウンド実行 / ジャーナル再開 |
| [s21](./s21_goal_loop/) | Goal Loop | 目標ゲート / 信頼できる証拠 / 自動継続 |
| [s15](./s15_agent_teams/) | Agent Teams | 永続チームメイト / 原子的認領 / タスクに紐付く Worktree / 型付きプロトコル |
| [s16](./s16_mcp_plugin/) | MCP Plugin | ツール発見 / 名前空間 / ツールプール組み立て |
| [s17](./s17_integrated_harness/) | Integrated Harness | すべての仕組みを 1 つのループへ |
| [s18](./s18_workflow_runtime/) | Workflow Runtime | スクリプト編成 / バックグラウンド実行 / ジャーナル再開 |
| [s19](./s19_goal_loop/) | Goal Loop | 目標ゲート / conversation の評価 / 自動継続 |
## プロジェクト構成
@ -391,10 +385,10 @@ learn-claude-code/
images/ # SVG ダイアグラム
s02_tool_use/
...
s18_mcp_plugin/
s19_comprehensive/
s20_workflow_runtime/
s21_goal_loop/ # 終点セッション
s16_mcp_plugin/
s17_integrated_harness/
s18_workflow_runtime/
s19_goal_loop/ # 終点セッション
agents/ # 旧 12 セッションの実行可能コピー + s_full.py
skills/ # s07 で使用するスキルファイル
docs/ # 旧 12 セッション文書、移行期間中は保持
@ -404,7 +398,7 @@ learn-claude-code/
## 次のステップ -- 理解から出荷へ
21 セッションを終えれば、Harness 工学の内部構造を理解できる。その知識を活かす 2 つの方法:
19 セッションを終えれば、Harness 工学の内部構造を理解できる。その知識を活かす 2 つの方法:
### Kode Agent CLI -- オープンソース Coding Agent CLI
@ -443,7 +437,7 @@ claw agent = agent core + heartbeat + cron + IM chat + memory + soul
learn-claude-code claw0
(agent harness コア: (能動的な常時稼働 harness:
ループ、ツール、計画、 ハートビート、cron、IM チャネル、
チーム、worktree 分離) メモリ、Soul パーソナリティ)
チーム、タスクに紐付く worktree) メモリ、Soul パーソナリティ)
```
## ライセンス

View file

@ -100,13 +100,13 @@ Claude Code = 一个 agent loop
+ 子 agent 派生
+ 带依赖图的任务系统
+ 异步邮箱的团队协调
+ worktree 隔离的并行执行
+ 任务绑定的 worktree 并行执行
+ 权限治理
```
就这些。这就是全部架构。每一个组件都是 harness 机制 -- 为 agent 构建的栖居世界的一部分。Agent 本身呢?是 Claude。一个模型。由 Anthropic 在人类推理和代码的全部广度上训练而成。Harness 没有让 Claude 变聪明。Claude 本来就聪明。Harness 给了 Claude 双手、双眼和一个工作空间。
这就是 Claude Code 作为教学标本的意义:**它展示了当你信任模型、把工程精力集中在 harness 上时会发生什么。** 本仓库的课程s01-s21逐步拆解并重组 harness 机制。学完之后,你理解的不只是一个 coding agent 怎么工作,而是适用于不同领域的 harness 工程原则。
这就是 Claude Code 作为教学标本的意义:**它展示了当你信任模型、把工程精力集中在 harness 上时会发生什么。** 本仓库的课程s01-s19)逐步拆解并重组 harness 机制。学完之后,你理解的不只是一个 coding agent 怎么工作,而是适用于不同领域的 harness 工程原则。
启示不是 "复制 Claude Code"。启示是:**最好的 agent 产品,出自那些明白自己的工作是 harness 而非 intelligence 的工程师之手。**
@ -159,7 +159,7 @@ Claude Code = 一个 agent loop
让 agent 在特定领域高效工作的 harness。
```
**21 个递进式课程, 从简单循环到目标闭环。**
**19 个递进式课程, 从简单循环到目标闭环。**
**每个课程添加一个 harness 机制。每个机制有一句格言。**
> **s01** &nbsp; *"One loop & Bash is all you need"* &mdash; 一个工具 + 一个循环 = 一个 Agent
@ -190,19 +190,15 @@ Claude Code = 一个 agent loop
>
> **s14** &nbsp; *"定时触发, 不需要人推"* &mdash; 按时间自动触发任务
>
> **s15** &nbsp; *"一个 Agent 顾不过来,就让队友分工协作"* &mdash; 持久队友、自动消息投递与类型化协作协议
> **s15** &nbsp; *"一个 Agent 顾不过来,就让队友分工协作"* &mdash; 持久队友协作、认领就绪任务,并使用任务绑定的工作目录
>
> **s16** &nbsp; *"队友空闲时自己看板,有活就认领"* &mdash; 发现就绪任务并原子认领,不需要逐个派发
> **s16** &nbsp; *"能力不够? 插上 MCP"* &mdash; 把外部工具接进同一个工具池
>
> **s17** &nbsp; *"各干各的目录, 互不干扰"* &mdash; 任务管目标, worktree 管目录, 按 ID 绑定
> **s17** &nbsp; *"机制很多,循环一个"* &mdash; 前面所有机制集成到同一个 harness
>
> **s18** &nbsp; *"能力不够? 插上 MCP"* &mdash; 把外部工具接进同一个工具池
> **s18** &nbsp; *"编排形状固定时,就把它写进代码"* &mdash; 可恢复 journal 支撑确定性 workflow
>
> **s19** &nbsp; *"机制很多,循环一个"* &mdash; 前面所有机制集成到同一个 harness
>
> **s20** &nbsp; *"编排形状固定时,就把它写进代码"* &mdash; 可恢复 journal 支撑确定性 workflow
>
> **s21** &nbsp; *"目标决定循环什么时候真正结束"* &mdash; 持续工作,直到可信证据满足目标
> **s19** &nbsp; *"目标决定循环什么时候真正结束"* &mdash; 持续工作,直到独立判断器根据对话确认目标达成
---
@ -239,16 +235,16 @@ def agent_loop(messages):
本仓库现在同时保留两条教程线:
- **新版主线:根目录 `s01-s21`**
根目录下的 `s01_*``s21_*` 是新的主版本,也是当前推荐阅读路径。每章包含默认英文 README、中文/日文译本、可运行的 `code.py`,以及必要的图示。
- **新版主线:根目录 `s01-s19`**
根目录下的 `s01_*``s19_*` 是新的主版本,也是当前推荐阅读路径。每章包含默认英文 README、中文/日文译本、可运行的 `code.py`,以及必要的图示。
- **旧版过渡:`docs/``agents/`**
这些仍保留旧 12 章体系,暂时用于已有读者和旧链接过渡。
新读者请从根目录 `s01_agent_loop/` 读到 `s21_goal_loop/`。旧版章节号和新版不完全一致,不要混用章节号。
新读者请从根目录 `s01_agent_loop/` 读到 `s19_goal_loop/`。旧版章节号和新版不完全一致,不要混用章节号。
### 旧版到新版的对应关系
| 旧 12 章版本 | 新 21 章版本 | 主题 |
| 旧 12 章版本 | 新 19 章版本 | 主题 |
|---|---|---|
| 旧 s01 | 新 s01 | Agent Loop |
| 旧 s02 | 新 s02 | Tool Use |
@ -260,17 +256,17 @@ def agent_loop(messages):
| 旧 s08 | 新 s13 | Background Tasks |
| 旧 s09 | 新 s15 | Agent Teams |
| 旧 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、Agent Harness 集成、Workflow Runtime、Goal Loop |
| 旧 s11 | 新 s15 | 自主认领任务 |
| 旧 s12 | 新 s15 | 任务绑定的 Worktree |
| 新版新增 | s03、s04、s09、s10、s11、s14、s16、s17、s18、s19 | Permission、Hooks、Memory、Context Assembly、Error Recovery、Cron、MCP、Agent Harness 集成、Workflow Runtime、Goal Loop |
## 课程边界
这是一个从 0 到 1 的 harness 工程课程。每章先单独展开一个机制s19 再把它们接回完整的 Agent 循环。团队运行时使用 JSONL 邮箱,后续章节继续加入 workflow 编排和由目标控制的持续循环。
这是一个从 0 到 1 的 harness 工程课程。每章先单独展开一个机制s17 再把它们接回完整的 Agent 循环。团队运行时使用 JSONL 邮箱,后续章节继续加入 workflow 编排和由目标控制的持续循环。
## 快速开始
### 新版 21 章主线
### 新版 19 章主线
```sh
git clone https://github.com/shareAI-lab/learn-claude-code
@ -280,7 +276,7 @@ cp .env.example .env # 编辑 .env 填入你的 ANTHROPIC_API_KEY
python s01_agent_loop/code.py # 起点 — 一个循环 + bash
python s08_context_compact/code.py # 上下文压缩(复杂章)
python s21_goal_loop/code.py # 终点章:用目标闭合循环
python s19_goal_loop/code.py # 终点章:用目标闭合循环
```
### 旧版 12 章过渡线
@ -293,7 +289,7 @@ python agents/s_full.py
### Web 平台
Web 平台从根目录课程生成内容。s20、s21 提供阅读、源码、模拟和架构视图;仅专用首屏可视化保持精简。
Web 平台从根目录课程生成内容。s18、s19 提供阅读、源码、模拟和架构视图;仅专用首屏可视化保持精简。
```sh
cd web && npm install && npm run dev # http://localhost:3000
@ -333,9 +329,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>s15 Agent Teams</b><br/>└─ 队友 + 消息投递 + 协作协议<br/><br/><b>s16 Autonomous Agents</b><br/>└─ 自己看板认领就绪任务<br/><br/><b>s17 Worktree Isolation</b><br/>└─ 隔离目录"]:::stage5
S5["<b>第五阶段:让多个 Agent 协作</b><br/>━━━━━━━━━━━━━<br/><b>s15 Agent Teams</b><br/>└─ 队友 + 消息投递 + 协作协议<br/>└─ 原子认领就绪任务<br/>└─ 任务绑定的 Worktree"]:::stage5
S6["<b>第六阶段:接外部能力合体</b><br/>━━━━━━━━━━━━━<br/><b>s07 Skill Loading</b><br/>└─ 技能按需展开<br/><br/><b>s18 MCP Plugin</b><br/>└─ 外部接进工具池<br/><br/><b>s19 Agent Harness 集成</b><br/>└─ 全机制回单循环"]:::stage6
S6["<b>第六阶段:接外部能力合体</b><br/>━━━━━━━━━━━━━<br/><b>s07 Skill Loading</b><br/>└─ 技能按需展开<br/><br/><b>s16 MCP Plugin</b><br/>└─ 外部接进工具池<br/><br/><b>s17 Agent Harness 集成</b><br/>└─ 全机制回单循环"]:::stage6
S4 ==> S5 ==> S6
end
@ -343,7 +339,7 @@ flowchart TD
%% 第三层:编排与目标闭环
subgraph Phase3 ["🎯 第七阶段:编排与目标闭环"]
direction LR
S7["<b>第七阶段:编排并完成</b><br/>━━━━━━━━━━━━━<br/><b>s20 Workflow Runtime</b><br/>└─ 脚本拥有固定编排<br/><br/><b>s21 Goal Loop</b><br/>└─ 可信证据决定何时停止"]:::stage1
S7["<b>第七阶段:编排并完成</b><br/>━━━━━━━━━━━━━<br/><b>s18 Workflow Runtime</b><br/>└─ 脚本拥有固定编排<br/><br/><b>s19 Goal Loop</b><br/>└─ 独立判断决定何时停止"]:::stage1
S6 ==> S7
end
@ -372,13 +368,11 @@ 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 | 持久队友 / 自动消息投递 / 类型协议 / 计划闸门 |
| [s16](./s16_autonomous_agents/) | Autonomous Agents | 扫描任务板 / 原子认领 / 自组织 |
| [s17](./s17_worktree_isolation/) | Worktree Isolation | `WorktreeRecord` / 任务-目录绑定 |
| [s18](./s18_mcp_plugin/) | MCP Plugin | 工具发现 / 命名空间 / 工具池组装 |
| [s19](./s19_comprehensive/) | Agent Harness 集成 | 全部机制归到一个循环 |
| [s20](./s20_workflow_runtime/) | Workflow Runtime | 脚本编排 / 后台运行 / journal 续跑 |
| [s21](./s21_goal_loop/) | Goal Loop | 目标闸门 / 可信证据 / 自动续轮 |
| [s15](./s15_agent_teams/) | Agent Teams | 持久队友 / 原子认领 / 任务绑定的 Worktree / 类型协议 |
| [s16](./s16_mcp_plugin/) | MCP Plugin | 工具发现 / 命名空间 / 工具池组装 |
| [s17](./s17_integrated_harness/) | Agent Harness 集成 | 全部机制归到一个循环 |
| [s18](./s18_workflow_runtime/) | Workflow Runtime | 脚本编排 / 后台运行 / journal 续跑 |
| [s19](./s19_goal_loop/) | Goal Loop | 目标闸门 / 对话判断 / 自动续轮 |
## 项目结构
@ -392,10 +386,10 @@ learn-claude-code/
images/ # SVG 流程图
s02_tool_use/
...
s18_mcp_plugin/
s19_comprehensive/
s20_workflow_runtime/
s21_goal_loop/ # 终点章
s16_mcp_plugin/
s17_integrated_harness/
s18_workflow_runtime/
s19_goal_loop/ # 终点章
agents/ # 旧 12 章可运行副本 + s_full.py
skills/ # s07 使用的 skill 文件
docs/ # 旧 12 章文档,过渡期保留
@ -405,7 +399,7 @@ learn-claude-code/
## 学完之后 -- 从理解到落地
21 个课程走完, 你已经从内到外理解了 harness 工程的运作原理。两种方式把知识变成产品:
19 个课程走完, 你已经从内到外理解了 harness 工程的运作原理。两种方式把知识变成产品:
### Kode Agent CLI -- 开源 Coding Agent CLI
@ -444,7 +438,7 @@ claw agent = agent core + heartbeat + cron + IM chat + memory + soul
learn-claude-code claw0
(agent harness 内核: (主动式常驻 harness:
循环、工具、规划、 心跳、定时任务、IM 通道、
团队、worktree 隔离) 记忆、Soul 人格)
团队、任务绑定的 worktree) 记忆、Soul 人格)
```
## 许可证

View file

@ -96,7 +96,7 @@ Claude Code = one agent loop
+ subagent spawning
+ task system with dependency graphs
+ async mailbox team coordination
+ worktree-isolated parallel execution
+ task-bound worktrees for parallel edits
+ permission governance
+ hooks extension system
+ memory persistence
@ -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-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.
- **Current track: root-level `s01-s19`**
The root-level `s01_*` ... `s19_*` 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 `s21_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 `s19_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 21-lesson track | Topic |
| Legacy 12-lesson track | Current 19-lesson track | Topic |
|---|---|---|
| old s01 | new s01 | Agent Loop |
| old s02 | new s02 | Tool Use |
@ -188,19 +188,19 @@ If you are starting now, read the root-level `s01_agent_loop/` through `s21_goal
| old s08 | new s13 | Background Tasks |
| old s09 | new s15 | Agent Teams |
| 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, Integrated Harness, Workflow Runtime, Goal Loop |
| old s11 | new s15 | Autonomous task claiming |
| old s12 | new s15 | Task-bound worktrees |
| new only | s03, s04, s09, s10, s11, s14, s16, s17, s18, s19 | Permission, Hooks, Memory, Context Assembly, Error Recovery, Cron, MCP, Integrated Harness, Workflow Runtime, Goal Loop |
---
## Course Boundary
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.
This is a 0-to-1 harness engineering course. Each chapter isolates one mechanism, then s17 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.
---
## 21 Progressive Lessons
## 19 Progressive Lessons
**Each lesson adds one harness mechanism. Each mechanism has a motto.**
@ -232,19 +232,15 @@ This is a 0-to-1 harness engineering course. Each chapter isolates one mechanism
>
> **s14** &nbsp; *"Fire on schedule, no human kick needed"* &mdash; trigger tasks automatically by time
>
> **s15** &nbsp; *"Too big for one agent -- let teammates divide the work"* &mdash; persistent teammates, automatic message delivery, and typed coordination protocols
> **s15** &nbsp; *"Too big for one agent -- let teammates divide the work"* &mdash; persistent teammates coordinate, claim ready tasks, and use task-bound working directories
>
> **s16** &nbsp; *"Idle teammates check the board and claim ready work"* &mdash; task discovery and atomic claiming without one-by-one dispatch
> **s16** &nbsp; *"Not enough capability? Plug in more via MCP"* &mdash; connect external tools into the same tool pool
>
> **s17** &nbsp; *"Each works in its own directory, no interference"* &mdash; tasks own goals, worktrees own directories, bound by ID
> **s17** &nbsp; *"Many mechanisms, one loop"* &mdash; all previous mechanisms return to one integrated harness
>
> **s18** &nbsp; *"Not enough capability? Plug in more via MCP"* &mdash; connect external tools into the same tool pool
> **s18** &nbsp; *"When the orchestration shape is fixed, put it in code"* &mdash; deterministic workflows with resumable journals
>
> **s19** &nbsp; *"Many mechanisms, one loop"* &mdash; all previous mechanisms return to one integrated harness
>
> **s20** &nbsp; *"When the orchestration shape is fixed, put it in code"* &mdash; deterministic workflows with resumable journals
>
> **s21** &nbsp; *"A goal decides when the loop may stop"* &mdash; continue until trusted evidence satisfies the goal
> **s19** &nbsp; *"A goal decides when the loop may stop"* &mdash; continue until an independent evaluator finds the goal satisfied in the conversation
---
@ -282,9 +278,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>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
S5["<b>5. Coordinate many Agents</b><br/>━━━━━━━━━━━━━<br/><b>s15 Agent Teams</b><br/>└─ teammates + delivery + protocols<br/>└─ atomic task claims<br/>└─ task-bound worktrees"]:::stage5
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 Integrated Harness</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>s16 MCP Plugin</b><br/>└─ external tools, one pool<br/><br/><b>s17 Integrated Harness</b><br/>└─ all mechanisms, one loop"]:::stage6
S4 ==> S5 ==> S6
end
@ -292,7 +288,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>s20 Workflow Runtime</b><br/>└─ scripts own fixed orchestration<br/><br/><b>s21 Goal Loop</b><br/>└─ trusted evidence decides when to stop"]:::stage1
S7["<b>7. Orchestrate and finish</b><br/>━━━━━━━━━━━━━<br/><b>s18 Workflow Runtime</b><br/>└─ scripts own fixed orchestration<br/><br/><b>s19 Goal Loop</b><br/>└─ independent evaluation decides when to stop"]:::stage1
S6 ==> S7
end
@ -322,13 +318,11 @@ 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 | 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/) | Integrated Harness | 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 |
| [s15](./s15_agent_teams/) | Agent Teams | persistent teammates / atomic task claims / task-bound worktrees / typed protocols |
| [s16](./s16_mcp_plugin/) | MCP Plugin | tool discovery / namespaced tools / tool pool assembly |
| [s17](./s17_integrated_harness/) | Integrated Harness | all mechanisms around one loop |
| [s18](./s18_workflow_runtime/) | Workflow Runtime | script orchestration / background execution / journal resume |
| [s19](./s19_goal_loop/) | Goal Loop | goal gate / conversation evaluation / automatic continuation |
---
@ -347,13 +341,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 s21 in order. Each chapter assumes you've read the previous ones and ends with a hook into the next.
Read from s01 through s19 in order. Each chapter assumes you've read the previous ones and ends with a hook into the next.
---
## Quick Start
### Current 21-Lesson Track
### Current 19-Lesson Track
```sh
git clone https://github.com/shareAI-lab/learn-claude-code
@ -363,7 +357,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 s21_goal_loop/code.py # Endpoint: close the loop with a durable goal
python s19_goal_loop/code.py # Endpoint: close the loop with a durable goal
```
### Legacy 12-Lesson Track
@ -376,7 +370,7 @@ python agents/s_full.py
### Web Platform
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.
The web app extracts the root-level course. Lessons s18 and s19 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
@ -396,10 +390,10 @@ learn-claude-code/
images/ # SVG diagrams
s02_tool_use/
...
s18_mcp_plugin/
s19_comprehensive/
s20_workflow_runtime/
s21_goal_loop/ # endpoint chapter
s16_mcp_plugin/
s17_integrated_harness/
s18_workflow_runtime/
s19_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
@ -411,7 +405,7 @@ learn-claude-code/
## What's Next
After 21 lessons, you understand harness engineering from the inside out. Two paths to turn that knowledge into product:
After 19 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
@ -450,7 +444,7 @@ claw agent = agent core + heartbeat + cron + IM chat + memory + soul
learn-claude-code claw0
(agent harness internals: (always-on harness:
loop, tools, planning, heartbeat, cron, IM channels,
teams, worktree isolation) memory, Soul personality)
teams, task-bound worktrees) memory, Soul personality)
```
## License

View file

@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20 → s21
`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s18 → s19
> *"One loop & Bash is all you need"* — ツール一つ + ループ一つ = 一つの Agent。
>
> **Harness レイヤー**: ループ — モデルと現実世界をつなぐ最初の架け橋。

View file

@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20 → s21
`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s18 → s19
> *"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.

View file

@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20 → s21
`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s18 → s19
> *"One loop & Bash is all you need"* — 一个工具 + 一个循环 = 一个 Agent。
>
> **Harness 层**: 循环 — 模型与真实世界的第一道连接。

View file

@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20 → s21
s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s18 → s19
> *"ツールを一つ追加、ハンドラを一つ追加"* — ループはそのまま。新しいツールをディスパッチマップに登録するだけ。
>
> **Harness レイヤー**: ツールディスパッチ — モデルが触れる範囲を拡張。

View file

@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20 → s21
s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s18 → s19
> *"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.

View file

@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20 → s21
s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s18 → s19
> *"加一个工具, 只加一个 handler"* — 循环不用动, 新工具注册进 dispatch map 就行。
>
> **Harness 层**: 工具分发 — 扩展模型能触达的边界。

View file

@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20 → s21
s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s18 → s19
> *"ツール実行前に権限を判断"* — 権限パイプラインは、どの操作に承認が必要かを決める。
>
> **Harness レイヤー**: 権限 — ツール実行前に一つのゲートを追加。

View file

@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20 → s21
s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s18 → s19
> *"Check permissions before executing"* — The permission pipeline decides which operations need approval.
>
> **Harness Layer**: Permission — a gate before tool execution.

View file

@ -2,7 +2,7 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20 → s21
s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s18 → s19
> *"工具执行前先做权限判断"* — 权限管线决定哪些操作需要审批。
>
> **Harness 层**: 权限 — 在工具执行前加一道门。

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
s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s18 → s19
> *"ループに掛ける、ループには書き込まない"* — フックがツール実行の前後に拡張ロジックを注入する。
>

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
s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s18 → s19
> *"Hang on the loop, don't write into it"* — Hooks inject extension logic before and after tool execution.
>

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
s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s18 → s19
> *"挂在循环上, 不写进循环里"* — hook 在工具执行前后注入扩展逻辑。
>

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
s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s18 → s19
> *"計画なき agent は風の向くままに"* — まず手順を列挙してから実行。長いタスクで見落としが減る。
>

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
s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s18 → s19
> *"An agent without a plan goes wherever the wind blows"* — List the steps first, then execute. Complex tasks are less likely to miss steps.
>

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
s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s18 → s19
> *"没有计划的 agent 走哪算哪"* — 先列步骤再动手,长任务更不容易漏项。
>

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
s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s18 → s19
> *"大きなタスクは小さく、小さなタスクごとにクリーンなコンテキスト"* — Subagent は独立した messages[] を使い、メイン会話を汚染しない。
>

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
s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s18 → s19
> *"Break large tasks small, each with clean context"* — Subagent uses an independent messages[], no pollution in the main conversation.
>

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
s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s18 → s19
> *"大任务拆小, 每个小任务干净的上下文"* — Subagent 用独立 messages[], 不污染主对话。
>

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
s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s18 → s19
> *"Load when needed, don't stuff the prompt"* — tool_result で注入、system prompt には詰め込まない。
>
> **Harness レイヤー**: 知識 — 必要に応じて読み込み、コンテキストに詰め込まない。

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
s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s18 → s19
> *"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.

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
s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s18 → s19
> *"用到时再加载, 别全塞 prompt 里"* — 通过 tool_result 注入, 不塞 system prompt。
>
> **Harness 层**: 知识 — 按需加载, 不堆满上下文。

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
s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s18 → s19
> *「コンテキストには上限があるため、空きを作る仕組みが必要になる。」* 4 つの処理を低コストな順に実行します。
>

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
s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s18 → s19
> *"Context will fill up, so the Harness needs a way to make room."* Four steps run from lower cost to higher cost.
>

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
s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s18 → s19
> *"上下文总会满,要有办法腾地方。"* 四步压缩,低成本的操作优先执行。
>

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
s01 → ... → s07 → s08 → `s09` → [s10](../s10_system_prompt/) → s11 → ... → s18 → s19
> *"圧縮は詳細を失う、失わない層が必要"* — ファイルストア + インデックス + オンデマンド読み込み。圧縮を越え、セッションを越えて。
>
> **Harness レイヤー**: 記憶 — 圧縮とセッションを越える知識の蓄積。

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
s01 → ... → s07 → s08 → `s09` → [s10](../s10_system_prompt/) → s11 → ... → s18 → s19
> *"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.

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
s01 → ... → s07 → s08 → `s09` → [s10](../s10_system_prompt/) → s11 → ... → s18 → s19
> *"压缩会丢细节, 要有一层不丢的"* — 文件仓库 + 索引 + 按需加载,跨压缩、跨会话。
>
> **Harness 层**: 记忆 — 跨压缩、跨会话的知识积累。

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
s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s18 → s19
> *"モデル入力は組み立てるもの、固定するものではない"* — 安定セクション + 実行時状態 + キャッシュ。
>
> **Harness レイヤー**: コンテキスト組み立て — 安定した指示と動的状態をモデル入力にまとめる。

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
s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s18 → s19
> *"Model input is assembled, not hardcoded"* — Stable sections + runtime state + caching.
>
> **Harness Layer**: Context assembly — turn stable instructions and dynamic state into model input.

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
s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s18 → s19
> *"模型输入是组装出来的,不是写死的"* — 稳定分段 + 运行时状态 + 缓存。
>
> **Harness 层**: 上下文组装 — 把稳定指令和动态状态组成模型输入。

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
s01 → ... → s09 → s10 → `s11` → [s12](../s12_task_system/) → s13 → ... → s18 → s19
> *"エラーは終わりではなく、リトライの始まり"* — トークン拡張、コンテキスト圧縮、モデル切り替え。
>
> **Harness 層**: 耐障害性 — メインループのエラーを分類し復旧。

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
s01 → ... → s09 → s10 → `s11` → [s12](../s12_task_system/) → s13 → ... → s18 → s19
> *"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.

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
s01 → ... → s09 → s10 → `s11` → [s12](../s12_task_system/) → s13 → ... → s18 → s19
> *"错误不是终点, 是重试的起点"* — 升级 token、压缩上下文、切换模型。
>
> **Harness 层**: 韧性 — 主循环遇到错误时分类并恢复。

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
s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s14 → ... → s18 → s19
> *"大きな目標を小さなタスクに分け、順序付け、永続化"* — ファイル永続化タスクグラフ、マルチ Agent 協調の基盤。
>

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
s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s14 → ... → s18 → s19
> *"Break big goals into small tasks, order them, persist"* — File-persisted task graph, the foundation for multi-agent collaboration.
>

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
s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s14 → ... → s18 → s19
> *"大目标拆成小任务, 排好序, 持久化"* — 文件持久化的任务图, 多 agent 协作的基础。
>

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
s01 → ... → s11 → s12 → `s13` → [s14](../s14_cron_scheduler/) → s15 → ... → s18 → s19
> *"遅い操作はバックグラウンドへ、agent は処理を継続"* — バックグラウンドスレッドでコマンドを実行、完了時に通知を注入。
>

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
s01 → ... → s11 → s12 → `s13` → [s14](../s14_cron_scheduler/) → s15 → ... → s18 → s19
> *"Slow operations go to the background, agent continues processing"* — Background threads run commands, inject notifications when done.
>

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
s01 → ... → s11 → s12 → `s13` → [s14](../s14_cron_scheduler/) → s15 → ... → s18 → s19
> *"慢操作丢后台, agent 继续处理"* — 后台线程跑命令, 完成后注入通知。
>

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
s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s18 → s19
> *"スケジュールに従って作業を生産、スケジューリングと実行を分離"* — cron スケジューリング、永続またはセッションレベル。
>
> **Harness 層**: スケジューリング — 独立スレッドが時刻を判定、キューがトリガーを配信。

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
s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s18 → s19
> *"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.

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
s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s18 → s19
> *"按时间表生产工作, 调度与执行解耦"* — cron 调度, 持久化或会话级。
>
> **Harness 层**: 调度 — 独立线程判断时间, 队列传递触发。

View file

@ -2,32 +2,34 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s13 → s14 → `s15` → [s16](../s16_autonomous_agents/) → s17 → s18 → s19 → s20 → s21
s01 → ... → s13 → s14 → `s15` → [s16](../s16_mcp_plugin/) → s17 → s18 → s19
> *「1 つの Agent だけでは扱いきれないなら、チームメイトで分担する。」* — 永続チームメイト、メッセージ配信、協調プロトコル。
> *「1 つの Agent で仕事全体を抱えきれないなら、チームメイトで分担する。」* — 永続チームメイト、共有タスクの Claim、任意の worktree、協調プロトコル。
>
> **Harness レイヤー**チーム — 複数 Agent を並行動作させながら制御を保つ
> **Harness レイヤー**Team — 複数の Agent が Lead の管理下で仕事を分担し、状態を共有する仕組み
---
## 問題
Agent にバックエンド全体のリファクタリングを頼む場合、設定読み込み、認証、テストを同時に扱うことになる。1 つの Agent が順番に処理することもできるが、時間がかかり、初期の詳細は徐々にコンテキストから抜けていく。
Agent にバックエンド全体のリファクタリングを依頼するとする。作業範囲は設定の読み込み、認証、テストにまたがる。1 つの Agent でも順番に処理できるが、時間がかかり、初期の詳細は少しずつコンテキストから抜けていく。
このような仕事は並列化に向いている。しかし、通常のユーザーはチーム構成ではなく目的だけを伝える:
この仕事は並列化に向いている。ただし、ユーザーは通常、チーム構成ではなく目標を伝える:
```text
このサンプルバックエンドをリファクタリングしてください。
設定読み込み、認証ロジック、テストを整理し、
既存インターフェースを保ったままテストを通してください。
設定読み込み、認証、テストを整理し、既存インターフェースを保ち、
テストが通ることを確認してください。
```
そのため Harness は、単に Agent を増やすだけでなく、次の 4 点を解決する必要がある:
Harness は、つながった 6 つの問題を扱う必要がある:
1. 並列化が有効かを誰が判断し、追加 Agent の起動を誰が確認するか。
2. チームメイトが複数の依頼にまたがって、どう身元とコンテキストを保つか。
3. モデルに受信箱を繰り返し確認させず、結果をどう Lead へ戻すか。
4. 終了と計画承認を、どう追跡可能で強制可能なプロトコルにするか。
1. 並列作業が有効だと誰が判断し、追加の Agent を誰が承認するのか。
2. 各チームメイトは、複数の割り当てをまたいで識別子とコンテキストをどう保つのか。
3. モデルに受信箱をポーリングさせず、結果を Lead へどう返すのか。
4. IDLE のチームメイトは、次の指示を待たずに ready task を引き受けられるか。
5. 並列編集が衝突し得る時、タスクはどの作業ディレクトリを使うのか。
6. shutdown と計画承認を、追跡できて実際に制約をかけるプロトコルにするにはどうするか。
---
@ -35,24 +37,25 @@ Agent にバックエンド全体のリファクタリングを頼む場合、
![Agent Teams Overview](images/agent-teams-overview.ja.svg)
s15 は単一 Agent の Harness の外側に、Lead が管理するチームランタイムを追加する:
s15 は、単一 Agent の Harness に Lead 管理のチームランタイムを加える:
- **Lead** はユーザーとの会話を維持し、分担案を提示して確認を待つ。
- **チームメイト** は独立した Agent Loop をバックグラウンドスレッドで実行し、作業後は IDLE になる。
- **MessageBus** はファイル受信箱を通して、通常メッセージ、結果、制御イベントを運ぶ。
- **ランタイム配信** は Lead の受信箱を消費し、チームイベントを次のターンへ注入する。
- **協調プロトコル**`type``request_id`、状態遷移で終了と計画承認を扱う。
- **計画ゲート** は、必要な計画が承認されるまで `bash``write_file` を遮断する。
- **Lead** はユーザーとの会話を担当し、分担案を示して確認を待つ。
- **チームメイト** は独立した Agent Loop を実行し、WORK と IDLE を行き来する。
- **MessageBus** は、ファイルベースの受信箱で通常メッセージ、結果、制御イベントを運ぶ。
- **ランタイム配信** は Lead の受信箱を消費し、チームイベントを次のターンへ追加する。
- **共有タスクボード** により、IDLE のチームメイトは ready task を探し、ロック下で Claim できる。
- **任意の worktree** は、必要なタスクだけを別の作業ディレクトリへ紐付ける。紐付けのないタスクは通常のリポジトリディレクトリを使う。
- **型付きプロトコルと計画ゲート** は shutdown と承認状態を明示し、必要な計画が承認されるまで変更系ツールを止める。
モデルはタスクを理解して分担を決める。コードは配信、ライフサイクル、プロトコル制約を担う
これらはすべて Team Harness レイヤーの一部である。タスク発見のために別の Agent Loop は要らず、worktree が別種の Agent を作るわけでもない
---
## 仕組み
### 1. Lead はチーム案を示し、確認を待つ
### 1. Lead はチーム案を示し、ユーザーの確認を待つ
チームメイトの起動は、コスト、並行度、ワークスペースを書き換える主体を変える。この境界を通常のツール呼び出しの中に隠してはいけない。Lead の system prompt は次のように定める:
チームメイトを起動すると、コスト、並行度、ワークスペースを編集できる主体が変わる。Lead のシステムプロンプトは、その境界を明示する:
```python
"When parallel work would help, first propose a small team with clear "
@ -60,35 +63,35 @@ s15 は単一 Agent の Harness の外側に、Lead が管理するチームラ
"spawn_teammate before the user confirms."
```
最初の依頼に対して、Lead はまず分担案だけを返す:
最初の要求に対して、Lead は分担案だけを示す:
```text
次の 3 方向で並行処理することを提案します。
- config設定読み込みの整理
- auth認証ロジックのリファクタリング
- tests回帰テスト追加
3 つの領域を並行して進めることを提案します:
- config設定の読み込みを整理
- auth認証リファクタリング
- tests回帰テスト追加
確認後にチームメイトを起動します。
```
ユーザーが「始めてください」と返した後で、Lead は `spawn_teammate` を呼ぶ。ユーザーが目的を示し、Lead がチームを設計し、ユーザーが実行境界を確認する。
ユーザーが「始めてください」と返した後、Lead は `spawn_teammate` を呼べる。ユーザーが目標を示し、Lead がチームを設計し、ユーザーが実行境界を確認する。
### 2. 各チームメイトは独立したループを持つ
s06 の Subagent は 1 回限りの呼び出しだが、チームメイトは永続する実行単位である
s06 の subagent は 1 回限りの呼び出しである。チームメイトは永続する実行単位だ
| | s06 Subagent | s15 チームメイト |
| | s06 Subagent | s15 Teammate |
|---|---|---|
| ライフサイクル | 1 回の呼び出し後に終了 | 終了要求まで `WORK → IDLE → WORK` |
| コンテキスト | 1 つのタスクだけ | 複数の依頼をまたいで保持 |
| 通信 | 1 回だけ結果を返す | メッセージを受け取りイベントを送る |
| 協調 | 一方向の委 | Lead との双方向協調 |
| ライフサイクル | 1 回の呼び出し後に終了 | shutdown まで `WORK → IDLE → WORK` |
| コンテキスト | 1 つのタスクにだけ存在 | 割り当てをまたいで保持 |
| 通信 | 1 回だけ結果を返す | メッセージを受け取りイベントを送る |
| 協調 | 一方向の委 | Lead との双方向協調 |
`spawn_teammate_thread()`チームメイトごとに system prompt、messages、ツールを作り、daemon thread でループを実行する。Lead はチームメイトの終了を待たずに、別の依頼や結果を調整できる。
`spawn_teammate_thread()`、各チームメイト専用のシステムプロンプト、messages、ツール、現在の作業ディレクトリ状態を用意し、daemon thread でループを実行する。チームメイトの作業中も Lead は調整を続けられる。`lead``agent` はランタイム識別子として予約されるが、`MessageBus` はコーディネーターの受信箱として `lead` を引き続き受け付ける。
### 3. MessageBus は通信をモデルのコンテキスト外に置く
Lead とチームメイトが同じ messages 配列を共有すると、あるチームメイトのツール結果が別のチームメイトの推論へ混ざる。`MessageBus` Agent に `.mailboxes/<name>.jsonl` 受信箱を与える:
Lead とチームメイトは同じ messages 配列を共有できない。共有すると、あるチームメイトのツール結果が別のチームメイトの推論へ混ざる。`MessageBus` は Agent ごと`.mailboxes/<name>.jsonl` 受信箱を用意する:
```python
class MessageBus:
@ -102,21 +105,27 @@ class MessageBus:
"metadata": metadata or {},
}
with self._changed:
append_jsonl(self._path(to_agent), msg)
with open(self._path(to_agent), "a") as f:
f.write(json.dumps(msg) + "\n")
self._changed.notify_all()
def wait_for_messages(self, agent):
def wait_for_messages(self, agent, timeout=None):
deadline = None if timeout is None else time.monotonic() + timeout
with self._changed:
while not self.peek(agent):
self._changed.wait()
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)
```
ロックは複数スレッドによる受信箱ファイルの破損を防ぐ。`Condition` により、IDLE のチームメイトはポーリングせずイベント到着まで待機できる。
ロックは、チームメイトによる受信箱ファイルの並行アクセスを保護する。`Condition` はメッセージ到着時にチームメイトを起こし、IDLE 中の短い timeout にも使える。
### 4. 受信イベントはランタイムが自動配信する
### 4. 受信イベントはランタイムが配信する
`read_inbox()`メッセージを読み、受信箱ファイルを削除する。そのため Lead の消費入口`consume_lead_inbox()` だけにする:
`read_inbox()`受信箱ファイルを読み取って削除するため、Lead 側の消費処理`consume_lead_inbox()` だけにする:
```python
def consume_lead_inbox():
@ -127,33 +136,165 @@ def consume_lead_inbox():
return messages
```
メインループのイベントスレッドは、新しいメッセージが届くと Lead を起こす:
メインループの隣で動くイベントスレッドが、新しいメッセージの到着時に Lead を起こす:
```text
MessageBus → consume_lead_inbox
→ プロトコル状態を更新
→ [Team events] を history へ注入
→ [Team events] を history に追加
→ Lead の次ターンを開始
```
`check_inbox` はモデルのツールではない。メッセージの到着はランタイムの責務であり、モデルはコンテキストへ配信済みのイベントだけを処理する。
`check_inbox` はモデルのツールではない。メッセージの到着と消費はランタイムが担当し、モデルはコンテキストへ配信済みのイベントを処理する。
### 5. 結果と IDLE は別のイベント
### 5. 結果と IDLE は別のイベントである
チームメイトが 1 件の作業を終えると、ランタイムは次の順序で 2 つのイベントを送る:
チームメイトが 1 つの割り当てを終えると、ランタイムは 2 つのイベントを順に送る:
```text
result: "認証をリファクタリングし、関連テストが通りました。"
idle_notification: "Waiting for more work."
```
`result` は「今回の作業で何が得られたか」、`idle_notification` は「新しい仕事を受けられるか」を表す。1 つの曖昧な「done」では両者を区別できない。
`result` は「この割り当てで何ができたか」、`idle_notification` は「このチームメイトが次の仕事を受けられるか」を表す。曖昧な「完了」だけでは、両方の状態を表せない。
IDLE になったチームメイトは終了しない。通常メッセージで WORK に戻り、`shutdown_request` で終了ハンドシェイクを始める。
IDLE のチームメイトは終了しない。直接メッセージか ready task を受けると WORK に戻り、`shutdown_request` を受けると段階的な shutdown handshake を始める。
### 6. 制御メッセージには型と request_id を使う
### 6. IDLE は受信箱を先に確認し、その後 ready task を探す
通常の協調は自由文でよいが、終了と承認を意図の推測に任せてはいけない。制御イベントは構造化する:
IDLE ではメッセージを優先し、その後に共有タスクボードを確認する:
```python
while True:
inbox = BUS.wait_for_messages(name, IDLE_SCAN_INTERVAL)
if inbox:
should_stop = handle_messages(inbox)
if should_stop or messages[-1]["role"] == "user":
break
continue
task = claim_next_task(name)
if task:
messages.append({
"role": "user",
"content": f"[Auto-claimed task {task.id}] {task.subject}",
})
break
```
shutdown、計画承認、Lead からの直接指示は、空き時間に見つけた仕事より先に扱う。メッセージも ready task もなければ、チームメイトは IDLE を続ける。別のチームメイトが前提タスクを完了すると、blocked task が ready になることもある。
### 7. 発見と Claim を分け、Claim はアトミックに行う
走査は候補を探すだけで、状態を変更しない:
```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)
]
```
候補一覧は一時点の snapshot にすぎない。別のチームメイトも同じタスクを見る可能性があるため、所有権の変更は `task_lock` で保護した `claim_task()` 内で行う:
```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 is not None:
return "Task is no longer available"
if _owner_in_progress(owner):
return "Owner must complete its current task first"
if not can_start(task_id):
return "Task is blocked"
cwd, error = task_worktree_cwd(task)
if error:
return f"Cannot claim {task_id}: {error}"
task.owner = owner
task.status = "in_progress"
save_task(task)
teammate_assignments[owner] = {"task_id": task.id, "cwd": cwd}
return f"Claimed {task.id}"
```
複数のチームメイトが同じ候補を発見しても、`in_progress` へ進められる Claim は 1 つだけである。現在のタスクを完了するまで、チームメイトは次のタスクを Claim できない。worktree の紐付けが壊れている場合、リポジトリディレクトリへ戻さず Claim を失敗させる。
### 8. Claim した仕事は同じ WORK ループを再利用する
Claim に成功すると、ランタイムはタスク ID、件名、説明をチームメイトの messages へ追加する:
```text
ready task が現れる
→ IDLE のチームメイトが発見
→ claim_task が owner と in_progress を記録
→ タスクがチームメイトの messages に入る
→ WORK
→ complete_task
→ result + idle_notification
→ IDLE
```
チームメイトは、Lead が直接割り当てた時と同じモデル呼び出し、ファイルツール、Shell、計画ゲート、結果通知、shutdown protocol を使う。タスク発見は、既存の WORK ループへの別の入口である。
### 9. タスクがツールの作業ディレクトリを選ぶ
`Task.worktree` は任意フィールドである:
```python
@dataclass
class Task:
id: str
subject: str
description: str
status: str
owner: str | None
blockedBy: list[str]
worktree: str | None = None
```
並列編集を別ディレクトリに分けたい時、Lead は worktree を作成してタスクへ紐付けられる:
```python
create_worktree(name="auth-refactor", task_id="task_1234")
```
`create_worktree` は Lead 専用ツールである。pending、owner なし、worktree 未設定のタスクを受け取り、名前、パス、ブランチ、Git registry を確認する。checkout の作成後にだけタスクへ紐付ける。Git が失敗を返しても branch や登録済み checkout が残った場合は partial operation を報告し、task は未紐付けのまま、それらを manual recovery 用に保持する。チームメイトが使うのはタスクツールとファイルツールである。
Claim 時に、解決済みのディレクトリを `teammate_assignments` へ保存する。チームメイトの `bash``read_file``write_file` wrapper は assignment からディレクトリを読む。worktree のないタスクは `WORKDIR` に解決されるため、worktree は opt-in である:
```python
cwd, error = task_worktree_cwd(task)
if not error:
teammate_assignments[owner] = {
"task_id": task.id,
"cwd": cwd,
}
```
`complete_task(task_id, owner)` は、呼び出し元が進行中タスクの owner か確認する。ランタイムが assignment を削除するのは完了に成功した時だけである。失敗時はタスクのディレクトリを維持し、チームメイトが修正して再試行できるようにする。タスクの `worktree` 紐付けは checkout を削除するまで残る。
> Worktree が分離するのは Git の作業ディレクトリとブランチであり、sandbox ではない。Shell コマンドは親プロセスに許可されたパスやリソースへアクセスできる。
### 10. Worktree のクリーンアップはデフォルトで作業を残す
モデル向けの `remove_worktree(name)` tool は、`pending` または `in_progress` のタスクに紐付いた worktree の削除を拒否する。タスク完了後も tracked、untracked、ignored file をすべて未コミットデータとして扱い、clean な checkout だけを `--force` なしで削除する。
低レベルの Python helper は、host が別途ユーザーの明示的な確認を得た場合のために `discard_changes=True` を残すが、この parameter はモデルの tool schema にはない。変更のある worktree は削除せず、user が確認できる状態で残す。どちらの削除経路でも `wt/<name>` ブランチはリポジトリに残り、upstream のない clean な local commit も保持される。削除成功後は checkout が存在しないため、タスクの worktree 紐付けを解除する。
```text
clean worktree → ディレクトリを削除し、wt/<name> ブランチは保持
changed worktree → model tool は拒否し、保持か破棄かを user が決める
pending/running task → 削除を拒否
```
タスク完了と worktree cleanup も分かれている。`complete_task` はタスク結果を記録し、Lead はその後に worktree を確認、merge、keep、remove できる。
### 11. 制御メッセージには型と request_id を使う
通常の協調には自由形式のテキストを使えるが、shutdown と承認を意図の推測に任せるべきではない。これらは構造化メッセージを使う:
![Team Protocols](images/team-protocols-overview.ja.svg)
@ -171,22 +312,22 @@ class ProtocolState:
pending_requests: dict[str, ProtocolState] = {}
```
終了プロトコルは次の経路を通る
shutdown の流れは次の通り
```text
Lead が pending の shutdown request を作る
→ shutdown_request(request_id) をチームメイトへ送
→ チームメイトが現在の手順を終える
→ shutdown_response(request_id) を Lead へ返す
→ request_id で元の要求を特定する
→ pending が approved になり、チームメイトループが終了する
→ shutdown_request(request_id) がチームメイトの受信箱に入
→ チームメイトが現在のステップを終える
→ shutdown_response(request_id) が Lead へ戻る
→ request_id で元の request を特定する
→ pending が approved になり、チームメイトの loop が終了する
```
ID は要求と応答を対応付け、型は誤った応答による状態変更を防ぎ、状態は重複応答の再適用を防ぐ。
ID は応答を 1 つの request に対応付け、型は不一致の応答による状態変更を防ぎ、status は同じ応答の二重適用を防ぐ。
### 7. 計画承認は実行も制約する
### 12. 計画承認は実行も制約する
計画プロトコルは逆方向に流れる
計画プロトコルは逆方向に進む
```text
Lead → plan_request
@ -194,7 +335,7 @@ Lead → plan_request
Lead → plan_approval_response(request_id, approve, feedback)
```
「承認まで待つ」と伝えるだけでは確実なゲートにならない。そこでツール dispatch が計画状態を検査する:
ツール dispatch がゲートを強制する:
```python
def _run_teammate_tool(name, block, handlers):
@ -206,32 +347,38 @@ def _run_teammate_tool(name, block, handlers):
return handlers[block.name](**block.input)
```
状態が `required``pending``rejected` の間、チームメイトはファイルを読み、計画を提出または修正できるが、Shell 実行やファイル書き込みはできない。承認応答で `approved` になった後にだけツールが解放される。
状態が `required``pending``rejected` の間、チームメイトはファイルを読み、計画を提出または修正できるが、Shell コマンドの実行とファイルの書き込みはできない。承認応答で状態が `approved` になると、ツールを使えるようになる。
---
## 一連の実行例
```text
s15 >> このサンプルバックエンドをリファクタリングしてください。
設定読み込み、認証、テストを整理し、
既存インターフェースを保ってテストを通してください。
s15 >> バックエンドのリファクタリングを共有タスクボードに分解し、
設定、認証、テストを可能な範囲で並行実行してください。
認証には worktree を使い、既存インターフェースを保ち、
テストが通ることを確認してください。
Lead: config、auth、tests の 3 方向で並行処理することを提案します。
チームを開始しますか?
Leadconfig、auth、tests の 3 領域に分けることを提案します。
チームを起動しますか?
s15 >> 始めてください
[teammate] config spawned
[teammate] auth spawned
[teammate] tests spawned
[bus] auth → lead (result) ...
[bus] auth → lead (idle_notification) ...
[task] config created
[task] auth created → worktree auth-refactor
[task] tests created
[teammate] alice spawned
[teammate] bob spawned
[claim] alice → config (cwd: repository)
[claim] bob → auth (cwd: .worktrees/auth-refactor)
[complete] auth
[bus] bob → lead (result) ...
[bus] bob → lead (idle_notification) ...
[wake: 2 team events → new turn]
Lead: 認証の結果を受け取りました。残りの作業も調整します。
Lead:認証タスクの結果を受け取りました。残りの作業を調整します。
```
端末には、ユーザー要求、Lead の分担、起動、メッセージ、結果、IDLE、終了イベントが表示される。ユーザーが Lead を指名したり、受信箱の確認を頼んだりする必要はない。
ターミナルには、ユーザーの要求、Lead の提案、タスク状態、Claim、選択されたディレクトリ、結果、IDLE 遷移、制御イベントが表示される。ユーザーが Lead を指定したり、受信箱の確認を依頼したりする必要はない。
---
@ -239,13 +386,15 @@ Lead: 認証の結果を受け取りました。残りの作業も調整しま
| コンポーネント | s14 | s15 |
|---|---|---|
| Agent | 1 つ | 1 つの Lead + 永続チームメイト |
| ユーザーフロー | 依頼を直接実行 | チーム案を提示してから起動を確認 |
| 通信 | なし | ファイル受信箱 + 自動イベント配信 |
| Agent | 1 つの Agent | 1 つの Lead と永続チームメイト |
| ユーザーフロー | 要求を実行 | チーム案を示してから起動確認 |
| 通信 | なし | ファイル受信箱とランタイム配信 |
| ライフサイクル | 1 つのループ | チームメイトの `WORK / IDLE / shutdown` |
| 共有作業 | Lead の既存タスクツール | IDLE 走査とチームメイトのアトミックな Claim |
| 作業ディレクトリ | リポジトリの `WORKDIR` | デフォルトは `WORKDIR`、タスクごとに worktree を選択可能 |
| 結果通知 | 現在の Agent の出力 | `result``idle_notification` を分離 |
| 制御 | なし | 終了と計画承認プロトコル |
| 強制 | チーム制約なし | 必須計画が変更系ツールをゲート |
| 制御 | なし | 型付き shutdown と計画承認プロトコル |
| 強制 | チーム向け制約なし | 必須計画が変更系ツールをゲート |
---
@ -256,12 +405,12 @@ cd learn-claude-code
python s15_agent_teams/code.py
```
まず通常の依頼を入力する:
通常の要求を入力する:
```text
このサンプルバックエンドをリファクタリングしてください。
設定読み込み、認証ロジック、テストを整理し、
既存インターフェースを保ったままテストを通してください。
バックエンドのリファクタリングを共有タスクボードへ分解し、依存関係が
許す範囲で設定、認証、テストを並行実行してください。認証には worktree
を使い、既存インターフェースを維持して、最後に結果をまとめてください。
```
Lead がチーム案を示したら、次のように返す:
@ -270,14 +419,16 @@ Lead がチーム案を示したら、次のように返す:
始めてください
```
`spawned`、`result``idle_notification``plan_approval_*``shutdown_*` の各イベントと、`.mailboxes/` のファイルが生成・消費される流れを確認する。
`.tasks/` が `pending``in_progress``completed` と変化する様子、`.mailboxes/``result``idle_notification` を配信する様子、紐付けたタスクにだけ `.worktrees/` が作られることを確認する。直接メッセージがタスクボード走査より優先されることと、`complete_task` の失敗後もチームメイトの作業ディレクトリが変わらないことも確認できる。
---
## 次へ
s15 では、Lead が各チームメイトへ明示的に仕事を割り当てる。次のセッションでは共有タスクボードを IDLE のチームメイトに公開し、実行可能な仕事を自ら見つけて claim できるようにする。
チームランタイムは、委譲、共有タスクの Claim、任意の作業ディレクトリを扱えるようになった。ただし、ツールは今も Python コードへ直接定義している。
へ:[s16 Autonomous Agents](../s16_autonomous_agents/)
のレッスンでは、標準の発見・呼び出しプロトコルを使って外部ツールへ接続する
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
次へ:[s16 MCP Tools](../s16_mcp_plugin/)。
<!-- translation-sync: zh@v3, en@v3, ja@v3 -->

View file

@ -2,11 +2,11 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s13 → s14 → `s15` → [s16](../s16_autonomous_agents/) → s17 → s18 → s19 → s20 → s21
s01 → ... → s13 → s14 → `s15` → [s16](../s16_mcp_plugin/) → s17 → s18 → s19
> *"When one agent cannot hold the whole job, let teammates divide the work."* — Persistent teammates, message delivery, and coordination protocols.
> *"When one agent cannot hold the whole job, let teammates divide the work."* — Persistent teammates, shared task selection, optional worktrees, and coordination protocols.
>
> **Harness layer**: Team — how multiple agents work in parallel without losing control.
> **Harness layer**: Team — how multiple agents divide work, share state, and stay under Lead's control.
---
@ -22,12 +22,14 @@ authentication, and tests, preserve the existing interfaces,
and make sure the tests pass.
```
The harness therefore has to solve four connected problems:
The harness has to answer a connected set of questions:
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?
3. How do results return to Lead without asking the model to poll an inbox?
4. Can an idle teammate pick up ready work without waiting for another assignment?
5. Which directory should a task use when parallel edits may conflict?
6. How do shutdown and plan approval become traceable, enforceable protocols?
---
@ -35,24 +37,25 @@ The harness therefore has to solve four connected problems:
![Agent Teams Overview](images/agent-teams-overview.en.svg)
s15 adds a Lead-managed team runtime around the single-agent harness:
s15 adds one Lead-managed team runtime around the single-agent harness:
- **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.
- **Teammates** run independent agent loops and alternate between WORK and IDLE.
- **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.
- **The shared task board** lets idle teammates find ready work and claim it under a lock.
- **Optional worktrees** bind a task to another working directory when the work needs it. Unbound tasks use the normal repository directory.
- **Typed protocols and a plan gate** make shutdown and approval state explicit and block mutating tools until a required plan is approved.
The model understands tasks and chooses a useful division of work. Code owns delivery, lifecycle, and protocol constraints.
These are all parts of the Team harness layer. Teammates do not need a separate loop for task discovery, and a worktree does not create a new kind of agent.
---
## How It Works
### 1. Lead proposes a team and waits for confirmation
### 1. Lead proposes a team and waits for user confirmation
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:
Starting teammates changes cost, concurrency, and the set of actors that may edit the workspace. Lead's system prompt keeps that boundary visible:
```python
"When parallel work would help, first propose a small team with clear "
@ -84,7 +87,7 @@ An s06 subagent is a one-shot call. A teammate is a persistent execution unit:
| 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.
`spawn_teammate_thread()` gives each teammate its own system prompt, messages, tools, and current working-directory state, then runs its loop in a daemon thread. Lead can keep coordinating while teammates work. The names `lead` and `agent` are reserved for runtime identities, while `MessageBus` still accepts `lead` as the coordinator mailbox.
### 3. MessageBus keeps communication outside model context
@ -102,19 +105,25 @@ class MessageBus:
"metadata": metadata or {},
}
with self._changed:
append_jsonl(self._path(to_agent), msg)
with open(self._path(to_agent), "a") as f:
f.write(json.dumps(msg) + "\n")
self._changed.notify_all()
def wait_for_messages(self, agent):
def wait_for_messages(self, agent, timeout=None):
deadline = None if timeout is None else time.monotonic() + timeout
with self._changed:
while not self.peek(agent):
self._changed.wait()
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)
```
A lock protects mailbox files from concurrent teammate access. A `Condition` lets idle teammates sleep until an event arrives instead of polling continuously.
A lock protects mailbox files from concurrent access. A `Condition` lets the runtime wake a teammate for a message and also supports the short timeout used while IDLE.
### 4. The runtime delivers inbox events automatically
### 4. The runtime delivers inbox events
`read_inbox()` consumes messages by reading and deleting the mailbox file, so Lead keeps a single consumer, `consume_lead_inbox()`:
@ -136,9 +145,9 @@ MessageBus → consume_lead_inbox
→ start another Lead turn
```
`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.
`check_inbox` is not a model tool. Message arrival belongs to the runtime; the model handles events after the runtime has delivered them into its context.
### 5. Result and idle are separate events
### 5. Result and IDLE are separate events
When a teammate finishes one assignment, the runtime sends two events in order:
@ -147,13 +156,145 @@ 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.
`result` answers "What did this assignment produce?" `idle_notification` answers "Can this teammate accept more work?" One 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.
An idle teammate does not exit. A direct message or a ready task returns it to WORK; a `shutdown_request` starts a graceful shutdown handshake.
### 6. Control messages use types and request IDs
### 6. IDLE checks the mailbox before looking for ready tasks
Free-form text is fine for ordinary collaboration, but shutdown and approval should not depend on guessing intent. They use structured messages:
IDLE gives messages priority, then checks the shared task board:
```python
while True:
inbox = BUS.wait_for_messages(name, IDLE_SCAN_INTERVAL)
if inbox:
should_stop = handle_messages(inbox)
if should_stop or messages[-1]["role"] == "user":
break
continue
task = claim_next_task(name)
if task:
messages.append({
"role": "user",
"content": f"[Auto-claimed task {task.id}] {task.subject}",
})
break
```
Shutdown, plan approval, and direct instructions from Lead should arrive before opportunistic work. If there is no message and no ready task, the teammate remains IDLE. A blocked task may become ready after another teammate completes its prerequisite.
### 7. Discovery and claim are separate, and claim is atomic
Scanning only finds candidates:
```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)
]
```
The list is a snapshot. Another teammate may see the same task, so ownership changes happen inside `claim_task()` under `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 is not None:
return "Task is no longer available"
if _owner_in_progress(owner):
return "Owner must complete its current task first"
if not can_start(task_id):
return "Task is blocked"
cwd, error = task_worktree_cwd(task)
if error:
return f"Cannot claim {task_id}: {error}"
task.owner = owner
task.status = "in_progress"
save_task(task)
teammate_assignments[owner] = {"task_id": task.id, "cwd": cwd}
return f"Claimed {task.id}"
```
Many teammates may discover the same candidate, but only one claim can move it to `in_progress`. A teammate must also finish its current task before claiming another, and a broken worktree binding fails closed rather than falling back to the repository directory.
### 8. Claimed work reuses the same WORK loop
After a successful claim, the runtime injects the task ID, subject, and description into the teammate's messages:
```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
→ IDLE
```
The teammate uses the same model call, file tools, Shell, plan gate, result reporting, and shutdown protocol as a direct Lead assignment. Task discovery is another entry into the existing WORK loop.
### 9. The task selects the tools' working directory
`Task.worktree` is optional:
```python
@dataclass
class Task:
id: str
subject: str
description: str
status: str
owner: str | None
blockedBy: list[str]
worktree: str | None = None
```
Lead can create and bind a worktree when separate directories will help:
```python
create_worktree(name="auth-refactor", task_id="task_1234")
```
`create_worktree` is a Lead-only tool. It accepts a pending, unowned, unbound task, validates the name, path, branch, and Git registry, creates the checkout, then writes the task binding. If Git reports failure after leaving a branch or registered checkout, the runtime reports a partial operation, leaves the task unbound, and preserves those artifacts for manual recovery. Teammates only see task and file tools.
Claiming the task stores its resolved directory in `teammate_assignments`; that teammate's `bash`, `read_file`, and `write_file` wrappers read the directory from the assignment. A task with no worktree resolves to `WORKDIR`, so worktrees remain opt-in:
```python
cwd, error = task_worktree_cwd(task)
if not error:
teammate_assignments[owner] = {
"task_id": task.id,
"cwd": cwd,
}
```
`complete_task(task_id, owner)` checks that the caller owns the in-progress task. It clears the assignment only after completion succeeds. A failed completion leaves the task directory selected so the teammate can fix the task and try again. The task keeps its `worktree` binding until that checkout is removed.
> A worktree separates Git working directories and branches. It is not a sandbox: Shell commands can still access paths and resources allowed to the parent process.
### 10. Worktree cleanup preserves work by default
The model-facing `remove_worktree(name)` tool refuses to remove a worktree while its bound task is `pending` or `in_progress`. After the task is completed, it still treats tracked, untracked, and ignored files as uncommitted data, then asks Git to remove only a clean checkout without `--force`.
The lower-level Python helper retains `discard_changes=True` for host code that has already obtained explicit user confirmation, but that parameter is not present in the model's tool schema. A dirty worktree is left for the user to inspect. Either removal path retains the `wt/<name>` branch, including clean local commits with no upstream. A successful removal clears the task's worktree binding because the checkout no longer exists.
```text
clean worktree → remove directory, retain wt/<name> branch
changed worktree → model tool refuses; user decides how to preserve or discard it
pending/running task → refuse removal
```
Task completion also stays separate from worktree cleanup. `complete_task` records the task result; Lead can inspect, merge, keep, or remove the worktree afterward.
### 11. Control messages use types and request IDs
Free-form text works 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)
@ -184,7 +325,7 @@ Lead creates a pending shutdown request
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
### 12. Plan approval constrains execution
The plan protocol runs in the opposite direction:
@ -194,7 +335,7 @@ 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:
Tool dispatch enforces the gate:
```python
def _run_teammate_tool(name, block, handlers):
@ -206,32 +347,38 @@ def _run_teammate_tool(name, block, handlers):
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`.
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 after an approval response changes the state to `approved`.
---
## One Complete Run
```text
s15 >> Refactor this sample backend. Clean up configuration loading,
authentication, and tests, preserve existing interfaces,
s15 >> Put the backend refactor on a shared task board. Clean up
configuration, authentication, and tests in parallel where possible.
Use a worktree for authentication, preserve existing interfaces,
and make sure the tests pass.
Lead: I suggest config, auth, and tests as three parallel areas.
Lead: I suggest config, auth, and tests as three 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) ...
[task] config created
[task] auth created → worktree auth-refactor
[task] tests created
[teammate] alice spawned
[teammate] bob spawned
[claim] alice → config (cwd: repository)
[claim] bob → auth (cwd: .worktrees/auth-refactor)
[complete] auth
[bus] bob → lead (result) ...
[bus] bob → 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.
The terminal exposes the user request, Lead's proposal, task state, claims, selected directories, results, IDLE transitions, and control events. The user does not have to name a Lead or ask it to check an inbox.
---
@ -241,10 +388,12 @@ The terminal exposes the user request, Lead's split, teammate startup, messages,
|---|---|---|
| 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 |
| Communication | None | File mailboxes plus runtime delivery |
| Lifecycle | One loop | Teammate `WORK / IDLE / shutdown` |
| Shared work | Lead's existing task tools | IDLE scan plus atomic teammate claims |
| Working directory | Repository `WORKDIR` | `WORKDIR` by default, optional task worktree |
| Reporting | Current agent output | Separate `result` and `idle_notification` |
| Control | None | Shutdown and plan approval protocols |
| Control | None | Typed shutdown and plan approval protocols |
| Enforcement | No team constraint | Required plans gate mutating tools |
---
@ -259,9 +408,10 @@ python s15_agent_teams/code.py
Start with an ordinary request:
```text
Refactor this sample backend. Clean up configuration loading,
authentication, and tests, preserve the existing interfaces,
and make sure the tests pass.
Put the backend refactor on a shared task board. Complete configuration,
authentication, and tests in parallel where dependencies allow. Use a
worktree for authentication, preserve existing interfaces, and summarize
the result.
```
After Lead proposes the team, reply:
@ -270,14 +420,16 @@ After Lead proposes the team, reply:
Go ahead.
```
Watch for `spawned`, `result`, `idle_notification`, `plan_approval_*`, and `shutdown_*` events, along with mailbox files appearing and being consumed under `.mailboxes/`.
Watch `.tasks/` move from `pending` to `in_progress` and `completed`, `.mailboxes/` deliver `result` and `idle_notification`, and `.worktrees/` appear only for the bound task. Also check that direct messages beat task-board scans and that a failed `complete_task` does not reset the teammate's working directory.
---
## Next
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.
The team runtime now covers delegation, shared task selection, and optional working directories. Its tools are still defined directly in Python.
Next: [s16 Autonomous Agents](../s16_autonomous_agents/).
The next lesson connects external tools through a standard discovery and invocation protocol.
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
Next: [s16 MCP Tools](../s16_mcp_plugin/).
<!-- translation-sync: zh@v3, en@v3, ja@v3 -->

View file

@ -2,31 +2,33 @@
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s13 → s14 → `s15` → [s16](../s16_autonomous_agents/) → s17 → s18 → s19 → s20 → s21
s01 → ... → s13 → s14 → `s15` → [s16](../s16_mcp_plugin/) → s17 → s18 → s19
> *"一个 Agent 顾不过来,就让队友分工协作。"* — 持久队友、消息投递与协作协议。
> *“一个 Agent 装不下整项工作时,就让队友分头完成。”* — 持久队友、共享任务认领、可选 worktree 与协作协议。
>
> **Harness 层**团队 — 多个 Agent 如何并行工作,又如何保持可控
> **Harness 层**Team团队— 多个 Agent 如何分工、共享状态,同时接受 Lead 控制
---
## 问题
当我们需要 Agent 帮助我们重构整个后端时,任务可能同时涉及配置加载、认证逻辑和测试。一个 Agent 依次处理所有模块,不但耗时更长,早期细节也会逐渐退出上下文。
假设我们让 Agent 重构整个后端,工作涉及配置加载、认证和测试。一个 Agent 可以依次处理,但总耗时更长,早期细节也会逐渐离开上下文。
这类任务适合拆给多个 Agent但用户通常只会描述需求不会先设计一套团队:
这类工作适合并行,可用户通常只描述目标,不会替运行时设计团队:
```text
请重构这个示例后端,分别整理配置加载、认证逻辑和测试,
保持现有接口兼容,并确保测试通过。
重构这个示例后端。清理配置加载、认证和测试,
保持现有接口,并确保测试通过。
```
因此Harness 需要连续解决四个问题:
Harness 需要回答一组相互关联的问题:
1. 谁判断任务是否值得并行,以及如何征得用户确认?
2. 队友如何保留自己的身份和上下文,持续接收工作?
3. 队友的结果如何自动回到 Lead而不是依赖模型反复检查邮箱
4. 关机与计划审批如何变成可追踪、可执行的协议?
1. 谁判断并行是否有用,新增 Agent 又由谁确认?
2. 每个队友如何跨任务保留身份和上下文?
3. 结果如何自动返回 Lead而不是让模型轮询收件箱
4. 空闲队友能否直接接手 ready task不再等待 Lead 逐项派发?
5. 并行修改可能冲突时,任务应该使用哪个工作目录?
6. 关机和计划审批如何成为可追踪、可执行的协议?
---
@ -34,16 +36,17 @@ s01 → ... → s13 → s14 → `s15` → [s16](../s16_autonomous_agents/) → s
![Agent Teams Overview](images/agent-teams-overview.svg)
s15 在单 Agent Harness 外增加一由 Lead 管理的团队运行时:
s15 在单 Agent Harness 外增加一由 Lead 管理的团队运行时:
- **Lead** 保持用户对话,判断是否需要团队,提出分工并等待确认。
- **队友** 在独立线程中运行自己的 Agent Loop完成工作后进入空闲。
- **MessageBus** 用文件邮箱传递普通消息、结果和控制事件。
- **运行时投递** 自动消费 Lead 的邮箱,把团队事件注入下一轮上下文。
- **协作协议**`type``request_id` 和状态机处理关机与计划审批。
- **计划闸门** 在计划未批准时拦截队友的 `bash``write_file`
- **Lead** 负责用户对话,提出分工方案并等待确认。
- **队友** 运行独立 Agent Loop在 WORK 和 IDLE 之间切换。
- **MessageBus** 通过文件收件箱传递普通消息、结果和控制事件。
- **运行时投递** 消费 Lead 的收件箱,把团队事件注入下一轮对话。
- **共享任务板** 让空闲队友发现 ready task并在锁内完成认领。
- **可选 worktree** 在需要时把任务绑定到另一个工作目录;未绑定任务仍使用仓库目录。
- **类型化协议和计划闸门** 显式记录关机与审批状态,并在计划获批前阻止修改型工具。
模型负责理解任务与分工,代码负责消息投递、生命周期和协议约束
这些机制都属于 Team 这一层。任务发现不需要另一套 Agent Loopworktree 也不会产生另一种 Agent
---
@ -51,7 +54,7 @@ s15 在单 Agent Harness 外增加一个由 Lead 管理的团队运行时:
### 1. Lead 先提出团队,再等待用户确认
是否创建团队会改变成本、并发度和可写入范围不应该被隐藏在一次普通工具调用里。Lead 的 system prompt 明确规定
启动队友会改变成本、并发度和可以修改工作区的角色集合。Lead 的系统提示词会把这条边界明确写出来
```python
"When parallel work would help, first propose a small team with clear "
@ -59,35 +62,35 @@ s15 在单 Agent Harness 外增加一个由 Lead 管理的团队运行时:
"spawn_teammate before the user confirms."
```
第一次输入需求时Lead 只需要说明建议的拆分
收到第一条需求后Lead 只提出分工
```text
我建议分成三个方向并行处理:
- config理配置加载
- auth重构认证逻辑
- tests回归测试
我建议并行处理三个方向
- config理配置加载
- auth重构认证
- tests回归测试
确认后我会启动队友并协调结果
你确认后我再启动队友
```
用户回复“开始吧”后Lead 才调用 `spawn_teammate`。用户表达目标Lead 设计团队,用户确认执行边界;三者的职责不会混在一起
用户回复“开始吧”后Lead 才能调用 `spawn_teammate`。用户给出目标Lead 设计团队,用户确认执行边界。
### 2. 每个队友拥有独立循环
s06 的子 Agent 是一次性调用,返回结果后就结束。队友则是持久执行单元:
s06 的 subagent 是一次性调用,队友则是持久执行单元:
| | s06 子 Agent | s15 队友 |
| | s06 Subagent | s15 队友 |
|---|---|---|
| 生命周期 | 完成一次调用后结束 | `WORK → IDLE → WORK`,直到收到关机请求 |
| 上下文 | 只服务当前任务 | 在多轮协作中保留 |
| 通信 | 返回一次结果 | 持续接收消息并上报事件 |
| 协调 | 主 Agent 单向委派 | Lead 与队友双向协作 |
| 生命周期 | 一次调用后结束 | `WORK → IDLE → WORK`,直到关机 |
| 上下文 | 只服务一个任务 | 跨任务保留 |
| 通信 | 返回一次结果 | 接收消息并发出事件 |
| 协作 | 单向委派 | 与 Lead 双向协作 |
`spawn_teammate_thread()`队友创建独立的 system prompt、messages 和工具集,并把循环放入 daemon 线程。Lead 不必等待某个队友结束,仍可继续派发任务或处理其他结果
`spawn_teammate_thread()`每个队友保存独立的系统提示词、messages、工具和当前工作目录状态再在线程中运行循环。队友工作时Lead 可以继续协调其他任务。`lead``agent` 保留给运行时身份,但 `MessageBus` 仍允许把 `lead` 作为协调者收件箱
### 3. MessageBus 把通信放在上下文之外
### 3. MessageBus 把通信放在模型上下文之外
Lead 和队友不能共享同一份 messages否则一个队友的工具结果会混入另一个队友的推理。`MessageBus` 为每个 Agent 建立 `.mailboxes/<name>.jsonl`
Lead 和队友不能共享同一个 messages 数组,否则一个队友的工具结果会进入另一个队友的推理上下文。`MessageBus` 为每个 Agent 提供 `.mailboxes/<name>.jsonl` 收件箱
```python
class MessageBus:
@ -101,21 +104,27 @@ class MessageBus:
"metadata": metadata or {},
}
with self._changed:
append_jsonl(self._path(to_agent), msg)
with open(self._path(to_agent), "a") as f:
f.write(json.dumps(msg) + "\n")
self._changed.notify_all()
def wait_for_messages(self, agent):
def wait_for_messages(self, agent, timeout=None):
deadline = None if timeout is None else time.monotonic() + timeout
with self._changed:
while not self.peek(agent):
self._changed.wait()
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)
```
锁保证同一进程中的多个队友不会同时破坏邮箱文件,`Condition` 让空闲队友等待事件,而不是持续轮询。
会保护收件箱文件,避免队友并发读写。`Condition` 既能在消息到达时唤醒队友,也能支持 IDLE 状态下的短时等待
### 4. 收件箱由运行时自动投递
### 4. 收件箱事件由运行时投递
`read_inbox()` 是消费式读取读出后删除邮箱文件。因此Lead 只保留一个消费入口 `consume_lead_inbox()`
`read_inbox()` 会读取并删除收件箱文件,因此 Lead 只保留一个消费者 `consume_lead_inbox()`
```python
def consume_lead_inbox():
@ -126,33 +135,165 @@ def consume_lead_inbox():
return messages
```
主循环旁的事件线程发现新消息后,会唤醒 Lead
主循环旁边的事件线程会在新消息到达时唤醒 Lead
```text
MessageBus → consume_lead_inbox
→ 更新协议状态
→ [Team events] 注入 history
Lead 开始新一轮
[Team events] 注入 history
启动新一轮 Lead 调用
```
`check_inbox`再是模型工具。消息何时到达属于运行时职责;模型只需要处理已经送入上下文的事件。
`check_inbox`是模型工具。消息到达和消费属于运行时,模型只处理已经投递到上下文里的事件。
### 5. 结果与空闲是两个不同事件
### 5. 结果与 IDLE 是两个事件
队友完成一项工作时,运行时依次发送
队友完成一项任务后,运行时按顺序发送两个事件
```text
result: "认证逻辑已重构,相关测试通过。"
result: "认证已重构,相关测试通过。"
idle_notification: "Waiting for more work."
```
`result` 回答“这次工作产出了什么”,`idle_notification` 表示“这个队友现在可以接新任务”。如果把两者合成一个模糊的“done”Lead 就无法区分任务结果和资源状态。
`result` 回答“这项任务产出了什么”,`idle_notification` 回答“这个队友能否继续接任务”。一个含糊的“完成了”无法同时表达这两种状态。
队友进入 IDLE 后不会退出。新普通消息会让它回到 WORK`shutdown_request` 则让它完成关机握手并结束线程
空闲队友不会退出。直接消息或 ready task 会让它回到 WORK`shutdown_request` 则会启动平滑关机握手
### 6. 控制消息使用类型和 request_id
### 6. IDLE 先看收件箱,再找 ready task
普通消息可以交给模型理解,关机和审批不能依赖自由文本猜测。它们使用结构化消息:
队友进入 IDLE 后优先处理消息,然后检查共享任务板:
```python
while True:
inbox = BUS.wait_for_messages(name, IDLE_SCAN_INTERVAL)
if inbox:
should_stop = handle_messages(inbox)
if should_stop or messages[-1]["role"] == "user":
break
continue
task = claim_next_task(name)
if task:
messages.append({
"role": "user",
"content": f"[Auto-claimed task {task.id}] {task.subject}",
})
break
```
关机、计划审批和 Lead 的直接指令应该先于临时发现的工作。如果没有消息,也没有 ready task队友会保持 IDLE。前置任务完成后当前受阻的任务可能变为 ready。
### 7. 发现和认领分成两步,认领必须原子执行
扫描只负责找候选任务:
```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)
]
```
候选列表只是某一时刻的快照。另一个队友也可能看到同一任务,因此所有权变更必须放进 `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 is not None:
return "Task is no longer available"
if _owner_in_progress(owner):
return "Owner must complete its current task first"
if not can_start(task_id):
return "Task is blocked"
cwd, error = task_worktree_cwd(task)
if error:
return f"Cannot claim {task_id}: {error}"
task.owner = owner
task.status = "in_progress"
save_task(task)
teammate_assignments[owner] = {"task_id": task.id, "cwd": cwd}
return f"Claimed {task.id}"
```
多个队友可以同时发现同一候选,但只有一个 claim 能把它推进到 `in_progress`。队友完成当前任务后才能再认领下一项worktree 绑定损坏时,认领会直接失败,不会回退到仓库目录。
### 8. 认领后的工作复用同一个 WORK 循环
认领成功后,运行时把任务 ID、标题和描述放进队友的 messages
```text
任务板出现 ready task
→ IDLE 队友发现候选
→ claim_task 写入 owner 和 in_progress
→ 任务进入队友 messages
→ WORK
→ complete_task
→ result + idle_notification
→ IDLE
```
队友继续使用直接派发任务时的模型调用、文件工具、Shell、计划闸门、结果上报和关机协议。任务发现只是现有 WORK 循环的另一个入口。
### 9. 由任务选择工具的工作目录
`Task.worktree` 是可选字段:
```python
@dataclass
class Task:
id: str
subject: str
description: str
status: str
owner: str | None
blockedBy: list[str]
worktree: str | None = None
```
并行修改需要分开目录时Lead 可以创建并绑定 worktree
```python
create_worktree(name="auth-refactor", task_id="task_1234")
```
`create_worktree` 只提供给 Lead。它要求任务处于 pending、无人认领且尚未绑定随后检查名称、路径、分支和 Git 注册信息,创建 checkout最后才写入任务绑定。如果 Git 报告失败却已经留下分支或已注册的 checkout运行时会报告 partial operation让任务保持未绑定并保留这些内容供人工恢复。队友只使用任务工具和文件工具。
认领任务时,运行时会把解析后的目录写入 `teammate_assignments`,该队友的 `bash``read_file``write_file` 包装器从 assignment 读取目录。没有绑定 worktree 的任务解析到 `WORKDIR`,所以 worktree 默认不开启:
```python
cwd, error = task_worktree_cwd(task)
if not error:
teammate_assignments[owner] = {
"task_id": task.id,
"cwd": cwd,
}
```
`complete_task(task_id, owner)` 会检查调用者是否拥有这个进行中的任务。只有任务成功完成后,运行时才会清除 assignment完成失败时仍保留任务目录队友可以修正问题后再次提交。任务上的 `worktree` 绑定会一直保留到 checkout 被移除。
> Worktree 只分开 Git 工作目录和分支不是安全沙箱。Shell 命令仍能访问父进程有权访问的路径和资源。
### 10. Worktree 清理默认保留工作
模型可调用的 `remove_worktree(name)` 工具会拒绝移除仍绑定 `pending``in_progress` 任务的 worktree。任务完成后它仍把已跟踪、未跟踪和已忽略文件都视为未提交数据只会不带 `--force` 移除干净的 checkout。
底层 Python 函数保留 `discard_changes=True`,供已经另行取得用户明确确认的宿主调用,但模型的工具 schema 不包含这个参数。遇到有改动的 worktree模型只能停下来交给用户检查。两种移除路径都会保留仓库里的 `wt/<name>` 分支,包括没有 upstream 的干净本地提交。移除成功后,任务的 worktree 绑定会被清空,因为对应 checkout 已不存在。
```text
干净 worktree → 移除目录,保留 wt/<name> 分支
有改动 worktree → 模型工具拒绝;由用户决定保留还是丢弃
待办/进行中任务 → 拒绝移除
```
任务完成与 worktree 清理也互相独立。`complete_task` 记录任务结果Lead 随后可以检查、合并、保留或移除 worktree。
### 11. 控制消息使用类型和 request_id
普通协作可以使用自由文本,关机和审批则不能依靠猜测消息意图。它们使用结构化消息:
![Team Protocols](images/team-protocols-overview.svg)
@ -170,21 +311,22 @@ class ProtocolState:
pending_requests: dict[str, ProtocolState] = {}
```
关机协议的完整路径是
关机路径如下
```text
Lead 创建 shutdown 请求,状态为 pending
→ shutdown_request(request_id) 发给队友
→ 队友完成当前步骤并回复 shutdown_response(request_id)
→ Lead 用 request_id 找到原请求
→ pending 变为 approved队友线程退出
Lead 创建 pending 状态的关机请求
→ shutdown_request(request_id) 进入队友收件箱
→ 队友完成当前步骤
→ shutdown_response(request_id) 返回 Lead
→ request_id 找到原始请求
→ pending 变为 approved队友循环退出
```
`request_id` 负责关联请求与回复,`type` 防止错误类型的回复修改状态,`status` 防止重复响应被再次处理
ID 把回复关联到请求,类型阻止不匹配的回复修改状态,状态则阻止同一回复重复生效
### 7. 计划审批不仅传消息,还约束执行
### 12. 计划审批会约束执行
计划协议沿相反方向流动
计划协议的方向相反
```text
Lead → plan_request
@ -192,7 +334,7 @@ Lead → plan_request
Lead → plan_approval_response(request_id, approve, feedback)
```
只告诉队友“请等待批准”并不可靠,所以工具分发器检查计划状态
工具分发层负责执行闸门
```python
def _run_teammate_tool(name, block, handlers):
@ -204,30 +346,36 @@ def _run_teammate_tool(name, block, handlers):
return handlers[block.name](**block.input)
```
当状态为 `required``pending``rejected` 时,队友仍可读取文件、提交或修改计划,但不能执行 Shell 或写文件。批准消息到达后,状态变为 `approved`,工具才会放行
状态是 `required``pending``rejected` 时,队友可以读取文件、提交或修改计划,但不能运行 Shell 命令或写文件。审批回复把状态改成 `approved` 后,这些工具才会放开
---
## 一次完整运行
```text
s15 >> 请重构这个示例后端,分别整理配置加载、认证逻辑和测试,
保持现有接口兼容,并确保测试通过。
s15 >> 把后端重构拆到共享任务板,尽量并行完成配置、认证和测试。
认证任务使用 worktree保持现有接口,并确保测试通过。
Lead: 建议由 config、auth、tests 三个方向并行处理,是否开始?
Lead我建议按 config、auth 和 tests 三个方向分工。
是否启动团队?
s15 >> 开始吧
[teammate] config spawned
[teammate] auth spawned
[teammate] tests spawned
[bus] auth → lead (result) ...
[bus] auth → lead (idle_notification) ...
[task] config created
[task] auth created → worktree auth-refactor
[task] tests created
[teammate] alice spawned
[teammate] bob spawned
[claim] alice → config (cwd: repository)
[claim] bob → auth (cwd: .worktrees/auth-refactor)
[complete] auth
[bus] bob → lead (result) ...
[bus] bob → lead (idle_notification) ...
[wake: 2 team events → new turn]
Lead: 已收到认证部分结果,继续等待并协调其他队友。
Lead:我已收到认证任务的结果,接下来继续协调其余工作
```
终端中显示的是用户需求、Lead 分工、队友启动、消息流、结果、空闲和关机事件。用户不需要在提示词里指定谁是 Lead也不需要手动要求检查邮箱。
终端会显示用户请求、Lead 的团队方案、任务状态、认领结果、所选目录、结果、IDLE 切换和控制事件。用户不需要指定谁是 Lead也不必提醒它检查收件箱。
---
@ -235,13 +383,15 @@ Lead: 已收到认证部分结果,继续等待并协调其他队友。
| 组件 | s14 | s15 |
|---|---|---|
| Agent 数量 | 一个 Agent | 一个 Lead + 多个持久队友 |
| 用户交互 | 直接执行任务 | 先提出团队方案,再确认启动 |
| 通信 | 无 | 文件邮箱 + 自动事件投递 |
| 生命周期 | 单循环 | 队友 `WORK / IDLE / shutdown` |
| 结果上报 | 当前 Agent 输出 | `result``idle_notification` 分离 |
| 控制协议 | 无 | 关机与计划审批 |
| 执行约束 | 无团队约束 | 未批准计划会拦截写入类工具 |
| Agent | 单个 Agent | 一个 Lead 加持久队友 |
| 用户流程 | 直接执行请求 | 先提团队方案,再确认启动 |
| 通信 | 无 | 文件收件箱加运行时投递 |
| 生命周期 | 一个循环 | 队友 `WORK / IDLE / shutdown` |
| 共享工作 | Lead 已有的任务工具 | IDLE 扫描加队友原子认领 |
| 工作目录 | 仓库 `WORKDIR` | 默认 `WORKDIR`,任务可选 worktree |
| 结果上报 | 当前 Agent 输出 | 分开的 `result``idle_notification` |
| 控制 | 无 | 类型化关机与计划审批协议 |
| 执行约束 | 无团队约束 | 必需计划会锁住修改型工具 |
---
@ -252,27 +402,29 @@ cd learn-claude-code
python s15_agent_teams/code.py
```
输入一个自然需求:
输入一个自然需求:
```text
请重构这个示例后端,分别整理配置加载、认证逻辑和测试,
保持现有接口兼容,并确保测试通过
把后端重构拆到共享任务板,在依赖允许时并行完成配置、认证和测试。
认证任务使用 worktree保持现有接口并在最后汇总结果
```
看到 Lead 给出分工后,再回复:
Lead 提出团队方案后回复:
```text
开始吧
```
观察终端中的 `spawned``result``idle_notification``plan_approval_*``shutdown_*` 事件,以及 `.mailboxes/` 中消息写入和消费的过程
观察 `.tasks/` 如何从 `pending` 进入 `in_progress``completed``.mailboxes/` 如何投递 `result``idle_notification`,以及 `.worktrees/` 是否只为绑定的任务创建。还可以检查直接消息是否先于任务板扫描,以及 `complete_task` 失败后队友的工作目录是否保持不变
---
## 接下来
s15 中Lead 仍然要明确告诉每个队友做什么。下一章把共享任务看板交给空闲队友,让它们自己发现并认领可执行任务
团队运行时现在可以处理委派、共享任务认领和可选工作目录,但工具仍然直接定义在 Python 代码里
下一章[s16 Autonomous Agents](../s16_autonomous_agents/)
下一章通过标准的发现与调用协议接入外部工具
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
下一章:[s16 MCP Tools](../s16_mcp_plugin/)。
<!-- translation-sync: zh@v3, en@v3, ja@v3 -->

View file

@ -8,6 +8,8 @@ Need: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY
Changes from s14:
- MessageBus: thread-safe, file-backed mailboxes (.mailboxes/*.jsonl)
- Persistent teammate loops with WORK and IDLE states
- Idle teammates discover and atomically claim ready tasks
- Task-bound Git worktrees give teammate file operations separate checkouts
- 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
@ -46,6 +48,12 @@ MODEL = os.environ["MODEL_ID"]
TASKS_DIR = WORKDIR / ".tasks"
TASKS_DIR.mkdir(exist_ok=True)
TASKS_ROOT = TASKS_DIR.resolve()
task_lock = threading.RLock()
# owner -> {"task_id": str, "cwd": Path}. A teammate gets one assignment at
# a time, and every filesystem tool resolves its cwd through this registry.
teammate_assignments: dict[str, dict[str, object]] = {}
@dataclass
@ -56,10 +64,19 @@ class Task:
status: str # pending | in_progress | completed
owner: str | None
blockedBy: list[str]
worktree: str | None = None
def _task_path(task_id: str) -> Path:
return TASKS_DIR / f"{task_id}.json"
if not isinstance(task_id, str) or not task_id:
raise ValueError("Task ID must be a non-empty string")
if Path(task_id).name != task_id or task_id in {".", ".."}:
raise ValueError(f"Invalid task ID: {task_id!r}")
path = (TASKS_DIR / f"{task_id}.json").resolve()
if (not TASKS_ROOT.is_relative_to(WORKDIR.resolve())
or not path.is_relative_to(TASKS_ROOT)):
raise ValueError(f"Invalid task ID: {task_id!r}")
return path
def create_task(subject: str, description: str = "",
@ -75,16 +92,21 @@ def create_task(subject: str, description: str = "",
def save_task(task: Task):
_task_path(task.id).write_text(json.dumps(asdict(task), indent=2))
with task_lock:
_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()))
with task_lock:
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"))]
with task_lock:
if not TASKS_ROOT.is_relative_to(WORKDIR.resolve()):
raise ValueError("Tasks directory escapes workspace")
return [load_task(path.stem)
for path in sorted(TASKS_DIR.glob("task_*.json"))]
def get_task(task_id: str) -> str:
@ -98,36 +120,76 @@ def can_start(task_id: str) -> bool:
Missing dependencies are treated as blocked."""
task = load_task(task_id)
for dep_id in task.blockedBy:
if not _task_path(dep_id).exists():
try:
dep_path = _task_path(dep_id)
except ValueError:
return False
if not dep_path.exists():
return False
if load_task(dep_id).status != "completed":
return False
return True
def _owner_in_progress(owner: str) -> Task | None:
return next((task for task in list_tasks()
if task.status == "in_progress" and task.owner == owner), None)
def _incomplete_dependencies(task: Task) -> list[str]:
incomplete = []
for dep_id in task.blockedBy:
try:
dep_path = _task_path(dep_id)
except ValueError:
incomplete.append(dep_id)
continue
if not dep_path.exists() or load_task(dep_id).status != "completed":
incomplete.append(dep_id)
return incomplete
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)
"""Atomically claim one task and bind the owner's filesystem cwd."""
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} is already owned by {task.owner}"
current = _owner_in_progress(owner)
if current:
return (f"Owner {owner} must complete {current.id} before "
"claiming another task")
if not can_start(task_id):
return f"Blocked by: {_incomplete_dependencies(task)}"
cwd, error = task_worktree_cwd(task)
if error:
return f"Cannot claim {task_id}: {error}"
task.owner = owner
task.status = "in_progress"
save_task(task)
teammate_assignments[owner] = {"task_id": task.id, "cwd": cwd}
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)]
def complete_task(task_id: str, owner: str = "agent") -> str:
"""Complete an assignment only when the caller owns it."""
with task_lock:
task = load_task(task_id)
if task.status != "in_progress":
return f"Task {task_id} is {task.status}, cannot complete"
if task.owner != owner:
return (f"Task {task_id} is owned by {task.owner}, "
f"not {owner}; cannot complete")
task.status = "completed"
save_task(task)
assignment = teammate_assignments.get(owner)
if assignment and assignment.get("task_id") == task_id:
teammate_assignments.pop(owner, None)
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:
@ -136,6 +198,252 @@ def complete_task(task_id: str) -> str:
return msg
# ── Task-bound Worktrees ──
WORKTREES_DIR = WORKDIR / ".worktrees"
WORKTREES_DIR.mkdir(exist_ok=True)
WORKTREES_ROOT = WORKTREES_DIR.resolve()
VALID_WORKTREE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
def validate_worktree_name(name: str) -> str | None:
if not isinstance(name, str) or not VALID_WORKTREE_NAME.fullmatch(name):
return ("worktree name must be 1-64 letters, digits, dots, "
"underscores, or dashes, and start with a letter or digit")
if name in {".", ".."} or ".." in name:
return "worktree name cannot contain '..'"
return None
def _worktree_path(name: str) -> Path:
path = (WORKTREES_DIR / name).resolve()
if (not WORKTREES_ROOT.is_relative_to(WORKDIR.resolve())
or not path.is_relative_to(WORKTREES_ROOT)
or path == WORKTREES_ROOT):
raise ValueError(f"Worktree path escapes directory: {name!r}")
return path
def _worktree_branch(name: str) -> str:
return f"wt/{name}"
def run_git(args: list[str], cwd: Path | None = None) -> tuple[bool, str]:
"""Run Git without shell interpolation and return (ok, combined output)."""
try:
result = subprocess.run(
["git", *args], cwd=cwd or WORKDIR,
capture_output=True, text=True, timeout=30,
)
except (OSError, subprocess.TimeoutExpired) as exc:
return False, f"{type(exc).__name__}: {exc}"
output = (result.stdout + result.stderr).strip()
return result.returncode == 0, output[:5000] or "(no output)"
def _registered_worktrees() -> tuple[dict[Path, dict[str, str]], str | None]:
ok, output = run_git(["worktree", "list", "--porcelain"])
if not ok:
return {}, f"cannot read Git worktree registry: {output}"
entries: dict[Path, dict[str, str]] = {}
current: dict[str, str] = {}
for line in output.splitlines() + [""]:
if not line:
raw_path = current.get("worktree")
if raw_path:
entries[Path(raw_path).resolve()] = current
current = {}
continue
key, _, value = line.partition(" ")
current[key] = value
return entries, None
def _registered_worktree(name: str) -> tuple[Path | None, str | None]:
try:
path = _worktree_path(name)
except ValueError as exc:
return None, str(exc)
entries, error = _registered_worktrees()
if error:
return None, error
if path not in entries:
return None, f"worktree '{name}' is not registered with Git"
if not path.is_dir():
return None, f"worktree '{name}' is missing at {path}"
expected_branch = f"refs/heads/{_worktree_branch(name)}"
if entries[path].get("branch") != expected_branch:
return None, (f"worktree '{name}' is not registered on expected "
f"branch '{_worktree_branch(name)}'")
return path, None
def task_worktree_cwd(task: Task) -> tuple[Path, str | None]:
"""Resolve a task cwd, failing closed for broken worktree bindings."""
if not task.worktree:
return WORKDIR, None
path, error = _registered_worktree(task.worktree)
return (path or WORKDIR), error
def assignment_cwd(owner: str) -> Path:
with task_lock:
assignment = teammate_assignments.get(owner)
if not assignment:
if _owner_in_progress(owner):
raise ValueError(f"Missing assignment metadata for {owner}")
return WORKDIR
task = load_task(str(assignment["task_id"]))
if task.status != "in_progress" or task.owner != owner:
raise ValueError(f"Assignment for {owner} is no longer active")
cwd, error = task_worktree_cwd(task)
if error:
raise ValueError(error)
if cwd.resolve() != Path(assignment["cwd"]).resolve():
raise ValueError(f"Assignment cwd changed for task {task.id}")
return cwd
def release_teammate_assignment(owner: str):
"""Return abandoned teammate work to the task board on thread exit."""
with task_lock:
try:
task = _owner_in_progress(owner)
if task:
task.status = "pending"
task.owner = None
save_task(task)
finally:
teammate_assignments.pop(owner, None)
def create_worktree(name: str, task_id: str) -> str:
"""Create and bind a dedicated worktree after all inputs validate."""
error = validate_worktree_name(name)
if error:
return f"Error: {error}"
try:
path = _worktree_path(name)
task_path = _task_path(task_id)
except ValueError as exc:
return f"Error: {exc}"
branch = _worktree_branch(name)
with task_lock:
if not task_path.exists():
return f"Error: Task {task_id} not found"
task = load_task(task_id)
if task.status != "pending" or task.owner is not None:
return f"Error: Task {task_id} must be pending and unowned"
if task.worktree:
return f"Error: Task {task_id} already uses worktree '{task.worktree}'"
if any(t.worktree == name for t in list_tasks() if t.id != task_id):
return f"Error: Worktree '{name}' is already bound to another task"
if path.exists():
return f"Error: Worktree path already exists: {path}"
ok, root = run_git(["rev-parse", "--show-toplevel"])
if not ok or Path(root).resolve() != WORKDIR.resolve():
return "Error: Working directory must be the root of a Git repository"
ok, branch_check = run_git(["check-ref-format", "--branch", branch])
if not ok:
return f"Error: Invalid worktree branch '{branch}': {branch_check}"
exists, _ = run_git(["show-ref", "--verify", "--quiet",
f"refs/heads/{branch}"])
if exists:
return f"Error: Branch '{branch}' already exists"
entries, registry_error = _registered_worktrees()
if registry_error:
return f"Error: {registry_error}"
if path in entries:
return f"Error: Worktree path is already registered: {path}"
ok, result = run_git(["worktree", "add", "-b", branch,
str(path), "HEAD"])
if not ok:
entries, registry_error = _registered_worktrees()
branch_exists, _ = run_git(
["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"]
)
artifacts = []
if path.exists():
artifacts.append(f"checkout path '{path}'")
if registry_error is None and path in entries:
artifacts.append("registered Git worktree")
if branch_exists:
artifacts.append(f"branch '{branch}'")
if artifacts:
return (
"Partial operation: git worktree add reported an error "
f"after leaving {', '.join(artifacts)}. Task {task_id} "
"remains unbound and no Git data was deleted. Run "
f"`git worktree list`, inspect '{path}' and '{branch}', "
"then keep or remove those artifacts manually after "
f"preserving any work. Git error: {result}"
)
return f"Git error: {result}"
try:
task.worktree = name
save_task(task)
except Exception as exc:
return (f"Partial success: Worktree '{name}' was created at "
f"{path} on branch '{branch}', but task binding failed: "
f"{exc}. Git data was retained for manual recovery.")
print(f" \033[33m[worktree] created: {name} at {path}\033[0m")
return f"Worktree '{name}' created at {path} for task {task_id}"
def remove_worktree(name: str, discard_changes: bool = False) -> str:
"""Remove a registered checkout while always retaining its branch."""
error = validate_worktree_name(name)
if error:
return f"Error: {error}"
with task_lock:
path, error = _registered_worktree(name)
if error:
return f"Error: {error}"
bound = [task for task in list_tasks() if task.worktree == name]
if not bound:
return f"Error: Worktree '{name}' is not bound to a task"
active = [task for task in bound if task.status != "completed"]
if active:
return (f"Error: Worktree '{name}' is bound to active task "
f"{active[0].id}; complete it before removal")
ok, status = run_git(
["status", "--porcelain", "--ignored"], cwd=path
)
if not ok:
return f"Error: Cannot verify worktree '{name}' status: {status}"
if status != "(no output)" and not discard_changes:
changed = len([line for line in status.splitlines() if line.strip()])
return (f"Error: Worktree '{name}' has {changed} uncommitted "
"change(s); preserve or discard them manually")
args = ["worktree", "remove"]
if discard_changes:
args.append("--force")
args.append(str(path))
ok, result = run_git(args)
if not ok:
return f"Git error: {result}"
try:
for task in bound:
task.worktree = None
save_task(task)
except Exception as exc:
return (f"Partial success: Worktree '{name}' was removed and "
f"branch '{_worktree_branch(name)}' retained, but task "
f"unbinding failed: {exc}. Manual recovery is required.")
print(f" \033[33m[worktree] removed: {name}; branch retained\033[0m")
return f"Worktree '{name}' removed; branch '{_worktree_branch(name)}' retained"
# ── Prompt Assembly (from s10, synced) ──
PROMPT_SECTIONS = {
@ -144,13 +452,18 @@ PROMPT_SECTIONS = {
"get_task, create_task, list_tasks, claim_task, complete_task, "
"schedule_cron, list_crons, cancel_cron, "
"spawn_teammate, send_message, request_shutdown, "
"request_plan, review_plan.",
"request_plan, review_plan, create_worktree, remove_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. After confirmation, delegate "
"independent work, react to team events delivered by the runtime, and "
"shut teammates down when coordination is complete."
"independent work by creating a Task for each parallel change, then "
"create a task-bound worktree only when a separate working directory "
"would prevent conflicting edits. A teammate must complete its current "
"Task before claiming another. A worktree changes tool default cwd "
"only; it is not a sandbox. The remove_worktree tool removes only clean "
"checkouts and never discards changes. 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.",
@ -183,27 +496,32 @@ def get_system_prompt(context: dict) -> str:
# ── Tools ──
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
def safe_path(p: str, cwd: Path | None = None) -> Path:
base = (cwd or WORKDIR).resolve()
path = (base / p).resolve()
if not path.is_relative_to(base):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str, run_in_background: bool = False) -> str:
def run_bash(command: str, run_in_background: bool = False,
cwd: Path | None = None) -> str:
# run_in_background is handled by agent_loop dispatch, not here
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
r = subprocess.run(command, shell=True, cwd=cwd or 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)"
except OSError as exc:
return f"Error: {type(exc).__name__}: {exc}"
def run_read(path: str, limit: int | None = None) -> str:
def run_read(path: str, limit: int | None = None,
cwd: Path | None = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
lines = safe_path(path, cwd).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
return "\n".join(lines)
@ -211,9 +529,9 @@ def run_read(path: str, limit: int | None = None) -> str:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
def run_write(path: str, content: str, cwd: Path | None = None) -> str:
try:
fp = safe_path(path)
fp = safe_path(path, cwd)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes to {path}"
@ -241,24 +559,37 @@ def run_list_tasks() -> str:
"completed": ""}.get(t.status, "?")
deps = f" (blockedBy: {', '.join(t.blockedBy)})" if t.blockedBy else ""
owner = f" [{t.owner}]" if t.owner else ""
worktree = f" (worktree: {t.worktree})" if t.worktree else ""
lines.append(f" {icon} {t.id}: {t.subject} "
f"[{t.status}]{owner}{deps}")
f"[{t.status}]{owner}{deps}{worktree}")
return "\n".join(lines)
def run_get_task(task_id: str) -> str:
try:
return get_task(task_id)
except ValueError as exc:
return f"Error: {exc}"
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")
try:
return claim_task(task_id, owner="agent")
except ValueError as exc:
return f"Error: {exc}"
except FileNotFoundError:
return f"Error: Task {task_id} not found"
def run_complete_task(task_id: str) -> str:
return complete_task(task_id)
try:
return complete_task(task_id, owner="agent")
except ValueError as exc:
return f"Error: {exc}"
except FileNotFoundError:
return f"Error: Task {task_id} not found"
# ── Background Tasks (from s13, synced) ──
@ -301,6 +632,8 @@ def execute_tool(block) -> str:
"request_shutdown": run_request_shutdown,
"request_plan": run_request_plan,
"review_plan": run_review_plan,
"create_worktree": run_create_worktree,
"remove_worktree": run_remove_worktree,
}.get(block.name)
if handler:
return handler(**block.input)
@ -607,6 +940,7 @@ 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}$")
RESERVED_TEAMMATE_NAMES = {"lead", "agent"}
def is_valid_agent_name(name: str) -> bool:
@ -852,6 +1186,37 @@ def _teammate_send_message(from_name: str, to: str, content: str) -> str:
return f"Sent to {to}"
# ── Autonomous Task Discovery ──
IDLE_SCAN_INTERVAL = 2.0
def scan_unclaimed_tasks() -> list[Task]:
"""Return ready tasks whose optional worktree binding is usable."""
with task_lock:
ready = []
for task in list_tasks():
if (task.status != "pending" or task.owner is not None
or not can_start(task.id)):
continue
_, error = task_worktree_cwd(task)
if not error:
ready.append(task)
return ready
def claim_next_task(name: str) -> Task | None:
"""Claim the first still-available task, never a second assignment."""
with task_lock:
if _owner_in_progress(name):
return 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
# ── Teammate Thread ──
def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
@ -859,19 +1224,60 @@ def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
if not is_valid_agent_name(name):
return ("Invalid teammate name: use 1-64 letters, digits, "
"underscores, or dashes")
if name.lower() in RESERVED_TEAMMATE_NAMES:
return f"Invalid teammate name: '{name}' is reserved by the runtime"
with team_lock:
if name in active_teammates:
if any(existing.casefold() == name.casefold()
for existing 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}. "
"Use tools to complete assigned work. "
"Use tools to complete assigned work. You can list, claim, and "
"complete tasks from the shared board. For a bound task, the "
"runtime defaults bash, read_file, and write_file to its "
"worktree; otherwise they use the shared WORKDIR. This default "
"cwd is not a sandbox. "
"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():
def run_loop():
def current_cwd() -> tuple[Path | None, str | None]:
try:
return assignment_cwd(name), None
except (FileNotFoundError, ValueError) as exc:
return None, f"Error: Invalid task assignment: {exc}"
def teammate_bash(command: str) -> str:
cwd, error = current_cwd()
return error or run_bash(command, cwd=cwd)
def teammate_read(path: str) -> str:
cwd, error = current_cwd()
return error or run_read(path, cwd=cwd)
def teammate_write(path: str, content: str) -> str:
cwd, error = current_cwd()
return error or run_write(path, content, cwd=cwd)
def teammate_claim(task_id: str) -> str:
try:
return claim_task(task_id, owner=name)
except ValueError as exc:
return f"Error: {exc}"
except FileNotFoundError:
return f"Error: Task {task_id} not found"
def teammate_complete(task_id: str) -> str:
try:
return complete_task(task_id, owner=name)
except ValueError as exc:
return f"Error: {exc}"
except FileNotFoundError:
return f"Error: Task {task_id} not found"
messages = [{"role": "user", "content": prompt}]
sub_tools = [
{"name": "bash", "description": "Run a shell command.",
@ -898,12 +1304,31 @@ def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
"input_schema": {"type": "object",
"properties": {"plan": {"type": "string"}},
"required": ["plan"]}},
{"name": "list_tasks",
"description": "List tasks on the shared board.",
"input_schema": {"type": "object", "properties": {},
"required": []}},
{"name": "claim_task",
"description": "Claim a ready task from the shared board.",
"input_schema": {"type": "object",
"properties": {"task_id": {"type": "string"}},
"required": ["task_id"]}},
{"name": "complete_task",
"description": "Complete the task owned by this teammate.",
"input_schema": {"type": "object",
"properties": {"task_id": {"type": "string"}},
"required": ["task_id"]}},
]
sub_handlers = {
"bash": run_bash, "read_file": run_read, "write_file": run_write,
"bash": teammate_bash,
"read_file": teammate_read,
"write_file": teammate_write,
"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": teammate_claim,
"complete_task": teammate_complete,
}
def handle_messages(inbox: list[dict]) -> bool:
@ -979,20 +1404,60 @@ def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
"idle_notification")
while True:
inbox = BUS.wait_for_messages(name)
should_stop = handle_messages(inbox)
if should_stop or messages[-1]["role"] == "user":
break
inbox = BUS.wait_for_messages(name, IDLE_SCAN_INTERVAL)
if inbox:
should_stop = handle_messages(inbox)
if should_stop or messages[-1]["role"] == "user":
break
continue
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")
task = claim_next_task(name)
if not task:
continue
try:
cwd = str(assignment_cwd(name))
except (FileNotFoundError, ValueError) as exc:
cwd = f"unavailable ({exc})"
messages.append({
"role": "user",
"content": (
f"[Auto-claimed task {task.id}] {task.subject}\n"
f"{task.description}\nWork directory: {cwd}"
),
})
print(f" \033[32m[idle] {name} claimed "
f"{task.id}: {task.subject}\033[0m")
break
def run():
try:
run_loop()
except Exception as exc:
try:
BUS.send(name, "lead", f"{type(exc).__name__}: {exc}", "error")
except Exception:
pass
finally:
try:
release_teammate_assignment(name)
except Exception as exc:
try:
BUS.send(
name, "lead",
f"Assignment cleanup failed: {type(exc).__name__}: {exc}",
"error",
)
except Exception:
pass
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")
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}"
return f"Teammate '{name}' spawned as {role} (autonomous)"
# ── Lead Team Tools ──
@ -1055,6 +1520,15 @@ def run_review_plan(request_id: str, approve: bool,
return f"Plan {state.status} ({request_id})"
def run_create_worktree(name: str, task_id: str) -> str:
return create_worktree(name, task_id)
def run_remove_worktree(name: str) -> str:
"""Model-facing cleanup never opts into destructive removal."""
return remove_worktree(name)
# ── Tool Definitions ──
TOOLS = [
@ -1160,6 +1634,30 @@ TOOLS = [
"approve": {"type": "boolean"},
"feedback": {"type": "string"}},
"required": ["request_id", "approve"]}},
{"name": "create_worktree",
"description": "Create a task-bound Git worktree and dedicated branch.",
"input_schema": {"type": "object",
"properties": {"name": {
"type": "string",
"pattern": ("^(?!.*\\.\\.)[A-Za-z0-9]"
"[A-Za-z0-9._-]{0,63}$"),
"maxLength": 64,
},
"task_id": {"type": "string"}},
"required": ["name", "task_id"],
"additionalProperties": False}},
{"name": "remove_worktree",
"description": "Remove a clean task worktree while retaining its branch.",
"input_schema": {"type": "object",
"properties": {
"name": {
"type": "string",
"pattern": ("^(?!.*\\.\\.)[A-Za-z0-9]"
"[A-Za-z0-9._-]{0,63}$"),
"maxLength": 64,
}},
"required": ["name"],
"additionalProperties": False}},
]

View file

@ -1,120 +1,107 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 470" font-family="system-ui, -apple-system, sans-serif">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 620" 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="#0891b2"/>
</linearGradient>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#475569"/>
</marker>
<marker id="arrow-cyan" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<marker id="arrow-cyan" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#0891b2"/>
</marker>
<marker id="arrow-amber" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#d97706"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<marker id="arrow-green" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
<marker id="arrow-amber" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#d97706"/>
</marker>
</defs>
<rect width="760" height="470" fill="#fafbfc" rx="8"/>
<rect width="760" height="620" rx="8" fill="#fafbfc"/>
<rect width="760" height="44" rx="8" fill="url(#header)"/>
<rect y="36" width="760" height="8" fill="url(#header)"/>
<text x="380" y="28" text-anchor="middle" fill="#fff" font-size="15" font-weight="700">Agent Teams — One Team Harness</text>
<!-- 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">Agent Teams — Lead Loop + Teammate Threads + MessageBus</text>
<!-- User confirmation, Lead, and typed control -->
<rect x="28" y="82" width="140" height="62" rx="8" fill="#f8fafc" stroke="#64748b" stroke-width="1.5"/>
<text x="98" y="106" text-anchor="middle" fill="#334155" font-size="11" font-weight="700">User</text>
<text x="98" y="124" text-anchor="middle" fill="#64748b" font-size="9">confirm team first</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">s10-s14 Preserved</text>
<rect x="180" y="56" width="12" height="10" rx="2" fill="#ecfeff" stroke="#0891b2" stroke-width="1"/>
<text x="198" y="66" fill="#0891b2" font-size="10" font-weight="600">s15 New</text>
<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">Protocol gate</text>
<line x1="168" y1="113" x2="220" y2="113" stroke="#475569" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- ===== Row 1: Lead Agent Loop ===== -->
<rect x="28" y="90" width="70" height="40" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="1.5"/>
<text x="63" y="114" fill="#4f46e5" font-size="8" font-weight="600" text-anchor="middle">cron_queue</text>
<rect x="220" y="72" width="300" height="82" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.8"/>
<text x="370" y="96" text-anchor="middle" fill="#1e3a5f" font-size="12" font-weight="700">Lead Agent Loop</text>
<text x="370" y="116" text-anchor="middle" fill="#475569" font-size="9">user conversation · task creation · team coordination</text>
<text x="370" y="134" text-anchor="middle" fill="#2563eb" font-size="9" font-weight="600">spawn · send · worktree create/remove · plan review</text>
<line x1="98" y1="110" x2="112" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<line x1="520" y1="113" x2="570" y2="113" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow-amber)"/>
<rect x="115" y="90" width="72" height="40" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="151" y="114" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">messages</text>
<rect x="570" y="82" width="162" height="62" rx="8" fill="#fffbeb" stroke="#d97706" stroke-width="1.5"/>
<text x="651" y="105" text-anchor="middle" fill="#92400e" font-size="10" font-weight="700">Typed control</text>
<text x="651" y="122" text-anchor="middle" fill="#a16207" font-size="8.5">request_id · shutdown</text>
<text x="651" y="136" text-anchor="middle" fill="#a16207" font-size="8.5">plan approval gate</text>
<line x1="187" y1="110" x2="201" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- Connect top row to MessageBus; endpoints meet box edges -->
<line x1="370" y1="154" x2="370" y2="190" stroke="#0891b2" stroke-width="1.8" marker-end="url(#arrow-cyan)"/>
<line x1="651" y1="144" x2="651" y2="190" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow-amber)"/>
<rect x="204" y="86" width="86" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="247" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">prompt + cache</text>
<rect x="60" y="190" width="640" height="44" rx="22" fill="#ecfeff" stroke="#0891b2" stroke-width="2"/>
<text x="380" y="209" text-anchor="middle" fill="#0e7490" font-size="11" font-weight="700">MessageBus · .mailboxes/&lt;name&gt;.jsonl</text>
<text x="380" y="225" text-anchor="middle" fill="#0f766e" font-size="8.5">runtime delivery · ordinary messages · result · idle_notification · control events</text>
<line x1="290" y1="110" x2="304" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- MessageBus and teammate loops -->
<line x1="118" y1="234" x2="118" y2="282" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<line x1="348" y1="234" x2="348" y2="282" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<line x1="578" y1="234" x2="578" y2="282" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<line x1="182" y1="282" x2="182" y2="234" stroke="#0891b2" stroke-width="1.2" stroke-dasharray="4 3" marker-end="url(#arrow-cyan)"/>
<line x1="412" y1="282" x2="412" y2="234" stroke="#0891b2" stroke-width="1.2" stroke-dasharray="4 3" marker-end="url(#arrow-cyan)"/>
<line x1="642" y1="282" x2="642" y2="234" stroke="#0891b2" stroke-width="1.2" stroke-dasharray="4 3" marker-end="url(#arrow-cyan)"/>
<rect x="307" y="86" width="74" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="344" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">LLM call</text>
<rect x="40" y="282" width="220" height="82" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
<text x="150" y="305" text-anchor="middle" fill="#166534" font-size="10.5" font-weight="700">Teammate: config</text>
<text x="150" y="324" text-anchor="middle" fill="#15803d" font-size="8.5">independent prompt · messages · tools</text>
<text x="150" y="342" text-anchor="middle" fill="#475569" font-size="8.5">WORK → result → IDLE</text>
<text x="150" y="356" text-anchor="middle" fill="#64748b" font-size="8">direct message returns to WORK</text>
<line x1="381" y1="110" x2="395" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="270" y="282" width="220" height="82" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
<text x="380" y="305" text-anchor="middle" fill="#166534" font-size="10.5" font-weight="700">Teammate: auth</text>
<text x="380" y="324" text-anchor="middle" fill="#15803d" font-size="8.5">independent prompt · messages · tools</text>
<text x="380" y="342" text-anchor="middle" fill="#475569" font-size="8.5">WORK → result → IDLE</text>
<text x="380" y="356" text-anchor="middle" fill="#64748b" font-size="8">claimed task returns to WORK</text>
<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 · send · shutdown · plan review</text>
<rect x="500" y="282" width="220" height="82" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
<text x="610" y="305" text-anchor="middle" fill="#166534" font-size="10.5" font-weight="700">Teammate: tests</text>
<text x="610" y="324" text-anchor="middle" fill="#15803d" font-size="8.5">independent prompt · messages · tools</text>
<text x="610" y="342" text-anchor="middle" fill="#475569" font-size="8.5">WORK → result → IDLE</text>
<text x="610" y="356" text-anchor="middle" fill="#64748b" font-size="8">shutdown exits the loop</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"/>
<!-- IDLE task discovery -->
<line x1="150" y1="364" x2="150" y2="404" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<line x1="380" y1="364" x2="380" y2="404" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<line x1="610" y1="364" x2="610" y2="404" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<!-- ===== Spawn arrow: TOOLS bottom → MessageBus top ===== -->
<line x1="560" y1="140" x2="560" y2="178" stroke="#0891b2" stroke-width="2" marker-end="url(#arrow-cyan)"/>
<rect x="543" y="152" width="38" height="14" rx="3" fill="#ecfeff"/>
<text x="562" y="163" fill="#0891b2" font-size="8" font-weight="600" text-anchor="middle">spawn</text>
<rect x="60" y="404" width="640" height="54" rx="8" fill="#f8fafc" stroke="#64748b" stroke-width="1.5"/>
<text x="380" y="425" text-anchor="middle" fill="#334155" font-size="11" font-weight="700">Shared Task Board · .tasks/</text>
<text x="380" y="444" text-anchor="middle" fill="#475569" font-size="9">IDLE: wait for mailbox first → scan ready tasks → claim atomically → reuse WORK loop</text>
<!-- ===== Row 2: MessageBus ===== -->
<rect x="60" y="178" width="640" height="34" rx="17" fill="#ecfeff" stroke="#0891b2" stroke-width="2"/>
<text x="380" y="200" fill="#0e7490" font-size="11" font-weight="700" text-anchor="middle">MessageBus (.mailboxes/*.jsonl)</text>
<!-- Task binding selects cwd -->
<line x1="380" y1="458" x2="380" y2="482" stroke="#475569" stroke-width="1.5"/>
<line x1="220" y1="482" x2="540" y2="482" stroke="#475569" stroke-width="1.5"/>
<line x1="220" y1="482" x2="220" y2="506" stroke="#475569" stroke-width="1.5" marker-end="url(#arrow)"/>
<line x1="540" y1="482" x2="540" y2="506" stroke="#475569" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="311" y="468" width="138" height="18" rx="4" fill="#fafbfc"/>
<text x="380" y="480" text-anchor="middle" fill="#475569" font-size="8.5" font-weight="600">Task.worktree selects cwd</text>
<!-- ===== Row 3: Teammates ===== -->
<line x1="170" y1="212" x2="170" y2="248" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<line x1="380" y1="212" x2="380" y2="248" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<line x1="590" y1="212" x2="590" y2="248" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<text x="124" y="226" fill="#16a34a" font-size="9" font-weight="600">receive</text>
<text x="334" y="226" fill="#16a34a" font-size="9" font-weight="600">receive</text>
<text x="544" y="226" fill="#16a34a" font-size="9" font-weight="600">receive</text>
<rect x="60" y="506" width="300" height="66" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="210" y="529" text-anchor="middle" fill="#1e3a5f" font-size="10.5" font-weight="700">Default: repository WORKDIR</text>
<text x="210" y="548" text-anchor="middle" fill="#475569" font-size="8.5">task.worktree is null</text>
<text x="210" y="562" text-anchor="middle" fill="#64748b" font-size="8">same behavior as earlier lessons</text>
<line x1="200" y1="248" x2="200" y2="212" stroke="#0891b2" stroke-width="1" stroke-dasharray="4,2" marker-end="url(#arrow-cyan)"/>
<line x1="410" y1="248" x2="410" y2="212" stroke="#0891b2" stroke-width="1" stroke-dasharray="4,2" marker-end="url(#arrow-cyan)"/>
<line x1="620" y1="248" x2="620" y2="212" stroke="#0891b2" stroke-width="1" stroke-dasharray="4,2" marker-end="url(#arrow-cyan)"/>
<text x="214" y="241" fill="#0891b2" font-size="9" font-weight="600">send</text>
<text x="424" y="241" fill="#0891b2" font-size="9" font-weight="600">send</text>
<text x="634" y="241" fill="#0891b2" font-size="9" font-weight="600">send</text>
<rect x="400" y="506" width="300" height="66" rx="8" fill="#fff7ed" stroke="#d97706" stroke-width="1.5"/>
<text x="550" y="529" text-anchor="middle" fill="#92400e" font-size="10.5" font-weight="700">Opt-in: .worktrees/&lt;name&gt;</text>
<text x="550" y="548" text-anchor="middle" fill="#a16207" font-size="8.5">separate checkout + retained wt/&lt;name&gt; branch</text>
<text x="550" y="562" text-anchor="middle" fill="#78716c" font-size="8">working-directory isolation, not a sandbox</text>
<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">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>
<text x="285" y="284" fill="#16a34a" font-size="8">Independent agent_loop, shared client</text>
<text x="285" y="298" fill="#6b7280" font-size="8">Thread(daemon=True)</text>
<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 → result → idle</text>
<!-- ===== 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">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">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 + persistent teammates + event delivery + typed protocols + plan gate</text>
<rect x="60" y="590" width="640" height="20" rx="5" fill="#ecfdf5" stroke="#bbf7d0"/>
<text x="380" y="604" text-anchor="middle" fill="#166534" font-size="8.5" font-weight="600">Only successful task completion clears the teammate assignment and cwd.</text>
</svg>

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Before After
Before After

View file

@ -1,120 +1,107 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 470" font-family="system-ui, -apple-system, sans-serif">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 620" 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="#0891b2"/>
</linearGradient>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#475569"/>
</marker>
<marker id="arrow-cyan" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<marker id="arrow-cyan" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#0891b2"/>
</marker>
<marker id="arrow-amber" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#d97706"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<marker id="arrow-green" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
<marker id="arrow-amber" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#d97706"/>
</marker>
</defs>
<rect width="760" height="470" fill="#fafbfc" rx="8"/>
<rect width="760" height="620" rx="8" fill="#fafbfc"/>
<rect width="760" height="44" rx="8" fill="url(#header)"/>
<rect y="36" width="760" height="8" fill="url(#header)"/>
<text x="380" y="28" text-anchor="middle" fill="#fff" font-size="15" font-weight="700">Agent Teams — 1 つの Team Harness</text>
<!-- タイトル -->
<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">Agent Teams — Lead ループ + チームメイトスレッド + MessageBus</text>
<!-- ユーザー確認、Lead、制御プロトコ-->
<rect x="28" y="82" width="140" height="62" rx="8" fill="#f8fafc" stroke="#64748b" stroke-width="1.5"/>
<text x="98" y="106" text-anchor="middle" fill="#334155" font-size="11" font-weight="700">ユーザー</text>
<text x="98" y="124" text-anchor="middle" fill="#64748b" font-size="9">先にチームを確認</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">s10-s14 保持</text>
<rect x="160" y="56" width="12" height="10" rx="2" fill="#ecfeff" stroke="#0891b2" stroke-width="1"/>
<text x="178" y="66" fill="#0891b2" font-size="10" font-weight="600">s15 新規</text>
<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">プロトコルゲート</text>
<line x1="168" y1="113" x2="220" y2="113" stroke="#475569" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- ===== 行 1: Lead Agent ループ ===== -->
<rect x="28" y="90" width="70" height="40" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="1.5"/>
<text x="63" y="114" fill="#4f46e5" font-size="8" font-weight="600" text-anchor="middle">cron_queue</text>
<rect x="220" y="72" width="300" height="82" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.8"/>
<text x="370" y="96" text-anchor="middle" fill="#1e3a5f" font-size="12" font-weight="700">Lead Agent Loop</text>
<text x="370" y="116" text-anchor="middle" fill="#475569" font-size="9">ユーザー対話 · タスク作成 · チーム調整</text>
<text x="370" y="134" text-anchor="middle" fill="#2563eb" font-size="9" font-weight="600">起動 · 送信 · worktree 作成/削除 · 計画レビュー</text>
<line x1="98" y1="110" x2="112" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<line x1="520" y1="113" x2="570" y2="113" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow-amber)"/>
<rect x="115" y="90" width="72" height="40" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="151" y="114" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">messages</text>
<rect x="570" y="82" width="162" height="62" rx="8" fill="#fffbeb" stroke="#d97706" stroke-width="1.5"/>
<text x="651" y="105" text-anchor="middle" fill="#92400e" font-size="10" font-weight="700">型付き制御</text>
<text x="651" y="122" text-anchor="middle" fill="#a16207" font-size="8.5">request_id · shutdown</text>
<text x="651" y="136" text-anchor="middle" fill="#a16207" font-size="8.5">計画承認ゲート</text>
<line x1="187" y1="110" x2="201" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- 線の端点はコンポーネントの境界に合わせる -->
<line x1="370" y1="154" x2="370" y2="190" stroke="#0891b2" stroke-width="1.8" marker-end="url(#arrow-cyan)"/>
<line x1="651" y1="144" x2="651" y2="190" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow-amber)"/>
<rect x="204" y="86" width="86" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="247" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">prompt + cache</text>
<rect x="60" y="190" width="640" height="44" rx="22" fill="#ecfeff" stroke="#0891b2" stroke-width="2"/>
<text x="380" y="209" text-anchor="middle" fill="#0e7490" font-size="11" font-weight="700">MessageBus · .mailboxes/&lt;name&gt;.jsonl</text>
<text x="380" y="225" text-anchor="middle" fill="#0f766e" font-size="8.5">ランタイム配信 · 通常メッセージ · result · idle_notification · 制御イベント</text>
<line x1="290" y1="110" x2="304" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- MessageBus とチームメイトループ -->
<line x1="118" y1="234" x2="118" y2="282" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<line x1="348" y1="234" x2="348" y2="282" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<line x1="578" y1="234" x2="578" y2="282" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<line x1="182" y1="282" x2="182" y2="234" stroke="#0891b2" stroke-width="1.2" stroke-dasharray="4 3" marker-end="url(#arrow-cyan)"/>
<line x1="412" y1="282" x2="412" y2="234" stroke="#0891b2" stroke-width="1.2" stroke-dasharray="4 3" marker-end="url(#arrow-cyan)"/>
<line x1="642" y1="282" x2="642" y2="234" stroke="#0891b2" stroke-width="1.2" stroke-dasharray="4 3" marker-end="url(#arrow-cyan)"/>
<rect x="307" y="86" width="74" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="344" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">LLM call</text>
<rect x="40" y="282" width="220" height="82" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
<text x="150" y="305" text-anchor="middle" fill="#166534" font-size="10.5" font-weight="700">チームメイトconfig</text>
<text x="150" y="324" text-anchor="middle" fill="#15803d" font-size="8.5">独立した prompt · messages · tools</text>
<text x="150" y="342" text-anchor="middle" fill="#475569" font-size="8.5">WORK → result → IDLE</text>
<text x="150" y="356" text-anchor="middle" fill="#64748b" font-size="8">直接メッセージで WORK へ戻る</text>
<line x1="381" y1="110" x2="395" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="270" y="282" width="220" height="82" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
<text x="380" y="305" text-anchor="middle" fill="#166534" font-size="10.5" font-weight="700">チームメイトauth</text>
<text x="380" y="324" text-anchor="middle" fill="#15803d" font-size="8.5">独立した prompt · messages · tools</text>
<text x="380" y="342" text-anchor="middle" fill="#475569" font-size="8.5">WORK → result → IDLE</text>
<text x="380" y="356" text-anchor="middle" fill="#64748b" font-size="8">Claim したタスクで WORK へ戻る</text>
<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 · send · shutdown · plan review</text>
<rect x="500" y="282" width="220" height="82" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
<text x="610" y="305" text-anchor="middle" fill="#166534" font-size="10.5" font-weight="700">チームメイトtests</text>
<text x="610" y="324" text-anchor="middle" fill="#15803d" font-size="8.5">独立した prompt · messages · tools</text>
<text x="610" y="342" text-anchor="middle" fill="#475569" font-size="8.5">WORK → result → IDLE</text>
<text x="610" y="356" text-anchor="middle" fill="#64748b" font-size="8">shutdown でループ終了</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"/>
<!-- IDLE のタスク発見 -->
<line x1="150" y1="364" x2="150" y2="404" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<line x1="380" y1="364" x2="380" y2="404" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<line x1="610" y1="364" x2="610" y2="404" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<!-- ===== spawn 矢印 ===== -->
<line x1="560" y1="140" x2="560" y2="178" stroke="#0891b2" stroke-width="2" marker-end="url(#arrow-cyan)"/>
<rect x="543" y="152" width="38" height="14" rx="3" fill="#ecfeff"/>
<text x="562" y="163" fill="#0891b2" font-size="8" font-weight="600" text-anchor="middle">spawn</text>
<rect x="60" y="404" width="640" height="54" rx="8" fill="#f8fafc" stroke="#64748b" stroke-width="1.5"/>
<text x="380" y="425" text-anchor="middle" fill="#334155" font-size="11" font-weight="700">共有タスクボード · .tasks/</text>
<text x="380" y="444" text-anchor="middle" fill="#475569" font-size="9">IDLE受信箱を先に待つ → ready task を走査 → アトミックに Claim → WORK を再利用</text>
<!-- ===== 行 2: MessageBus ===== -->
<rect x="60" y="178" width="640" height="34" rx="17" fill="#ecfeff" stroke="#0891b2" stroke-width="2"/>
<text x="380" y="200" fill="#0e7490" font-size="11" font-weight="700" text-anchor="middle">MessageBus (.mailboxes/*.jsonl)</text>
<!-- タスクの紐付けが cwd を選ぶ -->
<line x1="380" y1="458" x2="380" y2="482" stroke="#475569" stroke-width="1.5"/>
<line x1="220" y1="482" x2="540" y2="482" stroke="#475569" stroke-width="1.5"/>
<line x1="220" y1="482" x2="220" y2="506" stroke="#475569" stroke-width="1.5" marker-end="url(#arrow)"/>
<line x1="540" y1="482" x2="540" y2="506" stroke="#475569" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="311" y="468" width="138" height="18" rx="4" fill="#fafbfc"/>
<text x="380" y="480" text-anchor="middle" fill="#475569" font-size="8.5" font-weight="600">Task.worktree が cwd を選択</text>
<!-- ===== 行 3: チームメイト ===== -->
<line x1="170" y1="212" x2="170" y2="248" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<line x1="380" y1="212" x2="380" y2="248" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<line x1="590" y1="212" x2="590" y2="248" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<text x="124" y="226" fill="#16a34a" font-size="9" font-weight="600">receive</text>
<text x="334" y="226" fill="#16a34a" font-size="9" font-weight="600">receive</text>
<text x="544" y="226" fill="#16a34a" font-size="9" font-weight="600">receive</text>
<rect x="60" y="506" width="300" height="66" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="210" y="529" text-anchor="middle" fill="#1e3a5f" font-size="10.5" font-weight="700">デフォルト:リポジトリ WORKDIR</text>
<text x="210" y="548" text-anchor="middle" fill="#475569" font-size="8.5">task.worktree は null</text>
<text x="210" y="562" text-anchor="middle" fill="#64748b" font-size="8">以前のレッスンと同じ動作</text>
<line x1="200" y1="248" x2="200" y2="212" stroke="#0891b2" stroke-width="1" stroke-dasharray="4,2" marker-end="url(#arrow-cyan)"/>
<line x1="410" y1="248" x2="410" y2="212" stroke="#0891b2" stroke-width="1" stroke-dasharray="4,2" marker-end="url(#arrow-cyan)"/>
<line x1="620" y1="248" x2="620" y2="212" stroke="#0891b2" stroke-width="1" stroke-dasharray="4,2" marker-end="url(#arrow-cyan)"/>
<text x="214" y="241" fill="#0891b2" font-size="9" font-weight="600">send</text>
<text x="424" y="241" fill="#0891b2" font-size="9" font-weight="600">send</text>
<text x="634" y="241" fill="#0891b2" font-size="9" font-weight="600">send</text>
<rect x="400" y="506" width="300" height="66" rx="8" fill="#fff7ed" stroke="#d97706" stroke-width="1.5"/>
<text x="550" y="529" text-anchor="middle" fill="#92400e" font-size="10.5" font-weight="700">任意:.worktrees/&lt;name&gt;</text>
<text x="550" y="548" text-anchor="middle" fill="#a16207" font-size="8.5">独立 checkout + wt/&lt;name&gt; branch を保持</text>
<text x="550" y="562" text-anchor="middle" fill="#78716c" font-size="8">作業ディレクトリの分離であり sandbox ではない</text>
<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">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>
<text x="285" y="284" fill="#16a34a" font-size="8">独立 agent_loop、共有 client</text>
<text x="285" y="298" fill="#6b7280" font-size="8">Thread(daemon=True)</text>
<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 → result → idle</text>
<!-- ===== 行 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">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">プラン承認ゲート</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 + 永続チームメイト + イベント配信 + 型付きプロトコル + プランゲート</text>
<rect x="60" y="590" width="640" height="20" rx="5" fill="#ecfdf5" stroke="#bbf7d0"/>
<text x="380" y="604" text-anchor="middle" fill="#166534" font-size="8.5" font-weight="600">タスク完了に成功した時だけ、チームメイトの assignment と cwd を解除する。</text>
</svg>

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Before After
Before After

View file

@ -1,131 +1,107 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 470" font-family="system-ui, -apple-system, sans-serif">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 620" 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="#0891b2"/>
</linearGradient>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#475569"/>
</marker>
<marker id="arrow-cyan" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<marker id="arrow-cyan" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#0891b2"/>
</marker>
<marker id="arrow-amber" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#d97706"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<marker id="arrow-green" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
<marker id="arrow-amber" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#d97706"/>
</marker>
</defs>
<rect width="760" height="470" fill="#fafbfc" rx="8"/>
<rect width="760" height="620" rx="8" fill="#fafbfc"/>
<rect width="760" height="44" rx="8" fill="url(#header)"/>
<rect y="36" width="760" height="8" fill="url(#header)"/>
<text x="380" y="28" text-anchor="middle" fill="#fff" font-size="15" font-weight="700">Agent Teams — 一套 Team Harness</text>
<!-- 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">Agent Teams — Lead Loop + Teammate Threads + MessageBus</text>
<!-- 用户确认、Lead 与控制协议 -->
<rect x="28" y="82" width="140" height="62" rx="8" fill="#f8fafc" stroke="#64748b" stroke-width="1.5"/>
<text x="98" y="106" text-anchor="middle" fill="#334155" font-size="11" font-weight="700">用户</text>
<text x="98" y="124" text-anchor="middle" fill="#64748b" font-size="9">先确认团队方案</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">s10-s14 保留</text>
<rect x="160" y="56" width="12" height="10" rx="2" fill="#ecfeff" stroke="#0891b2" stroke-width="1"/>
<text x="178" y="66" fill="#0891b2" font-size="10" font-weight="600">s15 新增</text>
<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">协议闸门</text>
<line x1="168" y1="113" x2="220" y2="113" stroke="#475569" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- ===== 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) -->
<rect x="220" y="72" width="300" height="82" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.8"/>
<text x="370" y="96" text-anchor="middle" fill="#1e3a5f" font-size="12" font-weight="700">Lead Agent Loop</text>
<text x="370" y="116" text-anchor="middle" fill="#475569" font-size="9">用户对话 · 创建任务 · 协调团队</text>
<text x="370" y="134" text-anchor="middle" fill="#2563eb" font-size="9" font-weight="600">启动 · 发消息 · 创建/移除 worktree · 审批计划</text>
<rect x="28" y="90" width="70" height="40" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="1.5"/>
<text x="63" y="114" fill="#4f46e5" font-size="8" font-weight="600" text-anchor="middle">cron_queue</text>
<line x1="520" y1="113" x2="570" y2="113" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow-amber)"/>
<line x1="98" y1="110" x2="112" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="570" y="82" width="162" height="62" rx="8" fill="#fffbeb" stroke="#d97706" stroke-width="1.5"/>
<text x="651" y="105" text-anchor="middle" fill="#92400e" font-size="10" font-weight="700">类型化控制</text>
<text x="651" y="122" text-anchor="middle" fill="#a16207" font-size="8.5">request_id · shutdown</text>
<text x="651" y="136" text-anchor="middle" fill="#a16207" font-size="8.5">计划审批闸门</text>
<rect x="115" y="90" width="72" height="40" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="151" y="114" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">messages</text>
<!-- 连线端点精确落在组件边缘 -->
<line x1="370" y1="154" x2="370" y2="190" stroke="#0891b2" stroke-width="1.8" marker-end="url(#arrow-cyan)"/>
<line x1="651" y1="144" x2="651" y2="190" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow-amber)"/>
<line x1="187" y1="110" x2="201" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="60" y="190" width="640" height="44" rx="22" fill="#ecfeff" stroke="#0891b2" stroke-width="2"/>
<text x="380" y="209" text-anchor="middle" fill="#0e7490" font-size="11" font-weight="700">MessageBus · .mailboxes/&lt;name&gt;.jsonl</text>
<text x="380" y="225" text-anchor="middle" fill="#0f766e" font-size="8.5">运行时投递 · 普通消息 · result · idle_notification · 控制事件</text>
<rect x="204" y="86" width="86" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="247" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">prompt + cache</text>
<!-- MessageBus 与队友循环 -->
<line x1="118" y1="234" x2="118" y2="282" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<line x1="348" y1="234" x2="348" y2="282" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<line x1="578" y1="234" x2="578" y2="282" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<line x1="182" y1="282" x2="182" y2="234" stroke="#0891b2" stroke-width="1.2" stroke-dasharray="4 3" marker-end="url(#arrow-cyan)"/>
<line x1="412" y1="282" x2="412" y2="234" stroke="#0891b2" stroke-width="1.2" stroke-dasharray="4 3" marker-end="url(#arrow-cyan)"/>
<line x1="642" y1="282" x2="642" y2="234" stroke="#0891b2" stroke-width="1.2" stroke-dasharray="4 3" marker-end="url(#arrow-cyan)"/>
<line x1="290" y1="110" x2="304" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="40" y="282" width="220" height="82" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
<text x="150" y="305" text-anchor="middle" fill="#166534" font-size="10.5" font-weight="700">队友config</text>
<text x="150" y="324" text-anchor="middle" fill="#15803d" font-size="8.5">独立 prompt · messages · tools</text>
<text x="150" y="342" text-anchor="middle" fill="#475569" font-size="8.5">WORK → result → IDLE</text>
<text x="150" y="356" text-anchor="middle" fill="#64748b" font-size="8">直接消息使其回到 WORK</text>
<rect x="307" y="86" width="74" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="344" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">LLM call</text>
<rect x="270" y="282" width="220" height="82" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
<text x="380" y="305" text-anchor="middle" fill="#166534" font-size="10.5" font-weight="700">队友auth</text>
<text x="380" y="324" text-anchor="middle" fill="#15803d" font-size="8.5">独立 prompt · messages · tools</text>
<text x="380" y="342" text-anchor="middle" fill="#475569" font-size="8.5">WORK → result → IDLE</text>
<text x="380" y="356" text-anchor="middle" fill="#64748b" font-size="8">认领任务使其回到 WORK</text>
<line x1="381" y1="110" x2="395" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="500" y="282" width="220" height="82" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
<text x="610" y="305" text-anchor="middle" fill="#166534" font-size="10.5" font-weight="700">队友tests</text>
<text x="610" y="324" text-anchor="middle" fill="#15803d" font-size="8.5">独立 prompt · messages · tools</text>
<text x="610" y="342" text-anchor="middle" fill="#475569" font-size="8.5">WORK → result → IDLE</text>
<text x="610" y="356" text-anchor="middle" fill="#64748b" font-size="8">shutdown 结束循环</text>
<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 · send · shutdown · plan review</text>
<!-- IDLE 时发现任务 -->
<line x1="150" y1="364" x2="150" y2="404" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<line x1="380" y1="364" x2="380" y2="404" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<line x1="610" y1="364" x2="610" y2="404" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<!-- 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"/>
<rect x="60" y="404" width="640" height="54" rx="8" fill="#f8fafc" stroke="#64748b" stroke-width="1.5"/>
<text x="380" y="425" text-anchor="middle" fill="#334155" font-size="11" font-weight="700">共享任务板 · .tasks/</text>
<text x="380" y="444" text-anchor="middle" fill="#475569" font-size="9">IDLE先等收件箱 → 扫描 ready task → 原子认领 → 复用 WORK 循环</text>
<!-- ===== Spawn arrow: TOOLS bottom → MessageBus top ===== -->
<!-- TOOLS bottom: y=140, Bus top: y=178 -->
<line x1="560" y1="140" x2="560" y2="178" stroke="#0891b2" stroke-width="2" marker-end="url(#arrow-cyan)"/>
<rect x="543" y="152" width="38" height="14" rx="3" fill="#ecfeff"/>
<text x="562" y="163" fill="#0891b2" font-size="8" font-weight="600" text-anchor="middle">spawn</text>
<!-- 任务绑定选择 cwd -->
<line x1="380" y1="458" x2="380" y2="482" stroke="#475569" stroke-width="1.5"/>
<line x1="220" y1="482" x2="540" y2="482" stroke="#475569" stroke-width="1.5"/>
<line x1="220" y1="482" x2="220" y2="506" stroke="#475569" stroke-width="1.5" marker-end="url(#arrow)"/>
<line x1="540" y1="482" x2="540" y2="506" stroke="#475569" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="311" y="468" width="138" height="18" rx="4" fill="#fafbfc"/>
<text x="380" y="480" text-anchor="middle" fill="#475569" font-size="8.5" font-weight="600">Task.worktree 选择 cwd</text>
<!-- ===== Row 2: MessageBus ===== -->
<!-- y=178..212 (h=34) -->
<rect x="60" y="178" width="640" height="34" rx="17" fill="#ecfeff" stroke="#0891b2" stroke-width="2"/>
<text x="380" y="200" fill="#0e7490" font-size="11" font-weight="700" text-anchor="middle">MessageBus (.mailboxes/*.jsonl)</text>
<rect x="60" y="506" width="300" height="66" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="210" y="529" text-anchor="middle" fill="#1e3a5f" font-size="10.5" font-weight="700">默认:仓库 WORKDIR</text>
<text x="210" y="548" text-anchor="middle" fill="#475569" font-size="8.5">task.worktree 为 null</text>
<text x="210" y="562" text-anchor="middle" fill="#64748b" font-size="8">行为与前面章节一致</text>
<!-- ===== Row 3: Teammates ===== -->
<!-- Bus bottom: y=212, Teammate top: y=248 (gap=36) -->
<rect x="400" y="506" width="300" height="66" rx="8" fill="#fff7ed" stroke="#d97706" stroke-width="1.5"/>
<text x="550" y="529" text-anchor="middle" fill="#92400e" font-size="10.5" font-weight="700">按需开启:.worktrees/&lt;name&gt;</text>
<text x="550" y="548" text-anchor="middle" fill="#a16207" font-size="8.5">独立 checkout + 保留 wt/&lt;name&gt; 分支</text>
<text x="550" y="562" text-anchor="middle" fill="#78716c" font-size="8">只隔开工作目录,不是安全沙箱</text>
<!-- Bus → Teammate: incoming arrows (solid green) -->
<line x1="170" y1="212" x2="170" y2="248" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<line x1="380" y1="212" x2="380" y2="248" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<line x1="590" y1="212" x2="590" y2="248" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<text x="124" y="226" fill="#16a34a" font-size="9" font-weight="600">receive</text>
<text x="334" y="226" fill="#16a34a" font-size="9" font-weight="600">receive</text>
<text x="544" y="226" fill="#16a34a" font-size="9" font-weight="600">receive</text>
<!-- Teammate → Bus: send_message (dashed cyan, offset 30px right) -->
<line x1="200" y1="248" x2="200" y2="212" stroke="#0891b2" stroke-width="1" stroke-dasharray="4,2" marker-end="url(#arrow-cyan)"/>
<line x1="410" y1="248" x2="410" y2="212" stroke="#0891b2" stroke-width="1" stroke-dasharray="4,2" marker-end="url(#arrow-cyan)"/>
<line x1="620" y1="248" x2="620" y2="212" stroke="#0891b2" stroke-width="1" stroke-dasharray="4,2" marker-end="url(#arrow-cyan)"/>
<text x="214" y="241" fill="#0891b2" font-size="9" font-weight="600">send</text>
<text x="424" y="241" fill="#0891b2" font-size="9" font-weight="600">send</text>
<text x="634" y="241" fill="#0891b2" font-size="9" font-weight="600">send</text>
<!-- alice: x=60..280, y=248..314 -->
<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">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"/>
<text x="380" y="268" fill="#166534" font-size="10" font-weight="700" text-anchor="middle">Teammate: bob (Frontend)</text>
<text x="285" y="284" fill="#16a34a" font-size="8">独立 agent_loop共享 client</text>
<text x="285" y="298" fill="#6b7280" font-size="8">Thread(daemon=True)</text>
<!-- charlie: x=480..700, y=248..314 -->
<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 → result → idle</text>
<!-- ===== 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">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">计划审批闸门</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 + 持久队友 + 自动事件投递 + 类型化协议 + 计划闸门</text>
<rect x="60" y="590" width="640" height="20" rx="5" fill="#ecfdf5" stroke="#bbf7d0"/>
<text x="380" y="604" text-anchor="middle" fill="#166534" font-size="8.5" font-weight="600">只有任务成功完成后,运行时才会清除队友的 assignment 和 cwd。</text>
</svg>

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Before After
Before After

View file

@ -1,204 +0,0 @@
# 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 -->

View file

@ -1,204 +0,0 @@
# 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 -->

View file

@ -1,207 +0,0 @@
# 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

View file

@ -1,109 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 470" 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="#059669"/>
</linearGradient>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#059669"/>
</marker>
<marker id="arrow-red" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#dc2626"/>
</marker>
</defs>
<rect width="760" height="470" fill="#fafbfc" rx="8"/>
<!-- 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 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">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">s16 New</text>
<!-- ===== 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>
<line x1="90" y1="110" x2="104" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="107" y="90" width="70" height="40" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="142" y="114" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">messages</text>
<line x1="177" y1="110" x2="191" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="194" y="86" width="80" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="234" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">prompt</text>
<line x1="274" y1="110" x2="288" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="291" y="86" width="70" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="326" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">LLM</text>
<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 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: 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">idle teammates look for ready work</text>
<!-- ===== 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">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">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">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)"/>
<text x="350" y="253" fill="#059669" font-size="8" font-weight="600" text-anchor="middle">task done</text>
<!-- Arrow: IDLE → WORK (curved, above) -->
<path d="M 415 232 C 375 200, 320 200, 285 232" fill="none" stroke="#059669" stroke-width="1.5" marker-end="url(#arrow-green)" stroke-dasharray="5,3"/>
<text x="350" y="208" fill="#059669" font-size="8" font-weight="600" text-anchor="middle">work found</text>
<!-- 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</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">└ 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"/>
<text x="580" y="351" fill="#991b1b" font-size="9" font-weight="700" text-anchor="middle">SHUTDOWN</text>
<!-- 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">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">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">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 creates the task graph · teammates gain 3 task tools and atomically claim ready work</text>
</svg>

Before

Width:  |  Height:  |  Size: 7.4 KiB

View file

@ -1,109 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 470" 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="#059669"/>
</linearGradient>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#059669"/>
</marker>
<marker id="arrow-red" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#dc2626"/>
</marker>
</defs>
<rect width="760" height="470" fill="#fafbfc" rx="8"/>
<!-- 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 — アイドル時のタスク発見 + 原子的な認領</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">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">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"/>
<text x="55" y="114" fill="#4f46e5" font-size="8" font-weight="600" text-anchor="middle">turn</text>
<line x1="90" y1="110" x2="104" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="107" y="90" width="70" height="40" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="142" y="114" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">messages</text>
<line x1="177" y1="110" x2="191" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="194" y="86" width="80" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="234" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">prompt</text>
<line x1="274" y1="110" x2="288" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="291" y="86" width="70" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="326" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">LLM</text>
<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 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: 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">待機中に実行可能な仕事を探す</text>
<!-- ===== 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">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">ランタイム配信メッセージ → 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">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)"/>
<text x="350" y="253" fill="#059669" font-size="8" font-weight="600" text-anchor="middle">タスク完了</text>
<!-- Arrow: IDLE → WORK -->
<path d="M 415 232 C 375 200, 320 200, 285 232" fill="none" stroke="#059669" stroke-width="1.5" marker-end="url(#arrow-green)" stroke-dasharray="5,3"/>
<text x="350" y="208" fill="#059669" font-size="8" font-weight="600" text-anchor="middle">仕事を発見</text>
<!-- 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 フェーズ</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">└ 実行可能なタスクなし → 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"/>
<text x="580" y="351" fill="#991b1b" font-size="9" font-weight="700" text-anchor="middle">SHUTDOWN</text>
<!-- 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">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">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">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 が依存グラフを作成 · チームメイトは 3 つのタスクツールで仕事を原子的に認領</text>
</svg>

Before

Width:  |  Height:  |  Size: 7.5 KiB

View file

@ -1,109 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 470" 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="#059669"/>
</linearGradient>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#059669"/>
</marker>
<marker id="arrow-red" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#dc2626"/>
</marker>
</defs>
<rect width="760" height="470" fill="#fafbfc" rx="8"/>
<!-- 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 — 空闲任务发现 + 原子认领</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">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">s16 新增</text>
<!-- ===== 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>
<line x1="90" y1="110" x2="104" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="107" y="90" width="70" height="40" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="142" y="114" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">messages</text>
<line x1="177" y1="110" x2="191" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="194" y="86" width="80" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="234" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">prompt</text>
<line x1="274" y1="110" x2="288" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="291" y="86" width="70" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="326" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">LLM</text>
<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 (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: 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">空闲时从任务板寻找可执行工作</text>
<!-- ===== 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">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">消息自动进入上下文 → 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">完成后发送 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)"/>
<text x="350" y="253" fill="#059669" font-size="8" font-weight="600" text-anchor="middle">任务完成</text>
<!-- Arrow: IDLE → WORK (curved, above) -->
<path d="M 415 232 C 375 200, 320 200, 285 232" fill="none" stroke="#059669" stroke-width="1.5" marker-end="url(#arrow-green)" stroke-dasharray="5,3"/>
<text x="350" y="208" fill="#059669" font-size="8" font-weight="600" text-anchor="middle">发现新任务</text>
<!-- 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 阶段</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">└ 没有就绪任务 → 保持 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"/>
<text x="580" y="351" fill="#991b1b" font-size="9" font-weight="700" text-anchor="middle">SHUTDOWN</text>
<!-- 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">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">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">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 创建任务依赖图 · 队友获得 3 个任务工具并原子认领就绪工作</text>
</svg>

Before

Width:  |  Height:  |  Size: 7.4 KiB

View file

@ -1,8 +1,8 @@
# s18: MCP Tools — 外部ツール、標準プロトコル
# s16: MCP Tools — 外部ツール、標準プロトコル
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s16 → s17 → `s18` → [s19](../s19_comprehensive/) → s20 → s21
[s15](../s15_agent_teams/) → `s16` → [s17](../s17_integrated_harness/) → s18 → s19
> *"外部ツール、標準プロトコル"* — 発見、組み立て、呼び出し。Agent はツールを誰が書いたか知る必要がない。
>
@ -12,7 +12,7 @@ s01 → ... → s16 → s17 → `s18` → [s19](../s19_comprehensive/) → s20
## 課題
s01 から s17 まで、Agent の全ツールは手書き — bash、read、write、task、worktree。入力検証、実行ロジック、エラーハンドリング、全て一行ずつ書いた。
s01 から s15 まで、Agent の全ツールは手書き — bash、read、write、task、worktree。入力検証、実行ロジック、エラーハンドリング、全て一行ずつ書いた。
今、統合したい外部サービスが 3 つある:社内の Jira APIissue 検索、ticket 作成、独自のデプロイシステムdeploy トリガー、ログ閲覧)、チームの Notion ナレッジベース(ドキュメント検索、ページ作成)。各サービスのためにツールコードを書き直したくない。
@ -33,7 +33,11 @@ MCPModel Context Protocolは、Agent が外部ツールを発見・呼び
| assemble_tool_pool | 組み込みツールと MCP ツールを一つのツールプールに組み立てる |
| mcp\_\_server\_\_tool 命名 | 異なる server 間のツール名衝突を防止 |
s17 の worktree 分離、自動認領、チームプロトコルを引き継ぐ。本章では `connect_mcp` ツールを追加し、サービスへの接続、ツール発見、ツールプールへの追加を行う。
s15 の Team runtime を土台にし、idle 時の atomic task claim、安全な task-worktree binding、coordination protocol を引き継ぐ。cron scheduling、background bash の lifecycle、完了後に Lead を自動で起こす通知もそのまま残す。本章では `connect_mcp` ツールを追加し、サービスへの接続、ツール発見、ツールプールへの追加を行う。
task-bound worktree はチームメイトのファイルツールに対するデフォルト作業ディレクトリを変更するだけであり、セキュリティサンドボックスではない。
モデルに公開する `remove_worktree` が受け取るのは `name` だけなので、削除できるのは clean な checkout に限られる。変更を破棄する場合は、ユーザーが Git を手動実行するか、明示的な確認を経て host が下位の強制削除経路を呼び出す。モデル自身が強制削除を選ぶことはできない。
本章はプロセス内の server handler を登録し、発見から呼び出しまでをオフラインで実行する。各 handler はクライアントが必要とする `tools/list``tools/call` を提供する。
@ -110,13 +114,13 @@ def assemble_tool_pool() -> tuple[list[dict], dict]:
return tools, handlers
```
プレフィックス `mcp__{server}__{tool}`異なる server 間のツール名衝突を防止。名前は `normalize_mcp_name` で正規化。
プレフィックス `mcp__{server}__{tool}` server ごとのツールを分離し、名前は `normalize_mcp_name` で正規化する。異なる元の名前が同じプレフィックスになる可能性があるため、`assemble_tool_pool()` は先に登録された handler を暗黙に上書きせず、衝突を拒否する
MCP ツールの description に `(readOnly)` または `(destructive)` を付け、読み取りと変更の区別をツールメタデータ上で明示する。
### キャッシュなし:ツールプールが変われば、プロンプトも変わる
s10-s17 の agent_loop は prompt cache で再シリアライズを回避。s18 はキャッシュを削除:
s10-s15 の agent loop は prompt cache で再シリアライズを回避。s16 はキャッシュを削除:
```python
def agent_loop(messages, context):
@ -132,13 +136,13 @@ def agent_loop(messages, context):
### MCP ツールは Lead のみ利用可能
`connect_mcp` は Lead のツールであり、`assemble_tool_pool` も Lead の agent loop に使われる。チームメイトはタスク、ファイル、メッセージ、プランの各ツールを保持し、Lead が外部サービスを呼び出して得た仕事を割り当てる。
`connect_mcp` は Lead のツールであり、`assemble_tool_pool` も Lead の agent loop に使われる。チームメイトはタスク、ファイル、メッセージ、プランの各ツールを保持する。Lead は外部サービスを呼び出して得た仕事を共有 task board に置き、idle のチームメイトが atomic に claim する。
---
## s17 からの変更
## s15 からの変更
| コンポーネント | 変更前 (s17) | 変更後 (s18) |
| コンポーネント | 変更前 (s15) | 変更後 (s16) |
|--------------|------------|------------|
| ツールソース | 全て手書き builtin | 手書き + MCP 外部ツール動的発見 |
| ツールプール | 固定 BUILTIN_TOOLS | assemble_tool_pool が動的に mcp\_\_ プレフィックスツールを組み立てる |
@ -147,7 +151,8 @@ def agent_loop(messages, context):
| 名前空間 | — | mcp\_\_server\_\_tool 衝突防止 |
| ツール説明 | アノテーションなし | (readOnly)/(destructive) アノテーション |
| プロンプトキャッシュ | ありs10 から) | 削除 — ツールプールが動的、キャッシュが陳腐化 |
| Lead ツール | worktree・チームツール | + connect_mcp と動的に発見した MCP ツール |
| 既存 runtime | task、cron、background bash、team、worktree | 全て維持 |
| Lead ツール | cron、background、worktree・チームツール | + connect_mcp と動的に発見した MCP ツール |
| チームメイトツール | タスク、ファイル、メッセージ、プランのツール | 変更なし |
| 拡張方法 | ツール追加のコードを書く | 標準プロトコル、任意言語で server を実装 |
@ -157,7 +162,7 @@ def agent_loop(messages, context):
```sh
cd learn-claude-code
python s18_mcp_plugin/code.py
python s16_mcp_plugin/code.py
```
以下のプロンプトを試してください:
@ -172,11 +177,11 @@ python s18_mcp_plugin/code.py
## 次の章
Agent は標準プロトコルで外部ツールに接続できるようになった。前 18 章では、各境界を観察できるように仕組みを一つずつ追加してきた。
Agent は標準プロトコルで外部ツールに接続できるようになった。前 16 章では、各境界を観察できるように仕組みを一つずつ追加してきた。
tools、permissions、hooks、todo、task graph、memory、compact、background work、cron、teams、worktree、MCP は、別々の例ではなく同じ loop に接続されるべきです。
s19 Integrated Harness → s01-s18 の仕組みを 1 つの harness に統合。仕組みは多く、loop は 1 つ。
[s17 Integrated Harness](../s17_integrated_harness/) → s01-s16 の仕組みを 1 つの harness に統合。仕組みは多く、loop は 1 つ。
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->
<!-- translation-sync: zh@v4, en@v4, ja@v4 -->

View file

@ -1,8 +1,8 @@
# s18: MCP Tools — External Tools, Standard Protocol
# s16: MCP Tools — External Tools, Standard Protocol
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s16 → s17 → `s18` → [s19](../s19_comprehensive/) → s20 → s21
[s15](../s15_agent_teams/) → `s16` → [s17](../s17_integrated_harness/) → s18 → s19
> *"External tools, standard protocol"* — Discover, assemble, invoke. Agent doesn't need to know who wrote them.
>
@ -12,7 +12,7 @@ s01 → ... → s16 → s17 → `s18` → [s19](../s19_comprehensive/) → s20
## The Problem
From s01 through s17, every tool the agent uses was hand-written — bash, read, write, task, worktree. Input validation, execution logic, error handling — all written line by line.
From s01 through s15, every tool the agent uses was hand-written — bash, read, write, task, worktree. Input validation, execution logic, error handling — all written line by line.
Now you have 3 external services to integrate: the company's Jira API (query issues, create tickets), an in-house deployment system (trigger deploys, view logs), and the team's Notion knowledge base (search docs, create pages). You don't want to rewrite tool code for every service.
@ -33,7 +33,11 @@ MCP (Model Context Protocol) defines how agents discover and invoke external too
| assemble_tool_pool | Assembles built-in tools and MCP tools into one tool pool |
| mcp\_\_server\_\_tool naming | Prevents tool name collisions across different servers |
Carries forward s17's worktree isolation, autonomous claiming, and team protocols. This chapter adds the `connect_mcp` tool, which connects to a service, discovers its tools, and adds them to the tool pool.
Builds on s15's team runtime: atomic idle task claiming, safe task-bound worktrees, and coordination protocols. It also retains cron scheduling, the background bash lifecycle, and completion notifications that automatically wake the Lead. This chapter adds the `connect_mcp` tool, which connects to a service, discovers its tools, and adds them to the tool pool.
A task-bound worktree changes the teammate file tools' default working directory; it is not a security sandbox.
The model-facing `remove_worktree` tool accepts only `name`, so it can remove only a clean checkout. Discarding changes remains a manual Git operation for the user, or a host action that follows explicit confirmation; the model cannot opt into the lower-level force path itself.
The chapter registers in-process server handlers so the full discovery and invocation flow runs offline. Each handler exposes the two operations the client needs: `tools/list` and `tools/call`.
@ -110,13 +114,13 @@ def assemble_tool_pool() -> tuple[list[dict], dict]:
return tools, handlers
```
The prefix `mcp__{server}__{tool}` prevents tool name collisions across different servers. Names are normalized through `normalize_mcp_name`.
The prefix `mcp__{server}__{tool}` separates tools across servers, and names are normalized through `normalize_mcp_name`. Because different raw names can normalize to the same prefix, `assemble_tool_pool()` rejects a collision instead of silently replacing the earlier handler.
MCP tool descriptions include `(readOnly)` or `(destructive)` labels, making the distinction visible in the tool metadata.
### No Cache: Tool Pool Changes, Prompt Changes Too
s10-s17's agent_loop used prompt caching to avoid re-serialization. s18 removes the cache:
s10-s15's agent loop used prompt caching to avoid re-serialization. s16 removes the cache:
```python
def agent_loop(messages, context):
@ -132,13 +136,13 @@ After `connect_mcp`, the tool pool gains entries such as `mcp__docs__search`. Re
### MCP Tools: Lead Only
`connect_mcp` belongs to the Lead, and `assemble_tool_pool` serves the Lead's agent loop. Teammates keep their task, file, message, and plan tools; the Lead invokes external services and dispatches the resulting work.
`connect_mcp` belongs to the Lead, and `assemble_tool_pool` serves the Lead's agent loop. Teammates keep their task, file, message, and plan tools; the Lead invokes external services and puts resulting work on the shared task board, where idle teammates can claim it atomically.
---
## Changes from s17
## Changes from s15
| Component | Before (s17) | After (s18) |
| Component | Before (s15) | After (s16) |
|------|-----------|-----------|
| Tool source | All hand-written built-in | Hand-written + MCP external tools with dynamic discovery |
| Tool pool | Fixed BUILTIN_TOOLS | assemble_tool_pool dynamically assembles mcp\_\_ prefixed tools |
@ -147,7 +151,8 @@ After `connect_mcp`, the tool pool gains entries such as `mcp__docs__search`. Re
| Namespace | — | mcp\_\_server\_\_tool prevents collisions |
| Tool descriptions | No annotations | (readOnly)/(destructive) annotations |
| Prompt cache | Yes (since s10) | Removed — tool pool is dynamic, cache goes stale |
| Lead tools | Worktree and team tools | + connect_mcp and dynamically discovered MCP tools |
| Existing runtime | Tasks, cron, background bash, teams, and worktrees | All retained |
| Lead tools | Cron, background, worktree, and team tools | + connect_mcp and dynamically discovered MCP tools |
| Teammate tools | Task, file, message, and plan tools | Unchanged |
| Extension method | Write code to add tools | Standard protocol, implement servers in any language |
@ -157,7 +162,7 @@ After `connect_mcp`, the tool pool gains entries such as `mcp__docs__search`. Re
```sh
cd learn-claude-code
python s18_mcp_plugin/code.py
python s16_mcp_plugin/code.py
```
Try these prompts:
@ -172,11 +177,11 @@ What to observe: After connecting to an MCP server, do tool names have `mcp__doc
## What's Next
The Agent can now connect external tools through a standard protocol. The first 18 chapters introduced these mechanisms one at a time so each boundary stayed visible.
The Agent can now connect external tools through a standard protocol. The first 16 chapters introduced these mechanisms one at a time so each boundary stayed visible.
Tools, permissions, hooks, todo, task graph, memory, compact, background work, cron, teams, worktrees, and MCP should all attach to the same loop, not live in separate examples.
s19 Integrated Harness → Combine the mechanisms from s01-s18 into one harness. Many mechanisms, one loop.
[s17 Integrated Harness](../s17_integrated_harness/) → Combine the mechanisms from s01-s16 into one harness. Many mechanisms, one loop.
<!-- translation-sync: zh@v2, en@v2, ja@v0 -->
<!-- translation-sync: zh@v4, en@v4, ja@v4 -->

View file

@ -1,8 +1,8 @@
# s18: MCP Tools — 外接工具,标准协议
# s16: MCP Tools — 外接工具,标准协议
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s16 → s17 → `s18` → [s19](../s19_comprehensive/) → s20 → s21
[s15](../s15_agent_teams/) → `s16` → [s17](../s17_integrated_harness/) → s18 → s19
> *"外接工具, 标准协议"* — 发现、组装、调用Agent 不需要知道工具是谁写的。
>
@ -12,7 +12,7 @@ s01 → ... → s16 → s17 → `s18` → [s19](../s19_comprehensive/) → s20
## 问题
s01 到 s17Agent 的所有工具都是手写的,包括 bash、read、write、task 和 worktree。每个工具的输入验证、执行逻辑、错误处理都是你一行行写的。
s01 到 s15Agent 的所有工具都是手写的,包括 bash、read、write、task 和 worktree。每个工具的输入验证、执行逻辑、错误处理都是你一行行写的。
现在你有 3 个外部服务想接入:公司的 Jira API查 issue、建 ticket、自建的部署系统触发 deploy、看日志、团队的 Notion 知识库(搜文档、建页面)。你不想为每个服务重写一套工具代码。
@ -33,7 +33,11 @@ MCPModel Context Protocol定义了 Agent 如何发现和调用外部工具
| assemble_tool_pool | 把内置工具和 MCP 工具组装成一个工具池 |
| mcp\_\_server\_\_tool 命名 | 避免不同 server 的工具名冲突 |
沿用 s17 的 worktree 隔离、自主认领和团队协议。本章新增 `connect_mcp` 工具,用于连接服务、发现工具并加入工具池。
本章建立在 s15 团队运行时之上,沿用 idle 阶段的原子任务认领、安全的 task-worktree 绑定和协调协议,也保留 cron 调度、后台 bash 生命周期,以及任务完成后自动唤醒 Lead 的通知。新增的 `connect_mcp` 工具用于连接服务、发现工具并加入工具池。
task-bound worktree 只会改变队友文件工具的默认工作目录,并不是安全沙箱。
模型可见的 `remove_worktree` 只接受 `name`,因此只能移除状态干净的 checkout。若确实要丢弃改动应由用户手动执行 Git或者由宿主在明确确认后调用底层的强制清理路径不能让模型自行选择。
本章注册进程内 server handler让工具发现和调用流程可以离线运行。每个 handler 都提供客户端需要的 `tools/list``tools/call` 两个操作。
@ -110,13 +114,13 @@ def assemble_tool_pool() -> tuple[list[dict], dict]:
return tools, handlers
```
前缀 `mcp__{server}__{tool}` 避免不同 server 的工具名冲突。名称经过 `normalize_mcp_name` 规范化
前缀 `mcp__{server}__{tool}` 用于分隔不同 server 的工具,名称再经过 `normalize_mcp_name` 规范化。不同原始名称仍可能得到同一个前缀,因此 `assemble_tool_pool()` 会拒绝冲突,而不是静默覆盖先注册的 handler
MCP 工具的 description 带 `(readOnly)``(destructive)` 标注,让只读操作和修改操作在工具元数据中直接可见。
### 无缓存工具池变了prompt 也变
s10-s17 的 agent_loop 用 prompt cache 避免重复序列化。s18 去掉了缓存:
s10-s15 的 agent loop 用 prompt cache 避免重复序列化。s16 去掉了缓存:
```python
def agent_loop(messages, context):
@ -132,13 +136,13 @@ def agent_loop(messages, context):
### MCP 工具只有 Lead 可用
`connect_mcp` 属于 Lead`assemble_tool_pool` 也服务于 Lead 的 agent loop。Teammate 保留任务、文件、消息和计划工具,由 Lead 调用外部服务,再把得到的工作分派下去
`connect_mcp` 属于 Lead`assemble_tool_pool` 也服务于 Lead 的 agent loop。Teammate 保留任务、文件、消息和计划工具Lead 调用外部服务后把工作放入共享任务板idle 队友再进行原子认领
---
## 相对 s17 的变更
## 相对 s15 的变更
| 组件 | 之前 (s17) | 之后 (s18) |
| 组件 | 之前 (s15) | 之后 (s16) |
|------|-----------|-----------|
| 工具来源 | 全部手写 builtin | 手写 + MCP 外部工具动态发现 |
| 工具池 | 固定 BUILTIN_TOOLS | assemble_tool_pool 动态组装 mcp\_\_ 前缀工具 |
@ -147,7 +151,8 @@ def agent_loop(messages, context):
| 命名空间 | — | mcp\_\_server\_\_tool 避免冲突 |
| 工具描述 | 无标注 | (readOnly)/(destructive) 标注 |
| prompt 缓存 | 有s10 起) | 去掉,因为工具池动态变化后缓存失效 |
| Lead 工具 | worktree 与团队工具 | + connect_mcp 和动态发现的 MCP 工具 |
| 已有运行时 | task、cron、后台 bash、团队与 worktree | 全部保留 |
| Lead 工具 | cron、后台、worktree 与团队工具 | + connect_mcp 和动态发现的 MCP 工具 |
| Teammate 工具 | 任务、文件、消息与计划工具 | 不变 |
| 扩展方式 | 写代码加工具 | 标准协议,任意语言实现 server |
@ -157,7 +162,7 @@ def agent_loop(messages, context):
```sh
cd learn-claude-code
python s18_mcp_plugin/code.py
python s16_mcp_plugin/code.py
```
试试这些 prompt
@ -172,11 +177,11 @@ python s18_mcp_plugin/code.py
## 接下来
现在 Agent 可以通过标准协议接入外部工具了。前 18 章逐个引入这些机制,让每个边界都能单独观察。
现在 Agent 可以通过标准协议接入外部工具了。前 16 章逐个引入这些机制,让每个边界都能单独观察。
工具、权限、hooks、todo、任务图、记忆、压缩、后台、cron、团队、worktree、MCP 这些机制应该挂在同一个循环上,而不是散在 19 个 demo 里。
工具、权限、hooks、todo、任务图、记忆、压缩、后台、cron、团队、worktree、MCP 这些机制应该挂在同一个循环上,而不是分散在不同示例里。
s19 Agent Harness 集成 → 把 s01-s18 的机制合回同一个 harness。机制很多循环一个。
[s17 Agent Harness 集成](../s17_integrated_harness/) → 把 s01-s16 的机制合回同一个 harness。机制很多循环一个。
<!-- translation-sync: zh@v2, en@v0, ja@v0 -->
<!-- translation-sync: zh@v4, en@v4, ja@v4 -->

File diff suppressed because it is too large Load diff

View file

@ -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">s15 Preserved</text>
<rect x="160" y="56" width="12" height="10" rx="2" fill="#fff1f2" stroke="#dc2626" stroke-width="1"/>
<text x="178" y="66" fill="#dc2626" font-size="10" font-weight="600">s18 New</text>
<text x="178" y="66" fill="#dc2626" font-size="10" font-weight="600">s16 New</text>
<!-- ===== Row 1: Lead Loop (s17 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,18 +49,18 @@
<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="72" 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 (Lead 18 tools)</text>
<text x="556" y="94" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCH (Lead 16 tools)</text>
<text x="394" y="109" fill="#2563eb" font-size="7.5">bash · read · write · task(4) · send · inbox</text>
<text x="394" y="121" fill="#7c3aed" font-size="7.5" font-weight="700">request_shutdown · request_plan · review_plan</text>
<text x="394" y="133" fill="#b45309" font-size="7.5" font-weight="700">create_worktree · remove_worktree · keep_worktree</text>
<text x="394" y="133" fill="#b45309" font-size="7.5" font-weight="700">create_worktree · remove_worktree</text>
<text x="394" y="145" fill="#dc2626" font-size="7.5" font-weight="700">★ connect_mcp + dynamic mcp__server__tool tools</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"/>
<!-- ===== Row 2: MCP Architecture (s18 new) ===== -->
<!-- ===== Row 2: MCP Architecture (s16 new) ===== -->
<rect x="30" y="172" width="700" height="215" rx="8" fill="#fff1f2" stroke="#dc2626" stroke-width="2"/>
<text x="380" y="194" fill="#991b1b" font-size="11" font-weight="700" text-anchor="middle">MCP Architecture (s18 new: standard protocol + external tools dynamic integration)</text>
<text x="380" y="194" fill="#991b1b" font-size="11" font-weight="700" text-anchor="middle">MCP Architecture (s16 new: standard protocol + dynamic external tools)</text>
<!-- Agent Side -->
<rect x="50" y="210" width="255" height="140" rx="6" fill="#fff" stroke="#dc2626" stroke-width="1.5"/>
@ -102,11 +102,11 @@
<!-- ===== Row 3: Bottom notes ===== -->
<rect x="30" y="400" width="700" height="22" rx="4" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<rect x="50" y="408" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="70" y="418" fill="#475569" font-size="10">s17: worktree + events + protocols (Lead 17)</text>
<text x="70" y="418" fill="#475569" font-size="10">s15: atomic claims + task worktrees + protocols</text>
<rect x="420" y="408" width="12" height="10" rx="2" fill="#fff1f2" stroke="#dc2626" stroke-width="1"/>
<text x="440" y="418" fill="#475569" font-size="10">s18: MCP + dynamic tools (Lead 18)</text>
<text x="440" y="418" fill="#475569" font-size="10">s16: MCP + dynamic tools (Lead 16)</text>
<!-- ===== Final note ===== -->
<rect x="30" y="430" width="700" height="22" rx="4" fill="#fff1f2" stroke="#dc2626" stroke-width="1"/>
<text x="380" y="444" fill="#991b1b" font-size="9" font-weight="600" text-anchor="middle">Next: s19 combines tools, permissions, teams, worktrees, MCP, and more into one while True loop.</text>
<text x="380" y="444" fill="#991b1b" font-size="9" font-weight="600" text-anchor="middle">Next: s17 combines tools, permissions, teams, worktrees, MCP, and more into one while True loop.</text>
</svg>

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Before After
Before After

View file

@ -23,9 +23,9 @@
<!-- 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 保持</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="#fff1f2" stroke="#dc2626" stroke-width="1"/>
<text x="148" y="66" fill="#dc2626" font-size="10" font-weight="600">s18 新規</text>
<text x="148" y="66" fill="#dc2626" 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,10 +49,10 @@
<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="72" 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 DISPATCHLead 18 tools</text>
<text x="556" y="94" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCHLead 16 tools</text>
<text x="394" y="109" fill="#2563eb" font-size="7.5">bash · read · write · task(4) · send · inbox</text>
<text x="394" y="121" fill="#7c3aed" font-size="7.5" font-weight="700">request_shutdown · request_plan · review_plan</text>
<text x="394" y="133" fill="#b45309" font-size="7.5" font-weight="700">create_worktree · remove_worktree · keep_worktree</text>
<text x="394" y="133" fill="#b45309" font-size="7.5" font-weight="700">create_worktree · remove_worktree</text>
<text x="394" y="145" fill="#dc2626" font-size="7.5" font-weight="700">★ connect_mcp + 動的 mcp__server__tool ツール</text>
<!-- Loop back -->
@ -60,7 +60,7 @@
<!-- ===== Row 2: MCP Architecture ===== -->
<rect x="30" y="172" width="700" height="215" rx="8" fill="#fff1f2" stroke="#dc2626" stroke-width="2"/>
<text x="380" y="194" fill="#991b1b" font-size="11" font-weight="700" text-anchor="middle">MCP アーキテクチャs18 新規:標準プロトコル + 外部ツール動的統合)</text>
<text x="380" y="194" fill="#991b1b" font-size="11" font-weight="700" text-anchor="middle">MCP アーキテクチャs16 新規:標準プロトコル + 外部ツール動的統合)</text>
<!-- Agent Side -->
<rect x="50" y="210" width="255" height="140" rx="6" fill="#fff" stroke="#dc2626" stroke-width="1.5"/>
@ -102,11 +102,11 @@
<!-- ===== Row 3: Bottom notes ===== -->
<rect x="30" y="400" width="700" height="22" rx="4" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<rect x="50" y="408" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="70" y="418" fill="#475569" font-size="10">s17: worktree + events + protocolsLead 17</text>
<text x="70" y="418" fill="#475569" font-size="10">s15: atomic claim + task worktree + protocols</text>
<rect x="420" y="408" width="12" height="10" rx="2" fill="#fff1f2" stroke="#dc2626" stroke-width="1"/>
<text x="440" y="418" fill="#475569" font-size="10">s18: MCP + dynamic toolsLead 18</text>
<text x="440" y="418" fill="#475569" font-size="10">s16: MCP + dynamic toolsLead 16</text>
<!-- ===== Final note ===== -->
<rect x="30" y="430" width="700" height="22" rx="4" fill="#fff1f2" stroke="#dc2626" stroke-width="1"/>
<text x="380" y="444" fill="#991b1b" font-size="9" font-weight="600" text-anchor="middle">次の s19tools、permissions、teams、worktree、MCP などを 1 つの while True ループに統合。</text>
<text x="380" y="444" fill="#991b1b" font-size="9" font-weight="600" text-anchor="middle">次の s17tools、permissions、teams、worktree、MCP などを 1 つの while True ループに統合。</text>
</svg>

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Before After
Before After

View file

@ -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 保留</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="#fff1f2" stroke="#dc2626" stroke-width="1"/>
<text x="158" y="66" fill="#dc2626" font-size="10" font-weight="600">s18 新增</text>
<text x="158" y="66" fill="#dc2626" font-size="10" font-weight="600">s16 新增</text>
<!-- ===== Row 1: Lead Loop (s17 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,18 +49,18 @@
<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="72" 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 (Lead 18 tools)</text>
<text x="556" y="94" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCH (Lead 16 tools)</text>
<text x="394" y="109" fill="#2563eb" font-size="7.5">bash · read · write · task(4) · send · inbox</text>
<text x="394" y="121" fill="#7c3aed" font-size="7.5" font-weight="700">request_shutdown · request_plan · review_plan</text>
<text x="394" y="133" fill="#b45309" font-size="7.5" font-weight="700">create_worktree · remove_worktree · keep_worktree</text>
<text x="394" y="133" fill="#b45309" font-size="7.5" font-weight="700">create_worktree · remove_worktree</text>
<text x="394" y="145" fill="#dc2626" font-size="7.5" font-weight="700">★ connect_mcp + 动态 mcp__server__tool 工具</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"/>
<!-- ===== Row 2: MCP Architecture (s18 new) ===== -->
<!-- ===== Row 2: MCP Architecture (s16 new) ===== -->
<rect x="30" y="172" width="700" height="215" rx="8" fill="#fff1f2" stroke="#dc2626" stroke-width="2"/>
<text x="380" y="194" fill="#991b1b" font-size="11" font-weight="700" text-anchor="middle">MCP 架构s18 新增:标准协议 + 外部工具动态接入)</text>
<text x="380" y="194" fill="#991b1b" font-size="11" font-weight="700" text-anchor="middle">MCP 架构s16 新增:标准协议 + 外部工具动态接入)</text>
<!-- Agent Side -->
<rect x="50" y="210" width="255" height="140" rx="6" fill="#fff" stroke="#dc2626" stroke-width="1.5"/>
@ -102,11 +102,11 @@
<!-- ===== Row 3: Bottom notes ===== -->
<rect x="30" y="400" width="700" height="22" rx="4" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<rect x="50" y="408" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="70" y="418" fill="#475569" font-size="10">s17: worktree + events + protocols (Lead 17)</text>
<text x="70" y="418" fill="#475569" font-size="10">s15: 原子认领 + 任务 worktree + 协议</text>
<rect x="420" y="408" width="12" height="10" rx="2" fill="#fff1f2" stroke="#dc2626" stroke-width="1"/>
<text x="440" y="418" fill="#475569" font-size="10">s18: MCP + dynamic tools (Lead 18)</text>
<text x="440" y="418" fill="#475569" font-size="10">s16: MCP + dynamic tools (Lead 16)</text>
<!-- ===== Final note ===== -->
<rect x="30" y="430" width="700" height="22" rx="4" fill="#fff1f2" stroke="#dc2626" stroke-width="1"/>
<text x="380" y="444" fill="#991b1b" font-size="9" font-weight="600" text-anchor="middle">下一章 s19把工具、权限、团队、worktree、MCP 等机制合回同一个 while True 循环。</text>
<text x="380" y="444" fill="#991b1b" font-size="9" font-weight="600" text-anchor="middle">下一章 s17把工具、权限、团队、worktree、MCP 等机制合回同一个 while True 循环。</text>
</svg>

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Before After
Before After

View file

@ -1,18 +1,18 @@
# s19: Integrated Harness — 多くの仕組みを 1 つのループへ
# s17: Integrated Harness — 多くの仕組みを 1 つのループへ
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s17 → s18 → `s19` → [s20](../s20_workflow_runtime/) → s21
s01 → ... → s15 → [s16](../s16_mcp_plugin/) → `s17` → [s18](../s18_workflow_runtime/) → s19
> *"仕組みは多い、ループは 1 つ"* — tools、permissions、memory、tasks、teams、plugins はすべて同じ `while True` に接続される。
>
> **Harness レイヤー**: 統合 — s01-s18 の仕組みを 1 つの実行可能なシステムへ戻す。
> **Harness レイヤー**: 統合 — s01-s16 の仕組みを 1 つの実行可能なシステムへ戻す。
---
## 問題
前 18 章では、各境界を観察できるように仕組みを一つずつ追加した。本章では、それらを一つのランタイムへ接続する。
前 16 章では、各境界を観察できるように仕組みを一つずつ追加した。本章では、それらを一つのランタイムへ接続する。
長時間動く coding agent には、同時に次のものが必要になる:
@ -23,10 +23,10 @@ s01 → ... → s17 → s18 → `s19` → [s20](../s20_workflow_runtime/) → s2
- compaction と error recovery
- background task と cron scheduling
- team、protocol、autonomous claiming
- worktree isolation
- task-bound worktree
- MCP external tool integration
難しいのは機能を積み上げることではない。それぞれの仕組みが loop のどこに接続されるかを見抜くことだ。S19 は統合チェックポイントであり、これまでの component を 1 つの harness に戻してから、s20-s21 が編成と目標完了を外側に追加する。
難しいのは機能を積み上げることではない。それぞれの仕組みが loop のどこに接続されるかを見抜くことだ。S17 は統合チェックポイントであり、これまでの component を 1 つの harness に戻してから、s18-s19 が編成と目標完了を外側に追加する。
---
@ -34,7 +34,7 @@ s01 → ... → s17 → s18 → `s19` → [s20](../s20_workflow_runtime/) → s2
![System Architecture](images/system-architecture.ja.svg)
S19 は新しい mechanism を追加せず、前章までの component を同じ harness に統合する:
S17 は新しい mechanism を追加せず、前章までの component を同じ harness に統合する:
```text
user input
@ -79,7 +79,7 @@ loop 自体は同じ構造のままだ。model を呼び、response に `tool_us
### Tools と Dispatch
built-in tool pool には 26 個の tool がある:
built-in tool pool には 25 個の tool がある:
```text
bash, read_file, write_file, edit_file, glob
@ -88,7 +88,7 @@ create_task, list_tasks, get_task, claim_task, complete_task
schedule_cron, list_crons, cancel_cron
spawn_teammate, send_message
request_shutdown, request_plan, review_plan
create_worktree, remove_worktree, keep_worktree
create_worktree, remove_worktree
connect_mcp
```
@ -112,11 +112,13 @@ if blocked:
continue
```
これにより permission、logging、audit が同じ hook point に接続できる。実行後には `PostToolUse` hook が走る。
これにより permission、logging、audit が同じ hook point に接続できる。Lead、one-shot subagent、teammate の tool はすべて先に `PreToolUse` を通り、許可された call は handler 実行後に `PostToolUse` を通る。
MCP tool では discovery metadata を確認し、`(readOnly)` と示された tool はそのまま実行する。mutating または分類されていない tool は先に user へ確認する。
### Plan と Task
S19 には 2 層の plan がある:
S17 には 2 層の plan がある:
- `todo_write`: current session 用の軽量 plan。メモリに保持。
- task graph: cross-session、dependency-aware、claimable な task file。`.tasks/task_*.json` に保存。
@ -127,10 +129,10 @@ S19 には 2 層の plan がある:
### Subagent と Team
S19 には 2 種類の delegation がある:
S17 には 2 種類の delegation がある:
- `task`: one-shot subagent。独立した `messages[]` を使い、中間 context を捨て、final summary だけ返す。
- `spawn_teammate`: persistent teammate thread。ランタイムが `MessageBus` event を自動配信し、teammate は idle 中に task board を確認して自律的に claim できる。
- `spawn_teammate`: persistent teammate thread。固定の tool round 上限なしで `WORK → result → IDLE` を続ける。model または dispatch の失敗は `error` を送り、thread cleanup は未完了 assignment を task board へ戻す。idle 中はまず `MessageBus` を待ち、timeout 後だけ ready task を scan して最大 1 件を atomic に claim する。
one-shot subagent は context isolation を解決する。persistent teammate は長期並列協作を解決する。
@ -170,40 +172,44 @@ should_run_background → start_background_task → placeholder tool_result
background done → task_notification → next round injects messages
```
cron scheduler は daemon thread として動き、1 秒ごとに確認する。CLI は `cron_queue` を監視し、発火した job を `[Scheduled] ...` として注入して Agent を 1 turn 自動実行する。
cron scheduler は daemon thread として動き、1 秒ごとに確認する。CLI は `cron_queue`、Lead inbox、完了済み background work を監視し、どの event からでも Agent を 1 turn 自動で起動する。
### Worktree と MCP
worktree isolation は directory を担当する:
s15 から継承した task-scoped worktree は working directory を管理する:
- `create_worktree(name, task_id)` が isolated branch と directory を作る
- task の `worktree` field が task と directory を紐付ける
- teammate が worktree 付き task を claim すると、bash/read/write はその directory で実行される
- pending かつ unowned の task は main workspace のままでもよく、`create_worktree(name, task_id)` で別々の branch と directory に紐付けることもできる
- 作成前に task、name、path、branch、Git registry を検証する。Git command が失敗した後も registry と branch state を照合し、部分的に作成された checkout は未紐付けのまま manual recovery 用に保持する
- idle teammate は ready task を 1 つ atomic に claim し、assignment は `task_id` と effective `cwd` の両方を保持する
- teammate のすべての file tool はその `cwd` を使い、task owner だけが task を complete して assignment を解除できる
- モデル向けの `remove_worktree(name)` tool は unfinished task の binding を拒否し、clean checkout だけを削除する。tracked、untracked、ignored file はすべて削除を止める。破壊的な削除は host の操作として別途 user confirmation を必要とする。成功後は binding を解除して branch を保持し、checkout 削除後の unbind 永続化が失敗した場合は manual recovery 用の partial success を返す
worktree は tool の default working directory を変更して working copy を分離するだけで、sandbox ではない。
MCP は external capability を担当する:
- `connect_mcp(name)` が mock server に接続する
- `assemble_tool_pool()` が MCP tools を tool pool に組み立てる
- `assemble_tool_pool()` が MCP tools を tool pool に組み立て、正規化後の名前衝突を拒否す
- tool name は `mcp__server__tool` 形式に統一する
---
## s18 からの変化
## s16 からの変化
| Component | s18 | s19 |
| Component | s16 MCP | s17 Integrated Harness |
|-----------|-----|-----|
| tool pool | built-in + MCP | built-in + MCP、s01-s17 の tool を補完 |
| permission | s18 の対象外 | `PreToolUse` hook で実行 |
| hooks | s18 の対象外 | UserPromptSubmit / PreToolUse / PostToolUse / Stop |
| todo | s18 の対象外 | `todo_write` + reminder |
| skill | s18 の対象外 | system prompt の catalog + `load_skill` |
| compact | s18 の対象外 | LLM 前 compaction + `compact` tool + reactive compact |
| tool pool | built-in + MCP | built-in + MCP、s01-s15 の mechanism を補完 |
| permission | s16 の focus 外 | `PreToolUse` hook で実行 |
| hooks | s16 の focus 外 | UserPromptSubmit / PreToolUse / PostToolUse / Stop |
| todo | s16 の focus 外 | `todo_write` + reminder |
| skill | s16 の focus 外 | system prompt の catalog + `load_skill` |
| compact | s16 の focus 外 | LLM 前 compaction + `compact` tool + reactive compact |
| error recovery | simple try/except | retry / max_tokens / prompt too long |
| background | s18 の対象外 | slow-operation thread + task notification |
| cron | s18 の対象外 | daemon scheduler + durable jobs |
| multi-agent | 維持 | 維持。teammate は isolated directory 上の basic tools を使う |
| worktree | 維持 | 維持 |
| MCP | 新規 | final tool pool の一部として維持 |
| background | s16 の focus 外 | slow-operation thread + task notification |
| cron | s16 の focus 外 | daemon scheduler + durable jobs |
| multi-agent | s15 から継承 | atomic task ownership と task-scoped `cwd` を維持 |
| worktree | task の optional binding | safe create/remove semantics を維持 |
| MCP | 新規 | integrated tool pool の一部として維持 |
---
@ -211,7 +217,7 @@ MCP は external capability を担当する:
```sh
cd learn-claude-code
python s19_comprehensive/code.py
python s17_integrated_harness/code.py
```
試す prompt
@ -229,14 +235,15 @@ python s19_comprehensive/code.py
- 遅い operation が background placeholder を返すか
- cron が時刻到達時に自動で reminder を返すか
- teammate が plan を提出し、approval 前に停止するか
- plan approval 後、teammate が task を claim できるか
- worktree binding 後、teammate が対応 directory に切り替わるか
- idle teammate が ready task を 1 つだけ atomic に claim するか
- teammate のすべての file tool が claimed task の `cwd` へ切り替わるか
- task owner だけが complete して assignment を解除できるか
---
## 終わりは始まり
s01 から s19 まで、コードの能力は増えていく。しかし中心は変わらない:
s01 から s17 まで、コードの能力は増えていく。しかし中心は変わらない:
```python
while True:
@ -251,4 +258,6 @@ while True:
これは本コースの統合チェックポイントだ:仕組みは多い、ループは 1 つ。
次へ:[s20 Workflow Runtime](../s20_workflow_runtime/) — 編成の形が固定なら、多数の会話ターンではなく、決定的で再開可能なコードへ移す。
次へ:[s18 Workflow Runtime](../s18_workflow_runtime/) — 編成の形が固定なら、多数の会話ターンではなく、決定的で再開可能なコードへ移す。
<!-- translation-sync: zh@v3, en@v3, ja@v3 -->

View file

@ -1,18 +1,18 @@
# s19: Integrated Harness — Many Mechanisms, One Loop
# s17: Integrated Harness — Many Mechanisms, One Loop
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s17 → s18 → `s19` → [s20](../s20_workflow_runtime/) → s21
s01 → ... → s15 → [s16](../s16_mcp_plugin/) → `s17` → [s18](../s18_workflow_runtime/) → s19
> *"Many mechanisms, one loop"* — tools, permissions, memory, tasks, teams, and plugins all hang off the same `while True`.
>
> **Harness layer**: Integration — put the mechanisms from s01-s18 into one runnable system.
> **Harness layer**: Integration — put the mechanisms from s01-s16 into one runnable system.
---
## Problem
The first 18 chapters add one mechanism at a time so each boundary stays visible. This chapter connects them in one runtime.
The first 16 chapters add one mechanism at a time so each boundary stays visible. This chapter connects them in one runtime.
A long-running coding agent needs all of these at once:
@ -23,10 +23,10 @@ A long-running coding agent needs all of these at once:
- compaction and error recovery
- background tasks and cron scheduling
- teams, protocols, autonomous claiming
- worktree isolation
- task-bound worktrees
- MCP external tool integration
The hard part is not piling up features. The hard part is seeing where each mechanism belongs around the loop. S19 is the integration checkpoint: every earlier component is placed back into one harness before s20-s21 add orchestration and goal closure around it.
The hard part is not piling up features. The hard part is seeing where each mechanism belongs around the loop. S17 is the integration checkpoint: every earlier component is placed back into one harness before s18-s19 add orchestration and goal closure around it.
---
@ -34,7 +34,7 @@ The hard part is not piling up features. The hard part is seeing where each mech
![System Architecture](images/system-architecture.en.svg)
S19 does not introduce a new mechanism. It connects the components from the earlier chapters in one integrated harness:
S17 does not introduce a new mechanism. It connects the components from the earlier chapters in one integrated harness:
```text
user input
@ -79,7 +79,7 @@ The loop keeps the same structure: call the model, check whether the response co
### Tools and Dispatch
The built-in tool pool contains 26 tools:
The built-in tool pool contains 25 tools:
```text
bash, read_file, write_file, edit_file, glob
@ -88,7 +88,7 @@ create_task, list_tasks, get_task, claim_task, complete_task
schedule_cron, list_crons, cancel_cron
spawn_teammate, send_message
request_shutdown, request_plan, review_plan
create_worktree, remove_worktree, keep_worktree
create_worktree, remove_worktree
connect_mcp
```
@ -112,11 +112,13 @@ if blocked:
continue
```
That means permission, logging, and audit logic all attach to the same hook point. After execution, `PostToolUse` hooks run.
That means permission, logging, and audit logic all attach to the same hook point. Lead tools, one-shot subagent tools, and teammate tools all pass through `PreToolUse`; an allowed call then runs `PostToolUse` after its handler.
For MCP tools, the hook reads the discovered metadata: a tool marked `(readOnly)` can run directly, while a mutating or unclassified tool asks the user first.
### Planning and Tasks
S19 keeps two planning layers:
S17 keeps two planning layers:
- `todo_write`: lightweight plan for the current session, kept in memory
- task graph: cross-session, dependency-aware, claimable task files under `.tasks/task_*.json`
@ -127,10 +129,10 @@ They share an intent, not an implementation: `todo_write` replaces one session c
### Subagents and Teams
S19 has two kinds of delegation:
S17 has two kinds of delegation:
- `task`: one-shot subagent. It uses an isolated `messages[]`, discards intermediate context, and returns only a final summary.
- `spawn_teammate`: persistent teammate thread. The runtime delivers `MessageBus` events, and the teammate scans the task board while idle so it can claim work autonomously.
- `spawn_teammate`: persistent teammate thread. It follows `WORK → result → IDLE` without a fixed tool-round cap; model or dispatch failures emit an `error`, and thread cleanup releases an unfinished assignment back to the task board. While idle it waits for `MessageBus` delivery first, then scans ready tasks only after the wait times out and atomically claims at most one.
One-shot subagents solve context isolation. Persistent teammates solve long-running parallel collaboration.
@ -148,7 +150,7 @@ Skills only put their catalog into the system prompt. Full content is loaded on
### Compaction and Recovery
Before the LLM call, S19 runs the compaction pipeline:
Before the LLM call, S17 runs the compaction pipeline:
```text
tool_result_budget → snip_compact → micro_compact → compact_history
@ -170,40 +172,44 @@ should_run_background → start_background_task → placeholder tool_result
background done → task_notification → next round injects messages
```
The cron scheduler runs as a daemon thread and checks once per second. The CLI watches `cron_queue`; when a job fires, it injects `[Scheduled] ...` and runs one agent turn automatically.
The cron scheduler runs as a daemon thread and checks once per second. The CLI watches `cron_queue`, Lead's inbox, and completed background work; any of them can wake one automatic agent turn.
### Worktree and MCP
Worktree isolation owns directories:
The task-scoped worktree behavior inherited from s15 manages working directories:
- `create_worktree(name, task_id)` creates an isolated branch and directory
- the task `worktree` field binds a task to that directory
- when a teammate claims a task with a worktree, its bash/read/write tools run in that directory
- a pending, unowned task may remain in the main workspace or be bound by `create_worktree(name, task_id)` to a separate branch and directory
- creation prevalidates the task, name, path, branch, and Git registry; a failed Git command is reconciled against the registry and branch state, and any partial checkout remains unbound and preserved for manual recovery
- an idle teammate atomically claims one ready task; the assignment records both `task_id` and its effective `cwd`
- all teammate file tools use that `cwd`, and only the owning teammate can complete the task and clear the assignment
- the model-facing `remove_worktree(name)` tool refuses unfinished task bindings and removes only clean checkouts; tracked, untracked, and ignored files all block it. Destructive removal remains a host operation that requires separate user confirmation. Successful removal clears the binding and preserves the branch; a post-removal unbind failure is reported as partial success for manual recovery
The worktree changes tool default directories. It separates working copies; it is not a sandbox.
MCP owns external capability:
- `connect_mcp(name)` connects a mock server
- `assemble_tool_pool()` assembles MCP tools into the tool pool
- `assemble_tool_pool()` assembles MCP tools and rejects normalized name collisions
- tool names use `mcp__server__tool`
---
## Changes from s18
## Changes from s16
| Component | s18 | s19 |
| Component | s16 MCP | s17 Integrated Harness |
|-----------|-----|-----|
| tool pool | built-in + MCP | built-in + MCP, with s01-s17 tools restored |
| permission | outside s18's scope | runs inside `PreToolUse` hook |
| hooks | outside s18's scope | UserPromptSubmit / PreToolUse / PostToolUse / Stop |
| todo | outside s18's scope | `todo_write` + reminder |
| skill | outside s18's scope | catalog in system prompt + `load_skill` |
| compact | outside s18's scope | pre-LLM compaction + `compact` tool + reactive compact |
| tool pool | built-in + MCP | built-in + MCP, with s01-s15 mechanisms restored |
| permission | outside s16's focus | runs inside `PreToolUse` hook |
| hooks | outside s16's focus | UserPromptSubmit / PreToolUse / PostToolUse / Stop |
| todo | outside s16's focus | `todo_write` + reminder |
| skill | outside s16's focus | catalog in system prompt + `load_skill` |
| compact | outside s16's focus | pre-LLM compaction + `compact` tool + reactive compact |
| error recovery | simple try/except | retry / max_tokens / prompt too long |
| background | outside s18's scope | slow-operation thread + task notification |
| cron | outside s18's scope | daemon scheduler + durable jobs |
| multi-agent | kept | kept; teammates use basic tools in isolated directories |
| worktree | kept | kept |
| MCP | new | kept as part of the final tool pool |
| background | outside s16's focus | slow-operation thread + task notification |
| cron | outside s16's focus | daemon scheduler + durable jobs |
| multi-agent | inherited from s15 | preserved with atomic task ownership and task-scoped `cwd` |
| worktree | optional task binding | preserved with safe create/remove semantics |
| MCP | introduced | preserved as part of the integrated tool pool |
---
@ -211,14 +217,14 @@ MCP owns external capability:
```sh
cd learn-claude-code
python s19_comprehensive/code.py
python s17_integrated_harness/code.py
```
Try:
1. `Inspect this repository and tell me which Python files matter most.`
2. `Search the connected documentation for agent loop guidance.`
3. `Refactor the authentication module and login page in parallel in isolated worktrees. Show me each plan before editing.`
3. `Refactor the authentication module and login page in parallel in separate worktrees. Show me each plan before editing.`
4. `Remind me about the meeting in 3 minutes.`
5. `Install the dependencies in the background while you read README.md.`
@ -229,14 +235,15 @@ Watch for:
- whether slow operations return a background placeholder
- whether cron automatically reminds you when the time arrives
- whether teammates submit plans and pause before approval
- whether teammates can claim tasks after plan approval
- whether teammates switch to the bound worktree directory
- whether an idle teammate atomically claims only one ready task
- whether every teammate file tool switches to the claimed task's `cwd`
- whether only the task owner can complete it and clear the assignment
---
## The End Is the Beginning
From s01 to s19, the code gets more capable, but the core remains unchanged:
From s01 to s17, the code gets more capable, but the core remains unchanged:
```python
while True:
@ -251,4 +258,6 @@ A mature harness gets its complexity from coordination around the model. The mod
This is the course's integration checkpoint: many mechanisms, one loop.
Next: [s20 Workflow Runtime](../s20_workflow_runtime/) — when the orchestration shape is fixed, move it out of chat turns and into deterministic, resumable code.
Next: [s18 Workflow Runtime](../s18_workflow_runtime/) — when the orchestration shape is fixed, move it out of chat turns and into deterministic, resumable code.
<!-- translation-sync: zh@v3, en@v3, ja@v3 -->

View file

@ -1,18 +1,18 @@
# s19: Agent Harness 集成 — 多种机制,一个循环
# s17: Agent Harness 集成 — 多种机制,一个循环
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s17 → s18 → `s19` → [s20](../s20_workflow_runtime/) → s21
s01 → ... → s15 → [s16](../s16_mcp_plugin/) → `s17` → [s18](../s18_workflow_runtime/) → s19
> *"机制很多,循环一个"* — 工具、权限、记忆、任务、团队、插件都挂在同一个 while True 上。
>
> **Harness 层**: 集成 — 把 s01-s18 的机制放回同一个可运行系统。
> **Harness 层**: 集成 — 把 s01-s16 的机制放回同一个可运行系统。
---
## 问题
前 18 章每章只加一个机制,让每个边界都能单独观察。本章把它们接入同一个运行时。
前 16 章每章只加一个机制,让每个边界都能单独观察。本章把它们接入同一个运行时。
一个能长期工作的 coding agent 需要同时拥有:
@ -23,10 +23,10 @@ s01 → ... → s17 → s18 → `s19` → [s20](../s20_workflow_runtime/) → s2
- 压缩和错误恢复
- 后台任务和 cron 调度
- 团队、协议、自治认领
- worktree 隔离
- 任务绑定的 worktree
- MCP 外部工具接入
本章的难点在于看清楚每项功能挂在循环的哪个位置。S19 是集成检查点:先把此前组件归位,再由 s20-s21 在外层加入编排与目标闭环。
本章的难点在于看清楚每项功能挂在循环的哪个位置。S17 是集成检查点:先把此前组件归位,再由 s18-s19 在外层加入编排与目标闭环。
---
@ -34,7 +34,7 @@ s01 → ... → s17 → s18 → `s19` → [s20](../s20_workflow_runtime/) → s2
![System Architecture](images/system-architecture.svg)
S19 不再引入新机制,而是把前面各章的组件集成到同一个 harness
S17 不再引入新机制,而是把前面各章的组件集成到同一个 harness
```text
用户输入
@ -79,7 +79,7 @@ S19 不再引入新机制,而是把前面各章的组件集成到同一个 har
### 工具与分发
内置工具池包含 26 个工具:
内置工具池包含 25 个工具:
```text
bash, read_file, write_file, edit_file, glob
@ -88,7 +88,7 @@ create_task, list_tasks, get_task, claim_task, complete_task
schedule_cron, list_crons, cancel_cron
spawn_teammate, send_message
request_shutdown, request_plan, review_plan
create_worktree, remove_worktree, keep_worktree
create_worktree, remove_worktree
connect_mcp
```
@ -112,11 +112,13 @@ if blocked:
continue
```
这样 permission、log、审计都可以挂在同一个 hook 点上。执行后再触发 `PostToolUse`
这样 permission、log、审计都可以挂在同一个 hook 点上。Lead、一次性 subagent 和队友的工具都会先经过 `PreToolUse`;允许执行的调用会在 handler 返回后触发 `PostToolUse`
对于 MCP 工具hook 会读取发现阶段得到的元数据:标记为 `(readOnly)` 的工具可以直接运行,修改型或没有分类的工具则先询问用户。
### 计划与任务
S19 同时保留两层计划:
S17 同时保留两层计划:
- `todo_write`:当前会话内的轻量计划,保存在内存中
- task graph跨会话、可依赖、可认领的任务文件写入 `.tasks/task_*.json`
@ -127,10 +129,10 @@ S19 同时保留两层计划:
### 子 agent 与团队
S19 有两种 delegation
S17 有两种 delegation
- `task`:一次性 subagent。独立 `messages[]`,中间过程丢弃,只返回最终摘要。
- `spawn_teammate`:持久队友线程。运行时自动投递 MessageBus 事件,队友在 idle 时扫描任务板并自主认领
- `spawn_teammate`:持久队友线程。它按 `WORK → result → IDLE` 运行,不设固定的工具轮数上限;模型或分发失败会发出 `error`,线程清理会把未完成 assignment 释放回任务板。idle 时先等待 `MessageBus` 消息,只在超时后扫描就绪 task并以原子操作最多认领一个
一次性 subagent 解决“上下文隔离”;持久队友解决“长期并行协作”。
@ -170,40 +172,44 @@ should_run_background → start_background_task → placeholder tool_result
后台完成 → task_notification → 下一轮注入 messages
```
cron 调度器独立 daemon thread 每秒检查一次。CLI 会监听 `cron_queue`,命中后主动把 `[Scheduled] ...` 注入并运行一轮 Agent。
cron 调度器独立 daemon thread 每秒检查一次。CLI 同时监听 `cron_queue`、Lead 收件箱和已完成的后台任务,任一事件都能自动唤醒一轮 Agent。
### worktree 与 MCP
worktree 负责隔离目录:
从 s15 继承的任务级 worktree 机制负责管理任务工作目录:
- `create_worktree(name, task_id)` 创建独立分支和目录
- task 的 `worktree` 字段绑定目录
- 队友 claim 到带 worktree 的 task 后bash/read/write 自动在对应目录下执行
- pending 且未被认领的 task 可以留在主工作区,也可以通过 `create_worktree(name, task_id)` 绑定独立分支和目录
- 创建前会校验 task、名称、路径、分支和 Git registryGit 命令失败后还会核对 registry 和分支状态,任何部分创建的 checkout 都保持未绑定并保留供人工恢复
- idle 队友以原子操作认领一个就绪 taskassignment 同时记录 `task_id` 和有效 `cwd`
- 队友所有文件工具都使用该 `cwd`;只有 task owner 能完成任务并清空 assignment
- 模型可调用的 `remove_worktree(name)` 工具会拒绝绑定未完成 task 的目录,并且只移除干净 checkout已跟踪、未跟踪和已忽略文件都会阻止它。破坏性移除属于宿主操作需要另行取得用户确认。成功移除后会清除绑定并保留分支若 checkout 删除后的解绑持久化失败,则报告 partial success 供人工恢复
worktree 只改变工具的默认工作目录,用于分离 working copy并不是安全沙箱。
MCP 负责外部能力:
- `connect_mcp(name)` 连接 mock server
- `assemble_tool_pool()` 把 MCP 工具组装进工具池
- `assemble_tool_pool()` 把 MCP 工具组装进工具池,并拒绝规范化后的名称冲突
- 工具名统一为 `mcp__server__tool`
---
## 相对 s18 的变化
## 相对 s16 的变化
| 组件 | s18 | s19 |
| 组件 | s16 MCP | s17 Agent Harness 集成 |
|------|-----|-----|
| 工具池 | 内置 + MCP | 内置 + MCP补齐 s01-s17 的工具 |
| 权限 | 不在 s18 范围内 | `PreToolUse` hook 中执行 |
| hooks | 不在 s18 范围内 | UserPromptSubmit / PreToolUse / PostToolUse / Stop |
| todo | 不在 s18 范围内 | `todo_write` + reminder |
| skill | 不在 s18 范围内 | catalog in system prompt + `load_skill` |
| compact | 不在 s18 范围内 | LLM 前压缩 + `compact` 工具 + reactive compact |
| 工具池 | 内置 + MCP | 内置 + MCP补齐 s01-s15 的机制 |
| 权限 | 不在 s16 重点范围内 | `PreToolUse` hook 中执行 |
| hooks | 不在 s16 重点范围内 | UserPromptSubmit / PreToolUse / PostToolUse / Stop |
| todo | 不在 s16 重点范围内 | `todo_write` + reminder |
| skill | 不在 s16 重点范围内 | catalog in system prompt + `load_skill` |
| compact | 不在 s16 重点范围内 | LLM 前压缩 + `compact` 工具 + reactive compact |
| error recovery | 简化 try/except | retry / max_tokens / prompt too long |
| background | 不在 s18 范围内 | 慢操作后台线程 + task notification |
| cron | 不在 s18 范围内 | daemon scheduler + durable jobs |
| multi-agent | 保留 | 保留;队友使用隔离目录下的基础工具 |
| worktree | 保留 | 保留 |
| MCP | 新增 | 保留,作为最终工具池的一部分 |
| background | 不在 s16 重点范围内 | 慢操作后台线程 + task notification |
| cron | 不在 s16 重点范围内 | daemon scheduler + durable jobs |
| multi-agent | 从 s15 继承 | 保留原子 task ownership 和任务级 `cwd` |
| worktree | task 可选绑定 | 保留安全的创建和移除语义 |
| MCP | 新增 | 保留,作为集成工具池的一部分 |
---
@ -211,14 +217,14 @@ MCP 负责外部能力:
```sh
cd learn-claude-code
python s19_comprehensive/code.py
python s17_integrated_harness/code.py
```
可以试:
1. `检查这个仓库,告诉我哪些 Python 文件最重要。`
2. `从已连接的文档中查一下 agent loop 的相关说明。`
3. `请在隔离的 worktree 中并行重构认证模块和登录页,修改前先把各自的计划给我看。`
3. `请在独立的 worktree 中并行重构认证模块和登录页,修改前先把各自的计划给我看。`
4. `3 分钟后提醒我开会。`
5. `在后台安装依赖,同时继续阅读 README.md。`
@ -229,14 +235,15 @@ python s19_comprehensive/code.py
- 慢操作是否返回 background placeholder
- 到点是不是自动提醒开会
- 队友是否提交 plan并在 approval 前暂停
- plan 批准后,队友是否能认领任务
- worktree 绑定后,队友是否切到对应目录
- idle 队友是否只原子认领一个就绪 task
- 队友所有文件工具是否都切换到已认领 task 的 `cwd`
- 是否只有 task owner 能完成任务并清空 assignment
---
## 结束亦是开始
从 s01 到 s19,代码表面越来越复杂,但核心始终没变:
从 s01 到 s17,代码表面越来越复杂,但核心始终没变:
```python
while True:
@ -251,4 +258,6 @@ while True:
这是课程的集成检查点:机制很多,循环一个。
下一章:[s20 Workflow Runtime](../s20_workflow_runtime/) — 当编排形状固定时,把它从多轮对话移入确定性、可恢复的代码。
下一章:[s18 Workflow Runtime](../s18_workflow_runtime/) — 当编排形状固定时,把它从多轮对话移入确定性、可恢复的代码。
<!-- translation-sync: zh@v3, en@v3, ja@v3 -->

File diff suppressed because it is too large Load diff

View file

@ -20,7 +20,7 @@
<rect width="920" height="620" rx="8" fill="#fafbfc"/>
<rect x="0" y="0" width="920" height="46" rx="8" fill="url(#header)"/>
<rect x="0" y="38" width="920" height="8" fill="url(#header)"/>
<text x="460" y="29" text-anchor="middle" fill="#fff" font-size="17" font-weight="700">s19 Integrated Harness — Many Mechanisms, One Loop</text>
<text x="460" y="29" text-anchor="middle" fill="#fff" font-size="17" font-weight="700">s17 Integrated Harness — Many Mechanisms, One Loop</text>
<rect x="40" y="76" width="840" height="212" rx="8" fill="#eef2ff" stroke="#2563eb" stroke-width="1.8"/>
<text x="460" y="101" text-anchor="middle" fill="#1e3a8a" font-size="13" font-weight="700">Core Agent Loop</text>
<rect x="70" y="128" width="110" height="48" rx="7" fill="#fff" stroke="#2563eb" stroke-width="1.4"/>
@ -69,9 +69,9 @@
<rect x="700" y="318" width="180" height="104" rx="8" fill="#f0fdfa" stroke="#0d9488" stroke-width="1.4"/>
<text x="790" y="341" text-anchor="middle" fill="#0f766e" font-size="11" font-weight="700">Teams &amp; Plugins</text>
<text x="718" y="362" fill="#0f766e" font-size="9">s06 subagent</text>
<text x="718" y="377" fill="#0f766e" font-size="9">s15-s16 team protocols</text>
<text x="718" y="392" fill="#0f766e" font-size="9">s17 worktree isolation</text>
<text x="718" y="407" fill="#0f766e" font-size="9">s18 MCP tools</text>
<text x="718" y="377" fill="#0f766e" font-size="9">s15 teams + task protocols</text>
<text x="718" y="392" fill="#0f766e" font-size="9">s15 task-bound worktrees</text>
<text x="718" y="407" fill="#0f766e" font-size="9">s16 MCP tools</text>
<path d="M 790 318 L 790 188" fill="none" stroke="#0d9488" stroke-width="1.3" marker-end="url(#arrow-green)" stroke-dasharray="5,3"/>
<rect x="70" y="462" width="780" height="112" rx="8" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1.2"/>
<text x="460" y="487" text-anchor="middle" fill="#1e293b" font-size="12" font-weight="700">TOOL POOL: 27 builtins + dynamic mcp__server__tool</text>
@ -80,6 +80,6 @@
<text x="95" y="548" fill="#334155" font-size="9">durable work: task tools · cron tools</text>
<text x="510" y="512" fill="#334155" font-size="9">team: spawn_teammate · send_message · typed protocols</text>
<text x="510" y="530" fill="#334155" font-size="9">protocol: request_shutdown · request_plan · review_plan</text>
<text x="510" y="548" fill="#334155" font-size="9">isolation/plugin: worktree tools · connect_mcp</text>
<text x="510" y="548" fill="#334155" font-size="9">workdir/plugin: create/remove_worktree · connect_mcp</text>
<path d="M 850 518 L 890 518 L 890 152 L 850 152" fill="none" stroke="#475569" stroke-width="1.2" marker-end="url(#arrow)" stroke-dasharray="4,4"/>
</svg>

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Before After
Before After

View file

@ -20,7 +20,7 @@
<rect width="920" height="620" rx="8" fill="#fafbfc"/>
<rect x="0" y="0" width="920" height="46" rx="8" fill="url(#header)"/>
<rect x="0" y="38" width="920" height="8" fill="url(#header)"/>
<text x="460" y="29" text-anchor="middle" fill="#fff" font-size="17" font-weight="700">s19 Integrated Harness — 多くの仕組みを 1 つのループへ</text>
<text x="460" y="29" text-anchor="middle" fill="#fff" font-size="17" font-weight="700">s17 Integrated Harness — 多くの仕組みを 1 つのループへ</text>
<rect x="40" y="76" width="840" height="212" rx="8" fill="#eef2ff" stroke="#2563eb" stroke-width="1.8"/>
<text x="460" y="101" text-anchor="middle" fill="#1e3a8a" font-size="13" font-weight="700">Core Agent Loop</text>
<rect x="70" y="128" width="110" height="48" rx="7" fill="#fff" stroke="#2563eb" stroke-width="1.4"/>
@ -69,9 +69,9 @@
<rect x="700" y="318" width="180" height="104" rx="8" fill="#f0fdfa" stroke="#0d9488" stroke-width="1.4"/>
<text x="790" y="341" text-anchor="middle" fill="#0f766e" font-size="11" font-weight="700">Teams / Plugins</text>
<text x="718" y="362" fill="#0f766e" font-size="9">s06 subagent</text>
<text x="718" y="377" fill="#0f766e" font-size="9">s15-s16 team protocols</text>
<text x="718" y="392" fill="#0f766e" font-size="9">s17 worktree isolation</text>
<text x="718" y="407" fill="#0f766e" font-size="9">s18 MCP tools</text>
<text x="718" y="377" fill="#0f766e" font-size="9">s15 teams + task protocols</text>
<text x="718" y="392" fill="#0f766e" font-size="9">s15 task-bound worktrees</text>
<text x="718" y="407" fill="#0f766e" font-size="9">s16 MCP tools</text>
<path d="M 790 318 L 790 188" fill="none" stroke="#0d9488" stroke-width="1.3" marker-end="url(#arrow-green)" stroke-dasharray="5,3"/>
<rect x="70" y="462" width="780" height="112" rx="8" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1.2"/>
<text x="460" y="487" text-anchor="middle" fill="#1e293b" font-size="12" font-weight="700">TOOL POOL: 27 builtins + dynamic mcp__server__tool</text>
@ -80,6 +80,6 @@
<text x="95" y="548" fill="#334155" font-size="9">durable work: task tools · cron tools</text>
<text x="510" y="512" fill="#334155" font-size="9">team: spawn_teammate · send_message · typed protocols</text>
<text x="510" y="530" fill="#334155" font-size="9">protocol: request_shutdown · request_plan · review_plan</text>
<text x="510" y="548" fill="#334155" font-size="9">isolation/plugin: worktree tools · connect_mcp</text>
<text x="510" y="548" fill="#334155" font-size="9">workdir/plugin: create/remove_worktree · connect_mcp</text>
<path d="M 850 518 L 890 518 L 890 152 L 850 152" fill="none" stroke="#475569" stroke-width="1.2" marker-end="url(#arrow)" stroke-dasharray="4,4"/>
</svg>

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Before After
Before After

View file

@ -21,7 +21,7 @@
<rect width="920" height="620" rx="8" fill="#fafbfc"/>
<rect x="0" y="0" width="920" height="46" rx="8" fill="url(#header)"/>
<rect x="0" y="38" width="920" height="8" fill="url(#header)"/>
<text x="460" y="29" text-anchor="middle" fill="#fff" font-size="17" font-weight="700">s19 Agent Harness 集成 — 多种机制,一个循环</text>
<text x="460" y="29" text-anchor="middle" fill="#fff" font-size="17" font-weight="700">s17 Agent Harness 集成 — 多种机制,一个循环</text>
<!-- Main loop band -->
<rect x="40" y="76" width="840" height="212" rx="8" fill="#eef2ff" stroke="#2563eb" stroke-width="1.8"/>
@ -87,9 +87,9 @@
<rect x="700" y="318" width="180" height="104" rx="8" fill="#f0fdfa" stroke="#0d9488" stroke-width="1.4"/>
<text x="790" y="341" text-anchor="middle" fill="#0f766e" font-size="11" font-weight="700">团队与插件</text>
<text x="718" y="362" fill="#0f766e" font-size="9">s06 subagent</text>
<text x="718" y="377" fill="#0f766e" font-size="9">s15-s16 team protocols</text>
<text x="718" y="392" fill="#0f766e" font-size="9">s17 worktree isolation</text>
<text x="718" y="407" fill="#0f766e" font-size="9">s18 MCP tools</text>
<text x="718" y="377" fill="#0f766e" font-size="9">s15 teams + task protocols</text>
<text x="718" y="392" fill="#0f766e" font-size="9">s15 task-bound worktrees</text>
<text x="718" y="407" fill="#0f766e" font-size="9">s16 MCP tools</text>
<path d="M 790 318 L 790 188" fill="none" stroke="#0d9488" stroke-width="1.3" marker-end="url(#arrow-green)" stroke-dasharray="5,3"/>
<!-- Tool pool -->
@ -100,6 +100,6 @@
<text x="95" y="548" fill="#334155" font-size="9">durable work: create/list/get/claim/complete_task · schedule/list/cancel_cron</text>
<text x="510" y="512" fill="#334155" font-size="9">team: spawn_teammate · send_message · typed protocols</text>
<text x="510" y="530" fill="#334155" font-size="9">protocol: request_shutdown · request_plan · review_plan</text>
<text x="510" y="548" fill="#334155" font-size="9">isolation/plugin: create/remove/keep_worktree · connect_mcp</text>
<text x="510" y="548" fill="#334155" font-size="9">workdir/plugin: create/remove_worktree · connect_mcp</text>
<path d="M 850 518 L 890 518 L 890 152 L 850 152" fill="none" stroke="#475569" stroke-width="1.2" marker-end="url(#arrow)" stroke-dasharray="4,4"/>
</svg>

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Before After
Before After

View file

@ -1,172 +0,0 @@
# s17: Worktree Isolation — それぞれのディレクトリ、互いに干渉しない
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s15 → s16 → `s17` → [s18](../s18_mcp_plugin/) → s19 → s20 → s21
> *"それぞれのディレクトリ、互いに干渉しない"* — タスクは目標を管理、worktree はディレクトリを管理、ID で紐付け。
>
> **Harness 層**: 隔離 — 並列実行のディレクトリ分離。
---
## 課題
s16 では、Alice も Bob も同じディレクトリで作業。Alice のタスクは「認証モジュールのリファクタリング」、Bob のタスクは「UI ログインページのリファクタリング」。
Alice が `write_file("config.py", ...)` を呼び出し、Bob も `write_file("config.py", ...)` を呼び出す。両者が同じファイルを編集し、互いに上書き。クリーンなロールバックもできない——どの変更が誰のものか区別できない。
s15-s16 は「誰が何をするか」(タスクシステム)と「どう通信するか」(メッセージバス)を解決したが、「どこで作業するか」は未解決。
---
## ソリューション
![Worktree Overview](images/worktree-overview.ja.svg)
Git worktree を使うと、同じリポジトリ内に複数の独立した作業ディレクトリを作成でき、それぞれが独自のブランチを持つ。Alice は `.worktrees/auth-refactor/` で作業、Bob は `.worktrees/ui-login/` で作業——互いに干渉しない。
s16 の MessageBus、プロトコル、自動認領を引き継ぐ。本章では次を追加する
| 機能 | 目的 |
|------|------|
| create_worktree | タスク用の独立ディレクトリ + 独立ブランチを作成 |
| bind_task_to_worktree | タスクとディレクトリを紐付け(状態は変更しない) |
| remove_worktree / keep_worktree | 完了後のクリーンアップまたは保持 |
| validate_worktree_name | パストラバーサルと不正文字を拒否 |
---
## 仕組み
### 作成:タスク-Worktree 紐付け
```python
def create_worktree(name: str, task_id: str = "") -> str:
validate_worktree_name(name) # [A-Za-z0-9._-]{1,64} のみ許可
path = WORKTREES_DIR / name
ok, result = run_git(["worktree", "add", str(path), "-b", f"wt/{name}", "HEAD"])
if not ok:
return f"Git error: {result}"
if task_id:
bind_task_to_worktree(task_id, name)
log_event("create", name, task_id)
return f"Worktree '{name}' created at {path}"
def bind_task_to_worktree(task_id: str, worktree_name: str):
task = load_task(task_id)
task.worktree = worktree_name # worktree フィールドのみ書き込み
save_task(task) # 状態は pending のまま、チームメイトの claim を待つ
```
紐付けルール1 つのタスクに 1 つの worktree を紐付け。紐付けはタスクの状態を変更しない——タスクは `pending` のままで、チームメイトが認領した時に `in_progress` に進む。これにより Lead は事前にタスクと worktree を作成でき、チームメイトは idle 時に自然に worktree 紐付け済みタスクを認領する。
### チームメイトツールの cwd 切り替え
各チームメイトは、現在の worktree パスを記録する `wt_ctx` 辞書を持つ。worktree に紐付いたタスクを認領すると、ランタイムが `wt_ctx` を更新し、そのチームメイトの `bash``read_file``write_file` は対応する worktree ディレクトリで実行される:
```python
# チームメイトスレッド内部
wt_ctx = {"path": None}
def _run_claim_task(task_id):
result = claim_task(task_id, owner=name)
if "Claimed" in result:
task = load_task(task_id)
if task.worktree:
wt_ctx["path"] = str(WORKTREES_DIR / task.worktree)
return result
def _run_bash(command):
return run_bash(command, cwd=wt_ctx["path"]) # worktree で実行
```
### クリーンアップKeep または Remove
タスク完了後、2 つの選択肢:
```python
def remove_worktree(name: str, discard_changes: bool = False) -> str:
# 安全チェック:変更がある場合デフォルトで拒否
if not discard_changes:
files, commits = _count_worktree_changes(path)
if files > 0 or commits > 0:
return "未コミットの変更あり。discard_changes=true で強制削除、または keep_worktree で保持"
ok, _ = run_git(["worktree", "remove", str(path), "--force"])
if not ok:
return "削除失敗"
run_git(["branch", "-D", f"wt/{name}"])
log_event("remove", name)
def keep_worktree(name: str) -> str:
log_event("keep", name)
return f"Worktree '{name}' kept for review (branch: wt/{name})"
```
Keep = ブランチを保持し、手動 review 後にマージ。Remove = 未コミット変更がある場合デフォルトで拒否、`discard_changes=true` で確認が必要。タスクの自動 complete はしない——タスク完了はチームメイトの `complete_task` で明示的にトリガー。
### イベントログ:監査可能
各ライフサイクル操作はログに記録され、監査に利用:
```python
def log_event(event_type: str, worktree_name: str, task_id: str = ""):
event = {"type": event_type, "worktree": worktree_name,
"task_id": task_id, "ts": time.time()}
# .worktrees/events.jsonl に append
```
イベントタイプは `create``remove``keep`。ログは手動監査に使い、復元時は `git worktree list` から現在の worktree 一覧を再構築できる。
### run_git成功/失敗を返す
```python
def run_git(args: list[str]) -> tuple[bool, str]:
r = subprocess.run(["git"] + args, cwd=WORKDIR, ...)
return r.returncode == 0, output
```
`create_worktree``remove_worktree` は git コマンド成功後のみイベントログに書き込み、ログが実際の状態を反映することを保証。
---
## s16 からの変更
| コンポーネント | 変更前 (s16) | 変更後 (s17) |
|--------------|------------|------------|
| 作業ディレクトリ | 全 Agent が WORKDIR を共有 | 各タスクが git worktree に紐付け可能 |
| タスクデータ | id/subject/status/owner/blockedBy | + worktree フィールド |
| チームメイトツール cwd | 常に WORKDIR | worktree 紐付けタスク認領時に自動切り替え |
| 新規関数 | — | create_worktree, bind_task_to_worktree, remove_worktree, keep_worktree, validate_worktree_name |
| worktree 安全性 | なし | name 検証 + 変更ありの場合削除拒否 |
| イベントログ | なし | events.jsonl ライフサイクル監査 |
| Lead ツール | チーム・タスクツール | + create_worktree、remove_worktree、keep_worktree |
| チームメイトツール | タスク・ファイルツール | ツールは同じ。bash/read/write は認領した worktree の cwd を使う |
---
## 試してみる
```sh
cd learn-claude-code
python s17_worktree_isolation/code.py
```
以下のプロンプトを試してください:
`認証モジュールとログインページを並行してリファクタリングし、変更が互いに干渉しないようにしてください。`
観察ポイント2 つの worktree の `git status` 出力は異なるブランチを表示しているか?チームメイトが worktree 紐付けタスクを認領後、bash コマンドは worktree ディレクトリで実行されているか?`remove_worktree` は変更がある場合に拒否するか?紐付け後のタスク状態は `pending` のままか?
---
## 次の章
Agent チームが隔離されたワークスペースで自己組織化できるようになった。しかし Agent の能力はツールに制限される——bash、read、write、task...
もしユーザーが独自のツールを持っていたら?例えば社内 Jira API や独自デプロイシステム?
s18 MCP Plugin → Agent にプラグインシステムを追加。外部ツールが標準プロトコルで接続、Agent は誰が書いたか知る必要がない。
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->

View file

@ -1,172 +0,0 @@
# s17: Worktree Isolation — Separate Directories, No Conflicts
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
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.
>
> **Harness Layer**: Isolation — Parallel execution in separate directories.
---
## The Problem
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-s16 solved "who does what" (task system) and "how to communicate" (message bus), but not "where to work".
---
## The Solution
![Worktree Overview](images/worktree-overview.en.svg)
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 s16's MessageBus, protocols, and autonomous claiming. This chapter adds:
| Capability | Purpose |
|------------|---------|
| create_worktree | Create isolated directory + branch for a task |
| bind_task_to_worktree | Bind task and directory (no status change) |
| remove_worktree / keep_worktree | Cleanup or preserve after completion |
| validate_worktree_name | Reject path traversal and illegal characters |
---
## How It Works
### Creation: Task-Worktree Binding
```python
def create_worktree(name: str, task_id: str = "") -> str:
validate_worktree_name(name) # Only [A-Za-z0-9._-]{1,64}
path = WORKTREES_DIR / name
ok, result = run_git(["worktree", "add", str(path), "-b", f"wt/{name}", "HEAD"])
if not ok:
return f"Git error: {result}"
if task_id:
bind_task_to_worktree(task_id, name)
log_event("create", name, task_id)
return f"Worktree '{name}' created at {path}"
def bind_task_to_worktree(task_id: str, worktree_name: str):
task = load_task(task_id)
task.worktree = worktree_name # Write worktree field only
save_task(task) # Status stays pending, waits for teammate claim
```
Binding rule: one task binds to one worktree. Binding does NOT change task status — the task stays `pending`, and advances to `in_progress` only when a teammate claims it. This way Lead can pre-create tasks and worktrees, and teammates naturally claim worktree-bound tasks during idle.
### Teammate Tool Cwd Switching
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
wt_ctx = {"path": None}
def _run_claim_task(task_id):
result = claim_task(task_id, owner=name)
if "Claimed" in result:
task = load_task(task_id)
if task.worktree:
wt_ctx["path"] = str(WORKTREES_DIR / task.worktree)
return result
def _run_bash(command):
return run_bash(command, cwd=wt_ctx["path"]) # Execute in worktree
```
### Cleanup: Keep or Remove
After task completion, two choices:
```python
def remove_worktree(name: str, discard_changes: bool = False) -> str:
# Safety check: refuse by default if changes exist
if not discard_changes:
files, commits = _count_worktree_changes(path)
if files > 0 or commits > 0:
return "Has uncommitted changes. Use discard_changes=true to force, or keep_worktree"
ok, _ = run_git(["worktree", "remove", str(path), "--force"])
if not ok:
return "Remove failed"
run_git(["branch", "-D", f"wt/{name}"])
log_event("remove", name)
def keep_worktree(name: str) -> str:
log_event("keep", name)
return f"Worktree '{name}' kept for review (branch: wt/{name})"
```
Keep = preserve branch for manual review and merge. Remove = refuse by default if uncommitted changes; requires `discard_changes=true` to confirm. Does NOT auto-complete task — task completion is triggered explicitly by the teammate's `complete_task`.
### Event Log: Auditable
Each lifecycle operation writes to a log for auditing:
```python
def log_event(event_type: str, worktree_name: str, task_id: str = ""):
event = {"type": event_type, "worktree": worktree_name,
"task_id": task_id, "ts": time.time()}
# append to .worktrees/events.jsonl
```
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
```python
def run_git(args: list[str]) -> tuple[bool, str]:
r = subprocess.run(["git"] + args, cwd=WORKDIR, ...)
return r.returncode == 0, output
```
`create_worktree` and `remove_worktree` only write event logs after successful git commands, ensuring logs reflect actual state.
---
## Changes from s16
| 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 |
| Teammate tool cwd | Always WORKDIR | Auto-switches when claiming worktree-bound task |
| 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 | 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 |
---
## Try It
```sh
cd learn-claude-code
python s17_worktree_isolation/code.py
```
Try this prompt:
`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?
---
## What's Next
Agent teams can now self-organize in isolated workspaces. But Agent capabilities are limited to the tools we wrote — bash, read, write, task...
What if users already have their own tools? Like an internal Jira API, or a custom deployment system?
s18 MCP Plugin → Give Agent a plugin system. External tools connect via standard protocol; Agent doesn't need to know who wrote them.
<!-- translation-sync: zh@v1, en@v1, ja@v0 -->

View file

@ -1,172 +0,0 @@
# s17: Worktree Isolation — 各干各的,互不干扰
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s15 → s16 → `s17` → [s18](../s18_mcp_plugin/) → s19 → s20 → s21
> *"各干各的目录, 互不干扰"* — 任务管目标, worktree 管目录, 按 ID 绑定。
>
> **Harness 层**: 隔离 — 并行执行的目录隔离。
---
## 问题
s16 中Alice 和 Bob 都在同一个目录下工作。Alice 的任务是"重构认证模块"Bob 的任务是"重构 UI 登录页"。
Alice `write_file("config.py", ...)`。Bob 也 `write_file("config.py", ...)`。两个人改同一个文件,互相覆盖,而且无法干净地回滚,因为已经分不清每处改动来自谁。
s15-s16 解决了"谁干什么"(任务系统)和"怎么通信"(消息总线),但没解决"在哪干"。
---
## 解决方案
![Worktree Overview](images/worktree-overview.svg)
Git worktree 让你在同一仓库中创建多个独立的工作目录每个目录都有自己的分支。Alice 在 `.worktrees/auth-refactor/` 下工作Bob 在 `.worktrees/ui-login/` 下工作,两者互不干扰。
沿用 s16 的 MessageBus、协议和自治认领机制。本章新增
| 能力 | 作用 |
|------|------|
| create_worktree | 为任务创建独立目录 + 独立分支 |
| bind_task_to_worktree | 把任务和工作目录绑定(不改状态) |
| remove_worktree / keep_worktree | 完成后清理或保留 |
| validate_worktree_name | 拒绝路径穿越和非法字符 |
---
## 工作原理
### 创建:任务-Worktree 绑定
```python
def create_worktree(name: str, task_id: str = "") -> str:
validate_worktree_name(name) # 只允许 [A-Za-z0-9._-]{1,64}
path = WORKTREES_DIR / name
ok, result = run_git(["worktree", "add", str(path), "-b", f"wt/{name}", "HEAD"])
if not ok:
return f"Git error: {result}"
if task_id:
bind_task_to_worktree(task_id, name)
log_event("create", name, task_id)
return f"Worktree '{name}' created at {path}"
def bind_task_to_worktree(task_id: str, worktree_name: str):
task = load_task(task_id)
task.worktree = worktree_name # 只写 worktree 字段
save_task(task) # 状态保持 pending等队友 claim
```
绑定规则:一个任务绑定一个 worktree。绑定不会改变任务状态。任务仍是 `pending`,队友自动认领时才推进到 `in_progress`。这样 Lead 可以提前创建任务和 worktree队友 idle 时自然认领带 worktree 的任务。
### 队友工具的 cwd 切换
每个队友都有一个 `wt_ctx` 字典,用来记录当前 worktree 路径。队友认领绑定了 worktree 的任务后,运行时会更新 `wt_ctx`;该队友的 `bash``read_file``write_file` 随后都在对应的 worktree 目录下执行:
```python
# 队友线程内部
wt_ctx = {"path": None}
def _run_claim_task(task_id):
result = claim_task(task_id, owner=name)
if "Claimed" in result:
task = load_task(task_id)
if task.worktree:
wt_ctx["path"] = str(WORKTREES_DIR / task.worktree)
return result
def _run_bash(command):
return run_bash(command, cwd=wt_ctx["path"]) # 在 worktree 下执行
```
### 收尾Keep 还是 Remove
任务完成后,两个选择:
```python
def remove_worktree(name: str, discard_changes: bool = False) -> str:
# 安全检查:有改动时默认拒绝
if not discard_changes:
files, commits = _count_worktree_changes(path)
if files > 0 or commits > 0:
return "有未提交改动,使用 discard_changes=true 强制删除,或 keep_worktree 保留"
ok, _ = run_git(["worktree", "remove", str(path), "--force"])
if not ok:
return "删除失败"
run_git(["branch", "-D", f"wt/{name}"])
log_event("remove", name)
def keep_worktree(name: str) -> str:
log_event("keep", name)
return f"Worktree '{name}' kept for review (branch: wt/{name})"
```
Keep = 留着分支,等人工 review 后合并到主分支。Remove = 有改动时默认拒绝,需要 `discard_changes=true` 确认。系统不会自动 complete task任务完成由队友的 `complete_task` 显式触发。
### 事件流:可审计
每次生命周期操作写入日志,方便排查:
```python
def log_event(event_type: str, worktree_name: str, task_id: str = ""):
event = {"type": event_type, "worktree": worktree_name,
"task_id": task_id, "ts": time.time()}
# append to .worktrees/events.jsonl
```
事件类型包括 `create`(创建)、`remove`(删除)和 `keep`(保留)。日志用于人工排查;恢复流程可以通过 `git worktree list` 重建当前 worktree 集合。
### run_git返回成功/失败
```python
def run_git(args: list[str]) -> tuple[bool, str]:
r = subprocess.run(["git"] + args, cwd=WORKDIR, ...)
return r.returncode == 0, output
```
`create_worktree``remove_worktree` 只在 git 命令成功后才写事件日志,保证日志反映真实状态。
---
## 相对 s16 的变更
| 组件 | 之前 (s16) | 之后 (s17) |
|------|-----------|-----------|
| 工作目录 | 所有 Agent 共享 WORKDIR | 每个任务可绑定独立 git worktree |
| Task 数据 | id/subject/status/owner/blockedBy | + worktree 字段 |
| 队友工具 cwd | 始终 WORKDIR | 认领带 worktree 的任务时自动切换 |
| 新函数 | — | create_worktree, bind_task_to_worktree, remove_worktree, keep_worktree, validate_worktree_name |
| worktree 安全 | 无 | name 校验 + 有改动时拒绝删除 |
| 事件日志 | 无 | events.jsonl 生命周期审计 |
| Lead 工具 | 团队与任务工具 | + create_worktree、remove_worktree、keep_worktree |
| 队友工具 | 任务与文件工具 | 工具不变bash/read/write 使用已认领任务的 worktree cwd |
---
## 试一下
```sh
cd learn-claude-code
python s17_worktree_isolation/code.py
```
试试这个 prompt
`请并行重构认证模块和登录页面,确保两部分改动不会互相干扰。`
观察重点:两个 worktree 的 `git status` 输出是否显示不同的分支?队友认领带 worktree 的任务后bash 命令是否在 worktree 目录下执行?`remove_worktree` 对有改动的 worktree 是否拒绝?`.tasks/` 中的任务在绑定后状态是否仍为 `pending`
---
## 接下来
Agent 团队能在隔离的工作空间中自组织了,但 Agent 的能力仅限于我们为它编写的 bash、read、write、task 等工具。
如果用户已经有了自己的工具怎么办?比如一个公司内部的 Jira API、一个自建的部署系统
s18 MCP Plugin → 给 Agent 装一个插件系统。外部工具通过标准协议接入Agent 不需要知道它们是谁写的。
<!-- translation-sync: zh@v1, en@v0, ja@v0 -->

File diff suppressed because it is too large Load diff

View file

@ -1,103 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 450" 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="#b45309"/>
</linearGradient>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
<marker id="arrow-amber" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#b45309"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
</defs>
<rect width="760" height="450" fill="#fafbfc" rx="8"/>
<!-- 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">Worktree Isolation — Git Worktree + Task-Directory Binding + Event Log</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>
<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">s17 New</text>
<!-- ===== 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>
<line x1="90" y1="110" x2="104" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="107" y="90" width="70" height="40" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="142" y="114" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">messages</text>
<line x1="177" y1="110" x2="191" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="194" y="86" width="80" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="234" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">prompt</text>
<line x1="274" y1="110" x2="288" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="291" y="86" width="70" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="326" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">LLM</text>
<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 (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>
<!-- 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 (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 (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"/>
<text x="380" y="231" fill="#78350f" font-size="10" font-weight="600" text-anchor="middle">Main repo (.tasks/ + .worktrees/ + .mailboxes/)</text>
<!-- Arrow: Main repo → Worktree 1 (Alice) -->
<line x1="310" y1="244" x2="178" y2="272" stroke="#b45309" stroke-width="1.5" marker-end="url(#arrow-amber)"/>
<text x="200" y="262" fill="#b45309" font-size="7" font-weight="600" transform="rotate(-12 200 262)">create + bind</text>
<!-- Arrow: Main repo → Worktree 2 (Bob) -->
<line x1="450" y1="244" x2="582" y2="272" stroke="#b45309" stroke-width="1.5" marker-end="url(#arrow-amber)"/>
<text x="530" y="252" fill="#b45309" font-size="7" font-weight="600" transform="rotate(12 530 252)">create + bind</text>
<!-- Worktree 1: Alice -->
<rect x="50" y="275" width="255" height="78" rx="6" fill="#fff" stroke="#16a34a" stroke-width="1.5"/>
<text x="177" y="294" fill="#166534" font-size="10" font-weight="700" text-anchor="middle">Alice: .worktrees/auth/</text>
<text x="65" y="310" fill="#374151" font-size="8">branch: wt/auth-refactor</text>
<text x="65" y="324" fill="#374151" font-size="8">Task: Refactor auth module</text>
<text x="65" y="344" fill="#16a34a" font-size="8" font-weight="600">✓ Isolated, no impact on Bob or main repo</text>
<!-- Worktree 2: Bob -->
<rect x="455" y="275" width="255" height="78" rx="6" fill="#fff" stroke="#16a34a" stroke-width="1.5"/>
<text x="582" y="294" fill="#166534" font-size="10" font-weight="700" text-anchor="middle">Bob: .worktrees/ui/</text>
<text x="470" y="310" fill="#374151" font-size="8">branch: wt/ui-login</text>
<text x="470" y="324" fill="#374151" font-size="8">Task: Refactor UI login page</text>
<text x="470" y="344" fill="#16a34a" font-size="8" font-weight="600">✓ Isolated, no impact on Alice or main repo</text>
<!-- Event log + Lifecycle -->
<rect x="50" y="362" width="310" height="20" rx="4" fill="#fef3c7" stroke="#d97706" stroke-width="1"/>
<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 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">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">s17: create_worktree + bind_task + remove/keep + events.jsonl</text>
</svg>

Before

Width:  |  Height:  |  Size: 7 KiB

View file

@ -1,103 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 450" 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="#b45309"/>
</linearGradient>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
<marker id="arrow-amber" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#b45309"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
</defs>
<rect width="760" height="450" fill="#fafbfc" rx="8"/>
<!-- 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">Worktree Isolation — Git Worktree + タスク・ディレクトリ紐付け + イベントログ</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>
<rect x="130" y="56" width="12" height="10" rx="2" fill="#fffbeb" stroke="#b45309" stroke-width="1"/>
<text x="148" y="66" fill="#b45309" font-size="10" font-weight="600">s17 新規</text>
<!-- ===== Row 1: Lead Loop ===== -->
<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>
<line x1="90" y1="110" x2="104" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="107" y="90" width="70" height="40" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="142" y="114" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">messages</text>
<line x1="177" y1="110" x2="191" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="194" y="86" width="80" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="234" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">prompt</text>
<line x1="274" y1="110" x2="288" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="291" y="86" width="70" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="326" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">LLM</text>
<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 DISPATCHs16 + 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>
<!-- 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 ===== -->
<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 隔離s17 新規:各タスクに独立ディレクトリ + 独立ブランチ)</text>
<!-- Main repo box -->
<rect x="230" y="208" width="300" height="36" rx="6" fill="#fff" stroke="#b45309" stroke-width="1.5"/>
<text x="380" y="231" fill="#78350f" font-size="10" font-weight="600" text-anchor="middle">メインリポジトリ(.tasks/ + .worktrees/ + .mailboxes/</text>
<!-- Arrow: Main repo → Worktree 1 (Alice) -->
<line x1="310" y1="244" x2="178" y2="272" stroke="#b45309" stroke-width="1.5" marker-end="url(#arrow-amber)"/>
<text x="200" y="262" fill="#b45309" font-size="7" font-weight="600" transform="rotate(-12 200 262)">create + bind</text>
<!-- Arrow: Main repo → Worktree 2 (Bob) -->
<line x1="450" y1="244" x2="582" y2="272" stroke="#b45309" stroke-width="1.5" marker-end="url(#arrow-amber)"/>
<text x="530" y="252" fill="#b45309" font-size="7" font-weight="600" transform="rotate(12 530 252)">create + bind</text>
<!-- Worktree 1: Alice -->
<rect x="50" y="275" width="255" height="78" rx="6" fill="#fff" stroke="#16a34a" stroke-width="1.5"/>
<text x="177" y="294" fill="#166534" font-size="10" font-weight="700" text-anchor="middle">Alice: .worktrees/auth/</text>
<text x="65" y="310" fill="#374151" font-size="8">branch: wt/auth-refactor</text>
<text x="65" y="324" fill="#374151" font-size="8">Task: 認証モジュールのリファクタリング</text>
<text x="65" y="344" fill="#16a34a" font-size="8" font-weight="600">✓ 隔離、Bob とメインリポジトリに影響なし</text>
<!-- Worktree 2: Bob -->
<rect x="455" y="275" width="255" height="78" rx="6" fill="#fff" stroke="#16a34a" stroke-width="1.5"/>
<text x="582" y="294" fill="#166534" font-size="10" font-weight="700" text-anchor="middle">Bob: .worktrees/ui/</text>
<text x="470" y="310" fill="#374151" font-size="8">branch: wt/ui-login</text>
<text x="470" y="324" fill="#374151" font-size="8">Task: UI ログインページのリファクタリング</text>
<text x="470" y="344" fill="#16a34a" font-size="8" font-weight="600">✓ 隔離、Alice とメインリポジトリに影響なし</text>
<!-- Event log + Lifecycle -->
<rect x="50" y="362" width="310" height="20" rx="4" fill="#fef3c7" stroke="#d97706" stroke-width="1"/>
<text x="205" y="376" fill="#92400e" font-size="8" text-anchor="middle">イベントログ: .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">片付け: keepreview 用に保持)/ removeworktree を削除)</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">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">s17: create_worktree + bind_task + remove/keep + events.jsonl</text>
</svg>

Before

Width:  |  Height:  |  Size: 7.2 KiB

View file

@ -1,103 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 450" 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="#b45309"/>
</linearGradient>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
<marker id="arrow-amber" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#b45309"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
</defs>
<rect width="760" height="450" fill="#fafbfc" rx="8"/>
<!-- 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">Worktree Isolation — Git Worktree + 任务-目录绑定 + 事件日志</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>
<rect x="140" y="56" width="12" height="10" rx="2" fill="#fffbeb" stroke="#b45309" stroke-width="1"/>
<text x="158" y="66" fill="#b45309" font-size="10" font-weight="600">s17 新增</text>
<!-- ===== 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>
<line x1="90" y1="110" x2="104" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="107" y="90" width="70" height="40" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="142" y="114" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">messages</text>
<line x1="177" y1="110" x2="191" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="194" y="86" width="80" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="234" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">prompt</text>
<line x1="274" y1="110" x2="288" y2="110" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="291" y="86" width="70" height="48" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="326" y="114" fill="#1e3a5f" font-size="9" font-weight="600" text-anchor="middle">LLM</text>
<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 (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>
<!-- 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 (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 隔离s17 新增:每个任务独立目录 + 独立分支)</text>
<!-- Main repo box -->
<rect x="230" y="208" width="300" height="36" rx="6" fill="#fff" stroke="#b45309" stroke-width="1.5"/>
<text x="380" y="231" fill="#78350f" font-size="10" font-weight="600" text-anchor="middle">主仓库 (.tasks/ + .worktrees/ + .mailboxes/)</text>
<!-- Arrow: Main repo → Worktree 1 (Alice) -->
<line x1="310" y1="244" x2="178" y2="272" stroke="#b45309" stroke-width="1.5" marker-end="url(#arrow-amber)"/>
<text x="200" y="262" fill="#b45309" font-size="7" font-weight="600" transform="rotate(-12 200 262)">create + bind</text>
<!-- Arrow: Main repo → Worktree 2 (Bob) -->
<line x1="450" y1="244" x2="582" y2="272" stroke="#b45309" stroke-width="1.5" marker-end="url(#arrow-amber)"/>
<text x="530" y="252" fill="#b45309" font-size="7" font-weight="600" transform="rotate(12 530 252)">create + bind</text>
<!-- Worktree 1: Alice -->
<rect x="50" y="275" width="255" height="78" rx="6" fill="#fff" stroke="#16a34a" stroke-width="1.5"/>
<text x="177" y="294" fill="#166534" font-size="10" font-weight="700" text-anchor="middle">Alice: .worktrees/auth/</text>
<text x="65" y="310" fill="#374151" font-size="8">branch: wt/auth-refactor</text>
<text x="65" y="324" fill="#374151" font-size="8">Task: 重构认证模块</text>
<text x="65" y="344" fill="#16a34a" font-size="8" font-weight="600">✓ 隔离,不影响 Bob 和主仓库</text>
<!-- Worktree 2: Bob -->
<rect x="455" y="275" width="255" height="78" rx="6" fill="#fff" stroke="#16a34a" stroke-width="1.5"/>
<text x="582" y="294" fill="#166534" font-size="10" font-weight="700" text-anchor="middle">Bob: .worktrees/ui/</text>
<text x="470" y="310" fill="#374151" font-size="8">branch: wt/ui-login</text>
<text x="470" y="324" fill="#374151" font-size="8">Task: 重构 UI 登录页</text>
<text x="470" y="344" fill="#16a34a" font-size="8" font-weight="600">✓ 隔离,不影响 Alice 和主仓库</text>
<!-- Event log + Lifecycle -->
<rect x="50" y="362" width="310" height="20" rx="4" fill="#fef3c7" stroke="#d97706" stroke-width="1"/>
<text x="205" y="376" fill="#92400e" font-size="8" text-anchor="middle">事件日志: .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">收尾: keep (保留分支 review) / remove (删除 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">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">s17: create_worktree + bind_task + remove/keep + events.jsonl</text>
</svg>

Before

Width:  |  Height:  |  Size: 7 KiB

View file

@ -1,18 +1,16 @@
# s20: Workflow Runtime — モデルが単一 step を決め、script が orchestration を決める
# s18: Workflow Runtime — モデルが単一 step を決め、script が orchestration を決める
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s18 → s19 → `s20` → [s21](../s21_goal_loop/)
s01 → ... → s16 → [s17](../s17_integrated_harness/) → `s18` → [s19](../s19_goal_loop/)
> *「1 回の tool_use で、バックグラウンドに一式の orchestration を走らせる」* — `Workflow` ツールが決定的で復元可能な script runtime を起動し、多数の subagent をまとめて送り出します。
> *「1 回の tool_use で、一式の orchestration を実行する」* — `Workflow` ツールが決定的で復元可能な script runtime を起動し、多数の subagent をまとめて送り出します。
>
> **Harness 層**: Orchestration — single-agent loop の上に、決定的な multi-agent script runtime を追加します。
`code.py` は demo を決定的に保つため、`async_launched` を出した後、同じ process で完了を待ちます。常駐 background service を用意しなくても、lifecycle と journal を確認できます。
---
s01 から s19 まで、loop は常にモデル駆動で 1 step ずつ進みました。各ラウンドでモデルが 1 つのツールを選び、結果を `messages[]` へ入れ、次のラウンドへ進みます。open-ended なタスクには最適です。次に何をするかを、モデルが context を見てその場で決められます。
s01 から s17 まで、loop は常にモデル駆動で 1 step ずつ進みました。各ラウンドでモデルが 1 つのツールを選び、結果を `messages[]` へ入れ、次のラウンドへ進みます。open-ended なタスクには最適です。次に何をするかを、モデルが context を見てその場で決められます。
しかし、複数の Agent を決定的に指揮したい仕事もあります。大きな変更の review を考えてください。10 の観点から並行して問題を探す → 各 finding へ別 Agent を送り adversarial verification を行う → 結果を集約して重複を除く → severity 順に並べる。この流れの形は固定されており、本当に必要なのは 3 つです。
@ -26,7 +24,7 @@ s01 から s19 まで、loop は常にモデル駆動で 1 step ずつ進みま
harness の tool pool に `Workflow` ツールを追加します。ユーザーまたはモデルが渡す script は、`agent() / parallel() / pipeline() / phase()` という少数の primitive を使い、orchestration を決定的なコードとして表します。
main loop から見えるのは 1 回の `tool_use` だけで、すぐ「バックグラウンドで起動済み」という結果を受け取ります。本当の実行は background runtime で進み、進捗をリアルタイムに報告し、全過程をディスク上の journal へ記録します。script の中間結果は変数に保存され、会話履歴の場所を取りません。`resume_from_run_id` で再開すると、変更されていない `agent()` は journal cache に当たり、以前の結果を直接使って checkpoint から続行します。
main loop から見えるのは 1 回の `tool_use` だけです。script の実行中、runtime は lifecycle event と progress event を出し、各 step をディスク上の journal へ記録します。script が終わると、この call は launch 情報、result、task state を返します。script の中間結果は変数に保存され、会話履歴の場所を取りません。`resume_from_run_id` で再開すると、変更されていない `agent()` は journal cache に当たり、以前の結果を直接使って checkpoint から続行します。
![Workflow Runtime Overview](images/workflow-runtime-overview.svg)
@ -41,11 +39,11 @@ async def sample_workflow(ctx, args):
return {"confirmed": confirmed}
```
## Workflow ツール: バックグラウンド起動、main loop には 1 回の call だけ
## Workflow ツール: 1 回の call で run 全体を実行する
`Workflow` は main Agent の tool pool にあります。ユーザーが保存済み workflow の実行を求めるか、タスクが既知の orchestration に一致したときにモデルがこのツールを選びます。どちらも 1 回の `Workflow(...)` tool call になります。
ツールは argument を parse し、meta 情報を検証し、permission check を通し、local workflow task を登録すると、すぐ「非同期で起動済み」と返します。main loop は block せず別の仕事を続け、workflow は background で実行されます。これは s13 の引換券 pattern を拡大したものです。先に引換券を渡し、結果ができたら通知します。
ツールは argument を parse し、meta 情報を検証し、permission check を通し、local workflow task を登録して、script の実行前に `async_launched` を出します。その後に progress event と最後の `task_notification` が続き、call は launch 情報、result、task state を返します。
```python
class WorkflowTool:
@ -54,8 +52,11 @@ class WorkflowTool:
check_permission(meta)
run_id = resume_from_run_id or create_run_id(meta)
task = LocalWorkflowTask(create_task_id(run_id), run_id, meta)
task.event("async_launched", runId=run_id, taskId=task.task_id) # すぐ return
... # 残りはバックグラウンドで進む
task.event("async_launched", runId=run_id, taskId=task.task_id)
...
result = await script_fn(ctx, args)
task.event("task_notification", status=task.status)
return {"launched": launched, "result": result, "task": task}
```
## Workflow metadata: 起動前に検証する
@ -124,11 +125,11 @@ if schema is not None:
raise WorkflowInputError(f"agent({{schema}}) の出力が不正です: {err}")
```
## Background task と progress event
## Task state と progress event
`LocalWorkflowTask` は status と token usage を管理し、SDK style の event stream を外へ出します。`task_started` → phase change、subagent start、log batch を含む一連の `task_progress` → 完了、失敗、停止に加え、output file、token 数、tool call 数、所要時間を含む最後の `task_notification` です。
`LocalWorkflowTask` は status と token usage を管理し、SDK style の event stream を外へ出します。`task_started` → phase change、subagent start、log を含む一連の `task_progress` → 完了または失敗に加え、output file、agent 数、token 数を含む最後の `task_notification` です。
main session は通常 event として処理し、最後の完了通知だけが main loop へ再び入ります。
demo はこれらの event を順番に表示し、最後の notification の後で task state を返します。
```python
class LocalWorkflowTask:
@ -139,7 +140,7 @@ class LocalWorkflowTask:
## 保存: Snapshot + journal で中断から再開する
runtime は各 run を `s20_workflow_runtime/.runtime/` に保存します。`<runId>.json` snapshot、`<runId>.output.json` output、`<runId>.journal.jsonl` journal です。snapshot と journal は安定した `runId` を共有し、resume 時に同じ run の状態と完了済み step を特定できるようにします。
runtime は各 run を `s18_workflow_runtime/.runtime/` に保存します。`<runId>.json` snapshot、`<runId>.output.json` output、`<runId>.journal.jsonl` journal です。snapshot と journal は安定した `runId` を共有し、resume 時に同じ run の状態と完了済み step を特定できるようにします。
journal は checkpoint resume の中心で、各 `agent()` の結果を 1 行ずつ記録します。
@ -199,30 +200,30 @@ async def sample_workflow(ctx, args):
...
```
## s19 からの変更点
## s17 からの変更点
| | s19 Integrated Harness | s20 Workflow Runtime |
| | s17 Integrated Harness | s18 Workflow Runtime |
|--|-----------|---------------------|
| loop | 1 つ、モデル駆動 | main loop は不変。その上に決定的 orchestration を追加 |
| 次の step を決めるもの | モデルが毎ラウンド判断 | script が orchestration flow を事前に定義 |
| multi-agent | s06 subagent を一度だけ派遣 | script 化された、再現可能で復元可能な一括 orchestration |
| 新しい仕組み | — | script DSL、background task、progress event、journal/resume、structured output、deterministic VM |
| 新しい仕組み | — | script DSL、task lifecycle、progress event、journal/resume、structured output、deterministic VM |
s20 は main loop を置き換えません。tool layer に `Workflow` を公開し、背後で local workflow runtime を起動します。1 つの workflow が N 個の Agent loop を決定的に駆動します。s06 の subagent はモデルがその場で 1 回派遣し、s20 は orchestration を replay 可能な script にします。
s18 は main loop を置き換えません。tool layer に `Workflow` を公開し、背後で local workflow runtime を起動します。1 つの workflow が N 個の Agent loop を決定的に駆動します。s06 の subagent はモデルがその場で 1 回派遣し、s18 は orchestration を replay 可能な script にします。
## 試してみる
```bash
python s20_workflow_runtime/code.py # review-changes を起動し、event stream を確認
python s20_workflow_runtime/code.py resume # 前回の runId から resume。すべての agent() が journal cache に当たる
python s18_workflow_runtime/code.py # review-changes を起動し、event stream を確認
python s18_workflow_runtime/code.py resume # 前回の runId から resume。すべての agent() が journal cache に当たる
```
1 回の起動から `async_launched`background の phase change と subagent progress、最後の `task_notification` までを観察してください。結果は task object に保存されます。resume 時はすべて cache hit するため `agents=0 tokens=0` と表示され、結果は前回と 1 byte も違いません。
1 回の起動から `async_launched`、phase change と subagent progress、最後の `task_notification` までを観察してください。結果は task object に保存されます。resume 時はすべて cache hit するため `agents=0 tokens=0` と表示され、結果は前回と 1 byte も違いません。
## 次へ
orchestration は Agent 能力の上にもう 1 層を加えます。main loop は個々の操作を管理し、script はチーム全体の flow を管理します。仕事が決定的で復元可能な script になると、モデルは「ラウンドごとの driver」から「script に schedule される実行 unit」へ変わります。同じ `agent()` を main loop でモデルがその場で呼ぶことも、workflow 内で script がまとめて編成することもできます。
次へ: [s21 Goal Loop](../s21_goal_loop/) — Orchestration は仕事を fan-out し、main loop から離れます。次章は逆に、1 つの goal が control を main loop へ引き戻し、objective が達成されるまで turn の終了を認めません。
次へ: [s19 Goal Loop](../s19_goal_loop/) — Orchestration は仕事を複数の agent へ fan-out します。次章は逆に、1 つの goal が control を main loop へ引き戻し、objective が達成されるまで turn の終了を認めません。
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->

View file

@ -1,18 +1,16 @@
# s20: Workflow Runtime — The Model Decides Each Step; a Script Decides the Orchestration
# s18: Workflow Runtime — The Model Decides Each Step; a Script Decides the Orchestration
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s18 → s19 → `s20` → [s21](../s21_goal_loop/)
s01 → ... → s16 → [s17](../s17_integrated_harness/) → `s18` → [s19](../s19_goal_loop/)
> *"One tool_use starts an entire orchestration in the background"* — The `Workflow` tool starts a deterministic, recoverable script runtime that dispatches many subagents in bulk.
> *"One tool_use runs an entire orchestration"* — The `Workflow` tool starts a deterministic, recoverable script runtime that dispatches many subagents in bulk.
>
> **Harness layer**: Orchestration — a deterministic multi-agent script runtime above the single-agent loop.
`code.py` keeps the demo deterministic: it emits `async_launched` and then awaits completion in one process. This demonstrates the lifecycle and journal without requiring a long-running background service.
---
From s01 through s19, our loop has always been model-driven and step-by-step: the model chooses one tool each round, its result enters `messages[]`, and another round begins. That is ideal for open-ended tasks because the model can inspect the current context and decide the next step on the spot.
From s01 through s17, our loop has always been model-driven and step-by-step: the model chooses one tool each round, its result enters `messages[]`, and another round begins. That is ideal for open-ended tasks because the model can inspect the current context and decide the next step on the spot.
Some jobs, however, require deterministic command of a group of agents. Consider reviewing a large change: inspect ten dimensions in parallel → send each finding to a separate agent for adversarial verification → combine and deduplicate the results → sort by severity. The shape is fixed, and you really need three properties:
@ -26,7 +24,7 @@ Making the model drive this process one round at a time in the main loop is slow
Add a `Workflow` tool to the harness tool pool. The user or model provides a script that expresses deterministic orchestration through a few simple primitives: `agent()`, `parallel()`, `pipeline()`, and `phase()`.
The main loop sees only one `tool_use` and immediately receives a "started in the background" result. Real execution continues inside the background runtime, which reports progress in real time and records every step in a journal on disk. Intermediate script results live in variables instead of taking space in conversation history. When restarted with `resume_from_run_id`, unchanged `agent()` calls hit the journal cache and reuse previous results, resuming from the checkpoint.
The main loop sees only one `tool_use`. As the script runs, the runtime emits lifecycle and progress events and records every step in a journal on disk. When the script finishes, the call returns the launch envelope, result, and task state. Intermediate script results live in variables instead of taking space in conversation history. When restarted with `resume_from_run_id`, unchanged `agent()` calls hit the journal cache and reuse previous results, resuming from the checkpoint.
![Workflow Runtime Overview](images/workflow-runtime-overview.svg)
@ -41,11 +39,11 @@ async def sample_workflow(ctx, args):
return {"confirmed": confirmed}
```
## The Workflow Tool: Start in the Background; the Main Loop Sees One Call
## The Workflow Tool: One Call, One Complete Run
`Workflow` lives in the main agent's tool pool. The user can request a saved workflow, or the model can select the tool when a task matches a known orchestration. In either case, the model emits one `Workflow(...)` tool call.
The tool parses the arguments, validates metadata, checks permissions, registers a local workflow task, and immediately returns "started asynchronously." The main loop does not block and can continue with other work while the workflow runs in the background. This is the claim-ticket pattern from s13 at a larger scale: hand over the ticket now, notify the user when the result is ready.
The tool parses the arguments, validates metadata, checks permissions, registers a local workflow task, and emits `async_launched` before running the script. Progress events follow, then the final `task_notification`; the call returns the launch envelope, result, and task state.
```python
class WorkflowTool:
@ -54,8 +52,11 @@ class WorkflowTool:
check_permission(meta)
run_id = resume_from_run_id or create_run_id(meta)
task = LocalWorkflowTask(create_task_id(run_id), run_id, meta)
task.event("async_launched", runId=run_id, taskId=task.task_id) # Return immediately
... # The rest proceeds in the background
task.event("async_launched", runId=run_id, taskId=task.task_id)
...
result = await script_fn(ctx, args)
task.event("task_notification", status=task.status)
return {"launched": launched, "result": result, "task": task}
```
## Workflow Metadata: Validate Before Launch
@ -124,11 +125,11 @@ if schema is not None:
raise WorkflowInputError(f"agent({{schema}}) returned invalid output: {err}")
```
## Background Tasks and Progress Events
## Task State and Progress Events
`LocalWorkflowTask` maintains status and token usage and emits an SDK-style event stream: `task_started` → a sequence of `task_progress` events containing phase changes, subagent starts, and log batches → one final `task_notification` reporting completion, failure, or stop, plus output files, token count, tool calls, and elapsed time.
`LocalWorkflowTask` maintains status and token usage and emits an SDK-style event stream: `task_started` → a sequence of `task_progress` events containing phase changes, subagent starts, and log batches → one final `task_notification` reporting completion or failure, plus the output file and agent and token counts.
The main session treats these as ordinary events. Only the final completion notification re-enters the main loop.
The demo prints these events in order and returns the task state after the final notification.
```python
class LocalWorkflowTask:
@ -139,7 +140,7 @@ class LocalWorkflowTask:
## Storage: Snapshot + Journal for Resuming after Interruptions
The runtime stores each run under `s20_workflow_runtime/.runtime/`: a `<runId>.json` snapshot, `<runId>.output.json` output, and `<runId>.journal.jsonl` journal. The snapshot and journal share a stable `runId`, so resume can locate one run's state and completed steps.
The runtime stores each run under `s18_workflow_runtime/.runtime/`: a `<runId>.json` snapshot, `<runId>.output.json` output, and `<runId>.journal.jsonl` journal. The snapshot and journal share a stable `runId`, so resume can locate one run's state and completed steps.
The journal is the core of checkpointed resume. It records every `agent()` result one line at a time:
@ -199,30 +200,30 @@ async def sample_workflow(ctx, args):
...
```
## Changes from s19
## Changes from s17
| | s19 Integrated Harness | s20 Workflow Runtime |
| | s17 Integrated Harness | s18 Workflow Runtime |
|--|-----------|---------------------|
| Loop | One model-driven loop | Main loop unchanged; deterministic orchestration added above it |
| Who decides the next step | Model decides each round | Script declares the orchestration in advance |
| Multiple agents | One-shot s06 subagents | Scripted, reproducible, recoverable bulk orchestration |
| New mechanisms | — | Script DSL, background tasks, progress events, journal/resume, structured output, deterministic VM |
| New mechanisms | — | Script DSL, task lifecycle, progress events, journal/resume, structured output, deterministic VM |
s20 does not replace the main loop. It exposes `Workflow` at the tool layer and starts a local workflow runtime behind it: one workflow deterministically drives N agent loops. An s06 subagent is dispatched once at the model's discretion; s20 turns orchestration into a replayable script.
s18 does not replace the main loop. It exposes `Workflow` at the tool layer and starts a local workflow runtime behind it: one workflow deterministically drives N agent loops. An s06 subagent is dispatched once at the model's discretion; s18 turns orchestration into a replayable script.
## Try It
```bash
python s20_workflow_runtime/code.py # Start review-changes and watch the event stream
python s20_workflow_runtime/code.py resume # Resume by the last runId; every agent() hits the journal cache
python s18_workflow_runtime/code.py # Start review-changes and watch the event stream
python s18_workflow_runtime/code.py resume # Resume by the last runId; every agent() hits the journal cache
```
Watch one launch produce `async_launched`, followed by background phase changes and subagent progress, then `task_notification`; the result is stored on the task object. A resumed run reports `agents=0 tokens=0` because every call hits the cache, and its result is byte-for-byte identical.
Watch one launch produce `async_launched`, followed by phase changes and subagent progress, then `task_notification`; the result is stored on the task object. A resumed run reports `agents=0 tokens=0` because every call hits the cache, and its result is byte-for-byte identical.
## Next
Orchestration adds a layer above agent capabilities: the main loop handles individual operations, while a script manages the whole team's flow. Once work becomes a deterministic, recoverable script, the model changes from the round-by-round driver into an execution unit scheduled by that script. The same `agent()` can be invoked ad hoc by the model in the main loop or orchestrated in bulk inside a workflow.
Next: [s21 Goal Loop](../s21_goal_loop/) — Orchestration fans work out and leaves the main loop. The next chapter moves in the opposite direction: a goal pulls control back into the main loop and refuses to let the turn end until the objective is achieved.
Next: [s19 Goal Loop](../s19_goal_loop/) — Orchestration fans work out across agents. The next chapter moves in the opposite direction: a goal pulls control back into the main loop and refuses to let the turn end until the objective is achieved.
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->

View file

@ -1,18 +1,16 @@
# s20: Workflow Runtime — 模型决定单步,脚本决定编排
# s18: Workflow Runtime — 模型决定单步,脚本决定编排
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s18 → s19 → `s20` → [s21](../s21_goal_loop/)
s01 → ... → s16 → [s17](../s17_integrated_harness/) → `s18` → [s19](../s19_goal_loop/)
> *"一次 tool_use后台跑完一整套编排"* — `Workflow` 工具启动一个确定、可恢复的脚本运行时,批量派出去一堆子 agent。
> *"一次 tool_use跑完一整套编排"* — `Workflow` 工具启动一个确定、可恢复的脚本运行时,批量派出去一堆子 agent。
>
> **Harness 层**: 编排 — 在单 agent 循环之上,加一层确定的多 agent 脚本运行时。
`code.py` 为了让演示保持确定,会先发出 `async_launched`,随后在同一进程里等待执行完成。这样不用启动常驻后台服务,也能看清生命周期和 journal。
---
从 s01 到 s19,我们的循环一直是模型驱动、一步一步来的:每一轮模型挑一个工具,结果塞回 `messages[]`,再来一轮。开放式任务这么干最合适,下一步做什么,让模型看着上下文临场决定就好。
从 s01 到 s17,我们的循环一直是模型驱动、一步一步来的:每一轮模型挑一个工具,结果塞回 `messages[]`,再来一轮。开放式任务这么干最合适,下一步做什么,让模型看着上下文临场决定就好。
但有些活,你需要的是确定地指挥一群 agent 干活。比如审一个大改动:十个维度并行找问题 → 每条发现各自派一个 agent 做对抗性验证 → 结果汇总去重 → 按严重度排序。这种流程的形状是固定的,你要的其实是三样东西:
@ -26,7 +24,7 @@ s01 → ... → s18 → s19 → `s20` → [s21](../s21_goal_loop/)
在 harness 的工具池里加入一个 `Workflow` 工具。用户或模型给它一段脚本,脚本用 `agent() / parallel() / pipeline() / phase()` 这几个简单的原语,把编排写成确定的代码。
主循环这边只看到一次 `tool_use`,立刻拿到"已在后台启动"的返回:真正的执行在后台运行时里推进,实时上报进度,所有过程都写到磁盘的 journal 文件里。脚本里的中间结果存在变量里,不会塞进对话历史占地方。下次用 `resume_from_run_id` 重启时,没改过的 `agent()` 直接命中 journal 缓存,直接用之前的结果,断点续跑。
主循环这边只看到一次 `tool_use`。脚本运行时runtime 会不断发出生命周期和进度事件,并把每一步写进磁盘上的 journal。脚本结束后这次调用返回启动信息、结果和任务状态。脚本里的中间结果存在变量里,不会塞进对话历史占地方。下次用 `resume_from_run_id` 重启时,没改过的 `agent()` 直接命中 journal 缓存,直接用之前的结果,断点续跑。
![Workflow Runtime 总览](images/workflow-runtime-overview.svg)
@ -41,11 +39,11 @@ async def sample_workflow(ctx, args):
return {"confirmed": confirmed}
```
## Workflow 工具:后台启动,主循环只看到一次调用
## Workflow 工具:一次调用,完成整次运行
`Workflow` 就在主 agent 的工具池里。用户可以要求运行一个保存好的 workflow模型也可以在任务匹配已知编排时选择这个工具两种情况最终都只发出一次 `Workflow(...)` 工具调用。
工具收到后会解析参数、校验 meta 信息、过权限检查、注册一个本地 workflow 任务,然后立刻返回"已异步启动"。主循环不阻塞该干嘛干嘛workflow 自己在后台跑。这其实就是 s13 后台任务那套"凭条模式"的放大版:先给你个取件条,结果好了再通知你
工具收到后会解析参数、校验 meta 信息、过权限检查、注册一个本地 workflow 任务,并在执行脚本前发出 `async_launched`。接下来依次发出进度事件和最终的 `task_notification`;调用返回启动信息、结果和任务状态
```python
class WorkflowTool:
@ -54,8 +52,11 @@ class WorkflowTool:
check_permission(meta)
run_id = resume_from_run_id or create_run_id(meta)
task = LocalWorkflowTask(create_task_id(run_id), run_id, meta)
task.event("async_launched", runId=run_id, taskId=task.task_id) # 立刻返回
... # 剩下的后台慢慢跑
task.event("async_launched", runId=run_id, taskId=task.task_id)
...
result = await script_fn(ctx, args)
task.event("task_notification", status=task.status)
return {"launched": launched, "result": result, "task": task}
```
## Workflow 元数据:启动前先校验
@ -124,11 +125,11 @@ if schema is not None:
raise WorkflowInputError(f"agent({{schema}}) 输出不合法: {err}")
```
## 后台任务和进度事件
## 任务状态和进度事件
`LocalWorkflowTask` 维护状态和 token 用量,向外发一条 SDK 风格的事件流:`task_started` → 一串 `task_progress`(包含阶段切换、子 agent 启动、日志输出这些批次)→ 最后一个 `task_notification`(完成/失败/停止带输出文件、token 数、工具调用数、耗时)。
`LocalWorkflowTask` 维护状态和 token 用量,向外发一条 SDK 风格的事件流:`task_started` → 一串 `task_progress`(包含阶段切换、子 agent 启动和日志输出)→ 最后一个 `task_notification`完成或失败带输出文件、agent 数和 token 数)。
主会话把这些当普通事件处理;只有最终的完成通知会重新进入主循环
演示会按顺序打印这些事件,并在最终通知后返回任务状态
```python
class LocalWorkflowTask:
@ -139,7 +140,7 @@ class LocalWorkflowTask:
## 存储:快照 + journal断了能续
运行时把每次运行的数据存在 `s20_workflow_runtime/.runtime/`:快照 `<runId>.json`、输出 `<runId>.output.json` 和 journal `<runId>.journal.jsonl`。快照与 journal 共享稳定的 `runId`,续跑时才能找到同一次运行的状态和已完成步骤。
运行时把每次运行的数据存在 `s18_workflow_runtime/.runtime/`:快照 `<runId>.json`、输出 `<runId>.output.json` 和 journal `<runId>.journal.jsonl`。快照与 journal 共享稳定的 `runId`,续跑时才能找到同一次运行的状态和已完成步骤。
journal 是断点续跑的核心,它一条一条记下来每个 `agent()` 的结果:
@ -199,30 +200,30 @@ async def sample_workflow(ctx, args):
...
```
## 相对 s19 的变更
## 相对 s17 的变更
| | s19 Agent Harness 集成 | s20 Workflow Runtime |
| | s17 Agent Harness 集成 | s18 Workflow Runtime |
|--|-----------|---------------------|
| 循环 | 单个、模型驱动 | 主循环不变;上面加一层确定的编排 |
| 谁决定下一步 | 模型逐轮决定 | 脚本预先写好编排流程 |
| 多 agent | s06 子 agent一次性派出去 | 脚本化、可复现、可恢复的批量编排 |
| 新增机制 | — | 脚本 DSL、后台任务、进度事件、journal/续跑、结构化输出、确定性 VM |
| 新增机制 | — | 脚本 DSL、任务生命周期、进度事件、journal/续跑、结构化输出、确定性 VM |
s20 不替换主循环,它只是在工具层暴露了 `Workflow`,背后启动一个本地 workflow 运行时:一个 workflow 确定地驱动 N 个 agent 循环。s06 的子 agent 是模型临场派一次s20 是把编排写成可以重放的脚本。
s18 不替换主循环,它只是在工具层暴露了 `Workflow`,背后启动一个本地 workflow 运行时:一个 workflow 确定地驱动 N 个 agent 循环。s06 的子 agent 是模型临场派一次s18 是把编排写成可以重放的脚本。
## 试一下
```bash
python s20_workflow_runtime/code.py # 启动 review-changes看事件流
python s20_workflow_runtime/code.py resume # 用上次的 runId 续跑,每个 agent() 都命中 journal 缓存
python s18_workflow_runtime/code.py # 启动 review-changes看事件流
python s18_workflow_runtime/code.py resume # 用上次的 runId 续跑,每个 agent() 都命中 journal 缓存
```
观察:一次启动 → `async_launched`后台阶段切换/子agent进度推进 → `task_notification`;结果存在任务对象上。续跑的时候会显示 `agents=0 tokens=0`(全部命中缓存),结果和上次一字不差。
观察:一次启动 → `async_launched` → 阶段切换/子agent进度推进 → `task_notification`;结果存在任务对象上。续跑的时候会显示 `agents=0 tokens=0`(全部命中缓存),结果和上次一字不差。
## 接下来
编排是在 agent 能力之上又加了一层:主循环管单步操作,脚本管整支队伍的流程。把工作写成确定、可恢复的脚本,模型就从"逐轮驱动者"变成了"被脚本调度的执行单元"。同一个 `agent()`,既能在主循环里被模型临场调用,也能在 workflow 里被脚本批量编排。
下一章:[s21 Goal Loop](../s21_goal_loop/) — 编排是把工作扇出去、脱离主循环;下一章反过来,一个目标把控制权重拉回主循环,没达成就不让这一轮结束。
下一章:[s19 Goal Loop](../s19_goal_loop/) — 编排把工作分派给多个 agent;下一章反过来,一个目标把控制权重拉回主循环,没达成就不让这一轮结束。
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->

View file

@ -1,22 +1,21 @@
"""
s20_workflow_runtime minimal dynamic Workflow runtime
s18_workflow_runtime minimal dynamic Workflow runtime
Idea:
s01-s19 build a single, model-driven agent loop. s20 adds a deterministic
s01-s17 build a single, model-driven agent loop. s18 adds a deterministic
orchestration LAYER on top: the main loop exposes a `Workflow` tool that
launches a background runtime; a script written with agent()/parallel()/
pipeline()/phase() drives many subagents deterministically, reports progress,
persists a journal, and can resume from a runId.
executes a script written with agent()/parallel()/pipeline()/phase(). One
call drives many subagents deterministically, reports progress, persists a
journal, and returns the result and task state. A runId can resume the work.
Run:
python code.py # run the sample workflow, print the event stream
python code.py resume # resume the last run; unchanged agent() calls hit cache
python s18_workflow_runtime/code.py
python s18_workflow_runtime/code.py resume
Implementation choices:
- MockAgentRunner is deterministic so resume behavior is reproducible.
- A workflow is a plain async Python function.
- The CLI emits `async_launched` and then awaits completion so event order is
deterministic.
- Lifecycle and progress events expose each run's state.
- Storage is a local .runtime/ directory beside this file.
"""
@ -261,7 +260,7 @@ class Budget:
# ============================================================
# Background task state + progress events (the outer event stream)
# Workflow task lifecycle + progress events
# ============================================================
class LocalWorkflowTask:
"""type local_workflow. Holds status/usage and emits the SDK-like event
@ -404,7 +403,8 @@ class ExecutionState:
class WorkflowTool:
"""The Workflow tool. .call() validates meta, runs the permission check,
creates runId/taskId, registers a LocalWorkflowTask, and emits lifecycle
events while the CLI awaits the final result. Supports resume."""
events while executing the script. It returns the result and task state and
supports resume."""
async def call(self, meta, script_fn, args=None, resume_from_run_id=None):
validate_meta(meta)

View file

@ -0,0 +1,115 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 640" font-family="system-ui, -apple-system, sans-serif" role="img" aria-labelledby="title description">
<title id="title">Workflow Runtime execution flow</title>
<desc id="description">One Workflow tool call executes a complete workflow run. Lifecycle and progress events remain inside the call, which returns one tool result containing launch metadata, the result, and task state.</desc>
<defs>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#22c55e"/>
</marker>
<marker id="arrow-gray" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#888888"/>
</marker>
</defs>
<rect width="960" height="640" rx="8" fill="#ffffff"/>
<text x="480" y="30" text-anchor="middle" fill="#1a1a1a" font-size="19" font-weight="700">Workflow Runtime — one Workflow call executes one complete run</text>
<text x="480" y="51" text-anchor="middle" fill="#888888" font-size="12">lifecycle and progress events are emitted during the call; one final tool_result returns to messages[]</text>
<!-- Main session loop -->
<rect x="20" y="68" width="920" height="132" rx="8" fill="#ffffff" stroke="#d0d0d0" stroke-width="1.5" stroke-dasharray="6,3"/>
<text x="40" y="88" fill="#1a1a1a" font-size="13" font-weight="700">Main session loop</text>
<path d="M 818 118 L 818 100 L 110 100 L 110 118" fill="none" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrow-green)"/>
<text x="464" y="95" text-anchor="middle" fill="#22c55e" font-size="10" font-weight="600">append one tool_result to messages[]</text>
<rect x="42" y="118" width="136" height="54" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5" stroke-dasharray="6,3"/>
<text x="110" y="141" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700" font-family="monospace">messages[]</text>
<text x="110" y="159" text-anchor="middle" fill="#888888" font-size="9">message history</text>
<line x1="178" y1="145" x2="218" y2="145" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<rect x="220" y="118" width="92" height="54" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
<text x="266" y="141" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700">LLM</text>
<text x="266" y="159" text-anchor="middle" fill="#888888" font-size="9">tool_use?</text>
<line x1="312" y1="145" x2="352" y2="145" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<rect x="354" y="118" width="220" height="54" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
<text x="464" y="140" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700" font-family="monospace">Workflow({script, args})</text>
<text x="464" y="159" text-anchor="middle" fill="#888888" font-size="8.5" font-family="monospace">resume_from_run_id?</text>
<rect x="715" y="110" width="205" height="70" rx="6" fill="#fafafa" stroke="#1a1a1a" stroke-width="1.5"/>
<text x="818" y="134" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700">tool_result</text>
<text x="818" y="153" text-anchor="middle" fill="#22c55e" font-size="9.5" font-weight="600" font-family="monospace">launched + result + task</text>
<text x="818" y="168" text-anchor="middle" fill="#888888" font-size="8.5">one return after the run</text>
<!-- Complete WorkflowTool.call lifecycle -->
<rect x="20" y="232" width="920" height="350" rx="8" fill="#ffffff" stroke="#d0d0d0" stroke-width="1.5" stroke-dasharray="6,3"/>
<text x="40" y="253" fill="#1a1a1a" font-size="13" font-weight="700">WorkflowTool.call — complete workflow task lifecycle</text>
<rect x="45" y="272" width="180" height="76" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
<text x="135" y="294" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700" font-family="monospace">WorkflowTool.call</text>
<text x="135" y="314" text-anchor="middle" fill="#888888" font-size="9">validate meta · permission</text>
<text x="135" y="331" text-anchor="middle" fill="#888888" font-size="9" font-family="monospace">runId · taskId · envelope</text>
<line x1="225" y1="310" x2="263" y2="310" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<rect x="265" y="272" width="180" height="76" rx="6" fill="#fafafa" stroke="#d0d0d0" stroke-width="1.5"/>
<text x="355" y="294" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700">Emit lifecycle</text>
<text x="355" y="314" text-anchor="middle" fill="#22c55e" font-size="9.5" font-weight="600" font-family="monospace">async_launched</text>
<text x="355" y="331" text-anchor="middle" fill="#888888" font-size="9.5" font-family="monospace">task_started</text>
<line x1="445" y1="310" x2="483" y2="310" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<rect x="485" y="272" width="180" height="76" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5" stroke-dasharray="6,3"/>
<text x="575" y="294" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700">Execute script</text>
<text x="575" y="314" text-anchor="middle" fill="#888888" font-size="9" font-family="monospace">phase · agent()</text>
<text x="575" y="331" text-anchor="middle" fill="#888888" font-size="9" font-family="monospace">parallel · pipeline</text>
<line x1="665" y1="310" x2="703" y2="310" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<rect x="705" y="272" width="210" height="76" rx="6" fill="#fafafa" stroke="#d0d0d0" stroke-width="1.5"/>
<text x="810" y="294" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700">Finalize task</text>
<text x="810" y="314" text-anchor="middle" fill="#888888" font-size="9">write output · save last run</text>
<text x="810" y="331" text-anchor="middle" fill="#22c55e" font-size="9.5" font-weight="600" font-family="monospace">task_notification</text>
<!-- Script execution details -->
<line x1="575" y1="348" x2="575" y2="388" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<text x="588" y="371" fill="#22c55e" font-size="9" font-weight="600">agent()</text>
<rect x="475" y="390" width="190" height="64" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
<text x="570" y="413" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700">Subagents × N</text>
<text x="570" y="432" text-anchor="middle" fill="#888888" font-size="9">schema validation · token budget</text>
<text x="570" y="447" text-anchor="middle" fill="#888888" font-size="8.5">parallel work, structured results</text>
<line x1="665" y1="422" x2="703" y2="422" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<text x="684" y="414" text-anchor="middle" fill="#22c55e" font-size="8.5" font-weight="600">record</text>
<rect x="705" y="390" width="190" height="64" rx="6" fill="#fafafa" stroke="#d0d0d0" stroke-width="1.5"/>
<text x="800" y="413" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700">Journal</text>
<text x="800" y="432" text-anchor="middle" fill="#888888" font-size="9" font-family="monospace">semantic key → result</text>
<text x="800" y="447" text-anchor="middle" fill="#888888" font-size="8.5">resume returns cached calls</text>
<path d="M 705 441 L 680 441 L 680 372 L 635 372 L 635 348" fill="none" stroke="#888888" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#arrow-gray)"/>
<text x="670" y="365" text-anchor="end" fill="#888888" font-size="8.5">cached</text>
<!-- Events remain within the call; the call returns once -->
<rect x="45" y="490" width="470" height="58" rx="6" fill="#fafafa" stroke="#d0d0d0" stroke-width="1.5" stroke-dasharray="6,3"/>
<text x="280" y="512" text-anchor="middle" fill="#1a1a1a" font-size="10.5" font-weight="700">Lifecycle + progress events emitted during the call</text>
<text x="280" y="532" text-anchor="middle" fill="#888888" font-size="8.8" font-family="monospace">async_launched · task_started · workflow_phase / agent / log · task_notification</text>
<path d="M 810 348 L 810 472 L 738 472 L 738 488" fill="none" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<rect x="560" y="490" width="355" height="58" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
<text x="738" y="513" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700" font-family="monospace">return { launched, result, task }</text>
<text x="738" y="532" text-anchor="middle" fill="#888888" font-size="9">after task_notification</text>
<!-- Cross-lane call and return -->
<path d="M 464 172 L 464 215 L 135 215 L 135 270" fill="none" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrow-green)"/>
<text x="250" y="209" text-anchor="middle" fill="#22c55e" font-size="9" font-weight="600">execute complete run</text>
<path d="M 915 519 L 934 519 L 934 211 L 818 211 L 818 180" fill="none" stroke="#22c55e" stroke-width="1.8" marker-end="url(#arrow-green)"/>
<text x="928" y="380" text-anchor="middle" fill="#22c55e" font-size="9" font-weight="600" transform="rotate(-90 928 380)">return once</text>
<text x="480" y="616" text-anchor="middle" fill="#888888" font-size="10">One return boundary: async_launched is a lifecycle event; launched + result + task return together.</text>
</svg>

After

Width:  |  Height:  |  Size: 9.4 KiB

View file

@ -1,8 +1,8 @@
# s21: Goal Loopモデルが停止を提案し、独立した evaluator が継続するかを決める
# s19: Goal Loopモデルが停止を提案し、独立した evaluator が継続するかを決める
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s19 → s20 → `s21`
s01 → ... → s17 → [s18](../s18_workflow_runtime/) → `s19`
> *「モデルが tool call をやめたのは、一つの turn を止めたいという意味にすぎない。goal 全体が完了したかは別の evaluator が判断する。」*
>
@ -106,7 +106,7 @@ tests/auth 以外の test file は変更しない
自動実行の turn 数を制限したい場合は、Goal の内部に固定 budget を隠さず、main loop の global turn limit を使います。
```bash
MAX_TURNS=20 python s21_goal_loop/code.py \
MAX_TURNS=20 python s19_goal_loop/code.py \
"/goal npm run typecheck が exit code 0 になるまで type error を修正する"
```
@ -205,7 +205,7 @@ GOAL_EVALUATOR_MODEL_ID=...
interactive session を開始します。
```bash
python s21_goal_loop/code.py
python s19_goal_loop/code.py
```
次に入力します。
@ -217,14 +217,14 @@ python s21_goal_loop/code.py
command line から直接 Goal を設定することもできます。
```bash
python s21_goal_loop/code.py "/goal python -m pytest が exit code 0 で終了する"
python s19_goal_loop/code.py "/goal python -m pytest が exit code 0 で終了する"
```
## s20 から何が変わったか
## s18 から何が変わったか
s20 は「複数の仕事をどう実行するか」を扱いました。どの step を並列化し、結果をどう検証し、中断後にどう resume するかを決めます。
s18 は「複数の仕事をどう実行するか」を扱いました。どの step を並列化し、結果をどう検証し、中断後にどう resume するかを決めます。
s21 は「task 全体が完了したか」を扱います。Workflow が正常に終了しても、user の最終要件をまだ満たしていないかもしれません。Workflow result が conversation に入ったあと、Goal evaluator が session を止めるか続けるかを決めます。
s19 は「task 全体が完了したか」を扱います。Workflow が正常に終了しても、user の最終要件をまだ満たしていないかもしれません。Workflow result が conversation に入ったあと、Goal evaluator が session を止めるか続けるかを決めます。
どちらも単独で利用できます。同じ host に接続すると、Workflow の completion message が conversation に入り、Goal Loop が task 全体を続けるか判断します。

View file

@ -1,8 +1,8 @@
# s21: Goal Loop: The Model Proposes a Stop; an Independent Evaluator Decides Whether to Continue
# s19: Goal Loop: The Model Proposes a Stop; an Independent Evaluator Decides Whether to Continue
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s19 → s20 → `s21`
s01 → ... → s17 → [s18](../s18_workflow_runtime/) → `s19`
> *"The model making no more tool calls means that one turn wants to stop. A separate evaluator decides whether the whole goal is complete."*
>
@ -106,7 +106,7 @@ without modifying test files outside tests/auth
If you need to bound unattended work, use the main loop's global turn limit instead of hiding a fixed budget inside Goal:
```bash
MAX_TURNS=20 python s21_goal_loop/code.py \
MAX_TURNS=20 python s19_goal_loop/code.py \
"/goal fix the type errors until npm run typecheck exits 0"
```
@ -205,7 +205,7 @@ GOAL_EVALUATOR_MODEL_ID=...
Start the interactive session:
```bash
python s21_goal_loop/code.py
python s19_goal_loop/code.py
```
Then enter:
@ -217,14 +217,14 @@ Then enter:
You can also set a Goal directly from the command line:
```bash
python s21_goal_loop/code.py "/goal python -m pytest exits with code 0"
python s19_goal_loop/code.py "/goal python -m pytest exits with code 0"
```
## What changed from s20
## What changed from s18
s20 answers how a batch of work should run: which steps are concurrent, how results are verified, and how an interrupted run resumes.
s18 answers how a batch of work should run: which steps are concurrent, how results are verified, and how an interrupted run resumes.
s21 answers whether the entire task is complete. A Workflow may finish successfully while the user's final requirements are still unmet. Once the Workflow result enters the conversation, the Goal evaluator decides whether the session should stop or continue.
s19 answers whether the entire task is complete. A Workflow may finish successfully while the user's final requirements are still unmet. Once the Workflow result enters the conversation, the Goal evaluator decides whether the session should stop or continue.
You can use either mechanism on its own. When one host connects them, the Workflow completion message enters the conversation and Goal Loop decides whether the overall task needs another turn.

View file

@ -1,8 +1,8 @@
# s21: Goal Loop模型提出停止独立判断器决定是否继续
# s19: Goal Loop模型提出停止独立判断器决定是否继续
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s19 → s20 → `s21`
s01 → ... → s17 → [s18](../s18_workflow_runtime/) → `s19`
> *“模型不再调用工具,只代表这一轮想停;目标是否完成,再交给一个独立判断器。”*
>
@ -106,7 +106,7 @@ Goal Loop 不是测试框架。真正的验证仍然由工具执行,它只负
如果想限制自动执行轮数,使用主循环的全局限制,而不是给 Goal 偷偷加一个固定预算:
```bash
MAX_TURNS=20 python s21_goal_loop/code.py \
MAX_TURNS=20 python s19_goal_loop/code.py \
"/goal 修复类型错误,直到 npm run typecheck 退出码为 0"
```
@ -205,7 +205,7 @@ GOAL_EVALUATOR_MODEL_ID=...
进入交互模式:
```bash
python s21_goal_loop/code.py
python s19_goal_loop/code.py
```
然后输入:
@ -217,14 +217,14 @@ python s21_goal_loop/code.py
也可以直接从命令行设置 Goal
```bash
python s21_goal_loop/code.py "/goal python -m pytest 退出码为 0"
python s19_goal_loop/code.py "/goal python -m pytest 退出码为 0"
```
## 相对 s20 的变化
## 相对 s18 的变化
s20 解决“一批工作怎样执行”:哪些步骤并行,结果怎样验证,失败后怎样恢复。
s18 解决“一批工作怎样执行”:哪些步骤并行,结果怎样验证,失败后怎样恢复。
s21 解决“整件事情是否已经完成”:即使 Workflow 已经结束结果也可能还没有满足用户的最终要求。Workflow 的结果回到对话后Goal 判断器再决定是结束还是继续工作。
s19 解决“整件事情是否已经完成”:即使 Workflow 已经结束结果也可能还没有满足用户的最终要求。Workflow 的结果回到对话后Goal 判断器再决定是结束还是继续工作。
两个机制可以单独使用。接到同一个宿主时Workflow 的完成通知进入会话Goal Loop 再决定整个任务是否还要继续。

View file

@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
s21: Goal Loop
s19: Goal Loop
The model not calling another tool means that one turn wants to stop. A goal
adds a session-scoped Stop hook: a separate evaluator reads the conversation,
@ -8,8 +8,8 @@ decides whether the completion condition holds, and sends unfinished work back
through the same agent loop.
Run:
python s21_goal_loop/code.py
python s21_goal_loop/code.py "/goal pytest tests exits with code 0"
python s19_goal_loop/code.py
python s19_goal_loop/code.py "/goal pytest tests exits with code 0"
The live path uses the Anthropic API for both the worker and the evaluator.
Test doubles belong in tests only.
@ -686,11 +686,11 @@ async def main(argv: list[str]) -> None:
print(f"\n[goal] {result.status}: {result.reason}")
return
print("s21: goal loop")
print("s19: goal loop")
print("Set a condition with /goal <condition>. Type q to quit.\n")
while True:
try:
query = input("s21 >> ")
query = input("s19 >> ")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in {"q", "quit", "exit"}:

View file

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Before After
Before After

View file

@ -1,120 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 580" font-family="system-ui, -apple-system, sans-serif">
<defs>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#22c55e"/>
</marker>
<marker id="arrow-gray" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#888888"/>
</marker>
</defs>
<!-- Background -->
<rect width="960" height="580" rx="8" fill="#ffffff"/>
<!-- Title -->
<text x="480" y="30" text-anchor="middle" fill="#1a1a1a" font-size="19" font-weight="700">Workflow Runtime — one tool_use launches a background orchestration</text>
<text x="480" y="50" text-anchor="middle" fill="#888888" font-size="12">the main loop calls Workflow like any tool; a deterministic runtime fans out subagents in the background and can resume</text>
<!-- ===== Lane 1: Main session loop (canonical agent loop) ===== -->
<rect x="20" y="66" width="920" height="160" rx="8" fill="#ffffff" stroke="#d0d0d0" stroke-width="1.5" stroke-dasharray="6,3"/>
<text x="40" y="86" fill="#1a1a1a" font-size="13" font-weight="700">Main session loop</text>
<!-- loop-back over the top: back into messages[] -->
<path d="M 828 130 L 828 102 L 99 102 L 99 130" fill="none" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrow-green)"/>
<text x="463" y="97" text-anchor="middle" fill="#22c55e" font-size="10" font-weight="600">append tool_result / notification -&gt; messages[] (loop continues)</text>
<circle cx="828" cy="130" r="3" fill="#22c55e"/>
<circle cx="99" cy="130" r="3" fill="#22c55e"/>
<!-- messages[] -->
<rect x="40" y="130" width="118" height="52" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5" stroke-dasharray="6,3"/>
<text x="99" y="152" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700" font-family="monospace">messages[]</text>
<text x="99" y="169" text-anchor="middle" fill="#888888" font-size="9">message history</text>
<line x1="158" y1="156" x2="176" y2="156" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<!-- LLM -->
<rect x="178" y="130" width="86" height="52" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
<text x="221" y="152" text-anchor="middle" fill="#1a1a1a" font-size="13" font-weight="700">LLM</text>
<text x="221" y="169" text-anchor="middle" fill="#888888" font-size="9">tool_use?</text>
<line x1="264" y1="156" x2="282" y2="156" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<!-- Workflow tool_use -->
<rect x="284" y="130" width="196" height="52" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
<text x="382" y="151" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700" font-family="monospace">Workflow({script, args})</text>
<text x="382" y="169" text-anchor="middle" fill="#888888" font-size="8.5" font-family="monospace">(or name | script) · resume_from_run_id</text>
<line x1="480" y1="156" x2="518" y2="156" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<!-- tool_result -->
<rect x="520" y="130" width="156" height="52" rx="6" fill="#fafafa" stroke="#d0d0d0" stroke-width="1.5"/>
<text x="598" y="151" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700">tool_result</text>
<text x="598" y="169" text-anchor="middle" fill="#888888" font-size="9" font-family="monospace">async_launched</text>
<!-- later (dashed gap) -->
<line x1="676" y1="156" x2="734" y2="156" stroke="#888888" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#arrow-gray)"/>
<text x="705" y="148" text-anchor="middle" fill="#888888" font-size="8">later</text>
<!-- task_notification -->
<rect x="738" y="130" width="180" height="52" rx="6" fill="#fafafa" stroke="#d0d0d0" stroke-width="1.5" stroke-dasharray="6,3"/>
<text x="828" y="151" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700">task_notification</text>
<text x="828" y="169" text-anchor="middle" fill="#888888" font-size="9">completed · final report</text>
<!-- ===== Lane 2: Background workflow runtime ===== -->
<rect x="20" y="270" width="920" height="244" rx="8" fill="#ffffff" stroke="#d0d0d0" stroke-width="1.5" stroke-dasharray="6,3"/>
<text x="40" y="290" fill="#1a1a1a" font-size="13" font-weight="700">Background workflow runtime — local_workflow</text>
<!-- spine: WorkflowTool.call -> LocalWorkflowTask -> Script VM -->
<rect x="44" y="316" width="158" height="66" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
<text x="123" y="338" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700" font-family="monospace">WorkflowTool.call</text>
<text x="123" y="355" text-anchor="middle" fill="#888888" font-size="9">validate meta · permission</text>
<text x="123" y="370" text-anchor="middle" fill="#888888" font-size="9" font-family="monospace">runId · taskId</text>
<line x1="202" y1="349" x2="220" y2="349" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<rect x="222" y="316" width="158" height="66" rx="6" fill="#fafafa" stroke="#d0d0d0" stroke-width="1.5"/>
<text x="301" y="338" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700">LocalWorkflowTask</text>
<text x="301" y="355" text-anchor="middle" fill="#888888" font-size="9">status · usage</text>
<text x="301" y="370" text-anchor="middle" fill="#888888" font-size="9">progress events</text>
<line x1="380" y1="349" x2="398" y2="349" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<rect x="400" y="316" width="200" height="66" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5" stroke-dasharray="6,3"/>
<text x="500" y="338" text-anchor="middle" fill="#1a1a1a" font-size="12" font-weight="700">Script VM — runs the script</text>
<text x="500" y="356" text-anchor="middle" fill="#888888" font-size="9.5" font-family="monospace">phase · agent()</text>
<text x="500" y="371" text-anchor="middle" fill="#888888" font-size="9.5" font-family="monospace">parallel · pipeline</text>
<!-- agent() cycle: VM -> Subagents -> Journal -> (resume) -> VM -->
<!-- Subagents (× N, fan-out: stacked) -->
<rect x="666" y="308" width="170" height="58" rx="6" fill="#f3f4f6" stroke="#d0d0d0" stroke-width="1.2"/>
<rect x="662" y="312" width="170" height="58" rx="6" fill="#ffffff" stroke="#1a1a1a" stroke-width="1.5"/>
<text x="747" y="334" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700">Subagents × N</text>
<text x="747" y="350" text-anchor="middle" fill="#888888" font-size="9">isolated ctx</text>
<text x="747" y="363" text-anchor="middle" fill="#888888" font-size="9">schema output</text>
<!-- Journal -->
<rect x="662" y="420" width="170" height="58" rx="6" fill="#fafafa" stroke="#d0d0d0" stroke-width="1.5"/>
<text x="747" y="442" text-anchor="middle" fill="#1a1a1a" font-size="11" font-weight="700">Journal</text>
<text x="747" y="458" text-anchor="middle" fill="#888888" font-size="9" font-family="monospace">started / result</text>
<text x="747" y="471" text-anchor="middle" fill="#888888" font-size="9" font-family="monospace">per agent()</text>
<!-- VM -> Subagents: agent() spawns -->
<line x1="600" y1="341" x2="660" y2="341" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<text x="630" y="333" text-anchor="middle" fill="#22c55e" font-size="9" font-weight="600">agent()</text>
<text x="630" y="356" text-anchor="middle" fill="#888888" font-size="8">spawns</text>
<!-- Subagents -> Journal: record -->
<line x1="747" y1="370" x2="747" y2="418" stroke="#22c55e" stroke-width="2" marker-end="url(#arrow-green)"/>
<text x="757" y="398" fill="#22c55e" font-size="9" font-weight="600">record</text>
<!-- Journal -> Script VM: resume cached -->
<path d="M 662 449 L 500 449 L 500 382" fill="none" stroke="#888888" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#arrow-gray)"/>
<text x="556" y="443" text-anchor="middle" fill="#888888" font-size="9" font-weight="600">resume_from_run_id -&gt; cached agent()</text>
<!-- ===== Cross arrows between lanes ===== -->
<!-- launch (outer Workflow tool_use -> inner runtime), left side -->
<path d="M 382 182 L 382 248 L 123 248 L 123 316" fill="none" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrow-green)"/>
<text x="152" y="242" fill="#22c55e" font-size="9" font-weight="600">launch (async)</text>
<!-- progress / notification (inner task -> outer notification), routed just below launch -->
<path d="M 301 316 L 301 256 L 828 256 L 828 182" fill="none" stroke="#888888" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#arrow-gray)"/>
<text x="600" y="250" text-anchor="middle" fill="#888888" font-size="9" font-weight="600">task_progress: workflow_phase · workflow_agent · workflow_log</text>
<!-- ===== Bottom note ===== -->
<text x="480" y="548" text-anchor="middle" fill="#888888" font-size="10">The runtime result stays on the task (scriptPath · transcripts · journal · output) — only the launch + final notification re-enter messages[].</text>
</svg>

Before

Width:  |  Height:  |  Size: 9.1 KiB

View file

@ -1,5 +1,6 @@
import importlib.util
import os
import subprocess
import sys
import tempfile
import threading
@ -7,15 +8,21 @@ import time
import types
import unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
LESSON = ROOT / "s15_agent_teams" / "code.py"
AUTONOMOUS_LESSON = ROOT / "s16_autonomous_agents" / "code.py"
DOWNSTREAM_LESSONS = (
ROOT / "s16_mcp_plugin" / "code.py",
ROOT / "s17_integrated_harness" / "code.py",
)
RUNTIME_LESSONS = (LESSON, *DOWNSTREAM_LESSONS)
def load_lesson(temp_cwd: Path, lesson_path: Path = LESSON):
fake_anthropic = types.ModuleType("anthropic")
fake_yaml = types.ModuleType("yaml")
class FakeAnthropic:
def __init__(self, *args, **kwargs):
@ -24,10 +31,13 @@ def load_lesson(temp_cwd: Path, lesson_path: Path = LESSON):
fake_dotenv = types.ModuleType("dotenv")
setattr(fake_anthropic, "Anthropic", FakeAnthropic)
setattr(fake_dotenv, "load_dotenv", lambda override=True: None)
setattr(fake_yaml, "safe_load", lambda value: {})
setattr(fake_yaml, "YAMLError", ValueError)
previous_modules = {
"anthropic": sys.modules.get("anthropic"),
"dotenv": sys.modules.get("dotenv"),
"yaml": sys.modules.get("yaml"),
}
previous_cwd = Path.cwd()
previous_model = os.environ.get("MODEL_ID")
@ -40,6 +50,7 @@ def load_lesson(temp_cwd: Path, lesson_path: Path = LESSON):
sys.modules["anthropic"] = fake_anthropic
sys.modules["dotenv"] = fake_dotenv
sys.modules["yaml"] = fake_yaml
sys.modules[name] = module
try:
os.chdir(temp_cwd)
@ -68,15 +79,76 @@ def wait_until(predicate, timeout=2.0):
return False
def init_git_repo(root: Path):
subprocess.run(
["git", "init", "-q", "-b", "main"], cwd=root, check=True
)
subprocess.run(
["git", "config", "user.email", "tests@example.com"],
cwd=root, check=True,
)
subprocess.run(
["git", "config", "user.name", "Runtime Tests"],
cwd=root, check=True,
)
(root / "tracked.txt").write_text("initial\n")
subprocess.run(["git", "add", "tracked.txt"], cwd=root, check=True)
subprocess.run(
["git", "commit", "-q", "-m", "initial"], cwd=root, check=True
)
class AgentTeamsRuntimeTests(unittest.TestCase):
def test_downstream_lessons_keep_the_merged_runtime_contract(self):
for lesson_path in DOWNSTREAM_LESSONS:
with self.subTest(lesson=lesson_path.parent.name):
source = lesson_path.read_text()
self.assertIn("worktree: str | None = None", source)
self.assertIn("teammate_assignments", source)
self.assertIn(
"def complete_task(task_id: str, owner: str = \"agent\")",
source,
)
self.assertIn(
"def create_worktree(name: str, task_id: str)", source
)
self.assertIn(
"def remove_worktree(name: str, "
"discard_changes: bool = False)",
source,
)
self.assertIn("def run_remove_worktree(name: str)", source)
self.assertNotIn("keep_worktree", source)
self.assertNotIn("@{push}", source)
self.assertNotRegex(
source, r'''branch["']\s*,\s*["']-[dD]'''
)
self.assertNotRegex(source, r"git\s+branch\s+-[dD]")
def test_inbox_delivery_is_runtime_owned(self):
with tempfile.TemporaryDirectory() as tmp:
lesson = load_lesson(Path(tmp))
tool_names = {tool["name"] for tool in lesson.TOOLS}
self.assertNotIn("check_inbox", tool_names)
self.assertIn("create_worktree", tool_names)
self.assertIn("remove_worktree", tool_names)
self.assertNotIn("keep_worktree", tool_names)
worktree_tools = {
tool["name"]: tool["input_schema"] for tool in lesson.TOOLS
if tool["name"] in {"create_worktree", "remove_worktree"}
}
for schema in worktree_tools.values():
self.assertFalse(schema["additionalProperties"])
self.assertEqual(schema["properties"]["name"]["maxLength"], 64)
self.assertIn("wait for the user's confirmation",
lesson.PROMPT_SECTIONS["teams"])
self.assertIn("creating a Task", lesson.PROMPT_SECTIONS["teams"])
self.assertIn("not a sandbox", lesson.PROMPT_SECTIONS["teams"])
self.assertNotIn(
"discard_changes",
worktree_tools["remove_worktree"]["properties"],
)
lesson.BUS.send("alice", "lead", "done", "result")
events = lesson.consume_lead_inbox()
@ -85,6 +157,76 @@ class AgentTeamsRuntimeTests(unittest.TestCase):
self.assertIn("[result] alice: done",
lesson.format_team_events(events))
def test_model_worktree_tool_never_exposes_destructive_discard(self):
for lesson_path in RUNTIME_LESSONS:
with self.subTest(lesson=lesson_path.parent.name):
with tempfile.TemporaryDirectory() as tmp:
lesson = load_lesson(Path(tmp), lesson_path)
tool_defs = getattr(lesson, "TOOLS", None)
if tool_defs is None:
tool_defs = lesson.BUILTIN_TOOLS
schema = next(
tool["input_schema"] for tool in tool_defs
if tool["name"] == "remove_worktree"
)
self.assertNotIn("discard_changes", schema["properties"])
self.assertEqual(list(schema["properties"]), ["name"])
with self.assertRaises(TypeError):
lesson.run_remove_worktree(
"example", discard_changes=True
)
def test_mcp_lesson_retains_s15_cron_and_background_tools(self):
required = {
"bash", "schedule_cron", "list_crons", "cancel_cron",
"spawn_teammate", "create_worktree", "remove_worktree",
}
for lesson_path in RUNTIME_LESSONS:
with self.subTest(lesson=lesson_path.parent.name):
with tempfile.TemporaryDirectory() as tmp:
lesson = load_lesson(Path(tmp), lesson_path)
tool_defs = getattr(lesson, "TOOLS", None)
if tool_defs is None:
tool_defs = lesson.BUILTIN_TOOLS
tool_names = {tool["name"] for tool in tool_defs}
bash_schema = next(
tool["input_schema"] for tool in tool_defs
if tool["name"] == "bash"
)
self.assertTrue(required.issubset(tool_names))
self.assertIn(
"run_in_background", bash_schema["properties"]
)
self.assertTrue(
lesson.should_run_background(
"bash", {"run_in_background": True}
)
)
self.assertTrue(callable(lesson.consume_cron_queue))
self.assertTrue(callable(lesson.collect_background_results))
def test_integrated_permission_uses_mcp_tool_metadata(self):
with tempfile.TemporaryDirectory() as tmp:
lesson = load_lesson(
Path(tmp), ROOT / "s17_integrated_harness" / "code.py"
)
lesson.connect_mcp("deploy")
status = types.SimpleNamespace(
name="mcp__deploy__status", input={"service": "web"}
)
trigger = types.SimpleNamespace(
name="mcp__deploy__trigger", input={"service": "web"}
)
self.assertIsNone(lesson.permission_hook(status))
with patch("builtins.input", return_value="no"):
self.assertEqual(
lesson.permission_hook(trigger),
"Permission denied by user",
)
def test_message_bus_rejects_unregistered_or_unsafe_recipients(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
@ -101,6 +243,46 @@ class AgentTeamsRuntimeTests(unittest.TestCase):
self.assertIn("not active", result)
self.assertFalse((lesson.MAILBOX_DIR / "ghost.jsonl").exists())
def test_reserved_teammate_names_do_not_shadow_runtime_identities(self):
for lesson_path in RUNTIME_LESSONS:
with self.subTest(lesson=lesson_path.parent.name):
with tempfile.TemporaryDirectory() as tmp:
lesson = load_lesson(Path(tmp), lesson_path)
for name in ("lead", "agent", "Lead", "Agent"):
rejected = lesson.spawn_teammate_thread(
name, "backend", "Inspect auth."
)
self.assertIn("reserved", rejected.lower())
self.assertNotIn(name, lesson.active_teammates)
lesson.BUS.send("alice", "lead", "still routable")
self.assertEqual(
lesson.BUS.read_inbox("lead")[0]["content"],
"still routable",
)
lesson.active_teammates["Alice"] = "idle"
duplicate = lesson.spawn_teammate_thread(
"alice", "backend", "Inspect auth."
)
self.assertIn("already exists", duplicate)
def test_public_task_tools_return_errors_for_bad_ids(self):
for lesson_path in RUNTIME_LESSONS:
with self.subTest(lesson=lesson_path.parent.name):
with tempfile.TemporaryDirectory() as tmp:
lesson = load_lesson(Path(tmp), lesson_path)
for task_id in ("../escape", "task_missing"):
for tool_name in (
"run_get_task",
"run_claim_task",
"run_complete_task",
):
with self.subTest(tool=tool_name, task_id=task_id):
result = getattr(lesson, tool_name)(task_id)
self.assertIn("Error:", result)
def test_plan_gate_blocks_mutating_tools_until_approval(self):
with tempfile.TemporaryDirectory() as tmp:
lesson = load_lesson(Path(tmp))
@ -123,6 +305,77 @@ class AgentTeamsRuntimeTests(unittest.TestCase):
self.assertEqual(allowed, "wrote")
self.assertEqual(len(calls), 1)
def test_s17_teammate_dispatch_runs_permission_and_post_hooks(self):
with tempfile.TemporaryDirectory() as tmp:
lesson = load_lesson(
Path(tmp), ROOT / "s17_integrated_harness" / "code.py"
)
block = types.SimpleNamespace(
name="write_file",
input={"path": "config.py", "content": "VALUE = 1"},
)
calls = []
handlers = {
"write_file": lambda **kwargs: calls.append(
("handler", kwargs)
) or "wrote"
}
lesson.plan_gates["alice"] = "approved"
lesson.HOOKS["PreToolUse"] = [
lambda seen: calls.append(("pre", seen.name)) or "denied"
]
lesson.HOOKS["PostToolUse"] = [
lambda seen, output: calls.append(
("post", seen.name, output)
)
]
denied = lesson._run_teammate_tool("alice", block, handlers)
self.assertEqual(denied, "denied")
self.assertEqual(calls, [("pre", "write_file")])
calls.clear()
lesson.HOOKS["PreToolUse"] = [
lambda seen: calls.append(("pre", seen.name))
]
allowed = lesson._run_teammate_tool("alice", block, handlers)
self.assertEqual(allowed, "wrote")
self.assertEqual(
calls,
[
("pre", "write_file"),
("handler", block.input),
("post", "write_file", "wrote"),
],
)
def test_normalized_mcp_tool_name_collisions_are_rejected(self):
for lesson_path in DOWNSTREAM_LESSONS:
with self.subTest(lesson=lesson_path.parent.name):
with tempfile.TemporaryDirectory() as tmp:
lesson = load_lesson(Path(tmp), lesson_path)
first = lesson.MCPClient("docs.one")
first.register(
[{"name": "get.version", "inputSchema": {}}],
{"get.version": lambda: "one"},
)
second = lesson.MCPClient("docs_one")
second.register(
[{"name": "get_version", "inputSchema": {}}],
{"get_version": lambda: "two"},
)
lesson.mcp_clients.clear()
lesson.mcp_clients.update({
"docs.one": first,
"docs_one": second,
})
with self.assertRaisesRegex(
ValueError, "collision.*mcp__docs_one__get_version"
):
lesson.assemble_tool_pool()
def test_plan_rejection_requires_a_new_submission(self):
with tempfile.TemporaryDirectory() as tmp:
lesson = load_lesson(Path(tmp))
@ -238,10 +491,20 @@ class AgentTeamsRuntimeTests(unittest.TestCase):
def test_teammate_emits_result_then_idle_and_shuts_down(self):
with tempfile.TemporaryDirectory() as tmp:
lesson = load_lesson(Path(tmp))
lesson.client.messages.create = lambda **kwargs: types.SimpleNamespace(
stop_reason="end_turn",
content=[types.SimpleNamespace(type="text", text="Task complete.")],
)
lesson.IDLE_SCAN_INTERVAL = 5.0
pending = lesson.create_task("Do not claim before mailbox delivery")
seen_tools = set()
def respond(**kwargs):
seen_tools.update(tool["name"] for tool in kwargs["tools"])
return types.SimpleNamespace(
stop_reason="end_turn",
content=[types.SimpleNamespace(
type="text", text="Task complete."
)],
)
lesson.client.messages.create = respond
lesson.spawn_teammate_thread("alice", "backend", "Inspect auth.")
lead_inbox = lesson.MAILBOX_DIR / "lead.jsonl"
@ -258,6 +521,14 @@ class AgentTeamsRuntimeTests(unittest.TestCase):
["result", "idle_notification"],
)
self.assertEqual(lesson.active_teammates["alice"], "idle")
self.assertTrue(
{"list_tasks", "claim_task", "complete_task"}
.issubset(seen_tools)
)
self.assertTrue(
{"create_worktree", "remove_worktree", "keep_worktree"}
.isdisjoint(seen_tools)
)
lesson.run_request_shutdown("alice")
self.assertTrue(
@ -269,12 +540,209 @@ class AgentTeamsRuntimeTests(unittest.TestCase):
self.assertEqual(
lesson.pending_requests[request_id].status, "approved"
)
self.assertEqual(lesson.load_task(pending.id).status, "pending")
def test_downstream_teammates_continue_past_ten_tool_rounds(self):
for lesson_path in DOWNSTREAM_LESSONS:
with self.subTest(lesson=lesson_path.parent.name):
with tempfile.TemporaryDirectory() as tmp:
lesson = load_lesson(Path(tmp), lesson_path)
lesson.IDLE_SCAN_INTERVAL = 5.0
calls = 0
def respond(**kwargs):
nonlocal calls
calls += 1
if calls <= 11:
return types.SimpleNamespace(
stop_reason="tool_use",
content=[types.SimpleNamespace(
type="tool_use",
name="list_tasks",
id=f"list-{calls}",
input={},
)],
)
return types.SimpleNamespace(
stop_reason="end_turn",
content=[types.SimpleNamespace(
type="text", text="Long task complete."
)],
)
lesson.client.messages.create = respond
lesson.spawn_teammate_thread(
"alice", "backend", "Use more than ten tool rounds."
)
lead_inbox = lesson.MAILBOX_DIR / "lead.jsonl"
self.assertTrue(wait_until(
lambda: (
lead_inbox.exists()
and len(lead_inbox.read_text().splitlines()) >= 2
),
timeout=3.0,
))
events = lesson.consume_lead_inbox()
self.assertEqual(calls, 12)
self.assertEqual(
[event["type"] for event in events],
["result", "idle_notification"],
)
self.assertEqual(
lesson.active_teammates.get("alice"), "idle"
)
lesson.run_request_shutdown("alice")
self.assertTrue(wait_until(
lambda: "alice" not in lesson.active_teammates
))
def test_teammate_exception_releases_runtime_and_task_ownership(self):
for lesson_path in RUNTIME_LESSONS:
with self.subTest(lesson=lesson_path.parent.name):
with tempfile.TemporaryDirectory() as tmp:
lesson = load_lesson(Path(tmp), lesson_path)
task = lesson.create_task("Implement auth")
calls = 0
def respond(**kwargs):
nonlocal calls
calls += 1
tool_name = "claim_task" if calls == 1 else "list_tasks"
tool_input = {"task_id": task.id} if calls == 1 else {}
return types.SimpleNamespace(
stop_reason="tool_use",
content=[types.SimpleNamespace(
type="tool_use", name=tool_name,
id=f"tool-{calls}", input=tool_input,
)],
)
original_dispatch = lesson._run_teammate_tool
def crash_after_claim(name, block, handlers):
if block.name == "list_tasks":
raise RuntimeError("simulated dispatch failure")
return original_dispatch(name, block, handlers)
lesson.client.messages.create = respond
lesson._run_teammate_tool = crash_after_claim
lesson.spawn_teammate_thread(
"alice", "backend", "Claim and begin work."
)
self.assertTrue(wait_until(
lambda: "alice" not in lesson.active_teammates
))
self.assertNotIn("alice", lesson.teammate_assignments)
recovered = lesson.load_task(task.id)
self.assertEqual(recovered.status, "pending")
self.assertIsNone(recovered.owner)
events = lesson.consume_lead_inbox()
self.assertEqual([event["type"] for event in events], ["error"])
self.assertIn("simulated dispatch failure", events[0]["content"])
def test_s17_completed_background_task_wakes_the_agent_once(self):
with tempfile.TemporaryDirectory() as tmp:
lesson = load_lesson(
Path(tmp), ROOT / "s17_integrated_harness" / "code.py"
)
seen_messages = []
def respond(messages, context, tools, state, max_tokens):
seen_messages.append(list(messages))
return types.SimpleNamespace(
stop_reason="end_turn",
content=[types.SimpleNamespace(
type="text", text="Background result handled."
)],
)
lesson.call_llm = respond
lesson.background_tasks["bg_0001"] = {
"tool_use_id": "tool-1",
"command": "pytest",
"status": "completed",
}
lesson.background_results["bg_0001"] = "all tests passed"
history = []
context = {}
session_state = {"active_user_request": "Run tests"}
threading.Thread(
target=lesson.async_event_loop,
args=(history, context, session_state),
daemon=True,
).start()
self.assertTrue(wait_until(lambda: bool(seen_messages), timeout=3.0))
delivered = str(seen_messages[0])
self.assertIn("<task_notification>", delivered)
self.assertIn("all tests passed", delivered)
self.assertFalse(lesson.has_pending_background())
calls_after_delivery = len(seen_messages)
time.sleep(1.2)
self.assertEqual(len(seen_messages), calls_after_delivery)
def test_teammate_survives_stale_worktree_assignment(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
init_git_repo(root)
lesson = load_lesson(root)
lesson.IDLE_SCAN_INTERVAL = 5.0
task = lesson.create_task("Implement auth")
lesson.create_worktree("auth", task.id)
worktree = lesson.WORKTREES_DIR / "auth"
calls = 0
bash_result = []
def respond(**kwargs):
nonlocal calls
calls += 1
if calls == 1:
return types.SimpleNamespace(
stop_reason="tool_use",
content=[types.SimpleNamespace(
type="tool_use", name="claim_task", id="claim-1",
input={"task_id": task.id},
)],
)
if calls == 2:
subprocess.run(
["git", "worktree", "remove", "--force",
str(worktree)], cwd=root, check=True,
)
return types.SimpleNamespace(
stop_reason="tool_use",
content=[types.SimpleNamespace(
type="tool_use", name="bash", id="bash-1",
input={"command": "pwd"},
)],
)
bash_result.append(
kwargs["messages"][-1]["content"][0]["content"]
)
return types.SimpleNamespace(
stop_reason="end_turn",
content=[types.SimpleNamespace(
type="text", text="Handled stale assignment."
)],
)
lesson.client.messages.create = respond
lesson.spawn_teammate_thread("alice", "backend", "Claim the task.")
self.assertTrue(wait_until(lambda: bool(bash_result)))
self.assertIn("Invalid task assignment", bash_result[0])
self.assertIn("alice", lesson.active_teammates)
lesson.run_request_shutdown("alice")
self.assertTrue(
wait_until(lambda: "alice" not in lesson.active_teammates)
)
def test_autonomous_claim_is_atomic_across_teammates(self):
with tempfile.TemporaryDirectory() as tmp:
lesson = load_lesson(Path(tmp), AUTONOMOUS_LESSON)
lesson.create_task("Refactor auth")
lesson.create_task("Refactor login")
lesson = load_lesson(Path(tmp))
task = lesson.create_task("Refactor auth")
barrier = threading.Barrier(3)
claimed = {}
@ -295,13 +763,313 @@ class AgentTeamsRuntimeTests(unittest.TestCase):
self.assertTrue(all(not thread.is_alive() for thread in threads))
self.assertEqual(
{task.owner for task in claimed.values() if task is not None},
{"alice", "bob"},
len([result for result in claimed.values() if result is not None]),
1,
)
self.assertEqual(
len({task.id for task in claimed.values() if task is not None}),
2,
winner = next(result.owner for result in claimed.values()
if result is not None)
self.assertEqual(lesson.load_task(task.id).owner, winner)
def test_assignment_enforces_one_task_and_owner_only_completion(self):
with tempfile.TemporaryDirectory() as tmp:
lesson = load_lesson(Path(tmp))
first = lesson.create_task("Refactor auth")
second = lesson.create_task("Refactor login")
self.assertIn("Claimed", lesson.claim_task(first.id, owner="alice"))
denied = lesson.claim_task(second.id, owner="alice")
self.assertIn("must complete", denied)
self.assertEqual(lesson.load_task(second.id).status, "pending")
denied = lesson.complete_task(first.id, owner="bob")
self.assertIn("not bob", denied)
self.assertEqual(lesson.load_task(first.id).status, "in_progress")
self.assertIn(
"Completed", lesson.complete_task(first.id, owner="alice")
)
self.assertNotIn("alice", lesson.teammate_assignments)
self.assertIn("Claimed", lesson.claim_task(second.id, owner="alice"))
def test_task_worktree_sets_assignment_cwd_and_contains_file_tools(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
init_git_repo(root)
lesson = load_lesson(root)
task = lesson.create_task("Implement auth")
created = lesson.create_worktree("auth", task.id)
self.assertIn("created", created)
worktree = lesson.WORKTREES_DIR / "auth"
self.assertEqual(lesson.load_task(task.id).worktree, "auth")
self.assertIn("Claimed", lesson.claim_task(task.id, owner="alice"))
assignment = lesson.teammate_assignments["alice"]
self.assertEqual(assignment["task_id"], task.id)
self.assertEqual(assignment["cwd"], worktree)
self.assertIn(
"Wrote", lesson.run_write(
"nested/result.txt", "done", cwd=lesson.assignment_cwd("alice")
)
)
self.assertEqual((worktree / "nested" / "result.txt").read_text(),
"done")
escaped = lesson.run_write(
"../outside.txt", "bad", cwd=lesson.assignment_cwd("alice")
)
self.assertIn("escapes workspace", escaped)
self.assertFalse((lesson.WORKTREES_DIR / "outside.txt").exists())
missing_cwd = lesson.run_bash("pwd", cwd=worktree / "missing")
self.assertIn("FileNotFoundError", missing_cwd)
def test_invalid_or_unregistered_worktree_never_becomes_claimable(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
init_git_repo(root)
lesson = load_lesson(root)
task = lesson.create_task("Implement auth")
invalid = lesson.create_worktree("../escape", task.id)
self.assertIn("Error", invalid)
self.assertIsNone(lesson.load_task(task.id).worktree)
self.assertFalse((root / "escape").exists())
missing = lesson.create_worktree("auth", "../missing")
self.assertIn("Error", missing)
self.assertFalse((lesson.WORKTREES_DIR / "auth").exists())
bound = lesson.load_task(task.id)
bound.worktree = "ghost"
lesson.save_task(bound)
denied = lesson.claim_task(task.id, owner="alice")
self.assertIn("not registered", denied)
self.assertEqual(lesson.load_task(task.id).status, "pending")
self.assertEqual(lesson.scan_unclaimed_tasks(), [])
def test_create_validates_branch_and_binds_only_after_git_add(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
init_git_repo(root)
lesson = load_lesson(root)
task = lesson.create_task("Implement auth")
subprocess.run(
["git", "branch", "wt/auth"], cwd=root, check=True
)
collision = lesson.create_worktree("auth", task.id)
self.assertIn("already exists", collision)
self.assertIsNone(lesson.load_task(task.id).worktree)
self.assertFalse((lesson.WORKTREES_DIR / "auth").exists())
original_run_git = lesson.run_git
def fail_add(args, cwd=None):
if args[:2] == ["worktree", "add"]:
return False, "simulated add failure"
return original_run_git(args, cwd=cwd)
lesson.run_git = fail_add
failed = lesson.create_worktree("login", task.id)
self.assertIn("simulated add failure", failed)
self.assertIsNone(lesson.load_task(task.id).worktree)
self.assertFalse((lesson.WORKTREES_DIR / "login").exists())
def test_failed_git_add_reports_and_preserves_partial_artifacts(self):
for lesson_path in RUNTIME_LESSONS:
with self.subTest(lesson=lesson_path.parent.name):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
init_git_repo(root)
lesson = load_lesson(root, lesson_path)
task = lesson.create_task("Implement auth")
original_run_git = lesson.run_git
def fail_after_add(args, cwd=None):
if args[:2] == ["worktree", "add"]:
ok, output = original_run_git(args, cwd=cwd)
self.assertTrue(ok, output)
return False, "simulated late add failure"
return original_run_git(args, cwd=cwd)
lesson.run_git = fail_after_add
result = lesson.create_worktree("auth", task.id)
self.assertIn("Partial operation", result)
self.assertIn("simulated late add failure", result)
self.assertIn("remains unbound", result)
self.assertIn("git worktree list", result)
self.assertTrue((lesson.WORKTREES_DIR / "auth").is_dir())
self.assertIsNone(lesson.load_task(task.id).worktree)
branch = subprocess.run(
["git", "show-ref", "--verify", "--quiet",
"refs/heads/wt/auth"],
cwd=root,
)
self.assertEqual(branch.returncode, 0)
def test_binding_failure_retains_created_git_data_for_recovery(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
init_git_repo(root)
lesson = load_lesson(root)
task = lesson.create_task("Implement auth")
original_save_task = lesson.save_task
def fail_binding(candidate):
if candidate.worktree == "auth":
raise OSError("simulated task persistence failure")
original_save_task(candidate)
lesson.save_task = fail_binding
result = lesson.create_worktree("auth", task.id)
self.assertIn("Partial success", result)
self.assertIn("manual recovery", result)
self.assertTrue((lesson.WORKTREES_DIR / "auth").is_dir())
self.assertIsNone(lesson.load_task(task.id).worktree)
branch = subprocess.run(
["git", "show-ref", "--verify", "--quiet",
"refs/heads/wt/auth"], cwd=root,
)
self.assertEqual(branch.returncode, 0)
def test_remove_worktree_refuses_dirty_checkout_by_default(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
init_git_repo(root)
lesson = load_lesson(root)
task = lesson.create_task("Implement auth")
lesson.create_worktree("auth", task.id)
lesson.claim_task(task.id, owner="alice")
lesson.complete_task(task.id, owner="alice")
worktree = lesson.WORKTREES_DIR / "auth"
(worktree / "dirty.txt").write_text("unsaved\n")
denied = lesson.remove_worktree("auth")
self.assertIn("uncommitted", denied)
self.assertTrue(worktree.exists())
self.assertEqual(lesson.load_task(task.id).worktree, "auth")
def test_remove_worktree_treats_ignored_files_as_uncommitted_data(self):
for lesson_path in RUNTIME_LESSONS:
with self.subTest(lesson=lesson_path.parent.name):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
init_git_repo(root)
(root / ".gitignore").write_text("ignored.log\n")
subprocess.run(
["git", "add", ".gitignore"], cwd=root, check=True
)
subprocess.run(
["git", "commit", "-q", "-m", "ignore runtime log"],
cwd=root,
check=True,
)
lesson = load_lesson(root, lesson_path)
task = lesson.create_task("Implement auth")
lesson.create_worktree("auth", task.id)
lesson.claim_task(task.id, owner="alice")
lesson.complete_task(task.id, owner="alice")
worktree = lesson.WORKTREES_DIR / "auth"
(worktree / "ignored.log").write_text("valuable output\n")
denied = lesson.run_remove_worktree("auth")
self.assertIn("uncommitted", denied)
self.assertTrue(worktree.exists())
self.assertEqual(lesson.load_task(task.id).worktree, "auth")
removed = lesson.remove_worktree(
"auth", discard_changes=True
)
self.assertIn("branch 'wt/auth' retained", removed)
self.assertFalse(worktree.exists())
def test_discard_removes_checkout_but_retains_branch(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
init_git_repo(root)
lesson = load_lesson(root)
task = lesson.create_task("Implement auth")
lesson.create_worktree("auth", task.id)
lesson.claim_task(task.id, owner="alice")
lesson.complete_task(task.id, owner="alice")
worktree = lesson.WORKTREES_DIR / "auth"
(worktree / "dirty.txt").write_text("discard me\n")
removed = lesson.remove_worktree("auth", discard_changes=True)
self.assertIn("branch 'wt/auth' retained", removed)
self.assertFalse(worktree.exists())
self.assertIsNone(lesson.load_task(task.id).worktree)
branch = subprocess.run(
["git", "show-ref", "--verify", "--quiet",
"refs/heads/wt/auth"], cwd=root,
)
self.assertEqual(branch.returncode, 0)
def test_clean_local_commit_survives_non_force_checkout_removal(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
init_git_repo(root)
lesson = load_lesson(root)
task = lesson.create_task("Implement auth")
lesson.create_worktree("auth", task.id)
lesson.claim_task(task.id, owner="alice")
lesson.complete_task(task.id, owner="alice")
worktree = lesson.WORKTREES_DIR / "auth"
(worktree / "feature.txt").write_text("committed work\n")
subprocess.run(
["git", "add", "feature.txt"], cwd=worktree, check=True
)
subprocess.run(
["git", "commit", "-q", "-m", "feature"],
cwd=worktree, check=True,
)
commit = subprocess.check_output(
["git", "rev-parse", "HEAD"], cwd=worktree, text=True
).strip()
upstream = subprocess.check_output(
["git", "for-each-ref", "--format=%(upstream)",
"refs/heads/wt/auth"], cwd=root, text=True,
).strip()
self.assertEqual(upstream, "")
removed = lesson.remove_worktree("auth")
self.assertIn("branch 'wt/auth' retained", removed)
self.assertFalse(worktree.exists())
retained = subprocess.check_output(
["git", "rev-parse", "wt/auth"], cwd=root, text=True
).strip()
self.assertEqual(retained, commit)
def test_active_task_blocks_normal_and_discard_removal(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
init_git_repo(root)
lesson = load_lesson(root)
task = lesson.create_task("Implement auth")
lesson.create_worktree("auth", task.id)
worktree = lesson.WORKTREES_DIR / "auth"
pending_normal = lesson.remove_worktree("auth")
pending_discard = lesson.remove_worktree(
"auth", discard_changes=True
)
self.assertIn("active task", pending_normal)
self.assertIn("active task", pending_discard)
lesson.claim_task(task.id, owner="alice")
progress_normal = lesson.remove_worktree("auth")
progress_discard = lesson.remove_worktree(
"auth", discard_changes=True
)
self.assertIn("active task", progress_normal)
self.assertIn("active task", progress_discard)
self.assertTrue(worktree.exists())
self.assertEqual(lesson.load_task(task.id).status, "in_progress")
if __name__ == "__main__":

View file

@ -7,7 +7,7 @@ CHAPTERS = sorted(ROOT.glob("s[0-9][0-9]_*"))
def test_every_chapter_uses_english_as_the_default_readme() -> None:
assert len(CHAPTERS) == 21
assert len(CHAPTERS) == 19
for chapter in CHAPTERS:
assert (chapter / "README.md").is_file()

View file

@ -11,7 +11,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
MODULES = {
"s08": REPO_ROOT / "s08_context_compact" / "code.py",
"s09": REPO_ROOT / "s09_memory" / "code.py",
"s19": REPO_ROOT / "s19_comprehensive" / "code.py",
"s17": REPO_ROOT / "s17_integrated_harness" / "code.py",
}
@ -244,9 +244,9 @@ class CompactionToolPairTests(unittest.TestCase):
self.assertEqual(compacted[1:], messages[3:])
assert_no_orphan_tool_results(self, compacted)
def test_s19_has_tool_use_still_accepts_content_blocks(self):
def test_s17_has_tool_use_still_accepts_content_blocks(self):
with tempfile.TemporaryDirectory() as tmp:
module = load_module("s19_has_tool_use_under_test", MODULES["s19"], Path(tmp))
module = load_module("s17_has_tool_use_under_test", MODULES["s17"], Path(tmp))
self.assertTrue(module.has_tool_use([types.SimpleNamespace(type="tool_use")]))
self.assertFalse(module.has_tool_use([types.SimpleNamespace(type="text")]))

View file

@ -9,8 +9,8 @@ from types import SimpleNamespace
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
MODULE_PATH = REPO_ROOT / "s21_goal_loop" / "code.py"
MODULE_NAME = "s21_goal_loop_under_test"
MODULE_PATH = REPO_ROOT / "s19_goal_loop" / "code.py"
MODULE_NAME = "s19_goal_loop_under_test"
SPEC = importlib.util.spec_from_file_location(MODULE_NAME, MODULE_PATH)
if SPEC is None or SPEC.loader is None:
raise RuntimeError(f"Unable to load {MODULE_PATH}")

View file

@ -13,7 +13,7 @@ COURSE_MODULES = [
("s06", REPO_ROOT / "s06_subagent" / "code.py"),
("s07", REPO_ROOT / "s07_skill_loading" / "code.py"),
("s08", REPO_ROOT / "s08_context_compact" / "code.py"),
("s19", REPO_ROOT / "s19_comprehensive" / "code.py"),
("s17", REPO_ROOT / "s17_integrated_harness" / "code.py"),
]

View file

@ -36,7 +36,7 @@ def run_lesson(script: Path, *args: str) -> str:
def test_workflow_runtime_resumes_from_journal(tmp_path: Path) -> None:
script = tmp_path / "code.py"
shutil.copy2(ROOT / "s20_workflow_runtime" / "code.py", script)
shutil.copy2(ROOT / "s18_workflow_runtime" / "code.py", script)
first = run_lesson(script)
resumed = run_lesson(script, "resume")
@ -49,7 +49,7 @@ def test_workflow_runtime_resumes_from_journal(tmp_path: Path) -> None:
def test_workflow_runtime_rejects_unsafe_artifact_names() -> None:
workflow = load_lesson(
"workflow_name_test", ROOT / "s20_workflow_runtime" / "code.py"
"workflow_name_test", ROOT / "s18_workflow_runtime" / "code.py"
)
for name in ("../escape", "../../escape", "nested/name"):
@ -61,7 +61,7 @@ def test_workflow_runtime_enforces_budget_and_shared_agent_cap(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
workflow = load_lesson(
"workflow_limit_test", ROOT / "s20_workflow_runtime" / "code.py"
"workflow_limit_test", ROOT / "s18_workflow_runtime" / "code.py"
)
budget = workflow.Budget(total=1)
with pytest.raises(workflow.WorkflowInputError):
@ -105,7 +105,7 @@ def test_workflow_runtime_enforces_budget_and_shared_agent_cap(
def test_workflow_runtime_rejects_corrupt_resume_journal(tmp_path: Path) -> None:
workflow = load_lesson(
"workflow_journal_test", ROOT / "s20_workflow_runtime" / "code.py"
"workflow_journal_test", ROOT / "s18_workflow_runtime" / "code.py"
)
run_id = "wf_corrupt_0001"
(tmp_path / f"{run_id}.journal.jsonl").write_text("{not-json}\n")

View file

@ -11,13 +11,13 @@
<rect width="720" height="400" fill="#fafbfc" rx="8"/>
<rect x="0" y="0" width="720" height="38" fill="url(#header)" rx="8"/>
<rect x="0" y="30" width="720" height="8" fill="url(#header)"/>
<text x="360" y="25" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">L4: autoCompact — LLM Full Summary</text>
<text x="360" y="25" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">Step 4: compact_history, Summarize History</text>
<!-- Trigger Condition -->
<rect x="20" y="54" width="680" height="44" rx="6" fill="#fef2f2" stroke="#fca5a5" stroke-width="1"/>
<text x="35" y="70" fill="#991b1b" font-size="11" font-weight="600">Trigger Condition</text>
<text x="140" y="70" fill="#991b1b" font-size="11">All three preprocessing layers have run, estimated tokens &gt; contextWindow - maxOutputTokens - 13_000.</text>
<text x="140" y="86" fill="#991b1b" font-size="10">Tries sessionMemoryCompact first (lightweight summary from existing memory), only calls LLM if insufficient.</text>
<text x="140" y="70" fill="#991b1b" font-size="11">After Steps 13, estimate_size(messages) &gt; CONTEXT_LIMIT.</text>
<text x="140" y="86" fill="#991b1b" font-size="10">The current CONTEXT_LIMIT is 50,000 characters.</text>
<!-- Steps -->
<rect x="20" y="106" width="200" height="110" rx="8" fill="#fff" stroke="#94a3b8" stroke-width="1.5"/>
@ -30,21 +30,21 @@
<line x1="225" y1="161" x2="265" y2="161" stroke="#dc2626" stroke-width="2" marker-end="url(#arrow)"/>
<rect x="270" y="106" width="200" height="110" rx="8" fill="#fff" stroke="#94a3b8" stroke-width="1.5"/>
<text x="370" y="130" fill="#1e3a5f" font-size="12" font-weight="700" text-anchor="middle">Step 2: LLM generates summary</text>
<text x="290" y="152" fill="#475569" font-size="10">Send conversation history to LLM</text>
<text x="290" y="166" fill="#475569" font-size="9">Summary must include 9 sections:</text>
<text x="370" y="180" fill="#94a3b8" font-size="8" text-anchor="middle">request · concepts · files · errors</text>
<text x="370" y="192" fill="#94a3b8" font-size="8" text-anchor="middle">resolutions · user messages · todos</text>
<text x="370" y="204" fill="#94a3b8" font-size="8" text-anchor="middle">current state · next steps</text>
<text x="370" y="130" fill="#1e3a5f" font-size="12" font-weight="700" text-anchor="middle">Step 2: Factual summary</text>
<text x="370" y="152" fill="#475569" font-size="9" text-anchor="middle">Conversation is untrusted data</text>
<text x="290" y="166" fill="#475569" font-size="9">Summary preserves 5 categories:</text>
<text x="370" y="180" fill="#94a3b8" font-size="8" text-anchor="middle">goal · findings and decisions · files</text>
<text x="370" y="192" fill="#94a3b8" font-size="8" text-anchor="middle">remaining work · user constraints</text>
<text x="370" y="204" fill="#94a3b8" font-size="8" text-anchor="middle">do not propose or select an action</text>
<line x1="475" y1="161" x2="515" y2="161" stroke="#dc2626" stroke-width="2" marker-end="url(#arrow)"/>
<rect x="520" y="106" width="180" height="110" rx="8" fill="#fef2f2" stroke="#dc2626" stroke-width="2"/>
<text x="610" y="130" fill="#991b1b" font-size="12" font-weight="700" text-anchor="middle">Step 3: Replace message list</text>
<text x="540" y="152" fill="#991b1b" font-size="10">All old messages → 1 summary</text>
<text x="540" y="168" fill="#991b1b" font-size="10">Model continues from summary</text>
<text x="540" y="184" fill="#991b1b" font-size="10">Includes recently_read file list</text>
<text x="540" y="200" fill="#ef4444" font-size="9">⚠ This is an irreversible operation</text>
<text x="610" y="130" fill="#991b1b" font-size="12" font-weight="700" text-anchor="middle">Step 3: Replace history</text>
<text x="610" y="152" fill="#991b1b" font-size="9" text-anchor="middle">Old history → 1 message</text>
<text x="610" y="168" fill="#991b1b" font-size="9" text-anchor="middle">Request + reference state</text>
<text x="610" y="184" fill="#991b1b" font-size="9" text-anchor="middle">System separates instructions/data</text>
<text x="610" y="200" fill="#ef4444" font-size="9" text-anchor="middle">Transcript remains on disk</text>
<!-- Before/After comparison -->
<rect x="20" y="234" width="320" height="94" rx="6" fill="#fff" stroke="#94a3b8" stroke-width="1"/>
@ -54,19 +54,19 @@
<rect x="149" y="264" width="52" height="16" rx="3" fill="#e2e8f0"/><text x="154" y="276" fill="#475569" font-size="8">user</text>
<rect x="206" y="264" width="52" height="16" rx="3" fill="#e2e8f0"/><text x="211" y="276" fill="#475569" font-size="8">assistant</text>
<rect x="263" y="264" width="52" height="16" rx="3" fill="#e2e8f0"/><text x="268" y="276" fill="#475569" font-size="8">user</text>
<text x="180" y="318" fill="#94a3b8" font-size="9" text-anchor="middle">~180 messages, occupying 62K tokens</text>
<text x="180" y="318" fill="#94a3b8" font-size="9" text-anchor="middle">Many messages, estimated size &gt; 50,000</text>
<line x1="345" y1="281" x2="375" y2="281" stroke="#dc2626" stroke-width="2" marker-end="url(#arrow)"/>
<rect x="380" y="234" width="320" height="94" rx="6" fill="#fef2f2" stroke="#dc2626" stroke-width="1"/>
<text x="540" y="256" fill="#991b1b" font-size="11" font-weight="600" text-anchor="middle">After messages</text>
<rect x="395" y="264" width="290" height="32" rx="4" fill="#fee2e2" stroke="#fca5a5" stroke-width="0.5"/>
<text x="540" y="276" fill="#991b1b" font-size="9" text-anchor="middle">[Compacted] Summary: goal → create hello.py ...</text>
<text x="540" y="290" fill="#991b1b" font-size="9" text-anchor="middle">Recent files: hello.py, README.md ...</text>
<text x="540" y="318" fill="#94a3b8" font-size="9" text-anchor="middle">~1 message, occupying 1K tokens</text>
<text x="540" y="276" fill="#991b1b" font-size="9" text-anchor="middle">Authoritative request: captured at input</text>
<text x="540" y="290" fill="#991b1b" font-size="9" text-anchor="middle">Reference state: untrusted factual summary</text>
<text x="540" y="318" fill="#94a3b8" font-size="9" text-anchor="middle">One summary message, well below the limit</text>
<!-- Circuit breaker -->
<!-- Error recovery -->
<rect x="20" y="340" width="680" height="36" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
<text x="35" y="362" fill="#475569" font-size="11" font-weight="600">Circuit breaker:</text>
<text x="130" y="362" fill="#475569" font-size="10">3 consecutive autocompact failures → stop retrying. Prevents wasting API calls when context is unrecoverable.</text>
<text x="35" y="362" fill="#475569" font-size="11" font-weight="600">Error recovery:</text>
<text x="125" y="362" fill="#475569" font-size="10">If the API still returns prompt_too_long, run reactive_compact and retry only once.</text>
</svg>

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Before After
Before After

View file

@ -11,13 +11,13 @@
<rect width="720" height="400" fill="#fafbfc" rx="8"/>
<rect x="0" y="0" width="720" height="38" fill="url(#header)" rx="8"/>
<rect x="0" y="30" width="720" height="8" fill="url(#header)"/>
<text x="360" y="25" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">L4: autoCompact — LLM 完全要約</text>
<text x="360" y="25" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">Step 4: compact_history、履歴を要約</text>
<!-- トリガー条件 -->
<rect x="20" y="54" width="680" height="44" rx="6" fill="#fef2f2" stroke="#fca5a5" stroke-width="1"/>
<text x="35" y="70" fill="#991b1b" font-size="11" font-weight="600">トリガー条件</text>
<text x="115" y="70" fill="#991b1b" font-size="11">前 3 層の前処理を全て実行後、推定 token &gt; contextWindow - maxOutputTokens - 13_000</text>
<text x="115" y="86" fill="#991b1b" font-size="10">まず sessionMemoryCompact を試行(既存のメモリで軽量要約)、不足時のみ LLM を呼び出し</text>
<text x="115" y="70" fill="#991b1b" font-size="11">Step 13 の後、estimate_size(messages) &gt; CONTEXT_LIMIT</text>
<text x="115" y="86" fill="#991b1b" font-size="10">現在の CONTEXT_LIMIT は 50,000 文字</text>
<!-- ステップ -->
<rect x="20" y="106" width="200" height="110" rx="8" fill="#fff" stroke="#94a3b8" stroke-width="1.5"/>
@ -30,21 +30,21 @@
<line x1="225" y1="161" x2="265" y2="161" stroke="#dc2626" stroke-width="2" marker-end="url(#arrow)"/>
<rect x="270" y="106" width="200" height="110" rx="8" fill="#fff" stroke="#94a3b8" stroke-width="1.5"/>
<text x="370" y="130" fill="#1e3a5f" font-size="12" font-weight="700" text-anchor="middle">ステップ 2LLM 要約生成</text>
<text x="290" y="152" fill="#475569" font-size="10">対話履歴を LLM に送信</text>
<text x="290" y="166" fill="#475569" font-size="9">要約は 9 つのセクションを含む</text>
<text x="290" y="180" fill="#94a3b8" font-size="8">リクエスト・概念・ファイル・エラー・解決</text>
<text x="290" y="192" fill="#94a3b8" font-size="8">ユーザーメッセージ・TODO・現在・次ステップ</text>
<text x="290" y="206" fill="#94a3b8" font-size="9">1 回のみ生成</text>
<text x="370" y="130" fill="#1e3a5f" font-size="12" font-weight="700" text-anchor="middle">ステップ 2事実要約</text>
<text x="370" y="152" fill="#475569" font-size="9" text-anchor="middle">元の対話は信頼しないデータ</text>
<text x="290" y="166" fill="#475569" font-size="9">要約は 5 種類の情報を保持</text>
<text x="290" y="180" fill="#94a3b8" font-size="8">目標・発見と判断・関連ファイル</text>
<text x="290" y="192" fill="#94a3b8" font-size="8">残作業・ユーザー制約</text>
<text x="290" y="206" fill="#94a3b8" font-size="9">行動を提案・選択しない</text>
<line x1="475" y1="161" x2="515" y2="161" stroke="#dc2626" stroke-width="2" marker-end="url(#arrow)"/>
<rect x="520" y="106" width="180" height="110" rx="8" fill="#fef2f2" stroke="#dc2626" stroke-width="2"/>
<text x="610" y="130" fill="#991b1b" font-size="12" font-weight="700" text-anchor="middle">ステップ 3要約に置換</text>
<text x="540" y="152" fill="#991b1b" font-size="10">全旧メッセージ → 1 件の要約に</text>
<text x="540" y="168" fill="#991b1b" font-size="10">モデルは要約から作業を継続</text>
<text x="540" y="184" fill="#991b1b" font-size="10">recently_read を添付</text>
<text x="540" y="200" fill="#ef4444" font-size="9">⚠ これは復元不可能な操作</text>
<text x="610" y="130" fill="#991b1b" font-size="12" font-weight="700" text-anchor="middle">ステップ 3履歴置換</text>
<text x="610" y="152" fill="#991b1b" font-size="9" text-anchor="middle">旧履歴 → 1 件の圧縮</text>
<text x="610" y="168" fill="#991b1b" font-size="9" text-anchor="middle">要求と参照状態を分離</text>
<text x="610" y="184" fill="#991b1b" font-size="8" text-anchor="middle">system が指示とデータを区別</text>
<text x="610" y="200" fill="#ef4444" font-size="9" text-anchor="middle">transcript はディスクに保持</text>
<!-- 圧縮前/後 比較 -->
<rect x="20" y="234" width="320" height="94" rx="6" fill="#fff" stroke="#94a3b8" stroke-width="1"/>
@ -54,19 +54,19 @@
<rect x="149" y="264" width="52" height="16" rx="3" fill="#e2e8f0"/><text x="154" y="276" fill="#475569" font-size="8">user</text>
<rect x="206" y="264" width="52" height="16" rx="3" fill="#e2e8f0"/><text x="211" y="276" fill="#475569" font-size="8">assistant</text>
<rect x="263" y="264" width="52" height="16" rx="3" fill="#e2e8f0"/><text x="268" y="276" fill="#475569" font-size="8">user</text>
<text x="180" y="318" fill="#94a3b8" font-size="9" text-anchor="middle">~180 件のメッセージ、62K トークンを占有</text>
<text x="180" y="318" fill="#94a3b8" font-size="9" text-anchor="middle">多数のメッセージ、推定 size &gt; 50,000</text>
<line x1="345" y1="281" x2="375" y2="281" stroke="#dc2626" stroke-width="2" marker-end="url(#arrow)"/>
<rect x="380" y="234" width="320" height="94" rx="6" fill="#fef2f2" stroke="#dc2626" stroke-width="1"/>
<text x="540" y="256" fill="#991b1b" font-size="11" font-weight="600" text-anchor="middle">圧縮後 messages</text>
<rect x="395" y="264" width="290" height="32" rx="4" fill="#fee2e2" stroke="#fca5a5" stroke-width="0.5"/>
<text x="540" y="276" fill="#991b1b" font-size="9" text-anchor="middle">[Compacted] 要約:目標 → hello.py を作成 ...</text>
<text x="540" y="290" fill="#991b1b" font-size="9" text-anchor="middle">最近のファイルhello.py, README.md ...</text>
<text x="540" y="318" fill="#94a3b8" font-size="9" text-anchor="middle">~1 件のメッセージ、1K トークンを占有</text>
<text x="540" y="276" fill="#991b1b" font-size="9" text-anchor="middle">Authoritative request入力時に取得した要求</text>
<text x="540" y="290" fill="#991b1b" font-size="9" text-anchor="middle">Reference state信頼しない事実要約</text>
<text x="540" y="318" fill="#94a3b8" font-size="9" text-anchor="middle">1 件の要約メッセージ、上限を下回る</text>
<!-- サーキットブレーカー -->
<!-- エラー回復 -->
<rect x="20" y="340" width="680" height="36" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
<text x="35" y="362" fill="#475569" font-size="11" font-weight="600">サーキットブレーカー</text>
<text x="145" y="362" fill="#475569" font-size="10">autocompact が連続 3 回失敗 → リトライ停止。コンテキストが復元不可能な場合の API 呼び出しの無駄な反復を防止</text>
<text x="35" y="362" fill="#475569" font-size="11" font-weight="600">エラー回復</text>
<text x="115" y="362" fill="#475569" font-size="10">API が prompt_too_long を返したら reactive_compact を実行し、1 回だけ再試行</text>
</svg>

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Before After
Before After

View file

@ -11,40 +11,40 @@
<rect width="720" height="400" fill="#fafbfc" rx="8"/>
<rect x="0" y="0" width="720" height="38" fill="url(#header)" rx="8"/>
<rect x="0" y="30" width="720" height="8" fill="url(#header)"/>
<text x="360" y="25" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">L4: autoCompact — LLM 全量摘要</text>
<text x="360" y="25" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">Step 4: compact_history生成历史摘要</text>
<!-- 触发条件 -->
<rect x="20" y="54" width="680" height="44" rx="6" fill="#fef2f2" stroke="#fca5a5" stroke-width="1"/>
<text x="35" y="70" fill="#991b1b" font-size="11" font-weight="600">触发条件</text>
<text x="105" y="70" fill="#991b1b" font-size="11">前三层预处理全跑完,估算 token &gt; contextWindow - maxOutputTokens - 13_000</text>
<text x="105" y="86" fill="#991b1b" font-size="10">先尝试 sessionMemoryCompact用已有记忆做轻量摘要不足才调 LLM</text>
<text x="105" y="70" fill="#991b1b" font-size="11">前三步执行后estimate_size(messages) &gt; CONTEXT_LIMIT</text>
<text x="105" y="86" fill="#991b1b" font-size="10">当前实现的 CONTEXT_LIMIT 为 50,000 个字符</text>
<!-- 步骤 -->
<rect x="20" y="106" width="200" height="110" rx="8" fill="#fff" stroke="#94a3b8" stroke-width="1.5"/>
<text x="120" y="130" fill="#1e3a5f" font-size="12" font-weight="700" text-anchor="middle">步骤 1保存 transcript</text>
<text x="40" y="152" fill="#475569" font-size="10">完整对话写入 .transcripts/</text>
<text x="40" y="168" fill="#475569" font-size="10">JSONL 格式,一行一条消息</text>
<text x="40" y="184" fill="#475569" font-size="10">文件名transcript_{timestamp}.jsonl</text>
<text x="40" y="184" fill="#475569" font-size="10">文件名transcript_{time}.jsonl</text>
<text x="40" y="200" fill="#94a3b8" font-size="9">信息没有丢失,只是移出活跃区</text>
<line x1="225" y1="161" x2="265" y2="161" stroke="#dc2626" stroke-width="2" marker-end="url(#arrow)"/>
<rect x="270" y="106" width="200" height="110" rx="8" fill="#fff" stroke="#94a3b8" stroke-width="1.5"/>
<text x="370" y="130" fill="#1e3a5f" font-size="12" font-weight="700" text-anchor="middle">步骤 2LLM 生成摘要</text>
<text x="290" y="152" fill="#475569" font-size="10">把对话历史发给 LLM</text>
<text x="290" y="166" fill="#475569" font-size="9">摘要需包含 9 个部分</text>
<text x="290" y="180" fill="#94a3b8" font-size="8">请求·概念·文件·错误·解决</text>
<text x="290" y="192" fill="#94a3b8" font-size="8">用户消息·待办·当前·下一步</text>
<text x="290" y="206" fill="#94a3b8" font-size="9">只生成一次</text>
<text x="370" y="130" fill="#1e3a5f" font-size="12" font-weight="700" text-anchor="middle">步骤 2生成事实摘要</text>
<text x="370" y="152" fill="#475569" font-size="9" text-anchor="middle">原对话是不可信数据</text>
<text x="290" y="166" fill="#475569" font-size="9">摘要保留 5 类信息</text>
<text x="290" y="180" fill="#94a3b8" font-size="8">目标·发现与决定·相关文件</text>
<text x="290" y="192" fill="#94a3b8" font-size="8">剩余工作·用户约束</text>
<text x="290" y="206" fill="#94a3b8" font-size="9">不提出或选择后续动作</text>
<line x1="475" y1="161" x2="515" y2="161" stroke="#dc2626" stroke-width="2" marker-end="url(#arrow)"/>
<rect x="520" y="106" width="180" height="110" rx="8" fill="#fef2f2" stroke="#dc2626" stroke-width="2"/>
<text x="610" y="130" fill="#991b1b" font-size="12" font-weight="700" text-anchor="middle">步骤 3替换消息列表</text>
<text x="540" y="152" fill="#991b1b" font-size="10">所有旧消息 → 1 条摘要</text>
<text x="540" y="168" fill="#991b1b" font-size="10">模型从摘要继续工作</text>
<text x="540" y="184" fill="#991b1b" font-size="10">附带 recently_read 文件列表</text>
<text x="540" y="200" fill="#ef4444" font-size="9">⚠ 这是无法恢复的操作</text>
<text x="610" y="130" fill="#991b1b" font-size="12" font-weight="700" text-anchor="middle">步骤 3替换历史</text>
<text x="610" y="152" fill="#991b1b" font-size="9" text-anchor="middle">旧历史 → 1 条压缩消息</text>
<text x="610" y="168" fill="#991b1b" font-size="9" text-anchor="middle">用户要求与参考状态分开</text>
<text x="610" y="184" fill="#991b1b" font-size="9" text-anchor="middle">system 区分指令与数据</text>
<text x="610" y="200" fill="#ef4444" font-size="9" text-anchor="middle">完整 transcript 保留在磁盘</text>
<!-- Before/After 对比 -->
<rect x="20" y="234" width="320" height="94" rx="6" fill="#fff" stroke="#94a3b8" stroke-width="1"/>
@ -54,19 +54,19 @@
<rect x="149" y="264" width="52" height="16" rx="3" fill="#e2e8f0"/><text x="154" y="276" fill="#475569" font-size="8">user</text>
<rect x="206" y="264" width="52" height="16" rx="3" fill="#e2e8f0"/><text x="211" y="276" fill="#475569" font-size="8">assistant</text>
<rect x="263" y="264" width="52" height="16" rx="3" fill="#e2e8f0"/><text x="268" y="276" fill="#475569" font-size="8">user</text>
<text x="180" y="318" fill="#94a3b8" font-size="9" text-anchor="middle">~180 条消息,占 62K token</text>
<text x="180" y="318" fill="#94a3b8" font-size="9" text-anchor="middle">多条消息,估算 size &gt; 50,000</text>
<line x1="345" y1="281" x2="375" y2="281" stroke="#dc2626" stroke-width="2" marker-end="url(#arrow)"/>
<rect x="380" y="234" width="320" height="94" rx="6" fill="#fef2f2" stroke="#dc2626" stroke-width="1"/>
<text x="540" y="256" fill="#991b1b" font-size="11" font-weight="600" text-anchor="middle">压缩后 messages</text>
<rect x="395" y="264" width="290" height="32" rx="4" fill="#fee2e2" stroke="#fca5a5" stroke-width="0.5"/>
<text x="540" y="276" fill="#991b1b" font-size="9" text-anchor="middle">[Compacted] 摘要:目标 → 创建 hello.py ...</text>
<text x="540" y="290" fill="#991b1b" font-size="9" text-anchor="middle">最近文件hello.py, README.md ...</text>
<text x="540" y="318" fill="#94a3b8" font-size="9" text-anchor="middle">~1 条消息,占 1K token</text>
<text x="540" y="276" fill="#991b1b" font-size="9" text-anchor="middle">Authoritative request入口捕获的用户要求</text>
<text x="540" y="290" fill="#991b1b" font-size="9" text-anchor="middle">Reference state不可信的事实摘要</text>
<text x="540" y="318" fill="#94a3b8" font-size="9" text-anchor="middle">1 条摘要消息,显著低于阈值</text>
<!-- 熔断器 -->
<!-- 错误后补救 -->
<rect x="20" y="340" width="680" height="36" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
<text x="35" y="362" fill="#475569" font-size="11" font-weight="600">熔断器</text>
<text x="95" y="362" fill="#475569" font-size="10">连续 autocompact 失败 3 次 → 停止重试。防止上下文不可恢复时反复浪费 API 调用</text>
<text x="35" y="362" fill="#475569" font-size="11" font-weight="600">错误后补救</text>
<text x="115" y="362" fill="#475569" font-size="10">API 仍返回 prompt_too_long 时执行 reactive_compact并且只重试 1 次</text>
</svg>

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Before After
Before After

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