vtcode_core/subagents/
background.rs1use anyhow::{Context, Result};
2use std::path::{Path, PathBuf};
3use tokio::fs;
4
5use super::constants::SUBAGENT_PREVIEW_LINES;
6use super::types::{PersistedBackgroundRecord, PersistedBackgroundState};
7use crate::utils::file_utils::write_file_atomic_with_context;
8use crate::utils::session_archive::{SessionListing, SessionSnapshot};
9
10const BACKGROUND_DEMO_AGENT: &str = "background-demo";
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub(crate) struct BackgroundLaunchSpec {
14 pub command: Vec<String>,
15 pub use_pty: bool,
16}
17
18fn background_state_path(workspace_root: &Path) -> PathBuf {
21 workspace_root.join(".vtcode").join("state").join("background_subagents.json")
22}
23
24pub(crate) async fn load_background_state(workspace_root: &Path) -> Result<PersistedBackgroundState> {
25 let path = background_state_path(workspace_root);
26 let raw = match fs::read_to_string(&path).await {
27 Ok(raw) => raw,
28 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
29 return Ok(PersistedBackgroundState::default());
30 }
31 Err(e) => {
32 return Err(e).with_context(|| format!("Failed to read {}", path.display()));
33 }
34 };
35 serde_json::from_str(&raw).with_context(|| format!("Failed to parse {}", path.display()))
36}
37
38pub(crate) async fn persist_background_state(
39 workspace_root: &Path,
40 records: Vec<PersistedBackgroundRecord>,
41) -> Result<()> {
42 let path = background_state_path(workspace_root);
43 if let Some(parent) = path.parent() {
44 fs::create_dir_all(parent)
45 .await
46 .with_context(|| format!("Failed to create {}", parent.display()))?;
47 }
48 let payload = serde_json::to_string_pretty(&PersistedBackgroundState { records })?;
49 write_file_atomic_with_context(&path, &payload, "background subagent state").await?;
50 Ok(())
51}
52
53pub(crate) fn build_background_launch_spec(
56 workspace_root: &Path,
57 agent_name: &str,
58 parent_session_id: &str,
59 session_id: &str,
60 prompt: &str,
61 max_turns: Option<usize>,
62 model_override: Option<&str>,
63 reasoning_override: Option<&str>,
64) -> Result<BackgroundLaunchSpec> {
65 if agent_name == BACKGROUND_DEMO_AGENT {
66 let script = workspace_root.join("scripts").join("demo-background-subagent.sh");
67 return Ok(BackgroundLaunchSpec {
68 command: vec![script.to_string_lossy().into_owned()],
69 use_pty: false,
70 });
71 }
72
73 Ok(BackgroundLaunchSpec {
74 command: build_background_subagent_command(
75 workspace_root,
76 agent_name,
77 parent_session_id,
78 session_id,
79 prompt,
80 max_turns,
81 model_override,
82 reasoning_override,
83 )?,
84 use_pty: true,
85 })
86}
87
88pub fn build_background_subagent_command(
89 workspace_root: &Path,
90 agent_name: &str,
91 parent_session_id: &str,
92 session_id: &str,
93 prompt: &str,
94 max_turns: Option<usize>,
95 model_override: Option<&str>,
96 reasoning_override: Option<&str>,
97) -> Result<Vec<String>> {
98 let executable = std::env::current_exe().context("Failed to resolve current vtcode binary")?;
99 let executable = resolve_background_subagent_executable_for_workspace(workspace_root, &executable);
100 let mut command = vec![
101 executable.to_string_lossy().into_owned(),
102 "background-subagent".to_string(),
103 "--workspace".to_string(),
104 workspace_root.to_string_lossy().into_owned(),
105 "--agent-name".to_string(),
106 agent_name.to_string(),
107 "--parent-session-id".to_string(),
108 parent_session_id.to_string(),
109 "--session-id".to_string(),
110 session_id.to_string(),
111 "--prompt".to_string(),
112 prompt.to_string(),
113 ];
114
115 if let Some(max_turns) = max_turns {
116 command.push("--max-turns".to_string());
117 command.push(max_turns.to_string());
118 }
119 if let Some(model_override) = model_override
120 && !model_override.trim().is_empty()
121 {
122 command.push("--model-override".to_string());
123 command.push(model_override.to_string());
124 }
125 if let Some(reasoning_override) = reasoning_override
126 && !reasoning_override.trim().is_empty()
127 {
128 command.push("--reasoning-override".to_string());
129 command.push(reasoning_override.to_string());
130 }
131
132 Ok(command)
133}
134
135fn resolve_background_subagent_executable_for_workspace(workspace_root: &Path, current_exe: &Path) -> PathBuf {
136 let workspace_target = workspace_root.join("target");
137 if current_exe.starts_with(&workspace_target) {
138 return current_exe.to_path_buf();
139 }
140
141 let binary_name = format!("vtcode{}", std::env::consts::EXE_SUFFIX);
142 for profile in ["debug", "release"] {
143 let candidate = workspace_target.join(profile).join(&binary_name);
144 if candidate.is_file() {
145 return candidate;
146 }
147 }
148
149 current_exe.to_path_buf()
150}
151
152pub fn background_record_id(agent_name: &str) -> String {
153 format!("background-{}", super::controller_helpers::sanitize_component(agent_name))
154}
155
156pub fn extract_tail_lines(content: &str, max_lines: usize) -> String {
159 let lines: Vec<_> = content.lines().collect();
160 let start = lines.len().saturating_sub(max_lines);
161 lines[start..].join("\n")
162}
163
164pub async fn load_archive_preview(path: &Path) -> Result<String> {
165 let listing = load_session_listing(path).await?;
166 Ok(extract_tail_lines(&listing.snapshot.transcript.join("\n"), SUBAGENT_PREVIEW_LINES))
167}
168
169async fn load_session_listing(path: &Path) -> Result<SessionListing> {
170 let raw = fs::read_to_string(path)
171 .await
172 .with_context(|| format!("Failed to read session archive {}", path.display()))?;
173 let snapshot: SessionSnapshot =
174 serde_json::from_str(&raw).with_context(|| format!("Failed to parse session archive {}", path.display()))?;
175 Ok(SessionListing { path: path.to_path_buf(), snapshot })
176}
177
178#[must_use]
179pub(crate) fn exec_session_is_running(session: &crate::tools::types::VTCodeExecSession) -> bool {
180 matches!(session.lifecycle_state, Some(crate::tools::types::VTCodeSessionLifecycleState::Running))
181}
182
183pub fn subagent_display_label(spec: &vtcode_config::SubagentSpec) -> String {
184 spec.nickname_candidates.first().cloned().unwrap_or_else(|| spec.name.clone())
185}
186
187#[cfg(test)]
188mod tests {
189 use super::{build_background_launch_spec, resolve_background_subagent_executable_for_workspace};
190 use std::fs;
191 use std::path::Path;
192 use tempfile::TempDir;
193
194 #[test]
195 fn prefers_workspace_built_binary_when_current_exe_is_external() {
196 let temp_dir = TempDir::new().expect("temp dir");
197 let workspace_root = temp_dir.path();
198 let candidate = workspace_root.join("target/debug/vtcode");
199 fs::create_dir_all(candidate.parent().expect("parent")).expect("mkdir");
200 fs::write(&candidate, b"binary").expect("write candidate");
201
202 let resolved =
203 resolve_background_subagent_executable_for_workspace(workspace_root, Path::new("/usr/local/bin/vtcode"));
204
205 assert_eq!(resolved, candidate);
206 }
207
208 #[test]
209 fn keeps_current_exe_when_already_running_workspace_binary() {
210 let temp_dir = TempDir::new().expect("temp dir");
211 let workspace_root = temp_dir.path();
212 let current = workspace_root.join("target/debug/vtcode");
213 fs::create_dir_all(current.parent().expect("parent")).expect("mkdir");
214 fs::write(¤t, b"binary").expect("write current");
215
216 let resolved = resolve_background_subagent_executable_for_workspace(workspace_root, ¤t);
217
218 assert_eq!(resolved, current);
219 }
220
221 #[test]
222 fn background_demo_launch_uses_direct_script_pipe_session() {
223 let temp_dir = TempDir::new().expect("temp dir");
224 let workspace_root = temp_dir.path();
225
226 let launch = build_background_launch_spec(
227 workspace_root,
228 "background-demo",
229 "parent",
230 "child",
231 "Report readiness once.",
232 Some(4),
233 None,
234 None,
235 )
236 .expect("background demo launch");
237
238 assert!(!launch.use_pty);
239 assert_eq!(
240 launch.command,
241 vec![
242 workspace_root
243 .join("scripts")
244 .join("demo-background-subagent.sh")
245 .to_string_lossy()
246 .into_owned()
247 ]
248 );
249 }
250}