抽取通用多 Agent DAG 编排 crate
新增独立 agent-runtime-orchestration crate,统一任务图校验、ready、依赖波次和返工闭包。 接入 platform-agent 与 AGC Runtime catalog,保留游戏领域任务语义和现有持久化格式。 补充非游戏 conformance 测试、根检查脚本及架构文档。
This commit is contained in:
+10
@@ -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> {
|
||||
|
||||
+1
-1
@@ -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,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、幂等键、登录态和事务权力交给模型。
|
||||
|
||||
@@ -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 伪造完成。
|
||||
|
||||
@@ -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`
|
||||
|
||||
+2
-1
@@ -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": {
|
||||
|
||||
@@ -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,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<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 {}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct TaskNodeInput {
|
||||
id: String,
|
||||
agent_id: String,
|
||||
status: TaskStatus,
|
||||
dependencies: Vec<String>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for TaskNode {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
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<String>,
|
||||
agent_id: impl Into<String>,
|
||||
status: TaskStatus,
|
||||
dependencies: impl IntoIterator<Item = impl Into<String>>,
|
||||
) -> Result<Self, OrchestrationError> {
|
||||
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<TaskNode>,
|
||||
by_id: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
impl TaskGraph {
|
||||
pub fn try_new(
|
||||
goal: impl Into<String>,
|
||||
tasks: impl IntoIterator<Item = TaskNode>,
|
||||
) -> Result<Self, OrchestrationError> {
|
||||
let goal = goal.into();
|
||||
validate_goal(&goal)?;
|
||||
let tasks = tasks.into_iter().collect::<Vec<_>>();
|
||||
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::<HashSet<_>>();
|
||||
|
||||
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<T: AsRef<str>>(
|
||||
&self,
|
||||
task_ids: &[T],
|
||||
) -> Result<Vec<String>, 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<A: AsRef<str>, S: AsRef<str>>(
|
||||
&self,
|
||||
active_task_ids: &[A],
|
||||
satisfied_task_ids: &[S],
|
||||
) -> Result<Vec<Vec<String>>, 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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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<String> {
|
||||
self.tasks.iter().map(|task| task.id.clone()).collect()
|
||||
}
|
||||
|
||||
fn collect_known_task_ids<T: AsRef<str>>(
|
||||
&self,
|
||||
task_ids: &[T],
|
||||
label: &str,
|
||||
) -> Result<BTreeSet<String>, 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<String, usize>,
|
||||
) -> Result<(), OrchestrationError> {
|
||||
let mut indegrees = tasks
|
||||
.iter()
|
||||
.map(|task| task.dependencies.len())
|
||||
.collect::<Vec<_>>();
|
||||
let mut dependents = vec![Vec::<usize>::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::<VecDeque<_>>();
|
||||
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::<Vec<_>>();
|
||||
Err(OrchestrationError::new(
|
||||
OrchestrationErrorKind::Cycle,
|
||||
format!("task graph 包含依赖环:{}", cyclic.join(", ")),
|
||||
))
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user