让 agent 现场造一个工具
cordis_inspect_list → cordis_define → cordis_run:模型给自己长出一个新能力,然后原样卸掉。
前四个 Lab 都是你在写插件。这一课换个角色:你只提需求,模型自己写插件、自己挂上去、自己用。
它不是魔法表演。做完这一课你要能回答一个具体问题:为什么这件事在 dsh 里是结构上安全的,而在别的架构里不是。答案早在 M2 就给过了——注册即 effect。
官方对这个工具集的定性是:「沙箱隔离了全局,但它不是安全边界……把这个工具集当成 bash 权限来对待。」
动态代码拿到的 Service 连的是真实运行时。所以:拿一个你不介意弄坏的 profile,别在生产机器上做这一课。
1 把工具集挂上去
它不在任何一棵出厂的插件树里——生成式 tool catalog 在它那一行写着「Not in any shipped tree(a deliberate opt-in)」。仓库里有一个现成的 demo 组合:
$ pnpm run demo:cordis Cordis Web: http://127.0.0.1:3081
它做的事很朴素:拿 examples/web-cordis/cordis.yml 当 --patch overlay,叠在出厂的 web 组合上,占 3081 端口。也可以走 ACP 那条:pnpm run demo:cordis acp。
本站实测,pnpm run demo:cordis 在这个 commit 上会大声失败:
Error: dsh: plugin tree failed to load: failed to apply loader entry
include (cordis:include): duplicate loader entry id: cordis-host-runner
原因很清楚:packages/bundle/web-app/cordis.patch.yml 自己已经插了 cordis-host-runner(第 108 行)、cordis-client-runner(179 行)和 ui-cordis(223 行),而 examples/web-cordis/cordis.yml 又插了一遍 cordis-host-runner。web-app bundle 后来把这三行吸收进去了,demo overlay 没跟上。
这本身就是一次「规则卡 02 · 误配置必须大声失败」的现场演示——重复 id 不会被静默忽略,它在加载时直接把进程停掉。
绕过它不需要改仓库:overlay 是配置层的东西,自己写一份只补 tool-cordis 的就行。
# web-app bundle 已经自带 runner 与浏览器 UI 三行,这里只补模型可见的工具 - id: webserver config: host: 127.0.0.1 port: 3081 - insert: - id: tool-cordis name: '@deepseek-ai/dsh-tool-cordis'
$ node --import tsx apps/cli/src/bin.ts web --patch ~/cordis-demo.overlay.yml
本站的 demo 录屏就是这么跑出来的。核对于 commit b150a55;上游修好之后 pnpm run demo:cordis 应该会重新可用。
光有这四个工具还不够。它们注入的是 ctx.dynamicCordisRunner(由 @deepseek-ai/dsh-cordis-host-runner 提供,那个包才拥有定义注册表和 vm 沙箱)。一个只有工具、没有 runner 的组合,这些工具永远不会激活——这是 inject 依赖声明的直接后果。
2 先只读地问:三个 inspect
给模型一个需求之前,先自己看看它会怎么开局。官方 system prompt 里的推荐工作流,前三步全是只读的:
cordis_inspect_list—— 发现当前 Host 和 Client 有哪些 Inspect Provider,以及它们的只读查询方法cordis_inspect_query—— 用返回的 platform / provider / method / schema 去查精确的 Service、Event、Builtin、Slot、主题 token 或工具信息cordis_inspect_self—— 看当前 session 自己的插件、Package、版本指针、源码和诊断
prompt 里写得很明确:「查 Service.listService 和 Event.listEvents 时不带 input,从紧凑签名目录里挑,然后再查那个精确的 service 或 event」——精确查询才返回结构化契约和它引用到的类型。
为什么不一次给全?因为只有被查询到的那一份契约会进入上下文。把一份大目录做成两级查询,比塞进 system prompt 便宜一个数量级(见 M4)。
3 提一个需求,看它 define
试试这种量级的需求(太大它会正确地拒绝走这条路):
给我造一个临时工具,输入一段文本,返回它的字符数、 行数和最长一行的长度。再给它配一个小面板,我能直接粘贴进去看结果。
模型会调 cordis_define。关键在于这一步什么都没跑——它只校验参数和语法、记录源码。README 的原话:「它不申请审批、不执行 apply、不改 currentPackageId。」
你会看到对话里出现一张卡片,带一个启动控件。返回值里有两个 id:
pluginId—— 这个可以随时间被修改的插件。新插件你只提交一个 3–6 个小写字母的语义前缀,最终 id 由 Host 分配。packageId—— 该插件下一个不可变的源码版本。改代码 = 定义一个新 Package,永不覆盖旧版本。
4 run:审批、异步、以及「starting 不等于成功」
cordis_run 才是真正把它挂进当前 context 的那一步。这里有三个状态要分清,prompt 里专门花了三段教模型别搞混:
| 返回 | 意思 | 模型该做什么 |
|---|---|---|
awaiting-approval | 未授权的 Client Package 产生了一个审批请求 | 告诉用户去 UI 里允许或拒绝。不要等、不要重试、不要声称它在跑。 |
starting | 请求进了异步流程,浏览器还在激活 | starting 不等于成功。等系统通过 steering 上下文报最终结果。 |
| 技术失败 | 加载或激活出错 | 用 cordis_inspect_self 读诊断,修同一个插件——不要悄悄新建一个替身。 |
还有一条关于授权的设计:单个勾只授权当前这个 Package,双勾授权这个插件的未来版本。而且「一次技术失败之后,授权仍然有效」——失败不该惩罚用户已经做过的信任决定。
以及一句很硬的:「用户拒绝之后,不要再次请求审批。」
5 stop:看见「注册即 effect」
这才是这一课真正的高潮。让模型调 cordis_stop,然后观察:
- 刚才那个工具从工具表里消失了——下一次模型请求就看不见它的 schema 了
- 那个面板从浏览器里撤走了
- 但定义还在:插件、每一个不可变 Package、授权、版本指针全部保留,随时能再
run - 对一个已经停止的插件再
stop一次,幂等成功
动态插件的作者(模型)没有写过 unregisterTool,也没有写过 removePanel。它只是在 apply(ctx) 里注册了东西,而 Cordis 把这些注册当作 effect 记着,处置这棵子树就是把它们逐个撤回来。
顺带说个诚实的限制:这个 ctx façade 不暴露 effect(),所以插件代码没法注册自定义 disposer——on / provide / tools.register 是受支持的清理路径。README 的「已知限制」里明写着。
最后 cordis_undefine 是永久删除:先停、先取消未完成请求,然后删掉每个 Package、授权和版本指针。历史卡片只剩一条「插件已移除」的记录。
6 顺便观察一件事:KV cache
tool-cordis 自己的 README 在 Model Experience 一节里写了这么一句:
「运行或停止一个 prompt / 工具贡献,会改变后续请求的前缀,并可能从第一个变动的贡献开始让复用失效;一个不变的运行集合保持前缀稳定。」
换句话说,你刚才那次 cordis_run 和 cordis_stop,各自打掉了一次 KV cache。这不是 bug,是这个能力的固有成本,而且它被逐字写进了包的公开契约里——这正是 M4 那套做法的意义:一个能力的代价,和它的功能一样,是要写出来的。
同样这件事,在别家要几步?
待核对
待核对
M10 · 批判视角与常见问答——把这一整套东西的代价和风险摊开算账。