feat: teammates flag enabling peer messaging between concurrent graph agent nodes
Agent nodes marked teammates: true whose sub-agents run concurrently (map fan-out branches or multiple flagged agent nodes in one super-step) now form a team: each member gets a pre-provisioned inbox in a messaging-only PeerRegistry before any branch starts, a teammate roster injected into its prompt, peer resolution in agent__send_message, and a peers section in agent__list_running. Registries are scoped to a single fan-out and carry no lifecycle semantics: outputs still flow exclusively through collect_into/state reducers, finished peers reject new messages, and nothing registers in a supervisor. Also hardens the agent-recursion depth guard to work without a supervisor and adds validator warnings for zero-valued orchestration limits.
This commit is contained in:
11 files changed
+975
-45
No files matched your search
@@ -113,6 +113,7 @@ mod tests {
|
||||
state_updates: updates,
|
||||
output_schema: None,
|
||||
timeout: None,
|
||||
teammates: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use super::types::{EndNode, Graph, Node, NodeType};
|
||||
use super::user_interaction::{ApprovalNodeExecutor, InputNodeExecutor};
|
||||
use super::validator::{AgentValidationContext, GraphValidator};
|
||||
use crate::config::{RenderMode, RequestContext};
|
||||
use crate::supervisor::mailbox::{Inbox, PeerAssignment, PeerRegistry, graph_agent_id};
|
||||
use crate::utils::AbortSignal;
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use futures_util::future::join_all;
|
||||
@@ -160,6 +161,13 @@ impl GraphExecutor {
|
||||
logger.super_step_start(&branches);
|
||||
}
|
||||
|
||||
// Pre-provision teammate identities for `teammates: true` agent
|
||||
// nodes running concurrently in this super-step, before any branch
|
||||
// starts, so an early finisher can message one still waiting on
|
||||
// the semaphore. A lone flagged node has no peers.
|
||||
let flagged = teammate_flagged_nodes(&graph, &frontier);
|
||||
let (peer_registry, mut peer_assignments) = provision_frontier_peers(flagged);
|
||||
|
||||
let mut branch_tasks = Vec::with_capacity(frontier_size);
|
||||
for node_id in &frontier {
|
||||
let node = graph
|
||||
@@ -171,6 +179,10 @@ impl GraphExecutor {
|
||||
logger.node_start(&node, in_super_step);
|
||||
let branch_state = state.fork_for_branch_state();
|
||||
let mut branch_ctx = ctx.fork_for_branch();
|
||||
if let Some(assignment) = peer_assignments.remove(node_id) {
|
||||
branch_ctx.peer_registry = peer_registry.clone();
|
||||
branch_ctx.peer_assignment = Some(assignment);
|
||||
}
|
||||
if in_super_step {
|
||||
branch_ctx.render_mode = RenderMode::Silent;
|
||||
}
|
||||
@@ -355,6 +367,36 @@ fn sorted_frontier(frontier: &HashSet<String>) -> Vec<String> {
|
||||
v
|
||||
}
|
||||
|
||||
fn teammate_flagged_nodes(graph: &Graph, frontier: &HashSet<String>) -> Vec<(String, String)> {
|
||||
frontier
|
||||
.iter()
|
||||
.filter_map(
|
||||
|node_id| match graph.get_node(node_id).map(|n| &n.node_type) {
|
||||
Some(NodeType::Agent(n)) if n.teammates => Some((node_id.clone(), n.agent.clone())),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn provision_frontier_peers(
|
||||
flagged: Vec<(String, String)>,
|
||||
) -> (Option<Arc<PeerRegistry>>, HashMap<String, PeerAssignment>) {
|
||||
if flagged.len() < 2 {
|
||||
return (None, HashMap::new());
|
||||
}
|
||||
|
||||
let registry = Arc::new(PeerRegistry::new());
|
||||
let mut assignments = HashMap::new();
|
||||
for (node_id, agent_name) in flagged {
|
||||
let id = graph_agent_id(&agent_name);
|
||||
let inbox = Arc::new(Inbox::new());
|
||||
registry.insert(id.clone(), node_id.clone(), Arc::clone(&inbox));
|
||||
assignments.insert(node_id, (id, inbox));
|
||||
}
|
||||
(Some(registry), assignments)
|
||||
}
|
||||
|
||||
pub(super) struct StepContext<'a> {
|
||||
pub graph: &'a Graph,
|
||||
pub script_executor: &'a ScriptExecutor,
|
||||
@@ -475,7 +517,9 @@ fn apply_simple_state_updates(updates: Option<&HashMap<String, String>>, state:
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::types::{AgentNode, GraphSettings};
|
||||
use super::*;
|
||||
use indexmap::IndexMap;
|
||||
use serde_json::json;
|
||||
|
||||
fn state_with(pairs: &[(&str, Value)]) -> StateManager {
|
||||
@@ -557,6 +601,135 @@ mod tests {
|
||||
|
||||
assert_eq!(state.state().get("k"), Some(&json!("new-old")));
|
||||
}
|
||||
|
||||
fn agent_node(id: &str, teammates: bool) -> Node {
|
||||
Node {
|
||||
id: id.into(),
|
||||
description: String::new(),
|
||||
node_type: NodeType::Agent(AgentNode {
|
||||
agent: format!("{id}-agent"),
|
||||
prompt: "p".into(),
|
||||
state_updates: None,
|
||||
output_schema: None,
|
||||
timeout: None,
|
||||
teammates,
|
||||
}),
|
||||
next: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_node(id: &str) -> Node {
|
||||
Node {
|
||||
id: id.into(),
|
||||
description: String::new(),
|
||||
node_type: NodeType::End(EndNode {
|
||||
output: String::new(),
|
||||
state_updates: None,
|
||||
}),
|
||||
next: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn graph_of(nodes: Vec<Node>) -> Graph {
|
||||
let start = nodes[0].id.clone();
|
||||
let mut map: IndexMap<String, Node> = IndexMap::new();
|
||||
for node in nodes {
|
||||
map.insert(node.id.clone(), node);
|
||||
}
|
||||
|
||||
Graph {
|
||||
name: "t".into(),
|
||||
description: String::new(),
|
||||
version: "1.0".into(),
|
||||
model: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
reasoning_effort: None,
|
||||
max_concurrent_jobs: None,
|
||||
can_spawn_agents: None,
|
||||
max_concurrent_agents: None,
|
||||
max_agent_depth: None,
|
||||
global_tools: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
mcp_tools: None,
|
||||
skills_enabled: None,
|
||||
enabled_skills: None,
|
||||
inject_skill_instructions: None,
|
||||
skill_instructions: None,
|
||||
conversation_starters: Vec::new(),
|
||||
variables: Vec::new(),
|
||||
settings: GraphSettings::default(),
|
||||
initial_state: HashMap::new(),
|
||||
reducers: HashMap::new(),
|
||||
start,
|
||||
nodes: map,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teammate_flagged_nodes_selects_only_flagged_agent_nodes() {
|
||||
let graph = graph_of(vec![
|
||||
agent_node("a", true),
|
||||
agent_node("b", true),
|
||||
agent_node("c", false),
|
||||
terminal_node("done"),
|
||||
]);
|
||||
let frontier: HashSet<String> = ["a", "b", "c", "done"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
let mut flagged = teammate_flagged_nodes(&graph, &frontier);
|
||||
flagged.sort();
|
||||
|
||||
assert_eq!(
|
||||
flagged,
|
||||
vec![
|
||||
("a".to_string(), "a-agent".to_string()),
|
||||
("b".to_string(), "b-agent".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provision_frontier_peers_two_flagged_share_one_registry() {
|
||||
let flagged = vec![
|
||||
("a".to_string(), "worker".to_string()),
|
||||
("b".to_string(), "worker".to_string()),
|
||||
];
|
||||
|
||||
let (registry, assignments) = provision_frontier_peers(flagged);
|
||||
|
||||
let registry = registry.expect("two flagged nodes should get a registry");
|
||||
assert_eq!(assignments.len(), 2);
|
||||
let roster = registry.roster();
|
||||
assert_eq!(roster.len(), 2);
|
||||
|
||||
let (id_a, inbox_a) = &assignments["a"];
|
||||
let (id_b, _) = &assignments["b"];
|
||||
assert_ne!(id_a, id_b);
|
||||
let resolved = registry.get(id_a).expect("assigned id should resolve");
|
||||
assert!(Arc::ptr_eq(&resolved, inbox_a));
|
||||
assert!(roster.iter().any(|(id, label)| id == id_a && label == "a"));
|
||||
assert!(roster.iter().any(|(id, label)| id == id_b && label == "b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provision_frontier_peers_single_flagged_gets_no_registry() {
|
||||
let (registry, assignments) =
|
||||
provision_frontier_peers(vec![("a".to_string(), "worker".to_string())]);
|
||||
|
||||
assert!(registry.is_none());
|
||||
assert!(assignments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provision_frontier_peers_empty_frontier_gets_no_registry() {
|
||||
let (registry, assignments) = provision_frontier_peers(Vec::new());
|
||||
|
||||
assert!(registry.is_none());
|
||||
assert!(assignments.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::state::StateManager;
|
||||
use super::types::{MapNode, NodeType};
|
||||
use crate::config::{RenderMode, RequestContext};
|
||||
use crate::graph::type_name;
|
||||
use crate::supervisor::mailbox::{Inbox, PeerAssignment, PeerRegistry, graph_agent_id};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use futures_util::future::join_all;
|
||||
use serde_json::Value;
|
||||
@@ -55,6 +56,8 @@ impl MapNodeExecutor {
|
||||
let semaphore = Arc::new(Semaphore::new(max_conc));
|
||||
let mut sub_tasks = Vec::with_capacity(items.len());
|
||||
|
||||
let peers = provision_map_peers(&branch_node.node_type, &node.branch, items.len());
|
||||
|
||||
for (idx, item) in items.iter().enumerate() {
|
||||
let item = item.clone();
|
||||
let as_name = node.as_name.clone();
|
||||
@@ -62,6 +65,10 @@ impl MapNodeExecutor {
|
||||
let mut sub_state = state.fork_for_branch_state();
|
||||
let mut sub_ctx = ctx.fork_for_branch();
|
||||
sub_ctx.render_mode = RenderMode::Silent;
|
||||
if let Some((registry, assignments)) = &peers {
|
||||
sub_ctx.peer_registry = Some(Arc::clone(registry));
|
||||
sub_ctx.peer_assignment = Some(assignments[idx].clone());
|
||||
}
|
||||
let script_clone = step_ctx.script_executor.clone();
|
||||
let sub_branch_id = node.branch.clone();
|
||||
let sem = semaphore.clone();
|
||||
@@ -152,3 +159,83 @@ impl MapNodeExecutor {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-provision teammate identities before any branch starts, so a fast
|
||||
/// sibling can message one still waiting on the semaphore (the message
|
||||
/// queues in the pre-created inbox). A single-item fan-out has no peers, so
|
||||
/// it gets no registry.
|
||||
fn provision_map_peers(
|
||||
branch: &NodeType,
|
||||
branch_id: &str,
|
||||
item_count: usize,
|
||||
) -> Option<(Arc<PeerRegistry>, Vec<PeerAssignment>)> {
|
||||
match branch {
|
||||
NodeType::Agent(n) if n.teammates && item_count >= 2 => {
|
||||
let registry = Arc::new(PeerRegistry::new());
|
||||
let assignments = (0..item_count)
|
||||
.map(|i| {
|
||||
let id = graph_agent_id(&n.agent);
|
||||
let inbox = Arc::new(Inbox::new());
|
||||
registry.insert(id.clone(), format!("{branch_id}[{i}]"), inbox.clone());
|
||||
(id, inbox)
|
||||
})
|
||||
.collect();
|
||||
Some((registry, assignments))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::types::{AgentNode, EndNode};
|
||||
use super::*;
|
||||
|
||||
fn agent_branch(teammates: bool) -> NodeType {
|
||||
NodeType::Agent(AgentNode {
|
||||
agent: "worker".into(),
|
||||
prompt: "p".into(),
|
||||
state_updates: None,
|
||||
output_schema: None,
|
||||
timeout: None,
|
||||
teammates,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provision_map_peers_registers_every_branch() {
|
||||
let (registry, assignments) =
|
||||
provision_map_peers(&agent_branch(true), "shards", 3).expect("should provision");
|
||||
|
||||
assert_eq!(assignments.len(), 3);
|
||||
let roster = registry.roster();
|
||||
assert_eq!(roster.len(), 3);
|
||||
for (i, (id, inbox)) in assignments.iter().enumerate() {
|
||||
assert_eq!(roster[i].0, *id);
|
||||
assert_eq!(roster[i].1, format!("shards[{i}]"));
|
||||
let resolved = registry.get(id).expect("assigned id should resolve");
|
||||
assert!(Arc::ptr_eq(&resolved, inbox));
|
||||
}
|
||||
assert_ne!(assignments[0].0, assignments[1].0);
|
||||
assert_ne!(assignments[1].0, assignments[2].0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provision_map_peers_single_item_gets_no_registry() {
|
||||
assert!(provision_map_peers(&agent_branch(true), "shards", 1).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provision_map_peers_unflagged_gets_no_registry() {
|
||||
assert!(provision_map_peers(&agent_branch(false), "shards", 3).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provision_map_peers_non_agent_branch_gets_no_registry() {
|
||||
let branch = NodeType::End(EndNode {
|
||||
output: String::new(),
|
||||
state_updates: None,
|
||||
});
|
||||
assert!(provision_map_peers(&branch, "shards", 3).is_none());
|
||||
}
|
||||
}
|
||||
@@ -239,6 +239,9 @@ pub struct AgentNode {
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timeout: Option<u64>,
|
||||
|
||||
#[serde(default)]
|
||||
pub teammates: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -545,6 +548,41 @@ nodes:
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_node_teammates_parses_and_defaults_false() {
|
||||
let yaml = r#"
|
||||
name: g
|
||||
start: a
|
||||
nodes:
|
||||
a:
|
||||
id: a
|
||||
type: agent
|
||||
agent: helper
|
||||
prompt: hi
|
||||
teammates: true
|
||||
next: b
|
||||
b:
|
||||
id: b
|
||||
type: agent
|
||||
agent: helper
|
||||
prompt: hi
|
||||
next: e
|
||||
e:
|
||||
id: e
|
||||
type: end
|
||||
output: done
|
||||
"#;
|
||||
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
|
||||
match &graph.get_node("a").unwrap().node_type {
|
||||
NodeType::Agent(n) => assert!(n.teammates),
|
||||
_ => panic!("expected Agent variant"),
|
||||
}
|
||||
match &graph.get_node("b").unwrap().node_type {
|
||||
NodeType::Agent(n) => assert!(!n.teammates),
|
||||
_ => panic!("expected Agent variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_mcp_tools_at_graph_and_node_level() {
|
||||
let yaml = r#"
|
||||
|
||||
@@ -176,6 +176,7 @@ impl GraphValidator {
|
||||
self.validate_llm_nodes(graph, &mut result);
|
||||
self.validate_llm_skills(graph, &mut result);
|
||||
self.validate_max_concurrency(graph, &mut result);
|
||||
self.validate_orchestration_limits(graph, &mut result);
|
||||
self.validate_map_branches(graph, &mut result);
|
||||
self.validate_parallel_user_interaction(graph, &mut result);
|
||||
self.validate_parallel_writes(graph, &mut result);
|
||||
@@ -529,6 +530,23 @@ impl GraphValidator {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_orchestration_limits(&self, graph: &Graph, result: &mut ValidationResult) {
|
||||
if graph.max_agent_depth == Some(0) {
|
||||
result.warning(ValidationError::new(
|
||||
"`max_agent_depth: 0` forbids spawning any sub-agent, so every \
|
||||
`agent` node and dynamic spawn will fail; remove the key or \
|
||||
set it >= 1",
|
||||
));
|
||||
}
|
||||
if graph.max_concurrent_agents == Some(0) {
|
||||
result.warning(ValidationError::new(
|
||||
"`max_concurrent_agents: 0` leaves no slot for any spawn to \
|
||||
acquire, so agent spawning deadlocks; remove the key or set \
|
||||
it >= 1",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_map_branches(&self, graph: &Graph, result: &mut ValidationResult) {
|
||||
for (map_id, node) in &graph.nodes {
|
||||
let NodeType::Map(m) = &node.node_type else {
|
||||
@@ -1027,6 +1045,9 @@ mod tests {
|
||||
top_p: None,
|
||||
reasoning_effort: None,
|
||||
max_concurrent_jobs: None,
|
||||
can_spawn_agents: None,
|
||||
max_concurrent_agents: None,
|
||||
max_agent_depth: None,
|
||||
global_tools: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
mcp_tools: None,
|
||||
@@ -1637,6 +1658,7 @@ mod tests {
|
||||
state_updates: None,
|
||||
output_schema: None,
|
||||
timeout: None,
|
||||
teammates: false,
|
||||
}),
|
||||
next: next.map(NextTargets::from),
|
||||
}
|
||||
@@ -2596,6 +2618,57 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_max_agent_depth_warns() {
|
||||
let mut graph = graph_with(vec![("e", end_node("e"))], "e");
|
||||
graph.max_agent_depth = Some(0);
|
||||
|
||||
let result = validator().validate(&graph);
|
||||
|
||||
assert!(result.is_valid());
|
||||
assert!(
|
||||
result
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|w| w.message.contains("`max_agent_depth: 0`")),
|
||||
"expected max_agent_depth=0 warning: {:?}",
|
||||
result.warnings
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_max_concurrent_agents_warns() {
|
||||
let mut graph = graph_with(vec![("e", end_node("e"))], "e");
|
||||
graph.max_concurrent_agents = Some(0);
|
||||
|
||||
let result = validator().validate(&graph);
|
||||
|
||||
assert!(result.is_valid());
|
||||
assert!(
|
||||
result
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|w| w.message.contains("`max_concurrent_agents: 0`")),
|
||||
"expected max_concurrent_agents=0 warning: {:?}",
|
||||
result.warnings
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_orchestration_limits_do_not_warn() {
|
||||
let graph = graph_with(vec![("e", end_node("e"))], "e");
|
||||
|
||||
let result = validator().validate(&graph);
|
||||
|
||||
assert!(
|
||||
!result.warnings.iter().any(|w| {
|
||||
w.message.contains("max_agent_depth") || w.message.contains("max_concurrent_agents")
|
||||
}),
|
||||
"unset limits should not warn: {:?}",
|
||||
result.warnings
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_max_concurrency_zero_errors() {
|
||||
let mut map = map_node_basic("m", "br", Some("end"));
|
||||
|
||||
Reference in new issue
Block a user