Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions .changeset/fix-tools-to-model-tools.md
Original file line number Diff line number Diff line change
@@ -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
186 changes: 186 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,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([]);
});
});
57 changes: 47 additions & 10 deletions packages/ai/src/agent/tools-to-model-tools.ts
Original file line number Diff line number Diff line change
@@ -1,15 +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/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<LanguageModelV3FunctionTool[]> {
return Promise.all(
Object.entries(tools).map(async ([name, tool]) => ({
type: 'function' as const,
name,
description: tool.description,
inputSchema: await asSchema(tool.inputSchema).jsonSchema,
}))
);
): Promise<Array<LanguageModelV3FunctionTool | LanguageModelV3ProviderTool>> {
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;
}