用好这 5 把钥匙,你就能拦截 AI Agent 的一切行为

0 阅读10分钟

第11章:事件系统与拦截扩展——用好 Cordis 的五把钥匙

系列:DeepSeek Harness 源码实战
原文仓库:github.com/deepseek-ai…

插件注册了工具,工具能跑了。但 dsh 的扩展能力远不止「注册工具」——事件系统才是真正的深度扩展入口。

Cordis 提供五种事件分发模式:emit、waterfall、serial、parallel、bail。每种模式的语义不同,适用场景不同,用错了要么静默失效、要么死锁。这一章把五把钥匙一次讲透,然后用真实事件做实战拦截器。

我是怕浪猫,这是系列第 11 章。我们先从五把钥匙的对比开始。11.png

11.1 五种分发模式对比

Cordis Primer(docs/cordis-primer.md)定义了四种模式,加上 API 文档中的 bail(docs/cordis-api/events.md),一共五种:

模式调用方式是否 await执行顺序返回值语义
emitctx.emit(name, …args)注册顺序同步广播
waterfallctx.waterfall(name, …args, next)注册顺序环绕中间件
parallelawait ctx.parallel(name, …args)并发并行 fan-out
serialawait ctx.serial(name, …args)注册顺序有序 bailing
bailctx.bail(name, …args)注册顺序同步 bailing

dispatch mode(分发模式)是事件契约的一部分,不是实现细节。每个事件在声明时确定自己的模式,消费方必须按模式编写监听器。dsh 用 @mode 标签标注事件模式,生成文档时会交叉校验。

API 文档的签名(docs/cordis-api/events.md):

// emit:同步广播,不收集返回值
emit<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): void

// parallel:并发执行所有监听器,await 全部完成
parallel<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): Promise<void>

// serial:按序 await,第一个非 null/false/undefined 的返回值胜出
serial<K extends keyof Events>(name: K, ...args): Promisify<ReturnType<Events[K]>>

// bail:同步版 serial
bail<K extends keyof Events>(name: K, ...args): ReturnType<Events[K]>

// waterfall:环绕中间件,每个监听器收到 ...args + next
waterfall<K extends keyof Events>(name: K, ...args): ReturnType<Events[K]>

怎么选?一句话决策树:

需要返回值?
  是 -> 需要中间件语义?
    是 -> waterfall
    否 -> serial(async)或 bail(sync)
  否 -> 需要并发?
    是 -> parallel
    否 -> emit

金句:五种模式不是五种便利函数,是五种不同的协作契约。选错模式,监听器要么拿不到返回值、要么死锁。

11.2 emit:同步广播

emit 是最简单的模式。所有监听器按注册顺序同步执行,返回值被忽略。

来自教程的例子(docs/cordis-tutorial/04-events.md):

import { Service, type Context } from '@deepseek-ai/cordis'

declare module '@deepseek-ai/cordis' {
  interface Context {
    stats: StatsService
  }
  interface Events {
    'stats/report'(name: string, count: number): void
  }
}

export class StatsService extends Service {
  private counts = new Map<string, number>()

  constructor(ctx: Context) {
    super(ctx, 'stats')
  }

  bump(name: string) {
    const next = (this.counts.get(name) ?? 0) + 1
    this.counts.set(name, next)
    this.ctx.emit('stats/report', name, next)
  }
}

监听端:

ctx.on('stats/report', (name, count) => {
  console.log(`[stats] ${name} -> ${count}`)
})

emit 的适用场景:

场景说明
通知/广播某事发生了,不关心谁在听、听到后做什么
状态更新通知各组件状态已变
日志/审计记录事件发生

dsh 中的 emit 事件举例(来自 docs/event-producer-consumer.zh.md):

事件派发方监听方
agent/createdagentagent-presets, goal-round-driver, schedule
session/createdsessioncompaction, goal, permission-presets, tools, user-approval
tools/resulttoolsagent-instructions, subagent-in-process-driver
session/eventsession20+ 个包监听

注意 session/event 有 20+ 个监听方——这是 dsh 的核心事件,几乎所有需要感知会话变化的插件都监听它。

11.3 waterfall:环绕中间件

waterfall 是 dsh 最重要的事件模式。工具执行流水线的三个关键事件——tools/pre-execute、tools/execute、tools/post-execute——全是 waterfall。

教程的 waterfall 演示(docs/cordis-tutorial/04-events.md):

declare module '@deepseek-ai/cordis' {
  interface Events {
    'demo/transform'(input: string, next: () => Promise<string>): Promise<string>
  }
}

export function apply(ctx: Context) {
  // 监听器 1:包装下游结果
  ctx.on('demo/transform', async (input, next) => {
    const downstream = await next()
    return downstream.toUpperCase()
  })

  // 监听器 2:条件短路
  ctx.on('demo/transform', async (input, next) => {
    if (input.includes('blocked')) return '** blocked **'
    return next()
  })

  void (async () => {
    console.log(await ctx.waterfall('demo/transform', 'hello', async () => 'hello'))
    console.log(await ctx.waterfall('demo/transform', 'blocked words', async () => 'blocked words'))
  })()
}

运行结果:

HELLO
** BLOCKED **

执行流程图解(第一次调用,input=‘hello’):

ctx.waterfall('demo/transform', 'hello', defaultFn)
  |
  v
监听器 1:(input='hello', next=...)
  调用 next() ->
    |
    v
    监听器 2:(input='hello', next=...)
      调用 next() ->
        |
        v
        defaultFn() 返回 'hello'
      返回 'hello'(原样传递)
    <--
  返回 'hello'.toUpperCase() = 'HELLO'
<--

第二次调用(input=‘blocked words’):

监听器 1:(input='blocked words', next=...)
  调用 next() ->
    |
    v
    监听器 2:(input='blocked words', next=...)
      检测到 'blocked'
      return '** blocked **'(不调 next,短路)
    <--
  返回 '** BLOCKED **'.toUpperCase()
<--
(defaultFn 从未执行)

金句:waterfall 的 next() 不是可选的——只观察不拦截的监听器必须调它。忘调 next() 不会报错,只会静默吞掉所有下游行为。

教程的警告(docs/cordis-tutorial/04-events.md):

a waterfall listener that only observes or annotates must call next(); returning without it is a deliberate short-circuit. Forgetting next() in a logging listener silently swallows the default behavior for everyone downstream.

Cordis Primer 的补充(docs/cordis-primer.md):

Use prepend: true only when the listener must run before ordinary registrations.

prepend: true 让监听器插队到链头,在其他普通注册的监听器之前执行。

11.4 serial 和 bail:有序 bailing

serial 是异步有序 bailing,bail 是同步有序 bailing。监听器按注册顺序执行,第一个返回非 null/false/undefined 的监听器胜出,后续监听器不执行。

API 文档(docs/cordis-api/events.md):

// serial:异步有序,第一个 bail 值胜出
serial<K extends keyof Events>(name: K, ...args): Promisify<ReturnType<Events[K]>>

// bail:同步有序,第一个 bail 值胜出
bail<K extends keyof Events>(name: K, ...args): ReturnType<Events[K]>

dsh 中的 serial 事件:agent/turn-stopping(来自事件矩阵)。当 agent 即将停止当前轮次时,serial 事件让监听器有机会注入续跑消息。

serial 和 waterfall 的区别:

维度waterfallserial
中间件语义有 next(),可包装结果无 next(),返回值即终值
短路方式不调 next()返回非 null/false/undefined
返回值来源最内层 default 或包装后的值第一个 bail 的监听器
典型用途拦截/转换流水线决策/投票

bail 和 serial 的区别仅在同步/异步:

维度bailserial
是否 await
监听器可否 async不行可以

11.5 parallel:并行 fan-out

parallel 让所有监听器并发执行,await 全部完成。返回值被忽略。

API 文档(docs/cordis-api/events.md):

parallel<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): Promise<void>

dsh 中的 parallel 事件:session/flush(来自事件矩阵)。当会话需要刷盘时,所有持久化相关插件并发执行刷盘操作。

session/flush (parallel)
  |-- session-persistence -> 写 JSONL/SQLite
  |-- session-telemetry -> 发送遥测数据

两个操作互不依赖,parallel 让它们并发执行,总耗时等于最慢的那个。

11.6 类型化事件声明

Cordis 用 TypeScript 声明合并给事件加类型。声明位置在 interface Events 里(docs/cordis-tutorial/04-events.md):

declare module '@deepseek-ai/cordis' {
  interface Events {
    // emit 事件:返回 void
    'stats/report'(name: string, count: number): void
    // waterfall 事件:最后一个参数是 next
    'demo/transform'(input: string, next: () => Promise<string>): Promise<string>
    // serial/bail 事件:返回值用于 bailing
    'some-check'(input: string): boolean | undefined
  }
}

消费方需要 import 声明文件才能拿到类型。通常通过 import '@deepseek-ai/dsh-tools'import type {} from './stats.ts' 拉入声明合并。

金句:声明合并不是可选的类型装饰,是事件系统的编译时契约。没有它,ctx.emit 和 ctx.on 的参数类型是 any——运行时不报错,但 IDE 提示全废。

11.7 实战:权限拦截器

用 waterfall 写一个真实的权限拦截器。监听 tools/pre-execute,按工具名和参数决定 allow/deny/ask。

参考 docs/cookbook/adding-a-tool.md 中的说明:

Prefer not to build deployment policy into the tool. Use tools/pre-execute for extensible allow/deny/ask policy.

tools/pre-execute 是 waterfall 事件,监听器返回 PreToolDecision 决定是否放行。

import type { Context } from '@deepseek-ai/cordis'
import '@deepseek-ai/dsh-tools'

export const name = 'my-permission-gate'
export const inject = ['tools']

export function apply(ctx: Context) {
  // 禁止写入系统目录
  ctx.on('tools/pre-execute', async (exec, next) => {
    if (exec.name === 'write_file' || exec.name === 'edit_file') {
      const path = exec.arguments?.path as string
      if (path && (path.startsWith('/etc/') || path.startsWith('/sys/'))) {
        return { kind: 'deny' as const, reason: 'System directories are read-only' }
      }
    }
    // 其他情况委托下游
    return next()
  })

  // 对 bash 命令做关键词过滤
  ctx.on('tools/pre-execute', async (exec, next) => {
    if (exec.name === 'bash') {
      const command = exec.arguments?.command as string
      // 拦截危险命令模式
      if (command && /rm\s+-rf\s+\//.test(command)) {
        return { kind: 'deny' as const, reason: 'Recursive root deletion is blocked' }
      }
    }
    return next()
  })
}

两个监听器串成链:

tools/pre-execute waterfall
  |-- 监听器 1:文件路径检查
  |     匹配 /etc/ 或 /sys/ -> deny
  |     其他 -> next()
  |-- 监听器 2:bash 命令检查
  |     匹配危险模式 -> deny
  |     其他 -> next()
  |-- default:allow

第一个返回 deny 的监听器短路整条链——后续监听器和 default 都不执行。如果都调了 next(),default 返回 allow。

11.8 实战:结果裁剪器

tools/post-execute 也是 waterfall 事件,可以替换工具返回的内容。适用于敏感信息脱敏、长结果截断等场景。

import type { Context } from '@deepseek-ai/cordis'
import '@deepseek-ai/dsh-tools'

export const name = 'result-sanitizer'
export const inject = ['tools']

export function apply(ctx: Context) {
  ctx.on('tools/post-execute', async (exec, result, next) => {
    // 对 bash 输出做 API key 脱敏
    if (exec.name === 'bash') {
      const sanitized = result.content.map(block => {
        if (block.type === 'text') {
          return {
            ...block,
            text: block.text
              .replace(/[A-Za-z0-9]{32,}/g, '[REDACTED]')
              .replace(/sk-[A-Za-z0-9]+/g, '[API_KEY_REDACTED]')
          }
        }
        return block
      })
      return { ...result, content: sanitized }
    }
    return next()
  })
}

post-execute 的决策类型(来自 docs/cookbook/adding-a-tool.md):

决策说明
accept接受原结果(调 next())
replace用新内容替换模型可见的 content
enrich在原结果后追加额外上下文
block阻断结果,模型收到错误

教程原文的说明:

tools/post-execute - accept/block/replace/add context - A content replacement leaves programmatic access to value intact; confidentiality policy blocks or replaces the value.

注意:replace 只改模型可见的 content,不改程序化访问的 canonical value。如果要做真正的敏感信息阻断,需要用 block。

11.9 实战:轮次续跑注入器

agent/turn-stopping 是 serial 事件。当 agent 即将停止当前轮次时,监听器可以返回非 null 值阻止停止,注入续跑消息。

import type { Context } from '@deepseek-ai/cordis'
import '@deepseek-ai/dsh-agent-loop'

export const name = 'auto-continue'
export const inject = ['agents']

export function apply(ctx: Context) {
  ctx.on('agent/turn-stopping', async (agent) => {
    // 检查是否有未完成的 todo
    const todos = agent.session?.getTodos?.() ?? []
    const pending = todos.filter(t => t.status === 'pending')
    
    if (pending.length > 3) {
      return {
        kind: 'inject' as const,
        content: `还有 ${pending.length} 个待办事项未完成,请继续。`,
        source: { kind: 'plugin' as const, plugin: 'auto-continue' }
      }
    }
    
    // 返回 null,不阻止停止
    return null
  })
}

serial 的 bailing 语义:第一个返回非 null 的监听器胜出,后续监听器不执行。如果所有监听器都返回 null,agent 正常停止。

dsh 中 agent/turn-stopping 的监听方(来自事件矩阵):

监听方作用
hooks-claude-code桥接 Claude Code 的停止钩子
hooks-codex桥接 Codex 的停止钩子

你可以加自己的监听器,和 hooks 串成有序决策链。

金句:emit 是大喇叭,waterfall 是流水线,serial 是投票表,parallel 是并发扇出,bail 是同步投票。五把钥匙,五种协作方式——选对了,代码自然清晰。

11.10 dsh 核心事件速查

从事件矩阵(docs/event-producer-consumer.zh.md)摘录最常用的事件:

事件模式用途
tools/pre-executewaterfall权限拦截、审计前置
tools/executewaterfall超时、重试、metrics 包装
tools/post-executewaterfall结果裁剪、脱敏、追加上下文
tools/resultemit观察最终结果(不可改)
agent/pre-stepwaterfall步骤前注入上下文
agent/requestwaterfall改写模型请求配置
agent/turn-stoppingserial阻止停止、注入续跑
llm/streamwaterfall包装模型流式输出
session/eventemit观察所有会话事件
system-prompt/assemblewaterfall改写系统提示

每个事件的模式决定了你能怎么用它:

你想做什么用什么事件什么模式
禁止某个工具调用tools/pre-executewaterfall(return deny)
截断工具输出tools/post-executewaterfall(return replace)
记录工具调用日志tools/resultemit
注入系统提示内容system-prompt/assemblewaterfall
阻止 agent 停止agent/turn-stoppingserial(return 非 null)
观察会话事件session/eventemit
包装模型流llm/streamwaterfall

本章小结

模式调用返回值短路方式典型用途
emitctx.emit广播通知
waterfallctx.waterfall + next不调 next()拦截流水线
serialawait ctx.serial返回非 null/false/undefined异步决策
bailctx.bail返回非 null/false/undefined同步决策
parallelawait ctx.parallel并发 fan-out
实战拦截器事件模式效果
权限拦截器tools/pre-executewaterfalldeny 危险操作
结果脱敏器tools/post-executewaterfall替换敏感内容
轮次续跑器agent/turn-stoppingserial注入续跑消息

我是怕浪猫,第 11 章写完。五种模式、三个实战拦截器,覆盖了 dsh 事件系统 80% 的使用场景。

有问题评论区聊,有纠错欢迎指出。如果这篇对你有帮助,收藏起来——写拦截器时这张速查表最好用。

下一章是系列的收官:用 Seam 三段式从零设计一个可替换的能力。

系列进度:11/12 | 未完待续