1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
//! Tool-name self-heal.
//!
//! Weaker models sometimes call a tool by a near-miss name they invented
//! rather than the registered one — e.g. `tg_send_message` for the real
//! `telegram_send` (observed 2026-06-07, issue #176). The tool schema IS
//! in every request, so the model knew it wanted to send a Telegram
//! message; it just guessed the wrong identifier.
//!
//! This mirrors the per-parameter `PARAM_ALIASES` heal in `registry.rs`
//! but at the tool-name level: map a requested-but-unknown tool name to
//! the closest registered tool so the call routes instead of erroring.
//!
//! Conservative by design — a wrong route could fire an unintended (maybe
//! destructive) tool, so a heal only happens on a UNIQUE, high-confidence
//! match. When in doubt, return `None` and let the caller surface the
//! normal "tool not found" error.
/// Common abbreviations models use in invented tool names. Expanded before
/// token matching so `tg` lines up with `telegram`, `msg` with `message`, etc.
const ABBREVIATIONS: &[(&str, &str)] = &[
("tg", "telegram"),
("tgram", "telegram"),
("dc", "discord"),
("wa", "whatsapp"),
("msg", "message"),
("img", "image"),
("vid", "video"),
];
/// Resolve a requested-but-unknown tool name to the closest registered
/// tool, or `None` when there is no confident, unambiguous match.
pub fn resolve_tool_name(requested: &str, registered: &[String]) -> Option<String> {
if requested.is_empty() || registered.is_empty() {
return None;
}
// 0. Exact (defensive — the caller usually checked already).
if let Some(r) = registered.iter().find(|r| r.as_str() == requested) {
return Some(r.clone());
}
// 1. Normalized-exact: lowercase + strip non-alphanumerics. Catches
// `tg-send` vs `tg_send`, casing, and stray punctuation. Must be
// unique to heal.
let req_norm = normalize(requested);
let norm_matches: Vec<&String> = registered
.iter()
.filter(|r| normalize(r) == req_norm)
.collect();
if norm_matches.len() == 1 {
return Some(norm_matches[0].clone());
}
// 2. Token overlap with abbreviation expansion. A registered tool
// matches when ALL of its (expanded) name tokens appear in the
// requested (expanded) tokens AND it has at least two tokens — so
// `telegram_send` matches `tg_send_message`
// (telegram, send ⊆ telegram, send, message) but `send_photo` does
// NOT match `telegram_send` (telegram absent). Pick the most-
// specific candidate; bail on a tie (ambiguous).
let req_tokens = expand_tokens(requested);
let mut candidates: Vec<&String> = Vec::new();
let mut best_len = 0usize;
for r in registered {
let r_tokens = expand_tokens(r);
if r_tokens.len() >= 2 && r_tokens.iter().all(|t| req_tokens.contains(t)) {
match r_tokens.len().cmp(&best_len) {
std::cmp::Ordering::Greater => {
best_len = r_tokens.len();
candidates = vec![r];
}
std::cmp::Ordering::Equal => candidates.push(r),
std::cmp::Ordering::Less => {}
}
}
}
if candidates.len() == 1 {
return Some(candidates[0].clone());
}
// 3. Typo fallback: small edit distance on the normalized names, must
// be unique. Catches `telegram_sned` → `telegram_send`. Budget is
// ~1 edit per 6 chars (min 1, cap 2) so unrelated names don't match.
let limit = (req_norm.len() / 6).clamp(1, 2);
let close: Vec<&String> = registered
.iter()
.filter(|r| levenshtein(&normalize(r), &req_norm) <= limit)
.collect();
if close.len() == 1 {
return Some(close[0].clone());
}
None
}
/// Lowercase + keep only ASCII alphanumerics.
fn normalize(s: &str) -> String {
s.to_lowercase()
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect()
}
/// Split a tool name into lowercase tokens on `_`/`-`/other separators and
/// camelCase boundaries, then expand known abbreviations.
fn expand_tokens(s: &str) -> Vec<String> {
let mut tokens: Vec<String> = Vec::new();
let mut cur = String::new();
let mut prev_was_lower_or_digit = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() {
// camelCase boundary: lower→Upper starts a new token.
if c.is_ascii_uppercase() && prev_was_lower_or_digit && !cur.is_empty() {
tokens.push(std::mem::take(&mut cur));
}
cur.push(c.to_ascii_lowercase());
prev_was_lower_or_digit = c.is_ascii_lowercase() || c.is_ascii_digit();
} else if !cur.is_empty() {
tokens.push(std::mem::take(&mut cur));
prev_was_lower_or_digit = false;
}
}
if !cur.is_empty() {
tokens.push(cur);
}
tokens
.into_iter()
.map(|t| {
ABBREVIATIONS
.iter()
.find(|(abbr, _)| *abbr == t)
.map(|(_, full)| full.to_string())
.unwrap_or(t)
})
.collect()
}
/// Classic Levenshtein edit distance.
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for (i, &ca) in a.iter().enumerate() {
cur[0] = i + 1;
for (j, &cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}