Skip to main content

vtcode_core/tools/registry/
builder.rs

1//! ToolRegistry construction helpers.
2
3use std::future::Future;
4use std::path::Path;
5use std::path::PathBuf;
6use std::sync::Arc;
7use std::sync::RwLock;
8
9use parking_lot::Mutex;
10
11use crate::config::{PersistentMemoryConfig, PtyConfig};
12use crate::core::memory_pool::MemoryPool;
13use crate::tool_policy::ToolPolicyManager;
14use crate::tools::handlers::PlanningWorkflowState;
15use crate::tools::output_spooler::{SpoolerConfig, ToolOutputSpooler};
16use crate::tools::safety_gateway::SafetyGateway;
17use vtcode_config::DynamicContextConfig;
18use vtcode_config::loader::ConfigManager;
19
20use super::ToolRegistry;
21use super::assembly::ToolAssembly;
22use super::circuit_breaker;
23use super::distributed::ToolConfigSnapshot;
24use super::execution_history::ToolExecutionHistory;
25use super::harness::HarnessContext;
26use super::inventory::ToolInventory;
27use super::pack::register_builtin_packs;
28use super::policy::ToolPolicyGateway;
29use super::pty;
30use super::resiliency::ResiliencyContext;
31use super::shell_policy::ShellPolicyChecker;
32use super::timeout::ToolTimeoutPolicy;
33
34fn spooler_config_from_dynamic_context(config: &DynamicContextConfig) -> SpoolerConfig {
35    SpoolerConfig {
36        enabled: config.enabled,
37        threshold_bytes: config.tool_output_threshold,
38        max_files: config.max_spooled_files,
39        max_age_secs: config.spool_max_age_secs,
40        include_file_reference: true,
41    }
42}
43
44#[derive(Debug, Default)]
45struct WorkspaceToolConfig {
46    tool: ToolConfigSnapshot,
47    spooler: SpoolerConfig,
48    persistent_memory: PersistentMemoryConfig,
49    persistent_memory_enabled: bool,
50}
51
52/// Load the config needed by tool registration in one parse. Falls back to
53/// defaults on any load/parse error so a malformed config never blocks tool
54/// registration.
55fn load_workspace_tool_config(workspace_root: &Path) -> WorkspaceToolConfig {
56    match ConfigManager::load_from_workspace(workspace_root) {
57        Ok(manager) => workspace_tool_config_from_loaded(manager.config()),
58        Err(err) => {
59            tracing::warn!(
60                workspace = %workspace_root.display(),
61                error = %err,
62                "Failed to load workspace config for tool registry; using defaults"
63            );
64            WorkspaceToolConfig::default()
65        }
66    }
67}
68
69fn workspace_tool_config_from_loaded(config: &vtcode_config::loader::VTCodeConfig) -> WorkspaceToolConfig {
70    let persistent_memory_enabled = config.persistent_memory_enabled();
71    let mut persistent_memory = config.agent.persistent_memory.clone();
72    persistent_memory.enabled = persistent_memory_enabled;
73    WorkspaceToolConfig {
74        tool: ToolConfigSnapshot {
75            web_search: config.tools.web_search.clone(),
76            web_fetch: config.tools.web_fetch.clone(),
77        },
78        spooler: spooler_config_from_dynamic_context(&config.context.dynamic),
79        persistent_memory,
80        persistent_memory_enabled,
81    }
82}
83
84impl ToolRegistry {
85    pub fn new(workspace_root: PathBuf) -> impl Future<Output = Self> {
86        Self::build(workspace_root, PtyConfig::default())
87    }
88
89    /// Build a registry reusing an already-loaded session config snapshot.
90    ///
91    /// Interactive startup already owns the merged `VTCodeConfig`; passing it
92    /// here avoids a second `ConfigManager::load_from_workspace` file read +
93    /// TOML parse on the first-paint path. Falls back to defaults for any
94    /// field the snapshot does not carry, matching `load_workspace_tool_config`
95    /// behavior on parse failure.
96    pub fn new_with_loaded_config(
97        workspace_root: PathBuf,
98        config: &vtcode_config::loader::VTCodeConfig,
99    ) -> impl Future<Output = Self> {
100        let snapshot = workspace_tool_config_from_loaded(config);
101        Self::build_with_snapshot(workspace_root, PtyConfig::default(), None, true, snapshot)
102    }
103
104    /// Build a first-paint registry: reuses the loaded session config snapshot
105    /// and skips workspace policy file I/O.
106    ///
107    /// Policy evaluation fails open to tool metadata defaults until hydration
108    /// attaches the manager via `ensure_workspace_policy_manager`, which runs
109    /// before any tool executes.
110    pub fn new_for_first_paint_with_loaded_config(
111        workspace_root: PathBuf,
112        config: &vtcode_config::loader::VTCodeConfig,
113    ) -> impl Future<Output = Self> {
114        let snapshot = workspace_tool_config_from_loaded(config);
115        Self::build_with_snapshot(workspace_root, PtyConfig::default(), None, false, snapshot)
116    }
117
118    pub fn new_with_config(workspace_root: PathBuf, pty_config: PtyConfig) -> impl Future<Output = Self> {
119        Self::build(workspace_root, pty_config)
120    }
121
122    /// Build a registry for offline metadata without creating a workspace policy file.
123    pub fn new_for_schema(workspace_root: PathBuf) -> impl Future<Output = Self> {
124        Self::build_with_policy(workspace_root, PtyConfig::default(), None, false)
125    }
126
127    pub fn new_with_custom_policy(
128        workspace_root: PathBuf,
129        policy_manager: ToolPolicyManager,
130    ) -> impl Future<Output = Self> {
131        Self::build_with_policy(workspace_root, PtyConfig::default(), Some(policy_manager), true)
132    }
133
134    pub fn new_with_custom_policy_and_config(
135        workspace_root: PathBuf,
136        pty_config: PtyConfig,
137        policy_manager: ToolPolicyManager,
138    ) -> impl Future<Output = Self> {
139        Self::build_with_policy(workspace_root, pty_config, Some(policy_manager), true)
140    }
141
142    async fn build(workspace_root: PathBuf, pty_config: PtyConfig) -> Self {
143        Self::build_with_policy(workspace_root, pty_config, None, true).await
144    }
145
146    async fn build_with_policy(
147        workspace_root: PathBuf,
148        pty_config: PtyConfig,
149        policy_manager: Option<ToolPolicyManager>,
150        initialize_policy: bool,
151    ) -> Self {
152        // Load the user-config snapshot *before* constructing the inventory so
153        // `WebFetchTool`/`WebSearchTool` are built with the user's allow/block
154        // lists, cooldown, and session cap rather than defaulting out. The
155        // snapshot is passed explicitly to active packs; parse the workspace
156        // config once because both the web tools and output spooler consume it.
157        let config_workspace = workspace_root.clone();
158        let workspace_config =
159            match tokio::task::spawn_blocking(move || load_workspace_tool_config(&config_workspace)).await {
160                Ok(config) => config,
161                Err(error) => {
162                    tracing::warn!(%error, "Workspace tool config task panicked; using defaults");
163                    WorkspaceToolConfig::default()
164                }
165            };
166        Self::build_with_snapshot(workspace_root, pty_config, policy_manager, initialize_policy, workspace_config).await
167    }
168
169    async fn build_with_snapshot(
170        workspace_root: PathBuf,
171        pty_config: PtyConfig,
172        policy_manager: Option<ToolPolicyManager>,
173        initialize_policy: bool,
174        workspace_config: WorkspaceToolConfig,
175    ) -> Self {
176        let WorkspaceToolConfig {
177            tool: tool_config,
178            spooler: spooler_config,
179            persistent_memory: persistent_memory_config,
180            persistent_memory_enabled,
181        } = workspace_config;
182        let edited_file_monitor = Arc::new(crate::tools::edited_file_monitor::EditedFileMonitor::new());
183        let inventory = ToolInventory::new(workspace_root.clone(), Arc::clone(&edited_file_monitor));
184        let planning_workflow_state = PlanningWorkflowState::new(workspace_root.clone());
185
186        register_builtin_packs(&inventory, &planning_workflow_state, &tool_config).await;
187
188        let pty_sessions = pty::PtySessionManager::new(workspace_root.clone(), pty_config);
189        let exec_sessions =
190            crate::tools::exec_session::ExecSessionManager::new(workspace_root.clone(), pty_sessions.clone());
191
192        let policy_gateway = match (policy_manager, initialize_policy) {
193            (Some(pm), _) => ToolPolicyGateway::with_policy_manager(pm),
194            (None, true) => ToolPolicyGateway::new(&workspace_root).await,
195            (None, false) => ToolPolicyGateway::without_persistence(),
196        };
197
198        let optimization_config = vtcode_config::OptimizationConfig::default();
199        let metrics = Arc::new(crate::metrics::MetricsCollector::new());
200        let hot_cache_size = std::num::NonZeroUsize::new(optimization_config.tool_registry.hot_cache_size)
201            .unwrap_or(std::num::NonZeroUsize::MIN);
202        let output_spooler = Arc::new(ToolOutputSpooler::with_config(&workspace_root, spooler_config));
203        // Age-expired leftovers from prior sessions are pruned immediately;
204        // periodic cleanup only fires every N spools inside one session, so
205        // short sessions never reached the threshold and stale spools piled up
206        // across runs. The count budget stays on the in-session periodic path:
207        // pinning is per-process, so a startup count prune in this session
208        // could delete young spools a concurrent session still reads.
209        if let Err(error) = output_spooler.cleanup_expired_files().await {
210            tracing::debug!(%error, "startup spool prune failed");
211        }
212
213        // Pre-allocate FxHashMaps with expected capacity for typical MCP tool sets.
214        // Most sessions register 10-50 MCP tools; start with room for 32 to
215        // avoid rehashing during initial discovery without wasting memory.
216        let mcp_tool_index = rustc_hash::FxHashMap::with_capacity_and_hasher(32, rustc_hash::FxBuildHasher);
217        let mcp_reverse_index = rustc_hash::FxHashMap::with_capacity_and_hasher(32, rustc_hash::FxBuildHasher);
218
219        let registry = Self {
220            inventory,
221            persistent_memory_config: Arc::new(persistent_memory_config),
222            persistent_memory_enabled,
223            edited_file_monitor,
224            policy_gateway: Arc::new(policy_gateway),
225            pty_sessions,
226            exec_sessions,
227            mcp_client: Arc::new(parking_lot::RwLock::new(None)),
228            mcp_tool_index: Arc::new(tokio::sync::RwLock::new(mcp_tool_index)),
229            mcp_reverse_index: Arc::new(tokio::sync::RwLock::new(mcp_reverse_index)),
230            timeout_policy: Arc::new(parking_lot::RwLock::new(ToolTimeoutPolicy::default())),
231            execution_history: ToolExecutionHistory::with_workspace_root(100, workspace_root.clone()),
232            patch_recovery_reads: Arc::new(Mutex::new(super::patch_recovery::PatchRecoveryReads::default())),
233            harness_context: HarnessContext::default(),
234            resiliency: Arc::new(Mutex::new(ResiliencyContext::default())),
235            mcp_circuit_breaker: Arc::new(circuit_breaker::McpCircuitBreaker::with_metrics(metrics.clone())),
236            shared_circuit_breaker: Arc::new(RwLock::new(None)),
237            initialized: Arc::new(std::sync::atomic::AtomicBool::new(false)),
238            tool_call_counter: Arc::new(std::sync::atomic::AtomicU64::new(0)),
239            pty_poll_counter: Arc::new(std::sync::atomic::AtomicU64::new(0)),
240            canonical_plans_dir: Arc::new(std::sync::OnceLock::new()),
241            metrics,
242            shell_policy: Arc::new(RwLock::new(ShellPolicyChecker::new())),
243            runtime_sandbox_config: Arc::new(RwLock::new(super::sandbox_facade::runtime_sandbox_config_default())),
244            agent_type: Arc::new(RwLock::new("unknown".to_owned())),
245            cached_available_tools: Arc::new(parking_lot::RwLock::new(None)),
246            active_tool_profile: Arc::new(RwLock::new(crate::config::ToolProfile::default())),
247            progress_callback: Arc::new(RwLock::new(None)),
248            active_pty_sessions: Arc::new(RwLock::new(None)),
249
250            memory_pool: Arc::new(MemoryPool::from_config(&optimization_config.memory_pool)),
251            hot_tool_cache: Arc::new(parking_lot::RwLock::new(lru::LruCache::new(hot_cache_size))),
252            optimization_config,
253            middleware: crate::tools::tool_middleware::MiddlewareChain::new(),
254
255            output_spooler,
256
257            planning_workflow_state,
258            planning_mode_policy_overrides: Arc::new(parking_lot::RwLock::new(None)),
259            safety_gateway: Arc::new(SafetyGateway::default()),
260            cgp_runtime_mode: Arc::new(RwLock::new(None)),
261            tool_assembly: Arc::new(RwLock::new(ToolAssembly::empty())),
262            tool_catalog_state: Arc::new(super::tool_catalog_facade::SessionToolCatalogState::new()),
263            subagent_controller: Arc::new(RwLock::new(None)),
264            matrix_worker: Arc::new(parking_lot::RwLock::new(None)),
265            matrix_coordinator: Arc::new(std::sync::atomic::AtomicBool::new(false)),
266            session_scheduler: Arc::new(tokio::sync::Mutex::new(crate::scheduler::SessionScheduler::new())),
267            session_model_tools: Arc::new(RwLock::new(None)),
268            self_ref: Arc::new(RwLock::new(None)),
269        };
270
271        registry.rebuild_tool_assembly().await;
272        registry.sync_policy_catalog().await;
273        registry.initialize_resiliency_trackers();
274        registry
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use tempfile::tempdir;
282
283    #[tokio::test]
284    async fn tool_registry_uses_workspace_dynamic_context_for_output_spooler() {
285        let temp = tempdir().unwrap();
286        std::fs::write(
287            temp.path().join("vtcode.toml"),
288            r#"[context.dynamic]
289enabled = true
290tool_output_threshold = 4096
291max_spooled_files = 7
292spool_max_age_secs = 12
293
294[workspace]
295use_root_config = true
296"#,
297        )
298        .unwrap();
299
300        let registry = ToolRegistry::new(temp.path().to_path_buf()).await;
301        let config = registry.output_spooler().config();
302
303        assert!(config.enabled);
304        assert_eq!(config.threshold_bytes, 4096);
305        assert_eq!(config.max_files, 7);
306        assert_eq!(config.max_age_secs, 12);
307    }
308
309    #[tokio::test]
310    async fn tool_registry_falls_back_to_default_spooler_config_on_invalid_workspace_config() {
311        let temp = tempdir().unwrap();
312        std::fs::write(
313            temp.path().join("vtcode.toml"),
314            r#"[context.dynamic]
315tool_output_threshold = "oops"
316"#,
317        )
318        .unwrap();
319
320        let registry = ToolRegistry::new(temp.path().to_path_buf()).await;
321        let config = registry.output_spooler().config();
322
323        assert!(config.enabled);
324        assert_eq!(config.threshold_bytes, DynamicContextConfig::default().tool_output_threshold);
325        assert_eq!(config.max_files, SpoolerConfig::default().max_files);
326        assert_eq!(config.max_age_secs, SpoolerConfig::default().max_age_secs);
327    }
328
329    #[tokio::test]
330    async fn tool_registry_captures_effective_memory_config() {
331        let temp = tempdir().expect("workspace");
332        std::fs::write(
333            temp.path().join("vtcode.toml"),
334            "[features]\nmemories = true\n\n[agent.persistent_memory]\nenabled = true\n\n[workspace]\nuse_root_config = true\n",
335        )
336        .expect("workspace config");
337
338        let snapshot = load_workspace_tool_config(temp.path());
339        assert!(snapshot.persistent_memory_enabled);
340        assert!(snapshot.persistent_memory.enabled);
341
342        let registry = ToolRegistry::new(temp.path().to_path_buf()).await;
343        assert!(registry.persistent_memory_enabled);
344        assert!(registry.persistent_memory_config.enabled);
345    }
346
347    #[tokio::test]
348    async fn schema_registry_does_not_create_workspace_policy() {
349        let temp = tempdir().expect("workspace");
350
351        let _registry = ToolRegistry::new_for_schema(temp.path().to_path_buf()).await;
352
353        assert!(!temp.path().join(".vtcode").exists());
354    }
355
356    #[tokio::test]
357    async fn loaded_config_registry_reuses_snapshot_without_workspace_parse() {
358        // Asymmetric: workspace file says 4096/7/12, loaded snapshot says
359        // 8192/3/99. The registry must reflect the snapshot, proving the
360        // first-paint path does not pay a second workspace TOML parse.
361        let temp = tempdir().expect("workspace");
362        std::fs::write(
363            temp.path().join("vtcode.toml"),
364            r#"[context.dynamic]
365enabled = true
366tool_output_threshold = 4096
367max_spooled_files = 7
368spool_max_age_secs = 12
369
370[workspace]
371use_root_config = true
372"#,
373        )
374        .expect("workspace config");
375
376        let mut loaded = vtcode_config::loader::VTCodeConfig::default();
377        loaded.context.dynamic.enabled = true;
378        loaded.context.dynamic.tool_output_threshold = 8192;
379        loaded.context.dynamic.max_spooled_files = 3;
380        loaded.context.dynamic.spool_max_age_secs = 99;
381
382        let registry = ToolRegistry::new_with_loaded_config(temp.path().to_path_buf(), &loaded).await;
383        let config = registry.output_spooler().config();
384
385        assert!(config.enabled);
386        assert_eq!(config.threshold_bytes, 8192);
387        assert_eq!(config.max_files, 3);
388        assert_eq!(config.max_age_secs, 99);
389    }
390
391    #[tokio::test]
392    async fn first_paint_registry_defers_policy_manager_until_ensure() {
393        let temp = tempdir().expect("workspace");
394        let loaded = vtcode_config::loader::VTCodeConfig::default();
395
396        let registry = ToolRegistry::new_for_first_paint_with_loaded_config(temp.path().to_path_buf(), &loaded).await;
397        assert!(!registry.has_policy_manager().await, "first-paint registry must skip policy file I/O");
398
399        registry.ensure_workspace_policy_manager(temp.path()).await;
400        assert!(
401            registry.has_policy_manager().await,
402            "hydration must attach the workspace policy manager before any tool runs"
403        );
404    }
405}