第一个扩展:
写一个自己的工具。
模块 C 开始「改」这套框架,但第一个练习不是改 loop——而是加一个工具。
这是最小但完整的扩展:一次碰到 ctx.tools 注册、JSON schema、
三段瀑布管线和 UI 渲染意图四个核心概念,却完全发生在文档化的扩展点上。
- 说清为什么第一个扩展练习是加工具,以及「Plugins, not loop changes」铁律的唯一例外
- 照 cookbook 写出最小工具:
defineTool、parameters schema、execute()契约与注册返回的 disposer - 画出工具调用的三段瀑布管线,指出审批、策略、沙箱各自挂在哪个事件上
- 为工具前置设计 UI 渲染意图,并在 Web / headless 里让模型成功调用它
为什么第一个练习是加 tool
因为加工具是这套框架里最小但完整的扩展动作。说它「最小」:一个插件、一次注册、 几十行代码就能跑通;说它「完整」:这一下会碰到几乎所有扩展共用的四个核心概念——
ctx.tools注册表:能力的服务入口,插件通过inject拿到它;- JSON schema DSL:模型可见的参数声明,同时驱动运行时校验与 TypeScript 类型推导;
- 三段瀑布执行管线:
tools/pre-execute、tools/execute、tools/post-execute,策略与钩子的挂载点; - UI 渲染意图:工具调用在界面上如何呈现,是设计的一部分而不是事后补丁。
而这一切不碰 loop。这正是在实践仓库的第一铁律:Plugins, not loop changes——
新行为挂在文档化的扩展点上,而不是改 agent loop。loop 是所有部署共享的核心,
改它影响面最大;而扩展点(注册表、瀑布事件、渲染意图)天生为「加行为」而设计,
可逆、可组合、可测试。唯一的例外:确实要改 agent-loop 时,
必须在同一变更里同步更新 docs/architecture.md,让文档与代码保持一致。
以后每想加一个行为,先问:「有没有现成的扩展点?」工具、瀑布监听器、守卫、渲染意图能覆盖绝大多数需求;答案几乎是「有」。
最小形态与 execute() 契约
下面是 docs/cookbook/adding-a-tool.zh.md 给出的最小工具——读一个文件。
每个字段都有讲究,先读代码,再拆契约:
// scratch-plugin/src/my-plugin.ts
import { readFile } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'my-tool'
export const inject = ['tools'] // 等 tools 注册表就绪后再跑 apply
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'read_file',
description: 'Read a file from disk.', // 模型看到的说明
parameters: {
path: { type: 'string', required: true, description: 'Absolute path' },
limit: { type: 'number' }, // 不写 required 即为可选
},
output: {
schema: { type: 'string' }, // 规范返回值的声明
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
// args 已按 schema 推导并校验:{ path: string; limit?: number }
return readFile(args.path, { encoding: 'utf8', signal: exec.signal })
},
}))
}围绕这段代码,有六条契约必须记住:
- 注册即 effect:
ctx.tools.register()返回一个 disposer;dispose 插件 fiber 即注销该工具,不需要手动清理。schema 会自动流入系统提示词的组装——注册成功,模型下一轮就能看见它。 - 参数已为你校验:
defineTool在execute运行前按统一的ParameterSchemaSpec校验模型生成的 arguments,args的类型就是推导结果。schema 表达不了的约束(非空字符串、正数、跨字段规则)仍要手动检查。 - args 只读:注册表把 arguments 物化为无损 JSON,在策略开始前冻结;把
args当只读输入。 - 返回规范 JSON 值:
execute只返回output.schema声明的值,不要返回内容块;模型可见的自然语言由output.render投影。 - 抛异常 =
isError:基础设施故障抛异常;成功的领域结果(哪怕是「不理想的状态」,如进程非零退出)应写进规范值,由渲染器去解释。 - 遵守
exec.signal:信号触发时取消进行中的工作——上面的readFile直接把 signal 透传了过去。
执行管线与拦截:三段瀑布
模型发出工具调用后,这个调用并不直接进你的 execute(),而是穿过一条可扩展的管线:
tool/call 会话事件先行落日志,然后依次经过三段瀑布和一道单调守卫,
最后以 tool/result 会话事件收尾。
三段瀑布各司其职,记住这个分工就记住了整条管线:
tools/pre-execute:决定要不要跑。监听器返回allow/deny/ask。ask触发ctx.approval的一次性询问——审批应答者(Web UI 的人类、ACP 的机器决策)挂在approval/request瀑布上;只有allowed-once才放行,rejected、cancelled、unavailable(包括根本没有审批通道)一律拒绝。审计事件approval/asked/approval/decided只落日志,不进模型 transcript。- 单调守卫:
ctx.tools.guard()注册的最终检查,返回值里没有 allow——只能收紧不能放行,因此监听顺序永远无法把拒绝翻案成许可。 tools/execute:包装怎么跑。环绕分发层,超时、重试、指标收集挂在这里;包装器可以替换exec.signal(施加截止时间),但不能移除它。tools/post-execute:决定结果长什么样。接受、替换展示内容或规范值、把结果阻止成带纠正反馈的错误,或附加下一次模型请求可见的上下文。之后tools/result同步观察冻结的权威结果——只读,不能改。
不要在 execute() 里写「先确认再执行」「检查路径白名单」这类部署策略。审批、权限、沙箱全部挂在 tools/* 瀑布事件和守卫上——工具实现保持纯粹,同一工具才能在不同部署里被不同策略复用。
长时间运行的工作走另一条路:通过 producer 配置开启 run_in_background,
用 ctx.jobs.start({ kind, label, owner: exec.agent, run }) 注册任务,
前台分支返回类型化的规范句柄 { kind: 'background', jobId }。
任务 id 发布之后,取消归任务自有的信号管,不再归 exec.signal——
外层调用的取消只停止等待,不会终止已发布的工作;其生命周期属于 job_kill、
owner dispose 和服务 teardown。dsh-tool-bash 是这条路径的参考实现。
UI 渲染意图:设计的一部分,前置决定
output.render 决定模型看到什么;工具调用在 UI 卡片上长什么样是另一项独立关注点,
由可选的 presentCall / presentResult 声明。二者都返回一个
card 标签的渲染意图,由 host/client 运行时映射成各自的具体视图——
工具本身绝不导入任何 UI 或传输类型。
presentCall(args)→ pending 卡片:generic(默认,可设kind选图标、设locations标注涉及的文件供编辑器跟随跳转)、terminal(调用本身是 shell 命令,如 tool-bash)、diff(调用创建或修改文件,如 tool-fs 的write/edit)。presentResult(args, result)→ 完成卡片:除 generic/terminal/diff 外还有search(grep/glob 的发现型结果,带truncated/total)、read(带行号的代码视图)、web(检索来源或抓取摘要)。完成视图会替换 pending 视图,所以变更类工具要保留 diff 结果。- 回退:没有展示方法的工具退回通用卡片——标题 = 工具名,原始 args 作为输入。
注意渲染侧的分工:内置 Web Client 不直接消费 presentCall/presentResult——它运输原始
tool/call/tool/result 事件,由 Client 插件在 keyed slot tool.call.toolview
里按工具名注册自己的视图组件(见 docs/subsystems/slots.md)。render intent 依然是工具侧与宿主之间的展示契约。
关键纪律是「前置决定」:渲染意图在写工具时就和 schema、execute 一起设计, 不是等 UI 丑了再补。随之而来的是三条硬性规则,违反会出问题:
- 纯函数:这两个方法在实时流式输出和会话日志回放时都会运行,因此必须是
args(加 result)的纯函数——不做 I/O、不读会话状态、不用时钟和随机数。 - UI 格式不进模型结果:围栏代码块、diff 文本、相对化路径不应仅为服务 UI 而进入规范值或 Native 内容。
- 软校验回退:
defineTool对展示路径做软校验,格式错误返回undefined(通用回退)而非抛异常——展示绝不能导致回放崩溃。
想在 presentCall 里读文件旧内容来生成 diff?请停下:调用时的展示器拿不到、也不该拿文件先前内容——那属于 output.presentationMeta(从同一个规范值派生、随 tool/result 持久化的结果期投影)或 UI 适配器。write 的调用卡片用 oldText: null 正是这个原因。
动手试:完整加一个 greet 工具
照 docs/user/develop/basic/tool.zh.md 的步骤,把最小形态落地成一个能被模型真实调用的工具。
前置:已完成 L1 的源码构建,并按「第一个插件」教程建好 scratch-plugin 目录。
第 1 步:把 scratch-plugin/src/my-plugin.ts 替换为工具插件——
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet someone by name.',
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return `Hello, ${args.name}!`
},
}))
}
第 2 步:创建 scratch-plugin/cordis.yml 作为插入本地插件的覆盖层。
注意插件路径必须是绝对路径(在仓库根目录跑 pwd 拿到它),patch 只贡献配置,
不改变 loader 解析模块路径时使用的 profile 目录:
- insert:
- id: greet
name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'第 3 步:自检。仓库基线必须先干净,这也是每次改动前的固定动作:
- 跑类型检查,确认基线通过:
pnpm run typecheck2. 用覆盖层启动 Web UI,打开 http://127.0.0.1:3080:
pnpm dsh web --patch ./scratch-plugin/cordis.yml3. 在对话框输入 Use the greet tool to greet Ada.——模型会调用 greet 并收到 Hello, Ada!。观察 UI:pending 卡片来自 presentCall 的回退(你没写展示方法,标题 = 工具名)。
4. 再用 headless 验证同一工具在纯净日志里的完整事件链(tool/call → tool/result):
pnpm dsh --profile headless --patch ./scratch-plugin/cordis.yml "Use the greet tool to greet Ada."给它加上 presentCall,返回 { card: 'generic', title: `Greet ${args.name}`, kind: 'other' },重启后对比 UI 卡片的变化——亲身体验「渲染意图是 args 的纯函数」。
课堂练习
B 对:注册入口就是 ctx.tools.register,插件用 inject = ['tools'] 拿到注册表,注册返回 disposer。A 错:cordis.yml 的 patch 负责把插件插进组合,工具本体仍是 apply 里的一次 register;C 错:改核心包源码违背「Plugins, not loop changes」;D 错:tools/change 是工具集变更的通知事件,不是注册入口。
C 对:审批是部署策略,挂在 tools/* 瀑布事件上——ask 触发 ctx.approval 询问,仅 allowed-once 放行,拒绝、取消、无应答一律 deny。A 错:策略内建进工具会让工具失去跨部署复用性;B 错:改 loop 违反铁律,且扩展点已够用;D 错:render 是纯投影,跑在结果之后,根本不在决策路径上。
A–D 正是 cookbook 最小形态的四个组成部分:schema、execute 契约、渲染意图、注册即 effect 的 disposer。E 错:loop 通过注册表分派一切已注册工具,新工具对它透明——需要改 loop 才能加工具,恰恰说明你把扩展点用错了。
A、B 是本课 04 节的硬规则。C 错:pending 卡片在执行之前就要渲染(presentCall 只拿得到 args),结果期的丰富状态走 presentResult 与 presentationMeta;D 错:展示器必须是纯函数,文件旧内容属于结果期元数据或 UI 适配器,write 的调用卡片用 oldText: null 正是这个原因。
问答为什么「Plugins, not loop changes」是铁律?它的例外是什么?写下答案后点击对照›
参考要点:① agent loop 是所有部署共享的核心,改它影响面最大,还容易随版本漂移;② 文档化的扩展点——注册表、三段瀑布、守卫、渲染意图、能力缝隙——已覆盖绝大多数新行为,插件方式可逆(注册返回 disposer)、可组合(cordis.yml patch)、可测试;③ 例外:确实要改 agent-loop 时,必须在同一变更里同步更新 docs/architecture.md,保持架构文档与代码一致。
问答描述你照 cookbook 加的工具,从注册到被模型调用再到 UI 呈现的完整链路。写下答案后点击对照›
参考要点:① 注册:cordis.yml patch 把插件插进组合,apply 里 ctx.tools.register(defineTool(...)) 完成注册,schema 流入系统提示词组装,模型下一轮即可见;② 调用:模型产出 tool-call 块 → tool/call 落日志 → tools/pre-execute(可经 ask 触发 ctx.approval)→ 单调守卫 → tools/execute 环绕 → execute() → tools/post-execute → finalizeContent → tools/result 观察 → tool/result 落日志,模型看到 output.render 投影的内容;③ 呈现:presentCall(args) 先渲染 pending 卡片(未声明则回退通用卡片),presentResult(args, result) 用持久化结果(含 presentationMeta 的 meta)渲染完成卡片并替换 pending。
本课小结
你完成了在这套框架里的第一个扩展:一个不经任何 loop 改动、完全长在扩展点上的工具。
你拿到了四件公共词汇——ctx.tools 注册与 disposer、schema 驱动的 execute() 契约、
三段瀑布管线(审批与策略的挂载点)、前置决定的 UI 渲染意图——并在 Web 和 headless 里让模型真实调用了它。
下一课换一个更硬的缝隙:照 Service Definition / Provider / Consumer 三角色设计法,
把一个新的模型适配器接进 ctx.llm。