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:
co-authored by
Claude Opus 4.7
parent
9d0c5696cd
commit
c4d888082d
+23
-3
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user