DeepSeek Harness: イベントシステム
最終更新:2026-08-31
イベントは Cordis の疎結合なプラグイン間通信のコア機構——プラグインは互いに直接呼び出さず、イベントのブロードキャストとサブスクリプションを通じて連携します。4つのイベントパターンが単純な通知から複雑なパイプラインまであらゆるシナリオをカバーします。
📋 前提知識:14-inject.md の完了、依存性注入を理解していること
1. 学習内容
- emit:一般イベントブロードキャスト
- bail:中断可能イベント
- serial:順次イベント
- waterfall:チェーン渡しイベント
- イベントドメイン:session/agent/capability
- カスタムイベントと型安全性
2. emit:一般イベントブロードキャスト
(1) 基本的な使用方法
emit は最もシンプルなイベントパターン——通知をブロードキャスト;すべてのリスナーが受信し、戻り値は無視:
// イベント発行
ctx.emit('session/created', { id: 'abc-123', user: 'Alice' })
// イベントリッスン
ctx.on('session/created', (data) => {
ctx.logger.info(`new session: ${data.id}`)
})
(2) 特性
| 特性 | 説明 |
|---|---|
| ブロードキャスト | すべてのリスナーが呼び出される |
| 戻り値なし | リスナーの戻り値は無視される |
| 中断なし | リスナーは後続リスナーの実行を阻止できない |
| 順次実行 | リスナーは登録順で実行 |
(3) 典型的シナリオ
- 通知:
session/created、plugin/loaded - ロギング:
tool/executed、llm/request - 統計:
request/completed、error/occurred
▶ サンプル 4:
ctx.on('session/created', (data) => {
ctx.logger.info(`logger: ${data.id}`)
})
ctx.on('session/created', (data) => {
ctx.metrics.inc('session_count')
})
ctx.on('session/created', (data) => {
ctx.cache.set(`session:${data.id}`, data)
})
ctx.emit('session/created', { id: 'abc', user: 'Alice' })
// 3つのリスナーがすべて実行
3. bail:中断可能イベント
(1) 基本的な使用方法
bail は中断可能イベント——リスナーが非 undefined 値を返すと、後続リスナーは実行されない:
// リスナーがイベントを「インターセプト」可能
ctx.on('tool/beforeExecute', (data) => {
if (data.tool === 'shell' && data.params.command.includes('rm')) {
return { denied: true, reason: 'dangerous command' }
}
})
ctx.bail('tool/beforeExecute', { tool: 'shell', params: { command: 'rm -rf /' } })
// → { denied: true, reason: 'dangerous command' } を返す
// 後続リスナーは実行されない
(2) 特性
| 特性 | 説明 |
|---|---|
| 中断可能 | 非 undefined の返却で伝播を中断 |
| ショートサーキット | 最初に値を返したリスナーが伝播を終了 |
| 戻り値あり | bail 呼び出しはインターセプトされた値を返す |
| 順序依存 | 先に登録されたリスナーが先にインターセプト |
(3) 典型的シナリオ
- 権限チェック:
tool/beforeExecute(危険操作を拒否) - コンテンツフィルタリング:
message/beforeSend(機密内容をフィルタ) - 条件付きスキップ:
task/beforeRun(不適用タスクをスキップ)
▶ サンプル 4:
// 承認ポリシープラグイン
ctx.on('tool/beforeExecute', (data) => {
const policy = getApprovalPolicy(data.tool)
if (policy === 'deny') {
return { denied: true, reason: `${data.tool} is denied by policy` }
}
if (policy === 'ask') {
return { pending: true, requiresApproval: true }
}
// undefined を返す → インターセプトせず、伝播を継続
})
// サンドボックスプラグイン(承認の後)
ctx.on('tool/beforeExecute', (data) => {
if (!isInSandbox(data.params.cwd)) {
return { denied: true, reason: 'execution outside sandbox' }
}
})
▶ サンプル 5:
const result = ctx.bail('tool/beforeExecute', data)
if (result) {
// インターセプトされた
ctx.logger.warn('tool execution denied:', result.reason)
} else {
// インターセプトされていない、実行可能
await executeTool(data)
}
4. serial:順次イベント
(1) 基本的な使用方法
serial は非同期リスナーを順次実行し、各リスナーは前の完了を待機:
ctx.on('session/initialized', async (data) => {
await loadUserPreferences(data.userId)
})
ctx.on('session/initialized', async (data) => {
await setupWorkspace(data.workspaceId)
})
ctx.on('session/initialized', async (data) => {
await warmCache(data.projectPath)
})
// 3つのリスナーが順次実行
await ctx.serial('session/initialized', { userId: 'alice', workspaceId: 'ws-1' })
(2) 特性
| 特性 | 説明 |
|---|---|
| 順次 | リスナーが登録順に1つずつ実行 |
| 非同期 | 非同期リスナーをサポート |
| 完了待機 | serial 呼び出しは全リスナーの完了を待機 |
| 中断なし | リスナーは後続実行を阻止できない |
(3) 典型的シナリオ
- 初期化フロー:
session/initialized(設定ロード → 接続確立 → キャッシュウォームの順) - クリーンアップフロー:
session/closing(データ保存 → 切断 → 一時ファイル削除の順) - データパイプライン:
data/transform(段階的にデータ変換)
(4) emit との違い
// emit:並列(待機しない)
ctx.emit('session/created', data) // リスナーの完了を待機しない
// serial:順次(待機する)
await ctx.serial('session/created', data) // 全リスナーの完了を待機
5. waterfall:チェーン渡しイベント
(1) 基本的な使用方法
waterfall は前のリスナーの戻り値を次に渡し、チェーンを形成:
ctx.on('message/format', async (data, next) => {
data.text = data.text.trim()
return next(data)
})
ctx.on('message/format', async (data, next) => {
data.text = data.text.replace(/\s+/g, ' ')
return next(data)
})
ctx.on('message/format', async (data, next) => {
data.text = data.text.substring(0, 4096)
return next(data)
})
const result = await ctx.waterfall('message/format', { text: ' hello world ' })
// result.text === 'hello world' (trim → 空白統合 → 切り詰め)
(2) 特性
| 特性 | 説明 |
|---|---|
| チェーン渡し | 前の出力が次の入力 |
| next() 呼び出し | リスナーは next() を呼んで次に渡す必要あり |
| 変更可能 | 各リスナーがデータを変更可能 |
| 中断可能 | next() を呼ばないとチェーンが中断 |
(3) next() 関数
各 waterfall リスナーは next パラメータを受け取る:
ctx.on('event/name', async (data, next) => {
// データを変更
data.field = newValue
// next を呼んで次のリスナーに渡す
return next(data)
// next を呼ばない → チェーン中断、データは先に渡されない
})
(4) 典型的シナリオ
- メッセージ処理:
message/format(フォーマット → フィルタ → 切り詰め) - リクエストパイプライン:
request/process(認証 → 認可 → 処理 → ログ) - データ変換:
data/transform(解析 → 検証 → 正規化 → 出力)
(5) チェーンの中断
ctx.on('request/process', async (data, next) => {
if (!data.authenticated) {
return { error: 'unauthenticated' } // next を呼ばない、チェーン中断
}
return next(data)
})
6. イベントドメイン
(1) ドメイン分割
Cordis イベントはドメインごとに分割され、/ で区切られます:
session/created → セッションドメイン
session/destroyed → セッションドメイン
tool/beforeExecute → ツールドメイン(capability サブドメイン)
tool/afterExecute → ツールドメイン
agent/initialized → エージェントドメイン
llm/request → LLM ドメイン
(2) コアイベントドメイン
| ドメイン | プレフィックス | 代表的なイベント |
|---|---|---|
| session | session/ |
created, destroyed, forked |
| agent | agent/ |
initialized, stopped, error |
| tool | tool/ |
beforeExecute, afterExecute, error |
| llm | llm/ |
request, response, stream, error |
| fiber | fiber/ |
created, active, disposing, disposed, errored |
| config | config/ |
updated, validated |
(3) ドメインの目的
イベントドメインは単なる糖衣構文ではなく、フレームワークはドメインに基づいて最適化します:
- イベントフィルタリング:特定ドメインのイベントのみサブスクライブ
- スコープ分離:セッションドメインイベントはセッションコンテキスト内で伝播
- 監査グループ化:ドメインごとにイベントログを収集
(4) 特定ドメインのサブスクライブ
// ツールドメインのすべてのイベントをサブスクライブ
ctx.on('tool/*', (eventName, data) => {
ctx.logger.info(`tool event: ${eventName}`)
})
7. カスタムイベントと型安全性
(1) カスタムイベントの宣言
// events.ts
interface MyPluginEvents {
'my-plugin/data-loaded': { source: string; count: number }
'my-plugin/data-error': { source: string; error: Error }
}
declare module '@deepseek-ai/cordis' {
interface Events extends MyPluginEvents {}
}
(2) 型安全なイベント発行
ctx.emit('my-plugin/data-loaded', { source: 'api', count: 42 }) // ✅ 型正しい
ctx.emit('my-plugin/data-loaded', { wrong: true }) // ❌ 型エラー
(3) 型安全なリッスン
ctx.on('my-plugin/data-loaded', (data) => {
// data は自動的に { source: string; count: number } と推論
ctx.logger.info(`loaded ${data.count} items from ${data.source}`)
})
(4) イベント型定義パターン
// プラグイン内部イベント
interface InternalEvents {
'cache/hit': { key: string; age: number }
'cache/miss': { key: string }
'cache/evicted': { key: string; reason: string }
}
// グローバル Events インターフェースを拡張
declare module '@deepseek-ai/cordis' {
interface Events extends InternalEvents {}
}
// 他のプラグインが使用できるようエクスポート
export type CacheEvents = InternalEvents
(5) イベント命名規則
{domain}/{verb-past-tense} ✅ session/created
{domain}/{verb-present} ✅ tool/execute (進行中)
{domain}/before{Action} ✅ tool/beforeExecute (事前フック)
{domain}/after{Action} ✅ tool/afterExecute (事後フック)
{domain}/{noun}-{state} ✅ fiber/errored (状態)
❓ よくある質問
ctx.off() を使用——同じ関数参照が必要です。typescript ctx.logger.info('listeners:', ctx.listenerCount('tool/beforeExecute')) 📖 まとめ
- 4つのイベントパターン:emit(ブロードキャスト)、bail(中断可能)、serial(順次非同期)、waterfall(チェーン渡し)
- emit は通知、bail はインターセプト、serial は順序付き初期化、waterfall はデータパイプラインに適する
- イベントドメインは
/で区切られ:session/agent/tool/llm/fiber/config - カスタムイベントは
declare moduleで Events インターフェースを拡張して型安全性を実現 - waterfall の next() 呼び出しはチェーン伝播の鍵;呼び出し忘れはチェーンを中断
📝 練習問題
1. ⭐ 基礎:emit で my-plugin/loaded イベントをブロードキャストするプラグインを書き、別のプラグインでリッスンしてログ出力してください。
2. ⭐⭐ 応用:bail を使って tool/beforeExecute で承認インターセプターを実装し、rm を含むシェルコマンドをインターセプトしてください。テスト:ls は正常に実行、rm -rf / はインターセプトされること。
3. ⭐⭐⭐ チャレンジ:waterfall でメッセージ処理パイプラインを実装してください:trim → 機密語削除 → 長文切り詰め。各ステップが独立したリスナー;中間ステップはデータを変更可能、最後のステップが最終結果を返す。パイプライン動作を確認するテストを書くこと。