Files
analysis_claude_code/s15_agent_teams/README.ja.md

20 KiB
Raw Blame History

s15: Agent Teams — チームランタイムと協調プロトコル

English · 中文 · 日本語

s01 → ... → s13 → s14 → s15s16 → s17 → s18 → s19

「1 つの Agent で仕事全体を抱えきれないなら、チームメイトで分担する。」 — 永続チームメイト、共有タスクの Claim、任意の worktree、協調プロトコル。

Harness レイヤーTeam — 複数の Agent が Lead の管理下で仕事を分担し、状態を共有する仕組み。


問題

Agent にバックエンド全体のリファクタリングを依頼するとする。作業範囲は設定の読み込み、認証、テストにまたがる。1 つの Agent でも順番に処理できるが、時間がかかり、初期の詳細は少しずつコンテキストから抜けていく。

この仕事は並列化に向いている。ただし、ユーザーは通常、チーム構成ではなく目標を伝える:

このサンプルバックエンドをリファクタリングしてください。
設定の読み込み、認証、テストを整理し、既存インターフェースを保ち、
テストが通ることを確認してください。

Harness は、つながった 6 つの問題を扱う必要がある:

  1. 並列作業が有効だと誰が判断し、追加の Agent を誰が承認するのか。
  2. 各チームメイトは、複数の割り当てをまたいで識別子とコンテキストをどう保つのか。
  3. モデルに受信箱をポーリングさせず、結果を Lead へどう返すのか。
  4. IDLE のチームメイトは、次の指示を待たずに ready task を引き受けられるか。
  5. 並列編集が衝突し得る時、タスクはどの作業ディレクトリを使うのか。
  6. shutdown と計画承認を、追跡できて実際に制約をかけるプロトコルにするにはどうするか。

解決策

Agent Teams Overview

s15 は、単一 Agent の Harness に Lead 管理のチームランタイムを加える:

  • Lead はユーザーとの会話を担当し、分担案を示して確認を待つ。
  • チームメイト は独立した Agent Loop を実行し、WORK と IDLE を行き来する。
  • MessageBus は、ファイルベースの受信箱で通常メッセージ、結果、制御イベントを運ぶ。
  • ランタイム配信 は Lead の受信箱を消費し、チームイベントを次のターンへ追加する。
  • 共有タスクボード により、IDLE のチームメイトは ready task を探し、ロック下で Claim できる。
  • 任意の worktree は、必要なタスクだけを別の作業ディレクトリへ紐付ける。紐付けのないタスクは通常のリポジトリディレクトリを使う。
  • 型付きプロトコルと計画ゲート は shutdown と承認状態を明示し、必要な計画が承認されるまで変更系ツールを止める。

これらはすべて Team Harness レイヤーの一部である。タスク発見のために別の Agent Loop は要らず、worktree が別種の Agent を作るわけでもない。


仕組み

1. Lead はチーム案を示し、ユーザーの確認を待つ

チームメイトを起動すると、コスト、並行度、ワークスペースを編集できる主体が変わる。Lead のシステムプロンプトは、その境界を明示する:

"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 は分担案だけを示す:

3 つの領域を並行して進めることを提案します:
- config設定の読み込みを整理
- auth認証をリファクタリング
- tests回帰テストを追加

確認後にチームメイトを起動します。

ユーザーが「始めてください」と返した後、Lead は spawn_teammate を呼べる。ユーザーが目標を示し、Lead がチームを設計し、ユーザーが実行境界を確認する。

2. 各チームメイトは独立したループを持つ

s06 の subagent は 1 回限りの呼び出しである。チームメイトは永続する実行単位だ:

s06 Subagent s15 Teammate
ライフサイクル 1 回の呼び出し後に終了 shutdown まで WORK → IDLE → WORK
コンテキスト 1 つのタスクにだけ存在 割り当てをまたいで保持
通信 1 回だけ結果を返す メッセージを受け取りイベントを送る
協調 一方向の委譲 Lead との双方向協調

spawn_teammate_thread() は、各チームメイト専用のシステムプロンプト、messages、ツール、現在の作業ディレクトリ状態を用意し、daemon thread でループを実行する。チームメイトの作業中も Lead は調整を続けられる。leadagent はランタイム識別子として予約されるが、MessageBus はコーディネーターの受信箱として lead を引き続き受け付ける。

3. MessageBus は通信をモデルのコンテキスト外に置く

Lead とチームメイトは同じ messages 配列を共有できない。共有すると、あるチームメイトのツール結果が別のチームメイトの推論へ混ざる。MessageBus は Agent ごとに .mailboxes/<name>.jsonl 受信箱を用意する:

class MessageBus:
    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:
            with open(self._path(to_agent), "a") as f:
                f.write(json.dumps(msg) + "\n")
            self._changed.notify_all()

    def wait_for_messages(self, agent, timeout=None):
        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)

ロックは、チームメイトによる受信箱ファイルの並行アクセスを保護する。Condition はメッセージ到着時にチームメイトを起こし、IDLE 中の短い timeout にも使える。

4. 受信イベントはランタイムが配信する

read_inbox() は受信箱ファイルを読み取って削除するため、Lead 側の消費処理は consume_lead_inbox() だけにする:

def consume_lead_inbox():
    messages = BUS.read_inbox("lead")
    for message in messages:
        if message["type"].endswith("_response"):
            match_response(...)
    return messages

メインループの隣で動くイベントスレッドが、新しいメッセージの到着時に Lead を起こす:

MessageBus → consume_lead_inbox
           → プロトコル状態を更新
           → [Team events] を history に追加
           → Lead の次ターンを開始

check_inbox はモデルのツールではない。メッセージの到着と消費はランタイムが担当し、モデルはコンテキストへ配信済みのイベントを処理する。

5. 結果と IDLE は別のイベントである

チームメイトが 1 つの割り当てを終えると、ランタイムは 2 つのイベントを順に送る:

result:            "認証をリファクタリングし、関連テストが通りました。"
idle_notification: "Waiting for more work."

result は「この割り当てで何ができたか」、idle_notification は「このチームメイトが次の仕事を受けられるか」を表す。曖昧な「完了」だけでは、両方の状態を表せない。

IDLE のチームメイトは終了しない。直接メッセージか ready task を受けると WORK に戻り、shutdown_request を受けると段階的な shutdown handshake を始める。

6. IDLE は受信箱を先に確認し、その後 ready task を探す

IDLE ではメッセージを優先し、その後に共有タスクボードを確認する:

while True:
    inbox = BUS.wait_for_messages(name, IDLE_SCAN_INTERVAL)
    if inbox:
        should_stop = handle_messages(inbox)
        if should_stop or messages[-1]["role"] == "user":
            break
        continue

    task = claim_next_task(name)
    if task:
        messages.append({
            "role": "user",
            "content": f"[Auto-claimed task {task.id}] {task.subject}",
        })
        break

shutdown、計画承認、Lead からの直接指示は、空き時間に見つけた仕事より先に扱う。メッセージも ready task もなければ、チームメイトは IDLE を続ける。別のチームメイトが前提タスクを完了すると、blocked task が ready になることもある。

7. 発見と Claim を分け、Claim はアトミックに行う

走査は候補を探すだけで、状態を変更しない:

def scan_unclaimed_tasks() -> list[Task]:
    return [
        task for task in list_tasks()
        if task.status == "pending"
        and task.owner is None
        and can_start(task.id)
    ]

候補一覧は一時点の snapshot にすぎない。別のチームメイトも同じタスクを見る可能性があるため、所有権の変更は task_lock で保護した claim_task() 内で行う:

def claim_task(task_id: str, owner: str) -> str:
    with task_lock:
        task = load_task(task_id)
        if task.status != "pending" or task.owner is not None:
            return "Task is no longer available"
        if _owner_in_progress(owner):
            return "Owner must complete its current task first"
        if not can_start(task_id):
            return "Task is blocked"
        cwd, error = task_worktree_cwd(task)
        if error:
            return f"Cannot claim {task_id}: {error}"
        task.owner = owner
        task.status = "in_progress"
        save_task(task)
        teammate_assignments[owner] = {"task_id": task.id, "cwd": cwd}
        return f"Claimed {task.id}"

複数のチームメイトが同じ候補を発見しても、in_progress へ進められる Claim は 1 つだけである。現在のタスクを完了するまで、チームメイトは次のタスクを Claim できない。worktree の紐付けが壊れている場合、リポジトリディレクトリへ戻さず Claim を失敗させる。

8. Claim した仕事は同じ WORK ループを再利用する

Claim に成功すると、ランタイムはタスク ID、件名、説明をチームメイトの messages へ追加する:

ready task が現れる
  → IDLE のチームメイトが発見
  → claim_task が owner と in_progress を記録
  → タスクがチームメイトの messages に入る
  → WORK
  → complete_task
  → result + idle_notification
  → IDLE

チームメイトは、Lead が直接割り当てた時と同じモデル呼び出し、ファイルツール、Shell、計画ゲート、結果通知、shutdown protocol を使う。タスク発見は、既存の WORK ループへの別の入口である。

9. タスクがツールの作業ディレクトリを選ぶ

Task.worktree は任意フィールドである:

@dataclass
class Task:
    id: str
    subject: str
    description: str
    status: str
    owner: str | None
    blockedBy: list[str]
    worktree: str | None = None

並列編集を別ディレクトリに分けたい時、Lead は worktree を作成してタスクへ紐付けられる:

create_worktree(name="auth-refactor", task_id="task_1234")

create_worktree は Lead 専用ツールである。pending、owner なし、worktree 未設定のタスクを受け取り、名前、パス、ブランチ、Git registry を確認する。checkout の作成後にだけタスクへ紐付ける。Git が失敗を返しても branch や登録済み checkout が残った場合は partial operation を報告し、task は未紐付けのまま、それらを manual recovery 用に保持する。チームメイトが使うのはタスクツールとファイルツールである。

Claim 時に、解決済みのディレクトリを teammate_assignments へ保存する。チームメイトの bashread_filewrite_file wrapper は assignment からディレクトリを読む。worktree のないタスクは WORKDIR に解決されるため、worktree は opt-in である:

cwd, error = task_worktree_cwd(task)
if not error:
    teammate_assignments[owner] = {
        "task_id": task.id,
        "cwd": cwd,
    }

complete_task(task_id, owner) は、呼び出し元が進行中タスクの owner か確認する。ランタイムが assignment を削除するのは完了に成功した時だけである。失敗時はタスクのディレクトリを維持し、チームメイトが修正して再試行できるようにする。タスクの worktree 紐付けは checkout を削除するまで残る。

Worktree が分離するのは Git の作業ディレクトリとブランチであり、sandbox ではない。Shell コマンドは親プロセスに許可されたパスやリソースへアクセスできる。

10. Worktree のクリーンアップはデフォルトで作業を残す

モデル向けの remove_worktree(name) tool は、pending または in_progress のタスクに紐付いた worktree の削除を拒否する。タスク完了後も tracked、untracked、ignored file をすべて未コミットデータとして扱い、clean な checkout だけを --force なしで削除する。

低レベルの Python helper は、host が別途ユーザーの明示的な確認を得た場合のために discard_changes=True を残すが、この parameter はモデルの tool schema にはない。変更のある worktree は削除せず、user が確認できる状態で残す。どちらの削除経路でも wt/<name> ブランチはリポジトリに残り、upstream のない clean な local commit も保持される。削除成功後は checkout が存在しないため、タスクの worktree 紐付けを解除する。

clean worktree   → ディレクトリを削除し、wt/<name> ブランチは保持
changed worktree → model tool は拒否し、保持か破棄かを user が決める
pending/running task → 削除を拒否

タスク完了と worktree cleanup も分かれている。complete_task はタスク結果を記録し、Lead はその後に worktree を確認、merge、keep、remove できる。

11. 制御メッセージには型と request_id を使う

通常の協調には自由形式のテキストを使えるが、shutdown と承認を意図の推測に任せるべきではない。これらは構造化メッセージを使う:

Team Protocols

@dataclass
class ProtocolState:
    request_id: str
    type: str
    sender: str
    target: str
    status: str
    payload: str


pending_requests: dict[str, ProtocolState] = {}

shutdown の流れは次の通り:

Lead が pending の shutdown request を作る
  → shutdown_request(request_id) がチームメイトの受信箱に入る
  → チームメイトが現在のステップを終える
  → shutdown_response(request_id) が Lead へ戻る
  → request_id で元の request を特定する
  → pending が approved になり、チームメイトの loop が終了する

ID は応答を 1 つの request に対応付け、型は不一致の応答による状態変更を防ぎ、status は同じ応答の二重適用を防ぐ。

12. 計画承認は実行も制約する

計画プロトコルは逆方向に進む:

Lead → plan_request
チームメイト → plan_approval_request(request_id, plan)
Lead → plan_approval_response(request_id, approve, feedback)

ツール dispatch がゲートを強制する:

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)

状態が requiredpendingrejected の間、チームメイトはファイルを読み、計画を提出または修正できるが、Shell コマンドの実行とファイルの書き込みはできない。承認応答で状態が approved になると、ツールを使えるようになる。


一連の実行例

s15 >> バックエンドのリファクタリングを共有タスクボードに分解し、
       設定、認証、テストを可能な範囲で並行実行してください。
       認証には worktree を使い、既存インターフェースを保ち、
       テストが通ることを確認してください。

Leadconfig、auth、tests の 3 領域に分けることを提案します。
      チームを起動しますか?

s15 >> 始めてください

[task] config created
[task] auth created → worktree auth-refactor
[task] tests created
[teammate] alice spawned
[teammate] bob spawned
[claim] alice → config (cwd: repository)
[claim] bob → auth (cwd: .worktrees/auth-refactor)
[complete] auth
[bus] bob → lead (result) ...
[bus] bob → lead (idle_notification) ...
[wake: 2 team events → new turn]
Lead認証タスクの結果を受け取りました。残りの作業を調整します。

ターミナルには、ユーザーの要求、Lead の提案、タスク状態、Claim、選択されたディレクトリ、結果、IDLE 遷移、制御イベントが表示される。ユーザーが Lead を指定したり、受信箱の確認を依頼したりする必要はない。


s14 からの変更

コンポーネント s14 s15
Agent 1 つの Agent 1 つの Lead と永続チームメイト
ユーザーフロー 要求を実行 チーム案を示してから起動確認
通信 なし ファイル受信箱とランタイム配信
ライフサイクル 1 つのループ チームメイトの WORK / IDLE / shutdown
共有作業 Lead の既存タスクツール IDLE 走査とチームメイトのアトミックな Claim
作業ディレクトリ リポジトリの WORKDIR デフォルトは WORKDIR、タスクごとに worktree を選択可能
結果通知 現在の Agent の出力 resultidle_notification を分離
制御 なし 型付き shutdown と計画承認プロトコル
強制 チーム向け制約なし 必須計画が変更系ツールをゲート

試してみる

cd learn-claude-code
python s15_agent_teams/code.py

通常の要求を入力する:

バックエンドのリファクタリングを共有タスクボードへ分解し、依存関係が
許す範囲で設定、認証、テストを並行実行してください。認証には worktree
を使い、既存インターフェースを維持して、最後に結果をまとめてください。

Lead がチーム案を示したら、次のように返す:

始めてください

.tasks/pendingin_progresscompleted と変化する様子、.mailboxes/resultidle_notification を配信する様子、紐付けたタスクにだけ .worktrees/ が作られることを確認する。直接メッセージがタスクボード走査より優先されることと、complete_task の失敗後もチームメイトの作業ディレクトリが変わらないことも確認できる。


次へ

チームランタイムは、委譲、共有タスクの Claim、任意の作業ディレクトリを扱えるようになった。ただし、ツールは今も Python コードへ直接定義している。

次のレッスンでは、標準の発見・呼び出しプロトコルを使って外部ツールへ接続する。

次へ:s16 MCP Tools