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 = {
926 let state = self.state.read().await;
927 targets
928 .iter()
929 .filter_map(|target| {
930 state
931 .children
932 .get(target)
933 .map(|record| record.notify.clone())
934 })
935 .collect::<Vec<_>>()
936 };
937 if notifies.is_empty() {
938 return Ok(None);
939 }
940
941 for target in targets {
942 if let Ok(entry) = self.status_for(target).await
943 && entry.status.is_terminal()
944 {
945 return Ok(Some(entry));
946 }
947 }
948
949 let sleep = tokio::time::sleep_until(deadline);
950 tokio::pin!(sleep);
951 let wait_any = select_all(
952 notifies
953 .into_iter()
954 .map(|notify| Box::pin(async move { notify.notified().await }))
955 .collect::<Vec<_>>(),
956 );
957 tokio::pin!(wait_any);
958
959 tokio::select! {
960 _ = &mut sleep => return Ok(None),
961 _ = &mut wait_any => {}
962 }
963 }
964 }
965
966 pub async fn status_for(&self, target: &str) -> Result<SubagentStatusEntry> {
967 let state = self.state.read().await;
968 let record = state
969 .children
970 .get(target)
971 .ok_or_else(|| anyhow!("Unknown subagent id {}", target))?;
972 Ok(record.build_status_entry())
973 }
974
975 async fn spawn_child_ids_for_parent(&self, parent_thread_id: &str) -> Vec<String> {
976 let state = self.state.read().await;
977 let mut child_ids = state
978 .children
979 .values()
980 .filter(|record| record.parent_thread_id == parent_thread_id)
981 .map(|record| record.id.clone())
982 .collect::<Vec<_>>();
983 child_ids.sort();
984 child_ids
985 }
986
987 async fn collect_spawn_subtree_ids(&self, root_thread_id: &str) -> Result<Vec<String>> {
988 let mut subtree_ids = Vec::new();
989 let mut stack = vec![root_thread_id.to_string()];
990
991 while let Some(thread_id) = stack.pop() {
992 subtree_ids.push(thread_id.clone());
993 let child_ids = self.spawn_child_ids_for_parent(&thread_id).await;
994 for child_id in child_ids.into_iter().rev() {
995 stack.push(child_id);
996 }
997 }
998
999 Ok(subtree_ids)
1000 }
1001
1002 async fn reopen_single(&self, target: &str) -> Result<bool> {
1003 let mut state = self.state.write().await;
1004 let record = state
1005 .children
1006 .get_mut(target)
1007 .ok_or_else(|| anyhow!("Unknown subagent id {}", target))?;
1008 if matches!(
1009 record.status,
1010 SubagentStatus::Running | SubagentStatus::Queued
1011 ) {
1012 return Ok(false);
1013 }
1014 let prompt = record.last_prompt.clone().unwrap_or_else(|| {
1015 "Continue the delegated task from the existing context.".to_string()
1016 });
1017 record.status = SubagentStatus::Queued;
1018 record.updated_at = Utc::now();
1019 record.completed_at = None;
1020 record.error = None;
1021 record.summary = None;
1022 record.queued_prompts.push_back(prompt);
1023 Ok(true)
1024 }
1025
1026 async fn close_single(&self, target: &str) -> Result<SubagentStatusEntry> {
1027 let mut state = self.state.write().await;
1028 let record = state
1029 .children
1030 .get_mut(target)
1031 .ok_or_else(|| anyhow!("Unknown subagent id {}", target))?;
1032 if record.status == SubagentStatus::Closed {
1033 return Ok(record.build_status_entry());
1034 }
1035 if let Some(handle) = record.handle.take() {
1036 handle.abort();
1037 }
1038 record.status = SubagentStatus::Closed;
1039 record.updated_at = Utc::now();
1040 record.completed_at = Some(Utc::now());
1041 record.notify.notify_waiters();
1042 Ok(record.build_status_entry())
1043 }
1044
1045 async fn background_status_for(&self, target: &str) -> Result<BackgroundSubprocessEntry> {
1046 let state = self.state.read().await;
1047 let record = state
1048 .background_children
1049 .get(target)
1050 .ok_or_else(|| anyhow!("Unknown background subprocess {}", target))?;
1051 Ok(record.build_status_entry())
1052 }
1053
1054 async fn ensure_background_record_running(
1055 &self,
1056 agent_name: &str,
1057 stable_id: Option<&str>,
1058 restart_attempts: u8,
1059 overrides: Option<BackgroundLaunchOverrides>,
1060 ) -> Result<BackgroundSubprocessEntry> {
1061 let spec = self
1062 .resolve_requested_spec(Some(agent_name))
1063 .await
1064 .with_context(|| format!("Failed to resolve background subagent '{agent_name}'"))?;
1065 let record_id = stable_id
1066 .map(ToOwned::to_owned)
1067 .unwrap_or_else(|| background_record_id(agent_name));
1068 let previous_record = {
1069 let state = self.state.read().await;
1070 state.background_children.get(&record_id).map(|record| {
1071 (
1072 record.created_at,
1073 record.prompt.clone(),
1074 record.max_turns,
1075 record.model_override.clone(),
1076 record.reasoning_override.clone(),
1077 )
1078 })
1079 };
1080 let parent_session_id = self.parent_session_id.read().await.clone();
1081 let session_id = format!(
1082 "{}-{}-{}",
1083 sanitize_component(parent_session_id.as_str()),
1084 sanitize_component(record_id.as_str()),
1085 Utc::now().format("%Y%m%dT%H%M%S%3fZ")
1086 );
1087 let exec_session_id = format!("exec-{session_id}");
1088 let (
1089 created_at,
1090 previous_prompt,
1091 previous_max_turns,
1092 previous_model_override,
1093 previous_reasoning_override,
1094 ) = previous_record.unwrap_or((Utc::now(), String::new(), None, None, None));
1095 let prompt = overrides
1096 .as_ref()
1097 .and_then(|overrides| overrides.prompt.clone())
1098 .filter(|value| !value.trim().is_empty())
1099 .or_else(|| (!previous_prompt.trim().is_empty()).then_some(previous_prompt))
1100 .or_else(|| spec.initial_prompt.clone())
1101 .filter(|value| !value.trim().is_empty())
1102 .unwrap_or_else(|| {
1103 format!(
1104 "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.",
1105 spec.name
1106 )
1107 });
1108 let max_turns = normalize_background_child_max_turns(
1109 overrides
1110 .as_ref()
1111 .and_then(|overrides| overrides.max_turns)
1112 .or(previous_max_turns)
1113 .or(spec.max_turns),
1114 true,
1115 );
1116 let model_override = overrides
1117 .as_ref()
1118 .and_then(|overrides| overrides.model_override.clone())
1119 .or(previous_model_override)
1120 .or_else(|| spec.model.clone());
1121 let reasoning_override = overrides
1122 .as_ref()
1123 .and_then(|overrides| overrides.reasoning_override.clone())
1124 .or(previous_reasoning_override)
1125 .or_else(|| spec.reasoning_effort.clone());
1126
1127 {
1128 let mut state = self.state.write().await;
1129 state.background_children.insert(
1130 record_id.clone(),
1131 BackgroundRecord {
1132 id: record_id.clone(),
1133 agent_name: spec.name.clone(),
1134 display_label: subagent_display_label(&spec),
1135 description: spec.description.clone(),
1136 source: spec.source.label(),
1137 color: spec.color.clone(),
1138 session_id: session_id.clone(),
1139 exec_session_id: exec_session_id.clone(),
1140 desired_enabled: true,
1141 status: BackgroundSubprocessStatus::Starting,
1142 created_at,
1143 updated_at: Utc::now(),
1144 started_at: None,
1145 ended_at: None,
1146 pid: None,
1147 prompt: prompt.clone(),
1148 summary: Some("Starting background subagent".to_string()),
1149 error: None,
1150 archive_path: None,
1151 transcript_path: None,
1152 max_turns,
1153 model_override: model_override.clone(),
1154 reasoning_override: reasoning_override.clone(),
1155 restart_attempts,
1156 },
1157 );
1158 }
1159
1160 let launch = build_background_launch_spec(
1161 &self.config.workspace_root,
1162 spec.name.as_str(),
1163 parent_session_id.as_str(),
1164 session_id.as_str(),
1165 prompt.as_str(),
1166 max_turns,
1167 model_override.as_deref(),
1168 reasoning_override.as_deref(),
1169 )?;
1170 let metadata = if launch.use_pty {
1171 self.config
1172 .exec_sessions
1173 .create_pty_session(
1174 exec_session_id.clone().into(),
1175 launch.command,
1176 self.config.workspace_root.clone(),
1177 PtySize {
1178 rows: 24,
1179 cols: 80,
1180 pixel_width: 0,
1181 pixel_height: 0,
1182 },
1183 hashbrown::HashMap::new(),
1184 None,
1185 )
1186 .await
1187 } else {
1188 self.config
1189 .exec_sessions
1190 .create_pipe_session(
1191 exec_session_id.clone().into(),
1192 launch.command,
1193 self.config.workspace_root.clone(),
1194 hashbrown::HashMap::new(),
1195 )
1196 .await
1197 }
1198 .with_context(|| {
1199 format!(
1200 "Failed to spawn background subprocess for subagent '{}'",
1201 spec.name
1202 )
1203 })?;
1204
1205 tracing::info!(
1206 agent_name = spec.name.as_str(),
1207 record_id = record_id.as_str(),
1208 exec_session_id = exec_session_id.as_str(),
1209 pid = metadata.child_pid,
1210 "Spawned background subagent subprocess"
1211 );
1212
1213 {
1214 let mut state = self.state.write().await;
1215 let record = state
1216 .background_children
1217 .get_mut(&record_id)
1218 .ok_or_else(|| anyhow!("Unknown background subprocess {}", record_id))?;
1219 record.exec_session_id = exec_session_id;
1220 record.pid = metadata.child_pid;
1221 record.started_at = metadata.started_at;
1222 record.status = BackgroundSubprocessStatus::Running;
1223 record.updated_at = Utc::now();
1224 record.ended_at = None;
1225 record.error = None;
1226 record.summary = Some("Background subagent is running".to_string());
1227 }
1228
1229 self.save_background_state().await?;
1230 self.background_status_for(&record_id).await
1231 }
1232
1233 async fn refresh_background_archive_metadata(&self, target: &str) -> Result<()> {
1234 let session_id = {
1235 let state = self.state.read().await;
1236 state
1237 .background_children
1238 .get(target)
1239 .map(|record| record.session_id.clone())
1240 .ok_or_else(|| anyhow!("Unknown background subprocess {}", target))?
1241 };
1242
1243 if let Some(listing) = find_session_by_identifier(&session_id).await? {
1244 let mut state = self.state.write().await;
1245 if let Some(record) = state.background_children.get_mut(target) {
1246 record.archive_path = Some(listing.path.clone());
1247 record.transcript_path = Some(listing.path);
1248 }
1249 }
1250
1251 Ok(())
1252 }
1253
1254 async fn save_background_state(&self) -> Result<()> {
1255 let records = {
1256 let state = self.state.read().await;
1257 state
1258 .background_children
1259 .values()
1260 .cloned()
1261 .map(BackgroundRecord::into_persisted)
1262 .collect()
1263 };
1264 persist_background_state(&self.config.workspace_root, records)
1265 }
1266
1267 async fn find_spec(&self, candidate: &str) -> Option<SubagentSpec> {
1268 self.state
1269 .read()
1270 .await
1271 .discovered
1272 .effective
1273 .iter()
1274 .find(|spec| spec.is_subagent() && spec.matches_name(candidate))
1275 .cloned()
1276 }
1277
1278 async fn resolve_requested_spec(&self, requested: Option<&str>) -> Result<SubagentSpec> {
1279 let requested = requested.unwrap_or("default");
1280 self.find_spec(requested)
1281 .await
1282 .ok_or_else(|| anyhow!("Unknown subagent type {}", requested))
1283 }
1284
1285 async fn prepare_delegation_context(
1286 &self,
1287 requested_agent: Option<String>,
1288 items: &mut Vec<SubagentInputItem>,
1289 model: &mut Option<String>,
1290 tool_name: &'static str,
1291 ) -> Result<PreparedDelegationContext> {
1292 let state = self.state.read().await;
1293 sanitize_subagent_input_items(items);
1294 *model = normalize_requested_model_override(model.take(), &state.turn_hints.current_input);
1295 let requested_agent = if let Some(agent_type) = requested_agent {
1296 Some(agent_type)
1297 } else {
1298 match state.turn_hints.explicit_mentions.as_slice() {
1299 [] => None,
1300 [single] => Some(single.clone()),
1301 mentions => {
1302 bail!(
1303 "{} omitted agent_type, but the user explicitly selected multiple agents: {}. Specify agent_type explicitly.",
1304 tool_name,
1305 mentions.join(", ")
1306 );
1307 }
1308 }
1309 };
1310 Ok(PreparedDelegationContext {
1311 requested_agent,
1312 explicit_mentions: state.turn_hints.explicit_mentions.clone(),
1313 explicit_request: state.turn_hints.explicit_request,
1314 current_input: state.turn_hints.current_input.clone(),
1315 })
1316 }
1317
1318 fn prepare_delegation_prompt(
1319 &self,
1320 spec: &SubagentSpec,
1321 delegation: &PreparedDelegationContext,
1322 model: &mut Option<String>,
1323 message: &Option<String>,
1324 items: &[SubagentInputItem],
1325 tool_name: &'static str,
1326 launch_phrase: &'static str,
1327 ignored_model_warning: &'static str,
1328 ) -> Result<String> {
1329 if let Some(explicit) = delegation.explicit_mentions.first()
1330 && delegation.explicit_mentions.len() == 1
1331 && !spec.matches_name(explicit)
1332 {
1333 bail!(
1334 "{} requested agent_type '{}', but the user explicitly selected '{}'. Use the selected agent or ask the user to clarify.",
1335 tool_name,
1336 spec.name,
1337 explicit
1338 );
1339 }
1340 if !spec.is_read_only()
1341 && !delegation.explicit_request
1342 && delegation.requested_agent.is_none()
1343 {
1344 bail!(
1345 "{} 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.",
1346 tool_name,
1347 spec.name
1348 );
1349 }
1350 if spec.is_read_only()
1351 && !self.config.vt_cfg.subagents.auto_delegate_read_only
1352 && !delegation.explicit_request
1353 {
1354 bail!(
1355 "{} cannot proactively launch read-only agent '{}' because `subagents.auto_delegate_read_only` is disabled and the current user turn did not explicitly request delegation.",
1356 tool_name,
1357 spec.name
1358 );
1359 }
1360 if let Some(requested_model) = model.as_deref()
1361 && !contains_explicit_model_request(&delegation.current_input, requested_model)
1362 {
1363 tracing::warn!(
1364 agent_name = spec.name.as_str(),
1365 requested_model = requested_model.trim(),
1366 "{ignored_model_warning}"
1367 );
1368 *model = None;
1369 }
1370 let prompt = request_prompt(message, items)
1371 .or_else(|| spec.initial_prompt.clone())
1372 .filter(|value| !value.trim().is_empty())
1373 .ok_or_else(|| anyhow!("{tool_name} requires a task message or items"))?;
1374 if delegated_task_requires_clarification(&prompt) {
1375 bail!(
1376 "{} task for '{}' is too vague ('{}'). Ask the user for a specific delegated task before {}.",
1377 tool_name,
1378 spec.name,
1379 prompt.trim(),
1380 launch_phrase
1381 );
1382 }
1383 Ok(prompt)
1384 }
1385
1386 fn active_background_launch_conflicts(
1387 record: &BackgroundRecord,
1388 prompt: &str,
1389 max_turns: Option<usize>,
1390 model_override: Option<&str>,
1391 reasoning_override: Option<&str>,
1392 ) -> Vec<&'static str> {
1393 let mut conflicts = Vec::new();
1394 if record.prompt != prompt {
1395 conflicts.push("prompt");
1396 }
1397 if record.max_turns != max_turns {
1398 conflicts.push("max_turns");
1399 }
1400 if record.model_override.as_deref() != model_override {
1401 conflicts.push("model");
1402 }
1403 if record.reasoning_override.as_deref() != reasoning_override {
1404 conflicts.push("reasoning_effort");
1405 }
1406 conflicts
1407 }
1408
1409 async fn spawn_with_spec(
1410 &self,
1411 spec: SubagentSpec,
1412 prompt: String,
1413 fork_context: bool,
1414 background: bool,
1415 max_turns: Option<usize>,
1416 model_override: Option<String>,
1417 reasoning_override: Option<String>,
1418 ) -> Result<SubagentStatusEntry> {
1419 if !self.config.vt_cfg.subagents.enabled {
1420 bail!("Subagents are disabled by configuration");
1421 }
1422 if self.config.depth.saturating_add(1) > self.config.vt_cfg.subagents.max_depth {
1423 bail!(
1424 "Subagent depth limit reached (max_depth={})",
1425 self.config.vt_cfg.subagents.max_depth
1426 );
1427 }
1428 if spec.isolation.as_deref() == Some("worktree") {
1429 bail!("Subagent isolation=worktree is not supported in this VT Code build");
1430 }
1431
1432 let active_count = {
1433 let state = self.state.read().await;
1434 state
1435 .children
1436 .values()
1437 .filter(|record| {
1438 matches!(
1439 record.status,
1440 SubagentStatus::Queued | SubagentStatus::Running | SubagentStatus::Waiting
1441 )
1442 })
1443 .count()
1444 };
1445 let effective_max_concurrent = self
1446 .config
1447 .vt_cfg
1448 .subagents
1449 .max_concurrent
1450 .min(SUBAGENT_HARD_CONCURRENCY_LIMIT);
1451 if active_count >= effective_max_concurrent {
1452 bail!(
1453 "Subagent concurrency limit reached (max_concurrent={})",
1454 effective_max_concurrent
1455 );
1456 }
1457 let is_background_child = background;
1458 let child_max_turns =
1459 normalize_background_child_max_turns(max_turns.or(spec.max_turns), is_background_child);
1460 let (_, _, effective_config) = prepare_child_runtime_config(
1461 &self.config.vt_cfg,
1462 &spec,
1463 self.config.parent_model.as_str(),
1464 self.config.parent_provider.as_str(),
1465 self.config.parent_reasoning_effort,
1466 child_max_turns,
1467 model_override.as_deref(),
1468 reasoning_override.as_deref(),
1469 resolve_effective_subagent_model,
1470 )?;
1471
1472 let id = format!(
1473 "agent-{}-{}",
1474 sanitize_component(spec.name.as_str()),
1475 Utc::now().format("%Y%m%dT%H%M%S%3fZ")
1476 );
1477 let parent_session_id = self.parent_session_id.read().await.clone();
1478 let session_id = format!(
1479 "{}-{}",
1480 sanitize_component(parent_session_id.as_str()),
1481 sanitize_component(id.as_str())
1482 );
1483 let display_label = subagent_display_label(&spec);
1484 let notify = Arc::new(Notify::new());
1485 let mut state = self.state.write().await;
1486 let initial_messages = if fork_context {
1487 state.parent_messages.clone()
1488 } else {
1489 Vec::new()
1490 };
1491 let entry = ChildRecord {
1492 id: id.clone(),
1493 session_id,
1494 parent_thread_id: parent_session_id,
1495 spec: spec.clone(),
1496 display_label,
1497 status: SubagentStatus::Queued,
1498 background: is_background_child,
1499 depth: self.config.depth.saturating_add(1),
1500 created_at: Utc::now(),
1501 updated_at: Utc::now(),
1502 completed_at: None,
1503 summary: None,
1504 error: None,
1505 archive_metadata: None,
1506 archive_path: None,
1507 transcript_path: None,
1508 effective_config: Some(effective_config),
1509 stored_messages: initial_messages,
1510 last_prompt: Some(prompt.clone()),
1511 queued_prompts: VecDeque::from([prompt]),
1512 max_turns: child_max_turns,
1513 model_override,
1514 reasoning_override,
1515 thread_handle: None,
1516 handle: None,
1517 notify,
1518 };
1519 state.children.insert(id.clone(), entry);
1520 drop(state);
1521
1522 self.launch_child(id.as_str()).await?;
1523 self.status_for(&id).await
1524 }
1525
1526 async fn restart_child(&self, target: &str) -> Result<()> {
1527 let has_queued_input = {
1528 let mut state = self.state.write().await;
1529 let record = state
1530 .children
1531 .get_mut(target)
1532 .ok_or_else(|| anyhow!("Unknown subagent id {}", target))?;
1533 if record.queued_prompts.is_empty()
1534 && let Some(prompt) = record.last_prompt.clone()
1535 {
1536 record.queued_prompts.push_back(prompt);
1537 }
1538 !record.queued_prompts.is_empty()
1539 };
1540 if !has_queued_input {
1541 bail!("Subagent {} has no queued input", target);
1542 }
1543 self.launch_child(target).await
1544 }
1545
1546 async fn launch_child(&self, child_id: &str) -> Result<()> {
1547 let controller = self.clone();
1548 let target = child_id.to_string();
1549 let handle = tokio::spawn(async move {
1550 controller.child_loop(&target).await;
1551 });
1552 let mut state = self.state.write().await;
1553 let record = state
1554 .children
1555 .get_mut(child_id)
1556 .ok_or_else(|| anyhow!("Unknown subagent id {}", child_id))?;
1557 record.handle = Some(handle);
1558 record.status = SubagentStatus::Queued;
1559 record.updated_at = Utc::now();
1560 Ok(())
1561 }
1562
1563 async fn child_loop(&self, child_id: &str) {
1564 loop {
1565 let request = {
1566 let mut state = self.state.write().await;
1567 let Some(record) = state.children.get_mut(child_id) else {
1568 return;
1569 };
1570 record.dequeue_run()
1571 };
1572 let Some(request) = request else {
1573 let mut state = self.state.write().await;
1574 if let Some(record) = state.children.get_mut(child_id) {
1575 record.handle = None;
1576 record.updated_at = Utc::now();
1577 }
1578 return;
1579 };
1580
1581 let execute = self
1582 .run_child_once(
1583 child_id,
1584 request.prompt,
1585 request.max_turns,
1586 request.model_override,
1587 request.reasoning_override,
1588 )
1589 .await;
1590
1591 let (has_more_work, hook_payload) = {
1592 let mut state = self.state.write().await;
1593 let Some(record) = state.children.get_mut(child_id) else {
1594 return;
1595 };
1596 record.updated_at = Utc::now();
1597 let has_more_work = record.apply_result(execute);
1598 let hook_payload = (!has_more_work).then(|| record.build_hook_payload());
1599 (has_more_work, hook_payload)
1600 };
1601
1602 if let Some((
1603 parent_session_id,
1604 child_thread_id,
1605 agent_name,
1606 display_label,
1607 background,
1608 status,
1609 transcript_path,
1610 )) = hook_payload
1611 && let Some(hooks) = self.lifecycle_hooks.as_ref()
1612 && let Err(err) = hooks
1613 .run_subagent_stop(
1614 &parent_session_id,
1615 &child_thread_id,
1616 &agent_name,
1617 &display_label,
1618 background,
1619 &status,
1620 transcript_path.as_deref(),
1621 )
1622 .await
1623 {
1624 tracing::warn!(
1625 child_id,
1626 error = %err,
1627 "Failed to run subagent stop hooks"
1628 );
1629 }
1630
1631 if has_more_work {
1632 continue;
1633 } else {
1634 let mut state = self.state.write().await;
1635 if let Some(record) = state.children.get_mut(child_id) {
1636 record.handle = None;
1637 record.updated_at = Utc::now();
1638 }
1639 return;
1640 }
1641 }
1642 }
1643
1644 async fn run_child_once(
1645 &self,
1646 child_id: &str,
1647 prompt: String,
1648 max_turns: Option<usize>,
1649 model_override: Option<String>,
1650 reasoning_override: Option<String>,
1651 ) -> Result<ChildRunResult> {
1652 let (spec, session_id, bootstrap_messages, display_label, background) = {
1653 let mut state = self.state.write().await;
1654 let record = state
1655 .children
1656 .get_mut(child_id)
1657 .ok_or_else(|| anyhow!("Unknown subagent id {}", child_id))?;
1658 record.status = SubagentStatus::Running;
1659 record.updated_at = Utc::now();
1660 (
1661 record.spec.clone(),
1662 record.session_id.clone(),
1663 record.stored_messages.clone(),
1664 record.display_label.clone(),
1665 record.background,
1666 )
1667 };
1668
1669 let (resolved_model, child_reasoning_effort, child_cfg) = prepare_child_runtime_config(
1670 &self.config.vt_cfg,
1671 &spec,
1672 self.config.parent_model.as_str(),
1673 self.config.parent_provider.as_str(),
1674 self.config.parent_reasoning_effort,
1675 max_turns,
1676 model_override.as_deref(),
1677 reasoning_override.as_deref(),
1678 resolve_effective_subagent_model,
1679 )?;
1680 let parent_session_id = self.parent_session_id.read().await.clone();
1681
1682 let archive_metadata = build_subagent_archive_metadata(
1683 &self.config.workspace_root,
1684 child_cfg.agent.default_model.as_str(),
1685 child_cfg.agent.provider.as_str(),
1686 child_cfg.agent.theme.as_str(),
1687 child_reasoning_effort.as_str(),
1688 parent_session_id.as_str(),
1689 !bootstrap_messages.is_empty(),
1690 );
1691 let bootstrap = ThreadBootstrap::new(Some(archive_metadata.clone()))
1692 .with_messages(bootstrap_messages.clone());
1693 let archive = if let Some(listing) = find_session_by_identifier(&session_id).await? {
1694 SessionArchive::resume_from_listing(&listing, archive_metadata.clone())
1695 } else {
1696 SessionArchive::new_with_identifier(archive_metadata.clone(), session_id.clone())
1697 .await?
1698 };
1699 checkpoint_subagent_archive_start(&archive, &bootstrap_messages).await?;
1700 let mut runner = AgentRunner::new_with_bootstrap(
1701 agent_type_for_spec(&spec),
1702 resolved_model,
1703 self.config.api_key.clone(),
1704 self.config.workspace_root.clone(),
1705 session_id.clone(),
1706 RunnerSettings {
1707 reasoning_effort: Some(child_reasoning_effort),
1708 verbosity: None,
1709 },
1710 None,
1711 bootstrap,
1712 Some(child_cfg.clone()),
1713 self.config.openai_chatgpt_auth.clone(),
1714 )
1715 .await?;
1716 runner.set_quiet(true);
1717 let thread_handle = runner.thread_handle();
1718 let archive_path = archive.path().to_path_buf();
1719
1720 {
1721 let mut state = self.state.write().await;
1722 let record = state
1723 .children
1724 .get_mut(child_id)
1725 .ok_or_else(|| anyhow!("Unknown subagent id {}", child_id))?;
1726 record.archive_metadata = Some(archive_metadata.clone());
1727 record.archive_path = Some(archive_path.clone());
1728 record.effective_config = Some(child_cfg.clone());
1729 record.thread_handle = Some(thread_handle.clone());
1730 }
1731 if let Some(hooks) = self.lifecycle_hooks.as_ref()
1732 && let Err(err) = hooks
1733 .run_subagent_start(
1734 parent_session_id.as_str(),
1735 thread_handle.thread_id().as_str(),
1736 spec.name.as_str(),
1737 &display_label,
1738 background,
1739 SubagentStatus::Running.as_str(),
1740 Some(archive_path.as_path()),
1741 )
1742 .await
1743 {
1744 tracing::warn!(
1745 child_id,
1746 error = %err,
1747 "Failed to run subagent start hooks"
1748 );
1749 }
1750
1751 let filtered_tools = filter_child_tools(
1752 &spec,
1753 runner.build_universal_tools().await?,
1754 spec.is_read_only(),
1755 );
1756 let allowed_tools = filtered_tools
1757 .iter()
1758 .map(|tool| tool.function_name().to_string())
1759 .collect::<Vec<_>>();
1760 runner.set_tool_definitions_override(filtered_tools);
1761 runner.enable_full_auto(&allowed_tools).await;
1762
1763 let memory_appendix =
1764 load_memory_appendix(&self.config.workspace_root, spec.name.as_str(), spec.memory)?;
1765 let mut task = Task::new(
1766 format!("subagent-{}", spec.name),
1767 format!("Subagent {}", spec.name),
1768 prompt,
1769 );
1770 task.instructions = Some(compose_subagent_instructions(&spec, memory_appendix));
1771
1772 let results = runner.execute_task(&task, &[]).await?;
1773 let messages = runner.session_messages();
1774 let transcript_path =
1775 persist_child_archive(&archive, &messages, spec.name.as_str()).await?;
1776
1777 Ok(ChildRunResult {
1778 messages,
1779 summary: if results.summary.trim().is_empty() {
1780 results.outcome.description()
1781 } else {
1782 results.summary.clone()
1783 },
1784 outcome: results.outcome,
1785 transcript_path,
1786 })
1787 }
1788}
1789
1790fn sanitize_component(value: &str) -> String {
1791 value
1792 .chars()
1793 .map(|ch| {
1794 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
1795 ch
1796 } else {
1797 '-'
1798 }
1799 })
1800 .collect::<String>()
1801 .trim_matches('-')
1802 .to_string()
1803}
1804
1805fn load_session_listing(
1806 path: &std::path::Path,
1807) -> Result<crate::utils::session_archive::SessionListing> {
1808 use anyhow::Context;
1809 let raw = std::fs::read_to_string(path)
1810 .with_context(|| format!("Failed to read session archive {}", path.display()))?;
1811 let snapshot: crate::utils::session_archive::SessionSnapshot = serde_json::from_str(&raw)
1812 .with_context(|| format!("Failed to parse session archive {}", path.display()))?;
1813 Ok(crate::utils::session_archive::SessionListing {
1814 path: path.to_path_buf(),
1815 snapshot,
1816 })
1817}
1818
1819async fn checkpoint_subagent_archive_start(
1820 archive: &SessionArchive,
1821 messages: &[Message],
1822) -> Result<()> {
1823 use crate::utils::session_archive::SessionMessage;
1824 let recent_messages = messages
1825 .iter()
1826 .map(SessionMessage::from)
1827 .collect::<Vec<_>>();
1828 archive
1829 .persist_progress_async(crate::utils::session_archive::SessionProgressArgs {
1830 total_messages: recent_messages.len(),
1831 distinct_tools: Vec::new(),
1832 recent_messages,
1833 turn_number: 1,
1834 token_usage: None,
1835 max_context_tokens: None,
1836 loaded_skills: Some(Vec::new()),
1837 })
1838 .await?;
1839 Ok(())
1840}
1841
1842async fn persist_child_archive(
1843 archive: &SessionArchive,
1844 messages: &[Message],
1845 agent_name: &str,
1846) -> Result<Option<PathBuf>> {
1847 use crate::utils::session_archive::SessionMessage;
1848 let transcript = messages
1849 .iter()
1850 .filter_map(transcript_line_from_message)
1851 .take(SUBAGENT_TRANSCRIPT_LINE_LIMIT)
1852 .collect::<Vec<_>>();
1853 let stored_messages = messages
1854 .iter()
1855 .map(SessionMessage::from)
1856 .collect::<Vec<_>>();
1857 let path = archive.finalize(
1858 transcript,
1859 stored_messages.len(),
1860 vec![agent_name.to_string()],
1861 stored_messages,
1862 )?;
1863 Ok(Some(path))
1864}
1865
1866fn transcript_line_from_message(message: &Message) -> Option<String> {
1867 let role = message.role.to_string();
1868 let content = message.content.trim();
1869 if content.is_empty() {
1870 return None;
1871 }
1872 Some(format!("{role}: {content}"))
1873}
1874
1875#[cfg(test)]
1878mod tests {
1879 use super::*;
1880 use crate::config::PermissionMode;
1881 use crate::config::constants::models;
1882 use crate::config::constants::tools;
1883 use crate::config::models::{ModelId, Provider};
1884 use crate::llm::provider::ToolDefinition;
1885 use crate::tools::exec_session::ExecSessionManager;
1886 use crate::tools::registry::PtySessionManager;
1887 use anyhow::{Result, anyhow};
1888 use std::collections::BTreeMap;
1889 use std::collections::VecDeque;
1890 use std::path::PathBuf;
1891 use std::sync::Arc;
1892 use std::time::Duration;
1893 use tempfile::TempDir;
1894 use tokio::sync::Notify;
1895 use vtcode_config::{SubagentMcpServer, SubagentMemoryScope, SubagentSource, SubagentSpec};
1896
1897 fn test_controller_config(
1898 workspace_root: PathBuf,
1899 vt_cfg: VTCodeConfig,
1900 ) -> SubagentControllerConfig {
1901 let pty_sessions = PtySessionManager::new(workspace_root.clone(), vt_cfg.pty.clone());
1902 let exec_sessions = ExecSessionManager::new(workspace_root.clone(), pty_sessions.clone());
1903 SubagentControllerConfig {
1904 workspace_root,
1905 parent_session_id: "parent-session".to_string(),
1906 parent_model: models::openai::GPT_5_4.to_string(),
1907 parent_provider: "openai".to_string(),
1908 parent_reasoning_effort: ReasoningEffortLevel::Medium,
1909 api_key: "test-key".to_string(),
1910 vt_cfg,
1911 openai_chatgpt_auth: None,
1912 depth: 0,
1913 exec_sessions,
1914 pty_manager: pty_sessions.manager().clone(),
1915 managed_background_runtime: false,
1916 }
1917 }
1918
1919 fn test_child_record(
1920 id: &str,
1921 parent_thread_id: &str,
1922 spec: &SubagentSpec,
1923 status: SubagentStatus,
1924 depth: usize,
1925 ) -> ChildRecord {
1926 ChildRecord {
1927 id: id.to_string(),
1928 session_id: format!("session-{id}"),
1929 parent_thread_id: parent_thread_id.to_string(),
1930 spec: spec.clone(),
1931 display_label: subagent_display_label(spec),
1932 status,
1933 background: false,
1934 depth,
1935 created_at: Utc::now(),
1936 updated_at: Utc::now(),
1937 completed_at: status.is_terminal().then_some(Utc::now()),
1938 summary: None,
1939 error: None,
1940 archive_metadata: None,
1941 archive_path: None,
1942 transcript_path: None,
1943 effective_config: Some(VTCodeConfig::default()),
1944 stored_messages: Vec::new(),
1945 last_prompt: Some(format!("prompt-{id}")),
1946 queued_prompts: VecDeque::new(),
1947 max_turns: None,
1948 model_override: None,
1949 reasoning_override: None,
1950 thread_handle: None,
1951 handle: None,
1952 notify: Arc::new(Notify::new()),
1953 }
1954 }
1955
1956 fn write_test_background_subagent(workspace_root: &std::path::Path) {
1957 let agent_dir = workspace_root.join(".vtcode/agents");
1958 std::fs::create_dir_all(&agent_dir).expect("agent dir");
1959 std::fs::write(
1960 agent_dir.join("background-demo.md"),
1961 r#"---
1962name: background-demo
1963description: Minimal demo agent for the managed background subprocess flow.
1964tools:
1965 - unified_exec
1966background: true
1967maxTurns: 2
1968initialPrompt: Report readiness once.
1969---
1970
1971Run the managed background demo.
1972"#,
1973 )
1974 .expect("write background agent");
1975 }
1976
1977 fn write_test_primary_agent(workspace_root: &std::path::Path) {
1978 let agent_dir = workspace_root.join(".vtcode/agents");
1979 std::fs::create_dir_all(&agent_dir).expect("agent dir");
1980 std::fs::write(
1981 agent_dir.join("duck.md"),
1982 r#"---
1983name: duck
1984description: Discussion controller.
1985mode: primary
1986permissionMode: plan
1987---
1988
1989Discuss before implementation.
1990"#,
1991 )
1992 .expect("write primary agent");
1993 }
1994
1995 fn write_test_read_only_subagent(workspace_root: &std::path::Path) {
1996 let agent_dir = workspace_root.join(".vtcode/agents");
1997 std::fs::create_dir_all(&agent_dir).expect("agent dir");
1998 std::fs::write(
1999 agent_dir.join("readonly-demo.md"),
2000 r#"---
2001name: readonly-demo
2002description: Read-only test child agent.
2003tools:
2004 - read_file
2005permissionMode: plan
2006---
2007
2008Inspect the repository.
2009"#,
2010 )
2011 .expect("write read-only agent");
2012 }
2013
2014 #[test]
2015 fn request_prompt_prefers_message() {
2016 let request = SpawnAgentRequest {
2017 message: Some("hello".to_string()),
2018 ..SpawnAgentRequest::default()
2019 };
2020 assert_eq!(
2021 request_prompt(&request.message, &request.items).as_deref(),
2022 Some("hello")
2023 );
2024 }
2025
2026 #[test]
2027 fn delegated_task_requires_clarification_for_vague_prompt() {
2028 assert!(delegated_task_requires_clarification("report"));
2029 assert!(delegated_task_requires_clarification("report findings"));
2030 assert!(!delegated_task_requires_clarification(
2031 "review current code changes"
2032 ));
2033 }
2034
2035 #[test]
2036 fn resolve_subagent_model_maps_aliases() {
2037 let cfg = VTCodeConfig::default();
2038 let resolved = resolve_subagent_model(
2039 &cfg,
2040 models::anthropic::CLAUDE_SONNET_4_6,
2041 "anthropic",
2042 Some("haiku"),
2043 "explorer",
2044 )
2045 .expect("resolve model");
2046 assert_eq!(resolved.as_str(), models::anthropic::CLAUDE_HAIKU_4_5);
2047 }
2048
2049 #[test]
2050 fn resolve_subagent_model_defaults_to_parent_when_omitted() {
2051 let cfg = VTCodeConfig::default();
2052 let resolved = resolve_subagent_model(
2053 &cfg,
2054 models::ollama::GPT_OSS_120B_CLOUD,
2055 "ollama",
2056 None,
2057 "worker",
2058 )
2059 .expect("resolve model");
2060 assert_eq!(resolved.as_str(), models::ollama::GPT_OSS_120B_CLOUD);
2061 }
2062
2063 #[test]
2064 fn resolve_subagent_model_accepts_dotted_claude_aliases_for_anthropic() {
2065 let cfg = VTCodeConfig::default();
2066 let resolved =
2067 resolve_subagent_model(&cfg, "claude-haiku-4.5", "anthropic", None, "worker")
2068 .expect("resolve model");
2069 assert_eq!(resolved.as_str(), models::anthropic::CLAUDE_HAIKU_4_5);
2070 }
2071
2072 #[test]
2073 fn resolve_subagent_model_falls_back_to_copilot_default_for_unsupported_inherit_model() {
2074 let cfg = VTCodeConfig::default();
2075 let resolved = resolve_subagent_model(&cfg, "claude-haiku-4.5", "copilot", None, "worker")
2076 .expect("resolve model");
2077 assert_eq!(
2078 resolved,
2079 ModelId::default_orchestrator_for_provider(Provider::Copilot)
2080 );
2081 }
2082
2083 #[test]
2084 fn resolve_effective_subagent_model_uses_explicit_inherit_override() {
2085 let cfg = VTCodeConfig::default();
2086 let resolved = resolve_effective_subagent_model(
2087 &cfg,
2088 models::anthropic::CLAUDE_SONNET_4_6,
2089 "anthropic",
2090 Some("inherit"),
2091 Some("haiku"),
2092 "worker",
2093 )
2094 .expect("resolve model");
2095 assert_eq!(resolved.as_str(), models::anthropic::CLAUDE_SONNET_4_6);
2096 }
2097
2098 #[test]
2099 fn resolve_effective_subagent_model_falls_back_to_parent_on_invalid_override() {
2100 let cfg = VTCodeConfig::default();
2101 let resolved = resolve_effective_subagent_model(
2102 &cfg,
2103 models::ollama::GPT_OSS_120B_CLOUD,
2104 "ollama",
2105 Some("not-a-real-model"),
2106 None,
2107 "rust-engineer",
2108 )
2109 .expect("resolve model");
2110 assert_eq!(resolved.as_str(), models::ollama::GPT_OSS_120B_CLOUD);
2111 }
2112
2113 #[test]
2114 fn resolve_subagent_small_model_rejects_cross_provider_configured_lightweight_model() {
2115 let mut cfg = VTCodeConfig::default();
2116 cfg.agent.small_model.model = models::anthropic::CLAUDE_HAIKU_4_5.to_string();
2117
2118 let resolved = resolve_subagent_model(
2119 &cfg,
2120 models::openai::GPT_5_4,
2121 "openai",
2122 Some("small"),
2123 "worker",
2124 )
2125 .expect("resolve model");
2126
2127 assert_eq!(resolved, ModelId::GPT54Mini);
2128 }
2129
2130 #[test]
2131 fn resolve_effective_subagent_model_falls_back_to_spec_model_on_invalid_override() {
2132 let cfg = VTCodeConfig::default();
2133 let resolved = resolve_effective_subagent_model(
2134 &cfg,
2135 models::anthropic::CLAUDE_SONNET_4_6,
2136 "anthropic",
2137 Some("not-a-real-model"),
2138 Some("haiku"),
2139 "reviewer",
2140 )
2141 .expect("resolve model");
2142 assert_eq!(resolved.as_str(), models::anthropic::CLAUDE_HAIKU_4_5);
2143 }
2144
2145 #[test]
2146 fn background_record_ids_are_stable_and_sanitized() {
2147 assert_eq!(
2148 background_record_id("Rust Engineer"),
2149 "background-Rust-Engineer"
2150 );
2151 assert_eq!(
2152 background_record_id("plugin:reviewer/default"),
2153 "background-plugin-reviewer-default"
2154 );
2155 }
2156
2157 #[test]
2158 fn background_subagent_command_includes_expected_flags() {
2159 let workspace = std::env::current_dir().expect("workspace");
2160 let command = build_background_subagent_command(
2161 &workspace,
2162 "rust-engineer",
2163 "session-parent",
2164 "session-child",
2165 "Inspect the repo",
2166 Some(7),
2167 Some("gpt-5.4-mini"),
2168 Some("high"),
2169 )
2170 .expect("background command");
2171
2172 assert!(command.len() >= 15);
2173 assert_eq!(command[1], "background-subagent");
2174 assert!(
2175 command
2176 .windows(2)
2177 .any(|pair| pair == ["--agent-name", "rust-engineer"])
2178 );
2179 assert!(
2180 command
2181 .windows(2)
2182 .any(|pair| pair == ["--parent-session-id", "session-parent"])
2183 );
2184 assert!(
2185 command
2186 .windows(2)
2187 .any(|pair| pair == ["--session-id", "session-child"])
2188 );
2189 assert!(
2190 command
2191 .windows(2)
2192 .any(|pair| pair == ["--prompt", "Inspect the repo"])
2193 );
2194 assert!(command.windows(2).any(|pair| pair == ["--max-turns", "7"]));
2195 assert!(
2196 command
2197 .windows(2)
2198 .any(|pair| pair == ["--model-override", "gpt-5.4-mini"])
2199 );
2200 assert!(
2201 command
2202 .windows(2)
2203 .any(|pair| pair == ["--reasoning-override", "high"])
2204 );
2205 }
2206
2207 #[test]
2208 fn resolve_effective_subagent_model_still_errors_on_invalid_spec_model() {
2209 let cfg = VTCodeConfig::default();
2210 let err = resolve_effective_subagent_model(
2211 &cfg,
2212 models::anthropic::CLAUDE_SONNET_4_6,
2213 "anthropic",
2214 None,
2215 Some("not-a-real-model"),
2216 "reviewer",
2217 )
2218 .expect_err("invalid spec model should fail");
2219 assert!(err.to_string().contains("Failed to resolve model"));
2220 }
2221
2222 async fn wait_for_effective_model(
2223 controller: &SubagentController,
2224 target: &str,
2225 ) -> Result<String> {
2226 for _ in 0..50 {
2227 if let Ok(snapshot) = controller.snapshot_for_thread(target).await {
2228 return Ok(snapshot.effective_config.agent.default_model);
2229 }
2230 tokio::time::sleep(Duration::from_millis(10)).await;
2231 }
2232
2233 Err(anyhow!(
2234 "Subagent {target} did not capture an effective runtime configuration in time"
2235 ))
2236 }
2237
2238 fn read_only_test_spec(name: &str) -> SubagentSpec {
2239 SubagentSpec {
2240 name: name.to_string(),
2241 description: "test".to_string(),
2242 prompt: String::new(),
2243 tools: Some(vec![tools::READ_FILE.to_string()]),
2244 disallowed_tools: Vec::new(),
2245 model: None,
2246 color: None,
2247 reasoning_effort: None,
2248 permission_mode: Some(PermissionMode::Plan),
2249 skills: Vec::new(),
2250 mcp_servers: Vec::new(),
2251 hooks: None,
2252 background: false,
2253 mode: vtcode_config::AgentMode::Subagent,
2254 max_turns: None,
2255 nickname_candidates: Vec::new(),
2256 initial_prompt: None,
2257 memory: None,
2258 isolation: None,
2259 aliases: Vec::new(),
2260 source: SubagentSource::Builtin,
2261 file_path: None,
2262 warnings: Vec::new(),
2263 }
2264 }
2265
2266 #[test]
2267 fn filter_child_tools_removes_subagent_tools_in_children() {
2268 let defs = vec![
2269 ToolDefinition::function(
2270 tools::SPAWN_AGENT.to_string(),
2271 "Spawn".to_string(),
2272 serde_json::json!({"type": "object"}),
2273 ),
2274 ToolDefinition::function(
2275 tools::UNIFIED_SEARCH.to_string(),
2276 "Search".to_string(),
2277 serde_json::json!({"type": "object"}),
2278 ),
2279 ToolDefinition::function(
2280 tools::LIST_FILES.to_string(),
2281 "List".to_string(),
2282 serde_json::json!({"type": "object"}),
2283 ),
2284 ToolDefinition::function(
2285 tools::REQUEST_USER_INPUT.to_string(),
2286 "Ask".to_string(),
2287 serde_json::json!({"type": "object"}),
2288 ),
2289 ];
2290 let spec = vtcode_config::builtin_subagents()
2291 .into_iter()
2292 .find(|spec| spec.name == "explorer")
2293 .expect("explorer");
2294 let filtered = filter_child_tools(&spec, defs, true);
2295 assert_eq!(filtered.len(), 1);
2296 assert_eq!(filtered[0].function_name(), tools::UNIFIED_SEARCH);
2297 }
2298
2299 #[test]
2300 fn filter_child_tools_keeps_unified_exec_for_shell_capable_agents() {
2301 let defs = vec![
2302 ToolDefinition::function(
2303 tools::UNIFIED_EXEC.to_string(),
2304 "Exec".to_string(),
2305 serde_json::json!({"type": "object"}),
2306 ),
2307 ToolDefinition::function(
2308 tools::UNIFIED_SEARCH.to_string(),
2309 "Search".to_string(),
2310 serde_json::json!({"type": "object"}),
2311 ),
2312 ];
2313 let spec = SubagentSpec {
2314 name: "shell-demo".to_string(),
2315 description: "test".to_string(),
2316 prompt: String::new(),
2317 tools: Some(vec![
2318 tools::UNIFIED_EXEC.to_string(),
2319 tools::UNIFIED_SEARCH.to_string(),
2320 ]),
2321 disallowed_tools: Vec::new(),
2322 model: None,
2323 color: None,
2324 reasoning_effort: None,
2325 permission_mode: None,
2326 skills: Vec::new(),
2327 mcp_servers: Vec::new(),
2328 hooks: None,
2329 background: false,
2330 mode: vtcode_config::AgentMode::Subagent,
2331 max_turns: None,
2332 nickname_candidates: Vec::new(),
2333 initial_prompt: None,
2334 memory: None,
2335 isolation: None,
2336 aliases: Vec::new(),
2337 source: SubagentSource::Builtin,
2338 file_path: None,
2339 warnings: Vec::new(),
2340 };
2341
2342 let filtered = filter_child_tools(&spec, defs, spec.is_read_only());
2343 assert_eq!(filtered.len(), 2);
2344 assert_eq!(filtered[0].function_name(), tools::UNIFIED_EXEC);
2345 assert_eq!(filtered[1].function_name(), tools::UNIFIED_SEARCH);
2346 }
2347
2348 #[test]
2349 fn build_child_config_clamps_permissions_and_intersects_allowed_tools() {
2350 let mut parent = VTCodeConfig::default();
2351 parent.permissions.default_mode = PermissionMode::Default;
2352 parent.permissions.allow = vec![
2353 tools::READ_FILE.to_string(),
2354 tools::UNIFIED_SEARCH.to_string(),
2355 ];
2356 parent.permissions.deny = vec![tools::UNIFIED_EXEC.to_string()];
2357
2358 let mut spec = vtcode_config::builtin_subagents()
2359 .into_iter()
2360 .find(|spec| spec.name == "worker")
2361 .expect("worker");
2362 spec.permission_mode = Some(PermissionMode::BypassPermissions);
2363 spec.tools = Some(vec![
2364 tools::SPAWN_AGENT.to_string(),
2365 tools::UNIFIED_SEARCH.to_string(),
2366 tools::READ_FILE.to_string(),
2367 ]);
2368
2369 let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, None);
2370 assert_eq!(child.permissions.default_mode, PermissionMode::Default);
2371 assert_eq!(
2372 child.permissions.allow,
2373 vec![
2374 tools::READ_FILE.to_string(),
2375 tools::UNIFIED_SEARCH.to_string()
2376 ]
2377 );
2378 assert!(
2379 child
2380 .permissions
2381 .deny
2382 .contains(&tools::UNIFIED_EXEC.to_string())
2383 );
2384 assert!(
2385 child
2386 .permissions
2387 .deny
2388 .contains(&tools::SPAWN_AGENT.to_string())
2389 );
2390 }
2391
2392 #[test]
2393 fn build_child_config_preserves_matching_rule_and_exact_tool_ids() {
2394 let mut parent = VTCodeConfig::default();
2395 parent.permissions.allow = vec![
2396 "Read(/docs/**)".to_string(),
2397 "mcp::context7::search".to_string(),
2398 tools::READ_FILE.to_string(),
2399 ];
2400
2401 let mut spec = vtcode_config::builtin_subagents()
2402 .into_iter()
2403 .find(|spec| spec.name == "worker")
2404 .expect("worker");
2405 spec.tools = Some(vec![
2406 "mcp::context7::search".to_string(),
2407 tools::UNIFIED_EXEC.to_string(),
2408 tools::READ_FILE.to_string(),
2409 ]);
2410
2411 let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, None);
2412
2413 assert_eq!(
2414 child.permissions.allow,
2415 vec![
2416 "Read(/docs/**)".to_string(),
2417 "mcp::context7::search".to_string(),
2418 tools::READ_FILE.to_string()
2419 ]
2420 );
2421 }
2422
2423 #[test]
2424 fn build_child_config_preserves_parent_rule_shaped_allowlist() {
2425 let mut parent = VTCodeConfig::default();
2426 parent.permissions.allow = vec!["Read".to_string()];
2427
2428 let mut spec = vtcode_config::builtin_subagents()
2429 .into_iter()
2430 .find(|spec| spec.name == "worker")
2431 .expect("worker");
2432 spec.tools = Some(vec![
2433 tools::READ_FILE.to_string(),
2434 tools::UNIFIED_SEARCH.to_string(),
2435 tools::UNIFIED_EXEC.to_string(),
2436 ]);
2437
2438 let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, None);
2439
2440 assert_eq!(child.permissions.allow, vec!["Read".to_string()]);
2441 }
2442
2443 #[test]
2444 fn build_child_config_promotes_single_turn_budget_to_recovery_budget() {
2445 let parent = VTCodeConfig::default();
2446 let spec = vtcode_config::builtin_subagents()
2447 .into_iter()
2448 .find(|spec| spec.name == "worker")
2449 .expect("worker");
2450
2451 let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, Some(1));
2452
2453 assert_eq!(child.automation.full_auto.max_turns, SUBAGENT_MIN_MAX_TURNS);
2454 }
2455
2456 #[test]
2457 fn background_children_get_a_higher_turn_floor() {
2458 assert_eq!(normalize_background_child_max_turns(Some(2), true), Some(4));
2459 assert_eq!(normalize_background_child_max_turns(Some(3), true), Some(4));
2460 assert_eq!(normalize_background_child_max_turns(Some(4), true), Some(4));
2461 }
2462
2463 #[test]
2464 fn foreground_children_keep_the_existing_turn_floor() {
2465 assert_eq!(
2466 normalize_background_child_max_turns(Some(1), false),
2467 Some(SUBAGENT_MIN_MAX_TURNS)
2468 );
2469 assert_eq!(
2470 normalize_background_child_max_turns(Some(2), false),
2471 Some(2)
2472 );
2473 assert_eq!(normalize_background_child_max_turns(None, true), None);
2474 }
2475
2476 #[test]
2477 fn build_child_config_merges_inline_mcp_provider() {
2478 let parent = VTCodeConfig::default();
2479 let mut spec = vtcode_config::builtin_subagents()
2480 .into_iter()
2481 .find(|spec| spec.name == "default")
2482 .expect("default");
2483 spec.mcp_servers = vec![SubagentMcpServer::Inline(BTreeMap::from([(
2484 "playwright".to_string(),
2485 serde_json::json!({
2486 "type": "stdio",
2487 "command": "npx",
2488 "args": ["-y", "@playwright/mcp@latest"],
2489 }),
2490 )]))];
2491
2492 let child = build_child_config(&parent, &spec, models::openai::GPT_5_4, None);
2493 let provider = child
2494 .mcp
2495 .providers
2496 .iter()
2497 .find(|provider| provider.name == "playwright")
2498 .expect("playwright provider");
2499 assert_eq!(provider.name, "playwright");
2500 }
2501
2502 #[test]
2503 fn explicit_delegation_request_detects_mentions_and_keywords() {
2504 let direct_mentions = extract_explicit_agent_mentions("@agent-worker fix the issue", &[]);
2505 assert!(contains_explicit_delegation_request(
2506 "@agent-worker fix the issue",
2507 direct_mentions.as_slice()
2508 ));
2509 let no_mentions = extract_explicit_agent_mentions("delegate this in parallel", &[]);
2510 assert!(contains_explicit_delegation_request(
2511 "delegate this in parallel",
2512 no_mentions.as_slice()
2513 ));
2514 let empty_mentions = extract_explicit_agent_mentions("review the repository", &[]);
2515 assert!(!contains_explicit_delegation_request(
2516 "review the repository",
2517 empty_mentions.as_slice()
2518 ));
2519 }
2520
2521 #[test]
2522 fn explicit_agent_mentions_detect_natural_language_selection() {
2523 let rust_engineer = read_only_test_spec("rust-engineer");
2524 assert_eq!(
2525 extract_explicit_agent_mentions(
2526 "use rust-engineer agent to review current code",
2527 &[rust_engineer]
2528 ),
2529 vec!["rust-engineer".to_string()]
2530 );
2531 }
2532
2533 #[test]
2534 fn explicit_agent_mentions_detect_looser_subagent_selection() {
2535 let background_demo = read_only_test_spec("background-demo");
2536 assert_eq!(
2537 extract_explicit_agent_mentions(
2538 "use background-demo and run the subagent",
2539 &[background_demo]
2540 ),
2541 vec!["background-demo".to_string()]
2542 );
2543 }
2544
2545 #[test]
2546 fn explicit_agent_mentions_detect_run_subagent_selection() {
2547 let rust_engineer = read_only_test_spec("rust-engineer");
2548 assert_eq!(
2549 extract_explicit_agent_mentions(
2550 "run rust-engineer subagent and review changes",
2551 &[rust_engineer]
2552 ),
2553 vec!["rust-engineer".to_string()]
2554 );
2555 }
2556
2557 #[test]
2558 fn explicit_agent_mentions_ignore_primary_only_agents() {
2559 let mut duck = read_only_test_spec("duck");
2560 duck.mode = vtcode_config::AgentMode::Primary;
2561
2562 assert_eq!(
2563 extract_explicit_agent_mentions("@agent-duck discuss the task", &[duck.clone()]),
2564 Vec::<String>::new()
2565 );
2566 assert_eq!(
2567 extract_explicit_agent_mentions("run duck agent and discuss the task", &[duck]),
2568 Vec::<String>::new()
2569 );
2570 }
2571
2572 #[test]
2573 fn explicit_model_request_detects_aliases_and_full_ids() {
2574 assert!(contains_explicit_model_request(
2575 "delegate this using gpt-5.4-mini",
2576 "gpt-5.4-mini"
2577 ));
2578 assert!(contains_explicit_model_request(
2579 "use the worker subagent with haiku",
2580 "haiku"
2581 ));
2582 assert!(contains_explicit_model_request(
2583 "run this with the small model",
2584 "small"
2585 ));
2586 assert!(!contains_explicit_model_request(
2587 "delegate this small cleanup task",
2588 "small"
2589 ));
2590 assert!(!contains_explicit_model_request(
2591 "delegate this task",
2592 "gpt-5.4-mini"
2593 ));
2594 }
2595
2596 #[test]
2597 fn normalize_requested_model_override_drops_default_like_values() {
2598 assert_eq!(
2599 normalize_requested_model_override(Some("default".to_string()), "delegate this task"),
2600 None
2601 );
2602 assert_eq!(
2603 normalize_requested_model_override(Some(" inherit ".to_string()), "delegate this task"),
2604 None
2605 );
2606 assert_eq!(
2607 normalize_requested_model_override(
2608 Some(" inherit ".to_string()),
2609 "delegate this task using inherit"
2610 ),
2611 Some("inherit".to_string())
2612 );
2613 }
2614
2615 #[test]
2616 fn sanitize_subagent_input_items_drops_empty_fields() {
2617 let mut items = vec![
2618 SubagentInputItem {
2619 item_type: Some("text".to_string()),
2620 text: Some(" Workspace: /tmp/repo ".to_string()),
2621 path: Some(String::new()),
2622 name: Some(" ".to_string()),
2623 image_url: None,
2624 },
2625 SubagentInputItem {
2626 item_type: Some("text".to_string()),
2627 text: Some(" ".to_string()),
2628 path: Some(String::new()),
2629 name: None,
2630 image_url: None,
2631 },
2632 ];
2633
2634 sanitize_subagent_input_items(&mut items);
2635
2636 assert_eq!(items.len(), 1);
2637 assert_eq!(items[0].text.as_deref(), Some("Workspace: /tmp/repo"));
2638 assert!(items[0].path.is_none());
2639 assert!(items[0].name.is_none());
2640 }
2641
2642 #[tokio::test]
2643 async fn controller_exposes_builtin_specs() {
2644 let temp = TempDir::new().expect("tempdir");
2645 let controller = SubagentController::new(test_controller_config(
2646 temp.path().to_path_buf(),
2647 VTCodeConfig::default(),
2648 ))
2649 .await
2650 .expect("controller");
2651 let specs = controller.effective_specs().await;
2652 assert!(specs.iter().any(|spec| spec.name == "explorer"));
2653 assert!(specs.iter().any(|spec| spec.name == "worker"));
2654 }
2655
2656 #[tokio::test]
2657 async fn spawn_defaults_to_single_explicit_mention() {
2658 let temp = TempDir::new().expect("tempdir");
2659 let controller = SubagentController::new(test_controller_config(
2660 temp.path().to_path_buf(),
2661 VTCodeConfig::default(),
2662 ))
2663 .await
2664 .expect("controller");
2665
2666 controller
2667 .set_turn_delegation_hints_from_input("@agent-explorer inspect the codebase")
2668 .await;
2669
2670 let spawned = controller
2671 .spawn(SpawnAgentRequest {
2672 message: Some("Inspect the codebase.".to_string()),
2673 ..SpawnAgentRequest::default()
2674 })
2675 .await
2676 .expect("spawn");
2677
2678 assert_eq!(spawned.agent_name, "explorer");
2679 controller.close(&spawned.id).await.expect("close");
2680 }
2681
2682 #[tokio::test]
2683 async fn spawn_defaults_to_single_natural_language_selection() {
2684 let temp = TempDir::new().expect("tempdir");
2685 let controller = SubagentController::new(test_controller_config(
2686 temp.path().to_path_buf(),
2687 VTCodeConfig::default(),
2688 ))
2689 .await
2690 .expect("controller");
2691
2692 let mentions = controller
2693 .set_turn_delegation_hints_from_input("use explorer agent to inspect the codebase")
2694 .await;
2695 assert_eq!(mentions, vec!["explorer".to_string()]);
2696
2697 let spawned = controller
2698 .spawn(SpawnAgentRequest {
2699 message: Some("Inspect the codebase.".to_string()),
2700 ..SpawnAgentRequest::default()
2701 })
2702 .await
2703 .expect("spawn");
2704
2705 assert_eq!(spawned.agent_name, "explorer");
2706 controller.close(&spawned.id).await.expect("close");
2707 }
2708
2709 #[tokio::test]
2710 async fn spawn_rejects_mismatched_explicit_mention() {
2711 let temp = TempDir::new().expect("tempdir");
2712 let controller = SubagentController::new(test_controller_config(
2713 temp.path().to_path_buf(),
2714 VTCodeConfig::default(),
2715 ))
2716 .await
2717 .expect("controller");
2718
2719 controller
2720 .set_turn_delegation_hints_from_input("@agent-explorer inspect the codebase")
2721 .await;
2722
2723 let err = controller
2724 .spawn(SpawnAgentRequest {
2725 agent_type: Some("worker".to_string()),
2726 message: Some("Implement a change.".to_string()),
2727 ..SpawnAgentRequest::default()
2728 })
2729 .await
2730 .expect_err("mismatched mention should fail");
2731
2732 assert!(
2733 err.to_string()
2734 .contains("user explicitly selected 'explorer'")
2735 );
2736 }
2737
2738 #[tokio::test]
2739 async fn spawn_rejects_write_capable_agent_without_explicit_request_or_agent_type() {
2740 let temp = TempDir::new().expect("tempdir");
2741 let controller = SubagentController::new(test_controller_config(
2742 temp.path().to_path_buf(),
2743 VTCodeConfig::default(),
2744 ))
2745 .await
2746 .expect("controller");
2747
2748 let err = controller
2749 .spawn(SpawnAgentRequest {
2750 message: Some("Implement a change.".to_string()),
2751 ..SpawnAgentRequest::default()
2752 })
2753 .await
2754 .expect_err("write-capable agent should require explicit request or agent_type");
2755
2756 assert!(
2757 err.to_string()
2758 .contains("cannot launch write-capable agent")
2759 );
2760 }
2761
2762 #[tokio::test]
2763 async fn spawn_allows_write_capable_agent_with_explicit_agent_type() {
2764 let temp = TempDir::new().expect("tempdir");
2765 let controller = SubagentController::new(test_controller_config(
2766 temp.path().to_path_buf(),
2767 VTCodeConfig::default(),
2768 ))
2769 .await
2770 .expect("controller");
2771
2772 let spawned = controller
2773 .spawn(SpawnAgentRequest {
2774 agent_type: Some("worker".to_string()),
2775 message: Some("Implement a change.".to_string()),
2776 ..SpawnAgentRequest::default()
2777 })
2778 .await
2779 .expect("explicit agent_type should allow write-capable agent");
2780
2781 controller.close(&spawned.id).await.expect("close");
2782 }
2783
2784 #[tokio::test]
2785 async fn spawn_rejects_primary_only_agent_as_child() {
2786 let temp = TempDir::new().expect("tempdir");
2787 write_test_primary_agent(temp.path());
2788 let controller = SubagentController::new(test_controller_config(
2789 temp.path().to_path_buf(),
2790 VTCodeConfig::default(),
2791 ))
2792 .await
2793 .expect("controller");
2794
2795 let mentions = controller
2796 .set_turn_delegation_hints_from_input("@agent-duck discuss the task")
2797 .await;
2798 assert!(mentions.is_empty());
2799
2800 let err = controller
2801 .spawn(SpawnAgentRequest {
2802 agent_type: Some("duck".to_string()),
2803 message: Some("Discuss the task.".to_string()),
2804 ..SpawnAgentRequest::default()
2805 })
2806 .await
2807 .expect_err("primary-only agent should not spawn as child");
2808
2809 assert!(err.to_string().contains("Unknown subagent type duck"));
2810 }
2811
2812 #[tokio::test]
2813 async fn spawn_accepts_background_flag_outside_managed_background_runtime() {
2814 let temp = TempDir::new().expect("tempdir");
2815 let controller = SubagentController::new(test_controller_config(
2816 temp.path().to_path_buf(),
2817 VTCodeConfig::default(),
2818 ))
2819 .await
2820 .expect("controller");
2821
2822 controller
2823 .set_turn_delegation_hints_from_input("delegate this task")
2824 .await;
2825
2826 let spawned = controller
2827 .spawn(SpawnAgentRequest {
2828 agent_type: Some("explorer".to_string()),
2829 message: Some("Inspect the codebase.".to_string()),
2830 background: true,
2831 ..SpawnAgentRequest::default()
2832 })
2833 .await
2834 .expect("background child spawn should succeed");
2835
2836 assert!(spawned.background);
2837 controller.close(&spawned.id).await.expect("close");
2838 }
2839
2840 #[tokio::test]
2841 async fn spawn_allows_background_capable_spec_as_foreground_child() {
2842 let temp = TempDir::new().expect("tempdir");
2843 write_test_background_subagent(temp.path());
2844 let controller = SubagentController::new(test_controller_config(
2845 temp.path().to_path_buf(),
2846 VTCodeConfig::default(),
2847 ))
2848 .await
2849 .expect("controller");
2850
2851 controller
2852 .set_turn_delegation_hints_from_input("run background-demo subagent and demo")
2853 .await;
2854
2855 let spawned = controller
2856 .spawn(SpawnAgentRequest {
2857 agent_type: Some("background-demo".to_string()),
2858 message: Some("Run the demo.".to_string()),
2859 background: false,
2860 ..SpawnAgentRequest::default()
2861 })
2862 .await
2863 .expect("foreground background-capable spawn should succeed");
2864
2865 assert_eq!(spawned.agent_name, "background-demo");
2866 assert!(!spawned.background);
2867 controller.close(&spawned.id).await.expect("close");
2868 }
2869
2870 #[tokio::test]
2871 async fn spawn_rejects_vague_task_even_with_explicit_request() {
2872 let temp = TempDir::new().expect("tempdir");
2873 let controller = SubagentController::new(test_controller_config(
2874 temp.path().to_path_buf(),
2875 VTCodeConfig::default(),
2876 ))
2877 .await
2878 .expect("controller");
2879
2880 controller
2881 .set_turn_delegation_hints_from_input("run worker subagent and report")
2882 .await;
2883
2884 let err = controller
2885 .spawn(SpawnAgentRequest {
2886 agent_type: Some("worker".to_string()),
2887 message: Some("report".to_string()),
2888 ..SpawnAgentRequest::default()
2889 })
2890 .await
2891 .expect_err("vague task should require clarification");
2892
2893 assert!(err.to_string().contains("too vague ('report')"));
2894 }
2895
2896 #[tokio::test]
2897 async fn spawn_defaults_to_write_capable_run_subagent_selection() {
2898 let temp = TempDir::new().expect("tempdir");
2899 let controller = SubagentController::new(test_controller_config(
2900 temp.path().to_path_buf(),
2901 VTCodeConfig::default(),
2902 ))
2903 .await
2904 .expect("controller");
2905
2906 let mentions = controller
2907 .set_turn_delegation_hints_from_input("run worker subagent and implement the change")
2908 .await;
2909 assert_eq!(mentions, vec!["worker".to_string()]);
2910
2911 let spawned = controller
2912 .spawn(SpawnAgentRequest {
2913 message: Some("Implement the change.".to_string()),
2914 ..SpawnAgentRequest::default()
2915 })
2916 .await
2917 .expect("spawn");
2918
2919 assert_eq!(spawned.agent_name, "worker");
2920 controller.close(&spawned.id).await.expect("close");
2921 }
2922
2923 #[tokio::test]
2924 async fn spawn_rejects_read_only_agent_when_auto_delegate_is_disabled() {
2925 let temp = TempDir::new().expect("tempdir");
2926 write_test_read_only_subagent(temp.path());
2927 let mut cfg = VTCodeConfig::default();
2928 cfg.subagents.auto_delegate_read_only = false;
2929 let controller =
2930 SubagentController::new(test_controller_config(temp.path().to_path_buf(), cfg))
2931 .await
2932 .expect("controller");
2933
2934 let err = controller
2935 .spawn(SpawnAgentRequest {
2936 agent_type: Some("readonly-demo".to_string()),
2937 message: Some("Inspect the repository.".to_string()),
2938 ..SpawnAgentRequest::default()
2939 })
2940 .await
2941 .expect_err("read-only agent should require explicit delegation");
2942
2943 assert!(
2944 err.to_string()
2945 .contains("cannot proactively launch read-only agent 'readonly-demo'")
2946 );
2947 }
2948
2949 #[test]
2950 fn load_memory_appendix_renders_compact_summary() {
2951 let temp = TempDir::new().expect("tempdir");
2952 let memory_dir = temp.path().join(".vtcode/agent-memory/reviewer");
2953 std::fs::create_dir_all(&memory_dir).expect("memory dir");
2954 std::fs::write(
2955 memory_dir.join("MEMORY.md"),
2956 "# 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",
2957 )
2958 .expect("write memory");
2959
2960 let appendix =
2961 load_memory_appendix(temp.path(), "reviewer", Some(SubagentMemoryScope::Project))
2962 .expect("appendix")
2963 .expect("memory appendix");
2964
2965 assert!(appendix.contains("Persistent memory file:"));
2966 assert!(appendix.contains("Key points:"));
2967 assert!(appendix.contains("Keep diffs surgical."));
2968 assert!(appendix.contains("Open `MEMORY.md` when exact wording or more detail matters."));
2969 assert!(!appendix.contains("Current MEMORY.md excerpt"));
2970 assert!(!appendix.contains("## Preferences"));
2971 }
2972
2973 #[tokio::test]
2974 async fn spawn_ignores_model_override_without_explicit_user_model_request() {
2975 let temp = TempDir::new().expect("tempdir");
2976 let controller = SubagentController::new(test_controller_config(
2977 temp.path().to_path_buf(),
2978 VTCodeConfig::default(),
2979 ))
2980 .await
2981 .expect("controller");
2982
2983 controller
2984 .set_turn_delegation_hints_from_input("delegate this task")
2985 .await;
2986
2987 let spawned = controller
2988 .spawn(SpawnAgentRequest {
2989 agent_type: Some("worker".to_string()),
2990 message: Some("Implement the change.".to_string()),
2991 model: Some(models::openai::GPT_5_4_MINI.to_string()),
2992 ..SpawnAgentRequest::default()
2993 })
2994 .await
2995 .expect("spawn");
2996
2997 let effective_model = wait_for_effective_model(&controller, &spawned.id)
2998 .await
2999 .expect("effective model");
3000 assert_eq!(effective_model, models::openai::GPT_5_4);
3001 controller.close(&spawned.id).await.expect("close");
3002 }
3003
3004 #[tokio::test]
3005 async fn spawn_honors_model_override_when_user_explicitly_requests_it() {
3006 let temp = TempDir::new().expect("tempdir");
3007 let controller = SubagentController::new(test_controller_config(
3008 temp.path().to_path_buf(),
3009 VTCodeConfig::default(),
3010 ))
3011 .await
3012 .expect("controller");
3013
3014 controller
3015 .set_turn_delegation_hints_from_input("delegate this task using gpt-5.4-mini")
3016 .await;
3017
3018 let spawned = controller
3019 .spawn(SpawnAgentRequest {
3020 agent_type: Some("worker".to_string()),
3021 message: Some("Implement the change.".to_string()),
3022 model: Some(models::openai::GPT_5_4_MINI.to_string()),
3023 ..SpawnAgentRequest::default()
3024 })
3025 .await
3026 .expect("spawn");
3027
3028 let effective_model = wait_for_effective_model(&controller, &spawned.id)
3029 .await
3030 .expect("effective model");
3031 assert_eq!(effective_model, models::openai::GPT_5_4_MINI);
3032 controller.close(&spawned.id).await.expect("close");
3033 }
3034
3035 #[tokio::test]
3036 async fn spawn_background_subprocess_rejects_non_background_agent() {
3037 let temp = TempDir::new().expect("tempdir");
3038 let mut cfg = VTCodeConfig::default();
3039 cfg.subagents.background.enabled = true;
3040 let controller =
3041 SubagentController::new(test_controller_config(temp.path().to_path_buf(), cfg))
3042 .await
3043 .expect("controller");
3044
3045 controller
3046 .set_turn_delegation_hints_from_input("delegate this task")
3047 .await;
3048
3049 let err = controller
3050 .spawn_background_subprocess(SpawnBackgroundSubprocessRequest {
3051 agent_type: Some("worker".to_string()),
3052 message: Some("Implement a change.".to_string()),
3053 ..SpawnBackgroundSubprocessRequest::default()
3054 })
3055 .await
3056 .expect_err("non-background agent should be rejected");
3057
3058 assert!(err.to_string().contains("background: true"));
3059 assert!(err.to_string().contains("Use spawn_agent instead"));
3060 }
3061
3062 #[tokio::test]
3063 async fn spawn_background_subprocess_returns_active_record_when_settings_match() {
3064 let temp = TempDir::new().expect("tempdir");
3065 write_test_background_subagent(temp.path());
3066 let mut cfg = VTCodeConfig::default();
3067 cfg.subagents.background.enabled = true;
3068 let controller =
3069 SubagentController::new(test_controller_config(temp.path().to_path_buf(), cfg))
3070 .await
3071 .expect("controller");
3072
3073 controller
3074 .set_turn_delegation_hints_from_input("delegate this task")
3075 .await;
3076
3077 let spec = controller
3078 .resolve_requested_spec(Some("background-demo"))
3079 .await
3080 .expect("spec");
3081 let record_id = background_record_id(spec.name.as_str());
3082 let created_at = Utc::now();
3083 {
3084 let mut state = controller.state.write().await;
3085 state.background_children.insert(
3086 record_id.clone(),
3087 BackgroundRecord {
3088 id: record_id.clone(),
3089 agent_name: spec.name.clone(),
3090 display_label: subagent_display_label(&spec),
3091 description: spec.description.clone(),
3092 source: spec.source.label(),
3093 color: spec.color.clone(),
3094 session_id: "session-background-demo".to_string(),
3095 exec_session_id: "exec-session-background-demo".to_string(),
3096 desired_enabled: true,
3097 status: BackgroundSubprocessStatus::Running,
3098 created_at,
3099 updated_at: created_at,
3100 started_at: Some(created_at),
3101 ended_at: None,
3102 pid: Some(42),
3103 prompt: "Report readiness once.".to_string(),
3104 summary: Some("ready".to_string()),
3105 error: None,
3106 archive_path: None,
3107 transcript_path: None,
3108 max_turns: Some(4),
3109 model_override: None,
3110 reasoning_override: None,
3111 restart_attempts: 0,
3112 },
3113 );
3114 }
3115
3116 let entry = controller
3117 .spawn_background_subprocess(SpawnBackgroundSubprocessRequest {
3118 agent_type: Some("background-demo".to_string()),
3119 ..SpawnBackgroundSubprocessRequest::default()
3120 })
3121 .await
3122 .expect("matching active record should be returned");
3123
3124 assert_eq!(entry.id, record_id);
3125 assert_eq!(entry.status, BackgroundSubprocessStatus::Running);
3126 assert_eq!(entry.pid, Some(42));
3127 }
3128
3129 #[tokio::test]
3130 async fn spawn_background_subprocess_rejects_conflicting_active_record_settings() {
3131 let temp = TempDir::new().expect("tempdir");
3132 write_test_background_subagent(temp.path());
3133 let mut cfg = VTCodeConfig::default();
3134 cfg.subagents.background.enabled = true;
3135 let controller =
3136 SubagentController::new(test_controller_config(temp.path().to_path_buf(), cfg))
3137 .await
3138 .expect("controller");
3139
3140 controller
3141 .set_turn_delegation_hints_from_input("delegate this task")
3142 .await;
3143
3144 let spec = controller
3145 .resolve_requested_spec(Some("background-demo"))
3146 .await
3147 .expect("spec");
3148 let record_id = background_record_id(spec.name.as_str());
3149 let created_at = Utc::now();
3150 {
3151 let mut state = controller.state.write().await;
3152 state.background_children.insert(
3153 record_id,
3154 BackgroundRecord {
3155 id: background_record_id(spec.name.as_str()),
3156 agent_name: spec.name.clone(),
3157 display_label: subagent_display_label(&spec),
3158 description: spec.description.clone(),
3159 source: spec.source.label(),
3160 color: spec.color.clone(),
3161 session_id: "session-background-demo".to_string(),
3162 exec_session_id: "exec-session-background-demo".to_string(),
3163 desired_enabled: true,
3164 status: BackgroundSubprocessStatus::Running,
3165 created_at,
3166 updated_at: created_at,
3167 started_at: Some(created_at),
3168 ended_at: None,
3169 pid: Some(42),
3170 prompt: "Report readiness once.".to_string(),
3171 summary: Some("ready".to_string()),
3172 error: None,
3173 archive_path: None,
3174 transcript_path: None,
3175 max_turns: Some(4),
3176 model_override: None,
3177 reasoning_override: None,
3178 restart_attempts: 0,
3179 },
3180 );
3181 }
3182
3183 let err = controller
3184 .spawn_background_subprocess(SpawnBackgroundSubprocessRequest {
3185 agent_type: Some("background-demo".to_string()),
3186 message: Some("Run a different task.".to_string()),
3187 ..SpawnBackgroundSubprocessRequest::default()
3188 })
3189 .await
3190 .expect_err("conflicting active record should be rejected");
3191
3192 assert!(err.to_string().contains("different prompt"));
3193 assert!(err.to_string().contains("Stop or restart"));
3194 }
3195
3196 #[tokio::test]
3197 async fn resume_preserves_captured_runtime_overrides() {
3198 let temp = TempDir::new().expect("tempdir");
3199 let controller = SubagentController::new(test_controller_config(
3200 temp.path().to_path_buf(),
3201 VTCodeConfig::default(),
3202 ))
3203 .await
3204 .expect("controller");
3205
3206 controller
3207 .set_turn_delegation_hints_from_input("delegate this task using gpt-5.4-mini")
3208 .await;
3209
3210 let spawned = controller
3211 .spawn(SpawnAgentRequest {
3212 agent_type: Some("worker".to_string()),
3213 message: Some("Implement the change.".to_string()),
3214 model: Some(models::openai::GPT_5_4_MINI.to_string()),
3215 max_turns: Some(2),
3216 ..SpawnAgentRequest::default()
3217 })
3218 .await
3219 .expect("spawn");
3220
3221 let initial_model = wait_for_effective_model(&controller, &spawned.id)
3222 .await
3223 .expect("initial effective model");
3224 assert_eq!(initial_model, models::openai::GPT_5_4_MINI);
3225
3226 let closed = controller.close(&spawned.id).await.expect("close");
3227 assert_eq!(closed.status, SubagentStatus::Closed);
3228
3229 controller.resume(&spawned.id).await.expect("resume");
3230
3231 for _ in 0..100 {
3232 let status = controller.status_for(&spawned.id).await.expect("status");
3233 if status.updated_at > closed.updated_at && status.status != SubagentStatus::Closed {
3234 let snapshot = controller
3235 .snapshot_for_thread(&spawned.id)
3236 .await
3237 .expect("snapshot");
3238 assert_eq!(
3239 snapshot.effective_config.agent.default_model,
3240 models::openai::GPT_5_4_MINI
3241 );
3242 controller.close(&spawned.id).await.expect("final close");
3243 return;
3244 }
3245 tokio::time::sleep(Duration::from_millis(10)).await;
3246 }
3247
3248 panic!("resumed subagent did not capture runtime config in time");
3249 }
3250
3251 #[tokio::test]
3252 async fn spawn_captures_runtime_config_before_first_child_turn() {
3253 let temp = TempDir::new().expect("tempdir");
3254 let controller = SubagentController::new(test_controller_config(
3255 temp.path().to_path_buf(),
3256 VTCodeConfig::default(),
3257 ))
3258 .await
3259 .expect("controller");
3260
3261 controller
3262 .set_turn_delegation_hints_from_input("delegate this task")
3263 .await;
3264
3265 let spawned = controller
3266 .spawn(SpawnAgentRequest {
3267 agent_type: Some("worker".to_string()),
3268 message: Some("Implement the change.".to_string()),
3269 ..SpawnAgentRequest::default()
3270 })
3271 .await
3272 .expect("spawn");
3273
3274 let snapshot = controller
3275 .snapshot_for_thread(&spawned.id)
3276 .await
3277 .expect("snapshot");
3278
3279 assert_eq!(snapshot.id, spawned.id);
3280 assert!(
3281 !snapshot
3282 .effective_config
3283 .agent
3284 .default_model
3285 .trim()
3286 .is_empty()
3287 );
3288
3289 controller.close(&spawned.id).await.expect("close");
3290 }
3291
3292 #[tokio::test]
3293 async fn spawn_custom_uses_explicit_spec_without_delegation_hints() {
3294 let temp = TempDir::new().expect("tempdir");
3295 let controller = SubagentController::new(test_controller_config(
3296 temp.path().to_path_buf(),
3297 VTCodeConfig::default(),
3298 ))
3299 .await
3300 .expect("controller");
3301
3302 let mut spec = vtcode_config::builtin_subagents()
3303 .into_iter()
3304 .find(|spec| spec.name == "explorer")
3305 .expect("explorer");
3306 spec.name = "init-grounding-explorer".to_string();
3307 spec.description = "VT Code /init grounding explorer.".to_string();
3308 spec.source = SubagentSource::ProjectVtcode;
3309
3310 let spawned = controller
3311 .spawn_custom(
3312 spec,
3313 SpawnAgentRequest {
3314 message: Some(
3315 "Inspect the repository and report agent-facing findings.".to_string(),
3316 ),
3317 max_turns: Some(2),
3318 ..SpawnAgentRequest::default()
3319 },
3320 )
3321 .await
3322 .expect("spawn");
3323
3324 assert_eq!(spawned.agent_name, "init-grounding-explorer");
3325 assert_eq!(spawned.source, SubagentSource::ProjectVtcode.label());
3326 controller.close(&spawned.id).await.expect("close");
3327 }
3328
3329 #[tokio::test]
3330 async fn spawn_custom_rejects_write_capable_spec() {
3331 let temp = TempDir::new().expect("tempdir");
3332 let controller = SubagentController::new(test_controller_config(
3333 temp.path().to_path_buf(),
3334 VTCodeConfig::default(),
3335 ))
3336 .await
3337 .expect("controller");
3338
3339 let spec = vtcode_config::builtin_subagents()
3340 .into_iter()
3341 .find(|spec| spec.name == "worker")
3342 .expect("worker");
3343
3344 let err = controller
3345 .spawn_custom(
3346 spec,
3347 SpawnAgentRequest {
3348 message: Some("Implement a change.".to_string()),
3349 ..SpawnAgentRequest::default()
3350 },
3351 )
3352 .await
3353 .expect_err("write-capable custom spec should be rejected");
3354
3355 assert!(
3356 err.to_string()
3357 .contains("custom subagent spawn only supports read-only specs")
3358 );
3359 }
3360
3361 #[tokio::test]
3362 async fn spawn_custom_rejects_primary_only_spec() {
3363 let temp = TempDir::new().expect("tempdir");
3364 write_test_primary_agent(temp.path());
3365 let controller = SubagentController::new(test_controller_config(
3366 temp.path().to_path_buf(),
3367 VTCodeConfig::default(),
3368 ))
3369 .await
3370 .expect("controller");
3371
3372 let spec = controller
3373 .effective_specs()
3374 .await
3375 .into_iter()
3376 .find(|spec| spec.name == "duck")
3377 .expect("duck primary agent");
3378
3379 let err = controller
3380 .spawn_custom(
3381 spec,
3382 SpawnAgentRequest {
3383 message: Some("Discuss the task.".to_string()),
3384 ..SpawnAgentRequest::default()
3385 },
3386 )
3387 .await
3388 .expect_err("primary-only custom spec should be rejected");
3389
3390 assert!(
3391 err.to_string()
3392 .contains("custom subagent spawn only supports subagent-capable specs")
3393 );
3394 }
3395
3396 #[tokio::test]
3397 async fn close_marks_child_closed() {
3398 let temp = TempDir::new().expect("tempdir");
3399 let controller = SubagentController::new(test_controller_config(
3400 temp.path().to_path_buf(),
3401 VTCodeConfig::default(),
3402 ))
3403 .await
3404 .expect("controller");
3405 controller
3406 .set_turn_delegation_hints_from_input("delegate this task")
3407 .await;
3408 let spawned = controller
3409 .spawn(SpawnAgentRequest {
3410 agent_type: Some("default".to_string()),
3411 message: Some("Summarize the repository.".to_string()),
3412 ..SpawnAgentRequest::default()
3413 })
3414 .await
3415 .expect("spawn");
3416 let closed = controller.close(&spawned.id).await.expect("close");
3417 assert_eq!(closed.status, SubagentStatus::Closed);
3418 }
3419
3420 #[tokio::test]
3421 async fn close_is_idempotent_for_closed_agents() {
3422 let temp = TempDir::new().expect("tempdir");
3423 let controller = SubagentController::new(test_controller_config(
3424 temp.path().to_path_buf(),
3425 VTCodeConfig::default(),
3426 ))
3427 .await
3428 .expect("controller");
3429 controller
3430 .set_turn_delegation_hints_from_input("delegate this task")
3431 .await;
3432 let spawned = controller
3433 .spawn(SpawnAgentRequest {
3434 agent_type: Some("default".to_string()),
3435 message: Some("Summarize the repository.".to_string()),
3436 ..SpawnAgentRequest::default()
3437 })
3438 .await
3439 .expect("spawn");
3440
3441 let closed = controller.close(&spawned.id).await.expect("first close");
3442 let closed_again = controller.close(&spawned.id).await.expect("second close");
3443
3444 assert_eq!(closed_again.status, SubagentStatus::Closed);
3445 assert_eq!(closed_again.updated_at, closed.updated_at);
3446 assert_eq!(closed_again.completed_at, closed.completed_at);
3447 }
3448
3449 #[tokio::test]
3450 async fn close_and_resume_cascade_through_spawn_tree() {
3451 let temp = TempDir::new().expect("tempdir");
3452 let controller = SubagentController::new(test_controller_config(
3453 temp.path().to_path_buf(),
3454 VTCodeConfig::default(),
3455 ))
3456 .await
3457 .expect("controller");
3458
3459 let spec = vtcode_config::builtin_subagents()
3460 .into_iter()
3461 .find(|spec| spec.name == "explorer")
3462 .expect("explorer");
3463
3464 {
3465 let mut state = controller.state.write().await;
3466 state.children.insert(
3467 "parent".to_string(),
3468 test_child_record("parent", "session-root", &spec, SubagentStatus::Running, 1),
3469 );
3470 state.children.insert(
3471 "child".to_string(),
3472 test_child_record("child", "parent", &spec, SubagentStatus::Running, 2),
3473 );
3474 state.children.insert(
3475 "grandchild".to_string(),
3476 test_child_record("grandchild", "child", &spec, SubagentStatus::Running, 3),
3477 );
3478 }
3479
3480 let closed = controller.close("parent").await.expect("close");
3481 assert_eq!(closed.status, SubagentStatus::Closed);
3482 assert_eq!(
3483 controller.status_for("child").await.expect("child").status,
3484 SubagentStatus::Closed
3485 );
3486 assert_eq!(
3487 controller
3488 .status_for("grandchild")
3489 .await
3490 .expect("grandchild")
3491 .status,
3492 SubagentStatus::Closed
3493 );
3494
3495 let subtree_ids = controller
3496 .collect_spawn_subtree_ids("parent")
3497 .await
3498 .expect("collect subtree");
3499 assert_eq!(
3500 subtree_ids,
3501 vec![
3502 "parent".to_string(),
3503 "child".to_string(),
3504 "grandchild".to_string()
3505 ]
3506 );
3507
3508 let mut restart_ids = Vec::new();
3509 for node_id in subtree_ids {
3510 if controller
3511 .reopen_single(node_id.as_str())
3512 .await
3513 .expect("reopen subtree node")
3514 {
3515 restart_ids.push(node_id);
3516 }
3517 }
3518
3519 assert_eq!(
3520 restart_ids,
3521 vec![
3522 "parent".to_string(),
3523 "child".to_string(),
3524 "grandchild".to_string()
3525 ]
3526 );
3527 assert_eq!(
3528 controller
3529 .status_for("parent")
3530 .await
3531 .expect("parent")
3532 .status,
3533 SubagentStatus::Queued
3534 );
3535 assert_eq!(
3536 controller.status_for("child").await.expect("child").status,
3537 SubagentStatus::Queued
3538 );
3539 assert_eq!(
3540 controller
3541 .status_for("grandchild")
3542 .await
3543 .expect("grandchild")
3544 .status,
3545 SubagentStatus::Queued
3546 );
3547 }
3548
3549 #[tokio::test]
3550 async fn spawn_rejects_fourth_active_subagent() {
3551 let temp = TempDir::new().expect("tempdir");
3552 let controller = SubagentController::new(test_controller_config(
3553 temp.path().to_path_buf(),
3554 VTCodeConfig::default(),
3555 ))
3556 .await
3557 .expect("controller");
3558 controller
3559 .set_turn_delegation_hints_from_input("delegate this task")
3560 .await;
3561
3562 let spec = vtcode_config::builtin_subagents()
3563 .into_iter()
3564 .find(|spec| spec.name == "explorer")
3565 .expect("explorer");
3566
3567 {
3568 let mut state = controller.state.write().await;
3569 for idx in 0..SUBAGENT_HARD_CONCURRENCY_LIMIT {
3570 let id = format!("active-{idx}");
3571 state.children.insert(
3572 id.clone(),
3573 ChildRecord {
3574 id: id.clone(),
3575 session_id: format!("session-{id}"),
3576 parent_thread_id: "parent-session".to_string(),
3577 spec: spec.clone(),
3578 display_label: subagent_display_label(&spec),
3579 status: SubagentStatus::Running,
3580 background: false,
3581 depth: 1,
3582 created_at: Utc::now(),
3583 updated_at: Utc::now(),
3584 completed_at: None,
3585 summary: None,
3586 error: None,
3587 archive_metadata: None,
3588 archive_path: None,
3589 transcript_path: None,
3590 effective_config: None,
3591 stored_messages: Vec::new(),
3592 last_prompt: Some("Inspect the codebase.".to_string()),
3593 queued_prompts: VecDeque::new(),
3594 max_turns: None,
3595 model_override: None,
3596 reasoning_override: None,
3597 thread_handle: None,
3598 handle: None,
3599 notify: Arc::new(Notify::new()),
3600 },
3601 );
3602 }
3603 }
3604
3605 let err = controller
3606 .spawn(SpawnAgentRequest {
3607 agent_type: Some("explorer".to_string()),
3608 message: Some("Inspect another codepath.".to_string()),
3609 ..SpawnAgentRequest::default()
3610 })
3611 .await
3612 .expect_err("fourth active subagent should be rejected");
3613
3614 assert!(err.to_string().contains(&format!(
3615 "Subagent concurrency limit reached (max_concurrent={})",
3616 SUBAGENT_HARD_CONCURRENCY_LIMIT
3617 )));
3618 }
3619
3620 #[tokio::test]
3621 async fn wait_returns_first_terminal_child() {
3622 let temp = TempDir::new().expect("tempdir");
3623 let controller = SubagentController::new(test_controller_config(
3624 temp.path().to_path_buf(),
3625 VTCodeConfig::default(),
3626 ))
3627 .await
3628 .expect("controller");
3629 let spec = vtcode_config::builtin_subagents()
3630 .into_iter()
3631 .find(|spec| spec.name == "default")
3632 .expect("default");
3633
3634 {
3635 let mut state = controller.state.write().await;
3636 for id in ["first", "second"] {
3637 state.children.insert(
3638 id.to_string(),
3639 ChildRecord {
3640 id: id.to_string(),
3641 session_id: format!("session-{id}"),
3642 parent_thread_id: "parent-session".to_string(),
3643 spec: spec.clone(),
3644 display_label: subagent_display_label(&spec),
3645 status: SubagentStatus::Running,
3646 background: false,
3647 depth: 1,
3648 created_at: Utc::now(),
3649 updated_at: Utc::now(),
3650 completed_at: None,
3651 summary: None,
3652 error: None,
3653 archive_metadata: None,
3654 archive_path: None,
3655 transcript_path: None,
3656 effective_config: None,
3657 stored_messages: Vec::new(),
3658 last_prompt: None,
3659 queued_prompts: VecDeque::new(),
3660 max_turns: None,
3661 model_override: None,
3662 reasoning_override: None,
3663 thread_handle: None,
3664 handle: None,
3665 notify: Arc::new(Notify::new()),
3666 },
3667 );
3668 }
3669 }
3670
3671 let controller_clone = controller.clone();
3672 tokio::spawn(async move {
3673 tokio::time::sleep(Duration::from_millis(20)).await;
3674 let mut state = controller_clone.state.write().await;
3675 let record = state.children.get_mut("second").expect("second child");
3676 record.status = SubagentStatus::Completed;
3677 record.summary = Some("done".to_string());
3678 record.completed_at = Some(Utc::now());
3679 record.updated_at = Utc::now();
3680 record.notify.notify_waiters();
3681 });
3682
3683 let result = controller
3684 .wait(&["first".to_string(), "second".to_string()], Some(500))
3685 .await
3686 .expect("wait result")
3687 .expect("terminal child");
3688 assert_eq!(result.id, "second");
3689 assert_eq!(result.status, SubagentStatus::Completed);
3690 }
3691}