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