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 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 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 pub max_rounds: usize,
56 pub max_history_messages: usize,
58 pub max_tool_result_chars: usize,
60 pub max_context_chars: usize,
62 pub fast_model: String,
64 pub heavy_model: String,
66 pub permissions: PermissionRulesConfig,
68 pub permission_profile: String,
70 pub engine: String,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
78pub enum AgentEngine {
79 #[default]
81 Legacy,
82 Rx4,
84}
85
86impl AgentConfig {
87 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
123#[serde(default)]
124pub struct PermissionRulesConfig {
125 pub deny: Vec<String>,
127 pub allow: Vec<String>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[serde(default)]
133pub struct RuntimeConfig {
134 pub kind: String, 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, 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, pub token: Option<String>,
181 pub allowed_chat_ids: Vec<String>,
183 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 #[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 pub principal_id: Option<String>,
246 pub inject_context: bool,
248 pub graph_recall_limit: usize,
249 pub heartbeat_chat_id: Option<String>,
251 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
295pub 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}