1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4pub const DEFAULT_LOCAL_LLM_MODEL: &str = "qwen3.5:4b";
5
6#[derive(Debug, Clone, Serialize, Deserialize, Default)]
8pub struct Config {
9 #[serde(default)]
10 pub embedding: EmbeddingConfig,
11
12 #[serde(default)]
13 pub llm: LlmConfig,
14
15 #[serde(default)]
16 pub retrieval: RetrievalConfig,
17
18 #[serde(default)]
19 pub paths: PathConfig,
20
21 #[serde(default)]
22 pub server: ServerConfig,
23
24 #[serde(default)]
25 pub community: CommunityConfig,
26
27 #[serde(default)]
28 pub conversations: ConversationsConfig,
29
30 #[serde(default)]
31 pub sync: SyncConfig,
32
33 #[serde(default)]
35 pub storage: StorageConfig,
36
37 #[serde(default)]
38 pub sources_global: SourcesGlobalConfig,
39
40 #[serde(default)]
42 pub sleep_cycle: SleepCycleConfig,
43
44 #[serde(default)]
46 pub skills: SkillsConfig,
47
48 #[serde(default)]
50 pub skill_llm: SkillLlmConfig,
51
52 #[serde(default)]
54 pub cross_agent: CrossAgentConfig,
55
56 #[serde(default)]
58 pub nudge: NudgeConfig,
59}
60
61impl Config {
62 pub fn load_or_default(path: &std::path::Path) -> Self {
64 std::fs::read_to_string(path)
65 .ok()
66 .and_then(|s| serde_yaml_ng::from_str(&s).ok())
67 .unwrap_or_default()
68 }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, Default)]
72pub struct SyncConfig {
73 #[serde(default = "default_sync_method")]
75 pub method: String,
76
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub git_remote: Option<String>,
80
81 #[serde(default)]
83 pub auto: bool,
84
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub team_id: Option<String>,
88}
89
90fn default_sync_method() -> String {
91 "local".to_string()
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct ServerConfig {
96 #[serde(default = "default_server_url")]
98 pub url: String,
99}
100
101impl Default for ServerConfig {
102 fn default() -> Self {
103 Self {
104 url: default_server_url(),
105 }
106 }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, Default)]
110pub struct CommunityConfig {
111 #[serde(default)]
113 pub enabled: bool,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct EmbeddingConfig {
118 #[serde(default = "default_embedding_provider")]
120 pub provider: String,
121
122 #[serde(default = "default_embedding_model")]
124 pub model: String,
125
126 #[serde(default = "default_dimensions")]
128 pub dimensions: usize,
129
130 #[serde(default = "default_ollama_endpoint")]
132 pub ollama_endpoint: String,
133
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub api_key_env: Option<String>,
137
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub openai_url: Option<String>,
141}
142
143impl Default for EmbeddingConfig {
144 fn default() -> Self {
145 Self {
146 provider: default_embedding_provider(),
147 model: default_embedding_model(),
148 dimensions: default_dimensions(),
149 ollama_endpoint: default_ollama_endpoint(),
150 api_key_env: None,
151 openai_url: None,
152 }
153 }
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct LlmConfig {
158 #[serde(default = "default_llm_provider")]
160 pub provider: String,
161
162 #[serde(default = "default_llm_model")]
163 pub model: String,
164
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub api_key_env: Option<String>,
168
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub openai_url: Option<String>,
172}
173
174impl Default for LlmConfig {
175 fn default() -> Self {
176 Self {
177 provider: default_llm_provider(),
178 model: default_llm_model(),
179 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
180 openai_url: None,
181 }
182 }
183}
184
185impl LlmConfig {
186 pub fn to_backend_config(&self) -> BackendConfig {
199 let provider = match self.provider.as_str() {
200 "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
201 _ if self.openai_url.is_some() => "openai".into(),
202 other => other.into(), };
204 BackendConfig {
205 provider,
206 model: self.model.clone(),
207 endpoint: self.openai_url.clone(),
208 api_key_env: self.api_key_env.clone(),
209 timeout_secs: None,
210 }
211 }
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
225#[serde(default)]
226pub struct BackendConfig {
227 pub provider: String,
229 pub model: String,
231 pub endpoint: Option<String>,
234 pub api_key_env: Option<String>,
236 pub timeout_secs: Option<u64>,
238}
239
240impl Default for BackendConfig {
241 fn default() -> Self {
242 Self {
243 provider: "ollama".into(),
244 model: DEFAULT_LOCAL_LLM_MODEL.into(),
245 endpoint: None,
246 api_key_env: None,
247 timeout_secs: None,
248 }
249 }
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct RetrievalConfig {
254 #[serde(default = "default_max_patterns")]
256 pub max_patterns: usize,
257
258 #[serde(default = "default_max_tokens")]
260 pub max_tokens: usize,
261
262 #[serde(default = "default_min_score")]
264 pub min_score: f64,
265
266 #[serde(default = "default_mmr_threshold")]
268 pub mmr_threshold: f64,
269}
270
271impl Default for RetrievalConfig {
272 fn default() -> Self {
273 Self {
274 max_patterns: default_max_patterns(),
275 max_tokens: default_max_tokens(),
276 min_score: default_min_score(),
277 mmr_threshold: default_mmr_threshold(),
278 }
279 }
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct PathConfig {
284 #[serde(default = "default_mur_dir")]
286 pub mur_dir: PathBuf,
287}
288
289impl Default for PathConfig {
290 fn default() -> Self {
291 Self {
292 mur_dir: default_mur_dir(),
293 }
294 }
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct StorageConfig {
299 #[serde(default = "default_vector_backend")]
301 pub vector_backend: String,
302
303 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub qdrant_url: Option<String>,
306
307 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub qdrant_api_key_ref: Option<String>,
310}
311
312impl Default for StorageConfig {
313 fn default() -> Self {
314 Self {
315 vector_backend: default_vector_backend(),
316 qdrant_url: None,
317 qdrant_api_key_ref: None,
318 }
319 }
320}
321
322fn default_vector_backend() -> String {
323 "lancedb".to_string()
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct SourcesGlobalConfig {
328 #[serde(default = "default_poll_interval_secs")]
330 pub poll_interval_secs: u64,
331
332 #[serde(default = "default_max_chunks_per_sync")]
334 pub max_chunks_per_sync: usize,
335
336 #[serde(default = "default_max_parallel_sources")]
338 pub max_parallel_sources: usize,
339
340 #[serde(default = "default_source_weight")]
342 pub default_weight: f32,
343
344 #[serde(default = "default_embedding_batch_size")]
346 pub embedding_batch_size: usize,
347}
348
349impl Default for SourcesGlobalConfig {
350 fn default() -> Self {
351 Self {
352 poll_interval_secs: default_poll_interval_secs(),
353 max_chunks_per_sync: default_max_chunks_per_sync(),
354 max_parallel_sources: default_max_parallel_sources(),
355 default_weight: default_source_weight(),
356 embedding_batch_size: default_embedding_batch_size(),
357 }
358 }
359}
360
361fn default_poll_interval_secs() -> u64 {
362 600
363}
364fn default_max_chunks_per_sync() -> usize {
365 10_000
366}
367fn default_max_parallel_sources() -> usize {
368 3
369}
370fn default_source_weight() -> f32 {
371 1.0
372}
373fn default_embedding_batch_size() -> usize {
374 32
375}
376
377fn default_embedding_provider() -> String {
378 "ollama".to_string()
379}
380fn default_embedding_model() -> String {
381 "qwen3-embedding:0.6b".to_string()
382}
383fn default_dimensions() -> usize {
384 1024
385}
386fn default_ollama_endpoint() -> String {
387 "http://localhost:11434".to_string()
388}
389fn default_llm_provider() -> String {
390 "anthropic".to_string()
391}
392fn default_llm_model() -> String {
393 "claude-opus-4-6".to_string()
394}
395fn default_max_patterns() -> usize {
396 5
397}
398fn default_max_tokens() -> usize {
399 2000
400}
401fn default_min_score() -> f64 {
402 0.35
403}
404fn default_mmr_threshold() -> f64 {
405 0.85
406}
407fn default_mur_dir() -> PathBuf {
408 let home = std::env::var("HOME")
411 .map(PathBuf::from)
412 .unwrap_or_else(|_| PathBuf::from("/tmp"));
413 home.join(".mur")
414}
415fn default_server_url() -> String {
416 "https://mur-server.fly.dev".to_string()
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
422pub struct AskConfig {
423 #[serde(default = "ask_default_model")]
424 pub model: String,
425 #[serde(default = "compact_default_ollama_endpoint")]
426 pub ollama_endpoint: String,
427 #[serde(default = "ask_default_k_summary")]
428 pub k_summary: u32,
429 #[serde(default = "ask_default_k_raw")]
430 pub k_raw: u32,
431 #[serde(default = "ask_default_esc")]
432 pub escalation_threshold: f64,
433 #[serde(default = "ask_default_mmr")]
434 pub mmr_threshold: f64,
435 #[serde(default = "ask_default_max_ctx")]
436 pub max_context_tokens: u32,
437 #[serde(default = "ask_default_resp_tok")]
438 pub response_tokens: u32,
439 #[serde(default = "ask_default_timeout")]
440 pub timeout_secs: u32,
441 #[serde(default = "ask_default_min_score")]
442 pub min_score: f64,
443 #[serde(default = "ask_default_continue_history_turns")]
444 pub continue_history_turns: u32,
445 #[serde(default = "ask_default_rewriter_timeout")]
451 pub rewriter_timeout_secs: u32,
452 #[serde(default = "ask_default_compress_hits_enabled")]
453 pub compress_hits_enabled: bool,
454 #[serde(default = "ask_default_summarize_hits_enabled")]
455 pub summarize_hits_enabled: bool,
456 #[serde(default)]
457 pub summarize_model: Option<String>,
458 #[serde(default)]
461 pub backend: Option<BackendConfig>,
462 #[serde(default)]
466 pub rewriter_backend: Option<BackendConfig>,
467}
468
469impl AskConfig {
470 pub fn synthesize_backend(&self) -> BackendConfig {
480 self.backend.clone().unwrap_or_else(|| BackendConfig {
481 provider: "ollama".into(),
482 model: self.model.clone(),
483 endpoint: Some(self.ollama_endpoint.clone()),
484 api_key_env: None,
485 timeout_secs: Some(self.timeout_secs as u64),
486 })
487 }
488
489 pub fn synthesize_rewriter_backend(&self) -> BackendConfig {
499 self.rewriter_backend
500 .clone()
501 .unwrap_or_else(|| BackendConfig {
502 provider: "ollama".into(),
503 model: self.model.clone(),
504 endpoint: Some(self.ollama_endpoint.clone()),
505 api_key_env: None,
506 timeout_secs: Some(self.rewriter_timeout_secs as u64),
507 })
508 }
509}
510
511impl Default for AskConfig {
512 fn default() -> Self {
513 Self {
514 model: ask_default_model(),
515 ollama_endpoint: compact_default_ollama_endpoint(),
516 k_summary: ask_default_k_summary(),
517 k_raw: ask_default_k_raw(),
518 escalation_threshold: ask_default_esc(),
519 mmr_threshold: ask_default_mmr(),
520 max_context_tokens: ask_default_max_ctx(),
521 response_tokens: ask_default_resp_tok(),
522 timeout_secs: ask_default_timeout(),
523 min_score: ask_default_min_score(),
524 continue_history_turns: ask_default_continue_history_turns(),
525 rewriter_timeout_secs: ask_default_rewriter_timeout(),
526 compress_hits_enabled: ask_default_compress_hits_enabled(),
527 summarize_hits_enabled: ask_default_summarize_hits_enabled(),
528 summarize_model: None,
529 backend: None,
530 rewriter_backend: None,
531 }
532 }
533}
534
535fn ask_default_model() -> String {
536 DEFAULT_LOCAL_LLM_MODEL.into()
537}
538fn ask_default_k_summary() -> u32 {
539 5
540}
541fn ask_default_k_raw() -> u32 {
542 10
543}
544fn ask_default_esc() -> f64 {
545 0.5
546}
547fn ask_default_mmr() -> f64 {
548 0.88
549}
550fn ask_default_max_ctx() -> u32 {
551 6000
552}
553fn ask_default_resp_tok() -> u32 {
554 1024
555}
556fn ask_default_timeout() -> u32 {
557 120
558}
559fn ask_default_min_score() -> f64 {
560 0.35
561}
562fn ask_default_rewriter_timeout() -> u32 {
563 8
564}
565fn ask_default_continue_history_turns() -> u32 {
566 3
567}
568fn ask_default_compress_hits_enabled() -> bool {
569 true
570}
571fn ask_default_summarize_hits_enabled() -> bool {
572 true
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize)]
584pub struct ConversationsConfig {
585 #[serde(default)]
586 pub enabled: bool,
587 #[serde(default = "conv_default_retention_days")]
588 pub retention_days: u32,
589 #[serde(default = "conv_default_poll_interval")]
590 pub poll_interval_secs: u64,
591 #[serde(default)]
592 pub sources: ConversationsSources,
593 #[serde(default)]
594 pub filter: ConversationsFilter,
595 #[serde(default)]
596 pub compact: CompactConfig,
597 #[serde(default)]
598 pub ask: AskConfig,
599 #[serde(default)]
600 pub rollup: RollupConfig,
601}
602
603impl Default for ConversationsConfig {
604 fn default() -> Self {
605 Self {
606 enabled: false,
607 retention_days: conv_default_retention_days(),
608 poll_interval_secs: conv_default_poll_interval(),
609 sources: ConversationsSources::default(),
610 filter: ConversationsFilter::default(),
611 compact: CompactConfig::default(),
612 ask: AskConfig::default(),
613 rollup: RollupConfig::default(),
614 }
615 }
616}
617
618fn conv_default_retention_days() -> u32 {
619 30
620}
621fn conv_default_poll_interval() -> u64 {
622 300
623}
624fn conv_truthy() -> bool {
625 true
626}
627fn conv_default_dedup() -> f64 {
628 0.85
629}
630
631#[derive(Debug, Clone, Serialize, Deserialize)]
632pub struct CompactConfig {
633 #[serde(default = "conv_truthy")]
634 pub enabled_in_daemon: bool,
635 #[serde(default = "compact_default_max_days")]
636 pub max_days_per_run: u32,
637 #[serde(default = "compact_default_model")]
638 pub extractive_model: String,
639 #[serde(default = "compact_default_model")]
640 pub abstractive_model: String,
641 #[serde(default = "compact_default_ollama_endpoint")]
642 pub ollama_endpoint: String,
643 #[serde(default = "compact_default_max_spans")]
644 pub max_extractive_spans: u32,
645 #[serde(default = "compact_default_max_words")]
646 pub max_abstractive_words: u32,
647 #[serde(default = "compact_default_chunk_tokens")]
648 pub chunk_tokens: u32,
649 #[serde(default = "compact_default_history_retain")]
650 pub history_retain: u32,
651 #[serde(default = "compact_default_cron")]
652 pub daemon_cron: String,
653 #[serde(default)]
656 pub extractive_backend: Option<BackendConfig>,
657 #[serde(default)]
660 pub abstractive_backend: Option<BackendConfig>,
661}
662
663impl CompactConfig {
664 pub fn synthesize_extractive_backend(&self) -> BackendConfig {
673 self.extractive_backend
674 .clone()
675 .unwrap_or_else(|| BackendConfig {
676 provider: "ollama".into(),
677 model: self.extractive_model.clone(),
678 endpoint: Some(self.ollama_endpoint.clone()),
679 api_key_env: None,
680 timeout_secs: Some(120),
681 })
682 }
683
684 pub fn synthesize_abstractive_backend(&self) -> BackendConfig {
687 self.abstractive_backend
688 .clone()
689 .unwrap_or_else(|| BackendConfig {
690 provider: "ollama".into(),
691 model: self.abstractive_model.clone(),
692 endpoint: Some(self.ollama_endpoint.clone()),
693 api_key_env: None,
694 timeout_secs: Some(120),
695 })
696 }
697}
698
699impl Default for CompactConfig {
700 fn default() -> Self {
701 Self {
702 enabled_in_daemon: true,
703 max_days_per_run: compact_default_max_days(),
704 extractive_model: compact_default_model(),
705 abstractive_model: compact_default_model(),
706 ollama_endpoint: compact_default_ollama_endpoint(),
707 max_extractive_spans: compact_default_max_spans(),
708 max_abstractive_words: compact_default_max_words(),
709 chunk_tokens: compact_default_chunk_tokens(),
710 history_retain: compact_default_history_retain(),
711 daemon_cron: compact_default_cron(),
712 extractive_backend: None,
713 abstractive_backend: None,
714 }
715 }
716}
717
718fn compact_default_max_days() -> u32 {
719 7
720}
721fn compact_default_model() -> String {
722 DEFAULT_LOCAL_LLM_MODEL.into()
723}
724fn compact_default_ollama_endpoint() -> String {
725 "http://localhost:11434".into()
726}
727fn compact_default_max_spans() -> u32 {
728 20
729}
730fn compact_default_max_words() -> u32 {
731 400
732}
733fn compact_default_chunk_tokens() -> u32 {
734 6000
735}
736fn compact_default_history_retain() -> u32 {
737 5
738}
739fn compact_default_cron() -> String {
740 "0 0 3 * * * *".into()
741}
742
743#[derive(Debug, Clone, Serialize, Deserialize)]
746pub struct RollupConfig {
747 #[serde(default = "rollup_default_enabled")]
748 pub enabled: bool,
749 #[serde(default = "rollup_default_max_weeks")]
750 pub max_weeks_per_run: u32,
751 #[serde(default = "rollup_default_max_months")]
752 pub max_months_per_run: u32,
753 #[serde(default = "rollup_default_max_spans_week")]
754 pub max_extractive_spans_per_week: u32,
755 #[serde(default = "rollup_default_max_words_week")]
756 pub max_abstractive_words_per_week: u32,
757 #[serde(default = "rollup_default_max_spans_month")]
758 pub max_extractive_spans_per_month: u32,
759 #[serde(default = "rollup_default_max_words_month")]
760 pub max_abstractive_words_per_month: u32,
761 #[serde(default = "rollup_default_week_mmr")]
762 pub week_mmr_threshold: f64,
763 #[serde(default = "rollup_default_month_mmr")]
764 pub month_mmr_threshold: f64,
765 #[serde(default = "compact_default_model")]
766 pub extractive_model: String,
767 #[serde(default = "compact_default_model")]
768 pub abstractive_model: String,
769 #[serde(default = "compact_default_ollama_endpoint")]
770 pub ollama_endpoint: String,
771}
772
773impl Default for RollupConfig {
774 fn default() -> Self {
775 Self {
776 enabled: rollup_default_enabled(),
777 max_weeks_per_run: rollup_default_max_weeks(),
778 max_months_per_run: rollup_default_max_months(),
779 max_extractive_spans_per_week: rollup_default_max_spans_week(),
780 max_abstractive_words_per_week: rollup_default_max_words_week(),
781 max_extractive_spans_per_month: rollup_default_max_spans_month(),
782 max_abstractive_words_per_month: rollup_default_max_words_month(),
783 week_mmr_threshold: rollup_default_week_mmr(),
784 month_mmr_threshold: rollup_default_month_mmr(),
785 extractive_model: compact_default_model(),
786 abstractive_model: compact_default_model(),
787 ollama_endpoint: compact_default_ollama_endpoint(),
788 }
789 }
790}
791
792fn rollup_default_enabled() -> bool {
793 true
794}
795fn rollup_default_max_weeks() -> u32 {
796 4
797}
798fn rollup_default_max_months() -> u32 {
799 2
800}
801fn rollup_default_max_spans_week() -> u32 {
802 20
803}
804fn rollup_default_max_words_week() -> u32 {
805 500
806}
807fn rollup_default_max_spans_month() -> u32 {
808 20
809}
810fn rollup_default_max_words_month() -> u32 {
811 700
812}
813fn rollup_default_week_mmr() -> f64 {
814 0.85
815}
816fn rollup_default_month_mmr() -> f64 {
817 0.82
818}
819
820#[derive(Debug, Clone, Serialize, Deserialize)]
821pub struct ConversationsSources {
822 #[serde(default = "conv_truthy")]
823 pub claude_code: bool,
824 #[serde(default = "conv_truthy")]
825 pub cursor: bool,
826 #[serde(default = "conv_truthy")]
827 pub gemini: bool,
828 #[serde(default)]
829 pub aider: AiderSourceConfig,
830}
831
832impl Default for ConversationsSources {
833 fn default() -> Self {
834 Self {
835 claude_code: true,
836 cursor: true,
837 gemini: true,
838 aider: AiderSourceConfig::default(),
839 }
840 }
841}
842
843#[derive(Debug, Clone, Serialize, Deserialize)]
844pub struct AiderSourceConfig {
845 #[serde(default = "conv_truthy")]
846 pub enabled: bool,
847 #[serde(default)]
848 pub watched_dirs: Vec<String>,
849}
850
851impl Default for AiderSourceConfig {
852 fn default() -> Self {
853 Self {
854 enabled: true,
855 watched_dirs: Vec::new(),
856 }
857 }
858}
859
860#[derive(Debug, Clone, Serialize, Deserialize)]
861pub struct ConversationsFilter {
862 #[serde(default = "conv_default_dedup")]
863 pub dedup_threshold: f64,
864 #[serde(default = "conv_truthy")]
865 pub reject_heartbeat: bool,
866 #[serde(default = "conv_truthy")]
867 pub reject_system_restatement: bool,
868}
869
870impl Default for ConversationsFilter {
871 fn default() -> Self {
872 Self {
873 dedup_threshold: conv_default_dedup(),
874 reject_heartbeat: true,
875 reject_system_restatement: true,
876 }
877 }
878}
879
880#[cfg(test)]
881mod conversations_tests {
882 use super::*;
883
884 #[test]
885 fn conversations_section_defaults() {
886 let c = ConversationsConfig::default();
887 assert!(!c.enabled);
888 assert_eq!(c.retention_days, 30);
889 assert_eq!(c.poll_interval_secs, 300);
890 assert!(c.sources.claude_code);
891 assert!(c.sources.cursor);
892 assert!(c.sources.gemini);
893 assert!(c.sources.aider.enabled);
894 assert!(c.sources.aider.watched_dirs.is_empty());
895 assert_eq!(c.filter.dedup_threshold, 0.85);
896 assert!(c.filter.reject_heartbeat);
897 assert!(c.filter.reject_system_restatement);
898 }
899
900 #[test]
901 fn parse_from_yaml_with_overrides() {
902 let y = r#"
903conversations:
904 enabled: true
905 retention_days: 45
906 poll_interval_secs: 120
907 sources:
908 cursor: false
909 aider:
910 watched_dirs: ["~/Projects/a", "~/Projects/b"]
911 filter:
912 dedup_threshold: 0.9
913"#;
914 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
915 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
916 assert!(conv.enabled);
917 assert_eq!(conv.retention_days, 45);
918 assert_eq!(conv.poll_interval_secs, 120);
919 assert!(conv.sources.claude_code); assert!(!conv.sources.cursor); assert!(conv.sources.gemini); assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
923 assert_eq!(conv.filter.dedup_threshold, 0.9);
924 assert!(conv.filter.reject_heartbeat); }
926
927 #[test]
928 fn missing_conversations_section_is_fine() {
929 let y = r#"
930# No conversations section at all
931foo: bar
932"#;
933 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
934 let conv: ConversationsConfig = v
936 .get("conversations")
937 .cloned()
938 .map(|x| serde_yaml::from_value(x).unwrap_or_default())
939 .unwrap_or_default();
940 assert_eq!(conv.retention_days, 30);
941 }
942
943 #[test]
944 fn compact_config_defaults() {
945 let c = CompactConfig::default();
946 assert!(c.enabled_in_daemon);
947 assert_eq!(c.max_days_per_run, 7);
948 assert_eq!(c.extractive_model, "qwen3.5:4b");
949 assert_eq!(c.abstractive_model, "qwen3.5:4b");
950 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
951 assert_eq!(c.max_extractive_spans, 20);
952 assert_eq!(c.chunk_tokens, 6000);
953 assert_eq!(c.history_retain, 5);
954 assert_eq!(c.daemon_cron, "0 0 3 * * * *");
955 }
956
957 #[test]
958 fn compact_parses_partial_overrides() {
959 let y = r#"
960conversations:
961 compact:
962 max_days_per_run: 3
963 extractive_model: qwen3:4b
964"#;
965 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
966 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
967 assert_eq!(conv.compact.max_days_per_run, 3);
968 assert_eq!(conv.compact.extractive_model, "qwen3:4b");
969 assert!(conv.compact.enabled_in_daemon); assert_eq!(conv.compact.abstractive_model, "qwen3.5:4b"); }
972
973 #[test]
974 fn ask_config_defaults() {
975 let c = AskConfig::default();
976 assert_eq!(c.model, "qwen3.5:4b");
977 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
978 assert_eq!(c.k_raw, 10);
979 assert_eq!(c.escalation_threshold, 0.5);
980 assert_eq!(c.mmr_threshold, 0.88);
981 assert_eq!(c.max_context_tokens, 6000);
982 assert_eq!(c.response_tokens, 1024);
983 assert_eq!(c.timeout_secs, 120);
984 assert_eq!(c.min_score, 0.35);
985 }
986
987 #[test]
988 fn ask_config_mmr_threshold_default_is_cosine_scaled() {
989 let c = AskConfig::default();
991 assert!(
992 (c.mmr_threshold - 0.88).abs() < 1e-9,
993 "expected 0.88, got {}",
994 c.mmr_threshold
995 );
996 }
997
998 #[test]
999 fn rollup_config_defaults() {
1000 let c = RollupConfig::default();
1001 assert!(c.enabled);
1002 assert_eq!(c.max_weeks_per_run, 4);
1003 assert_eq!(c.max_months_per_run, 2);
1004 assert_eq!(c.max_extractive_spans_per_week, 20);
1005 assert_eq!(c.max_abstractive_words_per_week, 500);
1006 assert_eq!(c.max_extractive_spans_per_month, 20);
1007 assert_eq!(c.max_abstractive_words_per_month, 700);
1008 assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
1009 assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
1010 assert_eq!(c.extractive_model, "qwen3.5:4b");
1011 assert_eq!(c.abstractive_model, "qwen3.5:4b");
1012 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1013 }
1014
1015 #[test]
1016 fn rollup_config_plumbed_into_conversations_config() {
1017 let c = ConversationsConfig::default();
1018 assert!(c.rollup.enabled);
1019 }
1020
1021 #[test]
1022 fn ask_config_default_continue_history_turns_is_3() {
1023 let c = AskConfig::default();
1024 assert_eq!(c.continue_history_turns, 3);
1025 }
1026
1027 #[test]
1028 fn ask_config_default_compress_hits_enabled_is_true() {
1029 let c = AskConfig::default();
1030 assert!(c.compress_hits_enabled);
1031 }
1032
1033 #[test]
1034 fn ask_config_default_summarize_hits_enabled_is_true() {
1035 let c = AskConfig::default();
1036 assert!(c.summarize_hits_enabled);
1037 }
1038
1039 #[test]
1040 fn ask_config_default_summarize_model_is_none() {
1041 let c = AskConfig::default();
1042 assert!(c.summarize_model.is_none());
1043 }
1044
1045 #[test]
1046 fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1047 let y = r#"
1048conversations:
1049 ask:
1050 summarize_hits_enabled: false
1051 summarize_model: qwen3:4b
1052"#;
1053 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1054 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1055 assert!(!conv.ask.summarize_hits_enabled);
1056 assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1057 }
1058
1059 #[test]
1060 fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1061 let y = r#"
1065conversations:
1066 ask:
1067 model: qwen3:14b
1068"#;
1069 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1070 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1071 assert!(conv.ask.summarize_hits_enabled);
1072 assert!(conv.ask.summarize_model.is_none());
1073 }
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078 use super::*;
1079
1080 #[test]
1081 fn nudge_config_defaults() {
1082 let c = NudgeConfig::default();
1083 assert!(c.enabled);
1084 assert_eq!(c.daily_cap, 3);
1085 assert_eq!(c.snooze_days, 7);
1086 assert_eq!(c.threshold, 3);
1087 }
1088
1089 #[test]
1090 fn config_has_nudge_section_with_defaults() {
1091 let c: Config = serde_yaml_ng::from_str("{}").unwrap();
1092 assert_eq!(c.nudge.daily_cap, 3);
1093 }
1094
1095 #[test]
1096 fn storage_config_default_is_lancedb() {
1097 let c = StorageConfig::default();
1098 assert_eq!(c.vector_backend, "lancedb");
1099 assert_eq!(c.qdrant_url, None);
1100 assert_eq!(c.qdrant_api_key_ref, None);
1101 }
1102
1103 #[test]
1104 fn sources_global_config_has_sensible_defaults() {
1105 let c = SourcesGlobalConfig::default();
1106 assert_eq!(c.poll_interval_secs, 600);
1107 assert_eq!(c.max_chunks_per_sync, 10_000);
1108 assert_eq!(c.max_parallel_sources, 3);
1109 assert_eq!(c.default_weight, 1.0);
1110 assert_eq!(c.embedding_batch_size, 32);
1111 }
1112
1113 #[test]
1114 fn config_default_has_storage_and_sources_global() {
1115 let c = Config::default();
1116 assert_eq!(c.storage.vector_backend, "lancedb");
1117 assert_eq!(c.sources_global.default_weight, 1.0);
1118 }
1119
1120 #[test]
1121 fn config_loads_yaml_without_new_fields() {
1122 let yaml = r#"
1125embedding:
1126 provider: ollama
1127 model: test-model
1128 dimensions: 512
1129 ollama_endpoint: http://localhost:11434
1130"#;
1131 let c: Config = serde_yaml::from_str(yaml).expect("parses");
1132 assert_eq!(c.storage.vector_backend, "lancedb");
1133 assert_eq!(c.sources_global.max_parallel_sources, 3);
1134 }
1135
1136 #[test]
1137 fn llm_config_to_backend_config_anthropic_passthrough() {
1138 let cfg = LlmConfig {
1139 provider: "anthropic".into(),
1140 model: "claude-haiku-4-5".into(),
1141 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1142 openai_url: None,
1143 };
1144 let b = cfg.to_backend_config();
1145 assert_eq!(b.provider, "anthropic");
1146 assert_eq!(b.model, "claude-haiku-4-5");
1147 assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
1148 assert_eq!(b.endpoint, None);
1149 assert_eq!(b.timeout_secs, None);
1150 }
1151
1152 #[test]
1153 fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
1154 let cfg = LlmConfig {
1155 provider: "openai".into(),
1156 model: "gpt-4o-mini".into(),
1157 api_key_env: None,
1158 openai_url: Some("https://api.together.xyz/v1".into()),
1159 };
1160 let b = cfg.to_backend_config();
1161 assert_eq!(b.provider, "openai");
1162 assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
1163 assert_eq!(b.api_key_env, None); }
1165
1166 #[test]
1167 fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
1168 let cfg = LlmConfig {
1169 provider: "ollama".into(),
1170 model: "qwen3:14b".into(),
1171 api_key_env: None,
1172 openai_url: Some("http://192.168.1.10:11434".into()),
1173 };
1174 let b = cfg.to_backend_config();
1175 assert_eq!(b.provider, "ollama");
1176 assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1177 }
1178
1179 #[test]
1180 fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
1181 let cfg = LlmConfig {
1185 provider: "custom-name".into(),
1186 model: "some-model".into(),
1187 api_key_env: Some("CUSTOM_KEY".into()),
1188 openai_url: Some("https://my-proxy.local/v1".into()),
1189 };
1190 let b = cfg.to_backend_config();
1191 assert_eq!(
1192 b.provider, "openai",
1193 "unknown provider + openai_url should alias to openai"
1194 );
1195 assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
1196 }
1197}
1198
1199#[cfg(test)]
1200mod backend_config_tests {
1201 use super::*;
1202
1203 #[test]
1204 fn default_is_ollama_qwen3() {
1205 let cfg = BackendConfig::default();
1206 assert_eq!(cfg.provider, "ollama");
1207 assert_eq!(cfg.model, "qwen3.5:4b");
1208 assert_eq!(cfg.endpoint, None);
1209 assert_eq!(cfg.api_key_env, None);
1210 assert_eq!(cfg.timeout_secs, None);
1211 }
1212
1213 #[test]
1214 fn deserializes_anthropic_full() {
1215 let yaml = "\
1216provider: anthropic
1217model: claude-haiku-4-5
1218api_key_env: ANTHROPIC_API_KEY
1219timeout_secs: 60
1220";
1221 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1222 assert_eq!(cfg.provider, "anthropic");
1223 assert_eq!(cfg.model, "claude-haiku-4-5");
1224 assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
1225 assert_eq!(cfg.timeout_secs, Some(60));
1226 assert_eq!(cfg.endpoint, None);
1227 }
1228
1229 #[test]
1230 fn deserializes_partial_fills_defaults() {
1231 let yaml = "provider: anthropic\nmodel: claude-sonnet-4-6\n";
1232 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1233 assert_eq!(cfg.provider, "anthropic");
1234 assert_eq!(cfg.model, "claude-sonnet-4-6");
1235 assert_eq!(cfg.api_key_env, None);
1236 assert_eq!(cfg.timeout_secs, None);
1237 }
1238
1239 #[test]
1240 fn round_trips_through_yaml() {
1241 let original = BackendConfig {
1242 provider: "anthropic".into(),
1243 model: "claude-haiku-4-5".into(),
1244 endpoint: Some("https://api.anthropic.com".into()),
1245 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1246 timeout_secs: Some(60),
1247 };
1248 let yaml = serde_yaml::to_string(&original).unwrap();
1249 let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
1250 assert_eq!(parsed, original);
1251 }
1252
1253 #[test]
1254 fn skills_config_curation_gate_defaults_on() {
1255 let c = SkillsConfig::default();
1256 assert!(c.require_human_curation_before_stable);
1257 }
1258}
1259
1260#[derive(Debug, Clone, Serialize, Deserialize)]
1264#[serde(default)]
1265pub struct SkillsConfig {
1266 pub max_skills_in_prompt: usize,
1267 pub max_total_tokens: usize,
1268 pub priority_order: Vec<String>,
1269 pub adaptive: Option<AdaptiveSkillsConfig>,
1270
1271 #[serde(default = "default_require_human_curation")]
1275 pub require_human_curation_before_stable: bool,
1276}
1277
1278fn default_require_human_curation() -> bool {
1279 true
1280}
1281
1282impl Default for SkillsConfig {
1283 fn default() -> Self {
1284 Self {
1285 max_skills_in_prompt: 5,
1286 max_total_tokens: 2000,
1287 priority_order: vec!["agent".into(), "global".into()],
1288 adaptive: Some(AdaptiveSkillsConfig::default()),
1289 require_human_curation_before_stable: default_require_human_curation(),
1290 }
1291 }
1292}
1293
1294#[derive(Debug, Clone, Serialize, Deserialize)]
1295#[serde(default)]
1296pub struct AdaptiveSkillsConfig {
1297 pub context_fill_decay: f64,
1298 pub min_remaining_context_ratio: f64,
1299 pub recent_fire_boost_turns: usize,
1300 pub model_max_context_tokens: u64,
1304}
1305
1306impl Default for AdaptiveSkillsConfig {
1307 fn default() -> Self {
1308 Self {
1309 context_fill_decay: 1.5,
1310 min_remaining_context_ratio: 0.20,
1311 recent_fire_boost_turns: 5,
1312 model_max_context_tokens: 200_000,
1313 }
1314 }
1315}
1316
1317#[derive(Debug, Clone, Serialize, Deserialize)]
1320pub struct SleepCycleConfig {
1321 #[serde(default)]
1323 pub enabled: bool,
1324
1325 #[serde(default = "default_idle_threshold_minutes")]
1327 pub idle_threshold_minutes: u64,
1328
1329 #[serde(default = "default_agent_idle_minutes")]
1331 pub agent_idle_minutes: u64,
1332}
1333
1334fn default_idle_threshold_minutes() -> u64 {
1335 15
1336}
1337
1338fn default_agent_idle_minutes() -> u64 {
1339 5
1340}
1341
1342impl Default for SleepCycleConfig {
1343 fn default() -> Self {
1344 Self {
1345 enabled: false,
1346 idle_threshold_minutes: default_idle_threshold_minutes(),
1347 agent_idle_minutes: default_agent_idle_minutes(),
1348 }
1349 }
1350}
1351
1352#[derive(Debug, Clone, Serialize, Deserialize)]
1355pub struct NudgeConfig {
1356 #[serde(default = "default_nudge_enabled")]
1358 pub enabled: bool,
1359 #[serde(default = "default_nudge_daily_cap")]
1360 pub daily_cap: u32,
1361 #[serde(default = "default_nudge_snooze_days")]
1362 pub snooze_days: u32,
1363 #[serde(default = "default_nudge_threshold")]
1364 pub threshold: usize,
1365}
1366
1367fn default_nudge_enabled() -> bool {
1368 true
1369}
1370fn default_nudge_daily_cap() -> u32 {
1371 3
1372}
1373fn default_nudge_snooze_days() -> u32 {
1374 7
1375}
1376fn default_nudge_threshold() -> usize {
1377 3
1378}
1379
1380impl Default for NudgeConfig {
1381 fn default() -> Self {
1382 Self {
1383 enabled: true,
1384 daily_cap: default_nudge_daily_cap(),
1385 snooze_days: default_nudge_snooze_days(),
1386 threshold: default_nudge_threshold(),
1387 }
1388 }
1389}
1390
1391#[derive(Debug, Clone, Serialize, Deserialize)]
1394#[serde(default)]
1395pub struct CrossAgentConfig {
1396 #[serde(default = "default_half_life_days")]
1397 pub fitness_half_life_days: u32,
1398 #[serde(default = "default_fitness_floor")]
1399 pub fitness_floor: f64,
1400}
1401
1402fn default_half_life_days() -> u32 {
1403 7
1404}
1405fn default_fitness_floor() -> f64 {
1406 0.1
1407}
1408
1409impl Default for CrossAgentConfig {
1410 fn default() -> Self {
1411 Self {
1412 fitness_half_life_days: default_half_life_days(),
1413 fitness_floor: default_fitness_floor(),
1414 }
1415 }
1416}
1417
1418#[derive(Debug, Clone, Serialize, Deserialize)]
1421#[serde(default)]
1422pub struct SkillLlmConfig {
1423 #[serde(default = "default_per_call_token_cap")]
1425 pub per_call_token_cap: u32,
1426
1427 #[serde(default = "default_per_day_usd_cap")]
1429 pub per_day_usd_cap: f64,
1430
1431 #[serde(default = "default_cache_ttl_days")]
1433 pub cache_ttl_days: u32,
1434
1435 #[serde(default, skip_serializing_if = "Option::is_none")]
1437 pub model_ref: Option<String>,
1438}
1439
1440fn default_per_call_token_cap() -> u32 {
1441 1500
1442}
1443fn default_per_day_usd_cap() -> f64 {
1444 0.50
1445}
1446fn default_cache_ttl_days() -> u32 {
1447 30
1448}
1449
1450impl Default for SkillLlmConfig {
1451 fn default() -> Self {
1452 Self {
1453 per_call_token_cap: default_per_call_token_cap(),
1454 per_day_usd_cap: default_per_day_usd_cap(),
1455 cache_ttl_days: default_cache_ttl_days(),
1456 model_ref: None,
1457 }
1458 }
1459}
1460#[cfg(test)]
1461mod per_stage_backend_tests {
1462 use super::*;
1463
1464 #[test]
1465 fn legacy_compact_config_has_no_per_stage_overrides() {
1466 let yaml = "\
1467extractive_model: qwen3:14b
1468abstractive_model: qwen3:14b
1469ollama_endpoint: http://localhost:11434
1470";
1471 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1472 assert!(cfg.extractive_backend.is_none());
1473 assert!(cfg.abstractive_backend.is_none());
1474 assert_eq!(cfg.extractive_model, "qwen3:14b");
1475 assert_eq!(cfg.abstractive_model, "qwen3:14b");
1476 assert_eq!(cfg.ollama_endpoint, "http://localhost:11434");
1477 }
1478
1479 #[test]
1480 fn legacy_ask_config_has_no_per_stage_overrides() {
1481 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1482 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1483 assert!(cfg.backend.is_none());
1484 assert!(cfg.rewriter_backend.is_none());
1485 assert_eq!(cfg.model, "qwen3:14b");
1486 }
1487
1488 #[test]
1489 fn compact_extractive_backend_override_parses() {
1490 let yaml = "\
1491extractive_backend:
1492 provider: anthropic
1493 model: claude-haiku-4-5
1494 api_key_env: ANTHROPIC_API_KEY
1495abstractive_model: qwen3:14b
1496";
1497 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1498 let extractive = cfg
1499 .extractive_backend
1500 .as_ref()
1501 .expect("override should parse");
1502 assert_eq!(extractive.provider, "anthropic");
1503 assert_eq!(extractive.model, "claude-haiku-4-5");
1504 assert!(cfg.abstractive_backend.is_none());
1505 }
1506
1507 #[test]
1508 fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
1509 let yaml = "\
1510backend:
1511 provider: anthropic
1512 model: claude-sonnet-4-6
1513 api_key_env: ANTHROPIC_API_KEY
1514rewriter_backend:
1515 provider: ollama
1516 model: llama3.2:3b
1517";
1518 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1519 assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
1520 assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
1521 }
1522
1523 #[test]
1524 fn synthesize_legacy_to_backend_config_for_compact_extractive() {
1525 let yaml = "\
1526extractive_model: qwen3:14b
1527ollama_endpoint: http://192.168.1.10:11434
1528";
1529 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1530 let synth = cfg.synthesize_extractive_backend();
1531 assert_eq!(synth.provider, "ollama");
1532 assert_eq!(synth.model, "qwen3:14b");
1533 assert_eq!(synth.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1534 assert_eq!(synth.api_key_env, None);
1535 }
1536
1537 #[test]
1538 fn synthesize_legacy_to_backend_config_for_ask() {
1539 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1540 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1541 let synth = cfg.synthesize_backend();
1542 assert_eq!(synth.provider, "ollama");
1543 assert_eq!(synth.model, "qwen3:14b");
1544 assert_eq!(synth.endpoint.as_deref(), Some("http://localhost:11434"));
1545 }
1546
1547 #[test]
1548 fn synthesize_rewriter_uses_legacy_ollama_when_no_rewriter_override() {
1549 let yaml = "\
1558backend:
1559 provider: anthropic
1560 model: claude-sonnet-4-6
1561 api_key_env: ANTHROPIC_API_KEY
1562";
1563 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1564 let rewriter = cfg.synthesize_rewriter_backend();
1565 assert_eq!(rewriter.provider, "ollama");
1566 assert_eq!(rewriter.model, ask_default_model());
1567 assert_eq!(
1568 rewriter.timeout_secs,
1569 Some(ask_default_rewriter_timeout() as u64)
1570 );
1571 }
1572
1573 #[test]
1574 fn ask_synthesize_backend_inherits_timeout_secs_from_legacy_field() {
1575 let cfg = AskConfig {
1576 timeout_secs: 45,
1577 ..AskConfig::default()
1578 };
1579 let b = cfg.synthesize_backend();
1580 assert_eq!(
1581 b.timeout_secs,
1582 Some(45),
1583 "synthesize_backend() must propagate ask.timeout_secs into the synthesized BackendConfig"
1584 );
1585 }
1586
1587 #[test]
1588 fn ask_synthesize_backend_does_not_override_explicit_per_stage_timeout() {
1589 let mut cfg = AskConfig {
1590 timeout_secs: 45,
1591 ..AskConfig::default()
1592 };
1593 cfg.backend = Some(BackendConfig {
1594 provider: "anthropic".into(),
1595 model: "claude-haiku-4-5".into(),
1596 endpoint: None,
1597 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1598 timeout_secs: Some(10),
1599 });
1600 let b = cfg.synthesize_backend();
1601 assert_eq!(
1602 b.timeout_secs,
1603 Some(10),
1604 "explicit per-stage timeout_secs must NOT be overridden by ask.timeout_secs"
1605 );
1606 }
1607
1608 #[test]
1609 fn ask_synthesize_rewriter_backend_uses_rewriter_timeout_secs_when_synthesizing() {
1610 let cfg = AskConfig {
1611 timeout_secs: 120,
1612 rewriter_timeout_secs: 8,
1613 ..AskConfig::default()
1614 };
1615 let b = cfg.synthesize_rewriter_backend();
1616 assert_eq!(
1617 b.timeout_secs,
1618 Some(8),
1619 "rewriter synthesis must use rewriter_timeout_secs (not the answer-call timeout)"
1620 );
1621 }
1622
1623 #[test]
1624 fn ask_synthesize_rewriter_backend_does_not_override_explicit_per_stage_timeout() {
1625 let mut cfg = AskConfig {
1626 rewriter_timeout_secs: 8,
1627 ..AskConfig::default()
1628 };
1629 cfg.rewriter_backend = Some(BackendConfig {
1630 provider: "anthropic".into(),
1631 model: "claude-haiku-4-5".into(),
1632 endpoint: None,
1633 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1634 timeout_secs: Some(30),
1635 });
1636 let b = cfg.synthesize_rewriter_backend();
1637 assert_eq!(
1638 b.timeout_secs,
1639 Some(30),
1640 "explicit per-stage rewriter timeout_secs must NOT be overridden by ask.rewriter_timeout_secs"
1641 );
1642 }
1643
1644 #[test]
1645 fn compact_synthesize_extractive_backend_inherits_default_timeout_when_no_override() {
1646 let cfg = CompactConfig::default();
1649 let b = cfg.synthesize_extractive_backend();
1650 assert_eq!(
1651 b.timeout_secs,
1652 Some(120),
1653 "compact synthesis without per-stage override must produce 120s timeout"
1654 );
1655 }
1656
1657 #[test]
1658 fn compact_synthesize_abstractive_backend_inherits_default_timeout_when_no_override() {
1659 let cfg = CompactConfig::default();
1660 let b = cfg.synthesize_abstractive_backend();
1661 assert_eq!(b.timeout_secs, Some(120));
1662 }
1663}
1664
1665#[cfg(test)]
1666mod skills_config_tests {
1667 use super::*;
1668
1669 #[test]
1670 fn empty_yaml_hydrates_defaults() {
1671 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
1672 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
1673 assert_eq!(cfg.skills.max_total_tokens, 2000);
1674 assert!(cfg.skills.adaptive.is_some());
1675 }
1676
1677 #[test]
1678 fn load_or_default_missing_file_returns_default() {
1679 let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
1680 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
1681 }
1682}