From e8f23c8f5798464432bece71945968d67b3596d9 Mon Sep 17 00:00:00 2001 From: tsushanth <78000697+tsushanth@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:35:20 -0700 Subject: [PATCH] fix(ai): respect failureMode when tool-call parameter decoding fails Tool-call parameter decode errors were thrown before the handler queue existed, bypassing the failureMode gate entirely. Both the streaming and generateText paths now use a relaxed schema (Schema.Unknown) for the initial LLM-output decode pass, deferring strict validation into Toolkit.handle where failureMode is available. When decoding fails inside handle, failureMode:"return" now emits a tool-result part with isFailure:true and the encoded AiError instead of propagating the error as an effect failure. failureMode:"error" (and the default) continues to fail the effect as before. Updated two tests that were asserting the old broken behavior. Fixes #6335 --- .../effect/src/unstable/ai/LanguageModel.ts | 4 +- packages/effect/src/unstable/ai/Response.ts | 10 +- packages/effect/src/unstable/ai/Toolkit.ts | 23 ++++- packages/effect/test/unstable/ai/Tool.test.ts | 92 +++++++++++++------ 4 files changed, 93 insertions(+), 36 deletions(-) diff --git a/packages/effect/src/unstable/ai/LanguageModel.ts b/packages/effect/src/unstable/ai/LanguageModel.ts index 8e6a0b2efd7..820d900332d 100644 --- a/packages/effect/src/unstable/ai/LanguageModel.ts +++ b/packages/effect/src/unstable/ai/LanguageModel.ts @@ -1165,7 +1165,7 @@ export const make: (params: { // Construct the response schema with the tools from the toolkit const ResponseSchema = Schema.mutable( - Schema.Array(Response.Part(toolkit)) + Schema.Array(Response.Part(toolkit, { relaxParams: true })) ) // If tool call resolution is disabled, return the response without @@ -1465,7 +1465,7 @@ export const make: (params: { > } - const ResponseSchema = Schema.NonEmptyArray(Response.StreamPart(toolkit)) + const ResponseSchema = Schema.NonEmptyArray(Response.StreamPart(toolkit, { relaxParams: true })) const decodeParts = Schema.decodeEffect(ResponseSchema) // Queue for decoded parts and tool results diff --git a/packages/effect/src/unstable/ai/Response.ts b/packages/effect/src/unstable/ai/Response.ts index c14811ffa87..c26156e63d9 100644 --- a/packages/effect/src/unstable/ai/Response.ts +++ b/packages/effect/src/unstable/ai/Response.ts @@ -262,7 +262,8 @@ export type PartEncoded = * @since 4.0.0 */ export const Part = >( - toolkit: T + toolkit: T, + options?: { readonly relaxParams?: boolean } ): Schema.Codec< Part : Toolkit.WithHandlerTools>, PartEncoded, @@ -272,7 +273,7 @@ export const Part = >( const toolCalls: Array = [] const toolResults: Array = [] for (const tool of Object.values(toolkit.tools as Record)) { - const toolCall = ToolCallPart(tool.name, tool.parametersSchema) + const toolCall = ToolCallPart(tool.name, options?.relaxParams === true ? Schema.Unknown : tool.parametersSchema) const toolResult = ToolResultPart(tool.name, tool.successSchema, tool.failureSchema) toolCalls.push(toolCall) toolResults.push(toolResult) @@ -354,7 +355,8 @@ export type StreamPartEncoded = * @since 4.0.0 */ export const StreamPart = >( - toolkit: T + toolkit: T, + options?: { readonly relaxParams?: boolean } ): Schema.Codec< StreamPart : Toolkit.WithHandlerTools>, StreamPartEncoded, @@ -364,7 +366,7 @@ export const StreamPart = >( const toolCalls: Array = [] const toolResults: Array = [] for (const tool of Object.values(toolkit.tools as Record)) { - const toolCall = ToolCallPart(tool.name, tool.parametersSchema) + const toolCall = ToolCallPart(tool.name, options?.relaxParams === true ? Schema.Unknown : tool.parametersSchema) const toolResult = ToolResultPart(tool.name, tool.successSchema, tool.failureSchema) toolCalls.push(toolCall) toolResults.push(toolResult) diff --git a/packages/effect/src/unstable/ai/Toolkit.ts b/packages/effect/src/unstable/ai/Toolkit.ts index af3be989f98..c2f676cba23 100644 --- a/packages/effect/src/unstable/ai/Toolkit.ts +++ b/packages/effect/src/unstable/ai/Toolkit.ts @@ -280,7 +280,8 @@ const Proto = { // Fetch cached schemas / handlers for the tool const schemas = getSchemas(tool) - // Decode the tool call parameters which will be passed to the handler + // Decode the tool call parameters which will be passed to the handler. + // When decoding fails, respect the tool's failureMode before propagating. const decodedParams = yield* schemas.decodeParameters(params).pipe( Effect.mapError((cause) => AiError.make({ @@ -292,8 +293,26 @@ const Proto = { description: cause.message }) }) - ) + ), + Effect.matchEffect({ + onFailure: (error) => + tool.failureMode === "error" + ? Effect.fail(error) + : schemas.encodeResult(error).pipe( + Effect.map((encodedResult) => + Stream.succeed({ result: error, encodedResult, isFailure: true, preliminary: false }) as Stream.Stream< + { readonly result: any; readonly encodedResult: any; readonly isFailure: boolean; readonly preliminary: boolean }, + never + > + ), + Effect.orDie + ), + onSuccess: Effect.succeed + }) ) + if (Stream.isStream(decodedParams)) { + return decodedParams + } // Setup the handler context const queue = yield* Queue.make<{ diff --git a/packages/effect/test/unstable/ai/Tool.test.ts b/packages/effect/test/unstable/ai/Tool.test.ts index 974cbcc293f..aa113b59604 100644 --- a/packages/effect/test/unstable/ai/Tool.test.ts +++ b/packages/effect/test/unstable/ai/Tool.test.ts @@ -158,7 +158,7 @@ describe("Tool", () => { deepStrictEqual(response, toolResult) })) - it.effect("should raise an error when tool call parameters are invalid", () => + it.effect("should return a tool-result with isFailure when tool call parameters are invalid", () => Effect.gen(function*() { const toolkit = Toolkit.make(FailureModeReturn) @@ -182,22 +182,40 @@ describe("Tool", () => { params: {} }] }), - Effect.provide(handlers), - Effect.flip + Effect.provide(handlers) ) - deepStrictEqual( - response, - AiError.make({ - module: "Toolkit", - method: "FailureModeReturn.handle", - reason: new AiError.ToolParameterValidationError({ - toolName: "FailureModeReturn", - toolParams: {}, - description: `Missing key\n at ["testParam"]` - }) + const validationError = AiError.make({ + module: "Toolkit", + method: "FailureModeReturn.handle", + reason: new AiError.ToolParameterValidationError({ + toolName: "FailureModeReturn", + toolParams: {}, + description: `Missing key\n at ["testParam"]` }) - ) + }) + + deepStrictEqual(response.toolResults, [ + Response.toolResultPart({ + id: toolCallId, + name: toolName, + isFailure: true, + providerExecuted: false, + preliminary: false, + result: validationError, + encodedResult: { + _tag: "AiError", + module: "Toolkit", + method: "FailureModeReturn.handle", + reason: { + _tag: "ToolParameterValidationError", + toolName: "FailureModeReturn", + toolParams: {}, + description: `Missing key\n at ["testParam"]` + } + } + }) + ]) })) it.effect("should return AiError when user returns an AiErrorReason when failure mode is return", () => @@ -870,7 +888,7 @@ describe("Tool", () => { deepStrictEqual(response, toolResult) })) - it.effect("should raise an error when tool call parameters are invalid", () => + it.effect("should return a tool-result with isFailure when tool call parameters are invalid", () => Effect.gen(function*() { const tool = HandlerRequired({ failureMode: "return", @@ -902,22 +920,40 @@ describe("Tool", () => { } ] }), - Effect.provide(handlers), - Effect.flip + Effect.provide(handlers) ) - deepStrictEqual( - response, - AiError.make({ - module: "Toolkit", - method: "HandlerRequired.handle", - reason: new AiError.ToolParameterValidationError({ - toolName: "HandlerRequired", - toolParams: {}, - description: `Missing key\n at ["testParam"]` - }) + const validationError = AiError.make({ + module: "Toolkit", + method: "HandlerRequired.handle", + reason: new AiError.ToolParameterValidationError({ + toolName: "HandlerRequired", + toolParams: {}, + description: `Missing key\n at ["testParam"]` }) - ) + }) + + deepStrictEqual(response.toolResults, [ + Response.toolResultPart({ + id: toolCallId, + name: tool.name, + isFailure: true, + providerExecuted: false, + preliminary: false, + result: validationError, + encodedResult: { + _tag: "AiError", + module: "Toolkit", + method: "HandlerRequired.handle", + reason: { + _tag: "ToolParameterValidationError", + toolName: "HandlerRequired", + toolParams: {}, + description: `Missing key\n at ["testParam"]` + } + } + }) + ]) })) describe("addDependency", () => {