From 8d6d7f494275fa0f19e44bdc6cabe81798b392a1 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 1 Sep 2026 00:22:36 -0500 Subject: [PATCH 1/4] feat(ai): add tool namespaces --- packages/ai/src/cache-policy.ts | 32 +++-- packages/ai/src/llm.ts | 7 +- .../ai/src/protocols/anthropic-messages.ts | 6 +- packages/ai/src/protocols/bedrock-converse.ts | 11 +- packages/ai/src/protocols/gemini.ts | 4 +- packages/ai/src/protocols/mistral-chat.ts | 4 +- packages/ai/src/protocols/open-responses.ts | 42 +++++- packages/ai/src/protocols/openai-chat.ts | 4 +- packages/ai/src/protocols/openai-responses.ts | 48 ++++++- packages/ai/src/protocols/shared.ts | 27 ++++ .../ai/src/protocols/utils/tool-stream.ts | 11 +- packages/ai/src/route/client.ts | 18 ++- packages/ai/src/schema/events.ts | 22 ++- packages/ai/src/schema/messages.ts | 70 ++++++++- packages/ai/src/tool-history.ts | 16 ++- packages/ai/src/tool-runtime.ts | 16 ++- packages/ai/src/tool.ts | 1 + packages/ai/test/cache-policy.test.ts | 53 +++++++ packages/ai/test/compile.test.ts | 64 ++++++++- packages/ai/test/llm.test.ts | 49 ++++++- .../openai-compatible-responses.test.ts | 33 +++++ .../ai/test/provider/openai-responses.test.ts | 133 ++++++++++++++++++ packages/ai/test/tool-history.test.ts | 17 ++- packages/ai/test/tool-runtime.test.ts | 22 +++ packages/core/src/aisdk.ts | 9 +- packages/core/src/tool/runtime.ts | 1 + packages/core/test/tool-schema.test.ts | 5 + 27 files changed, 657 insertions(+), 68 deletions(-) diff --git a/packages/ai/src/cache-policy.ts b/packages/ai/src/cache-policy.ts index 78b376acb5aa..3ab5ffaa171c 100644 --- a/packages/ai/src/cache-policy.ts +++ b/packages/ai/src/cache-policy.ts @@ -11,7 +11,7 @@ // Manual `cache: CacheHint` placements on individual parts are preserved and // count against the four-breakpoint budget; auto only fills remaining slots. import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options.js" -import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages.js" +import { LLMRequest, Message, ToolDefinition, type ContentPart, type ToolEntry } from "./schema/messages.js" const AUTO: CachePolicyObject = { tools: true, @@ -50,18 +50,30 @@ interface Budget { remaining: number } -const markLastTool = ( - tools: ReadonlyArray, - hint: CacheHint, - budget: Budget, -): ReadonlyArray => { +const markLastTool = (tools: ReadonlyArray, hint: CacheHint, budget: Budget): ReadonlyArray => { if (tools.length === 0) return tools - const last = tools.length - 1 - if (tools[last]!.cache || budget.remaining === 0) return tools + const last = tools.findLastIndex((tool) => tool.type === "tool" || tool.tools.some(hasTool)) + if (last === -1) return tools + const target = tools[last]! + if (target.type === "namespace") { + const nested = markLastTool(target.tools, hint, budget) + return nested === target.tools + ? tools + : tools.map((tool, index) => (index === last ? { ...target, tools: nested } : tool)) + } + if (target.cache || budget.remaining === 0) return tools budget.remaining -= 1 - return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool)) + return tools.map((tool, index) => (index === last ? new ToolDefinition({ ...target, cache: hint }) : tool)) } +const hasTool = (tool: ToolEntry): boolean => tool.type === "tool" || tool.tools.some(hasTool) + +const countToolHints = (tools: ReadonlyArray): number => + tools.reduce( + (count, tool) => count + (tool.type === "tool" ? (tool.cache === undefined ? 0 : 1) : countToolHints(tool.tools)), + 0, + ) + const markSystemBoundaries = (system: LLMRequest["system"], hint: CacheHint, budget: Budget): LLMRequest["system"] => { if (system.length === 0) return system let changed = false @@ -122,7 +134,7 @@ const markMessages = ( } const countHints = (request: LLMRequest) => - request.tools.reduce((count, tool) => count + (tool.cache === undefined ? 0 : 1), 0) + + countToolHints(request.tools) + request.system.reduce((count, part) => count + (part.cache === undefined ? 0 : 1), 0) + request.messages.reduce( (count, message) => diff --git a/packages/ai/src/llm.ts b/packages/ai/src/llm.ts index 5f55a9e98390..4f108dc2de8b 100644 --- a/packages/ai/src/llm.ts +++ b/packages/ai/src/llm.ts @@ -12,9 +12,10 @@ import { LanguageModel, SystemPart, ToolChoice, - ToolDefinition, + ToolEntry, type ContentPart, type LanguageModelProviderOptions, + type ToolEntryInput, } from "./schema/index.js" import { make as makeTool, toDefinitions, type ToolSchema } from "./tool.js" @@ -27,7 +28,7 @@ export type RequestInput readonly prompt?: string | ContentPart | ReadonlyArray readonly messages?: ReadonlyArray - readonly tools?: ReadonlyArray + readonly tools?: ReadonlyArray readonly toolChoice?: ToolChoice.Input readonly generation?: GenerationOptions.Input readonly providerOptions?: NoInfer> @@ -56,7 +57,7 @@ export const request = ( ...rest, system: SystemPart.content(requestSystem), messages: [...(messages?.map(Message.make) ?? []), ...(prompt === undefined ? [] : [Message.user(prompt)])], - tools: tools?.map(ToolDefinition.make) ?? [], + tools: tools?.map((tool) => ToolEntry.make(tool)) ?? [], toolChoice: requestToolChoice ? ToolChoice.make(requestToolChoice) : undefined, generation: requestGeneration === undefined ? undefined : GenerationOptions.make(requestGeneration), providerOptions: requestProviderOptions, diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index 6388d4184f44..1710f66b3db4 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -1003,16 +1003,18 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* }) const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { + yield* ProviderShared.requireFlatToolHistory("Anthropic Messages", request.messages) const generation = request.generation const toolSchemaCompatibility = request.model.compatibility?.toolSchema // Allocate the 4-breakpoint budget in invalidation order: tools → system → // messages. Tools live highest in the cache hierarchy, so when callers // over-mark we keep their tool hints and shed the message-tail ones first. const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP) + const definitions = yield* ProviderShared.requireFlatTools("Anthropic Messages", request.tools) const tools = - request.tools.length === 0 + definitions.length === 0 ? undefined - : request.tools.map((tool) => + : definitions.map((tool) => lowerTool( breakpoints, tool, diff --git a/packages/ai/src/protocols/bedrock-converse.ts b/packages/ai/src/protocols/bedrock-converse.ts index 2517b722bf65..91f3be85893c 100644 --- a/packages/ai/src/protocols/bedrock-converse.ts +++ b/packages/ai/src/protocols/bedrock-converse.ts @@ -415,10 +415,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* ( // System prompts share the cache-point convention: emit the text block, then // optionally a positional `cachePoint` marker. -const lowerSystem = ( - breakpoints: BedrockCache.Breakpoints, - system: ReadonlyArray, -) => { +const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArray) => { const content = system .filter((part) => part.text.length > 0) .flatMap((part) => textWithCache(breakpoints, part.text, part.cache)) @@ -426,15 +423,17 @@ const lowerSystem = ( } const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) { + yield* ProviderShared.requireFlatToolHistory("Bedrock Converse", request.messages) const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined + const tools = yield* ProviderShared.requireFlatTools("Bedrock Converse", request.tools) const generation = request.generation // Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in // tools → system → messages order to favour the highest-impact prefixes. const breakpoints = BedrockCache.breakpoints() const toolConfig = (() => { - if (request.tools.length === 0) return undefined + if (tools.length === 0) return undefined return { - tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools), + tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, tools), // Converse has no native "none". Keep definitions stable for prompt // caching and omit only the unsupported choice. toolChoice, diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 6d3a157955fa..daff8e54a4c8 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -465,7 +465,9 @@ function mapSafetySettings(value: unknown) { } const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) { + yield* ProviderShared.requireFlatToolHistory("Gemini", request.messages) const hasTools = request.tools.length > 0 + const tools = yield* ProviderShared.requireFlatTools("Gemini", request.tools) const generation = request.generation const options = resolveOptions(request) const toolSchemaCompatibility = request.model.compatibility?.toolSchema @@ -491,7 +493,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque tools: hasTools ? [ { - functionDeclarations: request.tools.map((tool) => + functionDeclarations: tools.map((tool) => lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), ), }, diff --git a/packages/ai/src/protocols/mistral-chat.ts b/packages/ai/src/protocols/mistral-chat.ts index 8de7a4ee0e4c..b552b03e6434 100644 --- a/packages/ai/src/protocols/mistral-chat.ts +++ b/packages/ai/src/protocols/mistral-chat.ts @@ -396,6 +396,7 @@ const lowerTool = (tool: ToolDefinition): MistralTool => ({ }) export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (request: LLMRequest) { + yield* ProviderShared.requireFlatToolHistory("Mistral Chat", request.messages) const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(MistralOptions))( request.providerOptions ?? {}, ) @@ -414,10 +415,11 @@ export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (reque tool: (name) => ({ type: "function" as const, function: { name } }), }) : undefined + const tools = yield* ProviderShared.requireFlatTools("Mistral Chat", request.tools) return { model: request.model.id, messages: yield* lowerMessages(request), - tools: request.tools.length > 0 ? request.tools.map(lowerTool) : undefined, + tools: tools.length > 0 ? tools.map(lowerTool) : undefined, tool_choice: toolChoice, stream: true as const, max_tokens: request.generation?.maxTokens, diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index 232eafb3e461..a65a0943beed 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -166,6 +166,7 @@ export const InputItem = Schema.Union([ id: Schema.optionalKey(Schema.String), call_id: Schema.String, name: Schema.String, + namespace: Schema.optionalKey(Schema.String), arguments: Schema.String, }), Schema.Struct({ @@ -287,6 +288,7 @@ export const StreamItem = Schema.StructWithRest( id: Schema.optional(Schema.String), call_id: Schema.optional(Schema.String), name: Schema.optional(Schema.String), + namespace: Schema.optional(Schema.String), arguments: Schema.optional(Schema.String), encrypted_content: optionalNull(Schema.String), }), @@ -376,6 +378,7 @@ export type Event = Schema.Schema.Type export interface ProviderAdapter { readonly id: string readonly name: string + readonly toolNamespaces?: boolean readonly lowerMedia?: (input: { readonly part: MediaPart readonly media: ProviderShared.NormalizedMedia @@ -462,6 +465,7 @@ const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenRes ...(id === undefined ? {} : { id }), call_id: part.id, name: part.name, + ...(part.namespace === undefined ? {} : { namespace: part.namespace }), arguments: ProviderShared.encodeJson(part.input), } } @@ -742,14 +746,16 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd adapter: ProviderAdapter, ) { const generation = request.generation + if (adapter.toolNamespaces !== true) yield* ProviderShared.requireFlatToolHistory(adapter.name, request.messages) + const tools = yield* ProviderShared.requireFlatTools(adapter.name, request.tools) const toolSchemaCompatibility = request.model.compatibility?.toolSchema return { model: request.model.id, input: yield* lowerMessages(request, adapter), tools: - request.tools.length === 0 + tools.length === 0 ? undefined - : yield* Effect.forEach(request.tools, (tool) => + : yield* Effect.forEach(tools, (tool) => lowerTool( adapter.name, tool, @@ -1020,11 +1026,20 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => { tools: ToolStream.start(state.tools, id, { id: item.call_id, name: item.name ?? "", + ...(item.namespace === undefined ? {} : { namespace: item.namespace }), input: item.arguments ?? "", providerMetadata: metadata, }), }, - [...events, LLMEvent.toolInputStart({ id: item.call_id, name: item.name ?? "", providerMetadata: metadata })], + [ + ...events, + LLMEvent.toolInputStart({ + id: item.call_id, + name: item.name ?? "", + ...(item.namespace === undefined ? {} : { namespace: item.namespace }), + providerMetadata: metadata, + }), + ], ] } @@ -1137,14 +1152,19 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* ( ? fallback : Object.keys(state.tools).find((key) => state.tools[key]?.id === callID) const id = registered ?? fallback + const pending = registered === undefined ? undefined : state.tools[registered] const tools = - registered !== undefined - ? state.tools - : ToolStream.start(state.tools, id, { + pending === undefined + ? ToolStream.start(state.tools, id, { id: callID, name: item.name, + namespace: item.namespace, providerMetadata: metadata, }) + : ToolStream.start(state.tools, id, { + ...pending, + ...(pending.namespace === undefined && item.namespace !== undefined ? { namespace: item.namespace } : {}), + }) const result = item.arguments === undefined ? yield* ToolStream.finish(state.id, tools, id) @@ -1155,7 +1175,15 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* ( const resultEvents = registered !== undefined || finished.length === 0 ? finished - : [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished] + : [ + LLMEvent.toolInputStart({ + id: callID, + name: item.name, + ...(item.namespace === undefined ? {} : { namespace: item.namespace }), + providerMetadata: metadata, + }), + ...finished, + ] const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle events.push(...resultEvents) return [ diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index 4a1e0d9d03a1..f8b77f592f10 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -727,6 +727,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* ( request: LLMRequest, options: LoweringOptions = {}, ) { + yield* ProviderShared.requireFlatToolHistory("OpenAI Chat", request.messages) // `fromRequest` returns the provider body only. Endpoint, auth, framing, // validation, and HTTP execution are composed by `Route.make`. const reasoningField = request.model.compatibility?.reasoningField @@ -736,6 +737,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* ( ) const generation = request.generation const toolSchemaCompatibility = request.model.compatibility?.toolSchema + const tools = yield* ProviderShared.requireFlatTools("OpenAI Chat", request.tools) const provider = String(request.model.provider) const baseURL = request.model.route.endpoint.baseURL const detectedMaxTokensField = detectMaxTokensField(provider, baseURL) @@ -757,7 +759,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* ( ? hasHistory ? [] : undefined - : request.tools.map((tool) => + : tools.map((tool) => lowerTool( tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility), diff --git a/packages/ai/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts index e744283a9100..92e2fe1dfdde 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js" import { Endpoint } from "../route/endpoint.js" import { Protocol } from "../route/protocol.js" import { HttpTransport } from "../route/transport/index.js" -import { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js" +import { LLMRequest, type JsonSchema, type ToolDefinition, type ToolEntry } from "../schema/index.js" import { OpenResponses } from "./open-responses.js" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js" import { OpenAIImage } from "./utils/openai-image.js" @@ -66,7 +66,18 @@ const OpenAIResponsesHostedToolItem = Schema.Union([ ), ]) -const OpenAIResponsesTools = Schema.Union([OpenResponses.Tool, OpenAIResponsesImageGenerationTool]) +const OpenAIResponsesNamespace = Schema.Struct({ + type: Schema.tag("namespace"), + name: Schema.String, + description: Schema.String, + tools: Schema.Array(OpenResponses.Tool), +}) + +const OpenAIResponsesTools = Schema.Union([ + OpenResponses.Tool, + OpenAIResponsesNamespace, + OpenAIResponsesImageGenerationTool, +]) const OpenAIResponsesToolChoice = Schema.Union([ OpenResponses.ToolChoice, @@ -89,6 +100,7 @@ export type OpenAIResponsesBody = Schema.Schema.Type const adapter = { id: ADAPTER, name: NAME, + toolNamespaces: true, restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined), } satisfies OpenResponses.ProviderAdapter @@ -111,13 +123,37 @@ const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDe return yield* OpenResponses.lowerTool(NAME, tool, inputSchema) }) -const lowerToolChoice = (toolChoice: NonNullable, tools: ReadonlyArray) => +const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* ( + tool: ToolEntry, + compatibility: Parameters[1], +) { + if (tool.type === "tool") + return yield* lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, compatibility)) + if (tool.description === undefined) + return yield* ProviderShared.invalidRequest("OpenAI Responses tool namespaces require a description") + return { + type: "namespace" as const, + name: tool.name, + description: tool.description, + tools: yield* Effect.forEach(tool.tools, (child) => { + if (child.type === "namespace") + return Effect.fail(ProviderShared.invalidRequest("OpenAI Responses does not support nested tool namespaces")) + return OpenResponses.lowerTool( + NAME, + child, + ToolSchemaProjection.modelCompatibility(child.inputSchema, compatibility), + ) + }), + } +}) + +const lowerToolChoice = (toolChoice: NonNullable, tools: ReadonlyArray) => ProviderShared.matchToolChoice(NAME, toolChoice, { auto: () => "auto" as const, none: () => "none" as const, required: () => "required" as const, tool: (name) => - tools.some((tool) => tool.name === name && nativeImageTool(tool) !== undefined) + tools.some((tool) => tool.type === "tool" && tool.name === name && nativeImageTool(tool) !== undefined) ? ({ type: "image_generation" } as const) : { type: "function" as const, name }, }) @@ -137,9 +173,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: tools: request.tools.length === 0 ? undefined - : yield* Effect.forEach(request.tools, (tool) => - lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), - ), + : yield* Effect.forEach(request.tools, (tool) => lowerToolEntry(tool, toolSchemaCompatibility)), tool_choice: body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined), }) diff --git a/packages/ai/src/protocols/shared.ts b/packages/ai/src/protocols/shared.ts index 9ec502de65cf..0e7ca614d914 100644 --- a/packages/ai/src/protocols/shared.ts +++ b/packages/ai/src/protocols/shared.ts @@ -12,6 +12,8 @@ import { type LLMRequest, type MediaPart, type TextPart, + type ToolDefinition, + type ToolEntry, type ToolResultPart, } from "../schema/index.js" import { isRecord } from "../utils/record.js" @@ -44,6 +46,7 @@ export const promptCacheKey = (request: LLMRequest): string | undefined => { export interface ToolAccumulator { readonly id: string readonly name: string + readonly namespace?: string readonly input: string } @@ -254,6 +257,30 @@ export const invalidRequest = (message: string, cause?: unknown) => reason: new InvalidRequestError({ message, cause }), }) +export const requireFlatTools = Effect.fn("ProviderShared.requireFlatTools")(function* ( + protocol: string, + tools: ReadonlyArray, +) { + return yield* Effect.forEach(tools, (tool): Effect.Effect => { + if (tool.type === "namespace") return Effect.fail(invalidRequest(`${protocol} does not support tool namespaces`)) + return Effect.succeed(tool) + }) +}) + +export const requireFlatToolHistory = Effect.fn("ProviderShared.requireFlatToolHistory")(function* ( + protocol: string, + messages: LLMRequest["messages"], +) { + if ( + messages.some((message) => + message.content.some( + (part) => (part.type === "tool-call" || part.type === "tool-result") && part.namespace !== undefined, + ), + ) + ) + return yield* invalidRequest(`${protocol} does not support tool namespaces in message history`) +}) + export const imageResponse = Effect.fn("ProviderShared.imageResponse")(function* ( route: string, name: string, diff --git a/packages/ai/src/protocols/utils/tool-stream.ts b/packages/ai/src/protocols/utils/tool-stream.ts index 256b53a32ec3..2cd4cffd899b 100644 --- a/packages/ai/src/protocols/utils/tool-stream.ts +++ b/packages/ai/src/protocols/utils/tool-stream.ts @@ -55,6 +55,7 @@ const inputStart = (tool: PendingTool) => LLMEvent.toolInputStart({ id: tool.id, name: tool.name, + ...(tool.namespace === undefined ? {} : { namespace: tool.namespace }), providerExecuted: tool.providerExecuted ? true : undefined, providerMetadata: tool.providerMetadata, }) @@ -63,6 +64,7 @@ const inputDelta = (tool: PendingTool, text: string) => LLMEvent.toolInputDelta({ id: tool.id, name: tool.name, + ...(tool.namespace === undefined ? {} : { namespace: tool.namespace }), text, input: Option.getOrElse(parsePartialInput(tool.input), () => ({})), }) @@ -85,6 +87,7 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => { LLMEvent.toolCall({ id: tool.id, name: tool.name, + ...(tool.namespace === undefined ? {} : { namespace: tool.namespace }), input, providerExecuted: tool.providerExecuted ? true : undefined, providerMetadata: tool.providerMetadata, @@ -94,7 +97,12 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => { } const finishEvents = (tool: PendingTool, event: ToolCall): ReadonlyArray => [ - LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), + LLMEvent.toolInputEnd({ + id: tool.id, + name: tool.name, + ...(tool.namespace === undefined ? {} : { namespace: tool.namespace }), + providerMetadata: tool.providerMetadata, + }), event, ] @@ -150,6 +158,7 @@ export const appendOrStart = ( const tool = { id, name, + namespace: current?.namespace, input: `${current?.input ?? ""}${delta.text}`, providerExecuted: current?.providerExecuted, providerMetadata: current?.providerMetadata, diff --git a/packages/ai/src/route/client.ts b/packages/ai/src/route/client.ts index 186c20143f84..552a24b79497 100644 --- a/packages/ai/src/route/client.ts +++ b/packages/ai/src/route/client.ts @@ -448,10 +448,22 @@ export function make( } const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) { - const original = applyCachePolicy(resolveRequestOptions(request)) + const original = resolveRequestOptions(request) const sanitized = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined })) - const tools = [...new Map(sanitized.tools.map((tool) => [tool.name, tool])).values()] - const resolved = tools.length === sanitized.tools.length ? sanitized : LLMRequest.update(sanitized, { tools }) + const dedupe = (tools: LLMRequest["tools"]): LLMRequest["tools"] => { + const result = Array.from( + new Map( + tools.map((tool) => [ + `${tool.type}:${tool.name}`, + tool.type === "tool" ? tool : { ...tool, tools: dedupe(tool.tools) }, + ]), + ).values(), + ) + return result.length === tools.length && result.every((tool, index) => tool === tools[index]) ? tools : result + } + const tools = dedupe(sanitized.tools) + const deduplicated = tools === sanitized.tools ? sanitized : LLMRequest.update(sanitized, { tools }) + const resolved = applyCachePolicy(deduplicated) const route = resolved.model.route const body = yield* route.body diff --git a/packages/ai/src/schema/events.ts b/packages/ai/src/schema/events.ts index c1357fb8f1fd..42dc37311f4a 100644 --- a/packages/ai/src/schema/events.ts +++ b/packages/ai/src/schema/events.ts @@ -146,6 +146,7 @@ export const ToolInputStart = Schema.Struct({ type: Schema.tag("tool-input-start"), id: ToolCallID, name: Schema.String, + namespace: Schema.optional(Schema.String), providerExecuted: Schema.optional(Schema.Boolean), providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.ToolInputStart" }) @@ -155,6 +156,7 @@ export const ToolInputDelta = Schema.Struct({ type: Schema.tag("tool-input-delta"), id: ToolCallID, name: Schema.String, + namespace: Schema.optional(Schema.String), text: Schema.String, /** Best-effort parse of all input fragments received through this delta. */ input: Schema.optional(Schema.Unknown), @@ -165,6 +167,7 @@ export const ToolInputEnd = Schema.Struct({ type: Schema.tag("tool-input-end"), id: ToolCallID, name: Schema.String, + namespace: Schema.optional(Schema.String), providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.ToolInputEnd" }) export type ToolInputEnd = Schema.Schema.Type @@ -174,6 +177,7 @@ export const ToolInputError = Schema.Struct({ type: Schema.tag("tool-input-error"), id: ToolCallID, name: Schema.String, + namespace: Schema.optional(Schema.String), raw: Schema.String, }).annotate({ identifier: "LLM.Event.ToolInputError" }) export type ToolInputError = Schema.Schema.Type @@ -182,6 +186,7 @@ export const ToolCall = Schema.Struct({ type: Schema.tag("tool-call"), id: ToolCallID, name: Schema.String, + namespace: Schema.optional(Schema.String), input: Schema.Unknown, providerExecuted: Schema.optional(Schema.Boolean), providerMetadata: Schema.optional(ProviderMetadata), @@ -192,6 +197,7 @@ export const ToolResult = Schema.Struct({ type: Schema.tag("tool-result"), id: ToolCallID, name: Schema.String, + namespace: Schema.optional(Schema.String), result: ToolResultValue, output: Schema.optional(ToolOutput), providerExecuted: Schema.optional(Schema.Boolean), @@ -203,6 +209,7 @@ export const ToolError = Schema.Struct({ type: Schema.tag("tool-error"), id: ToolCallID, name: Schema.String, + namespace: Schema.optional(Schema.String), message: Schema.String, error: Schema.optional(Schema.Defect()), providerMetadata: Schema.optional(ProviderMetadata), @@ -373,6 +380,7 @@ interface ContentAssembly { interface ToolInputAssembly { readonly name: string + readonly namespace?: string readonly text: string readonly providerMetadata?: ProviderMetadata } @@ -510,12 +518,17 @@ const reduceToolInputStart = (state: ResponseState, event: ToolInputStart): Resp ...state, toolInputs: { ...state.toolInputs, - [event.id]: { name: event.name, text: "", providerMetadata: event.providerMetadata }, + [event.id]: { + name: event.name, + namespace: event.namespace, + text: "", + providerMetadata: event.providerMetadata, + }, }, }) const reduceToolInputDelta = (state: ResponseState, event: ToolInputDelta): ResponseState => { - const current = state.toolInputs[event.id] ?? { name: event.name, text: "" } + const current = state.toolInputs[event.id] ?? { name: event.name, namespace: event.namespace, text: "" } return { ...state, toolInputs: { ...state.toolInputs, [event.id]: { ...current, text: current.text + event.text } }, @@ -523,7 +536,7 @@ const reduceToolInputDelta = (state: ResponseState, event: ToolInputDelta): Resp } const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): ResponseState => { - const current = state.toolInputs[event.id] ?? { name: event.name, text: "" } + const current = state.toolInputs[event.id] ?? { name: event.name, namespace: event.namespace, text: "" } return { ...state, toolInputs: { @@ -531,6 +544,7 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response [event.id]: { ...current, name: event.name, + namespace: event.namespace, providerMetadata: event.providerMetadata ?? current.providerMetadata, }, }, @@ -541,6 +555,7 @@ const toolCallContent = (event: ToolCall): ContentPart => ToolCallPart.make({ id: event.id, name: event.name, + ...(event.namespace === undefined ? {} : { namespace: event.namespace }), input: event.input, ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }), ...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }), @@ -550,6 +565,7 @@ const toolResultContent = (event: ToolResult): ContentPart => ToolResultPart.make({ id: event.id, name: event.name, + ...(event.namespace === undefined ? {} : { namespace: event.namespace }), result: event.result, ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }), ...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }), diff --git a/packages/ai/src/schema/messages.ts b/packages/ai/src/schema/messages.ts index 46f5e8aafc5c..2320bc6e2e8b 100644 --- a/packages/ai/src/schema/messages.ts +++ b/packages/ai/src/schema/messages.ts @@ -132,6 +132,7 @@ export const ToolCallPart = Object.assign( type: Schema.Literal("tool-call"), id: Schema.String, name: Schema.String, + namespace: Schema.optional(Schema.String), input: Schema.Unknown, providerExecuted: Schema.optional(Schema.Boolean), cache: Schema.optional(CacheHint), @@ -149,6 +150,7 @@ export const ToolResultPart = Object.assign( type: Schema.Literal("tool-result"), id: Schema.String, name: Schema.String, + namespace: Schema.optional(Schema.String), result: ToolResultValue, providerExecuted: Schema.optional(Schema.Boolean), cache: Schema.optional(CacheHint), @@ -165,6 +167,7 @@ export const ToolResultPart = Object.assign( type: "tool-result", id: input.id, name: input.name, + ...(input.namespace === undefined ? {} : { namespace: input.namespace }), result: ToolResultValue.make(input.result, input.resultType), providerExecuted: input.providerExecuted, cache: input.cache, @@ -231,7 +234,18 @@ export namespace Message { make({ role: "tool", content: ["type" in result ? result : ToolResultPart.make(result)] }) } +export type ToolDefinitionInput = { + readonly name: string + readonly description: string + readonly inputSchema: JsonSchema + readonly outputSchema?: JsonSchema + readonly cache?: CacheHint + readonly metadata?: Readonly> + readonly native?: Readonly> +} + export class ToolDefinition extends Schema.Class("LLM.ToolDefinition")({ + type: Schema.Literal("tool"), name: Schema.String, description: Schema.String, inputSchema: JsonSchema, @@ -239,15 +253,65 @@ export class ToolDefinition extends Schema.Class("LLM.ToolDefini cache: Schema.optional(CacheHint), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), -}) {} +}) { + constructor(input: ToolDefinitionInput) { + super({ ...input, type: "tool" }) + } +} export namespace ToolDefinition { - export type Input = ToolDefinition | ConstructorParameters[0] + export type Input = ToolDefinition | ToolDefinitionInput /** Normalize tool definition input into the canonical `ToolDefinition` class. */ export const make = (input: Input) => (input instanceof ToolDefinition ? input : new ToolDefinition(input)) } +export type ToolNamespace = { + readonly type: "namespace" + readonly name: string + readonly description?: string + readonly tools: ReadonlyArray +} + +export type ToolNamespaceInput = Omit & { + readonly tools: ReadonlyArray +} +export type ToolNamespaceEntryInput = ToolNamespaceInput & { readonly type: "namespace" } + +export const ToolNamespace: Schema.Codec & { + readonly make: (input: ToolNamespace | ToolNamespaceInput) => ToolNamespace +} = Object.assign( + Schema.Struct({ + type: Schema.Literal("namespace"), + name: Schema.String, + description: Schema.optional(Schema.String), + tools: Schema.Array(Schema.suspend((): Schema.Codec => ToolEntry)), + }).annotate({ identifier: "LLM.ToolNamespace" }), + { + make: (input: ToolNamespace | ToolNamespaceInput): ToolNamespace => ({ + type: "namespace", + name: input.name, + ...(input.description === undefined ? {} : { description: input.description }), + tools: input.tools.map((tool) => ToolEntry.make(tool)), + }), + }, +) + +export type ToolEntry = ToolDefinition | ToolNamespace +export type ToolEntryInput = ToolDefinition.Input | ToolNamespaceEntryInput +export const ToolEntry: Schema.Codec & { + readonly make: (input: ToolEntryInput) => ToolEntry +} = Object.assign( + Schema.Union([ToolDefinition, ToolNamespace]).pipe( + Schema.toTaggedUnion("type"), + Schema.annotate({ identifier: "LLM.ToolEntry" }), + ), + { + make: (input: ToolEntryInput): ToolEntry => + "type" in input && input.type === "namespace" ? ToolNamespace.make(input) : ToolDefinition.make(input), + }, +) + export class ToolChoice extends Schema.Class("LLM.ToolChoice")({ type: Schema.Literals(["auto", "none", "required", "tool"]), name: Schema.optional(Schema.String), @@ -277,7 +341,7 @@ export class LLMRequest extends Schema.Class("LLM.Request")({ model: LanguageModelSchema, system: Schema.Array(SystemPart), messages: Schema.Array(Message), - tools: Schema.Array(ToolDefinition), + tools: Schema.Array(ToolEntry), toolChoice: Schema.optional(ToolChoice), generation: Schema.optional(GenerationOptions), providerOptions: Schema.optional(ProviderOptions), diff --git a/packages/ai/src/tool-history.ts b/packages/ai/src/tool-history.ts index a36bbbee3cc1..6c9ecd32a8ca 100644 --- a/packages/ai/src/tool-history.ts +++ b/packages/ai/src/tool-history.ts @@ -37,7 +37,13 @@ function missingToolResults(calls: Iterable) { return new Message({ role: "tool", content: [...calls].map((call) => - ToolResultPart.make({ id: call.id, name: call.name, result: MISSING_TOOL_RESULT, resultType: "error" }), + ToolResultPart.make({ + id: call.id, + name: call.name, + namespace: call.namespace, + result: MISSING_TOOL_RESULT, + resultType: "error", + }), ), }) } @@ -47,7 +53,7 @@ function normalizeToolMessage(message: Message, pending: Map part === message.content[index])) return message @@ -60,8 +66,10 @@ function normalizeToolMessage(message: Message, pending: Map => { - const tool = tools[call.name] - if (!tool) return Effect.succeed(result(call, { type: "error", value: `Unknown tool: ${call.name}` })) + const name = call.namespace === undefined ? call.name : `${call.namespace}.${call.name}` + const tool = tools[name] + if (!tool) return Effect.succeed(result(call, { type: "error", value: `Unknown tool: ${name}` })) if (!tool.execute) - return Effect.succeed(result(call, { type: "error", value: `Tool has no execute handler: ${call.name}` })) + return Effect.succeed(result(call, { type: "error", value: `Tool has no execute handler: ${name}` })) return decodeAndExecute(tool, call).pipe( Effect.map((value) => result(call, value)), @@ -38,7 +39,11 @@ const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect new ToolFailure({ message: `Invalid tool input: ${error.message}` })), Effect.flatMap((decoded) => - tool.execute!(decoded, { id: call.id, name: call.name }).pipe( + tool.execute!(decoded, { + id: call.id, + name: call.name, + ...(call.namespace === undefined ? {} : { namespace: call.namespace }), + }).pipe( Effect.flatMap((value) => tool._encode(value).pipe( Effect.mapError( @@ -71,6 +76,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement, LLMEvent.toolError({ id: call.id, name: call.name, + ...(call.namespace === undefined ? {} : { namespace: call.namespace }), message: String(settlement.result.value), error, providerMetadata: call.providerMetadata, @@ -78,6 +84,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement, LLMEvent.toolResult({ id: call.id, name: call.name, + ...(call.namespace === undefined ? {} : { namespace: call.namespace }), result: settlement.result, providerMetadata: call.providerMetadata, }), @@ -86,6 +93,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement, LLMEvent.toolResult({ id: call.id, name: call.name, + ...(call.namespace === undefined ? {} : { namespace: call.namespace }), result: settlement.result, output: settlement.output, providerMetadata: call.providerMetadata, diff --git a/packages/ai/src/tool.ts b/packages/ai/src/tool.ts index 47181f922802..e324cb2000d1 100644 --- a/packages/ai/src/tool.ts +++ b/packages/ai/src/tool.ts @@ -16,6 +16,7 @@ export type ToolSchema = Schema.Codec export interface ToolExecuteContext { readonly id: ToolCallPart["id"] readonly name: ToolCallPart["name"] + readonly namespace?: ToolCallPart["namespace"] } export type ToolExecute, Success extends ToolSchema> = ( diff --git a/packages/ai/test/cache-policy.test.ts b/packages/ai/test/cache-policy.test.ts index aa4b7aeb2295..6c5f00c5fc4d 100644 --- a/packages/ai/test/cache-policy.test.ts +++ b/packages/ai/test/cache-policy.test.ts @@ -215,6 +215,35 @@ describe("applyCachePolicy", () => { }), ) + it.effect("deduplicates tools before counting cache hints", () => + Effect.gen(function* () { + const manual = new CacheHint({ type: "ephemeral" }) + const duplicate = (description: string) => ({ + name: "lookup", + description, + inputSchema: { type: "object" }, + cache: manual, + }) + const prepared = yield* compileRequest( + LLM.request({ + model: anthropicModel, + tools: [ + duplicate("first"), + duplicate("second"), + duplicate("third"), + duplicate("fourth"), + { name: "lookup", description: "final", inputSchema: { type: "object" } }, + ], + cache: { tools: true }, + }), + ) + + expect(prepared.body.tools).toEqual([ + expect.objectContaining({ name: "lookup", description: "final", cache_control: { type: "ephemeral" } }), + ]) + }), + ) + it.effect("auto policy preserves manual CacheHints on other parts", () => Effect.gen(function* () { const prepared = yield* compileRequest( @@ -281,6 +310,30 @@ describe("applyCachePolicy", () => { }), ) + test("marks the final leaf inside a tool namespace", () => { + const request = LLM.request({ + model: anthropicModel, + tools: [ + { + type: "namespace", + name: "crm", + tools: [ + { name: "lookup", description: "lookup", inputSchema: {} }, + { name: "orders", description: "orders", inputSchema: {} }, + ], + }, + ], + cache: { tools: true }, + }) + const applied = applyCachePolicy(request) + const namespace = applied.tools[0] + + expect(namespace?.type).toBe("namespace") + if (namespace?.type !== "namespace") throw new Error("Expected namespace") + expect(namespace.tools[0]).not.toHaveProperty("cache") + expect(namespace.tools[1]).toHaveProperty("cache", { type: "ephemeral" }) + }) + it.effect("ttlSeconds in the policy flows through to wire markers", () => Effect.gen(function* () { const prepared = yield* compileRequest( diff --git a/packages/ai/test/compile.test.ts b/packages/ai/test/compile.test.ts index 502381e864b8..760521a12466 100644 --- a/packages/ai/test/compile.test.ts +++ b/packages/ai/test/compile.test.ts @@ -1,8 +1,16 @@ import { describe, expect, test } from "bun:test" import { Effect, Ref, Schema } from "effect" import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" -import { LLM, LLMRequest, Message, ToolCallPart, ToolDefinition, mergeProviderOptions } from "../src/index.js" -import { AnthropicMessages, OpenAIChat } from "../src/protocols.js" +import { + LLM, + LLMRequest, + Message, + ToolCallPart, + ToolDefinition, + ToolNamespace, + mergeProviderOptions, +} from "../src/index.js" +import { AnthropicMessages, OpenAIChat, OpenAIResponses } from "../src/protocols.js" import { Auth, LLMClient } from "../src/route.js" import { compileRequest } from "../src/route/client.js" import { it } from "./lib/effect.js" @@ -106,6 +114,58 @@ describe("request option precedence", () => { }), ) + it.effect("deduplicates tools within each namespace", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: OpenAIResponses.route.model({ id: "gpt-5.4" }), + tools: [ + ToolDefinition.make({ name: "crm", description: "Top-level CRM tool", inputSchema: {} }), + ToolNamespace.make({ + name: "crm", + description: "CRM tools", + tools: [ + ToolDefinition.make({ name: "lookup", description: "old", inputSchema: {} }), + ToolDefinition.make({ name: "search", description: "search", inputSchema: {} }), + ToolDefinition.make({ name: "lookup", description: "new", inputSchema: {} }), + ], + }), + ToolNamespace.make({ + name: "support", + description: "Support tools", + tools: [ToolDefinition.make({ name: "lookup", description: "support", inputSchema: {} })], + }), + ], + }), + ) + + expect(prepared.body.tools).toEqual([ + { + type: "function", + name: "crm", + description: "Top-level CRM tool", + parameters: {}, + strict: false, + }, + { + type: "namespace", + name: "crm", + description: "CRM tools", + tools: [ + { type: "function", name: "lookup", description: "new", parameters: {}, strict: false }, + { type: "function", name: "search", description: "search", parameters: {}, strict: false }, + ], + }, + { + type: "namespace", + name: "support", + description: "Support tools", + tools: [{ type: "function", name: "lookup", description: "support", parameters: {}, strict: false }], + }, + ]) + }), + ) + it.effect("normalizes tool history before protocol lowering", () => Effect.gen(function* () { const prepared = yield* compileRequest( diff --git a/packages/ai/test/llm.test.ts b/packages/ai/test/llm.test.ts index 53e1e4b29d66..408d451a4d17 100644 --- a/packages/ai/test/llm.test.ts +++ b/packages/ai/test/llm.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import { CacheHint, LLM, LLMResponse } from "../src/index.js" +import { Schema } from "effect" +import { CacheHint, LLM, LLMResponse, ToolEntry, ToolNamespace } from "../src/index.js" import * as OpenAIChat from "../src/protocols/openai-chat.js" import * as OpenAIResponses from "../src/protocols/openai-responses.js" import { @@ -17,6 +18,52 @@ const chatRoute = OpenAIChat.route const responsesRoute = OpenAIResponses.route describe("llm constructors", () => { + test("normalizes recursive tool namespaces", () => { + const request = LLM.request({ + model: LanguageModel.make({ id: "fake-model", provider: "fake", route: responsesRoute }), + tools: [ + { + type: "namespace", + name: "crm", + description: "Customer management", + tools: [ + { name: "lookup", description: "Look up a customer", inputSchema: { type: "object" } }, + { + type: "namespace", + name: "orders", + tools: [{ name: "list", description: "List orders", inputSchema: { type: "object" } }], + }, + ], + }, + ], + }) + + expect(request.tools[0]).toEqual({ + type: "namespace", + name: "crm", + description: "Customer management", + tools: [ + expect.objectContaining({ type: "tool", name: "lookup" }), + { + type: "namespace", + name: "orders", + description: undefined, + tools: [expect.objectContaining({ type: "tool", name: "list" })], + }, + ], + }) + expect(request.tools[0]).toEqual( + ToolNamespace.make({ + name: "crm", + description: "Customer management", + tools: request.tools[0]!.type === "namespace" ? request.tools[0].tools : [], + }), + ) + expect(Schema.decodeUnknownSync(ToolEntry)(Schema.encodeUnknownSync(ToolEntry)(request.tools[0]))).toEqual( + request.tools[0], + ) + }) + test("builds canonical schema classes from ergonomic input", () => { const request = LLM.request({ id: "req_1", diff --git a/packages/ai/test/provider/openai-compatible-responses.test.ts b/packages/ai/test/provider/openai-compatible-responses.test.ts index a24b0c7c5a9e..e24d6a1224f4 100644 --- a/packages/ai/test/provider/openai-compatible-responses.test.ts +++ b/packages/ai/test/provider/openai-compatible-responses.test.ts @@ -171,6 +171,39 @@ describe("Open Responses-compatible route", () => { }), ) + it.effect("rejects tool namespaces", () => + Effect.gen(function* () { + const model = configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model( + "example-model", + ) + const error = yield* compileRequest( + LLM.request({ model, tools: [{ type: "namespace", name: "crm", tools: [] }] }), + ).pipe(Effect.flip) + + expect(error.reason._tag).toBe("InvalidRequest") + expect(error.message).toContain("does not support tool namespaces") + }), + ) + + it.effect("rejects tool namespaces in history", () => + Effect.gen(function* () { + const model = configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model( + "example-model", + ) + const error = yield* compileRequest( + LLM.request({ + model, + messages: [ + Message.assistant({ type: "tool-call", id: "call_1", name: "lookup", namespace: "crm", input: {} }), + ], + }), + ).pipe(Effect.flip) + + expect(error.reason._tag).toBe("InvalidRequest") + expect(error.message).toContain("does not support tool namespaces in message history") + }), + ) + it.effect("lowers canonical parallel tool control", () => Effect.gen(function* () { const model = configure({ diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index c2d3347c00d1..8320f29b1e11 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -12,6 +12,7 @@ import { LanguageModel, ToolCallPart, ToolDefinition, + ToolNamespace, ToolResultPart, TransportError, Usage, @@ -143,6 +144,71 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("lowers tool namespaces without flattening leaf names", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model, + prompt: "Find a customer and their orders.", + tools: [ + ToolNamespace.make({ + name: "crm", + description: "Customer management", + tools: [ + ToolDefinition.make({ name: "lookup", description: "Look up a customer", inputSchema: {} }), + ToolDefinition.make({ name: "orders", description: "List customer orders", inputSchema: {} }), + ], + }), + ], + }), + ) + + expect(prepared.body.tools).toEqual([ + { + type: "namespace", + name: "crm", + description: "Customer management", + tools: [ + { type: "function", name: "lookup", description: "Look up a customer", parameters: {}, strict: false }, + { type: "function", name: "orders", description: "List customer orders", parameters: {}, strict: false }, + ], + }, + ]) + }), + ) + + it.effect("rejects nested tool namespaces", () => + Effect.gen(function* () { + const error = yield* compileRequest( + LLM.request({ + model, + tools: [ + { + type: "namespace", + name: "crm", + description: "Customer management", + tools: [{ type: "namespace", name: "orders", description: "Order management", tools: [] }], + }, + ], + }), + ).pipe(Effect.flip) + + expect(error.reason._tag).toBe("InvalidRequest") + expect(error.message).toContain("does not support nested tool namespaces") + }), + ) + + it.effect("requires tool namespace descriptions", () => + Effect.gen(function* () { + const error = yield* compileRequest( + LLM.request({ model, tools: [{ type: "namespace", name: "crm", tools: [] }] }), + ).pipe(Effect.flip) + + expect(error.reason._tag).toBe("InvalidRequest") + expect(error.message).toContain("tool namespaces require a description") + }), + ) + it.effect("rejects invalid hosted image generation options locally", () => Effect.gen(function* () { const error = yield* compileRequest( @@ -2129,6 +2195,73 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("preserves tool namespaces through streaming and history replay", () => + Effect.gen(function* () { + const item = { + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "lookup", + arguments: "", + } + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "response.output_item.added", output_index: 0, item }, + { + type: "response.function_call_arguments.delta", + output_index: 0, + item_id: "fc_1", + delta: '{"id":"123"}', + }, + { + type: "response.output_item.done", + output_index: 0, + item: { ...item, namespace: "crm", arguments: '{"id":"123"}' }, + }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + const toolEvents = response.events.filter((event) => event.type.startsWith("tool-")) + expect(toolEvents).toEqual([ + expect.objectContaining({ type: "tool-input-start", name: "lookup" }), + expect.objectContaining({ type: "tool-input-delta", name: "lookup" }), + expect.objectContaining({ type: "tool-input-end", name: "lookup", namespace: "crm" }), + expect.objectContaining({ type: "tool-call", name: "lookup", namespace: "crm", input: { id: "123" } }), + ]) + expect(toolEvents[0]).not.toHaveProperty("namespace") + expect(toolEvents[1]).not.toHaveProperty("namespace") + expect(response.message.content).toEqual([ + expect.objectContaining({ type: "tool-call", name: "lookup", namespace: "crm", input: { id: "123" } }), + ]) + + const prepared = yield* compileRequest( + LLM.request({ + model, + messages: [ + response.message, + Message.tool({ id: "call_1", name: "lookup", namespace: "crm", result: { customer: "Ada" } }), + ], + }), + ) + expect(prepared.body.input).toEqual([ + { + type: "function_call", + id: "fc_1", + call_id: "call_1", + namespace: "crm", + name: "lookup", + arguments: '{"id":"123"}', + }, + { type: "function_call_output", call_id: "call_1", output: '{"customer":"Ada"}' }, + ]) + }), + ) + it.effect("routes item-id-less function arguments by output index and prefers item completion", () => Effect.gen(function* () { const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" } diff --git a/packages/ai/test/tool-history.test.ts b/packages/ai/test/tool-history.test.ts index 302e3f4b334e..9c674d170fd2 100644 --- a/packages/ai/test/tool-history.test.ts +++ b/packages/ai/test/tool-history.test.ts @@ -15,13 +15,7 @@ describe("tool history normalization", () => { Message.assistant(toolCall("trailing")), ]) - expect(normalized.map((message) => message.role)).toEqual([ - "assistant", - "tool", - "tool", - "user", - "assistant", - ]) + expect(normalized.map((message) => message.role)).toEqual(["assistant", "tool", "tool", "user", "assistant"]) expect(normalized[1]?.content[0]).toMatchObject({ type: "tool-result", id: "first", name: "first" }) expect(normalized[2]?.content).toEqual([ { type: "tool-result", id: "second", name: "second", result: { type: "error", value: "Tool result missing" } }, @@ -74,4 +68,13 @@ describe("tool history normalization", () => { expect(normalizeToolHistory([orphan, hosted])).toEqual([orphan, hosted]) }) + + test("uses a matching call as the complete tool identity", () => { + const normalized = normalizeToolHistory([ + Message.assistant(ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })), + Message.tool(ToolResultPart.make({ id: "call_1", name: "wrong", namespace: "stale", result: "done" })), + ]) + + expect(normalized[1]?.content[0]).toMatchObject({ name: "lookup", namespace: undefined }) + }) }) diff --git a/packages/ai/test/tool-runtime.test.ts b/packages/ai/test/tool-runtime.test.ts index 8930c486afaf..523279c17597 100644 --- a/packages/ai/test/tool-runtime.test.ts +++ b/packages/ai/test/tool-runtime.test.ts @@ -7,6 +7,7 @@ import { LLMEvent, LLMRequest, LLMResponse, + ToolCallPart, ToolChoice, ToolOutput, toDefinitions, @@ -36,6 +37,27 @@ const baseRequest = LLM.request({ }) const weatherFailureCause = new Error("weather lookup denied") +test("dispatches namespaced calls by qualified identity", async () => { + let context: ToolExecuteContext | undefined + const lookup = Tool.make({ + description: "Look up a customer.", + parameters: Schema.Struct({}), + success: Schema.String, + execute: (_, value) => { + context = value + return Effect.succeed("customer") + }, + }) + const call = ToolCallPart.make({ id: "call_1", namespace: "crm", name: "lookup", input: {} }) + const result = await Effect.runPromise( + ToolRuntime.dispatch({ "crm.lookup": lookup, lookup: schema_only_weather }, call), + ) + + expect(result.result).toEqual({ type: "text", value: "customer" }) + expect(context).toEqual({ id: "call_1", namespace: "crm", name: "lookup" }) + expect(result.events).toEqual([expect.objectContaining({ type: "tool-result", namespace: "crm", name: "lookup" })]) +}) + const get_weather = Tool.make({ description: "Get current weather for a city.", parameters: Schema.Struct({ city: Schema.String }), diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 841ca6a51728..8ebe905fa5b0 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -327,7 +327,11 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) { }, body: { schema: Schema.Unknown, - from: (request) => Effect.succeed(callOptions(request, packageName, info.modelID ?? info.id, optionKey)), + from: Effect.fn("AISDK.fromRequest")(function* (request) { + yield* ProviderShared.requireFlatToolHistory("AI SDK", request.messages) + const tools = yield* ProviderShared.requireFlatTools("AI SDK", request.tools) + return callOptions(request, packageName, info.modelID ?? info.id, optionKey, tools) + }), }, with: () => route, model: (input) => @@ -406,6 +410,7 @@ function callOptions( packageName: string | undefined, modelID: ID, optionKey: string, + tools: ReadonlyArray, ): LanguageModelV3CallOptions { return { prompt: prompt(request), @@ -417,7 +422,7 @@ function callOptions( presencePenalty: request.generation?.presencePenalty, frequencyPenalty: request.generation?.frequencyPenalty, seed: request.generation?.seed, - tools: request.tools.map(tool), + tools: tools.map(tool), toolChoice: toolChoice(request.toolChoice), headers: request.http?.headers, providerOptions: requestProviderOptions(request.providerOptions, packageName, modelID, optionKey), diff --git a/packages/core/src/tool/runtime.ts b/packages/core/src/tool/runtime.ts index b56d488db037..b08f231e6b58 100644 --- a/packages/core/src/tool/runtime.ts +++ b/packages/core/src/tool/runtime.ts @@ -18,6 +18,7 @@ const jsonSchemas = Effect.runSync( ) export const definition = (tool: Tool.Info): ToolDefinition => ({ + type: "tool", name: effectiveName(tool), description: tool.description, inputSchema: inputJsonSchema(tool.input), diff --git a/packages/core/test/tool-schema.test.ts b/packages/core/test/tool-schema.test.ts index 4adb07f2ec54..443fa88ee03d 100644 --- a/packages/core/test/tool-schema.test.ts +++ b/packages/core/test/tool-schema.test.ts @@ -27,6 +27,7 @@ test("tools are structural values", async () => { const tool: Info = config expect(definition(tool)).toEqual({ + type: "tool", name: "foreign", description: "Foreign tool", inputSchema: { @@ -142,6 +143,7 @@ test("portable schemas validate and describe typed tools", async () => { } expect(definition(tool)).toEqual({ + type: "tool", name: "portable", description: "Portable tool", inputSchema: { type: "object", properties: { count: { type: "string" } } }, @@ -161,6 +163,7 @@ test("Zod schemas validate, transform, and describe typed tools", async () => { } expect(definition(tool)).toEqual({ + type: "tool", name: "zod", description: "Zod tool", inputSchema: { @@ -317,6 +320,7 @@ test("raw JSON schemas validate and decode tool input", async () => { } expect(definition(tool)).toEqual({ + type: "tool", name: "raw", description: "Raw tool", inputSchema: input, @@ -400,6 +404,7 @@ test("missing external input schemas fall back to an empty schema", () => { } as unknown as Info expect(definition(tool)).toEqual({ + type: "tool", name: "external", description: "External tool", inputSchema: {}, From 6806ac451b400b3c5e0ac6c35bb2e58ae8e60847 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 1 Sep 2026 10:44:32 -0500 Subject: [PATCH 2/4] refactor(ai): simplify namespace propagation --- packages/ai/src/protocols/open-responses.ts | 8 ++++---- packages/ai/src/protocols/utils/tool-stream.ts | 8 ++++---- packages/ai/src/schema/events.ts | 4 ++-- packages/ai/src/schema/messages.ts | 2 +- packages/ai/src/tool-runtime.ts | 8 ++++---- .../test/provider/open-responses-lifecycle.test.ts | 14 +++++++++++--- packages/ai/test/provider/openai-responses.test.ts | 4 ++-- 7 files changed, 28 insertions(+), 20 deletions(-) diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index a65a0943beed..68be97eacba3 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -1026,7 +1026,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => { tools: ToolStream.start(state.tools, id, { id: item.call_id, name: item.name ?? "", - ...(item.namespace === undefined ? {} : { namespace: item.namespace }), + namespace: item.namespace, input: item.arguments ?? "", providerMetadata: metadata, }), @@ -1036,7 +1036,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => { LLMEvent.toolInputStart({ id: item.call_id, name: item.name ?? "", - ...(item.namespace === undefined ? {} : { namespace: item.namespace }), + namespace: item.namespace, providerMetadata: metadata, }), ], @@ -1163,7 +1163,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* ( }) : ToolStream.start(state.tools, id, { ...pending, - ...(pending.namespace === undefined && item.namespace !== undefined ? { namespace: item.namespace } : {}), + namespace: pending.namespace ?? item.namespace, }) const result = item.arguments === undefined @@ -1179,7 +1179,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* ( LLMEvent.toolInputStart({ id: callID, name: item.name, - ...(item.namespace === undefined ? {} : { namespace: item.namespace }), + namespace: item.namespace, providerMetadata: metadata, }), ...finished, diff --git a/packages/ai/src/protocols/utils/tool-stream.ts b/packages/ai/src/protocols/utils/tool-stream.ts index 2cd4cffd899b..7a5847ab4901 100644 --- a/packages/ai/src/protocols/utils/tool-stream.ts +++ b/packages/ai/src/protocols/utils/tool-stream.ts @@ -55,7 +55,7 @@ const inputStart = (tool: PendingTool) => LLMEvent.toolInputStart({ id: tool.id, name: tool.name, - ...(tool.namespace === undefined ? {} : { namespace: tool.namespace }), + namespace: tool.namespace, providerExecuted: tool.providerExecuted ? true : undefined, providerMetadata: tool.providerMetadata, }) @@ -64,7 +64,7 @@ const inputDelta = (tool: PendingTool, text: string) => LLMEvent.toolInputDelta({ id: tool.id, name: tool.name, - ...(tool.namespace === undefined ? {} : { namespace: tool.namespace }), + namespace: tool.namespace, text, input: Option.getOrElse(parsePartialInput(tool.input), () => ({})), }) @@ -87,7 +87,7 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => { LLMEvent.toolCall({ id: tool.id, name: tool.name, - ...(tool.namespace === undefined ? {} : { namespace: tool.namespace }), + namespace: tool.namespace, input, providerExecuted: tool.providerExecuted ? true : undefined, providerMetadata: tool.providerMetadata, @@ -100,7 +100,7 @@ const finishEvents = (tool: PendingTool, event: ToolCall): ReadonlyArray ToolCallPart.make({ id: event.id, name: event.name, - ...(event.namespace === undefined ? {} : { namespace: event.namespace }), + namespace: event.namespace, input: event.input, ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }), ...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }), @@ -565,7 +565,7 @@ const toolResultContent = (event: ToolResult): ContentPart => ToolResultPart.make({ id: event.id, name: event.name, - ...(event.namespace === undefined ? {} : { namespace: event.namespace }), + namespace: event.namespace, result: event.result, ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }), ...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }), diff --git a/packages/ai/src/schema/messages.ts b/packages/ai/src/schema/messages.ts index 2320bc6e2e8b..d98e896fcb07 100644 --- a/packages/ai/src/schema/messages.ts +++ b/packages/ai/src/schema/messages.ts @@ -167,7 +167,7 @@ export const ToolResultPart = Object.assign( type: "tool-result", id: input.id, name: input.name, - ...(input.namespace === undefined ? {} : { namespace: input.namespace }), + namespace: input.namespace, result: ToolResultValue.make(input.result, input.resultType), providerExecuted: input.providerExecuted, cache: input.cache, diff --git a/packages/ai/src/tool-runtime.ts b/packages/ai/src/tool-runtime.ts index cca5154129e1..87e294a0f23b 100644 --- a/packages/ai/src/tool-runtime.ts +++ b/packages/ai/src/tool-runtime.ts @@ -42,7 +42,7 @@ const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect tool._encode(value).pipe( @@ -76,7 +76,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement, LLMEvent.toolError({ id: call.id, name: call.name, - ...(call.namespace === undefined ? {} : { namespace: call.namespace }), + namespace: call.namespace, message: String(settlement.result.value), error, providerMetadata: call.providerMetadata, @@ -84,7 +84,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement, LLMEvent.toolResult({ id: call.id, name: call.name, - ...(call.namespace === undefined ? {} : { namespace: call.namespace }), + namespace: call.namespace, result: settlement.result, providerMetadata: call.providerMetadata, }), @@ -93,7 +93,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement, LLMEvent.toolResult({ id: call.id, name: call.name, - ...(call.namespace === undefined ? {} : { namespace: call.namespace }), + namespace: call.namespace, result: settlement.result, output: settlement.output, providerMetadata: call.providerMetadata, diff --git a/packages/ai/test/provider/open-responses-lifecycle.test.ts b/packages/ai/test/provider/open-responses-lifecycle.test.ts index 9a35b4c9a834..3ed0cb98ca11 100644 --- a/packages/ai/test/provider/open-responses-lifecycle.test.ts +++ b/packages/ai/test/provider/open-responses-lifecycle.test.ts @@ -347,7 +347,7 @@ describe("Open Responses basic-item lifecycles", () => { ) const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } } expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([ - { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata }, + { type: "tool-input-start", id: "call_1", name: "lookup", namespace: undefined, providerMetadata }, { type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata }, { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata }, ]) @@ -383,11 +383,19 @@ describe("Open Responses basic-item lifecycles", () => { type: "tool-input-delta", id: "call_1", name: "lookup", + namespace: undefined, text: '{"query":"weather"}', input: { query: "weather" }, }, - { type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata }, - { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata }, + { type: "tool-input-end", id: "call_1", name: "lookup", namespace: undefined, providerMetadata }, + { + type: "tool-call", + id: "call_1", + name: "lookup", + namespace: undefined, + input: { query: "weather" }, + providerMetadata, + }, ]) }), ) diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index 8320f29b1e11..fbdaeea0d965 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -2233,8 +2233,8 @@ describe("OpenAI Responses route", () => { expect.objectContaining({ type: "tool-input-end", name: "lookup", namespace: "crm" }), expect.objectContaining({ type: "tool-call", name: "lookup", namespace: "crm", input: { id: "123" } }), ]) - expect(toolEvents[0]).not.toHaveProperty("namespace") - expect(toolEvents[1]).not.toHaveProperty("namespace") + expect(toolEvents[0]?.namespace).toBeUndefined() + expect(toolEvents[1]?.namespace).toBeUndefined() expect(response.message.content).toEqual([ expect.objectContaining({ type: "tool-call", name: "lookup", namespace: "crm", input: { id: "123" } }), ]) From e01ca88243e8c74e825999eb0938d1d2973f9e27 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 1 Sep 2026 10:48:42 -0500 Subject: [PATCH 3/4] refactor(ai): simplify namespace handling --- packages/ai/src/protocols/anthropic-messages.ts | 3 +-- packages/ai/src/protocols/bedrock-converse.ts | 3 +-- packages/ai/src/protocols/gemini.ts | 3 +-- packages/ai/src/protocols/mistral-chat.ts | 3 +-- packages/ai/src/protocols/open-responses.ts | 12 +++++++----- packages/ai/src/protocols/openai-chat.ts | 3 +-- packages/ai/src/protocols/openai-responses.ts | 2 +- packages/ai/src/protocols/shared.ts | 10 +++++++++- packages/ai/src/schema/messages.ts | 4 ++-- packages/core/src/aisdk.ts | 3 +-- 10 files changed, 25 insertions(+), 21 deletions(-) diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index 1710f66b3db4..a52cdd712486 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -1003,14 +1003,13 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* }) const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { - yield* ProviderShared.requireFlatToolHistory("Anthropic Messages", request.messages) const generation = request.generation const toolSchemaCompatibility = request.model.compatibility?.toolSchema // Allocate the 4-breakpoint budget in invalidation order: tools → system → // messages. Tools live highest in the cache hierarchy, so when callers // over-mark we keep their tool hints and shed the message-tail ones first. const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP) - const definitions = yield* ProviderShared.requireFlatTools("Anthropic Messages", request.tools) + const definitions = yield* ProviderShared.requireFlatToolRequest("Anthropic Messages", request) const tools = definitions.length === 0 ? undefined diff --git a/packages/ai/src/protocols/bedrock-converse.ts b/packages/ai/src/protocols/bedrock-converse.ts index 91f3be85893c..24b699adec30 100644 --- a/packages/ai/src/protocols/bedrock-converse.ts +++ b/packages/ai/src/protocols/bedrock-converse.ts @@ -423,9 +423,8 @@ const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArra } const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) { - yield* ProviderShared.requireFlatToolHistory("Bedrock Converse", request.messages) const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined - const tools = yield* ProviderShared.requireFlatTools("Bedrock Converse", request.tools) + const tools = yield* ProviderShared.requireFlatToolRequest("Bedrock Converse", request) const generation = request.generation // Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in // tools → system → messages order to favour the highest-impact prefixes. diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index daff8e54a4c8..91705b1f43fc 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -465,9 +465,8 @@ function mapSafetySettings(value: unknown) { } const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) { - yield* ProviderShared.requireFlatToolHistory("Gemini", request.messages) const hasTools = request.tools.length > 0 - const tools = yield* ProviderShared.requireFlatTools("Gemini", request.tools) + const tools = yield* ProviderShared.requireFlatToolRequest("Gemini", request) const generation = request.generation const options = resolveOptions(request) const toolSchemaCompatibility = request.model.compatibility?.toolSchema diff --git a/packages/ai/src/protocols/mistral-chat.ts b/packages/ai/src/protocols/mistral-chat.ts index b552b03e6434..ffed21b534fc 100644 --- a/packages/ai/src/protocols/mistral-chat.ts +++ b/packages/ai/src/protocols/mistral-chat.ts @@ -396,7 +396,6 @@ const lowerTool = (tool: ToolDefinition): MistralTool => ({ }) export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (request: LLMRequest) { - yield* ProviderShared.requireFlatToolHistory("Mistral Chat", request.messages) const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(MistralOptions))( request.providerOptions ?? {}, ) @@ -415,7 +414,7 @@ export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (reque tool: (name) => ({ type: "function" as const, function: { name } }), }) : undefined - const tools = yield* ProviderShared.requireFlatTools("Mistral Chat", request.tools) + const tools = yield* ProviderShared.requireFlatToolRequest("Mistral Chat", request) return { model: request.model.id, messages: yield* lowerMessages(request), diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index 68be97eacba3..7b0296dff248 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -166,7 +166,7 @@ export const InputItem = Schema.Union([ id: Schema.optionalKey(Schema.String), call_id: Schema.String, name: Schema.String, - namespace: Schema.optionalKey(Schema.String), + namespace: Schema.optionalKey(Schema.UndefinedOr(Schema.String)), arguments: Schema.String, }), Schema.Struct({ @@ -378,7 +378,7 @@ export type Event = Schema.Schema.Type export interface ProviderAdapter { readonly id: string readonly name: string - readonly toolNamespaces?: boolean + readonly toolNamespaceHistory?: boolean readonly lowerMedia?: (input: { readonly part: MediaPart readonly media: ProviderShared.NormalizedMedia @@ -465,7 +465,7 @@ const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenRes ...(id === undefined ? {} : { id }), call_id: part.id, name: part.name, - ...(part.namespace === undefined ? {} : { namespace: part.namespace }), + namespace: part.namespace, arguments: ProviderShared.encodeJson(part.input), } } @@ -746,8 +746,10 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd adapter: ProviderAdapter, ) { const generation = request.generation - if (adapter.toolNamespaces !== true) yield* ProviderShared.requireFlatToolHistory(adapter.name, request.messages) - const tools = yield* ProviderShared.requireFlatTools(adapter.name, request.tools) + const tools = + adapter.toolNamespaceHistory === true + ? yield* ProviderShared.requireFlatTools(adapter.name, request.tools) + : yield* ProviderShared.requireFlatToolRequest(adapter.name, request) const toolSchemaCompatibility = request.model.compatibility?.toolSchema return { model: request.model.id, diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index f8b77f592f10..f6383d371d7c 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -727,7 +727,6 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* ( request: LLMRequest, options: LoweringOptions = {}, ) { - yield* ProviderShared.requireFlatToolHistory("OpenAI Chat", request.messages) // `fromRequest` returns the provider body only. Endpoint, auth, framing, // validation, and HTTP execution are composed by `Route.make`. const reasoningField = request.model.compatibility?.reasoningField @@ -737,7 +736,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* ( ) const generation = request.generation const toolSchemaCompatibility = request.model.compatibility?.toolSchema - const tools = yield* ProviderShared.requireFlatTools("OpenAI Chat", request.tools) + const tools = yield* ProviderShared.requireFlatToolRequest("OpenAI Chat", request) const provider = String(request.model.provider) const baseURL = request.model.route.endpoint.baseURL const detectedMaxTokensField = detectMaxTokensField(provider, baseURL) diff --git a/packages/ai/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts index 92e2fe1dfdde..1fa72f4959d2 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -100,7 +100,7 @@ export type OpenAIResponsesBody = Schema.Schema.Type const adapter = { id: ADAPTER, name: NAME, - toolNamespaces: true, + toolNamespaceHistory: true, restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined), } satisfies OpenResponses.ProviderAdapter diff --git a/packages/ai/src/protocols/shared.ts b/packages/ai/src/protocols/shared.ts index 0e7ca614d914..07d8d547dbcb 100644 --- a/packages/ai/src/protocols/shared.ts +++ b/packages/ai/src/protocols/shared.ts @@ -267,7 +267,7 @@ export const requireFlatTools = Effect.fn("ProviderShared.requireFlatTools")(fun }) }) -export const requireFlatToolHistory = Effect.fn("ProviderShared.requireFlatToolHistory")(function* ( +const requireFlatToolHistory = Effect.fn("ProviderShared.requireFlatToolHistory")(function* ( protocol: string, messages: LLMRequest["messages"], ) { @@ -281,6 +281,14 @@ export const requireFlatToolHistory = Effect.fn("ProviderShared.requireFlatToolH return yield* invalidRequest(`${protocol} does not support tool namespaces in message history`) }) +export const requireFlatToolRequest = Effect.fn("ProviderShared.requireFlatToolRequest")(function* ( + protocol: string, + request: LLMRequest, +) { + yield* requireFlatToolHistory(protocol, request.messages) + return yield* requireFlatTools(protocol, request.tools) +}) + export const imageResponse = Effect.fn("ProviderShared.imageResponse")(function* ( route: string, name: string, diff --git a/packages/ai/src/schema/messages.ts b/packages/ai/src/schema/messages.ts index d98e896fcb07..3d1036edaa6d 100644 --- a/packages/ai/src/schema/messages.ts +++ b/packages/ai/src/schema/messages.ts @@ -284,14 +284,14 @@ export const ToolNamespace: Schema.Codec & { Schema.Struct({ type: Schema.Literal("namespace"), name: Schema.String, - description: Schema.optional(Schema.String), + description: Schema.optional(Schema.UndefinedOr(Schema.String)), tools: Schema.Array(Schema.suspend((): Schema.Codec => ToolEntry)), }).annotate({ identifier: "LLM.ToolNamespace" }), { make: (input: ToolNamespace | ToolNamespaceInput): ToolNamespace => ({ type: "namespace", name: input.name, - ...(input.description === undefined ? {} : { description: input.description }), + description: input.description, tools: input.tools.map((tool) => ToolEntry.make(tool)), }), }, diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 8ebe905fa5b0..cf656c40ea77 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -328,8 +328,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) { body: { schema: Schema.Unknown, from: Effect.fn("AISDK.fromRequest")(function* (request) { - yield* ProviderShared.requireFlatToolHistory("AI SDK", request.messages) - const tools = yield* ProviderShared.requireFlatTools("AI SDK", request.tools) + const tools = yield* ProviderShared.requireFlatToolRequest("AI SDK", request) return callOptions(request, packageName, info.modelID ?? info.id, optionKey, tools) }), }, From 74e128887e1a43a8b951bdd70f36a881636ceeff Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 1 Sep 2026 11:05:09 -0500 Subject: [PATCH 4/4] fix(ai): flatten tool namespaces --- .../ai/src/protocols/anthropic-messages.ts | 5 +- packages/ai/src/protocols/bedrock-converse.ts | 5 +- packages/ai/src/protocols/gemini.ts | 5 +- packages/ai/src/protocols/mistral-chat.ts | 5 +- packages/ai/src/protocols/open-responses.ts | 8 +-- packages/ai/src/protocols/openai-chat.ts | 5 +- packages/ai/src/protocols/openai-responses.ts | 30 +++++++---- packages/ai/src/protocols/shared.ts | 50 ++++++++++--------- .../openai-compatible-responses.test.ts | 49 ++++++++++++++---- .../ai/test/provider/openai-responses.test.ts | 25 +++++++--- packages/core/src/aisdk.ts | 4 +- 11 files changed, 125 insertions(+), 66 deletions(-) diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index a52cdd712486..b457670c41d7 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -1009,7 +1009,8 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques // messages. Tools live highest in the cache hierarchy, so when callers // over-mark we keep their tool hints and shed the message-tail ones first. const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP) - const definitions = yield* ProviderShared.requireFlatToolRequest("Anthropic Messages", request) + const flattened = ProviderShared.flattenToolRequest(request) + const definitions = flattened.tools const tools = definitions.length === 0 ? undefined @@ -1031,7 +1032,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques text: part.text, cache_control: cacheControl(breakpoints, part.cache), })) - const messages = yield* lowerMessages(request, breakpoints) + const messages = yield* lowerMessages(flattened.request, breakpoints) if (breakpoints.dropped > 0) { yield* Effect.logWarning( `Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`, diff --git a/packages/ai/src/protocols/bedrock-converse.ts b/packages/ai/src/protocols/bedrock-converse.ts index 24b699adec30..6c98aff86a8b 100644 --- a/packages/ai/src/protocols/bedrock-converse.ts +++ b/packages/ai/src/protocols/bedrock-converse.ts @@ -424,7 +424,8 @@ const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArra const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) { const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined - const tools = yield* ProviderShared.requireFlatToolRequest("Bedrock Converse", request) + const flattened = ProviderShared.flattenToolRequest(request) + const tools = flattened.tools const generation = request.generation // Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in // tools → system → messages order to favour the highest-impact prefixes. @@ -439,7 +440,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: } })() const system = lowerSystem(breakpoints, request.system) - const messages = yield* lowerMessages(request, breakpoints) + const messages = yield* lowerMessages(flattened.request, breakpoints) if (breakpoints.dropped > 0) { yield* Effect.logWarning( `Bedrock Converse: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${BedrockCache.BEDROCK_BREAKPOINT_CAP} per request.`, diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 91705b1f43fc..d9fdbe8b11aa 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -466,7 +466,8 @@ function mapSafetySettings(value: unknown) { const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) { const hasTools = request.tools.length > 0 - const tools = yield* ProviderShared.requireFlatToolRequest("Gemini", request) + const flattened = ProviderShared.flattenToolRequest(request) + const tools = flattened.tools const generation = request.generation const options = resolveOptions(request) const toolSchemaCompatibility = request.model.compatibility?.toolSchema @@ -484,7 +485,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque return { cachedContent: options.cachedContent, - contents: yield* lowerMessages(request), + contents: yield* lowerMessages(flattened.request), safetySettings: options.safetySettings, serviceTier: options.serviceTier, systemInstruction: diff --git a/packages/ai/src/protocols/mistral-chat.ts b/packages/ai/src/protocols/mistral-chat.ts index ffed21b534fc..7a84062679f9 100644 --- a/packages/ai/src/protocols/mistral-chat.ts +++ b/packages/ai/src/protocols/mistral-chat.ts @@ -414,10 +414,11 @@ export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (reque tool: (name) => ({ type: "function" as const, function: { name } }), }) : undefined - const tools = yield* ProviderShared.requireFlatToolRequest("Mistral Chat", request) + const flattened = ProviderShared.flattenToolRequest(request) + const tools = flattened.tools return { model: request.model.id, - messages: yield* lowerMessages(request), + messages: yield* lowerMessages(flattened.request), tools: tools.length > 0 ? tools.map(lowerTool) : undefined, tool_choice: toolChoice, stream: true as const, diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index 7b0296dff248..86e07a4c6f41 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -746,14 +746,14 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd adapter: ProviderAdapter, ) { const generation = request.generation + const flattened = adapter.toolNamespaceHistory === true ? undefined : ProviderShared.flattenToolRequest(request) const tools = - adapter.toolNamespaceHistory === true - ? yield* ProviderShared.requireFlatTools(adapter.name, request.tools) - : yield* ProviderShared.requireFlatToolRequest(adapter.name, request) + flattened === undefined ? yield* ProviderShared.requireFlatTools(adapter.name, request.tools) : flattened.tools + const input = flattened?.request ?? request const toolSchemaCompatibility = request.model.compatibility?.toolSchema return { model: request.model.id, - input: yield* lowerMessages(request, adapter), + input: yield* lowerMessages(input, adapter), tools: tools.length === 0 ? undefined diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index f6383d371d7c..28f4a7a5f25e 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -736,7 +736,8 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* ( ) const generation = request.generation const toolSchemaCompatibility = request.model.compatibility?.toolSchema - const tools = yield* ProviderShared.requireFlatToolRequest("OpenAI Chat", request) + const flattened = ProviderShared.flattenToolRequest(request) + const tools = flattened.tools const provider = String(request.model.provider) const baseURL = request.model.route.endpoint.baseURL const detectedMaxTokensField = detectMaxTokensField(provider, baseURL) @@ -752,7 +753,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* ( const hasActiveTools = request.tools.length > 0 return { model: request.model.id, - messages: yield* lowerMessages(request, options), + messages: yield* lowerMessages(flattened.request, options), tools: request.tools.length === 0 ? hasHistory diff --git a/packages/ai/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts index 1fa72f4959d2..5f358119774e 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js" import { Endpoint } from "../route/endpoint.js" import { Protocol } from "../route/protocol.js" import { HttpTransport } from "../route/transport/index.js" -import { LLMRequest, type JsonSchema, type ToolDefinition, type ToolEntry } from "../schema/index.js" +import { LLMRequest, type AIError, type JsonSchema, type ToolDefinition, type ToolEntry } from "../schema/index.js" import { OpenResponses } from "./open-responses.js" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js" import { OpenAIImage } from "./utils/openai-image.js" @@ -123,6 +123,24 @@ const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDe return yield* OpenResponses.lowerTool(NAME, tool, inputSchema) }) +function lowerNamespaceTools( + tools: ReadonlyArray, + compatibility: Parameters[1], + path: ReadonlyArray = [], +): Effect.Effect, AIError> { + return Effect.gen(function* () { + const entries = yield* Effect.forEach(tools, (tool) => { + if (tool.type === "namespace") return lowerNamespaceTools(tool.tools, compatibility, [...path, tool.name]) + return OpenResponses.lowerTool( + NAME, + tool, + ToolSchemaProjection.modelCompatibility(tool.inputSchema, compatibility), + ).pipe(Effect.map((lowered) => [{ ...lowered, name: [...path, lowered.name].join("_") }])) + }).pipe(Effect.map((entries) => entries.flat())) + return Array.from(new Map(entries.map((tool) => [tool.name, tool])).values()) + }) +} + const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* ( tool: ToolEntry, compatibility: Parameters[1], @@ -135,15 +153,7 @@ const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* ( type: "namespace" as const, name: tool.name, description: tool.description, - tools: yield* Effect.forEach(tool.tools, (child) => { - if (child.type === "namespace") - return Effect.fail(ProviderShared.invalidRequest("OpenAI Responses does not support nested tool namespaces")) - return OpenResponses.lowerTool( - NAME, - child, - ToolSchemaProjection.modelCompatibility(child.inputSchema, compatibility), - ) - }), + tools: yield* lowerNamespaceTools(tool.tools, compatibility), } }) diff --git a/packages/ai/src/protocols/shared.ts b/packages/ai/src/protocols/shared.ts index 07d8d547dbcb..aa425d479c3e 100644 --- a/packages/ai/src/protocols/shared.ts +++ b/packages/ai/src/protocols/shared.ts @@ -8,11 +8,12 @@ import { InvalidRequestError, AIError, HttpContext, + LLMRequest, + Message, + ToolDefinition, type ContentPart, - type LLMRequest, type MediaPart, type TextPart, - type ToolDefinition, type ToolEntry, type ToolResultPart, } from "../schema/index.js" @@ -267,27 +268,30 @@ export const requireFlatTools = Effect.fn("ProviderShared.requireFlatTools")(fun }) }) -const requireFlatToolHistory = Effect.fn("ProviderShared.requireFlatToolHistory")(function* ( - protocol: string, - messages: LLMRequest["messages"], -) { - if ( - messages.some((message) => - message.content.some( - (part) => (part.type === "tool-call" || part.type === "tool-result") && part.namespace !== undefined, - ), - ) - ) - return yield* invalidRequest(`${protocol} does not support tool namespaces in message history`) -}) - -export const requireFlatToolRequest = Effect.fn("ProviderShared.requireFlatToolRequest")(function* ( - protocol: string, - request: LLMRequest, -) { - yield* requireFlatToolHistory(protocol, request.messages) - return yield* requireFlatTools(protocol, request.tools) -}) +export const flattenToolRequest = (request: LLMRequest) => { + const flatten = (tools: ReadonlyArray, path: ReadonlyArray): ReadonlyArray => + tools.flatMap((tool) => { + if (tool.type === "namespace") return flatten(tool.tools, [...path, tool.name]) + if (path.length === 0) return [tool] + return [new ToolDefinition({ ...tool, name: [...path, tool.name].join("_") })] + }) + const tools = Array.from(new Map(flatten(request.tools, []).map((tool) => [tool.name, tool])).values()) + const messages = request.messages.map((message) => { + const content = message.content.map((part) => { + if ((part.type !== "tool-call" && part.type !== "tool-result") || part.namespace === undefined) return part + return { ...part, name: `${part.namespace}_${part.name}`, namespace: undefined } + }) + return content.every((part, index) => part === message.content[index]) + ? message + : new Message({ ...message, content }) + }) + return { + tools, + request: messages.every((message, index) => message === request.messages[index]) + ? request + : LLMRequest.update(request, { messages }), + } +} export const imageResponse = Effect.fn("ProviderShared.imageResponse")(function* ( route: string, diff --git a/packages/ai/test/provider/openai-compatible-responses.test.ts b/packages/ai/test/provider/openai-compatible-responses.test.ts index e24d6a1224f4..5ec1f8ff9b6b 100644 --- a/packages/ai/test/provider/openai-compatible-responses.test.ts +++ b/packages/ai/test/provider/openai-compatible-responses.test.ts @@ -171,36 +171,63 @@ describe("Open Responses-compatible route", () => { }), ) - it.effect("rejects tool namespaces", () => + it.effect("flattens tool namespaces", () => Effect.gen(function* () { const model = configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model( "example-model", ) - const error = yield* compileRequest( - LLM.request({ model, tools: [{ type: "namespace", name: "crm", tools: [] }] }), - ).pipe(Effect.flip) + const prepared = yield* compileRequest( + LLM.request({ + model, + tools: [ + { + type: "namespace", + name: "acme", + tools: [ + { + type: "namespace", + name: "billing", + tools: [ToolDefinition.make({ name: "lookup", description: "Lookup billing", inputSchema: {} })], + }, + ToolDefinition.make({ name: "users", description: "Lookup users", inputSchema: {} }), + ], + }, + ], + }), + ) - expect(error.reason._tag).toBe("InvalidRequest") - expect(error.message).toContain("does not support tool namespaces") + expect(prepared.body.tools).toEqual([ + { + type: "function", + name: "acme_billing_lookup", + description: "Lookup billing", + parameters: {}, + strict: false, + }, + { type: "function", name: "acme_users", description: "Lookup users", parameters: {}, strict: false }, + ]) }), ) - it.effect("rejects tool namespaces in history", () => + it.effect("flattens tool namespaces in history", () => Effect.gen(function* () { const model = configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model( "example-model", ) - const error = yield* compileRequest( + const prepared = yield* compileRequest( LLM.request({ model, messages: [ Message.assistant({ type: "tool-call", id: "call_1", name: "lookup", namespace: "crm", input: {} }), + Message.tool({ id: "call_1", name: "lookup", namespace: "crm", result: "done", resultType: "text" }), ], }), - ).pipe(Effect.flip) + ) - expect(error.reason._tag).toBe("InvalidRequest") - expect(error.message).toContain("does not support tool namespaces in message history") + expect(prepared.body.input).toEqual([ + { type: "function_call", call_id: "call_1", name: "crm_lookup", namespace: undefined, arguments: "{}" }, + { type: "function_call_output", call_id: "call_1", output: "done" }, + ]) }), ) diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index fbdaeea0d965..28f4ccb516ec 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -177,9 +177,9 @@ describe("OpenAI Responses route", () => { }), ) - it.effect("rejects nested tool namespaces", () => + it.effect("flattens nested levels within a native tool namespace", () => Effect.gen(function* () { - const error = yield* compileRequest( + const prepared = yield* compileRequest( LLM.request({ model, tools: [ @@ -187,14 +187,27 @@ describe("OpenAI Responses route", () => { type: "namespace", name: "crm", description: "Customer management", - tools: [{ type: "namespace", name: "orders", description: "Order management", tools: [] }], + tools: [ + { + type: "namespace", + name: "orders", + description: "Order management", + tools: [ToolDefinition.make({ name: "list", description: "List orders", inputSchema: {} })], + }, + ], }, ], }), - ).pipe(Effect.flip) + ) - expect(error.reason._tag).toBe("InvalidRequest") - expect(error.message).toContain("does not support nested tool namespaces") + expect(prepared.body.tools).toEqual([ + { + type: "namespace", + name: "crm", + description: "Customer management", + tools: [{ type: "function", name: "orders_list", description: "List orders", parameters: {}, strict: false }], + }, + ]) }), ) diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index cf656c40ea77..662c538e3cdd 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -328,8 +328,8 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) { body: { schema: Schema.Unknown, from: Effect.fn("AISDK.fromRequest")(function* (request) { - const tools = yield* ProviderShared.requireFlatToolRequest("AI SDK", request) - return callOptions(request, packageName, info.modelID ?? info.id, optionKey, tools) + const flattened = ProviderShared.flattenToolRequest(request) + return callOptions(flattened.request, packageName, info.modelID ?? info.id, optionKey, flattened.tools) }), }, with: () => route,