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//! - `SubagentTool::execute` builds a fresh child `State`, a
18//!   filtered `ToolRegistry` (no self-recursion, no GUI tools), and
19//!   a child `EffectRunner` + msg channel. It drives the child
20//!   reducer to `Idle`, streaming progress back to the parent via
21//!   `ProgressEvent::Subagent*`, and returns the last assistant
22//!   message as the tool's `output`.
23
24use std::sync::Arc;
25use std::time::{Duration, Instant};
26
27use async_trait::async_trait;
28use serde_json::Value;
29use tokio::sync::{Semaphore, mpsc};
30use tokio_util::sync::CancellationToken;
31
32use crate::domain::{
33    Msg, State, ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata, TurnState, update,
34};
35use crate::effect::{EffectRunner, MSG_CHANNEL_CAPACITY};
36use crate::models::MessageRole;
37use crate::providers::ProviderFactory;
38use crate::providers::ctx::{ExecContext, ProgressEvent, SubagentPhase};
39
40use super::ToolExecutor;
41use super::ToolRegistry;
42
43/// Maximum subagents running simultaneously across the whole process.
44/// Covers the pathological "parent emits 30 agent calls in one turn"
45/// case. Hit this cap → later calls block on the semaphore until
46/// some earlier subagent finishes or cancels.
47pub const MAX_INFLIGHT: usize = 10;
48
49/// Hard ceiling on a subagent's wall-clock runtime. Above this the
50/// subagent is cancelled and reports `Error`.
51pub const DEFAULT_TIMEOUT_SECS: u64 = 20 * 60;
52
53/// Shared spawner. One per process; held by `SubagentTool`.
54pub struct SubagentSpawner {
55    providers: Arc<ProviderFactory>,
56    inflight: Arc<Semaphore>,
57}
58
59impl SubagentSpawner {
60    pub fn new(providers: Arc<ProviderFactory>) -> Self {
61        Self {
62            providers,
63            inflight: Arc::new(Semaphore::new(MAX_INFLIGHT)),
64        }
65    }
66}
67
68/// The `agent` tool the model sees.
69pub struct SubagentTool {
70    spawner: Arc<SubagentSpawner>,
71}
72
73impl SubagentTool {
74    pub fn new(spawner: Arc<SubagentSpawner>) -> Self {
75        Self { spawner }
76    }
77}
78
79#[async_trait]
80impl ToolExecutor for SubagentTool {
81    fn name(&self) -> &'static str {
82        "agent"
83    }
84
85    fn schema(&self) -> ToolDefinition {
86        ToolDefinition {
87            name: "agent".to_string(),
88            description: format!(
89                "Spawn a child agent with its own context and tool access to work on an \
90                 independent sub-task. Useful for parallel fan-out (emit multiple `agent` \
91                 calls in the same turn to run them concurrently) or for scoping a noisy \
92                 sub-task (the child's tool output doesn't clutter the parent's turn). \
93                 Breadth-capped at {max_breadth} concurrent; subagents can't themselves \
94                 spawn subagents. Subagents don't get GUI (screenshot/click/…) access \
95                 because coordinate metadata can't be shared cleanly.",
96                max_breadth = MAX_INFLIGHT,
97            ),
98            input_schema: serde_json::json!({
99                "type": "object",
100                "properties": {
101                    "prompt": {
102                        "type": "string",
103                        "description": "The task for the subagent. Self-contained; the subagent has no access to the parent's conversation."
104                    },
105                    "description": {
106                        "type": "string",
107                        "description": "Short label shown in the parent's status line (e.g. 'list domain files')."
108                    }
109                },
110                "required": ["prompt"]
111            }),
112        }
113    }
114
115    async fn execute(&self, args: Value, ctx: ExecContext) -> ToolOutcome {
116        let started = Instant::now();
117
118        // Parse args.
119        let prompt = match args.get("prompt").and_then(|v| v.as_str()) {
120            Some(s) if !s.trim().is_empty() => s.to_string(),
121            _ => {
122                return ToolOutcome::error("agent requires non-empty `prompt`", 0.0);
123            },
124        };
125        let description = args
126            .get("description")
127            .and_then(|v| v.as_str())
128            .unwrap_or("subagent")
129            .to_string();
130
131        // Safety gate: spawning a subagent is a Process-class action.
132        // ReadOnly (or a Deny override) blocks it; the child's own tool
133        // calls are independently gated by the same policy.
134        if let Some(blocked) = super::policy_gate::gate_external(
135            &ctx,
136            "agent",
137            crate::runtime::ToolCategory::Subagent,
138            format!("subagent: {}", description),
139            &args,
140        )
141        .await
142        {
143            return blocked;
144        }
145
146        // Acquire a breadth permit. Respects parent cancellation so
147        // a fan-out that lands 30 calls doesn't hold the parent's
148        // Ctrl+C response hostage.
149        let permit = tokio::select! {
150            biased;
151            _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
152            p = self.spawner.inflight.clone().acquire_owned() => match p {
153                Ok(permit) => permit,
154                Err(_) => return ToolOutcome::error(
155                    "subagent semaphore closed",
156                    started.elapsed().as_secs_f64(),
157                ),
158            },
159        };
160
161        // Build the child runtime. The child uses the same parent
162        // config + cwd + model id, with a fresh `State` and a tool
163        // registry filtered to remove self-recursion and GUI tools.
164        //
165        // F7: `ExecContext` now carries the parent's `Config` +
166        // `model_id`. Previously we built `Config::default()` here and
167        // the child model id defaulted to `config.default_model.name`
168        // (usually empty), which made subagents fail at provider
169        // resolution.
170        let config = (*ctx.config).clone();
171        let cwd = ctx.workdir.clone();
172        let model_id = if ctx.model_id.is_empty() {
173            default_model_id(&config)
174        } else {
175            ctx.model_id.clone()
176        };
177        let child_model_id = model_id.clone();
178        // Inherit the parent's LIVE safety mode (Shift+Tab / `/safety` apply
179        // immediately) rather than the static config default `State::new`
180        // would pick up — otherwise a downgraded session could be escaped by
181        // delegating risky work to a subagent. The child runs headless (no
182        // approval broker), so in `ask` its mutations block/await rather than
183        // silently escalate; non-replayable tools fail closed (see #3).
184        let mut child_state =
185            State::new(config.clone(), cwd.clone(), model_id, chrono::Local::now());
186        child_state.session.safety_mode = ctx.safety_mode;
187        // Load project instructions + the memory index synchronously, before the
188        // child is driven. Dispatching RefreshInstructions/RefreshMemory as
189        // effects (as this used to) races the child's FIRST model call — which
190        // is emitted synchronously from the seed prompt — so the opening call
191        // went out blind. The child has no config watcher either, so this is the
192        // only load.
193        let (instructions, memory) =
194            crate::app::instructions::load_project_context(&cwd, &config.memory);
195        child_state.instructions = instructions;
196        child_state.memory = memory;
197
198        let child_tools = build_child_registry(self.spawner.providers.clone());
199
200        // Child runner rooted at parent's scope child token. When
201        // parent cancels, `child_token.cancelled()` fires and the
202        // child's subprocess + model streams abort.
203        let child_token = ctx.token.child_token();
204        let (child_tx, child_rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
205        let child_runner =
206            EffectRunner::new_child(child_tx, cwd, self.spawner.providers.clone(), child_tools);
207
208        // Drive the child reducer loop to completion. The wall-clock
209        // timeout lives inside `drive_child` so the child runner is always
210        // shut down — even on timeout — rather than dropped mid-flight (#76).
211        let result = drive_child(
212            child_state,
213            child_runner,
214            child_rx,
215            ctx.progress.clone(),
216            prompt,
217            description.clone(),
218            child_token,
219        )
220        .await;
221        drop(permit);
222
223        let elapsed = started.elapsed().as_secs_f64();
224        match result {
225            Ok(summary) => ToolOutcome::success(summary, "subagent completed", elapsed)
226                .with_metadata(subagent_metadata(child_model_id)),
227            Err(DriveError::Cancelled) => ToolOutcome::cancelled(),
228            Err(DriveError::TimedOut) => ToolOutcome::error(
229                format!(
230                    "subagent ({}) exceeded {}s timeout",
231                    description, DEFAULT_TIMEOUT_SECS
232                ),
233                elapsed,
234            )
235            .with_metadata(subagent_metadata(child_model_id)),
236            Err(DriveError::Errored(e)) => {
237                ToolOutcome::error(format!("subagent ({}): {}", description, e), elapsed)
238                    .with_metadata(subagent_metadata(child_model_id))
239            },
240        }
241    }
242}
243
244fn subagent_metadata(model_id: String) -> ToolRunMetadata {
245    ToolRunMetadata {
246        detail: ToolMetadata::Subagent { model_id },
247        ..ToolRunMetadata::default()
248    }
249}
250
251enum DriveError {
252    Cancelled,
253    TimedOut,
254    Errored(String),
255}
256
257/// Drive the child's reducer loop to `Idle`. Forwards child
258/// `ToolStarted` / `ToolFinished` / `StreamText` events to the
259/// parent's progress channel as `ProgressEvent::Subagent*`.
260async fn drive_child(
261    mut state: State,
262    mut runner: EffectRunner,
263    mut msg_rx: mpsc::Receiver<Msg>,
264    parent_progress: mpsc::Sender<ProgressEvent>,
265    prompt: String,
266    description: String,
267    token: CancellationToken,
268) -> Result<String, DriveError> {
269    // Signal start to parent.
270    let _ = parent_progress
271        .send(ProgressEvent::SubagentText(format!(
272            "{} — {}",
273            description,
274            prompt.chars().take(80).collect::<String>()
275        )))
276        .await;
277
278    // Project instructions + memory are loaded synchronously into `state` before
279    // `drive_child` is called (see `execute`), so the child's first model call
280    // sees them — no RefreshInstructions/RefreshMemory dispatch here, which would
281    // race that first call.
282
283    // Seed the child turn.
284    let seed = Msg::SubmitPrompt {
285        text: prompt,
286        attachment_ids: vec![],
287    };
288    let (new_state, cmds) = update(state, seed);
289    state = new_state;
290    for cmd in cmds {
291        runner.dispatch(cmd);
292    }
293
294    // Drive the child reducer to Idle, bounded by a wall-clock deadline.
295    // The deadline is a `select!` arm (not a `timeout()` wrapper) so the
296    // single `runner.shutdown()` below always runs — on normal exit,
297    // cancel, OR timeout — instead of the runner being dropped mid-flight
298    // and leaking its MCP children (#76).
299    let deadline = tokio::time::sleep(Duration::from_secs(DEFAULT_TIMEOUT_SECS));
300    tokio::pin!(deadline);
301
302    let mut outcome: Result<(), DriveError> = Ok(());
303    loop {
304        if token.is_cancelled() {
305            outcome = Err(DriveError::Cancelled);
306            break;
307        }
308        if matches!(state.turn, TurnState::Idle) && state.ui.queued_messages.is_empty() {
309            break;
310        }
311
312        let msg = tokio::select! {
313            biased;
314            _ = token.cancelled() => {
315                outcome = Err(DriveError::Cancelled);
316                break;
317            },
318            _ = &mut deadline => {
319                outcome = Err(DriveError::TimedOut);
320                break;
321            },
322            recv = msg_rx.recv() => match recv {
323                Some(m) => m,
324                None => break, // channel closed — child runner shut down
325            },
326        };
327
328        // Forward child activity to parent progress BEFORE the
329        // reducer mutates state (we want `call_id` + `tool_name`
330        // semantic info, which reducer events strip).
331        forward_child_event(&msg, &parent_progress, &state).await;
332
333        let (new_state, cmds) = update(state, msg);
334        state = new_state;
335        for cmd in cmds {
336            runner.dispatch(cmd);
337        }
338        if state.should_exit {
339            break;
340        }
341    }
342
343    // Always reap the child runner (cancel scopes, gracefully terminate MCP
344    // server children) regardless of how the loop exited.
345    runner.shutdown().await;
346
347    outcome?;
348
349    // Extract last assistant message as the result.
350    let summary = state
351        .session
352        .messages()
353        .iter()
354        .rev()
355        .find(|m| m.role == MessageRole::Assistant)
356        .map(|m| m.content.clone())
357        .unwrap_or_default();
358    if summary.trim().is_empty() {
359        return Err(DriveError::Errored(
360            "subagent produced no assistant output".to_string(),
361        ));
362    }
363    Ok(summary)
364}
365
366/// Translate child-scope `Msg` events into parent-scope
367/// `ProgressEvent::Subagent*`. Flat mapping, never recursive — the
368/// parent reducer just sees "a tool started / finished / said
369/// something" with the child's call identity.
370async fn forward_child_event(msg: &Msg, progress: &mpsc::Sender<ProgressEvent>, state: &State) {
371    match msg {
372        Msg::ToolStarted {
373            turn: _, call_id, ..
374        } => {
375            let tool_name = lookup_tool_name(state, *call_id).unwrap_or_else(|| "tool".to_string());
376            let _ = progress
377                .send(ProgressEvent::SubagentToolCall {
378                    child_call_id: *call_id,
379                    tool_name,
380                    phase: SubagentPhase::Started,
381                })
382                .await;
383        },
384        Msg::ToolFinished {
385            turn: _,
386            call_id,
387            outcome,
388        } => {
389            let tool_name = lookup_tool_name(state, *call_id).unwrap_or_else(|| "tool".to_string());
390            let phase = if outcome.is_success() {
391                SubagentPhase::Finished
392            } else {
393                SubagentPhase::Errored
394            };
395            let _ = progress
396                .send(ProgressEvent::SubagentToolCall {
397                    child_call_id: *call_id,
398                    tool_name,
399                    phase,
400                })
401                .await;
402        },
403        Msg::StreamText { chunk, .. } => {
404            // Only forward a compact preview; long assistant text is
405            // overwhelming in the parent's status line.
406            if !chunk.trim().is_empty() {
407                let snippet: String = chunk.chars().take(120).collect();
408                let _ = progress.send(ProgressEvent::SubagentText(snippet)).await;
409            }
410        },
411        _ => {},
412    }
413}
414
415/// Look up a tool name from a `PendingToolCall` in the state.
416/// Returns `None` if the call id isn't known (e.g. during teardown).
417fn lookup_tool_name(state: &State, call_id: crate::domain::ToolCallId) -> Option<String> {
418    match &state.turn {
419        TurnState::ExecutingTools { calls, .. } => calls
420            .iter()
421            .find(|c| c.call_id == call_id)
422            .map(|c| c.source.function.name.clone()),
423        _ => None,
424    }
425}
426
427/// Construct the child `ToolRegistry` — a subset of what the parent
428/// offers. Explicitly excludes:
429///
430///   - `agent` itself — subagents don't spawn subagents. This
431///     exclusion is the guard (there is no depth counter).
432///   - All seven GUI / computer-use tools — the parent's
433///     `ComputerUseDriver` owns the screenshot coord registry; a
434///     subagent clicking would corrupt the parent's latest-capture
435///     pointer.
436///
437/// Filesystem + exec + web + MCP tools come along unchanged. That
438/// lets subagents read/write files, run commands, and call MCP tools
439/// for their work.
440fn build_child_registry(providers: Arc<ProviderFactory>) -> Arc<ToolRegistry> {
441    use super::{
442        computer_use, exec, filesystem, mcp,
443        web::{WebFetchTool, WebSearchTool},
444    };
445    let mut r = ToolRegistry::new();
446    r.register(Arc::new(filesystem::ReadFileTool));
447    r.register(Arc::new(filesystem::WriteFileTool));
448    r.register(Arc::new(filesystem::EditFileTool));
449    r.register(Arc::new(filesystem::DeleteFileTool));
450    r.register(Arc::new(filesystem::CreateDirectoryTool));
451    r.register(Arc::new(exec::ExecuteCommandTool));
452    r.register(Arc::new(mcp::McpToolProxy));
453    if let Some(key) = crate::utils::resolve_api_key("OLLAMA_API_KEY", None) {
454        r.register(Arc::new(WebSearchTool::new(key.clone())));
455        r.register(Arc::new(WebFetchTool::new(key)));
456    }
457    // NO computer_use::*  — GUI tools are parent-only.
458    // NO subagent::SubagentTool — subagents can't spawn subagents; this
459    // exclusion IS the guard (there is no depth counter).
460    // Silence unused-import if the above imports don't all resolve.
461    let _ = computer_use::probe;
462    let _ = providers;
463    Arc::new(r)
464}
465
466/// Fallback child model id when `ExecContext::model_id` is empty
467/// (e.g. a test harness that uses the default `test_exec_context`
468/// builder). Production code always provides the parent's active model
469/// id via `Cmd::ExecuteTool::model_id`.
470fn default_model_id(config: &crate::app::Config) -> String {
471    if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
472        format!(
473            "{}/{}",
474            config.default_model.provider, config.default_model.name
475        )
476    } else {
477        config.default_model.name.clone()
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use crate::domain::{ToolCallId, TurnId};
485    use crate::providers::ctx::test_exec_context;
486    use std::path::PathBuf;
487
488    #[tokio::test]
489    async fn empty_prompt_is_rejected() {
490        let spawner = Arc::new(SubagentSpawner::new(Arc::new(ProviderFactory::new(
491            crate::app::Config::default(),
492        ))));
493        let tool = SubagentTool::new(spawner);
494        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
495        let outcome = tool.execute(serde_json::json!({"prompt": "  "}), ctx).await;
496        assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
497    }
498
499    #[test]
500    fn child_state_inherits_live_safety_mode_over_config_default() {
501        // #2: a subagent must run at the parent's LIVE safety mode, not the
502        // static config default `State::new` would otherwise apply — otherwise
503        // a downgraded session is escapable by delegating to a subagent.
504        use crate::runtime::SafetyMode;
505        let mut config = crate::app::Config::default();
506        config.safety.mode = SafetyMode::FullAccess; // static config default
507        let mut child_state = State::new(
508            config,
509            PathBuf::from("/tmp"),
510            "ollama/test".to_string(),
511            chrono::Local::now(),
512        );
513        // The bug source: State::new picks up the config default…
514        assert_eq!(child_state.session.safety_mode, SafetyMode::FullAccess);
515        // …and the fix: the parent's live ctx.safety_mode overrides it.
516        child_state.session.safety_mode = SafetyMode::Ask;
517        assert_eq!(child_state.session.safety_mode, SafetyMode::Ask);
518    }
519
520    /// F7: when `ExecContext::model_id` is empty (the test builder's
521    /// default), the fallback walks `config.default_model.{provider,name}`.
522    /// This pins the happy-path behavior.
523    #[test]
524    fn default_model_id_reads_config_provider_and_name() {
525        let mut cfg = crate::app::Config::default();
526        cfg.default_model.provider = "ollama".to_string();
527        cfg.default_model.name = "qwen3-coder:30b".to_string();
528        assert_eq!(default_model_id(&cfg), "ollama/qwen3-coder:30b");
529    }
530
531    #[test]
532    fn default_model_id_returns_bare_name_when_provider_empty() {
533        let mut cfg = crate::app::Config::default();
534        cfg.default_model.name = "just-a-name".to_string();
535        // provider is empty — single-slash shape would be
536        // "/just-a-name", which provider resolution would reject.
537        assert_eq!(default_model_id(&cfg), "just-a-name");
538    }
539
540    #[test]
541    fn build_child_registry_excludes_gui_and_self() {
542        let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
543        let r = build_child_registry(providers);
544        // GUI tools absent.
545        assert!(r.get("screenshot").is_none());
546        assert!(r.get("click").is_none());
547        assert!(r.get("type_text").is_none());
548        assert!(r.get("press_key").is_none());
549        assert!(r.get("scroll").is_none());
550        assert!(r.get("mouse_move").is_none());
551        assert!(r.get("list_windows").is_none());
552        // Self absent — no recursion bootstrap.
553        assert!(r.get("agent").is_none());
554        // Core tools present.
555        assert!(r.get("read_file").is_some());
556        assert!(r.get("execute_command").is_some());
557    }
558}