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};
12use std::path::Path;
13
14/// Settings for the agent runner
15#[derive(Clone, Default)]
16pub struct RunnerSettings {
17    /// Reasoning effort level for the agent
18    pub reasoning_effort: Option<ReasoningEffortLevel>,
19    /// Verbosity level for output text
20    pub verbosity: Option<VerbosityLevel>,
21}
22
23use crate::core::agent::types::AgentType;
24use crate::core::loop_detector::LoopDetector;
25use crate::exec::events::ThreadEvent;
26use crate::llm::AnyClient;
27use crate::llm::client::ProviderClientAdapter;
28use crate::llm::factory::{ProviderConfig, create_provider_with_config, infer_provider_from_model};
29use crate::llm::provider as uni_provider;
30use crate::primary_agent::ActivePrimaryAgent;
31use crate::prompts::PromptContext;
32use crate::tools::ToolRegistry;
33
34use anyhow::{Context, Result, anyhow};
35use parking_lot::{Mutex, RwLock};
36use std::path::PathBuf;
37use std::str::FromStr;
38use std::sync::Arc;
39use tracing::{info, warn};
40use vtcode_config::auth::OpenAIChatGptAuthHandle;
41
42mod config_helpers;
43mod constants;
44mod continuation;
45mod contract_render;
46mod escalation;
47mod evaluator_types;
48mod execute;
49mod execute_checks;
50mod execute_helpers;
51mod helpers;
52mod orchestration;
53mod output;
54mod planner_helpers;
55mod planner_types;
56pub mod prompt_alignment;
57mod replan_helpers;
58mod retry;
59mod summarize;
60mod summary;
61mod task_setup;
62mod telemetry;
63mod tool_access;
64mod tool_args;
65mod tool_dispatch_common;
66mod tool_exec;
67mod tool_execution_guard;
68mod tool_rejection;
69mod tool_types;
70mod types;
71mod validation;
72mod verification;
73mod workspace_detection;
74
75/// Attach an eagerly initialized MCP client to the runner's tool registry.
76///
77/// Exec and other non-interactive runner sessions do not drive the async MCP
78/// manager, so MCP tools would otherwise remain invisible for the whole run.
79async fn attach_mcp_client(
80    tool_registry: &ToolRegistry,
81    session_config: &ResolvedSessionConfig,
82    workspace: &Path,
83) -> Result<()> {
84    let config = session_config.effective();
85    let sandbox_context = if config.sandbox.enabled
86        && !matches!(config.sandbox.default_policy, vtcode_config::SandboxPolicy::DangerFullAccess)
87    {
88        let policy = crate::tools::registry::sandbox_policy_from_runtime_config(&config.sandbox, workspace)?;
89        Some(crate::mcp::McpSandboxContext::new(policy, workspace))
90    } else {
91        None
92    };
93
94    let mut client = crate::mcp::McpClient::with_sandbox_context(config.mcp.clone(), sandbox_context);
95    let startup_timeout = std::time::Duration::from_secs(config.mcp.startup_timeout_seconds.unwrap_or(30));
96    let result = tokio::time::timeout(startup_timeout, client.initialize()).await;
97    match result {
98        Ok(Ok(())) => {}
99        Ok(Err(err)) => return Err(err).context("MCP client initialization failed"),
100        Err(_) => anyhow::bail!("MCP client initialization timed out after {} seconds", startup_timeout.as_secs()),
101    }
102
103    let client = Arc::new(client);
104    tool_registry.set_mcp_client(client).await;
105    if let Err(err) = tool_registry.refresh_mcp_tools().await {
106        warn!("Failed to refresh MCP tools after attach: {err:#}");
107    }
108    info!("MCP client attached to runner tool registry");
109    Ok(())
110}
111
112#[cfg(test)]
113mod tests;
114
115type ToolArgTransform = Arc<dyn Fn(&str, serde_json::Value) -> serde_json::Value + Send + Sync>;
116
117/// Individual agent runner for executing specialized agent tasks
118pub struct AgentRunner {
119    /// Agent type and configuration
120    agent_type: AgentType,
121    /// LLM client for this agent
122    client: AnyClient,
123    /// Unified provider client (OpenAI/Anthropic/Gemini) for tool-calling
124    provider_client: Box<dyn uni_provider::LLMProvider>,
125    /// Tool registry with restricted access
126    tool_registry: ToolRegistry,
127    /// System prompt content
128    system_prompt: String,
129    /// Token-budget report for `system_prompt`, computed at session
130    /// construction time (or recomputed by `set_system_prompt`). Reused by
131    /// `compose_task_system_prompt` for non-simple tasks so the budget
132    /// warning and preflight check stay accurate without recomposing the
133    /// prompt on every turn.
134    system_prompt_report: crate::prompts::system::SystemPromptReport,
135    /// Session information
136    session_id: String,
137    /// Initial archived history used to seed the first task on this runner.
138    bootstrap_messages: Vec<crate::llm::provider::Message>,
139    /// Workspace path
140    _workspace: PathBuf,
141    /// Frozen session-scoped configuration snapshot
142    session_config: Arc<ResolvedSessionConfig>,
143    /// Tool catalogue configuration used for this runner's model surface.
144    session_tools_config: crate::tools::handlers::SessionToolsConfig,
145    /// Model identifier
146    model: String,
147    /// API key (for provider client construction in future flows)
148    _api_key: String,
149    /// Reasoning effort level for models that support it
150    reasoning_effort: Option<ReasoningEffortLevel>,
151    /// Verbosity level for output text
152    verbosity: Option<VerbosityLevel>,
153    /// Suppress stdout output when emitting structured events
154    quiet: bool,
155    /// Optional sink for streaming structured events
156    event_sink: Option<EventSink>,
157    /// Shared thread runtime state for history/event ownership
158    thread_handle: ThreadRuntimeHandle,
159    /// Maximum number of autonomous turns before halting
160    max_turns: usize,
161    /// Loop detector to prevent infinite exploration
162    loop_detector: Mutex<LoopDetector>,
163    /// Cached shell policy patterns to avoid recompilation
164
165    /// Receiver for steering messages (e.g., stop, pause)
166    steering_receiver: Mutex<Option<tokio::sync::mpsc::UnboundedReceiver<SteeringMessage>>>,
167    /// Optional restricted tool definitions used instead of the default registry projection.
168    tool_definitions_override: RwLock<Option<Vec<uni_provider::ToolDefinition>>>,
169    /// Optional argument transformer applied before tool validation/execution.
170    tool_arg_transform: Option<ToolArgTransform>,
171    local_tools_only: bool,
172    /// Active primary-agent policy to intersect with runner tools.
173    active_primary_agent: Option<ActivePrimaryAgent>,
174}
175
176impl AgentRunner {
177    /// Get the selected model for the current turn.
178    fn get_selected_model(&self) -> String {
179        self.model.clone()
180    }
181
182    fn runner_println(&self, args: std::fmt::Arguments) {
183        if !self.quiet {
184            println!("{args}");
185        }
186    }
187
188    /// Create a new agent runner.
189    pub async fn new(
190        agent_type: AgentType,
191        model: ModelId,
192        api_key: String,
193        workspace: PathBuf,
194        session_id: String,
195        settings: RunnerSettings,
196        steering_receiver: Option<tokio::sync::mpsc::UnboundedReceiver<SteeringMessage>>,
197    ) -> Result<Self> {
198        Box::pin(Self::new_internal(
199            agent_type,
200            model,
201            api_key,
202            workspace,
203            session_id,
204            settings,
205            steering_receiver,
206            ThreadBootstrap::new(None),
207            None,
208            None,
209        ))
210        .await
211    }
212
213    /// Create an agent runner with prebuilt thread bootstrap, config, and auth.
214    #[allow(
215        clippy::too_many_arguments,
216        reason = "Intentional compatibility, platform, or test-only suppression."
217    )]
218    pub async fn new_with_bootstrap(
219        agent_type: AgentType,
220        model: ModelId,
221        api_key: String,
222        workspace: PathBuf,
223        session_id: String,
224        settings: RunnerSettings,
225        steering_receiver: Option<tokio::sync::mpsc::UnboundedReceiver<SteeringMessage>>,
226        bootstrap: ThreadBootstrap,
227        vt_cfg: Option<VTCodeConfig>,
228        openai_chatgpt_auth: Option<OpenAIChatGptAuthHandle>,
229    ) -> Result<Self> {
230        Box::pin(Self::new_internal(
231            agent_type,
232            model,
233            api_key,
234            workspace,
235            session_id,
236            settings,
237            steering_receiver,
238            bootstrap,
239            vt_cfg,
240            openai_chatgpt_auth,
241        ))
242        .await
243    }
244
245    #[expect(
246        clippy::too_many_arguments,
247        reason = "Intentional compatibility, platform, test, or API-shape suppression."
248    )]
249    async fn new_internal(
250        agent_type: AgentType,
251        model: ModelId,
252        api_key: String,
253        workspace: PathBuf,
254        session_id: String,
255        settings: RunnerSettings,
256        steering_receiver: Option<tokio::sync::mpsc::UnboundedReceiver<SteeringMessage>>,
257        bootstrap: ThreadBootstrap,
258        vt_cfg: Option<VTCodeConfig>,
259        openai_chatgpt_auth: Option<OpenAIChatGptAuthHandle>,
260    ) -> Result<Self> {
261        // Load configuration once to seed system prompt and runtime policies
262        let session_config = if let Some(vt_cfg) = vt_cfg {
263            ResolvedSessionConfig::from_config(vt_cfg)
264        } else {
265            match ResolvedSessionConfig::load_from_workspace(&workspace) {
266                Ok(session_config) => session_config,
267                Err(err) => {
268                    warn!("Failed to load vtcode configuration for system prompt composition: {err:#}");
269                    ResolvedSessionConfig::from_config(VTCodeConfig::default())
270                }
271            }
272        };
273        let session_config = Arc::new(session_config);
274        let provider_name = {
275            let configured = session_config.effective().agent.provider.trim();
276            if configured.is_empty() {
277                infer_provider_from_model(&model.as_str())
278                    .map(|provider| provider.to_string())
279                    .ok_or_else(|| anyhow!("Failed to determine provider for model {model}"))?
280            } else {
281                configured.to_lowercase()
282            }
283        };
284        let provider_config = ProviderConfig {
285            api_key: Some(api_key.clone()),
286            openai_chatgpt_auth: openai_chatgpt_auth.clone(),
287            copilot_auth: Some(session_config.effective().auth.copilot.clone()),
288            base_url: None,
289            model: Some(model.to_string()),
290            prompt_cache: Some(session_config.effective().prompt_cache.clone()),
291            timeouts: None,
292            openai: Some(session_config.effective().provider.openai.clone()),
293            anthropic: Some(session_config.effective().provider.anthropic.clone()),
294            openrouter: Some(session_config.effective().provider.openrouter.clone()),
295            model_behavior: Some(session_config.effective().model.clone()),
296            workspace_root: Some(workspace.clone()),
297        };
298
299        let client: AnyClient = Box::new(ProviderClientAdapter::new(
300            create_provider_with_config(&provider_name, provider_config.clone())
301                .with_context(|| "Failed to create client provider")?,
302            model.to_string(),
303        ));
304        let provider_client = create_provider_with_config(&provider_name, provider_config)
305            .with_context(|| "Failed to create provider client")?;
306        if std::env::var_os("VTCODE_DEBUG_PROVIDER").is_some() {
307            eprintln!(
308                "vtcode-debug: runner provider={} client_provider={} model={}",
309                provider_name,
310                provider_client.name(),
311                model
312            );
313        }
314        let max_repeated_tool_calls = session_config.effective().tools.max_repeated_tool_calls.max(1);
315        let deferred_tool_policy = crate::tools::handlers::deferred_tool_policy_for_runtime(
316            crate::llm::factory::infer_provider(Some(&session_config.effective().agent.provider), &model.as_str()),
317            provider_client.supports_responses_compaction(&model.as_str()),
318            Some(session_config.effective()),
319        );
320        let anthropic_native_memory_enabled = crate::tools::handlers::anthropic_native_memory_enabled_for_runtime(
321            Provider::from_str(provider_client.name()).ok(),
322            &model.as_str(),
323            Some(session_config.effective()),
324        );
325        let tool_registry = ToolRegistry::new(workspace.clone()).await;
326        tool_registry.set_inspection_mode(session_config.effective().agent.harness.inspection.mode);
327        tool_registry.set_harness_session(session_id.clone());
328        tool_registry.set_matrix_coordinator(session_config.effective().default_primary_agent == "coordinator");
329        tool_registry.set_agent_type(agent_type.to_string());
330        tool_registry.initialize_async().await?;
331        if let Err(err) = tool_registry
332            .apply_session_runtime_config(
333                &session_config.effective().commands,
334                &session_config.effective().permissions,
335                &session_config.effective().sandbox,
336                &session_config.effective().timeouts,
337                &session_config.effective().tools,
338            )
339            .await
340        {
341            warn!("Failed to apply tool policies from config: {}", err);
342        }
343        if session_config.effective().mcp.enabled {
344            if let Err(err) = crate::mcp::validate_mcp_config(&session_config.effective().mcp) {
345                warn!("MCP configuration validation error: {err}");
346            }
347            if let Err(err) = attach_mcp_client(&tool_registry, &session_config, &workspace).await {
348                warn!("Failed to attach MCP client: {err:#}");
349            }
350        }
351        if session_config.effective().context.dynamic.enabled
352            && let Err(err) =
353                crate::context::initialize_dynamic_context(&workspace, &session_config.effective().context.dynamic)
354                    .await
355        {
356            warn!("Failed to initialize dynamic context directories: {}", err);
357        }
358        let session_tools_config = crate::tools::handlers::SessionToolsConfig {
359            surface: crate::tools::handlers::SessionSurface::AgentRunner,
360            capability_level: crate::config::types::CapabilityLevel::CodeSearch,
361            documentation_mode: session_config.effective().agent.tool_documentation_mode,
362            planning_active: tool_registry.is_planning_active(),
363            request_user_input_enabled: false,
364            model_capabilities: crate::tools::handlers::ToolModelCapabilities::for_model_name(&model.as_str()),
365            deferred_tool_policy,
366            anthropic_native_memory_enabled,
367            tool_profile: session_config.effective().tools.profile,
368        };
369        let available_tools = tool_registry
370            .model_tools(session_tools_config.clone())
371            .await
372            .into_iter()
373            .map(|tool| tool.function_name().to_string())
374            .collect::<Vec<_>>();
375        let mut prompt_context = PromptContext::from_workspace_tools(&workspace, available_tools);
376        prompt_context.set_current_directory(workspace.clone());
377        prompt_context.load_available_skills_async().await;
378        let (system_prompt, system_prompt_report) = helpers::compose_system_prompt_with_appendix(
379            workspace.as_path(),
380            session_config.effective(),
381            &prompt_context,
382        )
383        .await?;
384        let loop_detector = LoopDetector::with_max_repeated_calls(max_repeated_tool_calls);
385        let bootstrap_messages = bootstrap.messages.clone();
386        let mut bootstrap = bootstrap;
387        if bootstrap.metadata.is_none() {
388            bootstrap.metadata = Some(build_thread_archive_metadata(
389                workspace.as_path(),
390                &model.as_str(),
391                &session_config.effective().agent.provider,
392                &session_config.effective().agent.theme,
393                settings
394                    .reasoning_effort
395                    .unwrap_or(session_config.effective().agent.reasoning_effort)
396                    .as_str(),
397            ));
398        }
399        let thread_handle =
400            crate::core::threads::ThreadManager::new().start_thread_with_identifier(session_id.clone(), bootstrap);
401        let max_turns = session_config.effective().automation.full_auto.max_turns.max(1);
402        if session_config.effective().default_primary_agent == "coordinator" {
403            let controller =
404                Box::pin(crate::subagents::SubagentController::new(crate::subagents::SubagentControllerConfig {
405                    workspace_root: workspace.clone(),
406                    parent_session_id: session_id.clone(),
407                    parent_model: model.to_string(),
408                    parent_provider: provider_name,
409                    parent_reasoning_effort: settings
410                        .reasoning_effort
411                        .unwrap_or(session_config.effective().agent.reasoning_effort),
412                    api_key: api_key.clone(),
413                    vt_cfg: session_config.effective().clone(),
414                    openai_chatgpt_auth,
415                    depth: 0,
416                    workspace_gated: session_config
417                        .effective()
418                        .workspace_lifecycle_hooks
419                        .as_ref()
420                        .is_some_and(|hooks| !hooks.is_empty()),
421                    exec_sessions: tool_registry.exec_session_manager(),
422                    pty_manager: tool_registry.pty_manager().clone(),
423                    managed_background_runtime: false,
424                }))
425                .await?;
426            tool_registry.set_subagent_controller(Arc::new(controller));
427        }
428
429        Ok(Self {
430            agent_type,
431            client,
432            provider_client,
433            tool_registry,
434            system_prompt,
435            system_prompt_report,
436            session_id,
437            bootstrap_messages,
438            _workspace: workspace,
439            session_config,
440            session_tools_config,
441            model: model.to_string(),
442            _api_key: api_key,
443            reasoning_effort: settings.reasoning_effort,
444            verbosity: settings.verbosity,
445            quiet: false,
446            event_sink: None,
447            thread_handle,
448            max_turns,
449            loop_detector: Mutex::new(loop_detector),
450            steering_receiver: Mutex::new(steering_receiver),
451            tool_definitions_override: RwLock::new(None),
452            tool_arg_transform: None,
453            local_tools_only: false,
454            active_primary_agent: None,
455        })
456    }
457
458    /// Enable or disable console output for this runner.
459    pub fn set_quiet(&mut self, quiet: bool) {
460        self.quiet = quiet;
461    }
462
463    /// Configure the loop detector for subagent mode with tighter read-only
464    /// budgets and earlier navigation streak intervention.
465    pub fn set_subagent_mode(&self, is_subagent: bool) {
466        self.loop_detector.lock().set_subagent_mode(is_subagent);
467    }
468
469    /// Attach a subagent controller to this runner's tool registry so the
470    /// subagent-lifecycle tools (`agent` and its aliases) become available to
471    /// the model. Used to enable nested delegation: the attached controller
472    /// carries the child-scoped depth so the depth check in `spawn_with_spec`
473    /// still governs grandchild spawns.
474    pub fn set_subagent_controller(&self, controller: Arc<crate::subagents::SubagentController>) {
475        self.tool_registry.set_subagent_controller(controller);
476    }
477
478    /// Snapshot the runner-owned conversation messages for archive persistence.
479    pub fn session_messages(&self) -> Vec<crate::llm::provider::Message> {
480        self.thread_handle.messages()
481    }
482
483    /// Clone the underlying thread handle so callers can capture snapshots.
484    pub fn thread_handle(&self) -> ThreadRuntimeHandle {
485        self.thread_handle.clone()
486    }
487
488    /// Workspace root this runner operates within.
489    pub fn workspace(&self) -> &Path {
490        &self._workspace
491    }
492
493    /// Enable read-only planning workflow for the underlying tool registry.
494    pub fn enable_planning(&self) {
495        self.tool_registry.enable_planning();
496    }
497
498    /// Disable read-only planning workflow for the underlying tool registry.
499    pub fn disable_planning(&self) {
500        self.tool_registry.disable_planning();
501    }
502
503    /// Attach a callback that will be invoked for each structured event as it is recorded.
504    pub fn set_event_handler<F>(&mut self, handler: F)
505    where
506        F: FnMut(&ThreadEvent) + Send + 'static,
507    {
508        self.event_sink = Some(Arc::new(Mutex::new(Box::new(handler))));
509    }
510
511    /// Remove any previously registered structured event callback.
512    pub fn clear_event_handler(&mut self) {
513        self.event_sink = None;
514    }
515
516    /// Override the composed system prompt for downstream embedders.
517    ///
518    /// Recomputes `system_prompt_report` against the overridden text so the
519    /// budget warning and preflight check stay accurate even when the prompt
520    /// bypasses the normal section-based composition pipeline.
521    pub fn set_system_prompt(&mut self, system_prompt: impl Into<String>) {
522        self.system_prompt = system_prompt.into();
523        self.system_prompt_report = crate::prompts::system::SystemPromptReport::measure(
524            &self.system_prompt,
525            self.config().agent.max_system_prompt_tokens,
526        );
527    }
528
529    /// Clone the underlying tool registry so embedders can register custom tools.
530    pub fn tool_registry(&self) -> ToolRegistry {
531        self.tool_registry.clone()
532    }
533
534    /// Override the tool definitions used for LLM requests instead of the default registry projection.
535    pub fn set_tool_definitions_override(&mut self, definitions: Vec<uni_provider::ToolDefinition>) {
536        *self.tool_definitions_override.write() = Some(definitions);
537    }
538
539    /// Clear any previously set tool definition override, restoring the default registry projection.
540    pub fn clear_tool_definitions_override(&mut self) {
541        *self.tool_definitions_override.write() = None;
542    }
543
544    /// Set an argument transformer applied to tool calls before validation and execution.
545    pub fn set_tool_arg_transform(&mut self, transform: ToolArgTransform) {
546        self.tool_arg_transform = Some(transform);
547    }
548
549    /// Restrict skill child execution using live trusted registration metadata.
550    pub fn restrict_to_local_tools(&mut self) {
551        self.local_tools_only = true;
552    }
553
554    /// Clear any previously set tool argument transformer.
555    pub fn clear_tool_arg_transform(&mut self) {
556        self.tool_arg_transform = None;
557    }
558
559    /// Apply active primary-agent tool and permission policy to this runner.
560    pub fn set_active_primary_agent(&mut self, active_primary_agent: ActivePrimaryAgent) {
561        self.tool_registry
562            .set_matrix_coordinator(active_primary_agent.identity.name == "coordinator");
563        self.active_primary_agent = Some(active_primary_agent);
564    }
565
566    /// Enable full-auto execution with the provided allow-list.
567    pub async fn enable_full_auto(&mut self, allowed_tools: &[String]) {
568        let mut permission_catalog_config = self.session_tools_config.clone();
569        permission_catalog_config.planning_active = allowed_tools
570            .iter()
571            .any(|tool_name| crate::tools::names::canonical_tool_name(tool_name) == tools::CODE_SEARCH);
572        self.tool_registry
573            .enable_full_auto_permission_for_session(allowed_tools, permission_catalog_config)
574            .await;
575    }
576
577    /// Restrict an allow-list to tools suitable for strict review-only runs.
578    pub async fn review_tool_allowlist(&self, allowed_tools: &[String]) -> Vec<String> {
579        let review_candidates = if allowed_tools.iter().any(|tool| tool.trim() == tools::WILDCARD_ALL) {
580            let mut candidates = self.tool_registry.available_tools().await;
581            if !candidates.iter().any(|tool| tool == tools::CODE_SEARCH) {
582                candidates.push(tools::CODE_SEARCH.to_string());
583            }
584            candidates
585        } else {
586            allowed_tools.to_vec()
587        };
588
589        review_candidates
590            .iter()
591            .filter(|tool_name| {
592                let canonical = crate::tools::names::canonical_tool_name(tool_name);
593
594                !matches!(canonical, tools::REQUEST_USER_INPUT | tools::TASK_TRACKER | tools::START_PLANNING)
595                    && !self.tool_registry.is_mutating_tool(tool_name)
596            })
597            .cloned()
598            .collect()
599    }
600
601    fn features(&self) -> FeatureSet {
602        self.session_config.features().clone()
603    }
604}