- Agent 循环在几个固定时刻会停下来,喊一个名字,比如
agent/pre-step,问“有没有插件要插手”。这些时刻叫钩子。 - 插件在自己的启动函数里,把一个处理函数注册到这个名字上(和第 01 章注册工具是同一回事)。处理函数返回
{ kind: 'reject' }(不进入这一步)就是拦下;调用 next() 就是不管,交给后面照常处理;拿到 next() 的结果再改,就是改结果。 - 一条规矩:想改模型看到的内容,必须记进会话日志,不能偷偷改。
上一章说,插件还能在 Agent 干活的过程中插手。这一章看它是怎么做到的:Agent 循环的代码,一行都不用改。
agent/pre-step,问“有没有插件要插手”。这些时刻叫钩子。{ kind: 'reject' }(不进入这一步)就是拦下;调用 next() 就是不管,交给后面照常处理;拿到 next() 的结果再改,就是改结果。模型不知道现在几点。你想让 Agent 每走一步之前,都告诉模型当前时间。在 DSH 里最省事的做法是?
点一个选项。
回到开头的问题:不改 Agent 循环,怎么让它多做一件事?答案分两半。前一半是:Agent 循环自己会在几个固定时刻停下来,问一句“有没有插件要插手”。
Agent 循环干活时,会在下面这几个时刻停下来,问一句“有没有插件要插手”。这些时刻就叫钩子(hook)。标了黄色小字的,只在特定情况下才会经过。右边绿色的,是真的挂在上面的插件。
agent/pre-step报时已归档的会话不许再跑对话太长先压缩agent/request只能换模型、换参数,不能改消息(后面“一条规矩”讲为什么)agent/request-error网络出错就重试太长就压缩后再试tools/pre-execute按规则拒绝这次调用(比如实验性的自动审查)tools/execute跑太久就喊停tools/post-execute输出太长只留开头结尾agent/turn-stopping活没干完,不许停 比如让它查三个文件,它查了两个就想收工。这个钩子只是通知,没有 next():处理函数的参数里有 agent,就是当前在跑的这个 Agent,插件调用 agent.steer(消息),把一条消息排进下一步,循环看到下一步还有消息,就接着走“停下来问一句”在代码里是什么样?拿第 00 章那个循环来看,第 1 个钩子就是开头多出来的两行:
while (true) {
const 决定 = await 发出钩子('agent/pre-step', 这一步的消息, 默认处理)
// 决定只有两种:{ kind: 'reject' } 或 { kind: 'enter', messages }
if (决定.kind === 'reject') break // reject:有插件拦下,这一步不走
const 回复 = await ctx.llm.stream({ …, messages: [...会话, ...决定.messages] }) // enter:带上它的 messages 问模型
… // 往下和第 00 章一样
}
循环只喊一个名字 agent/pre-step,带上这一步的消息,再附一个默认处理:没有插件插手时,就由它交回 { kind: 'enter', messages },带上这一步的消息进入这一步。谁挂在这个名字上,它不知道,也不用知道。(真实代码里,“发出钩子”叫 dispatch.waterfall。)
超时、重试、压缩……这些听起来像 Agent 循环的本职工作,其实都是挂在钩子上的插件。不装它们,循环照样能跑。
答案的后一半:插件在自己的启动函数里,把一个处理函数注册到钩子的名字上。Agent 循环每次喊这个名字,就调用这个函数,听它的决定。
先看一个完整的插件:报时插件。它的样子和第 01 章的 bash 工具一模一样,依赖加启动函数:
export const name = 'time-context'
export const inject = ['agents', …] // 依赖
export function apply(ctx) { // 启动函数
ctx.on('agent/pre-step', async (这一步, next) => { // 注册:处理函数挂到这个名字上
const 决定 = await next()
if (决定.kind === 'reject') return 决定
if (离上次报时不到 10 分钟) return 决定 // 默认十分钟最多报一次
return { ...决定, messages: [...决定.messages, 现在几点] } // 还是 enter,只是多了一条消息
})
}
bash 把工具注册给工具服务,报时插件把处理函数注册给一个钩子,都是在启动函数里注册。所以规矩也一样:卸载报时插件,这个处理函数自动摘掉,循环照样跑,只是不再报时。
注意两样东西跑的时机不一样:
ctx.on 把处理函数存起来,这时它还没被调用agent/pre-step,处理函数被调用所以“现在几点”这种每一步都要重新算的东西,要写在处理函数里,不能写在 apply 里。写在 apply 里,就只在装上时算了一次。
再看处理函数本身:它拿到什么、能交回什么?这些不是插件自己说了算,而是调用它的那段代码定的。下面是 agent/pre-step 的契约。蓝色几行由 DSH 定,绿色一行由你定:
{ messages, turn, step, agent, signal },外加 next。{ kind: 'reject' },这一步不走;或 { kind: 'enter', messages },进入这一步,带上这些消息。没有第三种,比如“等 5 秒再走”。(想等,可以先 await 再交回这两种之一。)reject:这一步不走,不问模型。enter:把 messages 记进会话日志,再从日志拼出发给模型的请求。(例外:没有任何新消息、这一轮本来就能结束时,循环直接收尾,不再问模型。)还有一个容易看错的地方:报时插件加的那条“现在几点”,在模型看来和你的话一样,都是 user 角色。区分它们靠每条消息上标的来源:你的话来源是 user,报时那条来源是 time-context。想判断“是不是用户亲口说的”,要看来源,不能看角色。
按这份契约,处理函数能做的就三种。左边是另一个插件的处理函数,右边把三种做法放在一起对照:
ctx.on('agent/pre-step', (step, next) => {
if (会话已归档) return { kind: 'reject' }
return next()
})
会话已经归档(办完收起来了),就返回 { kind: 'reject' }:这一步不走,不会去问模型。没归档,就 return next():我不管,交给后面处理。
拦下:返回 { kind: 'reject' },通常也不再调用 next()。左边,会话已归档时。
不管:return next(),交给后面处理。左边,没归档时。
改结果:先 await next() 拿到结果,改完再交回。结果要是 reject,报时插件就原样交回:它只管加时间,不该把别人的拦截改成 enter。(技术上你能改,最终以你 return 的为准。)
代码都是简化过的真实代码(中文是为了好读)。这里有两个独立的决定,别混在一起:
把所有写法摆在一起(以 agent/pre-step 为例):
| 处理函数这样写 | 后面的插件和默认处理 | 这一步 | 什么时候用 |
|---|---|---|---|
return next() | 交给下一个;更后面的跑不跑,看它们自己调不调 next() | 后面交回什么,就是什么 | 和自己无关时。最常见 |
const d = await next(),改完 return | 同上 | 以你最后 return 的为准。报时这类插件只管加消息,所以 d 是 reject 就原样交回,尊重后面的决定 | 要在结果上加东西,比如报时插件 |
return { kind: 'reject' } | 不跑 | 不进 | 要拦下,比如归档拦截 |
return { kind: 'enter', messages },不调 next | 不跑 | 进,你给的消息顶替这一步原本的新消息 | 几乎不用:后面插件的意见全被跳过了 |
| 什么都不 return | 不跑 | 拿不到决定,这一轮出错结束 | 别这样写 |
写插件时,你不知道同一个钩子上还有没有别的插件,也不需要知道:next() 一定能调。后面没有插件时,它调的就是循环自己的默认处理,返回 { kind: 'enter', messages }。所以默认先调 next(),只有要拦下时才不调。
注意,reject 和 enter 只是 agent/pre-step 这一个钩子的约定。每个钩子能交回什么,由喊它的那段代码定,所以取值各不相同。同样写 ctx.on,挂在不同的钩子上,能做的事完全不同:
| 钩子 | 谁喊它 | 有没有 next | 处理函数交回什么 |
|---|---|---|---|
agent/pre-step 这一步要不要走 | Agent 循环 | 有 | { kind: 'reject' },或 { kind: 'enter', messages } |
agent/request 发给模型之前 | Agent 循环 | 有 | 这次请求的配置:只能换模型、换参数,不能改消息(后面“一条规矩”讲为什么) |
agent/request-error 模型请求失败了 | Agent 循环 | 有 | { kind: 'retry' } 重试,或 undefined 不重试 |
agent/turn-stopping 真的可以停了吗 | Agent 循环 | 没有,只是通知 | 不用交回,返回值没人看。想让它别停,调用 agent.steer(消息),把一条消息排进下一步 |
tools/pre-execute 工具执行之前 | 工具服务 | 有 | allow 放行、deny 拒绝(带 reason)、ask 先问用户、cancel 取消(第 05 章) |
tools/execute 工具执行的时候 | 工具服务 | 有 | 工具结果本身。超时插件就挂在这里 |
tools/post-execute 工具执行之后 | 工具服务 | 有 | accept 接受(可以换掉结果),或 block 拦下(带 feedback) |
tools/result 工具结果出来了 | 工具服务 | 没有,只是旁观 | 只能看,改不了结果;它出了错也不会影响工具结果,DSH 也不等它做完 |
取值不用背,记两条规律就够:有 next 的钩子,没意见时都写 return next();要插手时交回什么,查这个钩子的类型声明,也就是喊它的那段代码写下的契约。
所以判断一个钩子能做什么,不能看名字猜,要看三处:它的类型声明、发出它的那行代码、调用方拿返回值做了什么。
上面两个插件,其实挂在同一个钩子上(“这一步要不要走”)。它们是不同的人写的,互相不知道对方存在。那 Agent 循环停在这一刻时,先问谁?
答案藏在 next() 里:next 指的“后面”,就是同一个钩子上的下一个插件;最后一个插件之后,是 Agent 循环自己的默认处理(交回 enter,带上这一步的消息)。所以它们排成一串:前一个调用 next(),才轮到后一个。谁都不用认识谁,各管各的事,不归自己管就 next()。
这一串的先后是怎么排的?同一个钩子上,先注册的排在外面,先被调用;默认处理永远在最里面。插件谁先启动看依赖(第 01 章),所以别指望“我一定排在谁前面”。真要排到最前,注册时加一个选项:ctx.on('agent/pre-step', 处理函数, { prepend: true }),它会插到现有处理函数的最前面。
点“下一个画面”,看这一串怎么一个个往里调用、再一个个把结果交回来。勾上“会话已归档”,看拦下时会怎样。
这一串排好以后,Agent 循环拿到最终的决定,才真正去问模型。回到开头的问题:报时就是这样加进去的。Agent 循环的代码,一行没改。
报时插件明明可以在“发给模型之前”把时间直接塞进请求,为什么要在“这一步开始前”往消息里加一条?
模型看到了时间,但这件事哪儿都没记下来。以后重新打开这个会话,就算不出当时模型到底看到了什么。
这条时间消息会记进会话日志。任何时候都能从日志里原样算出当时发给模型的内容,恢复会话、回放都靠它。
“往消息里加一条”有唯一的正确姿势:把它放进交回去的决定里,由 Agent 循环去记进日志。下面两种看起来也在“加”,其实都不算数:
还有一个边界:卸载报时插件,以后不再加时间;但以前已经记进日志的时间消息还在,卸载不会抹掉历史。
所以“发给模型之前”这个钩子只许换模型、换参数,不许改消息。DSH 把这条规矩叫“模型看到的,必须记下来”。会话日志是怎么回事,下一章讲。
一个插件想拦下这一步,它的处理函数怎么写?
报时插件为什么要先调用 next(),再加时间?
一个插件只看 agent/pre-step 给的 messages,里面有“报告”两个字就加一条提示。你说“写个报告”,模型第一步调用工具,第二步直接回答。这个插件一共加了几次提示?
同一个钩子上依次是甲、乙、丙。甲先 await next(),拿到结果后在消息末尾加“甲”;乙不调用 next(),直接交回 { kind: 'enter', messages: ['乙'] };丙会加“丙”。最后这一步走不走?消息是什么?
enter,不是 reject。甲拿到乙的结果,再把“甲”加在后面。