一组插件声明,怎样变成一个可共享的 Agent 配置
AgentPresetRegistry 把一组 Cordis 插件声明打包成一个预设:注册时把它挂载进一个独立 scope 的常驻树,多个 Agent 共享同一份插件实例;会话空白时可选预设,一旦开了头就锁死。预设服务必须躲在 isolate realm 里,否则 mountPreset 直接拒绝。
本章以中文撰写
同一个 host 进程里,一个会话想要”全套编码能力”,另一个会话只想”轻量问答”。这两套配置不是两个进程、也不是两份要分别维护的 cordis.yml——它们是同一个注册表(AgentPresetRegistry)里的两个预设,每个预设是一组插件声明。这一章讲预设怎样从”一组声明”变成”一个被多个会话共享的常驻插件树”,以及为什么会话一旦开了头就不能再换预设。
预设不是目录发现,是注册进来的声明
和技能(磁盘扫描)不同,预设不是从某个目录里扫出来的。AgentPresetRegistry 维护一个 definitions: Map<id, Definition>,预设由某个 Cordis 插件用一个 AgentPreset 行注册进来——那一行的 config 就是 {id, name, description, order, plugins},plugins 是一串 Cordis 插件条目。
本节源码依据(4 处)
内置预设就是这么声明的:standard(order 1,全套编码:persona + bash/fs/search + 规划/压缩/委派三个 isolate 组)、ptc(order 2)、minimal(order 3,只带 persistent-shell)、cordis(order 4)。它们各自是 packages/bundle/web-app/presets/*.patch.yml 里的一行 dsh-agent-preset 声明。
挂载:一组声明变成一棵共享常驻树
注册之后,activate 为这个预设建一个独立 scope、把它的子插件装进一棵 PresetTree,挂载成一个 PresetMount。关键是”共享常驻”:这棵树只建一次,之后选中这个预设的每个 Agent 都 join 到同一棵树上,而不是每人挂载一份。
本节源码依据(5 处)
为什么共享一棵树而不是每会话一份。 如果每个会话都把自己那套插件重新挂载一遍,几十个会话就是几十份重复的 tool-bash、skill-filesystem——内存和启动时间都吃不消。共享常驻树把”插件实例”和”会话”解耦:树只建一次,会话只是 join 上去。generation.users 就是共享的代价与边界:有人用就计数,没人用了(且已 retired)才 dispose。这和进程里的连接池、单例是同一个权衡。
绑定与代次:users 引用计数管理生死
Agent 要用上一个预设,得 bind 到那一代的 standing scope;离开时 users--。collect 只在”已 retired 且 users===0”时才真正 dispose——正在被会话用的那一代,哪怕预设被卸载了,也要撑到最后一个会话放手。
本节源码依据(4 处)
空白可选、开轮即锁:会话的预设冻结点
一个会话还没说过话时,可以选/换预设;一旦开了第一轮,配置就冻结——因为这时候换预设,等于模型的工具 schema 和系统提示突然变了,对话历史和新上下文对不上。
本节源码依据(4 处)
为什么冻结点要卡在”开第一轮”而不是”创建时”。 刚创建的会话还是一张白纸,这时候换预设没有任何历史要兼容;一旦开了第一轮,模型已经基于这套工具 schema 和系统提示开始说话了,中途换预设等于给它一套全新的手脚、却让它继续之前的话头——会错乱。select 查 turnBoundary 就是这个判断点。agent-preset/selected 还会写进会话日志,所以 resume 一个旧会话时能把它恢复到当时那套预设,而不是默认值。
isolate realm:预设服务不许泄漏到根
预设的子插件可能提供服务。如果它不加隔离地把服务注册到 root realm,就等于一个会话的预设往全局撒了个服务——别的会话、host 都会看到,预设的”隔离性”就破了。mountPreset 挂载完用 leakedServices 扫一遍,发现泄漏直接拒绝挂载。
本节源码依据(4 处)
| 预设能力 | 怎么共享/隔离 |
|---|---|
| 插件实例 | 一棵常驻树,多 Agent join 共享(users 计数) |
| 服务可见性 | 必须 isolate realm,泄漏到 root 即拒绝 |
| 配置写回 | PresetTree.write() 空实现,不写回磁盘 |
| 会话绑定 | 空白可选,开轮锁死,selected 写日志可恢复 |
| 子 Agent | composeFrom 继承父同一代,不另起 |
serviceForAgent:预设外面怎样读到 isolate 里的服务
预设的服务躲在 isolate realm 里,host 上的普通 ctx.xxx 寻址是够不到的——这正是隔离的目的。但浏览器 RPC 需要读某个会话的 plan-mode 状态、压缩进度,这些状态恰好存在预设的 isolate 服务里。serviceForAgent 就是那把”受控的钥匙”:它拿着一个 Agent 引用,反查这个 Agent 加入的那一代 mount,再在服务存储里找 fiber 属于这棵子树、且名字匹配的实现。
本节源码依据(4 处)
为什么是”只读寻址”而不是开放句柄。 如果 host 能随便拿到任何会话的内部服务,预设的隔离就形同虚设。所以 serviceForAgent 把钥匙绑死在”你已经持有这个 Agent”这个前提上:浏览器 RPC 拿着它当前会话的 Agent 来问,才读得到这个会话自己的状态;它拿不到别的会话的、也拿不到 host 全局的。这和 leakedServices 是同一套 fiber 归属判断的正反两面——那边数”漏到根的服务”,这边数”属于这棵子树的服务”,判据一致,方向相反。
| 方向 | 函数 | 数什么 | 干什么 |
|---|---|---|---|
| 向外(防泄漏) | leakedServices | 子树发布到 root realm 的服务 | 挂载时发现即拒绝 |
| 向内(受控读) | serviceForAgent | 子树发布到任意 realm 的某命名服务 | 持 Agent 引用只读寻址 |
读侧每次都重审计:roster 不是快照
预设挂载时审计过一次就万事大吉了吗?不是。list/resolve 这些读路径每次都重新跑一遍审计——因为 Host 的插件树是异步 settle 的,挂载那一刻某些 row 可能还在等依赖,过一会才就绪或才报错。读侧不能缓存一份”挂载时的结论”,否则会把一个后来才坏的 row 一直报成好的。
本节源码依据(3 处)
为什么读侧不缓存。 缓存审计结论的诱惑很大——审计要遍历服务 store,看起来不便宜。但预设的 row 状态是随时间变化的(依赖后到、异步报错),一份”挂载时拍的快照”必然在某个时刻失真。读侧重新审计的成本,换来的是”用户在 UI 上看到的永远是当前真实状态”。这是把正确性放在性能前面:roster 是给人看的诊断面,不是热路径。
小结
Agent preset 把一组插件声明打包成一个共享常驻树:声明注册进来、activate 建独立 scope 的树、多会话 join 共享、users 计数管理生死;空白会话可选、开轮锁死;预设服务必须躲 isolate realm,泄漏即拒。下一章离开第六部——第 43 章看这套运行时怎样无头地跑在命令行里。