跳到主要内容

内容阅读

web-cordis:dsh 里会改自己插件树的 agent

web-cordis:dsh 里会改自己插件树的 agent

web-cordis 让 agent 在运行时往自己的 Cordis 进程里挂载模型写的插件:看清当前活着的插件树,定义一个新包,跑起来,停掉,忘掉,全程不碰磁盘、不重启进程、不改任何配置文件。安全骨架是两阶段设计,define 只做语法检查和登记,run 才产生效果,所有副作用集中在一个可以干净回退的点上;代码进浏览器只走一条显式拉取通道,事件永远只带元数据。 信任立场要先说死:vm 沙箱隔离全局变量,但不是安全边界,host 半边的助手函数让包代码可以到达 Node,加载这套工具集等于给 agent 一个 bash 工具。它是插件的草稿纸,不是插件的替代品,实验要固化就走常规开发流程。

自指是什么:把扩展从开发时搬到运行时

dsh 的一切能力都是 Cordis 插件挂上去的:模型适配器、工具注册表、会话日志、agent loop,全是插件。正常情况下你扩展这个系统的方式是编辑 cordis.yml、重启进程。这是开发时的扩展,动手的是人,时机是停机。

自指(self-referential)指的是另一种情况:agent 在对话进行中,通过一次工具调用,往当前进程的内存里挂一个它自己写的插件。这个插件挂上去立刻活起来,可以注册工具、贡献 prompt 片段、监听事件,和任何正常插件没有区别。不需要重启,不需要改文件,不需要装包。

这个词听起来像递归玄学,落到工程上很具体:五个动词、一个注册表、一个沙箱、一组事件。它是"一切皆插件"架构的终极推论:如果连 agent loop 都是插件,那 agent 自己就该能编辑这个插件组合。这个推论能成立,靠的是 Cordis 的注册全部是可逆副作用,挂载和卸载是干净的运行时操作,fiber 销毁时按序撤销其中所有注册。没有这条地基,运行时改插件树就是把进程往不稳定里推。

两个包的分工:工具是薄壳,能力是服务

这套功能拆成两个包。@deepseek-ai/dsh-tool-cordis 提供五个模型可见的工具(cordis_inspectcordis_definecordis_runcordis_stopcordis_undefine),它只负责把模型意图翻译成对运行器的调用,自己不持有状态,不跑任何代码。@deepseek-ai/dsh-cordis-host-runner 提供真正干活的 ctx.dynamicCordisRunner 服务,持有定义注册表、vm 沙箱、fiber 生命周期、invoke 处理器表。

两者用 inject 声明咬合:组合里挂了工具包但没挂运行器,五个工具不会激活。这是 dsh 一贯模式的又一次重复,工具是薄壳,能力是服务,工具负责对模型暴露,服务负责持有机制。

部署方式是在 cordis.yml 的 patch 层插一个 insert 块,块里两个条目:运行器和工具包。pnpm run demo:cordis 一行起 Web 界面,默认 3081 端口(避开常规 demo 的 3080),因为它是一个 patch overlay 叠在 web profile 上;另有 pnpm run demo:cordis acp 起 ACP 自动化服务器形态。两个命令都要 DEEPSEEK_API_KEY

五个动词覆盖一个完整生命周期

模型操作动态包的全部动作由五个动词覆盖,先逐个看清。

cordis_inspect 是模型的眼睛。默认报告当前进程里的 services、所有活着的 plugin fiber、已注册的工具、当前会话的动态包。指定 what: "api" 看某个服务的完整方法签名,what: "events" 看事件契约,what: "client" 看浏览器端可以贡献 UI 的 slot 接口。宽泛的 api/events 报告只渲染摘要和签名,省略 JSDoc 以保持紧凑;精确到名字的查询才带全文 JSDoc。查一个不知道的或没在跑的目标,结果是大声失败,不是含糊的空报告。

cordis_define 记录一个包定义。模型提交包名、用途、host 半边代码(服务端跑)和可选的 client 半边代码(浏览器跑)。两个半边都先做语法检查,编译但不运行,通不过就拒绝,连 id 都不发。通过后拿到一个 dyn-<n> 形式的 id,对话里出现一张卡片,带启动控件。这个 id 同时落在工具结果值和持久的展示元数据上,这是回放会话时卡片仍然能对上后续 run 动词的原因。

cordis_run 让包活起来。host 半边在 vm 沙箱里求值,在 cordis-dynamic group fiber 下运行;带 client 半边的包在这一步变成一个需要人确认的往返,下一节专门讲。重复 run 一个已经在跑的包不报错,而是重新投递当前活的版本,浏览器页面刷新之后就是这样找回包的。

cordis_stop 停掉一个运行中的包:丢弃处理器,把 host 半边的 fiber 销毁到平静(quiescence),广播 retract 通知。定义保留,可以再跑。

cordis_undefine 在包还在跑时先停,然后忘掉定义。卡片留在对话里,作为一条未加载的记录。

五个动词的拒绝方式也统一:每一次拒绝都是一个工具错误,错误文本由运行器的教学文案携带,告诉模型下一步该怎么改。模型在这个子世界里拿到的反馈和它在 dsh 其他地方拿到的一样,是可以据以行动的说明,不是一句裸报错。

定义、运行、停止、卸载,加一个只读的观察,五个动词不多不少。

两阶段:define 只记录,run 才有效果

这个两阶段设计是整个功能的安全骨架,值得把它挡掉的东西逐条数一遍。

define 阶段只做三件事:检查元数据、对两个半边做语法编译、发一个 id。没有任何需要回滚的副作用。语法不对的代码在 id 存在之前就被拒绝,对话历史里不会出现一个"定义了但坏了"的幽灵包。设计文档把验证时机点名为核心要求:一个畸形的工具 schema 必须在注册时失败,不能等下一次请求把它拼进 prompt 时才炸。边界上 harness.defineTool 在 host 领域重建 schema,注册表在观测发生前执行工具输出契约;挂载代码通过 ctx.tools.get 只拿得到 schema 视图,绕不过 ToolRuntime.execute

run 阶段才产生效果,这让 stop 变得干净:只回退 run 时建立的注册,define 的登记不需要动。运行中的包如果注册了工具、prompt 片段或监听器,后续请求就带着这些贡献跑;cordis_stopcordis_undefine 在平静之后把它们移除。包的生命周期和它对模型行为的影响完全对齐,跑起来才影响,停下来就消失。

这个对齐还有一个可观测的侧面:工具集变了,请求头跟着变。会话日志通过变更的请求头记录工具集的差异,于是挂载动作在日志里天然可见,是 tool/calltool/result 的一对。对缓存的影响也是实打实的:工具视图不变时工具 schema 的开销前缀稳定,一旦某个包注册了新贡献,KV 缓存复用从第一个变更的贡献开始失效。挂载不是免费的,每一次挂载都是一次缓存断点。

事件只带元数据,代码只走拉取

带 client 半边的包,run 是一个可回答的往返。运行器发出 cordis/request-run 事件,载荷是 requestId、agentId、包 id、名称、用途。没有代码,永远没有。

一个打开的浏览器页面收到事件,显示确认界面。人点了允许,页面先调 runHostHalf 在服务端跑 host 半边,host 半边失败就短路,浏览器半边不会加载;成功后页面通过 getClientCode 拉取 client 半边的源码去加载。代码只通过 getClientCode 这一条路到达浏览器,从不搭事件通知的便车。

为什么把两条通道物理隔离?事件是广播,把代码混进广播等于把通知通道变成注入通道。元数据是展示物,代码是执行物,各走各的路,这是 dsh 在这个功能里守得最严的一条边界。

事件家族里还有一对值得认识:dynamicCordisRunner/packagedynamicCordisRunner/retract,载荷是 {id, name, rev}{id, rev}。每一次新鲜的启动和停止,不管包有没有浏览器半边,这对事件都对称地发一次。客户端靠它维持一个连贯的视图:哪些动态包活着、到了第几个修订版。确认往返只服务带浏览器半边的 run,这对事件服务所有包的状态播报,两者不混。

往返的裁决规则值得细看。第一个回答赢,其他页面收到 cordis/request-run-resolved 后丢掉待处理的确认界面。迟到的或未知的请求 id 被接受但忽略。一个回答如果命名了一个已被取代的 revision,会被拒绝(accepted: false),请求继续挂起,因为那个页面的分派已经陈旧;这种请求最终由另一个页面的回答或调用方的取消来收尾。一个失败的裁决只有在"正是这个请求求值了 host 半边"时才回退 host 半边:一个页面加载失败,不会停掉别的页面正在用的包。归属上还有一个已知粗糙点:runHostHalf 不携带请求 id,归属用的是该定义最近武装的那个请求。

这个往返没有自己的超时。出口只有两个:人回答,或调用方的取消信号(取消会广播出去,让其他页面不再提供确认界面)。没有页面连接的部署里,比如 headless 或 ACP,请求会一直挂到发起的 turn 被取消。无人值守的自动化用不了带浏览器半边的包,这是姿态不是缺陷。

信任立场:沙箱不是安全边界

整个功能最重要的一句话,README 用原话钉死:

The sandbox isolates globals but is not a security boundary.

沙箱隔离全局,但不是安全边界。具体形态是:Node 的全局变量被移除或重定向到 Cordis 服务,ctx.fs(文件系统)、ctx.web(联网)、ctx.bash(命令执行)、定时器助手;写到 globalThis 的操作留在沙箱局部。沙箱全局刻意很小:一个打标的 console、注册工具的一对入口、编码原语(btoa/atobTextEncoder/TextDecoder),再加上对被扣下的 Node API 的可调用陷阱,一调用就抛错,错误里指名 cordis 的替代品。processBuffer 保持 undefined:typeof 探针保持惰性,不会引爆一个抛错的访问器。

挂载插件拿到的 ctx 是一个不带框架内部的门面,它够得到的只有声明过 inject 的服务。这个收窄不是装饰:它让"包代码能碰到什么"有一个可枚举的答案,可枚举才可测试,可测试才谈得上关掉无守卫的 context 逃逸。

但 host 领域的助手函数在沙箱全局上可达,包代码可以借此到达 Node。所以 README 的另一句原话:

Treat this toolset like bash access.

设计文档明确否决过做一个加固的、能力受限的沙箱。否决理由是它误导:门面收窄 API 面是为了正确性和堵住无守卫的 context 逃逸,暴露的服务到达的是真实运行时;真做安全边界会跟这个功能的全部意义打架,它的意义就是把活的运行时交给模型。正确的定位是 opt-in 的开发工具,bash 等价的信任:你不会拿它跑不受信任的代码,正如你不会把 bash 交给不受信任的代码。

会话作用域与进程内存真相

动态包有两个作用域属性,一个是隔离,一个是易逝。

隔离这一面:每个动词都会话作用域,包只在定义它的会话里可见、可控。inventory 是全局注册表视图,每行标着属主会话和有没有浏览器半边,因为运行控制面是全局的;但每个操作动词都查所有权,别的会话定义的包,读出来是不存在,不是禁止。连存在性都不泄漏,这比"知道但拒绝"更干净。snapshot 是会话作用域的 host 本地对账视图,带着每个活 host 半边的 fiber,cordis_inspect 靠它渲染 provides、waiting、state(fiber 过不了线,只能本地看)。

易逝这一面:注册表是进程内存,唯一真相。会话日志只记录 define 的元数据,从不记录代码。进程重启后所有动态包消失,没有自动恢复,没有自动转正。卡片上的 id 不再对应任何定义时,inspect 如实说定义不存在,不假装还能跑。还有一条共享的代价:动态包注册的工具对同进程的所有会话可见,一个会话的挂载可能影响另一个会话的行为,README 对此有明示。

想固化一个实验,README 的指引是让 agent 把它实现成正常的本地、项目或仓库插件,走常规开发流程。动态包不创建插件文件、不装包、不改 cordis.yml,它从头到尾是一张草稿纸。

回放的时候这张草稿纸的边界也很清楚。会话日志里存着 define 的元数据和那张卡片,但不存代码,所以回放出的对话能看到"这里定义过一个包",卡片却是一个未加载的记录:id 对应的定义在当前进程里不存在。inspect 的空态文案把这件事说破,定义只活在进程内存里。回放保真的是对话的样子,不是运行时的状态,这和 dsh"模型可见即可重建"的总纪律并不冲突:卡片和元数据是模型可见的,它们被如实重建;进程内的注册表从来不是模型可见物,也就不在重建义务之内。

只在内存里不是一个偷懒的实现选择,是设计文档点名的正确性要求:挂上去的每一样东西必须完全可丢弃,模型随手能丢,普通插件生命周期也能丢。可丢弃性靠两条腿站着:没有持久化,就没有恢复路径要写对;没有自定义销毁器,就没有卸载语义要追。把这两条中的任何一条换成"顺便支持一下落盘"或"顺便允许注册清理钩子",停干净这件事就从结构保证退化成约定,而约定是会被忘掉的。

模型怎么知道往哪儿挂:两个生成的目录

挂载原语有了,模型还缺地图:当前运行时有哪些服务、每个服务什么签名、浏览器端有哪些座位。设计文档把这一点列为先期成本:模型盲猜一个签名,代价是很多步的盲目探测。

第一份地图是 api-catalog。仓库里所有 Cordis 声明的投影:方法签名、源码 JSDoc、harness 事件的派发模式、类型形状,全部由 AST 遍历生成。它和 docs/subsystems 的文档渲染用的是同一次 AST 遍历,模型读到的和文档渲染的不会分叉,因为它们是同一份数据的两次排版。pnpm run gen-cordis-api 生成,pnpm run verify-cordis-api 做新鲜度门禁。被这个方案替换掉的是手写参考表:签名一变手表就漂移,而且没有任何门禁拦得住这种漂移。

目录里有一个有意识的筛选:每个 ctx.<key> 被分成三类,injectable(可注入)、not-a-service(不是服务)、other-face(另一面,比如浏览器端的 service),只有 injectable 进入报告。命名一个包代码够不到的 key,等于打一个打不通的电话,模型不该在电话簿里看到它。分类是入口处的数据,可以独立测试;verify 门禁钉住分类集合,新声明的 key 没分类就挡在门禁,而不是悄悄邀请模型去 inject 一个永远不会到货的服务。服务存在性的最终权威是运行时的服务库:已分类的 key 只要有活的 provider,照报 running 和 injectable。同类筛选还有一条:只展示可调用的方法,非方法是带初始化器的状态,符号键成员是内部接缝,列出来等于宣传一个打不了的调用。

第二份地图是 client-catalog,浏览器半边可以贡献 UI 的 slot 接口,由 SlotMap 声明合并和 slots.register 调用点的词法扫描生成。它的门禁失败条件列得很硬:缺登记人文案、kind/scope 不是字面量、owner props 没有导出提供、重复 key、注册进一个未声明的 slot,任何一条都会让生成失败,而不是带着残缺条目出货。每个座位的报告有预算上限,owner props 只展开一层。教学文本就是声明处的 JSDoc:想改某段说明,去改声明它的包,不是改目录。

两份地图都是编译时事实,不是运行时反射。模型读到的 API 表和仓库代码同步,因为它是从代码生成的。框架继承的那部分 ctx(ctx.onctx.effectctx.loader、定时器)住在被钉版本的 vendor 包里,AST 分析的视野之外,目录对这一层做手工维护。所以这套机制不是"全自动生成"的神话,是能生成的生成、生成不了的钉死加门禁,两条路最终都落在同一个门禁上。

一次挂载从头走到尾

把一次典型的挂载走完整,看各层怎么接手。

模型在对话里提议一个包,比如注册一个能查内部状态的诊断工具。cordis_define 提交两个半边的代码,语法编译通过,拿到 dyn-3,对话里出现带启动控件的卡片。人点启动(或模型直接调 cordis_run)。host 半边进 vm 求值,在 cordis-dynamic fiber 下把工具注册进注册表。下一个请求的工具清单变了,请求头记录这次变更,会话日志里这次挂载是一对 tool/calltool/result。从此模型的每次请求都带着这个新工具,直到有人调 cordis_stop:处理器丢弃,fiber 平静,retract 广播,工具从清单消失;定义还在,随时可以再 run。

进程在这一切中间重启的话,一切归零:注册表空了,对话还在,卡片变成未加载记录,模型重新 define 一遍就是恢复路径。没有比这更朴素的崩溃语义,也没有比这更彻底的无状态。

如果包带浏览器半边,run 走上一节的往返:事件出去(只带元数据),页面确认,host 先跑,代码后拉,第一个回答裁决。页面里挂载的插件声明它的 inject,是页面从返回的插件对象上读的,不靠公告携带。

失败在这条链上有确定的名字。run 的拒绝理由是一组闭合的码:definition-missinghost-half-failedclient-half-failedrejectedcancellednot-running。前三个是缺陷,后三个是答案:被人拒绝、被取消、本来就没在跑,都不算坏。模型按码决定下一步,重试还是换路,不用从报错文本里猜。host 半边失败发生在浏览器加载之前(host 先跑的顺序就是为这个短路设计的),所以一次失败的 run 不会留下半个包:要么两半都活了,要么什么都没发生。

还有一个跨世界的细节值得记:包提供的服务值是 vm 领域的对象,消费者不能假设它上面有 host 的原型方法。两个世界(vm 领域和 host 领域)之间的声明传递用迭代式 JSON 克隆加 schema 归一化,深声明受内存约束而不是调用栈约束;带 JSON 不可见键的记录、被子类化或装饰过的 schema 数组,在归一化之前就被拒绝。

它会踩自己的雷:真实的坑

这套功能最微妙的地方,是挂上去的代码运行在 agent 自己的执行路径上,坑都从这一点长出来。

一个 waterfall 监听器返回时没调 next(),链条被短路,这个挂载可以停掉 agent 自己的工具分派。挂载代码本身跑在一次工具调用里:await 一个只有 turn 结束才会 resolve 的东西,直接死锁。

run 的回执不等于渲染成功。run 在页面加载了 client 半边后就返回,React 渲染发生在之后,一个组件抛出的异常不会出现在 run 的回执里。渲染失败走 reportRenderFailure 异步报告:火后不理,没有裁决权,永远不碰 run 的结果;host 侧每个定义保留最后一次失败(第二次报告覆盖第一次),重跑、stop、undefine 会清掉它;非属主会话发来的报告被丢弃。读回来的办法是 cordis_inspect what:"temporary"

vmTimeoutMs 默认 5000 毫秒,约束 host 半边的同步求值,一个 async 的包体逃逸这个限制。它是运行器唯一的配置字段。ctx 门面不暴露 effect(),包代码注册不了自定义销毁器,支持的清理路径只有 onprovidetools.register 三种。这是有意的限制:自定义销毁器会让卸载语义不可控,而可卸载性是这个功能的存在前提。

调用方向也只有一条:浏览器半边可以通过 invoke 调 host 半边用 harness.handle 注册的方法,host 到浏览器没有方向。想从服务端主动推东西给页面,没有这条路。

被否决的方案

设计文档里躺着四个被否决的替代方案,每个都值得看一眼否决理由。

按能力拆结构化注册工具,比如 cordis_register_tool。它唯一的收益是最常见场景省一点插件样板,代价是工具面按能力种类无界增长。单一挂载原语用一条路覆盖所有能力(工具、监听器、服务、inject 关系),一个沙箱、一条归一化路径、一处受守卫的注册。文档留了活口:将来如果需要,可以回来加一个糖,用生成挂载代码的方式实现,不动原语。

手写的服务/事件参考表,被生成的目录替换,理由是漂移无门禁。

专门的 cordis/mount 会话事件,v1 不做:挂载已经作为 tool/calltool/result 对可见,工具集变更已经由请求头变更记录,再加事件是重复记账。审计需求真出现了再加。

加固沙箱,在信任立场一节讲过,误导且与功能意义相悖。

权衡与局限

把代价摆在一起看。临时性:重启清零,没有保存、转正、安装路径。进程共享:一个会话的挂载影响同进程所有会话。无页面的部署里带浏览器半边的包挂死到 turn 取消。异步包体逃逸 vmTimeoutMs。渲染失败是异步的、不进 run 回执。信任等于 bash,装载决策要按装载 bash 的标准做。

换来的是插件实验的零摩擦:不动磁盘、不重启、不装包,一个想法从对话到运行只有一次工具调用的距离,停下即消失,不留残骸。对探索"这个能力挂上去会怎样"的问题,这是最短的回路;对要长期存在的东西,它从一开始就说清楚了不接收。

模型侧还有一笔账要看。五个工具的 schema 开销固定、前缀稳定,工具视图不变时这部分在请求之间可以复用;变化的是数据:inspect 的输出、define 提交的代码,都是数据相关内容,会一直重发直到压缩把它们收走。生命周期确认都很小。client 报告按已交付的 slot 数封顶,每个座位两行,要细节得按座位点名。换句话说,用这套工具的边际成本集中在读目录和写代码那几步,不是每轮都付的固定税,但一次长实验里反复 inspect 的累积成本,值得在决定"让模型自己改自己"之前算一算。

结论

web-cordis 把"一切皆插件"推到终点:agent 用五个工具在运行时编辑自己的插件树,define 只记录、run 才生效,代码进浏览器只走 getClientCode 的显式拉取,进程内存是唯一真相,重启即清零。模型能上手靠的是两张从代码生成的地图,不是运行时反射。信任立场要记牢:vm 沙箱不是安全边界,加载这套工具集等于给 agent 一个 bash。它是插件的草稿纸,不是插件的替代品;想留下的东西,让 agent 把它写成一个真正的插件,走常规开发流程。

延伸阅读

上一篇:ACP 协议与 acp-agent:dsh 的 agent 通话标准 下一篇:配置、凭证与存储:dsh 的有状态底座三件套


GitHub 原文:34-web-cordis-self-referential-agent.md

评论

0
登录后可以参与评论和讨论。

EMPTY

还没有评论

欢迎留下第一条评论,帮助这篇内容更快形成讨论。