Skip to main content

vtcode_config/core/agent/
harness.rs

1//! Harness execution, verification, and tool-result-clearing configuration.
2
3use serde::{Deserialize, Serialize};
4
5use crate::constants::defaults;
6use crate::constants::tool_limits;
7
8use super::approval::{AsyncApprovalConfig, ConfidenceEscalationConfig, SkepticPanelConfig};
9use super::{ContextResetMode, ContinuationPolicy, HarnessOrchestrationMode};
10
11#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
12#[derive(Debug, Clone, Deserialize, Serialize)]
13pub struct AgentHarnessConfig {
14    /// Source exposure policy; advisory by default, independently of verification.
15    #[serde(default)]
16    pub inspection: InspectionConfig,
17    /// Maximum number of tool calls allowed per turn. Defaults to `120`.
18    /// Set to `0` to disable the cap.
19    #[serde(default = "default_harness_max_tool_calls_per_turn")]
20    pub max_tool_calls_per_turn: usize,
21    /// Maximum wall clock time (seconds) for tool execution in a turn
22    #[serde(default = "default_harness_max_tool_wall_clock_secs")]
23    pub max_tool_wall_clock_secs: u64,
24    /// Maximum retries for retryable tool errors
25    #[serde(default = "default_harness_max_tool_retries")]
26    pub max_tool_retries: u32,
27    /// Maximum number of tool calls that may execute concurrently within a single parallel batch.
28    /// Set to `0` to disable the cap (unlimited concurrency).  Default: 4.
29    #[serde(default = "default_harness_max_parallel_tool_calls")]
30    pub max_parallel_tool_calls: usize,
31    /// Enable automatic context compaction when token pressure crosses threshold.
32    ///
33    /// Enabled by default. When disabled, normal threshold-triggered automatic
34    /// compaction is skipped; the bounded post-tool recovery path may still
35    /// compact the older prefix as a safety fallback after a provider failure.
36    #[serde(default = "default_harness_auto_compaction_enabled")]
37    pub auto_compaction_enabled: bool,
38    /// Optional absolute compaction threshold (tokens) for native and local compaction.
39    ///
40    /// When set, this may lower the trigger but cannot bypass the resolved
41    /// model capacity or `context.max_context_tokens` safety ceiling.
42    /// The next response budget is reserved before deriving the trigger.
43    #[serde(default)]
44    pub auto_compaction_threshold_tokens: Option<u64>,
45    /// Optional custom instructions for the compaction summarization prompt.
46    /// When set, replaces the default Anthropic compaction prompt entirely.
47    /// Useful for tool-use scenarios to prevent the model from calling tools
48    /// during summarization. Only applies to Anthropic provider.
49    #[serde(default)]
50    pub auto_compaction_instructions: Option<String>,
51    /// Whether to pause after compaction (Anthropic only).
52    /// When true and compaction triggers, the API returns early with
53    /// `stop_reason: "compaction"` and only the compaction block.
54    /// The caller can then insert additional messages before the model
55    /// generates its text response.
56    #[serde(default)]
57    pub auto_compaction_pause_after: bool,
58    /// Automatically compact conversation context when the main session model
59    /// or provider is switched mid-conversation, so the newly selected model
60    /// starts from a summary instead of the outgoing model's raw trace.
61    /// Default: true. Disable to keep the full history across a model switch.
62    #[serde(default = "default_harness_compact_on_model_switch")]
63    pub compact_on_model_switch: bool,
64    /// Provider-native tool-result clearing policy. When enabled, old tool
65    /// results are stripped from the context once it grows past
66    /// `trigger_tokens`, protecting recent results and assistant tool-call batches
67    /// and always retaining at least `clear_at_least_tokens`. This bounds
68    /// per-turn context growth and is enabled by default.
69    #[serde(default)]
70    pub tool_result_clearing: ToolResultClearingConfig,
71    /// Optional maximum estimated API cost in USD before VT Code stops the session.
72    #[serde(default)]
73    pub max_budget_usd: Option<f64>,
74    /// Fraction of `max_budget_usd` at which VT Code emits a one-time
75    /// near-budget warning. Ignored when `max_budget_usd` is unset.
76    #[serde(default = "default_harness_budget_warning_threshold")]
77    pub budget_warning_threshold: f64,
78    /// Controls whether harness-managed continuation loops are enabled.
79    #[serde(default)]
80    pub continuation_policy: ContinuationPolicy,
81    /// When to trigger a context reset — starting a clean session from
82    /// external artifacts only, discarding conversation history. Distinct
83    /// from compaction (which preserves conversational continuity).
84    /// Default: `off` (carry forward history as before).
85    #[serde(default)]
86    pub context_reset_mode: ContextResetMode,
87    /// Number of consecutive stall turns before `on_stall` context reset
88    /// triggers. Ignored unless `context_reset_mode = "on_stall"`.
89    /// Default: 2.
90    #[serde(default = "default_harness_context_reset_stall_threshold")]
91    pub context_reset_stall_threshold: u32,
92    /// Optional compatibility/export JSONL path for harness events.
93    /// Canonical events are always stored under the workspace session store;
94    /// unset configuration creates no global harness file.
95    #[serde(default)]
96    pub event_log_path: Option<String>,
97    /// Select the exec/full-auto harness orchestration path.
98    #[serde(default)]
99    pub orchestration_mode: HarnessOrchestrationMode,
100    /// Maximum generator revision rounds after evaluator rejection.
101    #[serde(default = "default_harness_max_revision_rounds")]
102    pub max_revision_rounds: usize,
103    /// Confidence-based escalation for autonomous decisions.
104    ///
105    /// Implements the escalation decision rule:
106    ///   Escalate iff p_success < tau_conf OR action in A_irreversible OR cost > B_auto
107    ///
108    /// When a tool call is classified as irreversible or below the confidence
109    /// threshold, the harness escalates to blocked-handoff instead of proceeding
110    /// autonomously.  Opt-in (default: disabled).
111    #[serde(default)]
112    pub confidence_escalation: ConfidenceEscalationConfig,
113    /// Async (out-of-band) approval for deferred tool execution requests.
114    ///
115    /// When enabled, approval requests exceeding the auto-approve cost threshold
116    /// write a blocker file and notify the user out-of-band rather than blocking
117    /// on terminal input.  Opt-in (default: disabled).
118    #[serde(default)]
119    async_approval: AsyncApprovalConfig,
120    /// Adversarial multi-model evaluator panel.  When enabled, the harness
121    /// runs the evaluator prompt against every listed model in parallel and
122    /// aggregates the strictest verdict/scorecard across the panel.
123    /// Opt-in (default: disabled).
124    #[serde(default)]
125    pub skeptic_panel: SkepticPanelConfig,
126    /// Autonomous recovery for the anti-blind-editing verification gate.
127    /// When the model emits text instead of running a verifier, the harness
128    /// grants bounded directive retries and (optionally) runs the detected
129    /// project verifier itself instead of forcing manual `continue`.
130    #[serde(default)]
131    pub verification: VerificationAutoRecoveryConfig,
132    /// Tracker-aware auto-continuation: when `task_tracker` still has
133    /// incomplete steps, keep looping / auto-queue the next turn instead of
134    /// ending and nudging the user to resume.
135    #[serde(default)]
136    pub continuation: TrackerContinuationConfig,
137}
138
139impl Default for AgentHarnessConfig {
140    fn default() -> Self {
141        Self {
142            inspection: InspectionConfig::default(),
143            max_tool_calls_per_turn: default_harness_max_tool_calls_per_turn(),
144            max_tool_wall_clock_secs: default_harness_max_tool_wall_clock_secs(),
145            max_tool_retries: default_harness_max_tool_retries(),
146            max_parallel_tool_calls: default_harness_max_parallel_tool_calls(),
147            auto_compaction_enabled: default_harness_auto_compaction_enabled(),
148            auto_compaction_threshold_tokens: None,
149            auto_compaction_instructions: None,
150            auto_compaction_pause_after: false,
151            compact_on_model_switch: default_harness_compact_on_model_switch(),
152            tool_result_clearing: ToolResultClearingConfig::default(),
153            max_budget_usd: None,
154            budget_warning_threshold: default_harness_budget_warning_threshold(),
155            continuation_policy: ContinuationPolicy::default(),
156            context_reset_mode: ContextResetMode::default(),
157            context_reset_stall_threshold: default_harness_context_reset_stall_threshold(),
158            event_log_path: None,
159            orchestration_mode: HarnessOrchestrationMode::default(),
160            max_revision_rounds: default_harness_max_revision_rounds(),
161            confidence_escalation: ConfidenceEscalationConfig::default(),
162            async_approval: AsyncApprovalConfig::default(),
163            skeptic_panel: SkepticPanelConfig::default(),
164            verification: VerificationAutoRecoveryConfig::default(),
165            continuation: TrackerContinuationConfig::default(),
166        }
167    }
168}
169/// Source inspection policy, independent of post-edit verification.
170#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
171#[derive(Debug, Clone, Default, Deserialize, Serialize)]
172pub struct InspectionConfig {
173    #[serde(default)]
174    pub mode: InspectionMode,
175}
176
177/// Whether missing source exposure blocks mutations.
178#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
179#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
180#[serde(rename_all = "snake_case")]
181pub enum InspectionMode {
182    Enforce,
183    #[default]
184    Warn,
185    Off,
186}
187/// Tracker-aware auto-continuation policy.
188///
189/// When `task_tracker` still has incomplete items, the binary runloop
190/// continues in-turn for status-only responses and, after recoverable
191/// budget/recovery turn ends (or on session resume), auto-queues a bounded
192/// follow-up turn instead of requiring the user to type `continue`.
193#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
194#[derive(Debug, Clone, Deserialize, Serialize)]
195pub struct TrackerContinuationConfig {
196    /// Auto-continue while the task tracker has incomplete steps.
197    /// Default: true.
198    #[serde(default = "default_tracker_auto_continue")]
199    pub auto_continue_tracker: bool,
200    /// Bounded cross-turn auto-continue turns after a recoverable end
201    /// (budget/preview/tool-free recovery) or on resume while tracker work
202    /// remains. The episode budget **progress-resets** when any tracker step
203    /// completes. `0` disables cross-turn tracker auto-queue (in-turn
204    /// continuation still applies when `auto_continue_tracker` is true).
205    /// Default: 32.
206    #[serde(default = "default_tracker_cross_turn_turns")]
207    pub cross_turn_turns: u8,
208}
209
210impl Default for TrackerContinuationConfig {
211    fn default() -> Self {
212        Self {
213            auto_continue_tracker: default_tracker_auto_continue(),
214            cross_turn_turns: default_tracker_cross_turn_turns(),
215        }
216    }
217}
218
219#[inline]
220const fn default_tracker_auto_continue() -> bool {
221    true
222}
223
224#[inline]
225const fn default_tracker_cross_turn_turns() -> u8 {
226    32
227}
228/// Completion verification and bounded corrective recovery.
229///
230/// Editing remains available while checks are missing. At completion, run
231/// missing checks through the normal pipeline. At most one corrective pass
232/// per request is allowed; legacy numeric settings remain parseable and may
233/// disable or tighten this bound, never expand it.
234#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
235#[derive(Debug, Clone, Deserialize, Serialize)]
236pub struct VerificationAutoRecoveryConfig {
237    /// Run missing interactive completion checks through the normal pipeline.
238    /// Default: true. False disables automatic interactive execution and
239    /// corrective recovery; headless mandatory verification remains required.
240    #[serde(default = "default_verification_auto_execute")]
241    pub auto_execute: bool,
242    /// Legacy in-turn recovery bound. Zero disables the corrective pass;
243    /// nonzero values permit at most one pass. Default: 2 for compatibility.
244    #[serde(default = "default_verification_in_turn_attempts")]
245    pub in_turn_attempts: u8,
246    /// Legacy cross-turn recovery bound. Zero disables the corrective pass;
247    /// nonzero values permit at most one pass per request. Default: 2 for compatibility.
248    #[serde(default = "default_verification_cross_turn_turns")]
249    pub cross_turn_turns: u8,
250    /// Explicit verifier command overriding project-marker detection
251    /// (e.g. `"cargo nextest run -p mycrate"`). Must be a standalone verifier
252    /// or pure `&&` chain; pipes and `;`/`||` joins are rejected at use.
253    #[serde(default)]
254    pub default_verifier_override: Option<String>,
255    /// Consecutive failed harness auto-verifications before escalation to a
256    /// manual blocked handoff carrying the failure log. Reset by any success,
257    /// completed turn, or fresh user input. Default: 3.
258    #[serde(default = "default_verification_max_consecutive_failures")]
259    pub max_consecutive_failures: u8,
260}
261
262impl Default for VerificationAutoRecoveryConfig {
263    fn default() -> Self {
264        Self {
265            auto_execute: default_verification_auto_execute(),
266            in_turn_attempts: default_verification_in_turn_attempts(),
267            cross_turn_turns: default_verification_cross_turn_turns(),
268            default_verifier_override: None,
269            max_consecutive_failures: default_verification_max_consecutive_failures(),
270        }
271    }
272}
273#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
274#[derive(Debug, Clone, Deserialize, Serialize)]
275pub struct ToolResultClearingConfig {
276    /// Enables native or request-only local clearing; set `false` to opt out.
277    /// Durable history and events retain full outputs.
278    #[serde(default = "default_tool_result_clearing_enabled")]
279    pub enabled: bool,
280    /// Local clearing caps this at 60% of the provider/session history budget
281    /// after output reserves and system/tool-schema overhead (minimum positive
282    /// cap: 1). Lower configured triggers win; unknown capacity keeps this
283    /// value. Configured values must be positive. Native edit thresholds keep
284    /// the configured value.
285    #[serde(default = "default_tool_result_clearing_trigger_tokens")]
286    pub trigger_tokens: u64,
287    /// Protect newest results. Local clearing also retains all results from
288    /// their originating assistant tool-call batches, matching call IDs across
289    /// reordered completions and intervening directives.
290    #[serde(default = "default_tool_result_clearing_keep_tool_uses")]
291    pub keep_tool_uses: u32,
292    /// Local clearing additionally protects the newest assistant messages with
293    /// tool calls, selected by originating message order (default 2). Protection
294    /// is the union with keep_tool_uses; 0 restores count-based batch retention.
295    /// Native Anthropic context edits continue to use keep_tool_uses only.
296    #[serde(default = "default_tool_result_clearing_keep_recent_tool_batches")]
297    pub keep_recent_tool_batches: u32,
298    #[serde(default = "default_tool_result_clearing_clear_at_least_tokens")]
299    pub clear_at_least_tokens: u64,
300    /// Replace paired `tool_calls[].function.arguments` for stubbed results.
301    /// Leaving inputs in place keeps full `apply_patch`/`write_file` bodies on
302    /// every request after the result was already reclaimed. Defaults to
303    /// `true`; set `false` to opt out. Bare `#[serde(default)]` on a `bool`
304    /// is `false`, so this field names its default fn explicitly.
305    #[serde(default = "default_tool_result_clearing_clear_tool_inputs")]
306    pub clear_tool_inputs: bool,
307}
308
309impl Default for ToolResultClearingConfig {
310    fn default() -> Self {
311        Self {
312            enabled: default_tool_result_clearing_enabled(),
313            trigger_tokens: default_tool_result_clearing_trigger_tokens(),
314            keep_tool_uses: default_tool_result_clearing_keep_tool_uses(),
315            keep_recent_tool_batches: default_tool_result_clearing_keep_recent_tool_batches(),
316            clear_at_least_tokens: default_tool_result_clearing_clear_at_least_tokens(),
317            clear_tool_inputs: default_tool_result_clearing_clear_tool_inputs(),
318        }
319    }
320}
321
322impl ToolResultClearingConfig {
323    pub(crate) fn validate(&self) -> Result<(), String> {
324        if self.trigger_tokens == 0 {
325            return Err("tool_result_clearing.trigger_tokens must be greater than 0".to_string());
326        }
327        if self.keep_tool_uses == 0 {
328            return Err("tool_result_clearing.keep_tool_uses must be greater than 0".to_string());
329        }
330        if self.clear_at_least_tokens == 0 {
331            return Err("tool_result_clearing.clear_at_least_tokens must be greater than 0".to_string());
332        }
333        Ok(())
334    }
335}
336#[inline]
337const fn default_harness_max_tool_calls_per_turn() -> usize {
338    tool_limits::DEFAULT_MAX_TOOL_CALLS_PER_TURN
339}
340
341#[inline]
342const fn default_harness_max_tool_wall_clock_secs() -> u64 {
343    defaults::DEFAULT_MAX_TOOL_WALL_CLOCK_SECS
344}
345
346#[inline]
347const fn default_harness_max_tool_retries() -> u32 {
348    defaults::DEFAULT_MAX_TOOL_RETRIES
349}
350
351#[inline]
352const fn default_harness_max_parallel_tool_calls() -> usize {
353    4 // Cap parallel fan-out at 4; set to 0 in vtcode.toml to remove the limit.
354}
355
356#[inline]
357const fn default_harness_auto_compaction_enabled() -> bool {
358    true
359}
360
361const fn default_harness_compact_on_model_switch() -> bool {
362    true
363}
364
365#[inline]
366const fn default_harness_context_reset_stall_threshold() -> u32 {
367    2
368}
369
370#[inline]
371const fn default_verification_auto_execute() -> bool {
372    true
373}
374
375#[inline]
376const fn default_verification_in_turn_attempts() -> u8 {
377    2
378}
379
380#[inline]
381const fn default_verification_cross_turn_turns() -> u8 {
382    2
383}
384
385#[inline]
386const fn default_verification_max_consecutive_failures() -> u8 {
387    3
388}
389
390#[inline]
391const fn default_tool_result_clearing_enabled() -> bool {
392    true
393}
394
395#[inline]
396const fn default_tool_result_clearing_trigger_tokens() -> u64 {
397    // 40k: research/audit turns were observed at ~1M input tokens/turn with
398    // the old 100k trigger — tool results piled up long before any clearing.
399    40_000
400}
401
402#[inline]
403const fn default_tool_result_clearing_keep_recent_tool_batches() -> u32 {
404    2
405}
406
407const fn default_tool_result_clearing_keep_tool_uses() -> u32 {
408    2
409}
410
411#[inline]
412const fn default_tool_result_clearing_clear_at_least_tokens() -> u64 {
413    30_000
414}
415
416#[inline]
417const fn default_tool_result_clearing_clear_tool_inputs() -> bool {
418    true
419}
420
421#[inline]
422const fn default_harness_max_revision_rounds() -> usize {
423    2
424}
425
426#[inline]
427const fn default_harness_budget_warning_threshold() -> f64 {
428    0.75
429}
430
431#[cfg(test)]
432mod inspection_tests {
433    use super::*;
434
435    #[test]
436    fn source_inspection_defaults_warns_and_parses_explicit_modes() {
437        assert_eq!(AgentHarnessConfig::default().inspection.mode, InspectionMode::Warn);
438        for text in ["", "[inspection]"] {
439            let config: AgentHarnessConfig = toml::from_str(text).unwrap();
440            assert_eq!(config.inspection.mode, InspectionMode::Warn);
441        }
442        for (text, expected) in [
443            ("enforce", InspectionMode::Enforce),
444            ("warn", InspectionMode::Warn),
445            ("off", InspectionMode::Off),
446        ] {
447            let config: AgentHarnessConfig = toml::from_str(&format!("[inspection]\nmode = '{text}'")).unwrap();
448            assert_eq!(config.inspection.mode, expected);
449        }
450        assert!(toml::from_str::<AgentHarnessConfig>("[inspection]\nmode = 'permissive'").is_err());
451    }
452}