From 938a84172a1e8d861a46134b77cafd91ab880f5d Mon Sep 17 00:00:00 2001 From: Ertem Biyik Date: Mon, 22 Sep 2025 15:12:44 +0300 Subject: [PATCH] fix: open ai compatible models misuse '' in tools arguments call when an empty object is the valid option --- .changeset/quick-frogs-lie.md | 5 + packages/agents-extensions/src/aiSdk.ts | 64 ++++++++++- packages/agents-extensions/test/aiSdk.test.ts | 104 ++++++++++++++++++ 3 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 .changeset/quick-frogs-lie.md diff --git a/.changeset/quick-frogs-lie.md b/.changeset/quick-frogs-lie.md new file mode 100644 index 00000000..0dfc099c --- /dev/null +++ b/.changeset/quick-frogs-lie.md @@ -0,0 +1,5 @@ +--- +'@openai/agents-extensions': minor +--- + +Fix open ai compatible models misuse '' in tools arguments call when an empty object is the valid option diff --git a/packages/agents-extensions/src/aiSdk.ts b/packages/agents-extensions/src/aiSdk.ts index 5f62635b..39e58bed 100644 --- a/packages/agents-extensions/src/aiSdk.ts +++ b/packages/agents-extensions/src/aiSdk.ts @@ -301,6 +301,36 @@ function convertToAiSdkOutput( ); } +function schemaAcceptsObject(schema: JSONSchema7 | undefined): boolean { + if (!schema) { + return false; + } + const schemaType = schema.type; + if (Array.isArray(schemaType)) { + if (schemaType.includes('object')) { + return true; + } + } else if (schemaType === 'object') { + return true; + } + return Boolean(schema.properties || schema.additionalProperties); +} + +function expectsObjectArguments( + tool: SerializedTool | SerializedHandoff | undefined, +): boolean { + if (!tool) { + return false; + } + if ('toolName' in tool) { + return schemaAcceptsObject(tool.inputJsonSchema as JSONSchema7 | undefined); + } + if (tool.type === 'function') { + return schemaAcceptsObject(tool.parameters as JSONSchema7 | undefined); + } + return false; +} + /** * @internal * Converts a tool to a language model V2 tool. @@ -481,15 +511,41 @@ export class AiSdkModel implements Model { (c: any) => c && c.type === 'tool-call', ); const hasToolCalls = toolCalls.length > 0; + + const toolsNameToToolMap = new Map< + string, + SerializedTool | SerializedHandoff + >(request.tools.map((tool) => [tool.name, tool] as const)); + + for (const handoff of request.handoffs) { + toolsNameToToolMap.set(handoff.toolName, handoff); + } for (const toolCall of toolCalls) { + const requestedTool = + typeof toolCall.toolName === 'string' + ? toolsNameToToolMap.get(toolCall.toolName) + : undefined; + + if (!requestedTool && toolCall.toolName) { + this.#logger.warn( + `Received tool call for unknown tool '${toolCall.toolName}'.`, + ); + } + + let toolCallArguments: string; + if (typeof toolCall.input === 'string') { + toolCallArguments = + toolCall.input === '' && expectsObjectArguments(requestedTool) + ? JSON.stringify({}) + : toolCall.input; + } else { + toolCallArguments = JSON.stringify(toolCall.input ?? {}); + } output.push({ type: 'function_call', callId: toolCall.toolCallId, name: toolCall.toolName, - arguments: - typeof toolCall.input === 'string' - ? toolCall.input - : JSON.stringify(toolCall.input ?? {}), + arguments: toolCallArguments, status: 'completed', providerData: hasToolCalls ? result.providerMetadata : undefined, }); diff --git a/packages/agents-extensions/test/aiSdk.test.ts b/packages/agents-extensions/test/aiSdk.test.ts index 4f8c1352..0cb505d1 100644 --- a/packages/agents-extensions/test/aiSdk.test.ts +++ b/packages/agents-extensions/test/aiSdk.test.ts @@ -427,6 +427,110 @@ describe('AiSdkModel.getResponse', () => { ]); }); + test('normalizes empty string tool input for object schemas', async () => { + const model = new AiSdkModel( + stubModel({ + async doGenerate() { + return { + content: [ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'objectTool', + input: '', + }, + ], + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + providerMetadata: { meta: true }, + response: { id: 'id' }, + finishReason: 'tool-calls', + warnings: [], + } as any; + }, + }), + ); + + const res = await withTrace('t', () => + model.getResponse({ + input: 'hi', + tools: [ + { + type: 'function', + name: 'objectTool', + description: 'accepts object', + parameters: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + } as any, + ], + handoffs: [], + modelSettings: {}, + outputType: 'text', + tracing: false, + } as any), + ); + + expect(res.output).toHaveLength(1); + expect(res.output[0]).toMatchObject({ + type: 'function_call', + arguments: '{}', + }); + }); + + test('normalizes empty string tool input for handoff schemas', async () => { + const model = new AiSdkModel( + stubModel({ + async doGenerate() { + return { + content: [ + { + type: 'tool-call', + toolCallId: 'handoff-call', + toolName: 'handoffTool', + input: '', + }, + ], + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + providerMetadata: { meta: true }, + response: { id: 'id' }, + finishReason: 'tool-calls', + warnings: [], + } as any; + }, + }), + ); + + const res = await withTrace('t', () => + model.getResponse({ + input: 'hi', + tools: [], + handoffs: [ + { + toolName: 'handoffTool', + toolDescription: 'handoff accepts object', + inputJsonSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + strictJsonSchema: true, + } as any, + ], + modelSettings: {}, + outputType: 'text', + tracing: false, + } as any), + ); + + expect(res.output).toHaveLength(1); + expect(res.output[0]).toMatchObject({ + type: 'function_call', + arguments: '{}', + }); + }); + test('forwards toolChoice to AI SDK (generate)', async () => { const seen: any[] = []; const model = new AiSdkModel(