Skip to main content

vtcode_config/core/agent/
mod.rs

1use crate::constants::{defaults, execution, llm_generation, prompt_budget, tool_limits};
2use crate::types::{
3    ReasoningEffortLevel, ShellPromptProfile, SystemPromptMode, ToolDocumentationMode, UiSurfacePreference,
4    VerbosityLevel,
5};
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9const DEFAULT_CHECKPOINTS_ENABLED: bool = true;
10const DEFAULT_MAX_SNAPSHOTS: usize = 50;
11const DEFAULT_MAX_AGE_DAYS: u64 = 30;
12
13mod approval;
14mod circuit_breaker;
15mod codex_app_server;
16mod harness;
17mod memory;
18mod onboarding;
19mod open_responses;
20mod prompt_suggestions;
21mod small_model;
22mod vibe_coding;
23
24pub use approval::{AsyncApprovalConfig, ConfidenceEscalationConfig, SkepticPanelConfig};
25pub use circuit_breaker::CircuitBreakerConfig;
26pub use codex_app_server::AgentCodexAppServerConfig;
27pub use harness::{
28    AgentHarnessConfig, ToolResultClearingConfig, TrackerContinuationConfig, VerificationAutoRecoveryConfig,
29};
30pub use memory::{MemoriesConfig, PersistentMemoryConfig};
31pub use onboarding::AgentOnboardingConfig;
32pub use open_responses::OpenResponsesConfig;
33pub use prompt_suggestions::AgentPromptSuggestionsConfig;
34pub use small_model::AgentSmallModelConfig;
35pub use vibe_coding::AgentVibeCodingConfig;
36
37/// Agent-wide configuration
38#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
39#[derive(Debug, Clone, Deserialize, Serialize)]
40pub struct AgentConfig {
41    /// Active provider for single-agent runs.
42    #[serde(default = "default_provider")]
43    pub provider: String,
44
45    /// Environment variable that stores the API key for the active provider
46    #[serde(default = "default_api_key_env")]
47    pub api_key_env: String,
48
49    /// Default model for new conversations.
50    #[serde(default = "default_model")]
51    pub default_model: String,
52
53    /// UI theme identifier controlling ANSI styling
54    #[serde(default = "default_theme")]
55    pub theme: String,
56
57    /// System prompt mode controlling prompt verbosity and token overhead.
58    /// Options target lean base prompts: minimal (~500 tokens), lightweight (~750 tokens),
59    /// default and specialized (~900 tokens) before dynamic runtime addenda.
60    #[serde(default)]
61    pub system_prompt_mode: SystemPromptMode,
62
63    /// Soft token budget for the fully composed system prompt (character-based
64    /// estimate, ~4 chars/token). Includes workspace instructions, guidelines,
65    /// and runtime addenda on top of the base prompt.
66    #[serde(default = "default_max_system_prompt_tokens")]
67    pub max_system_prompt_tokens: u64,
68
69    /// Warn when the composed system prompt exceeds `max_system_prompt_tokens`.
70    #[serde(default = "default_system_prompt_budget_warning")]
71    pub system_prompt_budget_warning: bool,
72
73    /// Trim low-priority advisory system prompt sections when over budget.
74    /// Base, shell-safety, and active-tool contracts are never trimmed.
75    #[serde(default = "default_trim_system_prompt")]
76    pub trim_system_prompt: bool,
77
78    /// Tool documentation mode controlling token overhead for tool definitions
79    /// Options: minimal, progressive (default, ~1.8k tokens for the builtin catalog), full
80    /// Progressive: complete tool and parameter descriptions; only unusually long tails are trimmed at a sentence boundary (recommended)
81    /// Minimal: first sentence of each tool description and no parameter descriptions (power users)
82    /// Full: every tool and parameter description sent unmodified
83    #[serde(default)]
84    pub tool_documentation_mode: ToolDocumentationMode,
85
86    /// Shell syntax profile used in model-facing command examples.
87    /// This controls prompt wording only; command policy remains in the runtime.
88    /// Values: auto, unix_like, powershell.
89    #[serde(default)]
90    pub shell_prompt_profile: ShellPromptProfile,
91
92    /// Enable split tool results for massive token savings (Phase 4)
93    /// When enabled, tools return dual-channel output:
94    /// - llm_content: Concise summary sent to LLM (token-optimized, 53-95% reduction)
95    /// - ui_content: Rich output displayed to user (full details preserved)
96    ///   Applies to: exec_command, code_search, apply_patch, and retained internal helpers
97    ///   Default: true (opt-out for compatibility), recommended for production use
98    #[serde(default = "default_enable_split_tool_results")]
99    pub enable_split_tool_results: bool,
100
101    /// Enable TODO planning helper mode for structured task management
102    #[serde(default = "default_todo_planning_mode")]
103    pub todo_planning_mode: bool,
104
105    /// Preferred rendering surface for the interactive chat UI (inline by default; auto, alternate, inline)
106    #[serde(default)]
107    pub ui_surface: UiSurfacePreference,
108
109    /// Maximum number of conversation turns before auto-termination
110    #[serde(default = "default_max_conversation_turns")]
111    pub max_conversation_turns: usize,
112
113    /// Maximum consecutive idle turns (no tool calls, no meaningful response) before
114    /// the agent runner treats the session as stalled and aborts the loop.
115    #[serde(default = "default_idle_turn_limit")]
116    pub idle_turn_limit: usize,
117
118    /// Reasoning depth for capable models (none to max).
119    #[serde(default = "default_reasoning_effort")]
120    pub reasoning_effort: ReasoningEffortLevel,
121    /// Permit a lower supported effort with an explicit harness diagnostic.
122    #[serde(default)]
123    pub allow_reasoning_effort_downgrade: bool,
124
125    /// Output verbosity for supported models (low to high).
126    #[serde(default = "default_verbosity")]
127    pub verbosity: VerbosityLevel,
128
129    /// Sampling temperature (0.0 precise to 1.0 creative).
130    #[serde(default = "default_temperature")]
131    pub temperature: f32,
132
133    /// Temperature for prompt refinement (0.0-1.0).
134    #[serde(default = "default_refine_temperature")]
135    pub refine_temperature: f32,
136
137    /// Enable an extra self-review pass to refine final responses
138    #[serde(default = "default_enable_self_review")]
139    pub enable_self_review: bool,
140
141    /// Maximum number of self-review passes
142    #[serde(default = "default_max_review_passes")]
143    pub max_review_passes: usize,
144
145    /// Enable prompt refinement pass before sending to LLM
146    #[serde(default = "default_refine_prompts_enabled")]
147    pub refine_prompts_enabled: bool,
148
149    /// Max refinement passes for prompt writing
150    #[serde(default = "default_refine_max_passes")]
151    pub refine_prompts_max_passes: usize,
152
153    /// Optional model override for the refiner (empty = auto pick efficient sibling)
154    #[serde(default)]
155    pub refine_prompts_model: String,
156
157    /// Small/lightweight model configuration for efficient operations
158    /// Used for tasks like large file reads, parsing, git history, conversation summarization
159    /// Typically 70-80% cheaper than main model; ~50% of VT Code's calls use this tier
160    #[serde(default)]
161    pub small_model: AgentSmallModelConfig,
162
163    /// Inline prompt suggestion configuration for the chat composer
164    #[serde(default)]
165    pub prompt_suggestions: AgentPromptSuggestionsConfig,
166
167    /// Session onboarding and welcome message configuration
168    #[serde(default)]
169    pub onboarding: AgentOnboardingConfig,
170
171    /// Maximum bytes of AGENTS.md/CLAUDE.md content to load from project hierarchy
172    #[serde(default = "default_project_doc_max_bytes")]
173    pub project_doc_max_bytes: usize,
174
175    /// Additional filenames to check when AGENTS.md is absent at a directory level.
176    #[serde(default)]
177    pub project_doc_fallback_filenames: Vec<String>,
178
179    /// Maximum bytes of instruction content to load from AGENTS.md/CLAUDE.md hierarchy
180    #[serde(default = "default_instruction_max_bytes", alias = "rule_doc_max_bytes")]
181    pub instruction_max_bytes: usize,
182
183    /// Additional instruction files or globs to merge into the hierarchy
184    #[serde(default, alias = "instruction_paths", alias = "instructions")]
185    pub instruction_files: Vec<String>,
186
187    /// Instruction files or globs to exclude from AGENTS.md and rules discovery
188    #[serde(default)]
189    pub instruction_excludes: Vec<String>,
190
191    /// Maximum recursive `@path` import depth for instruction and rule files
192    #[serde(default = "default_instruction_import_max_depth")]
193    pub instruction_import_max_depth: usize,
194
195    /// Durable per-repository memory for main sessions
196    #[serde(default)]
197    pub persistent_memory: PersistentMemoryConfig,
198
199    /// Provider/key identities captured from interactive configuration flows
200    ///
201    /// Note: Actual API keys are stored securely in the configured credential
202    /// backend (OS keyring when available, otherwise encrypted file storage).
203    /// Keys use `<provider>/<environment-variable>` identity keys and this
204    /// field only tracks which identities have keys stored (for UI/migration purposes).
205    /// The keys themselves are NOT serialized to the config file for security.
206    #[serde(default, skip_serializing)]
207    pub custom_api_keys: BTreeMap<String, String>,
208
209    /// Preferred storage backend for credentials (OAuth tokens, API keys, etc.)
210    ///
211    /// - `keyring`: Use OS-specific secure storage (macOS Keychain, Windows Credential
212    ///   Manager, Linux Secret Service), with encrypted-file fallback when unavailable.
213    /// - `file`: Use AES-256-GCM encrypted file with machine-derived key
214    /// - `auto`: Try keyring first, fall back to file if unavailable
215    #[serde(default)]
216    pub credential_storage_mode: crate::auth::AuthCredentialsStoreMode,
217
218    /// Checkpointing configuration for automatic turn snapshots
219    #[serde(default)]
220    pub checkpointing: AgentCheckpointingConfig,
221
222    /// Vibe coding configuration for lazy or vague request support
223    #[serde(default)]
224    pub vibe_coding: AgentVibeCodingConfig,
225
226    /// Maximum number of retries for agent task execution (default: 2)
227    /// When an agent task fails due to retryable errors (timeout, network, 503, etc.),
228    /// it will be retried up to this many times with exponential backoff
229    #[serde(default = "default_max_task_retries")]
230    pub max_task_retries: u32,
231
232    /// Harness configuration for turn-level budgets, telemetry, and execution limits
233    #[serde(default)]
234    pub harness: AgentHarnessConfig,
235
236    /// Experimental Codex app-server sidecar configuration.
237    #[serde(default)]
238    pub codex_app_server: AgentCodexAppServerConfig,
239
240    /// Include current date/time in system prompt for temporal awareness
241    /// Helps LLM understand context for time-sensitive tasks (default: true)
242    #[serde(default = "default_include_temporal_context")]
243    pub include_temporal_context: bool,
244
245    /// Use UTC instead of local time for temporal context in system prompts
246    #[serde(default)]
247    pub temporal_context_use_utc: bool,
248
249    /// Include current working directory in system prompt (default: true)
250    #[serde(default = "default_include_working_directory")]
251    pub include_working_directory: bool,
252
253    /// Controls inclusion of the structured reasoning tag instructions block.
254    ///
255    /// Behavior:
256    /// - `Some(true)`: always include structured reasoning instructions.
257    /// - `Some(false)`: never include structured reasoning instructions.
258    /// - `None` (default): omit structured reasoning instructions in every prompt mode.
259    ///
260    /// Models with native reasoning do not need visible reasoning tags, so the
261    /// block is opt-in for users who want tag-based reasoning guidance.
262    #[serde(default)]
263    pub include_structured_reasoning_tags: Option<bool>,
264
265    /// Custom instructions provided by the user via configuration to guide agent behavior
266    #[serde(default)]
267    pub user_instructions: Option<String>,
268
269    /// Require user confirmation before executing a plan generated in planning workflow
270    /// When true, exiting planning workflow shows the implementation blueprint and
271    /// requires explicit user approval before enabling edit tools.
272    #[serde(default = "default_require_plan_confirmation")]
273    pub require_plan_confirmation: bool,
274
275    /// Circuit breaker configuration for resilient tool execution
276    /// Controls when the agent should pause and ask for user guidance due to repeated failures
277    #[serde(default)]
278    pub circuit_breaker: CircuitBreakerConfig,
279
280    /// Open Responses specification compliance configuration
281    /// Enables vendor-neutral LLM API format for interoperable workflows
282    #[serde(default)]
283    pub open_responses: OpenResponsesConfig,
284}
285
286#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
287#[cfg_attr(feature = "schema", schemars(rename_all = "snake_case"))]
288#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
289#[serde(rename_all = "snake_case")]
290pub enum ContinuationPolicy {
291    Off,
292    ExecOnly,
293    #[default]
294    All,
295}
296
297impl ContinuationPolicy {
298    pub fn as_str(&self) -> &'static str {
299        match self {
300            Self::Off => "off",
301            Self::ExecOnly => "exec_only",
302            Self::All => "all",
303        }
304    }
305
306    fn parse(value: &str) -> Option<Self> {
307        let normalized = value.trim();
308        if normalized.eq_ignore_ascii_case("off") {
309            Some(Self::Off)
310        } else if normalized.eq_ignore_ascii_case("exec_only") || normalized.eq_ignore_ascii_case("exec-only") {
311            Some(Self::ExecOnly)
312        } else if normalized.eq_ignore_ascii_case("all") {
313            Some(Self::All)
314        } else {
315            None
316        }
317    }
318}
319
320impl<'de> Deserialize<'de> for ContinuationPolicy {
321    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
322    where
323        D: serde::Deserializer<'de>,
324    {
325        let raw = String::deserialize(deserializer)?;
326        Ok(Self::parse(&raw).unwrap_or_default())
327    }
328}
329
330/// When to trigger a context reset — starting a clean session from external
331/// artifacts only, discarding conversation history to clear noise and bad
332/// assumptions. This is distinct from compaction, which preserves
333/// conversational continuity within the same task/agent loop.
334///
335/// Following the context engineering pattern: "Context reset uses external
336/// artifacts (files from note-taking, git logs, test results, task lists) as
337/// startup material to open a clean new context/session. It does not preserve
338/// the full conversation history, and can clear noise and bad assumptions so
339/// that a new agent can reorient itself."
340#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
341#[cfg_attr(feature = "schema", schemars(rename_all = "snake_case"))]
342#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
343#[serde(rename_all = "snake_case")]
344pub enum ContextResetMode {
345    /// Never reset — always carry forward conversation history (current behavior).
346    #[default]
347    Off,
348    /// Reset when the progress monitor detects a stall (no forward progress for
349    /// `context_reset_stall_threshold` consecutive turns).
350    OnStall,
351    /// Reset after every automatic compaction, so the post-compaction session
352    /// starts from artifacts only rather than the compacted summary.
353    OnCompaction,
354}
355
356impl ContextResetMode {
357    pub fn as_str(&self) -> &'static str {
358        match self {
359            Self::Off => "off",
360            Self::OnStall => "on_stall",
361            Self::OnCompaction => "on_compaction",
362        }
363    }
364
365    fn parse(value: &str) -> Option<Self> {
366        let normalized = value.trim();
367        if normalized.eq_ignore_ascii_case("off") {
368            Some(Self::Off)
369        } else if normalized.eq_ignore_ascii_case("on_stall") || normalized.eq_ignore_ascii_case("on-stall") {
370            Some(Self::OnStall)
371        } else if normalized.eq_ignore_ascii_case("on_compaction") || normalized.eq_ignore_ascii_case("on-compaction") {
372            Some(Self::OnCompaction)
373        } else {
374            None
375        }
376    }
377}
378
379impl<'de> Deserialize<'de> for ContextResetMode {
380    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
381    where
382        D: serde::Deserializer<'de>,
383    {
384        let raw = String::deserialize(deserializer)?;
385        Ok(Self::parse(&raw).unwrap_or_default())
386    }
387}
388
389#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
390#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
391#[serde(rename_all = "snake_case")]
392pub enum HarnessOrchestrationMode {
393    #[default]
394    PlanBuildEvaluate,
395    Single,
396}
397
398impl HarnessOrchestrationMode {
399    pub fn as_str(&self) -> &'static str {
400        match self {
401            Self::Single => "single",
402            Self::PlanBuildEvaluate => "plan_build_evaluate",
403        }
404    }
405
406    fn parse(value: &str) -> Option<Self> {
407        let normalized = value.trim();
408        if normalized.eq_ignore_ascii_case("single") {
409            Some(Self::Single)
410        } else if normalized.eq_ignore_ascii_case("plan_build_evaluate")
411            || normalized.eq_ignore_ascii_case("plan-build-evaluate")
412            || normalized.eq_ignore_ascii_case("planner_generator_evaluator")
413            || normalized.eq_ignore_ascii_case("planner-generator-evaluator")
414        {
415            Some(Self::PlanBuildEvaluate)
416        } else {
417            None
418        }
419    }
420}
421
422impl<'de> Deserialize<'de> for HarnessOrchestrationMode {
423    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
424    where
425        D: serde::Deserializer<'de>,
426    {
427        let raw = String::deserialize(deserializer)?;
428        Ok(Self::parse(&raw).unwrap_or_default())
429    }
430}
431
432impl Default for AgentConfig {
433    fn default() -> Self {
434        Self {
435            provider: default_provider(),
436            api_key_env: default_api_key_env(),
437            default_model: default_model(),
438            theme: default_theme(),
439            system_prompt_mode: SystemPromptMode::default(),
440            max_system_prompt_tokens: default_max_system_prompt_tokens(),
441            system_prompt_budget_warning: default_system_prompt_budget_warning(),
442            trim_system_prompt: default_trim_system_prompt(),
443            tool_documentation_mode: ToolDocumentationMode::default(),
444            shell_prompt_profile: ShellPromptProfile::default(),
445            enable_split_tool_results: default_enable_split_tool_results(),
446            todo_planning_mode: default_todo_planning_mode(),
447            ui_surface: UiSurfacePreference::default(),
448            max_conversation_turns: default_max_conversation_turns(),
449            idle_turn_limit: default_idle_turn_limit(),
450            reasoning_effort: default_reasoning_effort(),
451            allow_reasoning_effort_downgrade: false,
452            verbosity: default_verbosity(),
453            temperature: default_temperature(),
454            refine_temperature: default_refine_temperature(),
455            enable_self_review: default_enable_self_review(),
456            max_review_passes: default_max_review_passes(),
457            refine_prompts_enabled: default_refine_prompts_enabled(),
458            refine_prompts_max_passes: default_refine_max_passes(),
459            refine_prompts_model: String::new(),
460            small_model: AgentSmallModelConfig::default(),
461            prompt_suggestions: AgentPromptSuggestionsConfig::default(),
462            onboarding: AgentOnboardingConfig::default(),
463            project_doc_max_bytes: default_project_doc_max_bytes(),
464            project_doc_fallback_filenames: Vec::new(),
465            instruction_max_bytes: default_instruction_max_bytes(),
466            instruction_files: Vec::new(),
467            instruction_excludes: Vec::new(),
468            instruction_import_max_depth: default_instruction_import_max_depth(),
469            persistent_memory: PersistentMemoryConfig::default(),
470            custom_api_keys: BTreeMap::new(),
471            credential_storage_mode: crate::auth::AuthCredentialsStoreMode::default(),
472            checkpointing: AgentCheckpointingConfig::default(),
473            vibe_coding: AgentVibeCodingConfig::default(),
474            max_task_retries: default_max_task_retries(),
475            harness: AgentHarnessConfig::default(),
476            codex_app_server: AgentCodexAppServerConfig::default(),
477            include_temporal_context: default_include_temporal_context(),
478            temporal_context_use_utc: false, // Default to local time
479            include_working_directory: default_include_working_directory(),
480            include_structured_reasoning_tags: None,
481            user_instructions: None,
482            require_plan_confirmation: default_require_plan_confirmation(),
483            circuit_breaker: CircuitBreakerConfig::default(),
484            open_responses: OpenResponsesConfig::default(),
485        }
486    }
487}
488
489impl AgentConfig {
490    /// Determine whether structured reasoning tag instructions should be included.
491    pub fn should_include_structured_reasoning_tags(&self) -> bool {
492        self.include_structured_reasoning_tags.unwrap_or(false)
493    }
494
495    /// Validate LLM generation parameters
496    pub(crate) fn validate_llm_params(&self) -> Result<(), String> {
497        // Validate temperature range
498        if !(0.0..=1.0).contains(&self.temperature) {
499            return Err(format!("temperature must be between 0.0 and 1.0, got {}", self.temperature));
500        }
501
502        if !(0.0..=1.0).contains(&self.refine_temperature) {
503            return Err(format!("refine_temperature must be between 0.0 and 1.0, got {}", self.refine_temperature));
504        }
505
506        if self.instruction_import_max_depth == 0 {
507            return Err("instruction_import_max_depth must be greater than 0".to_string());
508        }
509
510        if !(0.0..=1.0).contains(&self.harness.budget_warning_threshold) {
511            return Err(format!(
512                "harness.budget_warning_threshold must be between 0.0 and 1.0, got {}",
513                self.harness.budget_warning_threshold
514            ));
515        }
516
517        self.persistent_memory.validate()?;
518        self.harness.tool_result_clearing.validate()?;
519
520        Ok(())
521    }
522}
523
524// Optimized: Use inline defaults with constants to reduce function call overhead
525#[inline]
526fn default_provider() -> String {
527    defaults::DEFAULT_PROVIDER.into()
528}
529
530#[inline]
531fn default_api_key_env() -> String {
532    defaults::DEFAULT_API_KEY_ENV.into()
533}
534
535#[inline]
536fn default_model() -> String {
537    defaults::DEFAULT_MODEL.into()
538}
539
540#[inline]
541fn default_theme() -> String {
542    defaults::DEFAULT_THEME.into()
543}
544
545#[inline]
546const fn default_todo_planning_mode() -> bool {
547    true
548}
549
550#[inline]
551const fn default_enable_split_tool_results() -> bool {
552    true // Default: enabled for production use (84% token savings)
553}
554
555#[inline]
556const fn default_max_conversation_turns() -> usize {
557    tool_limits::DEFAULT_MAX_CONVERSATION_TURNS
558}
559
560#[inline]
561const fn default_idle_turn_limit() -> usize {
562    execution::IDLE_TURN_LIMIT
563}
564
565#[inline]
566fn default_reasoning_effort() -> ReasoningEffortLevel {
567    ReasoningEffortLevel::None
568}
569
570#[inline]
571fn default_verbosity() -> VerbosityLevel {
572    VerbosityLevel::default()
573}
574
575#[inline]
576const fn default_temperature() -> f32 {
577    llm_generation::DEFAULT_TEMPERATURE
578}
579
580#[inline]
581const fn default_refine_temperature() -> f32 {
582    llm_generation::DEFAULT_REFINE_TEMPERATURE
583}
584
585#[inline]
586const fn default_enable_self_review() -> bool {
587    false
588}
589
590#[inline]
591const fn default_max_review_passes() -> usize {
592    1
593}
594
595#[inline]
596const fn default_refine_prompts_enabled() -> bool {
597    false
598}
599
600#[inline]
601const fn default_refine_max_passes() -> usize {
602    1
603}
604
605#[inline]
606const fn default_max_system_prompt_tokens() -> u64 {
607    prompt_budget::DEFAULT_MAX_SYSTEM_PROMPT_TOKENS
608}
609
610#[inline]
611const fn default_system_prompt_budget_warning() -> bool {
612    true
613}
614
615#[inline]
616const fn default_trim_system_prompt() -> bool {
617    true
618}
619
620#[inline]
621const fn default_project_doc_max_bytes() -> usize {
622    prompt_budget::DEFAULT_MAX_BYTES
623}
624
625#[inline]
626const fn default_instruction_max_bytes() -> usize {
627    prompt_budget::DEFAULT_MAX_BYTES
628}
629
630#[inline]
631const fn default_instruction_import_max_depth() -> usize {
632    5
633}
634
635#[inline]
636const fn default_max_task_retries() -> u32 {
637    2 // Retry twice on transient failures
638}
639
640#[inline]
641const fn default_include_temporal_context() -> bool {
642    true // Enable by default - minimal overhead (~20 tokens)
643}
644
645#[inline]
646const fn default_include_working_directory() -> bool {
647    true // Enable by default - minimal overhead (~10 tokens)
648}
649
650#[inline]
651const fn default_require_plan_confirmation() -> bool {
652    true // Default: require confirmation (HITL pattern)
653}
654
655#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
656#[derive(Debug, Clone, Deserialize, Serialize)]
657pub struct AgentCheckpointingConfig {
658    /// Enable automatic checkpoints after each successful turn
659    #[serde(default = "default_checkpointing_enabled")]
660    pub enabled: bool,
661
662    /// Optional custom directory for storing checkpoints (relative to workspace or absolute)
663    #[serde(default)]
664    pub storage_dir: Option<String>,
665
666    /// Maximum number of checkpoints to retain on disk
667    #[serde(default = "default_checkpointing_max_snapshots")]
668    pub max_snapshots: usize,
669
670    /// Maximum age in days before checkpoints are removed automatically (None disables)
671    #[serde(default = "default_checkpointing_max_age_days")]
672    pub max_age_days: Option<u64>,
673}
674
675impl Default for AgentCheckpointingConfig {
676    fn default() -> Self {
677        Self {
678            enabled: default_checkpointing_enabled(),
679            storage_dir: None,
680            max_snapshots: default_checkpointing_max_snapshots(),
681            max_age_days: default_checkpointing_max_age_days(),
682        }
683    }
684}
685
686#[inline]
687const fn default_checkpointing_enabled() -> bool {
688    DEFAULT_CHECKPOINTS_ENABLED
689}
690
691#[inline]
692const fn default_checkpointing_max_snapshots() -> usize {
693    DEFAULT_MAX_SNAPSHOTS
694}
695
696#[inline]
697const fn default_checkpointing_max_age_days() -> Option<u64> {
698    Some(DEFAULT_MAX_AGE_DAYS)
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704
705    #[test]
706    fn test_continuation_policy_defaults_and_parses() {
707        assert_eq!(ContinuationPolicy::default(), ContinuationPolicy::All);
708        assert_eq!(ContinuationPolicy::parse("off"), Some(ContinuationPolicy::Off));
709        assert_eq!(ContinuationPolicy::parse("exec-only"), Some(ContinuationPolicy::ExecOnly));
710        assert_eq!(ContinuationPolicy::parse("all"), Some(ContinuationPolicy::All));
711        assert_eq!(ContinuationPolicy::parse("invalid"), None);
712    }
713
714    #[test]
715    fn test_tracker_continuation_defaults_and_deserializes() {
716        assert!(TrackerContinuationConfig::default().auto_continue_tracker);
717        assert_eq!(TrackerContinuationConfig::default().cross_turn_turns, 32);
718        let parsed: AgentHarnessConfig =
719            toml::from_str("[continuation]\nauto_continue_tracker = false\ncross_turn_turns = 3")
720                .expect("valid harness config");
721        assert!(!parsed.continuation.auto_continue_tracker);
722        assert_eq!(parsed.continuation.cross_turn_turns, 3);
723        let fallback: AgentHarnessConfig = toml::from_str("").expect("empty harness config");
724        assert!(fallback.continuation.auto_continue_tracker);
725        assert_eq!(fallback.continuation.cross_turn_turns, 32);
726    }
727
728    #[test]
729    fn test_harness_config_continuation_policy_deserializes_with_fallback() {
730        let parsed: AgentHarnessConfig = toml::from_str("continuation_policy = \"all\"").expect("valid harness config");
731        assert_eq!(parsed.continuation_policy, ContinuationPolicy::All);
732
733        let fallback: AgentHarnessConfig =
734            toml::from_str("continuation_policy = \"unexpected\"").expect("fallback config");
735        assert_eq!(fallback.continuation_policy, ContinuationPolicy::All);
736    }
737
738    #[test]
739    fn test_harness_config_tool_call_budget_defaults_to_120() {
740        assert_eq!(AgentHarnessConfig::default().max_tool_calls_per_turn, tool_limits::DEFAULT_MAX_TOOL_CALLS_PER_TURN);
741
742        let parsed: AgentHarnessConfig = toml::from_str("").expect("default harness config");
743        assert_eq!(parsed.max_tool_calls_per_turn, tool_limits::DEFAULT_MAX_TOOL_CALLS_PER_TURN);
744    }
745
746    #[test]
747    fn test_harness_orchestration_mode_defaults_and_parses() {
748        assert_eq!(HarnessOrchestrationMode::default(), HarnessOrchestrationMode::PlanBuildEvaluate);
749        assert_eq!(HarnessOrchestrationMode::parse("single"), Some(HarnessOrchestrationMode::Single));
750        assert_eq!(
751            HarnessOrchestrationMode::parse("plan_build_evaluate"),
752            Some(HarnessOrchestrationMode::PlanBuildEvaluate)
753        );
754        assert_eq!(
755            HarnessOrchestrationMode::parse("planner-generator-evaluator"),
756            Some(HarnessOrchestrationMode::PlanBuildEvaluate)
757        );
758        assert_eq!(HarnessOrchestrationMode::parse("unexpected"), None);
759    }
760
761    #[test]
762    fn test_harness_config_orchestration_deserializes_with_fallback() {
763        let parsed: AgentHarnessConfig =
764            toml::from_str("orchestration_mode = \"plan_build_evaluate\"").expect("valid harness config");
765        assert_eq!(parsed.orchestration_mode, HarnessOrchestrationMode::PlanBuildEvaluate);
766        assert_eq!(parsed.max_revision_rounds, 2);
767
768        let fallback: AgentHarnessConfig =
769            toml::from_str("orchestration_mode = \"unexpected\"").expect("fallback config");
770        assert_eq!(fallback.orchestration_mode, HarnessOrchestrationMode::PlanBuildEvaluate);
771    }
772
773    /// Drift guard for hand-written enum string mappings. Each of these enums
774    /// exposes the same value through serde, `as_str()`, and `parse()`; see
775    /// [`crate::test_support::assert_string_enum_lockstep`].
776    #[test]
777    fn string_config_enums_keep_as_str_and_serde_in_lockstep() {
778        use crate::test_support::assert_string_enum_lockstep;
779
780        assert_string_enum_lockstep!(
781            ContinuationPolicy,
782            [
783                ContinuationPolicy::Off,
784                ContinuationPolicy::ExecOnly,
785                ContinuationPolicy::All
786            ]
787        );
788        assert_string_enum_lockstep!(
789            ContextResetMode,
790            [
791                ContextResetMode::Off,
792                ContextResetMode::OnStall,
793                ContextResetMode::OnCompaction
794            ]
795        );
796        assert_string_enum_lockstep!(
797            HarnessOrchestrationMode,
798            [
799                HarnessOrchestrationMode::Single,
800                HarnessOrchestrationMode::PlanBuildEvaluate
801            ]
802        );
803    }
804
805    #[test]
806    fn test_verification_auto_recovery_defaults_to_bounded_auto_execute() {
807        let config = VerificationAutoRecoveryConfig::default();
808        assert!(config.auto_execute);
809        assert_eq!(config.in_turn_attempts, 2);
810        assert_eq!(config.cross_turn_turns, 2);
811        assert_eq!(config.default_verifier_override, None);
812        assert_eq!(config.max_consecutive_failures, 3);
813    }
814
815    #[test]
816    fn test_verification_auto_recovery_survives_missing_field_for_backward_compatibility() {
817        // Older vtcode.toml files predate [agent.harness.verification]: the
818        // whole table and each field must fall back to defaults.
819        let without_table: AgentHarnessConfig = toml::from_str("").expect("default harness config");
820        assert!(without_table.verification.auto_execute);
821        assert_eq!(without_table.verification.in_turn_attempts, 2);
822
823        let partial: VerificationAutoRecoveryConfig =
824            toml::from_str("auto_execute = false").expect("minimal verification config parses");
825        assert!(!partial.auto_execute);
826        assert_eq!(partial.in_turn_attempts, 2);
827        assert_eq!(partial.max_consecutive_failures, 3);
828
829        let full: VerificationAutoRecoveryConfig = toml::from_str(
830            "auto_execute = true\nin_turn_attempts = 1\ncross_turn_turns = 0\ndefault_verifier_override = \"cargo nextest run -p mycrate\"\nmax_consecutive_failures = 5",
831        )
832        .expect("full verification config parses");
833        assert_eq!(full.in_turn_attempts, 1);
834        assert_eq!(full.cross_turn_turns, 0);
835        assert_eq!(full.default_verifier_override.as_deref(), Some("cargo nextest run -p mycrate"));
836        assert_eq!(full.max_consecutive_failures, 5);
837    }
838
839    #[test]
840    fn test_plan_confirmation_config_default() {
841        let config = AgentConfig::default();
842        assert!(config.require_plan_confirmation);
843    }
844
845    #[test]
846    fn test_system_prompt_budget_defaults() {
847        let config = AgentConfig::default();
848        assert_eq!(config.max_system_prompt_tokens, prompt_budget::DEFAULT_MAX_SYSTEM_PROMPT_TOKENS);
849        assert!(config.system_prompt_budget_warning);
850        assert!(config.trim_system_prompt);
851
852        let parsed: AgentConfig = toml::from_str(
853            r#"
854max_system_prompt_tokens = 4000
855system_prompt_budget_warning = false
856trim_system_prompt = true
857"#,
858        )
859        .expect("agent config should parse");
860        assert_eq!(parsed.max_system_prompt_tokens, 4000);
861        assert!(!parsed.system_prompt_budget_warning);
862        assert!(parsed.trim_system_prompt);
863    }
864
865    #[test]
866    fn test_budget_warning_threshold_default_and_validation() {
867        let config = AgentConfig::default();
868        assert!((config.harness.budget_warning_threshold - 0.75).abs() < f64::EPSILON);
869        assert!(config.validate_llm_params().is_ok());
870
871        let parsed: AgentHarnessConfig = toml::from_str(
872            r#"
873max_budget_usd = 5.0
874budget_warning_threshold = 0.5
875"#,
876        )
877        .expect("harness config should parse");
878        assert_eq!(parsed.max_budget_usd, Some(5.0));
879        assert!((parsed.budget_warning_threshold - 0.5).abs() < f64::EPSILON);
880
881        let mut invalid = AgentConfig::default();
882        invalid.harness.budget_warning_threshold = 1.5;
883        assert!(invalid.validate_llm_params().is_err());
884    }
885
886    #[test]
887    fn test_persistent_memory_is_disabled_by_default() {
888        let config = AgentConfig::default();
889        assert!(!config.persistent_memory.enabled);
890        assert!(config.persistent_memory.auto_write);
891    }
892
893    #[test]
894    fn test_tool_result_clearing_defaults() {
895        let config = AgentConfig::default();
896        let clearing = config.harness.tool_result_clearing;
897
898        assert!(clearing.enabled);
899        assert_eq!(clearing.trigger_tokens, 40_000);
900        assert_eq!(clearing.keep_tool_uses, 2);
901        assert_eq!(clearing.keep_recent_tool_batches, 2);
902        assert_eq!(clearing.clear_at_least_tokens, 30_000);
903        assert!(clearing.clear_tool_inputs);
904    }
905
906    #[test]
907    fn test_tool_result_clearing_missing_key_defaults_clear_tool_inputs_true() {
908        let parsed: AgentHarnessConfig = toml::from_str(
909            r#"
910                [tool_result_clearing]
911                enabled = true
912                trigger_tokens = 40000
913                keep_tool_uses = 2
914                clear_at_least_tokens = 30000
915            "#,
916        )
917        .expect("valid harness config");
918
919        assert!(parsed.tool_result_clearing.clear_tool_inputs);
920        assert_eq!(parsed.tool_result_clearing.keep_recent_tool_batches, 2);
921    }
922
923    #[test]
924    fn tool_result_clearing_recent_batches_round_trip_including_compatibility() {
925        for batches in [0, 1, 2, 9] {
926            let parsed: AgentHarnessConfig =
927                toml::from_str(&format!("[tool_result_clearing]\nkeep_recent_tool_batches = {batches}")).unwrap();
928            assert!(parsed.tool_result_clearing.validate().is_ok());
929            let restored: AgentHarnessConfig = toml::from_str(&toml::to_string(&parsed).unwrap()).unwrap();
930            assert_eq!(restored.tool_result_clearing.keep_recent_tool_batches, batches);
931        }
932    }
933
934    #[test]
935    fn test_tool_result_clearing_explicit_false_opts_out_of_input_clearing() {
936        let parsed: AgentHarnessConfig = toml::from_str(
937            r#"
938                [tool_result_clearing]
939                clear_tool_inputs = false
940            "#,
941        )
942        .expect("valid harness config");
943
944        assert!(!parsed.tool_result_clearing.clear_tool_inputs);
945    }
946
947    #[test]
948    fn test_codex_app_server_experimental_features_default_to_disabled() {
949        let config = AgentConfig::default();
950
951        assert!(!config.codex_app_server.experimental_features);
952    }
953
954    #[test]
955    fn test_codex_app_server_experimental_features_parse_from_toml() {
956        let parsed: AgentCodexAppServerConfig = toml::from_str(
957            r#"
958                command = "codex"
959                args = ["app-server"]
960                startup_timeout_secs = 15
961                experimental_features = true
962            "#,
963        )
964        .expect("valid codex app-server config");
965
966        assert!(parsed.experimental_features);
967        assert_eq!(parsed.startup_timeout_secs, 15);
968    }
969
970    #[test]
971    fn test_tool_result_clearing_parses_and_validates() {
972        let parsed: AgentHarnessConfig = toml::from_str(
973            r#"
974                [tool_result_clearing]
975                enabled = true
976                trigger_tokens = 123456
977                keep_tool_uses = 6
978                clear_at_least_tokens = 4096
979                clear_tool_inputs = true
980            "#,
981        )
982        .expect("valid harness config");
983
984        assert!(parsed.tool_result_clearing.enabled);
985        assert_eq!(parsed.tool_result_clearing.trigger_tokens, 123_456);
986        assert_eq!(parsed.tool_result_clearing.keep_tool_uses, 6);
987        assert_eq!(parsed.tool_result_clearing.clear_at_least_tokens, 4_096);
988        assert!(parsed.tool_result_clearing.clear_tool_inputs);
989        assert!(parsed.tool_result_clearing.validate().is_ok());
990    }
991
992    #[test]
993    fn test_tool_result_clearing_rejects_zero_values() {
994        let clearing = ToolResultClearingConfig {
995            trigger_tokens: 0,
996            ..ToolResultClearingConfig::default()
997        };
998        assert!(clearing.validate().is_err());
999
1000        let clearing = ToolResultClearingConfig {
1001            keep_tool_uses: 0,
1002            ..ToolResultClearingConfig::default()
1003        };
1004        assert!(clearing.validate().is_err());
1005
1006        let clearing = ToolResultClearingConfig {
1007            clear_at_least_tokens: 0,
1008            ..ToolResultClearingConfig::default()
1009        };
1010        assert!(clearing.validate().is_err());
1011    }
1012
1013    #[test]
1014    fn test_structured_reasoning_is_opt_in_for_every_prompt_mode() {
1015        let default_prompt_config = AgentConfig {
1016            system_prompt_mode: SystemPromptMode::Default,
1017            ..Default::default()
1018        };
1019        assert!(!default_prompt_config.should_include_structured_reasoning_tags());
1020
1021        let specialized_mode = AgentConfig {
1022            system_prompt_mode: SystemPromptMode::Specialized,
1023            ..Default::default()
1024        };
1025        assert!(!specialized_mode.should_include_structured_reasoning_tags());
1026
1027        let minimal_mode = AgentConfig {
1028            system_prompt_mode: SystemPromptMode::Minimal,
1029            ..Default::default()
1030        };
1031        assert!(!minimal_mode.should_include_structured_reasoning_tags());
1032
1033        let lightweight_mode = AgentConfig {
1034            system_prompt_mode: SystemPromptMode::Lightweight,
1035            ..Default::default()
1036        };
1037        assert!(!lightweight_mode.should_include_structured_reasoning_tags());
1038    }
1039
1040    #[test]
1041    fn test_structured_reasoning_explicit_override() {
1042        let mut config = AgentConfig {
1043            system_prompt_mode: SystemPromptMode::Minimal,
1044            include_structured_reasoning_tags: Some(true),
1045            ..AgentConfig::default()
1046        };
1047        assert!(config.should_include_structured_reasoning_tags());
1048
1049        config.include_structured_reasoning_tags = Some(false);
1050        assert!(!config.should_include_structured_reasoning_tags());
1051    }
1052}