深色模式
Demo 7 · 用事件钩子拦截请求与工具
目标:一个插件同时挂上五个扩展点,从"观察"升级到"拦截":包裹模型调用、替换请求配置、审批工具执行、观察持久事件、把守 turn 关闭。对应原理篇 05.4。
运行
sh
cd demos/07-hooks
DSH_HOME="$PWD/.dsh-home" npx dsh --profile headless --patch hooks.patch.yml "请 echo 一句话验证工具链路"完整输出(这是本教程最有信息量的一段日志):
text
[hooks] 五个钩子已就绪
[echo-tool] 已注册 echo 工具
[hooks] ④ session 事件:turn/start(seq=4)
[hooks] ④ session 事件:step/start(seq=6)
[hooks] ② agent/request:turn 1 step 1,配置 = mock/mock-1(maxTokens=undefined)
[hooks] ① llm/stream 第 1 次调用开始:mock/mock-1,3 条消息,26 个工具 schema
[hooks] ① llm/stream 第 2 次调用开始:mock/mock-1,1 条消息,0 个工具 schema ← 标题生成!
[hooks] ① 第 1 次调用结束(13 个 chunk)
[hooks] ④ session 事件:tool/call(seq=28)
[hooks] ③ tools/pre-execute:echo({"text":"来自 MockAdapter 的问候"})
[hooks] ① 第 2 次调用结束(61 个 chunk)
[echo-tool] 收到调用:args={"text":"来自 MockAdapter 的问候"} callId=call-echo-1
[hooks] ④ session 事件:tool/result(seq=30)
[hooks] ④ session 事件:step/end(seq=31)
[hooks] ④ session 事件:step/start(seq=32)
[hooks] ② agent/request:turn 1 step 2,配置 = mock/mock-1(maxTokens=undefined)
[hooks] ① llm/stream 第 3 次调用开始:mock/mock-1,5 条消息,26 个工具 schema
[hooks] ① 第 3 次调用结束(17 个 chunk)
[hooks] ④ session 事件:step/end(seq=51)
[hooks] ⑤ agent/turn-stopping:turn 1 即将关闭(serial 检查点)
[hooks] ④ session 事件:turn/end(seq=52)
echo 工具返回了:「来自 MockAdapter 的问候」。任务完成。输出解读:事件流 = 生命周期的横切面
把输出按时间轴排开,你看到的是原理篇 5.2 时序图的实时投影:
| 顺序 | 事件 | 说明 |
|---|---|---|
| 1 | session/event: turn/start, step/start | 持久边界先落盘 |
| 2 | agent/request(waterfall) | 每次模型请求前询问配置 |
| 3 | llm/stream 第 1 次 | 真正的任务请求:3 条消息、26 个工具 schema |
| 4 | llm/stream 第 2 次 | 标题生成的辅助调用:1 条消息、0 个工具——llm/stream 包裹了所有模型调用 |
| 5 | tool/call + tools/pre-execute | 工具执行前过审批闸门 |
| 6 | tool/result → step/end → step/start | 工具循环闭合,进入 step 2 |
| 7 | agent/request + llm/stream 第 3 次 | step 2 的请求:5 条消息(多了 tool-call/tool-result) |
| 8 | agent/turn-stopping(serial)→ turn/end | 最终检查点后 turn 闭环 |
五个钩子的代码
plugins/hooks.ts 里每个钩子只有几行:
ts
// ① llm/stream:包裹每次模型调用(generator 监听器)
ctx.on('llm/stream', async function* (options, next) {
for await (const chunk of next()) yield chunk // 必须转发,否则模型"失声"
})
// ② agent/request:拿到默认配置,返回替换值即生效
ctx.on('agent/request', async (payload, next) => {
const config = await next() // 先拿默认
return config // 原样返回;换个对象=替换
})
// ③ tools/pre-execute:审批闸门
ctx.on('tools/pre-execute', async (exec, next) => {
if (process.env.DSH_DEMO_DENY_ECHO === '1' && exec.name === 'echo') {
return { kind: 'deny', reason: '演示:策略拒绝了 echo' } // 不调 next() = 否决
}
return next() // 只读观察必须委托
})
// ④ session/event:持久事件流(emit 广播)
ctx.on('session/event', (_session, event) => { /* 过滤感兴趣的 type */ })
// ⑤ agent/turn-stopping:serial 检查点
ctx.on('agent/turn-stopping', (payload) => { /* 记录 turn 即将关闭 */ })拒绝路径:让策略真正生效
sh
DSH_DEMO_DENY_ECHO=1 DSH_HOME="$PWD/.dsh-home" \
npx dsh --profile headless --patch hooks.patch.yml "请 echo 一句话验证工具链路"关键差异:
text
[hooks] ③ tools/pre-execute:echo({"text":"来自 MockAdapter 的问候"})
[hooks] ③ 策略生效:拒绝 echo 调用(deny)
[hooks] ④ session 事件:tool/result(seq=29) ← echo 的 execute 从未执行!
…
echo 工具返回了:「Error: 演示:策略拒绝了 echo」。任务完成。pre-execute 返回 deny 后:工具的 execute 根本没被调用,管线直接产出一条 isError 的 tool/result 返回给模型。这就是 dsh 审批系统的底层机制——Web UI 上"是否允许执行"的对话框,另一端就是这个 waterfall 决策。
亲手做实验
实验 1:替换请求配置
改 agent/request 钩子:return { ...config, maxTokens: 10 }。观察 llm/stream 收到的 options 变化(可在 ① 里打印 options.maxTokens)。
实验 2:拦截 llm/stream 短路
在 ① 里对"标题生成调用"(0 个工具)不调 next() 直接 yield 固定文本。你刚刚"劫持"了标题生成——任何模型调用都可以被任何插件替换。
实验 3:post-execute 改写结果
加一个 tools/post-execute 钩子,把 echo 的结果改为 { kind: 'accept', value: '被改写了' }。对照原理篇 6.3的表格,体会 accept/block 的语义。
常见错误
| 现象 | 原因 |
|---|---|
| 模型"失声"(没有回复) | ① 的 generator 监听器忘了 yield next() 的 chunk |
| 所有工具都被拒 | ③ 忘了调 next() 就返回——只读观察必须委托 |
| 钩子没触发 | inject 缺了对应服务(llm/tools/sessions/agents),fiber 停在 PENDING |
下一步
Demo 8 把前三个 Demo 的零散实验沉淀成一个可复用的自定义 Profile。