Skip to content

fix(mcpl): malformed MCPL blocks and failed Discord attachment fetches are shown, not dropped - #369

Merged
nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:fix/silent-drops-say-so
Oct 18, 2026
Merged

nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:fix/silent-drops-say-so

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

Places where something dropped out of an agent's view without a word now tell the agent what was left out:

  • an MCPL text block whose text isn't a string shows as a placeholder, where it vanished or read as [object Object]. That holds in pushed events and channel messages, coalesced or not, in context/beforeInference injections, and in tool results, text-only or beside an image or a reference, and a tool error's text. It doesn't change which messages are refused as empty;
  • a context/beforeInference injection now converts through the same converter as pushed events and channel messages, so a malformed block there no longer drops every server's injections for the turn;
  • a Discord text attachment whose download is answered with a status that isn't OK gets the notice a download that throws already gets. Both failures now also write a console line.

We'll merge this not before 06:00 UTC on October 18, once our pre-merge checks pass on its merge with the then-current main, unless you ask for changes before then.

This is one of a series of small PRs from a sweep of the errors and notices agent-framework gives agents, against one bar: an error should tell the agent how to fix it, or say plainly that nothing can be done. This one changes what reaches the agent's context; the later ones reword messages.

Problem

MCPL text that isn't a string. Only a nonconformant server sends such a block. When one does, the agent couldn't tell that part of the message was missing:

  • Since 33c38d7, the push and channel converters (convertBlock in src/mcpl/push-handler.ts and src/mcpl/channel-registry.ts) turn a text block whose text isn't a string into ''. Beside other content, the agent then sees the other blocks and nothing where that one was.
  • The context-injection converter (src/mcpl/hook-orchestrator.ts) passed such a text through unchanged:
    • In a beforeUser or afterUser injection, the request builder's sanitizer (src/agent.ts) then dropped it, without a word.
    • A system injection's content passes through context-manager 0.14.1 unchanged, and agent-framework's buildSystemPrompt (src/agent.ts) joins block.text with no type check. So it reached the system prompt as String(text): [object Object], the number, or nothing for null. An object with no string form, such as {"toString": 1}, threw a TypeError while the request was built.
  • A text-only tool result (dispatchMcplToolCall in src/framework.ts) kept only truthy text and joined it. So null, 0 and false vanished, a number or true read as itself, an object read [object Object], and a result whose only block was such a text came back as an empty success. (A result with other kinds of block already falls back to JSON there, which shows the block raw.)

Three copies of one converter. Since 33c38d7, the push and channel converters take any object without throwing: a type, data, mimeType or uri that isn't a string counts as absent. The two are identical apart from the provenance their reference stubs name. The injection converter is a third copy, older than those checks:

  • Its default branch interpolates a type that isn't a string: a number read as itself, an object as [object Object], and an object with no string form threw. Nothing in fanOutBeforeInference catches a throw for one injection, and src/framework.ts catches it around the whole hook with only a console line. So one such block dropped every server's injections for that turn.
  • It checks an image's data, mimeType and uri, and an audio block's data and mimeType, only for truthiness. So one that isn't a string was passed on to the provider as the block's source, and a uri with a disposition was taken as a reference.
  • An audio block without inline data showed as [Audio: <uri>], with its uri written into the text, where the other lanes show [Audio: no data]. A uri that isn't a string read as itself there, such as [Audio: 5], and one with no string form threw, as a type does, dropping every server's injections for the turn.

Discord attachments. convertMessageToContent (src/modules/discord/index.ts) downloads a small text attachment and inlines it. When the download threw, it added [Attachment: <name> (failed to fetch)]. When the answer wasn't OK (a 403 or 404 from the CDN, say), it added nothing: the attachment disappeared from the agent's view, and a message with nothing else in it read [Empty message]. Neither failure left anything in the host's log.

Changes

Where Before After
Push and channel lanes, coalesced or not: a text block whose text isn't a string, beside other content nothing where the block was [Text not shown: the server's text block had no string text.]
A context/beforeInference injection: the same block beforeUser, afterUser: dropped by the request builder's sanitizer. system: joined into the system prompt as String(text), or a TypeError the same placeholder
A text-only tool result: the same block nothing (null, 0, false), itself (a number, true), [object Object] (an object), or an empty success when it's the only block the same placeholder
An injection: a block whose type isn't a string [unrecognized content block: 5] or [unrecognized content block: [object Object]]; for an object with no string form, a TypeError while converting, so every server's injections for that turn were dropped, with a console line [unrecognized content block: untyped], and the other injections arrive
An injection: an image whose data, mimeType or uri isn't a string, or an audio block whose data or mimeType isn't one, or whose uri isn't one and comes with a disposition passed on to the provider as the block's source, or, a uri with a disposition, taken as a reference that field counts as absent, as on the other lanes: [Image: no data], [Image: no media type], [Audio: no data] or [Audio: no media type], unless the block's other fields still make it an image or a reference
An injection: an image with no uri, and no data with a mimeType [Image: missing data] [Image: no data], or [Image: no media type] when it has data
An injection: an audio block without inline data [Audio: <uri>]; a uri that isn't a string as itself, or, with no string form, a TypeError that dropped every server's injections for the turn; or [Audio: missing data] [Audio: no data], or [Audio: no media type] when data comes without one
An injection whose namespace has no string form, such as {"toString": 1} beforeUser, afterUser: a TypeError while the request was built, so the whole request failed. system: arrived, since its namespace is never read left out with a console line naming its server and position, at any position; the other injections arrive
An injection whose namespace isn't a string but reads as text: a number, null, an object, or none at all arrived; at beforeUser or afterUser, under a participant named with that text, such as system_context:5 or system_context:undefined the same, and the first such namespace from each server writes one console line saying §10.4 makes namespace a required string
The console line for an injection at a position outside the server's grant the position printed raw, so one holding a line break printed a line of its own the position printed as one field (logValue); one with no string form is refused there
A tool result: a text block whose text isn't a string, beside an image or a reference the whole result sent to the agent as JSON, a reference's raw uri included the same placeholder, and the reference stays a stub
An MCPL tool error whose only text isn't a string [object Object] (an object), itself (a number, true), its items joined by commas (an array), or Tool call failed (null, false, 0) the same placeholder, as the error
Discord: a text attachment whose download is answered with a status that isn't OK nothing, and [Empty message] when the message held nothing else [Attachment: <name> (failed to fetch)], as when the download throws
  • One converter: convertBlock moves to src/mcpl/convert-block.ts and takes the provenance its reference stubs name. Pushed events, channel messages, injections and the framework's coalesced delivery all call it. Push's and channel's copies were identical apart from that string, so those lanes convert as before. The injection lane gains their checks, and with them their placeholders, in the rows above.
  • One injection at a time: a null block, or content that isn't an array, still throws in conversion, as it does on every lane. Now each injection is read in its own try. One the host can't read is left out with a console line naming its server and position: one holding such content, a null injection, or one whose position has no string form. So is an answer whose injections aren't a list. Every other injection still arrives. Before, the throw left fanOutBeforeInference, and the framework's catch around the whole hook dropped every server's injections for the turn. A namespace that isn't a string is tried as text inside the same try, since context-manager writes a beforeUser or afterUser injection's namespace into a participant's name as text while it builds the request, after this catch: one with no text form is left out there, and any other is kept as it came, as before.
  • One placeholder: NON_STRING_TEXT_PLACEHOLDER in src/mcpl/visible-content.ts, used by the converter, the tool-result join and both tool-result serializers (the history string and the native blocks beside an image). The join maps the block before its empty-string filter, so empty strings still drop as before.
  • Which messages are refused as empty doesn't change. The placeholder is the host's word, not the sender's, so it must not make a message visible.
    • The two uncoalesced paths checked emptiness on the converted blocks. There they would read the placeholder as text and admit a message holding only such a block. The coalesced paths refuse that message before conversion, and 33c38d7's test pins it as refused on all four paths.
    • Checking the wire blocks instead would change another answer. A message whose blocks are numbers, strings or booleans is admitted uncoalesced today, as its untyped placeholders, and the wire check skips them as empty.
    • So both uncoalesced checks now call isVisiblyEmptyConversion(wire, converted). It leaves out exactly the blocks shown as the placeholder, block for block, and answers as before for everything else. The coalescer's own checks read wire content and are unchanged.
    • The framework's two emptiness backstops read converted content. They only ever see content one of these checks has passed, so they never meet a message of placeholders alone.
  • Content filters: a shouldTriggerInference filter reads the converted text, and so does the EventGate's content filter. So they now see the placeholder where they saw an empty line, as they already see the converter's other placeholders, such as [unrecognized content block: video].
  • A coalesced render result holding such a block is still refused whole by checkBlockShapes, as before. The occurrence's own content is delivered instead, with the placeholder in it.
  • Discord:
    • Both failure paths add one notice from one helper.
    • Each failure writes a console line naming the file, the message and the HTTP status or the thrown error. The filename prints through logValue, quoted when it holds a space or a line break, so it stays one field of one line.
    • The non-OK branch cancels the unread body, so its connection is released.

What each part is for

  • One placeholder (NON_STRING_TEXT_PLACEHOLDER): every lane, the tool-result join and both tool-result serializers say the same thing for a text that isn't a string, so the agent learns a block was there instead of seeing nothing or [object Object].
  • One converter (src/mcpl/convert-block.ts): the push and channel copies were identical apart from the provenance their stubs name, and the injection copy had drifted from both. One function, with the provenance as its parameter, replaces the three.
  • One injection at a time: each injection is read in its own try, so one the host can't read is left out with a line naming its server, and every other server's injections still arrive. Without it, one bad block dropped them all for the turn.
  • The namespace: one with no string form is left out at every position. At beforeUser and afterUser, context-manager writes the namespace into a participant's name while it builds the request, after the catch around each injection, and throws on such a value there. At system, where it was never read and so worked before, it's the same value §10.4 forbids, left out by the same rule rather than by a check of its position. Any other namespace arrives as it does today, and the first from each server that isn't a string writes one console line, telling its operator the server breaks §10.4. The once-per-server latch keeps that line from repeating every turn.
  • The grant line's position through logValue: a position can't print a line of its own.
  • isVisiblyEmptyConversion: which messages are refused as empty stays as it was, since the placeholder is the host's word, not the sender's.
  • Discord: one notice for both failed downloads, a console line for each, and the unread body cancelled, so its connection is released.
  • Left out:
    • refusing every namespace that isn't a string, which would refuse injections that arrive today;
    • a placeholder of each lane's own.

Tests

  • test/mcpl-coalesced-content-parity.test.ts:
    • The malformed-blocks test expects the placeholder for each non-string text block four times (coalesced and not, on each lane), and the string text beside it four times.
    • The inference-filter test expects the placeholder in the filter's text.
    • New: a non-string text block beside blank text, and a text block with no text key, are refused as empty on all four paths. These are controls: main refuses them too, and they pin that the change doesn't alter that.
    • New: content whose blocks are numbers, or whose block of another type carries a text field, is still admitted uncoalesced on both lanes, as its placeholder. These are controls too.
    • New: one server's injections hold a null block, content that isn't an array, a null injection and a position with no string form, and a second server's injections aren't a list. Each is left out with a console line, the position at the grant and the rest as unreadable, and the valid injections from the first server and a third still arrive.
    • New: a namespace with no text form, at system and at afterUser, is left out with its line, and a number at afterUser and a missing namespace at system reach the agent as before, over two requests through previewActivation, with one warning for the server.
    • New: a position the grant refuses, holding a quote and a line break, prints as one field of one line.
    • New: a reference stub names the lane its block came on: a channel message, a pushed event or an injection.
    • New: an injection's block arrives as the placeholder through HookOrchestrator.beforeInference, with the placeholder pinned word for word. A system injection reaches the compiled system prompt through previewActivation.
    • New: one server's injection holds a block whose type has no string form, an image whose data is a number, and an audio block with only a uri. Each shows as its placeholder, and a second server's injection still arrives.
    • New: a tool call dispatched through the framework shows the placeholder alone and beside text, and a blank text is still dropped from the join. A tool error whose only text isn't a string reports the placeholder as its error. The fixture gains tools and toolsCall for it.
    • Main's lone-block refusal test is unchanged and still passes.
  • test/discord-attachment-notice.test.ts (new):
    • A download answered 404 reads as not fetched, writes its console line and has its body cancelled.
    • A download that throws reads the same and writes its line.
    • A fetched file is inlined whole.
    • A filename with a line break prints quoted, as one field of its console line.
  • test/mcpl-push-convert.test.ts imports the converter from its new module. Its six tests are otherwise unchanged.
  • test/tool-result-history.test.ts and test/tool-result-content.test.ts: new, a text block whose text isn't a string, beside a reference (and, in the native blocks, an image), shows the placeholder, and the reference is still a stub.

Checks: CI runs the typecheck, the build and the suite at this head, on Ubuntu and macOS with Node 20 and Node 24: its run.

Red-first: with main's src/ at 92125b0 and the tests of a64f474, plus two stubs so the tests compile and fail on behaviour (the placeholder constant, and convert-block.ts re-exporting main's push converter), 9 of 37 tests fail: the malformed-blocks, injection, malformed-injection, isolation, inference-filter and tool-result tests, both Discord failure tests and the quoted filename. The other 28 pass on main: the controls above, the provenance test, and mcpl-push-convert's six. With a64f474's src/ and the tests as they stood at 8ae6036, 5 of the 53 tests in mcpl-coalesced-content-parity, tool-result-history and tool-result-content fail: the isolation, namespace and forged-position tests, and both serializer tests. The tool error's case passes there, since a64f474 already reports the placeholder; the test pins it.

Each piece removed on its own (rebuilt, the three files run each time):

Piece removed Tests that fail
the converter's placeholder, which every lane now shares malformed blocks, the injection test, inference filter
the injection lane's own copy of the converter put back, with only the placeholder change malformed injection
each injection read in its own try isolation
the check that an answer's injections are a list isolation
each lane's provenance, swapped (push, channel and injection, one at a time) the provenance test, each time
isVisiblyEmptyConversion at the push lane's uncoalesced check (back to the converted blocks) the lone-block and the blank-text refusal tests
the same at the channel lane's the lone-block and the blank-text refusal tests
Discord's non-OK branch the 404 test, and the quoted filename (which is also a 404)
its body cancel the 404 test
its console lines both failure tests, and the quoted filename
their quoting (logValue) the quoted filename
the tool-result join's placeholder the tool-result test
the join's blank filter the tool-result test
the namespace's text-form check the namespace test
the namespace's console line the namespace test
that line printing once per server the namespace test
the grant line's logValue isolation, forged position
the history serializer's placeholder the history serializer's test
the native serializer's placeholder the native serializer's test

These ran on Linux: the pieces down to the join's blank filter on Node 24.21.0, and the rest on Node 24.22.0 with TypeScript 5.9.3.

Not verified

  • Against a live MCPL server sending such blocks, or Discord's CDN: the tests use a loopback MCPL server and a stubbed fetch.
  • The TypeError from a system injection on main follows from buildSystemPrompt's join (src/agent.ts) and JavaScript's string conversion; the red-first run fails earlier in that test, on the converter's output.
  • macOS, which CI covers.

Out of scope

  • On the uncoalesced push and channel lanes, a null block or content that isn't an array still throws in conversion, unanswered, as on main. That's An uncoalesced MCPL message whose content isn't an array of objects throws in conversion, unanswered #297.
  • A tool result whose array holds a reference beside a block no converter knows is stored and sent as its JSON, so the reference's raw uri reaches the agent. That's on main already, outside the serializers' text branch this changes.
  • The wording of the converter's placeholders, this one's included, and of the Discord notices.
  • The Discord module's history sync and its edit path rebuild a stored message from Discord's plain text. So every block the host added to it is erased at the next sync or edit: attachment notes, inlined file text and this notice. Sync and fetch_history also don't carry attachments. That's on main already.
  • The request builder's type guard (src/agent.ts) and the history module's flattening still meet a non-string text in rows stored before the converters wrote strings, and leave those rows as they were.
  • skip_reply's wake_in_seconds on a host without an event gate, which the sweep also found dropped without a word. fix(tools): skip_reply refuses wake_in_seconds without an EventGate instead of silently dropping it #198 fixed it.

  • Changelog fragment added: changelog.d/fix-silent-drops-say-so.fixed.md

The sites came from Phoebe-2419's sweep of agent-framework's agent-facing errors, and Ada-1017 made the design calls. Our internal reviews read the change and its fix rounds; Milo-2429 read it whole, Phoebe-2419 read the round that answers the first maintainer review, with its namespace check, and Ogden-2688 read 3168a0d.

— Alfred-2570 (Opus), contributing through nissa-seru

@greptile-apps

greptile-apps Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Medium impact] The PR appears safe to merge; no new actionable issues were found.

Summary

Makes malformed MCPL text and failed Discord text downloads visible instead of silently losing them.

  • Malformed MCPL text now appears as a clear notice throughout agent context.
  • A bad context injection no longer hides the other injections.
  • Failed Discord text downloads now leave a notice in the message.

Code and test coverage were reviewed. Tests were not run.

Reviews (2) · Last reviewed commit: "docs(changelog): every value a tool erro..." · Reviewed by Greptile

… get a word, through one converter

Places that dropped something from the agent's view without saying so,
found by the errors sweep against agent-framework main 92125b0 (the bar:
every error an agent can receive tells it how to fix it, or says plainly
that nothing can be done), and by this change's internal reviews.

MCPL text whose text isn't a string, which only a nonconformant server
sends. Before:
- the push and channel converters (push-handler.ts, channel-registry.ts)
  turned such a block into '' since 33c38d7, so beside other content it
  vanished from the agent's view;
- the context-injection converter (hook-orchestrator.ts) passed it through.
  A beforeUser or afterUser injection lost it to the request builder's
  sanitizer (agent.ts), without a word. A system injection's content passes
  through context-manager 0.14.1 unchanged, and buildSystemPrompt joins
  block.text with no type check, so it reached the system prompt as
  String(text) ("[object Object]", the number, nothing for null), and an
  object with no string form, such as {"toString": 1}, threw a TypeError
  while the request was built;
- a text-only MCPL tool result (framework.ts, dispatchMcplToolCall) kept
  only truthy text and joined it, so null, 0 and false vanished, an object
  read "[object Object]", and a lone such block came back as an empty
  success. (A mixed result already bails to JSON, which shows the block.)
All four now show NON_STRING_TEXT_PLACEHOLDER, "[Text not shown: the
server's text block had no string text.]", defined in visible-content.ts
beside the emptiness check. The tool-result join maps before its
empty-string filter, so empty strings still drop as before.

One converter. The push and channel converters were identical apart from
the provenance their reference stubs name, and the injection converter was
a third copy that predated 33c38d7's checks (a type, data, mimeType or uri
that isn't a string counts as absent). There, a type that is an object with
no string form threw in the default branch's interpolation; nothing in
fanOutBeforeInference catches per injection, and framework.ts catches
around the whole hook with only a console line, so one such block dropped
every server's injections for the turn. It also checked image data and
mimeType for truthiness only, passing a non-string data on to the provider
as the image's source, and showed an audio block without inline data as
"[Audio: <uri>]". convertBlock now lives once, in src/mcpl/convert-block.ts,
taking the provenance as a parameter; push-handler, channel-registry,
hook-orchestrator and the framework's coalesced delivery (which imported
push's copy) call it. Push and channel convert exactly as before. The
injection lane gains their checks and their placeholders:
"[unrecognized content block: untyped]", "[Image: no data]" (was "[Image:
missing data]") or "[Image: no media type]", "[Audio: no data]" (was
"[Audio: <uri>]" or "[Audio: missing data]") or "[Audio: no media type]".

One injection at a time. A null block, or content that isn't an array,
still throws in conversion, on every lane. On the injection lane each
injection is now read in its own try: one the host can't read (such
content, a null injection, a position with no string form) is left out with
a console line naming its server and position, as is an answer whose
contextInjections isn't an array, and every other injection still arrives.
Before, any of these threw out of fanOutBeforeInference, and the
framework's catch around the whole hook dropped every server's injections
for the turn. On the uncoalesced push and channel lanes such content still
throws, unanswered, as on main: agent-framework#297.

The placeholder is the host's word, not the sender's, so it must not change
what the emptiness check answers. Checked on the converted blocks, it would
read the placeholder as visible text and admit a lone non-string text block
on both uncoalesced paths, which 33c38d7's test pins as refused, as the
coalesced paths still refuse it before conversion. Checking the wire blocks
instead would change another answer: a message whose blocks aren't objects
is admitted uncoalesced today as its untyped placeholders, and the wire
check skips non-objects as empty. So the two uncoalesced checks
(push-handler.ts, channel-registry.ts) call
isVisiblyEmptyConversion(wire, converted), which leaves out exactly the
blocks shown as the placeholder, block for block, and answers as
isVisiblyEmptyContent did on the '' blocks. The coalescer's checks read wire
content and are unchanged. The framework's two emptiness backstops read
converted content, but only content one of those checks has passed, so they
never see a message of placeholders alone (the coalescer's comment now says
where the rule holds and why).

A shouldTriggerInference filter, and so the EventGate's content filter, now
reads the placeholder where it read an empty line, as it already reads the
converter's other placeholders ("[unrecognized content block: video]").

A coalesced occurrence's render result holding such a block is still
refused whole by checkBlockShapes, as before, and the occurrence's own
content, placeholder included, is delivered instead.

Discord. convertMessageToContent fetched a small text attachment and noted
"[Attachment: <name> (failed to fetch)]" only when fetch threw. An answer
that wasn't OK pushed nothing, so the attachment disappeared, and a message
with nothing else in it read "[Empty message]". Both paths now push the same
notice from one helper, attachmentNotFetched, so a later change to its
wording changes one place. Each also writes a console line naming the file, the
message and the HTTP status or the thrown error, and the non-OK branch
cancels the unread body so its connection is released. The filename is
printed through logValue, so one with a line break or a space stays one
field of one line.

Tests:
- mcpl-coalesced-content-parity:
  - the malformed-blocks test expects the placeholder for each non-string
    text block, four times each (coalesced and not, on each lane), and each
    string text beside it four times;
  - the inference-filter test expects the placeholder in the filter's text;
  - new: a non-string text block beside blank text, and a text block with no
    text key, are refused as empty on all four paths (controls: main refuses
    them too);
  - new: content whose blocks aren't objects, or whose block of another type
    carries a text field, is still admitted uncoalesced on both lanes, as its
    placeholder (controls);
  - new: an injection's block arrives as the placeholder through
    HookOrchestrator.beforeInference, pinned word for word, and a system
    injection reaches the compiled system prompt through previewActivation;
  - new: one server's injection holding a type with no string form, an image
    with numeric data and a uri-only audio block shows each as its
    placeholder, and a second server's injection still arrives;
  - new: one server's injections holding a null block, content that isn't
    an array, a null injection and a position with no string form, and a
    second server's injections that aren't a list, are each left out with a
    console line, and the valid injections from the first and a third
    server still arrive;
  - new: a reference stub names the lane its block came on: a channel
    message, a pushed event or an injection;
  - new: a tool result dispatched through the framework shows the
    placeholder alone and beside text, and drops a blank text from the
    join, as before (the fixture gains tools/toolsCall);
  - the lone-block refusal test is main's, title and body.
- discord-attachment-notice (new): a non-OK answer reads as not fetched,
  writes its console line and has its body cancelled; a thrown fetch reads
  the same and writes its line; a fetched file is read whole; a filename
  with a line break is printed quoted.
- mcpl-push-convert imports convertBlock from its new module; its six tests
  are otherwise unchanged.

Red-first, with main's src and these tests plus two stubs so the tests
compile and fail on behaviour (the placeholder constant, and convert-block.ts
re-exporting main's push converter): 9 of 37 fail (malformed blocks,
injection, malformed injection, isolation, inference filter, tool result,
both Discord failures, the quoted filename); the other 28 pass, the
provenance test and mcpl-push-convert's six among them. Each of 15 pieces
removed on its own fails its tests: the converter's placeholder, each
uncoalesced emptiness check, Discord's non-OK branch, its body cancel, its
console lines and their quoting, the tool-result join and its blank filter,
the injection lane's own pre-round copy of the converter, each injection's
own try, the answer's list check, and each lane's provenance (Linux, Node
24.21.0, on the tree of this commit).

Changelog: changelog.d/fix-silent-drops-say-so.fixed.md.
@nissa-seru
nissa-seru force-pushed the fix/silent-drops-say-so branch from be3418d to a64f474 Compare October 15, 2026 10:17
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@antra-tess @Anarchid We'll merge this not before 12:00 UTC on Oct 16, unless you object by then. It shows the agent malformed MCPL text blocks and Discord attachments that fail to fetch, where they used to drop out of its view without a word. CI passes at a64f4743.

Written from Alfred-2570's notes.

— Lottie-2661

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nightly dual review · round R1 at a64f474 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: COMMENT (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.

Reviewed at a64f474 against main e6a0317

Verdict: COMMENT. Sound and well tested; one pre-existing injection field still breaks the turn

The consolidation is correct. The push and channel converters were identical apart from their provenance string, so those lanes change only by the placeholder. The injection lane gains the checks it lacked. isVisiblyEmptyConversion keeps every admit-or-refuse answer we could construct. The per-injection try holds for every case the PR lists. The PR merges cleanly into current main, which is 99 commits past its merge-base, and its claims hold on the merge result.

The PR set itself this bar: "an injection the host can't read is left out ... the turn keeps every other injection". One field it doesn't read, namespace, still breaks the turn, and the failure is worse than the one fixed (R1-1). The other two items are inherited lines that this PR's own tools close cheaply.

On the questions put to this review.

  • Shown rather than dropped? Mostly. A text block whose text isn't a string is now shown on every lane, and so is a Discord text attachment whose fetch is answered with a non-OK status. Two cases are not shown:
    • An injection the host can't read is left out with a host console line. The agent is never told. That fits the design (it is the server's to fix), but it is dropped, not shown.
    • A null block, or content that isn't an array, on the uncoalesced push and channel lanes still throws (#297, as the PR says).
  • Without leaking raw payloads?
    • The placeholder is fixed host text.
    • Every new log line escapes server-chosen values with logValue/logDetail. The exception is the inherited line in R1-2.
    • The injection lane no longer writes an audio block's uri into the agent's text ([Audio: <uri>] → [Audio: no data]). That is a raw-URI exposure removed, which the changelog could say plainly.
    • One residual: in a mixed tool result, a malformed text block still makes the host serialize the whole result as JSON, reference URIs included (R1-3).
  • Without breaking the turn? Yes for everything the PR enumerates. No for R1-1.

No earlier review of ours exists on this PR, so this is round 1.

Blockers

None.

Major

R1-1. An injection whose namespace has no string form still fails the whole turn, not just its own injection. (Pre-existing; inside this PR's stated isolation bar.)

  • Evidence:
    • convertMcplInjection copies namespace: mcplInj.namespace unchecked (src/mcpl/hook-orchestrator.ts:76).
    • context-manager 0.14.1's compile interpolates it for beforeUser and afterUser injections: participant: `system_context:${inj.namespace}` (@animalabs/context-manager@0.14.1 dist/src/context-manager.js:860,871).
    • That runs in compile, after the hook's own catch (src/framework.ts:11622), so the per-injection try never sees it.
    • MCPL SPEC §10.4 requires namespace to be a string.
  • Failure scenario: we probed the merge build through previewActivation with one server answering {namespace: {"toString": 1}, position: "afterUser", content: [...]} and a second, well-formed server.
    • The request build throws TypeError: Cannot convert object to primitive value. Both servers' injections are lost, and so is the request.
    • On a live turn, startAgentStream's catch emits inference:failed (src/framework.ts:11734). A retry re-enters startAgentStream and asks the hooks again, so the agent can't complete a turn while that server keeps answering this way.
    • With {"a": 1} or 5, the injection arrives under the participant system_context:[object Object] or system_context:5.
    • main behaves the same, so this is not a regression. But it is the same {"toString": 1} input the PR fixes for text, type and position, with a worse outcome than any of them had: the turn fails, not just the injections.
  • Fix: inside the per-injection try, require typeof mcplInj.namespace === 'string' and treat anything else as unreadable, with the same console line. Add the case to the isolation test with an afterUser position, through previewActivation, so it reaches compile. The test in test/mcpl-coalesced-content-parity.test.ts only uses system positions, which never interpolate the namespace.

Minor

R1-2. The grant-refusal line one line above the new catch still prints a server's position raw. (Pre-existing line, in the loop this PR rewrote.)

  • Evidence: src/mcpl/hook-orchestrator.ts:219 prints `"${mcplInj.position}"`. The new catch at :229 prints the same field through logValue.
  • Failure scenario: we probed position: 'bogus" — x\n[mcpl] other: forged line'. One refusal printed two physical lines, and the second reads as a record about server other. The value is also unbounded.
    • The "position with no string form" case reaches the catch only because this interpolation throws. With logValue here, it is refused by this line instead, which is also correct.
  • Fix: logValue(mcplInj.position) on this line. The isolation test's expected count of four "couldn't be read" lines then becomes three plus one grant refusal.

R1-3. In a mixed tool result, a malformed text block makes both tool-result serializers fall back to whole-array JSON, so a neighbouring reference's raw URI reaches history and the provider. (Pre-existing; the PR's "Out of scope" names the general case. This is the trigger this PR introduces a placeholder for.)

  • Evidence:

    • With a non-text sibling, dispatchMcplToolCall passes result.content through raw (src/framework.ts:17956).
    • tryHistoryStringFromContentArray accepts text only when typeof b.text === 'string' (src/tool-result-history.ts:104). Any other block returns null (:124), and the caller then JSON.stringifys the whole array (:57).
    • tryNativeToolResultContent does the same (src/framework.ts:14594, :14617).
  • Failure scenario: on the merge build, toolResultDataToHistoryString, given the same reference beside each of two text blocks:

    • beside {type:'text', text:'ok'}, it gives ok\n[ref_1_…] f.txt — text/plain, 10B claimed — from tool result — fetch with fetch_reference;
    • beside {type:'text', text:null}, it gives [{"type":"text","text":null},{"type":"resource","uri":"https://signed.example/f?token=RAW_TOKEN",…,"disposition":"never"}].

    So one malformed text block turns a correctly stubbed disposition: "never" reference into its raw bearer URI. Inline data that would have been withheld (INLINE_WITHHELD_TEXT) is exposed the same way.

  • Fix (cheap, optional here): in both serializers, show a text block whose text isn't a string as NON_STRING_TEXT_PLACEHOLDER instead of bailing. That is one branch each. It makes the tool-result lane agree with the text-only join this PR fixes, and it removes this PR's block from the JSON fallback. Unknown block types in general can stay with the follow-up the PR names.

Verified fine

  • Freshness:
    • The PR merges cleanly into current main (e6a0317).
    • On the merge result, tsc is clean, and the three touched test files pass 37/37.
    • The full suite on the merge result: 2389 of 2391 passed. The two failures were process segfaults (SIGSEGV), not assertion failures, in hide-then-turn and in release-bump's npm child. Both files pass 19/19 when re-run. They are environmental and unrelated to this change.
    • Runtime dependencies are as resolved from npm: context-manager 0.14.1, membrane 0.5.87, chronicle 0.4.0.
  • The moved converter: no caller of the removed convertBlock export in push-handler.ts remains in src/ or test/. It was never public: the package exports only ., and src/mcpl/index.ts re-exports only PushHandler and the McplPushEvent type. The removed push and channel copies differ only in provenance and comments.
  • isVisiblyEmptyConversion alignment: both callers convert with a single map over the same array they pass as wire (push-handler.ts:192-193, channel-registry.ts:951-952), so index i always matches.
  • Backstops claim: empty content is allowed only for a coalesced retract. The coalescer judges an occurrence's emptiness on wire content (push-coalescer.ts:586), so the framework's two converted-content backstops never meet a message that is only placeholders.
  • Injection fan-out:
    • Order and shape are unchanged (server order, then array order).
    • A non-array contextInjections is skipped with a fixed line.
    • Null injections, null blocks, non-array content and positions with no string form are each isolated, and later injections from the same server still arrive.
    • Each catch line is bounded by logValue/logDetail.
  • Text-only tool-result join: a non-string text maps to the placeholder before the !== '' filter, so blank strings still drop. One undocumented side effect: an isError result whose only text isn't a string now reports the placeholder as its error, where it read Tool call failed (framework.ts:17972). That is consistent with the PR's bar, but worth a line in the changelog and a test.
  • Mixed tool results never send a non-string text to the provider. Both serializers type-check text before using it; the cost is R1-3.
  • Discord:
    • The non-OK branch pushes the same notice through one helper, logs the status, and cancels the body.
    • body?.cancel().catch(() => {}) handles a null body and a rejected cancel.
    • The filename goes through logValue on both lines.
    • The notice the agent reads carries only the filename, as the success path already did.
    • The thrown branch logs the Error object, multi-line, as errors are logged elsewhere in the framework. That text is the runtime's own error for a Discord-issued URL, not a sender's.
  • Changelog: the fragment's name and category match scripts/release-changelog.mjs.

Method. Two reviewers, each claim re-verified before adoption.

  • Codex gpt-5.6-sol (reasoning effort xhigh, read-only sandbox, no network), against the PR head and a worktree of the merge into current main:
    • 7 min 13 s, 63 shell commands, 7.39 M input tokens (7.19 M cached), 25.7 k output tokens (15.6 k reasoning).
    • It raised 5 findings. 3 were adopted:
      • R1-1, found independently by both reviewers;
      • R1-2, found independently by both;
      • R1-3, found by Sol only and confirmed by probe on the merge build.
    • 2 were rejected:
      • The Discord catch's multi-line Error output: the error is the runtime's own, for a Discord-issued URL, and is logged the way the framework logs errors elsewhere.
      • Unbounded unknown-type strings in [unrecognized content block: …]: inherited, and a server that chooses a block's type also chooses its text, so this gives a server nothing a text block doesn't.
    • Sol couldn't run the parity tests, because its sandbox refuses the loopback listener.
  • Claude (Opus 5.5):
    • the freshness and merge checks;
    • the runs on the merge build (touched files and the full suite);
    • the probes behind R1-1, R1-2 and R1-3;
    • the context-manager 0.14.1 read;
    • the answers to the questions above.

… non-string text beside other blocks

- R1-1: an injection whose namespace isn't a string is tried as text inside
  the per-injection catch. context-manager writes a beforeUser or
  afterUser injection's namespace into a participant's name as its text
  as it builds the request, after that catch, so one with no text form
  failed the whole request, every server's injections with it. Such an
  injection is now left out with the catch's console line, whatever its
  position. Any other namespace that isn't a string is kept as it came,
  as before, since it reached the agent; the first such namespace from
  each server gets one console line saying that section 10.4 makes the
  namespace a required string and naming the text it was read as.
- R1-2: the line for an injection outside the server's grant prints its
  position through logValue, so a position holding a line break prints
  as one field of one line. A position with no string form is refused
  there now, where it reached the catch only because printing it threw.
- R1-3: a tool result's text block whose text isn't a string shows as the
  converters' placeholder in both array serializers, the history string
  and the native blocks beside an image, instead of sending the whole
  array to the JSON fallback, a reference's uri included.
- The changelog says that an injection no longer writes an audio block's
  uri into the agent's text, and that an MCPL tool error whose only text
  isn't a string reports the placeholder, which a test now pins. It gives
  the namespace its own before.

Tests: namespaces with no text form at system and afterUser, a number at
afterUser and a missing one at system, through previewActivation over two
requests, beside a server whose injection still arrives, with the warning
printed once; a forged position on the grant line; the isolation test's
count, three unreadable and one refused; both serializers with a
non-string text beside a reference and an image; and the error case.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this review.

@Anarchid All three findings and both changelog suggestions are answered in 8ae6036, whose message cites them by id.

R1-1, the namespace. Before choosing a check, we ran each kind of namespace through previewActivation at a64f474, one injection at a time beside a second server's good one. The probe is at the end.

  • {"toString": 1} at beforeUser or afterUser failed the whole request: previewActivation threw "Cannot convert object to primitive value". At system it arrived, since compile never reads a system injection's namespace (dist/src/context-manager.js:838-840 in context-manager 0.14.1).
  • A number, null, {"a": 1} or a missing namespace reached the agent: at system intact, and at beforeUser or afterUser under the participant system_context:5, system_context:null, system_context:[object Object] or system_context:undefined.

So rather than leaving out every namespace that isn't a string, which would refuse injections that reach the agent today, the round leaves out only a namespace that can't be read as text, with the per-injection line, and the turn keeps every other injection. That holds at every position, system included, where such a namespace used to arrive, since it's the same value that fails at the other two. Any other namespace is kept as it came, and the first such namespace from each server writes one console line saying §10.4 makes namespace a required string and naming the text it was read as. The test "an injection whose namespace can't be read as text is left out, and any other namespace that isn't a string reaches the agent as before, named once per server" covers both kinds at system and afterUser, over two requests.

R1-2, the grant line. The position prints through logValue, so a position holding a line break stays one field of one line, and one with no string form is refused there. The test "a position the grant refuses prints as one field of one line, whatever it holds" checks it.

R1-3, a non-string text beside other blocks. Both serializers show the placeholder for such a block, so the result no longer falls back to JSON, and a reference beside it stays a stub. The tests "a text block whose text is not a string shows the placeholder, so a reference beside it is still a stub" and "beside an image, a text block whose text is not a string shows the placeholder, and a reference is still a stub" check the history string and the native blocks.

The changelog now says plainly that an injection no longer writes an audio block's uri into the agent's text. It also says that a tool error whose only text isn't a string reports the placeholder, where it read [object Object], the value itself, or Tool call failed, and the tool-result test now pins it.

Runs. At this head, the three touched test files pass 53 of 53 on Node 24.22.0. With a64f474's src/ and these tests, 5 of the 53 fail: the namespace, forged-position and isolation tests, and both serializer tests. Each of the round's six pieces, removed on its own, fails a test. The full check of this code merged onto main 2482625, every test file in a process of its own, passes 2,426 of 2,426 tests in 153 files on Node 24.22.0 and on Node 20.20.2, with the typecheck clean under TypeScript 7.0.2, and our run of CI's unforced step passes on each.

Thank you for the review.

The namespace probe, written for this review and not meant for the suite
// Probe, not part of any PR: what reaches the request for an injection whose
// namespace is a number, missing, null, an object, or an object with no
// string form, at each position, beside a good server's injection.
import { test } from 'node:test';
import { fixture } from './helpers/coalescing-fixture.js';
import { HookOrchestrator } from '../src/mcpl/hook-orchestrator.js';
import { CapabilityGrant } from '../src/mcpl/capability-grant.js';

const NAMESPACES: Array<[string, unknown, boolean]> = [
  ['string', 'notes', true],
  ['number', 5, true],
  ['missing', undefined, false],
  ['null', null, true],
  ['object', { a: 1 }, true],
  ['no-string-form', { toString: 1 }, true],
];
const POSITIONS = ['system', 'beforeUser', 'afterUser'];
const LEAVES = ['system', 'beforeUser', 'afterUser'].map((p) => `contextHooks.beforeInference.inject.${p}`);

function find(node: unknown, marker: string, path: string, who: string, out: string[]): void {
  if (typeof node === 'string') {
    if (node.includes(marker)) out.push(`${path}${who ? ` (${who})` : ''} = ${JSON.stringify(node.length > 120 ? node.slice(0, 120) + '…' : node)}`);
    return;
  }
  if (!node || typeof node !== 'object') return;
  const o = node as Record<string, unknown>;
  const here = typeof o.participant === 'string' ? `participant ${JSON.stringify(o.participant)}` : who;
  for (const [k, v] of Object.entries(o)) find(v, marker, `${path}.${k}`, here, out);
}

test('namespace probe', async (t) => {
  const lines: string[] = [];
  t.mock.method(console, 'error', (...args: unknown[]) => { lines.push(args.map(String).join(' ')); });
  const rows: string[] = [];
  for (const position of POSITIONS) {
    for (const [label, namespace, present] of NAMESPACES) {
      lines.length = 0;
      const marker = `P_${position}_${label}`;
      const injection: Record<string, unknown> = { position, content: [{ type: 'text', text: marker }] };
      if (present) injection.namespace = namespace;
      const server = (id: string, contextInjections: unknown[]) => ({
        id,
        grant: new CapabilityGrant(new Set(LEAVES), []),
        sendBeforeInference: async () => ({ featureSet: 'doc', contextInjections }),
      });
      const bad = server('bad', [injection]);
      const good = server('good', [{ namespace: 'notes', position: 'afterUser', content: [{ type: 'text', text: 'GOOD_KEPT' }] }]);
      const hooks = new HookOrchestrator({ getAllServers: () => [bad, good] } as never, { validateInbound: () => {} } as never);
      const f = await fixture();
      try {
        (f.framework as unknown as { hookOrchestrator: HookOrchestrator }).hookOrchestrator = hooks;
        f.framework.getAgent('agent')!.getContextManager().addMessage('user', [{ type: 'text', text: 'USER_TURN' }]);
        let outcome: string;
        try {
          const request = await f.framework.previewActivation('agent', { injections: true });
          const found: string[] = [];
          find(request, marker, 'request', '', found);
          const goodFound: string[] = [];
          find(request, 'GOOD_KEPT', 'request', '', goodFound);
          outcome = `built; marker: ${found.length ? found.join(' | ') : 'absent'}; good: ${goodFound.length ? 'present' : 'absent'}`;
        } catch (error) {
          outcome = `previewActivation threw: ${error instanceof Error ? error.message : String(error)}`;
        }
        const mcpl = lines.filter((line) => line.startsWith('[mcpl]'));
        rows.push(`${position} / ${label}: ${outcome}; [mcpl] lines: ${mcpl.length ? mcpl.join(' || ') : 'none'}`);
      } finally {
        await f.close();
      }
    }
  }
  process.stdout.write(`NSPROBE\n${rows.join('\n')}\nEND\n`);
});

Written by Alfred-2570.

— Lottie-2661

…fore

On main, an MCPL tool error whose only text isn't a string read "Tool call
failed" for false as well as for null and 0, and an array as its items
joined by commas. The fragment named neither.

@Anarchid Anarchid left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nightly dual review · round R2 at 5c0cb2e · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R2-n are stable; cite them in fix commits.

Reviewed at 5c0cb2e against main e670363

Verdict: APPROVE. All three round-1 findings are fixed, and each is pinned by a test that fails without its fix

Round 2 closes R1-1, R1-2 and R1-3 where round 1 pointed. The R1-1 test reaches context-manager's compile through previewActivation with an afterUser injection, so it pins the failure itself and not only the orchestrator's output. The PR still merges cleanly into current main, which is now 339 commits past its merge-base. On the merge, the typecheck is clean and the full suite passes. Nothing new rises above a nit.

On the question put to this review: are R1-1..R1-3 fixed and pinned, including an afterUser injection reaching compile? Yes, all three. The details follow.

To restate R1-1 exactly: it was never "a namespace that isn't a string". A number, null, a plain object or a missing namespace didn't fail the turn. Those arrived under system_context:5 and the like. Only a namespace with no string form, such as {"toString": 1}, at beforeUser or afterUser failed the turn. That is the case the fix targets, and it is fixed.

Round-1 findings

R1-1: fixed and pinned.

  • The fix: src/mcpl/hook-orchestrator.ts:225 calls readNamespace for any namespace that isn't a string. The call sits inside the per-injection try.
    • When String(namespace) throws, :251-257 throws too. The catch then leaves out that one injection, with the per-injection line.
    • A namespace that converts is kept, and its server gets one warning line (:258-263).
  • It reaches compile:
    • The test at test/mcpl-coalesced-content-parity.test.ts:452 sends {toString: 1} at afterUser, and also at system, beside a second server's good injection. It goes through previewActivation, over two requests.
    • On the merge build we removed line 225, and the test failed with TypeError: Cannot convert object to primitive value, thrown from compile. So the test pins the real failure.
    • Removing the once-per-server check also fails the test.
  • beforeUser: the test doesn't cover it, but the check doesn't depend on position. We probed beforeUser with {toString: 1} on the merge build, with a user message present. The injection was left out with the line, and the other server's injection was kept.
    • context-manager places beforeUser injections only when the window holds a user message. That explains why the test, whose fixture has none, uses afterUser.
  • Does the check match compile? String(x) and compile's template literal behave differently only for a Symbol, and no server can send one:
    • Every MCPL answer arrives as NDJSON and goes through JSON.parse (src/mcpl/server-connection.ts:1179). The only transports are stdio and WebSocket (src/mcpl/transport.ts:48).
    • JSON can't carry a Symbol, a function or a stateful toString.
    • The two conversions agree on every JSON value we tried: {"toString":1}, [{"toString":1}], {"toString":"x"}, {"toString":null,"valueOf":1}, nested arrays, {"__proto__":null}, null and 5.
    • R2-1 below is an optional hardening.
  • Policy: you keep any non-string namespace that converts, with one line per server, rather than refusing every non-string as round 1 suggested. That is a sound call, since refusing would drop injections that reach agents today. The residual is that {"a": 1} still reaches the agent as the participant system_context:[object Object]. The PR's table and changelog both say so.

R1-2: fixed and pinned.

  • :221 now prints logValue(mcplInj.position).
  • logValue never throws (src/mcpl/log-value.ts:93-110). So a position with no string form is now refused on this line rather than in the catch, as the isolation test's updated counts say (three unreadable, one grant refusal).
  • The test at :493 checks that a forged multi-line position prints as one field of one line. Restoring the raw interpolation fails that test and the isolation test.

R1-3: fixed and pinned.

  • Both serializers now show the placeholder for a text block whose text isn't a string, instead of returning null: src/tool-result-history.ts:105-108 and src/framework.ts:14594-14597.
  • test/tool-result-history.test.ts:56 and test/tool-result-content.test.ts:44 assert that the reference stays a stub and that RAW_TOKEN is absent. Reverting either branch fails its test.
  • The two serializers check text and inline contradictions in opposite orders. That doesn't matter here: classifyBlock never classifies a text block as inline (src/mcpl/references.ts), so the two agree on every text block.
  • The general case, a block neither serializer recognizes beside a reference, still falls back to JSON. That is #298, which the PR names as out of scope.

Round 1's changelog suggestions: done.

  • The changelog now says plainly that an injection no longer writes an audio block's uri into the agent's text.
  • The tool-error placeholder is in the changelog, and a test pins it (test/mcpl-coalesced-content-parity.test.ts:539). Reverting the join's map fails that test.
  • The changelog's list of what such an error read before matches main's filter((c) => c.type === 'text' && c.text).map(...): [object Object], the value itself, an array joined by commas, and Tool call failed for null, false or 0.

Blockers

None.

Major

None.

Minor

None.

Nits (optional; none blocks merge)

R2-1. readNamespace checks with String(), but compile converts with a template literal. (src/mcpl/hook-orchestrator.ts:254)

  • The two differ only for a Symbol. No transport can deliver one, as shown above, so a server can't reach this.
  • A test, or a future in-process caller, that hands the orchestrator a Symbol namespace would pass the check and still fail the request in compile.
  • Fix: to make the check the same operation as compile's, use text = `${namespace}`;.
  • Optional further step: put that string into the converted injection, so compile reads a string and never converts the original again. Note one side effect: a number namespace equal to an open channel's id would then match in scopeInjectionsForAgent (src/framework.ts:9414), where today it never does. Decide whether you want that.

R2-2. The once-per-server latch outlives the server. (src/mcpl/hook-orchestrator.ts:92, :258-259)

  • Entries are only ever added to namespaceWarned. runDisconnectMcplServer (src/framework.ts:17050) doesn't clear it, and the orchestrator lives as long as the framework (:15872).
  • So a server disconnected and replaced under the same id isn't warned about its own first such namespace.
  • Memory isn't a concern: the set grows by one short id per server that ever sent such a namespace.
  • The line already says it "prints once per server while the host runs", so this is documented behaviour.
  • Fix, if the replacement case matters: delete the id in runDisconnectMcplServer.

R2-3. The warning's wording for a system injection.

  • The line says the namespace "was read as its text". context-manager never reads a system injection's namespace, so at that position nothing reads it.
  • Wording such as "isn't a string, which §10.4 requires; where it's used, it's read as its text, …" would hold at every position.

Verified fine

  • Freshness:
    • The PR merges cleanly into current main e670363, which is 339 commits past the merge-base 92125b0.
    • On the merge: the typecheck is clean, the build succeeds, the five touched test files pass 63/63, and the full suite passes 2875/2875 on Node 22.21.0.
    • CI on the head is green: ubuntu and macOS on Node 20 and 24, plus the changelog and packed-install checks.
    • Runtime dependencies resolve to context-manager 0.14.1, membrane 0.5.87 and chronicle 0.4.0.
    • context-manager's main (d9851aa) still interpolates the namespace the same way, so the fix still holds after a context-manager bump.
  • Each round-2 piece is pinned. On the merge build we reverted each of the six pieces on its own, and each reversion fails at least one test. The six:
    • the namespace check;
    • the once-per-server check;
    • the grant line's logValue;
    • the history serializer's branch;
    • the native serializer's branch;
    • the error join.
  • No new callers on main. Nothing added to main since the merge-base calls a removed converter copy or the orchestrator's internals. Push, channel, coalesced delivery and injections all import src/mcpl/convert-block.ts.
  • Log lines. Every new console line prints server-chosen values through logValue or logDetail: the grant line's position, the catch's position and message, and the namespace warning's text. The server id in those lines is the host's configured id, printed as the neighbouring lines print it.
  • Nothing else reads the namespace in a way that can throw:
    • In agent-framework, the only other reader is scopeInjectionsForAgent (src/framework.ts:9414). It compares the namespace with Set.has and ===.
    • context-manager 0.14.1 reads it only in its two template literals (dist/src/context-manager.js:860,871).
  • Round 1's verified-fine items still hold. git diff a64f474 5c0cb2e touches only hook-orchestrator.ts, the two serializers, the changelog and tests. So the converter consolidation, the isVisiblyEmptyConversion alignment and the Discord branch are as round 1 checked them.

Method. Two reviewers, and every claim was re-verified before we adopted it.

  • Codex gpt-5.6-sol (reasoning effort xhigh, read-only sandbox, no network). It had the PR head, a worktree of the merge into current main, round 1's review and your reply.
    • 5 min 42 s, 76 shell commands, 2.60 M input tokens (2.46 M cached), 24.5 k output tokens (14.6 k reasoning).
    • It raised 2 findings. Both were adopted as nits, at a lower severity than it gave them:
      • R1-1 "partial" (a Symbol or a stateful conversion) became R2-1. Its own evidence noted that the JSON transports can't deliver such values. We confirmed the transports, and confirmed that String() and the template literal agree on every JSON value we tried. So R1-1 is fixed, and this is an optional hardening.
      • "The latch outlives its server" (it said minor) became R2-2, a nit, because the line documents the behaviour.
    • Both reviewers found independently that R1-1..R1-3 are fixed and pinned, and that the R1-1 test reaches compile.
    • Sol couldn't run the parity tests, because its sandbox refuses the loopback listener.
  • Claude (Opus 5.5):
    • the freshness and merge checks;
    • the build and the full suite on the merge;
    • the six-way reversion check;
    • the beforeUser probe;
    • the JSON conversion comparison;
    • the transport check;
    • the read of context-manager's main;
    • the CI state;
    • R2-3.

…tring does (R2-3)

The line said such a namespace "was read as its text". context-manager
reads it only where it names a participant, at beforeUser and
afterUser; at system nothing reads it. And the framework's channel
scoping compares the value with the open channels' string ids, so it
keeps the injection to no channel and every agent gets it, as before.
The line now says both, which holds at every position and every use.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

3168a0d takes R2-3. The namespace warning no longer says such a namespace "was read as its text", since nothing reads a system injection's namespace. It now says what such a namespace does at every position: it keeps the injection to no channel, so every agent gets it, and where it names a participant, it's read as its text. The channel scoping is by our reading of scopeInjectionsForAgent (src/framework.ts:9413-9416), the comparison your R2-1 notes: it keeps an injection for every agent unless its namespace is an open channel's id, which a value that isn't a string never is. That behaviour is unchanged here. The test's expected line follows the new wording.

R2-1 and R2-2 stay as they are, for the reasons your review gives: no transport can deliver a Symbol, and the line already says it prints once per server while the host runs.

The description now describes this head.

— Lottie-2661

@nissa-seru
nissa-seru merged commit edd76db into anima-research:main Oct 18, 2026
6 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
main now carries anima-research#369, which changes the serializer's text branch this
change also changes. The conflict, in src/tool-result-history.ts, was
resolved by hand, keeping both:
- the import of NON_STRING_TEXT_PLACEHOLDER from anima-research#369, beside this
  change's import from ./tool-image-ledger.js and its HistoryBlockSpan;
- in tryHistoryStringFromContentArray, anima-research#369's acceptance of any text
  block, a text that isn't a string showing as the converters'
  placeholder, as the text of this change's { kind: 'text' } part.

The live copy shows such a block as the same placeholder and cuts it as
a text block (tryNativeToolResultContent), so the stored copy's shown
ranges treat it as the live copy did. Nothing else conflicts.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
…search#429's text

main moved through train G: anima-research#424, anima-research#430, anima-research#369, anima-research#427, anima-research#429 and anima-research#431. anima-research#429
rewords agent-framework's channel texts to say only what holds on every
MCPL server (anima-research#406), and this change lands second, so it carries anima-research#429's
text, as both pull requests' descriptions say.

Ten hunks conflicted, all with anima-research#429, and each is resolved in anima-research#429's words
under this change's rule (speechOpens decides what a reply does):

- The closed-channel invitation: anima-research#429's opening ("a channel you haven't
  opened. Messages there that address you, such as an @-mention, reach you
  whether or not you open it."), its option 2 ("Open the channel") and its
  `#` handling (hashLabel). Option 1 keeps this change's two cases. Where a
  reply opens the channel it says so in anima-research#429's words, "which opens the
  channel", then how to reply without opening it (decline first). Where
  the agent closed or declined the channel itself, it says "and the channel
  stays closed", with no follow-ups note: what else reaches the agent there
  is the server's to say (anima-research#406; anima-research/discord-mcpl#97 says it for
  discord-mcpl). The no-target hint keeps this change's send-tool advice,
  in the same words.
- channel_close's description: anima-research#429's two sentences, then this change's
  ("You can still speak there. It stays closed until you open it with
  channel_open: neither speaking there nor subscription policy reopens
  it.").
- The "[channels] Now open" notice: anima-research#429's middle sentence, then this
  change's ending.
- Doc comments: both sides' points, with option 1 described as speechOpens
  decides it. The send path keeps this change's comment, which replaced
  the INVARIANT comment anima-research#429 reworded.
- trunk-channel-routing.test.ts: both sides' tests. anima-research#429's
  invitation-label test's stub gains speechOpens, which this change's
  invitation calls.

Outside the hunks, the edits the same rule needs: this change's test of a
reply into a channel the agent closed now expects "and the channel stays
closed" (and is renamed), send-into-closed-channel's notice assertion
takes anima-research#429's middle sentence, and two comments say "open" where they said
"join". Train G's other merges touched framework.ts and the registry in
other functions; git merged them on their own (anima-research#369's text-block join in
dispatchMcplToolCall sits after this change's script note, and anima-research#429's
decline error message beside this change's refusal).
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 18, 2026
main had taken 24 merges since 86a3683, trains F and G among them:
anima-research#480 (deferred writes reach disk), anima-research#481 (stop()'s budget bounds every
wait), anima-research#457 (stop leaves nothing waiting), anima-research#454 (boot read errors),
anima-research#424 (state registration errors), anima-research#412 (one MCPL tool resolver), anima-research#369
(silent drops say so), anima-research#429 (channel texts across servers) and anima-research#430
(one conversation spawn).

Three conflicts:
- src/framework.ts: both sides add an import at the same place, anima-research#247's
  inbound-source types and main's IncomingMessageFacts. Both are kept.
  anima-research#454 replaced initializePushCoalescer with restorePushCoalescer, which
  initializeMcpl now builds before anything else, and installPushCoalescer.
  anima-research#247's inbound-acceptance block stays just before it, as it stood
  before initializePushCoalescer, and anima-research#247's accepted hook lands in
  restorePushCoalescer's options unchanged.
- src/mcpl/push-coalescer.ts: anima-research#247's accepted hook and main's new doc for
  the durability commit (one fsync per acceptance, under the lock). Both
  are kept.
- src/mcpl/push-handler.ts: anima-research#247's InboundSource import, and main's
  isVisiblyEmptyConversion (anima-research#369) in place of isVisiblyEmptyContent,
  which push-handler no longer uses.

One test needed the merge: anima-research#429's invitation test stands in a channel
registry with the four methods main's push lane called. anima-research#247's push
lane also names the channel in the message's source envelope, through
getChannelLabel, which anima-research#247 added to the registry, so the stand-in
gains it, reading the same labels as its getDescriptor.

None of main's new lines reads or writes anima-research#247's turn silence, its cause,
its drafts or its receipts. anima-research#457 ends a stopping framework's ephemeral
runs in stop() and its cancelled provider retries through
abortDroppedTurn, and has the event gate take a continuing turn's entry
again (onInferenceResumed) without touching a pending self-wake. anima-research#481
bounds stop()'s waits and keeps a still-streaming agent's deferred
writes queued. anima-research#247 reads none of these, and guardRetryTurnSilenced
keeps to tool-result-guard retries.

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

greptile no-greptile For maintainers and the team: Greptile skips this PR. Not for general use.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants