1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4mod attribution;
5mod backend;
6mod conversations;
7mod ops;
8mod skills;
9
10pub use attribution::*;
11pub use backend::*;
12pub use conversations::*;
13pub use ops::*;
14pub use skills::*;
15
16use ops::{
17 default_heartbeat_interval_secs, default_heartbeat_stale_after_intervals, default_rotate_at_mb,
18};
19
20pub const DEFAULT_LOCAL_LLM_MODEL: &str = "qwen3.5:4b";
21
22pub const DEFAULT_BUNDLED_MODEL_ID: &str = "Qwen3.5-2B-MLX-4bit";
27
28pub const DEFAULT_MAX_RETRIES: u32 = 1;
29pub const DEFAULT_BACKOFF_BASE_MS: u64 = 500;
30pub const DEFAULT_COOLDOWN_SECS: u64 = 60;
31pub const DEFAULT_ROUTING_THRESHOLD: u32 = 2000;
32pub const DEFAULT_SMART_MAX_ESCALATIONS: u32 = 1;
33
34pub const DEFAULT_SMART_ENABLED: bool = false;
38
39pub const DEFAULT_OLLAMA_ENDPOINT: &str = "http://localhost:11434";
44
45fn default_max_retries() -> u32 {
46 DEFAULT_MAX_RETRIES
47}
48fn default_backoff_base_ms() -> u64 {
49 DEFAULT_BACKOFF_BASE_MS
50}
51fn default_cooldown_secs() -> u64 {
52 DEFAULT_COOLDOWN_SECS
53}
54fn default_smart_max_escalations() -> u32 {
55 DEFAULT_SMART_MAX_ESCALATIONS
56}
57fn default_smart_enabled() -> bool {
58 DEFAULT_SMART_ENABLED
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
67pub struct SmartConfig {
68 #[serde(default = "default_smart_enabled")]
69 pub enabled: bool,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub cheap: Option<String>,
72 #[serde(default = "default_smart_max_escalations")]
73 pub max_escalations: u32,
74}
75
76impl Default for SmartConfig {
77 fn default() -> Self {
78 Self {
79 enabled: DEFAULT_SMART_ENABLED,
80 cheap: None,
81 max_escalations: DEFAULT_SMART_MAX_ESCALATIONS,
82 }
83 }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, Default)]
89pub struct ModelSwitchConfig {
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub default: Option<String>,
93 #[serde(default, skip_serializing_if = "Vec::is_empty")]
95 pub fallback_chain: Vec<String>,
96 #[serde(default)]
97 pub retry: RetryConfig,
98 #[serde(default)]
99 pub routing: RoutingConfig,
100 #[serde(default)]
101 pub smart: SmartConfig,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct RetryConfig {
106 #[serde(default = "default_max_retries")]
107 pub max_retries: u32,
108 #[serde(default = "default_backoff_base_ms")]
109 pub backoff_base_ms: u64,
110 #[serde(default = "default_cooldown_secs")]
111 pub cooldown_secs: u64,
112}
113
114impl Default for RetryConfig {
115 fn default() -> Self {
116 Self {
117 max_retries: DEFAULT_MAX_RETRIES,
118 backoff_base_ms: DEFAULT_BACKOFF_BASE_MS,
119 cooldown_secs: DEFAULT_COOLDOWN_SECS,
120 }
121 }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
125pub struct RoutingConfig {
126 #[serde(default)]
127 pub enabled: bool,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub cheap: Option<String>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub frontier: Option<String>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub threshold_input_tokens: Option<u32>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
141pub struct SmartOverride {
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub enabled: Option<bool>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub cheap: Option<String>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub max_escalations: Option<u32>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
153pub struct RoutingOverride {
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub enabled: Option<bool>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub cheap: Option<String>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub frontier: Option<String>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub threshold_input_tokens: Option<u32>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub smart: Option<SmartOverride>,
168}
169
170impl SmartConfig {
171 pub fn merged(&self, ov: Option<&SmartOverride>) -> SmartConfig {
173 let Some(o) = ov else { return self.clone() };
174 SmartConfig {
175 enabled: o.enabled.unwrap_or(self.enabled),
176 cheap: o.cheap.clone().or_else(|| self.cheap.clone()),
177 max_escalations: o.max_escalations.unwrap_or(self.max_escalations),
178 }
179 }
180}
181
182impl RoutingConfig {
183 pub fn merged(&self, ov: Option<&RoutingOverride>) -> RoutingConfig {
185 let Some(o) = ov else { return self.clone() };
186 RoutingConfig {
187 enabled: o.enabled.unwrap_or(self.enabled),
188 cheap: o.cheap.clone().or_else(|| self.cheap.clone()),
189 frontier: o.frontier.clone().or_else(|| self.frontier.clone()),
190 threshold_input_tokens: o.threshold_input_tokens.or(self.threshold_input_tokens),
191 }
192 }
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, Default)]
197pub struct Config {
198 #[serde(default)]
199 pub embedding: EmbeddingConfig,
200
201 #[serde(default)]
202 pub llm: LlmConfig,
203
204 #[serde(default)]
205 pub models: ModelSwitchConfig,
206
207 #[serde(default)]
208 pub retrieval: RetrievalConfig,
209
210 #[serde(default)]
211 pub paths: PathConfig,
212
213 #[serde(default)]
214 pub server: ServerConfig,
215
216 #[serde(default)]
217 pub community: CommunityConfig,
218
219 #[serde(default)]
220 pub conversations: ConversationsConfig,
221
222 #[serde(default)]
223 pub sync: SyncConfig,
224
225 #[serde(default)]
227 pub storage: StorageConfig,
228
229 #[serde(default)]
230 pub sources_global: SourcesGlobalConfig,
231
232 #[serde(default)]
234 pub sleep_cycle: SleepCycleConfig,
235
236 #[serde(default)]
238 pub skills: SkillsConfig,
239
240 #[serde(default)]
242 pub skill_llm: SkillLlmConfig,
243
244 #[serde(default)]
246 pub cross_agent: CrossAgentConfig,
247
248 #[serde(default)]
250 pub nudge: NudgeConfig,
251
252 #[serde(default)]
254 pub mobile_relay: MobileRelayConfig,
255
256 #[serde(default)]
258 pub session: SessionCfg,
259
260 #[serde(default)]
261 pub harvest: HarvestCfg,
262
263 #[serde(default)]
265 pub cc_proxy: CcProxyConfig,
266
267 #[serde(default)]
269 pub cli: CliConfig,
270
271 #[serde(default)]
273 pub parallel_jobs: ParallelJobsConfig,
274
275 #[serde(default)]
277 pub federation_snapshot: SnapshotConfig,
278
279 #[serde(default)]
281 pub memory: MemoryConfig,
282
283 #[serde(default)]
285 pub fleet_run: FleetRunConfig,
286
287 #[serde(default)]
289 pub open_items: OpenItemsConfig,
290
291 #[serde(default)]
293 pub fleet: FleetConfig,
294
295 #[serde(default)]
297 pub update: UpdateConfig,
298
299 #[serde(default)]
301 pub runs: RunsConfig,
302
303 #[serde(default)]
305 pub capture: CaptureConfig,
306
307 #[serde(default)]
311 pub limits: crate::limits::Limits,
312
313 #[serde(default)]
316 pub notifications: NotificationsConfig,
317
318 #[serde(default)]
321 pub monitor_resolver: MonitorResolverConfig,
322
323 #[serde(default)]
326 pub triage: TriageConfig,
327
328 #[serde(default)]
330 pub attribution: AttributionConfig,
331}
332
333impl Config {
334 pub fn load_or_default(path: &std::path::Path) -> Self {
339 let Ok(text) = std::fs::read_to_string(path) else {
340 return Self::default();
341 };
342 let text = crate::config_migrate::migrate_conversations_yaml(&text).unwrap_or(text);
343 let mut cfg: Self = serde_yaml_ng::from_str(&text).unwrap_or_default();
344 cfg.sanitize();
345 cfg
346 }
347
348 fn sanitize(&mut self) {
353 if self.runs.heartbeat_interval_secs == 0 {
357 self.runs.heartbeat_interval_secs = default_heartbeat_interval_secs();
358 }
359 if self.capture.rotate_at_mb == 0 {
361 self.capture.rotate_at_mb = default_rotate_at_mb();
362 }
363 if self.runs.heartbeat_stale_after_intervals == 0 {
364 self.runs.heartbeat_stale_after_intervals = default_heartbeat_stale_after_intervals();
365 }
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 #[test]
372 fn update_defaults_apply_when_block_absent_and_fields_parse() {
373 let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
374 assert_eq!(c.update, super::UpdateConfig::default());
375 assert!(c.update.codesign_identity.is_none());
376 assert!(c.update.restart_exclude.is_empty());
377
378 let c: super::Config = serde_yaml_ng::from_str(
379 "update:\n codesign_identity: \"Developer ID Application: X (TEAM)\"\n restart_exclude: [dr_worker_1]\n",
380 )
381 .unwrap();
382 assert_eq!(
383 c.update.codesign_identity.as_deref(),
384 Some("Developer ID Application: X (TEAM)")
385 );
386 assert_eq!(c.update.restart_exclude, vec!["dr_worker_1".to_string()]);
387 }
388
389 #[test]
390 fn federation_snapshot_defaults_apply_when_block_absent() {
391 let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
392 assert_eq!(c.federation_snapshot.poll_secs, 30);
393 assert_eq!(c.federation_snapshot.request_max_age_secs, 600);
394 assert_eq!(
395 c.federation_snapshot.min_lifecycle,
396 crate::skill::stats::LifecycleState::Stable
397 );
398 assert_eq!(c.memory.capture, super::CaptureMode::AutoAnnounce);
400 let c: super::Config = serde_yaml_ng::from_str("memory:\n capture: off\n").unwrap();
401 assert_eq!(c.memory.capture, super::CaptureMode::Off);
402 }
403
404 use super::*;
405
406 #[test]
407 fn default_bundled_model_id_is_qwen35_2b() {
408 assert_eq!(
409 crate::config::DEFAULT_BUNDLED_MODEL_ID,
410 "Qwen3.5-2B-MLX-4bit"
411 );
412 }
413
414 #[test]
415 fn nudge_config_defaults() {
416 let c = NudgeConfig::default();
417 assert!(c.enabled);
418 assert_eq!(c.daily_cap, 3);
419 assert_eq!(c.snooze_days, 7);
420 assert_eq!(c.threshold, 3);
421 }
422
423 #[test]
424 fn config_has_nudge_section_with_defaults() {
425 let c: Config = serde_yaml_ng::from_str("{}").unwrap();
426 assert_eq!(c.nudge.daily_cap, 3);
427 }
428
429 #[test]
430 fn storage_config_default_is_lancedb() {
431 let c = StorageConfig::default();
432 assert_eq!(c.vector_backend, "lancedb");
433 assert_eq!(c.qdrant_url, None);
434 assert_eq!(c.qdrant_api_key_ref, None);
435 }
436
437 #[test]
438 fn sources_global_config_has_sensible_defaults() {
439 let c = SourcesGlobalConfig::default();
440 assert_eq!(c.poll_interval_secs, 600);
441 assert_eq!(c.max_chunks_per_sync, 10_000);
442 assert_eq!(c.max_parallel_sources, 3);
443 assert_eq!(c.default_weight, 1.0);
444 assert_eq!(c.embedding_batch_size, 32);
445 }
446
447 #[test]
448 fn config_default_has_storage_and_sources_global() {
449 let c = Config::default();
450 assert_eq!(c.storage.vector_backend, "lancedb");
451 assert_eq!(c.sources_global.default_weight, 1.0);
452 }
453
454 #[test]
455 fn config_loads_yaml_without_new_fields() {
456 let yaml = r#"
459embedding:
460 provider: ollama
461 model: test-model
462 dimensions: 512
463 ollama_endpoint: http://localhost:11434
464"#;
465 let c: Config = serde_yaml::from_str(yaml).expect("parses");
466 assert_eq!(c.storage.vector_backend, "lancedb");
467 assert_eq!(c.sources_global.max_parallel_sources, 3);
468 }
469
470 #[test]
471 fn llm_config_to_backend_config_anthropic_passthrough() {
472 let cfg = LlmConfig {
473 provider: "anthropic".into(),
474 model: "claude-haiku-4-5".into(),
475 api_key_env: Some("ANTHROPIC_API_KEY".into()),
476 api_key_ref: None,
477 openai_url: None,
478 };
479 let b = cfg.to_backend_config();
480 assert_eq!(b.provider, "anthropic");
481 assert_eq!(b.model, "claude-haiku-4-5");
482 assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
483 assert_eq!(b.endpoint, None);
484 assert_eq!(b.timeout_secs, None);
485 }
486
487 #[test]
488 fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
489 let cfg = LlmConfig {
490 provider: "openai".into(),
491 model: "gpt-4o-mini".into(),
492 api_key_env: None,
493 api_key_ref: None,
494 openai_url: Some("https://api.together.xyz/v1".into()),
495 };
496 let b = cfg.to_backend_config();
497 assert_eq!(b.provider, "openai");
498 assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
499 assert_eq!(b.api_key_env, None); }
501
502 #[test]
503 fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
504 let cfg = LlmConfig {
505 provider: "ollama".into(),
506 model: "qwen3:14b".into(),
507 api_key_env: None,
508 api_key_ref: None,
509 openai_url: Some("http://192.168.1.10:11434".into()),
510 };
511 let b = cfg.to_backend_config();
512 assert_eq!(b.provider, "ollama");
513 assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
514 }
515
516 #[test]
517 fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
518 let cfg = LlmConfig {
522 provider: "custom-name".into(),
523 model: "some-model".into(),
524 api_key_env: Some("CUSTOM_KEY".into()),
525 api_key_ref: None,
526 openai_url: Some("https://my-proxy.local/v1".into()),
527 };
528 let b = cfg.to_backend_config();
529 assert_eq!(
530 b.provider, "openai",
531 "unknown provider + openai_url should alias to openai"
532 );
533 assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
534 }
535
536 #[test]
537 fn api_key_ref_roundtrips_and_defaults_none() {
538 let b: BackendConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
540 assert_eq!(b.api_key_ref, None);
541 let l: LlmConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
542 assert_eq!(l.api_key_ref, None);
543 let e: EmbeddingConfig = serde_yaml_ng::from_str("provider: ollama\nmodel: m\n").unwrap();
544 assert_eq!(e.api_key_ref, None);
545
546 let l2 = LlmConfig {
548 api_key_ref: Some("keychain:mur/anthropic".into()),
549 ..Default::default()
550 };
551 let y = serde_yaml_ng::to_string(&l2).unwrap();
552 let l3: LlmConfig = serde_yaml_ng::from_str(&y).unwrap();
553 assert_eq!(l3.api_key_ref.as_deref(), Some("keychain:mur/anthropic"));
554 assert_eq!(
555 l3.to_backend_config().api_key_ref.as_deref(),
556 Some("keychain:mur/anthropic")
557 );
558 }
559
560 #[test]
561 fn open_items_muted_parses_and_defaults_empty() {
562 let c: Config = serde_yaml::from_str("open_items:\n muted:\n - inbox\n").unwrap();
563 assert_eq!(c.open_items.muted, vec!["inbox".to_string()]);
564
565 let d: Config = serde_yaml::from_str("llm:\n model: x\n").unwrap();
566 assert!(d.open_items.muted.is_empty(), "must default to no mutes");
567 }
568
569 #[test]
572 fn unreadable_config_yields_no_mutes() {
573 let tmp = tempfile::tempdir().unwrap();
574 let path = tmp.path().join("config.yaml");
575 std::fs::write(&path, "this: is: not: valid: yaml: [[[\n").unwrap();
576 let cfg = Config::load_or_default(&path);
577 assert!(
578 cfg.open_items.muted.is_empty(),
579 "a broken config must hide nothing"
580 );
581
582 let missing = Config::load_or_default(&tmp.path().join("nope.yaml"));
584 assert!(missing.open_items.muted.is_empty());
585 }
586
587 #[test]
588 fn rollup_config_accepts_backend_overrides() {
589 let yaml = r#"
590enabled: true
591extractive_backend:
592 provider: openai
593 model: Qwen3.5-4B-MLX-4bit
594 endpoint: http://127.0.0.1:8000/v1
595"#;
596 let c: RollupConfig = serde_yaml_ng::from_str(yaml).expect("parses");
597 let b = c.extractive_backend.expect("override present");
598 assert_eq!(b.provider, "openai");
599 assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
600 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
601 assert!(c.abstractive_backend.is_none());
602 }
603
604 #[test]
605 fn legacy_conversation_fields_are_gone_from_serialized_output() {
606 let cfg = Config::default();
607 let yaml = serde_yaml_ng::to_string(&cfg.conversations).expect("serializes");
613 for key in ["extractive_model", "abstractive_model", "ollama_endpoint"] {
614 assert!(
615 !yaml.contains(key),
616 "legacy key {key} still serialized:\n{yaml}"
617 );
618 }
619 }
620
621 #[test]
622 fn embedding_ollama_endpoint_is_omitted_when_unset() {
623 let mut cfg = Config::default();
624 cfg.embedding.provider = "omlx".into();
625 cfg.embedding.openai_url = Some("http://127.0.0.1:8000/v1".into());
626 cfg.embedding.ollama_endpoint = None;
627 let yaml = serde_yaml_ng::to_string(&cfg).expect("serializes");
628 assert!(
629 !yaml.contains("ollama_endpoint"),
630 "dead field re-emitted:\n{yaml}"
631 );
632 }
633
634 #[test]
635 fn embedding_ollama_endpoint_still_round_trips_when_set() {
636 let yaml =
637 "provider: ollama\nmodel: nomic-embed-text\nollama_endpoint: http://box.local:11434\n";
638 let e: EmbeddingConfig = serde_yaml_ng::from_str(yaml).expect("parses");
639 assert_eq!(e.ollama_endpoint.as_deref(), Some("http://box.local:11434"));
640 }
641}
642
643#[cfg(test)]
644mod model_switch_config_tests {
645 use super::*;
646
647 #[test]
648 fn model_switch_config_defaults_and_omitted_block() {
649 let cfg: Config = serde_yaml::from_str("{}").unwrap();
651 assert_eq!(cfg.models.default, None);
652 assert!(cfg.models.fallback_chain.is_empty());
653 assert_eq!(cfg.models.retry.max_retries, DEFAULT_MAX_RETRIES);
654 assert_eq!(cfg.models.retry.backoff_base_ms, DEFAULT_BACKOFF_BASE_MS);
655 assert_eq!(cfg.models.retry.cooldown_secs, DEFAULT_COOLDOWN_SECS);
656 assert!(!cfg.models.routing.enabled);
657
658 let yaml = "models:\n default: claude_sonnet\n fallback_chain: [claude_sonnet, deepseek_v4_pro]\n routing:\n enabled: true\n cheap: deepseek_v4_flash\n frontier: claude_opus\n threshold_input_tokens: 1500\n";
660 let cfg: Config = serde_yaml::from_str(yaml).unwrap();
661 assert_eq!(cfg.models.default.as_deref(), Some("claude_sonnet"));
662 assert_eq!(
663 cfg.models.fallback_chain,
664 vec!["claude_sonnet", "deepseek_v4_pro"]
665 );
666 assert!(cfg.models.routing.enabled);
667 assert_eq!(cfg.models.routing.threshold_input_tokens, Some(1500));
668 }
669
670 #[test]
671 fn smart_config_defaults_off_with_autopick() {
672 let cfg: Config = serde_yaml::from_str("{}").unwrap();
673 assert!(
674 !cfg.models.smart.enabled,
675 "Smart background routing is opt-in (capability-gate spec §2)"
676 );
677 assert_eq!(cfg.models.smart.cheap, None); assert_eq!(
679 cfg.models.smart.max_escalations,
680 DEFAULT_SMART_MAX_ESCALATIONS
681 );
682 }
683
684 #[test]
685 fn smart_override_inherits_field_by_field() {
686 let global = SmartConfig {
687 enabled: true,
688 cheap: Some("g".into()),
689 max_escalations: 3,
690 };
691 assert_eq!(global.merged(None), global);
692 assert_eq!(global.merged(Some(&SmartOverride::default())), global);
693 let only_cheap = SmartOverride {
695 cheap: Some("a".into()),
696 ..Default::default()
697 };
698 let m = global.merged(Some(&only_cheap));
699 assert!(m.enabled, "overriding cheap must not disable Smart");
700 assert_eq!(m.cheap.as_deref(), Some("a"));
701 assert_eq!(m.max_escalations, 3);
702 let off = SmartOverride {
704 enabled: Some(false),
705 ..Default::default()
706 };
707 assert!(!global.merged(Some(&off)).enabled);
708 }
709
710 #[test]
711 fn routing_override_inherits_field_by_field() {
712 let global = RoutingConfig {
713 enabled: true,
714 cheap: Some("c".into()),
715 frontier: Some("f".into()),
716 threshold_input_tokens: Some(9),
717 };
718 let only_cheap = RoutingOverride {
719 cheap: Some("a".into()),
720 ..Default::default()
721 };
722 let m = global.merged(Some(&only_cheap));
723 assert!(m.enabled, "overriding cheap must not disable routing");
724 assert_eq!(m.cheap.as_deref(), Some("a"));
725 assert_eq!(m.frontier.as_deref(), Some("f"));
726 assert_eq!(m.threshold_input_tokens, Some(9));
727 }
728}