From a113baded497de58870cf6b09554c5a418ec4691 Mon Sep 17 00:00:00 2001 From: inishant Date: Sat, 28 Mar 2026 16:46:55 +0530 Subject: [PATCH 1/2] fix(ai): forward tool strict mode in toolsToModelTools The core AI SDK's `prepareToolsAndToolChoice` already forwards `tool.strict` when building `LanguageModelV2FunctionTool` objects, but `@workflow/ai`'s `toolsToModelTools` does not. This means tools with `strict: true` lose that flag when run through DurableAgent, causing providers that support strict schema validation to fall back to non-strict mode. Align with the AI SDK by conditionally spreading `strict` when the tool defines it. --- packages/ai/src/agent/tools-to-model-tools.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/ai/src/agent/tools-to-model-tools.ts b/packages/ai/src/agent/tools-to-model-tools.ts index 64ed2ef0c9..23ba5f62f3 100644 --- a/packages/ai/src/agent/tools-to-model-tools.ts +++ b/packages/ai/src/agent/tools-to-model-tools.ts @@ -1,6 +1,8 @@ import type { LanguageModelV3FunctionTool } from '@ai-sdk/provider'; import { asSchema, type ToolSet } from 'ai'; +// Mirrors the tool→LanguageModelV3FunctionTool mapping in the core AI SDK's +// prepareToolsAndToolChoice (ai/src/prompt/prepare-tools-and-tool-choice.ts). export async function toolsToModelTools( tools: ToolSet ): Promise { @@ -10,6 +12,11 @@ export async function toolsToModelTools( 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 } : {}), })) ); } From 9df00899d281c487ef7046e5494196c8038f1d37 Mon Sep 17 00:00:00 2001 From: inishant Date: Sat, 4 Apr 2026 13:58:09 +0530 Subject: [PATCH 2/2] review comments --- .changeset/fix-tools-to-model-tools.md | 5 + .../ai/src/agent/tools-to-model-tools.test.ts | 186 ++++++++++++++++++ packages/ai/src/agent/tools-to-model-tools.ts | 64 ++++-- 3 files changed, 238 insertions(+), 17 deletions(-) create mode 100644 .changeset/fix-tools-to-model-tools.md create mode 100644 packages/ai/src/agent/tools-to-model-tools.test.ts diff --git a/.changeset/fix-tools-to-model-tools.md b/.changeset/fix-tools-to-model-tools.md new file mode 100644 index 0000000000..563856aef0 --- /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 add support for `type: 'provider'` 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 new file mode 100644 index 0000000000..e5d1c16365 --- /dev/null +++ b/packages/ai/src/agent/tools-to-model-tools.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { tool } from 'ai'; +import { toolsToModelTools } from './tools-to-model-tools.js'; + +describe('toolsToModelTools', () => { + it('converts a basic function tool', async () => { + const tools = { + weather: tool({ + description: 'Get weather', + inputSchema: z.object({ location: z.string() }), + execute: async () => 'sunny', + }), + }; + + const result = await toolsToModelTools(tools); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: 'function', + name: 'weather', + description: 'Get weather', + }); + expect(result[0]).toHaveProperty('inputSchema'); + expect(result[0]).not.toHaveProperty('strict'); + expect(result[0]).not.toHaveProperty('inputExamples'); + }); + + 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 provider-type tools', async () => { + const tools = { + webSearch: { + type: 'provider' as const, + id: 'openai.web_search' as const, + args: { search_context_size: 'medium' }, + }, + }; + + const result = await toolsToModelTools( + tools as any // provider tools don't have inputSchema/execute + ); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'provider', + name: 'webSearch', + id: 'openai.web_search', + args: { search_context_size: 'medium' }, + }); + }); + + it('handles a mix of function and provider tools', async () => { + const tools = { + weather: tool({ + description: 'Get weather', + inputSchema: z.object({ location: z.string() }), + execute: async () => 'sunny', + }), + webSearch: { + type: 'provider' as const, + id: 'openai.web_search' as const, + args: {}, + }, + }; + + const result = await toolsToModelTools(tools as any); + + expect(result).toHaveLength(2); + expect(result.find((t) => t.name === 'weather')?.type).toBe('function'); + expect(result.find((t) => t.name === 'webSearch')?.type).toBe('provider'); + }); + + 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 23ba5f62f3..e45b19917e 100644 --- a/packages/ai/src/agent/tools-to-model-tools.ts +++ b/packages/ai/src/agent/tools-to-model-tools.ts @@ -1,22 +1,52 @@ -import type { LanguageModelV3FunctionTool } from '@ai-sdk/provider'; +import type { + LanguageModelV3FunctionTool, + LanguageModelV3ProviderTool, +} from '@ai-sdk/provider'; import { asSchema, type ToolSet } from 'ai'; -// Mirrors the tool→LanguageModelV3FunctionTool mapping in the core AI SDK's -// prepareToolsAndToolChoice (ai/src/prompt/prepare-tools-and-tool-choice.ts). +// 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]) => ({ - 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 } : {}), - })) - ); +): Promise> { + const result: Array< + LanguageModelV3FunctionTool | LanguageModelV3ProviderTool + > = []; + + for (const [name, tool] of Object.entries(tools)) { + const toolType = tool.type; + + switch (toolType) { + case undefined: + case 'dynamic': + case 'function': + result.push({ + 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 } : {}), + }); + break; + case 'provider': + result.push({ + type: 'provider' as const, + name, + id: tool.id, + args: tool.args, + }); + break; + default: { + const exhaustiveCheck: never = toolType as never; + throw new Error(`Unsupported tool type: ${exhaustiveCheck}`); + } + } + } + + return result; }