claude_agent/context/
routing.rs1use serde::{Deserialize, Serialize};
6
7use super::skill_index::SkillIndex;
8
9#[derive(Clone, Debug, Serialize, Deserialize)]
11#[serde(tag = "type", rename_all = "snake_case")]
12pub enum RoutingStrategy {
13 Explicit {
15 skill_name: String,
17 },
18
19 KeywordMatch {
21 matched_triggers: Vec<String>,
23 skill_names: Vec<String>,
25 },
26
27 Semantic {
32 confidence: f32,
34 },
35
36 NoSkill,
38}
39
40impl RoutingStrategy {
41 pub fn is_explicit(&self) -> bool {
43 matches!(self, Self::Explicit { .. })
44 }
45
46 pub fn has_skill(&self) -> bool {
48 !matches!(self, Self::NoSkill)
49 }
50
51 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#[derive(Clone, Debug, Default, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum SkillDiscoveryMode {
66 ExplicitOnly,
68
69 #[default]
76 IndexBased,
77
78 RagAssisted {
83 embedding_model: String,
85 similarity_threshold: f32,
87 },
88}
89
90#[derive(Debug)]
92pub struct SkillRouter {
93 mode: SkillDiscoveryMode,
95}
96
97impl SkillRouter {
98 pub fn new(mode: SkillDiscoveryMode) -> Self {
100 Self { mode }
101 }
102
103 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 self.route_index_based(input, skill_indices)
111 }
112 }
113 }
114
115 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 fn route_index_based(&self, input: &str, skill_indices: &[SkillIndex]) -> RoutingStrategy {
128 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 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 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 let result = router.route("/commit", &skills);
240 assert!(matches!(result, RoutingStrategy::Explicit { .. }));
241
242 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}