Skip to content

unstable/ai: constructing ToolParameterValidationError throws when toolParams contain NaN/Infinity, turning a typed tool error into a defect #7416

Description

@agcty

Summary

AiError.ToolParameterValidationError declares toolParams: Schema.Json and validates it at construction. Because Schema.Json rejects NaN, Infinity, and -Infinity, constructing the error throws whenever the offending params contain one of those values — so the diagnostic path for a bad tool call becomes a defect (Die) instead of the typed, retryable failure it is meant to be.

The value that triggers this is the very value the error exists to report, which makes the failure mode inverted: the worse the input, the less usable the error.

Line refs at main HEAD 4d89bb8ffb4cf567a1d11072246b6161ce638712:

  • packages/effect/src/unstable/ai/AiError.ts:1017toolParams: Schema.Json
  • packages/effect/src/unstable/ai/Toolkit.ts:302-304 — the only construction site, inside Toolkit.handle's Effect.mapError, passing the raw params straight through:
const decodedParams = yield* schemas.decodeParameters(params).pipe(
  Effect.mapError((cause) =>
    AiError.make({
      module: "Toolkit",
      method: `${name}.handle`,
      reason: new AiError.ToolParameterValidationError({
        toolName: name,
        toolParams: params,        // <- unvalidated, may contain NaN/Infinity
        description: cause.message
      })
    })
  )
)

Since the constructor throws synchronously inside mapError, the throw escapes as a defect rather than the typed error.

Scope / severity

This cannot arise from a model's JSON tool call — JSON cannot encode NaN or infinities. It arises from programmatic callers of toolkit.handle (tests, in-process tool invocation, non-JSON transports, provider adapters that hand through already-decoded JS values). We hit it in a test that used NaN as a deliberately-bad tool parameter and got an opaque Error: Schema validation failed defect instead of ToolParameterValidationError.

So: low frequency, but it converts a diagnostic path into a crash path, and the resulting defect carries none of the tool name or decode description that the typed error would have carried.

Repro

effect@4.0.0-rc.109; 4.0.0-rc.111 and main are byte-identical in AiError.ts for this declaration.

import * as Effect from "effect/Effect"
import * as Exit from "effect/Exit"
import * as Schema from "effect/Schema"
import * as Stream from "effect/Stream"
import * as AiError from "effect/unstable/ai/AiError"
import * as Tool from "effect/unstable/ai/Tool"
import * as Toolkit from "effect/unstable/ai/Toolkit"

// 1. Direct construction of the diagnostic error throws on a non-JSON value.
try {
  new AiError.ToolParameterValidationError({
    toolName: "GetWeather",
    toolParams: { city: NaN },
    description: "Expected string, got number"
  })
  console.log("1. constructed OK")
} catch (e) {
  console.log("1. construction THREW:", (e as Error).message)
}

// 2. End to end through Toolkit.handle.
const GetWeather = Tool.make("GetWeather", {
  parameters: Schema.Struct({ city: Schema.String }),
  success: Schema.String
})
const kit = Toolkit.make(GetWeather)
const layer = kit.toLayer({ GetWeather: () => Effect.succeed("sunny") })

const run = (params: unknown) =>
  Effect.gen(function*() {
    const built = yield* kit
    const exit = yield* Effect.exit(
      Effect.flatMap(built.handle("GetWeather", params as any), Stream.runCollect)
    )
    if (Exit.isSuccess(exit)) return "Success"
    return (exit.cause as any).reasons
      .map((r: any) => `${r._tag}(${r.error?.reason?._tag ?? String(r.defect).split("\n")[0]})`)
      .join(", ")
  }).pipe(Effect.provide(layer))

Effect.runPromise(
  Effect.gen(function*() {
    console.log("2. { city: 123 } ->", yield* run({ city: 123 }))
    console.log("3. { city: NaN } ->", yield* run({ city: NaN }))
  }) as Effect.Effect<void>
)

Output:

1. construction THREW: Schema validation failed
2. { city: 123 } -> Fail(ToolParameterValidationError)
3. { city: NaN } -> Die(Error: Schema validation failed)

Line 2 is the expected shape. Line 3 is the bug: the same class of input, one JSON-representable and one not, produces a typed retryable failure in one case and an untyped defect in the other. Infinity and -Infinity behave the same as NaN.

Expected

toolkit.handle should fail with AiError / ToolParameterValidationError for any parameter value that fails the tool's parameter schema, including values Schema.Json cannot represent.

No caller-side workaround

ToolParameterValidationError has no unsafe constructor: .make validates identically, there is no makeUnsafe, and a second { disableValidation: true } argument is not honoured. The only construction site is internal to Toolkit.handle, so a consumer cannot sanitize params between the decode failure and the error construction — only before calling handle, which defeats the purpose of reporting what was actually passed.

Suggested direction

Any one of:

  1. Type the field as Schema.Unknown — it is diagnostic payload, not a wire contract.
  2. Keep Schema.Json but sanitize at the construction site in Toolkit.ts (e.g. encode non-finite numbers to their string sentinels, or fall back to a string rendering of params).
  3. Validate the field lazily rather than at construction.

(1) seems closest to the field's purpose. Happy to open a PR for whichever direction you prefer.

Versions

  • Observed on effect@4.0.0-rc.109
  • Verified unchanged on effect@4.0.0-rc.111 (current rc dist-tag) — packages/effect/src/unstable/ai/AiError.ts is identical to rc.109
  • Verified unchanged on main @ 4d89bb8ffb4cf567a1d11072246b6161ce638712
  • Bun 1.x, macOS

Searched open and closed issues for ToolParameterValidationError, toolParams, AiError NaN, Schema.Json NaN — the nearest matches are #6335 (parameter-decode failure bypasses failureMode: "return", same code path, different symptom) and #6350 (Schema.Number non-finite audit, opposite direction: schemas that are too permissive). Neither covers this.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions