把 Agent 运行时装进 Bevy World:rig-ecs 的实体化 Effect Bus 与图式运行架构

原创2026-10-01 13:42:011,628 阅读
文章标签:AI AgentAgent 框架RAG后端

把 Agent 运行时装进 Bevy World:rig-ecs 的实体化 Effect Bus 与图式运行架构

rig-ecs 是 rig 生态中一个实验性 crate,它将 Rig 的 effect bus 与 agent 运行时整体迁移到 Bevy 的 World 中:效果(effect)和处理器(handler)都是实体,系统按调度集合分发工作并发布结果,关系组件承载因果与对话结构。本文以 crates/rig-ecs/README.md 为主体,结合 crates/rig-ecs/src 下的源码与 crates/rig-ecs/tests 中的回归测试,完整讲解其宿主模型、图式运行结构、调度边界、引导(steering)方式、检查点与重放,帮助你掌握如何在 Rust 中把 Agent 的一次次对话组织成可检查点、可重放、可被应用系统任意插入修改的实体图。

1. 宿主模型:一个插件、一次 update、一个 World

rig-ecs 的宿主形态极其简洁,官方 README 给出的核心用法是:

App::new().add_plugins(rig_ecs::RigPlugin::default())

app.update() 是 RigSchedule 的一轮 pass;默认 runner 会在任务 raise bus::Wake 时再次更新。也就是说,整个 Agent 运行时不是被某个循环驱动的,而是作为 Bevy 应用中的一个调度(schedule)被反复执行。对应的最小可运行代码(见 crates/rig-ecs/src/lib.rs):

let mut app = bevy_app::App::new();
app.add_plugins(rig_ecs::RigPlugin::default());
app.update();

从源码看,RigPlugin(crates/rig-ecs/src/lib.rs)做的事情包括:

  • 若应用没有 TimePlugin / DiagnosticsPlugin,自动补装;
  • 叠加安装 BusPlugin 与 systems::AgentPlugin;
  • 调用 checkpoint::register_types 注册全部反射组件类型。

RigPlugin 还提供两个构造器/方法:

  • RigPlugin::with_policy(ServingPolicy):直接以指定服务策略构建;
  • .ambiguity_detection(LogLevel):设置 Bevy 调度歧义检测级别(默认 Warn)。

1.1 两个重要边界

README 特别强调两点,这决定了 rig-ecs 的定位:

  1. 处理器任务与应用系统保留各自的执行开销:有界收集(bounded collection)不会让任意用户工作变成非阻塞。也就是说,stream_capacity、每 pass 的收集配额等只约束流式交付的工作量,并不约束 CPU 时间、载荷字节或用户系统的成本。
  2. 本 crate 只依赖 bevy_ecs 与 bevy_tasks:具体的日志与重放适配器位于 rig-cassette 的 ecs feature 中,本运行时从不依赖该 crate。日志录制通过 bus::Recording::install 挂接任意 rig_core::serve::Recorder,或通过 rig_cassette::ecs::EffectLogResource::install 保留一个 EffectLogRecorder;重放则需在 RigPlugin(或 BusPlugin)之后添加 rig_cassette::ecs::ReplayPlugin,再以 rig_cassette::ecs::Replay::register(&mut World, &EffectLog) 注册。插件安装录制交付收集器与空闲拒绝诊断,而运行时保留的是通用的调度、交付与观察机制。

1.2 裸 World 的安装方式

如果不想用 bevy_app 的完整插件机制,BusPlugin::install(&mut World)(crates/rig-ecs/src/bus/plugin.rs)可以把运行时装进一个裸 world,然后由宿主自行 world.run_schedule(RigSchedule)。注意:install 会 panic 如果 world 中已有 Policy 资源;同时它会将 command_capacity 强制 max(1),因为零配额会让 pending effects 永远无法被分发。

2. 一次运行就是一张图:实体、组件与关系

rig-ecs 最核心的架构决策是:一次 run 的所有状态都是一张实体关系图,而不是隐藏在运行时内部的数据结构。README 用一张表完整列出了一个 run 涉及的实体与组件(以下为原文核心表格的完整继承):

Entity Components
Agent Owner、Preamble、Temperature、MaxTokens、AdditionalParams、ToolChoiceSpec、Output { mode, schema }、OutputToolConfig、MaxTurns、DefaultMaxTurns、InvalidCalls;UsesModel → 模型的 handler 实体;Grant 链接实体 → 工具 handler 实体;Context 链接实体 → 文档
Document DocumentId、DocumentText、DocumentProps;通过 Attachment 链接挂到 turn 上
Utterance Utterance、Role、MessageId(assistant 才有);ChildOf run,带有序内容子实体
Content part ContentPart;ChildOf utterance 或 ContentPart::ToolResult 实体;工具结果上可选 ToolResultStatus
Run Run、RunOf → agent、RunSeq、StreamRequested、Cursor、RunPhase(LoadingMemory、Assembling、AwaitingModel、ResolvingTools)或结局(Settled、Failed(Failure))、RunResult、Usage、OutputRetries、OutputToolName、run 自身对 agent 设置的覆盖、bus 的 Scope、Name
Turn Turn、ChildOf run;Advert 链接 → 它广告的工具;Attachment 链接 → 它的文档;Outputs(流式时每 tick);Reprompt;工具调用在途时 Batch;systems::{Fresh, Folded, Materialised}
Effect bus 模块的,ChildOf turn:先是 completion,再是每次对被授权工具的调用(ToolCallSlot 指明是哪次调用;bus 的 ToolInputs 携带 run 的 ToolContextSpec)——批是 turn 的子实体,ToolPolicy { concurrency }(在 run 或 agent 上)决定同时飞多少个
Invalid call InvalidCall + Resolution,ChildOf turn
checkpoint rig_ecs::checkpoint::{save_world, load_world}:整个 world,图和效果一起

2.1 内容部分(ContentPart):不再是组件堆

README 明确说明了一个迁移:每条 utterance 拥有有序的 ChildOf/Children 内容实体,每个实体携带一个反射的 agent::content::parts::ContentPart 枚举。查询 &ContentPart(或 &mut ContentPart)然后匹配变体(如 ContentPart::Text(text))即可;原先分离的载荷组件已不存在。媒体元数据结构是枚举变体字段,而不是组件。ContentPart::ToolResult 拥有有序的文本/图片/JSON 子实体;ToolResultStatus 仍是独立组件。

两个实用 API:

  • read_message:重建角色校验过的 DTO;
  • write_message:在替换图之前校验来源。

二进制载荷通过 BinaryAssets 共享。实现位于 crates/rig-ecs/src/agent/content/parts.rs。

变体变化不是组件的增删。要做生命周期观察,应在 On<Insert, ContentPart> / On<Discard, ContentPart> 观察器中插入替换后的 ContentPart 并匹配变体;这些观察器不观察原地编辑。原先互斥的可变载荷查询现在访问同一个组件:要么用一个匹配查询,要么用 ParamSet。检查点格式 2 存储这些枚举组件;格式 1 的检查点会被拒绝,且没有旧组件适配器。

2.2 请求是推导出来的,不是手写的

README 的关键论断:模型看到的请求是派生(derived)的,绝不是手写的。组成要素包括:

  • run 实体;
  • 作为其 ChildOf、按 Children 兄弟顺序排列的 utterances;
  • 文档作为独立实体,由链接实体挂到 turn 上;
  • 工具就是 bus 已有的 handler 实体(由链接实体授权);
  • 模型是一个关系(relationship);
  • 每个设置都是一个组件。

然后由一个函数 policy::fold_request 完成折叠:fold_turn 在 RigSet::Assemble 时对它调用(gather_turn 遍历过的内容),把 wire 层的 CompletionRequest 写进 turn 的 PendingEffect。CONTRACT.md 逐字段命名了这次遍历以及固定它的 golden;world 解释器 crates/rig-cassette/tests/corpus/world.rs 通过维护中的语料库演练请求组装,覆盖工具、记忆与引导。

2.3 输出工具配置

OutputToolConfig(crates/rig-ecs/src/agent/mod.rs)可选设置输出工具(output tool)的保留名、描述与前缀增强(augment_preamble 默认 true)。run 的组件覆盖 agent 的。带有 schema 且显式命名时,输出以工具调用形式提交;与之冲突的已授权工具会在分发前失败。名字一旦铸成(minted),run 生命周期内不再变化。检查点与重放身份都会保留这份配置。

3. 调度:RigSet 与 BusSet 的完整边界

rig-ecs 的全部推进动作都发生在具名调度集合中。README 给出的 agent 集合表(完整继承)如下:

set true before written during
RigSet::Advance 一个处于 Assembling 的 run 没有新 turn 一个带 adverts 与 attachments 的 turn,或 Failed(MaxTurns)
RigSet::Select run 可能没有自己的模型 agent 的 UsesModel 被复制——之前的 routing 系统可能给 run 另一个
RigSet::Assemble 新 turn 的图完整 gather_turn 把图以 AssemblyInputs 留在 turn 上;fold_turn 折叠它并生成 effect;run 进入 AwaitingModel
RigSet::Patch 折叠后的 effect 是 PendingEffect 第二个引导槽:用户系统改写已折叠的请求
RigSet::Release turn 的工具批在途 release_batch 按调用顺序释放其 rig-ecs/batch owner 直到并发上限。策略使用 bus::acquire_hold / bus::release_hold,带各自稳定的 emitter 名;Held 保持到所有 owner 释放。预先存在的裸 Held 被保留为独立未知 owner
BusSet::Gate … BusSet::Judge
RigSet::Fold effect 可能已流式或落地 turn 上的 Outputs
RigSet::Judge turn 的输出完整 用户系统可改写它们,或改写工具子实体的 EffectOutcome
RigSet::Materialise 完整 turn 未读,或它的批已落地 land_batch:按调用顺序生成一条用户 utterance 的结果(CONTRACT §8),或失败;然后链 record_usage、judge_invalid_calls、read_turn、materialise_assistant、materialise_batch、materialise_reprompt、materialise_answer:assistant utterance、答案、reprompt、非法调用、工具批或失败(CONTRACT §4)
RigSet::Checkpoint 已提交的图写入可见 宿主的检查/保存槽,在下次 advance 之前
RigSet::Settle run 已 settled 或 failed append_memory(记忆 run 的追加);存在 DiagnosticsStore 时 diagnostics::measure;Settled/Failed 上的观察器

源码层面,这些集合定义在 crates/rig-ecs/src/systems/mod.rs。install_agent(同文件 L359-L426)把它们组织为两条链:Advance → Select → Assemble → Patch → Release 全部排在 BusSet::Gate 之前;Fold → Judge → Materialise → Checkpoint → Settle 全部排在 BusSet::Judge 之后。Materialise 内部还细链了十个系统,顺序即 README 所述。

bus 自身的集合(crates/rig-ecs/src/bus/plugin.rs):

set true before written during
Gate pending effects 如同刚 spawn 用户系统 patch PendingEffect、拒绝一个(EffectOutcome(Err(..)))、或 hold 一个(Held)
Dispatch 每个未 hold、未答的 PendingEffect 都是候选 插件按 Seq 顺序取到本 pass 的 intake 上限:Issued、InFlight、ServedBy、Serving/Streaming/Asked;记录开启
Collect handlers 可能已结束或已流式 插件写 Streamed、EffectOutcome;记录关闭(settle);InFlight 移除
Judge 本 pass 的 outcomes 已落地并记录 用户系统可在 Judge 之后的任何读取之前改写 EffectOutcome

关键语义(README 原话):决策是程序(program)的,不是记录(record)的——记录是 handler 在 Collect 槽之间答的内容。Gate 拒绝从未有过 InFlight,所以没有记录;Judge 改写是 Changed 而非 Added,所以不会被重复记录。despawn 一个 effect 会取消它(任务 drop,记录说 Cancelled),Bevy 会随之 despawn 它的 ChildOf 后代。

3.1 诊断指标

systems::diagnostics(crates/rig-ecs/src/systems/diagnostics.rs)在应用存在 DiagnosticsStore 时发布 bevy_diagnostic 路径——EFFECTS_IN_FLIGHT、EFFECTS_PENDING、RUNS_LIVE——每 pass 一次测量。RigPlugin 在缺失时补装 DiagnosticsPlugin 与 TimePlugin。

4. 引导(Steering):每个 hook 都是一个系统

README 强调:应用系统只在显式调度边界改变组件。下面的映射表(完整继承)把 rig-agent 的 hook 动作对应到调度边界:

Intent Boundary and operation
启动或停止一次 run 观察 On<Add, Run>;在未完成 run 上插入 Cancelled 停止它。已发出的 effects 可能继续排空
选择模型 在 Advance 之后、Select 之前设置 run 的 UsesModel
塑形请求 在 Assemble 之前合并新 turn 的 RequestPatch,或在 Patch 中编辑已折叠的 effect
Patch、拒绝或 hold 分发 在 BusSet::Gate 中改 pending work;用 acquire_hold/release_hold 配不同 owner 名
替换答案 在 BusSet::Judge 改 EffectOutcome,或在 RigSet::Judge 改已完成 turn 的 Outputs;记录保留 handler 的原始答案
重试或解决非法调用 在 Materialise 之前、受支持的 turn/stream 边界内写 Retry 或 Resolution
观察完成 观察 Settled/Failed;应用需要时另行等待记忆确认

README 给出两条实操建议:

  1. 显式组合 patch:组合多个 patch 的系统要显式排序,并在它们之间应用 deferred writes;
  2. 策略声明:PolicyVersion 声明命名应用的组合方式,但它不对系统实现做指纹识别。

记忆与检索都是图关系:Remembers/Conversation 选择持久化,Retrieves/Retrieval 挂接检索到的文档或广告检索到的工具。

4.1 两个示例的引导写法

examples/agent_with_tools.rs 展示了最典型的引导:为 agent 实体 spawn Grant(tool) 链接子实体,然后 world.spawn_run(agent, &[], "Calculate 2 - 5.", false, None)。Grant 链接让工具进入每个 turn 的 adverts,模型即可调用它们。

examples/human_in_the_loop.rs 则是一个在 BusSet::Gate 中读 stdin 的系统:按 AgentHook::on_dispatch 批准、用 EffectOutcome 拒绝、用 Cancelled 中止。

5. Handler 回复与宿主轮询

Serve::serve 返回 Reply::Outcome(Result<Outcome, ErrorReport>) 或自有 Reply::Stream(StreamEvents)。Dispatch 携带 effect ID、请求的交付模式与 scopes。适配器保留各自的领域 trait:

  • Reply::written:提供一个 writer,其 future 与有界私有 receiver 一起被轮询;
  • deferred():提供外部 resolver 与 handler 等待的 future。

初始任务在 executor 上准备回复并折叠一元请求。流式请求由一个自有 worker 把流轮询进有界私有队列。原生解析、writer 工作与流式 verdict 在池线程上运行;浏览器 !Send 工作使用 Bevy 的 web executor,无法抢占浏览器线程上的同步工作——保持持续 tick 以收集就绪交付并 settle effects。

5.1 有界收集

Collect 每 effect 每 pass 最多排空 64 项,共享 STREAM_WORK_PER_TICK(4096)次队列检查。配额耗尽时按分发顺序轮转,下一 pass 从最后一个被服务的 Seq 之后继续。这些限制约束流式交付工作,而非 CPU 时间、载荷字节或用户系统成本;每个 pass 都有新配额。stream_capacity 提供队列共享槽位(至少钳制为 1),单一 sender 额外保留一个槽位。

任务活在非 send 的 Executions 表中,位于 effect 实体之下,旁边是 Serving 标记或 Streaming { events, .. } 组件。离开 InFlight(移除、despawn、drop world)都会取消任务——包括停在满队列上的 worker。原生 poll 可能在取消 drop future 之前完成;关闭的记录会拒绝它的迟到观察。handler 替换不影响已拥有的工作。

6. 词汇表:一次对话的关键 API

README 的词汇表完整如下(可作为阅读源码与测试的索引):

Concept API
一次分发 commands.spawn(PendingEffect { key, kind });PendingEffect::{new, typed, custom}
分发顺序 Seq,从 SeqCounter(全局、保留)加时盖章
effect 的 id Dispatch 之后 Issued;之前 Reserved,供检查点或日志使用
已取走、在途 InFlight { key } + Serving(初始任务)或 Streaming { events, fold, delivered },任务是 non-send Executions 中的一行(Tasks 是它的 SystemParam);ServedBy(Entity) → handler 实体(它的 Serves 是逆关系)
一个作为系统的 handler 被问了 Asked<E>;系统用 Answer<E> 回答——或对 open 绑定的 key(Handlers::register_open,任意 family),effect 实体本身,用提交 WorldOutcome 回答
答案 EffectOutcome(Result<Outcome, ErrorReport>);流的每 tick 折叠在 Streamed { events, errors, text, outcome },每个 error 及其项位置独立于记录保留
记录关闭 Landed { entity, id },effect 上的实体事件 settle 触发;它沿 ChildOf(effect → turn → run → agent)上冒,original_event_target() 是 effect
被决策 hold Held
程序的 scope 祖先上的 Scope(String);读入记录
工具调用的上下文(effect 旁) effect 实体上的 ToolInputs(ToolContext),挂到 handler 的 Dispatch 上下文;工具发布的内容在 outcome 时作为 ToolOutputs(ToolContext) 落地(Publishing 在途持有槽位)
一个 handler 带 Bound { key, descriptor }(不可变:每次变化都是 insert)和 Name 的实体;被擦除的 handler 在 NonSend HandlerTable 中,同实体上以 Handler 标记;HandlerIndex(key → 实体),由 Bound 的 hooks 保持精确
注册表 Handlers(SystemParam):register、register_erased、register_typed、register_world、register_open、deregister、descriptor、keys、descriptors;系统外 Handlers::with(world, ..)
宿主模型组装 提供方特定构造器或可选的 rig-core 注册表配置 → Model → ModelAdapter → Handlers::register_erased;ECS 中无提供方 recipe 或凭据解析器
恢复需求 Checkpoint::requirements() 暴露保存的 descriptors;Checkpoint::validate(&World) 在宿主构造前校验保存的数据
类型化视图 Typed<F>(Key<F>),只要系统想要
驱动器 BusSet::Dispatch 中的 dispatch;BusSet::Collect 中的 collect_tasks、collect_streams、settle
记录 Recording(任意 rig_core::serve::Recorder);rig_cassette::ecs::EffectLogResource(同时作为两者安装的 EffectLogRecorder);对每个任务服务的 handler,Dispatch 安装一个记录观察器(WorldObserver,槽位在 Observed),使 layer 的 discard 与 patch 到达记录,记录保留最内层 handler 的答案
检查点 rig_ecs::checkpoint::{save_world, load_world}
重放 rig_cassette::ecs::Replay::{register, load},按 id;需要其 ReplayPlugin
策略 Policy(ServingPolicy):每 tick intake 与串行 key;stream_capacity 约束驱动器交付队列

7. 串行服务与重入

在 ServingPolicy::serial_per_handler 下,Dispatch 只在 key 上无 InFlight 时才取——即对 handler 的 Serves 做一次查询。effect 的祖先(沿 ChildOf 上溯)若在自身 key 上在途,它只能等自己:在任何分发前就以其 Request 报告被拒绝,且无记录。

8. 作为系统的 handler

Handlers::register_world::<E>(key) 绑定一个 WorldEffect(payload 与 answer 均为 Send + Sync 的 CustomEffect)。分发到该 key 会作为 Asked<E> 落在 effect 实体上;带任意 World 访问的用户系统插入 Answer<E>;插件在 Collect 中把它排队发布为 EffectOutcome。

Handlers::register_open(key, family) 把任意 family 的 key 绑定到 world 本身:分发被取走并留在其实体上(InFlight),由系统提交 WorldOutcome::new(outcome) 来回答——一个由系统服务的工具,把所需内容嵌套为 ChildOf 该调用的 effects(测试 bus_world::a_system_serves_an_open_tool_key_and_nests_a_completion_under_it 固定此行为)。收集器按提交顺序发布答案;Collect 之后的提交下一 pass 才可见。无需任务。仅一元:系统只回答一次。必须触达 world 的 handler 属于这类;任务服务的 handler 不行。

9. 检查点:把整个 world 作为反射数据保存

rig_ecs::checkpoint::save_world 把 world 当作反射数据保存:

  • 每个带已注册反射组件的实体,组件按类型路径组织;
  • 组件中的 Entity 记为该实体的检查点索引;
  • 父先于子,兄弟按 Children 顺序。

宿主自己的组件只要在 world 的 AppTypeRegistry 中以 #[reflect(Component)] 注册,就会随其所在实体一起保存;未注册的类型路径在加载时被拒绝。在途状态不反射、永不保存:Serving、Streaming、Handler、缓存视图都不保存;关系目标由源实体的 hooks 重建。资源、系统局部状态与活任务仍是宿主拥有的。

两条实操规则:

  1. 加载前绑定 handlers:绑定到 world 所服务 key 的检查点 handler 会合并进 world 的(world 的胜出);
  2. 加载后安装应用插入观察器:避免对部分恢复的状态做出反应。

9.1 恢复模式与宿主组装

checkpoint::load_world(checkpoint, world, mode, handlers) 接收显式 RestoreMode 与已构建的 (HandlerKey, ErasedHandler) 对:

  • Strict:别名前检查原始保存的 descriptors;
  • Replace:显式接受同一 effect family 内的 descriptor 变化。

提供的 handlers 即使 descriptors 匹配也会安装(凭据轮换不是 no-op)。省略的 key 必须已在目标中服务。整个保存的绑定集与图在安装状态或 handlers 之前被完整校验。用 Strict + [] 可保留已匹配的预装 handlers(包括 replayers)。每个未完成的 effect 必须有原始保存的 handler 契约;目标绑定不能凭空发明一个。

检查点枚举格式(crates/rig-ecs/src/checkpoint.rs):CHECKPOINT_FORMAT = 2,反序列化拒绝未知信封字段与非 2 的格式。保存捕获数据供检查,不证明可恢复性——组装前检查 requirements() 并调用 validate(world);实际实现兼容性在 load_world 时检查。

examples/host_resume.rs 演示了宿主构造、严格重建与独立 effect 重放,使用显式离线的录制传输(不读凭据、不发网络请求):save_world 后安装 EffectLogResource,再在新 world 中 load_world(..., RestoreMode::Strict, [])。

9.2 流与自定义答案快照

已完成流的 Streamed(events、errors、text、终态 fold)与其他组件一样是检查点数据:在插入答案前恢复,handler 永不再执行。load_world 返回结果:先在 scratch world 中校验整个检查点,拒绝带已交付进度的未完成流,目标 world 保持不动。没有通用提供方游标:在进度前或完成后保存。安全未答 intents 在保存的 id 下重启(已取未答的 effect 加载为 Reserved(id))。自定义 outcomes 把用户的 JSON 值存在 Outcome::Custom { payload } 中,类型化字符串/标量/数组/对象往返不改变答案类型(包括自带 outcome 字段的对象)。

9.3 记忆最终化跨快照

Settled 只表示模型 run 产出了答案,不表示外部记忆已提交。MemoryAppendScheduled 持久化“最终化创建了一次 append”这一事实;其子 effect 携带请求、dispatch id 与 outcome。在最终化前加载快照会调度该 append;加载已排队、在途或已完成的 append 不会创建新操作。测试 tests/memory_resume.rs 覆盖这些切点的活 handler。

这不是 exactly-once 执行:未答 effect 在保存 id 下重试,外部写入可能已发生。需要去重的宿主必须:把外部幂等键与操作持久关联(含 session/日志命名空间,因为 EffectId 本身不是全局唯一)、使用幂等 handler、或在恢复前对歧义写入对账。despawn 或放弃 world 不会回滚写入。只在 deferred commands 已应用的调度边界保存,并把检查点与匹配的日志一起持久化。

10. 身份(Identity)与可重放性检查

rig_cassette::ecs::identity::stamp_run 捕获受支持的有效配置,包括 run-over-agent 的覆盖。该模块的 check_replayable 检查请求 run 的精确 Scope;它不搜索另一个匹配的策略哈希。应用必须为自己的自定义系统、排序以及其它未哈希配置声明非空 agent::PolicyVersion;缺失声明被报告为 unverified。此声明不是自动代码指纹,也不检查环境凭据与外部状态。rig-agent golden 的 builder header 是该运行时自己的,不是有效的兼容性检查。

check_replayable 可在绑定到日志 replayers 的新 world 中运行。记录的模型身份与能力保持权威(包括带工具的原生输出组合)。检查前重新应用同一中间件:replayer 提供内层 handler 的记录交换,不自行执行 descriptor 的命名 layers。语义模型、能力、有效设置与应用版本的变化都会改变身份。Replayer 注册包含每个 scoped 必需行,因此广告但未使用的工具在场景重建时仍可用;意外调用它会以 divergence 失败。

11. 重放交付与流式

记录保持分发顺序。EffectLogResource::install 安装 ECS recorder 时,日志还记录 header.deliveries:按调度 pass 分组的 outcome 插入与流条目计数的有序轨迹。handler 完成与收集器交付是两个独立边界。重放缓冲就绪 handler 数据,并一起暴露每个记录的批次,允许策略系统在批次间运行;并发活服务保持并发。重合的 agent turns 按 RunSeq 顺序落地并 landing 工具批,与无关实体 archetype 无关。

  • Replay::policy_visible():供选择首个可见答案或检查部分 Streamed 状态的策略使用;录制流需安装 EffectLogRecorder::keeping_stream_events()。缺失交付元数据或省略流字节会被拒绝,不一致的轨迹报告为 ReplayFailure。
  • 受支持的策略观察点是 On<Add, EffectOutcome> 以及排在整个 BusSet::Collect 之后的系统,要求活与重放时保持相同相关排序。穿插在单个收集器之间或读 handler 就绪/inbox 的系统不在保证范围内。
  • WorldOutcome 或类型化 Answer<E> 提交 world 答案;直接插入在途 EffectOutcome 会记录 header.delivery_limitations 诊断并让策略重放拒绝日志。WorldOutcome 像就绪任务一样是瞬态的;保存检查点前收集它。
  • Replay::default() 支持 exchange 消费者:它遵循可用交付批次,但折叠流只提供最终答案,记录的取消以错误返回。保留事件字节保存事件序列;保留交付批次额外保存部分状态策略看到哪些事件一起出现。header.stream_errors 在原始位置保留 error 项;折叠 outcome 无法恢复那些位置。

这些保证要求相同的声明程序与相关调度顺序;它们不重现墙钟间隙、任意资源、系统局部状态、环境输入或外部写入。保存的 ID 允许子集重放;创建新 effects 的程序仍须重现其因果分发。通用自定义与 world-served 流式仍不受支持:StreamWriter 不改变哪些 effect family 流式。

12. 宿主组装与恢复边界

宿主拥有模型选择、凭据、端点/传输策略、SDK 准备与运行时生命周期;ECS 只执行已构建的 handler。把启动设置与执行检查点分开存储;descriptor 不是等价凭据、端点或传输策略的证明。

实操要点(README 原话):

  • 在凭据查找、诊断秘密收集或 SDK 初始化之前选择重放;
  • 含已移除提供方绑定组件路径的旧检查点被拒绝;把它们的启动设置显式移入宿主,而不是静默丢弃;
  • 参见 CONTRACT.md §12.1 Host assembly, runtime execution。

13. Witness:记录旁边的决策观察

effect 日志是重放 oracle:记录 handler 服务并回答了什么。Witnessing 资源(rig_ecs::bus::Witnessing::install(world, sink),任意 rig_core::observe::Witness;rig_core::observe::ObservationLog 是有界内存实现)记录这些交换周围发生的事,作为带 Subject(scope、分发顺序、effect id、父、key)、Stage(Gate、Dispatch、Handler、Collect、Judge、Runtime、Host)、Emitter 与 Action 的类型化 Observation:

site fact
Gate 系统写 Held / 移除它 Held / Released(除非策略发出,否则 emitter 未知)
Gate 系统在分发前回答意图 Denied { reason }
Dispatch Issued;Refused { handler_unavailable | reentrant | ids_exhausted }
layer(Intercept)拒绝 Handler 处 Denied { layer_discarded },emitter 以 layer 命名
Collect StreamTruncated { delivered, tail, errors }、Landed { outcome }、layer 的 verdict 与记录不同时 Replaced(emitter 以声明替换的 layer 命名;无 layer 声称的差异 emitter 未知)、在途 despawn 的 Cancelled
Judge 系统覆盖已落地 outcome(insert;就地 Mut 改写必须自行 emit) 整个值变化时 Replaced { recorded, consumed }
agent 运行时 Ended { settled | max_turns | provider | cancelled | … }
Gemini 一元与 SSE 驱动 Handler 处的 Adapter 发送/状态、usage、提供方 verdict/错误信封、EOF 与关闭事实,带执行局部操作与发送序号;显式调用上下文优先于 bus 上下文
宿主系统 Witnessing::emit(subject, Stage::Host, Emitter::versioned(..), HostAction::action(..))

bus::AdapterOperation:宿主需要重试关联时,在分发前把它挂到 pending completion 上。bus 绑定当前 effect、scope 与 parent,同时保留所提供操作的 witness 与发送计数器;组件非零的宿主尝试序号与 HTTP 发送序号分离。缺失时 bus 为单个 effect 创建操作。该组件仅运行时,不是 scene/effect-log 状态。

观察 API 不承诺语义执行比较。测试用 rig_core::test_utils::observations 做确定性 fixture 比较;应用拥有自己的比较与聚合规则。可选宿主时钟戳是诊断数据;不计算任何 run/handler/provider 间隔。tests/bus_witness.rs 与 tests/run_witness.rs 覆盖发出的事实。

检查点一起保留命名 hold owners 与批调度标记;恢复的批只释放自己的 hold,独立策略 holds 保持有效;裸 Held 屏障仍是不知名 holds,需宿主重新评估。

14. Prelude 与功能开关

rig_ecs::prelude 只命名用户系统需要的:集合(RigSet、BusSet)、用户写的组件(Cancelled、RequestPatch、Retry、Resolution、Held、UsesModel、Grant、Context、Remembers、Retrieves)与用户读的组件(Streamed、Outputs、EffectOutcome、RunResult、Settled、Failed、Usage)。定义见 crates/rig-ecs/src/prelude.rs。

  • 反射:bus 与图的每个组件都 derive Reflect;它们持有的 rig-core 值通过不透明远程包装器反射(bus::reflect、agent::reflect——以其 wire 形式序列化,因此检查器看到 effect 实体的 payload 与日志一致);checkpoint::register_types(reflect::install_reflect)注册全部,RigPlugin 调用它。检查点是规范的(实体按 Children 顺序,组件中的 Entity 是其在检查点中的索引),所以 world 与其检查点加载进的 world 导出相同 JSON(tests/reflect_scene.rs);每个组件按值往返 ReflectSerializer/ReflectDeserializer/FromReflect(tests/reflect_roundtrip.rs)。运行时组件(Serving、Streaming、Handler、Publishing、Observed、Asked、Answer、Typed 与 asset handles)不反射。rig-core 不依赖 Bevy。
  • assets feature(默认关闭):assets::PromptAsset(.md/.txt 文件)与 assets::ToolDefinitions({ name, description, parameters } 的 .json 数组)是带 loader 的 bevy_asset assets;agent 上的 PromptHandle/ToolsHandle 变成其 Preamble 与其 Grants——每个定义一个,按文件顺序,指向 descriptor 为该工具名的已绑定 handler;无服务者的定义不被授权——在 asset 加载的那一 tick 生效一次(Applied<A>)。assets::AssetsPlugin 放在 bevy_asset::AssetPlugin 之后;其系统在 Update 的 assets::AssetsSet 中运行,先于 RigSchedule。测试 tests/assets_prompt.rs,示例 examples/prompt_from_assets.rs(内存源;默认目录用真实目录)。

Cargo.toml(crates/rig-ecs/Cargo.toml)显示 Bevy 各 crate 均以 default features off 引入,wasm 目标显式选择 Bevy 的 web executor。

15. 并排示例

Runnable 程序基于 examples/support 的脚本化 mock,cargo run -p rig-ecs --example <name> 即可运行,全部无需 API key:

示例 演示内容
agent_with_tools.rs Grant 链接与 run 实体(对应 builder 的 dynamic_tools 与 prompt)
human_in_the_loop.rs BusSet::Gate 中读 stdin 的系统:批准、以 EffectOutcome 拒绝、以 Cancelled 中止
best_of_n.rs agent::fork n−1 次,对 settled runs 的评判系统,做并行扇出
streaming_ui.rs 流式 run + RigSet::Fold 后对 Changed<Streamed> 轮询的系统
prompt_from_assets.rs assets feature
host_resume.rs 宿主构造、严格恢复与离线 effect 重放

最小的 hello_model.rs 只用三十行用户代码走完“加插件 → 注册 handler → spawn effect → 观察答案”:BusPlugin::with_policy(ServingPolicy::default())、Startup 中 Handlers::register("model", Mock)、commands.spawn(PendingEffect::new(...)),以及 On<Add, EffectOutcome> 观察器打印回答。

16. 回归测试与受支持边界

crates/rig-ecs/tests 下的测试覆盖分发、取消、有界 intake、流式、注册表替换、world-served handlers、检查点、重放与 WASM:

  • bus_scale:大规模 pending 集;
  • bus_world:Gate/Judge 与嵌套;
  • bus_scene:保存/加载;
  • run_identity:scoped 重放检查;
  • tool_batch(README 原话):两个调用是两个按调用顺序分发的子实体与一次结果 utterance;ToolPolicy 决定同时飞多少个;Judge 系统的替换进入历史而记录保留答案;Gate 拒绝是被跳过的结果且无记录;despawn 子实体使 run 以 Cancelled 失败;系统的 Resolution::{Repair, Retry} 重命名或重试非法调用(Skip 与 Ignore 同理)。语料库的嵌套用例通过 world 服务的 key(Handlers::register_open)、由系统服务的 lookup 工具(spawn 其子实体 ChildOf 调用)运行;
  • run_graph:图引导的胜出规则(utterance despawn 留下下一个请求;一个文档实体喂两个 run;grant 链接广告工具、移除则取消广告;run 上换模型改变下一个 key;Patch 系统的改写到达 handler 与记录;Assemble 前的系统改写 utterance);
  • run_scene:图即状态——mid-turn 检查点的 run 在新 world 中恢复到相同第二个请求;
  • run_lifetime:RunCommands::despawn_run(World 上或 Commands 排队)整体 despawn 已结束 run,拒绝仍在运行的(NotARun/Unsettled/InFlight)。

跨运行时回归的范围说明见 tests/ecs_parity/README.md:原生/ECS 对照活在可执行测试与其共享 helper 中;每个运行时只把自己的日志与自己的 committed golden 比较,两套语料互不归一化。

受支持边界(README 原话):world-served handlers 只回答一元 effects;自定义 world 流式不支持。检查点持有每个已注册反射组件;宿主显式保存资源与其他状态。非确定性操作属于宿主 handlers。详细运行时行为以 CONTRACT.md 为准。

17. 在 wasm 上

系统持有的所有东西在每个目标上都 Send + Sync。被擦除的 handler 与任务在 wasm 上是 !Send,因此每个目标上都位于 NonSend HandlerTable 与 Executions 中(以 Handler、Serving、Streaming 组件名标记)——一套存储、一套代码路径——注册或分发的系统在主线程运行。tests/bus_wasm.rs 手工驱动调度:web 上 bevy_app 的 runner 由浏览器帧调度。取消在原始 handler 答案已被观察后记录 DeliveryKind::Cancelled 边界而非 outcome 交付,从而保留原始答案(包括等待 layer verdict 的)而不声称消费者收到;策略重放必须重现该取消,exchange 重放返回取消并隐藏未交付的终态条目。

结语

rig-ecs 把 Rig 的 agent 运行时从"内部状态机"重构成"Bevy world 中的实体图":效果、处理器、运行、轮次、内容与工具调用全部是可查询、可反射、可检查点的实体与组件,调度边界(RigSet × BusSet)是应用系统唯一的合法介入点,重放与 witness 机制则把"记录"与"决策"严格分离。对需要持久化、审计、人工审批或深度定制的 Agent 场景,这是 rig 生态中值得重点研究的运行时形态。

登录后查看全文
rig