Skip to main content

enact_context/
config.rs

1//! Context Configuration
2//!
3//! Unified configuration for context window management, loaded via the standard
4//! config resolution chain: env var -> cwd -> ~/.enact -> defaults.
5
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8
9/// Budget configuration for context window
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct BudgetConfig {
12    /// Total context window size (model limit)
13    #[serde(default = "default_total_tokens")]
14    pub total_tokens: usize,
15
16    /// Tokens reserved for output generation
17    #[serde(default = "default_output_reserve")]
18    pub output_reserve: usize,
19
20    /// Warning threshold (percentage, 0-100)
21    #[serde(default = "default_warning_threshold")]
22    pub warning_threshold: u8,
23
24    /// Critical threshold (percentage, 0-100)
25    #[serde(default = "default_critical_threshold")]
26    pub critical_threshold: u8,
27}
28
29fn default_total_tokens() -> usize {
30    128_000
31}
32fn default_output_reserve() -> usize {
33    4_096
34}
35fn default_warning_threshold() -> u8 {
36    80
37}
38fn default_critical_threshold() -> u8 {
39    95
40}
41
42impl Default for BudgetConfig {
43    fn default() -> Self {
44        Self {
45            total_tokens: 128_000,
46            output_reserve: 4_096,
47            warning_threshold: 80,
48            critical_threshold: 95,
49        }
50    }
51}
52
53/// Condenser configuration
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct CondenserConfigSettings {
56    /// Target token count for condensed result
57    #[serde(default = "default_condenser_target_tokens")]
58    pub target_tokens: usize,
59
60    /// Maximum token count (hard limit)
61    #[serde(default = "default_condenser_max_tokens")]
62    pub max_tokens: usize,
63
64    /// Maximum steps to summarize
65    #[serde(default = "default_condenser_max_steps")]
66    pub max_steps: usize,
67
68    /// Maximum decision count
69    #[serde(default = "default_condenser_max_decisions")]
70    pub max_decisions: usize,
71}
72
73fn default_condenser_target_tokens() -> usize {
74    1500
75}
76fn default_condenser_max_tokens() -> usize {
77    2000
78}
79fn default_condenser_max_steps() -> usize {
80    10
81}
82fn default_condenser_max_decisions() -> usize {
83    5
84}
85
86impl Default for CondenserConfigSettings {
87    fn default() -> Self {
88        Self {
89            target_tokens: 1500,
90            max_tokens: 2000,
91            max_steps: 10,
92            max_decisions: 5,
93        }
94    }
95}
96
97/// Calibrator configuration
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct CalibratorConfigSettings {
100    /// Maximum tokens for the calibrated prompt
101    #[serde(default = "default_calibrator_max_tokens")]
102    pub max_tokens: usize,
103
104    /// Minimum tokens to reserve for response
105    #[serde(default = "default_calibrator_response_reserve")]
106    pub response_reserve: usize,
107
108    /// Maximum history messages to include
109    #[serde(default = "default_calibrator_max_history_messages")]
110    pub max_history_messages: usize,
111
112    /// Maximum RAG chunks to include
113    #[serde(default = "default_calibrator_max_rag_chunks")]
114    pub max_rag_chunks: usize,
115}
116
117fn default_calibrator_max_tokens() -> usize {
118    8000
119}
120fn default_calibrator_response_reserve() -> usize {
121    2000
122}
123fn default_calibrator_max_history_messages() -> usize {
124    20
125}
126fn default_calibrator_max_rag_chunks() -> usize {
127    5
128}
129
130impl Default for CalibratorConfigSettings {
131    fn default() -> Self {
132        Self {
133            max_tokens: 8000,
134            response_reserve: 2000,
135            max_history_messages: 20,
136            max_rag_chunks: 5,
137        }
138    }
139}
140
141/// Root context configuration
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct ContextConfig {
144    /// Default budget preset name (e.g., "gpt4_128k", "gpt4_32k", "claude_200k")
145    #[serde(default = "default_preset")]
146    pub default_preset: String,
147
148    /// Budget configuration
149    #[serde(default)]
150    pub budget: BudgetConfig,
151
152    /// Condenser configuration
153    #[serde(default)]
154    pub condenser: CondenserConfigSettings,
155
156    /// Calibrator configuration
157    #[serde(default)]
158    pub calibrator: CalibratorConfigSettings,
159}
160
161fn default_preset() -> String {
162    "gpt4_128k".to_string()
163}
164
165impl Default for ContextConfig {
166    fn default() -> Self {
167        Self {
168            default_preset: default_preset(),
169            budget: BudgetConfig::default(),
170            condenser: CondenserConfigSettings::default(),
171            calibrator: CalibratorConfigSettings::default(),
172        }
173    }
174}
175
176/// Load context configuration using the standard resolution chain.
177///
178/// Resolution order:
179/// 1. `ENACT_CONTEXT_CONFIG_PATH` environment variable
180/// 2. `./context.yaml` in current working directory
181/// 3. `~/.enact/context.yaml`
182/// 4. Hardcoded defaults if no file found
183///
184/// # Returns
185///
186/// Returns the loaded `ContextConfig` or an error if the file exists but cannot be parsed.
187///
188/// # Example
189///
190/// ```rust,no_run
191/// use enact_context::config::load_default_context_config;
192///
193/// let config = load_default_context_config().expect("Failed to load config");
194/// println!("Default preset: {}", config.default_preset);
195/// ```
196pub fn load_default_context_config() -> Result<ContextConfig, ConfigError> {
197    match enact_config::resolve_config_file("context.yaml", "ENACT_CONTEXT_CONFIG_PATH") {
198        Some(path) => load_context_config_from_path(&path),
199        None => {
200            tracing::debug!("No context.yaml found, using hardcoded defaults");
201            Ok(ContextConfig::default())
202        }
203    }
204}
205
206/// Load context configuration from a specific path.
207///
208/// # Arguments
209///
210/// * `path` - Path to the YAML configuration file
211///
212/// # Returns
213///
214/// Returns the loaded `ContextConfig` or an error if the file cannot be read or parsed.
215pub fn load_context_config_from_path(path: &PathBuf) -> Result<ContextConfig, ConfigError> {
216    let content = std::fs::read_to_string(path).map_err(|e| ConfigError::Io {
217        path: path.clone(),
218        source: e,
219    })?;
220
221    serde_yaml::from_str(&content).map_err(|e| ConfigError::Parse {
222        path: path.clone(),
223        source: e,
224    })
225}
226
227/// Configuration loading errors
228#[derive(Debug, thiserror::Error)]
229pub enum ConfigError {
230    /// Failed to read configuration file
231    #[error("Failed to read config file {path}: {source}")]
232    Io {
233        path: PathBuf,
234        #[source]
235        source: std::io::Error,
236    },
237
238    /// Failed to parse configuration file
239    #[error("Failed to parse config file {path}: {source}")]
240    Parse {
241        path: PathBuf,
242        #[source]
243        source: serde_yaml::Error,
244    },
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use std::io::Write;
251    use tempfile::NamedTempFile;
252
253    #[test]
254    fn test_default_config() {
255        let config = ContextConfig::default();
256        assert_eq!(config.default_preset, "gpt4_128k");
257        assert_eq!(config.budget.total_tokens, 128_000);
258        assert_eq!(config.condenser.target_tokens, 1500);
259        assert_eq!(config.calibrator.max_tokens, 8000);
260    }
261
262    #[test]
263    fn test_load_from_yaml() {
264        let yaml_content = r#"
265default_preset: gpt4_32k
266budget:
267  total_tokens: 32000
268  output_reserve: 2048
269  warning_threshold: 75
270  critical_threshold: 90
271condenser:
272  target_tokens: 1000
273  max_tokens: 1500
274  max_steps: 5
275  max_decisions: 3
276calibrator:
277  max_tokens: 4000
278  response_reserve: 1000
279  max_history_messages: 10
280  max_rag_chunks: 3
281"#;
282
283        let mut temp_file = NamedTempFile::new().unwrap();
284        temp_file.write_all(yaml_content.as_bytes()).unwrap();
285        let path = temp_file.path().to_path_buf();
286
287        let config = load_context_config_from_path(&path).unwrap();
288
289        assert_eq!(config.default_preset, "gpt4_32k");
290        assert_eq!(config.budget.total_tokens, 32000);
291        assert_eq!(config.budget.output_reserve, 2048);
292        assert_eq!(config.budget.warning_threshold, 75);
293        assert_eq!(config.condenser.target_tokens, 1000);
294        assert_eq!(config.condenser.max_steps, 5);
295        assert_eq!(config.calibrator.max_tokens, 4000);
296        assert_eq!(config.calibrator.max_rag_chunks, 3);
297    }
298
299    #[test]
300    fn test_partial_yaml_uses_defaults() {
301        let yaml_content = r#"
302default_preset: custom
303budget:
304  total_tokens: 64000
305"#;
306
307        let mut temp_file = NamedTempFile::new().unwrap();
308        temp_file.write_all(yaml_content.as_bytes()).unwrap();
309        let path = temp_file.path().to_path_buf();
310
311        let config = load_context_config_from_path(&path).unwrap();
312
313        // Specified values
314        assert_eq!(config.default_preset, "custom");
315        assert_eq!(config.budget.total_tokens, 64000);
316
317        // Default values for unspecified fields
318        assert_eq!(config.budget.output_reserve, 4096);
319        assert_eq!(config.condenser.target_tokens, 1500);
320        assert_eq!(config.calibrator.max_tokens, 8000);
321    }
322
323    #[test]
324    fn test_load_default_returns_defaults_when_no_file() {
325        // Clear the env var to ensure we don't pick up an existing file
326        std::env::remove_var("ENACT_CONTEXT_CONFIG_PATH");
327
328        // This should return defaults since context.yaml likely doesn't exist in cwd during tests
329        let result = load_default_context_config();
330        assert!(result.is_ok());
331    }
332}