Skip to main content

harness_loop/
registry.rs

1//! A tiny name-keyed tool registry used by `AgentLoop`.
2
3use 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    /// Tool schemas in a **stable, name-sorted order**. Deterministic ordering
22    /// keeps the request's `tools` block byte-identical across turns, which is
23    /// what lets a provider's prefix cache (e.g. DeepSeek) hit — a `HashMap`'s
24    /// arbitrary iteration order would silently break it.
25    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    /// The correction appended to a "tool not found" error.
48    ///
49    /// A bare "tool `read_files` not found" is the model's only clue, so the next
50    /// turn is another guess — the failure costs a whole round trip, often more
51    /// than one, and a small model can spend the whole budget circling a name it
52    /// nearly had. Naming the nearest tool, then listing the rest, turns that
53    /// into a single-turn correction.
54    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            // Only offer a correction that is plausibly a typo of what was asked;
64            // suggesting `grep` for `book_flight` is worse than suggesting nothing.
65            .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    /// The risk class of a tool by name (used to decide parallel-safe dispatch).
76    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
88/// Levenshtein distance, iterative with a single row. Small alphabets, short
89/// strings — a tool registry is a handful of names, so the naive version is
90/// well inside its budget.
91fn 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    /// The commonest miss is a near-miss — a plural, a tense, a separator. The
147    /// model gets one line back and has to decide the next turn from it.
148    #[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    /// A name nothing like the registry gets the list, not a misleading guess:
156    /// pointing at `grep` for `book_flight` sends the model somewhere wrong with
157    /// confidence, which costs more than saying nothing.
158    #[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    /// The hint is part of the error the loop feeds back, not a side channel.
172    #[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}