mermaid_cli/providers/tool/
subagent.rs1use 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
43pub const MAX_INFLIGHT: usize = 10;
48
49pub const DEFAULT_TIMEOUT_SECS: u64 = 20 * 60;
52
53pub 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
68pub 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 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 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 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 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 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 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 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
246async 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 let _ = parent_progress
260 .send(ProgressEvent::SubagentText(format!(
261 "{} — {}",
262 description,
263 prompt.chars().take(80).collect::<String>()
264 )))
265 .await;
266
267 runner.dispatch(crate::domain::Cmd::RefreshInstructions);
269 runner.dispatch(crate::domain::Cmd::RefreshMemory);
270
271 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 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, },
314 };
315
316 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 runner.shutdown().await;
334
335 outcome?;
336
337 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
354async 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 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
403fn 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
415fn 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 let _ = computer_use::probe;
450 let _ = providers;
451 Arc::new(r)
452}
453
454fn 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 use crate::runtime::SafetyMode;
493 let mut config = crate::app::Config::default();
494 config.safety.mode = SafetyMode::FullAccess; let mut child_state = State::new(config, PathBuf::from("/tmp"), "ollama/test".to_string());
496 assert_eq!(child_state.session.safety_mode, SafetyMode::FullAccess);
498 child_state.session.safety_mode = SafetyMode::Ask;
500 assert_eq!(child_state.session.safety_mode, SafetyMode::Ask);
501 }
502
503 #[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 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 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 assert!(r.get("agent").is_none());
537 assert!(r.get("read_file").is_some());
539 assert!(r.get("execute_command").is_some());
540 }
541}