Skip to main content

mermaid_cli/providers/tool/
subagent.rs

1//! `agent` tool — spawn a child reducer loop as a tool.
2//!
3//! The design rests on one observation: from the model's perspective,
4//! delegating to a subagent is "call a tool with a prompt, get back
5//! a summary". There's no state-machine visibility the parent
6//! reducer needs — `TurnState::ExecutingTools` already parallelizes
7//! tool calls for free, so a single model turn emitting three
8//! `agent` calls gets three concurrent `SubagentTool::execute`
9//! invocations with zero additional infrastructure.
10//!
11//! Everything lives inside this module:
12//!
13//! - `SubagentSpawner` owns the shared `ProviderFactory` + a
14//!   `Semaphore(max_inflight)` that backpressures parallel fan-out.
15//!   Subagents can't themselves spawn subagents — `build_child_registry`
16//!   omits the `agent` tool — so there's no recursion to depth-cap.
17//! - An **agent type** shapes the child: a tool filter, a safety ceiling
18//!   (the child runs at the LESS permissive of the parent's live mode and
19//!   the ceiling), a system-prompt preamble, and an optional default
20//!   model. Built-ins: `general` (everything, at the parent's mode) and
21//!   `explore` (read-only reconnaissance). `[agents.types.*]` config
22//!   entries define more — a custom name shadows a built-in.
23//! - `SubagentTool::execute` builds a fresh child `State` (flagged
24//!   `is_subagent`, so its system prompt carries the report contract;
25//!   MCP entries seeded Ready from the process-global manager), a
26//!   filtered `ToolRegistry` (no self-recursion, no GUI tools), and
27//!   a child `EffectRunner` + msg channel. It drives the child
28//!   reducer to `Idle`, streaming progress back to the parent via
29//!   `ProgressEvent::Subagent*` (rendered live in the status line),
30//!   and returns the last assistant message as the tool's `output`,
31//!   with the child's token usage on the outcome metadata so the
32//!   parent's session totals count the whole tree.
33//! - **Continuations**: every result carries an `[agent_id: …]` trailer.
34//!   The finished child's full `State` is kept in a bounded spawner cache;
35//!   passing `agent_id` restores it and seeds the new prompt as its next
36//!   user message, so a follow-up question reuses the context the child
37//!   already built instead of re-exploring from scratch.
38
39use std::collections::{HashMap, VecDeque};
40use std::sync::Arc;
41use std::sync::Mutex;
42use std::sync::atomic::{AtomicU64, Ordering};
43use std::time::{Duration, Instant};
44
45use async_trait::async_trait;
46use serde_json::Value;
47use tokio::sync::{Semaphore, mpsc};
48use tokio_util::sync::CancellationToken;
49
50use crate::domain::{
51    Msg, State, TokenUsageTotals, ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata,
52    TurnState, update,
53};
54use crate::effect::{EffectRunner, MSG_CHANNEL_CAPACITY};
55use crate::models::MessageRole;
56use crate::providers::ProviderFactory;
57use crate::providers::ctx::{ExecContext, ProgressEvent, SubagentPhase};
58use crate::runtime::SafetyMode;
59
60use super::ToolExecutor;
61use super::ToolRegistry;
62use super::web::WebCapabilities;
63
64/// Maximum subagents running simultaneously across the whole process.
65/// Covers the pathological "parent emits 30 agent calls in one turn"
66/// case. Hit this cap → later calls block on the semaphore until
67/// some earlier subagent finishes or cancels.
68pub const MAX_INFLIGHT: usize = 10;
69
70/// Hard ceiling on a subagent's wall-clock runtime when the user hasn't set
71/// `[agents] timeout_secs` (or set it to 0). Above this the subagent is
72/// cancelled and reports `Error`.
73pub const DEFAULT_TIMEOUT_SECS: u64 = 20 * 60;
74
75/// How many finished children the spawner keeps for continuation
76/// (`agent_id` arg). Oldest evicted first.
77pub const MAX_CACHED_AGENTS: usize = 8;
78
79/// System-prompt block for the built-in `explore` type.
80const EXPLORE_PREAMBLE: &str = "\
81## Explore Agent
82You are an Explore agent: read-only reconnaissance. Locate files, map \
83structure, and extract exactly the facts asked for, using reads and \
84read-only commands. You cannot mutate anything — do not try. Report \
85concrete paths, names, and findings.";
86
87/// Tool names an agent type's `tools` filter may reference — the full child
88/// surface (`mcp` covers every `mcp__server__tool` via the proxy). GUI tools
89/// and `agent` itself are structurally absent from children and can't be
90/// granted here.
91const CHILD_TOOL_NAMES: &[&str] = &[
92    "read_file",
93    "write_file",
94    "apply_patch",
95    "delete_file",
96    "create_directory",
97    "execute_command",
98    "web_search",
99    "web_fetch",
100    "mcp",
101];
102
103/// A resolved agent type: what the `type` arg maps to after merging the
104/// built-ins with `[agents.types]` config entries (a custom name shadows a
105/// built-in, so users can retune `explore`).
106#[derive(Debug)]
107struct AgentType {
108    name: String,
109    /// Allowed tool names (`None` = the full child set).
110    tools: Option<Vec<String>>,
111    /// The child runs at the LESS permissive of the parent's live mode and
112    /// this ceiling — a type can tighten safety, never widen it.
113    safety_ceiling: SafetyMode,
114    /// Extra system-prompt block, appended after the subagent contract.
115    preamble: Option<String>,
116    /// Default model for this type; a per-call `model` arg wins.
117    model: Option<String>,
118}
119
120impl AgentType {
121    fn allows_tool(&self, name: &str) -> bool {
122        self.tools
123            .as_ref()
124            .is_none_or(|tools| tools.iter().any(|t| t == name))
125    }
126}
127
128fn builtin_agent_type(name: &str) -> Option<AgentType> {
129    match name {
130        "general" => Some(AgentType {
131            name: "general".to_string(),
132            tools: None,
133            safety_ceiling: SafetyMode::FullAccess,
134            preamble: None,
135            model: None,
136        }),
137        "explore" => Some(AgentType {
138            name: "explore".to_string(),
139            tools: Some(vec!["read_file".to_string(), "execute_command".to_string()]),
140            safety_ceiling: SafetyMode::ReadOnly,
141            preamble: Some(EXPLORE_PREAMBLE.to_string()),
142            model: None,
143        }),
144        _ => None,
145    }
146}
147
148/// Resolve a requested type name (default `general`) against config-defined
149/// types first, then built-ins. Errors are model-facing and actionable: they
150/// name the valid types / tools / safety modes.
151fn resolve_agent_type(
152    requested: Option<&str>,
153    config: &crate::app::Config,
154) -> Result<AgentType, String> {
155    let name = requested.unwrap_or("general");
156    if let Some(custom) = config.agents.types.get(name) {
157        let safety_ceiling = match custom.safety.as_deref() {
158            None => SafetyMode::FullAccess,
159            Some(s) => SafetyMode::parse(s).ok_or_else(|| {
160                format!(
161                    "[agents.types.{name}] safety '{s}' is not one of \
162                     read_only/ask/auto/full_access"
163                )
164            })?,
165        };
166        if let Some(tools) = &custom.tools
167            && let Some(bad) = tools
168                .iter()
169                .find(|t| !CHILD_TOOL_NAMES.contains(&t.as_str()))
170        {
171            return Err(format!(
172                "[agents.types.{name}] unknown tool '{bad}'; valid tools: {}",
173                CHILD_TOOL_NAMES.join(", ")
174            ));
175        }
176        return Ok(AgentType {
177            name: name.to_string(),
178            tools: custom.tools.clone(),
179            safety_ceiling,
180            preamble: custom.preamble.clone(),
181            model: custom.model.clone(),
182        });
183    }
184    builtin_agent_type(name).ok_or_else(|| {
185        let mut available: Vec<&str> = vec!["general", "explore"];
186        available.extend(config.agents.types.keys().map(String::as_str));
187        format!(
188            "unknown agent type '{name}'; available: {}",
189            available.join(", ")
190        )
191    })
192}
193
194/// A finished child kept for follow-ups: its full session state plus the
195/// type name it was built with (re-resolved on continuation so the registry,
196/// ceiling, and preamble are rebuilt the same way).
197struct CachedAgent {
198    state: State,
199    type_name: String,
200}
201
202#[derive(Default)]
203struct AgentCache {
204    entries: HashMap<String, CachedAgent>,
205    /// Insertion/refresh order for eviction (front = oldest).
206    order: VecDeque<String>,
207}
208
209/// Shared spawner. One per process; held by `SubagentTool`.
210pub struct SubagentSpawner {
211    providers: Arc<ProviderFactory>,
212    web_capabilities: Arc<WebCapabilities>,
213    inflight: Arc<Semaphore>,
214    /// Monotonic source for continuation handles ("a1", "a2", …).
215    next_agent_id: AtomicU64,
216    /// Finished children kept for continuation. An entry is REMOVED while
217    /// its agent runs a continuation (re-stored afterward), so concurrent
218    /// continuations of one id error instead of racing a single `State`.
219    cache: Mutex<AgentCache>,
220    /// Kill handles for detached (Ctrl+B backgrounded) children. Registered
221    /// by `detach_child` before its task spawns, removed by that task when
222    /// the drive ends — so an entry here always maps to a live child.
223    detached_cancels: Mutex<HashMap<String, CancellationToken>>,
224}
225
226/// What `kill_detached` found for an agent id.
227#[derive(Debug, PartialEq, Eq)]
228pub enum KillResult {
229    /// A running detached child — its cancel token was fired; expect a
230    /// `Msg::BackgroundAgentFinished { cancelled: true, .. }` shortly.
231    Killed,
232    /// No running child, but a finished one sat in the continuation cache —
233    /// it was evicted (the id can no longer be continued).
234    Evicted,
235    NotFound,
236}
237
238impl SubagentSpawner {
239    pub fn new(providers: Arc<ProviderFactory>, web_capabilities: Arc<WebCapabilities>) -> Self {
240        Self {
241            providers,
242            web_capabilities,
243            inflight: Arc::new(Semaphore::new(MAX_INFLIGHT)),
244            next_agent_id: AtomicU64::new(0),
245            cache: Mutex::new(AgentCache::default()),
246            detached_cancels: Mutex::new(HashMap::new()),
247        }
248    }
249
250    fn mint_agent_id(&self) -> String {
251        format!(
252            "a{}",
253            self.next_agent_id.fetch_add(1, Ordering::Relaxed) + 1
254        )
255    }
256
257    /// Remove and return a cached agent (see `cache` field docs).
258    fn cache_take(&self, id: &str) -> Option<CachedAgent> {
259        let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
260        cache.order.retain(|x| x != id);
261        cache.entries.remove(id)
262    }
263
264    /// Insert (or refresh) a cached agent, evicting the oldest past the cap.
265    fn cache_store(&self, id: String, agent: CachedAgent) {
266        let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
267        cache.order.retain(|x| x != &id);
268        cache.order.push_back(id.clone());
269        cache.entries.insert(id, agent);
270        while cache.entries.len() > MAX_CACHED_AGENTS {
271            let Some(oldest) = cache.order.pop_front() else {
272                break;
273            };
274            cache.entries.remove(&oldest);
275        }
276    }
277
278    fn register_detached(&self, agent_id: String, cancel: CancellationToken) {
279        self.detached_cancels
280            .lock()
281            .unwrap_or_else(|e| e.into_inner())
282            .insert(agent_id, cancel);
283    }
284
285    fn unregister_detached(&self, agent_id: &str) {
286        self.detached_cancels
287            .lock()
288            .unwrap_or_else(|e| e.into_inner())
289            .remove(agent_id);
290    }
291
292    /// Kill a detached child (fires its cancel token; the child unwinds
293    /// orderly and reports through `Msg::BackgroundAgentFinished`), or —
294    /// if the id only names a FINISHED child — evict it from the
295    /// continuation cache.
296    pub fn kill_detached(&self, agent_id: &str) -> KillResult {
297        let cancel = self
298            .detached_cancels
299            .lock()
300            .unwrap_or_else(|e| e.into_inner())
301            .remove(agent_id);
302        if let Some(cancel) = cancel {
303            cancel.cancel();
304            return KillResult::Killed;
305        }
306        if self.cache_take(agent_id).is_some() {
307            return KillResult::Evicted;
308        }
309        KillResult::NotFound
310    }
311
312    /// Kill every detached child. Returns how many tokens were fired.
313    pub fn kill_all_detached(&self) -> usize {
314        let cancels: Vec<CancellationToken> = {
315            let mut map = self
316                .detached_cancels
317                .lock()
318                .unwrap_or_else(|e| e.into_inner());
319            map.drain().map(|(_, c)| c).collect()
320        };
321        let n = cancels.len();
322        for cancel in cancels {
323            cancel.cancel();
324        }
325        n
326    }
327}
328
329/// The `agent` tool the model sees.
330pub struct SubagentTool {
331    spawner: Arc<SubagentSpawner>,
332}
333
334impl SubagentTool {
335    pub fn new(spawner: Arc<SubagentSpawner>) -> Self {
336        Self { spawner }
337    }
338}
339
340#[async_trait]
341impl ToolExecutor for SubagentTool {
342    fn name(&self) -> &'static str {
343        "agent"
344    }
345
346    fn schema(&self) -> ToolDefinition {
347        ToolDefinition {
348            name: "agent".to_string(),
349            description: format!(
350                "Spawn a child agent with its own context and tool access to work on an \
351                 independent sub-task. Useful for parallel fan-out (emit multiple `agent` \
352                 calls in the same turn to run them concurrently) or for scoping a noisy \
353                 sub-task (the child's tool output doesn't clutter the parent's turn). \
354                 Types: 'general' (default — full tool access at your safety mode) and \
355                 'explore' (read-only reconnaissance: locate files and extract facts, \
356                 cannot mutate), plus any defined in config [agents.types]. Every result \
357                 ends with an [agent_id: …] trailer; pass that id back as `agent_id` to \
358                 send a follow-up prompt to the same child with its context intact (the \
359                 {max_cached} most recent children are kept). Breadth-capped at \
360                 {max_breadth} concurrent; subagents can't themselves spawn subagents \
361                 and never get GUI (screenshot/click/…) access. A child moved to the \
362                 background (the user detaches one with Ctrl+B) can be cancelled with \
363                 action: \"kill\" plus its agent_id.",
364                max_cached = MAX_CACHED_AGENTS,
365                max_breadth = MAX_INFLIGHT,
366            ),
367            input_schema: serde_json::json!({
368                "type": "object",
369                "properties": {
370                    "action": {
371                        "type": "string",
372                        "enum": ["spawn", "kill"],
373                        "description": "Default 'spawn' (also covers continuing via agent_id). 'kill' cancels a backgrounded child by agent_id — no prompt needed."
374                    },
375                    "prompt": {
376                        "type": "string",
377                        "description": "The task for the subagent (required unless action is 'kill'). Self-contained; the subagent has no access to the parent's conversation. When continuing via agent_id, this is the next user message to that child."
378                    },
379                    "description": {
380                        "type": "string",
381                        "description": "Short label shown in the parent's status line (e.g. 'list domain files')."
382                    },
383                    "type": {
384                        "type": "string",
385                        "description": "Agent type: 'general' (default), 'explore' (read-only recon), or a config-defined type. Ignored when continuing via agent_id — the child keeps the type it was built with."
386                    },
387                    "model": {
388                        "type": "string",
389                        "description": "Model id override for this child (e.g. 'ollama/qwen3:8b') — use a cheaper/faster model for search-and-summarize subtasks. Defaults to the type's model, else the session model."
390                    },
391                    "agent_id": {
392                        "type": "string",
393                        "description": "Continue a previous child (its conversation context is restored and `prompt` becomes its next user message) or, with action 'kill', the backgrounded child to cancel. Use the id from a prior result's [agent_id: …] trailer or the background notice."
394                    }
395                },
396                "required": []
397            }),
398        }
399    }
400
401    async fn execute(&self, args: Value, ctx: ExecContext) -> ToolOutcome {
402        let started = Instant::now();
403
404        // Kill action: cancel a backgrounded child (or evict a finished one
405        // from the continuation cache). Short-circuits before the policy
406        // gate and the breadth permit — there is no model-authored prompt to
407        // vet and no new capacity consumed; it only reaches children this
408        // session already spawned. The dying child reports through
409        // `Msg::BackgroundAgentFinished { cancelled: true, .. }`.
410        if args.get("action").and_then(|v| v.as_str()) == Some("kill") {
411            let Some(id) = args
412                .get("agent_id")
413                .and_then(|v| v.as_str())
414                .map(str::trim)
415                .filter(|s| !s.is_empty())
416            else {
417                return ToolOutcome::error("action 'kill' requires `agent_id`", 0.0);
418            };
419            return match self.spawner.kill_detached(id) {
420                KillResult::Killed => ToolOutcome::success(
421                    format!(
422                        "Background agent '{id}' cancelled — it unwinds at its next \
423                         await point; a cancellation notice will appear in the \
424                         conversation."
425                    ),
426                    "subagent killed",
427                    started.elapsed().as_secs_f64(),
428                ),
429                KillResult::Evicted => ToolOutcome::success(
430                    format!(
431                        "Agent '{id}' had already finished; removed it from the \
432                         continuation cache instead."
433                    ),
434                    "subagent evicted",
435                    started.elapsed().as_secs_f64(),
436                ),
437                KillResult::NotFound => ToolOutcome::error(
438                    format!(
439                        "no background or cached agent '{id}' — it may have already \
440                         finished and been evicted, or the id was never issued"
441                    ),
442                    started.elapsed().as_secs_f64(),
443                ),
444            };
445        }
446
447        // Parse args.
448        let prompt = match args.get("prompt").and_then(|v| v.as_str()) {
449            Some(s) if !s.trim().is_empty() => s.to_string(),
450            _ => {
451                return ToolOutcome::error("agent requires non-empty `prompt`", 0.0);
452            },
453        };
454        let description = args
455            .get("description")
456            .and_then(|v| v.as_str())
457            .unwrap_or("subagent")
458            .to_string();
459        let requested_type = args
460            .get("type")
461            .and_then(|v| v.as_str())
462            .map(str::trim)
463            .filter(|s| !s.is_empty());
464        let model_override = args
465            .get("model")
466            .and_then(|v| v.as_str())
467            .map(str::trim)
468            .filter(|s| !s.is_empty());
469        let continue_id = args
470            .get("agent_id")
471            .and_then(|v| v.as_str())
472            .map(str::trim)
473            .filter(|s| !s.is_empty())
474            .map(str::to_string);
475
476        // Safety gate: Ask/Auto still vet the spawn (the prompt is
477        // model-authored), and Deny overrides plus the destructive-prompt
478        // hard-deny always win. ReadOnly deliberately ALLOWS the spawn: the
479        // child inherits the live safety mode below, so its own tool calls
480        // are re-gated at the same strength — a read_only child can fan out
481        // exploration but still can't mutate anything.
482        if let Some(blocked) = super::policy_gate::gate_external(
483            &ctx,
484            "agent",
485            crate::runtime::ToolCategory::Subagent,
486            format!("subagent: {}", description),
487            &args,
488        )
489        .await
490        {
491            return blocked;
492        }
493
494        // Acquire a breadth permit. Respects parent cancellation so
495        // a fan-out that lands 30 calls doesn't hold the parent's
496        // Ctrl+C response hostage.
497        let permit = tokio::select! {
498            biased;
499            _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
500            p = self.spawner.inflight.clone().acquire_owned() => match p {
501                Ok(permit) => permit,
502                Err(_) => return ToolOutcome::error(
503                    "subagent semaphore closed",
504                    started.elapsed().as_secs_f64(),
505                ),
506            },
507        };
508
509        // Build the child runtime. The child uses the same parent
510        // config + cwd, with a fresh (or cache-restored) `State` and a tool
511        // registry filtered by the agent type (never self-recursion or GUI).
512        //
513        // F7: `ExecContext` now carries the parent's `Config` +
514        // `model_id`. Previously we built `Config::default()` here and
515        // the child model id defaulted to `config.default_model.name`
516        // (usually empty), which made subagents fail at provider
517        // resolution.
518        let config = (*ctx.config).clone();
519        let cwd = ctx.workdir.clone();
520
521        // Continuation: pull the cached child out of the spawner cache (it
522        // stays out while it runs, so concurrent continuations of one id
523        // error instead of racing a single `State`). Fresh spawn: mint a
524        // new continuation handle.
525        let (agent_id, cached) = match continue_id {
526            Some(id) => match self.spawner.cache_take(&id) {
527                Some(cached) => (id, Some(cached)),
528                None => {
529                    return ToolOutcome::error(
530                        format!(
531                            "unknown agent_id '{id}': it may have expired (the \
532                             {MAX_CACHED_AGENTS} most recent children are kept), be running \
533                             a continuation right now, or never have existed. Omit agent_id \
534                             to start a new agent."
535                        ),
536                        started.elapsed().as_secs_f64(),
537                    );
538                },
539            },
540            None => (self.spawner.mint_agent_id(), None),
541        };
542
543        // Resolve the agent type. A continuation keeps the type it was built
544        // with (its registry/ceiling/preamble must match the context it
545        // accumulated); a fresh spawn resolves the request (default general).
546        let type_name = cached
547            .as_ref()
548            .map(|c| c.type_name.clone())
549            .or_else(|| requested_type.map(str::to_string));
550        let agent_type = match resolve_agent_type(type_name.as_deref(), &config) {
551            Ok(agent_type) => agent_type,
552            Err(e) => {
553                // Don't lose a cached child over a config error — put it back.
554                if let Some(cached) = cached {
555                    self.spawner.cache_store(agent_id, cached);
556                }
557                return ToolOutcome::error(e, started.elapsed().as_secs_f64());
558            },
559        };
560
561        // Inherit the parent's LIVE safety mode (Shift+Tab / `/safety` apply
562        // immediately) rather than the static config default `State::new`
563        // would pick up — otherwise a downgraded session could be escaped by
564        // delegating risky work to a subagent — then tighten it by the
565        // type's ceiling (`explore` pins read_only regardless of parent).
566        // The child runs headless (no approval broker), so in `ask` its
567        // mutations block/await rather than silently escalate;
568        // non-replayable tools fail closed (see #3).
569        let child_safety = SafetyMode::least_permissive(ctx.safety_mode, agent_type.safety_ceiling);
570
571        // Model priority: per-call arg > type default > parent's active model.
572        let model_id = model_override
573            .map(str::to_string)
574            .or_else(|| agent_type.model.clone())
575            .unwrap_or_else(|| {
576                if ctx.model_id.is_empty() {
577                    default_model_id(&config)
578                } else {
579                    ctx.model_id.clone()
580                }
581            });
582
583        let (mut child_state, usage_before) = match cached {
584            Some(cached) => {
585                // Continuations accumulate usage across drives in one State;
586                // snapshot so only THIS drive's delta rolls up to the parent.
587                let before = cached.state.session.cumulative_token_usage;
588                (cached.state, before)
589            },
590            None => (
591                State::new(
592                    config.clone(),
593                    cwd.clone(),
594                    model_id.clone(),
595                    chrono::Local::now(),
596                ),
597                TokenUsageTotals::default(),
598            ),
599        };
600        // A per-call model override retargets a continued child too; without
601        // one it keeps the model it was built with.
602        if let Some(model) = model_override {
603            child_state.session.model_id = model.to_string();
604        }
605        let child_model_id = child_state.session.model_id.clone();
606
607        // Refresh everything that may have moved since the child was built
608        // (or since the parent session started): the injected clock, the
609        // live safety mode, the type preamble, project instructions + the
610        // memory index, and the MCP surface. Instructions/memory load
611        // synchronously — dispatching RefreshInstructions/RefreshMemory as
612        // effects (as this used to) races the child's FIRST model call,
613        // which is emitted synchronously from the seed prompt.
614        child_state.now = chrono::Local::now();
615        child_state.session.safety_mode = child_safety;
616        // Mark the child as a subagent: its system prompt gains the report
617        // contract (final message = the report returned to the parent; never
618        // ask questions — nobody is watching to answer them).
619        child_state.session.is_subagent = true;
620        child_state.session.agent_preamble = agent_type.preamble.clone();
621        // The child shares the parent session's scratchpad: subagent work is
622        // part of the same session, and a child never receives its own
623        // `EnsureScratchpad`. Sitting in the refresh block means both fresh
624        // spawns AND continuations pick up the parent's current dir (which
625        // may have appeared, or moved after a `/clear`, since the child was
626        // first built).
627        child_state.session.scratchpad = ctx.scratchpad.clone();
628        let (instructions, memory, skills) =
629            crate::app::instructions::load_project_context(&cwd, &config.memory);
630        child_state.instructions = instructions;
631        child_state.memory = memory;
632        child_state.skills = skills;
633        // Advertise the parent's live MCP tools to the child (types that
634        // exclude `mcp` skip this — their registry has no proxy, so
635        // advertising would invite unknown-tool calls). The MCP manager is
636        // process-global (`crate::mcp::manager_ref`), so the child's
637        // `mcp_proxy` calls hit the SAME already-running servers — no
638        // per-child processes. Without this seeding, the child's server
639        // entries sit `Starting` forever (a child has no `InitMcpServers`
640        // path of its own) and `build_chat_request` advertises zero `mcp__`
641        // tools, making the registry's MCP proxy unreachable in practice.
642        if agent_type.allows_tool("mcp") {
643            seed_child_mcp(&mut child_state);
644        }
645
646        let child_tools = build_child_registry(
647            self.spawner.providers.clone(),
648            agent_type.tools.as_deref(),
649            &config,
650            child_safety,
651            &self.spawner.web_capabilities,
652        );
653
654        // Child cancel token OWNED here rather than derived from the turn's
655        // token: a Ctrl+B detach must sever the child from the turn scope
656        // (a derived token would die with the turn). Parent cancellation is
657        // forwarded explicitly in the select below.
658        let child_cancel = CancellationToken::new();
659        let (child_tx, child_rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
660        let child_runner =
661            EffectRunner::new_child(child_tx, cwd, self.spawner.providers.clone(), child_tools);
662
663        // Drive the child reducer loop to completion. The wall-clock
664        // timeout lives inside `drive_child` so the child runner is always
665        // shut down — even on timeout — rather than dropped mid-flight (#76).
666        let timeout_secs = match config.agents.timeout_secs {
667            0 => DEFAULT_TIMEOUT_SECS,
668            secs => secs,
669        };
670        // Child progress flows through a local channel so a Ctrl+B detach can
671        // re-route it (turn progress channel → turn-independent notify Msgs).
672        let (child_progress_tx, mut child_progress_rx) = mpsc::channel::<ProgressEvent>(16);
673        let mut drive = Box::pin(drive_child(
674            child_state,
675            child_runner,
676            child_rx,
677            child_progress_tx,
678            prompt,
679            child_cancel.clone(),
680            Duration::from_secs(timeout_secs),
681        ));
682
683        let mut progress_open = true;
684        let (result, final_state) = loop {
685            tokio::select! {
686                biased;
687                _ = ctx.token.cancelled() => {
688                    // Parent turn cancelled (Esc): stop the child, then await
689                    // its orderly shutdown — the returned state still carries
690                    // the usage this drive burned.
691                    child_cancel.cancel();
692                    break drive.await;
693                },
694                _ = ctx.background.cancelled() => {
695                    // Ctrl+B: detach. The child keeps running in its own task
696                    // (holding its breadth permit), the turn gets an immediate
697                    // outcome, and the report arrives later through
698                    // `Msg::BackgroundAgentFinished`.
699                    return self.detach_child(DetachArgs {
700                        drive,
701                        progress_rx: child_progress_rx,
702                        permit,
703                        cancel: child_cancel.clone(),
704                        notify: ctx.notify.clone(),
705                        agent_id,
706                        description,
707                        type_name: agent_type.name.clone(),
708                        child_model_id,
709                        usage_before,
710                        timeout_secs,
711                        started,
712                    });
713                },
714                ev = child_progress_rx.recv(), if progress_open => match ev {
715                    Some(ev) => { let _ = ctx.progress.send(ev).await; },
716                    None => progress_open = false,
717                },
718                r = &mut drive => break r,
719            }
720        };
721        drop(permit);
722
723        finish_drive(
724            &self.spawner,
725            agent_type.name.clone(),
726            agent_id,
727            &description,
728            child_model_id,
729            usage_before,
730            timeout_secs,
731            started,
732            result,
733            final_state,
734        )
735    }
736}
737
738/// Everything a Ctrl+B detach hands off to the background task. Bundled so
739/// the handoff reads as one unit instead of a 11-argument call.
740struct DetachArgs<F> {
741    drive: std::pin::Pin<Box<F>>,
742    progress_rx: mpsc::Receiver<ProgressEvent>,
743    permit: tokio::sync::OwnedSemaphorePermit,
744    /// The child's own cancel token — registered on the spawner so
745    /// `/agents kill` and the `agent` tool's kill action can reach it.
746    cancel: CancellationToken,
747    notify: Option<mpsc::Sender<Msg>>,
748    agent_id: String,
749    description: String,
750    type_name: String,
751    child_model_id: String,
752    usage_before: TokenUsageTotals,
753    timeout_secs: u64,
754    started: Instant,
755}
756
757impl SubagentTool {
758    /// Detach a running child from its turn: keep driving it in a spawned
759    /// task, translate its progress into `Msg::BackgroundAgent*` (the turn's
760    /// progress relay dies with the turn), and deliver the finished report
761    /// through the queued-message path. Returns the immediate outcome the
762    /// releasing turn reports to the model.
763    fn detach_child<F>(&self, args: DetachArgs<F>) -> ToolOutcome
764    where
765        F: std::future::Future<Output = (Result<String, DriveError>, State)> + Send + 'static,
766    {
767        let DetachArgs {
768            mut drive,
769            mut progress_rx,
770            permit,
771            cancel,
772            notify,
773            agent_id,
774            description,
775            type_name,
776            child_model_id,
777            usage_before,
778            timeout_secs,
779            started,
780        } = args;
781        if let Some(notify) = &notify {
782            let _ = notify.try_send(Msg::BackgroundAgentStarted {
783                agent_id: agent_id.clone(),
784                description: description.clone(),
785            });
786        }
787        let spawner = self.spawner.clone();
788        // Register the kill handle before the task spawns so a kill can
789        // never race a not-yet-registered child.
790        spawner.register_detached(agent_id.clone(), cancel);
791        let outcome_text = format!(
792            "Agent '{description}' ({agent_id}) moved to background — it keeps running and \
793             its report will be posted to the conversation when it finishes."
794        );
795        let (bg_agent_id, bg_description) = (agent_id, description);
796        tokio::spawn(async move {
797            // Hold the breadth permit for the child's whole life — detached
798            // agents still consume real provider capacity.
799            let _permit = permit;
800            let mut activity = String::new();
801            let mut tokens = 0usize;
802            let mut progress_open = true;
803            let (result, final_state) = loop {
804                tokio::select! {
805                    ev = progress_rx.recv(), if progress_open => match ev {
806                        Some(ev) => {
807                            match &ev {
808                                ProgressEvent::SubagentToolCall { tool_name, phase, .. } => {
809                                    activity = match phase {
810                                        SubagentPhase::Started => format!("{tool_name}…"),
811                                        SubagentPhase::Finished => format!("{tool_name} done"),
812                                        SubagentPhase::Errored => format!("{tool_name} failed"),
813                                    };
814                                },
815                                ProgressEvent::SubagentActivity(label) => activity = label.clone(),
816                                ProgressEvent::SubagentTokens(count) => tokens = *count,
817                                _ => continue,
818                            }
819                            if let Some(notify) = &notify {
820                                let _ = notify.try_send(Msg::BackgroundAgentProgress {
821                                    agent_id: bg_agent_id.clone(),
822                                    activity: activity.clone(),
823                                    tokens,
824                                });
825                            }
826                        },
827                        None => progress_open = false,
828                    },
829                    r = &mut drive => break r,
830                }
831            };
832            spawner.unregister_detached(&bg_agent_id);
833            let cancelled = matches!(result, Err(DriveError::Cancelled));
834            let outcome = finish_drive(
835                &spawner,
836                type_name,
837                bg_agent_id.clone(),
838                &bg_description,
839                child_model_id,
840                usage_before,
841                timeout_secs,
842                started,
843                result,
844                final_state,
845            );
846            if let Some(notify) = notify {
847                let usage = outcome.metadata.token_usage.clone();
848                let tokens_total = usage.as_ref().map_or(tokens, |u| u.total_tokens());
849                let _ = notify
850                    .send(Msg::BackgroundAgentFinished {
851                        agent_id: bg_agent_id,
852                        description: bg_description,
853                        report: outcome.model_content.clone(),
854                        success: outcome.is_success(),
855                        cancelled,
856                        usage,
857                        tokens: tokens_total,
858                        duration_secs: started.elapsed().as_secs(),
859                    })
860                    .await;
861            }
862        });
863        ToolOutcome::success(
864            outcome_text,
865            "subagent backgrounded",
866            started.elapsed().as_secs_f64(),
867        )
868    }
869}
870
871/// Shared post-drive processing for foreground and detached children: cache
872/// the child for continuations (unless cancelled), roll up this drive's
873/// usage, and shape the model-facing outcome.
874#[allow(clippy::too_many_arguments)]
875fn finish_drive(
876    spawner: &SubagentSpawner,
877    type_name: String,
878    agent_id: String,
879    description: &str,
880    child_model_id: String,
881    usage_before: TokenUsageTotals,
882    timeout_secs: u64,
883    started: Instant,
884    result: Result<String, DriveError>,
885    mut final_state: State,
886) -> ToolOutcome {
887    let child_usage = usage_delta(final_state.session.cumulative_token_usage, usage_before);
888
889    // Keep the child for follow-ups unless the parent cancelled (that
890    // turn is being torn down). Timeout/error children are kept too —
891    // "continue a3: what did you find so far?" is exactly the follow-up
892    // a timeout invites. Normalize the turn first: the child's runner is
893    // gone, so a mid-turn state could never complete — a continuation
894    // must be able to seed a fresh prompt into an Idle child.
895    if !matches!(result, Err(DriveError::Cancelled)) {
896        final_state.turn = TurnState::Idle;
897        final_state.ui.queued_messages.clear();
898        final_state.ui.live_tool_status.clear();
899        final_state.pending_approval.clear();
900        spawner.cache_store(
901            agent_id.clone(),
902            CachedAgent {
903                state: final_state,
904                type_name,
905            },
906        );
907    }
908
909    let elapsed = started.elapsed().as_secs_f64();
910    let trailer = format!("[agent_id: {agent_id} — pass agent_id to continue this child]");
911    let metadata = subagent_metadata(child_model_id, child_usage, agent_id);
912    match result {
913        Ok(summary) => ToolOutcome::success(
914            format!("{summary}\n\n{trailer}"),
915            "subagent completed",
916            elapsed,
917        )
918        .with_metadata(metadata),
919        Err(DriveError::Cancelled) => ToolOutcome::cancelled(),
920        Err(DriveError::TimedOut) => ToolOutcome::error(
921            format!(
922                "subagent ({description}) exceeded {timeout_secs}s timeout; its context \
923                 is preserved — {trailer}"
924            ),
925            elapsed,
926        )
927        .with_metadata(metadata),
928        Err(DriveError::Errored(e)) => {
929            ToolOutcome::error(format!("subagent ({description}): {e} {trailer}"), elapsed)
930                .with_metadata(metadata)
931        },
932    }
933}
934
935/// Metadata for the parent: which model ran the child, the continuation
936/// handle, and what THIS drive cost. The usage rides
937/// `ToolRunMetadata.token_usage`, which `handle_tool_finished` folds into the
938/// parent session's totals — without it the footer and the run summary
939/// silently exclude subagent spend. Timeout and error outcomes carry it too
940/// (that work was still billed); `None` when the provider reported nothing,
941/// so the UI doesn't render a bogus "0 tokens".
942fn subagent_metadata(
943    model_id: String,
944    usage: TokenUsageTotals,
945    agent_id: String,
946) -> ToolRunMetadata {
947    let token_usage = (usage.total_tokens() > 0).then(|| crate::models::TokenUsage {
948        prompt_tokens: usage.prompt_tokens,
949        completion_tokens: usage.completion_tokens,
950        cached_input_tokens: usage.cached_input_tokens,
951        cache_creation_input_tokens: usage.cache_creation_input_tokens,
952        reasoning_output_tokens: usage.reasoning_output_tokens,
953        source: Default::default(),
954    });
955    ToolRunMetadata {
956        detail: ToolMetadata::Subagent { model_id, agent_id },
957        token_usage,
958        ..ToolRunMetadata::default()
959    }
960}
961
962/// The child-session usage attributable to ONE drive: cumulative totals
963/// minus the pre-drive snapshot. Continuations accumulate usage across
964/// drives in a single `State`; reporting the delta keeps the parent from
965/// double-counting spend it already rolled up.
966fn usage_delta(after: TokenUsageTotals, before: TokenUsageTotals) -> TokenUsageTotals {
967    TokenUsageTotals {
968        prompt_tokens: after.prompt_tokens.saturating_sub(before.prompt_tokens),
969        completion_tokens: after
970            .completion_tokens
971            .saturating_sub(before.completion_tokens),
972        cached_input_tokens: after
973            .cached_input_tokens
974            .saturating_sub(before.cached_input_tokens),
975        cache_creation_input_tokens: after
976            .cache_creation_input_tokens
977            .saturating_sub(before.cache_creation_input_tokens),
978        reasoning_output_tokens: after
979            .reasoning_output_tokens
980            .saturating_sub(before.reasoning_output_tokens),
981    }
982}
983
984enum DriveError {
985    Cancelled,
986    TimedOut,
987    Errored(String),
988}
989
990/// Drive the child's reducer loop to `Idle`, bounded by `timeout`. Forwards
991/// stable child activity (tool calls, coarse phase changes, throttled token
992/// counts — never raw stream text) to the parent's progress channel as
993/// `ProgressEvent::Subagent*`. Returns the child's final report alongside its
994/// full `State` — returned on EVERY exit path so the caller can roll up the
995/// usage (real spend regardless of how the child ended) and cache the context
996/// for continuations.
997async fn drive_child(
998    mut state: State,
999    mut runner: EffectRunner,
1000    mut msg_rx: mpsc::Receiver<Msg>,
1001    parent_progress: mpsc::Sender<ProgressEvent>,
1002    prompt: String,
1003    token: CancellationToken,
1004    timeout: Duration,
1005) -> (Result<String, DriveError>, State) {
1006    // Signal start to parent. One stable label — the prompt itself never
1007    // belongs on the parent's status line.
1008    let _ = parent_progress
1009        .send(ProgressEvent::SubagentActivity("starting…".to_string()))
1010        .await;
1011
1012    // Project instructions + memory are loaded synchronously into `state` before
1013    // `drive_child` is called (see `execute`), so the child's first model call
1014    // sees them — no RefreshInstructions/RefreshMemory dispatch here, which would
1015    // race that first call.
1016
1017    // Seed the child turn.
1018    let seed = Msg::SubmitPrompt {
1019        text: prompt,
1020        attachment_ids: vec![],
1021    };
1022    let (new_state, cmds) = update(state, seed);
1023    state = new_state;
1024    for cmd in cmds {
1025        runner.dispatch(cmd);
1026    }
1027
1028    // Drive the child reducer to Idle, bounded by a wall-clock deadline.
1029    // The deadline is a `select!` arm (not a `timeout()` wrapper) so the
1030    // single `runner.shutdown()` below always runs — on normal exit,
1031    // cancel, OR timeout — instead of the runner being dropped mid-flight
1032    // and leaking its MCP children (#76).
1033    let deadline = tokio::time::sleep(timeout);
1034    tokio::pin!(deadline);
1035
1036    let mut outcome: Result<(), DriveError> = Ok(());
1037    let mut child_progress = ChildProgress::new(tokio::time::Instant::now());
1038    loop {
1039        if token.is_cancelled() {
1040            outcome = Err(DriveError::Cancelled);
1041            break;
1042        }
1043        if matches!(state.turn, TurnState::Idle) && state.ui.queued_messages.is_empty() {
1044            break;
1045        }
1046
1047        let msg = tokio::select! {
1048            biased;
1049            _ = token.cancelled() => {
1050                outcome = Err(DriveError::Cancelled);
1051                break;
1052            },
1053            _ = &mut deadline => {
1054                outcome = Err(DriveError::TimedOut);
1055                break;
1056            },
1057            recv = msg_rx.recv() => match recv {
1058                Some(m) => m,
1059                None => break, // channel closed — child runner shut down
1060            },
1061        };
1062
1063        // Forward child activity to parent progress BEFORE the
1064        // reducer mutates state (we want `call_id` + `tool_name`
1065        // semantic info, which reducer events strip).
1066        for event in child_progress.observe(&msg, &state, tokio::time::Instant::now()) {
1067            let _ = parent_progress.send(event).await;
1068        }
1069
1070        let (new_state, cmds) = update(state, msg);
1071        state = new_state;
1072        for cmd in cmds {
1073            runner.dispatch(cmd);
1074        }
1075        if state.should_exit {
1076            break;
1077        }
1078    }
1079
1080    // Always reap the child runner regardless of how the loop exited. This
1081    // cancels the child's scopes and drains its tasks; it does NOT touch the
1082    // process-global MCP manager (`new_child` opts out of that reap — the
1083    // servers are shared with the parent).
1084    runner.shutdown().await;
1085
1086    if let Err(e) = outcome {
1087        return (Err(e), state);
1088    }
1089
1090    // Extract last assistant message as the result.
1091    let summary = state
1092        .session
1093        .messages()
1094        .iter()
1095        .rev()
1096        .find(|m| m.role == MessageRole::Assistant)
1097        .map(|m| m.content.clone())
1098        .unwrap_or_default();
1099    if summary.trim().is_empty() {
1100        return (
1101            Err(DriveError::Errored(
1102                "subagent produced no assistant output".to_string(),
1103            )),
1104            state,
1105        );
1106    }
1107    (Ok(summary), state)
1108}
1109
1110/// Minimum spacing between `SubagentTokens` progress events. Anything faster
1111/// is invisible churn: the parent redraws at most a few times a second and
1112/// per-chunk updates were the source of the status-line flicker.
1113const TOKEN_PROGRESS_INTERVAL: Duration = Duration::from_millis(500);
1114
1115/// Translates child-scope `Msg` events into the parent-facing
1116/// `ProgressEvent::Subagent*` vocabulary — a pure state machine so the
1117/// calm-down rules are unit-testable:
1118///
1119/// - tool starts/finishes forward as `SubagentToolCall` (stable labels);
1120/// - stream chunks NEVER forward text — they only flip a coarse phase
1121///   ("thinking"/"replying"), emitted once per phase CHANGE;
1122/// - output tokens accumulate as a chars/4 estimate, snapped to the
1123///   provider-reported count on `StreamDone`, and emit as `SubagentTokens`
1124///   at most every `TOKEN_PROGRESS_INTERVAL` (piggybacking on other events
1125///   so a busy child still reads fresh).
1126struct ChildProgress {
1127    phase: &'static str,
1128    /// Provider-confirmed output tokens from the child's completed model calls.
1129    confirmed_tokens: usize,
1130    /// Character count of the in-flight stream (reset when `StreamDone` snaps
1131    /// to the provider-reported figure).
1132    streamed_chars: usize,
1133    last_tokens_sent: usize,
1134    last_tokens_at: tokio::time::Instant,
1135}
1136
1137impl ChildProgress {
1138    fn new(now: tokio::time::Instant) -> Self {
1139        Self {
1140            phase: "",
1141            confirmed_tokens: 0,
1142            streamed_chars: 0,
1143            last_tokens_sent: 0,
1144            last_tokens_at: now,
1145        }
1146    }
1147
1148    fn total_tokens(&self) -> usize {
1149        self.confirmed_tokens + self.streamed_chars / 4
1150    }
1151
1152    /// Observe one child `Msg`; returns the progress events the parent
1153    /// should see (usually none).
1154    fn observe(
1155        &mut self,
1156        msg: &Msg,
1157        state: &State,
1158        now: tokio::time::Instant,
1159    ) -> Vec<ProgressEvent> {
1160        let mut out = Vec::new();
1161        match msg {
1162            Msg::ToolStarted {
1163                turn: _, call_id, ..
1164            } => {
1165                let tool_name =
1166                    lookup_tool_name(state, *call_id).unwrap_or_else(|| "tool".to_string());
1167                out.push(ProgressEvent::SubagentToolCall {
1168                    child_call_id: *call_id,
1169                    tool_name,
1170                    phase: SubagentPhase::Started,
1171                });
1172                // Next stream chunk re-announces its phase after the tool.
1173                self.phase = "";
1174            },
1175            Msg::ToolFinished {
1176                turn: _,
1177                call_id,
1178                outcome,
1179            } => {
1180                let tool_name =
1181                    lookup_tool_name(state, *call_id).unwrap_or_else(|| "tool".to_string());
1182                let phase = if outcome.is_success() {
1183                    SubagentPhase::Finished
1184                } else {
1185                    SubagentPhase::Errored
1186                };
1187                out.push(ProgressEvent::SubagentToolCall {
1188                    child_call_id: *call_id,
1189                    tool_name,
1190                    phase,
1191                });
1192                self.phase = "";
1193            },
1194            Msg::StreamReasoning { chunk, .. } => {
1195                self.streamed_chars += chunk.text.len();
1196                self.set_phase("thinking", &mut out);
1197            },
1198            Msg::StreamText { chunk, .. } => {
1199                self.streamed_chars += chunk.len();
1200                self.set_phase("replying", &mut out);
1201            },
1202            Msg::StreamDone {
1203                usage: Some(usage), ..
1204            } => {
1205                self.confirmed_tokens += usage
1206                    .completion_tokens
1207                    .saturating_add(usage.reasoning_output_tokens);
1208                self.streamed_chars = 0;
1209            },
1210            _ => {},
1211        }
1212        // Token count rides along whenever something else is being said, and
1213        // otherwise at most every TOKEN_PROGRESS_INTERVAL.
1214        let total = self.total_tokens();
1215        let due = now.duration_since(self.last_tokens_at) >= TOKEN_PROGRESS_INTERVAL;
1216        if total != self.last_tokens_sent && (due || !out.is_empty()) {
1217            out.push(ProgressEvent::SubagentTokens(total));
1218            self.last_tokens_sent = total;
1219            self.last_tokens_at = now;
1220        }
1221        out
1222    }
1223
1224    fn set_phase(&mut self, phase: &'static str, out: &mut Vec<ProgressEvent>) {
1225        if self.phase != phase {
1226            self.phase = phase;
1227            out.push(ProgressEvent::SubagentActivity(phase.to_string()));
1228        }
1229    }
1230}
1231
1232/// Look up a tool name from a `PendingToolCall` in the state.
1233/// Returns `None` if the call id isn't known (e.g. during teardown).
1234fn lookup_tool_name(state: &State, call_id: crate::domain::ToolCallId) -> Option<String> {
1235    match &state.turn {
1236        TurnState::ExecutingTools { calls, .. } => calls
1237            .iter()
1238            .find(|c| c.call_id == call_id)
1239            .map(|c| c.source.function.name.clone()),
1240        _ => None,
1241    }
1242}
1243
1244/// Mark the child's configured MCP servers `Ready` (with their live tool
1245/// lists) from the process-global manager, so the child's outgoing requests
1246/// advertise `mcp__` tools. `State::new` seeds every configured server as
1247/// `Starting`, and only the app entrypoints ever dispatch `InitMcpServers` —
1248/// a child has no init path, so without this its MCP surface is empty even
1249/// though its registry carries the proxy. No-op when no manager is installed
1250/// (MCP unconfigured, or startup init still racing — same window in which the
1251/// parent's own first turn sees no MCP tools either).
1252fn seed_child_mcp(state: &mut State) {
1253    let Some(manager) = crate::mcp::manager_ref::get() else {
1254        return;
1255    };
1256    apply_live_mcp(&mut state.mcp.servers, &manager.all_specs(), |name| {
1257        manager.has_server(name)
1258    });
1259}
1260
1261/// Pure core of [`seed_child_mcp`], injectable for tests: flip every entry
1262/// the live manager actually runs to `Ready` and attach its advertised
1263/// (already-sanitized) specs. Entries for servers the manager doesn't have
1264/// (failed to start) keep their `Starting` status and stay un-advertised —
1265/// same as in the parent.
1266fn apply_live_mcp(
1267    servers: &mut std::collections::HashMap<String, crate::domain::McpServerEntry>,
1268    live_specs: &[(String, crate::domain::McpToolSpec)],
1269    has_server: impl Fn(&str) -> bool,
1270) {
1271    for (name, entry) in servers.iter_mut() {
1272        if !has_server(name) {
1273            continue;
1274        }
1275        entry.status = crate::domain::McpServerStatus::Ready;
1276        let cfg = &entry.config;
1277        let tools: Vec<crate::domain::McpToolSpec> = live_specs
1278            .iter()
1279            .filter(|(server, _)| server == name)
1280            // Honor the per-server enabled_tools/disabled_tools filter,
1281            // matched against the server's own (raw) tool names.
1282            .filter(|(_, spec)| cfg.tool_allowed(&spec.raw_name))
1283            .map(|(_, spec)| spec.clone())
1284            .collect();
1285        entry.tools = tools;
1286    }
1287}
1288
1289/// Construct the child `ToolRegistry` — a subset of what the parent
1290/// offers, optionally narrowed further by an agent type's `tools` filter
1291/// (`None` = the full child set; see `CHILD_TOOL_NAMES`). Always excludes:
1292///
1293///   - `agent` itself — subagents don't spawn subagents. This
1294///     exclusion is the guard (there is no depth counter).
1295///   - All seven GUI / computer-use tools — the parent's
1296///     `ComputerUseDriver` owns the screenshot coord registry; a
1297///     subagent clicking would corrupt the parent's latest-capture
1298///     pointer.
1299///
1300/// The MCP proxy routes through the process-global `McpServerManager` —
1301/// the child calls the SAME running servers as the parent (advertised via
1302/// `seed_child_mcp`, which marks them Ready in the child's state). Every
1303/// registered tool is gated at the child's effective safety mode.
1304fn build_child_registry(
1305    providers: Arc<ProviderFactory>,
1306    tools: Option<&[String]>,
1307    config: &crate::app::Config,
1308    safety_mode: SafetyMode,
1309    web: &WebCapabilities,
1310) -> Arc<ToolRegistry> {
1311    use super::{apply_patch, computer_use, exec, filesystem, mcp};
1312    let allowed = |name: &str| tools.is_none_or(|t| t.iter().any(|x| x == name));
1313    let mut r = ToolRegistry::new();
1314    if allowed("read_file") {
1315        r.register(Arc::new(filesystem::ReadFileTool));
1316    }
1317    if allowed("write_file") {
1318        r.register(Arc::new(filesystem::WriteFileTool));
1319    }
1320    if allowed("apply_patch") {
1321        r.register(Arc::new(apply_patch::ApplyPatchTool));
1322    }
1323    if allowed("delete_file") {
1324        r.register(Arc::new(filesystem::DeleteFileTool));
1325    }
1326    if allowed("create_directory") {
1327        r.register(Arc::new(filesystem::CreateDirectoryTool));
1328    }
1329    if allowed("execute_command") {
1330        r.register(Arc::new(exec::ExecuteCommandTool));
1331    }
1332    if allowed("mcp") {
1333        r.register(Arc::new(mcp::McpToolProxy));
1334    }
1335    // A child runner has no approval UI. Do not advertise a web tool whose
1336    // effective policy can only return Ask: the model would see a capability
1337    // that deterministically fails before transport. Auto/FullAccess, an
1338    // explicit policy Allow, and the two headless/read-only opt-ins remain
1339    // executable and therefore visible.
1340    let search_allowed =
1341        allowed("web_search") && headless_web_tool_is_executable(config, safety_mode, "web_search");
1342    let fetch_allowed =
1343        allowed("web_fetch") && headless_web_tool_is_executable(config, safety_mode, "web_fetch");
1344    if search_allowed || fetch_allowed {
1345        if search_allowed && let Some(tool) = web.search_tool() {
1346            r.register(Arc::new(tool));
1347        }
1348        if fetch_allowed && let Some(tool) = web.fetch_tool() {
1349            r.register(Arc::new(tool));
1350        }
1351    }
1352    // NO computer_use::*  — GUI tools are parent-only.
1353    // NO subagent::SubagentTool — subagents can't spawn subagents; this
1354    // exclusion IS the guard (there is no depth counter).
1355    // Silence unused-import if the above imports don't all resolve.
1356    let _ = computer_use::probe;
1357    let _ = providers;
1358    Arc::new(r)
1359}
1360
1361/// Whether a headless child can get past policy for this web tool without an
1362/// inline approval broker. Mirrors the policy gate's explicit headless and
1363/// ReadOnly opt-ins while also honoring user safety overrides.
1364fn headless_web_tool_is_executable(
1365    config: &crate::app::Config,
1366    safety_mode: SafetyMode,
1367    tool: &'static str,
1368) -> bool {
1369    use crate::runtime::{ActionRequest, PolicyDecision, PolicyEngine, ToolCategory};
1370
1371    if config.safety.network == crate::app::NetworkPolicy::Deny {
1372        return false;
1373    }
1374    let request = ActionRequest::new(tool, ToolCategory::Web, tool);
1375    let decision = PolicyEngine::new(safety_mode)
1376        .with_overrides(config.safety.overrides.clone())
1377        .with_external_writes(config.safety.external_writes)
1378        .with_system_installs(config.safety.system_installs)
1379        .decide(&request);
1380    match decision {
1381        PolicyDecision::Allow { .. } => true,
1382        // Child runners receive a classifier only in Auto mode.
1383        PolicyDecision::Classify { .. } => safety_mode == SafetyMode::Auto,
1384        PolicyDecision::Ask { .. } => {
1385            config.safety.allow_untrusted_headless_tools
1386                || (safety_mode == SafetyMode::ReadOnly && config.safety.allow_readonly_web)
1387        },
1388        PolicyDecision::Deny { .. } => false,
1389    }
1390}
1391
1392/// Fallback child model id when `ExecContext::model_id` is empty
1393/// (e.g. a test harness that uses the default `test_exec_context`
1394/// builder). Production code always provides the parent's active model
1395/// id via `Cmd::ExecuteTool::model_id`.
1396fn default_model_id(config: &crate::app::Config) -> String {
1397    if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
1398        format!(
1399            "{}/{}",
1400            config.default_model.provider, config.default_model.name
1401        )
1402    } else {
1403        config.default_model.name.clone()
1404    }
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409    use super::*;
1410    use crate::domain::{ToolCallId, TurnId};
1411    use crate::providers::ctx::test_exec_context;
1412    use std::path::PathBuf;
1413
1414    fn test_state() -> State {
1415        State::new(
1416            crate::app::Config::default(),
1417            PathBuf::from("/tmp"),
1418            "ollama/test".to_string(),
1419            chrono::Local::now(),
1420        )
1421    }
1422
1423    fn test_spawner() -> SubagentSpawner {
1424        let config = crate::app::Config::default();
1425        let providers = Arc::new(ProviderFactory::new(config.clone()));
1426        let web_capabilities = Arc::new(WebCapabilities::resolve(&config.web));
1427        SubagentSpawner::new(providers, web_capabilities)
1428    }
1429
1430    fn test_spawner_arc() -> Arc<SubagentSpawner> {
1431        Arc::new(test_spawner())
1432    }
1433
1434    fn stream_text(chunk: &str) -> Msg {
1435        Msg::StreamText {
1436            turn: TurnId(1),
1437            chunk: chunk.to_string(),
1438        }
1439    }
1440
1441    #[tokio::test]
1442    async fn child_stream_chunks_never_forward_text_only_one_phase_change() {
1443        // The status-line flicker regression: every child StreamText chunk
1444        // used to become a parent progress event. Now the FIRST chunk flips
1445        // the phase ("replying") and subsequent chunks are silent until the
1446        // token throttle elapses.
1447        let state = test_state();
1448        let now = tokio::time::Instant::now();
1449        let mut progress = ChildProgress::new(now);
1450
1451        let first = progress.observe(&stream_text("chunk one — some text"), &state, now);
1452        assert!(
1453            first.iter().any(
1454                |e| matches!(e, ProgressEvent::SubagentActivity(label) if label == "replying")
1455            ),
1456            "first chunk announces the phase: {first:?}"
1457        );
1458        assert!(
1459            !first
1460                .iter()
1461                .any(|e| matches!(e, ProgressEvent::SubagentToolCall { .. })),
1462            "no raw text ever forwards: {first:?}"
1463        );
1464
1465        // A burst of further chunks inside the throttle window emits NOTHING.
1466        for i in 0..50 {
1467            let events = progress.observe(&stream_text(&format!("chunk {i}")), &state, now);
1468            assert!(
1469                events.is_empty(),
1470                "chunk {i} must be silent inside the throttle window: {events:?}"
1471            );
1472        }
1473    }
1474
1475    #[tokio::test]
1476    async fn token_estimates_respect_the_throttle_and_snap_to_provider_usage() {
1477        let state = test_state();
1478        let start = tokio::time::Instant::now();
1479        let mut progress = ChildProgress::new(start);
1480
1481        // First tiny chunk: phase flip only (0 tokens → nothing to report).
1482        let _ = progress.observe(&stream_text("xy"), &state, start);
1483        // 400 chars ≈ 100 tokens accumulate silently inside the window…
1484        let silent = progress.observe(&stream_text(&"x".repeat(400)), &state, start);
1485        assert!(
1486            silent.is_empty(),
1487            "inside the window stays silent: {silent:?}"
1488        );
1489        // …and flush once the interval has elapsed.
1490        let later = start + TOKEN_PROGRESS_INTERVAL;
1491        let events = progress.observe(&stream_text("y"), &state, later);
1492        assert!(
1493            events
1494                .iter()
1495                .any(|e| matches!(e, ProgressEvent::SubagentTokens(t) if *t >= 100)),
1496            "tokens flush after the interval: {events:?}"
1497        );
1498
1499        // StreamDone snaps the estimate to the provider-reported count and
1500        // piggybacks... only once the throttle allows again.
1501        let done = Msg::StreamDone {
1502            turn: TurnId(1),
1503            usage: Some(crate::models::TokenUsage::provider(10, 5_000)),
1504            provider_continuation: None,
1505            stop_reason: None,
1506        };
1507        let much_later = later + TOKEN_PROGRESS_INTERVAL;
1508        let events = progress.observe(&done, &state, much_later);
1509        assert!(
1510            events
1511                .iter()
1512                .any(|e| matches!(e, ProgressEvent::SubagentTokens(t) if *t >= 5_000)),
1513            "provider usage snaps the counter: {events:?}"
1514        );
1515    }
1516
1517    #[tokio::test]
1518    async fn empty_prompt_is_rejected() {
1519        let spawner = test_spawner_arc();
1520        let tool = SubagentTool::new(spawner);
1521        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1522        let outcome = tool.execute(serde_json::json!({"prompt": "  "}), ctx).await;
1523        assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
1524    }
1525
1526    #[test]
1527    fn child_state_inherits_live_safety_mode_over_config_default() {
1528        // #2: a subagent must run at the parent's LIVE safety mode, not the
1529        // static config default `State::new` would otherwise apply — otherwise
1530        // a downgraded session is escapable by delegating to a subagent.
1531        use crate::runtime::SafetyMode;
1532        let mut config = crate::app::Config::default();
1533        config.safety.mode = SafetyMode::FullAccess; // static config default
1534        let mut child_state = State::new(
1535            config,
1536            PathBuf::from("/tmp"),
1537            "ollama/test".to_string(),
1538            chrono::Local::now(),
1539        );
1540        // The bug source: State::new picks up the config default…
1541        assert_eq!(child_state.session.safety_mode, SafetyMode::FullAccess);
1542        // …and the fix: the parent's live ctx.safety_mode overrides it.
1543        child_state.session.safety_mode = SafetyMode::Ask;
1544        assert_eq!(child_state.session.safety_mode, SafetyMode::Ask);
1545    }
1546
1547    #[test]
1548    fn child_state_inherits_the_parent_scratchpad() {
1549        // A subagent shares the parent session's scratch directory — the
1550        // child never receives its own `EnsureScratchpad`, so without the
1551        // refresh-block inheritance it would run with `scratchpad: None`.
1552        let (mut ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1553        ctx.scratchpad = Some(PathBuf::from("/data/tmp/scratchpad/-proj/s"));
1554        // The bug source: a fresh (or cached) child State starts without one…
1555        let mut child_state = test_state();
1556        assert_eq!(child_state.session.scratchpad, None);
1557        // …and the fix: the refresh block copies the parent's live dir onto
1558        // the child, fresh spawns and continuations alike.
1559        child_state.session.scratchpad = ctx.scratchpad.clone();
1560        assert_eq!(
1561            child_state.session.scratchpad.as_deref(),
1562            Some(std::path::Path::new("/data/tmp/scratchpad/-proj/s"))
1563        );
1564    }
1565
1566    /// F7: when `ExecContext::model_id` is empty (the test builder's
1567    /// default), the fallback walks `config.default_model.{provider,name}`.
1568    /// This pins the happy-path behavior.
1569    #[test]
1570    fn default_model_id_reads_config_provider_and_name() {
1571        let mut cfg = crate::app::Config::default();
1572        cfg.default_model.provider = "ollama".to_string();
1573        cfg.default_model.name = "qwen3-coder:30b".to_string();
1574        assert_eq!(default_model_id(&cfg), "ollama/qwen3-coder:30b");
1575    }
1576
1577    #[test]
1578    fn default_model_id_returns_bare_name_when_provider_empty() {
1579        let mut cfg = crate::app::Config::default();
1580        cfg.default_model.name = "just-a-name".to_string();
1581        // provider is empty — single-slash shape would be
1582        // "/just-a-name", which provider resolution would reject.
1583        assert_eq!(default_model_id(&cfg), "just-a-name");
1584    }
1585
1586    #[test]
1587    fn apply_live_mcp_marks_running_servers_ready_with_their_tools() {
1588        use crate::domain::{McpServerEntry, McpServerStatus};
1589        let entry = || McpServerEntry {
1590            config: crate::app::McpServerConfig::default(),
1591            status: McpServerStatus::Starting,
1592            tools: Vec::new(),
1593        };
1594        let mut servers = std::collections::HashMap::new();
1595        servers.insert("slack".to_string(), entry());
1596        servers.insert("broken".to_string(), entry());
1597
1598        let live = vec![
1599            (
1600                "slack".to_string(),
1601                crate::domain::McpToolSpec {
1602                    name: "mcp__slack__send".to_string(),
1603                    raw_name: "send".to_string(),
1604                    description: "send a message".to_string(),
1605                    input_schema: serde_json::json!({"type": "object"}),
1606                    read_only_hint: false,
1607                },
1608            ),
1609            // A tool from a server the child doesn't have configured must
1610            // not create an entry out of thin air.
1611            (
1612                "other".to_string(),
1613                crate::domain::McpToolSpec {
1614                    name: "mcp__other__x".to_string(),
1615                    raw_name: "x".to_string(),
1616                    description: String::new(),
1617                    input_schema: serde_json::json!({}),
1618                    read_only_hint: false,
1619                },
1620            ),
1621        ];
1622        apply_live_mcp(&mut servers, &live, |name| name == "slack");
1623
1624        let slack = &servers["slack"];
1625        assert_eq!(slack.status, McpServerStatus::Ready);
1626        assert_eq!(slack.tools.len(), 1);
1627        assert_eq!(slack.tools[0].name, "mcp__slack__send");
1628        assert_eq!(slack.tools[0].raw_name, "send");
1629        // A configured server the manager doesn't run stays un-advertised.
1630        assert_eq!(servers["broken"].status, McpServerStatus::Starting);
1631        assert!(servers["broken"].tools.is_empty());
1632        assert!(!servers.contains_key("other"));
1633    }
1634
1635    #[test]
1636    fn subagent_metadata_carries_usage_only_when_reported() {
1637        let some = subagent_metadata(
1638            "ollama/test".to_string(),
1639            TokenUsageTotals {
1640                prompt_tokens: 100,
1641                completion_tokens: 40,
1642                ..TokenUsageTotals::default()
1643            },
1644            "a7".to_string(),
1645        );
1646        let usage = some.token_usage.expect("usage attached");
1647        assert_eq!(usage.total_tokens(), 140);
1648        assert_eq!(usage.completion_tokens, 40);
1649        assert!(matches!(
1650            some.detail,
1651            crate::domain::ToolMetadata::Subagent { ref model_id, ref agent_id }
1652                if model_id == "ollama/test" && agent_id == "a7"
1653        ));
1654        // A provider that reported nothing must not render as "0 tokens".
1655        let none = subagent_metadata(
1656            "ollama/test".to_string(),
1657            TokenUsageTotals::default(),
1658            "a8".to_string(),
1659        );
1660        assert!(none.token_usage.is_none());
1661    }
1662
1663    #[test]
1664    fn usage_delta_reports_only_this_drive() {
1665        // Continuations accumulate usage in one State; the parent must see
1666        // the delta, not the cumulative total again (double-count).
1667        let before = TokenUsageTotals {
1668            prompt_tokens: 1_000,
1669            completion_tokens: 200,
1670            ..TokenUsageTotals::default()
1671        };
1672        let after = TokenUsageTotals {
1673            prompt_tokens: 1_600,
1674            completion_tokens: 350,
1675            ..TokenUsageTotals::default()
1676        };
1677        let delta = usage_delta(after, before);
1678        assert_eq!(delta.prompt_tokens, 600);
1679        assert_eq!(delta.completion_tokens, 150);
1680        assert_eq!(delta.total_tokens(), 750);
1681        // A fresh spawn's snapshot is zero — the delta IS the total.
1682        let fresh = usage_delta(after, TokenUsageTotals::default());
1683        assert_eq!(fresh.total_tokens(), 1_950);
1684    }
1685
1686    #[test]
1687    fn resolve_agent_type_builtins_custom_shadowing_and_errors() {
1688        use crate::app::AgentTypeConfig;
1689        let mut config = crate::app::Config::default();
1690
1691        // Built-ins: no request defaults to general; explore pins read_only.
1692        assert_eq!(resolve_agent_type(None, &config).unwrap().name, "general");
1693        assert_eq!(
1694            resolve_agent_type(None, &config).unwrap().safety_ceiling,
1695            SafetyMode::FullAccess,
1696        );
1697        let explore = resolve_agent_type(Some("explore"), &config).unwrap();
1698        assert_eq!(explore.safety_ceiling, SafetyMode::ReadOnly);
1699        assert!(explore.preamble.as_deref().unwrap().contains("read-only"));
1700        assert!(explore.allows_tool("read_file"));
1701        assert!(!explore.allows_tool("write_file"));
1702        assert!(!explore.allows_tool("mcp"));
1703
1704        // Unknown type: actionable error naming what exists.
1705        let err = resolve_agent_type(Some("nope"), &config).unwrap_err();
1706        assert!(err.contains("general") && err.contains("explore"), "{err}");
1707
1708        // Custom type from config.
1709        config.agents.types.insert(
1710            "scout".to_string(),
1711            AgentTypeConfig {
1712                tools: Some(vec!["read_file".to_string()]),
1713                safety: Some("read_only".to_string()),
1714                preamble: Some("You are a scout.".to_string()),
1715                model: Some("ollama/qwen3:8b".to_string()),
1716            },
1717        );
1718        let scout = resolve_agent_type(Some("scout"), &config).unwrap();
1719        assert_eq!(scout.model.as_deref(), Some("ollama/qwen3:8b"));
1720        assert_eq!(scout.safety_ceiling, SafetyMode::ReadOnly);
1721
1722        // A custom name shadows the built-in, so users can retune explore.
1723        config.agents.types.insert(
1724            "explore".to_string(),
1725            AgentTypeConfig {
1726                safety: Some("ask".to_string()),
1727                ..AgentTypeConfig::default()
1728            },
1729        );
1730        assert_eq!(
1731            resolve_agent_type(Some("explore"), &config)
1732                .unwrap()
1733                .safety_ceiling,
1734            SafetyMode::Ask,
1735        );
1736
1737        // Invalid safety string and unknown tool name both fail fast with
1738        // the offending value in the message.
1739        config.agents.types.insert(
1740            "bad-safety".to_string(),
1741            AgentTypeConfig {
1742                safety: Some("yolo".to_string()),
1743                ..AgentTypeConfig::default()
1744            },
1745        );
1746        assert!(
1747            resolve_agent_type(Some("bad-safety"), &config)
1748                .unwrap_err()
1749                .contains("yolo")
1750        );
1751        config.agents.types.insert(
1752            "bad-tool".to_string(),
1753            AgentTypeConfig {
1754                tools: Some(vec!["screenshot".to_string()]),
1755                ..AgentTypeConfig::default()
1756            },
1757        );
1758        assert!(
1759            resolve_agent_type(Some("bad-tool"), &config)
1760                .unwrap_err()
1761                .contains("screenshot")
1762        );
1763    }
1764
1765    #[test]
1766    fn agent_cache_stores_takes_and_evicts_oldest() {
1767        let spawner = test_spawner();
1768        let mk_state = || {
1769            State::new(
1770                crate::app::Config::default(),
1771                PathBuf::from("/tmp"),
1772                "ollama/test".to_string(),
1773                chrono::Local::now(),
1774            )
1775        };
1776        let mk = || CachedAgent {
1777            state: mk_state(),
1778            type_name: "general".to_string(),
1779        };
1780
1781        // Handles mint monotonically distinct.
1782        assert_ne!(spawner.mint_agent_id(), spawner.mint_agent_id());
1783
1784        // Store/take round-trip; take REMOVES (a running continuation owns
1785        // the state exclusively).
1786        spawner.cache_store("x".to_string(), mk());
1787        assert!(spawner.cache_take("x").is_some());
1788        assert!(spawner.cache_take("x").is_none(), "take must remove");
1789
1790        // Eviction: past the cap, oldest goes first.
1791        for i in 0..(MAX_CACHED_AGENTS + 2) {
1792            spawner.cache_store(format!("e{i}"), mk());
1793        }
1794        assert!(spawner.cache_take("e0").is_none(), "oldest evicted");
1795        assert!(spawner.cache_take("e1").is_none(), "second-oldest evicted");
1796        assert!(
1797            spawner
1798                .cache_take(&format!("e{}", MAX_CACHED_AGENTS + 1))
1799                .is_some(),
1800            "newest survives",
1801        );
1802    }
1803
1804    #[tokio::test]
1805    async fn continuing_an_unknown_agent_id_errors_actionably() {
1806        let spawner = test_spawner_arc();
1807        let tool = SubagentTool::new(spawner);
1808        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1809        let outcome = tool
1810            .execute(
1811                serde_json::json!({"prompt": "follow up", "agent_id": "a99"}),
1812                ctx,
1813            )
1814            .await;
1815        assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
1816        let msg = outcome.error_message().unwrap_or_default();
1817        assert!(msg.contains("a99"), "names the bad id: {msg}");
1818        assert!(
1819            msg.contains("Omit agent_id"),
1820            "tells the model how to recover: {msg}"
1821        );
1822    }
1823
1824    #[tokio::test]
1825    async fn unknown_agent_type_errors_actionably() {
1826        let spawner = test_spawner_arc();
1827        let tool = SubagentTool::new(spawner);
1828        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1829        let outcome = tool
1830            .execute(
1831                serde_json::json!({"prompt": "look around", "type": "wizard"}),
1832                ctx,
1833            )
1834            .await;
1835        assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
1836        let msg = outcome.error_message().unwrap_or_default();
1837        assert!(msg.contains("wizard") && msg.contains("explore"), "{msg}");
1838    }
1839
1840    #[test]
1841    fn build_child_registry_excludes_gui_and_self() {
1842        let config = crate::app::Config::default();
1843        let providers = Arc::new(ProviderFactory::new(config.clone()));
1844        let web = WebCapabilities::resolve(&config.web);
1845        let r = build_child_registry(providers, None, &config, SafetyMode::Ask, &web);
1846        // GUI tools absent.
1847        assert!(r.get("screenshot").is_none());
1848        assert!(r.get("click").is_none());
1849        assert!(r.get("type_text").is_none());
1850        assert!(r.get("press_key").is_none());
1851        assert!(r.get("scroll").is_none());
1852        assert!(r.get("mouse_move").is_none());
1853        assert!(r.get("list_windows").is_none());
1854        // Self absent — no recursion bootstrap.
1855        assert!(r.get("agent").is_none());
1856        // Core tools present.
1857        assert!(r.get("read_file").is_some());
1858        assert!(r.get("execute_command").is_some());
1859        // Default Ask cannot be satisfied by a headless child, so dead web
1860        // capabilities must not be advertised.
1861        assert!(r.get("web_fetch").is_none());
1862        assert!(r.get("web_search").is_none());
1863    }
1864
1865    #[test]
1866    fn child_registry_exposes_web_only_when_headless_policy_can_execute_it() {
1867        let configured = |mode, readonly_web, headless_opt_in, network| {
1868            let mut config = crate::app::Config::default();
1869            config.safety.mode = mode;
1870            config.safety.allow_readonly_web = readonly_web;
1871            config.safety.allow_untrusted_headless_tools = headless_opt_in;
1872            config.safety.network = network;
1873            // A configured SearXNG client is platform-independent, unlike the
1874            // managed bundle, so this test covers both tools on Windows too.
1875            config.web.search_backend = crate::app::SearchBackend::Searxng;
1876            config.web.searxng_url = "http://127.0.0.1:8080".to_string();
1877            let providers = Arc::new(ProviderFactory::new(config.clone()));
1878            let web = WebCapabilities::resolve(&config.web);
1879            build_child_registry(providers, None, &config, mode, &web)
1880        };
1881
1882        for mode in [SafetyMode::Auto, SafetyMode::FullAccess] {
1883            let registry = configured(mode, false, false, crate::app::NetworkPolicy::Allow);
1884            assert!(registry.get("web_fetch").is_some(), "mode {mode:?}");
1885            assert!(registry.get("web_search").is_some(), "mode {mode:?}");
1886        }
1887
1888        let readonly = configured(
1889            SafetyMode::ReadOnly,
1890            true,
1891            false,
1892            crate::app::NetworkPolicy::Allow,
1893        );
1894        assert!(readonly.get("web_fetch").is_some());
1895        assert!(readonly.get("web_search").is_some());
1896
1897        let opted_in = configured(
1898            SafetyMode::Ask,
1899            false,
1900            true,
1901            crate::app::NetworkPolicy::Allow,
1902        );
1903        assert!(opted_in.get("web_fetch").is_some());
1904        assert!(opted_in.get("web_search").is_some());
1905
1906        let denied = configured(
1907            SafetyMode::FullAccess,
1908            true,
1909            true,
1910            crate::app::NetworkPolicy::Deny,
1911        );
1912        assert!(denied.get("web_fetch").is_none());
1913        assert!(denied.get("web_search").is_none());
1914    }
1915
1916    #[test]
1917    fn child_web_visibility_honors_explicit_policy_overrides() {
1918        let mut config = crate::app::Config::default();
1919        config.safety.overrides = vec![crate::runtime::PolicyOverride {
1920            category: Some(crate::runtime::ToolCategory::Web),
1921            decision: crate::runtime::PolicyOverrideDecision::Allow,
1922            ..crate::runtime::PolicyOverride::default()
1923        }];
1924        assert!(headless_web_tool_is_executable(
1925            &config,
1926            SafetyMode::Ask,
1927            "web_fetch"
1928        ));
1929
1930        config.safety.overrides[0].decision = crate::runtime::PolicyOverrideDecision::Deny;
1931        assert!(!headless_web_tool_is_executable(
1932            &config,
1933            SafetyMode::FullAccess,
1934            "web_fetch"
1935        ));
1936    }
1937
1938    #[test]
1939    fn kill_detached_fires_registered_tokens_and_evicts_cached_children() {
1940        let spawner = test_spawner();
1941
1942        // Running detached child: token registered → killed exactly once.
1943        let cancel = CancellationToken::new();
1944        spawner.register_detached("a1".to_string(), cancel.clone());
1945        assert_eq!(spawner.kill_detached("a1"), KillResult::Killed);
1946        assert!(cancel.is_cancelled());
1947        // The handle is consumed — a second kill finds nothing.
1948        assert_eq!(spawner.kill_detached("a1"), KillResult::NotFound);
1949
1950        // Finished child (continuation cache only): evicted, not killable.
1951        spawner.cache_store(
1952            "a2".to_string(),
1953            CachedAgent {
1954                state: test_state(),
1955                type_name: "general".to_string(),
1956            },
1957        );
1958        assert_eq!(spawner.kill_detached("a2"), KillResult::Evicted);
1959        assert!(spawner.cache_take("a2").is_none(), "eviction is permanent");
1960
1961        // Never-issued id.
1962        assert_eq!(spawner.kill_detached("a99"), KillResult::NotFound);
1963
1964        // kill_all fires every registered token and drains the map.
1965        let (c1, c2) = (CancellationToken::new(), CancellationToken::new());
1966        spawner.register_detached("a3".to_string(), c1.clone());
1967        spawner.register_detached("a4".to_string(), c2.clone());
1968        assert_eq!(spawner.kill_all_detached(), 2);
1969        assert!(c1.is_cancelled() && c2.is_cancelled());
1970        assert_eq!(spawner.kill_all_detached(), 0);
1971    }
1972
1973    #[tokio::test]
1974    async fn kill_action_validates_agent_id_and_skips_prompt_requirement() {
1975        let spawner = test_spawner_arc();
1976        let tool = SubagentTool::new(spawner.clone());
1977
1978        // No agent_id → arg error (and NOT the missing-prompt error: the
1979        // kill path must not require a prompt).
1980        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1981        let outcome = tool
1982            .execute(serde_json::json!({"action": "kill"}), ctx)
1983            .await;
1984        assert!(!outcome.is_success());
1985        assert!(outcome.model_content.contains("requires `agent_id`"));
1986
1987        // Unknown id → error naming the id.
1988        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
1989        let outcome = tool
1990            .execute(serde_json::json!({"action": "kill", "agent_id": "a7"}), ctx)
1991            .await;
1992        assert!(!outcome.is_success());
1993        assert!(outcome.model_content.contains("a7"));
1994
1995        // Registered detached child → success, token fired.
1996        let cancel = CancellationToken::new();
1997        spawner.register_detached("a7".to_string(), cancel.clone());
1998        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(3), PathBuf::from("/tmp"));
1999        let outcome = tool
2000            .execute(serde_json::json!({"action": "kill", "agent_id": "a7"}), ctx)
2001            .await;
2002        assert!(outcome.is_success(), "{}", outcome.model_content);
2003        assert!(cancel.is_cancelled());
2004    }
2005
2006    #[test]
2007    fn explore_registry_is_a_read_only_surface() {
2008        let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
2009        let explore = builtin_agent_type("explore").expect("builtin");
2010        let config = crate::app::Config::default();
2011        let web = WebCapabilities::resolve(&config.web);
2012        let r = build_child_registry(
2013            providers,
2014            explore.tools.as_deref(),
2015            &config,
2016            SafetyMode::ReadOnly,
2017            &web,
2018        );
2019        assert!(r.get("read_file").is_some());
2020        assert!(r.get("execute_command").is_some());
2021        for tool in [
2022            "write_file",
2023            "apply_patch",
2024            "delete_file",
2025            "create_directory",
2026            "mcp_proxy",
2027            "agent",
2028        ] {
2029            assert!(r.get(tool).is_none(), "explore must not carry {tool}");
2030        }
2031    }
2032}