合并最新 Agent 编排基线
合并 origin/master 的 Agent Runtime 编排依赖与测试门禁 恢复 platform-agent 的任务图校验与动态编排实现 为后续 AGC Router 流程调试提供可启动的最新基线
This commit is contained in:
@@ -8,6 +8,7 @@ default-members = [
|
||||
]
|
||||
exclude = [
|
||||
"crates/agent-runtime-core",
|
||||
"crates/agent-runtime-orchestration",
|
||||
"crates/module-bark-battle",
|
||||
"crates/module-big-fish",
|
||||
"crates/module-combat",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/Cargo.lock
|
||||
/target/
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "agent-runtime-orchestration"
|
||||
edition = "2024"
|
||||
version = "0.1.0"
|
||||
license = "UNLICENSED"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
agent-runtime-core = { path = "../agent-runtime-core" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,64 @@
|
||||
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,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
impl OrchestrationError {
|
||||
pub(crate) fn new(kind: OrchestrationErrorKind, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> OrchestrationErrorKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
pub fn detail(&self) -> &str {
|
||||
&self.detail
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for OrchestrationError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.detail)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for OrchestrationError {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
//! Deterministic task-graph orchestration layered over `agent-runtime-core`.
|
||||
//!
|
||||
//! Hosts register task graphs at runtime. This crate validates the graph and
|
||||
//! computes ready tasks, dependency waves and downstream repair impact without
|
||||
//! owning persistence, threads, providers, tools or product-specific policy.
|
||||
|
||||
mod error;
|
||||
mod graph;
|
||||
mod plan;
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{OrchestrationError, OrchestrationErrorKind, TaskGraph};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum PlanSelection {
|
||||
All,
|
||||
Repair { task_ids: Vec<String> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OrchestrationPlan {
|
||||
active_task_ids: Vec<String>,
|
||||
carried_task_ids: Vec<String>,
|
||||
dependency_waves: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
impl OrchestrationPlan {
|
||||
pub fn active_task_ids(&self) -> &[String] {
|
||||
&self.active_task_ids
|
||||
}
|
||||
|
||||
pub fn carried_task_ids(&self) -> &[String] {
|
||||
&self.carried_task_ids
|
||||
}
|
||||
|
||||
pub fn dependency_waves(&self) -> &[Vec<String>] {
|
||||
&self.dependency_waves
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskGraph {
|
||||
pub fn plan(&self, selection: PlanSelection) -> Result<OrchestrationPlan, OrchestrationError> {
|
||||
let all_task_ids = self.all_task_ids();
|
||||
let active_task_ids = match selection {
|
||||
PlanSelection::All => all_task_ids.clone(),
|
||||
PlanSelection::Repair { task_ids } => {
|
||||
if task_ids.is_empty() {
|
||||
return Err(OrchestrationError::new(
|
||||
OrchestrationErrorKind::InvalidInput,
|
||||
"repair selection 至少需要一个 task",
|
||||
));
|
||||
}
|
||||
self.expand_downstream(&task_ids)?
|
||||
}
|
||||
};
|
||||
let active = active_task_ids
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
let carried_task_ids = all_task_ids
|
||||
.into_iter()
|
||||
.filter(|task_id| !active.contains(task_id.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
let dependency_waves = self.dependency_waves(&active_task_ids, &carried_task_ids)?;
|
||||
|
||||
Ok(OrchestrationPlan {
|
||||
active_task_ids,
|
||||
carried_task_ids,
|
||||
dependency_waves,
|
||||
})
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
use agent_runtime_core::{AgentCatalog, AgentDescriptor};
|
||||
use agent_runtime_orchestration::{
|
||||
OrchestrationErrorKind, PlanSelection, TaskGraph, TaskNode, TaskStatus,
|
||||
};
|
||||
|
||||
fn task(id: &str, agent_id: &str, status: TaskStatus, dependencies: &[&str]) -> TaskNode {
|
||||
TaskNode::try_new(id, agent_id, status, dependencies.iter().copied()).expect("valid task")
|
||||
}
|
||||
|
||||
fn document_review_graph() -> TaskGraph {
|
||||
TaskGraph::try_new(
|
||||
"Review a document collection",
|
||||
[
|
||||
task("collect", "researcher", TaskStatus::Pending, &[]),
|
||||
task("inspect", "reviewer", TaskStatus::Pending, &[]),
|
||||
task("draft", "writer", TaskStatus::Pending, &["collect"]),
|
||||
task("verify", "reviewer", TaskStatus::Pending, &["inspect"]),
|
||||
task(
|
||||
"publish",
|
||||
"writer",
|
||||
TaskStatus::Pending,
|
||||
&["draft", "verify"],
|
||||
),
|
||||
],
|
||||
)
|
||||
.expect("valid dynamic task graph")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_non_game_dag_produces_stable_ready_waves_and_repair_closure() {
|
||||
let graph = document_review_graph();
|
||||
|
||||
assert_eq!(graph.ready_task_ids(), vec!["collect", "inspect"]);
|
||||
let full = graph.plan(PlanSelection::All).expect("full plan");
|
||||
assert_eq!(
|
||||
full.dependency_waves(),
|
||||
&[
|
||||
vec!["collect".to_string(), "inspect".to_string()],
|
||||
vec!["draft".to_string(), "verify".to_string()],
|
||||
vec!["publish".to_string()],
|
||||
]
|
||||
);
|
||||
|
||||
let repair = graph
|
||||
.plan(PlanSelection::Repair {
|
||||
task_ids: vec!["draft".to_string()],
|
||||
})
|
||||
.expect("repair plan");
|
||||
assert_eq!(repair.active_task_ids(), ["draft", "publish"]);
|
||||
assert_eq!(repair.carried_task_ids(), ["collect", "inspect", "verify"]);
|
||||
assert_eq!(
|
||||
repair.dependency_waves(),
|
||||
&[vec!["draft".to_string()], vec!["publish".to_string()]]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestration_graph_validates_agent_catalog_before_dispatch() {
|
||||
let graph = document_review_graph();
|
||||
let catalog = AgentCatalog::try_new([
|
||||
AgentDescriptor::try_new("researcher", "research", std::iter::empty::<&str>())
|
||||
.expect("researcher"),
|
||||
AgentDescriptor::try_new("reviewer", "review", std::iter::empty::<&str>())
|
||||
.expect("reviewer"),
|
||||
AgentDescriptor::try_new("writer", "writing", std::iter::empty::<&str>()).expect("writer"),
|
||||
])
|
||||
.expect("catalog");
|
||||
graph.validate_agents(&catalog).expect("known agents");
|
||||
|
||||
let incomplete = AgentCatalog::try_new([
|
||||
AgentDescriptor::try_new("researcher", "research", std::iter::empty::<&str>())
|
||||
.expect("researcher"),
|
||||
AgentDescriptor::try_new("reviewer", "review", std::iter::empty::<&str>())
|
||||
.expect("reviewer"),
|
||||
])
|
||||
.expect("incomplete catalog");
|
||||
let error = graph
|
||||
.validate_agents(&incomplete)
|
||||
.expect_err("writer must be registered");
|
||||
assert_eq!(error.kind(), OrchestrationErrorKind::UnknownAgent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_dependencies_and_cycles_fail_closed() {
|
||||
let duplicate_dependency = TaskNode::try_new(
|
||||
"draft",
|
||||
"writer",
|
||||
TaskStatus::Pending,
|
||||
["collect", "collect"],
|
||||
)
|
||||
.expect_err("duplicate dependency");
|
||||
assert_eq!(
|
||||
duplicate_dependency.kind(),
|
||||
OrchestrationErrorKind::DuplicateDependency
|
||||
);
|
||||
|
||||
let duplicate_task = TaskGraph::try_new(
|
||||
"duplicate task",
|
||||
[
|
||||
task("collect", "researcher", TaskStatus::Pending, &[]),
|
||||
task("collect", "reviewer", TaskStatus::Pending, &[]),
|
||||
],
|
||||
)
|
||||
.expect_err("duplicate task");
|
||||
assert_eq!(duplicate_task.kind(), OrchestrationErrorKind::DuplicateTask);
|
||||
|
||||
let unknown = TaskGraph::try_new(
|
||||
"unknown dependency",
|
||||
[task("publish", "writer", TaskStatus::Pending, &["missing"])],
|
||||
)
|
||||
.expect_err("unknown dependency");
|
||||
assert_eq!(unknown.kind(), OrchestrationErrorKind::UnknownDependency);
|
||||
|
||||
let self_dependency =
|
||||
TaskNode::try_new("inspect", "reviewer", TaskStatus::Pending, ["inspect"])
|
||||
.expect_err("self dependency");
|
||||
assert_eq!(
|
||||
self_dependency.kind(),
|
||||
OrchestrationErrorKind::SelfDependency
|
||||
);
|
||||
|
||||
let cycle = TaskGraph::try_new(
|
||||
"cycle",
|
||||
[
|
||||
task("left", "researcher", TaskStatus::Pending, &["right"]),
|
||||
task("right", "reviewer", TaskStatus::Pending, &["left"]),
|
||||
],
|
||||
)
|
||||
.expect_err("cycle");
|
||||
assert_eq!(cycle.kind(), OrchestrationErrorKind::Cycle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_partition_requires_explicitly_satisfied_dependencies() {
|
||||
let graph = document_review_graph();
|
||||
let error = graph
|
||||
.dependency_waves(&["publish"], &[] as &[&str])
|
||||
.expect_err("publish prerequisites are neither active nor satisfied");
|
||||
assert_eq!(error.kind(), OrchestrationErrorKind::UnsatisfiedDependency);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialization_revalidates_task_node_contract() {
|
||||
let error = serde_json::from_str::<TaskNode>(
|
||||
r#"{
|
||||
"id": "draft",
|
||||
"agentId": "writer",
|
||||
"status": "pending",
|
||||
"dependencies": ["collect", "collect"]
|
||||
}"#,
|
||||
)
|
||||
.expect_err("duplicate dependency must not bypass the constructor");
|
||||
assert!(error.to_string().contains("重复依赖"));
|
||||
}
|
||||
@@ -9,6 +9,8 @@ default = []
|
||||
legacy-creative-agent = ["dep:async-trait", "dep:langchainrust", "dep:tokio"]
|
||||
|
||||
[dependencies]
|
||||
agent-runtime-core = { path = "../agent-runtime-core" }
|
||||
agent-runtime-orchestration = { path = "../agent-runtime-orchestration" }
|
||||
async-trait = { version = "0.1", optional = true }
|
||||
langchainrust = { version = "0.2.20", optional = true }
|
||||
platform-llm = { path = "../platform-llm", default-features = false }
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
use agent_runtime_core::AgentCatalog;
|
||||
use agent_runtime_orchestration::{
|
||||
OrchestrationError, PlanSelection, TaskGraph, TaskNode, TaskStatus,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -825,7 +829,7 @@ pub fn build_game_creation_seed_task_graph(
|
||||
));
|
||||
}
|
||||
|
||||
Ok(GameCreationTaskGraph {
|
||||
let graph = GameCreationTaskGraph {
|
||||
goal: goal.to_string(),
|
||||
tasks: vec![
|
||||
task(
|
||||
@@ -984,29 +988,55 @@ pub fn build_game_creation_seed_task_graph(
|
||||
["标题、简介、标签、封面需求和导出检查已完成"],
|
||||
),
|
||||
],
|
||||
})
|
||||
};
|
||||
compile_game_creation_task_graph(&graph)?;
|
||||
Ok(graph)
|
||||
}
|
||||
|
||||
pub fn select_ready_game_creation_tasks(graph: &GameCreationTaskGraph) -> Vec<GameCreationTask> {
|
||||
let completed = graph
|
||||
fn compile_game_creation_task_graph(
|
||||
graph: &GameCreationTaskGraph,
|
||||
) -> Result<TaskGraph, PlatformAgentError> {
|
||||
let tasks = graph
|
||||
.tasks
|
||||
.iter()
|
||||
.filter(|task| task.status == GameCreationTaskStatus::Completed)
|
||||
.map(|task| task.id.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
graph
|
||||
.tasks
|
||||
.iter()
|
||||
.filter(|task| {
|
||||
task.status == GameCreationTaskStatus::Pending
|
||||
&& task
|
||||
.dependencies
|
||||
.iter()
|
||||
.all(|dependency| completed.contains(dependency.as_str()))
|
||||
.map(|task| {
|
||||
TaskNode::try_new(
|
||||
&task.id,
|
||||
&task.id,
|
||||
match task.status {
|
||||
GameCreationTaskStatus::Pending => TaskStatus::Pending,
|
||||
GameCreationTaskStatus::Running => TaskStatus::Running,
|
||||
GameCreationTaskStatus::WaitingForConfirmation => TaskStatus::Waiting,
|
||||
GameCreationTaskStatus::Completed => TaskStatus::Completed,
|
||||
GameCreationTaskStatus::Failed => TaskStatus::Failed,
|
||||
},
|
||||
task.dependencies.iter().cloned(),
|
||||
)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(invalid_orchestration)?;
|
||||
TaskGraph::try_new(&graph.goal, tasks).map_err(invalid_orchestration)
|
||||
}
|
||||
|
||||
pub fn validate_game_creation_task_agents(
|
||||
graph: &GameCreationTaskGraph,
|
||||
catalog: &AgentCatalog,
|
||||
) -> Result<(), PlatformAgentError> {
|
||||
compile_game_creation_task_graph(graph)?
|
||||
.validate_agents(catalog)
|
||||
.map_err(invalid_orchestration)
|
||||
}
|
||||
|
||||
pub fn select_ready_game_creation_tasks(
|
||||
graph: &GameCreationTaskGraph,
|
||||
) -> Result<Vec<GameCreationTask>, PlatformAgentError> {
|
||||
let orchestration_graph = compile_game_creation_task_graph(graph)?;
|
||||
Ok(orchestration_graph
|
||||
.ready_task_ids()
|
||||
.into_iter()
|
||||
.filter_map(|task_id| graph.tasks.iter().find(|task| task.id == task_id))
|
||||
.cloned()
|
||||
.collect()
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
@@ -1034,7 +1064,8 @@ pub fn plan_game_creation_agent_pass(
|
||||
graph: &GameCreationTaskGraph,
|
||||
pass: u8,
|
||||
findings_markdown: &str,
|
||||
) -> GameCreationAgentPassPlan {
|
||||
) -> Result<GameCreationAgentPassPlan, PlatformAgentError> {
|
||||
let orchestration_graph = compile_game_creation_task_graph(graph)?;
|
||||
let structured_repair_routes =
|
||||
extract_game_creation_evaluator_repair_routes(graph, findings_markdown);
|
||||
let mut repair_focus = extract_game_creation_evaluator_issues(findings_markdown);
|
||||
@@ -1044,11 +1075,6 @@ pub fn plan_game_creation_agent_pass(
|
||||
.map(|route| route.issue.clone())
|
||||
.collect();
|
||||
}
|
||||
let all_task_ids = graph
|
||||
.tasks
|
||||
.iter()
|
||||
.map(|task| task.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let repair_routes = if pass <= 1 || repair_focus.is_empty() {
|
||||
Vec::new()
|
||||
} else if !structured_repair_routes.is_empty() {
|
||||
@@ -1056,20 +1082,27 @@ pub fn plan_game_creation_agent_pass(
|
||||
} else {
|
||||
route_game_creation_repair_issues(graph, &repair_focus)
|
||||
};
|
||||
let repair_routes = expand_game_creation_repair_route_impacts(graph, repair_routes);
|
||||
let active_task_ids =
|
||||
select_agent_pass_active_tasks(pass, &repair_focus, &repair_routes, graph);
|
||||
let active = active_task_ids
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<HashSet<_>>();
|
||||
let carried_task_ids = all_task_ids
|
||||
.iter()
|
||||
.filter(|task_id| !active.contains(task_id.as_str()))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let dependency_waves =
|
||||
build_game_creation_dependency_waves(graph, &active_task_ids, &carried_task_ids);
|
||||
let repair_routes =
|
||||
expand_game_creation_repair_route_impacts(&orchestration_graph, repair_routes)?;
|
||||
let mut selected_task_ids = Vec::new();
|
||||
for route in &repair_routes {
|
||||
for task_id in &route.task_ids {
|
||||
push_unique(&mut selected_task_ids, task_id);
|
||||
}
|
||||
}
|
||||
let selection = if pass <= 1 || repair_focus.is_empty() || selected_task_ids.is_empty() {
|
||||
PlanSelection::All
|
||||
} else {
|
||||
PlanSelection::Repair {
|
||||
task_ids: selected_task_ids,
|
||||
}
|
||||
};
|
||||
let orchestration_plan = orchestration_graph
|
||||
.plan(selection)
|
||||
.map_err(invalid_orchestration)?;
|
||||
let active_task_ids = orchestration_plan.active_task_ids().to_vec();
|
||||
let carried_task_ids = orchestration_plan.carried_task_ids().to_vec();
|
||||
let dependency_waves = orchestration_plan.dependency_waves().to_vec();
|
||||
let mode = if pass <= 1 || repair_focus.is_empty() {
|
||||
"initial"
|
||||
} else {
|
||||
@@ -1093,7 +1126,7 @@ pub fn plan_game_creation_agent_pass(
|
||||
)
|
||||
};
|
||||
|
||||
GameCreationAgentPassPlan {
|
||||
Ok(GameCreationAgentPassPlan {
|
||||
pass,
|
||||
mode: mode.to_string(),
|
||||
active_task_ids,
|
||||
@@ -1102,7 +1135,7 @@ pub fn plan_game_creation_agent_pass(
|
||||
repair_focus,
|
||||
repair_routes,
|
||||
summary,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn extract_game_creation_evaluator_issues(findings_markdown: &str) -> Vec<String> {
|
||||
@@ -1152,30 +1185,6 @@ pub fn extract_game_creation_evaluator_repair_routes(
|
||||
sanitize_repair_routes(graph, routes)
|
||||
}
|
||||
|
||||
fn select_agent_pass_active_tasks(
|
||||
pass: u8,
|
||||
repair_focus: &[String],
|
||||
repair_routes: &[GameCreationAgentRepairRoute],
|
||||
graph: &GameCreationTaskGraph,
|
||||
) -> Vec<String> {
|
||||
if pass <= 1 || repair_focus.is_empty() {
|
||||
return graph.tasks.iter().map(|task| task.id.clone()).collect();
|
||||
}
|
||||
|
||||
let mut task_ids = Vec::new();
|
||||
for route in repair_routes {
|
||||
for task_id in &route.task_ids {
|
||||
push_unique(&mut task_ids, task_id);
|
||||
}
|
||||
}
|
||||
|
||||
if task_ids.is_empty() {
|
||||
graph.tasks.iter().map(|task| task.id.clone()).collect()
|
||||
} else {
|
||||
task_ids
|
||||
}
|
||||
}
|
||||
|
||||
pub fn route_game_creation_repair_issues(
|
||||
graph: &GameCreationTaskGraph,
|
||||
issues: &[String],
|
||||
@@ -1273,13 +1282,15 @@ fn sanitize_repair_routes(
|
||||
}
|
||||
|
||||
fn expand_game_creation_repair_route_impacts(
|
||||
graph: &GameCreationTaskGraph,
|
||||
graph: &TaskGraph,
|
||||
routes: Vec<GameCreationAgentRepairRoute>,
|
||||
) -> Vec<GameCreationAgentRepairRoute> {
|
||||
) -> Result<Vec<GameCreationAgentRepairRoute>, PlatformAgentError> {
|
||||
routes
|
||||
.into_iter()
|
||||
.map(|route| {
|
||||
let expanded_task_ids = expand_task_ids_with_downstream_impacts(graph, &route.task_ids);
|
||||
let expanded_task_ids = graph
|
||||
.expand_downstream(&route.task_ids)
|
||||
.map_err(invalid_orchestration)?;
|
||||
let reason = if expanded_task_ids.len() > route.task_ids.len()
|
||||
&& !route.reason.contains("dependency-impact")
|
||||
{
|
||||
@@ -1288,46 +1299,15 @@ fn expand_game_creation_repair_route_impacts(
|
||||
route.reason
|
||||
};
|
||||
|
||||
GameCreationAgentRepairRoute {
|
||||
Ok(GameCreationAgentRepairRoute {
|
||||
issue: route.issue,
|
||||
task_ids: expanded_task_ids,
|
||||
reason,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn expand_task_ids_with_downstream_impacts(
|
||||
graph: &GameCreationTaskGraph,
|
||||
task_ids: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut impacted = task_ids.iter().cloned().collect::<HashSet<_>>();
|
||||
let mut changed = true;
|
||||
while changed {
|
||||
changed = false;
|
||||
for task in &graph.tasks {
|
||||
if impacted.contains(&task.id) {
|
||||
continue;
|
||||
}
|
||||
if task
|
||||
.dependencies
|
||||
.iter()
|
||||
.any(|dependency| impacted.contains(dependency))
|
||||
{
|
||||
impacted.insert(task.id.clone());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
graph
|
||||
.tasks
|
||||
.iter()
|
||||
.filter(|task| impacted.contains(&task.id))
|
||||
.map(|task| task.id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn route_game_creation_repair_issue(
|
||||
graph: &GameCreationTaskGraph,
|
||||
issue: &str,
|
||||
@@ -1452,54 +1432,6 @@ fn push_unique(values: &mut Vec<String>, value: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_game_creation_dependency_waves(
|
||||
graph: &GameCreationTaskGraph,
|
||||
active_task_ids: &[String],
|
||||
carried_task_ids: &[String],
|
||||
) -> Vec<Vec<String>> {
|
||||
let active = active_task_ids.iter().cloned().collect::<HashSet<_>>();
|
||||
let known = graph
|
||||
.tasks
|
||||
.iter()
|
||||
.map(|task| task.id.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
let mut remaining = active_task_ids.to_vec();
|
||||
let mut completed = carried_task_ids.iter().cloned().collect::<HashSet<_>>();
|
||||
let mut waves = Vec::new();
|
||||
|
||||
while !remaining.is_empty() {
|
||||
let wave = remaining
|
||||
.iter()
|
||||
.filter(|task_id| {
|
||||
graph
|
||||
.tasks
|
||||
.iter()
|
||||
.find(|task| task.id == **task_id)
|
||||
.is_some_and(|task| {
|
||||
task.dependencies.iter().all(|dependency| {
|
||||
!active.contains(dependency)
|
||||
|| completed.contains(dependency)
|
||||
|| !known.contains(dependency)
|
||||
})
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if wave.is_empty() {
|
||||
waves.push(remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
for task_id in &wave {
|
||||
completed.insert(task_id.clone());
|
||||
}
|
||||
remaining.retain(|task_id| !wave.contains(task_id));
|
||||
waves.push(wave);
|
||||
}
|
||||
|
||||
waves
|
||||
}
|
||||
|
||||
fn contains_any(value: &str, needles: &[&str]) -> bool {
|
||||
needles.iter().any(|needle| value.contains(needle))
|
||||
}
|
||||
@@ -1525,6 +1457,10 @@ fn task<const D: usize, const A: usize, const C: usize>(
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_orchestration(error: OrchestrationError) -> PlatformAgentError {
|
||||
PlatformAgentError::InvalidInput(format!("多 Agent 编排任务图无效:{error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
@@ -1886,6 +1822,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_task_graph_validates_against_an_injected_agent_catalog() {
|
||||
use agent_runtime_core::AgentDescriptor;
|
||||
|
||||
let graph = build_game_creation_seed_task_graph("做一个像素风横版动作原型").unwrap();
|
||||
let catalog = AgentCatalog::try_new(graph.tasks.iter().map(|task| {
|
||||
AgentDescriptor::try_new(&task.id, &task.role, std::iter::empty::<&str>())
|
||||
.expect("agent descriptor")
|
||||
}))
|
||||
.expect("agent catalog");
|
||||
validate_game_creation_task_agents(&graph, &catalog).expect("known task agents");
|
||||
|
||||
let incomplete = AgentCatalog::try_new(graph.tasks.iter().skip(1).map(|task| {
|
||||
AgentDescriptor::try_new(&task.id, &task.role, std::iter::empty::<&str>())
|
||||
.expect("agent descriptor")
|
||||
}))
|
||||
.expect("incomplete catalog");
|
||||
let error = validate_game_creation_task_agents(&graph, &incomplete)
|
||||
.expect_err("missing task agent must fail closed");
|
||||
assert!(error.to_string().contains("未注册 Agent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_director_waits_for_design_assets_audio_and_balance() {
|
||||
let graph = build_game_creation_seed_task_graph("做一个像素风横版动作原型").unwrap();
|
||||
@@ -1912,6 +1870,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
select_ready_game_creation_tasks(&graph)
|
||||
.expect("ready tasks")
|
||||
.iter()
|
||||
.map(|task| task.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
@@ -1927,6 +1886,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
select_ready_game_creation_tasks(&graph)
|
||||
.expect("ready tasks")
|
||||
.iter()
|
||||
.map(|task| task.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
@@ -1942,6 +1902,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
select_ready_game_creation_tasks(&graph)
|
||||
.expect("ready tasks")
|
||||
.iter()
|
||||
.map(|task| task.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
@@ -1968,7 +1929,8 @@ mod tests {
|
||||
&graph,
|
||||
1,
|
||||
"# Evaluator Findings\n\n- pass: 0\n- status: needs-revision\n\n- 暂无上一轮问题,Generator 可开始首轮实现。\n",
|
||||
);
|
||||
)
|
||||
.expect("initial pass plan");
|
||||
|
||||
assert_eq!(plan.mode, "initial");
|
||||
assert_eq!(plan.active_task_ids.len(), 16);
|
||||
@@ -1984,7 +1946,8 @@ mod tests {
|
||||
&graph,
|
||||
2,
|
||||
"# Evaluator Findings\n\n- pass: 1\n- status: needs-revision\n\n- gameHtml 缺少 canvas、requestAnimationFrame 和输入监听。\n",
|
||||
);
|
||||
)
|
||||
.expect("repair pass plan");
|
||||
|
||||
assert_eq!(plan.mode, "repair");
|
||||
assert_eq!(
|
||||
@@ -2033,7 +1996,8 @@ mod tests {
|
||||
]
|
||||
```
|
||||
"#,
|
||||
);
|
||||
)
|
||||
.expect("structured repair pass plan");
|
||||
|
||||
assert_eq!(plan.mode, "repair");
|
||||
assert_eq!(
|
||||
@@ -2088,7 +2052,8 @@ mod tests {
|
||||
]
|
||||
```
|
||||
"#,
|
||||
);
|
||||
)
|
||||
.expect("asset repair pass plan");
|
||||
|
||||
assert_eq!(
|
||||
plan.active_task_ids,
|
||||
@@ -2128,11 +2093,43 @@ mod tests {
|
||||
&graph,
|
||||
2,
|
||||
"# Evaluator Findings\n\n- pass: 1\n- status: needs-revision\n\n- handoffs 缺少 publishing 专业组交接。\n",
|
||||
);
|
||||
)
|
||||
.expect("cross-group pass plan");
|
||||
|
||||
assert_eq!(plan.mode, "repair");
|
||||
assert_eq!(plan.active_task_ids.len(), 16);
|
||||
assert!(plan.carried_task_ids.is_empty());
|
||||
assert_eq!(plan.repair_routes[0].reason, "cross-group-handoff");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_plan_rejects_a_cyclic_game_task_graph() {
|
||||
let graph = GameCreationTaskGraph {
|
||||
goal: "验证非法环".to_string(),
|
||||
tasks: vec![
|
||||
task(
|
||||
"left",
|
||||
"左节点",
|
||||
GameCreationAgentGroup::Design,
|
||||
"Left",
|
||||
["right"],
|
||||
[],
|
||||
["左节点完成"],
|
||||
),
|
||||
task(
|
||||
"right",
|
||||
"右节点",
|
||||
GameCreationAgentGroup::Code,
|
||||
"Right",
|
||||
["left"],
|
||||
[],
|
||||
["右节点完成"],
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
let error = plan_game_creation_agent_pass(&graph, 1, "")
|
||||
.expect_err("cyclic graph must fail closed");
|
||||
assert!(error.to_string().contains("依赖环"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ pub use game_creation::{
|
||||
build_game_creation_seed_task_graph, extract_game_creation_evaluator_issues,
|
||||
extract_game_creation_evaluator_repair_routes, plan_game_creation_agent_pass,
|
||||
route_game_creation_repair_issues, select_ready_game_creation_tasks,
|
||||
validate_game_creation_task_agents,
|
||||
};
|
||||
#[cfg(feature = "legacy-creative-agent")]
|
||||
pub use langchain_adapter::LangChainRustAdapter;
|
||||
|
||||
@@ -709,52 +709,6 @@ pub struct AdminDatabaseTableRowsResponse {
|
||||
pub scan_limit_reached: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminExternalApiKeyListQuery {
|
||||
pub owner_user_id: Option<String>,
|
||||
pub public_user_code: Option<String>,
|
||||
pub key_id: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub key_prefix: Option<String>,
|
||||
pub created_after: Option<String>,
|
||||
pub created_before: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub purpose: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
pub sort_column: Option<String>,
|
||||
pub sort_direction: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminExternalApiKeyPayload {
|
||||
pub key_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub name: String,
|
||||
pub key_prefix: String,
|
||||
pub purpose: String,
|
||||
pub scopes: Vec<String>,
|
||||
pub created_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
pub revoked_at: Option<String>,
|
||||
pub updated_at: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminExternalApiKeyListResponse {
|
||||
pub keys: Vec<AdminExternalApiKeyPayload>,
|
||||
pub total: usize,
|
||||
pub limit: u32,
|
||||
pub offset: u32,
|
||||
pub scanned_count: usize,
|
||||
pub scan_limit: u32,
|
||||
pub scan_limit_reached: bool,
|
||||
}
|
||||
|
||||
// 单行查询结果,值统一用 JSON 承载以兼容不同表字段类型。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -1168,11 +1122,11 @@ mod tests {
|
||||
model: None,
|
||||
provider: None,
|
||||
task_id: None,
|
||||
source_resource_id: None,
|
||||
source_image_src: None,
|
||||
source_object_key: None,
|
||||
source_asset_object_id: None,
|
||||
source_label: None,
|
||||
source_resource_id: Some("source-resource-1".to_string()),
|
||||
source_image_src: Some("/generated-character-drafts/editor/source.png".to_string()),
|
||||
source_object_key: Some("generated-character-drafts/editor/source.png".to_string()),
|
||||
source_asset_object_id: Some("source-asset-object-1".to_string()),
|
||||
source_label: Some("来源素材".to_string()),
|
||||
asset_kind: Some("character".to_string()),
|
||||
generation_inputs: None,
|
||||
thumbnail_src: Some("/generated-character-drafts/editor/spec-thumb.png".to_string()),
|
||||
@@ -1219,6 +1173,17 @@ mod tests {
|
||||
value["thumbnailSrc"],
|
||||
json!("/generated-character-drafts/editor/spec-thumb.png")
|
||||
);
|
||||
assert_eq!(value["sourceResourceId"], json!("source-resource-1"));
|
||||
assert_eq!(
|
||||
value["sourceImageSrc"],
|
||||
json!("/generated-character-drafts/editor/source.png")
|
||||
);
|
||||
assert_eq!(
|
||||
value["sourceObjectKey"],
|
||||
json!("generated-character-drafts/editor/source.png")
|
||||
);
|
||||
assert_eq!(value["sourceAssetObjectId"], json!("source-asset-object-1"));
|
||||
assert_eq!(value["sourceLabel"], json!("来源素材"));
|
||||
assert_eq!(
|
||||
value["imageSequenceFrames"].as_array().map(Vec::len),
|
||||
Some(2)
|
||||
@@ -1227,6 +1192,11 @@ mod tests {
|
||||
assert!(value.get("author_display_name").is_none());
|
||||
assert!(value.get("author_public_user_code").is_none());
|
||||
assert!(value.get("thumbnail_src").is_none());
|
||||
assert!(value.get("source_resource_id").is_none());
|
||||
assert!(value.get("source_image_src").is_none());
|
||||
assert!(value.get("source_object_key").is_none());
|
||||
assert!(value.get("source_asset_object_id").is_none());
|
||||
assert!(value.get("source_label").is_none());
|
||||
assert!(value.get("image_sequence_frames").is_none());
|
||||
assert!(value.get("image_sequence_duration_ms").is_none());
|
||||
}
|
||||
|
||||
@@ -65,3 +65,199 @@ pub use runtime::*;
|
||||
pub use square_hole::*;
|
||||
pub use visual_novel::*;
|
||||
pub use wooden_fish::*;
|
||||
|
||||
// Host-side unit tests need to link the module crate as a normal test binary.
|
||||
// SpacetimeDB's raw ABI imports only exist in the WASM host, so provide
|
||||
// deterministic error-returning symbols for tests that exercise pure helpers.
|
||||
// Reducer/procedure integration tests must use a real SpacetimeDB runtime.
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod host_test_imports {
|
||||
type TableId = u32;
|
||||
type IndexId = u32;
|
||||
type ColId = u16;
|
||||
type BytesSource = u32;
|
||||
type BytesSink = u32;
|
||||
type RowIter = u32;
|
||||
|
||||
const HOST_TEST_UNSUPPORTED: u16 = 1;
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn table_id_from_name(_: *const u8, _: usize, _: *mut TableId) -> u16 {
|
||||
HOST_TEST_UNSUPPORTED
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn index_id_from_name(_: *const u8, _: usize, _: *mut IndexId) -> u16 {
|
||||
HOST_TEST_UNSUPPORTED
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn datastore_table_row_count(_: TableId, _: *mut u64) -> u16 {
|
||||
HOST_TEST_UNSUPPORTED
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn datastore_table_scan_bsatn(_: TableId, _: *mut RowIter) -> u16 {
|
||||
HOST_TEST_UNSUPPORTED
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn datastore_index_scan_range_bsatn(
|
||||
_: IndexId,
|
||||
_: *const u8,
|
||||
_: usize,
|
||||
_: ColId,
|
||||
_: *const u8,
|
||||
_: usize,
|
||||
_: *const u8,
|
||||
_: usize,
|
||||
_: *mut RowIter,
|
||||
) -> u16 {
|
||||
HOST_TEST_UNSUPPORTED
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn datastore_delete_by_index_scan_range_bsatn(
|
||||
_: IndexId,
|
||||
_: *const u8,
|
||||
_: usize,
|
||||
_: ColId,
|
||||
_: *const u8,
|
||||
_: usize,
|
||||
_: *const u8,
|
||||
_: usize,
|
||||
_: *mut u32,
|
||||
) -> u16 {
|
||||
HOST_TEST_UNSUPPORTED
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn datastore_delete_all_by_eq_bsatn(
|
||||
_: TableId,
|
||||
_: *const u8,
|
||||
_: usize,
|
||||
_: *mut u32,
|
||||
) -> u16 {
|
||||
HOST_TEST_UNSUPPORTED
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn row_iter_bsatn_advance(_: RowIter, _: *mut u8, _: *mut usize) -> i16 {
|
||||
-1
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn row_iter_bsatn_close(_: RowIter) -> u16 {
|
||||
HOST_TEST_UNSUPPORTED
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn datastore_insert_bsatn(_: TableId, _: *mut u8, _: *mut usize) -> u16 {
|
||||
HOST_TEST_UNSUPPORTED
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn datastore_update_bsatn(
|
||||
_: TableId,
|
||||
_: IndexId,
|
||||
_: *mut u8,
|
||||
_: *mut usize,
|
||||
) -> u16 {
|
||||
HOST_TEST_UNSUPPORTED
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn bytes_sink_write(_: BytesSink, _: *const u8, _: *mut usize) -> u16 {
|
||||
HOST_TEST_UNSUPPORTED
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn bytes_source_read(_: BytesSource, _: *mut u8, _: *mut usize) -> i16 {
|
||||
-1
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn console_log(
|
||||
_: u8,
|
||||
_: *const u8,
|
||||
_: usize,
|
||||
_: *const u8,
|
||||
_: usize,
|
||||
_: u32,
|
||||
_: *const u8,
|
||||
_: usize,
|
||||
) {
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn console_timer_start(_: *const u8, _: usize) -> u32 {
|
||||
0
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn console_timer_end(_: u32) -> u16 {
|
||||
HOST_TEST_UNSUPPORTED
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn identity(out: *mut u8) {
|
||||
if !out.is_null() {
|
||||
unsafe { std::ptr::write_bytes(out, 0, 32) };
|
||||
}
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn bytes_source_remaining_length(_: BytesSource, _: *mut u32) -> i16 {
|
||||
HOST_TEST_UNSUPPORTED as i16
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn get_jwt(_: *const u8, _: *mut BytesSource) -> u16 {
|
||||
HOST_TEST_UNSUPPORTED
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn procedure_sleep_until(wake_at: i64) -> i64 {
|
||||
wake_at
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn procedure_start_mut_tx(_: *mut i64) -> u16 {
|
||||
HOST_TEST_UNSUPPORTED
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn procedure_commit_mut_tx() -> u16 {
|
||||
HOST_TEST_UNSUPPORTED
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn procedure_abort_mut_tx() -> u16 {
|
||||
HOST_TEST_UNSUPPORTED
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn datastore_index_scan_point_bsatn(
|
||||
_: IndexId,
|
||||
_: *const u8,
|
||||
_: usize,
|
||||
_: *mut RowIter,
|
||||
) -> u16 {
|
||||
HOST_TEST_UNSUPPORTED
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn datastore_delete_by_index_scan_point_bsatn(
|
||||
_: IndexId,
|
||||
_: *const u8,
|
||||
_: usize,
|
||||
_: *mut u32,
|
||||
) -> u16 {
|
||||
HOST_TEST_UNSUPPORTED
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn datastore_clear(_: TableId, _: *mut u64) -> u16 {
|
||||
HOST_TEST_UNSUPPORTED
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user