From 300dc84c69896140b538d49b3345788f6121cb9e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 29 Sep 2026 22:04:23 -0500 Subject: [PATCH] refactor(ai): keep last-one-wins for colliding tool names --- packages/ai/src/route/client.ts | 18 ++++---- packages/ai/src/tool-names.ts | 42 +++++++++---------- .../openai-compatible-responses.test.ts | 17 -------- 3 files changed, 28 insertions(+), 49 deletions(-) diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index cf3fd7a8b63..d0b310fc1b7 100644 --- a/packages/ai/src/route/client.ts +++ b/packages/ai/src/route/client.ts @@ -581,7 +581,7 @@ const lowerRequest = (request: LLMRequest) => ToolNames.lower(prepareRequest(request), request.model.route.namespaces ?? "flat") const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) { - const lowered = yield* lowerRequest(request) + const lowered = lowerRequest(request) const resolved = lowered.request const route = resolved.model.route @@ -683,15 +683,13 @@ export const layer: Layer.Layer = Layer options: TriggerCompactOptions, ): Effect.Effect function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) { - return lowerRequest(request).pipe( - Effect.flatMap((lowered): Effect.Effect => { - if (options?.mechanism === "trigger" && canCompact(request, options)) - return request.model.route.compact.trigger(lowered.request, executor, options) - if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request)) - return request.model.route.compact.endpoint(lowered.request, executor, options) - return unsupportedCompaction(request, options?.mechanism) - }), - ) + return Effect.suspend((): Effect.Effect => { + if (options?.mechanism === "trigger" && canCompact(request, options)) + return request.model.route.compact.trigger(lowerRequest(request).request, executor, options) + if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request)) + return request.model.route.compact.endpoint(lowerRequest(request).request, executor, options) + return unsupportedCompaction(request, options?.mechanism) + }) } return Service.of({ stream, diff --git a/packages/ai/src/tool-names.ts b/packages/ai/src/tool-names.ts index 2c5b311372f..5602e641655 100644 --- a/packages/ai/src/tool-names.ts +++ b/packages/ai/src/tool-names.ts @@ -1,6 +1,4 @@ -import { Effect } from "effect" import { LLMRequest, Message, ToolDefinition, type LLMEvent, type ToolEntry } from "./schema/index.js" -import { ProviderShared } from "./protocols/shared.js" /** * How a protocol receives tool namespaces. Callers always use the declared @@ -14,21 +12,17 @@ interface Name { readonly name: string } -/** Lower declared tool names to wire names; `raise` maps tool events back. */ -export const lower = Effect.fn("ToolNames.lower")(function* (request: LLMRequest, style: NamespaceStyle) { - const names = new Map() - for (const leaf of walk(request.tools)) { - const bad = leaf.path.find((part) => part.includes(".")) - if (bad !== undefined) return yield* ProviderShared.invalidRequest(`Tool namespace "${bad}" must not contain "."`) - const name = { namespace: leaf.path.join(".") || undefined, name: leaf.tool.name } - const key = id(wire(name, style)) - const taken = names.get(key) - if (taken) - return yield* ProviderShared.invalidRequest( - `Tools "${id(taken)}" and "${id(name)}" both use the provider tool name "${key}"`, - ) - names.set(key, name) - } +/** + * Lower declared tool names to wire names; `raise` maps tool events back. + * Tools that share a wire name resolve last-one-wins in both directions. + */ +export const lower = (request: LLMRequest, style: NamespaceStyle) => { + const names = new Map( + walk(request.tools).map((leaf) => { + const name = { namespace: leaf.path.join(".") || undefined, name: leaf.tool.name } + return [id(wire(name, style)), name] as const + }), + ) return { request: LLMRequest.update(request, { // Native namespaces are one level deep, so deeper levels join into the leaf name. @@ -50,7 +44,7 @@ export const lower = Effect.fn("ToolNames.lower")(function* (request: LLMRequest return name ? { ...event, ...name } : event }, } -}) +} const walk = ( tools: ReadonlyArray, @@ -58,10 +52,14 @@ const walk = ( ): Array<{ path: ReadonlyArray; tool: ToolDefinition }> => tools.flatMap((tool) => (tool.type === "tool" ? [{ path, tool }] : walk(tool.tools, [...path, tool.name]))) -const flatten = (tools: ReadonlyArray) => - walk(tools).map((leaf) => - leaf.path.length ? new ToolDefinition({ ...leaf.tool, name: [...leaf.path, leaf.tool.name].join("_") }) : leaf.tool, - ) +const flatten = (tools: ReadonlyArray) => [ + ...new Map( + walk(tools).map((leaf) => { + const name = [...leaf.path, leaf.tool.name].join("_") + return [name, leaf.path.length ? new ToolDefinition({ ...leaf.tool, name }) : leaf.tool] as const + }), + ).values(), +] const wire = (tool: Name, style: NamespaceStyle): Name => { const path = tool.namespace?.split(".") ?? [] diff --git a/packages/ai/test/provider/openai-compatible-responses.test.ts b/packages/ai/test/provider/openai-compatible-responses.test.ts index baf0f1e8e33..b926c622d7a 100644 --- a/packages/ai/test/provider/openai-compatible-responses.test.ts +++ b/packages/ai/test/provider/openai-compatible-responses.test.ts @@ -213,23 +213,6 @@ describe("Open Responses-compatible route", () => { strict: false, }, ]) - - const collision = yield* compileRequest( - LLM.request({ - model, - tools: [ - ToolDefinition.make({ name: "acme_users", description: "Top-level users", inputSchema: {} }), - { - type: "namespace", - name: "acme", - tools: [ToolDefinition.make({ name: "users", description: "Lookup users", inputSchema: {} })], - }, - ], - }), - ).pipe(Effect.flip) - expect(collision.message).toContain( - 'Tools "acme_users" and "acme.users" both use the provider tool name "acme_users"', - ) }), )