feat: consolidate course into 21 lessons

This commit is contained in:
Haoran
2026-07-31 03:15:58 +08:00
parent 4bc33ec858
commit 2d69019342
200 changed files with 7338 additions and 10829 deletions

View File

@@ -1,155 +1,251 @@
# s15: Agent Teams — ランタイム実験:永続チームメイト
# s15: Agent Teams — チームランタイムと協調プロトコル
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s13 → s14 → `s15` → [s16](../s16_team_protocols/) → s17 → s18 → s19 → s20 → s21 → s22
> *"一人では無理、チームを組もう"* — ファイル受信箱 + チームメイトスレッド。
s01 → ... → s13 → s14 → `s15` → [s16](../s16_autonomous_agents/) → s17 → s18 → s19 → s20 → s21
> *「1 つの Agent だけでは扱いきれないなら、チームメイトで分担する。」* — 永続チームメイト、メッセージ配信、協調プロトコル。
>
> **Harness 層**: チーム — マルチ Agent 協調、メッセージバス
> **モジュール 1/2** s15 と s16 は一つの Agent Teams モジュールに含まれる二つの集中実験。この章でランタイムを構築し、s16 はランタイムを繰り返さず型付き協調プロトコルを追加する。
> **Harness レイヤー**チーム — 複数 Agent を並行動作させながら制御を保つ
---
##
##
バックエンド全体リファクタリング」は認証モジュール、データベース層、API ルート、テストに及ぶ。一つの Agent が API ルートを修正中、認証モジュールの詳細はコンテキストから外れている。コンテキストウィンドウには限界があり、単一 Agent の注意は全モジュールをカバーできない
Agent にバックエンド全体リファクタリングを頼む場合、設定読み込み、認証、テストを同時に扱うことになる。1 つの Agent が順番に処理することもできるが、時間がかかり、初期の詳細は徐々にコンテキストから抜けていく
s06 のサブ Agent は臨時スタッフ、一つの仕事を終えたら去る。だが、通信でき、協力できるチームメイトが必要なタスクもある。
このような仕事は並列化に向いている。しかし、通常のユーザーはチーム構成ではなく目的だけを伝える:
```text
このサンプルバックエンドをリファクタリングしてください。
設定読み込み、認証ロジック、テストを整理し、
既存インターフェースを保ったままテストを通してください。
```
そのため Harness は、単に Agent を増やすだけでなく、次の 4 点を解決する必要がある:
1. 並列化が有効かを誰が判断し、追加 Agent の起動を誰が確認するか。
2. チームメイトが複数の依頼にまたがって、どう身元とコンテキストを保つか。
3. モデルに受信箱を繰り返し確認させず、結果をどう Lead へ戻すか。
4. 終了と計画承認を、どう追跡可能で強制可能なプロトコルにするか。
---
## ソリューション
## 解決策
![Agent Teams Overview](images/agent-teams-overview.ja.svg)
教学版は S14 の能力プロンプト組み立て、タスクシステム、バックグラウンド実行、cron スケジューリング)を踏襲。チーム機構に集中するため、完全なエラーリカバリ、メモリ、スキルシステムは省略。追加:**MessageBus**(ファイル受信箱)、**spawn_teammate_thread**(チームメイトスレッド起動)、**inbox 注入**Lead がチームメイトメッセージを受信し history に注入)。
s15 は単一 Agent の Harness の外側に、Lead が管理するチームランタイムを追加する:
サブ Agent vs チームメイト:
- **Lead** はユーザーとの会話を維持し、分担案を提示して確認を待つ。
- **チームメイト** は独立した Agent Loop をバックグラウンドスレッドで実行し、作業後は IDLE になる。
- **MessageBus** はファイル受信箱を通して、通常メッセージ、結果、制御イベントを運ぶ。
- **ランタイム配信** は Lead の受信箱を消費し、チームイベントを次のターンへ注入する。
- **協調プロトコル** は `type``request_id`、状態遷移で終了と計画承認を扱う。
- **計画ゲート** は、必要な計画が承認されるまで `bash``write_file` を遮断する。
| | s06 サブ Agent | s15 チームメイト |
|---|---|---|
| ライフサイクル | 一回きり、終了後に破棄 | マルチターン(教学版は 10 ラウンド制限、真实 CC は idle loop |
| 通信 | 結果のみ返却 | 非同期受信箱、いつでも通信可能 |
| コンテキスト | 完全に隔離 | メッセージで情報共有 |
| 数 | メイン Agent + たまにサブ Agent | 1 Lead + 複数チームメイト |
モデルはタスクを理解して分担を決める。コードは配信、ライフサイクル、プロトコル制約を担う。
---
## 仕組み
![Team Topology](images/team-topology.ja.svg)
### 1. Lead はチーム案を示し、確認を待つ
### MessageBus: ファイル受信箱
チームメイトの起動は、コスト、並行度、ワークスペースを書き換える主体を変える。この境界を通常のツール呼び出しの中に隠してはいけない。Lead の system prompt は次のように定める:
各 AgentLead とチームメイトを含む)には `.jsonl` 受信箱がある。メッセージ送信 = 相手のファイルに 1 行 JSON を append。メッセージ読み取り = ファイル読み込み + 削除(消費式):
```python
"When parallel work would help, first propose a small team with clear "
"responsibilities and wait for the user's confirmation. Do not call "
"spawn_teammate before the user confirms."
```
最初の依頼に対して、Lead はまず分担案だけを返す:
```text
次の 3 方向で並行処理することを提案します。
- config設定読み込みの整理
- auth認証ロジックのリファクタリング
- tests回帰テストの追加
確認後にチームメイトを起動します。
```
ユーザーが「始めてください」と返した後で、Lead は `spawn_teammate` を呼ぶ。ユーザーが目的を示し、Lead がチームを設計し、ユーザーが実行境界を確認する。
### 2. 各チームメイトは独立したループを持つ
s06 の Subagent は 1 回限りの呼び出しだが、チームメイトは永続する実行単位である:
| | s06 Subagent | s15 チームメイト |
|---|---|---|
| ライフサイクル | 1 回の呼び出し後に終了 | 終了要求まで `WORK → IDLE → WORK` |
| コンテキスト | 1 つのタスクだけ | 複数の依頼をまたいで保持 |
| 通信 | 1 回だけ結果を返す | メッセージを受け取り、イベントを送る |
| 協調 | 一方向の委任 | Lead との双方向協調 |
`spawn_teammate_thread()` はチームメイトごとに system prompt、messages、ツールを作り、daemon thread でループを実行する。Lead はチームメイトの終了を待たずに、別の依頼や結果を調整できる。
### 3. MessageBus は通信をモデルのコンテキスト外に置く
Lead とチームメイトが同じ messages 配列を共有すると、あるチームメイトのツール結果が別のチームメイトの推論へ混ざる。`MessageBus` は各 Agent に `.mailboxes/<name>.jsonl` 受信箱を与える:
```python
class MessageBus:
def send(self, from_agent: str, to_agent: str,
content: str, msg_type: str = "message"):
msg = {"from": from_agent, "to": to_agent,
"content": content, "type": msg_type,
"ts": time.time()}
inbox = MAILBOX_DIR / f"{to_agent}.jsonl"
with open(inbox, "a") as f:
f.write(json.dumps(msg) + "\n")
def send(self, from_agent, to_agent, content,
msg_type="message", metadata=None):
msg = {
"from": from_agent,
"to": to_agent,
"content": content,
"type": msg_type,
"metadata": metadata or {},
}
with self._changed:
append_jsonl(self._path(to_agent), msg)
self._changed.notify_all()
def read_inbox(self, agent: str) -> list[dict]:
inbox = MAILBOX_DIR / f"{agent}.jsonl"
if not inbox.exists():
return []
msgs = [json.loads(line) for line in inbox.read_text().splitlines()]
inbox.unlink() # 消費式:読んだら削除
return msgs
def wait_for_messages(self, agent):
with self._changed:
while not self.peek(agent):
self._changed.wait()
return self._read_unlocked(agent)
```
なぜファイルか、メモリキューではなく?教学版がファイルを選ぶ理由は、直感的でスレッドをまたいで観察可能だから。真实 CC もファイル受信箱(`~/.claude/teams/{team}/inboxes/`)を使うが、`proper-lockfile` で並行書き込みの安全性を確保。教学版の `read_inbox` には read + unlink の競合状態があり、マルチスレッド同時読みでメッセージを損失する可能性があるが、教学目的には許容範囲
ロックは複数スレッドによる受信箱ファイルの破損を防ぐ。`Condition` により、IDLE のチームメイトはポーリングせずイベント到着まで待機できる
### spawn_teammate_thread: チームメイト起動
### 4. 受信イベントはランタイムが自動配信する
Lead が `spawn_teammate` ツールを呼び出してチームメイトを起動。チームメイトは独自の daemon スレッドで動作、独自の system prompt、messages、簡易ツールセットを持つ
`read_inbox()` はメッセージを読み、受信箱ファイルを削除する。そのため Lead の消費入口は `consume_lead_inbox()` だけにする
```python
def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
system = f"You are '{name}', a {role}. Use tools to complete tasks."
def run():
messages = [{"role": "user", "content": prompt}]
sub_tools = [bash, read_file, write_file, send_message]
for _ in range(10): # 最大 10 ラウンド
inbox = BUS.read_inbox(name)
if inbox:
messages.append({"role": "user",
"content": f"<inbox>{json.dumps(inbox)}</inbox>"})
response = client.messages.create(
model=MODEL, system=system, messages=messages[-20:],
tools=sub_tools, max_tokens=8000)
# ... ツール実行、結果処理
# 完了後 summary を Lead に送信
BUS.send(name, "lead", summary, "result")
threading.Thread(target=run, daemon=True).start()
def consume_lead_inbox():
messages = BUS.read_inbox("lead")
for message in messages:
if message["type"].endswith("_response"):
match_response(...)
return messages
```
重要な設計
- **チームメイトの簡易ツールセット**bash、read、write、send_message。教学版は通信機構に集中するためタスクと cron を省略。真实 CC のチームメイトには TaskCreate、TaskUpdate 等のツールもあり、タスクシステムはチーム全体で共有
- **教学版は 10 ラウンド制限**:無限ループを防止。真实 CC は idle loop1 ラウンド終了後に `idle_notification` を送信、inbox メッセージを待機、到着後に再開、`shutdown_request` でのみ終了
- **完了時自動報告**`BUS.send(name, "lead", summary)` で最終結果を Lead の受信箱に送信
メインループのイベントスレッドは、新しいメッセージが届くと Lead を起こす
### Lead の inbox 注入
```text
MessageBus → consume_lead_inbox
→ プロトコル状態を更新
→ [Team events] を history へ注入
→ Lead の次ターンを開始
```
Lead はメインループの各反復後に受信箱を確認。チームメイトからのメッセージを history に注入し、LLM が確認して反応できるようにする
`check_inbox` はモデルのツールではない。メッセージの到着はランタイムの責務であり、モデルはコンテキストへ配信済みのイベントだけを処理する
### 5. 結果と IDLE は別のイベント
チームメイトが 1 件の作業を終えると、ランタイムは次の順序で 2 つのイベントを送る:
```text
result: "認証をリファクタリングし、関連テストが通りました。"
idle_notification: "Waiting for more work."
```
`result` は「今回の作業で何が得られたか」、`idle_notification` は「新しい仕事を受けられるか」を表す。1 つの曖昧な「done」では両者を区別できない。
IDLE になったチームメイトは終了しない。通常メッセージで WORK に戻り、`shutdown_request` で終了ハンドシェイクを始める。
### 6. 制御メッセージには型と request_id を使う
通常の協調は自由文でよいが、終了と承認を意図の推測に任せてはいけない。制御イベントは構造化する:
![Team Protocols](images/team-protocols-overview.ja.svg)
```python
# メインループ反復後
inbox = BUS.read_inbox("lead")
if inbox:
inbox_text = "\n".join(
f"From {m['from']}: {m['content'][:200]}" for m in inbox)
history.append({"role": "user",
"content": f"[Inbox]\n{inbox_text}"})
@dataclass
class ProtocolState:
request_id: str
type: str
sender: str
target: str
status: str
payload: str
pending_requests: dict[str, ProtocolState] = {}
```
教学版はユーザー入力ループ内で注入。真实 CC はより精密、Lead の `useInboxPoller` が毎秒チェックし、ユーザー入力を待たずにメッセージを新しい turn として送信。
終了プロトコルは次の経路を通る:
### 権限バブリング
教学版は権限バブリングを省略。真实 CC のフロー(`permissionSync.ts``useSwarmPermissionPoller.ts`
1. チームメイトが承認が必要な操作に遭遇 → `permission_request` を Lead の受信箱に送信
2. Lead の `useInboxPoller` がリクエストを検出 → 承認キューにルーティング
3. ユーザーが承認 → Lead が `permission_response` をチームメイトに返信
4. チームメイトの `useSwarmPermissionPoller`500ms ごとにポーリング)が返信を受信 → 続行または拒否
### 組み合わせて実行
```
1. Lead: "バックエンド構築:一人では無理、チームを組もう"
2. Lead → spawn_teammate("alice", "backend dev", "データベーススキーマを作成")
3. Lead → spawn_teammate("bob", "frontend dev", "API クライアントを作成")
4. alice スレッド起動 → 独自の LLM 呼び出し → bash "python manage.py migrate"
5. bob スレッド起動 → 独自の LLM 呼び出し → write_file("client.ts", ...)
6. alice 完了 → BUS.send("alice", "lead", "Schema done: users, orders tables")
7. bob 完了 → BUS.send("bob", "lead", "Client written with types")
8. Lead 次回反復 → inbox を history に注入 → LLM が alice と bob の結果を確認
```text
Lead が pending の shutdown request を作る
→ shutdown_request(request_id) をチームメイトへ送る
→ チームメイトが現在の手順を終える
→ shutdown_response(request_id) を Lead へ返す
→ request_id で元の要求を特定する
→ pending が approved になり、チームメイトループが終了する
```
2 人のチームメイトが並行作業
ID は要求と応答を対応付け、型は誤った応答による状態変更を防ぎ、状態は重複応答の再適用を防ぐ
### 7. 計画承認は実行も制約する
計画プロトコルは逆方向に流れる:
```text
Lead → plan_request
チームメイト → plan_approval_request(request_id, plan)
Lead → plan_approval_response(request_id, approve, feedback)
```
「承認まで待つ」と伝えるだけでは確実なゲートにならない。そこでツール dispatch が計画状態を検査する:
```python
def _run_teammate_tool(name, block, handlers):
gate = plan_gates.get(name, "not_required")
if block.name in {"bash", "write_file"} and gate not in {
"not_required", "approved"
}:
return f"Blocked: plan status is {gate}."
return handlers[block.name](**block.input)
```
状態が `required``pending``rejected` の間、チームメイトはファイルを読み、計画を提出または修正できるが、Shell 実行やファイル書き込みはできない。承認応答で `approved` になった後にだけツールが解放される。
---
## 一連の実行例
```text
s15 >> このサンプルバックエンドをリファクタリングしてください。
設定読み込み、認証、テストを整理し、
既存インターフェースを保ってテストを通してください。
Lead: config、auth、tests の 3 方向で並行処理することを提案します。
チームを開始しますか?
s15 >> 始めてください
[teammate] config spawned
[teammate] auth spawned
[teammate] tests spawned
[bus] auth → lead (result) ...
[bus] auth → lead (idle_notification) ...
[wake: 2 team events → new turn]
Lead: 認証の結果を受け取りました。残りの作業も調整します。
```
端末には、ユーザー要求、Lead の分担、起動、メッセージ、結果、IDLE、終了イベントが表示される。ユーザーが Lead を指名したり、受信箱の確認を頼んだりする必要はない。
---
## s14 からの変更
| コンポーネント | 変更前 (s14) | 変更後 (s15) |
|--------------|------------|------------|
| Agent | 1 | 1 Lead + N チームメイトスレッド |
| 通信 | なし | MessageBus + .mailboxes/*.jsonl |
| 新規クラス | | MessageBus, active_teammates dict |
| 新規関数 | — | spawn_teammate_thread, run_send_message, run_check_inbox |
| Lead ツール | 11 (s14) | + spawn_teammate, send_message, check_inbox (14) |
| チームメイトツール | | bash, read_file, write_file, send_message (4) |
| 権限 | ローカル判断 | 教学版は省略(真实 CC はバブリング機構あり) |
| コンポーネント | s14 | s15 |
|---|---|---|
| Agent | 1 | 1 つの Lead + 永続チームメイト |
| ユーザーフロー | 依頼を直接実行 | チーム案を提示してから起動を確認 |
| 通信 | なし | ファイル受信箱 + 自動イベント配信 |
| ライフサイクル | 1 つのループ | チームメイトの `WORK / IDLE / shutdown` |
| 結果通知 | 現在の Agent の出力 | `result``idle_notification` を分離 |
| 制御 | なし | 終了と計画承認プロトコル |
| 強制 | チーム制約なし | 必須計画が変更系ツールをゲート |
---
@@ -160,97 +256,28 @@ cd learn-claude-code
python s15_agent_teams/code.py
```
以下のプロンプトを試してください
まず通常の依頼を入力する
1. `Spawn alice as a backend developer. Ask her to create a file called schema.sql with a users table.`
2. `Check your inbox for alice's result.`
3. `Spawn bob as a tester. Ask him to check if schema.sql exists and list its contents.`
```text
このサンプルバックエンドをリファクタリングしてください。
設定読み込み、認証ロジック、テストを整理し、
既存インターフェースを保ったままテストを通してください。
```
観察ポイント:Lead チームメイトをどう起動するか?`.mailboxes/` ディレクトリの JSONL ファイルの中身はチームメイト完了後、Lead の inbox は history に注入されているか?
Lead チーム案を示したら、次のように返す:
```text
始めてください
```
`spawned``result``idle_notification``plan_approval_*``shutdown_*` の各イベントと、`.mailboxes/` のファイルが生成・消費される流れを確認する。
---
## 次の章
## 次
チームメイトは仕事をし、通信できる。しかし、Lead が Alice にシャットダウンを頼む場合、スレッドを強制終了すると書きかけのファイルが残る。丁寧なシャットダウンプロトコルが必要Lead が shutdown_request を送信、チームメイトは收尾後に終了
s15 では、Lead が各チームメイトへ明示的に仕事を割り当てる。次のセッションでは共有タスクボードを IDLE のチームメイトに公開し、実行可能な仕事を自ら見つけて claim できるようにする
s16 Agent Teams プロトコル実験 → このランタイムにシャットダウンハンドシェイク、計画承認、型付きリクエスト-返信を追加する
次へ:[s16 Autonomous Agents](../s16_autonomous_agents/)
<details>
<summary>CC ソースコード深掘り</summary>
> 以下は CC ソースコード `spawnMultiAgent.ts`、`useInboxPoller.ts`969 行)、`useSwarmPermissionPoller.ts`330 行)、`teammateMailbox.ts`、`teamHelpers.ts` の完全分析に基づく。
### 一、中央メッセージバスはない、ファイルシステム
教学版は `MessageBus` クラスでメッセージを送受信。真实 CC はもっと直接的、各 Agent が他の Agent の受信箱ファイルに直接書き込む。
受信箱パス:`~/.claude/teams/{teamName}/inboxes/{agentName}.json`
書き込み時は `proper-lockfile` で並行安全性を確保(最大 10 回リトライ)。各ファイルは JSON 配列、append 時に読み取り→追加→書き戻し。
### 二、15 種のメッセージ型
CC のチーム通信には 15 種の構造化メッセージ(`teammateMailbox.ts`)がある:
| 型 | 方向 | 用途 |
|------|------|------|
| `plain text` | 双方向 | 通常のチームメイト間通信 |
| `idle_notification` | チームメイト→Lead | チームメイトが 1 ターン完了、アイドル状態に |
| `permission_request` | チームメイト→Lead | 操作承認が必要 |
| `permission_response` | Lead→チームメイト | Lead の承認結果 |
| `plan_approval_request` | チームメイト→Lead | 計画提出、審査待ち |
| `plan_approval_response` | Lead→チームメイト | Lead の計画審査 |
| `shutdown_request` | Lead→チームメイト | 丁寧なシャットダウン要求 |
| `shutdown_approved` | チームメイト→Lead | シャットダウン確認 |
| `shutdown_rejected` | チームメイト→Lead | シャットダウン拒否(理由付き) |
| `task_assignment` | Lead→チームメイト | タスク割り当て |
| `team_permission_update` | Lead→チームメイト | 権限変更のブロードキャスト |
| `mode_set_request` | Lead→チームメイト | チームメイトの権限モード変更 |
| `sandbox_permission_*` | 双方向 | ネットワーク権限リクエスト/返信 |
| `teammate_terminated` | システム | チームメイト削除通知 |
テキストメッセージは `<teammate-message>` XML タグでラップされモデルに配信。
### 三、権限バブリング:双方向ポーリング
教学版は権限バブリングを省略。真实 CC のフロー(`permissionSync.ts`
1. **チームメイト**が承認が必要な操作に遭遇 → `permission_request` を Lead の受信箱に送信
2. **Lead**`useInboxPoller`1 秒ごとにポーリング)がリクエストを検出 → `ToolUseConfirmQueue` にルーティング
3. Lead の UI にチームメイト名と色付きの承認ダイアログを表示
4. ユーザー承認後 → Lead が `permission_response` をチームメイトの受信箱に返信
5. **チームメイト**の `useSwarmPermissionPoller`500ms ごとにポーリング)が返信を受信 → 続行または拒否
### 四、チームメイトライフサイクル
CC のチームメイトは `spawnTeammate()``spawnMultiAgent.ts`)で作成:
1. **Spawn**tmux ペインまたはプロセス内を作成、色を割り当て、team config に書き込み
2. **Work**`useInboxPoller` が毎秒受信箱をチェック → メッセージ到着時に新しい turn として送信
3. **Idle**Stop hook 発火 → `idle_notification` を Lead に送信
4. **Shutdown**Lead が `shutdown_request` を送信 → チームメイトが `shutdown_approved` で返信 → Lead がクリーンアップ
### 五、Team Config
チーム登録は `~/.claude/teams/{teamName}/config.json``teamHelpers.ts`
```json
{
"name": "my-team",
"leadAgentId": "lead@my-team",
"members": [{
"agentId": "researcher@my-team",
"name": "researcher",
"agentType": "general-purpose",
"color": "blue",
"isActive": true
}]
}
```
チームメイトのネストは禁止(`AgentTool.tsx:273` で "teammates spawning other teammates" を明示的に禁止)。
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->

View File

@@ -1,21 +1,33 @@
# s15: Agent Teams — Runtime Lab: Persistent Teammates
# s15: Agent Teams — Runtime and Coordination Protocols
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s13 → s14 → `s15` → [s16](../s16_team_protocols/) → s17 → s18 → s19 → s20 → s21 → s22
> *"One agent isn't enough, form a team"* — File-based inboxes + teammate threads.
>
> **Harness Layer**: Teams — Multi-agent collaboration, message bus.
s01 → ... → s13 → s14 → `s15` → [s16](../s16_autonomous_agents/) → s17 → s18 → s19 → s20 → s21
> **Module 1 of 2:** s15 and s16 are two focused labs in one Agent Teams module. This lab builds the runtime; s16 adds typed coordination protocols without repeating the runtime.
> *"When one agent cannot hold the whole job, let teammates divide the work."* — Persistent teammates, message delivery, and coordination protocols.
>
> **Harness layer**: Team — how multiple agents work in parallel without losing control.
---
## The Problem
"Refactor the entire backend" touches auth, database layer, API routes, and tests. One agent working on API routes no longer has auth module details in context. The context window is limited, a single agent can't cover every module.
Suppose we ask an agent to refactor an entire backend. The work may cover configuration loading, authentication, and tests. One agent can process those areas sequentially, but it takes longer and earlier details gradually leave its context.
s06's sub-agents are temps, called in for one job, then gone. Some tasks need teammates that can communicate and collaborate.
This is a good candidate for parallel work, yet users normally describe the goal rather than design the team:
```text
Refactor this sample backend. Clean up configuration loading,
authentication, and tests, preserve the existing interfaces,
and make sure the tests pass.
```
The harness therefore has to solve four connected problems:
1. Who decides that parallel work is useful, and who confirms the extra agents?
2. How does each teammate keep its identity and context across assignments?
3. How do results return to Lead automatically, without asking the model to poll an inbox?
4. How do shutdown and plan approval become traceable, enforceable protocols?
---
@@ -23,133 +35,217 @@ s06's sub-agents are temps, called in for one job, then gone. Some tasks need te
![Agent Teams Overview](images/agent-teams-overview.en.svg)
Teaching code carries forward S14's capabilities (prompt assembly, task system, background execution, cron scheduling). To stay focused on the team mechanism, it omits full error recovery, memory, and skill systems. Added: **MessageBus** (file-based inboxes), **spawn_teammate_thread** (launch teammate threads), **inbox injection** (Lead receives teammate messages and injects into history).
s15 adds a Lead-managed team runtime around the single-agent harness:
Sub-agent vs Teammate:
- **Lead** owns the user conversation, proposes a division of work, and waits for confirmation.
- **Teammates** run independent agent loops in background threads and become idle after an assignment.
- **MessageBus** carries ordinary messages, results, and control events through file-backed mailboxes.
- **Runtime delivery** consumes Lead's mailbox and injects team events into the next turn.
- **Coordination protocols** use `type`, `request_id`, and state transitions for shutdown and plan approval.
- **A plan gate** blocks teammate `bash` and `write_file` calls until a required plan is approved.
| | s06 Sub-agent | s15 Teammate |
|---|---|---|
| Lifetime | One-shot, destroyed after use | Multi-turn (teaching: 10 rounds; real CC: idle loop) |
| Communication | Only returns conclusion | Async inbox, communicate anytime |
| Context | Fully isolated | Shared via messages |
| Count | One lead + occasional sub-agent | One Lead + multiple teammates |
The model understands tasks and chooses a useful division of work. Code owns delivery, lifecycle, and protocol constraints.
---
## How It Works
![Team Topology](images/team-topology.en.svg)
### 1. Lead proposes a team and waits for confirmation
### MessageBus: File-Based Inboxes
Starting teammates changes cost, concurrency, and the set of actors that may edit the workspace. That boundary should not be hidden inside an ordinary tool call. Lead's system prompt says:
Each agent (including Lead and teammates) has a `.jsonl` inbox. Send = append a JSON line to the target's file. Read = read file + delete (consumption):
```python
"When parallel work would help, first propose a small team with clear "
"responsibilities and wait for the user's confirmation. Do not call "
"spawn_teammate before the user confirms."
```
For the first request, Lead only proposes a split:
```text
I suggest three parallel areas:
- config: clean up configuration loading
- auth: refactor authentication
- tests: add regression coverage
I will start the teammates after you confirm.
```
After the user says "Go ahead," Lead can call `spawn_teammate`. The user states the goal, Lead designs the team, and the user confirms the execution boundary.
### 2. Every teammate owns an independent loop
An s06 subagent is a one-shot call. A teammate is a persistent execution unit:
| | s06 Subagent | s15 Teammate |
|---|---|---|
| Lifecycle | Ends after one call | `WORK → IDLE → WORK` until shutdown |
| Context | Exists for one task | Persists across assignments |
| Communication | Returns one result | Receives messages and emits events |
| Coordination | One-way delegation | Two-way collaboration with Lead |
`spawn_teammate_thread()` gives each teammate its own system prompt, messages, and tools, then runs its loop in a daemon thread. Lead can keep coordinating while teammates work.
### 3. MessageBus keeps communication outside model context
Lead and teammates cannot share one messages array. Otherwise one teammate's tool results would leak into another teammate's reasoning. `MessageBus` gives each agent a `.mailboxes/<name>.jsonl` inbox:
```python
class MessageBus:
def send(self, from_agent: str, to_agent: str,
content: str, msg_type: str = "message"):
msg = {"from": from_agent, "to": to_agent,
"content": content, "type": msg_type,
"ts": time.time()}
inbox = MAILBOX_DIR / f"{to_agent}.jsonl"
with open(inbox, "a") as f:
f.write(json.dumps(msg) + "\n")
def send(self, from_agent, to_agent, content,
msg_type="message", metadata=None):
msg = {
"from": from_agent,
"to": to_agent,
"content": content,
"type": msg_type,
"metadata": metadata or {},
}
with self._changed:
append_jsonl(self._path(to_agent), msg)
self._changed.notify_all()
def read_inbox(self, agent: str) -> list[dict]:
inbox = MAILBOX_DIR / f"{agent}.jsonl"
if not inbox.exists():
return []
msgs = [json.loads(line) for line in inbox.read_text().splitlines()]
inbox.unlink() # consume: read + delete
return msgs
def wait_for_messages(self, agent):
with self._changed:
while not self.peek(agent):
self._changed.wait()
return self._read_unlocked(agent)
```
Why files instead of in-memory queues? Teaching code uses files because they're intuitive and observable across threads. Real CC also uses file inboxes (`~/.claude/teams/{team}/inboxes/`) but adds `proper-lockfile` for concurrent write safety. The teaching version's `read_inbox` has a read + unlink race, concurrent reads could lose messages, acceptable for teaching purposes.
A lock protects mailbox files from concurrent teammate access. A `Condition` lets idle teammates sleep until an event arrives instead of polling continuously.
### spawn_teammate_thread: Launching a Teammate
### 4. The runtime delivers inbox events automatically
Lead calls the `spawn_teammate` tool to start a teammate. The teammate runs in its own daemon thread with its own system prompt, messages, and simplified tool set:
`read_inbox()` consumes messages by reading and deleting the mailbox file, so Lead keeps a single consumer, `consume_lead_inbox()`:
```python
def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
system = f"You are '{name}', a {role}. Use tools to complete tasks."
def run():
messages = [{"role": "user", "content": prompt}]
sub_tools = [bash, read_file, write_file, send_message]
for _ in range(10): # max 10 rounds
inbox = BUS.read_inbox(name)
if inbox:
messages.append({"role": "user",
"content": f"<inbox>{json.dumps(inbox)}</inbox>"})
response = client.messages.create(
model=MODEL, system=system, messages=messages[-20:],
tools=sub_tools, max_tokens=8000)
# ... execute tools, process results
# Send final summary to Lead
BUS.send(name, "lead", summary, "result")
threading.Thread(target=run, daemon=True).start()
def consume_lead_inbox():
messages = BUS.read_inbox("lead")
for message in messages:
if message["type"].endswith("_response"):
match_response(...)
return messages
```
Key design:
- **Simplified tool set**: bash, read, write, send_message. Teaching code omits tasks and cron to focus on communication. Real CC teammates also have TaskCreate, TaskUpdate, etc., the task system is shared across the team
- **Teaching: 10 rounds max**: prevents infinite loops. Real CC uses idle loop: after each round, send `idle_notification`, wait for inbox messages, resume on arrival, exit only on `shutdown_request`
- **Auto-report on completion**: `BUS.send(name, "lead", summary)` sends the final result to Lead's inbox
An event thread beside the main loop wakes Lead when a new message arrives:
### Lead's Inbox Injection
```text
MessageBus → consume_lead_inbox
→ update protocol state
→ inject [Team events] into history
→ start another Lead turn
```
Lead checks inbox after each main loop iteration. Teammate messages are injected into history so the LLM can see and react to them:
`check_inbox` is not a model tool. Message arrival belongs to the runtime; the model only handles events that have already been delivered into its context.
### 5. Result and idle are separate events
When a teammate finishes one assignment, the runtime sends two events in order:
```text
result: "Authentication refactored; related tests pass."
idle_notification: "Waiting for more work."
```
`result` answers "What did this assignment produce?" `idle_notification` answers "Can this teammate accept more work?" A single vague "done" cannot represent both facts.
An idle teammate does not exit. An ordinary message returns it to WORK; a `shutdown_request` starts a graceful shutdown handshake.
### 6. Control messages use types and request IDs
Free-form text is fine for ordinary collaboration, but shutdown and approval should not depend on guessing intent. They use structured messages:
![Team Protocols](images/team-protocols-overview.en.svg)
```python
# After main loop iteration
inbox = BUS.read_inbox("lead")
if inbox:
inbox_text = "\n".join(
f"From {m['from']}: {m['content'][:200]}" for m in inbox)
history.append({"role": "user",
"content": f"[Inbox]\n{inbox_text}"})
@dataclass
class ProtocolState:
request_id: str
type: str
sender: str
target: str
status: str
payload: str
pending_requests: dict[str, ProtocolState] = {}
```
Teaching code injects in the user input loop. Real CC is more refined, Lead's `useInboxPoller` checks every 1 second, submitting messages as new turns without waiting for user input.
The shutdown path is:
### Permission Bubbling
Teaching code omits permission bubbling. Real CC's flow (`permissionSync.ts`, `useSwarmPermissionPoller.ts`):
1. Teammate encounters an operation needing approval → sends `permission_request` to Lead's inbox
2. Lead's `useInboxPoller` detects the request → routes to approval queue
3. User approves → Lead sends `permission_response` back to teammate
4. Teammate's `useSwarmPermissionPoller` (polls every 500ms) receives reply → continue or reject
### Putting It Together
```
1. Lead: "Build the backend: one agent isn't enough, form a team"
2. Lead → spawn_teammate("alice", "backend dev", "Create database schema")
3. Lead → spawn_teammate("bob", "frontend dev", "Write API client")
4. Alice thread starts → her own LLM call → bash "python manage.py migrate"
5. Bob thread starts → his own LLM call → write_file("client.ts", ...)
6. Alice done → BUS.send("alice", "lead", "Schema done: users, orders tables")
7. Bob done → BUS.send("bob", "lead", "Client written with types")
8. Lead next iteration → inbox injected into history → LLM sees both results
```text
Lead creates a pending shutdown request
→ shutdown_request(request_id) enters the teammate inbox
→ the teammate finishes its current step
→ shutdown_response(request_id) returns to Lead
→ request_id locates the original request
→ pending becomes approved and the teammate loop exits
```
Two teammates work in parallel.
The ID correlates one reply with one request, the type prevents a mismatched reply from changing state, and the status prevents duplicate responses from being applied twice.
### 7. Plan approval constrains execution
The plan protocol runs in the opposite direction:
```text
Lead → plan_request
teammate → plan_approval_request(request_id, plan)
Lead → plan_approval_response(request_id, approve, feedback)
```
Merely telling a teammate to wait is not a reliable gate, so tool dispatch checks the plan state:
```python
def _run_teammate_tool(name, block, handlers):
gate = plan_gates.get(name, "not_required")
if block.name in {"bash", "write_file"} and gate not in {
"not_required", "approved"
}:
return f"Blocked: plan status is {gate}."
return handlers[block.name](**block.input)
```
While the state is `required`, `pending`, or `rejected`, the teammate can read files and submit or revise a plan, but it cannot run Shell commands or write files. The tools are released only after an approval response changes the state to `approved`.
---
## Changes from s14
## One Complete Run
| Component | Before (s14) | After (s15) |
|-----------|-------------|-------------|
| Agent count | 1 | 1 Lead + N teammate threads |
| Communication | None | MessageBus + .mailboxes/*.jsonl |
| New classes | — | MessageBus, active_teammates dict |
| New functions | — | spawn_teammate_thread, run_send_message, run_check_inbox |
| Lead tools | 11 (s14) | + spawn_teammate, send_message, check_inbox (14) |
| Teammate tools | — | bash, read_file, write_file, send_message (4) |
| Permissions | Local decisions | Teaching code omits (real CC has bubbling) |
```text
s15 >> Refactor this sample backend. Clean up configuration loading,
authentication, and tests, preserve existing interfaces,
and make sure the tests pass.
Lead: I suggest config, auth, and tests as three parallel areas.
Shall I start the team?
s15 >> Go ahead.
[teammate] config spawned
[teammate] auth spawned
[teammate] tests spawned
[bus] auth → lead (result) ...
[bus] auth → lead (idle_notification) ...
[wake: 2 team events → new turn]
Lead: I received the authentication result and will coordinate the rest.
```
The terminal exposes the user request, Lead's split, teammate startup, messages, results, idle transitions, and shutdown events. The user does not have to name a Lead or ask it to check an inbox.
---
## What Changed from s14
| Component | s14 | s15 |
|---|---|---|
| Agents | One agent | One Lead plus persistent teammates |
| User flow | Execute the request | Propose a team, then confirm startup |
| Communication | None | File mailboxes plus automatic delivery |
| Lifecycle | One loop | Teammate `WORK / IDLE / shutdown` |
| Reporting | Current agent output | Separate `result` and `idle_notification` |
| Control | None | Shutdown and plan approval protocols |
| Enforcement | No team constraint | Required plans gate mutating tools |
---
@@ -160,97 +256,28 @@ cd learn-claude-code
python s15_agent_teams/code.py
```
Try these prompts:
Start with an ordinary request:
1. `Spawn alice as a backend developer. Ask her to create a file called schema.sql with a users table.`
2. `Check your inbox for alice's result.`
3. `Spawn bob as a tester. Ask him to check if schema.sql exists and list its contents.`
```text
Refactor this sample backend. Clean up configuration loading,
authentication, and tests, preserve the existing interfaces,
and make sure the tests pass.
```
What to observe: How does Lead spawn teammates? What do the `.mailboxes/` JSONL files look like? After teammates finish, is Lead's inbox injected into history?
After Lead proposes the team, reply:
```text
Go ahead.
```
Watch for `spawned`, `result`, `idle_notification`, `plan_approval_*`, and `shutdown_*` events, along with mailbox files appearing and being consumed under `.mailboxes/`.
---
## What's Next
## Next
Teammates can work and communicate. But if Lead wants Alice to shut down, killing the thread outright could leave half-written files. A graceful shutdown protocol is needed: Lead sends shutdown_request, teammate wraps up and exits.
In s15, Lead still assigns each teammate explicitly. The next lesson gives idle teammates access to the shared task board so they can discover and claim ready work themselves.
s16 Agent Teams Protocol Lab → keep this runtime and add shutdown handshakes, plan approval, and typed request-reply messages.
Next: [s16 Autonomous Agents](../s16_autonomous_agents/).
<details>
<summary>Deep Dive into CC Source</summary>
> The following is a complete analysis based on CC source code `spawnMultiAgent.ts`, `useInboxPoller.ts` (969 lines), `useSwarmPermissionPoller.ts` (330 lines), `teammateMailbox.ts`, `teamHelpers.ts`.
### 1. No Central Message Bus, It's the Filesystem
Teaching code uses a `MessageBus` class to send and receive messages. Real CC is more direct, each agent writes directly to other agents' inbox files.
Inbox path: `~/.claude/teams/{teamName}/inboxes/{agentName}.json`
Writes use `proper-lockfile` for concurrent write safety (up to 10 retries). Each file is a JSON array; appending reads → appends → writes back.
### 2. 15 Message Types
CC team communication has 15 structured message types (`teammateMailbox.ts`):
| Type | Direction | Purpose |
|------|-----------|---------|
| `plain text` | Both ways | Normal inter-teammate communication |
| `idle_notification` | Teammate→Lead | Teammate finished a turn, now idle |
| `permission_request` | Teammate→Lead | Teammate needs operation approval |
| `permission_response` | Lead→Teammate | Lead's approval result |
| `plan_approval_request` | Teammate→Lead | Teammate submits plan for review |
| `plan_approval_response` | Lead→Teammate | Lead's plan review |
| `shutdown_request` | Lead→Teammate | Request graceful shutdown |
| `shutdown_approved` | Teammate→Lead | Confirm shutdown |
| `shutdown_rejected` | Teammate→Lead | Reject shutdown (with reason) |
| `task_assignment` | Lead→Teammate | Assign a task |
| `team_permission_update` | Lead→Teammate | Broadcast permission changes |
| `mode_set_request` | Lead→Teammate | Change teammate's permission mode |
| `sandbox_permission_*` | Both ways | Network permission request/reply |
| `teammate_terminated` | System | Teammate removed notification |
Text messages are wrapped in `<teammate-message>` XML tags for delivery to the model.
### 3. Permission Bubbling: Bidirectional Polling
Teaching code omits permission bubbling. Real CC's flow (`permissionSync.ts`):
1. **Teammate** encounters operation needing approval → sends `permission_request` to Lead's inbox
2. **Lead's** `useInboxPoller` (polls every 1s) detects request → routes to `ToolUseConfirmQueue`
3. Lead's UI shows approval dialog with teammate name and color
4. User approves → Lead sends `permission_response` back to teammate's inbox
5. **Teammate's** `useSwarmPermissionPoller` (polls every 500ms) receives reply → continue or reject
### 4. Teammate Lifecycle
CC teammates are created by `spawnTeammate()` (`spawnMultiAgent.ts`):
1. **Spawn**: Create tmux pane (or in-process), assign color, write team config
2. **Work**: `useInboxPoller` checks inbox every 1s → submit as new turn when messages arrive
3. **Idle**: Stop hook fires → send `idle_notification` to Lead
4. **Shutdown**: Lead sends `shutdown_request` → teammate replies `shutdown_approved` → Lead cleans up
### 5. Team Config
Team registry at `~/.claude/teams/{teamName}/config.json` (`teamHelpers.ts`):
```json
{
"name": "my-team",
"leadAgentId": "lead@my-team",
"members": [{
"agentId": "researcher@my-team",
"name": "researcher",
"agentType": "general-purpose",
"color": "blue",
"isActive": true
}]
}
```
Teammates cannot be nested (`AgentTool.tsx:273` explicitly forbids "teammates spawning other teammates").
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->

View File

@@ -1,21 +1,32 @@
# s15: Agent Teams — 运行时实验:持久队友
# s15: Agent Teams — 团队运行时与协作协议
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → ... → s13 → s14 → `s15` → [s16](../s16_team_protocols/) → s17 → s18 → s19 → s20 → s21 → s22
> *"一个搞不定, 组队来"* — 文件收件箱 + 队友线程。
>
> **Harness 层**: 团队 — 多 Agent 协作, 消息总线。
s01 → ... → s13 → s14 → `s15` → [s16](../s16_autonomous_agents/) → s17 → s18 → s19 → s20 → s21
> **模块 1/2** s15 与 s16 是同一个 Agent Teams 模块中的两次聚焦实验。本章搭建运行时s16 在不重复运行时的前提下增加带类型的协作协议。
> *"一个 Agent 顾不过来,就让队友分工协作。"* — 持久队友、消息投递与协作协议。
>
> **Harness 层**:团队 — 多个 Agent 如何并行工作,又如何保持可控。
---
## 问题
"重构整个后端"涉及认证模块、数据库层、API 路由、测试。一个 Agent 在修 API 路由时,认证模块的细节已经不在上下文里了。上下文窗口就那么大,单个 Agent 的注意力覆盖不了所有模块
当我们需要 Agent 帮助我们重构整个后端时,任务可能同时涉及配置加载、认证逻辑和测试。一个 Agent 依次处理所有模块,不但耗时更长,早期细节也会逐渐退出上下文
s06 的子 Agent 是临时工,叫来干一件事就走了。但有些任务需要能通信、能协作的队友。
这类任务适合拆给多个 Agent但用户通常只会描述需求不会先设计一套团队
```text
请重构这个示例后端,分别整理配置加载、认证逻辑和测试,
保持现有接口兼容,并确保测试通过。
```
因此Harness 需要解决的不只是“再启动几个 Agent”而是四个连续问题
1. 谁判断任务是否值得并行,以及如何征得用户确认?
2. 队友如何保留自己的身份和上下文,持续接收工作?
3. 队友的结果如何自动回到 Lead而不是依赖模型反复检查邮箱
4. 关机与计划审批如何变成可追踪、可执行的协议?
---
@@ -23,133 +34,214 @@ s06 的子 Agent 是临时工,叫来干一件事就走了。但有些任务需
![Agent Teams Overview](images/agent-teams-overview.svg)
教学代码沿用 S14 的能力prompt 组装、任务系统、后台执行、cron 调度)。为了聚焦团队机制,省略了完整错误恢复、记忆和技能系统。新增三样:**MessageBus**(文件收件箱)、**spawn_teammate_thread**(启动队友线程)、**inbox 注入**Lead 接收队友消息并注入 history
s15 在单 Agent Harness 外增加一个由 Lead 管理的团队运行时:
子 Agent vs 队友:
- **Lead** 保持用户对话,判断是否需要团队,提出分工并等待确认。
- **队友** 在独立线程中运行自己的 Agent Loop完成工作后进入空闲。
- **MessageBus** 用文件邮箱传递普通消息、结果和控制事件。
- **运行时投递** 自动消费 Lead 的邮箱,把团队事件注入下一轮上下文。
- **协作协议** 用 `type``request_id` 和状态机处理关机与计划审批。
- **计划闸门** 在计划未批准时拦截队友的 `bash``write_file`
| | s06 子 Agent | s15 队友 |
|---|---|---|
| 生命周期 | 一次性,用完销毁 | 多轮(教学版限 10 轮,真实 CC 用 idle loop |
| 通信 | 只回传结论 | 异步收件箱,随时通信 |
| 上下文 | 完全隔离 | 通过消息共享信息 |
| 数量 | 一个主 Agent + 偶尔子 Agent | 一个 Lead + 多个队友 |
模型负责理解任务与分工,代码负责消息投递、生命周期和协议约束。
---
## 工作原理
![Team Topology](images/team-topology.svg)
### 1. Lead 先提出团队,再等待用户确认
### MessageBus: 文件收件箱
是否创建团队会改变成本、并发度和可写入范围不应该被隐藏在一次普通工具调用里。Lead 的 system prompt 明确规定:
每个 Agent包括 Lead 和队友)有一个 `.jsonl` 邮箱。发消息 = 往对方的文件里 append 一行 JSON。读消息 = 读文件 + 删除(消费式):
```python
"When parallel work would help, first propose a small team with clear "
"responsibilities and wait for the user's confirmation. Do not call "
"spawn_teammate before the user confirms."
```
第一次输入需求时Lead 只需要说明建议的拆分:
```text
我建议分成三个方向并行处理:
- config整理配置加载
- auth重构认证逻辑
- tests补齐回归测试
确认后我会启动队友并协调结果。
```
用户回复“开始吧”后Lead 才调用 `spawn_teammate`。用户表达目标Lead 设计团队,用户确认执行边界;三者的职责不会混在一起。
### 2. 每个队友拥有独立循环
s06 的子 Agent 是一次性调用,返回结果后就结束。队友则是持久执行单元:
| | s06 子 Agent | s15 队友 |
|---|---|---|
| 生命周期 | 完成一次调用后结束 | `WORK → IDLE → WORK`,直到收到关机请求 |
| 上下文 | 只服务当前任务 | 在多轮协作中保留 |
| 通信 | 返回一次结果 | 持续接收消息并上报事件 |
| 协调 | 主 Agent 单向委派 | Lead 与队友双向协作 |
`spawn_teammate_thread()` 为队友创建独立的 system prompt、messages 和工具集,并把循环放入 daemon 线程。Lead 不必等待某个队友结束,仍可继续派发任务或处理其他结果。
### 3. MessageBus 把通信放在上下文之外
Lead 和队友不能共享同一份 messages否则一个队友的工具结果会混入另一个队友的推理。`MessageBus` 为每个 Agent 建立 `.mailboxes/<name>.jsonl`
```python
class MessageBus:
def send(self, from_agent: str, to_agent: str,
content: str, msg_type: str = "message"):
msg = {"from": from_agent, "to": to_agent,
"content": content, "type": msg_type,
"ts": time.time()}
inbox = MAILBOX_DIR / f"{to_agent}.jsonl"
with open(inbox, "a") as f:
f.write(json.dumps(msg) + "\n")
def send(self, from_agent, to_agent, content,
msg_type="message", metadata=None):
msg = {
"from": from_agent,
"to": to_agent,
"content": content,
"type": msg_type,
"metadata": metadata or {},
}
with self._changed:
append_jsonl(self._path(to_agent), msg)
self._changed.notify_all()
def read_inbox(self, agent: str) -> list[dict]:
inbox = MAILBOX_DIR / f"{agent}.jsonl"
if not inbox.exists():
return []
msgs = [json.loads(line) for line in inbox.read_text().splitlines()]
inbox.unlink() # 消费式:读完删除
return msgs
def wait_for_messages(self, agent):
with self._changed:
while not self.peek(agent):
self._changed.wait()
return self._read_unlocked(agent)
```
为什么用文件而不是内存队列?教学版选文件是因为直观、跨线程可观察。真实 CC 也用文件收件箱(`~/.claude/teams/{team}/inboxes/`),但加了 `proper-lockfile` 防并发写冲突。教学版的 `read_inbox` 有 read + unlink 竞态,多线程同时读可能丢消息,对教学场景可以接受
锁保证同一进程中的多个队友不会同时破坏邮箱文件,`Condition` 让空闲队友等待事件,而不是持续轮询
### spawn_teammate_thread: 启动队友
### 4. 收件箱由运行时自动投递
Lead 调用 `spawn_teammate` 工具启动一个队友。队友跑在自己的 daemon 线程里,有自己的 system prompt、自己的 messages、自己的简化工具集
`read_inbox()` 是消费式读取读出后删除邮箱文件。因此Lead 只保留一个消费入口 `consume_lead_inbox()`
```python
def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
system = f"You are '{name}', a {role}. Use tools to complete tasks."
def run():
messages = [{"role": "user", "content": prompt}]
sub_tools = [bash, read_file, write_file, send_message]
for _ in range(10): # 最多 10 轮
inbox = BUS.read_inbox(name)
if inbox:
messages.append({"role": "user",
"content": f"<inbox>{json.dumps(inbox)}</inbox>"})
response = client.messages.create(
model=MODEL, system=system, messages=messages[-20:],
tools=sub_tools, max_tokens=8000)
# ... 执行工具、处理结果
# 完成后发 summary 给 Lead
BUS.send(name, "lead", summary, "result")
threading.Thread(target=run, daemon=True).start()
def consume_lead_inbox():
messages = BUS.read_inbox("lead")
for message in messages:
if message["type"].endswith("_response"):
match_response(...)
return messages
```
关键设计
- **队友有简化工具集**bash、read、write、send_message。教学版省略了任务和 cron聚焦通信机制。真实 CC 的队友也有 TaskCreate、TaskUpdate 等工具,任务系统是团队共享的
- **教学版限 10 轮**:防止队友无限循环。真实 CC 用 idle loop跑完一轮后发 `idle_notification`,等 inbox 消息,收到后继续,直到 `shutdown_request` 才退出
- **完成后自动汇报**`BUS.send(name, "lead", summary)` 把最终结果发到 Lead 的收件箱
主循环旁的事件线程发现新消息后,会唤醒 Lead
### Lead 的 inbox 注入
```text
MessageBus → consume_lead_inbox
→ 更新协议状态
→ [Team events] 注入 history
→ Lead 开始新一轮
```
Lead 在每轮主循环结束后检查收件箱。队友发来的消息注入到 history 里,让 LLM 能看到并做出反应:
`check_inbox` 不再是模型工具。消息何时到达属于运行时职责;模型只需要处理已经送入上下文的事件。
### 5. 结果与空闲是两个不同事件
队友完成一项工作时,运行时依次发送:
```text
result: "认证逻辑已重构,相关测试通过。"
idle_notification: "Waiting for more work."
```
`result` 回答“这次工作产出了什么”,`idle_notification` 表示“这个队友现在可以接新任务”。如果把两者合成一个模糊的“done”Lead 就无法区分任务结果和资源状态。
队友进入 IDLE 后不会退出。新普通消息会让它回到 WORK`shutdown_request` 则让它完成关机握手并结束线程。
### 6. 控制消息使用类型和 request_id
普通消息可以交给模型理解,关机和审批不能依赖自由文本猜测。它们使用结构化消息:
![Team Protocols](images/team-protocols-overview.svg)
```python
# 主循环结束后
inbox = BUS.read_inbox("lead")
if inbox:
inbox_text = "\n".join(
f"From {m['from']}: {m['content'][:200]}" for m in inbox)
history.append({"role": "user",
"content": f"[Inbox]\n{inbox_text}"})
@dataclass
class ProtocolState:
request_id: str
type: str
sender: str
target: str
status: str
payload: str
pending_requests: dict[str, ProtocolState] = {}
```
教学版在用户输入循环外注入。CC 更精细Lead 的 `useInboxPoller` 每 1 秒检查一次,有消息就提交为新的 turn不需要等用户输入。
关机协议的完整路径是:
### 权限冒泡
教学版省略了权限冒泡。真实 CC 的流程(`permissionSync.ts``useSwarmPermissionPoller.ts`
1. 队友遇到需要审批的操作 → 发 `permission_request` 到 Lead 收件箱
2. Lead 的 `useInboxPoller` 检测到请求 → 路由到审批队列
3. 用户审批后 → Lead 发 `permission_response` 回队友
4. 队友的 `useSwarmPermissionPoller`(每 500ms 轮询)收到回复 → 继续或拒绝
### 合起来跑
```
1. Lead: "搭建后端:一个人搞不定,组队吧"
2. Lead → spawn_teammate("alice", "backend dev", "创建数据库 schema")
3. Lead → spawn_teammate("bob", "frontend dev", "写 API 客户端")
4. alice 线程启动 → 自己的 LLM 调用 → bash "python manage.py migrate"
5. bob 线程启动 → 自己的 LLM 调用 → write_file("client.ts", ...)
6. alice 完成 → BUS.send("alice", "lead", "Schema done: users, orders tables")
7. bob 完成 → BUS.send("bob", "lead", "Client written with types")
8. Lead 下次循环 → inbox 注入 history → LLM 看到 alice 和 bob 的结果
```text
Lead 创建 shutdown 请求,状态为 pending
→ shutdown_request(request_id) 发给队友
→ 队友完成当前步骤并回复 shutdown_response(request_id)
→ Lead 用 request_id 找到原请求
→ pending 变为 approved队友线程退出
```
两个队友并行工作
`request_id` 负责关联请求与回复,`type` 防止错误类型的回复修改状态,`status` 防止重复响应被再次处理
### 7. 计划审批不仅传消息,还约束执行
计划协议沿相反方向流动:
```text
Lead → plan_request
队友 → plan_approval_request(request_id, plan)
Lead → plan_approval_response(request_id, approve, feedback)
```
只告诉队友“请等待批准”并不可靠,所以工具分发器检查计划状态:
```python
def _run_teammate_tool(name, block, handlers):
gate = plan_gates.get(name, "not_required")
if block.name in {"bash", "write_file"} and gate not in {
"not_required", "approved"
}:
return f"Blocked: plan status is {gate}."
return handlers[block.name](**block.input)
```
当状态为 `required``pending``rejected` 时,队友仍可读取文件、提交或修改计划,但不能执行 Shell 或写文件。批准消息到达后,状态变为 `approved`,工具才会放行。
---
## 相对 s14 的变更
## 一次完整运行
| 组件 | 之前 (s14) | 之后 (s15) |
|------|-----------|-----------|
| Agent 数量 | 1 | 1 Lead + N 队友线程 |
| 通信 | 无 | MessageBus + .mailboxes/*.jsonl |
| 新类 | — | MessageBus, active_teammates dict |
| 新函数 | — | spawn_teammate_thread, run_send_message, run_check_inbox |
| Lead 工具 | 11 (s14) | + spawn_teammate, send_message, check_inbox (14) |
| 队友工具 | — | bash, read_file, write_file, send_message (4) |
| 权限 | 本地决策 | 教学版省略(真实 CC 有冒泡机制) |
```text
s15 >> 请重构这个示例后端,分别整理配置加载、认证逻辑和测试,
保持现有接口兼容,并确保测试通过。
Lead: 建议由 config、auth、tests 三个方向并行处理,是否开始?
s15 >> 开始吧
[teammate] config spawned
[teammate] auth spawned
[teammate] tests spawned
[bus] auth → lead (result) ...
[bus] auth → lead (idle_notification) ...
[wake: 2 team events → new turn]
Lead: 已收到认证部分结果,继续等待并协调其他队友。
```
终端中显示的是用户需求、Lead 分工、队友启动、消息流、结果、空闲和关机事件。用户不需要在提示词里指定谁是 Lead也不需要手动要求检查邮箱。
---
## 相对 s14 的变化
| 组件 | s14 | s15 |
|---|---|---|
| Agent 数量 | 一个 Agent | 一个 Lead + 多个持久队友 |
| 用户交互 | 直接执行任务 | 先提出团队方案,再确认启动 |
| 通信 | 无 | 文件邮箱 + 自动事件投递 |
| 生命周期 | 单循环 | 队友 `WORK / IDLE / shutdown` |
| 结果上报 | 当前 Agent 输出 | `result``idle_notification` 分离 |
| 控制协议 | 无 | 关机与计划审批 |
| 执行约束 | 无团队约束 | 未批准计划会拦截写入类工具 |
---
@@ -160,97 +252,27 @@ cd learn-claude-code
python s15_agent_teams/code.py
```
试试这些 prompt
先输入一个自然需求
1. `Spawn alice as a backend developer. Ask her to create a file called schema.sql with a users table.`
2. `Check your inbox for alice's result.`
3. `Spawn bob as a tester. Ask him to check if schema.sql exists and list its contents.`
```text
请重构这个示例后端,分别整理配置加载、认证逻辑和测试,
保持现有接口兼容,并确保测试通过。
```
观察重点Lead 如何启动队友?`.mailboxes/` 目录下的 JSONL 文件长什么样?队友完成后 Lead 的 inbox 有没有注入到 history
看到 Lead 给出分工后,再回复:
```text
开始吧
```
观察终端中的 `spawned``result``idle_notification``plan_approval_*``shutdown_*` 事件,以及 `.mailboxes/` 中消息写入和消费的过程。
---
## 接下来
队友能干活、能通信。但如果 Lead 想让 Alice 关机直接杀线程会留下写到一半的文件。需要一个体面的关机协议Lead 发 shutdown_request队友收尾后退出
s15 中Lead 仍然要明确告诉每个队友做什么。下一章把共享任务看板交给空闲队友,让它们自己发现并认领可执行任务
s16 Agent Teams 协议实验 → 沿用本章运行时,加入关机握手、计划审批与带类型的请求-回复消息
下一章:[s16 Autonomous Agents](../s16_autonomous_agents/)
<details>
<summary>深入 CC 源码</summary>
> 以下基于 CC 源码 `spawnMultiAgent.ts`、`useInboxPoller.ts`969 行)、`useSwarmPermissionPoller.ts`330 行)、`teammateMailbox.ts`、`teamHelpers.ts` 的完整分析。
### 一、没有中央消息总线,是文件系统
教学版用 `MessageBus` 类收发消息。CC 的做法更直接,每个 Agent 直接写其他 Agent 的收件箱文件。
收件箱路径:`~/.claude/teams/{teamName}/inboxes/{agentName}.json`
写入时用 `proper-lockfile` 文件锁保证并发安全(最多重试 10 次)。每个文件是一个 JSON 数组append 新消息时读→追加→写回。
### 二、15 种消息类型
CC 的团队通信有 15 种结构化消息(`teammateMailbox.ts`
| 类型 | 方向 | 用途 |
|------|------|------|
| `plain text` | 双向 | 普通队友间通信 |
| `idle_notification` | 队友→Lead | 队友完成一轮工作,进入空闲 |
| `permission_request` | 队友→Lead | 队友需要操作审批 |
| `permission_response` | Lead→队友 | Lead 审批结果 |
| `plan_approval_request` | 队友→Lead | 队友提交计划待审 |
| `plan_approval_response` | Lead→队友 | Lead 审批计划 |
| `shutdown_request` | Lead→队友 | 请求体面关机 |
| `shutdown_approved` | 队友→Lead | 确认关机 |
| `shutdown_rejected` | 队友→Lead | 拒绝关机(附原因) |
| `task_assignment` | Lead→队友 | 分配任务 |
| `team_permission_update` | Lead→队友 | 广播权限变更 |
| `mode_set_request` | Lead→队友 | 修改队友的权限模式 |
| `sandbox_permission_*` | 双向 | 网络权限请求/回复 |
| `teammate_terminated` | 系统 | 队友被移除通知 |
文本消息被包装在 `<teammate-message>` XML 标签中交付给模型。
### 三、权限冒泡:双向轮询
教学版省略了权限冒泡。CC 的实际流程(`permissionSync.ts`
1. **队友**遇到需要审批的操作 → 发 `permission_request` 到 Lead 的收件箱
2. **Lead**`useInboxPoller`(每 1 秒轮询)检测到请求 → 路由到 `ToolUseConfirmQueue`
3. Lead 的 UI 显示审批对话框,带队友名字和颜色
4. 用户审批后 → Lead 发 `permission_response` 回队友的收件箱
5. **队友**的 `useSwarmPermissionPoller`(每 500ms 轮询)收到回复 → 继续或拒绝执行
### 四、队友生命周期
CC 的队友由 `spawnTeammate()``spawnMultiAgent.ts`)创建:
1. **Spawn**:创建 tmux 窗格(或进程内),分配颜色,写入 team config
2. **Work**`useInboxPoller` 每 1 秒检查收件箱 → 有消息就提交为新的 turn
3. **Idle**Stop hook 触发 → 发 `idle_notification` 给 Lead
4. **Shutdown**Lead 发 `shutdown_request` → 队友回复 `shutdown_approved` → Lead 清理
### 五、Team Config
团队注册表在 `~/.claude/teams/{teamName}/config.json``teamHelpers.ts`
```json
{
"name": "my-team",
"leadAgentId": "lead@my-team",
"members": [{
"agentId": "researcher@my-team",
"name": "researcher",
"agentType": "general-purpose",
"color": "blue",
"isActive": true
}]
}
```
队友之间不能嵌套(`AgentTool.tsx:273` 明确禁止 "teammates spawning other teammates")。
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->

View File

@@ -1,29 +1,27 @@
#!/usr/bin/env python3
"""
s15: Agent Teams — MessageBus + spawn_teammate_thread + inbox injection.
s15: Agent Teams — persistent teammates, mailboxes, and typed protocols.
Run: python s15_agent_teams/code.py
Need: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY
Changes from s14:
- MessageBus class: file-based mailboxes (.mailboxes/*.jsonl)
- spawn_teammate_thread: creates teammate in background thread
- Teammate runs own simplified agent_loop (bash, read, write, send_message)
- Lead tools: spawn_teammate, send_message, check_inbox (3 new)
- Lead inbox: teammate messages injected into history (not just printed)
- Teaching version: teammates limited to 10 rounds (real CC uses idle loop)
- MessageBus: thread-safe, file-backed mailboxes (.mailboxes/*.jsonl)
- Persistent teammate loops with WORK and IDLE states
- Runtime delivery of teammate results and idle notifications to Lead
- Typed shutdown and plan-approval protocols with request_id matching
- Plan approval gates bash and write_file until Lead approves
ASCII flow:
Lead: cron_queue → messages → prompt → LLM → TOOLS ────→ loop
|
└── inbox ← MessageBus teammate.send_message ←
Teammate: inbox → LLM → bash/read/write/send → loop (max 10 turns)
User → Lead → spawn_teammate → teammate WORK → result → IDLE
|
└──────── MessageBus + typed protocol
"""
import os, subprocess, json, time, random, threading, queue
import os, subprocess, json, time, random, threading, queue, re
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass, asdict
from dataclasses import dataclass, asdict, field
try:
import readline
@@ -145,7 +143,15 @@ PROMPT_SECTIONS = {
"tools": "Available tools: bash, read_file, write_file, "
"get_task, create_task, list_tasks, claim_task, complete_task, "
"schedule_cron, list_crons, cancel_cron, "
"spawn_teammate, send_message, check_inbox.",
"spawn_teammate, send_message, request_shutdown, "
"request_plan, review_plan.",
"teams": (
"When parallel work would help, first propose a small team with clear "
"responsibilities and wait for the user's confirmation. Do not call "
"spawn_teammate before the user confirms. After confirmation, delegate "
"independent work, react to team events delivered by the runtime, and "
"shut teammates down when coordination is complete."
),
"workspace": f"Working directory: {WORKDIR}",
"memory": "Relevant memories are injected below when available.",
}
@@ -154,6 +160,7 @@ PROMPT_SECTIONS = {
def assemble_system_prompt(context: dict) -> str:
sections = [PROMPT_SECTIONS["identity"],
PROMPT_SECTIONS["tools"],
PROMPT_SECTIONS["teams"],
PROMPT_SECTIONS["workspace"]]
memories = context.get("memories", "")
if memories:
@@ -290,7 +297,10 @@ def execute_tool(block) -> str:
"schedule_cron": run_schedule_cron, "list_crons": run_list_crons,
"cancel_cron": run_cancel_cron,
"spawn_teammate": run_spawn_teammate,
"send_message": run_send_message, "check_inbox": run_check_inbox,
"send_message": run_send_message,
"request_shutdown": run_request_shutdown,
"request_plan": run_request_plan,
"review_plan": run_review_plan,
}.get(block.name)
if handler:
return handler(**block.input)
@@ -591,66 +601,275 @@ def run_cancel_cron(job_id: str) -> str:
return cancel_job(job_id)
# ── MessageBus (s15 new) ──
# Teaching version uses simple file append + unlink.
# Real CC uses proper-lockfile for concurrent write safety.
# ── MessageBus + Team Protocols (s15 new) ──
MAILBOX_DIR = WORKDIR / ".mailboxes"
MAILBOX_DIR.mkdir(exist_ok=True)
MAILBOX_ROOT = MAILBOX_DIR.resolve()
VALID_AGENT_NAME = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
def is_valid_agent_name(name: str) -> bool:
return bool(VALID_AGENT_NAME.fullmatch(name))
class MessageBus:
"""File-based message bus. Each agent has a .jsonl inbox.
Read is destructive: read_text + unlink (consumes messages).
Teaching version: no file locking; real CC uses proper-lockfile."""
"""Thread-safe file mailboxes with destructive reads."""
def send(self, from_agent: str, to_agent: str, content: str,
msg_type: str = "message"):
msg = {"from": from_agent, "to": to_agent,
"content": content, "type": msg_type,
"ts": time.time()}
inbox = MAILBOX_DIR / f"{to_agent}.jsonl"
with open(inbox, "a") as f:
f.write(json.dumps(msg) + "\n")
print(f" \033[33m[bus] {from_agent}{to_agent}: "
f"{content[:50]}\033[0m")
def __init__(self):
self._lock = threading.RLock()
self._changed = threading.Condition(self._lock)
def read_inbox(self, agent: str) -> list[dict]:
inbox = MAILBOX_DIR / f"{agent}.jsonl"
def _path(self, agent: str) -> Path:
if not is_valid_agent_name(agent):
raise ValueError(f"Invalid mailbox recipient: {agent!r}")
path = (MAILBOX_DIR / f"{agent}.jsonl").resolve()
if not path.is_relative_to(MAILBOX_ROOT):
raise ValueError(f"Mailbox path escapes directory: {agent!r}")
return path
def _read_unlocked(self, agent: str) -> list[dict]:
inbox = self._path(agent)
if not inbox.exists():
return []
msgs = [json.loads(line) for line in inbox.read_text().splitlines()
if line.strip()]
inbox.unlink() # consume: read + delete
inbox.unlink()
return msgs
def send(self, from_agent: str, to_agent: str, content: str,
msg_type: str = "message", metadata: dict | None = None):
msg = {"from": from_agent, "to": to_agent,
"content": content, "type": msg_type,
"ts": time.time(), "metadata": metadata or {}}
with self._changed:
with open(self._path(to_agent), "a") as f:
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
self._changed.notify_all()
print(f" \033[33m[bus] {from_agent}{to_agent}: "
f"({msg_type}) {content[:50]}\033[0m")
def read_inbox(self, agent: str) -> list[dict]:
with self._lock:
return self._read_unlocked(agent)
def peek(self, agent: str) -> bool:
"""Non-destructive: True if the agent has unread inbox messages.
The Lead's inbox poller uses this to decide whether to wake a turn
without consuming the mailbox."""
inbox = MAILBOX_DIR / f"{agent}.jsonl"
return inbox.exists() and inbox.stat().st_size > 0
with self._lock:
inbox = self._path(agent)
return inbox.exists() and inbox.stat().st_size > 0
def wait_for_messages(self, agent: str,
timeout: float | None = None) -> list[dict]:
"""Block until the agent has messages or timeout expires."""
deadline = None if timeout is None else time.monotonic() + timeout
with self._changed:
while not self.peek(agent):
remaining = (None if deadline is None
else deadline - time.monotonic())
if remaining is not None and remaining <= 0:
return []
self._changed.wait(remaining)
return self._read_unlocked(agent)
BUS = MessageBus()
# Track spawned teammates
active_teammates: dict[str, bool] = {}
# working | waiting_approval | idle | stopping
active_teammates: dict[str, str] = {}
plan_gates: dict[str, str] = {}
plan_request_ids: dict[str, str] = {}
team_lock = threading.RLock()
# ── Teammate Thread (s15 new) ──
@dataclass
class ProtocolState:
request_id: str
type: str
sender: str
target: str
status: str
payload: str
created_at: float = field(default_factory=time.time)
pending_requests: dict[str, ProtocolState] = {}
def new_request_id() -> str:
while True:
request_id = f"req_{random.randint(0, 999999):06d}"
if request_id not in pending_requests:
return request_id
def match_response(response_type: str, request_id: str, approve: bool,
from_agent: str, to_agent: str) -> bool:
"""Match one protocol response to one pending request."""
with team_lock:
state = pending_requests.get(request_id)
if not state:
print(f" \033[31m[protocol] unknown request_id: {request_id}\033[0m")
return False
expected = {
"shutdown": "shutdown_response",
"plan_approval": "plan_approval_response",
}[state.type]
if response_type != expected:
print(f" \033[31m[protocol] expected {expected}, "
f"got {response_type}\033[0m")
return False
if from_agent != state.target or to_agent != state.sender:
print(f" \033[31m[protocol] {request_id} responder mismatch\033[0m")
return False
if state.status != "pending":
print(f" \033[33m[protocol] {request_id} already "
f"{state.status}\033[0m")
return False
state.status = "approved" if approve else "rejected"
print(f" \033[35m[protocol] {request_id}{state.status}\033[0m")
return True
def consume_lead_inbox() -> list[dict]:
"""Consume Lead events and update protocol state before model delivery."""
msgs = BUS.read_inbox("lead")
for msg in msgs:
metadata = msg.get("metadata", {})
request_id = metadata.get("request_id", "")
if request_id and msg.get("type", "").endswith("_response"):
match_response(msg["type"], request_id,
metadata.get("approve", False),
msg.get("from", ""), msg.get("to", ""))
return msgs
def format_team_events(msgs: list[dict]) -> str:
lines = []
for msg in msgs:
metadata = msg.get("metadata", {})
request_id = metadata.get("request_id")
suffix = f" request_id={request_id}" if request_id else ""
lines.append(
f"[{msg['type']}{suffix}] {msg['from']}: {msg['content']}"
)
return "[Team events]\n" + "\n".join(lines)
def _last_assistant_text(content) -> str:
for block in content:
if getattr(block, "type", None) == "text":
return block.text.strip()
if isinstance(block, dict) and block.get("type") == "text":
return str(block.get("text", "")).strip()
return ""
def _teammate_submit_plan(from_name: str, plan: str) -> str:
with team_lock:
if plan_gates.get(from_name) == "pending":
return "A plan is already waiting for review."
request_id = new_request_id()
pending_requests[request_id] = ProtocolState(
request_id=request_id,
type="plan_approval",
sender=from_name,
target="lead",
status="pending",
payload=plan,
)
plan_gates[from_name] = "pending"
plan_request_ids[from_name] = request_id
active_teammates[from_name] = "waiting_approval"
BUS.send(from_name, "lead", plan, "plan_approval_request",
{"request_id": request_id})
return f"Plan submitted ({request_id}). Wait for Lead's decision."
def _run_teammate_tool(name: str, block, handlers: dict) -> str:
gate = plan_gates.get(name, "not_required")
if block.name in {"bash", "write_file"} and gate != "not_required":
if gate != "approved":
return (f"Blocked: plan status is {gate}. Submit or revise the "
"plan and wait for approval before changing the workspace.")
handler = handlers.get(block.name)
return str(handler(**block.input)) if handler else f"Unknown tool: {block.name}"
def apply_plan_response(name: str, msg: dict) -> tuple[bool, str]:
"""Apply only the Lead response for this teammate's current plan."""
metadata = msg.get("metadata", {})
request_id = metadata.get("request_id", "")
with team_lock:
state = pending_requests.get(request_id)
expected_id = plan_request_ids.get(name)
valid = (
msg.get("from") == "lead"
and msg.get("to") == name
and request_id == expected_id
and state is not None
and state.type == "plan_approval"
and state.sender == name
and state.target == "lead"
and state.status in {"approved", "rejected"}
and metadata.get("approve", False)
== (state.status == "approved")
)
if not valid:
return False, "[Ignored plan response: request mismatch]"
plan_gates[name] = state.status
active_teammates[name] = "working"
plan_request_ids.pop(name, None)
outcome = state.status
return True, f"[Plan {outcome}] {msg['content']}"
def apply_shutdown_request(name: str, msg: dict) -> tuple[bool, str]:
"""Accept only a pending shutdown request sent by Lead to this teammate."""
request_id = msg.get("metadata", {}).get("request_id", "")
with team_lock:
state = pending_requests.get(request_id)
valid = (
msg.get("from") == "lead"
and msg.get("to") == name
and state is not None
and state.type == "shutdown"
and state.sender == "lead"
and state.target == name
and state.status == "pending"
and active_teammates.get(name) != "stopping"
)
if not valid:
return False, "[Ignored shutdown request: request mismatch]"
active_teammates[name] = "stopping"
return True, request_id
def _teammate_send_message(from_name: str, to: str, content: str) -> str:
with team_lock:
if to != "lead" and to not in active_teammates:
return f"Agent '{to}' is not active"
BUS.send(from_name, to, content)
return f"Sent to {to}"
# ── Teammate Thread ──
def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
"""Spawn a teammate agent in a background thread.
Teaching version: max 10 rounds per teammate.
Real CC: teammates use idle loop (wait for inbox, work, repeat)
until shutdown_request."""
if name in active_teammates:
return f"Teammate '{name}' already exists"
"""Spawn a persistent teammate that alternates between WORK and IDLE."""
if not is_valid_agent_name(name):
return ("Invalid teammate name: use 1-64 letters, digits, "
"underscores, or dashes")
with team_lock:
if name in active_teammates:
return f"Teammate '{name}' already exists"
active_teammates[name] = "working"
plan_gates[name] = "not_required"
system = (f"You are '{name}', a {role}. "
f"Use tools to complete tasks. "
f"Send results via send_message to 'lead'.")
"Use tools to complete assigned work. "
"When asked for a plan, call submit_plan before bash or "
"write_file and wait for approval. End each assignment with a "
"concise result; the runtime delivers it to Lead.")
def run():
messages = [{"role": "user", "content": prompt}]
@@ -674,77 +893,166 @@ def spawn_teammate_thread(name: str, role: str, prompt: str) -> str:
"properties": {"to": {"type": "string"},
"content": {"type": "string"}},
"required": ["to", "content"]}},
{"name": "submit_plan",
"description": "Submit a work plan for Lead approval.",
"input_schema": {"type": "object",
"properties": {"plan": {"type": "string"}},
"required": ["plan"]}},
]
sub_handlers = {
"bash": run_bash, "read_file": run_read, "write_file": run_write,
"send_message": lambda to, content: (BUS.send(name, to, content),
"Sent")[1],
"send_message": lambda to, content: _teammate_send_message(
name, to, content),
"submit_plan": lambda plan: _teammate_submit_plan(name, plan),
}
for _ in range(10):
inbox = BUS.read_inbox(name)
if inbox:
def handle_messages(inbox: list[dict]) -> bool:
"""Return True when a shutdown request ends the teammate."""
work_messages = []
for msg in inbox:
msg_type = msg.get("type", "message")
metadata = msg.get("metadata", {})
request_id = metadata.get("request_id", "")
if msg_type == "shutdown_request":
accepted, notice = apply_shutdown_request(name, msg)
if not accepted:
work_messages.append(notice)
continue
request_id = notice
BUS.send(name, "lead", "Shutdown acknowledged.",
"shutdown_response",
{"request_id": request_id, "approve": True})
return True
if msg_type == "plan_approval_response":
_, notice = apply_plan_response(name, msg)
work_messages.append(notice)
continue
if msg_type == "plan_request":
work_messages.append(
f"[Plan required] {msg['content']}"
)
continue
work_messages.append(
f"[Message from {msg['from']}] {msg['content']}"
)
if work_messages:
messages.append({"role": "user",
"content": f"<inbox>{json.dumps(inbox)}</inbox>"})
"content": "\n".join(work_messages)})
return False
should_stop = False
while not should_stop:
with team_lock:
active_teammates[name] = "working"
try:
response = client.messages.create(
model=MODEL, system=system, messages=messages[-20:],
tools=sub_tools, max_tokens=8000)
except Exception:
except Exception as exc:
BUS.send(name, "lead",
f"{type(exc).__name__}: {exc}", "error")
break
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type == "tool_use":
handler = sub_handlers.get(block.name)
output = handler(**block.input) if handler else "Unknown"
if response.stop_reason == "tool_use":
results = []
for block in response.content:
if block.type != "tool_use":
continue
output = _run_teammate_tool(name, block, sub_handlers)
results.append({"type": "tool_result",
"tool_use_id": block.id,
"content": str(output)})
messages.append({"role": "user", "content": results})
"content": output})
messages.append({"role": "user", "content": results})
continue
# Send final summary to Lead
summary = "Done."
for msg in reversed(messages):
if msg["role"] == "assistant" and isinstance(msg["content"], list):
for b in msg["content"]:
if getattr(b, "type", None) == "text":
summary = b.text
break
else:
continue
break
BUS.send(name, "lead", summary, "result")
active_teammates.pop(name, None)
summary = _last_assistant_text(response.content)
gate = plan_gates.get(name, "not_required")
if gate != "pending" and summary:
BUS.send(name, "lead", summary, "result")
if gate == "pending":
with team_lock:
active_teammates[name] = "waiting_approval"
else:
with team_lock:
active_teammates[name] = "idle"
BUS.send(name, "lead", "Waiting for more work.",
"idle_notification")
while True:
inbox = BUS.wait_for_messages(name)
should_stop = handle_messages(inbox)
if should_stop or messages[-1]["role"] == "user":
break
with team_lock:
active_teammates.pop(name, None)
plan_gates.pop(name, None)
plan_request_ids.pop(name, None)
print(f" \033[32m[teammate] {name} finished\033[0m")
active_teammates[name] = True
threading.Thread(target=run, daemon=True).start()
print(f" \033[36m[teammate] {name} spawned as {role}\033[0m")
return f"Teammate '{name}' spawned as {role}"
# ── Team Tool Handlers (s15 new) ──
# ── Lead Team Tools ──
def run_spawn_teammate(name: str, role: str, prompt: str) -> str:
return spawn_teammate_thread(name, role, prompt)
def run_send_message(to: str, content: str) -> str:
if to not in active_teammates:
return f"Teammate '{to}' is not active"
BUS.send("lead", to, content)
return f"Sent to {to}"
def run_check_inbox() -> str:
msgs = BUS.read_inbox("lead")
if not msgs:
return "(inbox empty)"
lines = []
for m in msgs:
lines.append(f" [{m['from']}] {m['content'][:200]}")
return "\n".join(lines)
def run_request_shutdown(teammate: str) -> str:
if teammate not in active_teammates:
return f"Teammate '{teammate}' is not active"
with team_lock:
request_id = new_request_id()
pending_requests[request_id] = ProtocolState(
request_id=request_id,
type="shutdown",
sender="lead",
target=teammate,
status="pending",
payload="",
)
BUS.send("lead", teammate, "Finish the current step and shut down.",
"shutdown_request", {"request_id": request_id})
return f"Shutdown requested from {teammate} ({request_id})"
def run_request_plan(teammate: str, task: str) -> str:
if teammate not in active_teammates:
return f"Teammate '{teammate}' is not active"
with team_lock:
plan_gates[teammate] = "required"
BUS.send("lead", teammate, task, "plan_request")
return f"Plan requested from {teammate}"
def run_review_plan(request_id: str, approve: bool,
feedback: str = "") -> str:
with team_lock:
state = pending_requests.get(request_id)
if not state:
return f"Request {request_id} not found"
if state.type != "plan_approval":
return f"Request {request_id} is not a plan"
if state.status != "pending":
return f"Request {request_id} already {state.status}"
if plan_request_ids.get(state.sender) != request_id:
return f"Request {request_id} is not the current plan"
state.status = "approved" if approve else "rejected"
content = feedback or ("Plan approved." if approve
else "Revise the plan and submit it again.")
BUS.send("lead", state.sender, content, "plan_approval_response",
{"request_id": request_id, "approve": approve})
return f"Plan {state.status} ({request_id})"
# ── Tool Definitions ──
@@ -820,7 +1128,10 @@ TOOLS = [
"description": "Spawn a teammate agent in a background thread.",
"input_schema": {"type": "object",
"properties": {
"name": {"type": "string"},
"name": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{1,64}$",
},
"role": {"type": "string"},
"prompt": {"type": "string"}},
"required": ["name", "role", "prompt"]}},
@@ -830,10 +1141,25 @@ TOOLS = [
"properties": {"to": {"type": "string"},
"content": {"type": "string"}},
"required": ["to", "content"]}},
{"name": "check_inbox",
"description": "Check Lead's inbox for teammate messages.",
"input_schema": {"type": "object", "properties": {},
"required": []}},
{"name": "request_shutdown",
"description": "Ask an active teammate to shut down gracefully.",
"input_schema": {"type": "object",
"properties": {"teammate": {"type": "string"}},
"required": ["teammate"]}},
{"name": "request_plan",
"description": "Require a teammate to submit a plan before changing files.",
"input_schema": {"type": "object",
"properties": {"teammate": {"type": "string"},
"task": {"type": "string"}},
"required": ["teammate", "task"]}},
{"name": "review_plan",
"description": "Approve or reject a submitted plan by request_id.",
"input_schema": {"type": "object",
"properties": {
"request_id": {"type": "string"},
"approve": {"type": "boolean"},
"feedback": {"type": "string"}},
"required": ["request_id", "approve"]}},
]
@@ -854,9 +1180,8 @@ def update_context(context: dict, messages: list) -> dict:
# ── Agent Loop ──
# Teaching code keeps a basic agent loop. S11's full error recovery is omitted.
# Cron queue is consumed when agent_loop is called; real CC auto-wakes via
# queue processor (useQueueProcessor.ts) when items arrive.
# Keep the loop focused on the mechanisms introduced in this chapter.
# Fired cron entries are injected at the start of each model turn.
def agent_loop(messages: list, context: dict):
system = get_system_prompt(context)
@@ -955,16 +1280,16 @@ if __name__ == "__main__":
history.append({"role": "user", "content": payload})
else: # "wake": teammate inbox or background results are ready
parts = []
inbox = BUS.read_inbox("lead")
inbox = consume_lead_inbox()
if inbox:
parts.append("[Inbox]\n" + "\n".join(
f"From {m['from']}: {m['content'][:200]}" for m in inbox))
parts.append(format_team_events(inbox))
bg = collect_background_results()
parts.extend(bg)
if not parts:
continue # already drained by an earlier wake (idempotent)
history.append({"role": "user", "content": "\n".join(parts)})
print(f"\n\033[33m[wake: {len(inbox)} inbox + {len(bg)} background "
print(f"\n\033[33m[wake: {len(inbox)} team events + "
f"{len(bg)} background "
f"-> new turn]\033[0m")
# One turn for whichever source woke us.
@@ -976,10 +1301,10 @@ if __name__ == "__main__":
elif isinstance(block, dict) and block.get("type") == "text":
print(block.get("text", ""))
# Announce once when every teammate has finished and its output drained.
# Announce once after all requested shutdowns have completed.
if active_teammates:
had_teammates = True
elif had_teammates and not BUS.peek("lead") and not has_pending_background():
print("\033[32m[all teammates done]\033[0m")
print("\033[32m[all teammates shut down]\033[0m")
had_teammates = False
print()

View File

@@ -32,7 +32,7 @@
<rect x="280" y="56" width="12" height="10" rx="2" fill="#f0fdf4" stroke="#16a34a" stroke-width="1"/>
<text x="298" y="66" fill="#16a34a" font-size="10" font-weight="600">Teammate</text>
<rect x="395" y="56" width="12" height="10" rx="2" fill="#fffbeb" stroke="#d97706" stroke-width="1"/>
<text x="413" y="66" fill="#d97706" font-size="10" font-weight="600">Real CC detail</text>
<text x="413" y="66" fill="#d97706" font-size="10" font-weight="600">Protocol gate</text>
<!-- ===== Row 1: Lead Agent Loop ===== -->
<rect x="28" y="90" width="70" height="40" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="1.5"/>
@@ -58,7 +58,7 @@
<rect x="398" y="80" width="336" height="60" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="566" y="98" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL DISPATCH</text>
<text x="414" y="114" fill="#2563eb" font-size="8">bash · read · write · task(4) · cron(3)</text>
<text x="414" y="128" fill="#0891b2" font-size="8" font-weight="700">★ spawn_teammate · send_message · check_inbox</text>
<text x="414" y="128" fill="#0891b2" font-size="8" font-weight="700">★ spawn · send · shutdown · plan review</text>
<!-- Loop back -->
<path d="M 734 110 L 748 110 L 748 150 L 63 150 L 63 130" fill="none" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)" stroke-dasharray="6,3"/>
@@ -90,7 +90,7 @@
<rect x="60" y="248" width="220" height="66" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
<text x="170" y="268" fill="#166534" font-size="10" font-weight="700" text-anchor="middle">Teammate: alice (Backend)</text>
<text x="75" y="284" fill="#16a34a" font-size="8">inbox → LLM → bash/read/write/send</text>
<text x="75" y="298" fill="#6b7280" font-size="8">Max 10 rounds → summary → BUS.send</text>
<text x="75" y="298" fill="#6b7280" font-size="8">WORK → result → IDLE → next message</text>
<rect x="270" y="248" width="220" height="66" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
<text x="380" y="268" fill="#166534" font-size="10" font-weight="700" text-anchor="middle">Teammate: bob (Frontend)</text>
@@ -100,21 +100,21 @@
<rect x="480" y="248" width="220" height="66" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.5"/>
<text x="590" y="268" fill="#166534" font-size="10" font-weight="700" text-anchor="middle">Teammate: charlie (QA)</text>
<text x="495" y="284" fill="#16a34a" font-size="8">Cannot spawn other teammates</text>
<text x="495" y="298" fill="#6b7280" font-size="8">spawn → work → summary</text>
<text x="495" y="298" fill="#6b7280" font-size="8">spawn → work → result → idle</text>
<!-- ===== Row 4: Permission bubbling (real CC detail) ===== -->
<!-- ===== Row 4: Plan approval gate ===== -->
<path d="M 60 360 L 10 360 L 10 195 L 60 195" fill="none" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow-amber)" stroke-dasharray="5,3"/>
<rect x="20" y="318" width="126" height="18" rx="4" fill="#fffbeb" stroke="#f59e0b" stroke-width="1"/>
<text x="83" y="331" fill="#d97706" font-size="10" font-weight="700" text-anchor="middle">permission_request</text>
<text x="83" y="331" fill="#d97706" font-size="10" font-weight="700" text-anchor="middle">plan_request</text>
<rect x="60" y="340" width="640" height="50" rx="6" fill="#fffbeb" stroke="#d97706" stroke-width="1.5"/>
<text x="380" y="360" fill="#92400e" font-size="11" font-weight="700" text-anchor="middle">Permission Bubbling (real CC; omitted in teaching code)</text>
<text x="80" y="378" fill="#78716c" font-size="9">① Teammate needs approval → MessageBus sends permission_request ② Lead receives → user approval → approve/deny</text>
<text x="380" y="360" fill="#92400e" font-size="11" font-weight="700" text-anchor="middle">Plan Approval Gate</text>
<text x="80" y="378" fill="#78716c" font-size="9">① Teammate submits plan ② Lead approves or rejects ③ bash / write_file stay blocked until approved</text>
<!-- ===== Row 5: Bottom notes ===== -->
<rect x="60" y="410" width="640" height="44" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<rect x="80" y="424" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="100" y="434" fill="#475569" font-size="10">s10-s14: prompt assembly, error recovery, task graph, background threads, cron scheduling</text>
<rect x="80" y="440" width="12" height="10" rx="2" fill="#ecfeff" stroke="#0891b2" stroke-width="1"/>
<text x="100" y="450" fill="#475569" font-size="10">s15: MessageBus + spawn_teammate_thread + send_message + check_inbox (permission bubbling is a real CC detail)</text>
<text x="100" y="450" fill="#475569" font-size="10">s15: MessageBus + persistent teammates + event delivery + typed protocols + plan gate</text>
</svg>

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

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

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

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

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -0,0 +1,141 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 664" 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="#7c3aed"/>
</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-purple" 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="#7c3aed"/>
</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>
<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="664" fill="#fafbfc" rx="8"/>
<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">Team Protocols — Request-Response + request_id Correlation + State Machine</text>
<rect x="40" y="56" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="58" y="66" fill="#2563eb" font-size="10" font-weight="600">Team runtime</text>
<rect x="160" y="56" width="12" height="10" rx="2" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1"/>
<text x="178" y="66" fill="#7c3aed" font-size="10" font-weight="600">Protocols</text>
<!-- Row 1: Lead Loop -->
<rect x="20" y="90" width="70" height="40" rx="8" fill="#eef2ff" stroke="#4f46e5" stroke-width="1.5"/>
<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 (core tool set)</text>
<text x="394" y="114" fill="#2563eb" font-size="8">bash · read · write · task(4) · spawn · send · inbox</text>
<text x="394" y="128" fill="#7c3aed" font-size="8" font-weight="700">★ request_shutdown · request_plan · review_plan</text>
<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: shutdown protocol -->
<rect x="30" y="176" width="700" height="90" rx="8" fill="#fef3c7" stroke="#d97706" stroke-width="1.5"/>
<text x="380" y="196" fill="#92400e" font-size="11" font-weight="700" text-anchor="middle">Protocol A: Shutdown (Lead initiates → Teammate responds)</text>
<rect x="50" y="208" width="180" height="46" rx="6" fill="#fff" stroke="#d97706" stroke-width="1"/>
<text x="140" y="226" fill="#92400e" font-size="9" font-weight="600" text-anchor="middle">① Lead: request_shutdown</text>
<text x="140" y="242" fill="#6b7280" font-size="7" text-anchor="middle">new_request_id() → ProtocolState</text>
<line x1="230" y1="231" x2="254" y2="231" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="257" y="208" width="180" height="46" rx="6" fill="#fff" stroke="#d97706" stroke-width="1"/>
<text x="347" y="226" fill="#92400e" font-size="9" font-weight="600" text-anchor="middle">② Teammate: handle_shutdown</text>
<text x="347" y="242" fill="#6b7280" font-size="7" text-anchor="middle">ack → shutdown_response</text>
<line x1="437" y1="231" x2="461" y2="231" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="464" y="208" width="180" height="46" rx="6" fill="#fff" stroke="#d97706" stroke-width="1"/>
<text x="554" y="226" fill="#92400e" font-size="9" font-weight="600" text-anchor="middle">③ Lead: consume_lead_inbox</text>
<text x="554" y="242" fill="#6b7280" font-size="7" text-anchor="middle">match_response(request_id) → ✓</text>
<!-- Row 3: plan approval protocol -->
<rect x="30" y="280" width="700" height="110" rx="8" fill="#dbeafe" stroke="#2563eb" stroke-width="1.5"/>
<text x="380" y="300" fill="#1e40af" font-size="11" font-weight="700" text-anchor="middle">Protocol B: Plan Approval (Teammate initiates → Lead reviews)</text>
<rect x="50" y="312" width="150" height="46" rx="6" fill="#f1f5f9" stroke="#94a3b8" stroke-width="1"/>
<text x="125" y="330" fill="#475569" font-size="8" font-weight="600" text-anchor="middle">0. Lead: request_plan</text>
<text x="125" y="346" fill="#6b7280" font-size="7" text-anchor="middle">Plain message (msg_type="message")</text>
<line x1="200" y1="335" x2="222" y2="335" stroke="#94a3b8" stroke-width="1" marker-end="url(#arrow)" stroke-dasharray="4,3"/>
<rect x="225" y="312" width="160" height="46" rx="6" fill="#fff" stroke="#2563eb" stroke-width="1"/>
<text x="305" y="330" fill="#1e40af" font-size="8" font-weight="600" text-anchor="middle">① Teammate: submit_plan</text>
<text x="305" y="346" fill="#6b7280" font-size="7" text-anchor="middle">plan_approval_request + request_id</text>
<line x1="385" y1="335" x2="407" y2="335" stroke="#2563eb" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="410" y="312" width="160" height="46" rx="6" fill="#fff" stroke="#2563eb" stroke-width="1"/>
<text x="490" y="330" fill="#1e40af" font-size="8" font-weight="600" text-anchor="middle">② Lead: review_plan</text>
<text x="490" y="346" fill="#6b7280" font-size="7" text-anchor="middle">plan_approval_response</text>
<line x1="570" y1="335" x2="592" y2="335" stroke="#2563eb" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="595" y="312" width="120" height="46" rx="6" fill="#fff" stroke="#2563eb" stroke-width="1"/>
<text x="655" y="330" fill="#1e40af" font-size="8" font-weight="600" text-anchor="middle">③ Teammate: receives</text>
<text x="655" y="346" fill="#6b7280" font-size="7" text-anchor="middle">[Plan approved/rejected]</text>
<text x="380" y="380" fill="#6b7280" font-size="8" text-anchor="middle">request_plan is a plain message, not a protocol; submit_plan is the protocol entry point (creates ProtocolState on teammate side)</text>
<!-- Row 4: State Machine + Storage -->
<rect x="30" y="406" width="340" height="85" rx="8" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1.5"/>
<text x="200" y="426" fill="#4c1d95" font-size="10" font-weight="700" text-anchor="middle">State Machine (shared by both protocols)</text>
<rect x="55" y="440" width="58" height="20" rx="4" fill="#f1f5f9" stroke="#94a3b8" stroke-width="1"/>
<text x="84" y="454" fill="#475569" font-size="9" text-anchor="middle">pending</text>
<line x1="113" y1="450" x2="192" y2="450" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<text x="152" y="445" fill="#16a34a" font-size="8" font-weight="600" text-anchor="middle">approve</text>
<rect x="192" y="440" width="62" height="20" rx="4" fill="#dcfce7" stroke="#16a34a" stroke-width="1"/>
<text x="223" y="454" fill="#166534" font-size="9" text-anchor="middle">approved</text>
<path d="M 84 460 L 84 478 L 128 478" fill="none" stroke="#dc2626" stroke-width="1.5" marker-end="url(#arrow-red)"/>
<text x="76" y="472" fill="#dc2626" font-size="8" font-weight="600" text-anchor="end">reject</text>
<rect x="128" y="468" width="60" height="20" rx="4" fill="#fef2f2" stroke="#dc2626" stroke-width="1"/>
<text x="158" y="482" fill="#991b1b" font-size="9" text-anchor="middle">rejected</text>
<rect x="400" y="406" width="330" height="85" rx="8" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1.5"/>
<text x="565" y="426" fill="#4c1d95" font-size="10" font-weight="700" text-anchor="middle">pending_requests Storage</text>
<text x="420" y="446" fill="#7c3aed" font-size="9">pending_requests: dict[str, ProtocolState]</text>
<text x="420" y="462" fill="#6b7280" font-size="8">request_id → {type, sender, status, created_at}</text>
<text x="420" y="478" fill="#6b7280" font-size="8">match_response: find request by request_id</text>
<!-- Row 5: Two protocols same machine -->
<rect x="30" y="506" width="700" height="52" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<text x="55" y="524" fill="#1e3a5f" font-size="10" font-weight="600">Two protocols, one mechanism:</text>
<rect x="230" y="512" width="130" height="18" rx="4" fill="#fef3c7" stroke="#d97706" stroke-width="1"/>
<text x="295" y="525" fill="#92400e" font-size="9" text-anchor="middle">shutdown_request</text>
<text x="368" y="525" fill="#475569" font-size="10">and</text>
<rect x="390" y="512" width="140" height="18" rx="4" fill="#dbeafe" stroke="#2563eb" stroke-width="1"/>
<text x="460" y="525" fill="#1e40af" font-size="9" text-anchor="middle">plan_approval_request</text>
<text x="55" y="548" fill="#6b7280" font-size="8">Share the same pending → approved / rejected state machine. New protocol type = new msg_type, no new state machine needed. request_id links request and response.</text>
<!-- Row 6: distinguish -->
<rect x="30" y="570" width="700" height="38" rx="6" fill="#f0fdf4" stroke="#16a34a" stroke-width="1"/>
<text x="55" y="588" fill="#166534" font-size="9" font-weight="600">Note:</text>
<text x="100" y="588" fill="#475569" font-size="9">request_plan is a plain message (msg_type="message") sent by lead to prompt a plan submission.</text>
<text x="55" y="602" fill="#475569" font-size="9">submit_plan is the protocol entry point (msg_type="plan_approval_request"), initiated by teammate, carrying request_id into pending_requests.</text>
<!-- Bottom notes -->
<rect x="30" y="622" width="700" height="28" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<rect x="50" y="632" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="70" y="642" fill="#475569" font-size="10">Runtime: MessageBus + persistent teammates + automatic delivery</text>
<rect x="310" y="632" width="12" height="10" rx="2" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1"/>
<text x="330" y="642" fill="#475569" font-size="10">Protocol: request_id + dispatch + pending_requests + plan gate</text>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,141 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 664" 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="#7c3aed"/>
</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-purple" 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="#7c3aed"/>
</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>
<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="664" fill="#fafbfc" rx="8"/>
<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">Team Protocols — リクエスト・レスポンス + request_id 紐付け + 状態機械</text>
<rect x="40" y="56" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="58" y="66" fill="#2563eb" font-size="10" font-weight="600">チームランタイム</text>
<rect x="140" y="56" width="12" height="10" rx="2" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1"/>
<text x="158" y="66" fill="#7c3aed" font-size="10" font-weight="600">協調プロトコル</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 DISPATCHコアツールセット</text>
<text x="394" y="114" fill="#2563eb" font-size="8">bash · read · write · task(4) · spawn · send · inbox</text>
<text x="394" y="128" fill="#7c3aed" font-size="8" font-weight="700">★ request_shutdown · request_plan · review_plan</text>
<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: shutdown protocol -->
<rect x="30" y="176" width="700" height="90" rx="8" fill="#fef3c7" stroke="#d97706" stroke-width="1.5"/>
<text x="380" y="196" fill="#92400e" font-size="11" font-weight="700" text-anchor="middle">プロトコル Ashutdown フローLead が開始 → チームメイトが応答)</text>
<rect x="50" y="208" width="180" height="46" rx="6" fill="#fff" stroke="#d97706" stroke-width="1"/>
<text x="140" y="226" fill="#92400e" font-size="9" font-weight="600" text-anchor="middle">① Lead: request_shutdown</text>
<text x="140" y="242" fill="#6b7280" font-size="7" text-anchor="middle">new_request_id() → ProtocolState</text>
<line x1="230" y1="231" x2="254" y2="231" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="257" y="208" width="180" height="46" rx="6" fill="#fff" stroke="#d97706" stroke-width="1"/>
<text x="347" y="226" fill="#92400e" font-size="9" font-weight="600" text-anchor="middle">② チームメイト: handle_shutdown</text>
<text x="347" y="242" fill="#6b7280" font-size="7" text-anchor="middle">ack → shutdown_response</text>
<line x1="437" y1="231" x2="461" y2="231" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="464" y="208" width="180" height="46" rx="6" fill="#fff" stroke="#d97706" stroke-width="1"/>
<text x="554" y="226" fill="#92400e" font-size="9" font-weight="600" text-anchor="middle">③ Lead: consume_lead_inbox</text>
<text x="554" y="242" fill="#6b7280" font-size="7" text-anchor="middle">match_response(request_id) → ✓</text>
<!-- Row 3: plan approval protocol -->
<rect x="30" y="280" width="700" height="110" rx="8" fill="#dbeafe" stroke="#2563eb" stroke-width="1.5"/>
<text x="380" y="300" fill="#1e40af" font-size="11" font-weight="700" text-anchor="middle">プロトコル Bplan approval フロー(チームメイトが開始 → Lead が審査)</text>
<rect x="50" y="312" width="150" height="46" rx="6" fill="#f1f5f9" stroke="#94a3b8" stroke-width="1"/>
<text x="125" y="330" fill="#475569" font-size="8" font-weight="600" text-anchor="middle">0. Lead: request_plan</text>
<text x="125" y="346" fill="#6b7280" font-size="7" text-anchor="middle">通常メッセージmsg_type="message"</text>
<line x1="200" y1="335" x2="222" y2="335" stroke="#94a3b8" stroke-width="1" marker-end="url(#arrow)" stroke-dasharray="4,3"/>
<rect x="225" y="312" width="160" height="46" rx="6" fill="#fff" stroke="#2563eb" stroke-width="1"/>
<text x="305" y="330" fill="#1e40af" font-size="8" font-weight="600" text-anchor="middle">① チームメイト: submit_plan</text>
<text x="305" y="346" fill="#6b7280" font-size="7" text-anchor="middle">plan_approval_request + request_id</text>
<line x1="385" y1="335" x2="407" y2="335" stroke="#2563eb" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="410" y="312" width="160" height="46" rx="6" fill="#fff" stroke="#2563eb" stroke-width="1"/>
<text x="490" y="330" fill="#1e40af" font-size="8" font-weight="600" text-anchor="middle">② Lead: review_plan</text>
<text x="490" y="346" fill="#6b7280" font-size="7" text-anchor="middle">plan_approval_response</text>
<line x1="570" y1="335" x2="592" y2="335" stroke="#2563eb" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="595" y="312" width="120" height="46" rx="6" fill="#fff" stroke="#2563eb" stroke-width="1"/>
<text x="655" y="330" fill="#1e40af" font-size="8" font-weight="600" text-anchor="middle">③ チームメイト: 受信</text>
<text x="655" y="346" fill="#6b7280" font-size="7" text-anchor="middle">[Plan approved/rejected]</text>
<text x="380" y="380" fill="#6b7280" font-size="8" text-anchor="middle">request_plan は通常メッセージでプロトコルではない。submit_plan がプロトコルエントリポイント(チームメイト側で ProtocolState を作成)</text>
<!-- Row 4: State Machine + Storage -->
<rect x="30" y="406" width="340" height="85" rx="8" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1.5"/>
<text x="200" y="426" fill="#4c1d95" font-size="10" font-weight="700" text-anchor="middle">状態機械(両プロトコルで共用)</text>
<rect x="55" y="440" width="58" height="20" rx="4" fill="#f1f5f9" stroke="#94a3b8" stroke-width="1"/>
<text x="84" y="454" fill="#475569" font-size="9" text-anchor="middle">pending</text>
<line x1="113" y1="450" x2="192" y2="450" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<text x="152" y="445" fill="#16a34a" font-size="8" font-weight="600" text-anchor="middle">approve</text>
<rect x="192" y="440" width="62" height="20" rx="4" fill="#dcfce7" stroke="#16a34a" stroke-width="1"/>
<text x="223" y="454" fill="#166534" font-size="9" text-anchor="middle">approved</text>
<path d="M 84 460 L 84 478 L 128 478" fill="none" stroke="#dc2626" stroke-width="1.5" marker-end="url(#arrow-red)"/>
<text x="76" y="472" fill="#dc2626" font-size="8" font-weight="600" text-anchor="end">reject</text>
<rect x="128" y="468" width="60" height="20" rx="4" fill="#fef2f2" stroke="#dc2626" stroke-width="1"/>
<text x="158" y="482" fill="#991b1b" font-size="9" text-anchor="middle">rejected</text>
<rect x="400" y="406" width="330" height="85" rx="8" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1.5"/>
<text x="565" y="426" fill="#4c1d95" font-size="10" font-weight="700" text-anchor="middle">pending_requests ストレージ</text>
<text x="420" y="446" fill="#7c3aed" font-size="9">pending_requests: dict[str, ProtocolState]</text>
<text x="420" y="462" fill="#6b7280" font-size="8">request_id → {type, sender, status, created_at}</text>
<text x="420" y="478" fill="#6b7280" font-size="8">match_response: request_id でリクエストを検索</text>
<!-- Row 5: Two protocols same machine -->
<rect x="30" y="506" width="700" height="52" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<text x="55" y="524" fill="#1e3a5f" font-size="10" font-weight="600">2つのプロトコル、1つのメカニズム</text>
<rect x="270" y="512" width="130" height="18" rx="4" fill="#fef3c7" stroke="#d97706" stroke-width="1"/>
<text x="335" y="525" fill="#92400e" font-size="9" text-anchor="middle">shutdown_request</text>
<text x="408" y="525" fill="#475569" font-size="10"></text>
<rect x="425" y="512" width="140" height="18" rx="4" fill="#dbeafe" stroke="#2563eb" stroke-width="1"/>
<text x="495" y="525" fill="#1e40af" font-size="9" text-anchor="middle">plan_approval_request</text>
<text x="55" y="548" fill="#6b7280" font-size="8">同じ pending → approved / rejected 状態機械を共有。新しいプロトコルタイプ = 新しい msg_type、新しい状態機械は不要。request_id がリクエストとレスポンスを関連付ける。</text>
<!-- Row 6: distinguish -->
<rect x="30" y="570" width="700" height="38" rx="6" fill="#f0fdf4" stroke="#16a34a" stroke-width="1"/>
<text x="55" y="588" fill="#166534" font-size="9" font-weight="600">区別:</text>
<text x="100" y="588" fill="#475569" font-size="9">request_plan は通常メッセージmsg_type="message"で、lead がチームメイトに計画提出を促すために送信する。</text>
<text x="55" y="602" fill="#475569" font-size="9">submit_plan がプロトコルエントリポイントmsg_type="plan_approval_request"で、チームメイトが自発的に開始し、request_id を pending_requests に書き込む。</text>
<!-- Bottom notes -->
<rect x="30" y="622" width="700" height="28" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<rect x="50" y="632" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="70" y="642" fill="#475569" font-size="10">Runtime: MessageBus + 永続チームメイト + 自動イベント配信</text>
<rect x="310" y="632" width="12" height="10" rx="2" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1"/>
<text x="330" y="642" fill="#475569" font-size="10">Protocol: request_id + dispatch + pending_requests + プランゲート</text>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,141 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 660" 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="#7c3aed"/>
</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-purple" 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="#7c3aed"/>
</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>
<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="660" fill="#fafbfc" rx="8"/>
<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">Team Protocols — 请求-响应协议 + request_id 关联 + 状态机</text>
<rect x="40" y="56" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="58" y="66" fill="#2563eb" font-size="10" font-weight="600">团队运行时</text>
<rect x="140" y="56" width="12" height="10" rx="2" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1"/>
<text x="158" y="66" fill="#7c3aed" font-size="10" font-weight="600">协作协议</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 DISPATCH核心工具集</text>
<text x="394" y="114" fill="#2563eb" font-size="8">bash · read · write · task(4) · spawn · send · inbox</text>
<text x="394" y="128" fill="#7c3aed" font-size="8" font-weight="700">★ request_shutdown · request_plan · review_plan</text>
<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: shutdown protocol -->
<rect x="30" y="176" width="700" height="90" rx="8" fill="#fef3c7" stroke="#d97706" stroke-width="1.5"/>
<text x="380" y="196" fill="#92400e" font-size="11" font-weight="700" text-anchor="middle">协议 Ashutdown 流程Lead 发起 → 队友响应)</text>
<rect x="50" y="208" width="180" height="46" rx="6" fill="#fff" stroke="#d97706" stroke-width="1"/>
<text x="140" y="226" fill="#92400e" font-size="9" font-weight="600" text-anchor="middle">① Lead: request_shutdown</text>
<text x="140" y="242" fill="#6b7280" font-size="7" text-anchor="middle">new_request_id() → ProtocolState</text>
<line x1="230" y1="231" x2="254" y2="231" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="257" y="208" width="180" height="46" rx="6" fill="#fff" stroke="#d97706" stroke-width="1"/>
<text x="347" y="226" fill="#92400e" font-size="9" font-weight="600" text-anchor="middle">② 队友: handle_shutdown</text>
<text x="347" y="242" fill="#6b7280" font-size="7" text-anchor="middle">ack → shutdown_response</text>
<line x1="437" y1="231" x2="461" y2="231" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="464" y="208" width="180" height="46" rx="6" fill="#fff" stroke="#d97706" stroke-width="1"/>
<text x="554" y="226" fill="#92400e" font-size="9" font-weight="600" text-anchor="middle">③ Lead: consume_lead_inbox</text>
<text x="554" y="242" fill="#6b7280" font-size="7" text-anchor="middle">match_response(request_id) → ✓</text>
<!-- Row 3: plan approval protocol -->
<rect x="30" y="280" width="700" height="110" rx="8" fill="#dbeafe" stroke="#2563eb" stroke-width="1.5"/>
<text x="380" y="300" fill="#1e40af" font-size="11" font-weight="700" text-anchor="middle">协议 Bplan approval 流程(队友发起 → Lead 审批)</text>
<rect x="50" y="312" width="150" height="46" rx="6" fill="#f1f5f9" stroke="#94a3b8" stroke-width="1"/>
<text x="125" y="330" fill="#475569" font-size="8" font-weight="600" text-anchor="middle">0. Lead: request_plan</text>
<text x="125" y="346" fill="#6b7280" font-size="7" text-anchor="middle">普通消息msg_type="message"</text>
<line x1="200" y1="335" x2="222" y2="335" stroke="#94a3b8" stroke-width="1" marker-end="url(#arrow)" stroke-dasharray="4,3"/>
<rect x="225" y="312" width="160" height="46" rx="6" fill="#fff" stroke="#2563eb" stroke-width="1"/>
<text x="305" y="330" fill="#1e40af" font-size="8" font-weight="600" text-anchor="middle">① 队友: submit_plan</text>
<text x="305" y="346" fill="#6b7280" font-size="7" text-anchor="middle">plan_approval_request + request_id</text>
<line x1="385" y1="335" x2="407" y2="335" stroke="#2563eb" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="410" y="312" width="160" height="46" rx="6" fill="#fff" stroke="#2563eb" stroke-width="1"/>
<text x="490" y="330" fill="#1e40af" font-size="8" font-weight="600" text-anchor="middle">② Lead: review_plan</text>
<text x="490" y="346" fill="#6b7280" font-size="7" text-anchor="middle">plan_approval_response</text>
<line x1="570" y1="335" x2="592" y2="335" stroke="#2563eb" stroke-width="1.5" marker-end="url(#arrow)"/>
<rect x="595" y="312" width="120" height="46" rx="6" fill="#fff" stroke="#2563eb" stroke-width="1"/>
<text x="655" y="330" fill="#1e40af" font-size="8" font-weight="600" text-anchor="middle">③ 队友: 收到结果</text>
<text x="655" y="346" fill="#6b7280" font-size="7" text-anchor="middle">[Plan approved/rejected]</text>
<text x="380" y="380" fill="#6b7280" font-size="8" text-anchor="middle">request_plan 是普通消息不是协议submit_plan 才是协议入口(由队友端创建 ProtocolState</text>
<!-- Row 4: State Machine + Storage -->
<rect x="30" y="406" width="340" height="85" rx="8" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1.5"/>
<text x="200" y="426" fill="#4c1d95" font-size="10" font-weight="700" text-anchor="middle">状态机(同一套,两种协议共用)</text>
<rect x="55" y="440" width="58" height="20" rx="4" fill="#f1f5f9" stroke="#94a3b8" stroke-width="1"/>
<text x="84" y="454" fill="#475569" font-size="9" text-anchor="middle">pending</text>
<line x1="113" y1="450" x2="192" y2="450" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<text x="152" y="445" fill="#16a34a" font-size="8" font-weight="600" text-anchor="middle">approve</text>
<rect x="192" y="440" width="62" height="20" rx="4" fill="#dcfce7" stroke="#16a34a" stroke-width="1"/>
<text x="223" y="454" fill="#166534" font-size="9" text-anchor="middle">approved</text>
<path d="M 84 460 L 84 478 L 128 478" fill="none" stroke="#dc2626" stroke-width="1.5" marker-end="url(#arrow-red)"/>
<text x="76" y="472" fill="#dc2626" font-size="8" font-weight="600" text-anchor="end">reject</text>
<rect x="128" y="468" width="60" height="20" rx="4" fill="#fef2f2" stroke="#dc2626" stroke-width="1"/>
<text x="158" y="482" fill="#991b1b" font-size="9" text-anchor="middle">rejected</text>
<rect x="400" y="406" width="330" height="85" rx="8" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1.5"/>
<text x="565" y="426" fill="#4c1d95" font-size="10" font-weight="700" text-anchor="middle">pending_requests 存储</text>
<text x="420" y="446" fill="#7c3aed" font-size="9">pending_requests: dict[str, ProtocolState]</text>
<text x="420" y="462" fill="#6b7280" font-size="8">request_id → {type, sender, status, created_at}</text>
<text x="420" y="478" fill="#6b7280" font-size="8">match_response: 按 request_id 找回对应请求</text>
<!-- Row 5: Two protocols same machine -->
<rect x="30" y="506" width="700" height="52" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<text x="55" y="524" fill="#1e3a5f" font-size="10" font-weight="600">两种协议,同一套机制:</text>
<rect x="230" y="512" width="130" height="18" rx="4" fill="#fef3c7" stroke="#d97706" stroke-width="1"/>
<text x="295" y="525" fill="#92400e" font-size="9" text-anchor="middle">shutdown_request</text>
<text x="368" y="525" fill="#475569" font-size="10"></text>
<rect x="390" y="512" width="140" height="18" rx="4" fill="#dbeafe" stroke="#2563eb" stroke-width="1"/>
<text x="460" y="525" fill="#1e40af" font-size="9" text-anchor="middle">plan_approval_request</text>
<text x="55" y="548" fill="#6b7280" font-size="8">共用 pending → approved / rejected 状态机。新增协议类型 = 新的 msg_type不需要新状态机。request_id 关联请求和响应。</text>
<!-- Row 6: distinguish -->
<rect x="30" y="570" width="700" height="38" rx="6" fill="#f0fdf4" stroke="#16a34a" stroke-width="1"/>
<text x="55" y="588" fill="#166534" font-size="9" font-weight="600">区分:</text>
<text x="100" y="588" fill="#475569" font-size="9">request_plan 是普通消息msg_type="message"),由 lead 发送给队友提示去提交计划。</text>
<text x="55" y="602" fill="#475569" font-size="9">submit_plan 才是协议入口msg_type="plan_approval_request"),由队友主动发起,携带 request_id 写入 pending_requests。</text>
<!-- Bottom notes -->
<rect x="30" y="622" width="700" height="28" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<rect x="50" y="632" width="12" height="10" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="70" y="642" fill="#475569" font-size="10">运行时: MessageBus + 持久队友 + 自动事件投递</text>
<rect x="310" y="632" width="12" height="10" rx="2" fill="#f5f3ff" stroke="#7c3aed" stroke-width="1"/>
<text x="330" y="642" fill="#475569" font-size="10">协议: request_id + dispatch + pending_requests + 计划闸门</text>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

View File

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

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

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

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

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

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB