Skip to main content

mermaid_cli/providers/tool/
subagent.rs

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