Skip to content

Make model tool call errors recoverable - #7452

Closed
candrewlee14 wants to merge 2 commits into
Effect-TS:mainfrom
candrewlee14:fix/model-visible-tool-call-errors
Closed

Make model tool call errors recoverable#7452
candrewlee14 wants to merge 2 commits into
Effect-TS:mainfrom
candrewlee14:fix/model-visible-tool-call-errors

Conversation

@candrewlee14

@candrewlee14 candrewlee14 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

  • normalize provider tool-call parameters once in LanguageModel
  • return unknown tools and invalid parameters as Response.ToolCallErrorPart values instead of failing the whole model operation
  • preserve malformed provider JSON as the same model-visible error shape
  • turn tool-call errors into the original assistant call plus a failed tool result in chat history, so the model can correct and retry
  • preserve canonical encoded parameters when automatic tool resolution is disabled
  • feed only valid, decoded calls into the lookahead and interruption machinery added in Execute streaming tool handlers eagerly with interruption and synthesized results #7486
  • expose decoded Toolkit execution as a typed composition boundary for integrations which route or decorate resolved toolkits
  • discriminate successful and failed HandlerResult values with isFailure
  • keep arbitrary invalid parameters as typed diagnostics while retaining a JSON-safe encoded error representation

Problem

Given a normal toolkit:

const GetWeather = Tool.make("GetWeather", {
  parameters: Schema.Struct({ city: Schema.String }),
  success: Schema.String
})

const toolkit = Toolkit.make(GetWeather)

const response = yield* LanguageModel.generateText({
  prompt: "What is the weather?",
  toolkit
})

if the provider returns a complete tool call like this:

{
  type: "tool-call",
  id: "call_1",
  name: "GetWeather",
  params: { city: 42 }
}

the current implementation fails generateText with a
ToolParameterValidationError. The model never receives that retryable error,
so it cannot correct the call. An invented tool name has the same problem, and
several providers fail even earlier when tool arguments contain malformed JSON.

With this change, the operation succeeds and exposes the failure directly:

response.toolCallErrors[0]?.error._tag
// "ToolParameterValidationError"

When response parts are added to a Chat, the error becomes an assistant tool
call followed by a failed tool result. Direct Toolkit.handle calls remain
strict; only model-originated calls are converted into model-visible feedback.

Design

Providers translate their wire formats but leave toolkit lookup and Schema
decoding to the shared LanguageModel layer. The shared layer normalizes tool
calls and validates the complete response before any handler can perform side
effects. Invalid and unknown calls become error parts; valid calls are decoded
once and executed through the Toolkit's decoded-handler boundary.

For streaming responses, only valid decoded calls enter the one-chunk lookahead
buffer introduced in #7486. Invalid calls are emitted immediately and never
execute, while incomplete responses still interrupt or suppress valid calls and
produce their existing synthesized failure results. Mixed responses preserve
and execute valid sibling calls.

Toolkit.makeWithHandler exposes the same encoded and decoded boundaries used
internally so integrations can compose resolved toolkits without rebuilding
them or weakening their types. HandlerResult is discriminated by isFailure,
which preserves success and failure narrowing through those compositions.

The manual-resolution path additionally re-encodes valid calls through the
original tool Schema, preserving its documented encoded parameter type.

ToolParameterValidationError.toolParams accepts the original runtime value
but uses Effect's canonical defect encoder for its JSON representation. This
keeps diagnostic construction total without weakening the encoded contract or
changing ordinary JSON objects during decoding.

OpenAI, OpenAI-compatible, Anthropic, and OpenRouter use one shared secure JSON
constructor for streamed and non-streamed calls. A small leaf module for HTTP
detail schemas keeps the new Response / AiError relationship free of import
cycles; the existing public Response.HttpRequestDetails and
Response.HttpResponseDetails exports are unchanged.

Related issues and work

Prior art

This follows the recovery boundary used by other agent runtimes:

Validation

  • pnpm lint-fix
  • pnpm check
  • pnpm build
  • 100 focused core AI runtime tests
  • focused type tests on TypeScript 5.9.3 and 6.0.3
  • downstream Vesper package, static, and test verification (805 tests)

@changeset-bot

changeset-bot Bot commented Aug 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e16a01f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 30 packages
Name Type
@effect/ai-anthropic Patch
@effect/ai-openai Patch
@effect/ai-openai-compat Patch
@effect/ai-openrouter Patch
effect Patch
@effect/opentelemetry Patch
@effect/vitest Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node-shared Patch
@effect/platform-node Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/openapi-generator Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
arbitrary-combinators.ts 33.78 KB 33.78 KB 0.00 KB (0.00%)
basic.ts 7.00 KB 7.00 KB 0.00 KB (0.00%)
batching.ts 9.87 KB 9.87 KB 0.00 KB (0.00%)
brand.ts 6.56 KB 6.56 KB 0.00 KB (0.00%)
cache.ts 10.74 KB 10.74 KB 0.00 KB (0.00%)
config.ts 21.46 KB 21.46 KB 0.00 KB (0.00%)
differ.ts 20.31 KB 20.31 KB 0.00 KB (0.00%)
http-client.ts 21.77 KB 21.77 KB 0.00 KB (0.00%)
logger.ts 10.94 KB 10.94 KB 0.00 KB (0.00%)
metric.ts 8.98 KB 8.98 KB 0.00 KB (0.00%)
optic.ts 6.73 KB 6.73 KB 0.00 KB (0.00%)
pubsub.ts 15.12 KB 15.12 KB 0.00 KB (0.00%)
queue.ts 11.84 KB 11.84 KB 0.00 KB (0.00%)
schedule.ts 10.86 KB 10.86 KB 0.00 KB (0.00%)
schema-binary.ts 39.42 KB 39.42 KB 0.00 KB (0.00%)
schema-class.ts 20.07 KB 20.07 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 30.14 KB 30.14 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 26.19 KB 26.19 KB 0.00 KB (0.00%)
schema-string-transformation.ts 13.66 KB 13.66 KB 0.00 KB (0.00%)
schema-string.ts 11.18 KB 11.18 KB 0.00 KB (0.00%)
schema-template-literal.ts 15.47 KB 15.47 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 33.33 KB 33.33 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.51 KB 24.51 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 19.28 KB 19.28 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 19.42 KB 19.42 KB 0.00 KB (0.00%)
schema-toFormatter.ts 19.52 KB 19.52 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 23.42 KB 23.42 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 19.56 KB 19.56 KB 0.00 KB (0.00%)
schema.ts 19.28 KB 19.28 KB 0.00 KB (0.00%)
stm.ts 12.74 KB 12.74 KB 0.00 KB (0.00%)
stream.ts 9.83 KB 9.83 KB 0.00 KB (0.00%)

@candrewlee14
candrewlee14 force-pushed the fix/model-visible-tool-call-errors branch from c9ca41a to 89a5342 Compare August 27, 2026 03:47
@candrewlee14
candrewlee14 force-pushed the fix/model-visible-tool-call-errors branch from 89a5342 to c93e6ec Compare August 27, 2026 04:11
@candrewlee14
candrewlee14 force-pushed the fix/model-visible-tool-call-errors branch from c93e6ec to 8a7151d Compare August 27, 2026 04:21
@candrewlee14
candrewlee14 force-pushed the fix/model-visible-tool-call-errors branch from 8a7151d to 1226f8d Compare August 27, 2026 04:33
@candrewlee14
candrewlee14 force-pushed the fix/model-visible-tool-call-errors branch from 1226f8d to 2d8ecc8 Compare August 27, 2026 05:28
@candrewlee14
candrewlee14 force-pushed the fix/model-visible-tool-call-errors branch from 2d8ecc8 to 43bc5b9 Compare August 27, 2026 05:29

@IMax153 IMax153 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've done a cursory review and noted some significant bugs that could result from this proposal.

However, I'm not too sure in general about the changes proposed by this PR because they would materially change how invalid model-generated calls are handled by LanguageModel.

Instead of being surfaced in the Effect error channel, operations like generateText would succeed, which may result in existing callers silently missing the fact that you now need to interrogate response.toolCallErrors to get at invalid tool calls.

I think the API needs to be revisited. It may be better to have an opt-in option on tool call declarations to enable this behavior (like we have with failureMode), or a different API altogether.

I don't think I can merge this proposal in its current form.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this point, toolCall.params would have already been decoded by normalizeToolCall. This decodes it a second time.

For tool call schemas that contain transformations (i.e. Schema.FiniteFromString), the second decode fails and orElseSucceed(false) would disable approval.

Please pass the decoded parameters directly to needsApproval and add a transformed-schema regression test to ensure this case is covered moving forward.


const tool = toolkit.tools[toolCall.name]
const parametersSchema = tool.parametersSchema
const codec = toolCall.providerExecuted === true || Tool.isDynamic(tool)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dynamic tools can use either raw JSON Schema OR an Effect Schema for defining parameters.

Tool params defined with a Schema still need to use the provider codec. This bypass should really only be used if tool.jsonSchema is defined.

assistantParts.push(makePart("tool-call", {
id: part.id,
name: part.name,
params: part.params,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would copy unchecked parameters into chat history.

For example, a value like 1n becomes a model-visible error with these changes, but would then cause Chat.exportJson to fail. The preserved parameters need the same JSON-safe treatment as error.toolParams.

type: "function_call",
name: toolName,
call_id: part.id,
arguments: JSON.stringify(part.params),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For malformed calls, part.params is already the original argument string. Stringifying it again here would change "{" into ""{"", so history no longer reproduces the call that failed. Please preserve the original provider argument representation.

Approval was decided by decoding tool parameters a second time, after
`normalizeToolCalls` had already decoded them. For any schema with a
transformation the second decode fails, and the surrounding
`orElseSucceed(constFalse)` turned that failure into "no approval
needed", so a tool that required approval ran without it. Approval now
reads the decoded parameters, which is what `NeedsApprovalFunction`
already declares it receives, and the function can no longer fail.

Dynamic tools bypassed the provider codec whenever they were dynamic.
Only a dynamic tool that declares raw JSON Schema should bypass it; one
whose parameters are an Effect `Schema` still needs it. The guard now
matches the one `Tool.getJsonSchema` already uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@candrewlee14

candrewlee14 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #7574.

Correcting this comment: it previously said that PR followed the review by making recovery opt-in per tool through failureMode. That was not accurate — it used an operation-level option throughout.

#7574 has since been rebuilt on top of #7588 and does now follow the review where it can: a call naming a tool in the toolkit follows that tool's failureMode, with no new API. Only a call naming a tool which is not in the toolkit still needs an operation-level option, because there is no declaration to carry a per-tool flag. Details and the four review points are in that PR's description.

Thanks for the review — it was right, and the corrected shape is much smaller.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants