1use mermaid_domain::{ProgressEvent, SubagentPhase};
40use std::collections::{HashMap, VecDeque};
41use std::sync::Arc;
42use std::sync::Mutex;
43use std::sync::atomic::{AtomicU64, Ordering};
44use std::time::{Duration, Instant};
45
46use async_trait::async_trait;
47use serde_json::Value;
48use tokio::sync::{Semaphore, mpsc};
49use tokio_util::sync::CancellationToken;
50
51use crate::effect::{EffectRunner, MSG_CHANNEL_CAPACITY};
52use crate::providers::ProviderFactory;
53use crate::providers::ctx::ExecContext;
54use mermaid_domain::{
55 Msg, State, TokenUsageTotals, ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata,
56 TurnState, update,
57};
58use mermaid_model::models::MessageRole;
59use mermaid_runtime::SafetyMode;
60
61use super::ToolExecutor;
62use super::ToolRegistry;
63use super::web::WebCapabilities;
64use super::workspace::{Isolation, MergeContext, Workspace, WorkspaceReport};
65
66pub const MAX_INFLIGHT: usize = 10;
71
72pub const DEFAULT_TIMEOUT_SECS: u64 = 20 * 60;
76
77pub const MAX_CACHED_AGENTS: usize = 8;
80
81const EXPLORE_PREAMBLE: &str = "\
83## Explore Agent
84You are an Explore agent: read-only reconnaissance. Locate files, map \
85structure, and extract exactly the facts asked for, using reads and \
86read-only commands. You cannot mutate anything — do not try. Report \
87concrete paths, names, and findings.";
88
89const CHILD_TOOL_NAMES: &[&str] = &[
94 "read_file",
95 "write_file",
96 "apply_patch",
97 "delete_file",
98 "create_directory",
99 "execute_command",
100 "web_search",
101 "web_fetch",
102 "mcp",
103];
104
105#[derive(Debug)]
109struct AgentType {
110 name: String,
111 tools: Option<Vec<String>>,
113 safety_ceiling: SafetyMode,
116 preamble: Option<String>,
118 model: Option<String>,
120 isolation: Isolation,
122}
123
124impl AgentType {
125 fn allows_tool(&self, name: &str) -> bool {
126 self.tools
127 .as_ref()
128 .is_none_or(|tools| tools.iter().any(|t| t == name))
129 }
130}
131
132fn builtin_agent_type(name: &str) -> Option<AgentType> {
133 match name {
134 "general" => Some(AgentType {
139 name: "general".to_string(),
140 tools: None,
141 safety_ceiling: SafetyMode::FullAccess,
142 preamble: None,
143 model: None,
144 isolation: Isolation::Shared,
145 }),
146 "explore" => Some(AgentType {
149 name: "explore".to_string(),
150 tools: Some(vec!["read_file".to_string(), "execute_command".to_string()]),
151 safety_ceiling: SafetyMode::ReadOnly,
152 preamble: Some(EXPLORE_PREAMBLE.to_string()),
153 model: None,
154 isolation: Isolation::Shared,
155 }),
156 _ => None,
157 }
158}
159
160const ISOLATED_PREAMBLE: &str = "\
164## Isolated Workspace
165You are working in a private copy of the project, seeded with the user's \
166current uncommitted state. Your edits are invisible to the user and to any \
167other agent until you finish, at which point they are applied to the real \
168project as one patch. Work normally and use ordinary paths. Do not try to \
169reach outside this directory to \"really\" apply your changes, and do not \
170commit: finishing is what lands the work.";
171
172fn resolve_agent_type(
176 requested: Option<&str>,
177 config: &mermaid_domain::Config,
178) -> Result<AgentType, String> {
179 let name = requested.unwrap_or("general");
180 if let Some(custom) = config.agents.types.get(name) {
181 let safety_ceiling = match custom.safety.as_deref() {
182 None => SafetyMode::FullAccess,
183 Some(s) => SafetyMode::parse(s).ok_or_else(|| {
184 format!(
185 "[agents.types.{name}] safety '{s}' is not one of \
186 read_only/ask/auto/full_access"
187 )
188 })?,
189 };
190 if let Some(tools) = &custom.tools
191 && let Some(bad) = tools
192 .iter()
193 .find(|t| !CHILD_TOOL_NAMES.contains(&t.as_str()))
194 {
195 return Err(format!(
196 "[agents.types.{name}] unknown tool '{bad}'; valid tools: {}",
197 CHILD_TOOL_NAMES.join(", ")
198 ));
199 }
200 let isolation = match custom.isolation.as_deref() {
201 None => Isolation::default(),
202 Some(s) => Isolation::parse(s).ok_or_else(|| {
203 format!(
204 "[agents.types.{name}] isolation '{s}' is not one of {}",
205 Isolation::NAMES
206 )
207 })?,
208 };
209 return Ok(AgentType {
210 name: name.to_string(),
211 tools: custom.tools.clone(),
212 safety_ceiling,
213 preamble: custom.preamble.clone(),
214 model: custom.model.clone(),
215 isolation,
216 });
217 }
218 builtin_agent_type(name).ok_or_else(|| {
219 let mut available: Vec<&str> = vec!["general", "explore"];
220 available.extend(config.agents.types.keys().map(String::as_str));
221 format!(
222 "unknown agent type '{name}'; available: {}",
223 available.join(", ")
224 )
225 })
226}
227
228struct CachedAgent {
232 state: State,
233 type_name: String,
234 workspace: Workspace,
238}
239
240#[derive(Default)]
241struct AgentCache {
242 entries: HashMap<String, CachedAgent>,
243 order: VecDeque<String>,
245}
246
247pub struct SubagentSpawner {
249 providers: Arc<ProviderFactory>,
250 web_capabilities: Arc<WebCapabilities>,
251 inflight: Arc<Semaphore>,
252 next_agent_id: AtomicU64,
254 cache: Mutex<AgentCache>,
258 detached_cancels: Mutex<HashMap<String, CancellationToken>>,
262}
263
264#[derive(Debug)]
266pub(crate) enum KillResult {
267 Killed,
270 Evicted(Workspace),
274 NotFound,
275}
276
277impl SubagentSpawner {
278 pub fn new(providers: Arc<ProviderFactory>, web_capabilities: Arc<WebCapabilities>) -> Self {
279 Self {
280 providers,
281 web_capabilities,
282 inflight: Arc::new(Semaphore::new(MAX_INFLIGHT)),
283 next_agent_id: AtomicU64::new(0),
284 cache: Mutex::new(AgentCache::default()),
285 detached_cancels: Mutex::new(HashMap::new()),
286 }
287 }
288
289 fn mint_agent_id(&self) -> String {
290 format!(
291 "a{}",
292 self.next_agent_id.fetch_add(1, Ordering::Relaxed) + 1
293 )
294 }
295
296 fn cache_take(&self, id: &str) -> Option<CachedAgent> {
298 let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
299 cache.order.retain(|x| x != id);
300 cache.entries.remove(id)
301 }
302
303 #[must_use = "an evicted workspace owns a checkout that has to be discarded"]
310 fn cache_store(&self, id: String, agent: CachedAgent) -> Vec<Workspace> {
311 let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
312 cache.order.retain(|x| x != &id);
313 cache.order.push_back(id.clone());
314 cache.entries.insert(id, agent);
315 let mut evicted = Vec::new();
316 while cache.entries.len() > MAX_CACHED_AGENTS {
317 let Some(oldest) = cache.order.pop_front() else {
318 break;
319 };
320 if let Some(agent) = cache.entries.remove(&oldest) {
321 evicted.push(agent.workspace);
322 }
323 }
324 evicted
325 }
326
327 fn register_detached(&self, agent_id: String, cancel: CancellationToken) {
328 self.detached_cancels
329 .lock()
330 .unwrap_or_else(|e| e.into_inner())
331 .insert(agent_id, cancel);
332 }
333
334 fn unregister_detached(&self, agent_id: &str) {
335 self.detached_cancels
336 .lock()
337 .unwrap_or_else(|e| e.into_inner())
338 .remove(agent_id);
339 }
340
341 pub(crate) fn kill_detached(&self, agent_id: &str) -> KillResult {
346 let cancel = self
347 .detached_cancels
348 .lock()
349 .unwrap_or_else(|e| e.into_inner())
350 .remove(agent_id);
351 if let Some(cancel) = cancel {
352 cancel.cancel();
353 return KillResult::Killed;
354 }
355 if let Some(evicted) = self.cache_take(agent_id) {
356 return KillResult::Evicted(evicted.workspace);
357 }
358 KillResult::NotFound
359 }
360
361 pub fn kill_all_detached(&self) -> usize {
363 let cancels: Vec<CancellationToken> = {
364 let mut map = self
365 .detached_cancels
366 .lock()
367 .unwrap_or_else(|e| e.into_inner());
368 map.drain().map(|(_, c)| c).collect()
369 };
370 let n = cancels.len();
371 for cancel in cancels {
372 cancel.cancel();
373 }
374 n
375 }
376}
377
378pub struct SubagentTool {
380 spawner: Arc<SubagentSpawner>,
381}
382
383impl SubagentTool {
384 pub fn new(spawner: Arc<SubagentSpawner>) -> Self {
385 Self { spawner }
386 }
387}
388
389#[expect(
390 clippy::too_many_lines,
391 reason = "predates the lint; see .github/baselines/expect_budget.txt"
392)]
393#[async_trait]
394impl ToolExecutor for SubagentTool {
395 fn name(&self) -> &'static str {
396 "agent"
397 }
398
399 fn schema(&self) -> ToolDefinition {
400 ToolDefinition {
401 name: "agent".to_string(),
402 description: format!(
403 "Spawn a child agent with its own context and tool access to work on an \
404 independent sub-task. Useful for parallel fan-out (emit multiple `agent` \
405 calls in the same turn to run them concurrently) or for scoping a noisy \
406 sub-task (the child's tool output doesn't clutter the parent's turn). \
407 Types: 'general' (default — full tool access at your safety mode) and \
408 'explore' (read-only reconnaissance: locate files and extract facts, \
409 cannot mutate), plus any defined in config [agents.types]. Every result \
410 ends with an [agent_id: …] trailer; pass that id back as `agent_id` to \
411 send a follow-up prompt to the same child with its context intact (the \
412 {MAX_CACHED_AGENTS} most recent children are kept). Breadth-capped at \
413 {MAX_INFLIGHT} concurrent; subagents can't themselves spawn subagents \
414 and never get GUI (screenshot/click/…) access. A child moved to the \
415 background (the user detaches one with Ctrl+B) can be cancelled with \
416 action: \"kill\" plus its agent_id.",
417 ),
418 input_schema: serde_json::json!({
419 "type": "object",
420 "properties": {
421 "action": {
422 "type": "string",
423 "enum": ["spawn", "kill"],
424 "description": "Default 'spawn' (also covers continuing via agent_id). 'kill' cancels a backgrounded child by agent_id — no prompt needed."
425 },
426 "prompt": {
427 "type": "string",
428 "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."
429 },
430 "description": {
431 "type": "string",
432 "description": "Short label shown in the parent's status line (e.g. 'list domain files')."
433 },
434 "type": {
435 "type": "string",
436 "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."
437 },
438 "model": {
439 "type": "string",
440 "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."
441 },
442 "isolation": {
443 "type": "string",
444 "enum": ["shared", "worktree"],
445 "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."
446 },
447 "agent_id": {
448 "type": "string",
449 "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."
450 }
451 },
452 "required": []
453 }),
454 }
455 }
456
457 async fn execute(&self, args: Value, ctx: ExecContext) -> ToolOutcome {
458 let started = Instant::now();
459
460 if args.get("action").and_then(|v| v.as_str()) == Some("kill") {
467 let Some(id) = args
468 .get("agent_id")
469 .and_then(|v| v.as_str())
470 .map(str::trim)
471 .filter(|s| !s.is_empty())
472 else {
473 return ToolOutcome::error("action 'kill' requires `agent_id`", 0.0);
474 };
475 return match self.spawner.kill_detached(id) {
476 KillResult::Killed => ToolOutcome::success(
477 format!(
478 "Background agent '{id}' cancelled — it unwinds at its next \
479 await point; a cancellation notice will appear in the \
480 conversation."
481 ),
482 "subagent killed",
483 started.elapsed().as_secs_f64(),
484 ),
485 KillResult::Evicted(workspace) => {
486 workspace.discard().await;
490 ToolOutcome::success(
491 format!(
492 "Agent '{id}' had already finished; removed it from the \
493 continuation cache instead."
494 ),
495 "subagent evicted",
496 started.elapsed().as_secs_f64(),
497 )
498 },
499 KillResult::NotFound => ToolOutcome::error(
500 format!(
501 "no background or cached agent '{id}' — it may have already \
502 finished and been evicted, or the id was never issued"
503 ),
504 started.elapsed().as_secs_f64(),
505 ),
506 };
507 }
508
509 let prompt = match args.get("prompt").and_then(|v| v.as_str()) {
511 Some(s) if !s.trim().is_empty() => s.to_string(),
512 _ => {
513 return ToolOutcome::error("agent requires non-empty `prompt`", 0.0);
514 },
515 };
516 let description = args
517 .get("description")
518 .and_then(|v| v.as_str())
519 .unwrap_or("subagent")
520 .to_string();
521 let requested_type = args
522 .get("type")
523 .and_then(|v| v.as_str())
524 .map(str::trim)
525 .filter(|s| !s.is_empty());
526 let model_override = args
527 .get("model")
528 .and_then(|v| v.as_str())
529 .map(str::trim)
530 .filter(|s| !s.is_empty());
531 let isolation_override = match args
532 .get("isolation")
533 .and_then(|v| v.as_str())
534 .map(str::trim)
535 .filter(|s| !s.is_empty())
536 {
537 None => None,
538 Some(raw) => match Isolation::parse(raw) {
539 Some(mode) => Some(mode),
540 None => {
541 return ToolOutcome::error(
542 format!("isolation '{raw}' is not one of {}", Isolation::NAMES),
543 started.elapsed().as_secs_f64(),
544 );
545 },
546 },
547 };
548 let continue_id = args
549 .get("agent_id")
550 .and_then(|v| v.as_str())
551 .map(str::trim)
552 .filter(|s| !s.is_empty())
553 .map(str::to_string);
554
555 if let Some(blocked) = super::policy_gate::gate_external(
562 &ctx,
563 "agent",
564 mermaid_runtime::ToolCategory::Subagent,
565 format!("subagent: {description}"),
566 &args,
567 )
568 .await
569 {
570 return blocked;
571 }
572
573 let permit = tokio::select! {
577 biased;
578 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
579 p = self.spawner.inflight.clone().acquire_owned() => match p {
580 Ok(permit) => permit,
581 Err(_) => return ToolOutcome::error(
582 "subagent semaphore closed",
583 started.elapsed().as_secs_f64(),
584 ),
585 },
586 };
587
588 let config = (*ctx.config).clone();
598
599 let (agent_id, cached) = match continue_id {
604 Some(id) => match self.spawner.cache_take(&id) {
605 Some(cached) => (id, Some(cached)),
606 None => {
607 return ToolOutcome::error(
608 format!(
609 "unknown agent_id '{id}': it may have expired (the \
610 {MAX_CACHED_AGENTS} most recent children are kept), be running \
611 a continuation right now, or never have existed. Omit agent_id \
612 to start a new agent."
613 ),
614 started.elapsed().as_secs_f64(),
615 );
616 },
617 },
618 None => (self.spawner.mint_agent_id(), None),
619 };
620
621 let type_name = cached
625 .as_ref()
626 .map(|c| c.type_name.clone())
627 .or_else(|| requested_type.map(str::to_string));
628 let agent_type = match resolve_agent_type(type_name.as_deref(), &config) {
629 Ok(agent_type) => agent_type,
630 Err(e) => {
631 if let Some(cached) = cached {
635 let evicted = self.spawner.cache_store(agent_id, cached);
636 debug_assert!(evicted.is_empty());
637 }
638 return ToolOutcome::error(e, started.elapsed().as_secs_f64());
639 },
640 };
641
642 let child_safety = SafetyMode::least_permissive(ctx.safety_mode, agent_type.safety_ceiling);
651
652 let (workspace, cached) = match cached {
656 Some(cached) => (cached.workspace, Some(cached.state)),
657 None => {
658 let isolation = isolation_override.unwrap_or(agent_type.isolation);
659 match Workspace::create(isolation, ctx.workdir.clone(), &agent_id).await {
660 Ok(workspace) => (workspace, None),
661 Err(e) => {
662 return ToolOutcome::error(e, started.elapsed().as_secs_f64());
663 },
664 }
665 },
666 };
667 let cwd = workspace.root().to_path_buf();
668
669 let model_id = model_override
671 .map(str::to_string)
672 .or_else(|| agent_type.model.clone())
673 .unwrap_or_else(|| {
674 if ctx.model_id.is_empty() {
675 default_model_id(&config)
676 } else {
677 ctx.model_id.clone()
678 }
679 });
680
681 let (mut child_state, usage_before) = match cached {
682 Some(state) => {
683 let before = state.session.cumulative_token_usage;
686 (state, before)
687 },
688 None => (
689 State::new(
690 config.clone(),
691 cwd.clone(),
692 model_id.clone(),
693 chrono::Local::now(),
694 std::env::temp_dir(),
695 ),
696 TokenUsageTotals::default(),
697 ),
698 };
699 if let Some(model) = model_override {
702 child_state.session.model_id = model.to_string();
703 }
704 let child_model_id = child_state.session.model_id.clone();
705
706 child_state.now = chrono::Local::now();
714 child_state.session.safety_mode = child_safety;
715 child_state.session.is_subagent = true;
719 child_state.session.agent_preamble = match (&agent_type.preamble, workspace.is_isolated()) {
723 (_, false) => agent_type.preamble.clone(),
724 (None, true) => Some(ISOLATED_PREAMBLE.to_string()),
725 (Some(preamble), true) => Some(format!("{preamble}\n\n{ISOLATED_PREAMBLE}")),
726 };
727 child_state.session.scratchpad = ctx.scratchpad.clone();
734 let (instructions, memory, skills) =
735 crate::app::instructions::load_project_context(&cwd, &config.memory);
736 child_state.instructions = instructions;
737 child_state.memory = memory;
738 child_state.skills = skills;
739 if agent_type.allows_tool("mcp") {
749 seed_child_mcp(&mut child_state);
750 }
751
752 let child_tools = build_child_registry(
753 self.spawner.providers.clone(),
754 agent_type.tools.as_deref(),
755 &config,
756 child_safety,
757 &self.spawner.web_capabilities,
758 );
759
760 let child_cancel = CancellationToken::new();
765 let (child_tx, child_rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
766 let child_runner =
767 EffectRunner::new_child(child_tx, cwd, self.spawner.providers.clone(), child_tools);
768
769 let timeout_secs = match config.agents.timeout_secs {
773 0 => DEFAULT_TIMEOUT_SECS,
774 secs => secs,
775 };
776 let (child_progress_tx, mut child_progress_rx) = mpsc::channel::<ProgressEvent>(16);
779 let mut drive = Box::pin(drive_child(
780 child_state,
781 child_runner,
782 child_rx,
783 child_progress_tx,
784 prompt,
785 child_cancel.clone(),
786 Duration::from_secs(timeout_secs),
787 ));
788
789 let mut progress_open = true;
790 let (result, final_state) = loop {
791 tokio::select! {
792 biased;
793 _ = ctx.token.cancelled() => {
794 child_cancel.cancel();
798 break drive.await;
799 },
800 _ = ctx.background.cancelled() => {
801 return self.detach_child(DetachArgs {
806 drive,
807 progress_rx: child_progress_rx,
808 permit,
809 cancel: child_cancel.clone(),
810 notify: ctx.notify.clone(),
811 agent_id,
812 description,
813 type_name: agent_type.name.clone(),
814 child_model_id,
815 usage_before,
816 timeout_secs,
817 started,
818 workspace,
819 merge_cx: MergeContext::from_exec(&ctx),
820 });
821 },
822 ev = child_progress_rx.recv(), if progress_open => match ev {
823 Some(ev) => { let _ = ctx.progress.send(ev).await; },
824 None => progress_open = false,
825 },
826 r = &mut drive => break r,
827 }
828 };
829 drop(permit);
830
831 finish_drive(
832 &self.spawner,
833 agent_type.name.clone(),
834 agent_id,
835 &description,
836 child_model_id,
837 usage_before,
838 timeout_secs,
839 started,
840 result,
841 final_state,
842 workspace,
843 MergeContext::from_exec(&ctx),
844 )
845 .await
846 }
847}
848
849struct DetachArgs<F> {
852 drive: std::pin::Pin<Box<F>>,
853 progress_rx: mpsc::Receiver<ProgressEvent>,
854 permit: tokio::sync::OwnedSemaphorePermit,
855 cancel: CancellationToken,
858 notify: Option<mpsc::Sender<Msg>>,
859 agent_id: String,
860 description: String,
861 type_name: String,
862 child_model_id: String,
863 usage_before: TokenUsageTotals,
864 timeout_secs: u64,
865 started: Instant,
866 workspace: Workspace,
870 merge_cx: MergeContext,
871}
872
873impl SubagentTool {
874 #[expect(
880 clippy::too_many_lines,
881 reason = "predates the lint; see .github/baselines/expect_budget.txt"
882 )]
883 fn detach_child<F>(&self, args: DetachArgs<F>) -> ToolOutcome
884 where
885 F: std::future::Future<Output = (Result<String, DriveError>, State)> + Send + 'static,
886 {
887 let DetachArgs {
888 mut drive,
889 mut progress_rx,
890 permit,
891 cancel,
892 notify,
893 agent_id,
894 description,
895 type_name,
896 child_model_id,
897 usage_before,
898 timeout_secs,
899 started,
900 workspace,
901 merge_cx,
902 } = args;
903 if let Some(notify) = ¬ify {
904 let _ = notify.try_send(Msg::BackgroundAgentStarted {
905 agent_id: agent_id.clone(),
906 description: description.clone(),
907 });
908 }
909 let spawner = self.spawner.clone();
910 spawner.register_detached(agent_id.clone(), cancel);
913 let outcome_text = format!(
914 "Agent '{description}' ({agent_id}) moved to background — it keeps running and \
915 its report will be posted to the conversation when it finishes."
916 );
917 let (bg_agent_id, bg_description) = (agent_id, description);
918 tokio::spawn(async move {
919 let _permit = permit;
922 let mut activity = String::new();
923 let mut tokens = 0usize;
924 let mut progress_open = true;
925 let (result, final_state) = loop {
926 tokio::select! {
927 ev = progress_rx.recv(), if progress_open => match ev {
928 Some(ev) => {
929 match &ev {
930 ProgressEvent::SubagentToolCall { tool_name, phase, .. } => {
931 activity = match phase {
932 SubagentPhase::Started => format!("{tool_name}…"),
933 SubagentPhase::Finished => format!("{tool_name} done"),
934 SubagentPhase::Errored => format!("{tool_name} failed"),
935 };
936 },
937 ProgressEvent::SubagentActivity(label) => activity = label.clone(),
938 ProgressEvent::SubagentTokens(count) => tokens = *count,
939 _ => continue,
940 }
941 if let Some(notify) = ¬ify {
942 let _ = notify.try_send(Msg::BackgroundAgentProgress {
943 agent_id: bg_agent_id.clone(),
944 activity: activity.clone(),
945 tokens,
946 });
947 }
948 },
949 None => progress_open = false,
950 },
951 r = &mut drive => break r,
952 }
953 };
954 spawner.unregister_detached(&bg_agent_id);
955 let cancelled = matches!(result, Err(DriveError::Cancelled));
956 let outcome = finish_drive(
957 &spawner,
958 type_name,
959 bg_agent_id.clone(),
960 &bg_description,
961 child_model_id,
962 usage_before,
963 timeout_secs,
964 started,
965 result,
966 final_state,
967 workspace,
968 merge_cx,
969 )
970 .await;
971 if let Some(notify) = notify {
972 let usage = outcome.metadata.token_usage.clone();
973 let tokens_total = usage.as_ref().map_or(tokens, |u| u.total_tokens());
974 let _ = notify
975 .send(Msg::BackgroundAgentFinished {
976 agent_id: bg_agent_id,
977 description: bg_description,
978 report: outcome.model_content.clone(),
979 success: outcome.is_success(),
980 cancelled,
981 usage,
982 tokens: tokens_total,
983 duration_secs: started.elapsed().as_secs(),
984 })
985 .await;
986 }
987 });
988 ToolOutcome::success(
989 outcome_text,
990 "subagent backgrounded",
991 started.elapsed().as_secs_f64(),
992 )
993 }
994}
995
996#[expect(clippy::too_many_arguments)]
1001async fn finish_drive(
1002 spawner: &SubagentSpawner,
1003 type_name: String,
1004 agent_id: String,
1005 description: &str,
1006 child_model_id: String,
1007 usage_before: TokenUsageTotals,
1008 timeout_secs: u64,
1009 started: Instant,
1010 result: Result<String, DriveError>,
1011 mut final_state: State,
1012 workspace: Workspace,
1013 merge_cx: MergeContext,
1014) -> ToolOutcome {
1015 let child_usage = usage_delta(final_state.session.cumulative_token_usage, usage_before);
1016
1017 let (workspace, workspace_report) = match &result {
1022 Ok(_) => workspace.merge(&merge_cx).await,
1023 Err(DriveError::Cancelled) => (workspace, WorkspaceReport::default()),
1024 Err(_) => {
1025 let note = workspace.unmerged_note();
1026 let report = WorkspaceReport {
1027 note,
1028 needs_attention: false,
1029 };
1030 (workspace, report)
1031 },
1032 };
1033
1034 if matches!(result, Err(DriveError::Cancelled)) {
1041 workspace.discard().await;
1044 } else {
1045 final_state.turn = TurnState::Idle;
1046 final_state.ui.queued_messages.clear();
1047 final_state.ui.live_tool_status.clear();
1048 final_state.pending_approval.clear();
1049 let evicted = spawner.cache_store(
1050 agent_id.clone(),
1051 CachedAgent {
1052 state: final_state,
1053 type_name,
1054 workspace,
1055 },
1056 );
1057 for workspace in evicted {
1058 workspace.discard().await;
1059 }
1060 }
1061
1062 let elapsed = started.elapsed().as_secs_f64();
1063 let trailer = format!("[agent_id: {agent_id} — pass agent_id to continue this child]");
1064 let metadata = subagent_metadata(child_model_id, child_usage, agent_id);
1065 let trailer = if workspace_report.note.is_empty() {
1068 trailer
1069 } else {
1070 format!("{}\n\n{trailer}", workspace_report.note)
1071 };
1072 match result {
1073 Ok(summary) if workspace_report.needs_attention => ToolOutcome::error(
1078 format!("subagent ({description}) finished but its work did not land.\n\n{summary}\n\n{trailer}"),
1079 elapsed,
1080 )
1081 .with_metadata(metadata),
1082 Ok(summary) => ToolOutcome::success(
1083 format!("{summary}\n\n{trailer}"),
1084 "subagent completed",
1085 elapsed,
1086 )
1087 .with_metadata(metadata),
1088 Err(DriveError::Cancelled) => ToolOutcome::cancelled(),
1089 Err(DriveError::TimedOut) => ToolOutcome::error(
1090 format!(
1091 "subagent ({description}) exceeded {timeout_secs}s timeout; its context \
1092 is preserved — {trailer}"
1093 ),
1094 elapsed,
1095 )
1096 .with_metadata(metadata),
1097 Err(DriveError::Errored(e)) => {
1098 ToolOutcome::error(format!("subagent ({description}): {e} {trailer}"), elapsed)
1099 .with_metadata(metadata)
1100 },
1101 }
1102}
1103
1104fn subagent_metadata(
1112 model_id: String,
1113 usage: TokenUsageTotals,
1114 agent_id: String,
1115) -> ToolRunMetadata {
1116 let token_usage = (usage.total_tokens() > 0).then(|| mermaid_model::models::TokenUsage {
1117 prompt_tokens: usage.prompt_tokens,
1118 completion_tokens: usage.completion_tokens,
1119 cached_input_tokens: usage.cached_input_tokens,
1120 cache_creation_input_tokens: usage.cache_creation_input_tokens,
1121 reasoning_output_tokens: usage.reasoning_output_tokens,
1122 source: Default::default(),
1123 });
1124 ToolRunMetadata {
1125 detail: ToolMetadata::Subagent { model_id, agent_id },
1126 token_usage,
1127 ..ToolRunMetadata::default()
1128 }
1129}
1130
1131fn usage_delta(after: TokenUsageTotals, before: TokenUsageTotals) -> TokenUsageTotals {
1136 TokenUsageTotals {
1137 prompt_tokens: after.prompt_tokens.saturating_sub(before.prompt_tokens),
1138 completion_tokens: after
1139 .completion_tokens
1140 .saturating_sub(before.completion_tokens),
1141 cached_input_tokens: after
1142 .cached_input_tokens
1143 .saturating_sub(before.cached_input_tokens),
1144 cache_creation_input_tokens: after
1145 .cache_creation_input_tokens
1146 .saturating_sub(before.cache_creation_input_tokens),
1147 reasoning_output_tokens: after
1148 .reasoning_output_tokens
1149 .saturating_sub(before.reasoning_output_tokens),
1150 }
1151}
1152
1153enum DriveError {
1154 Cancelled,
1155 TimedOut,
1156 Errored(String),
1157}
1158
1159async fn drive_child(
1167 mut state: State,
1168 mut runner: EffectRunner,
1169 mut msg_rx: mpsc::Receiver<Msg>,
1170 parent_progress: mpsc::Sender<ProgressEvent>,
1171 prompt: String,
1172 token: CancellationToken,
1173 timeout: Duration,
1174) -> (Result<String, DriveError>, State) {
1175 let _ = parent_progress
1178 .send(ProgressEvent::SubagentActivity("starting…".to_string()))
1179 .await;
1180
1181 let seed = Msg::SubmitPrompt {
1188 text: prompt,
1189 attachment_ids: vec![],
1190 };
1191 let (new_state, cmds) = update(state, seed);
1192 state = new_state;
1193 for cmd in cmds {
1194 runner.dispatch(cmd);
1195 }
1196
1197 let deadline = tokio::time::sleep(timeout);
1203 tokio::pin!(deadline);
1204
1205 let mut outcome: Result<(), DriveError> = Ok(());
1206 let mut child_progress = ChildProgress::new(tokio::time::Instant::now());
1207 loop {
1208 if token.is_cancelled() {
1209 outcome = Err(DriveError::Cancelled);
1210 break;
1211 }
1212 if matches!(state.turn, TurnState::Idle) && state.ui.queued_messages.is_empty() {
1213 break;
1214 }
1215
1216 let msg = tokio::select! {
1217 biased;
1218 _ = token.cancelled() => {
1219 outcome = Err(DriveError::Cancelled);
1220 break;
1221 },
1222 _ = &mut deadline => {
1223 outcome = Err(DriveError::TimedOut);
1224 break;
1225 },
1226 recv = msg_rx.recv() => match recv {
1227 Some(m) => m,
1228 None => break, },
1230 };
1231
1232 for event in child_progress.observe(&msg, &state, tokio::time::Instant::now()) {
1236 let _ = parent_progress.send(event).await;
1237 }
1238
1239 let (new_state, cmds) = update(state, msg);
1240 state = new_state;
1241 for cmd in cmds {
1242 runner.dispatch(cmd);
1243 }
1244 if state.should_exit {
1245 break;
1246 }
1247 }
1248
1249 runner.shutdown().await;
1254
1255 if let Err(e) = outcome {
1256 return (Err(e), state);
1257 }
1258
1259 let summary = state
1261 .session
1262 .messages()
1263 .iter()
1264 .rev()
1265 .find(|m| m.role == MessageRole::Assistant)
1266 .map(|m| m.content.clone())
1267 .unwrap_or_default();
1268 if summary.trim().is_empty() {
1269 return (
1270 Err(DriveError::Errored(
1271 "subagent produced no assistant output".to_string(),
1272 )),
1273 state,
1274 );
1275 }
1276 (Ok(summary), state)
1277}
1278
1279const TOKEN_PROGRESS_INTERVAL: Duration = Duration::from_millis(500);
1283
1284struct ChildProgress {
1296 phase: &'static str,
1297 confirmed_tokens: usize,
1299 streamed_chars: usize,
1302 last_tokens_sent: usize,
1303 last_tokens_at: tokio::time::Instant,
1304}
1305
1306impl ChildProgress {
1307 fn new(now: tokio::time::Instant) -> Self {
1308 Self {
1309 phase: "",
1310 confirmed_tokens: 0,
1311 streamed_chars: 0,
1312 last_tokens_sent: 0,
1313 last_tokens_at: now,
1314 }
1315 }
1316
1317 fn total_tokens(&self) -> usize {
1318 self.confirmed_tokens + self.streamed_chars / 4
1319 }
1320
1321 fn observe(
1324 &mut self,
1325 msg: &Msg,
1326 state: &State,
1327 now: tokio::time::Instant,
1328 ) -> Vec<ProgressEvent> {
1329 let mut out = Vec::new();
1330 match msg {
1331 Msg::ToolStarted {
1332 turn: _, call_id, ..
1333 } => {
1334 let tool_name =
1335 lookup_tool_name(state, *call_id).unwrap_or_else(|| "tool".to_string());
1336 out.push(ProgressEvent::SubagentToolCall {
1337 child_call_id: *call_id,
1338 tool_name,
1339 phase: SubagentPhase::Started,
1340 });
1341 self.phase = "";
1343 },
1344 Msg::ToolFinished {
1345 turn: _,
1346 call_id,
1347 outcome,
1348 } => {
1349 let tool_name =
1350 lookup_tool_name(state, *call_id).unwrap_or_else(|| "tool".to_string());
1351 let phase = if outcome.is_success() {
1352 SubagentPhase::Finished
1353 } else {
1354 SubagentPhase::Errored
1355 };
1356 out.push(ProgressEvent::SubagentToolCall {
1357 child_call_id: *call_id,
1358 tool_name,
1359 phase,
1360 });
1361 self.phase = "";
1362 },
1363 Msg::StreamReasoning { chunk, .. } => {
1364 self.streamed_chars += chunk.text.len();
1365 self.set_phase("thinking", &mut out);
1366 },
1367 Msg::StreamText { chunk, .. } => {
1368 self.streamed_chars += chunk.len();
1369 self.set_phase("replying", &mut out);
1370 },
1371 Msg::StreamDone {
1372 usage: Some(usage), ..
1373 } => {
1374 self.confirmed_tokens += usage
1375 .completion_tokens
1376 .saturating_add(usage.reasoning_output_tokens);
1377 self.streamed_chars = 0;
1378 },
1379 _ => {},
1380 }
1381 let total = self.total_tokens();
1384 let due = now.duration_since(self.last_tokens_at) >= TOKEN_PROGRESS_INTERVAL;
1385 if total != self.last_tokens_sent && (due || !out.is_empty()) {
1386 out.push(ProgressEvent::SubagentTokens(total));
1387 self.last_tokens_sent = total;
1388 self.last_tokens_at = now;
1389 }
1390 out
1391 }
1392
1393 fn set_phase(&mut self, phase: &'static str, out: &mut Vec<ProgressEvent>) {
1394 if self.phase != phase {
1395 self.phase = phase;
1396 out.push(ProgressEvent::SubagentActivity(phase.to_string()));
1397 }
1398 }
1399}
1400
1401fn lookup_tool_name(state: &State, call_id: mermaid_domain::ToolCallId) -> Option<String> {
1404 match &state.turn {
1405 TurnState::ExecutingTools { calls, .. } => calls
1406 .iter()
1407 .find(|c| c.call_id == call_id)
1408 .map(|c| c.source.function.name.clone()),
1409 _ => None,
1410 }
1411}
1412
1413fn seed_child_mcp(state: &mut State) {
1422 let Some(manager) = crate::mcp::manager_ref::get() else {
1423 return;
1424 };
1425 apply_live_mcp(&mut state.mcp.servers, &manager.all_specs(), |name| {
1426 manager.has_server(name)
1427 });
1428}
1429
1430fn apply_live_mcp(
1436 servers: &mut std::collections::HashMap<String, mermaid_domain::McpServerEntry>,
1437 live_specs: &[(String, mermaid_domain::McpToolSpec)],
1438 has_server: impl Fn(&str) -> bool,
1439) {
1440 for (name, entry) in servers.iter_mut() {
1441 if !has_server(name) {
1442 continue;
1443 }
1444 entry.status = mermaid_domain::McpServerStatus::Ready;
1445 let cfg = &entry.config;
1446 let tools: Vec<mermaid_domain::McpToolSpec> = live_specs
1447 .iter()
1448 .filter(|(server, _)| server == name)
1449 .filter(|(_, spec)| cfg.tool_allowed(&spec.raw_name))
1452 .map(|(_, spec)| spec.clone())
1453 .collect();
1454 entry.tools = tools;
1455 }
1456}
1457
1458fn build_child_registry(
1474 providers: Arc<ProviderFactory>,
1475 tools: Option<&[String]>,
1476 config: &mermaid_domain::Config,
1477 safety_mode: SafetyMode,
1478 web: &WebCapabilities,
1479) -> Arc<ToolRegistry> {
1480 use super::{apply_patch, computer_use, exec, filesystem, mcp};
1481 let allowed = |name: &str| tools.is_none_or(|t| t.iter().any(|x| x == name));
1482 let mut r = ToolRegistry::new();
1483 if allowed("read_file") {
1484 r.register(Arc::new(filesystem::ReadFileTool));
1485 }
1486 if allowed("write_file") {
1487 r.register(Arc::new(filesystem::WriteFileTool));
1488 }
1489 if allowed("apply_patch") {
1490 r.register(Arc::new(apply_patch::ApplyPatchTool));
1491 }
1492 if allowed("delete_file") {
1493 r.register(Arc::new(filesystem::DeleteFileTool));
1494 }
1495 if allowed("create_directory") {
1496 r.register(Arc::new(filesystem::CreateDirectoryTool));
1497 }
1498 if allowed("execute_command") {
1499 r.register(Arc::new(exec::ExecuteCommandTool));
1500 }
1501 if allowed("mcp") {
1502 r.register(Arc::new(mcp::McpToolProxy));
1503 }
1504 let search_allowed =
1510 allowed("web_search") && headless_web_tool_is_executable(config, safety_mode, "web_search");
1511 let fetch_allowed =
1512 allowed("web_fetch") && headless_web_tool_is_executable(config, safety_mode, "web_fetch");
1513 if search_allowed || fetch_allowed {
1514 if search_allowed && let Some(tool) = web.search_tool() {
1515 r.register(Arc::new(tool));
1516 }
1517 if fetch_allowed && let Some(tool) = web.fetch_tool() {
1518 r.register(Arc::new(tool));
1519 }
1520 }
1521 let _ = computer_use::probe;
1526 let _ = providers;
1527 Arc::new(r)
1528}
1529
1530fn headless_web_tool_is_executable(
1534 config: &mermaid_domain::Config,
1535 safety_mode: SafetyMode,
1536 tool: &'static str,
1537) -> bool {
1538 use mermaid_runtime::{ActionRequest, PolicyDecision, PolicyEngine, ToolCategory};
1539
1540 if config.safety.network == mermaid_domain::NetworkPolicy::Deny {
1541 return false;
1542 }
1543 let request = ActionRequest::new(tool, ToolCategory::Web, tool);
1544 let decision = PolicyEngine::new(safety_mode)
1545 .with_overrides(config.safety.overrides.clone())
1546 .with_external_writes(config.safety.external_writes)
1547 .with_system_installs(config.safety.system_installs)
1548 .decide(&request);
1549 match decision {
1550 PolicyDecision::Allow { .. } => true,
1551 PolicyDecision::Classify { .. } => safety_mode == SafetyMode::Auto,
1553 PolicyDecision::Ask { .. } => {
1554 config.safety.allow_untrusted_headless_tools
1555 || (safety_mode == SafetyMode::ReadOnly && config.safety.allow_readonly_web)
1556 },
1557 PolicyDecision::Deny { .. } => false,
1558 }
1559}
1560
1561fn default_model_id(config: &mermaid_domain::Config) -> String {
1566 if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
1567 format!(
1568 "{}/{}",
1569 config.default_model.provider, config.default_model.name
1570 )
1571 } else {
1572 config.default_model.name.clone()
1573 }
1574}
1575
1576#[cfg(test)]
1577mod tests {
1578 use super::*;
1579 use crate::providers::ctx::test_exec_context;
1580 use mermaid_domain::{ToolCallId, TurnId};
1581 use std::path::PathBuf;
1582
1583 fn test_state() -> State {
1584 State::new(
1585 mermaid_domain::Config::default(),
1586 PathBuf::from("/tmp"),
1587 "ollama/test".to_string(),
1588 chrono::Local::now(),
1589 PathBuf::from("/tmp"),
1590 )
1591 }
1592
1593 fn test_spawner() -> SubagentSpawner {
1594 let config = mermaid_domain::Config::default();
1595 let providers = Arc::new(ProviderFactory::new(config.clone()));
1596 let web_capabilities = Arc::new(WebCapabilities::resolve(&config.web));
1597 SubagentSpawner::new(providers, web_capabilities)
1598 }
1599
1600 fn test_spawner_arc() -> Arc<SubagentSpawner> {
1601 Arc::new(test_spawner())
1602 }
1603
1604 fn stream_text(chunk: &str) -> Msg {
1605 Msg::StreamText {
1606 turn: TurnId(1),
1607 chunk: chunk.to_string(),
1608 }
1609 }
1610
1611 #[tokio::test]
1612 async fn child_stream_chunks_never_forward_text_only_one_phase_change() {
1613 let state = test_state();
1618 let now = tokio::time::Instant::now();
1619 let mut progress = ChildProgress::new(now);
1620
1621 let first = progress.observe(&stream_text("chunk one — some text"), &state, now);
1622 assert!(
1623 first.iter().any(
1624 |e| matches!(e, ProgressEvent::SubagentActivity(label) if label == "replying")
1625 ),
1626 "first chunk announces the phase: {first:?}"
1627 );
1628 assert!(
1629 !first
1630 .iter()
1631 .any(|e| matches!(e, ProgressEvent::SubagentToolCall { .. })),
1632 "no raw text ever forwards: {first:?}"
1633 );
1634
1635 for i in 0..50 {
1637 let events = progress.observe(&stream_text(&format!("chunk {i}")), &state, now);
1638 assert!(
1639 events.is_empty(),
1640 "chunk {i} must be silent inside the throttle window: {events:?}"
1641 );
1642 }
1643 }
1644
1645 #[tokio::test]
1646 async fn token_estimates_respect_the_throttle_and_snap_to_provider_usage() {
1647 let state = test_state();
1648 let start = tokio::time::Instant::now();
1649 let mut progress = ChildProgress::new(start);
1650
1651 let _ = progress.observe(&stream_text("xy"), &state, start);
1653 let silent = progress.observe(&stream_text(&"x".repeat(400)), &state, start);
1655 assert!(
1656 silent.is_empty(),
1657 "inside the window stays silent: {silent:?}"
1658 );
1659 let later = start + TOKEN_PROGRESS_INTERVAL;
1661 let events = progress.observe(&stream_text("y"), &state, later);
1662 assert!(
1663 events
1664 .iter()
1665 .any(|e| matches!(e, ProgressEvent::SubagentTokens(t) if *t >= 100)),
1666 "tokens flush after the interval: {events:?}"
1667 );
1668
1669 let done = Msg::StreamDone {
1672 turn: TurnId(1),
1673 usage: Some(mermaid_model::models::TokenUsage::provider(10, 5_000)),
1674 provider_continuation: None,
1675 stop_reason: None,
1676 };
1677 let much_later = later + TOKEN_PROGRESS_INTERVAL;
1678 let events = progress.observe(&done, &state, much_later);
1679 assert!(
1680 events
1681 .iter()
1682 .any(|e| matches!(e, ProgressEvent::SubagentTokens(t) if *t >= 5_000)),
1683 "provider usage snaps the counter: {events:?}"
1684 );
1685 }
1686
1687 #[tokio::test]
1688 async fn empty_prompt_is_rejected() {
1689 let spawner = test_spawner_arc();
1690 let tool = SubagentTool::new(spawner);
1691 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1692 let outcome = tool.execute(serde_json::json!({"prompt": " "}), ctx).await;
1693 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1694 }
1695
1696 #[test]
1697 fn child_state_inherits_live_safety_mode_over_config_default() {
1698 use mermaid_runtime::SafetyMode;
1702 let mut config = mermaid_domain::Config::default();
1703 config.safety.mode = SafetyMode::FullAccess; let mut child_state = State::new(
1705 config,
1706 PathBuf::from("/tmp"),
1707 "ollama/test".to_string(),
1708 chrono::Local::now(),
1709 PathBuf::from("/tmp"),
1710 );
1711 assert_eq!(child_state.session.safety_mode, SafetyMode::FullAccess);
1713 child_state.session.safety_mode = SafetyMode::Ask;
1715 assert_eq!(child_state.session.safety_mode, SafetyMode::Ask);
1716 }
1717
1718 #[test]
1719 fn child_state_inherits_the_parent_scratchpad() {
1720 let (mut ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1724 ctx.scratchpad = Some(PathBuf::from("/data/tmp/scratchpad/-proj/s"));
1725 let mut child_state = test_state();
1727 assert_eq!(child_state.session.scratchpad, None);
1728 child_state.session.scratchpad = ctx.scratchpad.clone();
1731 assert_eq!(
1732 child_state.session.scratchpad.as_deref(),
1733 Some(std::path::Path::new("/data/tmp/scratchpad/-proj/s"))
1734 );
1735 }
1736
1737 #[test]
1741 fn default_model_id_reads_config_provider_and_name() {
1742 let mut cfg = mermaid_domain::Config::default();
1743 cfg.default_model.provider = "ollama".to_string();
1744 cfg.default_model.name = "qwen3-coder:30b".to_string();
1745 assert_eq!(default_model_id(&cfg), "ollama/qwen3-coder:30b");
1746 }
1747
1748 #[test]
1749 fn default_model_id_returns_bare_name_when_provider_empty() {
1750 let mut cfg = mermaid_domain::Config::default();
1751 cfg.default_model.name = "just-a-name".to_string();
1752 assert_eq!(default_model_id(&cfg), "just-a-name");
1755 }
1756
1757 #[test]
1758 fn apply_live_mcp_marks_running_servers_ready_with_their_tools() {
1759 use mermaid_domain::{McpServerEntry, McpServerStatus};
1760 let entry = || McpServerEntry {
1761 config: mermaid_domain::McpServerConfig::default(),
1762 status: McpServerStatus::Starting,
1763 tools: Vec::new(),
1764 };
1765 let mut servers = std::collections::HashMap::new();
1766 servers.insert("slack".to_string(), entry());
1767 servers.insert("broken".to_string(), entry());
1768
1769 let live = vec![
1770 (
1771 "slack".to_string(),
1772 mermaid_domain::McpToolSpec {
1773 name: "mcp__slack__send".to_string(),
1774 raw_name: "send".to_string(),
1775 description: "send a message".to_string(),
1776 input_schema: serde_json::json!({"type": "object"}),
1777 read_only_hint: false,
1778 },
1779 ),
1780 (
1783 "other".to_string(),
1784 mermaid_domain::McpToolSpec {
1785 name: "mcp__other__x".to_string(),
1786 raw_name: "x".to_string(),
1787 description: String::new(),
1788 input_schema: serde_json::json!({}),
1789 read_only_hint: false,
1790 },
1791 ),
1792 ];
1793 apply_live_mcp(&mut servers, &live, |name| name == "slack");
1794
1795 let slack = &servers["slack"];
1796 assert_eq!(slack.status, McpServerStatus::Ready);
1797 assert_eq!(slack.tools.len(), 1);
1798 assert_eq!(slack.tools[0].name, "mcp__slack__send");
1799 assert_eq!(slack.tools[0].raw_name, "send");
1800 assert_eq!(servers["broken"].status, McpServerStatus::Starting);
1802 assert!(servers["broken"].tools.is_empty());
1803 assert!(!servers.contains_key("other"));
1804 }
1805
1806 #[test]
1807 fn subagent_metadata_carries_usage_only_when_reported() {
1808 let some = subagent_metadata(
1809 "ollama/test".to_string(),
1810 TokenUsageTotals {
1811 prompt_tokens: 100,
1812 completion_tokens: 40,
1813 ..TokenUsageTotals::default()
1814 },
1815 "a7".to_string(),
1816 );
1817 let usage = some.token_usage.expect("usage attached");
1818 assert_eq!(usage.total_tokens(), 140);
1819 assert_eq!(usage.completion_tokens, 40);
1820 assert!(matches!(
1821 some.detail,
1822 mermaid_domain::ToolMetadata::Subagent { ref model_id, ref agent_id }
1823 if model_id == "ollama/test" && agent_id == "a7"
1824 ));
1825 let none = subagent_metadata(
1827 "ollama/test".to_string(),
1828 TokenUsageTotals::default(),
1829 "a8".to_string(),
1830 );
1831 assert!(none.token_usage.is_none());
1832 }
1833
1834 #[test]
1835 fn usage_delta_reports_only_this_drive() {
1836 let before = TokenUsageTotals {
1839 prompt_tokens: 1_000,
1840 completion_tokens: 200,
1841 ..TokenUsageTotals::default()
1842 };
1843 let after = TokenUsageTotals {
1844 prompt_tokens: 1_600,
1845 completion_tokens: 350,
1846 ..TokenUsageTotals::default()
1847 };
1848 let delta = usage_delta(after, before);
1849 assert_eq!(delta.prompt_tokens, 600);
1850 assert_eq!(delta.completion_tokens, 150);
1851 assert_eq!(delta.total_tokens(), 750);
1852 let fresh = usage_delta(after, TokenUsageTotals::default());
1854 assert_eq!(fresh.total_tokens(), 1_950);
1855 }
1856
1857 #[test]
1858 fn resolve_agent_type_builtins_custom_shadowing_and_errors() {
1859 use mermaid_domain::AgentTypeConfig;
1860 let mut config = mermaid_domain::Config::default();
1861
1862 assert_eq!(resolve_agent_type(None, &config).unwrap().name, "general");
1864 assert_eq!(
1865 resolve_agent_type(None, &config).unwrap().safety_ceiling,
1866 SafetyMode::FullAccess,
1867 );
1868 let explore = resolve_agent_type(Some("explore"), &config).unwrap();
1869 assert_eq!(explore.safety_ceiling, SafetyMode::ReadOnly);
1870 assert!(explore.preamble.as_deref().unwrap().contains("read-only"));
1871 assert!(explore.allows_tool("read_file"));
1872 assert!(!explore.allows_tool("write_file"));
1873 assert!(!explore.allows_tool("mcp"));
1874
1875 let err = resolve_agent_type(Some("nope"), &config).unwrap_err();
1877 assert!(err.contains("general") && err.contains("explore"), "{err}");
1878
1879 config.agents.types.insert(
1881 "scout".to_string(),
1882 AgentTypeConfig {
1883 tools: Some(vec!["read_file".to_string()]),
1884 safety: Some("read_only".to_string()),
1885 preamble: Some("You are a scout.".to_string()),
1886 model: Some("ollama/qwen3:8b".to_string()),
1887 isolation: Some("worktree".to_string()),
1888 },
1889 );
1890 let scout = resolve_agent_type(Some("scout"), &config).unwrap();
1891 assert_eq!(scout.model.as_deref(), Some("ollama/qwen3:8b"));
1892 assert_eq!(scout.isolation, Isolation::Worktree);
1893 assert_eq!(scout.safety_ceiling, SafetyMode::ReadOnly);
1894
1895 config.agents.types.insert(
1897 "explore".to_string(),
1898 AgentTypeConfig {
1899 safety: Some("ask".to_string()),
1900 ..AgentTypeConfig::default()
1901 },
1902 );
1903 assert_eq!(
1904 resolve_agent_type(Some("explore"), &config)
1905 .unwrap()
1906 .safety_ceiling,
1907 SafetyMode::Ask,
1908 );
1909
1910 config.agents.types.insert(
1913 "bad-safety".to_string(),
1914 AgentTypeConfig {
1915 safety: Some("yolo".to_string()),
1916 ..AgentTypeConfig::default()
1917 },
1918 );
1919 assert!(
1920 resolve_agent_type(Some("bad-safety"), &config)
1921 .unwrap_err()
1922 .contains("yolo")
1923 );
1924 config.agents.types.insert(
1925 "bad-tool".to_string(),
1926 AgentTypeConfig {
1927 tools: Some(vec!["screenshot".to_string()]),
1928 ..AgentTypeConfig::default()
1929 },
1930 );
1931 assert!(
1932 resolve_agent_type(Some("bad-tool"), &config)
1933 .unwrap_err()
1934 .contains("screenshot")
1935 );
1936 }
1937
1938 #[test]
1939 fn agent_cache_stores_takes_and_evicts_oldest() {
1940 let spawner = test_spawner();
1941 let mk_state = || {
1942 State::new(
1943 mermaid_domain::Config::default(),
1944 PathBuf::from("/tmp"),
1945 "ollama/test".to_string(),
1946 chrono::Local::now(),
1947 PathBuf::from("/tmp"),
1948 )
1949 };
1950 let mk = || CachedAgent {
1951 state: mk_state(),
1952 type_name: "general".to_string(),
1953 workspace: Workspace::Shared {
1954 root: PathBuf::from("/tmp"),
1955 },
1956 };
1957
1958 assert_ne!(spawner.mint_agent_id(), spawner.mint_agent_id());
1960
1961 assert!(spawner.cache_store("x".to_string(), mk()).is_empty());
1964 assert!(spawner.cache_take("x").is_some());
1965 assert!(spawner.cache_take("x").is_none(), "take must remove");
1966
1967 let mut evicted = Vec::new();
1970 for i in 0..(MAX_CACHED_AGENTS + 2) {
1971 evicted.extend(spawner.cache_store(format!("e{i}"), mk()));
1972 }
1973 assert_eq!(evicted.len(), 2, "two past the cap, two handed back");
1974 assert!(spawner.cache_take("e0").is_none(), "oldest evicted");
1975 assert!(spawner.cache_take("e1").is_none(), "second-oldest evicted");
1976 assert!(
1977 spawner
1978 .cache_take(&format!("e{}", MAX_CACHED_AGENTS + 1))
1979 .is_some(),
1980 "newest survives",
1981 );
1982 }
1983
1984 #[tokio::test]
1985 async fn continuing_an_unknown_agent_id_errors_actionably() {
1986 let spawner = test_spawner_arc();
1987 let tool = SubagentTool::new(spawner);
1988 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1989 let outcome = tool
1990 .execute(
1991 serde_json::json!({"prompt": "follow up", "agent_id": "a99"}),
1992 ctx,
1993 )
1994 .await;
1995 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1996 let msg = outcome.error_message().unwrap_or_default();
1997 assert!(msg.contains("a99"), "names the bad id: {msg}");
1998 assert!(
1999 msg.contains("Omit agent_id"),
2000 "tells the model how to recover: {msg}"
2001 );
2002 }
2003
2004 #[tokio::test]
2005 async fn an_unparseable_isolation_arg_names_the_valid_modes() {
2006 let tool = SubagentTool::new(test_spawner_arc());
2007 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2008 let outcome = tool
2009 .execute(
2010 serde_json::json!({"prompt": "go", "isolation": "sandbox"}),
2011 ctx,
2012 )
2013 .await;
2014 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
2015 let msg = outcome.error_message().unwrap_or_default();
2016 assert!(msg.contains("sandbox"), "names what was rejected: {msg}");
2017 assert!(msg.contains("worktree"), "names the valid modes: {msg}");
2018 }
2019
2020 #[tokio::test]
2021 async fn asking_to_isolate_outside_a_repo_fails_the_spawn() {
2022 let tool = SubagentTool::new(test_spawner_arc());
2026 let dir = std::env::temp_dir().join(format!("mermaid_sub_norepo_{}", std::process::id()));
2027 let _ = std::fs::remove_dir_all(&dir);
2028 std::fs::create_dir_all(&dir).unwrap();
2029 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir);
2030 let outcome = tool
2031 .execute(
2032 serde_json::json!({"prompt": "go", "isolation": "worktree"}),
2033 ctx,
2034 )
2035 .await;
2036 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
2037 let msg = outcome.error_message().unwrap_or_default();
2038 assert!(msg.contains("could not isolate"), "{msg}");
2039 }
2040
2041 #[tokio::test]
2042 async fn a_failed_isolated_child_keeps_its_checkout_and_says_where() {
2043 use mermaid_runtime::git::git;
2044 let project = std::env::temp_dir().join(format!("mermaid_sub_keep_{}", std::process::id()));
2045 let _ = std::fs::remove_dir_all(&project);
2046 std::fs::create_dir_all(&project).unwrap();
2047 if git(&project).args(["init", "-q"]).run().is_err() {
2048 return;
2049 }
2050 std::fs::write(project.join("seed.txt"), "seed\n").unwrap();
2051 git(&project).args(["add", "-A"]).run().unwrap();
2052 let seed_id = project
2057 .file_name()
2058 .and_then(|n| n.to_str())
2059 .unwrap_or("repo");
2060 git(&project)
2061 .args(["commit", "-qm", &format!("init {seed_id}")])
2062 .run()
2063 .unwrap();
2064
2065 let mut config = mermaid_domain::Config::default();
2069 config.ollama.host = "http://127.0.0.1:1".to_string();
2070 config.safety.mode = SafetyMode::FullAccess;
2073 let providers = Arc::new(ProviderFactory::new(config.clone()));
2074 let web = Arc::new(WebCapabilities::resolve(&config.web));
2075 let tool = SubagentTool::new(Arc::new(SubagentSpawner::new(providers, web)));
2076 let (ctx, _rx) = crate::providers::ctx::test_exec_context_with_config(
2077 TurnId(1),
2078 ToolCallId(1),
2079 project.clone(),
2080 config,
2081 );
2082
2083 let outcome = tool
2084 .execute(
2085 serde_json::json!({
2086 "prompt": "go",
2087 "isolation": "worktree",
2088 "model": "ollama/does-not-exist",
2089 }),
2090 ctx,
2091 )
2092 .await;
2093
2094 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
2097 let msg = outcome.error_message().unwrap_or_default();
2098 assert!(msg.contains("isolated worktree is kept"), "{msg}");
2099 assert!(msg.contains("NOT in the project"), "{msg}");
2100 assert!(
2103 std::fs::read_to_string(project.join("seed.txt")).is_ok(),
2104 "the project must survive a failed child"
2105 );
2106 }
2107
2108 #[tokio::test]
2109 async fn unknown_agent_type_errors_actionably() {
2110 let spawner = test_spawner_arc();
2111 let tool = SubagentTool::new(spawner);
2112 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2113 let outcome = tool
2114 .execute(
2115 serde_json::json!({"prompt": "look around", "type": "wizard"}),
2116 ctx,
2117 )
2118 .await;
2119 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
2120 let msg = outcome.error_message().unwrap_or_default();
2121 assert!(msg.contains("wizard") && msg.contains("explore"), "{msg}");
2122 }
2123
2124 #[test]
2125 fn build_child_registry_excludes_gui_and_self() {
2126 let config = mermaid_domain::Config::default();
2127 let providers = Arc::new(ProviderFactory::new(config.clone()));
2128 let web = WebCapabilities::resolve(&config.web);
2129 let r = build_child_registry(providers, None, &config, SafetyMode::Ask, &web);
2130 assert!(r.get("screenshot").is_none());
2132 assert!(r.get("click").is_none());
2133 assert!(r.get("type_text").is_none());
2134 assert!(r.get("press_key").is_none());
2135 assert!(r.get("scroll").is_none());
2136 assert!(r.get("mouse_move").is_none());
2137 assert!(r.get("list_windows").is_none());
2138 assert!(r.get("agent").is_none());
2140 assert!(r.get("read_file").is_some());
2142 assert!(r.get("execute_command").is_some());
2143 assert!(r.get("web_fetch").is_none());
2146 assert!(r.get("web_search").is_none());
2147 }
2148
2149 #[test]
2150 fn child_registry_exposes_web_only_when_headless_policy_can_execute_it() {
2151 let configured = |mode, readonly_web, headless_opt_in, network| {
2152 let mut config = mermaid_domain::Config::default();
2153 config.safety.mode = mode;
2154 config.safety.allow_readonly_web = readonly_web;
2155 config.safety.allow_untrusted_headless_tools = headless_opt_in;
2156 config.safety.network = network;
2157 config.web.search_backend = mermaid_domain::SearchBackend::Searxng;
2160 config.web.searxng_url = "http://127.0.0.1:8080".to_string();
2161 let providers = Arc::new(ProviderFactory::new(config.clone()));
2162 let web = WebCapabilities::resolve(&config.web);
2163 build_child_registry(providers, None, &config, mode, &web)
2164 };
2165
2166 for mode in [SafetyMode::Auto, SafetyMode::FullAccess] {
2167 let registry = configured(mode, false, false, mermaid_domain::NetworkPolicy::Allow);
2168 assert!(registry.get("web_fetch").is_some(), "mode {mode:?}");
2169 assert!(registry.get("web_search").is_some(), "mode {mode:?}");
2170 }
2171
2172 let readonly = configured(
2173 SafetyMode::ReadOnly,
2174 true,
2175 false,
2176 mermaid_domain::NetworkPolicy::Allow,
2177 );
2178 assert!(readonly.get("web_fetch").is_some());
2179 assert!(readonly.get("web_search").is_some());
2180
2181 let opted_in = configured(
2182 SafetyMode::Ask,
2183 false,
2184 true,
2185 mermaid_domain::NetworkPolicy::Allow,
2186 );
2187 assert!(opted_in.get("web_fetch").is_some());
2188 assert!(opted_in.get("web_search").is_some());
2189
2190 let denied = configured(
2191 SafetyMode::FullAccess,
2192 true,
2193 true,
2194 mermaid_domain::NetworkPolicy::Deny,
2195 );
2196 assert!(denied.get("web_fetch").is_none());
2197 assert!(denied.get("web_search").is_none());
2198 }
2199
2200 #[test]
2201 fn child_web_visibility_honors_explicit_policy_overrides() {
2202 let mut config = mermaid_domain::Config::default();
2203 config.safety.overrides = vec![mermaid_runtime::PolicyOverride {
2204 category: Some(mermaid_runtime::ToolCategory::Web),
2205 decision: mermaid_runtime::PolicyOverrideDecision::Allow,
2206 ..mermaid_runtime::PolicyOverride::default()
2207 }];
2208 assert!(headless_web_tool_is_executable(
2209 &config,
2210 SafetyMode::Ask,
2211 "web_fetch"
2212 ));
2213
2214 config.safety.overrides[0].decision = mermaid_runtime::PolicyOverrideDecision::Deny;
2215 assert!(!headless_web_tool_is_executable(
2216 &config,
2217 SafetyMode::FullAccess,
2218 "web_fetch"
2219 ));
2220 }
2221
2222 #[test]
2223 fn kill_detached_fires_registered_tokens_and_evicts_cached_children() {
2224 let spawner = test_spawner();
2225
2226 let cancel = CancellationToken::new();
2228 spawner.register_detached("a1".to_string(), cancel.clone());
2229 assert!(matches!(spawner.kill_detached("a1"), KillResult::Killed));
2230 assert!(cancel.is_cancelled());
2231 assert!(matches!(spawner.kill_detached("a1"), KillResult::NotFound));
2233
2234 let _ = spawner.cache_store(
2237 "a2".to_string(),
2238 CachedAgent {
2239 state: test_state(),
2240 type_name: "general".to_string(),
2241 workspace: Workspace::Shared {
2242 root: PathBuf::from("/tmp"),
2243 },
2244 },
2245 );
2246 assert!(matches!(
2247 spawner.kill_detached("a2"),
2248 KillResult::Evicted(_)
2249 ));
2250 assert!(spawner.cache_take("a2").is_none(), "eviction is permanent");
2251
2252 assert!(matches!(spawner.kill_detached("a99"), KillResult::NotFound));
2254
2255 let (c1, c2) = (CancellationToken::new(), CancellationToken::new());
2257 spawner.register_detached("a3".to_string(), c1.clone());
2258 spawner.register_detached("a4".to_string(), c2.clone());
2259 assert_eq!(spawner.kill_all_detached(), 2);
2260 assert!(c1.is_cancelled() && c2.is_cancelled());
2261 assert_eq!(spawner.kill_all_detached(), 0);
2262 }
2263
2264 #[tokio::test]
2265 async fn kill_action_validates_agent_id_and_skips_prompt_requirement() {
2266 let spawner = test_spawner_arc();
2267 let tool = SubagentTool::new(spawner.clone());
2268
2269 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2272 let outcome = tool
2273 .execute(serde_json::json!({"action": "kill"}), ctx)
2274 .await;
2275 assert!(!outcome.is_success());
2276 assert!(outcome.model_content.contains("requires `agent_id`"));
2277
2278 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
2280 let outcome = tool
2281 .execute(serde_json::json!({"action": "kill", "agent_id": "a7"}), ctx)
2282 .await;
2283 assert!(!outcome.is_success());
2284 assert!(outcome.model_content.contains("a7"));
2285
2286 let cancel = CancellationToken::new();
2288 spawner.register_detached("a7".to_string(), cancel.clone());
2289 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(3), PathBuf::from("/tmp"));
2290 let outcome = tool
2291 .execute(serde_json::json!({"action": "kill", "agent_id": "a7"}), ctx)
2292 .await;
2293 assert!(outcome.is_success(), "{}", outcome.model_content);
2294 assert!(cancel.is_cancelled());
2295 }
2296
2297 #[test]
2298 fn explore_registry_is_a_read_only_surface() {
2299 let providers = Arc::new(ProviderFactory::new(mermaid_domain::Config::default()));
2300 let explore = builtin_agent_type("explore").expect("builtin");
2301 let config = mermaid_domain::Config::default();
2302 let web = WebCapabilities::resolve(&config.web);
2303 let r = build_child_registry(
2304 providers,
2305 explore.tools.as_deref(),
2306 &config,
2307 SafetyMode::ReadOnly,
2308 &web,
2309 );
2310 assert!(r.get("read_file").is_some());
2311 assert!(r.get("execute_command").is_some());
2312 for tool in [
2313 "write_file",
2314 "apply_patch",
2315 "delete_file",
2316 "create_directory",
2317 "mcp_proxy",
2318 "agent",
2319 ] {
2320 assert!(r.get(tool).is_none(), "explore must not carry {tool}");
2321 }
2322 }
2323}