スポンサーLobeHubLobeHub詳しく見る
dshfind

第 4 課:イベントをリッスンする:正しいタイミングでロジックを挿入する

一言でいうと:エージェントの重要な瞬間ごとにイベントがブロードキャストされます。プラグインは ctx.on でリスナーを 1 つ登録するだけで、「モデルリクエスト前」「ツール呼び出し後」「ターン終了直前」に自分のロジックを挿入できます——観察したいなら emit イベント、インターセプトや動作変更をしたいなら waterfall イベント、next() を呼べば通過、呼ばなければ引き継ぎ(乗っ取り)です


1. ユーザーストーリー:フレームワークのソースを変更せず、「モデルリクエスト前」と「ツール呼び出し後」にロジックを挿す

前の課では DSH のイベントシステムを学びました。メインループの各ステップがイベントをブロードキャストし、イベントこそがサービスの拡張 API だということです。この課ではその知識をコードに変えます——実際に動くリスナープラグインを書きます。

背景:あなたの所属するプラットフォームチームは、すでに本番稼働中の DSH デプロイを引き継ぎました。業務側から 2 つの要件が出ています。

  1. モデルがリクエストするたびにログを 1 件記録する:誰が、どのターンの何ステップ目で、どのモデルを使ったか——月末にモデル費用の突合をするためです。
  2. ツール呼び出しのたびに構造化された監査レコードを残す:どのツールが、どんなパラメータで呼ばれたか——「このツールが実際に何をしたのか」を調べやすくするためです。

フレームワークに拡張ポイントがなければ、この 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.mdpackages/core/agent/src/runtime-types.ts の宣言)。

⚠️ 初心者が必ずハマる罠:tool/callturn/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(滝式)イベントを使えば、重要な決定ポイントでインターセプトと書き換えができます。この課で最も重要な部分です。図と照らし合わせながら読んでください。

事件如 agent/request监听器 1中间件监听器 2中间件消费方最终处理next()next()不调用 next() = 直接返回 → 接管/短路

waterfall = 环绕中间件:监听器用 next() 把控制权交给下一位,不调用就是接管

waterfall のセマンティクスは、入門ドキュメントに明確に書かれています。

ctx.waterfall はラップ型ミドルウェアです。リスナーは (...args, next) を受け取ります。next() を呼ぶと下流のリスナーが実行され、下流の戻り値は next() を通じて現在のラップ層に返され、その層がラップしてさらに外側へ返すことができます。next() を呼ばずに直接 return するとショートカットします。(出典:docs/cordis-primer.zh.md

3 つのルールに分解します。

  1. すべてのリスナーはミドルウェアです。イベントは発生源から出発し、各リスナーを順番に通過して、最後にコンシューマー(モデル呼び出しやツール実行など)に到達します。
  2. next() を呼ぶことは通過を意味しますawait next() で得られるのは「下流のすべてのリスナーが処理を終えた後」の結果です——それをラップしたり、書き換えたりして、外側へ返すことができます。
  3. next() を呼ばずに直接 return する = ショートカット = 引き継ぎ。これ以降のリスナーもコンシューマーも、このイベントを見ることができなくなります。これは「規則違反」に見えますが、実は意図的な設計です。

単一の決定を行うイベントでは、ショートカットは設計意図です。決定権を持つポリシーリスナーは next() を呼ばずに直接 return できますが、注釈や観察のみを行うリスナーは必ず委譲しなければなりません。(出典:docs/cordis-primer.zh.md

開発者ドキュメントはこれを警告として明記しています。

waterfall リスナーは必ず next() を呼ばなければなりませんnext を呼ばないとパイプライン全体がショートカットします。これは意図的な設計です——インターセプト/ゲートウェイロジックを実装するためのものです。(出典:docs/user/develop/framework/events.zh.md

実際のシナリオを 2 つ見てみましょう。

シナリオ 1:モデルリクエストの「設定差し替え」。 agent/requestnext() はマシンが凍結した呼び出し設定(LlmCallConfigprovidermodelmaxTokens などのフィールドを含む)を返します。リスナーが返したものを、マシンがそのまま使います(出典: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-stepwaterfall各ステップ開始前。このステップに入るメッセージのバッチを携えているステップ全体を拒否、またはメッセージの差し替え・注入——plan-mode(計画モード)や agent-instructions(ワークスペースコンテキスト)はここにいます
agent/requestwaterfallモデルリクエスト送出前。凍結された呼び出し設定を携えているprovider、model、maxTokens などの設定差し替え。この滝ではメッセージ内容を変更できないことに注意
agent/request-errorwaterfallモデルリクエスト失敗後、リトライまたはステップを閉じる前リトライするかどうかを決定——llm-retry(リトライプラグイン)はここにいます
agent/turn-stoppingserialターン終了直前(モデルが応答義務を負わなくなった時点)ターン終了を観察。停止を阻止したいなら agent.steer() で新しい入力を 1 件詰め込めば、マシンがもう 1 ステップ走ります
tools/pre-executewaterfallツール実行前事前チェック、危険な呼び出しのインターセプト——決定は 許可 / 拒否 / 確認
tools/post-executewaterfallツール実行後、結果が確定する前ツール結果の差し替えや拡充(長すぎる結果の要約、コンテキストの付加)
tools/resultemitツール呼び出しが完全に終了し、結果が凍結された後監査、ログ、統計——見るだけ、触らない
session/eventemit永続化されたログの事実が書き込まれるたびログストリームの観察:UI レンダリング、テレメトリ送信、トークン統計はすべてこれを聞いています
fs/write-intentwaterfallファイル書き込みの意図が発生したときファイルセーフティポリシー: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. 重要ポイントの振り返り

  1. リッスン = 挿入ctx.on('event-name', handler) でリスナーを登録すれば、フレームワークのメインループの任意のスロットにロジックを挿入でき、フレームワークのソースには一切触れません(出典:docs/user/develop/framework/events.zh.md
  2. 実際のスロット 3 つagent/request(モデルリクエスト前、waterfall)、tools/result(ツール呼び出し後、emit)、agent/turn-stopping(ターン終了直前、serial の観察ポイント)——モードは「イベントのプロデューサー・コンシューマーマトリクス」で確認できます(出典:docs/event-producer-consumer.md
  3. waterfall の 2 つの道next() を呼んで通過させて戻り値をラップする。呼ばずに直接 return = ショートカットして引き継ぐ——決定権を持つポリシーリスナーがこれを使い、観察系リスナーは必ず委譲します(出典:docs/cordis-primer.zh.md
  4. 選定の判断:動作を変えるなら waterfall(インターセプト/書き換え)、観察だけなら emit。tool/call などの永続化セッションイベントは session/event 経由で観察します
  5. 注意点 2 つ:リスナーのエラーは自分で受け止め、メインループを道連れにしないこと。emit は読み取り専用の観察ポイントなので、データ変更は waterfall で行うこと

🚀 次の課では、引き続きプラグインを書いていきます。設定の公開方法——コードを書き換えるのではなく、ユーザーが画面からプラグインのパラメータを調整できるようにする方法を学びます。

セルフテスト · イベントをリッスンする

回答を終えたら「解答を送信」をクリックすると、正誤と解説を確認できます。

1. 「モデルがリクエストするたびに」ログを 1 件記録したい場合、どのイベントをリッスンすべきですか?
2. waterfall イベント内の next() について、正しい説明はどれですか?
3. 「あるツール呼び出しを拒否する」インターセプトロジックを実装したい場合、どのイベントをリッスンして、どうすべきですか?
4. 「読み取り専用の観察」と「ペイロードの変更」は、それぞれどの種類のイベントを選ぶべきですか?