Skip to content

Recover tool calls which Toolkit never sees - #7574

Open
candrewlee14 wants to merge 1 commit into
Effect-TS:mainfrom
candrewlee14:fix/tool-call-recovery-opt-in
Open

Recover tool calls which Toolkit never sees#7574
candrewlee14 wants to merge 1 commit into
Effect-TS:mainfrom
candrewlee14:fix/tool-call-recovery-opt-in

Conversation

@candrewlee14

@candrewlee14 candrewlee14 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Rewritten on top of #7588, and re-shaped to follow @IMax153's review of #7452.

What the review asked for, and what changed

The review asked for this to be opt-in on tool declarations, like failureMode — or a different API altogether — and for generateText not to start succeeding where existing callers might silently miss an invalid call.

Since then #7588 landed the first half: a parameter failure on a call which reaches a handler routes through the tool's own failureMode. Everything this PR previously did for those calls is gone, along with all provider package changes.

What remains is calls Toolkit never sees, and they split cleanly:

A call which names a tool in the toolkit now follows that tool's failureMode, even when disableToolCallResolution: true means Toolkit will not run. With "return" it produces the same failed tool-result Toolkit itself would have produced. No new API — this is the per-tool opt-in the review asked for, extended to the one place it was not being honoured.

A call which names no tool at all has no declaration to carry a flag, so this is the "different API" case, kept as narrow as possible: one option, on the operation, defaulted off.

const response = yield* LanguageModel.generateText({ prompt, toolkit, unknownToolCalls: "return" })

response.toolCallErrors // tool-call-error parts: { name, params, error }
Prompt.fromResponseParts(response.content) // assistant tool call + failed tool result

One rule: the tool decides for its own calls; the operation decides only for calls that belong to no tool.

The silent-success concern is answered by the types rather than by convention: with the default, toolCallErrors is Array<never> and "tool-call-error" is not assignable to the response part type, so no existing caller's behavior or types change, and nobody reaches the new part without asking for it.

Why the unknown-tool case is worth recovering

Response.Part(toolkit) builds its union from Object.values(toolkit.tools), so a name that is not in the toolkit has no member and the decode fails the operation. resolveToolCalls then skips the call (if (!tool) return Stream.empty), so even if it decoded, it would be left without a result — which the next request rejects.

For an agent whose toolkit changes between turns, a stale tool name is an ordinary event rather than a hallucination: the model saw the tool a moment ago and it is gone now. Failing the turn is a poor answer when telling the model to try again is available.

Supersedes #6432.

The four detailed review points from #7452
  1. needsApproval double-decode — no longer applicable; this PR does not touch approval or parameter decoding, which unstable/ai: route tool param validation through failureMode and drop ToolParameterValidationError.toolParams #7588 owns.
  2. Dynamic tools with an Effect Schema must use the provider codec — no longer applicable; no codec is bypassed.
  3. Unchecked parameters in chat history — parameters on a returned part are normalized through Formatter (internal/json.ts). Regression-tested with your 1n example: it becomes a model-visible error and Chat.exportJson still succeeds.
  4. Preserve the original provider argument representation — a malformed call's argument text passes through unchanged rather than being re-stringified; {"broken stays {"broken.
Types

unknownToolCalls threads like disableToolCallResolution: Response.Part<Tools, ParametersMode, UnknownToolCalls>, StreamPart, GenerateTextResponse, ExtractUnknownToolCalls<Options>, and Chat forwards it. With the default, toolCallErrors is Array<never>; with "return", ToolCallErrorPart joins the union. An option whose value is not statically known keeps the part in the type.

HttpRequestDetails and HttpResponseDetails move to internal/http-details.ts so Response can reference AiError without a cycle; both modules re-export them unchanged.

Implementation

Recovery is driven by the decode which already validates the response rather than by a separate validation pass: parts are decoded one at a time, and a failure is recoverable only when the part is a tool call which can never reach a handler. Parameters are never validated twice, and any other decode failure fails the operation exactly as it does today.

The returned tool-call-error is itself decoded rather than constructed. A tool call may fail to decode for a reason which has nothing to do with the tool — a non-string id, invalid metadata — and such a call keeps failing instead of being returned under a misleading error. The helper is generic in the decoded part type and its only source of one is that decode, so an unvalidated part cannot be returned.

Verification
  • test/unstable/ai: 1079 passed, 44 skipped · packages/ai/*: 209 passed · doctests: 119 passed · tstyche: 128 assertions
  • tsc -b, oxlint, dprint check, jsdocs, circular: clean. No provider package is touched.
  • Covered: unknown tools and invalid parameters across generateText / streamText / generateObject, resolution enabled and disabled, failureMode "return" and "error", an option value which is not statically known, and a 300-run property test asserting that a returned part always satisfies the response schema.
  • Also asserted: a response containing an unrecoverable part runs no handler at all, and no tool call is ever left unanswered in the resulting history.

If the operation-level option is still the wrong shape for the unknown-tool case, the alternatives I can see are a toolkit-level policy (a second type parameter on Toolkit, which would flow to Chat and generateObject for free) or dropping that half entirely and keeping only the failureMode change. Happy to take either.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ptuky5ZxuPvZhR5wxtMoX1

@changeset-bot

changeset-bot Bot commented Sep 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5267d26

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

This PR includes changesets to release 30 packages
Name Type
effect Patch
@effect/opentelemetry Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter 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 Sep 1, 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.75 KB 33.75 KB 0.00 KB (0.00%)
basic.ts 6.99 KB 6.99 KB 0.00 KB (0.00%)
batching.ts 9.88 KB 9.88 KB 0.00 KB (0.00%)
brand.ts 6.55 KB 6.55 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.27 KB 20.27 KB 0.00 KB (0.00%)
http-client.ts 21.76 KB 21.76 KB 0.00 KB (0.00%)
logger.ts 10.92 KB 10.92 KB 0.00 KB (0.00%)
metric.ts 8.96 KB 8.96 KB 0.00 KB (0.00%)
optic.ts 6.75 KB 6.75 KB 0.00 KB (0.00%)
pubsub.ts 15.11 KB 15.11 KB 0.00 KB (0.00%)
queue.ts 11.80 KB 11.80 KB 0.00 KB (0.00%)
schedule.ts 10.85 KB 10.85 KB 0.00 KB (0.00%)
schema-binary.ts 39.39 KB 39.39 KB 0.00 KB (0.00%)
schema-class.ts 20.03 KB 20.03 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 30.10 KB 30.10 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 26.15 KB 26.15 KB 0.00 KB (0.00%)
schema-string-transformation.ts 13.64 KB 13.64 KB 0.00 KB (0.00%)
schema-string.ts 11.16 KB 11.16 KB 0.00 KB (0.00%)
schema-template-literal.ts 15.45 KB 15.45 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 33.30 KB 33.30 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.47 KB 24.47 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 19.24 KB 19.24 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 19.38 KB 19.38 KB 0.00 KB (0.00%)
schema-toFormatter.ts 19.48 KB 19.48 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 23.37 KB 23.37 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 19.52 KB 19.52 KB 0.00 KB (0.00%)
schema.ts 19.24 KB 19.24 KB 0.00 KB (0.00%)
stm.ts 12.73 KB 12.73 KB 0.00 KB (0.00%)
stream.ts 9.81 KB 9.81 KB 0.00 KB (0.00%)

@candrewlee14
candrewlee14 force-pushed the fix/tool-call-recovery-opt-in branch from 6f18e2e to 2632a0e Compare September 2, 2026 01:11
@candrewlee14
candrewlee14 force-pushed the fix/tool-call-recovery-opt-in branch from 2632a0e to 63e3da1 Compare September 2, 2026 01:34
@candrewlee14 candrewlee14 changed the title Return invalid model tool calls for tools which opt in Return invalid tool calls to the model when the operation opts in Sep 2, 2026
@candrewlee14
candrewlee14 force-pushed the fix/tool-call-recovery-opt-in branch from 63e3da1 to c62edc4 Compare September 2, 2026 04:37
@candrewlee14 candrewlee14 changed the title Return invalid tool calls to the model when the operation opts in Return tool calls which never reach a handler to the model Sep 2, 2026
@candrewlee14
candrewlee14 force-pushed the fix/tool-call-recovery-opt-in branch from c62edc4 to 6306aeb Compare September 2, 2026 04:47
@effect-slopcop effect-slopcop Bot added enhancement New feature or request bug Something isn't working and removed bug Something isn't working labels Sep 2, 2026
@candrewlee14
candrewlee14 force-pushed the fix/tool-call-recovery-opt-in branch from 6306aeb to d3bbf0b Compare September 2, 2026 04:53
@effect-slopcop effect-slopcop Bot removed the bug Something isn't working label Sep 2, 2026
@candrewlee14
candrewlee14 force-pushed the fix/tool-call-recovery-opt-in branch from d3bbf0b to 2b237e7 Compare September 2, 2026 05:13
@candrewlee14
candrewlee14 force-pushed the fix/tool-call-recovery-opt-in branch from 2b237e7 to 3d93382 Compare September 2, 2026 05:17
@effect-slopcop effect-slopcop Bot added bug Something isn't working enhancement New feature or request and removed enhancement New feature or request bug Something isn't working labels Sep 2, 2026
@candrewlee14
candrewlee14 force-pushed the fix/tool-call-recovery-opt-in branch from 3d93382 to ae1bab7 Compare September 2, 2026 05:22
@candrewlee14
candrewlee14 force-pushed the fix/tool-call-recovery-opt-in branch from ae1bab7 to 1a53a2f Compare September 2, 2026 05:29
@effect-slopcop effect-slopcop Bot added bug Something isn't working enhancement New feature or request ready for review and removed enhancement New feature or request bug Something isn't working labels Sep 2, 2026
A tool call's parameter failure is routed through the tool's `failureMode`,
but only when `Toolkit` resolves the call. Two calls never reach it: one made
with `disableToolCallResolution: true`, and one naming a tool which is not in
the toolkit. Both fail the whole operation today.

A call which names a tool in the toolkit now follows that tool's `failureMode`
in both cases, producing the same failed `tool-result` `Toolkit` itself would
have produced. A call which names no tool has no declaration to decide for it,
so `generateText` and `streamText` accept `unknownToolCalls`; with `"return"`
it comes back as a `tool-call-error` part which `Prompt.fromResponseParts`
adds to history as a failed tool result. The default is unchanged.

Recovery is driven by the decode which already validates the response: parts
are decoded one at a time, and a failure is recoverable only when the part is
a tool call which can never reach a handler. Parameters are never validated
twice, and any other decode failure fails the operation as before.

`HttpRequestDetails` and `HttpResponseDetails` move to a leaf module so
`Response` can reference `AiError` without a cycle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ptuky5ZxuPvZhR5wxtMoX1
@candrewlee14
candrewlee14 force-pushed the fix/tool-call-recovery-opt-in branch from 1a53a2f to 5267d26 Compare September 2, 2026 05:46
@candrewlee14 candrewlee14 changed the title Return tool calls which never reach a handler to the model Recover tool calls which Toolkit never sees Sep 2, 2026
@effect-slopcop effect-slopcop Bot added bug Something isn't working and removed enhancement New feature or request ready for review labels Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0 bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant