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-{}", sanitize_component(agent_name))
154}
155
156fn sanitize_component(value: &str) -> String {
157 value
158 .chars()
159 .map(|ch| {
160 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
161 ch
162 } else {
163 '-'
164 }
165 })
166 .collect::<String>()
167 .trim_matches('-')
168 .to_string()
169}
170
171pub fn extract_tail_lines(content: &str, max_lines: usize) -> String {
174 let lines: Vec<_> = content.lines().collect();
175 let start = lines.len().saturating_sub(max_lines);
176 lines[start..].join("\n")
177}
178
179pub async fn load_archive_preview(path: &Path) -> Result<String> {
180 let listing = load_session_listing(path).await?;
181 Ok(extract_tail_lines(&listing.snapshot.transcript.join("\n"), SUBAGENT_PREVIEW_LINES))
182}
183
184async fn load_session_listing(path: &Path) -> Result<SessionListing> {
185 let raw = fs::read_to_string(path)
186 .await
187 .with_context(|| format!("Failed to read session archive {}", path.display()))?;
188 let snapshot: SessionSnapshot =
189 serde_json::from_str(&raw).with_context(|| format!("Failed to parse session archive {}", path.display()))?;
190 Ok(SessionListing { path: path.to_path_buf(), snapshot })
191}
192
193#[must_use]
194pub(crate) fn exec_session_is_running(session: &crate::tools::types::VTCodeExecSession) -> bool {
195 matches!(session.lifecycle_state, Some(crate::tools::types::VTCodeSessionLifecycleState::Running))
196}
197
198pub fn subagent_display_label(spec: &vtcode_config::SubagentSpec) -> String {
199 spec.nickname_candidates.first().cloned().unwrap_or_else(|| spec.name.clone())
200}
201
202#[cfg(test)]
203mod tests {
204 use super::{build_background_launch_spec, resolve_background_subagent_executable_for_workspace};
205 use std::fs;
206 use std::path::Path;
207 use tempfile::TempDir;
208
209 #[test]
210 fn prefers_workspace_built_binary_when_current_exe_is_external() {
211 let temp_dir = TempDir::new().expect("temp dir");
212 let workspace_root = temp_dir.path();
213 let candidate = workspace_root.join("target/debug/vtcode");
214 fs::create_dir_all(candidate.parent().expect("parent")).expect("mkdir");
215 fs::write(&candidate, b"binary").expect("write candidate");
216
217 let resolved =
218 resolve_background_subagent_executable_for_workspace(workspace_root, Path::new("/usr/local/bin/vtcode"));
219
220 assert_eq!(resolved, candidate);
221 }
222
223 #[test]
224 fn keeps_current_exe_when_already_running_workspace_binary() {
225 let temp_dir = TempDir::new().expect("temp dir");
226 let workspace_root = temp_dir.path();
227 let current = workspace_root.join("target/debug/vtcode");
228 fs::create_dir_all(current.parent().expect("parent")).expect("mkdir");
229 fs::write(¤t, b"binary").expect("write current");
230
231 let resolved = resolve_background_subagent_executable_for_workspace(workspace_root, ¤t);
232
233 assert_eq!(resolved, current);
234 }
235
236 #[test]
237 fn background_demo_launch_uses_direct_script_pipe_session() {
238 let temp_dir = TempDir::new().expect("temp dir");
239 let workspace_root = temp_dir.path();
240
241 let launch = build_background_launch_spec(
242 workspace_root,
243 "background-demo",
244 "parent",
245 "child",
246 "Report readiness once.",
247 Some(4),
248 None,
249 None,
250 )
251 .expect("background demo launch");
252
253 assert!(!launch.use_pty);
254 assert_eq!(
255 launch.command,
256 vec![
257 workspace_root
258 .join("scripts")
259 .join("demo-background-subagent.sh")
260 .to_string_lossy()
261 .into_owned()
262 ]
263 );
264 }
265}