diff --git a/src/core/ai/gateway.ts b/src/core/ai/gateway.ts index 48cfa2015..25c09223b 100644 --- a/src/core/ai/gateway.ts +++ b/src/core/ai/gateway.ts @@ -2791,6 +2791,22 @@ async function classifyGatewayGuardrail(input: { } } +export function toAISDKTools(tools: ChatToolDef[] | undefined): Record | undefined { + if (!tools || tools.length === 0) return undefined; + return tools.reduce((acc, t) => { + acc[t.name] = { + description: t.description, + // AI SDK v6 requires a Schema (carrying the schema symbol), not a plain + // `{jsonSchema}` object — the bare object makes asSchema() treat it as a + // thunk and call schema(), throwing "schema is not a function". Wrap the + // raw JSON Schema with the SDK's jsonSchema() helper so tool calls work + // through the real toolLoop (skillopt rollouts + subagent jobs). + inputSchema: jsonSchema(t.inputSchema as any), + }; + return acc; + }, {} as Record); +} + export async function chat(opts: ChatOpts): Promise { const tracker = __budgetStore.getStore() ?? null; const modelStrEarly = opts.model ?? getChatModel(); @@ -2878,21 +2894,7 @@ export async function chat(opts: ChatOpts): Promise { const supportsCache = recipe.touchpoints.chat?.supports_prompt_cache === true; const useCache = !!opts.cacheSystem && supportsCache; - // Build messages. Anthropic prompt-cache markers ride on system + last tool - // via providerOptions; the AI SDK accepts the system as a string for - // generateText, so cache markers go through providerOptions.anthropic. - const tools = (opts.tools ?? []).reduce((acc, t) => { - acc[t.name] = { - description: t.description, - // AI SDK v6 requires a Schema (carrying the schema symbol), not a plain - // `{jsonSchema}` object — the bare object makes asSchema() treat it as a - // thunk and call schema(), throwing "schema is not a function". Wrap the - // raw JSON Schema with the SDK's jsonSchema() helper so tool calls work - // through the real toolLoop (skillopt rollouts + subagent jobs). - inputSchema: jsonSchema(t.inputSchema as any), - }; - return acc; - }, {} as Record); + const tools = toAISDKTools(opts.tools); const providerOptions: Record = {}; if (useCache) { diff --git a/test/ai/gateway-tools-schema.test.ts b/test/ai/gateway-tools-schema.test.ts index 83d4b4a7b..f5c57790c 100644 --- a/test/ai/gateway-tools-schema.test.ts +++ b/test/ai/gateway-tools-schema.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'bun:test'; import { generateText, jsonSchema } from 'ai'; import { MockLanguageModelV3 } from 'ai/test'; -import { toModelMessages, type ChatMessage } from '../../src/core/ai/gateway.ts'; +import { toAISDKTools, toModelMessages, type ChatMessage } from '../../src/core/ai/gateway.ts'; // v0.42 AI SDK v6 fix — the regression guard that the original bug evaded. // Every gateway/toolLoop test stubs the chat transport, which short-circuits @@ -36,13 +36,13 @@ const internalMessages: ChatMessage[] = [ describe('gateway tool schema + message shape (real AI SDK v6)', () => { it('jsonSchema()-wrapped tools + adapted messages pass generateText without throwing', async () => { const model = mockModel(); - // Built exactly as gateway.chat() builds it (the primary fix). - const tools = { - search: { + const tools = toAISDKTools([ + { + name: 'search', description: 'search the brain', - inputSchema: jsonSchema({ type: 'object', properties: { q: { type: 'string' } } } as any), + inputSchema: { type: 'object', properties: { q: { type: 'string' } } }, }, - }; + ]); const result = await generateText({ model: model as any,