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
229 lines
8.4 KiB
Rust
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);
|
|
}
|
|
}
|