DeepSeek Harness 进阶:写钩子插件,在 Agent 动手前拦截它
生命不息,折腾不止。一个框架光能「加能力」不算本事,能让你「改行为」才是真本事——这一课写个钩子插件,在 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 | import type { Context } from '@deepseek-ai/cordis' |
拆开讲:
ctx.on('tools/pre-execute', ...):往「工具执行前」这个事件挂监听器。事件名是 dsh 定的稳定扩展点。- 回调签名
(exec, next):exec是这次工具调用的上下文,next是接力棒函数。 - 放行:
return next()—— 把控制权交给下一个监听器,最终流到真实执行。 - 拦截:
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 | import type { Context } from '@deepseek-ai/cordis' |
关键纪律:tools/result 是「最终不可变结果」,只许看、不许改也不许短路,否则会破坏整条链路的记账。
五、怎么挂上去跑起来
钩子插件不用打包成正式 npm 包也能先跑通,用 --patch 覆盖层最省事(8 月的安装教程已装好 dsh,这里直接复用):
1 | # my-hook.yml —— 覆盖层:把我们的两个插件塞进插件树 |
1 | # headless 一次性任务,跑起来就会命中权限门 |
正式要长期用、或发给别人用,就走插件安装:dsh plugin --profile <profile> add <本地路径或 GitHub 仓库>(这命令底层转发给 pnpm,没装 pnpm 会直接报错,先 npm i -g pnpm)。
六、四个必踩的坑(全是实测血泪)
忘记调
next():waterfall 里这是头号杀手。监听器光做事不next(),整条链被短路,界面上像「卡死」或「工具没反应」。写完先数一遍:每个监听器是不是要么next()要么明确return。裸导入解析不到依赖:
--patch用绝对路径挂插件时,插件里的import { defineTool } from '@deepseek-ai/dsh-tools'是从插件自己目录向上找node_modules,找不到就启动失败。官方仓库内开发是软链依赖解决的;直接用dsh plugin add正式安装则没这问题(pnpm 会解析 peer 依赖)。函数插件必须命名导出:
export function apply(ctx),别配默认导出,混用会让 Loader 丢掉inject元数据,加载期静默出错。可选服务用ctx.get('xxx'),别写进inject(写了等于声明「必须有」,缺了会卡加载)。可逆注册:能
ctx.on就别在不该常驻的地方乱return。Cordis 的注册都能在插件卸载(HMR / 关停)时自动回滚,靠的是框架帮你记账;你自己别在外头留野生的定时器或句柄。
七、小结
把今天和上一课连起来看,Harness 的插件体系就立体了:
- 工具插件(上篇)= 往
ctx.tools发装备,让 Agent 能干更多活; - 钩子插件(本篇)= 挂
ctx.on蹲事件,让你能管住 Agent 怎么干活。
「一切皆插件」真正的意思是:Agent 主循环的每一个心跳,都是可拦截、可替换、可观察的事件。你不需要 fork 框架、不用改源码,挂个监听器就能给一个开箱即用的 Agent 加上你的规矩。
生命不息,折腾不止。权限门只是蹲在「工具调用」这一个节点。下一篇我们钻进 Agent 的会话事件流(
session.events)——用钩子插件把一整轮对话自动整理成一份带 SHA-256 凭据的「日报 / 交接文档」,让 Agent 自己给你交付可验证的工作成果,而不是聊完就散。这是「会话 → 交付物」的进阶玩法,别错过。