第 4 課:イベントをリッスンする:正しいタイミングでロジックを挿入する
一言でいうと:エージェントの重要な瞬間ごとにイベントがブロードキャストされます。プラグインは
ctx.onでリスナーを 1 つ登録するだけで、「モデルリクエスト前」「ツール呼び出し後」「ターン終了直前」に自分のロジックを挿入できます——観察したいなら emit イベント、インターセプトや動作変更をしたいなら waterfall イベント、next()を呼べば通過、呼ばなければ引き継ぎ(乗っ取り)です。
1. ユーザーストーリー:フレームワークのソースを変更せず、「モデルリクエスト前」と「ツール呼び出し後」にロジックを挿す
前の課では DSH のイベントシステムを学びました。メインループの各ステップがイベントをブロードキャストし、イベントこそがサービスの拡張 API だということです。この課ではその知識をコードに変えます——実際に動くリスナープラグインを書きます。
背景:あなたの所属するプラットフォームチームは、すでに本番稼働中の DSH デプロイを引き継ぎました。業務側から 2 つの要件が出ています。
- モデルがリクエストするたびにログを 1 件記録する:誰が、どのターンの何ステップ目で、どのモデルを使ったか——月末にモデル費用の突合をするためです。
- ツール呼び出しのたびに構造化された監査レコードを残す:どのツールが、どんなパラメータで呼ばれたか——「このツールが実際に何をしたのか」を調べやすくするためです。
フレームワークに拡張ポイントがなければ、この 2 つの要件は同じ答えに行き着きます:ソースを fork して、メインループを書き換える。そして上流がアップグレードされるたびにパッチをマージし直さなければならず、メインループの中核に手を入れれば、例外 1 つでエージェント全体がクラッシュしかねません。
DSH の答えは、何も fork しなくていい、です。メインループには重要なタイミングごとに「スロット」があらかじめ用意されています——各タイミングがイベントとしてブロードキャストされます。agent/request はモデルリクエスト送出前、tools/result はツール呼び出し終了後、agent/turn-stopping はターン終了直前……あなたは小さなプラグインを書いて ctx.on でリスナーを登録し、対応するスロットに「掛ける」だけです。ドキュメントは単刀直入にこう言っています。
イベントはプラグイン間通信の中核となるメカニズムです。Harness はイベントを多用して疎結合な拡張ポイントを実現しています。(出典:
docs/user/develop/framework/events.zh.md)
これは前の課と同じ流れで、違いはただ 1 つ。前の課は「読む」、この課は「書く」です。
2. プラグインでイベントをリッスンする:ctx.on でリスナーを登録する
イベントをリッスンする基本の書き方は 2 行だけです(出典:docs/user/develop/framework/events.zh.md):
ctx.on('event-name', (payload) => {
// イベントを処理する
})
ctx はプラグインのエントリ apply(ctx) が受け取るコンテキストで、「リスナー」はごく普通の関数です。イベントが発生すれば呼び出されます。発火する側はフレームワーク自身で、ctx.emit('event-name', payload) で起動します——プラグイン作者はリスナー側の半分だけを書けばよく、発火側に触れる必要はありません。
では、ユーザーストーリーを本物のプラグインにしてみましょう(events.zh.md のログプラグイン例を改造したものです):
export const name = 'audit-logs'
export function apply(ctx: Context) {
// 要件 1:モデルリクエスト前に、リクエストログを 1 件記録する
ctx.on('agent/request', async (_payload, next) => {
console.log('[audit] モデルリクエストがまもなく送出されます')
const config = await next() // 通過:マシンが本来使うはずの呼び出し設定を取得
console.log(`[audit] 今回のリクエストで使用するモデル:${config.model}`)
return config
})
// 要件 2:ツール呼び出し終了後に、監査を行う
ctx.on('tools/result', (exec, result) => {
console.log(`[audit] ツール ${exec.name} の実行が完了し、${result.content.length} ブロックのコンテンツを返しました`)
})
// おまけ:ターンがまもなく終了する際にマークを付ける
ctx.on('agent/turn-stopping', ({ agent, turn }) => {
console.log(`[audit] エージェント ${agent.id} の第 ${turn} ターンがまもなく終了します`)
})
}
順番に読み解きましょう。
agent/requestは waterfall イベントで、モデルリクエスト送出前に発火します。リスナーは 2 つの引数を受け取ります:ペイロードとnext()。私たちはawait next()でマシンが本来使うはずの呼び出し設定(config.modelが選択されたモデルです)を取得し、ログを記録したらそのまま返します——見ただけで、動作は何も変えていません。この 3 つのイベント名と発火パターンは、リポジトリの「イベントのプロデューサー・コンシューマーマトリクス」で確認できます(出典:docs/event-producer-consumer.md)。tools/resultは emit イベントで、ツール呼び出し終了後にブロードキャストされます。events.zh.md のサンプルログプラグインがリッスンしているのはまさにこれです。第 1 引数は今回の実行(exec.nameがツール名)、第 2 引数は凍結された結果スナップショットです——監査に使うにはこれ以上のものはありません(出典:docs/user/develop/framework/events.zh.md)。agent/turn-stoppingは serial イベントで、ターン終了直前に発火します。nextパラメータはありません——これは純粋な観察ポイントで、リスナーは「見る」ことしかできず、ショートカットはできません(出典:docs/event-producer-consumer.mdとpackages/core/agent/src/runtime-types.tsの宣言)。
⚠️ 初心者が必ずハマる罠:
tool/call、turn/startなどは永続化されるセッションイベントの型であり、同名のランタイムイベントではありません。これらを観察するにはsession/eventをリッスンしてevent.typeをチェックします(出典:docs/user/develop/framework/events.zh.md):ctx.on('session/event', (session, event) => { if (event.type === 'tool/call') { console.log(`[audit] セッション ${session.id} がツール ${event.data.name} を呼び出しました`) } })
もう 1 つ嬉しいニュースがあります。イベントリスナーもエフェクト(effect)です。ctx.on() で登録したリスナーは、プラグインがアンロードされるとき自動的に解除されます。手動でのクリーンアップは不要で、残りカスも残りません(出典:docs/user/develop/framework/events.zh.md)。
3. waterfall セマンティクスの実践:next() を呼べば通過、呼ばなければ引き継ぎ
前節の 2 つのリスナーはどちらも「観察」でした。データを受け取り、記録し、そのまま通す。しかしリスナーの価値は観察にとどまりません——waterfall(滝式)イベントを使えば、重要な決定ポイントでインターセプトと書き換えができます。この課で最も重要な部分です。図と照らし合わせながら読んでください。
waterfall = 环绕中间件:监听器用 next() 把控制权交给下一位,不调用就是接管
waterfall のセマンティクスは、入門ドキュメントに明確に書かれています。
ctx.waterfallはラップ型ミドルウェアです。リスナーは(...args, next)を受け取ります。next()を呼ぶと下流のリスナーが実行され、下流の戻り値はnext()を通じて現在のラップ層に返され、その層がラップしてさらに外側へ返すことができます。next()を呼ばずに直接 return するとショートカットします。(出典:docs/cordis-primer.zh.md)
3 つのルールに分解します。
- すべてのリスナーはミドルウェアです。イベントは発生源から出発し、各リスナーを順番に通過して、最後にコンシューマー(モデル呼び出しやツール実行など)に到達します。
next()を呼ぶことは通過を意味します。await next()で得られるのは「下流のすべてのリスナーが処理を終えた後」の結果です——それをラップしたり、書き換えたりして、外側へ返すことができます。next()を呼ばずに直接 return する = ショートカット = 引き継ぎ。これ以降のリスナーもコンシューマーも、このイベントを見ることができなくなります。これは「規則違反」に見えますが、実は意図的な設計です。
単一の決定を行うイベントでは、ショートカットは設計意図です。決定権を持つポリシーリスナーは
next()を呼ばずに直接 return できますが、注釈や観察のみを行うリスナーは必ず委譲しなければなりません。(出典:docs/cordis-primer.zh.md)
開発者ドキュメントはこれを警告として明記しています。
waterfall リスナーは必ず
next()を呼ばなければなりません。nextを呼ばないとパイプライン全体がショートカットします。これは意図的な設計です——インターセプト/ゲートウェイロジックを実装するためのものです。(出典:docs/user/develop/framework/events.zh.md)
実際のシナリオを 2 つ見てみましょう。
シナリオ 1:モデルリクエストの「設定差し替え」。 agent/request の next() はマシンが凍結した呼び出し設定(LlmCallConfig。provider、model、maxTokens などのフィールドを含む)を返します。リスナーが返したものを、マシンがそのまま使います(出典:packages/core/agent/src/runtime-types.ts):
ctx.on('agent/request', async (_payload, next) => {
const config = await next() // マシンのデフォルト設定を取得
return { ...config, maxTokens: 512 } // 書き換えてから戻す:この滝は設定差し替え専用
})
シナリオ 2:ツール呼び出しにセーフティポリシーを掛ける。 tools/pre-execute は waterfall イベントで、決定の型は「許可 / 拒否 / 確認」です。拒否する場合は next() を呼ばず、直接 deny の決定を返せばツールは実行されません。許可する場合は決定権を下流に委譲します(出典:packages/core/tools/src/index.ts):
ctx.on('tools/pre-execute', async (exec, next) => {
if (isBanned(exec.name)) {
return { kind: 'deny', reason: 'このツール呼び出しはポリシーにより禁止されています' } // next() を呼ばない:直接引き継ぐ
}
return next() // 通過:下流に決定を任せる
})
💡 判断のコツ:「自分が決める」なら
next()を呼ばない。「見るだけ」なら必ずnext()を呼ぶ。 方向を間違えると、通すべきでないものをこっそり通してしまうか、止めるべきでないものをこっそり止めてしまいます。
4. イベントの選定と注意点:どのタイミングにどんなロジックを挿すか
4.1 イベント選定表
以下のイベントはすべて、リポジトリの「イベントのプロデューサー・コンシューマーマトリクス」からのものです(出典:docs/event-producer-consumer.md):
| イベント | モード | 発生するタイミング | 主な用途 |
|---|---|---|---|
agent/pre-step | waterfall | 各ステップ開始前。このステップに入るメッセージのバッチを携えている | ステップ全体を拒否、またはメッセージの差し替え・注入——plan-mode(計画モード)や agent-instructions(ワークスペースコンテキスト)はここにいます |
agent/request | waterfall | モデルリクエスト送出前。凍結された呼び出し設定を携えている | provider、model、maxTokens などの設定差し替え。この滝ではメッセージ内容を変更できないことに注意 |
agent/request-error | waterfall | モデルリクエスト失敗後、リトライまたはステップを閉じる前 | リトライするかどうかを決定——llm-retry(リトライプラグイン)はここにいます |
agent/turn-stopping | serial | ターン終了直前(モデルが応答義務を負わなくなった時点) | ターン終了を観察。停止を阻止したいなら agent.steer() で新しい入力を 1 件詰め込めば、マシンがもう 1 ステップ走ります |
tools/pre-execute | waterfall | ツール実行前 | 事前チェック、危険な呼び出しのインターセプト——決定は 許可 / 拒否 / 確認 |
tools/post-execute | waterfall | ツール実行後、結果が確定する前 | ツール結果の差し替えや拡充(長すぎる結果の要約、コンテキストの付加) |
tools/result | emit | ツール呼び出しが完全に終了し、結果が凍結された後 | 監査、ログ、統計——見るだけ、触らない |
session/event | emit | 永続化されたログの事実が書き込まれるたび | ログストリームの観察:UI レンダリング、テレメトリ送信、トークン統計はすべてこれを聞いています |
fs/write-intent | waterfall | ファイル書き込みの意図が発生したとき | ファイルセーフティポリシー:fs-observation-policy(ファイルポリシープラグイン)はここにいます |
選定の判断順序:まず「動作を変えたいか?」と問う。動作を変えたい → waterfall イベントを選ぶ(インターセプト/書き換え)。観察したいだけ → emit イベントを選ぶ(または serial の観察ポイント)。
4.2 注意点 1:リスナー内のエラーハンドリング
リスナーはフレームワークのメインループ経路上で動きます。スローされた例外 1 つがリクエスト全体を中断させる可能性があります。実践ルールは 2 つ。
- 観察系リスナー(emit)は自分のロジックを try/catch で包む:監査ログの書き込み失敗やネットワーク送信のタイムアウトで、モデルリクエストまで巻き込んで失敗させてはいけません。
tools/resultのような emit イベントのリスナー失敗は封じ込められますが(出典:packages/core/tools/src/index.ts)、「フレームワークがフォローしてくれる」ことに頼るのは良い習慣ではありません。自分の例外は自分で受け止めましょう。 - waterfall リスナーは、通過するか明確な決定を返すかのどちらかにし、途中で例外を投げない:あなたの層で投げれば、下流は
next()の値を受け取れません。本当にエラーが起きた場合でも、パイプラインを自分のところで死なせるくらいなら、return next()で通過させるほうがましです。
4.3 注意点 2:読み取り専用のリッスン vs ペイロードの変更
- emit イベントはブロードキャスト:すべてのリスナーが登録順に同期的に「一目見る」だけで、戻り値は無視されます。ペイロードは渡されてきますが、契約上これは読み取り専用の観察ポイントです——データを変更したいなら waterfall イベントを選ぶべきで、emit のペイロードをこっそり書き換えるのではありません。
- 変更が許されるのは waterfall イベントだけ:あなたが返したものが、下流に見えるものです。
agent/requestの宣言には「モデルに見えるコンテンツは記録済みのチャネルを使わなければならず、この滝でメッセージを変更してはならない」と明記されているほどです(出典:packages/core/agent/src/runtime-types.ts)——変更できても、すべてを変更すべきとは限りません。
一言でまとめると:emit は見るため、waterfall は変えるため。モードを間違えると、プラグインは効果が出ないか(インターセプトしたいのに emit を使った)、権限を逸脱するか(観察したいだけなのにペイロードをこっそり書き換えた)のどちらかです。
5. 重要ポイントの振り返り
- リッスン = 挿入:
ctx.on('event-name', handler)でリスナーを登録すれば、フレームワークのメインループの任意のスロットにロジックを挿入でき、フレームワークのソースには一切触れません(出典:docs/user/develop/framework/events.zh.md) - 実際のスロット 3 つ:
agent/request(モデルリクエスト前、waterfall)、tools/result(ツール呼び出し後、emit)、agent/turn-stopping(ターン終了直前、serial の観察ポイント)——モードは「イベントのプロデューサー・コンシューマーマトリクス」で確認できます(出典:docs/event-producer-consumer.md) - waterfall の 2 つの道:
next()を呼んで通過させて戻り値をラップする。呼ばずに直接 return = ショートカットして引き継ぐ——決定権を持つポリシーリスナーがこれを使い、観察系リスナーは必ず委譲します(出典:docs/cordis-primer.zh.md) - 選定の判断:動作を変えるなら waterfall(インターセプト/書き換え)、観察だけなら emit。
tool/callなどの永続化セッションイベントはsession/event経由で観察します - 注意点 2 つ:リスナーのエラーは自分で受け止め、メインループを道連れにしないこと。emit は読み取り専用の観察ポイントなので、データ変更は waterfall で行うこと
🚀 次の課では、引き続きプラグインを書いていきます。設定の公開方法——コードを書き換えるのではなく、ユーザーが画面からプラグインのパラメータを調整できるようにする方法を学びます。
セルフテスト · イベントをリッスンする
回答を終えたら「解答を送信」をクリックすると、正誤と解説を確認できます。
