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