diff --git a/packages/coding-agent/src/core/extensions/builtin/tool-search/changes.md b/packages/coding-agent/src/core/extensions/builtin/tool-search/changes.md index 6e734276f7..46613f8d7e 100644 --- a/packages/coding-agent/src/core/extensions/builtin/tool-search/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/tool-search/changes.md @@ -1,5 +1,24 @@ # Tool Search Builtin Changes +## 2026-09-04 - Fix the native search tool name and gate injection to first-party Anthropic hosts + +### What changed + +- `ANTHROPIC_TOOL_SEARCH_NAME` is now `tool_search_tool_bm25`, the only `name` the Messages API accepts for `tool_search_tool_bm25_20251119`; the previous `tool_search` was rejected with `400 tools.N.tool_search_tool_bm25_20251119.name: Input should be 'tool_search_tool_bm25'`. +- `AnthropicNativeToolSearchAdapter.applyBeforeRequest` takes the request model (`event.model ?? ctx.model`) instead of the bare `api` string and skips injection unless the model's `baseUrl` host is `anthropic.com` (or a subdomain). Third-party endpoints that speak the Anthropic Messages wire format (Kimi Code, OpenRouter, proxies) do not implement native tool search and answered the injected tool with an opaque `400 Invalid request Error`; they now receive the untouched payload and keep the local `tool_search` tool. +- A missing `baseUrl` keeps the previous behaviour so existing callers and tests stay unchanged. +- The request-validator mock now enforces the tool `name` the same way the API does, and `test/tool-search/native-anthropic.test.ts` covers the name, the host gate, and that a third-party 400 is not attributed to native search. + +### Why + +- The eval-only tool routing default (`bash`/`workflow`/`monitor` inactive) made the extension catalog non-empty for every session, which turned the native adapter on for every `anthropic-messages` request and surfaced both defects at once: every Anthropic request failed with the name error, and every Kimi Code request failed with the opaque 400. +- The 400 fallback only helps after the first failed turn of each session, and on third-party hosts it hid the real cause behind a generic error. + +### Expected merge conflict zones + +- LOW: `native-search.ts` constants and the adapter entry point. +- LOW: `index.ts` provider-request hook wiring. + ## 2026-08-11 - Defer local tool registration until the catalog is searchable ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/tool-search/index.ts b/packages/coding-agent/src/core/extensions/builtin/tool-search/index.ts index f6c26ad43b..495b14e3e9 100644 --- a/packages/coding-agent/src/core/extensions/builtin/tool-search/index.ts +++ b/packages/coding-agent/src/core/extensions/builtin/tool-search/index.ts @@ -46,7 +46,9 @@ export function createToolSearchExtension(service: ToolSearchService): Extension }, searchToolName: TOOL_SEARCH_TOOL_NAME, }); - pi.on("before_provider_request", (event, ctx) => nativeAdapter.applyBeforeRequest(ctx.model?.api, event.payload)); + pi.on("before_provider_request", (event, ctx) => + nativeAdapter.applyBeforeRequest(event.model ?? ctx.model, event.payload), + ); pi.on("after_provider_response", (event) => nativeAdapter.noteResponseStatus(event.status)); }; } diff --git a/packages/coding-agent/src/core/extensions/builtin/tool-search/native-search.ts b/packages/coding-agent/src/core/extensions/builtin/tool-search/native-search.ts index 7940b5ae24..25533e3edc 100644 --- a/packages/coding-agent/src/core/extensions/builtin/tool-search/native-search.ts +++ b/packages/coding-agent/src/core/extensions/builtin/tool-search/native-search.ts @@ -6,7 +6,8 @@ import type { ToolSearchDocument } from "./engine/document.ts"; // scope because it requires a provider-layer seam. export const ANTHROPIC_TOOL_SEARCH_TYPE = "tool_search_tool_bm25_20251119"; -export const ANTHROPIC_TOOL_SEARCH_NAME = "tool_search"; +/** The API rejects any other `name` for this tool type (400: "Input should be 'tool_search_tool_bm25'"). */ +export const ANTHROPIC_TOOL_SEARCH_NAME = "tool_search_tool_bm25"; /** Anthropic caps a request at 10k tools; beyond that native search is invalid. */ export const ANTHROPIC_MAX_TOOLS = 10000; @@ -45,6 +46,29 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +/** The subset of the request model the adapter needs to decide whether native search applies. */ +export interface NativeToolSearchRequestModel { + readonly api?: string; + readonly baseUrl?: string; +} + +/** + * Native tool search is an Anthropic server-side feature. Third-party endpoints + * that speak the Anthropic Messages wire format (Kimi Code, OpenRouter, proxies) + * reject the `tool_search_tool_bm25_20251119` tool with an opaque 400, so only + * first-party Anthropic hosts opt in. A missing `baseUrl` (older callers, tests) + * keeps the previous behaviour. + */ +export function isFirstPartyAnthropicEndpoint(baseUrl: string | undefined): boolean { + if (baseUrl === undefined) return true; + try { + const host = new URL(baseUrl).hostname.toLowerCase(); + return host === "anthropic.com" || host.endsWith(".anthropic.com"); + } catch { + return false; + } +} + /** * Pure payload transform. Injects eligible inactive catalog schemas, adds exactly * one native search tool, and enforces Anthropic's HARD RULES: never defer the @@ -124,10 +148,11 @@ export class AnthropicNativeToolSearchAdapter { this.#deps = deps; } - applyBeforeRequest(api: string | undefined, payload: unknown): unknown { + applyBeforeRequest(model: NativeToolSearchRequestModel | undefined, payload: unknown): unknown { this.#injectedLastRequest = false; if (this.#disabled || !this.#deps.enabled()) return payload; - const next = addAnthropicNativeToolSearch(api, payload, this.#deps); + if (!isFirstPartyAnthropicEndpoint(model?.baseUrl)) return payload; + const next = addAnthropicNativeToolSearch(model?.api, payload, this.#deps); this.#injectedLastRequest = next !== payload; return next; } diff --git a/packages/coding-agent/test/mcp/fixtures/native-search-mocks.ts b/packages/coding-agent/test/mcp/fixtures/native-search-mocks.ts index 19beb85499..051f2edcc6 100644 --- a/packages/coding-agent/test/mcp/fixtures/native-search-mocks.ts +++ b/packages/coding-agent/test/mcp/fixtures/native-search-mocks.ts @@ -157,6 +157,7 @@ export function anthropicToolSearchResultBlock(toolUseId = "srvtoolu_spike_1"): // --------------------------------------------------------------------------- export const ANTHROPIC_TOOL_SEARCH_TYPE = "tool_search_tool_bm25_20251119"; +export const ANTHROPIC_TOOL_SEARCH_NAME = "tool_search_tool_bm25"; export interface AnthropicValidationResult { readonly status: 200 | 400; @@ -174,6 +175,14 @@ export function validateAnthropicToolSearchPayload(payload: unknown): AnthropicV const objs = tools.filter(isObj); const deferred = objs.filter((tool) => tool.defer_loading === true); const hasSearchTool = objs.some((tool) => tool.type === ANTHROPIC_TOOL_SEARCH_TYPE); + for (const [index, tool] of objs.entries()) { + if (tool.type === ANTHROPIC_TOOL_SEARCH_TYPE && tool.name !== ANTHROPIC_TOOL_SEARCH_NAME) { + return { + status: 400, + error: `invalid_request_error: tools.${index}.${ANTHROPIC_TOOL_SEARCH_TYPE}.name: Input should be '${ANTHROPIC_TOOL_SEARCH_NAME}'`, + }; + } + } for (const tool of deferred) { if ("cache_control" in tool) { return { status: 400, error: "invalid_request: defer_loading and cache_control on the same tool" }; diff --git a/packages/coding-agent/test/mcp/native-anthropic.test.ts b/packages/coding-agent/test/mcp/native-anthropic.test.ts index 9c6080b1b9..c391884040 100644 --- a/packages/coding-agent/test/mcp/native-anthropic.test.ts +++ b/packages/coding-agent/test/mcp/native-anthropic.test.ts @@ -117,7 +117,10 @@ describe("todo33 anthropic native: 400 -> local fallback", () => { fallback = reason; }, }); - const injected = adapter.applyBeforeRequest("anthropic-messages", mcpToolsPayload(3)); + const injected = adapter.applyBeforeRequest( + { api: "anthropic-messages", baseUrl: "https://api.anthropic.com" }, + mcpToolsPayload(3), + ); expect(searchTool(toolsOf(injected))).toHaveLength(1); adapter.noteResponseStatus(400); @@ -126,13 +129,17 @@ describe("todo33 anthropic native: 400 -> local fallback", () => { // Subsequent requests are byte-identical (no injection): session continues. const next = mcpToolsPayload(3); - expect(adapter.applyBeforeRequest("anthropic-messages", next)).toBe(next); + expect( + adapter.applyBeforeRequest({ api: "anthropic-messages", baseUrl: "https://api.anthropic.com" }, next), + ).toBe(next); }); it("ignores a 400 on a request it did not inject", () => { const adapter = new AnthropicNativeToolSearchAdapter({ ...CONFIG, enabled: () => false }); const payload = mcpToolsPayload(3); - expect(adapter.applyBeforeRequest("anthropic-messages", payload)).toBe(payload); // config off -> no-op + expect( + adapter.applyBeforeRequest({ api: "anthropic-messages", baseUrl: "https://api.anthropic.com" }, payload), + ).toBe(payload); // config off -> no-op adapter.noteResponseStatus(400); expect(adapter.disabled).toBe(false); }); diff --git a/packages/coding-agent/test/tool-search/native-anthropic.test.ts b/packages/coding-agent/test/tool-search/native-anthropic.test.ts index 20ea0df9d1..007e97624d 100644 --- a/packages/coding-agent/test/tool-search/native-anthropic.test.ts +++ b/packages/coding-agent/test/tool-search/native-anthropic.test.ts @@ -12,11 +12,13 @@ import { describe, expect, it } from "vitest"; import { addAnthropicWebSearchToPayload } from "../../src/core/extensions/builtin/anthropic-web-search/index.ts"; import toolSearchExtension from "../../src/core/extensions/builtin/tool-search/index.ts"; import { + ANTHROPIC_TOOL_SEARCH_NAME, ANTHROPIC_TOOL_SEARCH_TYPE, AnthropicNativeToolSearchAdapter, addAnthropicNativeToolSearch, buildToolReferenceBlocks, installMcpNativeToolSearchGate, + isFirstPartyAnthropicEndpoint, isMcpNativeToolSearchEnabled, } from "../../src/core/extensions/builtin/tool-search/native-search.ts"; import type { ExtensionAPI, ExtensionFactory } from "../../src/core/extensions/types.ts"; @@ -30,6 +32,9 @@ const CONFIG = { searchToolName: "tool_search", isDeferrable: (name: string) => name.startsWith("mcp_") && name !== "tool_search", }; +const ANTHROPIC_MODEL = { api: "anthropic-messages", baseUrl: "https://api.anthropic.com" }; +/** Anthropic-compatible wire format on a third-party host (Kimi Code). */ +const KIMI_MODEL = { api: "anthropic-messages", baseUrl: "https://api.kimi.com/coding" }; function toolsOf(payload: unknown): Record[] { return ((payload as { tools?: unknown[] }).tools ?? []).filter( @@ -71,9 +76,13 @@ describe("todo 9 generalized catalog injection", () => { ], }); try { - const output = await harness.getExtensionRunner().emitBeforeProviderRequest({ - tools: [{ name: "tool_search", description: "Search", input_schema: {} }], - }); + const output = await harness + .getExtensionRunner() + .emitBeforeProviderRequest( + { tools: [{ name: "tool_search", description: "Search", input_schema: {} }] }, + undefined, + { model: { ...harness.models[0], baseUrl: ANTHROPIC_MODEL.baseUrl }, headers: {} }, + ); expect(named(toolsOf(output), "weather_forecast")).toMatchObject({ name: "weather_forecast", @@ -141,10 +150,10 @@ describe("todo 9 generalized catalog injection", () => { const payload = { tools: [{ name: "tool_search", description: "search", input_schema: {} }] }; installMcpNativeToolSearchGate(() => false); - expect(makeAdapter().applyBeforeRequest("anthropic-messages", payload)).toBe(payload); + expect(makeAdapter().applyBeforeRequest(ANTHROPIC_MODEL, payload)).toBe(payload); installMcpNativeToolSearchGate(() => true); expect( - named(toolsOf(makeAdapter().applyBeforeRequest("anthropic-messages", payload)), "mcp_weather_forecast"), + named(toolsOf(makeAdapter().applyBeforeRequest(ANTHROPIC_MODEL, payload)), "mcp_weather_forecast"), ).toMatchObject({ defer_loading: true, input_schema: { type: "object" } }); installMcpNativeToolSearchGate(() => false); }); @@ -337,7 +346,7 @@ describe("todo33 anthropic native: 400 -> local fallback", () => { fallback = reason; }, }); - const injected = adapter.applyBeforeRequest("anthropic-messages", mcpToolsPayload(3)); + const injected = adapter.applyBeforeRequest(ANTHROPIC_MODEL, mcpToolsPayload(3)); expect(searchTool(toolsOf(injected))).toHaveLength(1); adapter.noteResponseStatus(400); @@ -346,15 +355,59 @@ describe("todo33 anthropic native: 400 -> local fallback", () => { // Subsequent requests are byte-identical (no injection): session continues. const next = mcpToolsPayload(3); - expect(adapter.applyBeforeRequest("anthropic-messages", next)).toBe(next); + expect(adapter.applyBeforeRequest(ANTHROPIC_MODEL, next)).toBe(next); }); it("ignores a 400 on a request it did not inject", () => { const adapter = new AnthropicNativeToolSearchAdapter({ ...CONFIG, enabled: () => false }); const payload = mcpToolsPayload(3); - expect(adapter.applyBeforeRequest("anthropic-messages", payload)).toBe(payload); // config off -> no-op + expect(adapter.applyBeforeRequest(ANTHROPIC_MODEL, payload)).toBe(payload); // config off -> no-op + adapter.noteResponseStatus(400); + expect(adapter.disabled).toBe(false); + }); +}); + +describe("anthropic native: tool name and endpoint gating", () => { + it("names the native search tool exactly as the API requires", () => { + const out = addAnthropicNativeToolSearch("anthropic-messages", mcpToolsPayload(2), CONFIG); + const [search] = searchTool(toolsOf(out)); + expect(search).toEqual({ type: ANTHROPIC_TOOL_SEARCH_TYPE, name: ANTHROPIC_TOOL_SEARCH_NAME }); + expect(validateAnthropicToolSearchPayload(out)).toEqual({ status: 200 }); + }); + + it("the validator 400s on the pre-fix name the same way the API does", () => { + const payload = { + tools: [ + { name: "tool_search", description: "search", input_schema: {} }, + { name: "mcp_docs_tool-1", description: "tool", input_schema: {}, defer_loading: true }, + { type: ANTHROPIC_TOOL_SEARCH_TYPE, name: "tool_search" }, + ], + }; + expect(validateAnthropicToolSearchPayload(payload)).toEqual({ + status: 400, + error: `invalid_request_error: tools.2.${ANTHROPIC_TOOL_SEARCH_TYPE}.name: Input should be '${ANTHROPIC_TOOL_SEARCH_NAME}'`, + }); + }); + + it("only treats anthropic.com hosts as first-party", () => { + expect(isFirstPartyAnthropicEndpoint("https://api.anthropic.com")).toBe(true); + expect(isFirstPartyAnthropicEndpoint("https://api.anthropic.com/v1")).toBe(true); + expect(isFirstPartyAnthropicEndpoint(undefined)).toBe(true); + expect(isFirstPartyAnthropicEndpoint("https://api.kimi.com/coding")).toBe(false); + expect(isFirstPartyAnthropicEndpoint("https://openrouter.ai/api")).toBe(false); + expect(isFirstPartyAnthropicEndpoint("https://evil-anthropic.com")).toBe(false); + expect(isFirstPartyAnthropicEndpoint("not a url")).toBe(false); + }); + + it("leaves the payload untouched for anthropic-messages models on third-party hosts", () => { + const adapter = new AnthropicNativeToolSearchAdapter({ ...CONFIG, enabled: () => true }); + const payload = mcpToolsPayload(3); + expect(adapter.applyBeforeRequest(KIMI_MODEL, payload)).toBe(payload); + // A later 400 from that host must not be attributed to native search. adapter.noteResponseStatus(400); expect(adapter.disabled).toBe(false); + // The same session still injects for the first-party host. + expect(searchTool(toolsOf(adapter.applyBeforeRequest(ANTHROPIC_MODEL, payload)))).toHaveLength(1); }); });