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() 経由で現在のラップ層に返されます。

例

// ディスパッチ側: 初期値は input そのもので、最初のリスナーに渡す
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 の宣言マージを使用して、イベントに型安全性を提供します。

例

// ファイルパス: scratch-plugin/src/events.ts
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 をリッスンしています。

例

// ファイルパス: scratch-plugin/src/tool-logger.ts
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 のマージによってイベント名と引数に型保証が提供されます。

自己テストしてみましょう:

  1. 5つのディスパッチモードはそれぞれどのメソッドでトリガーしますか?戻り値があるのはどれですか?
  2. waterfall リスナーが next() を呼び出さないとどうなりますか?これはバグですか、それとも設計ですか?
  3. turn/start のような永続化されたセッションイベントは、どのイベントをリッスンして、どのフィールドを確認すべきですか?
その他の拡張