agent-host-chat-contributions について
Agent Host のチャットライフサイクルを拡張するためのコントリビューションモデル開発ガイドです。大きなオーケストレーションクラスに横断的な処理を加えず、個別の動作を設計・登録・順序付けする方法を示します。
主な機能
- 01コントリビューションに適した動作の判定
- 027 種類のライフサイクルフックの定義
- 03依存性注入による登録
- 04フック実行順の指定
- 05テストとサービス依存関係の指針
活用シーン
- 01チャットライフサイクルへの個別動作の追加
- 02リクエスト受付やプロンプト処理の実装
- 03順序変更に伴う回帰テストの更新
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ microsoft/ vscode --skill agent-host-chat-contributionsインストール後、Claude Code で/agent-host-chat-contributionsを入力して読み込まれたか確認します。
詳細
エージェントホストのチャットコントリビューション
エージェントホストのチャットライフサイクル境界をまたぐ、自己完結した動作にはコントリビューションモデルを使用してください。モデルを変更する前に、次のファイルを確認してください。
src/vs/platform/agentHost/common/agentHostChatContributionsService.tssrc/vs/platform/agentHost/node/agentHostChatContributionsService.tssrc/vs/platform/agentHost/node/chatContributions/TODO.mdsrc/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts
以前の失敗を防ぐルール
新しい横断的な機能は、
AgentSideEffectsやAgentServiceではなく、コントリビューションに実装してください。
AgentSideEffects は、17 個の動作を切り出し、700 行以上削減されました。ライフサイクルの副作用、送信プロンプトへの追加、復元したターンの拡張、受付チェック、アクションの監視を、どちらかの大規模なオーケストレーションクラスに再び追加しないでください。AgentSideEffects はライフサイクルをディスパッチし、ルーティングと送信の仕組みを担います。AgentService はセッションのライフサイクルを管理し、ハイドレーションとメメントの破棄を呼び出します。agentSideEffects.ts と agentService.ts を参照してください。
これはレビュー時のルールでもあります。どちらかのファイルに追加するコードには、それがルーティング、正当性の不変条件、プロバイダー固有の動作、またはディスパッチャーの呼び出し箇所に該当するという、具体的な理由が必要です。「ここに追加すると便利だった」は理由になりません。
コーディングの前に判断してください
動作が次のテストに当てはまる場合は、コントリビューションを作成してください。
- 既存のフックで名前が付けられているライフサイクルのタイミングに反応しますか? — ターンの続行を求めるリクエスト、完了したターン、ディスパッチされたアクション、送信直前のターン、復元されたターンの一覧、または復元中のチャットです。
- プロトコルのルーティングやプロバイダーのマッピングを変更せずに、明示的に登録され、依存関係を注入されたユニットがその動作を担えますか?
- 定義された順序で、他の動作と組み合わせられますか?
該当する例として、受付のゲーティング、ローカルコマンドのインターセプト、チェックポイントと変更セットの処理、キューの排出、GitHub 参照の添付、タイトルの処理、使用状況の永続化、セッションの入力待ち状態の集約、セッションフラグの永続化、チャットの下書き、チャット固有の設定、ワークツリーの通知、未読状態、リッチリンクのガイダンス、チャット画面のガイダンスがあります。node/chatContributions/ 以下の各ディレクトリにある実装を参考にしてください。
次の処理には、コントリビューションを使用しないでください。
node/agentSideEffects.tsのサブエージェントのシグナルルーティングとバッファリングは、ルーティング基盤であり、正当性の不変条件です。node/agentSideEffects.tsのターン ID のマッピングも、ルーティング基盤です。- 3 つの SDK イベントマッパーと 3 つの添付ファイルシリアライザーは、プロバイダー固有の処理です。
- 復元時に適用されるプロバイダー固有のオーバーレイ。たとえば、
_doRestoreSessionでIAgentが提供するセッションメタデータから下書きのモデルを設定する処理です。これを移動すると、1 つのコンシューマーのためにプロバイダーメタデータをIHydrationContextに持ち込むことになります。
「状態が変わる前に、すべてのシグナルを正しくルーティングする必要がある」または「SDK によって異なる」と判断した場合は、コントリビューションではありません。未対応の作業と継続中の注意事項は、node/chatContributions/TODO.md に記録されています。
フックの対象を絞る
IAgentHostChatContribution には、common/agentHostChatContributionsService.ts に7つのフックがあります。既存のペイロードで動作を表現できない場合に限り、新しいメソッドを追加してください。
以前、フックの数は6つから4つに減らされました。
onTurnConsumableは、TurnEndReason.kind === 'localCommand'を指定するonTurnEndになりました。onUserMessageはonOutgoingTurnになりました。
フックを追加するよりも、TurnEndReason の新しいバリアントのように、既存の型に判別子やペイロードを追加することを優先してください。呼び出し元と実装者がそれぞれ1つしかない単一目的のフックを提案することは、これら2つが削除される原因となった兆候です。1つの直接呼び出しを移すだけの抽象化は作成しないでください。
その後に追加された3つのフックは、明確な基準を満たしています。異なるデータを扱うだけでなく、異なるタイミングまたは異なるソースから呼び出されるため、既存のペイロードでは表現できません。
onHydrateChatは、チャットがカタログに追加される前に、ホストが所有するチャット状態を復元します。onHydrateTurnsは、プロバイダーがターンを返した後に遅延実行されるため、これを再利用すると、復元されたピアチャットは開かれるまでタイトルが付かないままになります。onIncomingRequestは、ターンがプロバイダーに届く前にチェックし、拒否できます。オブザーバーフックには拒否する機能がありません。onDidDispatchActionは、サーバーからディスパッチされたアクションと拒否されたアクションを監視しますが、構造上、onDidApplyClientActionではこれらを確認できません。
onHydrateChat については、既存のフックに { kind: ... } 判別子を追加する案も検討されましたが、採用されませんでした。判別子に関するガイダンスが対象とするのは、同じタイミング内のペイロードのバリアントです。これは、TurnEndReason が1つのターン終了における結果を区別するのと同様です。
構成と登録
各コントリビューションには、node/chatContributions/<feature>/ の下に専用のディレクトリを用意してください。実装、ヘルパー、テストは、成長に応じてそのディレクトリにまとめます。各コントリビューションには、次のものが必要です。
- 登録全体で一意な
static readonly id。 - 現時点で独立しているように見える場合でも、明示的な
order。 - 第1引数を
IAgentHostChatContributionContextとし、その後に注入するサービスを続けるコンストラクター。 node/chatContributions/builtInChatContributions.tsに1か所だけ登録すること。
これは、markdownPlanRichLinks/markdownPlanRichLinksContribution.ts を基にした、既存のMarkdownプラン用リッチリンクコントリビューションの完全な形式です。
export class MarkdownPlanRichLinksContribution extends Disposable implements IAgentHostChatContribution {
static readonly id = 'markdownPlanRichLinks';
readonly order = 100;
constructor(
protected readonly _context: IAgentHostChatContributionContext,
@IAgentConfigurationService private readonly _agentConfigService: IAgentConfigurationService,
) {
super();
}
onOutgoingTurn(turn: IOutgoingTurn): ISendContribution | undefined {
return this._agentConfigService.getRootValue(platformRootSchema, AgentHostMarkdownPlanRichLinksEnabledConfigKey)
? { instructions: [createMarkdownPlanRichLinksInstruction(turn.chat)] }
: undefined;
}
}
コンストラクターを、contributions.registerContribution(MarkdownPlanRichLinksContribution) を使って1つのビルトインリストに追加してください。このリストは、node/agentHostContributions.ts の activateAgentHostContributions によって有効化されます。別の登録箇所を追加しないでください。ディスパッチャーは IInstantiationService を通じて各コントリビューションを構築し、IDの重複を拒否し、登録解除時に破棄します。
7 つのフック
| フック | 実行されるタイミング | 前提にしてはいけないこと | 既存の例 |
|---|---|---|---|
onIncomingRequest | ターンリクエストがプロバイダーへの処理の進行を求めたとき、副作用が発生する前に実行されます。同期処理で、フェイルクローズです。 | 受け入れ可否を決定するため、例外をスローするとスキップされずに拒否されます。accept、handled、reject を返します。最初に返された accept 以外の結果が優先され、それ以降のコントリビューションは参照されません。 | localCommand 50、turnAdmission 100 |
onTurnEnd | 開始済みのターンが到達し得るすべての終端結果に加え、ターン開始前に拒否されたリクエストでも実行されます。 | rejected と error を区別してください。前者には確定処理すべき開始済みのターンも、取得すべきチェックポイントもありません。例外をスローしないでください。アクティブなターンがない状態でエージェントが出力した終端アクションでは実行されません。これはリデューサーがそのようなアクションを何も処理しないためです。 | checkpointAndChangeset、queueDrain、githubReferences、sessionTitle、markUnread |
onDidApplyClientAction | クライアントからディスパッチされたアクションがホストの状態に適用されたときに実行されます。 | サーバーからディスパッチされたアクションや拒否されたアクションは参照しません。ここで参照できるアクションは、確実にリデュース済みです。 | queueDrain はキューに入った送信者を追跡し、sessionTitle はユーザーによるタイトル変更を永続化します。 |
onDidDispatchAction | アクションの送信元を問わず、あらゆるアクションがディスパッチされ、その結果が判明したときに実行されます。 | リデュースされなかった拒否済みアクションも通知されるため、rejectionReason を確認してください。これと onDidApplyClientAction の両方を実装すると、すべてのクライアントアクションが 2 回参照されます。 | sessionInputNeeded、persistedTurnUsage、chatConfig、sessionFlags |
onOutgoingTurn | 受け入れ判定とプロバイダーの検索の後、ターンが送信される前に、完了を待って実行されます。 | 拒否されたターンや noAgent エラーでは実行されません。IOutgoingTurn には完全な Message が含まれます。コントリビューションは順番にそのテキストを置き換えたり、指示を追加したりできますが、すでに確定した添付ファイル、モデル、エージェント、送信元、メタデータは置き換えられません。コンテキストの挿入にはメッセージテキストのチャネルを優先してください。指示を変更するとプロバイダーのプロンプトキャッシュが無効になり、ユーザーのコストが増加します。送信経路を迂回しないでください。 | markdownPlanRichLinks、chatSurface、sessionTitle、sideChat |
onHydrateTurns | プロバイダーがチャットの復元済みターンリスト全体を返したときに実行されます。 | デフォルトのチャットに限らず、次の段階に渡すリストを返す必要があります。以前のコントリビューションが変換している可能性があるため、入力がプロバイダーの出力そのままだとは限りません。 | turnDelegation、persistedTurnUsage、worktreeAnnouncement、sideChat |
onHydrateChat | チャットが復元され、セッションカタログに追加される前に、先行して実行されます。 | プロバイダーなしで実行されるメタデータのみの読み取りであり、チャットが存在する前に実行されます。そのため、ここで取得したチャットのメメントは、登録に失敗した場合も残り続けることになります。 | sessionTitle、chatDraft、chatConfig |
呼び出し元は node/agentSideEffects.ts、node/agentHostTurnStarter.ts、node/agentService.ts にあります。
順序は動作に影響します
order は動作に影響します。ディスパッチャーは値の小さい順に並べ替え、同じ値の場合にのみ登録順が適用されます。順序はフックごとに独立しているため、100番台は送信ターンとは別にハイドレーションで再利用できます。node/agentHostChatContributionsService.ts を参照してください。
必要な順序を維持するには、明示的な順序を指定してください。
onIncomingRequestでは、LocalCommandContributionが50、TurnAdmissionContributionが100です。この順序は動作に影響します。ローカルコマンドは常に読み取り専用ガードより先に処理されていたため、読み取り専用またはアーカイブ済みのチャットでも/renameが引き続き機能します。CheckpointAndChangesetContributionは100で実行され、後続の変更セット関連の処理より前にチェックポイントを取得します。QueueDrainContributionは200、SessionInputNeededContributionも200です。GitHubReferencesContributionとChatSurfaceContributionは、該当する場合、300です。SessionTitleContributionは400、MarkUnreadContributionとSideChatContributionは500、ChatDraftContributionは600、ChatConfigContributionは650、SessionFlagsContributionは700です。
markUnread と sideChat はどちらも500を宣言し、onTurnEnd を実装しているため、相対的な実行順序は登録順に依存します。この同じ順序に加えるのではなく、新しいコントリビューションには異なる順序を割り当ててください。
順序を誤って変更する回帰が、過去に実際に発生しました。test/node/chatContributions.test.ts の組み込みシーケンス回帰テストは、元のターン終了、送信ターン、ハイドレーションのシーケンスを保護します。順序を意図的に変更する場合は、これらのテストを追加または更新してください。
増え続けるコンテキストではなく、サービスに依存する
IAgentHostChatContributionContext にコールバックやサービスを追加しないでください。ここに含まれるのは、コントリビューションの識別情報と、中央で破棄されるメメントのみです。@Injectable サービスを介して連携してください。
IAgentHostChatContributionHost は、AgentSideEffects への意図的に限定されたブリッジであり、hostLaunchKind と sendTurnMessage のみを持ちます。実際のサービスとして協力要素を昇格させる前は、メンバーが7つありましたが、次の2つまで削減されました。
IAgentHostSessionTitleControllerIAgentHostProviderServiceIAgentHostTelemetryReporterIAgentHostTurnTrackerIAgentHostToolCallTrackerIAgentHostLocalCommands
IAgentHostWorktreeIsolation も注入可能です。「注入可能ではない」という判断は、サービス登録順が原因の誤った結論であり、後に誤りであることが判明しました。node/agentHostServices.ts から node/agentHostBootstrap.ts、さらに node/agentServiceComposition.ts の createAgentServiceComposition まで、登録の流れを追ってください。インスタンス化の経路でコントリビューションが構築される前に実際のサービスを登録し、コンテキストやホストブリッジを介して紛れ込ませないでください。
サービスを追加しても、コストがかからないわけではありません。AgentSideEffects を構築するか、registerBuiltInChatContributions を呼び出す手書きのテスト用 ServiceCollection が約6つあり、それぞれに新しい登録を追加しないと、UNKNOWN service <id> で失敗します。実際に発生した関連する落とし穴が2つあります。
- テスト登録を
disposables.add(...)でラップするのは、クラスがDisposableを継承している場合に限ってください。プレーンオブジェクトをラップすると、ティアダウン時にd.dispose is not a functionが発生します。 - 協力要素がステートレスなら、
ServicesAccessorを引数に取るプレーン関数を使い、IInstantiationService.invokeFunction経由で呼び出して、DI を完全に避けてください。node/agentHostTurnStarter.tsが参考例です。これをサービス化しても利点はなく、すべてのグラフに登録を追加するコストがかかりました。
メメントにはライフサイクルがあります
common/agentHostChatContributionsService.ts で createChatMementoKey または createSessionMementoKey を使ってキーを作成します。コンテキストはコントリビューションごとにスコープされるため、2 つのコントリビューションで同じデバッグ名を使っても衝突しません。
- チャットのメメントは、そのチャットが破棄されると削除されます。
- セッションのメメントと、指定されたすべてのチャットのメメントは、それらを所有するセッションが破棄されると削除されます。
- 追加のキーセグメントは、識別情報の一部です。キューに入ったメッセージ ID など、範囲が限られた識別情報に使用します。
- 追加セグメントの値が不要になったら、
deleteMementoを呼び出してください。 そのオブザーバブルをundefinedに設定しても、チャットまたはセッションが破棄されるまでマップエントリーは残るため、長時間存続するチャットでは、セグメント値ごとにエントリーが蓄積されます。 onHydrateChatはチャットが登録される前に実行されるため、ここで取得したチャットのメメントは、その後の登録に失敗しても残り続けます。現在これを行うコントリビューションはありませんが、対処せずに最初に行うコントリビューションにならないでください。
queueDrain/queueDrainContribution.ts では、キューに入ったメッセージの削除と消費時に必要な削除処理を確認できます。中央での削除処理は node/agentHostChatContributionsService.ts にあり、node/agentService.ts から呼び出されます。
失敗の分離と、唯一の例外
ディスパッチャーは、オブザーバーフックごとに失敗を個別に記録し、分離します。
onTurnEnd、onDidApplyClientAction、またはonDidDispatchActionで例外をスローしたコントリビューションがあっても、後続のコントリビューションは停止しません。onOutgoingTurnで失敗したコントリビューションは指示を追加せず、送信や後続のコントリビューションを妨げません。onHydrateTurnsまたはonHydrateChatで失敗したコントリビューションは、意図的に直前の値を次の段階に渡します。1 つのエンリッチメントを失うより、チャット履歴、タイトル、下書きを失うほうが深刻です。
onIncomingRequest はこれとは逆に、フェイルクローズします。 ここで例外をスローするコントリビューションがあると、リクエストは validation 段階で internalError とともに拒否されます。このゲートは、UI が入力欄を非表示にするための強制措置です。フェイルオープンにすると、問題のあるクライアントやリモートクライアントが、分離されたワークツリーがディスク上に存在しなくなっている可能性のあるセッションで、処理を実行できてしまいます。1 つのエンリッチメントを失うのは許容できますが、ガードを通過したリクエストを許すことはできません。
同じ理由で、これは意図的に同期処理になっています。ゲートは送信処理が何らかの await を実行する前に判定を下す必要があり、読み取る状態が判定とその効果の間に変化しないようにするためです。現在ホストが行っているすべての受け入れチェックは、すでに同期処理です。最初は非同期版を試しましたが、読み取り専用のガードがアサーションより 1 マイクロタスク遅れて実行され、agentSideEffects の 3 つのテストが失敗しました。これは、ゲートを最初の await より前に置くべきだという設計上の判断を裏付けるものでした。
機能の改善につながる場合は通常のエラー処理を実装してください。ただし、ライフサイクルの制御にスローされたエラーを決して頼りにしないでください。正確なディスパッチ動作は node/agentHostChatContributionsService.ts にあります。
テストと抽出の検証
フックのディスパッチ、順序、メメントの削除、失敗の分離について、test/node/chatContributions.test.ts に焦点を絞ったディスパッチャーのテストを追加します。組み込みシーケンスの回帰テストも同じファイルにあります。
AgentSideEffects から既存の動作を抽出する場合は、既存の test/node/agentSideEffects.test.ts スイートを変更しないでください。このテストが合格することが、抽出によってセマンティクスが保たれた証拠です。古いアサーションを書き換えて新しい構造に合わせるのではなく、新しいディスパッチャーの仕組みは別途テストしてください。
2 度問題になった注意点が 1 つあります。一部の手作業で構築されたテストグラフでは、registerBuiltInChatContributions を呼び出さずに AgentSideEffects を構築するため、動作がコントリビューションに移された途端、その動作を検証しなくなります。抽出によってこのようなスイートが失敗した場合は、グラフが本番環境の配線を再現するように、不足している登録を追加してください。アサーションを弱めてはいけません。失敗はテストが不完全なグラフを検証していたことを示しているのであり、抽出が間違っていたことを示しているのではありません。