diff --git a/.changeset/popular-suits-jam.md b/.changeset/popular-suits-jam.md new file mode 100644 index 00000000000..7c99799d123 --- /dev/null +++ b/.changeset/popular-suits-jam.md @@ -0,0 +1,5 @@ +--- +'ai': patch +--- + +fix (ai/core): support tool calls without arguments diff --git a/packages/ai/core/generate-text/generate-text.ts b/packages/ai/core/generate-text/generate-text.ts index d11a61fc6c6..fb2ffc1686e 100644 --- a/packages/ai/core/generate-text/generate-text.ts +++ b/packages/ai/core/generate-text/generate-text.ts @@ -25,10 +25,11 @@ import { calculateLanguageModelUsage, } from '../types/usage'; import { GenerateTextResult } from './generate-text-result'; +import { parseToolCall } from './parse-tool-call'; +import { StepResult } from './step-result'; import { toResponseMessages } from './to-response-messages'; -import { ToToolCallArray, parseToolCall } from './tool-call'; +import { ToToolCallArray } from './tool-call'; import { ToToolResultArray } from './tool-result'; -import { StepResult } from './step-result'; const originalGenerateId = createIdGenerator({ prefix: 'aitxt-', size: 24 }); diff --git a/packages/ai/core/generate-text/parse-tool-call.test.ts b/packages/ai/core/generate-text/parse-tool-call.test.ts new file mode 100644 index 00000000000..ec45c7cb273 --- /dev/null +++ b/packages/ai/core/generate-text/parse-tool-call.test.ts @@ -0,0 +1,110 @@ +import { z } from 'zod'; +import { InvalidToolArgumentsError } from '../../errors/invalid-tool-arguments-error'; +import { NoSuchToolError } from '../../errors/no-such-tool-error'; +import { tool } from '../tool'; +import { parseToolCall } from './parse-tool-call'; + +it('should successfully parse a valid tool call', () => { + const result = parseToolCall({ + toolCall: { + toolCallType: 'function', + toolName: 'testTool', + toolCallId: '123', + args: '{"param1": "test", "param2": 42}', + }, + tools: { + testTool: tool({ + parameters: z.object({ + param1: z.string(), + param2: z.number(), + }), + }), + } as const, + }); + + expect(result).toEqual({ + type: 'tool-call', + toolCallId: '123', + toolName: 'testTool', + args: { param1: 'test', param2: 42 }, + }); +}); + +it('should successfully process empty calls for tools that have no parameters', () => { + const result = parseToolCall({ + toolCall: { + toolCallType: 'function', + toolName: 'testTool', + toolCallId: '123', + args: '', + }, + tools: { + testTool: tool({ + parameters: z.object({}), + }), + } as const, + }); + + expect(result).toEqual({ + type: 'tool-call', + toolCallId: '123', + toolName: 'testTool', + args: {}, + }); +}); + +it('should throw NoSuchToolError when tools is null', () => { + expect(() => + parseToolCall({ + toolCall: { + toolCallType: 'function', + toolName: 'testTool', + toolCallId: '123', + args: '{}', + }, + tools: undefined, + }), + ).toThrow(NoSuchToolError); +}); + +it('should throw NoSuchToolError when tool is not found', () => { + expect(() => + parseToolCall({ + toolCall: { + toolCallType: 'function', + toolName: 'nonExistentTool', + toolCallId: '123', + args: '{}', + }, + tools: { + testTool: tool({ + parameters: z.object({ + param1: z.string(), + param2: z.number(), + }), + }), + } as const, + }), + ).toThrow(NoSuchToolError); +}); + +it('should throw InvalidToolArgumentsError when args are invalid', () => { + expect(() => + parseToolCall({ + toolCall: { + toolCallType: 'function', + toolName: 'testTool', + toolCallId: '123', + args: '{"param1": "test"}', // Missing required param2 + }, + tools: { + testTool: tool({ + parameters: z.object({ + param1: z.string(), + param2: z.number(), + }), + }), + } as const, + }), + ).toThrow(InvalidToolArgumentsError); +}); diff --git a/packages/ai/core/generate-text/parse-tool-call.ts b/packages/ai/core/generate-text/parse-tool-call.ts new file mode 100644 index 00000000000..ba4755364e9 --- /dev/null +++ b/packages/ai/core/generate-text/parse-tool-call.ts @@ -0,0 +1,57 @@ +import { LanguageModelV1FunctionToolCall } from '@ai-sdk/provider'; +import { safeParseJSON, safeValidateTypes } from '@ai-sdk/provider-utils'; +import { Schema, asSchema } from '@ai-sdk/ui-utils'; +import { InvalidToolArgumentsError } from '../../errors/invalid-tool-arguments-error'; +import { NoSuchToolError } from '../../errors/no-such-tool-error'; +import { CoreTool } from '../tool'; +import { inferParameters } from '../tool/tool'; +import { ToToolCall } from './tool-call'; + +export function parseToolCall>({ + toolCall, + tools, +}: { + toolCall: LanguageModelV1FunctionToolCall; + tools?: TOOLS; +}): ToToolCall { + const toolName = toolCall.toolName as keyof TOOLS & string; + + if (tools == null) { + throw new NoSuchToolError({ toolName: toolCall.toolName }); + } + + const tool = tools[toolName]; + + if (tool == null) { + throw new NoSuchToolError({ + toolName: toolCall.toolName, + availableTools: Object.keys(tools), + }); + } + + const schema = asSchema(tool.parameters) as Schema< + inferParameters + >; + + // when the tool call has no arguments, we try passing an empty object to the schema + // (many LLMs generate empty strings for tool calls with no arguments) + const parseResult = + toolCall.args.trim() === '' + ? safeValidateTypes({ value: {}, schema }) + : safeParseJSON({ text: toolCall.args, schema }); + + if (parseResult.success === false) { + throw new InvalidToolArgumentsError({ + toolName, + toolArgs: toolCall.args, + cause: parseResult.error, + }); + } + + return { + type: 'tool-call', + toolCallId: toolCall.toolCallId, + toolName, + args: parseResult.value, + }; +} diff --git a/packages/ai/core/generate-text/run-tools-transformation.ts b/packages/ai/core/generate-text/run-tools-transformation.ts index 67f4169c713..4a54e3f800f 100644 --- a/packages/ai/core/generate-text/run-tools-transformation.ts +++ b/packages/ai/core/generate-text/run-tools-transformation.ts @@ -8,13 +8,14 @@ import { selectTelemetryAttributes } from '../telemetry/select-telemetry-attribu import { TelemetrySettings } from '../telemetry/telemetry-settings'; import { CoreTool } from '../tool'; import { - LanguageModelUsage, FinishReason, + LanguageModelUsage, LogProbs, ProviderMetadata, } from '../types'; import { calculateLanguageModelUsage } from '../types/usage'; -import { parseToolCall, ToToolCall } from './tool-call'; +import { parseToolCall } from './parse-tool-call'; +import { ToToolCall } from './tool-call'; import { ToToolResult } from './tool-result'; export type SingleRequestTextStreamPart< diff --git a/packages/ai/core/generate-text/tool-call.ts b/packages/ai/core/generate-text/tool-call.ts index 24e86ab335e..2c5a3160a5e 100644 --- a/packages/ai/core/generate-text/tool-call.ts +++ b/packages/ai/core/generate-text/tool-call.ts @@ -1,8 +1,3 @@ -import { LanguageModelV1FunctionToolCall } from '@ai-sdk/provider'; -import { safeParseJSON } from '@ai-sdk/provider-utils'; -import { Schema, asSchema } from '@ai-sdk/ui-utils'; -import { InvalidToolArgumentsError } from '../../errors/invalid-tool-arguments-error'; -import { NoSuchToolError } from '../../errors/no-such-tool-error'; import { CoreTool } from '../tool'; import { inferParameters } from '../tool/tool'; import { ValueOf } from '../util/value-of'; @@ -41,48 +36,3 @@ export type ToToolCall> = ValueOf<{ export type ToToolCallArray> = Array< ToToolCall >; - -export function parseToolCall>({ - toolCall, - tools, -}: { - toolCall: LanguageModelV1FunctionToolCall; - tools?: TOOLS; -}): ToToolCall { - const toolName = toolCall.toolName as keyof TOOLS & string; - - if (tools == null) { - throw new NoSuchToolError({ toolName: toolCall.toolName }); - } - - const tool = tools[toolName]; - - if (tool == null) { - throw new NoSuchToolError({ - toolName: toolCall.toolName, - availableTools: Object.keys(tools), - }); - } - - const parseResult = safeParseJSON({ - text: toolCall.args, - schema: asSchema(tool.parameters) as Schema< - inferParameters - >, - }); - - if (parseResult.success === false) { - throw new InvalidToolArgumentsError({ - toolName, - toolArgs: toolCall.args, - cause: parseResult.error, - }); - } - - return { - type: 'tool-call', - toolCallId: toolCall.toolCallId, - toolName, - args: parseResult.value, - }; -}