1. /quit and /help commands restored: - /quit: exits the interactive loop - /help: shows formatted help box with available commands & tips - Unknown commands get a friendly error pointing to /help - Slash commands intercepted before LLM processing (no token waste) 2. Zero warnings across entire workspace (13 files fixed): - Fixed ambiguous glob re-exports in gyandoot-core - Added #[allow(dead_code)] on intentional dead fields - Fixed unused imports, variables, and macros - Fixed unused assignments in subagents - cleaned up service.rs, profile, kanban warnings install.sh now produces a completely clean build with zero warnings.
903 lines
27 KiB
Rust
903 lines
27 KiB
Rust
//! 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<String, toml::Value>,
|
||
}
|
||
|
||
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<String, toml::Value>,
|
||
}
|
||
|
||
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<PathBuf>,
|
||
/// 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<String>,
|
||
}
|
||
|
||
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<GyandootConfig> 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<OpenAIConfig>,
|
||
pub anthropic: Option<AnthropicConfig>,
|
||
pub ollama: Option<OllamaConfig>,
|
||
}
|
||
|
||
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<String>,
|
||
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<String>,
|
||
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<TelegramConfig>,
|
||
pub discord: Option<DiscordConfig>,
|
||
pub webhook: Option<WebhookConfig>,
|
||
}
|
||
|
||
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<i64>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct DiscordConfig {
|
||
pub bot_token: String,
|
||
pub guild_id: Option<u64>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct WebhookConfig {
|
||
pub listen_host: String,
|
||
pub listen_port: u16,
|
||
pub secret: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct MemoryRootConfig {
|
||
pub backend: String,
|
||
pub storage_path: Option<PathBuf>,
|
||
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<PathBuf>,
|
||
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<PathBuf>,
|
||
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<String>) -> Self {
|
||
Self {
|
||
env_prefix: prefix.into(),
|
||
}
|
||
}
|
||
|
||
/// Read and parse a single TOML config file.
|
||
pub fn from_file(path: &std::path::Path) -> Result<GyandootConfig, ConfigError> {
|
||
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::<usize>() {
|
||
config.agent.max_tokens = n;
|
||
}
|
||
}
|
||
if let Ok(v) = std::env::var(p("AGENT_TEMPERATURE")) {
|
||
if let Ok(n) = v.parse::<f64>() {
|
||
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::<u16>() {
|
||
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<GyandootConfig, ConfigError> {
|
||
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<AgentConfig, ConfigError> {
|
||
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<AgentConfig> {
|
||
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);
|
||
}
|
||
} |