//! Configuration types for GyanDoot agents and platform. //! //! Provides [`GyandootConfig`] as the canonical TOML-deserializable //! configuration, [`ConfigLoader`] for layered resolution //! (env vars > profile > default file > hardcoded defaults), and //! backward-compatible aliases used by the rest of the workspace. use std::collections::HashMap; use std::path::PathBuf; use crate::ConfigError; use serde::{Deserialize, Serialize}; // ─── GyandootConfig (canonical TOML schema) ─────────────────────────────── /// Top-level TOML-deserializable configuration for GyanDoot. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GyandootConfig { #[serde(default)] pub agent: AgentSection, #[serde(default)] pub providers: ProvidersSection, #[serde(default)] pub platform: PlatformSection, #[serde(default)] pub memory: MemorySection, #[serde(default)] pub gateway: GatewaySection, #[serde(default)] pub browser: BrowserSection, #[serde(default)] pub identity: IdentitySection, } impl Default for GyandootConfig { fn default() -> Self { Self { agent: AgentSection::default(), providers: ProvidersSection::default(), platform: PlatformSection::default(), memory: MemorySection::default(), gateway: GatewaySection::default(), browser: BrowserSection::default(), identity: IdentitySection::default(), } } } /// `[agent]` section. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentSection { /// Agent display name. #[serde(default = "default_agent_name")] pub name: String, /// Default model identifier. #[serde(default = "default_agent_model")] pub model: String, /// Maximum tokens per response. #[serde(default = "default_max_tokens")] pub max_tokens: usize, /// Sampling temperature (0.0 – 2.0). #[serde(default = "default_temperature")] pub temperature: f64, /// System prompt prepended to every conversation. #[serde(default)] pub system_prompt: String, } fn default_agent_name() -> String { "gyandoot".to_string() } fn default_agent_model() -> String { "gpt-4o".to_string() } fn default_max_tokens() -> usize { 4096 } fn default_temperature() -> f64 { 0.7 } impl Default for AgentSection { fn default() -> Self { Self { name: default_agent_name(), model: default_agent_model(), max_tokens: default_max_tokens(), temperature: default_temperature(), system_prompt: String::new(), } } } /// `[providers]` section — maps provider name → per-provider config. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProvidersSection { /// Default provider key (e.g. "openai"). #[serde(default = "default_provider_default")] pub default: String, /// Provider-specific settings keyed by name. #[serde(default, flatten)] pub providers: HashMap, } fn default_provider_default() -> String { "openai".to_string() } impl Default for ProvidersSection { fn default() -> Self { let mut providers = HashMap::new(); providers.insert( "openai".to_string(), toml::Value::Table({ let mut m = toml::map::Map::new(); m.insert("model".to_string(), toml::Value::String("gpt-4o".to_string())); m }), ); Self { default: default_provider_default(), providers, } } } /// `[platform]` section. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlatformSection { /// Default platform (e.g. "cli", "telegram", "discord"). #[serde(default = "default_platform_default")] pub default: String, /// Platform-specific config (flattened for extensibility). #[serde(default, flatten)] pub platforms: HashMap, } fn default_platform_default() -> String { "cli".to_string() } impl Default for PlatformSection { fn default() -> Self { Self { default: default_platform_default(), platforms: HashMap::new(), } } } /// `[memory]` section. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MemorySection { /// Memory backend (e.g. "local", "redis"). #[serde(default = "default_memory_backend")] pub backend: String, /// Storage path for local backend. #[serde(default)] pub path: Option, /// Maximum entries before compression / eviction. #[serde(default = "default_memory_max_entries")] pub max_entries: usize, /// Whether auto-compression is enabled. #[serde(default = "default_true")] pub auto_compress: bool, } fn default_memory_backend() -> String { "local".to_string() } fn default_memory_max_entries() -> usize { 10000 } fn default_true() -> bool { true } impl Default for MemorySection { fn default() -> Self { Self { backend: default_memory_backend(), path: None, max_entries: default_memory_max_entries(), auto_compress: true, } } } /// `[gateway]` section. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GatewaySection { /// Whether the gateway server is enabled. #[serde(default = "default_false")] pub enabled: bool, /// TCP port for the gateway. #[serde(default = "default_gateway_port")] pub port: u16, /// Bind address / host. #[serde(default = "default_gateway_host")] pub host: String, } fn default_false() -> bool { false } fn default_gateway_port() -> u16 { 8765 } fn default_gateway_host() -> String { "127.0.0.1".to_string() } impl Default for GatewaySection { fn default() -> Self { Self { enabled: false, port: default_gateway_port(), host: default_gateway_host(), } } } /// `[browser]` section. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BrowserSection { /// Whether the browser automation layer is enabled. #[serde(default = "default_false")] pub enabled: bool, /// Camofox URL for browser automation. #[serde(default)] pub camofox_url: Option, } impl Default for BrowserSection { fn default() -> Self { Self { enabled: false, camofox_url: None, } } } /// `[identity]` section — agent and user identity for MemPalace onboarding. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IdentitySection { /// Agent's name (e.g. "GyanDoot", "Maya"). #[serde(default)] pub agent_name: String, /// Agent's role / personality description. #[serde(default)] pub agent_role: String, /// User's name (the human operating the agent). #[serde(default)] pub user_name: String, /// User's role / relationship to the agent. #[serde(default)] pub user_role: String, /// Whether identity onboarding has been completed. #[serde(default)] pub setup_complete: bool, } impl Default for IdentitySection { fn default() -> Self { Self { agent_name: String::new(), agent_role: String::new(), user_name: String::new(), user_role: String::new(), setup_complete: false, } } } // ─── Legacy / backward-compatible config types ────────────────────────── // These are re-exported under their original names so downstream crates // (gyandoot-agent, gyandoot-providers, etc.) continue to compile unchanged. /// Top-level agent configuration (backward-compatible). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentConfig { pub name: String, pub model: String, pub max_tokens: usize, pub temperature: f64, pub system_prompt: String, pub provider: ProviderRootConfig, pub platform: PlatformRootConfig, pub memory: MemoryRootConfig, pub security: SecurityRootConfig, pub kanban: KanbanRootConfig, pub skills: SkillsRootConfig, pub identity: IdentitySection, } impl Default for AgentConfig { fn default() -> Self { Self { name: default_agent_name(), model: default_agent_model(), max_tokens: default_max_tokens(), temperature: default_temperature(), system_prompt: String::new(), provider: ProviderRootConfig::default(), platform: PlatformRootConfig::default(), memory: MemoryRootConfig::default(), security: SecurityRootConfig::default(), kanban: KanbanRootConfig::default(), skills: SkillsRootConfig::default(), identity: IdentitySection::default(), } } } impl From for AgentConfig { fn from(cfg: GyandootConfig) -> Self { // Extract provider-specific configs from the providers map let openai = cfg.providers.providers.get("openai").map(|v| OpenAIConfig::from_toml_value(v)); let anthropic = cfg.providers.providers.get("anthropic").map(|v| AnthropicConfig::from_toml_value(v)); let ollama = cfg.providers.providers.get("ollama").map(|v| OllamaConfig::from_toml_value(v)); Self { name: cfg.agent.name, model: cfg.agent.model, max_tokens: cfg.agent.max_tokens, temperature: cfg.agent.temperature, system_prompt: cfg.agent.system_prompt, provider: ProviderRootConfig { default: cfg.providers.default, openai, anthropic, ollama, }, platform: PlatformRootConfig { default: cfg.platform.default, telegram: None, discord: None, webhook: None, }, memory: MemoryRootConfig { backend: cfg.memory.backend, storage_path: cfg.memory.path, max_entries: cfg.memory.max_entries, auto_compress: cfg.memory.auto_compress, }, security: SecurityRootConfig::default(), kanban: KanbanRootConfig::default(), skills: SkillsRootConfig::default(), identity: cfg.identity, } } } /// Provider configurations (backward-compatible). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProviderRootConfig { pub default: String, pub openai: Option, pub anthropic: Option, pub ollama: Option, } impl Default for ProviderRootConfig { fn default() -> Self { Self { default: "openai".to_string(), openai: Some(OpenAIConfig::default()), anthropic: None, ollama: None, } } } /// Per-provider config struct used by gyandoot-agent's `create_provider`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OpenAIConfig { pub api_key: String, pub model: String, pub base_url: Option, pub max_retries: u32, } impl OpenAIConfig { fn from_toml_value(v: &toml::Value) -> Self { let table = match v.as_table() { Some(t) => t, None => return Self::default(), }; Self { api_key: table.get("api_key").and_then(|v| v.as_str()).unwrap_or_default().to_string(), model: table.get("model").and_then(|v| v.as_str()).unwrap_or_else(|| "gpt-4o").to_string(), base_url: table.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string()), max_retries: table.get("max_retries").and_then(|v| v.as_integer()).unwrap_or(3) as u32, } } } impl Default for OpenAIConfig { fn default() -> Self { Self { api_key: String::new(), model: "gpt-4o".to_string(), base_url: None, max_retries: 3, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AnthropicConfig { pub api_key: String, pub model: String, pub max_retries: u32, } impl AnthropicConfig { fn from_toml_value(v: &toml::Value) -> Self { let table = match v.as_table() { Some(t) => t, None => return Self::default(), }; Self { api_key: table.get("api_key").and_then(|v| v.as_str()).unwrap_or_default().to_string(), model: table.get("model").and_then(|v| v.as_str()).unwrap_or_else(|| "claude-3-5-sonnet-20241022").to_string(), max_retries: table.get("max_retries").and_then(|v| v.as_integer()).unwrap_or(3) as u32, } } } impl Default for AnthropicConfig { fn default() -> Self { Self { api_key: String::new(), model: "claude-3-5-sonnet-20241022".to_string(), max_retries: 3, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OllamaConfig { pub base_url: String, pub model: String, #[serde(default)] pub api_key: Option, pub max_retries: u32, } impl OllamaConfig { fn from_toml_value(v: &toml::Value) -> Self { let table = match v.as_table() { Some(t) => t, None => return Self::default(), }; Self { base_url: table.get("base_url").and_then(|v| v.as_str()).unwrap_or("http://localhost:11434").to_string(), model: table.get("model").and_then(|v| v.as_str()).unwrap_or("llama3").to_string(), api_key: table.get("api_key").and_then(|v| v.as_str()).map(|s| s.to_string()), max_retries: table.get("max_retries").and_then(|v| v.as_integer()).unwrap_or(2) as u32, } } } impl Default for OllamaConfig { fn default() -> Self { Self { base_url: "http://localhost:11434".to_string(), model: "llama3".to_string(), api_key: None, max_retries: 2, } } } // Backward-compatible wrappers pub type ProviderConfig = ProviderRootConfig; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlatformRootConfig { pub default: String, pub telegram: Option, pub discord: Option, pub webhook: Option, } impl Default for PlatformRootConfig { fn default() -> Self { Self { default: "cli".to_string(), telegram: None, discord: None, webhook: None, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TelegramConfig { pub bot_token: String, pub allowed_users: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DiscordConfig { pub bot_token: String, pub guild_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebhookConfig { pub listen_host: String, pub listen_port: u16, pub secret: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MemoryRootConfig { pub backend: String, pub storage_path: Option, pub max_entries: usize, pub auto_compress: bool, } impl Default for MemoryRootConfig { fn default() -> Self { Self { backend: "local".to_string(), storage_path: None, max_entries: 10000, auto_compress: true, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SecurityRootConfig { pub vault_path: Option, pub encryption_enabled: bool, pub threat_detection: bool, } impl Default for SecurityRootConfig { fn default() -> Self { Self { vault_path: None, encryption_enabled: true, threat_detection: true, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KanbanRootConfig { pub enabled: bool, pub max_concurrent_tasks: usize, pub auto_decompose: bool, } impl Default for KanbanRootConfig { fn default() -> Self { Self { enabled: true, max_concurrent_tasks: 5, auto_decompose: true, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SkillsRootConfig { pub enabled: bool, pub skills_dir: Option, pub auto_load: bool, } impl Default for SkillsRootConfig { fn default() -> Self { Self { enabled: true, skills_dir: None, auto_load: true, } } } // ─── ConfigLoader ───────────────────────────────────────────────────────── /// Layered configuration loader. /// /// Resolution order (later overrides earlier): /// 1. Hardcoded defaults /// 2. Default file (`~/.config/gyandoot/config.toml`) /// 3. Profile file (`~/.gyandoot/config.yaml` — legacy) /// 4. Environment variables (`GYANDOOT_*`) /// /// The final result is a [`GyandootConfig`] which can also be converted /// to the legacy [`AgentConfig`] via `.into()`. #[derive(Debug, Clone)] pub struct ConfigLoader { env_prefix: String, } impl Default for ConfigLoader { fn default() -> Self { Self::new() } } impl ConfigLoader { /// Create a new loader with the default `GYANDOOT_` prefix. pub fn new() -> Self { Self { env_prefix: "GYANDOOT_".to_string(), } } /// Create a loader with a custom environment-variable prefix. pub fn with_prefix(prefix: impl Into) -> Self { Self { env_prefix: prefix.into(), } } /// Read and parse a single TOML config file. pub fn from_file(path: &std::path::Path) -> Result { let content = std::fs::read_to_string(path) .map_err(|e| ConfigError::FileNotFound { path: path.to_path_buf(), source: e, })?; toml::from_str(&content).map_err(|e| ConfigError::ParseError { path: path.to_path_buf(), detail: e.to_string(), }) } /// Override a config with environment variables. /// /// Supported variables: /// - `GYANDOOT_AGENT_NAME`, `GYANDOOT_AGENT_MODEL`, /// `GYANDOOT_AGENT_MAX_TOKENS`, `GYANDOOT_AGENT_TEMPERATURE`, /// `GYANDOOT_AGENT_SYSTEM_PROMPT` /// - `GYANDOOT_PROVIDERS_DEFAULT` /// - `GYANDOOT_PLATFORM_DEFAULT` /// - `GYANDOOT_MEMORY_BACKEND`, `GYANDOOT_MEMORY_PATH` /// - `GYANDOOT_GATEWAY_ENABLED`, `GYANDOOT_GATEWAY_PORT`, `GYANDOOT_GATEWAY_HOST` /// - `GYANDOOT_BROWSER_ENABLED`, `GYANDOOT_BROWSER_CAMOFOX_URL` /// - Provider-specific: `GYANDOOT_OPENAI_API_KEY`, `GYANDOOT_OPENAI_MODEL`, /// `GYANDOOT_OPENAI_BASE_URL`, `GYANDOOT_ANTHROPIC_API_KEY`, etc. pub fn from_env(&self, config: &mut GyandootConfig) { #[allow(unused_macros)] macro_rules! env_opt { ($key:literal, $target:expr) => { if let Ok(val) = std::env::var(concat!(env_prefix_str!(), $key)) { $target = val; } }; } // We can't use concat! with a runtime string, so we build keys manually. let prefix = &self.env_prefix; let p = |k: &str| format!("{prefix}{k}"); // Agent if let Ok(v) = std::env::var(p("AGENT_NAME")) { config.agent.name = v; } if let Ok(v) = std::env::var(p("AGENT_MODEL")) { config.agent.model = v; } if let Ok(v) = std::env::var(p("AGENT_MAX_TOKENS")) { if let Ok(n) = v.parse::() { config.agent.max_tokens = n; } } if let Ok(v) = std::env::var(p("AGENT_TEMPERATURE")) { if let Ok(n) = v.parse::() { config.agent.temperature = n; } } if let Ok(v) = std::env::var(p("AGENT_SYSTEM_PROMPT")) { config.agent.system_prompt = v; } // Providers if let Ok(v) = std::env::var(p("PROVIDERS_DEFAULT")) { config.providers.default = v; } // Provider-specific API keys / settings stored in the flatten map for (provider_key, env_suffix, toml_field) in [ ("openai", "OPENAI_API_KEY", "api_key"), ("openai", "OPENAI_MODEL", "model"), ("openai", "OPENAI_BASE_URL", "base_url"), ("anthropic", "ANTHROPIC_API_KEY", "api_key"), ("anthropic", "ANTHROPIC_MODEL", "model"), ("ollama", "OLLAMA_BASE_URL", "base_url"), ("ollama", "OLLAMA_MODEL", "model"), ] { if let Ok(val) = std::env::var(p(env_suffix)) { let entry = config .providers .providers .entry(provider_key.to_string()) .or_insert_with(|| toml::Value::Table(toml::map::Map::new())); if let toml::Value::Table(ref mut table) = entry { table.insert(toml_field.to_string(), toml::Value::String(val)); } } } // Platform if let Ok(v) = std::env::var(p("PLATFORM_DEFAULT")) { config.platform.default = v; } // Memory if let Ok(v) = std::env::var(p("MEMORY_BACKEND")) { config.memory.backend = v; } if let Ok(v) = std::env::var(p("MEMORY_PATH")) { config.memory.path = Some(PathBuf::from(v)); } // Gateway if let Ok(v) = std::env::var(p("GATEWAY_ENABLED")) { config.gateway.enabled = v == "true" || v == "1"; } if let Ok(v) = std::env::var(p("GATEWAY_PORT")) { if let Ok(n) = v.parse::() { config.gateway.port = n; } } if let Ok(v) = std::env::var(p("GATEWAY_HOST")) { config.gateway.host = v; } // Browser if let Ok(v) = std::env::var(p("BROWSER_ENABLED")) { config.browser.enabled = v == "true" || v == "1"; } if let Ok(v) = std::env::var(p("BROWSER_CAMOFOX_URL")) { config.browser.camofox_url = Some(v); } } /// Load configuration using the layered resolution strategy. /// /// Order: hardcoded defaults → `~/.config/gyandoot/config.toml` → /// `~/.gyandoot/config.toml` (legacy) → env vars. pub fn load(&self) -> Result { let mut config = GyandootConfig::default(); // 1. Try XDG-style config file (primary) if let Some(cfg_dir) = dirs::config_dir() { let xdg_path = cfg_dir.join("gyandoot").join("config.toml"); if xdg_path.exists() { if let Ok(loaded) = Self::from_file(&xdg_path) { config = loaded; } } } // 2. Try legacy ~/.gyandoot/config.toml (profile directory) if let Some(home) = dirs::home_dir() { let legacy_path = home.join(".gyandoot").join("config.toml"); if legacy_path.exists() { if let Ok(loaded) = Self::from_file(&legacy_path) { // Legacy overrides XDG for backward compatibility config = loaded; } } } // 3. Environment variable overrides self.from_env(&mut config); Ok(config) } /// Load and convert to the legacy [`AgentConfig`]. pub fn load_agent_config(&self) -> Result { self.load().map(AgentConfig::from) } } /// Convenience: load config from a single TOML file (legacy API). pub fn load_config_from_file(path: &std::path::Path) -> anyhow::Result { let _loader = ConfigLoader::new(); let gyandoot = ConfigLoader::from_file(path)?; Ok(AgentConfig::from(gyandoot)) } // ─── Tests ──────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use std::io::Write; #[test] fn test_default_config() { let config = AgentConfig::default(); assert_eq!(config.name, "gyandoot"); assert_eq!(config.max_tokens, 4096); } #[test] fn test_provider_default() { let provider = ProviderRootConfig::default(); assert_eq!(provider.default, "openai"); assert!(provider.openai.is_some()); } #[test] fn test_gyandoot_config_defaults() { let cfg = GyandootConfig::default(); assert_eq!(cfg.agent.name, "gyandoot"); assert_eq!(cfg.agent.max_tokens, 4096); assert_eq!(cfg.gateway.port, 8765); assert!(!cfg.browser.enabled); } #[test] fn test_gyandoot_to_agent_config() { let gyandoot = GyandootConfig::default(); let agent: AgentConfig = gyandoot.into(); assert_eq!(agent.name, "gyandoot"); } #[test] fn test_config_loader_from_file() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.toml"); let toml_content = r#" [agent] name = "test-agent" model = "gpt-4o" max_tokens = 2048 temperature = 0.5 [providers] default = "ollama" [memory] backend = "redis" path = "/tmp/gyandoot-memory" [gateway] enabled = true port = 9999 host = "0.0.0.0" [browser] enabled = true camofox_url = "http://camofox:9222" "#; std::fs::write(&path, toml_content).unwrap(); let config = ConfigLoader::from_file(&path).unwrap(); assert_eq!(config.agent.name, "test-agent"); assert_eq!(config.agent.max_tokens, 2048); assert_eq!(config.agent.temperature, 0.5); assert_eq!(config.providers.default, "ollama"); assert_eq!(config.memory.backend, "redis"); assert!(config.gateway.enabled); assert_eq!(config.gateway.port, 9999); assert!(config.browser.enabled); assert_eq!( config.browser.camofox_url.as_deref(), Some("http://camofox:9222") ); } #[test] fn test_config_loader_file_not_found() { let err = ConfigLoader::from_file(std::path::Path::new("/nonexistent/gyandoot.toml")); assert!(err.is_err()); } #[test] fn test_config_loader_invalid_toml() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("bad.toml"); std::fs::write(&path, "[[[invalid]]]\n").unwrap(); let err = ConfigLoader::from_file(&path); assert!(err.is_err()); match err { Err(ConfigError::ParseError { .. }) => {} // expected _ => panic!("Expected ParseError"), } } #[test] fn test_agent_config_from_gyandoot_roundtrip() { let gyandoot = GyandootConfig::default(); let agent: AgentConfig = gyandoot.clone().into(); // Verify the conversion preserves defaults assert_eq!(agent.name, gyandoot.agent.name); assert_eq!(agent.model, gyandoot.agent.model); assert_eq!(agent.max_tokens, gyandoot.agent.max_tokens); assert_eq!(agent.temperature, gyandoot.agent.temperature); } }