新增运行中自主扩图提案能力

新增 GraphProposal、GraphLimits 与 TaskGraph 原子扩图校验。

补充严格 JSON、预算、环和失败原子性测试。

同步 Agent Runtime 技术方案与项目记忆。
This commit is contained in:
2026-08-26 20:24:06 +08:00
parent 1104215b2a
commit 92d53ea6cc
9 changed files with 1223 additions and 4 deletions
@@ -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);
}