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
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user