use async_trait::async_trait;
use serde::Deserialize;
use serde_json::json;
use lingshu_types::{Content, Message, Role, ToolError, ToolSchema};
use crate::registry::{DelegationEvent, SubAgentRunRequest, ToolContext, ToolHandler};
const MAX_DEPTH: u32 = 2;
const MAX_CONCURRENT_CHILDREN: usize = 3;
const DEFAULT_CHILD_MAX_ITERATIONS: u32 = 50;
const BLOCKED_TOOLSETS: &[&str] = &[
"delegation", "clarify", "memory", "code_execution", "messaging", ];
pub struct DelegateTaskToolReal;
#[derive(Deserialize)]
struct DelegateTaskArgs {
#[serde(default)]
goal: Option<String>,
#[serde(default)]
task: Option<String>,
#[serde(default)]
context: Option<String>,
#[serde(default)]
toolsets: Option<Vec<String>>,
#[serde(default)]
tasks: Option<Vec<BatchTask>>,
#[serde(default)]
max_iterations: Option<u32>,
}
#[derive(Deserialize, Clone)]
struct BatchTask {
goal: String,
#[serde(default)]
context: Option<String>,
#[serde(default)]
toolsets: Option<Vec<String>>,
}
fn build_child_system_prompt(goal: &str, context: Option<&str>) -> String {
let mut parts = vec![
"You are a focused subagent working on a specific delegated task.".to_string(),
String::new(),
format!("YOUR TASK:\n{}", goal),
];
if let Some(ctx) = context
&& !ctx.trim().is_empty()
{
parts.push(format!("\nCONTEXT:\n{}", ctx));
}
parts.push(
"\nComplete this task using the tools available to you. \
When finished, provide a clear, concise summary of:\n\
- What you did\n\
- What you found or accomplished\n\
- Any files you created or modified\n\
- Any issues encountered\n\n\
Be thorough but concise — your response is returned to the \
parent agent as a summary."
.to_string(),
);
parts.join("\n")
}
fn filter_child_toolsets(requested: Option<&[String]>, parent_enabled: &[String]) -> Vec<String> {
let base = if let Some(req) = requested {
if parent_enabled.is_empty() {
req.to_vec()
} else {
req.iter()
.filter(|t| parent_enabled.iter().any(|p| p == *t))
.cloned()
.collect()
}
} else if !parent_enabled.is_empty() {
parent_enabled.to_vec()
} else {
vec!["terminal".into(), "file".into(), "web".into()]
};
base.into_iter()
.filter(|t| !BLOCKED_TOOLSETS.contains(&t.as_str()))
.collect()
}
fn build_tool_trace(messages: &[Message]) -> Vec<serde_json::Value> {
let mut tool_trace = Vec::new();
let mut trace_by_id: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
for msg in messages {
match msg.role {
Role::Assistant => {
if let Some(tool_calls) = &msg.tool_calls {
for tc in tool_calls {
let idx = tool_trace.len();
tool_trace.push(json!({
"tool": tc.function.name,
"args_bytes": tc.function.arguments.len(),
}));
trace_by_id.insert(tc.id.clone(), idx);
}
}
}
Role::Tool => {
let content = match &msg.content {
Some(Content::Text(text)) => text.clone(),
Some(Content::Parts(parts)) => parts
.iter()
.filter_map(|part| match part {
lingshu_types::ContentPart::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n"),
None => String::new(),
};
let status = if content
.get(..80)
.unwrap_or(content.as_str())
.to_ascii_lowercase()
.contains("error")
{
"error"
} else {
"ok"
};
let result_meta = json!({
"result_bytes": content.len(),
"status": status,
});
if let Some(tool_call_id) = &msg.tool_call_id
&& let Some(idx) = trace_by_id.get(tool_call_id).copied()
{
if let Some(entry) = tool_trace.get_mut(idx)
&& let Some(obj) = entry.as_object_mut()
{
obj.extend(result_meta.as_object().cloned().unwrap_or_default());
}
continue;
}
if let Some(entry) = tool_trace.last_mut()
&& let Some(obj) = entry.as_object_mut()
{
obj.extend(result_meta.as_object().cloned().unwrap_or_default());
}
}
_ => {}
}
}
tool_trace
}
struct ChildTaskRequest {
task_index: usize,
task_count: usize,
goal: String,
context: Option<String>,
toolsets: Option<Vec<String>>,
max_iterations: u32,
model_override: Option<String>,
}
async fn run_child_task(parent_ctx: &ToolContext, request: ChildTaskRequest) -> serde_json::Value {
let ChildTaskRequest {
task_index,
task_count,
goal,
context,
toolsets,
max_iterations,
model_override,
} = request;
let start = std::time::Instant::now();
let Some(ref runner) = parent_ctx.sub_agent_runner else {
return json!({
"task_index": task_index,
"status": "skipped",
"summary": format!(
"Sub-task delegated (no sub-agent runner available).\n\
Task: {}\nStatus: Please proceed with the task inline.",
goal
),
"duration_seconds": 0,
});
};
let system_prompt = build_child_system_prompt(&goal, context.as_deref());
if let Some(tx) = &parent_ctx.delegation_event_tx {
let agent_id = crate::subagent_ids::resolve_subagent_agent_id(
parent_ctx.delegate_agent_id.as_deref(),
task_index,
);
let parent_id = parent_ctx.delegate_agent_id.clone();
let depth = parent_ctx.delegate_depth.saturating_add(1);
let _ = tx.send(DelegationEvent::TaskStarted {
task_index,
task_count,
goal: goal.clone(),
depth,
agent_id: agent_id.clone(),
parent_id: parent_id.clone(),
});
}
let child_toolsets = filter_child_toolsets(
toolsets.as_deref(),
&parent_ctx.config.parent_active_toolsets,
);
match runner
.run_task(SubAgentRunRequest {
goal: goal.clone(),
system_prompt,
enabled_toolsets: child_toolsets,
max_iterations,
model_override,
parent_cancel: parent_ctx.cancel.clone(),
progress_tx: parent_ctx.delegation_event_tx.clone(),
task_index,
task_count,
agent_id: crate::subagent_ids::resolve_subagent_agent_id(
parent_ctx.delegate_agent_id.as_deref(),
task_index,
),
parent_id: parent_ctx.delegate_agent_id.clone(),
depth: parent_ctx.delegate_depth.saturating_add(1),
})
.await
{
Ok(result) => {
let duration = start.elapsed().as_secs_f64();
let summary = result.summary.trim().to_string();
let status = result.run_outcome.state.as_str().to_string();
let duration_ms = start.elapsed().as_millis() as u64;
if let Some(tx) = &parent_ctx.delegation_event_tx {
let _ = tx.send(DelegationEvent::TaskFinished {
task_index,
task_count,
status: status.clone(),
duration_ms,
summary: summary.clone(),
api_calls: result.api_calls,
model: result.model.clone(),
});
}
let exit_reason = result.run_outcome.exit_reason.as_str();
let tool_trace = build_tool_trace(&result.messages);
json!({
"task_index": task_index,
"status": status,
"exit_reason": exit_reason,
"summary": if summary.is_empty() {
format!("Sub-agent completed but returned empty response.\nTask: {}", goal)
} else {
summary
},
"api_calls": result.api_calls,
"model": result.model,
"duration_seconds": (duration * 100.0).round() / 100.0,
"tokens": {
"input": result.input_tokens,
"output": result.output_tokens,
"cache_read": result.cache_read_tokens,
"cache_write": result.cache_write_tokens,
"reasoning": result.reasoning_tokens,
"prompt": result.input_tokens
+ result.cache_read_tokens
+ result.cache_write_tokens,
"total": result.input_tokens
+ result.cache_read_tokens
+ result.cache_write_tokens
+ result.output_tokens
+ result.reasoning_tokens,
},
"tool_trace": tool_trace,
})
}
Err(e) => {
if let Some(tx) = &parent_ctx.delegation_event_tx {
let _ = tx.send(DelegationEvent::TaskFinished {
task_index,
task_count,
status: "error".to_string(),
duration_ms: start.elapsed().as_millis() as u64,
summary: String::new(),
api_calls: 0,
model: None,
});
}
json!({
"task_index": task_index,
"status": "error",
"error": format!("Sub-agent execution failed: {}", e),
"duration_seconds": start.elapsed().as_secs_f64(),
})
}
}
}
#[async_trait]
impl ToolHandler for DelegateTaskToolReal {
fn name(&self) -> &'static str {
"delegate_task"
}
fn toolset(&self) -> &'static str {
"delegation"
}
fn emoji(&self) -> &'static str {
"🔀"
}
fn is_available(&self) -> bool {
true
}
fn schema(&self) -> ToolSchema {
ToolSchema {
name: "delegate_task".into(),
description: "Spawn one or more focused sub-agents to handle delegated tasks. \
Supports single-task (goal) or batch (tasks array, max 3 parallel). \
Each child gets its own execute_loop with tools and independent context. \
Pass all relevant paths, errors, and constraints via `context` because \
child agents do not inherit your full conversation. Use this for \
reasoning-heavy or parallel subtasks, not for single direct tool calls. \
Required: provide either `goal` (single task) or `tasks` (batch) — \
calling without either returns an error."
.into(),
parameters: json!({
"type": "object",
"properties": {
"goal": {
"type": "string",
"description": "Clear task description for single-task mode"
},
"context": {
"type": "string",
"description": "Optional additional context or constraints"
},
"toolsets": {
"type": "array",
"items": { "type": "string" },
"description": "Toolsets to enable for the sub-agent (default: terminal, file, web)"
},
"tasks": {
"type": "array",
"maxItems": MAX_CONCURRENT_CHILDREN,
"items": {
"type": "object",
"properties": {
"goal": { "type": "string" },
"context": { "type": "string" },
"toolsets": { "type": "array", "items": { "type": "string" } }
},
"required": ["goal"]
},
"description": "Batch mode: array of tasks to run in parallel (max 3)"
},
"max_iterations": {
"type": "integer",
"description": "Iteration budget per sub-agent (default: 50)"
}
}
}),
strict: None,
}
}
async fn execute(
&self,
args: serde_json::Value,
ctx: &ToolContext,
) -> Result<String, ToolError> {
let args: DelegateTaskArgs =
serde_json::from_value(args).map_err(|e| ToolError::InvalidArgs {
tool: "delegate_task".into(),
message: format!("Invalid delegate_task args: {e}"),
})?;
if ctx.cancel.is_cancelled() {
return Err(ToolError::Other("Cancelled".into()));
}
if !ctx.config.delegation_enabled {
return Err(ToolError::Other(
"Delegation is disabled in configuration (delegation.enabled=false).".into(),
));
}
if let Some(message) = crate::delegation_state::spawn_blocked_message() {
return Err(ToolError::Other(message));
}
if ctx.delegate_depth >= MAX_DEPTH {
return Err(ToolError::Other(format!(
"Delegation depth limit reached ({}). Subagents cannot spawn further subagents.",
MAX_DEPTH
)));
}
let config_default_max_iter = if ctx.config.delegation_max_iterations > 0 {
ctx.config.delegation_max_iterations
} else {
DEFAULT_CHILD_MAX_ITERATIONS
};
let max_iter = args.max_iterations.unwrap_or(config_default_max_iter);
let configured_max_subagents = if ctx.config.delegation_max_subagents > 0 {
ctx.config.delegation_max_subagents as usize
} else {
MAX_CONCURRENT_CHILDREN
};
let max_subagents = configured_max_subagents.min(MAX_CONCURRENT_CHILDREN);
let model_override = ctx
.config
.delegation_model
.as_deref()
.map(str::trim)
.filter(|m| !m.is_empty())
.map(|m| {
if m.contains('/') {
m.to_string()
} else if let Some(provider) = ctx
.config
.delegation_provider
.as_deref()
.map(str::trim)
.filter(|p| !p.is_empty())
{
format!("{provider}/{m}")
} else {
m.to_string()
}
});
let task_list: Vec<BatchTask> = if let Some(tasks) = args.tasks {
if tasks.is_empty() {
return Err(ToolError::InvalidArgs {
tool: "delegate_task".into(),
message: "tasks array is empty".into(),
});
}
let tasks: Vec<BatchTask> = tasks.into_iter().take(max_subagents).collect();
for (idx, task) in tasks.iter().enumerate() {
if task.goal.trim().is_empty() {
return Err(ToolError::InvalidArgs {
tool: "delegate_task".into(),
message: format!("Task {idx} is missing a non-empty 'goal'."),
});
}
}
tasks
} else {
let goal = args.goal.or(args.task).unwrap_or_default();
if goal.trim().is_empty() {
return Err(ToolError::InvalidArgs {
tool: "delegate_task".into(),
message: "Provide either 'goal' (single task) or 'tasks' (batch).".into(),
});
}
vec![BatchTask {
goal,
context: args.context.clone(),
toolsets: args.toolsets.clone(),
}]
};
let overall_start = std::time::Instant::now();
let task_count = task_list.len();
if task_list.len() == 1 {
let t = &task_list[0];
let result = run_child_task(
ctx,
ChildTaskRequest {
task_index: 0,
task_count,
goal: t.goal.clone(),
context: t.context.clone(),
toolsets: t.toolsets.clone(),
max_iterations: max_iter,
model_override: model_override.clone(),
},
)
.await;
let total_duration = (overall_start.elapsed().as_secs_f64() * 100.0).round() / 100.0;
Ok(serde_json::to_string_pretty(&json!({
"results": [result],
"total_duration_seconds": total_duration,
}))
.unwrap_or_default())
} else {
let mut handles = Vec::new();
for (i, t) in task_list.into_iter().enumerate() {
let runner = ctx.sub_agent_runner.clone();
let platform = ctx.platform;
let cancel = ctx.cancel.clone();
let child_ctx = ToolContext {
task_id: uuid::Uuid::new_v4().to_string(),
cwd: ctx.cwd.clone(),
session_id: format!("subagent-{}", i),
user_task: None,
cancel,
config: ctx.config.clone(),
state_db: ctx.state_db.clone(),
platform,
process_table: ctx.process_table.clone(),
provider: None,
tool_registry: None,
delegate_depth: ctx.delegate_depth + 1,
delegate_agent_id: Some(crate::subagent_ids::resolve_subagent_agent_id(
ctx.delegate_agent_id.as_deref(),
i,
)),
delegate_parent_id: ctx.delegate_agent_id.clone(),
sub_agent_runner: runner,
delegation_event_tx: ctx.delegation_event_tx.clone(),
clarify_tx: None, approval_tx: None, on_skills_changed: None, gateway_sender: None,
origin_chat: None, session_key: ctx.session_key.clone(),
todo_store: None,
current_tool_call_id: None,
current_tool_name: None,
injected_messages: None,
tool_progress_tx: None,
watch_notification_tx: None,
mutation_turn: None,
lsp_gate: None,
kanban_task_id: None,
};
let goal = t.goal.clone();
let context = t.context.clone();
let toolsets = t.toolsets.clone();
let model_override = model_override.clone();
handles.push(tokio::spawn(async move {
run_child_task(
&child_ctx,
ChildTaskRequest {
task_index: i,
task_count,
goal,
context,
toolsets,
max_iterations: max_iter,
model_override,
},
)
.await
}));
}
let mut results = Vec::new();
for handle in handles {
match handle.await {
Ok(result) => results.push(result),
Err(e) => results.push(json!({
"status": "error",
"error": format!("Task join error: {}", e),
})),
}
}
results.sort_by_key(|r| r.get("task_index").and_then(|v| v.as_u64()).unwrap_or(0));
let total_duration = (overall_start.elapsed().as_secs_f64() * 100.0).round() / 100.0;
Ok(serde_json::to_string_pretty(&json!({
"results": results,
"total_duration_seconds": total_duration,
}))
.unwrap_or_default())
}
}
}
inventory::submit!(&DelegateTaskToolReal as &dyn ToolHandler);
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use lingshu_types::{CompletionDecision, ExitReason, RunOutcome};
use crate::registry::{SubAgentResult, SubAgentRunRequest, SubAgentRunner};
fn completed_outcome(summary: &str) -> RunOutcome {
RunOutcome::new(
CompletionDecision::Completed,
ExitReason::ModelReturnedFinalText,
summary,
)
}
#[derive(Default, Clone)]
struct RecordedCall {
enabled_toolsets: Vec<String>,
max_iterations: u32,
model_override: Option<String>,
}
struct RecordingRunner {
calls: Arc<Mutex<Vec<RecordedCall>>>,
}
#[async_trait]
impl SubAgentRunner for RecordingRunner {
async fn run_task(&self, request: SubAgentRunRequest) -> Result<SubAgentResult, String> {
self.calls
.lock()
.expect("recording mutex poisoned")
.push(RecordedCall {
enabled_toolsets: request.enabled_toolsets,
max_iterations: request.max_iterations,
model_override: request.model_override,
});
Ok(SubAgentResult {
summary: "ok".into(),
api_calls: 1,
input_tokens: 10,
output_tokens: 5,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
model: Some("mock/model".into()),
interrupted: false,
budget_exhausted: false,
run_outcome: completed_outcome("ok"),
messages: Vec::new(),
})
}
}
#[test]
fn tool_metadata() {
let tool = DelegateTaskToolReal;
assert_eq!(tool.name(), "delegate_task");
assert_eq!(tool.toolset(), "delegation");
assert!(tool.is_available());
}
#[tokio::test]
async fn delegate_without_provider_returns_fallback() {
let tool = DelegateTaskToolReal;
let ctx = ToolContext::test_context(); let result = tool
.execute(
json!({ "goal": "Find all TODO comments in the codebase" }),
&ctx,
)
.await
.expect("should succeed");
assert!(result.contains("no sub-agent runner"));
assert!(result.contains("TODO comments"));
}
#[tokio::test]
async fn delegate_depth_limit_rejects() {
let tool = DelegateTaskToolReal;
let mut ctx = ToolContext::test_context();
ctx.delegate_depth = MAX_DEPTH;
let result = tool.execute(json!({ "goal": "some task" }), &ctx).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("depth limit"));
}
#[test]
#[serial_test::serial(delegation_spawn_pause)]
fn delegate_spawn_pause_rejects() {
let _pause = crate::delegation_state::SpawnPauseGuard::set(true);
let message = crate::delegation_state::spawn_blocked_message().expect("paused");
assert!(message.contains("paused"));
}
#[tokio::test]
async fn delegate_empty_goal_rejected() {
let tool = DelegateTaskToolReal;
let ctx = ToolContext::test_context();
let result = tool.execute(json!({ "goal": "" }), &ctx).await;
assert!(result.is_err());
}
#[tokio::test]
async fn delegate_legacy_task_field_works() {
let tool = DelegateTaskToolReal;
let ctx = ToolContext::test_context();
let result = tool
.execute(json!({ "task": "legacy task field" }), &ctx)
.await
.expect("should succeed");
assert!(result.contains("legacy task field"));
}
#[test]
fn filter_blocked_toolsets() {
let parent: Vec<String> = vec![
"terminal".into(),
"file".into(),
"delegation".into(),
"memory".into(),
"code_execution".into(),
"messaging".into(),
"web".into(),
];
let filtered = filter_child_toolsets(None, &parent);
assert!(filtered.contains(&"terminal".to_string()));
assert!(filtered.contains(&"file".to_string()));
assert!(filtered.contains(&"web".to_string()));
assert!(!filtered.contains(&"delegation".to_string()));
assert!(!filtered.contains(&"memory".to_string()));
assert!(!filtered.contains(&"code_execution".to_string()));
assert!(!filtered.contains(&"messaging".to_string()));
}
#[test]
fn child_system_prompt_includes_goal() {
let prompt = build_child_system_prompt("Find all tests", Some("Focus on Rust files"));
assert!(prompt.contains("Find all tests"));
assert!(prompt.contains("Focus on Rust files"));
assert!(prompt.contains("focused subagent"));
}
#[tokio::test]
async fn delegate_respects_parent_toolsets_and_blocked_child_toolsets() {
let tool = DelegateTaskToolReal;
let calls = Arc::new(Mutex::new(Vec::<RecordedCall>::new()));
let mut ctx = ToolContext::test_context();
ctx.config.parent_active_toolsets = vec![
"terminal".into(),
"file".into(),
"web".into(),
"code_execution".into(),
"messaging".into(),
];
ctx.sub_agent_runner = Some(Arc::new(RecordingRunner {
calls: calls.clone(),
}));
let _ = tool
.execute(
json!({
"goal": "run task",
"toolsets": ["terminal", "code_execution", "messaging", "web", "unknown"]
}),
&ctx,
)
.await
.expect("delegate_task should succeed");
let calls = calls.lock().expect("recording mutex poisoned");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].enabled_toolsets, vec!["terminal", "web"]);
}
#[tokio::test]
async fn delegate_uses_config_defaults_for_iterations_and_model_override() {
let tool = DelegateTaskToolReal;
let calls = Arc::new(Mutex::new(Vec::<RecordedCall>::new()));
let mut ctx = ToolContext::test_context();
ctx.config.delegation_max_iterations = 17;
ctx.config.delegation_model = Some("gpt-4.1-mini".into());
ctx.config.delegation_provider = Some("copilot".into());
ctx.sub_agent_runner = Some(Arc::new(RecordingRunner {
calls: calls.clone(),
}));
let _ = tool
.execute(json!({ "goal": "config defaults" }), &ctx)
.await
.expect("delegate_task should succeed");
let calls = calls.lock().expect("recording mutex poisoned");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].max_iterations, 17);
assert_eq!(
calls[0].model_override.as_deref(),
Some("copilot/gpt-4.1-mini")
);
}
#[tokio::test]
async fn delegate_respects_configured_max_subagents() {
let tool = DelegateTaskToolReal;
let calls = Arc::new(Mutex::new(Vec::<RecordedCall>::new()));
let mut ctx = ToolContext::test_context();
ctx.config.delegation_max_subagents = 1;
ctx.sub_agent_runner = Some(Arc::new(RecordingRunner {
calls: calls.clone(),
}));
let result = tool
.execute(
json!({
"tasks": [
{ "goal": "task 1" },
{ "goal": "task 2" }
]
}),
&ctx,
)
.await
.expect("delegate_task should succeed");
let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid json");
let results = parsed
.get("results")
.and_then(|v| v.as_array())
.expect("results array");
assert_eq!(results.len(), 1);
let calls = calls.lock().expect("recording mutex poisoned");
assert_eq!(calls.len(), 1);
}
#[tokio::test]
async fn delegate_rejects_when_disabled_in_config() {
let tool = DelegateTaskToolReal;
let mut ctx = ToolContext::test_context();
ctx.config.delegation_enabled = false;
let err = tool
.execute(json!({ "goal": "should fail" }), &ctx)
.await
.expect_err("delegation should be disabled");
assert!(err.to_string().contains("disabled"));
}
#[tokio::test]
async fn delegate_includes_observability_fields() {
#[derive(Clone)]
struct ObservabilityRunner;
#[async_trait]
impl SubAgentRunner for ObservabilityRunner {
async fn run_task(
&self,
_request: SubAgentRunRequest,
) -> Result<SubAgentResult, String> {
Ok(SubAgentResult {
summary: "done".into(),
api_calls: 2,
input_tokens: 42,
output_tokens: 7,
cache_read_tokens: 11,
cache_write_tokens: 3,
reasoning_tokens: 5,
model: Some("test/model".into()),
interrupted: false,
budget_exhausted: false,
run_outcome: completed_outcome("done"),
messages: vec![
Message::assistant_with_tool_calls(
"",
vec![lingshu_types::ToolCall {
id: "tc_1".into(),
r#type: "function".into(),
function: lingshu_types::FunctionCall {
name: "terminal".into(),
arguments: r#"{"command":"pwd"}"#.into(),
},
thought_signature: None,
}],
),
Message::tool_result("tc_1", "terminal", "ok"),
],
})
}
}
let tool = DelegateTaskToolReal;
let mut ctx = ToolContext::test_context();
ctx.sub_agent_runner = Some(Arc::new(ObservabilityRunner));
let result = tool
.execute(json!({ "goal": "collect metadata" }), &ctx)
.await
.expect("delegate_task should succeed");
let parsed: serde_json::Value = serde_json::from_str(&result).expect("valid json");
let entry = &parsed["results"][0];
assert_eq!(entry["model"], "test/model");
assert_eq!(entry["exit_reason"], "model_returned_final_text");
assert_eq!(entry["tokens"]["input"], 42);
assert_eq!(entry["tokens"]["output"], 7);
assert_eq!(entry["tokens"]["cache_read"], 11);
assert_eq!(entry["tokens"]["cache_write"], 3);
assert_eq!(entry["tokens"]["reasoning"], 5);
assert_eq!(entry["tokens"]["prompt"], 56);
assert_eq!(entry["tokens"]["total"], 68);
assert_eq!(entry["tool_trace"][0]["tool"], "terminal");
assert_eq!(entry["tool_trace"][0]["status"], "ok");
}
#[tokio::test]
async fn delegate_rejects_empty_goal_in_batch_task() {
let tool = DelegateTaskToolReal;
let ctx = ToolContext::test_context();
let err = tool
.execute(
json!({
"tasks": [
{ "goal": "valid" },
{ "goal": " " }
]
}),
&ctx,
)
.await
.expect_err("empty batch goal should fail");
assert!(err.to_string().contains("Task 1"));
}
#[tokio::test]
async fn delegate_emits_progress_events() {
#[derive(Clone)]
struct ProgressRunner;
#[async_trait]
impl SubAgentRunner for ProgressRunner {
async fn run_task(
&self,
request: SubAgentRunRequest,
) -> Result<SubAgentResult, String> {
if let Some(tx) = request.progress_tx {
let _ = tx.send(DelegationEvent::ToolCalled {
task_index: request.task_index,
task_count: request.task_count,
tool_name: "terminal".into(),
args_json: r#"{"command":"pwd"}"#.into(),
});
}
Ok(SubAgentResult {
summary: "done".into(),
api_calls: 1,
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
model: Some("mock/model".into()),
interrupted: false,
budget_exhausted: false,
run_outcome: completed_outcome("done"),
messages: Vec::new(),
})
}
}
let tool = DelegateTaskToolReal;
let mut ctx = ToolContext::test_context();
let (progress_tx, mut progress_rx) =
tokio::sync::mpsc::unbounded_channel::<DelegationEvent>();
ctx.delegation_event_tx = Some(progress_tx);
ctx.sub_agent_runner = Some(Arc::new(ProgressRunner));
tool.execute(json!({ "goal": "inspect repo" }), &ctx)
.await
.expect("delegate_task should succeed");
let first = progress_rx.recv().await.expect("start event");
let second = progress_rx.recv().await.expect("tool event");
let third = progress_rx.recv().await.expect("finish event");
match first {
DelegationEvent::TaskStarted {
task_index,
task_count,
goal,
depth,
agent_id,
parent_id,
} => {
assert_eq!(task_index, 0);
assert_eq!(task_count, 1);
assert_eq!(goal, "inspect repo");
assert_eq!(depth, 1);
assert_eq!(agent_id, "sa-0");
assert_eq!(parent_id, None);
}
other => panic!("unexpected first event: {other:?}"),
}
match second {
DelegationEvent::ToolCalled {
task_index,
task_count,
tool_name,
..
} => {
assert_eq!(task_index, 0);
assert_eq!(task_count, 1);
assert_eq!(tool_name, "terminal");
}
other => panic!("unexpected second event: {other:?}"),
}
match third {
DelegationEvent::TaskFinished {
task_index,
task_count,
status,
..
} => {
assert_eq!(task_index, 0);
assert_eq!(task_count, 1);
assert_eq!(status, "completed");
}
other => panic!("unexpected third event: {other:?}"),
}
}
}