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 listing = if self.profiles.is_empty() {
"\n(none configured — define one inline via `system_prompt`)".to_string()
} else {
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));
}
listing
};
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\
Pick a pre-configured subagent by `agent` name, OR define an ephemeral one \
inline by providing `system_prompt` (with optional `tools`, `model`, \
`provider`, `max_iterations`). Inline subagents exist only for this one call \
and are not saved. Exactly one of `agent` or `system_prompt` is required.\n\
\n\
Available subagents:{listing}"
);
let mut agent_schema = json!({
"type": "string",
"description": "Name of a pre-configured subagent to delegate to. \
Mutually exclusive with `system_prompt`."
});
if !self.profiles.is_empty() {
let names: Vec<String> = self.profiles.iter().map(|p| p.name.clone()).collect();
agent_schema["enum"] = json!(names);
}
Tool {
tool_type: "function".to_string(),
function: FunctionDefinition {
name: DELEGATE_TOOL_NAME.to_string(),
description,
parameters: json!({
"type": "object",
"properties": {
"agent": agent_schema,
"system_prompt": {
"type": "string",
"description": "System prompt for an ephemeral inline subagent — \
its persona and instructions. Mutually exclusive \
with `agent`."
},
"tools": {
"type": "array",
"items": { "type": "string" },
"description": "Inline subagent only: restrict it to this set of \
tool names. Omitted = it inherits your full tool set."
},
"model": {
"type": "string",
"description": "Inline subagent only: run it on this model instead \
of yours."
},
"provider": {
"type": "string",
"description": "Inline subagent only: provider for `model`. Only \
used when `model` is also set."
},
"max_iterations": {
"type": "integer",
"description": "Inline subagent only: cap its agent-loop iterations."
},
"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": ["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 task = v["task"]
.as_str()
.ok_or_else(|| Error::ParseError("missing 'task' argument".to_string()))?;
let profile = match (v["agent"].as_str(), v["system_prompt"].as_str()) {
(Some(_), Some(_)) => {
return Ok(
"Provide either 'agent' (a pre-configured subagent) or 'system_prompt' \
(an inline one), not both."
.to_string(),
);
}
(Some(agent_name), None) => match self.find_profile(agent_name) {
Some(profile) => profile.clone(),
None => {
let available = self
.profiles
.iter()
.map(|p| p.name.as_str())
.collect::<Vec<_>>()
.join(", ");
return Ok(format!(
"Unknown subagent '{agent_name}'. Available subagents: {available}. \
Alternatively, define an inline subagent via 'system_prompt'."
));
}
},
(None, Some(system_prompt)) => inline_profile(system_prompt, &v)?,
(None, None) => {
return Ok(
"Missing subagent: provide 'agent' (a pre-configured subagent) or \
'system_prompt' (an inline one)."
.to_string(),
);
}
};
let profile = &profile;
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 '{}' reached its iteration limit \
({}) before finishing — this answer may be incomplete.]",
result.final_response, profile.name, config.max_iterations
))
} else {
Ok(result.final_response)
}
}
}
fn inline_profile(system_prompt: &str, v: &serde_json::Value) -> Result<AgentProfile> {
let tools = match &v["tools"] {
serde_json::Value::Null => None,
serde_json::Value::Array(items) => {
let mut names = Vec::with_capacity(items.len());
for item in items {
let name = item.as_str().ok_or_else(|| {
Error::ParseError("'tools' must be an array of strings".to_string())
})?;
names.push(name.to_string());
}
Some(names)
}
_ => {
return Err(Error::ParseError(
"'tools' must be an array of strings".to_string(),
));
}
};
Ok(AgentProfile {
name: "inline".to_string(),
description: String::new(),
model: v["model"].as_str().map(str::to_string),
provider: v["provider"].as_str().map(str::to_string),
tools,
max_iterations: v["max_iterations"].as_u64().map(|n| n as usize),
system_prompt: system_prompt.to_string(),
})
}
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> {
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_exposes_delegate_tool_even_without_profiles() {
let llm = Arc::new(MockLlm::new(vec![]));
let base: Arc<dyn ToolExecutor> = Arc::new(EmptyExecutor);
let executor = with_delegation(
base,
PathBuf::from("/tmp"),
false,
vec![],
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()));
}
#[test]
fn definition_omits_enum_when_no_profiles() {
let llm = Arc::new(MockLlm::new(vec![]));
let tool = make_tool(vec![], llm);
let def = tool.definition();
assert!(def.function.parameters["properties"]["agent"]["enum"].is_null());
assert!(def.function.description.contains("none configured"));
let required = def.function.parameters["required"].as_array().unwrap();
assert_eq!(required, &vec![serde_json::Value::String("task".into())]);
}
#[tokio::test]
async fn execute_runs_inline_subagent_from_system_prompt() {
let llm = Arc::new(MockLlm::new(vec![text_choice("inline answer")]));
let tool = make_tool(vec![], llm);
let harness = TurnHarness::new();
let result = tool
.execute(
r#"{"system_prompt": "You are a poet.", "task": "write a haiku"}"#,
&harness.turn(),
)
.await
.unwrap();
assert_eq!(result, "inline answer");
}
#[tokio::test]
async fn execute_notes_when_inline_subagent_hits_iteration_limit() {
let llm = Arc::new(MockLlm::new(vec![tool_call_choice(), tool_call_choice()]));
let tool = make_tool(vec![], llm);
let harness = TurnHarness::new();
let result = tool
.execute(
r#"{"system_prompt": "Loop.", "max_iterations": 2, "task": "go"}"#,
&harness.turn(),
)
.await
.unwrap();
assert!(result.contains("subagent 'inline' reached its iteration limit (2)"));
}
#[tokio::test]
async fn execute_rejects_both_agent_and_system_prompt() {
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": "reviewer", "system_prompt": "You are X.", "task": "go"}"#,
&harness.turn(),
)
.await
.unwrap();
assert!(result.contains("not both"));
}
#[tokio::test]
async fn execute_rejects_neither_agent_nor_system_prompt() {
let llm = Arc::new(MockLlm::new(vec![]));
let tool = make_tool(vec![], llm);
let harness = TurnHarness::new();
let result = tool
.execute(r#"{"task": "go"}"#, &harness.turn())
.await
.unwrap();
assert!(result.contains("Missing subagent"));
}
#[tokio::test]
async fn execute_rejects_non_string_inline_tools() {
let llm = Arc::new(MockLlm::new(vec![]));
let tool = make_tool(vec![], llm);
let harness = TurnHarness::new();
let err = tool
.execute(
r#"{"system_prompt": "X.", "tools": [1, 2], "task": "go"}"#,
&harness.turn(),
)
.await
.unwrap_err();
assert!(err.to_string().contains("array of strings"));
}
#[test]
fn inline_profile_maps_all_optional_fields() {
let v: serde_json::Value = serde_json::from_str(
r#"{
"tools": ["read_file"],
"model": "claude-haiku-4-5",
"provider": "anthropic",
"max_iterations": 3
}"#,
)
.unwrap();
let profile = inline_profile("You are X.", &v).unwrap();
assert_eq!(profile.name, "inline");
assert_eq!(profile.system_prompt, "You are X.");
assert_eq!(profile.tools, Some(vec!["read_file".to_string()]));
assert_eq!(profile.model.as_deref(), Some("claude-haiku-4-5"));
assert_eq!(profile.provider.as_deref(), Some("anthropic"));
assert_eq!(profile.max_iterations, Some(3));
}
#[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");
}
}