From 6e5bbcbcad9cf348fa4acbea761104b313ff02db Mon Sep 17 00:00:00 2001 From: Maxwell Calkin Date: Sun, 8 Mar 2026 13:43:27 -0400 Subject: [PATCH] fix(@effect/ai): prevent double-decode of tool call params with disableToolCallResolution When `disableToolCallResolution: true`, `generateContent` and `streamContent` decoded tool call parameters through the tool's `parametersSchema` (e.g. string -> URL). When the user then called `toolkit.handle()`, it decoded again, failing on transforming schemas like `Schema.URL` or `Schema.NumberFromString`. Use generic tool-call/result schemas with `Schema.Unknown` params in the `disableToolCallResolution` path so params remain as raw JSON, matching what `toolkit.handle()` expects. Fixes #6119 Co-Authored-By: Claude Opus 4.6 --- packages/ai/ai/src/LanguageModel.ts | 90 +++++++++++++++++- packages/ai/ai/test/LanguageModel.test.ts | 111 ++++++++++++++++++++++ 2 files changed, 196 insertions(+), 5 deletions(-) diff --git a/packages/ai/ai/src/LanguageModel.ts b/packages/ai/ai/src/LanguageModel.ts index c95c460bde1..d6114fb87e0 100644 --- a/packages/ai/ai/src/LanguageModel.ts +++ b/packages/ai/ai/src/LanguageModel.ts @@ -775,10 +775,15 @@ export const make: (params: ConstructorParams) => Effect.Effect = Effec const ResponseSchema = Schema.mutable(Schema.Array(Response.Part(toolkit))) // If tool call resolution is disabled, return the response without - // resolving the tool calls that were generated + // resolving the tool calls that were generated. + // Use the raw schema (GenericToolCallPart) to avoid decoding tool + // call parameters through the tool's parametersSchema, so that + // params remain as raw JSON values. This prevents double-decoding + // when the user later calls toolkit.handle() which also decodes + // via parametersSchema. if (options.disableToolCallResolution === true) { const rawContent = yield* params.generateText(providerOptions) - const content = yield* Schema.decodeUnknown(ResponseSchema)(rawContent) + const content = yield* Schema.decodeUnknown(RawPartSchema)(rawContent) return content as Array> } @@ -837,10 +842,11 @@ export const make: (params: ConstructorParams) => Effect.Effect = Effec providerOptions.toolChoice = toolChoice // If tool call resolution is disabled, return the response without - // resolving the tool calls that were generated + // resolving the tool calls that were generated. + // Use the raw schema to avoid decoding tool call parameters, keeping + // params as raw JSON for the same reason as in generateContent above. if (options.disableToolCallResolution === true) { - const schema = Schema.ChunkFromSelf(Response.StreamPart(toolkit)) - const decode = Schema.decode(schema) + const decode = Schema.decode(RawStreamPartSchema) return params.streamText(providerOptions).pipe( Stream.mapChunksEffect(decode) ) as Stream.Stream, AiError.AiError | ParseResult.ParseError, IdGenerator> @@ -985,6 +991,80 @@ export const streamText = < LanguageModel | ExtractContext > => Stream.unwrap(LanguageModel.pipe(Effect.map((model) => model.streamText(options)))) +// ============================================================================= +// Raw Response Schemas (for disableToolCallResolution) +// ============================================================================= + +// A generic tool-call schema that passes params through as unknown, +// avoiding parameter decoding that would cause double-decode when +// toolkit.handle() is later called manually. +const GenericToolCallPart = Schema.Struct({ + type: Schema.Literal("tool-call"), + id: Schema.String, + name: Schema.String, + params: Schema.Unknown, + providerName: Schema.optional(Schema.String), + providerExecuted: Schema.optionalWith(Schema.Boolean, { default: () => false }), + metadata: Schema.optionalWith(Response.ProviderMetadata, { default: () => ({}) }) +}).pipe( + Schema.attachPropertySignature(Response.PartTypeId, Response.PartTypeId), + Schema.annotations({ identifier: "GenericToolCallPart" }) +) + +// A generic tool-result schema that passes result through as unknown. +// Included for type-completeness (the provider stream type includes +// ToolResultPartEncoded even though results shouldn't appear when +// tool call resolution is disabled). +const GenericToolResultPart = Schema.Struct({ + type: Schema.Literal("tool-result"), + id: Schema.String, + name: Schema.String, + result: Schema.Unknown, + isFailure: Schema.Boolean, + providerName: Schema.optional(Schema.String), + providerExecuted: Schema.optionalWith(Schema.Boolean, { default: () => false }), + metadata: Schema.optionalWith(Response.ProviderMetadata, { default: () => ({}) }) +}).pipe( + Schema.attachPropertySignature(Response.PartTypeId, Response.PartTypeId), + Schema.annotations({ identifier: "GenericToolResultPart" }) +) + +const RawPartSchema = Schema.mutable(Schema.Array( + Schema.Union( + Response.TextPart, + Response.ReasoningPart, + Response.FilePart, + Response.DocumentSourcePart, + Response.UrlSourcePart, + Response.ResponseMetadataPart, + Response.FinishPart, + GenericToolCallPart, + GenericToolResultPart + ) +)) + +const RawStreamPartSchema = Schema.ChunkFromSelf( + Schema.Union( + Response.TextStartPart, + Response.TextDeltaPart, + Response.TextEndPart, + Response.ReasoningStartPart, + Response.ReasoningDeltaPart, + Response.ReasoningEndPart, + Response.ToolParamsStartPart, + Response.ToolParamsDeltaPart, + Response.ToolParamsEndPart, + Response.FilePart, + Response.DocumentSourcePart, + Response.UrlSourcePart, + Response.ResponseMetadataPart, + Response.FinishPart, + Response.ErrorPart, + GenericToolCallPart, + GenericToolResultPart + ) +) + // ============================================================================= // Tool Call Resolution // ============================================================================= diff --git a/packages/ai/ai/test/LanguageModel.test.ts b/packages/ai/ai/test/LanguageModel.test.ts index fc72e214cf1..1eb382dab11 100644 --- a/packages/ai/ai/test/LanguageModel.test.ts +++ b/packages/ai/ai/test/LanguageModel.test.ts @@ -23,7 +23,65 @@ const MyToolkitLayer = MyToolkit.toLayer({ ) }) +// Tool with a transforming schema (NumberFromString: string -> number) +// to verify that disableToolCallResolution keeps params as raw JSON +const TransformTool = Tool.make("TransformTool", { + description: "A tool with a transforming parameter schema", + parameters: { count: Schema.NumberFromString }, + success: Schema.Struct({ doubled: Schema.Number }) +}) + +const TransformToolkit = Toolkit.make(TransformTool) + +const TransformToolkitLayer = TransformToolkit.toLayer({ + TransformTool: ({ count }) => + Effect.succeed({ doubled: count * 2 }) +}) + describe("LanguageModel", () => { + describe("generateText", () => { + it.effect( + "disableToolCallResolution should not double-decode transforming params", + () => + Effect.gen(function*() { + const toolCallId = "tool-transform-1" + const toolName = "TransformTool" + // The LLM returns params as raw JSON (encoded form: string "42") + const toolParams = { count: "42" } + + const response = yield* LanguageModel.generateText({ + prompt: "test", + toolkit: TransformToolkit, + disableToolCallResolution: true + }).pipe( + TestUtils.withLanguageModel({ + generateText: [ + { + type: "tool-call", + id: toolCallId, + name: toolName, + params: toolParams + } + ] + }), + Effect.provide(TransformToolkitLayer) + ) + + // Params should remain as raw JSON (string "42"), not decoded to number 42 + assert.strictEqual(response.toolCalls.length, 1) + const toolCall = response.toolCalls[0]! + assert.strictEqual(toolCall.name, toolName) + + // Now manually call toolkit.handle with the raw params — should succeed + const toolkit = yield* TransformToolkit.pipe( + Effect.provide(TransformToolkitLayer) + ) + const result = yield* toolkit.handle(toolCall.name, toolCall.params as any) + assert.deepStrictEqual(result.result, { doubled: 84 }) + }) + ) + }) + describe("streamText", () => { it.effect("should emit tool calls before executing tool handlers", () => Effect.gen(function*() { @@ -82,5 +140,58 @@ describe("LanguageModel", () => { assert.deepStrictEqual(parts, [toolCallPart, toolResultPart]) })) + + it.effect( + "disableToolCallResolution should not double-decode transforming params in stream", + () => + Effect.gen(function*() { + const parts: Array>> = [] + const latch = yield* Effect.makeLatch() + + const toolCallId = "tool-transform-stream-1" + const toolName = "TransformTool" + const toolParams = { count: "42" } + + yield* LanguageModel.streamText({ + prompt: "test", + toolkit: TransformToolkit, + disableToolCallResolution: true + }).pipe( + Stream.runForEach((part) => + Effect.andThen(latch.open, () => { + parts.push(part) + }) + ), + TestUtils.withLanguageModel({ + streamText: [ + { + type: "tool-call", + id: toolCallId, + name: toolName, + params: toolParams + } + ] + }), + Effect.provide(TransformToolkitLayer), + Effect.fork + ) + + yield* latch.await + + // Verify tool call params remain as raw JSON + const toolCallParts = parts.filter((p) => p.type === "tool-call") + assert.strictEqual(toolCallParts.length, 1) + + // Manually call toolkit.handle — should succeed without double-decode + const toolkit = yield* TransformToolkit.pipe( + Effect.provide(TransformToolkitLayer) + ) + const result = yield* toolkit.handle( + toolCallParts[0]!.name as any, + toolCallParts[0]!.params as any + ) + assert.deepStrictEqual(result.result, { doubled: 84 }) + }) + ) }) })