复刻一个 web search provider
照着 web-search-exa 的骨架裁一个新后端出来,并把它接进真正的 seam。
Lab 3 是「往注册面上加东西」,这一课是「往一个 seam 里加一个实现」——难度台阶主要不在代码,在于你要先读懂一份接口契约。
目标:写一个 WebSearchProvider,注册进 ctx.web,让它和官方的 deepseek / exa / perplexity 三个 provider 平级共存,然后用一段配置把它选中。
这一课需要从源码跑(git clone + pnpm install + pnpm run build),因为你要读 packages/web/ 的真实源码作为参照。真正的插件仍然写在树外。
1 先读契约,别先写代码
整个 seam 对一个搜索 provider 的要求,就是这三行:
export interface WebSearchProvider { readonly id: string /** 廉价的本地可用性检查;不得发起网络调用。 */ available(): boolean /** 跑一次搜索;要尊重 signal 的取消。 */ search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> }
然后读 Definition 那个包的文件头注释,它把选路规则写死了:
「重复 id 会被拒绝。执行时,被配置的 provider 必须存在且可用;没有配置的话,必须恰好有一个可用的 provider——所以选路永远不依赖注册顺序。」
还有一句在 WebRuntimeConfig 的注释里:「诸如环境变量之类的运行期覆盖,必须喂给这同一组字段,而不是引入一条隐藏的优先级链。」
这两句话决定了后面所有行为:「装了两个 provider 但都没配」是一个错误,不是「随便挑一个」。
2 照着真实 provider 裁一个骨架
packages/web/web-search-exa/ 是最好的参照,它一共只有 11 个文件。你的树外版本可以更薄:
/** Cordis 插件名。 */ export const name = 'my-search' /** 它注册进去的那个 seam —— 注意:不是拥有它。 */ export const inject = ['web'] class MySearchProvider { constructor(options) { this.options = options } get id() { return 'my-search' } // 契约要求:廉价、纯本地、不发网络请求 available() { return this.options.apiKey !== '' } async search(request, signal) { const res = await fetch(this.options.baseURL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ q: request.query, n: request.maxResults }), signal, // ← 契约要求:尊重取消 }) const data = await res.json() return { sources: data.items.map(toSource) } } } export function apply(ctx, config) { ctx.web.registerSearchProvider(new MySearchProvider({ apiKey: config.apiKey ?? process.env.MY_SEARCH_KEY ?? '', baseURL: config.baseURL ?? 'https://example.invalid/search', })) }
apply 里没写 ctx.effectregisterSearchProvider 自己就返回 disposer,并且跟着调用它的 fiber 一起被处置——这是「注册即 effect」在 API 层面的样子:注册表把撤销责任接过去了,调用方不用写清理代码。
3 把默认值放在正确的位置
上面那段 config.apiKey ?? … ?? '' 看着平常,其实踩在一条明确的规则上。AGENTS.md:
「包边界上显式优于隐式:默认值填充是拥有方实现里一个显式的 resolve(request): Spec 步骤,而绝不是 run() 里藏着的一个 ?? default。」
看真实的 exa 包怎么做的:所有默认值都在 apply() 里一次性解析完,然后把一个完整的 options 对象交给 provider 构造函数。search() 里再也见不到 ??。
这条规矩的收益在调试时才显现:「这个值是从哪来的」永远只有一个答案,就是那个 apply。而不是散落在三层调用里的兜底。
另外,Config 应该用 schema 声明(官方用 @deepseek-ai/schemastery 的 z),这样它会自动进 生成式 config catalog,而且非法配置会在加载时大声失败。
4 装上、选中、验证
$ dsh plugin --profile web add ~/my-search-provider
- id: web config: searchProvider: my-search # ← 必须是你 provider 的 id - insert: - id: my-search name: my-search-provider
然后跑一个需要联网的问题。三种失败你应该都能认出来:
| 错误码 | 你干了什么 |
|---|---|
WEB_DUPLICATE_PROVIDER | 你的 id 跟已注册的撞了 |
| 配置的 provider 不存在 / 不可用 | searchProvider 写错了,或者 available() 返回 false(多半是 key 没传进去) |
| 可用 provider 有歧义 | 你没配 searchProvider,但同时挂了两个可用的——这是设计内的大声失败 |
5 如果你要把它提到树内:还欠什么
树外插件到这里就完事了。但如果你打算给仓库提 PR,M7.3 那张清单上的每一行都得补齐。对一个 provider 来说,最容易漏的是这三样:
- 双语 README +
README.i18n.yaml。那个 yaml 里是两侧的 git blob hash,verify-translation-pairing靠它发现「只改了一边」。 - Model Experience 一节。provider 后端用审计过的短式就行——一句
Indirectly, through …加一个 KV Cache 字段。决策记录明写着:「provider 后端使用间接形式,即便它们会对数据做上限或过滤。」 - 不变量伴生插件。如果这个包没有独立的事件序列或可变数据关系,就写一个带解释的空实现——「我检查过了,这里没有」比「我没写」多出来的信息,正是 review 要的。
还有一条容易忘:e2e 测试要按自己的 key 自跳过。官方的 exa 包就是这么写的(tests/exa.e2e.ts 在没有 EXA_API_KEY 时自跳过),这样无密钥的 CI 和无密钥的贡献者都不会被挡住。
同样这件事,在别家要几步?
id / available() / search()。接口是公开的、有文档的、有错误码的,而且官方自己就有三个实现——说明它真的被当作可替换点在维护。
待核对
待核对
Lab 5 · 让 agent 现场造一个工具——这次不是你写插件,是模型自己写。25 分钟。