use crate::agent::{Agent, ReActAgent};
use crate::provider::Provider;
use crate::tool::{SharedState, Tool, ToolError, ToolRegistry, ToolSchema};
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use tokio::sync::Mutex;
type SubAgentFactory =
Box<dyn Fn(serde_json::Value) -> Result<Box<dyn Agent + Send>, ToolError> + Send + Sync>;
type ProviderFactory = Box<dyn (Fn() -> Box<dyn Provider>) + Send + Sync>;
enum SubAgentSource {
Instance(Mutex<Box<dyn Agent + Send>>),
Factory(SubAgentFactory),
React {
make_provider: ProviderFactory,
tools: ToolRegistry,
},
}
pub struct SubAgentTool {
schema: ToolSchema,
source: SubAgentSource,
}
impl SubAgentTool {
pub fn from_agent(
name: impl Into<String>,
description: impl Into<String>,
parameters: serde_json::Value,
agent: Box<dyn Agent + Send>,
) -> Self {
Self {
schema: ToolSchema {
name: name.into(),
description: description.into(),
parameters,
},
source: SubAgentSource::Instance(Mutex::new(agent)),
}
}
pub fn from_factory(
name: impl Into<String>,
description: impl Into<String>,
parameters: serde_json::Value,
factory: impl Fn(serde_json::Value) -> Result<Box<dyn Agent + Send>, ToolError>
+ Send
+ Sync
+ 'static,
) -> Self {
Self {
schema: ToolSchema {
name: name.into(),
description: description.into(),
parameters,
},
source: SubAgentSource::Factory(Box::new(factory)),
}
}
pub fn from_react(
name: impl Into<String>,
description: impl Into<String>,
provider: impl Provider + Clone + 'static,
tools: ToolRegistry,
parameters: serde_json::Value,
) -> Self {
let make_provider: ProviderFactory =
Box::new(move || Box::new(provider.clone()) as Box<dyn Provider>);
Self {
schema: ToolSchema {
name: name.into(),
description: description.into(),
parameters,
},
source: SubAgentSource::React {
make_provider,
tools,
},
}
}
}
impl fmt::Debug for SubAgentTool {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SubAgentTool")
.field("name", &self.schema.name)
.finish_non_exhaustive()
}
}
#[async_trait::async_trait]
impl Tool for SubAgentTool {
fn schema(&self) -> ToolSchema {
self.schema.clone()
}
async fn call(
&self,
arguments: serde_json::Value,
_state: &SharedState,
) -> Result<String, ToolError> {
match &self.source {
SubAgentSource::Instance(agent) => {
let mut guard = agent.lock().await;
run_sub_agent(&mut **guard, &arguments).await
}
SubAgentSource::Factory(factory) => {
let mut agent = factory(arguments.clone())?;
run_sub_agent(&mut *agent, &arguments).await
}
SubAgentSource::React {
make_provider,
tools,
} => {
let system_prompt = arguments
.get("system_prompt")
.and_then(|v| v.as_str())
.unwrap_or("");
let task = arguments.get("task").and_then(|v| v.as_str()).unwrap_or("");
let mut agent = ReActAgent::new((make_provider)(), tools.clone(), system_prompt);
agent
.run(task)
.await
.map_err(|e| ToolError::Execution(e.to_string()))
}
}
}
}
async fn run_sub_agent(
agent: &mut (dyn Agent + Send),
arguments: &serde_json::Value,
) -> Result<String, ToolError> {
agent
.run(&arguments.to_string())
.await
.map_err(|e| ToolError::Execution(e.to_string()))
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum PoolError {
#[error("sub agent '{0}' already exists")]
Duplicate(String),
#[error("no such sub agent: '{0}'")]
NotFound(String),
#[error("sub agent factory failed: {0}")]
Factory(String),
#[error("sub agent run failed: {0}")]
Run(String),
}
impl From<PoolError> for ToolError {
fn from(err: PoolError) -> Self {
ToolError::Execution(err.to_string())
}
}
type AgentSlot = Arc<Mutex<Box<dyn Agent + Send>>>;
#[derive(Clone, Default)]
pub struct SubAgentPool {
agents: Arc<Mutex<HashMap<String, AgentSlot>>>,
}
impl SubAgentPool {
pub fn new() -> Self {
Self::default()
}
pub async fn spawn(
&self,
name: &str,
factory: impl FnOnce() -> Result<Box<dyn Agent + Send>, ToolError>,
input: &str,
) -> Result<String, PoolError> {
let slot = {
let mut agents = self.agents.lock().await;
if agents.contains_key(name) {
return Err(PoolError::Duplicate(name.to_string()));
}
let slot = Arc::new(Mutex::new(
factory().map_err(|e| PoolError::Factory(e.to_string()))?,
));
agents.insert(name.to_string(), slot.clone());
slot
};
let mut guard = slot.lock().await;
guard
.run(input)
.await
.map_err(|e| PoolError::Run(e.to_string()))
}
pub async fn spawn_react(
&self,
name: &str,
provider: impl Provider + 'static,
tools: ToolRegistry,
system_prompt: &str,
task: &str,
) -> Result<String, PoolError> {
self.spawn(
name,
move || -> Result<Box<dyn Agent + Send>, ToolError> {
Ok(Box::new(ReActAgent::new(provider, tools, system_prompt)))
},
task,
)
.await
}
pub async fn send(&self, name: &str, input: &str) -> Result<String, PoolError> {
let slot = {
let agents = self.agents.lock().await;
agents
.get(name)
.cloned()
.ok_or_else(|| PoolError::NotFound(name.to_string()))?
};
let mut guard = slot.lock().await;
guard
.run(input)
.await
.map_err(|e| PoolError::Run(e.to_string()))
}
pub async fn contains(&self, name: &str) -> bool {
self.agents.lock().await.contains_key(name)
}
pub async fn names(&self) -> Vec<String> {
let mut names: Vec<String> = self.agents.lock().await.keys().cloned().collect();
names.sort();
names
}
}
impl fmt::Debug for SubAgentPool {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SubAgentPool").finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::{AgentError, ReActAgent};
use crate::message::{ContentBlock, Message, ToolCall};
use crate::provider::{FakeProvider, FakeReply, ProviderError};
use crate::tool::ToolRegistry;
use serde_json::json;
fn user_has_text(msg: &Message, text: &str) -> bool {
matches!(
msg,
Message::User(blocks)
if blocks.iter().any(|b| matches!(b, ContentBlock::Text(t) if t == text))
)
}
#[derive(Default)]
struct RecordingAgent {
seen: Arc<Mutex<Vec<String>>>,
}
#[async_trait::async_trait]
impl Agent for RecordingAgent {
async fn run(&mut self, input: &str) -> Result<String, AgentError> {
self.seen.lock().await.push(input.to_string());
Ok(format!("processed: {input}"))
}
}
struct FlakyAgent {
failed_once: bool,
}
#[async_trait::async_trait]
impl Agent for FlakyAgent {
async fn run(&mut self, _input: &str) -> Result<String, AgentError> {
if !self.failed_once {
self.failed_once = true;
return Err(AgentError::Provider(ProviderError::Api {
status: 0,
message: "boom".into(),
}));
}
Ok("recovered".into())
}
}
#[test]
fn schema_passthrough() {
let tool = SubAgentTool::from_agent(
"consult",
"Consult a sub-agent",
json!({ "type": "object", "properties": { "q": { "type": "string" } } }),
Box::new(RecordingAgent::default()),
);
let schema = tool.schema();
assert_eq!(schema.name, "consult");
assert_eq!(schema.description, "Consult a sub-agent");
assert_eq!(schema.parameters["properties"]["q"]["type"], "string");
}
#[tokio::test]
async fn persistent_agent_continues_session_across_calls() {
let seen = Arc::new(Mutex::new(Vec::new()));
let tool = SubAgentTool::from_agent(
"consult",
"Consult a sub-agent",
json!({}),
Box::new(RecordingAgent { seen: seen.clone() }),
);
tool.call(json!({ "q": 1 }), &SharedState::default())
.await
.unwrap();
tool.call(json!({ "q": 2 }), &SharedState::default())
.await
.unwrap();
let all = seen.lock().await;
assert_eq!(all.len(), 2);
assert!(all[0].contains("1"));
assert!(all[1].contains("2"));
}
#[tokio::test]
async fn dynamic_factory_fresh_agent_per_call() {
let spawns = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let spawns_factory = spawns.clone();
let tool = SubAgentTool::from_factory(
"delegate",
"One-shot delegation",
json!({}),
move |_args| {
spawns_factory.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(Box::new(RecordingAgent::default()))
},
);
tool.call(json!({ "q": 1 }), &SharedState::default())
.await
.unwrap();
tool.call(json!({ "q": 2 }), &SharedState::default())
.await
.unwrap();
assert_eq!(spawns.load(std::sync::atomic::Ordering::SeqCst), 2);
}
#[tokio::test]
async fn factory_failure_maps_to_invalid_arguments() {
let tool = SubAgentTool::from_factory("delegate", "One-shot delegation", json!({}), |_| {
Err(ToolError::InvalidArguments("bad kind".into()))
});
let err = tool
.call(json!({}), &SharedState::default())
.await
.unwrap_err();
assert_eq!(err, ToolError::InvalidArguments("bad kind".into()));
}
#[tokio::test]
async fn sub_agent_failure_maps_to_execution() {
let tool = SubAgentTool::from_agent(
"flaky",
"A sub-agent that fails",
json!({}),
Box::new(FlakyAgent { failed_once: false }),
);
let err = tool
.call(json!({}), &SharedState::default())
.await
.unwrap_err();
match err {
ToolError::Execution(msg) => assert!(msg.contains("boom")),
other => panic!("expected Execution, got {other:?}"),
}
}
#[tokio::test]
async fn pool_spawn_then_send_continues_session() {
let pool = SubAgentPool::new();
let seen = Arc::new(Mutex::new(Vec::new()));
let spawn_factory = {
let seen = seen.clone();
move || -> Result<Box<dyn Agent + Send>, ToolError> {
Ok(Box::new(RecordingAgent { seen }))
}
};
let reply = pool.spawn("red", spawn_factory, "task one").await.unwrap();
assert_eq!(reply, "processed: task one");
let reply = pool.send("red", "task two").await.unwrap();
assert_eq!(reply, "processed: task two");
let all = seen.lock().await;
assert_eq!(all.len(), 2);
assert!(all[0].contains("task one"));
}
#[tokio::test]
async fn pool_rejects_duplicate_names() {
let pool = SubAgentPool::new();
pool.spawn(
"a",
|| -> Result<Box<dyn Agent + Send>, ToolError> {
Ok(Box::new(RecordingAgent::default()))
},
"x",
)
.await
.unwrap();
let err = pool
.spawn(
"a",
|| -> Result<Box<dyn Agent + Send>, ToolError> {
Ok(Box::new(RecordingAgent::default()))
},
"y",
)
.await
.unwrap_err();
assert_eq!(err, PoolError::Duplicate("a".into()));
}
#[tokio::test]
async fn pool_send_unknown_name_errors() {
let pool = SubAgentPool::new();
let err = pool.send("ghost", "hi").await.unwrap_err();
assert_eq!(err, PoolError::NotFound("ghost".into()));
}
#[tokio::test]
async fn pool_keeps_failed_agent_for_retry() {
let pool = SubAgentPool::new();
let err = pool
.spawn(
"flaky",
|| -> Result<Box<dyn Agent + Send>, ToolError> {
Ok(Box::new(FlakyAgent { failed_once: false }))
},
"first task",
)
.await
.unwrap_err();
assert!(err.to_string().contains("boom"));
let reply = pool.send("flaky", "try again").await.unwrap();
assert_eq!(reply, "recovered");
}
#[tokio::test]
async fn pool_names_sorted_and_contains() {
let pool = SubAgentPool::new();
pool.spawn(
"b",
|| -> Result<Box<dyn Agent + Send>, ToolError> {
Ok(Box::new(RecordingAgent::default()))
},
"x",
)
.await
.unwrap();
pool.spawn(
"a",
|| -> Result<Box<dyn Agent + Send>, ToolError> {
Ok(Box::new(RecordingAgent::default()))
},
"x",
)
.await
.unwrap();
assert_eq!(pool.names().await, vec!["a".to_string(), "b".to_string()]);
assert!(pool.contains("a").await);
assert!(!pool.contains("c").await);
}
#[tokio::test]
async fn concurrent_sends_to_same_name_serialize() {
let pool = SubAgentPool::new();
pool.spawn(
"solo",
|| -> Result<Box<dyn Agent + Send>, ToolError> {
Ok(Box::new(RecordingAgent::default()))
},
"x",
)
.await
.unwrap();
let (a, b) = tokio::join!(pool.send("solo", "one"), pool.send("solo", "two"));
assert_eq!(a.unwrap(), "processed: one");
assert_eq!(b.unwrap(), "processed: two");
}
#[tokio::test]
async fn nested_parent_calls_subagent() {
let sub = ReActAgent::new(
FakeProvider::new([FakeReply::Text("sub conclusion".into())]),
ToolRegistry::new(),
"sub-agent",
);
let sub_tool = SubAgentTool::from_agent(
"consult",
"Consult a sub-agent",
json!({ "type": "object", "properties": {} }),
Box::new(sub),
);
let mut registry = ToolRegistry::new();
registry.register(sub_tool);
let parent = ReActAgent::new(
FakeProvider::new([
FakeReply::ToolCalls {
content: String::new(),
calls: vec![ToolCall {
id: "t1".into(),
name: "consult".into(),
arguments: "{\"q\":\"x\"}".into(),
}],
},
FakeReply::Text("Overall conclusion".into()),
]),
registry,
"parent agent",
);
let mut parent = parent;
let answer = parent.run("go").await.unwrap();
assert_eq!(answer, "Overall conclusion");
}
#[tokio::test]
async fn from_react_system_prompt_and_task_reach_sub_agent() {
let fake = Arc::new(FakeProvider::new([FakeReply::Text("sub answer".into())]));
let tool = SubAgentTool::from_react(
"delegate",
"Delegate a sub-task",
fake.clone(),
ToolRegistry::new(),
json!({}),
);
let result = tool
.call(
json!({ "system_prompt": "You are a reviewer", "task": "Review this code" }),
&SharedState::default(),
)
.await
.unwrap();
assert_eq!(result, "sub answer");
let reqs = fake.requests();
assert_eq!(reqs.len(), 1);
assert!(
reqs[0]
.messages
.iter()
.any(|m| matches!(m, Message::System(s) if s.contains("You are a reviewer")))
);
assert!(
reqs[0]
.messages
.iter()
.any(|m| user_has_text(m, "Review this code"))
);
}
#[tokio::test]
async fn from_react_missing_fields_default_to_empty() {
let fake = Arc::new(FakeProvider::new([FakeReply::Text("sub answer".into())]));
let tool = SubAgentTool::from_react(
"delegate",
"Delegate a sub-task",
fake.clone(),
ToolRegistry::new(),
json!({}),
);
tool.call(json!({}), &SharedState::default()).await.unwrap();
let reqs = fake.requests();
assert!(
reqs[0]
.messages
.iter()
.all(|m| !matches!(m, Message::System(_)))
);
assert!(reqs[0].messages.iter().any(|m| user_has_text(m, "")));
}
#[tokio::test]
async fn spawn_react_then_send_continues_session() {
let pool = SubAgentPool::new();
let fake = Arc::new(FakeProvider::new([
FakeReply::Text("answer one".into()),
FakeReply::Text("answer two".into()),
]));
let reply = pool
.spawn_react(
"red",
fake.clone(),
ToolRegistry::new(),
"review expert",
"task one",
)
.await
.unwrap();
assert_eq!(reply, "answer one");
pool.send("red", "task two").await.unwrap();
let reqs = fake.requests();
assert_eq!(reqs.len(), 2);
assert!(
reqs[1]
.messages
.iter()
.any(|m| matches!(m, Message::System(s) if s.contains("review expert")))
);
assert!(
reqs[1]
.messages
.iter()
.any(|m| user_has_text(m, "task one"))
);
assert!(
reqs[1]
.messages
.iter()
.any(|m| user_has_text(m, "task two"))
);
}
}