diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 0fa587df0..273aaae9a 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -171,6 +171,7 @@ type Agent struct { toolCallBudget *toolCallBudget // per-session tool invocation limit (action-level guardrail) commandCache *commandCache // deterministic build/test command result caching effectLedger *effectLedgerState // side-effect ledger: duplicate-effect awareness on retries (LangEffect/RAC-inspired) + toolSearch *toolSearchState // deferred MCP tool schema disclosure (Anthropic Tool Search-inspired) postEditVerify postEditVerifyState // tracks source-code edits to inject periodic verification hints planner *planState // agent-side auto task decomposition (Devin/Claude Code-inspired) todoStaleness *todoStalenessState // mid-run stale todo detection (plan abandonment awareness) @@ -370,6 +371,7 @@ func NewAgent(p provider.Provider, tools *tool.Registry, systemPrompt string, ma toolCallBudget: newToolCallBudget(), commandCache: newCommandCache(), effectLedger: newEffectLedger(), + toolSearch: newToolSearchState(), errorClassifier: NewErrorClassifier(), planner: newPlanState(), todoStaleness: newTodoStalenessState(), @@ -1519,6 +1521,10 @@ func (a *Agent) RunStreamWithContent(ctx context.Context, content []provider.Con a.maybeInjectRatchetRules() transientCompactWarned := false toolDefs := a.tools.ToDefinitions() + a.toolSearch.init(toolDefs) + if a.toolSearch.enabled { + debug.Log("agent", "tool search: %d MCP tool schemas deferred behind %s", len(a.toolSearch.deferred), ToolSearchToolName) + } if cm, ok := a.contextManager.(interface{ SetToolDefinitionOverhead(int) }); ok { cm.SetToolDefinitionOverhead(estimateToolDefinitionOverhead(toolDefs)) } @@ -2067,7 +2073,10 @@ func (a *Agent) RunStreamWithContent(ctx context.Context, content []provider.Con // contexts), and description truncation misleads the model into // guessing tool behavior from names. All registered tools are sent // with their full, unmodified descriptions. - activeToolDefs := toolDefs + activeToolDefs := a.toolSearch.activeDefs(toolDefs) + if cm, ok := a.contextManager.(interface{ SetToolDefinitionOverhead(int) }); ok { + cm.SetToolDefinitionOverhead(estimateToolDefinitionOverhead(activeToolDefs)) + } // #1672: read the manager snapshot HERE, at the consumption point. // msgs is initialized once before the loop and only refreshed at // 37 conditional sites; the recovery nudges and final gates Add to diff --git a/internal/agent/agent_tool.go b/internal/agent/agent_tool.go index 3555c444e..042fad195 100644 --- a/internal/agent/agent_tool.go +++ b/internal/agent/agent_tool.go @@ -269,10 +269,22 @@ func (a *Agent) executeTool(ctx context.Context, tc provider.ToolCallDelta) tool )) } + // MCP Tool Search meta-tool: handled agent-side (not registry-backed) so + // it never appears in ToDefinitions and activation state stays per-agent. + if tc.Name == ToolSearchToolName && a.toolSearch != nil { + return a.toolSearch.executeResult(tc.Arguments) + } + t, ok := a.tools.Get(tc.Name) if !ok { return tool.Result{Content: tool.FormatUnknownToolError(a.tools, tc.Name), IsError: true} } + // Deferred MCP tool called by name (history carry-over or model prior): + // activate its schema so subsequent requests stay consistent with tools + // the conversation already references, then execute normally. + if a.toolSearch != nil && a.toolSearch.maybeAutoActivate(tc.Name) { + debug.Log("agent", "tool search: auto-activated schema for %s called by name", tc.Name) + } // JSON argument repair: many OpenAI-compatible backends (vLLM, LiteLLM, // goolm) and weaker models produce arguments that are *almost* valid JSON diff --git a/internal/agent/error_cascade.go b/internal/agent/error_cascade.go index ed84d0f8d..b603d3e98 100644 --- a/internal/agent/error_cascade.go +++ b/internal/agent/error_cascade.go @@ -294,9 +294,9 @@ func (e *errorCascadeState) recordError(toolName, content string) string { case 3: e.firedTier[rootKey] = tier guidance = fmt.Sprintf( - "[Error Cascade: ABORT] %d tool failures share root cause %s '%s'. "+ + "[Error Cascade: ABORT] %d tool failures share root cause %s %q. "+ "The current approach is not working -- every operation touching this "+ - "%s fails. STOP attempting operations on '%s'. Instead: (1) re-read "+ + "%s fails. STOP attempting operations on %q. Instead: (1) re-read "+ "the %s from scratch to understand its current state, (2) check if "+ "another process or agent modified it, (3) consider reverting to a "+ "known-good state, or (4) escalate to the user if this is an "+ @@ -306,9 +306,9 @@ func (e *errorCascadeState) recordError(toolName, content string) string { case 2: e.firedTier[rootKey] = tier guidance = fmt.Sprintf( - "[Error Cascade: ROOT CAUSE] %d tool failures share root cause %s '%s'. "+ + "[Error Cascade: ROOT CAUSE] %d tool failures share root cause %s %q. "+ "These are NOT independent errors -- they all stem from the same "+ - "underlying problem with this %s. FIX '%s' first before attempting "+ + "underlying problem with this %s. FIX %q first before attempting "+ "any other dependent operations. Common root causes: syntax error, "+ "missing import, incorrect type, renamed symbol, or file corruption.", count, rootType, rootKey, rootType, rootKey, @@ -316,9 +316,9 @@ func (e *errorCascadeState) recordError(toolName, content string) string { case 1: e.firedTier[rootKey] = tier guidance = fmt.Sprintf( - "[Error Cascade] %d tool failures share root cause %s '%s'. "+ + "[Error Cascade] %d tool failures share root cause %s %q. "+ "Multiple errors are clustering around this %s -- they likely share "+ - "a common root cause. Focus on fixing '%s' first; fixing it may "+ + "a common root cause. Focus on fixing %q first; fixing it may "+ "resolve several downstream errors at once.", count, rootType, rootKey, rootType, rootKey, ) diff --git a/internal/agent/tool_search.go b/internal/agent/tool_search.go new file mode 100644 index 000000000..d8ddd691b --- /dev/null +++ b/internal/agent/tool_search.go @@ -0,0 +1,264 @@ +package agent + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strings" + "sync" + + "github.com/topcheer/ggcode/internal/debug" + "github.com/topcheer/ggcode/internal/provider" + "github.com/topcheer/ggcode/internal/tool" +) + +// MCP Tool Search: deferred disclosure of MCP tool schemas. +// +// Inspired by Anthropic's "Tool Search Tool" (Advanced Tool Use beta, +// advanced-tool-use-2025-11-20; docs.en/agents-and-tools/tool-use/tool-search-tool): +// instead of paying the context cost of every connected MCP server's tool +// schemas on every request, MCP tools (mcp__server__tool naming) are hidden +// behind a single meta-tool. The model searches on demand; matched schemas +// are returned in the tool result AND activated, so the next request carries +// core tools + the meta-tool + every activated schema. Activation is strictly +// monotonic: schemas are only ever ADDED mid-run, never removed — the failure +// mode that got dynamic tool pruning reverted (see the comment at the +// activeToolDefs site in agent.go) cannot recur. +// +// Built-in tools are never deferred: file edit/read/search/run stay fully +// available from the first turn, so the extra discovery round-trip only ever +// applies to optional MCP integrations. The feature activates automatically +// when the registry carries >= toolSearchThreshold MCP tools (smaller setups +// keep the zero-round-trip behavior) and can be disabled with +// GGCODE_TOOL_SEARCH=off. + +const ( + // mcpToolPrefix is the registry naming convention for MCP-provided tools. + mcpToolPrefix = "mcp__" + + // ToolSearchToolName is the synthetic meta-tool name. It is handled + // agent-side (not registry-backed) so it never appears in + // Registry.ToDefinitions and activation state stays per-agent. + ToolSearchToolName = "tool_search" + + // toolSearchThreshold is the minimum number of MCP tools before schemas + // are deferred. Below this the full list is sent (fewer round-trips beats + // token savings on small registries). + toolSearchThreshold = 20 + + // toolSearchMaxResults caps schemas returned per search to bound the + // tool_result size. + toolSearchMaxResults = 10 +) + +// toolSearchState tracks deferred MCP tool schemas and their activation. +// Safe for concurrent use: parallel tool execution may activate tools while +// the main loop builds the next request's tool list. +type toolSearchState struct { + mu sync.Mutex + enabled bool + deferred map[string]provider.ToolDefinition // all MCP tool defs from the registry + activated map[string]bool // schemas included in requests (monotonic within a conversation) +} + +func newToolSearchState() *toolSearchState { + return &toolSearchState{ + deferred: make(map[string]provider.ToolDefinition), + activated: make(map[string]bool), + } +} + +// init refreshes the deferred set from the current registry snapshot. +// Previously activated names survive re-init (a new run in the same +// conversation keeps history consistent with the schemas it references); +// names that disappeared from the registry are pruned. +func (s *toolSearchState) init(defs []provider.ToolDefinition) { + deferred := make(map[string]provider.ToolDefinition) + for _, d := range defs { + if strings.HasPrefix(d.Name, mcpToolPrefix) { + deferred[d.Name] = d + } + } + s.mu.Lock() + defer s.mu.Unlock() + s.deferred = deferred + for name := range s.activated { + if _, ok := deferred[name]; !ok { + delete(s.activated, name) + } + } + s.enabled = len(deferred) >= toolSearchThreshold && !toolSearchEnvDisabled() +} + +func toolSearchEnvDisabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("GGCODE_TOOL_SEARCH"))) { + case "0", "off", "false", "no": + return true + } + return false +} + +// activeDefs returns the tool definition list for the next request. With the +// feature disabled (or nil receiver) it returns the input unchanged. When +// enabled it returns core tools + the tool_search meta-tool + activated MCP +// schemas, in deterministic order. +func (s *toolSearchState) activeDefs(all []provider.ToolDefinition) []provider.ToolDefinition { + if s == nil || !s.enabled { + return all + } + s.mu.Lock() + defer s.mu.Unlock() + out := make([]provider.ToolDefinition, 0, len(all)+1) + for _, d := range all { + if !strings.HasPrefix(d.Name, mcpToolPrefix) { + out = append(out, d) + } + } + out = append(out, toolSearchDefinition()) + activated := make([]string, 0, len(s.activated)) + for name := range s.activated { + activated = append(activated, name) + } + sort.Strings(activated) + for _, name := range activated { + out = append(out, s.deferred[name]) + } + return out +} + +// maybeAutoActivate promotes a deferred MCP tool whose schema was never sent +// but which the model is calling anyway (history carry-over or outside +// knowledge). Returns true when an activation happened. +func (s *toolSearchState) maybeAutoActivate(name string) bool { + if s == nil || !s.enabled || !strings.HasPrefix(name, mcpToolPrefix) { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + if s.activated[name] { + return false + } + if _, ok := s.deferred[name]; !ok { + return false + } + s.activated[name] = true + return true +} + +// search finds deferred tools whose name+description contain every query +// token (case-insensitive). Empty query returns the deferred catalog head so +// the model can browse without guessing keywords. Matches are activated as a +// side effect. Already-activated tools are omitted (their schemas are already +// in the request). +func (s *toolSearchState) search(query string, limit int) []provider.ToolDefinition { + s.mu.Lock() + defer s.mu.Unlock() + if limit <= 0 { + limit = toolSearchMaxResults + } + if limit > toolSearchMaxResults { + limit = toolSearchMaxResults + } + tokens := strings.Fields(strings.ToLower(query)) + names := make([]string, 0, len(s.deferred)) + for name, d := range s.deferred { + if s.activated[name] { + continue + } + hay := strings.ToLower(name + " " + d.Description) + match := true + for _, tok := range tokens { + if !strings.Contains(hay, tok) { + match = false + break + } + } + if match { + names = append(names, name) + } + } + sort.Strings(names) + if len(names) > limit { + names = names[:limit] + } + out := make([]provider.ToolDefinition, 0, len(names)) + for _, name := range names { + out = append(out, s.deferred[name]) + s.activated[name] = true + } + return out +} + +// executeResult runs the tool_search meta-tool: parse arguments, search, and +// format matched schemas for the model. +func (s *toolSearchState) executeResult(args json.RawMessage) tool.Result { + if s == nil || !s.enabled { + return tool.Result{Content: "tool_search is not active.", IsError: true} + } + var params struct { + Query string `json:"query"` + Limit int `json:"limit"` + } + if len(args) > 0 { + if err := json.Unmarshal(args, ¶ms); err != nil { + return tool.Result{Content: fmt.Sprintf("invalid tool_search arguments: %v", err), IsError: true} + } + } + if strings.TrimSpace(params.Query) == "" && params.Limit <= 0 { + return tool.Result{Content: "tool_search requires a non-empty \"query\" (keywords to match against tool names and descriptions). Use limit<=0 only with a browse intent: {\"query\":\"\",\"limit\":10}.", IsError: true} + } + matches := s.search(params.Query, params.Limit) + if len(matches) == 0 { + s.mu.Lock() + total := len(s.deferred) - len(s.activated) + s.mu.Unlock() + return tool.Result{Content: fmt.Sprintf("No deferred tools match %q. %d hidden tool(s) remain. Try broader keywords, or different server/tool name fragments (tools are named mcp____).", params.Query, total)} + } + type matchJSON struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters json.RawMessage `json:"parameters"` + } + list := make([]matchJSON, 0, len(matches)) + names := make([]string, 0, len(matches)) + for _, m := range matches { + list = append(list, matchJSON{Name: m.Name, Description: m.Description, Parameters: m.Parameters}) + names = append(names, m.Name) + } + body, err := json.MarshalIndent(list, "", " ") + if err != nil { + return tool.Result{Content: fmt.Sprintf("tool_search failed to format results: %v", err), IsError: true} + } + debug.Log("agent", "tool_search %q activated %d schemas: %v", params.Query, len(matches), names) + return tool.Result{Content: fmt.Sprintf( + "Activated %d tool(s). Their full schemas are below and will be included in subsequent requests — call them directly by name:\n\n%s\n\nUse Tool Effectiveness responsibly: prefer the narrowest matching tool; search again if none of these fit.", + len(matches), body, + )} +} + +// toolSearchDefinition builds the synthetic meta-tool definition. +func toolSearchDefinition() provider.ToolDefinition { + params := `{ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Space-separated keywords matched against MCP tool names and descriptions (all keywords must match). E.g. 'github pull request' or 'railway deploy'." + }, + "limit": { + "type": "integer", + "description": "Maximum number of tools to activate per search (1-10, default 10)." + } + }, + "required": ["query"] +}` + return provider.ToolDefinition{ + Name: ToolSearchToolName, + Description: fmt.Sprintf( + "Search and activate deferred MCP tool schemas. MCP server tools beyond the first %d are not loaded into context upfront to save tokens; this tool discovers them by keyword and returns their full schemas. After a match, call the tool directly by its name (e.g. mcp__github__create_issue) — no re-search needed. If you call an MCP tool you already know by name, that works too: it is activated automatically.", + toolSearchThreshold, + ), + Parameters: json.RawMessage(params), + } +} diff --git a/internal/agent/tool_search_test.go b/internal/agent/tool_search_test.go new file mode 100644 index 000000000..caf5d7d39 --- /dev/null +++ b/internal/agent/tool_search_test.go @@ -0,0 +1,286 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "testing" + + "github.com/topcheer/ggcode/internal/provider" + "github.com/topcheer/ggcode/internal/tool" +) + +// fakeMCPTool is a minimal registry-backed MCP-named tool for wiring tests. +type fakeMCPTool struct { + name string +} + +func (f *fakeMCPTool) Name() string { return f.name } +func (f *fakeMCPTool) Description() string { return "fake MCP tool for integration tests" } +func (f *fakeMCPTool) Parameters() json.RawMessage { + return json.RawMessage(`{"type":"object","properties":{}}`) +} +func (f *fakeMCPTool) Execute(_ context.Context, _ json.RawMessage) (tool.Result, error) { + return tool.Result{Content: "ok"}, nil +} + +// newToolSearchTestAgent builds an Agent whose registry carries nMCP MCP tools +// and runs the run-start tool search init exactly as agent.go does. +func newToolSearchTestAgent(t *testing.T, nMCP int) (*Agent, *tool.Registry) { + t.Helper() + reg := tool.NewRegistry() + for i := 0; i < nMCP; i++ { + if err := reg.Register(&fakeMCPTool{name: fmt.Sprintf("mcp__srv__tool_%02d", i)}); err != nil { + t.Fatalf("register fake tool: %v", err) + } + } + a := NewAgent(&mockProvider{}, reg, "sys", 1) + if a == nil { + t.Fatal("NewAgent returned nil") + } + a.toolSearch.init(reg.ToDefinitions()) + return a, reg +} + +// TestAgentExecuteToolDispatchesToolSearch covers the agent_tool.go dispatch: +// the meta-tool is handled agent-side without a registry lookup, and a +// successful search feeds activation state seen by the next request. +func TestAgentExecuteToolDispatchesToolSearch(t *testing.T) { + a, reg := newToolSearchTestAgent(t, toolSearchThreshold) + if !a.toolSearch.enabled { + t.Fatal("expected tool search enabled with threshold MCP tools") + } + res := a.executeTool(context.Background(), provider.ToolCallDelta{ + ID: "call-1", Name: ToolSearchToolName, Arguments: json.RawMessage(`{"query":"tool"}`), + }) + if res.IsError { + t.Fatalf("meta-tool dispatch failed: %s", res.Content) + } + if !strings.Contains(res.Content, "mcp__srv__tool_") { + t.Fatalf("expected schemas in result, got: %s", res.Content) + } + got := a.toolSearch.activeDefs(reg.ToDefinitions()) + nActivated := 0 + for _, d := range got { + if strings.HasPrefix(d.Name, mcpToolPrefix) { + nActivated++ + } + } + if nActivated == 0 { + t.Fatal("search via agent dispatch activated no schemas") + } +} + +// TestAgentExecuteToolAutoActivatesDeferredMCP covers the by-name fallback: +// calling a deferred MCP tool directly executes it AND promotes its schema so +// subsequent requests stay consistent with tools the conversation references. +func TestAgentExecuteToolAutoActivatesDeferredMCP(t *testing.T) { + a, reg := newToolSearchTestAgent(t, toolSearchThreshold+2) + target := "mcp__srv__tool_07" + res := a.executeTool(context.Background(), provider.ToolCallDelta{ + ID: "call-2", Name: target, Arguments: json.RawMessage(`{}`), + }) + if res.IsError || res.Content != "ok" { + t.Fatalf("deferred tool execution failed: %+v", res) + } + found := false + for _, d := range a.toolSearch.activeDefs(reg.ToDefinitions()) { + if d.Name == target { + found = true + } + } + if !found { + t.Fatalf("schema for %s was not auto-activated after by-name call", target) + } +} + +// TestAgentBelowThresholdSendsFullList verifies the send-site behavior end to +// end when tool search is disabled: every registered tool (no meta-tool) is +// included, so small setups keep zero-round-trip semantics. +func TestAgentBelowThresholdSendsFullList(t *testing.T) { + a, reg := newToolSearchTestAgent(t, toolSearchThreshold-1) + if a.toolSearch.enabled { + t.Fatal("tool search must stay disabled below threshold") + } + defs := a.toolSearch.activeDefs(reg.ToDefinitions()) + if len(defs) != toolSearchThreshold-1 { + t.Fatalf("expected %d defs, got %d", toolSearchThreshold-1, len(defs)) + } + for _, d := range defs { + if d.Name == ToolSearchToolName { + t.Fatal("meta-tool must be absent when disabled") + } + } +} + +func toolSearchTestDefs(nMCP int, nCore int) []provider.ToolDefinition { + defs := make([]provider.ToolDefinition, 0, nMCP+nCore) + for i := 0; i < nCore; i++ { + defs = append(defs, provider.ToolDefinition{ + Name: "core_" + strings.Repeat("x", i+1), + Description: "built-in core tool", + Parameters: json.RawMessage(`{"type":"object"}`), + }) + } + servers := []string{"github", "railway", "slack"} + for i := 0; i < nMCP; i++ { + defs = append(defs, provider.ToolDefinition{ + Name: "mcp__" + servers[i%len(servers)] + "__tool" + strings.Repeat("y", i+1), + Description: "MCP tool for pull requests and deploys", + Parameters: json.RawMessage(`{"type":"object","properties":{"q":{"type":"string"}}}`), + }) + } + return defs +} + +func TestToolSearchBelowThresholdKeepsFullList(t *testing.T) { + s := newToolSearchState() + defs := toolSearchTestDefs(toolSearchThreshold-1, 3) + s.init(defs) + if s.enabled { + t.Fatalf("expected disabled below threshold (%d MCP tools)", toolSearchThreshold-1) + } + got := s.activeDefs(defs) + if len(got) != len(defs) { + t.Fatalf("expected unchanged list, got %d of %d", len(got), len(defs)) + } +} + +func TestToolSearchDefersAndActivates(t *testing.T) { + s := newToolSearchState() + defs := toolSearchTestDefs(toolSearchThreshold+5, 3) + s.init(defs) + if !s.enabled { + t.Fatal("expected enabled at/above threshold") + } + got := s.activeDefs(defs) + // core + tool_search meta-tool only; MCP schemas deferred. + if want := toolSearchThreshold + 5 - (toolSearchThreshold + 5) + 3 + 1; len(got) != want { + t.Fatalf("expected %d defs (3 core + meta), got %d", want, len(got)) + } + found := false + for _, d := range got { + if d.Name == ToolSearchToolName { + found = true + } + if strings.HasPrefix(d.Name, mcpToolPrefix) { + t.Fatalf("deferred MCP tool %s leaked into active list", d.Name) + } + } + if !found { + t.Fatal("tool_search meta-tool missing from active list") + } + + res := s.executeResult(json.RawMessage(`{"query":"github pull"}`)) + if res.IsError { + t.Fatalf("unexpected error result: %s", res.Content) + } + if !strings.Contains(res.Content, "mcp__github__") { + t.Fatalf("expected github tools in result, got: %s", res.Content) + } + // Activated schemas now appear in the next request. + got2 := s.activeDefs(defs) + nActivated := 0 + for _, d := range got2 { + if strings.HasPrefix(d.Name, mcpToolPrefix) { + nActivated++ + } + } + if nActivated == 0 { + t.Fatal("search did not activate any schema") + } + // Second search for the same scope returns nothing (already activated). + res2 := s.executeResult(json.RawMessage(`{"query":"github pull"}`)) + if res2.IsError { + t.Fatalf("unexpected error result: %s", res2.Content) + } + if !strings.Contains(res2.Content, "No deferred tools match") { + t.Fatalf("expected no-match on re-search of activated tools, got: %s", res2.Content) + } +} + +func TestToolSearchAutoActivateByName(t *testing.T) { + s := newToolSearchState() + defs := toolSearchTestDefs(toolSearchThreshold, 0) + s.init(defs) + real := "" + for _, d := range defs { + if strings.HasPrefix(d.Name, mcpToolPrefix) { + real = d.Name + break + } + } + if real == "" { + t.Fatal("no MCP fixtures") + } + if !s.maybeAutoActivate(real) { + t.Fatalf("expected activation for %s", real) + } + if s.maybeAutoActivate(real) { + t.Fatal("second activation should be a no-op") + } + if s.maybeAutoActivate("mcp__missing__nope") { + t.Fatal("unknown tool must not activate") + } + if s.maybeAutoActivate("core_x") { + t.Fatal("non-MCP tool must not activate") + } +} + +func TestToolSearchEnvDisabled(t *testing.T) { + t.Setenv("GGCODE_TOOL_SEARCH", "off") + s := newToolSearchState() + defs := toolSearchTestDefs(toolSearchThreshold+10, 1) + s.init(defs) + if s.enabled { + t.Fatal("GGCODE_TOOL_SEARCH=off must disable the feature") + } +} + +func TestToolSearchDeterministicOrder(t *testing.T) { + s := newToolSearchState() + defs := toolSearchTestDefs(toolSearchThreshold+8, 2) + s.init(defs) + s.executeResult(json.RawMessage(`{"query":"","limit":5}`)) + a := s.activeDefs(defs) + b := s.activeDefs(defs) + if len(a) != len(b) { + t.Fatalf("unstable list length: %d vs %d", len(a), len(b)) + } + for i := range a { + if a[i].Name != b[i].Name { + t.Fatalf("unstable order at %d: %s vs %s", i, a[i].Name, b[i].Name) + } + } +} + +func TestToolSearchConcurrentActivation(t *testing.T) { + s := newToolSearchState() + defs := toolSearchTestDefs(toolSearchThreshold+12, 1) + s.init(defs) + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + s.search("tool", 3) + s.activeDefs(defs) + s.maybeAutoActivate("mcp__github__tooly") + }() + } + wg.Wait() + if got := s.activeDefs(defs); len(got) < 1+1 { + t.Fatalf("unexpectedly small active list: %d", len(got)) + } +} + +func TestToolSearchRequiresQuery(t *testing.T) { + s := newToolSearchState() + s.init(toolSearchTestDefs(toolSearchThreshold, 0)) + res := s.executeResult(json.RawMessage(`{}`)) + if !res.IsError { + t.Fatal("empty arguments must produce an error result") + } +}