spec_ai_config/config/
agent_config.rs

1//! Application-level configuration
2//!
3//! Defines the top-level application configuration, including model settings,
4//! database configuration, UI preferences, and logging.
5
6use crate::config::agent::AgentProfile;
7use anyhow::{Context, Result};
8use directories::BaseDirs;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::PathBuf;
12
13/// Embedded default configuration file
14const DEFAULT_CONFIG: &str =
15    include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/spec-ai.config.toml"));
16
17/// Configuration file name
18const CONFIG_FILE_NAME: &str = "spec-ai.config.toml";
19
20/// Top-level application configuration
21#[derive(Debug, Clone, Serialize, Deserialize, Default)]
22pub struct AppConfig {
23    /// Database configuration
24    #[serde(default)]
25    pub database: DatabaseConfig,
26    /// Model provider configuration
27    #[serde(default)]
28    pub model: ModelConfig,
29    /// UI configuration
30    #[serde(default)]
31    pub ui: UiConfig,
32    /// Logging configuration
33    #[serde(default)]
34    pub logging: LoggingConfig,
35    /// Audio transcription configuration
36    #[serde(default)]
37    pub audio: AudioConfig,
38    /// Mesh networking configuration
39    #[serde(default)]
40    pub mesh: MeshConfig,
41    /// Plugin configuration for custom tools
42    #[serde(default)]
43    pub plugins: PluginConfig,
44    /// Graph synchronization configuration
45    #[serde(default)]
46    pub sync: SyncConfig,
47    /// Available agent profiles
48    #[serde(default)]
49    pub agents: HashMap<String, AgentProfile>,
50    /// Default agent to use (if not specified)
51    #[serde(default)]
52    pub default_agent: Option<String>,
53}
54
55impl AppConfig {
56    /// Load configuration from file or create a default configuration
57    pub fn load() -> Result<Self> {
58        // Try to load from spec-ai.config.toml in current directory
59        if let Ok(content) = std::fs::read_to_string(CONFIG_FILE_NAME) {
60            return toml::from_str(&content)
61                .map_err(|e| anyhow::anyhow!("Failed to parse {}: {}", CONFIG_FILE_NAME, e));
62        }
63
64        // Try to load from ~/.spec-ai/spec-ai.config.toml
65        if let Ok(base_dirs) =
66            BaseDirs::new().ok_or(anyhow::anyhow!("Could not determine home directory"))
67        {
68            let home_config = base_dirs.home_dir().join(".spec-ai").join(CONFIG_FILE_NAME);
69            if let Ok(content) = std::fs::read_to_string(&home_config) {
70                return toml::from_str(&content).map_err(|e| {
71                    anyhow::anyhow!("Failed to parse {}: {}", home_config.display(), e)
72                });
73            }
74        }
75
76        // Try to load from environment variable CONFIG_PATH
77        if let Ok(config_path) = std::env::var("CONFIG_PATH") {
78            if let Ok(content) = std::fs::read_to_string(&config_path) {
79                return toml::from_str(&content)
80                    .map_err(|e| anyhow::anyhow!("Failed to parse config: {}", e));
81            }
82        }
83
84        // No config file found - create one from embedded default
85        eprintln!(
86            "No configuration file found. Creating {} with default settings...",
87            CONFIG_FILE_NAME
88        );
89        if let Err(e) = std::fs::write(CONFIG_FILE_NAME, DEFAULT_CONFIG) {
90            eprintln!("Warning: Could not create {}: {}", CONFIG_FILE_NAME, e);
91            eprintln!("Continuing with default configuration in memory.");
92        } else {
93            eprintln!(
94                "Created {}. You can edit this file to customize your settings.",
95                CONFIG_FILE_NAME
96            );
97        }
98
99        // Parse and return the embedded default config
100        toml::from_str(DEFAULT_CONFIG)
101            .map_err(|e| anyhow::anyhow!("Failed to parse embedded default config: {}", e))
102    }
103
104    /// Load configuration from a specific file path
105    /// If the file doesn't exist, creates it with default settings
106    pub fn load_from_file(path: &std::path::Path) -> Result<Self> {
107        // Try to read existing file
108        match std::fs::read_to_string(path) {
109            Ok(content) => toml::from_str(&content).map_err(|e| {
110                anyhow::anyhow!("Failed to parse config file {}: {}", path.display(), e)
111            }),
112            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
113                // File doesn't exist - create it with default config
114                eprintln!(
115                    "Configuration file not found at {}. Creating with default settings...",
116                    path.display()
117                );
118
119                // Create parent directories if needed
120                if let Some(parent) = path.parent() {
121                    std::fs::create_dir_all(parent)
122                        .context(format!("Failed to create directory {}", parent.display()))?;
123                }
124
125                // Write default config
126                std::fs::write(path, DEFAULT_CONFIG).context(format!(
127                    "Failed to create config file at {}",
128                    path.display()
129                ))?;
130
131                eprintln!(
132                    "Created {}. You can edit this file to customize your settings.",
133                    path.display()
134                );
135
136                // Parse and return the embedded default config
137                toml::from_str(DEFAULT_CONFIG)
138                    .map_err(|e| anyhow::anyhow!("Failed to parse embedded default config: {}", e))
139            }
140            Err(e) => Err(anyhow::anyhow!(
141                "Failed to read config file {}: {}",
142                path.display(),
143                e
144            )),
145        }
146    }
147
148    /// Validate the configuration
149    pub fn validate(&self) -> Result<()> {
150        // Validate model provider: must be non-empty and supported
151        if self.model.provider.is_empty() {
152            return Err(anyhow::anyhow!("Model provider cannot be empty"));
153        }
154        // Validate against known provider names independent of compile-time feature flags
155        {
156            let p = self.model.provider.to_lowercase();
157            let known = ["mock", "openai", "anthropic", "ollama", "mlx", "lmstudio"];
158            if !known.contains(&p.as_str()) {
159                return Err(anyhow::anyhow!(
160                    "Invalid model provider: {}",
161                    self.model.provider
162                ));
163            }
164        }
165
166        // Validate temperature
167        if self.model.temperature < 0.0 || self.model.temperature > 2.0 {
168            return Err(anyhow::anyhow!(
169                "Temperature must be between 0.0 and 2.0, got {}",
170                self.model.temperature
171            ));
172        }
173
174        // Validate log level
175        match self.logging.level.as_str() {
176            "trace" | "debug" | "info" | "warn" | "error" => {}
177            _ => return Err(anyhow::anyhow!("Invalid log level: {}", self.logging.level)),
178        }
179
180        // If a default agent is specified, it must exist in the agents map
181        if let Some(default_agent) = &self.default_agent {
182            if !self.agents.contains_key(default_agent) {
183                return Err(anyhow::anyhow!(
184                    "Default agent '{}' not found in agents map",
185                    default_agent
186                ));
187            }
188        }
189
190        Ok(())
191    }
192
193    /// Apply environment variable overrides to the configuration
194    pub fn apply_env_overrides(&mut self) {
195        // Helper: prefer AGENT_* over SPEC_AI_* if both present
196        fn first(a: &str, b: &str) -> Option<String> {
197            std::env::var(a).ok().or_else(|| std::env::var(b).ok())
198        }
199
200        if let Some(provider) = first("AGENT_MODEL_PROVIDER", "SPEC_AI_PROVIDER") {
201            self.model.provider = provider;
202        }
203        if let Some(model_name) = first("AGENT_MODEL_NAME", "SPEC_AI_MODEL") {
204            self.model.model_name = Some(model_name);
205        }
206        if let Some(api_key_source) = first("AGENT_API_KEY_SOURCE", "SPEC_AI_API_KEY_SOURCE") {
207            self.model.api_key_source = Some(api_key_source);
208        }
209        if let Some(temp_str) = first("AGENT_MODEL_TEMPERATURE", "SPEC_AI_TEMPERATURE") {
210            if let Ok(temp) = temp_str.parse::<f32>() {
211                self.model.temperature = temp;
212            }
213        }
214        if let Some(level) = first("AGENT_LOG_LEVEL", "SPEC_AI_LOG_LEVEL") {
215            self.logging.level = level;
216        }
217        if let Some(db_path) = first("AGENT_DB_PATH", "SPEC_AI_DB_PATH") {
218            self.database.path = PathBuf::from(db_path);
219        }
220        if let Some(theme) = first("AGENT_UI_THEME", "SPEC_AI_UI_THEME") {
221            self.ui.theme = theme;
222        }
223        if let Some(default_agent) = first("AGENT_DEFAULT_AGENT", "SPEC_AI_DEFAULT_AGENT") {
224            self.default_agent = Some(default_agent);
225        }
226    }
227
228    /// Get a summary of the configuration
229    pub fn summary(&self) -> String {
230        let mut summary = String::new();
231        summary.push_str("Configuration loaded:\n");
232        summary.push_str(&format!("Database: {}\n", self.database.path.display()));
233        summary.push_str(&format!("Model Provider: {}\n", self.model.provider));
234        if let Some(model) = &self.model.model_name {
235            summary.push_str(&format!("Model Name: {}\n", model));
236        }
237        summary.push_str(&format!("Temperature: {}\n", self.model.temperature));
238        summary.push_str(&format!("Logging Level: {}\n", self.logging.level));
239        summary.push_str(&format!("UI Theme: {}\n", self.ui.theme));
240        summary.push_str(&format!("Available Agents: {}\n", self.agents.len()));
241        if let Some(default) = &self.default_agent {
242            summary.push_str(&format!("Default Agent: {}\n", default));
243        }
244        summary
245    }
246}
247
248/// Database configuration
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct DatabaseConfig {
251    /// Path to the database file
252    pub path: PathBuf,
253}
254
255impl Default for DatabaseConfig {
256    fn default() -> Self {
257        Self {
258            path: PathBuf::from("spec-ai.duckdb"),
259        }
260    }
261}
262
263/// Model provider configuration
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct ModelConfig {
266    /// Provider name (e.g., "openai", "anthropic", "mlx", "lmstudio", "mock")
267    pub provider: String,
268    /// Model name to use (e.g., "gpt-4", "claude-3-opus")
269    #[serde(default)]
270    pub model_name: Option<String>,
271    /// Embeddings model name (optional, for semantic search)
272    #[serde(default)]
273    pub embeddings_model: Option<String>,
274    /// API key source (e.g., environment variable name or path)
275    #[serde(default)]
276    pub api_key_source: Option<String>,
277    /// Default temperature for model completions (0.0 to 2.0)
278    #[serde(default = "default_temperature")]
279    pub temperature: f32,
280}
281
282fn default_temperature() -> f32 {
283    0.7
284}
285
286impl Default for ModelConfig {
287    fn default() -> Self {
288        Self {
289            provider: "mock".to_string(),
290            model_name: None,
291            embeddings_model: None,
292            api_key_source: None,
293            temperature: default_temperature(),
294        }
295    }
296}
297
298/// UI configuration
299#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct UiConfig {
301    /// Command prompt string
302    pub prompt: String,
303    /// UI theme name
304    pub theme: String,
305}
306
307impl Default for UiConfig {
308    fn default() -> Self {
309        Self {
310            prompt: "> ".to_string(),
311            theme: "default".to_string(),
312        }
313    }
314}
315
316/// Logging configuration
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct LoggingConfig {
319    /// Log level (trace, debug, info, warn, error)
320    pub level: String,
321}
322
323impl Default for LoggingConfig {
324    fn default() -> Self {
325        Self {
326            level: "info".to_string(),
327        }
328    }
329}
330
331/// Mesh networking configuration
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct MeshConfig {
334    /// Enable mesh networking
335    #[serde(default)]
336    pub enabled: bool,
337    /// Registry port for mesh coordination
338    #[serde(default = "default_registry_port")]
339    pub registry_port: u16,
340    /// Heartbeat interval in seconds
341    #[serde(default = "default_heartbeat_interval")]
342    pub heartbeat_interval_secs: u64,
343    /// Leader timeout in seconds (how long before new election)
344    #[serde(default = "default_leader_timeout")]
345    pub leader_timeout_secs: u64,
346    /// Replication factor for knowledge graph
347    #[serde(default = "default_replication_factor")]
348    pub replication_factor: usize,
349    /// Auto-join mesh on startup
350    #[serde(default)]
351    pub auto_join: bool,
352}
353
354fn default_registry_port() -> u16 {
355    3000
356}
357
358fn default_heartbeat_interval() -> u64 {
359    5
360}
361
362fn default_leader_timeout() -> u64 {
363    15
364}
365
366fn default_replication_factor() -> usize {
367    2
368}
369
370impl Default for MeshConfig {
371    fn default() -> Self {
372        Self {
373            enabled: false,
374            registry_port: default_registry_port(),
375            heartbeat_interval_secs: default_heartbeat_interval(),
376            leader_timeout_secs: default_leader_timeout(),
377            replication_factor: default_replication_factor(),
378            auto_join: true,
379        }
380    }
381}
382
383/// Audio transcription configuration
384#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct AudioConfig {
386    /// Enable audio transcription
387    #[serde(default)]
388    pub enabled: bool,
389    /// Transcription provider (mock, vttrs)
390    #[serde(default = "default_transcription_provider")]
391    pub provider: String,
392    /// Transcription model (e.g., "whisper-1", "whisper-large-v3")
393    #[serde(default)]
394    pub model: Option<String>,
395    /// API key source for cloud transcription
396    #[serde(default)]
397    pub api_key_source: Option<String>,
398    /// Use on-device transcription (offline mode)
399    #[serde(default)]
400    pub on_device: bool,
401    /// Custom API endpoint (optional)
402    #[serde(default)]
403    pub endpoint: Option<String>,
404    /// Audio chunk duration in seconds
405    #[serde(default = "default_chunk_duration")]
406    pub chunk_duration_secs: f64,
407    /// Default transcription duration in seconds
408    #[serde(default = "default_duration")]
409    pub default_duration_secs: u64,
410    /// Default transcription duration in seconds (legacy field name)
411    #[serde(default = "default_duration")]
412    pub default_duration: u64,
413    /// Output file path for transcripts (optional)
414    #[serde(default)]
415    pub out_file: Option<String>,
416    /// Language code (e.g., "en", "es", "fr")
417    #[serde(default)]
418    pub language: Option<String>,
419    /// Whether to automatically respond to transcriptions
420    #[serde(default)]
421    pub auto_respond: bool,
422    /// Mock scenario for testing (e.g., "simple_conversation", "emotional_context")
423    #[serde(default = "default_mock_scenario")]
424    pub mock_scenario: String,
425    /// Delay between mock transcription events in milliseconds
426    #[serde(default = "default_event_delay_ms")]
427    pub event_delay_ms: u64,
428    /// Speak assistant responses aloud (macOS only, uses `say`)
429    #[serde(default)]
430    pub speak_responses: bool,
431}
432
433fn default_transcription_provider() -> String {
434    "vttrs".to_string()
435}
436
437fn default_chunk_duration() -> f64 {
438    5.0
439}
440
441fn default_duration() -> u64 {
442    30
443}
444
445fn default_mock_scenario() -> String {
446    "simple_conversation".to_string()
447}
448
449fn default_event_delay_ms() -> u64 {
450    500
451}
452
453impl Default for AudioConfig {
454    fn default() -> Self {
455        Self {
456            enabled: false,
457            provider: default_transcription_provider(),
458            model: Some("whisper-1".to_string()),
459            api_key_source: None,
460            on_device: false,
461            endpoint: None,
462            chunk_duration_secs: default_chunk_duration(),
463            default_duration_secs: default_duration(),
464            default_duration: default_duration(),
465            out_file: None,
466            language: None,
467            auto_respond: false,
468            mock_scenario: default_mock_scenario(),
469            event_delay_ms: default_event_delay_ms(),
470            speak_responses: false,
471        }
472    }
473}
474
475/// Plugin configuration for custom tools
476#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct PluginConfig {
478    /// Enable plugin loading
479    #[serde(default)]
480    pub enabled: bool,
481
482    /// Directory containing plugin libraries (.dylib/.so/.dll)
483    #[serde(default = "default_plugins_dir")]
484    pub custom_tools_dir: PathBuf,
485
486    /// Continue startup even if some plugins fail to load
487    #[serde(default = "default_continue_on_error")]
488    pub continue_on_error: bool,
489
490    /// Allow plugins to override built-in tools
491    #[serde(default)]
492    pub allow_override_builtin: bool,
493}
494
495fn default_plugins_dir() -> PathBuf {
496    PathBuf::from("~/.spec-ai/tools")
497}
498
499fn default_continue_on_error() -> bool {
500    true
501}
502
503impl Default for PluginConfig {
504    fn default() -> Self {
505        Self {
506            enabled: false,
507            custom_tools_dir: default_plugins_dir(),
508            continue_on_error: true,
509            allow_override_builtin: false,
510        }
511    }
512}
513
514/// Graph synchronization configuration
515#[derive(Debug, Clone, Serialize, Deserialize)]
516pub struct SyncConfig {
517    /// Enable graph synchronization
518    #[serde(default)]
519    pub enabled: bool,
520
521    /// How often to check for sync opportunities (in seconds)
522    #[serde(default = "default_sync_interval")]
523    pub interval_secs: u64,
524
525    /// Maximum number of concurrent sync operations
526    #[serde(default = "default_max_concurrent_syncs")]
527    pub max_concurrent_syncs: usize,
528
529    /// Retry interval for failed syncs (in seconds)
530    #[serde(default = "default_retry_interval")]
531    pub retry_interval_secs: u64,
532
533    /// Maximum number of retry attempts
534    #[serde(default = "default_max_retries")]
535    pub max_retries: usize,
536
537    /// Graph namespaces to sync automatically on startup
538    #[serde(default)]
539    pub namespaces: Vec<SyncNamespace>,
540}
541
542/// A graph namespace to participate in synchronization
543#[derive(Debug, Clone, Serialize, Deserialize)]
544pub struct SyncNamespace {
545    /// Session ID (namespace) for the graph
546    pub session_id: String,
547    /// Graph name within the session (defaults to "default")
548    #[serde(default = "default_graph_name")]
549    pub graph_name: String,
550}
551
552fn default_sync_interval() -> u64 {
553    60
554}
555
556fn default_max_concurrent_syncs() -> usize {
557    3
558}
559
560fn default_retry_interval() -> u64 {
561    300
562}
563
564fn default_max_retries() -> usize {
565    3
566}
567
568fn default_graph_name() -> String {
569    "default".to_string()
570}
571
572impl Default for SyncConfig {
573    fn default() -> Self {
574        Self {
575            enabled: false,
576            interval_secs: default_sync_interval(),
577            max_concurrent_syncs: default_max_concurrent_syncs(),
578            retry_interval_secs: default_retry_interval(),
579            max_retries: default_max_retries(),
580            namespaces: Vec::new(),
581        }
582    }
583}