rx4 0.4.0

The agent harness engine — loop, tools, providers, sessions, permissions, computer-use
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
//! Intelligent model routing: map task tiers to model preferences, with
//! prompt heuristics, fallback resolution, and low-power proactive
//! monitoring inspired by Hermes/omi.
//!
//! [`ModelRouter`] selects a [`ModelTier`] for a given [`TaskType`] or prompt.
//! [`ProactiveMonitor`] runs lightweight background classification and
//! learning extraction on the Lite tier.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use thiserror::Error;

/// Errors raised by model routing operations.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ModelRouterError {
    #[error("no model available for tier: {0}")]
    NoModelAvailable(String),
    #[error("invalid tier: {0}")]
    InvalidTier(String),
}

/// Power level for a task type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskTier {
    /// Low-power tasks: skill creation, monitoring, consolidation, classification.
    Lite,
    /// Normal tasks: code generation, tool execution, conversation.
    Standard,
    /// Complex tasks: multi-step reasoning, architecture, review, debugging.
    Heavy,
    /// Delegated tasks: model chosen by primary model.
    Subagent,
}

impl TaskTier {
    pub fn as_str(&self) -> &'static str {
        match self {
            TaskTier::Lite => "lite",
            TaskTier::Standard => "standard",
            TaskTier::Heavy => "heavy",
            TaskTier::Subagent => "subagent",
        }
    }
}

/// Specific task category that maps to a [`TaskTier`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TaskType {
    SkillExtraction,
    MemoryConsolidation,
    ProactiveMonitor,
    KeywordExtraction,
    SimpleClassification,
    CodeGeneration,
    ToolExecution,
    Conversation,
    FileEdit,
    ShellCommand,
    ArchitectureDesign,
    CodeReview,
    Debugging,
    MultiStepReasoning,
    ComplexRefactor,
    /// Delegated task; the inner string is the task description.
    SubagentTask(String),
}

impl TaskType {
    /// Returns the [`TaskTier`] this task type belongs to.
    pub fn tier(&self) -> TaskTier {
        match self {
            TaskType::SkillExtraction
            | TaskType::MemoryConsolidation
            | TaskType::ProactiveMonitor
            | TaskType::KeywordExtraction
            | TaskType::SimpleClassification => TaskTier::Lite,
            TaskType::CodeGeneration
            | TaskType::ToolExecution
            | TaskType::Conversation
            | TaskType::FileEdit
            | TaskType::ShellCommand => TaskTier::Standard,
            TaskType::ArchitectureDesign
            | TaskType::CodeReview
            | TaskType::Debugging
            | TaskType::MultiStepReasoning
            | TaskType::ComplexRefactor => TaskTier::Heavy,
            TaskType::SubagentTask(_) => TaskTier::Subagent,
        }
    }
}

/// Model configuration for a single tier.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModelTier {
    /// Preferred model for this tier.
    pub model: String,
    /// Fallback model if the preferred model is unavailable.
    pub fallback: Option<String>,
    /// Maximum output tokens.
    pub max_tokens: u32,
    /// Sampling temperature.
    pub temperature: f32,
    /// Reasoning effort for thinking models ("low", "medium", "high", "xhigh").
    pub thinking_level: Option<String>,
}

impl ModelTier {
    /// Builds a [`ModelTier`] with the given preferred model.
    pub fn new(model: impl Into<String>) -> Self {
        Self {
            model: model.into(),
            fallback: None,
            max_tokens: 4096,
            temperature: 0.7,
            thinking_level: None,
        }
    }

    fn lite() -> Self {
        Self {
            model: "gpt-4o-mini".to_string(),
            fallback: Some("claude-3.5-haiku".to_string()),
            max_tokens: 2048,
            temperature: 0.3,
            thinking_level: None,
        }
    }

    fn standard() -> Self {
        Self {
            model: "gpt-4o".to_string(),
            fallback: Some("claude-3.5-sonnet".to_string()),
            max_tokens: 8192,
            temperature: 0.7,
            thinking_level: None,
        }
    }

    fn heavy() -> Self {
        Self {
            model: "claude-3.5-sonnet".to_string(),
            fallback: Some("o1".to_string()),
            max_tokens: 16384,
            temperature: 0.5,
            thinking_level: Some("high".to_string()),
        }
    }

    fn subagent() -> Self {
        Self {
            model: "gpt-4o-mini".to_string(),
            fallback: Some("claude-3.5-haiku".to_string()),
            max_tokens: 4096,
            temperature: 0.5,
            thinking_level: None,
        }
    }
}

/// Configuration for a [`ModelRouter`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RouterConfig {
    /// Tier to model configuration mapping.
    pub tiers: HashMap<TaskTier, ModelTier>,
    /// Keyword to tier mapping used by prompt heuristics.
    pub prompt_heuristics: HashMap<String, TaskTier>,
    /// Primary model to default subagent model mapping.
    pub subagent_defaults: HashMap<String, String>,
}

impl Default for RouterConfig {
    fn default() -> Self {
        let mut tiers = HashMap::new();
        tiers.insert(TaskTier::Lite, ModelTier::lite());
        tiers.insert(TaskTier::Standard, ModelTier::standard());
        tiers.insert(TaskTier::Heavy, ModelTier::heavy());
        tiers.insert(TaskTier::Subagent, ModelTier::subagent());

        let mut prompt_heuristics = HashMap::new();
        for kw in ["architecture", "design", "review", "debug"] {
            prompt_heuristics.insert(kw.to_string(), TaskTier::Heavy);
        }
        for kw in ["skill", "memory", "consolidate", "monitor", "classify"] {
            prompt_heuristics.insert(kw.to_string(), TaskTier::Lite);
        }

        let mut subagent_defaults = HashMap::new();
        subagent_defaults.insert("gpt-4o".to_string(), "gpt-4o-mini".to_string());
        subagent_defaults.insert(
            "claude-3.5-sonnet".to_string(),
            "claude-3.5-haiku".to_string(),
        );

        Self {
            tiers,
            prompt_heuristics,
            subagent_defaults,
        }
    }
}

/// Routes tasks to appropriate models based on tier heuristics.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelRouter {
    config: RouterConfig,
}

impl Default for ModelRouter {
    fn default() -> Self {
        Self::new()
    }
}

impl ModelRouter {
    /// Creates a router with sensible defaults.
    pub fn new() -> Self {
        Self {
            config: RouterConfig::default(),
        }
    }

    /// Creates a router from a custom [`RouterConfig`].
    pub fn with_config(config: RouterConfig) -> Self {
        Self { config }
    }

    /// Returns the [`ModelTier`] for a given [`TaskType`].
    pub fn route(&self, task: &TaskType) -> &ModelTier {
        let tier = task.tier();
        self.config
            .tiers
            .get(&tier)
            .expect("tier config must be present")
    }

    /// Heuristic routing based on prompt content.
    ///
    /// Keywords are matched case-insensitively against the configured
    /// [`RouterConfig::prompt_heuristics`]. The first matching keyword wins;
    /// if none match, [`TaskTier::Standard`] is used.
    pub fn route_prompt(&self, prompt: &str) -> &ModelTier {
        let lower = prompt.to_lowercase();
        for (keyword, tier) in &self.config.prompt_heuristics {
            if lower.contains(keyword) {
                if let Some(model_tier) = self.config.tiers.get(tier) {
                    return model_tier;
                }
            }
        }
        self.config
            .tiers
            .get(&TaskTier::Standard)
            .expect("standard tier config must be present")
    }

    /// Overrides the preferred model for a tier.
    pub fn set_model(&mut self, tier: TaskTier, model: String) {
        if let Some(t) = self.config.tiers.get_mut(&tier) {
            t.model = model;
        }
    }

    /// Sets the fallback model for a tier.
    pub fn set_fallback(&mut self, tier: TaskTier, fallback: String) {
        if let Some(t) = self.config.tiers.get_mut(&tier) {
            t.fallback = Some(fallback);
        }
    }

    /// Resolves a task to an available model, falling back if the preferred
    /// model is unavailable.
    ///
    /// Returns [`ModelRouterError::NoModelAvailable`] if neither the preferred
    /// nor the fallback model is in `available_models`.
    pub fn resolve(
        &self,
        task: &TaskType,
        available_models: &[String],
    ) -> Result<String, ModelRouterError> {
        let tier = self.route(task);
        if available_models.iter().any(|m| m == &tier.model) {
            return Ok(tier.model.clone());
        }
        if let Some(ref fallback) = tier.fallback {
            if available_models.iter().any(|m| m == fallback) {
                return Ok(fallback.clone());
            }
        }
        Err(ModelRouterError::NoModelAvailable(format!(
            "no available model for tier {} (preferred: {}, fallback: {:?})",
            task.tier().as_str(),
            tier.model,
            tier.fallback
        )))
    }

    /// Lets the primary model decide the subagent model based on task
    /// complexity.
    ///
    /// Uses [`SubagentModelSelector`] heuristics. If the primary model has a
    /// configured default and no heuristic matches, that default is used.
    pub fn subagent_model(&self, task_description: &str, primary_model: &str) -> String {
        let selector = SubagentModelSelector::new();
        let available: Vec<String> = self
            .config
            .tiers
            .values()
            .map(|t| t.model.clone())
            .collect();
        let selected = selector.select(task_description, primary_model, &available);
        if selected != primary_model {
            return selected;
        }
        if let Some(default) = self.config.subagent_defaults.get(primary_model) {
            return default.clone();
        }
        self.config
            .tiers
            .get(&TaskTier::Subagent)
            .map(|t| t.model.clone())
            .unwrap_or_else(|| "gpt-4o-mini".to_string())
    }

    /// Returns a reference to the underlying [`RouterConfig`].
    pub fn config(&self) -> &RouterConfig {
        &self.config
    }
}

/// Lets the primary model choose subagent models based on task complexity
/// heuristics.
#[derive(Debug, Clone)]
pub struct SubagentModelSelector {
    lite_model: String,
    standard_model: String,
    heavy_model: String,
}

impl Default for SubagentModelSelector {
    fn default() -> Self {
        Self::new()
    }
}

impl SubagentModelSelector {
    /// Creates a selector with default model preferences.
    pub fn new() -> Self {
        Self {
            lite_model: "gpt-4o-mini".to_string(),
            standard_model: "gpt-4o".to_string(),
            heavy_model: "claude-3.5-sonnet".to_string(),
        }
    }

    /// Selects a subagent model for the given task description.
    ///
    /// Heuristics:
    /// - "explore", "search", "find" -> lite model
    /// - "implement", "write", "create" -> standard model
    /// - "review", "analyze", "design" -> heavy model
    /// - default -> subagent tier model (first available)
    pub fn select(
        &self,
        task_description: &str,
        primary_model: &str,
        available_models: &[String],
    ) -> String {
        let lower = task_description.to_lowercase();
        let pick = |model: &str| -> String {
            if available_models.iter().any(|m| m == model) {
                model.to_string()
            } else {
                primary_model.to_string()
            }
        };

        if ["explore", "search", "find"]
            .iter()
            .any(|k| lower.contains(k))
        {
            return pick(&self.lite_model);
        }
        if ["implement", "write", "create"]
            .iter()
            .any(|k| lower.contains(k))
        {
            return pick(&self.standard_model);
        }
        if ["review", "analyze", "design"]
            .iter()
            .any(|k| lower.contains(k))
        {
            return pick(&self.heavy_model);
        }
        pick(&self.lite_model)
    }

    /// Builds a prompt for the primary model to select a subagent model.
    pub fn build_prompt(&self, task_description: &str, available_models: &[String]) -> String {
        let models = available_models.join(", ");
        format!(
            "You are selecting a subagent model for the following task.\n\n\
             Task: {task_description}\n\n\
             Available models: {models}\n\n\
             Choose the most appropriate model for this task based on its \
             complexity. Reply with only the model name from the available \
             list. Use a lighter model for exploration/search, a standard \
             model for implementation, and a heavier model for review, \
             analysis, or design."
        )
    }
}

/// Classification of a completed conversation turn.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TurnClassification {
    /// Estimated complexity of the turn.
    pub complexity: TaskTier,
    /// Tools invoked during the turn.
    pub tools_used: Vec<String>,
    /// Whether the turn completed successfully.
    pub success: bool,
    /// Wall-clock duration in seconds.
    pub duration_secs: f64,
}

/// A learning extracted from conversation history.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Learning {
    /// Short summary of the learning.
    pub summary: String,
    /// Category tag (e.g., "tooling", "debugging").
    pub category: String,
    /// Confidence in the learning, in `[0.0, 1.0]`.
    pub confidence: f64,
    /// When the learning was recorded.
    pub timestamp: DateTime<Utc>,
}

/// A suggestion to create a new skill from observed conversation patterns.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SkillSuggestion {
    /// Proposed skill name.
    pub name: String,
    /// Human-readable description of what the skill does.
    pub description: String,
    /// Prompt patterns that should trigger the skill.
    pub trigger_patterns: Vec<String>,
    /// Instructions the skill should follow when triggered.
    pub instructions: String,
    /// Confidence in the suggestion, in `[0.0, 1.0]`.
    pub confidence: f64,
}

/// Low-power background monitoring inspired by Hermes/omi.
///
/// Runs on the Lite tier and classifies turns, extracts learnings, and
/// suggests skills without blocking the main agent loop.
#[derive(Debug, Clone)]
pub struct ProactiveMonitor {
    router: ModelRouter,
}

impl ProactiveMonitor {
    /// Creates a new monitor backed by the given router.
    pub fn new(router: ModelRouter) -> Self {
        Self { router }
    }

    /// Returns `true` if enough time has elapsed since `last_run` for the
    /// monitor to run again.
    pub fn should_run(&self, last_run: DateTime<Utc>, interval_minutes: u32) -> bool {
        let elapsed = Utc::now().signed_duration_since(last_run);
        elapsed.num_minutes() >= i64::from(interval_minutes)
    }

    /// Classifies a completed turn based on its prompt and response.
    ///
    /// Heuristics mirror [`ModelRouter::route_prompt`] for complexity, and
    /// inspect the response for tool-call markers and success indicators.
    pub fn classify_turn(&self, prompt: &str, response: &str) -> TurnClassification {
        let tier = self.router.route_prompt(prompt);
        let complexity = match tier.model.as_str() {
            "gpt-4o-mini" => TaskTier::Lite,
            "gpt-4o" => TaskTier::Standard,
            "claude-3.5-sonnet" => TaskTier::Heavy,
            _ => TaskTier::Standard,
        };

        let mut tools_used = Vec::new();
        let lower = response.to_lowercase();
        for tool in [
            "read", "write", "edit", "exec", "grep", "search", "bash", "shell",
        ] {
            if lower.contains(tool) {
                tools_used.push(tool.to_string());
            }
        }

        let success = !response.trim().is_empty()
            && !lower.contains("error")
            && !lower.contains("failed")
            && !lower.contains("traceback");

        let duration_secs = if tools_used.is_empty() { 0.5 } else { 2.0 } * tools_used.len() as f64;

        TurnClassification {
            complexity,
            tools_used,
            success,
            duration_secs,
        }
    }

    /// Extracts a [`Learning`] from a conversation transcript, if one is
    /// detectable.
    ///
    /// A learning is detected when a turn contains an error followed by a
    /// resolution, or when a tool usage pattern repeats.
    pub fn extract_learning(&self, conversation: &[String]) -> Option<Learning> {
        if conversation.len() < 2 {
            return None;
        }
        let combined = conversation.join("\n");
        let lower = combined.to_lowercase();

        let (category, summary, confidence) = if lower.contains("error") || lower.contains("failed")
        {
            (
                "debugging".to_string(),
                "Errors were encountered and resolved during the turn.".to_string(),
                0.7,
            )
        } else if lower.contains("refactor") {
            (
                "refactoring".to_string(),
                "A refactoring pattern was applied successfully.".to_string(),
                0.6,
            )
        } else if lower.contains("test") {
            (
                "testing".to_string(),
                "Tests were run and informed the workflow.".to_string(),
                0.5,
            )
        } else {
            return None;
        };

        Some(Learning {
            summary,
            category,
            confidence,
            timestamp: Utc::now(),
        })
    }

    /// Suggests creating a skill when a prompt pattern repeats across the
    /// conversation.
    pub fn suggest_skill(&self, conversation: &[String]) -> Option<SkillSuggestion> {
        if conversation.len() < 3 {
            return None;
        }
        let lower: Vec<String> = conversation.iter().map(|s| s.to_lowercase()).collect();

        let (name, description, trigger_patterns, instructions, confidence) =
            if lower.iter().filter(|s| s.contains("test")).count() >= 2 {
                (
                    "run-tests".to_string(),
                    "Run the project test suite and report failures.".to_string(),
                    vec!["run tests".to_string(), "check tests".to_string()],
                    "Execute the test command, capture output, and summarize failures.".to_string(),
                    0.8,
                )
            } else if lower.iter().filter(|s| s.contains("lint")).count() >= 2 {
                (
                    "lint".to_string(),
                    "Run the linter and report issues.".to_string(),
                    vec!["lint".to_string(), "check style".to_string()],
                    "Run the configured linter and summarize violations.".to_string(),
                    0.7,
                )
            } else if lower.iter().filter(|s| s.contains("build")).count() >= 2 {
                (
                    "build".to_string(),
                    "Build the project and report errors.".to_string(),
                    vec!["build".to_string(), "compile".to_string()],
                    "Run the build command and report any compilation errors.".to_string(),
                    0.6,
                )
            } else {
                return None;
            };

        Some(SkillSuggestion {
            name,
            description,
            trigger_patterns,
            instructions,
            confidence,
        })
    }

    /// Returns a reference to the monitor's router.
    pub fn router(&self) -> &ModelRouter {
        &self.router
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn lite_task_types_map_to_lite_tier() {
        assert_eq!(TaskType::SkillExtraction.tier(), TaskTier::Lite);
        assert_eq!(TaskType::MemoryConsolidation.tier(), TaskTier::Lite);
        assert_eq!(TaskType::ProactiveMonitor.tier(), TaskTier::Lite);
        assert_eq!(TaskType::KeywordExtraction.tier(), TaskTier::Lite);
        assert_eq!(TaskType::SimpleClassification.tier(), TaskTier::Lite);
    }

    #[test]
    fn standard_task_types_map_to_standard_tier() {
        assert_eq!(TaskType::CodeGeneration.tier(), TaskTier::Standard);
        assert_eq!(TaskType::ToolExecution.tier(), TaskTier::Standard);
        assert_eq!(TaskType::Conversation.tier(), TaskTier::Standard);
        assert_eq!(TaskType::FileEdit.tier(), TaskTier::Standard);
        assert_eq!(TaskType::ShellCommand.tier(), TaskTier::Standard);
    }

    #[test]
    fn heavy_task_types_map_to_heavy_tier() {
        assert_eq!(TaskType::ArchitectureDesign.tier(), TaskTier::Heavy);
        assert_eq!(TaskType::CodeReview.tier(), TaskTier::Heavy);
        assert_eq!(TaskType::Debugging.tier(), TaskTier::Heavy);
        assert_eq!(TaskType::MultiStepReasoning.tier(), TaskTier::Heavy);
        assert_eq!(TaskType::ComplexRefactor.tier(), TaskTier::Heavy);
    }

    #[test]
    fn subagent_task_maps_to_subagent_tier() {
        assert_eq!(
            TaskType::SubagentTask("explore the repo".to_string()).tier(),
            TaskTier::Subagent
        );
    }

    #[test]
    fn route_returns_correct_model_tier() {
        let router = ModelRouter::new();
        let lite = router.route(&TaskType::SkillExtraction);
        assert_eq!(lite.model, "gpt-4o-mini");
        assert_eq!(lite.max_tokens, 2048);

        let standard = router.route(&TaskType::CodeGeneration);
        assert_eq!(standard.model, "gpt-4o");
        assert_eq!(standard.max_tokens, 8192);

        let heavy = router.route(&TaskType::ArchitectureDesign);
        assert_eq!(heavy.model, "claude-3.5-sonnet");
        assert_eq!(heavy.thinking_level, Some("high".to_string()));

        let subagent = router.route(&TaskType::SubagentTask("do thing".to_string()));
        assert_eq!(subagent.model, "gpt-4o-mini");
        assert_eq!(subagent.max_tokens, 4096);
    }

    #[test]
    fn route_prompt_architecture_is_heavy() {
        let router = ModelRouter::new();
        let tier = router.route_prompt("design the architecture for the new module");
        assert_eq!(tier.model, "claude-3.5-sonnet");
    }

    #[test]
    fn route_prompt_skill_is_lite() {
        let router = ModelRouter::new();
        let tier = router.route_prompt("extract a skill from this conversation");
        assert_eq!(tier.model, "gpt-4o-mini");
    }

    #[test]
    fn route_prompt_default_is_standard() {
        let router = ModelRouter::new();
        let tier = router.route_prompt("write a function that adds two numbers");
        assert_eq!(tier.model, "gpt-4o");
    }

    #[test]
    fn set_model_overrides_tier_model() {
        let mut router = ModelRouter::new();
        router.set_model(TaskTier::Lite, "custom-lite".to_string());
        let tier = router.route(&TaskType::SkillExtraction);
        assert_eq!(tier.model, "custom-lite");
    }

    #[test]
    fn set_fallback_overrides_tier_fallback() {
        let mut router = ModelRouter::new();
        router.set_fallback(TaskTier::Standard, "custom-fallback".to_string());
        let tier = router.route(&TaskType::CodeGeneration);
        assert_eq!(tier.fallback, Some("custom-fallback".to_string()));
    }

    #[test]
    fn resolve_returns_preferred_when_available() {
        let router = ModelRouter::new();
        let available = vec![
            "gpt-4o-mini".to_string(),
            "gpt-4o".to_string(),
            "claude-3.5-sonnet".to_string(),
        ];
        let model = router
            .resolve(&TaskType::CodeGeneration, &available)
            .expect("should resolve");
        assert_eq!(model, "gpt-4o");
    }

    #[test]
    fn resolve_falls_back_when_preferred_unavailable() {
        let router = ModelRouter::new();
        let available = vec!["claude-3.5-sonnet".to_string()];
        let model = router
            .resolve(&TaskType::CodeGeneration, &available)
            .expect("should resolve via fallback");
        assert_eq!(model, "claude-3.5-sonnet");
    }

    #[test]
    fn resolve_errors_when_nothing_available() {
        let router = ModelRouter::new();
        let available = vec!["some-other-model".to_string()];
        let result = router.resolve(&TaskType::CodeGeneration, &available);
        assert!(matches!(result, Err(ModelRouterError::NoModelAvailable(_))));
    }

    #[test]
    fn subagent_model_selection_explore_is_lite() {
        let selector = SubagentModelSelector::new();
        let available = vec![
            "gpt-4o-mini".to_string(),
            "gpt-4o".to_string(),
            "claude-3.5-sonnet".to_string(),
        ];
        let model = selector.select("explore the repository structure", "gpt-4o", &available);
        assert_eq!(model, "gpt-4o-mini");
    }

    #[test]
    fn subagent_model_selection_search_is_lite() {
        let selector = SubagentModelSelector::new();
        let available = vec![
            "gpt-4o-mini".to_string(),
            "gpt-4o".to_string(),
            "claude-3.5-sonnet".to_string(),
        ];
        let model = selector.select("search for usages of foo", "gpt-4o", &available);
        assert_eq!(model, "gpt-4o-mini");
    }

    #[test]
    fn subagent_model_selection_implement_is_standard() {
        let selector = SubagentModelSelector::new();
        let available = vec![
            "gpt-4o-mini".to_string(),
            "gpt-4o".to_string(),
            "claude-3.5-sonnet".to_string(),
        ];
        let model = selector.select("implement the new endpoint", "gpt-4o", &available);
        assert_eq!(model, "gpt-4o");
    }

    #[test]
    fn subagent_model_selection_write_is_standard() {
        let selector = SubagentModelSelector::new();
        let available = vec![
            "gpt-4o-mini".to_string(),
            "gpt-4o".to_string(),
            "claude-3.5-sonnet".to_string(),
        ];
        let model = selector.select("write the documentation", "gpt-4o", &available);
        assert_eq!(model, "gpt-4o");
    }

    #[test]
    fn subagent_model_selection_review_is_heavy() {
        let selector = SubagentModelSelector::new();
        let available = vec![
            "gpt-4o-mini".to_string(),
            "gpt-4o".to_string(),
            "claude-3.5-sonnet".to_string(),
        ];
        let model = selector.select("review the pull request", "gpt-4o", &available);
        assert_eq!(model, "claude-3.5-sonnet");
    }

    #[test]
    fn subagent_model_selection_analyze_is_heavy() {
        let selector = SubagentModelSelector::new();
        let available = vec![
            "gpt-4o-mini".to_string(),
            "gpt-4o".to_string(),
            "claude-3.5-sonnet".to_string(),
        ];
        let model = selector.select("analyze the performance bottleneck", "gpt-4o", &available);
        assert_eq!(model, "claude-3.5-sonnet");
    }

    #[test]
    fn subagent_model_selection_default_is_lite() {
        let selector = SubagentModelSelector::new();
        let available = vec![
            "gpt-4o-mini".to_string(),
            "gpt-4o".to_string(),
            "claude-3.5-sonnet".to_string(),
        ];
        let model = selector.select("do something generic", "gpt-4o", &available);
        assert_eq!(model, "gpt-4o-mini");
    }

    #[test]
    fn subagent_model_selection_falls_back_to_primary_when_unavailable() {
        let selector = SubagentModelSelector::new();
        let available = vec!["gpt-4o".to_string()];
        let model = selector.select("explore the repo", "gpt-4o", &available);
        assert_eq!(model, "gpt-4o");
    }

    #[test]
    fn subagent_model_prompt_contains_task_and_models() {
        let selector = SubagentModelSelector::new();
        let available = vec!["gpt-4o-mini".to_string(), "gpt-4o".to_string()];
        let prompt = selector.build_prompt("explore the repo", &available);
        assert!(prompt.contains("explore the repo"));
        assert!(prompt.contains("gpt-4o-mini"));
        assert!(prompt.contains("gpt-4o"));
    }

    #[test]
    fn router_subagent_model_uses_heuristics() {
        let router = ModelRouter::new();
        let model = router.subagent_model("review the architecture", "gpt-4o");
        assert_eq!(model, "claude-3.5-sonnet");
    }

    #[test]
    fn router_subagent_model_uses_default_for_unknown_pattern() {
        let router = ModelRouter::new();
        let model = router.subagent_model("do something generic", "gpt-4o");
        assert_eq!(model, "gpt-4o-mini");
    }

    #[test]
    fn config_based_router_construction() {
        let mut config = RouterConfig::default();
        config.tiers.insert(
            TaskTier::Lite,
            ModelTier {
                model: "my-lite".to_string(),
                fallback: None,
                max_tokens: 1024,
                temperature: 0.1,
                thinking_level: None,
            },
        );
        let router = ModelRouter::with_config(config);
        let tier = router.route(&TaskType::SkillExtraction);
        assert_eq!(tier.model, "my-lite");
        assert_eq!(tier.max_tokens, 1024);
    }

    #[test]
    fn proactive_monitor_should_run_when_interval_elapsed() {
        let router = ModelRouter::new();
        let monitor = ProactiveMonitor::new(router);
        let last_run = Utc::now() - chrono::Duration::minutes(10);
        assert!(monitor.should_run(last_run, 5));
    }

    #[test]
    fn proactive_monitor_should_not_run_within_interval() {
        let router = ModelRouter::new();
        let monitor = ProactiveMonitor::new(router);
        let last_run = Utc::now() - chrono::Duration::minutes(1);
        assert!(!monitor.should_run(last_run, 5));
    }

    #[test]
    fn proactive_monitor_classifies_heavy_turn() {
        let router = ModelRouter::new();
        let monitor = ProactiveMonitor::new(router);
        let classification = monitor.classify_turn(
            "design the architecture for the system",
            "Created module structure with traits.",
        );
        assert_eq!(classification.complexity, TaskTier::Heavy);
        assert!(classification.success);
    }

    #[test]
    fn proactive_monitor_classifies_lite_turn() {
        let router = ModelRouter::new();
        let monitor = ProactiveMonitor::new(router);
        let classification = monitor.classify_turn("extract a skill from this", "Done.");
        assert_eq!(classification.complexity, TaskTier::Lite);
        assert!(classification.success);
    }

    #[test]
    fn proactive_monitor_detects_failure_in_response() {
        let router = ModelRouter::new();
        let monitor = ProactiveMonitor::new(router);
        let classification = monitor.classify_turn("write a function", "error: compilation failed");
        assert!(!classification.success);
    }

    #[test]
    fn proactive_monitor_extracts_debugging_learning() {
        let router = ModelRouter::new();
        let monitor = ProactiveMonitor::new(router);
        let conversation = vec![
            "run the build".to_string(),
            "error: undefined variable".to_string(),
            "fixed the variable reference".to_string(),
        ];
        let learning = monitor
            .extract_learning(&conversation)
            .expect("should extract");
        assert_eq!(learning.category, "debugging");
        assert!(learning.confidence > 0.0);
    }

    #[test]
    fn proactive_monitor_extracts_nothing_from_short_conversation() {
        let router = ModelRouter::new();
        let monitor = ProactiveMonitor::new(router);
        let conversation = vec!["hello".to_string()];
        assert!(monitor.extract_learning(&conversation).is_none());
    }

    #[test]
    fn proactive_monitor_suggests_test_skill() {
        let router = ModelRouter::new();
        let monitor = ProactiveMonitor::new(router);
        let conversation = vec![
            "run the tests".to_string(),
            "tests passed".to_string(),
            "run the tests again".to_string(),
        ];
        let suggestion = monitor
            .suggest_skill(&conversation)
            .expect("should suggest");
        assert_eq!(suggestion.name, "run-tests");
        assert!(!suggestion.trigger_patterns.is_empty());
    }

    #[test]
    fn proactive_monitor_suggests_nothing_for_short_conversation() {
        let router = ModelRouter::new();
        let monitor = ProactiveMonitor::new(router);
        let conversation = vec!["hello".to_string(), "world".to_string()];
        assert!(monitor.suggest_skill(&conversation).is_none());
    }

    #[test]
    fn task_tier_as_str_round_trips() {
        assert_eq!(TaskTier::Lite.as_str(), "lite");
        assert_eq!(TaskTier::Standard.as_str(), "standard");
        assert_eq!(TaskTier::Heavy.as_str(), "heavy");
        assert_eq!(TaskTier::Subagent.as_str(), "subagent");
    }
}