feat(topology): cm-orchestrator topology runtime engine (Phase 2)
A pure async control-flow engine that executes a task across a TopologyGraph by sequencing safe agent turns. Safety by construction: the engine can only invoke turns via a generic TurnExecutor — it performs no side effects itself, so §15 gating (inside each turn) is inherited and switching topology cannot escalate authority. - TurnExecutor trait + TurnRequest/TurnOutcome (real impl will wrap cm-runtime::Runtime; tests use a scripted Echo executor). - Pure planners (plan.rs): hierarchical (delegate down / synthesize up), pipeline (topo-ordered threading), swarm (parallel attempts + aggregate). - RunRecord journal (per-step + RunMetrics: tokens, gated actions, approvals granted/blocked, turns) — feeds the Phase 4 comparison harness/paper. - Unsupported kinds return an error (no panic). 5 tests, clippy clean. Next (Phase 2b): a real TurnExecutor adapter over cm-runtime::send_message. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
817d8c712c
commit
e93fb24b79
@@ -0,0 +1,173 @@
|
||||
//! Pure, synchronous planners: turn a [`TopologyGraph`] into an ordered list
|
||||
//! of steps. Separated from execution so the control flow is unit-testable
|
||||
//! without any async/LLM machinery.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
use cm_topology::TopologyGraph;
|
||||
|
||||
use crate::{OrchestratorError, StepPhase};
|
||||
|
||||
/// One planned step: which node acts, in what phase, and which prior step
|
||||
/// outputs (plus optionally the top-level task) form its context.
|
||||
pub(crate) struct PlanStep {
|
||||
pub node_idx: usize,
|
||||
pub phase: StepPhase,
|
||||
pub use_task: bool,
|
||||
pub ctx_from: Vec<usize>,
|
||||
}
|
||||
|
||||
fn index_map(g: &TopologyGraph) -> HashMap<&str, usize> {
|
||||
g.nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| (n.id.as_str(), i))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn in_degrees(g: &TopologyGraph, idx: &HashMap<&str, usize>) -> Vec<usize> {
|
||||
let mut indeg = vec![0usize; g.nodes.len()];
|
||||
let mut seen = HashSet::new();
|
||||
for e in &g.edges {
|
||||
if let (Some(&a), Some(&b)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) {
|
||||
if a != b && seen.insert((a, b)) {
|
||||
indeg[b] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
indeg
|
||||
}
|
||||
|
||||
/// Hierarchical: root plans (top-down), direct children work, root synthesizes
|
||||
/// (bottom-up). Deeper trees are flattened to one level for v1.
|
||||
pub(crate) fn hierarchical(g: &TopologyGraph) -> Result<Vec<PlanStep>, OrchestratorError> {
|
||||
let idx = index_map(g);
|
||||
let n = g.nodes.len();
|
||||
let indeg = in_degrees(g, &idx);
|
||||
let root = (0..n).find(|&i| indeg[i] == 0).unwrap_or(0);
|
||||
|
||||
let mut children = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for e in &g.edges {
|
||||
if e.from == g.nodes[root].id {
|
||||
if let Some(&c) = idx.get(e.to.as_str()) {
|
||||
if c != root && seen.insert(c) {
|
||||
children.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut steps = Vec::new();
|
||||
if children.is_empty() {
|
||||
steps.push(PlanStep {
|
||||
node_idx: root,
|
||||
phase: StepPhase::Work,
|
||||
use_task: true,
|
||||
ctx_from: vec![],
|
||||
});
|
||||
return Ok(steps);
|
||||
}
|
||||
steps.push(PlanStep {
|
||||
node_idx: root,
|
||||
phase: StepPhase::Plan,
|
||||
use_task: true,
|
||||
ctx_from: vec![],
|
||||
});
|
||||
let mut child_steps = Vec::new();
|
||||
for c in children {
|
||||
steps.push(PlanStep {
|
||||
node_idx: c,
|
||||
phase: StepPhase::Work,
|
||||
use_task: true,
|
||||
ctx_from: vec![0],
|
||||
});
|
||||
child_steps.push(steps.len() - 1);
|
||||
}
|
||||
steps.push(PlanStep {
|
||||
node_idx: root,
|
||||
phase: StepPhase::Synth,
|
||||
use_task: false,
|
||||
ctx_from: child_steps,
|
||||
});
|
||||
Ok(steps)
|
||||
}
|
||||
|
||||
/// Pipeline: topological order; each stage takes the previous stage's output.
|
||||
pub(crate) fn pipeline(g: &TopologyGraph) -> Result<Vec<PlanStep>, OrchestratorError> {
|
||||
let idx = index_map(g);
|
||||
let n = g.nodes.len();
|
||||
let mut succ = vec![Vec::new(); n];
|
||||
let mut indeg = vec![0usize; n];
|
||||
let mut seen = HashSet::new();
|
||||
for e in &g.edges {
|
||||
if let (Some(&a), Some(&b)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) {
|
||||
if a != b && seen.insert((a, b)) {
|
||||
succ[a].push(b);
|
||||
indeg[b] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut queue: VecDeque<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
|
||||
let mut order = Vec::new();
|
||||
while let Some(u) = queue.pop_front() {
|
||||
order.push(u);
|
||||
for &v in &succ[u] {
|
||||
indeg[v] -= 1;
|
||||
if indeg[v] == 0 {
|
||||
queue.push_back(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Any nodes left in a cycle: append in node order so we still run them.
|
||||
for i in 0..n {
|
||||
if !order.contains(&i) {
|
||||
order.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
let steps = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &node)| PlanStep {
|
||||
node_idx: node,
|
||||
phase: StepPhase::Work,
|
||||
use_task: i == 0,
|
||||
ctx_from: if i == 0 { vec![] } else { vec![i - 1] },
|
||||
})
|
||||
.collect();
|
||||
Ok(steps)
|
||||
}
|
||||
|
||||
/// Swarm: every node attempts the task independently, then an aggregator
|
||||
/// (a "coordinator" role, else a source node) synthesizes all outputs.
|
||||
pub(crate) fn swarm(g: &TopologyGraph) -> Result<Vec<PlanStep>, OrchestratorError> {
|
||||
let n = g.nodes.len();
|
||||
if n == 0 {
|
||||
return Err(OrchestratorError::Malformed("empty topology".into()));
|
||||
}
|
||||
let idx = index_map(g);
|
||||
let indeg = in_degrees(g, &idx);
|
||||
|
||||
let mut steps: Vec<PlanStep> = (0..n)
|
||||
.map(|i| PlanStep {
|
||||
node_idx: i,
|
||||
phase: StepPhase::Work,
|
||||
use_task: true,
|
||||
ctx_from: vec![],
|
||||
})
|
||||
.collect();
|
||||
|
||||
let aggregator = (0..n)
|
||||
.find(|&i| g.nodes[i].role.to_lowercase().contains("coordinator"))
|
||||
.or_else(|| (0..n).find(|&i| indeg[i] == 0))
|
||||
.unwrap_or(0);
|
||||
|
||||
steps.push(PlanStep {
|
||||
node_idx: aggregator,
|
||||
phase: StepPhase::Aggregate,
|
||||
use_task: false,
|
||||
ctx_from: (0..n).collect(),
|
||||
});
|
||||
Ok(steps)
|
||||
}
|
||||
Reference in New Issue
Block a user