1use harness_core::{Action, Tool, ToolError, ToolResult, ToolRisk, ToolSchema, World};
4use std::collections::HashMap;
5use std::sync::Arc;
6
7#[derive(Default)]
8pub struct ToolRegistry {
9 tools: HashMap<String, Arc<dyn Tool>>,
10}
11
12impl ToolRegistry {
13 pub fn new() -> Self {
14 Self::default()
15 }
16
17 pub fn insert(&mut self, t: Arc<dyn Tool>) {
18 self.tools.insert(t.name().to_string(), t);
19 }
20
21 pub fn schemas(&self) -> Vec<ToolSchema> {
26 let mut v: Vec<ToolSchema> = self.tools.values().map(|t| t.schema().clone()).collect();
27 v.sort_by(|a, b| a.name.cmp(&b.name));
28 v
29 }
30
31 pub async fn dispatch(
32 &self,
33 action: &Action,
34 world: &mut World,
35 ) -> Result<ToolResult, ToolError> {
36 let tool = self
37 .tools
38 .get(&action.tool)
39 .ok_or_else(|| ToolError::NotFound {
40 name: action.tool.clone(),
41 hint: self.not_found_hint(&action.tool),
42 })?
43 .clone();
44 tool.invoke(action.args.clone(), world).await
45 }
46
47 fn not_found_hint(&self, wanted: &str) -> String {
55 let mut names: Vec<&str> = self.tools.keys().map(|s| s.as_str()).collect();
56 names.sort_unstable();
57 if names.is_empty() {
58 return " (no tools are registered on this agent)".into();
59 }
60 let closest = names
61 .iter()
62 .map(|n| (edit_distance(wanted, n), *n))
63 .filter(|(d, n)| *d * 3 <= n.len().max(wanted.len()))
66 .min_by_key(|(d, _)| *d)
67 .map(|(_, n)| n);
68 let available = names.join(", ");
69 match closest {
70 Some(c) => format!(" — did you mean `{c}`? available tools: {available}"),
71 None => format!(" — available tools: {available}"),
72 }
73 }
74
75 pub fn risk(&self, name: &str) -> Option<ToolRisk> {
77 self.tools.get(name).map(|t| t.risk())
78 }
79
80 pub fn len(&self) -> usize {
81 self.tools.len()
82 }
83 pub fn is_empty(&self) -> bool {
84 self.tools.is_empty()
85 }
86}
87
88fn edit_distance(a: &str, b: &str) -> usize {
92 let b_chars: Vec<char> = b.chars().collect();
93 let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
94 let mut cur = vec![0usize; b_chars.len() + 1];
95 for (i, ca) in a.chars().enumerate() {
96 cur[0] = i + 1;
97 for (j, cb) in b_chars.iter().enumerate() {
98 let cost = usize::from(ca != *cb);
99 cur[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1);
100 }
101 std::mem::swap(&mut prev, &mut cur);
102 }
103 prev[b_chars.len()]
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use async_trait::async_trait;
110 use harness_core::{ToolRisk, ToolSchema};
111
112 struct Named(&'static str);
113 #[async_trait]
114 impl Tool for Named {
115 fn name(&self) -> &str {
116 self.0
117 }
118 fn schema(&self) -> &ToolSchema {
119 static S: std::sync::OnceLock<ToolSchema> = std::sync::OnceLock::new();
120 S.get_or_init(|| ToolSchema {
121 name: "x".into(),
122 description: String::new(),
123 input: serde_json::json!({"type": "object"}),
124 })
125 }
126 fn risk(&self) -> ToolRisk {
127 ToolRisk::ReadOnly
128 }
129 async fn invoke(
130 &self,
131 _a: serde_json::Value,
132 _w: &mut World,
133 ) -> Result<ToolResult, ToolError> {
134 unreachable!("never dispatched in these tests")
135 }
136 }
137
138 fn registry() -> ToolRegistry {
139 let mut r = ToolRegistry::new();
140 for n in ["read_file", "write_file", "list_dir", "grep"] {
141 r.insert(Arc::new(Named(n)));
142 }
143 r
144 }
145
146 #[test]
149 fn a_near_miss_is_corrected_by_name() {
150 let hint = registry().not_found_hint("read_files");
151 assert!(hint.contains("did you mean `read_file`"), "{hint}");
152 assert!(hint.contains("write_file"), "and lists the rest: {hint}");
153 }
154
155 #[test]
159 fn an_unrelated_name_gets_no_guess() {
160 let hint = registry().not_found_hint("book_flight");
161 assert!(!hint.contains("did you mean"), "{hint}");
162 assert!(hint.contains("available tools: grep, list_dir"), "{hint}");
163 }
164
165 #[test]
166 fn an_empty_registry_says_so() {
167 let hint = ToolRegistry::new().not_found_hint("anything");
168 assert!(hint.contains("no tools are registered"), "{hint}");
169 }
170
171 #[tokio::test]
173 async fn dispatch_surfaces_the_hint_in_the_error() {
174 let ws = std::env::temp_dir().join(format!("registry-nf-{}", std::process::id()));
175 std::fs::create_dir_all(&ws).unwrap();
176 let mut world = harness_context::default_world(&ws);
177 let err = registry()
178 .dispatch(
179 &Action {
180 tool: "read_fil".into(),
181 call_id: "1".into(),
182 args: serde_json::json!({}),
183 },
184 &mut world,
185 )
186 .await
187 .unwrap_err();
188 let _ = std::fs::remove_dir_all(&ws);
189 let msg = err.to_string();
190 assert!(msg.contains("read_fil"), "{msg}");
191 assert!(msg.contains("did you mean `read_file`"), "{msg}");
192 }
193}