Skip to content

fix(ai): keep an unencodable tool result from killing the session - #60604

Draft
HX-Wrdzgzs wants to merge 4 commits into
anomalyco:devfrom
HX-Wrdzgzs:tool-result-encode-fallback
Draft

HX-Wrdzgzs wants to merge 4 commits into
anomalyco:devfrom
HX-Wrdzgzs:tool-result-encode-fallback

Conversation

@HX-Wrdzgzs

@HX-Wrdzgzs HX-Wrdzgzs commented Oct 30, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #58934

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

toolResultText called encodeJson on json tool results without a guard. ToolOutput.toResultValue produces { type: "json", value: undefined } when a tool returns undefined with no content parts (tool.ts:262), and Schema.encodeSync(Json) rejects a top-level undefined with Expected a JSON-serializable value. It now goes through Schema.encodeUnknownOption(Json) and falls back to String(value) — the same guard schema/messages.ts:123 already applies to text results. Values that are JSON still encode exactly as before, including the quoted form for strings.

LLM.compile had no catchCause, so that throw was a defect that unwound through SessionStep.attempt into SessionRunner.drain: nothing was sent, and the session never recovered. Compile now turns a non-interrupt cause into a typed AIError through the existing streamError helper, so one step fails instead of the whole session.

I cannot show that this is the same trigger as the large pasted-image reports in #58934, and there is no end-to-end reproduction here. What is shown is the lowering path above, and that a throw at compile time is currently fatal.

How did you verify your code works?

From packages/ai: bun run test → 1992 pass / 28 skip / 0 fail (2020 tests, 154 files); bun run typecheck → no type errors on both tsconfigs.

The two new tests assert the lowered strings ("undefined", '{"a":1}', '"abc"'), not just "does not throw". Reverting the shared.ts line makes them fail with Die(SchemaError(Expected a JSON-serializable value)). Two existing tests still pass unchanged: anthropic-messages.test.ts -t "prepares tool call and tool result messages" (a json tool result still lowers to '{"forecast":"sunny"}') and media.test.ts -t "rejects local file references as request errors" (a typed AIError is still surfaced, not swallowed).

Real-session evidence (added 2026-10-31)

A second, independent trigger for the same fatal path, from a real session. A context-pruning plugin rewrites each tool-result part and stores the whole result as a bare string:

{
  "type": "tool-result",
  "id": "call_575531866a004e69a5872af4",
  "name": "read",
  "result": "[已裁剪 · 原 238 token] 完整信息请点击 tm_fetch { ref: \"tm://runs/r-20261031-000247-c0bb87/steps/s0003/result\", access_token: \"<redacted>\", expire_at: … }",
  "providerExecuted": false
}

(The stub text is the plugin's, in Chinese; the access token is redacted. The plugin is @te-river/opencode-team-mode 1.9.5; a diagnostic build logged typeof part.result.type === "undefined" for this part.)

Arm Build Outcome
A desktop 2.2.3 + plugin 1.9.5 (stock) Failed to drain Session, SchemaError: Expected a JSON-serializable value, 4.18 s after process spawn
B desktop 2.2.5 + plugin with the shape fix the stuck production session resumes and the turn completes: idle_outcome=succeeded

Arm A, verbatim from the log (ses_e7278d6dbffeKZzMxPMByoGqy9):

timestamp=2026-10-30T15:56:44.495Z level=INFO  message="spawning process" ...
timestamp=2026-10-30T15:56:48.676Z level=ERROR message="Failed to drain Session"
  cause="SchemaError: Expected a JSON-serializable value
    at OpenAIChat.fromRequest
    at LLM.compile
    at SessionStep.attempt
    at SessionRunner.runStep
    at SessionRunner.drain"

Arm B: the affected session (ses_e72dcf6ceffdGeH4rfE7YGdhMa) had been left idle_outcome='failed' at 128 messages. After replacing only the plugin's pruning file, the same session resumed on 2.2.5 and finished at 133 messages with idle_outcome='succeeded'; it then kept running to 232 messages, with zero Failed to drain in the monitoring window.

Why the stock 2.2.5 build does not reproduce it on its own: the pruning layer only rewrites results once context passes 70%. Our 2.2.5 runs stayed below the threshold (prune_checked=313 / below=313 / pct=68%, and checked=33 / pct=35%), so no malformed part was ever written. On 2.2.3 the same plugin pruned 2,363 parts / 1.68 M tokens in one session. The trigger is "pruning fires", not the 2.2.3 → 2.2.5 difference.

The same plugin also wrote tool-call.input as a bare string; the fixed build keeps both envelopes (result: {type, value} and input: {…}), which is what toolResultText and the protocol adapters expect.

Verification status at this head

The bun run test / bun run typecheck numbers quoted above were taken on the original two-commit branch. This branch has since been rebased onto a66964e and extended with two commits from @Te-River's construct-side work, and bun is not available in my environment. At the current head, verification is tsc --noEmit -p packages/ai/tsconfig.json → exit 0, plus a static check that every existing assertion touching toolResultText / toResultValue still holds. The full suite needs CI.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@Te-River

Copy link
Copy Markdown

Your two changes match where I landed on a fork branch, so I won't duplicate them. Three pieces from that branch are not here (Te-River:json-tool-result, based on v2.2.0 — cherry-pick if useful):

  • 7d6f7eb009, 75f6bf9a2f close the construct side: ToolResultValue.make(value, "json") returns null for undefined, and ToolOutput.toResultValue goes through make, so the { type: "json", value: undefined } you traced at messages.ts:115 cannot be built. Guarding only the encoder leaves the next caller free to build it again.
  • 431f30d4b6 names the value: compile walks system/messages/tools/toolChoice/providerOptions before lowering and reports the first cycle, bigint, function or symbol as a path, e.g. messages[1].content[0].result.value.self (circular). That is the "the error carries no field path" defect in SchemaError: Expected a JSON-serializable value while compiling an OpenAI Responses request fatally kills the session #58934, and it is the piece that would identify the value behind the large-image failures your PR honestly says it cannot explain.
  • 75f6bf9a2f also makes the fallback diagnosable: undefined → "null", a non-encodable object → [unserializable tool result], rather than handing the model "undefined" or "[object Object]".

Happy to split these into a PR against your branch if that is easier than cherry-picking. I cannot open one from my account — this repository caps the number of open pull requests it will accept from it.

Protocol lowering called encodeJson on json tool results without a guard, so
`{ type: "json", value: undefined }` — what ToolOutput.toResultValue produces
when a tool returned undefined and produced no content parts — threw
`Expected a JSON-serializable value`. That throw is a defect: nothing reaches
the wire, and it unwinds through SessionStep.attempt into SessionRunner.drain,
so every later turn on that session fails the same way.

- toolResultText now sends json results through Schema.encodeUnknownOption and
  falls back to String(value), the same guard schema/messages.ts already applies
  to text results. Values that are JSON still encode exactly as before.
- LLM.compile converts a non-interrupt cause into a typed AIError through
  streamError, so a lowering bug fails one step instead of the whole session.
  Interrupts are re-raised untouched and an AIError already in the cause is
  returned as-is.
@HX-Wrdzgzs
HX-Wrdzgzs force-pushed the tool-result-encode-fallback branch from 28d420c to 8332a7b Compare October 31, 2026 11:19

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SchemaError: Expected a JSON-serializable value while compiling an OpenAI Responses request fatally kills the session

2 participants