Skip to main content

claude_agent/context/
routing.rs

1//! Routing Strategy for Skill Discovery
2//!
3//! Determines how skills are discovered and activated based on user input.
4
5use serde::{Deserialize, Serialize};
6
7use crate::skills::SkillIndex;
8
9/// Strategy for routing user input to skills
10#[derive(Clone, Debug, Serialize, Deserialize)]
11#[serde(tag = "type", rename_all = "snake_case")]
12pub enum RoutingStrategy {
13    /// Explicit skill invocation via slash command (/skill-name)
14    Explicit {
15        /// The matched skill name
16        skill_name: String,
17    },
18
19    /// Keyword-based matching using trigger words
20    KeywordMatch {
21        /// Matched trigger keywords
22        matched_triggers: Vec<String>,
23        /// Candidate skill names
24        skill_names: Vec<String>,
25    },
26
27    /// Semantic matching (delegated to Claude)
28    ///
29    /// When no explicit or keyword match is found, Claude uses
30    /// the skill index summaries to decide.
31    Semantic {
32        /// Confidence score (0.0 to 1.0)
33        confidence: f32,
34    },
35
36    /// No skill routing needed
37    NoSkill,
38}
39
40impl RoutingStrategy {
41    /// Check if this is an explicit invocation
42    pub fn is_explicit(&self) -> bool {
43        matches!(self, Self::Explicit { .. })
44    }
45
46    /// Check if any skill was matched
47    pub fn has_skill(&self) -> bool {
48        !matches!(self, Self::NoSkill)
49    }
50
51    /// Get the primary skill name if available
52    pub fn primary_skill(&self) -> Option<&str> {
53        match self {
54            Self::Explicit { skill_name } => Some(skill_name),
55            Self::KeywordMatch { skill_names, .. } => skill_names.first().map(|s| s.as_str()),
56            Self::Semantic { .. } => None,
57            Self::NoSkill => None,
58        }
59    }
60}
61
62/// Route user input to determine skill discovery strategy.
63pub fn route(input: &str, skill_indices: &[SkillIndex]) -> RoutingStrategy {
64    // 1. Check for explicit slash command
65    if let Some(skill) = skill_indices.iter().find(|s| s.matches_command(input)) {
66        return RoutingStrategy::Explicit {
67            skill_name: skill.name.clone(),
68        };
69    }
70
71    // 2. Check for trigger keyword matches
72    let matches: Vec<_> = skill_indices
73        .iter()
74        .filter(|s| s.matches_triggers(input))
75        .collect();
76
77    if !matches.is_empty() {
78        let matched_triggers: Vec<String> = matches
79            .iter()
80            .flat_map(|s| {
81                s.triggers
82                    .iter()
83                    .filter(|t| input.to_lowercase().contains(&t.to_lowercase()))
84                    .cloned()
85            })
86            .collect();
87
88        let skill_names: Vec<String> = matches.iter().map(|s| s.name.clone()).collect();
89
90        return RoutingStrategy::KeywordMatch {
91            matched_triggers,
92            skill_names,
93        };
94    }
95
96    // 3. Fall back to semantic matching (Claude will decide)
97    if !skill_indices.is_empty() {
98        RoutingStrategy::Semantic { confidence: 0.0 }
99    } else {
100        RoutingStrategy::NoSkill
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::common::SourceType;
108
109    fn test_skills() -> Vec<SkillIndex> {
110        vec![
111            SkillIndex::new("commit", "Create a git commit")
112                .triggers(["git commit", "commit changes"])
113                .source_type(SourceType::User),
114            SkillIndex::new("review", "Review code changes")
115                .triggers(["code review", "review pr"])
116                .source_type(SourceType::Project),
117        ]
118    }
119
120    #[test]
121    fn test_explicit_routing() {
122        let skills = test_skills();
123
124        let result = route("/commit", &skills);
125        assert!(
126            matches!(result, RoutingStrategy::Explicit { skill_name } if skill_name == "commit")
127        );
128    }
129
130    #[test]
131    fn test_keyword_routing() {
132        let skills = test_skills();
133
134        let result = route("I want to git commit these changes", &skills);
135        assert!(matches!(result, RoutingStrategy::KeywordMatch { .. }));
136
137        if let RoutingStrategy::KeywordMatch { skill_names, .. } = result {
138            assert!(skill_names.contains(&"commit".to_string()));
139        }
140    }
141
142    #[test]
143    fn test_no_match_semantic() {
144        let skills = test_skills();
145
146        let result = route("help me with this bug", &skills);
147        assert!(matches!(result, RoutingStrategy::Semantic { .. }));
148    }
149
150    #[test]
151    fn test_no_skill_empty_index() {
152        let skills: Vec<SkillIndex> = vec![];
153
154        let result = route("anything", &skills);
155        assert!(matches!(result, RoutingStrategy::NoSkill));
156    }
157
158    #[test]
159    fn test_explicit_takes_precedence() {
160        let skills = test_skills();
161
162        // Explicit command works even if keywords also match
163        let result = route("/commit", &skills);
164        assert!(matches!(result, RoutingStrategy::Explicit { .. }));
165
166        // Keywords match when no explicit command
167        let result = route("git commit these changes", &skills);
168        assert!(matches!(result, RoutingStrategy::KeywordMatch { .. }));
169    }
170
171    #[test]
172    fn test_routing_strategy_methods() {
173        let explicit = RoutingStrategy::Explicit {
174            skill_name: "test".to_string(),
175        };
176        assert!(explicit.is_explicit());
177        assert!(explicit.has_skill());
178        assert_eq!(explicit.primary_skill(), Some("test"));
179
180        let no_skill = RoutingStrategy::NoSkill;
181        assert!(!no_skill.is_explicit());
182        assert!(!no_skill.has_skill());
183        assert_eq!(no_skill.primary_skill(), None);
184    }
185}