diff --git a/Cargo.lock b/Cargo.lock index 295ea898..9ede00ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -651,6 +651,27 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctor" version = "0.1.26" @@ -740,6 +761,7 @@ dependencies = [ "clap_complete", "colored", "crossterm", + "csv", "dirs", "dotenvy", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index 34d303f0..43dcbcea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ clap = { version = "4.5.54", features = ["derive"] } clap_complete = "4.5" colored = "3.0.0" crossterm = "0.28" +csv = "1.4" dotenvy = "0.15.7" dirs = "6.0.0" futures-util = "0.3.31" diff --git a/README.md b/README.md index 42ffb25d..37b06241 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ Three modes: - **Plan** — design-first, proposes before acting - **Agent** — multi-step autonomous tool use -- **YOLO** — full auto-approve, no guardrails +- **YOLO** — full auto-approve, no guardrails (preloads tools by default) -Sub-agent orchestration is in there too (background workers, parallel tool calls). Still shaking out the rough edges. +**Recent highlights**: sub‑agent orchestration (background workers, parallel tool calls, dependency‑aware swarms), parallel tool execution (`multi_tool_use.parallel`), runtime HTTP/SSE API (`deepseek serve --http`), background task queue (`/task`), interactive configuration (`/config`), model discovery (`/models`), command palette (`Ctrl+K`), expandable tool payloads (`v`), persistent sidebar for live plan/todo/sub‑agent state, and model context‑window suffix hints (`-32k`, `-256k`). ## Install @@ -57,13 +57,17 @@ deepseek # interactive TUI deepseek -p "explain this in 2 sentences" # one-shot prompt deepseek --yolo # agent mode, all tools auto-approved deepseek doctor # check your setup +deepseek models # list available models +deepseek serve --http # start HTTP/SSE API server ``` +Within the TUI, use `/config`, `/models`, `/task`, and `Ctrl+K` command palette. + ## Model IDs Common model IDs: `deepseek-chat`, `deepseek-reasoner`. -Any valid `deepseek-*` model ID is accepted (including future releases). To see live IDs from your configured endpoint: +Any valid `deepseek-*` model ID is accepted (including future releases). Model IDs can include context‑window suffix hints (`-32k`, `-256k`). To see live IDs from your configured endpoint: ```bash deepseek models @@ -83,4 +87,4 @@ Detailed docs are in the [docs/](docs/) folder — architecture, modes, MCP inte ## License -MIT +MIT \ No newline at end of file diff --git a/src/core/engine.rs b/src/core/engine.rs index 4851525a..230c697d 100644 --- a/src/core/engine.rs +++ b/src/core/engine.rs @@ -1288,7 +1288,8 @@ impl Engine { } Op::ListSubAgents => { let agents = { - let manager = self.subagent_manager.lock().await; + let mut manager = self.subagent_manager.lock().await; + manager.cleanup(Duration::from_secs(60 * 60)); manager.list() }; let _ = self.tx_event.send(Event::AgentList { agents }).await; diff --git a/src/prompts.rs b/src/prompts.rs index c97d978b..09b7126f 100644 --- a/src/prompts.rs +++ b/src/prompts.rs @@ -12,16 +12,25 @@ use std::path::Path; pub const BASE_PROMPT: &str = include_str!("prompts/base.txt"); pub const NORMAL_PROMPT: &str = include_str!("prompts/normal.txt"); pub const AGENT_PROMPT: &str = include_str!("prompts/agent.txt"); +pub const YOLO_PROMPT: &str = include_str!("prompts/yolo.txt"); pub const PLAN_PROMPT: &str = include_str!("prompts/plan.txt"); +fn mode_prompt(mode: AppMode) -> &'static str { + match mode { + AppMode::Normal => NORMAL_PROMPT, + AppMode::Agent => AGENT_PROMPT, + AppMode::Yolo => YOLO_PROMPT, + AppMode::Plan => PLAN_PROMPT, + } +} + +fn compose_mode_prompt(mode: AppMode) -> String { + format!("{}\n\n{}", BASE_PROMPT.trim(), mode_prompt(mode).trim()) +} + /// Get the system prompt for a specific mode pub fn system_prompt_for_mode(mode: AppMode) -> SystemPrompt { - let text = match mode { - AppMode::Normal => NORMAL_PROMPT, - AppMode::Agent | AppMode::Yolo => AGENT_PROMPT, - AppMode::Plan => PLAN_PROMPT, - }; - SystemPrompt::Text(text.trim().to_string()) + SystemPrompt::Text(compose_mode_prompt(mode)) } /// Get the system prompt for a specific mode with project context @@ -30,27 +39,21 @@ pub fn system_prompt_for_mode_with_context( workspace: &Path, working_set_summary: Option<&str>, ) -> SystemPrompt { - let base_prompt = match mode { - AppMode::Normal => NORMAL_PROMPT, - AppMode::Agent | AppMode::Yolo => AGENT_PROMPT, - AppMode::Plan => PLAN_PROMPT, - }; + let mode_prompt = compose_mode_prompt(mode); // Load project context from workspace let project_context = load_project_context_with_parents(workspace); // Combine base prompt with project context let mut full_prompt = if let Some(project_block) = project_context.as_system_block() { - format!("{}\n\n{}", base_prompt.trim(), project_block) + format!("{}\n\n{}", mode_prompt, project_block) } else { // Fallback: Generate an automatic project map summary let summary = crate::utils::summarize_project(workspace); let tree = crate::utils::project_tree(workspace, 2); // Shallow tree for prompt format!( "{}\n\n### Project Structure (Automatic Map)\n**Summary:** {}\n\n**Tree:**\n```\n{}\n```", - base_prompt.trim(), - summary, - tree + mode_prompt, summary, tree ) }; @@ -91,13 +94,17 @@ pub fn base_system_prompt() -> SystemPrompt { } pub fn normal_system_prompt() -> SystemPrompt { - SystemPrompt::Text(NORMAL_PROMPT.trim().to_string()) + system_prompt_for_mode(AppMode::Normal) } pub fn agent_system_prompt() -> SystemPrompt { - SystemPrompt::Text(AGENT_PROMPT.trim().to_string()) + system_prompt_for_mode(AppMode::Agent) +} + +pub fn yolo_system_prompt() -> SystemPrompt { + system_prompt_for_mode(AppMode::Yolo) } pub fn plan_system_prompt() -> SystemPrompt { - SystemPrompt::Text(PLAN_PROMPT.trim().to_string()) + system_prompt_for_mode(AppMode::Plan) } diff --git a/src/prompts/agent.txt b/src/prompts/agent.txt index 6f13c8b0..47262768 100644 --- a/src/prompts/agent.txt +++ b/src/prompts/agent.txt @@ -78,21 +78,43 @@ TASK MANAGEMENT: - note: Record important information SUB-AGENTS: +- spawn_agent: Spawn a background sub-agent (agent_type, message/items) - agent_spawn: Spawn a background sub-agent (type, prompt, allowed_tools) +- spawn_agents_on_csv: Batch-process CSV rows with one worker sub-agent per row +- report_agent_job_result: Worker-only job row report tool for spawn_agents_on_csv - agent_swarm: Spawn a dependency-aware swarm of sub-agents (tasks, shared_context) +- swarm_status: Check status for a previously started swarm (swarm_id) +- swarm_result: Get full results for a previously started swarm (swarm_id, optional block/timeout) - agent_result: Get result from a sub-agent (agent_id, block, timeout_ms) -- send_input: Send input to a running sub-agent (agent_id, message, interrupt) -- wait: Wait for one or more sub-agents to complete (ids, timeout_ms) +- send_input: Send input to a running sub-agent (agent_id, message/items, interrupt) +- agent_assign / assign_agent: Update assignment objective/role and optionally push immediate guidance +- wait: Wait for one or more sub-agents to complete (ids optional, wait_mode:any|all, timeout_ms) - agent_cancel: Cancel a running sub-agent (agent_id) +- close_agent: Close a running sub-agent (alias for cancel) +- resume_agent: Resume a previously closed/completed sub-agent - agent_list: List all sub-agents and their status -If you spawn a sub-agent, always follow up with agent_result (block: true) and incorporate its result before responding to the user. -If you use agent_swarm, incorporate its aggregated results (or follow up with agent_result for any running agents) before responding. +Delegation protocol: +- Delegate only bounded, parallelizable work with a clear input, expected output, and tool limits. +- Prefer multiple sub-agents for independent steps to maximize parallelism. +- When spawning/delegating, include explicit assignment metadata: objective + role (worker/explorer/awaiter/default) or agent_type. +- Use agent_assign to retask active sub-agents instead of respawning when objective/role changes. +- After spawning, immediately track completion with wait (for groups), swarm_result (for non-blocking swarms), or agent_result (block: true) per agent. +- For full barriers, use wait with wait_mode="all" and a generous timeout (prefer >= 60000ms). Omit ids to wait on all currently running agents. +- For spawn_agents_on_csv workers: call report_agent_job_result exactly once per row item; missing reports are treated as failures. +- Workers may set stop=true in report_agent_job_result to cancel remaining unstarted CSV rows. +- If sub-agents are still running, wait for their outputs before presenting final conclusions unless the user asked a direct question that needs an immediate reply. +- Do not present final conclusions until required sub-agent results are collected and integrated. +- If an agent stalls or fails, retry once with a tighter prompt; otherwise cancel it and continue with an explicit fallback. +- Close idle agents with close_agent to free capacity; use resume_agent to continue paused/completed assignments when needed. +- Verify critical sub-agent claims with primary tool output before applying changes. Planning and progress: - For complex or multi-file work, call update_plan to publish a checklist. - Keep exactly one plan step in_progress at a time. - Use todo tools for granular progress when helpful. - Prefer short progress notes over long narration. +- For long-running tasks, emit checkpoint updates every few actions with: done, next, and blockers. +- Re-baseline plan/todos at each checkpoint when scope shifts. Git hygiene: - Run git status early (to see the workspace state) and again before finishing. diff --git a/src/runtime_threads.rs b/src/runtime_threads.rs index e45cfd7a..9d823d87 100644 --- a/src/runtime_threads.rs +++ b/src/runtime_threads.rs @@ -27,6 +27,7 @@ use crate::models::{ compaction_threshold_for_model, }; use crate::tools::plan::new_shared_plan_state; +use crate::tools::subagent::SubAgentStatus; use crate::tools::todo::new_shared_todo_list; use crate::tui::app::AppMode; @@ -1746,6 +1747,123 @@ impl RuntimeThreadManager { ) .await?; } + EngineEvent::AgentSpawned { id, prompt } => { + let message = format!( + "Sub-agent {id} spawned: {}", + summarize_text(&prompt, SUMMARY_LIMIT) + ); + let item = TurnItemRecord { + schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, + id: format!("item_{}", &Uuid::new_v4().to_string()[..8]), + turn_id: turn_id.clone(), + kind: TurnItemKind::Status, + status: TurnItemLifecycleStatus::Completed, + summary: summarize_text(&message, SUMMARY_LIMIT), + detail: Some(message), + artifact_refs: Vec::new(), + started_at: Some(Utc::now()), + ended_at: Some(Utc::now()), + }; + self.store.save_item(&item)?; + self.attach_item_to_turn(&turn_id, &item.id)?; + self.emit_event( + &thread_id, + Some(&turn_id), + Some(&item.id), + "agent.spawned", + json!({ "item": item, "agent_id": id }), + ) + .await?; + } + EngineEvent::AgentProgress { id, status } => { + let message = format!("Sub-agent {id}: {status}"); + let item = TurnItemRecord { + schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, + id: format!("item_{}", &Uuid::new_v4().to_string()[..8]), + turn_id: turn_id.clone(), + kind: TurnItemKind::Status, + status: TurnItemLifecycleStatus::Completed, + summary: summarize_text(&message, SUMMARY_LIMIT), + detail: Some(message), + artifact_refs: Vec::new(), + started_at: Some(Utc::now()), + ended_at: Some(Utc::now()), + }; + self.store.save_item(&item)?; + self.attach_item_to_turn(&turn_id, &item.id)?; + self.emit_event( + &thread_id, + Some(&turn_id), + Some(&item.id), + "agent.progress", + json!({ "item": item, "agent_id": id }), + ) + .await?; + } + EngineEvent::AgentComplete { id, result } => { + let message = format!( + "Sub-agent {id} completed: {}", + summarize_text(&result, SUMMARY_LIMIT) + ); + let item = TurnItemRecord { + schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, + id: format!("item_{}", &Uuid::new_v4().to_string()[..8]), + turn_id: turn_id.clone(), + kind: TurnItemKind::Status, + status: TurnItemLifecycleStatus::Completed, + summary: summarize_text(&message, SUMMARY_LIMIT), + detail: Some(message), + artifact_refs: Vec::new(), + started_at: Some(Utc::now()), + ended_at: Some(Utc::now()), + }; + self.store.save_item(&item)?; + self.attach_item_to_turn(&turn_id, &item.id)?; + self.emit_event( + &thread_id, + Some(&turn_id), + Some(&item.id), + "agent.completed", + json!({ "item": item, "agent_id": id }), + ) + .await?; + } + EngineEvent::AgentList { agents } => { + let running = agents + .iter() + .filter(|agent| matches!(agent.status, SubAgentStatus::Running)) + .count(); + let completed = agents + .iter() + .filter(|agent| matches!(agent.status, SubAgentStatus::Completed)) + .count(); + let message = format!( + "Sub-agent list refreshed: {} total ({running} running, {completed} completed)", + agents.len() + ); + let item = TurnItemRecord { + schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, + id: format!("item_{}", &Uuid::new_v4().to_string()[..8]), + turn_id: turn_id.clone(), + kind: TurnItemKind::Status, + status: TurnItemLifecycleStatus::Completed, + summary: summarize_text(&message, SUMMARY_LIMIT), + detail: Some(message), + artifact_refs: Vec::new(), + started_at: Some(Utc::now()), + ended_at: Some(Utc::now()), + }; + self.store.save_item(&item)?; + self.attach_item_to_turn(&turn_id, &item.id)?; + self.emit_event( + &thread_id, + Some(&turn_id), + Some(&item.id), + "agent.list", + json!({ "item": item, "agents": agents }), + ) + .await?; + } EngineEvent::ApprovalRequired { id, tool_name, diff --git a/src/tools/registry.rs b/src/tools/registry.rs index bd983a62..73593823 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -446,31 +446,64 @@ impl ToolRegistryBuilder { runtime: super::subagent::SubAgentRuntime, ) -> Self { use super::subagent::{ - AgentCancelTool, AgentListTool, AgentResultTool, AgentSendInputTool, AgentSpawnTool, - AgentWaitTool, DelegateToAgentTool, + AgentAssignTool, AgentCancelTool, AgentCloseTool, AgentListTool, AgentResultTool, + AgentResumeTool, AgentSendInputTool, AgentSpawnTool, AgentWaitTool, + DelegateToAgentTool, ReportAgentJobResultTool, SpawnAgentsOnCsvTool, }; - use super::swarm::AgentSwarmTool; + use super::swarm::{AgentSwarmTool, SwarmResultTool, SwarmStatusTool}; self.with_tool(Arc::new(AgentSpawnTool::new( manager.clone(), runtime.clone(), ))) + .with_tool(Arc::new(AgentSpawnTool::with_name( + manager.clone(), + runtime.clone(), + "spawn_agent", + ))) .with_tool(Arc::new(DelegateToAgentTool::new( manager.clone(), runtime.clone(), ))) - .with_tool(Arc::new(AgentSwarmTool::new(manager.clone(), runtime))) + .with_tool(Arc::new(AgentSwarmTool::new( + manager.clone(), + runtime.clone(), + ))) + .with_tool(Arc::new(SpawnAgentsOnCsvTool::new( + manager.clone(), + runtime.clone(), + ))) + .with_tool(Arc::new(ReportAgentJobResultTool)) + .with_tool(Arc::new(SwarmStatusTool::new( + runtime.context.workspace.clone(), + ))) + .with_tool(Arc::new(SwarmResultTool::new( + runtime.context.workspace.clone(), + ))) .with_tool(Arc::new(AgentResultTool::new(manager.clone()))) .with_tool(Arc::new(AgentSendInputTool::new( manager.clone(), "send_input", ))) + .with_tool(Arc::new(AgentAssignTool::new( + manager.clone(), + "agent_assign", + ))) + .with_tool(Arc::new(AgentAssignTool::new( + manager.clone(), + "assign_agent", + ))) .with_tool(Arc::new(AgentWaitTool::new(manager.clone(), "wait"))) .with_tool(Arc::new(AgentSendInputTool::new( manager.clone(), "agent_send_input", ))) .with_tool(Arc::new(AgentWaitTool::new(manager.clone(), "agent_wait"))) + .with_tool(Arc::new(AgentResumeTool::new( + manager.clone(), + runtime.clone(), + ))) + .with_tool(Arc::new(AgentCloseTool::new(manager.clone()))) .with_tool(Arc::new(AgentCancelTool::new(manager.clone()))) .with_tool(Arc::new(AgentListTool::new(manager))) } diff --git a/src/tools/subagent.rs b/src/tools/subagent.rs index 55054de2..1dbf1b8c 100644 --- a/src/tools/subagent.rs +++ b/src/tools/subagent.rs @@ -4,14 +4,17 @@ //! and retrieve results. Sub-agents run with a filtered toolset and //! inherit the workspace configuration from the main session. -use std::collections::{HashMap, VecDeque}; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::Mutex; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex as StdMutex, OnceLock}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::sync::{Mutex, Semaphore}; use anyhow::{Result, anyhow}; use async_trait::async_trait; +use futures_util::stream::{FuturesUnordered, StreamExt}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tokio::{sync::mpsc, task::JoinHandle}; @@ -35,9 +38,41 @@ use crate::tools::todo::{SharedTodoList, TodoList}; const DEFAULT_MAX_STEPS: u32 = 20; const TOOL_TIMEOUT: Duration = Duration::from_secs(30); const RESULT_POLL_INTERVAL: Duration = Duration::from_millis(250); +const DEFAULT_RESULT_TIMEOUT_MS: u64 = 30_000; +const MIN_WAIT_TIMEOUT_MS: u64 = 10_000; +const MAX_RESULT_TIMEOUT_MS: u64 = 3_600_000; +const COMPLETED_AGENT_RETENTION: Duration = Duration::from_secs(60 * 60); +const SUBAGENT_STATE_SCHEMA_VERSION: u32 = 1; +const SUBAGENT_STATE_FILE: &str = "subagents.v1.json"; +const SUBAGENT_RESTART_REASON: &str = "Interrupted by process restart"; +const DEFAULT_CSV_MAX_CONCURRENCY: u64 = 16; +const DEFAULT_CSV_MAX_RUNTIME_SECONDS: u64 = 1800; +const MAX_CSV_MAX_RUNTIME_SECONDS: u64 = 86_400; + +const VALID_SUBAGENT_TYPES: &str = + "general, explore, plan, review, custom, worker, explorer, awaiter, default"; + +static AGENT_JOB_REPORTS: OnceLock>>> = + OnceLock::new(); +static AGENT_JOB_ASSIGNMENTS: OnceLock>>> = + OnceLock::new(); // === Types === +/// Assignment metadata for sub-agent orchestration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SubAgentAssignment { + pub objective: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub role: Option, +} + +impl SubAgentAssignment { + fn new(objective: String, role: Option) -> Self { + Self { objective, role } + } +} + /// Sub-agent execution types with specialized behavior and tool access. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(rename_all = "snake_case")] @@ -60,15 +95,28 @@ impl SubAgentType { #[must_use] pub fn from_str(s: &str) -> Option { match s.to_lowercase().as_str() { - "general" | "general-purpose" | "general_purpose" => Some(Self::General), - "explore" | "exploration" => Some(Self::Explore), - "plan" | "planning" => Some(Self::Plan), - "review" | "code-review" | "code_review" => Some(Self::Review), + "general" | "general-purpose" | "general_purpose" | "worker" | "default" => { + Some(Self::General) + } + "explore" | "exploration" | "explorer" => Some(Self::Explore), + "plan" | "planning" | "awaiter" => Some(Self::Plan), + "review" | "code-review" | "code_review" | "reviewer" => Some(Self::Review), "custom" => Some(Self::Custom), _ => None, } } + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::General => "general", + Self::Explore => "explore", + Self::Plan => "plan", + Self::Review => "review", + Self::Custom => "custom", + } + } + /// Get the system prompt for this agent type. #[must_use] pub fn system_prompt(&self) -> String { @@ -112,6 +160,7 @@ impl SubAgentType { "todo_update", "todo_list", "update_plan", + "report_agent_job_result", ], Self::Explore => vec![ "list_dir", @@ -170,18 +219,141 @@ pub enum SubAgentStatus { pub struct SubAgentResult { pub agent_id: String, pub agent_type: SubAgentType, + pub assignment: SubAgentAssignment, pub status: SubAgentStatus, pub result: Option, pub steps_taken: u32, pub duration_ms: u64, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WaitMode { + Any, + All, +} + +impl WaitMode { + fn from_str(value: &str) -> Option { + match value.to_ascii_lowercase().as_str() { + "any" | "first" => Some(Self::Any), + "all" => Some(Self::All), + _ => None, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Any => "any", + Self::All => "all", + } + } + + fn condition_met(self, snapshots: &[SubAgentResult]) -> bool { + match self { + Self::Any => snapshots + .iter() + .any(|snapshot| snapshot.status != SubAgentStatus::Running), + Self::All => snapshots + .iter() + .all(|snapshot| snapshot.status != SubAgentStatus::Running), + } + } +} + #[derive(Debug, Clone)] struct SubAgentInput { text: String, interrupt: bool, } +#[derive(Debug, Clone)] +struct SpawnRequest { + prompt: String, + agent_type: SubAgentType, + assignment: SubAgentAssignment, + allowed_tools: Option>, +} + +#[derive(Debug, Clone)] +struct AssignRequest { + agent_id: String, + objective: Option, + role: Option, + message: Option, + interrupt: bool, +} + +#[derive(Debug, Clone)] +struct CsvRowTask { + row_index: usize, + item_id: String, + values: HashMap, +} + +#[derive(Debug, Clone, Serialize)] +struct CsvWorkerOutcome { + #[serde(skip_serializing)] + row_index: usize, + item_id: String, + status: String, + #[serde(skip_serializing_if = "Option::is_none")] + agent_id: Option, + duration_ms: u64, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + result_json: Option, +} + +#[derive(Debug, Clone)] +struct AgentJobReport { + result: Value, + stop: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct SpawnAgentsOnCsvSummary { + job_id: String, + total: usize, + completed: usize, + failed: usize, + timed_out: usize, + skipped: usize, + output_csv_path: String, + results: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PersistedSubAgent { + id: String, + agent_type: SubAgentType, + prompt: String, + assignment: SubAgentAssignment, + status: SubAgentStatus, + result: Option, + steps_taken: u32, + duration_ms: u64, + allowed_tools: Vec, + updated_at_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PersistedSubAgentState { + schema_version: u32, + agents: Vec, +} + +impl Default for PersistedSubAgentState { + fn default() -> Self { + Self { + schema_version: SUBAGENT_STATE_SCHEMA_VERSION, + agents: Vec::new(), + } + } +} + /// Runtime configuration for spawning sub-agents. #[derive(Clone)] pub struct SubAgentRuntime { @@ -217,6 +389,7 @@ pub struct SubAgent { pub id: String, pub agent_type: SubAgentType, pub prompt: String, + pub assignment: SubAgentAssignment, pub status: SubAgentStatus, pub result: Option, pub steps_taken: u32, @@ -231,6 +404,7 @@ impl SubAgent { fn new( agent_type: SubAgentType, prompt: String, + assignment: SubAgentAssignment, allowed_tools: Vec, input_tx: mpsc::UnboundedSender, ) -> Self { @@ -240,6 +414,7 @@ impl SubAgent { id, agent_type, prompt, + assignment, status: SubAgentStatus::Running, result: None, steps_taken: 0, @@ -256,6 +431,7 @@ impl SubAgent { SubAgentResult { agent_id: self.id.clone(), agent_type: self.agent_type.clone(), + assignment: self.assignment.clone(), status: self.status.clone(), result: self.result.clone(), steps_taken: self.steps_taken, @@ -268,6 +444,7 @@ impl SubAgent { pub struct SubAgentManager { agents: HashMap, workspace: PathBuf, + state_path: Option, max_steps: u32, max_agents: usize, } @@ -279,16 +456,111 @@ impl SubAgentManager { Self { agents: HashMap::new(), workspace, + state_path: None, max_steps: DEFAULT_MAX_STEPS, max_agents, } } + #[must_use] + fn with_state_path(mut self, path: PathBuf) -> Self { + self.state_path = Some(path); + self + } + + fn persist_state(&self) -> Result<()> { + let Some(path) = self.state_path.as_ref() else { + return Ok(()); + }; + let now_ms = epoch_millis_now(); + let mut agents = Vec::with_capacity(self.agents.len()); + for agent in self.agents.values() { + agents.push(PersistedSubAgent { + id: agent.id.clone(), + agent_type: agent.agent_type.clone(), + prompt: agent.prompt.clone(), + assignment: agent.assignment.clone(), + status: agent.status.clone(), + result: agent.result.clone(), + steps_taken: agent.steps_taken, + duration_ms: u64::try_from(agent.started_at.elapsed().as_millis()) + .unwrap_or(u64::MAX), + allowed_tools: agent.allowed_tools.clone(), + updated_at_ms: now_ms, + }); + } + agents.sort_by(|a, b| a.id.cmp(&b.id)); + + let payload = PersistedSubAgentState { + schema_version: SUBAGENT_STATE_SCHEMA_VERSION, + agents, + }; + write_json_atomic(path, &payload) + } + + fn persist_state_best_effort(&self) { + if let Err(err) = self.persist_state() { + eprintln!("Failed to persist sub-agent state: {err}"); + } + } + + fn load_state(&mut self) -> Result<()> { + let Some(path) = self.state_path.as_ref() else { + return Ok(()); + }; + if !path.exists() { + return Ok(()); + } + + let raw = fs::read_to_string(path)?; + let state = serde_json::from_str::(&raw)?; + if state.schema_version != SUBAGENT_STATE_SCHEMA_VERSION { + return Err(anyhow!( + "Unsupported sub-agent state schema {}", + state.schema_version + )); + } + + self.agents.clear(); + for persisted in state.agents { + let mut status = persisted.status; + if matches!(status, SubAgentStatus::Running) { + status = SubAgentStatus::Failed(SUBAGENT_RESTART_REASON.to_string()); + } + + let started_at = instant_from_duration(Duration::from_millis(persisted.duration_ms)); + let agent = SubAgent { + id: persisted.id.clone(), + agent_type: persisted.agent_type, + prompt: persisted.prompt, + assignment: persisted.assignment, + status, + result: persisted.result, + steps_taken: persisted.steps_taken, + started_at, + allowed_tools: persisted.allowed_tools, + input_tx: None, + task_handle: None, + }; + self.agents.insert(persisted.id, agent); + } + + Ok(()) + } + /// Count running agents. pub fn running_count(&self) -> usize { self.agents .values() - .filter(|agent| agent.status == SubAgentStatus::Running) + .filter(|agent| { + if agent.status != SubAgentStatus::Running { + return false; + } + !agent + .task_handle + .as_ref() + .is_some_and(tokio::task::JoinHandle::is_finished) + }) .count() } @@ -313,16 +585,45 @@ impl SubAgentManager { prompt: String, allowed_tools: Option>, ) -> Result { + self.spawn_background_with_assignment( + manager_handle, + runtime, + agent_type, + prompt.clone(), + SubAgentAssignment::new(prompt, None), + allowed_tools, + ) + } + + /// Spawn a new background sub-agent with explicit assignment metadata. + pub fn spawn_background_with_assignment( + &mut self, + manager_handle: SharedSubAgentManager, + runtime: SubAgentRuntime, + agent_type: SubAgentType, + prompt: String, + assignment: SubAgentAssignment, + allowed_tools: Option>, + ) -> Result { + self.cleanup(COMPLETED_AGENT_RETENTION); + if self.running_count() >= self.max_agents { return Err(anyhow!( - "Sub-agent limit reached (max {}). Cancel or wait for an existing agent to finish.", - self.max_agents + "Sub-agent limit reached (max {}, running {}). Cancel, close, or wait for an existing agent to finish.", + self.max_agents, + self.running_count() )); } let tools = build_allowed_tools(&agent_type, allowed_tools, runtime.allow_shell)?; let (input_tx, input_rx) = mpsc::unbounded_channel(); - let mut agent = SubAgent::new(agent_type.clone(), prompt.clone(), tools.clone(), input_tx); + let mut agent = SubAgent::new( + agent_type.clone(), + prompt.clone(), + assignment.clone(), + tools.clone(), + input_tx, + ); let agent_id = agent.id.clone(); let started_at = agent.started_at; let max_steps = self.max_steps; @@ -340,6 +641,7 @@ impl SubAgentManager { agent_id: agent_id.clone(), agent_type, prompt, + assignment, allowed_tools: tools, started_at, max_steps, @@ -348,6 +650,7 @@ impl SubAgentManager { let handle = tokio::spawn(run_subagent_task(task)); agent.task_handle = Some(handle); self.agents.insert(agent_id.clone(), agent); + self.persist_state_best_effort(); Ok(self .agents @@ -367,19 +670,100 @@ impl SubAgentManager { /// Cancel a running sub-agent. pub fn cancel(&mut self, agent_id: &str) -> Result { - let agent = self - .agents - .get_mut(agent_id) - .ok_or_else(|| anyhow!("Agent {agent_id} not found"))?; + let (snapshot, changed) = { + let agent = self + .agents + .get_mut(agent_id) + .ok_or_else(|| anyhow!("Agent {agent_id} not found"))?; - if agent.status == SubAgentStatus::Running { - agent.status = SubAgentStatus::Cancelled; - if let Some(handle) = agent.task_handle.take() { - handle.abort(); + let mut changed = false; + if agent.status == SubAgentStatus::Running { + agent.status = SubAgentStatus::Cancelled; + if let Some(handle) = agent.task_handle.take() { + handle.abort(); + } + changed = true; } + (agent.snapshot(), changed) + }; + + if changed { + self.persist_state_best_effort(); + } + Ok(snapshot) + } + + /// Resume a non-running sub-agent by restarting it with the original assignment. + pub fn resume( + &mut self, + manager_handle: SharedSubAgentManager, + runtime: SubAgentRuntime, + agent_id: &str, + ) -> Result { + let status = self + .agents + .get(agent_id) + .ok_or_else(|| anyhow!("Agent {agent_id} not found"))? + .status + .clone(); + + if status == SubAgentStatus::Running { + let agent = self + .agents + .get(agent_id) + .ok_or_else(|| anyhow!("Agent {agent_id} not found"))?; + return Ok(agent.snapshot()); } - Ok(agent.snapshot()) + if self.running_count() >= self.max_agents { + return Err(anyhow!( + "Sub-agent limit reached (max {}, running {}). Close or wait for an existing agent before resuming.", + self.max_agents, + self.running_count() + )); + } + + let snapshot = { + let agent = self + .agents + .get_mut(agent_id) + .ok_or_else(|| anyhow!("Agent {agent_id} not found"))?; + + let (input_tx, input_rx) = mpsc::unbounded_channel(); + let restarted_at = Instant::now(); + let task = SubAgentTask { + manager_handle, + runtime: runtime.clone(), + agent_id: agent.id.clone(), + agent_type: agent.agent_type.clone(), + prompt: agent.prompt.clone(), + assignment: agent.assignment.clone(), + allowed_tools: agent.allowed_tools.clone(), + started_at: restarted_at, + max_steps: self.max_steps, + input_rx, + }; + let handle = tokio::spawn(run_subagent_task(task)); + + agent.status = SubAgentStatus::Running; + agent.result = None; + agent.steps_taken = 0; + agent.started_at = restarted_at; + agent.input_tx = Some(input_tx); + agent.task_handle = Some(handle); + + if let Some(event_tx) = runtime.event_tx { + let _ = event_tx.try_send(Event::AgentSpawned { + id: agent.id.clone(), + prompt: format!("(resumed) {}", agent.prompt), + }); + } + + agent.snapshot() + }; + self.persist_state_best_effort(); + + Ok(snapshot) } /// Send input to a running sub-agent. @@ -404,6 +788,111 @@ impl SubAgentManager { Ok(()) } + /// Update assignment metadata and optionally send immediate guidance. + pub fn assign( + &mut self, + agent_id: &str, + objective: Option, + role: Option, + message: Option, + interrupt: bool, + ) -> Result { + if objective.is_none() && role.is_none() && message.is_none() { + return Err(anyhow!( + "Provide at least one of objective, role, or message" + )); + } + + if message.is_some() { + let status = self + .agents + .get(agent_id) + .ok_or_else(|| anyhow!("Agent {agent_id} not found"))? + .status + .clone(); + if status != SubAgentStatus::Running { + return Err(anyhow!( + "Agent {agent_id} is not running; cannot deliver assignment message" + )); + } + } + + let mut changed = false; + let (input_tx, payload) = { + let agent = self + .agents + .get_mut(agent_id) + .ok_or_else(|| anyhow!("Agent {agent_id} not found"))?; + + let mut assignment_lines = Vec::new(); + if let Some(objective) = objective { + let objective = objective.trim(); + if objective.is_empty() { + return Err(anyhow!("objective cannot be empty")); + } + if agent.assignment.objective != objective { + agent.assignment.objective = objective.to_string(); + changed = true; + } + assignment_lines.push(format!("- objective: {}", agent.assignment.objective)); + } + + if let Some(role) = role { + let normalized = normalize_role_alias(&role) + .ok_or_else(|| { + anyhow!( + "Invalid role alias '{role}'. Use: worker, explorer, awaiter, default" + ) + })? + .to_string(); + if agent.assignment.role.as_deref() != Some(normalized.as_str()) { + agent.assignment.role = Some(normalized.clone()); + changed = true; + } + assignment_lines.push(format!("- role: {normalized}")); + } + + let mut payload_parts = Vec::new(); + if !assignment_lines.is_empty() && agent.status == SubAgentStatus::Running { + payload_parts.push(format!( + "Assignment updated:\n{}", + assignment_lines.join("\n") + )); + } + if let Some(message) = message { + let message = message.trim(); + if message.is_empty() { + return Err(anyhow!("message cannot be empty")); + } + payload_parts.push(format!("Coordinator note:\n{message}")); + } + + let payload = if payload_parts.is_empty() { + None + } else { + Some(payload_parts.join("\n\n")) + }; + + (agent.input_tx.clone(), payload) + }; + + if let Some(payload) = payload { + let tx = input_tx + .ok_or_else(|| anyhow!("Agent {agent_id} cannot accept assignment input"))?; + tx.send(SubAgentInput { + text: payload, + interrupt, + }) + .map_err(|_| anyhow!("Failed to send assignment to agent {agent_id}"))?; + } + + if changed { + self.persist_state_best_effort(); + } + + self.get_result(agent_id) + } + /// List all agents and their status. #[must_use] pub fn list(&self) -> Vec { @@ -412,6 +901,7 @@ impl SubAgentManager { /// Clean up completed agents older than the given duration. pub fn cleanup(&mut self, max_age: Duration) { + let before = self.agents.len(); self.agents.retain(|_, agent| { if agent.status == SubAgentStatus::Running { true @@ -419,21 +909,35 @@ impl SubAgentManager { agent.started_at.elapsed() < max_age } }); + if self.agents.len() != before { + self.persist_state_best_effort(); + } } fn update_from_result(&mut self, agent_id: &str, result: SubAgentResult) { + let mut changed = false; if let Some(agent) = self.agents.get_mut(agent_id) { agent.status = result.status; + agent.assignment = result.assignment; agent.result = result.result; agent.steps_taken = result.steps_taken; agent.task_handle = None; + changed = true; + } + if changed { + self.persist_state_best_effort(); } } fn update_failed(&mut self, agent_id: &str, error: String) { + let mut changed = false; if let Some(agent) = self.agents.get_mut(agent_id) { agent.status = SubAgentStatus::Failed(error); agent.task_handle = None; + changed = true; + } + if changed { + self.persist_state_best_effort(); } } } @@ -441,11 +945,47 @@ impl SubAgentManager { /// Thread-safe wrapper for `SubAgentManager`. pub type SharedSubAgentManager = Arc>; +fn default_state_path(workspace: &Path) -> PathBuf { + workspace + .join(".deepseek") + .join("state") + .join(SUBAGENT_STATE_FILE) +} + +fn epoch_millis_now() -> u64 { + match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(duration) => u64::try_from(duration.as_millis()).unwrap_or(u64::MAX), + Err(_) => 0, + } +} + +fn instant_from_duration(duration: Duration) -> Instant { + Instant::now() + .checked_sub(duration) + .unwrap_or_else(Instant::now) +} + +fn write_json_atomic(path: &Path, value: &T) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let payload = serde_json::to_string_pretty(value)?; + let tmp_path = path.with_extension("tmp"); + fs::write(&tmp_path, payload)?; + fs::rename(tmp_path, path)?; + Ok(()) +} + /// Create a shared sub-agent manager with a configurable limit. #[must_use] pub fn new_shared_subagent_manager(workspace: PathBuf, max_agents: usize) -> SharedSubAgentManager { let max_agents = max_agents.clamp(1, MAX_SUBAGENTS); - Arc::new(Mutex::new(SubAgentManager::new(workspace, max_agents))) + let state_path = default_state_path(&workspace); + let mut manager = SubAgentManager::new(workspace, max_agents).with_state_path(state_path); + if let Err(err) = manager.load_state() { + eprintln!("Failed to load sub-agent state: {err}"); + } + Arc::new(Mutex::new(manager)) } // === Tool Implementations === @@ -454,20 +994,35 @@ pub fn new_shared_subagent_manager(workspace: PathBuf, max_agents: usize) -> Sha pub struct AgentSpawnTool { manager: SharedSubAgentManager, runtime: SubAgentRuntime, + name: &'static str, } impl AgentSpawnTool { /// Create a new spawn tool. #[must_use] pub fn new(manager: SharedSubAgentManager, runtime: SubAgentRuntime) -> Self { - Self { manager, runtime } + Self::with_name(manager, runtime, "agent_spawn") + } + + /// Create a new spawn tool with a custom tool name alias. + #[must_use] + pub fn with_name( + manager: SharedSubAgentManager, + runtime: SubAgentRuntime, + name: &'static str, + ) -> Self { + Self { + manager, + runtime, + name, + } } } #[async_trait] impl ToolSpec for AgentSpawnTool { fn name(&self) -> &'static str { - "agent_spawn" + self.name } fn description(&self) -> &'static str { @@ -482,17 +1037,47 @@ impl ToolSpec for AgentSpawnTool { "type": "string", "description": "Task description for the sub-agent" }, + "message": { + "type": "string", + "description": "Alias for prompt" + }, + "objective": { + "type": "string", + "description": "Alias for prompt" + }, + "items": { + "type": "array", + "description": "Structured input items (text, mention, skill, local_image, image)", + "items": { + "type": "object" + } + }, "type": { "type": "string", "description": "Sub-agent type: general, explore, plan, review, custom" }, + "agent_type": { + "type": "string", + "description": "Alias for type" + }, + "agent_name": { + "type": "string", + "description": "Alias for type" + }, + "role": { + "type": "string", + "description": "Role alias: worker, explorer, awaiter, default" + }, + "agent_role": { + "type": "string", + "description": "Alias for role" + }, "allowed_tools": { "type": "array", "items": { "type": "string" }, "description": "Explicit tool allowlist (required for custom type)" } - }, - "required": ["prompt"] + } }) } @@ -508,43 +1093,39 @@ impl ToolSpec for AgentSpawnTool { } async fn execute(&self, input: Value, _context: &ToolContext) -> Result { - let prompt = required_str(&input, "prompt")?.to_string(); - let agent_type = if let Some(kind) = input.get("type").and_then(|v| v.as_str()) { - SubAgentType::from_str(kind).ok_or_else(|| { - ToolError::invalid_input(format!( - "Invalid sub-agent type '{kind}'. Use: general, explore, plan, review, custom" - )) - })? - } else { - SubAgentType::General - }; - - let allowed_tools = input - .get("allowed_tools") - .and_then(|v| v.as_array()) - .map(|items| { - items - .iter() - .filter_map(|item| item.as_str().map(str::to_string)) - .collect::>() - }); + let spawn_request = parse_spawn_request(&input)?; let mut manager = self.manager.lock().await; let result = manager - .spawn_background( + .spawn_background_with_assignment( Arc::clone(&self.manager), self.runtime.clone(), - agent_type, - prompt, - allowed_tools, + spawn_request.agent_type, + spawn_request.prompt, + spawn_request.assignment, + spawn_request.allowed_tools, ) .map_err(|e| ToolError::execution_failed(format!("Failed to spawn sub-agent: {e}")))?; - let mut tool_result = - ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))?; + let mut tool_result = if self.name == "spawn_agent" { + let payload = json!({ + "agent_id": result.agent_id.clone(), + "nickname": Value::Null + }); + ToolResult::json(&payload).map_err(|e| ToolError::execution_failed(e.to_string()))? + } else { + ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))? + }; if result.status == SubAgentStatus::Running { - tool_result.metadata = Some(json!({ "status": "Running" })); + if self.name == "spawn_agent" { + tool_result.metadata = Some(json!({ + "status": "Running", + "snapshot": result + })); + } else { + tool_result.metadata = Some(json!({ "status": "Running" })); + } } Ok(tool_result) } @@ -581,16 +1162,19 @@ impl ToolSpec for AgentResultTool { "type": "string", "description": "ID returned by agent_spawn" }, + "id": { + "type": "string", + "description": "Alias for agent_id" + }, "block": { "type": "boolean", "description": "Wait for completion (default: false)" }, "timeout_ms": { "type": "integer", - "description": "Max wait time in milliseconds (default: 30000)" + "description": "Max wait time in milliseconds (default: 30000, clamped to 1000-3600000)" } - }, - "required": ["agent_id"] + } }) } @@ -599,22 +1183,36 @@ impl ToolSpec for AgentResultTool { } async fn execute(&self, input: Value, _context: &ToolContext) -> Result { - let agent_id = required_str(&input, "agent_id")?; + let agent_id = input + .get("agent_id") + .or_else(|| input.get("id")) + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::missing_field("agent_id"))?; let block = optional_bool(&input, "block", false); - let timeout_ms = optional_u64(&input, "timeout_ms", 30_000).clamp(1000, 300_000); + let timeout_ms = optional_u64(&input, "timeout_ms", DEFAULT_RESULT_TIMEOUT_MS) + .clamp(1000, MAX_RESULT_TIMEOUT_MS); - let result = if block { + let (result, timed_out) = if block { wait_for_result(&self.manager, agent_id, Duration::from_millis(timeout_ms)).await? } else { let manager = self.manager.lock().await; - manager - .get_result(agent_id) - .map_err(|e| ToolError::execution_failed(e.to_string()))? + ( + manager + .get_result(agent_id) + .map_err(|e| ToolError::execution_failed(e.to_string()))?, + false, + ) }; let mut tool_result = ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))?; - if result.status == SubAgentStatus::Running { + if timed_out { + tool_result.metadata = Some(json!({ + "status": "TimedOut", + "timed_out": true, + "timeout_ms": timeout_ms + })); + } else if result.status == SubAgentStatus::Running { tool_result.metadata = Some(json!({ "status": "Running" })); } Ok(tool_result) @@ -684,6 +1282,135 @@ pub struct AgentListTool { manager: SharedSubAgentManager, } +/// Tool to close a running sub-agent (alias for cancel). +pub struct AgentCloseTool { + manager: SharedSubAgentManager, +} + +impl AgentCloseTool { + /// Create a new close tool. + #[must_use] + pub fn new(manager: SharedSubAgentManager) -> Self { + Self { manager } + } +} + +#[async_trait] +impl ToolSpec for AgentCloseTool { + fn name(&self) -> &'static str { + "close_agent" + } + + fn description(&self) -> &'static str { + "Close a running sub-agent. Alias for agent_cancel." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Agent id returned by agent_spawn" + }, + "agent_id": { + "type": "string", + "description": "Alias for id" + } + } + }) + } + + fn capabilities(&self) -> Vec { + vec![ + ToolCapability::ExecutesCode, + ToolCapability::RequiresApproval, + ] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Required + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let agent_id = input + .get("id") + .or_else(|| input.get("agent_id")) + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::missing_field("id"))?; + let mut manager = self.manager.lock().await; + let result = manager + .cancel(agent_id) + .map_err(|e| ToolError::execution_failed(format!("Failed to close sub-agent: {e}")))?; + ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string())) + } +} + +/// Tool to resume an existing sub-agent. +pub struct AgentResumeTool { + manager: SharedSubAgentManager, + runtime: SubAgentRuntime, +} + +impl AgentResumeTool { + /// Create a new resume tool. + #[must_use] + pub fn new(manager: SharedSubAgentManager, runtime: SubAgentRuntime) -> Self { + Self { manager, runtime } + } +} + +#[async_trait] +impl ToolSpec for AgentResumeTool { + fn name(&self) -> &'static str { + "resume_agent" + } + + fn description(&self) -> &'static str { + "Resume a previously closed or completed sub-agent by restarting its assignment." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Agent id to resume" + }, + "agent_id": { + "type": "string", + "description": "Alias for id" + } + } + }) + } + + fn capabilities(&self) -> Vec { + vec![ + ToolCapability::ExecutesCode, + ToolCapability::RequiresApproval, + ] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Required + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let agent_id = input + .get("id") + .or_else(|| input.get("agent_id")) + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::missing_field("id"))?; + let mut manager = self.manager.lock().await; + let result = manager + .resume(Arc::clone(&self.manager), self.runtime.clone(), agent_id) + .map_err(|e| ToolError::execution_failed(format!("Failed to resume sub-agent: {e}")))?; + ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string())) + } +} + impl AgentListTool { /// Create a new list tool. #[must_use] @@ -718,7 +1445,8 @@ impl ToolSpec for AgentListTool { _input: Value, _context: &ToolContext, ) -> Result { - let manager = self.manager.lock().await; + let mut manager = self.manager.lock().await; + manager.cleanup(COMPLETED_AGENT_RETENTION); let results = manager.list(); ToolResult::json(&results).map_err(|e| ToolError::execution_failed(e.to_string())) } @@ -768,12 +1496,18 @@ impl ToolSpec for AgentSendInputTool { "type": "string", "description": "Alias for message" }, + "items": { + "type": "array", + "description": "Structured input items (text, mention, skill, local_image, image)", + "items": { + "type": "object" + } + }, "interrupt": { "type": "boolean", "description": "Prioritize this message over pending inputs" } - }, - "required": ["message"] + } }) } @@ -787,16 +1521,12 @@ impl ToolSpec for AgentSendInputTool { .or_else(|| input.get("id")) .and_then(|v| v.as_str()) .ok_or_else(|| ToolError::missing_field("agent_id"))?; - let message = input - .get("message") - .or_else(|| input.get("input")) - .and_then(|v| v.as_str()) - .ok_or_else(|| ToolError::missing_field("message"))?; + let message = parse_text_or_items(&input, &["message", "input"], "items", "message")?; let interrupt = optional_bool(&input, "interrupt", false); let mut manager = self.manager.lock().await; manager - .send_input(agent_id, message.to_string(), interrupt) + .send_input(agent_id, message, interrupt) .map_err(|e| ToolError::execution_failed(e.to_string()))?; let snapshot = manager .get_result(agent_id) @@ -806,6 +1536,98 @@ impl ToolSpec for AgentSendInputTool { } } +/// Tool to update assignment metadata for a sub-agent. +pub struct AgentAssignTool { + manager: SharedSubAgentManager, + name: &'static str, +} + +impl AgentAssignTool { + /// Create a new assignment tool. + #[must_use] + pub fn new(manager: SharedSubAgentManager, name: &'static str) -> Self { + Self { manager, name } + } +} + +#[async_trait] +impl ToolSpec for AgentAssignTool { + fn name(&self) -> &'static str { + self.name + } + + fn description(&self) -> &'static str { + "Update a sub-agent assignment and optionally send an immediate instruction." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "Agent id returned by agent_spawn" + }, + "id": { + "type": "string", + "description": "Alias for agent_id" + }, + "objective": { + "type": "string", + "description": "Updated assignment objective" + }, + "role": { + "type": "string", + "description": "Updated role alias: worker, explorer, awaiter, default" + }, + "agent_role": { + "type": "string", + "description": "Alias for role" + }, + "message": { + "type": "string", + "description": "Optional coordinator note to send to the agent" + }, + "input": { + "type": "string", + "description": "Alias for message" + }, + "items": { + "type": "array", + "description": "Structured input items (text, mention, skill, local_image, image)", + "items": { + "type": "object" + } + }, + "interrupt": { + "type": "boolean", + "description": "Prioritize this assignment update in the agent inbox (default: true)" + } + } + }) + } + + fn capabilities(&self) -> Vec { + vec![] + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let request = parse_assign_request(&input)?; + let mut manager = self.manager.lock().await; + let result = manager + .assign( + &request.agent_id, + request.objective, + request.role, + request.message, + request.interrupt, + ) + .map_err(|e| ToolError::execution_failed(format!("Failed to assign sub-agent: {e}")))?; + + ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string())) + } +} + /// Tool to wait for sub-agents to complete. pub struct AgentWaitTool { manager: SharedSubAgentManager, @@ -837,15 +1659,28 @@ impl ToolSpec for AgentWaitTool { "ids": { "type": "array", "items": { "type": "string" }, - "description": "Agent IDs to wait on" + "description": "Agent IDs to wait on. When omitted, waits on all currently running sub-agents." + }, + "agent_ids": { + "type": "array", + "items": { "type": "string" }, + "description": "Alias for ids" }, "agent_id": { "type": "string", "description": "Single agent ID" }, + "id": { + "type": "string", + "description": "Alias for agent_id" + }, + "wait_mode": { + "type": "string", + "description": "Wait behavior: any (default) or all" + }, "timeout_ms": { "type": "integer", - "description": "Max wait time in milliseconds (default: 30000)" + "description": "Max wait time in milliseconds (default: 30000, clamped to 10000-3600000)" } } }) @@ -856,46 +1691,83 @@ impl ToolSpec for AgentWaitTool { } async fn execute(&self, input: Value, _context: &ToolContext) -> Result { - let timeout_ms = optional_u64(&input, "timeout_ms", 30_000).clamp(1000, 300_000); - let mut ids: Vec = Vec::new(); - if let Some(list) = input.get("ids").and_then(|v| v.as_array()) { - ids.extend( - list.iter() - .filter_map(|item| item.as_str().map(str::to_string)), - ); - } + let timeout_ms = optional_u64(&input, "timeout_ms", DEFAULT_RESULT_TIMEOUT_MS) + .clamp(MIN_WAIT_TIMEOUT_MS, MAX_RESULT_TIMEOUT_MS); + let mut ids = parse_wait_ids(&input); if ids.is_empty() { - if let Some(id) = input.get("agent_id").and_then(|v| v.as_str()) { - ids.push(id.to_string()); - } + let manager = self.manager.lock().await; + ids = manager + .list() + .into_iter() + .filter(|snapshot| snapshot.status == SubAgentStatus::Running) + .map(|snapshot| snapshot.agent_id) + .collect(); } + let wait_mode = parse_wait_mode(&input)?; + if ids.is_empty() { - return Err(ToolError::missing_field("ids")); + let empty: Vec = Vec::new(); + let mut result = + ToolResult::json(&empty).map_err(|e| ToolError::execution_failed(e.to_string()))?; + result.metadata = Some(json!({ + "wait_mode": wait_mode.as_str(), + "timed_out": false, + "status": "Completed", + "timeout_ms": timeout_ms, + "waited_ids": [], + "completed_ids": [], + "running_ids": [], + "status_by_id": {} + })); + return Ok(result); } - let deadline = Instant::now() + Duration::from_millis(timeout_ms); - loop { - let snapshots = { - let manager = self.manager.lock().await; - ids.iter() - .map(|id| { - manager - .get_result(id) - .map_err(|e| ToolError::execution_failed(e.to_string())) - }) - .collect::, _>>()? - }; + let waited_ids = ids.clone(); - let any_done = snapshots - .iter() - .any(|snapshot| snapshot.status != SubAgentStatus::Running); - if any_done || Instant::now() >= deadline { - return ToolResult::json(&snapshots) - .map_err(|e| ToolError::execution_failed(e.to_string())); - } + let (snapshots, timed_out) = wait_for_agents( + &self.manager, + &ids, + wait_mode, + Duration::from_millis(timeout_ms), + ) + .await?; - tokio::time::sleep(RESULT_POLL_INTERVAL).await; - } + let all_done = snapshots + .iter() + .all(|snapshot| snapshot.status != SubAgentStatus::Running); + let completed_ids = snapshots + .iter() + .filter(|snapshot| snapshot.status != SubAgentStatus::Running) + .map(|snapshot| snapshot.agent_id.clone()) + .collect::>(); + let running_ids = snapshots + .iter() + .filter(|snapshot| snapshot.status == SubAgentStatus::Running) + .map(|snapshot| snapshot.agent_id.clone()) + .collect::>(); + let status_by_id = snapshots + .iter() + .map(|snapshot| { + ( + snapshot.agent_id.clone(), + subagent_status_name(&snapshot.status).to_string(), + ) + }) + .collect::>(); + + let mut result = + ToolResult::json(&snapshots).map_err(|e| ToolError::execution_failed(e.to_string()))?; + result.metadata = Some(json!({ + "wait_mode": wait_mode.as_str(), + "timed_out": timed_out, + "status": if timed_out { "TimedOut" } else if all_done { "Completed" } else { "Partial" }, + "timeout_ms": timeout_ms, + "waited_ids": waited_ids, + "completed_ids": completed_ids, + "running_ids": running_ids, + "status_by_id": status_by_id + })); + Ok(result) } } @@ -929,14 +1801,49 @@ impl ToolSpec for DelegateToAgentTool { "properties": { "agent_name": { "type": "string", - "description": "Name or type of the agent: general, explore, plan, review" + "description": "Name/type alias for the agent (general, explore, plan, review, worker, explorer, awaiter)" + }, + "type": { + "type": "string", + "description": "Alias for agent_name" + }, + "agent_type": { + "type": "string", + "description": "Alias for agent_name" + }, + "role": { + "type": "string", + "description": "Role alias: worker, explorer, awaiter, default" + }, + "agent_role": { + "type": "string", + "description": "Alias for role" }, "objective": { "type": "string", "description": "The goal or task description for the agent" + }, + "prompt": { + "type": "string", + "description": "Alias for objective" + }, + "message": { + "type": "string", + "description": "Alias for objective" + }, + "items": { + "type": "array", + "description": "Structured input items (text, mention, skill, local_image, image)", + "items": { + "type": "object" + } + }, + "allowed_tools": { + "type": "array", + "items": { "type": "string" }, + "description": "Explicit tool allowlist (required for custom type)" } - }, - "required": ["objective"] + } }) } @@ -952,19 +1859,328 @@ impl ToolSpec for DelegateToAgentTool { } async fn execute(&self, input: Value, context: &ToolContext) -> Result { - let objective = required_str(&input, "objective")?.to_string(); - let agent_type_str = input - .get("agent_name") - .and_then(|v| v.as_str()) - .unwrap_or("general"); - - let spawn_input = json!({ - "prompt": objective, - "type": agent_type_str - }); - let spawn_tool = AgentSpawnTool::new(self.manager.clone(), self.runtime.clone()); - spawn_tool.execute(spawn_input, context).await + spawn_tool.execute(input, context).await + } +} + +/// Tool to process CSV rows by spawning one worker sub-agent per row. +pub struct SpawnAgentsOnCsvTool { + manager: SharedSubAgentManager, + runtime: SubAgentRuntime, +} + +struct AgentJobReportCleanup { + job_id: String, +} + +impl AgentJobReportCleanup { + fn new(job_id: String) -> Self { + clear_agent_job_results(&job_id); + Self { job_id } + } +} + +impl Drop for AgentJobReportCleanup { + fn drop(&mut self) { + clear_agent_job_results(&self.job_id); + } +} + +impl SpawnAgentsOnCsvTool { + /// Create a new CSV batch orchestration tool. + #[must_use] + pub fn new(manager: SharedSubAgentManager, runtime: SubAgentRuntime) -> Self { + Self { manager, runtime } + } +} + +#[async_trait] +impl ToolSpec for SpawnAgentsOnCsvTool { + fn name(&self) -> &'static str { + "spawn_agents_on_csv" + } + + fn description(&self) -> &'static str { + "Process a CSV by spawning one worker sub-agent per row. The instruction string is a template where `{column}` placeholders are replaced with row values. Each worker must call `report_agent_job_result` with a JSON object (matching `output_schema` when provided); missing reports are treated as failures. This call blocks until all rows finish and automatically exports results to `output_csv_path` (or a default path)." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "csv_path": { + "type": "string", + "description": "Path to the input CSV file" + }, + "instruction": { + "type": "string", + "description": "Instruction template. Use {column_name} placeholders for row values." + }, + "id_column": { + "type": "string", + "description": "Optional CSV column name used as stable item id" + }, + "max_concurrency": { + "type": "integer", + "description": "Maximum concurrent workers (default: 16)" + }, + "max_workers": { + "type": "integer", + "description": "Alias for max_concurrency" + }, + "max_runtime_seconds": { + "type": "integer", + "description": "Per-worker timeout in seconds (default: 1800)" + }, + "output_csv_path": { + "type": "string", + "description": "Optional output CSV path for worker results" + }, + "output_schema": { + "type": "object", + "description": "Optional JSON schema-like object used to validate worker JSON output" + } + }, + "required": ["csv_path", "instruction"] + }) + } + + fn capabilities(&self) -> Vec { + vec![ + ToolCapability::ExecutesCode, + ToolCapability::RequiresApproval, + ] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Required + } + + async fn execute(&self, input: Value, context: &ToolContext) -> Result { + let csv_path_raw = required_str(&input, "csv_path")?; + let csv_path = context.resolve_path(csv_path_raw)?; + let instruction_template = required_str(&input, "instruction")?; + if instruction_template.trim().is_empty() { + return Err(ToolError::invalid_input( + "instruction cannot be empty".to_string(), + )); + } + + let id_column = optional_input_str(&input, &["id_column"]).map(str::to_string); + let rows = load_csv_rows(&csv_path, id_column.as_deref())?; + if rows.is_empty() { + return Err(ToolError::invalid_input(format!( + "CSV '{}' has no data rows", + csv_path.display() + ))); + } + + let output_schema = input.get("output_schema").cloned(); + let output_csv_path = resolve_results_csv_path(context, &input, &csv_path)?; + let max_runtime_seconds = optional_u64( + &input, + "max_runtime_seconds", + DEFAULT_CSV_MAX_RUNTIME_SECONDS, + ) + .clamp(1, MAX_CSV_MAX_RUNTIME_SECONDS); + let requested_concurrency = parse_csv_concurrency(&input); + + let max_agents = { + let manager = self.manager.lock().await; + manager.max_agents().max(1) + }; + let max_concurrency = requested_concurrency.clamp(1, max_agents as u64) as usize; + + let semaphore = Arc::new(Semaphore::new(max_concurrency)); + let timeout = Duration::from_secs(max_runtime_seconds); + let job_id = format!("job_{}", &Uuid::new_v4().to_string()[..8]); + let _cleanup = AgentJobReportCleanup::new(job_id.clone()); + let stop_requested = Arc::new(AtomicBool::new(false)); + let mut workers = FuturesUnordered::new(); + + for row in rows { + let permit = semaphore + .clone() + .acquire_owned() + .await + .map_err(|_| ToolError::execution_failed("Worker semaphore closed"))?; + let manager = self.manager.clone(); + let runtime = self.runtime.clone(); + let template = instruction_template.to_string(); + let schema = output_schema.clone(); + let job_id = job_id.clone(); + let stop_requested = stop_requested.clone(); + + workers.push(tokio::spawn(async move { + let _permit = permit; + run_csv_row_agent( + manager, + runtime, + &job_id, + row, + &template, + timeout, + schema, + stop_requested, + ) + .await + })); + } + + let mut outcomes = Vec::new(); + while let Some(joined) = workers.next().await { + match joined { + Ok(outcome) => outcomes.push(outcome), + Err(err) => outcomes.push(CsvWorkerOutcome { + row_index: usize::MAX, + item_id: "worker_join".to_string(), + status: "failed".to_string(), + agent_id: None, + duration_ms: 0, + error: Some(format!("Worker task failed to join: {err}")), + result: None, + result_json: None, + }), + } + } + + outcomes.sort_by_key(|outcome| outcome.row_index); + + write_csv_worker_outcomes(&output_csv_path, &outcomes).map_err(|err| { + ToolError::execution_failed(format!("Failed to write output CSV: {err}")) + })?; + + let completed = outcomes + .iter() + .filter(|outcome| outcome.status == "completed") + .count(); + let skipped = outcomes + .iter() + .filter(|outcome| outcome.status == "skipped") + .count(); + let timed_out = outcomes + .iter() + .filter(|outcome| outcome.status == "timed_out") + .count(); + let failed = outcomes + .iter() + .filter(|outcome| outcome.status == "failed") + .count() + + timed_out; + + let summary = SpawnAgentsOnCsvSummary { + job_id, + total: outcomes.len(), + completed, + failed, + timed_out, + skipped, + output_csv_path: output_csv_path.display().to_string(), + results: outcomes, + }; + let status = if summary.failed > 0 { + if summary.completed == 0 && summary.skipped == 0 { + "Failed" + } else { + "Partial" + } + } else if stop_requested.load(Ordering::Relaxed) || summary.skipped > 0 { + "Cancelled" + } else { + "Completed" + }; + let mut result = + ToolResult::json(&summary).map_err(|e| ToolError::execution_failed(e.to_string()))?; + result.metadata = Some(json!({ + "status": status, + "job_id": summary.job_id, + "completed": summary.completed, + "failed": summary.failed, + "timed_out": summary.timed_out, + "skipped": summary.skipped, + "stop_requested": stop_requested.load(Ordering::Relaxed), + "output_csv_path": summary.output_csv_path, + })); + Ok(result) + } +} + +/// Worker-oriented tool to report structured row outcomes for CSV agent jobs. +pub struct ReportAgentJobResultTool; + +#[async_trait] +impl ToolSpec for ReportAgentJobResultTool { + fn name(&self) -> &'static str { + "report_agent_job_result" + } + + fn description(&self) -> &'static str { + "Worker-only tool to report a structured result for a spawn_agents_on_csv row." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Identifier of the CSV job" + }, + "item_id": { + "type": "string", + "description": "Identifier of the CSV row item" + }, + "result": { + "type": "object", + "description": "Structured JSON result to record for the row" + }, + "stop": { + "type": "boolean", + "description": "Optional. When true, cancels remaining unstarted CSV rows for this job." + } + }, + "required": ["job_id", "item_id", "result"] + }) + } + + fn capabilities(&self) -> Vec { + vec![] + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let job_id = required_str(&input, "job_id")?.trim(); + let item_id = required_str(&input, "item_id")?.trim(); + if job_id.is_empty() { + return Err(ToolError::invalid_input("job_id cannot be empty")); + } + if item_id.is_empty() { + return Err(ToolError::invalid_input("item_id cannot be empty")); + } + let result = input + .get("result") + .cloned() + .ok_or_else(|| ToolError::missing_field("result"))?; + if !result.is_object() { + return Err(ToolError::invalid_input("result must be a JSON object")); + } + let reporting_agent_id = input + .get("__reporting_agent_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let stop = optional_bool(&input, "stop", false); + let accepted = + record_agent_job_result(job_id, item_id, result.clone(), stop, reporting_agent_id); + + let payload = json!({ + "job_id": job_id, + "item_id": item_id, + "accepted": accepted, + "stop": stop, + "result": result + }); + ToolResult::json(&payload).map_err(|e| ToolError::execution_failed(e.to_string())) } } @@ -976,6 +2192,7 @@ struct SubAgentTask { agent_id: String, agent_type: SubAgentType, prompt: String, + assignment: SubAgentAssignment, allowed_tools: Vec, started_at: Instant, max_steps: u32, @@ -989,6 +2206,7 @@ async fn run_subagent_task(task: SubAgentTask) { task.agent_id.clone(), task.agent_type, task.prompt, + task.assignment, task.allowed_tools, task.started_at, task.max_steps, @@ -1020,6 +2238,7 @@ async fn run_subagent( agent_id: String, agent_type: SubAgentType, prompt: String, + assignment: SubAgentAssignment, allowed_tools: Vec, started_at: Instant, max_steps: u32, @@ -1033,12 +2252,24 @@ async fn run_subagent( Arc::new(Mutex::new(TodoList::new())), Arc::new(Mutex::new(PlanState::default())), ); + let unavailable_tools = tool_registry.unavailable_allowed_tools(); + if !unavailable_tools.is_empty() { + return Err(anyhow!( + "Sub-agent requested unavailable tools: {}", + unavailable_tools.join(", ") + )); + } let tools = tool_registry.tools_for_model(); + emit_agent_progress( + runtime.event_tx.as_ref(), + &agent_id, + format!("started ({})", agent_type.as_str()), + ); let mut messages = vec![Message { role: "user".to_string(), content: vec![ContentBlock::Text { - text: prompt, + text: build_assignment_prompt(&prompt, &assignment, &agent_type), cache_control: None, }], }]; @@ -1049,6 +2280,11 @@ async fn run_subagent( for _step in 0..max_steps { steps += 1; + emit_agent_progress( + runtime.event_tx.as_ref(), + &agent_id, + format!("step {steps}/{max_steps}: requesting model response"), + ); while let Ok(input) = input_rx.try_recv() { if input.interrupt { @@ -1115,15 +2351,35 @@ async fn run_subagent( pending_inputs.push_back(input); } if pending_inputs.is_empty() { + emit_agent_progress( + runtime.event_tx.as_ref(), + &agent_id, + format!("step {steps}/{max_steps}: complete"), + ); break; } continue; } + emit_agent_progress( + runtime.event_tx.as_ref(), + &agent_id, + format!( + "step {steps}/{max_steps}: executing {} tool call(s)", + tool_uses.len() + ), + ); let mut tool_results: Vec = Vec::new(); for (tool_id, tool_name, tool_input) in tool_uses { + emit_agent_progress( + runtime.event_tx.as_ref(), + &agent_id, + format!("step {steps}/{max_steps}: running tool '{tool_name}'"), + ); let result = match tokio::time::timeout(TOOL_TIMEOUT, async { - tool_registry.execute(&tool_name, tool_input).await + tool_registry + .execute(&agent_id, &tool_name, tool_input) + .await }) .await { @@ -1131,6 +2387,11 @@ async fn run_subagent( Ok(Err(e)) => format!("Error: {e}"), Err(_) => format!("Error: Tool {tool_name} timed out"), }; + emit_agent_progress( + runtime.event_tx.as_ref(), + &agent_id, + format!("step {steps}/{max_steps}: finished tool '{tool_name}'"), + ); tool_results.push(ContentBlock::ToolResult { tool_use_id: tool_id, @@ -1151,6 +2412,7 @@ async fn run_subagent( Ok(SubAgentResult { agent_id, agent_type, + assignment, status: SubAgentStatus::Completed, result: final_result, steps_taken: steps, @@ -1162,7 +2424,7 @@ async fn wait_for_result( manager: &SharedSubAgentManager, agent_id: &str, timeout: Duration, -) -> Result { +) -> Result<(SubAgentResult, bool), ToolError> { let deadline = Instant::now() + timeout; loop { @@ -1173,14 +2435,918 @@ async fn wait_for_result( .map_err(|e| ToolError::execution_failed(e.to_string()))? }; - if snapshot.status != SubAgentStatus::Running || Instant::now() >= deadline { - return Ok(snapshot); + if snapshot.status != SubAgentStatus::Running { + return Ok((snapshot, false)); + } + if Instant::now() >= deadline { + return Ok((snapshot, true)); } tokio::time::sleep(RESULT_POLL_INTERVAL).await; } } +async fn wait_for_agents( + manager: &SharedSubAgentManager, + ids: &[String], + wait_mode: WaitMode, + timeout: Duration, +) -> Result<(Vec, bool), ToolError> { + let deadline = Instant::now() + timeout; + + loop { + let snapshots = { + let manager = manager.lock().await; + ids.iter() + .map(|id| { + manager + .get_result(id) + .map_err(|e| ToolError::execution_failed(e.to_string())) + }) + .collect::, _>>()? + }; + + if wait_mode.condition_met(&snapshots) { + return Ok((snapshots, false)); + } + if Instant::now() >= deadline { + return Ok((snapshots, true)); + } + + tokio::time::sleep(RESULT_POLL_INTERVAL).await; + } +} + +fn parse_wait_mode(input: &Value) -> Result { + let raw_mode = input + .get("wait_mode") + .and_then(|v| v.as_str()) + .unwrap_or("any"); + WaitMode::from_str(raw_mode).ok_or_else(|| { + ToolError::invalid_input(format!("Invalid wait_mode '{raw_mode}'. Use: any or all")) + }) +} + +fn parse_wait_ids(input: &Value) -> Vec { + let mut ids = Vec::new(); + for key in ["ids", "agent_ids"] { + if let Some(list) = input.get(key).and_then(|v| v.as_array()) { + for value in list { + if let Some(id) = value.as_str() { + let id = id.trim(); + if !id.is_empty() && !ids.iter().any(|existing| existing == id) { + ids.push(id.to_string()); + } + } + } + } + } + + for key in ["agent_id", "id"] { + if let Some(id) = input.get(key).and_then(|v| v.as_str()) { + let id = id.trim(); + if !id.is_empty() && !ids.iter().any(|existing| existing == id) { + ids.push(id.to_string()); + } + } + } + + ids +} + +fn optional_input_str<'a>(input: &'a Value, keys: &[&str]) -> Option<&'a str> { + keys.iter() + .filter_map(|key| input.get(*key).and_then(Value::as_str)) + .map(str::trim) + .find(|value| !value.is_empty()) +} + +fn parse_text_or_items( + input: &Value, + text_keys: &[&str], + items_key: &str, + required_field: &str, +) -> Result { + let text = optional_input_str(input, text_keys).map(str::to_string); + let items = parse_items_text(input, items_key)?; + match (text, items) { + (Some(_), Some(_)) => Err(ToolError::invalid_input(format!( + "Provide either {required_field} text or {items_key}, but not both" + ))), + (Some(text), None) => Ok(text), + (None, Some(items)) => Ok(items), + (None, None) => Err(ToolError::missing_field(required_field)), + } +} + +fn parse_optional_text_or_items( + input: &Value, + text_keys: &[&str], + items_key: &str, +) -> Result, ToolError> { + let text = optional_input_str(input, text_keys).map(str::to_string); + let items = parse_items_text(input, items_key)?; + match (text, items) { + (Some(_), Some(_)) => Err(ToolError::invalid_input(format!( + "Provide either {} text or {}, but not both", + text_keys[0], items_key + ))), + (Some(text), None) => Ok(Some(text)), + (None, Some(items)) => Ok(Some(items)), + (None, None) => Ok(None), + } +} + +fn parse_items_text(input: &Value, key: &str) -> Result, ToolError> { + let Some(items) = input.get(key) else { + return Ok(None); + }; + let array = items + .as_array() + .ok_or_else(|| ToolError::invalid_input(format!("'{key}' must be an array")))?; + if array.is_empty() { + return Err(ToolError::invalid_input(format!("'{key}' cannot be empty"))); + } + + let mut lines = Vec::new(); + for item in array { + let object = item + .as_object() + .ok_or_else(|| ToolError::invalid_input("each item must be an object"))?; + let item_type = object + .get("type") + .and_then(Value::as_str) + .unwrap_or("text") + .trim(); + let rendered = match item_type { + "text" => object + .get("text") + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_string) + .ok_or_else(|| ToolError::invalid_input("text item requires non-empty text"))?, + "mention" => { + let name = object + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .ok_or_else(|| ToolError::invalid_input("mention item requires name"))?; + let path = object + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .ok_or_else(|| ToolError::invalid_input("mention item requires path"))?; + format!("[mention:${name}]({path})") + } + "skill" => { + let name = object + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .ok_or_else(|| ToolError::invalid_input("skill item requires name"))?; + let path = object + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .ok_or_else(|| ToolError::invalid_input("skill item requires path"))?; + format!("[skill:${name}]({path})") + } + "local_image" => { + let path = object + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .ok_or_else(|| ToolError::invalid_input("local_image item requires path"))?; + format!("[local_image:{path}]") + } + "image" => { + let url = object + .get("image_url") + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .ok_or_else(|| ToolError::invalid_input("image item requires image_url"))?; + format!("[image:{url}]") + } + _ => object + .get("text") + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| "[input]".to_string()), + }; + lines.push(rendered); + } + + Ok(Some(lines.join("\n"))) +} + +fn parse_spawn_request(input: &Value) -> Result { + let prompt = parse_text_or_items( + input, + &["prompt", "message", "objective"], + "items", + "prompt", + )?; + + let type_input = optional_input_str(input, &["type", "agent_type", "agent_name"]); + let role_input = optional_input_str(input, &["role", "agent_role"]); + + let parsed_type = type_input + .map(|kind| { + SubAgentType::from_str(kind).ok_or_else(|| { + ToolError::invalid_input(format!( + "Invalid sub-agent type '{kind}'. Use: {VALID_SUBAGENT_TYPES}" + )) + }) + }) + .transpose()?; + + let parsed_role_type = role_input + .map(|role| { + SubAgentType::from_str(role).ok_or_else(|| { + ToolError::invalid_input(format!( + "Invalid role alias '{role}'. Use: worker, explorer, awaiter, default" + )) + }) + }) + .transpose()?; + + if let (Some(type_kind), Some(role_kind)) = (&parsed_type, &parsed_role_type) + && type_kind != role_kind + { + return Err(ToolError::invalid_input( + "Conflicting type/agent_type and role/agent_role values".to_string(), + )); + } + + let agent_type = parsed_type + .or(parsed_role_type) + .unwrap_or(SubAgentType::General); + + if let Some(role) = role_input + && normalize_role_alias(role).is_none() + { + return Err(ToolError::invalid_input(format!( + "Invalid role alias '{role}'. Use: worker, explorer, awaiter, default" + ))); + } + + let role = role_input + .and_then(normalize_role_alias) + .or_else(|| type_input.and_then(normalize_role_alias)) + .map(str::to_string); + + let allowed_tools = input + .get("allowed_tools") + .and_then(|v| v.as_array()) + .map(|items| { + let mut tools = Vec::new(); + for item in items { + if let Some(tool) = item.as_str() { + let trimmed = tool.trim(); + if !trimmed.is_empty() && !tools.iter().any(|existing| existing == trimmed) { + tools.push(trimmed.to_string()); + } + } + } + tools + }); + + Ok(SpawnRequest { + prompt: prompt.clone(), + agent_type, + assignment: SubAgentAssignment::new(prompt, role), + allowed_tools, + }) +} + +fn parse_assign_request(input: &Value) -> Result { + let agent_id = input + .get("agent_id") + .or_else(|| input.get("id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + .ok_or_else(|| ToolError::missing_field("agent_id"))? + .to_string(); + let objective = optional_input_str(input, &["objective"]).map(str::to_string); + let role = optional_input_str(input, &["role", "agent_role"]) + .map(|role| { + normalize_role_alias(role).ok_or_else(|| { + ToolError::invalid_input(format!( + "Invalid role alias '{role}'. Use: worker, explorer, awaiter, default" + )) + }) + }) + .transpose()? + .map(str::to_string); + let message = parse_optional_text_or_items(input, &["message", "input"], "items")?; + let interrupt = optional_bool(input, "interrupt", true); + + if objective.is_none() && role.is_none() && message.is_none() { + return Err(ToolError::invalid_input( + "Provide at least one of objective, role/agent_role, message/input, or items" + .to_string(), + )); + } + + Ok(AssignRequest { + agent_id, + objective, + role, + message, + interrupt, + }) +} + +fn parse_csv_concurrency(input: &Value) -> u64 { + if input.get("max_concurrency").is_some() { + return optional_u64(input, "max_concurrency", DEFAULT_CSV_MAX_CONCURRENCY).max(1); + } + if input.get("max_workers").is_some() { + return optional_u64(input, "max_workers", DEFAULT_CSV_MAX_CONCURRENCY).max(1); + } + DEFAULT_CSV_MAX_CONCURRENCY +} + +fn agent_job_reports_store() -> &'static StdMutex>> +{ + AGENT_JOB_REPORTS.get_or_init(|| StdMutex::new(HashMap::new())) +} + +fn agent_job_assignments_store() -> &'static StdMutex>> { + AGENT_JOB_ASSIGNMENTS.get_or_init(|| StdMutex::new(HashMap::new())) +} + +fn record_agent_job_assignment(job_id: &str, item_id: &str, agent_id: &str) { + let mut store = agent_job_assignments_store() + .lock() + .expect("agent job assignments lock poisoned"); + let job = store.entry(job_id.to_string()).or_default(); + job.insert(item_id.to_string(), agent_id.to_string()); +} + +fn remove_agent_job_assignment(job_id: &str, item_id: &str) { + let mut store = agent_job_assignments_store() + .lock() + .expect("agent job assignments lock poisoned"); + if let Some(job) = store.get_mut(job_id) { + job.remove(item_id); + if job.is_empty() { + store.remove(job_id); + } + } +} + +fn clear_agent_job_assignments(job_id: &str) { + let mut store = agent_job_assignments_store() + .lock() + .expect("agent job assignments lock poisoned"); + store.remove(job_id); +} + +fn report_matches_assignment( + job_id: &str, + item_id: &str, + reporting_agent_id: Option<&str>, +) -> bool { + let Some(reporting_agent_id) = reporting_agent_id else { + return false; + }; + let store = agent_job_assignments_store() + .lock() + .expect("agent job assignments lock poisoned"); + store + .get(job_id) + .and_then(|job| job.get(item_id)) + .is_some_and(|expected| expected == reporting_agent_id) +} + +fn record_agent_job_result( + job_id: &str, + item_id: &str, + result: Value, + stop: bool, + reporting_agent_id: Option<&str>, +) -> bool { + if !report_matches_assignment(job_id, item_id, reporting_agent_id) { + return false; + } + let mut store = agent_job_reports_store() + .lock() + .expect("agent job reports lock poisoned"); + let job = store.entry(job_id.to_string()).or_default(); + if job.contains_key(item_id) { + return false; + } + job.insert(item_id.to_string(), AgentJobReport { result, stop }); + true +} + +fn take_agent_job_result(job_id: &str, item_id: &str) -> Option { + let mut store = agent_job_reports_store() + .lock() + .expect("agent job reports lock poisoned"); + let result = store.get_mut(job_id).and_then(|job| job.remove(item_id)); + if store + .get(job_id) + .is_some_and(|job_results| job_results.is_empty()) + { + store.remove(job_id); + } + remove_agent_job_assignment(job_id, item_id); + result +} + +fn clear_agent_job_results(job_id: &str) { + let mut store = agent_job_reports_store() + .lock() + .expect("agent job reports lock poisoned"); + store.remove(job_id); + clear_agent_job_assignments(job_id); +} + +fn resolve_results_csv_path( + context: &ToolContext, + input: &Value, + csv_path: &Path, +) -> Result { + if let Some(path) = optional_input_str(input, &["output_csv_path"]) { + context.resolve_path(path) + } else { + Ok(default_results_csv_path(csv_path)) + } +} + +fn default_results_csv_path(csv_path: &Path) -> PathBuf { + let stem = csv_path + .file_stem() + .and_then(|stem| stem.to_str()) + .filter(|stem| !stem.is_empty()) + .unwrap_or("results"); + csv_path.with_file_name(format!("{stem}.results.csv")) +} + +fn load_csv_rows(csv_path: &Path, id_column: Option<&str>) -> Result, ToolError> { + let mut reader = csv::ReaderBuilder::new() + .from_path(csv_path) + .map_err(|err| { + ToolError::execution_failed(format!( + "Failed to read CSV '{}': {err}", + csv_path.display() + )) + })?; + + let headers = reader + .headers() + .map_err(|err| { + ToolError::execution_failed(format!( + "Failed to read CSV headers '{}': {err}", + csv_path.display() + )) + })? + .clone(); + if headers.is_empty() { + return Err(ToolError::invalid_input(format!( + "CSV '{}' has no headers", + csv_path.display() + ))); + } + let mut seen_headers = HashSet::new(); + for header in &headers { + if !seen_headers.insert(header.to_string()) { + return Err(ToolError::invalid_input(format!( + "CSV '{}' has duplicate header '{}'", + csv_path.display(), + header + ))); + } + } + + let id_index = if let Some(column_name) = id_column { + let trimmed = column_name.trim(); + if trimmed.is_empty() { + None + } else { + let index = headers + .iter() + .position(|header| header == trimmed) + .ok_or_else(|| { + ToolError::invalid_input(format!( + "CSV '{}' is missing id_column '{trimmed}'", + csv_path.display() + )) + })?; + Some(index) + } + } else { + None + }; + + let mut rows = Vec::new(); + let mut seen_item_ids = HashSet::new(); + for (row_index, row) in reader.records().enumerate() { + let record = row.map_err(|err| { + ToolError::execution_failed(format!( + "Failed to parse CSV row {} in '{}': {err}", + row_index + 1, + csv_path.display() + )) + })?; + let mut values = HashMap::new(); + for (idx, header) in headers.iter().enumerate() { + values.insert( + header.to_string(), + record.get(idx).unwrap_or_default().to_string(), + ); + } + let base_item_id = id_index + .and_then(|idx| record.get(idx)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("row-{}", row_index + 1)); + let mut item_id = base_item_id.clone(); + let mut suffix = 2usize; + while !seen_item_ids.insert(item_id.clone()) { + item_id = format!("{base_item_id}-{suffix}"); + suffix = suffix.saturating_add(1); + } + + rows.push(CsvRowTask { + row_index, + item_id, + values, + }); + } + + Ok(rows) +} + +fn render_instruction_template(template: &str, values: &HashMap) -> String { + const OPEN_BRACE_SENTINEL: &str = "__DEEPSEEK_OPEN_BRACE__"; + const CLOSE_BRACE_SENTINEL: &str = "__DEEPSEEK_CLOSE_BRACE__"; + + let mut rendered = template + .replace("{{", OPEN_BRACE_SENTINEL) + .replace("}}", CLOSE_BRACE_SENTINEL); + for (key, value) in values { + rendered = rendered.replace(&format!("{{{key}}}"), value); + } + rendered + .replace(OPEN_BRACE_SENTINEL, "{") + .replace(CLOSE_BRACE_SENTINEL, "}") +} + +fn validate_output_schema(schema: &Value, payload: &Value) -> Result<(), String> { + let object = payload + .as_object() + .ok_or_else(|| "Expected JSON object output".to_string())?; + if let Some(expected_type) = schema.get("type").and_then(Value::as_str) + && expected_type != "object" + { + return Err("output_schema.type must be 'object' when provided".to_string()); + } + if let Some(required_fields) = schema.get("required").and_then(Value::as_array) { + for field in required_fields { + let Some(field_name) = field.as_str() else { + continue; + }; + if !object.contains_key(field_name) { + return Err(format!( + "Worker output missing required field '{field_name}'" + )); + } + } + } + Ok(()) +} + +fn write_csv_worker_outcomes(csv_path: &Path, outcomes: &[CsvWorkerOutcome]) -> Result<()> { + if let Some(parent) = csv_path.parent() { + fs::create_dir_all(parent)?; + } + let mut writer = csv::WriterBuilder::new().from_path(csv_path)?; + writer.write_record([ + "item_id", + "status", + "agent_id", + "duration_ms", + "error", + "result", + "result_json", + ])?; + for outcome in outcomes { + let result_json = outcome + .result_json + .as_ref() + .map(serde_json::to_string) + .transpose()? + .unwrap_or_default(); + writer.write_record([ + outcome.item_id.clone(), + outcome.status.clone(), + outcome.agent_id.clone().unwrap_or_default(), + outcome.duration_ms.to_string(), + outcome.error.clone().unwrap_or_default(), + outcome.result.clone().unwrap_or_default(), + result_json, + ])?; + } + writer.flush()?; + Ok(()) +} + +async fn run_csv_row_agent( + manager: SharedSubAgentManager, + runtime: SubAgentRuntime, + job_id: &str, + row: CsvRowTask, + instruction_template: &str, + timeout: Duration, + output_schema: Option, + stop_requested: Arc, +) -> CsvWorkerOutcome { + let CsvRowTask { + row_index, + item_id, + values, + } = row; + + if stop_requested.load(Ordering::Relaxed) { + return CsvWorkerOutcome { + row_index, + item_id, + status: "skipped".to_string(), + agent_id: None, + duration_ms: 0, + error: Some("Skipped because stop=true was reported by another worker".to_string()), + result: None, + result_json: None, + }; + } + + let schema_text = output_schema + .as_ref() + .map(serde_json::to_string_pretty) + .transpose() + .unwrap_or(None) + .unwrap_or_else(|| "{}".to_string()); + let rendered_instruction = render_instruction_template(instruction_template, &values); + let row_json = serde_json::to_string_pretty(&values).unwrap_or_else(|_| "{}".to_string()); + let prompt = format!( + "You are processing one item for a spawn_agents_on_csv job.\n\ +Job ID: {job_id}\n\ +Item ID: {item_id}\n\n\ +Task instruction:\n\ +{rendered_instruction}\n\n\ +Input row (JSON):\n\ +{row_json}\n\n\ +Expected result schema (JSON Schema or {{}}):\n\ +{schema_text}\n\n\ +You MUST call the `report_agent_job_result` tool exactly once with:\n\ +1. `job_id` = \"{job_id}\"\n\ +2. `item_id` = \"{item_id}\"\n\ +3. `result` = a JSON object for this row.\n\n\ +If you need to stop the job early, include `stop` = true in the same tool call.\n\n\ +After the tool call succeeds, stop.", + item_id = item_id.as_str() + ); + + let assignment = SubAgentAssignment::new( + format!("Process CSV item '{item_id}' for job '{job_id}'"), + Some("worker".to_string()), + ); + let spawn_deadline = Instant::now() + timeout.min(Duration::from_secs(60)); + let spawned = loop { + if stop_requested.load(Ordering::Relaxed) { + return CsvWorkerOutcome { + row_index, + item_id, + status: "skipped".to_string(), + agent_id: None, + duration_ms: 0, + error: Some("Skipped because stop=true was reported by another worker".to_string()), + result: None, + result_json: None, + }; + } + let attempt = { + let mut manager_guard = manager.lock().await; + manager_guard.spawn_background_with_assignment( + manager.clone(), + runtime.clone(), + SubAgentType::General, + prompt.clone(), + assignment.clone(), + None, + ) + }; + + match attempt { + Ok(snapshot) => break Ok(snapshot), + Err(err) => { + let message = err.to_string(); + if message.contains("Sub-agent limit reached") && Instant::now() < spawn_deadline { + tokio::time::sleep(RESULT_POLL_INTERVAL).await; + continue; + } + break Err(message); + } + } + }; + + let spawn_snapshot = match spawned { + Ok(snapshot) => snapshot, + Err(error) => { + return CsvWorkerOutcome { + row_index, + item_id, + status: "failed".to_string(), + agent_id: None, + duration_ms: 0, + error: Some(error), + result: None, + result_json: None, + }; + } + }; + + let agent_id = spawn_snapshot.agent_id.clone(); + record_agent_job_assignment(job_id, item_id.as_str(), &agent_id); + let deadline = Instant::now() + timeout; + let final_snapshot = loop { + let snapshot = { + let manager = manager.lock().await; + manager.get_result(&agent_id) + }; + match snapshot { + Ok(snapshot) if snapshot.status != SubAgentStatus::Running => break Ok(snapshot), + Ok(snapshot) => { + if Instant::now() >= deadline { + let cancelled = { + let mut manager = manager.lock().await; + manager.cancel(&agent_id) + }; + let mut outcome = CsvWorkerOutcome { + row_index, + item_id, + status: "timed_out".to_string(), + agent_id: Some(agent_id.clone()), + duration_ms: snapshot.duration_ms, + error: Some("Worker timed out and was cancelled".to_string()), + result: snapshot.result, + result_json: None, + }; + if let Ok(cancelled_snapshot) = cancelled { + outcome.duration_ms = cancelled_snapshot.duration_ms; + } + return outcome; + } + tokio::time::sleep(RESULT_POLL_INTERVAL).await; + } + Err(err) => break Err(err.to_string()), + } + }; + + let snapshot = match final_snapshot { + Ok(snapshot) => snapshot, + Err(error) => { + return CsvWorkerOutcome { + row_index, + item_id, + status: "failed".to_string(), + agent_id: Some(agent_id), + duration_ms: 0, + error: Some(error), + result: None, + result_json: None, + }; + } + }; + + match snapshot.status { + SubAgentStatus::Completed => { + let Some(report) = take_agent_job_result(job_id, item_id.as_str()) else { + return CsvWorkerOutcome { + row_index, + item_id, + status: "failed".to_string(), + agent_id: Some(snapshot.agent_id), + duration_ms: snapshot.duration_ms, + error: Some( + "Worker finished without calling report_agent_job_result".to_string(), + ), + result: snapshot.result, + result_json: None, + }; + }; + + if let Some(schema) = output_schema.as_ref() { + if let Err(error) = validate_output_schema(schema, &report.result) { + return CsvWorkerOutcome { + row_index, + item_id, + status: "failed".to_string(), + agent_id: Some(snapshot.agent_id), + duration_ms: snapshot.duration_ms, + error: Some(error), + result: snapshot.result, + result_json: Some(report.result), + }; + } + } + + if report.stop { + stop_requested.store(true, Ordering::Relaxed); + } + + CsvWorkerOutcome { + row_index, + item_id, + status: "completed".to_string(), + agent_id: Some(snapshot.agent_id), + duration_ms: snapshot.duration_ms, + error: None, + result: snapshot.result, + result_json: Some(report.result), + } + } + SubAgentStatus::Failed(error) => CsvWorkerOutcome { + row_index, + item_id, + status: "failed".to_string(), + agent_id: Some(snapshot.agent_id), + duration_ms: snapshot.duration_ms, + error: Some(error), + result: snapshot.result, + result_json: None, + }, + SubAgentStatus::Cancelled => CsvWorkerOutcome { + row_index, + item_id, + status: "failed".to_string(), + agent_id: Some(snapshot.agent_id), + duration_ms: snapshot.duration_ms, + error: Some("Worker cancelled".to_string()), + result: snapshot.result, + result_json: None, + }, + SubAgentStatus::Running => CsvWorkerOutcome { + row_index, + item_id, + status: "failed".to_string(), + agent_id: Some(snapshot.agent_id), + duration_ms: snapshot.duration_ms, + error: Some("Worker did not reach terminal status".to_string()), + result: snapshot.result, + result_json: None, + }, + } +} + +fn normalize_role_alias(input: &str) -> Option<&'static str> { + match input.to_ascii_lowercase().as_str() { + "default" => Some("default"), + "worker" | "general" => Some("worker"), + "explorer" | "explore" => Some("explorer"), + "awaiter" | "plan" | "planner" => Some("awaiter"), + _ => None, + } +} + +fn build_assignment_prompt( + prompt: &str, + assignment: &SubAgentAssignment, + agent_type: &SubAgentType, +) -> String { + let role = assignment.role.as_deref().unwrap_or("default"); + format!( + "Assignment metadata:\n- objective: {}\n- role: {}\n- resolved_type: {}\n\nTask:\n{}", + assignment.objective, + role, + agent_type.as_str(), + prompt + ) +} + +fn emit_agent_progress(event_tx: Option<&mpsc::Sender>, agent_id: &str, status: String) { + if let Some(event_tx) = event_tx { + let _ = event_tx.try_send(Event::AgentProgress { + id: agent_id.to_string(), + status, + }); + } +} + // === Tool Registry Helpers === struct SubAgentToolRegistry { @@ -1202,8 +3368,11 @@ impl SubAgentToolRegistry { .with_note_tool() .with_patch_tools() .with_web_tools() + .with_parallel_tool() + .with_structured_data_tools() .with_todo_tool(todo_list) - .with_plan_tool(plan_state); + .with_plan_tool(plan_state) + .with_tool(Arc::new(ReportAgentJobResultTool)); if allow_shell { builder = builder.with_shell_tools(); @@ -1225,10 +3394,26 @@ impl SubAgentToolRegistry { .collect() } - async fn execute(&self, name: &str, input: Value) -> Result { + fn unavailable_allowed_tools(&self) -> Vec { + self.allowed_tools + .iter() + .filter(|name| !self.registry.contains(name)) + .cloned() + .collect() + } + + async fn execute(&self, agent_id: &str, name: &str, mut input: Value) -> Result { if !self.allowed_tools.iter().any(|tool| tool == name) { return Err(anyhow!("Tool {name} not allowed for this sub-agent")); } + if name == "report_agent_job_result" + && let Some(object) = input.as_object_mut() + { + object.insert( + "__reporting_agent_id".to_string(), + Value::String(agent_id.to_string()), + ); + } self.registry .execute(name, input) @@ -1269,7 +3454,15 @@ fn build_allowed_tools( }); } - Ok(tools) + let mut deduped = Vec::new(); + for tool in tools { + let name = tool.trim(); + if !name.is_empty() && !deduped.iter().any(|existing: &String| existing == name) { + deduped.push(name.to_string()); + } + } + + Ok(deduped) } fn summarize_subagent_result(result: &SubAgentResult) -> String { @@ -1282,6 +3475,15 @@ fn summarize_subagent_result(result: &SubAgentResult) -> String { } } +fn subagent_status_name(status: &SubAgentStatus) -> &'static str { + match status { + SubAgentStatus::Running => "running", + SubAgentStatus::Completed => "completed", + SubAgentStatus::Failed(_) => "failed", + SubAgentStatus::Cancelled => "cancelled", + } +} + fn truncate_preview(text: &str) -> String { const MAX_LEN: usize = 240; if text.len() <= MAX_LEN { @@ -1295,43 +3497,45 @@ fn truncate_preview(text: &str) -> String { const GENERAL_AGENT_PROMPT: &str = r"You are a sub-agent spawned to handle a specific task autonomously. -Your capabilities: -- Full file system access (read, write, edit) -- Shell command execution -- Note taking and todo management +Execution contract: +- Use only the tools provided at runtime. +- Do not claim actions you did not execute. +- Keep work scoped to the assigned objective. Guidelines: -- Focus solely on the assigned task -- Be thorough but efficient -- Return a clear, concise summary of your findings/actions -- If you encounter errors, try alternative approaches -- Do not ask for user input - work autonomously +- Work autonomously and avoid asking for user input. +- Be thorough but efficient. +- If blocked, return a clear BLOCKED reason and 1-2 alternatives. +- For successful completion, return concise sections: + SUMMARY + EVIDENCE + CHANGES + RISKS Complete the task and provide your final result. "; const EXPLORE_AGENT_PROMPT: &str = r"You are a fast exploration sub-agent specialized for codebase search. -Your capabilities: -- Read files and directories -- Execute shell commands (grep, find, etc.) +Execution contract: +- Use only the tools provided at runtime. +- Do not claim actions you did not execute. Guidelines: - Focus on finding relevant code quickly - Use shell commands for efficient searching - Read only files that seem relevant - Summarize your findings concisely -- Return file paths and key code snippets +- Return file paths and key snippets as evidence Complete the exploration and provide your findings. "; const PLAN_AGENT_PROMPT: &str = r"You are a planning sub-agent specialized for architectural analysis. -Your capabilities: -- Read files and directories -- Take notes -- Update plans +Execution contract: +- Use only the tools provided at runtime. +- Do not claim actions you did not execute. Guidelines: - Analyze the codebase structure @@ -1345,9 +3549,9 @@ Complete the analysis and provide your plan. const REVIEW_AGENT_PROMPT: &str = r"You are a code review sub-agent. -Your capabilities: -- Read files and directories -- Take notes +Execution contract: +- Use only the tools provided at runtime. +- Do not claim actions you did not execute. Guidelines: - Focus on code quality and correctness @@ -1361,7 +3565,9 @@ Complete the review and provide your feedback. const CUSTOM_AGENT_PROMPT: &str = r"You are a custom sub-agent with specific tool access. -Work autonomously to complete the assigned task using only the tools available to you. +Use only the tools provided at runtime. Do not claim actions not executed. +If blocked, return BLOCKED with cause and alternatives. +Otherwise return concise sections: SUMMARY, EVIDENCE, CHANGES, RISKS. Complete the task and provide your final result. "; @@ -1371,6 +3577,23 @@ Complete the task and provide your final result. #[cfg(test)] mod tests { use super::*; + use tempfile::tempdir; + + fn make_assignment() -> SubAgentAssignment { + SubAgentAssignment::new("prompt".to_string(), Some("worker".to_string())) + } + + fn make_snapshot(status: SubAgentStatus) -> SubAgentResult { + SubAgentResult { + agent_id: "agent_test".to_string(), + agent_type: SubAgentType::General, + assignment: make_assignment(), + status, + result: None, + steps_taken: 0, + duration_ms: 0, + } + } #[test] fn test_agent_type_from_str() { @@ -1387,9 +3610,299 @@ mod tests { SubAgentType::from_str("code-review"), Some(SubAgentType::Review) ); + assert_eq!( + SubAgentType::from_str("worker"), + Some(SubAgentType::General) + ); + assert_eq!( + SubAgentType::from_str("default"), + Some(SubAgentType::General) + ); + assert_eq!( + SubAgentType::from_str("explorer"), + Some(SubAgentType::Explore) + ); + assert_eq!(SubAgentType::from_str("awaiter"), Some(SubAgentType::Plan)); assert_eq!(SubAgentType::from_str("invalid"), None); } + #[test] + fn test_parse_spawn_request_accepts_message_and_agent_type_aliases() { + let input = json!({ + "message": "Find references to Foo", + "agent_type": "explorer" + }); + let parsed = parse_spawn_request(&input).expect("spawn request should parse"); + assert_eq!(parsed.prompt, "Find references to Foo"); + assert_eq!(parsed.agent_type, SubAgentType::Explore); + assert_eq!(parsed.assignment.role.as_deref(), Some("explorer")); + } + + #[test] + fn test_parse_spawn_request_accepts_objective_and_role_alias() { + let input = json!({ + "objective": "Coordinate and wait", + "role": "awaiter" + }); + let parsed = parse_spawn_request(&input).expect("spawn request should parse"); + assert_eq!(parsed.prompt, "Coordinate and wait"); + assert_eq!(parsed.agent_type, SubAgentType::Plan); + assert_eq!(parsed.assignment.role.as_deref(), Some("awaiter")); + } + + #[test] + fn test_parse_spawn_request_accepts_items_payload() { + let input = json!({ + "items": [ + {"type": "text", "text": "Analyze module"}, + {"type": "mention", "name": "drive", "path": "app://drive"} + ], + "agent_name": "explorer" + }); + let parsed = parse_spawn_request(&input).expect("spawn request should parse"); + assert!(parsed.prompt.contains("Analyze module")); + assert!(parsed.prompt.contains("[mention:$drive](app://drive)")); + assert_eq!(parsed.agent_type, SubAgentType::Explore); + } + + #[test] + fn test_parse_spawn_request_rejects_text_and_items_together() { + let input = json!({ + "prompt": "Analyze module", + "items": [{"type": "text", "text": "dup"}] + }); + let err = parse_spawn_request(&input).expect_err("text+items should fail"); + assert!(err.to_string().contains("either prompt text or items")); + } + + #[test] + fn test_parse_spawn_request_rejects_invalid_role() { + let input = json!({ + "prompt": "do work", + "role": "unknown_role" + }); + let err = parse_spawn_request(&input).expect_err("invalid role should fail"); + assert!(err.to_string().contains("Invalid role alias")); + } + + #[test] + fn test_parse_spawn_request_rejects_conflicting_type_and_role() { + let input = json!({ + "prompt": "inspect internals", + "type": "explore", + "role": "worker" + }); + let err = parse_spawn_request(&input).expect_err("conflicting type+role should fail"); + assert!( + err.to_string() + .contains("Conflicting type/agent_type and role/agent_role") + ); + } + + #[test] + fn test_parse_assign_request_accepts_aliases() { + let input = json!({ + "id": "agent_1234", + "objective": "re-check failing tests", + "agent_role": "explorer", + "input": "focus on tests only", + "interrupt": false + }); + let request = parse_assign_request(&input).expect("assign request should parse"); + assert_eq!(request.agent_id, "agent_1234"); + assert_eq!(request.objective.as_deref(), Some("re-check failing tests")); + assert_eq!(request.role.as_deref(), Some("explorer")); + assert_eq!(request.message.as_deref(), Some("focus on tests only")); + assert!(!request.interrupt); + } + + #[test] + fn test_parse_assign_request_rejects_invalid_role() { + let input = json!({ + "agent_id": "agent_1234", + "role": "unknown" + }); + let err = parse_assign_request(&input).expect_err("invalid role should fail"); + assert!(err.to_string().contains("Invalid role alias")); + } + + #[test] + fn test_parse_assign_request_requires_update_fields() { + let input = json!({ + "agent_id": "agent_1234" + }); + let err = parse_assign_request(&input).expect_err("missing update fields should fail"); + assert!(err.to_string().contains( + "Provide at least one of objective, role/agent_role, message/input, or items" + )); + } + + #[test] + fn test_render_instruction_template_replaces_columns() { + let mut values = HashMap::new(); + values.insert("name".to_string(), "alpha".to_string()); + values.insert("owner".to_string(), "hunter".to_string()); + + let rendered = render_instruction_template("Inspect {name} for {owner}", &values); + assert_eq!(rendered, "Inspect alpha for hunter"); + } + + #[test] + fn test_render_instruction_template_preserves_escaped_braces() { + let mut values = HashMap::new(); + values.insert("name".to_string(), "alpha".to_string()); + + let rendered = render_instruction_template("literal {{x}} and {name}", &values); + assert_eq!(rendered, "literal {x} and alpha"); + } + + #[test] + fn test_record_agent_job_result_accepts_first_report_only() { + let job_id = "job_test_reports"; + clear_agent_job_results(job_id); + record_agent_job_assignment(job_id, "item-1", "agent_1"); + + assert!(record_agent_job_result( + job_id, + "item-1", + json!({"status":"ok"}), + false, + Some("agent_1") + )); + assert!(!record_agent_job_result( + job_id, + "item-1", + json!({"status":"duplicate"}), + true, + Some("agent_1") + )); + + let report = take_agent_job_result(job_id, "item-1").expect("report should exist"); + assert_eq!(report.result["status"], "ok"); + assert!(!report.stop); + assert!(take_agent_job_result(job_id, "item-1").is_none()); + clear_agent_job_results(job_id); + } + + #[test] + fn test_record_agent_job_result_rejects_wrong_agent_assignment() { + let job_id = "job_test_reports_wrong_agent"; + clear_agent_job_results(job_id); + record_agent_job_assignment(job_id, "item-1", "agent_good"); + + assert!(!record_agent_job_result( + job_id, + "item-1", + json!({"status":"bad"}), + false, + Some("agent_bad") + )); + assert!(take_agent_job_result(job_id, "item-1").is_none()); + clear_agent_job_results(job_id); + } + + #[test] + fn test_record_agent_job_result_rejects_missing_agent_assignment_context() { + let job_id = "job_test_reports_missing_agent_context"; + clear_agent_job_results(job_id); + record_agent_job_assignment(job_id, "item-1", "agent_good"); + + assert!(!record_agent_job_result( + job_id, + "item-1", + json!({"status":"bad"}), + false, + None + )); + assert!(take_agent_job_result(job_id, "item-1").is_none()); + clear_agent_job_results(job_id); + } + + #[test] + fn test_validate_output_schema_enforces_required_fields() { + let schema = json!({ + "type": "object", + "required": ["status", "score"] + }); + let ok_payload = json!({"status":"ok","score":1}); + assert!(validate_output_schema(&schema, &ok_payload).is_ok()); + + let missing = json!({"status":"ok"}); + let err = validate_output_schema(&schema, &missing).expect_err("missing required field"); + assert!(err.contains("missing required field 'score'")); + } + + #[test] + fn test_default_results_csv_path_uses_input_stem() { + let path = PathBuf::from("/tmp/inventory.csv"); + let output = default_results_csv_path(&path); + assert_eq!(output, PathBuf::from("/tmp/inventory.results.csv")); + } + + #[test] + fn test_parse_csv_concurrency_prefers_max_concurrency() { + let input = json!({ + "max_workers": 3, + "max_concurrency": 9 + }); + assert_eq!(parse_csv_concurrency(&input), 9); + } + + #[test] + fn test_load_csv_rows_uses_id_column_and_row_fallback() { + let tmp = tempdir().expect("tempdir"); + let csv_path = tmp.path().join("rows.csv"); + std::fs::write(&csv_path, "id,name\nalpha,First\n,Second\n").expect("write csv"); + + let rows = load_csv_rows(&csv_path, Some("id")).expect("load rows"); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].item_id, "alpha"); + assert_eq!(rows[1].item_id, "row-2"); + assert_eq!( + rows[1].values.get("name").map(String::as_str), + Some("Second") + ); + } + + #[test] + fn test_load_csv_rows_dedupes_item_ids() { + let tmp = tempdir().expect("tempdir"); + let csv_path = tmp.path().join("rows.csv"); + std::fs::write(&csv_path, "id,name\nfoo,First\nfoo,Second\n").expect("write csv"); + + let rows = load_csv_rows(&csv_path, Some("id")).expect("load rows"); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].item_id, "foo"); + assert_eq!(rows[1].item_id, "foo-2"); + } + + #[test] + fn test_load_csv_rows_rejects_duplicate_headers() { + let tmp = tempdir().expect("tempdir"); + let csv_path = tmp.path().join("rows.csv"); + std::fs::write(&csv_path, "id,id\nfoo,bar\n").expect("write csv"); + + let err = load_csv_rows(&csv_path, Some("id")).expect_err("duplicate headers should fail"); + assert!(err.to_string().contains("duplicate header")); + } + + #[test] + fn test_send_input_schema_does_not_require_message_field() { + let manager = Arc::new(Mutex::new(SubAgentManager::new(PathBuf::from("."), 1))); + let schema = AgentSendInputTool::new(manager, "send_input").input_schema(); + let required = schema + .get("required") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + assert!( + !required + .iter() + .any(|entry| entry.as_str().is_some_and(|name| name == "message")), + "send_input schema should allow items-only payloads" + ); + } + #[test] fn test_allowed_tools_shell_filter() { let tools = build_allowed_tools(&SubAgentType::General, None, false).unwrap(); @@ -1400,12 +3913,135 @@ mod tests { assert!(!tools.contains(&"exec_interact".to_string())); } + #[test] + fn test_allowed_tools_are_deduplicated() { + let tools = build_allowed_tools( + &SubAgentType::Custom, + Some(vec![ + "read_file".to_string(), + "read_file".to_string(), + " ".to_string(), + "grep_files".to_string(), + ]), + true, + ) + .unwrap(); + assert_eq!( + tools, + vec!["read_file".to_string(), "grep_files".to_string()] + ); + } + #[test] fn test_custom_agent_requires_allowed_tools() { let err = build_allowed_tools(&SubAgentType::Custom, None, true).unwrap_err(); assert!(err.to_string().contains("requires")); } + #[test] + fn test_wait_mode_condition_any_and_all() { + let one_done = vec![ + make_snapshot(SubAgentStatus::Running), + make_snapshot(SubAgentStatus::Completed), + ]; + let all_done = vec![ + make_snapshot(SubAgentStatus::Completed), + make_snapshot(SubAgentStatus::Cancelled), + ]; + + assert!(WaitMode::Any.condition_met(&one_done)); + assert!(!WaitMode::All.condition_met(&one_done)); + assert!(WaitMode::All.condition_met(&all_done)); + } + + #[test] + fn test_parse_wait_mode() { + assert_eq!(parse_wait_mode(&json!({})).unwrap(), WaitMode::Any); + assert_eq!( + parse_wait_mode(&json!({"wait_mode": "all"})).unwrap(), + WaitMode::All + ); + assert_eq!( + parse_wait_mode(&json!({"wait_mode": "first"})).unwrap(), + WaitMode::Any + ); + assert!(parse_wait_mode(&json!({"wait_mode": "invalid"})).is_err()); + } + + #[test] + fn test_parse_wait_ids_accepts_aliases() { + let ids = parse_wait_ids(&json!({ + "ids": ["agent_a", "agent_b"], + "agent_id": "agent_c", + "id": "agent_a" + })); + + assert_eq!(ids, vec!["agent_a", "agent_b", "agent_c"]); + } + + #[test] + fn test_parse_wait_ids_empty_when_omitted() { + let ids = parse_wait_ids(&json!({})); + assert!(ids.is_empty()); + } + + #[test] + fn test_build_assignment_prompt_includes_metadata() { + let assignment = SubAgentAssignment::new( + "Inspect parser behavior".to_string(), + Some("explorer".to_string()), + ); + let prompt = build_assignment_prompt( + "Inspect parser behavior", + &assignment, + &SubAgentType::Explore, + ); + assert!(prompt.contains("Assignment metadata")); + assert!(prompt.contains("resolved_type: explore")); + assert!(prompt.contains("role: explorer")); + } + + #[test] + fn test_subagent_tool_registry_reports_unavailable_tools() { + let tmp = tempdir().expect("tempdir"); + let context = ToolContext::new(tmp.path().to_path_buf()); + let registry = SubAgentToolRegistry::new( + context, + vec!["read_file".to_string(), "missing_tool".to_string()], + false, + Arc::new(Mutex::new(TodoList::new())), + Arc::new(Mutex::new(PlanState::default())), + ); + assert_eq!( + registry.unavailable_allowed_tools(), + vec!["missing_tool".to_string()] + ); + } + + #[tokio::test] + async fn test_wait_for_result_reports_timeout_when_still_running() { + let manager = Arc::new(Mutex::new(SubAgentManager::new(PathBuf::from("."), 2))); + let (input_tx, _input_rx) = mpsc::unbounded_channel(); + let agent = SubAgent::new( + SubAgentType::Explore, + "prompt".to_string(), + make_assignment(), + vec!["read_file".to_string()], + input_tx, + ); + let agent_id = agent.id.clone(); + { + let mut guard = manager.lock().await; + guard.agents.insert(agent_id.clone(), agent); + } + + let (snapshot, timed_out) = wait_for_result(&manager, &agent_id, Duration::from_millis(10)) + .await + .expect("wait_for_result should succeed"); + assert!(timed_out); + assert_eq!(snapshot.status, SubAgentStatus::Running); + } + #[test] fn test_running_count_respects_limit() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); @@ -1413,6 +4049,7 @@ mod tests { let mut agent = SubAgent::new( SubAgentType::Explore, "prompt".to_string(), + make_assignment(), vec!["read_file".to_string()], input_tx, ); @@ -1421,4 +4058,143 @@ mod tests { assert_eq!(manager.running_count(), 1); } + + #[tokio::test] + async fn test_running_count_ignores_finished_task_handles() { + let mut manager = SubAgentManager::new(PathBuf::from("."), 1); + let (input_tx, _input_rx) = mpsc::unbounded_channel(); + let mut agent = SubAgent::new( + SubAgentType::Explore, + "prompt".to_string(), + make_assignment(), + vec!["read_file".to_string()], + input_tx, + ); + agent.status = SubAgentStatus::Running; + let handle = tokio::spawn(async {}); + handle.await.expect("dummy task should finish immediately"); + agent.task_handle = Some(tokio::spawn(async {})); + if let Some(handle) = agent.task_handle.as_ref() { + while !handle.is_finished() { + tokio::task::yield_now().await; + } + } + manager.agents.insert(agent.id.clone(), agent); + + assert_eq!(manager.running_count(), 0); + } + + #[test] + fn test_assign_updates_running_agent_and_sends_message() { + let mut manager = SubAgentManager::new(PathBuf::from("."), 2); + let (input_tx, mut input_rx) = mpsc::unbounded_channel(); + let agent = SubAgent::new( + SubAgentType::General, + "work".to_string(), + make_assignment(), + vec!["read_file".to_string()], + input_tx, + ); + let agent_id = agent.id.clone(); + manager.agents.insert(agent_id.clone(), agent); + + let snapshot = manager + .assign( + &agent_id, + Some("Re-check module boundaries".to_string()), + Some("explorer".to_string()), + None, + true, + ) + .expect("assignment should succeed"); + assert_eq!(snapshot.assignment.objective, "Re-check module boundaries"); + assert_eq!(snapshot.assignment.role.as_deref(), Some("explorer")); + + let dispatched = input_rx + .try_recv() + .expect("running agent should receive assignment update"); + assert!(dispatched.interrupt); + assert!(dispatched.text.contains("Assignment updated")); + assert!(dispatched.text.contains("objective")); + } + + #[test] + fn test_assign_rejects_message_for_non_running_agent() { + let mut manager = SubAgentManager::new(PathBuf::from("."), 1); + let (input_tx, _input_rx) = mpsc::unbounded_channel(); + let mut agent = SubAgent::new( + SubAgentType::Explore, + "prompt".to_string(), + make_assignment(), + vec!["read_file".to_string()], + input_tx, + ); + agent.status = SubAgentStatus::Completed; + let agent_id = agent.id.clone(); + manager.agents.insert(agent_id.clone(), agent); + + let err = manager + .assign(&agent_id, None, None, Some("keep going".to_string()), true) + .expect_err("non-running agent cannot receive assignment message"); + assert!(err.to_string().contains("is not running")); + } + + #[test] + fn test_assign_updates_non_running_metadata_without_message() { + let mut manager = SubAgentManager::new(PathBuf::from("."), 1); + let (input_tx, _input_rx) = mpsc::unbounded_channel(); + let mut agent = SubAgent::new( + SubAgentType::Plan, + "prompt".to_string(), + make_assignment(), + vec!["read_file".to_string()], + input_tx, + ); + agent.status = SubAgentStatus::Completed; + let agent_id = agent.id.clone(); + manager.agents.insert(agent_id.clone(), agent); + + let snapshot = manager + .assign( + &agent_id, + Some("Draft retry plan".to_string()), + Some("awaiter".to_string()), + None, + true, + ) + .expect("metadata update should succeed"); + assert_eq!(snapshot.assignment.objective, "Draft retry plan"); + assert_eq!(snapshot.assignment.role.as_deref(), Some("awaiter")); + } + + #[test] + fn test_persist_and_reload_marks_running_agent_as_failed() { + let tmp = tempdir().expect("tempdir"); + let workspace = tmp.path().to_path_buf(); + let state_path = default_state_path(tmp.path()); + + let mut manager = SubAgentManager::new(workspace.clone(), 2).with_state_path(state_path); + let (input_tx, _input_rx) = mpsc::unbounded_channel(); + let running = SubAgent::new( + SubAgentType::General, + "work".to_string(), + make_assignment(), + vec!["read_file".to_string()], + input_tx, + ); + let running_id = running.id.clone(); + manager.agents.insert(running_id.clone(), running); + manager.persist_state().expect("persist state"); + + let mut reloaded = + SubAgentManager::new(workspace, 2).with_state_path(default_state_path(tmp.path())); + reloaded.load_state().expect("load state"); + let snapshot = reloaded + .get_result(&running_id) + .expect("reloaded agent should exist"); + assert!(matches!( + snapshot.status, + SubAgentStatus::Failed(ref message) if message.contains(SUBAGENT_RESTART_REASON) + )); + } } diff --git a/src/tools/swarm.rs b/src/tools/swarm.rs index c833cd69..e8edcead 100644 --- a/src/tools/swarm.rs +++ b/src/tools/swarm.rs @@ -1,7 +1,9 @@ //! Swarm orchestration for spawning multiple sub-agents with dependencies. -use std::collections::{HashMap, HashSet}; -use std::sync::Arc; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex as StdMutex, OnceLock}; use std::time::{Duration, Instant}; use async_trait::async_trait; @@ -9,18 +11,31 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use uuid::Uuid; +use crate::core::events::Event; use crate::tools::spec::{ ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_bool, optional_str, optional_u64, }; use crate::tools::subagent::{ - SharedSubAgentManager, SubAgentResult, SubAgentRuntime, SubAgentStatus, SubAgentType, + SharedSubAgentManager, SubAgentAssignment, SubAgentResult, SubAgentRuntime, SubAgentStatus, + SubAgentType, }; const SWARM_POLL_INTERVAL: Duration = Duration::from_millis(250); const DEFAULT_SWARM_TIMEOUT_MS: u64 = 600_000; -const DEFAULT_SWARM_TIMEOUT_NONBLOCK_MS: u64 = 15_000; +const DEFAULT_SWARM_TIMEOUT_NONBLOCK_MS: u64 = 600_000; const MAX_SWARM_TIMEOUT_MS: u64 = 3_600_000; +const DEFAULT_SWARM_RESULT_TIMEOUT_MS: u64 = 30_000; +const MAX_SWARM_HISTORY: usize = 256; +const SWARM_STATE_SCHEMA_VERSION: u32 = 1; +const SWARM_STATE_FILE: &str = "swarm_outcomes.v1.json"; +const DEFAULT_TASK_RETRY_DELAY_MS: u64 = 1_000; +const MAX_TASK_RETRY_DELAY_MS: u64 = 60_000; +const MAX_TASK_TIMEOUT_MS: u64 = 600_000; +const MAX_TASK_RETRIES: u32 = 10; + +static SWARM_OUTCOMES: OnceLock>> = OnceLock::new(); +static SWARM_ORDER: OnceLock>> = OnceLock::new(); #[derive(Debug, Clone, Deserialize)] struct SwarmTaskSpec { @@ -28,6 +43,16 @@ struct SwarmTaskSpec { prompt: String, #[serde(default, rename = "type")] agent_type: Option, + #[serde(default, alias = "agent_role")] + role: Option, + #[serde(default)] + objective: Option, + #[serde(default)] + retry_count: Option, + #[serde(default)] + retry_delay_ms: Option, + #[serde(default)] + task_timeout_ms: Option, #[serde(default)] allowed_tools: Option>, #[serde(default)] @@ -43,7 +68,7 @@ enum SwarmTaskState { Skipped(String), } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] enum SwarmTaskStatus { Pending, @@ -54,7 +79,7 @@ enum SwarmTaskStatus { Skipped, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct SwarmTaskOutcome { task_id: String, agent_id: Option, @@ -67,16 +92,33 @@ struct SwarmTaskOutcome { duration_ms: u64, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] enum SwarmStatus { + Running, Completed, Partial, Timeout, Failed, } -#[derive(Debug, Clone, Serialize)] +impl SwarmStatus { + fn is_terminal(&self) -> bool { + !matches!(self, Self::Running) + } + + fn as_str(&self) -> &'static str { + match self { + Self::Running => "running", + Self::Completed => "completed", + Self::Partial => "partial", + Self::Timeout => "timeout", + Self::Failed => "failed", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] struct SwarmCounts { total: usize, completed: usize, @@ -87,7 +129,7 @@ struct SwarmCounts { pending: usize, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct SwarmOutcome { swarm_id: String, status: SwarmStatus, @@ -96,6 +138,126 @@ struct SwarmOutcome { tasks: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PersistedSwarmStore { + schema_version: u32, + outcomes: HashMap, + order: VecDeque, +} + +impl Default for PersistedSwarmStore { + fn default() -> Self { + Self { + schema_version: SWARM_STATE_SCHEMA_VERSION, + outcomes: HashMap::new(), + order: VecDeque::new(), + } + } +} + +fn swarm_outcomes_store() -> &'static StdMutex> { + SWARM_OUTCOMES.get_or_init(|| StdMutex::new(HashMap::new())) +} + +fn swarm_order_store() -> &'static StdMutex> { + SWARM_ORDER.get_or_init(|| StdMutex::new(VecDeque::new())) +} + +fn swarm_state_path(workspace: &Path) -> PathBuf { + workspace + .join(".deepseek") + .join("state") + .join(SWARM_STATE_FILE) +} + +fn load_swarm_store(path: &Path) { + let Ok(raw) = fs::read_to_string(path) else { + return; + }; + let Ok(persisted) = serde_json::from_str::(&raw) else { + return; + }; + if persisted.schema_version != SWARM_STATE_SCHEMA_VERSION { + return; + } + + let mut outcomes = swarm_outcomes_store() + .lock() + .expect("swarm outcomes store lock poisoned"); + let mut order = swarm_order_store() + .lock() + .expect("swarm order store lock poisoned"); + for id in persisted.order { + if let Some(outcome) = persisted.outcomes.get(&id) + && !outcomes.contains_key(&id) + { + outcomes.insert(id.clone(), outcome.clone()); + order.push_back(id); + } + } + while order.len() > MAX_SWARM_HISTORY { + if let Some(oldest) = order.pop_front() { + outcomes.remove(&oldest); + } + } +} + +fn persist_swarm_store(path: &Path) { + let outcomes = swarm_outcomes_store() + .lock() + .expect("swarm outcomes store lock poisoned"); + let order = swarm_order_store() + .lock() + .expect("swarm order store lock poisoned"); + let payload = PersistedSwarmStore { + schema_version: SWARM_STATE_SCHEMA_VERSION, + outcomes: outcomes.clone(), + order: order.clone(), + }; + + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(raw) = serde_json::to_string_pretty(&payload) { + let tmp_path = path.with_extension("tmp"); + if fs::write(&tmp_path, raw).is_ok() { + let _ = fs::rename(tmp_path, path); + } + } +} + +fn store_swarm_outcome(outcome: &SwarmOutcome, persistence_path: Option<&Path>) { + let mut outcomes = swarm_outcomes_store() + .lock() + .expect("swarm outcomes store lock poisoned"); + outcomes.insert(outcome.swarm_id.clone(), outcome.clone()); + + let mut order = swarm_order_store() + .lock() + .expect("swarm order store lock poisoned"); + if let Some(idx) = order.iter().position(|id| id == &outcome.swarm_id) { + let _ = order.remove(idx); + } + order.push_back(outcome.swarm_id.clone()); + + while order.len() > MAX_SWARM_HISTORY { + if let Some(oldest) = order.pop_front() { + outcomes.remove(&oldest); + } + } + + if let Some(path) = persistence_path { + persist_swarm_store(path); + } +} + +fn load_swarm_outcome(swarm_id: &str) -> Option { + let outcomes = swarm_outcomes_store() + .lock() + .expect("swarm outcomes store lock poisoned"); + outcomes.get(swarm_id).cloned() +} + /// Tool to launch a swarm of sub-agents with dependency-aware scheduling. pub struct AgentSwarmTool { manager: SharedSubAgentManager, @@ -132,7 +294,13 @@ impl ToolSpec for AgentSwarmTool { "properties": { "id": { "type": "string", "description": "Unique task id." }, "prompt": { "type": "string", "description": "Task prompt for the sub-agent." }, + "objective": { "type": "string", "description": "Optional assignment objective shown in sub-agent views (defaults to prompt)." }, "type": { "type": "string", "description": "Sub-agent type: general, explore, plan, review, custom." }, + "role": { "type": "string", "description": "Optional role alias: worker, explorer, awaiter, default." }, + "agent_role": { "type": "string", "description": "Alias for role." }, + "retry_count": { "type": "integer", "description": "Retries after the initial attempt (default: 0)." }, + "retry_delay_ms": { "type": "integer", "description": "Base retry delay in milliseconds (default: 1000, exponential backoff)." }, + "task_timeout_ms": { "type": "integer", "description": "Per-task timeout in milliseconds; cancels and optionally retries on timeout." }, "allowed_tools": { "type": "array", "items": { "type": "string" }, @@ -184,6 +352,9 @@ impl ToolSpec for AgentSwarmTool { } async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let persistence_path = swarm_state_path(&self.runtime.context.workspace); + load_swarm_store(&persistence_path); + let tasks_value = input .get("tasks") .cloned() @@ -214,19 +385,198 @@ impl ToolSpec for AgentSwarmTool { requested.clamp(1, max_agents as u64) as usize }; - let outcome = run_swarm( - &self.manager, - &self.runtime, - tasks, - shared_context, - Duration::from_millis(timeout_ms), - max_parallel, - fail_fast, - block, - ) - .await?; + let swarm_id = format!("swarm_{}", &Uuid::new_v4().to_string()[..8]); - ToolResult::json(&outcome).map_err(|err| ToolError::execution_failed(err.to_string())) + if block { + let outcome = run_swarm( + &self.manager, + &self.runtime, + swarm_id, + tasks, + shared_context, + Duration::from_millis(timeout_ms), + max_parallel, + fail_fast, + false, + Some(persistence_path.clone()), + ) + .await?; + store_swarm_outcome(&outcome, Some(&persistence_path)); + return ToolResult::json(&outcome) + .map_err(|err| ToolError::execution_failed(err.to_string())); + } + + let initial = build_initial_outcome(&swarm_id, &tasks); + store_swarm_outcome(&initial, Some(&persistence_path)); + + let manager = self.manager.clone(); + let runtime = self.runtime.clone(); + let persistence_path_bg = persistence_path.clone(); + tokio::spawn(async move { + let outcome = run_swarm( + &manager, + &runtime, + swarm_id.clone(), + tasks, + shared_context, + Duration::from_millis(timeout_ms), + max_parallel, + fail_fast, + true, + Some(persistence_path_bg.clone()), + ) + .await + .unwrap_or_else(|err| build_failed_outcome(&swarm_id, err.to_string())); + store_swarm_outcome(&outcome, Some(&persistence_path_bg)); + }); + + let mut result = ToolResult::json(&initial) + .map_err(|err| ToolError::execution_failed(err.to_string()))?; + result.metadata = Some(json!({ + "status": "Running", + "swarm_id": initial.swarm_id, + })); + Ok(result) + } +} + +/// Tool to get lightweight swarm status. +pub struct SwarmStatusTool { + persistence_path: PathBuf, +} + +impl SwarmStatusTool { + #[must_use] + pub fn new(workspace: PathBuf) -> Self { + Self { + persistence_path: swarm_state_path(&workspace), + } + } +} + +#[async_trait] +impl ToolSpec for SwarmStatusTool { + fn name(&self) -> &'static str { + "swarm_status" + } + + fn description(&self) -> &'static str { + "Get the latest status for a previously spawned swarm." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "swarm_id": { "type": "string", "description": "Swarm id returned by agent_swarm." }, + "id": { "type": "string", "description": "Alias for swarm_id." } + } + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ReadOnly] + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + load_swarm_store(&self.persistence_path); + let swarm_id = parse_swarm_id(&input)?; + let outcome = load_swarm_outcome(swarm_id) + .ok_or_else(|| ToolError::execution_failed(format!("Swarm '{swarm_id}' not found")))?; + + let snapshot = json!({ + "swarm_id": outcome.swarm_id, + "status": outcome.status, + "counts": outcome.counts, + "duration_ms": outcome.duration_ms, + }); + ToolResult::json(&snapshot).map_err(|err| ToolError::execution_failed(err.to_string())) + } +} + +/// Tool to fetch full swarm outcomes, optionally waiting for completion. +pub struct SwarmResultTool { + persistence_path: PathBuf, +} + +impl SwarmResultTool { + #[must_use] + pub fn new(workspace: PathBuf) -> Self { + Self { + persistence_path: swarm_state_path(&workspace), + } + } +} + +#[async_trait] +impl ToolSpec for SwarmResultTool { + fn name(&self) -> &'static str { + "swarm_result" + } + + fn description(&self) -> &'static str { + "Get full outcomes for a previously spawned swarm." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "swarm_id": { "type": "string", "description": "Swarm id returned by agent_swarm." }, + "id": { "type": "string", "description": "Alias for swarm_id." }, + "block": { "type": "boolean", "description": "Wait for terminal status (default: false)." }, + "timeout_ms": { "type": "integer", "description": "Max wait in milliseconds when block=true (default: 30000)." } + } + }) + } + + fn capabilities(&self) -> Vec { + vec![ToolCapability::ReadOnly] + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + load_swarm_store(&self.persistence_path); + let swarm_id = parse_swarm_id(&input)?; + let block = optional_bool(&input, "block", false); + let timeout_ms = optional_u64(&input, "timeout_ms", DEFAULT_SWARM_RESULT_TIMEOUT_MS) + .clamp(1_000, MAX_SWARM_TIMEOUT_MS); + + let deadline = Instant::now() + Duration::from_millis(timeout_ms); + let mut timed_out = false; + let outcome = loop { + if let Some(outcome) = load_swarm_outcome(swarm_id) { + if !block || outcome.status.is_terminal() { + break outcome; + } + if Instant::now() >= deadline { + timed_out = true; + break outcome; + } + } else if !block { + return Err(ToolError::execution_failed(format!( + "Swarm '{swarm_id}' not found" + ))); + } else if Instant::now() >= deadline { + return Err(ToolError::execution_failed(format!( + "Swarm '{swarm_id}' not found" + ))); + } + + tokio::time::sleep(SWARM_POLL_INTERVAL).await; + }; + + let mut result = ToolResult::json(&outcome) + .map_err(|err| ToolError::execution_failed(err.to_string()))?; + if timed_out { + result.metadata = Some(json!({ + "status": "TimedOut", + "timed_out": true, + "timeout_ms": timeout_ms, + })); + } else if !outcome.status.is_terminal() { + result.metadata = Some(json!({ "status": "Running" })); + } + Ok(result) } } @@ -234,14 +584,15 @@ impl ToolSpec for AgentSwarmTool { async fn run_swarm( shared_manager: &SharedSubAgentManager, runtime: &SubAgentRuntime, + swarm_id: String, tasks: Vec, shared_context: Option, timeout: Duration, max_parallel: usize, fail_fast: bool, - block: bool, + publish_progress: bool, + persistence_path: Option, ) -> Result { - let swarm_id = format!("swarm_{}", &Uuid::new_v4().to_string()[..8]); let start = Instant::now(); let deadline = start + timeout; let task_order = tasks.iter().map(|task| task.id.clone()).collect::>(); @@ -256,6 +607,9 @@ async fn run_swarm( } let mut running: HashMap = HashMap::new(); + let mut running_started_at: HashMap = HashMap::new(); + let mut attempts_made: HashMap = HashMap::new(); + let mut retry_ready_at: HashMap = HashMap::new(); let mut fail_fast_triggered = false; let mut timed_out = false; @@ -274,11 +628,84 @@ async fn run_swarm( let running_ids = running.clone(); for (task_id, agent_id) in running_ids { + let Some(task) = task_map.get(&task_id) else { + states.insert( + task_id.clone(), + SwarmTaskState::Failed("Missing swarm task".to_string()), + ); + running.remove(&task_id); + running_started_at.remove(&task_id); + changed = true; + if fail_fast { + fail_fast_triggered = true; + } + continue; + }; + + if let Some(limit) = task_timeout(task) { + let started = running_started_at.get(&task_id).copied().unwrap_or(start); + if started.elapsed() >= limit { + let timeout_ms = u64::try_from(limit.as_millis()).unwrap_or(u64::MAX); + { + let mut manager = shared_manager.lock().await; + let _ = manager.cancel(&agent_id); + } + + if schedule_retry_if_possible( + task, + &task_id, + &attempts_made, + fail_fast, + &mut pending, + &mut running, + &mut running_started_at, + &mut retry_ready_at, + &mut states, + ) { + changed = true; + continue; + } + + states.insert( + task_id.clone(), + SwarmTaskState::Failed(format!("Timed out after {timeout_ms}ms")), + ); + running.remove(&task_id); + running_started_at.remove(&task_id); + retry_ready_at.remove(&task_id); + changed = true; + if fail_fast { + fail_fast_triggered = true; + } + continue; + } + } + match snapshot_map.get(&agent_id) { Some(snapshot) => { if snapshot.status != SubAgentStatus::Running { + if matches!( + snapshot.status, + SubAgentStatus::Failed(_) | SubAgentStatus::Cancelled + ) && schedule_retry_if_possible( + task, + &task_id, + &attempts_made, + fail_fast, + &mut pending, + &mut running, + &mut running_started_at, + &mut retry_ready_at, + &mut states, + ) { + changed = true; + continue; + } + states.insert(task_id.clone(), SwarmTaskState::Done(snapshot.clone())); running.remove(&task_id); + running_started_at.remove(&task_id); + retry_ready_at.remove(&task_id); changed = true; if fail_fast && matches!( @@ -291,11 +718,27 @@ async fn run_swarm( } } None => { + if schedule_retry_if_possible( + task, + &task_id, + &attempts_made, + fail_fast, + &mut pending, + &mut running, + &mut running_started_at, + &mut retry_ready_at, + &mut states, + ) { + changed = true; + continue; + } + states.insert( task_id.clone(), SwarmTaskState::Failed("Agent result not found".to_string()), ); running.remove(&task_id); + running_started_at.remove(&task_id); changed = true; if fail_fast { fail_fast_triggered = true; @@ -306,7 +749,26 @@ async fn run_swarm( } if fail_fast_triggered { - apply_fail_fast(shared_manager, &mut states, &mut pending, &mut running).await?; + apply_fail_fast( + shared_manager, + &mut states, + &mut pending, + &mut running, + &mut running_started_at, + &mut retry_ready_at, + ) + .await?; + if publish_progress { + let progress = build_progress_outcome( + &swarm_id, + start, + &task_order, + &states, + SwarmStatus::Failed, + ); + store_swarm_outcome(&progress, persistence_path.as_deref()); + emit_swarm_status(runtime.event_tx.as_ref(), &progress); + } break; } @@ -328,9 +790,14 @@ async fn run_swarm( } let mut ready = Vec::new(); + let now = Instant::now(); for task_id in pending.iter() { if let Some(task) = task_map.get(task_id) && dependencies_satisfied(task, &states) + && match retry_ready_at.get(task_id) { + Some(ready_at) => now >= *ready_at, + None => true, + } { ready.push(task_id.clone()); } @@ -349,16 +816,22 @@ async fn run_swarm( let task = task_map .get(&task_id) .ok_or_else(|| ToolError::execution_failed("Missing swarm task"))?; - let agent_type = task.agent_type.clone().unwrap_or_default(); + attempts_made + .entry(task_id.clone()) + .and_modify(|count| *count = count.saturating_add(1)) + .or_insert(1); + let (agent_type, role, objective) = resolve_task_assignment(task)?; let prompt = format_prompt(shared_context.as_deref(), &task.prompt); + let assignment = SubAgentAssignment { objective, role }; let spawn_result = { let mut manager = shared_manager.lock().await; - manager.spawn_background( + manager.spawn_background_with_assignment( Arc::clone(shared_manager), runtime.clone(), agent_type, prompt, + assignment, task.allowed_tools.clone(), ) }; @@ -372,19 +845,38 @@ async fn run_swarm( }, ); running.insert(task_id.clone(), snapshot.agent_id); + running_started_at.insert(task_id.clone(), Instant::now()); + retry_ready_at.remove(&task_id); pending.remove(&task_id); changed = true; } Err(err) => { let message = err.to_string(); if message.contains("Sub-agent limit reached") { + if let Some(count) = attempts_made.get_mut(&task_id) { + *count = count.saturating_sub(1); + } break; } - states.insert(task_id.clone(), SwarmTaskState::Failed(message)); - pending.remove(&task_id); - changed = true; - if fail_fast { - fail_fast_triggered = true; + if schedule_retry_if_possible( + task, + &task_id, + &attempts_made, + fail_fast, + &mut pending, + &mut running, + &mut running_started_at, + &mut retry_ready_at, + &mut states, + ) { + changed = true; + } else { + states.insert(task_id.clone(), SwarmTaskState::Failed(message)); + pending.remove(&task_id); + changed = true; + if fail_fast { + fail_fast_triggered = true; + } } } } @@ -393,21 +885,54 @@ async fn run_swarm( } if fail_fast_triggered { - apply_fail_fast(shared_manager, &mut states, &mut pending, &mut running).await?; + apply_fail_fast( + shared_manager, + &mut states, + &mut pending, + &mut running, + &mut running_started_at, + &mut retry_ready_at, + ) + .await?; + if publish_progress { + let progress = build_progress_outcome( + &swarm_id, + start, + &task_order, + &states, + SwarmStatus::Failed, + ); + store_swarm_outcome(&progress, persistence_path.as_deref()); + emit_swarm_status(runtime.event_tx.as_ref(), &progress); + } break; } if pending.is_empty() && running.is_empty() { break; } - if !block { - break; - } if Instant::now() >= deadline { timed_out = true; + if !running.is_empty() { + cancel_running_tasks(shared_manager, &running, &mut states).await?; + running.clear(); + running_started_at.clear(); + } break; } + if publish_progress && changed { + let progress = build_progress_outcome( + &swarm_id, + start, + &task_order, + &states, + SwarmStatus::Running, + ); + store_swarm_outcome(&progress, persistence_path.as_deref()); + emit_swarm_status(runtime.event_tx.as_ref(), &progress); + } + if !changed { tokio::time::sleep(SWARM_POLL_INTERVAL).await; } @@ -430,23 +955,249 @@ async fn run_swarm( SwarmStatus::Completed }; - Ok(SwarmOutcome { + let outcome = SwarmOutcome { swarm_id, status, duration_ms: u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX), counts, tasks: outcomes, - }) + }; + emit_swarm_status(runtime.event_tx.as_ref(), &outcome); + Ok(outcome) +} + +fn build_initial_outcome(swarm_id: &str, tasks: &[SwarmTaskSpec]) -> SwarmOutcome { + let task_ids = tasks.iter().map(|task| task.id.clone()).collect::>(); + let states = tasks + .iter() + .map(|task| (task.id.clone(), SwarmTaskState::Pending)) + .collect::>(); + build_progress_outcome( + swarm_id, + Instant::now(), + &task_ids, + &states, + SwarmStatus::Running, + ) +} + +fn build_failed_outcome(swarm_id: &str, error: String) -> SwarmOutcome { + SwarmOutcome { + swarm_id: swarm_id.to_string(), + status: SwarmStatus::Failed, + duration_ms: 0, + counts: SwarmCounts { + total: 0, + completed: 0, + failed: 1, + cancelled: 0, + skipped: 0, + running: 0, + pending: 0, + }, + tasks: vec![SwarmTaskOutcome { + task_id: "swarm_runtime".to_string(), + agent_id: None, + status: SwarmTaskStatus::Failed, + result: None, + error: Some(error), + steps_taken: 0, + duration_ms: 0, + }], + } +} + +fn build_progress_outcome( + swarm_id: &str, + start: Instant, + order: &[String], + states: &HashMap, + status: SwarmStatus, +) -> SwarmOutcome { + let tasks = build_task_outcomes(order, states); + let counts = build_counts(&tasks); + SwarmOutcome { + swarm_id: swarm_id.to_string(), + status, + duration_ms: u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX), + counts, + tasks, + } +} + +fn emit_swarm_status(event_tx: Option<&tokio::sync::mpsc::Sender>, outcome: &SwarmOutcome) { + let Some(event_tx) = event_tx else { + return; + }; + + let message = format!( + "Swarm {}: status={} completed={}/{} running={} failed={} skipped={} cancelled={}", + outcome.swarm_id, + outcome.status.as_str(), + outcome.counts.completed, + outcome.counts.total, + outcome.counts.running, + outcome.counts.failed, + outcome.counts.skipped, + outcome.counts.cancelled + ); + let _ = event_tx.try_send(Event::Status { message }); +} + +fn parse_swarm_id<'a>(input: &'a Value) -> Result<&'a str, ToolError> { + input + .get("swarm_id") + .or_else(|| input.get("id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| ToolError::missing_field("swarm_id")) } fn format_prompt(shared_context: Option<&str>, prompt: &str) -> String { if let Some(context) = shared_context { - format!("Shared context:\n{context}\n\nTask:\n{prompt}") + format!( + "Shared context (authoritative):\n{context}\n\nTask:\n{prompt}\n\nReturn sections:\nSUMMARY\nEVIDENCE\nCHANGES\nRISKS" + ) } else { - prompt.to_string() + format!("{prompt}\n\nReturn sections:\nSUMMARY\nEVIDENCE\nCHANGES\nRISKS") } } +fn normalize_role_alias(input: &str) -> Option<&'static str> { + match input.to_ascii_lowercase().as_str() { + "default" => Some("default"), + "worker" | "general" => Some("worker"), + "explorer" | "explore" => Some("explorer"), + "awaiter" | "plan" | "planner" => Some("awaiter"), + _ => None, + } +} + +fn default_role_for_type(agent_type: &SubAgentType) -> Option<&'static str> { + match agent_type { + SubAgentType::General => Some("worker"), + SubAgentType::Explore => Some("explorer"), + SubAgentType::Plan => Some("awaiter"), + SubAgentType::Review | SubAgentType::Custom => None, + } +} + +fn resolve_task_assignment( + task: &SwarmTaskSpec, +) -> Result<(SubAgentType, Option, String), ToolError> { + let prompt = task.prompt.trim(); + if prompt.is_empty() { + return Err(ToolError::invalid_input(format!( + "task '{}' prompt cannot be empty", + task.id + ))); + } + + let objective = task.objective.as_deref().unwrap_or(prompt).trim(); + if objective.is_empty() { + return Err(ToolError::invalid_input(format!( + "task '{}' objective cannot be empty", + task.id + ))); + } + + let normalized_role = task + .role + .as_deref() + .map(str::trim) + .filter(|role| !role.is_empty()) + .map(|role| { + normalize_role_alias(role).ok_or_else(|| { + ToolError::invalid_input(format!( + "task '{}' has invalid role '{}'. Use: worker, explorer, awaiter, default", + task.id, role + )) + }) + }) + .transpose()?; + + let role_type = task + .role + .as_deref() + .map(str::trim) + .filter(|role| !role.is_empty()) + .and_then(SubAgentType::from_str); + + if let (Some(explicit), Some(inferred)) = (&task.agent_type, &role_type) + && explicit != inferred + { + return Err(ToolError::invalid_input(format!( + "task '{}' has conflicting type and role values", + task.id + ))); + } + + let agent_type = task + .agent_type + .clone() + .or(role_type) + .unwrap_or(SubAgentType::General); + + let role = normalized_role + .or_else(|| default_role_for_type(&agent_type)) + .map(str::to_string); + + Ok((agent_type, role, objective.to_string())) +} + +fn task_retry_count(task: &SwarmTaskSpec) -> u32 { + task.retry_count.unwrap_or(0).min(MAX_TASK_RETRIES) +} + +fn task_retry_delay_ms(task: &SwarmTaskSpec) -> u64 { + task.retry_delay_ms + .unwrap_or(DEFAULT_TASK_RETRY_DELAY_MS) + .clamp(1, MAX_TASK_RETRY_DELAY_MS) +} + +fn task_timeout(task: &SwarmTaskSpec) -> Option { + task.task_timeout_ms + .map(|timeout_ms| timeout_ms.clamp(1, MAX_TASK_TIMEOUT_MS)) + .map(Duration::from_millis) +} + +fn retry_delay_for_attempt(task: &SwarmTaskSpec, attempts_made: u32) -> Duration { + let base = task_retry_delay_ms(task); + let exponent = attempts_made.saturating_sub(1).min(8); + let factor = 1u64 << exponent; + let delay = base.saturating_mul(factor).min(MAX_TASK_RETRY_DELAY_MS); + Duration::from_millis(delay) +} + +fn schedule_retry_if_possible( + task: &SwarmTaskSpec, + task_id: &str, + attempts_made: &HashMap, + fail_fast: bool, + pending: &mut HashSet, + running: &mut HashMap, + running_started_at: &mut HashMap, + retry_ready_at: &mut HashMap, + states: &mut HashMap, +) -> bool { + if fail_fast { + return false; + } + let attempts = attempts_made.get(task_id).copied().unwrap_or(0); + if attempts == 0 || attempts > task_retry_count(task) { + return false; + } + + let delay = retry_delay_for_attempt(task, attempts); + pending.insert(task_id.to_string()); + running.remove(task_id); + running_started_at.remove(task_id); + retry_ready_at.insert(task_id.to_string(), Instant::now() + delay); + states.insert(task_id.to_string(), SwarmTaskState::Pending); + true +} + fn dependencies_satisfied(task: &SwarmTaskSpec, states: &HashMap) -> bool { task.depends_on.iter().all(|dep| { matches!( @@ -495,6 +1246,8 @@ async fn apply_fail_fast( states: &mut HashMap, pending: &mut HashSet, running: &mut HashMap, + running_started_at: &mut HashMap, + retry_ready_at: &mut HashMap, ) -> Result<(), ToolError> { cancel_running_tasks(manager, running, states).await?; for task_id in pending.drain() { @@ -504,6 +1257,8 @@ async fn apply_fail_fast( ); } running.clear(); + running_started_at.clear(); + retry_ready_at.clear(); Ok(()) } @@ -633,7 +1388,20 @@ fn validate_swarm_tasks(tasks: &[SwarmTaskSpec]) -> Result<(), ToolError> { "task '{id}' prompt cannot be empty" ))); } - if matches!(task.agent_type, Some(SubAgentType::Custom)) { + if let Some(retry_count) = task.retry_count + && retry_count > MAX_TASK_RETRIES + { + return Err(ToolError::invalid_input(format!( + "task '{id}' retry_count must be <= {MAX_TASK_RETRIES}" + ))); + } + if matches!(task.task_timeout_ms, Some(0)) { + return Err(ToolError::invalid_input(format!( + "task '{id}' task_timeout_ms must be > 0" + ))); + } + let (resolved_type, _, _) = resolve_task_assignment(task)?; + if matches!(resolved_type, SubAgentType::Custom) { let tools = task.allowed_tools.as_deref().unwrap_or(&[]); if tools.is_empty() { return Err(ToolError::invalid_input(format!( @@ -717,13 +1485,23 @@ fn visit( #[cfg(test)] mod tests { - use super::{SwarmTaskSpec, validate_swarm_tasks}; + use super::{ + SwarmStatus, SwarmTaskSpec, build_initial_outcome, parse_swarm_id, resolve_task_assignment, + retry_delay_for_attempt, task_retry_count, task_timeout, validate_swarm_tasks, + }; + use serde_json::json; + use std::time::Duration; fn task(id: &str, deps: &[&str]) -> SwarmTaskSpec { SwarmTaskSpec { id: id.to_string(), prompt: "do work".to_string(), agent_type: None, + role: None, + objective: None, + retry_count: None, + retry_delay_ms: None, + task_timeout_ms: None, allowed_tools: None, depends_on: deps.iter().map(|dep| dep.to_string()).collect(), } @@ -746,4 +1524,72 @@ mod tests { let tasks = vec![task("a", &["b"]), task("b", &["a"])]; assert!(validate_swarm_tasks(&tasks).is_err()); } + + #[test] + fn validate_swarm_tasks_rejects_invalid_role_alias() { + let mut tasks = vec![task("a", &[])]; + tasks[0].role = Some("invalid".to_string()); + assert!(validate_swarm_tasks(&tasks).is_err()); + } + + #[test] + fn validate_swarm_tasks_rejects_conflicting_role_and_type() { + let mut tasks = vec![task("a", &[])]; + tasks[0].agent_type = Some(crate::tools::subagent::SubAgentType::Explore); + tasks[0].role = Some("worker".to_string()); + assert!(validate_swarm_tasks(&tasks).is_err()); + } + + #[test] + fn validate_swarm_tasks_rejects_zero_task_timeout() { + let mut tasks = vec![task("a", &[])]; + tasks[0].task_timeout_ms = Some(0); + assert!(validate_swarm_tasks(&tasks).is_err()); + } + + #[test] + fn retry_helpers_apply_caps_and_backoff() { + let mut t = task("a", &[]); + t.retry_count = Some(super::MAX_TASK_RETRIES + 5); + t.retry_delay_ms = Some(250); + t.task_timeout_ms = Some(super::MAX_TASK_TIMEOUT_MS + 5_000); + + assert_eq!(task_retry_count(&t), super::MAX_TASK_RETRIES); + assert_eq!( + task_timeout(&t).expect("timeout should exist"), + Duration::from_millis(super::MAX_TASK_TIMEOUT_MS) + ); + assert_eq!(retry_delay_for_attempt(&t, 1), Duration::from_millis(250)); + assert_eq!(retry_delay_for_attempt(&t, 2), Duration::from_millis(500)); + } + + #[test] + fn resolve_task_assignment_infers_role_and_objective_defaults() { + let mut task = task("a", &[]); + task.prompt = "scan files".to_string(); + task.role = Some("explorer".to_string()); + let (agent_type, role, objective) = + resolve_task_assignment(&task).expect("assignment should resolve"); + assert!(matches!( + agent_type, + crate::tools::subagent::SubAgentType::Explore + )); + assert_eq!(role.as_deref(), Some("explorer")); + assert_eq!(objective, "scan files"); + } + + #[test] + fn build_initial_outcome_marks_swarm_running() { + let tasks = vec![task("a", &[]), task("b", &["a"])]; + let outcome = build_initial_outcome("swarm_test", &tasks); + assert!(matches!(outcome.status, SwarmStatus::Running)); + assert_eq!(outcome.counts.total, 2); + assert_eq!(outcome.counts.pending, 2); + } + + #[test] + fn parse_swarm_id_supports_alias() { + let input = json!({ "id": "swarm_1234" }); + assert_eq!(parse_swarm_id(&input).unwrap(), "swarm_1234"); + } } diff --git a/src/tui/app.rs b/src/tui/app.rs index 21dbb2d5..0a9d300a 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -296,6 +296,10 @@ pub struct App { pub max_subagents: usize, /// Cached sub-agent snapshots for UI views. pub subagent_cache: Vec, + /// Last known per-agent progress text for running sub-agents. + pub agent_progress: HashMap, + /// Animation anchor for status-strip active sub-agent spinner. + pub agent_activity_started_at: Option, pub ui_theme: UiTheme, // Onboarding pub onboarding: OnboardingState, @@ -574,6 +578,8 @@ impl App { allow_shell, max_subagents, subagent_cache: Vec::new(), + agent_progress: HashMap::new(), + agent_activity_started_at: None, ui_theme, onboarding: if needs_onboarding { if was_onboarded && needs_api_key { diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 6b376f7a..bf9dd5b8 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -48,7 +48,7 @@ use crate::task_manager::{ use crate::tools::ReviewOutput; use crate::tools::plan::StepStatus; use crate::tools::spec::{ToolError, ToolResult}; -use crate::tools::subagent::{SubAgentResult, SubAgentStatus, SubAgentType}; +use crate::tools::subagent::{SubAgentResult, SubAgentStatus}; use crate::tools::todo::TodoStatus; use crate::tui::command_palette::{ CommandPaletteView, build_entries as build_command_palette_entries, @@ -95,6 +95,7 @@ const UI_ACTIVE_POLL_MS: u64 = 16; const UI_DEEPSEEK_SQUIGGLE_MS: u64 = 120; const UI_TYPING_INDICATOR_MS: u64 = 120; const UI_STATUS_ANIMATION_MS: u64 = UI_DEEPSEEK_SQUIGGLE_MS; +const MAX_ACTIVE_AGENT_STATUS_ROWS: usize = 2; const WORKSPACE_CONTEXT_REFRESH_SECS: u64 = 15; #[derive(Debug, Clone, PartialEq, Eq)] @@ -620,38 +621,46 @@ async fn run_event_loop( } } EngineEvent::AgentSpawned { id, prompt } => { - app.add_message(HistoryCell::System { - content: format!( - "Sub-agent {id} spawned: {}", - summarize_tool_output(&prompt) - ), - }); - if app.view_stack.top_kind() == Some(ModalKind::SubAgents) { - let _ = engine_handle.send(Op::ListSubAgents).await; + let prompt_summary = summarize_tool_output(&prompt); + app.agent_progress + .insert(id.clone(), format!("starting: {prompt_summary}")); + if app.agent_activity_started_at.is_none() { + app.agent_activity_started_at = Some(Instant::now()); } + app.add_message(HistoryCell::System { + content: format!("Sub-agent {id} spawned: {}", prompt_summary), + }); + let _ = engine_handle.send(Op::ListSubAgents).await; } EngineEvent::AgentProgress { id, status } => { + app.agent_progress + .insert(id.clone(), summarize_tool_output(&status)); + if app.agent_activity_started_at.is_none() { + app.agent_activity_started_at = Some(Instant::now()); + } app.status_message = Some(format!("Sub-agent {id}: {status}")); } EngineEvent::AgentComplete { id, result } => { + app.agent_progress.remove(&id); app.add_message(HistoryCell::System { content: format!( "Sub-agent {id} completed: {}", summarize_tool_output(&result) ), }); - if app.view_stack.top_kind() == Some(ModalKind::SubAgents) { - let _ = engine_handle.send(Op::ListSubAgents).await; - } + let _ = engine_handle.send(Op::ListSubAgents).await; } EngineEvent::AgentList { agents } => { - app.subagent_cache = agents.clone(); - if app.view_stack.update_subagents(&agents) { + let mut sorted = agents.clone(); + sort_subagents_in_place(&mut sorted); + app.subagent_cache = sorted.clone(); + reconcile_subagent_activity_state(app); + if app.view_stack.update_subagents(&sorted) { app.status_message = - Some(format!("Sub-agents: {} total", agents.len())); + Some(format!("Sub-agents: {} total", sorted.len())); } else { app.add_message(HistoryCell::System { - content: format_subagent_list(&agents), + content: format_subagent_list(&sorted), }); } } @@ -815,7 +824,8 @@ async fn run_event_loop( handle_view_events(app, &engine_handle, events).await; } - if app.is_loading + let has_running_agents = running_agent_count(app) > 0; + if (app.is_loading || has_running_agents) && last_status_frame.elapsed() >= Duration::from_millis(UI_STATUS_ANIMATION_MS) { app.needs_redraw = true; @@ -837,7 +847,7 @@ async fn run_event_loop( app.needs_redraw = false; } - let mut poll_timeout = if app.is_loading { + let mut poll_timeout = if app.is_loading || has_running_agents { Duration::from_millis(UI_ACTIVE_POLL_MS) } else { Duration::from_millis(UI_IDLE_POLL_MS) @@ -2140,6 +2150,84 @@ fn compact_queued_preview(app: &App, preview_rows_budget: usize) -> (Vec (previews, queue_count > shown_count) } +fn running_agent_count(app: &App) -> usize { + let mut ids: std::collections::HashSet<&str> = + app.agent_progress.keys().map(String::as_str).collect(); + for agent in app + .subagent_cache + .iter() + .filter(|agent| matches!(agent.status, SubAgentStatus::Running)) + { + ids.insert(agent.agent_id.as_str()); + } + ids.len() +} + +fn active_agent_rows(app: &App, limit: usize) -> Vec<(String, String)> { + if limit == 0 { + return Vec::new(); + } + + let mut rows = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for agent in app + .subagent_cache + .iter() + .filter(|agent| matches!(agent.status, SubAgentStatus::Running)) + { + let detail = app + .agent_progress + .get(&agent.agent_id) + .cloned() + .unwrap_or_else(|| summarize_tool_output(&agent.assignment.objective)); + rows.push((agent.agent_id.clone(), summarize_tool_output(&detail))); + seen.insert(agent.agent_id.clone()); + if rows.len() >= limit { + return rows; + } + } + + let mut extras: Vec<(String, String)> = app + .agent_progress + .iter() + .filter(|(id, _)| !seen.contains(id.as_str())) + .map(|(id, status)| (id.clone(), summarize_tool_output(status))) + .collect(); + extras.sort_by(|a, b| a.0.cmp(&b.0)); + + rows.extend(extras.into_iter().take(limit.saturating_sub(rows.len()))); + rows +} + +fn reconcile_subagent_activity_state(app: &mut App) { + let running_agents: Vec<(String, String)> = app + .subagent_cache + .iter() + .filter(|agent| matches!(agent.status, SubAgentStatus::Running)) + .map(|agent| { + ( + agent.agent_id.clone(), + summarize_tool_output(&agent.assignment.objective), + ) + }) + .collect(); + + let running_ids: std::collections::HashSet = + running_agents.iter().map(|(id, _)| id.clone()).collect(); + app.agent_progress + .retain(|id, _| running_ids.contains(id.as_str())); + for (id, objective) in running_agents { + app.agent_progress.entry(id).or_insert(objective); + } + + if running_ids.is_empty() { + app.agent_activity_started_at = None; + } else if app.agent_activity_started_at.is_none() { + app.agent_activity_started_at = Some(Instant::now()); + } +} + fn compute_status_layout( app: &App, terminal_height: u16, @@ -2154,7 +2242,10 @@ fn compute_status_layout( }; } - let fixed_rows = usize::from(app.is_loading) + usize::from(app.queued_draft.is_some()); + let active_agents = running_agent_count(app); + let fixed_rows = usize::from(app.is_loading) + + usize::from(app.queued_draft.is_some()) + + usize::from(active_agents > 0); let queue_rows_budget = usize::from(status_budget).saturating_sub(fixed_rows); let (queued_preview, preview_compacted) = if queue_rows_budget > 0 { @@ -2168,7 +2259,12 @@ fn compute_status_layout( } else { 0 }; - let requested_rows = fixed_rows + queue_rows; + let mut requested_rows = fixed_rows + queue_rows; + if active_agents > 0 { + let detail_rows_budget = usize::from(status_budget).saturating_sub(requested_rows); + let detail_rows = detail_rows_budget.min(active_agents.min(MAX_ACTIVE_AGENT_STATUS_ROWS)); + requested_rows += detail_rows; + } let status_height = u16::try_from(requested_rows.min(usize::from(status_budget))).unwrap_or(status_budget); let queued_compacted = preview_compacted || (app.queued_message_count() > 0 && queue_rows == 0); @@ -2605,15 +2701,10 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &App) { SubAgentStatus::Failed(_) => ("failed", palette::STATUS_ERROR), SubAgentStatus::Cancelled => ("cancelled", palette::TEXT_MUTED), }; - let agent_type = match agent.agent_type { - SubAgentType::General => "general", - SubAgentType::Explore => "explore", - SubAgentType::Plan => "plan", - SubAgentType::Review => "review", - SubAgentType::Custom => "custom", - }; + let agent_type = agent.agent_type.as_str(); + let role = agent.assignment.role.as_deref().unwrap_or("default"); let summary = format!( - "{} {agent_type} {status_label} ({} steps)", + "{} {agent_type}/{role} {status_label} ({} steps)", truncate_line_to_width(&agent.agent_id, 10), agent.steps_taken ); @@ -2621,6 +2712,16 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &App) { truncate_line_to_width(&summary, content_width.max(1)), Style::default().fg(status_color), ))); + lines.push(Line::from(Span::styled( + format!( + " {}", + truncate_line_to_width( + &agent.assignment.objective, + content_width.saturating_sub(2).max(1) + ) + ), + Style::default().fg(palette::TEXT_DIM), + ))); } let remaining = app.subagent_cache.len().saturating_sub(max_agents); @@ -3030,7 +3131,13 @@ fn render_status_indicator( queued: &[String], queued_compacted: bool, ) { - let mut lines = Vec::with_capacity(1 + queued.len() + usize::from(app.queued_draft.is_some())); + let max_rows = usize::from(area.height); + if max_rows == 0 { + return; + } + let mut lines = Vec::with_capacity( + 2 + queued.len() + usize::from(app.queued_draft.is_some()) + MAX_ACTIVE_AGENT_STATUS_ROWS, + ); if app.is_loading { let header = if app.show_thinking { @@ -3083,6 +3190,49 @@ fn render_status_indicator( lines.push(Line::from(spans)); } + let active_agent_total = running_agent_count(app); + if active_agent_total > 0 && lines.len() < max_rows { + let available = area.width as usize; + let spinner_start = app.agent_activity_started_at.or(app.turn_started_at); + let spinner = deepseek_squiggle(spinner_start); + let header = format!("AGENTS {active_agent_total} running | /agents"); + lines.push(Line::from(vec![ + Span::styled(spinner, Style::default().fg(palette::DEEPSEEK_SKY).bold()), + Span::raw(" "), + Span::styled( + truncate_line_to_width(&header, available.saturating_sub(2).max(1)), + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + ), + ])); + + let preview_limit = max_rows + .saturating_sub(lines.len()) + .min(MAX_ACTIVE_AGENT_STATUS_ROWS); + let active_rows = active_agent_rows(app, preview_limit); + for (id, status) in &active_rows { + if lines.len() >= max_rows { + break; + } + let id_short = truncate_line_to_width(id, 12); + let status_single_line = status.lines().next().unwrap_or(status.as_str()); + let prefix = format!(" {id_short}: "); + let detail_width = available.saturating_sub(prefix.width()).max(1); + let detail = truncate_line_to_width(status_single_line, detail_width); + lines.push(Line::from(vec![ + Span::styled(prefix, Style::default().fg(palette::TEXT_MUTED)), + Span::styled(detail, Style::default().fg(palette::TEXT_DIM)), + ])); + } + + let hidden = active_agent_total.saturating_sub(active_rows.len()); + if hidden > 0 && lines.len() < max_rows { + lines.push(Line::from(Span::styled( + format!(" +{hidden} more running"), + Style::default().fg(palette::TEXT_MUTED), + ))); + } + } + if let Some(draft) = app.queued_draft.as_ref() { let available = area.width as usize; let prefix = "Editing queued:"; @@ -3828,20 +3978,48 @@ fn extract_reasoning_header(text: &str) -> Option { } } +fn subagent_status_rank(status: &SubAgentStatus) -> u8 { + match status { + SubAgentStatus::Running => 0, + SubAgentStatus::Failed(_) => 1, + SubAgentStatus::Completed => 2, + SubAgentStatus::Cancelled => 3, + } +} + +fn sort_subagents_in_place(agents: &mut [SubAgentResult]) { + agents.sort_by(|a, b| { + subagent_status_rank(&a.status) + .cmp(&subagent_status_rank(&b.status)) + .then_with(|| a.agent_type.as_str().cmp(b.agent_type.as_str())) + .then_with(|| a.agent_id.cmp(&b.agent_id)) + }); +} + fn format_subagent_list(agents: &[SubAgentResult]) -> String { if agents.is_empty() { return "No sub-agents running.".to_string(); } + let mut sorted = agents.to_vec(); + sort_subagents_in_place(&mut sorted); + let mut lines = Vec::new(); lines.push("Sub-agents:".to_string()); lines.push("----------------------------------------".to_string()); - for agent in agents { + for agent in &sorted { let status = format_subagent_status(&agent.status); + let role = agent.assignment.role.as_deref().unwrap_or("default"); let mut line = format!( - " {} ({:?}) - {} | steps: {} | {}ms", - agent.agent_id, agent.agent_type, status, agent.steps_taken, agent.duration_ms + " {} ({}/{}) - {} | steps: {} | {}ms\n objective: {}", + agent.agent_id, + agent.agent_type.as_str(), + role, + status, + agent.steps_taken, + agent.duration_ms, + summarize_tool_output(&agent.assignment.objective) ); if matches!(agent.status, SubAgentStatus::Completed) && let Some(result) = agent.result.as_ref() diff --git a/src/tui/ui/tests.rs b/src/tui/ui/tests.rs index 63e499c4..3564b4ba 100644 --- a/src/tui/ui/tests.rs +++ b/src/tui/ui/tests.rs @@ -116,6 +116,111 @@ fn create_test_app() -> App { App::new(options, &Config::default()) } +fn make_subagent( + id: &str, + status: crate::tools::subagent::SubAgentStatus, +) -> crate::tools::subagent::SubAgentResult { + crate::tools::subagent::SubAgentResult { + agent_id: id.to_string(), + agent_type: crate::tools::subagent::SubAgentType::General, + assignment: crate::tools::subagent::SubAgentAssignment { + objective: format!("objective-{id}"), + role: Some("worker".to_string()), + }, + status, + result: None, + steps_taken: 0, + duration_ms: 0, + } +} + +#[test] +fn sort_subagents_orders_running_before_terminal_statuses() { + let mut agents = vec![ + make_subagent("agent_c", crate::tools::subagent::SubAgentStatus::Completed), + make_subagent("agent_a", crate::tools::subagent::SubAgentStatus::Running), + make_subagent( + "agent_b", + crate::tools::subagent::SubAgentStatus::Failed("boom".to_string()), + ), + ]; + + sort_subagents_in_place(&mut agents); + + assert_eq!(agents[0].agent_id, "agent_a"); + assert_eq!(agents[1].agent_id, "agent_b"); + assert_eq!(agents[2].agent_id, "agent_c"); +} + +#[test] +fn running_agent_count_unions_cache_and_progress() { + let mut app = create_test_app(); + app.subagent_cache = vec![ + make_subagent("agent_a", crate::tools::subagent::SubAgentStatus::Running), + make_subagent("agent_b", crate::tools::subagent::SubAgentStatus::Completed), + ]; + app.agent_progress + .insert("agent_c".to_string(), "planning".to_string()); + + assert_eq!(running_agent_count(&app), 2); +} + +#[test] +fn compute_status_layout_reserves_rows_for_active_agents() { + let app = create_test_app(); + let baseline = compute_status_layout(&app, 30, 3); + assert_eq!(baseline.status_height, 0); + + let mut with_agents = create_test_app(); + with_agents + .agent_progress + .insert("agent_a".to_string(), "running".to_string()); + let active = compute_status_layout(&with_agents, 30, 3); + assert!(active.status_height >= 1); +} + +#[test] +fn active_agent_rows_prefers_cache_order_and_progress_text() { + let mut app = create_test_app(); + app.subagent_cache = vec![ + make_subagent("agent_a", crate::tools::subagent::SubAgentStatus::Running), + make_subagent("agent_b", crate::tools::subagent::SubAgentStatus::Running), + ]; + app.agent_progress + .insert("agent_b".to_string(), "step 2".to_string()); + app.agent_progress + .insert("agent_c".to_string(), "queued".to_string()); + + let rows = active_agent_rows(&app, 3); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0].0, "agent_a"); + assert!(rows[0].1.contains("objective-agent_a")); + assert_eq!(rows[1].0, "agent_b"); + assert_eq!(rows[1].1, "step 2"); + assert_eq!(rows[2].0, "agent_c"); +} + +#[test] +fn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() { + let mut app = create_test_app(); + app.subagent_cache = vec![ + make_subagent("agent_a", crate::tools::subagent::SubAgentStatus::Running), + make_subagent("agent_b", crate::tools::subagent::SubAgentStatus::Completed), + ]; + app.agent_progress + .insert("agent_stale".to_string(), "old".to_string()); + + reconcile_subagent_activity_state(&mut app); + assert!(app.agent_progress.contains_key("agent_a")); + assert!(!app.agent_progress.contains_key("agent_stale")); + assert!(app.agent_activity_started_at.is_some()); + + app.subagent_cache.clear(); + reconcile_subagent_activity_state(&mut app); + assert!(app.agent_progress.is_empty()); + assert!(app.agent_activity_started_at.is_none()); +} + #[test] fn format_token_count_compact_formats_units() { assert_eq!(format_token_count_compact(999), "999"); diff --git a/src/tui/views/mod.rs b/src/tui/views/mod.rs index ce4dfb13..cc34d6f3 100644 --- a/src/tui/views/mod.rs +++ b/src/tui/views/mod.rs @@ -1360,6 +1360,22 @@ fn append_subagent_group( ])); } + if let Some(role) = agent.assignment.role.as_deref() { + let max_len = content_width.saturating_sub(14); + let role = truncate_view_text(role, max_len); + lines.push(Line::from(vec![ + Span::styled(" role: ", Style::default().fg(palette::TEXT_MUTED)), + Span::styled(role, Style::default().fg(palette::DEEPSEEK_SKY)), + ])); + } + + let max_len = content_width.saturating_sub(18); + let objective = truncate_view_text(&agent.assignment.objective, max_len); + lines.push(Line::from(vec![ + Span::styled(" objective: ", Style::default().fg(palette::TEXT_MUTED)), + Span::styled(objective, Style::default().fg(palette::TEXT_DIM)), + ])); + if let Some(result) = agent.result.as_ref() { let max_len = content_width.saturating_sub(16); let preview = truncate_view_text(result, max_len);