use crate::gateway::backend::GatewayBackendConfig;
use agent_types::hook::HookerRegistryConfig;
use agent_types::ReasoningEffort;
use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde_json::Value;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
const CONFIG_ENV_VAR: &str = "XIAOO_CONFIG";
#[derive(Debug, Deserialize, Default)]
pub struct FileConfig {
pub llm: Option<LlmSection>,
pub compact: Option<CompactSection>,
pub skills: Option<SkillsSection>,
#[serde(default)]
pub trace: Option<Value>,
pub hooker: Option<HookerRegistryConfig>,
#[serde(default)]
pub operation_backend: Option<GatewayBackendConfig>,
#[serde(default)]
pub subagent: BTreeMap<String, SubagentRoleConfig>,
}
#[derive(Debug, Deserialize, Default)]
pub struct SkillsSection {
pub dirs: Option<Vec<String>>,
pub allow_scripts: Option<bool>,
}
#[derive(Debug, Deserialize, Default)]
pub struct LlmSection {
pub provider: Option<String>,
pub model: Option<String>,
pub api_key_env: Option<String>,
pub api_base: Option<String>,
pub reasoning_effort: Option<ReasoningEffort>,
pub kvcache_enabled: Option<bool>,
pub kvcache_debug_enabled: Option<bool>,
}
#[derive(Debug, Deserialize, Default)]
pub struct CompactSection {
pub warning_ratio: Option<f64>,
pub auto_compact_ratio: Option<f64>,
pub blocking_ratio: Option<f64>,
pub snip_stale_after_ms: Option<u64>,
pub snip_preserve_tail: Option<usize>,
pub collapse_preserve_tail: Option<usize>,
pub summary_max_tokens: Option<usize>,
pub summary_preserve_tail: Option<usize>,
pub summary_llm_max_tokens: Option<usize>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SubagentRoleConfig {
#[serde(default)]
pub description: String,
#[serde(default)]
pub prompt: Option<String>,
#[serde(default)]
pub max_turns: Option<u32>,
#[serde(default)]
pub tools: BTreeMap<String, bool>,
}
impl FileConfig {
pub fn resolve_path(path: Option<&str>) -> Option<PathBuf> {
if let Some(path) = path.filter(|value| !value.trim().is_empty()) {
return Some(PathBuf::from(path));
}
if let Some(path) = std::env::var_os(CONFIG_ENV_VAR)
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
{
return Some(path);
}
dirs::home_dir().map(|home| home.join(".config").join("xiaoo").join("config.toml"))
}
pub fn load(path: Option<&str>, debug: bool) -> Self {
let Some(path) = Self::resolve_path(path) else {
return Self::default();
};
Self::load_from_path(&path, debug)
}
pub fn load_from_path(path: &Path, debug: bool) -> Self {
match std::fs::read_to_string(&path) {
Ok(content) => match toml::from_str::<toml::Value>(&content) {
Ok(root) => {
if debug {
eprintln!("[config] loaded {}", path.display());
}
Self {
llm: parse_optional_section(&root, "llm", &path, debug),
compact: parse_optional_section(&root, "compact", &path, debug),
trace: parse_optional_section(&root, "trace", &path, debug),
skills: parse_optional_section(&root, "skills", &path, debug),
hooker: parse_optional_section(&root, "hooker", &path, debug),
operation_backend: parse_optional_section(
&root,
"operation_backend",
&path,
debug,
),
subagent: parse_optional_section(&root, "subagent", &path, debug)
.unwrap_or_default(),
}
}
Err(e) => {
eprintln!("[config] parse error in {}: {}", path.display(), e);
Self::default()
}
},
Err(_) => Self::default(),
}
}
pub fn resolve_api_key(&self) -> Option<String> {
let env_name = self.llm.as_ref()?.api_key_env.as_deref()?.trim();
if env_name.is_empty() {
return None;
}
std::env::var(env_name)
.ok()
.filter(|value| !value.trim().is_empty())
}
}
fn parse_optional_section<T>(
root: &toml::Value,
key: &str,
path: &std::path::Path,
debug: bool,
) -> Option<T>
where
T: DeserializeOwned,
{
let section = root.get(key)?.clone();
match section.try_into() {
Ok(value) => Some(value),
Err(error) => {
if debug {
eprintln!(
"[config] parse error in {} [{}]: {}",
path.display(),
key,
error
);
} else {
eprintln!("[config] parse error in {} [{}]", path.display(), key);
}
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_load_subagent_config() {
let config_content = r#"
[llm]
provider = "anthropic"
model = "claude-sonnet-4-20250514"
[subagent.code_reviewer]
description = "Code review specialist"
prompt = "You are a code review specialist."
max_turns = 5
[subagent.test_writer]
description = "Test writing specialist"
prompt = "You are a test writing specialist."
max_turns = 8
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(config_content.as_bytes()).unwrap();
temp_file.flush().unwrap();
let config = FileConfig::load_from_path(temp_file.path(), false);
assert_eq!(config.subagent.len(), 2);
assert!(config.subagent.contains_key("code_reviewer"));
assert!(config.subagent.contains_key("test_writer"));
let reviewer = config.subagent.get("code_reviewer").unwrap();
assert_eq!(reviewer.description, "Code review specialist");
assert_eq!(
reviewer.prompt,
Some("You are a code review specialist.".to_string())
);
assert_eq!(reviewer.max_turns, Some(5));
let writer = config.subagent.get("test_writer").unwrap();
assert_eq!(writer.description, "Test writing specialist");
assert_eq!(writer.max_turns, Some(8));
}
#[test]
fn test_subagent_tools_config() {
let config_content = r#"
[llm]
provider = "openai"
[subagent.limited_agent]
description = "Agent with limited tools"
tools = { "bash" = true, "read" = true }
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(config_content.as_bytes()).unwrap();
temp_file.flush().unwrap();
let config = FileConfig::load_from_path(temp_file.path(), false);
assert_eq!(config.subagent.len(), 1);
let agent = config.subagent.get("limited_agent").unwrap();
assert_eq!(agent.tools.len(), 2);
assert_eq!(agent.tools.get("bash"), Some(&true));
assert_eq!(agent.tools.get("read"), Some(&true));
assert_eq!(agent.tools.get("write"), None);
}
#[test]
fn test_empty_subagent_config() {
let config_content = r#"
[llm]
provider = "anthropic"
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(config_content.as_bytes()).unwrap();
temp_file.flush().unwrap();
let config = FileConfig::load_from_path(temp_file.path(), false);
assert_eq!(config.subagent.len(), 0);
}
}