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