diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index df5655f04..00c219856 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,13 @@ --- +## 2026-08-26 运行中自主扩图提案留在编排层 + +- 背景:`agent-runtime-orchestration` 已能构造和调度动态 DAG,但 LLM 在执行中发现缺少步骤时没有通用的安全扩图合同。 +- 决策:新增严格 serde 的 `GraphProposal`(`TaskProposal` + `GraphEdge`)和 `GraphLimits`,由 `TaskGraph::apply_proposal` / `expand_with_proposal` 在内存中构造不可变候选图;新节点默认 `Pending`,边方向为前置 `from` → 依赖方 `to`。 +- 安全与一致性:所有 Agent、端点、重复引用、环、节点/边/深度/扇出预算在候选返回前一次校验;边只能指向新节点,禁止给已运行任务原地追加依赖。任一失败保留旧图。成功后的 epoch、基图版本、proposal 幂等和持久化由宿主负责,crate 不调用 LLM/Provider/ToolHost/Runner,也不写 `.agent/runtime/**`。 +- 验证:非游戏 conformance 覆盖有效扩图、ready/wave 重算、未知 Agent/端点、重复边、已有任务修改、环、预算、严格 JSON 和原子失败;关联文档为 `docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` V1.55。 + ## 2026-08-26 通用多 Agent DAG 编排与执行内核分层 - 背景:`agent-runtime-core` 已承接 catalog、run/action 生命周期、lane、宿主 ToolHost、spawn/all-join 和 Provider 契约,但动态任务图的 ready 选择、依赖波次与返工下游闭包仍混在 `platform-agent::game_creation`,其它产品无法复用且非法环会被合并成伪 wave。 diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md index 0ca84ed62..c7a2bccc2 100644 --- a/docs/project-memory/shared-memory/project-overview.md +++ b/docs/project-memory/shared-memory/project-overview.md @@ -51,7 +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 表达,不在单张依赖图中建立回边。 +- 通用 Agent Rust 分层为 `agent-runtime-core`(catalog、执行生命周期、ToolHost/spawn/all-join/Provider 契约)、`agent-runtime-orchestration`(动态无环任务图、ready、依赖波次、返工下游闭包和受限自主扩图提案)与 `platform-agent` 游戏适配器;循环返工通过新 pass / epoch 表达,不在单张依赖图中建立回边。LLM 可经宿主结构化 function call 提出新增节点/边,编排层只生成经校验的新候选图,epoch 与持久化仍由宿主掌控。 - DirectProject 只连接客户端内置的 `agc_tools` STDIO MCP。它负责审核引用读取、标准美术准备、已登记资源有界查询、视频 / 角色动画 / 音效 / BGM 的 create-or-derive、已登记图片去背景、desktop/mobile 浏览器试玩和受控 `agc_web_search`;付费资源调用由客户端绑定回合、幂等账本、请求上限和投影权威。 - DirectProject 的 Codex 原生文件、搜索、命令、图片查看和 Skill 仅在真实 `game/` cwd 与 `workspaceWrite(writableRoots=[game])` 内可用;原生命令网络保持关闭。多 Agent、Apps、插件、hooks、图片生成、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制保持关闭。app-server 使用隔离 `CODEX_HOME`,provider 凭据只由 AGC 客户端代理持有,不能进入模型上下文或 shell 环境。 - `ui-prototype`(设计图片)与 UI 编辑器 `UI` JSON 是不同资源。白名单 `ui.workflow.run` 按页面执行 `prepare → recognize → status → finalize`,由 provider-backed 识别、合并和组件绑定持久化 State/revision,并把 `reference-ready → structure-ready → merge-ready → binding-ready → application-ready → completed` 投影到 manifest。Provider 缺失、请求失败、工具缺失、结果不匹配或仍有待审节点时保留真实阶段并返回 blocker,不得用 deterministic seed 伪造完成。 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 38ac61005..a93160c97 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 @@ -1679,6 +1679,28 @@ V1.48-V1.50 已把 catalog、执行生命周期与 Provider 契约收进 `agent- - 未完成:Tauri `cargo check --tests` 已编译到 AGC 自定义 `build.rs`,随后因仓库四个候选路径均缺少内置 Codex CLI vendor 资源而退出(`build.rs:77`);本轮未执行 `npm ci`,因此不能将 Tauri 组合根或完整 `ai-game-creator-shell:check` 记为通过。 - 未执行:当前 Rust 1.96 工具链未安装 `clippy` component;没有把该静态检查结果用其它门禁结果替代。 +## V1.55 运行中自主扩图提案 + +V1.54 的公共编排层可以在运行前构造动态 DAG,但 LLM 在执行过程中发现缺少步骤时还没有一个通用、受限的扩图入口。V1.55 在同一 `agent-runtime-orchestration` crate 增加结构化 `GraphProposal`,让宿主能够把 LLM 的 function-call arguments 解析为候选节点和边,并在不改变 `agent-runtime-core` 执行职责的前提下生成下一张图。 + +### 提案 DTO 与宿主边界 + +- `GraphProposal` 只包含 `nodes` 与 `edges`。节点使用 `{ id, agentId }`,边使用 `{ from, to }`;`from` 是前置任务,`to` 是依赖它的任务。DTO 使用 `camelCase` 且拒绝未知字段,节点和边的稳定标识沿用公共图模型约束。 +- `TaskGraph::apply_proposal` / `expand_with_proposal` 是纯校验与候选构造 API:不调用 LLM、Provider、ToolHost、Runner 或持久化。宿主负责声明 function tool、把 arguments 反序列化为 `GraphProposal`,并在成功后把返回的候选图写入自己的新 epoch。 +- 新节点统一以 `Pending` 加入,并保留现有任务状态和注册顺序。提案边必须指向本次新增节点;不允许在运行中的旧任务上原地追加前置依赖。若业务确实要改旧边,宿主应构造完整候选图并按自己的 CAS/epoch 合同一次替换。 + +### 原子校验与预算 + +- 候选图只有在所有检查通过后才返回;未知 Agent、未知端点、重复节点/边、自依赖、有向环和已有节点依赖修改都会失败,原图保持不变。 +- `GraphLimits` 同时限制完整候选图的 `maxTasks`、`maxEdges`、`maxDepth`(根层计 1)和 `maxOutDegree`(一个前置任务的直接下游数)。默认值为 `128 / 512 / 32 / 32`;超限不截断、不部分提交。 +- 成功扩图后,宿主必须把它视为新的 graph/epoch,重新计算 ready task 与 dependency waves,并在自己的持久层记录提案身份、基图版本和幂等结果。crate 不把 epoch、proposal ID 或执行事实写入图,也不自动重放 Provider。 + +### V1.55 验收 + +- 非游戏 conformance 覆盖有效新增节点/边、全部新节点 `Pending`、ready/wave 重算、未知 Agent、未知端点、重复边、已有任务修改、环、节点/边/深度/扇出预算和失败原子性。 +- 覆盖 `GraphProposal`、`GraphEdge`、`GraphLimits` 与 `TaskGraph` 的严格 JSON round-trip;未知字段、非法标识和零预算均失败关闭。 +- 真实 LLM 接入仍由宿主后续提供;本切片证明了宿主可在一个结构化 function call 回合中安全生成候选新图,但不把 provider 请求或持久化当作 crate 的事实源。 + ## 验收命令 - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml structured_plan_ -- --nocapture` diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index da1e25334..b8a6883a2 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,11 @@ # AI 游戏创作智能体 App 实施计划 +## 2026-08-26 运行中自主扩图提案 + +- `agent-runtime-orchestration` 提供严格 serde 的 `GraphProposal`、`TaskProposal`、`GraphEdge` 与 `GraphLimits`。宿主可把 LLM function-call arguments 解析后交给 `TaskGraph::apply_proposal`,在内存中得到新的、完整校验过的候选 DAG。 +- 新节点默认 `Pending`;边使用 `from`(前置)→ `to`(依赖方),且新增边只能指向本次新增节点。未知 Agent/端点、重复节点/边、自依赖、有向环和节点/边/深度/扇出预算超限整次失败,旧图不变。 +- crate 不调用 Provider、Runner、ToolHost 或持久化;宿主负责 function tool 暴露、基图/epoch CAS、proposal 幂等、落盘和重新调度 ready/dependency waves。修改既有任务依赖时必须由宿主构造完整候选图并切换新 epoch。 + ## 2026-08-25 账户 / 项目画布 / 本地素材导入 - 素材读取区分三类来源:`asset.list` / `agc_list_registered_assets` 是当前项目本地 manifest,`agc_list_project_files` / `file.list` 只发现项目目录中实际存在但可能未登记的文件,`asset.library.list` 是当前登录账号素材库,项目画布资源读取是当前网页项目/画布的完整图片清单;账户素材库不能替代项目画布清单。 diff --git a/server-rs/crates/agent-runtime-orchestration/src/error.rs b/server-rs/crates/agent-runtime-orchestration/src/error.rs index 25af0518f..3cdd4be03 100644 --- a/server-rs/crates/agent-runtime-orchestration/src/error.rs +++ b/server-rs/crates/agent-runtime-orchestration/src/error.rs @@ -3,17 +3,35 @@ use std::fmt; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum OrchestrationErrorKind { InvalidInput, + InvalidLimits, + EmptyProposal, DuplicateTask, DuplicateDependency, + DuplicateEdge, UnknownDependency, SelfDependency, Cycle, UnknownAgent, UnknownTask, + ExistingTaskMutation, + NodeBudgetExceeded, + EdgeBudgetExceeded, + DepthBudgetExceeded, + FanOutBudgetExceeded, ConflictingTaskSet, UnsatisfiedDependency, } +#[allow(non_upper_case_globals)] +impl OrchestrationErrorKind { + /// Compatibility alias for callers that describe the node budget as a + /// task budget. + pub const TaskBudgetExceeded: Self = Self::NodeBudgetExceeded; + + /// Compatibility alias for callers that use the shorter fan-out spelling. + pub const FanoutBudgetExceeded: Self = Self::FanOutBudgetExceeded; +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct OrchestrationError { kind: OrchestrationErrorKind, diff --git a/server-rs/crates/agent-runtime-orchestration/src/graph.rs b/server-rs/crates/agent-runtime-orchestration/src/graph.rs index 512610462..69ff57b2b 100644 --- a/server-rs/crates/agent-runtime-orchestration/src/graph.rs +++ b/server-rs/crates/agent-runtime-orchestration/src/graph.rs @@ -1,7 +1,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque}; use agent_runtime_core::AgentCatalog; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::{OrchestrationError, OrchestrationErrorKind}; @@ -105,13 +105,32 @@ impl TaskNode { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] pub struct TaskGraph { goal: String, tasks: Vec, + #[serde(skip)] by_id: BTreeMap, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct TaskGraphInput { + goal: String, + tasks: Vec, +} + +impl<'de> Deserialize<'de> for TaskGraph { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let input = TaskGraphInput::deserialize(deserializer)?; + Self::try_new(input.goal, input.tasks).map_err(serde::de::Error::custom) + } +} + impl TaskGraph { pub fn try_new( goal: impl Into, @@ -165,6 +184,44 @@ impl TaskGraph { .and_then(|index| self.tasks.get(*index)) } + /// Number of tasks in this graph. + pub fn task_count(&self) -> usize { + self.tasks.len() + } + + /// Alias for [`TaskGraph::task_count`] using graph terminology. + pub fn node_count(&self) -> usize { + self.task_count() + } + + /// Number of prerequisite edges in this graph. + pub fn edge_count(&self) -> usize { + self.tasks.iter().map(|task| task.dependencies.len()).sum() + } + + /// Longest dependency path measured in task layers. A root task has + /// depth 1. Graph construction rejects cycles, so this calculation is + /// total for every `TaskGraph` value. + pub fn depth(&self) -> usize { + graph_depth(&self.tasks, &self.by_id) + } + + /// Number of direct dependents of a prerequisite task. + pub fn fan_out(&self, task_id: &str) -> Option { + self.get(task_id)?; + Some( + self.tasks + .iter() + .filter(|task| task.dependencies.iter().any(|id| id == task_id)) + .count(), + ) + } + + /// Alias for [`TaskGraph::fan_out`]. + pub fn out_degree(&self, task_id: &str) -> Option { + self.fan_out(task_id) + } + pub fn validate_agents(&self, catalog: &AgentCatalog) -> Result<(), OrchestrationError> { for task in &self.tasks { if catalog.get(&task.agent_id).is_none() { @@ -328,7 +385,7 @@ impl TaskGraph { } } -fn validate_identifier(value: &str, field: &str) -> Result<(), OrchestrationError> { +pub(crate) fn validate_identifier(value: &str, field: &str) -> Result<(), OrchestrationError> { if value != value.trim() { return Err(OrchestrationError::new( OrchestrationErrorKind::InvalidInput, @@ -417,3 +474,47 @@ fn validate_acyclic( format!("task graph 包含依赖环:{}", cyclic.join(", ")), )) } + +fn graph_depth(tasks: &[TaskNode], by_id: &BTreeMap) -> usize { + if tasks.is_empty() { + return 0; + } + + 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 { + // `TaskGraph::try_new` proves this lookup exists. Keeping the + // defensive branch makes this helper total if it is ever reused + // during a future internal refactor. + let Some(&dependency_index) = by_id.get(dependency) else { + return 0; + }; + dependents[dependency_index].push(task_index); + } + } + + let mut depths = vec![1usize; tasks.len()]; + let mut ready = indegrees + .iter() + .enumerate() + .filter_map(|(index, indegree)| (*indegree == 0).then_some(index)) + .collect::>(); + let mut visited = 0; + let mut maximum = 1; + while let Some(index) = ready.pop_front() { + visited += 1; + maximum = maximum.max(depths[index]); + for dependent in &dependents[index] { + depths[*dependent] = depths[*dependent].max(depths[index].saturating_add(1)); + indegrees[*dependent] -= 1; + if indegrees[*dependent] == 0 { + ready.push_back(*dependent); + } + } + } + if visited == tasks.len() { maximum } else { 0 } +} diff --git a/server-rs/crates/agent-runtime-orchestration/src/lib.rs b/server-rs/crates/agent-runtime-orchestration/src/lib.rs index ecf418b75..fe85e9199 100644 --- a/server-rs/crates/agent-runtime-orchestration/src/lib.rs +++ b/server-rs/crates/agent-runtime-orchestration/src/lib.rs @@ -7,7 +7,13 @@ mod error; mod graph; mod plan; +mod proposal; pub use error::{OrchestrationError, OrchestrationErrorKind}; pub use graph::{TaskGraph, TaskNode, TaskStatus}; pub use plan::{OrchestrationPlan, PlanSelection}; +pub use proposal::{ + AppliedGraphProposal, DEFAULT_GRAPH_MAX_DEPTH, DEFAULT_GRAPH_MAX_EDGES, + DEFAULT_GRAPH_MAX_OUT_DEGREE, DEFAULT_GRAPH_MAX_TASKS, GraphEdge, GraphExpansion, GraphLimits, + GraphProposal, TaskProposal, +}; diff --git a/server-rs/crates/agent-runtime-orchestration/src/proposal.rs b/server-rs/crates/agent-runtime-orchestration/src/proposal.rs new file mode 100644 index 000000000..df53cd180 --- /dev/null +++ b/server-rs/crates/agent-runtime-orchestration/src/proposal.rs @@ -0,0 +1,744 @@ +//! Structured, host-agnostic graph expansion proposed by an LLM or another +//! planner. +//! +//! This module deliberately stops at validation and candidate construction. +//! It does not call a provider, execute an agent, or persist an epoch. A host +//! can deserialize a provider function-call argument into [`GraphProposal`], +//! pass it to [`TaskGraph::expand_with_proposal`], and persist the returned +//! graph as the next epoch if the result is accepted. + +use std::collections::{BTreeMap, BTreeSet}; + +use agent_runtime_core::AgentCatalog; +use serde::{Deserialize, Deserializer, Serialize}; + +use crate::{ + OrchestrationError, OrchestrationErrorKind, TaskGraph, TaskNode, TaskStatus, + graph::validate_identifier, +}; + +/// Default maximum number of tasks in a candidate graph. +pub const DEFAULT_GRAPH_MAX_TASKS: usize = 128; +/// Default maximum number of dependency edges in a candidate graph. +pub const DEFAULT_GRAPH_MAX_EDGES: usize = 512; +/// Default maximum number of task layers in a candidate graph. +pub const DEFAULT_GRAPH_MAX_DEPTH: usize = 32; +/// Default maximum number of direct dependents of one task. +pub const DEFAULT_GRAPH_MAX_OUT_DEGREE: usize = 32; + +/// A task that a planner proposes to add to a graph. +/// +/// New tasks are always inserted with [`TaskStatus::Pending`]. Product +/// metadata such as a title, artifact path, or acceptance text belongs in the +/// host adapter and is intentionally not part of this generic DTO. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct TaskProposal { + id: String, + agent_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct TaskProposalInput { + id: String, + agent_id: String, +} + +impl<'de> Deserialize<'de> for TaskProposal { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let input = TaskProposalInput::deserialize(deserializer)?; + Self::try_new(input.id, input.agent_id).map_err(serde::de::Error::custom) + } +} + +impl TaskProposal { + /// Creates a validated task proposal. + pub fn try_new( + id: impl Into, + agent_id: impl Into, + ) -> Result { + let id = id.into(); + let agent_id = agent_id.into(); + validate_identifier(&id, "proposal task id")?; + validate_identifier(&agent_id, "proposal task agent id")?; + Ok(Self { id, agent_id }) + } + + /// Alias for [`TaskProposal::try_new`] for hosts that use `new` for DTO + /// construction while still handling validation errors. + pub fn new( + id: impl Into, + agent_id: impl Into, + ) -> Result { + Self::try_new(id, agent_id) + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn agent_id(&self) -> &str { + &self.agent_id + } +} + +/// A directed dependency edge in a proposal. +/// +/// `from` is the prerequisite and `to` is the task that depends on it. The +/// edge therefore corresponds to adding `from` to `to.dependencies` in the +/// resulting graph. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct GraphEdge { + from: String, + to: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct GraphEdgeInput { + #[serde(alias = "source")] + from: String, + #[serde(alias = "target")] + to: String, +} + +impl<'de> Deserialize<'de> for GraphEdge { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let input = GraphEdgeInput::deserialize(deserializer)?; + Self::try_new(input.from, input.to).map_err(serde::de::Error::custom) + } +} + +impl GraphEdge { + /// Creates a validated prerequisite-to-dependent edge. + pub fn try_new( + from: impl Into, + to: impl Into, + ) -> Result { + let from = from.into(); + let to = to.into(); + validate_identifier(&from, "proposal edge from")?; + validate_identifier(&to, "proposal edge to")?; + if from == to { + return Err(OrchestrationError::new( + OrchestrationErrorKind::SelfDependency, + format!("proposal edge 不能连接任务自身:{from}"), + )); + } + Ok(Self { from, to }) + } + + /// Alias for [`GraphEdge::try_new`]. + pub fn new(from: impl Into, to: impl Into) -> Result { + Self::try_new(from, to) + } + + /// Convenience constructor whose names make the dependency direction + /// explicit at call sites. + pub fn dependency( + prerequisite: impl Into, + dependent: impl Into, + ) -> Result { + Self::try_new(prerequisite, dependent) + } + + pub fn from(&self) -> &str { + &self.from + } + + pub fn to(&self) -> &str { + &self.to + } + + /// Alias for [`GraphEdge::from`], useful when a host calls the fields + /// source/target in its own graph model. + pub fn source(&self) -> &str { + &self.from + } + + /// Alias for [`GraphEdge::to`]. + pub fn target(&self) -> &str { + &self.to + } +} + +/// A structured graph change returned by a planner. +/// +/// The proposal contains only additions. Edges whose target is an existing +/// task are rejected so a running task never acquires a new prerequisite in +/// place. To replace existing dependencies, a host must build a complete +/// candidate graph and install it as a new epoch with its own persistence/CAS +/// contract. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct GraphProposal { + nodes: Vec, + edges: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct GraphProposalInput { + nodes: Vec, + edges: Vec, +} + +impl<'de> Deserialize<'de> for GraphProposal { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let input = GraphProposalInput::deserialize(deserializer)?; + Self::try_new(input.nodes, input.edges).map_err(serde::de::Error::custom) + } +} + +impl GraphProposal { + pub fn try_new( + nodes: impl IntoIterator, + edges: impl IntoIterator, + ) -> Result { + let nodes = nodes.into_iter().collect::>(); + let edges = edges.into_iter().collect::>(); + if nodes.is_empty() && edges.is_empty() { + return Err(OrchestrationError::new( + OrchestrationErrorKind::EmptyProposal, + "graph proposal 至少需要一个新节点或一条新边", + )); + } + + let mut node_ids = BTreeSet::new(); + for node in &nodes { + if !node_ids.insert(node.id.clone()) { + return Err(OrchestrationError::new( + OrchestrationErrorKind::DuplicateTask, + format!("proposal task id 重复:{}", node.id), + )); + } + } + + let mut edge_ids = BTreeSet::new(); + for edge in &edges { + if !edge_ids.insert((edge.from.clone(), edge.to.clone())) { + return Err(OrchestrationError::new( + OrchestrationErrorKind::DuplicateEdge, + format!("proposal edge 重复:{} -> {}", edge.from, edge.to), + )); + } + } + + Ok(Self { nodes, edges }) + } + + pub fn new( + nodes: impl IntoIterator, + edges: impl IntoIterator, + ) -> Result { + Self::try_new(nodes, edges) + } + + pub fn nodes(&self) -> &[TaskProposal] { + &self.nodes + } + + /// Alias for [`GraphProposal::nodes`] for callers that use task-oriented + /// terminology. + pub fn tasks(&self) -> &[TaskProposal] { + &self.nodes + } + + pub fn edges(&self) -> &[GraphEdge] { + &self.edges + } + + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() && self.edges.is_empty() + } + + pub fn validate(&self) -> Result<(), OrchestrationError> { + // The fields are private and constructors/deserialization already + // enforce these invariants. Re-running the cheap checks keeps this + // method useful as an explicit boundary for host adapters. + if self.is_empty() { + return Err(OrchestrationError::new( + OrchestrationErrorKind::EmptyProposal, + "graph proposal 至少需要一个新节点或一条新边", + )); + } + let mut node_ids = BTreeSet::new(); + for node in &self.nodes { + validate_identifier(&node.id, "proposal task id")?; + validate_identifier(&node.agent_id, "proposal task agent id")?; + if !node_ids.insert(node.id.as_str()) { + return Err(OrchestrationError::new( + OrchestrationErrorKind::DuplicateTask, + format!("proposal task id 重复:{}", node.id), + )); + } + } + let mut edge_ids = BTreeSet::new(); + for edge in &self.edges { + validate_identifier(&edge.from, "proposal edge from")?; + validate_identifier(&edge.to, "proposal edge to")?; + if edge.from == edge.to { + return Err(OrchestrationError::new( + OrchestrationErrorKind::SelfDependency, + format!("proposal edge 不能连接任务自身:{}", edge.from), + )); + } + if !edge_ids.insert((edge.from.as_str(), edge.to.as_str())) { + return Err(OrchestrationError::new( + OrchestrationErrorKind::DuplicateEdge, + format!("proposal edge 重复:{} -> {}", edge.from, edge.to), + )); + } + } + Ok(()) + } +} + +/// Resource limits applied to the candidate graph produced by a proposal. +/// +/// Limits are checked against the complete resulting graph, not just the +/// proposed delta. `max_depth` counts graph layers: a root task has depth 1. +/// `max_out_degree` counts dependents for one prerequisite (`from -> to`). +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct GraphLimits { + pub max_tasks: usize, + pub max_edges: usize, + pub max_depth: usize, + pub max_out_degree: usize, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct GraphLimitsInput { + #[serde(alias = "maxNodes")] + max_tasks: usize, + max_edges: usize, + max_depth: usize, + #[serde(alias = "maxFanOut")] + max_out_degree: usize, +} + +impl<'de> Deserialize<'de> for GraphLimits { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let input = GraphLimitsInput::deserialize(deserializer)?; + Self::try_new( + input.max_tasks, + input.max_edges, + input.max_depth, + input.max_out_degree, + ) + .map_err(serde::de::Error::custom) + } +} + +impl Default for GraphLimits { + fn default() -> Self { + Self { + max_tasks: DEFAULT_GRAPH_MAX_TASKS, + max_edges: DEFAULT_GRAPH_MAX_EDGES, + max_depth: DEFAULT_GRAPH_MAX_DEPTH, + max_out_degree: DEFAULT_GRAPH_MAX_OUT_DEGREE, + } + } +} + +impl GraphLimits { + pub const fn new( + max_tasks: usize, + max_edges: usize, + max_depth: usize, + max_out_degree: usize, + ) -> Self { + Self { + max_tasks, + max_edges, + max_depth, + max_out_degree, + } + } + + pub fn try_new( + max_tasks: usize, + max_edges: usize, + max_depth: usize, + max_out_degree: usize, + ) -> Result { + let limits = Self::new(max_tasks, max_edges, max_depth, max_out_degree); + limits.validate().map(|()| limits) + } + + pub fn with_max_tasks(mut self, value: usize) -> Self { + self.max_tasks = value; + self + } + + pub fn with_max_nodes(self, value: usize) -> Self { + self.with_max_tasks(value) + } + + pub fn with_max_edges(mut self, value: usize) -> Self { + self.max_edges = value; + self + } + + pub fn with_max_depth(mut self, value: usize) -> Self { + self.max_depth = value; + self + } + + pub fn with_max_out_degree(mut self, value: usize) -> Self { + self.max_out_degree = value; + self + } + + pub fn with_max_fan_out(self, value: usize) -> Self { + self.with_max_out_degree(value) + } + + pub fn max_nodes(&self) -> usize { + self.max_tasks + } + + pub fn max_fan_out(&self) -> usize { + self.max_out_degree + } + + pub fn validate(&self) -> Result<(), OrchestrationError> { + let invalid = [ + (self.max_tasks, "maxTasks"), + (self.max_edges, "maxEdges"), + (self.max_depth, "maxDepth"), + (self.max_out_degree, "maxOutDegree"), + ] + .into_iter() + .find(|(value, _)| *value == 0); + if let Some((_, field)) = invalid { + return Err(OrchestrationError::new( + OrchestrationErrorKind::InvalidLimits, + format!("graph limits 的 {field} 必须大于 0"), + )); + } + Ok(()) + } +} + +/// A validated candidate graph plus the delta that produced it. +/// +/// The host may use this report to persist an epoch/change journal without +/// re-parsing the untrusted provider payload. The graph itself remains the +/// authoritative candidate. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GraphExpansion { + graph: TaskGraph, + added_task_ids: Vec, + added_edges: Vec, +} + +/// Descriptive alias for [`GraphExpansion`]. +pub type AppliedGraphProposal = GraphExpansion; + +impl GraphExpansion { + pub fn graph(&self) -> &TaskGraph { + &self.graph + } + + pub fn into_graph(self) -> TaskGraph { + self.graph + } + + pub fn added_task_ids(&self) -> &[String] { + &self.added_task_ids + } + + pub fn added_edges(&self) -> &[GraphEdge] { + &self.added_edges + } +} + +impl TaskGraph { + /// Applies an additive proposal and returns a new validated graph. + /// + /// This method is intentionally immutable: a successful return is a + /// candidate for a new host-managed epoch, while every error leaves the + /// current graph untouched. New edges must target a newly proposed task; + /// this prevents changing the prerequisites of a task that may already be + /// running or completed. + pub fn apply_proposal( + &self, + proposal: &GraphProposal, + catalog: &AgentCatalog, + limits: &GraphLimits, + ) -> Result { + self.expand_with_proposal(proposal, catalog, limits) + .map(GraphExpansion::into_graph) + } + + /// Returns the candidate graph together with its validated additive delta. + pub fn expand_with_proposal( + &self, + proposal: &GraphProposal, + catalog: &AgentCatalog, + limits: &GraphLimits, + ) -> Result { + limits.validate()?; + proposal.validate()?; + self.validate_agents(catalog)?; + + let existing_task_count = self.tasks().len(); + if existing_task_count > limits.max_tasks { + return Err(OrchestrationError::new( + OrchestrationErrorKind::NodeBudgetExceeded, + format!( + "现有 task 数量 {} 已超过 maxTasks {}", + existing_task_count, limits.max_tasks + ), + )); + } + let resulting_task_count = existing_task_count + .checked_add(proposal.nodes.len()) + .ok_or_else(|| { + OrchestrationError::new( + OrchestrationErrorKind::NodeBudgetExceeded, + "proposal task 数量计算溢出", + ) + })?; + if resulting_task_count > limits.max_tasks { + return Err(OrchestrationError::new( + OrchestrationErrorKind::NodeBudgetExceeded, + format!( + "扩图后 task 数量 {} 超过 maxTasks {}", + resulting_task_count, limits.max_tasks + ), + )); + } + + let existing_ids = self + .tasks() + .iter() + .map(|task| task.id().to_string()) + .collect::>(); + let proposed_ids = proposal + .nodes + .iter() + .map(|node| node.id.clone()) + .collect::>(); + for node in &proposal.nodes { + if existing_ids.contains(&node.id) { + return Err(OrchestrationError::new( + OrchestrationErrorKind::DuplicateTask, + format!("proposal task 已存在于当前 graph:{}", node.id), + )); + } + if catalog.get(&node.agent_id).is_none() { + return Err(OrchestrationError::new( + OrchestrationErrorKind::UnknownAgent, + format!( + "proposal task {} 引用了未注册 Agent:{}", + node.id, node.agent_id + ), + )); + } + } + + let all_ids = existing_ids + .iter() + .chain(proposed_ids.iter()) + .cloned() + .collect::>(); + let existing_edges = dependency_edges(self); + let mut proposed_edges = BTreeSet::new(); + let mut dependencies_by_target = BTreeMap::>::new(); + for edge in &proposal.edges { + if !all_ids.contains(edge.from()) { + return Err(OrchestrationError::new( + OrchestrationErrorKind::UnknownDependency, + format!( + "proposal edge {} -> {} 引用了未知依赖:{}", + edge.from(), + edge.to(), + edge.from() + ), + )); + } + if !all_ids.contains(edge.to()) { + return Err(OrchestrationError::new( + OrchestrationErrorKind::UnknownTask, + format!( + "proposal edge {} -> {} 引用了未知目标 task:{}", + edge.from(), + edge.to(), + edge.to() + ), + )); + } + if !proposed_ids.contains(edge.to()) { + return Err(OrchestrationError::new( + OrchestrationErrorKind::ExistingTaskMutation, + format!( + "proposal edge {} -> {} 不能修改已有 task 的依赖", + edge.from(), + edge.to() + ), + )); + } + let edge_key = (edge.from().to_string(), edge.to().to_string()); + if !proposed_edges.insert(edge_key.clone()) { + return Err(OrchestrationError::new( + OrchestrationErrorKind::DuplicateEdge, + format!("proposal edge 重复:{} -> {}", edge.from(), edge.to()), + )); + } + if existing_edges.contains(&edge_key) { + return Err(OrchestrationError::new( + OrchestrationErrorKind::DuplicateEdge, + format!("proposal edge 已存在:{} -> {}", edge.from(), edge.to()), + )); + } + dependencies_by_target + .entry(edge.to().to_string()) + .or_default() + .push(edge.from().to_string()); + } + + let resulting_edge_count = self + .edge_count() + .checked_add(proposal.edges.len()) + .ok_or_else(|| { + OrchestrationError::new( + OrchestrationErrorKind::EdgeBudgetExceeded, + "proposal edge 数量计算溢出", + ) + })?; + if resulting_edge_count > limits.max_edges { + return Err(OrchestrationError::new( + OrchestrationErrorKind::EdgeBudgetExceeded, + format!( + "扩图后 dependency edge 数量 {} 超过 maxEdges {}", + resulting_edge_count, limits.max_edges + ), + )); + } + + validate_out_degree(self, &proposal.edges, limits.max_out_degree)?; + + let mut tasks = self.tasks().to_vec(); + let added_task_ids = proposal + .nodes + .iter() + .map(|node| node.id.clone()) + .collect::>(); + for node in &proposal.nodes { + let dependencies = dependencies_by_target.remove(&node.id).unwrap_or_default(); + tasks.push(TaskNode::try_new( + node.id.clone(), + node.agent_id.clone(), + TaskStatus::Pending, + dependencies, + )?); + } + + // TaskGraph::try_new performs the final unknown-dependency and cycle + // checks over the complete candidate, so no partially built graph can + // escape this method. + let candidate = Self::try_new(self.goal().to_string(), tasks)?; + if candidate.depth() > limits.max_depth { + return Err(OrchestrationError::new( + OrchestrationErrorKind::DepthBudgetExceeded, + format!( + "扩图后 graph depth {} 超过 maxDepth {}", + candidate.depth(), + limits.max_depth + ), + )); + } + + Ok(GraphExpansion { + graph: candidate, + added_task_ids, + added_edges: proposal.edges.clone(), + }) + } + + /// Parameter-order variant for hosts that keep limits before the catalog. + pub fn apply_proposal_with_limits( + &self, + proposal: &GraphProposal, + limits: &GraphLimits, + catalog: &AgentCatalog, + ) -> Result { + self.apply_proposal(proposal, catalog, limits) + } + + /// Short alias for [`TaskGraph::apply_proposal`]. + pub fn expand( + &self, + proposal: &GraphProposal, + catalog: &AgentCatalog, + limits: &GraphLimits, + ) -> Result { + self.apply_proposal(proposal, catalog, limits) + } +} + +fn dependency_edges(graph: &TaskGraph) -> BTreeSet<(String, String)> { + graph + .tasks() + .iter() + .flat_map(|task| { + task.dependencies() + .iter() + .map(|dependency| (dependency.clone(), task.id().to_string())) + }) + .collect() +} + +fn validate_out_degree( + graph: &TaskGraph, + proposed_edges: &[GraphEdge], + max_out_degree: usize, +) -> Result<(), OrchestrationError> { + let mut out_degree = BTreeMap::::new(); + for (from, _) in dependency_edges(graph) { + let count = out_degree.entry(from.clone()).or_default(); + *count = count.checked_add(1).ok_or_else(|| { + OrchestrationError::new( + OrchestrationErrorKind::FanOutBudgetExceeded, + format!("task {from} 的 fan-out 数量计算溢出"), + ) + })?; + } + for edge in proposed_edges { + let count = out_degree.entry(edge.from().to_string()).or_default(); + *count = count.checked_add(1).ok_or_else(|| { + OrchestrationError::new( + OrchestrationErrorKind::FanOutBudgetExceeded, + format!("task {} 的 fan-out 数量计算溢出", edge.from()), + ) + })?; + } + if let Some((task_id, count)) = out_degree + .iter() + .find(|(_, count)| **count > max_out_degree) + { + return Err(OrchestrationError::new( + OrchestrationErrorKind::FanOutBudgetExceeded, + format!("task {task_id} 的 fan-out {count} 超过 maxOutDegree {max_out_degree}"), + )); + } + Ok(()) +} diff --git a/server-rs/crates/agent-runtime-orchestration/tests/dynamic_proposal.rs b/server-rs/crates/agent-runtime-orchestration/tests/dynamic_proposal.rs new file mode 100644 index 000000000..e12e6f60d --- /dev/null +++ b/server-rs/crates/agent-runtime-orchestration/tests/dynamic_proposal.rs @@ -0,0 +1,315 @@ +use agent_runtime_core::{AgentCatalog, AgentDescriptor}; +use agent_runtime_orchestration::{ + GraphEdge, GraphLimits, GraphProposal, OrchestrationErrorKind, PlanSelection, TaskGraph, + TaskNode, TaskProposal, TaskStatus, +}; + +fn task(id: &str, agent_id: &str, status: TaskStatus, dependencies: &[&str]) -> TaskNode { + TaskNode::try_new(id, agent_id, status, dependencies.iter().copied()).expect("valid task") +} + +fn catalog() -> AgentCatalog { + AgentCatalog::try_new([ + AgentDescriptor::try_new("researcher", "research", std::iter::empty::<&str>()) + .expect("researcher"), + AgentDescriptor::try_new("reviewer", "review", std::iter::empty::<&str>()) + .expect("reviewer"), + AgentDescriptor::try_new("writer", "writing", std::iter::empty::<&str>()).expect("writer"), + ]) + .expect("catalog") +} + +fn base_graph() -> TaskGraph { + TaskGraph::try_new( + "Review a document collection", + [ + task("collect", "researcher", TaskStatus::Completed, &[]), + task("draft", "writer", TaskStatus::Pending, &["collect"]), + ], + ) + .expect("base graph") +} + +fn proposal(nodes: &[(&str, &str)], edges: &[(&str, &str)]) -> GraphProposal { + GraphProposal::try_new( + nodes + .iter() + .map(|(id, agent)| TaskProposal::try_new(*id, *agent).expect("valid proposal node")), + edges + .iter() + .map(|(from, to)| GraphEdge::try_new(*from, *to).expect("valid proposal edge")), + ) + .expect("valid proposal") +} + +#[test] +fn llm_proposal_creates_a_new_pending_subgraph_without_mutating_the_old_graph() { + let graph = base_graph(); + let candidate = graph + .apply_proposal( + &proposal( + &[("review", "reviewer"), ("publish", "writer")], + &[ + ("collect", "review"), + ("draft", "publish"), + ("review", "publish"), + ], + ), + &catalog(), + &GraphLimits::default(), + ) + .expect("proposal should be accepted"); + + assert_eq!(graph.task_count(), 2); + assert_eq!(graph.edge_count(), 1); + assert_eq!(candidate.task_count(), 4); + assert_eq!(candidate.edge_count(), 4); + assert_eq!( + candidate.get("review").expect("review task").status(), + TaskStatus::Pending + ); + assert_eq!( + candidate.get("review").expect("review task").dependencies(), + &["collect".to_string()] + ); + assert_eq!( + candidate + .get("publish") + .expect("publish task") + .dependencies(), + &["draft".to_string(), "review".to_string()] + ); + assert_eq!(candidate.ready_task_ids(), vec!["draft", "review"]); + let plan = candidate + .plan(PlanSelection::All) + .expect("expanded graph should produce dependency waves"); + assert_eq!( + plan.dependency_waves(), + &[ + vec!["collect".to_string()], + vec!["draft".to_string(), "review".to_string()], + vec!["publish".to_string()], + ] + ); +} + +#[test] +fn expansion_report_contains_only_the_validated_delta() { + let graph = base_graph(); + let change = proposal(&[("review", "reviewer")], &[("collect", "review")]); + let expansion = graph + .expand_with_proposal(&change, &catalog(), &GraphLimits::default()) + .expect("proposal should be accepted"); + + assert_eq!(expansion.added_task_ids(), ["review"]); + assert_eq!(expansion.added_edges(), change.edges()); + assert_eq!( + expansion.graph().get("review").map(TaskNode::id), + Some("review") + ); +} + +#[test] +fn proposal_rejects_unknown_agents_and_keeps_the_current_graph_intact() { + let graph = base_graph(); + let error = graph + .apply_proposal( + &proposal(&[("review", "unknown-agent")], &[]), + &catalog(), + &GraphLimits::default(), + ) + .expect_err("unknown agent"); + assert_eq!(error.kind(), OrchestrationErrorKind::UnknownAgent); + assert_eq!(graph.task_count(), 2); + assert!(graph.get("review").is_none()); +} + +#[test] +fn proposal_cycle_is_rejected_atomically() { + let graph = base_graph(); + let error = graph + .apply_proposal( + &proposal( + &[("left", "researcher"), ("right", "reviewer")], + &[("left", "right"), ("right", "left")], + ), + &catalog(), + &GraphLimits::default(), + ) + .expect_err("cycle"); + assert_eq!(error.kind(), OrchestrationErrorKind::Cycle); + assert_eq!(graph.task_count(), 2); + assert!(graph.get("left").is_none()); +} + +#[test] +fn proposal_cannot_add_a_prerequisite_to_an_existing_task() { + let graph = base_graph(); + let error = graph + .apply_proposal( + &proposal(&[("review", "reviewer")], &[("review", "draft")]), + &catalog(), + &GraphLimits::default(), + ) + .expect_err("existing task mutation"); + assert_eq!(error.kind(), OrchestrationErrorKind::ExistingTaskMutation); + assert_eq!( + graph.get("draft").expect("draft").dependencies(), + &["collect".to_string()] + ); +} + +#[test] +fn proposal_limits_cover_nodes_edges_depth_and_fan_out() { + let graph = base_graph(); + let limits = GraphLimits::new(8, 8, 8, 1); + let fan_out = graph + .apply_proposal( + &proposal( + &[("review", "reviewer"), ("verify", "reviewer")], + &[("collect", "review"), ("collect", "verify")], + ), + &catalog(), + &limits, + ) + .expect_err("fan-out budget"); + assert_eq!(fan_out.kind(), OrchestrationErrorKind::FanOutBudgetExceeded); + + let node_budget = graph + .apply_proposal( + &proposal(&[("review", "reviewer"), ("verify", "reviewer")], &[]), + &catalog(), + &GraphLimits::new(3, 8, 8, 8), + ) + .expect_err("node budget"); + assert_eq!( + node_budget.kind(), + OrchestrationErrorKind::NodeBudgetExceeded + ); + + let edge_budget = graph + .apply_proposal( + &proposal( + &[("review", "reviewer"), ("verify", "reviewer")], + &[("collect", "review"), ("collect", "verify")], + ), + &catalog(), + &GraphLimits::new(8, 2, 8, 8), + ) + .expect_err("edge budget"); + assert_eq!( + edge_budget.kind(), + OrchestrationErrorKind::EdgeBudgetExceeded + ); +} + +#[test] +fn strict_json_round_trips_proposals_and_rejects_unknown_fields() { + let change = proposal(&[("review", "reviewer")], &[("collect", "review")]); + let json = serde_json::to_value(&change).expect("serialize proposal"); + assert_eq!( + json, + serde_json::json!({ + "nodes": [{"id": "review", "agentId": "reviewer"}], + "edges": [{"from": "collect", "to": "review"}] + }) + ); + let decoded: GraphProposal = serde_json::from_value(json).expect("decode proposal"); + assert_eq!(decoded, change); + + let unknown = serde_json::from_str::( + r#"{"nodes":[{"id":"review","agentId":"reviewer","title":"not allowed"}],"edges":[]}"#, + ) + .expect_err("unknown proposal field"); + assert!(unknown.to_string().contains("unknown field")); + + let graph = base_graph(); + let graph_json = serde_json::to_value(&graph).expect("serialize graph"); + let restored: TaskGraph = serde_json::from_value(graph_json).expect("decode graph"); + assert_eq!(restored, graph); + + let limits = GraphLimits::default(); + let limits_json = serde_json::to_value(limits).expect("serialize limits"); + assert_eq!( + limits_json, + serde_json::json!({ + "maxTasks": 128, + "maxEdges": 512, + "maxDepth": 32, + "maxOutDegree": 32 + }) + ); + let restored_limits: GraphLimits = serde_json::from_value(limits_json).expect("decode limits"); + assert_eq!(restored_limits, limits); + + let unknown_limits = serde_json::from_str::( + r#"{"maxTasks":1,"maxEdges":1,"maxDepth":1,"maxOutDegree":1,"extra":true}"#, + ) + .expect_err("unknown limits field"); + assert!(unknown_limits.to_string().contains("unknown field")); +} + +#[test] +fn proposal_rejects_duplicate_edges_unknown_endpoints_and_invalid_limits() { + let duplicate = GraphProposal::try_new( + [TaskProposal::try_new("review", "reviewer").expect("node")], + [ + GraphEdge::try_new("collect", "review").expect("edge"), + GraphEdge::try_new("collect", "review").expect("edge"), + ], + ) + .expect_err("duplicate edge"); + assert_eq!(duplicate.kind(), OrchestrationErrorKind::DuplicateEdge); + + let unknown = base_graph() + .apply_proposal( + &proposal(&[("review", "reviewer")], &[("missing", "review")]), + &catalog(), + &GraphLimits::default(), + ) + .expect_err("unknown edge source"); + assert_eq!(unknown.kind(), OrchestrationErrorKind::UnknownDependency); + + let invalid_limits = GraphLimits::try_new(0, 1, 1, 1).expect_err("zero limit"); + assert_eq!(invalid_limits.kind(), OrchestrationErrorKind::InvalidLimits); +} + +#[test] +fn graph_reports_layer_depth_and_fan_out() { + let graph = TaskGraph::try_new( + "depth", + [ + task("root", "researcher", TaskStatus::Pending, &[]), + task("middle", "reviewer", TaskStatus::Pending, &["root"]), + task("leaf", "writer", TaskStatus::Pending, &["middle"]), + ], + ) + .expect("graph"); + assert_eq!(graph.depth(), 3); + assert_eq!(graph.fan_out("root"), Some(1)); + assert_eq!(graph.node_count(), 3); + assert_eq!(graph.fan_out("missing"), None); +} + +#[test] +fn proposal_rejects_empty_payload_and_depth_overflow() { + let empty = GraphProposal::try_new( + std::iter::empty::(), + std::iter::empty::(), + ) + .expect_err("empty proposal"); + assert_eq!(empty.kind(), OrchestrationErrorKind::EmptyProposal); + + let graph = base_graph(); + let error = graph + .apply_proposal( + &proposal( + &[("review", "reviewer"), ("publish", "writer")], + &[("collect", "review"), ("review", "publish")], + ), + &catalog(), + &GraphLimits::new(8, 8, 2, 8), + ) + .expect_err("candidate depth should exceed the limit"); + assert_eq!(error.kind(), OrchestrationErrorKind::DepthBudgetExceeded); +}