Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -45,6 +46,29 @@ function isRecord(value: unknown): value is Record<string, unknown> {
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
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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" };
Expand Down
13 changes: 10 additions & 3 deletions packages/coding-agent/test/mcp/native-anthropic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
});
Expand Down
69 changes: 61 additions & 8 deletions packages/coding-agent/test/tool-search/native-anthropic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string, unknown>[] {
return ((payload as { tools?: unknown[] }).tools ?? []).filter(
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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);
Expand All @@ -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);
});
});

Expand Down