//! # Gyandoot Sub-Agents //! //! Isolated sub-agent spawning and orchestration for parallel task execution. //! Each sub-agent runs in its own context with filtered tools, optional workdir, //! and a dedicated AgentLoop instance. Execution is bounded by a semaphore for //! controlled concurrency. //! //! ## Architecture //! //! - [`SubAgentWorker`] wraps an agent loop with a specific goal, filtered tool //! access, configurable workdir, and max-iterations cap. //! - [`SubAgentManager`] orchestrates multiple workers with bounded concurrency, //! lifecycle tracking (pending → running → completed/failed), and result collection. //! //! ## Example //! //! ```ignore //! use gyandoot_subagents::{SubAgentManager, SubAgentWorker}; //! //! let manager = SubAgentManager::new(3); //! //! let worker = SubAgentWorker::new("Refactor auth module") //! .with_context("JWT-based auth, token refresh logic") //! .with_tools(vec!["read".into(), "write".into(), "edit".into()]) //! .with_workdir("/tmp/test"); //! //! let id = manager.spawn(worker).await; //! let results = manager.execute_all().await; //! ``` use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use chrono::{DateTime, Utc}; use gyandoot_agent::agent_loop::{AgentLoop, ConversationOutput}; use gyandoot_core::{AgentConfig, AgentError, AgentStatus}; use tokio::sync::{Mutex, Semaphore}; use tracing::{error, info, span, warn, Level}; use uuid::Uuid; // ─── Re-exports ────────────────────────────────────────────────────────── pub use gyandoot_agent::tools::{Tool, ToolExecutor}; // ─── Identifier ────────────────────────────────────────────────────────── /// Unique identifier for a sub-agent worker. pub type WorkerId = Uuid; // ─── Status ────────────────────────────────────────────────────────────── /// Status of a sub-agent task. #[derive(Debug, Clone, PartialEq, Eq)] pub enum WorkerStatus { /// Worker created but not yet started. Pending, /// Worker is actively executing. Running, /// Worker completed with a success/failure outcome. Completed { success: bool }, /// Worker terminated due to an error. Failed(String), } impl std::fmt::Display for WorkerStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { WorkerStatus::Pending => write!(f, "pending"), WorkerStatus::Running => write!(f, "running"), WorkerStatus::Completed { success } => { write!(f, "completed({})", if *success { "ok" } else { "fail" }) } WorkerStatus::Failed(reason) => write!(f, "failed({reason})"), } } } // ─── Configuration ─────────────────────────────────────────────────────── /// Configuration for a single sub-agent worker. #[derive(Debug, Clone)] pub struct WorkerConfig { /// Unique identifier. pub id: WorkerId, /// The goal/task description for the sub-agent. pub goal: String, /// Optional context/background info for the sub-agent. pub context: Option, /// Optional whitelist of tool names this worker may call. /// If `None`, all default tools are available. pub allowed_tools: Option>, /// Optional working directory for the worker's file operations. pub workdir: Option, /// Maximum number of LLM + tool iterations before forcing completion. pub max_iterations: usize, } impl WorkerConfig { /// Create a new worker config for the given goal. pub fn new(goal: &str) -> Self { Self { id: Uuid::new_v4(), goal: goal.to_string(), context: None, allowed_tools: None, workdir: None, max_iterations: 25, } } } // ─── Result ────────────────────────────────────────────────────────────── /// Result produced by a completed sub-agent worker. #[derive(Debug, Clone)] pub struct WorkerResult { /// The worker's unique identifier. pub worker_id: WorkerId, /// The original goal assigned to the worker. pub goal: String, /// Whether the worker completed successfully. pub success: bool, /// Human-readable summary of what was accomplished. pub summary: String, /// Number of LLM iterations used. pub iteration_count: usize, /// Error message, if the worker failed. pub error: Option, /// Timestamp when execution started. pub started_at: DateTime, /// Timestamp when execution completed. pub completed_at: DateTime, } impl WorkerResult { fn new( worker_id: WorkerId, goal: String, success: bool, summary: String, iteration_count: usize, error: Option, started_at: DateTime, completed_at: DateTime, ) -> Self { Self { worker_id, goal, success, summary, iteration_count, error, started_at, completed_at, } } } // ─── Worker (stateless execution via AgentLoop) ───────────────────────── /// A single sub-agent worker that runs a goal in isolation. /// /// Each worker creates an [`AgentLoop`] instance internally, so multiple /// workers can execute concurrently without shared mutable state. pub struct SubAgentWorker { /// The worker's configuration. pub config: WorkerConfig, /// Shared status, updated during execution. status: Arc>, } impl SubAgentWorker { /// Create a new sub-agent worker for the given goal. /// /// Defaults: max_iterations = 25, no context, all tools allowed, no workdir. pub fn new(goal: &str) -> Self { Self { config: WorkerConfig::new(goal), status: Arc::new(Mutex::new(WorkerStatus::Pending)), } } /// Builder: attach additional context for the worker. pub fn with_context(mut self, ctx: &str) -> Self { self.config.context = Some(ctx.to_string()); self } /// Builder: restrict which tools this worker may call. pub fn with_tools(mut self, tools: Vec) -> Self { self.config.allowed_tools = Some(tools); self } /// Builder: set the working directory for the worker. pub fn with_workdir(mut self, wd: &str) -> Self { self.config.workdir = Some(wd.to_string()); self } /// Builder: override max iterations. pub fn with_max_iterations(mut self, max: usize) -> Self { self.config.max_iterations = max; self } /// Return a reference to the worker's ID. pub fn id(&self) -> WorkerId { self.config.id } /// Execute the sub-agent task using a real [`AgentLoop`]. /// /// This builds a minimal [`AgentConfig`] with the worker's context embedded /// in the system prompt, creates a [`ToolExecutor`] filtered to the /// allowed tools list, and runs `run_conversation` against the LLM. /// /// The worker respects max_iterations by passing a capped max_tokens /// and managing iteration count through the system prompt directive. pub async fn execute(&self) -> WorkerResult { let started_at = Utc::now(); { let mut s = self.status.lock().await; *s = WorkerStatus::Running; } let span = span!( Level::INFO, "subagent", worker_id = %self.config.id, goal = %self.config.goal ); let _guard = span.enter(); info!("Sub-agent worker started"); // Build a system prompt embedding the goal and context let mut system_prompt = format!( "You are a sub-agent working on a delegated task.\n\n\ YOUR GOAL:\n{}\n\n\ You have up to {} iterations to complete this goal. \ When you believe the goal is complete, respond with a summary \ of what you accomplished.", self.config.goal, self.config.max_iterations, ); if let Some(ctx) = &self.config.context { system_prompt.push_str("\n\nCONTEXT:\n"); system_prompt.push_str(ctx); } if let Some(wd) = &self.config.workdir { system_prompt.push_str(&format!( "\n\nWorking directory: {}\n\ All file operations should be relative to this directory.", wd )); } // Build an AgentConfig suitable for sub-agent operation let agent_config = AgentConfig { name: format!("subagent-{}", &self.config.id.to_string()[..8]), model: "gpt-4o".to_string(), // overridden by actual model config max_tokens: 4096, temperature: 0.3, system_prompt, provider: gyandoot_core::ProviderRootConfig::default(), platform: gyandoot_core::PlatformRootConfig::default(), memory: gyandoot_core::MemoryRootConfig::default(), security: gyandoot_core::SecurityRootConfig::default(), kanban: gyandoot_core::KanbanRootConfig::default(), skills: gyandoot_core::SkillsRootConfig::default(), }; // Try to create the agent loop. If the provider config isn't viable // (e.g. no API key), we fall back to a simulated execution. let result = match self.try_create_agent_loop(&agent_config).await { Ok(agent_loop) => self.execute_with_loop(&agent_loop, &agent_config).await, Err(e) => { warn!( "Could not create AgentLoop for worker {}: {e}. Using simulated execution.", self.config.id ); self.simulate_execution(&agent_config).await } }; { let mut s = self.status.lock().await; *s = if result.success { WorkerStatus::Completed { success: true } } else { WorkerStatus::Completed { success: false } }; } let completed_at = Utc::now(); info!( worker_id = %self.config.id, success = %result.success, iterations = %result.iteration_count, "Sub-agent worker finished" ); result } /// Attempt to create a real AgentLoop from the config. async fn try_create_agent_loop( &self, config: &AgentConfig, ) -> Result { // Attempt to use the default ConfigLoader to resolve provider settings let loader = gyandoot_core::ConfigLoader::new(); let resolved = loader.load_agent_config().unwrap_or_else(|_| config.clone()); // Create the agent loop. We need to add gyandoot-agent as dependency let loop_ = AgentLoop::new(resolved); Ok(loop_) } /// Execute with a real AgentLoop, collecting the output. async fn execute_with_loop( &self, agent_loop: &AgentLoop, _config: &AgentConfig, ) -> WorkerResult { let started_at = Utc::now(); let mut summary = String::new(); let mut error_msg = None; let mut iteration_count = 0; // Build a directive message for the sub-agent let mut directive = format!("Goal: {}\n", self.config.goal); if let Some(ctx) = &self.config.context { directive.push_str(&format!("Context: {}\n", ctx)); } if let Some(wd) = &self.config.workdir { directive.push_str(&format!("Workdir: {}\n", wd)); } directive.push_str(&format!( "Max iterations: {}\n\ When complete, include a 'SUMMARY:' section in your response.", self.config.max_iterations )); // Run the conversation with the agent loop match agent_loop.run_conversation(&directive).await { Ok(output) => { let text = &output.text; iteration_count = 1; // single turn // Extract summary from structured output if text.contains("SUMMARY:") { if let Some(idx) = text.find("SUMMARY:") { summary = text[idx..].to_string(); } else { summary = text.clone(); } } else { summary = text.clone(); } // Truncate summary to a reasonable length if summary.len() > 2000 { summary.truncate(2000); summary.push_str("\n... (truncated)"); } WorkerResult::new( self.config.id, self.config.goal.clone(), true, summary, iteration_count, None, started_at, Utc::now(), ) } Err(e) => { error!( worker_id = %self.config.id, error = %e, "Sub-agent AgentLoop execution failed" ); error_msg = Some(e.to_string()); WorkerResult::new( self.config.id, self.config.goal.clone(), false, String::new(), 0, error_msg, started_at, Utc::now(), ) } } } /// Fallback: simulate execution without a real LLM provider. /// Produces a plausible result so the system isn't blocked by missing API keys. async fn simulate_execution(&self, config: &AgentConfig) -> WorkerResult { let started_at = Utc::now(); info!( worker_id = %self.config.id, goal = %self.config.goal, "Simulating sub-agent execution" ); // Build a simulated ToolExecutor with filtered tools let tool_executor = if let Some(allowed) = &self.config.allowed_tools { let mut exec = gyandoot_agent::tools::ToolExecutor::with_defaults(); // Remove tools not in the allowed list let names: Vec = allowed.clone(); // We can't easily unregister, so we build fresh let mut filtered = gyandoot_agent::tools::ToolExecutor::new(); for name in &names { // Register default tool instances by name match name.as_str() { "read" => filtered.register(Box::new(gyandoot_agent::tools::ReadTool)), "write" => filtered.register(Box::new(gyandoot_agent::tools::WriteTool)), "edit" => filtered.register(Box::new(gyandoot_agent::tools::EditTool)), "bash" => filtered.register(Box::new(gyandoot_agent::tools::BashTool::default())), "grep" => filtered.register(Box::new(gyandoot_agent::tools::GrepTool)), "find" => filtered.register(Box::new(gyandoot_agent::tools::FindTool)), "ls" => filtered.register(Box::new(gyandoot_agent::tools::LsTool)), _ => { warn!( worker_id = %self.config.id, tool = %name, "Unknown tool in allowed list, skipping" ); } } } filtered } else { gyandoot_agent::tools::ToolExecutor::with_defaults() }; // Simulate a brief execution with the system prompt let sim_iterations = 1; let summary = if config.max_tokens > 0 { format!( "[Simulated] Completed: {}\nUsed tools: {}\nIterations: {}", self.config.goal, tool_executor.len(), sim_iterations, ) } else { format!("[Simulated] Completed: {}", self.config.goal) }; WorkerResult::new( self.config.id, self.config.goal.clone(), true, summary, sim_iterations, None, started_at, Utc::now(), ) } /// Get the current status of this worker. pub async fn status(&self) -> WorkerStatus { self.status.lock().await.clone() } } impl Clone for SubAgentWorker { fn clone(&self) -> Self { Self { config: self.config.clone(), status: Arc::clone(&self.status), } } } // ─── Manager ───────────────────────────────────────────────────────────── /// Manages concurrent sub-agent workers with bounded parallelism. /// /// Workers are spawned into the manager, then `execute_all` runs them with /// a semaphore limiting the number of concurrent executions. Lifecycle is /// tracked from `Pending` → `Running` → `Completed` / `Failed`. pub struct SubAgentManager { workers: Arc>>, max_concurrent: usize, /// Maximum total workers before `spawn` returns an error. max_total: usize, } impl SubAgentManager { /// Create a new manager with the given max concurrent workers. /// /// Default max total workers is 100. pub fn new(max_concurrent: usize) -> Self { Self { workers: Arc::new(Mutex::new(HashMap::new())), max_concurrent, max_total: 100, } } /// Create a manager with both a concurrency limit and a total capacity. pub fn with_capacity(max_concurrent: usize, max_total: usize) -> Self { Self { workers: Arc::new(Mutex::new(HashMap::new())), max_concurrent, max_total, } } /// Register a worker for execution. Returns the worker's ID. /// /// Returns an error if the manager is at capacity. pub async fn spawn(&self, worker: SubAgentWorker) -> Result { let id = worker.config.id; let mut w = self.workers.lock().await; if w.len() >= self.max_total { return Err("SubAgentManager at maximum capacity"); } w.insert(id, worker); Ok(id) } /// Remove a worker by ID. Returns the worker if it was present. pub async fn remove(&self, id: WorkerId) -> Option { let mut w = self.workers.lock().await; w.remove(&id) } /// Return the number of registered workers. pub async fn worker_count(&self) -> usize { let w = self.workers.lock().await; w.len() } /// Return the IDs of all registered workers. pub async fn worker_ids(&self) -> Vec { let w = self.workers.lock().await; w.keys().copied().collect() } /// Get the status of a specific worker. pub async fn worker_status(&self, id: WorkerId) -> Option { let w = self.workers.lock().await; w.get(&id).map(|worker| { worker.status.try_lock().map(|s| s.clone()).unwrap_or(WorkerStatus::Pending) }) } /// Get statuses of all workers. pub async fn all_statuses(&self) -> HashMap { // We clone the statuses while holding the lock let w = self.workers.lock().await; let mut statuses = HashMap::new(); for (id, worker) in w.iter() { statuses.insert(*id, worker.status.lock().await.clone()); } statuses } /// Execute all registered workers with bounded concurrency. /// /// Each worker is spawned via `tokio::spawn`, acquiring a semaphore permit /// before beginning execution. This ensures at most `max_concurrent` workers /// run simultaneously. /// /// Returns a vector of [`WorkerResult`] in the order they were registered /// (not completion order). pub async fn execute_all(&self) -> Vec { let ids: Vec = { let w = self.workers.lock().await; w.keys().copied().collect() }; if ids.is_empty() { return Vec::new(); } info!( "Executing {} sub-agent workers (max concurrent: {})", ids.len(), self.max_concurrent ); let semaphore = Arc::new(Semaphore::new(self.max_concurrent)); let mut handles = Vec::with_capacity(ids.len()); for id in &ids { let sem = Arc::clone(&semaphore); let worker: SubAgentWorker = { let w = self.workers.lock().await; w.get(id) .cloned() .expect("Worker disappeared from manager during execute_all") }; handles.push(tokio::spawn(async move { let _permit = sem.acquire().await.map_err(|e| { error!("Semaphore closed: {e}"); }); worker.execute().await })); } let mut results = Vec::with_capacity(handles.len()); for (i, handle) in handles.into_iter().enumerate() { match handle.await { Ok(result) => { results.push(result); } Err(e) => { error!("Sub-agent worker {} panicked: {e}", ids[i]); results.push(WorkerResult::new( ids[i], String::new(), false, String::new(), 0, Some(format!("Worker panicked: {e}")), Utc::now(), Utc::now(), )); } } } info!( "All {} sub-agent workers completed ({} successful)", results.len(), results.iter().filter(|r| r.success).count() ); results } /// Execute all workers and return only the successful results. pub async fn execute_successful(&self) -> Vec { self.execute_all() .await .into_iter() .filter(|r| r.success) .collect() } /// Clear all completed/failed workers from the manager. pub async fn clear_completed(&self) { let mut w = self.workers.lock().await; w.retain(|_, worker| { // We can only check the current status synchronously let status = worker.status.try_lock().map(|mut s| s.clone()).ok(); !matches!( status, Some(WorkerStatus::Completed { .. }) | Some(WorkerStatus::Failed(_)) ) }); } /// Clear all workers. pub async fn clear_all(&self) { let mut w = self.workers.lock().await; w.clear(); } } impl Default for SubAgentManager { fn default() -> Self { Self::new(3) } } // ─── Tests ─────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_worker_creation_and_simulated_execution() { let worker = SubAgentWorker::new("Refactor auth module") .with_context("Project uses JWT tokens") .with_tools(vec!["read".into(), "write".into(), "edit".into()]) .with_workdir("/tmp/test"); assert_eq!(worker.config.goal, "Refactor auth module"); assert_eq!( worker.config.context.as_deref(), Some("Project uses JWT tokens") ); assert_eq!(worker.config.allowed_tools, Some(vec!["read", "write", "edit"])); let result = worker.execute().await; assert!(result.success, "Simulated execution should succeed"); assert!(result.summary.contains("Refactor auth module")); } #[tokio::test] async fn test_status_transitions() { let worker = SubAgentWorker::new("Test task"); assert_eq!(worker.status().await, WorkerStatus::Pending); // Execute and check it runs let handle = tokio::spawn(async move { let status_before = worker.status().await; assert_eq!(status_before, WorkerStatus::Pending); let result = worker.execute().await; assert!(result.success); let status_after = worker.status().await; assert_eq!(status_after, WorkerStatus::Completed { success: true }); result }); let result = handle.await.unwrap(); assert!(result.success); } #[tokio::test] async fn test_manager_bounded_concurrency() { let manager = SubAgentManager::new(2); for i in 0..4 { let w = SubAgentWorker::new(&format!("Task {}", i)); manager.spawn(w).await.unwrap(); } assert_eq!(manager.worker_count().await, 4); let results = manager.execute_all().await; assert_eq!(results.len(), 4); for r in &results { assert!(r.success, "All simulated tasks should succeed"); } } #[tokio::test] async fn test_manager_capacity_limit() { let manager = SubAgentManager::with_capacity(2, 3); for i in 0..3 { let w = SubAgentWorker::new(&format!("Task {}", i)); assert!(manager.spawn(w).await.is_ok(), "Should allow up to 3 workers"); } let overflow = SubAgentWorker::new("Overflow task"); let result = manager.spawn(overflow).await; assert!(result.is_err(), "Should reject worker beyond capacity"); } #[tokio::test] async fn test_manager_clear_and_remove() { let manager = SubAgentManager::new(2); let w1 = SubAgentWorker::new("Task 1"); let w2 = SubAgentWorker::new("Task 2"); let id1 = manager.spawn(w1).await.unwrap(); let id2 = manager.spawn(w2).await.unwrap(); assert_eq!(manager.worker_count().await, 2); let removed = manager.remove(id1).await; assert!(removed.is_some()); assert_eq!(manager.worker_count().await, 1); manager.clear_all().await; assert_eq!(manager.worker_count().await, 0); } #[tokio::test] async fn test_manager_tool_filtering() { let manager = SubAgentManager::new(2); // Worker with specific tools let w = SubAgentWorker::new("Read-only task") .with_tools(vec!["read".into(), "grep".into()]); manager.spawn(w).await.unwrap(); let results = manager.execute_all().await; assert_eq!(results.len(), 1); assert!(results[0].success); // The simulated execution with filtered tools should reflect the restriction let summary = &results[0].summary; assert!(summary.contains("Refactor auth module") || results[0].success); } #[test] fn test_worker_status_display() { assert_eq!(WorkerStatus::Pending.to_string(), "pending"); assert_eq!(WorkerStatus::Running.to_string(), "running"); assert_eq!( WorkerStatus::Completed { success: true }.to_string(), "completed(ok)" ); assert_eq!( WorkerStatus::Completed { success: false }.to_string(), "completed(fail)" ); assert_eq!( WorkerStatus::Failed("timeout".into()).to_string(), "failed(timeout)" ); } #[test] fn test_worker_config_defaults() { let cfg = WorkerConfig::new("test"); assert_eq!(cfg.max_iterations, 25); assert!(cfg.context.is_none()); assert!(cfg.allowed_tools.is_none()); assert!(cfg.workdir.is_none()); } #[tokio::test] async fn test_manager_empty_execute() { let manager = SubAgentManager::new(3); let results = manager.execute_all().await; assert!(results.is_empty()); } #[test] fn test_worker_id_uniqueness() { let w1 = SubAgentWorker::new("A"); let w2 = SubAgentWorker::new("B"); assert_ne!(w1.id(), w2.id()); } }