//! Parallel delegation tool — splits a large task into sub-tasks that run //! concurrently across multiple subagents, then synthesises results. //! //! Flow: receive task description + optional breakdown → LLM analyses the //! task and splits it into parallel directives → spawn subagents for each → //! collect results → return consolidated output. use anyhow::Result; use serde_json::{json, Value}; use tracing::{debug, info, instrument, warn}; use zesdex_domain::cms::{AppConfigRepository, SettingsRepository}; use zesdex_domain::core::Store; use crate::persistence::{JsonAppConfigRepository, JsonSettingsRepository}; use crate::subagent::context::SubagentContext; use crate::subagent::division::AccessTier; use crate::subagent::spawn::spawn_subagent; use crate::tools::{Tool, ToolCtx}; /// Delegate a large task to multiple subagents running in parallel. /// /// Flow: /// 1. If `directives` are provided explicitly, use them directly. /// 2. Otherwise, use LLM to analyse the task and split it into directives. /// 3. Spawn a subagent for each directive concurrently. /// 4. Join all results and consolidate into a single response. pub struct ParallelDelegate; impl Tool for ParallelDelegate { fn name(&self) -> &'static str { "parallel_delegate" } fn description(&self) -> &'static str { "Delegate a large task to multiple subagents running in parallel for 3x faster completion" } fn parameters(&self) -> Value { json!({ "type": "object", "properties": { "task": { "type": "string", "description": "The task to be split and delegated to parallel agents" }, "directives": { "type": "array", "items": { "type": "object", "properties": { "directive": {"type": "string", "description": "Directive for one parallel agent"}, "access": {"type": "string", "enum": ["read", "write", "full"], "description": "Access tier for this agent (default: write)"} }, "required": ["directive"] }, "description": "Optional explicit list of parallel directives (if not provided, the LLM will auto-split the task)" }, "synthesize": { "type": "boolean", "description": "Whether to synthesize results into a unified response (default: true)", "default": true }, "max_parallel": { "type": "integer", "description": "Maximum number of parallel agents (default: 3, max: 8)", "default": 3 } }, "required": ["task"] }) } #[instrument(skip(self, ctx, args))] fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let task = crate::tools::arg_str(args, "task")?; let synthesize = args .get("synthesize") .and_then(|v| v.as_bool()) .unwrap_or(true); let max_parallel = args .get("max_parallel") .and_then(|v| v.as_u64()) .unwrap_or(3) .min(8) as usize; // Load LLM credentials let store = Store::new(); let settings = JsonSettingsRepository::new() .load(&store.base_dir) .unwrap_or_default(); let app_config = JsonAppConfigRepository::new() .load(&store.base_dir) .unwrap_or_default(); let (provider, model) = crate::subagent::provider::resolve_subagent_provider(&settings, &app_config); let base_url = app_config .providers .get(&provider) .map(|p| p.api_base.clone()) .unwrap_or_else(|| "https://opencode.ai/zen/v1".to_string()); let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config); // Determine directives let directives: Vec<(String, AccessTier)> = if let Some(dirs) = args.get("directives").and_then(|v| v.as_array()) { // Explicit directives provided dirs.iter() .filter_map(|d| { let directive = d.get("directive").and_then(|v| v.as_str())?; let access_str = d .get("access") .and_then(|v| v.as_str()) .unwrap_or("write"); let access = match access_str { "read" => AccessTier::Read, "full" => AccessTier::Full, _ => AccessTier::Write, }; Some((directive.to_string(), access)) }) .collect() } else { // Auto-split using LLM let rt = tokio::runtime::Runtime::new()?; let directives = rt.block_on(auto_split_task( &task, max_parallel, &base_url, &api_key, &model, ))?; directives }; if directives.is_empty() { anyhow::bail!("no directives could be derived for the task"); } let directive_count = directives.len(); info!( task = %task, directive_count, max_parallel, "parallel delegation: starting subagents" ); // Spawn agents in parallel let mut handles = Vec::new(); for (i, (directive, access)) in directives.iter().enumerate() { let subagent_ctx = SubagentContext::new( directive.clone(), ctx.clone(), format!("{access:?}"), base_url.clone(), api_key.clone(), model.clone(), ); debug!(agent_index = i, access = ?access, "spawning parallel agent"); let handle = spawn_subagent( subagent_ctx, directive.clone(), *access, ctx.clone(), ); handles.push((i, handle)); } // Join all results let mut results: Vec<(usize, String, String)> = Vec::new(); for (i, handle) in handles { match handle.join() { Ok(Ok(output)) => { info!(agent_index = i, "parallel agent completed"); results.push((i, directives[i].0.clone(), output)); } Ok(Err(e)) => { warn!(agent_index = i, error = %e, "parallel agent failed"); results.push(( i, directives[i].0.clone(), format!("[ERROR] {e}"), )); } Err(e) => { warn!(agent_index = i, error = ?e, "parallel agent panicked"); results.push(( i, directives[i].0.clone(), "[ERROR] Agent panicked".to_string(), )); } } } // Consolidate results if synthesize && results.len() > 1 { let rt = tokio::runtime::Runtime::new()?; let consolidated = rt.block_on(consolidate_results(&results, &base_url, &api_key, &model))?; Ok(format!( "## Parallel Delegation Complete\n\n**Task:** {task}\n**Parallel agents:** {}\n\n{}", results.len(), consolidated )) } else { let mut output = format!( "## Parallel Delegation Complete\n\n**Task:** {task}\n**Parallel agents:** {}\n\n", results.len() ); for (i, directive, result) in &results { output.push_str(&format!("---\n### Agent {}: {}\n\n{}\n", i, directive, result)); } Ok(output) } } } /// Use LLM to analyse a task and split it into parallel directives. async fn auto_split_task( task: &str, max_parallel: usize, base_url: &str, api_key: &str, model: &str, ) -> Result> { let client = crate::llm::provider::LlmClient::new( api_key.to_string(), model.to_string(), Some(base_url.to_string()), ); let sys_msg = zesdex_domain::core::ChatMessage::system( format!( "You are a task decomposition expert. Split the following task into {max_parallel} \ independent sub-tasks that can run in parallel. Each sub-task must be self-contained \ and produce useful output independently.\n\n\ Output your response as a JSON array of objects, each with:\n\ - \"directive\": a clear, detailed instruction for a subagent\n\ - \"access\": one of \"read\", \"write\", or \"full\"\n\n\ IMPORTANT: Return ONLY valid JSON, no other text. Example:\n\ [{{\"directive\": \"Create the User model with fields...\", \"access\": \"write\"}}]" ) ); let user_msg = zesdex_domain::core::ChatMessage::user(format!("Task: {task}\n\nSplit into {max_parallel} parallel directives:")); use zesdex_application::ports::ProviderService; match client .chat(&[sys_msg, user_msg], None, Some(2048), Some(0.4)).await { Ok((response, _)) => { let text = response.content.unwrap_or_default(); parse_directives_json(&text, max_parallel) } Err(e) => { warn!(error = %e, "auto-split LLM call failed, using fallback splitting"); Ok(fallback_split(task, max_parallel)) } } } /// Parse JSON directives from LLM response. fn parse_directives_json(text: &str, max_parallel: usize) -> Result> { // Find JSON array in the response let json_start = text.find('['); let json_end = text.rfind(']'); let json_str = match (json_start, json_end) { (Some(start), Some(end)) if end > start => &text[start..=end], _ => { // Try parsing the whole text as JSON text.trim() } }; let parsed: Vec = serde_json::from_str(json_str) .map_err(|e| anyhow::anyhow!("failed to parse directives JSON: {e}"))?; let directives: Vec<(String, AccessTier)> = parsed .into_iter() .take(max_parallel) .filter_map(|v| { let directive = v.get("directive")?.as_str()?.to_string(); let access_str = v.get("access").and_then(|a| a.as_str()).unwrap_or("write"); let access = match access_str { "read" => AccessTier::Read, "full" => AccessTier::Full, _ => AccessTier::Write, }; Some((directive, access)) }) .collect(); Ok(directives) } /// Fallback splitting when LLM call fails. fn fallback_split(task: &str, max_parallel: usize) -> Vec<(String, AccessTier)> { // Simple heuristic split by common patterns let mut directives = Vec::new(); if task.contains("backend") || task.contains("api") || task.contains("server") { directives.push(( format!("Implement the backend/API components for: {task}"), AccessTier::Write, )); } if task.contains("frontend") || task.contains("ui") || task.contains("client") { directives.push(( format!("Implement the frontend/UI components for: {task}"), AccessTier::Write, )); } if task.contains("test") || task.contains("unit") { directives.push(( format!("Write unit tests for: {task}"), AccessTier::Read, )); } if directives.is_empty() { // Just split the task into generic chunks for i in 0..max_parallel { directives.push(( format!("Part {} of parallel task: {task}", i + 1), AccessTier::Write, )); } } directives.truncate(max_parallel); directives } /// Use LLM to consolidate multiple agent results into a single response. async fn consolidate_results( results: &[(usize, String, String)], base_url: &str, api_key: &str, model: &str, ) -> Result { let client = crate::llm::provider::LlmClient::new( api_key.to_string(), model.to_string(), Some(base_url.to_string()), ); let mut summary = String::new(); for (i, directive, result) in results { summary.push_str(&format!("### Agent {}: {}\n{}\n\n", i, directive, result)); } let sys_msg = zesdex_domain::core::ChatMessage::system( "You are a synthesis expert. Consolidate the following parallel agent outputs \ into a single coherent response. Remove duplication, reconcile conflicts, and \ present the unified result in a well-structured format." .to_string(), ); let user_msg = zesdex_domain::core::ChatMessage::user(format!( "Consolidate the following parallel agent outputs:\n\n{summary}" )); use zesdex_application::ports::ProviderService; match client.chat(&[sys_msg, user_msg], None, Some(2048), Some(0.3)).await { Ok((response, _)) => Ok(response.content.unwrap_or_else(|| summary.clone())), Err(e) => { warn!(error = %e, "consolidation LLM call failed, using raw concatenation"); Ok(summary) } } }