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 = State::new(config.clone(), cwd.clone(), model_id);
185        child_state.session.safety_mode = ctx.safety_mode;
186
187        let child_tools = build_child_registry(self.spawner.providers.clone());
188
189        // Child runner rooted at parent's scope child token. When
190        // parent cancels, `child_token.cancelled()` fires and the
191        // child's subprocess + model streams abort.
192        let child_token = ctx.token.child_token();
193        let (child_tx, child_rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
194        let child_runner =
195            EffectRunner::new_child(child_tx, cwd, self.spawner.providers.clone(), child_tools);
196
197        // Drive the child reducer loop to completion. The wall-clock
198        // timeout lives inside `drive_child` so the child runner is always
199        // shut down — even on timeout — rather than dropped mid-flight (#76).
200        let result = drive_child(
201            child_state,
202            child_runner,
203            child_rx,
204            ctx.progress.clone(),
205            prompt,
206            description.clone(),
207            child_token,
208        )
209        .await;
210        drop(permit);
211
212        let elapsed = started.elapsed().as_secs_f64();
213        match result {
214            Ok(summary) => ToolOutcome::success(summary, "subagent completed", elapsed)
215                .with_metadata(subagent_metadata(child_model_id)),
216            Err(DriveError::Cancelled) => ToolOutcome::cancelled(),
217            Err(DriveError::TimedOut) => ToolOutcome::error(
218                format!(
219                    "subagent ({}) exceeded {}s timeout",
220                    description, DEFAULT_TIMEOUT_SECS
221                ),
222                elapsed,
223            )
224            .with_metadata(subagent_metadata(child_model_id)),
225            Err(DriveError::Errored(e)) => {
226                ToolOutcome::error(format!("subagent ({}): {}", description, e), elapsed)
227                    .with_metadata(subagent_metadata(child_model_id))
228            },
229        }
230    }
231}
232
233fn subagent_metadata(model_id: String) -> ToolRunMetadata {
234    ToolRunMetadata {
235        detail: ToolMetadata::Subagent { model_id },
236        ..ToolRunMetadata::default()
237    }
238}
239
240enum DriveError {
241    Cancelled,
242    TimedOut,
243    Errored(String),
244}
245
246/// Drive the child's reducer loop to `Idle`. Forwards child
247/// `ToolStarted` / `ToolFinished` / `StreamText` events to the
248/// parent's progress channel as `ProgressEvent::Subagent*`.
249async fn drive_child(
250    mut state: State,
251    mut runner: EffectRunner,
252    mut msg_rx: mpsc::Receiver<Msg>,
253    parent_progress: mpsc::Sender<ProgressEvent>,
254    prompt: String,
255    description: String,
256    token: CancellationToken,
257) -> Result<String, DriveError> {
258    // Signal start to parent.
259    let _ = parent_progress
260        .send(ProgressEvent::SubagentText(format!(
261            "{} — {}",
262            description,
263            prompt.chars().take(80).collect::<String>()
264        )))
265        .await;
266
267    // Project instructions + memory — same as the root interactive path.
268    runner.dispatch(crate::domain::Cmd::RefreshInstructions);
269    runner.dispatch(crate::domain::Cmd::RefreshMemory);
270
271    // Seed the child turn.
272    let seed = Msg::SubmitPrompt {
273        text: prompt,
274        attachment_ids: vec![],
275    };
276    let (new_state, cmds) = update(state, seed);
277    state = new_state;
278    for cmd in cmds {
279        runner.dispatch(cmd);
280    }
281
282    // Drive the child reducer to Idle, bounded by a wall-clock deadline.
283    // The deadline is a `select!` arm (not a `timeout()` wrapper) so the
284    // single `runner.shutdown()` below always runs — on normal exit,
285    // cancel, OR timeout — instead of the runner being dropped mid-flight
286    // and leaking its MCP children (#76).
287    let deadline = tokio::time::sleep(Duration::from_secs(DEFAULT_TIMEOUT_SECS));
288    tokio::pin!(deadline);
289
290    let mut outcome: Result<(), DriveError> = Ok(());
291    loop {
292        if token.is_cancelled() {
293            outcome = Err(DriveError::Cancelled);
294            break;
295        }
296        if matches!(state.turn, TurnState::Idle) && state.ui.queued_messages.is_empty() {
297            break;
298        }
299
300        let msg = tokio::select! {
301            biased;
302            _ = token.cancelled() => {
303                outcome = Err(DriveError::Cancelled);
304                break;
305            },
306            _ = &mut deadline => {
307                outcome = Err(DriveError::TimedOut);
308                break;
309            },
310            recv = msg_rx.recv() => match recv {
311                Some(m) => m,
312                None => break, // channel closed — child runner shut down
313            },
314        };
315
316        // Forward child activity to parent progress BEFORE the
317        // reducer mutates state (we want `call_id` + `tool_name`
318        // semantic info, which reducer events strip).
319        forward_child_event(&msg, &parent_progress, &state).await;
320
321        let (new_state, cmds) = update(state, msg);
322        state = new_state;
323        for cmd in cmds {
324            runner.dispatch(cmd);
325        }
326        if state.should_exit {
327            break;
328        }
329    }
330
331    // Always reap the child runner (cancel scopes, gracefully terminate MCP
332    // server children) regardless of how the loop exited.
333    runner.shutdown().await;
334
335    outcome?;
336
337    // Extract last assistant message as the result.
338    let summary = state
339        .session
340        .messages()
341        .iter()
342        .rev()
343        .find(|m| m.role == MessageRole::Assistant)
344        .map(|m| m.content.clone())
345        .unwrap_or_default();
346    if summary.trim().is_empty() {
347        return Err(DriveError::Errored(
348            "subagent produced no assistant output".to_string(),
349        ));
350    }
351    Ok(summary)
352}
353
354/// Translate child-scope `Msg` events into parent-scope
355/// `ProgressEvent::Subagent*`. Flat mapping, never recursive — the
356/// parent reducer just sees "a tool started / finished / said
357/// something" with the child's call identity.
358async fn forward_child_event(msg: &Msg, progress: &mpsc::Sender<ProgressEvent>, state: &State) {
359    match msg {
360        Msg::ToolStarted {
361            turn: _, call_id, ..
362        } => {
363            let tool_name = lookup_tool_name(state, *call_id).unwrap_or_else(|| "tool".to_string());
364            let _ = progress
365                .send(ProgressEvent::SubagentToolCall {
366                    child_call_id: *call_id,
367                    tool_name,
368                    phase: SubagentPhase::Started,
369                })
370                .await;
371        },
372        Msg::ToolFinished {
373            turn: _,
374            call_id,
375            outcome,
376        } => {
377            let tool_name = lookup_tool_name(state, *call_id).unwrap_or_else(|| "tool".to_string());
378            let phase = if outcome.is_success() {
379                SubagentPhase::Finished
380            } else {
381                SubagentPhase::Errored
382            };
383            let _ = progress
384                .send(ProgressEvent::SubagentToolCall {
385                    child_call_id: *call_id,
386                    tool_name,
387                    phase,
388                })
389                .await;
390        },
391        Msg::StreamText { chunk, .. } => {
392            // Only forward a compact preview; long assistant text is
393            // overwhelming in the parent's status line.
394            if !chunk.trim().is_empty() {
395                let snippet: String = chunk.chars().take(120).collect();
396                let _ = progress.send(ProgressEvent::SubagentText(snippet)).await;
397            }
398        },
399        _ => {},
400    }
401}
402
403/// Look up a tool name from a `PendingToolCall` in the state.
404/// Returns `None` if the call id isn't known (e.g. during teardown).
405fn lookup_tool_name(state: &State, call_id: crate::domain::ToolCallId) -> Option<String> {
406    match &state.turn {
407        TurnState::ExecutingTools { calls, .. } => calls
408            .iter()
409            .find(|c| c.call_id == call_id)
410            .map(|c| c.source.function.name.clone()),
411        _ => None,
412    }
413}
414
415/// Construct the child `ToolRegistry` — a subset of what the parent
416/// offers. Explicitly excludes:
417///
418///   - `agent` itself — subagents don't spawn subagents. This
419///     exclusion is the guard (there is no depth counter).
420///   - All seven GUI / computer-use tools — the parent's
421///     `ComputerUseDriver` owns the screenshot coord registry; a
422///     subagent clicking would corrupt the parent's latest-capture
423///     pointer.
424///
425/// Filesystem + exec + web + MCP tools come along unchanged. That
426/// lets subagents read/write files, run commands, and call MCP tools
427/// for their work.
428fn build_child_registry(providers: Arc<ProviderFactory>) -> Arc<ToolRegistry> {
429    use super::{
430        computer_use, exec, filesystem, mcp,
431        web::{WebFetchTool, WebSearchTool},
432    };
433    let mut r = ToolRegistry::new();
434    r.register(Arc::new(filesystem::ReadFileTool));
435    r.register(Arc::new(filesystem::WriteFileTool));
436    r.register(Arc::new(filesystem::EditFileTool));
437    r.register(Arc::new(filesystem::DeleteFileTool));
438    r.register(Arc::new(filesystem::CreateDirectoryTool));
439    r.register(Arc::new(exec::ExecuteCommandTool));
440    r.register(Arc::new(mcp::McpToolProxy));
441    if let Some(key) = crate::utils::resolve_api_key("OLLAMA_API_KEY", None) {
442        r.register(Arc::new(WebSearchTool::new(key.clone())));
443        r.register(Arc::new(WebFetchTool::new(key)));
444    }
445    // NO computer_use::*  — GUI tools are parent-only.
446    // NO subagent::SubagentTool — subagents can't spawn subagents; this
447    // exclusion IS the guard (there is no depth counter).
448    // Silence unused-import if the above imports don't all resolve.
449    let _ = computer_use::probe;
450    let _ = providers;
451    Arc::new(r)
452}
453
454/// Fallback child model id when `ExecContext::model_id` is empty
455/// (e.g. a test harness that uses the default `test_exec_context`
456/// builder). Production code always provides the parent's active model
457/// id via `Cmd::ExecuteTool::model_id`.
458fn default_model_id(config: &crate::app::Config) -> String {
459    if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
460        format!(
461            "{}/{}",
462            config.default_model.provider, config.default_model.name
463        )
464    } else {
465        config.default_model.name.clone()
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use crate::domain::{ToolCallId, TurnId};
473    use crate::providers::ctx::test_exec_context;
474    use std::path::PathBuf;
475
476    #[tokio::test]
477    async fn empty_prompt_is_rejected() {
478        let spawner = Arc::new(SubagentSpawner::new(Arc::new(ProviderFactory::new(
479            crate::app::Config::default(),
480        ))));
481        let tool = SubagentTool::new(spawner);
482        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
483        let outcome = tool.execute(serde_json::json!({"prompt": "  "}), ctx).await;
484        assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
485    }
486
487    #[test]
488    fn child_state_inherits_live_safety_mode_over_config_default() {
489        // #2: a subagent must run at the parent's LIVE safety mode, not the
490        // static config default `State::new` would otherwise apply — otherwise
491        // a downgraded session is escapable by delegating to a subagent.
492        use crate::runtime::SafetyMode;
493        let mut config = crate::app::Config::default();
494        config.safety.mode = SafetyMode::FullAccess; // static config default
495        let mut child_state = State::new(config, PathBuf::from("/tmp"), "ollama/test".to_string());
496        // The bug source: State::new picks up the config default…
497        assert_eq!(child_state.session.safety_mode, SafetyMode::FullAccess);
498        // …and the fix: the parent's live ctx.safety_mode overrides it.
499        child_state.session.safety_mode = SafetyMode::Ask;
500        assert_eq!(child_state.session.safety_mode, SafetyMode::Ask);
501    }
502
503    /// F7: when `ExecContext::model_id` is empty (the test builder's
504    /// default), the fallback walks `config.default_model.{provider,name}`.
505    /// This pins the happy-path behavior.
506    #[test]
507    fn default_model_id_reads_config_provider_and_name() {
508        let mut cfg = crate::app::Config::default();
509        cfg.default_model.provider = "ollama".to_string();
510        cfg.default_model.name = "qwen3-coder:30b".to_string();
511        assert_eq!(default_model_id(&cfg), "ollama/qwen3-coder:30b");
512    }
513
514    #[test]
515    fn default_model_id_returns_bare_name_when_provider_empty() {
516        let mut cfg = crate::app::Config::default();
517        cfg.default_model.name = "just-a-name".to_string();
518        // provider is empty — single-slash shape would be
519        // "/just-a-name", which provider resolution would reject.
520        assert_eq!(default_model_id(&cfg), "just-a-name");
521    }
522
523    #[test]
524    fn build_child_registry_excludes_gui_and_self() {
525        let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
526        let r = build_child_registry(providers);
527        // GUI tools absent.
528        assert!(r.get("screenshot").is_none());
529        assert!(r.get("click").is_none());
530        assert!(r.get("type_text").is_none());
531        assert!(r.get("press_key").is_none());
532        assert!(r.get("scroll").is_none());
533        assert!(r.get("mouse_move").is_none());
534        assert!(r.get("list_windows").is_none());
535        // Self absent — no recursion bootstrap.
536        assert!(r.get("agent").is_none());
537        // Core tools present.
538        assert!(r.get("read_file").is_some());
539        assert!(r.get("execute_command").is_some());
540    }
541}