use serde::{Deserialize, Serialize};
pub type AgentId = String;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TeamMessage {
pub sender: String,
pub content: String,
pub metadata: serde_json::Value,
}
impl TeamMessage {
#[must_use]
pub fn new(sender: impl Into<String>, content: impl Into<String>) -> Self {
Self {
sender: sender.into(),
content: content.into(),
metadata: serde_json::Value::Null,
}
}
#[must_use]
pub fn with_metadata(
sender: impl Into<String>,
content: impl Into<String>,
metadata: serde_json::Value,
) -> Self {
Self {
sender: sender.into(),
content: content.into(),
metadata,
}
}
}
#[derive(Debug, Clone)]
pub struct TeamState {
pub agents: Vec<AgentId>,
pub message_history: Vec<TeamMessage>,
pub iteration: usize,
pub current_index: usize,
}
impl TeamState {
#[must_use]
pub fn new(agents: Vec<AgentId>) -> Self {
Self {
agents,
message_history: Vec::new(),
iteration: 0,
current_index: 0,
}
}
pub fn record_message(&mut self, msg: TeamMessage) {
self.message_history.push(msg);
}
pub fn next_iteration(&mut self) {
self.iteration += 1;
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RoutingDecision {
SendTo(AgentId),
Broadcast,
Complete(String),
}
pub trait Router: Send + Sync + 'static {
fn route(&self, message: &TeamMessage, state: &TeamState) -> RoutingDecision;
}
pub struct SequentialRouter;
impl SequentialRouter {
#[must_use]
pub fn new() -> Self {
Self
}
}
impl Default for SequentialRouter {
fn default() -> Self {
Self::new()
}
}
impl Router for SequentialRouter {
fn route(&self, _message: &TeamMessage, state: &TeamState) -> RoutingDecision {
if state.current_index < state.agents.len() {
RoutingDecision::SendTo(state.agents[state.current_index].clone())
} else {
let output = state
.message_history
.last()
.map_or_else(|| String::new(), |m| m.content.clone());
RoutingDecision::Complete(output)
}
}
}
pub struct LeaderRouter {
leader_id: AgentId,
}
impl LeaderRouter {
#[must_use]
pub fn new(leader_id: impl Into<AgentId>) -> Self {
Self {
leader_id: leader_id.into(),
}
}
#[must_use]
pub fn leader_id(&self) -> &str {
&self.leader_id
}
}
impl Router for LeaderRouter {
fn route(&self, message: &TeamMessage, state: &TeamState) -> RoutingDecision {
if message.sender != self.leader_id {
return RoutingDecision::SendTo(self.leader_id.clone());
}
if let Some(target) = message.content.strip_prefix('@') {
let mut parts = target.splitn(2, ':');
if let (Some(agent_id), Some(_)) = (parts.next(), parts.next()) {
let agent_id = agent_id.trim();
if state.agents.iter().any(|a| a == agent_id) {
return RoutingDecision::SendTo(agent_id.to_string());
} else {
tracing::warn!("Leader delegated to unknown agent: {}", agent_id);
return RoutingDecision::Complete(format!(
"Error: Leader delegated to unknown agent '{}'",
agent_id
));
}
}
}
RoutingDecision::Complete(message.content.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn _assert_object_safe(_: &dyn Router) {}
#[test]
fn test_sequential_router_round_robin() {
let router = SequentialRouter::new();
let mut state = TeamState::new(vec!["agent_a".into(), "agent_b".into()]);
let msg = TeamMessage::new("user", "Hello");
let d1 = router.route(&msg, &state);
assert_eq!(d1, RoutingDecision::SendTo("agent_a".into()));
state.current_index = 1;
let d2 = router.route(&msg, &state);
assert_eq!(d2, RoutingDecision::SendTo("agent_b".into()));
state.current_index = 2;
state
.message_history
.push(TeamMessage::new("agent_b", "Final output"));
let d3 = router.route(&msg, &state);
assert_eq!(d3, RoutingDecision::Complete("Final output".into()));
}
#[test]
fn test_sequential_router_empty_complete() {
let router = SequentialRouter::new();
let state = TeamState::new(vec![]);
let msg = TeamMessage::new("user", "Hello");
let decision = router.route(&msg, &state);
assert_eq!(decision, RoutingDecision::Complete(String::new()));
}
#[test]
fn test_leader_router_delegates_to_leader() {
let router = LeaderRouter::new("leader");
let state = TeamState::new(vec!["leader".into(), "writer".into(), "coder".into()]);
let msg = TeamMessage::new("user", "Build me an app");
assert_eq!(
router.route(&msg, &state),
RoutingDecision::SendTo("leader".into())
);
}
#[test]
fn test_leader_router_delegation_syntax() {
let router = LeaderRouter::new("leader");
let state = TeamState::new(vec!["leader".into(), "writer".into(), "coder".into()]);
let msg = TeamMessage::new("leader", "@writer: Write the docs");
assert_eq!(
router.route(&msg, &state),
RoutingDecision::SendTo("writer".into())
);
}
#[test]
fn test_leader_router_final_output() {
let router = LeaderRouter::new("leader");
let state = TeamState::new(vec!["leader".into(), "writer".into()]);
let msg = TeamMessage::new("leader", "Here is the final summary.");
assert_eq!(
router.route(&msg, &state),
RoutingDecision::Complete("Here is the final summary.".into())
);
}
#[test]
fn test_team_message_with_metadata() {
let msg =
TeamMessage::with_metadata("user", "Hello", serde_json::json!({"priority": "high"}));
assert_eq!(msg.sender, "user");
assert_eq!(msg.content, "Hello");
assert_eq!(msg.metadata["priority"], "high");
}
#[test]
fn test_team_state_record_message() {
let mut state = TeamState::new(vec!["a".into()]);
assert!(state.message_history.is_empty());
state.record_message(TeamMessage::new("user", "test"));
assert_eq!(state.message_history.len(), 1);
}
#[test]
fn test_team_state_iteration() {
let mut state = TeamState::new(vec![]);
assert_eq!(state.iteration, 0);
state.next_iteration();
assert_eq!(state.iteration, 1);
}
#[test]
fn test_leader_delegates_to_nonexistent_agent() {
let router = LeaderRouter::new("leader");
let state = TeamState::new(vec!["leader".into(), "writer".into()]);
let msg = TeamMessage::new("leader", "@ghost: Do something");
assert_eq!(
router.route(&msg, &state),
RoutingDecision::Complete("Error: Leader delegated to unknown agent 'ghost'".into()),
"delegation to non-member should return an error message"
);
}
#[test]
fn test_leader_message_with_at_but_no_colon() {
let router = LeaderRouter::new("leader");
let state = TeamState::new(vec!["leader".into(), "writer".into()]);
let msg = TeamMessage::new("leader", "@writer please help");
assert_eq!(
router.route(&msg, &state),
RoutingDecision::Complete("@writer please help".into()),
"@ without colon should not match agent name"
);
}
}