use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::json;
use crate::config::{AgentConfig, AppConfig, client_for_config};
use crate::core::agent::run_agent_with_history;
use crate::core::llm::LlmClient;
use crate::core::models::{FunctionDefinition, Message, Tool};
use crate::error::{Error, Result};
use crate::rag::PromptBuilder;
use crate::subagents::AgentProfile;
use super::scoped_executor::ScopedExecutor;
use super::{SandboxedExecutor, ToolExecutor, ToolHandler};
pub const DELEGATE_TOOL_NAME: &str = "delegate_task";
pub struct DelegateTool {
base_executor: Arc<dyn ToolExecutor>,
work_dir: PathBuf,
allow_shell: bool,
profiles: Vec<AgentProfile>,
llm: Arc<dyn LlmClient>,
app_config: AppConfig,
base_config: AgentConfig,
}
impl DelegateTool {
pub fn new(
base_executor: Arc<dyn ToolExecutor>,
work_dir: PathBuf,
allow_shell: bool,
profiles: Vec<AgentProfile>,
llm: Arc<dyn LlmClient>,
app_config: AppConfig,
base_config: AgentConfig,
) -> Self {
Self {
base_executor,
work_dir,
allow_shell,
profiles,
llm,
app_config,
base_config,
}
}
fn find_profile(&self, name: &str) -> Option<&AgentProfile> {
self.profiles.iter().find(|p| p.name == name)
}
fn resolve_runtime(&self, profile: &AgentProfile) -> Result<(AgentConfig, Arc<dyn LlmClient>)> {
let config = match (&profile.provider, &profile.model) {
(Some(provider), Some(model)) => {
self.app_config.resolve_with_provider(provider, model)?
}
(None, Some(model)) => self.app_config.resolve(Some(model))?,
_ => self.base_config.clone(),
};
let config = match profile.max_iterations {
Some(max_iterations) => config.with_max_iterations(max_iterations),
None => config,
};
let llm = client_for_config(&config, &self.base_config, &self.llm)?;
Ok((config, llm))
}
fn build_executor(&self, profile: &AgentProfile) -> Arc<dyn ToolExecutor> {
let scoped: Arc<dyn ToolExecutor> = match &profile.tools {
Some(allowed) => Arc::new(ScopedExecutor::new(
self.base_executor.clone(),
allowed.clone(),
)),
None => self.base_executor.clone(),
};
Arc::new(SandboxedExecutor::new(
scoped,
self.work_dir.clone(),
self.allow_shell,
))
}
}
#[async_trait]
impl ToolHandler for DelegateTool {
fn definition(&self) -> Tool {
let names: Vec<String> = self.profiles.iter().map(|p| p.name.clone()).collect();
let mut listing = String::new();
for profile in &self.profiles {
let description = if profile.description.is_empty() {
"(no description provided)"
} else {
profile.description.as_str()
};
listing.push_str(&format!("\n- `{}`: {description}", profile.name));
}
let description = format!(
"Delegate a self-contained task to a specialized subagent that runs independently \
with its own context, persona, and (optionally) its own model or restricted tool \
set. The subagent CANNOT see this conversation, so `task` must be a complete, \
standalone brief containing every detail it needs. Only its final answer is \
returned to you — its intermediate steps are not visible.\n\
\n\
Available subagents:{listing}"
);
Tool {
tool_type: "function".to_string(),
function: FunctionDefinition {
name: DELEGATE_TOOL_NAME.to_string(),
description,
parameters: json!({
"type": "object",
"properties": {
"agent": {
"type": "string",
"enum": names,
"description": "Name of the subagent to delegate to."
},
"task": {
"type": "string",
"description": "A complete, self-contained description of the task. \
Include all context the subagent needs — it cannot \
see your conversation history."
}
},
"required": ["agent", "task"]
}),
},
}
}
async fn execute(&self, args: &str) -> Result<String> {
let v: serde_json::Value = serde_json::from_str(args)
.map_err(|e| Error::ParseError(format!("invalid arguments: {e}")))?;
let agent_name = v["agent"]
.as_str()
.ok_or_else(|| Error::ParseError("missing 'agent' argument".to_string()))?;
let task = v["task"]
.as_str()
.ok_or_else(|| Error::ParseError("missing 'task' argument".to_string()))?;
let Some(profile) = self.find_profile(agent_name) else {
let available = self
.profiles
.iter()
.map(|p| p.name.as_str())
.collect::<Vec<_>>()
.join(", ");
return Ok(format!(
"Unknown subagent '{agent_name}'. Available subagents: {available}"
));
};
let (config, llm) = self.resolve_runtime(profile)?;
let executor = self.build_executor(profile);
let mut prompt_builder = PromptBuilder::new();
prompt_builder.set_system(profile.system_prompt.clone());
let mut messages = vec![Message::user(task.to_string())];
let result =
run_agent_with_history(llm, executor, &config, &mut messages, Some(&prompt_builder))
.await?;
if result.iterations_used >= config.max_iterations {
Ok(format!(
"{}\n\n[Note: subagent '{agent_name}' reached its iteration limit \
({}) before finishing — this answer may be incomplete.]",
result.final_response, config.max_iterations
))
} else {
Ok(result.final_response)
}
}
}
struct WithDelegate {
inner: Arc<dyn ToolExecutor>,
delegate: Arc<DelegateTool>,
}
impl WithDelegate {
fn new(inner: Arc<dyn ToolExecutor>, delegate: Arc<DelegateTool>) -> Self {
Self { inner, delegate }
}
}
#[async_trait]
impl ToolExecutor for WithDelegate {
fn list_tools(&self) -> Vec<Tool> {
let mut tools = self.inner.list_tools();
tools.push(self.delegate.definition());
tools
}
async fn execute(&self, name: &str, args_json: &str) -> Result<String> {
if name == DELEGATE_TOOL_NAME {
self.delegate.execute(args_json).await
} else {
self.inner.execute(name, args_json).await
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn with_delegation(
executor: Arc<dyn ToolExecutor>,
work_dir: PathBuf,
allow_shell: bool,
profiles: Vec<AgentProfile>,
llm: Arc<dyn LlmClient>,
app_config: AppConfig,
base_config: AgentConfig,
) -> Arc<dyn ToolExecutor> {
if profiles.is_empty() {
return executor;
}
let delegate = Arc::new(DelegateTool::new(
executor.clone(),
work_dir,
allow_shell,
profiles,
llm,
app_config,
base_config,
));
Arc::new(WithDelegate::new(executor, delegate))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::models::{Choice, FunctionCall, Role, ToolCall};
use std::collections::BTreeMap;
use std::sync::Mutex;
fn sample_app_config() -> AppConfig {
AppConfig {
default_provider: "mock".into(),
max_iterations: 10,
theme_color: None,
providers: BTreeMap::new(),
mcp_servers: BTreeMap::new(),
default_skills: vec![],
work_dir: None,
allow_shell: false,
}
}
fn sample_agent_config() -> AgentConfig {
AgentConfig::new(
"mock".into(),
"https://example.com".into(),
"key".into(),
"mock-model".into(),
5,
)
}
fn sample_profile(name: &str, description: &str) -> AgentProfile {
AgentProfile {
name: name.into(),
description: description.into(),
model: None,
provider: None,
tools: None,
max_iterations: None,
system_prompt: "You are a test subagent.".into(),
}
}
fn text_choice(content: &str, finish: &str) -> Choice {
Choice {
message: Message::assistant(content.into()),
finish_reason: Some(finish.into()),
}
}
fn tool_call_choice() -> Choice {
Choice {
message: Message {
role: Role::Assistant,
content: None,
tool_calls: Some(vec![ToolCall {
id: "call_1".into(),
call_type: "function".into(),
function: FunctionCall {
name: "nonexistent".into(),
arguments: "{}".into(),
},
}]),
tool_call_id: None,
tool_name: None,
is_error: false,
},
finish_reason: Some("tool_calls".into()),
}
}
struct MockLlm {
responses: Mutex<Vec<Choice>>,
}
impl MockLlm {
fn new(responses: Vec<Choice>) -> Self {
Self {
responses: Mutex::new(responses),
}
}
}
#[async_trait]
impl LlmClient for MockLlm {
async fn send(&self, _messages: &[Message], _tools: &[Tool]) -> Result<Choice> {
let mut responses = self.responses.lock().unwrap();
if responses.is_empty() {
Err(Error::ApiError("no more mock responses".into()))
} else {
Ok(responses.remove(0))
}
}
}
struct EmptyExecutor;
#[async_trait]
impl ToolExecutor for EmptyExecutor {
fn list_tools(&self) -> Vec<Tool> {
vec![]
}
async fn execute(&self, name: &str, _args_json: &str) -> Result<String> {
Err(Error::ToolExecutionError(format!("Unknown tool: {name}")))
}
}
fn make_tool(profiles: Vec<AgentProfile>, llm: Arc<dyn LlmClient>) -> DelegateTool {
DelegateTool::new(
Arc::new(EmptyExecutor),
PathBuf::from("/tmp"),
false,
profiles,
llm,
sample_app_config(),
sample_agent_config(),
)
}
#[test]
fn definition_lists_available_profiles() {
let llm = Arc::new(MockLlm::new(vec![]));
let tool = make_tool(
vec![sample_profile("reviewer", "Reviews code for bugs.")],
llm,
);
let def = tool.definition();
assert_eq!(def.function.name, DELEGATE_TOOL_NAME);
assert!(def.function.description.contains("reviewer"));
assert!(def.function.description.contains("Reviews code for bugs."));
let names = def.function.parameters["properties"]["agent"]["enum"]
.as_array()
.unwrap();
assert_eq!(names, &vec![serde_json::Value::String("reviewer".into())]);
}
#[tokio::test]
async fn execute_returns_message_for_unknown_agent() {
let llm = Arc::new(MockLlm::new(vec![]));
let tool = make_tool(vec![sample_profile("reviewer", "desc")], llm);
let result = tool
.execute(r#"{"agent": "ghost", "task": "do something"}"#)
.await
.unwrap();
assert!(result.contains("Unknown subagent 'ghost'"));
assert!(result.contains("reviewer"));
}
#[tokio::test]
async fn execute_runs_subagent_in_isolated_context_and_returns_final_answer() {
let llm = Arc::new(MockLlm::new(vec![text_choice("subagent answer", "stop")]));
let tool = make_tool(vec![sample_profile("reviewer", "desc")], llm);
let result = tool
.execute(r#"{"agent": "reviewer", "task": "look at this diff"}"#)
.await
.unwrap();
assert_eq!(result, "subagent answer");
}
#[tokio::test]
async fn execute_notes_when_subagent_hits_its_iteration_limit() {
let llm = Arc::new(MockLlm::new(vec![tool_call_choice(), tool_call_choice()]));
let mut profile = sample_profile("looper", "desc");
profile.max_iterations = Some(2);
let tool = make_tool(vec![profile], llm);
let result = tool
.execute(r#"{"agent": "looper", "task": "loop forever"}"#)
.await
.unwrap();
assert!(result.contains("reached its iteration limit (2)"));
}
#[test]
fn with_delegation_returns_executor_unchanged_when_no_profiles() {
let llm = Arc::new(MockLlm::new(vec![]));
let base: Arc<dyn ToolExecutor> = Arc::new(EmptyExecutor);
let result = with_delegation(
base.clone(),
PathBuf::from("/tmp"),
false,
vec![],
llm,
sample_app_config(),
sample_agent_config(),
);
assert!(Arc::ptr_eq(&base, &result));
}
#[tokio::test]
async fn with_delegation_exposes_delegate_tool_alongside_base_tools() {
let llm = Arc::new(MockLlm::new(vec![text_choice("done", "stop")]));
let base: Arc<dyn ToolExecutor> = Arc::new(EmptyExecutor);
let executor = with_delegation(
base,
PathBuf::from("/tmp"),
false,
vec![sample_profile("reviewer", "desc")],
llm,
sample_app_config(),
sample_agent_config(),
);
let names: Vec<_> = executor
.list_tools()
.into_iter()
.map(|t| t.function.name)
.collect();
assert!(names.contains(&DELEGATE_TOOL_NAME.to_string()));
let result = executor
.execute(DELEGATE_TOOL_NAME, r#"{"agent": "reviewer", "task": "go"}"#)
.await
.unwrap();
assert_eq!(result, "done");
}
}