use std::collections::HashSet; use std::sync::Arc; use std::time::Instant; use dashmap::DashMap; use tokio::sync::Semaphore; use tokio_util::sync::CancellationToken; use uuid::Uuid; use crate::agent::AgentError; use crate::agent::AgentLoop; use crate::agent::system_prompt::build_sub_agent_system_prompt; use crate::bus::ChatMessage; use crate::config::LLMProviderConfig; use crate::providers::{LLMProvider, create_provider}; use crate::skills::SkillsLoader; use crate::tools::{ToolExecutionContext, ToolRegistry}; tokio::task_local! { pub(crate) static DELEGATE_CONTEXT: DelegateContext; } /// Read the delegate context from the current task. Returns an error if not set. pub fn get_delegate_context() -> Result { DELEGATE_CONTEXT .try_with(|ctx| ctx.clone()) .map_err(|_| "DELEGATE_CONTEXT not set".to_string()) } const DEFAULT_MAX_ITERATIONS: usize = 99; const DEFAULT_TIMEOUT_SECS: u64 = 3600; const MAX_INLINE_RESULT_CHARS: usize = 8000; const DEFAULT_READONLY_TOOLS: &[&str] = &[ "file_read", "file_search", "content_search", "web_fetch", "http_request", "calculator", ]; #[derive(Debug, Clone)] pub struct SubAgentConfig { pub target: Option, pub prompt: String, pub context: Option, pub mode: ExecutionMode, pub allowed_tools: Option>, pub max_iterations: Option, pub timeout_secs: Option, pub plan_item_id: Option, pub session_id: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ExecutionMode { Foreground, Background, } #[derive(Debug, Clone)] pub struct SubAgentResult { pub task_id: String, /// Bounded projection returned to the model; may carry a truncation note. pub content: String, pub content_truncated: bool, /// Untruncated final text, persisted as the durable run result. pub full_content: String, pub status: TaskStatus, pub tool_calls_count: usize, pub iterations: usize, pub duration_ms: u64, } #[derive(Debug, Clone)] pub enum TaskStatus { Completed, Failed(String), Cancelled, TimedOut, } #[derive(Debug, Clone)] pub struct TaskNotification { pub task_id: String, pub session_id: String, pub channel: String, pub chat_id: String, pub status: TaskStatus, pub result_summary: String, } #[derive(Debug, Clone)] pub struct DelegateContext { pub session_id: String, pub channel: String, pub chat_id: String, } #[derive(Debug)] pub enum SubAgentError { TooManyTasks(usize), ProviderCreation(String), Storage(String), Other(String), } impl std::fmt::Display for SubAgentError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::TooManyTasks(max) => write!(f, "后台任务已达上限({}),请稍后重试", max), Self::ProviderCreation(e) => write!(f, "provider creation failed: {}", e), Self::Storage(e) => write!(f, "storage error: {}", e), Self::Other(e) => write!(f, "{}", e), } } } impl std::error::Error for SubAgentError {} pub struct SubAgentManager { provider_config: LLMProviderConfig, full_tools: Arc, storage: Option>, active_tasks: Arc>, background_permits: Arc, notify_tx: tokio::sync::mpsc::UnboundedSender, max_concurrent_background_tasks: usize, skills_loader: Option>, work_manager: Option>, task_supervisor: crate::task_supervisor::TaskSupervisor, admission: crate::gateway::reload::RuntimeAdmission, catalog: Arc, execution_gate: Arc, /// Late-bound durable Coordinator. Signals are only available to runs /// whose definition carries a signal contract AND the runtime has an /// active Coordinator; resolution happens at delegate time. coordinator: std::sync::RwLock>>, } #[derive(Clone)] pub(crate) struct ResolvedAgentRun { pub provider_config: Arc, pub tools: Arc, pub timeout_secs: u64, pub max_iterations: usize, pub max_result_chars: usize, pub role_prompt: Option, pub skills_prompt: Option, pub tool_context: ToolExecutionContext, /// Named-definition metadata used by the durable Coordinator; `None` for /// the legacy transient general Agent. pub agent_id: Option, pub definition_hash: Option, pub llm_profile: Option, /// Durable signal contract of the definition; `None` means the run can /// never emit signals and must not persist contract state. pub signal_contract: Option, } impl SubAgentManager { pub fn new( provider_config: LLMProviderConfig, full_tools: Arc, storage: Option>, notify_tx: tokio::sync::mpsc::UnboundedSender, max_concurrent_background_tasks: usize, skills_loader: Option>, task_supervisor: crate::task_supervisor::TaskSupervisor, ) -> Self { Self { provider_config, full_tools, storage, active_tasks: Arc::new(DashMap::new()), background_permits: Arc::new(Semaphore::new(max_concurrent_background_tasks)), notify_tx, max_concurrent_background_tasks, skills_loader, work_manager: None, task_supervisor, admission: crate::gateway::reload::RuntimeAdmission::open(), catalog: Arc::new(crate::agent::AgentCatalog::legacy()), execution_gate: crate::agent::gate::ExecutionGate::unbounded(), coordinator: std::sync::RwLock::new(None), } } /// Bind the durable Coordinator so named runs can resolve the /// contract-bound `emit_signal` tool. Kept as a weak reference: the /// Coordinator owns this manager, so a strong cycle must never exist. pub fn bind_coordinator(&self, coordinator: &Arc) { *self.coordinator.write().unwrap() = Some(Arc::downgrade(coordinator)); } fn coordinator(&self) -> Option> { self.coordinator .read() .unwrap() .as_ref() .and_then(std::sync::Weak::upgrade) } pub(crate) fn with_admission( mut self, admission: crate::gateway::reload::RuntimeAdmission, ) -> Self { self.admission = admission; self } pub fn with_catalog(mut self, catalog: Arc) -> Self { self.catalog = catalog; self } pub fn with_execution_gate( mut self, execution_gate: Arc, ) -> Self { self.execution_gate = execution_gate; self } pub fn catalog(&self) -> Arc { self.catalog.clone() } pub fn with_work_manager(mut self, work_manager: Arc) -> Self { self.work_manager = Some(work_manager); self } pub fn filter_tools(&self, allowed: &Option>) -> Arc { let allowed_set: HashSet<&str> = match allowed { Some(list) => list.iter().map(|s| s.as_str()).collect(), None => DEFAULT_READONLY_TOOLS.iter().copied().collect(), }; let filtered = ToolRegistry::new(); for (name, tool) in self.full_tools.iter() { if allowed_set.contains(name.as_str()) && tool.delegation_policy() == crate::tools::DelegationPolicy::Delegatable { filtered.register_raw(name, tool); } } Arc::new(filtered) } fn get_skills_prompt(&self, tools: &ToolRegistry) -> Option { let has_get_skill = tools.iter().iter().any(|(name, _)| name == "get_skill"); if has_get_skill && let Some(ref loader) = self.skills_loader { let prompt = loader.build_skills_prompt(); if !prompt.is_empty() { return Some(prompt); } } None } pub(crate) fn resolve_agent( &self, config: &SubAgentConfig, caller: &ToolExecutionContext, task_id: &str, ) -> Result { let Some(target) = config.target.as_deref() else { if caller.agent.is_some() { return Err(SubAgentError::Other( "named child Agents cannot use the legacy general Agent".to_string(), )); } let browser_session_id = config .session_id .clone() .or_else(|| caller.session_id.clone()) .or_else(|| { get_delegate_context() .ok() .map(|context| context.session_id) }) .unwrap_or_else(|| format!("sub-agent:{task_id}")); let tools = self.filter_tools(&config.allowed_tools); return Ok(ResolvedAgentRun { provider_config: Arc::new(self.provider_config.clone()), skills_prompt: self.get_skills_prompt(&tools), tools, timeout_secs: config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS), max_iterations: config.max_iterations.unwrap_or(DEFAULT_MAX_ITERATIONS), max_result_chars: MAX_INLINE_RESULT_CHARS, role_prompt: None, tool_context: ToolExecutionContext::for_session(browser_session_id) .with_cancellation(caller.cancellation.child_token()) .with_execution_gate(self.execution_gate.clone()), agent_id: None, definition_hash: None, llm_profile: None, signal_contract: None, }); }; if !self.catalog.enabled() { return Err(SubAgentError::Other(format!( "named Agent '{target}' requested while agent_orchestration is disabled" ))); } let definition = self .catalog .get(target) .ok_or_else(|| SubAgentError::Other(format!("unknown Agent target '{target}'")))?; let root_session_id = caller .agent .as_ref() .map(|context| context.root_session_id.clone()) .or_else(|| caller.session_id.clone()) .ok_or_else(|| { SubAgentError::Other( "delegate requires a session-bound ToolExecutionContext".to_string(), ) })?; let cancellation = caller.cancellation.child_token(); let execution = if let Some(parent) = caller.agent.as_ref() { if !self.catalog.can_delegate(&parent.current_agent_id, target) { return Err(SubAgentError::Other(format!( "Agent '{}' is not allowed to delegate to '{target}'", parent.current_agent_id ))); } if parent.ancestry.iter().any(|agent| agent == target) { return Err(SubAgentError::Other(format!( "delegation cycle rejected: '{target}' is already in the current ancestry" ))); } if parent.budget.remaining_runs == 0 || parent.budget.remaining_depth == 0 { return Err(SubAgentError::Other( "delegation budget exhausted".to_string(), )); } let next_depth = parent.depth.saturating_add(1); if next_depth > self.catalog.max_tree_depth() { return Err(SubAgentError::Other(format!( "delegation depth {next_depth} exceeds global limit {}", self.catalog.max_tree_depth() ))); } if parent .reserve_tree_run(self.catalog.max_runs_per_tree()) .is_none() { return Err(SubAgentError::Other(format!( "delegation tree already uses {} runs; max_runs_per_tree is {}", self.catalog.max_runs_per_tree(), self.catalog.max_runs_per_tree() ))); } let mut child = crate::agent::AgentExecutionContext::child( parent, task_id.to_string(), target.to_string(), config.plan_item_id.clone(), cancellation.clone(), ); child.budget.remaining_depth = child .budget .remaining_depth .min(definition.limits.max_depth); child.signal_contract = definition .signal_contract .as_ref() .map(|contract| Arc::new(contract.clone())); Arc::new(child) } else { if !self.catalog.root_can_delegate(target) { return Err(SubAgentError::Other(format!( "ROOT is not allowed to delegate to '{target}'" ))); } Arc::new(crate::agent::AgentExecutionContext { root_session_id: root_session_id.clone(), root_turn_id: caller.turn_id.clone(), run_id: task_id.to_string(), execution_id: task_id.to_string(), group_id: None, parent_run_id: None, caller_agent_id: "ROOT".to_string(), current_agent_id: target.to_string(), ancestry: vec![target.to_string()], depth: 1, plan_item_id: config.plan_item_id.clone(), cancellation: cancellation.clone(), budget: crate::agent::AgentBudget { remaining_runs: self.catalog.max_runs_per_tree().saturating_sub(1), remaining_depth: self .catalog .max_tree_depth() .saturating_sub(1) .min(definition.limits.max_depth), }, tree_runs: Arc::new(std::sync::atomic::AtomicUsize::new(1)), signal_contract: definition .signal_contract .as_ref() .map(|contract| Arc::new(contract.clone())), emitted_signals: Arc::new(std::sync::Mutex::new(Vec::new())), }) }; let mut effective_names = definition.tools.clone(); if let Some(allowed) = config.allowed_tools.as_ref() { effective_names.retain(|name| allowed.iter().any(|allowed| allowed == name)); } let has_get_skill = effective_names.iter().any(|name| name == "get_skill"); let mut names = effective_names; names.retain(|name| name != "get_skill"); let mut runtime_tools = Vec::new(); let skills_prompt = if has_get_skill { let loader = self .skills_loader .as_ref() .ok_or_else(|| SubAgentError::Other("skills loader is unavailable".to_string()))?; runtime_tools.push(Arc::new(crate::tools::GetSkillTool::scoped( loader.clone(), &definition.skills, )) as Arc); let prompt = loader.build_scoped_skills_prompt(&definition.skills); (!prompt.is_empty()).then_some(prompt) } else { None }; if !definition.delegates.is_empty() { let delegate = self.full_tools.get("delegate").ok_or_else(|| { SubAgentError::Other("delegate runtime tool is unavailable".to_string()) })?; runtime_tools.push(Arc::new(crate::tools::delegate::ScopedDelegateTool::new( delegate, definition.delegates.clone(), )) as Arc); } // The signal tool is contract-bound: it exists only when the // definition declares a signal block and the durable Coordinator is // live. If either is missing the run cannot emit signals. if definition.signal_contract.is_some() { match self.coordinator() { Some(coordinator) => { let contract = definition.signal_contract.clone().unwrap(); runtime_tools.push(Arc::new(crate::tools::EmitSignalTool::new( coordinator, Arc::new(contract), )) as Arc); } None => { return Err(SubAgentError::Other( "Agent '{}' declares a signal contract but the durable Coordinator is unavailable" .replace("{}", target), )); } } } let tools = self .full_tools .scoped_for_agent(&names, runtime_tools) .map_err(SubAgentError::Other)?; Ok(ResolvedAgentRun { provider_config: definition.provider_config.clone(), tools, timeout_secs: definition.limits.timeout_secs, max_iterations: definition.limits.max_iterations, max_result_chars: definition.limits.max_result_chars, role_prompt: Some(definition.role_prompt.clone()), skills_prompt, agent_id: Some(target.to_string()), definition_hash: Some(definition.definition_hash.clone()), llm_profile: Some(definition.llm_profile.clone()), signal_contract: definition.signal_contract.clone(), tool_context: ToolExecutionContext::for_session(format!("agent-run:{task_id}")) .with_turn_id( caller .turn_id .clone() .unwrap_or_else(|| task_id.to_string()), ) .with_agent(execution) .with_cancellation(cancellation) .with_execution_gate(self.execution_gate.clone()), }) } pub fn build_sub_agent( &self, config: &SubAgentConfig, tools: Arc, ) -> Result { self.build_sub_agent_with_provider(config, tools, &self.provider_config) } fn build_sub_agent_with_provider( &self, config: &SubAgentConfig, tools: Arc, provider_config: &LLMProviderConfig, ) -> Result { let mut provider = create_provider(provider_config.clone()) .map_err(|e| AgentError::ProviderCreation(e.to_string()))?; if let Some(ref s) = self.storage { provider.set_storage(s.clone()); } let provider: Arc = Arc::from(provider); let max_iterations = config.max_iterations.unwrap_or(DEFAULT_MAX_ITERATIONS); let workspace_dir = provider_config.workspace_dir.clone(); let model_name = provider_config.model_id.clone(); let input_types = provider_config.input_types.clone(); let agent = AgentLoop::with_provider_and_tools( provider, tools, max_iterations, model_name, workspace_dir, input_types, ) .with_context_window(provider_config.token_limit); Ok(agent) } pub async fn run_inline( &self, config: SubAgentConfig, ) -> Result { let mut caller = ToolExecutionContext::default(); if let Some(session_id) = config.session_id.clone() { caller.session_id = Some(session_id); } self.run_foreground(config, &caller).await } pub async fn run_foreground( &self, config: SubAgentConfig, caller: &ToolExecutionContext, ) -> Result { let task_id = generate_task_id(); let resolved = self.resolve_agent(&config, caller, &task_id)?; self.assign_work_item(&config, &task_id).await?; let result = self.execute_resolved(&config, resolved, &task_id).await?; self.finish_work_item(&config, &result).await; Ok(result) } /// Execute an already-resolved Agent without any plan-item side effects. /// The durable Coordinator owns plan admission/terminal updates itself; /// the legacy path wraps this with assign/finish hooks. pub(crate) async fn execute_resolved( &self, config: &SubAgentConfig, resolved: ResolvedAgentRun, task_id: &str, ) -> Result { let tools = resolved.tools; let timeout_secs = resolved.timeout_secs; let timeout_human = format_duration(timeout_secs); let mut system_prompt = build_sub_agent_system_prompt( &config.prompt, &timeout_human, &tools, &resolved.provider_config.workspace_dir, &resolved.provider_config.model_id, resolved.skills_prompt, ); if let Some(role_prompt) = resolved.role_prompt { system_prompt.push_str("\n\n## Agent Definition\n\n"); system_prompt.push_str(&role_prompt); } if let Some(context) = config .context .as_deref() .filter(|value| !value.trim().is_empty()) { system_prompt.push_str("\n\n## 调用方提供的任务上下文\n\n"); system_prompt.push_str(context); } let mut effective_config = config.clone(); effective_config.max_iterations = Some(resolved.max_iterations); let max_result_chars = resolved.max_result_chars; let agent = self .build_sub_agent_with_provider(&effective_config, tools, &resolved.provider_config) .map_err(|e| SubAgentError::ProviderCreation(e.to_string()))?; let history = vec![ ChatMessage::system(system_prompt), ChatMessage::user(&config.prompt), ]; let start = Instant::now(); let tool_context = resolved.tool_context; let result = tokio::select! { result = tokio::time::timeout( std::time::Duration::from_secs(timeout_secs), agent.process_with_context(history, tool_context.clone()), ) => result, _ = tool_context.cancellation.cancelled() => { return Ok(SubAgentResult { task_id: task_id.to_string(), content: String::new(), content_truncated: false, full_content: String::new(), status: TaskStatus::Cancelled, tool_calls_count: 0, iterations: 0, duration_ms: start.elapsed().as_millis() as u64, }); } }; let duration_ms = start.elapsed().as_millis() as u64; Ok(match result { Ok(Ok(agent_result)) => { let (content, truncated) = truncate_sub_agent_result_at( &agent_result.final_response.content, max_result_chars, ); let tool_calls_count = agent_result .emitted_messages .iter() .filter(|m| m.tool_calls.is_some()) .count(); let iterations = agent_result .emitted_messages .iter() .filter(|m| m.role == "assistant" && m.tool_calls.is_some()) .count(); SubAgentResult { task_id: task_id.to_string(), content, content_truncated: truncated, full_content: agent_result.final_response.content, status: TaskStatus::Completed, tool_calls_count, iterations, duration_ms, } } Ok(Err(error)) => SubAgentResult { task_id: task_id.to_string(), content: String::new(), content_truncated: false, full_content: String::new(), status: terminal_status_from_error(error), tool_calls_count: 0, iterations: 0, duration_ms, }, Err(_elapsed) => SubAgentResult { task_id: task_id.to_string(), content: String::new(), content_truncated: false, full_content: String::new(), status: TaskStatus::TimedOut, tool_calls_count: 0, iterations: 0, duration_ms, }, }) } pub async fn run_foreground_batch( &self, configs: Vec, caller: &ToolExecutionContext, ) -> Result, SubAgentError> { if configs.is_empty() { return Err(SubAgentError::Other( "foreground batch must contain at least one task".to_string(), )); } if let Some(parent) = caller.agent.as_ref() { let definition = self.catalog.get(&parent.current_agent_id).ok_or_else(|| { SubAgentError::Other(format!( "caller Agent '{}' is not present in the active catalog", parent.current_agent_id )) })?; if configs.len() > definition.limits.max_children { return Err(SubAgentError::Other(format!( "Agent '{}' may create at most {} children per delegate call", parent.current_agent_id, definition.limits.max_children ))); } if configs.len() > parent.budget.remaining_runs { return Err(SubAgentError::Other( "delegation run budget is smaller than the requested batch".to_string(), )); } if configs.len() > parent.remaining_tree_runs(self.catalog.max_runs_per_tree()) { return Err(SubAgentError::Other( "delegation tree capacity is smaller than the requested batch".to_string(), )); } } else if self.catalog.enabled() && configs.len() > self.catalog.max_runs_per_tree() { return Err(SubAgentError::Other(format!( "ROOT batch exceeds max_runs_per_tree ({})", self.catalog.max_runs_per_tree() ))); } let futures: Vec<_> = configs .into_iter() .map(|config| { let caller = caller.clone(); async move { self.run_foreground(config, &caller).await } }) .collect(); let results = futures_util::future::join_all(futures).await; Ok(results .into_iter() .enumerate() .map(|(index, result)| { result.unwrap_or_else(|error| SubAgentResult { task_id: format!("rejected-{}-{}", index + 1, generate_task_id()), content: String::new(), content_truncated: false, full_content: String::new(), status: TaskStatus::Failed(error.to_string()), tool_calls_count: 0, iterations: 0, duration_ms: 0, }) }) .collect()) } pub async fn run_background( &self, config: SubAgentConfig, ctx: DelegateContext, ) -> Result { let activity = self.admission.try_enter().ok_or_else(|| { SubAgentError::Other( "gateway is draining for configuration reload and cannot accept background tasks" .to_string(), ) })?; let permit = self .background_permits .clone() .try_acquire_owned() .map_err(|_| SubAgentError::TooManyTasks(self.max_concurrent_background_tasks))?; let task_id = generate_task_id(); let mut work_config = config.clone(); if work_config.session_id.is_none() { work_config.session_id = Some(ctx.session_id.clone()); } let cancel_token = CancellationToken::new(); // Write DB: pending if let Some(ref storage) = self.storage { let allowed_tools_json = config .allowed_tools .as_ref() .and_then(|v| serde_json::to_string(v).ok()); let record = crate::storage::BackgroundTask { id: task_id.clone(), session_id: ctx.session_id.clone(), channel: ctx.channel.clone(), chat_id: ctx.chat_id.clone(), prompt: config.prompt.clone(), allowed_tools: allowed_tools_json, status: "pending".to_string(), result: None, error: None, tool_calls_count: 0, iterations: 0, started_at: None, finished_at: None, created_at: chrono::Utc::now().timestamp_millis(), }; storage .create_background_task(&record) .await .map_err(|e| SubAgentError::Storage(e.to_string()))?; } if let Err(error) = self.assign_work_item(&work_config, &task_id).await { if let Some(ref storage) = self.storage { let _ = storage .update_background_task_status( &task_id, crate::storage::background_task::BackgroundTaskUpdate { status: "cancelled", result: None, error: Some("plan item assignment failed"), started_at: None, finished_at: Some(chrono::Utc::now().timestamp_millis()), tool_calls_count: None, iterations: None, }, ) .await; } return Err(error); } self.active_tasks .insert(task_id.clone(), cancel_token.clone()); let tools = self.filter_tools(&config.allowed_tools); let timeout_secs = config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS); let timeout_human = format_duration(timeout_secs); let skills_prompt = self.get_skills_prompt(&tools); let system_prompt = build_sub_agent_system_prompt( &config.prompt, &timeout_human, &tools, &self.provider_config.workspace_dir, &self.provider_config.model_id, skills_prompt, ); let provider_config = self.provider_config.clone(); let storage = self.storage.clone(); let notify_tx = self.notify_tx.clone(); let active_tasks = Arc::clone(&self.active_tasks); let shutdown = self.task_supervisor.cancellation_token(); let execution_gate = self.execution_gate.clone(); let tid = task_id.clone(); let sess_id = ctx.session_id.clone(); let ch = ctx.channel.clone(); let cid = ctx.chat_id.clone(); let prompt = config.prompt.clone(); let work_manager = self.work_manager.clone(); let work_item_id = work_config.plan_item_id.clone(); let work_session_id = work_config.session_id.clone(); let spawned = self.task_supervisor.spawn_graceful( format!("sub-agent:{task_id}"), async move { let _activity = activity; let _permit = permit; let started_at = chrono::Utc::now().timestamp_millis(); // Update DB: running if let Some(ref s) = storage { let _ = s .update_background_task_status(&tid, crate::storage::background_task::BackgroundTaskUpdate { status: "running", result: None, error: None, started_at: Some(started_at), finished_at: None, tool_calls_count: None, iterations: None, }) .await; } let mut provider = create_provider(provider_config.clone()).ok(); if let Some(ref mut p) = provider && let Some(ref s) = storage { p.set_storage(s.clone()); } let provider_result: Option> = provider.map(Arc::from); let result = match provider_result { Some(provider) => { let agent = AgentLoop::with_provider_and_tools( provider, tools, DEFAULT_MAX_ITERATIONS, provider_config.model_id.clone(), provider_config.workspace_dir.clone(), provider_config.input_types.clone(), ) .with_context_window(provider_config.token_limit); let history = vec![ ChatMessage::system(system_prompt), ChatMessage::user(&prompt), ]; let tool_context = ToolExecutionContext::for_session(&sess_id) .with_cancellation(cancel_token.clone()) .with_execution_gate(execution_gate.clone()); tokio::select! { r = tokio::time::timeout( std::time::Duration::from_secs(timeout_secs), agent.process_with_context(history, tool_context), ) => { match r { Ok(Ok(agent_result)) => { let tool_calls_count = agent_result.emitted_messages .iter().filter(|m| m.tool_calls.is_some()).count(); let iterations = agent_result.emitted_messages .iter().filter(|m| m.role == "assistant" && m.tool_calls.is_some()).count(); SubAgentResult { task_id: tid.clone(), content: agent_result.final_response.content.clone(), content_truncated: false, full_content: agent_result.final_response.content, status: TaskStatus::Completed, tool_calls_count, iterations, duration_ms: 0, } }, Ok(Err(error)) => SubAgentResult { task_id: tid.clone(), content: String::new(), content_truncated: false, full_content: String::new(), status: terminal_status_from_error(error), tool_calls_count: 0, iterations: 0, duration_ms: 0, }, Err(_) => SubAgentResult { task_id: tid.clone(), content: String::new(), content_truncated: false, full_content: String::new(), status: TaskStatus::TimedOut, tool_calls_count: 0, iterations: 0, duration_ms: 0, }, } } _ = cancel_token.cancelled() => SubAgentResult { task_id: tid.clone(), content: String::new(), content_truncated: false, full_content: String::new(), status: TaskStatus::Cancelled, tool_calls_count: 0, iterations: 0, duration_ms: 0, }, _ = shutdown.cancelled() => SubAgentResult { task_id: tid.clone(), content: String::new(), content_truncated: false, full_content: String::new(), status: TaskStatus::Cancelled, tool_calls_count: 0, iterations: 0, duration_ms: 0, }, } } None => SubAgentResult { task_id: tid.clone(), content: String::new(), content_truncated: false, full_content: String::new(), status: TaskStatus::Failed("provider creation failed".into()), tool_calls_count: 0, iterations: 0, duration_ms: 0, }, }; let finished_at = chrono::Utc::now().timestamp_millis(); let duration_ms = (finished_at - started_at) as u64; let (status_str, error_val) = match &result.status { TaskStatus::Completed => ("completed".to_string(), None), TaskStatus::Failed(e) => ("failed".to_string(), Some(e.clone())), TaskStatus::Cancelled => ("cancelled".to_string(), None), TaskStatus::TimedOut => ("failed".to_string(), Some("timeout".to_string())), }; if let (Some(manager), Some(session_id), Some(item_id)) = (work_manager.as_ref(), work_session_id.as_deref(), work_item_id.as_deref()) { let completed = matches!(result.status, TaskStatus::Completed); let summary = if completed { Some(result.content.as_str()) } else { error_val.as_deref().or(Some("子 Agent 未完成任务")) }; if let Err(error) = manager .finish_sub_agent(session_id, item_id, &tid, completed, summary) .await { tracing::warn!(task_id = %tid, item_id, error = %error, "Failed to update plan item"); } } if let Some(ref s) = storage { let _ = s .update_background_task_status(&tid, crate::storage::background_task::BackgroundTaskUpdate { status: &status_str, result: Some(&result.content), error: error_val.as_deref(), started_at: Some(started_at), finished_at: Some(finished_at), tool_calls_count: Some(result.tool_calls_count as i64), iterations: Some(result.iterations as i64), }) .await; } let _ = notify_tx.send(TaskNotification { task_id: tid.clone(), session_id: sess_id, channel: ch, chat_id: cid, status: result.status, result_summary: summarize_for_notification(&result.content, duration_ms), }); active_tasks.remove(&tid); }); if !spawned { self.active_tasks.remove(&task_id); if let Some(ref storage) = self.storage { let _ = storage .update_background_task_status( &task_id, crate::storage::background_task::BackgroundTaskUpdate { status: "cancelled", result: None, error: Some("gateway shutdown"), started_at: None, finished_at: Some(chrono::Utc::now().timestamp_millis()), tool_calls_count: None, iterations: None, }, ) .await; } if let (Some(manager), Some(session_id), Some(item_id)) = ( self.work_manager.as_ref(), work_config.session_id.as_deref(), work_config.plan_item_id.as_deref(), ) { let _ = manager .finish_sub_agent( session_id, item_id, &task_id, false, Some("gateway shutdown"), ) .await; } return Err(SubAgentError::Other( "gateway is shutting down and cannot accept background tasks".to_string(), )); } Ok(task_id) } pub async fn cancel_task(&self, task_id: &str) -> Result { if let Some((_, token)) = self.active_tasks.remove(task_id) { token.cancel(); if let Some(ref s) = self.storage { s.update_background_task_status( task_id, crate::storage::background_task::BackgroundTaskUpdate { status: "cancelled", result: None, error: None, started_at: None, finished_at: Some(chrono::Utc::now().timestamp_millis()), tool_calls_count: None, iterations: None, }, ) .await .map_err(|e| SubAgentError::Storage(e.to_string()))?; } Ok(true) } else if let Some(ref s) = self.storage { match s.get_background_task(task_id).await { Ok(task) => match task.status.as_str() { "pending" | "running" => { tracing::warn!(task_id, "task in DB but not in active_tasks"); Ok(false) } _ => Ok(false), }, Err(_) => Ok(false), } } else { Ok(false) } } pub async fn check_task(&self, task_id: &str) -> Option { if let Some(ref s) = self.storage { s.get_background_task(task_id).await.ok() } else { None } } pub async fn list_tasks(&self, session_id: &str) -> Vec { if let Some(ref s) = self.storage { s.list_background_tasks(session_id) .await .unwrap_or_default() } else { vec![] } } pub async fn cancel_by_session(&self, session_id: &str) { // Cancel all running tasks for a session by checking DB if let Some(ref s) = self.storage && let Ok(tasks) = s.list_background_tasks(session_id).await { for task in &tasks { if task.status == "pending" || task.status == "running" { let _ = self.cancel_task(&task.id).await; } } } } pub fn active_task_count(&self) -> usize { self.active_tasks.len() } async fn assign_work_item( &self, config: &SubAgentConfig, task_id: &str, ) -> Result<(), SubAgentError> { if let (Some(manager), Some(session_id), Some(item_id)) = ( self.work_manager.as_ref(), config.session_id.as_deref(), config.plan_item_id.as_deref(), ) { manager .assign_sub_agent(session_id, item_id, task_id) .await .map_err(|error| SubAgentError::Storage(error.to_string()))?; } Ok(()) } async fn finish_work_item(&self, config: &SubAgentConfig, result: &SubAgentResult) { let (Some(manager), Some(session_id), Some(item_id)) = ( self.work_manager.as_ref(), config.session_id.as_deref(), config.plan_item_id.as_deref(), ) else { return; }; let completed = matches!(result.status, TaskStatus::Completed); let summary = if completed { Some(result.content.as_str()) } else { match &result.status { TaskStatus::Failed(error) => Some(error.as_str()), TaskStatus::TimedOut => Some("子 Agent 执行超时"), TaskStatus::Cancelled => Some("子 Agent 已取消"), TaskStatus::Completed => None, } }; if let Err(error) = manager .finish_sub_agent(session_id, item_id, &result.task_id, completed, summary) .await { tracing::warn!(task_id = %result.task_id, item_id, error = %error, "Failed to update plan item"); } } } fn terminal_status_from_error(error: AgentError) -> TaskStatus { match error { AgentError::Cancelled => TaskStatus::Cancelled, AgentError::TimedOut => TaskStatus::TimedOut, other => TaskStatus::Failed(other.to_string()), } } fn generate_task_id() -> String { Uuid::new_v4().to_string() } fn format_duration(seconds: u64) -> String { if seconds < 60 { format!("{}s", seconds) } else if seconds < 3600 { format!("{}m", seconds / 60) } else { format!("{}h", seconds / 3600) } } fn truncate_sub_agent_result_at(content: &str, max_chars: usize) -> (String, bool) { if content.len() <= max_chars { (content.to_string(), false) } else { let truncate_at = content.floor_char_boundary(max_chars); ( format!( "{}\n\n[... 结果已截断,共 {} 字符,完整结果请使用 check_task 查看 ...]", &content[..truncate_at], content.len() ), true, ) } } fn summarize_for_notification(content: &str, _duration_ms: u64) -> String { const MAX_SUMMARY_BYTES: usize = 500; if content.len() <= MAX_SUMMARY_BYTES { content.to_string() } else { let truncate_at = content.floor_char_boundary(MAX_SUMMARY_BYTES); format!("{}...", &content[..truncate_at]) } } #[cfg(test)] mod tests { use super::*; use std::collections::HashMap; fn manager(max_tasks: usize) -> SubAgentManager { let (notify_tx, _notify_rx) = tokio::sync::mpsc::unbounded_channel(); SubAgentManager::new( LLMProviderConfig { provider_type: "openai".into(), name: "test".into(), base_url: "http://localhost".into(), api_key: "test".into(), extra_headers: HashMap::new(), model_id: "test".into(), 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".into()], price_input_per_million: None, price_output_per_million: None, }, Arc::new(ToolRegistry::new()), None, notify_tx, max_tasks, None, crate::task_supervisor::TaskSupervisor::new(), ) } #[tokio::test] async fn background_limit_is_enforced_by_atomic_permit() { let manager = manager(1); let _permit = manager .background_permits .clone() .try_acquire_owned() .unwrap(); let error = manager .run_background( SubAgentConfig { target: None, prompt: "test".into(), context: None, mode: ExecutionMode::Background, allowed_tools: None, max_iterations: None, timeout_secs: Some(1), plan_item_id: None, session_id: None, }, DelegateContext { session_id: "cli:test:dialog".into(), channel: "cli".into(), chat_id: "test".into(), }, ) .await .unwrap_err(); assert!(matches!(error, SubAgentError::TooManyTasks(1))); } #[test] fn reload_tool_is_never_delegated_to_sub_agents() { let manager = manager(1); manager .full_tools .register(crate::tools::ReloadConfigTool::new( crate::gateway::reload::ReloadHandle::unavailable(), )); let filtered = manager.filter_tools(&Some(vec!["reload_config".to_string()])); assert!(filtered.get("reload_config").is_none()); } fn catalog_with_agents( root: &std::path::Path, max_runs_per_tree: usize, ) -> crate::agent::AgentCatalog { std::fs::create_dir_all(root.join("agents")).unwrap(); let write = |id: &str, delegates: &[&str]| { let delegates = (!delegates.is_empty()).then(|| { format!( "delegates:\n{}\n", delegates .iter() .map(|name| format!(" - {name}")) .collect::>() .join("\n") ) }); std::fs::write( root.join("agents").join(format!("{id}.md")), format!( "---\nid: {id}\ndescription: {id} role\nllm_profile: research\n{}---\n# Role\n\nDo the assigned work.\n", delegates.unwrap_or_default() ), ) .unwrap(); }; write("researcher", &["reviewer"]); write("reviewer", &[]); let tools = ToolRegistry::new(); let loader = crate::skills::SkillsLoader::new_for_testing( root.join("skills"), root.join("external-skills"), ); let profiles = HashMap::from([( "research".to_string(), LLMProviderConfig { provider_type: "openai".into(), name: "test".into(), base_url: "http://localhost".into(), api_key: "test".into(), extra_headers: HashMap::new(), model_id: "test".into(), 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".into()], price_input_per_million: None, price_output_per_million: None, }, )]); let config = crate::config::AgentOrchestrationConfig { enabled: true, definitions_dir: "agents".to_string(), root_delegates: vec!["researcher".to_string()], max_runs_per_tree, ..Default::default() }; crate::agent::AgentCatalog::load(&config, root, &profiles, &tools, &loader, 1).unwrap() } #[tokio::test] async fn foreground_batch_rejects_when_tree_capacity_exhausted() { let root = tempfile::tempdir().unwrap(); let catalog = catalog_with_agents(root.path(), 2); let manager = manager(1).with_catalog(Arc::new(catalog)); let caller_context = Arc::new(crate::agent::AgentExecutionContext { root_session_id: "cli:test:dialog".to_string(), root_turn_id: None, run_id: "run-root".to_string(), execution_id: "run-root".to_string(), group_id: None, parent_run_id: None, caller_agent_id: "ROOT".to_string(), current_agent_id: "researcher".to_string(), ancestry: vec!["researcher".to_string()], depth: 1, plan_item_id: None, cancellation: CancellationToken::new(), budget: crate::agent::AgentBudget { remaining_runs: 15, remaining_depth: 3, }, tree_runs: Arc::new(std::sync::atomic::AtomicUsize::new(2)), signal_contract: None, emitted_signals: Arc::new(std::sync::Mutex::new(Vec::new())), }); let caller = ToolExecutionContext::for_session("cli:test:dialog").with_agent(caller_context); let error = manager .run_foreground_batch( vec![SubAgentConfig { target: Some("reviewer".to_string()), prompt: "test".to_string(), context: None, mode: ExecutionMode::Foreground, allowed_tools: None, max_iterations: None, timeout_secs: None, plan_item_id: None, session_id: Some("cli:test:dialog".to_string()), }], &caller, ) .await .unwrap_err(); assert!( matches!(error, SubAgentError::Other(message) if message.contains("tree capacity")) ); } #[tokio::test] async fn foreground_batch_preserves_per_task_rejections() { let manager = manager(1); let config = |target: &str| SubAgentConfig { target: Some(target.to_string()), prompt: "test".to_string(), context: None, mode: ExecutionMode::Foreground, allowed_tools: None, max_iterations: None, timeout_secs: None, plan_item_id: None, session_id: Some("cli:test:dialog".to_string()), }; let results = manager .run_foreground_batch( vec![config("missing-a"), config("missing-b")], &ToolExecutionContext::for_session("cli:test:dialog"), ) .await .unwrap(); assert_eq!(results.len(), 2); assert!( results .iter() .all(|result| matches!(result.status, TaskStatus::Failed(_))) ); } }