use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ProjectBackend {
#[default]
Github,
Jira,
Linear,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ProjectConfig {
pub backend: ProjectBackend,
pub default_project: String,
pub jira_url: String,
pub jira_token: Option<String>,
pub github_token: Option<String>,
pub linear_api_key: Option<String>,
}
impl Default for ProjectConfig {
fn default() -> Self {
Self {
backend: ProjectBackend::Github,
default_project: String::new(),
jira_url: String::new(),
jira_token: None,
github_token: None,
linear_api_key: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct Config {
pub agents: AgentConfig,
pub channels: ChannelsConfig,
pub providers: ProvidersConfig,
pub gateway: GatewayConfig,
pub tools: ToolsConfig,
pub memory: MemoryConfig,
pub heartbeat: HeartbeatConfig,
pub skills: SkillsConfig,
pub runtime: RuntimeConfig,
pub container_agent: ContainerAgentConfig,
pub swarm: SwarmConfig,
pub approval: crate::tools::approval::ApprovalConfig,
pub plugins: crate::plugins::types::PluginConfig,
pub telemetry: crate::utils::telemetry::TelemetryConfig,
pub cost: crate::utils::cost::CostConfig,
pub batch: crate::batch::BatchConfig,
pub hooks: crate::hooks::HooksConfig,
pub safety: crate::safety::SafetyConfig,
pub compaction: CompactionConfig,
pub mcp: McpConfig,
pub routines: RoutinesConfig,
pub tunnel: TunnelConfig,
pub stripe: StripeConfig,
pub cache: CacheConfig,
pub agent_mode: crate::security::agent_mode::AgentModeConfig,
pub pairing: PairingConfig,
pub session: SessionConfig,
#[serde(default)]
pub custom_tools: Vec<CustomToolDef>,
pub transcription: TranscriptionConfig,
#[serde(default)]
pub tool_profiles: HashMap<String, Option<Vec<String>>>,
pub project: ProjectConfig,
#[serde(default)]
pub health: HealthConfig,
#[serde(default)]
pub devices: DevicesConfig,
#[serde(default)]
pub logging: LoggingConfig,
#[serde(default)]
pub panel: PanelConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum LogFormat {
Pretty,
Component,
Json,
}
fn default_log_format() -> LogFormat {
LogFormat::Component
}
fn default_log_level() -> String {
"info".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingConfig {
#[serde(default = "default_log_format")]
pub format: LogFormat,
pub file: Option<String>,
#[serde(default = "default_log_level")]
pub level: String,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
format: default_log_format(),
file: None,
level: default_log_level(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DevicesConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub monitor_usb: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuthMode {
#[default]
Token,
Password,
None,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PanelConfig {
pub enabled: bool,
pub port: u16,
pub api_port: u16,
pub auth_mode: AuthMode,
pub bind: String,
}
impl Default for PanelConfig {
fn default() -> Self {
Self {
enabled: false,
port: 9092,
api_port: 9091,
auth_mode: AuthMode::Token,
bind: "127.0.0.1".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CacheConfig {
pub enabled: bool,
pub ttl_secs: u64,
pub max_entries: usize,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
enabled: false,
ttl_secs: 3600,
max_entries: 500,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PairingConfig {
pub enabled: bool,
pub max_attempts: u32,
pub lockout_secs: u64,
}
impl Default for PairingConfig {
fn default() -> Self {
Self {
enabled: false,
max_attempts: 5,
lockout_secs: 300,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SessionConfig {
pub auto_repair: bool,
}
impl Default for SessionConfig {
fn default() -> Self {
Self { auto_repair: true }
}
}
fn default_health_host() -> String {
"127.0.0.1".to_string()
}
fn default_health_port() -> u16 {
9090
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_health_host")]
pub host: String,
#[serde(default = "default_health_port")]
pub port: u16,
}
impl Default for HealthConfig {
fn default() -> Self {
Self {
enabled: false,
host: default_health_host(),
port: default_health_port(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CompactionConfig {
pub enabled: bool,
pub context_limit: usize,
pub threshold: f64,
pub emergency_threshold: f64,
pub critical_threshold: f64,
}
impl Default for CompactionConfig {
fn default() -> Self {
Self {
enabled: false,
context_limit: 100_000,
threshold: 0.70,
emergency_threshold: 0.90,
critical_threshold: 0.95,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct McpConfig {
pub servers: Vec<McpServerConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerConfig {
pub name: String,
pub url: Option<String>,
pub command: Option<String>,
pub args: Option<Vec<String>>,
pub env: Option<std::collections::HashMap<String, String>>,
#[serde(default = "default_mcp_timeout")]
pub timeout_secs: u64,
}
fn default_mcp_timeout() -> u64 {
30
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RoutinesConfig {
pub enabled: bool,
pub cron_interval_secs: u64,
pub max_concurrent: usize,
#[serde(default)]
pub jitter_ms: u64,
#[serde(default)]
pub on_miss: crate::cron::OnMiss,
}
impl Default for RoutinesConfig {
fn default() -> Self {
Self {
enabled: false,
cron_interval_secs: 60,
max_concurrent: 3,
jitter_ms: 0,
on_miss: crate::cron::OnMiss::Skip,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct StripeConfig {
pub secret_key: Option<String>,
pub default_currency: String,
pub webhook_secret: Option<String>,
}
impl Default for StripeConfig {
fn default() -> Self {
Self {
secret_key: None,
default_currency: "usd".to_string(),
webhook_secret: None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct TunnelConfig {
pub provider: Option<String>,
pub cloudflare: Option<CloudflareTunnelConfig>,
pub ngrok: Option<NgrokTunnelConfig>,
pub tailscale: Option<TailscaleTunnelConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CloudflareTunnelConfig {
pub token: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct NgrokTunnelConfig {
pub authtoken: Option<String>,
pub domain: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TailscaleTunnelConfig {
#[serde(default = "default_true")]
pub funnel: bool,
}
impl Default for TailscaleTunnelConfig {
fn default() -> Self {
Self { funnel: true }
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TranscriptionConfig {
pub enabled: bool,
pub model: String,
}
impl Default for TranscriptionConfig {
fn default() -> Self {
Self {
enabled: true,
model: "whisper-1".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct AgentConfig {
pub defaults: AgentDefaults,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LoopGuardConfig {
pub enabled: bool,
pub warn_threshold: u32,
pub block_threshold: u32,
pub global_circuit_breaker: u32,
pub ping_pong_min_repeats: u32,
pub poll_multiplier: u32,
pub outcome_warn_threshold: u32,
pub outcome_block_threshold: u32,
#[serde(default = "default_window_size")]
pub window_size: u32,
}
fn default_window_size() -> u32 {
200
}
impl Default for LoopGuardConfig {
fn default() -> Self {
Self {
enabled: true,
warn_threshold: 3,
block_threshold: 5,
global_circuit_breaker: 30,
ping_pong_min_repeats: 3,
poll_multiplier: 3,
outcome_warn_threshold: 2,
outcome_block_threshold: 3,
window_size: default_window_size(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AgentDefaults {
pub workspace: String,
pub model: String,
pub max_tokens: u32,
pub temperature: f32,
pub max_tool_iterations: u32,
pub agent_timeout_secs: u64,
pub tool_timeout_secs: u64,
pub message_queue_mode: MessageQueueMode,
pub streaming: bool,
pub token_budget: u64,
#[serde(default)]
pub compact_tools: bool,
#[serde(default)]
pub tool_profile: Option<String>,
#[serde(default)]
pub active_hand: Option<String>,
#[serde(default = "default_timezone")]
pub timezone: String,
#[serde(default)]
pub loop_guard: LoopGuardConfig,
#[serde(default = "default_max_tool_result_bytes")]
pub max_tool_result_bytes: usize,
#[serde(default)]
pub max_tool_calls: Option<u32>,
}
fn default_timezone() -> String {
if let Ok(tz) = std::env::var("TZ") {
if !tz.is_empty() {
return tz;
}
}
#[cfg(unix)]
{
if let Ok(target) = std::fs::read_link("/etc/localtime") {
let path = target.to_string_lossy();
if let Some(pos) = path.find("zoneinfo/") {
return path[pos + 9..].to_string();
}
}
}
"UTC".to_string()
}
fn default_max_tool_result_bytes() -> usize {
crate::utils::sanitize::DEFAULT_MAX_RESULT_BYTES
}
const COMPILE_TIME_DEFAULT_MODEL: &str = match option_env!("ZEPTOCLAW_DEFAULT_MODEL") {
Some(v) => v,
None => "claude-sonnet-4-5-20250929",
};
impl Default for AgentDefaults {
fn default() -> Self {
Self {
workspace: "~/.zeptoclaw/workspace".to_string(),
model: COMPILE_TIME_DEFAULT_MODEL.to_string(),
max_tokens: 8192,
temperature: 0.7,
max_tool_iterations: 20,
agent_timeout_secs: 300,
tool_timeout_secs: 0,
message_queue_mode: MessageQueueMode::default(),
streaming: false,
token_budget: 0,
compact_tools: false,
tool_profile: None,
active_hand: None,
timezone: default_timezone(),
loop_guard: LoopGuardConfig::default(),
max_tool_result_bytes: default_max_tool_result_bytes(),
max_tool_calls: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum MessageQueueMode {
#[default]
Collect,
Followup,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ChannelsConfig {
pub telegram: Option<TelegramConfig>,
pub discord: Option<DiscordConfig>,
pub slack: Option<SlackConfig>,
pub whatsapp: Option<WhatsAppConfig>,
pub whatsapp_cloud: Option<WhatsAppCloudConfig>,
pub feishu: Option<FeishuConfig>,
pub lark: Option<LarkConfig>,
pub maixcam: Option<MaixCamConfig>,
pub qq: Option<QQConfig>,
pub dingtalk: Option<DingTalkConfig>,
pub webhook: Option<WebhookConfig>,
pub email: Option<EmailConfig>,
pub serial: Option<SerialChannelConfig>,
pub mqtt: Option<MqttChannelConfig>,
#[serde(default)]
pub channel_plugins_dir: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SerialChannelConfig {
pub enabled: bool,
pub port: String,
pub baud_rate: u32,
#[serde(default)]
pub allow_from: Vec<String>,
#[serde(default)]
pub deny_by_default: bool,
}
impl Default for SerialChannelConfig {
fn default() -> Self {
Self {
enabled: false,
port: String::new(),
baud_rate: 115_200,
allow_from: Vec::new(),
deny_by_default: false,
}
}
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct MqttChannelConfig {
pub enabled: bool,
pub broker_url: String,
pub client_id: String,
pub subscribe_topics: Vec<String>,
pub publish_prefix: String,
pub qos: u8,
#[serde(default)]
pub username: String,
#[serde(default)]
pub password: String,
#[serde(default)]
pub allow_from: Vec<String>,
#[serde(default)]
pub deny_by_default: bool,
}
impl Default for MqttChannelConfig {
fn default() -> Self {
Self {
enabled: false,
broker_url: "mqtt://localhost:1883".to_string(),
client_id: "zeptoclaw-agent".to_string(),
subscribe_topics: vec!["zeptoclaw/inbox/#".to_string()],
publish_prefix: "zeptoclaw/outbox".to_string(),
qos: 1,
username: String::new(),
password: String::new(),
allow_from: Vec::new(),
deny_by_default: false,
}
}
}
impl std::fmt::Debug for MqttChannelConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MqttChannelConfig")
.field("enabled", &self.enabled)
.field("broker_url", &self.broker_url)
.field("client_id", &self.client_id)
.field("subscribe_topics", &self.subscribe_topics)
.field("publish_prefix", &self.publish_prefix)
.field("qos", &self.qos)
.field("username", &self.username)
.field("password", &"[redacted]")
.field("allow_from", &self.allow_from)
.field("deny_by_default", &self.deny_by_default)
.finish()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_webhook_bind_address")]
pub bind_address: String,
#[serde(default = "default_webhook_port")]
pub port: u16,
#[serde(default = "default_webhook_path")]
pub path: String,
#[serde(default)]
pub auth_token: Option<String>,
#[serde(default)]
pub allow_from: Vec<String>,
#[serde(default)]
pub deny_by_default: bool,
}
fn default_webhook_bind_address() -> String {
"127.0.0.1".to_string()
}
fn default_webhook_port() -> u16 {
9876
}
fn default_webhook_path() -> String {
"/webhook".to_string()
}
impl Default for WebhookConfig {
fn default() -> Self {
Self {
enabled: false,
bind_address: default_webhook_bind_address(),
port: default_webhook_port(),
path: default_webhook_path(),
auth_token: None,
allow_from: Vec::new(),
deny_by_default: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TelegramConfig {
#[serde(default)]
pub enabled: bool,
pub token: String,
#[serde(default)]
pub allow_from: Vec<String>,
#[serde(default)]
pub deny_by_default: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DiscordConfig {
#[serde(default)]
pub enabled: bool,
pub token: String,
#[serde(default)]
pub allow_from: Vec<String>,
#[serde(default)]
pub deny_by_default: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SlackConfig {
#[serde(default)]
pub enabled: bool,
pub bot_token: String,
pub app_token: String,
#[serde(default)]
pub allow_from: Vec<String>,
#[serde(default)]
pub deny_by_default: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WhatsAppConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_whatsapp_bridge_url")]
pub bridge_url: String,
#[serde(default)]
pub bridge_token: Option<String>,
#[serde(default)]
pub allow_from: Vec<String>,
#[serde(default)]
pub deny_by_default: bool,
#[serde(default = "default_bridge_managed")]
pub bridge_managed: bool,
}
fn default_whatsapp_bridge_url() -> String {
"ws://localhost:3001".to_string()
}
fn default_bridge_managed() -> bool {
true
}
impl Default for WhatsAppConfig {
fn default() -> Self {
Self {
enabled: false,
bridge_url: default_whatsapp_bridge_url(),
bridge_token: None,
allow_from: Vec::new(),
deny_by_default: false,
bridge_managed: default_bridge_managed(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WhatsAppCloudConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub phone_number_id: String,
#[serde(default)]
pub access_token: String,
#[serde(default)]
pub webhook_verify_token: String,
#[serde(default = "default_whatsapp_cloud_bind")]
pub bind_address: String,
#[serde(default = "default_whatsapp_cloud_port")]
pub port: u16,
#[serde(default = "default_whatsapp_cloud_path")]
pub path: String,
#[serde(default)]
pub allow_from: Vec<String>,
#[serde(default)]
pub deny_by_default: bool,
}
fn default_whatsapp_cloud_bind() -> String {
"127.0.0.1".to_string()
}
fn default_whatsapp_cloud_port() -> u16 {
9877
}
fn default_whatsapp_cloud_path() -> String {
"/whatsapp".to_string()
}
impl Default for WhatsAppCloudConfig {
fn default() -> Self {
Self {
enabled: false,
phone_number_id: String::new(),
access_token: String::new(),
webhook_verify_token: String::new(),
bind_address: default_whatsapp_cloud_bind(),
port: default_whatsapp_cloud_port(),
path: default_whatsapp_cloud_path(),
allow_from: Vec::new(),
deny_by_default: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FeishuConfig {
#[serde(default)]
pub enabled: bool,
pub app_id: String,
pub app_secret: String,
#[serde(default)]
pub encrypt_key: String,
#[serde(default)]
pub verification_token: String,
#[serde(default)]
pub allow_from: Vec<String>,
#[serde(default)]
pub deny_by_default: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LarkConfig {
#[serde(default)]
pub enabled: bool,
pub app_id: String,
pub app_secret: String,
#[serde(default)]
pub feishu: bool,
#[serde(default)]
pub allowed_senders: Vec<String>,
#[serde(default)]
pub bot_open_id: Option<String>,
#[serde(default)]
pub deny_by_default: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaixCamConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_maixcam_host")]
pub host: String,
#[serde(default = "default_maixcam_port")]
pub port: u16,
#[serde(default)]
pub allow_from: Vec<String>,
#[serde(default)]
pub deny_by_default: bool,
}
fn default_maixcam_host() -> String {
"0.0.0.0".to_string()
}
fn default_maixcam_port() -> u16 {
18790
}
impl Default for MaixCamConfig {
fn default() -> Self {
Self {
enabled: false,
host: default_maixcam_host(),
port: default_maixcam_port(),
allow_from: Vec::new(),
deny_by_default: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct QQConfig {
#[serde(default)]
pub enabled: bool,
pub app_id: String,
pub app_secret: String,
#[serde(default)]
pub allow_from: Vec<String>,
#[serde(default)]
pub deny_by_default: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DingTalkConfig {
#[serde(default)]
pub enabled: bool,
pub client_id: String,
pub client_secret: String,
#[serde(default)]
pub allow_from: Vec<String>,
#[serde(default)]
pub deny_by_default: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ProvidersConfig {
pub anthropic: Option<ProviderConfig>,
pub openai: Option<ProviderConfig>,
pub openrouter: Option<ProviderConfig>,
pub groq: Option<ProviderConfig>,
pub zhipu: Option<ProviderConfig>,
pub vllm: Option<ProviderConfig>,
pub gemini: Option<ProviderConfig>,
pub ollama: Option<ProviderConfig>,
pub nvidia: Option<ProviderConfig>,
pub deepseek: Option<ProviderConfig>,
pub kimi: Option<ProviderConfig>,
#[serde(default)]
pub azure: Option<ProviderConfig>,
#[serde(default)]
pub bedrock: Option<ProviderConfig>,
#[serde(default)]
pub xai: Option<ProviderConfig>,
#[serde(default)]
pub qianfan: Option<ProviderConfig>,
pub retry: RetryConfig,
pub fallback: FallbackConfig,
pub rotation: RotationConfig,
#[serde(default)]
pub plugins: Vec<ProviderPluginConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderConfig {
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub api_base: Option<String>,
#[serde(default)]
pub auth_method: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub quota: Option<crate::providers::quota::QuotaConfig>,
#[serde(default)]
pub auth_header: Option<String>,
#[serde(default)]
pub api_version: Option<String>,
}
impl ProviderConfig {
pub fn resolved_auth_method(&self) -> crate::auth::AuthMethod {
crate::auth::AuthMethod::from_option(self.auth_method.as_deref())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderPluginConfig {
pub name: String,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RetryConfig {
pub enabled: bool,
pub max_retries: u32,
pub base_delay_ms: u64,
pub max_delay_ms: u64,
pub retry_budget_ms: u64,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
enabled: false,
max_retries: 3,
base_delay_ms: 1_000,
max_delay_ms: 30_000,
retry_budget_ms: 45_000,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct FallbackConfig {
pub enabled: bool,
pub provider: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RotationConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub order: Vec<String>,
#[serde(default)]
pub strategy: crate::providers::rotation::RotationStrategy,
#[serde(default = "default_rotation_failure_threshold")]
pub failure_threshold: u32,
#[serde(default = "default_rotation_cooldown_secs")]
pub cooldown_secs: u64,
}
fn default_rotation_failure_threshold() -> u32 {
3
}
fn default_rotation_cooldown_secs() -> u64 {
30
}
impl Default for RotationConfig {
fn default() -> Self {
Self {
enabled: false,
order: Vec::new(),
strategy: crate::providers::rotation::RotationStrategy::default(),
failure_threshold: default_rotation_failure_threshold(),
cooldown_secs: default_rotation_cooldown_secs(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct RateLimitConfig {
pub pair_per_min: u32,
pub webhook_per_min: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct StartupGuardConfig {
pub enabled: bool,
pub crash_threshold: u32,
pub window_secs: u64,
}
impl Default for StartupGuardConfig {
fn default() -> Self {
Self {
enabled: true,
crash_threshold: 4,
window_secs: 300,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GatewayConfig {
pub host: String,
pub port: u16,
#[serde(default)]
pub rate_limit: RateLimitConfig,
#[serde(default)]
pub startup_guard: StartupGuardConfig,
}
impl Default for GatewayConfig {
fn default() -> Self {
Self {
host: "0.0.0.0".to_string(),
port: 8080,
rate_limit: RateLimitConfig::default(),
startup_guard: StartupGuardConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct TranscribeConfig {
#[serde(default)]
pub enabled: bool,
pub groq_api_key: Option<String>,
#[serde(default = "default_transcribe_model")]
pub model: String,
}
fn default_transcribe_model() -> String {
"whisper-large-v3-turbo".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ToolsConfig {
pub web: WebToolsConfig,
pub whatsapp: WhatsAppToolConfig,
pub google_sheets: GoogleSheetsToolConfig,
#[serde(default)]
pub google: GoogleToolConfig,
pub http_request: Option<HttpRequestConfig>,
#[serde(default)]
pub transcribe: TranscribeConfig,
#[serde(default)]
pub skills: SkillsMarketplaceConfig,
#[serde(default)]
pub coding_tools: bool,
#[serde(default)]
pub deny: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HttpRequestConfig {
#[serde(default)]
pub allowed_domains: Vec<String>,
#[serde(default = "default_http_request_timeout")]
pub timeout_secs: u64,
#[serde(default = "default_http_request_max_bytes")]
pub max_response_bytes: usize,
}
fn default_http_request_timeout() -> u64 {
30
}
fn default_http_request_max_bytes() -> usize {
512 * 1024
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct WebToolsConfig {
pub search: WebSearchConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct WebSearchConfig {
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub api_url: Option<String>,
pub max_results: u32,
}
impl Default for WebSearchConfig {
fn default() -> Self {
Self {
provider: None,
api_key: None,
api_url: None,
max_results: 5,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct WhatsAppToolConfig {
#[serde(default)]
pub business_account_id: Option<String>,
#[serde(default)]
pub phone_number_id: Option<String>,
#[serde(default)]
pub access_token: Option<String>,
#[serde(default)]
pub webhook_verify_token: Option<String>,
pub default_language: String,
}
impl Default for WhatsAppToolConfig {
fn default() -> Self {
Self {
business_account_id: None,
phone_number_id: None,
access_token: None,
webhook_verify_token: None,
default_language: "ms".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct GoogleSheetsToolConfig {
#[serde(default)]
pub access_token: Option<String>,
#[serde(default)]
pub service_account_base64: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GoogleToolConfig {
#[serde(default)]
pub access_token: Option<String>,
#[serde(default)]
pub client_id: Option<String>,
#[serde(default)]
pub client_secret: Option<String>,
pub default_calendar: String,
pub max_search_results: u32,
}
impl Default for GoogleToolConfig {
fn default() -> Self {
Self {
access_token: None,
client_id: None,
client_secret: None,
default_calendar: "primary".to_string(),
max_search_results: 20,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum MemoryBackend {
#[serde(rename = "none")]
Disabled,
#[default]
Builtin,
Bm25,
Embedding,
Hnsw,
Tantivy,
Qmd,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum MemoryCitationsMode {
#[default]
Auto,
On,
Off,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct MemoryConfig {
pub backend: MemoryBackend,
pub citations: MemoryCitationsMode,
pub include_default_memory: bool,
pub max_results: u32,
pub min_score: f32,
pub max_snippet_chars: u32,
#[serde(default)]
pub extra_paths: Vec<String>,
#[serde(default)]
pub embedding_provider: Option<String>,
#[serde(default)]
pub embedding_model: Option<String>,
#[serde(default)]
pub hnsw_index_path: Option<String>,
#[serde(default)]
pub tantivy_index_path: Option<String>,
#[serde(default)]
pub hygiene: crate::memory::hygiene::HygieneConfig,
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
backend: MemoryBackend::Builtin,
citations: MemoryCitationsMode::Auto,
include_default_memory: true,
max_results: 6,
min_score: 0.2,
max_snippet_chars: 700,
extra_paths: Vec::new(),
embedding_provider: None,
embedding_model: None,
hnsw_index_path: None,
tantivy_index_path: None,
hygiene: crate::memory::hygiene::HygieneConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HeartbeatConfig {
pub enabled: bool,
pub interval_secs: u64,
#[serde(default)]
pub file_path: Option<String>,
#[serde(default)]
pub deliver_to: Option<String>,
}
impl Default for HeartbeatConfig {
fn default() -> Self {
Self {
enabled: false,
interval_secs: 30 * 60,
file_path: None,
deliver_to: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct SkillsMarketplaceConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub clawhub: ClawHubConfig,
#[serde(default)]
pub search_cache: SearchCacheConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ClawHubConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_clawhub_url")]
pub base_url: String,
#[serde(default)]
pub auth_token: Option<String>,
#[serde(default)]
pub allowed_hosts: Vec<String>,
}
fn default_clawhub_url() -> String {
"https://clawhub.ai".to_string()
}
impl Default for ClawHubConfig {
fn default() -> Self {
Self {
enabled: true,
base_url: default_clawhub_url(),
auth_token: None,
allowed_hosts: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SearchCacheConfig {
#[serde(default = "default_cache_size")]
pub max_size: usize,
#[serde(default = "default_cache_ttl")]
pub ttl_seconds: u64,
}
fn default_cache_size() -> usize {
50
}
fn default_cache_ttl() -> u64 {
300
}
impl Default for SearchCacheConfig {
fn default() -> Self {
Self {
max_size: default_cache_size(),
ttl_seconds: default_cache_ttl(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SkillsConfig {
pub enabled: bool,
#[serde(default)]
pub workspace_dir: Option<String>,
#[serde(default)]
pub always_load: Vec<String>,
#[serde(default)]
pub disabled: Vec<String>,
}
impl Default for SkillsConfig {
fn default() -> Self {
Self {
enabled: true,
workspace_dir: None,
always_load: Vec::new(),
disabled: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SwarmConfig {
pub enabled: bool,
pub max_depth: u32,
pub max_concurrent: u32,
pub roles: std::collections::HashMap<String, SwarmRole>,
}
impl Default for SwarmConfig {
fn default() -> Self {
Self {
enabled: true,
max_depth: 1,
max_concurrent: 3,
roles: std::collections::HashMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct SwarmRole {
pub system_prompt: String,
pub tools: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum RuntimeType {
#[default]
Native,
Docker,
#[serde(rename = "apple")]
AppleContainer,
#[cfg(target_os = "linux")]
Landlock,
#[cfg(target_os = "linux")]
Firejail,
#[cfg(target_os = "linux")]
Bubblewrap,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RuntimeConfig {
pub runtime_type: RuntimeType,
pub allow_fallback_to_native: bool,
#[serde(default = "default_mount_allowlist_path")]
pub mount_allowlist_path: String,
pub docker: DockerConfig,
pub apple: AppleContainerConfig,
pub landlock: LandlockConfig,
pub firejail: FirejailConfig,
pub bubblewrap: BubblewrapConfig,
}
fn default_mount_allowlist_path() -> String {
"~/.zeptoclaw/mount-allowlist.json".to_string()
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self {
runtime_type: RuntimeType::Native,
allow_fallback_to_native: false,
mount_allowlist_path: default_mount_allowlist_path(),
docker: DockerConfig::default(),
apple: AppleContainerConfig::default(),
landlock: LandlockConfig::default(),
firejail: FirejailConfig::default(),
bubblewrap: BubblewrapConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DockerConfig {
pub image: String,
pub extra_mounts: Vec<String>,
pub memory_limit: Option<String>,
pub cpu_limit: Option<String>,
pub network: String,
#[serde(default)]
pub pids_limit: Option<u32>,
#[serde(default = "default_stop_timeout")]
pub stop_timeout_secs: u64,
}
fn default_stop_timeout() -> u64 {
300 }
impl Default for DockerConfig {
fn default() -> Self {
Self {
image: "alpine:latest".to_string(),
extra_mounts: Vec::new(),
memory_limit: Some("512m".to_string()),
cpu_limit: Some("1.0".to_string()),
network: "none".to_string(),
pids_limit: Some(100),
stop_timeout_secs: default_stop_timeout(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct AppleContainerConfig {
pub image: String,
pub extra_mounts: Vec<String>,
pub allow_experimental: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LandlockConfig {
pub fs_read_dirs: Vec<String>,
pub fs_write_dirs: Vec<String>,
pub allow_read_workspace: bool,
pub allow_write_workspace: bool,
}
impl Default for LandlockConfig {
fn default() -> Self {
Self {
fs_read_dirs: vec![
"/usr".to_string(),
"/lib".to_string(),
"/lib64".to_string(),
"/etc".to_string(),
"/bin".to_string(),
"/sbin".to_string(),
"/tmp".to_string(),
],
fs_write_dirs: vec!["/tmp".to_string()],
allow_read_workspace: true,
allow_write_workspace: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct FirejailConfig {
pub profile: Option<String>,
pub extra_args: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct BubblewrapConfig {
pub ro_binds: Vec<String>,
pub dev_bind: bool,
pub proc_bind: bool,
pub extra_args: Vec<String>,
}
impl Default for BubblewrapConfig {
fn default() -> Self {
Self {
ro_binds: vec![
"/usr".to_string(),
"/lib".to_string(),
"/lib64".to_string(),
"/etc".to_string(),
"/bin".to_string(),
"/sbin".to_string(),
],
dev_bind: true,
proc_bind: true,
extra_args: vec![],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ContainerAgentBackend {
#[default]
Auto,
Docker,
#[cfg(target_os = "macos")]
#[serde(rename = "apple")]
Apple,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ContainerAgentConfig {
pub backend: ContainerAgentBackend,
pub image: String,
pub docker_binary: Option<String>,
pub memory_limit: Option<String>,
pub cpu_limit: Option<String>,
pub timeout_secs: u64,
pub network: String,
pub extra_mounts: Vec<String>,
pub max_concurrent: usize,
}
impl Default for ContainerAgentConfig {
fn default() -> Self {
Self {
backend: ContainerAgentBackend::Auto,
image: "zeptoclaw:latest".to_string(),
docker_binary: None,
memory_limit: Some("1g".to_string()),
cpu_limit: Some("2.0".to_string()),
timeout_secs: 300,
network: "none".to_string(),
extra_mounts: Vec::new(),
max_concurrent: 5,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomToolDef {
pub name: String,
pub description: String,
pub command: String,
#[serde(default)]
pub parameters: Option<HashMap<String, String>>,
#[serde(default)]
pub working_dir: Option<String>,
#[serde(default)]
pub timeout_secs: Option<u64>,
#[serde(default)]
pub env: Option<HashMap<String, String>>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_swarm_config_defaults() {
let config = SwarmConfig::default();
assert!(config.enabled);
assert_eq!(config.max_depth, 1);
assert_eq!(config.max_concurrent, 3);
assert!(config.roles.is_empty());
}
#[test]
fn test_swarm_config_deserialize() {
let json = r#"{
"enabled": true,
"roles": {
"researcher": {
"system_prompt": "You are a researcher.",
"tools": ["web_search", "web_fetch"]
}
}
}"#;
let config: SwarmConfig = serde_json::from_str(json).unwrap();
assert!(config.enabled);
assert_eq!(config.roles.len(), 1);
let role = config.roles.get("researcher").unwrap();
assert_eq!(role.tools, vec!["web_search", "web_fetch"]);
}
#[test]
fn test_swarm_role_defaults() {
let role = SwarmRole::default();
assert!(role.system_prompt.is_empty());
assert!(role.tools.is_empty());
}
#[test]
fn test_streaming_defaults_to_false() {
let defaults = AgentDefaults::default();
assert!(!defaults.streaming);
}
#[test]
fn test_streaming_config_deserialize() {
let json = r#"{"streaming": true}"#;
let defaults: AgentDefaults = serde_json::from_str(json).unwrap();
assert!(defaults.streaming);
}
#[test]
fn test_config_with_swarm_deserialize() {
let json = r#"{
"swarm": {
"enabled": false,
"max_depth": 2
}
}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert!(!config.swarm.enabled);
assert_eq!(config.swarm.max_depth, 2);
}
#[test]
fn test_heartbeat_config_default_deliver_to() {
let config = HeartbeatConfig::default();
assert!(config.deliver_to.is_none());
}
#[test]
fn test_heartbeat_config_deserialize_deliver_to() {
let json = r#"{"enabled": true, "interval_secs": 600, "deliver_to": "telegram"}"#;
let config: HeartbeatConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.deliver_to, Some("telegram".to_string()));
}
#[test]
fn test_heartbeat_config_deserialize_no_deliver_to() {
let json = r#"{"enabled": true, "interval_secs": 600}"#;
let config: HeartbeatConfig = serde_json::from_str(json).unwrap();
assert!(config.deliver_to.is_none());
}
#[test]
fn test_custom_tool_def_deserialize() {
let json = r#"{
"name": "cpu_temp",
"description": "Read CPU temp",
"command": "cat /sys/class/thermal/thermal_zone0/temp",
"parameters": {"zone": "string"},
"working_dir": "/tmp",
"timeout_secs": 10,
"env": {"LANG": "C"}
}"#;
let def: CustomToolDef = serde_json::from_str(json).unwrap();
assert_eq!(def.name, "cpu_temp");
assert_eq!(def.description, "Read CPU temp");
assert_eq!(def.command, "cat /sys/class/thermal/thermal_zone0/temp");
assert_eq!(
def.parameters.as_ref().unwrap().get("zone").unwrap(),
"string"
);
assert_eq!(def.working_dir.as_ref().unwrap(), "/tmp");
assert_eq!(def.timeout_secs.unwrap(), 10);
assert_eq!(def.env.as_ref().unwrap().get("LANG").unwrap(), "C");
}
#[test]
fn test_custom_tool_def_minimal() {
let json = r#"{"name": "test", "description": "Test tool", "command": "echo hi"}"#;
let def: CustomToolDef = serde_json::from_str(json).unwrap();
assert_eq!(def.name, "test");
assert!(def.parameters.is_none());
assert!(def.working_dir.is_none());
assert!(def.timeout_secs.is_none());
assert!(def.env.is_none());
}
#[test]
fn test_custom_tool_def_with_parameters() {
let json = r#"{
"name": "search_logs",
"description": "Search logs",
"command": "grep {{pattern}} /var/log/app.log",
"parameters": {"pattern": "string"}
}"#;
let def: CustomToolDef = serde_json::from_str(json).unwrap();
let params = def.parameters.unwrap();
assert_eq!(params.len(), 1);
assert_eq!(params.get("pattern").unwrap(), "string");
}
#[test]
fn test_custom_tools_default_empty() {
let config = Config::default();
assert!(config.custom_tools.is_empty());
}
#[test]
fn test_tool_profiles_deserialize() {
let json = r#"{
"tool_profiles": {
"minimal": ["shell", "longterm_memory"],
"full": null
}
}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.tool_profiles.len(), 2);
let minimal = config.tool_profiles.get("minimal").unwrap();
assert_eq!(minimal.as_ref().unwrap().len(), 2);
assert!(config.tool_profiles.get("full").unwrap().is_none());
}
#[test]
fn test_tool_profiles_default_empty() {
let config = Config::default();
assert!(config.tool_profiles.is_empty());
}
#[test]
fn test_compact_tools_default_false() {
let defaults = AgentDefaults::default();
assert!(!defaults.compact_tools);
assert!(defaults.tool_profile.is_none());
}
#[test]
fn test_compact_tools_deserialize() {
let json =
r#"{"agents": {"defaults": {"compact_tools": true, "tool_profile": "minimal"}}}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert!(config.agents.defaults.compact_tools);
assert_eq!(
config.agents.defaults.tool_profile.as_ref().unwrap(),
"minimal"
);
}
#[test]
fn test_routines_config_jitter_default() {
let config = RoutinesConfig::default();
assert_eq!(config.jitter_ms, 0);
}
#[test]
fn test_routines_config_jitter_deserialize() {
let json = r#"{"enabled": true, "cron_interval_secs": 60, "max_concurrent": 3, "jitter_ms": 5000}"#;
let config: RoutinesConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.jitter_ms, 5000);
}
#[test]
fn test_tunnel_config_defaults() {
let config = TunnelConfig::default();
assert!(config.provider.is_none());
assert!(config.cloudflare.is_none());
assert!(config.ngrok.is_none());
assert!(config.tailscale.is_none());
}
#[test]
fn test_tunnel_config_deserialize() {
let json = r#"{"tunnel": {"provider": "cloudflare", "cloudflare": {"token": "abc"}}}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.tunnel.provider.as_deref(), Some("cloudflare"));
assert_eq!(
config.tunnel.cloudflare.as_ref().unwrap().token.as_deref(),
Some("abc")
);
}
#[test]
fn test_tailscale_tunnel_config_default_funnel_true() {
let config = TailscaleTunnelConfig::default();
assert!(config.funnel);
}
#[test]
fn test_ngrok_tunnel_config_deserialize() {
let json = r#"{"tunnel": {"provider": "ngrok", "ngrok": {"authtoken": "tok_123", "domain": "my.ngrok.io"}}}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.tunnel.provider.as_deref(), Some("ngrok"));
let ngrok = config.tunnel.ngrok.as_ref().unwrap();
assert_eq!(ngrok.authtoken.as_deref(), Some("tok_123"));
assert_eq!(ngrok.domain.as_deref(), Some("my.ngrok.io"));
}
#[test]
fn test_whatsapp_cloud_config_defaults() {
let config = WhatsAppCloudConfig::default();
assert!(!config.enabled);
assert!(config.phone_number_id.is_empty());
assert!(config.access_token.is_empty());
assert!(config.webhook_verify_token.is_empty());
assert_eq!(config.bind_address, "127.0.0.1");
assert_eq!(config.port, 9877);
assert_eq!(config.path, "/whatsapp");
assert!(config.allow_from.is_empty());
assert!(!config.deny_by_default);
}
#[test]
fn test_whatsapp_cloud_config_deserialize() {
let json = r#"{
"enabled": true,
"phone_number_id": "123456",
"access_token": "EAAx...",
"webhook_verify_token": "my-verify-secret",
"port": 8443,
"allow_from": ["60123456789"]
}"#;
let config: WhatsAppCloudConfig = serde_json::from_str(json).unwrap();
assert!(config.enabled);
assert_eq!(config.phone_number_id, "123456");
assert_eq!(config.access_token, "EAAx...");
assert_eq!(config.webhook_verify_token, "my-verify-secret");
assert_eq!(config.port, 8443);
assert_eq!(config.allow_from, vec!["60123456789"]);
}
#[test]
fn test_channels_config_with_whatsapp_cloud() {
let json = r#"{
"channels": {
"whatsapp_cloud": {
"enabled": true,
"phone_number_id": "999",
"access_token": "tok",
"webhook_verify_token": "verify"
}
}
}"#;
let config: Config = serde_json::from_str(json).unwrap();
let wac = config.channels.whatsapp_cloud.unwrap();
assert!(wac.enabled);
assert_eq!(wac.phone_number_id, "999");
}
#[test]
fn test_memory_backend_bm25_deserialize() {
let json = r#"{"memory": {"backend": "bm25"}}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.memory.backend, MemoryBackend::Bm25);
}
#[test]
fn test_memory_backend_embedding_deserialize() {
let json = r#"{"memory": {"backend": "embedding", "embedding_provider": "openai", "embedding_model": "text-embedding-3-small"}}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.memory.backend, MemoryBackend::Embedding);
assert_eq!(config.memory.embedding_provider.as_deref(), Some("openai"));
assert_eq!(
config.memory.embedding_model.as_deref(),
Some("text-embedding-3-small")
);
}
#[test]
fn test_memory_backend_hnsw_deserialize() {
let json = r#"{"memory": {"backend": "hnsw"}}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.memory.backend, MemoryBackend::Hnsw);
}
#[test]
fn test_memory_backend_tantivy_deserialize() {
let json = r#"{"memory": {"backend": "tantivy"}}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.memory.backend, MemoryBackend::Tantivy);
}
#[test]
fn test_transcription_config_defaults() {
let config = Config::default();
assert_eq!(config.transcription.model, "whisper-1");
assert!(config.transcription.enabled);
}
#[test]
fn test_memory_config_new_fields_default_none() {
let config = MemoryConfig::default();
assert!(config.embedding_provider.is_none());
assert!(config.embedding_model.is_none());
assert!(config.hnsw_index_path.is_none());
assert!(config.tantivy_index_path.is_none());
}
#[test]
fn test_docker_config_defaults() {
let config = DockerConfig::default();
assert_eq!(config.pids_limit, Some(100));
assert_eq!(config.stop_timeout_secs, 300);
assert_eq!(config.memory_limit, Some("512m".to_string()));
assert_eq!(config.cpu_limit, Some("1.0".to_string()));
assert_eq!(config.network, "none");
}
#[test]
fn test_docker_config_deserialize_new_fields() {
let json = r#"{"pids_limit": 50, "stop_timeout_secs": 120}"#;
let config: DockerConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.pids_limit, Some(50));
assert_eq!(config.stop_timeout_secs, 120);
}
#[test]
fn test_docker_config_deserialize_no_pids_limit() {
let json = r#"{}"#;
let config: DockerConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.pids_limit, None);
assert_eq!(config.stop_timeout_secs, 300);
}
#[test]
fn test_landlock_config_default_read_dirs() {
let cfg = LandlockConfig::default();
assert!(cfg.fs_read_dirs.iter().any(|d| d == "/usr"));
assert!(cfg.allow_read_workspace);
assert!(cfg.allow_write_workspace);
}
#[test]
fn test_firejail_config_default_no_profile() {
let cfg = FirejailConfig::default();
assert!(cfg.profile.is_none());
assert!(cfg.extra_args.is_empty());
}
#[test]
fn test_bubblewrap_config_default_ro_binds() {
let cfg = BubblewrapConfig::default();
assert!(cfg.ro_binds.iter().any(|d| d == "/usr"));
assert!(cfg.dev_bind);
assert!(cfg.proc_bind);
}
#[test]
fn test_runtime_config_has_sandbox_fields() {
let cfg = RuntimeConfig::default();
assert!(cfg.landlock.fs_read_dirs.contains(&"/usr".to_string()));
assert!(cfg.firejail.profile.is_none());
assert!(cfg.bubblewrap.dev_bind);
}
#[test]
fn test_google_tool_config_default() {
let config = GoogleToolConfig::default();
assert!(config.access_token.is_none());
assert!(config.client_id.is_none());
assert!(config.client_secret.is_none());
assert_eq!(config.default_calendar, "primary");
assert_eq!(config.max_search_results, 20);
}
#[test]
fn test_google_tool_config_deserialize() {
let json = r#"{
"access_token": "ya29.test",
"client_id": "123.apps.googleusercontent.com",
"client_secret": "secret",
"default_calendar": "work",
"max_search_results": 50
}"#;
let config: GoogleToolConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.access_token.as_deref(), Some("ya29.test"));
assert_eq!(config.default_calendar, "work");
assert_eq!(config.max_search_results, 50);
}
#[test]
fn test_provider_config_model_default_is_none() {
let config = ProviderConfig::default();
assert!(config.model.is_none());
}
#[test]
fn test_provider_config_model_deserialize() {
let json = r#"{
"api_key": "sk-test",
"model": "gpt-4o"
}"#;
let config: ProviderConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.api_key.as_deref(), Some("sk-test"));
assert_eq!(config.model.as_deref(), Some("gpt-4o"));
}
#[test]
fn test_provider_config_model_absent() {
let json = r#"{"api_key": "sk-test"}"#;
let config: ProviderConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.api_key.as_deref(), Some("sk-test"));
assert!(config.model.is_none());
}
#[test]
fn test_provider_config_quota_default_is_none() {
let config = ProviderConfig::default();
assert!(config.quota.is_none());
}
#[test]
fn test_provider_config_quota_serde() {
use crate::providers::quota::{QuotaAction, QuotaConfig, QuotaPeriod};
let original = ProviderConfig {
api_key: Some("sk-test".to_string()),
quota: Some(QuotaConfig {
max_cost_usd: Some(10.0),
max_tokens: None,
period: QuotaPeriod::Monthly,
action: QuotaAction::Reject,
}),
..Default::default()
};
let json = serde_json::to_string(&original).unwrap();
let decoded: ProviderConfig = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.api_key.as_deref(), Some("sk-test"));
let quota = decoded
.quota
.expect("quota should be present after round-trip");
assert_eq!(quota.max_cost_usd, Some(10.0));
assert!(quota.max_tokens.is_none());
assert_eq!(quota.period, QuotaPeriod::Monthly);
assert_eq!(quota.action, QuotaAction::Reject);
let no_quota_json = r#"{"api_key": "sk-test"}"#;
let no_quota: ProviderConfig = serde_json::from_str(no_quota_json).unwrap();
assert!(
no_quota.quota.is_none(),
"missing quota key should deserialize as None"
);
}
#[test]
fn test_mcp_server_config_stdio_fields() {
let json = r#"{
"name": "test",
"command": "node",
"args": ["server.js"],
"env": {"KEY": "val"},
"timeout_secs": 30
}"#;
let config: McpServerConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.name, "test");
assert_eq!(config.command, Some("node".to_string()));
assert_eq!(config.args, Some(vec!["server.js".to_string()]));
assert_eq!(
config
.env
.as_ref()
.and_then(|e| e.get("KEY"))
.map(|s| s.as_str()),
Some("val")
);
assert!(config.url.is_none());
}
#[test]
fn test_provider_config_auth_header_and_api_version_default_none() {
let c = ProviderConfig::default();
assert!(c.auth_header.is_none());
assert!(c.api_version.is_none());
}
#[test]
fn test_providers_config_has_azure_and_bedrock_fields() {
let c = ProvidersConfig::default();
assert!(c.azure.is_none());
assert!(c.bedrock.is_none());
}
#[test]
fn test_azure_provider_config_round_trips() {
let json = r#"{
"providers": {
"azure": {
"api_key": "my-azure-key",
"api_base": "https://myco.openai.azure.com/openai/deployments/gpt-4o",
"auth_header": "api-key",
"api_version": "2024-08-01-preview"
}
}
}"#;
let config: Config = serde_json::from_str(json).unwrap();
let azure = config.providers.azure.as_ref().unwrap();
assert_eq!(azure.api_key.as_deref(), Some("my-azure-key"));
assert_eq!(azure.auth_header.as_deref(), Some("api-key"));
assert_eq!(azure.api_version.as_deref(), Some("2024-08-01-preview"));
}
#[test]
fn test_web_search_config_defaults() {
let cfg = WebSearchConfig::default();
assert_eq!(cfg.provider, None);
assert_eq!(cfg.api_key, None);
assert_eq!(cfg.api_url, None);
assert_eq!(cfg.max_results, 5);
}
#[test]
fn test_web_search_config_deserialize_provider() {
let json = r#"{"provider": "searxng", "api_url": "https://search.example.com"}"#;
let cfg: WebSearchConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.provider.as_deref(), Some("searxng"));
assert_eq!(cfg.api_url.as_deref(), Some("https://search.example.com"));
}
}
fn default_email_imap_port() -> u16 {
993
}
fn default_email_smtp_port() -> u16 {
587
}
fn default_email_imap_folder() -> String {
"INBOX".into()
}
fn default_email_idle_timeout_secs() -> u64 {
1740
}
#[derive(Clone, Serialize, Deserialize)]
pub struct EmailConfig {
pub imap_host: String,
#[serde(default = "default_email_imap_port")]
pub imap_port: u16,
pub smtp_host: String,
#[serde(default = "default_email_smtp_port")]
pub smtp_port: u16,
pub username: String,
pub password: String,
#[serde(default = "default_email_imap_folder")]
pub imap_folder: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub allowed_senders: Vec<String>,
#[serde(default)]
pub deny_by_default: bool,
#[serde(default = "default_email_idle_timeout_secs")]
pub idle_timeout_secs: u64,
#[serde(default)]
pub enabled: bool,
}
impl Default for EmailConfig {
fn default() -> Self {
Self {
imap_host: String::new(),
imap_port: default_email_imap_port(),
smtp_host: String::new(),
smtp_port: default_email_smtp_port(),
username: String::new(),
password: String::new(),
imap_folder: default_email_imap_folder(),
display_name: None,
allowed_senders: Vec::new(),
deny_by_default: false,
idle_timeout_secs: default_email_idle_timeout_secs(),
enabled: false,
}
}
}
impl std::fmt::Debug for EmailConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EmailConfig")
.field("imap_host", &self.imap_host)
.field("imap_port", &self.imap_port)
.field("smtp_host", &self.smtp_host)
.field("smtp_port", &self.smtp_port)
.field("username", &self.username)
.field("password", &"[redacted]")
.field("imap_folder", &self.imap_folder)
.field("display_name", &self.display_name)
.field("allowed_senders", &self.allowed_senders)
.field("deny_by_default", &self.deny_by_default)
.field("idle_timeout_secs", &self.idle_timeout_secs)
.field("enabled", &self.enabled)
.finish()
}
}