抽取多 Agent 编排 crate 并支持运行中自主扩图 (#207)
Project CI / Repository checks (push) Successful in 2m16s
Project CI / Frontend tests (push) Successful in 6m37s
Project CI / Backend tests (push) Failing after 7m45s
Project CI / Native shell tests (push) Failing after 7m6s

## 概要

- 抽取通用 `agent-runtime-orchestration` crate,承接多 Agent DAG 的构图校验、ready/wave、下游闭包和全量/返工选择。
- 保留 `platform-agent` 的游戏领域任务与语义路由,避免把 Runtime、Provider、ToolHost 和持久化职责下沉到公共编排层。
- 增加 `GraphProposal` / `TaskProposal` / `GraphEdge` / `GraphLimits`,允许宿主在执行中安全应用 LLM 提出的新增节点和边。
- 扩图采用候选图原子校验:未知 Agent/端点、重复边、自依赖、环及节点/边/深度/扇出预算都会拒绝,失败时原图保持不变;新增节点默认为 `Pending`。

## 验证

- `npm run agent-runtime-orchestration:check`(15 项通过)
- `cargo test --manifest-path server-rs/crates/platform-agent/Cargo.toml`(19 项通过)
- `npm run agc:skill-pack:check`
- `npm run check:encoding`
- `git diff --check`

前端 typecheck 本轮未执行:当前工作树未安装 `node_modules/tsc`,命令会报 `tsc is not recognized`。

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/207
This commit was merged in pull request #207.
This commit is contained in:
2026-08-31 10:51:11 +08:00
parent 63abea0b3e
commit d2254d1e8c
22 changed files with 2144 additions and 158 deletions
+10
View File
@@ -16,6 +16,14 @@ dependencies = [
"serde_json",
]
[[package]]
name = "agent-runtime-orchestration"
version = "0.1.0"
dependencies = [
"agent-runtime-core",
"serde",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -3772,6 +3780,8 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
name = "platform-agent"
version = "0.1.0"
dependencies = [
"agent-runtime-core",
"agent-runtime-orchestration",
"platform-llm",
"serde",
"serde_json",
@@ -7,7 +7,8 @@ pub(crate) fn write_agent_pass_agenda(
) -> Result<AgentPassAgenda, String> {
let graph = build_game_creation_seed_task_graph("AI 游戏创作")
.map_err(|error| format!("构建 Agent 编排任务图失败:{error}"))?;
let pass_plan = plan_game_creation_agent_pass(&graph, pass, findings_markdown);
let pass_plan = plan_game_creation_agent_pass(&graph, pass, findings_markdown)
.map_err(|error| format!("规划 Agent 编排任务图失败:{error}"))?;
let repair_routes = pass_plan
.repair_routes
.iter()
@@ -52,8 +52,13 @@ fn build_game_creator_runtime_agent_catalog() -> Result<AgentCatalog, String> {
);
}
}
AgentCatalog::try_new(agents)
.map_err(|error| format!("AI 游戏创作 Agent catalog 无效:{error}"))
let catalog = AgentCatalog::try_new(agents)
.map_err(|error| format!("AI 游戏创作 Agent catalog 无效:{error}"))?;
let task_graph = build_game_creation_seed_task_graph("AI 游戏创作 Agent catalog 验证")
.map_err(|error| format!("AI 游戏创作任务图无效:{error}"))?;
platform_agent::validate_game_creation_task_agents(&task_graph, &catalog)
.map_err(|error| format!("AI 游戏创作 Agent catalog 与任务图不一致:{error}"))?;
Ok(catalog)
}
pub(crate) fn game_creator_runtime_agent_catalog() -> Result<&'static AgentCatalog, String> {
@@ -515,7 +515,7 @@ fn evaluator_findings_include_structured_repair_routes() {
assert!(findings.contains("\"code-prototype\""));
let graph = build_game_creation_seed_task_graph("像素厨房弹幕").expect("task graph");
let plan = plan_game_creation_agent_pass(&graph, 2, &findings);
let plan = plan_game_creation_agent_pass(&graph, 2, &findings).expect("repair plan");
assert_eq!(plan.mode, "repair");
assert!(plan.active_task_ids.contains(&"code-prototype".to_string()));
assert!(plan
@@ -16,6 +16,20 @@
---
## 2026-08-26 运行中自主扩图提案留在编排层
- 背景:`agent-runtime-orchestration` 已能构造和调度动态 DAG,但 LLM 在执行中发现缺少步骤时没有通用的安全扩图合同。
- 决策:新增严格 serde 的 `GraphProposal``TaskProposal` + `GraphEdge`)和 `GraphLimits`,由 `TaskGraph::apply_proposal` / `expand_with_proposal` 在内存中构造不可变候选图;新节点默认 `Pending`,边方向为前置 `from` → 依赖方 `to`
- 安全与一致性:所有 Agent、端点、重复引用、环、节点/边/深度/扇出预算在候选返回前一次校验;边只能指向新节点,禁止给已运行任务原地追加依赖。任一失败保留旧图。成功后的 epoch、基图版本、proposal 幂等和持久化由宿主负责,crate 不调用 LLM/Provider/ToolHost/Runner,也不写 `.agent/runtime/**`
- 验证:非游戏 conformance 覆盖有效扩图、ready/wave 重算、未知 Agent/端点、重复边、已有任务修改、环、预算、严格 JSON 和原子失败;关联文档为 `docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` V1.55。
## 2026-08-26 通用多 Agent DAG 编排与执行内核分层
- 背景:`agent-runtime-core` 已承接 catalog、run/action 生命周期、lane、宿主 ToolHost、spawn/all-join 和 Provider 契约,但动态任务图的 ready 选择、依赖波次与返工下游闭包仍混在 `platform-agent::game_creation`,其它产品无法复用且非法环会被合并成伪 wave。
- 决策:新增纯 Rust `agent-runtime-orchestration`,依赖方向固定为 `agent-runtime-orchestration -> agent-runtime-core`。公共层只持有任务 ID、Agent ID、通用状态和依赖边,统一负责构图校验、ready、active/satisfied 波次、下游闭包和全量/返工选择;动态构图仍必须是 DAG,跨轮循环通过新的 pass / epoch 表达。
- 产品边界:16 个游戏任务、六组角色、产物/验收条件、Evaluator Markdown 和中文语义路由继续留在 `platform-agent`;AGC 组合根使用公共层校验任务图与 `AgentCatalog`。Runtime store、Runner、Provider、权限、ToolHost、委派 journal、isolated write scope 和 `.agent/runtime/**` 不迁移、不双写。
- 验证方式:非游戏 conformance 覆盖并行分支、汇合、repair closure、AgentCatalog 和非法图失败关闭;`platform-agent` 锁定种子 DAG 与现役波次/返工顺序,并验证环拒绝和 catalog 注入。根检查脚本必须执行新 crate 测试。
- 关联文档:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` V1.54。
## 2026-08-27 `plan.submit_gdd` 拒绝无审批决定的 `user_revision`
- 背景:结构校验允许 `round=0 + user_revision + confirmed`,提交闸原先只做结构、身份和 Session CAS。Provider 可在首次 collecting、澄清续跑或提交前质量返工里把未确认项标成用户审批修改,审批卡显示「已确认」。
@@ -51,6 +51,7 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐
## AGC DirectProject 与 UI workflow
- 通用 Agent Rust 分层为 `agent-runtime-core`catalog、执行生命周期、ToolHost/spawn/all-join/Provider 契约)、`agent-runtime-orchestration`(动态无环任务图、ready、依赖波次、返工下游闭包和受限自主扩图提案)与 `platform-agent` 游戏适配器;循环返工通过新 pass / epoch 表达,不在单张依赖图中建立回边。LLM 可经宿主结构化 function call 提出新增节点/边,编排层只生成经校验的新候选图,epoch 与持久化仍由宿主掌控。
- DirectProject 只连接客户端内置的 `agc_tools` STDIO MCP。它负责审核引用读取、标准美术准备、已登记资源有界查询、视频 / 角色动画 / 音效 / BGM 的 create-or-derive、已登记图片去背景、desktop/mobile 浏览器试玩和受控 `agc_web_search`;付费资源调用由客户端绑定回合、幂等账本、请求上限和投影权威。
- DirectProject 的 Codex 原生文件、搜索、命令、图片查看和 Skill 仅在真实 `game/` cwd 与 `workspaceWrite(writableRoots=[game])` 内可用;原生命令网络保持关闭。多 Agent、Apps、插件、hooks、图片生成、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制保持关闭。app-server 使用隔离 `CODEX_HOME`provider 凭据只由 AGC 客户端代理持有,不能进入模型上下文或 shell 环境。
- `ui-prototype`(设计图片)与 UI 编辑器 `UI` JSON 是不同资源。白名单 `ui.workflow.run` 按页面执行 `prepare → recognize → status → finalize`,由 provider-backed 识别、合并和组件绑定持久化 State/revision,并把 `reference-ready → structure-ready → merge-ready → binding-ready → application-ready → completed` 投影到 manifest。Provider 缺失、请求失败、工具缺失、结果不匹配或仍有待审节点时保留真实阶段并返回 blocker,不得用 deterministic seed 伪造完成。
@@ -1651,6 +1651,58 @@ V1.53 把根 Project Supervisor 的 same-run steer 从“收到消息立即中
- LLM 判定、解析或持久化失败时写入关联的非终态 fallback 回复,保持当前任务运行,并在下一安全边界应用已排队 steer;失败不能退回“默认中断”。判定与回复按 `agentId / runId / steerId` 幂等,冲突终态失败关闭。
- 验收必须覆盖判定 LLM 的 `true / false` 协议、公开回复幂等、入队本身不中断、`runtime.steer` 有活动 Provider 时仍不中断、缺失 decision 拒绝条件中断、`false` decision 不中断、`true` decision 只中断旧 cursor,以及判定失败后同一 run 继续。
## V1.54 通用多 Agent DAG 编排 crate
V1.48-V1.50 已把 catalog、执行生命周期与 Provider 契约收进 `agent-runtime-core`,但动态任务图的 ready 选择、依赖波次和返工下游闭包仍编译在 `platform-agent::game_creation`。V1.54 新增独立 `server-rs/crates/agent-runtime-orchestration`package name `agent-runtime-orchestration`),作为 `agent-runtime-core` 上方的纯 Rust 编排层;它不复制 Runtime store、Runner、Provider、ToolHost、delegation journal、权限或产品持久文件。
### 图模型与无环合同
- 公共层只保存任务 ID、执行 Agent ID、通用状态和依赖边,不保存游戏组别、角色文案、产物路径、Evaluator Markdown 或中文关键词。任务在运行时注册,因此“动态 DAG”表示宿主可以按目标或 pass 动态构图与重规划,不表示依赖边可以形成环。
- 构图一次性失败关闭:拒绝空或非法 ID、重复任务、重复依赖、未知依赖、自依赖和任意有向环,并保持任务注册顺序作为所有确定性输出的稳定顺序。环内没有可证明的首个 ready 节点,也无法给 completion、重放和下游失效定义单调顺序;需要迭代时由宿主建立新的 pass / epoch,并显式携带上一轮结果,不在同一依赖图里回边。
- 编排层提供 pending ready 选择、active/satisfied 依赖波次、指定任务的下游影响闭包和全量/返工选择计划。active 任务依赖的非 active 节点必须显式位于 satisfied 集合;缺失前置不能按“图外即完成”静默放行。
- 每个任务携带 `agentId`,并可对 V1.48 `AgentCatalog` 做引用校验;未知 Agent 在创建任何 run 或调用宿主前失败关闭。crate 依赖方向固定为 `agent-runtime-orchestration -> agent-runtime-core`,依赖闭包只使用标准库与 `serde / serde_json`,不得反向依赖 AGC、Tauri、`platform-agent``platform-llm`
### AGC 生产适配与兼容
- `platform-agent` 继续拥有 16 个游戏任务、六组枚举、标题/角色/产物/验收条件、Evaluator Markdown 解析和游戏关键词路由;它把现有 `GameCreationTaskGraph` 映射为公共任务图,并由公共层计算 ready、dependency waves 和 repair downstream closure。
- `GameCreationTaskGraph``GameCreationAgentPassPlan` 的 serde 字段、种子任务、有效 DAG 的顺序与现役 `.agent/passes/pass-N/task-graph.json` 输出保持不变。构图或计划函数改为显式返回错误,非法环、未知依赖或不完整 partition 不再合并成一个伪 wave。
- AGC 的现役 Agent catalog 构造同时用公共编排层校验 16 个任务的 Agent 引用,证明新 crate 已进入生产组合根。isolated child 的项目路径、write scope、证据和深度限制仍是 AGC 工具/策略合同;通用 spawn/all-join 继续由 `agent-runtime-core` 执行,本轮不再造第二套委派协议。
### V1.54 验收
- 新 crate 的非游戏 conformance fixture 在运行时构造并行分支与汇合节点,覆盖稳定 ready 集合、依赖波次、返工下游闭包、AgentCatalog 引用,以及重复/未知/自依赖/有环/缺失 satisfied 的失败关闭;fixture 不得出现游戏任务名、AGC 路径或 Tauri 类型。
- `platform-agent` 回归锁定 16 任务种子图、首轮全量波次、结构化返工和下游扩展的现有顺序,并增加非法游戏图不会进入计划的负向用例;AGC adapter 回归锁定 catalog 与任务图一致。
- 根脚本增加 `agent-runtime-orchestration:check`,并纳入 `ai-game-creator-shell:check`。完成后至少运行新 crate、`platform-agent`、AGC adapter/生成编排定向测试、Tauri `cargo check --tests`、依赖树、`npm run check:encoding``git diff --check`;独立 crate 产生的本地 `Cargo.lock/target` 不进入提交。
### V1.54 本轮验证记录(2026-08-26
- 已通过:`agent-runtime-core` 20 项、`agent-runtime-orchestration` 5 项、`platform-agent` 19 项;新 crate 依赖树仅引入 `agent-runtime-core``serde`(测试专用 `serde_json`),`cargo fmt --check``npm run check:encoding``git diff --check` 均通过。
- 已通过:AGC 任务图与注入 `AgentCatalog` 的一致性测试、非法环失败关闭、反序列化重复依赖失败关闭;测试产生的独立 crate `Cargo.lock/target` 已清理。
- 未完成:Tauri `cargo check --tests` 已编译到 AGC 自定义 `build.rs`,随后因仓库四个候选路径均缺少内置 Codex CLI vendor 资源而退出(`build.rs:77`);本轮未执行 `npm ci`,因此不能将 Tauri 组合根或完整 `ai-game-creator-shell:check` 记为通过。
- 未执行:当前 Rust 1.96 工具链未安装 `clippy` component;没有把该静态检查结果用其它门禁结果替代。
## V1.55 运行中自主扩图提案
V1.54 的公共编排层可以在运行前构造动态 DAG,但 LLM 在执行过程中发现缺少步骤时还没有一个通用、受限的扩图入口。V1.55 在同一 `agent-runtime-orchestration` crate 增加结构化 `GraphProposal`,让宿主能够把 LLM 的 function-call arguments 解析为候选节点和边,并在不改变 `agent-runtime-core` 执行职责的前提下生成下一张图。
### 提案 DTO 与宿主边界
- `GraphProposal` 只包含 `nodes``edges`。节点使用 `{ id, agentId }`,边使用 `{ from, to }``from` 是前置任务,`to` 是依赖它的任务。DTO 使用 `camelCase` 且拒绝未知字段,节点和边的稳定标识沿用公共图模型约束。
- `TaskGraph::apply_proposal` / `expand_with_proposal` 是纯校验与候选构造 API:不调用 LLM、Provider、ToolHost、Runner 或持久化。宿主负责声明 function tool、把 arguments 反序列化为 `GraphProposal`,并在成功后把返回的候选图写入自己的新 epoch。
- 新节点统一以 `Pending` 加入,并保留现有任务状态和注册顺序。提案边必须指向本次新增节点;不允许在运行中的旧任务上原地追加前置依赖。若业务确实要改旧边,宿主应构造完整候选图并按自己的 CAS/epoch 合同一次替换。
### 原子校验与预算
- 候选图只有在所有检查通过后才返回;未知 Agent、未知端点、重复节点/边、自依赖、有向环和已有节点依赖修改都会失败,原图保持不变。
- `GraphLimits` 同时限制完整候选图的 `maxTasks``maxEdges``maxDepth`(根层计 1)和 `maxOutDegree`(一个前置任务的直接下游数)。默认值为 `128 / 512 / 32 / 32`;超限不截断、不部分提交。
- 成功扩图后,宿主必须把它视为新的 graph/epoch,重新计算 ready task 与 dependency waves,并在自己的持久层记录提案身份、基图版本和幂等结果。crate 不把 epoch、proposal ID 或执行事实写入图,也不自动重放 Provider。
### V1.55 验收
- 非游戏 conformance 覆盖有效新增节点/边、全部新节点 `Pending`、ready/wave 重算、未知 Agent、未知端点、重复边、已有任务修改、环、节点/边/深度/扇出预算和失败原子性。
- 覆盖 `GraphProposal``GraphEdge``GraphLimits``TaskGraph` 的严格 JSON round-trip;未知字段、非法标识和零预算均失败关闭。
- 真实 LLM 接入仍由宿主后续提供;本切片证明了宿主可在一个结构化 function call 回合中安全生成候选新图,但不把 provider 请求或持久化当作 crate 的事实源。
## 验收命令
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml structured_plan_ -- --nocapture`
@@ -1,5 +1,11 @@
# AI 游戏创作智能体 App 实施计划
## 2026-08-26 运行中自主扩图提案
- `agent-runtime-orchestration` 提供严格 serde 的 `GraphProposal``TaskProposal``GraphEdge``GraphLimits`。宿主可把 LLM function-call arguments 解析后交给 `TaskGraph::apply_proposal`,在内存中得到新的、完整校验过的候选 DAG。
- 新节点默认 `Pending`;边使用 `from`(前置)→ `to`(依赖方),且新增边只能指向本次新增节点。未知 Agent/端点、重复节点/边、自依赖、有向环和节点/边/深度/扇出预算超限整次失败,旧图不变。
- crate 不调用 Provider、Runner、ToolHost 或持久化;宿主负责 function tool 暴露、基图/epoch CAS、proposal 幂等、落盘和重新调度 ready/dependency waves。修改既有任务依赖时必须由宿主构造完整候选图并切换新 epoch。
## 2026-08-25 账户 / 项目画布 / 本地素材导入
- 素材读取区分三类来源:`asset.list` / `agc_list_registered_assets` 是当前项目本地 manifest`agc_list_project_files` / `file.list` 只发现项目目录中实际存在但可能未登记的文件,`asset.library.list` 是当前登录账号素材库,项目画布资源读取是当前网页项目/画布的完整图片清单;账户素材库不能替代项目画布清单。
+2 -1
View File
@@ -189,8 +189,9 @@
"ai-game-creator-shell:agent-runtime:steer-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:steer-real-e2e --",
"ai-game-creator-shell:agent-runtime:steer-runner-kill-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:steer-runner-kill-real-e2e --",
"agent-runtime-core:check": "cargo test --manifest-path server-rs/crates/agent-runtime-core/Cargo.toml",
"agent-runtime-orchestration:check": "cargo test --manifest-path server-rs/crates/agent-runtime-orchestration/Cargo.toml",
"ai-game-creator-shell:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck",
"ai-game-creator-shell:check": "npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests && npm run agent-runtime-core:check && cargo test --locked -p platform-llm --manifest-path server-rs/Cargo.toml && cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app && cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1 && npm run ai-game-creator-shell:agent-run:smoke",
"ai-game-creator-shell:check": "npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests && npm run agent-runtime-core:check && npm run agent-runtime-orchestration:check && cargo test --locked -p platform-llm --manifest-path server-rs/Cargo.toml && cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app && cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1 && npm run ai-game-creator-shell:agent-run:smoke",
"check:native-shells": "node scripts/check-native-shells.mjs"
},
"dependencies": {
+1
View File
@@ -8,6 +8,7 @@ default-members = [
]
exclude = [
"crates/agent-runtime-core",
"crates/agent-runtime-orchestration",
"crates/module-bark-battle",
"crates/module-big-fish",
"crates/module-combat",
@@ -0,0 +1,2 @@
/Cargo.lock
/target/
@@ -0,0 +1,13 @@
[package]
name = "agent-runtime-orchestration"
edition = "2024"
version = "0.1.0"
license = "UNLICENSED"
publish = false
[dependencies]
agent-runtime-core = { path = "../agent-runtime-core" }
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
serde_json = "1"
@@ -0,0 +1,64 @@
use std::fmt;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OrchestrationErrorKind {
InvalidInput,
InvalidLimits,
EmptyProposal,
DuplicateTask,
DuplicateDependency,
DuplicateEdge,
UnknownDependency,
SelfDependency,
Cycle,
UnknownAgent,
UnknownTask,
ExistingTaskMutation,
NodeBudgetExceeded,
EdgeBudgetExceeded,
DepthBudgetExceeded,
FanOutBudgetExceeded,
ConflictingTaskSet,
UnsatisfiedDependency,
}
#[allow(non_upper_case_globals)]
impl OrchestrationErrorKind {
/// Compatibility alias for callers that describe the node budget as a
/// task budget.
pub const TaskBudgetExceeded: Self = Self::NodeBudgetExceeded;
/// Compatibility alias for callers that use the shorter fan-out spelling.
pub const FanoutBudgetExceeded: Self = Self::FanOutBudgetExceeded;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct OrchestrationError {
kind: OrchestrationErrorKind,
detail: String,
}
impl OrchestrationError {
pub(crate) fn new(kind: OrchestrationErrorKind, detail: impl Into<String>) -> Self {
Self {
kind,
detail: detail.into(),
}
}
pub fn kind(&self) -> OrchestrationErrorKind {
self.kind
}
pub fn detail(&self) -> &str {
&self.detail
}
}
impl fmt::Display for OrchestrationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.detail)
}
}
impl std::error::Error for OrchestrationError {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
//! Deterministic task-graph orchestration layered over `agent-runtime-core`.
//!
//! Hosts register task graphs at runtime. This crate validates the graph and
//! computes ready tasks, dependency waves and downstream repair impact without
//! owning persistence, threads, providers, tools or product-specific policy.
mod error;
mod graph;
mod plan;
mod proposal;
pub use error::{OrchestrationError, OrchestrationErrorKind};
pub use graph::{TaskGraph, TaskNode, TaskStatus};
pub use plan::{OrchestrationPlan, PlanSelection};
pub use proposal::{
AppliedGraphProposal, DEFAULT_GRAPH_MAX_DEPTH, DEFAULT_GRAPH_MAX_EDGES,
DEFAULT_GRAPH_MAX_OUT_DEGREE, DEFAULT_GRAPH_MAX_TASKS, GraphEdge, GraphExpansion, GraphLimits,
GraphProposal, TaskProposal,
};
@@ -0,0 +1,64 @@
use serde::{Deserialize, Serialize};
use crate::{OrchestrationError, OrchestrationErrorKind, TaskGraph};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PlanSelection {
All,
Repair { task_ids: Vec<String> },
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OrchestrationPlan {
active_task_ids: Vec<String>,
carried_task_ids: Vec<String>,
dependency_waves: Vec<Vec<String>>,
}
impl OrchestrationPlan {
pub fn active_task_ids(&self) -> &[String] {
&self.active_task_ids
}
pub fn carried_task_ids(&self) -> &[String] {
&self.carried_task_ids
}
pub fn dependency_waves(&self) -> &[Vec<String>] {
&self.dependency_waves
}
}
impl TaskGraph {
pub fn plan(&self, selection: PlanSelection) -> Result<OrchestrationPlan, OrchestrationError> {
let all_task_ids = self.all_task_ids();
let active_task_ids = match selection {
PlanSelection::All => all_task_ids.clone(),
PlanSelection::Repair { task_ids } => {
if task_ids.is_empty() {
return Err(OrchestrationError::new(
OrchestrationErrorKind::InvalidInput,
"repair selection 至少需要一个 task",
));
}
self.expand_downstream(&task_ids)?
}
};
let active = active_task_ids
.iter()
.map(String::as_str)
.collect::<std::collections::HashSet<_>>();
let carried_task_ids = all_task_ids
.into_iter()
.filter(|task_id| !active.contains(task_id.as_str()))
.collect::<Vec<_>>();
let dependency_waves = self.dependency_waves(&active_task_ids, &carried_task_ids)?;
Ok(OrchestrationPlan {
active_task_ids,
carried_task_ids,
dependency_waves,
})
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,315 @@
use agent_runtime_core::{AgentCatalog, AgentDescriptor};
use agent_runtime_orchestration::{
GraphEdge, GraphLimits, GraphProposal, OrchestrationErrorKind, PlanSelection, TaskGraph,
TaskNode, TaskProposal, TaskStatus,
};
fn task(id: &str, agent_id: &str, status: TaskStatus, dependencies: &[&str]) -> TaskNode {
TaskNode::try_new(id, agent_id, status, dependencies.iter().copied()).expect("valid task")
}
fn catalog() -> AgentCatalog {
AgentCatalog::try_new([
AgentDescriptor::try_new("researcher", "research", std::iter::empty::<&str>())
.expect("researcher"),
AgentDescriptor::try_new("reviewer", "review", std::iter::empty::<&str>())
.expect("reviewer"),
AgentDescriptor::try_new("writer", "writing", std::iter::empty::<&str>()).expect("writer"),
])
.expect("catalog")
}
fn base_graph() -> TaskGraph {
TaskGraph::try_new(
"Review a document collection",
[
task("collect", "researcher", TaskStatus::Completed, &[]),
task("draft", "writer", TaskStatus::Pending, &["collect"]),
],
)
.expect("base graph")
}
fn proposal(nodes: &[(&str, &str)], edges: &[(&str, &str)]) -> GraphProposal {
GraphProposal::try_new(
nodes
.iter()
.map(|(id, agent)| TaskProposal::try_new(*id, *agent).expect("valid proposal node")),
edges
.iter()
.map(|(from, to)| GraphEdge::try_new(*from, *to).expect("valid proposal edge")),
)
.expect("valid proposal")
}
#[test]
fn llm_proposal_creates_a_new_pending_subgraph_without_mutating_the_old_graph() {
let graph = base_graph();
let candidate = graph
.apply_proposal(
&proposal(
&[("review", "reviewer"), ("publish", "writer")],
&[
("collect", "review"),
("draft", "publish"),
("review", "publish"),
],
),
&catalog(),
&GraphLimits::default(),
)
.expect("proposal should be accepted");
assert_eq!(graph.task_count(), 2);
assert_eq!(graph.edge_count(), 1);
assert_eq!(candidate.task_count(), 4);
assert_eq!(candidate.edge_count(), 4);
assert_eq!(
candidate.get("review").expect("review task").status(),
TaskStatus::Pending
);
assert_eq!(
candidate.get("review").expect("review task").dependencies(),
&["collect".to_string()]
);
assert_eq!(
candidate
.get("publish")
.expect("publish task")
.dependencies(),
&["draft".to_string(), "review".to_string()]
);
assert_eq!(candidate.ready_task_ids(), vec!["draft", "review"]);
let plan = candidate
.plan(PlanSelection::All)
.expect("expanded graph should produce dependency waves");
assert_eq!(
plan.dependency_waves(),
&[
vec!["collect".to_string()],
vec!["draft".to_string(), "review".to_string()],
vec!["publish".to_string()],
]
);
}
#[test]
fn expansion_report_contains_only_the_validated_delta() {
let graph = base_graph();
let change = proposal(&[("review", "reviewer")], &[("collect", "review")]);
let expansion = graph
.expand_with_proposal(&change, &catalog(), &GraphLimits::default())
.expect("proposal should be accepted");
assert_eq!(expansion.added_task_ids(), ["review"]);
assert_eq!(expansion.added_edges(), change.edges());
assert_eq!(
expansion.graph().get("review").map(TaskNode::id),
Some("review")
);
}
#[test]
fn proposal_rejects_unknown_agents_and_keeps_the_current_graph_intact() {
let graph = base_graph();
let error = graph
.apply_proposal(
&proposal(&[("review", "unknown-agent")], &[]),
&catalog(),
&GraphLimits::default(),
)
.expect_err("unknown agent");
assert_eq!(error.kind(), OrchestrationErrorKind::UnknownAgent);
assert_eq!(graph.task_count(), 2);
assert!(graph.get("review").is_none());
}
#[test]
fn proposal_cycle_is_rejected_atomically() {
let graph = base_graph();
let error = graph
.apply_proposal(
&proposal(
&[("left", "researcher"), ("right", "reviewer")],
&[("left", "right"), ("right", "left")],
),
&catalog(),
&GraphLimits::default(),
)
.expect_err("cycle");
assert_eq!(error.kind(), OrchestrationErrorKind::Cycle);
assert_eq!(graph.task_count(), 2);
assert!(graph.get("left").is_none());
}
#[test]
fn proposal_cannot_add_a_prerequisite_to_an_existing_task() {
let graph = base_graph();
let error = graph
.apply_proposal(
&proposal(&[("review", "reviewer")], &[("review", "draft")]),
&catalog(),
&GraphLimits::default(),
)
.expect_err("existing task mutation");
assert_eq!(error.kind(), OrchestrationErrorKind::ExistingTaskMutation);
assert_eq!(
graph.get("draft").expect("draft").dependencies(),
&["collect".to_string()]
);
}
#[test]
fn proposal_limits_cover_nodes_edges_depth_and_fan_out() {
let graph = base_graph();
let limits = GraphLimits::new(8, 8, 8, 1);
let fan_out = graph
.apply_proposal(
&proposal(
&[("review", "reviewer"), ("verify", "reviewer")],
&[("collect", "review"), ("collect", "verify")],
),
&catalog(),
&limits,
)
.expect_err("fan-out budget");
assert_eq!(fan_out.kind(), OrchestrationErrorKind::FanOutBudgetExceeded);
let node_budget = graph
.apply_proposal(
&proposal(&[("review", "reviewer"), ("verify", "reviewer")], &[]),
&catalog(),
&GraphLimits::new(3, 8, 8, 8),
)
.expect_err("node budget");
assert_eq!(
node_budget.kind(),
OrchestrationErrorKind::NodeBudgetExceeded
);
let edge_budget = graph
.apply_proposal(
&proposal(
&[("review", "reviewer"), ("verify", "reviewer")],
&[("collect", "review"), ("collect", "verify")],
),
&catalog(),
&GraphLimits::new(8, 2, 8, 8),
)
.expect_err("edge budget");
assert_eq!(
edge_budget.kind(),
OrchestrationErrorKind::EdgeBudgetExceeded
);
}
#[test]
fn strict_json_round_trips_proposals_and_rejects_unknown_fields() {
let change = proposal(&[("review", "reviewer")], &[("collect", "review")]);
let json = serde_json::to_value(&change).expect("serialize proposal");
assert_eq!(
json,
serde_json::json!({
"nodes": [{"id": "review", "agentId": "reviewer"}],
"edges": [{"from": "collect", "to": "review"}]
})
);
let decoded: GraphProposal = serde_json::from_value(json).expect("decode proposal");
assert_eq!(decoded, change);
let unknown = serde_json::from_str::<GraphProposal>(
r#"{"nodes":[{"id":"review","agentId":"reviewer","title":"not allowed"}],"edges":[]}"#,
)
.expect_err("unknown proposal field");
assert!(unknown.to_string().contains("unknown field"));
let graph = base_graph();
let graph_json = serde_json::to_value(&graph).expect("serialize graph");
let restored: TaskGraph = serde_json::from_value(graph_json).expect("decode graph");
assert_eq!(restored, graph);
let limits = GraphLimits::default();
let limits_json = serde_json::to_value(limits).expect("serialize limits");
assert_eq!(
limits_json,
serde_json::json!({
"maxTasks": 128,
"maxEdges": 512,
"maxDepth": 32,
"maxOutDegree": 32
})
);
let restored_limits: GraphLimits = serde_json::from_value(limits_json).expect("decode limits");
assert_eq!(restored_limits, limits);
let unknown_limits = serde_json::from_str::<GraphLimits>(
r#"{"maxTasks":1,"maxEdges":1,"maxDepth":1,"maxOutDegree":1,"extra":true}"#,
)
.expect_err("unknown limits field");
assert!(unknown_limits.to_string().contains("unknown field"));
}
#[test]
fn proposal_rejects_duplicate_edges_unknown_endpoints_and_invalid_limits() {
let duplicate = GraphProposal::try_new(
[TaskProposal::try_new("review", "reviewer").expect("node")],
[
GraphEdge::try_new("collect", "review").expect("edge"),
GraphEdge::try_new("collect", "review").expect("edge"),
],
)
.expect_err("duplicate edge");
assert_eq!(duplicate.kind(), OrchestrationErrorKind::DuplicateEdge);
let unknown = base_graph()
.apply_proposal(
&proposal(&[("review", "reviewer")], &[("missing", "review")]),
&catalog(),
&GraphLimits::default(),
)
.expect_err("unknown edge source");
assert_eq!(unknown.kind(), OrchestrationErrorKind::UnknownDependency);
let invalid_limits = GraphLimits::try_new(0, 1, 1, 1).expect_err("zero limit");
assert_eq!(invalid_limits.kind(), OrchestrationErrorKind::InvalidLimits);
}
#[test]
fn graph_reports_layer_depth_and_fan_out() {
let graph = TaskGraph::try_new(
"depth",
[
task("root", "researcher", TaskStatus::Pending, &[]),
task("middle", "reviewer", TaskStatus::Pending, &["root"]),
task("leaf", "writer", TaskStatus::Pending, &["middle"]),
],
)
.expect("graph");
assert_eq!(graph.depth(), 3);
assert_eq!(graph.fan_out("root"), Some(1));
assert_eq!(graph.node_count(), 3);
assert_eq!(graph.fan_out("missing"), None);
}
#[test]
fn proposal_rejects_empty_payload_and_depth_overflow() {
let empty = GraphProposal::try_new(
std::iter::empty::<TaskProposal>(),
std::iter::empty::<GraphEdge>(),
)
.expect_err("empty proposal");
assert_eq!(empty.kind(), OrchestrationErrorKind::EmptyProposal);
let graph = base_graph();
let error = graph
.apply_proposal(
&proposal(
&[("review", "reviewer"), ("publish", "writer")],
&[("collect", "review"), ("review", "publish")],
),
&catalog(),
&GraphLimits::new(8, 8, 2, 8),
)
.expect_err("candidate depth should exceed the limit");
assert_eq!(error.kind(), OrchestrationErrorKind::DepthBudgetExceeded);
}
@@ -0,0 +1,154 @@
use agent_runtime_core::{AgentCatalog, AgentDescriptor};
use agent_runtime_orchestration::{
OrchestrationErrorKind, PlanSelection, TaskGraph, TaskNode, TaskStatus,
};
fn task(id: &str, agent_id: &str, status: TaskStatus, dependencies: &[&str]) -> TaskNode {
TaskNode::try_new(id, agent_id, status, dependencies.iter().copied()).expect("valid task")
}
fn document_review_graph() -> TaskGraph {
TaskGraph::try_new(
"Review a document collection",
[
task("collect", "researcher", TaskStatus::Pending, &[]),
task("inspect", "reviewer", TaskStatus::Pending, &[]),
task("draft", "writer", TaskStatus::Pending, &["collect"]),
task("verify", "reviewer", TaskStatus::Pending, &["inspect"]),
task(
"publish",
"writer",
TaskStatus::Pending,
&["draft", "verify"],
),
],
)
.expect("valid dynamic task graph")
}
#[test]
fn dynamic_non_game_dag_produces_stable_ready_waves_and_repair_closure() {
let graph = document_review_graph();
assert_eq!(graph.ready_task_ids(), vec!["collect", "inspect"]);
let full = graph.plan(PlanSelection::All).expect("full plan");
assert_eq!(
full.dependency_waves(),
&[
vec!["collect".to_string(), "inspect".to_string()],
vec!["draft".to_string(), "verify".to_string()],
vec!["publish".to_string()],
]
);
let repair = graph
.plan(PlanSelection::Repair {
task_ids: vec!["draft".to_string()],
})
.expect("repair plan");
assert_eq!(repair.active_task_ids(), ["draft", "publish"]);
assert_eq!(repair.carried_task_ids(), ["collect", "inspect", "verify"]);
assert_eq!(
repair.dependency_waves(),
&[vec!["draft".to_string()], vec!["publish".to_string()]]
);
}
#[test]
fn orchestration_graph_validates_agent_catalog_before_dispatch() {
let graph = document_review_graph();
let catalog = AgentCatalog::try_new([
AgentDescriptor::try_new("researcher", "research", std::iter::empty::<&str>())
.expect("researcher"),
AgentDescriptor::try_new("reviewer", "review", std::iter::empty::<&str>())
.expect("reviewer"),
AgentDescriptor::try_new("writer", "writing", std::iter::empty::<&str>()).expect("writer"),
])
.expect("catalog");
graph.validate_agents(&catalog).expect("known agents");
let incomplete = AgentCatalog::try_new([
AgentDescriptor::try_new("researcher", "research", std::iter::empty::<&str>())
.expect("researcher"),
AgentDescriptor::try_new("reviewer", "review", std::iter::empty::<&str>())
.expect("reviewer"),
])
.expect("incomplete catalog");
let error = graph
.validate_agents(&incomplete)
.expect_err("writer must be registered");
assert_eq!(error.kind(), OrchestrationErrorKind::UnknownAgent);
}
#[test]
fn invalid_dependencies_and_cycles_fail_closed() {
let duplicate_dependency = TaskNode::try_new(
"draft",
"writer",
TaskStatus::Pending,
["collect", "collect"],
)
.expect_err("duplicate dependency");
assert_eq!(
duplicate_dependency.kind(),
OrchestrationErrorKind::DuplicateDependency
);
let duplicate_task = TaskGraph::try_new(
"duplicate task",
[
task("collect", "researcher", TaskStatus::Pending, &[]),
task("collect", "reviewer", TaskStatus::Pending, &[]),
],
)
.expect_err("duplicate task");
assert_eq!(duplicate_task.kind(), OrchestrationErrorKind::DuplicateTask);
let unknown = TaskGraph::try_new(
"unknown dependency",
[task("publish", "writer", TaskStatus::Pending, &["missing"])],
)
.expect_err("unknown dependency");
assert_eq!(unknown.kind(), OrchestrationErrorKind::UnknownDependency);
let self_dependency =
TaskNode::try_new("inspect", "reviewer", TaskStatus::Pending, ["inspect"])
.expect_err("self dependency");
assert_eq!(
self_dependency.kind(),
OrchestrationErrorKind::SelfDependency
);
let cycle = TaskGraph::try_new(
"cycle",
[
task("left", "researcher", TaskStatus::Pending, &["right"]),
task("right", "reviewer", TaskStatus::Pending, &["left"]),
],
)
.expect_err("cycle");
assert_eq!(cycle.kind(), OrchestrationErrorKind::Cycle);
}
#[test]
fn active_partition_requires_explicitly_satisfied_dependencies() {
let graph = document_review_graph();
let error = graph
.dependency_waves(&["publish"], &[] as &[&str])
.expect_err("publish prerequisites are neither active nor satisfied");
assert_eq!(error.kind(), OrchestrationErrorKind::UnsatisfiedDependency);
}
#[test]
fn deserialization_revalidates_task_node_contract() {
let error = serde_json::from_str::<TaskNode>(
r#"{
"id": "draft",
"agentId": "writer",
"status": "pending",
"dependencies": ["collect", "collect"]
}"#,
)
.expect_err("duplicate dependency must not bypass the constructor");
assert!(error.to_string().contains("重复依赖"));
}
@@ -9,6 +9,8 @@ default = []
legacy-creative-agent = ["dep:async-trait", "dep:langchainrust", "dep:tokio"]
[dependencies]
agent-runtime-core = { path = "../agent-runtime-core" }
agent-runtime-orchestration = { path = "../agent-runtime-orchestration" }
async-trait = { version = "0.1", optional = true }
langchainrust = { version = "0.2.20", optional = true }
platform-llm = { path = "../platform-llm", default-features = false }
@@ -1,3 +1,7 @@
use agent_runtime_core::AgentCatalog;
use agent_runtime_orchestration::{
OrchestrationError, PlanSelection, TaskGraph, TaskNode, TaskStatus,
};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
@@ -825,7 +829,7 @@ pub fn build_game_creation_seed_task_graph(
));
}
Ok(GameCreationTaskGraph {
let graph = GameCreationTaskGraph {
goal: goal.to_string(),
tasks: vec![
task(
@@ -984,29 +988,55 @@ pub fn build_game_creation_seed_task_graph(
["标题、简介、标签、封面需求和导出检查已完成"],
),
],
})
};
compile_game_creation_task_graph(&graph)?;
Ok(graph)
}
pub fn select_ready_game_creation_tasks(graph: &GameCreationTaskGraph) -> Vec<GameCreationTask> {
let completed = graph
fn compile_game_creation_task_graph(
graph: &GameCreationTaskGraph,
) -> Result<TaskGraph, PlatformAgentError> {
let tasks = graph
.tasks
.iter()
.filter(|task| task.status == GameCreationTaskStatus::Completed)
.map(|task| task.id.as_str())
.collect::<HashSet<_>>();
graph
.tasks
.iter()
.filter(|task| {
task.status == GameCreationTaskStatus::Pending
&& task
.dependencies
.iter()
.all(|dependency| completed.contains(dependency.as_str()))
.map(|task| {
TaskNode::try_new(
&task.id,
&task.id,
match task.status {
GameCreationTaskStatus::Pending => TaskStatus::Pending,
GameCreationTaskStatus::Running => TaskStatus::Running,
GameCreationTaskStatus::WaitingForConfirmation => TaskStatus::Waiting,
GameCreationTaskStatus::Completed => TaskStatus::Completed,
GameCreationTaskStatus::Failed => TaskStatus::Failed,
},
task.dependencies.iter().cloned(),
)
})
.collect::<Result<Vec<_>, _>>()
.map_err(invalid_orchestration)?;
TaskGraph::try_new(&graph.goal, tasks).map_err(invalid_orchestration)
}
pub fn validate_game_creation_task_agents(
graph: &GameCreationTaskGraph,
catalog: &AgentCatalog,
) -> Result<(), PlatformAgentError> {
compile_game_creation_task_graph(graph)?
.validate_agents(catalog)
.map_err(invalid_orchestration)
}
pub fn select_ready_game_creation_tasks(
graph: &GameCreationTaskGraph,
) -> Result<Vec<GameCreationTask>, PlatformAgentError> {
let orchestration_graph = compile_game_creation_task_graph(graph)?;
Ok(orchestration_graph
.ready_task_ids()
.into_iter()
.filter_map(|task_id| graph.tasks.iter().find(|task| task.id == task_id))
.cloned()
.collect()
.collect())
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -1034,7 +1064,8 @@ pub fn plan_game_creation_agent_pass(
graph: &GameCreationTaskGraph,
pass: u8,
findings_markdown: &str,
) -> GameCreationAgentPassPlan {
) -> Result<GameCreationAgentPassPlan, PlatformAgentError> {
let orchestration_graph = compile_game_creation_task_graph(graph)?;
let structured_repair_routes =
extract_game_creation_evaluator_repair_routes(graph, findings_markdown);
let mut repair_focus = extract_game_creation_evaluator_issues(findings_markdown);
@@ -1044,11 +1075,6 @@ pub fn plan_game_creation_agent_pass(
.map(|route| route.issue.clone())
.collect();
}
let all_task_ids = graph
.tasks
.iter()
.map(|task| task.id.clone())
.collect::<Vec<_>>();
let repair_routes = if pass <= 1 || repair_focus.is_empty() {
Vec::new()
} else if !structured_repair_routes.is_empty() {
@@ -1056,20 +1082,27 @@ pub fn plan_game_creation_agent_pass(
} else {
route_game_creation_repair_issues(graph, &repair_focus)
};
let repair_routes = expand_game_creation_repair_route_impacts(graph, repair_routes);
let active_task_ids =
select_agent_pass_active_tasks(pass, &repair_focus, &repair_routes, graph);
let active = active_task_ids
.iter()
.map(String::as_str)
.collect::<HashSet<_>>();
let carried_task_ids = all_task_ids
.iter()
.filter(|task_id| !active.contains(task_id.as_str()))
.cloned()
.collect::<Vec<_>>();
let dependency_waves =
build_game_creation_dependency_waves(graph, &active_task_ids, &carried_task_ids);
let repair_routes =
expand_game_creation_repair_route_impacts(&orchestration_graph, repair_routes)?;
let mut selected_task_ids = Vec::new();
for route in &repair_routes {
for task_id in &route.task_ids {
push_unique(&mut selected_task_ids, task_id);
}
}
let selection = if pass <= 1 || repair_focus.is_empty() || selected_task_ids.is_empty() {
PlanSelection::All
} else {
PlanSelection::Repair {
task_ids: selected_task_ids,
}
};
let orchestration_plan = orchestration_graph
.plan(selection)
.map_err(invalid_orchestration)?;
let active_task_ids = orchestration_plan.active_task_ids().to_vec();
let carried_task_ids = orchestration_plan.carried_task_ids().to_vec();
let dependency_waves = orchestration_plan.dependency_waves().to_vec();
let mode = if pass <= 1 || repair_focus.is_empty() {
"initial"
} else {
@@ -1093,7 +1126,7 @@ pub fn plan_game_creation_agent_pass(
)
};
GameCreationAgentPassPlan {
Ok(GameCreationAgentPassPlan {
pass,
mode: mode.to_string(),
active_task_ids,
@@ -1102,7 +1135,7 @@ pub fn plan_game_creation_agent_pass(
repair_focus,
repair_routes,
summary,
}
})
}
pub fn extract_game_creation_evaluator_issues(findings_markdown: &str) -> Vec<String> {
@@ -1152,30 +1185,6 @@ pub fn extract_game_creation_evaluator_repair_routes(
sanitize_repair_routes(graph, routes)
}
fn select_agent_pass_active_tasks(
pass: u8,
repair_focus: &[String],
repair_routes: &[GameCreationAgentRepairRoute],
graph: &GameCreationTaskGraph,
) -> Vec<String> {
if pass <= 1 || repair_focus.is_empty() {
return graph.tasks.iter().map(|task| task.id.clone()).collect();
}
let mut task_ids = Vec::new();
for route in repair_routes {
for task_id in &route.task_ids {
push_unique(&mut task_ids, task_id);
}
}
if task_ids.is_empty() {
graph.tasks.iter().map(|task| task.id.clone()).collect()
} else {
task_ids
}
}
pub fn route_game_creation_repair_issues(
graph: &GameCreationTaskGraph,
issues: &[String],
@@ -1273,13 +1282,15 @@ fn sanitize_repair_routes(
}
fn expand_game_creation_repair_route_impacts(
graph: &GameCreationTaskGraph,
graph: &TaskGraph,
routes: Vec<GameCreationAgentRepairRoute>,
) -> Vec<GameCreationAgentRepairRoute> {
) -> Result<Vec<GameCreationAgentRepairRoute>, PlatformAgentError> {
routes
.into_iter()
.map(|route| {
let expanded_task_ids = expand_task_ids_with_downstream_impacts(graph, &route.task_ids);
let expanded_task_ids = graph
.expand_downstream(&route.task_ids)
.map_err(invalid_orchestration)?;
let reason = if expanded_task_ids.len() > route.task_ids.len()
&& !route.reason.contains("dependency-impact")
{
@@ -1288,46 +1299,15 @@ fn expand_game_creation_repair_route_impacts(
route.reason
};
GameCreationAgentRepairRoute {
Ok(GameCreationAgentRepairRoute {
issue: route.issue,
task_ids: expanded_task_ids,
reason,
}
})
})
.collect()
}
fn expand_task_ids_with_downstream_impacts(
graph: &GameCreationTaskGraph,
task_ids: &[String],
) -> Vec<String> {
let mut impacted = task_ids.iter().cloned().collect::<HashSet<_>>();
let mut changed = true;
while changed {
changed = false;
for task in &graph.tasks {
if impacted.contains(&task.id) {
continue;
}
if task
.dependencies
.iter()
.any(|dependency| impacted.contains(dependency))
{
impacted.insert(task.id.clone());
changed = true;
}
}
}
graph
.tasks
.iter()
.filter(|task| impacted.contains(&task.id))
.map(|task| task.id.clone())
.collect()
}
fn route_game_creation_repair_issue(
graph: &GameCreationTaskGraph,
issue: &str,
@@ -1452,54 +1432,6 @@ fn push_unique(values: &mut Vec<String>, value: &str) {
}
}
fn build_game_creation_dependency_waves(
graph: &GameCreationTaskGraph,
active_task_ids: &[String],
carried_task_ids: &[String],
) -> Vec<Vec<String>> {
let active = active_task_ids.iter().cloned().collect::<HashSet<_>>();
let known = graph
.tasks
.iter()
.map(|task| task.id.clone())
.collect::<HashSet<_>>();
let mut remaining = active_task_ids.to_vec();
let mut completed = carried_task_ids.iter().cloned().collect::<HashSet<_>>();
let mut waves = Vec::new();
while !remaining.is_empty() {
let wave = remaining
.iter()
.filter(|task_id| {
graph
.tasks
.iter()
.find(|task| task.id == **task_id)
.is_some_and(|task| {
task.dependencies.iter().all(|dependency| {
!active.contains(dependency)
|| completed.contains(dependency)
|| !known.contains(dependency)
})
})
})
.cloned()
.collect::<Vec<_>>();
if wave.is_empty() {
waves.push(remaining);
break;
}
for task_id in &wave {
completed.insert(task_id.clone());
}
remaining.retain(|task_id| !wave.contains(task_id));
waves.push(wave);
}
waves
}
fn contains_any(value: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| value.contains(needle))
}
@@ -1525,6 +1457,10 @@ fn task<const D: usize, const A: usize, const C: usize>(
}
}
fn invalid_orchestration(error: OrchestrationError) -> PlatformAgentError {
PlatformAgentError::InvalidInput(format!("多 Agent 编排任务图无效:{error}"))
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
@@ -1886,6 +1822,28 @@ mod tests {
);
}
#[test]
fn seed_task_graph_validates_against_an_injected_agent_catalog() {
use agent_runtime_core::AgentDescriptor;
let graph = build_game_creation_seed_task_graph("做一个像素风横版动作原型").unwrap();
let catalog = AgentCatalog::try_new(graph.tasks.iter().map(|task| {
AgentDescriptor::try_new(&task.id, &task.role, std::iter::empty::<&str>())
.expect("agent descriptor")
}))
.expect("agent catalog");
validate_game_creation_task_agents(&graph, &catalog).expect("known task agents");
let incomplete = AgentCatalog::try_new(graph.tasks.iter().skip(1).map(|task| {
AgentDescriptor::try_new(&task.id, &task.role, std::iter::empty::<&str>())
.expect("agent descriptor")
}))
.expect("incomplete catalog");
let error = validate_game_creation_task_agents(&graph, &incomplete)
.expect_err("missing task agent must fail closed");
assert!(error.to_string().contains("未注册 Agent"));
}
#[test]
fn code_director_waits_for_design_assets_audio_and_balance() {
let graph = build_game_creation_seed_task_graph("做一个像素风横版动作原型").unwrap();
@@ -1912,6 +1870,7 @@ mod tests {
assert_eq!(
select_ready_game_creation_tasks(&graph)
.expect("ready tasks")
.iter()
.map(|task| task.id.as_str())
.collect::<Vec<_>>(),
@@ -1927,6 +1886,7 @@ mod tests {
assert_eq!(
select_ready_game_creation_tasks(&graph)
.expect("ready tasks")
.iter()
.map(|task| task.id.as_str())
.collect::<Vec<_>>(),
@@ -1942,6 +1902,7 @@ mod tests {
assert_eq!(
select_ready_game_creation_tasks(&graph)
.expect("ready tasks")
.iter()
.map(|task| task.id.as_str())
.collect::<Vec<_>>(),
@@ -1968,7 +1929,8 @@ mod tests {
&graph,
1,
"# Evaluator Findings\n\n- pass: 0\n- status: needs-revision\n\n- 暂无上一轮问题,Generator 可开始首轮实现。\n",
);
)
.expect("initial pass plan");
assert_eq!(plan.mode, "initial");
assert_eq!(plan.active_task_ids.len(), 16);
@@ -1984,7 +1946,8 @@ mod tests {
&graph,
2,
"# Evaluator Findings\n\n- pass: 1\n- status: needs-revision\n\n- gameHtml 缺少 canvas、requestAnimationFrame 和输入监听。\n",
);
)
.expect("repair pass plan");
assert_eq!(plan.mode, "repair");
assert_eq!(
@@ -2033,7 +1996,8 @@ mod tests {
]
```
"#,
);
)
.expect("structured repair pass plan");
assert_eq!(plan.mode, "repair");
assert_eq!(
@@ -2088,7 +2052,8 @@ mod tests {
]
```
"#,
);
)
.expect("asset repair pass plan");
assert_eq!(
plan.active_task_ids,
@@ -2128,11 +2093,43 @@ mod tests {
&graph,
2,
"# Evaluator Findings\n\n- pass: 1\n- status: needs-revision\n\n- handoffs 缺少 publishing 专业组交接。\n",
);
)
.expect("cross-group pass plan");
assert_eq!(plan.mode, "repair");
assert_eq!(plan.active_task_ids.len(), 16);
assert!(plan.carried_task_ids.is_empty());
assert_eq!(plan.repair_routes[0].reason, "cross-group-handoff");
}
#[test]
fn pass_plan_rejects_a_cyclic_game_task_graph() {
let graph = GameCreationTaskGraph {
goal: "验证非法环".to_string(),
tasks: vec![
task(
"left",
"左节点",
GameCreationAgentGroup::Design,
"Left",
["right"],
[],
["左节点完成"],
),
task(
"right",
"右节点",
GameCreationAgentGroup::Code,
"Right",
["left"],
[],
["右节点完成"],
),
],
};
let error = plan_game_creation_agent_pass(&graph, 1, "")
.expect_err("cyclic graph must fail closed");
assert!(error.to_string().contains("依赖环"));
}
}
@@ -32,6 +32,7 @@ pub use game_creation::{
build_game_creation_seed_task_graph, extract_game_creation_evaluator_issues,
extract_game_creation_evaluator_repair_routes, plan_game_creation_agent_pass,
route_game_creation_repair_issues, select_ready_game_creation_tasks,
validate_game_creation_task_agents,
};
#[cfg(feature = "legacy-creative-agent")]
pub use langchain_adapter::LangChainRustAdapter;