Skip to main content

vtcode_core/core/agent/
bootstrap.rs

1//! Component builder and bootstrap utilities for the core agent.
2//!
3//! This module extracts the initialization logic from [`Agent`](super::core::Agent)
4//! so it can be reused by downstream consumers. The builder pattern makes it easy
5//! to override default components (for example when embedding VT Code in other
6//! applications or exposing a reduced open-source surface area) without relying
7//! on the binary crate's internal setup.
8
9use std::fs;
10use std::path::Path;
11use std::sync::Arc;
12use std::time::{Duration, SystemTime, UNIX_EPOCH};
13
14use anyhow::{Context, Result};
15
16use crate::config::models::Provider;
17use crate::config::types::{AgentConfig, SessionInfo};
18use crate::models_manager::ModelsManager;
19use crate::utils::error_messages::{ERR_CREATE_DIR, ERR_GET_METADATA};
20
21use crate::core::decision_tracker::DecisionTracker;
22use crate::core::error_recovery::ErrorRecoveryManager;
23use crate::ctx_err;
24use crate::llm::client::{AnyClient, ProviderClientAdapter};
25use crate::llm::factory::{ProviderConfig, create_provider_with_config, infer_provider_from_model};
26use crate::tools::ToolRegistry;
27use tracing::warn;
28
29/// Collection of dependencies required by the [`Agent`](super::core::Agent).
30///
31/// Consumers that want to reuse the agent loop can either construct this bundle
32/// directly with [`AgentComponentBuilder`] or provide their own specialized
33/// implementation when embedding VT Code.
34pub struct AgentComponentSet {
35    pub client: AnyClient,
36    pub tool_registry: Arc<ToolRegistry>,
37    pub decision_tracker: DecisionTracker,
38    pub error_recovery: ErrorRecoveryManager,
39    pub models_manager: Arc<ModelsManager>,
40
41    pub session_info: SessionInfo,
42}
43
44/// Builder for [`AgentComponentSet`].
45///
46/// The builder exposes hooks for overriding individual components which makes
47/// the agent easier to adapt for open-source scenarios or bespoke deployments.
48pub struct AgentComponentBuilder<'config> {
49    config: &'config AgentConfig,
50    client: Option<AnyClient>,
51    tool_registry: Option<Arc<ToolRegistry>>,
52    decision_tracker: Option<DecisionTracker>,
53    error_recovery: Option<ErrorRecoveryManager>,
54    models_manager: Option<Arc<ModelsManager>>,
55
56    session_info: Option<SessionInfo>,
57}
58
59impl<'config> AgentComponentBuilder<'config> {
60    /// Create a new builder scoped to the provided configuration.
61    pub fn new(config: &'config AgentConfig) -> Self {
62        Self {
63            config,
64            client: None,
65            tool_registry: None,
66            decision_tracker: None,
67            error_recovery: None,
68            models_manager: None,
69            session_info: None,
70        }
71    }
72
73    /// Override the LLM client instance.
74    pub fn with_client(mut self, client: AnyClient) -> Self {
75        self.client = Some(client);
76        self
77    }
78
79    /// Override the tool registry instance.
80    pub fn with_tool_registry(mut self, registry: Arc<ToolRegistry>) -> Self {
81        self.tool_registry = Some(registry);
82        self
83    }
84
85    /// Override the decision tracker instance.
86    pub fn with_decision_tracker(mut self, tracker: DecisionTracker) -> Self {
87        self.decision_tracker = Some(tracker);
88        self
89    }
90
91    /// Override the error recovery manager instance.
92    pub fn with_error_recovery(mut self, manager: ErrorRecoveryManager) -> Self {
93        self.error_recovery = Some(manager);
94        self
95    }
96
97    /// Override the models manager instance.
98    pub fn with_models_manager(mut self, manager: Arc<ModelsManager>) -> Self {
99        self.models_manager = Some(manager);
100        self
101    }
102
103    /// Override the session metadata.
104    pub fn with_session_info(mut self, session_info: SessionInfo) -> Self {
105        self.session_info = Some(session_info);
106        self
107    }
108
109    /// Build the component set, lazily constructing any missing dependencies.
110    pub async fn build(mut self) -> Result<AgentComponentSet> {
111        ensure_workspace_ready(&self.config.workspace)?;
112
113        let client = match self.client.take() {
114            Some(client) => client,
115            None => create_llm_client(self.config)?,
116        };
117
118        let session_info = match self.session_info.take() {
119            Some(info) => info,
120            None => create_session_info()
121                .context("Failed to initialize agent session metadata for bootstrap")?,
122        };
123
124        let tool_registry = match self.tool_registry {
125            Some(registry) => registry,
126            None => {
127                let registry = ToolRegistry::new(self.config.workspace.clone()).await;
128                registry.set_harness_session(session_info.session_id.clone());
129                Arc::new(registry)
130            }
131        };
132
133        // Prefer custom manager if provided, otherwise reuse global singleton.
134        // The global singleton is provider-agnostic; provider filtering happens at query time.
135        let models_manager = self.models_manager.take().unwrap_or_else(|| {
136            // Clone Arc from global - this is cheap since ModelsManager is behind LazyLock
137            Arc::new(ModelsManager::with_provider(
138                self.config
139                    .provider
140                    .parse::<Provider>()
141                    .ok()
142                    .unwrap_or_default(),
143            ))
144        });
145
146        let decision_tracker = self.decision_tracker.unwrap_or_default();
147
148        let error_recovery = self.error_recovery.unwrap_or_default();
149
150        Ok(AgentComponentSet {
151            client,
152            tool_registry,
153            decision_tracker,
154            error_recovery,
155            models_manager,
156
157            session_info,
158        })
159    }
160}
161
162fn create_llm_client(config: &AgentConfig) -> Result<AnyClient> {
163    let provider_name = if config.provider.trim().is_empty() {
164        infer_provider_from_model(&config.model)
165            .map(|provider| provider.to_string())
166            .ok_or_else(|| {
167                anyhow::anyhow!("Cannot determine provider for model: {}", config.model)
168            })?
169    } else {
170        config.provider.to_lowercase()
171    };
172
173    let provider = create_provider_with_config(
174        &provider_name,
175        ProviderConfig {
176            api_key: Some(config.api_key.clone()),
177            openai_chatgpt_auth: config.openai_chatgpt_auth.clone(),
178            copilot_auth: None,
179            base_url: None,
180            model: Some(config.model.clone()),
181            prompt_cache: Some(config.prompt_cache.clone()),
182            timeouts: None,
183            openai: None,
184            anthropic: None,
185            model_behavior: config.model_behavior.clone(),
186            workspace_root: Some(config.workspace.clone()),
187        },
188    )
189    .with_context(|| format!("Failed to initialize provider '{}'", provider_name))?;
190
191    Ok(Box::new(ProviderClientAdapter::new(
192        provider,
193        config.model.clone(),
194    )))
195}
196
197fn create_session_info() -> Result<SessionInfo> {
198    let now = SystemTime::now()
199        .duration_since(UNIX_EPOCH)
200        .map_err(|err| err.duration());
201
202    Ok(build_session_info(now))
203}
204
205fn build_session_info(duration: Result<Duration, Duration>) -> SessionInfo {
206    let (start_time, session_id) = match duration {
207        Ok(duration) => {
208            let secs = duration.as_secs();
209            (secs, format!("session_{}", secs))
210        }
211        Err(delta) => {
212            let fallback = delta.as_secs();
213            warn!(
214                fallback_seconds = fallback,
215                "System time is before UNIX epoch; using fallback session id"
216            );
217            (fallback, format!("session_fallback_{}", fallback))
218        }
219    };
220
221    SessionInfo {
222        session_id,
223        start_time,
224        total_turns: 0,
225        total_decisions: 0,
226        error_count: 0,
227    }
228}
229
230fn ensure_workspace_ready(workspace_root: &Path) -> Result<()> {
231    if workspace_root.exists() {
232        let metadata = fs::metadata(workspace_root)
233            .with_context(|| ctx_err!(ERR_GET_METADATA, workspace_root.display()))?;
234
235        anyhow::ensure!(
236            metadata.is_dir(),
237            "Workspace path is not a directory: {}",
238            workspace_root.display()
239        );
240    } else {
241        fs::create_dir_all(workspace_root)
242            .with_context(|| ctx_err!(ERR_CREATE_DIR, workspace_root.display()))?;
243    }
244
245    Ok(())
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::config::constants::models;
252    use crate::config::core::PromptCachingConfig;
253    use crate::config::models::Provider;
254    use crate::config::types::{ModelSelectionSource, ReasoningEffortLevel, UiSurfacePreference};
255    use crate::core::agent::snapshots::{
256        DEFAULT_CHECKPOINTS_ENABLED, DEFAULT_MAX_AGE_DAYS, DEFAULT_MAX_SNAPSHOTS,
257    };
258    use std::collections::BTreeMap;
259
260    #[tokio::test]
261    async fn builds_default_component_set() {
262        let temp_dir = tempfile::tempdir().expect("temp dir");
263        let agent_config = AgentConfig {
264            model: models::google::GEMINI_3_FLASH_PREVIEW.to_string(),
265            api_key: "test-api-key".to_owned(),
266            provider: Provider::Gemini.to_string(),
267            api_key_env: Provider::Gemini.default_api_key_env().to_string(),
268            workspace: temp_dir.path().to_path_buf(),
269            verbose: false,
270            theme: "default".to_owned(),
271            reasoning_effort: ReasoningEffortLevel::default(),
272            ui_surface: UiSurfacePreference::Inline,
273            prompt_cache: PromptCachingConfig::default(),
274            model_source: ModelSelectionSource::WorkspaceConfig,
275            custom_api_keys: BTreeMap::new(),
276            checkpointing_enabled: DEFAULT_CHECKPOINTS_ENABLED,
277            checkpointing_storage_dir: None,
278            checkpointing_max_snapshots: DEFAULT_MAX_SNAPSHOTS,
279            checkpointing_max_age_days: Some(DEFAULT_MAX_AGE_DAYS),
280            quiet: false,
281            max_conversation_turns: 1000,
282            model_behavior: None,
283            openai_chatgpt_auth: None,
284        };
285
286        let components = AgentComponentBuilder::new(&agent_config)
287            .build()
288            .await
289            .expect("component build succeeds");
290
291        assert!(components.session_info.session_id.starts_with("session_"));
292        assert_eq!(components.session_info.total_turns, 0);
293        assert!(!components.tool_registry.available_tools().await.is_empty());
294    }
295
296    #[tokio::test]
297    async fn allows_overriding_components() {
298        let temp_dir = tempfile::tempdir().expect("temp dir");
299        let agent_config = AgentConfig {
300            model: models::google::GEMINI_3_FLASH_PREVIEW.to_string(),
301            api_key: "test-api-key".to_owned(),
302            provider: Provider::Gemini.to_string(),
303            api_key_env: Provider::Gemini.default_api_key_env().to_string(),
304            workspace: temp_dir.path().to_path_buf(),
305            verbose: true,
306            theme: "custom".to_owned(),
307            reasoning_effort: ReasoningEffortLevel::High,
308            ui_surface: UiSurfacePreference::Alternate,
309            prompt_cache: PromptCachingConfig::default(),
310            model_source: ModelSelectionSource::WorkspaceConfig,
311            custom_api_keys: BTreeMap::new(),
312            checkpointing_enabled: DEFAULT_CHECKPOINTS_ENABLED,
313            checkpointing_storage_dir: None,
314            checkpointing_max_snapshots: DEFAULT_MAX_SNAPSHOTS,
315            checkpointing_max_age_days: Some(DEFAULT_MAX_AGE_DAYS),
316            quiet: false,
317            max_conversation_turns: 1000,
318            model_behavior: None,
319            openai_chatgpt_auth: None,
320        };
321
322        let custom_session = SessionInfo {
323            session_id: "session_custom".to_owned(),
324            start_time: 42,
325            total_turns: 1,
326            total_decisions: 2,
327            error_count: 3,
328        };
329
330        let registry = Arc::new(ToolRegistry::new(agent_config.workspace.clone()).await);
331
332        let components = AgentComponentBuilder::new(&agent_config)
333            .with_session_info(custom_session.clone())
334            .with_tool_registry(Arc::clone(&registry))
335            .build()
336            .await
337            .expect("component build succeeds with overrides");
338
339        assert_eq!(
340            components.session_info.session_id,
341            custom_session.session_id
342        );
343        assert_eq!(
344            components.session_info.start_time,
345            custom_session.start_time
346        );
347        assert_eq!(
348            Arc::as_ptr(&components.tool_registry),
349            Arc::as_ptr(&registry)
350        );
351    }
352
353    #[test]
354    fn session_info_uses_fallback_when_clock_is_before_epoch() {
355        let info = build_session_info(Err(Duration::from_secs(42)));
356        assert_eq!(info.session_id, "session_fallback_42");
357        assert_eq!(info.start_time, 42);
358        assert_eq!(info.total_turns, 0);
359    }
360
361    #[tokio::test]
362    async fn rejects_non_directory_workspace() {
363        let temp_dir = tempfile::tempdir().expect("temp dir");
364        let file_path = temp_dir.path().join("not_dir");
365        fs::write(&file_path, "not a dir").expect("write file");
366
367        let agent_config = AgentConfig {
368            workspace: file_path.clone(),
369            ..sample_config(temp_dir.path())
370        };
371
372        let result = AgentComponentBuilder::new(&agent_config).build().await;
373        assert!(result.is_err(), "expected workspace validation to fail");
374    }
375
376    fn sample_config(workspace: &Path) -> AgentConfig {
377        AgentConfig {
378            model: models::google::GEMINI_3_FLASH_PREVIEW.to_string(),
379            api_key: "test-api-key".to_owned(),
380            provider: Provider::Gemini.to_string(),
381            api_key_env: Provider::Gemini.default_api_key_env().to_string(),
382            workspace: workspace.to_path_buf(),
383            verbose: false,
384            theme: "default".to_owned(),
385            reasoning_effort: ReasoningEffortLevel::default(),
386            ui_surface: UiSurfacePreference::Inline,
387            prompt_cache: PromptCachingConfig::default(),
388            model_source: ModelSelectionSource::WorkspaceConfig,
389            custom_api_keys: BTreeMap::new(),
390            checkpointing_enabled: DEFAULT_CHECKPOINTS_ENABLED,
391            checkpointing_storage_dir: None,
392            checkpointing_max_snapshots: DEFAULT_MAX_SNAPSHOTS,
393            checkpointing_max_age_days: Some(DEFAULT_MAX_AGE_DAYS),
394            quiet: false,
395            max_conversation_turns: 1000,
396            model_behavior: None,
397            openai_chatgpt_auth: None,
398        }
399    }
400}