周毓杰

不只是给 Agent 装工具:Open Nua 如何把 Plugin 变成领域产品

Open Nua Engineering

很多 Agent 产品把“插件”理解成一个工具列表:接入几个 API,写一段 Prompt,再让模型决定何时调用。

这足以做出演示,却很难交付一个长期运行的领域产品。工具从哪里来?谁可以调用?一次查询和一次删除是否应该经过同样的确认?领域界面如何获得数据?本地状态如何升级?插件停用后,Skill、工具和界面是否会一起消失?一个压缩包又如何被安装、校验和审计?

在 Open Nua 中,Plugin 不是“更多工具”的同义词。它是一个可版本化的交付单元,把领域知识、受控能力、Agent 定义、用户界面和状态契约组合起来,再交给 Desktop、Gateway 和 Hub 分别执行各自拥有的那部分。

这篇文章解释这套体系为什么这样设计,以及一个 Plugin 如何从归档包一路变成用户眼前的领域工作台。

最初的问题:一个 runtime kind 承担了太多职责

早期实现把官方 Plugin 分成 remote_mcplocal_apppython_agent 三类。这个分类看起来直接,实际却混合了几个完全不同的问题:

  • 能力由远程服务还是本机 Host 提供;
  • Plugin 是否拥有 App;
  • Agent 能调用哪些能力;
  • 状态存在哪里;
  • 是否要替换 Desktop 的主 Agent 执行图。

很快,真实产品就打破了这个模型。Vibe Trading 和 DeepTutor 的能力来自远程 MCP,但它们同样拥有完整工作台;抓娃娃的状态完全留在本机,也拥有同一种 App surface。是否有 UI,显然不应该由“远程”或“本地”决定。

python_agent 的问题更深。它不是一种普通能力,而是允许 Plugin 替换整条 Python 主 Agent 图。没有官方 Plugin 真正需要这条路径,它却迫使 Desktop、Bridge 和安装系统长期维护一个特殊执行分支。

我们最终删除了这组顶层类型,把问题重新拆开:Plugin 是一种资产;Skill、Capability、App、Agent Definition 和 runtime adapter 是彼此正交的贡献。

一个包,两层契约

Open Nua Plugin 的根目录使用 Agent Plugins v1 作为可移植核心:

my-plugin/
├── PLUGIN.md
├── plugin.json
├── skills/
│   └── my-plugin/
│       └── SKILL.md
└── com.opennua.desktop/
    ├── agents/
    └── ui/

plugin.json 只保存标准身份、版本、描述和许可证等元数据。标准 Skill 固定放在 skills/<name>/SKILL.md;只有确实要向通用 Agent Plugins 客户端暴露标准 MCP endpoint 时,包里才需要根 mcp.json

Open Nua 专有语义全部进入 extensions.com.opennua.desktop:受控 Gateway 路由、本地状态、Capability、Agent Definition、声明式 UI、App 入口和签名资源都在这里定义。普通客户端可以忽略不认识的 extension,仍然发现并读取标准 Skill;Open Nua Desktop 则能把同一个包激活成完整领域工作台。

这里也有一条容易被误读的当前边界:Hub 能校验根 mcp.json,但 Desktop 不会用它建立 Open Nua runtime,四个官方包目前也都没有这个文件。Open Nua 的远程执行链只读取已签 extension 中的 gateway_mcp,根 mcp.json 只是为通用客户端预留的 portable discovery,而不是绕过 Gateway 的第二条通道。

这个边界很重要。可移植标准不需要假装理解某个桌面客户端的沙箱、状态或界面协议;客户端专有能力也不需要污染 Plugin 的通用身份。

Open Nua Plugin 从包到领域工作台的受控路径

Skill 告诉 Agent 怎么做,但不授予执行权

Skill 和 Plugin 经常被当作同一个概念。在 Open Nua 中,它们刻意分离。

Skill 是给 Agent 阅读的操作知识:何时使用某项能力、按什么步骤工作、输出要保留什么证据、哪些行为必须停止。它可以只依赖已有工具,不需要任何新 runtime。

Plugin 则拥有版本、安装、启用、Capability、App 和状态等产品语义。一个 Plugin 可以贡献多个 Skill,但读到一份 SKILL.md 不代表获得了任何网络、文件、账户或工具权限。

OpenCLI 就是当前官方包中的纯 Skill Plugin:它贡献适配器编写、浏览器驱动、sitemap 和自动修复等操作知识,却不声明 Open Nua runtime adapter。这个例子说明,Plugin 是分发和治理边界,runtime 并不是成为 Plugin 的前提。

Capability 是整个体系的最小安全单元

真正把 Agent、App 和底层实现连接起来的,不是工具名,而是 Capability 声明。每项 Capability 都要明确回答:

  • provider:能力由 gateway_mcp 还是本机只读上下文提供;
  • target:最终映射到哪个受控操作;
  • audience:Agent、App,还是二者都可调用;
  • operation:查询还是变更;
  • risk:风险等级;
  • requiresConfirmation / requiresHitl:是否需要用户确认或人工介入;
  • idempotency:变更操作如何处理重复执行。

例如,“读取学习路径”和“删除学习路径”可能来自同一个 MCP 服务,但它们不是同一种授权。前者可以是低风险查询,后者则是高风险、需要确认且要求幂等的变更。

这份声明同时约束 Agent、声明式 UI 和 Plugin App。界面上的按钮不能因为“是 UI 发起的”就绕过策略;Agent 也不能因为知道底层工具名就越过白名单。所有调用最终回到同一条 Capability 校验和分派链。

换句话说,Plugin 获得的不是 Host 权限,而是 Host 代为执行的一组可审计能力。

两个 Adapter,三种产品形态

当前 1.2 契约只保留两个内部 runtime adapter,并允许完全没有 adapter:

形态适合的问题能力与状态的所有者当前官方例子
gateway_mcp集中数据、远程工具、统一策略与审计Gateway 后方的受控领域服务Vibe Trading、DeepTutor
local_state离线、本地优先的领域 AppDesktop Host抓娃娃
纯 Skill只需操作知识和已有工具无新增 runtimeOpenCLI

gateway_mcp 的 manifest 只声明 Gateway 路径、工具白名单和超时。Plugin 不能在客户端写入任意 upstream URL,也不会拿到长期模型凭据。实际服务地址和网络出口属于部署策略,由 Gateway 统一代理。

local_state 则完全不同。App 不获得文件系统、浏览器存储或 Node API;它通过 Host Bridge 读写一个按用户和 Plugin 隔离的 JSON 文档。文档有 64 KiB 上限、schema version、revision 和原子替换语义。版本不匹配时,Host 返回旧数据并要求 Plugin 显式迁移;文档损坏时也不会静默清空,而是进入可恢复错误。

1.2 暂不允许一个 Plugin 同时声明 gateway_mcplocal_state。这不是格式做不到,而是混合信任边界需要独立安全设计,不能靠增加一个字段顺手开放。

App 是受控视图,不是藏在 iframe 里的第二个客户端

一个领域 Agent 只靠聊天很难承载所有交互。学习路径需要进度和复习列表,投资研究需要证据与运行对比,抓娃娃需要实时 3D 场景。因此 Plugin 可以贡献三类界面:对话卡片、声明式工作台和完整 App surface。

但完整 App 仍然不是一个拥有任意权限的网页。发布包记录入口 HTML 及依赖 JS、CSS、WASM、GLB 等资源的 SHA-256;Desktop 校验资源后,通过 openneo-plugin:// 自定义协议加载多文件 SPA。App 运行在受限 frame 中,不能直接访问 Electron IPC、模型、MCP、网络或文件系统。

它能做的只有两件事:呈现自己的资源,以及通过 Host Bridge 请求 manifest 已声明、当前用户已授权、audience 允许 App 使用的 Capability。

这让“丰富界面”和“扩大权限”不再是同一件事。抓娃娃可以加载 Babylon.js、Havok WASM 和 3D 模型,却仍然只有受控本地状态;Vibe Trading 可以呈现复杂研究工作台,却不能从 iframe 里任意访问远程地址。

从压缩包到一次真实调用

一个 Plugin 真正可用之前,要经过一条明确的所有权链:

  1. Plugins 仓库拥有领域产品。 官方包、Skill、Agent Definition、UI 源码、状态 schema、领域 runtime 和测试在这里独立演化。
  2. Backend Plugin Hub 校验分发资产。 它检查归档结构、路径穿越、链接和特殊文件、manifest schema、SemVer、Skill、Agent Definition、App 与依赖资源 digest,并保存不可覆盖的版本事实。发布时,Hub 生成签名 envelope,把版本身份与归档 hash 绑定。
  3. Desktop 验签、原子安装并默认停用。 Desktop 核对归档 SHA-256、manifest SHA-256、Ed25519 签名和 archive binding,在 staging 中重新校验 Skill、Agent 与 App 资源,最后用一次原子替换发布到安装目录。只有 installed + enabled 的 Plugin 才能向新会话贡献 Skill、Agent context、Capability 和 App。停用会撤掉运行时贡献,但不会擅自删除本地状态。
  4. Desktop 编译统一运行计划。 Main process 把 manifest 解析为 PluginRuntimePlan,激活 PluginHandle;Renderer 和 App Host 不需要知道底层 adapter 的实现细节。
  5. 调用经过统一授权。 Agent 或 App 请求 Capability,Desktop 检查启用状态、audience、风险与目标映射;远程能力使用 scoped tool token 进入 Gateway 的受控 MCP 路由,本地能力则留在 Host 边界内。
  6. 领域结果回到统一会话。 Plugin 工作台复用 Desktop 的标准 Thread、执行、Trace 和 Artifact 体系,不再维护一套私有聊天运行时。

这条链里没有一个“Plugin 服务”包办所有事情。Hub 拥有包与版本事实,Gateway 拥有受控远程代理,Desktop 拥有本机 Host 和用户交互,Plugin 自己拥有领域逻辑与体验。边界越清楚,插件越能独立演化。

一个工作台,两条执行路径,一套标准会话

Plugin 工作台没有再造一套聊天系统。它直接复用 Desktop 的标准 Thread,因此天然继承消息持久化、流式生成、工具调用、HITL 审批、重试、Trace、Artifact、诊断和远程控制。用户从普通对话进入 Vibe Trading,或从抓娃娃 App 打开助理,背后运行的仍是同一种会话,而不是 Plugin 私有的聊天记录、执行器和调试链。

但“复用会话”不等于“把领域状态塞进会话”。研究账本、学习进度和游戏状态的事实源仍然属于 Plugin runtime;Thread 保存的是围绕某个领域对象展开的人机协作过程。App 也不会通过解析助理文本来恢复界面。这样,关闭右侧助理只是在工作台里折叠会话视图,不会删除对话;刷新 App 可以从 runtime 恢复领域状态,也不需要重放聊天历史。

两者通过一个由 runtime 签发、Host 校验的 contextRef 连接。它是领域对象的受控定位符,不是访问令牌。标准 Thread 上保存不可变的 (pluginId, contextRef, agentId) 绑定:

  • App 调用 activateContext(contextRef) 后,Host 先重新校验它,再恢复最近一个完全匹配的 Thread。
  • 如果还没有匹配会话,工作台先保持空白,直到用户第一次发送消息才创建 Thread,避免为每次浏览制造空会话。
  • 用户在 App 中切换研究项目、课程或游戏实例时,Host 选择另一个绑定会话,绝不会把已有 Thread 悄悄改绑到新对象。
  • Plugin Agent 在每次运行前,从 runtime 获取最小、版本化的上下文投影;App 无权直接改写系统提示词或模型上下文。

这也解释了为什么工作台需要两条执行路径。确定性的操作——例如保存研究条目、更新学习进度或重置游戏——由 App 通过 invokeCapability 直接请求 Host,经过同一套授权、确认和审计后执行。需要解释、研究、权衡和生成的任务,则进入绑定领域上下文的标准 Thread,由 Agent 调用同一组受治理 Capability。两条路径共享权限与领域事实,却不强迫所有按钮都伪装成一句 prompt,也不让自然语言输出成为状态写入协议。

会话与 App surface 的同步是双向的,但双方交换的是意图和失效信号,而不是彼此的内部状态:

Plugin 工作台在 App surface、Desktop Host、标准 Thread 与 Plugin runtime 之间同步上下文、能力调用和失效事件

从 App 到会话,openAssistant 只负责展开或恢复 Host 拥有的助理;需要从零探索时,也只能请求一个未绑定草稿,真正发送第一条消息后仍落入标准 Thread。从会话到 App,Host 以当前 Thread、消息数量和待审批状态生成 revision,并通知受限 frame 数据可能已变化。App 收到通知后主动重读权威数据,Host 不会把原始消息、MCP 结果或任意领域对象直接推入 iframe。

一次确定性 App 操作完成后,Plugin 还可以生成一条受控的 conversation continuation,把“刚刚发生了什么、下一步需要讨论什么”送回当前 Thread。于是用户点下一个领域按钮,右侧助理能够接着解释或分析;但领域写入已经由 Capability 完成,助理只是继续协作,而不是被当成脆弱的同步总线。

这个边界看似多绕了一步,实际换来了非常具体的一致性:App 可以独立恢复,Thread 可以长期保留,切换领域对象不会污染旧会话,关闭助理不会关掉工作台,停用或重装 Plugin 也不需要级联删除标准会话历史。工作台与会话因此保持同步,却不争夺同一份状态的所有权。

我们从这套体系里学到的五件事

1. Plugin 是产品交付边界,不是工具传输格式。 只有把版本、启停、界面、状态、能力和审计一起考虑,领域体验才可能长期维护。

2. 正交能力比互斥类型更耐用。 App、Skill、Agent Definition 和 Adapter 可以组合;用一个 runtime kind 推断所有行为,迟早会被真实产品击穿。

3. Skill 不能成为权限捷径。 它教 Agent 如何工作,但执行权必须来自独立、可验证的 Capability。

4. UI 越丰富,Host 边界越要简单。 App 只通过窄 Bridge 调用声明能力,才能在支持多文件 SPA、WASM 和 3D 资源的同时维持清晰的安全模型。

5. 共享会话不等于共享状态。 Thread 承载协作历史,runtime 持有领域事实,App 只维护呈现和临时交互;用受控引用与失效通知同步它们,比复制状态或解析聊天文本更可靠。

Open Nua 的 Plugin 体系还会继续扩展,但下一步不应该是无止境增加新的顶层类型。更好的方向是继续深化同一组边界:更清晰的能力策略、更可靠的签名与分发、更完整的开发工具,以及让同一份标准核心在更多 Agent 客户端中保持可移植。

一个好的插件系统,最终不是让 Agent “什么都能调”。它应该让每一项领域能力都知道自己从哪里来、谁可以使用、在哪个边界运行,以及出了问题由谁负责。