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