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. If the task needs a capability you \
88lack (live web access, writes), say so plainly in your report — never \
89invent findings, sources, or citations.";
90
91const CHILD_TOOL_NAMES: &[&str] = &[
96 "read_file",
97 "write_file",
98 "apply_patch",
99 "delete_file",
100 "create_directory",
101 "execute_command",
102 "web_search",
103 "web_fetch",
104 "mcp",
105];
106
107#[derive(Debug)]
111struct AgentType {
112 name: String,
113 tools: Option<Vec<String>>,
115 safety_ceiling: SafetyMode,
118 preamble: Option<String>,
120 model: Option<String>,
122 isolation: Isolation,
124}
125
126impl AgentType {
127 fn allows_tool(&self, name: &str) -> bool {
128 self.tools
129 .as_ref()
130 .is_none_or(|tools| tools.iter().any(|t| t == name))
131 }
132}
133
134fn builtin_agent_type(name: &str) -> Option<AgentType> {
135 match name {
136 "general" => Some(AgentType {
141 name: "general".to_string(),
142 tools: None,
143 safety_ceiling: SafetyMode::FullAccess,
144 preamble: None,
145 model: None,
146 isolation: Isolation::Shared,
147 }),
148 "explore" => Some(AgentType {
151 name: "explore".to_string(),
152 tools: Some(vec!["read_file".to_string(), "execute_command".to_string()]),
153 safety_ceiling: SafetyMode::ReadOnly,
154 preamble: Some(EXPLORE_PREAMBLE.to_string()),
155 model: None,
156 isolation: Isolation::Shared,
157 }),
158 _ => None,
159 }
160}
161
162const ISOLATED_PREAMBLE: &str = "\
166## Isolated Workspace
167You are working in a private copy of the project, seeded with the user's \
168current uncommitted state. Your edits are invisible to the user and to any \
169other agent until you finish, at which point they are applied to the real \
170project as one patch. Work normally and use ordinary paths. Do not try to \
171reach outside this directory to \"really\" apply your changes, and do not \
172commit: finishing is what lands the work.";
173
174fn resolve_agent_type(
178 requested: Option<&str>,
179 config: &mermaid_domain::Config,
180) -> Result<AgentType, String> {
181 let name = requested.unwrap_or("general");
182 if let Some(custom) = config.agents.types.get(name) {
183 let safety_ceiling = match custom.safety.as_deref() {
184 None => SafetyMode::FullAccess,
185 Some(s) => SafetyMode::parse(s).ok_or_else(|| {
186 format!(
187 "[agents.types.{name}] safety '{s}' is not one of \
188 read_only/ask/auto/full_access"
189 )
190 })?,
191 };
192 if let Some(tools) = &custom.tools
193 && let Some(bad) = tools
194 .iter()
195 .find(|t| !CHILD_TOOL_NAMES.contains(&t.as_str()))
196 {
197 return Err(format!(
198 "[agents.types.{name}] unknown tool '{bad}'; valid tools: {}",
199 CHILD_TOOL_NAMES.join(", ")
200 ));
201 }
202 let isolation = match custom.isolation.as_deref() {
203 None => Isolation::default(),
204 Some(s) => Isolation::parse(s).ok_or_else(|| {
205 format!(
206 "[agents.types.{name}] isolation '{s}' is not one of {}",
207 Isolation::NAMES
208 )
209 })?,
210 };
211 return Ok(AgentType {
212 name: name.to_string(),
213 tools: custom.tools.clone(),
214 safety_ceiling,
215 preamble: custom.preamble.clone(),
216 model: custom.model.clone(),
217 isolation,
218 });
219 }
220 builtin_agent_type(name).ok_or_else(|| {
221 let mut available: Vec<&str> = vec!["general", "explore"];
222 available.extend(config.agents.types.keys().map(String::as_str));
223 format!(
224 "unknown agent type '{name}'; available: {}",
225 available.join(", ")
226 )
227 })
228}
229
230struct CachedAgent {
234 state: State,
235 type_name: String,
236 workspace: Workspace,
240}
241
242#[derive(Default)]
243struct AgentCache {
244 entries: HashMap<String, CachedAgent>,
245 order: VecDeque<String>,
247}
248
249pub struct SubagentSpawner {
251 providers: Arc<ProviderFactory>,
252 web_capabilities: Arc<WebCapabilities>,
253 inflight: Arc<Semaphore>,
254 next_agent_id: AtomicU64,
256 cache: Mutex<AgentCache>,
260 detached_cancels: Mutex<HashMap<String, CancellationToken>>,
264}
265
266#[derive(Debug)]
268pub(crate) enum KillResult {
269 Killed,
272 Evicted(Workspace),
276 NotFound,
277}
278
279impl SubagentSpawner {
280 pub fn new(providers: Arc<ProviderFactory>, web_capabilities: Arc<WebCapabilities>) -> Self {
281 Self {
282 providers,
283 web_capabilities,
284 inflight: Arc::new(Semaphore::new(MAX_INFLIGHT)),
285 next_agent_id: AtomicU64::new(0),
286 cache: Mutex::new(AgentCache::default()),
287 detached_cancels: Mutex::new(HashMap::new()),
288 }
289 }
290
291 fn mint_agent_id(&self) -> String {
292 format!(
293 "a{}",
294 self.next_agent_id.fetch_add(1, Ordering::Relaxed) + 1
295 )
296 }
297
298 fn cache_take(&self, id: &str) -> Option<CachedAgent> {
300 let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
301 cache.order.retain(|x| x != id);
302 cache.entries.remove(id)
303 }
304
305 #[must_use = "an evicted workspace owns a checkout that has to be discarded"]
312 fn cache_store(&self, id: String, agent: CachedAgent) -> Vec<Workspace> {
313 let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
314 cache.order.retain(|x| x != &id);
315 cache.order.push_back(id.clone());
316 cache.entries.insert(id, agent);
317 let mut evicted = Vec::new();
318 while cache.entries.len() > MAX_CACHED_AGENTS {
319 let Some(oldest) = cache.order.pop_front() else {
320 break;
321 };
322 if let Some(agent) = cache.entries.remove(&oldest) {
323 evicted.push(agent.workspace);
324 }
325 }
326 evicted
327 }
328
329 fn register_detached(&self, agent_id: String, cancel: CancellationToken) {
330 self.detached_cancels
331 .lock()
332 .unwrap_or_else(|e| e.into_inner())
333 .insert(agent_id, cancel);
334 }
335
336 fn unregister_detached(&self, agent_id: &str) {
337 self.detached_cancels
338 .lock()
339 .unwrap_or_else(|e| e.into_inner())
340 .remove(agent_id);
341 }
342
343 pub(crate) fn kill_detached(&self, agent_id: &str) -> KillResult {
348 let cancel = self
349 .detached_cancels
350 .lock()
351 .unwrap_or_else(|e| e.into_inner())
352 .remove(agent_id);
353 if let Some(cancel) = cancel {
354 cancel.cancel();
355 return KillResult::Killed;
356 }
357 if let Some(evicted) = self.cache_take(agent_id) {
358 return KillResult::Evicted(evicted.workspace);
359 }
360 KillResult::NotFound
361 }
362
363 pub fn kill_all_detached(&self) -> usize {
365 let cancels: Vec<CancellationToken> = {
366 let mut map = self
367 .detached_cancels
368 .lock()
369 .unwrap_or_else(|e| e.into_inner());
370 map.drain().map(|(_, c)| c).collect()
371 };
372 let n = cancels.len();
373 for cancel in cancels {
374 cancel.cancel();
375 }
376 n
377 }
378}
379
380pub struct SubagentTool {
382 spawner: Arc<SubagentSpawner>,
383}
384
385impl SubagentTool {
386 pub fn new(spawner: Arc<SubagentSpawner>) -> Self {
387 Self { spawner }
388 }
389}
390
391#[expect(
392 clippy::too_many_lines,
393 reason = "predates the lint; see .github/baselines/expect_budget.txt"
394)]
395#[async_trait]
396impl ToolExecutor for SubagentTool {
397 fn name(&self) -> &'static str {
398 "agent"
399 }
400
401 fn schema(&self) -> ToolDefinition {
402 ToolDefinition {
403 name: "agent".to_string(),
404 description: format!(
405 "Spawn a child agent with its own context and tool access to work on an \
406 independent sub-task. Useful for parallel fan-out (emit multiple `agent` \
407 calls in the same turn to run them concurrently) or for scoping a noisy \
408 sub-task (the child's tool output doesn't clutter the parent's turn). \
409 Types: 'general' (default — full tool access at your safety mode) and \
410 'explore' (read-only reconnaissance: locate files and extract facts, \
411 cannot mutate), plus any defined in config [agents.types]. Every result \
412 ends with an [agent_id: …] trailer; pass that id back as `agent_id` to \
413 send a follow-up prompt to the same child with its context intact (the \
414 {MAX_CACHED_AGENTS} most recent children are kept). Breadth-capped at \
415 {MAX_INFLIGHT} concurrent; subagents can't themselves spawn subagents \
416 and never get GUI (screenshot/click/…) access. A child moved to the \
417 background (the user detaches one with Ctrl+B) can be cancelled with \
418 action: \"kill\" plus its agent_id.",
419 ),
420 input_schema: serde_json::json!({
421 "type": "object",
422 "properties": {
423 "action": {
424 "type": "string",
425 "enum": ["spawn", "kill"],
426 "description": "Default 'spawn' (also covers continuing via agent_id). 'kill' cancels a backgrounded child by agent_id — no prompt needed."
427 },
428 "prompt": {
429 "type": "string",
430 "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."
431 },
432 "description": {
433 "type": "string",
434 "description": "Short label shown in the parent's status line (e.g. 'list domain files')."
435 },
436 "type": {
437 "type": "string",
438 "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."
439 },
440 "model": {
441 "type": "string",
442 "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."
443 },
444 "isolation": {
445 "type": "string",
446 "enum": ["shared", "worktree"],
447 "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."
448 },
449 "agent_id": {
450 "type": "string",
451 "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."
452 }
453 },
454 "required": []
455 }),
456 }
457 }
458
459 async fn execute(&self, args: Value, ctx: ExecContext) -> ToolOutcome {
460 let started = Instant::now();
461
462 if args.get("action").and_then(|v| v.as_str()) == Some("kill") {
469 let Some(id) = args
470 .get("agent_id")
471 .and_then(|v| v.as_str())
472 .map(str::trim)
473 .filter(|s| !s.is_empty())
474 else {
475 return ToolOutcome::error("action 'kill' requires `agent_id`", 0.0);
476 };
477 return match self.spawner.kill_detached(id) {
478 KillResult::Killed => ToolOutcome::success(
479 format!(
480 "Background agent '{id}' cancelled — it unwinds at its next \
481 await point; a cancellation notice will appear in the \
482 conversation."
483 ),
484 "subagent killed",
485 started.elapsed().as_secs_f64(),
486 ),
487 KillResult::Evicted(workspace) => {
488 workspace.discard().await;
492 ToolOutcome::success(
493 format!(
494 "Agent '{id}' had already finished; removed it from the \
495 continuation cache instead."
496 ),
497 "subagent evicted",
498 started.elapsed().as_secs_f64(),
499 )
500 },
501 KillResult::NotFound => ToolOutcome::error(
502 format!(
503 "no background or cached agent '{id}' — it may have already \
504 finished and been evicted, or the id was never issued"
505 ),
506 started.elapsed().as_secs_f64(),
507 ),
508 };
509 }
510
511 let prompt = match args.get("prompt").and_then(|v| v.as_str()) {
513 Some(s) if !s.trim().is_empty() => s.to_string(),
514 _ => {
515 return ToolOutcome::error("agent requires non-empty `prompt`", 0.0);
516 },
517 };
518 let description = args
519 .get("description")
520 .and_then(|v| v.as_str())
521 .unwrap_or("subagent")
522 .to_string();
523 let requested_type = args
524 .get("type")
525 .and_then(|v| v.as_str())
526 .map(str::trim)
527 .filter(|s| !s.is_empty());
528 let model_override = args
529 .get("model")
530 .and_then(|v| v.as_str())
531 .map(str::trim)
532 .filter(|s| !s.is_empty());
533 let isolation_override = match args
534 .get("isolation")
535 .and_then(|v| v.as_str())
536 .map(str::trim)
537 .filter(|s| !s.is_empty())
538 {
539 None => None,
540 Some(raw) => match Isolation::parse(raw) {
541 Some(mode) => Some(mode),
542 None => {
543 return ToolOutcome::error(
544 format!("isolation '{raw}' is not one of {}", Isolation::NAMES),
545 started.elapsed().as_secs_f64(),
546 );
547 },
548 },
549 };
550 let continue_id = args
551 .get("agent_id")
552 .and_then(|v| v.as_str())
553 .map(str::trim)
554 .filter(|s| !s.is_empty())
555 .map(str::to_string);
556
557 if let Some(blocked) = super::policy_gate::gate_external(
564 &ctx,
565 "agent",
566 mermaid_runtime::ToolCategory::Subagent,
567 format!("subagent: {description}"),
568 &args,
569 )
570 .await
571 {
572 return blocked;
573 }
574
575 let permit = tokio::select! {
579 biased;
580 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
581 p = self.spawner.inflight.clone().acquire_owned() => match p {
582 Ok(permit) => permit,
583 Err(_) => return ToolOutcome::error(
584 "subagent semaphore closed",
585 started.elapsed().as_secs_f64(),
586 ),
587 },
588 };
589
590 let config = (*ctx.config).clone();
600
601 let (agent_id, cached) = match continue_id {
606 Some(id) => match self.spawner.cache_take(&id) {
607 Some(cached) => (id, Some(cached)),
608 None => {
609 return ToolOutcome::error(
610 format!(
611 "unknown agent_id '{id}': it may have expired (the \
612 {MAX_CACHED_AGENTS} most recent children are kept), be running \
613 a continuation right now, or never have existed. Omit agent_id \
614 to start a new agent."
615 ),
616 started.elapsed().as_secs_f64(),
617 );
618 },
619 },
620 None => (self.spawner.mint_agent_id(), None),
621 };
622
623 let type_name = cached
627 .as_ref()
628 .map(|c| c.type_name.clone())
629 .or_else(|| requested_type.map(str::to_string));
630 let agent_type = match resolve_agent_type(type_name.as_deref(), &config) {
631 Ok(agent_type) => agent_type,
632 Err(e) => {
633 if let Some(cached) = cached {
637 let evicted = self.spawner.cache_store(agent_id, cached);
638 debug_assert!(evicted.is_empty());
639 }
640 return ToolOutcome::error(e, started.elapsed().as_secs_f64());
641 },
642 };
643
644 let child_safety = SafetyMode::least_permissive(ctx.safety_mode, agent_type.safety_ceiling);
653
654 let (workspace, cached) = match cached {
658 Some(cached) => (cached.workspace, Some(cached.state)),
659 None => {
660 let isolation = isolation_override.unwrap_or(agent_type.isolation);
661 match Workspace::create(isolation, ctx.workdir.clone(), &agent_id).await {
662 Ok(workspace) => (workspace, None),
663 Err(e) => {
664 return ToolOutcome::error(e, started.elapsed().as_secs_f64());
665 },
666 }
667 },
668 };
669 let cwd = workspace.root().to_path_buf();
670
671 let model_id = model_override
673 .map(str::to_string)
674 .or_else(|| agent_type.model.clone())
675 .unwrap_or_else(|| {
676 if ctx.model_id.is_empty() {
677 default_model_id(&config)
678 } else {
679 ctx.model_id.clone()
680 }
681 });
682
683 let (mut child_state, usage_before) = match cached {
684 Some(state) => {
685 let before = state.session.cumulative_token_usage;
688 (state, before)
689 },
690 None => (
691 State::new(
692 config.clone(),
693 cwd.clone(),
694 model_id.clone(),
695 chrono::Local::now(),
696 std::env::temp_dir(),
697 ),
698 TokenUsageTotals::default(),
699 ),
700 };
701 if let Some(model) = model_override {
704 child_state.session.model_id = model.to_string();
705 }
706 let child_model_id = child_state.session.model_id.clone();
707
708 child_state.now = chrono::Local::now();
716 child_state.session.safety_mode = child_safety;
717 child_state.session.is_subagent = true;
721 child_state.session.agent_preamble = match (&agent_type.preamble, workspace.is_isolated()) {
725 (_, false) => agent_type.preamble.clone(),
726 (None, true) => Some(ISOLATED_PREAMBLE.to_string()),
727 (Some(preamble), true) => Some(format!("{preamble}\n\n{ISOLATED_PREAMBLE}")),
728 };
729 child_state.session.scratchpad = ctx.scratchpad.clone();
736 let (instructions, memory, skills) =
737 crate::app::instructions::load_project_context(&cwd, &config.memory);
738 child_state.instructions = instructions;
739 child_state.memory = memory;
740 child_state.skills = skills;
741 if agent_type.allows_tool("mcp") {
751 seed_child_mcp(&mut child_state);
752 }
753
754 let child_tools = build_child_registry(
755 self.spawner.providers.clone(),
756 &agent_type.name,
757 agent_type.tools.as_deref(),
758 &config,
759 child_safety,
760 &self.spawner.web_capabilities,
761 );
762
763 let child_cancel = CancellationToken::new();
768 let (child_tx, child_rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
769 let child_runner =
770 EffectRunner::new_child(child_tx, cwd, self.spawner.providers.clone(), child_tools);
771
772 let timeout_secs = match config.agents.timeout_secs {
776 0 => DEFAULT_TIMEOUT_SECS,
777 secs => secs,
778 };
779 let (child_progress_tx, mut child_progress_rx) = mpsc::channel::<ProgressEvent>(16);
782 let mut drive = Box::pin(drive_child(
783 child_state,
784 child_runner,
785 child_rx,
786 child_progress_tx,
787 prompt,
788 child_cancel.clone(),
789 Duration::from_secs(timeout_secs),
790 ));
791
792 let mut progress_open = true;
793 let (result, final_state) = loop {
794 tokio::select! {
795 biased;
796 _ = ctx.token.cancelled() => {
797 child_cancel.cancel();
801 break drive.await;
802 },
803 _ = ctx.background.cancelled() => {
804 return self.detach_child(DetachArgs {
809 drive,
810 progress_rx: child_progress_rx,
811 permit,
812 cancel: child_cancel.clone(),
813 notify: ctx.notify.clone(),
814 agent_id,
815 description,
816 type_name: agent_type.name.clone(),
817 child_model_id,
818 usage_before,
819 timeout_secs,
820 started,
821 workspace,
822 merge_cx: MergeContext::from_exec(&ctx),
823 });
824 },
825 ev = child_progress_rx.recv(), if progress_open => match ev {
826 Some(ev) => { let _ = ctx.progress.send(ev).await; },
827 None => progress_open = false,
828 },
829 r = &mut drive => break r,
830 }
831 };
832 drop(permit);
833
834 finish_drive(
835 &self.spawner,
836 agent_type.name.clone(),
837 agent_id,
838 &description,
839 child_model_id,
840 usage_before,
841 timeout_secs,
842 started,
843 result,
844 final_state,
845 workspace,
846 MergeContext::from_exec(&ctx),
847 )
848 .await
849 }
850}
851
852struct DetachArgs<F> {
855 drive: std::pin::Pin<Box<F>>,
856 progress_rx: mpsc::Receiver<ProgressEvent>,
857 permit: tokio::sync::OwnedSemaphorePermit,
858 cancel: CancellationToken,
861 notify: Option<mpsc::Sender<Msg>>,
862 agent_id: String,
863 description: String,
864 type_name: String,
865 child_model_id: String,
866 usage_before: TokenUsageTotals,
867 timeout_secs: u64,
868 started: Instant,
869 workspace: Workspace,
873 merge_cx: MergeContext,
874}
875
876impl SubagentTool {
877 #[expect(
883 clippy::too_many_lines,
884 reason = "predates the lint; see .github/baselines/expect_budget.txt"
885 )]
886 fn detach_child<F>(&self, args: DetachArgs<F>) -> ToolOutcome
887 where
888 F: std::future::Future<Output = (Result<String, DriveError>, State)> + Send + 'static,
889 {
890 let DetachArgs {
891 mut drive,
892 mut progress_rx,
893 permit,
894 cancel,
895 notify,
896 agent_id,
897 description,
898 type_name,
899 child_model_id,
900 usage_before,
901 timeout_secs,
902 started,
903 workspace,
904 merge_cx,
905 } = args;
906 if let Some(notify) = ¬ify {
907 let _ = notify.try_send(Msg::BackgroundAgentStarted {
908 agent_id: agent_id.clone(),
909 description: description.clone(),
910 });
911 }
912 let spawner = self.spawner.clone();
913 spawner.register_detached(agent_id.clone(), cancel);
916 let outcome_text = format!(
917 "Agent '{description}' ({agent_id}) moved to background — it keeps running and \
918 its report will be posted to the conversation when it finishes."
919 );
920 let (bg_agent_id, bg_description) = (agent_id, description);
921 tokio::spawn(async move {
922 let _permit = permit;
925 let mut activity = String::new();
926 let mut tokens = 0usize;
927 let mut progress_open = true;
928 let (result, final_state) = loop {
929 tokio::select! {
930 ev = progress_rx.recv(), if progress_open => match ev {
931 Some(ev) => {
932 match &ev {
933 ProgressEvent::SubagentToolCall { tool_name, phase, .. } => {
934 activity = match phase {
935 SubagentPhase::Started => format!("{tool_name}…"),
936 SubagentPhase::Finished => format!("{tool_name} done"),
937 SubagentPhase::Errored => format!("{tool_name} failed"),
938 };
939 },
940 ProgressEvent::SubagentActivity(label) => activity = label.clone(),
941 ProgressEvent::SubagentTokens(count) => tokens = *count,
942 _ => continue,
943 }
944 if let Some(notify) = ¬ify {
945 let _ = notify.try_send(Msg::BackgroundAgentProgress {
946 agent_id: bg_agent_id.clone(),
947 activity: activity.clone(),
948 tokens,
949 });
950 }
951 },
952 None => progress_open = false,
953 },
954 r = &mut drive => break r,
955 }
956 };
957 spawner.unregister_detached(&bg_agent_id);
958 let cancelled = matches!(result, Err(DriveError::Cancelled));
959 let outcome = finish_drive(
960 &spawner,
961 type_name,
962 bg_agent_id.clone(),
963 &bg_description,
964 child_model_id,
965 usage_before,
966 timeout_secs,
967 started,
968 result,
969 final_state,
970 workspace,
971 merge_cx,
972 )
973 .await;
974 if let Some(notify) = notify {
975 let usage = outcome.metadata.token_usage.clone();
976 let tokens_total = usage.as_ref().map_or(tokens, |u| u.total_tokens());
977 let _ = notify
978 .send(Msg::BackgroundAgentFinished {
979 agent_id: bg_agent_id,
980 description: bg_description,
981 report: outcome.model_content.clone(),
982 success: outcome.is_success(),
983 cancelled,
984 usage,
985 tokens: tokens_total,
986 duration_secs: started.elapsed().as_secs(),
987 })
988 .await;
989 }
990 });
991 ToolOutcome::success(
992 outcome_text,
993 "subagent backgrounded",
994 started.elapsed().as_secs_f64(),
995 )
996 }
997}
998
999#[expect(clippy::too_many_arguments)]
1004async fn finish_drive(
1005 spawner: &SubagentSpawner,
1006 type_name: String,
1007 agent_id: String,
1008 description: &str,
1009 child_model_id: String,
1010 usage_before: TokenUsageTotals,
1011 timeout_secs: u64,
1012 started: Instant,
1013 result: Result<String, DriveError>,
1014 mut final_state: State,
1015 workspace: Workspace,
1016 merge_cx: MergeContext,
1017) -> ToolOutcome {
1018 let child_usage = usage_delta(final_state.session.cumulative_token_usage, usage_before);
1019
1020 let (workspace, workspace_report) = match &result {
1025 Ok(_) => workspace.merge(&merge_cx).await,
1026 Err(DriveError::Cancelled) => (workspace, WorkspaceReport::default()),
1027 Err(_) => {
1028 let note = workspace.unmerged_note();
1029 let report = WorkspaceReport {
1030 note,
1031 needs_attention: false,
1032 };
1033 (workspace, report)
1034 },
1035 };
1036
1037 if matches!(result, Err(DriveError::Cancelled)) {
1044 workspace.discard().await;
1047 } else {
1048 final_state.turn = TurnState::Idle;
1049 final_state.ui.queued_messages.clear();
1050 final_state.ui.live_tool_status.clear();
1051 final_state.pending_approval.clear();
1052 let evicted = spawner.cache_store(
1053 agent_id.clone(),
1054 CachedAgent {
1055 state: final_state,
1056 type_name,
1057 workspace,
1058 },
1059 );
1060 for workspace in evicted {
1061 workspace.discard().await;
1062 }
1063 }
1064
1065 let elapsed = started.elapsed().as_secs_f64();
1066 let trailer = format!("[agent_id: {agent_id} — pass agent_id to continue this child]");
1067 let metadata = subagent_metadata(child_model_id, child_usage, agent_id);
1068 let trailer = if workspace_report.note.is_empty() {
1071 trailer
1072 } else {
1073 format!("{}\n\n{trailer}", workspace_report.note)
1074 };
1075 match result {
1076 Ok(summary) if workspace_report.needs_attention => ToolOutcome::error(
1081 format!("subagent ({description}) finished but its work did not land.\n\n{summary}\n\n{trailer}"),
1082 elapsed,
1083 )
1084 .with_metadata(metadata),
1085 Ok(summary) => ToolOutcome::success(
1086 format!("{summary}\n\n{trailer}"),
1087 "subagent completed",
1088 elapsed,
1089 )
1090 .with_metadata(metadata),
1091 Err(DriveError::Cancelled) => ToolOutcome::cancelled(),
1092 Err(DriveError::TimedOut) => ToolOutcome::error(
1093 format!(
1094 "subagent ({description}) exceeded {timeout_secs}s timeout; its context \
1095 is preserved — {trailer}"
1096 ),
1097 elapsed,
1098 )
1099 .with_metadata(metadata),
1100 Err(DriveError::Errored(e)) => {
1101 ToolOutcome::error(format!("subagent ({description}): {e} {trailer}"), elapsed)
1102 .with_metadata(metadata)
1103 },
1104 }
1105}
1106
1107fn subagent_metadata(
1115 model_id: String,
1116 usage: TokenUsageTotals,
1117 agent_id: String,
1118) -> ToolRunMetadata {
1119 let token_usage = (usage.total_tokens() > 0).then(|| mermaid_model::models::TokenUsage {
1120 prompt_tokens: usage.prompt_tokens,
1121 completion_tokens: usage.completion_tokens,
1122 cached_input_tokens: usage.cached_input_tokens,
1123 cache_creation_input_tokens: usage.cache_creation_input_tokens,
1124 reasoning_output_tokens: usage.reasoning_output_tokens,
1125 source: Default::default(),
1126 });
1127 ToolRunMetadata {
1128 detail: ToolMetadata::Subagent { model_id, agent_id },
1129 token_usage,
1130 ..ToolRunMetadata::default()
1131 }
1132}
1133
1134fn usage_delta(after: TokenUsageTotals, before: TokenUsageTotals) -> TokenUsageTotals {
1139 TokenUsageTotals {
1140 prompt_tokens: after.prompt_tokens.saturating_sub(before.prompt_tokens),
1141 completion_tokens: after
1142 .completion_tokens
1143 .saturating_sub(before.completion_tokens),
1144 cached_input_tokens: after
1145 .cached_input_tokens
1146 .saturating_sub(before.cached_input_tokens),
1147 cache_creation_input_tokens: after
1148 .cache_creation_input_tokens
1149 .saturating_sub(before.cache_creation_input_tokens),
1150 reasoning_output_tokens: after
1151 .reasoning_output_tokens
1152 .saturating_sub(before.reasoning_output_tokens),
1153 }
1154}
1155
1156enum DriveError {
1157 Cancelled,
1158 TimedOut,
1159 Errored(String),
1160}
1161
1162async fn drive_child(
1170 mut state: State,
1171 mut runner: EffectRunner,
1172 mut msg_rx: mpsc::Receiver<Msg>,
1173 parent_progress: mpsc::Sender<ProgressEvent>,
1174 prompt: String,
1175 token: CancellationToken,
1176 timeout: Duration,
1177) -> (Result<String, DriveError>, State) {
1178 let _ = parent_progress
1181 .send(ProgressEvent::SubagentActivity("starting…".to_string()))
1182 .await;
1183
1184 let seed = Msg::SubmitPrompt {
1191 text: prompt,
1192 attachment_ids: vec![],
1193 };
1194 let (new_state, cmds) = update(state, seed);
1195 state = new_state;
1196 for cmd in cmds {
1197 runner.dispatch(cmd);
1198 }
1199
1200 let deadline = tokio::time::sleep(timeout);
1206 tokio::pin!(deadline);
1207
1208 let mut outcome: Result<(), DriveError> = Ok(());
1209 let mut child_progress = ChildProgress::new(tokio::time::Instant::now());
1210 loop {
1211 if token.is_cancelled() {
1212 outcome = Err(DriveError::Cancelled);
1213 break;
1214 }
1215 if matches!(state.turn, TurnState::Idle) && state.ui.queued_messages.is_empty() {
1216 break;
1217 }
1218
1219 let msg = tokio::select! {
1220 biased;
1221 _ = token.cancelled() => {
1222 outcome = Err(DriveError::Cancelled);
1223 break;
1224 },
1225 _ = &mut deadline => {
1226 outcome = Err(DriveError::TimedOut);
1227 break;
1228 },
1229 recv = msg_rx.recv() => match recv {
1230 Some(m) => m,
1231 None => break, },
1233 };
1234
1235 for event in child_progress.observe(&msg, &state, tokio::time::Instant::now()) {
1239 let _ = parent_progress.send(event).await;
1240 }
1241
1242 let (new_state, cmds) = update(state, msg);
1243 state = new_state;
1244 for cmd in cmds {
1245 runner.dispatch(cmd);
1246 }
1247 if state.should_exit {
1248 break;
1249 }
1250 }
1251
1252 runner.shutdown().await;
1257
1258 if let Err(e) = outcome {
1259 return (Err(e), state);
1260 }
1261
1262 let summary = state
1264 .session
1265 .messages()
1266 .iter()
1267 .rev()
1268 .find(|m| m.role == MessageRole::Assistant)
1269 .map(|m| m.content.clone())
1270 .unwrap_or_default();
1271 if summary.trim().is_empty() {
1272 return (
1273 Err(DriveError::Errored(
1274 "subagent produced no assistant output".to_string(),
1275 )),
1276 state,
1277 );
1278 }
1279 (Ok(summary), state)
1280}
1281
1282const TOKEN_PROGRESS_INTERVAL: Duration = Duration::from_millis(500);
1286
1287struct ChildProgress {
1299 phase: &'static str,
1300 confirmed_tokens: usize,
1302 streamed_chars: usize,
1305 last_tokens_sent: usize,
1306 last_tokens_at: tokio::time::Instant,
1307}
1308
1309impl ChildProgress {
1310 fn new(now: tokio::time::Instant) -> Self {
1311 Self {
1312 phase: "",
1313 confirmed_tokens: 0,
1314 streamed_chars: 0,
1315 last_tokens_sent: 0,
1316 last_tokens_at: now,
1317 }
1318 }
1319
1320 fn total_tokens(&self) -> usize {
1321 self.confirmed_tokens + self.streamed_chars / 4
1322 }
1323
1324 fn observe(
1327 &mut self,
1328 msg: &Msg,
1329 state: &State,
1330 now: tokio::time::Instant,
1331 ) -> Vec<ProgressEvent> {
1332 let mut out = Vec::new();
1333 match msg {
1334 Msg::ToolStarted {
1335 turn: _, call_id, ..
1336 } => {
1337 let tool_name =
1338 lookup_tool_name(state, *call_id).unwrap_or_else(|| "tool".to_string());
1339 out.push(ProgressEvent::SubagentToolCall {
1340 child_call_id: *call_id,
1341 tool_name,
1342 phase: SubagentPhase::Started,
1343 });
1344 self.phase = "";
1346 },
1347 Msg::ToolFinished {
1348 turn: _,
1349 call_id,
1350 outcome,
1351 } => {
1352 let tool_name =
1353 lookup_tool_name(state, *call_id).unwrap_or_else(|| "tool".to_string());
1354 let phase = if outcome.is_success() {
1355 SubagentPhase::Finished
1356 } else {
1357 SubagentPhase::Errored
1358 };
1359 out.push(ProgressEvent::SubagentToolCall {
1360 child_call_id: *call_id,
1361 tool_name,
1362 phase,
1363 });
1364 self.phase = "";
1365 },
1366 Msg::StreamReasoning { chunk, .. } => {
1367 self.streamed_chars += chunk.text.len();
1368 self.set_phase("thinking", &mut out);
1369 },
1370 Msg::StreamText { chunk, .. } => {
1371 self.streamed_chars += chunk.len();
1372 self.set_phase("replying", &mut out);
1373 },
1374 Msg::StreamDone {
1375 usage: Some(usage), ..
1376 } => {
1377 self.confirmed_tokens += usage
1378 .completion_tokens
1379 .saturating_add(usage.reasoning_output_tokens);
1380 self.streamed_chars = 0;
1381 },
1382 _ => {},
1383 }
1384 let total = self.total_tokens();
1387 let due = now.duration_since(self.last_tokens_at) >= TOKEN_PROGRESS_INTERVAL;
1388 if total != self.last_tokens_sent && (due || !out.is_empty()) {
1389 out.push(ProgressEvent::SubagentTokens(total));
1390 self.last_tokens_sent = total;
1391 self.last_tokens_at = now;
1392 }
1393 out
1394 }
1395
1396 fn set_phase(&mut self, phase: &'static str, out: &mut Vec<ProgressEvent>) {
1397 if self.phase != phase {
1398 self.phase = phase;
1399 out.push(ProgressEvent::SubagentActivity(phase.to_string()));
1400 }
1401 }
1402}
1403
1404fn lookup_tool_name(state: &State, call_id: mermaid_domain::ToolCallId) -> Option<String> {
1407 match &state.turn {
1408 TurnState::ExecutingTools { calls, .. } => calls
1409 .iter()
1410 .find(|c| c.call_id == call_id)
1411 .map(|c| c.source.function.name.clone()),
1412 _ => None,
1413 }
1414}
1415
1416fn seed_child_mcp(state: &mut State) {
1425 let Some(manager) = crate::mcp::manager_ref::get() else {
1426 return;
1427 };
1428 apply_live_mcp(&mut state.mcp.servers, &manager.all_specs(), |name| {
1429 manager.has_server(name)
1430 });
1431}
1432
1433fn apply_live_mcp(
1439 servers: &mut std::collections::HashMap<String, mermaid_domain::McpServerEntry>,
1440 live_specs: &[(String, mermaid_domain::McpToolSpec)],
1441 has_server: impl Fn(&str) -> bool,
1442) {
1443 for (name, entry) in servers.iter_mut() {
1444 if !has_server(name) {
1445 continue;
1446 }
1447 entry.status = mermaid_domain::McpServerStatus::Ready;
1448 let cfg = &entry.config;
1449 let tools: Vec<mermaid_domain::McpToolSpec> = live_specs
1450 .iter()
1451 .filter(|(server, _)| server == name)
1452 .filter(|(_, spec)| cfg.tool_allowed(&spec.raw_name))
1455 .map(|(_, spec)| spec.clone())
1456 .collect();
1457 entry.tools = tools;
1458 }
1459}
1460
1461fn build_child_registry(
1477 providers: Arc<ProviderFactory>,
1478 agent_type_name: &str,
1479 tools: Option<&[String]>,
1480 config: &mermaid_domain::Config,
1481 safety_mode: SafetyMode,
1482 web: &WebCapabilities,
1483) -> Arc<ToolRegistry> {
1484 use super::{apply_patch, computer_use, exec, filesystem, mcp};
1485 let allowed = |name: &str| tools.is_none_or(|t| t.iter().any(|x| x == name));
1486 let mut r = ToolRegistry::new();
1487 if allowed("read_file") {
1488 r.register(Arc::new(filesystem::ReadFileTool));
1489 }
1490 if allowed("write_file") {
1491 r.register(Arc::new(filesystem::WriteFileTool));
1492 }
1493 if allowed("apply_patch") {
1494 r.register(Arc::new(apply_patch::ApplyPatchTool));
1495 }
1496 if allowed("delete_file") {
1497 r.register(Arc::new(filesystem::DeleteFileTool));
1498 }
1499 if allowed("create_directory") {
1500 r.register(Arc::new(filesystem::CreateDirectoryTool));
1501 }
1502 if allowed("execute_command") {
1503 r.register(Arc::new(exec::ExecuteCommandTool));
1504 }
1505 if allowed("mcp") {
1506 r.register(Arc::new(mcp::McpToolProxy));
1507 }
1508 let search_allowed =
1514 allowed("web_search") && headless_web_tool_is_executable(config, safety_mode, "web_search");
1515 let fetch_allowed =
1516 allowed("web_fetch") && headless_web_tool_is_executable(config, safety_mode, "web_fetch");
1517 if search_allowed || fetch_allowed {
1518 if search_allowed && let Some(tool) = web.search_tool() {
1519 r.register(Arc::new(tool));
1520 }
1521 if fetch_allowed && let Some(tool) = web.fetch_tool() {
1522 r.register(Arc::new(tool));
1523 }
1524 }
1525 let _ = computer_use::probe;
1530 let _ = providers;
1531 note_absent_child_tools(&mut r, agent_type_name, tools, config, safety_mode, web);
1532 Arc::new(r)
1533}
1534
1535fn note_absent_child_tools(
1541 r: &mut ToolRegistry,
1542 type_name: &str,
1543 tools: Option<&[String]>,
1544 config: &mermaid_domain::Config,
1545 safety_mode: SafetyMode,
1546 web: &WebCapabilities,
1547) {
1548 const FILTERABLE: &[(&str, &str)] = &[
1551 ("read_file", "read_file"),
1552 ("write_file", "write_file"),
1553 ("apply_patch", "apply_patch"),
1554 ("delete_file", "delete_file"),
1555 ("create_directory", "create_directory"),
1556 ("execute_command", "execute_command"),
1557 ("web_search", "web_search"),
1558 ("web_fetch", "web_fetch"),
1559 ("mcp_proxy", "mcp"),
1560 ];
1561 let allowed = |name: &str| tools.is_none_or(|t| t.iter().any(|x| x == name));
1562 let toolset = tools.map_or_else(|| CHILD_TOOL_NAMES.join(", "), |t| t.join(", "));
1563 for &(key, filter_name) in FILTERABLE {
1564 if r.get(key).is_none() && !allowed(filter_name) {
1565 r.note_unavailable(
1566 key,
1567 format!(
1568 "not in agent type '{type_name}'s toolset ({toolset}). State in \
1569 your report that the task needs it — the parent can re-run \
1570 with a type that carries it (e.g. \"general\")"
1571 ),
1572 );
1573 }
1574 }
1575 for tool in ["web_search", "web_fetch"] {
1580 if r.get(tool).is_some() || r.unavailable_reason(tool).is_some() {
1581 continue;
1582 }
1583 let reason = if config.safety.network == mermaid_domain::NetworkPolicy::Deny {
1584 "network access is off (safety.network = \"deny\" / --no-network)".to_string()
1585 } else if !headless_web_tool_is_executable(config, safety_mode, tool) {
1586 let readonly_extra = if safety_mode == SafetyMode::ReadOnly {
1587 ", or set [safety] allow_readonly_web = true to permit unattended \
1588 public-web reads in read_only"
1589 } else {
1590 ""
1591 };
1592 format!(
1593 "safety mode '{}' requires an interactive approval for web egress, \
1594 and a subagent runs headless. Do NOT fabricate web findings — \
1595 report that live web access was unavailable. The user can enable \
1596 it with /safety auto or full_access{readonly_extra}",
1597 safety_mode.as_str()
1598 )
1599 } else {
1600 let status = if tool == "web_search" {
1601 &web.search
1602 } else {
1603 &web.fetch
1604 };
1605 status.absence_reason(tool)
1606 };
1607 r.note_unavailable(tool, reason);
1608 }
1609 r.note_unavailable(
1611 "agent",
1612 "subagents cannot spawn subagents; do the work directly or report \
1613 what should be delegated back to the parent",
1614 );
1615 for gui in [
1616 "screenshot",
1617 "click",
1618 "type_text",
1619 "press_key",
1620 "scroll",
1621 "mouse_move",
1622 "list_windows",
1623 ] {
1624 r.note_unavailable(
1625 gui,
1626 "GUI / computer-use tools are parent-only; a subagent cannot drive \
1627 the desktop",
1628 );
1629 }
1630}
1631
1632fn headless_web_tool_is_executable(
1636 config: &mermaid_domain::Config,
1637 safety_mode: SafetyMode,
1638 tool: &'static str,
1639) -> bool {
1640 use mermaid_runtime::{ActionRequest, PolicyDecision, PolicyEngine, ToolCategory};
1641
1642 if config.safety.network == mermaid_domain::NetworkPolicy::Deny {
1643 return false;
1644 }
1645 let request = ActionRequest::new(tool, ToolCategory::Web, tool);
1646 let decision = PolicyEngine::new(safety_mode)
1647 .with_overrides(config.safety.overrides.clone())
1648 .with_external_writes(config.safety.external_writes)
1649 .with_system_installs(config.safety.system_installs)
1650 .decide(&request);
1651 match decision {
1652 PolicyDecision::Allow { .. } => true,
1653 PolicyDecision::Classify { .. } => safety_mode == SafetyMode::Auto,
1655 PolicyDecision::Ask { .. } => {
1656 config.safety.allow_untrusted_headless_tools
1657 || (safety_mode == SafetyMode::ReadOnly && config.safety.allow_readonly_web)
1658 },
1659 PolicyDecision::Deny { .. } => false,
1660 }
1661}
1662
1663fn default_model_id(config: &mermaid_domain::Config) -> String {
1668 if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
1669 format!(
1670 "{}/{}",
1671 config.default_model.provider, config.default_model.name
1672 )
1673 } else {
1674 config.default_model.name.clone()
1675 }
1676}
1677
1678#[cfg(test)]
1679mod tests {
1680 use super::*;
1681 use crate::providers::ctx::test_exec_context;
1682 use mermaid_domain::{ToolCallId, TurnId};
1683 use std::path::PathBuf;
1684
1685 fn test_state() -> State {
1686 State::new(
1687 mermaid_domain::Config::default(),
1688 PathBuf::from("/tmp"),
1689 "ollama/test".to_string(),
1690 chrono::Local::now(),
1691 PathBuf::from("/tmp"),
1692 )
1693 }
1694
1695 fn test_spawner() -> SubagentSpawner {
1696 let config = mermaid_domain::Config::default();
1697 let providers = Arc::new(ProviderFactory::new(config.clone()));
1698 let web_capabilities = Arc::new(WebCapabilities::resolve(&config.web));
1699 SubagentSpawner::new(providers, web_capabilities)
1700 }
1701
1702 fn test_spawner_arc() -> Arc<SubagentSpawner> {
1703 Arc::new(test_spawner())
1704 }
1705
1706 fn stream_text(chunk: &str) -> Msg {
1707 Msg::StreamText {
1708 turn: TurnId(1),
1709 chunk: chunk.to_string(),
1710 }
1711 }
1712
1713 #[tokio::test]
1714 async fn child_stream_chunks_never_forward_text_only_one_phase_change() {
1715 let state = test_state();
1720 let now = tokio::time::Instant::now();
1721 let mut progress = ChildProgress::new(now);
1722
1723 let first = progress.observe(&stream_text("chunk one — some text"), &state, now);
1724 assert!(
1725 first.iter().any(
1726 |e| matches!(e, ProgressEvent::SubagentActivity(label) if label == "replying")
1727 ),
1728 "first chunk announces the phase: {first:?}"
1729 );
1730 assert!(
1731 !first
1732 .iter()
1733 .any(|e| matches!(e, ProgressEvent::SubagentToolCall { .. })),
1734 "no raw text ever forwards: {first:?}"
1735 );
1736
1737 for i in 0..50 {
1739 let events = progress.observe(&stream_text(&format!("chunk {i}")), &state, now);
1740 assert!(
1741 events.is_empty(),
1742 "chunk {i} must be silent inside the throttle window: {events:?}"
1743 );
1744 }
1745 }
1746
1747 #[tokio::test]
1748 async fn token_estimates_respect_the_throttle_and_snap_to_provider_usage() {
1749 let state = test_state();
1750 let start = tokio::time::Instant::now();
1751 let mut progress = ChildProgress::new(start);
1752
1753 let _ = progress.observe(&stream_text("xy"), &state, start);
1755 let silent = progress.observe(&stream_text(&"x".repeat(400)), &state, start);
1757 assert!(
1758 silent.is_empty(),
1759 "inside the window stays silent: {silent:?}"
1760 );
1761 let later = start + TOKEN_PROGRESS_INTERVAL;
1763 let events = progress.observe(&stream_text("y"), &state, later);
1764 assert!(
1765 events
1766 .iter()
1767 .any(|e| matches!(e, ProgressEvent::SubagentTokens(t) if *t >= 100)),
1768 "tokens flush after the interval: {events:?}"
1769 );
1770
1771 let done = Msg::StreamDone {
1774 turn: TurnId(1),
1775 usage: Some(mermaid_model::models::TokenUsage::provider(10, 5_000)),
1776 provider_continuation: None,
1777 stop_reason: None,
1778 };
1779 let much_later = later + TOKEN_PROGRESS_INTERVAL;
1780 let events = progress.observe(&done, &state, much_later);
1781 assert!(
1782 events
1783 .iter()
1784 .any(|e| matches!(e, ProgressEvent::SubagentTokens(t) if *t >= 5_000)),
1785 "provider usage snaps the counter: {events:?}"
1786 );
1787 }
1788
1789 #[tokio::test]
1790 async fn empty_prompt_is_rejected() {
1791 let spawner = test_spawner_arc();
1792 let tool = SubagentTool::new(spawner);
1793 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1794 let outcome = tool.execute(serde_json::json!({"prompt": " "}), ctx).await;
1795 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1796 }
1797
1798 #[test]
1799 fn child_state_inherits_live_safety_mode_over_config_default() {
1800 use mermaid_runtime::SafetyMode;
1804 let mut config = mermaid_domain::Config::default();
1805 config.safety.mode = SafetyMode::FullAccess; let mut child_state = State::new(
1807 config,
1808 PathBuf::from("/tmp"),
1809 "ollama/test".to_string(),
1810 chrono::Local::now(),
1811 PathBuf::from("/tmp"),
1812 );
1813 assert_eq!(child_state.session.safety_mode, SafetyMode::FullAccess);
1815 child_state.session.safety_mode = SafetyMode::Ask;
1817 assert_eq!(child_state.session.safety_mode, SafetyMode::Ask);
1818 }
1819
1820 #[test]
1821 fn child_state_inherits_the_parent_scratchpad() {
1822 let (mut ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1826 ctx.scratchpad = Some(PathBuf::from("/data/tmp/scratchpad/-proj/s"));
1827 let mut child_state = test_state();
1829 assert_eq!(child_state.session.scratchpad, None);
1830 child_state.session.scratchpad = ctx.scratchpad.clone();
1833 assert_eq!(
1834 child_state.session.scratchpad.as_deref(),
1835 Some(std::path::Path::new("/data/tmp/scratchpad/-proj/s"))
1836 );
1837 }
1838
1839 #[test]
1843 fn default_model_id_reads_config_provider_and_name() {
1844 let mut cfg = mermaid_domain::Config::default();
1845 cfg.default_model.provider = "ollama".to_string();
1846 cfg.default_model.name = "qwen3-coder:30b".to_string();
1847 assert_eq!(default_model_id(&cfg), "ollama/qwen3-coder:30b");
1848 }
1849
1850 #[test]
1851 fn default_model_id_returns_bare_name_when_provider_empty() {
1852 let mut cfg = mermaid_domain::Config::default();
1853 cfg.default_model.name = "just-a-name".to_string();
1854 assert_eq!(default_model_id(&cfg), "just-a-name");
1857 }
1858
1859 #[test]
1860 fn apply_live_mcp_marks_running_servers_ready_with_their_tools() {
1861 use mermaid_domain::{McpServerEntry, McpServerStatus};
1862 let entry = || McpServerEntry {
1863 config: mermaid_domain::McpServerConfig::default(),
1864 status: McpServerStatus::Starting,
1865 tools: Vec::new(),
1866 };
1867 let mut servers = std::collections::HashMap::new();
1868 servers.insert("slack".to_string(), entry());
1869 servers.insert("broken".to_string(), entry());
1870
1871 let live = vec![
1872 (
1873 "slack".to_string(),
1874 mermaid_domain::McpToolSpec {
1875 name: "mcp__slack__send".to_string(),
1876 raw_name: "send".to_string(),
1877 description: "send a message".to_string(),
1878 input_schema: serde_json::json!({"type": "object"}),
1879 read_only_hint: false,
1880 },
1881 ),
1882 (
1885 "other".to_string(),
1886 mermaid_domain::McpToolSpec {
1887 name: "mcp__other__x".to_string(),
1888 raw_name: "x".to_string(),
1889 description: String::new(),
1890 input_schema: serde_json::json!({}),
1891 read_only_hint: false,
1892 },
1893 ),
1894 ];
1895 apply_live_mcp(&mut servers, &live, |name| name == "slack");
1896
1897 let slack = &servers["slack"];
1898 assert_eq!(slack.status, McpServerStatus::Ready);
1899 assert_eq!(slack.tools.len(), 1);
1900 assert_eq!(slack.tools[0].name, "mcp__slack__send");
1901 assert_eq!(slack.tools[0].raw_name, "send");
1902 assert_eq!(servers["broken"].status, McpServerStatus::Starting);
1904 assert!(servers["broken"].tools.is_empty());
1905 assert!(!servers.contains_key("other"));
1906 }
1907
1908 #[test]
1909 fn subagent_metadata_carries_usage_only_when_reported() {
1910 let some = subagent_metadata(
1911 "ollama/test".to_string(),
1912 TokenUsageTotals {
1913 prompt_tokens: 100,
1914 completion_tokens: 40,
1915 ..TokenUsageTotals::default()
1916 },
1917 "a7".to_string(),
1918 );
1919 let usage = some.token_usage.expect("usage attached");
1920 assert_eq!(usage.total_tokens(), 140);
1921 assert_eq!(usage.completion_tokens, 40);
1922 assert!(matches!(
1923 some.detail,
1924 mermaid_domain::ToolMetadata::Subagent { ref model_id, ref agent_id }
1925 if model_id == "ollama/test" && agent_id == "a7"
1926 ));
1927 let none = subagent_metadata(
1929 "ollama/test".to_string(),
1930 TokenUsageTotals::default(),
1931 "a8".to_string(),
1932 );
1933 assert!(none.token_usage.is_none());
1934 }
1935
1936 #[test]
1937 fn usage_delta_reports_only_this_drive() {
1938 let before = TokenUsageTotals {
1941 prompt_tokens: 1_000,
1942 completion_tokens: 200,
1943 ..TokenUsageTotals::default()
1944 };
1945 let after = TokenUsageTotals {
1946 prompt_tokens: 1_600,
1947 completion_tokens: 350,
1948 ..TokenUsageTotals::default()
1949 };
1950 let delta = usage_delta(after, before);
1951 assert_eq!(delta.prompt_tokens, 600);
1952 assert_eq!(delta.completion_tokens, 150);
1953 assert_eq!(delta.total_tokens(), 750);
1954 let fresh = usage_delta(after, TokenUsageTotals::default());
1956 assert_eq!(fresh.total_tokens(), 1_950);
1957 }
1958
1959 #[test]
1960 fn resolve_agent_type_builtins_custom_shadowing_and_errors() {
1961 use mermaid_domain::AgentTypeConfig;
1962 let mut config = mermaid_domain::Config::default();
1963
1964 assert_eq!(resolve_agent_type(None, &config).unwrap().name, "general");
1966 assert_eq!(
1967 resolve_agent_type(None, &config).unwrap().safety_ceiling,
1968 SafetyMode::FullAccess,
1969 );
1970 let explore = resolve_agent_type(Some("explore"), &config).unwrap();
1971 assert_eq!(explore.safety_ceiling, SafetyMode::ReadOnly);
1972 assert!(explore.preamble.as_deref().unwrap().contains("read-only"));
1973 assert!(explore.allows_tool("read_file"));
1974 assert!(!explore.allows_tool("write_file"));
1975 assert!(!explore.allows_tool("mcp"));
1976
1977 let err = resolve_agent_type(Some("nope"), &config).unwrap_err();
1979 assert!(err.contains("general") && err.contains("explore"), "{err}");
1980
1981 config.agents.types.insert(
1983 "scout".to_string(),
1984 AgentTypeConfig {
1985 tools: Some(vec!["read_file".to_string()]),
1986 safety: Some("read_only".to_string()),
1987 preamble: Some("You are a scout.".to_string()),
1988 model: Some("ollama/qwen3:8b".to_string()),
1989 isolation: Some("worktree".to_string()),
1990 },
1991 );
1992 let scout = resolve_agent_type(Some("scout"), &config).unwrap();
1993 assert_eq!(scout.model.as_deref(), Some("ollama/qwen3:8b"));
1994 assert_eq!(scout.isolation, Isolation::Worktree);
1995 assert_eq!(scout.safety_ceiling, SafetyMode::ReadOnly);
1996
1997 config.agents.types.insert(
1999 "explore".to_string(),
2000 AgentTypeConfig {
2001 safety: Some("ask".to_string()),
2002 ..AgentTypeConfig::default()
2003 },
2004 );
2005 assert_eq!(
2006 resolve_agent_type(Some("explore"), &config)
2007 .unwrap()
2008 .safety_ceiling,
2009 SafetyMode::Ask,
2010 );
2011
2012 config.agents.types.insert(
2015 "bad-safety".to_string(),
2016 AgentTypeConfig {
2017 safety: Some("yolo".to_string()),
2018 ..AgentTypeConfig::default()
2019 },
2020 );
2021 assert!(
2022 resolve_agent_type(Some("bad-safety"), &config)
2023 .unwrap_err()
2024 .contains("yolo")
2025 );
2026 config.agents.types.insert(
2027 "bad-tool".to_string(),
2028 AgentTypeConfig {
2029 tools: Some(vec!["screenshot".to_string()]),
2030 ..AgentTypeConfig::default()
2031 },
2032 );
2033 assert!(
2034 resolve_agent_type(Some("bad-tool"), &config)
2035 .unwrap_err()
2036 .contains("screenshot")
2037 );
2038 }
2039
2040 #[test]
2041 fn agent_cache_stores_takes_and_evicts_oldest() {
2042 let spawner = test_spawner();
2043 let mk_state = || {
2044 State::new(
2045 mermaid_domain::Config::default(),
2046 PathBuf::from("/tmp"),
2047 "ollama/test".to_string(),
2048 chrono::Local::now(),
2049 PathBuf::from("/tmp"),
2050 )
2051 };
2052 let mk = || CachedAgent {
2053 state: mk_state(),
2054 type_name: "general".to_string(),
2055 workspace: Workspace::Shared {
2056 root: PathBuf::from("/tmp"),
2057 },
2058 };
2059
2060 assert_ne!(spawner.mint_agent_id(), spawner.mint_agent_id());
2062
2063 assert!(spawner.cache_store("x".to_string(), mk()).is_empty());
2066 assert!(spawner.cache_take("x").is_some());
2067 assert!(spawner.cache_take("x").is_none(), "take must remove");
2068
2069 let mut evicted = Vec::new();
2072 for i in 0..(MAX_CACHED_AGENTS + 2) {
2073 evicted.extend(spawner.cache_store(format!("e{i}"), mk()));
2074 }
2075 assert_eq!(evicted.len(), 2, "two past the cap, two handed back");
2076 assert!(spawner.cache_take("e0").is_none(), "oldest evicted");
2077 assert!(spawner.cache_take("e1").is_none(), "second-oldest evicted");
2078 assert!(
2079 spawner
2080 .cache_take(&format!("e{}", MAX_CACHED_AGENTS + 1))
2081 .is_some(),
2082 "newest survives",
2083 );
2084 }
2085
2086 #[tokio::test]
2087 async fn continuing_an_unknown_agent_id_errors_actionably() {
2088 let spawner = test_spawner_arc();
2089 let tool = SubagentTool::new(spawner);
2090 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2091 let outcome = tool
2092 .execute(
2093 serde_json::json!({"prompt": "follow up", "agent_id": "a99"}),
2094 ctx,
2095 )
2096 .await;
2097 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
2098 let msg = outcome.error_message().unwrap_or_default();
2099 assert!(msg.contains("a99"), "names the bad id: {msg}");
2100 assert!(
2101 msg.contains("Omit agent_id"),
2102 "tells the model how to recover: {msg}"
2103 );
2104 }
2105
2106 #[tokio::test]
2107 async fn an_unparseable_isolation_arg_names_the_valid_modes() {
2108 let tool = SubagentTool::new(test_spawner_arc());
2109 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2110 let outcome = tool
2111 .execute(
2112 serde_json::json!({"prompt": "go", "isolation": "sandbox"}),
2113 ctx,
2114 )
2115 .await;
2116 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
2117 let msg = outcome.error_message().unwrap_or_default();
2118 assert!(msg.contains("sandbox"), "names what was rejected: {msg}");
2119 assert!(msg.contains("worktree"), "names the valid modes: {msg}");
2120 }
2121
2122 #[tokio::test]
2123 async fn asking_to_isolate_outside_a_repo_fails_the_spawn() {
2124 let tool = SubagentTool::new(test_spawner_arc());
2128 let dir = std::env::temp_dir().join(format!("mermaid_sub_norepo_{}", std::process::id()));
2129 let _ = std::fs::remove_dir_all(&dir);
2130 std::fs::create_dir_all(&dir).unwrap();
2131 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir);
2132 let outcome = tool
2133 .execute(
2134 serde_json::json!({"prompt": "go", "isolation": "worktree"}),
2135 ctx,
2136 )
2137 .await;
2138 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
2139 let msg = outcome.error_message().unwrap_or_default();
2140 assert!(msg.contains("could not isolate"), "{msg}");
2141 }
2142
2143 #[tokio::test]
2144 async fn a_failed_isolated_child_keeps_its_checkout_and_says_where() {
2145 use mermaid_runtime::git::git;
2146 let project = std::env::temp_dir().join(format!("mermaid_sub_keep_{}", std::process::id()));
2147 let _ = std::fs::remove_dir_all(&project);
2148 std::fs::create_dir_all(&project).unwrap();
2149 if git(&project).args(["init", "-q"]).run().is_err() {
2150 return;
2151 }
2152 std::fs::write(project.join("seed.txt"), "seed\n").unwrap();
2153 git(&project).args(["add", "-A"]).run().unwrap();
2154 let seed_id = project
2159 .file_name()
2160 .and_then(|n| n.to_str())
2161 .unwrap_or("repo");
2162 git(&project)
2163 .args(["commit", "-qm", &format!("init {seed_id}")])
2164 .run()
2165 .unwrap();
2166
2167 let mut config = mermaid_domain::Config::default();
2171 config.ollama.host = "http://127.0.0.1:1".to_string();
2172 config.safety.mode = SafetyMode::FullAccess;
2175 let providers = Arc::new(ProviderFactory::new(config.clone()));
2176 let web = Arc::new(WebCapabilities::resolve(&config.web));
2177 let tool = SubagentTool::new(Arc::new(SubagentSpawner::new(providers, web)));
2178 let (ctx, _rx) = crate::providers::ctx::test_exec_context_with_config(
2179 TurnId(1),
2180 ToolCallId(1),
2181 project.clone(),
2182 config,
2183 );
2184
2185 let outcome = tool
2186 .execute(
2187 serde_json::json!({
2188 "prompt": "go",
2189 "isolation": "worktree",
2190 "model": "ollama/does-not-exist",
2191 }),
2192 ctx,
2193 )
2194 .await;
2195
2196 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
2199 let msg = outcome.error_message().unwrap_or_default();
2200 assert!(msg.contains("isolated worktree is kept"), "{msg}");
2201 assert!(msg.contains("NOT in the project"), "{msg}");
2202 assert!(
2205 std::fs::read_to_string(project.join("seed.txt")).is_ok(),
2206 "the project must survive a failed child"
2207 );
2208 }
2209
2210 #[tokio::test]
2211 async fn unknown_agent_type_errors_actionably() {
2212 let spawner = test_spawner_arc();
2213 let tool = SubagentTool::new(spawner);
2214 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2215 let outcome = tool
2216 .execute(
2217 serde_json::json!({"prompt": "look around", "type": "wizard"}),
2218 ctx,
2219 )
2220 .await;
2221 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
2222 let msg = outcome.error_message().unwrap_or_default();
2223 assert!(msg.contains("wizard") && msg.contains("explore"), "{msg}");
2224 }
2225
2226 #[test]
2227 fn build_child_registry_excludes_gui_and_self() {
2228 let config = mermaid_domain::Config::default();
2229 let providers = Arc::new(ProviderFactory::new(config.clone()));
2230 let web = WebCapabilities::resolve(&config.web);
2231 let r = build_child_registry(providers, "general", None, &config, SafetyMode::Ask, &web);
2232 assert!(r.get("screenshot").is_none());
2234 assert!(r.get("click").is_none());
2235 assert!(r.get("type_text").is_none());
2236 assert!(r.get("press_key").is_none());
2237 assert!(r.get("scroll").is_none());
2238 assert!(r.get("mouse_move").is_none());
2239 assert!(r.get("list_windows").is_none());
2240 assert!(r.get("agent").is_none());
2242 assert!(r.get("read_file").is_some());
2244 assert!(r.get("execute_command").is_some());
2245 assert!(r.get("web_fetch").is_none());
2248 assert!(r.get("web_search").is_none());
2249 for tool in ["web_fetch", "web_search"] {
2252 let reason = r.unavailable_reason(tool).expect("absence reason");
2253 assert!(reason.contains("headless"), "{tool}: {reason}");
2254 assert!(reason.contains("ask"), "{tool}: {reason}");
2255 assert!(reason.contains("fabricate"), "{tool}: {reason}");
2256 assert!(
2257 !reason.contains("allow_readonly_web"),
2258 "the read_only remedy must not show for ask: {reason}"
2259 );
2260 }
2261 let agent = r.unavailable_reason("agent").expect("agent reason");
2263 assert!(agent.contains("cannot spawn subagents"), "{agent}");
2264 let gui = r.unavailable_reason("screenshot").expect("gui reason");
2265 assert!(gui.contains("parent-only"), "{gui}");
2266 assert!(r.unavailable_reason("not_a_tool").is_none());
2268 let outcome = r.unknown_tool_outcome("not_a_tool", "not_a_tool");
2269 assert_eq!(
2270 outcome.error_message().unwrap_or_default(),
2271 "unknown tool: not_a_tool"
2272 );
2273 }
2274
2275 #[test]
2276 fn readonly_child_web_reason_names_the_readonly_opt_in() {
2277 let mut config = mermaid_domain::Config::default();
2278 config.safety.mode = SafetyMode::ReadOnly;
2279 let providers = Arc::new(ProviderFactory::new(config.clone()));
2280 let web = WebCapabilities::resolve(&config.web);
2281 let r = build_child_registry(
2282 providers,
2283 "general",
2284 None,
2285 &config,
2286 SafetyMode::ReadOnly,
2287 &web,
2288 );
2289 assert!(r.get("web_fetch").is_none());
2290 let reason = r.unavailable_reason("web_fetch").expect("absence reason");
2291 assert!(reason.contains("allow_readonly_web"), "{reason}");
2292 }
2293
2294 #[test]
2295 fn network_deny_child_web_reason_names_the_kill_switch() {
2296 let mut config = mermaid_domain::Config::default();
2297 config.safety.network = mermaid_domain::NetworkPolicy::Deny;
2298 let providers = Arc::new(ProviderFactory::new(config.clone()));
2299 let web = WebCapabilities::resolve(&config.web);
2300 let r = build_child_registry(
2301 providers,
2302 "general",
2303 None,
2304 &config,
2305 SafetyMode::FullAccess,
2306 &web,
2307 );
2308 assert!(r.get("web_search").is_none());
2309 let reason = r.unavailable_reason("web_search").expect("absence reason");
2310 assert!(reason.contains("safety.network"), "{reason}");
2311 }
2312
2313 #[test]
2314 fn child_registry_exposes_web_only_when_headless_policy_can_execute_it() {
2315 let configured = |mode, readonly_web, headless_opt_in, network| {
2316 let mut config = mermaid_domain::Config::default();
2317 config.safety.mode = mode;
2318 config.safety.allow_readonly_web = readonly_web;
2319 config.safety.allow_untrusted_headless_tools = headless_opt_in;
2320 config.safety.network = network;
2321 config.web.search_backend = mermaid_domain::SearchBackend::Searxng;
2324 config.web.searxng_url = "http://127.0.0.1:8080".to_string();
2325 let providers = Arc::new(ProviderFactory::new(config.clone()));
2326 let web = WebCapabilities::resolve(&config.web);
2327 build_child_registry(providers, "general", None, &config, mode, &web)
2328 };
2329
2330 for mode in [SafetyMode::Auto, SafetyMode::FullAccess] {
2331 let registry = configured(mode, false, false, mermaid_domain::NetworkPolicy::Allow);
2332 assert!(registry.get("web_fetch").is_some(), "mode {mode:?}");
2333 assert!(registry.get("web_search").is_some(), "mode {mode:?}");
2334 }
2335
2336 let readonly = configured(
2337 SafetyMode::ReadOnly,
2338 true,
2339 false,
2340 mermaid_domain::NetworkPolicy::Allow,
2341 );
2342 assert!(readonly.get("web_fetch").is_some());
2343 assert!(readonly.get("web_search").is_some());
2344
2345 let opted_in = configured(
2346 SafetyMode::Ask,
2347 false,
2348 true,
2349 mermaid_domain::NetworkPolicy::Allow,
2350 );
2351 assert!(opted_in.get("web_fetch").is_some());
2352 assert!(opted_in.get("web_search").is_some());
2353
2354 let denied = configured(
2355 SafetyMode::FullAccess,
2356 true,
2357 true,
2358 mermaid_domain::NetworkPolicy::Deny,
2359 );
2360 assert!(denied.get("web_fetch").is_none());
2361 assert!(denied.get("web_search").is_none());
2362 }
2363
2364 #[test]
2365 fn child_web_visibility_honors_explicit_policy_overrides() {
2366 let mut config = mermaid_domain::Config::default();
2367 config.safety.overrides = vec![mermaid_runtime::PolicyOverride {
2368 category: Some(mermaid_runtime::ToolCategory::Web),
2369 decision: mermaid_runtime::PolicyOverrideDecision::Allow,
2370 ..mermaid_runtime::PolicyOverride::default()
2371 }];
2372 assert!(headless_web_tool_is_executable(
2373 &config,
2374 SafetyMode::Ask,
2375 "web_fetch"
2376 ));
2377
2378 config.safety.overrides[0].decision = mermaid_runtime::PolicyOverrideDecision::Deny;
2379 assert!(!headless_web_tool_is_executable(
2380 &config,
2381 SafetyMode::FullAccess,
2382 "web_fetch"
2383 ));
2384 }
2385
2386 #[test]
2387 fn kill_detached_fires_registered_tokens_and_evicts_cached_children() {
2388 let spawner = test_spawner();
2389
2390 let cancel = CancellationToken::new();
2392 spawner.register_detached("a1".to_string(), cancel.clone());
2393 assert!(matches!(spawner.kill_detached("a1"), KillResult::Killed));
2394 assert!(cancel.is_cancelled());
2395 assert!(matches!(spawner.kill_detached("a1"), KillResult::NotFound));
2397
2398 let _ = spawner.cache_store(
2401 "a2".to_string(),
2402 CachedAgent {
2403 state: test_state(),
2404 type_name: "general".to_string(),
2405 workspace: Workspace::Shared {
2406 root: PathBuf::from("/tmp"),
2407 },
2408 },
2409 );
2410 assert!(matches!(
2411 spawner.kill_detached("a2"),
2412 KillResult::Evicted(_)
2413 ));
2414 assert!(spawner.cache_take("a2").is_none(), "eviction is permanent");
2415
2416 assert!(matches!(spawner.kill_detached("a99"), KillResult::NotFound));
2418
2419 let (c1, c2) = (CancellationToken::new(), CancellationToken::new());
2421 spawner.register_detached("a3".to_string(), c1.clone());
2422 spawner.register_detached("a4".to_string(), c2.clone());
2423 assert_eq!(spawner.kill_all_detached(), 2);
2424 assert!(c1.is_cancelled() && c2.is_cancelled());
2425 assert_eq!(spawner.kill_all_detached(), 0);
2426 }
2427
2428 #[tokio::test]
2429 async fn kill_action_validates_agent_id_and_skips_prompt_requirement() {
2430 let spawner = test_spawner_arc();
2431 let tool = SubagentTool::new(spawner.clone());
2432
2433 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
2436 let outcome = tool
2437 .execute(serde_json::json!({"action": "kill"}), ctx)
2438 .await;
2439 assert!(!outcome.is_success());
2440 assert!(outcome.model_content.contains("requires `agent_id`"));
2441
2442 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
2444 let outcome = tool
2445 .execute(serde_json::json!({"action": "kill", "agent_id": "a7"}), ctx)
2446 .await;
2447 assert!(!outcome.is_success());
2448 assert!(outcome.model_content.contains("a7"));
2449
2450 let cancel = CancellationToken::new();
2452 spawner.register_detached("a7".to_string(), cancel.clone());
2453 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(3), PathBuf::from("/tmp"));
2454 let outcome = tool
2455 .execute(serde_json::json!({"action": "kill", "agent_id": "a7"}), ctx)
2456 .await;
2457 assert!(outcome.is_success(), "{}", outcome.model_content);
2458 assert!(cancel.is_cancelled());
2459 }
2460
2461 #[test]
2462 fn explore_registry_is_a_read_only_surface() {
2463 let providers = Arc::new(ProviderFactory::new(mermaid_domain::Config::default()));
2464 let explore = builtin_agent_type("explore").expect("builtin");
2465 let config = mermaid_domain::Config::default();
2466 let web = WebCapabilities::resolve(&config.web);
2467 let r = build_child_registry(
2468 providers,
2469 &explore.name,
2470 explore.tools.as_deref(),
2471 &config,
2472 SafetyMode::ReadOnly,
2473 &web,
2474 );
2475 assert!(r.get("read_file").is_some());
2476 assert!(r.get("execute_command").is_some());
2477 for tool in [
2478 "write_file",
2479 "apply_patch",
2480 "delete_file",
2481 "create_directory",
2482 "mcp_proxy",
2483 "agent",
2484 ] {
2485 assert!(r.get(tool).is_none(), "explore must not carry {tool}");
2486 }
2487 for tool in ["web_search", "web_fetch", "write_file", "mcp_proxy"] {
2491 let reason = r.unavailable_reason(tool).expect("absence reason");
2492 assert!(reason.contains("'explore'"), "{tool}: {reason}");
2493 assert!(reason.contains("general"), "{tool}: {reason}");
2494 }
2495 assert!(r.unavailable_reason("read_file").is_none());
2497 let outcome = r.unknown_tool_outcome("mcp_proxy", "mcp__github__search");
2499 let msg = outcome.error_message().unwrap_or_default();
2500 assert!(
2501 msg.starts_with("mcp__github__search is not available:"),
2502 "{msg}"
2503 );
2504 }
2505}