Skip to content

fix: what reaches a request without the context store is made well formed where it leaves for it - #705

Merged
nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/request-text-as-stored
Oct 22, 2026
Merged

nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/request-text-as-stored

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

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

  • Context injections. A module's gatherContext injection and an MCPL server's context/beforeInference injection aren't stored, so they go out as given in every request while they're injected. By a run on main at 8e0bb04e, 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, at beforeUser and at system, 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 a beforeUser or afterUser injection's message system_context:<namespace> by the namespace's text form.
  • A tool boundary's live copies. A boundary stores what arrived during a tool round and injects it into the live round. It sends each message's sender's name as queued, and, where the strategy can't render a message, its queued text. It also injects two notices it builds itself, one naming the sender of each message it leaves out and one naming the channel the turn moves to. The store mends each of these when it stores them, but the live copies went as built. By a run on main, a sender named "Al \ud83d x" is stored, and compiled into a request, as "Al \ufffd x". On main this change's boundary tests show the live copies carrying the lone half raw.
  • What the API does with that. On Oct 22 the API refused a request whose text held a lone high half, sent as the JSON escape JSON.stringify writes for one, with 400 invalid_request_error and "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 to claude-haiku-5-5 through 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 main at caf2bc39 (see Checks).

Changes

  • An Agent's two request exits for injections, assembleActivationRequest (a stream and the preview) and runInferenceWithInjections (the direct API), make every string in each injection's content, and its namespace, well formed, through injectionsAsStored. 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 but readNamespace accepts, is taken by its text form. In assembleActivationRequest it comes ahead of the layout hash, so the hash and the compile see the same text.
  • The tool boundary, as it hands its injected copies to 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 boundary's gate, which keeps a message named as the agent out of the live round, compares the name as the boundary now sends it.

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 on main, the store keeps an image link a\ud83d.png as a\ufffd.png. The change is +87 −3 lines in src, comments included.

What each part is for

  • Two exits rather than each place text is built. Apart from the agent's configuration, a request's text is either compiled from the store or comes through one of these two. Mending at the two exits covers a module's injection, a hook's, a notice the boundary builds, and any later notice or lane, without each having to remember.
  • The namespace after scoping. scopeInjectionsForAgent compares 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.
  • The gate on the name as sent. A name that becomes the agent's own once well formed would otherwise pass the gate and go into the live round as the agent's own turn.
  • A sender's name that isn't a string passes as it did. A module without types can store one, and on main the 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, answers context/beforeInference with one injection holding HOOK 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:

    • a hook's beforeUser injection, as a text block, as a string, and as a block of a type the host can't show, named by the text;
    • a hook's system injection, as a text block and as a string, with that position granted (enabledCapabilities), since the default grant leaves it out;
    • a hook's namespace holding the text, as a string and as an array at beforeUser, and as a string at afterUser, also checking the message's participant;
    • a module's injection, at beforeUser and at system, and a module's injected image whose link holds it;
    • injections given straight to an Agent's 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. InterjectingModule gains a participant for those messages. The cases are:

    • its sender's name holds the lone half, checked in the live copy and against the next request;
    • its text holds it, with the strategy's render taken away;
    • it's left out of the live round, and the boundary's notice names its sender;
    • its sender's name, well formed, is the agent's own, and it's kept out of the live round;
    • its sender's name isn't a string, and the round's results still reach the stream.
  • 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

  • Red-first. On main at caf2bc39, with this change's tests and fixture:
    • all 12 injection-well-formed cases fail at their well-formed assertion;
    • the four boundary cases for a name, unrendered text, the left-out notice and the gate fail on behaviour, as does the re-pin case;
    • the scoping case and the case of a name that isn't a string pass there. They pin that this change keeps main's scoping and main's handling of such a name.
  • Removals. Each piece taken out on its own fails only its own cases:
    • the activation exit, every framework injection case;
    • the direct API's exit, its case;
    • an injection's strings, its content cases, and its namespace, the namespace cases, the array form alone failing without the text form;
    • a text-only filter in place of every string, the image-link case alone;
    • the boundary's name and its content, their cases, and the content removal also fails the left-out and re-pin notices;
    • the gate's name, its case, and the guard for a name that isn't a string, its case;
    • the namespace mended before scoping instead of after, the scoping case.
  • Focused runs at this head, on Linux with Node 24.22.0:
    • the typecheck is clean;
    • injection-well-formed 12/12, mid-turn-injection-as-compiled 13/13, present-while-acting 80/80, conversation-fork-injection-scoping 8/8, mcpl-reference-fields 17/17, mcpl-push-convert 8/8, mcpl-agent-words 20/20, mcpl-capability-grant 11/11, mcp-protocol-family 38/38, tool-result-well-formed 16/16, tool-result-guard 150/150, and, since the mock membrane gains stream, agent-cache-ttl 7/7, conversation-spawn-in-flight 6/6, explicit-prose-routing 103/103, state-read-and-write-errors 12/12.

What this leaves

These stay out of this change:

Not verified

  • What our proxy forwarded for the refused request. The refusal names a JSON escape, and the control went through the same path.

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-seru account.

…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>

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 in src/text-as-stored.ts extend 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. injectionsAsStored is called at the start of assembleActivationRequest and runInferenceWithInjections. nameAsStored/textAsStored wrap the existing provideToolResults injectedMessages mapping. No new dependency, public API or concept is added. The separate review notes that only text blocks 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 main and a changelog fragment. It adds changelog.d/request-text-as-stored.fixed.md in 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:

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 10s Promise.race timeout and passes on main. The test races turnWith against a 10-second setTimeout. A hang is silently converted into a slow pass or fail that depends on whatever receivedToolResults holds at that moment. On main nothing touched the participant, so the test cannot fail on unfixed code. It only guards a choice made in nameAsStored. It would be cleaner to await turnWith with 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 for afterUser injections, or for the namespace mend at system or with a well-formed array namespace. The PR and changelog say a beforeUser or afterUser injection's namespace is written into the participant and is now mended. CASES only exercise beforeUser and system. No case checks an afterUser injection's text or its system_context:<namespace> participant. There is also no case for a non-string namespace that is already well formed, which injectionsAsStored must 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 configured systemPrompt (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 private membrane field. The test casts the agent to { membrane: unknown } and replaces its private membrane with an ad-hoc stream stub, because MockMembrane lacks stream. That couples the test to a private field name and an internal call shape. Renaming the field or changing how doInference calls membrane would break the test without any behaviour change. Extending MockMembrane with stream would keep the test on public seams.

Comment thread src/text-as-stored.ts Outdated
Comment on lines +16 to +20
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>
@nissa-seru
nissa-seru merged commit e4d1e46 into anima-research:main Oct 22, 2026
12 checks passed
@nissa-seru
nissa-seru deleted the fix/request-text-as-stored branch October 22, 2026 22:56
nissa-seru pushed a commit that referenced this pull request Oct 24, 2026
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>
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.

1 participant