claude_agent/context/
routing.rs1use serde::{Deserialize, Serialize};
6
7use crate::skills::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
62pub fn route(input: &str, skill_indices: &[SkillIndex]) -> RoutingStrategy {
64 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 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 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 let result = route("/commit", &skills);
164 assert!(matches!(result, RoutingStrategy::Explicit { .. }));
165
166 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}