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