什么时候写插件,什么时候 fork
判断题、一个新 provider 到底要写哪些文件、守规矩的 delta 长什么样,以及 fork 维护的残酷算术。
前面六个模块讲的都是「这套设计是什么」。这一页讲「你真要动手改的时候会撞上什么」——这是最容易被架构文章跳过、也最贵的一段。
本页的判断框架、文件清单、门禁清单、速度数据全部来自本仓库,可点到源码坐标与命令。本页不复述任何一个具体第三方 fork 的 commit 细节——那类材料本站无法逐条核对,也就不写。
如果你手上正好有一个 fork,把它对着 7.3 的清单过一遍,比读任何案例都有用。
7.1 判断题:什么时候树外插件就够
先把结论摆出来,再解释:
| 你要做的事 | 够不够 | 为什么 |
|---|---|---|
| 加一个 provider——新的搜索后端、新的模型厂商、新的沙箱、新的存储 | 树外插件够 | seam 的 Definition 已经存在,你只是往注册表里再塞一个实现。核心零改动。 |
| 加一个工具 / 一段 prompt section | 树外插件够 | ctx.tools 和 ctx.systemPrompt 都是公开注册面。见 Lab 3。 |
| 换掉某个能力的默认实现 | 一段配置够 | cordis.patch.yml 按 id 替换那一行的 config。见 Lab 2。 |
| 拦截 / 改写请求、工具调用、turn | 树外插件够 | agent/* 和 tools/* 是文档化的 waterfall 扩展点。 |
| 改一个 seam 的接口本身——比如让 credentials 支持一种它没有的获取方式 | 必须 fork | Definition 拥有那个 ctx key 和方法签名。你没法从外面给一个抽象类加方法。 |
| 改 agent loop 的行为本身 | 先别 fork | 官方规则是「插件,而不是改 loop」。改 agent-loop 必须同时更新 docs/architecture.md——这条规则的存在本身就是在告诉你,先去扩展点上找答案。 |
| 改一个持久格式 / 线上协议 | 必须 fork,而且要慎重 | SessionEventMap 的成员默认 required-on-read;不认识的事件会让别的构建拒绝这份日志。 |
7.2 一个新 provider 到底要写哪些文件
把 packages/web/web-search-exa 完整摊开——这是一个真实的、在树内跑着的搜索 provider 的全部:
package.json # 名字、exports、peerDeps(cordis + 它要用的 seam) tsconfig.json README.md # 含 Model Experience 三节 + 已知限制节 README.zh.md # 中文对照,与英文等权威 README.i18n.yaml # 双语一致性记录:两侧的 git blob hash src/index.ts # 插件本体:name / inject / Config / apply —— 约 70 行 src/provider.ts # 真正干活的:请求映射、错误映射 src/types.ts src/invariant.ts # 不变量伴生插件(这个包显式声明为空) tests/exa.spec.ts # 单测,逐文件 100% 行覆盖 tests/exa.e2e.ts # 真 API e2e,无 EXA_API_KEY 时自跳过
插件本体本身薄得出乎意料。它的核心就是一个 apply:
/** Cordis 插件名,loader 诊断用。 */ export const name = 'web-search-exa' /** 它注册进去的那个 seam。 */ export const inject = ['web'] /** 把 Exa 搜索 provider 注册到 `ctx.web`。 */ export function apply(ctx: Context, config: Config): void { ctx.web.registerSearchProvider(new ExaSearchProvider({ apiKey: config.apiKey ?? launchEnvironmentOf(ctx).get('EXA_API_KEY')?.value ?? '', baseURL: config.baseURL ?? EXA_DEFAULT_BASE_URL, // …其余默认值 })) }
文件头那段 JSDoc 才是这个包最值钱的部分,它把角色说得清清楚楚:
「一个函数 / 命名空间插件(不是 default-export 的 service):一个搜索 provider 不拥有 ctx.web 这个 key——它注册进这个 seam 的 provider 注册表,就像 dsh-llm-deepseek 把一个适配器注册进 ctx.llm 一样。key 由 @deepseek-ai/dsh-web 拥有。」
这句话是从一次真实事故里长出来的。docs/postmortem/0001 记的就是:一个插件用了 default export,导致 inject 被丢掉。所以现在 「Namespace plugin:具名导出 name / inject / apply,无 default export」 这句话,出现在几乎每个包的 README 的「Export shape」一节里。
那个「空的」不变量伴生插件
src/invariant.ts 值得单独说,因为它体现了一条很少见的规矩。AGENTS.md 写着:「运行时不变量断言的是自己拥有的关系……在没有一个说得通的关系时,一个被解释过的空伴生插件才是正确的。」
/** * 没有运行时不变量:这个包除了它所属 seam 已经强制的契约之外, * 不暴露任何独立的事件序列或可变数据关系。 */ const install: InvariantInstaller = () => {}
「我检查过了,这里确实没有可断言的东西,原因是这个」——这比「我没写」多出来的信息,正是 review 需要的。这跟 M4.1.2 那个「审计过的零」是同一套思路:把「有意的缺席」和「忘了」在结构上区分开。
7.3 守规矩的 delta 长什么样
假设你要往这个仓库里加一个包(或者往你的 fork 里加)。除了代码,你还欠这些:
| 欠什么 | 谁在管 |
|---|---|
| 双语 README | verify-translation-pairing:README.i18n.yaml 记着两侧的 git blob hash,改了一侧不带上另一侧,门禁红 |
| Model Experience 三节 | verify-package-readme-model-experience(见 M4) |
| 已知限制节 | verify-package-readme-limitations |
| 每个导出的 JSDoc | verify-export-jsdoc:函数型导出还必须有 @param / @returns |
| 逐文件 100% 行覆盖 | test:coverage——注意是它,不是 test,才是 CI 的覆盖率门禁 |
| 一篇 Agent Note | verify-agent-note-format + verify-agent-note-classification:非平凡改动必须在同一个 PR 里加或更新一篇,而且也是双语 |
| 一个 keyless 快照场景 | 模型可见 / 协议可见 / 人可见的改动,要在同一个 PR 里通过某个可运行 example 的快照套件加一条 |
| 不重复的代码 | duplication(jscpd 跨文件克隆检测)——这就是为什么那个空 invariant 文件要用 /* jscpd:ignore-start */ 包起来 |
| 干净的依赖 | hygiene:knip(找没用的导出/依赖)+ publint + workspace 约束 + NodeNext consumer 检查 |
7.4 fork 维护的残酷算术
说服力最强的不是道理,是数字。本站在 commit b150a55 上实测:
| 窗口 | 落到 master 的合并数 | 提交数 |
|---|---|---|
| 最近 4 天 | 63 | 367 |
| 最近 7 天 | 92 | 604 |
| 最近 14 天 | 288 | — |
| 最近 30 天 | 713 | — |
| 从第一个 commit(2026-06-10)到 v0.1.1-rc.2 | 1,073 | 13,147 |
表 7.4 「合并数」用 git rev-list --first-parent 数,即真正落在主线上的改动批次;「提交数」是全部 commit。73 天,13,147 个 commit。你可以自己复现:git log --first-parent --since='7 days ago' --oneline | wc -l。
你出门喝杯咖啡回来,上游可能已经多了几十个合并。请一周假,就是九十多个。
这意味着:一个碰了核心的 fork,不是「以后要花时间同步」,而是「从第一天起就在持续偿还」。而且冲突不会均匀分布——它们会集中在你改过的那几个文件上,因为那些文件恰恰是仓库最活跃的部分。
反过来,一个只在 cordis.patch.yml 里加了两段的「fork」,上游怎么跑都不关它的事。这就是 M2 那套 seam 抽象,最终兑现价值的地方。
如果你确实必须 fork:三条生存策略
- 把 delta 分层。能进
cordis.patch.yml的进配置层,能进新包的进新包,实在必须改的核心文件单独列一张清单——每次同步只需要人工看那张清单上的文件。 - 让 delta 长在别人不动的地方。加一个新包,冲突概率接近零;改一个既有文件的中间,冲突概率接近一。dsh 的包粒度细到 227 个,恰恰让「新增而不修改」这条路特别好走。
- 盯着仓库自己的「预发布姿态」。根 AGENTS.md 里有一段叫「Pre-release stance: foundation over blast radius」,明写着「在第一个正式发布时删掉本节」——在那之前,官方明确保留了自由重命名、自由重构包结构的权利,后端会直接拒绝旧的磁盘格式。这段话还在,就意味着你的 fork 随时可能撞上一次大规模重命名。
Lab 4 · 复刻一个 web search provider(60 分钟)会照着 7.2 的清单,从零裁出一个新后端。