From b1930b307def795f014d186c16290ecb80c26105 Mon Sep 17 00:00:00 2001 From: Gregor Martynus <39992+gr2m@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:10:53 -0700 Subject: [PATCH 1/2] fix(ai): preserve provider tool identity across step boundaries Port of vercel/ai#14229. Provider tools (e.g. anthropic.tools.webSearch) were converted to plain function tools in toolsToModelTools, stripping type, id, and args fields. This caused providers like Anthropic Gateway to not recognize them as provider-executed tools. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/fix-provider-tool-identity.md | 7 + .../ai/src/agent/tools-to-model-tools.test.ts | 106 ++++++++++++++ packages/ai/src/agent/tools-to-model-tools.ts | 32 +++-- packages/ai/src/providers/mock.ts | 132 ++++++++++++------ packages/core/e2e/e2e-agent.test.ts | 24 ++++ .../workflows/100_durable_agent_e2e.ts | 83 +++++++++++ 6 files changed, 330 insertions(+), 54 deletions(-) create mode 100644 .changeset/fix-provider-tool-identity.md create mode 100644 packages/ai/src/agent/tools-to-model-tools.test.ts diff --git a/.changeset/fix-provider-tool-identity.md b/.changeset/fix-provider-tool-identity.md new file mode 100644 index 0000000000..8aea387ebc --- /dev/null +++ b/.changeset/fix-provider-tool-identity.md @@ -0,0 +1,7 @@ +--- +'@workflow/ai': patch +--- + +fix(ai): preserve provider tool identity across step boundaries + +Provider tools (e.g. `anthropic.tools.webSearch`) were being converted to plain function tools in `toolsToModelTools`, stripping `type: 'provider'`, `id`, and `args`. This caused providers like Anthropic Gateway to not recognize them as provider-executed 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..88e37a5860 --- /dev/null +++ b/packages/ai/src/agent/tools-to-model-tools.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { tool } from 'ai'; +import { z } from 'zod'; +import { toolsToModelTools } from './tools-to-model-tools.js'; + +describe('toolsToModelTools', () => { + it('serializes function tools with description and inputSchema', async () => { + const tools = { + weather: tool({ + description: 'Get the weather', + parameters: z.object({ city: z.string() }), + execute: async ({ city }) => `Weather in ${city}: sunny`, + }), + }; + + const result = await toolsToModelTools(tools); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: 'function', + name: 'weather', + description: 'Get the weather', + }); + expect(result[0]).toHaveProperty('inputSchema'); + expect(result[0]).not.toHaveProperty('id'); + expect(result[0]).not.toHaveProperty('args'); + }); + + it('preserves provider tool type, id, and args', async () => { + // Simulate a provider tool (e.g. anthropic.tools.webSearch) + const providerTool = { + type: 'provider' as const, + id: 'anthropic.web_search' as const, + args: { maxUses: 5 }, + inputSchema: { type: 'object' as const, properties: {} }, + }; + + const tools = { + webSearch: providerTool, + } as any; + + const result = await toolsToModelTools(tools); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'provider', + id: 'anthropic.web_search', + name: 'webSearch', + args: { maxUses: 5 }, + }); + }); + + it('handles mixed function and provider tools', async () => { + const providerTool = { + type: 'provider' as const, + id: 'anthropic.web_search' as const, + args: {}, + inputSchema: { type: 'object' as const, properties: {} }, + }; + + const tools = { + weather: tool({ + description: 'Get the weather', + parameters: z.object({ city: z.string() }), + execute: async ({ city }) => `Weather in ${city}: sunny`, + }), + webSearch: providerTool, + } as any; + + const result = await toolsToModelTools(tools); + + expect(result).toHaveLength(2); + + const functionTool = result.find((t) => t.name === 'weather'); + const provider = result.find((t) => t.name === 'webSearch'); + + expect(functionTool).toMatchObject({ type: 'function', name: 'weather' }); + expect(provider).toEqual({ + type: 'provider', + id: 'anthropic.web_search', + name: 'webSearch', + args: {}, + }); + }); + + it('defaults args to empty object when not provided on provider tool', async () => { + const providerTool = { + type: 'provider' as const, + id: 'anthropic.code_execution' as const, + inputSchema: { type: 'object' as const, properties: {} }, + }; + + const tools = { + codeExec: providerTool, + } as any; + + const result = await toolsToModelTools(tools); + + expect(result[0]).toEqual({ + type: 'provider', + id: 'anthropic.code_execution', + name: 'codeExec', + args: {}, + }); + }); +}); diff --git a/packages/ai/src/agent/tools-to-model-tools.ts b/packages/ai/src/agent/tools-to-model-tools.ts index 64ed2ef0c9..a061a8a435 100644 --- a/packages/ai/src/agent/tools-to-model-tools.ts +++ b/packages/ai/src/agent/tools-to-model-tools.ts @@ -1,15 +1,31 @@ -import type { LanguageModelV3FunctionTool } from '@ai-sdk/provider'; +import type { + LanguageModelV3FunctionTool, + LanguageModelV3ProviderTool, +} from '@ai-sdk/provider'; import { asSchema, type ToolSet } from 'ai'; export async function toolsToModelTools( tools: ToolSet -): Promise { +): 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, - })) + 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 ?? {}, + }; + } + + return { + type: 'function' as const, + name, + description: tool.description, + inputSchema: await asSchema(tool.inputSchema).jsonSchema, + }; + }) ); } diff --git a/packages/ai/src/providers/mock.ts b/packages/ai/src/providers/mock.ts index a39ae98ee7..0eed83217c 100644 --- a/packages/ai/src/providers/mock.ts +++ b/packages/ai/src/providers/mock.ts @@ -2,7 +2,13 @@ import { mockProvider } from './mock-function-wrapper.js'; export type MockResponseDescriptor = | { type: 'text'; text: string } - | { type: 'tool-call'; toolName: string; input: string }; + | { type: 'tool-call'; toolName: string; input: string } + | { + type: 'provider-tool-call'; + toolName: string; + input: string; + result: unknown; + }; /** * Mock model that returns a fixed text response. @@ -64,51 +70,85 @@ export function mockSequenceModel(responses: MockResponseDescriptor[]) { _responses.length - 1 ); const r = _responses[idx]; - const parts = - r.type === 'text' - ? [ - { type: 'stream-start', warnings: [] }, - { - type: 'response-metadata', - id: 'r', - modelId: 'mock', - timestamp: new Date(), - }, - { type: 'text-start', id: '1' }, - { type: 'text-delta', id: '1', delta: r.text }, - { type: 'text-end', id: '1' }, - { - type: 'finish', - finishReason: { unified: 'stop', raw: 'stop' }, - usage: { - inputTokens: { total: 5, noCache: 5 }, - outputTokens: { total: 10, text: 10 }, - }, - }, - ] - : [ - { type: 'stream-start', warnings: [] }, - { - type: 'response-metadata', - id: 'r', - modelId: 'mock', - timestamp: new Date(), - }, - { - type: 'tool-call', - toolCallId: `call-${idx + 1}`, - toolName: r.toolName, - input: r.input, - }, - { - type: 'finish', - finishReason: { unified: 'tool-calls', raw: undefined }, - usage: { - inputTokens: { total: 5, noCache: 5 }, - outputTokens: { total: 10, text: 10 }, - }, - }, - ]; + let parts: any[]; + if (r.type === 'text') { + parts = [ + { type: 'stream-start', warnings: [] }, + { + type: 'response-metadata', + id: 'r', + modelId: 'mock', + timestamp: new Date(), + }, + { type: 'text-start', id: '1' }, + { type: 'text-delta', id: '1', delta: r.text }, + { type: 'text-end', id: '1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 5, noCache: 5 }, + outputTokens: { total: 10, text: 10 }, + }, + }, + ]; + } else if (r.type === 'provider-tool-call') { + const callId = `call-${idx + 1}`; + parts = [ + { type: 'stream-start', warnings: [] }, + { + type: 'response-metadata', + id: 'r', + modelId: 'mock', + timestamp: new Date(), + }, + { + type: 'tool-call', + toolCallId: callId, + toolName: r.toolName, + input: r.input, + providerExecuted: true, + }, + { + type: 'tool-result', + toolCallId: callId, + toolName: r.toolName, + result: r.result, + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: undefined }, + usage: { + inputTokens: { total: 5, noCache: 5 }, + outputTokens: { total: 10, text: 10 }, + }, + }, + ]; + } else { + parts = [ + { type: 'stream-start', warnings: [] }, + { + type: 'response-metadata', + id: 'r', + modelId: 'mock', + timestamp: new Date(), + }, + { + type: 'tool-call', + toolCallId: `call-${idx + 1}`, + toolName: r.toolName, + input: r.input, + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: undefined }, + usage: { + inputTokens: { total: 5, noCache: 5 }, + outputTokens: { total: 10, text: 10 }, + }, + }, + ]; + } return { stream: new ReadableStream({ start(c) { diff --git a/packages/core/e2e/e2e-agent.test.ts b/packages/core/e2e/e2e-agent.test.ts index 32d360b51c..5584d85916 100644 --- a/packages/core/e2e/e2e-agent.test.ts +++ b/packages/core/e2e/e2e-agent.test.ts @@ -95,6 +95,30 @@ describe.skipIf(isCanary)('DurableAgent e2e', { timeout: 120_000 }, () => { }); }); + // ========================================================================== + // Provider tool tests + // ========================================================================== + + describe('provider tools', () => { + it('provider tool identity preserved across step boundaries', async () => { + const run = await start(await agentE2e('agentProviderToolE2e'), []); + const rv = await run.returnValue; + expect(rv).toMatchObject({ + stepCount: 2, + lastStepText: 'I found a result for you.', + }); + }); + + it('mixed provider and function tools', async () => { + const run = await start(await agentE2e('agentMixedToolsE2e'), [3, 7]); + const rv = await run.returnValue; + expect(rv).toMatchObject({ + stepCount: 3, + lastStepText: 'The answer is 10', + }); + }); + }); + // ========================================================================== // onStepFinish callback tests // ========================================================================== diff --git a/workbench/example/workflows/100_durable_agent_e2e.ts b/workbench/example/workflows/100_durable_agent_e2e.ts index d0e32ff275..8ac66328d9 100644 --- a/workbench/example/workflows/100_durable_agent_e2e.ts +++ b/workbench/example/workflows/100_durable_agent_e2e.ts @@ -140,6 +140,89 @@ export async function agentErrorToolE2e() { }; } +// ============================================================================ +// Provider tool tests — tool identity preserved across step boundaries +// ============================================================================ + +/** + * Tests that provider tools (e.g. anthropic.tools.webSearch) are correctly + * passed through to the model without being converted to function tools. + * The mock model simulates a provider-executed tool call + result. + */ +export async function agentProviderToolE2e() { + 'use workflow'; + const agent = new DurableAgent({ + model: mockSequenceModel([ + { + type: 'provider-tool-call', + toolName: 'webSearch', + input: JSON.stringify({ query: 'workflow sdk' }), + result: { title: 'Workflow SDK', url: 'https://example.com' }, + }, + { type: 'text', text: 'I found a result for you.' }, + ]), + tools: { + webSearch: { + type: 'provider', + id: 'anthropic.web_search', + args: { maxUses: 5 }, + } as any, + }, + }); + const result = await agent.stream({ + messages: [{ role: 'user', content: 'Search for workflow sdk' }], + writable: getWritable(), + }); + return { + stepCount: result.steps.length, + lastStepText: result.steps[result.steps.length - 1]?.text, + }; +} + +/** + * Tests mixing provider tools with regular function tools. + * The mock model first calls a provider tool, then a regular tool. + */ +export async function agentMixedToolsE2e(a: number, b: number) { + 'use workflow'; + const agent = new DurableAgent({ + model: mockSequenceModel([ + { + type: 'provider-tool-call', + toolName: 'webSearch', + input: JSON.stringify({ query: 'what is a + b' }), + result: { answer: `${a} + ${b}` }, + }, + { + type: 'tool-call', + toolName: 'addNumbers', + input: JSON.stringify({ a, b }), + }, + { type: 'text', text: `The answer is ${a + b}` }, + ]), + tools: { + webSearch: { + type: 'provider', + id: 'anthropic.web_search', + args: {}, + } as any, + addNumbers: { + description: 'Add two numbers', + inputSchema: z.object({ a: z.number(), b: z.number() }), + execute: addNumbers, + }, + }, + }); + const result = await agent.stream({ + messages: [{ role: 'user', content: `Search and add ${a} + ${b}` }], + writable: getWritable(), + }); + return { + stepCount: result.steps.length, + lastStepText: result.steps[result.steps.length - 1]?.text, + }; +} + // ============================================================================ // Callback tests — onStepFinish // ============================================================================ From 9bc8db3ca918474a03fc5382f2c67f8f3fef01b4 Mon Sep 17 00:00:00 2001 From: Gregor Martynus <39992+gr2m@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:00:25 -0700 Subject: [PATCH 2/2] DCO Remediation Commit for Gregor Martynus <39992+gr2m@users.noreply.github.com> I, Gregor Martynus <39992+gr2m@users.noreply.github.com>, hereby add my Signed-off-by to this commit: b1930b307def795f014d186c16290ecb80c26105 Signed-off-by: Gregor Martynus <39992+gr2m@users.noreply.github.com>