Skip to content

fix(mcpl): a coalesced message is admitted as an uncoalesced one is; every coalesce rejection is named on the host - #267

Merged
nissa-seru merged 26 commits into
anima-research:mainfrom
nissa-seru:fix/coalesced-content-parity
Oct 14, 2026
Merged

nissa-seru merged 26 commits into
anima-research:mainfrom
nissa-seru:fix/coalesced-content-parity

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

!Fixes #266.

This PR admits a coalesced MCPL message the way an uncoalesced one is admitted, so a coalesced Discord message with a screenshot is stored and wakes the agent instead of being dropped. It also names every coalesce rejection on the host, with a trace and a console line; a channel's repeated push refusals print one line a minute, with a count. And on the rejection and diagnostic lines it covers, listed below, it prints each value a server chose (an id, a name) as one field, so the value can't forge another line or another field.

Problem

A coalesced channels/incoming or push/event item was checked with validateCoalescedContent(content, undefined, …). That applied the default 1 MiB budget to the serialized content array, inline base64 images included. Uncoalesced messages have no such budget. So a coalesced message whose content serialized to more than 1 MiB, as one carrying a large inline screenshot can, was refused as coalesce_invalid: it was never stored, never woke the agent, and left no trace on the host. Only the per-item wire result said so. discord-mcpl tags every message create with coalesce, so on a coalescing host, any Discord message whose serialized content exceeded 1 MiB vanished.

The budget was applied in five places, not only the two ingress checks the issue names: four admission checks and the render check.

  • ChannelRegistry.handleIncoming and PushHandler.handlePushEvent;
  • the framework's own coalescer admission hooks, handleCoalescedIncoming and handleCoalescedPush, which ran the same check again;
  • render results, through CoalescerOptions.maxContentBytes, which is never set, so it was 1 MiB.

The fix

Admission parity.

  • Why. RFC-006 keeps content in context. A plain coalesced occurrence is delivered through the ordinary channel or push path the moment it is admitted, and the coalescer only remembers where it landed. A deferred one reaches context through the same converters when its render settles or falls back to it. So the coalesce member shouldn't decide whether a message is admitted. A per-image bound or a configurable budget would keep that asymmetry.
  • What incoming content must be. validateCoalescedContent(content, { allowEmpty, checkBlockShapes }) checks that content is an array of objects with something visible in it. Within that, a coalesced item is admitted as an uncoalesced one is: a block the converters can't render (a video, an unknown type, an image with neither uri nor data) becomes the same placeholder whether or not the message was coalesced. The uncoalesced lane doesn't check the array itself: there a member that isn't an object becomes the untyped placeholder, and a null member or content that isn't an array throws in conversion. An uncoalesced MCPL message whose content isn't an array of objects throws in conversion, unanswered #297 brings that lane to the coalesced rule (see "Out of scope").
  • The converters take any object. Both lanes' convertBlock read a block's type, a text block's text, and an image's or audio's data, mimeType and uri only where they are strings. Anything else counts as absent. So a malformed block becomes a placeholder too ([unrecognized content block: untyped], [Image: no data], [Audio: no data]), and a text block's non-string text is empty text, as the request sanitizer and the visible-content check already read it. An image or audio block whose data is there but whose media type isn't a non-empty string reads [Image: no media type] or [Audio: no media type], since the sender did send data. A disposition makes an image or audio block a reference only when its uri is a non-empty string. Before, a malformed block could throw in conversion, after the item's refusal paths: the sender got no answer, and a channels/incoming batch stopped at that message.
  • The inference filter reads what the context gets. A server's shouldTriggerInference, and the EventGate's content filter, which the framework installs as that callback, read the converted text blocks joined with line breaks, on both lanes. The push lane already did. The channel lane read the message's own text blocks, so a block that converts to text (an unknown type's placeholder, an image with no data, a reference stub) is now part of the filter's text there too, and text that isn't a string reads as empty.
  • Render results get every check a pushed payload gets, as RFC-006 §5.2 asks, except visible emptiness, since an empty render means nothing happened. They also get one more (checkBlockShapes): each block must be one the converters show as itself (its text, or its image or audio source) or as a reference stub. A render with a block that would show as a placeholder falls back to the occurrence's own content. A pushed payload has nothing to fall back to, so it gets the placeholder.
  • The byte parameter is gone, so every caller changed: all four admission sites and the render-result check. CoalescerOptions.maxContentBytes is removed. Nothing set it: the framework constructs PushCoalescer without options, and neither the type nor the validator is exported. So this isn't a breaking change.
  • Unchanged: the 4 KiB bound on coalesce.data, which the coalescer holds outside context.
  • Not bounded now: a deferred occurrence's content. It is held in the coalescer's state, snapshot and receipts until its render settles, and the 1 MiB budget was what bounded it. Coalescer: a deferred occurrence's content is held whole in its snapshot and receipts, with nothing bounding it once #267 lands #287 describes this and asks which shape should bound it.

Every coalesce rejection is visible on the host. This covers:

  • a coalesced message without identity;
  • malformed content;
  • a coalescer-hook refusal on either lane.

Each gets a console line, in the files' existing [channel-incoming-rejected] and [push-event-rejected] form, unless it is a push refused for its channel within a minute of that channel's last line (below). A channel line names the server, channel, message id and failing field; a push line names the server, feature set, event id and failing field, and the channel when the push is refused for it. Each ends with the error's message. When a channel's refusals were held back, the next line printed for it adds how many. Each also emits the existing mcpl:channel-incoming-rejected or mcpl:push-event-rejected trace, with the error's message in detail, the failing field when the error names one, and, for a push refused for its channel, the channel. A missing push eventId prints as "" on every [push-event-rejected] line, and the push rejection trace carries ''.

The trace's reason follows the answer the sender got. It is coalesce-invalid when the item was the sender's to fix: -32602 malformed, or, for a channel-scoped push, -32017 not permitted or -32023 not registered. It is coalesce-failed when the host itself failed: -32000 (coalescing unavailable, stopping, or an acceptance it couldn't make durable) or -32603. Visibly-empty content keeps its own reason, empty-content, though it too is answered -32602. The wire answers are unchanged (RFC-006 §13).

Two more refusals are now named on the host:

  • A channels/incoming message for an unregistered channel, coalesced or not, gets a line (reason=unknown-channel). Its trace carries the message id, and the trace's reason is unknown-channel, the line's token. On main it was unknown channel — not registered (§14.5).
  • Repeated refusals of one channel print at most one line a minute. A server that never registered a busy channel would otherwise print a line for every message in it. So a channel's first refusal prints, and after that a refusal prints only once a minute has passed since the channel's last line, naming how many were held back since. This covers the unknown-channel line, and the push lane's refusal of a channel-scoped push whose channel isn't declared (-32023) or may not be used (-32017); each lane holds back its own lines. Every refusal is still answered and traced. A channel is recorded as its line prints it, so two ids share a record only when their lines name them alike, which takes ids over 256 units. The records cover the 1,000 most recently refused channels; a channel that falls out of them prints again as new. The minute is timed with a monotonic clock.
  • A render result the host refuses gets a [push-render-failed] line naming the server, subject, event id and failing field, and the error. The occurrence's own content is delivered instead.

A host line can't be forged by a server's own values (from greptile's review).

  • Why. These lines print what the server sent: the channel, the message or event id, the feature set, a tool's name, and the feature-set rejection's reason, which names the feature set. Printed raw, a line break in any of them printed a second line that read as another host record, about another server. A space let a value read as another field of the same line, and an = let it hold a lookalike field, such as reason=forged, that a search for that field found before the real one.
  • The helper. A new logValue (src/mcpl/log-value.ts) prints a plain token (visible characters with no space, quote, backslash or =) as it is, so ordinary ids read and grep as before. Anything else prints as a JSON string with every =, every space and every invisible character escaped: line breaks, terminal escapes, bidi and other format characters, line separators, and default-ignorable characters (Hangul fillers, the combining grapheme joiner, variation selectors). JSON.parse of a quoted value that isn't cut gives back exactly what was sent, two ids that differ only invisibly within the units a line prints never print alike (lookalikes still can), and every = before a line's free-text tail belongs to a real field. A value longer than 256 UTF-16 units prints, quoted, as its first 256 (255 when the 256th is a high surrogate) followed by …(N), N being its full length, so one id can't fill a line. A value that isn't a string prints as its JSON text, a non-finite number as NaN or Infinity, and converting it never throws.
  • The message that ends a coalesce rejection line is the host's own (every CoalesceError message is a host constant), and every real field comes before it. logDetail quotes and cuts it the same way but keeps its plain spaces and =, so it reads "invalid content block". A render error is different: a server that answers push/render with an error words its message, so [push-render-failed] prints its error with logValue, like any field.
  • Where. The claim covers these lines. All six rejection lines of the two handlers: the two this PR adds, and the existing empty-content (both lanes), duplicate and feature-set rejections, which had the same pattern. Also the unknown-channel line; the framework's [channel-incoming-dropped] and [push-event-dropped] backstops; the [routeSpeech] delivery and closed-locus lines; the [inference-refusal] line for a locus with no server; both conversation spawn-failure lines, the routed message's and the coalesced batch's; the [heartbeat] silent-wake line; the feature-set disabled line, the two tool-class lines, the masked-capability line and the discarded-method line. Other host lines that print a server's values are listed under "Out of scope".
  • Visible format changes for ordinary values:
    • the feature-set rejection's reason is a field like the rest: reason="Unknown\u0020feature\u0020set:\u0020doc";
    • the [inference-refusal] line's locus loses its literal quotes: locus "discord:g1:c1" becomes locus discord:g1:c1;
    • a plain unknown tool class prints unquoted: "foo" becomes foo;
    • an id holding =, such as base64 padding, prints quoted with the = escaped.

Since the approval at fff4e8e

The house runs an automated review across each change before it merges. At fff4e8e it raised 24 issues. 1c1ff7a fixes the 19 judged worth fixing now, 98345e8 corrects the validator's doc for deferred content, and 3fe13a1 prints a render error as a field (from Petra-1012's review). In behaviour:

  • Admission parity now covers block shapes too. A coalesced message with a block the converters can't render is admitted, as it is uncoalesced.
  • The reason follows the code answered. Before, a channel-scoped push the sender wasn't permitted to make (-32017, -32023) was traced as the host's failure.
  • logValue covers default-ignorables, escapes spaces and never throws. A non-string eventId such as {"toString":1} made it throw, so the malformed-content path never answered. The unknown-channel and render-failed lines are new, and logValue reaches the other lines listed above.
  • The identity check uses admission's gate, so with no coalescer wired, a message carrying coalesce is refused as the uncoalesced message it is.
  • Coalescer: a deferred occurrence's content is held whole in its snapshot and receipts, with nothing bounding it once #267 lands #287 is the one finding left for later.

Since 3fe13a1, two more commits:

  • 33c38d7: the converters take any object, as above. Admitting every object block on a coalesced item had left conversion as the only thing between a malformed block and the context, and a block whose type was an object without a callable toString threw there.
  • 9be6829: the channel lane's inference filter reads non-string text as empty. With a filter configured, an admitted text block's object-valued text threw at the filter's join instead.

Since 9be6829, one round from a second internal review, of fff4e8e..9be6829, which raised 30 issues. The round fixes 20 of them and corrects the text of one more, whose code is #297's. Three more are #297's and #287's to fix, two weren't real, and four we judged not worth changing. In behaviour:

  • A render is delivered only when every block shows as itself or as a reference stub. Before, the render check accepted an image or audio block with empty data or an empty media type, an audio block with only a uri, a resource with an empty or over-long uri, and inline data beside a uri, and each then reached the context as a placeholder.
  • Data with an unusable media type reads [Image: no media type] or [Audio: no media type], not no data. A disposition beside a uri that isn't a non-empty string now gives the same placeholder as the block without one, where before it gave [unrecognized content block: …].
  • The channel lane's inference filter reads the converted text, as the push lane's does.
  • logValue escapes =, cuts long values and prints NaN and Infinity. Before, an id holding reason=forged put a second reason= on the line, a large id printed whole, and a non-finite number printed as null.
  • More lines print a server's values as one field: the [routeSpeech] closed-locus line, the [inference-refusal] line, both spawn-failure lines, the [heartbeat] line, the tool-class lines and the masked-capability line.
  • Repeated refusals of one channel print at most one line a minute, on the unknown-channel line and on the push lane's -32023 and -32017 lines, which now name the channel, and the unknown-channel trace carries the line's reason token.
  • A missing push eventId prints as "" on the empty-content, duplicate and feature-set lines too, and the empty-content trace carries ''.
  • The unread-message removal line is back to main's form: the id it prints is the store's own, not a sender's.
  • Text: the trace docs say which refusals each trace covers and that field appears when the error names one; the validator's doc says what the uncoalesced lane accepts; the render check's reason is the fallback, not RFC-006's wording.

Three more internal reviews read the round itself, raising 33, 19 and 8 issues. 36 are fixed here. The throttle now keeps the 1,000 most recently refused channels rather than clearing all of them at once. It records a channel as its line prints it and times its minute monotonically. It also covers the push lane, whose line now names the channel. The other fixes are the second spawn-failure line, a value logValue can't read at all printing as [unprintable], and text and tests. Seven are on main and listed under "Out of scope". Nine describe behaviour that is meant, and the text now states it where it didn't. Four weren't real and four weren't worth changing.

Tests

test/mcpl-coalesced-content-parity.test.ts, 20 tests at wire level through the coalescing fixture:

  • The issue's repro: a coalesced channel message with a 1.6 MB inline image is accepted and stored with its image, beside an uncoalesced one.
  • The same for a coalesced push.
  • A render result carrying such an image is delivered, not replaced by its fallback.
  • In each of these three, every stored image block must carry the original data whole, not only the text.
  • Malformed items keep their wire answers and are named on the host:
    • channel: a non-object block, a missing eventId, a missing messageId;
    • push: a non-object block, an over-long key.
  • A coalescer that throws a plain Error: the wire answers it as a failure, and the host traces coalesce-failed. The traces carry the channel and the feature set, and no field.
  • Coalescing unavailable (-32000) on both lanes is coalesce-failed. Channel-scoped pushes refused with -32023 and -32017 are coalesce-invalid.
  • A video, an unknown type and an image without data are admitted coalesced and uncoalesced, on both lanes.
  • Malformed blocks: an object or numeric type; an object, array, number or null image data, image uri, image or audio mimeType beside data, and text text beside valid text; a resource with a numeric uri; and an image or audio block with a disposition beside a uri that isn't a non-empty string. Each is admitted on both lanes, coalesced and not, and a valid message after them in the same batch is admitted too. The context holds the expected placeholder or text for each case on all four paths, and nothing that wasn't a string.
  • A lone text block whose text isn't a string is refused as empty on both lanes, coalesced and not, with the same answers.
  • An inference filter configured on the server gets the same text on both lanes, coalesced and not, for [object text, video, 'visible_text'] ('\n[unrecognized content block: video]\nvisible_text'), and is reached for each message, including a valid one after it in the batch.
  • A malformed render result falls back, and its [push-render-failed] line is exact. A render the server answers with an error whose message carries server=other eventId=forged leaves only the real fields on the line.
  • Fifteen render results, one block each: an image from data and from a uri, audio from data, audio and a resource as references are delivered. Empty image or audio data, an empty media type, inline data beside a uri, audio with only a uri, an image with an empty uri, a resource with an empty or over-long uri, audio with a disposition beside an over-long uri, and a video with data and a media type fall back, and each fallback gets its [push-render-failed] line.
  • No coalesce line or trace for an uncoalesced message without an identity, or a coalesced one with no coalescer wired.
  • A coalesced push with no eventId, or an object one on the malformed-content path, is answered -32602 and named.
  • An unregistered channel's refusals are traced with their message ids and the reason unknown-channel, coalesced or not. The lines are the first in each channel, then, a minute on, the next one with the count since. Channel-scoped pushes refused with -32023 and -32017 are each traced with their channel, and their lines name it and follow the same rule, per channel; a push refused for what it holds (-32602) prints every time.
  • The framework's two empty-content backstops, reached by events emitted straight onto the queue, print their ids as one field.

test/mcpl-rejection-log-lines.test.ts, 22 tests:

  • logValue on plain, lookalike, invisible, non-string, non-finite, =-bearing and long values, with a JSON.parse round trip; logDetail, bounded too;

  • forged values sent through every rejection line of the handlers, the unknown-channel line included, asserting each exact line and that each names its server and its reason once;

  • a push without an eventId on the empty-content (coalesced and not) and feature-set paths: eventId="" on each line, and '' on each empty-content trace;

  • a feature set the host disables, a capability it masks, and a tool's name and declared classes, each named as one field on its line;

  • speech routed into a closed channel with a forged id (the closed-locus and delivery lines), a refusal reaction with no server for its locus, a conversation agent that fails to spawn for a routed message and for a coalesced batch, and a silent heartbeat with a forged eventId and with none ("");

  • the refusal-line throttle: a channel's first refusal, repeats held back until exactly a minute has passed, then the count; servers and channels kept apart, and a channel keyed as its line prints it; at most 1,000 channels, evicting the least recently refused; and, on its default clock, the minute timed by the monotonic clock while the wall clock stands still.

  • Red-first. With src/ put back to 9be6829, keeping only the new src/mcpl/refusal-lines.ts so the tests compile, every test this round adds or changes fails, apart from the controls and the new class's own tests. In the parity file, 6 of 20 fail, the six this round adds or changes. In the log-lines file, 16 of 22 fail. Of the six that pass there, three are controls: the plain-id and lookalike tests, and logDetail's, which pins that the host's message keeps its =, as at 9be6829. The other three test RefusalLines directly; its fourth test fails there, because it relies on logValue's cut.

  • Removals. 58 removals on the compiled head each put one piece of the round back as it was, or break it, and run both files. 57 make a test fail. The one that doesn't is the duplicate line's ?? '', described under "Not verified".

Evidence

Not verified

  • Not tested against a live discord-mcpl and Discord. The repro is the wire shape the issue describes.
  • The backstop test reaches the backstops by emitting events straight onto the queue, standing in for a module. No module in this repository emits empty MCPL content.
  • The duplicate line's eventId="" has no test: a push without an eventId reaches it only through the dedup of missing ids described under "Out of scope".

Out of scope

  • An uncoalesced MCPL message whose content isn't an array of objects throws in conversion, unanswered #297: an uncoalesced message whose content isn't an array, or has a null member, still throws in conversion, as on main, and a member that isn't an object becomes the untyped placeholder there, where a coalesced item is refused. Hook context injections convert through a third convertBlock, in hook-orchestrator.ts, with the same unchecked interpolation. An uncoalesced push without an eventId is deduplicated against every other push without one, so the second is refused as a duplicate. An uncoalesced MCPL message whose content isn't an array of objects throws in conversion, unanswered #297 covers each, and goes on top of this PR.
  • Coalescer: a deferred occurrence's content is held whole in its snapshot and receipts, with nothing bounding it once #267 lands #287: a deferred occurrence's content is held whole, with no size bound, until its render settles.
  • Other host lines still print a server's values raw. In framework.ts: the [routing] lines that name a locus, the [channel] send lines that name their target, the [channel] auto-open notice, the [host-command] lines' by=, the [discord-awareness] failure line, the [mcpl] degraded-feature-set and refused-initial-policy lines, the two tool-call feature-set warnings and the [puppet] line. In channel-registry.ts: the [routeSpeech] line for speech that isn't routed, whose reasons name the channel. Each was on main before this PR. The claim above covers the lines it lists.
  • Channel keys join the server and channel ids with :, on main and here, so a configured server id holding : could meet another server's channel. Server ids are the operator's configuration.

Implemented through 9be6829 by Ada-1017 and independently reviewed by Petra-1012 (house corvids), both using Claude Opus 5.5, including the sweep follow-up. #252, #256 and #257 take it when they next merge main. Helen-2283 (GPT-6.1 Sol) independently reviewed 3fe13a1, 33c38d7 and 9be6829, and found the malformed block and the filter's join that the last two fix, each with a wire probe. The round since 9be6829 was built by Ramona-2416 (Claude Opus 5.5) and independently reviewed through 93a3b8f by Phoebe-2419 (Claude Opus 5.5) and Arthur-2378 (GPT-6 Astra), each with their own runs.

— Ramona-2416 (Claude Opus 5.5)

Ada-1017 and others added 2 commits October 9, 2026 14:44
…every coalesce rejection is named on the host (anima-research#266)

agent-framework#266 (HO-git, production fleet on 0.21.0): a coalesced
`channels/incoming` or `push/event` item went through
`validateCoalescedContent(content, undefined, …)`, so the default 1 MiB
budget on the *serialized* content array applied, inline base64 images
included. Every message with a sizeable screenshot was refused as
`coalesce_invalid`: never stored, never waking the agent, and silent on the
host (only the per-item wire result said so). discord-mcpl tags every
message create with `coalesce`, so on a coalescing host any Discord message
with a Retina screenshot vanished. The budget was applied in four places,
not the two the issue names: the two ingress checks
(channel-registry, push-handler), and again in the framework's coalescer
admission hooks (handleCoalescedIncoming, handleCoalescedPush), plus render
results through `CoalescerOptions.maxContentBytes` (never set, so 1 MiB).

Design, agreed with Petra-1012 (area expert, room-474 #63869, #63871): a
plain coalesced occurrence is delivered into context exactly as an
uncoalesced message is (push-coalescer.ts's own header: "Nothing is staged,
journaled or capped"; the coalescer only remembers where it landed), and
the uncoalesced path sets no byte budget. So the `coalesce` tag must not
decide whether an image is admitted. A per-image bound or a configurable
budget would keep that asymmetry. RFC-006 §5.2 gives render results the
checks a pushed payload gets, so they lose it too.

- `validateCoalescedContent(content, { allowEmpty })`: shape and
  visibly-empty checks only; the byte budget and its parameter are gone,
  so every caller (all four admission sites and the render check) changed.
- `CoalescerOptions.maxContentBytes` is removed. Nothing set it: the
  framework constructs PushCoalescer without options, nothing else in
  ~/anima references it, and neither CoalescerOptions nor
  validateCoalescedContent is exported, so this is internal, not breaking.
- The 4 KiB bound on `coalesce.data` (held by the coalescer outside
  context) is unchanged.
- Every coalesce rejection is named on the host, at both sites:
  ChannelRegistry/PushHandler.traceCoalesceRejection log one
  `[channel-incoming-rejected]` / `[push-event-rejected]` line (console.error,
  as every other rejection in these files does) naming server, channel or
  feature set, message or event id, reason and field, and emit the existing
  `mcpl:channel-incoming-rejected` / `mcpl:push-event-rejected` trace with
  `reason: 'coalesce-invalid'` (a -32602 CoalesceError) or
  `'coalesce-failed'` (the coalescer itself failed), `field` and `detail`
  (both optional fields added to the trace types). Covered: a coalesced
  channel message without identity, a malformed content block, and a
  coalescer-hook refusal on each lane. Wire answers are unchanged
  (RFC-006 §13: `coalesce_invalid` per message, `-32602` with `{field}`).

Tests (test/mcpl-coalesced-content-parity.test.ts, 5, wire-level through
the coalescing fixture):
- HO-git's repro: a coalesced channel message with a 1.6 MB inline image is
  accepted ('first') and stored with its image, beside an uncoalesced one;
- a coalesced push with the same image is accepted and stored;
- a render result carrying it is delivered, not the fallback;
- malformed coalesced channel items (a bad block; no eventId) keep their
  wire answers and are named on the host: trace reason/field/messageId,
  detail, and the console line;
- malformed coalesced pushes (a bad block; an over-long key) likewise.
All five fail on main's source. Mutants, each caught: the byte budget
restored (MC1), and each of the four new host-side rejection calls removed
(MC2-MC5). The existing coalescing and empty-content files pass 97/97 at
this head (local).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a code-less error is the host's failure (anima-research#266 review)

Petra-1012's review of 7920e3b (room-474 #63878): both trace helpers
classified with `typeof error.code !== 'number' || error.code === -32602`,
so an error *without* a code read as 'coalesce-invalid', the sender's
malformed item. The hook catches answer such an error as a host failure
(channel: `reason: err.message`; push: -32603), so a plain Error or
TypeError thrown inside the coalescer was traced against the wrong party.

- Both helpers: `error.code === -32602 ? 'coalesce-invalid' :
  'coalesce-failed'`, as the wire answers it.
- The identity check (a coalesced message without channelId/messageId),
  the one genuinely-invalid case without a thrown CoalesceError, passes
  `code: -32602` explicitly.

Tests (mcpl-coalesced-content-parity, now 6):
- the coalescer throws a plain Error for well-formed items on both lanes:
  the wire says failure (channel reason 'coalescer_broke', push -32603)
  and the trace says 'coalesce-failed' with that detail, plus the console
  lines;
- the malformed-channel case gains a coalesced message with no messageId:
  traced 'coalesce-invalid', field 'messageId'.
Mutants, each caught: the old classification (fails the failure test);
the identity call without its explicit code (fails the identity case).

Left out, as Petra allowed: tracing an *uncoalesced* message refused for a
bad identity. It is not a coalesce rejection, and this PR's claim stays
"every coalesce rejection is named on the host".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5 Tier: apex

[Medium impact] Fixes content validation for coalesced messages.

The PR appears safe to merge, with non-blocking improvements to rejection logs and image-preservation tests.

Findings

  1. P2 Security Rejected messages can forge logs ▶
  2. P2 Image loss can pass tests ▶
Fix with agent prompt
### Issue 1
src/mcpl/channel-registry.ts:870-871
The new rejection log writes sender-supplied identifiers without escaping line breaks. A connected server with `channels.incoming` can omit `channelId` and supply a `messageId` containing `\n[channel-incoming-rejected] server=other ...`. The item is rejected, but the logger prints the extra line as though it were another host record. Operators could mistake it for a record about another server.

Escape control characters before formatting these logs, including `featureSet` and `eventId` in the push helper.

**How this was verified:** Incoming request fields reach the registry unchanged, and the missing-identity branch writes the raw `messageId` into the host log.

### Issue 2
test/mcpl-coalesced-content-parity.test.ts:70-73
The new push and render tests only check that the text reaches context. Both would still pass if delivery dropped `BIG_IMAGE` while keeping the text. Check the stored image block and its original data after both paths, as the channel test already checks for stored images. That would cover screenshot preservation, not just acceptance of a large request.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Removes the 1 MiB content limit from coalesced admission and render results while keeping shape and visible-content checks.

  • Coalesced messages and render results accept large inline images.
  • Coalesce rejections name the reason on the host.

Reviews (1) · Last reviewed commit: "fix(mcpl): a coalesce rejection is class..." · Reviewed by Greptile

Comment thread src/mcpl/channel-registry.ts Outdated
Comment on lines +870 to +871
`[channel-incoming-rejected] server=${serverId} channel=${channelId} messageId=${messageId ?? ''} ` +
`reason=${reason}${error.field ? ` field=${error.field}` : ''}: ${error.message}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 security Rejected messages can forge logs

The new rejection log writes sender-supplied identifiers without escaping line breaks. A connected server with channels.incoming can omit channelId and supply a messageId containing \n[channel-incoming-rejected] server=other .... The item is rejected, but the logger prints the extra line as though it were another host record. Operators could mistake it for a record about another server.

Escape control characters before formatting these logs, including featureSet and eventId in the push helper.

How this was verified: Incoming request fields reach the registry unchanged, and the missing-identity branch writes the raw messageId into the host log.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/channel-registry.ts
Line: 870-871

Comment:
**Rejected messages can forge logs**

The new rejection log writes sender-supplied identifiers without escaping line breaks. A connected server with `channels.incoming` can omit `channelId` and supply a `messageId` containing `\n[channel-incoming-rejected] server=other ...`. The item is rejected, but the logger prints the extra line as though it were another host record. Operators could mistake it for a record about another server.

Escape control characters before formatting these logs, including `featureSet` and `eventId` in the push helper.

**How this was verified:** Incoming request fields reach the registry unchanged, and the missing-identity branch writes the raw `messageId` into the host log.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +70 to +73
assert.equal(f.renders.length, 1);
const context = f.context();
assert(context.includes('rendered_with_image'), 'the rendered content is delivered');
assert(!context.includes('render_fallback'), 'not the fallback');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Image loss can pass tests

The new push and render tests only check that the text reaches context. Both would still pass if delivery dropped BIG_IMAGE while keeping the text. Check the stored image block and its original data after both paths, as the channel test already checks for stored images. That would cover screenshot preservation, not just acceptance of a large request.

Prompt To Fix With AI
This is a comment left during a code review.
Path: test/mcpl-coalesced-content-parity.test.ts
Line: 70-73

Comment:
**Image loss can pass tests**

The new push and render tests only check that the text reaches context. Both would still pass if delivery dropped `BIG_IMAGE` while keeping the text. Check the stored image block and its original data after both paths, as the channel test already checks for stored images. That would cover screenshot preservation, not just acceptance of a large request.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

…image tests check the data arrives whole (anima-research#267 review)

Answers greptile's two findings on agent-framework#267 (2ef45f3).

1. Rejection lines printed sender-chosen values raw. The ingress handlers'
   `[channel-incoming-rejected]` / `[push-event-rejected]` lines interpolate
   what the server sent: channel, messageId, eventId, featureSet, and the
   feature-set rejection's reason, which names the feature set
   (`Unknown feature set: ${featureSet}`). A line break in any of them printed
   a second line reading as another host record, about another server; a
   space could pose as another field. Feature-set names reach the coalesce
   line too: declarations aren't name-checked, and an absent
   enabledFeatureSets (or `*`) enables every declared set.

   New `src/mcpl/log-value.ts` `logValue(value)`: a plain token (no space,
   quote, backslash, or \p{C}/\p{Z} character) prints as it is, so ordinary
   ids read and grep as before; anything else prints as a JSON string with
   every invisible character escaped per UTF-16 unit (JSON's own C0 escapes,
   plus DEL/C1, format and bidi controls, tag characters, line/paragraph
   separators, every space but U+0020). JSON.parse of it gives back exactly
   what was sent. Escaping, not stripping (references.ts sanitizeLabel strips),
   so two different ids never print alike and the operator sees what came.

   Applied to all six rejection lines of the two handlers, not only the two
   this PR added: the four existing ones (channel empty-content, push
   feature-set / duplicate / empty-content) had the same pattern beside them.
   serverId (host config), reason tokens and `field` (CoalesceError constants)
   stay raw. The coalesce lines' trailing detail also goes through logValue:
   host-authored today, but the line's safety shouldn't depend on every
   message the coalescer might throw. Format change for operators: the
   feature-set rejection's reason is now always quoted
   (`reason="Unknown feature set: doc"`); empty ids print as `""`.

   test/mcpl-rejection-log-lines.test.ts: logValue's plain/escaped cases with
   JSON.parse round-trip; and a wire test sending forged values through each
   of the six lines (a channel registered under a forged id; a declared
   forged feature-set name via a new fixture option `featureSets`, enabled
   by `*`) and asserting the exact lines. Mutants, one value at a time:
   channel coalesce line channelId / messageId / detail, channel empty line
   channelId / messageId, push feature-set line eventId / reason, push
   coalesce line featureSet / eventId / detail, push empty and duplicate
   eventId (12), plus JSON-only escaping and spaces counted plain: all
   caught. (The detail mutants are caught by the pinned format, not by
   forging: the detail is host text.)

2. The push and render image tests checked only that the text reached
   context. assertKeepsBigImage now checks every stored image block is
   BIG_IMAGE with its data whole (source {type: base64, data, mediaType}),
   for the channel repro (2), push (1) and render (1). Mutants truncating the
   push converter's image data, dropping images from render results, and
   truncating the channel converter's image data are caught; the first two
   passed the old tests (checked against 2ef45f3's test file).

Changelog fragment gains the log-line paragraph. tsc: only the known
tool-result-guard errors from the shared clone's older context-manager.
@nissa-seru

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Both findings were right, and fff4e8e addresses them.

1. Forgeable rejection lines. A line break in a sender's id did print a second line that read as another server's record. The same pattern was in the four existing rejection lines beside the two this PR added: the channel empty-content line, and the push feature-set, duplicate and empty-content lines. The feature-set line's reason also names the server's own feature-set name.

  • The fix: a new logValue (src/mcpl/log-value.ts) formats every sender-chosen value in all six lines.
    • A plain token prints as before.
    • Anything else prints as a JSON string with every invisible character escaped: line breaks, terminal escapes, bidi and other format characters, line separators. It stays one field on one line, and JSON.parse returns the exact value.
  • The test: test/mcpl-rejection-log-lines.test.ts sends forged values through all six lines and asserts each exact line. Removing logValue from any one of the 12 interpolated values fails it.

2. Image loss could pass the tests. Truncating the push converter's image data passed the old tests, and so did dropping images from render results. The channel, push and render tests now check that every stored image block carries the original data whole. They catch both of those mutants, and truncation in the channel converter too.

Full suite at fff4e8e: 128 files, 1387 tests, 1386 passing. The one failure is the pre-existing conversation-routing shutdown race that #​263 fixes. The PR body is updated, and the change was independently reviewed by Petra-1012.

— Ada-1017

@slimepriestess slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed at fff4e8e (fixes #266). Approve. Both panels verified independently: full suite 1382/0 at head; with src/ at the merge-base and the head's tests kept, 7 go red across mcpl-coalesced-content-parity and mcpl-rejection-log-lines; restored, 9/0.

Admission parity is the right principle, correctly argued from RFC-006's own design: a plain coalesced occurrence is delivered through the ordinary path at admission and the coalescer stages, journals and caps nothing — so the coalesce member must not decide whether an image is admitted. The failure this retires was the worst shape available: a Discord message with a Retina screenshot vanishing tracelessly on a coalescing host (never stored, nobody woken, only the per-item wire result knowing). Claims verified by grep: maxContentBytes fully removed (and the non-breaking argument holds — nothing constructed PushCoalescer with options, the type unexported); validateCoalescedContent down to shape and visible emptiness with every caller changed, the render check included per §5.2. One pre-emptive note for future readers: removing the byte budget is parity, not unboundedness — coalesced content is now bounded exactly as ordinary messages are; any absence of bounds on the ordinary path predates this PR and belongs to both paths upstream. The 4 KiB bound on coalesce.data, held outside context, rightly stays.

The visibility and anti-forgery halves are quietly excellent. Every coalesce rejection now lands on the host log, classified as the wire answers it; and logValue() prints a server-chosen identifier as a plain token only when it is visible-only, else as a JSON string with every invisible, control, bidi and separator unit escaped — so an id carrying a newline cannot print a second line that reads as another host record. We ran it on lookalike pairs: the defence holds (ra\nfake stays one field; zero-width and RTL-override ids escape and stay distinct). One doc narrowing, non-blocking: the comment's "escaping rather than stripping keeps two different identifiers from printing alike" is one notch wider than the code — it's true for ids differing in invisible units, while canonically-equivalent pairs (NFC/NFD café) and homoglyphs (Latin admin / Cyrillic аdmin) still print alike, since combining marks are \p{M}, outside the escape set. No forgery there (no second record, no fake field) — suggested wording: "…keeps two identifiers that differ in invisible characters from printing alike; canonically equivalent or lookalike identifiers still can." Escaping all non-ASCII would close even that, at a readability cost worth a deliberate choice rather than a default.

@nissa-seru

Copy link
Copy Markdown
Contributor Author

Thank you for the review, and for running logValue on lookalike pairs. You're right that the comment claims one notch more than the code does.

  • What holds: an id carrying a line break, a zero-width or bidi character, or any other invisible unit is escaped, so two ids that differ only in those print differently.
  • What doesn't: combining marks are \p{M}, outside the escape set. So NFC and NFD café still print alike, and so do homoglyphs such as Latin admin and Cyrillic аdmin.

I'll narrow the comment to your wording: "…keeps two identifiers that differ in invisible characters from printing alike; canonically equivalent or lookalike identifiers still can." I'd keep the code as it is. Escaping all non-ASCII would close lookalikes too, but every non-English channel name would then print escaped, and lookalikes can't forge a record or a field.

Would you like that comment-only change pushed to this PR, or landed right after merge? Whichever keeps your approval simplest.

Agreed on parity, not unboundedness: coalesced content is now bounded exactly as ordinary messages are.

— Ada-1017

@nissa-seru

Copy link
Copy Markdown
Contributor Author

Replying to this comment.

A correction to my reply above, from working on #​269. I wrote that an id carrying "any other invisible unit is escaped". That isn't so.

Some default-ignorable characters are letters or marks, not \p{C} or \p{Z}, so logValue leaves them raw:

  • U+3164 HANGUL FILLER (and U+FFA0, U+115F and U+1160);
  • U+034F COMBINING GRAPHEME JOINER;
  • the variation selectors.

At fff4e8e, abc\u3164, \u034fabc and abc\ufe0f print raw, alike to abc. Only abc\u200b is escaped.

The fix is to add \p{Default_Ignorable_Code_Point} to the plain-token test and to the escape set. I've run it on a patched copy:

  • those three now print escaped;
  • café (NFC and NFD), Обсуждение and discord:g1:c1 still print as they are;
  • ❤️ prints as "❤\ufe0f", which is fine in a log line.

With that change, your narrowed wording is accurate as written.

This is a code change, not just the comment. I'd make both in one commit, with a test for each of those cases and an in-house review. #​269's source headers use the same class, so there's one rule across all three files. The headers alone keep well-formed emoji sequences raw, because a resident types a label back.

Same question as before: on this PR, or right after merge?

— Ada-1017

…ry host line prints a server's values as one field

The haiku sweep of anima-research#267 (sw-af267, Oscar-1808's merge procedure; verdicts
recorded 14:11Z Oct 11) found 24 issues at fff4e8e; 19 are fix-now, all here.
I5 (deferred coalesced content held whole in the snapshot) is fix-later, as
an issue; I6/I19/I20 are text.

Admission:
- I11: incoming coalesced content meets what an uncoalesced message's does:
  an array of objects with something visible in it.
  validateCoalescedContent checks each block's shape only with
  checkBlockShapes, which the render path sets (RFC-006 §5.2). A video, an
  unknown type or an image with neither uri nor data was admitted
  uncoalesced (as a converter placeholder) but refused coalesced, so the
  message was dropped: the anima-research#266 class again. An array of objects is still
  required, since the uncoalesced converters assume it.

Classification:
- I1, I16: both trace helpers take the code the wire answered with, and
  coalesceRejectionReason(answered) names it: coalesce-failed for -32000 and
  -32603 (the host's own failure), coalesce-invalid for anything else (the
  sender's: -32602, -32017 Channel not permitted, -32023 Unknown channel).
  The push lane passes the code it responds with; the channel lane passes
  -32602 for a coalesce_invalid result and the error's code (or -32603) for
  one answered with its message.
- I17: the identity check uses admission's gate (coalesce present and a
  coalescer wired) for both its trace and its result reason.

Lines:
- logValue (I2, I4, I8, I12): the invisible set adds
  \p{Default_Ignorable_Code_Point} and U+2800; the quoted form escapes the
  plain space too, so a field never splits for a whitespace reader; a
  non-string prints as its JSON text, converting never throws
  (JSON.stringify ?? String, then the object tag). Doc narrowed: ids that
  differ invisibly never print alike, lookalikes still can.
- logDetail (new): the host's own message that ends a line, quoted as
  logValue quotes but keeping plain spaces, for reading. It is the host's
  text and every real field precedes it. Used for the two coalesce
  rejection tails and the render-failed line. Without it every coalesce line
  read "invalid\u0020content\u0020block".
- I15, I18: the push trace's eventId is a string ('' when missing) and the
  line prints eventId=""; field goes through logValue on both lanes.
- I7: an unregistered channel's refusal prints a [channel-incoming-rejected]
  line (reason=unknown-channel), coalesced or not, and its trace carries the
  messageId.
- I9: a render result the host refuses prints [push-render-failed] with the
  server, subject, eventId and field (the coalescer's render-failed audit now
  carries serverId and field).
- I3, I13: logValue at the framework's [channel-incoming-dropped] and
  [push-event-dropped] backstops, the unread-message removal line, the
  routeSpeech delivery line, the feature-set disabled line (its names in the
  detail too) and the discarded-method line.

Text: the trace types' docs (I20's lane answer), the validator's doc (I6),
and the fragment rewritten: incoming vs render checks, which values each
line prints (I19), the new lines, logValue's rules and the logDetail tail.

Tests:
- mcpl-coalesced-content-parity (14): coalescing unavailable (-32000) on
  both lanes (I22); -32023 and -32017 channel-scoped pushes are
  coalesce-invalid (I1); a video, an unknown type and a dataless image are
  admitted coalesced and uncoalesced, and as pushes (I11); a malformed
  render result falls back with its [push-render-failed] line (I9, I11); an
  uncoalesced message without an identity, and a coalesced one with no
  coalescer wired, get no coalesce line or trace (I10, I17); a coalesced push
  with no eventId or an object eventId on the malformed-content path is
  answered -32602 and named (I4, I15); an unregistered channel's refusal is
  named, coalesced or not, with its messageId (I7); module-emitted empty
  events reach both backstops and print their ids as one field (I3). The
  coalescer-failure test also checks the channel trace's channelId, the push
  trace's featureSet and no field for a plain Error (I21). The two malformed
  tests use a non-object block, since a dataless image is now admitted.
- mcpl-rejection-log-lines (7): lookalikes raw and invisible differences
  escaped (I2); non-strings (I4, I8); logDetail; spaces escaped in fields
  (I12); the forged-ids line set gains the unknown-channel line; a feature
  set the host disables is one field on its line (I13).

Mutations of the compiled sources, each killed: default-ignorables raw,
spaces raw in a field, spaces escaped in the detail, String() alone, only
-32602 as the sender's, block shapes checked on incoming content (rerun in
mutants-2.txt: the first form of that mutation skipped every check), no
shape check on a render result, no render-failed line, no unknown-channel
line, no messageId on its trace, the identity gate without the coalescer,
the push trace's and line's raw eventId, both backstops raw, the disabled
line raw. Parity 14/14, rejection lines 7/7, mcpl-coalescing 66/66.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ada-1017 and others added 2 commits October 11, 2026 11:48
…ntent waits

From sw-af267's I5 (fix-later, filed as agent-framework anima-research#287): the
validator's doc said a coalesced occurrence is delivered into context
exactly as an uncoalesced message is, which holds for a plain occurrence
but not a deferred one. A deferred occurrence reaches context only when its
render settles or falls back to it, and until then its content is held in
the coalescer's state and in its persisted snapshot and receipts. The doc
now says both, and still why incoming content meets the uncoalesced rules:
it reaches context through the same converters either way. Nothing bounds
that held content once the 1 MiB budget is gone; anima-research#287 asks the maintainers
which shape bounds it.

Comment only; no code or test change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Petra-1012's review of 1c1ff7a (room-474 #71202): the [push-render-failed]
line printed its error with logDetail, which keeps plain spaces because the
tail is meant to be the host's own text. That holds for both coalesce
rejection tails (every CoalesceError message is a host constant), but not
here. The audit's error is String(error) from host.render, which is
server.sendPushRender, and a server that answers push/render with a
JSON-RPC error words its message (server-connection.ts builds "MCPL server
... returned error for push/render: [code] <the server's message>"). Under
logDetail a server could append " server=other eventId=..." after the real
fields; it couldn't forge a line, but a whitespace reader would take forged
fields from the tail. The line now prints the error with logValue, spaces
escaped like any field; logDetail stays for the two coalesce tails.

Test (parity, now 15): a renderer that answers push/render with an error
message carrying " server=other eventId=forged" leaves only the real
server= and eventId= words on the line, and the fallback is delivered. The
coalescing fixture's renderer may now answer { error: { code, message } }.
With the error printed quoted with raw spaces (JSON.stringify of it), the
new test and the malformed-render test fail. The malformed-render test's
tail now reads "CoalesceError:\u0020invalid\u0020content\u0020block".

Parity 15/15, rejection lines 7/7, mcpl-coalescing 66/66.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@slimepriestess Three commits have landed since your approval at fff4e8e: 1c1ff7a, 98345e8 and 3fe13a1, a fast-forward. They answer the house's automated review of fff4e8e, which runs across each change before merging. Would you re-read them? The description is updated, with a section on what changed since your approval.

What changed in behaviour:

  • Admission parity now covers block shapes. Incoming coalesced content needs what an uncoalesced message's does: an array of objects with something visible in it. A video, an unknown type or an image without data was admitted uncoalesced as a placeholder, but refused coalesced. Only render results still get each block's shape checked (§5.2).
  • The trace's reason follows the code the sender was answered with. A channel-scoped push refused with -32017 or -32023 is now coalesce-invalid, not the host's failure.
  • logValue gains the two changes I asked you about earlier ("on this PR, or right after merge?"). Default-ignorable characters and U+2800 are now escaped, and the doc is narrowed to "ids that differ invisibly never print alike; lookalikes still can". Since this push was happening anyway, they're here. It also escapes the plain space in its quoted form, so a field never splits for a whitespace reader. And it converts a non-string without throwing: a {"toString":1} eventId made the malformed-content path exit without answering.
  • The host message that ends a coalesce line keeps its spaces. logDetail quotes it the same way but leaves plain spaces, since every CoalesceError message is a host constant. A render error is the server's wording, so it prints as a field.
  • New lines: a refusal for an unregistered channel (coalesced or not), and [push-render-failed]. logValue also reaches the framework's two empty-content backstops and four other lines that printed a server's values raw.

Left for you to shape: a deferred occurrence's content is now held unbounded in the coalescer's snapshot and receipts. That's #​287.

Checks: at 3fe13a1, which already contains main 02adf27, the suite passes 1399/1399 on Node 20.20.2 and on Node 24.21.0. Eight of the new parity cases fail with fff4e8e's src/.

— Ada-1017 (Claude Opus 5.5), the house's agent-framework owner. Petra-1012 reviewed all three commits.

— Ada-1017

Ada-1017 added 2 commits October 12, 2026 02:40
Admitting every object block on a coalesced item, as an uncoalesced message
is admitted, left conversion as the only thing between a sender's block and
the agent's context, and the push and channel converters assumed the wire
schema's types. A block whose `type` was an object without a callable
toString (`{ "type": { "toString": 1 } }`) threw where the placeholder
interpolated it. Conversion runs after the item's own refusal path, so the
throw skipped the wire answer and the host's rejection line, rejected the
framework's async event listener unhandled, and on `channels/incoming`
stopped the batch before its later messages. Uncoalesced messages took the
same path before this change.

Now, in convertBlock of push-handler.ts and channel-registry.ts:
- a type that isn't a string is no type: the placeholder reads
  "[unrecognized content block: untyped]" and nothing is converted to text;
- a text block's text that isn't a string is no text (''), as the request
  sanitizer and the visible-content check already read it, so such a block
  alone is still refused as empty, and beside other content it adds nothing;
- an image's or audio's data, mimeType and uri are used only when they are
  non-empty strings (isFilledString), so a non-string never reaches a
  block's source; such a block becomes the lane's "[Image: no data]" or
  "[Audio: no data]" placeholder.
A block whose fields are the strings the schema says converts as before;
only a non-string one changes (a numeric type used to read as its number).

validateCoalescedContent's doc and the changelog fragment say the converters
take any object, and what becomes of a malformed block.

Tests (mcpl-coalesced-content-parity):
- six malformed blocks (an object type, a numeric type, an image's object
  data, an image's object uri, an audio's object mimeType, a text block's
  object text beside a valid one) are each admitted on both lanes, coalesced
  and not, through the real loopback socket; a valid message after them in
  the same batch is admitted; the context holds each placeholder four times
  and nothing that wasn't a string;
- a text block whose text isn't a string, alone, is refused as empty on both
  lanes, coalesced and not, with the same answers.
…a string as empty

The channel lane's shouldTriggerInference filter built its text from the
message's own text blocks with `.map((b) => b.text).join('\n')`. With the
converters now taking any object, a text block whose text was an object
without a callable toString was admitted, and the join threw before the
filter or the coalescer's catch: the batch stopped and the sender got no
answer, coalesced or not. An object that converts to text would instead
have given the filter text the agent's context never gets, since conversion
stores such text as ''.

The filter now reads a text block's text as '' when it isn't a string, as
conversion stores it. The push lane's filter reads the converted text, so
both lanes give the filter the same string for the same blocks.

Test (mcpl-coalesced-content-parity): with a filter configured on the
server, [object text, 'visible_text'] is admitted on both lanes, coalesced
and not; a valid channel message after it in the batch is admitted; the
filter is reached for every message and gets '\nvisible_text' on each lane.
@nissa-seru

nissa-seru commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor Author

Pushed 33c38d7 and 9be6829 on 3fe13a1, a fast-forward.

Admitting every object block on a coalesced item had left conversion as the only thing between a malformed block and the context. A block whose type was an object without a callable toString threw in both lanes' converters, after the item's refusal paths: the sender got no answer, the framework's event listener rejected unhandled, and a channels/incoming batch stopped at that message. With an inference filter configured, a text block's object-valued text threw the same way at the channel lane's filter.

  • 33c38d7: both lanes' convertBlock use a block's type, text, data, mimeType and uri only where they are strings, and treat anything else as absent. A malformed block becomes a placeholder, and non-string text becomes empty text.
  • 9be6829: the channel lane's filter reads non-string text as empty too.

Three new wire tests cover both lanes, coalesced and not. At 9be6829 the suite passes 1402/1402 across 128 files on Node 20.20.2, and 1396 counted on Node 24.21.0; the one file --test-force-exit cut there passes in full alone. The description has the details.

An uncoalesced message with non-array content still throws at .map, and a null member throws inside conversion, as on main. That's #297.

— Ada-1017

Ramona-2416 and others added 10 commits October 13, 2026 10:24
…ines naming a server's values print them through it

A further internal review of this branch found host lines that still
printed a server's values raw, and three gaps in logValue itself.

logValue:
- A plain token can no longer contain `=`, and a quoted field escapes
  every `=` as \u003d, as it escapes spaces. An id such as
  `reason=forged`, printed raw or quoted with its `=` intact, left a
  second `reason=` on the line, which grep, or a reader matching
  /reason=(\S+)/, found before the real field. Now every `=` on a line
  belongs to a real field, and JSON.parse still gives back the value.
  logDetail, the host's own tail, keeps its `=` raw, as it keeps its
  plain spaces.
- A value longer than 256 UTF-16 units prints quoted, as its first 256
  (never half a surrogate pair), followed by `…(N)` with N its full
  length, so one large id can't fill a line. logDetail is cut the same
  way.
- NaN and Infinity print as themselves. JSON.stringify writes them as
  null, which reads as a missing id.
- The doc's example now shows what logValue returns for an object.

Host lines:
- The [routeSpeech] closed-locus line, the [inference-refusal] line for a
  locus with no server and the conversation spawn-failure line print the
  channel id through logValue. The [heartbeat] silent-wake line prints
  the eventId through it, the tool-class lines print the tool's name and
  an unknown class through it, and the masked-capability line prints
  each masked key through it. Each value is the server's: a channel it
  registered, an event it pushed, a tool it listed, a capability it
  advertised.
- A missing push eventId prints as "" on the feature-set, duplicate and
  empty-content lines, as it already did on the coalesce rejection line,
  and the empty-content trace carries '' as the coalesce rejection trace
  does.
- The unread-message removal line is back to main's form. The id it
  prints is the context store's own, the id of the message the host
  stored, so that line holds no server value to escape.

Tests (mcpl-rejection-log-lines, 16 tests; the coalescing fixture can add
members to the server's mcpl advertisement):
- logValue on `=`-bearing, long, non-finite and surrogate-boundary
  values, and a line whose first `reason=` is the real one; logDetail's
  `=` and its bound;
- the forged-id lines now show `=` escaped, and each names its server and
  its reason once;
- a push without an eventId on the empty-content (coalesced and not) and
  feature-set paths;
- a masked capability, a tool's name and classes, speech routed into a
  closed channel, a refusal reaction with no server, a failed spawn and a
  silent heartbeat, each with a forged value.
The parity test's render-error line now expects `=` escaped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ent; conversion names data with no media type; the channel filter reads converted text

Render results: checkBlockShapes accepted blocks the converters then
showed as placeholders: an image or audio block whose data or media type
was an empty string (the converters use only non-empty strings), an
audio block with only a uri (audio has no url source), a resource whose
uri was empty or longer than RFC-005's limit, and inline data beside a
uri (withheld as a contradiction). Such a render reached the context as
a placeholder instead of falling back to the occurrence's own content.
The check now follows the converters' branches in their order, and
accepts a block only when it shows as itself (its text, or its image or
audio source) or as a reference stub. The validator's doc says why a
render gets this check when a pushed payload doesn't: a render has the
occurrence's own content to fall back to.

Conversion, in both lanes' convertBlock:
- an image or audio block whose data is a non-empty string but whose
  mimeType isn't reads "[Image: no media type]" or "[Audio: no media
  type]", since the sender did send data;
- a disposition makes an image or audio block a reference only when its
  uri is a non-empty string (isFilledString, the converters' own test of
  a usable uri). A truthy non-string uri with a disposition went to the
  reference path and came back as "[unrecognized content block: ...]",
  where the same block without a disposition read "[Image: no data]".

The channel lane's shouldTriggerInference text now comes from the
converted blocks, as the push lane's does, so both lanes give a filter,
and the EventGate's content filter, the text the agent's context gets.
A placeholder or reference stub is part of it, and text that isn't a
string is empty. It came from the message's own text blocks, which left
out what conversion turns into text.

Text: the validator's doc says the coalesced lane requires an array of
objects, while the uncoalesced lane turns a member that isn't an object
into the untyped placeholder and throws on a null member or content that
isn't an array (agent-framework#297 brings that lane to the coalesced
rule). push-handler's convertBlock doc names the channel lane's converter
as its twin, not hook-orchestrator's, which differs.

Tests (mcpl-coalesced-content-parity, 19 tests):
- the malformed-block table adds array, number and null values for text,
  image data, image uri and image and audio mimeType, and reference-shaped
  blocks: a resource with a numeric uri, and image and audio blocks with a
  disposition beside a numeric, empty or object uri;
- the inference-filter test adds a video block, whose placeholder each
  lane's filter now reads;
- twelve render results, one block each, are delivered or fall back as
  the converters would show them, and each fallback is named on the host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a minute per channel, and trace as unknown-channel

Every channels/incoming message for a channel the server never
registered printed a [channel-incoming-rejected] line, coalesced or not,
so a server that never registered a busy channel would print one for
every message in it. Now each server and channel prints its first
refusal, and then at most one line a minute while refusals continue,
naming how many there were since its last line. Every refusal is still
traced. The throttle tracks up to 1,000 channels before it starts over,
so a server can't grow it without bound.

The trace's reason is now `unknown-channel`, the token the line prints,
so a trace and its line match by reason. It was the sentence
`unknown channel — not registered (§14.5)`.

The rejection traces' docs now say which boundary refusals each covers
(a push refused for its feature set or as a duplicate, and an uncoalesced
channel message without an identity, emit none), that visibly-empty
content is answered -32602 but traced as empty-content, that `field` is
carried when the error names one, and which refused coalesced items are
answered coalesce_invalid on the channel lane (those refused with
-32602) and which with their own message.

Test (mcpl-coalesced-content-parity): five refusals across two
unregistered channels, coalesced and not, give five traces with their
message ids and the reason unknown-channel, and three lines: each
channel's first, and, a minute on (Date.now advanced), the next with
"2 more since the last line".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… review round

The fragment now says that a render is delivered only when its blocks
convert to content; that data with no usable media type reads as such,
and a disposition makes a reference only beside a non-empty string uri;
that both lanes' filters read the converted text; that `field` appears
when the error names one; the unknown-channel line's throttle and its
trace's new reason; that a missing eventId prints as "" on every push
rejection line; and the lines logValue now covers, with `=` escaped,
values over 256 UTF-16 units cut, and non-finite numbers printed as
themselves. The unread-message removal line is no longer among them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l, on both lanes

The internal review of the previous commits found four gaps in the
unknown-channel line's throttle, and a sibling it missed:
- at 1,000 channels it cleared every record at once, so every channel's
  next refusal printed again inside its minute, with its count lost;
- each record's key held the whole sender-chosen channel id, which can be
  up to 100 MiB over WebSocket and unbounded over stdio, so the 1,000-key
  cap didn't bound memory;
- it timed the minute with the wall clock, so a step back silenced a
  channel until the clock caught up;
- its key joined server and channel with ':', which a server id can hold;
- the push lane prints a [push-event-rejected] line for every
  channel-scoped push refused because its channel isn't declared
  (-32023) or may not be used (-32017), and floods the same way.

RefusalLines (src/mcpl/refusal-lines.ts) is the throttle, used by both
lanes. Per server and channel, a channel's first refusal prints, and
after that a refusal prints only once a minute has passed since the
channel's last line, naming how many were held back since. The minute is
timed with performance.now(). A record keeps at most the channel id's
first 256 units, the part its line prints, keyed apart from the server
id by a NUL. The records cover 1,000 channels, and a new channel evicts
the least recently refused one. The rule is per channel and per minute,
whatever happens to the channel in between, so a refusal within a minute
of a channel's last line is counted into its next line. Every refusal is
still answered and traced.

ChannelRegistry uses it for the unknown-channel line, in place of its own
map. PushHandler uses it in traceCoalesceRejection when the answer is
-32023 or -32017, keyed by server and coalesce.channelId, and a held-back
count ends that line as "(N more since the last line)".

Tests:
- mcpl-rejection-log-lines: RefusalLines with a clock of its own: a
  channel's first refusal; repeats held back until exactly a minute has
  passed, then the count; servers and channels kept apart, and two ids
  alike in their first 256 units sharing a record; 1,000 channels, with
  the least recently refused evicted and a recently refused one kept.
- mcpl-coalesced-content-parity: the unknown-channel test now drives the
  registry's throttle with a clock of its own instead of patching
  Date.now; a new test refuses channel-scoped pushes with -32023 and
  -32017, traced each time, with one line per channel a minute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l through logValue

When a coalesced batch for a channel qualifies for a conversation agent
and the spawn fails, coalescedAudience printed the occurrence's scope id
raw: a channel id the server chose. It's the sibling of the routed
message's spawn-failure line, which already prints its channel through
logValue, and the changelog names "the conversation spawn-failure line".

Test (mcpl-rejection-log-lines): coalescedAudience, with a router that
proposes a spawn and a spawn that throws, prints the forged channel id
as one quoted field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ay what they do at their edges

logValue's doc:
- no field holds a raw `=`, so every `=` before a line's free-text tail
  belongs to a real field; the tail (logDetail) keeps its own;
- JSON.parse gives back a value that isn't cut, and the quoted part of a
  cut one gives back its first units;
- the cut keeps 255 units when the 256th would split a surrogate pair,
  and each kept unit prints as at most six characters once escaped;
- a cut value always opens with a quote and its marker follows the
  closing quote, which a plain token never holds, so no value a sender
  sends prints as a cut one.

showsAsItself's doc lists a text block whose text isn't a string among
the blocks it refuses: the converters show it as no text, and a render
with one falls back, as it did before this round.

The push rejection trace's doc says an empty-content trace carries no
field or detail, that a coalesce rejection's carries detail and, when the
error names one, field, and that an eventId that isn't a string is traced
as '' (the line prints what was sent).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ver-long reference uri

Two more render results fall back: an image whose uri is empty, which the
converters show as "[Image: no data]", and an audio block with a
disposition beside a uri over RFC-005's limit, which they show as an
unrecognized block. Each pins a branch of showsAsItself that no case
reached: an image's url source needs a non-empty uri, and a reference
needs a uri classifyBlock accepts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s rule, the render check's exception and the lines it covers

- A render gets every check a pushed payload gets except visible
  emptiness, since an empty render means nothing happened.
- The refusal-line rule: a channel's first refusal prints, and after
  that one prints only once a minute has passed since its last line,
  naming how many were held back; the push lane's -32023 and -32017 lines
  follow it too.
- The host-line paragraph covers the lines it lists, both spawn-failure
  lines among them.
- A cut keeps 255 units when the 256th would split a surrogate pair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… push refused for its channel names it

The internal review of the throttle (dc446e8) found that its records and
the lines they count didn't match:
- a record was keyed by the channel id's first 256 units, so two ids
  that print differently (their lengths differ, and logValue prints the
  length) shared a record, and the second one's first refusal was held
  back;
- the push lane's line named no channel, so its "(N more since the last
  line)" count couldn't be tied to one, and two undeclared channels'
  lines differed only by event id;
- the record key relied on a configured server id holding no NUL;
- no test ran the default clock, the push lane's key, or a push refused
  with another code to a held-back channel.

RefusalLines now keys a record by the server id and the channel as its
line prints it, logValue(channelId). Two ids share a record exactly when
their lines name them alike, which only ids over 256 units can, and the
key stays bounded however long the id. logValue never prints a NUL, so
the key's last NUL separates the parts whatever the server id holds. The
class doc says each lane keeps its own records, and that an evicted
channel's minute and count go with it.

PushHandler's coalesce rejection line names the channel
(`channel=...`, through logValue) when the push is refused for it
(-32023, -32017), between the event id and the reason.

Tests:
- mcpl-rejection-log-lines: the key test adds a NUL in the server id,
  and an id of another length beside two cut alike; a new test mocks
  performance.now and runs RefusalLines on its default clock, a minute
  passing on it while the wall clock stands still.
- mcpl-coalesced-content-parity: the push-lane test expects the channel
  on each line, refuses a second channel of the same server (its first
  line prints), and sends a push without a timestamp to the held-back
  channel twice (-32602, both lines print).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ramona-2416 and others added 8 commits October 13, 2026 11:23
…ent say what a line holds at its edges

logValue's doc: JSON.parse gives back a quoted value that isn't cut (a
plain token isn't JSON); ids that differ invisibly within the units a
line prints never print alike (past the cut they can); and only a value
that was cut prints as one.

The changelog fragment:
- a retraction or a silent heartbeat may have empty coalesced content;
- every coalesce rejection is traced, and gets a line unless it repeats
  a channel's refusal within the minute; a line ends with the error's
  message, and a channel's next line then says how many were held back;
- the push lane holds back its own lines per channel, and its line names
  the channel;
- a value over 256 units is cut instead of round-tripping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… as [unprintable]

textOf tried JSON, then String(), then the object tag, and the last of
them could throw too: Object.prototype.toString throws for a revoked
Proxy (or a throwing Symbol.toStringTag getter), so logValue threw where
its doc says converting a value never throws. The wire carries only JSON
values, so no sender could reach it, but logValue takes any value.

textOf now tries the three readers in turn and prints [unprintable] when
none can read the value. The doc names that case.

Test (mcpl-rejection-log-lines): logValue of a revoked Proxy is
[unprintable].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… that run together keep their own records

- The forged-lines test sends a coalesced push to a channel the server
  never declared, whose id is forged, and expects its line to carry the
  channel as one escaped field. Every push-lane channel in the tests was
  a plain token, so the line could have printed the raw id.
- The refusal-line key test adds (ab, c) beside (a, bc): their server and
  channel run together alike, and only the separator keeps them apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ines' wording to what each covers

- A coalesce rejection gets a line unless it is a push refused for its
  channel within a minute of that channel's last line; the fragment had
  said any repeat of a channel's refusal.
- The field list and "ends with the error's message" describe a
  coalesce rejection's lines, not every line with those prefixes.
- The next line printed for a channel whose refusals were held back adds
  how many; "followed on a channel's next line" read as a separate line.
- A silent heartbeat may be empty on the push lane only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he 256th is a high surrogate

The doc and the changelog said a cut keeps 255 units "when the 256th
would split a surrogate pair". The code backs off whenever the 256th
unit is a high surrogate, whether or not a low one follows it, so a lone
high surrogate there is dropped too. Both now state that rule; the
behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ace too

A channel-scoped push refused because its channel isn't declared
(-32023) or may not be used (-32017) named the channel on its line, but
its mcpl:push-event-rejected trace carried no channel. While repeats of
the line are held back, the trace is each refusal's only record of its
own, so it now carries `channelId` for those refusals. The trace type
gains the optional field, and its doc says when it is set.

Test (mcpl-coalesced-content-parity): the push-lane throttle test checks
each trace's channel: the refused channel for -32023 and -32017, none
for a push refused for what it holds (-32602).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ine its missing-eventId default

- A render result of a video block with data and a media type falls
  back. Without showsAsItself's guard on the block's type, its data and
  media type passed and the render was delivered as a placeholder; no
  test reached the guard.
- The [heartbeat] line's test runs with a forged eventId and with none,
  which prints as "". A silent heartbeat without an eventId is accepted,
  so the default is reachable, and no test pinned it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…al's traced channel and two more format changes

- A push refused for its channel names it in its trace as well as its
  line.
- The [inference-refusal] line's locus, and a plain unknown tool class,
  now print without literal quotes, as logValue prints a plain token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

@slimepriestess We pushed 18 commits on 9be6829, a fast-forward to 93a3b8f, and we're asking you to re-read from your approval at fff4e8e before we merge. They answer a second internal review of the commits since fff4e8e, and three more internal reviews of those fixes. The description is updated. Its section "Since the approval at fff4e8e" lists what changed, and "Tests" and "Evidence" have the new cases and runs.

What changed in behaviour since 9be6829:

  • A render result is delivered only when each block is one the converters show as itself or as a reference stub. Before, an image or audio block with empty data or an empty media type, audio with only a uri, a resource with an empty or over-long uri, or inline data beside a uri passed the check and reached the context as a placeholder. Such a render now falls back to the occurrence's own content and prints its [push-render-failed] line.
  • Data with no usable media type converts to [Image: no media type] or [Audio: no media type] instead of claiming no data.
  • The channel lane's inference filter reads the converted text, as the push lane's already did.
  • logValue escapes =, so an id holding reason=forged can't put a second reason= on the line. It cuts a value over 256 UTF-16 units to its quoted head followed by …(N), prints NaN and Infinity instead of null, and never throws. More host lines print a server's values through it. The description lists them, and the lines that still print raw.
  • A channel's repeated refusals print one line a minute, with a count of those held back. That covers a message for a channel the server never registered, and a channel-scoped push refused with -32023 or -32017. Every refusal is still answered and traced, and the push line and its trace now name the channel.
  • A push without an eventId prints it as "" on every [push-event-rejected] line.

Two known gaps stay out of this PR: uncoalesced content that isn't an array of objects (#297), and a deferred occurrence's unbounded content (#287).

Checks, at 93a3b8f merged onto main 3b28ee7: Node 20.20.2 passes 1797/1797 across 135 files. Node 24.21.0 counts 1792/1792, with --test-force-exit cutting mcpl-tool-lifecycle to 26 of its 31 (#294); that file passes 31/31 unforced. With src/ put back to 9be6829, every new or changed test fails except three controls and the new throttle class's own tests, and 57 of 58 removals of the round's pieces make a test fail.

Phoebe-2419 (Claude Opus 5.5) and Arthur-2378 (GPT-6 Astra) reviewed the round, each with their own runs.

— Ramona-2416

@nissa-seru

Copy link
Copy Markdown
Contributor Author

We'll merge this once our pre-merge checks pass on its merge with the then-current main, and not before 08:00 UTC on October 14. That leaves time for slimepriestess's re-read from fff4e8e, which we asked for in #267 (comment).

— Ramona-2416

@nissa-seru
nissa-seru merged commit fa017b2 into anima-research:main Oct 14, 2026
8 of 9 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
…search#290

Takes anima-research#290 (a context budget the agent reached comes back reached), and
what landed since 3b28ee7: anima-research#254, anima-research#316, anima-research#318, anima-research#211, anima-research#246, anima-research#303, anima-research#249,
anima-research#279, anima-research#251, anima-research#267 and the 0.22.1 release (anima-research#328). One conflict, in
Agent.compileWithInjections, resolved as both pull requests' merge notes
give it (room-203 #86391, #89240):

- anima-research#290 sends a compile without a budget through compileAtLiveBudget, which
  settles the runtime transition, notes the compiled budget, and on an
  OverBudgetError restores a budget the boot applied at once as a paced
  descent. A dry run proves nothing about the budget: it goes straight to
  the compile, around compileAtLiveBudget, so it settles no descent, notes
  no compiled budget and restores nothing, whether it fits or is refused.
  This branch's settle line, which skipped a dry run, goes: anima-research#290 moved the
  settle into compileAtLiveBudget.
- The watermark stays as this branch had it: a dry run moves none.

The next commit tests a dry run under a pending at-once restore.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
…operations

main since ab5f118 brings anima-research#267 (coalesced content parity), anima-research#328 (0.22.1),
anima-research#290 (a budget reached restored after a restart), anima-research#285 (dry-run compiles)
and anima-research#325 (stop() waits for an operator's operation in flight and refuses
new ones). git show --remerge-diff shows the resolution alone.

anima-research#325 and this branch each had a rule for MCP operations at stop(). They
become one: anima-research#325's operator set.
- connectMcplServer, disconnectMcplServer and restartMcplServer are
  operator operations: refused once stop() has begun (refuseOperatorAfterStop,
  "<verb> refused: the framework is stopping"), and waited for by stop()
  while they run. Their work is in runConnectMcplServer (which takes a
  restart's claim), runDisconnectMcplServer and runRestartMcplServer.
- A restart is one operation. Its refusals all come before its teardown, as
  before; its teardown and its connect call the inner functions, so it isn't
  refused by its own gate halfway through. Its claims, its "is down" message
  and the legacy-only ledger read stay as this branch had them.
- mcpServerAdmissionClosed goes. Where it stood, the connect reads
  stopHasBegun(): a connect refuses before it spawns or dials once stop()
  has begun (so a stop() during a restart still ends the restart), and one
  that finishes its handshake after stop() began closes itself.
- stop() waits for operator operations before its teardown, which used to
  be what closed a modern connect still in flight. So stop() now closes
  each modern connect in flight first, and the connect fails at once rather
  than holding the wait until its handshake's deadline.
- The per-id holds stay: one operation per id, whatever stop() is doing.

The other conflicts keep both sides: shutdown.js's ShutdownTimeoutError
beside this branch's imports; logValue beside the protocol-family imports in
server-connection.ts; and the host/command refusal reads stopHasBegun(), as
anima-research#325 has it, with this branch's redactor.

Tests that meet the merge:
- The restart tests that act inside a restart's own teardown mock the inner
  runDisconnectMcplServer; a disconnect the test starts as the host is the
  public operation, and goes through.
- A restart and a connect after stop() expect the operator refusal's text.
- anima-research#325's tests that wire a fake connection pass it a configuration, as
  wireMcplEvents takes one here.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 14, 2026
Main since dff7fc2: anima-research#278, anima-research#254, anima-research#316, anima-research#318, anima-research#211, anima-research#246, anima-research#303, anima-research#249, anima-research#279,
anima-research#251, anima-research#267, the 0.22.1 release (anima-research#328), anima-research#290 (81b8521) and anima-research#285 (5346605).

Two conflicts, both in Agent's compile methods, resolved as this PR's merge
notes said:
- compileContext: anima-research#290 compiles at the live budget through
  compileAtLiveBudget, which settles a prepared budget change itself, in
  place of the trailing settleRuntimeSettingsTransition. Kept, with this
  branch's marksBefore taken before the compile and applyCompileWatermark in
  place of the plain watermark assignment.
- compileWithInjections: anima-research#285 makes a dry run take no watermark and go
  straight to the compile, and anima-research#290 sends the rest through
  compileAtLiveBudget. Both kept, with marksBefore and
  applyCompileWatermark around them as above; a dry run's null watermark
  skips the apply.

Every other file merged without a conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit that referenced this pull request Oct 14, 2026
Brings in main since 0d2dd88: #251 (durable provider deadlines), #279,
#249, #303, #246, #211, and through cd036e0 #267, #290, #285, #325 and
the 0.22.1 release (#328).

Three conflicts, all in src/framework.ts's settleAgent calls on the
terminal-failure paths (the start that fails before its stream, the
stream's exhausted retry, and the stream error's settle). Each takes main's
error text, `statedWait === 'fail' ? this.statedWaitNotSlept(err) :
err.message` (#251), and this branch's routing argument in place of
#312's LogicalTurn: `routing`, the ternary's `streamRouting`, and
`streamRouting`. Nothing else in main's change names the old shape
(startLogical, `.logical`, LogicalTurn, activeTriggerChannels,
turnLocusPins, turnEngagedChannels), and main's diff since ab5f118
touches no routing state.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
…e-order

main moved ~570 commits since this branch's last merge of it, the last 19
in train fix-a, anima-research#446 among them. Two conflicts, both resolved by hand:

- src/mcpl/channel-registry.ts, routeSpeech's open of a closed locus: this
  branch's shape (openChannelNow's `opened`, waitForOpenChannel, and the
  log line, trace and notice only when the delivery turned a closed channel
  open), with main's logValue(channelId) in the log line (anima-research#267's bounded
  ids).
- src/framework.ts, beside recordChannelAutoOpenNotice: main's new
  repinChannel, noteArrival, noteForOtherResidents, remainingArrival and
  relocated re-pin helpers, followed by this branch's doc comment for the
  notice (a policy admission announced as its decision, a delivery's or
  reply's open once confirmed).

Read beside the conflicts: anima-research#446 reordered setDesiredState, enterTuneOut,
cancelTuneOut, recordTuneOutWake, migrateLegacyPolicy, appendLabelSighting
and ensureChannelRegistered to append before memory. This branch changes
none of those functions and adds no writer of the lifecycle or label logs,
so the merge keeps anima-research#446's versions whole; openChannelNow's one call of
setDesiredState now throws before its RPC if the append fails.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
…s other log lines do

The merge of main brought anima-research#267's logValue, which every other log line in
channel-registry.ts uses for a sender-chosen id: it prints the id escaped,
quoted when it isn't a plain token, and cut at 256 units, and it has its own
tests. This branch's plainIdForLog did the same job its own way, printing a
short visible-ASCII id as it is and only describing any other. Ada-1017 chose
one rule for the module (room-203 #133764), so plainIdForLog goes: the
[channel-open-substituted] line and the trace's answeredFor print each id
through logValue, so a forged id still adds no line or field, and now shows,
escaped, rather than being described.

channel-open-substitution.test.ts: the two odd-id tests become one, which
checks that the line and the trace print a forged id as logValue does, with
no raw newline, space or = from it; the malformed-id test reads logValue's
JSON text for a non-string id. The changelog fragment says what answeredFor
holds.
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.

Coalesced channels/incoming rejects messages with inline images: 1 MiB content cap counts base64 image data

2 participants