18 · 实战一:自己的能力 seam 和笔记工具¶
18.1 先定义你要交给模型的能力¶
目标是一个个人笔记 Agent。模型可以搜索与读取宿主明确提供的笔记,并在回答中引用笔记 ID。模型不能选择文件路径、执行 Shell 或修改这些数据。这个目标可检验:请求中只公布 search_notes 和 read_note;模型越权调用 bash 时,实际目录里没有出现标记文件。
这是一个完整的小型纵切面,不声称复制所有 dsh 功能。我们采用 rc.2 官方 sdk-minimal profile,加自己的 Cordis 插件 overlay。插件调用同一个 tools runtime、prompt assembler、Agent loop 和 Session,而不是写一个影子 Agent 循环。
下载 实验,解压后先看 src/note-service.ts、memory-notes.ts、json-notes.ts、notes-tools.ts。全部源码也收在 附录。
18.2 三个角色对应四个文件¶
NoteStore 继承 Cordis Service,构造时用 super(ctx, 'courseNotes') 发布相应服务名,声明 search(query) 和 read(id) 两个异步方法。TypeScript declaration merging 只让 ctx.courseNotes 获得类型,并不会凭空创建 provider。
export abstract class NoteStore extends Service {
constructor(ctx: Context) { super(ctx, 'courseNotes') }
abstract search(query: string): Promise<Note[]>
abstract read(id: string): Promise<Note | undefined>
}
definition 是消费者与 provider 共用的契约。数据来源属于 provider 的实现,工具的模型描述、参数 schema 和调用策略属于 consumer。将“读文件”直接写在 tool body 中也能得到一次答案,但会让每个消费者都认识存储位置,替换数据源时必须改工具;这里刻意练习 dsh 的 seam 设计。
SnapshotNoteStore 在构造时复制并冻结每条笔记,读时又返回副本。这样外部拿到返回对象后修改 text,不会改掉后续回答的原始数据。异步方法允许以后换成数据库/provider,但本例不假装已经实现数据库事务、分页、远程鉴权或向量检索。
18.3 在发布 provider 前验证边界¶
validateNotes(unknown) 是两个 provider 共用的入口校验:数据必须是数组,最多 100 条;每项 id 必须符合 [a-z0-9-]{1,64},文本为 string、最多 8192 个 JavaScript 字符,同一 ID 不得重复。配置文件是宿主输入,并非默认可信到可以跳过格式检查。
上限用 JavaScript string 长度,与 UTF-8 字节、token 数不同。100 × 8192 也不是最终模型上下文大小承诺;搜索可能返回多个匹配,生产版应按模型上下文做分页/输出限额。这份课程让数据集很小,以把重心放在组合边界。
memory provider 接收经过 schema 的 notes 配置再调用共同 validator。JSON provider 接收 启动者提供的绝对路径,打开时检查 regular file 与 1 MiB 文件上限,读后再次检查 buffer 长度,解析后按同一 validator 验证。它在初始化完成前建立快照,tool body 从此不再读宿主文件。
这并不是“限制到某个目录的 OS 沙箱”。启动时的路径由有权限的部署者选择;文件在读取期间可能变更,前后 size 检查也不是 concurrent grow 下硬内存上限。实验前提是宿主维护一个稳定的小型可信文件,模型只看到 opaque ID。若数据来自不可信上传,应使用 bounded streaming、业务存储和独立租户校验。
18.4 Consumer 声明依赖并注册模型工具¶
notes-tools.ts 导出 inject = ['tools', 'courseNotes', 'systemPrompt']。Cordis 必须发现这些服务才激活 consumer;provider 卸载会令依赖消费者失效,服务恢复后重激活。不是在 apply 内轮询某个全局对象。
工具使用官方 defineTool(),参数 schema 决定模型调用的结构边界,body 再验证业务规则。search_notes 要求非空、最多 128 字符的 query;read_note 要求已知 opaque ID,拒绝 ../secrets 与 unknown id。schema 检查不能代替这些领域约束。
执行前 exec.signal.throwIfAborted() 阻止已经取消的调用进入 service。异步数据库 provider 还应把 signal 传给真正的外部请求;本例 service 是快速只读快照,因此没有装作能够取消一个已经发生的远端提交。
canonical output 声明为 string,body 返回 JSON string,render 把它转换为文本内容块。这里选择简单稳定的 string 契约,是为了让模型结果与 canonical 验证路径容易观察,并非所有工具都应该“双重 JSON”。第 08 章展示其他结果结构。
两个工具 isConcurrencySafe: () => true,因为对同一个冻结快照的读取无可变共享副作用。这只影响调度中的 body overlap,不表示 pre/post gate 和结果提交无序,也不表示以后换成带写入的数据库仍可以继续标 true。
18.5 Prompt 是行为说明,权限由能力和策略实现¶
consumer 注册 order 100 的 course:notes-policy section:说明只用两个工具取证,引用 ID,把笔记正文当数据,不声称执行文件或命令。它是模型遵循的文字,不是访问控制机制。
本例限制来自两处真正可检查的实现:组合不注册模型 Shell 工具,read_note 不接受路径。为了检查“模型不听提示”时的行为,HTTP fixture 故意生成 bash tool call;运行时给出错误 tool result,标记文件不存在。这个负例比看到模型自愿不用 Shell 更有证明力。
工具 gate 的几个易错关系在契约测试中明确验证:pre-execute deny 时 body counter 为 0;post-execute block 时副作用 counter 已是 1;canonical output 类型错误生成 tool error;已取消 signal 在 dispatch 前拒绝 body;异步 waterfall 确实等待 downstream gate。失败结果不总意味着动作没有发生。
18.6 插件生命周期是实战的一部分¶
ctx.tools.register 和 ctx.systemPrompt.section 归属于当前 fiber 的可撤销 effect。不需要在自己的源码中维护永久全局工具数组。测试卸载 consumer 后工具和 section 都消失;卸载 provider 后 consumer 停用,再挂载新的 provider 会恢复。
这证明的是相应注册的生命周期,不意味着文件、数据库记录、外部网络请求可以由 Cordis 自动回滚。真有资源时应拥有 explicit dispose/drain 协议。MCP、进程、浏览器各自的关闭确认边界见 10–12 章,不能将这里的纯数据 service 行为推给所有 provider。
18.7 如何替换成你自己的业务能力¶
按此例替换 NoteStore:先写清模型需要的业务方法、数据所有者、出错结果与取消语义;再实现 provider;最后写工具 consumer 与输出 schema。如果业务是查订单,应让 tool 收 opaque order id,再由 provider 根据 authenticated tenant 查找,不能把用户输入拼进任意 SQL 或把 SessionId 当 tenantId。
你可以保持 notes-tools 契约,先用 memory provider 做可重复测试,再换 JSON 或自己的数据库 provider。若服务接口变化,就明确升级 consumer 和兼容范围。可以替换并不代表任意两个实现都满足同一语义,例如搜索排序、返回条数和错误含义也属于契约。
对应源码基础:Cordis Service、tools runtime。实验使用发布版,这里的链接也锁 rc.2 提交。