use crate::agent::AgentConfig;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
#[derive(Default)]
pub struct AppConfig {
pub model: ModelConfig,
pub agent: AgentYamlConfig,
pub mcp: McpYamlConfig,
pub channels: ChannelsConfig,
pub server: ServerConfig,
pub logging: LoggingConfig,
}
impl AppConfig {
pub fn to_agent_config(&self) -> AgentConfig {
AgentConfig::standard(
&self.model.name,
&self.agent.name,
&self.agent.system_prompt,
)
.enable_tool(self.agent.enable_tools)
.enable_memory(self.agent.enable_memory)
.enable_human_in_loop(self.agent.enable_human_in_loop)
.max_iterations(self.agent.max_iterations)
.memory_path(&self.agent.memory_path)
.temperature(self.model.temperature)
.max_tokens(self.model.max_tokens)
.tool_execution(crate::tools::ToolExecutionConfig {
timeout_ms: self.agent.tool_timeout_ms,
..Default::default()
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct ModelConfig {
pub name: String,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
}
impl Default for ModelConfig {
fn default() -> Self {
Self {
name: "qwen-plus".to_string(),
max_tokens: None,
temperature: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct AgentYamlConfig {
pub name: String,
pub system_prompt: String,
pub max_iterations: usize,
pub enable_tools: bool,
pub enable_memory: bool,
pub enable_human_in_loop: bool,
pub memory_path: String,
pub tool_timeout_ms: u64,
}
impl Default for AgentYamlConfig {
fn default() -> Self {
Self {
name: "echo-assistant".to_string(),
system_prompt: "You are an intelligent assistant that helps users answer questions and complete tasks.".to_string(),
max_iterations: 10,
enable_tools: true,
enable_memory: true,
enable_human_in_loop: true,
memory_path: "~/.echo-agent/memory".to_string(),
tool_timeout_ms: 120_000,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct McpYamlConfig {
pub config_path: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
#[derive(Default)]
pub struct ChannelsConfig {
pub qq: QqChannelConfig,
pub feishu: FeishuChannelConfig,
pub session: SessionYamlConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
#[derive(Default)]
pub struct QqChannelConfig {
pub enabled: bool,
pub app_id: String,
pub client_secret: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct FeishuChannelConfig {
pub enabled: bool,
pub app_id: String,
pub app_secret: String,
pub mode: String,
}
impl Default for FeishuChannelConfig {
fn default() -> Self {
Self {
enabled: false,
app_id: String::new(),
app_secret: String::new(),
mode: "long_poll".to_string(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct SessionYamlConfig {
pub timeout_minutes: u64,
pub reset_keywords: Vec<String>,
pub reset_commands: Vec<String>,
}
impl Default for SessionYamlConfig {
fn default() -> Self {
Self {
timeout_minutes: 60,
reset_keywords: vec![
"reset conversation".to_string(),
"new conversation".to_string(),
"clear memory".to_string(),
],
reset_commands: vec![
"/reset".to_string(),
"/clear".to_string(),
"/new".to_string(),
],
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub max_body_bytes: usize,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: "0.0.0.0".to_string(),
port: 3000,
max_body_bytes: 1024 * 1024, }
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct LoggingConfig {
pub level: String,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: "info".to_string(),
}
}
}
fn config_search_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Ok(explicit) = std::env::var("ECHO_AGENT_CONFIG")
&& !explicit.trim().is_empty()
{
paths.push(PathBuf::from(explicit));
}
paths.push(PathBuf::from("echo-agent.yaml"));
if let Ok(home) = std::env::var("HOME") {
paths.push(PathBuf::from(home).join(".echo-agent").join("config.yaml"));
}
paths
}
fn load_from_file(path: &PathBuf) -> Result<AppConfig, String> {
let content =
std::fs::read_to_string(path).map_err(|e| format!("Failed to read config file: {}", e))?;
serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse config file: {}", e))
}
pub fn load_config(explicit_path: Option<&str>) -> AppConfig {
if let Some(path_str) = explicit_path {
let path = PathBuf::from(path_str);
match load_from_file(&path) {
Ok(config) => {
tracing::info!("Config loaded: {}", path.display());
return config;
}
Err(e) => {
tracing::error!("Failed to load config {}: {}", path.display(), e);
tracing::info!("Using default config");
return AppConfig::default();
}
}
}
for path in config_search_paths() {
if path.exists() {
match load_from_file(&path) {
Ok(config) => {
tracing::info!("Config loaded: {}", path.display());
return config;
}
Err(e) => {
tracing::warn!("Failed to load config {}: {}", path.display(), e);
}
}
}
}
tracing::info!("No config file found, using defaults");
AppConfig::default()
}
pub fn apply_env_overrides(config: &mut AppConfig) {
if let Ok(v) = std::env::var("QQ_APP_ID") {
config.channels.qq.app_id = v;
if !config.channels.qq.app_id.is_empty() {
config.channels.qq.enabled = true;
}
}
if let Ok(v) = std::env::var("QQ_CLIENT_SECRET") {
config.channels.qq.client_secret = v;
}
if let Ok(v) = std::env::var("FEISHU_APP_ID") {
config.channels.feishu.app_id = v;
if !config.channels.feishu.app_id.is_empty() {
config.channels.feishu.enabled = true;
}
}
if let Ok(v) = std::env::var("FEISHU_APP_SECRET") {
config.channels.feishu.app_secret = v;
}
if let Ok(v) = std::env::var("MCP_CONFIG_PATH") {
config.mcp.config_path = Some(v);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = AppConfig::default();
assert_eq!(config.model.name, "qwen-plus");
assert_eq!(config.agent.name, "echo-assistant");
assert_eq!(config.agent.max_iterations, 10);
assert!(config.agent.enable_tools);
assert!(config.agent.enable_memory);
assert!(config.agent.enable_human_in_loop);
assert!(!config.channels.qq.enabled);
assert!(!config.channels.feishu.enabled);
assert_eq!(config.server.port, 3000);
assert_eq!(config.logging.level, "info");
}
#[test]
fn test_to_agent_config() {
let config = AppConfig::default();
let agent_config = config.to_agent_config();
assert_eq!(agent_config.get_model_name(), "qwen-plus");
assert_eq!(agent_config.get_agent_name(), "echo-assistant");
assert!(agent_config.is_tool_enabled());
assert!(agent_config.is_memory_enabled());
assert!(agent_config.is_human_in_loop_enabled());
assert_eq!(agent_config.get_max_iterations(), 10);
}
#[test]
fn test_load_config_no_file() {
let missing_path =
std::env::temp_dir().join(format!("echo-agent-missing-config-{}.yaml", uuid()));
let config = load_config(missing_path.to_str());
assert_eq!(config.model.name, "qwen-plus");
}
#[test]
fn test_yaml_roundtrip() {
let config = AppConfig::default();
let yaml = serde_yaml::to_string(&config).unwrap();
let parsed: AppConfig = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(parsed.model.name, config.model.name);
assert_eq!(parsed.agent.system_prompt, config.agent.system_prompt);
}
#[test]
fn test_load_config_honors_echo_agent_config_env() {
let temp_path =
std::env::temp_dir().join(format!("echo-agent-config-{}.yaml", std::process::id()));
std::fs::write(
&temp_path,
r#"
model:
name: qwen-vl-max
"#,
)
.unwrap();
unsafe { std::env::set_var("ECHO_AGENT_CONFIG", &temp_path) };
let config = load_config(None);
unsafe { std::env::remove_var("ECHO_AGENT_CONFIG") };
std::fs::remove_file(&temp_path).unwrap();
assert_eq!(config.model.name, "qwen-vl-max");
}
fn uuid() -> u128 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
}
}