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

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

系列:DeepSeek Harness 源码实战

原文仓库:github.com/deepseek-ai...

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

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

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

11.1 五种分发模式对比

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

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

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

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

ts 复制代码
// 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]>

怎么选?一句话决策树:

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

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

11.2 emit:同步广播

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

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

ts 复制代码
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)
  }
}

监听端:

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

emit 的适用场景:

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

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

事件 派发方 监听方
agent/created agent agent-presets, goal-round-driver, schedule
session/created session compaction, goal, permission-presets, tools, user-approval
tools/result tools agent-instructions, subagent-in-process-driver
session/event session 20+ 个包监听

注意 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):

ts 复制代码
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'))
  })()
}

运行结果:

bash 复制代码
HELLO
** BLOCKED **

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

bash 复制代码
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'):

bash 复制代码
监听器 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):

ts 复制代码
// 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 的区别:

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

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

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

11.5 parallel:并行 fan-out

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

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

ts 复制代码
parallel<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): Promise<void>

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

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

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

11.6 类型化事件声明

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

ts 复制代码
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 决定是否放行。

ts 复制代码
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()
  })
}

两个监听器串成链:

bash 复制代码
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 事件,可以替换工具返回的内容。适用于敏感信息脱敏、长结果截断等场景。

ts 复制代码
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 值阻止停止,注入续跑消息。

ts 复制代码
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-execute waterfall 权限拦截、审计前置
tools/execute waterfall 超时、重试、metrics 包装
tools/post-execute waterfall 结果裁剪、脱敏、追加上下文
tools/result emit 观察最终结果(不可改)
agent/pre-step waterfall 步骤前注入上下文
agent/request waterfall 改写模型请求配置
agent/turn-stopping serial 阻止停止、注入续跑
llm/stream waterfall 包装模型流式输出
session/event emit 观察所有会话事件
system-prompt/assemble waterfall 改写系统提示

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

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

本章小结

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

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

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

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

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

相关推荐
澄怀36 分钟前
Agent 说「我已经改好文件了」,这句话到底能不能信?
llm·agent
luckystar513~1 小时前
Hermes 工程化实战专栏:安装部署实战——从装好到跑通第一个任务
人工智能·agent·智能体·hermes·实战专栏·hermes安装配置
新知图书2 小时前
14.5 AI试驾预约系统的完整实现
人工智能·agent·ai agent·智能体
水管在开花.4 小时前
Agent范式与LangGraph-②零基础保姆级教程
人工智能·面试·langchain·agent
ShallWeL4 小时前
RAG 文档变更后的检索回归清单
人工智能·agent·知识库·rag·检索
Patrick在香港5 小时前
Claude Agent 进阶编排:循环控制 + 写权限审批闸门 + 幂等重试
python·agent·claude·编排·anthropic api
李燚5 小时前
数据库迁移不翻车:golang-migrate 实战,143 个 DDL 有序执行(第97篇-E83)
golang·agent·multiagent·migrate·eino·subagent·deepflux
彼日花5 小时前
我做了一个开源项目,让 AI 记住我们解决过的问题:Usora
人工智能·agent·ai编程
wangfpp5 小时前
生产级 RAG 知识库全流程实践
人工智能·agent·全栈