Skip to main content

mermaid_cli/providers/tool/
subagent.rs

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