新增运行中自主扩图提案能力
新增 GraphProposal、GraphLimits 与 TaskGraph 原子扩图校验。 补充严格 JSON、预算、环和失败原子性测试。 同步 Agent Runtime 技术方案与项目记忆。
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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 伪造完成。
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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` 是当前登录账号素材库,项目画布资源读取是当前网页项目/画布的完整图片清单;账户素材库不能替代项目画布清单。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TaskNode>,
|
||||
#[serde(skip)]
|
||||
by_id: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct TaskGraphInput {
|
||||
goal: String,
|
||||
tasks: Vec<TaskNode>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for TaskGraph {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
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<String>,
|
||||
@@ -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<usize> {
|
||||
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<usize> {
|
||||
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<String, usize>) -> usize {
|
||||
if tasks.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
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 {
|
||||
// `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::<VecDeque<_>>();
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,315 @@
|
||||
use agent_runtime_core::{AgentCatalog, AgentDescriptor};
|
||||
use agent_runtime_orchestration::{
|
||||
GraphEdge, GraphLimits, GraphProposal, OrchestrationErrorKind, PlanSelection, TaskGraph,
|
||||
TaskNode, TaskProposal, TaskStatus,
|
||||
};
|
||||
|
||||
fn task(id: &str, agent_id: &str, status: TaskStatus, dependencies: &[&str]) -> TaskNode {
|
||||
TaskNode::try_new(id, agent_id, status, dependencies.iter().copied()).expect("valid task")
|
||||
}
|
||||
|
||||
fn catalog() -> AgentCatalog {
|
||||
AgentCatalog::try_new([
|
||||
AgentDescriptor::try_new("researcher", "research", std::iter::empty::<&str>())
|
||||
.expect("researcher"),
|
||||
AgentDescriptor::try_new("reviewer", "review", std::iter::empty::<&str>())
|
||||
.expect("reviewer"),
|
||||
AgentDescriptor::try_new("writer", "writing", std::iter::empty::<&str>()).expect("writer"),
|
||||
])
|
||||
.expect("catalog")
|
||||
}
|
||||
|
||||
fn base_graph() -> TaskGraph {
|
||||
TaskGraph::try_new(
|
||||
"Review a document collection",
|
||||
[
|
||||
task("collect", "researcher", TaskStatus::Completed, &[]),
|
||||
task("draft", "writer", TaskStatus::Pending, &["collect"]),
|
||||
],
|
||||
)
|
||||
.expect("base graph")
|
||||
}
|
||||
|
||||
fn proposal(nodes: &[(&str, &str)], edges: &[(&str, &str)]) -> GraphProposal {
|
||||
GraphProposal::try_new(
|
||||
nodes
|
||||
.iter()
|
||||
.map(|(id, agent)| TaskProposal::try_new(*id, *agent).expect("valid proposal node")),
|
||||
edges
|
||||
.iter()
|
||||
.map(|(from, to)| GraphEdge::try_new(*from, *to).expect("valid proposal edge")),
|
||||
)
|
||||
.expect("valid proposal")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_proposal_creates_a_new_pending_subgraph_without_mutating_the_old_graph() {
|
||||
let graph = base_graph();
|
||||
let candidate = graph
|
||||
.apply_proposal(
|
||||
&proposal(
|
||||
&[("review", "reviewer"), ("publish", "writer")],
|
||||
&[
|
||||
("collect", "review"),
|
||||
("draft", "publish"),
|
||||
("review", "publish"),
|
||||
],
|
||||
),
|
||||
&catalog(),
|
||||
&GraphLimits::default(),
|
||||
)
|
||||
.expect("proposal should be accepted");
|
||||
|
||||
assert_eq!(graph.task_count(), 2);
|
||||
assert_eq!(graph.edge_count(), 1);
|
||||
assert_eq!(candidate.task_count(), 4);
|
||||
assert_eq!(candidate.edge_count(), 4);
|
||||
assert_eq!(
|
||||
candidate.get("review").expect("review task").status(),
|
||||
TaskStatus::Pending
|
||||
);
|
||||
assert_eq!(
|
||||
candidate.get("review").expect("review task").dependencies(),
|
||||
&["collect".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
candidate
|
||||
.get("publish")
|
||||
.expect("publish task")
|
||||
.dependencies(),
|
||||
&["draft".to_string(), "review".to_string()]
|
||||
);
|
||||
assert_eq!(candidate.ready_task_ids(), vec!["draft", "review"]);
|
||||
let plan = candidate
|
||||
.plan(PlanSelection::All)
|
||||
.expect("expanded graph should produce dependency waves");
|
||||
assert_eq!(
|
||||
plan.dependency_waves(),
|
||||
&[
|
||||
vec!["collect".to_string()],
|
||||
vec!["draft".to_string(), "review".to_string()],
|
||||
vec!["publish".to_string()],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expansion_report_contains_only_the_validated_delta() {
|
||||
let graph = base_graph();
|
||||
let change = proposal(&[("review", "reviewer")], &[("collect", "review")]);
|
||||
let expansion = graph
|
||||
.expand_with_proposal(&change, &catalog(), &GraphLimits::default())
|
||||
.expect("proposal should be accepted");
|
||||
|
||||
assert_eq!(expansion.added_task_ids(), ["review"]);
|
||||
assert_eq!(expansion.added_edges(), change.edges());
|
||||
assert_eq!(
|
||||
expansion.graph().get("review").map(TaskNode::id),
|
||||
Some("review")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proposal_rejects_unknown_agents_and_keeps_the_current_graph_intact() {
|
||||
let graph = base_graph();
|
||||
let error = graph
|
||||
.apply_proposal(
|
||||
&proposal(&[("review", "unknown-agent")], &[]),
|
||||
&catalog(),
|
||||
&GraphLimits::default(),
|
||||
)
|
||||
.expect_err("unknown agent");
|
||||
assert_eq!(error.kind(), OrchestrationErrorKind::UnknownAgent);
|
||||
assert_eq!(graph.task_count(), 2);
|
||||
assert!(graph.get("review").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proposal_cycle_is_rejected_atomically() {
|
||||
let graph = base_graph();
|
||||
let error = graph
|
||||
.apply_proposal(
|
||||
&proposal(
|
||||
&[("left", "researcher"), ("right", "reviewer")],
|
||||
&[("left", "right"), ("right", "left")],
|
||||
),
|
||||
&catalog(),
|
||||
&GraphLimits::default(),
|
||||
)
|
||||
.expect_err("cycle");
|
||||
assert_eq!(error.kind(), OrchestrationErrorKind::Cycle);
|
||||
assert_eq!(graph.task_count(), 2);
|
||||
assert!(graph.get("left").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proposal_cannot_add_a_prerequisite_to_an_existing_task() {
|
||||
let graph = base_graph();
|
||||
let error = graph
|
||||
.apply_proposal(
|
||||
&proposal(&[("review", "reviewer")], &[("review", "draft")]),
|
||||
&catalog(),
|
||||
&GraphLimits::default(),
|
||||
)
|
||||
.expect_err("existing task mutation");
|
||||
assert_eq!(error.kind(), OrchestrationErrorKind::ExistingTaskMutation);
|
||||
assert_eq!(
|
||||
graph.get("draft").expect("draft").dependencies(),
|
||||
&["collect".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proposal_limits_cover_nodes_edges_depth_and_fan_out() {
|
||||
let graph = base_graph();
|
||||
let limits = GraphLimits::new(8, 8, 8, 1);
|
||||
let fan_out = graph
|
||||
.apply_proposal(
|
||||
&proposal(
|
||||
&[("review", "reviewer"), ("verify", "reviewer")],
|
||||
&[("collect", "review"), ("collect", "verify")],
|
||||
),
|
||||
&catalog(),
|
||||
&limits,
|
||||
)
|
||||
.expect_err("fan-out budget");
|
||||
assert_eq!(fan_out.kind(), OrchestrationErrorKind::FanOutBudgetExceeded);
|
||||
|
||||
let node_budget = graph
|
||||
.apply_proposal(
|
||||
&proposal(&[("review", "reviewer"), ("verify", "reviewer")], &[]),
|
||||
&catalog(),
|
||||
&GraphLimits::new(3, 8, 8, 8),
|
||||
)
|
||||
.expect_err("node budget");
|
||||
assert_eq!(
|
||||
node_budget.kind(),
|
||||
OrchestrationErrorKind::NodeBudgetExceeded
|
||||
);
|
||||
|
||||
let edge_budget = graph
|
||||
.apply_proposal(
|
||||
&proposal(
|
||||
&[("review", "reviewer"), ("verify", "reviewer")],
|
||||
&[("collect", "review"), ("collect", "verify")],
|
||||
),
|
||||
&catalog(),
|
||||
&GraphLimits::new(8, 2, 8, 8),
|
||||
)
|
||||
.expect_err("edge budget");
|
||||
assert_eq!(
|
||||
edge_budget.kind(),
|
||||
OrchestrationErrorKind::EdgeBudgetExceeded
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_json_round_trips_proposals_and_rejects_unknown_fields() {
|
||||
let change = proposal(&[("review", "reviewer")], &[("collect", "review")]);
|
||||
let json = serde_json::to_value(&change).expect("serialize proposal");
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({
|
||||
"nodes": [{"id": "review", "agentId": "reviewer"}],
|
||||
"edges": [{"from": "collect", "to": "review"}]
|
||||
})
|
||||
);
|
||||
let decoded: GraphProposal = serde_json::from_value(json).expect("decode proposal");
|
||||
assert_eq!(decoded, change);
|
||||
|
||||
let unknown = serde_json::from_str::<GraphProposal>(
|
||||
r#"{"nodes":[{"id":"review","agentId":"reviewer","title":"not allowed"}],"edges":[]}"#,
|
||||
)
|
||||
.expect_err("unknown proposal field");
|
||||
assert!(unknown.to_string().contains("unknown field"));
|
||||
|
||||
let graph = base_graph();
|
||||
let graph_json = serde_json::to_value(&graph).expect("serialize graph");
|
||||
let restored: TaskGraph = serde_json::from_value(graph_json).expect("decode graph");
|
||||
assert_eq!(restored, graph);
|
||||
|
||||
let limits = GraphLimits::default();
|
||||
let limits_json = serde_json::to_value(limits).expect("serialize limits");
|
||||
assert_eq!(
|
||||
limits_json,
|
||||
serde_json::json!({
|
||||
"maxTasks": 128,
|
||||
"maxEdges": 512,
|
||||
"maxDepth": 32,
|
||||
"maxOutDegree": 32
|
||||
})
|
||||
);
|
||||
let restored_limits: GraphLimits = serde_json::from_value(limits_json).expect("decode limits");
|
||||
assert_eq!(restored_limits, limits);
|
||||
|
||||
let unknown_limits = serde_json::from_str::<GraphLimits>(
|
||||
r#"{"maxTasks":1,"maxEdges":1,"maxDepth":1,"maxOutDegree":1,"extra":true}"#,
|
||||
)
|
||||
.expect_err("unknown limits field");
|
||||
assert!(unknown_limits.to_string().contains("unknown field"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proposal_rejects_duplicate_edges_unknown_endpoints_and_invalid_limits() {
|
||||
let duplicate = GraphProposal::try_new(
|
||||
[TaskProposal::try_new("review", "reviewer").expect("node")],
|
||||
[
|
||||
GraphEdge::try_new("collect", "review").expect("edge"),
|
||||
GraphEdge::try_new("collect", "review").expect("edge"),
|
||||
],
|
||||
)
|
||||
.expect_err("duplicate edge");
|
||||
assert_eq!(duplicate.kind(), OrchestrationErrorKind::DuplicateEdge);
|
||||
|
||||
let unknown = base_graph()
|
||||
.apply_proposal(
|
||||
&proposal(&[("review", "reviewer")], &[("missing", "review")]),
|
||||
&catalog(),
|
||||
&GraphLimits::default(),
|
||||
)
|
||||
.expect_err("unknown edge source");
|
||||
assert_eq!(unknown.kind(), OrchestrationErrorKind::UnknownDependency);
|
||||
|
||||
let invalid_limits = GraphLimits::try_new(0, 1, 1, 1).expect_err("zero limit");
|
||||
assert_eq!(invalid_limits.kind(), OrchestrationErrorKind::InvalidLimits);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_reports_layer_depth_and_fan_out() {
|
||||
let graph = TaskGraph::try_new(
|
||||
"depth",
|
||||
[
|
||||
task("root", "researcher", TaskStatus::Pending, &[]),
|
||||
task("middle", "reviewer", TaskStatus::Pending, &["root"]),
|
||||
task("leaf", "writer", TaskStatus::Pending, &["middle"]),
|
||||
],
|
||||
)
|
||||
.expect("graph");
|
||||
assert_eq!(graph.depth(), 3);
|
||||
assert_eq!(graph.fan_out("root"), Some(1));
|
||||
assert_eq!(graph.node_count(), 3);
|
||||
assert_eq!(graph.fan_out("missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proposal_rejects_empty_payload_and_depth_overflow() {
|
||||
let empty = GraphProposal::try_new(
|
||||
std::iter::empty::<TaskProposal>(),
|
||||
std::iter::empty::<GraphEdge>(),
|
||||
)
|
||||
.expect_err("empty proposal");
|
||||
assert_eq!(empty.kind(), OrchestrationErrorKind::EmptyProposal);
|
||||
|
||||
let graph = base_graph();
|
||||
let error = graph
|
||||
.apply_proposal(
|
||||
&proposal(
|
||||
&[("review", "reviewer"), ("publish", "writer")],
|
||||
&[("collect", "review"), ("review", "publish")],
|
||||
),
|
||||
&catalog(),
|
||||
&GraphLimits::new(8, 8, 2, 8),
|
||||
)
|
||||
.expect_err("candidate depth should exceed the limit");
|
||||
assert_eq!(error.kind(), OrchestrationErrorKind::DepthBudgetExceeded);
|
||||
}
|
||||
Reference in New Issue
Block a user