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 super::skill_index::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/// Skill discovery mode configuration
63#[derive(Clone, Debug, Default, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum SkillDiscoveryMode {
66    /// Only explicit slash commands activate skills
67    ExplicitOnly,
68
69    /// Index-based discovery (default)
70    ///
71    /// Skills are discoverable via:
72    /// - Slash commands (/skill-name)
73    /// - Trigger keyword matching
74    /// - Semantic matching (Claude uses skill descriptions)
75    #[default]
76    IndexBased,
77
78    /// RAG-assisted discovery (optional feature)
79    ///
80    /// Uses embeddings for semantic similarity matching
81    /// before delegating to Claude.
82    RagAssisted {
83        /// Embedding model to use
84        embedding_model: String,
85        /// Minimum similarity threshold
86        similarity_threshold: f32,
87    },
88}
89
90/// Router for skill discovery
91#[derive(Debug)]
92pub struct SkillRouter {
93    /// Discovery mode
94    mode: SkillDiscoveryMode,
95}
96
97impl SkillRouter {
98    /// Create a new router with the specified mode
99    pub fn new(mode: SkillDiscoveryMode) -> Self {
100        Self { mode }
101    }
102
103    /// Route user input to a skill
104    pub fn route(&self, input: &str, skill_indices: &[SkillIndex]) -> RoutingStrategy {
105        match &self.mode {
106            SkillDiscoveryMode::ExplicitOnly => self.route_explicit_only(input, skill_indices),
107            SkillDiscoveryMode::IndexBased => self.route_index_based(input, skill_indices),
108            SkillDiscoveryMode::RagAssisted { .. } => {
109                // RAG would be handled externally; fall back to index-based
110                self.route_index_based(input, skill_indices)
111            }
112        }
113    }
114
115    /// Route with explicit-only mode
116    fn route_explicit_only(&self, input: &str, skill_indices: &[SkillIndex]) -> RoutingStrategy {
117        if let Some(skill) = skill_indices.iter().find(|s| s.matches_command(input)) {
118            RoutingStrategy::Explicit {
119                skill_name: skill.name.clone(),
120            }
121        } else {
122            RoutingStrategy::NoSkill
123        }
124    }
125
126    /// Route with index-based discovery
127    fn route_index_based(&self, input: &str, skill_indices: &[SkillIndex]) -> RoutingStrategy {
128        // 1. Check for explicit slash command
129        if let Some(skill) = skill_indices.iter().find(|s| s.matches_command(input)) {
130            return RoutingStrategy::Explicit {
131                skill_name: skill.name.clone(),
132            };
133        }
134
135        // 2. Check for trigger keyword matches
136        let matches: Vec<_> = skill_indices
137            .iter()
138            .filter(|s| s.matches_triggers(input))
139            .collect();
140
141        if !matches.is_empty() {
142            let matched_triggers: Vec<String> = matches
143                .iter()
144                .flat_map(|s| {
145                    s.triggers
146                        .iter()
147                        .filter(|t| input.to_lowercase().contains(&t.to_lowercase()))
148                        .cloned()
149                })
150                .collect();
151
152            let skill_names: Vec<String> = matches.iter().map(|s| s.name.clone()).collect();
153
154            return RoutingStrategy::KeywordMatch {
155                matched_triggers,
156                skill_names,
157            };
158        }
159
160        // 3. Fall back to semantic matching (Claude will decide)
161        if !skill_indices.is_empty() {
162            RoutingStrategy::Semantic { confidence: 0.0 }
163        } else {
164            RoutingStrategy::NoSkill
165        }
166    }
167}
168
169impl Default for SkillRouter {
170    fn default() -> Self {
171        Self::new(SkillDiscoveryMode::default())
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::context::skill_index::SkillScope;
179
180    fn test_skills() -> Vec<SkillIndex> {
181        vec![
182            SkillIndex::new("commit", "Create a git commit")
183                .with_triggers(vec!["git commit".into(), "commit changes".into()])
184                .with_scope(SkillScope::User),
185            SkillIndex::new("review", "Review code changes")
186                .with_triggers(vec!["code review".into(), "review pr".into()])
187                .with_scope(SkillScope::Project),
188        ]
189    }
190
191    #[test]
192    fn test_explicit_routing() {
193        let router = SkillRouter::default();
194        let skills = test_skills();
195
196        let result = router.route("/commit", &skills);
197        assert!(
198            matches!(result, RoutingStrategy::Explicit { skill_name } if skill_name == "commit")
199        );
200    }
201
202    #[test]
203    fn test_keyword_routing() {
204        let router = SkillRouter::default();
205        let skills = test_skills();
206
207        let result = router.route("I want to git commit these changes", &skills);
208        assert!(matches!(result, RoutingStrategy::KeywordMatch { .. }));
209
210        if let RoutingStrategy::KeywordMatch { skill_names, .. } = result {
211            assert!(skill_names.contains(&"commit".to_string()));
212        }
213    }
214
215    #[test]
216    fn test_no_match_semantic() {
217        let router = SkillRouter::default();
218        let skills = test_skills();
219
220        let result = router.route("help me with this bug", &skills);
221        assert!(matches!(result, RoutingStrategy::Semantic { .. }));
222    }
223
224    #[test]
225    fn test_no_skill_empty_index() {
226        let router = SkillRouter::default();
227        let skills: Vec<SkillIndex> = vec![];
228
229        let result = router.route("anything", &skills);
230        assert!(matches!(result, RoutingStrategy::NoSkill));
231    }
232
233    #[test]
234    fn test_explicit_only_mode() {
235        let router = SkillRouter::new(SkillDiscoveryMode::ExplicitOnly);
236        let skills = test_skills();
237
238        // Explicit command works
239        let result = router.route("/commit", &skills);
240        assert!(matches!(result, RoutingStrategy::Explicit { .. }));
241
242        // Keywords don't match in explicit-only mode
243        let result = router.route("git commit these changes", &skills);
244        assert!(matches!(result, RoutingStrategy::NoSkill));
245    }
246
247    #[test]
248    fn test_routing_strategy_methods() {
249        let explicit = RoutingStrategy::Explicit {
250            skill_name: "test".to_string(),
251        };
252        assert!(explicit.is_explicit());
253        assert!(explicit.has_skill());
254        assert_eq!(explicit.primary_skill(), Some("test"));
255
256        let no_skill = RoutingStrategy::NoSkill;
257        assert!(!no_skill.is_explicit());
258        assert!(!no_skill.has_skill());
259        assert_eq!(no_skill.primary_skill(), None);
260    }
261}