新功能怎么接进来
这是Pi《源码精读》的第八集,新功能怎么接进来?这次的例子,改自包里的第六和第三十个示例。第六个示例装了三包代码:files 里是读文件、写文件两个工具,search 里是一个搜索工具,snippets 往系统提示词里加一段工具说明;第三十个示例用了另一包代码,名字叫 versioned,只放一个 version 工具,却把装扩展、换代码、重启都演了一遍:对话存进 SQLite,趁调用跑到一半换代码,再重启一次。
先看第三十个例子。对话里选了 versioned,存储里就记着这个名字。后来我们趁 version 工具还没返回,把同名扩展换成 v2,再把程序关掉重开。名字一直没变,代码却换了两回。这一包有名字的代码,就叫扩展,新功能都装在扩展里:工具、系统提示词的段、钩子、包装、任务,都是扩展的字段。工具还能标 deferred,先不给模型看,等 modelTools 点名再放出来;callers 则能把它限制成只给模型调,或只给别的工具调。工具的参数用 pi-schema 的 obj()、str() 写,defineTool 的 args 由它的 Static 推导。
图 8-1 · 扩展是一个有名字的代码包:工具、提示词段、钩子、包装、任务都是它的字段
扩展写好了,要装进注册表。注册表就是程序放扩展的地方,一般在启动时装好,也可以随时换;换代码,就是再装一个同名对象。装一个同名的扩展,会把原来那个整个换掉,位置不变,一次换完,中间不会出现没有它的时刻。看代码。
源码对照 · packages/durable/src/harness/registry.ts:73
install(extension: Extension<Tool>): void { validateExtension(extension); const current = this.#current.installed(); const index = current.findIndex((installed) => installed.name === extension.name); const next = index < 0 ? [...current, extension] : current.map((installed, at) => (at === index ? extension : installed)); this.#publish(next); }按名字找到旧的那一格,整格换成新对象;找不到才追加。publish 一次换掉整份快照,所以换代码不是打补丁,是整包替换。
图 8-2 · 同名扩展按 name 找到旧格,整包原位换掉,一次 publish 换完整份快照
第六集讲过,系统提示词的那几段,就是从扩展里来的。开场白和工作目录来自 coding,那条规矩来自另一个扩展 agents-md;terse 只再包装 coding 的开场白,不单独占一段。
图 8-3 · 开场白和工作目录来自 coding,那条规矩来自 agents-md;terse 只再包装开场白
注册表只在程序里,不存进存储。对话要选用扩展时,存下的只是名字,不是代码;不挑,扩展这一栏就空着,默认用 host 的 settings.extensions,它没设时才是所有装好的扩展。第六个例子就是后者:files、search、snippets 三包都装好,先不选扩展;第一次 configure 之后,pi.agent 里只有模型、thinkingLevel 和工具名,没有扩展这一栏;第三十个例子明确选了 versioned,存下的就是那个名字。看代码,不管传进来的是对象、数组,还是 add 和 remove,落盘之前都先取出 name。
源码对照 · packages/durable/src/harness/agent.ts:130
const extensions = change.extensions; set( "extensions", extensions === undefined || extensions === null ? extensions : isList(extensions) ? names(extensions) : { ...(extensions.add === undefined ? {} : { add: names(extensions.add) }), ...(extensions.remove === undefined ? {} : { remove: names(extensions.remove) }), }, ); // …configure() 和 root() 的 agent 选项最后都走到这里,names() 把扩展对象变成字符串数组;扩展这一项落盘时,只有名字。
图 8-4 · 对话里只存扩展名:选了写名字,不选就空着,默认跟随 host 的 settings.extensions,没设时用全部已装扩展
每次要用,就拿这个名字去注册表里找。找到了才用,找不到就跳过。程序重启以后,存储里还是那个 versioned;我们查一下。
存储实况 · SQLite(示例 30 的保留库;示例本身用临时目录,跑完会删除库)
SELECT d.id, d.kind, json_extract(r.content, '$.model') AS model, json_extract(r.content, '$.extensions') AS extensionsFROM documents d JOIN document_revisions r ON r.document_id = d.idWHERE d.kind = '"pi.agent"' AND r.kind = 'base'ORDER BY r.seq DESCLIMIT 1;| id | kind | model | extensions |
|---|---|---|---|
| 6 | “pi.agent” | {“provider”:“faux”,“modelId”:“faux-1”} | [“versioned”] |
这个例子一次跑完:先关掉第一个 Harness,再用空注册表打开同一个库,模拟新进程。在保留库上,重启前后各查一次,都是这一行。整条记录里没有工具定义,也没有 execute 函数,只有一个模型名和一个扩展名。
图 8-5 · 重启后注册表是空的,名字查不到就跳过;装上 v3,工具才回来
可新程序没装它,所以一个工具都没有。重启后先打印 [],把 v3 装上,再打印 [ 'version' ]。那换代码的时候,正在跑的工具会怎样?例子里,v1 的工具调用还没返回,我们换上 v2 再放行,这次调用返回的还是 v1;下一次提问,才返回 v2。看代码。
源码对照 · packages/durable/src/harness/scheduler.ts:2616
#runtime(invocation: Invocation, phase: Phase): ErasedRuntime { // Resolved at most once per phase handler, at first use, with the invocation's context; fixed for the phase. const agent = (): Promise<Agent> => { if (invocation.ended) return Promise.reject(endedError(invocation)); if (phase.agent === undefined) { phase.agent = this.#agent(invocation.conversationId, phase.snapshot(), invocation.context); // A caller that stops waiting must not leave the shared resolution's failure unobserved. phase.agent.catch(() => {}); } return phase.agent; }; // …每个任务阶段(代码里叫 phase)第一次用到 agent 时才解析,而且最多一次,之后固定在这个阶段里;阶段跑完、做下一次决定时才换新的注册表快照。所以一次调用不会被半路换掉,下一次才用新代码。
图 8-6 · 跑着的调用用旧快照跑完,下一次决定才换新代码
为什么不把代码也存进存储,只存名字有个好处,代码可以随时改,随时升级,存储里的对话,却不用跟着动。换了新代码,新程序启动时,把扩展再装上,对话就能接着用,用的是新代码。这一集记住一句话,新功能装在扩展里,代码不进存储,对话里只留扩展的名字。
图 8-7 · 代码可以随时升级,对话里的名字不用跟着动
下一集我们来看,多个agent是怎么一起干活的,假如你交给agent一大堆资料,他嫌活太多,就叫来另一个agent帮忙,帮手读到一半,你改了主意,按下了停止,主 agent 把那个帮手停了,帮手会不会没人管,接着问模型,接着花钱。