第 41 章:実行中 Agent のステアリング
追加指示は「もう一つのメッセージ」ではありません。動いている並行性の境界を越え、どのターンに答えるかを変えながら、独自の配送契約も守る必要があります。
クイックトラック(5 分で核心をつかむ)
- accepted、committed、completed を分ける——三つは異なる瞬間
- 反復境界で追加指示を取り出し、モデルが次のツールを選ぶ前に見せる
- 実行終了時の競合を原子的に閉じ、遅い追加指示を取り込むか新しい実行に渡す
- 各回答が属する受信メッセージを追跡する。一つに統合した実行が複数の返信を生み得る
- 返信の配送後にだけ
delivery_ackを送り、配送失敗時は再送できるようにする
公開ソースのスナップショット:実装例は 2026-07-27 に確認した Kocoro 公開
origin/mainのコミット4ec6772に基づきます。学ぶべき対象は不変条件であり、定数は最新ソースで再確認してください。
41.1 「完了」の瞬間に届いた追加指示から始める
Agent がサービスのリファクタリングに十二分かけています。最終回答を書いている最中、ユーザーが送ります。
データベース移行も更新し、結合テストをもう一度実行してください。
明らかに見える実装は二つありますが、どちらも負けます。
すぐ二つ目の実行を始めれば、二つのループが同じ作業ツリーを同時に編集します。メッセージを後回しにして最初の実行を返せば、データベース移行に手を付けていないのにユーザーへ「完了」と伝えます。返却直前にメモリ上のキューを覗いても競合は残ります。確認の直後、経路を閉じる直前に追加指示が届き得るからです。API は成功を返しましたが、そのメッセージを所有する実行がありません。
さらに見つけにくい不具合もあります。動いているループが追加指示を吸収したとして、チャネルの返信は元のリファクタリング依頼と「移行も更新して」のどちらを宛先にするのでしょう。セッション ID だけなら、転送層が偶然覚えていたメッセージの下へ回答が付きます。
実行中のステアリングは同時に三つの契約です。並行性、会話境界、返信先の指定です。
41.2 Accepted と Committed と Completed は別物
メッセージが消える原因は一つの動詞に押し込むことです。三つを使います。
Accepted はルーターが実行中の処理を見つけ、バッファ付き注入チャネルへメッセージを置いた状態です。InjectOK は所有権の移動を示しますが、モデルが本文を見たとは限りません。
Committed はループが反復境界でメッセージを取り出し、本物のユーザーターンを作り、進行中の会話へ追加し、ライフサイクルフックを発火した状態です。待機中の下書きが通常のユーザー発言になれるのはここからです。永続メールボックスの行に由来するなら、その行を削除できるのもこの瞬間です。本文がメモリに入った時点ではなく、会話記録に居場所を得た後です。
Completed は、その受信メッセージへの回答が配送された状態です。転送層が再送記録を削除できるのは、配送後だけです。
accepted committed completed
router → inject queue → live user turn → model ... → reply delivered → ack
各矢印は独立して失敗します。状態ごとに一人の所有者を置けば回復できます。すべてを「received」に潰すと、障害のたびにその単語の意味から議論することになります。
41.3 意思決定の境界で追加指示を取り出す
実行中の処理はメールボックスを所有します。反復の先頭で、利用可能な追加指示をブロックせずに取り出し、撤回済みのものを除き、残りを一つのユーザーターンにまとめ、次の主モデル要求より前に追加します。
境界の位置には理由があります。ツール実行中に取り出すと、すでに選んだ操作の足元でコンテキストが変わります。次のモデル応答後まで待つと、ユーザーの修正を見る前に、もう一つ高価または破壊的なツールを実行しかねません。反復境界は、前の効果が判明した後、次の操作を選ぶ前です。
追加指示の確定時には latestUserText も更新するため、Skill の探索と遅延ツールの継続処理が、元の指示ではなく新しい指示を基に推論できます。会話記録だけ変えて周辺システムに古いユーザー文を読ませるステアリングは、半分しか実装されていません。
複数の待機中の追加指示を一つのユーザーターンにまとめられます。これは製品上の決定であり、転送層の偶然ではありません。メッセージごとに回答が必要ならまとめません。まとめるなら、統合した返信を所有する受信 ID と、残りの扱いを定義します。
41.4 最後のメッセージの競合を閉じる
ループ先頭で取り出すだけでは足りません。最も難しい到着は §41.1 の状況、モデルが最終回答を構成している最中です。
単純な len(queue) > 0 では競合を閉じられません。確認時点でキューが空でも、経路を閉じる前にメッセージが入ります。公開ランタイムは経路ロックが所有する最終排出処理を使います。
under one lock:
if a surviving follow-up exists:
drain it and keep the run open
else:
close the injection window
競合する送信側の合法な結果は二つだけです。原子的な最終排出より前に届いて現在の実行がその場で確定するか、閉じた注入窓を見て新しい実行を始めます。「accepted」を受けた後で所有者を失うことはありません。
撤回も同じ境界に属します。ユーザーが取り消した待機中の下書きが最終排出を抜けて、完了済みのループを再開してはいけません。「除外後に空」なら返却し、「一つ以上残る」なら継続します。
これは第 40 章の一般的な教訓です。永続性は状態を書くことだけではありません。所有権の遷移を十分に原子的にし、次に何をするかを正確に一つの構成要素が知ることです。
41.5 すべての回答に宛先が要る
一つの物理 Run が複数の論理ユーザーターンを持てるなら、「実行のメッセージ ID」だけでは足りません。
ループは主たる受信 ID から始まります。追加指示を確定すると replyCloudMessageID を新しいメッセージ ID へ進め、その ID を確認待ち集合に記録します。完成済みの回答が別の注入ターンに上書きされそうなら、ループは進める前に古い返信 ID を捕捉し、OnIntermediateAnswer から回答を出します。
順序が重要です。
capture old reply target
→ commit superseding follow-up
→ deliver old turn's answer to old target
→ continue under new target
先に進めると元の回答が追加指示の下へ付きます。中間回答を出さなければ消えます。セッションだけで宛先を決めると、二人が素早く送ったメッセージが一つの曖昧なスレッドへ潰れます。
複数のメッセージを一つのターンへまとめる場合、スナップショットは統合回答を最後の空でない受信 ID に割り当て、先に吸収した ID も確認集合に残します。別の製品は別の選択ができます。重要なのは方針が明示され、公開された転送境界でテストされることです。
41.6 配送確認はメールボックス受理ではない
受信側の転送は at-least-once です。Cloud は未確認メッセージを再送バッファに残し、再接続後に再配送できます。
delivery_ack が意味するのは一つだけです。最終返信がユーザーへ正常に配送されたことです。Daemon は先に返信を送り、成功後に吸収済みの受信 ID をすべて確認します。返信の配送が失敗したら確認しないため、再接続時に入力を再送でき、ユーザーが見なかった回答を静かに失いません。
だから前の言葉の区別が必要です。メッセージが注入キューに入った時点でも、コンテキストへ確定された時点でも delivery_ack を出せません。どちらもユーザーが回答を得る前です。
重複排除には永続的な識別子が必要です。再送で同じ受信 ID が届いたとき、現在の所有者がいるのか、完了済みの返信があるのか、本当に別の実行が必要なのかを識別します。At-least-once の転送と冪等な処理の組み合わせが信頼できる配送を作ります。転送が exactly-once だと思い込めば重複が生まれます。
41.7 追加、撤回、中断は異なる操作
通常の追加指示は、現在の実行を保ち、次の意思決定より前に情報を加える操作です。
撤回は、まだ確定されていない待機中のメッセージを消す操作です。クライアントメッセージ ID と、遅延配送との競合に勝つ墓標が必要です。
中断は、現在の実行を止める操作です。プロンプトではなく取り消し信号で伝えます。「stop」を本文として追加しても、モデルが次のツールを選んだ後にしか届かず、本物の取り消しには遅すぎます。
「中断して送信」はさらに別です。古い実行を取り消し、代替要求を新しい所有者として開始します。ルーターは cancelPending の経路を非活動とみなし、終了中のループに代替要求が跳ね返されないようにします。
経路は分けてください。モード文字列付きの一つの steer API にすると、取り消し処理がメールボックスの排出を待ち、撤回が新しい指示に見え、各分岐がメッセージ受理を別々に定義しがちです。
41.8 スナップショットの根拠
| 観測 | 4ec6772 のソース |
|---|---|
| 注入の確定処理は追加指示を受理した時ではなく取り出した時に発火 | loop.go L670 |
| 注入メッセージはメールボックス、Cloud、クライアントの識別子を別々に保持 | loop.go L716 |
| 一つの確定経路が本物のユーザーターンを作成・追加 | loop.go L3106 |
| 通常の排出処理は反復の先頭で実行 | loop.go L3223 |
| 原子的な最終排出が実行終了時の競合を閉じる | loop.go L4213 |
| 返信先は最後に処理した受信メッセージへ進む | loop.go L1498 |
| 確認待ち ID は最終配送後にだけ確認 | loop.go L1520 |
delivery_ack は SendReply 成功後にだけ送信 | client.go L537 |
| ルーターの投入と終了時排出が一つの所有権ロックを共有 | router.go L599 |
これは特定時点の実装を述べたもので、普遍的なメッセージ配送契約ではありません。
41.9 よくある失敗
投入時に確認する。 受理後、返信前の障害でメッセージを永久に失います。
ツール実行中に追加指示を入れる。 次の操作はすでに古いコンテキストから選択されています。
返却前にキュー長を見る。 一度見るだけでは到着と終了処理の競合を閉じられません。
実行ごとに一つの ID だけを追う。 統合ターンの回答を間違った受信メッセージへ送り、中間回答を落とします。
会話記録へ確定する前にメールボックス行を消費済みにする。 障害後、待機メッセージも会話記録も残りません。
プロンプト本文で取り消す。 ループは「stop」を読む前にもう一度動けます。
再送を転送層の不具合とみなす。 再送は未確認返信の配送失敗を回復する仕組みです。永続的な識別子で重複排除します。
追加指示に CWD を暗黙変更させる。 現在の実行はすでにプロジェクトのコンテキストを所有します。異なる対象には新しい実行が必要です。
要点
- Accepted、Committed、Completed は別の状態です。 三つに名前を付け、それぞれに一人の所有者を置きます。
- 次の意思決定より前に取り出します。 追加指示は別のツールが選ばれる前にコンテキストへ届く必要があります。
- 終了処理を原子的に閉じます。 遅いメッセージは現在の実行か新しい実行のどちらかに属し、どちらにも属さない状態を作りません。
- 論理ターンごとに宛先を決めます。 一つのループが複数の受信 ID の下で複数の回答を作り得ます。
- 配送後に確認します。 未確認の再送は消したい重複ではなく回復です。
次章の第 42 章では、対応する時間の問いを扱います。動いているターンが静かなとき、その待ち時間を本当に所有する段階はどれでしょうか。