fix(mcp): gate suggester on leading verb match

Production v0.18.1 logs showed read_message → "did you mean: send_message"
— two edits away by Levenshtein, but the opposite intent. An agent asking
to READ a single message getting nudged toward SEND is actively
misleading. Same trap was active for anything sharing a verb-less suffix
like _message, _channel, _task.

Constrain the Levenshtein candidates to those whose leading verb (token
before the first underscore) matches the input verb exactly. Substring
matching is unchanged. Drop the now-stale sned_message typo test case
(cross-verb typo correction is no longer in scope) and add a regression
test covering read_message, delete_message, fetch_channel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Algis Dumbris
2026-05-13 21:39:43 +03:00
co-authored by Claude Opus 4.7
parent 9d0c5696cd
commit c4d888082d
2 changed files with 53 additions and 6 deletions
+23 -3
View File
@@ -250,8 +250,12 @@ var knownBridgeActions = []string{
// suggestBridgeAction returns the closest known action name to `name`, or ""
// if nothing is plausibly close. Strategy: cheap prefix/substring check first,
// then Levenshtein within distance 3. Distance threshold scales with the
// length of the input so very short strings don't false-match.
// then Levenshtein within distance 3 — but Levenshtein matches must share the
// leading verb (the token before the first underscore) with the candidate.
// Without that constraint, e.g. `read_message` is two edits from `send_message`
// and gets suggested, which is the opposite intent and actively misleading.
// Distance threshold scales with the length of the input so very short strings
// don't false-match.
func suggestBridgeAction(name string) string {
if name == "" {
return ""
@@ -265,14 +269,19 @@ func suggestBridgeAction(name string) string {
}
}
// 2. Levenshtein on full names.
// 2. Levenshtein on full names, gated on a matching leading verb so
// `read_message` is not "fixed" by suggesting `send_message`.
threshold := 3
if len(name) <= 6 {
threshold = 2
}
inputVerb := leadingVerb(lower)
bestDist := threshold + 1
best := ""
for _, candidate := range knownBridgeActions {
if leadingVerb(candidate) != inputVerb {
continue
}
d := levenshtein(lower, candidate)
if d < bestDist {
bestDist = d
@@ -285,6 +294,17 @@ func suggestBridgeAction(name string) string {
return ""
}
// leadingVerb returns the substring before the first underscore, or the
// whole string if there is no underscore. Used to gate Levenshtein
// suggestions so a shared suffix (like `_message`) doesn't pair `read_…`
// with `send_…`.
func leadingVerb(s string) string {
if i := strings.IndexByte(s, '_'); i >= 0 {
return s[:i]
}
return s
}
// levenshtein computes the Levenshtein edit distance between a and b using
// a single rolling row of O(min(len)) space. Pure Go, no deps.
func levenshtein(a, b string) int {
+30 -3
View File
@@ -612,10 +612,10 @@ func TestBridge_UnknownAction_Suggestion(t *testing.T) {
}{
// substring hit: "send" is contained in "send_message"
{input: "send", wantContains: "send_message"},
// Levenshtein: typo "sned_message" → "send_message" (distance 2)
{input: "sned_message", wantContains: "send_message"},
// Levenshtein: "list_channel" → "list_channels" (distance 1)
// Levenshtein within a shared verb: "list_channel" → "list_channels" (distance 1)
{input: "list_channel", wantContains: "list_channels"},
// Levenshtein within a shared verb: "get_channel_message" → "get_channel_messages"
{input: "get_channel_message", wantContains: "get_channel_messages"},
}
for _, tt := range tests {
@@ -650,6 +650,33 @@ func TestBridge_UnknownAction_NoSuggestion(t *testing.T) {
}
}
// TestBridge_UnknownAction_NoCrossVerbSuggestion guards against the suggester
// pairing actions that share a suffix but have opposite intent — e.g.
// `read_message` is two edits from `send_message`, but suggesting "send" to an
// agent that asked to read is actively misleading. The leading-verb gate in
// suggestBridgeAction must prevent this.
func TestBridge_UnknownAction_NoCrossVerbSuggestion(t *testing.T) {
bridge, _, _, _ := newTestBridge(t)
ctx := context.Background()
cases := []string{
"read_message", // would have suggested send_message (distance 2)
"delete_message", // would have suggested send_message (distance 3)
"fetch_channel", // unrelated verb; must not suggest send/list/get
}
for _, in := range cases {
t.Run(in, func(t *testing.T) {
_, err := bridge.Call(ctx, in, map[string]any{})
if err == nil {
t.Fatalf("expected error for %q", in)
}
if strings.Contains(err.Error(), "did you mean") {
t.Errorf("cross-verb suggestion leaked for %q: %v", in, err)
}
})
}
}
func TestLevenshtein(t *testing.T) {
tests := []struct {
a, b string