From 2e3ff4a15a53afcd68cc3b76f9710ba860af9e32 Mon Sep 17 00:00:00 2001 From: Nico Bailon Date: Sun, 7 Dec 2025 03:07:15 -0800 Subject: [PATCH 1/3] Fix truncation test assertions to match new message format (#136) --- packages/coding-agent/test/tools.test.ts | 50 ++++++++++++------------ 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 8331315b..9eeebd1e 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -43,7 +43,8 @@ describe("Coding Agent Tools", () => { const result = await readTool.execute("test-call-1", { path: testFile }); expect(getTextOutput(result)).toBe(content); - expect(getTextOutput(result)).not.toContain("more lines not shown"); + // No truncation message since file fits within limits + expect(getTextOutput(result)).not.toContain("Use offset="); expect(result.details).toBeUndefined(); }); @@ -64,23 +65,21 @@ describe("Coding Agent Tools", () => { expect(output).toContain("Line 1"); expect(output).toContain("Line 2000"); expect(output).not.toContain("Line 2001"); - expect(output).toContain("500 more lines not shown"); - expect(output).toContain("Use offset=2001 to continue reading"); + expect(output).toContain("[Showing lines 1-2000 of 2500. Use offset=2001 to continue]"); }); - it("should truncate long lines and show notice", async () => { - const testFile = join(testDir, "long-lines.txt"); - const longLine = "a".repeat(3000); - const content = `Short line\n${longLine}\nAnother short line`; - writeFileSync(testFile, content); + it("should truncate when byte limit exceeded", async () => { + const testFile = join(testDir, "large-bytes.txt"); + // Create file that exceeds 50KB byte limit but has fewer than 2000 lines + const lines = Array.from({ length: 500 }, (_, i) => `Line ${i + 1}: ${"x".repeat(200)}`); + writeFileSync(testFile, lines.join("\n")); const result = await readTool.execute("test-call-4", { path: testFile }); const output = getTextOutput(result); - expect(output).toContain("Short line"); - expect(output).toContain("Another short line"); - expect(output).toContain("Some lines were truncated to 2000 characters"); - expect(output.split("\n")[1].length).toBe(2000); + expect(output).toContain("Line 1:"); + // Should show byte limit message + expect(output).toMatch(/\[Showing lines 1-\d+ of 500 \(.* limit\)\. Use offset=\d+ to continue\]/); }); it("should handle offset parameter", async () => { @@ -94,7 +93,8 @@ describe("Coding Agent Tools", () => { expect(output).not.toContain("Line 50"); expect(output).toContain("Line 51"); expect(output).toContain("Line 100"); - expect(output).not.toContain("more lines not shown"); + // No truncation message since file fits within limits + expect(output).not.toContain("Use offset="); }); it("should handle limit parameter", async () => { @@ -108,8 +108,7 @@ describe("Coding Agent Tools", () => { expect(output).toContain("Line 1"); expect(output).toContain("Line 10"); expect(output).not.toContain("Line 11"); - expect(output).toContain("90 more lines not shown"); - expect(output).toContain("Use offset=11 to continue reading"); + expect(output).toContain("[90 more lines in file. Use offset=11 to continue]"); }); it("should handle offset + limit together", async () => { @@ -128,8 +127,7 @@ describe("Coding Agent Tools", () => { expect(output).toContain("Line 41"); expect(output).toContain("Line 60"); expect(output).not.toContain("Line 61"); - expect(output).toContain("40 more lines not shown"); - expect(output).toContain("Use offset=61 to continue reading"); + expect(output).toContain("[40 more lines in file. Use offset=61 to continue]"); }); it("should show error when offset is beyond file length", async () => { @@ -141,17 +139,19 @@ describe("Coding Agent Tools", () => { ); }); - it("should show both truncation notices when applicable", async () => { - const testFile = join(testDir, "both-truncations.txt"); - const longLine = "b".repeat(3000); - const lines = Array.from({ length: 2500 }, (_, i) => (i === 500 ? longLine : `Line ${i + 1}`)); + it("should include truncation details when truncated", async () => { + const testFile = join(testDir, "large-file.txt"); + const lines = Array.from({ length: 2500 }, (_, i) => `Line ${i + 1}`); writeFileSync(testFile, lines.join("\n")); const result = await readTool.execute("test-call-9", { path: testFile }); - const output = getTextOutput(result); - expect(output).toContain("Some lines were truncated to 2000 characters"); - expect(output).toContain("500 more lines not shown"); + expect(result.details).toBeDefined(); + expect(result.details?.truncation).toBeDefined(); + expect(result.details?.truncation?.truncated).toBe(true); + expect(result.details?.truncation?.truncatedBy).toBe("lines"); + expect(result.details?.truncation?.totalLines).toBe(2500); + expect(result.details?.truncation?.outputLines).toBe(2000); }); }); @@ -276,7 +276,7 @@ describe("Coding Agent Tools", () => { expect(output).toContain("context.txt-1- before"); expect(output).toContain("context.txt:2: match one"); expect(output).toContain("context.txt-3- after"); - expect(output).toContain("(truncated, limit of 1 matches reached)"); + expect(output).toContain("[1 matches limit reached. Use limit=2 for more, or refine pattern]"); // Ensure second match is not present expect(output).not.toContain("match two"); }); From 01963082664876bdc4e2f405a46453724ec2406a Mon Sep 17 00:00:00 2001 From: Markus Ylisiurunen Date: Sun, 7 Dec 2025 17:24:06 +0200 Subject: [PATCH 2/3] add option to skip provider tool call validation --- packages/agent/src/agent.ts | 1 + packages/agent/src/transports/AppTransport.ts | 2 ++ packages/agent/src/transports/ProviderTransport.ts | 1 + packages/agent/src/transports/types.ts | 1 + packages/ai/CHANGELOG.md | 4 ++++ packages/ai/README.md | 14 ++++++++++++++ packages/ai/src/providers/anthropic.ts | 3 ++- packages/ai/src/providers/google.ts | 3 ++- packages/ai/src/providers/openai-completions.ts | 3 ++- packages/ai/src/providers/openai-responses.ts | 3 ++- packages/ai/src/stream.ts | 1 + packages/ai/src/types.ts | 6 ++++++ 12 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 9897984d..df39e925 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -221,6 +221,7 @@ export class Agent { tools: this._state.tools, model, reasoning, + validateToolCallsAtProvider: false, getQueuedMessages: async () => { // Return queued messages based on queue mode if (this.queueMode === "one-at-a-time") { diff --git a/packages/agent/src/transports/AppTransport.ts b/packages/agent/src/transports/AppTransport.ts index 5beb9dc6..0404f37a 100644 --- a/packages/agent/src/transports/AppTransport.ts +++ b/packages/agent/src/transports/AppTransport.ts @@ -77,6 +77,7 @@ function streamSimpleProxy( temperature: options.temperature, maxTokens: options.maxTokens, reasoning: options.reasoning, + validateToolCallsAtProvider: options.validateToolCallsAtProvider, // Don't send apiKey or signal - those are added server-side }, }), @@ -365,6 +366,7 @@ export class AppTransport implements AgentTransport { model: cfg.model, reasoning: cfg.reasoning, getQueuedMessages: cfg.getQueuedMessages, + validateToolCallsAtProvider: cfg.validateToolCallsAtProvider ?? false, }; // Yield events from the upstream agentLoop iterator diff --git a/packages/agent/src/transports/ProviderTransport.ts b/packages/agent/src/transports/ProviderTransport.ts index c46b16c0..1435b160 100644 --- a/packages/agent/src/transports/ProviderTransport.ts +++ b/packages/agent/src/transports/ProviderTransport.ts @@ -65,6 +65,7 @@ export class ProviderTransport implements AgentTransport { reasoning: cfg.reasoning, apiKey, getQueuedMessages: cfg.getQueuedMessages, + validateToolCallsAtProvider: cfg.validateToolCallsAtProvider ?? false, }; // Yield events from agentLoop diff --git a/packages/agent/src/transports/types.ts b/packages/agent/src/transports/types.ts index d5d4053c..43982a69 100644 --- a/packages/agent/src/transports/types.ts +++ b/packages/agent/src/transports/types.ts @@ -9,6 +9,7 @@ export interface AgentRunConfig { model: Model; reasoning?: "low" | "medium" | "high"; getQueuedMessages?: () => Promise[]>; + validateToolCallsAtProvider?: boolean; } /** diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index eda79305..9cd5b522 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `validateToolCallsAtProvider` option to streaming and agent APIs to optionally skip provider-level tool-call validation (default on), allowing agent loops to surface schema errors as toolResult messages and retry. + ## [0.13.0] - 2025-12-06 ### Breaking Changes diff --git a/packages/ai/README.md b/packages/ai/README.md index c633fa76..acd4b78e 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -270,6 +270,20 @@ for await (const event of s) { - Full validation only occurs at `toolcall_end` when arguments are complete - The Google provider does not support function call streaming. Instead, you will receive a single `toolcall_delta` event with the full arguments. +### Provider tool-call validation + +By default, providers validate streamed tool calls against your tool schema and abort the stream on validation errors. Set `validateToolCallsAtProvider: false` on `stream`, `streamSimple`, `complete`, `completeSimple`, or `AgentLoopConfig` to skip provider-level validation and let downstream code (for example, `agentLoop` via `executeToolCalls` → `validateToolArguments`) surface schema errors as `toolResult` messages. This enables the model to retry after receiving a validation error. + +```typescript +await streamSimple(model, context, { + apiKey: 'your-key', + validateToolCallsAtProvider: false +}); +``` + +- `true` (default): Provider validates tool calls and emits an error if arguments do not match the schema +- `false`: Provider emits tool calls even when arguments are invalid; callers must validate and handle errors themselves + ### Complete Event Reference All streaming events emitted during assistant message generation: diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index e2e91be2..d3421553 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -92,6 +92,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( options?: AnthropicOptions, ): AssistantMessageEventStream => { const stream = new AssistantMessageEventStream(); + const shouldValidateToolCalls = options?.validateToolCallsAtProvider !== false; (async () => { const output: AssistantMessage = { @@ -233,7 +234,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( block.arguments = parseStreamingJson(block.partialJson); // Validate tool arguments if tool definition is available - if (context.tools) { + if (shouldValidateToolCalls && context.tools) { const tool = context.tools.find((t) => t.name === block.name); if (tool) { block.arguments = validateToolArguments(tool, block); diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index 9d3ade4f..e571aeb0 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -43,6 +43,7 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = ( options?: GoogleOptions, ): AssistantMessageEventStream => { const stream = new AssistantMessageEventStream(); + const shouldValidateToolCalls = options?.validateToolCallsAtProvider !== false; (async () => { const output: AssistantMessage = { @@ -167,7 +168,7 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = ( }; // Validate tool arguments if tool definition is available - if (context.tools) { + if (shouldValidateToolCalls && context.tools) { const tool = context.tools.find((t) => t.name === toolCall.name); if (tool) { toolCall.arguments = validateToolArguments(tool, toolCall); diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index a3c0a17e..dafacd9e 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -37,6 +37,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( options?: OpenAICompletionsOptions, ): AssistantMessageEventStream => { const stream = new AssistantMessageEventStream(); + const shouldValidateToolCalls = options?.validateToolCallsAtProvider !== false; (async () => { const output: AssistantMessage = { @@ -86,7 +87,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( block.arguments = JSON.parse(block.partialArgs || "{}"); // Validate tool arguments if tool definition is available - if (context.tools) { + if (shouldValidateToolCalls && context.tools) { const tool = context.tools.find((t) => t.name === block.name); if (tool) { block.arguments = validateToolArguments(tool, block); diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 76a582be..9b2c4fb6 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -45,6 +45,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( options?: OpenAIResponsesOptions, ): AssistantMessageEventStream => { const stream = new AssistantMessageEventStream(); + const shouldValidateToolCalls = options?.validateToolCallsAtProvider !== false; // Start async processing (async () => { @@ -240,7 +241,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( }; // Validate tool arguments if tool definition is available - if (context.tools) { + if (shouldValidateToolCalls && context.tools) { const tool = context.tools.find((t) => t.name === toolCall.name); if (tool) { toolCall.arguments = validateToolArguments(tool, toolCall); diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 94fb21b1..9c5b20d7 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -120,6 +120,7 @@ function mapOptionsForApi( maxTokens: options?.maxTokens || Math.min(model.maxTokens, 32000), signal: options?.signal, apiKey: apiKey || options?.apiKey, + validateToolCallsAtProvider: options?.validateToolCallsAtProvider ?? true, }; switch (model.api) { diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index a7269bc8..b3b8a885 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -37,6 +37,12 @@ export interface StreamOptions { maxTokens?: number; signal?: AbortSignal; apiKey?: string; + /** + * Controls whether providers validate streamed tool calls against the tool schema. + * Defaults to true. Set to false to skip provider-level validation and allow + * downstream consumers (e.g., agentLoop) to handle validation failures. + */ + validateToolCallsAtProvider?: boolean; } // Unified options with reasoning passed to streamSimple() and completeSimple() From 8bec289dc6caa4ecae2d4cd3a86e222755634aa4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 8 Dec 2025 18:04:33 +0100 Subject: [PATCH 3/3] Remove provider-level tool validation, add validateToolCall helper --- packages/agent/src/agent.ts | 1 - packages/agent/src/transports/AppTransport.ts | 2 - .../agent/src/transports/ProviderTransport.ts | 1 - packages/agent/src/transports/types.ts | 1 - packages/ai/CHANGELOG.md | 6 ++- packages/ai/README.md | 47 ++++++++++++++----- packages/ai/src/index.ts | 1 + packages/ai/src/providers/anthropic.ts | 12 +---- packages/ai/src/providers/google.ts | 11 +---- .../ai/src/providers/openai-completions.ts | 12 +---- packages/ai/src/providers/openai-responses.ts | 11 +---- packages/ai/src/stream.ts | 1 - packages/ai/src/types.ts | 6 --- packages/ai/src/utils/validation.ts | 15 ++++++ 14 files changed, 59 insertions(+), 68 deletions(-) diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index df39e925..9897984d 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -221,7 +221,6 @@ export class Agent { tools: this._state.tools, model, reasoning, - validateToolCallsAtProvider: false, getQueuedMessages: async () => { // Return queued messages based on queue mode if (this.queueMode === "one-at-a-time") { diff --git a/packages/agent/src/transports/AppTransport.ts b/packages/agent/src/transports/AppTransport.ts index 0404f37a..5beb9dc6 100644 --- a/packages/agent/src/transports/AppTransport.ts +++ b/packages/agent/src/transports/AppTransport.ts @@ -77,7 +77,6 @@ function streamSimpleProxy( temperature: options.temperature, maxTokens: options.maxTokens, reasoning: options.reasoning, - validateToolCallsAtProvider: options.validateToolCallsAtProvider, // Don't send apiKey or signal - those are added server-side }, }), @@ -366,7 +365,6 @@ export class AppTransport implements AgentTransport { model: cfg.model, reasoning: cfg.reasoning, getQueuedMessages: cfg.getQueuedMessages, - validateToolCallsAtProvider: cfg.validateToolCallsAtProvider ?? false, }; // Yield events from the upstream agentLoop iterator diff --git a/packages/agent/src/transports/ProviderTransport.ts b/packages/agent/src/transports/ProviderTransport.ts index 1435b160..c46b16c0 100644 --- a/packages/agent/src/transports/ProviderTransport.ts +++ b/packages/agent/src/transports/ProviderTransport.ts @@ -65,7 +65,6 @@ export class ProviderTransport implements AgentTransport { reasoning: cfg.reasoning, apiKey, getQueuedMessages: cfg.getQueuedMessages, - validateToolCallsAtProvider: cfg.validateToolCallsAtProvider ?? false, }; // Yield events from agentLoop diff --git a/packages/agent/src/transports/types.ts b/packages/agent/src/transports/types.ts index 43982a69..d5d4053c 100644 --- a/packages/agent/src/transports/types.ts +++ b/packages/agent/src/transports/types.ts @@ -9,7 +9,6 @@ export interface AgentRunConfig { model: Model; reasoning?: "low" | "medium" | "high"; getQueuedMessages?: () => Promise[]>; - validateToolCallsAtProvider?: boolean; } /** diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 9cd5b522..a333e15b 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,9 +2,13 @@ ## [Unreleased] +### Breaking Changes + +- Removed provider-level tool argument validation. Validation now happens in `agentLoop` via `executeToolCalls`, allowing models to retry on validation errors. For manual tool execution, use `validateToolCall(tools, toolCall)` or `validateToolArguments(tool, toolCall)`. + ### Added -- Added `validateToolCallsAtProvider` option to streaming and agent APIs to optionally skip provider-level tool-call validation (default on), allowing agent loops to surface schema errors as toolResult messages and retry. +- Added `validateToolCall(tools, toolCall)` helper that finds the tool by name and validates arguments. ## [0.13.0] - 2025-12-06 diff --git a/packages/ai/README.md b/packages/ai/README.md index acd4b78e..52d69605 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -194,8 +194,8 @@ const response = await complete(model, context); // Check for tool calls in the response for (const block of response.content) { if (block.type === 'toolCall') { - // Arguments are automatically validated against the TypeBox schema using AJV - // If validation fails, an error event is emitted + // Execute your tool with the arguments + // See "Validating Tool Arguments" section for validation const result = await executeWeatherApi(block.arguments); // Add tool result with text content @@ -253,7 +253,7 @@ for await (const event of s) { } if (event.type === 'toolcall_end') { - // Here toolCall.arguments is complete and validated + // Here toolCall.arguments is complete (but not yet validated) const toolCall = event.toolCall; console.log(`Tool completed: ${toolCall.name}`, toolCall.arguments); } @@ -267,22 +267,43 @@ for await (const event of s) { - Arrays may be incomplete - Nested objects may be partially populated - At minimum, `arguments` will be an empty object `{}`, never `undefined` -- Full validation only occurs at `toolcall_end` when arguments are complete - The Google provider does not support function call streaming. Instead, you will receive a single `toolcall_delta` event with the full arguments. -### Provider tool-call validation +### Validating Tool Arguments -By default, providers validate streamed tool calls against your tool schema and abort the stream on validation errors. Set `validateToolCallsAtProvider: false` on `stream`, `streamSimple`, `complete`, `completeSimple`, or `AgentLoopConfig` to skip provider-level validation and let downstream code (for example, `agentLoop` via `executeToolCalls` → `validateToolArguments`) surface schema errors as `toolResult` messages. This enables the model to retry after receiving a validation error. +When using `agentLoop`, tool arguments are automatically validated against your TypeBox schemas before execution. If validation fails, the error is returned to the model as a tool result, allowing it to retry. + +When implementing your own tool execution loop with `stream()` or `complete()`, use `validateToolCall` to validate arguments before passing them to your tools: ```typescript -await streamSimple(model, context, { - apiKey: 'your-key', - validateToolCallsAtProvider: false -}); -``` +import { stream, validateToolCall, Tool } from '@mariozechner/pi-ai'; -- `true` (default): Provider validates tool calls and emits an error if arguments do not match the schema -- `false`: Provider emits tool calls even when arguments are invalid; callers must validate and handle errors themselves +const tools: Tool[] = [weatherTool, calculatorTool]; +const s = stream(model, { messages, tools }); + +for await (const event of s) { + if (event.type === 'toolcall_end') { + const toolCall = event.toolCall; + + try { + // Validate arguments against the tool's schema (throws on invalid args) + const validatedArgs = validateToolCall(tools, toolCall); + const result = await executeMyTool(toolCall.name, validatedArgs); + // ... add tool result to context + } catch (error) { + // Validation failed - return error as tool result so model can retry + context.messages.push({ + role: 'toolResult', + toolCallId: toolCall.id, + toolName: toolCall.name, + content: [{ type: 'text', text: error.message }], + isError: true, + timestamp: Date.now() + }); + } + } +} +``` ### Complete Event Reference diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 874686b7..f9ed0753 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -8,3 +8,4 @@ export * from "./stream.js"; export * from "./types.js"; export * from "./utils/overflow.js"; export * from "./utils/typebox-helpers.js"; +export * from "./utils/validation.js"; diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index d3421553..ff6e60e2 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -25,7 +25,7 @@ import type { import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { parseStreamingJson } from "../utils/json-parse.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; -import { validateToolArguments } from "../utils/validation.js"; + import { transformMessages } from "./transorm-messages.js"; /** @@ -92,7 +92,6 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( options?: AnthropicOptions, ): AssistantMessageEventStream => { const stream = new AssistantMessageEventStream(); - const shouldValidateToolCalls = options?.validateToolCallsAtProvider !== false; (async () => { const output: AssistantMessage = { @@ -232,15 +231,6 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( }); } else if (block.type === "toolCall") { block.arguments = parseStreamingJson(block.partialJson); - - // Validate tool arguments if tool definition is available - if (shouldValidateToolCalls && context.tools) { - const tool = context.tools.find((t) => t.name === block.name); - if (tool) { - block.arguments = validateToolArguments(tool, block); - } - } - delete (block as any).partialJson; stream.push({ type: "toolcall_end", diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index e571aeb0..5b5a0356 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -23,7 +23,7 @@ import type { } from "../types.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; -import { validateToolArguments } from "../utils/validation.js"; + import { transformMessages } from "./transorm-messages.js"; export interface GoogleOptions extends StreamOptions { @@ -43,7 +43,6 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = ( options?: GoogleOptions, ): AssistantMessageEventStream => { const stream = new AssistantMessageEventStream(); - const shouldValidateToolCalls = options?.validateToolCallsAtProvider !== false; (async () => { const output: AssistantMessage = { @@ -167,14 +166,6 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = ( ...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }), }; - // Validate tool arguments if tool definition is available - if (shouldValidateToolCalls && context.tools) { - const tool = context.tools.find((t) => t.name === toolCall.name); - if (tool) { - toolCall.arguments = validateToolArguments(tool, toolCall); - } - } - output.content.push(toolCall); stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output }); stream.push({ diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index dafacd9e..ca9f1c30 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -23,7 +23,7 @@ import type { import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { parseStreamingJson } from "../utils/json-parse.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; -import { validateToolArguments } from "../utils/validation.js"; + import { transformMessages } from "./transorm-messages.js"; export interface OpenAICompletionsOptions extends StreamOptions { @@ -37,7 +37,6 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( options?: OpenAICompletionsOptions, ): AssistantMessageEventStream => { const stream = new AssistantMessageEventStream(); - const shouldValidateToolCalls = options?.validateToolCallsAtProvider !== false; (async () => { const output: AssistantMessage = { @@ -85,15 +84,6 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( }); } else if (block.type === "toolCall") { block.arguments = JSON.parse(block.partialArgs || "{}"); - - // Validate tool arguments if tool definition is available - if (shouldValidateToolCalls && context.tools) { - const tool = context.tools.find((t) => t.name === block.name); - if (tool) { - block.arguments = validateToolArguments(tool, block); - } - } - delete block.partialArgs; stream.push({ type: "toolcall_end", diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 9b2c4fb6..c36e5254 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -27,7 +27,7 @@ import type { import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { parseStreamingJson } from "../utils/json-parse.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; -import { validateToolArguments } from "../utils/validation.js"; + import { transformMessages } from "./transorm-messages.js"; // OpenAI Responses-specific options @@ -45,7 +45,6 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( options?: OpenAIResponsesOptions, ): AssistantMessageEventStream => { const stream = new AssistantMessageEventStream(); - const shouldValidateToolCalls = options?.validateToolCallsAtProvider !== false; // Start async processing (async () => { @@ -240,14 +239,6 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( arguments: JSON.parse(item.arguments), }; - // Validate tool arguments if tool definition is available - if (shouldValidateToolCalls && context.tools) { - const tool = context.tools.find((t) => t.name === toolCall.name); - if (tool) { - toolCall.arguments = validateToolArguments(tool, toolCall); - } - } - stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output }); } } diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 9c5b20d7..94fb21b1 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -120,7 +120,6 @@ function mapOptionsForApi( maxTokens: options?.maxTokens || Math.min(model.maxTokens, 32000), signal: options?.signal, apiKey: apiKey || options?.apiKey, - validateToolCallsAtProvider: options?.validateToolCallsAtProvider ?? true, }; switch (model.api) { diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index b3b8a885..a7269bc8 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -37,12 +37,6 @@ export interface StreamOptions { maxTokens?: number; signal?: AbortSignal; apiKey?: string; - /** - * Controls whether providers validate streamed tool calls against the tool schema. - * Defaults to true. Set to false to skip provider-level validation and allow - * downstream consumers (e.g., agentLoop) to handle validation failures. - */ - validateToolCallsAtProvider?: boolean; } // Unified options with reasoning passed to streamSimple() and completeSimple() diff --git a/packages/ai/src/utils/validation.ts b/packages/ai/src/utils/validation.ts index 08335807..4c778880 100644 --- a/packages/ai/src/utils/validation.ts +++ b/packages/ai/src/utils/validation.ts @@ -27,6 +27,21 @@ if (!isBrowserExtension) { } } +/** + * Finds a tool by name and validates the tool call arguments against its TypeBox schema + * @param tools Array of tool definitions + * @param toolCall The tool call from the LLM + * @returns The validated arguments + * @throws Error if tool is not found or validation fails + */ +export function validateToolCall(tools: Tool[], toolCall: ToolCall): any { + const tool = tools.find((t) => t.name === toolCall.name); + if (!tool) { + throw new Error(`Tool "${toolCall.name}" not found`); + } + return validateToolArguments(tool, toolCall); +} + /** * Validates tool call arguments against the tool's TypeBox schema * @param tool The tool definition with TypeBox schema