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 881e56bc9cdd..59973da382fc 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -1067,10 +1067,12 @@ 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 flattened = ProviderShared.flattenToolRequest(request) + const definitions = flattened.tools const tools = - request.tools.length === 0 + definitions.length === 0 ? undefined - : request.tools.map((tool) => + : definitions.map((tool) => lowerTool( breakpoints, tool, @@ -1088,7 +1090,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 2517b722bf65..6c98aff86a8b 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)) @@ -427,21 +424,23 @@ const lowerSystem = ( const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) { const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined + 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. 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, } })() 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 6d3a157955fa..d9fdbe8b11aa 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -466,6 +466,8 @@ function mapSafetySettings(value: unknown) { const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) { const hasTools = request.tools.length > 0 + const flattened = ProviderShared.flattenToolRequest(request) + const tools = flattened.tools const generation = request.generation const options = resolveOptions(request) const toolSchemaCompatibility = request.model.compatibility?.toolSchema @@ -483,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: @@ -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..7a84062679f9 100644 --- a/packages/ai/src/protocols/mistral-chat.ts +++ b/packages/ai/src/protocols/mistral-chat.ts @@ -414,10 +414,12 @@ export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (reque tool: (name) => ({ type: "function" as const, function: { name } }), }) : undefined + const flattened = ProviderShared.flattenToolRequest(request) + const tools = flattened.tools return { model: request.model.id, - messages: yield* lowerMessages(request), - tools: request.tools.length > 0 ? request.tools.map(lowerTool) : undefined, + messages: yield* lowerMessages(flattened.request), + 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 96bd37da73b6..3246645f1952 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -189,6 +189,7 @@ export const InputItem = Schema.Union([ id: Schema.optionalKey(Schema.String), call_id: Schema.String, name: Schema.String, + namespace: Schema.optionalKey(Schema.UndefinedOr(Schema.String)), arguments: Schema.String, }), Schema.Struct({ @@ -315,6 +316,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), }), @@ -406,6 +408,7 @@ export type NormalizedEvent = Event & { readonly item?: OutputItem | null } export interface ProviderAdapter { readonly id: string readonly name: string + readonly toolNamespaceHistory?: boolean readonly lowerMedia?: (input: { readonly part: MediaPart readonly media: ProviderShared.NormalizedMedia @@ -489,6 +492,7 @@ const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenRes ...(id === undefined ? {} : { id }), call_id: part.id, name: part.name, + namespace: part.namespace, arguments: ProviderShared.encodeJson(part.input), } } @@ -808,14 +812,17 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd request: LLMRequest, adapter: ProviderAdapter, ) { + const flattened = adapter.toolNamespaceHistory === true ? undefined : ProviderShared.flattenToolRequest(request) + const tools = flattened === undefined ? yield* ProviderShared.requireFlatTools(adapter.name, request.tools) : flattened.tools + const input = flattened?.request ?? request const toolSchemaCompatibility = request.model.compatibility?.toolSchema return { - ...(yield* lowerConversation(request, adapter)), + ...(yield* lowerConversation(input, adapter)), ...lowerGeneration(request), tools: - request.tools.length === 0 + tools.length === 0 ? undefined - : yield* Effect.forEach(request.tools, (tool) => + : yield* Effect.forEach(tools, (tool) => lowerTool( adapter.name, tool, @@ -1096,11 +1103,20 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu tools: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name ?? "", + 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 ?? "", + namespace: item.namespace, + providerMetadata: metadata, + }), + ], ] } @@ -1216,10 +1232,20 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* ( if (item.type === "function_call") { if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult const metadata = providerMetadata(state, { itemId: item.id }) - const registered = state.tools[item.id] !== undefined - const tools = registered - ? state.tools - : ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name, providerMetadata: metadata }) + const pending = state.tools[item.id] + const registered = pending !== undefined + const tools = + pending === undefined + ? ToolStream.start(state.tools, item.id, { + id: item.call_id, + name: item.name, + namespace: item.namespace, + providerMetadata: metadata, + }) + : ToolStream.start(state.tools, item.id, { + ...pending, + namespace: pending.namespace ?? item.namespace, + }) const result = item.arguments === undefined ? yield* ToolStream.finish(state.id, tools, item.id) @@ -1230,7 +1256,15 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* ( const resultEvents = registered || finished.length === 0 ? finished - : [LLMEvent.toolInputStart({ id: item.call_id, name: item.name, providerMetadata: metadata }), ...finished] + : [ + LLMEvent.toolInputStart({ + id: item.call_id, + name: item.name, + 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..28f4a7a5f25e 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -736,6 +736,8 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* ( ) const generation = request.generation const toolSchemaCompatibility = request.model.compatibility?.toolSchema + 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) @@ -751,13 +753,13 @@ 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 ? [] : 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 7ab98ec1611d..72b6d077c526 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 type { LLMRequest, JsonSchema, ToolDefinition } 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" @@ -75,7 +75,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, @@ -106,6 +117,7 @@ export type OpenAIResponsesBody = Schema.Schema.Type const adapter = { id: ADAPTER, name: NAME, + toolNamespaceHistory: true, restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined), } satisfies OpenResponses.ProviderAdapter @@ -128,13 +140,47 @@ const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDe return yield* OpenResponses.lowerTool(NAME, tool, inputSchema) }) -const lowerToolChoice = (toolChoice: NonNullable, tools: ReadonlyArray) => +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], +) { + 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* lowerNamespaceTools(tool.tools, 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 }, }) @@ -153,9 +199,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: OpenResponses.allowedToolChoice(request) ?? (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 41b1d5c1f558..41dff3e48783 100644 --- a/packages/ai/src/protocols/shared.ts +++ b/packages/ai/src/protocols/shared.ts @@ -9,11 +9,14 @@ import { UnsupportedOperationError, AIError, HttpContext, + LLMRequest, + Message, + ToolDefinition, type ContentPart, - type LLMRequest, type MediaPart, type ProviderID, type TextPart, + type ToolEntry, type ToolResultPart, } from "../schema/index.js" import { isRecord } from "../utils/record.js" @@ -46,6 +49,7 @@ export const promptCacheKey = (request: LLMRequest): string | undefined => { export interface ToolAccumulator { readonly id: string readonly name: string + readonly namespace?: string readonly input: string } @@ -279,6 +283,41 @@ export const unsupportedOperation = (input: { }), }) +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 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, name: string, diff --git a/packages/ai/src/protocols/utils/tool-stream.ts b/packages/ai/src/protocols/utils/tool-stream.ts index 256b53a32ec3..7a5847ab4901 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, + 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, + 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, + 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, + 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 c8665f6722a8..ef77a53b8f3b 100644 --- a/packages/ai/src/route/client.ts +++ b/packages/ai/src/route/client.ts @@ -487,10 +487,22 @@ export function make( } const prepareRequest = (request: LLMRequest) => { - 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 headers = resolved.model.route.headers?.({ request: resolved }) return headers === undefined ? resolved diff --git a/packages/ai/src/schema/events.ts b/packages/ai/src/schema/events.ts index 914381a4affd..e2cce25c97a1 100644 --- a/packages/ai/src/schema/events.ts +++ b/packages/ai/src/schema/events.ts @@ -155,6 +155,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" }) @@ -164,6 +165,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), @@ -174,6 +176,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 @@ -183,6 +186,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 @@ -191,6 +195,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), @@ -201,6 +206,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), @@ -212,6 +218,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), @@ -385,6 +392,7 @@ interface ContentAssembly { interface ToolInputAssembly { readonly name: string + readonly namespace?: string readonly text: string readonly providerMetadata?: ProviderMetadata } @@ -522,12 +530,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 } }, @@ -535,7 +548,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: { @@ -543,6 +556,7 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response [event.id]: { ...current, name: event.name, + namespace: event.namespace, providerMetadata: event.providerMetadata ?? current.providerMetadata, }, }, @@ -553,6 +567,7 @@ const toolCallContent = (event: ToolCall): ContentPart => ToolCallPart.make({ id: event.id, name: event.name, + namespace: event.namespace, input: event.input, ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }), ...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }), @@ -562,6 +577,7 @@ const toolResultContent = (event: ToolResult): ContentPart => ToolResultPart.make({ id: event.id, name: event.name, + 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 ad40af47695a..15935b9fc5f3 100644 --- a/packages/ai/src/schema/messages.ts +++ b/packages/ai/src/schema/messages.ts @@ -135,6 +135,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), @@ -152,6 +153,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), @@ -168,6 +170,7 @@ export const ToolResultPart = Object.assign( type: "tool-result", id: input.id, name: input.name, + namespace: input.namespace, result: ToolResultValue.make(input.result, input.resultType), providerExecuted: input.providerExecuted, cache: input.cache, @@ -266,7 +269,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, @@ -274,15 +288,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.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, + 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), @@ -312,7 +376,7 @@ const requestSchema = Schema.Struct({ 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 40e721117f74..f5f9ba9d8e51 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 @@ -61,8 +67,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, + 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, + 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, + 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, + 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/explicit-compaction.test.ts b/packages/ai/test/provider/explicit-compaction.test.ts index 070f55bd6766..05ef88db3edf 100644 --- a/packages/ai/test/provider/explicit-compaction.test.ts +++ b/packages/ai/test/provider/explicit-compaction.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { Effect, Schema } from "effect" -import { LLM, LLMRequest, Message } from "../../src/index.js" +import { LLM, LLMRequest, Message, ToolDefinition } from "../../src/index.js" import { LLMClient, Route } from "../../src/route/client.js" import { Auth } from "../../src/route/auth.js" import { Endpoint } from "../../src/route/endpoint.js" @@ -134,7 +134,12 @@ for (const model of [ [ LLMRequest.update(request, { tools: [ - { name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } }, + ToolDefinition.make({ + name: "unsupported", + description: "Generation only", + inputSchema: {}, + native: { unsupported: {} }, + }), ], }), "InvalidRequest", diff --git a/packages/ai/test/provider/open-responses-lifecycle.test.ts b/packages/ai/test/provider/open-responses-lifecycle.test.ts index aabe79683772..de127cc3ff57 100644 --- a/packages/ai/test/provider/open-responses-lifecycle.test.ts +++ b/packages/ai/test/provider/open-responses-lifecycle.test.ts @@ -337,7 +337,6 @@ describe("Open Responses basic-item lifecycles", () => { ]) }), ) - it.effect("mints an id for a done-only tool that never had one", () => Effect.gen(function* () { const events = yield* collect( @@ -368,9 +367,16 @@ describe("Open Responses basic-item lifecycles", () => { const events = yield* collect({ type: "response.output_item.done", item }, completed) const providerMetadata = { "openai-compatible": { itemId: "fc_1" } } expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([ - { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata }, - { 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-start", id: "call_1", name: "lookup", namespace: undefined, 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, + }, ]) expect(events.filter(LLMEvent.is.finish)).toEqual([ { diff --git a/packages/ai/test/provider/openai-compatible-responses.test.ts b/packages/ai/test/provider/openai-compatible-responses.test.ts index dec79a23abc2..f39c54228601 100644 --- a/packages/ai/test/provider/openai-compatible-responses.test.ts +++ b/packages/ai/test/provider/openai-compatible-responses.test.ts @@ -171,6 +171,66 @@ describe("Open Responses-compatible route", () => { }), ) + it.effect("flattens tool namespaces", () => + Effect.gen(function* () { + const model = configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model( + "example-model", + ) + 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(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("flattens tool namespaces in history", () => + Effect.gen(function* () { + const model = configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model( + "example-model", + ) + 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" }), + ], + }), + ) + + 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" }, + ]) + }), + ) + 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 8cbeb04ebfcc..3f00a3a290f9 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,84 @@ 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("flattens nested levels within a native tool namespace", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model, + tools: [ + { + type: "namespace", + name: "crm", + description: "Customer management", + tools: [ + { + type: "namespace", + name: "orders", + description: "Order management", + tools: [ToolDefinition.make({ name: "list", description: "List orders", inputSchema: {} })], + }, + ], + }, + ], + }), + ) + + expect(prepared.body.tools).toEqual([ + { + type: "namespace", + name: "crm", + description: "Customer management", + tools: [{ type: "function", name: "orders_list", description: "List orders", parameters: {}, strict: false }], + }, + ]) + }), + ) + + 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( @@ -2130,6 +2209,72 @@ 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]?.namespace).toBeUndefined() + expect(toolEvents[1]?.namespace).toBeUndefined() + 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 reasoning summary events by output index", () => Effect.gen(function* () { const response = yield* LLMClient.generate(request).pipe( 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 f1e0c5c3cdc1..54798ffa7a6a 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -331,12 +331,14 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) { }, body: { schema: Schema.Unknown, - from: (request) => - Effect.try({ - try: () => callOptions(request, packageName, info.modelID ?? info.id, optionKey), + from: (request) => { + const flattened = ProviderShared.flattenToolRequest(request) + return Effect.try({ + try: () => callOptions(flattened.request, packageName, info.modelID ?? info.id, optionKey, flattened.tools), catch: (cause) => cause instanceof AIError ? cause : ProviderShared.invalidRequest("Invalid AI SDK request", cause), - }), + }) + }, }, with: () => route, model: (input) => @@ -415,6 +417,7 @@ function callOptions( packageName: string | undefined, modelID: ID, optionKey: string, + tools: ReadonlyArray, ): LanguageModelV3CallOptions { return { prompt: prompt(request), @@ -426,7 +429,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: {},