Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-provider-tool-identity.md
Original file line number Diff line number Diff line change
@@ -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.
106 changes: 106 additions & 0 deletions packages/ai/src/agent/tools-to-model-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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: {},
});
});
});
32 changes: 24 additions & 8 deletions packages/ai/src/agent/tools-to-model-tools.ts
Original file line number Diff line number Diff line change
@@ -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<LanguageModelV3FunctionTool[]> {
): Promise<Array<LanguageModelV3FunctionTool | LanguageModelV3ProviderTool>> {
return Promise.all(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: (tool as any).type === 'provider' is the same pattern used in the upstream AI SDK fix (vercel/ai#14229). If ai later exports a type guard or the ToolSet type is widened to include provider tools natively, this could be made type-safe. Fine for now.

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,
};
})
);
}
132 changes: 86 additions & 46 deletions packages/ai/src/providers/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
24 changes: 24 additions & 0 deletions packages/core/e2e/e2e-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ==========================================================================
Expand Down
Loading
Loading