diff --git a/.changeset/approval-resume-null-widening.md b/.changeset/approval-resume-null-widening.md new file mode 100644 index 0000000000..fbbacf04f7 --- /dev/null +++ b/.changeset/approval-resume-null-widening.md @@ -0,0 +1,6 @@ +--- +'@tanstack/ai': patch +'@tanstack/openai-base': patch +--- + +Undo strict-mode `null`s on tool arguments before the input check. An approved call whose history still has those `null`s now runs when `repairToolInput` is off. diff --git a/docs/config.json b/docs/config.json index f75826e68f..ff91ee4a7f 100644 --- a/docs/config.json +++ b/docs/config.json @@ -360,7 +360,7 @@ "label": "Server Tools: Advanced", "to": "tools/server-tools-advanced", "addedAt": "2026-10-21", - "updatedAt": "2026-10-22" + "updatedAt": "2026-11-09" }, { "label": "Client Tools", diff --git a/docs/tools/server-tools-advanced.md b/docs/tools/server-tools-advanced.md index d546a5198c..fa52b4a476 100644 --- a/docs/tools/server-tools-advanced.md +++ b/docs/tools/server-tools-advanced.md @@ -24,7 +24,7 @@ The `inputSchema` checks the input right before the tool runs: Malformed JSON arguments and failed input checks (Standard Schema or JSON Schema) become tool errors. The tool does not run. The agent loop can send the error back to the model, so the model can correct the call. This also applies to Chat Completions tools with no required input fields. -For OpenAI-compatible providers, an omitted `.optional()` field is absent when your tool runs. A `.nullable()` field keeps `null`. +For OpenAI-compatible providers, an omitted `.optional()` field is absent when your tool runs. A `.nullable()` field keeps `null`. The removal runs before the input check. When you resume an approved call, the optional field stays absent. `repairToolInput: false` does not put the `null` back. ### The built-in repair diff --git a/packages/ai/src/activities/chat/adapter.ts b/packages/ai/src/activities/chat/adapter.ts index d051628bf5..f8b05b4566 100644 --- a/packages/ai/src/activities/chat/adapter.ts +++ b/packages/ai/src/activities/chat/adapter.ts @@ -1,4 +1,5 @@ import type { + AnyTool, DefaultMessageMetadataByModality, JSONSchema, MidConversationChannels, @@ -228,6 +229,19 @@ export interface TextAdapter< combinedStructuredOutputSource?: ( modelOptions?: TProviderOptions | undefined, ) => 'text' | 'event' + + /** + * Undo a provider rewrite of tool arguments before the input check. + * Strict mode returns `null` for an omitted optional field. The live + * stream undoes that on `TOOL_CALL_END`. History rebuilt from + * `TOOL_CALL_ARGS` still has the `null`, so a resume calls this first. + * Omit it when the adapter does not rewrite arguments. + */ + normalizeToolInput?: ( + toolName: string, + input: unknown, + tools: ReadonlyArray | undefined, + ) => unknown } /** diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index 2cfdcfe546..ae511a9f4c 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -2829,6 +2829,9 @@ class TextEngine< this.params.toolExecution, this.params.repairToolInput, this.toolCapabilities(), + this.toolInputNormalizer( + this.resolveExecutableTools(executablePendingCalls), + ), ) // Consume the async generator, yielding custom events and collecting the return value @@ -3079,6 +3082,9 @@ class TextEngine< this.params.toolExecution, this.params.repairToolInput, this.toolCapabilities(), + this.toolInputNormalizer( + this.resolveExecutableTools(executableToolCalls), + ), ) // Consume the async generator, yielding custom events and collecting the return value @@ -4193,9 +4199,23 @@ class TextEngine< undefined, this.params.repairToolInput, this.toolCapabilities(), + this.toolInputNormalizer(tools), ) } + /** + * The adapter's undo for provider-rewritten tool arguments, closed over + * the tools of this batch. Absent when the adapter does not rewrite them. + */ + private toolInputNormalizer( + tools: ReadonlyArray, + ): ((toolName: string, input: unknown) => unknown) | undefined { + const normalize = this.adapter.normalizeToolInput + if (!normalize) return undefined + const adapter = this.adapter + return (toolName, input) => normalize.call(adapter, toolName, input, tools) + } + /** The run's capability readers, for each server tool's context. */ private toolCapabilities(): Pick< ToolExecutionContext, diff --git a/packages/ai/src/activities/chat/tools/tool-calls.ts b/packages/ai/src/activities/chat/tools/tool-calls.ts index 8eb61bd48d..87a1dcbdc1 100644 --- a/packages/ai/src/activities/chat/tools/tool-calls.ts +++ b/packages/ai/src/activities/chat/tools/tool-calls.ts @@ -1022,6 +1022,9 @@ async function buildClientToolResult( * repaired: the built-in repair (default), none (`false`), or a function. * @param capabilities - The run's capability readers. Each server tool gets * them on its context as `get` and `getOptional`. + * @param normalizeToolInput - Undo a provider rewrite of the parsed + * arguments before the check. Strict-mode `null`s for omitted optional + * fields are removed here, including on an approval resume. */ export async function* executeToolCalls( toolCalls: Array, @@ -1041,6 +1044,7 @@ export async function* executeToolCalls( toolExecution: NonNullable = 'parallel', repairToolInput?: TextOptions['repairToolInput'], capabilities?: Pick, + normalizeToolInput?: (toolName: string, input: unknown) => unknown, ): AsyncGenerator { const results: Array = [] const needsApproval: Array = [] @@ -1184,6 +1188,7 @@ export async function* executeToolCalls( const parsed = JSON.parse(argsStr) // Normalize null/non-object to {} (e.g. Anthropic empty tool_use blocks) input = parsed && typeof parsed === 'object' ? parsed : {} + if (normalizeToolInput) input = normalizeToolInput(toolName, input) } catch { results.push({ toolCallId: toolCall.id, @@ -1423,6 +1428,7 @@ export async function* executeToolCalls( toolExecution, repairToolInput, capabilities, + normalizeToolInput, ) results.push(...rest.results) needsClientExecution.push(...rest.needsClientExecution) diff --git a/packages/ai/tests/tool-input-validation.test.ts b/packages/ai/tests/tool-input-validation.test.ts index ec46a8f8a3..ecf25d2cfa 100644 --- a/packages/ai/tests/tool-input-validation.test.ts +++ b/packages/ai/tests/tool-input-validation.test.ts @@ -43,6 +43,7 @@ async function runTool( middleware?: ToolExecutionMiddlewareHooks clientResults?: Map repairToolInput?: TextOptions['repairToolInput'] + normalizeToolInput?: (toolName: string, input: unknown) => unknown } = {}, ) { const executed: Array = [] @@ -78,6 +79,8 @@ async function runTool( undefined, undefined, options.repairToolInput, + undefined, + options.normalizeToolInput, ) while (true) { const next = await generator.next() @@ -1260,6 +1263,116 @@ describe('client tool input in chat()', () => { expect(clientInput(chunks)).toBeUndefined() }) + it('runs an approved tool when history arguments still have a strict null', async () => { + const { adapter } = createMockAdapter({ + iterations: [[ev.runStarted(), ev.runFinished()]], + }) + adapter.normalizeToolInput = (_toolName, input) => { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return input + } + const record = input as Record + if (record.date !== null) return input + const { date: _date, ...rest } = record + return rest + } + const executed: Array = [] + const chunks = await collectChunks( + chat({ + threadId: 'thread-1', + adapter, + parentRunId: 'old', + messages: history({ destination: 'Tokyo', date: null, note: null }), + tools: [ + { + name: 'check', + description: 'Book a flight', + needsApproval: true, + inputSchema: { + type: 'object', + properties: { + destination: { type: 'string' }, + date: { type: 'string' }, + note: { type: ['string', 'null'] }, + }, + required: ['destination'], + additionalProperties: false, + }, + execute: (input: unknown) => { + executed.push(input) + return { booked: true } + }, + }, + ], + resume: [ + { + interruptId: 'approval_call', + status: 'resolved', + payload: { approved: true }, + }, + ], + repairToolInput: false, + }), + ) + expect(executed).toEqual([{ destination: 'Tokyo', note: null }]) + expect( + chunks.some( + (chunk) => + chunk.type === EventType.TOOL_CALL_RESULT && + typeof chunk.content === 'string' && + chunk.content.includes('Input validation failed'), + ), + ).toBe(false) + }) + + it('keeps a strict null when the adapter does not undo it', async () => { + const { adapter } = createMockAdapter({ + iterations: [[ev.runStarted(), ev.runFinished()]], + }) + const chunks = await collectChunks( + chat({ + threadId: 'thread-1', + adapter, + parentRunId: 'old', + messages: history({ destination: 'Tokyo', date: null }), + tools: [ + { + name: 'check', + description: 'Book a flight', + needsApproval: true, + inputSchema: { + type: 'object', + properties: { + destination: { type: 'string' }, + date: { type: 'string' }, + }, + required: ['destination'], + additionalProperties: false, + }, + execute: () => ({ booked: true }), + }, + ], + resume: [ + { + interruptId: 'approval_call', + status: 'resolved', + payload: { approved: true }, + }, + ], + repairToolInput: false, + }), + ) + expect( + chunks.some( + (chunk) => + chunk.type === EventType.TOOL_CALL_RESULT && + typeof chunk.content === 'string' && + chunk.content.includes('Input validation failed') && + chunk.content.includes('\\"date\\":null'), + ), + ).toBe(true) + }) + it('checks an approval edit after middleware on resume', async () => { const { adapter, calls } = createMockAdapter({ iterations: [] }) const seen: Array = [] diff --git a/packages/openai-base/src/adapters/chat-completions-text.ts b/packages/openai-base/src/adapters/chat-completions-text.ts index 99dfadc9ad..eb3b69c756 100644 --- a/packages/openai-base/src/adapters/chat-completions-text.ts +++ b/packages/openai-base/src/adapters/chat-completions-text.ts @@ -48,6 +48,7 @@ import type { ChatCompletionMessageParam, } from 'openai/resources/chat/completions/completions' import type { + AnyTool, ContentPart, DefaultMessageMetadataByModality, Modality, @@ -822,6 +823,17 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< return makeStructuredOutputCompatibleWithMap(schema, originalRequired) } + /** Undo strict-mode nulls before the engine checks recorded arguments. */ + normalizeToolInput( + toolName: string, + input: unknown, + tools: ReadonlyArray | undefined, + ): unknown { + return createToolInputNormalizer(tools, (schema, required) => + this.makeStructuredOutputCompatibleWithMap(schema, required), + )(toolName, input) + } + /** * Applies provider-specific transformations for structured output compatibility. * Override `makeStructuredOutputCompatibleWithMap` when you need the inverse map diff --git a/packages/openai-base/src/adapters/responses-text.ts b/packages/openai-base/src/adapters/responses-text.ts index 0d2d54d375..8b99c84b62 100644 --- a/packages/openai-base/src/adapters/responses-text.ts +++ b/packages/openai-base/src/adapters/responses-text.ts @@ -1309,6 +1309,17 @@ export abstract class OpenAIBaseResponsesTextAdapter< return makeStructuredOutputCompatibleWithMap(schema, originalRequired) } + /** Undo strict-mode nulls before the engine checks recorded arguments. */ + normalizeToolInput( + toolName: string, + input: unknown, + tools: ReadonlyArray | undefined, + ): unknown { + return createToolInputNormalizer(tools, (schema, required) => + this.makeStructuredOutputCompatibleWithMap(schema, required), + )(toolName, input) + } + /** * Applies provider-specific transformations for structured output compatibility. * Override `makeStructuredOutputCompatibleWithMap` when you need the inverse map diff --git a/packages/openai-base/src/utils/tool-input-normalizer.test.ts b/packages/openai-base/src/utils/tool-input-normalizer.test.ts index 00aae89f91..ca349a920b 100644 --- a/packages/openai-base/src/utils/tool-input-normalizer.test.ts +++ b/packages/openai-base/src/utils/tool-input-normalizer.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest' +import { OpenAIBaseChatCompletionsTextAdapter } from '../adapters/chat-completions-text' +import { OpenAIBaseResponsesTextAdapter } from '../adapters/responses-text' import { createToolInputNormalizer } from './tool-input-normalizer' import type { Tool } from '@tanstack/ai' +class Completions extends OpenAIBaseChatCompletionsTextAdapter {} +class Responses extends OpenAIBaseResponsesTextAdapter {} + describe('createToolInputNormalizer', () => { it('removes a null synthesized for an optional enum', () => { const tool: Tool = { @@ -144,3 +149,33 @@ describe('createToolInputNormalizer', () => { expect(normalize(tool.name, { note: null })).toEqual({}) }) }) + +describe('adapter normalizeToolInput', () => { + const tool: Tool = { + name: 'bookFlight', + description: 'Book a flight', + inputSchema: { + type: 'object', + properties: { + destination: { type: 'string' }, + date: { type: 'string' }, + note: { type: ['string', 'null'] }, + }, + required: ['destination'], + additionalProperties: false, + }, + } + + it('removes a strict null and keeps a nullable null', () => { + const input = { destination: 'Tokyo', date: null, note: null } + for (const adapter of [ + new Completions('gpt-test', 'openai', {} as never), + new Responses('gpt-test', 'openai', {} as never), + ]) { + expect(adapter.normalizeToolInput(tool.name, input, [tool])).toEqual({ + destination: 'Tokyo', + note: null, + }) + } + }) +}) diff --git a/packages/openai-base/src/utils/tool-input-normalizer.ts b/packages/openai-base/src/utils/tool-input-normalizer.ts index 2843b48b9d..228750a7fb 100644 --- a/packages/openai-base/src/utils/tool-input-normalizer.ts +++ b/packages/openai-base/src/utils/tool-input-normalizer.ts @@ -21,7 +21,7 @@ type SchemaConverterWithMap = ( * null-widened on the wire. */ export function createToolInputNormalizer( - tools: Array | undefined, + tools: ReadonlyArray> | undefined, convertSchema: SchemaConverterWithMap = makeStructuredOutputCompatibleWithMap, ): ToolInputNormalizer { const maps = new Map() diff --git a/testing/e2e/src/routes/api.openai-strict-tool-null-wire.ts b/testing/e2e/src/routes/api.openai-strict-tool-null-wire.ts index ace94b8725..2cd10175ba 100644 --- a/testing/e2e/src/routes/api.openai-strict-tool-null-wire.ts +++ b/testing/e2e/src/routes/api.openai-strict-tool-null-wire.ts @@ -153,7 +153,13 @@ function makeTextStream(): ReadableStream { export const Route = createFileRoute('/api/openai-strict-tool-null-wire')({ server: { handlers: { - POST: async () => { + POST: async ({ request }) => { + const body: unknown = await request.json().catch(() => ({})) + const approvalResume = + typeof body === 'object' && + body !== null && + 'resume' in body && + body.resume === true let requestCount = 0 let firstRequestBody: unknown let executedInput: unknown @@ -167,7 +173,9 @@ export const Route = createFileRoute('/api/openai-strict-tool-null-wire')({ } return new Response( - requestCount === 1 ? makeToolCallStream() : makeTextStream(), + !approvalResume && requestCount === 1 + ? makeToolCallStream() + : makeTextStream(), { headers: { 'Content-Type': 'text/event-stream' } }, ) } @@ -190,6 +198,87 @@ export const Route = createFileRoute('/api/openai-strict-tool-null-wire')({ }) const text: Array = [] + if (approvalResume) { + const toolCallId = 'call_book' + const args = JSON.stringify({ + destination: 'Tokyo', + date: null, + note: null, + }) + const bookFlight = toolDefinition({ + name: 'bookFlight', + description: 'Book a flight', + needsApproval: true, + inputSchema: { + type: 'object', + properties: { + destination: { type: 'string' }, + date: { type: 'string' }, + note: { type: ['string', 'null'] }, + }, + required: ['destination'], + additionalProperties: false, + }, + }).server((input) => { + executedInput = input + return { booked: true } + }) + let toolError: string | undefined + try { + for await (const chunk of chat({ + threadId: 'openai-strict-tool-null-wire', + ...createChatOptions({ adapter }), + messages: [ + { + role: 'user', + content: 'Book a flight to Tokyo. I have no date yet.', + }, + { + role: 'assistant', + content: null, + toolCalls: [ + { + id: toolCallId, + type: 'function', + function: { name: 'bookFlight', arguments: args }, + }, + ], + }, + ], + tools: [bookFlight], + parentRunId: 'r1', + resume: [ + { + interruptId: `approval_${toolCallId}`, + status: 'resolved', + payload: { approved: true }, + }, + ], + repairToolInput: false, + agentLoopStrategy: maxIterations(3), + })) { + if ( + chunk.type === 'TOOL_CALL_RESULT' && + typeof chunk.content === 'string' && + chunk.content.includes('Input validation failed') + ) { + toolError = chunk.content + } + } + } catch (error) { + return Response.json({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }) + } + return Response.json({ + ok: toolError === undefined && executedInput !== undefined, + executedInput, + toolError, + requestCount, + }) + } + try { for await (const chunk of chat({ threadId: 'openai-strict-tool-null-wire', diff --git a/testing/e2e/tests/openai-strict-tool-null-wire.spec.ts b/testing/e2e/tests/openai-strict-tool-null-wire.spec.ts index 164ce1f089..daa9f717b2 100644 --- a/testing/e2e/tests/openai-strict-tool-null-wire.spec.ts +++ b/testing/e2e/tests/openai-strict-tool-null-wire.spec.ts @@ -59,4 +59,26 @@ test.describe('openai strict tool optional fields', () => { expect(result.requestCount).toBe(2) expect(result.text).toBe('Tool executed.') }) + + test('undoes a strict null on an approved resume when repair is off', async ({ + request, + }) => { + const response = await request.post('/api/openai-strict-tool-null-wire', { + data: { resume: true }, + }) + expect(response.ok()).toBe(true) + const result = (await response.json()) as { + ok: boolean + error?: string + toolError?: string + executedInput: Record + } + if (!result.ok) { + throw new Error(result.toolError ?? result.error ?? 'Route failed') + } + expect(result.executedInput).toEqual({ + destination: 'Tokyo', + note: null, + }) + }) })