use std::collections::HashMap;
use std::fmt;
use std::pin::Pin;
use std::sync::Arc;
use async_trait::async_trait;
use juncture_core::config::RunnableConfig;
use juncture_core::graph::CompiledGraph;
use juncture_core::state::messages::{Content, Message, Role};
use serde_json::json;
use tokio::sync::RwLock;
use crate::prebuilt::messages_state::MessagesState;
use crate::tools::{Tool, ToolError};
type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
type AgentInvokeFn = dyn Fn(MessagesState, &RunnableConfig) -> BoxFuture<'static, Result<MessagesState, SubagentError>>
+ Send
+ Sync;
#[derive(Debug, thiserror::Error)]
pub enum SubagentError {
#[error("agent not found: {0}")]
NotFound(String),
#[error("agent invocation failed: {0}")]
InvocationFailed(String),
#[error("no agents registered")]
Empty,
}
pub trait IntoAgentEntry: Send + Sync + 'static {
fn description(&self) -> String;
fn invoke_boxed<'a>(
&'a self,
state: MessagesState,
config: &'a RunnableConfig,
) -> BoxFuture<'a, Result<MessagesState, SubagentError>>;
}
pub struct AgentEntry {
pub description: String,
invoke_fn: Arc<AgentInvokeFn>,
}
impl AgentEntry {
#[must_use]
pub fn new<F>(description: String, invoke_fn: Arc<F>) -> Self
where
F: Fn(
MessagesState,
&RunnableConfig,
) -> BoxFuture<'static, Result<MessagesState, SubagentError>>
+ Send
+ Sync
+ 'static,
{
Self {
description,
invoke_fn,
}
}
#[must_use]
#[expect(
clippy::type_complexity,
reason = "Complex type is required for type-erased async function storage"
)]
pub fn from_graph<T: IntoAgentEntry + Clone + 'static>(graph: T) -> Self {
let description = graph.description();
let invoke_fn: Arc<
dyn Fn(
MessagesState,
&RunnableConfig,
)
-> Pin<Box<dyn Future<Output = Result<MessagesState, SubagentError>> + Send>>
+ Send
+ Sync,
> = Arc::new(move |state: MessagesState, config: &RunnableConfig| {
let graph = graph.clone();
let config = config.clone();
Box::pin(async move {
graph
.invoke_boxed(state, &config)
.await
.map_err(|e| SubagentError::InvocationFailed(e.to_string()))
})
});
Self {
description,
invoke_fn,
}
}
#[must_use]
pub fn description(&self) -> &str {
&self.description
}
pub async fn invoke(
&self,
state: MessagesState,
config: &RunnableConfig,
) -> Result<MessagesState, SubagentError> {
(self.invoke_fn)(state, config).await
}
}
impl fmt::Debug for AgentEntry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AgentEntry")
.field("description", &self.description)
.field("invoke_fn", &"<fn>")
.finish()
}
}
pub trait AgentRegistry: Send + Sync {
fn get(&self, name: &str) -> Option<AgentEntry>;
fn list(&self) -> Vec<String>;
fn register(&mut self, name: String, entry: AgentEntry);
}
#[derive(Debug, Default)]
pub struct InMemoryAgentRegistry {
agents: HashMap<String, AgentEntry>,
}
impl InMemoryAgentRegistry {
#[must_use]
pub fn new() -> Self {
Self {
agents: HashMap::new(),
}
}
}
impl AgentRegistry for InMemoryAgentRegistry {
fn get(&self, name: &str) -> Option<AgentEntry> {
self.agents.get(name).map(|entry| AgentEntry {
description: entry.description.clone(),
invoke_fn: Arc::clone(&entry.invoke_fn),
})
}
fn list(&self) -> Vec<String> {
let names: Vec<String> = self.agents.keys().cloned().collect();
if names.is_empty() {
}
names
}
fn register(&mut self, name: String, entry: AgentEntry) {
self.agents.insert(name, entry);
}
}
pub struct SubagentTool {
registry: Arc<RwLock<dyn AgentRegistry>>,
}
impl SubagentTool {
#[must_use]
pub fn new<R: AgentRegistry + 'static>(registry: R) -> Self {
Self {
registry: Arc::new(RwLock::new(registry)),
}
}
}
impl fmt::Debug for SubagentTool {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SubagentTool")
.field("registry", &"<registry>")
.finish()
}
}
#[async_trait]
impl Tool for SubagentTool {
fn name(&self) -> &'static str {
"task"
}
fn description(&self) -> &'static str {
"Delegate a task to a specialized sub-agent. Available agents are listed in the registry."
}
fn schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"subagent_type": {
"type": "string",
"description": "The type/name of the sub-agent to invoke"
},
"task": {
"type": "string",
"description": "The task description to delegate to the sub-agent"
}
},
"required": ["subagent_type", "task"]
})
}
async fn invoke(&self, input: serde_json::Value) -> Result<String, ToolError> {
let subagent_type = input["subagent_type"]
.as_str()
.ok_or_else(|| ToolError::invalid_input("Missing 'subagent_type' field".to_string()))?;
let task = input["task"]
.as_str()
.ok_or_else(|| ToolError::invalid_input("Missing 'task' field".to_string()))?;
let entry = {
let registry = self.registry.read().await;
registry.get(subagent_type).ok_or_else(|| {
ToolError::execution_failed(format!("Sub-agent not found: {subagent_type}"))
})?
};
let initial_state = MessagesState {
messages: vec![Message::human(task)],
};
let config = RunnableConfig::default();
let result_state = entry
.invoke(initial_state, &config)
.await
.map_err(|e| ToolError::execution_failed(e.to_string()))?;
#[expect(
clippy::map_unwrap_or,
reason = "unwrap_or_else is needed because the default value constructs a new String"
)]
let result_text = result_state
.messages
.iter()
.rev()
.find(|m| matches!(m.role, Role::Ai))
.map(|m| match &m.content {
Content::Text(t) => t.clone(),
Content::MultiPart(parts) => {
parts
.iter()
.filter_map(|p| match p {
juncture_core::state::messages::ContentPart::Text { text } => {
Some(text.as_str())
}
_ => None,
})
.collect::<Vec<_>>()
.join(" ")
}
})
.unwrap_or_else(|| "Sub-agent completed with no output".to_string());
Ok(result_text)
}
}
impl IntoAgentEntry for CompiledGraph<MessagesState> {
fn description(&self) -> String {
"Compiled agent graph".to_string()
}
fn invoke_boxed<'a>(
&'a self,
state: MessagesState,
config: &'a RunnableConfig,
) -> BoxFuture<'a, Result<MessagesState, SubagentError>> {
Box::pin(async move {
let output = self
.invoke_async(state, config)
.await
.map_err(|e| SubagentError::InvocationFailed(e.to_string()))?;
Ok(output.value)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mock_agent_entry(response_text: &str) -> AgentEntry {
let response = response_text.to_string();
let invoke_fn = Arc::new(
move |_state: MessagesState,
_config: &RunnableConfig|
-> BoxFuture<'static, Result<MessagesState, SubagentError>> {
let response = response.clone();
Box::pin(async move {
let mut state = MessagesState::default();
state.messages.push(Message::ai(&response));
Ok(state)
})
},
);
AgentEntry::new("Mock agent for testing".to_string(), invoke_fn)
}
#[test]
fn test_in_memory_registry_new() {
let registry = InMemoryAgentRegistry::new();
assert!(registry.agents.is_empty());
}
#[test]
fn test_in_memory_registry_register_and_get() {
let mut registry = InMemoryAgentRegistry::new();
let entry = mock_agent_entry("Test response");
registry.register("test_agent".to_string(), entry);
let retrieved = registry.get("test_agent");
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().description, "Mock agent for testing");
}
#[test]
fn test_in_memory_registry_list() {
let mut registry = InMemoryAgentRegistry::new();
let entry1 = mock_agent_entry("Response 1");
let entry2 = mock_agent_entry("Response 2");
registry.register("agent1".to_string(), entry1);
registry.register("agent2".to_string(), entry2);
let names = registry.list();
assert_eq!(names.len(), 2);
assert!(names.contains(&"agent1".to_string()));
assert!(names.contains(&"agent2".to_string()));
}
#[test]
fn test_in_memory_registry_not_found() {
let registry = InMemoryAgentRegistry::new();
let result = registry.get("nonexistent");
assert!(result.is_none());
}
#[test]
fn test_agent_entry_description() {
let entry = mock_agent_entry("response");
assert_eq!(entry.description(), "Mock agent for testing");
}
#[tokio::test]
async fn test_agent_entry_invoke() {
let entry = mock_agent_entry("Hello from agent");
let state = MessagesState::default();
let config = RunnableConfig::default();
let result = entry.invoke(state, &config).await.unwrap();
assert_eq!(result.messages.len(), 1);
assert!(matches!(result.messages[0].role, Role::Ai));
}
#[test]
fn test_subagent_tool_definition() {
let registry = InMemoryAgentRegistry::new();
let tool = SubagentTool::new(registry);
let def = tool.definition();
assert_eq!(def.name, "task");
assert!(def.description.contains("Delegate"));
assert_eq!(def.parameters["type"], "object");
assert!(def.parameters["properties"]["subagent_type"]["type"] == "string");
assert!(def.parameters["properties"]["task"]["type"] == "string");
}
#[tokio::test]
async fn test_subagent_tool_invoke_success() {
let mut registry = InMemoryAgentRegistry::new();
registry.register(
"test_agent".to_string(),
mock_agent_entry("Agent completed task"),
);
let tool = SubagentTool::new(registry);
let input = json!({
"subagent_type": "test_agent",
"task": "Do something"
});
let result = tool.invoke(input).await.unwrap();
assert_eq!(result, "Agent completed task");
}
#[tokio::test]
async fn test_subagent_tool_agent_not_found() {
let registry = InMemoryAgentRegistry::new();
let tool = SubagentTool::new(registry);
let input = json!({
"subagent_type": "nonexistent",
"task": "Test"
});
let result = tool.invoke(input).await;
let _ = result.unwrap_err();
}
#[tokio::test]
async fn test_subagent_tool_missing_subagent_type() {
let registry = InMemoryAgentRegistry::new();
let tool = SubagentTool::new(registry);
let input = json!({
"task": "Test"
});
let result = tool.invoke(input).await;
let _ = result.unwrap_err();
}
#[tokio::test]
async fn test_subagent_tool_missing_task() {
let registry = InMemoryAgentRegistry::new();
let tool = SubagentTool::new(registry);
let input = json!({
"subagent_type": "test"
});
let result = tool.invoke(input).await;
let _ = result.unwrap_err();
}
#[test]
fn test_subagent_error_display() {
let err = SubagentError::NotFound("test_agent".to_string());
assert!(err.to_string().contains("agent not found"));
assert!(err.to_string().contains("test_agent"));
let err = SubagentError::InvocationFailed("execution error".to_string());
assert!(err.to_string().contains("invocation failed"));
let err = SubagentError::Empty;
assert!(err.to_string().contains("no agents registered"));
}
#[test]
fn test_agent_entry_from_graph_closure() {
let description = "Test agent".to_string();
let response = "Test response".to_string();
let invoke_fn = Arc::new(
move |_state: MessagesState,
_config: &RunnableConfig|
-> BoxFuture<'static, Result<MessagesState, SubagentError>> {
let response = response.clone();
Box::pin(async move {
let mut state = MessagesState::default();
state.messages.push(Message::ai(&response));
Ok(state)
})
},
);
let entry = AgentEntry::new(description, invoke_fn);
assert_eq!(entry.description(), "Test agent");
}
#[test]
fn test_agent_entry_debug() {
let entry = mock_agent_entry("response");
let debug_str = format!("{entry:?}");
assert!(debug_str.contains("AgentEntry"));
assert!(debug_str.contains("description"));
assert!(debug_str.contains("Mock agent for testing"));
}
#[test]
fn test_subagent_tool_debug() {
let registry = InMemoryAgentRegistry::new();
let tool = SubagentTool::new(registry);
let debug_str = format!("{tool:?}");
assert!(debug_str.contains("SubagentTool"));
}
#[tokio::test]
async fn test_subagent_tool_result_extraction_multi_part() {
let mut registry = InMemoryAgentRegistry::new();
let invoke_fn = Arc::new(
|_state: MessagesState,
_config: &RunnableConfig|
-> BoxFuture<'static, Result<MessagesState, SubagentError>> {
Box::pin(async move {
use juncture_core::state::messages::ContentPart;
let mut state = MessagesState::default();
state.messages.push(Message {
id: uuid::Uuid::new_v4().to_string(),
role: Role::Ai,
content: Content::MultiPart(vec![
ContentPart::Text {
text: "Part 1".to_string(),
},
ContentPart::Text {
text: "Part 2".to_string(),
},
]),
tool_calls: vec![],
tool_call_id: None,
name: None,
usage: None,
});
Ok(state)
})
},
);
let entry = AgentEntry::new("Multi-part agent".to_string(), invoke_fn);
registry.register("multi_agent".to_string(), entry);
let tool = SubagentTool::new(registry);
let input = json!({
"subagent_type": "multi_agent",
"task": "Test"
});
let result = tool.invoke(input).await.unwrap();
assert!(result.contains("Part 1"));
assert!(result.contains("Part 2"));
}
#[tokio::test]
async fn test_subagent_tool_result_extraction_no_ai_message() {
let mut registry = InMemoryAgentRegistry::new();
let invoke_fn = Arc::new(
|_state: MessagesState,
_config: &RunnableConfig|
-> BoxFuture<'static, Result<MessagesState, SubagentError>> {
Box::pin(async move { Ok(MessagesState::default()) })
},
);
let entry = AgentEntry::new("Empty agent".to_string(), invoke_fn);
registry.register("empty_agent".to_string(), entry);
let tool = SubagentTool::new(registry);
let input = json!({
"subagent_type": "empty_agent",
"task": "Test"
});
let result = tool.invoke(input).await.unwrap();
assert_eq!(result, "Sub-agent completed with no output");
}
}