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