use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{json, Value};
use tracing::{info, warn};
use crate::agent::{AgentLoop, ContextBuilder};
use crate::bus::{InboundMessage, MessageBus};
use crate::config::Config;
use crate::error::{Result, ZeptoError};
use crate::providers::{ChatOptions, LLMProvider, LLMResponse, ToolDefinition};
use crate::runtime::NativeRuntime;
use crate::session::{Message, SessionManager};
use crate::tools::filesystem::{EditFileTool, ListDirTool, ReadFileTool, WriteFileTool};
use crate::tools::memory::{MemoryGetTool, MemorySearchTool};
use crate::tools::message::MessageTool;
use crate::tools::shell::ShellTool;
use crate::tools::web::WebFetchTool;
use crate::tools::EchoTool;
use super::{Tool, ToolContext};
pub struct DelegateTool {
config: Config,
provider: Arc<dyn LLMProvider>,
bus: Arc<MessageBus>,
}
impl DelegateTool {
pub fn new(config: Config, provider: Arc<dyn LLMProvider>, bus: Arc<MessageBus>) -> Self {
Self {
config,
provider,
bus,
}
}
fn create_sub_agent_tools(&self, whitelist: Option<&[String]>) -> Vec<Box<dyn Tool>> {
let mut all_tools: Vec<Box<dyn Tool>> = vec![
Box::new(EchoTool),
Box::new(ReadFileTool),
Box::new(WriteFileTool),
Box::new(ListDirTool),
Box::new(EditFileTool),
Box::new(ShellTool::with_runtime(Arc::new(NativeRuntime::new()))),
Box::new(WebFetchTool::new()),
Box::new(MessageTool::new(self.bus.clone())),
];
match &self.config.memory.backend {
crate::config::MemoryBackend::Disabled => {}
_ => {
all_tools.push(Box::new(MemorySearchTool::new(self.config.memory.clone())));
all_tools.push(Box::new(MemoryGetTool::new(self.config.memory.clone())));
}
}
match whitelist {
Some(names) => all_tools
.into_iter()
.filter(|t| names.iter().any(|n| n == t.name()))
.collect(),
None => all_tools,
}
}
}
#[async_trait]
impl Tool for DelegateTool {
fn name(&self) -> &str {
"delegate"
}
fn description(&self) -> &str {
"Delegate a task to a specialist sub-agent with a specific role. \
The sub-agent runs to completion and returns its result. \
Use this to decompose complex tasks into specialist subtasks."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"role": {
"type": "string",
"description": "The specialist role (e.g., 'researcher', 'writer', 'analyst')"
},
"task": {
"type": "string",
"description": "The task for the sub-agent to complete"
},
"tools": {
"type": "array",
"items": { "type": "string" },
"description": "Optional tool whitelist. If omitted, uses role preset or all available tools."
}
},
"required": ["role", "task"]
})
}
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
if ctx.channel.as_deref() == Some("delegate") {
return Err(ZeptoError::Tool(
"Cannot delegate from within a delegated task (recursion limit)".to_string(),
));
}
if !self.config.swarm.enabled {
return Err(ZeptoError::Tool(
"Delegation is disabled in configuration".to_string(),
));
}
let role = args
.get("role")
.and_then(|v| v.as_str())
.ok_or_else(|| ZeptoError::Tool("Missing required 'role' argument".into()))?;
let task = args
.get("task")
.and_then(|v| v.as_str())
.ok_or_else(|| ZeptoError::Tool("Missing required 'task' argument".into()))?;
let tool_override: Option<Vec<String>> =
args.get("tools").and_then(|v| v.as_array()).map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
});
let role_lower = role.to_lowercase();
let role_config = self.config.swarm.roles.get(&role_lower);
let system_prompt = match role_config {
Some(rc) if !rc.system_prompt.is_empty() => rc.system_prompt.clone(),
_ => format!(
"You are a specialist with the role: {}. \
Complete the task given to you thoroughly and return your findings. \
You can send interim updates to the user via the message tool.",
role
),
};
let allowed_tool_names: Option<Vec<String>> = tool_override.or_else(|| {
role_config
.filter(|rc| !rc.tools.is_empty())
.map(|rc| rc.tools.clone())
});
info!(role = %role, task_len = task.len(), "Delegating task to sub-agent");
let session_manager = SessionManager::new_memory();
let sub_bus = Arc::new(MessageBus::new());
let context_builder = ContextBuilder::new().with_system_prompt(&system_prompt);
let sub_agent = AgentLoop::with_context_builder(
self.config.clone(),
session_manager,
sub_bus,
context_builder,
);
sub_agent
.set_provider(Box::new(ProviderRef(Arc::clone(&self.provider))))
.await;
let tools = self.create_sub_agent_tools(allowed_tool_names.as_deref());
for tool in tools {
sub_agent.register_tool(tool).await;
}
let delegate_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
let inbound = InboundMessage::new(
"delegate",
&format!("delegate:{}", delegate_id),
&format!("delegate:{}", delegate_id),
task,
);
match sub_agent.process_message(&inbound).await {
Ok(result) => {
info!(role = %role, result_len = result.len(), "Sub-agent completed");
Ok(format!("[{}]: {}", role, result))
}
Err(e) => {
warn!(role = %role, error = %e, "Sub-agent failed");
Err(ZeptoError::Tool(format!(
"Sub-agent '{}' failed: {}",
role, e
)))
}
}
}
}
struct ProviderRef(Arc<dyn LLMProvider>);
#[async_trait]
impl LLMProvider for ProviderRef {
fn name(&self) -> &str {
self.0.name()
}
fn default_model(&self) -> &str {
self.0.default_model()
}
async fn chat(
&self,
messages: Vec<Message>,
tools: Vec<ToolDefinition>,
model: Option<&str>,
options: ChatOptions,
) -> Result<LLMResponse> {
self.0.chat(messages, tools, model, options).await
}
async fn chat_stream(
&self,
messages: Vec<Message>,
tools: Vec<ToolDefinition>,
model: Option<&str>,
options: ChatOptions,
) -> crate::error::Result<tokio::sync::mpsc::Receiver<crate::providers::StreamEvent>> {
self.0.chat_stream(messages, tools, model, options).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn test_delegate_tool(swarm_enabled: bool) -> DelegateTool {
let mut config = Config::default();
config.swarm.enabled = swarm_enabled;
let bus = Arc::new(MessageBus::new());
let provider: Arc<dyn LLMProvider> =
Arc::new(crate::providers::claude::ClaudeProvider::new("fake-key"));
DelegateTool::new(config, provider, bus)
}
#[tokio::test]
async fn test_delegate_blocked_from_subagent() {
let tool = test_delegate_tool(true);
let ctx = ToolContext::new().with_channel("delegate", "sub-123");
let result = tool
.execute(json!({"role": "test", "task": "hello"}), &ctx)
.await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("recursion"),
"Expected recursion error, got: {}",
err_msg
);
}
#[tokio::test]
async fn test_delegate_requires_role() {
let tool = test_delegate_tool(true);
let ctx = ToolContext::new().with_channel("telegram", "chat-1");
let result = tool.execute(json!({"task": "hello"}), &ctx).await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("role"),
"Expected role error, got: {}",
err_msg
);
}
#[tokio::test]
async fn test_delegate_requires_task() {
let tool = test_delegate_tool(true);
let ctx = ToolContext::new().with_channel("telegram", "chat-1");
let result = tool.execute(json!({"role": "test"}), &ctx).await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("task"),
"Expected task error, got: {}",
err_msg
);
}
#[tokio::test]
async fn test_delegate_disabled_in_config() {
let tool = test_delegate_tool(false);
let ctx = ToolContext::new().with_channel("telegram", "chat-1");
let result = tool
.execute(json!({"role": "test", "task": "hello"}), &ctx)
.await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("disabled"),
"Expected disabled error, got: {}",
err_msg
);
}
#[test]
fn test_delegate_tool_name() {
let tool = test_delegate_tool(true);
assert_eq!(tool.name(), "delegate");
}
#[test]
fn test_delegate_tool_parameters() {
let tool = test_delegate_tool(true);
let params = tool.parameters();
assert_eq!(params["type"], "object");
assert!(params["properties"]["role"].is_object());
assert!(params["properties"]["task"].is_object());
assert!(params["properties"]["tools"].is_object());
}
#[test]
fn test_create_sub_agent_tools_no_whitelist() {
let tool = test_delegate_tool(true);
let tools = tool.create_sub_agent_tools(None);
assert!(tools.len() >= 8);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(!names.contains(&"delegate"));
assert!(!names.contains(&"spawn"));
}
#[test]
fn test_create_sub_agent_tools_with_whitelist() {
let tool = test_delegate_tool(true);
let whitelist = vec!["echo".to_string(), "read_file".to_string()];
let tools = tool.create_sub_agent_tools(Some(&whitelist));
assert_eq!(tools.len(), 2);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(names.contains(&"echo"));
assert!(names.contains(&"read_file"));
}
}