DeepSeek Harness イベントシステム: emit / bail / serial / waterfall
プラグイン間で疎結合に通信するにはどうすればいいか?イベントこそが Cordis の通信の中核メカニズムです。
Harness は拡張可能な拡張ポイントを実現するためにイベントを多用しています。この記事では5つのディスパッチモードを明確に解説します。
基本的な使い方
イベントにはリッスン(監視)とトリガー(発火)の両側があります。
例
ctx.on('event-name', (payload) => {
// イベントを処理
})
// イベントをトリガー: すべてのリスナーにブロードキャスト
ctx.emit('event-name', payload)
ctx.on で登録したリスナーは、プラグインのアンロード時に自動的に削除されます。
5つのディスパッチモード
Cordis はさまざまなディスパッチモードを提供しており、異なるインタラクション契約に適用できます。
| モード | ディスパッチメソッド | await するか | 順序 | 戻り値があるか | 典型的なシナリオ |
|---|---|---|---|---|---|
| emitブロードキャスト | ctx.emit | 否 | 登録順 | 否 | 通知系: すべてのリスナーが監視 |
| bailショートサーキット | ctx.bail | 否 | 登録順 | である | 決定系: 最初の有効な戻り値が勝ち |
| serial順次 | ctx.serial | である | 登録順 | である | 段階的初期化: 順番に実行して待機 |
| waterfallパイプライン | ctx.waterfall | 否 | 登録順 | である | 処理チェーン: 下流の戻り値を層ごとにラップ |
| parallel並列 | ctx.parallel | である | すべて並列 | 否 | ファンアウト: 複数のリスナーが並列処理 |
各イベントには唯一つのディスパッチモードがあり、対応するメソッドでのみディスパッチできます。
公式の入門ドキュメントでは waterfall は「await しない」とされていますが、リスナーは通常非同期関数であるため、チュートリアルの例ではチェーン全体の最終結果を待つために await ctx.waterfall(...) と書くのが一般的です。この違いを覚えておけば十分で、どちらの書き方も間違いではありません。
emit: ブロードキャスト
すべてのリスナーが同期的に実行され、戻り値は無視されます。
例
ctx.emit('my-plugin/ready', { id: 'サンプル-worker-1' })
// リスナー側: 準備完了メッセージを受信し、ログを出力
ctx.on('my-plugin/ready', ({ id }) => {
console.log(`${id} is ready`)
})
bail: ショートサーキット
リスナーは順番に実行され、null、false、undefined 以外の最初の戻り値が最終結果になります。
例
const result = ctx.bail('some-check', input)
// リスナー側: インターセプト条件に一致したら 'blocked' を返し、それ以外は続行
ctx.on('some-check', (input) => {
if (shouldBlock(input)) return 'blocked'
// null / false / undefined を返すと「異論なし」を意味し、後続のリスナーに処理を続行させる
})
serial: 順次実行
リスナーは登録順に実行され、非同期の結果を待ちます。
null、false、undefined 以外の最初の戻り値が後続の実行を終了させます。
例
await ctx.serial('setup-phase', context)
waterfall: パイプライン
各リスナーは下流の戻り値をラップして、処理チェーンを形成できます。
リスナーは (...args, next) を受け取り、next() を呼び出すと下流のリスナーが実行され、下流の戻り値は next() 経由で現在のラップ層に返されます。
例
const output = await ctx.waterfall('my-plugin/transform', input, async () => input)
// リスナー側: next() を呼び出し、下流の結果を取得してから加工して返す必要がある
ctx.on('my-plugin/transform', async (_input, next) => {
const downstream = await next()
return downstream.trim()
})
waterfall リスナーは必ず next() を呼び出す必要があります。next を呼び出さないとパイプライン全体がショートサーキットしますが、これは意図的な設計であり、インターセプトやゲートウェイのロジックを実装するためのものです。
ポリシーリスナーは決定権を持つ場合、next() を呼び出さずに直接戻り値を返すことで、チェーン全体を止めることができます。
注釈や監視のみを行うリスナーは、必ず下流に委譲する必要があります。
型安全なイベント
TypeScript の宣言マージを使用して、イベントに型安全性を提供します。
例
import '@deepseek-ai/cordis'
// 宣言マージ: イベント名とシグネチャを登録
declare module '@deepseek-ai/cordis' {
interface Events {
'my-plugin/ready': (payload: { id: string }) => void
'my-plugin/check': (input: string) => boolean | undefined
'my-plugin/transform': (input: string, next: () => Promise<string>) => Promise<string>
}
}
// 以降、ctx.on('my-plugin/ready', ...) と ctx.emit('my-plugin/ready', ...) の
// 引数が自動的に推論され、イベント名や引数型の誤りはコンパイル時にエラーになる
Cordis イベントとセッション記録
Harness の Cordis イベントは namespace/action の命名規則に従います。例: agent/step、agent/request、agent/request-error、tools/result、session/event。
2種類のイベントを区別することに注意してください:
| イベント | 内容 | 観察方法 |
|---|---|---|
| agent/step、tools/result など | Cordis イベント、リアルタイムでディスパッチ | 直接 ctx.on('tools/result', ...) を使用 |
| turn/*、step/*、tool/call、tool/result、compaction/* | 永続化されたセッションイベントタイプ | session/event をリッスンし、event.type を確認 |
turn/*、step/*、tool/call、tool/result、compaction/* は永続化されたセッションイベントタイプであり、同名の Cordis イベントではありません。
これらを観察する必要がある場合は、session/event をリッスンして event.type を確認してください。
実践例: ログプラグイン
公式ドキュメントでは、次のプラグインを使用してツール呼び出しとツール結果を記録し、Cordis イベントの tools/result をリッスンしています。
例
import type { Context } from '@deepseek-ai/cordis'
import '@deepseek-ai/dsh-tools'
export const name = 'tool-logger'
export function apply(ctx: Context) {
// tools/result イベントをリッスン: ツールの実行が完了するたびに発火
ctx.on('tools/result', (exec, result) => {
// ツール名とパラメータを出力
console.log(`[tool] ${exec.name}(${JSON.stringify(exec.arguments)})`)
// 結果内のテキストブロックを連結し、先頭100文字だけを出力
const text = result.content
.map(block => block.type === 'text' ? block.text : '')
.join('')
console.log(`[tool result] ${text.slice(0, 100)}`)
})
}
このプラグインは、Agent がツールを呼び出すたびに、ツール名、パラメータ、結果の要約をターミナルに出力します。
リスナーは ctx.on で登録され、プラグインのアンロード時に自動的に削除されます。
まとめと自己テスト
5つのディスパッチモードは「ブロードキャスト、決定、順次、パイプライン、並列」の5種類の契約をカバーします。waterfall は next() を呼び出す必要があり、declare module のマージによってイベント名と引数に型保証が提供されます。
自己テストしてみましょう:
- 5つのディスパッチモードはそれぞれどのメソッドでトリガーしますか?戻り値があるのはどれですか?
- waterfall リスナーが next() を呼び出さないとどうなりますか?これはバグですか、それとも設計ですか?
- turn/start のような永続化されたセッションイベントは、どのイベントをリッスンして、どのフィールドを確認すべきですか?