From 1104215b2af07b1c7ac48615b09d51c25f9ade02 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 26 Aug 2026 17:50:42 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=BD=E5=8F=96=E9=80=9A=E7=94=A8=E5=A4=9A?= =?UTF-8?q?=20Agent=20DAG=20=E7=BC=96=E6=8E=92=20crate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增独立 agent-runtime-orchestration crate,统一任务图校验、ready、依赖波次和返工闭包。 接入 platform-agent 与 AGC Runtime catalog,保留游戏领域任务语义和现有持久化格式。 补充非游戏 conformance 测试、根检查脚本及架构文档。 --- .../src-tauri/Cargo.lock | 10 + .../src/agent/generation/pass_artifacts.rs | 3 +- .../src-tauri/src/agent/runtime_adapter.rs | 9 +- .../planning_strategy/repair_strategy.rs | 2 +- .../shared-memory/decision-log.md | 8 + .../shared-memory/project-overview.md | 1 + ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 30 ++ package.json | 3 +- server-rs/Cargo.toml | 1 + .../agent-runtime-orchestration/.gitignore | 2 + .../agent-runtime-orchestration/Cargo.toml | 13 + .../agent-runtime-orchestration/src/error.rs | 46 ++ .../agent-runtime-orchestration/src/graph.rs | 419 ++++++++++++++++++ .../agent-runtime-orchestration/src/lib.rs | 13 + .../agent-runtime-orchestration/src/plan.rs | 64 +++ .../tests/non_game_orchestration.rs | 154 +++++++ server-rs/crates/platform-agent/Cargo.toml | 2 + .../platform-agent/src/game_creation.rs | 303 +++++++------ server-rs/crates/platform-agent/src/lib.rs | 1 + 19 files changed, 926 insertions(+), 158 deletions(-) create mode 100644 server-rs/crates/agent-runtime-orchestration/.gitignore create mode 100644 server-rs/crates/agent-runtime-orchestration/Cargo.toml create mode 100644 server-rs/crates/agent-runtime-orchestration/src/error.rs create mode 100644 server-rs/crates/agent-runtime-orchestration/src/graph.rs create mode 100644 server-rs/crates/agent-runtime-orchestration/src/lib.rs create mode 100644 server-rs/crates/agent-runtime-orchestration/src/plan.rs create mode 100644 server-rs/crates/agent-runtime-orchestration/tests/non_game_orchestration.rs diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 52f92e2e5..032383d0f 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -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", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs index bdc1046b5..979ee4ddb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs @@ -7,7 +7,8 @@ pub(crate) fn write_agent_pass_agenda( ) -> Result { 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() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_adapter.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_adapter.rs index b7360c185..0c9d72039 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_adapter.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_adapter.rs @@ -52,8 +52,13 @@ fn build_game_creator_runtime_agent_catalog() -> Result { ); } } - 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> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/repair_strategy.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/repair_strategy.rs index 25525d409..eeaeb7bc2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/repair_strategy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/repair_strategy.rs @@ -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 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4ffd04577..df5655f04 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 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-24 AGC Direct 媒体能力只通过客户端语义工具开放 - 背景:资源页已经补齐视频、角色动画、音效和背景音乐的 create/derive 能力,但 Direct Codex 只能准备标准美术包,无法查询已登记源资源或表达新增媒体意图。直接开放 Tauri invoke 会把项目路径、revision、operation、幂等键、登录态和事务权力交给模型。 diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index 58bcece5f..0ca84ed62 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -51,6 +51,7 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.7.0` 对齐 ## AGC DirectProject 与 UI workflow +- 通用 Agent Rust 分层为 `agent-runtime-core`(catalog、执行生命周期、ToolHost/spawn/all-join/Provider 契约)、`agent-runtime-orchestration`(动态无环任务图、ready、依赖波次和返工下游闭包)与 `platform-agent` 游戏适配器;循环返工通过新 pass / 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 伪造完成。 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 4616fd53a..38ac61005 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -1649,6 +1649,36 @@ 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;没有把该静态检查结果用其它门禁结果替代。 + ## 验收命令 - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml structured_plan_ -- --nocapture` diff --git a/package.json b/package.json index a3bec46ba..f9844e1cd 100644 --- a/package.json +++ b/package.json @@ -187,8 +187,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": { diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 6ef1cca89..00a37db27 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -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", diff --git a/server-rs/crates/agent-runtime-orchestration/.gitignore b/server-rs/crates/agent-runtime-orchestration/.gitignore new file mode 100644 index 000000000..042776aad --- /dev/null +++ b/server-rs/crates/agent-runtime-orchestration/.gitignore @@ -0,0 +1,2 @@ +/Cargo.lock +/target/ diff --git a/server-rs/crates/agent-runtime-orchestration/Cargo.toml b/server-rs/crates/agent-runtime-orchestration/Cargo.toml new file mode 100644 index 000000000..9a35ff007 --- /dev/null +++ b/server-rs/crates/agent-runtime-orchestration/Cargo.toml @@ -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" diff --git a/server-rs/crates/agent-runtime-orchestration/src/error.rs b/server-rs/crates/agent-runtime-orchestration/src/error.rs new file mode 100644 index 000000000..25af0518f --- /dev/null +++ b/server-rs/crates/agent-runtime-orchestration/src/error.rs @@ -0,0 +1,46 @@ +use std::fmt; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OrchestrationErrorKind { + InvalidInput, + DuplicateTask, + DuplicateDependency, + UnknownDependency, + SelfDependency, + Cycle, + UnknownAgent, + UnknownTask, + ConflictingTaskSet, + UnsatisfiedDependency, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OrchestrationError { + kind: OrchestrationErrorKind, + detail: String, +} + +impl OrchestrationError { + pub(crate) fn new(kind: OrchestrationErrorKind, detail: impl Into) -> 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 {} diff --git a/server-rs/crates/agent-runtime-orchestration/src/graph.rs b/server-rs/crates/agent-runtime-orchestration/src/graph.rs new file mode 100644 index 000000000..512610462 --- /dev/null +++ b/server-rs/crates/agent-runtime-orchestration/src/graph.rs @@ -0,0 +1,419 @@ +use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque}; + +use agent_runtime_core::AgentCatalog; +use serde::{Deserialize, Serialize}; + +use crate::{OrchestrationError, OrchestrationErrorKind}; + +const IDENTIFIER_MAX_CHARS: usize = 128; +const GOAL_MAX_CHARS: usize = 4_000; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum TaskStatus { + Pending, + Running, + Waiting, + Completed, + Failed, + Cancelled, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct TaskNode { + id: String, + agent_id: String, + status: TaskStatus, + dependencies: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct TaskNodeInput { + id: String, + agent_id: String, + status: TaskStatus, + dependencies: Vec, +} + +impl<'de> Deserialize<'de> for TaskNode { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let input = TaskNodeInput::deserialize(deserializer)?; + Self::try_new(input.id, input.agent_id, input.status, input.dependencies) + .map_err(serde::de::Error::custom) + } +} + +impl TaskNode { + pub fn try_new( + id: impl Into, + agent_id: impl Into, + status: TaskStatus, + dependencies: impl IntoIterator>, + ) -> Result { + let id = id.into(); + let agent_id = agent_id.into(); + validate_identifier(&id, "task id")?; + validate_identifier(&agent_id, "task agent id")?; + + let mut seen = BTreeSet::new(); + let mut dependencies_output = Vec::new(); + for dependency in dependencies { + let dependency = dependency.into(); + validate_identifier(&dependency, "task dependency")?; + if dependency == id { + return Err(OrchestrationError::new( + OrchestrationErrorKind::SelfDependency, + format!("task {id} 不能依赖自身"), + )); + } + if !seen.insert(dependency.clone()) { + return Err(OrchestrationError::new( + OrchestrationErrorKind::DuplicateDependency, + format!("task {id} 重复依赖:{dependency}"), + )); + } + dependencies_output.push(dependency); + } + + Ok(Self { + id, + agent_id, + status, + dependencies: dependencies_output, + }) + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn agent_id(&self) -> &str { + &self.agent_id + } + + pub fn status(&self) -> TaskStatus { + self.status + } + + pub fn dependencies(&self) -> &[String] { + &self.dependencies + } +} + +#[derive(Clone, Debug)] +pub struct TaskGraph { + goal: String, + tasks: Vec, + by_id: BTreeMap, +} + +impl TaskGraph { + pub fn try_new( + goal: impl Into, + tasks: impl IntoIterator, + ) -> Result { + let goal = goal.into(); + validate_goal(&goal)?; + let tasks = tasks.into_iter().collect::>(); + if tasks.is_empty() { + return Err(OrchestrationError::new( + OrchestrationErrorKind::InvalidInput, + "task graph 至少需要一个任务", + )); + } + + let mut by_id = BTreeMap::new(); + for (index, task) in tasks.iter().enumerate() { + if by_id.insert(task.id.clone(), index).is_some() { + return Err(OrchestrationError::new( + OrchestrationErrorKind::DuplicateTask, + format!("task id 重复:{}", task.id), + )); + } + } + for task in &tasks { + for dependency in &task.dependencies { + if !by_id.contains_key(dependency) { + return Err(OrchestrationError::new( + OrchestrationErrorKind::UnknownDependency, + format!("task {} 引用了未知依赖:{dependency}", task.id), + )); + } + } + } + + validate_acyclic(&tasks, &by_id)?; + Ok(Self { goal, tasks, by_id }) + } + + pub fn goal(&self) -> &str { + &self.goal + } + + pub fn tasks(&self) -> &[TaskNode] { + &self.tasks + } + + pub fn get(&self, task_id: &str) -> Option<&TaskNode> { + self.by_id + .get(task_id) + .and_then(|index| self.tasks.get(*index)) + } + + pub fn validate_agents(&self, catalog: &AgentCatalog) -> Result<(), OrchestrationError> { + for task in &self.tasks { + if catalog.get(&task.agent_id).is_none() { + return Err(OrchestrationError::new( + OrchestrationErrorKind::UnknownAgent, + format!("task {} 引用了未注册 Agent:{}", task.id, task.agent_id), + )); + } + } + Ok(()) + } + + pub fn ready_task_ids(&self) -> Vec<&str> { + let completed = self + .tasks + .iter() + .filter(|task| task.status == TaskStatus::Completed) + .map(|task| task.id.as_str()) + .collect::>(); + + self.tasks + .iter() + .filter(|task| { + task.status == TaskStatus::Pending + && task + .dependencies + .iter() + .all(|dependency| completed.contains(dependency.as_str())) + }) + .map(|task| task.id.as_str()) + .collect() + } + + pub fn expand_downstream>( + &self, + task_ids: &[T], + ) -> Result, OrchestrationError> { + let seeds = self.collect_known_task_ids(task_ids, "downstream seeds")?; + if seeds.is_empty() { + return Ok(Vec::new()); + } + let mut impacted = seeds; + let mut changed = true; + while changed { + changed = false; + for task in &self.tasks { + if impacted.contains(&task.id) { + continue; + } + if task + .dependencies + .iter() + .any(|dependency| impacted.contains(dependency)) + { + impacted.insert(task.id.clone()); + changed = true; + } + } + } + + Ok(self + .tasks + .iter() + .filter(|task| impacted.contains(&task.id)) + .map(|task| task.id.clone()) + .collect()) + } + + pub fn dependency_waves, S: AsRef>( + &self, + active_task_ids: &[A], + satisfied_task_ids: &[S], + ) -> Result>, OrchestrationError> { + let active = self.collect_known_task_ids(active_task_ids, "active tasks")?; + let satisfied = self.collect_known_task_ids(satisfied_task_ids, "satisfied tasks")?; + if let Some(task_id) = active.iter().find(|task_id| satisfied.contains(*task_id)) { + return Err(OrchestrationError::new( + OrchestrationErrorKind::ConflictingTaskSet, + format!("task 同时位于 active 与 satisfied:{task_id}"), + )); + } + + for task in self.tasks.iter().filter(|task| active.contains(&task.id)) { + for dependency in &task.dependencies { + if !active.contains(dependency) && !satisfied.contains(dependency) { + return Err(OrchestrationError::new( + OrchestrationErrorKind::UnsatisfiedDependency, + format!( + "active task {} 的依赖既未 active 也未 satisfied:{dependency}", + task.id + ), + )); + } + } + } + + let mut remaining = self + .tasks + .iter() + .filter(|task| active.contains(&task.id)) + .map(|task| task.id.clone()) + .collect::>(); + let mut completed = satisfied; + let mut waves = Vec::new(); + while !remaining.is_empty() { + let wave = remaining + .iter() + .filter(|task_id| { + self.get(task_id).is_some_and(|task| { + task.dependencies + .iter() + .all(|dependency| completed.contains(dependency)) + }) + }) + .cloned() + .collect::>(); + if wave.is_empty() { + return Err(OrchestrationError::new( + OrchestrationErrorKind::Cycle, + format!( + "active task graph 无法生成下一依赖波次:{}", + remaining.join(", ") + ), + )); + } + for task_id in &wave { + completed.insert(task_id.clone()); + } + remaining.retain(|task_id| !completed.contains(task_id)); + waves.push(wave); + } + Ok(waves) + } + + pub(crate) fn all_task_ids(&self) -> Vec { + self.tasks.iter().map(|task| task.id.clone()).collect() + } + + fn collect_known_task_ids>( + &self, + task_ids: &[T], + label: &str, + ) -> Result, OrchestrationError> { + let mut output = BTreeSet::new(); + for task_id in task_ids { + let task_id = task_id.as_ref(); + if self.get(task_id).is_none() { + return Err(OrchestrationError::new( + OrchestrationErrorKind::UnknownTask, + format!("{label} 包含未知 task:{task_id}"), + )); + } + if !output.insert(task_id.to_string()) { + return Err(OrchestrationError::new( + OrchestrationErrorKind::DuplicateTask, + format!("{label} 包含重复 task:{task_id}"), + )); + } + } + Ok(output) + } +} + +fn validate_identifier(value: &str, field: &str) -> Result<(), OrchestrationError> { + if value != value.trim() { + return Err(OrchestrationError::new( + OrchestrationErrorKind::InvalidInput, + format!("{field} 不得包含首尾空白"), + )); + } + let mut chars = value.chars(); + let first = chars.next().ok_or_else(|| { + OrchestrationError::new( + OrchestrationErrorKind::InvalidInput, + format!("{field} 不能为空"), + ) + })?; + if value.chars().count() > IDENTIFIER_MAX_CHARS + || !first.is_ascii_alphanumeric() + || !chars.all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-' | ':') + }) + { + return Err(OrchestrationError::new( + OrchestrationErrorKind::InvalidInput, + format!("{field} 不是合法稳定标识:{value}"), + )); + } + Ok(()) +} + +fn validate_goal(goal: &str) -> Result<(), OrchestrationError> { + if goal != goal.trim() || goal.is_empty() || goal.chars().count() > GOAL_MAX_CHARS { + return Err(OrchestrationError::new( + OrchestrationErrorKind::InvalidInput, + format!("task graph goal 必须为 1..={GOAL_MAX_CHARS} 个无首尾空白字符"), + )); + } + if goal.chars().any(char::is_control) { + return Err(OrchestrationError::new( + OrchestrationErrorKind::InvalidInput, + "task graph goal 不能包含控制字符", + )); + } + Ok(()) +} + +fn validate_acyclic( + tasks: &[TaskNode], + by_id: &BTreeMap, +) -> Result<(), OrchestrationError> { + let mut indegrees = tasks + .iter() + .map(|task| task.dependencies.len()) + .collect::>(); + let mut dependents = vec![Vec::::new(); tasks.len()]; + for (task_index, task) in tasks.iter().enumerate() { + for dependency in &task.dependencies { + let dependency_index = by_id[dependency]; + dependents[dependency_index].push(task_index); + } + } + + let mut ready = indegrees + .iter() + .enumerate() + .filter_map(|(index, indegree)| (*indegree == 0).then_some(index)) + .collect::>(); + let mut visited = 0; + while let Some(index) = ready.pop_front() { + visited += 1; + for dependent in &dependents[index] { + indegrees[*dependent] -= 1; + if indegrees[*dependent] == 0 { + ready.push_back(*dependent); + } + } + } + if visited == tasks.len() { + return Ok(()); + } + + let cyclic = tasks + .iter() + .zip(indegrees) + .filter_map(|(task, indegree)| (indegree > 0).then_some(task.id.as_str())) + .collect::>(); + Err(OrchestrationError::new( + OrchestrationErrorKind::Cycle, + format!("task graph 包含依赖环:{}", cyclic.join(", ")), + )) +} diff --git a/server-rs/crates/agent-runtime-orchestration/src/lib.rs b/server-rs/crates/agent-runtime-orchestration/src/lib.rs new file mode 100644 index 000000000..ecf418b75 --- /dev/null +++ b/server-rs/crates/agent-runtime-orchestration/src/lib.rs @@ -0,0 +1,13 @@ +//! 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; + +pub use error::{OrchestrationError, OrchestrationErrorKind}; +pub use graph::{TaskGraph, TaskNode, TaskStatus}; +pub use plan::{OrchestrationPlan, PlanSelection}; diff --git a/server-rs/crates/agent-runtime-orchestration/src/plan.rs b/server-rs/crates/agent-runtime-orchestration/src/plan.rs new file mode 100644 index 000000000..a7ba32ff5 --- /dev/null +++ b/server-rs/crates/agent-runtime-orchestration/src/plan.rs @@ -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 }, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OrchestrationPlan { + active_task_ids: Vec, + carried_task_ids: Vec, + dependency_waves: Vec>, +} + +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] { + &self.dependency_waves + } +} + +impl TaskGraph { + pub fn plan(&self, selection: PlanSelection) -> Result { + 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::>(); + let carried_task_ids = all_task_ids + .into_iter() + .filter(|task_id| !active.contains(task_id.as_str())) + .collect::>(); + let dependency_waves = self.dependency_waves(&active_task_ids, &carried_task_ids)?; + + Ok(OrchestrationPlan { + active_task_ids, + carried_task_ids, + dependency_waves, + }) + } +} diff --git a/server-rs/crates/agent-runtime-orchestration/tests/non_game_orchestration.rs b/server-rs/crates/agent-runtime-orchestration/tests/non_game_orchestration.rs new file mode 100644 index 000000000..53470dafa --- /dev/null +++ b/server-rs/crates/agent-runtime-orchestration/tests/non_game_orchestration.rs @@ -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::( + r#"{ + "id": "draft", + "agentId": "writer", + "status": "pending", + "dependencies": ["collect", "collect"] + }"#, + ) + .expect_err("duplicate dependency must not bypass the constructor"); + assert!(error.to_string().contains("重复依赖")); +} diff --git a/server-rs/crates/platform-agent/Cargo.toml b/server-rs/crates/platform-agent/Cargo.toml index c22ea18c3..62a291332 100644 --- a/server-rs/crates/platform-agent/Cargo.toml +++ b/server-rs/crates/platform-agent/Cargo.toml @@ -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 } diff --git a/server-rs/crates/platform-agent/src/game_creation.rs b/server-rs/crates/platform-agent/src/game_creation.rs index d41321bfc..2f403e922 100644 --- a/server-rs/crates/platform-agent/src/game_creation.rs +++ b/server-rs/crates/platform-agent/src/game_creation.rs @@ -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 { - let completed = graph +fn compile_game_creation_task_graph( + graph: &GameCreationTaskGraph, +) -> Result { + let tasks = graph .tasks .iter() - .filter(|task| task.status == GameCreationTaskStatus::Completed) - .map(|task| task.id.as_str()) - .collect::>(); - - 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::, _>>() + .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, 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 { + 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::>(); 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::>(); - let carried_task_ids = all_task_ids - .iter() - .filter(|task_id| !active.contains(task_id.as_str())) - .cloned() - .collect::>(); - 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 { @@ -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 { - 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, -) -> Vec { +) -> Result, 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 { - let mut impacted = task_ids.iter().cloned().collect::>(); - 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, value: &str) { } } -fn build_game_creation_dependency_waves( - graph: &GameCreationTaskGraph, - active_task_ids: &[String], - carried_task_ids: &[String], -) -> Vec> { - let active = active_task_ids.iter().cloned().collect::>(); - let known = graph - .tasks - .iter() - .map(|task| task.id.clone()) - .collect::>(); - let mut remaining = active_task_ids.to_vec(); - let mut completed = carried_task_ids.iter().cloned().collect::>(); - 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::>(); - 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( } } +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::>(), @@ -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::>(), @@ -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::>(), @@ -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("依赖环")); + } } diff --git a/server-rs/crates/platform-agent/src/lib.rs b/server-rs/crates/platform-agent/src/lib.rs index f7c609c1d..63d7403a8 100644 --- a/server-rs/crates/platform-agent/src/lib.rs +++ b/server-rs/crates/platform-agent/src/lib.rs @@ -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;