diff --git a/docs/hooks/tools.mdx b/docs/hooks/tools.mdx index 4b3399fc3c..8e9314acc6 100644 --- a/docs/hooks/tools.mdx +++ b/docs/hooks/tools.mdx @@ -750,7 +750,7 @@ If a value is too large for the environment, it may be omitted (not set). Mux al
-tool_search (2) +tool_catalog_search (2) | Env var | JSON path | Type | Description | | ---------------------- | --------- | ------ | --------------------------------------------------------------------------------------------------------- | diff --git a/src/browser/features/Tools/Shared/ToolPrimitives.tsx b/src/browser/features/Tools/Shared/ToolPrimitives.tsx index 846c438604..4f55222f54 100644 --- a/src/browser/features/Tools/Shared/ToolPrimitives.tsx +++ b/src/browser/features/Tools/Shared/ToolPrimitives.tsx @@ -271,6 +271,7 @@ export const TOOL_NAME_TO_ICON: Partial> = { web_search: Globe, "server:GOOGLE_SEARCH_WEB": Globe, notify: Bell, + tool_catalog_search: Search, tool_search: Search, review_pane_update: Sparkles, review_pane_get: ScanEye, diff --git a/src/browser/features/Tools/Shared/getToolComponent.test.ts b/src/browser/features/Tools/Shared/getToolComponent.test.ts index a14a3cc567..3824658154 100644 --- a/src/browser/features/Tools/Shared/getToolComponent.test.ts +++ b/src/browser/features/Tools/Shared/getToolComponent.test.ts @@ -141,17 +141,23 @@ describe("getToolComponent", () => { expect(component).toBe(GenericToolCall); }); - test("returns ToolSearchToolCall for tool_search with valid args", () => { - expect(getToolComponent("tool_search", { query: "send slack message" })).toBe( + test("returns ToolSearchToolCall for tool_catalog_search with valid args", () => { + expect(getToolComponent("tool_catalog_search", { query: "send slack message" })).toBe( + ToolSearchToolCall + ); + expect(getToolComponent("tool_catalog_search", { query: "send slack message", limit: 5 })).toBe( ToolSearchToolCall ); - expect(getToolComponent("tool_search", { query: "send slack message", limit: 5 })).toBe( + }); + + test("renders legacy tool_search transcript calls", () => { + expect(getToolComponent("tool_search", { query: "send slack message" })).toBe( ToolSearchToolCall ); }); - test("tool_search falls back to GenericToolCall when args don't conform", () => { - expect(getToolComponent("tool_search", { query: 42 })).toBe(GenericToolCall); + test("tool_catalog_search falls back to GenericToolCall when args don't conform", () => { + expect(getToolComponent("tool_catalog_search", { query: 42 })).toBe(GenericToolCall); }); test("Object.prototype member names fall back to GenericToolCall instead of throwing", () => { diff --git a/src/browser/features/Tools/Shared/getToolComponent.ts b/src/browser/features/Tools/Shared/getToolComponent.ts index ef390de4d7..c5c17b925e 100644 --- a/src/browser/features/Tools/Shared/getToolComponent.ts +++ b/src/browser/features/Tools/Shared/getToolComponent.ts @@ -160,7 +160,15 @@ const TOOL_REGISTRY: Record = { // Legacy-only transcript renderer for historical status_set calls. status_set: { component: StatusSetToolCall, schema: legacyStatusSetSchema }, notify: { component: NotifyToolCall, schema: TOOL_DEFINITIONS.notify.schema }, - tool_search: { component: ToolSearchToolCall, schema: TOOL_DEFINITIONS.tool_search.schema }, + tool_catalog_search: { + component: ToolSearchToolCall, + schema: TOOL_DEFINITIONS.tool_catalog_search.schema, + }, + // Legacy-only transcript renderer from before AI SDK 7 reserved tool_search. + tool_search: { + component: ToolSearchToolCall, + schema: TOOL_DEFINITIONS.tool_catalog_search.schema, + }, analytics_query: { component: AnalyticsQueryToolCall, schema: TOOL_DEFINITIONS.analytics_query.schema, diff --git a/src/browser/features/Tools/ToolSearchToolCall.tsx b/src/browser/features/Tools/ToolSearchToolCall.tsx index c21f5da686..a02b39d804 100644 --- a/src/browser/features/Tools/ToolSearchToolCall.tsx +++ b/src/browser/features/Tools/ToolSearchToolCall.tsx @@ -18,7 +18,7 @@ import { } from "./Shared/toolUtils"; /** - * Transcript card for the `tool_search` tool (tool-search experiment) — the + * Transcript card for the `tool_catalog_search` tool (tool-search experiment), the * call the model makes to discover deferred MCP tools. Collapsed it reads as a * glanceable "Tool search · · N matches"; expanded it lists the matched * tool names with their descriptions. @@ -76,7 +76,7 @@ export const ToolSearchToolCall: React.FC = (props) => - + {props.args.query} {view.kind === "matches" && ( // Hide the count in very narrow containers so the truncating query keeps priority. diff --git a/src/common/constants/experiments.ts b/src/common/constants/experiments.ts index dd3c9b160a..1de039f83d 100644 --- a/src/common/constants/experiments.ts +++ b/src/common/constants/experiments.ts @@ -181,7 +181,7 @@ export const EXPERIMENTS: Record = { id: EXPERIMENT_IDS.TOOL_SEARCH, name: "Tool Search", description: - "Defer MCP tool definitions out of the model-visible tool list until the model discovers them via the tool_search tool", + "Defer MCP tool definitions out of the model-visible tool list until the model discovers them via the tool_catalog_search tool", enabledByDefault: false, userOverridable: true, showInSettings: true, diff --git a/src/common/types/tools.ts b/src/common/types/tools.ts index 15e132772a..6f2a2c7cff 100644 --- a/src/common/types/tools.ts +++ b/src/common/types/tools.ts @@ -432,7 +432,7 @@ export type WebFetchToolArgs = z.infer export type WebFetchToolResult = z.infer; // Tool Search Tool Types (tool-search experiment), derived from schema (avoid drift) -export type ToolSearchToolArgs = z.infer; +export type ToolSearchToolArgs = z.infer; export interface ToolSearchToolResult { query: string; diff --git a/src/common/utils/tools/toolCatalog.test.ts b/src/common/utils/tools/toolCatalog.test.ts index 64ad422aad..9f16cdc782 100644 --- a/src/common/utils/tools/toolCatalog.test.ts +++ b/src/common/utils/tools/toolCatalog.test.ts @@ -6,10 +6,13 @@ import { buildToolCatalog, computeActiveToolNames, extractPreActivatedToolNames, + LEGACY_TOOL_SEARCH_TOOL_NAME, + normalizeLegacyToolSearchMessages, prepareToolSearch, rebuildToolSearchState, searchToolCatalog, seedToolSearchActivationsFromMessages, + TOOL_SEARCH_TOOL_NAME, type ToolCatalogEntry, type ToolSearchStreamState, } from "@/common/utils/tools/toolCatalog"; @@ -38,7 +41,7 @@ function baseTools(): Record { return { bash: fakeTool("Run a shell command"), file_read: fakeTool("Read a file"), - tool_search: fakeTool("Search deferred tools"), + tool_catalog_search: fakeTool("Search deferred tools"), slack_send_message: mcpTool("Send a message to a Slack channel", { channel: { description: "Slack channel ID" }, }), @@ -77,17 +80,17 @@ describe("buildToolCatalog", () => { expect(result.catalog.some((entry) => entry.name === "github_create_issue")).toBe(false); }); - test("tool_search itself is never deferred, even if listed as an MCP name", () => { + test("tool_catalog_search itself is never deferred, even if listed as an MCP name", () => { const result = buildToolCatalog({ tools: baseTools(), - mcpToolNames: [...MCP_NAMES, "tool_search"], + mcpToolNames: [...MCP_NAMES, "tool_catalog_search"], }); - expect(result.deferredToolNames.has("tool_search")).toBe(false); + expect(result.deferredToolNames.has("tool_catalog_search")).toBe(false); }); test("extracts description and param text; degrades gracefully on odd schemas", () => { const tools: Record = { - tool_search: fakeTool("Search deferred tools"), + tool_catalog_search: fakeTool("Search deferred tools"), weird: fakeTool("Weird tool", "not-an-object"), slack_send_message: mcpTool("Send a message", { channel: { description: "channel ID" } }), }; @@ -113,27 +116,27 @@ describe("prepareToolSearch (post-policy gate)", () => { expect(result.state!.deferredToolNames.size).toBe(3); }); - test("tool_search policy-disabled (absent): no state, MCP tools untouched", () => { + test("tool_catalog_search policy-disabled (absent): no state, MCP tools untouched", () => { const tools = baseTools(); - delete tools.tool_search; + delete tools.tool_catalog_search; const result = prepareToolSearch({ tools, mcpToolNames: MCP_NAMES }); expect(result.state).toBeUndefined(); expect(result.tools).toBe(tools); expect(Object.keys(result.tools)).toContain("slack_send_message"); }); - test("all MCP tools policy-disabled: tool_search removed, no state", () => { + test("all MCP tools policy-disabled: tool_catalog_search removed, no state", () => { const tools = baseTools(); for (const name of MCP_NAMES) { delete tools[name]; } const result = prepareToolSearch({ tools, mcpToolNames: MCP_NAMES }); expect(result.state).toBeUndefined(); - expect(Object.keys(result.tools)).not.toContain("tool_search"); + expect(Object.keys(result.tools)).not.toContain("tool_catalog_search"); expect(Object.keys(result.tools).sort()).toEqual(["bash", "file_read"]); }); - test("all MCP tools required: nothing left to defer, tool_search removed", () => { + test("all MCP tools required: nothing left to defer, tool_catalog_search removed", () => { const policy: ToolPolicy = MCP_NAMES.map((name) => ({ regex_match: name, action: "require" as const, @@ -144,17 +147,17 @@ describe("prepareToolSearch (post-policy gate)", () => { toolPolicy: policy, }); expect(result.state).toBeUndefined(); - expect(Object.keys(result.tools)).not.toContain("tool_search"); + expect(Object.keys(result.tools)).not.toContain("tool_catalog_search"); }); - test("PTC enabled: deactivates, tool_search removed, MCP tools untouched", () => { + test("PTC enabled: deactivates, tool_catalog_search removed, MCP tools untouched", () => { // Non-exclusive PTC embeds/exposes MCP tools through code_execution's // bridge, bypassing activeTools scoping — deferral must deactivate. const tools = baseTools(); tools.code_execution = fakeTool("Run JS against bridged tools"); const result = prepareToolSearch({ tools, mcpToolNames: MCP_NAMES, ptcEnabled: true }); expect(result.state).toBeUndefined(); - expect(Object.keys(result.tools)).not.toContain("tool_search"); + expect(Object.keys(result.tools)).not.toContain("tool_catalog_search"); expect(Object.keys(result.tools)).toContain("code_execution"); expect(Object.keys(result.tools)).toContain("slack_send_message"); }); @@ -172,19 +175,19 @@ describe("prepareToolSearch (post-policy gate)", () => { expect(result.state!.deferredToolNames.has("code_execution")).toBe(true); }); - test("MCP name collision with tool_search: deactivates, record untouched", () => { - // Server "tool" + tool "search" normalize to "tool_search" and the MCP + test("MCP name collision with tool_catalog_search: deactivates, record untouched", () => { + // Server "tool" + tool "search" normalize to "tool_catalog_search" and the MCP // spread overwrites the built-in search tool — deferral must deactivate // (no working search tool) and the colliding MCP tool must survive. const tools = baseTools(); - tools.tool_search = mcpTool("MCP tool that happens to be named tool_search"); + tools.tool_catalog_search = mcpTool("MCP tool that happens to be named tool_catalog_search"); const result = prepareToolSearch({ tools, - mcpToolNames: [...MCP_NAMES, "tool_search"], + mcpToolNames: [...MCP_NAMES, "tool_catalog_search"], }); expect(result.state).toBeUndefined(); expect(result.tools).toBe(tools); - expect(Object.keys(result.tools)).toContain("tool_search"); + expect(Object.keys(result.tools)).toContain("tool_catalog_search"); expect(Object.keys(result.tools)).toContain("slack_send_message"); }); }); @@ -215,15 +218,15 @@ describe("rebuildToolSearchState (model-fallback path)", () => { delete nextTools[name]; } const result = rebuildToolSearchState(state, { tools: nextTools, mcpToolNames: MCP_NAMES }); - expect(Object.keys(result.tools)).not.toContain("tool_search"); + expect(Object.keys(result.tools)).not.toContain("tool_catalog_search"); // Same state object deactivates: prepareStep stops returning activeTools. expect(computeActiveToolNames(state)).toBeUndefined(); }); - test("deactivates in place when tool_search is gone from the fallback toolset", () => { + test("deactivates in place when tool_catalog_search is gone from the fallback toolset", () => { const state = activeState(); const nextTools = baseTools(); - delete nextTools.tool_search; + delete nextTools.tool_catalog_search; const result = rebuildToolSearchState(state, { tools: nextTools, mcpToolNames: MCP_NAMES }); expect(result.tools).toBe(nextTools); expect(computeActiveToolNames(state)).toBeUndefined(); @@ -298,22 +301,32 @@ describe("extractPreActivatedToolNames", () => { test("reads json-encoded outputs", () => { const names = extractPreActivatedToolNames([ - toolResultMessage("tool_search", { type: "json", value: matchesResult }), + toolResultMessage("tool_catalog_search", { type: "json", value: matchesResult }), ]); expect([...names]).toEqual(["slack_send_message"]); }); test("reads text-encoded (stringified) outputs", () => { const names = extractPreActivatedToolNames([ - toolResultMessage("tool_search", { type: "text", value: JSON.stringify(matchesResult) }), + toolResultMessage("tool_catalog_search", { + type: "text", + value: JSON.stringify(matchesResult), + }), ]); expect([...names]).toEqual(["slack_send_message"]); }); test("reads raw object outputs and ignores non-JSON text", () => { const names = extractPreActivatedToolNames([ - toolResultMessage("tool_search", matchesResult), - toolResultMessage("tool_search", { type: "text", value: "not json" }), + toolResultMessage("tool_catalog_search", matchesResult), + toolResultMessage("tool_catalog_search", { type: "text", value: "not json" }), + ]); + expect([...names]).toEqual(["slack_send_message"]); + }); + + test("reads legacy tool_search outputs from persisted history", () => { + const names = extractPreActivatedToolNames([ + toolResultMessage(LEGACY_TOOL_SEARCH_TOOL_NAME, { type: "json", value: matchesResult }), ]); expect([...names]).toEqual(["slack_send_message"]); }); @@ -340,12 +353,73 @@ describe("extractPreActivatedToolNames", () => { return raw as MuxMessage; }; const names = extractPreActivatedToolNames([ - muxMessage("tool_search", "output-available", matchesResult), + muxMessage("tool_catalog_search", "output-available", matchesResult), + muxMessage(LEGACY_TOOL_SEARCH_TOOL_NAME, "output-available", { + ...matchesResult, + matches: [{ name: "github_create_issue", description: "Create" }], + }), // Pending / other-tool parts must not contribute. - muxMessage("tool_search", "input-available"), + muxMessage("tool_catalog_search", "input-available"), muxMessage("bash", "output-available", matchesResult), ]); - expect([...names]).toEqual(["slack_send_message"]); + expect([...names]).toEqual(["slack_send_message", "github_create_issue"]); + }); +}); + +describe("normalizeLegacyToolSearchMessages", () => { + test("rewrites completed Mux search history without relabeling unrelated tools", () => { + const muxResult = { + query: "slack", + matches: [{ name: "slack_send_message", description: "Send" }], + totalDeferred: 3, + }; + const messages: ModelMessage[] = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "mux-search", + toolName: LEGACY_TOOL_SEARCH_TOOL_NAME, + input: { query: "slack" }, + }, + { + type: "tool-call", + toolCallId: "mcp-search", + toolName: LEGACY_TOOL_SEARCH_TOOL_NAME, + input: { text: "slack" }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "mux-search", + toolName: LEGACY_TOOL_SEARCH_TOOL_NAME, + output: { type: "json", value: muxResult }, + }, + { + type: "tool-result", + toolCallId: "mcp-search", + toolName: LEGACY_TOOL_SEARCH_TOOL_NAME, + output: { type: "json", value: { hits: [] } }, + }, + ], + }, + ]; + + const normalized = normalizeLegacyToolSearchMessages(messages); + const normalizedJson = JSON.stringify(normalized); + + expect(normalizedJson).toContain( + `"toolCallId":"mux-search","toolName":"${TOOL_SEARCH_TOOL_NAME}"` + ); + expect(normalizedJson).toContain( + `"toolCallId":"mcp-search","toolName":"${LEGACY_TOOL_SEARCH_TOOL_NAME}"` + ); + expect(JSON.stringify(messages)).not.toContain(TOOL_SEARCH_TOOL_NAME); }); }); @@ -358,7 +432,7 @@ describe("seedToolSearchActivationsFromMessages", () => { { type: "tool-result", toolCallId: "call-1", - toolName: "tool_search", + toolName: "tool_catalog_search", output: { type: "json", value: { @@ -399,12 +473,12 @@ describe("computeActiveToolNames", () => { const firstStep = computeActiveToolNames(state)!; expect(firstStep).toContain("bash"); expect(firstStep).toContain("file_read"); - expect(firstStep).toContain("tool_search"); + expect(firstStep).toContain("tool_catalog_search"); for (const name of MCP_NAMES) { expect(firstStep).not.toContain(name); } - // Simulate tool_search.execute activating a match: the next step's + // Simulate tool_catalog_search.execute activating a match: the next step's // activeTools must advertise it (acceptance criteria 2–3). state.activatedToolNames.add("slack_send_message"); const nextStep = computeActiveToolNames(state)!; diff --git a/src/common/utils/tools/toolCatalog.ts b/src/common/utils/tools/toolCatalog.ts index 8293f8fd69..fdf6dc927c 100644 --- a/src/common/utils/tools/toolCatalog.ts +++ b/src/common/utils/tools/toolCatalog.ts @@ -2,7 +2,7 @@ * Tool search catalog (tool-search experiment, Phase 1). * * Client-side deferred MCP tool loading: MCP tool schemas stay out of the - * model-visible tool list until the model discovers them via the `tool_search` + * model-visible tool list until the model discovers them via the `tool_catalog_search` * tool. All tools remain in the `tools:` record passed to streamText; the AI * SDK's `prepareStep` → `activeTools` mechanism only scopes what is advertised * to the model on each step, so this works with every provider. @@ -12,13 +12,20 @@ * aiService or streamText. */ -import type { Tool } from "ai"; +import type { + AssistantModelMessage, + Tool, + ToolCallPart, + ToolModelMessage, + ToolResultPart, +} from "ai"; import type { ModelMessage, MuxMessage } from "@/common/types/message"; import { buildRequiredToolPatterns, type ToolPolicy } from "@/common/utils/tools/toolPolicy"; -export const TOOL_SEARCH_TOOL_NAME = "tool_search"; +export const TOOL_SEARCH_TOOL_NAME = "tool_catalog_search"; +export const LEGACY_TOOL_SEARCH_TOOL_NAME = "tool_search"; -/** Default / max number of matches returned by a tool_search call. */ +/** Default / max number of matches returned by a tool_catalog_search call. */ export const TOOL_SEARCH_DEFAULT_LIMIT = 10; export const TOOL_SEARCH_MAX_LIMIT = 25; @@ -31,7 +38,7 @@ export interface ToolCatalogEntry { /** * Per-stream mutable tool-search state. Created by aiService after policy - * filtering, mutated by `tool_search.execute` (activations) and by the + * filtering, mutated by `tool_catalog_search.execute` (activations) and by the * model-fallback rebuild, and read by StreamManager's prepareStep. The object * identity must be stable for the lifetime of the stream — mutate in place, * never replace. @@ -42,7 +49,7 @@ export interface ToolSearchStreamState { deferredToolNames: Set; /** Final post-policy tool record keys (core + deferred). */ allToolNames: string[]; - /** Deferred tools discovered via tool_search (or prior-turn history). */ + /** Deferred tools discovered via tool_catalog_search (or prior-turn history). */ activatedToolNames: Set; } @@ -59,6 +66,10 @@ function isPlainRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function isMuxToolSearchName(toolName: string): boolean { + return toolName === TOOL_SEARCH_TOOL_NAME || toolName === LEGACY_TOOL_SEARCH_TOOL_NAME; +} + /** * Extract a flat scoring string from a tool's input schema. MCP tools carry * `jsonSchema()`-wrapped JSON schemas (a `Schema` object with a `jsonSchema` @@ -115,7 +126,7 @@ interface ToolCatalogClassification { * Phase 1 policy: only MCP tools are deferred. Deferred = record keys ∩ MCP * names, minus names matched by a policy `require` rule (required tools must * stay advertised so the model can satisfy the requirement), minus - * `tool_search` itself. Intersection semantics absorb policy-disable and + * `tool_catalog_search` itself. Intersection semantics absorb policy-disable and * PTC-exclusive removals: absent tools never enter the catalog. */ export function buildToolCatalog(inputs: ToolCatalogInputs): ToolCatalogClassification { @@ -147,20 +158,20 @@ export function buildToolCatalog(inputs: ToolCatalogInputs): ToolCatalogClassifi * Post-policy gate: decides whether tool-search deferral is active for this * stream and returns the (possibly adjusted) tool record plus the seed state. * - * - An MCP tool name collides with `tool_search` (e.g. server "tool" + tool - * "search" normalize to "tool_search"): the MCP spread overwrites the + * - An MCP tool name collides with `tool_catalog_search` (e.g. server "tool" + tool + * "search" normalize to "tool_catalog_search"): the MCP spread overwrites the * built-in search tool in the merged record, so deferring would leave MCP * tools unreachable with no working search tool ⇒ safe fallback: no state, * tools unchanged — the colliding entry behaves as a normal MCP tool. * - PTC enabled (`ptcEnabled`, non-exclusive programmatic tool calling): its * `code_execution` bridge already embeds/exposes MCP tools, bypassing - * activeTools ⇒ drop `tool_search`, no state — MCP tools stay advertised as + * activeTools ⇒ drop `tool_catalog_search`, no state. MCP tools stay advertised as * without deferral. (Exclusive mode removes MCP tools from the record, so * the empty-catalog branch deactivates it anyway.) - * - `tool_search` absent (policy-disabled) ⇒ safe fallback: no state, tools + * - `tool_catalog_search` absent (policy-disabled) ⇒ safe fallback: no state, tools * unchanged — MCP tools stay advertised exactly as without the experiment. * - Nothing deferred (all MCP tools policy-disabled / PTC-removed) ⇒ drop - * `tool_search` from the record (a search tool with an empty catalog is + * `tool_catalog_search` from the record (a search tool with an empty catalog is * noise) and return no state. * - Otherwise ⇒ tools unchanged plus a fresh state with an empty activation * set (callers seed prior-turn activations via @@ -171,7 +182,7 @@ export function prepareToolSearch(inputs: ToolCatalogInputs): { state?: ToolSearchStreamState; } { // Collision check must run before the empty-catalog branch below: when the - // record's `tool_search` entry is actually an MCP tool, dropping it would + // record's `tool_catalog_search` entry is actually an MCP tool, dropping it would // silently remove a legitimate MCP tool. if (inputs.mcpToolNames.includes(TOOL_SEARCH_TOOL_NAME)) { return { tools: inputs.tools }; @@ -183,7 +194,7 @@ export function prepareToolSearch(inputs: ToolCatalogInputs): { // bridged MCP tool in its description and exposes them as callable `mux.*` // functions, so activeTools scoping could neither reduce context nor gate // access. Deferral would be ineffective and silently bypassable ⇒ drop - // tool_search and run without deferral when both experiments are enabled. + // tool_catalog_search and run without deferral when both experiments are enabled. // Gated on the actual PTC flag, not record presence: a `code_execution` // record entry may be a same-named MCP tool (classified as normal deferred). if (inputs.ptcEnabled === true) { @@ -208,7 +219,7 @@ export function prepareToolSearch(inputs: ToolCatalogInputs): { * intersected with the new deferred set. When the fallback record no longer * supports deferral, the state deactivates (empty deferred set ⇒ * computeActiveToolNames returns undefined ⇒ no activeTools scoping) and - * `tool_search` is removed from the returned record. + * `tool_catalog_search` is removed from the returned record. */ export function rebuildToolSearchState( state: ToolSearchStreamState, @@ -296,7 +307,7 @@ export function searchToolCatalog( })); } -/** Read `matches[].name` strings from a defensively-parsed tool_search result value. */ +/** Read `matches[].name` strings from a defensively-parsed tool_catalog_search result value. */ function collectMatchNames(value: unknown, into: Set): void { if (!isPlainRecord(value) || !Array.isArray(value.matches)) { return; @@ -308,25 +319,55 @@ function collectMatchNames(value: unknown, into: Set): void { } } -/** Decode a tool_search output in any persisted encoding and collect match names. */ -function collectMatchNamesFromOutput(output: unknown, into: Set): void { +/** Decode a tool_catalog_search output in any persisted encoding. */ +function decodeToolSearchOutput(output: unknown): unknown { if (isPlainRecord(output) && output.type === "json") { - collectMatchNames(output.value, into); - } else if (isPlainRecord(output) && output.type === "text") { - if (typeof output.value === "string") { - try { - collectMatchNames(JSON.parse(output.value), into); - } catch { - // Not JSON — nothing to recover from a plain-text result. - } + return output.value; + } + if (isPlainRecord(output) && output.type === "text" && typeof output.value === "string") { + try { + return JSON.parse(output.value); + } catch { + return undefined; } - } else { - collectMatchNames(output, into); } + return output; +} + +function collectMatchNamesFromOutput(output: unknown, into: Set): void { + collectMatchNames(decodeToolSearchOutput(output), into); +} + +function isMuxToolSearchOutput(output: unknown): boolean { + const value = decodeToolSearchOutput(output); + return ( + isPlainRecord(value) && + typeof value.query === "string" && + Array.isArray(value.matches) && + value.matches.every( + (match) => + isPlainRecord(match) && + typeof match.name === "string" && + typeof match.description === "string" + ) && + typeof value.totalDeferred === "number" + ); +} + +function renameLegacyToolSearchCallPart(part: ToolCallPart): ToolCallPart { + return part.toolName === LEGACY_TOOL_SEARCH_TOOL_NAME + ? { ...part, toolName: TOOL_SEARCH_TOOL_NAME } + : part; +} + +function renameLegacyToolSearchResultPart(part: ToolResultPart): ToolResultPart { + return part.toolName === LEGACY_TOOL_SEARCH_TOOL_NAME + ? { ...part, toolName: TOOL_SEARCH_TOOL_NAME } + : part; } /** - * Scan conversation history for prior `tool_search` tool results and return + * Scan conversation history for prior `tool_catalog_search` tool results and return * the tool names they matched, so tools discovered in earlier turns (or * before a mid-turn stream retry) re-activate without a new search. Accepts * both provider-shaped ModelMessages (tool-result parts, `{type:"json"}` / @@ -344,7 +385,7 @@ export function extractPreActivatedToolNames( for (const part of message.parts) { if ( part.type === "dynamic-tool" && - part.toolName === TOOL_SEARCH_TOOL_NAME && + isMuxToolSearchName(part.toolName) && part.state === "output-available" ) { collectMatchNamesFromOutput(part.output, names); @@ -356,7 +397,7 @@ export function extractPreActivatedToolNames( continue; } for (const part of message.content) { - if (part.type !== "tool-result" || part.toolName !== TOOL_SEARCH_TOOL_NAME) { + if (part.type !== "tool-result" || !isMuxToolSearchName(part.toolName)) { continue; } collectMatchNamesFromOutput(part.output, names); @@ -366,7 +407,68 @@ export function extractPreActivatedToolNames( } /** - * Seed the activation set from prior tool_search results found in the + * AI SDK 7 reserves the legacy `tool_search` name for OpenAI's native tool. + * Rewrite only completed historical Mux search calls, request-only, so old + * workspaces remain usable without relabeling unrelated MCP tools. + */ +export function normalizeLegacyToolSearchMessages(messages: ModelMessage[]): ModelMessage[] { + const legacyCallIds = new Set(); + for (const message of messages) { + if (!Array.isArray(message.content)) { + continue; + } + for (const part of message.content) { + if ( + part.type === "tool-result" && + part.toolName === LEGACY_TOOL_SEARCH_TOOL_NAME && + isMuxToolSearchOutput(part.output) + ) { + legacyCallIds.add(part.toolCallId); + } + } + } + + if (legacyCallIds.size === 0) { + return messages; + } + + return messages.map((message) => { + if (message.role === "assistant" && Array.isArray(message.content)) { + const content: Exclude = message.content.map( + (part) => { + if (!legacyCallIds.has("toolCallId" in part ? part.toolCallId : "")) { + return part; + } + if (part.type === "tool-call") { + return renameLegacyToolSearchCallPart(part); + } + if (part.type === "tool-result") { + return renameLegacyToolSearchResultPart(part); + } + return part; + } + ); + const normalizedMessage: AssistantModelMessage = { ...message, content }; + return normalizedMessage; + } + + if (message.role === "tool") { + const content: ToolModelMessage["content"] = message.content.map((part) => { + if (part.type === "tool-result" && legacyCallIds.has(part.toolCallId)) { + return renameLegacyToolSearchResultPart(part); + } + return part; + }); + const normalizedMessage: ToolModelMessage = { ...message, content }; + return normalizedMessage; + } + + return message; + }); +} + +/** + * Seed the activation set from prior tool_catalog_search results found in the * stream's input messages, intersected with the current deferred set. */ export function seedToolSearchActivationsFromMessages( diff --git a/src/common/utils/tools/toolDefinitions.test.ts b/src/common/utils/tools/toolDefinitions.test.ts index 767433693b..85ca359621 100644 --- a/src/common/utils/tools/toolDefinitions.test.ts +++ b/src/common/utils/tools/toolDefinitions.test.ts @@ -642,14 +642,16 @@ describe("TOOL_DEFINITIONS", () => { expect(subAgentTools).not.toContain("review_pane_get"); }); - it("only includes tool_search when enableToolSearch is set", () => { + it("only includes tool_catalog_search when enableToolSearch is set", () => { // Off by default: the tool-search experiment must not leak into normal assembly. - expect(getAvailableTools("openai:gpt-4o")).not.toContain("tool_search"); + expect(getAvailableTools("openai:gpt-4o")).not.toContain("tool_catalog_search"); expect(getAvailableTools("openai:gpt-4o", { enableToolSearch: false })).not.toContain( - "tool_search" + "tool_catalog_search" ); - expect(getAvailableTools("openai:gpt-4o", { enableToolSearch: true })).toContain("tool_search"); + expect(getAvailableTools("openai:gpt-4o", { enableToolSearch: true })).toContain( + "tool_catalog_search" + ); }); it("requires workflow_run calls to use exactly one launch source", () => { diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index 6205476708..c84a954e9b 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -2479,11 +2479,11 @@ CREATE TABLE IF NOT EXISTS delegation_rollups ( .strict(), }, // #endregion NOTIFY_DOCS - tool_search: { + tool_catalog_search: { description: "Search the catalog of deferred tools. Some tools (provided by MCP servers) are deferred: " + "they exist but are not currently visible in your tool list. " + - "Call tool_search with task/capability keywords to discover them; matched tools become available on the next step. " + + "Call tool_catalog_search with task/capability keywords to discover them; matched tools become available on the next step. " + "Returns matched tool names and descriptions plus the total number of deferred tools (there may be more undiscovered — refine the query to find them).", schema: z .object({ @@ -2990,7 +2990,7 @@ export function getAvailableTools( enableDynamicWorkflows?: boolean; /** Whether the agent memory tool is available (memory experiment enabled). */ enableMemory?: boolean; - /** Whether tool_search is available (tool-search experiment + deferred MCP tools present). */ + /** Whether tool_catalog_search is available (tool-search experiment + deferred MCP tools present). */ enableToolSearch?: boolean; /** * Whether the Review pane tools (review_pane_update/review_pane_get) are @@ -3041,7 +3041,7 @@ export function getAvailableTools( "file_edit_insert", ...(enableMemory ? ["memory"] : []), ...(enableAdvisor ? ["advisor"] : []), - ...(enableToolSearch ? ["tool_search"] : []), + ...(enableToolSearch ? ["tool_catalog_search"] : []), "ask_user_question", "propose_plan", "bash", diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index c7bb5157c1..11054c30fc 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -307,7 +307,7 @@ export interface ToolConfiguration { abortSignal: AbortSignal; }; /** - * Runtime holder for the tool_search tool (tool-search experiment; present + * Runtime holder for the tool_catalog_search tool (tool-search experiment; present * only when the experiment is enabled and MCP tools exist for this stream). * `state` is assigned by aiService after policy filtering builds the catalog. */ @@ -595,7 +595,7 @@ export async function getToolsForModel( skills_catalog_search: createSkillsCatalogSearchTool(config), skills_catalog_read: createSkillsCatalogReadTool(config), ...(config.advisorRuntime ? { advisor: createAdvisorTool(config) } : {}), - ...(config.toolSearchRuntime ? { tool_search: createToolSearchTool(config) } : {}), + ...(config.toolSearchRuntime ? { tool_catalog_search: createToolSearchTool(config) } : {}), ask_user_question: createAskUserQuestionTool(config), propose_plan: createProposePlanTool(config), // propose_name and propose_status are intentionally NOT registered here — diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 85452b2108..5ff084921c 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -5491,7 +5491,7 @@ export const BUILTIN_SKILL_FILES: Record> = { "
", "", "
", - "tool_search (2)", + "tool_catalog_search (2)", "", "| Env var | JSON path | Type | Description |", "| ---------------------- | --------- | ------ | --------------------------------------------------------------------------------------------------------- |", diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 377e7d6297..2dcb37e699 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -137,6 +137,7 @@ import { prepareToolSearch, rebuildToolSearchState, seedToolSearchActivationsFromMessages, + TOOL_SEARCH_TOOL_NAME, type ToolSearchRuntime, } from "@/common/utils/tools/toolCatalog"; import type { PTCEventWithParent } from "@/node/services/tools/code_execution"; @@ -1678,7 +1679,7 @@ export class AIService extends EventEmitter { } // Tool search (tool-search experiment): assembly-time gate. The runtime - // holder makes getToolsForModel create the tool_search tool; its `state` + // holder makes getToolsForModel create the tool_catalog_search tool; its `state` // is assigned only after policy filtering builds the deferred catalog // (see prepareToolSearch below). Without MCP tools there is nothing to // defer, so the feature stays fully inactive. @@ -2226,7 +2227,7 @@ export class AIService extends EventEmitter { // must consume the policy-filtered record so policy-disabled tools never // enter the deferred catalog. This runs before every downstream consumer // of `tools` (system-prompt rebuild, sentinel tool names, telemetry, - // streaming) so a dropped tool_search cannot leak anywhere. + // streaming) so a dropped tool_catalog_search cannot leak anywhere. // PTC gate uses the same condition toolAssembly uses to add code_execution: // presence-sniffing the record would misfire on an MCP tool named // code_execution (see prepareToolSearch). @@ -2299,7 +2300,7 @@ export class AIService extends EventEmitter { systemMessageTokens = await tokenizer.countTokens(systemMessage); } - // Re-activate deferred tools discovered by tool_search in earlier turns + // Re-activate deferred tools discovered by tool_catalog_search in earlier turns // without requiring a new search. Must run before the sentinel list is // computed so pre-activated tools are advertised in agent transitions. if (toolSearchRuntime?.state) { @@ -2724,14 +2725,14 @@ export class AIService extends EventEmitter { toolPolicy: effectiveToolPolicy, ptcEnabled, }).tools; - } else if (!(mcpTools && "tool_search" in mcpTools)) { + } else if (!(mcpTools && TOOL_SEARCH_TOOL_NAME in mcpTools)) { // The primary-path gate deactivated deferral (e.g. every // MCP tool was policy-disabled). StreamManager was never - // handed scoping state, so tool_search must not appear in + // handed scoping state, so tool_catalog_search must not appear in // the fallback toolset either. Skipped when an MCP tool // collides with the name: that record entry is a // legitimate MCP tool, not our search tool. - const { tool_search: _removed, ...rest } = nextTools; + const { [TOOL_SEARCH_TOOL_NAME]: _removed, ...rest } = nextTools; nextTools = rest; } } diff --git a/src/node/services/messagePipeline.ts b/src/node/services/messagePipeline.ts index 4aa887446b..5654279f89 100644 --- a/src/node/services/messagePipeline.ts +++ b/src/node/services/messagePipeline.ts @@ -28,6 +28,7 @@ import { injectFileChangeNotifications, injectPostCompactionAttachments, } from "@/browser/utils/messages/modelMessageTransform"; +import { normalizeLegacyToolSearchMessages } from "@/common/utils/tools/toolCatalog"; import { applyCacheControl, type AnthropicCacheTtl } from "@/common/utils/ai/cacheStrategy"; import { log } from "./log"; @@ -183,7 +184,9 @@ export async function prepareMessagesForProvider( // --- ModelMessage-level transforms --- - const modelMessages = sanitizeAssistantModelMessages(rawModelMessages, workspaceId); + const modelMessages = normalizeLegacyToolSearchMessages( + sanitizeAssistantModelMessages(rawModelMessages, workspaceId) + ); log.debug_obj(`${workspaceId}/2_model_messages.json`, modelMessages); diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index cb0af7106c..ca7a41decb 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -5161,7 +5161,7 @@ describe("StreamManager - tool search activeTools scoping", () => { { name: "slack_list_channels", description: "List channels", paramText: "" }, ], deferredToolNames: new Set(["slack_send_message", "slack_list_channels"]), - allToolNames: ["bash", "tool_search", "slack_send_message", "slack_list_channels"], + allToolNames: ["bash", "tool_catalog_search", "slack_send_message", "slack_list_channels"], activatedToolNames: new Set(), }; @@ -5173,15 +5173,15 @@ describe("StreamManager - tool search activeTools scoping", () => { const prepareStep = capturePrepareStep(streamTextSpy); const firstStep = await prepareStep({ messages }); - expect(firstStep?.activeTools).toEqual(["bash", "tool_search"]); + expect(firstStep?.activeTools).toEqual(["bash", "tool_catalog_search"]); // Messages were unchanged, so no messages key should be introduced. expect(firstStep && "messages" in firstStep).toBe(false); - // Simulate tool_search.execute activating a tool mid-stream: the next + // Simulate tool_catalog_search.execute activating a tool mid-stream: the next // prepareStep call must advertise it without rebuilding the request. toolSearchState.activatedToolNames.add("slack_send_message"); const nextStep = await prepareStep({ messages }); - expect(nextStep?.activeTools).toEqual(["bash", "tool_search", "slack_send_message"]); + expect(nextStep?.activeTools).toEqual(["bash", "tool_catalog_search", "slack_send_message"]); }); test("buildStreamRequestConfig forwards the tool search state reference", () => { @@ -5214,7 +5214,7 @@ describe("StreamManager - tool search activeTools scoping", () => { toolSearchState ); - // Same reference, not a copy — tool_search.execute mutations must be + // Same reference, not a copy. tool_catalog_search.execute mutations must be // visible to prepareStep. expect(request.toolSearchState).toBe(toolSearchState); }); diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index e3b41efb24..395fba973c 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -191,7 +191,7 @@ interface StreamRequestConfig { toolPolicy?: ToolPolicy; /** * Tool-search deferral state (tool-search experiment). Owned and mutated by - * aiService/tool_search.execute; prepareStep reads it each step to compute + * aiService/tool_catalog_search.execute; prepareStep reads it each step to compute * `activeTools`. Absent when the feature is inactive. */ toolSearchState?: ToolSearchStreamState; @@ -1826,7 +1826,7 @@ export class StreamManager extends EventEmitter { request.onStepMessages?.(effectiveMessages); // Tool search (tool-search experiment): scope the advertised tool list // to core tools + activated deferred tools. Read per step so tools - // activated by tool_search.execute appear on the following step. + // activated by tool_catalog_search.execute appear on the following step. // undefined when the feature is inactive, keeping the return value // byte-identical to the pre-feature behavior. const activeTools = computeActiveToolNames(request.toolSearchState); diff --git a/src/node/services/tools/toolSearch.test.ts b/src/node/services/tools/toolSearch.test.ts new file mode 100644 index 0000000000..170ddbfcdf --- /dev/null +++ b/src/node/services/tools/toolSearch.test.ts @@ -0,0 +1,85 @@ +import { createOpenAI } from "@ai-sdk/openai"; +import { describe, expect, test } from "bun:test"; +import { generateText, tool, type ModelMessage } from "ai"; + +import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; +import { + LEGACY_TOOL_SEARCH_TOOL_NAME, + normalizeLegacyToolSearchMessages, + TOOL_SEARCH_TOOL_NAME, +} from "@/common/utils/tools/toolCatalog"; + +describe("tool catalog search provider compatibility", () => { + test("serializes as a custom function in OpenAI Responses history", async () => { + let capturedBody: Record | undefined; + const captureFetch = Object.assign( + (_input: Parameters[0], init?: Parameters[1]) => { + if (typeof init?.body !== "string") { + throw new Error("Expected the OpenAI provider to send a JSON string body"); + } + capturedBody = JSON.parse(init.body) as Record; + return Promise.resolve( + new Response( + JSON.stringify({ + id: "resp_test", + model: "gpt-5.6-sol", + output: [], + usage: { input_tokens: 1, output_tokens: 0 }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ) + ); + }, + { preconnect: fetch.preconnect.bind(fetch) } + ); + const openai = createOpenAI({ apiKey: "test", fetch: captureFetch }); + const result = { + query: "workspace goal", + matches: [{ name: "set_goal", description: "Create or replace a workspace goal" }], + totalDeferred: 3, + }; + const legacyMessages: ModelMessage[] = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call_1", + toolName: LEGACY_TOOL_SEARCH_TOOL_NAME, + input: { query: result.query }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: LEGACY_TOOL_SEARCH_TOOL_NAME, + output: { type: "json", value: result }, + }, + ], + }, + { role: "user", content: "Continue" }, + ]; + const messages = normalizeLegacyToolSearchMessages(legacyMessages); + + await generateText({ + model: openai.responses("gpt-5.6-sol"), + messages, + tools: { + [TOOL_SEARCH_TOOL_NAME]: tool({ + description: TOOL_DEFINITIONS.tool_catalog_search.description, + inputSchema: TOOL_DEFINITIONS.tool_catalog_search.schema, + }), + }, + maxRetries: 0, + }); + + const input = capturedBody?.input as Array> | undefined; + expect(input?.some((item) => item.type === "function_call")).toBe(true); + expect(input?.some((item) => item.type === "function_call_output")).toBe(true); + expect(input?.some((item) => item.type === "tool_search_output")).toBe(false); + }); +}); diff --git a/src/node/services/tools/toolSearch.ts b/src/node/services/tools/toolSearch.ts index 88e640b6e4..9dc52ac9fe 100644 --- a/src/node/services/tools/toolSearch.ts +++ b/src/node/services/tools/toolSearch.ts @@ -1,5 +1,5 @@ /** - * tool_search tool (tool-search experiment, Phase 1). + * tool_catalog_search tool (tool-search experiment, Phase 1). * * Lets the model discover deferred MCP tools by keyword. Matches are added to * the per-stream activation set, so StreamManager's prepareStep advertises @@ -17,8 +17,8 @@ export const createToolSearchTool: ToolFactory = (config) => { // gate builds the catalog — read it lazily at execute time. const runtime = config.toolSearchRuntime; return tool({ - description: TOOL_DEFINITIONS.tool_search.description, - inputSchema: TOOL_DEFINITIONS.tool_search.schema, + description: TOOL_DEFINITIONS.tool_catalog_search.description, + inputSchema: TOOL_DEFINITIONS.tool_catalog_search.schema, execute: ({ query, limit }): ToolSearchToolResult => { const state = runtime?.state; // Defensive: when the post-policy gate deactivated deferral this tool is