Skip to main content

agentforge_core/
multiagent.rs

1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4use crate::AgentFile;
5
6/// A node in a multi-agent graph — an agent with a role label.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct AgentNode {
9    /// Unique node identifier within the graph.
10    pub id: String,
11    /// Human-readable role (e.g. "planner", "executor", "critic").
12    pub role: String,
13    /// The agent's full specification.
14    pub agent: AgentFile,
15}
16
17/// A directed edge connecting two nodes: the output field of `from` feeds the input of `to`.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct GraphEdge {
20    /// ID of the source node.
21    pub from: String,
22    /// ID of the destination node.
23    pub to: String,
24    /// The field name in `from`'s output schema that is passed.
25    pub output_field: Option<String>,
26    /// The context key under which it is injected into `to`'s input.
27    pub input_key: Option<String>,
28}
29
30/// A directed acyclic graph of agents.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct AgentGraph {
33    pub id: Uuid,
34    pub name: String,
35    pub description: Option<String>,
36    pub nodes: Vec<AgentNode>,
37    pub edges: Vec<GraphEdge>,
38}
39
40impl AgentGraph {
41    /// Returns nodes in topological order (Kahn's algorithm).
42    /// Returns `Err` if the graph contains a cycle.
43    pub fn topological_order(&self) -> Result<Vec<&AgentNode>, String> {
44        use std::collections::{HashMap, VecDeque};
45
46        let mut in_degree: HashMap<&str, usize> = HashMap::new();
47        let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
48
49        for node in &self.nodes {
50            in_degree.entry(node.id.as_str()).or_insert(0);
51            adj.entry(node.id.as_str()).or_default();
52        }
53
54        for edge in &self.edges {
55            *in_degree.entry(edge.to.as_str()).or_insert(0) += 1;
56            adj.entry(edge.from.as_str())
57                .or_default()
58                .push(edge.to.as_str());
59        }
60
61        let mut queue: VecDeque<&str> = in_degree
62            .iter()
63            .filter(|(_, &d)| d == 0)
64            .map(|(&id, _)| id)
65            .collect();
66
67        let node_by_id: HashMap<&str, &AgentNode> =
68            self.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
69
70        let mut result = Vec::new();
71        while let Some(id) = queue.pop_front() {
72            if let Some(node) = node_by_id.get(id) {
73                result.push(*node);
74            }
75            if let Some(neighbors) = adj.get(id) {
76                for &neighbor in neighbors {
77                    let deg = in_degree.get_mut(neighbor).unwrap();
78                    *deg -= 1;
79                    if *deg == 0 {
80                        queue.push_back(neighbor);
81                    }
82                }
83            }
84        }
85
86        if result.len() != self.nodes.len() {
87            return Err("AgentGraph contains a cycle".to_string());
88        }
89
90        Ok(result)
91    }
92}
93
94/// Composite scoring result for a multi-agent graph run.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct MultiAgentScorecard {
97    pub graph_id: Uuid,
98    /// Per-node aggregate scores (keyed by node id).
99    pub node_scores: std::collections::HashMap<String, f64>,
100    /// Weighted average across all nodes.
101    pub composite_score: f64,
102    pub pass_rate: f64,
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    fn make_graph(edges: &[(&str, &str)]) -> AgentGraph {
110        use crate::{AgentFile, ModelConfig, ModelProvider};
111        let node_ids: std::collections::HashSet<&str> =
112            edges.iter().flat_map(|(a, b)| [*a, *b]).collect();
113
114        let nodes = node_ids
115            .into_iter()
116            .map(|id| AgentNode {
117                id: id.to_string(),
118                role: id.to_string(),
119                agent: AgentFile {
120                    agentforge_schema_version: "1".into(),
121                    name: id.to_string(),
122                    version: "0.1.0".into(),
123                    model: ModelConfig {
124                        provider: ModelProvider::Openai,
125                        model_id: "gpt-4o".into(),
126                        temperature: None,
127                        max_tokens: None,
128                        top_p: None,
129                    },
130                    system_prompt: String::new(),
131                    tools: vec![],
132                    output_schema: None,
133                    constraints: vec![],
134                    eval_hints: None,
135                    metadata: None,
136                },
137            })
138            .collect();
139
140        let graph_edges = edges
141            .iter()
142            .map(|(from, to)| GraphEdge {
143                from: from.to_string(),
144                to: to.to_string(),
145                output_field: None,
146                input_key: None,
147            })
148            .collect();
149
150        AgentGraph {
151            id: Uuid::new_v4(),
152            name: "test".into(),
153            description: None,
154            nodes,
155            edges: graph_edges,
156        }
157    }
158
159    #[test]
160    fn topological_order_linear() {
161        let g = make_graph(&[("a", "b"), ("b", "c")]);
162        let order = g.topological_order().unwrap();
163        let ids: Vec<&str> = order.iter().map(|n| n.id.as_str()).collect();
164        // 'a' must come before 'b', 'b' before 'c'
165        assert!(ids.iter().position(|&x| x == "a") < ids.iter().position(|&x| x == "b"));
166        assert!(ids.iter().position(|&x| x == "b") < ids.iter().position(|&x| x == "c"));
167    }
168}