在上一章中,我们学习了服务——它让一个插件可以向其他插件提供能力。通过 inject 声明依赖,插件 A 可以调用插件 B 提供的服务方法。这是一种直接的、点对点的通信方式。

但有时候,我们需要的不是“点对点”,而是“一对多”。

想象这样一个场景:你的插件要执行一个耗时操作,你希望在这个操作开始、进行中和结束时,其他插件能够感知到这些时刻并做出响应——比如记录日志、更新 UI、触发后续任务。你不可能提前知道有哪些插件会关心这些事件,也不应该让它们都来依赖你的服务。

这时候,事件就派上用场了。

事件是 Cordis 插件间通信的核心机制。与服务不同,事件不要求发送方知道接收方是谁——发送方只管“发出信号”,任何感兴趣的插件都可以“收听”。这是一种松耦合的通信方式,让插件之间可以“对话”,却不需要互相认识。

Harness 大量使用事件来实现可扩展的钩子点——比如工具执行结果、Agent 的每一步、会话状态的变更,都以事件的形式暴露出来,供其他插件监听和干预。

事件是什么?——插件的“广播站”

在 Cordis 中,事件本质上是一个命名的信号,附带一些数据(payload)。

  • 发送方调用 ctx.emit('事件名', 数据) 来发出信号。

  • 接收方调用 ctx.on('事件名', (数据) => { ... }) 来监听信号。

// 发送方:发出一个事件
ctx.emit('my-plugin/ready', { id: 'worker-1' })

// 接收方:监听这个事件
ctx.on('my-plugin/ready', ({ id }) => {
    console.log(`${id} is ready`)
})

发送方不关心谁会收到这个消息,接收方也不关心是谁发出的。它们只通过事件名来匹配——这就是“松耦合”的含义。

给初中级开发者的比喻:可以把事件想象成“广播电台”。发送方是电台,它只管广播信号(事件名 + 数据),不关心谁在听。接收方是收音机,它调到一个频道(事件名),就能收到广播内容。电台和收音机之间没有直接连接,全靠“频道”这个约定来匹配。

四种事件模式:不同的“对话方式”

Cordis 提供了四种事件分发模式,分别适用于不同的协作场景。

模式

是否异步

执行顺序

是否有返回值

适用场景

emit

否

按注册顺序

否

通知、日志

bail

否

按注册顺序

是(短路)

拦截、权限检查

serial

是

按注册顺序

是(短路)

异步顺序处理

waterfall

是

按注册顺序

是(流水线)

数据转换、中间件链

emit——广播通知

emit 是最简单的事件模式。发送方发出事件后,所有监听器同步执行,返回的值被忽略。

// 发送
ctx.emit('my-plugin/ready', { id: 'worker-1' })

// 监听——所有监听器都会执行,返回值不重要
ctx.on('my-plugin/ready', ({ id }) => {
    console.log(`Worker ${id} is ready`)
})

什么时候用? 当你想“通知”其他插件某件事发生了,但不关心它们是否处理、如何处理。比如:

  • 记录日志:ctx.emit('tools/result', { tool, result })。

  • 更新状态:ctx.emit('session/update', { sessionId, data })。

  • 触发 UI 刷新。

emit 的哲学是:“我告诉你一声,你爱咋办咋办。”

bail——短路拦截

bail 模式中,监听器按注册顺序执行,第一个返回非 null、非 false、非 undefined 的值会成为最终结果,后续监听器不再执行。

// 发送:检查某个操作是否被允许
const result = ctx.bail('some-check', input)

// 监听:返回非空值表示“拦截”
ctx.on('some-check', (input) => {
    if (shouldBlock(input)) {
        return 'blocked'  // 这个值会作为最终结果返回,后续监听器不再执行
    }
    // 返回 null / false / undefined 表示“放行”,继续检查下一个监听器
    return null
})

什么时候用? 当你想实现“谁说了算”的决策逻辑——多个插件都可以对某个请求做出判断,但只要有任何一个说“不”,整个操作就应该终止。比如:

  • 权限检查:任何插件都可以拒绝一个操作。

  • 内容审核:任何插件都可以标记某个内容为违规。

  • 特性开关:任何插件都可以决定某个功能是否启用。

bail 的哲学是:“你们一个一个来,谁先拍板就听谁的。”

serial——异步顺序执行

serial 和 bail 类似,但监听器是异步的(可以 await),并且按注册顺序逐个执行。

// 发送:按顺序执行一系列异步设置
await ctx.serial('setup-phase', context)

// 监听:可以执行异步操作
ctx.on('setup-phase', async (context) => {
    await initializeDatabase(context)
    // 返回非空值会停止后续执行
})

什么时候用? 当多个插件需要按顺序完成一系列异步任务,且任何一个任务失败就应该停止后续任务时。比如:

  • 插件初始化流程。

  • 请求预处理链。

  • 数据迁移步骤。

serial 的哲学是:“你们排好队,一个一个来,谁出问题就喊停。”

waterfall——流水线处理

waterfall 是最强大的事件模式。每个监听器可以包装下游的结果,形成一条处理链。

监听器接收一个 next 函数,调用 next() 将处理权交给下游,并拿到下游处理后的结果。

// 发送:输入是原始文本,经过流水线处理后得到最终输出
const output = await ctx.waterfall(
    'my-plugin/transform',
    input,
    async () => input  // 初始值:如果没有监听器,返回原始 input
)

// 监听:每个监听器都可以在调用 next() 前后对数据进行处理
ctx.on('my-plugin/transform', async (_input, next) => {
    const downstream = await next()  // 等待下游处理完
    return downstream.trim()         // 对下游结果做进一步处理
})

关键规则:waterfall 的监听器必须调用 next()。如果不调用,流水线就会在此处中断——下游监听器不会被执行。这种设计使得 waterfall 既可以用于“拦截”(不调用 next() 直接返回),也可以用于“转换”(调用 next() 后包装结果)。

什么时候用? 当多个插件需要依次处理同一份数据,每个插件都可以对前一个插件的结果进行加工时。比如:

  • 提示词(Prompt)处理链:多个插件依次追加或修改系统提示词。

  • 工具结果格式化:多个插件依次对工具返回结果做后处理。

  • 中间件模式:请求/响应的层层包装。

waterfall 的哲学是:“你们组成一条流水线,每个人加工上一道工序传来的半成品,最后产出成品。”

四种模式对比速查

为了帮你快速选择,这里做一个直观的对比:

你的需求

选择

通知大家某件事发生了,不关心返回值

emit

多个插件都可以否决某个操作,谁先否决听谁的

bail

多个插件按顺序执行异步任务,出问题就停

serial

多个插件依次加工同一份数据,每个都可以修改

waterfall

给初中级开发者的提示:在实际开发中,emit 是最常用的——大部分场景只是“通知”而已。waterfall 是最强大的,但也最需要谨慎设计——确保每个监听器都清楚地知道自己在链中的位置和作用。

类型安全事件:让 TypeScript 帮你检查

上面的 emit 例子中,事件名是字符串('my-plugin/ready'),payload 的类型也没有约束。这很容易出错——比如拼错了事件名,或者传错了数据类型。

Cordis 支持类型化事件,通过 TypeScript 的声明合约为事件提供类型安全:

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.emit('my-plugin/ready', { id: 'worker-1' }) // 类型匹配
ctx.on('my-plugin/ready', ({ id }) => { ... })  // id 是 string

声明合并的规则:

  • 事件名以 namespace/action 格式命名,比如 tools/result、agent/step。

  • 监听器函数的参数类型由 Events 接口中的签名决定。

  • emit、bail、serial、waterfall 的调用都会根据签名进行类型检查。

给初中级开发者的建议 :在开发自己的插件时,强烈建议为自定义事件添加类型声明。这不仅能防止拼写错误,还能让其他开发者(以及未来的你)清楚地知道这个事件携带什么数据、期望什么返回值。类型声明是插件 API 的“文档”。

事件监听器是 Effect——自动清理

还记得我们在生命周期章节中学过的“自动清理”吗?事件监听器也不例外。

通过 ctx.on() 注册的监听器,在插件卸载时会自动移除:

export function apply(ctx: Context) {
    // 这个监听器会在插件卸载时自动移除
    ctx.on('tools/result', (exec, result) => {
        console.log(`[tool] ${exec.tool} returned ${result}`)
    })
}

你不需要手动调用 ctx.off() 来清理监听器。框架会替你做——而且是在正确的时机、以正确的顺序。

给初中级开发者的提示:这是 Cordis“可逆副作用”设计理念的体现。所有通过 ctx 进行的注册——事件监听、工具注册、服务提供——都是“可逆的”。插件卸载时,这些注册自动撤销,不会留下任何残留。这让你可以放心地在插件中注册事件监听器,而不用担心内存泄漏或“幽灵监听器”。

完整示例:日志插件

让我们把本章的知识点串起来,写一个完整的日志插件。它监听 tools/result 事件,在每次工具调用完成时记录日志。

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

export const name = 'tool-logger'

export function apply(ctx: Context) {
  ctx.on('tools/result', (exec, result) => {
    console.log(`[tool] ${exec.name}(${JSON.stringify(exec.arguments)})`)
    const text = result.content
      .map(block => block.type === 'text' ? block.text : '')
      .join('')
    console.log(`[tool result] ${text.slice(0, 100)}`)
  })
}

这个插件做了什么?

  1. 没有注册任何工具——它只是“听”。

  2. 没有依赖任何服务——事件监听不需要 inject。

  3. 在插件卸载时自动清理——监听器会自动移除。

这就是事件驱动的魅力:一个纯“观察者”插件,只需要几行代码,就能为系统增加有价值的可观测性,而完全不需要修改任何现有代码。

在本文的结尾,我们将把日志插件与 2.2 节的服务示例相结合,展示如何将其部署到实际环境中运行,使整个系统既能通过服务(Metrics)提供指标记录,又能借助事件监听(Logger)实现调用追踪。

通过这个实践,你将亲眼看到:

  • 服务(Service)如何提供能力。

  • 事件(Event)如何实现松耦合的观察。

  • 两者如何在一个插件体系中协同工作。

Harness 中的内置事件

在实际开发中,你更多时候是在监听 Harness 提供的内置事件,而不是创建自己的事件。

Harness 使用 namespace/action 命名规范,内置事件包括:

事件名

 触发时机

agent/step

 Agent 执行每一步时

agent/request

Agent 发起 LLM 请求时

agent/request-error

LLM 请求出错时

tools/result

工具执行完成时

session/event

会话中发生任何事件时

重要区分:turn/*、step/*、tool/call、tool/result、compaction/* 是持久化会话事件类型,而不是同名的 Cordis 事件。要观察它们,需要监听 session/event 并通过 event.type 来判断具体类型,而不是直接监听 turn/step 这个事件名。

完整的签名和模式记录在子系统页面的 cordis-surface 区域中。

这些内置事件构成了 Harness 的扩展点体系——你可以在不修改核心代码的情况下,通过监听这些事件来注入自定义逻辑:记录日志、修改请求、拦截结果……一切皆可插拔。

事件与服务:如何选择?

现在你有了两种插件间通信的方式——服务和事件。什么时候用哪个?

维度

服务

事件

关系

点对点:一方提供,一方消费

一对多:一方发送,多方监听

耦合度

较高:消费方需要知道提供方的服务名

较低:发送方和接收方只通过事件名匹配

返回值

有:消费方调用方法获取返回值

视模式而定:emit 无返回值,bail/serial/waterfall 有

典型场景

能力共享(工具注册、LLM 调用)

通知、拦截、数据转换、扩展点

经验法则:

  • 如果你需要调用一个具体的能力(比如注册工具、发起 LLM 请求),用服务。

  • 如果你需要通知其他插件某件事发生了,或者让其他插件有机会干预某个流程,用事件。

两者不是互斥的——一个复杂的插件往往同时使用服务和事件:通过服务提供能力,通过事件暴露扩展点。

总结

本章我们学习了 Cordis 事件系统的完整机制。核心收获如下:

概念

说明

事件

插件间松耦合通信的命名信号

ctx.emit()

发送事件,所有监听器同步执行,返回值被忽略

ctx.on()

注册事件监听器

emit 模式

广播通知,适合日志、状态更新

bail 模式

短路拦截,第一个非空返回值终止后续监听器

serial 模式

异步顺序执行,适合初始化流程

waterfall 模式

流水线处理,每个监听器可包装下游结果

类型安全事件

通过 declare module 为事件提供 TypeScript 类型

事件监听器是 Effect

通过 ctx.on() 注册的监听器在插件卸载时自动移除

事件让插件之间可以“对话”,却不需要互相认识。这种松耦合的设计让 Harness 的插件体系具有极强的可扩展性——你可以在不修改任何现有代码的情况下,通过监听事件来注入自己的逻辑,或通过发送事件来触发其他插件的行为。

下一章,我们将进入实战进阶篇,学习如何将基础篇和框架篇的知识综合运用,构建生产级的 Harness 插件——包括能力的三种角色设计模式和 LLM 适配器的实现。

实践:将日志插件集成到服务示例中

在 2.2 的服务示例基础上,添加一个事件监听插件,让工具调用“看得见”

准备工作

我们继续使用 scratch-plugin 作为工作目录。请确保你已经完成了 2.2 节中“完整示例:从定义到使用”的所有步骤,即:

  • 已有 metrics-service.ts(服务提供方)。

  • 已有 consumer-plugin.ts(服务消费方,注册了 greet_with_metrics 工具)。

  • 已有 cordis.yml,按顺序加载了 metrics 和 consumer。

如果还没有,请先回到 2.2 节完成那些步骤,再继续下面的操作。

第一步:创建日志插件

在 scratch-plugin/src/ 目录下新建一个文件,命名为 logger-plugin.ts,内容如下:

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

/**
 * 插件名称,Cordis 框架通过此名称识别和加载插件
 */
export const name = 'logger-plugin'

/**
 * 插件入口函数
 *
 * @param ctx - Cordis 框架上下文对象,提供事件监听等能力
 *
 * 该插件的核心职责:
 * 1. 监听 tools/result 事件 —— 工具执行完毕后,记录工具名、入参和结果摘要
 * 2. 监听 session/event 事件 —— 会话级别的工具调用与结果日志,覆盖更上层的事件流
 */
export function apply(ctx: Context) {
    console.log('[logger] plugin loaded, listening to session tool events')

    /**
     * 监听低层的工具执行结果事件
     *
     * 每当一个工具完成执行,框架会通过此回调派发结果。
     * 这里将工具的名称、序列化参数以及结果文本(截断至 100 字符)输出到控制台,
     * 便于调试时快速查看每一次工具调用的输入与产出。
     */
    ctx.on('tools/result', (exec, result) => {
        console.log(`[tool] ${exec.name}(${JSON.stringify(exec.arguments)})`)

        // 从结果的 content 块中提取所有 text 类型的文本内容
        const text = result.content
            .map(block => block.type === 'text' ? block.text : '')
            .join('')

        // 仅打印前 100 个字符,避免长文本刷屏
        console.log(`[tool result] ${text.slice(0, 100)}`)
    })

    /**
     * 监听会话级事件
     *
     * 与 tools/result 不同,session/event 携带更丰富的上下文信息,
     * 可以同时捕获工具调用的发起(tool/call)和返回(tool/result)两个阶段。
     */
    ctx.on('session/event', (_session, event) => {
        if (event.type === 'tool/call') {
            // 工具被调用时,记录工具名和传入参数
            console.log(`[logger] tool "${event.data.name}" called with args:`, event.data.arguments)
        }
        if (event.type === 'tool/result') {
            // 工具返回结果时,记录完整的返回消息内容
            console.log(`[logger] tool result:`, event.data.message.content)
        }
    })
}

这个插件非常纯粹——它只做一件事:监听事件并打印日志。它不需要依赖任何服务,也不需要注册任何工具。它是“无侵入”的,可以随时添加或移除,而不影响其他插件的逻辑。

给初中级开发者的提示:事件监听是观察者模式的一种实现。日志插件就像一个“偷听者”——它默默听着系统里发生的一切,但从不打扰其他人的工作。

这个插件非常纯粹——它只做一件事:监听事件并打印日志。它不需要依赖任何服务,也不需要注册任何工具。它是“无侵入”的,可以随时添加或移除,而不影响其他插件的逻辑。

第二步:更新 cordis.yml

现在,我们需要把日志插件也加到配置中。打开 scratch-plugin/cordis.yml,将其修改为:

- insert:
    - id: metrics
      name: 'file:///E:/ProjectsAI/deepseek-harness/scratch-plugin/src/metrics-service.ts'
    - id: consumer
      name: 'file:///E:/ProjectsAI/deepseek-harness/scratch-plugin/src/consumer-plugin.ts'
    - id: logger
      name: 'file:///E:/ProjectsAI/deepseek-harness/scratch-plugin/src/logger-plugin.ts'

注意:

  • 将 name 对应的路径替换为你本地的实际路径(file://需要保留)。

  • 加载顺序:metrics → consumer → logger。因为 consumer 依赖 metrics,必须先加载 metrics。logger 不依赖任何服务,放在最后即可。

  • 每个插件都设置了唯一的 id,方便后续管理和调试。

第三步:启动并验证

启动 Web UI:

cd scratch-plugin/
pnpm dsh web --patch ./scratch-plugin/cordis.yml

观察启动日志,你应该看到:

[logger] plugin loaded, listening to session tool events
[metrics] service initialized!
[metrics] consumer_loaded: 1
dsh web: http://127.0.0.1:3080

这证明三个插件都已成功加载。

现在打开浏览器 http://127.0.0.1:3080,在对话框中输入:

Use the greet_with_metrics tool to greet Bob.

模型会调用 greet_with_metrics 工具,返回 Hello, Bob!。

I greeted Bob using the greet_with_metrics tool. The tool responded with "Hello, Bob!" and recorded the interaction.

此时,终端日志中会输出两方面的信息:

来自 Metrics 服务的记录(在 consumer 中调用的):

[metrics] greet_tool_called: 1

来自 Logger 插件的事件监听

[logger] tool "greet_with_metrics" called with args: {"name": "Bob"}
[tool] greet_with_metrics({"name":"Bob"})
[tool result] Hello, Bob!
[logger] tool result: [
  {
    type: 'tool-result',
    toolCallId: 'call_00_TdxDus46RefM09FmV1hJ1931',
    content: [ [Object] ],
    isError: false
  }
]

你可能注意到 consumer_loaded: 1 在服务加载时就打印了,而 Logger 的日志是在工具被调用时才出现。两者的时序不同,但都正确地反映了系统的运行状态。

第四步:进一步测试——多次调用

你可以多次在对话框中要求模型调用 greet_with_metrics(例如 greet Alice、greet Charlie)。你会看到:

  • Metrics 服务的 greet_tool_called 计数每次加 1。

  • Logger 插件每次都会打印完整的调用参数和结果。

[logger] tool "greet_with_metrics" called with args: {"name": "Alice"}
[metrics] greet_tool_called: 2
[tool] greet_with_metrics({"name":"Alice"})
[tool result] Hello, Alice!
[logger] tool result: [
  {
    type: 'tool-result',
    toolCallId: 'call_00_ET_x3BANkTCsNn43JkYgVQY5307',
    content: [ [Object] ],
    isError: false
  }
]

这完美地展示了:

  • 服务提供了可累加的状态(计数)。

  • 事件提供了即时的行为日志(每次调用的细节)。

两者相辅相成,却互不干扰——Logger 插件完全不知道 Metrics 的存在,Metrics 也不依赖 Logger。它们通过 tools/result 这个事件松耦合地联系在一起。

如果移除 Logger 插件会怎样?

你可以尝试注释掉 cordis.yml 中的 logger 行,然后重启(或使用 HMR 重载)。你会发现:

  • Metrics 和 Consumer 依然正常工作。

  • 只是不再有 [logger] 的日志输出。

这说明日志插件是“可插拔”的——它的存在与否不会影响核心业务逻辑。这正是事件驱动设计的优势:观察者可以随时加入或离开,而不影响被观察者的行为。

至此, 我们完成了一个多插件协作的小型系统:

插件

角色

使用的机制

metrics-service

服务提供方

继承 Service,对外暴露 record / increment 方法

consumer-plugin

服务消费方 + 工具提供方

inject 依赖 metrics,注册工具,并在工具中调用服务

logger-plugin

事件监听方

通过 ctx.on() 监听 tools/result 事件,打印日志