diff --git a/.changeset/fix-tools-to-model-tools.md b/.changeset/fix-tools-to-model-tools.md new file mode 100644 index 0000000000..304c93e8a6 --- /dev/null +++ b/.changeset/fix-tools-to-model-tools.md @@ -0,0 +1,5 @@ +--- +"@workflow/ai": patch +--- + +Forward `strict`, `inputExamples`, and `providerOptions` tool properties to language model providers, and handle `type: 'dynamic'` tools diff --git a/packages/ai/src/agent/tools-to-model-tools.test.ts b/packages/ai/src/agent/tools-to-model-tools.test.ts index 88e37a5860..9dfde769e5 100644 --- a/packages/ai/src/agent/tools-to-model-tools.test.ts +++ b/packages/ai/src/agent/tools-to-model-tools.test.ts @@ -8,7 +8,7 @@ describe('toolsToModelTools', () => { const tools = { weather: tool({ description: 'Get the weather', - parameters: z.object({ city: z.string() }), + inputSchema: z.object({ city: z.string() }), execute: async ({ city }) => `Weather in ${city}: sunny`, }), }; @@ -61,7 +61,7 @@ describe('toolsToModelTools', () => { const tools = { weather: tool({ description: 'Get the weather', - parameters: z.object({ city: z.string() }), + inputSchema: z.object({ city: z.string() }), execute: async ({ city }) => `Weather in ${city}: sunny`, }), webSearch: providerTool, @@ -103,4 +103,119 @@ describe('toolsToModelTools', () => { args: {}, }); }); + + it('forwards strict: true', async () => { + const tools = { + weather: tool({ + description: 'Get weather', + inputSchema: z.object({ location: z.string() }), + execute: async () => 'sunny', + strict: true, + }), + }; + + const result = await toolsToModelTools(tools); + + expect(result[0]).toMatchObject({ strict: true }); + }); + + it('forwards strict: false', async () => { + const tools = { + weather: tool({ + description: 'Get weather', + inputSchema: z.object({ location: z.string() }), + execute: async () => 'sunny', + strict: false, + }), + }; + + const result = await toolsToModelTools(tools); + + expect(result[0]).toMatchObject({ strict: false }); + }); + + it('omits strict key when not set', async () => { + const tools = { + weather: tool({ + description: 'Get weather', + inputSchema: z.object({ location: z.string() }), + execute: async () => 'sunny', + }), + }; + + const result = await toolsToModelTools(tools); + + expect(result[0]).not.toHaveProperty('strict'); + }); + + it('forwards inputExamples', async () => { + const examples = [{ input: { location: 'Tokyo' } }]; + const tools = { + weather: tool({ + description: 'Get weather', + inputSchema: z.object({ location: z.string() }), + execute: async () => 'sunny', + inputExamples: examples, + }), + }; + + const result = await toolsToModelTools(tools); + + expect(result[0]).toMatchObject({ inputExamples: examples }); + }); + + it('omits inputExamples key when not set', async () => { + const tools = { + weather: tool({ + description: 'Get weather', + inputSchema: z.object({ location: z.string() }), + execute: async () => 'sunny', + }), + }; + + const result = await toolsToModelTools(tools); + + expect(result[0]).not.toHaveProperty('inputExamples'); + }); + + it('forwards providerOptions', async () => { + const providerOptions = { openai: { parallel_tool_calls: false } }; + const tools = { + weather: tool({ + description: 'Get weather', + inputSchema: z.object({ location: z.string() }), + execute: async () => 'sunny', + providerOptions, + }), + }; + + const result = await toolsToModelTools(tools); + + expect(result[0]).toMatchObject({ providerOptions }); + }); + + it('handles tools with type: "dynamic" as function tools', async () => { + const tools = { + dynamic: { + type: 'dynamic' as const, + description: 'A dynamic tool', + inputSchema: z.object({ input: z.string() }), + execute: async () => 'result', + }, + }; + + const result = await toolsToModelTools(tools as any); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: 'function', + name: 'dynamic', + description: 'A dynamic tool', + }); + }); + + it('returns empty array for empty tools', async () => { + const result = await toolsToModelTools({}); + expect(result).toEqual([]); + }); }); diff --git a/packages/ai/src/agent/tools-to-model-tools.ts b/packages/ai/src/agent/tools-to-model-tools.ts index a061a8a435..f40ed5f9b3 100644 --- a/packages/ai/src/agent/tools-to-model-tools.ts +++ b/packages/ai/src/agent/tools-to-model-tools.ts @@ -4,28 +4,45 @@ import type { } from '@ai-sdk/provider'; import { asSchema, type ToolSet } from 'ai'; +// Mirrors the tool→LanguageModelV3FunctionTool/LanguageModelV3ProviderTool +// mapping in the core AI SDK's prepareToolsAndToolChoice +// (ai/src/prompt/prepare-tools-and-tool-choice.ts). export async function toolsToModelTools( tools: ToolSet ): Promise> { return Promise.all( Object.entries(tools).map(async ([name, tool]) => { - // Preserve provider tool identity (e.g. anthropic.tools.webSearch) - // instead of converting to a plain function tool - if ((tool as any).type === 'provider') { - return { - type: 'provider' as const, - id: (tool as any).id as `${string}.${string}`, - name, - args: (tool as any).args ?? {}, - }; - } + const toolType = tool.type; - return { - type: 'function' as const, - name, - description: tool.description, - inputSchema: await asSchema(tool.inputSchema).jsonSchema, - }; + switch (toolType) { + case undefined: + case 'dynamic': + case 'function': + return { + type: 'function' as const, + name, + description: tool.description, + inputSchema: await asSchema(tool.inputSchema).jsonSchema, + ...(tool.inputExamples != null + ? { inputExamples: tool.inputExamples } + : {}), + providerOptions: tool.providerOptions, + ...(tool.strict != null ? { strict: tool.strict } : {}), + }; + case 'provider': + // Preserve provider tool identity (e.g. anthropic.tools.webSearch) + // instead of converting to a plain function tool + return { + type: 'provider' as const, + name, + id: tool.id, + args: tool.args ?? {}, + }; + default: { + const exhaustiveCheck: never = toolType as never; + throw new Error(`Unsupported tool type: ${exhaustiveCheck}`); + } + } }) ); }