fix(mcp): alias common wrong action names + "did you mean" suggestions

Agents call() into the bridge with action names they guess from prior MCP
conventions or their training data — e.g. `read_channel`, `search`,
`my_status`, `read_dm`, `read_article`. Each produced a useless
"unknown action: X" WARN and no progress.

This change:
- Adds a small alias map (bridgeActionAliases) for observed wrong names
  that have a single unambiguous bridge equivalent:
    read_channel → get_channel_messages
    search       → search_messages
    read_dm      → read_inbox
    my_status    → read_inbox
    read_article → get_article
- Adds bridgeTopLevelOnly for wrong names whose real implementation lives
  as a top-level MCP tool, not a bridge action (rewrite_core_memory →
  memory_rewrite_core); the error now tells the agent to invoke the
  top-level tool instead of failing silently.
- For everything else, the default error includes a "did you mean"
  suggestion computed via substring match + Levenshtein (threshold 2-3)
  against the known bridge actions. Pure Go, no deps.

Tests cover each alias, the top-level-tool hint, "did you mean"
suggestions for close typos, no suggestion for distant strings, and the
Levenshtein helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Algis Dumbris
2026-05-13 07:00:16 +03:00
co-authored by Claude Opus 4.7
parent a2ea7cc516
commit e59fb5a1e2
2 changed files with 321 additions and 0 deletions
+151
View File
@@ -67,8 +67,43 @@ func NewServiceBridge(
}
}
// bridgeActionAliases maps observed wrong action names that agents have called
// to the real bridge action they probably meant. This is a small, hand-curated
// whitelist of guesses seen in production logs — not a fuzzy-match layer.
// Keep entries minimal and only add names that have a single unambiguous target.
var bridgeActionAliases = map[string]string{
// read_channel → fetch messages from a channel (requires channel_id/name)
"read_channel": "get_channel_messages",
// search → unified search over messages
"search": "search_messages",
// read_dm → DMs land in the inbox
"read_dm": "read_inbox",
// my_status → no exact bridge equivalent; agents probing for "what's new"
// most often want read_inbox. (The top-level MCP `my_status` tool is the
// canonical way; this alias keeps call() from failing.)
"my_status": "read_inbox",
// read_article → wiki retrieval
"read_article": "get_article",
}
// bridgeTopLevelOnly lists action names that exist as top-level MCP tools
// (registered on the MCP server), not as call() bridge actions. Agents that
// invoke these via call() / execute() get a targeted error pointing them at
// the real tool rather than a generic "unknown action".
var bridgeTopLevelOnly = map[string]string{
"rewrite_core_memory": "memory_rewrite_core",
}
// Call dispatches an action by name to the appropriate service method.
func (b *ServiceBridge) Call(ctx context.Context, actionName string, args map[string]any) (any, error) {
// Apply aliases for common wrong-name guesses observed in agent logs.
if real, ok := bridgeActionAliases[actionName]; ok {
actionName = real
}
// Catch wrong guesses that map to top-level MCP tools (not bridge actions).
if real, ok := bridgeTopLevelOnly[actionName]; ok {
return nil, fmt.Errorf("'%s' is a top-level MCP tool, not a call() action; invoke it as a tool directly (real name: %s)", actionName, real)
}
switch actionName {
// --- Messaging ---
case "read_inbox":
@@ -173,10 +208,126 @@ func (b *ServiceBridge) Call(ctx context.Context, actionName string, args map[st
return b.callQueryReputation(ctx, args)
default:
if suggestion := suggestBridgeAction(actionName); suggestion != "" {
return nil, fmt.Errorf("unknown action: %s (did you mean: %s)", actionName, suggestion)
}
return nil, fmt.Errorf("unknown action: %s", actionName)
}
}
// knownBridgeActions enumerates every action name routable through Call().
// Kept in sync with the switch in Call() by hand — there are not many. Used
// only to power "did you mean" suggestions when an unknown action arrives.
var knownBridgeActions = []string{
// Messaging
"read_inbox", "claim_messages", "mark_done", "search_messages",
"discover_agents", "send_message",
// Channels
"create_channel", "join_channel", "leave_channel", "list_channels",
"invite_to_channel", "kick_from_channel", "get_channel_messages",
"send_channel_message", "update_channel",
// Swarm
"post_task", "bid_task", "accept_bid", "complete_task", "list_tasks",
// Attachments
"upload_attachment", "download_attachment",
// Reactions
"react", "unreact", "get_reactions", "list_by_state",
// Threads
"get_replies",
// Trust
"get_trust",
// SQL Query
"query",
// Wiki
"create_article", "get_article", "update_article", "list_articles",
"get_backlinks",
// Marketplace
"post_auction", "bid", "award", "mark_task_done", "read_skill_card",
"query_reputation",
}
// 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.
func suggestBridgeAction(name string) string {
if name == "" {
return ""
}
lower := strings.ToLower(name)
// 1. Substring match — common when agents drop or add a prefix/suffix.
for _, candidate := range knownBridgeActions {
if strings.Contains(candidate, lower) || strings.Contains(lower, candidate) {
return candidate
}
}
// 2. Levenshtein on full names.
threshold := 3
if len(name) <= 6 {
threshold = 2
}
bestDist := threshold + 1
best := ""
for _, candidate := range knownBridgeActions {
d := levenshtein(lower, candidate)
if d < bestDist {
bestDist = d
best = candidate
}
}
if bestDist <= threshold {
return best
}
return ""
}
// 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 {
if a == b {
return 0
}
if len(a) == 0 {
return len(b)
}
if len(b) == 0 {
return len(a)
}
// Ensure a is the shorter — minimises row width.
if len(a) > len(b) {
a, b = b, a
}
prev := make([]int, len(a)+1)
curr := make([]int, len(a)+1)
for i := 0; i <= len(a); i++ {
prev[i] = i
}
for j := 1; j <= len(b); j++ {
curr[0] = j
for i := 1; i <= len(a); i++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
del := prev[i] + 1
ins := curr[i-1] + 1
sub := prev[i-1] + cost
m := del
if ins < m {
m = ins
}
if sub < m {
m = sub
}
curr[i] = m
}
prev, curr = curr, prev
}
return prev[len(a)]
}
// --- Messaging implementations ---
func (b *ServiceBridge) callSendMessage(ctx context.Context, args map[string]any) (any, error) {
+170
View File
@@ -3,6 +3,7 @@ package mcp
import (
"context"
"log/slog"
"strings"
"testing"
_ "modernc.org/sqlite"
@@ -486,3 +487,172 @@ func TestBridge_React_Toggle_Removes_WorkflowState(t *testing.T) {
}
var _ = storage.RunMigrations
// TestBridge_ActionAliases verifies that each observed wrong action name from
// production logs resolves to the correct real action. We don't validate the
// full result payload — only that the call succeeds (i.e. dispatch reached the
// real handler) rather than returning "unknown action".
func TestBridge_ActionAliases(t *testing.T) {
tests := []struct {
alias string
realName string
args map[string]any
setupCh string // optional: channel name to create+join before the call
expectErr string // optional: substring of expected error (when call reaches real handler but fails for unrelated reasons)
}{
{
alias: "search",
realName: "search_messages",
args: map[string]any{"query": "anything"},
},
{
alias: "my_status",
realName: "read_inbox",
args: map[string]any{"limit": 5},
},
{
alias: "read_dm",
realName: "read_inbox",
args: map[string]any{"limit": 5},
},
{
alias: "read_channel",
realName: "get_channel_messages",
args: map[string]any{"channel_name": "alias-ch"},
setupCh: "alias-ch",
},
{
alias: "read_article",
realName: "get_article",
args: map[string]any{"slug": "anything"},
expectErr: "wiki not available", // bridge has no wikiService → dispatch reached real handler
},
}
for _, tt := range tests {
t.Run(tt.alias, func(t *testing.T) {
bridge, _, _, channelService := newTestBridge(t)
ctx := context.Background()
if tt.setupCh != "" {
ch, err := channelService.CreateChannel(ctx, channels.CreateChannelRequest{
Name: tt.setupCh, Type: "standard", CreatedBy: "agent-a",
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
if err := channelService.JoinChannel(ctx, ch.ID, "agent-a"); err != nil {
t.Fatalf("join channel: %v", err)
}
}
_, err := bridge.Call(ctx, tt.alias, tt.args)
if tt.expectErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.expectErr)
}
if !strings.Contains(err.Error(), tt.expectErr) {
t.Fatalf("error %q does not contain %q", err.Error(), tt.expectErr)
}
if strings.Contains(err.Error(), "unknown action") {
t.Fatalf("alias %q should have been resolved, got unknown-action error: %v", tt.alias, err)
}
return
}
if err != nil {
t.Fatalf("alias %q (real=%s) failed: %v", tt.alias, tt.realName, err)
}
})
}
}
// TestBridge_TopLevelToolHint verifies that wrong-guess names which map to
// top-level MCP tools (not bridge actions) produce a targeted hint pointing
// at the real tool, instead of a generic "unknown action" error.
func TestBridge_TopLevelToolHint(t *testing.T) {
bridge, _, _, _ := newTestBridge(t)
ctx := context.Background()
_, err := bridge.Call(ctx, "rewrite_core_memory", map[string]any{})
if err == nil {
t.Fatal("expected error for top-level-tool wrong-guess")
}
msg := err.Error()
if !strings.Contains(msg, "top-level MCP tool") {
t.Errorf("error should mention top-level MCP tool, got: %v", err)
}
if !strings.Contains(msg, "memory_rewrite_core") {
t.Errorf("error should name the real tool memory_rewrite_core, got: %v", err)
}
}
// TestBridge_UnknownAction_Suggestion verifies that an unknown action that is
// close to a real one (Levenshtein-wise) returns a "did you mean" suggestion.
func TestBridge_UnknownAction_Suggestion(t *testing.T) {
bridge, _, _, _ := newTestBridge(t)
ctx := context.Background()
tests := []struct {
input string
wantContains string
}{
// 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)
{input: "list_channel", wantContains: "list_channels"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
_, err := bridge.Call(ctx, tt.input, map[string]any{})
if err == nil {
t.Fatalf("expected error for %q", tt.input)
}
if !strings.Contains(err.Error(), "did you mean") {
t.Errorf("error should contain 'did you mean', got: %v", err)
}
if !strings.Contains(err.Error(), tt.wantContains) {
t.Errorf("error should suggest %q, got: %v", tt.wantContains, err)
}
})
}
}
// TestBridge_UnknownAction_NoSuggestion verifies that a truly distant unknown
// action returns the plain "unknown action" error without a misleading
// suggestion.
func TestBridge_UnknownAction_NoSuggestion(t *testing.T) {
bridge, _, _, _ := newTestBridge(t)
ctx := context.Background()
_, err := bridge.Call(ctx, "xyzzy_quux_frobnicate", map[string]any{})
if err == nil {
t.Fatal("expected error")
}
if strings.Contains(err.Error(), "did you mean") {
t.Errorf("distant action should not get a suggestion, got: %v", err)
}
}
func TestLevenshtein(t *testing.T) {
tests := []struct {
a, b string
want int
}{
{"", "", 0},
{"abc", "abc", 0},
{"", "abc", 3},
{"abc", "", 3},
{"kitten", "sitting", 3},
{"send", "sned", 2},
{"list_channel", "list_channels", 1},
}
for _, tt := range tests {
got := levenshtein(tt.a, tt.b)
if got != tt.want {
t.Errorf("levenshtein(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
}
}
}