1use 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 observability: ObservabilityConfig,
18 pub channel: ChannelConfig,
19 pub policy: PolicyConfig,
20 pub plugin_layer: PluginLayerConfig,
21 pub group_chat: GroupChatConfig,
22 pub toolsets: ToolsetConfig,
23 pub memory: MemoryIdeasConfig,
24 #[serde(default)]
25 pub zkr: ZkrConfig,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(default)]
30pub struct ProviderConfig {
31 pub name: String,
32 pub api_key: Option<String>,
33 pub base_url: Option<String>,
34 pub native_web_search: bool,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(default)]
41pub struct EmbeddingsConfig {
42 pub enabled: bool,
43 pub provider: String,
44 pub api_key: Option<String>,
45 pub model: Option<String>,
46 pub base_url: Option<String>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(default)]
51pub struct AgentConfig {
52 pub max_rounds: usize,
54 pub max_history_messages: usize,
56 pub max_tool_result_chars: usize,
58 pub max_context_chars: usize,
60 pub fast_model: String,
62 pub heavy_model: String,
64 pub permissions: PermissionRulesConfig,
66 pub permission_profile: String,
68 pub engine: String,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
76pub enum AgentEngine {
77 #[default]
79 Legacy,
80 Rx4,
82}
83
84impl AgentConfig {
85 pub fn engine(&self) -> AgentEngine {
88 match self.engine.trim().to_ascii_lowercase().as_str() {
89 "rx4" | "rotary" => AgentEngine::Rx4,
90 "legacy" | "" => AgentEngine::Legacy,
91 other => {
92 tracing::warn!("unknown agent.engine {other:?}, using legacy");
93 AgentEngine::Legacy
94 }
95 }
96 }
97}
98
99impl Default for AgentConfig {
100 fn default() -> Self {
101 Self {
102 max_rounds: 50,
103 max_history_messages: 10,
104 max_tool_result_chars: 20_000,
105 max_context_chars: 150_000,
106 fast_model: "claude-haiku-4-5-20251001".to_string(),
107 heavy_model: "claude-sonnet-4-6".to_string(),
108 permissions: PermissionRulesConfig::default(),
109 permission_profile: "auto".to_string(),
110 engine: "legacy".to_string(),
111 }
112 }
113}
114
115#[derive(Debug, Clone, Default, Serialize, Deserialize)]
121#[serde(default)]
122pub struct PermissionRulesConfig {
123 pub deny: Vec<String>,
125 pub allow: Vec<String>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130#[serde(default)]
131pub struct RuntimeConfig {
132 pub kind: String, pub docker_image: Option<String>,
134 pub memory_limit_mb: Option<u64>,
135 pub state_path: Option<PathBuf>,
136 pub self_update: SelfUpdateConfig,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(default)]
141pub struct SelfUpdateConfig {
142 pub enabled: bool,
143 pub interval_secs: u64,
144 pub remote: String,
145 pub branch: String,
146 pub restart_service: Option<String>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150#[serde(default)]
151pub struct StorageConfig {
152 pub backend: String, pub root: PathBuf,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
157#[serde(default)]
158pub struct ObservabilityConfig {
159 pub service_name: String,
160 pub environment: String,
161 pub json_logs: bool,
162 pub trace_header_name: String,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166#[serde(default)]
167pub struct ChannelConfig {
168 pub kind: String, pub token: Option<String>,
170 pub allowed_chat_ids: Vec<String>,
172 pub allowed_sender_ids: Vec<String>,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177#[serde(default)]
178pub struct PolicyConfig {
179 pub allow_shell: bool,
180 pub allow_dynamic_tools: bool,
181 pub allow_plugin_shell: bool,
182 pub allow_plugin_git: bool,
183 pub allow_computer_use: bool,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187#[serde(default)]
188pub struct PluginLayerConfig {
189 pub enabled: bool,
190 #[serde(default = "default_manifest_path")]
191 pub manifest_path: PathBuf,
192 #[serde(default)]
194 pub host_plugin_roots: Vec<PathBuf>,
195 pub hook_events: Vec<String>,
196 pub allow_core_fallback: bool,
197 pub layered_overrides: Vec<String>,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
201#[serde(default)]
202pub struct GroupChatConfig {
203 pub enable_ambient_questions: bool,
204 pub rolling_memory_namespace: String,
205 pub rolling_memory_max_chars: usize,
206 pub rolling_memory_recent_turns: usize,
207 pub ambient_question_window: usize,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, Default)]
211#[serde(default)]
212pub struct ToolsetConfig {
213 pub enabled: Vec<String>,
214 pub disabled: Vec<String>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
218#[serde(default)]
219pub struct MemoryIdeasConfig {
220 pub principal_id: Option<String>,
222 pub inject_context: bool,
224 pub graph_recall_limit: usize,
225 pub heartbeat_chat_id: Option<String>,
227 pub dream_on_heartbeat: bool,
229}
230
231impl Default for MemoryIdeasConfig {
232 fn default() -> Self {
233 Self {
234 principal_id: None,
235 inject_context: true,
236 graph_recall_limit: 5,
237 heartbeat_chat_id: None,
238 dream_on_heartbeat: false,
239 }
240 }
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
244#[serde(default)]
245pub struct ZkrConfig {
246 pub enabled: bool,
247 pub database: PathBuf,
248 pub tenant_id: String,
249 pub person_id: String,
250 pub auto_capture: bool,
251 pub inject_recall: bool,
252 pub recall_limit: u32,
253 pub self_improve: bool,
254}
255
256impl Default for ZkrConfig {
257 fn default() -> Self {
258 Self {
259 enabled: true,
260 database: PathBuf::from(".apollo/zkr.db"),
261 tenant_id: "apollo".to_string(),
262 person_id: "local".to_string(),
263 auto_capture: true,
264 inject_recall: true,
265 recall_limit: 5,
266 self_improve: true,
267 }
268 }
269}
270
271pub fn apply_permission_profile(cfg: &mut Config, profile: &str) {
273 let p = profile.trim().to_ascii_lowercase().replace(['-', ' '], "_");
274 match p.as_str() {
275 "full" => {
276 cfg.agent.permission_profile = "full".to_string();
277 cfg.policy.allow_shell = true;
278 cfg.policy.allow_dynamic_tools = true;
279 cfg.policy.allow_computer_use = true;
280 cfg.toolsets = ToolsetConfig::default();
281 cfg.agent.permissions = PermissionRulesConfig::default();
282 }
283 "auto" => {
284 cfg.agent.permission_profile = "auto".to_string();
285 cfg.policy = PolicyConfig::default();
286 cfg.toolsets = ToolsetConfig::default();
287 cfg.agent.permissions = PermissionRulesConfig::default();
288 }
289 "prompt" => {
290 cfg.agent.permission_profile = "prompt".to_string();
291 cfg.policy = PolicyConfig::default();
292 cfg.toolsets = ToolsetConfig::default();
293 cfg.agent.permissions = PermissionRulesConfig::default();
294 }
295 "tools_only" | "tools" => {
296 cfg.agent.permission_profile = "tools_only".to_string();
297 cfg.policy.allow_shell = false;
298 cfg.policy.allow_dynamic_tools = false;
299 cfg.policy.allow_computer_use = false;
300 cfg.toolsets.enabled = vec![
301 "web".to_string(),
302 "memory".to_string(),
303 "sessions".to_string(),
304 ];
305 cfg.toolsets.disabled = vec![
306 "browser".to_string(),
307 "vibemania".to_string(),
308 "create_tool".to_string(),
309 "mcp".to_string(),
310 ];
311 cfg.agent.permissions = PermissionRulesConfig::default();
312 }
313 _ => {
314 cfg.agent.permission_profile = "auto".to_string();
315 cfg.policy = PolicyConfig::default();
316 cfg.toolsets = ToolsetConfig::default();
317 cfg.agent.permissions = PermissionRulesConfig::default();
318 }
319 }
320}
321
322const SECRET_LEAF_KEYS: &[&str] = &["api_key", "token", "secret", "password"];
324
325pub fn is_secret_key(leaf: &str) -> bool {
327 let leaf = leaf.to_ascii_lowercase();
328 SECRET_LEAF_KEYS
329 .iter()
330 .any(|s| leaf == *s || leaf.ends_with(&format!("_{s}")))
331}
332
333pub fn mask_secrets(value: &mut serde_json::Value) {
338 match value {
339 serde_json::Value::Object(map) => {
340 for (key, child) in map.iter_mut() {
341 if is_secret_key(key) {
342 if let serde_json::Value::String(s) = child {
343 if !s.is_empty() {
344 *child = serde_json::Value::String("********".to_string());
345 continue;
346 }
347 }
348 }
349 mask_secrets(child);
350 }
351 }
352 serde_json::Value::Array(items) => {
353 for item in items {
354 mask_secrets(item);
355 }
356 }
357 _ => {}
358 }
359}
360
361fn lookup<'a>(root: &'a serde_json::Value, key: &str) -> anyhow::Result<&'a serde_json::Value> {
362 let mut current = root;
363 let mut walked: Vec<&str> = Vec::new();
364 for segment in key.split('.') {
365 let object = current.as_object().ok_or_else(|| {
366 anyhow::anyhow!(
367 "unknown config key `{key}`: `{}` is not a section",
368 walked.join(".")
369 )
370 })?;
371 current = object.get(segment).ok_or_else(|| {
372 let mut names: Vec<&str> = object.keys().map(|k| k.as_str()).collect();
373 names.sort_unstable();
374 anyhow::anyhow!(
375 "unknown config key `{key}`. Available under `{}`: {}",
376 if walked.is_empty() {
377 "<root>".to_string()
378 } else {
379 walked.join(".")
380 },
381 names.join(", ")
382 )
383 })?;
384 walked.push(segment);
385 }
386 Ok(current)
387}
388
389fn coerce(existing: &serde_json::Value, input: &str) -> anyhow::Result<serde_json::Value> {
390 use serde_json::Value;
391 match existing {
392 Value::String(_) => Ok(Value::String(input.to_string())),
393 Value::Bool(_) => input
394 .parse::<bool>()
395 .map(Value::Bool)
396 .map_err(|_| anyhow::anyhow!("expected `true` or `false`, got `{input}`")),
397 Value::Number(n) => {
398 if n.is_f64() {
399 input
400 .parse::<f64>()
401 .ok()
402 .and_then(serde_json::Number::from_f64)
403 .map(Value::Number)
404 .ok_or_else(|| anyhow::anyhow!("expected a number, got `{input}`"))
405 } else {
406 input
407 .parse::<i64>()
408 .map(|v| Value::Number(v.into()))
409 .map_err(|_| anyhow::anyhow!("expected an integer, got `{input}`"))
410 }
411 }
412 Value::Array(_) | Value::Object(_) => serde_json::from_str(input)
413 .map_err(|e| anyhow::anyhow!("expected JSON matching the existing value: {e}")),
414 Value::Null => Ok(serde_json::from_str(input).unwrap_or(Value::String(input.to_string()))),
415 }
416}
417
418fn assign(root: &mut serde_json::Value, key: &str, value: serde_json::Value) {
419 let segments: Vec<&str> = key.split('.').collect();
420 let mut current = root;
421 for segment in &segments[..segments.len() - 1] {
422 if !current.is_object() {
423 *current = serde_json::Value::Object(serde_json::Map::new());
424 }
425 current = current
426 .as_object_mut()
427 .expect("object")
428 .entry((*segment).to_string())
429 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
430 }
431 if !current.is_object() {
432 *current = serde_json::Value::Object(serde_json::Map::new());
433 }
434 current
435 .as_object_mut()
436 .expect("object")
437 .insert(segments[segments.len() - 1].to_string(), value);
438}
439
440impl Config {
441 pub fn get_path(&self, key: &str) -> anyhow::Result<serde_json::Value> {
444 if key.trim().is_empty() {
445 anyhow::bail!("empty config key");
446 }
447 let root = serde_json::to_value(self)?;
448 lookup(&root, key).cloned()
449 }
450
451 pub fn set_path(&self, key: &str, value: &str) -> anyhow::Result<(Config, serde_json::Value)> {
458 if key.trim().is_empty() {
459 anyhow::bail!("empty config key");
460 }
461 let mut root = serde_json::to_value(self)?;
462 let existing = lookup(&root, key)?;
463 let coerced = coerce(existing, value)
464 .map_err(|e| anyhow::anyhow!("invalid value for `{key}`: {e}"))?;
465 assign(&mut root, key, coerced.clone());
466 let updated: Config = serde_json::from_value(root)
467 .map_err(|e| anyhow::anyhow!("invalid value for `{key}`: {e}"))?;
468 Ok((updated, coerced))
469 }
470
471 pub fn splice_into_raw(raw: &mut serde_json::Value, key: &str, value: serde_json::Value) {
474 assign(raw, key, value);
475 }
476
477 pub fn load(path: &str) -> anyhow::Result<Self> {
478 let content = std::fs::read_to_string(path)?;
479 let config: Config = serde_json::from_str(&content)?;
480 Ok(config)
481 }
482
483 pub fn default_config() -> Self {
484 Self {
485 provider: ProviderConfig::default(),
486 embeddings: EmbeddingsConfig::default(),
487 agent: AgentConfig::default(),
488 model: "claude-sonnet-4-6".to_string(),
489 system_prompt: "You are a helpful AI assistant.".to_string(),
490 workspace: PathBuf::from("."),
491 storage: StorageConfig::default(),
492 runtime: RuntimeConfig::default(),
493 observability: ObservabilityConfig::default(),
494 channel: ChannelConfig::default(),
495 policy: PolicyConfig::default(),
496 plugin_layer: PluginLayerConfig::default(),
497 group_chat: GroupChatConfig::default(),
498 toolsets: ToolsetConfig::default(),
499 memory: MemoryIdeasConfig::default(),
500 zkr: ZkrConfig::default(),
501 }
502 }
503}
504
505impl Default for Config {
506 fn default() -> Self {
507 Self::default_config()
508 }
509}
510
511impl Default for ProviderConfig {
512 fn default() -> Self {
513 Self {
514 name: "anthropic".to_string(),
515 api_key: None,
516 base_url: None,
517 native_web_search: false,
518 }
519 }
520}
521
522impl Default for EmbeddingsConfig {
523 fn default() -> Self {
524 Self {
525 enabled: false,
526 provider: "noop".to_string(),
527 api_key: None,
528 model: None,
529 base_url: None,
530 }
531 }
532}
533
534impl Default for RuntimeConfig {
535 fn default() -> Self {
536 Self {
537 kind: "native".to_string(),
538 docker_image: None,
539 memory_limit_mb: None,
540 state_path: None,
541 self_update: SelfUpdateConfig::default(),
542 }
543 }
544}
545
546impl Default for SelfUpdateConfig {
547 fn default() -> Self {
548 Self {
549 enabled: false,
550 interval_secs: 900,
551 remote: "origin".to_string(),
552 branch: "main".to_string(),
553 restart_service: Some("apollo".to_string()),
554 }
555 }
556}
557
558impl Default for StorageConfig {
559 fn default() -> Self {
560 Self {
561 backend: "surreal".to_string(),
562 root: PathBuf::from(".apollo"),
563 }
564 }
565}
566
567impl Default for ObservabilityConfig {
568 fn default() -> Self {
569 Self {
570 service_name: "apollo".to_string(),
571 environment: "development".to_string(),
572 json_logs: false,
573 trace_header_name: "traceparent".to_string(),
574 }
575 }
576}
577
578impl Default for ChannelConfig {
579 fn default() -> Self {
580 Self {
581 kind: "cli".to_string(),
582 token: None,
583 allowed_chat_ids: Vec::new(),
584 allowed_sender_ids: Vec::new(),
585 }
586 }
587}
588
589impl Default for PolicyConfig {
590 fn default() -> Self {
591 Self {
592 allow_shell: true,
593 allow_dynamic_tools: true,
594 allow_plugin_shell: true,
595 allow_plugin_git: true,
596 allow_computer_use: true,
597 }
598 }
599}
600
601fn default_manifest_path() -> PathBuf {
602 PathBuf::from("plugins/manifest.json")
603}
604
605impl Default for PluginLayerConfig {
606 fn default() -> Self {
607 Self {
608 enabled: true,
609 manifest_path: default_manifest_path(),
610 host_plugin_roots: Vec::new(),
611 hook_events: vec![
612 "before_message".to_string(),
613 "after_message".to_string(),
614 "before_tool".to_string(),
615 "after_tool".to_string(),
616 ],
617 allow_core_fallback: true,
618 layered_overrides: vec!["system_prompt".to_string(), "toolsets".to_string()],
619 }
620 }
621}
622
623impl Default for GroupChatConfig {
624 fn default() -> Self {
625 Self {
626 enable_ambient_questions: true,
627 rolling_memory_namespace: "group_memory".to_string(),
628 rolling_memory_max_chars: 6_000,
629 rolling_memory_recent_turns: 16,
630 ambient_question_window: 24,
631 }
632 }
633}
634
635#[cfg(test)]
636mod config_path_tests {
637 use super::*;
638
639 #[test]
640 fn get_reads_nested_and_top_level_keys() {
641 let cfg = Config::default_config();
642 assert_eq!(cfg.get_path("model").unwrap(), cfg.model.as_str());
643 assert_eq!(cfg.get_path("agent.engine").unwrap(), "legacy");
644 assert_eq!(cfg.get_path("provider.name").unwrap(), "anthropic");
645 assert_eq!(cfg.get_path("agent.max_rounds").unwrap(), 50);
646 }
647
648 #[test]
649 fn set_updates_strings_bools_and_numbers() {
650 let cfg = Config::default_config();
651 let (cfg, _) = cfg.set_path("agent.engine", "rx4").unwrap();
652 assert_eq!(cfg.agent.engine, "rx4");
653 let (cfg, _) = cfg.set_path("policy.allow_shell", "false").unwrap();
654 assert!(!cfg.policy.allow_shell);
655 let (cfg, written) = cfg.set_path("agent.max_rounds", "12").unwrap();
656 assert_eq!(cfg.agent.max_rounds, 12);
657 assert_eq!(written, 12);
658 }
659
660 #[test]
661 fn set_fills_an_optional_field_that_was_null() {
662 let cfg = Config::default_config();
663 let (cfg, _) = cfg
664 .set_path("provider.base_url", "http://localhost:11434")
665 .unwrap();
666 assert_eq!(
667 cfg.provider.base_url.as_deref(),
668 Some("http://localhost:11434")
669 );
670 }
671
672 #[test]
673 fn unknown_keys_are_rejected_with_the_available_names() {
674 let cfg = Config::default_config();
675 let err = cfg.set_path("agent.nope", "1").unwrap_err().to_string();
676 assert!(err.contains("unknown config key `agent.nope`"), "{err}");
677 assert!(err.contains("engine"), "{err}");
678
679 let err = cfg.get_path("not_a_section.x").unwrap_err().to_string();
680 assert!(err.contains("unknown config key"), "{err}");
681 }
682
683 #[test]
684 fn wrong_typed_values_are_rejected_before_they_reach_disk() {
685 let cfg = Config::default_config();
686 let err = cfg
687 .set_path("agent.max_rounds", "abc")
688 .unwrap_err()
689 .to_string();
690 assert!(err.contains("expected an integer"), "{err}");
691
692 let err = cfg
693 .set_path("policy.allow_shell", "yes-please")
694 .unwrap_err()
695 .to_string();
696 assert!(err.contains("expected `true` or `false`"), "{err}");
697 }
698
699 #[test]
700 fn secrets_are_masked_and_other_values_are_not() {
701 let mut cfg = Config::default_config();
702 cfg.provider.api_key = Some("sk-ant-secret-value".to_string());
703 cfg.channel.token = Some("123:telegram-secret".to_string());
704 let mut value = serde_json::to_value(&cfg).unwrap();
705 mask_secrets(&mut value);
706 let rendered = serde_json::to_string(&value).unwrap();
707 assert!(!rendered.contains("sk-ant-secret-value"), "{rendered}");
708 assert!(!rendered.contains("telegram-secret"), "{rendered}");
709 assert_eq!(value["provider"]["api_key"], "********");
710 assert_eq!(value["channel"]["token"], "********");
711 assert_eq!(value["provider"]["name"], "anthropic");
712 }
713
714 #[test]
715 fn splicing_preserves_unrelated_keys_in_the_file() {
716 let mut raw: serde_json::Value =
717 serde_json::from_str(r#"{"model":"m","custom":{"kept":true}}"#).unwrap();
718 Config::splice_into_raw(&mut raw, "agent.engine", serde_json::json!("rx4"));
719 assert_eq!(raw["custom"]["kept"], true);
720 assert_eq!(raw["model"], "m");
721 assert_eq!(raw["agent"]["engine"], "rx4");
722 }
723}
724
725#[cfg(test)]
726mod permission_profile_tests {
727 use super::*;
728
729 #[test]
730 fn full_enables_shell_and_resets_toolsets() {
731 let mut cfg = Config::default();
732 cfg.policy.allow_shell = false;
733 cfg.toolsets.enabled = vec!["browser".into()];
734 apply_permission_profile(&mut cfg, "full");
735 assert_eq!(cfg.agent.permission_profile, "full");
736 assert!(cfg.policy.allow_shell);
737 assert!(cfg.policy.allow_dynamic_tools);
738 assert!(cfg.toolsets.enabled.is_empty());
739 }
740
741 #[test]
742 fn tools_only_disables_shell_and_limits_toolsets() {
743 let mut cfg = Config::default();
744 apply_permission_profile(&mut cfg, "tools-only");
745 assert_eq!(cfg.agent.permission_profile, "tools_only");
746 assert!(!cfg.policy.allow_shell);
747 assert!(!cfg.policy.allow_dynamic_tools);
748 assert_eq!(
749 cfg.toolsets.enabled,
750 vec![
751 "web".to_string(),
752 "memory".to_string(),
753 "sessions".to_string()
754 ]
755 );
756 assert!(cfg.toolsets.disabled.contains(&"browser".to_string()));
757 }
758
759 #[test]
760 fn engine_defaults_to_legacy() {
761 assert_eq!(AgentConfig::default().engine(), AgentEngine::Legacy);
762 }
763
764 #[test]
765 fn engine_parses_rx4_aliases() {
766 for value in ["rx4", "RX4", " rotary "] {
767 let cfg = AgentConfig {
768 engine: value.to_string(),
769 ..AgentConfig::default()
770 };
771 assert_eq!(cfg.engine(), AgentEngine::Rx4, "value: {value:?}");
772 }
773 }
774
775 #[test]
776 fn unknown_engine_falls_back_to_legacy() {
777 for value in ["nope", ""] {
778 let cfg = AgentConfig {
779 engine: value.to_string(),
780 ..AgentConfig::default()
781 };
782 assert_eq!(cfg.engine(), AgentEngine::Legacy, "value: {value:?}");
783 }
784 }
785
786 #[test]
787 fn unknown_profile_falls_back_to_auto_defaults() {
788 let mut cfg = Config::default();
789 cfg.policy.allow_shell = false;
790 apply_permission_profile(&mut cfg, "nope");
791 assert_eq!(cfg.agent.permission_profile, "auto");
792 assert!(cfg.policy.allow_shell);
793 }
794}