use std::sync::Arc; use async_trait::async_trait; use serde_json::{Value, json}; use crate::agent::{AgentCoordinator, ExecutionMode, SubAgentConfig, SubAgentManager, TaskStatus}; use crate::tools::traits::{Tool, ToolExecutionContext, ToolOutput, ToolResult}; pub struct DelegateTool { sub_agent_manager: Arc, coordinator: Option>, } /// Per-run schema view for a child Agent. Authorization still happens in the /// manager from ToolExecutionContext; this wrapper keeps the model-visible /// target enum aligned with that Agent's configured outgoing edges. pub(crate) struct ScopedDelegateTool { inner: Arc, targets: Vec, } impl ScopedDelegateTool { pub(crate) fn new(inner: Arc, targets: Vec) -> Self { Self { inner, targets } } } #[async_trait] impl Tool for ScopedDelegateTool { fn name(&self) -> &str { self.inner.name() } fn description(&self) -> &str { self.inner.description() } fn parameters_schema(&self) -> Value { let mut schema = self.inner.parameters_schema(); schema["properties"]["target"]["enum"] = json!(self.targets); schema["properties"]["tasks"]["items"]["properties"]["target"]["enum"] = json!(self.targets); schema } fn read_only(&self) -> bool { self.inner.read_only() } fn runtime_injected(&self) -> bool { true } async fn execute(&self, args: Value) -> anyhow::Result { self.inner.execute(args).await } async fn execute_with_context( &self, context: &ToolExecutionContext, args: Value, ) -> anyhow::Result { self.inner.execute_with_context(context, args).await } } impl DelegateTool { pub fn new(sub_agent_manager: Arc) -> Self { Self { sub_agent_manager, coordinator: None, } } pub fn with_coordinator(mut self, coordinator: Arc) -> Self { self.coordinator = Some(coordinator); self } fn task_schema(&self) -> Value { let targets: Vec<_> = self .sub_agent_manager .catalog() .root_targets() .into_iter() .map(|definition| definition.id.clone()) .collect(); let mut target = json!({ "type": "string", "description": "目标具名 Agent ID(来自 agent_orchestration 的 agents 目录)" }); if !targets.is_empty() { target["enum"] = json!(targets); } json!({ "type": "object", "properties": { "target": target, "task": { "type": "string", "description": "明确、独立、可验收的子任务" }, "context": { "type": "string", "description": "完成任务所需的显式上下文;不会继承主会话历史" }, "plan_item_id": { "type": "string", "description": "可选的当前计划子项 ID" } }, "required": ["task"] }) } fn parse_config(&self, value: &Value, mode: ExecutionMode) -> anyhow::Result { let prompt = value .get("task") .or_else(|| value.get("prompt")) .and_then(Value::as_str) .ok_or_else(|| anyhow::anyhow!("missing required parameter: task"))? .trim() .to_string(); if prompt.is_empty() { anyhow::bail!("task must not be empty"); } let allowed_tools = value .get("allowed_tools") .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(Value::as_str) .map(str::to_string) .collect() }); Ok(SubAgentConfig { target: value .get("target") .and_then(Value::as_str) .map(str::to_string), prompt, context: value .get("context") .and_then(Value::as_str) .map(str::to_string), mode, allowed_tools, max_iterations: value .get("max_iterations") .and_then(Value::as_u64) .map(|v| v as usize), timeout_secs: value.get("timeout_secs").and_then(Value::as_u64), plan_item_id: value .get("plan_item_id") .and_then(Value::as_str) .map(str::to_string), session_id: None, }) } async fn handle_run( &self, args: &Value, context: &ToolExecutionContext, ) -> anyhow::Result { let requested_mode = match args .get("mode") .and_then(Value::as_str) .unwrap_or("foreground") { "foreground" => ExecutionMode::Foreground, "background" => ExecutionMode::Background, other => { return Ok(failure(format!( "unknown mode '{other}'; supported modes are foreground and background" ))); } }; let background_downgraded = requested_mode == ExecutionMode::Background && context.execution_origin.is_scheduled(); let mode = if background_downgraded { ExecutionMode::Foreground } else { requested_mode }; let task_values: Vec<&Value> = match args.get("tasks").and_then(Value::as_array) { Some(tasks) if !tasks.is_empty() => tasks.iter().collect(), Some(_) => return Ok(failure("tasks must not be empty")), None => vec![args], }; let mut configs = Vec::with_capacity(task_values.len()); for task in task_values { let mut config = self.parse_config(task, mode.clone())?; if config.target.is_none() { config.target = args .get("target") .and_then(Value::as_str) .map(str::to_string); } if config.context.is_none() { config.context = args .get("context") .and_then(Value::as_str) .map(str::to_string); } if config.allowed_tools.is_none() { config.allowed_tools = args.get("allowed_tools") .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(Value::as_str) .map(str::to_string) .collect() }); } config.max_iterations = config.max_iterations.or_else(|| { args.get("max_iterations") .and_then(Value::as_u64) .map(|v| v as usize) }); config.timeout_secs = config .timeout_secs .or_else(|| args.get("timeout_secs").and_then(Value::as_u64)); config.session_id = context .agent .as_ref() .map(|agent| agent.root_session_id.clone()) .or_else(|| context.session_id.clone()); configs.push(config); } match mode { ExecutionMode::Foreground => { let Some(coordinator) = self.coordinator.as_ref() else { return Ok(failure( "delegate requires agent_orchestration to be enabled (named Agents only)", )); }; let results = coordinator .delegate_foreground(context, configs) .await .map_err(|error| anyhow::anyhow!(error.to_string()))?; let payload: Vec<_> = results .into_iter() .map(|result| { let (status, error) = status_projection(&result.status); json!({ "run_id": result.task_id, "status": status, "result": result.content, "result_truncated": result.content_truncated, "error": error, "tool_calls": result.tool_calls_count, "iterations": result.iterations, "duration_ms": result.duration_ms }) }) .collect(); let all_completed = payload.iter().all(|value| value["status"] == "completed"); Ok(ToolResult { success: all_completed, output: serde_json::to_string(&json!({ "status": if all_completed { "completed" } else { "partial" }, "background_downgraded": background_downgraded, "results": payload }))?, error: None, }) } ExecutionMode::Background => { if context.agent.is_some() { return Ok(failure( "child Agents cannot create background runs in the current implementation", )); } let Some(coordinator) = self.coordinator.as_ref() else { return Ok(failure( "delegate requires agent_orchestration to be enabled (named Agents only)", )); }; match coordinator.delegate_background(context, configs).await { Ok(admission) => { let runs: Vec<_> = admission .run_ids .into_iter() .map(|run_id| json!({ "run_id": run_id, "status": "queued" })) .collect(); Ok(success(json!({ "status": "accepted", "runs": runs }))) } Err(error) => Ok(failure(error.to_string())), } } } } } #[async_trait] impl Tool for DelegateTool { fn name(&self) -> &str { "delegate" } fn description(&self) -> &str { "Delegate one or more independent tasks to configured Agents. foreground waits for all results; background returns accepted run IDs. Multiple tasks execute concurrently." } fn parameters_schema(&self) -> Value { json!({ "type": "object", "properties": { "target": self.task_schema()["properties"]["target"].clone(), "task": { "type": "string", "description": "Single delegated task" }, "context": { "type": "string", "description": "Explicit context for the child Agent" }, "mode": { "type": "string", "enum": ["foreground", "background"], "description": "foreground waits; background returns after acceptance" }, "tasks": { "type": "array", "minItems": 1, "items": self.task_schema(), "description": "Independent tasks; execution is concurrent and results preserve request order" }, "plan_item_id": { "type": "string" }, "allowed_tools": { "type": "array", "items": { "type": "string" }, "description": "Only narrows the target Definition's tool set; never expands permissions" }, "max_iterations": { "type": "integer", "minimum": 1 }, "timeout_secs": { "type": "integer", "minimum": 1 } }, "anyOf": [ { "required": ["task"] }, { "required": ["tasks"] } ] }) } fn read_only(&self) -> bool { false } fn runtime_injected(&self) -> bool { true } async fn execute(&self, args: Value) -> anyhow::Result { self.execute_with_context(&ToolExecutionContext::default(), args) .await .map(|output| output.result) } async fn execute_with_context( &self, context: &ToolExecutionContext, args: Value, ) -> anyhow::Result { self.handle_run(&args, context).await.map(Into::into) } } fn status_projection(status: &TaskStatus) -> (&'static str, Option<&str>) { match status { TaskStatus::Completed => ("completed", None), TaskStatus::Failed(error) => ("failed", Some(error.as_str())), TaskStatus::Cancelled => ("cancelled", None), TaskStatus::TimedOut => ("timed_out", None), } } fn success(value: Value) -> ToolResult { ToolResult { success: true, output: value.to_string(), error: None, } } fn failure(error: impl Into) -> ToolResult { ToolResult { success: false, output: String::new(), error: Some(error.into()), } } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; fn manager() -> Arc { Arc::new(SubAgentManager::new( crate::config::LLMProviderConfig { provider_type: "openai".to_string(), name: "test".to_string(), base_url: "https://example.invalid/v1".to_string(), api_key: "test".to_string(), extra_headers: HashMap::new(), model_id: "test-model".to_string(), temperature: None, max_tokens: None, model_extra: HashMap::new(), max_tool_iterations: 1, token_limit: 4096, workspace_dir: std::env::temp_dir(), input_types: vec!["text".to_string()], price_input_per_million: None, price_output_per_million: None, }, Arc::new(crate::tools::ToolRegistry::new()), None, None, )) } #[test] fn schema_exposes_only_canonical_lifecycle_modes() { let schema = DelegateTool::new(manager()).parameters_schema(); assert_eq!( schema["properties"]["mode"]["enum"], json!(["foreground", "background"]) ); } #[test] fn scoped_schema_replaces_root_targets_for_child() { let inner: Arc = Arc::new(DelegateTool::new(manager())); let scoped = ScopedDelegateTool::new(inner, vec!["reviewer".to_string()]); let schema = scoped.parameters_schema(); assert_eq!(schema["properties"]["target"]["enum"], json!(["reviewer"])); assert_eq!( schema["properties"]["tasks"]["items"]["properties"]["target"]["enum"], json!(["reviewer"]) ); } }