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;
63
64pub const MAX_INFLIGHT: usize = 10;
69
70pub const DEFAULT_TIMEOUT_SECS: u64 = 20 * 60;
74
75pub const MAX_CACHED_AGENTS: usize = 8;
78
79const EXPLORE_PREAMBLE: &str = "\
81## Explore Agent
82You are an Explore agent: read-only reconnaissance. Locate files, map \
83structure, and extract exactly the facts asked for, using reads and \
84read-only commands. You cannot mutate anything — do not try. Report \
85concrete paths, names, and findings.";
86
87const CHILD_TOOL_NAMES: &[&str] = &[
92 "read_file",
93 "write_file",
94 "apply_patch",
95 "delete_file",
96 "create_directory",
97 "execute_command",
98 "web_search",
99 "web_fetch",
100 "mcp",
101];
102
103#[derive(Debug)]
107struct AgentType {
108 name: String,
109 tools: Option<Vec<String>>,
111 safety_ceiling: SafetyMode,
114 preamble: Option<String>,
116 model: Option<String>,
118}
119
120impl AgentType {
121 fn allows_tool(&self, name: &str) -> bool {
122 self.tools
123 .as_ref()
124 .is_none_or(|tools| tools.iter().any(|t| t == name))
125 }
126}
127
128fn builtin_agent_type(name: &str) -> Option<AgentType> {
129 match name {
130 "general" => Some(AgentType {
131 name: "general".to_string(),
132 tools: None,
133 safety_ceiling: SafetyMode::FullAccess,
134 preamble: None,
135 model: None,
136 }),
137 "explore" => Some(AgentType {
138 name: "explore".to_string(),
139 tools: Some(vec!["read_file".to_string(), "execute_command".to_string()]),
140 safety_ceiling: SafetyMode::ReadOnly,
141 preamble: Some(EXPLORE_PREAMBLE.to_string()),
142 model: None,
143 }),
144 _ => None,
145 }
146}
147
148fn resolve_agent_type(
152 requested: Option<&str>,
153 config: &crate::app::Config,
154) -> Result<AgentType, String> {
155 let name = requested.unwrap_or("general");
156 if let Some(custom) = config.agents.types.get(name) {
157 let safety_ceiling = match custom.safety.as_deref() {
158 None => SafetyMode::FullAccess,
159 Some(s) => SafetyMode::parse(s).ok_or_else(|| {
160 format!(
161 "[agents.types.{name}] safety '{s}' is not one of \
162 read_only/ask/auto/full_access"
163 )
164 })?,
165 };
166 if let Some(tools) = &custom.tools
167 && let Some(bad) = tools
168 .iter()
169 .find(|t| !CHILD_TOOL_NAMES.contains(&t.as_str()))
170 {
171 return Err(format!(
172 "[agents.types.{name}] unknown tool '{bad}'; valid tools: {}",
173 CHILD_TOOL_NAMES.join(", ")
174 ));
175 }
176 return Ok(AgentType {
177 name: name.to_string(),
178 tools: custom.tools.clone(),
179 safety_ceiling,
180 preamble: custom.preamble.clone(),
181 model: custom.model.clone(),
182 });
183 }
184 builtin_agent_type(name).ok_or_else(|| {
185 let mut available: Vec<&str> = vec!["general", "explore"];
186 available.extend(config.agents.types.keys().map(String::as_str));
187 format!(
188 "unknown agent type '{name}'; available: {}",
189 available.join(", ")
190 )
191 })
192}
193
194struct CachedAgent {
198 state: State,
199 type_name: String,
200}
201
202#[derive(Default)]
203struct AgentCache {
204 entries: HashMap<String, CachedAgent>,
205 order: VecDeque<String>,
207}
208
209pub struct SubagentSpawner {
211 providers: Arc<ProviderFactory>,
212 web_capabilities: Arc<WebCapabilities>,
213 inflight: Arc<Semaphore>,
214 next_agent_id: AtomicU64,
216 cache: Mutex<AgentCache>,
220 detached_cancels: Mutex<HashMap<String, CancellationToken>>,
224}
225
226#[derive(Debug, PartialEq, Eq)]
228pub enum KillResult {
229 Killed,
232 Evicted,
235 NotFound,
236}
237
238impl SubagentSpawner {
239 pub fn new(providers: Arc<ProviderFactory>, web_capabilities: Arc<WebCapabilities>) -> Self {
240 Self {
241 providers,
242 web_capabilities,
243 inflight: Arc::new(Semaphore::new(MAX_INFLIGHT)),
244 next_agent_id: AtomicU64::new(0),
245 cache: Mutex::new(AgentCache::default()),
246 detached_cancels: Mutex::new(HashMap::new()),
247 }
248 }
249
250 fn mint_agent_id(&self) -> String {
251 format!(
252 "a{}",
253 self.next_agent_id.fetch_add(1, Ordering::Relaxed) + 1
254 )
255 }
256
257 fn cache_take(&self, id: &str) -> Option<CachedAgent> {
259 let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
260 cache.order.retain(|x| x != id);
261 cache.entries.remove(id)
262 }
263
264 fn cache_store(&self, id: String, agent: CachedAgent) {
266 let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
267 cache.order.retain(|x| x != &id);
268 cache.order.push_back(id.clone());
269 cache.entries.insert(id, agent);
270 while cache.entries.len() > MAX_CACHED_AGENTS {
271 let Some(oldest) = cache.order.pop_front() else {
272 break;
273 };
274 cache.entries.remove(&oldest);
275 }
276 }
277
278 fn register_detached(&self, agent_id: String, cancel: CancellationToken) {
279 self.detached_cancels
280 .lock()
281 .unwrap_or_else(|e| e.into_inner())
282 .insert(agent_id, cancel);
283 }
284
285 fn unregister_detached(&self, agent_id: &str) {
286 self.detached_cancels
287 .lock()
288 .unwrap_or_else(|e| e.into_inner())
289 .remove(agent_id);
290 }
291
292 pub fn kill_detached(&self, agent_id: &str) -> KillResult {
297 let cancel = self
298 .detached_cancels
299 .lock()
300 .unwrap_or_else(|e| e.into_inner())
301 .remove(agent_id);
302 if let Some(cancel) = cancel {
303 cancel.cancel();
304 return KillResult::Killed;
305 }
306 if self.cache_take(agent_id).is_some() {
307 return KillResult::Evicted;
308 }
309 KillResult::NotFound
310 }
311
312 pub fn kill_all_detached(&self) -> usize {
314 let cancels: Vec<CancellationToken> = {
315 let mut map = self
316 .detached_cancels
317 .lock()
318 .unwrap_or_else(|e| e.into_inner());
319 map.drain().map(|(_, c)| c).collect()
320 };
321 let n = cancels.len();
322 for cancel in cancels {
323 cancel.cancel();
324 }
325 n
326 }
327}
328
329pub struct SubagentTool {
331 spawner: Arc<SubagentSpawner>,
332}
333
334impl SubagentTool {
335 pub fn new(spawner: Arc<SubagentSpawner>) -> Self {
336 Self { spawner }
337 }
338}
339
340#[async_trait]
341impl ToolExecutor for SubagentTool {
342 fn name(&self) -> &'static str {
343 "agent"
344 }
345
346 fn schema(&self) -> ToolDefinition {
347 ToolDefinition {
348 name: "agent".to_string(),
349 description: format!(
350 "Spawn a child agent with its own context and tool access to work on an \
351 independent sub-task. Useful for parallel fan-out (emit multiple `agent` \
352 calls in the same turn to run them concurrently) or for scoping a noisy \
353 sub-task (the child's tool output doesn't clutter the parent's turn). \
354 Types: 'general' (default — full tool access at your safety mode) and \
355 'explore' (read-only reconnaissance: locate files and extract facts, \
356 cannot mutate), plus any defined in config [agents.types]. Every result \
357 ends with an [agent_id: …] trailer; pass that id back as `agent_id` to \
358 send a follow-up prompt to the same child with its context intact (the \
359 {max_cached} most recent children are kept). Breadth-capped at \
360 {max_breadth} concurrent; subagents can't themselves spawn subagents \
361 and never get GUI (screenshot/click/…) access. A child moved to the \
362 background (the user detaches one with Ctrl+B) can be cancelled with \
363 action: \"kill\" plus its agent_id.",
364 max_cached = MAX_CACHED_AGENTS,
365 max_breadth = MAX_INFLIGHT,
366 ),
367 input_schema: serde_json::json!({
368 "type": "object",
369 "properties": {
370 "action": {
371 "type": "string",
372 "enum": ["spawn", "kill"],
373 "description": "Default 'spawn' (also covers continuing via agent_id). 'kill' cancels a backgrounded child by agent_id — no prompt needed."
374 },
375 "prompt": {
376 "type": "string",
377 "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."
378 },
379 "description": {
380 "type": "string",
381 "description": "Short label shown in the parent's status line (e.g. 'list domain files')."
382 },
383 "type": {
384 "type": "string",
385 "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."
386 },
387 "model": {
388 "type": "string",
389 "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."
390 },
391 "agent_id": {
392 "type": "string",
393 "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."
394 }
395 },
396 "required": []
397 }),
398 }
399 }
400
401 async fn execute(&self, args: Value, ctx: ExecContext) -> ToolOutcome {
402 let started = Instant::now();
403
404 if args.get("action").and_then(|v| v.as_str()) == Some("kill") {
411 let Some(id) = args
412 .get("agent_id")
413 .and_then(|v| v.as_str())
414 .map(str::trim)
415 .filter(|s| !s.is_empty())
416 else {
417 return ToolOutcome::error("action 'kill' requires `agent_id`", 0.0);
418 };
419 return match self.spawner.kill_detached(id) {
420 KillResult::Killed => ToolOutcome::success(
421 format!(
422 "Background agent '{id}' cancelled — it unwinds at its next \
423 await point; a cancellation notice will appear in the \
424 conversation."
425 ),
426 "subagent killed",
427 started.elapsed().as_secs_f64(),
428 ),
429 KillResult::Evicted => ToolOutcome::success(
430 format!(
431 "Agent '{id}' had already finished; removed it from the \
432 continuation cache instead."
433 ),
434 "subagent evicted",
435 started.elapsed().as_secs_f64(),
436 ),
437 KillResult::NotFound => ToolOutcome::error(
438 format!(
439 "no background or cached agent '{id}' — it may have already \
440 finished and been evicted, or the id was never issued"
441 ),
442 started.elapsed().as_secs_f64(),
443 ),
444 };
445 }
446
447 let prompt = match args.get("prompt").and_then(|v| v.as_str()) {
449 Some(s) if !s.trim().is_empty() => s.to_string(),
450 _ => {
451 return ToolOutcome::error("agent requires non-empty `prompt`", 0.0);
452 },
453 };
454 let description = args
455 .get("description")
456 .and_then(|v| v.as_str())
457 .unwrap_or("subagent")
458 .to_string();
459 let requested_type = args
460 .get("type")
461 .and_then(|v| v.as_str())
462 .map(str::trim)
463 .filter(|s| !s.is_empty());
464 let model_override = args
465 .get("model")
466 .and_then(|v| v.as_str())
467 .map(str::trim)
468 .filter(|s| !s.is_empty());
469 let continue_id = args
470 .get("agent_id")
471 .and_then(|v| v.as_str())
472 .map(str::trim)
473 .filter(|s| !s.is_empty())
474 .map(str::to_string);
475
476 if let Some(blocked) = super::policy_gate::gate_external(
483 &ctx,
484 "agent",
485 crate::runtime::ToolCategory::Subagent,
486 format!("subagent: {}", description),
487 &args,
488 )
489 .await
490 {
491 return blocked;
492 }
493
494 let permit = tokio::select! {
498 biased;
499 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
500 p = self.spawner.inflight.clone().acquire_owned() => match p {
501 Ok(permit) => permit,
502 Err(_) => return ToolOutcome::error(
503 "subagent semaphore closed",
504 started.elapsed().as_secs_f64(),
505 ),
506 },
507 };
508
509 let config = (*ctx.config).clone();
519 let cwd = ctx.workdir.clone();
520
521 let (agent_id, cached) = match continue_id {
526 Some(id) => match self.spawner.cache_take(&id) {
527 Some(cached) => (id, Some(cached)),
528 None => {
529 return ToolOutcome::error(
530 format!(
531 "unknown agent_id '{id}': it may have expired (the \
532 {MAX_CACHED_AGENTS} most recent children are kept), be running \
533 a continuation right now, or never have existed. Omit agent_id \
534 to start a new agent."
535 ),
536 started.elapsed().as_secs_f64(),
537 );
538 },
539 },
540 None => (self.spawner.mint_agent_id(), None),
541 };
542
543 let type_name = cached
547 .as_ref()
548 .map(|c| c.type_name.clone())
549 .or_else(|| requested_type.map(str::to_string));
550 let agent_type = match resolve_agent_type(type_name.as_deref(), &config) {
551 Ok(agent_type) => agent_type,
552 Err(e) => {
553 if let Some(cached) = cached {
555 self.spawner.cache_store(agent_id, cached);
556 }
557 return ToolOutcome::error(e, started.elapsed().as_secs_f64());
558 },
559 };
560
561 let child_safety = SafetyMode::least_permissive(ctx.safety_mode, agent_type.safety_ceiling);
570
571 let model_id = model_override
573 .map(str::to_string)
574 .or_else(|| agent_type.model.clone())
575 .unwrap_or_else(|| {
576 if ctx.model_id.is_empty() {
577 default_model_id(&config)
578 } else {
579 ctx.model_id.clone()
580 }
581 });
582
583 let (mut child_state, usage_before) = match cached {
584 Some(cached) => {
585 let before = cached.state.session.cumulative_token_usage;
588 (cached.state, before)
589 },
590 None => (
591 State::new(
592 config.clone(),
593 cwd.clone(),
594 model_id.clone(),
595 chrono::Local::now(),
596 ),
597 TokenUsageTotals::default(),
598 ),
599 };
600 if let Some(model) = model_override {
603 child_state.session.model_id = model.to_string();
604 }
605 let child_model_id = child_state.session.model_id.clone();
606
607 child_state.now = chrono::Local::now();
615 child_state.session.safety_mode = child_safety;
616 child_state.session.is_subagent = true;
620 child_state.session.agent_preamble = agent_type.preamble.clone();
621 child_state.session.scratchpad = ctx.scratchpad.clone();
628 let (instructions, memory, skills) =
629 crate::app::instructions::load_project_context(&cwd, &config.memory);
630 child_state.instructions = instructions;
631 child_state.memory = memory;
632 child_state.skills = skills;
633 if agent_type.allows_tool("mcp") {
643 seed_child_mcp(&mut child_state);
644 }
645
646 let child_tools = build_child_registry(
647 self.spawner.providers.clone(),
648 agent_type.tools.as_deref(),
649 &config,
650 child_safety,
651 &self.spawner.web_capabilities,
652 );
653
654 let child_cancel = CancellationToken::new();
659 let (child_tx, child_rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
660 let child_runner =
661 EffectRunner::new_child(child_tx, cwd, self.spawner.providers.clone(), child_tools);
662
663 let timeout_secs = match config.agents.timeout_secs {
667 0 => DEFAULT_TIMEOUT_SECS,
668 secs => secs,
669 };
670 let (child_progress_tx, mut child_progress_rx) = mpsc::channel::<ProgressEvent>(16);
673 let mut drive = Box::pin(drive_child(
674 child_state,
675 child_runner,
676 child_rx,
677 child_progress_tx,
678 prompt,
679 child_cancel.clone(),
680 Duration::from_secs(timeout_secs),
681 ));
682
683 let mut progress_open = true;
684 let (result, final_state) = loop {
685 tokio::select! {
686 biased;
687 _ = ctx.token.cancelled() => {
688 child_cancel.cancel();
692 break drive.await;
693 },
694 _ = ctx.background.cancelled() => {
695 return self.detach_child(DetachArgs {
700 drive,
701 progress_rx: child_progress_rx,
702 permit,
703 cancel: child_cancel.clone(),
704 notify: ctx.notify.clone(),
705 agent_id,
706 description,
707 type_name: agent_type.name.clone(),
708 child_model_id,
709 usage_before,
710 timeout_secs,
711 started,
712 });
713 },
714 ev = child_progress_rx.recv(), if progress_open => match ev {
715 Some(ev) => { let _ = ctx.progress.send(ev).await; },
716 None => progress_open = false,
717 },
718 r = &mut drive => break r,
719 }
720 };
721 drop(permit);
722
723 finish_drive(
724 &self.spawner,
725 agent_type.name.clone(),
726 agent_id,
727 &description,
728 child_model_id,
729 usage_before,
730 timeout_secs,
731 started,
732 result,
733 final_state,
734 )
735 }
736}
737
738struct DetachArgs<F> {
741 drive: std::pin::Pin<Box<F>>,
742 progress_rx: mpsc::Receiver<ProgressEvent>,
743 permit: tokio::sync::OwnedSemaphorePermit,
744 cancel: CancellationToken,
747 notify: Option<mpsc::Sender<Msg>>,
748 agent_id: String,
749 description: String,
750 type_name: String,
751 child_model_id: String,
752 usage_before: TokenUsageTotals,
753 timeout_secs: u64,
754 started: Instant,
755}
756
757impl SubagentTool {
758 fn detach_child<F>(&self, args: DetachArgs<F>) -> ToolOutcome
764 where
765 F: std::future::Future<Output = (Result<String, DriveError>, State)> + Send + 'static,
766 {
767 let DetachArgs {
768 mut drive,
769 mut progress_rx,
770 permit,
771 cancel,
772 notify,
773 agent_id,
774 description,
775 type_name,
776 child_model_id,
777 usage_before,
778 timeout_secs,
779 started,
780 } = args;
781 if let Some(notify) = ¬ify {
782 let _ = notify.try_send(Msg::BackgroundAgentStarted {
783 agent_id: agent_id.clone(),
784 description: description.clone(),
785 });
786 }
787 let spawner = self.spawner.clone();
788 spawner.register_detached(agent_id.clone(), cancel);
791 let outcome_text = format!(
792 "Agent '{description}' ({agent_id}) moved to background — it keeps running and \
793 its report will be posted to the conversation when it finishes."
794 );
795 let (bg_agent_id, bg_description) = (agent_id, description);
796 tokio::spawn(async move {
797 let _permit = permit;
800 let mut activity = String::new();
801 let mut tokens = 0usize;
802 let mut progress_open = true;
803 let (result, final_state) = loop {
804 tokio::select! {
805 ev = progress_rx.recv(), if progress_open => match ev {
806 Some(ev) => {
807 match &ev {
808 ProgressEvent::SubagentToolCall { tool_name, phase, .. } => {
809 activity = match phase {
810 SubagentPhase::Started => format!("{tool_name}…"),
811 SubagentPhase::Finished => format!("{tool_name} done"),
812 SubagentPhase::Errored => format!("{tool_name} failed"),
813 };
814 },
815 ProgressEvent::SubagentActivity(label) => activity = label.clone(),
816 ProgressEvent::SubagentTokens(count) => tokens = *count,
817 _ => continue,
818 }
819 if let Some(notify) = ¬ify {
820 let _ = notify.try_send(Msg::BackgroundAgentProgress {
821 agent_id: bg_agent_id.clone(),
822 activity: activity.clone(),
823 tokens,
824 });
825 }
826 },
827 None => progress_open = false,
828 },
829 r = &mut drive => break r,
830 }
831 };
832 spawner.unregister_detached(&bg_agent_id);
833 let cancelled = matches!(result, Err(DriveError::Cancelled));
834 let outcome = finish_drive(
835 &spawner,
836 type_name,
837 bg_agent_id.clone(),
838 &bg_description,
839 child_model_id,
840 usage_before,
841 timeout_secs,
842 started,
843 result,
844 final_state,
845 );
846 if let Some(notify) = notify {
847 let usage = outcome.metadata.token_usage.clone();
848 let tokens_total = usage.as_ref().map_or(tokens, |u| u.total_tokens());
849 let _ = notify
850 .send(Msg::BackgroundAgentFinished {
851 agent_id: bg_agent_id,
852 description: bg_description,
853 report: outcome.model_content.clone(),
854 success: outcome.is_success(),
855 cancelled,
856 usage,
857 tokens: tokens_total,
858 duration_secs: started.elapsed().as_secs(),
859 })
860 .await;
861 }
862 });
863 ToolOutcome::success(
864 outcome_text,
865 "subagent backgrounded",
866 started.elapsed().as_secs_f64(),
867 )
868 }
869}
870
871#[allow(clippy::too_many_arguments)]
875fn finish_drive(
876 spawner: &SubagentSpawner,
877 type_name: String,
878 agent_id: String,
879 description: &str,
880 child_model_id: String,
881 usage_before: TokenUsageTotals,
882 timeout_secs: u64,
883 started: Instant,
884 result: Result<String, DriveError>,
885 mut final_state: State,
886) -> ToolOutcome {
887 let child_usage = usage_delta(final_state.session.cumulative_token_usage, usage_before);
888
889 if !matches!(result, Err(DriveError::Cancelled)) {
896 final_state.turn = TurnState::Idle;
897 final_state.ui.queued_messages.clear();
898 final_state.ui.live_tool_status.clear();
899 final_state.pending_approval.clear();
900 spawner.cache_store(
901 agent_id.clone(),
902 CachedAgent {
903 state: final_state,
904 type_name,
905 },
906 );
907 }
908
909 let elapsed = started.elapsed().as_secs_f64();
910 let trailer = format!("[agent_id: {agent_id} — pass agent_id to continue this child]");
911 let metadata = subagent_metadata(child_model_id, child_usage, agent_id);
912 match result {
913 Ok(summary) => ToolOutcome::success(
914 format!("{summary}\n\n{trailer}"),
915 "subagent completed",
916 elapsed,
917 )
918 .with_metadata(metadata),
919 Err(DriveError::Cancelled) => ToolOutcome::cancelled(),
920 Err(DriveError::TimedOut) => ToolOutcome::error(
921 format!(
922 "subagent ({description}) exceeded {timeout_secs}s timeout; its context \
923 is preserved — {trailer}"
924 ),
925 elapsed,
926 )
927 .with_metadata(metadata),
928 Err(DriveError::Errored(e)) => {
929 ToolOutcome::error(format!("subagent ({description}): {e} {trailer}"), elapsed)
930 .with_metadata(metadata)
931 },
932 }
933}
934
935fn subagent_metadata(
943 model_id: String,
944 usage: TokenUsageTotals,
945 agent_id: String,
946) -> ToolRunMetadata {
947 let token_usage = (usage.total_tokens() > 0).then(|| crate::models::TokenUsage {
948 prompt_tokens: usage.prompt_tokens,
949 completion_tokens: usage.completion_tokens,
950 cached_input_tokens: usage.cached_input_tokens,
951 cache_creation_input_tokens: usage.cache_creation_input_tokens,
952 reasoning_output_tokens: usage.reasoning_output_tokens,
953 source: Default::default(),
954 });
955 ToolRunMetadata {
956 detail: ToolMetadata::Subagent { model_id, agent_id },
957 token_usage,
958 ..ToolRunMetadata::default()
959 }
960}
961
962fn usage_delta(after: TokenUsageTotals, before: TokenUsageTotals) -> TokenUsageTotals {
967 TokenUsageTotals {
968 prompt_tokens: after.prompt_tokens.saturating_sub(before.prompt_tokens),
969 completion_tokens: after
970 .completion_tokens
971 .saturating_sub(before.completion_tokens),
972 cached_input_tokens: after
973 .cached_input_tokens
974 .saturating_sub(before.cached_input_tokens),
975 cache_creation_input_tokens: after
976 .cache_creation_input_tokens
977 .saturating_sub(before.cache_creation_input_tokens),
978 reasoning_output_tokens: after
979 .reasoning_output_tokens
980 .saturating_sub(before.reasoning_output_tokens),
981 }
982}
983
984enum DriveError {
985 Cancelled,
986 TimedOut,
987 Errored(String),
988}
989
990async fn drive_child(
998 mut state: State,
999 mut runner: EffectRunner,
1000 mut msg_rx: mpsc::Receiver<Msg>,
1001 parent_progress: mpsc::Sender<ProgressEvent>,
1002 prompt: String,
1003 token: CancellationToken,
1004 timeout: Duration,
1005) -> (Result<String, DriveError>, State) {
1006 let _ = parent_progress
1009 .send(ProgressEvent::SubagentActivity("starting…".to_string()))
1010 .await;
1011
1012 let seed = Msg::SubmitPrompt {
1019 text: prompt,
1020 attachment_ids: vec![],
1021 };
1022 let (new_state, cmds) = update(state, seed);
1023 state = new_state;
1024 for cmd in cmds {
1025 runner.dispatch(cmd);
1026 }
1027
1028 let deadline = tokio::time::sleep(timeout);
1034 tokio::pin!(deadline);
1035
1036 let mut outcome: Result<(), DriveError> = Ok(());
1037 let mut child_progress = ChildProgress::new(tokio::time::Instant::now());
1038 loop {
1039 if token.is_cancelled() {
1040 outcome = Err(DriveError::Cancelled);
1041 break;
1042 }
1043 if matches!(state.turn, TurnState::Idle) && state.ui.queued_messages.is_empty() {
1044 break;
1045 }
1046
1047 let msg = tokio::select! {
1048 biased;
1049 _ = token.cancelled() => {
1050 outcome = Err(DriveError::Cancelled);
1051 break;
1052 },
1053 _ = &mut deadline => {
1054 outcome = Err(DriveError::TimedOut);
1055 break;
1056 },
1057 recv = msg_rx.recv() => match recv {
1058 Some(m) => m,
1059 None => break, },
1061 };
1062
1063 for event in child_progress.observe(&msg, &state, tokio::time::Instant::now()) {
1067 let _ = parent_progress.send(event).await;
1068 }
1069
1070 let (new_state, cmds) = update(state, msg);
1071 state = new_state;
1072 for cmd in cmds {
1073 runner.dispatch(cmd);
1074 }
1075 if state.should_exit {
1076 break;
1077 }
1078 }
1079
1080 runner.shutdown().await;
1085
1086 if let Err(e) = outcome {
1087 return (Err(e), state);
1088 }
1089
1090 let summary = state
1092 .session
1093 .messages()
1094 .iter()
1095 .rev()
1096 .find(|m| m.role == MessageRole::Assistant)
1097 .map(|m| m.content.clone())
1098 .unwrap_or_default();
1099 if summary.trim().is_empty() {
1100 return (
1101 Err(DriveError::Errored(
1102 "subagent produced no assistant output".to_string(),
1103 )),
1104 state,
1105 );
1106 }
1107 (Ok(summary), state)
1108}
1109
1110const TOKEN_PROGRESS_INTERVAL: Duration = Duration::from_millis(500);
1114
1115struct ChildProgress {
1127 phase: &'static str,
1128 confirmed_tokens: usize,
1130 streamed_chars: usize,
1133 last_tokens_sent: usize,
1134 last_tokens_at: tokio::time::Instant,
1135}
1136
1137impl ChildProgress {
1138 fn new(now: tokio::time::Instant) -> Self {
1139 Self {
1140 phase: "",
1141 confirmed_tokens: 0,
1142 streamed_chars: 0,
1143 last_tokens_sent: 0,
1144 last_tokens_at: now,
1145 }
1146 }
1147
1148 fn total_tokens(&self) -> usize {
1149 self.confirmed_tokens + self.streamed_chars / 4
1150 }
1151
1152 fn observe(
1155 &mut self,
1156 msg: &Msg,
1157 state: &State,
1158 now: tokio::time::Instant,
1159 ) -> Vec<ProgressEvent> {
1160 let mut out = Vec::new();
1161 match msg {
1162 Msg::ToolStarted {
1163 turn: _, call_id, ..
1164 } => {
1165 let tool_name =
1166 lookup_tool_name(state, *call_id).unwrap_or_else(|| "tool".to_string());
1167 out.push(ProgressEvent::SubagentToolCall {
1168 child_call_id: *call_id,
1169 tool_name,
1170 phase: SubagentPhase::Started,
1171 });
1172 self.phase = "";
1174 },
1175 Msg::ToolFinished {
1176 turn: _,
1177 call_id,
1178 outcome,
1179 } => {
1180 let tool_name =
1181 lookup_tool_name(state, *call_id).unwrap_or_else(|| "tool".to_string());
1182 let phase = if outcome.is_success() {
1183 SubagentPhase::Finished
1184 } else {
1185 SubagentPhase::Errored
1186 };
1187 out.push(ProgressEvent::SubagentToolCall {
1188 child_call_id: *call_id,
1189 tool_name,
1190 phase,
1191 });
1192 self.phase = "";
1193 },
1194 Msg::StreamReasoning { chunk, .. } => {
1195 self.streamed_chars += chunk.text.len();
1196 self.set_phase("thinking", &mut out);
1197 },
1198 Msg::StreamText { chunk, .. } => {
1199 self.streamed_chars += chunk.len();
1200 self.set_phase("replying", &mut out);
1201 },
1202 Msg::StreamDone {
1203 usage: Some(usage), ..
1204 } => {
1205 self.confirmed_tokens += usage
1206 .completion_tokens
1207 .saturating_add(usage.reasoning_output_tokens);
1208 self.streamed_chars = 0;
1209 },
1210 _ => {},
1211 }
1212 let total = self.total_tokens();
1215 let due = now.duration_since(self.last_tokens_at) >= TOKEN_PROGRESS_INTERVAL;
1216 if total != self.last_tokens_sent && (due || !out.is_empty()) {
1217 out.push(ProgressEvent::SubagentTokens(total));
1218 self.last_tokens_sent = total;
1219 self.last_tokens_at = now;
1220 }
1221 out
1222 }
1223
1224 fn set_phase(&mut self, phase: &'static str, out: &mut Vec<ProgressEvent>) {
1225 if self.phase != phase {
1226 self.phase = phase;
1227 out.push(ProgressEvent::SubagentActivity(phase.to_string()));
1228 }
1229 }
1230}
1231
1232fn lookup_tool_name(state: &State, call_id: crate::domain::ToolCallId) -> Option<String> {
1235 match &state.turn {
1236 TurnState::ExecutingTools { calls, .. } => calls
1237 .iter()
1238 .find(|c| c.call_id == call_id)
1239 .map(|c| c.source.function.name.clone()),
1240 _ => None,
1241 }
1242}
1243
1244fn seed_child_mcp(state: &mut State) {
1253 let Some(manager) = crate::mcp::manager_ref::get() else {
1254 return;
1255 };
1256 apply_live_mcp(&mut state.mcp.servers, &manager.all_specs(), |name| {
1257 manager.has_server(name)
1258 });
1259}
1260
1261fn apply_live_mcp(
1267 servers: &mut std::collections::HashMap<String, crate::domain::McpServerEntry>,
1268 live_specs: &[(String, crate::domain::McpToolSpec)],
1269 has_server: impl Fn(&str) -> bool,
1270) {
1271 for (name, entry) in servers.iter_mut() {
1272 if !has_server(name) {
1273 continue;
1274 }
1275 entry.status = crate::domain::McpServerStatus::Ready;
1276 let cfg = &entry.config;
1277 let tools: Vec<crate::domain::McpToolSpec> = live_specs
1278 .iter()
1279 .filter(|(server, _)| server == name)
1280 .filter(|(_, spec)| cfg.tool_allowed(&spec.raw_name))
1283 .map(|(_, spec)| spec.clone())
1284 .collect();
1285 entry.tools = tools;
1286 }
1287}
1288
1289fn build_child_registry(
1305 providers: Arc<ProviderFactory>,
1306 tools: Option<&[String]>,
1307 config: &crate::app::Config,
1308 safety_mode: SafetyMode,
1309 web: &WebCapabilities,
1310) -> Arc<ToolRegistry> {
1311 use super::{apply_patch, computer_use, exec, filesystem, mcp};
1312 let allowed = |name: &str| tools.is_none_or(|t| t.iter().any(|x| x == name));
1313 let mut r = ToolRegistry::new();
1314 if allowed("read_file") {
1315 r.register(Arc::new(filesystem::ReadFileTool));
1316 }
1317 if allowed("write_file") {
1318 r.register(Arc::new(filesystem::WriteFileTool));
1319 }
1320 if allowed("apply_patch") {
1321 r.register(Arc::new(apply_patch::ApplyPatchTool));
1322 }
1323 if allowed("delete_file") {
1324 r.register(Arc::new(filesystem::DeleteFileTool));
1325 }
1326 if allowed("create_directory") {
1327 r.register(Arc::new(filesystem::CreateDirectoryTool));
1328 }
1329 if allowed("execute_command") {
1330 r.register(Arc::new(exec::ExecuteCommandTool));
1331 }
1332 if allowed("mcp") {
1333 r.register(Arc::new(mcp::McpToolProxy));
1334 }
1335 let search_allowed =
1341 allowed("web_search") && headless_web_tool_is_executable(config, safety_mode, "web_search");
1342 let fetch_allowed =
1343 allowed("web_fetch") && headless_web_tool_is_executable(config, safety_mode, "web_fetch");
1344 if search_allowed || fetch_allowed {
1345 if search_allowed && let Some(tool) = web.search_tool() {
1346 r.register(Arc::new(tool));
1347 }
1348 if fetch_allowed && let Some(tool) = web.fetch_tool() {
1349 r.register(Arc::new(tool));
1350 }
1351 }
1352 let _ = computer_use::probe;
1357 let _ = providers;
1358 Arc::new(r)
1359}
1360
1361fn headless_web_tool_is_executable(
1365 config: &crate::app::Config,
1366 safety_mode: SafetyMode,
1367 tool: &'static str,
1368) -> bool {
1369 use crate::runtime::{ActionRequest, PolicyDecision, PolicyEngine, ToolCategory};
1370
1371 if config.safety.network == crate::app::NetworkPolicy::Deny {
1372 return false;
1373 }
1374 let request = ActionRequest::new(tool, ToolCategory::Web, tool);
1375 let decision = PolicyEngine::new(safety_mode)
1376 .with_overrides(config.safety.overrides.clone())
1377 .with_external_writes(config.safety.external_writes)
1378 .with_system_installs(config.safety.system_installs)
1379 .decide(&request);
1380 match decision {
1381 PolicyDecision::Allow { .. } => true,
1382 PolicyDecision::Classify { .. } => safety_mode == SafetyMode::Auto,
1384 PolicyDecision::Ask { .. } => {
1385 config.safety.allow_untrusted_headless_tools
1386 || (safety_mode == SafetyMode::ReadOnly && config.safety.allow_readonly_web)
1387 },
1388 PolicyDecision::Deny { .. } => false,
1389 }
1390}
1391
1392fn default_model_id(config: &crate::app::Config) -> String {
1397 if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
1398 format!(
1399 "{}/{}",
1400 config.default_model.provider, config.default_model.name
1401 )
1402 } else {
1403 config.default_model.name.clone()
1404 }
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409 use super::*;
1410 use crate::domain::{ToolCallId, TurnId};
1411 use crate::providers::ctx::test_exec_context;
1412 use std::path::PathBuf;
1413
1414 fn test_state() -> State {
1415 State::new(
1416 crate::app::Config::default(),
1417 PathBuf::from("/tmp"),
1418 "ollama/test".to_string(),
1419 chrono::Local::now(),
1420 )
1421 }
1422
1423 fn test_spawner() -> SubagentSpawner {
1424 let config = crate::app::Config::default();
1425 let providers = Arc::new(ProviderFactory::new(config.clone()));
1426 let web_capabilities = Arc::new(WebCapabilities::resolve(&config.web));
1427 SubagentSpawner::new(providers, web_capabilities)
1428 }
1429
1430 fn test_spawner_arc() -> Arc<SubagentSpawner> {
1431 Arc::new(test_spawner())
1432 }
1433
1434 fn stream_text(chunk: &str) -> Msg {
1435 Msg::StreamText {
1436 turn: TurnId(1),
1437 chunk: chunk.to_string(),
1438 }
1439 }
1440
1441 #[tokio::test]
1442 async fn child_stream_chunks_never_forward_text_only_one_phase_change() {
1443 let state = test_state();
1448 let now = tokio::time::Instant::now();
1449 let mut progress = ChildProgress::new(now);
1450
1451 let first = progress.observe(&stream_text("chunk one — some text"), &state, now);
1452 assert!(
1453 first.iter().any(
1454 |e| matches!(e, ProgressEvent::SubagentActivity(label) if label == "replying")
1455 ),
1456 "first chunk announces the phase: {first:?}"
1457 );
1458 assert!(
1459 !first
1460 .iter()
1461 .any(|e| matches!(e, ProgressEvent::SubagentToolCall { .. })),
1462 "no raw text ever forwards: {first:?}"
1463 );
1464
1465 for i in 0..50 {
1467 let events = progress.observe(&stream_text(&format!("chunk {i}")), &state, now);
1468 assert!(
1469 events.is_empty(),
1470 "chunk {i} must be silent inside the throttle window: {events:?}"
1471 );
1472 }
1473 }
1474
1475 #[tokio::test]
1476 async fn token_estimates_respect_the_throttle_and_snap_to_provider_usage() {
1477 let state = test_state();
1478 let start = tokio::time::Instant::now();
1479 let mut progress = ChildProgress::new(start);
1480
1481 let _ = progress.observe(&stream_text("xy"), &state, start);
1483 let silent = progress.observe(&stream_text(&"x".repeat(400)), &state, start);
1485 assert!(
1486 silent.is_empty(),
1487 "inside the window stays silent: {silent:?}"
1488 );
1489 let later = start + TOKEN_PROGRESS_INTERVAL;
1491 let events = progress.observe(&stream_text("y"), &state, later);
1492 assert!(
1493 events
1494 .iter()
1495 .any(|e| matches!(e, ProgressEvent::SubagentTokens(t) if *t >= 100)),
1496 "tokens flush after the interval: {events:?}"
1497 );
1498
1499 let done = Msg::StreamDone {
1502 turn: TurnId(1),
1503 usage: Some(crate::models::TokenUsage::provider(10, 5_000)),
1504 provider_continuation: None,
1505 stop_reason: None,
1506 };
1507 let much_later = later + TOKEN_PROGRESS_INTERVAL;
1508 let events = progress.observe(&done, &state, much_later);
1509 assert!(
1510 events
1511 .iter()
1512 .any(|e| matches!(e, ProgressEvent::SubagentTokens(t) if *t >= 5_000)),
1513 "provider usage snaps the counter: {events:?}"
1514 );
1515 }
1516
1517 #[tokio::test]
1518 async fn empty_prompt_is_rejected() {
1519 let spawner = test_spawner_arc();
1520 let tool = SubagentTool::new(spawner);
1521 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1522 let outcome = tool.execute(serde_json::json!({"prompt": " "}), ctx).await;
1523 assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
1524 }
1525
1526 #[test]
1527 fn child_state_inherits_live_safety_mode_over_config_default() {
1528 use crate::runtime::SafetyMode;
1532 let mut config = crate::app::Config::default();
1533 config.safety.mode = SafetyMode::FullAccess; let mut child_state = State::new(
1535 config,
1536 PathBuf::from("/tmp"),
1537 "ollama/test".to_string(),
1538 chrono::Local::now(),
1539 );
1540 assert_eq!(child_state.session.safety_mode, SafetyMode::FullAccess);
1542 child_state.session.safety_mode = SafetyMode::Ask;
1544 assert_eq!(child_state.session.safety_mode, SafetyMode::Ask);
1545 }
1546
1547 #[test]
1548 fn child_state_inherits_the_parent_scratchpad() {
1549 let (mut ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1553 ctx.scratchpad = Some(PathBuf::from("/data/tmp/scratchpad/-proj/s"));
1554 let mut child_state = test_state();
1556 assert_eq!(child_state.session.scratchpad, None);
1557 child_state.session.scratchpad = ctx.scratchpad.clone();
1560 assert_eq!(
1561 child_state.session.scratchpad.as_deref(),
1562 Some(std::path::Path::new("/data/tmp/scratchpad/-proj/s"))
1563 );
1564 }
1565
1566 #[test]
1570 fn default_model_id_reads_config_provider_and_name() {
1571 let mut cfg = crate::app::Config::default();
1572 cfg.default_model.provider = "ollama".to_string();
1573 cfg.default_model.name = "qwen3-coder:30b".to_string();
1574 assert_eq!(default_model_id(&cfg), "ollama/qwen3-coder:30b");
1575 }
1576
1577 #[test]
1578 fn default_model_id_returns_bare_name_when_provider_empty() {
1579 let mut cfg = crate::app::Config::default();
1580 cfg.default_model.name = "just-a-name".to_string();
1581 assert_eq!(default_model_id(&cfg), "just-a-name");
1584 }
1585
1586 #[test]
1587 fn apply_live_mcp_marks_running_servers_ready_with_their_tools() {
1588 use crate::domain::{McpServerEntry, McpServerStatus};
1589 let entry = || McpServerEntry {
1590 config: crate::app::McpServerConfig::default(),
1591 status: McpServerStatus::Starting,
1592 tools: Vec::new(),
1593 };
1594 let mut servers = std::collections::HashMap::new();
1595 servers.insert("slack".to_string(), entry());
1596 servers.insert("broken".to_string(), entry());
1597
1598 let live = vec![
1599 (
1600 "slack".to_string(),
1601 crate::domain::McpToolSpec {
1602 name: "mcp__slack__send".to_string(),
1603 raw_name: "send".to_string(),
1604 description: "send a message".to_string(),
1605 input_schema: serde_json::json!({"type": "object"}),
1606 read_only_hint: false,
1607 },
1608 ),
1609 (
1612 "other".to_string(),
1613 crate::domain::McpToolSpec {
1614 name: "mcp__other__x".to_string(),
1615 raw_name: "x".to_string(),
1616 description: String::new(),
1617 input_schema: serde_json::json!({}),
1618 read_only_hint: false,
1619 },
1620 ),
1621 ];
1622 apply_live_mcp(&mut servers, &live, |name| name == "slack");
1623
1624 let slack = &servers["slack"];
1625 assert_eq!(slack.status, McpServerStatus::Ready);
1626 assert_eq!(slack.tools.len(), 1);
1627 assert_eq!(slack.tools[0].name, "mcp__slack__send");
1628 assert_eq!(slack.tools[0].raw_name, "send");
1629 assert_eq!(servers["broken"].status, McpServerStatus::Starting);
1631 assert!(servers["broken"].tools.is_empty());
1632 assert!(!servers.contains_key("other"));
1633 }
1634
1635 #[test]
1636 fn subagent_metadata_carries_usage_only_when_reported() {
1637 let some = subagent_metadata(
1638 "ollama/test".to_string(),
1639 TokenUsageTotals {
1640 prompt_tokens: 100,
1641 completion_tokens: 40,
1642 ..TokenUsageTotals::default()
1643 },
1644 "a7".to_string(),
1645 );
1646 let usage = some.token_usage.expect("usage attached");
1647 assert_eq!(usage.total_tokens(), 140);
1648 assert_eq!(usage.completion_tokens, 40);
1649 assert!(matches!(
1650 some.detail,
1651 crate::domain::ToolMetadata::Subagent { ref model_id, ref agent_id }
1652 if model_id == "ollama/test" && agent_id == "a7"
1653 ));
1654 let none = subagent_metadata(
1656 "ollama/test".to_string(),
1657 TokenUsageTotals::default(),
1658 "a8".to_string(),
1659 );
1660 assert!(none.token_usage.is_none());
1661 }
1662
1663 #[test]
1664 fn usage_delta_reports_only_this_drive() {
1665 let before = TokenUsageTotals {
1668 prompt_tokens: 1_000,
1669 completion_tokens: 200,
1670 ..TokenUsageTotals::default()
1671 };
1672 let after = TokenUsageTotals {
1673 prompt_tokens: 1_600,
1674 completion_tokens: 350,
1675 ..TokenUsageTotals::default()
1676 };
1677 let delta = usage_delta(after, before);
1678 assert_eq!(delta.prompt_tokens, 600);
1679 assert_eq!(delta.completion_tokens, 150);
1680 assert_eq!(delta.total_tokens(), 750);
1681 let fresh = usage_delta(after, TokenUsageTotals::default());
1683 assert_eq!(fresh.total_tokens(), 1_950);
1684 }
1685
1686 #[test]
1687 fn resolve_agent_type_builtins_custom_shadowing_and_errors() {
1688 use crate::app::AgentTypeConfig;
1689 let mut config = crate::app::Config::default();
1690
1691 assert_eq!(resolve_agent_type(None, &config).unwrap().name, "general");
1693 assert_eq!(
1694 resolve_agent_type(None, &config).unwrap().safety_ceiling,
1695 SafetyMode::FullAccess,
1696 );
1697 let explore = resolve_agent_type(Some("explore"), &config).unwrap();
1698 assert_eq!(explore.safety_ceiling, SafetyMode::ReadOnly);
1699 assert!(explore.preamble.as_deref().unwrap().contains("read-only"));
1700 assert!(explore.allows_tool("read_file"));
1701 assert!(!explore.allows_tool("write_file"));
1702 assert!(!explore.allows_tool("mcp"));
1703
1704 let err = resolve_agent_type(Some("nope"), &config).unwrap_err();
1706 assert!(err.contains("general") && err.contains("explore"), "{err}");
1707
1708 config.agents.types.insert(
1710 "scout".to_string(),
1711 AgentTypeConfig {
1712 tools: Some(vec!["read_file".to_string()]),
1713 safety: Some("read_only".to_string()),
1714 preamble: Some("You are a scout.".to_string()),
1715 model: Some("ollama/qwen3:8b".to_string()),
1716 },
1717 );
1718 let scout = resolve_agent_type(Some("scout"), &config).unwrap();
1719 assert_eq!(scout.model.as_deref(), Some("ollama/qwen3:8b"));
1720 assert_eq!(scout.safety_ceiling, SafetyMode::ReadOnly);
1721
1722 config.agents.types.insert(
1724 "explore".to_string(),
1725 AgentTypeConfig {
1726 safety: Some("ask".to_string()),
1727 ..AgentTypeConfig::default()
1728 },
1729 );
1730 assert_eq!(
1731 resolve_agent_type(Some("explore"), &config)
1732 .unwrap()
1733 .safety_ceiling,
1734 SafetyMode::Ask,
1735 );
1736
1737 config.agents.types.insert(
1740 "bad-safety".to_string(),
1741 AgentTypeConfig {
1742 safety: Some("yolo".to_string()),
1743 ..AgentTypeConfig::default()
1744 },
1745 );
1746 assert!(
1747 resolve_agent_type(Some("bad-safety"), &config)
1748 .unwrap_err()
1749 .contains("yolo")
1750 );
1751 config.agents.types.insert(
1752 "bad-tool".to_string(),
1753 AgentTypeConfig {
1754 tools: Some(vec!["screenshot".to_string()]),
1755 ..AgentTypeConfig::default()
1756 },
1757 );
1758 assert!(
1759 resolve_agent_type(Some("bad-tool"), &config)
1760 .unwrap_err()
1761 .contains("screenshot")
1762 );
1763 }
1764
1765 #[test]
1766 fn agent_cache_stores_takes_and_evicts_oldest() {
1767 let spawner = test_spawner();
1768 let mk_state = || {
1769 State::new(
1770 crate::app::Config::default(),
1771 PathBuf::from("/tmp"),
1772 "ollama/test".to_string(),
1773 chrono::Local::now(),
1774 )
1775 };
1776 let mk = || CachedAgent {
1777 state: mk_state(),
1778 type_name: "general".to_string(),
1779 };
1780
1781 assert_ne!(spawner.mint_agent_id(), spawner.mint_agent_id());
1783
1784 spawner.cache_store("x".to_string(), mk());
1787 assert!(spawner.cache_take("x").is_some());
1788 assert!(spawner.cache_take("x").is_none(), "take must remove");
1789
1790 for i in 0..(MAX_CACHED_AGENTS + 2) {
1792 spawner.cache_store(format!("e{i}"), mk());
1793 }
1794 assert!(spawner.cache_take("e0").is_none(), "oldest evicted");
1795 assert!(spawner.cache_take("e1").is_none(), "second-oldest evicted");
1796 assert!(
1797 spawner
1798 .cache_take(&format!("e{}", MAX_CACHED_AGENTS + 1))
1799 .is_some(),
1800 "newest survives",
1801 );
1802 }
1803
1804 #[tokio::test]
1805 async fn continuing_an_unknown_agent_id_errors_actionably() {
1806 let spawner = test_spawner_arc();
1807 let tool = SubagentTool::new(spawner);
1808 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1809 let outcome = tool
1810 .execute(
1811 serde_json::json!({"prompt": "follow up", "agent_id": "a99"}),
1812 ctx,
1813 )
1814 .await;
1815 assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
1816 let msg = outcome.error_message().unwrap_or_default();
1817 assert!(msg.contains("a99"), "names the bad id: {msg}");
1818 assert!(
1819 msg.contains("Omit agent_id"),
1820 "tells the model how to recover: {msg}"
1821 );
1822 }
1823
1824 #[tokio::test]
1825 async fn unknown_agent_type_errors_actionably() {
1826 let spawner = test_spawner_arc();
1827 let tool = SubagentTool::new(spawner);
1828 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1829 let outcome = tool
1830 .execute(
1831 serde_json::json!({"prompt": "look around", "type": "wizard"}),
1832 ctx,
1833 )
1834 .await;
1835 assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
1836 let msg = outcome.error_message().unwrap_or_default();
1837 assert!(msg.contains("wizard") && msg.contains("explore"), "{msg}");
1838 }
1839
1840 #[test]
1841 fn build_child_registry_excludes_gui_and_self() {
1842 let config = crate::app::Config::default();
1843 let providers = Arc::new(ProviderFactory::new(config.clone()));
1844 let web = WebCapabilities::resolve(&config.web);
1845 let r = build_child_registry(providers, None, &config, SafetyMode::Ask, &web);
1846 assert!(r.get("screenshot").is_none());
1848 assert!(r.get("click").is_none());
1849 assert!(r.get("type_text").is_none());
1850 assert!(r.get("press_key").is_none());
1851 assert!(r.get("scroll").is_none());
1852 assert!(r.get("mouse_move").is_none());
1853 assert!(r.get("list_windows").is_none());
1854 assert!(r.get("agent").is_none());
1856 assert!(r.get("read_file").is_some());
1858 assert!(r.get("execute_command").is_some());
1859 assert!(r.get("web_fetch").is_none());
1862 assert!(r.get("web_search").is_none());
1863 }
1864
1865 #[test]
1866 fn child_registry_exposes_web_only_when_headless_policy_can_execute_it() {
1867 let configured = |mode, readonly_web, headless_opt_in, network| {
1868 let mut config = crate::app::Config::default();
1869 config.safety.mode = mode;
1870 config.safety.allow_readonly_web = readonly_web;
1871 config.safety.allow_untrusted_headless_tools = headless_opt_in;
1872 config.safety.network = network;
1873 config.web.search_backend = crate::app::SearchBackend::Searxng;
1876 config.web.searxng_url = "http://127.0.0.1:8080".to_string();
1877 let providers = Arc::new(ProviderFactory::new(config.clone()));
1878 let web = WebCapabilities::resolve(&config.web);
1879 build_child_registry(providers, None, &config, mode, &web)
1880 };
1881
1882 for mode in [SafetyMode::Auto, SafetyMode::FullAccess] {
1883 let registry = configured(mode, false, false, crate::app::NetworkPolicy::Allow);
1884 assert!(registry.get("web_fetch").is_some(), "mode {mode:?}");
1885 assert!(registry.get("web_search").is_some(), "mode {mode:?}");
1886 }
1887
1888 let readonly = configured(
1889 SafetyMode::ReadOnly,
1890 true,
1891 false,
1892 crate::app::NetworkPolicy::Allow,
1893 );
1894 assert!(readonly.get("web_fetch").is_some());
1895 assert!(readonly.get("web_search").is_some());
1896
1897 let opted_in = configured(
1898 SafetyMode::Ask,
1899 false,
1900 true,
1901 crate::app::NetworkPolicy::Allow,
1902 );
1903 assert!(opted_in.get("web_fetch").is_some());
1904 assert!(opted_in.get("web_search").is_some());
1905
1906 let denied = configured(
1907 SafetyMode::FullAccess,
1908 true,
1909 true,
1910 crate::app::NetworkPolicy::Deny,
1911 );
1912 assert!(denied.get("web_fetch").is_none());
1913 assert!(denied.get("web_search").is_none());
1914 }
1915
1916 #[test]
1917 fn child_web_visibility_honors_explicit_policy_overrides() {
1918 let mut config = crate::app::Config::default();
1919 config.safety.overrides = vec![crate::runtime::PolicyOverride {
1920 category: Some(crate::runtime::ToolCategory::Web),
1921 decision: crate::runtime::PolicyOverrideDecision::Allow,
1922 ..crate::runtime::PolicyOverride::default()
1923 }];
1924 assert!(headless_web_tool_is_executable(
1925 &config,
1926 SafetyMode::Ask,
1927 "web_fetch"
1928 ));
1929
1930 config.safety.overrides[0].decision = crate::runtime::PolicyOverrideDecision::Deny;
1931 assert!(!headless_web_tool_is_executable(
1932 &config,
1933 SafetyMode::FullAccess,
1934 "web_fetch"
1935 ));
1936 }
1937
1938 #[test]
1939 fn kill_detached_fires_registered_tokens_and_evicts_cached_children() {
1940 let spawner = test_spawner();
1941
1942 let cancel = CancellationToken::new();
1944 spawner.register_detached("a1".to_string(), cancel.clone());
1945 assert_eq!(spawner.kill_detached("a1"), KillResult::Killed);
1946 assert!(cancel.is_cancelled());
1947 assert_eq!(spawner.kill_detached("a1"), KillResult::NotFound);
1949
1950 spawner.cache_store(
1952 "a2".to_string(),
1953 CachedAgent {
1954 state: test_state(),
1955 type_name: "general".to_string(),
1956 },
1957 );
1958 assert_eq!(spawner.kill_detached("a2"), KillResult::Evicted);
1959 assert!(spawner.cache_take("a2").is_none(), "eviction is permanent");
1960
1961 assert_eq!(spawner.kill_detached("a99"), KillResult::NotFound);
1963
1964 let (c1, c2) = (CancellationToken::new(), CancellationToken::new());
1966 spawner.register_detached("a3".to_string(), c1.clone());
1967 spawner.register_detached("a4".to_string(), c2.clone());
1968 assert_eq!(spawner.kill_all_detached(), 2);
1969 assert!(c1.is_cancelled() && c2.is_cancelled());
1970 assert_eq!(spawner.kill_all_detached(), 0);
1971 }
1972
1973 #[tokio::test]
1974 async fn kill_action_validates_agent_id_and_skips_prompt_requirement() {
1975 let spawner = test_spawner_arc();
1976 let tool = SubagentTool::new(spawner.clone());
1977
1978 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), PathBuf::from("/tmp"));
1981 let outcome = tool
1982 .execute(serde_json::json!({"action": "kill"}), ctx)
1983 .await;
1984 assert!(!outcome.is_success());
1985 assert!(outcome.model_content.contains("requires `agent_id`"));
1986
1987 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
1989 let outcome = tool
1990 .execute(serde_json::json!({"action": "kill", "agent_id": "a7"}), ctx)
1991 .await;
1992 assert!(!outcome.is_success());
1993 assert!(outcome.model_content.contains("a7"));
1994
1995 let cancel = CancellationToken::new();
1997 spawner.register_detached("a7".to_string(), cancel.clone());
1998 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(3), PathBuf::from("/tmp"));
1999 let outcome = tool
2000 .execute(serde_json::json!({"action": "kill", "agent_id": "a7"}), ctx)
2001 .await;
2002 assert!(outcome.is_success(), "{}", outcome.model_content);
2003 assert!(cancel.is_cancelled());
2004 }
2005
2006 #[test]
2007 fn explore_registry_is_a_read_only_surface() {
2008 let providers = Arc::new(ProviderFactory::new(crate::app::Config::default()));
2009 let explore = builtin_agent_type("explore").expect("builtin");
2010 let config = crate::app::Config::default();
2011 let web = WebCapabilities::resolve(&config.web);
2012 let r = build_child_registry(
2013 providers,
2014 explore.tools.as_deref(),
2015 &config,
2016 SafetyMode::ReadOnly,
2017 &web,
2018 );
2019 assert!(r.get("read_file").is_some());
2020 assert!(r.get("execute_command").is_some());
2021 for tool in [
2022 "write_file",
2023 "apply_patch",
2024 "delete_file",
2025 "create_directory",
2026 "mcp_proxy",
2027 "agent",
2028 ] {
2029 assert!(r.get(tool).is_none(), "explore must not carry {tool}");
2030 }
2031 }
2032}