Skip to content

@effect/ai: toolkit.handle double-decodes params when disableToolCallResolution: true #6119

@sukazavr

Description

@sukazavr

What version of Effect is running?

3.19.18

What steps can reproduce the bug?

  1. Define a tool with a transforming schema in its parameters (e.g. Schema.URL, Schema.DateFromString):
import { Tool, Toolkit, LanguageModel } from "@effect/ai"
import { Schema } from "effect"

const MyTool = Tool.make("my_tool", {
  description: "A tool that takes a URL",
  parameters: {
    url: Schema.URL,
  },
  success: Schema.Struct({ ok: Schema.Boolean }),
})

const MyToolkit = Toolkit.make(MyTool)
  1. Call LanguageModel.generateText with disableToolCallResolution: true and the toolkit:
const response = yield* LanguageModel.generateText({
  prompt,
  toolkit: MyToolkit,
  disableToolCallResolution: true,
})
  1. Manually call toolkit.handle with the tool call params from the response:
const toolkit = yield* MyToolkit
for (const toolCall of response.toolCalls) {
  const result = yield* toolkit.handle(toolCall.name, toolCall.params)
}

What is the expected behavior?

toolkit.handle(toolCall.name, toolCall.params) should successfully decode the parameters and execute the tool handler. Since disableToolCallResolution: true is designed for manual tool handling, toolCall.params should be in the format that toolkit.handle expects (raw/encoded JSON values).

What do you see instead?

toolkit.handle throws an AiError.MalformedOutput because it tries to decode parameters that were already decoded:

{ "module": "Toolkit", "method": "my_tool.handle",
  "description": "Failed to decode tool call parameters for tool 'my_tool'..." }
└─ ["url"]
   └─ URL for the new browse agent to visit
      └─ Encoded side transformation failure
         └─ Expected string, actual https://example.com

Schema.URL expects a string input but receives a URL object — because the params were already decoded once.

Tools with non-transforming parameter schemas (e.g. Schema.String, Schema.Number, Schema.Literal) are unaffected since their encoded and decoded types are identical.

Additional information

Root cause

In LanguageModel.ts, generateContent always decodes response content through Response.Part(toolkit), even when disableToolCallResolution: true:

// LanguageModel.ts lines 779-782
if (options.disableToolCallResolution === true) {
  const rawContent = yield* params.generateText(providerOptions)
  const content = yield* Schema.decodeUnknown(ResponseSchema)(rawContent)
  //              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  //              This decodes tool call params through the tool's parametersSchema
  //              e.g. string "https://example.com" → URL object
  return content as Array<Response.Part<Tools>>
}

Response.Part(toolkit) constructs a schema that includes tool-specific ToolCallPart schemas (Response.ts line 288):

for (const tool of Object.values(toolkit.tools)) {
  toolCalls.push(ToolCallPart(tool.name, tool.parametersSchema))
  //                                     ^^^^^^^^^^^^^^^^^^^^
  //                                     Params are decoded through the tool schema
}

So after generateContent, toolCall.params contains decoded values (e.g. URL objects, not strings).

Then toolkit.handle decodes them again via Schema.decodeUnknown(tool.parametersSchema) (Toolkit.ts line 285):

const decodeParameters = Schema.decodeUnknown(tool.parametersSchema) as any
// ...
const decodedParams = yield* schemas.decodeParameters(params)
//                                                    ^^^^^^
//                                                    params is already decoded!
//                                                    Schema.URL expects string, gets URL object → fails

The second decode fails because transforming schemas like Schema.URL expect the encoded type (string) but receive the decoded type (URL object).

Suggested fix

Option A — Skip tool parameter decoding when disableToolCallResolution: true:

In generateContent, use Response.Part(Toolkit.empty) instead of Response.Part(toolkit) when disableToolCallResolution is set. This keeps params as raw JSON, matching what toolkit.handle expects.

if (options.disableToolCallResolution === true) {
  const ResponseSchema = Schema.mutable(Schema.Array(Response.Part(Toolkit.empty)))
  const rawContent = yield* params.generateText(providerOptions)
  const content = yield* Schema.decodeUnknown(ResponseSchema)(rawContent)
  return content as Array<Response.Part<Tools>>
}

Option B — Use Schema.validate instead of Schema.decodeUnknown in toolkit.handle:

Schema.validate checks that the value conforms to the decoded (type) side without re-running transformations, so it would accept both raw and already-decoded params.

Workaround

Re-serialize params to raw JSON before calling toolkit.handle to undo the schema transformations:

const rawParams = JSON.parse(JSON.stringify(toolCall.params)) as unknown
const result = yield* toolkit.handle(toolCall.name, rawParams)

This works because URL.toJSON() returns the href string, Date.toJSON() returns ISO string, etc. — restoring values to their encoded form.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions