Skip to main content

apollo/config/
mod.rs

1//! Configuration management.
2
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(default)]
8pub struct Config {
9    pub provider: ProviderConfig,
10    pub embeddings: EmbeddingsConfig,
11    pub agent: AgentConfig,
12    pub model: String,
13    pub system_prompt: String,
14    pub workspace: PathBuf,
15    pub storage: StorageConfig,
16    pub runtime: RuntimeConfig,
17    pub hosting: HostingConfig,
18    pub observability: ObservabilityConfig,
19    pub channel: ChannelConfig,
20    pub gateway: GatewayConfig,
21    pub policy: PolicyConfig,
22    pub plugin_layer: PluginLayerConfig,
23    pub group_chat: GroupChatConfig,
24    pub toolsets: ToolsetConfig,
25    pub memory: MemoryIdeasConfig,
26    #[serde(default)]
27    pub zkr: ZkrConfig,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(default)]
32pub struct ProviderConfig {
33    pub name: String,
34    pub api_key: Option<String>,
35    pub base_url: Option<String>,
36    /// Let the provider run web search on its own infrastructure instead of
37    /// apollo's `web_search` tool. Anthropic only; billed by the provider.
38    pub native_web_search: bool,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(default)]
43pub struct EmbeddingsConfig {
44    pub enabled: bool,
45    pub provider: String,
46    pub api_key: Option<String>,
47    pub model: Option<String>,
48    pub base_url: Option<String>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(default)]
53pub struct AgentConfig {
54    /// Hard circuit breaker (absolute max execution rounds)
55    pub max_rounds: usize,
56    /// Max conversation history (prevents context overflow)
57    pub max_history_messages: usize,
58    /// Max chars for a single tool result
59    pub max_tool_result_chars: usize,
60    /// Max context chars before triggering mid-loop compaction
61    pub max_context_chars: usize,
62    /// Fast/cheap model for planning + summarization
63    pub fast_model: String,
64    /// Heavy model for complex coding/reasoning
65    pub heavy_model: String,
66    /// Per-tool allow/deny rules
67    pub permissions: PermissionRulesConfig,
68    /// Initial safety profile: `full`, `auto`, `prompt`, or `tools_only` (drives default `AgentMode`).
69    pub permission_profile: String,
70    /// Agent loop implementation: `legacy` (apollo's built-in state machine)
71    /// or `rx4` (the rotary harness engine). apollo keeps ownership of context
72    /// assembly and tools either way; `rx4` hands the loop itself to rx4.
73    pub engine: String,
74}
75
76/// Which agent loop executes a turn.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
78pub enum AgentEngine {
79    /// apollo's built-in Planning → Executing → Summarizing state machine.
80    #[default]
81    Legacy,
82    /// The rx4 (rotary) harness engine, via `agent::rotary_bridge`.
83    Rx4,
84}
85
86impl AgentConfig {
87    /// Parse the configured engine. Unknown values fall back to `Legacy` with
88    /// a warning rather than failing startup.
89    pub fn engine(&self) -> AgentEngine {
90        match self.engine.trim().to_ascii_lowercase().as_str() {
91            "rx4" | "rotary" => AgentEngine::Rx4,
92            "legacy" | "" => AgentEngine::Legacy,
93            other => {
94                tracing::warn!("unknown agent.engine {other:?}, using legacy");
95                AgentEngine::Legacy
96            }
97        }
98    }
99}
100
101impl Default for AgentConfig {
102    fn default() -> Self {
103        Self {
104            max_rounds: 50,
105            max_history_messages: 10,
106            max_tool_result_chars: 20_000,
107            max_context_chars: 150_000,
108            fast_model: "claude-haiku-4-5-20251001".to_string(),
109            heavy_model: "claude-sonnet-4-6".to_string(),
110            permissions: PermissionRulesConfig::default(),
111            permission_profile: "auto".to_string(),
112            engine: "legacy".to_string(),
113        }
114    }
115}
116
117/// Per-tool permission rules.
118///
119/// `deny` blocks matching tools outright (checked first).
120/// `allow` restricts to only those tools when non-empty (allowlist mode).
121/// Supports exact tool names and glob-style `*` wildcards.
122#[derive(Debug, Clone, Default, Serialize, Deserialize)]
123#[serde(default)]
124pub struct PermissionRulesConfig {
125    /// Tools that are always blocked (e.g. `["exec", "shell"]`).
126    pub deny: Vec<String>,
127    /// If non-empty, only these tools are allowed (allowlist mode).
128    pub allow: Vec<String>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[serde(default)]
133pub struct RuntimeConfig {
134    pub kind: String, // "native", "docker"
135    pub docker_image: Option<String>,
136    pub memory_limit_mb: Option<u64>,
137    pub state_path: Option<PathBuf>,
138    pub self_update: SelfUpdateConfig,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
142#[serde(default)]
143pub struct SelfUpdateConfig {
144    pub enabled: bool,
145    pub interval_secs: u64,
146    pub remote: String,
147    pub branch: String,
148    pub restart_service: Option<String>,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
152#[serde(default)]
153pub struct StorageConfig {
154    pub backend: String, // "surreal"
155    pub root: PathBuf,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
159#[serde(default)]
160pub struct HostingConfig {
161    pub enabled: bool,
162    pub tenant_root: PathBuf,
163    pub session_timeout_minutes: u64,
164    pub default_channel: String,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168#[serde(default)]
169pub struct ObservabilityConfig {
170    pub service_name: String,
171    pub environment: String,
172    pub json_logs: bool,
173    pub trace_header_name: String,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177#[serde(default)]
178pub struct ChannelConfig {
179    pub kind: String, // "cli", "telegram", "discord", "websocket"
180    pub token: Option<String>,
181    /// When non-empty, only these Telegram (or channel) chat IDs may send inbound messages.
182    pub allowed_chat_ids: Vec<String>,
183    /// When non-empty, only these sender user IDs may send inbound messages.
184    pub allowed_sender_ids: Vec<String>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188#[serde(default)]
189pub struct GatewayConfig {
190    pub bind: String,
191    pub auth_token: Option<String>,
192    pub enable_admin_api: bool,
193    pub request_body_limit_kb: usize,
194    pub request_timeout_secs: u64,
195    pub rate_limit_per_minute: usize,
196    pub trusted_proxies: Vec<String>,
197    pub allowed_origins: Vec<String>,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
201#[serde(default)]
202pub struct PolicyConfig {
203    pub allow_shell: bool,
204    pub allow_dynamic_tools: bool,
205    pub allow_plugin_shell: bool,
206    pub allow_plugin_git: bool,
207    pub allow_computer_use: bool,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211#[serde(default)]
212pub struct PluginLayerConfig {
213    pub enabled: bool,
214    #[serde(default = "default_manifest_path")]
215    pub manifest_path: PathBuf,
216    /// Extra directories to scan for OpenClaw SKILL.md and Hermes plugin.json
217    #[serde(default)]
218    pub host_plugin_roots: Vec<PathBuf>,
219    pub hook_events: Vec<String>,
220    pub allow_core_fallback: bool,
221    pub layered_overrides: Vec<String>,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
225#[serde(default)]
226pub struct GroupChatConfig {
227    pub enable_ambient_questions: bool,
228    pub rolling_memory_namespace: String,
229    pub rolling_memory_max_chars: usize,
230    pub rolling_memory_recent_turns: usize,
231    pub ambient_question_window: usize,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, Default)]
235#[serde(default)]
236pub struct ToolsetConfig {
237    pub enabled: Vec<String>,
238    pub disabled: Vec<String>,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
242#[serde(default)]
243pub struct MemoryIdeasConfig {
244    /// One logical user across channels (merged history when set).
245    pub principal_id: Option<String>,
246    /// Inject brief + recall gate + idea graph context each turn.
247    pub inject_context: bool,
248    pub graph_recall_limit: usize,
249    /// Route heartbeat synthetic messages to this chat_id (e.g. telegram chat id).
250    pub heartbeat_chat_id: Option<String>,
251    /// After idle, expand open loops into dream nodes (graph).
252    pub dream_on_heartbeat: bool,
253}
254
255impl Default for MemoryIdeasConfig {
256    fn default() -> Self {
257        Self {
258            principal_id: None,
259            inject_context: true,
260            graph_recall_limit: 5,
261            heartbeat_chat_id: None,
262            dream_on_heartbeat: false,
263        }
264    }
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
268#[serde(default)]
269pub struct ZkrConfig {
270    pub enabled: bool,
271    pub database: PathBuf,
272    pub tenant_id: String,
273    pub person_id: String,
274    pub auto_capture: bool,
275    pub inject_recall: bool,
276    pub recall_limit: u32,
277    pub self_improve: bool,
278}
279
280impl Default for ZkrConfig {
281    fn default() -> Self {
282        Self {
283            enabled: true,
284            database: PathBuf::from(".apollo/zkr.db"),
285            tenant_id: "apollo".to_string(),
286            person_id: "local".to_string(),
287            auto_capture: true,
288            inject_recall: true,
289            recall_limit: 5,
290            self_improve: true,
291        }
292    }
293}
294
295/// Apply a named onboarding permission profile to `cfg` (policy, toolsets, and `permission_profile`).
296pub fn apply_permission_profile(cfg: &mut Config, profile: &str) {
297    let p = profile.trim().to_ascii_lowercase().replace(['-', ' '], "_");
298    match p.as_str() {
299        "full" => {
300            cfg.agent.permission_profile = "full".to_string();
301            cfg.policy.allow_shell = true;
302            cfg.policy.allow_dynamic_tools = true;
303            cfg.policy.allow_computer_use = true;
304            cfg.toolsets = ToolsetConfig::default();
305            cfg.agent.permissions = PermissionRulesConfig::default();
306        }
307        "auto" => {
308            cfg.agent.permission_profile = "auto".to_string();
309            cfg.policy = PolicyConfig::default();
310            cfg.toolsets = ToolsetConfig::default();
311            cfg.agent.permissions = PermissionRulesConfig::default();
312        }
313        "prompt" => {
314            cfg.agent.permission_profile = "prompt".to_string();
315            cfg.policy = PolicyConfig::default();
316            cfg.toolsets = ToolsetConfig::default();
317            cfg.agent.permissions = PermissionRulesConfig::default();
318        }
319        "tools_only" | "tools" => {
320            cfg.agent.permission_profile = "tools_only".to_string();
321            cfg.policy.allow_shell = false;
322            cfg.policy.allow_dynamic_tools = false;
323            cfg.policy.allow_computer_use = false;
324            cfg.toolsets.enabled = vec![
325                "web".to_string(),
326                "memory".to_string(),
327                "sessions".to_string(),
328            ];
329            cfg.toolsets.disabled = vec![
330                "browser".to_string(),
331                "vibemania".to_string(),
332                "create_tool".to_string(),
333                "mcp".to_string(),
334            ];
335            cfg.agent.permissions = PermissionRulesConfig::default();
336        }
337        _ => {
338            cfg.agent.permission_profile = "auto".to_string();
339            cfg.policy = PolicyConfig::default();
340            cfg.toolsets = ToolsetConfig::default();
341            cfg.agent.permissions = PermissionRulesConfig::default();
342        }
343    }
344}
345
346impl Config {
347    pub fn load(path: &str) -> anyhow::Result<Self> {
348        let content = std::fs::read_to_string(path)?;
349        let config: Config = serde_json::from_str(&content)?;
350        Ok(config)
351    }
352
353    pub fn default_config() -> Self {
354        Self {
355            provider: ProviderConfig::default(),
356            embeddings: EmbeddingsConfig::default(),
357            agent: AgentConfig::default(),
358            model: "claude-sonnet-4-6".to_string(),
359            system_prompt: "You are a helpful AI assistant.".to_string(),
360            workspace: PathBuf::from("."),
361            storage: StorageConfig::default(),
362            runtime: RuntimeConfig::default(),
363            hosting: HostingConfig::default(),
364            observability: ObservabilityConfig::default(),
365            channel: ChannelConfig::default(),
366            gateway: GatewayConfig::default(),
367            policy: PolicyConfig::default(),
368            plugin_layer: PluginLayerConfig::default(),
369            group_chat: GroupChatConfig::default(),
370            toolsets: ToolsetConfig::default(),
371            memory: MemoryIdeasConfig::default(),
372            zkr: ZkrConfig::default(),
373        }
374    }
375}
376
377impl Default for Config {
378    fn default() -> Self {
379        Self::default_config()
380    }
381}
382
383impl Default for ProviderConfig {
384    fn default() -> Self {
385        Self {
386            name: "anthropic".to_string(),
387            api_key: None,
388            base_url: None,
389            native_web_search: false,
390        }
391    }
392}
393
394impl Default for EmbeddingsConfig {
395    fn default() -> Self {
396        Self {
397            enabled: false,
398            provider: "noop".to_string(),
399            api_key: None,
400            model: None,
401            base_url: None,
402        }
403    }
404}
405
406impl Default for RuntimeConfig {
407    fn default() -> Self {
408        Self {
409            kind: "native".to_string(),
410            docker_image: None,
411            memory_limit_mb: None,
412            state_path: None,
413            self_update: SelfUpdateConfig::default(),
414        }
415    }
416}
417
418impl Default for SelfUpdateConfig {
419    fn default() -> Self {
420        Self {
421            enabled: false,
422            interval_secs: 900,
423            remote: "origin".to_string(),
424            branch: "main".to_string(),
425            restart_service: Some("apollo".to_string()),
426        }
427    }
428}
429
430impl Default for StorageConfig {
431    fn default() -> Self {
432        Self {
433            backend: "surreal".to_string(),
434            root: PathBuf::from(".apollo"),
435        }
436    }
437}
438
439impl Default for HostingConfig {
440    fn default() -> Self {
441        Self {
442            enabled: true,
443            tenant_root: PathBuf::from(".apollo/tenants"),
444            session_timeout_minutes: 120,
445            default_channel: "gateway".to_string(),
446        }
447    }
448}
449
450impl Default for ObservabilityConfig {
451    fn default() -> Self {
452        Self {
453            service_name: "apollo".to_string(),
454            environment: "development".to_string(),
455            json_logs: false,
456            trace_header_name: "traceparent".to_string(),
457        }
458    }
459}
460
461impl Default for ChannelConfig {
462    fn default() -> Self {
463        Self {
464            kind: "cli".to_string(),
465            token: None,
466            allowed_chat_ids: Vec::new(),
467            allowed_sender_ids: Vec::new(),
468        }
469    }
470}
471
472impl Default for GatewayConfig {
473    fn default() -> Self {
474        Self {
475            bind: "127.0.0.1:8080".to_string(),
476            auth_token: None,
477            enable_admin_api: false,
478            request_body_limit_kb: 512,
479            request_timeout_secs: 60,
480            rate_limit_per_minute: 120,
481            trusted_proxies: Vec::new(),
482            allowed_origins: Vec::new(),
483        }
484    }
485}
486
487impl Default for PolicyConfig {
488    fn default() -> Self {
489        Self {
490            allow_shell: true,
491            allow_dynamic_tools: true,
492            allow_plugin_shell: true,
493            allow_plugin_git: true,
494            allow_computer_use: true,
495        }
496    }
497}
498
499fn default_manifest_path() -> PathBuf {
500    PathBuf::from("plugins/manifest.json")
501}
502
503impl Default for PluginLayerConfig {
504    fn default() -> Self {
505        Self {
506            enabled: true,
507            manifest_path: default_manifest_path(),
508            host_plugin_roots: Vec::new(),
509            hook_events: vec![
510                "before_message".to_string(),
511                "after_message".to_string(),
512                "before_tool".to_string(),
513                "after_tool".to_string(),
514            ],
515            allow_core_fallback: true,
516            layered_overrides: vec!["system_prompt".to_string(), "toolsets".to_string()],
517        }
518    }
519}
520
521impl Default for GroupChatConfig {
522    fn default() -> Self {
523        Self {
524            enable_ambient_questions: true,
525            rolling_memory_namespace: "group_memory".to_string(),
526            rolling_memory_max_chars: 6_000,
527            rolling_memory_recent_turns: 16,
528            ambient_question_window: 24,
529        }
530    }
531}
532
533#[cfg(test)]
534mod permission_profile_tests {
535    use super::*;
536
537    #[test]
538    fn full_enables_shell_and_resets_toolsets() {
539        let mut cfg = Config::default();
540        cfg.policy.allow_shell = false;
541        cfg.toolsets.enabled = vec!["browser".into()];
542        apply_permission_profile(&mut cfg, "full");
543        assert_eq!(cfg.agent.permission_profile, "full");
544        assert!(cfg.policy.allow_shell);
545        assert!(cfg.policy.allow_dynamic_tools);
546        assert!(cfg.toolsets.enabled.is_empty());
547    }
548
549    #[test]
550    fn tools_only_disables_shell_and_limits_toolsets() {
551        let mut cfg = Config::default();
552        apply_permission_profile(&mut cfg, "tools-only");
553        assert_eq!(cfg.agent.permission_profile, "tools_only");
554        assert!(!cfg.policy.allow_shell);
555        assert!(!cfg.policy.allow_dynamic_tools);
556        assert_eq!(
557            cfg.toolsets.enabled,
558            vec![
559                "web".to_string(),
560                "memory".to_string(),
561                "sessions".to_string()
562            ]
563        );
564        assert!(cfg.toolsets.disabled.contains(&"browser".to_string()));
565    }
566
567    #[test]
568    fn engine_defaults_to_legacy() {
569        assert_eq!(AgentConfig::default().engine(), AgentEngine::Legacy);
570    }
571
572    #[test]
573    fn engine_parses_rx4_aliases() {
574        for value in ["rx4", "RX4", " rotary "] {
575            let cfg = AgentConfig {
576                engine: value.to_string(),
577                ..AgentConfig::default()
578            };
579            assert_eq!(cfg.engine(), AgentEngine::Rx4, "value: {value:?}");
580        }
581    }
582
583    #[test]
584    fn unknown_engine_falls_back_to_legacy() {
585        for value in ["nope", ""] {
586            let cfg = AgentConfig {
587                engine: value.to_string(),
588                ..AgentConfig::default()
589            };
590            assert_eq!(cfg.engine(), AgentEngine::Legacy, "value: {value:?}");
591        }
592    }
593
594    #[test]
595    fn unknown_profile_falls_back_to_auto_defaults() {
596        let mut cfg = Config::default();
597        cfg.policy.allow_shell = false;
598        apply_permission_profile(&mut cfg, "nope");
599        assert_eq!(cfg.agent.permission_profile, "auto");
600        assert!(cfg.policy.allow_shell);
601    }
602}