Skip to main content

vtcode_core/core/agent/
runner.rs

1//! Agent runner for executing individual agent instances
2
3use crate::config::VTCodeConfig;
4use crate::config::constants::tools;
5use crate::config::models::{ModelId, Provider};
6use crate::config::types::{ReasoningEffortLevel, VerbosityLevel};
7use crate::core::agent::events::EventSink;
8use crate::core::agent::features::FeatureSet;
9use crate::core::agent::session_config::ResolvedSessionConfig;
10use crate::core::agent::steering::SteeringMessage;
11use crate::core::threads::{ThreadBootstrap, ThreadRuntimeHandle, build_thread_archive_metadata};
12
13/// Settings for the agent runner
14#[derive(Clone, Default)]
15pub struct RunnerSettings {
16    /// Reasoning effort level for the agent
17    pub reasoning_effort: Option<ReasoningEffortLevel>,
18    /// Verbosity level for output text
19    pub verbosity: Option<VerbosityLevel>,
20}
21
22use crate::core::agent::types::AgentType;
23use crate::core::loop_detector::LoopDetector;
24use crate::exec::events::ThreadEvent;
25use crate::llm::AnyClient;
26use crate::llm::client::ProviderClientAdapter;
27use crate::llm::factory::{ProviderConfig, create_provider_with_config, infer_provider_from_model};
28use crate::llm::provider as uni_provider;
29use crate::primary_agent::ActivePrimaryAgent;
30use crate::prompts::PromptContext;
31use crate::tools::ToolRegistry;
32
33use anyhow::{Context, Result, anyhow};
34use parking_lot::{Mutex, RwLock};
35use std::path::PathBuf;
36use std::str::FromStr;
37use std::sync::Arc;
38use tracing::{info, warn};
39use vtcode_config::auth::OpenAIChatGptAuthHandle;
40
41mod config_helpers;
42mod constants;
43mod continuation;
44mod execute;
45mod execute_checks;
46mod helpers;
47mod orchestration;
48mod output;
49pub mod prompt_alignment;
50mod retry;
51mod summarize;
52mod summary;
53mod telemetry;
54mod tool_access;
55mod tool_args;
56mod tool_exec;
57mod tool_execution_guard;
58mod types;
59mod validation;
60mod workspace_detection;
61
62#[cfg(test)]
63mod tests;
64
65type ToolArgTransform = Arc<dyn Fn(&str, serde_json::Value) -> serde_json::Value + Send + Sync>;
66
67/// Individual agent runner for executing specialized agent tasks
68pub struct AgentRunner {
69    /// Agent type and configuration
70    agent_type: AgentType,
71    /// LLM client for this agent
72    client: AnyClient,
73    /// Unified provider client (OpenAI/Anthropic/Gemini) for tool-calling
74    provider_client: Box<dyn uni_provider::LLMProvider>,
75    /// Tool registry with restricted access
76    tool_registry: ToolRegistry,
77    /// System prompt content
78    system_prompt: String,
79    /// Session information
80    session_id: String,
81    /// Initial archived history used to seed the first task on this runner.
82    bootstrap_messages: Vec<crate::llm::provider::Message>,
83    /// Workspace path
84    _workspace: PathBuf,
85    /// Frozen session-scoped configuration snapshot
86    session_config: Arc<ResolvedSessionConfig>,
87    /// Model identifier
88    model: String,
89    /// API key (for provider client construction in future flows)
90    _api_key: String,
91    /// Reasoning effort level for models that support it
92    reasoning_effort: Option<ReasoningEffortLevel>,
93    /// Verbosity level for output text
94    verbosity: Option<VerbosityLevel>,
95    /// Suppress stdout output when emitting structured events
96    quiet: bool,
97    /// Optional sink for streaming structured events
98    event_sink: Option<EventSink>,
99    /// Shared thread runtime state for history/event ownership
100    thread_handle: ThreadRuntimeHandle,
101    /// Maximum number of autonomous turns before halting
102    max_turns: usize,
103    /// Loop detector to prevent infinite exploration
104    loop_detector: Mutex<LoopDetector>,
105    /// Cached shell policy patterns to avoid recompilation
106
107    /// Receiver for steering messages (e.g., stop, pause)
108    steering_receiver: Mutex<Option<tokio::sync::mpsc::UnboundedReceiver<SteeringMessage>>>,
109    /// Optional restricted tool definitions used instead of the default registry projection.
110    tool_definitions_override: RwLock<Option<Vec<uni_provider::ToolDefinition>>>,
111    /// Optional argument transformer applied before tool validation/execution.
112    tool_arg_transform: Option<ToolArgTransform>,
113    /// Active primary-agent policy to intersect with runner tools.
114    active_primary_agent: Option<ActivePrimaryAgent>,
115}
116
117impl AgentRunner {
118    /// Get the selected model for the current turn.
119    fn get_selected_model(&self) -> String {
120        self.model.clone()
121    }
122
123    fn runner_println(&self, args: std::fmt::Arguments) {
124        if !self.quiet {
125            println!("{args}");
126        }
127    }
128
129    /// Create a new agent runner.
130    pub async fn new(
131        agent_type: AgentType,
132        model: ModelId,
133        api_key: String,
134        workspace: PathBuf,
135        session_id: String,
136        settings: RunnerSettings,
137        steering_receiver: Option<tokio::sync::mpsc::UnboundedReceiver<SteeringMessage>>,
138    ) -> Result<Self> {
139        Self::new_internal(
140            agent_type,
141            model,
142            api_key,
143            workspace,
144            session_id,
145            settings,
146            steering_receiver,
147            ThreadBootstrap::new(None),
148            None,
149            None,
150        )
151        .await
152    }
153
154    /// Create an agent runner with prebuilt thread bootstrap, config, and auth.
155    #[allow(clippy::too_many_arguments)]
156    pub async fn new_with_bootstrap(
157        agent_type: AgentType,
158        model: ModelId,
159        api_key: String,
160        workspace: PathBuf,
161        session_id: String,
162        settings: RunnerSettings,
163        steering_receiver: Option<tokio::sync::mpsc::UnboundedReceiver<SteeringMessage>>,
164        bootstrap: ThreadBootstrap,
165        vt_cfg: Option<VTCodeConfig>,
166        openai_chatgpt_auth: Option<OpenAIChatGptAuthHandle>,
167    ) -> Result<Self> {
168        Self::new_internal(
169            agent_type,
170            model,
171            api_key,
172            workspace,
173            session_id,
174            settings,
175            steering_receiver,
176            bootstrap,
177            vt_cfg,
178            openai_chatgpt_auth,
179        )
180        .await
181    }
182
183    #[expect(clippy::too_many_arguments)]
184    async fn new_internal(
185        agent_type: AgentType,
186        model: ModelId,
187        api_key: String,
188        workspace: PathBuf,
189        session_id: String,
190        settings: RunnerSettings,
191        steering_receiver: Option<tokio::sync::mpsc::UnboundedReceiver<SteeringMessage>>,
192        bootstrap: ThreadBootstrap,
193        vt_cfg: Option<VTCodeConfig>,
194        openai_chatgpt_auth: Option<OpenAIChatGptAuthHandle>,
195    ) -> Result<Self> {
196        // Load configuration once to seed system prompt and runtime policies
197        let session_config = if let Some(vt_cfg) = vt_cfg {
198            ResolvedSessionConfig::from_config(vt_cfg)
199        } else {
200            match ResolvedSessionConfig::load_from_workspace(&workspace) {
201                Ok(session_config) => session_config,
202                Err(err) => {
203                    warn!(
204                        "Failed to load vtcode configuration for system prompt composition: {err:#}"
205                    );
206                    ResolvedSessionConfig::from_config(VTCodeConfig::default())
207                }
208            }
209        };
210        let session_config = Arc::new(session_config);
211        let provider_name = {
212            let configured = session_config.effective().agent.provider.trim();
213            if configured.is_empty() {
214                infer_provider_from_model(model.as_str())
215                    .map(|provider| provider.to_string())
216                    .ok_or_else(|| anyhow!("Failed to determine provider for model {}", model))?
217            } else {
218                configured.to_lowercase()
219            }
220        };
221        let provider_config = ProviderConfig {
222            api_key: Some(api_key.clone()),
223            openai_chatgpt_auth: openai_chatgpt_auth.clone(),
224            copilot_auth: Some(session_config.effective().auth.copilot.clone()),
225            base_url: None,
226            model: Some(model.to_string()),
227            prompt_cache: Some(session_config.effective().prompt_cache.clone()),
228            timeouts: None,
229            openai: Some(session_config.effective().provider.openai.clone()),
230            anthropic: Some(session_config.effective().provider.anthropic.clone()),
231            model_behavior: Some(session_config.effective().model.clone()),
232            workspace_root: Some(workspace.clone()),
233        };
234
235        let client: AnyClient = Box::new(ProviderClientAdapter::new(
236            create_provider_with_config(&provider_name, provider_config.clone())
237                .with_context(|| "Failed to create client provider")?,
238            model.to_string(),
239        ));
240        let provider_client = create_provider_with_config(&provider_name, provider_config)
241            .with_context(|| "Failed to create provider client")?;
242        if std::env::var_os("VTCODE_DEBUG_PROVIDER").is_some() {
243            eprintln!(
244                "vtcode-debug: runner provider={} client_provider={} model={}",
245                provider_name,
246                provider_client.name(),
247                model
248            );
249        }
250        let max_repeated_tool_calls = session_config
251            .effective()
252            .tools
253            .max_repeated_tool_calls
254            .max(1);
255        let deferred_tool_policy = crate::tools::handlers::deferred_tool_policy_for_runtime(
256            crate::llm::factory::infer_provider(
257                Some(&session_config.effective().agent.provider),
258                model.as_str(),
259            ),
260            provider_client.supports_responses_compaction(model.as_str()),
261            Some(session_config.effective()),
262        );
263        let anthropic_native_memory_enabled =
264            crate::tools::handlers::anthropic_native_memory_enabled_for_runtime(
265                Provider::from_str(provider_client.name()).ok(),
266                model.as_str(),
267                Some(session_config.effective()),
268            );
269        let tool_registry = ToolRegistry::new(workspace.clone()).await;
270        tool_registry.set_harness_session(session_id.clone());
271        tool_registry.set_agent_type(agent_type.to_string());
272        tool_registry.initialize_async().await?;
273        if let Err(err) = tool_registry
274            .apply_session_runtime_config(
275                &session_config.effective().commands,
276                &session_config.effective().permissions,
277                &session_config.effective().sandbox,
278                &session_config.effective().timeouts,
279                &session_config.effective().tools,
280            )
281            .await
282        {
283            warn!("Failed to apply tool policies from config: {}", err);
284        }
285        if session_config.effective().mcp.enabled {
286            if let Err(err) = crate::mcp::validate_mcp_config(&session_config.effective().mcp) {
287                warn!("MCP configuration validation error: {err}");
288            }
289            info!("Deferring MCP client initialization to on-demand activation");
290        }
291        if session_config.effective().context.dynamic.enabled
292            && let Err(err) = crate::context::initialize_dynamic_context(
293                &workspace,
294                &session_config.effective().context.dynamic,
295            )
296            .await
297        {
298            warn!("Failed to initialize dynamic context directories: {}", err);
299        }
300        let available_tools = tool_registry
301            .model_tools(crate::tools::handlers::SessionToolsConfig {
302                surface: crate::tools::handlers::SessionSurface::AgentRunner,
303                capability_level: crate::config::types::CapabilityLevel::CodeSearch,
304                documentation_mode: session_config.effective().agent.tool_documentation_mode,
305                planning_active: tool_registry.is_planning_active(),
306                request_user_input_enabled: false,
307                model_capabilities: crate::tools::handlers::ToolModelCapabilities::for_model_name(
308                    model.as_str(),
309                ),
310                deferred_tool_policy,
311                anthropic_native_memory_enabled,
312            })
313            .await
314            .into_iter()
315            .map(|tool| tool.function_name().to_string())
316            .collect::<Vec<_>>();
317        let mut prompt_context = PromptContext::from_workspace_tools(&workspace, available_tools);
318        prompt_context.load_available_skills();
319        let system_prompt = helpers::compose_system_prompt_with_appendix(
320            workspace.as_path(),
321            session_config.effective(),
322            &prompt_context,
323        )
324        .await?;
325        let loop_detector = LoopDetector::with_max_repeated_calls(max_repeated_tool_calls);
326        let bootstrap_messages = bootstrap.messages.clone();
327        let mut bootstrap = bootstrap;
328        if bootstrap.metadata.is_none() {
329            bootstrap.metadata = Some(build_thread_archive_metadata(
330                workspace.as_path(),
331                model.as_str(),
332                &session_config.effective().agent.provider,
333                &session_config.effective().agent.theme,
334                settings
335                    .reasoning_effort
336                    .unwrap_or(session_config.effective().agent.reasoning_effort)
337                    .as_str(),
338            ));
339        }
340        let thread_handle = crate::core::threads::ThreadManager::new()
341            .start_thread_with_identifier(session_id.clone(), bootstrap);
342        let max_turns = session_config
343            .effective()
344            .automation
345            .full_auto
346            .max_turns
347            .max(1);
348
349        Ok(Self {
350            agent_type,
351            client,
352            provider_client,
353            tool_registry,
354            system_prompt,
355            session_id,
356            bootstrap_messages,
357            _workspace: workspace,
358            session_config,
359            model: model.to_string(),
360            _api_key: api_key,
361            reasoning_effort: settings.reasoning_effort,
362            verbosity: settings.verbosity,
363            quiet: false,
364            event_sink: None,
365            thread_handle,
366            max_turns,
367            loop_detector: Mutex::new(loop_detector),
368            steering_receiver: Mutex::new(steering_receiver),
369            tool_definitions_override: RwLock::new(None),
370            tool_arg_transform: None,
371            active_primary_agent: None,
372        })
373    }
374
375    /// Enable or disable console output for this runner.
376    pub fn set_quiet(&mut self, quiet: bool) {
377        self.quiet = quiet;
378    }
379
380    /// Snapshot the runner-owned conversation messages for archive persistence.
381    pub fn session_messages(&self) -> Vec<crate::llm::provider::Message> {
382        self.thread_handle.messages()
383    }
384
385    /// Clone the underlying thread handle so callers can capture snapshots.
386    pub fn thread_handle(&self) -> ThreadRuntimeHandle {
387        self.thread_handle.clone()
388    }
389
390    /// Enable read-only planning workflow for the underlying tool registry.
391    pub fn enable_planning(&self) {
392        self.tool_registry.enable_planning();
393    }
394
395    pub fn disable_planning(&self) {
396        self.tool_registry.disable_planning();
397    }
398
399    /// Attach a callback that will be invoked for each structured event as it is recorded.
400    pub fn set_event_handler<F>(&mut self, handler: F)
401    where
402        F: FnMut(&ThreadEvent) + Send + 'static,
403    {
404        self.event_sink = Some(Arc::new(Mutex::new(Box::new(handler))));
405    }
406
407    /// Remove any previously registered structured event callback.
408    pub fn clear_event_handler(&mut self) {
409        self.event_sink = None;
410    }
411
412    /// Override the composed system prompt for downstream embedders.
413    pub fn set_system_prompt(&mut self, system_prompt: impl Into<String>) {
414        self.system_prompt = system_prompt.into();
415    }
416
417    /// Clone the underlying tool registry so embedders can register custom tools.
418    pub fn tool_registry(&self) -> ToolRegistry {
419        self.tool_registry.clone()
420    }
421
422    pub fn set_tool_definitions_override(
423        &mut self,
424        definitions: Vec<uni_provider::ToolDefinition>,
425    ) {
426        *self.tool_definitions_override.write() = Some(definitions);
427    }
428
429    pub fn clear_tool_definitions_override(&mut self) {
430        *self.tool_definitions_override.write() = None;
431    }
432
433    pub fn set_tool_arg_transform(&mut self, transform: ToolArgTransform) {
434        self.tool_arg_transform = Some(transform);
435    }
436
437    pub fn clear_tool_arg_transform(&mut self) {
438        self.tool_arg_transform = None;
439    }
440
441    /// Apply active primary-agent tool and permission policy to this runner.
442    pub fn set_active_primary_agent(&mut self, active_primary_agent: ActivePrimaryAgent) {
443        self.active_primary_agent = Some(active_primary_agent);
444    }
445
446    /// Enable full-auto execution with the provided allow-list.
447    pub async fn enable_full_auto(&mut self, allowed_tools: &[String]) {
448        self.tool_registry
449            .enable_full_auto_permission(allowed_tools)
450            .await;
451    }
452
453    /// Restrict an allow-list to tools suitable for strict review-only runs.
454    pub async fn review_tool_allowlist(&self, allowed_tools: &[String]) -> Vec<String> {
455        let review_candidates = if allowed_tools
456            .iter()
457            .any(|tool| tool.trim() == tools::WILDCARD_ALL)
458        {
459            self.tool_registry.available_tools().await
460        } else {
461            allowed_tools.to_vec()
462        };
463
464        review_candidates
465            .iter()
466            .filter(|tool_name| {
467                let canonical = crate::tools::names::canonical_tool_name(tool_name);
468
469                !matches!(
470                    canonical,
471                    tools::REQUEST_USER_INPUT
472                        | tools::TASK_TRACKER
473                        | tools::START_PLANNING
474                        | tools::FINISH_PLANNING
475                ) && (canonical == tools::UNIFIED_FILE
476                    || !self.tool_registry.is_mutating_tool(tool_name))
477            })
478            .cloned()
479            .collect()
480    }
481
482    pub(crate) fn features(&self) -> FeatureSet {
483        self.session_config.features().clone()
484    }
485}