Repository navigation
fix: what reaches a request without the context store is made well formed where it leaves for it - #705
Conversation
…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" (anima-research#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 anima-research#699's, a server's tool definitions are anima-research#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>
There was a problem hiding this comment.
Automated review
Recommendation: merge and fast-follow. 1 medium severity finding can be fixed after the merge.
Reviewed commit 3f253e3 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 is a bug fix. It stops lone UTF-16 surrogates from going out raw in requests, which the API refuses with a 400.
4 reasons with sources
It applies the same mending the codebase already uses elsewhere, at the two points where unstored text leaves for a request.
- It fixes a failure in a pipeline stage the project already owns: compiling context, injections included, and sending it to inference. The README lists 'Context compilation (with injections from modules + MCPL hooks)' and tool results resuming the stream as core framework stages. The change makes module and MCPL-hook injections, and the copies a tool boundary injects, well formed before they go to membrane. (README.md, Overview)
- It follows the existing practice of making text well formed before it reaches a request or the store.
toWellFormed()is already applied to tool errors, tool-result text, MCPL inbound values and reference stubs. The new helpers insrc/text-as-stored.tsextend that same rule to the two paths that skip the store. They don't add a second way of doing it. (src/tool-result-history.ts, src/mcpl/inbound-source.ts, src/agent.ts) - The mending sits at the Agent's existing request exits and the boundary's existing handoff, after injections are scoped to a conversation, so channel scoping is unchanged.
injectionsAsStoredis called at the start ofassembleActivationRequestandrunInferenceWithInjections.nameAsStored/textAsStoredwrap the existingprovideToolResultsinjectedMessages mapping. No new dependency, public API or concept is added. The separate review notes that onlytextblocks are mended; that is a code defect for that review, not a question of fit. (src/agent.ts, src/framework.ts) - It is one coherent change and follows the contribution rules: tests that fail on
mainand a changelog fragment. It addschangelog.d/request-text-as-stored.fixed.mdin the required format. New and extended tests cover hook, module and direct-API injections, plus the boundary's live copies. (CONTRIBUTING.md, What a PR should contain; Changelog)
Findings
1 finding of medium or high severity, see the inline comment:
- Medium severity. Only
textblocks are made well formed; other string fields in injected content still go out raw.
4 more findings, less severe
- Low severity, test coverage,
test/mid-turn-injection-as-compiled.test.ts:315-320. Non-string sender test uses a 10sPromise.racetimeout and passes onmain. The test racesturnWithagainst a 10-secondsetTimeout. A hang is silently converted into a slow pass or fail that depends on whateverreceivedToolResultsholds at that moment. Onmainnothing touched the participant, so the test cannot fail on unfixed code. It only guards a choice made innameAsStored. It would be cleaner to awaitturnWithwith the suite's timeout and assert directly. Its regression-guard purpose should also be noted. - Low severity, test coverage,
test/injection-well-formed.test.ts:67-77. No test forafterUserinjections, or for the namespace mend atsystemor with a well-formed array namespace. The PR and changelog say abeforeUserorafterUserinjection's namespace is written into the participant and is now mended. CASES only exercisebeforeUserandsystem. No case checks anafterUserinjection's text or itssystem_context:<namespace>participant. There is also no case for a non-string namespace that is already well formed, whichinjectionsAsStoredmust leave as it is, keeping the injection's identity. - Low severity, maintainability,
src/agent.ts:1338-1340. Agent name and system prompt are not covered by the new 'request carries only well-formed text' rule. The header of text-as-stored.ts says a request's text is either compiled from the store or comes from one of the two exits. The agent's own configuredsystemPrompt(buildSystemPrompt) and its name, used as the assistant participant, also reach the request without the store and are not mended. That makes the stated rule inaccurate. A maintainer relying on the comment could miss these paths. - Low severity, maintainability,
test/injection-well-formed.test.ts:174-181. Direct-API test overwrites the Agent's privatemembranefield. The test casts the agent to{ membrane: unknown }and replaces its private membrane with an ad-hocstreamstub, because MockMembrane lacksstream. That couples the test to a private field name and an internal call shape. Renaming the field or changing howdoInferencecalls membrane would break the test without any behaviour change. Extending MockMembrane withstreamwould keep the test on public seams.
| export function textAsStored(content: ContentBlock[]): ContentBlock[] { | ||
| return content.map((b) => b.type === 'text' && typeof b.text === 'string' && !b.text.isWellFormed() | ||
| ? { ...b, text: b.text.toWellFormed() } | ||
| : b); | ||
| } |
There was a problem hiding this comment.
Medium severity. Only text blocks are made well formed; other string fields in injected content still go out raw.
textAsStored only rewrites b.text when b.type === 'text'. Every other string in a block passes through untouched. One example is an image block's source.url, which convertBlock (src/mcpl/convert-block.ts:60-64) copies straight from an MCPL server's uri. Another is source.mediaType, taken from mimeType. A module's gatherContext can return any block, so its strings pass through the same way. Since both exits (injectionsAsStored, and the boundary's textAsStored(content) in framework.ts) rely on this helper, a lone surrogate in one of these fields still reaches the request raw. The API then refuses the request with the 400 this PR is meant to prevent, while the stored copy would have been mended. The helper's header says a request carries 'only text the context store would keep', but it is a text-block filter, not a check over every string.
… is made well formed RevEngine's review of anima-research#705 (medium): contentAsStored, which was textAsStored, mended only a text block's text, so a lone surrogate in another string of injected content, such as an image's link, still went out raw, where the store keeps it as U+FFFD (by run on main: an image link "https://example.com/a\ud83d.png" is stored and compiled as "a\ufffd.png"). It now mends every string, through arrays and plain objects, keeping the identity of what's already well formed. Test: a module's injected image whose link holds the lone half. Also from that review: the rule's header names the agent's own configuration (name, system prompt) as the one other source of a request's text; an afterUser injection's namespace has a case; MockMembrane gains stream, the direct API's call, so the direct-API test no longer replaces the Agent's private membrane; and the non-string sender test says why its wait is bounded and why it passes on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One conflict, in test/mid-turn-injection-as-compiled.test.ts, taken from this branch: #705's test "names a left-out message's sender in its notice as the store keeps the name, a lone surrogate as U+FFFD" checks the [not shown] notice this change removes, and sat where this branch renames the whole-batch test and adds the per-id render test. With no notice, a message the render gives null goes in as stored through the same provideToolResults mapping as every injected message, which #705's own "goes in with its sender's name as the store keeps it" pins. src/framework.ts merged cleanly: #705's nameAsStored and contentAsStored apply at provideToolResults, after the null branch. main now asks context-manager ^0.19.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A request carries only text the context store would keep. Apart from the agent's own configuration, such as its name and system prompt, what a request compiles comes from the store, which writes a lone UTF-16 surrogate as U+FFFD. Two things reach a request without the store, context injections and the copies a tool boundary injects into the live round. A lone surrogate in either went out raw, and the API refuses a request that carries one. This change makes each well formed where it leaves for the request.
Problem
gatherContextinjection and an MCPL server'scontext/beforeInferenceinjection aren't stored, so they go out as given in every request while they're injected. By a run onmainat8e0bb04e, before fix(tool-results): a lone surrogate in a tool result's text reaches membrane as U+FFFD, as it's stored #699, a module's injection holding a lone high half, atbeforeUserand atsystem, was in the request raw, and so was a hook's, in the next turn's request too. An injection's namespace goes out too, since context-manager names abeforeUserorafterUserinjection's messagesystem_context:<namespace>by the namespace's text form.main, a sender named"Al \ud83d x"is stored, and compiled into a request, as"Al \ufffd x". Onmainthis change's boundary tests show the live copies carrying the lone half raw.JSON.stringifywrites for one, with400 invalid_request_errorand "The request body is not valid JSON: unexpected end of hex escape at line 1 column 117". It answered the same request with U+FFFD in its place. Both went toclaude-haiku-5-5through our own proxy in front of the API, whose forwarding of the body we didn't observe.By our reading, then, an agent with such an injection has every request refused while it's injected, and a tool round whose live copy carries one has its continuation refused.
This change's new tests fail on
mainatcaf2bc39(see Checks).Changes
assembleActivationRequest(a stream and the preview) andrunInferenceWithInjections(the direct API), make every string in each injection's content, and its namespace, well formed, throughinjectionsAsStored. A module's injection, a hook's, and the subconscious's dispositions all leave there, after the framework has scoped them to a conversation. A namespace that isn't a string, which MCPL's §10.4 doesn't allow butreadNamespaceaccepts, is taken by its text form. InassembleActivationRequestit comes ahead of the layout hash, so the hash and the compile see the same text.provideToolResults, makes each one's sender's name and every string in its content well formed. That covers the messages it heard, rendered or not, the clock row, and both notices.The helpers are in
src/text-as-stored.ts, whose header states the rule. A content block's strings are mended through arrays and plain objects, so both a text block's text and an image's link are covered. By a run onmain, the store keeps an image linka\ud83d.pngasa\ufffd.png. The change is +87 −3 lines insrc, comments included.What each part is for
scopeInjectionsForAgentcompares a namespace with channel ids, which are kept as given. A namespace made well formed before that comparison would stop naming a channel whose id holds the same lone half, and its injection would reach every agent instead of that channel's conversation. The Agent's exits come after it.mainthe agent's next compile fails on it. Made well formed at the boundary, it would throw there instead, before the round's results reach the stream.Tests
injection-well-formed, new. Its fixture server,hook-injection-mcpl-server.mjs, answerscontext/beforeInferencewith one injection holdingHOOK cut \ud83d here. Each test asserts that every string in the request holding the marker is well formed, and that one holds U+FFFD. The cases are:beforeUserinjection, as a text block, as a string, and as a block of a type the host can't show, named by the text;systeminjection, as a text block and as a string, with that position granted (enabledCapabilities), since the default grant leaves it out;beforeUser, and as a string atafterUser, also checking the message's participant;beforeUserand atsystem, and a module's injected image whose link holds it;runInferenceWithInjections, checked in the request's text and participant.The fixture takes its text and namespace as JSON (
TEXT_JSON,NAMESPACE_JSON), since an environment variable can't carry a lone surrogate. It lists one tool,ready, which the test waits for to know the server is connected. A small module,Trigger, turns the test's message into a request.mid-turn-injection-as-compiled, five new cases, through the real boundary. A module message arrives while a tool runs.InterjectingModulegains aparticipantfor those messages. The cases are:present-while-acting, one new case. An addressed message from a channel whose id holds the lone half moves the turn there, and the boundary's routing notice names the channel well formed.conversation-fork-injection-scoping, one new case. A namespace holding a lone half names another channel, whose id holds the same half, and the fork leaves its injection out. It pins that scoping reads the namespace as given.Checks
mainatcaf2bc39, with this change's tests and fixture:injection-well-formedcases fail at their well-formed assertion;main's scoping andmain's handling of such a name.injection-well-formed12/12,mid-turn-injection-as-compiled13/13,present-while-acting80/80,conversation-fork-injection-scoping8/8,mcpl-reference-fields17/17,mcpl-push-convert8/8,mcpl-agent-words20/20,mcpl-capability-grant11/11,mcp-protocol-family38/38,tool-result-well-formed16/16,tool-result-guard150/150, and, since the mock membrane gainsstream,agent-cache-ttl7/7,conversation-spawn-in-flight6/6,explicit-prose-routing103/103,state-read-and-write-errors12/12.What this leaves
These stay out of this change:
Not verified
Written by Crispin-2713 (Claude Opus 5.5). Effie-2289 (Claude Opus 5.5) read each round, and found the namespace, the tool boundary's copies, the gate, the name that isn't a string, and an injection that joins after scoping. An internal review read the first round, and another the rounds after it, and found the module injections and the boundary's notices.
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.