Skip to content

unstable/ai: recover unknown tool calls - #7574

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

unstable/ai: recover unknown tool calls#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

Follow-up to #7452, rewritten on top of #7588. Supersedes #6432.

Problem

If the model calls a tool that isn't in the toolkit, the whole generateText / streamText call fails. Response.Part(toolkit) builds its union from the toolkit's tools, so there's no schema member for the unknown name and the decode fails. Even if it decoded, resolveToolCalls skips it, so it'd be left without a result and the next request would be rejected.

For agents whose toolkit changes between turns this is a normal thing to happen, not a hallucination. The model saw the tool a turn ago and now it's gone. Telling the model "that tool doesn't exist, try again" is a better outcome than failing the turn.

Change

generateText and streamText take a new option, unknownToolCalls: "error" | "return". Default is "error", which is today's behaviour. With "return", an unknown tool call comes back as a tool-call-error part with the original call, its params, and a ToolNotFoundError. Prompt.fromResponseParts turns that into the assistant tool call plus a failed tool result, so the model can correct itself.

const response = yield* LanguageModel.generateText({ prompt, toolkit, unknownToolCalls: "return" })
response.toolCallErrors // [{ name, params, error }]

Calls to tools that are in the toolkit aren't affected. #7588 already routes their parameter failures through failureMode, and I left that alone. With disableToolCallResolution: true those calls still fail the operation like they do on main. So the split is: the tool decides for its own calls, the operation decides for calls that don't belong to any tool. If there's no toolkit, or it's empty, every call is unknown and the option applies the same way.

On @IMax153's concern from #7452 about callers silently missing invalid calls: with the default, toolCallErrors is Array<never> and "tool-call-error" isn't in the response part type, so nothing changes for existing code unless you opt in.

Notes

  • Recovery happens in the same decode that validates the response. Parts are decoded one at a time, and only a tool-call naming an unknown tool is recovered. Anything else that fails to decode still fails the operation. Params are never validated twice.
  • The tool-call-error part is produced by decoding, not constructed by hand, so a call that's malformed in some other way (non-string id, bad metadata) keeps failing rather than coming back under a misleading error.
  • Provider-executed calls are never recovered.
  • Params on the returned part go through Formatter so they're JSON-safe in history (the 1n case from the last review).
  • HttpRequestDetails / HttpResponseDetails moved to internal/http-details.ts so Response can import AiError without a cycle. Both modules still re-export them.
  • Response.AllParts includes the new part unconditionally.

Tests cover generateText, streamText and generateObject, with a toolkit, without one, and with an empty one, resolution on and off, a non-literal option value, and a property test that a returned part always satisfies the response schema. Also asserted: known-tool parameter failures still fail with resolution disabled, an unrecoverable response runs no handlers, and history never ends up with a call without a result or a result without a call.

If you'd rather this lived on the toolkit (a second type parameter, which would flow to Chat and generateObject for free) I'm happy to rework it that way.

🤖 Generated with Claude Code

https://claude.ai/code/session_019wFhhXHNc4Fnkye1orWQqr

@changeset-bot

changeset-bot Bot commented Sep 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1b9f096

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.88 KB 33.88 KB 0.00 KB (0.00%)
basic.ts 6.87 KB 6.87 KB 0.00 KB (0.00%)
batching.ts 10.14 KB 10.14 KB 0.00 KB (0.00%)
brand.ts 6.45 KB 6.45 KB 0.00 KB (0.00%)
cache.ts 10.76 KB 10.76 KB 0.00 KB (0.00%)
config.ts 21.50 KB 21.50 KB 0.00 KB (0.00%)
differ.ts 20.27 KB 20.27 KB 0.00 KB (0.00%)
http-client.ts 22.00 KB 22.00 KB 0.00 KB (0.00%)
http-router.ts 32.73 KB 32.73 KB 0.00 KB (0.00%)
logger.ts 10.87 KB 10.87 KB 0.00 KB (0.00%)
metric.ts 9.00 KB 9.00 KB 0.00 KB (0.00%)
optic.ts 6.70 KB 6.70 KB 0.00 KB (0.00%)
pubsub.ts 15.26 KB 15.26 KB 0.00 KB (0.00%)
queue.ts 11.84 KB 11.84 KB 0.00 KB (0.00%)
schedule.ts 10.95 KB 10.95 KB 0.00 KB (0.00%)
schema-binary.ts 39.47 KB 39.47 KB 0.00 KB (0.00%)
schema-class.ts 20.04 KB 20.04 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 30.31 KB 30.31 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.63 KB 13.63 KB 0.00 KB (0.00%)
schema-string.ts 11.11 KB 11.11 KB 0.00 KB (0.00%)
schema-template-literal.ts 15.50 KB 15.50 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 33.43 KB 33.43 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.47 KB 24.47 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 19.23 KB 19.23 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.57 KB 23.57 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 19.53 KB 19.53 KB 0.00 KB (0.00%)
schema.ts 19.25 KB 19.25 KB 0.00 KB (0.00%)
stm.ts 12.78 KB 12.78 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
@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
@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
@tim-smart

Copy link
Copy Markdown
Contributor

Hey! It will be a while until this gets a proper review as it is a large change and we have higher priorities atm :)

@candrewlee14

Copy link
Copy Markdown
Contributor Author

Thanks for letting me know! I'm also totally understanding if you throw this out and solve it in a different way, of course.
I think the underlying problem is worth solving though :)

@candrewlee14
candrewlee14 force-pushed the fix/tool-call-recovery-opt-in branch from 5267d26 to 18e7a62 Compare September 5, 2026 04:59
@candrewlee14 candrewlee14 changed the title Recover tool calls which Toolkit never sees unstable/ai: recover tool calls which Toolkit never sees Sep 5, 2026
A tool call naming a tool which is not in the toolkit fails the whole
operation today: `Response.Part(toolkit)` has no member for it, and even if
it decoded, `resolveToolCalls` would skip it and leave it unanswered, which
the next request rejects. For an agent whose toolkit changes between turns
that is an ordinary event rather than a hallucination.

`generateText` and `streamText` accept `unknownToolCalls`. With `"return"`
such a call comes back as a `tool-call-error` part carrying the original
call, its JSON parameters, and a `ToolNotFoundError`, and
`Prompt.fromResponseParts` adds it to history as the call plus a failed tool
result so the model can correct it. The default is unchanged, and the part is
absent from the response type unless the option is set. An operation with no
toolkit, or an empty one, treats every tool call as unknown, so the option
applies there too.

A call which names a tool in the toolkit is untouched: `Toolkit` routes a
failure of its parameters through that tool's `failureMode`, and with
resolution disabled it fails the operation as before.

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 call to a tool which is not in the toolkit. 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>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wFhhXHNc4Fnkye1orWQqr
@candrewlee14
candrewlee14 force-pushed the fix/tool-call-recovery-opt-in branch from 18e7a62 to 1b9f096 Compare September 6, 2026 15:26
@candrewlee14 candrewlee14 changed the title unstable/ai: recover tool calls which Toolkit never sees unstable/ai: recover unknown tool calls Sep 6, 2026
@effect-janitor effect-janitor Bot added the enhancement New feature or request label Sep 6, 2026

@danieljvdm danieljvdm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

the new response part and generic make sense for keeping the tool types intact. my concern is recovery rewriting calls while execution still reads the originals.

could we classify each call once and use that for recovery, execution and history? we'd still keep the raw params for Toolkit to decode.

Comment on lines +637 to +640
export type ExtractUnknownToolCalls<Options> = Options extends {
readonly unknownToolCalls: infer Mode
} ? [Exclude<Mode, undefined>] extends [never] ? "error" : Extract<Mode, "error" | "return">
: "error"

@danieljvdm danieljvdm Sep 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

optional unknownToolCalls gets typed as "error" even when set to "return", so toolCallErrors becomes never[]. same issue in streaming and UnknownToolCallsOf.

// `Toolkit` routes its parameter failure through the tool's own
// `failureMode` when it resolves the call. When resolution is disabled
// the caller owns the call, so it fails here exactly as it does today.
if (Object.hasOwn(toolkit.tools, part.name)) {

@danieljvdm danieljvdm Sep 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

toString, constructor and __proto__ still fail in return mode. the remaining lookups need Object.hasOwn too.

toString with finish reason "length" also produces two results for one call.

Comment on lines +2554 to +2560
decodePart({
type: "tool-call-error",
id: part.id,
name: part.name,
params: toJson(part.params),
error: encodeAiError(AiError.make({ module: "LanguageModel", method, reason })),
metadata: part.metadata

@danieljvdm danieljvdm Sep 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

providerExecuted: "true" gets dropped here and escapes validation. same for 1 and null. can we validate before converting?

Comment on lines +2402 to +2411
for (const part of result.success.content) {
if (part.type === "tool-call-error") {
strictEqual(typeof part.id, "string")
strictEqual(typeof part.name, "string")
assertTrue(AiError.isAiError(part.error))
}
if (part.type === "tool-call") {
strictEqual(typeof part.id, "string")
strictEqual(typeof part.name, "string")
}

@danieljvdm danieljvdm Sep 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can we assert the full schema here and generate invalid providerExecuted values too?

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 enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants