1mod background;
4mod config;
5mod constants;
6mod discovery;
7mod model;
8mod prompt;
9mod types;
10
11pub use background::{
14 background_record_id, build_background_subagent_command, extract_tail_lines,
15 load_archive_preview, subagent_display_label,
16};
17pub use config::{
18 build_child_config, compose_subagent_instructions, filter_child_tools,
19 normalize_background_child_max_turns, normalize_child_max_turns, prepare_child_runtime_config,
20};
21pub use model::{agent_type_for_spec, load_memory_appendix};
22pub use prompt::{
23 contains_explicit_delegation_request, contains_explicit_model_request,
24 delegated_task_requires_clarification, extract_explicit_agent_mentions,
25 normalize_requested_model_override, request_prompt, sanitize_subagent_input_items,
26};
27pub use types::{
28 BackgroundRecord, BackgroundSubprocessEntry, BackgroundSubprocessSnapshot,
29 BackgroundSubprocessStatus, ChildRecord, ChildRunResult, ControllerState,
30 PersistedBackgroundRecord, PersistedBackgroundState, SendInputRequest, SpawnAgentRequest,
31 SpawnBackgroundSubprocessRequest, StatusEntryBuilder, SubagentInputItem, SubagentStatus,
32 SubagentStatusEntry, SubagentThreadSnapshot, TurnDelegationHints,
33};
34
35pub fn is_subagent_tool(name: &str) -> bool {
38 SUBAGENT_TOOL_NAMES.contains(&name)
39}
40
41#[derive(Clone, Default)]
42struct BackgroundLaunchOverrides {
43 prompt: Option<String>,
44 max_turns: Option<usize>,
45 model_override: Option<String>,
46 reasoning_override: Option<String>,
47}
48
49#[derive(Clone, Default)]
50struct PreparedDelegationContext {
51 requested_agent: Option<String>,
52 explicit_mentions: Vec<String>,
53 explicit_request: bool,
54 current_input: String,
55}
56
57use anyhow::{Context, Result, anyhow, bail};
60use chrono::Utc;
61use futures::future::select_all;
62use std::collections::VecDeque;
63use std::path::PathBuf;
64use std::sync::Arc;
65use tokio::sync::{Notify, RwLock};
66
67use crate::config::VTCodeConfig;
68use crate::config::types::ReasoningEffortLevel;
69use crate::core::agent::runner::{AgentRunner, RunnerSettings};
70use crate::core::agent::task::Task;
71use crate::core::threads::{ThreadBootstrap, ThreadId, ThreadRuntimeHandle, ThreadSnapshot};
72use crate::hooks::{LifecycleHookEngine, SessionStartTrigger};
73use crate::llm::provider::Message;
74use crate::tools::exec_session::ExecSessionManager;
75use crate::tools::pty::{PtyManager, PtySize};
76use crate::utils::session_archive::{SessionArchive, find_session_by_identifier};
77use vtcode_config::SubagentSpec;
78use vtcode_config::auth::OpenAIChatGptAuthHandle;
79
80use self::background::*;
81use self::config::*;
82use self::constants::*;
83use self::discovery::discover_controller_subagents;
84use self::model::*;
85
86#[derive(Clone)]
89pub struct SubagentControllerConfig {
90 pub workspace_root: PathBuf,
91 pub parent_session_id: String,
92 pub parent_model: String,
93 pub parent_provider: String,
94 pub parent_reasoning_effort: ReasoningEffortLevel,
95 pub api_key: String,
96 pub vt_cfg: VTCodeConfig,
97 pub openai_chatgpt_auth: Option<OpenAIChatGptAuthHandle>,
98 pub depth: usize,
99 pub exec_sessions: ExecSessionManager,
100 pub pty_manager: PtyManager,
101 pub managed_background_runtime: bool,
102}
103
104#[derive(Clone)]
105pub struct SubagentController {
106 config: Arc<SubagentControllerConfig>,
107 parent_session_id: Arc<RwLock<String>>,
108 lifecycle_hooks: Option<LifecycleHookEngine>,
109 state: Arc<RwLock<ControllerState>>,
110}
111
112impl SubagentController {
113 pub async fn new(config: SubagentControllerConfig) -> Result<Self> {
114 let discovered = discover_controller_subagents(&config.workspace_root).await?;
115 let lifecycle_hooks = LifecycleHookEngine::new_with_session(
116 config.workspace_root.clone(),
117 &config.vt_cfg.hooks,
118 SessionStartTrigger::Startup,
119 config.parent_session_id.clone(),
120 config.vt_cfg.permissions.default_mode,
121 )?;
122 let background_children = load_background_state(&config.workspace_root)?
123 .records
124 .into_iter()
125 .map(|record| (record.id.clone(), BackgroundRecord::from_persisted(record)))
126 .collect();
127 Ok(Self {
128 parent_session_id: Arc::new(RwLock::new(config.parent_session_id.clone())),
129 lifecycle_hooks,
130 config: Arc::new(config),
131 state: Arc::new(RwLock::new(ControllerState {
132 discovered,
133 parent_messages: Vec::new(),
134 turn_hints: TurnDelegationHints::default(),
135 children: std::collections::BTreeMap::new(),
136 background_children,
137 })),
138 })
139 }
140
141 pub async fn reload(&self) -> Result<()> {
142 let discovered = discover_controller_subagents(&self.config.workspace_root).await?;
143 self.state.write().await.discovered = discovered;
144 Ok(())
145 }
146
147 pub async fn set_parent_messages(&self, messages: &[Message]) {
148 let cloned = messages.to_vec();
149 self.state.write().await.parent_messages = cloned;
150 }
151
152 pub async fn set_turn_delegation_hints_from_input(&self, input: &str) -> Vec<String> {
153 let mut state = self.state.write().await;
154 let explicit_mentions =
155 extract_explicit_agent_mentions(input, state.discovered.effective.as_slice());
156 let explicit_request =
157 contains_explicit_delegation_request(input, explicit_mentions.as_slice());
158 state.turn_hints = TurnDelegationHints {
159 explicit_mentions: explicit_mentions.clone(),
160 explicit_request,
161 current_input: input.to_string(),
162 };
163 explicit_mentions
164 }
165
166 pub async fn clear_turn_delegation_hints(&self) {
167 self.state.write().await.turn_hints = TurnDelegationHints::default();
168 }
169
170 pub async fn set_parent_session_id(&self, session_id: impl Into<String>) {
171 *self.parent_session_id.write().await = session_id.into();
172 }
173
174 pub async fn effective_specs(&self) -> Vec<SubagentSpec> {
175 self.state.read().await.discovered.effective.clone()
176 }
177
178 pub async fn shadowed_specs(&self) -> Vec<SubagentSpec> {
179 self.state.read().await.discovered.shadowed.clone()
180 }
181
182 pub async fn status_entries(&self) -> Vec<SubagentStatusEntry> {
183 let state = self.state.read().await;
184 state
185 .children
186 .values()
187 .map(ChildRecord::build_status_entry)
188 .collect()
189 }
190
191 pub async fn background_status_entries(&self) -> Vec<BackgroundSubprocessEntry> {
192 let state = self.state.read().await;
193 state
194 .background_children
195 .values()
196 .map(BackgroundRecord::build_status_entry)
197 .collect()
198 }
199
200 pub async fn background_snapshot(&self, target: &str) -> Result<BackgroundSubprocessSnapshot> {
201 let _ = self.refresh_background_processes().await?;
202
203 let entry = {
204 let state = self.state.read().await;
205 state
206 .background_children
207 .get(target)
208 .ok_or_else(|| anyhow!("Unknown background subprocess {}", target))?
209 .build_status_entry()
210 };
211
212 let preview = if entry.exec_session_id.is_empty() {
213 String::new()
214 } else {
215 match self
216 .config
217 .exec_sessions
218 .read_session_output(&entry.exec_session_id, false)
219 .await
220 {
221 Ok(Some(output)) => extract_tail_lines(&output, SUBAGENT_PREVIEW_LINES),
222 Ok(None) | Err(_) => {
223 if let Some(path) = entry
224 .transcript_path
225 .as_ref()
226 .or(entry.archive_path.as_ref())
227 {
228 load_archive_preview(path).unwrap_or_default()
229 } else {
230 String::new()
231 }
232 }
233 }
234 };
235
236 Ok(BackgroundSubprocessSnapshot { entry, preview })
237 }
238
239 #[must_use]
240 pub fn background_subagents_enabled(&self) -> bool {
241 self.config.vt_cfg.subagents.background.enabled
242 }
243
244 #[must_use]
245 pub fn configured_default_background_agent(&self) -> Option<&str> {
246 self.config
247 .vt_cfg
248 .subagents
249 .background
250 .default_agent
251 .as_deref()
252 .map(str::trim)
253 .filter(|agent| !agent.is_empty())
254 }
255
256 pub async fn toggle_default_background_subagent(&self) -> Result<BackgroundSubprocessEntry> {
257 if !self.background_subagents_enabled() {
258 bail!("Background subagents are disabled by configuration");
259 }
260
261 let agent_name = self
262 .configured_default_background_agent()
263 .ok_or_else(|| anyhow!("No default background subagent is configured"))?
264 .to_string();
265 let target_id = background_record_id(agent_name.as_str());
266 let should_stop = {
267 let state = self.state.read().await;
268 state
269 .background_children
270 .get(&target_id)
271 .is_some_and(|record| record.desired_enabled && record.status.is_active())
272 };
273
274 if should_stop {
275 self.graceful_stop_background(&target_id).await
276 } else {
277 self.ensure_background_record_running(
278 agent_name.as_str(),
279 Some(target_id.as_str()),
280 0,
281 None,
282 )
283 .await
284 }
285 }
286
287 pub async fn restore_background_subagents(&self) -> Result<Vec<BackgroundSubprocessEntry>> {
288 let desired_records = {
289 let state = self.state.read().await;
290 state
291 .background_children
292 .values()
293 .filter(|record| record.desired_enabled)
294 .map(|record| {
295 (
296 record.id.clone(),
297 record.agent_name.clone(),
298 record.exec_session_id.clone(),
299 record.restart_attempts,
300 )
301 })
302 .collect::<Vec<_>>()
303 };
304
305 for (record_id, agent_name, exec_session_id, restart_attempts) in desired_records {
306 let is_live = !exec_session_id.is_empty()
307 && self
308 .config
309 .exec_sessions
310 .snapshot_session(&exec_session_id)
311 .await
312 .ok()
313 .is_some_and(|snapshot| exec_session_is_running(&snapshot));
314
315 if is_live || !self.config.vt_cfg.subagents.background.auto_restore {
316 continue;
317 }
318 tracing::info!(
319 agent_name = agent_name.as_str(),
320 record_id = record_id.as_str(),
321 "Restoring background subagent subprocess"
322 );
323 self.ensure_background_record_running(
324 agent_name.as_str(),
325 Some(record_id.as_str()),
326 restart_attempts,
327 None,
328 )
329 .await?;
330 }
331
332 self.refresh_background_processes().await
333 }
334
335 pub async fn refresh_background_processes(&self) -> Result<Vec<BackgroundSubprocessEntry>> {
336 let record_ids = {
337 let state = self.state.read().await;
338 state
339 .background_children
340 .keys()
341 .cloned()
342 .collect::<Vec<_>>()
343 };
344
345 for record_id in record_ids {
346 let snapshot_target = {
347 let state = self.state.read().await;
348 state
349 .background_children
350 .get(&record_id)
351 .map(|record| record.exec_session_id.clone())
352 };
353
354 let snapshot = if let Some(exec_session_id) = snapshot_target.as_ref()
355 && !exec_session_id.is_empty()
356 {
357 self.config
358 .exec_sessions
359 .snapshot_session(exec_session_id)
360 .await
361 .ok()
362 } else {
363 None
364 };
365
366 let respawn = self
367 .update_background_record_state(&record_id, snapshot)
368 .await?;
369
370 if let Some((agent_name, stable_id, restart_attempts)) = respawn {
371 self.ensure_background_record_running(
372 agent_name.as_str(),
373 Some(stable_id.as_str()),
374 restart_attempts,
375 None,
376 )
377 .await?;
378 }
379
380 self.refresh_background_archive_metadata(&record_id).await?;
381 }
382
383 self.save_background_state().await?;
384 Ok(self.background_status_entries().await)
385 }
386
387 async fn update_background_record_state(
388 &self,
389 record_id: &str,
390 snapshot: Option<crate::tools::types::VTCodeExecSession>,
391 ) -> Result<Option<(String, String, u8)>> {
392 let mut state = self.state.write().await;
393 let Some(record) = state.background_children.get_mut(record_id) else {
394 return Ok(None);
395 };
396 record.updated_at = Utc::now();
397
398 let Some(snapshot) = snapshot else {
399 return Self::handle_missing_background_snapshot(record, &self.config);
400 };
401
402 record.pid = snapshot.child_pid;
403 record.started_at = snapshot.started_at.or(record.started_at);
404
405 match snapshot.lifecycle_state {
406 Some(crate::tools::types::VTCodeSessionLifecycleState::Running) => {
407 record.status = BackgroundSubprocessStatus::Running;
408 record.ended_at = None;
409 record.error = None;
410 }
411 Some(crate::tools::types::VTCodeSessionLifecycleState::Exited) | None => {
412 record.ended_at.get_or_insert(Utc::now());
413 if record.desired_enabled
414 && self.config.vt_cfg.subagents.background.auto_restore
415 && record.restart_attempts < 1
416 {
417 let next_restart_attempt = record.restart_attempts.saturating_add(1);
418 record.restart_attempts = next_restart_attempt;
419 record.status = BackgroundSubprocessStatus::Starting;
420 tracing::warn!(
421 agent_name = record.agent_name.as_str(),
422 record_id = record.id.as_str(),
423 attempt = next_restart_attempt,
424 "Background subprocess exited unexpectedly; scheduling restart"
425 );
426 return Ok(Some((
427 record.agent_name.clone(),
428 record.id.clone(),
429 next_restart_attempt,
430 )));
431 }
432 Self::mark_background_record_stopped_or_error(record, &snapshot, &self.config);
433 }
434 }
435
436 Ok(None)
437 }
438
439 fn handle_missing_background_snapshot(
440 record: &mut BackgroundRecord,
441 config: &SubagentControllerConfig,
442 ) -> Result<Option<(String, String, u8)>> {
443 if record.desired_enabled && config.vt_cfg.subagents.background.auto_restore {
444 if record.restart_attempts < 1 {
445 let next_restart_attempt = record.restart_attempts.saturating_add(1);
446 record.restart_attempts = next_restart_attempt;
447 record.status = BackgroundSubprocessStatus::Starting;
448 tracing::warn!(
449 agent_name = record.agent_name.as_str(),
450 record_id = record.id.as_str(),
451 attempt = next_restart_attempt,
452 "Background subprocess is missing; scheduling restart"
453 );
454 return Ok(Some((
455 record.agent_name.clone(),
456 record.id.clone(),
457 next_restart_attempt,
458 )));
459 }
460 record.status = BackgroundSubprocessStatus::Error;
461 record.error = Some("Background subprocess is not running".to_string());
462 record.ended_at.get_or_insert(Utc::now());
463 } else if !record.desired_enabled {
464 record.status = BackgroundSubprocessStatus::Stopped;
465 record.ended_at.get_or_insert(Utc::now());
466 }
467 Ok(None)
468 }
469
470 fn mark_background_record_stopped_or_error(
471 record: &mut BackgroundRecord,
472 snapshot: &crate::tools::types::VTCodeExecSession,
473 _config: &SubagentControllerConfig,
474 ) {
475 if record.desired_enabled {
476 record.status = BackgroundSubprocessStatus::Error;
477 record.error = Some(match snapshot.exit_code {
478 Some(exit_code) => format!("Background subprocess exited with code {exit_code}"),
479 None => "Background subprocess exited unexpectedly".to_string(),
480 });
481 } else {
482 record.status = BackgroundSubprocessStatus::Stopped;
483 }
484 }
485
486 pub async fn graceful_stop_background(
487 &self,
488 target: &str,
489 ) -> Result<BackgroundSubprocessEntry> {
490 let (agent_name, exec_session_id) = {
491 let mut state = self.state.write().await;
492 let record = state
493 .background_children
494 .get_mut(target)
495 .ok_or_else(|| anyhow!("Unknown background subprocess {}", target))?;
496 record.desired_enabled = false;
497 record.status = BackgroundSubprocessStatus::Stopped;
498 record.updated_at = Utc::now();
499 record.ended_at = Some(Utc::now());
500 (record.agent_name.clone(), record.exec_session_id.clone())
501 };
502
503 tracing::info!(
504 agent_name = agent_name.as_str(),
505 record_id = target,
506 exec_session_id = exec_session_id.as_str(),
507 "Gracefully stopping background subagent subprocess"
508 );
509
510 if !exec_session_id.is_empty() {
511 let _ = self
512 .config
513 .exec_sessions
514 .terminate_session(&exec_session_id)
515 .await;
516 let _ = self
517 .config
518 .exec_sessions
519 .prune_exited_session(&exec_session_id)
520 .await;
521 }
522
523 self.refresh_background_archive_metadata(target).await?;
524 self.save_background_state().await?;
525 self.background_status_for(target).await
526 }
527
528 pub async fn force_cancel_background(&self, target: &str) -> Result<BackgroundSubprocessEntry> {
529 let (agent_name, exec_session_id) = {
530 let mut state = self.state.write().await;
531 let record = state
532 .background_children
533 .get_mut(target)
534 .ok_or_else(|| anyhow!("Unknown background subprocess {}", target))?;
535 record.desired_enabled = false;
536 record.status = BackgroundSubprocessStatus::Stopped;
537 record.updated_at = Utc::now();
538 record.ended_at = Some(Utc::now());
539 (record.agent_name.clone(), record.exec_session_id.clone())
540 };
541
542 tracing::info!(
543 agent_name = agent_name.as_str(),
544 record_id = target,
545 exec_session_id = exec_session_id.as_str(),
546 "Force cancelling background subagent subprocess"
547 );
548
549 if !exec_session_id.is_empty() {
550 let _ = self
551 .config
552 .exec_sessions
553 .close_session(&exec_session_id)
554 .await;
555 }
556
557 self.refresh_background_archive_metadata(target).await?;
558 self.save_background_state().await?;
559 self.background_status_for(target).await
560 }
561
562 pub async fn snapshot_for_thread(&self, target: &str) -> Result<SubagentThreadSnapshot> {
563 let (
564 id,
565 session_id,
566 parent_thread_id,
567 agent_name,
568 display_label,
569 status,
570 background,
571 created_at,
572 updated_at,
573 archive_path,
574 transcript_path,
575 effective_config,
576 thread_handle,
577 archive_metadata,
578 stored_messages,
579 recent_events,
580 ) = {
581 let state = self.state.read().await;
582 let record = state
583 .children
584 .get(target)
585 .ok_or_else(|| anyhow!("Unknown subagent id {}", target))?;
586 (
587 record.id.clone(),
588 record.session_id.clone(),
589 record.parent_thread_id.clone(),
590 record.spec.name.clone(),
591 record.display_label.clone(),
592 record.status,
593 record.background,
594 record.created_at,
595 record.updated_at,
596 record.archive_path.clone(),
597 record.transcript_path.clone(),
598 record.effective_config.clone(),
599 record.thread_handle.clone(),
600 record.archive_metadata.clone(),
601 record.stored_messages.clone(),
602 record
603 .thread_handle
604 .as_ref()
605 .map(ThreadRuntimeHandle::recent_events)
606 .unwrap_or_default(),
607 )
608 };
609
610 let effective_config = effective_config.ok_or_else(|| {
611 anyhow!(
612 "Subagent {} does not have a captured runtime configuration yet",
613 target
614 )
615 })?;
616 let snapshot = match thread_handle {
617 Some(handle) => handle.snapshot(),
618 None => {
619 let archive_listing = match archive_path.as_ref() {
620 Some(path) if path.exists() => load_session_listing(path).ok(),
621 _ => None,
622 };
623 let metadata = archive_listing
624 .as_ref()
625 .map(|listing| listing.snapshot.metadata.clone())
626 .or(archive_metadata)
627 .or_else(|| {
628 Some(crate::core::threads::build_thread_archive_metadata(
629 &self.config.workspace_root,
630 effective_config.agent.default_model.as_str(),
631 effective_config.agent.provider.as_str(),
632 effective_config.agent.theme.as_str(),
633 effective_config.agent.reasoning_effort.as_str(),
634 ))
635 });
636 ThreadSnapshot {
637 thread_id: ThreadId::new(session_id.clone()),
638 metadata,
639 archive_listing,
640 messages: stored_messages,
641 loaded_skills: Vec::new(),
642 turn_in_flight: false,
643 }
644 }
645 };
646
647 Ok(SubagentThreadSnapshot {
648 id,
649 session_id,
650 parent_thread_id,
651 agent_name,
652 display_label,
653 status,
654 background,
655 created_at,
656 updated_at,
657 archive_path,
658 transcript_path,
659 effective_config,
660 snapshot,
661 recent_events,
662 })
663 }
664
665 pub async fn spawn(&self, request: SpawnAgentRequest) -> Result<SubagentStatusEntry> {
666 let mut request = request;
667 let delegation = self
668 .prepare_delegation_context(
669 request.agent_type.clone(),
670 &mut request.items,
671 &mut request.model,
672 "spawn_agent",
673 )
674 .await?;
675 let spec = self
676 .resolve_requested_spec(delegation.requested_agent.as_deref())
677 .await?;
678 let prompt = self.prepare_delegation_prompt(
679 &spec,
680 &delegation,
681 &mut request.model,
682 &request.message,
683 &request.items,
684 "spawn_agent",
685 "spawning the subagent",
686 "Ignoring subagent model override because the current user turn did not explicitly request that model",
687 )?;
688 self.spawn_with_spec(
689 spec,
690 prompt,
691 request.fork_context,
692 request.background,
693 request.max_turns,
694 request.model,
695 request.reasoning_effort,
696 )
697 .await
698 }
699
700 pub async fn spawn_background_subprocess(
701 &self,
702 request: SpawnBackgroundSubprocessRequest,
703 ) -> Result<BackgroundSubprocessEntry> {
704 if self.config.managed_background_runtime {
705 bail!("managed background subprocesses cannot launch nested background subprocesses");
706 }
707 if !self.config.vt_cfg.subagents.background.enabled {
708 bail!("Background subagents are disabled by configuration");
709 }
710
711 let mut request = request;
712 let delegation = self
713 .prepare_delegation_context(
714 request.agent_type.clone(),
715 &mut request.items,
716 &mut request.model,
717 "spawn_background_subprocess",
718 )
719 .await?;
720 let spec = self
721 .resolve_requested_spec(delegation.requested_agent.as_deref())
722 .await?;
723 if !spec.background {
724 bail!(
725 "spawn_background_subprocess requires an agent with `background: true`; '{}' is a normal delegated child agent. Use spawn_agent instead.",
726 spec.name
727 );
728 }
729 let prompt = self.prepare_delegation_prompt(
730 &spec,
731 &delegation,
732 &mut request.model,
733 &request.message,
734 &request.items,
735 "spawn_background_subprocess",
736 "launching the background subprocess",
737 "Ignoring background subprocess model override because the current user turn did not explicitly request that model",
738 )?;
739 let desired_max_turns =
740 normalize_background_child_max_turns(request.max_turns.or(spec.max_turns), true);
741 let desired_model_override = request.model.clone().or_else(|| spec.model.clone());
742 let desired_reasoning_override = request
743 .reasoning_effort
744 .clone()
745 .or_else(|| spec.reasoning_effort.clone());
746
747 let record_id = background_record_id(spec.name.as_str());
748 let _ = self.refresh_background_processes().await?;
749 {
750 let state = self.state.read().await;
751 if let Some(record) = state.background_children.get(&record_id)
752 && record.desired_enabled
753 && record.status.is_active()
754 {
755 let conflicts = Self::active_background_launch_conflicts(
756 record,
757 prompt.as_str(),
758 desired_max_turns,
759 desired_model_override.as_deref(),
760 desired_reasoning_override.as_deref(),
761 );
762 if !conflicts.is_empty() {
763 bail!(
764 "spawn_background_subprocess found active background subprocess '{}' with different {}. Stop or restart the existing subprocess before changing its launch settings.",
765 spec.name,
766 conflicts.join(", "),
767 );
768 }
769 return Ok(record.build_status_entry());
770 }
771 }
772
773 self.ensure_background_record_running(
774 spec.name.as_str(),
775 Some(record_id.as_str()),
776 0,
777 Some(BackgroundLaunchOverrides {
778 prompt: Some(prompt),
779 max_turns: request.max_turns,
780 model_override: request.model,
781 reasoning_override: request.reasoning_effort,
782 }),
783 )
784 .await
785 }
786
787 pub async fn spawn_custom(
788 &self,
789 spec: SubagentSpec,
790 request: SpawnAgentRequest,
791 ) -> Result<SubagentStatusEntry> {
792 if !spec.is_subagent() {
793 bail!(
794 "custom subagent spawn only supports subagent-capable specs; '{}' is primary-only",
795 spec.name
796 );
797 }
798
799 if !spec.is_read_only() {
800 bail!(
801 "custom subagent spawn only supports read-only specs; '{}' exposes write-capable behavior",
802 spec.name
803 );
804 }
805
806 let mut request = request;
807 sanitize_subagent_input_items(&mut request.items);
808
809 let prompt = request_prompt(&request.message, &request.items)
810 .or_else(|| spec.initial_prompt.clone())
811 .filter(|value| !value.trim().is_empty())
812 .ok_or_else(|| anyhow!("custom subagent spawn requires a task message or items"))?;
813 if delegated_task_requires_clarification(&prompt) {
814 bail!(
815 "custom subagent task for '{}' is too vague ('{}'). Provide a specific delegated task before spawning the subagent.",
816 spec.name,
817 prompt.trim()
818 );
819 }
820
821 self.spawn_with_spec(
822 spec,
823 prompt,
824 request.fork_context,
825 request.background,
826 request.max_turns,
827 request.model,
828 request.reasoning_effort,
829 )
830 .await
831 }
832
833 pub async fn send_input(&self, request: SendInputRequest) -> Result<SubagentStatusEntry> {
834 let prompt = request_prompt(&request.message, &request.items)
835 .ok_or_else(|| anyhow!("send_input requires a message or items"))?;
836
837 let maybe_restart = {
838 let mut state = self.state.write().await;
839 let record = state
840 .children
841 .get_mut(&request.target)
842 .ok_or_else(|| anyhow!("Unknown subagent id {}", request.target))?;
843
844 if record.status == SubagentStatus::Closed {
845 bail!("Subagent {} is closed", request.target);
846 }
847
848 record.updated_at = Utc::now();
849 record.last_prompt = Some(prompt.clone());
850
851 if request.interrupt {
852 if let Some(handle) = record.handle.take() {
853 handle.abort();
854 }
855 record.status = SubagentStatus::Queued;
856 record.queued_prompts.clear();
857 record.queued_prompts.push_back(prompt.clone());
858 true
859 } else if matches!(
860 record.status,
861 SubagentStatus::Running | SubagentStatus::Queued
862 ) {
863 record.status = SubagentStatus::Waiting;
864 record.queued_prompts.push_back(prompt.clone());
865 false
866 } else {
867 record.status = SubagentStatus::Queued;
868 record.queued_prompts.push_back(prompt.clone());
869 true
870 }
871 };
872
873 if maybe_restart {
874 self.restart_child(&request.target).await?;
875 }
876
877 self.status_for(&request.target).await
878 }
879
880 pub async fn resume(&self, target: &str) -> Result<SubagentStatusEntry> {
881 let subtree_ids = self.collect_spawn_subtree_ids(target).await?;
882 let mut restart_ids = Vec::new();
883 for node_id in subtree_ids {
884 if self.reopen_single(node_id.as_str()).await? {
885 restart_ids.push(node_id);
886 }
887 }
888 for restart_id in restart_ids {
889 self.restart_child(&restart_id).await?;
890 }
891 self.status_for(target).await
892 }
893
894 pub async fn close(&self, target: &str) -> Result<SubagentStatusEntry> {
895 let subtree_ids = self.collect_spawn_subtree_ids(target).await?;
896 for node_id in subtree_ids.into_iter().rev() {
897 self.close_single(node_id.as_str()).await?;
898 }
899 self.status_for(target).await
900 }
901
902 pub async fn wait(
903 &self,
904 targets: &[String],
905 timeout_ms: Option<u64>,
906 ) -> Result<Option<SubagentStatusEntry>> {
907 for target in targets {
908 if let Ok(entry) = self.status_for(target).await
909 && entry.status.is_terminal()
910 {
911 return Ok(Some(entry));
912 }
913 }
914
915 let timeout = std::time::Duration::from_millis(timeout_ms.unwrap_or_else(|| {
916 self.config
917 .vt_cfg
918 .subagents
919 .default_timeout_seconds
920 .saturating_mul(1000)
921 }));
922 let deadline = tokio::time::Instant::now() + timeout;
923
924 loop {
925 let notifies = {
927 let state = self.state.read().await;
928 targets
929 .iter()
930 .filter_map(|target| {
931 state
932 .children
933 .get(target)
934 .map(|record| record.notify.clone())
935 })
936 .collect::<Vec<_>>()
937 };
938 if notifies.is_empty() {
939 return Ok(None);
940 }
941
942 let wait_any = select_all(
951 notifies
952 .into_iter()
953 .map(|notify| Box::pin(async move { notify.notified().await }))
954 .collect::<Vec<_>>(),
955 );
956 tokio::pin!(wait_any);
957
958 for target in targets {
960 if let Ok(entry) = self.status_for(target).await
961 && entry.status.is_terminal()
962 {
963 return Ok(Some(entry));
964 }
965 }
966
967 let sleep = tokio::time::sleep_until(deadline);
968 tokio::pin!(sleep);
969
970 tokio::select! {
971 _ = &mut sleep => return Ok(None),
972 _ = &mut wait_any => {}
973 }
974 }
975 }
976
977 pub async fn status_for(&self, target: &str) -> Result<SubagentStatusEntry> {
978 let state = self.state.read().await;
979 let record = state
980 .children
981 .get(target)
982 .ok_or_else(|| anyhow!("Unknown subagent id {}", target))?;
983 Ok(record.build_status_entry())
984 }
985
986 async fn spawn_child_ids_for_parent(&self, parent_thread_id: &str) -> Vec<String> {
987 let state = self.state.read().await;
988 let mut child_ids = state
989 .children
990 .values()
991 .filter(|record| record.parent_thread_id == parent_thread_id)
992 .map(|record| record.id.clone())
993 .collect::<Vec<_>>();
994 child_ids.sort();
995 child_ids
996 }
997
998 async fn collect_spawn_subtree_ids(&self, root_thread_id: &str) -> Result<Vec<String>> {
999 let mut subtree_ids = Vec::new();
1000 let mut stack = vec![root_thread_id.to_string()];
1001
1002 while let Some(thread_id) = stack.pop() {
1003 subtree_ids.push(thread_id.clone());
1004 let child_ids = self.spawn_child_ids_for_parent(&thread_id).await;
1005 for child_id in child_ids.into_iter().rev() {
1006 stack.push(child_id);
1007 }
1008 }
1009
1010 Ok(subtree_ids)
1011 }
1012
1013 async fn reopen_single(&self, target: &str) -> Result<bool> {
1014 let mut state = self.state.write().await;
1015 let record = state
1016 .children
1017 .get_mut(target)
1018 .ok_or_else(|| anyhow!("Unknown subagent id {}", target))?;
1019 if matches!(
1020 record.status,
1021 SubagentStatus::Running | SubagentStatus::Queued
1022 ) {
1023 return Ok(false);
1024 }
1025 let prompt = record.last_prompt.clone().unwrap_or_else(|| {
1026 "Continue the delegated task from the existing context.".to_string()
1027 });
1028 record.status = SubagentStatus::Queued;
1029 record.updated_at = Utc::now();
1030 record.completed_at = None;
1031 record.error = None;
1032 record.summary = None;
1033 record.queued_prompts.push_back(prompt);
1034 Ok(true)
1035 }
1036
1037 async fn close_single(&self, target: &str) -> Result<SubagentStatusEntry> {
1038 let mut state = self.state.write().await;
1039 let record = state
1040 .children
1041 .get_mut(target)
1042 .ok_or_else(|| anyhow!("Unknown subagent id {}", target))?;
1043 if record.status == SubagentStatus::Closed {
1044 return Ok(record.build_status_entry());
1045 }
1046 if let Some(handle) = record.handle.take() {
1047 handle.abort();
1048 }
1049 record.status = SubagentStatus::Closed;
1050 record.updated_at = Utc::now();
1051 record.completed_at = Some(Utc::now());
1052 record.notify.notify_waiters();
1053 Ok(record.build_status_entry())
1054 }
1055
1056 async fn background_status_for(&self, target: &str) -> Result<BackgroundSubprocessEntry> {
1057 let state = self.state.read().await;
1058 let record = state
1059 .background_children
1060 .get(target)
1061 .ok_or_else(|| anyhow!("Unknown background subprocess {}", target))?;
1062 Ok(record.build_status_entry())
1063 }
1064
1065 async fn ensure_background_record_running(
1066 &self,
1067 agent_name: &str,
1068 stable_id: Option<&str>,
1069 restart_attempts: u8,
1070 overrides: Option<BackgroundLaunchOverrides>,
1071 ) -> Result<BackgroundSubprocessEntry> {
1072 let spec = self
1073 .resolve_requested_spec(Some(agent_name))
1074 .await
1075 .with_context(|| format!("Failed to resolve background subagent '{agent_name}'"))?;
1076 let record_id = stable_id
1077 .map(ToOwned::to_owned)
1078 .unwrap_or_else(|| background_record_id(agent_name));
1079 let previous_record = {
1080 let state = self.state.read().await;
1081 state.background_children.get(&record_id).map(|record| {
1082 (
1083 record.created_at,
1084 record.prompt.clone(),
1085 record.max_turns,
1086 record.model_override.clone(),
1087 record.reasoning_override.clone(),
1088 )
1089 })
1090 };
1091 let parent_session_id = self.parent_session_id.read().await.clone();
1092 let session_id = format!(
1093 "{}-{}-{}",
1094 sanitize_component(parent_session_id.as_str()),
1095 sanitize_component(record_id.as_str()),
1096 Utc::now().format("%Y%m%dT%H%M%S%3fZ")
1097 );
1098 let exec_session_id = format!("exec-{session_id}");
1099 let (
1100 created_at,
1101 previous_prompt,
1102 previous_max_turns,
1103 previous_model_override,
1104 previous_reasoning_override,
1105 ) = previous_record.unwrap_or((Utc::now(), String::new(), None, None, None));
1106 let prompt = overrides
1107 .as_ref()
1108 .and_then(|overrides| overrides.prompt.clone())
1109 .filter(|value| !value.trim().is_empty())
1110 .or_else(|| (!previous_prompt.trim().is_empty()).then_some(previous_prompt))
1111 .or_else(|| spec.initial_prompt.clone())
1112 .filter(|value| !value.trim().is_empty())
1113 .unwrap_or_else(|| {
1114 format!(
1115 "You are the VT Code background subagent `{}`. Summarize readiness briefly, inspect the workspace at a high level, then remain idle until the process is terminated.",
1116 spec.name
1117 )
1118 });
1119 let max_turns = normalize_background_child_max_turns(
1120 overrides
1121 .as_ref()
1122 .and_then(|overrides| overrides.max_turns)
1123 .or(previous_max_turns)
1124 .or(spec.max_turns),
1125 true,
1126 );
1127 let model_override = overrides
1128 .as_ref()
1129 .and_then(|overrides| overrides.model_override.clone())
1130 .or(previous_model_override)
1131 .or_else(|| spec.model.clone());
1132 let reasoning_override = overrides
1133 .as_ref()
1134 .and_then(|overrides| overrides.reasoning_override.clone())
1135 .or(previous_reasoning_override)
1136 .or_else(|| spec.reasoning_effort.clone());
1137
1138 {
1139 let mut state = self.state.write().await;
1140 state.background_children.insert(
1141 record_id.clone(),
1142 BackgroundRecord {
1143 id: record_id.clone(),
1144 agent_name: spec.name.clone(),
1145 display_label: subagent_display_label(&spec),
1146 description: spec.description.clone(),
1147 source: spec.source.label(),
1148 color: spec.color.clone(),
1149 session_id: session_id.clone(),
1150 exec_session_id: exec_session_id.clone(),
1151 desired_enabled: true,
1152 status: BackgroundSubprocessStatus::Starting,
1153 created_at,
1154 updated_at: Utc::now(),
1155 started_at: None,
1156 ended_at: None,
1157 pid: None,
1158 prompt: prompt.clone(),
1159 summary: Some("Starting background subagent".to_string()),
1160 error: None,
1161 archive_path: None,
1162 transcript_path: None,
1163 max_turns,
1164 model_override: model_override.clone(),
1165 reasoning_override: reasoning_override.clone(),
1166 restart_attempts,
1167 },
1168 );
1169 }
1170
1171 let launch = build_background_launch_spec(
1172 &self.config.workspace_root,
1173 spec.name.as_str(),
1174 parent_session_id.as_str(),
1175 session_id.as_str(),
1176 prompt.as_str(),
1177 max_turns,
1178 model_override.as_deref(),
1179 reasoning_override.as_deref(),
1180 )?;
1181 let metadata = if launch.use_pty {
1182 self.config
1183 .exec_sessions
1184 .create_pty_session(
1185 exec_session_id.clone().into(),
1186 launch.command,
1187 self.config.workspace_root.clone(),
1188 PtySize {
1189 rows: 24,
1190 cols: 80,
1191 pixel_width: 0,
1192 pixel_height: 0,
1193 },
1194 hashbrown::HashMap::new(),
1195 None,
1196 )
1197 .await
1198 } else {
1199 self.config
1200 .exec_sessions
1201 .create_pipe_session(
1202 exec_session_id.clone().into(),
1203 launch.command,
1204 self.config.workspace_root.clone(),
1205 hashbrown::HashMap::new(),
1206 )
1207 .await
1208 }
1209 .with_context(|| {
1210 format!(
1211 "Failed to spawn background subprocess for subagent '{}'",
1212 spec.name
1213 )
1214 })?;
1215
1216 tracing::info!(
1217 agent_name = spec.name.as_str(),
1218 record_id = record_id.as_str(),
1219 exec_session_id = exec_session_id.as_str(),
1220 pid = metadata.child_pid,
1221 "Spawned background subagent subprocess"
1222 );
1223
1224 {
1225 let mut state = self.state.write().await;
1226 let record = state
1227 .background_children
1228 .get_mut(&record_id)
1229 .ok_or_else(|| anyhow!("Unknown background subprocess {}", record_id))?;
1230 record.exec_session_id = exec_session_id;
1231 record.pid = metadata.child_pid;
1232 record.started_at = metadata.started_at;
1233 record.status = BackgroundSubprocessStatus::Running;
1234 record.updated_at = Utc::now();
1235 record.ended_at = None;
1236 record.error = None;
1237 record.summary = Some("Background subagent is running".to_string());
1238 }
1239
1240 self.save_background_state().await?;
1241 self.background_status_for(&record_id).await
1242 }
1243
1244 async fn refresh_background_archive_metadata(&self, target: &str) -> Result<()> {
1245 let session_id = {
1246 let state = self.state.read().await;
1247 state
1248 .background_children
1249 .get(target)
1250 .map(|record| record.session_id.clone())
1251 .ok_or_else(|| anyhow!("Unknown background subprocess {}", target))?
1252 };
1253
1254 if let Some(listing) = find_session_by_identifier(&session_id).await? {
1255 let mut state = self.state.write().await;
1256 if let Some(record) = state.background_children.get_mut(target) {
1257 record.archive_path = Some(listing.path.clone());
1258 record.transcript_path = Some(listing.path);
1259 }
1260 }
1261
1262 Ok(())
1263 }
1264
1265 async fn save_background_state(&self) -> Result<()> {
1266 let records = {
1267 let state = self.state.read().await;
1268 state
1269 .background_children
1270 .values()
1271 .cloned()
1272 .map(BackgroundRecord::into_persisted)
1273 .collect()
1274 };
1275 persist_background_state(&self.config.workspace_root, records)
1276 }
1277
1278 async fn find_spec(&self, candidate: &str) -> Option<SubagentSpec> {
1279 self.state
1280 .read()
1281 .await
1282 .discovered
1283 .effective
1284 .iter()
1285 .find(|spec| spec.is_subagent() && spec.matches_name(candidate))
1286 .cloned()
1287 }
1288
1289 async fn resolve_requested_spec(&self, requested: Option<&str>) -> Result<SubagentSpec> {
1290 let requested = requested.unwrap_or("default");
1291 self.find_spec(requested)
1292 .await
1293 .ok_or_else(|| anyhow!("Unknown subagent type {}", requested))
1294 }
1295
1296 async fn prepare_delegation_context(
1297 &self,
1298 requested_agent: Option<String>,
1299 items: &mut Vec<SubagentInputItem>,
1300 model: &mut Option<String>,
1301 tool_name: &'static str,
1302 ) -> Result<PreparedDelegationContext> {
1303 let state = self.state.read().await;
1304 sanitize_subagent_input_items(items);
1305 *model = normalize_requested_model_override(model.take(), &state.turn_hints.current_input);
1306 let requested_agent = if let Some(agent_type) = requested_agent {
1307 Some(agent_type)
1308 } else {
1309 match state.turn_hints.explicit_mentions.as_slice() {
1310 [] => None,
1311 [single] => Some(single.clone()),
1312 mentions => {
1313 bail!(
1314 "{} omitted agent_type, but the user explicitly selected multiple agents: {}. Specify agent_type explicitly.",
1315 tool_name,
1316 mentions.join(", ")
1317 );
1318 }
1319 }
1320 };
1321 Ok(PreparedDelegationContext {
1322 requested_agent,
1323 explicit_mentions: state.turn_hints.explicit_mentions.clone(),
1324 explicit_request: state.turn_hints.explicit_request,
1325 current_input: state.turn_hints.current_input.clone(),
1326 })
1327 }
1328
1329 fn prepare_delegation_prompt(
1330 &self,
1331 spec: &SubagentSpec,
1332 delegation: &PreparedDelegationContext,
1333 model: &mut Option<String>,
1334 message: &Option<String>,
1335 items: &[SubagentInputItem],
1336 tool_name: &'static str,
1337 launch_phrase: &'static str,
1338 ignored_model_warning: &'static str,
1339 ) -> Result<String> {
1340 if let Some(explicit) = delegation.explicit_mentions.first()
1341 && delegation.explicit_mentions.len() == 1
1342 && !spec.matches_name(explicit)
1343 {
1344 bail!(
1345 "{} requested agent_type '{}', but the user explicitly selected '{}'. Use the selected agent or ask the user to clarify.",
1346 tool_name,
1347 spec.name,
1348 explicit
1349 );
1350 }
1351 if !spec.is_read_only()
1352 && !delegation.explicit_request
1353 && delegation.requested_agent.is_none()
1354 {
1355 bail!(
1356 "{} cannot launch write-capable agent '{}' without an explicit delegation signal from the current user turn. Ask the user to mention the agent, say 'delegate'/'spawn', or request parallel work.",
1357 tool_name,
1358 spec.name
1359 );
1360 }
1361 if spec.is_read_only()
1362 && !self.config.vt_cfg.subagents.auto_delegate_read_only
1363 && !delegation.explicit_request
1364 {
1365 bail!(
1366 "{} cannot proactively launch read-only agent '{}' because `subagents.auto_delegate_read_only` is disabled and the current user turn did not explicitly request delegation.",
1367 tool_name,
1368 spec.name
1369 );
1370 }
1371 if let Some(requested_model) = model.as_deref()
1372 && !contains_explicit_model_request(&delegation.current_input, requested_model)
1373 {
1374 tracing::warn!(
1375 agent_name = spec.name.as_str(),
1376 requested_model = requested_model.trim(),
1377 "{ignored_model_warning}"
1378 );
1379 *model = None;
1380 }
1381 let prompt = request_prompt(message, items)
1382 .or_else(|| spec.initial_prompt.clone())
1383 .filter(|value| !value.trim().is_empty())
1384 .ok_or_else(|| anyhow!("{tool_name} requires a task message or items"))?;
1385 if delegated_task_requires_clarification(&prompt) {
1386 bail!(
1387 "{} task for '{}' is too vague ('{}'). Ask the user for a specific delegated task before {}.",
1388 tool_name,
1389 spec.name,
1390 prompt.trim(),
1391 launch_phrase
1392 );
1393 }
1394 Ok(prompt)
1395 }
1396
1397 fn active_background_launch_conflicts(
1398 record: &BackgroundRecord,
1399 prompt: &str,
1400 max_turns: Option<usize>,
1401 model_override: Option<&str>,
1402 reasoning_override: Option<&str>,
1403 ) -> Vec<&'static str> {
1404 let mut conflicts = Vec::new();
1405 if record.prompt != prompt {
1406 conflicts.push("prompt");
1407 }
1408 if record.max_turns != max_turns {
1409 conflicts.push("max_turns");
1410 }
1411 if record.model_override.as_deref() != model_override {
1412 conflicts.push("model");
1413 }
1414 if record.reasoning_override.as_deref() != reasoning_override {
1415 conflicts.push("reasoning_effort");
1416 }
1417 conflicts
1418 }
1419
1420 async fn spawn_with_spec(
1421 &self,
1422 spec: SubagentSpec,
1423 prompt: String,
1424 fork_context: bool,
1425 background: bool,
1426 max_turns: Option<usize>,
1427 model_override: Option<String>,
1428 reasoning_override: Option<String>,
1429 ) -> Result<SubagentStatusEntry> {
1430 if !self.config.vt_cfg.subagents.enabled {
1431 bail!("Subagents are disabled by configuration");
1432 }
1433 if self.config.depth.saturating_add(1) > self.config.vt_cfg.subagents.max_depth {
1434 bail!(
1435 "Subagent depth limit reached (max_depth={})",
1436 self.config.vt_cfg.subagents.max_depth
1437 );
1438 }
1439 if spec.isolation.as_deref() == Some("worktree") {
1440 bail!("Subagent isolation=worktree is not supported in this VT Code build");
1441 }
1442
1443 let active_count = {
1444 let state = self.state.read().await;
1445 state
1446 .children
1447 .values()
1448 .filter(|record| {
1449 matches!(
1450 record.status,
1451 SubagentStatus::Queued | SubagentStatus::Running | SubagentStatus::Waiting
1452 )
1453 })
1454 .count()
1455 };
1456 let effective_max_concurrent = self
1457 .config
1458 .vt_cfg
1459 .subagents
1460 .max_concurrent
1461 .min(SUBAGENT_HARD_CONCURRENCY_LIMIT);
1462 if active_count >= effective_max_concurrent {
1463 bail!(
1464 "Subagent concurrency limit reached (max_concurrent={})",
1465 effective_max_concurrent
1466 );
1467 }
1468 let is_background_child = background;
1469 let child_max_turns =
1470 normalize_background_child_max_turns(max_turns.or(spec.max_turns), is_background_child);
1471 let (_, _, effective_config) = prepare_child_runtime_config(
1472 &self.config.vt_cfg,
1473 &spec,
1474 self.config.parent_model.as_str(),
1475 self.config.parent_provider.as_str(),
1476 self.config.parent_reasoning_effort,
1477 child_max_turns,
1478 model_override.as_deref(),
1479 reasoning_override.as_deref(),
1480 resolve_effective_subagent_model,
1481 )?;
1482
1483 let id = format!(
1484 "agent-{}-{}",
1485 sanitize_component(spec.name.as_str()),
1486 Utc::now().format("%Y%m%dT%H%M%S%3fZ")
1487 );
1488 let parent_session_id = self.parent_session_id.read().await.clone();
1489 let session_id = format!(
1490 "{}-{}",
1491 sanitize_component(parent_session_id.as_str()),
1492 sanitize_component(id.as_str())
1493 );
1494 let display_label = subagent_display_label(&spec);
1495 let notify = Arc::new(Notify::new());
1496 let mut state = self.state.write().await;
1497 let initial_messages = if fork_context {
1498 state.parent_messages.clone()
1499 } else {
1500 Vec::new()
1501 };
1502 let entry = ChildRecord {
1503 id: id.clone(),
1504 session_id,
1505 parent_thread_id: parent_session_id,
1506 spec: spec.clone(),
1507 display_label,
1508 status: SubagentStatus::Queued,
1509 background: is_background_child,
1510 depth: self.config.depth.saturating_add(1),
1511 created_at: Utc::now(),
1512 updated_at: Utc::now(),
1513 completed_at: None,
1514 summary: None,
1515 error: None,
1516 archive_metadata: None,
1517 archive_path: None,
1518 transcript_path: None,
1519 effective_config: Some(effective_config),
1520 stored_messages: initial_messages,
1521 last_prompt: Some(prompt.clone()),
1522 queued_prompts: VecDeque::from([prompt]),
1523 max_turns: child_max_turns,
1524 model_override,
1525 reasoning_override,
1526 thread_handle: None,
1527 handle: None,
1528 notify,
1529 };
1530 state.children.insert(id.clone(), entry);
1531 drop(state);
1532
1533 self.launch_child(id.as_str()).await?;
1534 self.status_for(&id).await
1535 }
1536
1537 async fn restart_child(&self, target: &str) -> Result<()> {
1538 let has_queued_input = {
1539 let mut state = self.state.write().await;
1540 let record = state
1541 .children
1542 .get_mut(target)
1543 .ok_or_else(|| anyhow!("Unknown subagent id {}", target))?;
1544 if record.queued_prompts.is_empty()
1545 && let Some(prompt) = record.last_prompt.clone()
1546 {
1547 record.queued_prompts.push_back(prompt);
1548 }
1549 !record.queued_prompts.is_empty()
1550 };
1551 if !has_queued_input {
1552 bail!("Subagent {} has no queued input", target);
1553 }
1554 self.launch_child(target).await
1555 }
1556
1557 async fn launch_child(&self, child_id: &str) -> Result<()> {
1558 let controller = self.clone();
1559 let target = child_id.to_string();
1560 let handle = tokio::spawn(async move {
1561 controller.child_loop(&target).await;
1562 });
1563 let mut state = self.state.write().await;
1564 let record = state
1565 .children
1566 .get_mut(child_id)
1567 .ok_or_else(|| anyhow!("Unknown subagent id {}", child_id))?;
1568 record.handle = Some(handle);
1569 record.status = SubagentStatus::Queued;
1570 record.updated_at = Utc::now();
1571 Ok(())
1572 }
1573
1574 async fn child_loop(&self, child_id: &str) {
1575 loop {
1576 let request = {
1577 let mut state = self.state.write().await;
1578 let Some(record) = state.children.get_mut(child_id) else {
1579 return;
1580 };
1581 record.dequeue_run()
1582 };
1583 let Some(request) = request else {
1584 let mut state = self.state.write().await;
1585 if let Some(record) = state.children.get_mut(child_id) {
1586 record.handle = None;
1587 record.updated_at = Utc::now();
1588 }
1589 return;
1590 };
1591
1592 let execute = self
1593 .run_child_once(
1594 child_id,
1595 request.prompt,
1596 request.max_turns,
1597 request.model_override,
1598 request.reasoning_override,
1599 )
1600 .await;
1601
1602 let (has_more_work, hook_payload) = {
1603 let mut state = self.state.write().await;
1604 let Some(record) = state.children.get_mut(child_id) else {
1605 return;
1606 };
1607 record.updated_at = Utc::now();
1608 let has_more_work = record.apply_result(execute);
1609 let hook_payload = (!has_more_work).then(|| record.build_hook_payload());
1610 (has_more_work, hook_payload)
1611 };
1612
1613 if let Some((
1614 parent_session_id,
1615 child_thread_id,
1616 agent_name,
1617 display_label,
1618 background,
1619 status,
1620 transcript_path,
1621 )) = hook_payload
1622 && let Some(hooks) = self.lifecycle_hooks.as_ref()
1623 && let Err(err) = hooks
1624 .run_subagent_stop(
1625 &parent_session_id,
1626 &child_thread_id,
1627 &agent_name,
1628 &display_label,
1629 background,
1630 &status,
1631 transcript_path.as_deref(),
1632 )
1633 .await
1634 {
1635 tracing::warn!(
1636 child_id,
1637 error = %err,
1638 "Failed to run subagent stop hooks"
1639 );
1640 }
1641
1642 if has_more_work {
1643 continue;
1644 } else {
1645 let mut state = self.state.write().await;
1646 if let Some(record) = state.children.get_mut(child_id) {
1647 record.handle = None;
1648 record.updated_at = Utc::now();
1649 }
1650 return;
1651 }
1652 }
1653 }
1654
1655 async fn run_child_once(
1656 &self,
1657 child_id: &str,
1658 prompt: String,
1659 max_turns: Option<usize>,
1660 model_override: Option<String>,
1661 reasoning_override: Option<String>,
1662 ) -> Result<ChildRunResult> {
1663 let (spec, session_id, bootstrap_messages, display_label, background) = {
1664 let mut state = self.state.write().await;
1665 let record = state
1666 .children
1667 .get_mut(child_id)
1668 .ok_or_else(|| anyhow!("Unknown subagent id {}", child_id))?;
1669 record.status = SubagentStatus::Running;
1670 record.updated_at = Utc::now();
1671 (
1672 record.spec.clone(),
1673 record.session_id.clone(),
1674 record.stored_messages.clone(),
1675 record.display_label.clone(),
1676 record.background,
1677 )
1678 };
1679
1680 let (resolved_model, child_reasoning_effort, child_cfg) = prepare_child_runtime_config(
1681 &self.config.vt_cfg,
1682 &spec,
1683 self.config.parent_model.as_str(),
1684 self.config.parent_provider.as_str(),
1685 self.config.parent_reasoning_effort,
1686 max_turns,
1687 model_override.as_deref(),
1688 reasoning_override.as_deref(),
1689 resolve_effective_subagent_model,
1690 )?;
1691 let parent_session_id = self.parent_session_id.read().await.clone();
1692
1693 let archive_metadata = build_subagent_archive_metadata(
1694 &self.config.workspace_root,
1695 child_cfg.agent.default_model.as_str(),
1696 child_cfg.agent.provider.as_str(),
1697 child_cfg.agent.theme.as_str(),
1698 child_reasoning_effort.as_str(),
1699 parent_session_id.as_str(),
1700 !bootstrap_messages.is_empty(),
1701 );
1702 let bootstrap = ThreadBootstrap::new(Some(archive_metadata.clone()))
1703 .with_messages(bootstrap_messages.clone());
1704 let archive = if let Some(listing) = find_session_by_identifier(&session_id).await? {
1705 SessionArchive::resume_from_listing(&listing, archive_metadata.clone())
1706 } else {
1707 SessionArchive::new_with_identifier(archive_metadata.clone(), session_id.clone())
1708 .await?
1709 };
1710 checkpoint_subagent_archive_start(&archive, &bootstrap_messages).await?;
1711 let mut runner = AgentRunner::new_with_bootstrap(
1712 agent_type_for_spec(&spec),
1713 resolved_model,
1714 self.config.api_key.clone(),
1715 self.config.workspace_root.clone(),
1716 session_id.clone(),
1717 RunnerSettings {
1718 reasoning_effort: Some(child_reasoning_effort),
1719 verbosity: None,
1720 },
1721 None,
1722 bootstrap,
1723 Some(child_cfg.clone()),
1724 self.config.openai_chatgpt_auth.clone(),
1725 )
1726 .await?;
1727 runner.set_quiet(true);
1728 let thread_handle = runner.thread_handle();
1729 let archive_path = archive.path().to_path_buf();
1730
1731 {
1732 let mut state = self.state.write().await;
1733 let record = state
1734 .children
1735 .get_mut(child_id)
1736 .ok_or_else(|| anyhow!("Unknown subagent id {}", child_id))?;
1737 record.archive_metadata = Some(archive_metadata.clone());
1738 record.archive_path = Some(archive_path.clone());
1739 record.effective_config = Some(child_cfg.clone());
1740 record.thread_handle = Some(thread_handle.clone());
1741 }
1742 if let Some(hooks) = self.lifecycle_hooks.as_ref()
1743 && let Err(err) = hooks
1744 .run_subagent_start(
1745 parent_session_id.as_str(),
1746 thread_handle.thread_id().as_str(),
1747 spec.name.as_str(),
1748 &display_label,
1749 background,
1750 SubagentStatus::Running.as_str(),
1751 Some(archive_path.as_path()),
1752 )
1753 .await
1754 {
1755 tracing::warn!(
1756 child_id,
1757 error = %err,
1758 "Failed to run subagent start hooks"
1759 );
1760 }
1761
1762 let filtered_tools = filter_child_tools(
1763 &spec,
1764 runner.build_universal_tools().await?,
1765 spec.is_read_only(),
1766 );
1767 let allowed_tools = filtered_tools
1768 .iter()
1769 .map(|tool| tool.function_name().to_string())
1770 .collect::<Vec<_>>();
1771 runner.set_tool_definitions_override(filtered_tools);
1772 runner.enable_full_auto(&allowed_tools).await;
1773
1774 let memory_appendix =
1775 load_memory_appendix(&self.config.workspace_root, spec.name.as_str(), spec.memory)?;
1776 let mut task = Task::new(
1777 format!("subagent-{}", spec.name),
1778 format!("Subagent {}", spec.name),
1779 prompt,
1780 );
1781 task.instructions = Some(compose_subagent_instructions(&spec, memory_appendix));
1782
1783 let results = runner.execute_task(&task, &[]).await?;
1784 let messages = runner.session_messages();
1785 let transcript_path =
1786 persist_child_archive(&archive, &messages, spec.name.as_str()).await?;
1787
1788 Ok(ChildRunResult {
1789 messages,
1790 summary: if results.summary.trim().is_empty() {
1791 results.outcome.description()
1792 } else {
1793 results.summary.clone()
1794 },
1795 outcome: results.outcome,
1796 transcript_path,
1797 })
1798 }
1799}
1800
1801fn sanitize_component(value: &str) -> String {
1802 value
1803 .chars()
1804 .map(|ch| {
1805 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
1806 ch
1807 } else {
1808 '-'
1809 }
1810 })
1811 .collect::<String>()
1812 .trim_matches('-')
1813 .to_string()
1814}
1815
1816fn load_session_listing(
1817 path: &std::path::Path,
1818) -> Result<crate::utils::session_archive::SessionListing> {
1819 use anyhow::Context;
1820 let raw = std::fs::read_to_string(path)
1821 .with_context(|| format!("Failed to read session archive {}", path.display()))?;
1822 let snapshot: crate::utils::session_archive::SessionSnapshot = serde_json::from_str(&raw)
1823 .with_context(|| format!("Failed to parse session archive {}", path.display()))?;
1824 Ok(crate::utils::session_archive::SessionListing {
1825 path: path.to_path_buf(),
1826 snapshot,
1827 })
1828}
1829
1830async fn checkpoint_subagent_archive_start(
1831 archive: &SessionArchive,
1832 messages: &[Message],
1833) -> Result<()> {
1834 use crate::utils::session_archive::SessionMessage;
1835 let recent_messages = messages
1836 .iter()
1837 .map(SessionMessage::from)
1838 .collect::<Vec<_>>();
1839 archive
1840 .persist_progress_async(crate::utils::session_archive::SessionProgressArgs {
1841 total_messages: recent_messages.len(),
1842 distinct_tools: Vec::new(),
1843 recent_messages,
1844 turn_number: 1,
1845 token_usage: None,
1846 max_context_tokens: None,
1847 loaded_skills: Some(Vec::new()),
1848 })
1849 .await?;
1850 Ok(())
1851}
1852
1853async fn persist_child_archive(
1854 archive: &SessionArchive,
1855 messages: &[Message],
1856 agent_name: &str,
1857) -> Result<Option<PathBuf>> {
1858 use crate::utils::session_archive::SessionMessage;
1859 let transcript = messages
1860 .iter()
1861 .filter_map(transcript_line_from_message)
1862 .take(SUBAGENT_TRANSCRIPT_LINE_LIMIT)
1863 .collect::<Vec<_>>();
1864 let stored_messages = messages
1865 .iter()
1866 .map(SessionMessage::from)
1867 .collect::<Vec<_>>();
1868 let path = archive.finalize(
1869 transcript,
1870 stored_messages.len(),
1871 vec![agent_name.to_string()],
1872 stored_messages,
1873 )?;
1874 Ok(Some(path))
1875}
1876
1877fn transcript_line_from_message(message: &Message) -> Option<String> {
1878 let role = message.role.to_string();
1879 let content = message.content.trim();
1880 if content.is_empty() {
1881 return None;
1882 }
1883 Some(format!("{role}: {content}"))
1884}
1885
1886#[cfg(test)]
1889mod tests {
1890 use super::*;
1891 use crate::config::PermissionMode;
1892 use crate::config::constants::models;
1893 use crate::config::constants::tools;
1894 use crate::config::models::{ModelId, Provider};
1895 use crate::llm::provider::ToolDefinition;
1896 use crate::tools::exec_session::ExecSessionManager;
1897 use crate::tools::registry::PtySessionManager;
1898 use anyhow::{Result, anyhow};
1899 use std::collections::BTreeMap;
1900 use std::collections::VecDeque;
1901 use std::path::PathBuf;
1902 use std::sync::Arc;
1903 use std::time::Duration;
1904 use tempfile::TempDir;
1905 use tokio::sync::Notify;
1906 use vtcode_config::{SubagentMcpServer, SubagentMemoryScope, SubagentSource, SubagentSpec};
1907
1908 fn test_controller_config(
1909 workspace_root: PathBuf,
1910 vt_cfg: VTCodeConfig,
1911 ) -> SubagentControllerConfig {
1912 let pty_sessions = PtySessionManager::new(workspace_root.clone(), vt_cfg.pty.clone());
1913 let exec_sessions = ExecSessionManager::new(workspace_root.clone(), pty_sessions.clone());
1914 SubagentControllerConfig {
1915 workspace_root,
1916 parent_session_id: "parent-session".to_string(),
1917 parent_model: models::openai::GPT_5_4.to_string(),
1918 parent_provider: "openai".to_string(),
1919 parent_reasoning_effort: ReasoningEffortLevel::Medium,
1920 api_key: "test-key".to_string(),
1921 vt_cfg,
1922 openai_chatgpt_auth: None,
1923 depth: 0,
1924 exec_sessions,
1925 pty_manager: pty_sessions.manager().clone(),
1926 managed_background_runtime: false,
1927 }
1928 }
1929
1930 fn test_child_record(
1931 id: &str,
1932 parent_thread_id: &str,
1933 spec: &SubagentSpec,
1934 status: SubagentStatus,
1935 depth: usize,
1936 ) -> ChildRecord {
1937 ChildRecord {
1938 id: id.to_string(),
1939 session_id: format!("session-{id}"),
1940 parent_thread_id: parent_thread_id.to_string(),
1941 spec: spec.clone(),
1942 display_label: subagent_display_label(spec),
1943 status,
1944 background: false,
1945 depth,
1946 created_at: Utc::now(),
1947 updated_at: Utc::now(),
1948 completed_at: status.is_terminal().then_some(Utc::now()),
1949 summary: None,
1950 error: None,
1951 archive_metadata: None,
1952 archive_path: None,
1953 transcript_path: None,
1954 effective_config: Some(VTCodeConfig::default()),
1955 stored_messages: Vec::new(),
1956 last_prompt: Some(format!("prompt-{id}")),
1957 queued_prompts: VecDeque::new(),
1958 max_turns: None,
1959 model_override: None,
1960 reasoning_override: None,
1961 thread_handle: None,
1962 handle: None,
1963 notify: Arc::new(Notify::new()),
1964 }
1965 }
1966
1967 fn write_test_background_subagent(workspace_root: &std::path::Path) {
1968 let agent_dir = workspace_root.join(".vtcode/agents");
1969 std::fs::create_dir_all(&agent_dir).expect("agent dir");
1970 std::fs::write(
1971 agent_dir.join("background-demo.md"),
1972 r#"---
1973name: background-demo
1974description: Minimal demo agent for the managed background subprocess flow.
1975tools:
1976 - unified_exec
1977background: true
1978maxTurns: 2
1979initialPrompt: Report readiness once.
1980---
1981
1982Run the managed background demo.
1983"#,
1984 )
1985 .expect("write background agent");
1986 }
1987
1988 fn write_test_primary_agent(workspace_root: &std::path::Path) {
1989 let agent_dir = workspace_root.join(".vtcode/agents");
1990 std::fs::create_dir_all(&agent_dir).expect("agent dir");
1991 std::fs::write(
1992 agent_dir.join("duck.md"),
1993 r#"---
1994name: duck
1995description: Discussion controller.
1996mode: primary
1997permissionMode: plan
1998---
1999
2000Discuss before implementation.
2001"#,
2002 )
2003 .expect("write primary agent");
2004 }
2005
2006 fn write_test_read_only_subagent(workspace_root: &std::path::Path) {
2007 let agent_dir = workspace_root.join(".vtcode/agents");
2008 std::fs::create_dir_all(&agent_dir).expect("agent dir");
2009 std::fs::write(
2010 agent_dir.join("readonly-demo.md"),
2011 r#"---
2012name: readonly-demo
2013description: Read-only test child agent.
2014tools:
2015 - read_file
2016permissionMode: plan
2017---
2018
2019Inspect the repository.
2020"#,
2021 )
2022 .expect("write read-only agent");
2023 }
2024
2025 #[test]
2026 fn request_prompt_prefers_message() {
2027 let request = SpawnAgentRequest {
2028 message: Some("hello".to_string()),
2029 ..SpawnAgentRequest::default()
2030 };
2031 assert_eq!(
2032 request_prompt(&request.message, &request.items).as_deref(),
2033 Some("hello")
2034 );
2035 }
2036
2037 #[test]
2038 fn delegated_task_requires_clarification_for_vague_prompt() {
2039 assert!(delegated_task_requires_clarification("report"));
2040 assert!(delegated_task_requires_clarification("report findings"));
2041 assert!(!delegated_task_requires_clarification(
2042 "review current code changes"
2043 ));
2044 }
2045
2046 #[test]
2047 fn resolve_subagent_model_maps_aliases() {
2048 let cfg = VTCodeConfig::default();
2049 let resolved = resolve_subagent_model(
2050 &cfg,
2051 models::anthropic::CLAUDE_SONNET_4_6,
2052 "anthropic",
2053 Some("haiku"),
2054 "explorer",
2055 )
2056 .expect("resolve model");
2057 assert_eq!(resolved.as_str(), models::anthropic::CLAUDE_HAIKU_4_5);
2058 }
2059
2060 #[test]
2061 fn resolve_subagent_model_defaults_to_parent_when_omitted() {
2062 let cfg = VTCodeConfig::default();
2063 let resolved = resolve_subagent_model(
2064 &cfg,
2065 models::ollama::GPT_OSS_120B_CLOUD,
2066 "ollama",
2067 None,
2068 "worker",
2069 )
2070 .expect("resolve model");
2071 assert_eq!(resolved.as_str(), models::ollama::GPT_OSS_120B_CLOUD);
2072 }
2073
2074 #[test]
2075 fn resolve_subagent_model_accepts_dotted_claude_aliases_for_anthropic() {
2076 let cfg = VTCodeConfig::default();
2077 let resolved =
2078 resolve_subagent_model(&cfg, "claude-haiku-4.5", "anthropic", None, "worker")
2079 .expect("resolve model");
2080 assert_eq!(resolved.as_str(), models::anthropic::CLAUDE_HAIKU_4_5);
2081 }
2082
2083 #[test]
2084 fn resolve_subagent_model_falls_back_to_copilot_default_for_unsupported_inherit_model() {
2085 let cfg = VTCodeConfig::default();
2086 let resolved = resolve_subagent_model(&cfg, "claude-haiku-4.5", "copilot", None, "worker")
2087 .expect("resolve model");
2088 assert_eq!(
2089 resolved,
2090 ModelId::default_orchestrator_for_provider(Provider::Copilot)
2091 );
2092 }
2093
2094 #[test]
2095 fn resolve_effective_subagent_model_uses_explicit_inherit_override() {
2096 let cfg = VTCodeConfig::default();
2097 let resolved = resolve_effective_subagent_model(
2098 &cfg,
2099 models::anthropic::CLAUDE_SONNET_4_6,
2100 "anthropic",
2101 Some("inherit"),
2102 Some("haiku"),
2103 "worker",
2104 )
2105 .expect("resolve model");
2106 assert_eq!(resolved.as_str(), models::anthropic::CLAUDE_SONNET_4_6);
2107 }
2108
2109 #[test]
2110 fn resolve_effective_subagent_model_falls_back_to_parent_on_invalid_override() {
2111 let cfg = VTCodeConfig::default();
2112 let resolved = resolve_effective_subagent_model(
2113 &cfg,
2114 models::ollama::GPT_OSS_120B_CLOUD,
2115 "ollama",
2116 Some("not-a-real-model"),
2117 None,
2118 "rust-engineer",
2119 )
2120 .expect("resolve model");
2121 assert_eq!(resolved.as_str(), models::ollama::GPT_OSS_120B_CLOUD);
2122 }
2123
2124 #[test]
2125 fn resolve_subagent_small_model_rejects_cross_provider_configured_lightweight_model() {
2126 let mut cfg = VTCodeConfig::default();
2127 cfg.agent.small_model.model = models::anthropic::CLAUDE_HAIKU_4_5.to_string();
2128
2129 let resolved = resolve_subagent_model(
2130 &cfg,
2131 models::openai::GPT_5_4,
2132 "openai",
2133 Some("small"),
2134 "worker",
2135 )
2136 .expect("resolve model");
2137
2138 assert_eq!(resolved, ModelId::GPT54Mini);
2139 }
2140
2141 #[test]
2142 fn resolve_effective_subagent_model_falls_back_to_spec_model_on_invalid_override() {
2143 let cfg = VTCodeConfig::default();
2144 let resolved = resolve_effective_subagent_model(
2145 &cfg,
2146 models::anthropic::CLAUDE_SONNET_4_6,
2147 "anthropic",
2148 Some("not-a-real-model"),
2149 Some("haiku"),
2150 "reviewer",
2151 )
2152 .expect("resolve model");
2153 assert_eq!(resolved.as_str(), models::anthropic::CLAUDE_HAIKU_4_5);
2154 }
2155
2156 #[test]
2157 fn background_record_ids_are_stable_and_sanitized() {
2158 assert_eq!(
2159 background_record_id("Rust Engineer"),
2160 "background-Rust-Engineer"
2161 );
2162 assert_eq!(
2163 background_record_id("plugin:reviewer/default"),
2164 "background-plugin-reviewer-default"
2165 );
2166 }
2167
2168 #[test]
2169 fn background_subagent_command_includes_expected_flags() {
2170 let workspace = std::env::current_dir().expect("workspace");
2171 let command = build_background_subagent_command(
2172 &workspace,
2173 "rust-engineer",
2174 "session-parent",
2175 "session-child",
2176 "Inspect the repo",
2177 Some(7),
2178 Some("gpt-5.4-mini"),
2179 Some("high"),
2180 )
2181 .expect("background command");
2182
2183 assert!(command.len() >= 15);
2184 assert_eq!(command[1], "background-subagent");
2185 assert!(
2186 command
2187 .windows(2)
2188 .any(|pair| pair == ["--agent-name", "rust-engineer"])
2189 );
2190 assert!(
2191 command
2192 .windows(2)
2193 .any(|pair| pair == ["--parent-session-id", "session-parent"])
2194 );
2195 assert!(
2196 command
2197 .windows(2)
2198 .any(|pair| pair == ["--session-id", "session-child"])
2199 );
2200 assert!(
2201 command
2202 .windows(2)
2203 .any(|pair| pair == ["--prompt", "Inspect the repo"])
2204 );
2205 assert!(command.windows(2).any(|pair| pair == ["--max-turns", "7"]));
2206 assert!(
2207 command
2208 .windows(2)
2209 .any(|pair| pair == ["--model-override", "gpt-5.4-mini"])
2210 );
2211 assert!(
2212 command
2213 .windows(2)
2214 .any(|pair| pair == ["--reasoning-override", "high"])
2215 );
2216 }
2217
2218 #[test]
2219 fn resolve_effective_subagent_model_still_errors_on_invalid_spec_model() {
2220 let cfg = VTCodeConfig::default();
2221 let err = resolve_effective_subagent_model(
2222 &cfg,
2223 models::anthropic::CLAUDE_SONNET_4_6,
2224 "anthropic",
2225 None,
2226 Some("not-a-real-model"),
2227 "reviewer",
2228 )
2229 .expect_err("invalid spec model should fail");
2230 assert!(err.to_string().contains("Failed to resolve model"));
2231 }
2232
2233 async fn wait_for_effective_model(
2234 controller: &SubagentController,
2235 target: &str,
2236 ) -> Result<String> {
2237 for _ in 0..50 {
2238 if let Ok(snapshot) = controller.snapshot_for_thread(target).await {
2239 return Ok(snapshot.effective_config.agent.default_model);
2240 }
2241 tokio::time::sleep(Duration::from_millis(10)).await;
2242 }
2243
2244 Err(anyhow!(
2245 "Subagent {target} did not capture an effective runtime configuration in time"
2246 ))
2247 }
2248
2249 fn read_only_test_spec(name: &str) -> SubagentSpec {
2250 SubagentSpec {
2251 name: name.to_string(),
2252 description: "test".to_string(),
2253 prompt: String::new(),
2254 tools: Some(vec![tools::READ_FILE.to_string()]),
2255 disallowed_tools: Vec::new(),
2256 model: None,
2257 color: None,
2258 reasoning_effort: None,
2259 permission_mode: Some(PermissionMode::Plan),
2260 skills: Vec::new(),
2261 mcp_servers: Vec::new(),
2262 hooks: None,
2263 background: false,
2264 mode: vtcode_config::AgentMode::Subagent,
2265 max_turns: None,
2266 nickname_candidates: Vec::new(),
2267 initial_prompt: None,
2268 memory: None,
2269 isolation: None,
2270 aliases: Vec::new(),
2271 source: SubagentSource::Builtin,
2272 file_path: None,
2273 warnings: Vec::new(),
2274 }
2275 }
2276
2277 #[test]
2278 fn filter_child_tools_removes_subagent_tools_in_children() {
2279 let defs = vec![
2280 ToolDefinition::function(
2281 tools::SPAWN_AGENT.to_string(),
2282 "Spawn".to_string(),
2283 serde_json::json!({"type": "object"}),
2284 ),
2285 ToolDefinition::function(
2286 tools::UNIFIED_SEARCH.to_string(),
2287 "Search".to_string(),
2288 serde_json::json!({"type": "object"}),
2289 ),
2290 ToolDefinition::function(
2291 tools::LIST_FILES.to_string(),
2292 "List".to_string(),
2293 serde_json::json!({"type": "object"}),
2294 ),
2295 ToolDefinition::function(
2296 tools::REQUEST_USER_INPUT.to_string(),
2297 "Ask".to_string(),
2298 serde_json::json!({"type": "object"}),
2299 ),
2300 ];
2301 let spec = vtcode_config::builtin_subagents()
2302 .into_iter()
2303 .find(|spec| spec.name == "explorer")
2304 .expect("explorer");
2305 let filtered = filter_child_tools(&spec, defs, true);
2306 assert_eq!(filtered.len(), 1);
2307 assert_eq!(filtered[0].function_name(), tools::UNIFIED_SEARCH);
2308 }
2309
2310 #[test]
2311 fn filter_child_tools_keeps_unified_exec_for_shell_capable_agents() {
2312 let defs = vec![
2313 ToolDefinition::function(
2314 tools::UNIFIED_EXEC.to_string(),
2315 "Exec".to_string(),
2316 serde_json::json!({"type": "object"}),
2317 ),
2318 ToolDefinition::function(
2319 tools::UNIFIED_SEARCH.to_string(),
2320 "Search".to_string(),
2321 serde_json::json!({"type": "object"}),
2322 ),
2323 ];
2324 let spec = SubagentSpec {
2325 name: "shell-demo".to_string(),
2326 description: "test".to_string(),
2327 prompt: String::new(),
2328 tools: Some(vec![
2329 tools::UNIFIED_EXEC.to_string(),
2330 tools::UNIFIED_SEARCH.to_string(),
2331 ]),
2332 disallowed_tools: Vec::new(),
2333 model: None,
2334 color: None,
2335 reasoning_effort: None,
2336 permission_mode: None,
2337 skills: Vec::new(),
2338 mcp_servers: Vec::new(),
2339 hooks: None,
2340 background: false,
2341 mode: vtcode_config::AgentMode::Subagent,
2342 max_turns: None,
2343 nickname_candidates: Vec::new(),
2344 initial_prompt: None,
2345 memory: None,
2346 isolation: None,
2347 aliases: Vec::new(),
2348 source: SubagentSource::Builtin,
2349 file_path: None,
2350 warnings: Vec::new(),
2351 };
2352
2353 let filtered = filter_child_tools(&spec, defs, spec.is_read_only());
2354 assert_eq!(filtered.len(), 2);
2355 assert_eq!(filtered[0].function_name(), tools::UNIFIED_EXEC);
2356 assert_eq!(filtered[1].function_name(), tools::UNIFIED_SEARCH);
2357 }
2358
2359 #[test]
2360 fn build_child_config_clamps_permissions_and_intersects_allowed_tools() {
2361 let mut parent = VTCodeConfig::default();
2362 parent.permissions.default_mode = PermissionMode::Default;
2363 parent.permissions.allow = vec![
2364 tools::READ_FILE.to_string(),
2365 tools::UNIFIED_SEARCH.to_string(),
2366 ];
2367 parent.permissions.deny = vec![tools::UNIFIED_EXEC.to_string()];
2368
2369 let mut spec = vtcode_config::builtin_subagents()
2370 .into_iter()
2371 .find(|spec| spec.name == "worker")
2372 .expect("worker");
2373 spec.permission_mode = Some(PermissionMode::BypassPermissions);
2374 spec.tools = Some(vec![
2375 tools::SPAWN_AGENT.to_string(),
2376 tools::UNIFIED_SEARCH.to_string(),
2377 tools::READ_FILE.to_string(),
2378 ]);
2379
2380 let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, None);
2381 assert_eq!(child.permissions.default_mode, PermissionMode::Default);
2382 assert_eq!(
2383 child.permissions.allow,
2384 vec![
2385 tools::READ_FILE.to_string(),
2386 tools::UNIFIED_SEARCH.to_string()
2387 ]
2388 );
2389 assert!(
2390 child
2391 .permissions
2392 .deny
2393 .contains(&tools::UNIFIED_EXEC.to_string())
2394 );
2395 assert!(
2396 child
2397 .permissions
2398 .deny
2399 .contains(&tools::SPAWN_AGENT.to_string())
2400 );
2401 }
2402
2403 #[test]
2404 fn build_child_config_preserves_matching_rule_and_exact_tool_ids() {
2405 let mut parent = VTCodeConfig::default();
2406 parent.permissions.allow = vec![
2407 "Read(/docs/**)".to_string(),
2408 "mcp::context7::search".to_string(),
2409 tools::READ_FILE.to_string(),
2410 ];
2411
2412 let mut spec = vtcode_config::builtin_subagents()
2413 .into_iter()
2414 .find(|spec| spec.name == "worker")
2415 .expect("worker");
2416 spec.tools = Some(vec![
2417 "mcp::context7::search".to_string(),
2418 tools::UNIFIED_EXEC.to_string(),
2419 tools::READ_FILE.to_string(),
2420 ]);
2421
2422 let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, None);
2423
2424 assert_eq!(
2425 child.permissions.allow,
2426 vec![
2427 "Read(/docs/**)".to_string(),
2428 "mcp::context7::search".to_string(),
2429 tools::READ_FILE.to_string()
2430 ]
2431 );
2432 }
2433
2434 #[test]
2435 fn build_child_config_preserves_parent_rule_shaped_allowlist() {
2436 let mut parent = VTCodeConfig::default();
2437 parent.permissions.allow = vec!["Read".to_string()];
2438
2439 let mut spec = vtcode_config::builtin_subagents()
2440 .into_iter()
2441 .find(|spec| spec.name == "worker")
2442 .expect("worker");
2443 spec.tools = Some(vec![
2444 tools::READ_FILE.to_string(),
2445 tools::UNIFIED_SEARCH.to_string(),
2446 tools::UNIFIED_EXEC.to_string(),
2447 ]);
2448
2449 let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, None);
2450
2451 assert_eq!(child.permissions.allow, vec!["Read".to_string()]);
2452 }
2453
2454 #[test]
2455 fn build_child_config_promotes_single_turn_budget_to_recovery_budget() {
2456 let parent = VTCodeConfig::default();
2457 let spec = vtcode_config::builtin_subagents()
2458 .into_iter()
2459 .find(|spec| spec.name == "worker")
2460 .expect("worker");
2461
2462 let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, Some(1));
2463
2464 assert_eq!(child.automation.full_auto.max_turns, SUBAGENT_MIN_MAX_TURNS);
2465 }
2466
2467 #[test]
2468 fn background_children_get_a_higher_turn_floor() {
2469 assert_eq!(normalize_background_child_max_turns(Some(2), true), Some(4));
2470 assert_eq!(normalize_background_child_max_turns(Some(3), true), Some(4));
2471 assert_eq!(normalize_background_child_max_turns(Some(4), true), Some(4));
2472 }
2473
2474 #[test]
2475 fn foreground_children_keep_the_existing_turn_floor() {
2476 assert_eq!(
2477 normalize_background_child_max_turns(Some(1), false),
2478 Some(SUBAGENT_MIN_MAX_TURNS)
2479 );
2480 assert_eq!(
2481 normalize_background_child_max_turns(Some(2), false),
2482 Some(2)
2483 );
2484 assert_eq!(normalize_background_child_max_turns(None, true), None);
2485 }
2486
2487 #[test]
2488 fn build_child_config_merges_inline_mcp_provider() {
2489 let parent = VTCodeConfig::default();
2490 let mut spec = vtcode_config::builtin_subagents()
2491 .into_iter()
2492 .find(|spec| spec.name == "default")
2493 .expect("default");
2494 spec.mcp_servers = vec![SubagentMcpServer::Inline(BTreeMap::from([(
2495 "playwright".to_string(),
2496 serde_json::json!({
2497 "type": "stdio",
2498 "command": "npx",
2499 "args": ["-y", "@playwright/mcp@latest"],
2500 }),
2501 )]))];
2502
2503 let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, None);
2504 let provider = child
2505 .mcp
2506 .providers
2507 .iter()
2508 .find(|provider| provider.name == "playwright")
2509 .expect("playwright provider");
2510 assert_eq!(provider.name, "playwright");
2511 }
2512
2513 #[test]
2514 fn explicit_delegation_request_detects_mentions_and_keywords() {
2515 let direct_mentions = extract_explicit_agent_mentions("@agent-worker fix the issue", &[]);
2516 assert!(contains_explicit_delegation_request(
2517 "@agent-worker fix the issue",
2518 direct_mentions.as_slice()
2519 ));
2520 let no_mentions = extract_explicit_agent_mentions("delegate this in parallel", &[]);
2521 assert!(contains_explicit_delegation_request(
2522 "delegate this in parallel",
2523 no_mentions.as_slice()
2524 ));
2525 let empty_mentions = extract_explicit_agent_mentions("review the repository", &[]);
2526 assert!(!contains_explicit_delegation_request(
2527 "review the repository",
2528 empty_mentions.as_slice()
2529 ));
2530 }
2531
2532 #[test]
2533 fn explicit_agent_mentions_detect_natural_language_selection() {
2534 let rust_engineer = read_only_test_spec("rust-engineer");
2535 assert_eq!(
2536 extract_explicit_agent_mentions(
2537 "use rust-engineer agent to review current code",
2538 &[rust_engineer]
2539 ),
2540 vec!["rust-engineer".to_string()]
2541 );
2542 }
2543
2544 #[test]
2545 fn explicit_agent_mentions_detect_looser_subagent_selection() {
2546 let background_demo = read_only_test_spec("background-demo");
2547 assert_eq!(
2548 extract_explicit_agent_mentions(
2549 "use background-demo and run the subagent",
2550 &[background_demo]
2551 ),
2552 vec!["background-demo".to_string()]
2553 );
2554 }
2555
2556 #[test]
2557 fn explicit_agent_mentions_detect_run_subagent_selection() {
2558 let rust_engineer = read_only_test_spec("rust-engineer");
2559 assert_eq!(
2560 extract_explicit_agent_mentions(
2561 "run rust-engineer subagent and review changes",
2562 &[rust_engineer]
2563 ),
2564 vec!["rust-engineer".to_string()]
2565 );
2566 }
2567
2568 #[test]
2569 fn explicit_agent_mentions_ignore_primary_only_agents() {
2570 let mut duck = read_only_test_spec("duck");
2571 duck.mode = vtcode_config::AgentMode::Primary;
2572
2573 assert_eq!(
2574 extract_explicit_agent_mentions("@agent-duck discuss the task", &[duck.clone()]),
2575 Vec::<String>::new()
2576 );
2577 assert_eq!(
2578 extract_explicit_agent_mentions("run duck agent and discuss the task", &[duck]),
2579 Vec::<String>::new()
2580 );
2581 }
2582
2583 #[test]
2584 fn explicit_model_request_detects_aliases_and_full_ids() {
2585 assert!(contains_explicit_model_request(
2586 "delegate this using gpt-5.4-mini",
2587 "gpt-5.4-mini"
2588 ));
2589 assert!(contains_explicit_model_request(
2590 "use the worker subagent with haiku",
2591 "haiku"
2592 ));
2593 assert!(contains_explicit_model_request(
2594 "run this with the small model",
2595 "small"
2596 ));
2597 assert!(!contains_explicit_model_request(
2598 "delegate this small cleanup task",
2599 "small"
2600 ));
2601 assert!(!contains_explicit_model_request(
2602 "delegate this task",
2603 "gpt-5.4-mini"
2604 ));
2605 }
2606
2607 #[test]
2608 fn normalize_requested_model_override_drops_default_like_values() {
2609 assert_eq!(
2610 normalize_requested_model_override(Some("default".to_string()), "delegate this task"),
2611 None
2612 );
2613 assert_eq!(
2614 normalize_requested_model_override(Some(" inherit ".to_string()), "delegate this task"),
2615 None
2616 );
2617 assert_eq!(
2618 normalize_requested_model_override(
2619 Some(" inherit ".to_string()),
2620 "delegate this task using inherit"
2621 ),
2622 Some("inherit".to_string())
2623 );
2624 }
2625
2626 #[test]
2627 fn sanitize_subagent_input_items_drops_empty_fields() {
2628 let mut items = vec![
2629 SubagentInputItem {
2630 item_type: Some("text".to_string()),
2631 text: Some(" Workspace: /tmp/repo ".to_string()),
2632 path: Some(String::new()),
2633 name: Some(" ".to_string()),
2634 image_url: None,
2635 },
2636 SubagentInputItem {
2637 item_type: Some("text".to_string()),
2638 text: Some(" ".to_string()),
2639 path: Some(String::new()),
2640 name: None,
2641 image_url: None,
2642 },
2643 ];
2644
2645 sanitize_subagent_input_items(&mut items);
2646
2647 assert_eq!(items.len(), 1);
2648 assert_eq!(items[0].text.as_deref(), Some("Workspace: /tmp/repo"));
2649 assert!(items[0].path.is_none());
2650 assert!(items[0].name.is_none());
2651 }
2652
2653 #[tokio::test]
2654 async fn controller_exposes_builtin_specs() {
2655 let temp = TempDir::new().expect("tempdir");
2656 let controller = SubagentController::new(test_controller_config(
2657 temp.path().to_path_buf(),
2658 VTCodeConfig::default(),
2659 ))
2660 .await
2661 .expect("controller");
2662 let specs = controller.effective_specs().await;
2663 assert!(specs.iter().any(|spec| spec.name == "explorer"));
2664 assert!(specs.iter().any(|spec| spec.name == "worker"));
2665 }
2666
2667 #[tokio::test]
2668 async fn spawn_defaults_to_single_explicit_mention() {
2669 let temp = TempDir::new().expect("tempdir");
2670 let controller = SubagentController::new(test_controller_config(
2671 temp.path().to_path_buf(),
2672 VTCodeConfig::default(),
2673 ))
2674 .await
2675 .expect("controller");
2676
2677 controller
2678 .set_turn_delegation_hints_from_input("@agent-explorer inspect the codebase")
2679 .await;
2680
2681 let spawned = controller
2682 .spawn(SpawnAgentRequest {
2683 message: Some("Inspect the codebase.".to_string()),
2684 ..SpawnAgentRequest::default()
2685 })
2686 .await
2687 .expect("spawn");
2688
2689 assert_eq!(spawned.agent_name, "explorer");
2690 controller.close(&spawned.id).await.expect("close");
2691 }
2692
2693 #[tokio::test]
2694 async fn spawn_defaults_to_single_natural_language_selection() {
2695 let temp = TempDir::new().expect("tempdir");
2696 let controller = SubagentController::new(test_controller_config(
2697 temp.path().to_path_buf(),
2698 VTCodeConfig::default(),
2699 ))
2700 .await
2701 .expect("controller");
2702
2703 let mentions = controller
2704 .set_turn_delegation_hints_from_input("use explorer agent to inspect the codebase")
2705 .await;
2706 assert_eq!(mentions, vec!["explorer".to_string()]);
2707
2708 let spawned = controller
2709 .spawn(SpawnAgentRequest {
2710 message: Some("Inspect the codebase.".to_string()),
2711 ..SpawnAgentRequest::default()
2712 })
2713 .await
2714 .expect("spawn");
2715
2716 assert_eq!(spawned.agent_name, "explorer");
2717 controller.close(&spawned.id).await.expect("close");
2718 }
2719
2720 #[tokio::test]
2721 async fn spawn_rejects_mismatched_explicit_mention() {
2722 let temp = TempDir::new().expect("tempdir");
2723 let controller = SubagentController::new(test_controller_config(
2724 temp.path().to_path_buf(),
2725 VTCodeConfig::default(),
2726 ))
2727 .await
2728 .expect("controller");
2729
2730 controller
2731 .set_turn_delegation_hints_from_input("@agent-explorer inspect the codebase")
2732 .await;
2733
2734 let err = controller
2735 .spawn(SpawnAgentRequest {
2736 agent_type: Some("worker".to_string()),
2737 message: Some("Implement a change.".to_string()),
2738 ..SpawnAgentRequest::default()
2739 })
2740 .await
2741 .expect_err("mismatched mention should fail");
2742
2743 assert!(
2744 err.to_string()
2745 .contains("user explicitly selected 'explorer'")
2746 );
2747 }
2748
2749 #[tokio::test]
2750 async fn spawn_rejects_write_capable_agent_without_explicit_request_or_agent_type() {
2751 let temp = TempDir::new().expect("tempdir");
2752 let controller = SubagentController::new(test_controller_config(
2753 temp.path().to_path_buf(),
2754 VTCodeConfig::default(),
2755 ))
2756 .await
2757 .expect("controller");
2758
2759 let err = controller
2760 .spawn(SpawnAgentRequest {
2761 message: Some("Implement a change.".to_string()),
2762 ..SpawnAgentRequest::default()
2763 })
2764 .await
2765 .expect_err("write-capable agent should require explicit request or agent_type");
2766
2767 assert!(
2768 err.to_string()
2769 .contains("cannot launch write-capable agent")
2770 );
2771 }
2772
2773 #[tokio::test]
2774 async fn spawn_allows_write_capable_agent_with_explicit_agent_type() {
2775 let temp = TempDir::new().expect("tempdir");
2776 let controller = SubagentController::new(test_controller_config(
2777 temp.path().to_path_buf(),
2778 VTCodeConfig::default(),
2779 ))
2780 .await
2781 .expect("controller");
2782
2783 let spawned = controller
2784 .spawn(SpawnAgentRequest {
2785 agent_type: Some("worker".to_string()),
2786 message: Some("Implement a change.".to_string()),
2787 ..SpawnAgentRequest::default()
2788 })
2789 .await
2790 .expect("explicit agent_type should allow write-capable agent");
2791
2792 controller.close(&spawned.id).await.expect("close");
2793 }
2794
2795 #[tokio::test]
2796 async fn spawn_rejects_primary_only_agent_as_child() {
2797 let temp = TempDir::new().expect("tempdir");
2798 write_test_primary_agent(temp.path());
2799 let controller = SubagentController::new(test_controller_config(
2800 temp.path().to_path_buf(),
2801 VTCodeConfig::default(),
2802 ))
2803 .await
2804 .expect("controller");
2805
2806 let mentions = controller
2807 .set_turn_delegation_hints_from_input("@agent-duck discuss the task")
2808 .await;
2809 assert!(mentions.is_empty());
2810
2811 let err = controller
2812 .spawn(SpawnAgentRequest {
2813 agent_type: Some("duck".to_string()),
2814 message: Some("Discuss the task.".to_string()),
2815 ..SpawnAgentRequest::default()
2816 })
2817 .await
2818 .expect_err("primary-only agent should not spawn as child");
2819
2820 assert!(err.to_string().contains("Unknown subagent type duck"));
2821 }
2822
2823 #[tokio::test]
2824 async fn spawn_accepts_background_flag_outside_managed_background_runtime() {
2825 let temp = TempDir::new().expect("tempdir");
2826 let controller = SubagentController::new(test_controller_config(
2827 temp.path().to_path_buf(),
2828 VTCodeConfig::default(),
2829 ))
2830 .await
2831 .expect("controller");
2832
2833 controller
2834 .set_turn_delegation_hints_from_input("delegate this task")
2835 .await;
2836
2837 let spawned = controller
2838 .spawn(SpawnAgentRequest {
2839 agent_type: Some("explorer".to_string()),
2840 message: Some("Inspect the codebase.".to_string()),
2841 background: true,
2842 ..SpawnAgentRequest::default()
2843 })
2844 .await
2845 .expect("background child spawn should succeed");
2846
2847 assert!(spawned.background);
2848 controller.close(&spawned.id).await.expect("close");
2849 }
2850
2851 #[tokio::test]
2852 async fn spawn_allows_background_capable_spec_as_foreground_child() {
2853 let temp = TempDir::new().expect("tempdir");
2854 write_test_background_subagent(temp.path());
2855 let controller = SubagentController::new(test_controller_config(
2856 temp.path().to_path_buf(),
2857 VTCodeConfig::default(),
2858 ))
2859 .await
2860 .expect("controller");
2861
2862 controller
2863 .set_turn_delegation_hints_from_input("run background-demo subagent and demo")
2864 .await;
2865
2866 let spawned = controller
2867 .spawn(SpawnAgentRequest {
2868 agent_type: Some("background-demo".to_string()),
2869 message: Some("Run the demo.".to_string()),
2870 background: false,
2871 ..SpawnAgentRequest::default()
2872 })
2873 .await
2874 .expect("foreground background-capable spawn should succeed");
2875
2876 assert_eq!(spawned.agent_name, "background-demo");
2877 assert!(!spawned.background);
2878 controller.close(&spawned.id).await.expect("close");
2879 }
2880
2881 #[tokio::test]
2882 async fn spawn_rejects_vague_task_even_with_explicit_request() {
2883 let temp = TempDir::new().expect("tempdir");
2884 let controller = SubagentController::new(test_controller_config(
2885 temp.path().to_path_buf(),
2886 VTCodeConfig::default(),
2887 ))
2888 .await
2889 .expect("controller");
2890
2891 controller
2892 .set_turn_delegation_hints_from_input("run worker subagent and report")
2893 .await;
2894
2895 let err = controller
2896 .spawn(SpawnAgentRequest {
2897 agent_type: Some("worker".to_string()),
2898 message: Some("report".to_string()),
2899 ..SpawnAgentRequest::default()
2900 })
2901 .await
2902 .expect_err("vague task should require clarification");
2903
2904 assert!(err.to_string().contains("too vague ('report')"));
2905 }
2906
2907 #[tokio::test]
2908 async fn spawn_defaults_to_write_capable_run_subagent_selection() {
2909 let temp = TempDir::new().expect("tempdir");
2910 let controller = SubagentController::new(test_controller_config(
2911 temp.path().to_path_buf(),
2912 VTCodeConfig::default(),
2913 ))
2914 .await
2915 .expect("controller");
2916
2917 let mentions = controller
2918 .set_turn_delegation_hints_from_input("run worker subagent and implement the change")
2919 .await;
2920 assert_eq!(mentions, vec!["worker".to_string()]);
2921
2922 let spawned = controller
2923 .spawn(SpawnAgentRequest {
2924 message: Some("Implement the change.".to_string()),
2925 ..SpawnAgentRequest::default()
2926 })
2927 .await
2928 .expect("spawn");
2929
2930 assert_eq!(spawned.agent_name, "worker");
2931 controller.close(&spawned.id).await.expect("close");
2932 }
2933
2934 #[tokio::test]
2935 async fn spawn_rejects_read_only_agent_when_auto_delegate_is_disabled() {
2936 let temp = TempDir::new().expect("tempdir");
2937 write_test_read_only_subagent(temp.path());
2938 let mut cfg = VTCodeConfig::default();
2939 cfg.subagents.auto_delegate_read_only = false;
2940 let controller =
2941 SubagentController::new(test_controller_config(temp.path().to_path_buf(), cfg))
2942 .await
2943 .expect("controller");
2944
2945 let err = controller
2946 .spawn(SpawnAgentRequest {
2947 agent_type: Some("readonly-demo".to_string()),
2948 message: Some("Inspect the repository.".to_string()),
2949 ..SpawnAgentRequest::default()
2950 })
2951 .await
2952 .expect_err("read-only agent should require explicit delegation");
2953
2954 assert!(
2955 err.to_string()
2956 .contains("cannot proactively launch read-only agent 'readonly-demo'")
2957 );
2958 }
2959
2960 #[test]
2961 fn load_memory_appendix_renders_compact_summary() {
2962 let temp = TempDir::new().expect("tempdir");
2963 let memory_dir = temp.path().join(".vtcode/agent-memory/reviewer");
2964 std::fs::create_dir_all(&memory_dir).expect("memory dir");
2965 std::fs::write(
2966 memory_dir.join("MEMORY.md"),
2967 "# Reviewer Memory\n\n## Preferences\n- Keep diffs surgical.\n- Run focused tests before broad checks.\n- Prefer repo docs for orientation.\n- Ask only when a decision is materially blocked.\n- Additional long-form notes that should stay out of the prompt body.\n",
2968 )
2969 .expect("write memory");
2970
2971 let appendix =
2972 load_memory_appendix(temp.path(), "reviewer", Some(SubagentMemoryScope::Project))
2973 .expect("appendix")
2974 .expect("memory appendix");
2975
2976 assert!(appendix.contains("Persistent memory file:"));
2977 assert!(appendix.contains("Key points:"));
2978 assert!(appendix.contains("Keep diffs surgical."));
2979 assert!(appendix.contains("Open `MEMORY.md` when exact wording or more detail matters."));
2980 assert!(!appendix.contains("Current MEMORY.md excerpt"));
2981 assert!(!appendix.contains("## Preferences"));
2982 }
2983
2984 #[tokio::test]
2985 async fn spawn_ignores_model_override_without_explicit_user_model_request() {
2986 let temp = TempDir::new().expect("tempdir");
2987 let controller = SubagentController::new(test_controller_config(
2988 temp.path().to_path_buf(),
2989 VTCodeConfig::default(),
2990 ))
2991 .await
2992 .expect("controller");
2993
2994 controller
2995 .set_turn_delegation_hints_from_input("delegate this task")
2996 .await;
2997
2998 let spawned = controller
2999 .spawn(SpawnAgentRequest {
3000 agent_type: Some("worker".to_string()),
3001 message: Some("Implement the change.".to_string()),
3002 model: Some(models::openai::GPT_5_4_MINI.to_string()),
3003 ..SpawnAgentRequest::default()
3004 })
3005 .await
3006 .expect("spawn");
3007
3008 let effective_model = wait_for_effective_model(&controller, &spawned.id)
3009 .await
3010 .expect("effective model");
3011 assert_eq!(effective_model, models::openai::GPT_5_4);
3012 controller.close(&spawned.id).await.expect("close");
3013 }
3014
3015 #[tokio::test]
3016 async fn spawn_honors_model_override_when_user_explicitly_requests_it() {
3017 let temp = TempDir::new().expect("tempdir");
3018 let controller = SubagentController::new(test_controller_config(
3019 temp.path().to_path_buf(),
3020 VTCodeConfig::default(),
3021 ))
3022 .await
3023 .expect("controller");
3024
3025 controller
3026 .set_turn_delegation_hints_from_input("delegate this task using gpt-5.4-mini")
3027 .await;
3028
3029 let spawned = controller
3030 .spawn(SpawnAgentRequest {
3031 agent_type: Some("worker".to_string()),
3032 message: Some("Implement the change.".to_string()),
3033 model: Some(models::openai::GPT_5_4_MINI.to_string()),
3034 ..SpawnAgentRequest::default()
3035 })
3036 .await
3037 .expect("spawn");
3038
3039 let effective_model = wait_for_effective_model(&controller, &spawned.id)
3040 .await
3041 .expect("effective model");
3042 assert_eq!(effective_model, models::openai::GPT_5_4_MINI);
3043 controller.close(&spawned.id).await.expect("close");
3044 }
3045
3046 #[tokio::test]
3047 async fn spawn_background_subprocess_rejects_non_background_agent() {
3048 let temp = TempDir::new().expect("tempdir");
3049 let mut cfg = VTCodeConfig::default();
3050 cfg.subagents.background.enabled = true;
3051 let controller =
3052 SubagentController::new(test_controller_config(temp.path().to_path_buf(), cfg))
3053 .await
3054 .expect("controller");
3055
3056 controller
3057 .set_turn_delegation_hints_from_input("delegate this task")
3058 .await;
3059
3060 let err = controller
3061 .spawn_background_subprocess(SpawnBackgroundSubprocessRequest {
3062 agent_type: Some("worker".to_string()),
3063 message: Some("Implement a change.".to_string()),
3064 ..SpawnBackgroundSubprocessRequest::default()
3065 })
3066 .await
3067 .expect_err("non-background agent should be rejected");
3068
3069 assert!(err.to_string().contains("background: true"));
3070 assert!(err.to_string().contains("Use spawn_agent instead"));
3071 }
3072
3073 #[tokio::test]
3074 async fn spawn_background_subprocess_returns_active_record_when_settings_match() {
3075 let temp = TempDir::new().expect("tempdir");
3076 write_test_background_subagent(temp.path());
3077 let mut cfg = VTCodeConfig::default();
3078 cfg.subagents.background.enabled = true;
3079 let controller =
3080 SubagentController::new(test_controller_config(temp.path().to_path_buf(), cfg))
3081 .await
3082 .expect("controller");
3083
3084 controller
3085 .set_turn_delegation_hints_from_input("delegate this task")
3086 .await;
3087
3088 let spec = controller
3089 .resolve_requested_spec(Some("background-demo"))
3090 .await
3091 .expect("spec");
3092 let record_id = background_record_id(spec.name.as_str());
3093 let created_at = Utc::now();
3094 {
3095 let mut state = controller.state.write().await;
3096 state.background_children.insert(
3097 record_id.clone(),
3098 BackgroundRecord {
3099 id: record_id.clone(),
3100 agent_name: spec.name.clone(),
3101 display_label: subagent_display_label(&spec),
3102 description: spec.description.clone(),
3103 source: spec.source.label(),
3104 color: spec.color.clone(),
3105 session_id: "session-background-demo".to_string(),
3106 exec_session_id: "exec-session-background-demo".to_string(),
3107 desired_enabled: true,
3108 status: BackgroundSubprocessStatus::Running,
3109 created_at,
3110 updated_at: created_at,
3111 started_at: Some(created_at),
3112 ended_at: None,
3113 pid: Some(42),
3114 prompt: "Report readiness once.".to_string(),
3115 summary: Some("ready".to_string()),
3116 error: None,
3117 archive_path: None,
3118 transcript_path: None,
3119 max_turns: Some(4),
3120 model_override: None,
3121 reasoning_override: None,
3122 restart_attempts: 0,
3123 },
3124 );
3125 }
3126
3127 let entry = controller
3128 .spawn_background_subprocess(SpawnBackgroundSubprocessRequest {
3129 agent_type: Some("background-demo".to_string()),
3130 ..SpawnBackgroundSubprocessRequest::default()
3131 })
3132 .await
3133 .expect("matching active record should be returned");
3134
3135 assert_eq!(entry.id, record_id);
3136 assert_eq!(entry.status, BackgroundSubprocessStatus::Running);
3137 assert_eq!(entry.pid, Some(42));
3138 }
3139
3140 #[tokio::test]
3141 async fn spawn_background_subprocess_rejects_conflicting_active_record_settings() {
3142 let temp = TempDir::new().expect("tempdir");
3143 write_test_background_subagent(temp.path());
3144 let mut cfg = VTCodeConfig::default();
3145 cfg.subagents.background.enabled = true;
3146 let controller =
3147 SubagentController::new(test_controller_config(temp.path().to_path_buf(), cfg))
3148 .await
3149 .expect("controller");
3150
3151 controller
3152 .set_turn_delegation_hints_from_input("delegate this task")
3153 .await;
3154
3155 let spec = controller
3156 .resolve_requested_spec(Some("background-demo"))
3157 .await
3158 .expect("spec");
3159 let record_id = background_record_id(spec.name.as_str());
3160 let created_at = Utc::now();
3161 {
3162 let mut state = controller.state.write().await;
3163 state.background_children.insert(
3164 record_id,
3165 BackgroundRecord {
3166 id: background_record_id(spec.name.as_str()),
3167 agent_name: spec.name.clone(),
3168 display_label: subagent_display_label(&spec),
3169 description: spec.description.clone(),
3170 source: spec.source.label(),
3171 color: spec.color.clone(),
3172 session_id: "session-background-demo".to_string(),
3173 exec_session_id: "exec-session-background-demo".to_string(),
3174 desired_enabled: true,
3175 status: BackgroundSubprocessStatus::Running,
3176 created_at,
3177 updated_at: created_at,
3178 started_at: Some(created_at),
3179 ended_at: None,
3180 pid: Some(42),
3181 prompt: "Report readiness once.".to_string(),
3182 summary: Some("ready".to_string()),
3183 error: None,
3184 archive_path: None,
3185 transcript_path: None,
3186 max_turns: Some(4),
3187 model_override: None,
3188 reasoning_override: None,
3189 restart_attempts: 0,
3190 },
3191 );
3192 }
3193
3194 let err = controller
3195 .spawn_background_subprocess(SpawnBackgroundSubprocessRequest {
3196 agent_type: Some("background-demo".to_string()),
3197 message: Some("Run a different task.".to_string()),
3198 ..SpawnBackgroundSubprocessRequest::default()
3199 })
3200 .await
3201 .expect_err("conflicting active record should be rejected");
3202
3203 assert!(err.to_string().contains("different prompt"));
3204 assert!(err.to_string().contains("Stop or restart"));
3205 }
3206
3207 #[tokio::test]
3208 async fn resume_preserves_captured_runtime_overrides() {
3209 let temp = TempDir::new().expect("tempdir");
3210 let controller = SubagentController::new(test_controller_config(
3211 temp.path().to_path_buf(),
3212 VTCodeConfig::default(),
3213 ))
3214 .await
3215 .expect("controller");
3216
3217 controller
3218 .set_turn_delegation_hints_from_input("delegate this task using gpt-5.4-mini")
3219 .await;
3220
3221 let spawned = controller
3222 .spawn(SpawnAgentRequest {
3223 agent_type: Some("worker".to_string()),
3224 message: Some("Implement the change.".to_string()),
3225 model: Some(models::openai::GPT_5_4_MINI.to_string()),
3226 max_turns: Some(2),
3227 ..SpawnAgentRequest::default()
3228 })
3229 .await
3230 .expect("spawn");
3231
3232 let initial_model = wait_for_effective_model(&controller, &spawned.id)
3233 .await
3234 .expect("initial effective model");
3235 assert_eq!(initial_model, models::openai::GPT_5_4_MINI);
3236
3237 let closed = controller.close(&spawned.id).await.expect("close");
3238 assert_eq!(closed.status, SubagentStatus::Closed);
3239
3240 controller.resume(&spawned.id).await.expect("resume");
3241
3242 for _ in 0..100 {
3243 let status = controller.status_for(&spawned.id).await.expect("status");
3244 if status.updated_at > closed.updated_at && status.status != SubagentStatus::Closed {
3245 let snapshot = controller
3246 .snapshot_for_thread(&spawned.id)
3247 .await
3248 .expect("snapshot");
3249 assert_eq!(
3250 snapshot.effective_config.agent.default_model,
3251 models::openai::GPT_5_4_MINI
3252 );
3253 controller.close(&spawned.id).await.expect("final close");
3254 return;
3255 }
3256 tokio::time::sleep(Duration::from_millis(10)).await;
3257 }
3258
3259 panic!("resumed subagent did not capture runtime config in time");
3260 }
3261
3262 #[tokio::test]
3263 async fn spawn_captures_runtime_config_before_first_child_turn() {
3264 let temp = TempDir::new().expect("tempdir");
3265 let controller = SubagentController::new(test_controller_config(
3266 temp.path().to_path_buf(),
3267 VTCodeConfig::default(),
3268 ))
3269 .await
3270 .expect("controller");
3271
3272 controller
3273 .set_turn_delegation_hints_from_input("delegate this task")
3274 .await;
3275
3276 let spawned = controller
3277 .spawn(SpawnAgentRequest {
3278 agent_type: Some("worker".to_string()),
3279 message: Some("Implement the change.".to_string()),
3280 ..SpawnAgentRequest::default()
3281 })
3282 .await
3283 .expect("spawn");
3284
3285 let snapshot = controller
3286 .snapshot_for_thread(&spawned.id)
3287 .await
3288 .expect("snapshot");
3289
3290 assert_eq!(snapshot.id, spawned.id);
3291 assert!(
3292 !snapshot
3293 .effective_config
3294 .agent
3295 .default_model
3296 .trim()
3297 .is_empty()
3298 );
3299
3300 controller.close(&spawned.id).await.expect("close");
3301 }
3302
3303 #[tokio::test]
3304 async fn spawn_custom_uses_explicit_spec_without_delegation_hints() {
3305 let temp = TempDir::new().expect("tempdir");
3306 let controller = SubagentController::new(test_controller_config(
3307 temp.path().to_path_buf(),
3308 VTCodeConfig::default(),
3309 ))
3310 .await
3311 .expect("controller");
3312
3313 let mut spec = vtcode_config::builtin_subagents()
3314 .into_iter()
3315 .find(|spec| spec.name == "explorer")
3316 .expect("explorer");
3317 spec.name = "init-grounding-explorer".to_string();
3318 spec.description = "VT Code /init grounding explorer.".to_string();
3319 spec.source = SubagentSource::ProjectVtcode;
3320
3321 let spawned = controller
3322 .spawn_custom(
3323 spec,
3324 SpawnAgentRequest {
3325 message: Some(
3326 "Inspect the repository and report agent-facing findings.".to_string(),
3327 ),
3328 max_turns: Some(2),
3329 ..SpawnAgentRequest::default()
3330 },
3331 )
3332 .await
3333 .expect("spawn");
3334
3335 assert_eq!(spawned.agent_name, "init-grounding-explorer");
3336 assert_eq!(spawned.source, SubagentSource::ProjectVtcode.label());
3337 controller.close(&spawned.id).await.expect("close");
3338 }
3339
3340 #[tokio::test]
3341 async fn spawn_custom_rejects_write_capable_spec() {
3342 let temp = TempDir::new().expect("tempdir");
3343 let controller = SubagentController::new(test_controller_config(
3344 temp.path().to_path_buf(),
3345 VTCodeConfig::default(),
3346 ))
3347 .await
3348 .expect("controller");
3349
3350 let spec = vtcode_config::builtin_subagents()
3351 .into_iter()
3352 .find(|spec| spec.name == "worker")
3353 .expect("worker");
3354
3355 let err = controller
3356 .spawn_custom(
3357 spec,
3358 SpawnAgentRequest {
3359 message: Some("Implement a change.".to_string()),
3360 ..SpawnAgentRequest::default()
3361 },
3362 )
3363 .await
3364 .expect_err("write-capable custom spec should be rejected");
3365
3366 assert!(
3367 err.to_string()
3368 .contains("custom subagent spawn only supports read-only specs")
3369 );
3370 }
3371
3372 #[tokio::test]
3373 async fn spawn_custom_rejects_primary_only_spec() {
3374 let temp = TempDir::new().expect("tempdir");
3375 write_test_primary_agent(temp.path());
3376 let controller = SubagentController::new(test_controller_config(
3377 temp.path().to_path_buf(),
3378 VTCodeConfig::default(),
3379 ))
3380 .await
3381 .expect("controller");
3382
3383 let spec = controller
3384 .effective_specs()
3385 .await
3386 .into_iter()
3387 .find(|spec| spec.name == "duck")
3388 .expect("duck primary agent");
3389
3390 let err = controller
3391 .spawn_custom(
3392 spec,
3393 SpawnAgentRequest {
3394 message: Some("Discuss the task.".to_string()),
3395 ..SpawnAgentRequest::default()
3396 },
3397 )
3398 .await
3399 .expect_err("primary-only custom spec should be rejected");
3400
3401 assert!(
3402 err.to_string()
3403 .contains("custom subagent spawn only supports subagent-capable specs")
3404 );
3405 }
3406
3407 #[tokio::test]
3408 async fn close_marks_child_closed() {
3409 let temp = TempDir::new().expect("tempdir");
3410 let controller = SubagentController::new(test_controller_config(
3411 temp.path().to_path_buf(),
3412 VTCodeConfig::default(),
3413 ))
3414 .await
3415 .expect("controller");
3416 controller
3417 .set_turn_delegation_hints_from_input("delegate this task")
3418 .await;
3419 let spawned = controller
3420 .spawn(SpawnAgentRequest {
3421 agent_type: Some("default".to_string()),
3422 message: Some("Summarize the repository.".to_string()),
3423 ..SpawnAgentRequest::default()
3424 })
3425 .await
3426 .expect("spawn");
3427 let closed = controller.close(&spawned.id).await.expect("close");
3428 assert_eq!(closed.status, SubagentStatus::Closed);
3429 }
3430
3431 #[tokio::test]
3432 async fn close_is_idempotent_for_closed_agents() {
3433 let temp = TempDir::new().expect("tempdir");
3434 let controller = SubagentController::new(test_controller_config(
3435 temp.path().to_path_buf(),
3436 VTCodeConfig::default(),
3437 ))
3438 .await
3439 .expect("controller");
3440 controller
3441 .set_turn_delegation_hints_from_input("delegate this task")
3442 .await;
3443 let spawned = controller
3444 .spawn(SpawnAgentRequest {
3445 agent_type: Some("default".to_string()),
3446 message: Some("Summarize the repository.".to_string()),
3447 ..SpawnAgentRequest::default()
3448 })
3449 .await
3450 .expect("spawn");
3451
3452 let closed = controller.close(&spawned.id).await.expect("first close");
3453 let closed_again = controller.close(&spawned.id).await.expect("second close");
3454
3455 assert_eq!(closed_again.status, SubagentStatus::Closed);
3456 assert_eq!(closed_again.updated_at, closed.updated_at);
3457 assert_eq!(closed_again.completed_at, closed.completed_at);
3458 }
3459
3460 #[tokio::test]
3461 async fn close_and_resume_cascade_through_spawn_tree() {
3462 let temp = TempDir::new().expect("tempdir");
3463 let controller = SubagentController::new(test_controller_config(
3464 temp.path().to_path_buf(),
3465 VTCodeConfig::default(),
3466 ))
3467 .await
3468 .expect("controller");
3469
3470 let spec = vtcode_config::builtin_subagents()
3471 .into_iter()
3472 .find(|spec| spec.name == "explorer")
3473 .expect("explorer");
3474
3475 {
3476 let mut state = controller.state.write().await;
3477 state.children.insert(
3478 "parent".to_string(),
3479 test_child_record("parent", "session-root", &spec, SubagentStatus::Running, 1),
3480 );
3481 state.children.insert(
3482 "child".to_string(),
3483 test_child_record("child", "parent", &spec, SubagentStatus::Running, 2),
3484 );
3485 state.children.insert(
3486 "grandchild".to_string(),
3487 test_child_record("grandchild", "child", &spec, SubagentStatus::Running, 3),
3488 );
3489 }
3490
3491 let closed = controller.close("parent").await.expect("close");
3492 assert_eq!(closed.status, SubagentStatus::Closed);
3493 assert_eq!(
3494 controller.status_for("child").await.expect("child").status,
3495 SubagentStatus::Closed
3496 );
3497 assert_eq!(
3498 controller
3499 .status_for("grandchild")
3500 .await
3501 .expect("grandchild")
3502 .status,
3503 SubagentStatus::Closed
3504 );
3505
3506 let subtree_ids = controller
3507 .collect_spawn_subtree_ids("parent")
3508 .await
3509 .expect("collect subtree");
3510 assert_eq!(
3511 subtree_ids,
3512 vec![
3513 "parent".to_string(),
3514 "child".to_string(),
3515 "grandchild".to_string()
3516 ]
3517 );
3518
3519 let mut restart_ids = Vec::new();
3520 for node_id in subtree_ids {
3521 if controller
3522 .reopen_single(node_id.as_str())
3523 .await
3524 .expect("reopen subtree node")
3525 {
3526 restart_ids.push(node_id);
3527 }
3528 }
3529
3530 assert_eq!(
3531 restart_ids,
3532 vec![
3533 "parent".to_string(),
3534 "child".to_string(),
3535 "grandchild".to_string()
3536 ]
3537 );
3538 assert_eq!(
3539 controller
3540 .status_for("parent")
3541 .await
3542 .expect("parent")
3543 .status,
3544 SubagentStatus::Queued
3545 );
3546 assert_eq!(
3547 controller.status_for("child").await.expect("child").status,
3548 SubagentStatus::Queued
3549 );
3550 assert_eq!(
3551 controller
3552 .status_for("grandchild")
3553 .await
3554 .expect("grandchild")
3555 .status,
3556 SubagentStatus::Queued
3557 );
3558 }
3559
3560 #[tokio::test]
3561 async fn spawn_rejects_fourth_active_subagent() {
3562 let temp = TempDir::new().expect("tempdir");
3563 let controller = SubagentController::new(test_controller_config(
3564 temp.path().to_path_buf(),
3565 VTCodeConfig::default(),
3566 ))
3567 .await
3568 .expect("controller");
3569 controller
3570 .set_turn_delegation_hints_from_input("delegate this task")
3571 .await;
3572
3573 let spec = vtcode_config::builtin_subagents()
3574 .into_iter()
3575 .find(|spec| spec.name == "explorer")
3576 .expect("explorer");
3577
3578 {
3579 let mut state = controller.state.write().await;
3580 for idx in 0..SUBAGENT_HARD_CONCURRENCY_LIMIT {
3581 let id = format!("active-{idx}");
3582 state.children.insert(
3583 id.clone(),
3584 ChildRecord {
3585 id: id.clone(),
3586 session_id: format!("session-{id}"),
3587 parent_thread_id: "parent-session".to_string(),
3588 spec: spec.clone(),
3589 display_label: subagent_display_label(&spec),
3590 status: SubagentStatus::Running,
3591 background: false,
3592 depth: 1,
3593 created_at: Utc::now(),
3594 updated_at: Utc::now(),
3595 completed_at: None,
3596 summary: None,
3597 error: None,
3598 archive_metadata: None,
3599 archive_path: None,
3600 transcript_path: None,
3601 effective_config: None,
3602 stored_messages: Vec::new(),
3603 last_prompt: Some("Inspect the codebase.".to_string()),
3604 queued_prompts: VecDeque::new(),
3605 max_turns: None,
3606 model_override: None,
3607 reasoning_override: None,
3608 thread_handle: None,
3609 handle: None,
3610 notify: Arc::new(Notify::new()),
3611 },
3612 );
3613 }
3614 }
3615
3616 let err = controller
3617 .spawn(SpawnAgentRequest {
3618 agent_type: Some("explorer".to_string()),
3619 message: Some("Inspect another codepath.".to_string()),
3620 ..SpawnAgentRequest::default()
3621 })
3622 .await
3623 .expect_err("fourth active subagent should be rejected");
3624
3625 assert!(err.to_string().contains(&format!(
3626 "Subagent concurrency limit reached (max_concurrent={})",
3627 SUBAGENT_HARD_CONCURRENCY_LIMIT
3628 )));
3629 }
3630
3631 #[tokio::test]
3632 async fn wait_returns_first_terminal_child() {
3633 let temp = TempDir::new().expect("tempdir");
3634 let controller = SubagentController::new(test_controller_config(
3635 temp.path().to_path_buf(),
3636 VTCodeConfig::default(),
3637 ))
3638 .await
3639 .expect("controller");
3640 let spec = vtcode_config::builtin_subagents()
3641 .into_iter()
3642 .find(|spec| spec.name == "default")
3643 .expect("default");
3644
3645 {
3646 let mut state = controller.state.write().await;
3647 for id in ["first", "second"] {
3648 state.children.insert(
3649 id.to_string(),
3650 ChildRecord {
3651 id: id.to_string(),
3652 session_id: format!("session-{id}"),
3653 parent_thread_id: "parent-session".to_string(),
3654 spec: spec.clone(),
3655 display_label: subagent_display_label(&spec),
3656 status: SubagentStatus::Running,
3657 background: false,
3658 depth: 1,
3659 created_at: Utc::now(),
3660 updated_at: Utc::now(),
3661 completed_at: None,
3662 summary: None,
3663 error: None,
3664 archive_metadata: None,
3665 archive_path: None,
3666 transcript_path: None,
3667 effective_config: None,
3668 stored_messages: Vec::new(),
3669 last_prompt: None,
3670 queued_prompts: VecDeque::new(),
3671 max_turns: None,
3672 model_override: None,
3673 reasoning_override: None,
3674 thread_handle: None,
3675 handle: None,
3676 notify: Arc::new(Notify::new()),
3677 },
3678 );
3679 }
3680 }
3681
3682 let controller_clone = controller.clone();
3683 tokio::spawn(async move {
3684 tokio::time::sleep(Duration::from_millis(20)).await;
3685 let mut state = controller_clone.state.write().await;
3686 let record = state.children.get_mut("second").expect("second child");
3687 record.status = SubagentStatus::Completed;
3688 record.summary = Some("done".to_string());
3689 record.completed_at = Some(Utc::now());
3690 record.updated_at = Utc::now();
3691 record.notify.notify_waiters();
3692 });
3693
3694 let result = controller
3695 .wait(&["first".to_string(), "second".to_string()], Some(500))
3696 .await
3697 .expect("wait result")
3698 .expect("terminal child");
3699 assert_eq!(result.id, "second");
3700 assert_eq!(result.status, SubagentStatus::Completed);
3701 }
3702}