Skip to main content

vtcode_core/subagents/
background.rs

1use 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
18// ─── Background State Persistence ──────────────────────────────────────────
19
20fn 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
53// ─── Background Command Building ───────────────────────────────────────────
54
55pub(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
171// ─── Preview Utilities ─────────────────────────────────────────────────────
172
173pub 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(&current, b"binary").expect("write current");
230
231        let resolved = resolve_background_subagent_executable_for_workspace(workspace_root, &current);
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}