use super::traits::{Tool, ToolResult};
use crate::agent::host::HostAgent;
use crate::agent::loop_::run_tool_call_loop;
use crate::agent::worker::AppWorker;
use crate::approval::ApprovalManager;
use crate::config::DelegateAgentConfig;
use crate::memory::episode::EpisodeManager;
use crate::memory::{Memory, NoneMemory};
use crate::observability::runtime_trace;
use crate::observability::traits::{Observer, ObserverEvent, ObserverMetric};
use crate::perception::traits::{PerceptionProvider, ScreenState};
use crate::providers::{self, Provider};
use crate::security::policy::ToolOperation;
use crate::security::SecurityPolicy;
use async_trait::async_trait;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::collections::{BTreeSet, HashMap};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use uuid::Uuid;
const DELEGATE_TIMEOUT_SECS: u64 = 600;
const DELEGATE_AGENTIC_TIMEOUT_SECS: u64 = 3600;
fn delegate_session_id(agent_name: &str, execution_channel: &str, prompt: &str) -> String {
let seed = format!("{execution_channel}:{agent_name}:{prompt}");
let digest = Sha256::digest(seed.as_bytes());
format!("delegate_agentic_{}", &hex::encode(digest)[..16])
}
#[derive(Debug, Clone, Default)]
struct DelegateToolCallReport {
total_calls: usize,
successful_calls: usize,
failed_calls: usize,
tools_used: BTreeSet<String>,
}
impl DelegateToolCallReport {
fn from_outcomes(outcomes: &[crate::agent::lesson::ToolOutcome]) -> Self {
let mut report = Self::default();
report.record_outcomes(outcomes);
report
}
fn record_outcomes(&mut self, outcomes: &[crate::agent::lesson::ToolOutcome]) {
for outcome in outcomes {
self.total_calls += 1;
if outcome.success {
self.successful_calls += 1;
} else {
self.failed_calls += 1;
}
if !outcome.tool_name.trim().is_empty() {
self.tools_used.insert(outcome.tool_name.clone());
}
}
}
fn tools_used_vec(&self) -> Vec<String> {
self.tools_used.iter().cloned().collect()
}
fn tools_used_compact(&self) -> String {
let tools = self.tools_used_vec();
if tools.is_empty() {
"none".to_string()
} else {
tools.join(",")
}
}
fn append_footer(&self, mode: &str, agent_name: &str, output: String) -> String {
let footer = format!(
"[delegate_report mode={mode} agent={agent_name} tool_calls_total={} tool_calls_success={} tool_calls_failed={} tools_used={}]",
self.total_calls,
self.successful_calls,
self.failed_calls,
self.tools_used_compact()
);
if output.trim().is_empty() {
footer
} else {
format!("{output}\n\n{footer}")
}
}
fn as_trace_payload(&self) -> serde_json::Value {
json!({
"tool_calls_total": self.total_calls,
"tool_calls_success": self.successful_calls,
"tool_calls_failed": self.failed_calls,
"tools_used": self.tools_used_vec(),
})
}
}
fn snapshot_delegate_report(sink: &Arc<Mutex<DelegateToolCallReport>>) -> DelegateToolCallReport {
sink.lock()
.map(|report| report.clone())
.unwrap_or_else(|poisoned| poisoned.into_inner().clone())
}
struct DelegateTraceContext<'a> {
channel: &'a str,
provider: &'a str,
model: &'a str,
turn_id: &'a str,
mode: &'a str,
agent_name: &'a str,
timeout_secs: u64,
}
fn record_delegate_subagent_start(
trace: &DelegateTraceContext<'_>,
max_tool_iterations: Option<usize>,
tool_registry_count: Option<usize>,
) {
runtime_trace::record_event(
"delegate_subagent_start",
Some(trace.channel),
Some(trace.provider),
Some(trace.model),
Some(trace.turn_id),
None,
None,
json!({
"mode": trace.mode,
"agent_name": trace.agent_name,
"timeout_secs": trace.timeout_secs,
"max_tool_iterations": max_tool_iterations,
"tool_registry_count": tool_registry_count,
}),
);
}
fn record_delegate_subagent_end(
trace: &DelegateTraceContext<'_>,
duration_ms: u128,
success: bool,
failure_reason: Option<&str>,
report: &DelegateToolCallReport,
) {
runtime_trace::record_event(
"delegate_subagent_end",
Some(trace.channel),
Some(trace.provider),
Some(trace.model),
Some(trace.turn_id),
Some(success),
failure_reason,
json!({
"mode": trace.mode,
"agent_name": trace.agent_name,
"duration_ms": duration_ms,
"timeout_secs": trace.timeout_secs,
"failure_reason": failure_reason,
"tool_call_counts": report.as_trace_payload(),
}),
);
}
pub struct DelegateTool {
agents: Arc<HashMap<String, DelegateAgentConfig>>,
security: Arc<SecurityPolicy>,
fallback_credential: Option<String>,
provider_runtime_options: providers::ProviderRuntimeOptions,
depth: u32,
parent_tools: Arc<Vec<Arc<dyn Tool>>>,
multimodal_config: crate::config::MultimodalConfig,
approval: Option<Arc<ApprovalManager>>,
execution_channel: String,
episode_memory: Arc<dyn Memory>,
}
impl DelegateTool {
pub fn new(
agents: HashMap<String, DelegateAgentConfig>,
fallback_credential: Option<String>,
security: Arc<SecurityPolicy>,
) -> Self {
Self::new_with_options(
agents,
fallback_credential,
security,
providers::ProviderRuntimeOptions::default(),
)
}
pub fn new_with_options(
agents: HashMap<String, DelegateAgentConfig>,
fallback_credential: Option<String>,
security: Arc<SecurityPolicy>,
provider_runtime_options: providers::ProviderRuntimeOptions,
) -> Self {
Self {
agents: Arc::new(agents),
security,
fallback_credential,
provider_runtime_options,
depth: 0,
parent_tools: Arc::new(Vec::new()),
multimodal_config: crate::config::MultimodalConfig::default(),
approval: None,
execution_channel: "delegate".to_string(),
episode_memory: Arc::new(NoneMemory::new()),
}
}
pub fn with_depth(
agents: HashMap<String, DelegateAgentConfig>,
fallback_credential: Option<String>,
security: Arc<SecurityPolicy>,
depth: u32,
) -> Self {
Self::with_depth_and_options(
agents,
fallback_credential,
security,
depth,
providers::ProviderRuntimeOptions::default(),
)
}
pub fn with_depth_and_options(
agents: HashMap<String, DelegateAgentConfig>,
fallback_credential: Option<String>,
security: Arc<SecurityPolicy>,
depth: u32,
provider_runtime_options: providers::ProviderRuntimeOptions,
) -> Self {
Self {
agents: Arc::new(agents),
security,
fallback_credential,
provider_runtime_options,
depth,
parent_tools: Arc::new(Vec::new()),
multimodal_config: crate::config::MultimodalConfig::default(),
approval: None,
execution_channel: "delegate".to_string(),
episode_memory: Arc::new(NoneMemory::new()),
}
}
pub fn with_parent_tools(mut self, parent_tools: Arc<Vec<Arc<dyn Tool>>>) -> Self {
self.parent_tools = parent_tools;
self
}
pub fn with_multimodal_config(mut self, config: crate::config::MultimodalConfig) -> Self {
self.multimodal_config = config;
self
}
pub fn with_approval_manager(mut self, approval: Arc<ApprovalManager>) -> Self {
self.approval = Some(approval);
self
}
pub fn with_execution_channel(mut self, channel: impl Into<String>) -> Self {
self.execution_channel = channel.into();
self
}
pub fn with_episode_memory(mut self, episode_memory: Arc<dyn Memory>) -> Self {
self.episode_memory = episode_memory;
self
}
}
#[async_trait]
impl Tool for DelegateTool {
fn name(&self) -> &str {
"delegate"
}
fn description(&self) -> &str {
"Delegate a subtask to a specialized agent. Use when: a task benefits from a different model \
(e.g. fast summarization, deep reasoning, code generation). The sub-agent runs a single \
prompt by default; with agentic=true it can iterate with a filtered tool-call loop."
}
fn parameters_schema(&self) -> serde_json::Value {
let agent_names: Vec<&str> = self.agents.keys().map(|s: &String| s.as_str()).collect();
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"agent": {
"type": "string",
"minLength": 1,
"description": format!(
"Name of the agent to delegate to. Available: {}",
if agent_names.is_empty() {
"(none configured)".to_string()
} else {
agent_names.join(", ")
}
)
},
"prompt": {
"type": "string",
"minLength": 1,
"description": "The task/prompt to send to the sub-agent"
},
"context": {
"type": "string",
"description": "Optional context to prepend (e.g. relevant code, prior findings)"
}
},
"required": ["agent", "prompt"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let agent_name = args
.get("agent")
.and_then(|v| v.as_str())
.map(str::trim)
.ok_or_else(|| anyhow::anyhow!("Missing 'agent' parameter"))?;
if agent_name.is_empty() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("'agent' parameter must not be empty".into()),
});
}
let prompt = args
.get("prompt")
.and_then(|v| v.as_str())
.map(str::trim)
.ok_or_else(|| anyhow::anyhow!("Missing 'prompt' parameter"))?;
if prompt.is_empty() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("'prompt' parameter must not be empty".into()),
});
}
let context = args
.get("context")
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or("");
let agent_config = match self.agents.get(agent_name) {
Some(cfg) => cfg,
None => {
let available: Vec<&str> =
self.agents.keys().map(|s: &String| s.as_str()).collect();
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Unknown agent '{agent_name}'. Available agents: {}",
if available.is_empty() {
"(none configured)".to_string()
} else {
available.join(", ")
}
)),
});
}
};
if self.depth >= agent_config.max_depth {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Delegation depth limit reached ({depth}/{max}). \
Cannot delegate further to prevent infinite loops.",
depth = self.depth,
max = agent_config.max_depth
)),
});
}
if let Err(error) = self
.security
.enforce_tool_operation(ToolOperation::Act, "delegate")
{
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(error),
});
}
let provider_credential_owned = agent_config
.api_key
.clone()
.or_else(|| self.fallback_credential.clone());
#[allow(clippy::option_as_ref_deref)]
let provider_credential = provider_credential_owned.as_ref().map(String::as_str);
let provider: Arc<dyn Provider> = match providers::create_provider_with_options(
&agent_config.provider,
provider_credential,
&self.provider_runtime_options,
) {
Ok(p) => Arc::from(p),
Err(e) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Failed to create provider '{}' for agent '{agent_name}': {e}",
agent_config.provider
)),
});
}
};
let full_prompt = if context.is_empty() {
prompt.to_string()
} else {
format!("[Context]\n{context}\n\n[Task]\n{prompt}")
};
let temperature = agent_config.temperature.unwrap_or(0.7);
if agent_config.agentic {
return self
.execute_agentic(
agent_name,
agent_config,
Arc::clone(&provider),
&full_prompt,
temperature,
)
.await;
}
let trace_turn_id = Uuid::new_v4().to_string();
let delegate_started_at = Instant::now();
let empty_report = DelegateToolCallReport::default();
let trace_context = DelegateTraceContext {
channel: &self.execution_channel,
provider: &agent_config.provider,
model: &agent_config.model,
turn_id: &trace_turn_id,
mode: "single",
agent_name,
timeout_secs: DELEGATE_TIMEOUT_SECS,
};
record_delegate_subagent_start(&trace_context, None, None);
let result = tokio::time::timeout(
Duration::from_secs(DELEGATE_TIMEOUT_SECS),
provider.as_ref().chat_with_system(
agent_config.system_prompt.as_deref(),
&full_prompt,
&agent_config.model,
temperature,
),
)
.await;
let result = match result {
Ok(inner) => inner,
Err(_elapsed) => {
let failure =
format!("Agent '{agent_name}' timed out after {DELEGATE_TIMEOUT_SECS}s");
record_delegate_subagent_end(
&trace_context,
delegate_started_at.elapsed().as_millis(),
false,
Some(&failure),
&empty_report,
);
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(failure),
});
}
};
match result {
Ok(response) => {
let mut rendered = response;
if rendered.trim().is_empty() {
rendered = "[Empty response]".to_string();
}
record_delegate_subagent_end(
&trace_context,
delegate_started_at.elapsed().as_millis(),
true,
None,
&empty_report,
);
Ok(ToolResult {
success: true,
output: format!(
"[Agent '{agent_name}' ({provider}/{model})]\n{rendered}",
provider = agent_config.provider,
model = agent_config.model
),
error: None,
})
}
Err(e) => {
let failure = format!("Agent '{agent_name}' failed: {e}");
record_delegate_subagent_end(
&trace_context,
delegate_started_at.elapsed().as_millis(),
false,
Some(&failure),
&empty_report,
);
Ok(ToolResult {
success: false,
output: String::new(),
error: Some(failure),
})
}
}
}
}
impl DelegateTool {
async fn execute_agentic(
&self,
agent_name: &str,
agent_config: &DelegateAgentConfig,
provider: Arc<dyn Provider>,
full_prompt: &str,
temperature: f64,
) -> anyhow::Result<ToolResult> {
let allowed = agent_config
.allowed_tools
.iter()
.map(|name| name.trim())
.filter(|name| !name.is_empty())
.collect::<std::collections::HashSet<_>>();
let sub_tools: Vec<Box<dyn Tool>> = if allowed.is_empty() {
self.parent_tools
.iter()
.filter(|tool| tool.name() != "delegate")
.map(|tool| Box::new(ToolArcRef::new(tool.clone())) as Box<dyn Tool>)
.collect()
} else {
self.parent_tools
.iter()
.filter(|tool| allowed.contains(tool.name()))
.filter(|tool| tool.name() != "delegate")
.map(|tool| Box::new(ToolArcRef::new(tool.clone())) as Box<dyn Tool>)
.collect()
};
if sub_tools.is_empty() {
let reason = if allowed.is_empty() {
"Agent has no executable tools in the parent registry".to_string()
} else {
format!(
"Agent '{agent_name}' has no executable tools after filtering allowlist ({})",
agent_config.allowed_tools.join(", ")
)
};
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(reason),
});
}
let sub_tool_specs: Vec<_> = sub_tools.iter().map(|tool| tool.spec()).collect();
let mut combined_system_prompt = agent_config.system_prompt.clone().unwrap_or_default();
if !provider.supports_native_tools() {
let instructions = match provider.as_ref().convert_tools(&sub_tool_specs) {
providers::traits::ToolsPayload::PromptGuided { instructions } => instructions,
payload => {
anyhow::bail!(
"Provider returned non-prompt-guided tools payload ({payload:?}) while supports_native_tools() is false"
)
}
};
if !combined_system_prompt.trim().is_empty() {
combined_system_prompt.push_str("\n\n");
}
combined_system_prompt.push_str(&instructions);
}
let system_prompt = if combined_system_prompt.trim().is_empty() {
None
} else {
Some(combined_system_prompt)
};
let observer: Arc<dyn Observer> = Arc::new(NoopObserver);
let tool_report_sink = Arc::new(Mutex::new(DelegateToolCallReport::default()));
let trace_turn_id = Uuid::new_v4().to_string();
let delegate_started_at = Instant::now();
let sub_tool_count = sub_tools.len();
let trace_context = DelegateTraceContext {
channel: &self.execution_channel,
provider: &agent_config.provider,
model: &agent_config.model,
turn_id: &trace_turn_id,
mode: "agentic",
agent_name,
timeout_secs: DELEGATE_AGENTIC_TIMEOUT_SECS,
};
record_delegate_subagent_start(
&trace_context,
Some(agent_config.max_iterations),
Some(sub_tool_count),
);
let episode_manager = EpisodeManager::load_or_new(
Arc::clone(&self.episode_memory),
delegate_session_id(agent_name, &self.execution_channel, full_prompt),
full_prompt.to_string(),
)
.await?;
let mut host = HostAgent::new(Arc::new(DelegateRuntimePerceptionProvider), episode_manager);
host.register_worker(Arc::new(DelegateAgenticWorker {
provider,
tools_registry: sub_tools,
observer,
agent_name: agent_name.to_string(),
provider_name: agent_config.provider.clone(),
model_name: agent_config.model.clone(),
temperature,
silent: true,
channel_name: self.execution_channel.clone(),
multimodal_config: self.multimodal_config.clone(),
max_tool_iterations: agent_config.max_iterations,
system_prompt,
approval: self.approval.clone(),
tool_report_sink: Arc::clone(&tool_report_sink),
}));
let result = tokio::time::timeout(
Duration::from_secs(DELEGATE_AGENTIC_TIMEOUT_SECS),
host.run_task_with_result(full_prompt),
)
.await;
match result {
Ok(Ok(tool_result)) => {
let report = snapshot_delegate_report(&tool_report_sink);
record_delegate_subagent_end(
&trace_context,
delegate_started_at.elapsed().as_millis(),
tool_result.success,
tool_result.error.as_deref(),
&report,
);
let rendered = if tool_result.output.trim().is_empty() {
"[Empty response]".to_string()
} else {
tool_result.output
};
Ok(ToolResult {
success: true,
output: format!(
"[Agent '{agent_name}' ({provider}/{model}, agentic)]\n{rendered}",
provider = agent_config.provider,
model = agent_config.model
),
error: None,
})
}
Ok(Err(e)) => {
let report = snapshot_delegate_report(&tool_report_sink);
let failure = format!("Agent '{agent_name}' failed: {}", e.root_cause());
record_delegate_subagent_end(
&trace_context,
delegate_started_at.elapsed().as_millis(),
false,
Some(&failure),
&report,
);
Ok(ToolResult {
success: false,
output: String::new(),
error: Some(failure),
})
}
Err(_) => {
let report = snapshot_delegate_report(&tool_report_sink);
let failure = format!(
"Agent '{agent_name}' timed out after {DELEGATE_AGENTIC_TIMEOUT_SECS}s"
);
record_delegate_subagent_end(
&trace_context,
delegate_started_at.elapsed().as_millis(),
false,
Some(&failure),
&report,
);
Ok(ToolResult {
success: false,
output: String::new(),
error: Some(failure),
})
}
}
}
}
#[derive(Default)]
struct DelegateRuntimePerceptionProvider;
#[async_trait]
impl PerceptionProvider for DelegateRuntimePerceptionProvider {
fn name(&self) -> &str {
"delegate_runtime_perception"
}
async fn capture_state(&self) -> anyhow::Result<ScreenState> {
Ok(ScreenState {
screenshot_path: None,
widget_tree: None,
extracted_text: Vec::new(),
})
}
}
struct DelegateAgenticWorker {
provider: Arc<dyn Provider>,
tools_registry: Vec<Box<dyn Tool>>,
observer: Arc<dyn Observer>,
agent_name: String,
provider_name: String,
model_name: String,
temperature: f64,
silent: bool,
channel_name: String,
multimodal_config: crate::config::MultimodalConfig,
max_tool_iterations: usize,
system_prompt: Option<String>,
approval: Option<Arc<ApprovalManager>>,
tool_report_sink: Arc<Mutex<DelegateToolCallReport>>,
}
#[async_trait]
impl AppWorker for DelegateAgenticWorker {
fn name(&self) -> &str {
"delegate_agentic_worker"
}
fn can_handle(&self, _application_context: &str) -> bool {
true
}
async fn execute_step(
&self,
task_instruction: &str,
_current_state: &ScreenState,
) -> anyhow::Result<ToolResult> {
let mut history = Vec::new();
if let Some(system_prompt) = self.system_prompt.as_ref() {
history.push(crate::providers::ChatMessage::system(system_prompt.clone()));
}
history.push(crate::providers::ChatMessage::user(
task_instruction.to_string(),
));
let mut tool_outcomes = Vec::new();
let response = run_tool_call_loop(
self.provider.as_ref(),
&mut history,
&self.tools_registry,
self.observer.as_ref(),
&self.provider_name,
&self.model_name,
self.temperature,
self.silent,
self.approval.as_deref(),
&self.channel_name,
&self.multimodal_config,
self.max_tool_iterations,
None,
None,
None,
&[],
None,
Some(&mut tool_outcomes),
crate::config::ClickAtPreflightMode::default(),
None,
)
.await;
let step_report = DelegateToolCallReport::from_outcomes(&tool_outcomes);
{
let mut aggregate = self
.tool_report_sink
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
aggregate.record_outcomes(&tool_outcomes);
}
match response {
Ok(output) => Ok(ToolResult {
success: true,
output: step_report.append_footer("agentic", &self.agent_name, output),
error: None,
}),
Err(err) => Err(err),
}
}
}
struct ToolArcRef {
inner: Arc<dyn Tool>,
}
impl ToolArcRef {
fn new(inner: Arc<dyn Tool>) -> Self {
Self { inner }
}
}
#[async_trait]
impl Tool for ToolArcRef {
fn name(&self) -> &str {
self.inner.name()
}
fn description(&self) -> &str {
self.inner.description()
}
fn parameters_schema(&self) -> serde_json::Value {
self.inner.parameters_schema()
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
self.inner.execute(args).await
}
}
struct NoopObserver;
impl Observer for NoopObserver {
fn record_event(&self, _event: &ObserverEvent) {}
fn record_metric(&self, _metric: &ObserverMetric) {}
fn name(&self) -> &str {
"noop"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::approval::ApprovalManager;
use crate::config::AutonomyConfig;
use crate::providers::{ChatRequest, ChatResponse, ToolCall};
use crate::security::{AutonomyLevel, SecurityPolicy};
use anyhow::anyhow;
fn test_security() -> Arc<SecurityPolicy> {
Arc::new(SecurityPolicy::default())
}
fn sample_agents() -> HashMap<String, DelegateAgentConfig> {
let mut agents = HashMap::new();
agents.insert(
"researcher".to_string(),
DelegateAgentConfig {
provider: "ollama".to_string(),
model: "llama3".to_string(),
system_prompt: Some("You are a research assistant.".to_string()),
api_key: None,
temperature: Some(0.3),
max_depth: 3,
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
},
);
agents.insert(
"coder".to_string(),
DelegateAgentConfig {
provider: "openrouter".to_string(),
model: "anthropic/claude-sonnet-4-20250514".to_string(),
system_prompt: None,
api_key: Some("delegate-test-credential".to_string()),
temperature: None,
max_depth: 2,
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
},
);
agents
}
#[derive(Default)]
struct EchoTool;
#[async_trait]
impl Tool for EchoTool {
fn name(&self) -> &str {
"echo_tool"
}
fn description(&self) -> &str {
"Echoes the `value` argument."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"value": {"type": "string"}
},
"required": ["value"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let value = args
.get("value")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
Ok(ToolResult {
success: true,
output: format!("echo:{value}"),
error: None,
})
}
}
struct OneToolThenFinalProvider;
#[async_trait]
impl Provider for OneToolThenFinalProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
Ok("unused".to_string())
}
async fn chat(
&self,
request: ChatRequest<'_>,
_model: &str,
_temperature: f64,
) -> anyhow::Result<ChatResponse> {
let has_tool_message = request.messages.iter().any(|m| m.role == "tool");
if has_tool_message {
Ok(ChatResponse {
text: Some("done".to_string()),
tool_calls: Vec::new(),
usage: None,
reasoning_content: None,
})
} else {
Ok(ChatResponse {
text: None,
tool_calls: vec![ToolCall {
id: "call_1".to_string(),
name: "echo_tool".to_string(),
arguments: "{\"value\":\"ping\"}".to_string(),
}],
usage: None,
reasoning_content: None,
})
}
}
}
struct InfiniteToolCallProvider;
#[async_trait]
impl Provider for InfiniteToolCallProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
Ok("unused".to_string())
}
async fn chat(
&self,
_request: ChatRequest<'_>,
_model: &str,
_temperature: f64,
) -> anyhow::Result<ChatResponse> {
Ok(ChatResponse {
text: None,
tool_calls: vec![ToolCall {
id: "loop".to_string(),
name: "echo_tool".to_string(),
arguments: "{\"value\":\"x\"}".to_string(),
}],
usage: None,
reasoning_content: None,
})
}
}
struct FailingProvider;
#[async_trait]
impl Provider for FailingProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
Ok("unused".to_string())
}
async fn chat(
&self,
_request: ChatRequest<'_>,
_model: &str,
_temperature: f64,
) -> anyhow::Result<ChatResponse> {
Err(anyhow!("provider boom"))
}
}
struct CustomPromptGuidedProvider;
#[async_trait]
impl Provider for CustomPromptGuidedProvider {
fn convert_tools(
&self,
_tools: &[crate::tools::ToolSpec],
) -> providers::traits::ToolsPayload {
providers::traits::ToolsPayload::PromptGuided {
instructions: "CUSTOM_TOOL_INSTRUCTIONS".to_string(),
}
}
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
Ok("unused".to_string())
}
async fn chat(
&self,
request: ChatRequest<'_>,
_model: &str,
_temperature: f64,
) -> anyhow::Result<ChatResponse> {
let has_tool_message = request.messages.iter().any(|message| {
message.role == "tool"
|| (message.role == "user" && message.content.starts_with("[Tool results]"))
});
if has_tool_message {
return Ok(ChatResponse {
text: Some("done".to_string()),
tool_calls: Vec::new(),
usage: None,
reasoning_content: None,
});
}
let has_custom_instructions = request.messages.iter().any(|message| {
message.role == "system" && message.content.contains("CUSTOM_TOOL_INSTRUCTIONS")
});
if has_custom_instructions {
Ok(ChatResponse {
text: Some(
"<tool_call>\n{\"name\":\"echo_tool\",\"arguments\":{\"value\":\"ping\"}}\n</tool_call>"
.to_string(),
),
tool_calls: Vec::new(),
usage: None,
reasoning_content: None,
})
} else {
Ok(ChatResponse {
text: Some("missing custom instructions".to_string()),
tool_calls: Vec::new(),
usage: None,
reasoning_content: None,
})
}
}
}
fn agentic_config(allowed_tools: Vec<String>, max_iterations: usize) -> DelegateAgentConfig {
DelegateAgentConfig {
provider: "openrouter".to_string(),
model: "model-test".to_string(),
system_prompt: Some("You are agentic.".to_string()),
api_key: Some("delegate-test-credential".to_string()),
temperature: Some(0.2),
max_depth: 3,
agentic: true,
allowed_tools,
max_iterations,
}
}
#[test]
fn name_and_schema() {
let tool = DelegateTool::new(sample_agents(), None, test_security());
assert_eq!(tool.name(), "delegate");
let schema = tool.parameters_schema();
assert!(schema["properties"]["agent"].is_object());
assert!(schema["properties"]["prompt"].is_object());
assert!(schema["properties"]["context"].is_object());
let required = schema["required"].as_array().unwrap();
assert!(required.contains(&json!("agent")));
assert!(required.contains(&json!("prompt")));
assert_eq!(schema["additionalProperties"], json!(false));
assert_eq!(schema["properties"]["agent"]["minLength"], json!(1));
assert_eq!(schema["properties"]["prompt"]["minLength"], json!(1));
}
#[test]
fn description_not_empty() {
let tool = DelegateTool::new(sample_agents(), None, test_security());
assert!(!tool.description().is_empty());
}
#[test]
fn schema_lists_agent_names() {
let tool = DelegateTool::new(sample_agents(), None, test_security());
let schema = tool.parameters_schema();
let desc = schema["properties"]["agent"]["description"]
.as_str()
.unwrap();
assert!(desc.contains("researcher") || desc.contains("coder"));
}
#[tokio::test]
async fn missing_agent_param() {
let tool = DelegateTool::new(sample_agents(), None, test_security());
let result = tool.execute(json!({"prompt": "test"})).await;
assert!(result.is_err());
}
#[tokio::test]
async fn missing_prompt_param() {
let tool = DelegateTool::new(sample_agents(), None, test_security());
let result = tool.execute(json!({"agent": "researcher"})).await;
assert!(result.is_err());
}
#[tokio::test]
async fn unknown_agent_returns_error() {
let tool = DelegateTool::new(sample_agents(), None, test_security());
let result = tool
.execute(json!({"agent": "nonexistent", "prompt": "test"}))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("Unknown agent"));
}
#[tokio::test]
async fn depth_limit_enforced() {
let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 3);
let result = tool
.execute(json!({"agent": "researcher", "prompt": "test"}))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("depth limit"));
}
#[tokio::test]
async fn depth_limit_per_agent() {
let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 2);
let result = tool
.execute(json!({"agent": "coder", "prompt": "test"}))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("depth limit"));
}
#[test]
fn empty_agents_schema() {
let tool = DelegateTool::new(HashMap::new(), None, test_security());
let schema = tool.parameters_schema();
let desc = schema["properties"]["agent"]["description"]
.as_str()
.unwrap();
assert!(desc.contains("none configured"));
}
#[tokio::test]
async fn invalid_provider_returns_error() {
let mut agents = HashMap::new();
agents.insert(
"broken".to_string(),
DelegateAgentConfig {
provider: "totally-invalid-provider".to_string(),
model: "model".to_string(),
system_prompt: None,
api_key: None,
temperature: None,
max_depth: 3,
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
},
);
let tool = DelegateTool::new(agents, None, test_security());
let result = tool
.execute(json!({"agent": "broken", "prompt": "test"}))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("Failed to create provider"));
}
#[tokio::test]
async fn blank_agent_rejected() {
let tool = DelegateTool::new(sample_agents(), None, test_security());
let result = tool
.execute(json!({"agent": " ", "prompt": "test"}))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("must not be empty"));
}
#[tokio::test]
async fn blank_prompt_rejected() {
let tool = DelegateTool::new(sample_agents(), None, test_security());
let result = tool
.execute(json!({"agent": "researcher", "prompt": " \t "}))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("must not be empty"));
}
#[tokio::test]
async fn whitespace_agent_name_trimmed_and_found() {
let tool = DelegateTool::new(sample_agents(), None, test_security());
let result = tool
.execute(json!({"agent": " researcher ", "prompt": "test"}))
.await
.unwrap();
assert!(
result.error.is_none()
|| !result
.error
.as_deref()
.unwrap_or("")
.contains("Unknown agent")
);
}
#[tokio::test]
async fn delegation_blocked_in_readonly_mode() {
let readonly = Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::ReadOnly,
..SecurityPolicy::default()
});
let tool = DelegateTool::new(sample_agents(), None, readonly);
let result = tool
.execute(json!({"agent": "researcher", "prompt": "test"}))
.await
.unwrap();
assert!(!result.success);
assert!(result
.error
.as_deref()
.unwrap_or("")
.contains("read-only mode"));
}
#[tokio::test]
async fn delegation_blocked_when_rate_limited() {
let limited = Arc::new(SecurityPolicy {
max_actions_per_hour: 0,
..SecurityPolicy::default()
});
let tool = DelegateTool::new(sample_agents(), None, limited);
let result = tool
.execute(json!({"agent": "researcher", "prompt": "test"}))
.await
.unwrap();
assert!(!result.success);
assert!(result
.error
.as_deref()
.unwrap_or("")
.contains("Rate limit exceeded"));
}
#[tokio::test]
async fn delegate_context_is_prepended_to_prompt() {
let mut agents = HashMap::new();
agents.insert(
"tester".to_string(),
DelegateAgentConfig {
provider: "invalid-for-test".to_string(),
model: "test-model".to_string(),
system_prompt: None,
api_key: None,
temperature: None,
max_depth: 3,
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
},
);
let tool = DelegateTool::new(agents, None, test_security());
let result = tool
.execute(json!({
"agent": "tester",
"prompt": "do something",
"context": "some context data"
}))
.await
.unwrap();
assert!(!result.success);
assert!(result
.error
.as_deref()
.unwrap_or("")
.contains("Failed to create provider"));
}
#[tokio::test]
async fn delegate_empty_context_omits_prefix() {
let mut agents = HashMap::new();
agents.insert(
"tester".to_string(),
DelegateAgentConfig {
provider: "invalid-for-test".to_string(),
model: "test-model".to_string(),
system_prompt: None,
api_key: None,
temperature: None,
max_depth: 3,
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
},
);
let tool = DelegateTool::new(agents, None, test_security());
let result = tool
.execute(json!({
"agent": "tester",
"prompt": "do something",
"context": ""
}))
.await
.unwrap();
assert!(!result.success);
assert!(result
.error
.as_deref()
.unwrap_or("")
.contains("Failed to create provider"));
}
#[test]
fn delegate_depth_construction() {
let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 5);
assert_eq!(tool.depth, 5);
}
#[tokio::test]
async fn delegate_no_agents_configured() {
let tool = DelegateTool::new(HashMap::new(), None, test_security());
let result = tool
.execute(json!({"agent": "any", "prompt": "test"}))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("none configured"));
}
#[tokio::test]
async fn agentic_mode_uses_all_parent_tools_when_allowed_tools_empty() {
let config = agentic_config(Vec::new(), 10);
let tool = DelegateTool::new(HashMap::new(), None, test_security())
.with_parent_tools(Arc::new(vec![Arc::new(EchoTool)]));
let provider = OneToolThenFinalProvider;
let result = tool
.execute_agentic("agentic", &config, Arc::new(provider), "run", 0.2)
.await
.unwrap();
assert!(result.success, "{result:?}");
assert!(result.output.contains("done"));
}
#[tokio::test]
async fn agentic_mode_rejects_unmatched_allowed_tools() {
let mut agents = HashMap::new();
agents.insert(
"agentic".to_string(),
agentic_config(vec!["missing_tool".to_string()], 10),
);
let tool = DelegateTool::new(agents, None, test_security())
.with_parent_tools(Arc::new(vec![Arc::new(EchoTool)]));
let result = tool
.execute(json!({"agent": "agentic", "prompt": "test"}))
.await
.unwrap();
assert!(!result.success);
assert!(result
.error
.as_deref()
.unwrap_or("")
.contains("no executable tools"));
}
#[tokio::test]
async fn execute_agentic_runs_tool_call_loop_with_filtered_tools() {
let config = agentic_config(vec!["echo_tool".to_string()], 10);
let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools(
Arc::new(vec![
Arc::new(EchoTool),
Arc::new(DelegateTool::new(HashMap::new(), None, test_security())),
]),
);
let provider = OneToolThenFinalProvider;
let result = tool
.execute_agentic("agentic", &config, Arc::new(provider), "run", 0.2)
.await
.unwrap();
assert!(result.success, "{result:?}");
assert!(result.output.contains("(openrouter/model-test, agentic)"));
assert!(result.output.contains("done"));
assert!(result
.output
.contains("delegate_report mode=agentic agent=agentic"));
assert!(result.output.contains("tool_calls_total=1"));
assert!(result.output.contains("tool_calls_success=1"));
assert!(result.output.contains("tool_calls_failed=0"));
assert!(result.output.contains("tools_used=echo_tool"));
}
#[tokio::test]
async fn execute_agentic_uses_provider_prompt_guided_instructions_for_non_native_provider() {
let config = agentic_config(vec!["echo_tool".to_string()], 10);
let tool = DelegateTool::new(HashMap::new(), None, test_security())
.with_parent_tools(Arc::new(vec![Arc::new(EchoTool)]));
let provider = CustomPromptGuidedProvider;
let result = tool
.execute_agentic("agentic", &config, Arc::new(provider), "run", 0.2)
.await
.unwrap();
assert!(result.success, "{result:?}");
assert!(result.output.contains("done"));
}
#[tokio::test]
async fn execute_agentic_uses_approval_manager_when_configured() {
let config = agentic_config(vec!["echo_tool".to_string()], 10);
let mut approval_config = AutonomyConfig::default();
approval_config.level = AutonomyLevel::Supervised;
approval_config.auto_approve.clear();
approval_config.always_ask.clear();
let approval_manager = Arc::new(ApprovalManager::from_config(&approval_config));
let tool = DelegateTool::new(HashMap::new(), None, test_security())
.with_parent_tools(Arc::new(vec![Arc::new(EchoTool)]))
.with_approval_manager(approval_manager.clone());
let provider = OneToolThenFinalProvider;
let result = tool
.execute_agentic("agentic", &config, Arc::new(provider), "run", 0.2)
.await
.unwrap();
assert!(result.success);
let log = approval_manager.audit_log();
assert!(!log.is_empty());
assert_eq!(log[0].tool_name, "echo_tool");
assert_eq!(log[0].channel, "delegate");
}
#[tokio::test]
async fn execute_agentic_excludes_delegate_even_if_allowlisted() {
let config = agentic_config(vec!["delegate".to_string()], 10);
let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools(
Arc::new(vec![Arc::new(DelegateTool::new(
HashMap::new(),
None,
test_security(),
))]),
);
let provider = OneToolThenFinalProvider;
let result = tool
.execute_agentic("agentic", &config, Arc::new(provider), "run", 0.2)
.await
.unwrap();
assert!(!result.success);
assert!(result
.error
.as_deref()
.unwrap_or("")
.contains("no executable tools"));
}
#[tokio::test]
async fn execute_agentic_respects_max_iterations() {
let config = agentic_config(vec!["echo_tool".to_string()], 2);
let tool = DelegateTool::new(HashMap::new(), None, test_security())
.with_parent_tools(Arc::new(vec![Arc::new(EchoTool)]));
let provider = InfiniteToolCallProvider;
let result = tool
.execute_agentic("agentic", &config, Arc::new(provider), "run", 0.2)
.await
.unwrap();
assert!(!result.success);
assert!(result
.error
.as_deref()
.unwrap_or("")
.contains("maximum tool iterations (2)"));
}
#[tokio::test]
async fn execute_agentic_propagates_provider_errors() {
let config = agentic_config(vec!["echo_tool".to_string()], 10);
let tool = DelegateTool::new(HashMap::new(), None, test_security())
.with_parent_tools(Arc::new(vec![Arc::new(EchoTool)]));
let provider = FailingProvider;
let result = tool
.execute_agentic("agentic", &config, Arc::new(provider), "run", 0.2)
.await
.unwrap();
assert!(!result.success);
assert!(result
.error
.as_deref()
.unwrap_or("")
.contains("provider boom"));
}
}