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/// Autonomous recovery policy for the anti-blind-editing verification gate.
229///
230/// After 6 consecutive successful mutations without verification, text-only
231/// responses no longer block the turn immediately: the harness grants bounded
232/// directive retries naming the exact project verifier, then (when
233/// `auto_execute` is set) runs that verifier itself through the normal tool
234/// pipeline instead of forcing the user to type `continue`. A verifier that
235/// keeps failing escalates to a manual blocked handoff carrying the failure
236/// log after `max_consecutive_failures` consecutive failures.
237#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
238#[derive(Debug, Clone, Deserialize, Serialize)]
239pub struct VerificationAutoRecoveryConfig {
240    /// Run the detected project verifier through the normal tool pipeline
241    /// when the model exhausts its directive retries without verifying.
242    /// Default: true. Set to false to restore directive-only recovery.
243    #[serde(default = "default_verification_auto_execute")]
244    pub auto_execute: bool,
245    /// Bounded in-turn directive retries granted when the model emits text
246    /// instead of a verifier while the gate is pending. Each grant resets the
247    /// text-response streak once and injects a project-aware directive.
248    /// Default: 2.
249    #[serde(default = "default_verification_in_turn_attempts")]
250    pub in_turn_attempts: u8,
251    /// Autonomous cross-turn recovery turns scheduled after a
252    /// verification-blocked turn before a manual blocked handoff is written.
253    /// Default: 2.
254    #[serde(default = "default_verification_cross_turn_turns")]
255    pub cross_turn_turns: u8,
256    /// Explicit verifier command overriding project-marker detection
257    /// (e.g. `"cargo nextest run -p mycrate"`). Must be a standalone verifier
258    /// or pure `&&` chain; pipes and `;`/`||` joins are rejected at use.
259    #[serde(default)]
260    pub default_verifier_override: Option<String>,
261    /// Consecutive failed harness auto-verifications before escalation to a
262    /// manual blocked handoff carrying the failure log. Reset by any success,
263    /// completed turn, or fresh user input. Default: 3.
264    #[serde(default = "default_verification_max_consecutive_failures")]
265    pub max_consecutive_failures: u8,
266}
267
268impl Default for VerificationAutoRecoveryConfig {
269    fn default() -> Self {
270        Self {
271            auto_execute: default_verification_auto_execute(),
272            in_turn_attempts: default_verification_in_turn_attempts(),
273            cross_turn_turns: default_verification_cross_turn_turns(),
274            default_verifier_override: None,
275            max_consecutive_failures: default_verification_max_consecutive_failures(),
276        }
277    }
278}
279#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
280#[derive(Debug, Clone, Deserialize, Serialize)]
281pub struct ToolResultClearingConfig {
282    /// Enables native or request-only local clearing; set `false` to opt out.
283    /// Durable history and events retain full outputs.
284    #[serde(default = "default_tool_result_clearing_enabled")]
285    pub enabled: bool,
286    /// Local clearing caps this at 60% of the provider/session history budget
287    /// after output reserves and system/tool-schema overhead (minimum positive
288    /// cap: 1). Lower configured triggers win; unknown capacity keeps this
289    /// value. Configured values must be positive. Native edit thresholds keep
290    /// the configured value.
291    #[serde(default = "default_tool_result_clearing_trigger_tokens")]
292    pub trigger_tokens: u64,
293    /// Protect newest results. Local clearing also retains all results from
294    /// their originating assistant tool-call batches, matching call IDs across
295    /// reordered completions and intervening directives.
296    #[serde(default = "default_tool_result_clearing_keep_tool_uses")]
297    pub keep_tool_uses: u32,
298    /// Local clearing additionally protects the newest assistant messages with
299    /// tool calls, selected by originating message order (default 2). Protection
300    /// is the union with keep_tool_uses; 0 restores count-based batch retention.
301    /// Native Anthropic context edits continue to use keep_tool_uses only.
302    #[serde(default = "default_tool_result_clearing_keep_recent_tool_batches")]
303    pub keep_recent_tool_batches: u32,
304    #[serde(default = "default_tool_result_clearing_clear_at_least_tokens")]
305    pub clear_at_least_tokens: u64,
306    /// Replace paired `tool_calls[].function.arguments` for stubbed results.
307    /// Leaving inputs in place keeps full `apply_patch`/`write_file` bodies on
308    /// every request after the result was already reclaimed. Defaults to
309    /// `true`; set `false` to opt out. Bare `#[serde(default)]` on a `bool`
310    /// is `false`, so this field names its default fn explicitly.
311    #[serde(default = "default_tool_result_clearing_clear_tool_inputs")]
312    pub clear_tool_inputs: bool,
313}
314
315impl Default for ToolResultClearingConfig {
316    fn default() -> Self {
317        Self {
318            enabled: default_tool_result_clearing_enabled(),
319            trigger_tokens: default_tool_result_clearing_trigger_tokens(),
320            keep_tool_uses: default_tool_result_clearing_keep_tool_uses(),
321            keep_recent_tool_batches: default_tool_result_clearing_keep_recent_tool_batches(),
322            clear_at_least_tokens: default_tool_result_clearing_clear_at_least_tokens(),
323            clear_tool_inputs: default_tool_result_clearing_clear_tool_inputs(),
324        }
325    }
326}
327
328impl ToolResultClearingConfig {
329    pub(crate) fn validate(&self) -> Result<(), String> {
330        if self.trigger_tokens == 0 {
331            return Err("tool_result_clearing.trigger_tokens must be greater than 0".to_string());
332        }
333        if self.keep_tool_uses == 0 {
334            return Err("tool_result_clearing.keep_tool_uses must be greater than 0".to_string());
335        }
336        if self.clear_at_least_tokens == 0 {
337            return Err("tool_result_clearing.clear_at_least_tokens must be greater than 0".to_string());
338        }
339        Ok(())
340    }
341}
342#[inline]
343const fn default_harness_max_tool_calls_per_turn() -> usize {
344    tool_limits::DEFAULT_MAX_TOOL_CALLS_PER_TURN
345}
346
347#[inline]
348const fn default_harness_max_tool_wall_clock_secs() -> u64 {
349    defaults::DEFAULT_MAX_TOOL_WALL_CLOCK_SECS
350}
351
352#[inline]
353const fn default_harness_max_tool_retries() -> u32 {
354    defaults::DEFAULT_MAX_TOOL_RETRIES
355}
356
357#[inline]
358const fn default_harness_max_parallel_tool_calls() -> usize {
359    4 // Cap parallel fan-out at 4; set to 0 in vtcode.toml to remove the limit.
360}
361
362#[inline]
363const fn default_harness_auto_compaction_enabled() -> bool {
364    true
365}
366
367const fn default_harness_compact_on_model_switch() -> bool {
368    true
369}
370
371#[inline]
372const fn default_harness_context_reset_stall_threshold() -> u32 {
373    2
374}
375
376#[inline]
377const fn default_verification_auto_execute() -> bool {
378    true
379}
380
381#[inline]
382const fn default_verification_in_turn_attempts() -> u8 {
383    2
384}
385
386#[inline]
387const fn default_verification_cross_turn_turns() -> u8 {
388    2
389}
390
391#[inline]
392const fn default_verification_max_consecutive_failures() -> u8 {
393    3
394}
395
396#[inline]
397const fn default_tool_result_clearing_enabled() -> bool {
398    true
399}
400
401#[inline]
402const fn default_tool_result_clearing_trigger_tokens() -> u64 {
403    // 40k: research/audit turns were observed at ~1M input tokens/turn with
404    // the old 100k trigger — tool results piled up long before any clearing.
405    40_000
406}
407
408#[inline]
409const fn default_tool_result_clearing_keep_recent_tool_batches() -> u32 {
410    2
411}
412
413const fn default_tool_result_clearing_keep_tool_uses() -> u32 {
414    2
415}
416
417#[inline]
418const fn default_tool_result_clearing_clear_at_least_tokens() -> u64 {
419    30_000
420}
421
422#[inline]
423const fn default_tool_result_clearing_clear_tool_inputs() -> bool {
424    true
425}
426
427#[inline]
428const fn default_harness_max_revision_rounds() -> usize {
429    2
430}
431
432#[inline]
433const fn default_harness_budget_warning_threshold() -> f64 {
434    0.75
435}
436
437#[cfg(test)]
438mod inspection_tests {
439    use super::*;
440
441    #[test]
442    fn source_inspection_defaults_warns_and_parses_explicit_modes() {
443        assert_eq!(AgentHarnessConfig::default().inspection.mode, InspectionMode::Warn);
444        for text in ["", "[inspection]"] {
445            let config: AgentHarnessConfig = toml::from_str(text).unwrap();
446            assert_eq!(config.inspection.mode, InspectionMode::Warn);
447        }
448        for (text, expected) in [
449            ("enforce", InspectionMode::Enforce),
450            ("warn", InspectionMode::Warn),
451            ("off", InspectionMode::Off),
452        ] {
453            let config: AgentHarnessConfig = toml::from_str(&format!("[inspection]\nmode = '{text}'")).unwrap();
454            assert_eq!(config.inspection.mode, expected);
455        }
456        assert!(toml::from_str::<AgentHarnessConfig>("[inspection]\nmode = 'permissive'").is_err());
457    }
458}