colibri/crates/colibri-daemon/src/config.rs
Sam & Hermes b06d244e85 feat: cache warming on daemon startup + periodic re-warm (T1.4 PR3b)
Disabled by default. Enables DeepSeek prefix cache warming:
- Fire-and-forget probe on daemon startup
- Optional periodic re-warming (COLIBRI_CACHE_WARMING_INTERVAL_HOURS)
- Cache warming metrics exposed in daemon status
- Fix: Box<dyn Error> -> Box<dyn Error + Send + Sync> in deepseek crate

Config: COLIBRI_CACHE_WARMING=0|1, COLIBRI_CACHE_WARMING_INTERVAL_HOURS=N
Status: cache_warming.enabled, .last_warm_at, .last_warm_cache_hit,
        .last_warm_hit_tokens

Build: pass | Tests: workspace green | Fmt: clean
2026-05-31 17:33:53 +02:00

229 lines
8.4 KiB
Rust

//! Daemon configuration from environment variables.
//!
//! Replaces TypeScript `process.env` + zod validation scattered across
//! agent-runner.ts and agent-session.ts.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Daemon configuration
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaemonConfig {
/// Directory for session JSONL files and state.
pub data_dir: PathBuf,
/// Path for the Herdr Unix socket.
pub socket_path: PathBuf,
/// Maximum bytes per session before automatic rollover.
pub session_max_bytes: u64,
/// Path to the coordination SQLite database.
pub db_path: PathBuf,
/// DeepSeek API key for provider routing.
pub deepseek_api_key: Option<String>,
/// DeepSeek API endpoint.
pub deepseek_endpoint: String,
/// DeepSeek model string.
pub deepseek_model: String,
/// OpenRouter API key for fallback routing.
pub openrouter_api_key: Option<String>,
/// OpenRouter API endpoint.
pub openrouter_endpoint: String,
/// Anthropic API key for fallback routing.
pub anthropic_api_key: Option<String>,
/// Anthropic API endpoint.
pub anthropic_endpoint: String,
/// Host identifier.
pub host: String,
/// Maximum context window tokens (for compaction trigger).
pub max_context_tokens: u64,
/// Compaction target: max turns to keep uncompacted.
pub max_uncompacted_turns: usize,
/// Current cost mode (fast/smart/max).
pub cost_mode: String,
/// Inject session prompt context into spawned agent env (T1.4 PR3a).
/// Disabled by default — enable when prompt discipline is verified.
pub scheduler_prompt_injection: bool,
/// Enable DeepSeek prefix cache warming on daemon startup (T1.4 PR3b).
/// Disabled by default — warming consumes ~3,500 tokens per cycle.
pub cache_warming_enabled: bool,
/// Re-warm cache every N hours. 0 = only warm once on startup.
pub cache_warming_interval_hours: u64,
}
impl DaemonConfig {
/// Build configuration from environment variables with sensible defaults.
pub fn from_env() -> Self {
let host = std::env::var("COLIBRI_HOST")
.or_else(|_| std::env::var("HOSTNAME"))
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| "unknown".to_string());
let data_dir = std::env::var("COLIBRI_DAEMON_DATA_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| {
dirs_data().unwrap_or_else(|| PathBuf::from("/tmp/colibri-daemon"))
});
let socket_path = std::env::var("COLIBRI_DAEMON_SOCKET")
.map(PathBuf::from)
.unwrap_or_else(|_| data_dir.join("colibri-daemon.sock"));
let db_path = colibri_store::default_db_path();
Self {
data_dir,
socket_path,
db_path,
session_max_bytes: env_parse("COLIBRI_SESSION_MAX_BYTES").unwrap_or(2_000_000),
deepseek_api_key: nonempty_env("DEEPSEEK_API_KEY"),
deepseek_endpoint: std::env::var("DEEPSEEK_ENDPOINT")
.unwrap_or_else(|_| "https://api.deepseek.com/chat/completions".to_string()),
deepseek_model: std::env::var("DEEPSEEK_MODEL")
.unwrap_or_else(|_| "deepseek-chat".to_string()),
openrouter_api_key: nonempty_env("OPENROUTER_API_KEY"),
openrouter_endpoint: std::env::var("OPENROUTER_ENDPOINT")
.unwrap_or_else(|_| "https://openrouter.ai/api/v1/chat/completions".to_string()),
anthropic_api_key: nonempty_env("ANTHROPIC_API_KEY"),
anthropic_endpoint: std::env::var("ANTHROPIC_ENDPOINT")
.unwrap_or_else(|_| "https://api.anthropic.com/v1/messages".to_string()),
host,
max_context_tokens: env_parse("COLIBRI_MAX_CONTEXT_TOKENS").unwrap_or(128_000),
max_uncompacted_turns: env_parse("COLIBRI_MAX_UNCOMPACTED_TURNS").unwrap_or(20),
cost_mode: std::env::var("COLIBRI_COST_MODE").unwrap_or_else(|_| "smart".to_string()),
scheduler_prompt_injection: env_parse("COLIBRI_SCHEDULER_PROMPT_INJECTION")
.unwrap_or(false),
cache_warming_enabled: env_parse("COLIBRI_CACHE_WARMING").unwrap_or(false),
cache_warming_interval_hours: env_parse("COLIBRI_CACHE_WARMING_INTERVAL_HOURS")
.unwrap_or(0),
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn nonempty_env(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|v| !v.trim().is_empty())
}
fn env_parse<T: std::str::FromStr>(name: &str) -> Option<T> {
std::env::var(name).ok().and_then(|v| v.parse().ok())
}
fn dirs_data() -> Option<PathBuf> {
std::env::var("XDG_DATA_HOME")
.ok()
.filter(|v| !v.trim().is_empty())
.map(PathBuf::from)
.or_else(|| {
std::env::var("HOME").ok().map(|h| {
PathBuf::from(h)
.join(".local")
.join("share")
.join("colibri-daemon")
})
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn test_config_defaults_from_empty_env() {
let _guard = ENV_LOCK.lock().unwrap();
// Safety: temporarily clear relevant env vars
let vars = [
"COLIBRI_HOST",
"HOSTNAME",
"COLIBRI_DAEMON_DATA_DIR",
"XDG_DATA_HOME",
"COLIBRI_DAEMON_SOCKET",
"COLIBRI_SESSION_MAX_BYTES",
"DEEPSEEK_API_KEY",
"DEEPSEEK_ENDPOINT",
"DEEPSEEK_MODEL",
"OPENROUTER_API_KEY",
"OPENROUTER_ENDPOINT",
"ANTHROPIC_API_KEY",
"ANTHROPIC_ENDPOINT",
"COLIBRI_MAX_CONTEXT_TOKENS",
"COLIBRI_MAX_UNCOMPACTED_TURNS",
];
let saved: Vec<(&str, Option<String>)> = vars
.iter()
.map(|&name| (name, std::env::var(name).ok()))
.collect();
for &name in &vars {
std::env::remove_var(name);
}
let config = DaemonConfig::from_env();
// Restore saved env
for (name, val) in saved {
if let Some(v) = val {
std::env::set_var(name, v);
} else {
std::env::remove_var(name);
}
}
assert_eq!(config.host, "unknown");
assert!(config.data_dir.to_string_lossy().contains("colibri-daemon"));
assert_eq!(config.session_max_bytes, 2_000_000);
assert_eq!(config.deepseek_model, "deepseek-chat");
assert_eq!(config.max_context_tokens, 128_000);
assert_eq!(config.max_uncompacted_turns, 20);
assert!(config.deepseek_api_key.is_none());
assert!(config.openrouter_api_key.is_none());
assert!(config.anthropic_api_key.is_none());
}
#[test]
fn test_config_from_env_vars() {
let _guard = ENV_LOCK.lock().unwrap();
let vars = [
"COLIBRI_HOST",
"DEEPSEEK_API_KEY",
"DEEPSEEK_MODEL",
"COLIBRI_MAX_CONTEXT_TOKENS",
"COLIBRI_SESSION_MAX_BYTES",
];
let saved: Vec<(&str, Option<String>)> = vars
.iter()
.map(|&name| (name, std::env::var(name).ok()))
.collect();
std::env::set_var("COLIBRI_HOST", "test-host");
std::env::set_var("DEEPSEEK_API_KEY", "sk-test");
std::env::set_var("DEEPSEEK_MODEL", "deepseek-v4");
std::env::set_var("COLIBRI_MAX_CONTEXT_TOKENS", "64000");
std::env::set_var("COLIBRI_SESSION_MAX_BYTES", "500000");
let config = DaemonConfig::from_env();
for (name, val) in saved {
if let Some(v) = val {
std::env::set_var(name, v);
} else {
std::env::remove_var(name);
}
}
assert_eq!(config.host, "test-host");
assert_eq!(config.deepseek_api_key.as_deref(), Some("sk-test"));
assert_eq!(config.deepseek_model, "deepseek-v4");
assert_eq!(config.max_context_tokens, 64000);
assert_eq!(config.session_max_bytes, 500000);
}
}