DeepSeek Harness: イベントシステム

最終更新:2026-08-31

イベントは Cordis の疎結合なプラグイン間通信のコア機構——プラグインは互いに直接呼び出さず、イベントのブロードキャストとサブスクリプションを通じて連携します。4つのイベントパターンが単純な通知から複雑なパイプラインまであらゆるシナリオをカバーします。

💡 ヒント:イベントパターン選択の鍵は「インターセプトが必要か?」——純粋な通知には emit、中断可能には bail、順次処理には serial、チェーン渡しには waterfall。

📋 前提知識14-inject.md の完了、依存性注入を理解していること

1. 学習内容

イベントディスパッチモード


2. emit:一般イベントブロードキャスト

(1) 基本的な使用方法

emit は最もシンプルなイベントパターン——通知をブロードキャスト;すべてのリスナーが受信し、戻り値は無視:

TYPESCRIPT
// イベント発行
ctx.emit('session/created', { id: 'abc-123', user: 'Alice' })

// イベントリッスン
ctx.on('session/created', (data) => {
  ctx.logger.info(`new session: ${data.id}`)
})

(2) 特性

特性 説明
ブロードキャスト すべてのリスナーが呼び出される
戻り値なし リスナーの戻り値は無視される
中断なし リスナーは後続リスナーの実行を阻止できない
順次実行 リスナーは登録順で実行

(3) 典型的シナリオ

▶ サンプル 4:

TYPESCRIPT
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 値を返すと、後続リスナーは実行されない:

TYPESCRIPT
// リスナーがイベントを「インターセプト」可能
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) 典型的シナリオ

▶ サンプル 4:

TYPESCRIPT
// 承認ポリシープラグイン
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:

TYPESCRIPT
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 は非同期リスナーを順次実行し、各リスナーは前の完了を待機:

TYPESCRIPT
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) 典型的シナリオ

(4) emit との違い

TYPESCRIPT
// emit:並列(待機しない)
ctx.emit('session/created', data)   // リスナーの完了を待機しない

// serial:順次(待機する)
await ctx.serial('session/created', data)  // 全リスナーの完了を待機

5. waterfall:チェーン渡しイベント

(1) 基本的な使用方法

waterfall は前のリスナーの戻り値を次に渡し、チェーンを形成:

TYPESCRIPT
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 パラメータを受け取る:

TYPESCRIPT
ctx.on('event/name', async (data, next) => {
  // データを変更
  data.field = newValue
  
  // next を呼んで次のリスナーに渡す
  return next(data)
  
  // next を呼ばない → チェーン中断、データは先に渡されない
})

(4) 典型的シナリオ

(5) チェーンの中断

TYPESCRIPT
ctx.on('request/process', async (data, next) => {
  if (!data.authenticated) {
    return { error: 'unauthenticated' }  // next を呼ばない、チェーン中断
  }
  return next(data)
})

6. イベントドメイン

(1) ドメイン分割

Cordis イベントはドメインごとに分割され、/ で区切られます:

TEXT 📖 参照専用
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) 特定ドメインのサブスクライブ

TYPESCRIPT
// ツールドメインのすべてのイベントをサブスクライブ
ctx.on('tool/*', (eventName, data) => {
  ctx.logger.info(`tool event: ${eventName}`)
})

7. カスタムイベントと型安全性

(1) カスタムイベントの宣言

TYPESCRIPT
// 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) 型安全なイベント発行

TYPESCRIPT
ctx.emit('my-plugin/data-loaded', { source: 'api', count: 42 })  // ✅ 型正しい
ctx.emit('my-plugin/data-loaded', { wrong: true })                // ❌ 型エラー

(3) 型安全なリッスン

TYPESCRIPT
ctx.on('my-plugin/data-loaded', (data) => {
  // data は自動的に { source: string; count: number } と推論
  ctx.logger.info(`loaded ${data.count} items from ${data.source}`)
})

(4) イベント型定義パターン

TYPESCRIPT
// プラグイン内部イベント
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) イベント命名規則

TEXT 📖 参照専用
{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 (状態)

❓ よくある質問

Q emit と bail は同じイベント名を共有できますか?
A 非推奨。技術的には可能ですが、同じ名前で emit と bail のリスナーを混在させると混乱を招きます。異なるイベント名を使用してください。
Q waterfall で next() の呼び出しを忘れたらどうなりますか?
A チェーンが中断され、後続リスナーは実行されません。waterfall 呼び出しは現在のデータを返します。これは意図的(条件付き中断)な場合もあればバグ(呼び出し忘れ)な場合もあります。
Q イベントリスナーは複数回登録できますか?
A はい。同じリスナー関数が複数回登録されると、その回数分呼び出されます。削除には ctx.off() を使用——同じ関数参照が必要です。
Q イベントリスナーの実行順序は制御できますか?
A デフォルトは登録順です。一部のフレームワークは優先度パラメータをサポートしていますが、Cordis は現在 FIFO 順を使用しています。
Q emit で非同期リスナーは待機されますか?
A いいえ。emit は非同期リスナーの完了を待機しません。待機が必要な場合は serial を使用してください。
Q 登録済みイベントリスナーを確認するには?
A typescript ctx.logger.info('listeners:', ctx.listenerCount('tool/beforeExecute'))

📖 まとめ


📝 練習問題

1. ⭐ 基礎:emit で my-plugin/loaded イベントをブロードキャストするプラグインを書き、別のプラグインでリッスンしてログ出力してください。

2. ⭐⭐ 応用:bail を使って tool/beforeExecute で承認インターセプターを実装し、rm を含むシェルコマンドをインターセプトしてください。テスト:ls は正常に実行、rm -rf / はインターセプトされること。

3. ⭐⭐⭐ チャレンジ:waterfall でメッセージ処理パイプラインを実装してください:trim → 機密語削除 → 長文切り詰め。各ステップが独立したリスナー;中間ステップはデータを変更可能、最後のステップが最終結果を返す。パイプライン動作を確認するテストを書くこと。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%