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 prompt: String, 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 { Inline, Background, Parallel, } #[derive(Debug, Clone)] pub struct SubAgentResult { pub task_id: String, pub content: String, pub content_truncated: bool, 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, } 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(), } } pub(crate) fn with_admission( mut self, admission: crate::gateway::reload::RuntimeAdmission, ) -> Self { self.admission = admission; self } 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()) && name != "delegate" && name != "todo" && name != "reload_config" { 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 fn build_sub_agent( &self, config: &SubAgentConfig, tools: Arc, ) -> Result { let mut provider = create_provider(self.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 = self.provider_config.workspace_dir.clone(); let model_name = self.provider_config.model_id.clone(); let input_types = self.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(self.provider_config.token_limit); Ok(agent) } pub async fn run_inline( &self, config: SubAgentConfig, ) -> Result { let task_id = generate_task_id(); 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 http_get_only = config.allowed_tools.is_none() || config .allowed_tools .as_ref() .is_some_and(|v| v.iter().any(|t| t == "http_request")); 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, http_get_only, ); let agent = self .build_sub_agent(&config, tools) .map_err(|e| SubAgentError::ProviderCreation(e.to_string()))?; self.assign_work_item(&config, &task_id).await?; let history = vec![ ChatMessage::system(system_prompt), ChatMessage::user(&config.prompt), ]; let start = Instant::now(); let browser_session_id = config .session_id .clone() .or_else(|| { get_delegate_context() .ok() .map(|context| context.session_id) }) .unwrap_or_else(|| format!("sub-agent:{task_id}")); let result = tokio::time::timeout( std::time::Duration::from_secs(timeout_secs), agent.process_with_context( history, ToolExecutionContext::for_session(browser_session_id), ), ) .await; let duration_ms = start.elapsed().as_millis() as u64; let result = match result { Ok(Ok(agent_result)) => { let (content, truncated) = truncate_sub_agent_result(&agent_result.final_response.content); 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.clone(), content, content_truncated: truncated, status: TaskStatus::Completed, tool_calls_count, iterations, duration_ms, } } Ok(Err(e)) => SubAgentResult { task_id: task_id.clone(), content: String::new(), content_truncated: false, status: TaskStatus::Failed(e.to_string()), tool_calls_count: 0, iterations: 0, duration_ms, }, Err(_elapsed) => SubAgentResult { task_id: task_id.clone(), content: String::new(), content_truncated: false, status: TaskStatus::TimedOut, tool_calls_count: 0, iterations: 0, duration_ms, }, }; self.finish_work_item(&config, &result).await; Ok(result) } pub async fn run_parallel( &self, configs: Vec, ) -> Result, SubAgentError> { let futures: Vec<_> = configs .into_iter() .map(|config| { let mgr = self; // &self borrow, all tasks share the same manager async move { mgr.run_inline(config).await } }) .collect(); let results = futures_util::future::join_all(futures).await; results.into_iter().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 http_get_only = config.allowed_tools.is_none() || config .allowed_tools .as_ref() .is_some_and(|v| v.iter().any(|t| t == "http_request")); 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, http_get_only, ); 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 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), ]; tokio::select! { r = tokio::time::timeout( std::time::Duration::from_secs(timeout_secs), agent.process_with_context( history, ToolExecutionContext::for_session(&sess_id), ), ) => { 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, content_truncated: false, status: TaskStatus::Completed, tool_calls_count, iterations, duration_ms: 0, } }, Ok(Err(e)) => SubAgentResult { task_id: tid.clone(), content: String::new(), content_truncated: false, status: TaskStatus::Failed(e.to_string()), tool_calls_count: 0, iterations: 0, duration_ms: 0, }, Err(_) => SubAgentResult { task_id: tid.clone(), content: String::new(), content_truncated: false, 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, 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, status: TaskStatus::Cancelled, tool_calls_count: 0, iterations: 0, duration_ms: 0, }, } } None => SubAgentResult { task_id: tid.clone(), content: String::new(), content_truncated: false, 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 generate_task_id() -> String { Uuid::new_v4().to_string()[..8].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(content: &str) -> (String, bool) { if content.len() <= MAX_INLINE_RESULT_CHARS { (content.to_string(), false) } else { let truncate_at = content.floor_char_boundary(MAX_INLINE_RESULT_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 { prompt: "test".into(), 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()); } }