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