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::client_io::NoClientIo;
use crate::core::llm::LlmClient;
use crate::core::models::{FunctionDefinition, Message, StopReason, Tool};
use crate::core::turn::TurnContext;
use crate::error::{Error, Result};
use crate::rag::PromptBuilder;
use crate::subagents::AgentProfile;
use super::scoped_executor::ScopedExecutor;
use super::{SandboxedExecutor, ToolExecutor};
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,
Arc::new(NoClientIo),
))
}
pub 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"]
}),
},
}
}
pub async fn execute(&self, args: &str, turn: &TurnContext<'_>) -> 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),
&TurnContext {
cancel: turn.cancel,
permission_gate: turn.permission_gate,
},
)
.await?;
if result.stop_reason == StopReason::MaxIterations {
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, turn: &TurnContext<'_>) -> Result<String> {
if name == DELEGATE_TOOL_NAME {
self.delegate.execute(args_json, turn).await
} else {
self.inner.execute(name, args_json, turn).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, ContentBlock, FinishReason, Role};
use crate::core::permission::{AllowAll, PermissionDecision, PermissionGate};
use crate::tools::test_support::TurnHarness;
use std::collections::BTreeMap;
use std::sync::Mutex;
use tokio_util::sync::CancellationToken;
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) -> Choice {
Choice {
message: Message::assistant(content),
finish_reason: Some(FinishReason::Stop),
}
}
fn tool_call_choice() -> Choice {
Choice {
message: Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
id: "call_1".into(),
name: "nonexistent".into(),
arguments: "{}".into(),
}],
},
finish_reason: Some(FinishReason::ToolCalls),
}
}
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,
_turn: &TurnContext<'_>,
) -> 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 harness = TurnHarness::new();
let result = tool
.execute(
r#"{"agent": "ghost", "task": "do something"}"#,
&harness.turn(),
)
.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")]));
let tool = make_tool(vec![sample_profile("reviewer", "desc")], llm);
let harness = TurnHarness::new();
let result = tool
.execute(
r#"{"agent": "reviewer", "task": "look at this diff"}"#,
&harness.turn(),
)
.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 harness = TurnHarness::new();
let result = tool
.execute(
r#"{"agent": "looper", "task": "loop forever"}"#,
&harness.turn(),
)
.await
.unwrap();
assert!(result.contains("reached its iteration limit (2)"));
}
#[tokio::test]
async fn execute_stops_immediately_when_parent_turn_already_cancelled() {
let llm = Arc::new(MockLlm::new(vec![text_choice("should not be seen")]));
let tool = make_tool(vec![sample_profile("reviewer", "desc")], llm);
let cancel = CancellationToken::new();
cancel.cancel();
let permission_gate: Arc<dyn PermissionGate> = Arc::new(AllowAll);
let turn = TurnContext {
cancel: &cancel,
permission_gate: &permission_gate,
};
let result = tool
.execute(
r#"{"agent": "reviewer", "task": "look at this diff"}"#,
&turn,
)
.await
.unwrap();
assert_eq!(result, "");
}
struct RejectPermissionGate;
#[async_trait]
impl PermissionGate for RejectPermissionGate {
async fn check(
&self,
_tool_call_id: &str,
_tool_name: &str,
_arguments: &str,
) -> PermissionDecision {
PermissionDecision::RejectOnce
}
}
#[tokio::test]
async fn subagent_tool_calls_go_through_parent_permission_gate() {
let llm = Arc::new(MockLlm::new(vec![
tool_call_choice(),
text_choice("denied"),
]));
let tool = make_tool(vec![sample_profile("reviewer", "desc")], llm);
let cancel = CancellationToken::new();
let permission_gate: Arc<dyn PermissionGate> = Arc::new(RejectPermissionGate);
let turn = TurnContext {
cancel: &cancel,
permission_gate: &permission_gate,
};
let result = tool
.execute(
r#"{"agent": "reviewer", "task": "look at this diff"}"#,
&turn,
)
.await
.unwrap();
assert_eq!(result, "denied");
}
#[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")]));
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 harness = TurnHarness::new();
let result = executor
.execute(
DELEGATE_TOOL_NAME,
r#"{"agent": "reviewer", "task": "go"}"#,
&harness.turn(),
)
.await
.unwrap();
assert_eq!(result, "done");
}
}