From a113baded497de58870cf6b09554c5a418ec4691 Mon Sep 17 00:00:00 2001 From: inishant Date: Sat, 28 Mar 2026 16:46:55 +0530 Subject: [PATCH 1/5] 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/5] 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; } From 03ce3d7a0ccf174bcd1c535c7e53e8dd5a01eaf8 Mon Sep 17 00:00:00 2001 From: inishant Date: Fri, 17 Apr 2026 02:34:42 +0530 Subject: [PATCH 3/5] refactor(ai): restore Promise.all structure in toolsToModelTools Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/ai/src/agent/tools-to-model-tools.ts | 70 +++++++++---------- 1 file changed, 33 insertions(+), 37 deletions(-) diff --git a/packages/ai/src/agent/tools-to-model-tools.ts b/packages/ai/src/agent/tools-to-model-tools.ts index 2fddfafd4c..f40ed5f9b3 100644 --- a/packages/ai/src/agent/tools-to-model-tools.ts +++ b/packages/ai/src/agent/tools-to-model-tools.ts @@ -10,43 +10,39 @@ import { asSchema, type ToolSet } from 'ai'; export async function toolsToModelTools( tools: ToolSet ): Promise> { - const result: Array< - LanguageModelV3FunctionTool | LanguageModelV3ProviderTool - > = []; + return Promise.all( + Object.entries(tools).map(async ([name, tool]) => { + const toolType = tool.type; - 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}`); + 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}`); + } } - } - } - - return result; + }) + ); } From 65e20ae1a72f75498bd66fc18b9cfefa790c9a2a Mon Sep 17 00:00:00 2001 From: inishant Date: Fri, 17 Apr 2026 02:39:23 +0530 Subject: [PATCH 4/5] chore: update changeset to reflect actual changes Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/fix-tools-to-model-tools.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-tools-to-model-tools.md b/.changeset/fix-tools-to-model-tools.md index 563856aef0..304c93e8a6 100644 --- a/.changeset/fix-tools-to-model-tools.md +++ b/.changeset/fix-tools-to-model-tools.md @@ -2,4 +2,4 @@ "@workflow/ai": patch --- -Forward `strict`, `inputExamples`, and `providerOptions` tool properties to language model providers, and add support for `type: 'provider'` tools +Forward `strict`, `inputExamples`, and `providerOptions` tool properties to language model providers, and handle `type: 'dynamic'` tools From 84818d528fcb7a3b13fb2f7a7566383d781ac2fc Mon Sep 17 00:00:00 2001 From: inishant Date: Fri, 17 Apr 2026 14:14:26 +0530 Subject: [PATCH 5/5] DCO Remediation Commit for inishant I, inishant , hereby add my Signed-off-by to this commit: a113baded497de58870cf6b09554c5a418ec4691 I, inishant , hereby add my Signed-off-by to this commit: 9df00899d281c487ef7046e5494196c8038f1d37 I, inishant , hereby add my Signed-off-by to this commit: 03ce3d7a0ccf174bcd1c535c7e53e8dd5a01eaf8 I, inishant , hereby add my Signed-off-by to this commit: 65e20ae1a72f75498bd66fc18b9cfefa790c9a2a Signed-off-by: inishant