Files
Genarrative/server-rs/crates/agent-runtime-orchestration/src/graph.rs
T
kdletters d2254d1e8c
Project CI / Repository checks (push) Successful in 2m16s
Project CI / Frontend tests (push) Successful in 6m37s
Project CI / Backend tests (push) Failing after 7m45s
Project CI / Native shell tests (push) Failing after 7m6s
抽取多 Agent 编排 crate 并支持运行中自主扩图 (#207)
## 概要

- 抽取通用 `agent-runtime-orchestration` crate,承接多 Agent DAG 的构图校验、ready/wave、下游闭包和全量/返工选择。
- 保留 `platform-agent` 的游戏领域任务与语义路由,避免把 Runtime、Provider、ToolHost 和持久化职责下沉到公共编排层。
- 增加 `GraphProposal` / `TaskProposal` / `GraphEdge` / `GraphLimits`,允许宿主在执行中安全应用 LLM 提出的新增节点和边。
- 扩图采用候选图原子校验:未知 Agent/端点、重复边、自依赖、环及节点/边/深度/扇出预算都会拒绝,失败时原图保持不变;新增节点默认为 `Pending`。

## 验证

- `npm run agent-runtime-orchestration:check`(15 项通过)
- `cargo test --manifest-path server-rs/crates/platform-agent/Cargo.toml`(19 项通过)
- `npm run agc:skill-pack:check`
- `npm run check:encoding`
- `git diff --check`

前端 typecheck 本轮未执行:当前工作树未安装 `node_modules/tsc`,命令会报 `tsc is not recognized`。

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/207
2026-08-31 10:51:11 +08:00

521 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
use agent_runtime_core::AgentCatalog;
use serde::{Deserialize, Deserializer, 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, 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>,
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))
}
/// 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() {
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)
}
}
pub(crate) 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(", ")),
))
}
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 }
}