进阶:多个 runtime、数据契约、权限和发布
这一页讲一个插件长大之后会用到的东西:把工作拆成几个 runtime、让世界包给插件提供数据、在后台运行、访问外部接口、拦截工具调用,以及发布前要填的字段。例子是一个插件“fact-index”:一个 agent 从故事里提取事实,一个函数按需查询。
读这一页之前,先读完带本地代码的插件。每一节可以单独读,用到哪节读哪节。
多个 runtime 的布局
一个插件有多个 runtime 时,每个 runtime 住在 runtimes/ 下自己的文件夹里:
fact-index/
├── PLUGIN.md 包的说明、能力、共用的声明
├── README.md
├── server/index.js 可选:入口文件
├── schemas/facts.schema.json
└── runtimes/
├── extract/
│ └── RUNTIME.md agent:提取事实
└── query/
├── RUNTIME.md 函数:查询
└── handler.jsPLUGIN.md 只说“这个包是什么”,不再有 runtime::
---
description: Extracts and retrieves narrative facts.
requires: [narrative-engine@2]
contributes:
data:
facts:
accepts: [facts.index@1]
schema: ./schemas/facts.schema.json
---runtimes/extract/RUNTIME.md 是一个 agent。它没有 id,文件夹名就是它的名字,完整的名字是 fact-index/extract:
---
schedule: { stage: post-turn }
io:
inputs:
narrative:
from: { contract: narrative-engine@2 }
select: /segments
output:
contract: facts.index@1
schema: ../../schemas/facts.schema.json
recordAs: extracted-facts
---
Extract only the facts that `runtime-inputs.narrative.value` states.按每段的 speaker 区分谁在说话或行动;不要把 NPC 台词中的“我”归给玩家。输入选择的规则见故事输出。
提取器要区分渲染后的正文指针与原始输入。需要精确数据上下文时,可以在自身 PostContextAssembly 中从 payload.inputSlots 裁剪;时间插件只保留当前时钟与正文。对有限目标集合,还可像世界事实提取一样,在完成工具里检查逐项覆盖。
runtimes/query/RUNTIME.md 是一个手动触发的函数。它有 function 块,代码在同一个文件夹的 handler.js 里:
---
schedule:
trigger: { type: manual }
io:
output: { contract: facts.query@1 }
function:
timeoutMs: 90000
---这里新出现的东西:
| 这一段 | 意思 |
|---|---|
io.output.contract | 这个 runtime 的输出就是这项能力的内容。插件因此提供这项能力,别的插件可以 requires 它 |
io.output.schema | 输出要符合的形状。不符合时这次运行失败 |
io.output.recordAs | 把输出保存下来,取一个名字。以后的回合可以用 scope: committed 读到它 |
schedule.background | 不写时调用方等它完成;写 true 是放到后台,见后台工作与失败 |
规则:
- 同一个插件里,不能有两个 runtime 输出同一项能力。
- 插件提供的能力就是各个 runtime 的
io.output.contract,不用再写provides。只有不由任何 runtime 输出的能力(例如由入口文件里的服务提供)才写进provides。 - 路径相对于文件所在的位置。
RUNTIME.md里的./handler.js在它自己的文件夹里;PLUGIN.md里的路径从包的根目录算起。路径和符号链接都不能指到包外面。 RUNTIME.md里不能写contributes、id这些属于整个包的字段。设置项、数据声明、工具清单都写在PLUGIN.md里,所有 runtime 共用。- 多 runtime 的插件里,每个 agent 用
agent.tools.plugin列出自己要用的工具。
校验时,传文件夹、PLUGIN.md 或任何一个 RUNTIME.md 都会检查整个包:
pnpm validate:plugin ~/.covel/plugins/fact-index
pnpm validate:plugin ~/.covel/plugins/fact-index/runtimes/query/RUNTIME.md✓ Static package validation: /Users/you/.covel/plugins/fact-index
languages: text en; instructions en离线运行其中一个 runtime 时,写完整的名字:pnpm test:runtime -- fact-index/query --pretty。只写 fact-index 是运行它的全部测试用例;多 runtime 的插件没有用例文件时,这样写会报 plugin "fact-index" has no tests/runtime-cases.json or covel.test.json。
数据契约与世界导入
世界包可以给插件带数据:一个侦探世界可以带上“开局已知的事实”。做法是插件声明“我的这个数据空间接收哪种数据”,世界包把文件放到约定的位置。
声明接收什么
上面的 PLUGIN.md 里已经有了:
contributes:
data:
facts:
accepts: [facts.index@1]
schema: ./schemas/facts.schema.json意思是:facts 这个数据空间接收名为 facts.index@1 的数据,每条记录要符合 schema。
- 数据空间接收了某种数据,它的
schema就是这种数据的公开形状,不用在contracts里再写一遍。 - 只有不被任何数据空间接收的能力(例如某个 runtime 的输出)才需要写
contracts.<名字>.schema。 - 几个插件都接收同一种数据时,它们的 schema 必须一致。不一致会在加载或导入之前报错。
告诉世界作者怎么写
再加一段 authoring,世界作者和创作工具就知道这份内容该放在哪、怎么写:
contributes:
data:
facts:
schema: ./schemas/facts.schema.json
accepts: [facts.index@1]
authoring:
title: Known facts
hint: >-
Write one record for each fact the story starts with. Keep `text` to
one sentence.
example: ./examples/facts.json
source:
path: data/facts.yaml| 字段 | 意思 |
|---|---|
title | 这类内容的名称,给作者看 |
hint | 怎样写好这类记录。用简短明确的英文句子 |
summary | 给玩家看的一句简介。应用内的创作界面用它当选项说明 |
example | 一份合法的示例(JSON)。它会按上面的 schema 检查 |
source | 这份内容在世界包里的约定位置:path,以及可选的 key(默认 id)、visibility: hidden 和 lorebook: true。文件格式由 path 推出:.yaml、.json,或没有扩展名的媒体目录 |
references | 记录里哪些值写的是世界角色属性的 id。pnpm validate:world 会在开局前检查它们,写法见 插件参考 |
generate | 允许应用内的世界生成器只凭 hint 和 example 生成这份内容,见下文 |
title 是标签,所以清单里写英文,中文写在 locales/zh.yaml:
PLUGIN.md:
contributes:
data:
facts:
authoring:
title: 已知事实世界包把文件放在 source.path 写的位置,它就会被导入,不需要再写别的配置。同一个会话启用的两个插件声明了同一个 path 时,这个路径不再算约定位置,所以选一个别的插件不会用的路径。内核和世界包自己的路径(如 data/dimensions.yaml、world.yaml)不能声明,列表见世界数据。
generate 有两个取值:offer 把这类内容列为创作界面里的可选项,default 列出并默认选中。它要求有 example,并且 source 是公开的、不是媒体、以 id 为 key。玩家选中后,生成器会把 schema、提示和示例写进它的提示词,并把这个插件加进世界要启用的插件里。只给在真实模型上验证过生成质量的内容声明它。
检查声明
pnpm describe:authoring --plugins ~/.covel/plugins这条命令把所有插件的 authoring 汇总成一份说明。里面会多出你的这一段:
### Known facts — `facts.index@1` (plugin `fact-index`)
How to write it: Write one record for each fact the story starts with. Keep `text` to one sentence.
Put the file at `data/facts.yaml`: it is imported without a descriptor. The same source in a descriptor:
facts:
kind: yaml
path: data/facts.yaml
schema: contract:facts.index@1
to: contract:facts.index@1
key: id世界创作 skill 和应用内的生成器读的是同一份数据。所以新插件声明了 authoring 之后,不需要再去改别的文档。
加上 --check,它会按 schema 检查每一份示例。全部合格时最后一行是:
Authoring examples match their schemas.世界那一边
世界作者按约定位置放文件即可。需要自己指定来源时,在世界的数据描述文件里写 to: contract:facts.index@1。Covel 把数据交给当前启用的、声明接收它的插件。世界不会、也不能点名某个插件的数据空间。
想把世界的其他内容换算成自己的数据,用 contributes.worldProjections,它的输出必须对应一个可导入的数据空间。完整规则见世界数据参考,字段见插件清单字段表。
角色卡这类业务格式归对应的插件所有。Covel 只处理通用的“来源 → 目标”和角色本身,不会为某个插件单独包装数据。插件给某个角色的数据可以写在世界的角色记录里:在接收的数据空间上声明 authoring.characterField,Covel 按角色 ID 把记录里这个字段交给你,见一个世界一份角色文件。
服务与扩展
两种让别人调用你的代码的方式:
| 方式 | 谁来调用 | 声明在 | 注册用 |
|---|---|---|---|
| 服务 | 别的插件,在它需要的时候主动调用 | contributes.services | covel.registerService(...) |
| 扩展 | Covel,在固定的时机来问你要内容或策略 | contributes.extensions | covel.provideExtension(point, id, …) |
- 两者都用带版本号的名字,输入输出都会按 schema 检查。
- 注册的名字必须和清单一致。
contributes.prompt里的固定片段由 Covel 自动注册,不用再声明成扩展。- 函数 runtime 用
ctx.services、工具用context.services调用服务。注册时写modelFacing: true和description、在清单里写成{contract, modelFacing: true}的服务,agent 还可以在agent.tools.services里导入成工具,见给模型用的服务。
读服务(默认)的 handler 可以读取提供者自己的非隐藏数据,不能写入:
const item = await ctx.pluginData.get("items", key);
return { item };别的插件需要改你的数据时(商店买东西要扣你背包里的钱),提供一个写服务:注册和清单都写 access: write(清单里是 {contract, access: write})。它的 ctx.pluginData 能写,写入暂存为一个操作,随调用方的回合一起提交或丢弃;它拿不到模型和 HTTP,不能写 _ 开头的命名空间,update 不成立时抛 conflict:
covel.registerService({
name: "change",
contract: "my-inventory.change@1",
access: "write",
input: z.object({ item: z.string(), cost: z.number().int().min(0) }),
output: z.object({ ok: z.literal(true) }),
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 },
]);
return { ok: true };
},
});在哪里能调用、怎样提交、模型看到什么,见写服务。不用起一个回合就能测它:callTestService。
每个扩展点的输入、输出、多个提供者怎样合并、出错时怎样处理,见插件扩展与通信和扩展点参考。
IMPORTANT
如果现有的扩展点表达不了你的需求,不要去读别的插件的私有数据来绕过。那是它的内部实现,随时会变。
后台工作与失败
不让玩家等
有三种让 runtime 不挡住回合的写法:
| 写法 | 效果 |
|---|---|
schedule.detached: true | 回合先结束,符合脱离条件的函数叶节点在持久队列中继续;只支持 post-turn 和 audit |
schedule.detached: { settle: true } | 后台继续,但玩家的下一个行动会等它完成 |
schedule.background: true | 手动或事件触发的 runtime 放到后台,调用方立刻拿到一个任务编号 |
由事件触发的 runtime 写 schedule.trigger: { type: event, topic: ... }。
脱离回合的节点只能是函数,不能有仍在前台等它结果的下游;它不接受递归调用或挂起。手动、事件后台任务属于另一种独立执行。校验和运行时都会检查;未满足脱离条件的节点保留在前台并报告诊断。完整条件见回合管线。
在后台运行、并且自己调用模型的函数,要声明用的是哪个模型用途:
function:
model: memory代码里调用 ctx.gateway 时传同一个名字(presetId: "memory")。Covel 用这个声明判断模型的密钥是否就绪,再决定要不要开始这个后台任务。这一行不会替你改代码里的参数,两边要自己写成一样。
插件只能写用途名。用哪个模型、请求发到哪里,由玩家的模型设置决定。在插件目录里放一个 llm.toml 不起作用,pnpm validate:plugin 会提醒你删掉它。
时限
| 类型 | 字段 | 意思 |
|---|---|---|
| 函数 | function.timeoutMs | 一次运行的总时限。调用外部接口的时间也算在里面 |
| agent | agent.loop.timeoutMs | 一次运行的总时限。默认 120 秒,story runtime 240 秒 |
| agent | agent.loop.callTimeoutMs | 单次模型调用的时限(不是流式输出的调用)。默认 60 秒,story runtime 120 秒 |
| agent | agent.loop.maxRetries | 模型调用拿不到回复时重试几次。默认 3,story runtime 1;最大 5 |
完整的规则见插件字段参考。
循环的行为
agent 循环的其他行为来自一个循环 profile:文字是否边写边显示给玩家、是否读玩家的插话、模型反复调用同一个工具时怎么办。profile 按 io.visibility 推导,一般不用写。
只回答一段简报、不写故事正文的 agent 可以用 actor。它和 system 的区别是:模型打转时,actor 关掉工具直接作答,system 失败。
agent:
loop:
profile: actor
finalizer:
deadlineReserveMs: 10000 # 离时限还剩 10 秒时不再调用工具,直接作答子 agent 不写 profile 时就用 actor。字段、取值和检查规则见插件参考 · 循环 profile。
子 agent
子 agent 是交给另一个 agent 做的一件小事:给它一段简报,它回一份结果。比如编排者让“守卫”这个角色说一句台词。子 agent 在调用方的这次执行里运行,只交回输出,不写数据,也不发事件。
提供方是一个 agent runtime,写 schedule.subagentOnly: true。调度器从不自动运行它,玩家也不能手动运行它:
# runtimes/guard/RUNTIME.md
schedule:
subagentOnly: true
io:
output:
contract: gate-actor@1
schema: ./output.schema.json
agent:
model: actor
tools:
builtin: [get-character]函数 runtime 用 ctx.agents.run 调用它:
export default async function handler(ctx) {
const reply = await ctx.agents?.run({
target: { contract: "gate-actor@1" }, // 或 { runtimeId: "my-plugin/guard" }(本包)
brief: "A stranger asks to pass the gate.",
data: { stranger: "Mara" },
budget: { maxSteps: 4, timeoutMs: 20000 },
});
if (!reply?.ok) return { outcome: "success", value: { line: "..." } };
return { outcome: "success", value: { line: reply.output.line } };
}agent runtime 在 agent.tools.builtin 里声明 delegate,模型就能调用它,参数是 target、brief,可选 data 和 maxSteps。
- 子 agent 只看到简报和
data,看不到会话历史和调用方的对话。 - 它只能用只读工具。提供方声明的写入工具、
suspend和delegate都会被拿掉。 - 失败不会抛异常:结果是
{ ok: false, error: { code, message } },由调用方决定怎么办。 - 一次执行最多同时跑 8 个子 agent,子 agent 不能再调用子 agent。满了直接拒绝(
unavailable),不排队。 - 调用方被取消或超时,子 agent 跟着停下。
- 它用自己声明的模型用途。玩家可以在会话里为这个 runtime 单独换模型。
完整的请求、结果和错误码见插件参考 · 子 agent。
失败时
- 运行失败的 runtime,它的写入一条都不保存,它发的事件也不会触发别的 runtime。
- 所以只把合法的结果交回去。不要“先写一半,出错再补”。
- 同一个操作可能被重试。让重复执行得到同样的结果:用稳定的键写数据,而不是每次生成新的键。
- 长时间的工作要响应
ctx.signal:玩家取消时尽快停下。
不是工具的能力
函数里有几样东西是直接调用的,不经过工具清单和授权流程:ctx.gateway(调用模型)、ctx.images / ctx.speech / ctx.music(生成媒体)、ctx.media(保存媒体)、ctx.utils(网络请求)。
它们都写在 PluginFunctionContext 类型里。宿主配好了模型和媒体存储才会给,用之前先判断有没有:if (!ctx.images) return …。
生成图片时,在函数里直接调用 ctx.images.generate(...)。不要做一个让模型调用的“生成图片”工具,也不要自己写请求去调用图片服务商。细节见图片生成和媒体存储。
权限
访问外部接口
要访问外部接口的 runtime,先声明它会访问哪些地址:
permissions:
http:
- origin: https://api.example.com
methods: [GET]origin只能是https地址,不带路径、参数和账号密码。methods可选,取GET、POST、PUT、PATCH、DELETE、HEAD。- 请求用
ctx.utils.fetchWithRetry发出。社区插件访问没有声明的地址会被拒绝。 - 入口代码用
covel.http.fetchWithRetry发请求时,在根PLUGIN.md声明permissions.http。包级权限与 wire 端点的规则见插件参考。 - 不要把玩家输入的任意网址直接交给后端去抓取。先用
ctx.utils.validateBaseUrl检查。
WARNING
声明权限不等于得到权限。社区插件的代码要玩家授权之后才会运行,授权、限额和网址检查都由 Covel 执行。边界和原因见安全边界。
说明读写什么
effects 说明这个 runtime 读什么、写什么:
effects:
reads: ["plugin-data:self:facts"]
writes: ["plugin-data:self:facts", "http:https://api.example.com"]可以写的值:
| 写法 | 意思 |
|---|---|
plugin-data:self:<数据空间> | 自己的某个数据空间 |
event:<事件名> | 某个事件 |
http:https://<主机> | 某个外部地址 |
state:*、narrative:*、characters:*、lorebook:*、assets:*、media:*、ui:*、interaction:* | 对应的一类共用资源 |
parallelSafe: true 表示它可以和同一阶段的其他 runtime 同时运行。会改动共用数据或访问外部接口的 runtime 都应该声明 permissions 和 effects。
Hook
Hook 让你在固定的时刻插一脚:工具调用之前、模型调用之前、回合开始时,等等。它只用来拦截、改写或记录,不要把玩法写在里面。
在 PLUGIN.md 里列出要监听的时刻:
contributes:
hooks: [PreToolUse]在入口文件里注册。两边必须一致:
export default function register(covel) {
covel.on("PreToolUse", async (ctx, payload) => {
if (ctx.runtimeId !== "fact-index/extract") return { action: "continue" };
if (payload.toolCall.name !== "plugin-data-delete") {
return { action: "continue" };
}
return { action: "abort", reason: "This runtime may not delete facts." };
});
}- 要判断 hook 说的是哪个 runtime,就读
ctx.pluginId和ctx.runtimeId。payload里没有它们:读payload.runtimeId永远对不上,这个 hook 就什么都不做。 ctx.locale是这一局游戏的语言。往提示词里加文字时,用instructionLocaleFor(ctx.locale)决定用哪种语言,不要从提示词的文字去猜。ctx.getOwnSettings()读这个插件自己的设置。
有哪些时刻、每个时刻带什么数据、可以返回什么,见 Hook 参考。
世界模型与记忆
ctx.world给出的是“这次运行看到的世界”:已保存的状态,加上这一回合里前面的步骤提出、但还没保存的合法修改。- 改角色、改角色的字段定义,都走同一套检查。
- 不要把前面步骤的写入请求复制进自己的结果里再提交一遍。
- 记忆块是 memory 插件的数据和服务。世界自定义的记忆块通过
memory.blocks@1导入,默认的定义由memory.block-definitions@1服务提供。历史压缩通过history.compact@2扩展点处理。清单里没有memoryBlocks、summaryFocus这样的字段。
细节见世界模型参考。
本地化与热重载
- 提示词的中文版是包根目录的
PLUGIN.zh.md和每个 runtime 的RUNTIME.zh.md。别的语言的同类文件不会被读取。 - 中文版可以只写要翻译的部分,没写的沿用英文版。
- 中文版改了不该改的东西(片段的 ID、位置、输入、工具、时限)时,校验会报告,运行时仍然用英文版的值。
- 提示词在插件加载时读入,运行期间不再读磁盘。热重载之后,新开始的运行用新内容,已经开始的运行继续用它开始时的内容。所以不要在代码里自己重新读清单。
发布之前
填写发布字段
在 PLUGIN.md 里加上:
version: 0.1.0
covel: ">=0.0.51"
author:
name: Jane Doe
url: https://example.com
about: I write small mystery worlds.
license: MIT
homepage: https://example.com/fact-index| 字段 | 意思 |
|---|---|
version | 插件的版本。只写在这里,package.json 不写版本 |
covel | 适用的 Covel 版本范围。范围之外的 Covel 不安装也不加载它。写法见合集 · 版本范围 |
author | 作者名、主页、一段写给玩家的话、几个链接。链接只能是 https |
license | 许可证 |
homepage | 插件自己的页面 |
字段的长度限制和显示位置见作者信息。
WARNING
version 变了之后,已有的游戏会重新运行这个插件的开局 runtime(setup 阶段)。开局 runtime 的 guard 必须检查“这件事是不是已经做过”,做过就返回 { skip: true }。
写 README
写清楚这几件事:玩家能看到什么、输入和输出的能力、设置项、需要的权限、有哪些 runtime、怎样测试。
检查包里的东西
- 带
package.json时,它写了"type": "module"。不带也可以。 - 用户目录里的插件必须自己能运行,不依赖
npm install。 - 不要带上密钥、生成的数据和你电脑上的路径。
最后跑一遍
pnpm validate:plugin ~/.covel/plugins/fact-index
pnpm test:runtime -- fact-index --pretty第二条命令运行 tests/runtime-cases.json 里的全部用例,所以发布前至少给每个 runtime 写一个。
仓库内置的插件再加上自己的单元测试和全仓检查:
pnpm --filter @covel/plugin-fact-index test
pnpm check测试要覆盖的重点:数据形状的边界、不能读写别人的数据、重复调用、失败之后没有留下半截数据、同一次运行里先写后读。写法见插件测试。打包和安装见打包并试装。
不常用的字段
下面这些字段内置插件都没有用到,有特殊需要时再写:
| 字段 | 意思 |
|---|---|
agent.loop.maxSteps | 工具调用最多几步 |
agent.loop.firstTokenTimeoutMs | 流式调用里,等第一个字的时限。第一个字本来就慢的模型要写它,并把 timeoutMs 调大 |
agent.loop.idleTimeoutMs | 流式调用里,两次输出之间最长的静默。默认 120 秒 |
agent.loop.loopDetection | 模型连续重复同一个调用多少次算打转。默认 3 |
agent.loop.maxRecursionDepth | ctx.recursiveCall() 最多嵌套几层。默认 10 |
contributes.events[].advertise: false | 写故事的模型看不到这个事件,它只给界面和代码用 |
contributes.prompt[].position: {depth: N} | 按消息深度把静态提示词段插进历史 |
contributes.commands[].context | 命令调用 action 时带上的宿主信息 |
contributes.commands[].arguments[].variadic、choices | 把剩下的词都收进最后一个参数;列出参数可选的值 |
contributes.wires | 入口文件用 registerWires 注册的服务商 wire |
contributes.settings[].type | 除了 number、toggle、select,还有 text、textarea、integer、slider、slot |
全部高级字段见插件参考 · 高级字段。
没有按预期工作时
| 现象 | 原因和处理 |
|---|---|
校验报 inline runtime and runtimes/ cannot coexist | PLUGIN.md 里还留着 runtime:。多 runtime 的包里删掉它 |
校验报 ambiguous output contract … | 两个 runtime 输出了同一项能力。每项能力只由一个 runtime 输出 |
| 世界包的数据没有导入 | 文件不在 source.path 的位置,或者别的插件声明了同一个路径,或者这个插件没有启用 |
describe:authoring --check 报示例不合格 | example 文件不符合 schema |
| 后台 runtime 一直排队 | 它调用了模型,却没有声明 function.model |
| 请求外部接口被拒绝 | 地址没有写进 permissions.http,或者不是 https |
| hook 注册了却从不生效 | 在 payload 里找 runtime 的名字。改成读 ctx.runtimeId |
| 升级版本后开局步骤又跑了一遍 | 正常。让开局 runtime 的 guard 对已完成的工作返回 { skip: true } |
接下来
用可选角色拼成一个故事
这个社区包用两个可选策略和一个通用角色 agent 组合故事,只导入公共 SDK。先建 ~/.covel/plugins/conversation/。根 PLUGIN.md:
两个注册需要两条对应的服务声明,即使它们使用同一契约。
---
version: 0.1.0
description: Compose a conversation with optional character contributions.
contributes:
services:
- { contract: turn-policy@1, modelFacing: true }
- { contract: turn-policy@1, modelFacing: true }
---server/index.js:
import {
TURN_POLICY_CONTRACT,
turnPolicyInputSchema,
turnPolicyOutputSchema,
} from "@covel/plugin-handlers-utils";
export default function register(covel) {
for (const name of ["ensemble", "draw"]) {
covel.registerService({
name,
contract: TURN_POLICY_CONTRACT,
modelFacing: true,
description:
name === "ensemble"
? "Invite everyone present"
: "Invite one character",
input: turnPolicyInputSchema,
output: turnPolicyOutputSchema,
handler: (input, ctx) => ({
actors: (name === "ensemble"
? input.present
: input.present.length
? [input.present[ctx.random.int(0, input.present.length)]]
: []
).map(({ characterId }) => ({ characterId })),
order: "parallel",
narrator: "after",
yieldHint: "user",
}),
});
}
}runtimes/actor/RUNTIME.md:
---
schedule:
subagentOnly: true
io:
output:
contract: character-actor@1
---
Play only the supplied character. Return dialogue/action segments and optional private intent.runtimes/story/RUNTIME.md:
---
schedule:
stage: narrative
io:
visibility: story
output:
contract: narrative-engine@2
function: {}
---runtimes/story/handler.js:
import {
TURN_POLICY_CONTRACT,
CHARACTER_ACTOR_CONTRACT,
turnPolicyOutputSchema,
characterActorOutputSchema,
} from "@covel/plugin-handlers-utils";
export default async function compose(ctx) {
const present = ctx.world.characters.map(({ id, name }) => ({
characterId: id,
name,
}));
const fallback = {
kind: "narration",
text: "The conversation continues in the quiet room.",
};
let plan;
try {
plan = turnPolicyOutputSchema.parse(
await ctx.services.call({
contract: TURN_POLICY_CONTRACT,
input: { actions: ctx.playerActions, present, recentSegments: [] },
}),
);
} catch {
ctx.signal.throwIfAborted();
return { outcome: "success", value: { segments: [fallback] } };
}
const run = async (actor) => {
const reply = await ctx.agents.run({
target: { contract: CHARACTER_ACTOR_CONTRACT },
characterId: actor.characterId,
brief: "Respond to the conversation in the room.",
instruction: actor.instruction,
});
ctx.signal.throwIfAborted();
if (!reply.ok) return [];
const answer = characterActorOutputSchema.parse(reply.output);
const speaker = present.find(
(person) => person.characterId === actor.characterId,
).name;
return answer.segments.map((segment) => ({ ...segment, speaker }));
};
const replies = [];
if (plan.order === "parallel")
replies.push(...(await Promise.all(plan.actors.map(run))));
else for (const actor of plan.actors) replies.push(await run(actor));
const segments = replies.flat();
if (plan.narrator === "before") segments.unshift(fallback);
if (plan.narrator === "after" || segments.length === 0)
segments.push(fallback);
return { outcome: "success", value: { segments, yield: "user" } };
}也可把 story 的 RUNTIME.md 换成下面的 agent 版本;不要同时安装两个故事 runtime。delegate 仍逐个执行:
---
schedule:
stage: narrative
io:
visibility: story
output:
contract: narrative-engine@2
agent:
tools:
builtin: [delegate]
services:
- { contract: turn-policy@1, as: choose-speakers }
---
Call choose-speakers with present character names and recent story segments.
Delegate each planned reply with target contract character-actor@1 and character name.
Combine successful segments in policy order, supply speaker names, and add narration.
Keep intent private. If a policy or actor is unavailable, continue with a valid narrator fallback.在会话“编排”里选择一个策略。Promise.all 保留计划顺序,即使角色完成顺序不同。策略缺失时直接叙述;角色缺失或失败时跳过那份回复,所有角色都失败也返回非空故事。yieldHint 是建议,本例固定交回玩家。两种入口共用名额与取消规则,intent 从不复制到最终故事。
验证命令沿用插件测试。框架回归从本页读取声明和代码并实际加载。契约、限制和模型名字适配的权威说明在编排契约。导演策略可以检查 ctx.gateway,缺失时抛 ToolError("unavailable", ...);不要给工具路径额外借用模型凭据。
采用内置组合
对话叙事采用上面的公开契约,但把只读策略调用放在 pre-turn 函数,把 delegate 保留在唯一故事 agent 的工具循环中。函数读取 ctx.world 的当前角色/座位,输出安全名字;故事消费可选的同包输入。director 只有函数调用路径能借到 gateway;模型导入不能绕过代码专用设置。演员/策略都可缺失或由社区包替换。完整玩家行为见群像对话。
历史段落只供策略选人,不自动加入共享演员简报;运行器按角色提供亲历历史。scene-cast@1 的结构化名单供规划器读取,activeCastContext 则作为可选输入供故事 agent 使用。