#![allow(clippy::doc_markdown)]
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub use lingshu_plugins::config::PluginsConfig;
use serde::{Deserialize, Deserializer, Serialize};
use lingshu_tools::tools::backends::{
BackendKind, DaytonaBackendConfig, DockerBackendConfig, ModalBackendConfig,
SingularityBackendConfig, SshBackendConfig,
};
use lingshu_types::AgentError;
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct AppConfig {
pub model: ModelConfig,
pub agent: AgentConfig,
pub logging: LoggingConfig,
pub tools: ToolsConfig,
pub lsp: LspConfig,
pub worktree: bool,
pub save_trajectories: bool,
pub skip_context_files: bool,
pub skip_memory: bool,
pub gateway: GatewayConfig,
pub proxy: ProxyConfig,
pub mcp_servers: HashMap<String, McpServerConfig>,
pub memory: MemoryConfig,
pub skills: SkillsConfig,
pub curator: CuratorConfig,
pub plugins: PluginsConfig,
pub security: SecurityConfig,
pub terminal: TerminalConfig,
pub delegation: DelegationConfig,
pub compression: CompressionConfig,
pub display: DisplayConfig,
pub privacy: PrivacyConfig,
pub browser: BrowserConfig,
pub checkpoints: CheckpointsConfig,
pub computer_use: ComputerUseConfig,
pub timezone: Option<String>,
pub tts: TtsConfig,
pub stt: SttConfig,
pub image_generation: ImageGenerationConfig,
pub voice: VoiceConfig,
pub approvals: ApprovalsConfig,
pub updates: UpdatesConfig,
pub kanban: KanbanConfig,
pub surreal: SurrealConfig,
pub auxiliary: AuxiliaryConfig,
pub shadow_judge: ShadowJudgeConfig,
pub goals: GoalsConfig,
pub moa: MoaConfig,
pub reasoning_effort: Option<String>,
pub context: ContextConfig,
pub cache: CacheConfig,
pub web_search: WebSearchConfig,
pub web: WebToolsConfig,
pub local_inference: LocalInferenceConfig,
pub embedding: lingshu_embedding::EmbeddingConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct LocalInferenceConfig {
pub write_create_dirs: bool,
pub max_tool_turn_tokens: usize,
}
impl Default for LocalInferenceConfig {
fn default() -> Self {
Self {
write_create_dirs: true,
max_tool_turn_tokens: lingshu_tools::mutation_turn_policy::LOCAL_TOOL_TURN_ABS_MAX_TOKENS,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct CacheConfig {
pub prompt_prefix: PromptPrefixCacheConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct PromptPrefixCacheConfig {
pub enabled: bool,
pub ttl: String,
}
impl Default for PromptPrefixCacheConfig {
fn default() -> Self {
Self {
enabled: true,
ttl: "1h".into(),
}
}
}
impl PromptPrefixCacheConfig {
pub fn normalized_ttl(&self) -> &'static str {
match self.ttl.as_str() {
"1h" => "1h",
"5m" => "5m",
_ => "5m",
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct ContextConfig {
pub engine: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct WebToolsConfig {
pub search_backend: String,
pub extract_backend: String,
pub backend: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct WebSearchConfig {
pub primary: String,
pub fallbacks: Vec<String>,
pub timeout_secs: u64,
pub backends: HashMap<String, WebSearchBackendConfig>,
}
impl Default for WebSearchConfig {
fn default() -> Self {
Self {
primary: "searxng".into(),
fallbacks: vec!["brave".into(), "ddgs".into()],
timeout_secs: 8,
backends: HashMap::new(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct WebSearchBackendConfig {
pub api_key: Option<String>,
pub endpoint: Option<String>,
pub rps: Option<f64>,
pub timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct AgentConfig {
pub system_prompt: String,
pub personalities: HashMap<String, PersonalityPreset>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct LoggingConfig {
pub level: String,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: "info".into(),
}
}
}
impl AppConfig {
pub fn load() -> Result<Self, AgentError> {
let home = lingshu_home();
let path = home.join("config.yaml");
let mut config = if path.exists() {
let content = std::fs::read_to_string(&path).map_err(AgentError::Io)?;
Self::parse_compat_yaml(&content, &path)?
} else {
Self::default()
};
config.apply_env_overrides();
config.moa = config.moa.sanitized();
lingshu_tools::ensure_web_search_config_coherence_at(&path);
Ok(config)
}
pub fn load_from(path: &Path) -> Result<Self, AgentError> {
let content = std::fs::read_to_string(path).map_err(AgentError::Io)?;
let mut config: Self = Self::parse_compat_yaml(&content, path)?;
config.apply_env_overrides();
config.moa = config.moa.sanitized();
lingshu_tools::ensure_web_search_config_coherence_at(path);
Ok(config)
}
fn parse_compat_yaml(content: &str, path: &Path) -> Result<Self, AgentError> {
let mut raw: serde_yml::Value = serde_yml::from_str(content)
.map_err(|e| AgentError::Config(format!("{path:?}: {e}")))?;
normalize_model_keys(&mut raw);
normalize_tools_file_keys(&mut raw);
serde_yml::from_value(raw).map_err(|e| AgentError::Config(format!("{path:?}: {e}")))
}
pub fn persist_lsp_enabled(enabled: bool) -> Result<(), AgentError> {
let mut config = Self::load()?;
config.lsp.enabled = enabled;
config.save()
}
pub fn persist_computer_use_enabled(enabled: bool) -> Result<(), AgentError> {
let mut config = Self::load()?;
config.computer_use.enabled = enabled;
if enabled {
Self::ensure_toolset_enabled(&mut config.tools.enabled_toolsets, "computer_use");
} else {
Self::ensure_toolset_disabled(&mut config.tools.enabled_toolsets, "computer_use");
}
config.save()
}
fn ensure_toolset_disabled(toolsets: &mut Option<Vec<String>>, name: &str) {
if let Some(list) = toolsets {
list.retain(|entry| !entry.eq_ignore_ascii_case(name));
if list.is_empty() {
*toolsets = None;
}
}
}
fn ensure_toolset_enabled(toolsets: &mut Option<Vec<String>>, name: &str) {
match toolsets {
Some(list) if list.iter().any(|entry| entry.eq_ignore_ascii_case(name)) => {}
Some(list) => list.push(name.to_string()),
None => *toolsets = Some(vec![name.to_string()]),
}
}
pub fn lsp_status_summary(lsp: &LspConfig) -> String {
let post_write = if lsp.enabled {
"on (write_file, patch, apply_patch attach diagnostics + lsp_diagnostics)"
} else {
"off"
};
format!(
"LSP layer: {}\n\
Post-write semantic diagnostics: {}\n\
Diagnostic timeout: {} ms\n\
Configured language servers: {}\n\
\n\
Toggle: /lsp on | /lsp off | /lsp toggle\n\
Or set `lsp.enabled` in config.yaml (env: EDGECRAB_LSP_ENABLED=0|1)",
if lsp.enabled { "enabled" } else { "disabled" },
post_write,
lsp.timeout_ms,
lsp.servers.len()
)
}
pub fn persist_skills_write_approval(enabled: bool) -> Result<(), AgentError> {
let mut config = Self::load()?;
config.skills.write_approval = enabled;
config.save()
}
pub fn persist_memory_write_approval(enabled: bool) -> Result<(), AgentError> {
let mut config = Self::load()?;
config.memory.write_approval = enabled;
config.save()
}
pub fn persist_approvals_mode(mode: lingshu_security::approval::ApprovalMode) -> Result<(), AgentError> {
let mut config = Self::load()?;
config.approvals.mode = mode;
config.save()
}
pub fn persist_skills_inline_shell(enabled: bool) -> Result<(), AgentError> {
let mut config = Self::load()?;
config.skills.inline_shell = enabled;
config.save()
}
pub fn save(&self) -> Result<(), AgentError> {
let home = ensure_lingshu_home()?;
self.save_to(&home.join("config.yaml"))
}
pub fn save_to(&self, path: &Path) -> Result<(), AgentError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(AgentError::Io)?;
}
let mut sanitized = self.clone();
sanitized.moa = sanitized.moa.sanitized();
let yaml = serde_yml::to_string(&sanitized)
.map_err(|e| AgentError::Config(format!("failed to serialize config: {e}")))?;
std::fs::write(path, yaml).map_err(AgentError::Io)
}
pub fn merge_cli(&mut self, args: &CliOverrides) {
if let Some(ref model) = args.model {
self.model.default_model = model.clone();
}
if let Some(ref toolset) = args.toolset {
self.tools.enabled_toolsets = Some(vec![toolset.clone()]);
}
if let Some(max) = args.max_iterations {
self.model.max_iterations = max;
}
if let Some(temp) = args.temperature {
self.model.temperature = Some(temp);
}
}
pub fn is_plugin_enabled(&self, name: &str, platform: Option<&str>) -> bool {
self.plugins.is_plugin_enabled(name, platform)
}
fn apply_env_overrides(&mut self) {
if let Ok(val) = std::env::var("EDGECRAB_MODEL") {
self.model.default_model = val;
}
if let Ok(val) = std::env::var("EDGECRAB_MAX_ITERATIONS")
&& let Ok(n) = val.parse()
{
self.model.max_iterations = n;
}
if let Ok(val) = std::env::var("EDGECRAB_LOG_LEVEL") {
self.logging.level = val;
}
if let Ok(val) = std::env::var("EDGECRAB_TIMEZONE") {
self.timezone = Some(val);
}
if let Ok(val) = std::env::var("EDGECRAB_SAVE_TRAJECTORIES") {
self.save_trajectories = parse_bool_env(&val);
}
if let Ok(val) = std::env::var("EDGECRAB_WORKTREE") {
self.worktree = parse_bool_env(&val);
}
if let Ok(val) = std::env::var("EDGECRAB_SKIP_CONTEXT_FILES") {
self.skip_context_files = parse_bool_env(&val);
}
if let Ok(val) = std::env::var("EDGECRAB_SKIP_MEMORY") {
self.skip_memory = parse_bool_env(&val);
}
if let Ok(val) = std::env::var("EDGECRAB_LSP_ENABLED") {
self.lsp.enabled = parse_bool_env(&val);
}
if let Ok(val) = std::env::var("EDGECRAB_TOOL_RESULT_SPILL") {
self.tools.result_spill = parse_bool_env(&val);
}
if let Ok(val) = std::env::var("EDGECRAB_TOOL_RESULT_SPILL_THRESHOLD")
&& let Ok(n) = val.parse()
{
self.tools.result_spill_threshold = n;
}
if let Ok(val) = std::env::var("EDGECRAB_TOOL_RESULT_SPILL_PREVIEW_LINES")
&& let Ok(n) = val.parse()
{
self.tools.result_spill_preview_lines = n;
}
if let Ok(val) = std::env::var("EDGECRAB_TOOL_RESULT_TURN_BUDGET")
&& let Ok(n) = val.parse()
{
self.tools.result_turn_budget_chars = n;
}
if let Ok(val) = std::env::var("EDGECRAB_MAX_WRITE_PAYLOAD_KIB")
&& let Ok(n) = val.parse()
{
self.tools.file.max_write_payload_kib = Some(n);
}
if let Ok(val) = std::env::var("EDGECRAB_PLUGINS_ENABLED") {
self.plugins.enabled = parse_bool_env(&val);
}
if let Ok(val) = std::env::var("EDGECRAB_PLUGINS_AUTO_ENABLE") {
self.plugins.auto_enable = parse_bool_env(&val);
}
if let Ok(val) = std::env::var("EDGECRAB_PLUGINS_CALL_TIMEOUT")
&& let Ok(seconds) = val.parse()
{
self.plugins.call_timeout_secs = seconds;
}
if let Ok(val) = std::env::var("EDGECRAB_PLUGINS_HUB_ENABLED") {
self.plugins.hub.enabled = parse_bool_env(&val);
}
if let Ok(val) = std::env::var("EDGECRAB_PLUGINS_SCAN_ON_LOAD") {
self.plugins.security.scan_on_load = parse_bool_env(&val);
}
if let Ok(val) = std::env::var("EDGECRAB_TERMINAL_BACKEND") {
self.terminal.backend = val.parse().expect("infallible");
}
if let Ok(val) = std::env::var("EDGECRAB_TERMINAL_MODAL_MODE") {
self.terminal.modal.mode = match val.trim().to_ascii_lowercase().as_str() {
"auto" => lingshu_tools::tools::backends::ModalTransportMode::Auto,
"direct" => lingshu_tools::tools::backends::ModalTransportMode::Direct,
"managed" => lingshu_tools::tools::backends::ModalTransportMode::Managed,
_ => self.terminal.modal.mode.clone(),
};
}
if let Ok(val) = std::env::var("EDGECRAB_TERMINAL_MODAL_IMAGE") {
self.terminal.modal.image = val;
}
if let Ok(val) = std::env::var("EDGECRAB_TERMINAL_MODAL_GATEWAY_URL") {
self.terminal.modal.managed_gateway_url = Some(val);
}
if let Ok(val) = std::env::var("EDGECRAB_TERMINAL_DAYTONA_IMAGE") {
self.terminal.daytona.image = val;
}
if let Ok(val) = std::env::var("EDGECRAB_TERMINAL_SINGULARITY_IMAGE") {
self.terminal.singularity.image = val;
}
if let Ok(val) = std::env::var("EDGECRAB_GATEWAY_HOST") {
self.gateway.host = val;
}
if let Ok(val) = std::env::var("EDGECRAB_GATEWAY_PORT")
&& let Ok(port) = val.parse()
{
self.gateway.port = port;
}
if let Ok(val) = std::env::var("EDGECRAB_GATEWAY_WEBHOOK") {
self.gateway.webhook_enabled = parse_bool_env(&val);
}
let gateway_enabled_by_env = |gateway: &mut GatewayConfig, platform: &str, ready: bool| {
if ready && !gateway.platform_disabled(platform) {
gateway.enable_platform(platform);
}
};
if std::env::var("TELEGRAM_BOT_TOKEN").is_ok()
&& !self.gateway.platform_disabled("telegram")
{
self.gateway.telegram.enabled = true;
self.gateway.enable_platform("telegram");
}
if let Ok(val) = std::env::var("TELEGRAM_ALLOWED_USERS") {
self.gateway.telegram.allowed_users = parse_csv_env(&val);
}
if let Ok(val) = std::env::var("TELEGRAM_HOME_CHANNEL") {
self.gateway.telegram.home_channel = Some(val);
}
if std::env::var("DISCORD_BOT_TOKEN").is_ok() && !self.gateway.platform_disabled("discord")
{
self.gateway.discord.enabled = true;
self.gateway.enable_platform("discord");
}
if let Ok(val) = std::env::var("DISCORD_ALLOWED_USERS") {
self.gateway.discord.allowed_users = parse_csv_env(&val);
}
if let Ok(val) = std::env::var("DISCORD_HOME_CHANNEL") {
self.gateway.discord.home_channel = Some(val);
}
if std::env::var("SLACK_BOT_TOKEN").is_ok() && !self.gateway.platform_disabled("slack") {
self.gateway.slack.enabled = true;
self.gateway.enable_platform("slack");
}
gateway_enabled_by_env(
&mut self.gateway,
"feishu",
std::env::var("FEISHU_APP_ID").is_ok() && std::env::var("FEISHU_APP_SECRET").is_ok(),
);
gateway_enabled_by_env(
&mut self.gateway,
"wecom",
std::env::var("WECOM_BOT_ID").is_ok() && std::env::var("WECOM_SECRET").is_ok(),
);
if let Ok(val) = std::env::var("SLACK_ALLOWED_USERS") {
self.gateway.slack.allowed_users = parse_csv_env(&val);
}
if std::env::var("SIGNAL_HTTP_URL").is_ok()
&& std::env::var("SIGNAL_ACCOUNT").is_ok()
&& !self.gateway.platform_disabled("signal")
{
self.gateway.signal.enabled = true;
self.gateway.enable_platform("signal");
}
if let Ok(val) = std::env::var("SIGNAL_HTTP_URL") {
self.gateway.signal.http_url = Some(val);
}
if let Ok(val) = std::env::var("SIGNAL_ACCOUNT") {
self.gateway.signal.account = Some(val);
}
if let Ok(val) = std::env::var("SIGNAL_HOME_CHANNEL") {
self.gateway.signal.home_channel = Some(val);
}
if let Ok(val) = std::env::var("WHATSAPP_ENABLED")
&& parse_bool_env(&val)
&& !self.gateway.platform_disabled("whatsapp")
{
self.gateway.whatsapp.enabled = true;
self.gateway.enable_platform("whatsapp");
}
if let Ok(val) = std::env::var("WHATSAPP_MODE") {
self.gateway.whatsapp.mode = val;
}
if let Ok(val) = std::env::var("WHATSAPP_ALLOWED_USERS") {
self.gateway.whatsapp.allowed_users = parse_csv_env(&val);
}
if let Ok(val) = std::env::var("WHATSAPP_HOME_CHANNEL") {
self.gateway.whatsapp.home_channel = Some(val);
}
if let Ok(val) = std::env::var("WHATSAPP_BRIDGE_PORT")
&& let Ok(port) = val.parse()
{
self.gateway.whatsapp.bridge_port = port;
}
if let Ok(val) = std::env::var("WHATSAPP_BRIDGE_URL") {
self.gateway.whatsapp.bridge_url = Some(val);
}
if let Ok(val) = std::env::var("WHATSAPP_SESSION_PATH") {
self.gateway.whatsapp.session_path = Some(PathBuf::from(val));
}
if let Ok(val) = std::env::var("WHATSAPP_REPLY_PREFIX") {
self.gateway.whatsapp.reply_prefix = Some(val);
}
gateway_enabled_by_env(
&mut self.gateway,
"sms",
std::env::var("TWILIO_ACCOUNT_SID").is_ok()
&& std::env::var("TWILIO_AUTH_TOKEN").is_ok()
&& std::env::var("TWILIO_PHONE_NUMBER").is_ok(),
);
gateway_enabled_by_env(
&mut self.gateway,
"matrix",
std::env::var("MATRIX_HOMESERVER").is_ok()
&& std::env::var("MATRIX_ACCESS_TOKEN").is_ok(),
);
gateway_enabled_by_env(
&mut self.gateway,
"mattermost",
std::env::var("MATTERMOST_URL").is_ok() && std::env::var("MATTERMOST_TOKEN").is_ok(),
);
gateway_enabled_by_env(
&mut self.gateway,
"dingtalk",
std::env::var("DINGTALK_APP_KEY").is_ok()
&& std::env::var("DINGTALK_APP_SECRET").is_ok(),
);
gateway_enabled_by_env(
&mut self.gateway,
"homeassistant",
std::env::var("HA_URL").is_ok() && std::env::var("HA_TOKEN").is_ok(),
);
gateway_enabled_by_env(&mut self.gateway, "email", email_env_ready());
gateway_enabled_by_env(
&mut self.gateway,
"api_server",
std::env::var("API_SERVER_ENABLED")
.ok()
.is_some_and(|value| parse_bool_env(&value)),
);
if let Ok(val) = std::env::var("EDGECRAB_TTS_PROVIDER") {
self.tts.provider = val;
}
if let Ok(val) = std::env::var("EDGECRAB_TTS_VOICE") {
self.tts.voice = val;
}
if let Ok(val) = std::env::var("ELEVENLABS_API_KEY") {
let _ = val; }
if let Ok(val) = std::env::var("EDGECRAB_REASONING_EFFORT") {
self.reasoning_effort = Some(val);
}
if std::env::var("EDGECRAB_MANAGED").as_deref() == Ok("1") {
self.security.managed_mode = true;
}
}
pub fn is_managed(&self) -> bool {
self.security.managed_mode
}
}
fn parse_bool_env(val: &str) -> bool {
matches!(
val.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
fn parse_csv_env(val: &str) -> Vec<String> {
val.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string)
.collect()
}
fn email_env_ready() -> bool {
let provider = std::env::var("EMAIL_PROVIDER").unwrap_or_default();
let has_from = std::env::var("EMAIL_FROM")
.ok()
.is_some_and(|value| !value.trim().is_empty());
let has_api_key = std::env::var("EMAIL_API_KEY")
.ok()
.is_some_and(|value| !value.trim().is_empty());
let has_domain = std::env::var("EMAIL_DOMAIN")
.ok()
.is_some_and(|value| !value.trim().is_empty());
let has_smtp_host = std::env::var("EMAIL_SMTP_HOST")
.ok()
.is_some_and(|value| !value.trim().is_empty());
let has_smtp_password = std::env::var("EMAIL_SMTP_PASSWORD")
.ok()
.is_some_and(|value| !value.trim().is_empty());
match provider.trim().to_ascii_lowercase().as_str() {
"sendgrid" => has_from && has_api_key,
"mailgun" => has_from && has_api_key && has_domain,
"generic_smtp" | "smtp" => has_from && has_smtp_host && (has_smtp_password || has_api_key),
_ => false,
}
}
fn normalize_model_keys(root: &mut serde_yml::Value) {
let serde_yml::Value::Mapping(root_map) = root else {
return;
};
let model_key = serde_yml::Value::String("model".into());
let Some(model_value) = root_map.get(&model_key).cloned() else {
return;
};
if let serde_yml::Value::String(model_name) = &model_value {
let model_name = model_name.trim();
if model_name.is_empty() {
return;
}
let provider_key = serde_yml::Value::String("provider".into());
let provider = root_map
.get(&provider_key)
.and_then(serde_yml::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let normalized_model = if let Some(provider) = provider {
if has_explicit_provider_prefix(model_name) {
model_name.to_string()
} else {
format!("{provider}/{model_name}")
}
} else {
model_name.to_string()
};
let mut normalized = serde_yml::Mapping::new();
normalized.insert(
serde_yml::Value::String("default".into()),
serde_yml::Value::String(normalized_model),
);
root_map.insert(model_key, serde_yml::Value::Mapping(normalized));
return;
}
let provider_key = serde_yml::Value::String("provider".into());
let provider = root_map
.get(&provider_key)
.and_then(serde_yml::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let serde_yml::Value::Mapping(model_map) = root_map
.get_mut(serde_yml::Value::String("model".into()))
.expect("model key exists after clone")
else {
return;
};
let default_key = serde_yml::Value::String("default".into());
let legacy_key = serde_yml::Value::String("default_model".into());
let legacy = model_map.get(&legacy_key).cloned();
if let Some(legacy_val) = legacy {
let should_promote = match &legacy_val {
serde_yml::Value::String(s) => !s.trim().is_empty(),
_ => true,
};
if should_promote {
if let (Some(provider), serde_yml::Value::String(model_name)) = (&provider, &legacy_val) {
if !has_explicit_provider_prefix(model_name.trim()) {
let normalized = format!("{provider}/{}", model_name.trim());
model_map.insert(default_key, serde_yml::Value::String(normalized));
} else {
model_map.insert(default_key, legacy_val);
}
} else {
model_map.insert(default_key, legacy_val);
}
}
model_map.remove(&legacy_key);
} else if let Some((provider, serde_yml::Value::String(model_name))) = provider.zip(model_map.get(&default_key).cloned())
&& !has_explicit_provider_prefix(model_name.trim())
{
let normalized = format!("{provider}/{}", model_name.trim());
model_map.insert(default_key, serde_yml::Value::String(normalized));
}
}
fn normalize_tools_file_keys(root: &mut serde_yml::Value) {
let serde_yml::Value::Mapping(root_map) = root else {
return;
};
let tools_key = serde_yml::Value::String("tools".into());
let Some(serde_yml::Value::Mapping(tools_map)) = root_map.get_mut(&tools_key) else {
return;
};
let legacy_key = serde_yml::Value::String("allowed_paths".into());
let Some(legacy_allowed_paths) = tools_map.remove(&legacy_key) else {
return;
};
let file_key = serde_yml::Value::String("file".into());
let file_value = tools_map
.entry(file_key)
.or_insert_with(|| serde_yml::Value::Mapping(serde_yml::Mapping::new()));
let serde_yml::Value::Mapping(file_map) = file_value else {
return;
};
let allowed_roots_key = serde_yml::Value::String("allowed_roots".into());
file_map.insert(allowed_roots_key, legacy_allowed_paths);
}
fn has_explicit_provider_prefix(model_name: &str) -> bool {
let Some((provider, _)) = model_name.split_once('/') else {
return false;
};
matches!(
provider.trim().to_ascii_lowercase().as_str(),
"openai"
| "anthropic"
| "claude"
| "gemini"
| "google"
| "vertex"
| "vertexai"
| "openrouter"
| "open-router"
| "xai"
| "grok"
| "deepseek"
| "huggingface"
| "hf"
| "hugging-face"
| "hugging_face"
| "ollama"
| "lmstudio"
| "lm-studio"
| "lm_studio"
| "vscode"
| "vscode-copilot"
| "copilot"
| "mock"
| "mistral"
| "mistral-ai"
| "mistralai"
| "groq"
| "cohere"
| "perplexity"
| "zai"
| "nvidia"
| "nvidia-nim"
| "nim"
| "azure"
| "azure-openai"
| "azure_openai"
| "azureopenai"
| "bedrock"
| "aws-bedrock"
| "aws_bedrock"
| "openai-compatible"
| "openai_compatible"
)
}
#[derive(Debug, Default, Clone)]
pub struct CliOverrides {
pub model: Option<String>,
pub toolset: Option<String>,
pub max_iterations: Option<u32>,
pub temperature: Option<f32>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct ModelConfig {
#[serde(rename = "default", alias = "default_model")]
pub default_model: String,
pub fallback: Option<FallbackConfig>,
pub base_url: Option<String>,
pub api_key_env: String,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub streaming: bool,
pub max_iterations: u32,
pub prompt_caching: bool,
pub cache_ttl: u32,
pub smart_routing: SmartRoutingYaml,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct SmartRoutingYaml {
pub enabled: bool,
pub cheap_model: String,
pub cheap_base_url: Option<String>,
pub cheap_api_key_env: Option<String>,
}
impl Default for ModelConfig {
fn default() -> Self {
Self {
default_model: "ollama/gemma4:latest".into(),
fallback: None,
base_url: None,
api_key_env: "OPENROUTER_API_KEY".into(),
max_tokens: None,
temperature: None,
streaming: true,
max_iterations: 90,
prompt_caching: true,
cache_ttl: 300,
smart_routing: SmartRoutingYaml::default(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FallbackConfig {
pub model: String,
pub provider: String,
pub base_url: Option<String>,
pub api_key_env: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct ToolsConfig {
pub enabled_toolsets: Option<Vec<String>>,
pub disabled_toolsets: Option<Vec<String>>,
pub enabled_tools: Option<Vec<String>>,
pub disabled_tools: Option<Vec<String>>,
#[serde(default)]
pub custom_groups: HashMap<String, Vec<String>>,
#[serde(default)]
pub file: FileToolsConfig,
pub tool_delay: f32,
pub parallel_execution: bool,
pub max_parallel_workers: usize,
pub result_spill: bool,
pub result_spill_threshold: usize,
pub result_spill_preview_lines: usize,
#[serde(default = "default_result_turn_budget_chars")]
pub result_turn_budget_chars: usize,
}
fn default_result_turn_budget_chars() -> usize {
200_000
}
impl Default for ToolsConfig {
fn default() -> Self {
Self {
enabled_toolsets: None,
disabled_toolsets: None,
enabled_tools: None,
disabled_tools: None,
custom_groups: HashMap::new(),
file: FileToolsConfig::default(),
tool_delay: 1.0,
parallel_execution: true,
max_parallel_workers: 8,
result_spill: true,
result_spill_threshold: 16_384,
result_spill_preview_lines: 80,
result_turn_budget_chars: default_result_turn_budget_chars(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct LspConfig {
pub enabled: bool,
pub file_size_limit_bytes: u64,
pub timeout_ms: u64,
pub servers: HashMap<String, LspServerConfig>,
}
impl Default for LspConfig {
fn default() -> Self {
Self {
enabled: true,
file_size_limit_bytes: 10_000_000,
timeout_ms: 1_500,
servers: default_lsp_servers(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct LspServerConfig {
pub command: String,
pub args: Vec<String>,
pub file_extensions: Vec<String>,
pub language_id: String,
pub root_markers: Vec<String>,
pub env: HashMap<String, String>,
pub initialization_options: Option<serde_json::Value>,
}
fn default_lsp_servers() -> HashMap<String, LspServerConfig> {
fn server(
command: &str,
args: &[&str],
file_extensions: &[&str],
language_id: &str,
root_markers: &[&str],
) -> LspServerConfig {
LspServerConfig {
command: command.into(),
args: args.iter().map(|value| (*value).into()).collect(),
file_extensions: file_extensions
.iter()
.map(|value| (*value).into())
.collect(),
language_id: language_id.into(),
root_markers: root_markers.iter().map(|value| (*value).into()).collect(),
env: HashMap::new(),
initialization_options: None,
}
}
[
(
"rust",
server(
"rust-analyzer",
&[],
&["rs"],
"rust",
&["Cargo.toml", "rust-project.json"],
),
),
(
"typescript",
server(
"typescript-language-server",
&["--stdio"],
&["ts", "tsx"],
"typescript",
&["package.json", "tsconfig.json"],
),
),
(
"javascript",
server(
"typescript-language-server",
&["--stdio"],
&["js", "jsx", "mjs", "cjs"],
"javascript",
&["package.json", "jsconfig.json"],
),
),
(
"python",
server(
"pylsp",
&[],
&["py"],
"python",
&["pyproject.toml", "setup.py", "requirements.txt"],
),
),
("go", server("gopls", &[], &["go"], "go", &["go.mod"])),
(
"c",
server(
"clangd",
&[],
&["c", "h"],
"c",
&["compile_commands.json", ".clangd"],
),
),
(
"cpp",
server(
"clangd",
&[],
&["cc", "cpp", "cxx", "hpp", "hh", "hxx"],
"cpp",
&["compile_commands.json", ".clangd"],
),
),
(
"java",
server(
"jdtls",
&[],
&["java"],
"java",
&[
"pom.xml",
"build.gradle",
"build.gradle.kts",
"settings.gradle",
],
),
),
(
"csharp",
server(
"csharp-ls",
&[],
&["cs"],
"csharp",
&["*.sln", "*.csproj", "global.json", "Directory.Build.props"],
),
),
(
"php",
server(
"intelephense",
&["--stdio"],
&["php"],
"php",
&["composer.json", ".git"],
),
),
(
"ruby",
server(
"ruby-lsp",
&[],
&["rb", "rake", "gemspec"],
"ruby",
&["Gemfile", ".ruby-version"],
),
),
(
"bash",
server(
"bash-language-server",
&["start"],
&["sh", "bash"],
"shellscript",
&[".git"],
),
),
(
"html",
server(
"vscode-html-language-server",
&["--stdio"],
&["html", "htm"],
"html",
&["package.json", ".git"],
),
),
(
"css",
server(
"vscode-css-language-server",
&["--stdio"],
&["css", "scss", "less"],
"css",
&["package.json", ".git"],
),
),
(
"json",
server(
"vscode-json-language-server",
&["--stdio"],
&["json", "jsonc"],
"json",
&["package.json", ".git"],
),
),
]
.into_iter()
.map(|(name, cfg)| (name.to_string(), cfg))
.collect()
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct FileToolsConfig {
pub allowed_roots: Vec<PathBuf>,
pub max_write_payload_kib: Option<u32>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GroupPolicy {
#[default]
Disabled,
MentionOnly,
AllowedOnly,
Open,
}
impl std::fmt::Display for GroupPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Disabled => write!(f, "disabled"),
Self::MentionOnly => write!(f, "mention_only"),
Self::AllowedOnly => write!(f, "allowed_only"),
Self::Open => write!(f, "open"),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum UnauthorizedDmBehavior {
#[default]
Pair,
Ignore,
Reject,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct GatewayConfig {
pub host: String,
pub port: u16,
pub webhook_enabled: bool,
pub enabled_platforms: Vec<String>,
pub disabled_platforms: Vec<String>,
pub session_timeout_minutes: u32,
pub group_policy: GroupPolicy,
pub unauthorized_dm_behavior: UnauthorizedDmBehavior,
pub telegram: TelegramGatewayConfig,
pub discord: DiscordGatewayConfig,
pub slack: SlackGatewayConfig,
pub signal: SignalGatewayConfig,
pub whatsapp: WhatsAppGatewayConfig,
}
impl Default for GatewayConfig {
fn default() -> Self {
Self {
host: "127.0.0.1".into(),
port: 8080,
webhook_enabled: true,
enabled_platforms: Vec::new(),
disabled_platforms: Vec::new(),
session_timeout_minutes: 30,
group_policy: GroupPolicy::default(),
unauthorized_dm_behavior: UnauthorizedDmBehavior::default(),
telegram: TelegramGatewayConfig::default(),
discord: DiscordGatewayConfig::default(),
slack: SlackGatewayConfig::default(),
signal: SignalGatewayConfig::default(),
whatsapp: WhatsAppGatewayConfig::default(),
}
}
}
impl GatewayConfig {
pub fn platform_disabled(&self, platform: &str) -> bool {
self.disabled_platforms
.iter()
.any(|value| value.eq_ignore_ascii_case(platform))
}
pub fn platform_enabled(&self, platform: &str) -> bool {
!self.platform_disabled(platform)
&& self
.enabled_platforms
.iter()
.any(|value| value.eq_ignore_ascii_case(platform))
}
pub fn platform_requested(&self, platform: &str, legacy_enabled: bool) -> bool {
!self.platform_disabled(platform) && (legacy_enabled || self.platform_enabled(platform))
}
pub fn enable_platform(&mut self, platform: &str) {
self.disabled_platforms
.retain(|value| !value.eq_ignore_ascii_case(platform));
if !self.platform_enabled(platform) {
self.enabled_platforms.push(platform.to_ascii_lowercase());
}
}
pub fn disable_platform(&mut self, platform: &str) {
self.enabled_platforms
.retain(|value| !value.eq_ignore_ascii_case(platform));
if !self.platform_disabled(platform) {
self.disabled_platforms.push(platform.to_ascii_lowercase());
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct TelegramGatewayConfig {
pub enabled: bool,
pub token_env: String,
pub allowed_users: Vec<String>,
pub home_channel: Option<String>,
}
impl Default for TelegramGatewayConfig {
fn default() -> Self {
Self {
enabled: false,
token_env: "TELEGRAM_BOT_TOKEN".into(),
allowed_users: Vec::new(),
home_channel: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct DiscordGatewayConfig {
pub enabled: bool,
pub token_env: String,
pub allowed_users: Vec<String>,
pub home_channel: Option<String>,
}
impl Default for DiscordGatewayConfig {
fn default() -> Self {
Self {
enabled: false,
token_env: "DISCORD_BOT_TOKEN".into(),
allowed_users: Vec::new(),
home_channel: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct SlackGatewayConfig {
pub enabled: bool,
pub bot_token_env: String,
pub app_token_env: String,
pub allowed_users: Vec<String>,
pub home_channel: Option<String>,
}
impl Default for SlackGatewayConfig {
fn default() -> Self {
Self {
enabled: false,
bot_token_env: "SLACK_BOT_TOKEN".into(),
app_token_env: "SLACK_APP_TOKEN".into(),
allowed_users: Vec::new(),
home_channel: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default)]
pub struct SignalGatewayConfig {
pub enabled: bool,
pub http_url: Option<String>,
pub account: Option<String>,
pub allowed_users: Vec<String>,
pub home_channel: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct WhatsAppGatewayConfig {
pub enabled: bool,
pub bridge_port: u16,
pub bridge_url: Option<String>,
pub bridge_dir: Option<PathBuf>,
pub bridge_script: Option<PathBuf>,
pub session_path: Option<PathBuf>,
pub mode: String,
pub allowed_users: Vec<String>,
pub reply_prefix: Option<String>,
pub install_dependencies: bool,
pub home_channel: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct ProxyConfig {
pub bind: String,
pub port: u16,
pub token_path: Option<PathBuf>,
pub model_aliases: HashMap<String, String>,
pub forward_upstreams: HashMap<String, ForwardUpstreamConfig>,
#[serde(default)]
pub default_forward_upstream: Option<String>,
pub cors_allow_origins: Vec<String>,
pub max_body_bytes: usize,
}
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ForwardAdapterKind {
#[default]
Static,
HermesAuth,
NousPortal,
XaiOauth,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ForwardUpstreamConfig {
pub base_url: String,
#[serde(default)]
pub adapter: ForwardAdapterKind,
#[serde(default)]
pub auth_provider: Option<String>,
#[serde(default)]
pub auth_path: Option<PathBuf>,
#[serde(default)]
pub bearer_env: Option<String>,
#[serde(default)]
pub bearer: Option<String>,
#[serde(default)]
pub auth_hint: Option<String>,
}
impl Default for ForwardUpstreamConfig {
fn default() -> Self {
Self {
base_url: String::new(),
adapter: ForwardAdapterKind::Static,
auth_provider: None,
auth_path: None,
bearer_env: None,
bearer: None,
auth_hint: None,
}
}
}
impl Default for ProxyConfig {
fn default() -> Self {
Self {
bind: "127.0.0.1".into(),
port: 11_434,
token_path: None,
model_aliases: HashMap::new(),
forward_upstreams: HashMap::new(),
default_forward_upstream: None,
cors_allow_origins: Vec::new(),
max_body_bytes: 4 * 1024 * 1024,
}
}
}
impl ProxyConfig {
pub fn resolved_token_path(&self) -> PathBuf {
self.token_path
.clone()
.unwrap_or_else(|| lingshu_home().join("proxy-token"))
}
}
impl Default for WhatsAppGatewayConfig {
fn default() -> Self {
Self {
enabled: false,
bridge_port: 3000,
bridge_url: None,
bridge_dir: None,
bridge_script: None,
session_path: None,
mode: "self-chat".into(),
allowed_users: Vec::new(),
reply_prefix: Some("\u{2695} *Lingshu Agent*\n------------\n".into()),
install_dependencies: true,
home_channel: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct McpToolsFilterConfig {
pub include: Vec<String>,
pub exclude: Vec<String>,
pub resources: bool,
pub prompts: bool,
}
impl Default for McpToolsFilterConfig {
fn default() -> Self {
Self {
include: Vec::new(),
exclude: Vec::new(),
resources: true,
prompts: true,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct McpOauthConfig {
pub token_url: String,
pub grant_type: Option<String>,
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub auth_method: Option<String>,
pub device_authorization_url: Option<String>,
pub authorization_url: Option<String>,
pub redirect_url: Option<String>,
pub use_pkce: Option<bool>,
pub scopes: Vec<String>,
pub audience: Option<String>,
pub resource: Option<String>,
pub refresh_token: Option<String>,
pub authorization_params: HashMap<String, String>,
pub extra_params: HashMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct McpServerConfig {
pub command: String,
pub args: Vec<String>,
pub env: HashMap<String, String>,
pub cwd: Option<PathBuf>,
pub enabled: bool,
pub url: Option<String>,
pub headers: HashMap<String, String>,
pub bearer_token: Option<String>,
pub oauth: Option<McpOauthConfig>,
pub timeout: Option<u64>,
pub connect_timeout: Option<u64>,
pub tools: McpToolsFilterConfig,
}
impl Default for McpServerConfig {
fn default() -> Self {
Self {
command: String::new(),
args: Vec::new(),
env: HashMap::new(),
cwd: None,
enabled: true,
url: None,
headers: HashMap::new(),
bearer_token: None,
oauth: None,
timeout: None,
connect_timeout: None,
tools: McpToolsFilterConfig::default(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct MemoryConfig {
pub enabled: bool,
pub auto_flush: bool,
#[serde(default)]
pub write_approval: bool,
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
enabled: true,
auto_flush: true,
write_approval: false,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct SkillsConfig {
pub enabled: bool,
pub hub_url: Option<String>,
#[serde(default)]
pub disabled: Vec<String>,
#[serde(default)]
pub platform_disabled: std::collections::HashMap<String, Vec<String>>,
#[serde(default)]
pub external_dirs: Vec<String>,
#[serde(default)]
pub preloaded: Vec<String>,
#[serde(default)]
pub write_approval: bool,
#[serde(default = "default_true")]
pub template_vars: bool,
#[serde(default)]
pub inline_shell: bool,
#[serde(default = "default_inline_shell_timeout")]
pub inline_shell_timeout: u32,
}
fn default_true() -> bool {
true
}
fn default_inline_shell_timeout() -> u32 {
10
}
impl Default for SkillsConfig {
fn default() -> Self {
Self {
enabled: true,
hub_url: None,
disabled: Vec::new(),
platform_disabled: std::collections::HashMap::new(),
external_dirs: Vec::new(),
preloaded: Vec::new(),
write_approval: false,
template_vars: true,
inline_shell: false,
inline_shell_timeout: 10,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct CuratorConfig {
pub enabled: bool,
pub interval_hours: u32,
pub stale_after_days: u32,
pub archive_after_days: u32,
pub prune_builtins: bool,
#[serde(default)]
pub backup: CuratorBackupConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct CuratorBackupConfig {
pub enabled: bool,
pub keep: u32,
}
impl Default for CuratorBackupConfig {
fn default() -> Self {
Self {
enabled: true,
keep: 5,
}
}
}
impl Default for CuratorConfig {
fn default() -> Self {
Self {
enabled: false,
interval_hours: 168,
stale_after_days: 30,
archive_after_days: 90,
prune_builtins: false,
backup: CuratorBackupConfig::default(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct WebsiteBlocklistConfig {
pub enabled: bool,
pub domains: Vec<String>,
pub shared_files: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct SecurityConfig {
pub approval_required: Vec<String>,
pub blocked_commands: Vec<String>,
pub path_restrictions: Vec<PathBuf>,
pub injection_scanning: bool,
pub url_safety: bool,
pub website_blocklist: WebsiteBlocklistConfig,
#[serde(skip)]
pub managed_mode: bool,
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
approval_required: Vec::new(),
blocked_commands: Vec::new(),
path_restrictions: Vec::new(),
injection_scanning: true,
url_safety: true,
website_blocklist: WebsiteBlocklistConfig::default(),
managed_mode: false,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct TerminalConfig {
pub backend: BackendKind,
pub shell: Option<String>,
pub timeout: u32,
#[serde(default)]
pub env_passthrough: Vec<String>,
#[serde(default)]
pub docker: DockerBackendConfig,
#[serde(default)]
pub ssh: SshBackendConfig,
#[serde(default)]
pub modal: ModalBackendConfig,
#[serde(default)]
pub daytona: DaytonaBackendConfig,
#[serde(default)]
pub singularity: SingularityBackendConfig,
}
impl Default for TerminalConfig {
fn default() -> Self {
Self {
backend: BackendKind::Local,
shell: None,
timeout: 120,
env_passthrough: Vec::new(),
docker: DockerBackendConfig::default(),
ssh: SshBackendConfig::default(),
modal: ModalBackendConfig::default(),
daytona: DaytonaBackendConfig::default(),
singularity: SingularityBackendConfig::default(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct DelegationConfig {
pub enabled: bool,
pub model: Option<String>,
pub provider: Option<String>,
pub base_url: Option<String>,
pub max_subagents: u32,
pub max_iterations: u32,
pub shared_budget: bool,
}
impl Default for DelegationConfig {
fn default() -> Self {
Self {
enabled: true,
model: None,
provider: None,
base_url: None,
max_subagents: 3,
max_iterations: 50,
shared_budget: false,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct CompressionConfig {
pub enabled: bool,
pub threshold: f32,
pub target_ratio: f32,
pub protect_last_n: usize,
pub summary_model: Option<String>,
}
impl Default for CompressionConfig {
fn default() -> Self {
Self {
enabled: true,
threshold: 0.50,
target_ratio: 0.20,
protect_last_n: 20,
summary_model: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct DisplayConfig {
pub compact: bool,
pub personality: String,
pub show_reasoning: bool,
pub streaming: bool,
pub tool_progress: ToolProgressMode,
pub show_status_bar: bool,
pub show_cost: bool,
pub check_for_updates: bool,
pub update_check_interval_hours: u64,
pub skin: String,
pub file_mutation_verifier: bool,
pub activity_shelf: bool,
pub shelf_details: ShelfDetailsConfig,
pub status_indicator: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct ShelfDetailsConfig {
pub mode: String,
pub command_override: bool,
pub sections: HashMap<String, String>,
}
impl Default for ShelfDetailsConfig {
fn default() -> Self {
Self {
mode: "collapsed".into(),
command_override: false,
sections: HashMap::new(),
}
}
}
impl Default for DisplayConfig {
fn default() -> Self {
Self {
compact: false,
personality: "default".into(),
show_reasoning: false,
streaming: true,
tool_progress: ToolProgressMode::Verbose,
show_status_bar: true,
show_cost: true,
check_for_updates: true,
update_check_interval_hours: 24,
skin: "default".into(),
file_mutation_verifier: true,
activity_shelf: true,
shelf_details: ShelfDetailsConfig::default(),
status_indicator: "kaomoji".into(),
}
}
}
pub fn file_mutation_verifier_enabled(config_default: bool) -> bool {
if let Ok(env) = std::env::var("EDGECRAB_FILE_MUTATION_VERIFIER") {
let lower = env.trim().to_ascii_lowercase();
return !matches!(lower.as_str(), "0" | "false" | "no" | "off");
}
config_default
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ToolProgressMode {
Off,
New,
All,
#[default]
Verbose,
}
impl ToolProgressMode {
pub fn cycle(self) -> Self {
match self {
Self::Off => Self::New,
Self::New => Self::All,
Self::All => Self::Verbose,
Self::Verbose => Self::Off,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Off => "OFF",
Self::New => "NEW",
Self::All => "ALL",
Self::Verbose => "VERBOSE",
}
}
}
impl<'de> Deserialize<'de> for ToolProgressMode {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Repr {
Bool(bool),
Text(String),
}
let repr = Repr::deserialize(deserializer)?;
match repr {
Repr::Bool(false) => Ok(ToolProgressMode::Off),
Repr::Bool(true) => Ok(ToolProgressMode::All),
Repr::Text(raw) => match raw.trim().to_ascii_lowercase().as_str() {
"off" => Ok(ToolProgressMode::Off),
"new" => Ok(ToolProgressMode::New),
"all" => Ok(ToolProgressMode::All),
"verbose" => Ok(ToolProgressMode::Verbose),
other => Err(serde::de::Error::custom(format!(
"invalid tool progress mode '{other}'"
))),
},
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
pub enum PersonalityPreset {
Text(String),
Detailed {
#[serde(default)]
system_prompt: String,
#[serde(default)]
tone: String,
#[serde(default)]
style: String,
#[serde(default)]
description: String,
},
}
pub const PERSONALITY_PRESETS: &[(&str, &str)] = &[
(
"helpful",
"You are a helpful, friendly, and efficient AI assistant.",
),
(
"concise",
"You are a concise assistant. Keep responses brief and to the point. \
Avoid unnecessary preamble or padding.",
),
(
"technical",
"You are a technical expert. Provide detailed, accurate technical \
information with code examples and precise terminology.",
),
(
"kawaii",
"You are a super kawaii AI assistant! Use cute expressions like (◕‿◕), \
★, ♪, and ~! Add sparkles and be enthusiastically warm in every response. \
Every message should feel adorable and friendly, desu~! ヽ(>∀<☆)ノ",
),
(
"pirate",
"Arrr! Ye be talking to CrabCaptain, the most code-savvy buccaneer to sail \
the digital seas! Speak like a proper pirate, use nautical terms, and \
remember — every bug be just treasure waiting to be plundered! Yo ho ho!",
),
(
"philosopher",
"You are an assistant who contemplates the deeper meaning behind every query. \
Let us examine not just the 'how' but the 'why'. \
Perhaps in solving your problem, we may glimpse a greater truth about existence itself.",
),
(
"hype",
"YOOO LET'S GOOOO!!! I am SO PUMPED to help you today! \
Every question is AMAZING and we're gonna CRUSH IT together! \
ARE YOU READY?! LET'S DO THIS!!!",
),
(
"shakespeare",
"Hark! Thou speakest with an assistant most versed in the bardic arts. \
I shall respond in the eloquent manner of William Shakespeare, \
with flowery prose and dramatic flair. What light through yonder terminal breaks?",
),
(
"noir",
"The rain hammered against the terminal like regrets on a guilty conscience. \
They call me Lingshu — I solve problems, find answers, dig up truth that hides \
in the shadows of your codebase. Everyone's got something to hide. What's your story?",
),
(
"catgirl",
"You are Nyako-chan, a playful AI catgirl assistant, nya~! \
Add 'nya' and cat-like expressions. Use kaomoji like (=^・ω・^=) and ฅ^•ﻌ•^ฅ. \
Be curious and playful like a cat, nya~!",
),
(
"creative",
"You are a creative AI assistant with a vivid imagination. \
Approach every problem with fresh perspectives, lateral thinking, and an eye for \
unconventional solutions. Use metaphors, analogies, and narrative when they help. \
Don't be afraid to think outside the box.",
),
(
"teacher",
"You are a patient, encouraging teacher. Break concepts down step by step, \
check for understanding, and use concrete examples. Anticipate common misconceptions \
and address them proactively. Celebrate progress and make learning feel accessible.",
),
(
"surfer",
"Duuude, what's up! Ready to hang ten on this problem? \
Keep it super chill and laid-back, use surfer slang ('gnarly', 'stoked', 'rad', \
'cowabunga'), and make everything sound like a totally awesome wave to ride. \
Life's a beach, bro!",
),
(
"uwu",
"OwO what's this?! I'm your fwuffy assistant, and I'm here to hewp you! uwu \
Use substitutions like r→w, l→w, and sprinkle in 'uwu', 'owo', 'nyaa', \
and action emotes like *nuzzles* and *paws at keyboard* liberally~! (≧◡≦)",
),
];
pub fn resolve_builtin_personality(name: &str) -> Option<&'static str> {
let name = name.trim().to_lowercase();
if name.is_empty() || name == "default" || name == "none" {
return None;
}
PERSONALITY_PRESETS
.iter()
.find(|(preset_name, _)| *preset_name == name.as_str())
.map(|(_, text)| *text)
}
pub fn render_personality_preset(preset: &PersonalityPreset) -> String {
match preset {
PersonalityPreset::Text(text) => text.trim().to_string(),
PersonalityPreset::Detailed {
system_prompt,
tone,
style,
..
} => [system_prompt.trim(), tone.trim(), style.trim()]
.into_iter()
.filter(|part| !part.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
.join("\n"),
}
}
pub fn preview_personality_preset(preset: &PersonalityPreset) -> String {
match preset {
PersonalityPreset::Text(text) => text.trim().to_string(),
PersonalityPreset::Detailed {
description,
system_prompt,
..
} => {
let description = description.trim();
if description.is_empty() {
system_prompt.trim().to_string()
} else {
description.to_string()
}
}
}
}
pub fn resolve_personality(config: &AppConfig, name: &str) -> Option<String> {
let normalized = name.trim().to_lowercase();
if normalized.is_empty() || matches!(normalized.as_str(), "default" | "none" | "neutral") {
return None;
}
if let Some((_, preset)) = config
.agent
.personalities
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case(&normalized))
{
let rendered = render_personality_preset(preset);
if !rendered.trim().is_empty() {
return Some(rendered);
}
}
resolve_builtin_personality(&normalized).map(ToOwned::to_owned)
}
pub fn personality_catalog(config: &AppConfig) -> Vec<(String, String)> {
let mut catalog: HashMap<String, String> = PERSONALITY_PRESETS
.iter()
.map(|(name, prompt)| ((*name).to_string(), prompt.to_string()))
.collect();
for (name, preset) in &config.agent.personalities {
let preview = preview_personality_preset(preset);
if !preview.trim().is_empty() {
catalog.insert(name.to_lowercase(), preview);
}
}
let mut entries: Vec<(String, String)> = catalog.into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
entries
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct PrivacyConfig {
pub redact_pii: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct BrowserConfig {
pub record_sessions: bool,
pub command_timeout: u64,
pub recording_max_age_hours: u64,
}
impl Default for BrowserConfig {
fn default() -> Self {
Self {
record_sessions: false,
command_timeout: 30,
recording_max_age_hours: 72,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct CheckpointsConfig {
pub enabled: bool,
pub max_snapshots: u32,
pub max_total_size_mb: u32,
pub max_file_size_mb: u32,
pub auto_prune: bool,
pub retention_days: u32,
pub delete_orphans: bool,
pub min_interval_hours: u32,
}
impl Default for CheckpointsConfig {
fn default() -> Self {
Self {
enabled: true,
max_snapshots: 20,
max_total_size_mb: 200,
max_file_size_mb: 10,
auto_prune: true,
retention_days: 7,
delete_orphans: true,
min_interval_hours: 24,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct ComputerUseConfig {
pub enabled: bool,
pub keep_last_n_screenshots: u32,
pub confirm_destructive: bool,
pub cua_driver_cmd: String,
}
impl Default for ComputerUseConfig {
fn default() -> Self {
Self {
enabled: false,
keep_last_n_screenshots: 1,
confirm_destructive: true,
cua_driver_cmd: "cua-driver".into(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct TtsConfig {
pub provider: String,
pub voice: String,
pub rate: Option<String>,
pub model: Option<String>,
pub elevenlabs_voice_id: Option<String>,
pub elevenlabs_model_id: Option<String>,
pub elevenlabs_api_key_env: String,
pub auto_play: bool,
}
impl Default for TtsConfig {
fn default() -> Self {
Self {
provider: "edge-tts".into(),
voice: "en-US-AriaNeural".into(),
rate: None,
model: None,
elevenlabs_voice_id: None,
elevenlabs_model_id: None,
elevenlabs_api_key_env: "ELEVENLABS_API_KEY".into(),
auto_play: true,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct SttConfig {
pub provider: String,
pub whisper_model: String,
pub silence_threshold: f32,
pub silence_duration_ms: u32,
}
impl Default for SttConfig {
fn default() -> Self {
Self {
provider: "local".into(),
whisper_model: "base".into(),
silence_threshold: -40.0,
silence_duration_ms: 1500,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct ImageGenerationConfig {
pub provider: String,
pub model: String,
}
impl Default for ImageGenerationConfig {
fn default() -> Self {
Self {
provider: "gemini".into(),
model: "gemini-2.5-flash-image".into(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct VoiceConfig {
pub enabled: bool,
pub push_to_talk_key: String,
pub input_device: Option<String>,
pub continuous: bool,
pub hallucination_filter: bool,
}
impl Default for VoiceConfig {
fn default() -> Self {
Self {
enabled: false,
push_to_talk_key: "ctrl+b".into(),
input_device: None,
continuous: false,
hallucination_filter: true,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct ApprovalsConfig {
pub mode: lingshu_security::approval::ApprovalMode,
pub timeout: u32,
pub cron_mode: String,
pub smart_model: Option<String>,
}
impl Default for ApprovalsConfig {
fn default() -> Self {
Self {
mode: lingshu_security::approval::ApprovalMode::Manual,
timeout: 60,
cron_mode: "deny".into(),
smart_model: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct UpdatesConfig {
pub pre_update_snapshot: bool,
pub pre_update_snapshot_keep: u32,
}
impl Default for UpdatesConfig {
fn default() -> Self {
Self {
pre_update_snapshot: true,
pre_update_snapshot_keep: 1,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct KanbanConfig {
pub enabled: bool,
pub claim_ttl_secs: u32,
pub max_workers: u32,
pub max_in_progress_per_profile: Option<u32>,
pub dispatch_in_gateway: bool,
pub reclaim_interval_secs: u32,
pub failure_limit: u32,
pub default_max_runtime_secs: u32,
pub auto_decompose: bool,
pub auto_decompose_per_tick: u32,
pub require_api_auth: bool,
pub api_token_path: Option<PathBuf>,
pub allow_localhost_without_auth: bool,
pub orchestrator_profile: Option<String>,
pub default_assignee: Option<String>,
pub auto_promote_children: bool,
pub rate_limit_cooldown_secs: u32,
pub respawn_guard_success_window_secs: u32,
pub respawn_guard_pr_window_secs: u32,
}
impl Default for KanbanConfig {
fn default() -> Self {
Self {
enabled: false,
claim_ttl_secs: 900,
max_workers: 3,
max_in_progress_per_profile: None,
dispatch_in_gateway: true,
reclaim_interval_secs: 60,
failure_limit: 2,
default_max_runtime_secs: 0,
auto_decompose: true,
auto_decompose_per_tick: 3,
require_api_auth: true,
api_token_path: None,
allow_localhost_without_auth: true,
orchestrator_profile: None,
default_assignee: None,
auto_promote_children: true,
rate_limit_cooldown_secs: 300,
respawn_guard_success_window_secs: 3600,
respawn_guard_pr_window_secs: 86_400,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct SurrealConfig {
pub enabled: bool,
pub url: Option<String>,
pub namespace: Option<String>,
pub database: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub auto_start: bool,
pub data_dir: Option<PathBuf>,
}
impl Default for SurrealConfig {
fn default() -> Self {
Self {
enabled: false,
url: None,
namespace: None,
database: None,
username: None,
password: None,
auto_start: true,
data_dir: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct KanbanDecomposerConfig {
pub model: Option<String>,
pub max_tokens: u32,
}
impl Default for KanbanDecomposerConfig {
fn default() -> Self {
Self {
model: None,
max_tokens: 4096,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct ProfileDescriberConfig {
pub model: Option<String>,
pub max_tokens: u32,
}
impl Default for ProfileDescriberConfig {
fn default() -> Self {
Self {
model: None,
max_tokens: 512,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct AuxiliaryConfig {
pub model: Option<String>,
pub provider: Option<String>,
pub base_url: Option<String>,
pub api_key_env: Option<String>,
pub goal_judge: GoalJudgeConfig,
pub kanban_decomposer: KanbanDecomposerConfig,
pub profile_describer: ProfileDescriberConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct GoalJudgeConfig {
pub model: Option<String>,
pub max_tokens: u32,
}
impl Default for GoalJudgeConfig {
fn default() -> Self {
Self {
model: None,
max_tokens: 4096,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct GoalsConfig {
pub max_turns: u32,
}
impl Default for GoalsConfig {
fn default() -> Self {
Self { max_turns: 20 }
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct ShadowJudgeConfig {
pub enabled: bool,
pub model: Option<String>,
pub max_per_session: u32,
pub confidence_threshold: f32,
pub context_messages: usize,
pub min_messages_before_enable: usize,
}
impl Default for ShadowJudgeConfig {
fn default() -> Self {
Self {
enabled: false,
model: None,
max_per_session: 5,
confidence_threshold: 0.70,
context_messages: 20,
min_messages_before_enable: 4,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct MoaConfig {
pub enabled: bool,
pub reference_models: Vec<String>,
pub aggregator_model: String,
}
impl Default for MoaConfig {
fn default() -> Self {
Self {
enabled: true,
reference_models: lingshu_tools::tools::mixture_of_agents::default_reference_models(),
aggregator_model: lingshu_tools::tools::mixture_of_agents::DEFAULT_AGGREGATOR_MODEL
.to_string(),
}
}
}
impl MoaConfig {
pub fn sanitized(&self) -> Self {
let effective = lingshu_tools::tools::mixture_of_agents::sanitize_moa_config(
self.enabled,
&self.reference_models,
&self.aggregator_model,
);
Self {
enabled: effective.enabled,
reference_models: effective.reference_models,
aggregator_model: effective.aggregator_model,
}
}
}
pub fn lingshu_home() -> PathBuf {
std::env::var("EDGECRAB_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
dirs::home_dir()
.expect("no home directory found")
.join(".lingshu")
})
}
pub fn gateway_image_cache_dir() -> PathBuf {
lingshu_home().join("image_cache")
}
pub fn gateway_media_dir() -> PathBuf {
lingshu_home().join("gateway_media")
}
pub fn ensure_lingshu_home() -> Result<PathBuf, AgentError> {
let home = lingshu_home();
let subdirs = [
"memories",
"skills",
"skins",
"plugins",
"mcp",
"cache",
"cron",
"sessions",
"logs",
"sandboxes",
"hooks",
"checkpoints",
];
for dir in std::iter::once(home.as_path()).chain(
subdirs
.iter()
.map(|s| home.join(s))
.collect::<Vec<_>>()
.iter()
.map(|p| p.as_path()),
) {
if !dir.exists() {
std::fs::create_dir_all(dir).map_err(AgentError::Io)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
.map_err(AgentError::Io)?;
}
}
}
Ok(home)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_is_valid() {
let cfg = AppConfig::default();
assert_eq!(cfg.model.max_iterations, 90);
assert!(cfg.model.streaming);
assert_eq!(cfg.tools.max_parallel_workers, 8);
assert!(cfg.compression.enabled);
}
#[test]
fn serde_roundtrip() {
let cfg = AppConfig::default();
let yaml = serde_yml::to_string(&cfg).expect("serialize");
let parsed: AppConfig = serde_yml::from_str(&yaml).expect("deserialize");
assert_eq!(parsed.model.default_model, cfg.model.default_model);
assert_eq!(parsed.model.max_iterations, cfg.model.max_iterations);
}
#[test]
fn partial_yaml_fills_defaults() {
let yaml = r#"
model:
default: "openai/gpt-4o"
max_iterations: 42
"#;
let cfg: AppConfig = serde_yml::from_str(yaml).expect("parse partial");
assert_eq!(cfg.model.default_model, "openai/gpt-4o");
assert_eq!(cfg.model.max_iterations, 42);
assert!(cfg.model.streaming);
assert_eq!(cfg.tools.tool_delay, 1.0);
}
#[test]
fn ensure_toolset_enabled_appends_computer_use() {
let mut toolsets = Some(vec!["core".into()]);
AppConfig::ensure_toolset_enabled(&mut toolsets, "computer_use");
let list = toolsets.expect("toolsets");
assert!(list.iter().any(|name| name == "computer_use"));
}
#[test]
fn persist_lsp_enabled_round_trip_via_save_to() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.yaml");
let mut config = AppConfig::default();
config.lsp.enabled = true;
config.save_to(&path).expect("save initial");
config.lsp.enabled = false;
config.save_to(&path).expect("save off");
let loaded = AppConfig::load_from(&path).expect("load off");
assert!(!loaded.lsp.enabled);
config.lsp.enabled = true;
config.save_to(&path).expect("save on");
let loaded = AppConfig::load_from(&path).expect("load on");
assert!(loaded.lsp.enabled);
}
#[test]
fn lsp_status_summary_mentions_post_write() {
let summary = AppConfig::lsp_status_summary(&LspConfig::default());
assert!(summary.contains("Post-write"));
assert!(summary.contains("/lsp"));
}
#[test]
fn default_lsp_server_catalog_covers_mainstream_languages() {
let servers = default_lsp_servers();
for name in [
"rust",
"typescript",
"javascript",
"python",
"go",
"c",
"cpp",
"java",
"csharp",
"php",
"ruby",
"bash",
"html",
"css",
"json",
] {
assert!(
servers.contains_key(name),
"missing built-in LSP server config for {name}"
);
}
}
#[test]
fn default_lsp_server_catalog_has_expected_routing_details() {
let servers = default_lsp_servers();
let java = servers.get("java").expect("java config");
assert_eq!(java.command, "jdtls");
assert!(java.file_extensions.contains(&"java".to_string()));
let csharp = servers.get("csharp").expect("csharp config");
assert_eq!(csharp.command, "csharp-ls");
assert!(csharp.root_markers.contains(&"*.sln".to_string()));
let bash = servers.get("bash").expect("bash config");
assert_eq!(bash.command, "bash-language-server");
assert_eq!(bash.args, vec!["start".to_string()]);
let html = servers.get("html").expect("html config");
assert_eq!(html.command, "vscode-html-language-server");
assert!(html.file_extensions.contains(&"html".to_string()));
}
#[test]
fn custom_personality_from_agent_block_is_resolved() {
let yaml = r#"
agent:
personalities:
codereviewer:
description: "Meticulous reviewer"
system_prompt: "Review code for bugs and design issues."
display:
personality: "codereviewer"
"#;
let cfg: AppConfig = serde_yml::from_str(yaml).expect("parse custom personality");
let resolved = resolve_personality(&cfg, "codereviewer").expect("custom personality");
assert!(resolved.contains("Review code for bugs"));
let catalog = personality_catalog(&cfg);
assert!(catalog.iter().any(|(name, preview)| {
name == "codereviewer" && preview.contains("Meticulous reviewer")
}));
}
#[test]
fn built_in_personality_is_still_resolved() {
let cfg = AppConfig::default();
let resolved = resolve_personality(&cfg, "teacher").expect("teacher personality");
assert!(resolved.contains("patient, encouraging teacher"));
}
#[test]
fn legacy_default_model_key_is_accepted() {
let yaml = r#"
model:
default_model: "copilot/gpt-4.1"
"#;
let cfg: AppConfig = serde_yml::from_str(yaml).expect("parse legacy key");
assert_eq!(cfg.model.default_model, "copilot/gpt-4.1");
}
#[test]
fn parse_compat_handles_both_default_keys() {
let yaml = r#"
model:
default: "anthropic/claude-sonnet-4-20250514"
default_model: "copilot/gpt-4.1"
"#;
let cfg = AppConfig::parse_compat_yaml(yaml, Path::new("/tmp/test-config.yaml"))
.expect("parse compat");
assert_eq!(cfg.model.default_model, "copilot/gpt-4.1");
}
#[test]
fn parse_compat_accepts_legacy_scalar_model_with_provider() {
let yaml = r#"
provider: "openrouter"
model: "nousresearch/hermes-3-llama-3.1-405b"
"#;
let cfg = AppConfig::parse_compat_yaml(yaml, Path::new("/tmp/test-config.yaml"))
.expect("parse compat");
assert_eq!(
cfg.model.default_model,
"openrouter/nousresearch/hermes-3-llama-3.1-405b"
);
}
#[test]
fn parse_compat_promotes_legacy_tools_allowed_paths() {
let yaml = r#"
tools:
allowed_paths:
- /tmp/project
"#;
let cfg = AppConfig::parse_compat_yaml(yaml, Path::new("/tmp/test-config.yaml"))
.expect("parse compat");
assert_eq!(
cfg.tools.file.allowed_roots,
vec![PathBuf::from("/tmp/project")]
);
}
#[test]
fn parse_compat_accepts_provider_with_nested_model() {
let yaml = r#"
provider: openai
model:
default_model: xopglm
base_url: https://maas-coding-api.cn-huabei-1.xf-yun.com/v2
api_key_env: xfyun_API_KEY
"#;
let cfg = AppConfig::parse_compat_yaml(yaml, Path::new("/tmp/test-config.yaml"))
.expect("parse compat");
assert_eq!(cfg.model.default_model, "openai/xopglm");
assert_eq!(cfg.model.base_url, Some("https://maas-coding-api.cn-huabei-1.xf-yun.com/v2".to_string()));
assert_eq!(cfg.model.api_key_env, "xfyun_API_KEY");
}
#[test]
fn tools_file_allowed_roots_parse_directly() {
let yaml = r#"
tools:
file:
allowed_roots:
- /tmp/project
- ../shared
"#;
let cfg: AppConfig = serde_yml::from_str(yaml).expect("parse tools.file.allowed_roots");
assert_eq!(
cfg.tools.file.allowed_roots,
vec![PathBuf::from("/tmp/project"), PathBuf::from("../shared")]
);
}
#[test]
fn cli_overrides_merge() {
let mut cfg = AppConfig::default();
let cli = CliOverrides {
model: Some("local/llama".into()),
max_iterations: Some(10),
..Default::default()
};
cfg.merge_cli(&cli);
assert_eq!(cfg.model.default_model, "local/llama");
assert_eq!(cfg.model.max_iterations, 10);
assert!(cfg.model.streaming);
}
#[test]
fn env_override_model() {
unsafe { std::env::set_var("EDGECRAB_MODEL", "test/model") };
let mut cfg = AppConfig::default();
cfg.apply_env_overrides();
assert_eq!(cfg.model.default_model, "test/model");
unsafe { std::env::remove_var("EDGECRAB_MODEL") };
}
#[test]
fn env_override_worktree() {
unsafe { std::env::set_var("EDGECRAB_WORKTREE", "1") };
let mut cfg = AppConfig::default();
cfg.apply_env_overrides();
assert!(cfg.worktree);
unsafe { std::env::remove_var("EDGECRAB_WORKTREE") };
}
#[test]
fn managed_mode_from_env() {
unsafe { std::env::set_var("EDGECRAB_MANAGED", "1") };
let mut cfg = AppConfig::default();
cfg.apply_env_overrides();
assert!(cfg.is_managed());
unsafe { std::env::remove_var("EDGECRAB_MANAGED") };
}
#[test]
fn lingshu_home_respects_env() {
unsafe { std::env::set_var("EDGECRAB_HOME", "/tmp/test-lingshu") };
let home = lingshu_home();
assert_eq!(home, PathBuf::from("/tmp/test-lingshu"));
unsafe { std::env::remove_var("EDGECRAB_HOME") };
}
#[test]
fn env_override_gateway_whatsapp() {
unsafe {
std::env::set_var("WHATSAPP_ENABLED", "true");
std::env::set_var("WHATSAPP_MODE", "bot");
std::env::set_var("WHATSAPP_ALLOWED_USERS", "111,222");
std::env::set_var("WHATSAPP_BRIDGE_PORT", "4555");
}
let mut cfg = AppConfig::default();
cfg.apply_env_overrides();
assert!(cfg.gateway.whatsapp.enabled);
assert!(cfg.gateway.platform_enabled("whatsapp"));
assert_eq!(cfg.gateway.whatsapp.mode, "bot");
assert_eq!(cfg.gateway.whatsapp.allowed_users, vec!["111", "222"]);
assert_eq!(cfg.gateway.whatsapp.bridge_port, 4555);
unsafe {
std::env::remove_var("WHATSAPP_ENABLED");
std::env::remove_var("WHATSAPP_MODE");
std::env::remove_var("WHATSAPP_ALLOWED_USERS");
std::env::remove_var("WHATSAPP_BRIDGE_PORT");
}
}
#[test]
fn email_env_ready_accepts_generic_smtp_without_api_key() {
unsafe {
std::env::set_var("EMAIL_PROVIDER", "generic_smtp");
std::env::set_var("EMAIL_FROM", "bot@example.com");
std::env::set_var("EMAIL_SMTP_HOST", "smtp.example.com");
std::env::set_var("EMAIL_SMTP_PASSWORD", "secret");
std::env::remove_var("EMAIL_API_KEY");
std::env::remove_var("EMAIL_DOMAIN");
}
let mut cfg = AppConfig::default();
cfg.apply_env_overrides();
assert!(cfg.gateway.platform_enabled("email"));
unsafe {
std::env::remove_var("EMAIL_PROVIDER");
std::env::remove_var("EMAIL_FROM");
std::env::remove_var("EMAIL_SMTP_HOST");
std::env::remove_var("EMAIL_SMTP_PASSWORD");
}
}
#[test]
fn gateway_platform_enable_is_idempotent() {
let mut gateway = GatewayConfig::default();
gateway.enable_platform("whatsapp");
gateway.enable_platform("WhatsApp");
assert_eq!(gateway.enabled_platforms, vec!["whatsapp"]);
}
#[test]
fn gateway_platform_disable_overrides_env_activation() {
unsafe {
std::env::set_var("MATRIX_HOMESERVER", "https://matrix.example");
std::env::set_var("MATRIX_ACCESS_TOKEN", "token");
}
let mut cfg = AppConfig::default();
cfg.gateway.disable_platform("matrix");
cfg.apply_env_overrides();
assert!(cfg.gateway.platform_disabled("matrix"));
assert!(!cfg.gateway.platform_enabled("matrix"));
unsafe {
std::env::remove_var("MATRIX_HOMESERVER");
std::env::remove_var("MATRIX_ACCESS_TOKEN");
}
}
#[test]
fn gateway_platform_requested_respects_explicit_disable_over_legacy_flag() {
let mut gateway = GatewayConfig::default();
gateway.telegram.enabled = true;
gateway.enable_platform("telegram");
assert!(gateway.platform_requested("telegram", gateway.telegram.enabled));
gateway.disable_platform("telegram");
assert!(gateway.platform_disabled("telegram"));
assert!(!gateway.platform_requested("telegram", gateway.telegram.enabled));
}
#[test]
fn load_from_nonexistent_returns_error() {
let result = AppConfig::load_from(Path::new("/nonexistent/config.yaml"));
assert!(result.is_err());
}
#[test]
fn security_defaults_are_safe() {
let cfg = SecurityConfig::default();
assert!(cfg.injection_scanning);
assert!(cfg.url_safety);
assert!(!cfg.website_blocklist.enabled);
assert!(!cfg.managed_mode);
}
#[test]
fn smart_routing_yaml_defaults_disabled() {
let sr = SmartRoutingYaml::default();
assert!(!sr.enabled);
assert!(sr.cheap_model.is_empty());
assert!(sr.cheap_base_url.is_none());
}
#[test]
fn smart_routing_yaml_deserializes() {
let yaml = r#"
model:
default: "anthropic/claude-sonnet-4-20250514"
smart_routing:
enabled: true
cheap_model: "copilot/gpt-4.1-mini"
cheap_base_url: "https://api.openai.com/v1"
"#;
let cfg: AppConfig = serde_yml::from_str(yaml).expect("parse");
assert!(cfg.model.smart_routing.enabled);
assert_eq!(cfg.model.smart_routing.cheap_model, "copilot/gpt-4.1-mini");
}
#[test]
fn moa_defaults_match_tool_defaults() {
let moa = MoaConfig::default();
assert!(moa.enabled);
assert_eq!(
moa.aggregator_model,
lingshu_tools::tools::mixture_of_agents::DEFAULT_AGGREGATOR_MODEL
);
assert_eq!(
moa.reference_models,
lingshu_tools::tools::mixture_of_agents::default_reference_models()
);
}
#[test]
fn moa_config_deserializes() {
let yaml = r#"
moa:
enabled: false
aggregator_model: "anthropic/claude-opus-4.6"
reference_models:
- "anthropic/claude-opus-4.6"
- "openai/gpt-4.1"
"#;
let cfg: AppConfig = serde_yml::from_str(yaml).expect("parse");
assert!(!cfg.moa.enabled);
assert_eq!(cfg.moa.aggregator_model, "anthropic/claude-opus-4.6");
assert_eq!(
cfg.moa.reference_models,
vec!["anthropic/claude-opus-4.6", "openai/gpt-4.1"]
);
}
#[test]
fn moa_config_sanitizes_invalid_entries() {
let moa = MoaConfig {
enabled: false,
aggregator_model: " ".into(),
reference_models: vec![
"copilot/gpt-4.1-mini".into(),
"copilot/gpt-4.1-mini".into(),
"invalid".into(),
],
}
.sanitized();
assert!(!moa.enabled);
assert_eq!(moa.aggregator_model, "anthropic/claude-opus-4.6");
assert_eq!(moa.reference_models, vec!["vscode-copilot/gpt-4.1-mini"]);
}
}