use serde::Deserialize; #[derive(Debug, Clone, Deserialize)] pub struct Config { pub matrix: MatrixConfig, pub opensearch: OpenSearchConfig, pub mistral: MistralConfig, pub behavior: BehaviorConfig, #[serde(default)] pub agents: AgentsConfig, #[serde(default)] pub services: ServicesConfig, #[serde(default)] pub vault: VaultConfig, } #[derive(Debug, Clone, Deserialize)] pub struct AgentsConfig { /// Model for the orchestrator agent. #[serde(default = "default_model")] pub orchestrator_model: String, /// Model for domain agents. #[serde(default = "default_model")] pub domain_model: String, /// Token threshold for conversation compaction (~90% of model context window). #[serde(default = "default_compaction_threshold")] pub compaction_threshold: u32, /// Whether to use the Conversations API (vs manual message management). #[serde(default)] pub use_conversations_api: bool, } impl Default for AgentsConfig { fn default() -> Self { Self { orchestrator_model: default_model(), domain_model: default_model(), compaction_threshold: default_compaction_threshold(), use_conversations_api: false, } } } #[derive(Debug, Clone, Deserialize)] pub struct MatrixConfig { pub homeserver_url: String, pub user_id: String, pub state_store_path: String, /// Path to the SQLite database for persistent state (conversations, agents). /// Should be on a persistent volume in K8s (e.g. Longhorn PVC mounted at /data). #[serde(default = "default_db_path")] pub db_path: String, } #[derive(Debug, Clone, Deserialize)] pub struct OpenSearchConfig { pub url: String, pub index: String, #[serde(default = "default_batch_size")] pub batch_size: usize, #[serde(default = "default_flush_interval_ms")] pub flush_interval_ms: u64, #[serde(default = "default_embedding_pipeline")] pub embedding_pipeline: String, #[serde(default = "default_memory_index")] pub memory_index: String, } #[derive(Debug, Clone, Deserialize)] pub struct MistralConfig { #[serde(default = "default_model")] pub default_model: String, #[serde(default = "default_evaluation_model")] pub evaluation_model: String, #[serde(default = "default_research_model")] pub research_model: String, #[serde(default = "default_max_tool_iterations")] pub max_tool_iterations: usize, } #[derive(Debug, Clone, Deserialize)] pub struct BehaviorConfig { #[serde(default = "default_response_delay_min_ms")] pub response_delay_min_ms: u64, #[serde(default = "default_response_delay_max_ms")] pub response_delay_max_ms: u64, #[serde(default = "default_spontaneous_delay_min_ms")] pub spontaneous_delay_min_ms: u64, #[serde(default = "default_spontaneous_delay_max_ms")] pub spontaneous_delay_max_ms: u64, #[serde(default = "default_spontaneous_threshold")] pub spontaneous_threshold: f32, #[serde(default = "default_room_context_window")] pub room_context_window: usize, #[serde(default = "default_dm_context_window")] pub dm_context_window: usize, #[serde(default = "default_backfill_on_join")] pub backfill_on_join: bool, #[serde(default = "default_backfill_limit")] pub backfill_limit: usize, #[serde(default)] pub instant_responses: bool, #[serde(default = "default_cooldown_after_response_ms")] pub cooldown_after_response_ms: u64, #[serde(default = "default_evaluation_context_window")] pub evaluation_context_window: usize, #[serde(default = "default_detect_sol_in_conversation")] pub detect_sol_in_conversation: bool, #[serde(default)] pub evaluation_prompt_active: Option, #[serde(default)] pub evaluation_prompt_passive: Option, #[serde(default = "default_reaction_threshold")] pub reaction_threshold: f32, #[serde(default = "default_reaction_enabled")] pub reaction_enabled: bool, #[serde(default = "default_script_timeout_secs")] pub script_timeout_secs: u64, #[serde(default = "default_script_max_heap_mb")] pub script_max_heap_mb: usize, #[serde(default)] pub script_fetch_allowlist: Vec, #[serde(default = "default_memory_extraction_enabled")] pub memory_extraction_enabled: bool, } #[derive(Debug, Clone, Deserialize, Default)] pub struct ServicesConfig { #[serde(default)] pub gitea: Option, } #[derive(Debug, Clone, Deserialize)] pub struct GiteaConfig { pub url: String, } #[derive(Debug, Clone, Deserialize)] pub struct VaultConfig { /// OpenBao/Vault URL. Default: http://openbao.data.svc.cluster.local:8200 #[serde(default = "default_vault_url")] pub url: String, /// Kubernetes auth role name. Default: sol-agent #[serde(default = "default_vault_role")] pub role: String, /// KV v2 mount path. Default: secret #[serde(default = "default_vault_mount")] pub mount: String, } impl Default for VaultConfig { fn default() -> Self { Self { url: default_vault_url(), role: default_vault_role(), mount: default_vault_mount(), } } } fn default_vault_url() -> String { "http://openbao.data.svc.cluster.local:8200".into() } fn default_vault_role() -> String { "sol-agent".into() } fn default_vault_mount() -> String { "secret".into() } fn default_batch_size() -> usize { 50 } fn default_flush_interval_ms() -> u64 { 2000 } fn default_embedding_pipeline() -> String { "tuwunel_embedding_pipeline".into() } fn default_model() -> String { "mistral-medium-latest".into() } fn default_evaluation_model() -> String { "ministral-3b-latest".into() } fn default_research_model() -> String { "mistral-large-latest".into() } fn default_max_tool_iterations() -> usize { 5 } fn default_response_delay_min_ms() -> u64 { 100 } fn default_response_delay_max_ms() -> u64 { 2300 } fn default_spontaneous_delay_min_ms() -> u64 { 15000 } fn default_spontaneous_delay_max_ms() -> u64 { 60000 } fn default_spontaneous_threshold() -> f32 { 0.85 } fn default_cooldown_after_response_ms() -> u64 { 15000 } fn default_evaluation_context_window() -> usize { 200 } fn default_detect_sol_in_conversation() -> bool { true } fn default_reaction_threshold() -> f32 { 0.6 } fn default_reaction_enabled() -> bool { true } fn default_room_context_window() -> usize { 200 } fn default_dm_context_window() -> usize { 200 } fn default_backfill_on_join() -> bool { true } fn default_backfill_limit() -> usize { 10000 } fn default_script_timeout_secs() -> u64 { 5 } fn default_script_max_heap_mb() -> usize { 64 } fn default_memory_index() -> String { "sol_user_memory".into() } fn default_memory_extraction_enabled() -> bool { true } fn default_db_path() -> String { "/data/sol.db".into() } fn default_compaction_threshold() -> u32 { 118000 } // ~90% of 131K context window impl Config { pub fn load(path: &str) -> anyhow::Result { let content = std::fs::read_to_string(path)?; let config: Config = toml::from_str(&content)?; Ok(config) } pub fn from_str(content: &str) -> anyhow::Result { let config: Config = toml::from_str(content)?; Ok(config) } } #[cfg(test)] mod tests { use super::*; const MINIMAL_CONFIG: &str = r#" [matrix] homeserver_url = "https://chat.sunbeam.pt" user_id = "@sol:sunbeam.pt" state_store_path = "/data/sol/state" [opensearch] url = "http://opensearch:9200" index = "sol-archive" [mistral] [behavior] "#; const FULL_CONFIG: &str = r#" [matrix] homeserver_url = "https://chat.sunbeam.pt" user_id = "@sol:sunbeam.pt" state_store_path = "/data/sol/state" [opensearch] url = "http://opensearch:9200" index = "sol-archive" batch_size = 100 flush_interval_ms = 5000 embedding_pipeline = "my_pipeline" [mistral] default_model = "mistral-large-latest" evaluation_model = "ministral-8b-latest" research_model = "mistral-large-latest" max_tool_iterations = 10 [behavior] response_delay_min_ms = 1000 response_delay_max_ms = 5000 spontaneous_delay_min_ms = 10000 spontaneous_delay_max_ms = 30000 spontaneous_threshold = 0.8 room_context_window = 50 dm_context_window = 200 backfill_on_join = false backfill_limit = 5000 "#; #[test] fn test_minimal_config_with_defaults() { let config = Config::from_str(MINIMAL_CONFIG).unwrap(); assert_eq!(config.matrix.homeserver_url, "https://chat.sunbeam.pt"); assert_eq!(config.matrix.user_id, "@sol:sunbeam.pt"); assert_eq!(config.matrix.state_store_path, "/data/sol/state"); assert_eq!(config.opensearch.url, "http://opensearch:9200"); assert_eq!(config.opensearch.index, "sol-archive"); // Check defaults assert_eq!(config.opensearch.batch_size, 50); assert_eq!(config.opensearch.flush_interval_ms, 2000); assert_eq!(config.opensearch.embedding_pipeline, "tuwunel_embedding_pipeline"); assert_eq!(config.opensearch.memory_index, "sol_user_memory"); assert_eq!(config.mistral.default_model, "mistral-medium-latest"); assert_eq!(config.mistral.evaluation_model, "ministral-3b-latest"); assert_eq!(config.mistral.research_model, "mistral-large-latest"); assert_eq!(config.mistral.max_tool_iterations, 5); assert_eq!(config.behavior.response_delay_min_ms, 100); assert_eq!(config.behavior.response_delay_max_ms, 2300); assert_eq!(config.behavior.spontaneous_delay_min_ms, 15000); assert_eq!(config.behavior.spontaneous_delay_max_ms, 60000); assert!((config.behavior.spontaneous_threshold - 0.85).abs() < f32::EPSILON); assert!(!config.behavior.instant_responses); assert_eq!(config.behavior.cooldown_after_response_ms, 15000); assert_eq!(config.behavior.room_context_window, 200); assert_eq!(config.behavior.dm_context_window, 200); assert!(config.behavior.backfill_on_join); assert_eq!(config.behavior.backfill_limit, 10000); assert!(config.behavior.memory_extraction_enabled); } #[test] fn test_full_config_overrides() { let config = Config::from_str(FULL_CONFIG).unwrap(); assert_eq!(config.opensearch.batch_size, 100); assert_eq!(config.opensearch.flush_interval_ms, 5000); assert_eq!(config.opensearch.embedding_pipeline, "my_pipeline"); assert_eq!(config.mistral.default_model, "mistral-large-latest"); assert_eq!(config.mistral.evaluation_model, "ministral-8b-latest"); assert_eq!(config.mistral.max_tool_iterations, 10); assert_eq!(config.behavior.response_delay_min_ms, 1000); assert_eq!(config.behavior.response_delay_max_ms, 5000); assert!((config.behavior.spontaneous_threshold - 0.8).abs() < f32::EPSILON); assert_eq!(config.behavior.room_context_window, 50); assert_eq!(config.behavior.dm_context_window, 200); assert!(!config.behavior.backfill_on_join); assert_eq!(config.behavior.backfill_limit, 5000); } #[test] fn test_missing_required_section_fails() { let bad = r#" [matrix] homeserver_url = "https://chat.sunbeam.pt" user_id = "@sol:sunbeam.pt" state_store_path = "/data/sol/state" "#; assert!(Config::from_str(bad).is_err()); } #[test] fn test_services_config_default_is_none() { let config = Config::from_str(MINIMAL_CONFIG).unwrap(); assert!(config.services.gitea.is_none()); } #[test] fn test_services_config_with_gitea() { let with_services = format!( "{}\n[services.gitea]\nurl = \"http://gitea:3000\"\n", MINIMAL_CONFIG ); let config = Config::from_str(&with_services).unwrap(); let gitea = config.services.gitea.unwrap(); assert_eq!(gitea.url, "http://gitea:3000"); } #[test] fn test_missing_required_field_fails() { let bad = r#" [matrix] homeserver_url = "https://chat.sunbeam.pt" state_store_path = "/data/sol/state" [opensearch] url = "http://opensearch:9200" index = "sol-archive" [mistral] [behavior] "#; assert!(Config::from_str(bad).is_err()); } }