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