自定义工具、Provider 与渲染器谁覆盖谁
分别追踪 AgentSession tool registry、ModelRuntime provider composition 与 ExtensionRunner renderer lookup,建立同名注册的实际优先级与冲突诊断。
第 36 章反复依赖“extension 顺序”,但顺序不自动等于后者覆盖前者。Pi 至少有三种同名解析策略:tool registry 最终用 map 写入,provider config 做字段合并,message/entry renderer 做 first-match。再加上 ResourceLoader 只报告冲突、不卸载 extension,单说“项目级优先”或“最后加载优先”都不够准确。
本章只讨论固定版本 v0.83.0 的运行时结果。package manager 如何给 user、project、package resource 排序是上游输入;一旦 LoadExtensionsResult.extensions 已经排好,下面三条路径分别消费这份顺序。
Tool:built-in 先入 map,SDK custom 最后写
Runner 先收集 extension tools。它按 extensions 数组从前到后遍历,用 toolsByName.has() 保留某个名字的第一份注册。因此多个 extension 都注册 search 时,靠前 extension 的定义进入下一阶段;后面的仍属于已加载 extension,也会产生 conflict diagnostic,但不会成为该名字的 executor。
AgentSession 随后构造 allCustomTools = [...registeredTools, ...sdkCustomTools]。definition registry 先放 allowed built-ins,再顺序 set 每个 custom definition;execution registry 也先放 wrapped built-ins,再顺序 set custom tools。由 Map 语义可得:extension tool 覆盖同名 built-in,SDK customTools 又覆盖同名 extension;SDK 数组内部若重名,最后一个写入者留下。
这条 precedence 还受 allowlist/denylist 前置过滤。一个 SDK custom tool 虽然理论上能覆盖 read,如果 excludeTools 含 read,built-in 和 custom 两份同名定义都会被 isAllowedTool 过滤。覆盖规则只在候选进入 registry 后成立。
flowchart TD
accTitle: 三种同名注册的不同解析规则
accDescr: Tool 先按 extension first-wins,再由后写的 SDK custom 覆盖;Provider 对同一 config 做字段 merge;Renderer 按 extension 顺序查到第一项即返回。
TOOL["tool name"] --> T1["first extension registration"] --> T2["SDK custom last write"]
PROVIDER["provider id"] --> P1["compose builtin/config/extension"] --> P2["later defined fields merge"]
RENDER["customType"] --> R1["scan extensions in order"] --> R2["return first renderer"]
工具 definition 还携带 promptSnippet、promptGuidelines、renderCall 和 renderResult。覆盖同名 tool 时,AgentSession 的 definition registry 与 executor registry来自同一胜者,所以模型说明、执行函数和工具自身 renderer 一起替换;不会出现“执行 extension read,却沿用 built-in read prompt snippet”的有意混搭。
Provider:同一 id 是 composition,不是整对象替换
extension 在 factory 中调用 registerProvider(name, config) 时,注册先排队;Runner bind 后按顺序交给 ModelRuntime。registerProvider() 先独立校验新 config,再把它的 defined fields 覆盖到 previous extension config 上,值为 undefined 的字段不擦除旧值。因此两个 extension 先后为同一 provider 注册 {baseUrl} 与 {headers},最终 extension overlay 同时保留两者;后者再提供新的 baseUrl 时,才替换该字段。
ModelRuntime 还会把 overlay 与 built-in provider、models.json config 交给 composer。源码的 recomposeProvider() 以 native extension provider 或 built-in 为 base,再叠加配置与 extension overlay;composition 失败时记录错误,并回退到 base 或删除没有 base 的 provider。不能把一次 registerProvider("anthropic", {baseUrl}) 写成“整个 Anthropic provider 被 extension 接管”。
native Provider 与 config overlay 的切换更强:registerNativeProvider() 会删除同 id 的 extensionProviders,registerProvider() 会删除同 id 的 nativeExtensionProviders。也就是说,跨注册类型时后一次决定 extension 层采用 native base 还是 config overlay;在 config 类型内部才是逐字段 merge。
注册完成后会更新 model snapshot,并异步执行 refresh({ allowNetwork: false })。同步 registry 读可以先看到 provisional composition,依赖 catalog refresh 的完整结果则需要 await 相应刷新路径。扩展 command 里刚 register 就立即发请求时,仍要理解这一层同步/异步边界。
Renderer:按 extension 顺序取第一项
registerMessageRenderer(customType, renderer) 与 registerEntryRenderer 把函数写进各自 extension 的 map。Runner 的 getter 从 extensions 开头扫描,找到第一项立即返回,没有后写覆盖,也没有合并。它与 extension tool 的 first-wins 前半段一致,却没有 SDK custom renderer 的第二层覆盖。
renderer 只影响展示投影,不改变 custom message 或 custom entry 的持久数据。interactive mode 查询 Runner renderer 后构建 TUI component;print/JSON/RPC 有自己的输出协议,不能因为 TUI 能渲染一个 customType,就假设所有 mode 都得到同样视图。
冲突 diagnostic 不负责裁决
ResourceLoader 的 detectExtensionConflicts() 只检查 extension 之间重复的 tool 和 flag,把 message 加入 extensionsResult.errors。addExtensionConflictDiagnostics() 的注释明确保留所有 extensions,并把 precedence 留给 load order。它没有检查 provider id 或 renderer customType 冲突,也不会检查 SDK customTools 对 built-in 的覆盖。
可以把 precedence 复核成三条源码查询,不运行 extension:
repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" grep -n 'getAllRegisteredTools\|const allCustomTools\|const toolRegistry' \
v0.83.0 -- packages/coding-agent/src/core
git -C "$repo" grep -n 'const effective: ProviderConfigInput\|nativeExtensionProviders.delete' \
v0.83.0 -- packages/coding-agent/src/core/model-runtime.ts
git -C "$repo" grep -n 'getMessageRenderer\|getEntryRenderer' v0.83.0 -- \
packages/coding-agent/src/core/extensions/runner.ts
读完结果后,结论不应该压缩成一句“后加载覆盖先加载”:Tool 是 extension first、SDK custom last;Provider config 是 defined-field last merge,native/config 类型互相替换;Renderer 是 extension first。命令重名又采用 name:1、name:2 暴露多个 invocation,不属于上述任何一种覆盖。
第六部到这里完成了从 built-in definition 到扩展投影的整条装配链。下一部不再增加能力类型,而是改变宿主入口:同一套 AgentSession 若不启动交互 TUI,SDK、print、JSON 与 RPC 分别保留哪些控制面,又丢掉哪些 UI 合同。