enact-context 0.0.2

Context window management and compaction for Enact
Documentation
//! Context Configuration
//!
//! Unified configuration for context window management, loaded via the standard
//! config resolution chain: env var -> cwd -> ~/.enact -> defaults.

use serde::{Deserialize, Serialize};
use std::path::PathBuf;

/// Budget configuration for context window
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BudgetConfig {
    /// Total context window size (model limit)
    #[serde(default = "default_total_tokens")]
    pub total_tokens: usize,

    /// Tokens reserved for output generation
    #[serde(default = "default_output_reserve")]
    pub output_reserve: usize,

    /// Warning threshold (percentage, 0-100)
    #[serde(default = "default_warning_threshold")]
    pub warning_threshold: u8,

    /// Critical threshold (percentage, 0-100)
    #[serde(default = "default_critical_threshold")]
    pub critical_threshold: u8,
}

fn default_total_tokens() -> usize {
    128_000
}
fn default_output_reserve() -> usize {
    4_096
}
fn default_warning_threshold() -> u8 {
    80
}
fn default_critical_threshold() -> u8 {
    95
}

impl Default for BudgetConfig {
    fn default() -> Self {
        Self {
            total_tokens: 128_000,
            output_reserve: 4_096,
            warning_threshold: 80,
            critical_threshold: 95,
        }
    }
}

/// Condenser configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CondenserConfigSettings {
    /// Target token count for condensed result
    #[serde(default = "default_condenser_target_tokens")]
    pub target_tokens: usize,

    /// Maximum token count (hard limit)
    #[serde(default = "default_condenser_max_tokens")]
    pub max_tokens: usize,

    /// Maximum steps to summarize
    #[serde(default = "default_condenser_max_steps")]
    pub max_steps: usize,

    /// Maximum decision count
    #[serde(default = "default_condenser_max_decisions")]
    pub max_decisions: usize,
}

fn default_condenser_target_tokens() -> usize {
    1500
}
fn default_condenser_max_tokens() -> usize {
    2000
}
fn default_condenser_max_steps() -> usize {
    10
}
fn default_condenser_max_decisions() -> usize {
    5
}

impl Default for CondenserConfigSettings {
    fn default() -> Self {
        Self {
            target_tokens: 1500,
            max_tokens: 2000,
            max_steps: 10,
            max_decisions: 5,
        }
    }
}

/// Calibrator configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalibratorConfigSettings {
    /// Maximum tokens for the calibrated prompt
    #[serde(default = "default_calibrator_max_tokens")]
    pub max_tokens: usize,

    /// Minimum tokens to reserve for response
    #[serde(default = "default_calibrator_response_reserve")]
    pub response_reserve: usize,

    /// Maximum history messages to include
    #[serde(default = "default_calibrator_max_history_messages")]
    pub max_history_messages: usize,

    /// Maximum RAG chunks to include
    #[serde(default = "default_calibrator_max_rag_chunks")]
    pub max_rag_chunks: usize,
}

fn default_calibrator_max_tokens() -> usize {
    8000
}
fn default_calibrator_response_reserve() -> usize {
    2000
}
fn default_calibrator_max_history_messages() -> usize {
    20
}
fn default_calibrator_max_rag_chunks() -> usize {
    5
}

impl Default for CalibratorConfigSettings {
    fn default() -> Self {
        Self {
            max_tokens: 8000,
            response_reserve: 2000,
            max_history_messages: 20,
            max_rag_chunks: 5,
        }
    }
}

/// Root context configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextConfig {
    /// Default budget preset name (e.g., "gpt4_128k", "gpt4_32k", "claude_200k")
    #[serde(default = "default_preset")]
    pub default_preset: String,

    /// Budget configuration
    #[serde(default)]
    pub budget: BudgetConfig,

    /// Condenser configuration
    #[serde(default)]
    pub condenser: CondenserConfigSettings,

    /// Calibrator configuration
    #[serde(default)]
    pub calibrator: CalibratorConfigSettings,
}

fn default_preset() -> String {
    "gpt4_128k".to_string()
}

impl Default for ContextConfig {
    fn default() -> Self {
        Self {
            default_preset: default_preset(),
            budget: BudgetConfig::default(),
            condenser: CondenserConfigSettings::default(),
            calibrator: CalibratorConfigSettings::default(),
        }
    }
}

/// Load context configuration using the standard resolution chain.
///
/// Resolution order:
/// 1. `ENACT_CONTEXT_CONFIG_PATH` environment variable
/// 2. `./context.yaml` in current working directory
/// 3. `~/.enact/context.yaml`
/// 4. Hardcoded defaults if no file found
///
/// # Returns
///
/// Returns the loaded `ContextConfig` or an error if the file exists but cannot be parsed.
///
/// # Example
///
/// ```rust,no_run
/// use enact_context::config::load_default_context_config;
///
/// let config = load_default_context_config().expect("Failed to load config");
/// println!("Default preset: {}", config.default_preset);
/// ```
pub fn load_default_context_config() -> Result<ContextConfig, ConfigError> {
    match enact_config::resolve_config_file("context.yaml", "ENACT_CONTEXT_CONFIG_PATH") {
        Some(path) => load_context_config_from_path(&path),
        None => {
            tracing::debug!("No context.yaml found, using hardcoded defaults");
            Ok(ContextConfig::default())
        }
    }
}

/// Load context configuration from a specific path.
///
/// # Arguments
///
/// * `path` - Path to the YAML configuration file
///
/// # Returns
///
/// Returns the loaded `ContextConfig` or an error if the file cannot be read or parsed.
pub fn load_context_config_from_path(path: &PathBuf) -> Result<ContextConfig, ConfigError> {
    let content = std::fs::read_to_string(path).map_err(|e| ConfigError::Io {
        path: path.clone(),
        source: e,
    })?;

    serde_yaml::from_str(&content).map_err(|e| ConfigError::Parse {
        path: path.clone(),
        source: e,
    })
}

/// Configuration loading errors
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    /// Failed to read configuration file
    #[error("Failed to read config file {path}: {source}")]
    Io {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },

    /// Failed to parse configuration file
    #[error("Failed to parse config file {path}: {source}")]
    Parse {
        path: PathBuf,
        #[source]
        source: serde_yaml::Error,
    },
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::NamedTempFile;

    #[test]
    fn test_default_config() {
        let config = ContextConfig::default();
        assert_eq!(config.default_preset, "gpt4_128k");
        assert_eq!(config.budget.total_tokens, 128_000);
        assert_eq!(config.condenser.target_tokens, 1500);
        assert_eq!(config.calibrator.max_tokens, 8000);
    }

    #[test]
    fn test_load_from_yaml() {
        let yaml_content = r#"
default_preset: gpt4_32k
budget:
  total_tokens: 32000
  output_reserve: 2048
  warning_threshold: 75
  critical_threshold: 90
condenser:
  target_tokens: 1000
  max_tokens: 1500
  max_steps: 5
  max_decisions: 3
calibrator:
  max_tokens: 4000
  response_reserve: 1000
  max_history_messages: 10
  max_rag_chunks: 3
"#;

        let mut temp_file = NamedTempFile::new().unwrap();
        temp_file.write_all(yaml_content.as_bytes()).unwrap();
        let path = temp_file.path().to_path_buf();

        let config = load_context_config_from_path(&path).unwrap();

        assert_eq!(config.default_preset, "gpt4_32k");
        assert_eq!(config.budget.total_tokens, 32000);
        assert_eq!(config.budget.output_reserve, 2048);
        assert_eq!(config.budget.warning_threshold, 75);
        assert_eq!(config.condenser.target_tokens, 1000);
        assert_eq!(config.condenser.max_steps, 5);
        assert_eq!(config.calibrator.max_tokens, 4000);
        assert_eq!(config.calibrator.max_rag_chunks, 3);
    }

    #[test]
    fn test_partial_yaml_uses_defaults() {
        let yaml_content = r#"
default_preset: custom
budget:
  total_tokens: 64000
"#;

        let mut temp_file = NamedTempFile::new().unwrap();
        temp_file.write_all(yaml_content.as_bytes()).unwrap();
        let path = temp_file.path().to_path_buf();

        let config = load_context_config_from_path(&path).unwrap();

        // Specified values
        assert_eq!(config.default_preset, "custom");
        assert_eq!(config.budget.total_tokens, 64000);

        // Default values for unspecified fields
        assert_eq!(config.budget.output_reserve, 4096);
        assert_eq!(config.condenser.target_tokens, 1500);
        assert_eq!(config.calibrator.max_tokens, 8000);
    }

    #[test]
    fn test_load_default_returns_defaults_when_no_file() {
        // Clear the env var to ensure we don't pick up an existing file
        std::env::remove_var("ENACT_CONTEXT_CONFIG_PATH");

        // This should return defaults since context.yaml likely doesn't exist in cwd during tests
        let result = load_default_context_config();
        assert!(result.is_ok());
    }
}