Repository navigation
fix(tool-results): a lone surrogate in a tool result's text reaches membrane as U+FFFD, as it's stored - #699
Merged
nissa-seru merged 2 commits intoOct 22, 2026
Conversation
…embrane as U+FFFD, as it's stored A tool result's text was taken from the result as it was: a text block's text in both converters, a reference's stub (whose name keeps a lone half through sanitizeLabel), an error result's text, the error text of a failure the tool doesn't flag, and the direct Agent API's error text. The request sent to membrane then carried the lone half while the context stored U+FFFD. With the tool-result guard on, the stored message couldn't be read back, and an agent given a text block or an error result sent no request on its next turn, and stop() threw (anima-research#695). Each of those texts is now made well formed where it's taken (toWellFormed, which keeps the length), and the stub where referenceStubOrNull builds it. The direct Agent API's results are unchecked, so there an error that isn't a string is left as it was, and a text block's text is read once. Fixes anima-research#695. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Automated review
Recommendation: merge. The review found nothing of medium or high severity.
Reviewed commit 0e62d7a with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.
Does this change belong in the project?
It fits the project. This bug fix belongs in the project. The framework already makes some text well formed with toWellFormed() before it reaches membrane, and this change applies that to the tool-result text it missed, inside the existing converters and a tests-plus-changelog PR shape.
4 reasons with sources
- It fixes existing behavior: what gets sent to membrane no longer differs from what gets stored, and guard-stored messages can be read back again. No new feature, interface or dependency is added. It extends the sent copy = stored copy rule the framework already keeps to text blocks, reference stubs and error text. (src/framework.ts (tool-result copies are kept byte-matched to what membrane is sent))
- It repeats a pattern the codebase already uses rather than adding a second mechanism. String data is already made well formed with
toWellFormed()in toolResultDataToHistoryString (#698), and inbound-source does the same. This PR applies that call at the remaining places where text is taken from a tool result. (src/tool-result-history.ts:88, src/mcpl/inbound-source.ts:190) - The edits stay in the modules that already own this work. The history converter, the native converter, the reference-stub builder (referenceStubOrNull) and the direct Agent API's buildToolResultMessages each change at the point where they read the text. (src/tool-result-history.ts, src/mcpl/references.ts, src/agent.ts, src/framework.ts)
- It brings a test file that fails on main and a changelog fragment, as CONTRIBUTING asks. It adds test/tool-result-well-formed.test.ts, reported as failing first on main, and changelog.d/tool-result-lone-surrogate.fixed.md. Coverage gaps in those tests are left to the code review. (CONTRIBUTING.md (What a PR should contain; Changelog))
Findings
No findings of medium or high severity.
2 more findings, less severe
- Low severity, test coverage,
test/tool-result-well-formed.test.ts:108-142. Direct Agent API test covers only the error-result branch.buildToolResultMessageshas two branches that this change affects: the isError branch (the new typeof/toWellFormed code) andnonErrorResultText(an unflagged failure's error text and text blocks). The only direct-API test feeds{ isError: true, error: LONE }. Nothing checks the non-error branch through the direct API, or that a non-stringerroris still passed on unchanged as the new comment promises. Add cases for an unflagged failure and for a non-string error value. - Low severity, test coverage,
src/mcpl/references.ts:448. Change to nested reference stubs inside JSON data is untested. Making the stub well formed inreferenceStubOrNullalso changeshistoryJson's output for a reference nested in other data: the lone half used to appear as a six-character JSON escape and now appears as U+FFFD, five characters shorter. The PR description says so.referenceStubOrNullis also used by the push lanes (convert-block.ts), so their stubs change too. None of the new tests covers a nested reference or the push lane, so the change to the stored length and spans for that shape is unchecked.
…I, is passed on as it was RevEngine's review of anima-research#699 (low): the direct API's typeof check on its error, which passes a non-string error on as main does, had no test, so calling toWellFormed() on any error value would have passed. The two direct-API tests now share one round's harness. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru
pushed a commit
that referenced
this pull request
Oct 22, 2026
…rmed where it leaves for it A request carries only text the context store would keep. What it compiles comes from the store, which writes a lone UTF-16 surrogate as U+FFFD, and a provider refuses a request carrying one: by a live call, 400 "The request body is not valid JSON: unexpected end of hex escape" (#695). Two things reach a request without the store, and a lone half in either went out raw: - Context injections, a module's or a hook's, in every request while they're injected, text and namespace alike (context-manager names a beforeUser or afterUser injection's message system_context:<namespace>). They're now made well formed where an Agent compiles them into a request: assembleActivationRequest, for a stream and the preview, ahead of its layout hash, and runInferenceWithInjections, for the direct API (injectionsAsStored). A namespace that isn't a string, which the hook orchestrator keeps when it can be read as text, is taken by its text form. Scoping to a conversation still runs first, by the namespace as given, since channel ids are compared as given. - The copies a tool boundary injects into the live round: each heard message's sender's name, its text where the strategy can't render it, and the boundary's own left-out and re-pin notices. The store mends each when it stores it, but the live copies went as built. They now take their name and text as the store keeps them as they go to provideToolResults (nameAsStored, textAsStored). The boundary's gate, which keeps a message named as the agent out of the live round, compares the name as sent. A name that isn't a string passes as it did, since thrown at the boundary it would keep the round's results from the stream. The helpers and the rule are in src/text-as-stored.ts. Tool results are #699's, a server's tool definitions are #700's, and inference a server starts itself is left as it is. Tests, each red on main at its exit: injection-well-formed (hook injections of each shape, namespaces as a string and as an array, a module's beforeUser and system injection, and injections given straight to an Agent), mid-turn-injection-as-compiled (a sender's name, unrendered text, the left-out notice, the gate, and a name that isn't a string), present-while-acting (the re-pin notice), and conversation-fork-injection-scoping (scoping by the namespace as given). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A tool result whose text holds a lone UTF-16 surrogate went to membrane with the lone half raw, while the context stored U+FFFD in its place. With the tool-result guard on, the message the context stored couldn't be read back at all. This change makes each text a tool result's copies carry well formed where it's taken from the result, so the copy sent and the copy stored carry the same text.
Problem
String.prototype.toWellFormed()turns a lone surrogate into U+FFFD. The context store does the same when it writes a message. Onmainat8e0bb04e, the framework took a tool result's text as it was in these places:tryHistoryStringFromContentArray) and in the native one (tryNativeToolResultContent), which sends the blocks when the result carries an image;namepassessanitizeLabel, which keeps a lone half;storedToolResultText), and the error text of a failure the tool doesn't flag as an error (nonErrorResultText);buildToolResultMessages).So for that round, the request sent to membrane carried the lone half, and the stored copy held U+FFFD.
framework.tskeeps the two byte-matched on purpose, because the window stores what membrane sends. By the notes atsrc/modules/workspace/text.ts:17and in membrane'ssrc/utils/image-media.ts, a model provider can refuse a request that carries a lone surrogate. No such request went to a provider here.With the tool-result guard on (
toolResultGuard, off by default, and per agent throughagent_settingstool_result_guard), the harm is worse. Run onmainwith a text block or an error result holding a lone half:Deserialization error: unexpected end of hex escape, from context-manager'sMessageStore.getInternal; it throws first in the guard's settlement, whose message edit can't read back what it stored;stop()throws the same error.This change's new tests fail on
mainat8e0bb04e(see Checks).Changes
toWellFormed():referenceStubOrNullbuilds it, which every stub of a tool result passes through;storedToolResultText;nonErrorResultText;buildToolResultMessages.String data is already sent as its text, made well formed (
toolResultDataToHistoryString, from #698).What each part is for
toWellFormed()keeps the length. The cut at the cap, a block's span in the stored copy and the slots a stored copy keeps are computed from the well-formed text, the same way as before. By our reading, two outcomes can move: a cut that falls just after a lone high half no longer steps back over it, since it's U+FFFD now (safeSlice); and a reference nested in other data, whose lone half JSON wrote as a six-character escape, now shows U+FFFD in its stub, so that JSON is five characters shorter.namecan carry a lone half. Made well formed inreferenceStubOrNull, it is so in both converters, as a bare reference's whole data, and in the guard's kept text (rfc005TextOf).toMembraneToolResult's two fallbacks, for an error result and for any other. Both of its callers pass the copybuildStoredToolResultContentmade for every result, so, by our reading, neither fallback is reached from them.+40 −15 lines in
srcwith comments, +21 −13 without.Tests
tool-result-well-formed, new. Each test gives a tool result with a lone high half,cut \ud83d here. Each asserts that every text sent to membrane is well formed and holds U+FFFD, and that the copy sent is the copy stored. Where the result is sent as native blocks beside an image, it asserts that the stored copy holds each text sent. Each of these shapes runs with the guard off and with it on:One more test gives an error result through the direct Agent API, with the guard on, since the guard is what stores that API's results. Another gives an error that isn't a string through the direct Agent API, with the guard off, and asserts it's sent as it was, since that API's results are unchecked.
It protects against a text taken from a result raw, which leaves the copy sent and the copy stored different.
Checks
mainat8e0bb04e, with this change's test file as of0e62d7a6: all 15 tests fail. With the guard off, each shape fails at "the text sent is well formed". With the guard on, each shape and the direct API's test fail withDeserialization error: unexpected end of hex escape. The later test of an error that isn't a string pins whatmainalready does, by our reading oftruncateForHistory, so it isn't a red-first test.tool-result-well-formedpasses 16/16 andtool-result-guard150/150. At0e62d7a6, whosesrcthis head keeps unchanged:tool-result-well-formed15/15,tool-result-history22/22,unconvertible-tool-result11/11,tool-result-guard150/150,mcp-modern-framework18/18,tool-result-spill23/23,tool-result-cap-fit13/13,tool-result-reference-uri21/21,mcp-tool-result-normalize21/21.mainat8e0bb04eand at0e62d7a6, for a text block, string data and an error result, each with the guard off and on. Onmain, a text block or an error result with the guard on leaves a stored message that can't be read back, a next turn that sends no request, and astop()that throws. At0e62d7a6every case runs normally.Not verified
What this leaves
main, a message edit stores a lone half as a JSON escape that chronicle's replay can't parse, and by our reading the guard's settlement edit is that write (An edit whose JSON escapes a lone surrogate half makes its whole state unreadable, for good chronicle#52). This change closes the tool-result route.Fixes #695.
Written by Crispin-2713 (Claude Opus 5.5). Effie-2289 (Claude Opus 5.5) read each round and approved it. Our internal review read this change before its last round; this head carries the fixes it led to, and Effie-2289 read that round.
Description checked and posted by Fiona-2757 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.