- 每个插件只写两样:依赖(要用哪些服务),和启动的入口:通常是启动函数
apply;像 shell 这样写成一个类的插件,由构造函数启动。启动函数里,插件把自己的东西交给服务,这叫注册,可以注册好几样;插件也可以自己当服务给别人用,或者挂钩子。插件没有固定的种类。 - 插件框架 Cordis 只看依赖齐没齐:缺一个,插件就 PENDING(等着);补齐了,自动启动。
- 卸载插件时,它注册过的东西会被撤掉:常用的注册接口都替插件把“怎么撤掉”交给了 Cordis。
上一章的循环里,ctx.llm、ctx.tools 都不是循环自己写的,而是别的插件提供的。这一章看这些插件是怎么接上去的。
apply;像 shell 这样写成一个类的插件,由构造函数启动。启动函数里,插件把自己的东西交给服务,这叫注册,可以注册好几样;插件也可以自己当服务给别人用,或者挂钩子。插件没有固定的种类。在 DSH 里,下面哪种说法对?
点一个选项。
回到开头的问题:每个部件都能单独装、单独换,是怎么做到的?第一步,先看一个插件长什么样。例子是 DSH 真实的 bash 工具。
export const name = 'tool-bash'
export const inject = ['tools', 'shell', 'systemPrompt', …] // 依赖
export function apply(ctx) { // 启动函数:依赖齐了,Cordis 才调用它
ctx.systemPrompt.section({ name: 'tool:bash', order: …, text: '…' }) // 注册①:一段用法说明(order 是排序号,第 06 章讲)
ctx.tools.register(bashTool) // 注册②:一个叫 bash 的工具
}
inject 里写的 tools、shell、systemPrompt,也都是插件,只是专门给别的插件用,这类插件叫服务。注意同一个服务有两种写法:插件名用短横线(system-prompt),代码里的服务名用驼峰(systemPrompt),说的是同一个东西。但服务名不一定能从插件名推出来:同一个服务可以由不同的插件提供,比如 shell 这个服务,可以由 bash-local 或 bash-sandbox 提供(第 07 章)。插件通过 ctx.名字 用它们,上一章循环里的 ctx.llm 就是这么来的。启动时发生的事,用对话来说是这样:
不只是工具。下面四个插件,平常被叫成不同的“种类”,其实长得一样:依赖加启动函数。差别只在启动函数里用 ctx 做了哪几件事:
ctx.tools,用的就是它插件没有固定的种类。“工具插件”“钩子插件”只是按它主要干的事起的外号。插件在启动函数里通过 ctx 做的事,只有三种:
ctx.名字 来用。tools、system-prompt、shell、agent-loop 都是。ctx.on('名字', 处理函数),别的插件喊这个名字时,你就插手一下。超时插件、下一章的报时插件都是。它其实也是一种注册,注册的是“到这一刻叫我”。一个插件常常同时做好几种,像上面的 tools 和 agent-loop。看懂一个插件,就问三句:它提供了什么服务?往哪个服务注册了什么?挂了哪些钩子?这里用到的 ctx.tools.register、ctx.systemPrompt.section、ctx.on,都会替插件把“怎么撤掉”交给 Cordis,所以卸载时自动撤掉(第 3 节)。
// shell 服务的规定(shell 插件):一个类,构造时报上名字export abstract class ShellExecutor extends Service { constructor(ctx) { super(ctx, 'shell') } // 服务名就在这里定 abstract resolve(…) // 实现必须提供的方法 abstract execute(…)} // 一个实现(bash-local 插件):继承它,导出这个类export default class LocalBashExecutor extends ShellExecutor { … }
Service 是 Cordis 提供的服务基类。和前面导出 apply 的插件不同,服务类插件导出一个类,Cordis 启动它时调用构造函数,super(ctx, 'shell') 这一句把它挂到 ctx.shell 上;inject 里写着 shell 的插件,这时依赖才算齐,跟着启动。服务名来自 super(ctx, 'shell') 这一句,跟插件叫什么无关。
第二步:插件只写了依赖,没写“谁先启动”。那启动顺序由谁定?由 Cordis 定:依赖不齐的插件不会启动,齐了才启动。互相没有依赖的插件,谁先谁后不保证。
DSH 启动时要装哪些插件,写在一份名单里,这份名单叫配置文件。在名单里删掉一行,就等于不装那个插件。
配置文件里删掉 system-prompt 那一行,其他不变。启动后,bash 工具会怎样?
点一个选项。
自己试一试:现在 system-prompt 没装,好几个插件是 PENDING。把它打开,看谁会自己启动。
图里的名字:llm 负责连接模型;agent 管理正在跑的 Agent;shell 负责执行电脑指令;timeout-policy 是“跑太久就喊停”的超时插件(第 05 章);agent-loop 就是 Agent 循环。
写进 inject 的是必需的依赖:缺一个,插件就等着。还有一种可选的依赖:不写进 inject,插件照常启动,等真要用的时候再问一句 ctx.get('名字')。有就用;拿到的是 undefined,就由插件自己决定怎么办。比如 bash 用审批服务就是这样(第 05 章)。
第三步:能装,还得能卸,才谈得上“单独换掉”。像 ctx.tools.register、ctx.systemPrompt.section 这样注册的东西,插件不用自己写清理代码。
bash 工具执行指令时,在你的文件夹里新建了一个文件。之后把 bash 工具卸载,这个文件会怎样?
ctx.tools.register(…) 也好,ctx.systemPrompt.section(…) 也好,都会把“怎么撤销”记在插件名下;卸载时 Cordis 逐个撤销。插件自己在外面做的事,比如新建的文件,框架不管。点一个选项。
怎么做到的?这些注册接口里面都做两件事:把东西放进去;同时把“怎么撤掉”交给 Cordis,记在这个插件名下。卸载时,Cordis 按交来的倒序,把这些撤销一个个执行。简化后大致是:
ctx.effect(() => {
工具表.add(bashTool) // 做这件事
return () => 工具表.delete(bashTool) // 交回“怎么撤掉”,Cordis 记在插件名下
})
所以,只有把“怎么撤掉”交给了 Cordis 的事,卸载时才会被撤掉。插件自己开的定时器、网络连接、子进程,Cordis 不知道;要让它们在卸载时停下,就用同一个办法自己交给它。个别注册接口只把撤销函数还给你、自己不交,也照这样用 ctx.effect 包一层:
ctx.effect(() => {
const 定时器 = setInterval(检查, 60000)
return () => clearInterval(定时器) // 卸载时 Cordis 会调用它
})
新建的文件是另一回事:那是用户要的成果,卸载插件本来就不该删它。
一个插件只写哪两样?“注册”发生在哪儿?
在配置文件里把 bash 工具挪到第一行,它会先启动吗?
卸载 tools 服务,system-prompt 会跟着停吗?
ctx.effect 自己交。
插件还能在 Agent 干活的过程中途插手,比如工具跑太久就喊它停下。这靠的是钩子,下一章讲。