插件扩展边界与通信
一个插件很少单独工作:它要读别的插件算出的结果、发事件、调用别人的函数、在界面上画自己的面板。本页说明这些事各用哪个接口、每个接口的边界在哪里。写多个插件互相配合、接新的模型协议、或做自绘界面的作者需要它。
分工是这样的:框架负责发现、授权、调度、校验、取消和挂载。业务数据的结构、算法、服务商协议和界面组件都留在插件包里。
可以装上直接看的示例:
- Lifecycle Probe 和 Service Provider Probe:两个可以独立安装的插件,一起覆盖命令、面板、手动函数、数据提交和服务协作。
- Jev 选项推荐 Demo。
选择通信方式
先按需求在表里找接口:
| 需求 | 接口 | 执行与数据边界 |
|---|---|---|
| 消费本轮上游结果 | runtime.io.inputs + accepts | 按版本化 contract/runtime 绑定,建立调度依赖,携带来源;必需输入缺失时跳过 |
| 消费已提交的历史结果 | runtime.io.output.recordAs + io.inputs 的 scope: committed | 读取本次执行开始前的快照 |
| 发出事件、触发后续工作 | handler 结果的 events / emit-event 工具 + event trigger | 由调度和提交链路管理;不是内存中任意广播 |
| 调用其他插件的公共函数或模型协议 | covel.registerService + ctx.services / 工具的 context.services / agent.tools.services | 请求/响应,校验输入输出,只调用活跃且已获准运行的服务;agent 把给模型用的服务导入成工具 |
| 让另一个 agent 回答一段简报 | schedule.subagentOnly 提供方 + ctx.agents.run / delegate 工具 | 子 agent 在调用方的执行里运行,只交回输出,可通过写服务暂存操作;按契约找跨包的提供方 |
| Runtime 执行领域写入 | 已声明工具、handler effects / ctx.pluginData | 走 runtime 的 proposal/commit 边界,不直接写别的插件 |
| UI 触发自己的服务端逻辑 | invokeRuntime / invokePluginAction / invokeCommand | 复用现有 RPC、会话归属与审批;写入边界见下文 |
| UI 发出领域事件 | emitEvent | 只能发本插件声明的 topic,按 schema 校验;订阅的 runtime 以事件任务执行 |
| 自由绘制 UI | JSON view 或 HTML webview | HTML 在独立 sandbox 中运行,使用消息桥读取数据、发出动作 |
下面是表里装不下的细节。
函数 runtime 改自己的数据时,可以交一串操作而不是算好的整个值:ctx.pluginData.update(namespace, key, ops) 产生 plugin.data.update,提交时在最新的值上应用(见事务 · 操作式写入)。这只属于 runtime 的写入边界:扩展点和读服务的 ctx.pluginData 只读。别的插件要改这个插件的数据,调用它的写服务。
ctx.recursiveCall 的用途是包内嵌套调用。要请求其他包的结果,使用服务;要触发其他包的后续工作,使用事件。
会话的座位(谁在玩、控制哪些角色)不归任何插件,也不需要通过服务或输入去问别的插件。函数 runtime、guard、工具(context.world)和扩展提供者(ctx.world)都在 world.seats 上读到同一份,只读。要找本回合行动的角色,用 SDK 的 actingCharacterOf(actions, world.characters, world.seats),不要自己用 primarySeat 或 currentCharacterOf 拼:它先取玩家行动指明的角色,没有时才退回主座位的当前角色。函数 runtime 和 guard 传 ctx.playerActions;扩展提供者和工具拿不到行动,传 [],得到主座位的当前角色;后台作业传源回合的 ctx.inputs.turn.value.actions(缺省为 []),保留源行动的角色。seatCharacterIds 给出一个座位绑定的全部角色。完整规则见座位。插件不能写座位,玩家用切换接口换当前角色。
工具和 agent 怎样用服务
- 函数 runtime 的 handler 和 agent 的 guard 用
ctx.services。 - 插件工具用
context.services,规则和函数 runtime 相同:调用方是工具所属的插件和 runtime,调用继承工具的截止时间和取消,读到的数据和工具自己的context.store读取用同一个范围、记在同一处读取依赖上。工具返回时还没结束的调用会被取消。服务失败时调用抛出服务的错误;工具不处理就让它抛出,这次工具调用会按导入的服务工具的错误对应表回答模型,失败的服务调用记录里仍是这个提供方,这次失败不算调用方的错误。 - agent 可以在
agent.tools.services里导入给模型用的服务,每个导入变成一个工具:读服务是只读工具,写服务的工具会暂存写入,见下文给模型用的服务和写服务。框架不会把所有服务自动给每个 agent。
从工具或导入调用的服务拿不到 gateway 和 utils:需要模型的工作由调用方自己做。
事件怎么触发下游
runtime 发出的事件在同一回合的事件链里逐层匹配订阅者。
- 每一层里匹配到的 runtime,按它们声明的
needs/after/inputs关系排序执行。上一层的结果对下一层立即可见。 - 没匹配到的提供者不会被额外触发。
- 后台 follower,以及界面发出的事件所触发的任务,是各自独立的持久任务。同一回合内的排序不管它们:不保证它们之间的先后,也不拿一个的成功作为另一个的前提。
needs 指向一个只需要单个提供者的契约时,前一个阶段或前一层事件里已经满足的结果可以算数。即使当前这一批里的另一个提供者形成了循环依赖,已经满足需求的消费者仍然可以运行。after、需要全部提供者的需求、输入绑定,各自的排序约束不变。
界面触发的写入:三个动作的区别
| 动作 | 做什么 | 写入 |
|---|---|---|
invokePluginAction | 调用插件的 RPC action | 合法记录立即生效。被拒记录保持原样;handler 后面失败,之前成功的写入保留。 |
invokeRuntime | 触发一个 trigger.type: manual 的函数 runtime | ctx.pluginData 写入和领域 effects 进入同一次 proposal 提交,一起成功或一起失败。 |
invokeCommand | 执行清单里声明的玩家命令 | 走命令的校验与审计。不要用 invokePluginAction 绕开它。 |
RPC 的 scoped store 按数据集规则检查 set 和记录 schema;没有新增回合或操作记录。多条记录必须一起成功或一起失败时,用 invokeRuntime。这个函数 handler 不需要模型。手动触发本身不会运行叙事 runtime;它声明的事件下游仍按调度规则执行。
RPC action 的 handler 收到 ctx.signal,到达时限时中止;存储调用的关闭与已开始写入的结算见下方 RPC 通道。
参见 RPC 通道与 handler 写入契约。
工具不跨插件
插件工具在 entry 里用 covel.registerTool() 注册,名字在插件内唯一。不同插件用同一个名字不会互相覆盖。一次工具调用只会找到调用方插件自己的实现、框架内置工具和本 runtime 导入的服务。要给别的插件用的能力,做成下文的服务:别的插件的工具用 context.services 调用它;要让别的 agent 的模型直接调用,把它标成给模型用的服务,由对方在 agent.tools.services 里导入。
没有 runtime 的包
只有 entry、Hook 或 UI 的包可以没有 runtime。
- 它的 Hook 仍然按会话里启用的插件集合运行。
ctx.getOwnSettings()读到包级userSettings的默认值,加上这个世界和玩家的覆盖值。没有 runtime 不会丢掉这些默认值,也不会凭空多出一个 runtime 被调度。
把内核嵌进自己宿主的开发者还要知道:执行和提交各用一个明确的会话 Hook scope。直接调用 executeTurn 的宿主如果要包含没有 runtime 的包,在 TurnExecutorDeps.hookScope 里传入它的 activePluginIds 和解析好的 settings。执行入口会记下并冻结这个范围,随后随 execution 自动传给 commitExecution。Covel 的服务端从持久化的会话激活集合构造这个 scope。社区插件的 Hook 每次调用时仍检查当前的 server-code 授权。
Hook
每个 Hook 事件什么时候触发、负载是什么、可以返回什么、handler 收到什么上下文,见 Hooks。和通信有关的几条:
contributes.hooks按event和enforce声明允许的 Hook。省略enforce等于normal。- 一条声明可以多次调用
covel.on()注册多个独立的 handler。 - 注册了没声明的事件或分组,或声明了却没有实现,都会让整个 entry 发布失败。
- 同一事件的 handler 按分组依次执行:
pre→normal→post。组内先执行框架的 Hook,再按注册顺序执行插件的 Hook。不同插件之间的先后取决于加载顺序,不是可以声明的约定。 - 两个插件改写同一份负载时,后执行的
replace覆盖先执行的同名字段。需要确定先后的插件应声明不同的enforce。 TurnStop在一次执行的全部 runtime 结束之后、提交之前触发。这时写入还没有落库,也可能在提交时被拒绝。要在数据落库之后做事,用PostStateCommit。
插件作者类型
外部插件从公开包 @covel/plugin-handlers-utils 导入 PluginAPI 或 PluginEntryFactory 类型,不需要安装私有的 runtime / shared 包。
| 入口 | 提供什么 |
|---|---|
@covel/plugin-handlers-utils | PluginAPI、PluginEntryFactory |
@covel/plugin-handlers-utils/extension-points | 扩展点的输入、输出和上下文类型 |
@covel/plugin-handlers-utils/plugin-api | 完整的入口类型 |
/** @type {import("@covel/plugin-handlers-utils").PluginEntryFactory} */
export default function (covel) {
covel.provideExtension("prompt.segment@1", "direction", {
handler: (_input, ctx) => [
{
id: "direction",
content: `Locale: ${ctx.locale}`,
position: "system",
audience: "self",
volatility: "turn",
},
],
});
}用 TypeScript,或给 JavaScript 打开 checkJs 之后,已知的扩展点会约束 handler 的输入和输出。类型检查代替不了别的检查:清单声明和运行时的 schema 校验仍然必需,授权和输出校验照常进行。
SDK 还导出 estimateTokens、FRAMEWORK_TOOL_NAMES、多语言文本和表单的辅助函数等,完整的表见公开 SDK 里的辅助函数。
IMPORTANT
插件 JavaScript 模块里的相对导入必须指向包里真实存在的文件。导入一个没编译的 TypeScript 文件时,要写真实的 .ts 后缀。
在仓库里开发时,pnpm dev 用 tsx 加载器启动,它会处理这类导入。Node 自带的类型剥离不会把 .js 导入对应到 .ts 文件,所以直接用 node 的 import(...) 加载整个源码依赖图是行不通的。
Entry 资源生命周期
entry 工厂里可以打开连接、起计时器、建缓存。这一节说明这些资源什么时候该关、怎么关。
covel.signal代表这一次插件激活。初始化失败或宿主关闭时,它被取消。covel.onDispose(callback)在工厂执行期间登记清理函数,同步或异步都可以。用它关闭订阅、连接、计时器和共享缓存。- 拿到一个资源后立刻登记它的清理,再做下一步可能失败的初始化。
export default function (covel) {
const cache = new Map();
const timer = setInterval(() => cache.clear(), 60_000);
covel.onDispose(() => {
clearInterval(timer);
cache.clear();
});
// Pass covel.signal to asynchronous initialization and shared resource work.
// Register services, tools or RPC handlers here.
}关闭的顺序
- 取消信号,停止接收新的注册。
- 撤销已经发布的能力。
- 按登记的相反顺序等待清理函数。
一个清理函数失败不会阻止其他清理,错误会汇总报告。重复关闭不会再次执行回调。初始化失败时同样清理已经拿到的资源,重试会用一个新的 signal。测试运行器在执行完成或失败后也会关闭入口资源。
时间上限
| 事情 | 默认上限 |
|---|---|
| 服务端 entry 初始化 | 15 秒 |
| 初始化失败后的回滚清理 | 15 秒 |
| 失败后被动服务发现的退避 | 30 秒 |
清理超时不会阻止失败结果返回、后续重试,或其他会话取得快照。宿主关闭时仍会报告没完成的失败清理。显式激活和重载不受退避限制,可以马上重试。
并发
首次激活和重载的准备工作,对同一个插件是排队进行的。工厂运行、runtime 模块加载、失败后的资源清理都不占用全局发布队列:别的插件,以及不依赖这个插件的会话,可以继续执行。
一次执行在取得 runtime 的过程中如果有新一版发布,宿主会重新取快照。这保证同一次执行用到的工具、服务和 runtime 来自同一版。
entry 不属于某个会话
WARNING
entry 在宿主里每个插件只激活一次,所有会话共用它。不要把会话数据放在模块顶层变量或闭包里。
- 在一个会话里禁用插件,不会销毁其他会话共用的 entry。
- 会话数据用调用上下文和插件数据存储。
- 单次任务的取消用
ctx.signal。需要知道会话结束时,用SessionEndHook。 - 这套机制靠插件自己配合取消和清理。它不能强行终止一段不理会信号的 JavaScript,也不提供独立的后台调度器。
宿主怎么报告状态
宿主目录里的 hostState 只描述 entry 实际的发布状态:discovered / installed / loaded / error。它不包含“已批准”。
授权和启用是每个会话各自的事,由会话 API 单独给出:实时的 serverCodeApproved,以及依赖解析之后的 sessionState / autoAdded / rejection。不能从全局的 loaded 推断另一个会话有没有授权。
首次激活失败时显示 error。重载失败但旧版本还保留着时,仍显示 loaded,错误信息另外展示。
开发时单插件热重载
改了社区插件的文件,不用重启服务就能生效。这只在 NODE_ENV=development 下可用;内置插件和生产环境不允许。
触发方式
- 手动:
POST /api/plugins/:id/reload,请求体可选{ "sessionId": "..." }。 - 自动:开发宿主监听社区插件目录,把 150 毫秒内的文件变化合并成一次,走同一条重载路径。 用
pnpm dev启动时有一个例外:它的监视器(tsx watch)也盯着服务已经加载过的每个代码文件,所以改这样的文件(跑过的handler.js、entry,以及它们导入的模块)会让整个后端重启,而不是走这里的重载;重启后社区插件要重新授权。清单、界面文件和译文不受影响,仍然走这里的重载。
两种方式的条件相同:
- 操作要通过安装 API 的本机或管理员鉴权。
- 要有一个会话提供当前有效的 server-code 授权。请求里指定了会话就用它;没指定时用曾经批准过这个插件的会话。
- 没有有效授权时,拒绝执行插件代码。
一次重载做了什么
- 解析新的声明,运行新的 entry 工厂,校验注册和声明一致。
- 预加载 handler 和 guard(只加载同时拿到 server-code 和 runtime 两种授权的)。
- 一次性发布工具、Hook、RPC、服务、表单校验器、wire 和 runtime 元数据。
准备失败时保留旧版本。
正在进行的执行继续用旧版本,之后的执行用新版本。旧版本不再接受新的查找;等到用着它的执行都结束、超时后仍未结束的 provider promise 也释放之后,才调用它的 onDispose()。快照不保存授权:调用和提交时仍检查授权有没有被撤销。
重载之后的会话
新版本发布后,每个会话的下一次执行会先重新解析依赖关系,再取新快照。解析用的是玩家原来的请求、明确排除的插件和当前的授权。
- 新增了必需的契约却没有提供者,或出现互斥冲突时,这个插件不能沿用旧的激活结果继续执行。修正声明之后,玩家原来的选择仍然保留。
- 已经开始的执行保留原来的激活关系和服务引用。但显式停用、撤销授权或替换会话仍会让它失效。
事件 schema
事件 schema 在启动时和新版本准备期间读取并编译。编译结果和编译失败都按版本缓存,然后才发布新声明。同一路径的文件修正要到下一次重载才生效。已经被取用的旧版本即使还没处理过这个事件,也保持原来的校验器,不受磁盘上后续编辑的影响。
启动时就解析失败的插件
启动时静态解析失败的社区插件也保留它的发现位置,并接受文件监听。
- 重载只沿用这个包的 ID、根目录和发现来源。它按当前文件重新检查布局,不继承旧的
layoutError。补上根PLUGIN.md,或改对子清单的文件名之后,可以恢复。 - 新一次扫描仍有错误时,不发布新声明,也不清除失败的发现位置。
- 修正文件后,如果这个插件从未发布过任何能力,宿主可以在没有 server-code 授权的情况下重新校验并发布它的静态声明。这不会导入 entry、handler 或 guard;首次激活仍然要走正常审批。
- 已经有活动版本的插件不能走这条恢复路径。
- 监听的目录消失了只记一条警告,不中断宿主启动。
哪些改动重载不了
NOTE
每次重载都会重新导入 entry、handler 和 guard,以及它们用相对路径导入的、位于插件目录内的 ES 模块。所以只改 lib/helper.js 这样的依赖模块并保存,新代码也会生效。
- 同一次重载里,entry、handler 和 guard 共用同一份新的依赖模块;上一版继续用它自己的那一份。依赖模块顶层的状态因此不会带到新版本,需要保留的数据应写入插件数据。
- 这些不会重新加载:插件目录以外的文件、
node_modules里的包(包括@covel/plugin-handlers-utils)、CommonJS 文件(.cjs)和原生模块(.node)。改了它们、宿主代码,或不能安全清理的顶层资源,要重启服务。 - Node 不会释放已经加载的模块。每次重载都会在内存里多留一份这个插件的模块,只有重启服务才能回收;长时间反复重载之后,可以重启一次。
公共函数注册与调用
一个插件可以把一个函数公开给别的插件调用,这叫服务。
注册
服务在 entry 工厂运行期间注册。它和工具、Hook、RPC 一起发布:全部成功才生效,失败就全部撤回。宿主关闭时解除注册。
- 服务名在所属插件内唯一。
contract是插件作者定义的公开契约标识,形如<名称>@<主版本>:小写字母开头,只有小写字母、数字、.和-,例如my-plugin.rank@1。- 不兼容的修改用一个新的契约,不为开发数据写迁移。
- 每个注册的服务都要在
PLUGIN.md的contributes.services里声明,写契约 ID;给模型用的服务写成{ contract, modelFacing: true },写服务写成{ contract, access: write }。声明和注册对不上时,整批注册失败。
export default function (covel) {
const { z } = covel.toolkit;
covel.registerService({
name: "rank",
contract: "my-plugin.rank@1",
input: z.object({ candidates: z.array(z.string()).min(1) }),
output: z.object({ selected: z.string() }),
async handler(input, ctx) {
ctx.signal.throwIfAborted();
return { selected: input.candidates[0] };
},
});
}发现和调用
别的插件先用 ctx.services.discover("my-plugin.rank@1") 找到提供者,得到 { pluginId, name, contract, description? }[],再明确选一个来调用。
有多个提供者时,由调用方插件的设置或业务规则决定用哪个。框架不按插件名字猜,也不自动选第一个。
const value = await ctx.services.call({
pluginId: configuredProviderId,
name: "rank",
contract: "my-plugin.rank@1",
input: { candidates: ["Visit the library", "Explore the classroom"] },
});可以给一次调用设一个比 runtime 更短的时间上限,或单独取消它:
const value = await ctx.services.call(
{
pluginId: configuredProviderId,
name: "rank",
contract: "my-plugin.rank@1",
input: { candidates: ["Visit the library", "Explore the classroom"] },
},
{ timeoutMs: 1500, signal: ctx.signal },
);超时和取消
timeoutMs是正的有限毫秒数,最大2147483647。它包含排队等待准入的时间和实际执行的时间。- 超时抛出
name: "TimeoutError"的异常。单独取消时保留取消原因。 - 父 runtime 被取消时,服务调用也被取消。反过来不成立:一次调用超时或被取消不会取消父 runtime。调用方可以捕获错误,改用备用提供者。
- 服务里再调用的服务、gateway 和 HTTP 请求继承这次调用的信号。
- 调用一结束,它的上下文就关闭。不能把上下文存起来,等返回之后继续用它发起工作。
- 不配合取消的异步代码可能继续跑,但调用方会及时结束等待。迟到的结果不会被当作成功结果返回。
调用前的检查
每次调用前,框架重新检查:调用方和提供方是否仍然启用,社区插件的服务端代码是否获准运行。
- 发现接口不会激活未获准运行的社区代码。
- 一个服务在进程里注册了,不代表当前会话可以调用它。
服务能拿到什么,不能拿到什么
服务分两种访问级别:access: read(默认)的读服务只读,access: write 的写服务可以改提供者自己的数据。下表是读服务的上下文;写服务的差别见下文写服务能拿到什么。
| 字段 | 含义 |
|---|---|
callerPluginId | 直接发起这次调用的插件;嵌套调用时是上一层提供者。 |
signal | 调用的取消与截止信号;读取在等待存储前后都会检查。 |
gateway | 可选的模型访问,不携带调用方的密钥材料。 |
utils | 可选的 HTTP 辅助能力,继承本次调用的信号。 |
services | 继续发现和调用其他公共服务。 |
random | 提供方作用域的确定性随机数,流名 service:<name>。 |
sessionId | 当前会话。 |
locale | 会话内容语言;宿主未提供时可省略。 |
messages | 提供者自己的消息目录,与函数 runtime 相同;没有目录时省略。translate(ctx, text) 按会话内容语言取译文,labelText(ctx, text) 保留全部语言供界面选择。嵌套服务使用各自提供者的目录。 |
pluginData | 提供者自己的只读数据;get(namespace, key) 返回值或 null,list(namespace) 按存储顺序返回 { key, value }[]。_hidden.* 命名空间返回 null / 空列表。 |
world | 调用方 runtime 所见世界的独立只读副本,首次访问时复制。 |
getOwnSettings() | 提供者本回合已解析、冻结的设置;没有设置时返回空对象。 |
- 输入和输出按 schema 校验,并被复制一份。不能靠对象引用去改对方的状态。
- 数据读取每次都返回独立副本,没有调用方的 store、工具、设置或写入缓冲。
- 读服务没有写入方法,也不能产生 proposal。怎么保存结果,由发起调用的 runtime 决定;要改提供者自己的数据,用写服务。
- 服务调用链不允许循环,最多八层。它继承 runtime 的截止时间和能力撤销。
- 服务必须把
signal传给异步工作,不能把任务留在脱离调用的后台。 - 服务端的普通 JavaScript 仍遵循社区代码的授权模型。这不是进程级沙箱。
通过 runtime 客户端调用服务时,pluginData 在已提交数据上,按提交顺序叠加本次执行内提供者插件已完成、可提交的上游写入;不重复应用重试或后台任务带来的种子结果。同一层并行 runtime 的写入互不可见。只有提供者就是原始调用 runtime 所属插件时,才继续叠加该 runtime 自己的写入缓冲。列表去掉已删除的键,新键按写入顺序追加。执行外的客户端只读已提交数据,不记录读取依赖。
读到了尚未提交的上游写入,就在调用 runtime 的结果上记录读取依赖。嵌套服务仍读各自提供者的数据,依赖始终记在原始调用 runtime 上。提交时产出方被作废,辅助调用方随之作废;故事调用方照常提交,并在 trace 里记录(见事务)。调用读服务本身不建立新的调度依赖;故事 runtime 可以按需调用并处理服务缺失或失败。
服务实际读取 ctx.world.characters 或 characterSchema 时,也按 World Model 的规则记在原始调用 runtime 上。读服务、写服务里的读取、工具导入和嵌套调用都一样。首次取得 world 时,宿主复制调用方的独立快照,不记录属性读取;只读维度也不产生角色/schema 依赖。调用结束、取消或超时后,这份视图不能继续读取。没有 runtime 提交属主的执行外调用不产生提交依赖,提供者写入归属和嵌套只读限制不变。
工具和导入的调用读同样的数据:顶层 agent 的工具和函数 runtime 一样叠加上游写入并记录读取依赖。恢复执行的 runtime 和子 agent 的工具调用服务时只读已提交数据,不记录读取依赖,和它们自己的工具读取一样。
读服务还读得到同一个调用方此前通过写服务为这个提供者暂存的写入(见下文),但不为它们记录读取依赖。
写服务
写服务替调用方改提供者自己的数据,例如商店插件调用背包插件的 inventory.change@1 扣钱、加物品。声明和注册都要写 access: write,对不上时整批注册失败:
# PLUGIN.md
contributes:
services:
- { contract: inventory.change@1, access: write, modelFacing: true }covel.registerService({
name: "change",
contract: "inventory.change@1",
access: "write",
modelFacing: true,
description: "Pay coins and add an item to the player's bag.",
input: z.object({ item: z.string(), cost: z.number().int().min(0) }),
output: z.object({ coins: z.number() }),
async handler({ item, cost }, ctx) {
await ctx.pluginData.set("items", item, { owned: true });
await ctx.pluginData.update("purse", "coins", [
{ op: "inc", path: "/amount", by: -cost, min: 0 },
]);
const purse = await ctx.pluginData.get("purse", "coins");
return { coins: purse.amount };
},
});能力快照把写服务记成 "<契约> (write)":改成写服务、或者新加写服务,已信任来源的包也要重新确认(见插件安装)。扩展点提供者不能是写服务。
写服务能拿到什么
写服务继承读服务的 random,去掉 gateway 和 utils,把 pluginData 换成读写接口,并增加 turnId:
| 字段 | 含义 |
|---|---|
pluginData | 提供者自己数据的读写:get、list、set、delete、update。读到的是已提交数据,叠加本次执行里提供者上游的写入、同一调用方此前调用暂存的写入,以及这次调用自己的写入。 |
random | 确定性的随机数,流名是 service:<服务名>。 |
turnId | 调用方的回合。 |
- 即使调用方有模型或 HTTP 能力,写服务也拿不到:写服务应当短小、确定。
- 不能写任何
_开头的命名空间,包括提供者自己的_hidden.*,抛出invalid-input。 update在它看到的值上不成立时抛出代码为conflict的ToolError。- 写服务里还能调用读服务,但不能调用另一个写服务(
unavailable)。读服务里也不能调用写服务。 - 调用结束后再写入会报错,也不会被暂存。
暂存:一次调用就是一个操作
写服务的写入不立即落库。一次成功的调用把它的全部写入暂存为一个操作,挂在发起调用的 runtime 的结果上,随调用方的提交单元一起提交(见事务 · 写服务的操作):
- 操作里的写入以提供者的身份提交(
source.pluginId是提供者,runtimeId是service:<服务名>);PreStateCommit、数据集规则和错误统计看到的都是提供者。 - handler 抛错、超时、被取消或输出不合规时,什么都不暂存。没有写入的调用不留操作。
- 只有发起调用的那段代码成功时,操作才算数:工具调用失败、子 agent 失败、函数 handler 失败时,它们暂存的操作都被撤回(
discarded),即使写服务当时对模型已经回答成功。 - 同一个调用方对同一个提供者的调用一个接一个执行,包括并行的工具调用和并行的子 agent;每次调用都读得到前面调用暂存的写入。
- 每个操作留下一条操作记录,提交后写明它最终提交、失败还是被撤回。
故事附带的操作在提交时失败,不会让故事失败。提交后玩家立即看到固定提示,说明有多少操作没有生效;调试 trace 保留 operation.failed。这样的失败不算执行失败:回合流里没有它的 proposal.failed,回合照常显示已提交。下一次玩家或续行执行中,发起操作的 agent 收到 operations-not-applied 块,列出上一个已提交回合失败操作的摘要,最多十条;子 agent 发起的操作,由这个子 agent 下次被调用时收到。其他 runtime 和定向重试不显示这个通知。通知只跟随最新的已提交玩家或续行回合,详见回合管线。
哪里不能调用写服务
以下位置调用写服务得到 unavailable(“Write services cannot be called here.”),什么都不暂存:
- 脱离回合的后台 runtime(
detached)和background执行; - 恢复执行(
resume)和只修复内容的重试(带源回合的手动重试):它们会把一笔交易结算两次; - agent 的 guard、Hook、RPC handler 和扩展点提供者;
- 能挂起(
tools.builtin含suspend)的 agent:它的写服务导入不出现,原因是not-allowed。
函数 runtime 暂存了操作之后又返回 suspended 时,这次运行失败(“<runtime> cannot suspend after changing another plugin's data through a write service”),操作被撤回。
一个插件不能调用自己的写服务(refused):它直接写自己的数据,这些写入已经随它自己的 runtime 提交。子 agent 也不能调用委托它的那个插件的写服务。
模型看到什么
导入的写服务工具带写入注解(readOnly: false、stagesWrites: true、idempotent: false、concurrencySafe: false)。调用成功时回答 { data, staged: { count, kinds, notice } },notice 说明写入要到回合结束才生效、仍可能被拒绝。提供者抛出的 conflict 原样到达模型,并标为 refused。见导入的服务工具。
给模型用的服务
提供方把服务标成给模型用,agent 就能在 agent.tools.services 里导入它,模型直接调用。标记要写两处,而且必须一致:
# PLUGIN.md
contributes:
services:
- { contract: inventory.items@1, modelFacing: true }covel.registerService({
name: "items",
contract: "inventory.items@1",
modelFacing: true,
description: "List the items in one of the player's bags.",
input: z.object({ bag: z.string().describe("Bag name") }),
output: z.object({ items: z.array(z.object({ name: z.string() })) }),
handler: async ({ bag }, ctx) => ({
items: (await ctx.pluginData.get("bags", bag)) ?? [],
}),
});一个服务能被导入,要满足:
- 声明写成
{ contract, modelFacing: true },注册时也写modelFacing: true。 - 有非空的
description,它就是模型看到的工具说明。 - 输入能导出 JSON Schema:Zod schema 自动导出(去掉
$schema和服务商读不懂的正则);输入不是 Zod,或 Zod 输入导不出 JSON Schema(比如用了z.custom)时,给出显式的inputSchema;给了它就不再导出 Zod 输入。根必须是type: "object"。
这些在注册时检查,不满足时整批注册失败,不会拖到某个回合才出错。modelFacing 不进入能力快照:标记它不给提供方代码新的权限,已信任来源的包不会因此重新确认。
导入方这样写:
agent:
tools:
services:
- inventory.items@1 # 工具名 inventory-items
- { contract: world.map@2, as: look-at-map }工具名默认是去掉版本号、. 换成 - 的契约名;as 另起名字。名字规则、Schema、注解、超时和错误见导入的服务工具。函数 runtime 不能写 tools.services,它直接用 ctx.services。
每次 runtime 开始时,框架为每个导入找提供方。候选是声明了这个契约、给模型用、启用且获准运行的插件,以及因反复出错被暂停的插件:
| 情况 | 结果 |
|---|---|
| 恰好一个活跃的提供方 | 生成工具,绑定这个提供方 |
| 没有提供方 | 这个工具不出现;调用它得到 unavailable |
| 有提供方,但都不给模型用 | 同上,说明里写提供方不给模型用 |
| 不止一个给模型用的提供方 | 同上,说明里写不止一个插件提供;不按名字猜,也不选第一个 |
| 唯一的提供方被暂停 | 同上,说明里写提供方因反复出错暂停;下一次执行恢复后工具重新出现 |
| 一个活跃、一个被暂停 | 算作不止一个提供方:不会悄悄换成活跃的那个 |
缺少的导入只去掉这一个工具,runtime 照常开始,故事照常提交。导入不建立调度依赖,也不让包依赖提供方;导入的工具不能用来完成 runtime(finishWith、completion.afterTools 不能写它)。宿主通过准入接口告诉框架哪些插件被暂停;没有这个接口的嵌入方,被暂停的提供方等于不存在。
工具调用的服务调用在 plugin.service.completed 里带上 toolCallId,嵌套调用继承它,见协议。
模型与新协议
插件需要调用模型,或接入一家新的服务商时,按下面的情况选做法。
用已有的模型能力
- 文本、图片、语音、音乐:用
ctx.gateway、ctx.images、ctx.speech、ctx.music。它们的类型(PluginServiceGateway、PluginImages、PluginSpeech、PluginMusic、PluginMedia)由@covel/plugin-handlers-utils导出,宿主实现的就是这些类型。 - 评估:
ctx.gateway.evaluate({ presetId, state, questions, signal }),支持 Boolean、Choice、Score 三种问题。
自己拼长提示词时的预算
ctx.gateway.resolveSlot({ presetId })?.limits给出 gateway 对这个 slot 实际采用的{ contextWindow, maxOutputTokens }。模型没有声明上下文窗口时,这个字段省略;框架不会把自己的兜底值当成真实上限给你。generateText/generateObject可以传maxOutputTokens作为单次调用的上限。它只能低于玩家为这个 slot 配的输出预算,不能调高。
要 JSON 回复
给 generateText 传 responseFormat: { type: "json_schema", schema }。支持 JSON 模式的协议会打开它,schema 同时作为指令加进消息。
返回的 text 仍然是文本,插件自己解析并检查内容对不对。回复读不出来时,用 ctx.logger.warn 留一段有长度上限的摘录:trace 不保存回复正文。
日志
| 方法 | 写到哪里 |
|---|---|
warn / error | 本会话的 _logs 环,另外在服务器日志里写一行 [plugin-log] <runtimeId>: <message> <meta>(meta 截到 2000 字符)。 |
debug / info | 只进 _logs。 |
这样运维不用翻会话数据就能看到警告和错误。
新的服务商、新的协议
| 情况 | 做法 |
|---|---|
| 新服务商,用的是已有的 wire | 只配置 provider、base URL、模型和用途。不用改插件或框架。 |
| 新 wire,或新的返回形式 | 在一个服务内部用 ctx.gateway.resolveSlot 取得本次请求的模型配置,再用 ctx.utils.validateBaseUrl / fetchWithRetry 实现协议,定义自己的输入输出 schema。结果通过服务契约提供给其他插件。 |
| 图片、语音、转写、音乐,想复用统一的媒体管线 | 用 covel.registerWires。媒体落库仍通过现有的媒体接口。 |
| 新的文本协议(内置四种之外的请求和响应格式) | 也用 covel.registerWires 注册,见下文文本协议 wire。注册后它和内置协议一样,任何文本用途都可以选用,主叙事也可以。 |
密钥只留在服务端,不写进结果、日志或界面。
音乐生成
ctx.music.generate({ prompt, lyrics?, instrumental?, durationSeconds?, format?, presetId?, metadata?, signal? })
→ { refs, warnings, cached }- 去重和落库的约定与
ctx.speech.generate相同。 ctx.music.isAvailable(presetId?)只检查有没有配置音乐用途,不向服务商发请求。- 内核不带音乐 wire。提供音乐模型接入的插件用
covel.registerWires({ music: [{ id, compose }] })注册。compose(config, params, context)返回{ audio: { mimeType, data }, usage, warnings }。需要轮询的服务商,在compose里轮询完再返回。
WARNING
一首曲子要生成几十秒到几分钟。只在 detached 或后台 runtime 里调用它,不要放进回合。
权限不会因为跨插件而变大
服务里的 gateway 继承发起调用的 runtime 的请求配置、追踪、取消和撤销边界。HTTP 请求由提供方的代码发出,所以按提供方的声明检查:社区提供方处理函数里的 context.utils 和它的 covel.http 一样,只能访问它自己包的 permissions.http 列出的源,不管调用方是谁。扩展的处理函数也是这样。gateway 已经有标准方法的能力,优先走 gateway。
“不用改框架”的范围
它指插件能用自己的契约实现功能。如果希望框架的通用模型设置、调度器或媒体系统直接认识一种全新的能力类型(文本、图像、语音、转写、音乐、评估之外的),那仍然要改宿主的公共契约。注册一个服务不会自动给这份能力清单添加新成员。
文本协议 wire
export default function register(covel) {
covel.registerWires({
text: [
{
id: "converse",
label: "Acme Converse",
async generateText(config, params, context) {
const response = await covel.http.fetchWithRetry(
`${config.baseUrl}/converse`,
{
method: "POST",
headers: { authorization: `Bearer ${config.apiKey}` },
body: JSON.stringify(toAcmeRequest(params)),
signal: config.signal,
},
);
const body = await response.json();
return {
text: body.output,
finishReason: body.stopReason,
usage: { inputTokens: body.in, outputTokens: body.out },
};
},
async *streamText(config, params, context) {
// yield { type: "text-delta", textDelta } … then one final
// { type: "done", finishReason, usage }
},
},
],
});
}声明和命名:
- 和图片、语音等 wire 一样,文本 wire 的
id要写进清单的contributes.wires(上例为wires: [converse])。注册了没声明,或声明了没注册,插件都加载失败。 - 注册名是
<pluginId>/<wireId>(上例为acme/converse)。模型的protocol写这个名字即可,llm.toml和设置页都认。 - 设置页的协议下拉通过
GET /api/ai/protocols列出已加载插件注册的协议,显示的名称取label。
要实现什么:
- 必须实现
generateText和streamText。类型见@covel/plugin-handlers-utils的PluginTextWire。 params带model、messages(含工具调用与工具结果)、tools、responseFormat和providerRequestMetadata。- 消息文字和 text 块不含内部缓存断点标记。
instructionLocale是宿主按会话选出的指令语言(en或zh),结构化输出的补充指令也使用它。 - 流的最后一个事件必须是
done。 finishReason可以直接返回服务商自己的词,网关会统一成stop/length/tool_calls等(见 slots.md 的结束原因说明)。- 结构化输出不用单独实现:网关把 JSON Schema 放进
responseFormat并作为指令加入消息,调用generateText,再解析和校验结果。 - embedding 不走文本 wire。
- 可选的
listModels(config, signal)返回端点提供的模型 ID。设置页的“从服务读取模型列表”会用它。
请求和密钥:
- wire 自己发 HTTP。所以这些请求不出现在 provider 请求追踪里,框架的传输重试和请求预算也不作用于它。
config.signal在调用被取消或超时时触发,要把它传给请求。config里有该用途解析出的地址和密钥。它们只能用于向该地址发请求,不得写入结果、日志或界面。- 模型的
protocol指向的插件没有加载,或没有在当前会话启用时,调用以配置错误失败,错误里写明要启用哪个插件。它不会改用别的协议。
哪些请求可以选用这个协议:
| 请求 | 条件 |
|---|---|
| 会话里的请求 | 该插件在这个会话里处于启用状态(社区插件的启用已含玩家授权)。 |
| 不属于任何会话的请求(连接测试、读取模型列表、生成世界) | 可以选用内置插件的协议。 |
同上,self 形态 | 只有一个玩家,已加载的社区插件都经他授权,所以也可以选用社区插件的协议。 |
| 同上,托管形态 | 社区插件的协议要在已启用它的会话里使用。 |
请求级预设、llm.toml 的槽位和 runtime 自己的槽位偏好都按这张表判断。设置页的协议下拉仍列出所有已加载插件的协议。插件注册的图像、语音、转写和音乐 wire 也按这张表选用;选不到时,生成以配置错误失败,错误里写明要启用哪个插件。
CAUTION
信任边界和媒体 wire 相同:社区插件的服务端代码要玩家授权后才运行。wire 里的 covel.http 只能访问包里 permissions.http 声明的源,以及它正在服务的那个用途的端点。不同的是,文本 wire 会经手选用它的那个用途的全部提示词和回答。
自定义组件与挂载
插件在 ui.right 声明一个 JSON 外壳。里面用 view(JSON 描述的界面)或 webview(一个 HTML 文件),二选一。业务界面不必都用框架自带的组件。
{
"id": "my-widget",
"label": "My widget",
"surfaces": ["panel", "stage"],
"dataSource": { "namespace": "results" },
"webview": { "entry": "./widget.html", "height": 280 }
}面板出现在哪里
surfaces缺省为panel,即右侧面板。- 声明
stage后,面板也挂在舞台的决策区域。这个位置同样支持 JSONview。 - 右侧面板里每个插件面板都带一个“放大”按钮,把同一个面板移到大对话框里显示。同一时刻只有一个实例;
webview在那里占大约 72vh 的高度,不受height限制。这是宿主的行为,spec 不用声明。
宿主只提供已有的挂载位置,不关心面板的业务:它不知道面板是推荐、骰子还是地图。要新增一个产品级的全局位置,仍然需要宿主来提供。
HTML 文件的要求
webview.entry相对于这个 JSON 文件,必须是插件根目录内的.html。真实路径(包括符号链接指向的位置)不能越出插件目录。- 单个文件最多 512 KiB。
- 可以用原生 DOM、Canvas、SVG,或把 React / Vue 等构建成一个包含脚本和样式的单个 HTML。
- 不用改 Web 源码,也不用重新构建框架。开发时社区插件的文件变化会自动重载注册表;其他环境要重启服务端。
沙箱
HTML 跑在两层 sandbox="allow-scripts" 的 iframe 里。每一层都是独立的 opaque origin,拿不到宿主的 DOM、cookie、localStorage 或 provider key。
| 层 | 是什么 | 作用 |
|---|---|---|
| 外层 | Web 构建自带的静态文档 /plugin-frame.html,按 URL 加载,带自己的 CSP(不继承主页面的策略) | 只做两件事:把插件 HTML 放进内层;传递一次桥接端口。它的 frame-src 'none' 阻止内层导航到网络地址。 |
| 内层 | 插件的 HTML,通过 srcdoc 加载,继承外层的策略 | 可以用内联脚本(包括 onclick 这类内联事件属性)、内联样式、DOM、Canvas、SVG 和动作桥。 |
插件 HTML 不需要为这套结构做任何改动。需要知道的限制:
- 插件改不了外层的策略,也不能让外层或顶层页面跳转。
- 内层文档重新加载后,不会再拿到桥接端口。HTML 文件变了,宿主会重新创建整个容器。
- CSP 禁止 fetch、表单提交、加载外部脚本和样式。依赖和资源要打包成内联内容或 data 资源。
- 不要在 HTML 里直接调用 Covel 的 HTTP API。
- 桥接传来的状态属于插件可读的数据,不应该含密钥。
- 这条界面边界不改变服务端插件代码的信任模型,也不提供 CPU、内存或渲染资源的配额。
window.covel
宿主往页面里注入 window.covel:
const unsubscribe = window.covel.subscribe(
({ data, locale, locked, context, uiState, theme }) => {
// Render with textContent or your UI framework's escaping.
},
);
const snapshot = window.covel.getState();
const response = await window.covel.invoke("invokePluginAction", {
action: "refresh",
payload: { limit: 10 },
});状态里的字段:
| 字段 | 内容 |
|---|---|
data | 本插件声明的 namespace 的数据。更新和删除都随宿主的数据源传过来。 |
locale | 当前语言。 |
locked | 交互锁。宿主也会拒绝锁定期间发来的动作。 |
context | 在舞台上是 { surface: "stage", turnId, turnIds, choices }。turnIds 包含属于当前叙事的已提交重试;choices 是当前的 { id, text }[]。插件可以用它们核对回合和当前选项,把过期的结果藏起来。 |
uiState | 这个面板的临时状态,见下。 |
theme | 当前风格方案,见下。 |
跟随主题
theme 是 { id, scheme: "light" | "dark", tokens }。tokens 是一组已经解析好的字符串:
background、surface、foreground、mutedForeground、border、accent、accentForeground、success、warning、danger、radiusControl、radiusCard、fontSans、fontSerif、fontDisplay。
宿主同时把它们写成文档根元素上的 CSS 变量,名字是驼峰转连字符:--covel-background、--covel-font-sans 等。它还设置 color-scheme 和 data-covel-scheme。所以样式表里可以直接写 color: var(--covel-foreground),玩家切换方案或明暗时自动更新。
NOTE
沙箱里加载不了宿主的字体文件。字体变量只在系统已经装了对应字体时生效,记得写回退字体。
可以发出的动作
桥只接受宿主明确提供的动作:
| 动作 | 哪里可用 | 说明 |
|---|---|---|
invokeRuntime、invokePluginAction、invokeCommand | 都可用 | 执行类动作,自动绑定当前插件。 |
emitEvent({ topic, data }) | 都可用 | 发出本插件声明的事件。 |
draftMessage({ text, selectionGroup? }) | 右侧面板 | 把一句话放进待发送区,由玩家确认后发出。 |
sendMessage({ text }) | 舞台 | 把文字填进舞台输入框,由玩家确认发送。 |
setUiState({ value }) | 都可用 | 保存面板的临时状态,见下。 |
不能通过参数改写 pluginId 去调用别的插件。需要和别的插件配合时,从自己的 runtime 里用 ctx.services 或事件,不要在浏览器里读对方的私有数据。
执行类动作返回现有的 RPC 响应。后台的 accepted 只表示任务已被接收,不代表完成。桥接通信和调用的异常以不含服务端内部细节的错误返回。每个挂载实例有自己的连接,卸载时关闭。插件拿不到宿主里的任意函数。
面板的临时状态 uiState
uiState 是当前面板的一份临时 JSON 状态,初始为 null。用来保存草稿这类不必进服务端的东西。
await window.covel.invoke("setUiState", { value: { draft: "..." } });- 每次调用整体替换。传
value: null清空。成功返回{ status: "ok" }。 - 编码成 JSON 后最多 32 KiB(UTF-8)。非法值或超限会被拒绝,旧值保留。
- 它和
data互不相干:不写服务端,不调用 runtime。 - 这个动作同样受交互锁约束。
它能保留多久:
- 侧栏按会话、插件和面板分别缓存,切换标签后可以恢复。
- 需要恢复草稿的面板应声明一个稳定的
id。没有id的面板用它在包里的顺序生成身份。 - 离开会话、销毁侧栏宿主或刷新页面,状态丢失。
- 不提供缓存的其他位置只保留到面板卸载。
TIP
在第一次收到宿主状态时恢复草稿。之后的状态推送不要再覆盖输入框,否则会冲掉玩家正在输入的内容。
可安装的组合示例
- Clickable Map(在本仓库里):自绘面板的最小示例。
webview用宿主的方案变量画地图;点击经emitEvent交给事件 runtime 校验并写入;draftMessage把移动交给叙事;session.summary@1报告当前位置。pnpm e2e:extensions在真实浏览器里跑它。 - 官方记录工作台 demo 与两个处理服务(在插件仓库里,不随 Covel 一起安装):展示包级 HTML UI、
/notes命令、手动函数 runtime、服务发现,以及可以替换的只有 entry 的提供者。工作台只写自己的记录,服务只负责计算;原样保存、格式整理和项目列表都不用模型。新增的提供者通过examples.note-format@1契约接入,框架和工作台都不按提供者 ID 分支。
开发时查看注册与调用
在会话里输入 /plugins,打开调试页的插件视图。输入 /plugins my-plugin 则只看这个包,以及它作为调用方或提供方的服务记录。
这个命令由框架的命令目录提供:可以搜索、自动补全,走现有的命令 RPC 校验和追踪。它不消耗玩家回合,也不调用模型。/debug 和 /trace 仍然打开原来的调试入口。
插件视图里有什么
- 安装来源,以及当前会话里的启用和授权状态。
- runtime ID。
- 实际注册的工具、Hook、RPC action、服务,以及声明的 slash command。
读这个视图时注意:
registered只表示命令的 action 已经注册。实际执行时仍检查命令参数、会话和对应 action 的权限。- 没启用或没授权的包,不会显示成当前可用的注册能力。
- 这次查询只读宿主的注册表和会话的依赖解析结果。它不触发 entry、服务发现或任何插件代码。
- 错误信息用固定的说明文字。宿主识别出注册错误时,会展示结构化的
registrationError(code/registration)。具体的异常要去服务端日志里找。
最近的服务调用
| 项目 | 说明 |
|---|---|
| 保存在哪里 | 只在当前服务进程的内存里。重启后清空;请求落到别的进程时看不到这边的历史。 |
| 数量 | 全进程最多 500 条。每次返回当前会话最近的 100 条,可以按插件筛选。 |
| 记录什么 | 谁调了谁、属于哪个 runtime 和回合、耗时、成功 / 失败 / 超时 / 取消,以及固定的错误分类。 |
| 不记录什么 | 参数、结果、原始异常、会话的私有标识。 |
所以这个窗口不能当作持久审计或用量统计。其他细节:
- 没有取得调用方准入范围的调用,不进入会话历史。
- 会话删除后用同一个 ID 重建,读不到旧记录。
- 没匹配到服务的请求用
<unavailable>标识。 - 过长的诊断字符串截到 256 个 UTF-16 单元,并标上
...[truncated]。 - 页面支持手动和自动刷新。会话和筛选条件不变时,刷新保留当前列表,滚动位置不会被清空。切换会话或筛选条件时立即隐藏旧数据;查询失败时也清除已显示的数据。
接口见插件诊断 API。本地组合调试先用 test-runtime 的 --with-plugin,再用真实的服务端验证审批、slash 命令、Hook 和界面。
扩展调用的失败与 trace
一次扩展点调用的输出要过三步:扩展点的 output schema、权威归属处理、最终的 schema 校验。三步算作同一次调用的结算。
- 槽位类型不匹配这类最终错误,记为一次
error / output-validation,然后由扩展点的onError决定 skip 还是 fail-turn。不会先记一次 success 再补一次 failure。 - 超时之后才到的结果不会追加一条 success。
持久 trace。 带回合 emitter 的扩展调用、压缩调用和函数服务调用,用同一种完成事件 plugin.service.completed 写入持久 trace。嵌套调用继承 emitter,并带上 parentCallId。
- 事件只包含身份、扩展点的 point / id / slot、耗时和固定的结果分类。
- 事件不含调用的输入、输出、原始错误、凭据,或会话的私有标识。
- 它复用现有 TurnEmitter 的 traceId、seq 和重试范围,并等写入尝试完成。存储失败时按 trace 一贯的“尽力记录”处理,不改变插件的结果。耗时不包含写 trace 的时间。
- 没有回合 emitter 的界面后台投影,只留在进程内的诊断窗口里。
提供者被跳过时去哪里找原因。 onError 为 skip 的扩展点跳过一个失败或超时的提供者时,服务端日志里有一行:
[plugin-extensions] skipped provider <pluginId>/<extensionId> of <point> for session <sessionId>: <原因>trace 事件不含原始错误。自己的片段或槽位没有出现时,作者在这一行里找原因。
宿主的调用方可以在 createExecution 的作用域里传 onProviderError,在跳过发生时拿到提供者标识和原始错误。历史压缩用它把原因写进回合 trace,见扩展点参考。
诊断里的统计。 诊断按当前返回的最近 100 条调用(全进程最多保留 500 条)计算每个 point / provider 的 total、success、error、timeout、cancelled。
- 缓存命中,以及同一次执行里被合并的并发调用,不增加事件和统计。
- 没激活或没批准的提供者在发现阶段就被排除,不算调用失败。
- 调用发出之后准入失败的,保留失败事件。
- 这份统计只代表当前窗口,会随旧记录被淘汰而变化,不是累计用量。
回合策略与角色表演契约
框架提供 schema,插件提供行为:turn-policy@1 是只读服务,character-actor@1 是只供委托的 agent runtime。它们不是内核扩展点。故事必须在任一提供方缺失时仍可运行,不能把它们放进故事包的 requires、必需契约输入或 needs。
SDK 导出 TURN_POLICY_CONTRACT、CHARACTER_ACTOR_CONTRACT,以及 turnPolicyRequestSchema、turnPolicyInputSchema、turnPolicyOutputSchema、characterActorInputSchema、characterActorOutputSchema;同名的 …JsonSchema() 导出 draft-7 JSON Schema,类型名分别是 TurnPolicyRequest、TurnPolicyInput、TurnPolicyOutput、CharacterActorInput、CharacterActorOutput。@covel/shared 重导出同一份 schema。对象严格校验,不接受额外字段。
| 契约值 | 字段与边界 |
|---|---|
| 策略请求 | actions: TurnAction[] 使用已解析行动的校验与提交边界;present: {characterId, name}[] 最多 32 人,名字最多 80 字符;recentSegments: StorySegment[] 最多 60 段,无 speakerId;可选 limits |
| 策略 handler 输入 | 请求加必需的 limits: {maxActors, maxSubagentCalls}。宿主填入剩余的新角色名额和调用次数;调用方只能调低 |
| 策略输出 | actors: {characterId, reason?, instruction?}[] 最多 32 人,可为空;reason 最多 500 字符,instruction 最多 2,000;必需 order: sequential / parallel、narrator: before / after / none、yieldHint: user / continue |
| 角色输入 | characterId、1–20,000 字符的 brief、可选且最多 2,000 字符的 instruction |
| 角色输出 | 1–8 个 {kind: dialogue / action, text},每段 1–4,000 字符;可选 intent 最多 2,000。没有 speaker、叙述段或 effects |
策略的角色必须是 present 中不重复的 ID;validateTurnPolicyOutput({present}, output) 提供相同的上下文校验,调用方不必重建宿主名额。宿主即使面对宽松的提供方 parser 也会校验规范输入输出,然后按提供方顺序收窄计划。已准入的角色再次发言只占调用次数;新角色名额用完时,跳过新角色但保留后面的已准入角色。计划不预占名额,真正委托时仍可能被拒绝。三个建议字段由编排插件解释,不会改变内核调度。
const plan = await ctx.services.call({
contract: "turn-policy@1",
input: { actions: ctx.playerActions, present, recentSegments: [] },
});services.call 接受完整的 {pluginId, name, contract, input} 或 {contract, input},不能只写一个定位字段。零个或多个候选都返回 unavailable;多个候选带歧义说明。策略契约还读取会话的精确 {pluginId, name} 选择。同包的两个服务也需要选择。已选服务缺失、停用、撤销或因故障暂停时,不会换用另一项;discover 仍返回列表。选择和注册表在执行准入时冻结,调用时仍检查实时权限。嵌套服务继承这个绑定;修改选择只影响下一次执行。
模型导入只接受 {present: string[], recentSegments}。宿主按名字或别名解析角色,补入当前执行的行动和名额,返回 actors[].character 名字;模型参数和结果都没有规范角色 ID。未知或歧义名字在调用服务前失败,原始 ID 不作为名字接受。先应用会话选择,再检查 modelFacing:选中代码专用服务时,模型工具缺席,不能转用别的服务。策略工具注解为 readOnly: true、stagesWrites: false、idempotent: false、concurrencySafe: false。
读服务也有提供方作用域的 ctx.random,流名为 service:<name>,遵守现有种子规则。导演服务只能使用调用路径原本提供的 gateway:函数调用可借用,工具或 agent 导入不带 gateway/utils。没有 gateway 时,提供方返回不可用,编排插件继续写兜底故事。注册与声明仍须对应,策略不能注册成写服务、runtime 输出或扩展 handler。
内置策略与可选故事组合
内置回合策略提供一个名为 turn-policy 的代码专用读服务,通过提供方的 getOwnSettings().strategy 选择五种算法。只启用它时无需会话选择;同时启用其他提供方才需精确选择。对话叙事的函数通过契约调用一次,因此 director 可以借用 gateway;故事 agent 不再导入一次策略。规划结果在进入模型前投影为名字,原始规范请求和角色 ID 留在代码边界。若自定义包注册多个同契约服务,仍需每次注册各有一条属性匹配的声明,不能按契约去重。