perspt_sdk/toolset/
catalog.rs1use super::entry::ToolEntry;
11use crate::capability::Capability;
12use crate::error::{Result, SdkError};
13use crate::model::ToolSpec;
14
15pub trait ToolCatalog: Send + Sync {
17 fn entries(&self) -> &[ToolEntry];
19
20 fn lookup(&self, name: &str) -> Option<&ToolEntry> {
22 self.entries().iter().find(|e| e.name == name)
23 }
24
25 fn specs_for(&self, capabilities: &[Capability], strict: bool) -> Vec<ToolSpec> {
31 self.entries()
32 .iter()
33 .filter(|entry| {
34 capabilities
35 .iter()
36 .any(|c| c.effects.contains(&entry.effect))
37 })
38 .map(|entry| entry.to_spec(strict))
39 .collect()
40 }
41
42 fn deferred_specs_for(
49 &self,
50 capabilities: &[Capability],
51 activated: &std::collections::BTreeSet<String>,
52 strict: bool,
53 ) -> Vec<ToolSpec> {
54 self.entries()
55 .iter()
56 .filter(|entry| {
57 (entry.hot || activated.contains(&entry.name))
58 && capabilities
59 .iter()
60 .any(|capability| capability.effects.contains(&entry.effect))
61 })
62 .map(|entry| entry.to_spec(strict))
63 .collect()
64 }
65
66 fn search_specs(
70 &self,
71 capabilities: &[Capability],
72 query: &str,
73 limit: usize,
74 strict: bool,
75 ) -> Vec<ToolSpec> {
76 let terms: Vec<String> = query
77 .split(|character: char| !character.is_alphanumeric() && character != '_')
78 .filter(|term| !term.is_empty())
79 .map(str::to_ascii_lowercase)
80 .collect();
81 let mut matches: Vec<(usize, &ToolEntry)> = self
82 .entries()
83 .iter()
84 .filter(|entry| {
85 entry.name != "tool_search"
86 && capabilities
87 .iter()
88 .any(|capability| capability.effects.contains(&entry.effect))
89 })
90 .filter_map(|entry| {
91 let name = entry.name.to_ascii_lowercase();
92 let haystack = format!("{} {}", name, entry.discovery_text().to_ascii_lowercase());
96 let score = terms.iter().fold(0usize, |score, term| {
97 score
98 + usize::from(name == *term) * 8
99 + usize::from(name.contains(term)) * 4
100 + usize::from(haystack.contains(term))
101 });
102 (score > 0 || terms.is_empty()).then_some((score, entry))
103 })
104 .collect();
105 matches.sort_by(|(left_score, left), (right_score, right)| {
106 right_score
107 .cmp(left_score)
108 .then_with(|| left.name.cmp(&right.name))
109 });
110 matches
111 .into_iter()
112 .take(limit.clamp(1, 12))
113 .map(|(_, entry)| entry.to_spec(strict))
114 .collect()
115 }
116}
117
118#[derive(Debug, Default)]
120pub struct StaticCatalog {
121 entries: Vec<ToolEntry>,
122}
123
124impl StaticCatalog {
125 pub fn assemble(entries: Vec<ToolEntry>) -> Result<Self> {
128 let mut seen = std::collections::BTreeSet::new();
129 for entry in &entries {
130 entry.validate()?;
131 if !seen.insert(entry.name.clone()) {
132 return Err(SdkError::Domain(format!(
133 "duplicate tool name {:?} in catalog",
134 entry.name
135 )));
136 }
137 }
138 Ok(Self { entries })
139 }
140
141 pub fn with_base(extra: Vec<ToolEntry>) -> Result<Self> {
143 let mut entries = super::base::base_entries();
144 entries.extend(extra);
145 Self::assemble(entries)
146 }
147}
148
149impl ToolCatalog for StaticCatalog {
150 fn entries(&self) -> &[ToolEntry] {
151 &self.entries
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use crate::capability::{ActorId, EffectKind};
159
160 fn actor() -> ActorId {
161 ActorId("tester".into())
162 }
163
164 #[test]
165 fn base_catalog_assembles() {
166 let catalog = StaticCatalog::with_base(Vec::new()).unwrap();
167 assert!(catalog.lookup("edit_file").is_some());
168 assert!(catalog.lookup("sed_replace").is_none());
169 }
170
171 #[test]
172 fn specs_follow_held_capabilities_not_the_full_catalog() {
173 let catalog = StaticCatalog::with_base(Vec::new()).unwrap();
174 let read_only = Capability::new(
175 actor(),
176 vec![EffectKind::ReadFile, EffectKind::Search, EffectKind::List],
177 );
178 let specs = catalog.specs_for(&[read_only], false);
179 let names: Vec<&str> = specs.iter().map(|s| s.name.as_str()).collect();
180 assert!(names.contains(&"read_file"));
181 assert!(names.contains(&"grep"));
182 assert!(!names.contains(&"run_shell"), "no RunShell capability held");
183 assert!(!names.contains(&"edit_file"));
184 }
185
186 #[test]
187 fn no_capabilities_means_no_tools() {
188 let catalog = StaticCatalog::with_base(Vec::new()).unwrap();
189 assert!(catalog.specs_for(&[], false).is_empty());
190 }
191
192 #[test]
193 fn duplicate_names_are_rejected_at_assembly() {
194 let mut entries = super::super::base::base_entries();
195 entries.push(entries[0].clone());
196 assert!(StaticCatalog::assemble(entries).is_err());
197 }
198
199 #[test]
200 fn the_catalog_is_provider_invariant_for_a_fixed_capability_set() {
201 let catalog = StaticCatalog::with_base(Vec::new()).unwrap();
205 let capability = Capability::new(actor(), vec![EffectKind::ReadFile]);
206 let strict = catalog.specs_for(std::slice::from_ref(&capability), true);
207 let lax = catalog.specs_for(std::slice::from_ref(&capability), false);
208 let strict_names: Vec<&str> = strict.iter().map(|s| s.name.as_str()).collect();
209 let lax_names: Vec<&str> = lax.iter().map(|s| s.name.as_str()).collect();
210 assert_eq!(strict_names, lax_names);
211 }
212
213 #[test]
214 fn the_hot_set_includes_the_verifier_loop_and_rare_tools_stay_deferred() {
215 let catalog = StaticCatalog::with_base(Vec::new()).unwrap();
216 let capability = Capability::new(
217 actor(),
218 vec![
219 EffectKind::ToolSearch,
220 EffectKind::ReadFile,
221 EffectKind::RunTest,
222 EffectKind::MoveFile,
223 ],
224 );
225 let activated = std::collections::BTreeSet::new();
226 let initial =
227 catalog.deferred_specs_for(std::slice::from_ref(&capability), &activated, false);
228 assert!(initial.iter().any(|spec| spec.name == "tool_search"));
229 assert!(initial.iter().any(|spec| spec.name == "run_test"));
232 assert!(!initial.iter().any(|spec| spec.name == "move_file"));
234 let mut discovered = activated;
235 discovered.insert("move_file".into());
236 let after =
237 catalog.deferred_specs_for(std::slice::from_ref(&capability), &discovered, false);
238 assert!(after.iter().any(|spec| spec.name == "move_file"));
239 }
240}