16 KiB
Pod オーケストレーション: LLM によるマルチエージェント分業
背景
Insomnia の差別化の中核は「複数の Pod が独立プロセスとして並行動作し、LLM が自律的に分業・統括できる」点にある。シングルエージェントの LLM リグが1つのコンテキストで全タスクを処理するのに対し、Insomnia はタスクを別 Pod に委譲し、結果を集約してフィードバックするオーケストレーションを LLM 自身に行わせることで、コンテキスト長・専門性・並列性の壁を突破する。
現状の Pod は単独で動くことしかできず、Pod 間の連携は外部の人間が仲介するしかない。LLM が「この調査は別の Pod に任せて、結果が返ってきたら本筋に統合する」という判断をツールとして実行できる仕組みが無い。
ゴール
Pod の LLM が、ツール呼び出しを通じて別 Pod のライフサイクル全体(生成・指示・結果読み取り・終了)を制御でき、spawned Pod の進捗を非同期に受け取れる基盤を設計・実装する。あわせて、人間がこの Pod ネットワークを監視・介入できる観測経路を用意する。
コンセプト: 独立したピアとしての Pod
Human (GUI / TUI) ─── Pod A (orchestrator)
├── socket ──→ Pod B (researcher)
├── socket ──→ Pod C (coder)
│ └── socket ──→ Pod D (reviewer)
└── socket ──→ Pod E (tester)
プロセス独立
- spawn された Pod は 完全に独立したプロセス として動作する。OS レベルの親子関係(subprocess)は持たない
- spawner が落ちても、spawned Pod は続行する。再接続も可能
- spawner と spawned Pod の関係は「socket client として接続している」だけ。関係性はプロセスではなくソケット接続で表現される
- すべての Pod は同格。「誰が spawn したか」は Pod の runtime 属性であり、プロセスの従属関係ではない
- Pod の発見にはレジストリ(runtime_dir 内の socket パスを列挙、または明示的な登録)が必要
Scope の分譲
- Pod A が Pod B を spawn するとき、A は自身の scope の一部を B に譲渡する
- 譲渡した scope 領域は A の effective scope から deny される(A は自分が譲った部分にアクセスできなくなる)
- これにより構造的に排他制御が保証される:同一パスに対して write 権限を持つ Pod は常に高々1つ
- Pod B が終了すると、譲渡された scope は A に返却される(A の deny が解除される)
- Pod B が scope の一部をさらに Pod D に分譲した場合、B の終了時に D が保持している分は D にそのまま残る(D は独立して生きているため)。D が終了するまで、その scope 領域は誰にも返却されない
tickets/scope-exclusion.md との関係
scope 分譲により Pod 間の write 排他制御は spawn 時に構造的に保証される。tickets/scope-exclusion.md が扱おうとしていた「複数 Pod が同一パスに同時 write する問題」は、本チケットの scope 分譲モデルで吸収される。
ただし、分譲ではなく共有 read (複数 Pod が同じパスを同時に read する) は引き続き許可される。read 同士は衝突しない。
LLM に公開するツール群
Pod の LLM が使えるツールとして以下を設計する。いずれも通常の Tool trait 実装で、Worker に登録される。
SpawnPod
新しい Pod を起動し、scope の一部を譲渡する。
入力:
name: spawned Pod の識別名(spawner のスコープ内で一意)instruction: spawned Pod の instruction ファイル参照(省略時は$insomnia/default)task: spawned Pod への最初のメッセージ(spawn 後に即座に run される)scope: 譲渡する scope 定義(allow / deny ルール)。spawner の現在の effective scope のサブセットでなければならない。バリデーションで超過分は拒否
出力:
- spawned Pod の識別子(
pod_id)と接続状態
内部動作:
- spawner の effective scope から、譲渡する scope 領域を deny に追加(spawner 側の scope を縮小)
- PodFactory のカスケード(user manifest + project manifest)に加え、spawner からの overlay(譲渡 scope + instruction + provider 等)を重ねて spawned Pod の PodManifest を構築
- 独立プロセスとして Pod を起動、socket 確立
taskをMethod::Runで送信
SendToPod
spawned Pod にメッセージを送る。
入力:
pod_id: 対象の Podmessage: 送信するテキスト
出力:
- 送信確認
ReadPodOutput
spawned Pod の最新の出力を読む。
入力:
pod_id: 対象の Podsince: 前回読んだ時点からの差分のみ取得するオプション(省略時は全出力)
出力:
- 対象 Pod の assistant 応答テキスト(最新ターン or 差分)
- 現在の状態(
running/idle/stopped)
StopPod
spawned Pod を終了させ、譲渡した scope を回収する。
入力:
pod_id: 対象の Pod
出力:
- 終了確認
- 回収された scope の要約
内部動作:
- 対象 Pod に graceful shutdown を要求
- 対象 Pod が保持していた scope のうち、さらに下流に分譲されていない分を spawner に返却
- spawner の deny リストから返却分を解除
ListPods
spawner が spawn した Pod の一覧とそれぞれの状態を返す。
入力: なし
出力:
- spawned Pod の
pod_id、name、status(running / idle / stopped)、譲渡中の scope 要約、最終応答の要約
非同期通知
spawned Pod が出力を生成したとき、spawner の LLM にそれを伝える仕組みが必要。ただし LLM は「ターン」の単位で動くため、イベントストリームをリアルタイムに割り込ませるのは不自然。
方針: ターン間フックで集約通知
- spawner のターンが終了した後、次のターンの開始前にフックが走り、spawned Pod から届いているイベントを集約する
- 集約された通知は次のターンの先頭に system message として注入される(例:
[pod "researcher"] completed: found 3 relevant files) - 通知が無い場合は何もしない
通知の粒度
- ターン完了通知: spawned Pod の1ターンが終わったとき
- エラー通知: spawned Pod でエラーが発生したとき
- 終了通知: spawned Pod が停止したとき(scope 返却が発生する)
ツール出力の内容自体は ReadPodOutput で明示的に取りに行くモデル。通知は「何か変化があった」というシグナルのみで、内容全体は含まない(spawner のコンテキストを圧迫しない)。
Scope の分譲と回収の詳細
分譲時
- spawner が
SpawnPodでscopeを指定 - システムが検証: 指定された scope が spawner の 現在の effective scope のサブセットであること
- 検証通過後、spawner の effective scope から譲渡分を deny として差し引く
- spawned Pod は譲渡された scope を自身の allow として持って起動
回収時(StopPod または spawned Pod の自発的終了)
- 終了する Pod が保持している effective scope を確認
- そのうち、さらに下流に分譲中の scope は除外(下流 Pod が生きている間は返却されない)
- 残りの scope を spawner の deny から解除し、spawner の effective scope に復帰
分譲チェーンのケース
Pod A が Pod B に /src を分譲 → Pod B が Pod D に /src/core を分譲:
- A の scope:
/srcは deny - B の scope:
/srcを持つが/src/coreは deny(D に分譲済み) - D の scope:
/src/core
B が終了すると:
/srcのうち D に分譲中の/src/coreは返却されない(D が生きている)/srcから/src/coreを除いた部分が A に返却される- A の scope:
/srcの/src/core以外が復帰。/src/coreは引き続き deny(D が保持中) - D が終了すると
/src/coreが A に返却される
runtime scope state
- Pod の scope は manifest の static 定義だけでは決まらない。分譲と回収による動的な state が加わる
- この state を管理するレイヤーが必要(Pod 内のメモリ状態 + 永続化の選択肢)
- spawner がクラッシュして復帰した場合、分譲中の scope を復元する必要がある → scope ledger の永続化が事実上必須
Pod の設定
instruction
- spawned Pod の
instructionはSpawnPodの引数で明示指定 - 省略時は
$insomnia/default - spawner の instruction を引き継ぐケースはまれ(分業目的の spawn なので、spawned Pod には固有の役割がある)
provider
- API キーと provider 設定は通常ユーザー manifest / プロジェクト manifest 由来なので、spawned Pod は PodFactory 経由で同じカスケードから取得する
- spawned Pod に異なるモデルを使わせたい場合は overlay で上書きする(例: 調査用 Pod には小さいモデル、コード生成にはフルモデル)
人間向けの監視
各 Pod は独立して観測可能
- すべての Pod は通常の socket サーバーを持つ。人間は GUI / TUI で任意の Pod に接続して会話を閲覧・介入できる
- Pod 間に主従関係が無いので、人間はどの Pod にも同格にアクセスできる
Pod ネットワークの可視化
- GUI / TUI が Pod の一覧を表示(spawn 関係をグラフまたはリストで表現)
- 各ノードに status(running / idle / stopped)、保持中の scope 要約、最終ターンの要約を表示
- ノードをクリック/選択すると、その Pod の会話ビューに切り替わる
介入
- 人間は任意の Pod に直接メッセージを送れる(通常のクライアントとして接続)
- 人間が Pod を直接 stop できる(scope は spawner に返却される)
- spawner の LLM は spawned Pod に人間が介入したことを(次の通知で)知る
Pod の発見と登録
プロセスが独立しているため、起動中の Pod を発見する仕組みが要る。
候補:
- A. runtime_dir convention: 各 Pod が socket path を
runtime_dir/<pod_name>.sockに作る。ディレクトリを列挙すれば一覧が取れる(現行の仕組みの延長) - B. 明示的なレジストリ: Pod 起動時に共有レジストリ(ファイルまたは IPC)に登録。終了時に削除
- C. daemon: 軽量な daemon プロセスがレジストリ役を担う(
crates/daemonは現在空)
現時点では A が最もシンプル。daemon が必要になるのは「リモート Pod」や「Pod の自動再起動」が必要になってから。
設計で決めること
- Pod の起動方式: 独立プロセスとして
podバイナリを起動する形で確定。起動方法の詳細(fork? 直接 exec? nix-shell 経由?) - scope ledger の永続化方式: ファイルベース / session-store / 別の永続ストア
- scope の分譲粒度: パス単位で分譲するか、permission レベル(read / write)でも分譲できるか(例: write だけ譲って read は共有する等)
- 通知の注入方式: system message として注入するか、専用の
Eventとしてターン開始前に Worker に流すか - 通知のバッファリング: spawner の1ターンが数分かかる場合、その間のイベントをどこに溜めるか
- Pod の発見: A (runtime_dir) / B (レジストリファイル) / C (daemon)
- リソース制限: 最大 spawned Pod 数、ネスト深さの上限
- ツール名:
SpawnPod/SendToPod/ReadPodOutput/StopPod/ListPodsの命名はそのままで良いか - Pod ネットワークの可視化: GUI / TUI どちらに先行実装するか
- spawner 復帰時の再接続: scope ledger を読んで spawned Pod のソケットに再接続する手順
- 分譲チェーンの depth limit: 再帰的 spawn の深さ上限を設けるか
完了条件
- Pod の LLM が
SpawnPodツールで別 Pod を起動でき、spawned Pod が独立プロセスとして動作する - spawn 時に scope が分譲され、spawner の effective scope が縮小されることを検証できる
SendToPodで spawned Pod にメッセージを送り、ReadPodOutputで応答を読み取れるStopPodで spawned Pod を graceful に停止でき、scope が spawner に返却されるListPodsで spawned Pod の状態と scope 要約を一覧できる- spawned Pod のターン完了・エラー・停止が spawner に非同期通知され、次のターン開始時に LLM に伝わる
- spawner が停止しても spawned Pod が続行し、spawner 復帰時に再接続できる
- 人間が GUI または TUI で Pod ネットワークを閲覧でき、任意の Pod に接続して会話を見られる
- spawned Pod がさらに別の Pod を spawn する再帰的なネットワークが動作する
- scope 分譲チェーンの返却(中間ノードの終了時に部分返却)が正しく動作する
他チケットとの関係
- tickets/scope-exclusion.md: scope 分譲モデルにより本チケットに吸収される。write 排他制御は分譲時に構造的に保証されるため、別の排他制御メカニズムは不要。scope-exclusion.md は本チケットの scope 設計がカバーする
- tickets/protocol-design.md: Protocol に Pod 間通知のイベント(spawned Pod の状態変化通知など)を追加する必要があるかもしれない。ただし各 Pod は通常の socket サーバーなので、protocol 拡張なしで「複数 socket に繋ぐクライアント」として実装できる可能性もある
- tickets/native-gui-mvp.md: Pod ネットワーク可視化は GUI 側の拡張。MVP scope には含まれていないが、本チケットの監視要件は GUI の次フェーズに自然に接続する
- tickets/tui-pod-spawn-ui.md: TUI からの Pod spawn UI。本チケットは LLM からの spawn だが、spawn のインフラ(PodFactory + プロセス起動)は共通
- tickets/tui-pod-shutdown.md: Pod ネットワークの shutdown 戦略にも影響。Pod を停止したとき scope がどこに返るか
- tickets/permission-extension-point.md: spawned Pod のツール実行パーミッションを spawner が制御する可能性
範囲外
- Pod 間の直接メッセージパッシング: Pod 同士が spawner を介さずに直接通信する仕組み。すべてのやり取りは spawner(オーケストレーター)を介す
- 共有メモリ / 共有状態: Pod 間のデータ共有はツール経由のテキストメッセージのみ。ファイルシステムを介した暗黙の共有は scope が許す範囲で起きうるが、それは Pod の既存動作の延長であり、分譲により write 衝突は構造的に排除される
- 自動スケーリング / 負荷分散: Pod の spawn 先は常にローカルマシン。リモート実行やクラウド分散は扱わない
- 課金・トークン予算管理: spawned Pod が消費するトークンの予算制御や spawner への請求集約は扱わない
- 人間がネットワークの構造を変更する操作(Pod の spawn 元替え、ネットワークの組み替えなど)
- daemon の導入: Pod の発見は runtime_dir convention または軽量レジストリで対応。daemon は必要が明確になってから