Skip to main content

recursive/tools/
agent.rs

1//! Unified multi-agent delegation tool (`agent`) plus shared-memory tools.
2//!
3//! # Design
4//!
5//! A single `agent` tool replaces the previous fragmented delegation surface
6//! (`SubAgent` / `spawn_worker` / `spawn_workers_parallel` / `team_add_role` /
7//! `team_remove_role` / `team_list_roles`).  The caller provides a `manifest`
8//! that maps worker IDs to `{ system_prompt, allowed_tools }` entries and an
9//! execution `mode`:
10//!
11//! - `"single"`   — one worker, exactly as if `SubAgent` + explicit role had
12//!   been combined.
13//! - `"parallel"` — all workers run concurrently (join_all).  Read-only
14//!   workers benefit most.
15//! - `"sequential"` — workers run one after another, in manifest key order.
16//!
17//! Shared-memory read/write are kept as independent tools so workers can
18//! coordinate through a shared key-value store.
19//!
20//! # Recursive safety
21//!
22//! A depth limit (`RECURSIVE_SUBAGENT_MAX_DEPTH` env, default 2) prevents
23//! unbounded nesting.  Each child `agent` increments the depth counter; when
24//! the limit is reached the tool returns an error string instead of spawning.
25
26use async_trait::async_trait;
27use serde_json::{json, Value};
28use std::collections::HashMap;
29use std::sync::{Arc, Mutex};
30use tokio::sync::{mpsc, RwLock};
31
32use crate::agent::FinishReason;
33use crate::error::{Error, Result};
34use crate::llm::{ChatProvider, ToolSpec};
35use crate::multi::{AgentManifest, AgentMode, AgentPool, WorkerManifestEntry};
36use crate::runtime::{AgentRuntime, AgentRuntimeBuilder};
37use crate::tasks::{TaskId, TaskRegistry, TaskState};
38use crate::tools::agent_defs::AgentDefinitions;
39use crate::tools::edit::EditTool;
40use crate::tools::fs::{ReadFile, ReadFileState, WriteFile};
41use crate::tools::send_message::{ListWorkersTool, SendMessageTool, WorkerRegistry};
42use crate::tools::{PermissionHook, Tool, ToolRegistry, ToolSideEffect};
43
44// ---------------------------------------------------------------------------
45// SharedMemoryRead
46// ---------------------------------------------------------------------------
47
48/// The `shared_memory_read` tool — read a value from the shared memory store.
49pub struct SharedMemoryRead {
50    pool: Arc<RwLock<AgentPool>>,
51}
52
53impl SharedMemoryRead {
54    pub fn new(pool: Arc<RwLock<AgentPool>>) -> Self {
55        Self { pool }
56    }
57}
58
59#[async_trait]
60impl Tool for SharedMemoryRead {
61    fn spec(&self) -> ToolSpec {
62        ToolSpec {
63            name: "shared_memory_read".into(),
64            description: "Read a value from the shared memory store. Use this to retrieve context published by other workers.".into(),
65            parameters: json!({
66                "type": "object",
67                "properties": {
68                    "key": {
69                        "type": "string",
70                        "description": "The key to read from shared memory."
71                    }
72                },
73                "required": ["key"]
74            }),
75        }
76    }
77
78    fn side_effect_class(&self) -> ToolSideEffect {
79        ToolSideEffect::ReadOnly
80    }
81
82    async fn execute(&self, arguments: Value) -> Result<String> {
83        let key = arguments["key"]
84            .as_str()
85            .ok_or_else(|| Error::BadToolArgs {
86                name: "shared_memory_read".into(),
87                message: "missing required parameter: key".to_string(),
88            })?;
89
90        let pool = self.pool.read().await;
91        match pool.memory().get(key).await {
92            Some(entry) => Ok(entry.value),
93            None => Ok(format!("Key '{key}' not found in shared memory.")),
94        }
95    }
96}
97
98// ---------------------------------------------------------------------------
99// SharedMemoryWrite
100// ---------------------------------------------------------------------------
101
102/// The `shared_memory_write` tool — write a value into the shared memory store.
103pub struct SharedMemoryWrite {
104    pool: Arc<RwLock<AgentPool>>,
105    author: String,
106}
107
108impl SharedMemoryWrite {
109    pub fn new(pool: Arc<RwLock<AgentPool>>, author: String) -> Self {
110        Self { pool, author }
111    }
112}
113
114#[async_trait]
115impl Tool for SharedMemoryWrite {
116    fn spec(&self) -> ToolSpec {
117        ToolSpec {
118            name: "shared_memory_write".into(),
119            description: "Write a value to the shared memory store. Other workers can read this via shared_memory_read. Use this to publish findings, decisions, or intermediate results.".into(),
120            parameters: json!({
121                "type": "object",
122                "properties": {
123                    "key": {
124                        "type": "string",
125                        "description": "The key under which to store the value."
126                    },
127                    "value": {
128                        "type": "string",
129                        "description": "The value to store."
130                    }
131                },
132                "required": ["key", "value"]
133            }),
134        }
135    }
136
137    fn side_effect_class(&self) -> ToolSideEffect {
138        ToolSideEffect::External
139    }
140
141    async fn execute(&self, arguments: Value) -> Result<String> {
142        let key = arguments["key"]
143            .as_str()
144            .ok_or_else(|| Error::BadToolArgs {
145                name: "shared_memory_write".into(),
146                message: "missing required parameter: key".to_string(),
147            })?
148            .to_string();
149        let value = arguments["value"]
150            .as_str()
151            .ok_or_else(|| Error::BadToolArgs {
152                name: "shared_memory_write".into(),
153                message: "missing required parameter: value".to_string(),
154            })?
155            .to_string();
156
157        self.pool
158            .read()
159            .await
160            .memory()
161            .set(key.clone(), value, self.author.clone())
162            .await;
163        Ok(format!("Stored '{key}' in shared memory."))
164    }
165}
166
167// ---------------------------------------------------------------------------
168// AgentTool — unified delegation
169// ---------------------------------------------------------------------------
170
171/// A long-lived handle to a background worker, enabling cross-turn
172/// continuation via `send_message`.
173///
174/// When a worker is spawned in the background, its `AgentRuntime` lives in a
175/// dedicated tokio task that drains an mpsc channel of incoming prompts. Each
176/// `send_message` to this worker pushes a prompt onto `tx`; the worker task
177/// runs it as a new turn on the same runtime (preserving transcript/context).
178/// `task_id` links this handle to the `TaskRegistry` entry so `task_get` /
179/// `task_output` / `task_stop` work uniformly.
180pub struct WorkerHandle {
181    /// Push a new turn prompt to the background worker. Returns Err if the
182    /// worker task has exited (channel closed).
183    pub tx: mpsc::UnboundedSender<String>,
184    /// The TaskRegistry id under which this worker is registered.
185    pub task_id: TaskId,
186}
187
188/// A process-wide table of live background workers keyed by worker_id.
189/// Shared between the `agent` tool (which inserts) and the `send_message`
190/// tool ( which looks up to continue a worker).
191///
192/// The lock is a synchronous `std::sync::Mutex` (not tokio's `RwLock`) so a
193/// `Drop` guard can remove an entry without awaiting — the `task_stop` abort
194/// path unwinds the worker task and must deregister synchronously (goal-379).
195/// Critical sections are microsecond-scale (HashMap insert/get/remove).
196pub type WorkerTable = Arc<Mutex<HashMap<String, Arc<WorkerHandle>>>>;
197
198/// RAII guard that removes a background worker's `WorkerHandle` from the
199/// `WorkerTable` when the worker task exits.
200///
201/// The worker task normally removes its own entry at the end of the loop
202/// (channel close) and on the turn-failure path; but `task_stop` aborts the
203/// task's `JoinHandle`, unwinding the task at its next await point and
204/// skipping those trailing statements. Because this guard is a local of the
205/// spawned async block, its `Drop` runs on every exit path — normal return,
206/// panic unwind, AND abort unwind — and the synchronous table lock makes the
207/// removal safe from `Drop` (goal-379).
208struct WorkerDeregisterGuard {
209    workers: WorkerTable,
210    worker_key: String,
211}
212
213impl Drop for WorkerDeregisterGuard {
214    fn drop(&mut self) {
215        self.workers
216            .lock()
217            .unwrap_or_else(|e| e.into_inner())
218            .remove(&self.worker_key);
219    }
220}
221
222/// The unified `agent` delegation tool.
223///
224/// Spawns one or more specialist sub-agents (workers) according to a
225/// caller-supplied `manifest` and execution `mode`.
226pub struct AgentTool {
227    workspace: std::path::PathBuf,
228    provider: Arc<dyn ChatProvider>,
229    all_tools: ToolRegistry,
230    max_depth: usize,
231    current_depth: usize,
232    permission_hook: Option<Arc<dyn PermissionHook>>,
233    registry: Option<WorkerRegistry>,
234    pool: Option<Arc<RwLock<AgentPool>>>,
235    task_registry: Arc<TaskRegistry>,
236    definitions: Option<AgentDefinitions>,
237    /// Background worker continuation table (worker_id → handle). Populated
238    /// when a worker is spawned in the background; `send_message` reads here
239    /// to continue a worker across turns.
240    workers: WorkerTable,
241}
242
243impl AgentTool {
244    pub fn new(
245        workspace: impl Into<std::path::PathBuf>,
246        provider: Arc<dyn ChatProvider>,
247        all_tools: ToolRegistry,
248        max_depth: usize,
249        current_depth: usize,
250        permission_hook: Option<Arc<dyn PermissionHook>>,
251    ) -> Self {
252        Self {
253            workspace: workspace.into(),
254            provider,
255            all_tools,
256            max_depth,
257            current_depth,
258            permission_hook,
259            registry: None,
260            pool: None,
261            task_registry: Arc::new(TaskRegistry::new()),
262            definitions: None,
263            workers: Arc::new(Mutex::new(HashMap::new())),
264        }
265    }
266
267    /// Attach a `WorkerRegistry` so workers can send messages to each other.
268    pub fn with_registry(mut self, registry: WorkerRegistry) -> Self {
269        self.registry = Some(registry);
270        self
271    }
272
273    /// Attach an `AgentPool` for shared-memory coordination between workers.
274    pub fn with_pool(mut self, pool: Arc<RwLock<AgentPool>>) -> Self {
275        self.pool = Some(pool);
276        self
277    }
278
279    /// Attach a `TaskRegistry` so this agent and its descendants can
280    /// share background tasks (Phase D). If never called, a private
281    /// in-memory registry is used.
282    pub fn with_task_registry(mut self, reg: Arc<TaskRegistry>) -> Self {
283        self.task_registry = reg;
284        self
285    }
286
287    /// Attach an `AgentDefinitions` registry so manifest entries can
288    /// reference definitions by name via the `definition` field.
289    pub fn with_definitions(mut self, defs: AgentDefinitions) -> Self {
290        self.definitions = Some(defs);
291        self
292    }
293
294    /// Attach a shared background-worker continuation table. When set,
295    /// background workers register their continuation handle here so that
296    /// `send_message` can drive follow-up turns on the same runtime.
297    pub fn with_workers(mut self, workers: WorkerTable) -> Self {
298        self.workers = workers;
299        self
300    }
301
302    // ------------------------------------------------------------------
303    // Tool-registry construction
304    // ------------------------------------------------------------------
305
306    /// Build a restricted tool registry containing only the named tools.
307    ///
308    /// Uses `with_same_transport()` to start from an empty registry with the
309    /// same transport/permissions/policy as the parent, so only explicitly
310    /// listed tools are available — no accidental tool leakage.
311    ///
312    /// Sub-agents receive a **fresh** `ReadFileState` so their read history
313    /// is independent from the parent's.
314    fn build_sub_registry(&self, tool_names: &[String]) -> ToolRegistry {
315        let sub_read_state = Arc::new(Mutex::new(ReadFileState::new()));
316        // Start from parent's transport/permissions/policy but override
317        // read_file_state with a fresh instance for isolation.
318        let mut reg = self
319            .all_tools
320            .with_same_transport()
321            .with_read_file_state(sub_read_state.clone());
322        for name in tool_names {
323            // ReadFile and EditTool carry internal read_state references;
324            // create new instances bound to the sub-agent's fresh state rather
325            // than inheriting the parent's Arc.
326            let tool: Arc<dyn Tool> = match name.as_str() {
327                "Read" => {
328                    Arc::new(ReadFile::new(&self.workspace).with_read_state(sub_read_state.clone()))
329                }
330                "Edit" => {
331                    Arc::new(EditTool::new(&self.workspace).with_read_state(sub_read_state.clone()))
332                }
333                "Write" => Arc::new(
334                    WriteFile::new(&self.workspace).with_read_state(sub_read_state.clone()),
335                ),
336                _ => {
337                    if let Some(t) = self.all_tools.get(name) {
338                        t
339                    } else {
340                        continue;
341                    }
342                }
343            };
344            reg = reg.register(tool);
345        }
346        reg
347    }
348
349    /// Default tool set when no `allowed_tools` is specified: read-only + basic.
350    fn default_tool_names() -> Vec<String> {
351        vec![
352            "Read".to_string(),
353            "Grep".to_string(),
354            "Glob".to_string(),
355            "WebFetch".to_string(),
356            "SearchFiles".to_string(),
357        ]
358    }
359
360    // ------------------------------------------------------------------
361    // Worker execution
362    // ------------------------------------------------------------------
363
364    /// Build a fresh `AgentRuntime` for a worker with the manifest entry's
365    /// tool set and system prompt. The runtime retains transcript across
366    /// turns, which is what enables `send_message` to continue a background
367    /// worker: each follow-up prompt is a new turn on the same runtime.
368    ///
369    /// `worker_id` is used only for shared-memory tool namespacing.
370    async fn build_worker_runtime(
371        &self,
372        worker_id: &str,
373        entry: &WorkerManifestEntry,
374        max_steps: usize,
375        child_depth: usize,
376    ) -> Result<AgentRuntime> {
377        // Resolve allowed tools
378        let tool_names: Vec<String> = if entry.allowed_tools.is_empty() {
379            Self::default_tool_names()
380        } else {
381            entry.allowed_tools.clone()
382        };
383
384        // Build the worker's tool registry
385        let mut sub_registry = self.build_sub_registry(&tool_names);
386
387        // Register a child AgentTool for recursive delegation
388        let mut child_agent = AgentTool::new(
389            &self.workspace,
390            self.provider.clone(),
391            self.all_tools.fork(),
392            self.max_depth,
393            child_depth,
394            self.permission_hook.clone(),
395        );
396        if let Some(reg) = &self.registry {
397            child_agent = child_agent.with_registry(reg.clone());
398        }
399        if let Some(pool) = &self.pool {
400            child_agent = child_agent.with_pool(pool.clone());
401        }
402        // Always propagate the task registry and worker table so descendants
403        // share coordination state with the coordinator.
404        child_agent = child_agent
405            .with_task_registry(self.task_registry.clone())
406            .with_workers(self.workers.clone());
407        sub_registry = sub_registry.register(Arc::new(child_agent));
408
409        // Inject shared-memory tools if pool is available
410        if let Some(pool) = &self.pool {
411            sub_registry = sub_registry.register(Arc::new(SharedMemoryRead::new(pool.clone())));
412            sub_registry = sub_registry.register(Arc::new(SharedMemoryWrite::new(
413                pool.clone(),
414                worker_id.to_string(),
415            )));
416        }
417
418        // Inject inter-worker messaging tools if registry is available
419        if let Some(reg) = &self.registry {
420            sub_registry = sub_registry.register(Arc::new(SendMessageTool::new(
421                reg.clone(),
422                self.task_registry.clone(),
423                self.workers.clone(),
424            )));
425            sub_registry = sub_registry.register(Arc::new(ListWorkersTool::new(
426                reg.clone(),
427                self.task_registry.clone(),
428            )));
429        }
430
431        // Build the system prompt with shared-memory context
432        let mut system_prompt = entry.system_prompt.clone();
433        if let Some(pool) = &self.pool {
434            let memory_ctx = pool.read().await.memory().to_context_string().await;
435            if !memory_ctx.is_empty() {
436                system_prompt = format!("{}\n\n{}", system_prompt, memory_ctx);
437            }
438        }
439
440        AgentRuntimeBuilder::new()
441            .llm(self.provider.clone())
442            .tools(sub_registry)
443            .max_steps(max_steps)
444            .system_prompt(system_prompt)
445            .build()
446            .map_err(|e| Error::Tool {
447                name: "agent".into(),
448                call_id: None,
449                message: format!("failed to build worker '{}' runtime: {e}", worker_id),
450            })
451    }
452
453    /// Run a single worker synchronously and return its final text.
454    ///
455    /// The worker runs exactly one turn (the initial prompt) on a fresh
456    /// runtime; it is NOT registered for continuation. Use
457    /// [`spawn_background_worker`] for a long-lived, continuable worker.
458    async fn run_worker(
459        &self,
460        worker_id: &str,
461        entry: &WorkerManifestEntry,
462        prompt: &str,
463        max_steps: usize,
464        child_depth: usize,
465    ) -> Result<String> {
466        let mut runtime = self
467            .build_worker_runtime(worker_id, entry, max_steps, child_depth)
468            .await?;
469        let outcome = runtime.run(prompt).await.map_err(|e| Error::Tool {
470            name: "agent".into(),
471            call_id: None,
472            message: format!("worker '{}' failed: {e}", worker_id),
473        })?;
474
475        let finish_label = match outcome.finish_reason {
476            FinishReason::NoMoreToolCalls => "NoMoreToolCalls".to_string(),
477            FinishReason::BudgetExceeded => "BudgetExceeded".to_string(),
478            FinishReason::ProviderStop(r) => r,
479            FinishReason::Stuck { .. } => "Stuck".to_string(),
480            FinishReason::TranscriptLimit { .. } => "TranscriptLimit".to_string(),
481            FinishReason::Cancelled => "Cancelled".to_string(),
482            FinishReason::PermissionDenialLimit => "PermissionDenialLimit".to_string(),
483            FinishReason::WallClockExceeded { .. } => "WallClockExceeded".to_string(),
484        };
485
486        let final_text = outcome
487            .final_text
488            .unwrap_or_else(|| "(no final message)".to_string());
489
490        Ok(format!(
491            "[worker '{worker_id}' finished: {finish_label}]\n{final_text}"
492        ))
493    }
494
495    /// Spawn a worker into a background tokio task that drains an mpsc channel
496    /// of turn prompts against a single long-lived `AgentRuntime`. Returns the
497    /// task id (registered in the shared `TaskRegistry`) immediately; the
498    /// coordinator can continue the worker via `send_message(task_id=...)` and
499    /// inspect/cancel it via `task_get` / `task_output` / `task_stop`.
500    ///
501    /// The runtime is retained across turns, so follow-up prompts continue
502    /// the same conversation (transcript + todo + goals preserved).
503    async fn spawn_background_worker(
504        &self,
505        worker_id: &str,
506        entry: &WorkerManifestEntry,
507        prompt: &str,
508        max_steps: usize,
509        child_depth: usize,
510    ) -> Result<TaskId> {
511        let runtime = self
512            .build_worker_runtime(worker_id, entry, max_steps, child_depth)
513            .await?;
514        let runtime = Arc::new(tokio::sync::Mutex::new(runtime));
515
516        // Register a TaskState so task_* tools can observe/cancel this worker.
517        let (state, task_id) =
518            TaskState::new(format!("worker '{worker_id}'"), String::new(), worker_id);
519        let state = self.task_registry.register(state).await;
520
521        // Continuation channel: the first prompt is enqueued by the spawner;
522        // each `send_message` push adds another turn.
523        let (tx, mut rx) = mpsc::unbounded_channel::<String>();
524        let _ = tx.send(prompt.to_string());
525
526        // Record the continuation handle so `send_message` can reach this
527        // worker by worker_id.
528        let handle = Arc::new(WorkerHandle {
529            tx,
530            task_id: task_id.clone(),
531        });
532        self.workers
533            .lock()
534            .unwrap_or_else(|e| e.into_inner())
535            .insert(worker_id.to_string(), handle.clone());
536
537        // Spawn the long-lived worker task.
538        let state_for_task = state.clone();
539        // Clone the table + key so the task can deregister itself on EVERY
540        // exit path. A `WorkerDeregisterGuard` is a local of the spawned
541        // async block, so its `Drop` runs on normal exit (failure return,
542        // channel-close completion), on panic unwind, AND on the `task_stop`
543        // abort path where `JoinHandle::abort()` unwinds the task past any
544        // trailing statements. Without this the WorkerHandle — with its mpsc
545        // sender — lingers in the process-wide WorkerTable for the rest of
546        // the process: a slow memory + channel-buffer leak for long-running
547        // coordinator/HTTP/TUI processes (goal-370 normal exits, goal-379
548        // abort path).
549        let workers = self.workers.clone();
550        let worker_key = worker_id.to_string();
551        let join_handle = tokio::spawn(async move {
552            // Deregister from the WorkerTable when the task ends, no matter
553            // how it ends (see `WorkerDeregisterGuard` docs).
554            let _dereg_guard = WorkerDeregisterGuard {
555                workers: workers.clone(),
556                worker_key: worker_key.clone(),
557            };
558            // Drain the channel: each message is a new turn on the same runtime.
559            while let Some(msg) = rx.recv().await {
560                let mut rt = runtime.lock().await;
561                match rt.run(&msg).await {
562                    Ok(outcome) => {
563                        let text = outcome
564                            .final_text
565                            .unwrap_or_else(|| "(no final message)".to_string());
566                        let _ = state_for_task
567                            .append_output(format!("--- turn ---\n{text}"))
568                            .await;
569                    }
570                    Err(e) => {
571                        state_for_task
572                            .mark_failed(format!("worker turn failed: {e}"))
573                            .await;
574                        return;
575                    }
576                }
577            }
578            // Channel closed (no more send_message will arrive): mark complete.
579            state_for_task
580                .mark_completed("worker finished".to_string())
581                .await;
582        });
583
584        // Attach the JoinHandle so `task_stop` can truly abort this task.
585        state.set_handle(join_handle).await;
586
587        Ok(task_id)
588    }
589
590    // ------------------------------------------------------------------
591    // Mode dispatchers
592    // ------------------------------------------------------------------
593
594    /// Single mode: one worker.
595    async fn execute_single(
596        &self,
597        manifest: &AgentManifest,
598        prompt: &str,
599        max_steps: usize,
600        child_depth: usize,
601    ) -> Result<String> {
602        if manifest.len() != 1 {
603            return Err(Error::BadToolArgs {
604                name: "agent".into(),
605                message: format!(
606                    "mode 'single' requires exactly one manifest entry, got {}",
607                    manifest.len()
608                ),
609            });
610        }
611        // Safe: the `manifest.len() != 1` check above guarantees exactly one
612        // entry, so the iterator yields exactly one element.  Use
613        // `ok_or_else` (not `unwrap()`) to satisfy AGENTS.md invariant #5
614        // (no `unwrap()` in non-test code) while preserving the same error
615        // type.
616        let (worker_id, entry) = manifest.iter().next().ok_or_else(|| Error::BadToolArgs {
617            name: "agent".into(),
618            message: "mode 'single' requires exactly one manifest entry".to_string(),
619        })?;
620        self.run_worker(worker_id, entry, prompt, max_steps, child_depth)
621            .await
622    }
623
624    /// Parallel mode: all workers run concurrently via `futures_util::future::join_all`.
625    async fn execute_parallel(
626        &self,
627        manifest: &AgentManifest,
628        prompt: &str,
629        max_steps: usize,
630        child_depth: usize,
631    ) -> Result<String> {
632        if manifest.is_empty() {
633            return Err(Error::BadToolArgs {
634                name: "agent".into(),
635                message: "mode 'parallel' requires at least one manifest entry".to_string(),
636            });
637        }
638
639        // Pre-register all workers in the registry so they can message each other.
640        if let Some(reg) = &self.registry {
641            for worker_id in manifest.keys() {
642                reg.register(worker_id).await;
643            }
644        }
645
646        // Build a self-like AgentTool instance that can be moved into each task.
647        // The AgentTool struct is intentionally designed so that each parallel
648        // worker gets its own clone of the relevant fields.
649        let workspace = self.workspace.clone();
650        let provider = self.provider.clone();
651        let all_tools = self.all_tools.fork();
652        let max_depth = self.max_depth;
653        let permission_hook = self.permission_hook.clone();
654        let registry = self.registry.clone();
655        let pool = self.pool.clone();
656        let definitions = self.definitions.clone();
657        let workers = self.workers.clone();
658
659        // Spawn each worker into a tokio task, collecting JoinHandles.
660        let mut handles: Vec<tokio::task::JoinHandle<(String, Result<String>)>> = Vec::new();
661        for (worker_id, entry) in manifest.iter() {
662            let worker_id = worker_id.clone();
663            let entry = entry.clone();
664            let prompt = prompt.to_string();
665            let workspace = workspace.clone();
666            let provider = provider.clone();
667            let all_tools = all_tools.clone();
668            let permission_hook = permission_hook.clone();
669            let registry = registry.clone();
670            let pool = pool.clone();
671            let definitions = definitions.clone();
672            let workers = workers.clone();
673
674            handles.push(tokio::spawn(async move {
675                let agent = AgentTool {
676                    workspace,
677                    provider,
678                    all_tools,
679                    max_depth,
680                    current_depth: child_depth,
681                    permission_hook,
682                    registry: registry.clone(),
683                    pool: pool.clone(),
684                    task_registry: Arc::new(crate::tasks::TaskRegistry::new()),
685                    definitions,
686                    workers,
687                };
688                let result = agent
689                    .run_worker(&worker_id, &entry, &prompt, max_steps, child_depth)
690                    .await;
691
692                // Deregister this worker
693                if let Some(reg) = &registry {
694                    reg.deregister(&worker_id).await;
695                }
696
697                (worker_id, result)
698            }));
699        }
700
701        // Await all handles
702        let outcomes = futures_util::future::join_all(handles).await;
703
704        // Collect results, preserving order by worker ID
705        let mut results: Vec<(String, String)> = Vec::new();
706        for outcome in outcomes {
707            match outcome {
708                Ok((id, Ok(text))) => results.push((id, text)),
709                Ok((id, Err(e))) => {
710                    results.push((id, format!("ERROR: {e}")));
711                }
712                Err(join_err) => {
713                    results.push(("(unknown)".into(), format!("join error: {join_err}")));
714                }
715            }
716        }
717
718        // Sort by worker ID for deterministic output
719        results.sort_by(|a, b| a.0.cmp(&b.0));
720
721        Ok(results
722            .into_iter()
723            .map(|(id, text)| format!("=== {id} ===\n{text}"))
724            .collect::<Vec<_>>()
725            .join("\n\n"))
726    }
727
728    /// Sequential mode: workers run one after another.
729    async fn execute_sequential(
730        &self,
731        manifest: &AgentManifest,
732        prompt: &str,
733        max_steps: usize,
734        child_depth: usize,
735    ) -> Result<String> {
736        if manifest.is_empty() {
737            return Err(Error::BadToolArgs {
738                name: "agent".into(),
739                message: "mode 'sequential' requires at least one manifest entry".to_string(),
740            });
741        }
742
743        // Collect keys in stable order
744        let mut keys: Vec<&String> = manifest.keys().collect();
745        keys.sort();
746
747        let mut result_parts = Vec::new();
748        for worker_id in &keys {
749            let entry = &manifest[*worker_id];
750            let result = self
751                .run_worker(worker_id, entry, prompt, max_steps, child_depth)
752                .await?;
753            result_parts.push(result);
754        }
755
756        Ok(result_parts.join("\n\n"))
757    }
758
759    // ------------------------------------------------------------------
760    // Manifest validation
761    // ------------------------------------------------------------------
762
763    /// Parse a JSON Value into an AgentManifest, with helpful error messages.
764    ///
765    /// Supports named-definition resolution: if a manifest entry includes a
766    /// `definition` field, the entry is resolved from the loaded
767    /// `AgentDefinitions` registry.  Inline `system_prompt` and
768    /// `allowed_tools` override the definition's values when both are
769    /// provided.
770    fn parse_manifest(&self, value: &Value) -> Result<AgentManifest, Error> {
771        let obj = value.as_object().ok_or_else(|| Error::BadToolArgs {
772            name: "agent".into(),
773            message:
774                "`manifest` must be a JSON object mapping worker_id → {system_prompt | definition, allowed_tools?}"
775                    .to_string(),
776        })?;
777
778        if obj.is_empty() {
779            return Err(Error::BadToolArgs {
780                name: "agent".into(),
781                message: "`manifest` must have at least one entry".to_string(),
782            });
783        }
784
785        let mut manifest = AgentManifest::new();
786        for (worker_id, entry_val) in obj {
787            let entry_obj = entry_val.as_object().ok_or_else(|| Error::BadToolArgs {
788                name: "agent".into(),
789                message: format!(
790                    "manifest entry '{}' must be an object with `system_prompt` or `definition` (and optional `allowed_tools`)",
791                    worker_id
792                ),
793            })?;
794
795            // --- Resolve definition (if any) ---
796            let def_name = entry_obj.get("definition").and_then(|v| v.as_str());
797
798            let (base_system_prompt, base_allowed_tools) = if let Some(name) = def_name {
799                let defs = self.definitions.as_ref().ok_or_else(|| {
800                    Error::BadToolArgs {
801                        name: "agent".into(),
802                        message: format!(
803                            "manifest entry '{}' references definition '{}', but no agent definitions are loaded (missing .recursive/agents/)",
804                            worker_id, name
805                        ),
806                    }
807                })?;
808                let def = defs.get(name).ok_or_else(|| Error::BadToolArgs {
809                    name: "agent".into(),
810                    message: format!(
811                        "manifest entry '{}' references unknown definition '{}'. Available: {}",
812                        worker_id,
813                        name,
814                        defs.iter()
815                            .map(|(n, _)| n.as_str())
816                            .collect::<Vec<_>>()
817                            .join(", ")
818                    ),
819                })?;
820                (def.system_prompt.clone(), def.allowed_tools.clone())
821            } else {
822                (String::new(), Vec::new())
823            };
824
825            // --- Resolve system_prompt: inline wins over definition ---
826            let system_prompt = entry_obj
827                .get("system_prompt")
828                .and_then(|v| v.as_str())
829                .map(String::from)
830                .or({
831                    if !base_system_prompt.is_empty() {
832                        Some(base_system_prompt)
833                    } else {
834                        None
835                    }
836                })
837                .ok_or_else(|| Error::BadToolArgs {
838                    name: "agent".into(),
839                    message: format!(
840                        "manifest entry '{}' requires a `system_prompt` string or a `definition` reference",
841                        worker_id
842                    ),
843                })?;
844
845            // --- Resolve allowed_tools: inline wins over definition ---
846            let allowed_tools: Vec<String> = entry_obj
847                .get("allowed_tools")
848                .and_then(|v| v.as_array())
849                .map(|arr| {
850                    arr.iter()
851                        .filter_map(|v| v.as_str().map(String::from))
852                        .collect()
853                })
854                .or({
855                    if !base_allowed_tools.is_empty() {
856                        Some(base_allowed_tools)
857                    } else {
858                        None
859                    }
860                })
861                .unwrap_or_default();
862
863            manifest.insert(
864                worker_id.clone(),
865                WorkerManifestEntry {
866                    system_prompt,
867                    allowed_tools,
868                },
869            );
870        }
871        Ok(manifest)
872    }
873}
874
875#[async_trait]
876impl Tool for AgentTool {
877    fn spec(&self) -> ToolSpec {
878        ToolSpec {
879            name: "agent".into(),
880            // The coordinator guidance (how/when to delegate, writing worker
881            // prompts, never delegating understanding, continue vs spawn,
882            // verification) is intentionally embedded in the tool description
883            // rather than the system prompt. This mirrors the fake-cc pattern:
884            // the model sees it only when the `agent` tool is registered (i.e.
885            // sub-agent is enabled), so disabling sub-agent removes both the
886            // tool and its token cost, and the base system prompt stays stable
887            // for users who never delegate.
888            description: format!(
889                "{}\n\n{}",
890                concat!(
891                    "Spawn one or more specialist sub-agents (workers) defined by a `manifest`. ",
892                    "Use `mode: \"single\"` for one worker, `mode: \"parallel\"` for concurrent ",
893                    "execution, or `mode: \"sequential\"` when each worker depends on the previous. ",
894                    "Set `background: true` (with `single`) to spawn a long-lived worker that ",
895                    "returns a `task_id` immediately and can be continued across turns. ",
896                    "Workers have restricted tool sets and isolated transcripts."
897                ),
898                crate::multi::coordinator_system_prompt()
899            ),
900            parameters: json!({
901                "type": "object",
902                "properties": {
903                    "mode": {
904                        "type": "string",
905                        "enum": ["single", "parallel", "sequential"],
906                        "description": "Execution mode. 'single' spawns exactly one worker (manifest must have one entry). 'parallel' runs all workers concurrently. 'sequential' runs workers one after another.",
907                        "default": "single"
908                    },
909                    "manifest": {
910                        "type": "object",
911                        "description": "Map of worker_id → { system_prompt, allowed_tools? }. Each worker gets its own system prompt and restricted tool set.",
912                        "additionalProperties": {
913                            "type": "object",
914                            "properties": {
915                                "system_prompt": {
916                                    "type": "string",
917                                    "description": "System prompt defining the worker's role, behavior, and output format."
918                                },
919                                "allowed_tools": {
920                                    "type": "array",
921                                    "items": { "type": "string" },
922                                    "description": "Optional tool allowlist. Empty/absent defaults to read-only tools: Read, Grep, Glob, WebFetch, SearchFiles."
923                                }
924                            },
925                            "required": ["system_prompt"]
926                        }
927                    },
928                    "prompt": {
929                        "type": "string",
930                        "description": "The task description / goal for the worker(s). Every worker receives the same prompt."
931                    },
932                    "max_steps": {
933                        "type": "integer",
934                        "description": "Maximum steps per worker (default 30, max 100).",
935                        "default": 30
936                    },
937                    "background": {
938                        "type": "boolean",
939                        "description": "If true (and mode is 'single'), spawn the worker in the background and return its task_id immediately instead of waiting for it to finish. The worker runs on a long-lived runtime; use send_message(task_id=...) to run follow-up turns, and task_get/task_output/task_stop to inspect or cancel.",
940                        "default": false
941                    }
942                },
943                "required": ["manifest", "prompt"]
944            }),
945        }
946    }
947
948    fn side_effect_class(&self) -> ToolSideEffect {
949        // The agent tool may spawn workers that write files, so it's External
950        // by default.  Individual workers within a manifest can be constrained
951        // to read-only via their `allowed_tools`.
952        ToolSideEffect::External
953    }
954
955    async fn execute(&self, arguments: Value) -> Result<String> {
956        // --- Resolve mode ---
957        let mode_str = arguments
958            .get("mode")
959            .and_then(|v| v.as_str())
960            .unwrap_or("single");
961        let mode = AgentMode::parse(mode_str).ok_or_else(|| Error::BadToolArgs {
962            name: "agent".into(),
963            message: format!(
964                "unknown mode '{mode_str}'. Valid modes: single, parallel, sequential"
965            ),
966        })?;
967
968        // --- Resolve prompt ---
969        let prompt = arguments["prompt"]
970            .as_str()
971            .ok_or_else(|| Error::BadToolArgs {
972                name: "agent".into(),
973                message: "missing required parameter: prompt".to_string(),
974            })?;
975
976        // --- Resolve max_steps ---
977        let max_steps = arguments["max_steps"].as_i64().unwrap_or(30).clamp(1, 100) as usize;
978
979        // --- Resolve background flag ---
980        let background = arguments
981            .get("background")
982            .and_then(|v| v.as_bool())
983            .unwrap_or(false);
984
985        // --- Parse manifest ---
986        let manifest = self.parse_manifest(&arguments["manifest"])?;
987
988        // --- Depth limit check ---
989        if self.current_depth >= self.max_depth {
990            return Ok(format!(
991                "ERROR: agent depth limit reached (max_depth={}). Cannot spawn deeper agents.",
992                self.max_depth
993            ));
994        }
995
996        let child_depth = self.current_depth + 1;
997
998        // --- Background mode (single only): spawn and return task_id ---
999        if background && matches!(mode, AgentMode::Single) {
1000            if manifest.len() != 1 {
1001                return Err(Error::BadToolArgs {
1002                    name: "agent".into(),
1003                    message: format!(
1004                        "background=true with mode 'single' requires exactly one manifest entry, got {}",
1005                        manifest.len()
1006                    ),
1007                });
1008            }
1009            let (worker_id, entry) = manifest.iter().next().ok_or_else(|| Error::BadToolArgs {
1010                name: "agent".into(),
1011                message: "background=true requires one manifest entry".to_string(),
1012            })?;
1013            let task_id = self
1014                .spawn_background_worker(worker_id, entry, prompt, max_steps, child_depth)
1015                .await?;
1016            return Ok(format!(
1017                "Background worker '{worker_id}' spawned as task '{task_id}'. \
1018                 Use send_message(task_id=\"{task_id}\", ...) to run follow-up turns, \
1019                 task_get/task_output to inspect, or task_stop to cancel."
1020            ));
1021        }
1022
1023        // --- Dispatch (foreground) ---
1024        match mode {
1025            AgentMode::Single => {
1026                self.execute_single(&manifest, prompt, max_steps, child_depth)
1027                    .await
1028            }
1029            AgentMode::Parallel => {
1030                self.execute_parallel(&manifest, prompt, max_steps, child_depth)
1031                    .await
1032            }
1033            AgentMode::Sequential => {
1034                self.execute_sequential(&manifest, prompt, max_steps, child_depth)
1035                    .await
1036            }
1037        }
1038    }
1039}
1040
1041// ---------------------------------------------------------------------------
1042// Tests
1043// ---------------------------------------------------------------------------
1044
1045#[cfg(test)]
1046mod tests {
1047    use super::*;
1048    use crate::llm::{Completion, MockProvider};
1049    use crate::tools::{
1050        GlobTool, LocalTransport, ReadFile, SearchFiles, ToolTransport, WebFetch, WriteFile,
1051    };
1052
1053    fn mock_provider(script: Vec<Completion>) -> Arc<dyn ChatProvider> {
1054        Arc::new(MockProvider::new(script))
1055    }
1056
1057    fn full_tool_registry(workspace: &std::path::Path) -> ToolRegistry {
1058        let transport: Arc<dyn ToolTransport> = Arc::new(LocalTransport);
1059        ToolRegistry::new(transport)
1060            .register(Arc::new(ReadFile::new(workspace)))
1061            .register(Arc::new(SearchFiles::new(workspace)))
1062            .register(Arc::new(WriteFile::new(workspace)))
1063            .register(Arc::new(GlobTool::new(workspace)))
1064            .register(Arc::new(WebFetch::new()))
1065    }
1066
1067    #[tokio::test]
1068    async fn agent_single_mode_basic() {
1069        let provider = mock_provider(vec![Completion {
1070            content: "done".to_string(),
1071            tool_calls: vec![],
1072            finish_reason: Some("stop".into()),
1073            usage: None,
1074            reasoning_content: None,
1075        }]);
1076
1077        let tmp = tempfile::tempdir().unwrap();
1078        let all_tools = full_tool_registry(tmp.path());
1079        let agent = AgentTool::new(tmp.path(), provider, all_tools, 2, 0, None);
1080
1081        let result = agent
1082            .execute(json!({
1083                "mode": "single",
1084                "manifest": {
1085                    "helper": {
1086                        "system_prompt": "You are a helper.",
1087                        "allowed_tools": ["Read"]
1088                    }
1089                },
1090                "prompt": "say hi"
1091            }))
1092            .await
1093            .unwrap();
1094
1095        assert!(result.contains("helper"));
1096        assert!(result.contains("NoMoreToolCalls"));
1097        assert!(result.contains("done"));
1098    }
1099
1100    #[tokio::test]
1101    async fn agent_depth_limit() {
1102        let provider = mock_provider(vec![]);
1103        let tmp = tempfile::tempdir().unwrap();
1104        let all_tools = full_tool_registry(tmp.path());
1105        // current_depth == max_depth → should refuse
1106        let agent = AgentTool::new(tmp.path(), provider, all_tools, 2, 2, None);
1107
1108        let result = agent
1109            .execute(json!({
1110                "manifest": {
1111                    "w": { "system_prompt": "hi" }
1112                },
1113                "prompt": "test"
1114            }))
1115            .await
1116            .unwrap();
1117
1118        assert!(result.contains("depth limit reached"));
1119    }
1120
1121    #[tokio::test]
1122    async fn agent_sequential() {
1123        let provider = mock_provider(vec![
1124            Completion {
1125                content: "first".to_string(),
1126                tool_calls: vec![],
1127                finish_reason: Some("stop".into()),
1128                usage: None,
1129                reasoning_content: None,
1130            },
1131            Completion {
1132                content: "second".to_string(),
1133                tool_calls: vec![],
1134                finish_reason: Some("stop".into()),
1135                usage: None,
1136                reasoning_content: None,
1137            },
1138        ]);
1139
1140        let tmp = tempfile::tempdir().unwrap();
1141        let all_tools = full_tool_registry(tmp.path());
1142        let agent = AgentTool::new(tmp.path(), provider, all_tools, 2, 0, None);
1143
1144        let result = agent
1145            .execute(json!({
1146                "mode": "sequential",
1147                "manifest": {
1148                    "a": { "system_prompt": "A", "allowed_tools": ["Read"] },
1149                    "b": { "system_prompt": "B", "allowed_tools": ["Read"] }
1150                },
1151                "prompt": "process"
1152            }))
1153            .await
1154            .unwrap();
1155
1156        assert!(result.contains("first"));
1157        assert!(result.contains("second"));
1158    }
1159
1160    #[test]
1161    fn test_agent_mode_parse() {
1162        assert_eq!(AgentMode::parse("single"), Some(AgentMode::Single));
1163        assert_eq!(AgentMode::parse("parallel"), Some(AgentMode::Parallel));
1164        assert_eq!(AgentMode::parse("sequential"), Some(AgentMode::Sequential));
1165        assert_eq!(AgentMode::parse("unknown"), None);
1166    }
1167
1168    #[test]
1169    fn parse_manifest_empty_object_is_error() {
1170        // kills `if obj.is_empty() { return Err(...) }` guard removal mutation
1171        let tmp = tempfile::tempdir().unwrap();
1172        let all_tools = full_tool_registry(tmp.path());
1173        let agent = AgentTool::new(tmp.path(), mock_provider(vec![]), all_tools, 2, 0, None);
1174        let err = agent.parse_manifest(&json!({})).unwrap_err();
1175        let msg = format!("{err}");
1176        assert!(
1177            msg.contains("at least one entry"),
1178            "empty manifest must error; got: {msg}"
1179        );
1180    }
1181
1182    #[test]
1183    fn parse_manifest_non_object_is_error() {
1184        // kills `value.as_object().ok_or_else(...)` mutation
1185        let tmp = tempfile::tempdir().unwrap();
1186        let all_tools = full_tool_registry(tmp.path());
1187        let agent = AgentTool::new(tmp.path(), mock_provider(vec![]), all_tools, 2, 0, None);
1188        let err = agent.parse_manifest(&json!("not an object")).unwrap_err();
1189        let msg = format!("{err}");
1190        assert!(
1191            msg.contains("must be a JSON object"),
1192            "non-object manifest must error; got: {msg}"
1193        );
1194    }
1195
1196    // ------------------------------------------------------------------
1197    // Definition resolution tests
1198    // ------------------------------------------------------------------
1199
1200    #[test]
1201    fn parse_manifest_resolves_definition() {
1202        let tmp = tempfile::tempdir().unwrap();
1203        let all_tools = full_tool_registry(tmp.path());
1204
1205        // Populate agent definitions
1206        let agents_dir = tmp.path().join(".recursive").join("agents");
1207        std::fs::create_dir_all(&agents_dir).unwrap();
1208        std::fs::write(
1209            agents_dir.join("reviewer.md"),
1210            "---
1211name: reviewer
1212system_prompt: 'You review code.'
1213allowed_tools:
1214  - Read
1215  - Glob
1216---
1217",
1218        )
1219        .unwrap();
1220
1221        let defs = AgentDefinitions::load(tmp.path()).unwrap();
1222        let agent = AgentTool::new(tmp.path(), mock_provider(vec![]), all_tools, 2, 0, None)
1223            .with_definitions(defs);
1224
1225        let manifest = agent
1226            .parse_manifest(&json!({
1227                "rev": { "definition": "reviewer" }
1228            }))
1229            .unwrap();
1230
1231        let entry = manifest.get("rev").unwrap();
1232        assert_eq!(entry.system_prompt, "You review code.");
1233        assert_eq!(entry.allowed_tools, vec!["Read", "Glob"]);
1234    }
1235
1236    #[test]
1237    fn parse_manifest_definition_with_inline_override() {
1238        let tmp = tempfile::tempdir().unwrap();
1239        let all_tools = full_tool_registry(tmp.path());
1240
1241        let agents_dir = tmp.path().join(".recursive").join("agents");
1242        std::fs::create_dir_all(&agents_dir).unwrap();
1243        std::fs::write(
1244            agents_dir.join("helper.md"),
1245            "---
1246name: helper
1247system_prompt: 'Base prompt.'
1248allowed_tools:
1249  - Read
1250---
1251",
1252        )
1253        .unwrap();
1254
1255        let defs = AgentDefinitions::load(tmp.path()).unwrap();
1256        let agent = AgentTool::new(tmp.path(), mock_provider(vec![]), all_tools, 2, 0, None)
1257            .with_definitions(defs);
1258
1259        // Inline system_prompt overrides the definition's
1260        let manifest = agent
1261            .parse_manifest(&json!({
1262                "h": {
1263                    "definition": "helper",
1264                    "system_prompt": "Overridden prompt.",
1265                    "allowed_tools": ["Write"]
1266                }
1267            }))
1268            .unwrap();
1269
1270        let entry = manifest.get("h").unwrap();
1271        assert_eq!(entry.system_prompt, "Overridden prompt.");
1272        assert_eq!(entry.allowed_tools, vec!["Write"]);
1273    }
1274
1275    #[test]
1276    fn parse_manifest_unknown_definition_is_error() {
1277        let tmp = tempfile::tempdir().unwrap();
1278        let all_tools = full_tool_registry(tmp.path());
1279
1280        // Empty registry
1281        let defs = AgentDefinitions::load(tmp.path()).unwrap();
1282        let agent = AgentTool::new(tmp.path(), mock_provider(vec![]), all_tools, 2, 0, None)
1283            .with_definitions(defs);
1284
1285        let err = agent
1286            .parse_manifest(&json!({
1287                "w": { "definition": "nonexistent" }
1288            }))
1289            .unwrap_err();
1290
1291        let msg = format!("{err}");
1292        assert!(msg.contains("unknown definition"), "got: {msg}");
1293        assert!(msg.contains("nonexistent"), "got: {msg}");
1294    }
1295
1296    #[test]
1297    fn parse_manifest_definition_without_registry_is_error() {
1298        let tmp = tempfile::tempdir().unwrap();
1299        let all_tools = full_tool_registry(tmp.path());
1300
1301        // No with_definitions() call — definitions is None
1302        let agent = AgentTool::new(tmp.path(), mock_provider(vec![]), all_tools, 2, 0, None);
1303
1304        let err = agent
1305            .parse_manifest(&json!({
1306                "w": { "definition": "some-agent" }
1307            }))
1308            .unwrap_err();
1309
1310        let msg = format!("{err}");
1311        assert!(
1312            msg.contains("no agent definitions are loaded"),
1313            "got: {msg}"
1314        );
1315    }
1316
1317    #[test]
1318    fn parse_manifest_neither_definition_nor_system_prompt_is_error() {
1319        let tmp = tempfile::tempdir().unwrap();
1320        let all_tools = full_tool_registry(tmp.path());
1321        let agent = AgentTool::new(tmp.path(), mock_provider(vec![]), all_tools, 2, 0, None);
1322
1323        let err = agent
1324            .parse_manifest(&json!({
1325                "w": { "allowed_tools": ["Read"] }
1326            }))
1327            .unwrap_err();
1328
1329        let msg = format!("{err}");
1330        assert!(
1331            msg.contains("requires a `system_prompt` string or a `definition` reference"),
1332            "got: {msg}"
1333        );
1334    }
1335
1336    #[tokio::test]
1337    async fn agent_single_mode_with_definition() {
1338        let provider = mock_provider(vec![Completion {
1339            content: "review done".to_string(),
1340            tool_calls: vec![],
1341            finish_reason: Some("stop".into()),
1342            usage: None,
1343            reasoning_content: None,
1344        }]);
1345
1346        let tmp = tempfile::tempdir().unwrap();
1347        let all_tools = full_tool_registry(tmp.path());
1348
1349        // Set up agent definitions
1350        let agents_dir = tmp.path().join(".recursive").join("agents");
1351        std::fs::create_dir_all(&agents_dir).unwrap();
1352        std::fs::write(
1353            agents_dir.join("inspector.md"),
1354            "---
1355name: inspector
1356system_prompt: 'Inspect thoroughly.'
1357allowed_tools:
1358  - Read
1359---
1360",
1361        )
1362        .unwrap();
1363
1364        let defs = AgentDefinitions::load(tmp.path()).unwrap();
1365        let agent =
1366            AgentTool::new(tmp.path(), provider, all_tools, 2, 0, None).with_definitions(defs);
1367
1368        let result = agent
1369            .execute(json!({
1370                "mode": "single",
1371                "manifest": {
1372                    "inspector": { "definition": "inspector" }
1373                },
1374                "prompt": "inspect this"
1375            }))
1376            .await
1377            .unwrap();
1378
1379        assert!(result.contains("inspector"));
1380        assert!(result.contains("review done"));
1381    }
1382
1383    #[tokio::test]
1384    async fn background_worker_returns_task_id_and_runs() {
1385        // background=true in single mode returns a task_id immediately and the
1386        // worker runs in the background. The first turn's output should appear
1387        // in the task's output buffer.
1388        let provider = mock_provider(vec![Completion {
1389            content: "first turn done".to_string(),
1390            tool_calls: vec![],
1391            finish_reason: Some("stop".into()),
1392            usage: None,
1393            reasoning_content: None,
1394        }]);
1395        let tmp = tempfile::tempdir().unwrap();
1396        let all_tools = full_tool_registry(tmp.path());
1397        let task_registry = Arc::new(TaskRegistry::new());
1398        let worker_table: WorkerTable = Arc::new(Mutex::new(HashMap::new()));
1399        let agent = AgentTool::new(tmp.path(), provider, all_tools, 2, 0, None)
1400            .with_task_registry(task_registry.clone())
1401            .with_workers(worker_table);
1402
1403        let result = agent
1404            .execute(json!({
1405                "mode": "single",
1406                "background": true,
1407                "manifest": {
1408                    "w1": { "system_prompt": "You are a helper." }
1409                },
1410                "prompt": "do the first thing"
1411            }))
1412            .await
1413            .unwrap();
1414
1415        // Should return a task_id, not a finished worker transcript.
1416        assert!(result.contains("spawned as task"), "{result}");
1417        assert!(result.contains("send_message"), "{result}");
1418
1419        // The task should be registered. Give the background task a moment to
1420        // run its first turn, then drain output.
1421        tokio::time::sleep(std::time::Duration::from_millis(150)).await;
1422        let tasks = task_registry.list().await;
1423        assert_eq!(tasks.len(), 1, "exactly one task should be registered");
1424        let _ = task_registry.drain_output(&tasks[0].id).await;
1425        let output = tasks[0].output_snapshot().await;
1426        let joined = output.join("\n");
1427        assert!(joined.contains("first turn done"), "output was: {joined}");
1428    }
1429
1430    #[tokio::test]
1431    async fn send_message_continues_background_worker() {
1432        // A background worker can be continued via send_message(task_id=...):
1433        // the follow-up runs as a new turn on the same runtime, preserving
1434        // transcript. We verify both turns' outputs appear.
1435        let provider = mock_provider(vec![
1436            Completion {
1437                content: "turn one".to_string(),
1438                tool_calls: vec![],
1439                finish_reason: Some("stop".into()),
1440                usage: None,
1441                reasoning_content: None,
1442            },
1443            Completion {
1444                content: "turn two".to_string(),
1445                tool_calls: vec![],
1446                finish_reason: Some("stop".into()),
1447                usage: None,
1448                reasoning_content: None,
1449            },
1450        ]);
1451        let tmp = tempfile::tempdir().unwrap();
1452        let all_tools = full_tool_registry(tmp.path());
1453        let task_registry = Arc::new(TaskRegistry::new());
1454        let worker_registry = WorkerRegistry::new();
1455        let worker_table: WorkerTable = Arc::new(Mutex::new(HashMap::new()));
1456        let agent = AgentTool::new(tmp.path(), provider, all_tools, 2, 0, None)
1457            .with_task_registry(task_registry.clone())
1458            .with_registry(worker_registry.clone())
1459            .with_workers(worker_table.clone());
1460
1461        // Spawn the worker in the background.
1462        let spawn_result = agent
1463            .execute(json!({
1464                "mode": "single",
1465                "background": true,
1466                "manifest": {
1467                    "cw": { "system_prompt": "You are a helper." }
1468                },
1469                "prompt": "turn one please"
1470            }))
1471            .await
1472            .unwrap();
1473        let task_id = spawn_result
1474            .split("task '")
1475            .nth(1)
1476            .and_then(|s| s.split('\'').next())
1477            .expect("task id in spawn result")
1478            .to_string();
1479
1480        // Let turn one finish.
1481        tokio::time::sleep(std::time::Duration::from_millis(150)).await;
1482
1483        // Send a follow-up via send_message — this drives a new turn.
1484        let send_tool = SendMessageTool::new(
1485            worker_registry.clone(),
1486            task_registry.clone(),
1487            worker_table.clone(),
1488        );
1489        let send_result = send_tool
1490            .execute(json!({
1491                "task_id": task_id,
1492                "message": "now turn two"
1493            }))
1494            .await
1495            .unwrap();
1496        assert!(
1497            send_result.contains("new turn"),
1498            "send_message should report a new turn: {send_result}"
1499        );
1500
1501        // Let turn two finish, then drain the full output buffer.
1502        tokio::time::sleep(std::time::Duration::from_millis(150)).await;
1503        let task = task_registry
1504            .get(&TaskId(task_id.clone()))
1505            .await
1506            .expect("task still registered");
1507        let _ = task_registry.drain_output(&task.id).await;
1508        let joined = task.output_snapshot().await.join("\n");
1509        assert!(joined.contains("turn one"), "missing turn one: {joined}");
1510        assert!(joined.contains("turn two"), "missing turn two: {joined}");
1511    }
1512
1513    #[tokio::test]
1514    async fn background_worker_deregisters_from_worker_table_on_exit() {
1515        // goal-370: a background worker whose turn fails must remove its
1516        // WorkerHandle (and its mpsc sender) from the process-wide
1517        // WorkerTable when the task exits. Previously the mark_failed
1518        // early-return path left the entry in the table forever — a slow
1519        // memory + channel-buffer leak for long-running coordinator / HTTP /
1520        // TUI processes. (This test exercises the failure exit path; the
1521        // channel-close mark_completed path shares the same removal line.
1522        // A *panicked* task is not covered — its cleanup is out of scope.)
1523        let provider: Arc<dyn ChatProvider> =
1524            Arc::new(MockProvider::new(vec![]).with_errors(vec![Error::Llm {
1525                provider: "mock".into(),
1526                message: "injected failure for deregistration test".into(),
1527            }]));
1528        let tmp = tempfile::tempdir().unwrap();
1529        let all_tools = full_tool_registry(tmp.path());
1530        let task_registry = Arc::new(TaskRegistry::new());
1531        let worker_table: WorkerTable = Arc::new(Mutex::new(HashMap::new()));
1532        let agent = AgentTool::new(tmp.path(), provider, all_tools, 2, 0, None)
1533            .with_task_registry(task_registry.clone())
1534            .with_workers(worker_table.clone());
1535
1536        // Spawn the worker in the background.
1537        let spawn_result = agent
1538            .execute(json!({
1539                "mode": "single",
1540                "background": true,
1541                "manifest": {
1542                    "w1": { "system_prompt": "You are a helper." }
1543                },
1544                "prompt": "do the thing"
1545            }))
1546            .await
1547            .unwrap();
1548        let task_id = spawn_result
1549            .split("task '")
1550            .nth(1)
1551            .and_then(|s| s.split('\'').next())
1552            .expect("task id in spawn result")
1553            .to_string();
1554
1555        // While the worker is alive its entry must be present.
1556        assert!(
1557            worker_table
1558                .lock()
1559                .unwrap_or_else(|e| e.into_inner())
1560                .contains_key("w1"),
1561            "entry should exist while the worker is alive"
1562        );
1563
1564        // The injected provider error makes the first turn fail, so the
1565        // worker task takes the mark_failed early-return path and exits.
1566        // Wait (bounded) for the entry to disappear — the property under
1567        // test. On the old code this loop would time out because the entry
1568        // was never removed.
1569        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1570        loop {
1571            if !worker_table
1572                .lock()
1573                .unwrap_or_else(|e| e.into_inner())
1574                .contains_key("w1")
1575            {
1576                break;
1577            }
1578            if std::time::Instant::now() > deadline {
1579                panic!("worker table entry for 'w1' was not removed after the worker exited");
1580            }
1581            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1582        }
1583
1584        // Confirm the task actually failed (mark_failed path, not some other
1585        // exit), so the removal above is attributable to the fix.
1586        let task = task_registry
1587            .get(&TaskId(task_id))
1588            .await
1589            .expect("task still registered");
1590        let status = task.status().await;
1591        assert!(
1592            matches!(status, crate::tasks::TaskStatus::Failed),
1593            "worker task should have failed, was {status:?}"
1594        );
1595    }
1596
1597    #[tokio::test]
1598    async fn task_stop_deregisters_aborted_worker_from_worker_table() {
1599        // goal-379: `task_stop` aborts the worker's JoinHandle, which unwinds
1600        // the task at its next await point — skipping trailing cleanup. The
1601        // `WorkerDeregisterGuard` (a local of the spawned task) must remove
1602        // the WorkerHandle from the WorkerTable on that abort path too, so a
1603        // later `send_message` to the worker_id reports "not found" instead
1604        // of finding a stale handle (memory + channel-buffer leak, goal-370
1605        // class).
1606        let provider = mock_provider(vec![Completion {
1607            content: "first turn done".to_string(),
1608            tool_calls: vec![],
1609            finish_reason: Some("stop".into()),
1610            usage: None,
1611            reasoning_content: None,
1612        }]);
1613        let tmp = tempfile::tempdir().unwrap();
1614        let all_tools = full_tool_registry(tmp.path());
1615        let task_registry = Arc::new(TaskRegistry::new());
1616        let worker_registry = WorkerRegistry::new();
1617        let worker_table: WorkerTable = Arc::new(Mutex::new(HashMap::new()));
1618        let agent = AgentTool::new(tmp.path(), provider, all_tools, 2, 0, None)
1619            .with_task_registry(task_registry.clone())
1620            .with_registry(worker_registry.clone())
1621            .with_workers(worker_table.clone());
1622
1623        // Spawn the worker in the background.
1624        let spawn_result = agent
1625            .execute(json!({
1626                "mode": "single",
1627                "background": true,
1628                "manifest": {
1629                    "w1": { "system_prompt": "You are a helper." }
1630                },
1631                "prompt": "do the first thing"
1632            }))
1633            .await
1634            .unwrap();
1635        let task_id = spawn_result
1636            .split("task '")
1637            .nth(1)
1638            .and_then(|s| s.split('\'').next())
1639            .expect("task id in spawn result")
1640            .to_string();
1641
1642        // Let the first turn finish so the worker is alive and parked on
1643        // rx.recv(), then confirm its table entry is present.
1644        tokio::time::sleep(std::time::Duration::from_millis(150)).await;
1645        assert!(
1646            worker_table
1647                .lock()
1648                .unwrap_or_else(|e| e.into_inner())
1649                .contains_key("w1"),
1650            "entry should exist while the worker is alive"
1651        );
1652
1653        // Stop the task: `TaskRegistry::stop` → `TaskState::stop` aborts the
1654        // JoinHandle, unwinding the worker task (same path TaskStopTool uses,
1655        // without needing the coordinator-mode feature gate).
1656        let stopped = task_registry.stop(&TaskId(task_id.clone())).await;
1657        assert!(stopped, "task_stop must find a live handle to abort");
1658
1659        // The abort unwind must run the WorkerDeregisterGuard: wait (bounded)
1660        // for the entry to disappear. On the old code this loop would time
1661        // out because abort skipped the trailing remove statements.
1662        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1663        loop {
1664            if !worker_table
1665                .lock()
1666                .unwrap_or_else(|e| e.into_inner())
1667                .contains_key("w1")
1668            {
1669                break;
1670            }
1671            if std::time::Instant::now() > deadline {
1672                panic!("worker table entry for 'w1' was not removed after task_stop aborted the worker");
1673            }
1674            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1675        }
1676
1677        // The task itself stays registered (so task_get can report Stopped),
1678        // but its worker handle is gone.
1679        let task = task_registry
1680            .get(&TaskId(task_id.clone()))
1681            .await
1682            .expect("task still registered");
1683        assert_eq!(task.status().await, crate::tasks::TaskStatus::Stopped);
1684
1685        // A follow-up send_message by worker_id must report "not found"
1686        // rather than silently buffering into a dead continuation channel.
1687        let send_tool = SendMessageTool::new(
1688            worker_registry.clone(),
1689            task_registry.clone(),
1690            worker_table.clone(),
1691        );
1692        let send_result = send_tool
1693            .execute(json!({
1694                "worker_id": "w1",
1695                "message": "are you there?"
1696            }))
1697            .await
1698            .unwrap();
1699        assert!(
1700            send_result.contains("not found"),
1701            "send_message to a stopped worker must report not found, got: {send_result}"
1702        );
1703        assert!(
1704            !send_result.contains("new turn"),
1705            "send_message must not claim it will drive a new turn on a stopped worker: {send_result}"
1706        );
1707
1708        // The task_id addressing mode still resolves the (stopped) task, but
1709        // must NOT find a live continuation handle.
1710        let send_result = send_tool
1711            .execute(json!({
1712                "task_id": task_id,
1713                "message": "anyone home?"
1714            }))
1715            .await
1716            .unwrap();
1717        assert!(
1718            !send_result.contains("will run it as a new turn"),
1719            "stopped worker must not claim a new turn, got: {send_result}"
1720        );
1721    }
1722}