第 9 課:イベントシステム:すべてはイベント
一言でいうと:DSH はエージェントの重要なアクションをすべてイベントとして「ブロードキャスト」します。イベントこそサービスの拡張 API です。ソースを fork せずにカスタムロジックを差し込みたいなら、対応するイベントをリッスンするだけ。waterfall(滝式)イベントに出会ったら、
next()を呼んで制御を下流に委譲するか、呼ばずにショートサーキットして乗っ取ります。
1. ユーザーストーリー:ソースを fork せず、モデルリクエストの前後にカスタムロジックを差し込む
すでに稼働中の DSH デプロイを引き継いだとします。上司から 3 つの要件が出されました:
- すべてのモデルリクエストはデフォルトで安いモデルを使い、特別なタスクだけ高いモデルを使うこと。
- モデルリクエストの前に、コンテキストにチームで決められたワークスペース情報があるかチェックすること。
- ツール呼び出しのたびに構造化ログを 1 件記録し、問題の切り分けを容易にすること。
従来のフレームワークなら、こうした要件はほぼすべて同じ答えに行き着きます。ソースを fork してメインループを書き換える。そして上流がアップグレードされるたびにパッチをマージし直す、苦痛な日々の始まりです。
DSH の答えは:何も fork する必要はありません。エージェントのメインループの各ステップ(メッセージの取得、リクエストの組み立て、モデルの呼び出し、ツールのディスパッチ、ターンの終了)はすべてイベントを発行します。あなたは対応するイベントをリッスンする小さなプラグインを書くだけです:
export const name = 'team-hooks'
export function apply(ctx: Context) {
// 要件 1:モデルリクエストの前に、デフォルト設定を安いモデルに差し替える
ctx.on('agent/request', async (_payload, next) => {
const config = await next() // 下流(マシンのデフォルト)の呼び出し設定を受け取る
return { ...config, model: 'cheap-model' } // モデルを差し替えて返す
})
// 要件 3:ツール呼び出し後に、ログを 1 件記録する
ctx.on('tools/result', (exec, result) => {
console.log(`[tool] ${exec.name} 完了、${result.content.length} 個のコンテンツブロックを返しました`)
})
}
このコードは実際のドキュメントにあるサンプルプラグインからのものです(出典:docs/user/develop/framework/events.zh.md)。フレームワークのソースには一切触れず、ランタイムに「ぶら下がる」だけです。イベントリスナー自体がひとつのエフェクトであり、プラグインがアンロードされるとリスナーも自動的に除去され、残渣は残りません。
💡 この定型文を覚えてください。振る舞いを追加したいならイベントをリッスンする。振る舞いを変えたいなら waterfall イベントをリッスンして乗っ取る。 これが「すべてはイベント」の第一の意味です。
2. イベントはサービスの拡張 API:3 つのイベントドメイン
アーキテクチャドキュメントは単刀直入です:
イベントはサービスの拡張 API である。(出典:
docs/architecture.zh.md)
つまり、イベントは「ついでにお知らせする」補助的な仕組みではなく、DSH が意図的にプラグイン作者に開放している拡張インターフェースです。DSH はイベントを 3 つのドメインに分けています:
- セッションイベントは
session/eventを通じて発行される永続化されたログの事実です。- Agent イベントはアクティブな
Agentを運び、inbox、ステップ、ステータス、リクエスト、バリデーション、再開に使われます。- ケイパビリティイベントはインポートサイクルなしでポリシーやアダプターを取り付けられます。(出典:
docs/architecture.zh.md)
| イベントドメイン | どんな姿か | 何を担当するか | 実際の例 |
|---|---|---|---|
| セッションイベント | session/event(多種のログの事実を運ぶ 1 つのイベント) | 「何が起きたか」を記録する:セッションログに追記され、単一の真実の源となる | turn/start、step/end、tool/call、tool/result |
| Agent イベント | agent/* | アクティブな Agent を運び、ステップ、リクエスト、ステータス、停止を管理する | agent/pre-step、agent/request、agent/status、agent/turn-stopping |
| ケイパビリティイベント | tools/*、fs/*、llm/* | メインループに触れず、個々のケイパビリティにポリシーやアダプターを取り付ける | tools/pre-execute、fs/write-intent、llm/stream |
初心者が必ず踏む落とし穴を先に明確にしておきます。tool/call や turn/start などは永続化されるセッションイベントの型であり、同名のランタイムイベントではありません。これらを観測したいなら、session/event をリッスンして event.type をチェックします。ランタイムでブロードキャストされる Cordis イベントは tools/*、agent/* の一族です(出典:docs/user/develop/framework/events.zh.md)。
3. waterfall:next() で委譲、呼ばなければ乗っ取り(重要)
Cordis のイベントには 4 つのディスパッチモードがあります。前の 2 課で見た ctx.on() によるリッスンはそのうちの 1 つにすぎません:
| モード | 一言でいうと | 戻り値はあるか |
|---|---|---|
emit | ブロードキャスト通知:すべてのリスナーが登録順に「ひと目見る」 | なし |
waterfall | ラップ型ミドルウェア:各リスナーは結果をラップでき、ショートサーキットもできる | あり |
parallel | すべてのリスナーが並列に実行される | なし |
serial | 登録順に実行され、最初の非空の結果で後続が終了する | あり |
この中で waterfall は拡張力が最も強く、最も理解が必要なものです。ドキュメントはこう定義しています:
ctx.waterfallはラップ型ミドルウェアである。リスナーは(...args, next)を受け取る。next()を呼ぶと下流のリスナーが実行される。下流の戻り値はnext()を通じて現在のラップ層に戻り、その層でラップしてさらに外側へ返すことができる。next()を呼ばずに直接 return するとショートサーキットする。(出典:docs/cordis-primer.zh.md)
一言でいえば:waterfall はタマネギの鎖のようなもので、イベントは発生元から各リスナーを順に通過し、最後に消費者へ届きます。
waterfall = 环绕中间件:监听器用 next() 把控制权交给下一位,不调用就是接管
図のルールを分解して説明します:
- すべてのリスナーはミドルウェアです。 まず自分の仕事(引数の書き換え、ログ記録、チェック)をしてから、
next()を呼んで次のリスナーに制御を渡します。 - 下流の戻り値は来た道を戻ります。
await next()で得られるのは「後続のすべてのリスナーが処理を終えたあと」の結果であり、もう一層ラップして(たとえばモデル設定を差し替えて)から外側へ返せます。 next()を呼ばずに直接 return する = ショートサーキット = 乗っ取り。 後続のリスナーと消費者は、このイベントを一切見られなくなります。これは「ルール違反」に見えますが、実は意図的な設計です:
単一の意思決定イベントでは、ショートサーキットは設計意図である。ポリシーリスナーは決定権を持つとき
next()を呼ばずに直接 return してよく、注釈や観測だけを行うリスナーは委譲しなければならない。(出典:docs/cordis-primer.zh.md)
開発者向けドキュメントは、これを警告として明記さえしています:
waterfall リスナーは必ず
next()を呼ばなければならない。nextを呼ばないとパイプライン全体がショートサーキットする。これは意図的な設計であり、インターセプト/ゲートウェイのロジックを実装するために使われる。(出典:docs/user/develop/framework/events.zh.md)
実際のインターセプトの場面を見てみましょう。ファイル書き込みにセキュリティポリシーを取り付ける例(イメージ)です:
ctx.on('fs/write-intent', async (payload, next) => {
// 自分は「ポリシー」:決定権を持つ
if (isDangerousWrite(payload)) {
return { allowed: false } // next() を呼ばず、直接乗っ取る:この書き込みを拒否する
}
return next() // 許可:決定権を下流に委譲する
})
この判断の口訣を覚えてください。「自分が決める」なら next() を呼ばない。「見るだけ」なら必ず next() を呼ぶ。
4. 実際のイベントと再構築可能性:どのステップに何を差し込めるか
4.1 実際のイベント:どのステップに何を差し込めるか
以下のイベントはすべて、リポジトリの「イベントの生産者と消費者のマトリクス」(出典:docs/event-producer-consumer.md)とサブシステムドキュメント(出典:docs/subsystems/core.md)に由来します:
| イベント | モード | どのステップで起きるか | ここに何を差し込めるか |
|---|---|---|---|
agent/pre-step | waterfall | 各ステップの開始前、そのステップに入るメッセージのバッチを伴う | ステップ全体を拒否(reject)する、またはメッセージの置き換え/注入を行う——plan-mode(計画モード)や agent-instructions(ワークスペースコンテキスト)はここで動く |
agent/request | waterfall | モデルリクエストの送信前、凍結された呼び出し設定を伴う | provider、model、maxTokens などの設定を差し替える。ただしこの滝ではメッセージ内容を変えられないことに注意 |
agent/request-error | waterfall | モデルリクエスト失敗後、リトライまたはステップを閉じる前 | retry を返してリトライを乗っ取るか、下流に委譲する——llm-retry(リトライ)プラグインはここにいる |
tools/pre-execute | waterfall | ツール実行前 | 事前チェック、引数の書き換え |
agent/turn-stopping | serial | ターンが閉じる直前(モデルがもはや応答を負っていないとき) | 停止を阻止する:agent.steer() で新しい入力を押し込めば、マシンはもう 1 ステップ走る——これはドキュメント公認の「停止境界」 |
fs/write-intent | waterfall | ファイル書き込みの意図が生じたとき | セキュリティポリシー:許可、拒否、書き換え——fs-observation-policy(ファイルポリシー)プラグインはここにいる |
session/event | emit | 永続化されるログの事実が書き込まれるたび | ログストリームの観測:UI レンダリング、テレメトリの送信、トークン集計、永続化バックアップがすべてこれをリッスンしている |
このうち agent/pre-step はリクエストディスパッチ前の唯一の直列境界、agent/turn-stopping は停止境界です。この 2 つの文はそれぞれ docs/subsystems/core.md と docs/architecture.zh.md に由来し、「スロットの位置」に関する公式の定義です。
4.2 イベントと再構築可能性:セッションイベントはログそのもの
第 3 課『エージェントループとセッション:すべてに記録がある』で学んだ「実行の再構築可能性」を覚えていますか? 今回の課は、その担い手を徹底的に説明します:
セッションログは権威ある記録である。
deriveMessages()がモデルの履歴を射影し、生のassistant/chunkイベントがリプレイと UI の忠実性を保証する。fork、再開、transcript(書き起こし)のレンダリング、テレメトリ、永続化はすべてこのイベントストリームから派生する。(出典:docs/architecture.zh.md)
分解して見ましょう:
- セッションとは追記専用(append-only)のイベントログであり、12 種類の永続イベント——
turn/start、turn/end、step/start、step/end、user/message、assistant/chunk、assistant/message、tool/call、tool/result、steering/message、todo/write、request/header——が入っています(出典:docs/subsystems/core.md)。 - モデルが見る会話履歴は、別に保存されたコピーではありません。毎回
deriveMessages()でログからその場で計算される射影です。 - リプレイ、UI、テレメトリ、fork、再開——すべてが同じ 1 つのイベントストリームから派生し、第二の真実は存在しません。
つまり「すべてはイベント」の第二の意味は:イベントは拡張 API であると同時に、データの真実でもあるということです。ランタイムのイベントはあなたの介入を可能にし(前の 3 節)、ログのイベントは再構築を可能にします(この節)。この 2 つは、同じ「イベント」という言語を使っています。
5. 重要ポイントのおさらい
- イベントはサービスの拡張 API:ソースを fork せず、イベントをリッスンするだけでカスタムロジックを差し込める(出典:
docs/architecture.zh.md) - 3 つのイベントドメイン:セッションイベント(
session/event、永続化されるログの事実)、Agent イベント(agent/*、アクティブな Agent を運ぶ)、ケイパビリティイベント(tools/*、fs/*、llm/*、ポリシーとアダプターを取り付ける) - waterfall はラップ型ミドルウェア:
next()を呼んで下流に委譲し、戻り値をラップする。呼ばずに直接 return する = ショートサーキットして乗っ取る——ポリシーリスナーは決定権を持つときこれを使い、観測系リスナーは委譲しなければならない - 実際のスロット:
agent/pre-stepはステップメッセージのインターセプト/注入、agent/requestはモデル設定の差し替え、agent/request-errorはリトライの決定、agent/turn-stoppingはターン終了の阻止、fs/write-intentは書き込みポリシーの取り付け - セッションイベントはログそのもの:追記専用、射影可能、リプレイ可能——リプレイ、UI、テレメトリ、fork、再開がすべてここから派生する。第 3 課の「実行の再構築可能性」と呼応する
🚀 次の課では DSH のコードマップを開きます。イベント宣言、サービス定義、プラグインのエントリーポイントがソースのどのディレクトリに住んでいるか——読み終わる頃には、自分の手で最初のプラグインを書けるようになっているはずです。
セルフテスト · イベントシステム
回答を終えたら「解答を送信」をクリックすると、正誤と解説を確認できます。
