生命不息,折腾不止。一个框架光能「加能力」不算本事,能让你「改行为」才是真本事——这一课写个钩子插件,在 Agent 每次动手前拦它一道,把「一切皆插件」的第二层威力摸透。

上一篇《DeepSeek Harness 写第一个插件:让 Agent 调用你的工具》教的是工具插件:往 ctx.tools 上注册一个 defineTool,让模型多一个能调的函数。那是给 Agent「发装备」。

今天写的是另一种插件——钩子插件。它不新增能力,而是蹲在事件链路上,在 Agent 动手前后插一脚:拦下危险调用、改写工具结果、把每一步记进审计日志。这才是 Harness 那句「一切皆插件」真正吓人的地方——模型、工具、甚至 Agent 的主循环本身,都是能被你替换或拦截的插件。

一、工具插件是「给能力」,钩子插件是「改行为」

先厘清两者的区别,免得混:

工具插件 钩子插件
干什么 往 ctx.tools 注册新工具 给事件挂监听器
效果 模型多一个可调的 defineTool 在已有流程里「插一脚」
典型场景 读文件、查库、发消息 权限门、改写结果、审计埋点
注册方式 ctx.tools.register(...) ctx.on('事件名', ...)

一句话:工具插件面向模型的「手」,钩子插件面向框架的「心跳」。前者让 Agent 能干更多事,后者让你能管住它干的事。

dsh 的底层是 vendored 的 Cordis 插件运行时。它把整个产品的运行拆成一堆事件,插件之间不是靠 import 互相调用,而是靠挂在 ctx 上的服务 + 事件流转来协作。理解了下一条要讲的事件语义,你就拿到了钩子插件的钥匙。

二、先搞懂 Cordis 的事件:waterfall 是「接力棒」,不是「大喇叭」

Cordis 提供好几种事件分发模式,最常见也最容易踩坑的是 waterfall(瀑布/接力):

  • emit(广播):喊一嗓子,谁爱听谁听,没人管结果。
  • waterfall(接力):每个监听器收到 (参数, next),处理完必须调 next() 把「接力棒」传给下一个;不调 next() 直接 return,就相当于短路——后面所有监听器、乃至真正的执行都不跑了。

这个 next() 就是新手最常栽的地方:写了监听器忘记调 next(),结果 Agent 的工具调用被整个「吞掉」,界面看起来像卡死。记住一句话:waterfall 监听器,要么调 next() 放行,要么 return 一个结果短路,二选一,不能两个都当没看见。

dsh 的主循环就是靠这一串事件驱动的:一次工具调用会依次经过 tools/pre-execute(执行前,可拦)→ tools/execute(真实执行)→ tools/post-execute(执行后,可改写)→ tools/result(最终不可变结果,只读观察)。钩子插件就是挑其中一个节点蹲点。

三、实战:写一个 tools/pre-execute 权限门

需求:默认行为是「工具随便调」。我们想要一个规则引擎,在每次工具执行前检查一遍,不符合白名单就拒绝。

直接上可跑通的代码(TypeScript,permission-gate.ts):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import type { Context } from '@deepseek-ai/cordis'
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'

// 你的规则:比如只允许读操作,禁止一切写/删/联网
declare function isAllowed(exec: ToolExecution): Promise<boolean>

export const name = 'permission-gate'

export function apply(ctx: Context) {
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (!(await isAllowed(exec))) {
return { kind: 'deny', reason: '被权限门拦下:该操作不在白名单。' }
}
return next()
})
}

拆开讲:

  1. ctx.on('tools/pre-execute', ...):往「工具执行前」这个事件挂监听器。事件名是 dsh 定的稳定扩展点。
  2. 回调签名 (exec, next):exec 是这次工具调用的上下文,next 是接力棒函数。
  3. 放行:return next() —— 把控制权交给下一个监听器,最终流到真实执行。
  4. 拦截:return { kind: 'deny', reason: '...' } —— 不调 next(),直接短路,并返回一个决策对象告诉框架「拒绝 + 原因」。

为什么用 kind: 'deny' 而不是「能拦就拦」?因为 pre-execute 支持三种决策:

  • { kind: 'deny', reason }:直接拒绝,模型会看到拒绝原因。
  • { kind: 'ask', reason }:不拒绝,但停下来问人(配审批 UI 用)。
  • 不返回决策、坚持 next():放行。

想「最终否决、谁都不能撤销」的场景(比如安全红线),别用 pre-execute,用它兄弟 ctx.tools.guard()——这一层比监听器更硬。

扩展点速查,照着选就不迷路:

你想干的事 用哪个扩展点
允许 / 拒绝 / 询问某次工具调用 tools/pre-execute
最终否决、不可被后续覆盖 ctx.tools.guard()
包裹执行生命周期(超时 / 重试 / 打点) tools/execute
改写工具结果或呈现内容 tools/post-execute
只读观察最终结果(审计 / 捕获) tools/result

四、进阶:改写结果 + 审计埋点

权限门只是钩子插件的开胃菜。同样一套 ctx.on 的写法,你能干更多。

改写工具结果——挂在 tools/post-execute 上,把工具返回的内容加工一下再喂给模型(比如敏感信息脱敏、统一补一段风险提示)。核心思路和上面一样:拿到结果,改完,next() 或 return 一个替换后的结果。

审计日志——挂在 tools/result 上,只读观察每一次最终结果,把「哪个工具、什么参数、返回了什么」写进日志,全程不打断执行。示意如下(回调参数结构以官方 docs/architecture.md 为准):

1
2
3
4
5
6
7
8
9
10
import type { Context } from '@deepseek-ai/cordis'

export const name = 'tool-audit-log'

export function apply(ctx: Context) {
ctx.on('tools/result', (result) => {
// 只观察,不改动,永远别在这里 next() 短路
ctx.logger.info('[审计] 一次工具调用完成:', JSON.stringify(result))
})
}

关键纪律:tools/result 是「最终不可变结果」,只许看、不许改也不许短路,否则会破坏整条链路的记账。

五、怎么挂上去跑起来

钩子插件不用打包成正式 npm 包也能先跑通,用 --patch 覆盖层最省事(8 月的安装教程已装好 dsh,这里直接复用):

1
2
3
4
5
6
# my-hook.yml —— 覆盖层:把我们的两个插件塞进插件树
- insert:
- id: permission-gate
name: '/你的绝对路径/permission-gate.ts'
- id: tool-audit-log
name: '/你的绝对路径/tool-audit-log.ts'
1
2
# headless 一次性任务,跑起来就会命中权限门
dsh --profile headless --patch ./my-hook.yml "帮我执行一个会被拦下的操作"

正式要长期用、或发给别人用,就走插件安装:dsh plugin --profile <profile> add <本地路径或 GitHub 仓库>(这命令底层转发给 pnpm,没装 pnpm 会直接报错,先 npm i -g pnpm)。

六、四个必踩的坑(全是实测血泪)

  1. 忘记调 next():waterfall 里这是头号杀手。监听器光做事不 next(),整条链被短路,界面上像「卡死」或「工具没反应」。写完先数一遍:每个监听器是不是要么 next() 要么明确 return。

  2. 裸导入解析不到依赖:--patch 用绝对路径挂插件时,插件里的 import { defineTool } from '@deepseek-ai/dsh-tools' 是从插件自己目录向上找 node_modules,找不到就启动失败。官方仓库内开发是软链依赖解决的;直接用 dsh plugin add 正式安装则没这问题(pnpm 会解析 peer 依赖)。

  3. 函数插件必须命名导出:export function apply(ctx),别配默认导出,混用会让 Loader 丢掉 inject 元数据,加载期静默出错。可选服务用 ctx.get('xxx'),别写进 inject(写了等于声明「必须有」,缺了会卡加载)。

  4. 可逆注册:能 ctx.on 就别在不该常驻的地方乱 return。Cordis 的注册都能在插件卸载(HMR / 关停)时自动回滚,靠的是框架帮你记账;你自己别在外头留野生的定时器或句柄。

七、小结

把今天和上一课连起来看,Harness 的插件体系就立体了:

  • 工具插件(上篇)= 往 ctx.tools 发装备,让 Agent 能干更多活;
  • 钩子插件(本篇)= 挂 ctx.on 蹲事件,让你能管住 Agent 怎么干活。

「一切皆插件」真正的意思是:Agent 主循环的每一个心跳,都是可拦截、可替换、可观察的事件。你不需要 fork 框架、不用改源码,挂个监听器就能给一个开箱即用的 Agent 加上你的规矩。

生命不息,折腾不止。权限门只是蹲在「工具调用」这一个节点。下一篇我们钻进 Agent 的会话事件流(session.events)——用钩子插件把一整轮对话自动整理成一份带 SHA-256 凭据的「日报 / 交接文档」,让 Agent 自己给你交付可验证的工作成果,而不是聊完就散。这是「会话 → 交付物」的进阶玩法,别错过。