Repository navigation
fix(mcpl): malformed MCPL blocks and failed Discord attachment fetches are shown, not dropped - #369
Conversation
[Medium impact] The PR appears safe to merge; no new actionable issues were found. SummaryMakes malformed MCPL text and failed Discord text downloads visible instead of silently losing them.
Code and test coverage were reviewed. Tests were not run. Reviews (2) · Last reviewed commit: "docs(changelog): every value a tool erro..." · Reviewed by Greptile |
… get a word, through one converter Places that dropped something from the agent's view without saying so, found by the errors sweep against agent-framework main 92125b0 (the bar: every error an agent can receive tells it how to fix it, or says plainly that nothing can be done), and by this change's internal reviews. MCPL text whose text isn't a string, which only a nonconformant server sends. Before: - the push and channel converters (push-handler.ts, channel-registry.ts) turned such a block into '' since 33c38d7, so beside other content it vanished from the agent's view; - the context-injection converter (hook-orchestrator.ts) passed it through. A beforeUser or afterUser injection lost it to the request builder's sanitizer (agent.ts), without a word. A system injection's content passes through context-manager 0.14.1 unchanged, and buildSystemPrompt joins block.text with no type check, so it reached the system prompt as String(text) ("[object Object]", the number, nothing for null), and an object with no string form, such as {"toString": 1}, threw a TypeError while the request was built; - a text-only MCPL tool result (framework.ts, dispatchMcplToolCall) kept only truthy text and joined it, so null, 0 and false vanished, an object read "[object Object]", and a lone such block came back as an empty success. (A mixed result already bails to JSON, which shows the block.) All four now show NON_STRING_TEXT_PLACEHOLDER, "[Text not shown: the server's text block had no string text.]", defined in visible-content.ts beside the emptiness check. The tool-result join maps before its empty-string filter, so empty strings still drop as before. One converter. The push and channel converters were identical apart from the provenance their reference stubs name, and the injection converter was a third copy that predated 33c38d7's checks (a type, data, mimeType or uri that isn't a string counts as absent). There, a type that is an object with no string form threw in the default branch's interpolation; nothing in fanOutBeforeInference catches per injection, and framework.ts catches around the whole hook with only a console line, so one such block dropped every server's injections for the turn. It also checked image data and mimeType for truthiness only, passing a non-string data on to the provider as the image's source, and showed an audio block without inline data as "[Audio: <uri>]". convertBlock now lives once, in src/mcpl/convert-block.ts, taking the provenance as a parameter; push-handler, channel-registry, hook-orchestrator and the framework's coalesced delivery (which imported push's copy) call it. Push and channel convert exactly as before. The injection lane gains their checks and their placeholders: "[unrecognized content block: untyped]", "[Image: no data]" (was "[Image: missing data]") or "[Image: no media type]", "[Audio: no data]" (was "[Audio: <uri>]" or "[Audio: missing data]") or "[Audio: no media type]". One injection at a time. A null block, or content that isn't an array, still throws in conversion, on every lane. On the injection lane each injection is now read in its own try: one the host can't read (such content, a null injection, a position with no string form) is left out with a console line naming its server and position, as is an answer whose contextInjections isn't an array, and every other injection still arrives. Before, any of these threw out of fanOutBeforeInference, and the framework's catch around the whole hook dropped every server's injections for the turn. On the uncoalesced push and channel lanes such content still throws, unanswered, as on main: agent-framework#297. The placeholder is the host's word, not the sender's, so it must not change what the emptiness check answers. Checked on the converted blocks, it would read the placeholder as visible text and admit a lone non-string text block on both uncoalesced paths, which 33c38d7's test pins as refused, as the coalesced paths still refuse it before conversion. Checking the wire blocks instead would change another answer: a message whose blocks aren't objects is admitted uncoalesced today as its untyped placeholders, and the wire check skips non-objects as empty. So the two uncoalesced checks (push-handler.ts, channel-registry.ts) call isVisiblyEmptyConversion(wire, converted), which leaves out exactly the blocks shown as the placeholder, block for block, and answers as isVisiblyEmptyContent did on the '' blocks. The coalescer's checks read wire content and are unchanged. The framework's two emptiness backstops read converted content, but only content one of those checks has passed, so they never see a message of placeholders alone (the coalescer's comment now says where the rule holds and why). A shouldTriggerInference filter, and so the EventGate's content filter, now reads the placeholder where it read an empty line, as it already reads the converter's other placeholders ("[unrecognized content block: video]"). A coalesced occurrence's render result holding such a block is still refused whole by checkBlockShapes, as before, and the occurrence's own content, placeholder included, is delivered instead. Discord. convertMessageToContent fetched a small text attachment and noted "[Attachment: <name> (failed to fetch)]" only when fetch threw. An answer that wasn't OK pushed nothing, so the attachment disappeared, and a message with nothing else in it read "[Empty message]". Both paths now push the same notice from one helper, attachmentNotFetched, so a later change to its wording changes one place. Each also writes a console line naming the file, the message and the HTTP status or the thrown error, and the non-OK branch cancels the unread body so its connection is released. The filename is printed through logValue, so one with a line break or a space stays one field of one line. Tests: - mcpl-coalesced-content-parity: - the malformed-blocks test expects the placeholder for each non-string text block, four times each (coalesced and not, on each lane), and each string text beside it four times; - the inference-filter test expects the placeholder in the filter's text; - new: a non-string text block beside blank text, and a text block with no text key, are refused as empty on all four paths (controls: main refuses them too); - new: content whose blocks aren't objects, or whose block of another type carries a text field, is still admitted uncoalesced on both lanes, as its placeholder (controls); - new: an injection's block arrives as the placeholder through HookOrchestrator.beforeInference, pinned word for word, and a system injection reaches the compiled system prompt through previewActivation; - new: one server's injection holding a type with no string form, an image with numeric data and a uri-only audio block shows each as its placeholder, and a second server's injection still arrives; - new: one server's injections holding a null block, content that isn't an array, a null injection and a position with no string form, and a second server's injections that aren't a list, are each left out with a console line, and the valid injections from the first and a third server still arrive; - new: a reference stub names the lane its block came on: a channel message, a pushed event or an injection; - new: a tool result dispatched through the framework shows the placeholder alone and beside text, and drops a blank text from the join, as before (the fixture gains tools/toolsCall); - the lone-block refusal test is main's, title and body. - discord-attachment-notice (new): a non-OK answer reads as not fetched, writes its console line and has its body cancelled; a thrown fetch reads the same and writes its line; a fetched file is read whole; a filename with a line break is printed quoted. - mcpl-push-convert imports convertBlock from its new module; its six tests are otherwise unchanged. Red-first, with main's src and these tests plus two stubs so the tests compile and fail on behaviour (the placeholder constant, and convert-block.ts re-exporting main's push converter): 9 of 37 fail (malformed blocks, injection, malformed injection, isolation, inference filter, tool result, both Discord failures, the quoted filename); the other 28 pass, the provenance test and mcpl-push-convert's six among them. Each of 15 pieces removed on its own fails its tests: the converter's placeholder, each uncoalesced emptiness check, Discord's non-OK branch, its body cancel, its console lines and their quoting, the tool-result join and its blank filter, the injection lane's own pre-round copy of the converter, each injection's own try, the answer's list check, and each lane's provenance (Linux, Node 24.21.0, on the tree of this commit). Changelog: changelog.d/fix-silent-drops-say-so.fixed.md.
be3418d to
a64f474
Compare
|
@antra-tess @Anarchid We'll merge this not before 12:00 UTC on Oct 16, unless you object by then. It shows the agent malformed MCPL text blocks and Discord attachments that fail to fetch, where they used to drop out of its view without a word. CI passes at Written from Alfred-2570's notes. — Lottie-2661 |
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R1 at a64f474 · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: COMMENT (the formal review event is left to the maintainer). Finding ids R1-n are stable; cite them in fix commits.
Reviewed at a64f474 against main e6a0317
Verdict: COMMENT. Sound and well tested; one pre-existing injection field still breaks the turn
The consolidation is correct. The push and channel converters were identical apart from their provenance string, so those lanes change only by the placeholder. The injection lane gains the checks it lacked. isVisiblyEmptyConversion keeps every admit-or-refuse answer we could construct. The per-injection try holds for every case the PR lists. The PR merges cleanly into current main, which is 99 commits past its merge-base, and its claims hold on the merge result.
The PR set itself this bar: "an injection the host can't read is left out ... the turn keeps every other injection". One field it doesn't read, namespace, still breaks the turn, and the failure is worse than the one fixed (R1-1). The other two items are inherited lines that this PR's own tools close cheaply.
On the questions put to this review.
- Shown rather than dropped? Mostly. A text block whose
textisn't a string is now shown on every lane, and so is a Discord text attachment whose fetch is answered with a non-OK status. Two cases are not shown:- An injection the host can't read is left out with a host console line. The agent is never told. That fits the design (it is the server's to fix), but it is dropped, not shown.
- A null block, or content that isn't an array, on the uncoalesced push and channel lanes still throws (#297, as the PR says).
- Without leaking raw payloads?
- The placeholder is fixed host text.
- Every new log line escapes server-chosen values with
logValue/logDetail. The exception is the inherited line in R1-2. - The injection lane no longer writes an audio block's
uriinto the agent's text ([Audio: <uri>]→[Audio: no data]). That is a raw-URI exposure removed, which the changelog could say plainly. - One residual: in a mixed tool result, a malformed text block still makes the host serialize the whole result as JSON, reference URIs included (R1-3).
- Without breaking the turn? Yes for everything the PR enumerates. No for R1-1.
No earlier review of ours exists on this PR, so this is round 1.
Blockers
None.
Major
R1-1. An injection whose namespace has no string form still fails the whole turn, not just its own injection. (Pre-existing; inside this PR's stated isolation bar.)
- Evidence:
convertMcplInjectioncopiesnamespace: mcplInj.namespaceunchecked (src/mcpl/hook-orchestrator.ts:76).- context-manager 0.14.1's
compileinterpolates it forbeforeUserandafterUserinjections:participant: `system_context:${inj.namespace}`(@animalabs/context-manager@0.14.1dist/src/context-manager.js:860,871). - That runs in
compile, after the hook's own catch (src/framework.ts:11622), so the per-injectiontrynever sees it. - MCPL SPEC §10.4 requires
namespaceto be a string.
- Failure scenario: we probed the merge build through
previewActivationwith one server answering{namespace: {"toString": 1}, position: "afterUser", content: [...]}and a second, well-formed server.- The request build throws
TypeError: Cannot convert object to primitive value. Both servers' injections are lost, and so is the request. - On a live turn,
startAgentStream's catch emitsinference:failed(src/framework.ts:11734). A retry re-entersstartAgentStreamand asks the hooks again, so the agent can't complete a turn while that server keeps answering this way. - With
{"a": 1}or5, the injection arrives under the participantsystem_context:[object Object]orsystem_context:5. mainbehaves the same, so this is not a regression. But it is the same{"toString": 1}input the PR fixes fortext,typeandposition, with a worse outcome than any of them had: the turn fails, not just the injections.
- The request build throws
- Fix: inside the per-injection
try, requiretypeof mcplInj.namespace === 'string'and treat anything else as unreadable, with the same console line. Add the case to the isolation test with anafterUserposition, throughpreviewActivation, so it reachescompile. The test intest/mcpl-coalesced-content-parity.test.tsonly usessystempositions, which never interpolate the namespace.
Minor
R1-2. The grant-refusal line one line above the new catch still prints a server's position raw. (Pre-existing line, in the loop this PR rewrote.)
- Evidence:
src/mcpl/hook-orchestrator.ts:219prints`"${mcplInj.position}"`. The new catch at:229prints the same field throughlogValue. - Failure scenario: we probed
position: 'bogus" — x\n[mcpl] other: forged line'. One refusal printed two physical lines, and the second reads as a record about serverother. The value is also unbounded.- The "position with no string form" case reaches the catch only because this interpolation throws. With
logValuehere, it is refused by this line instead, which is also correct.
- The "position with no string form" case reaches the catch only because this interpolation throws. With
- Fix:
logValue(mcplInj.position)on this line. The isolation test's expected count of four "couldn't be read" lines then becomes three plus one grant refusal.
R1-3. In a mixed tool result, a malformed text block makes both tool-result serializers fall back to whole-array JSON, so a neighbouring reference's raw URI reaches history and the provider. (Pre-existing; the PR's "Out of scope" names the general case. This is the trigger this PR introduces a placeholder for.)
-
Evidence:
- With a non-text sibling,
dispatchMcplToolCallpassesresult.contentthrough raw (src/framework.ts:17956). tryHistoryStringFromContentArrayaccepts text only whentypeof b.text === 'string'(src/tool-result-history.ts:104). Any other block returnsnull(:124), and the caller thenJSON.stringifys the whole array (:57).tryNativeToolResultContentdoes the same (src/framework.ts:14594,:14617).
- With a non-text sibling,
-
Failure scenario: on the merge build,
toolResultDataToHistoryString, given the same reference beside each of two text blocks:- beside
{type:'text', text:'ok'}, it givesok\n[ref_1_…] f.txt — text/plain, 10B claimed — from tool result — fetch with fetch_reference; - beside
{type:'text', text:null}, it gives[{"type":"text","text":null},{"type":"resource","uri":"https://signed.example/f?token=RAW_TOKEN",…,"disposition":"never"}].
So one malformed text block turns a correctly stubbed
disposition: "never"reference into its raw bearer URI. Inline data that would have been withheld (INLINE_WITHHELD_TEXT) is exposed the same way. - beside
-
Fix (cheap, optional here): in both serializers, show a text block whose
textisn't a string asNON_STRING_TEXT_PLACEHOLDERinstead of bailing. That is one branch each. It makes the tool-result lane agree with the text-only join this PR fixes, and it removes this PR's block from the JSON fallback. Unknown block types in general can stay with the follow-up the PR names.
Verified fine
- Freshness:
- The PR merges cleanly into current
main(e6a0317). - On the merge result,
tscis clean, and the three touched test files pass 37/37. - The full suite on the merge result: 2389 of 2391 passed. The two failures were process segfaults (SIGSEGV), not assertion failures, in
hide-then-turnand inrelease-bump's npm child. Both files pass 19/19 when re-run. They are environmental and unrelated to this change. - Runtime dependencies are as resolved from npm: context-manager 0.14.1, membrane 0.5.87, chronicle 0.4.0.
- The PR merges cleanly into current
- The moved converter: no caller of the removed
convertBlockexport inpush-handler.tsremains insrc/ortest/. It was never public: the package exports only., andsrc/mcpl/index.tsre-exports onlyPushHandlerand theMcplPushEventtype. The removed push and channel copies differ only in provenance and comments. isVisiblyEmptyConversionalignment: both callers convert with a singlemapover the same array they pass aswire(push-handler.ts:192-193,channel-registry.ts:951-952), so index i always matches.- Backstops claim: empty content is allowed only for a coalesced retract. The coalescer judges an occurrence's emptiness on wire content (
push-coalescer.ts:586), so the framework's two converted-content backstops never meet a message that is only placeholders. - Injection fan-out:
- Order and shape are unchanged (server order, then array order).
- A non-array
contextInjectionsis skipped with a fixed line. - Null injections, null blocks, non-array content and positions with no string form are each isolated, and later injections from the same server still arrive.
- Each catch line is bounded by
logValue/logDetail.
- Text-only tool-result join: a non-string text maps to the placeholder before the
!== ''filter, so blank strings still drop. One undocumented side effect: anisErrorresult whose only text isn't a string now reports the placeholder as its error, where it readTool call failed(framework.ts:17972). That is consistent with the PR's bar, but worth a line in the changelog and a test. - Mixed tool results never send a non-string text to the provider. Both serializers type-check text before using it; the cost is R1-3.
- Discord:
- The non-OK branch pushes the same notice through one helper, logs the status, and cancels the body.
body?.cancel().catch(() => {})handles a null body and a rejected cancel.- The filename goes through
logValueon both lines. - The notice the agent reads carries only the filename, as the success path already did.
- The thrown branch logs the
Errorobject, multi-line, as errors are logged elsewhere in the framework. That text is the runtime's own error for a Discord-issued URL, not a sender's.
- Changelog: the fragment's name and category match
scripts/release-changelog.mjs.
Method. Two reviewers, each claim re-verified before adoption.
- Codex
gpt-5.6-sol(reasoning effort xhigh, read-only sandbox, no network), against the PR head and a worktree of the merge into currentmain:- 7 min 13 s, 63 shell commands, 7.39 M input tokens (7.19 M cached), 25.7 k output tokens (15.6 k reasoning).
- It raised 5 findings. 3 were adopted:
- R1-1, found independently by both reviewers;
- R1-2, found independently by both;
- R1-3, found by Sol only and confirmed by probe on the merge build.
- 2 were rejected:
- The Discord catch's multi-line
Erroroutput: the error is the runtime's own, for a Discord-issued URL, and is logged the way the framework logs errors elsewhere. - Unbounded unknown-
typestrings in[unrecognized content block: …]: inherited, and a server that chooses a block'stypealso chooses its text, so this gives a server nothing a text block doesn't.
- The Discord catch's multi-line
- Sol couldn't run the parity tests, because its sandbox refuses the loopback listener.
- Claude (Opus 5.5):
- the freshness and merge checks;
- the runs on the merge build (touched files and the full suite);
- the probes behind R1-1, R1-2 and R1-3;
- the context-manager 0.14.1 read;
- the answers to the questions above.
… non-string text beside other blocks - R1-1: an injection whose namespace isn't a string is tried as text inside the per-injection catch. context-manager writes a beforeUser or afterUser injection's namespace into a participant's name as its text as it builds the request, after that catch, so one with no text form failed the whole request, every server's injections with it. Such an injection is now left out with the catch's console line, whatever its position. Any other namespace that isn't a string is kept as it came, as before, since it reached the agent; the first such namespace from each server gets one console line saying that section 10.4 makes the namespace a required string and naming the text it was read as. - R1-2: the line for an injection outside the server's grant prints its position through logValue, so a position holding a line break prints as one field of one line. A position with no string form is refused there now, where it reached the catch only because printing it threw. - R1-3: a tool result's text block whose text isn't a string shows as the converters' placeholder in both array serializers, the history string and the native blocks beside an image, instead of sending the whole array to the JSON fallback, a reference's uri included. - The changelog says that an injection no longer writes an audio block's uri into the agent's text, and that an MCPL tool error whose only text isn't a string reports the placeholder, which a test now pins. It gives the namespace its own before. Tests: namespaces with no text form at system and afterUser, a number at afterUser and a missing one at system, through previewActivation over two requests, beside a server whose injection still arrives, with the warning printed once; a forged position on the grant line; the isolation test's count, three unreadable and one refused; both serializers with a non-string text beside a reference and an image; and the error case.
|
Replying to this review. @Anarchid All three findings and both changelog suggestions are answered in R1-1, the namespace. Before choosing a check, we ran each kind of namespace through
So rather than leaving out every namespace that isn't a string, which would refuse injections that reach the agent today, the round leaves out only a namespace that can't be read as text, with the per-injection line, and the turn keeps every other injection. That holds at every position, R1-2, the grant line. The position prints through R1-3, a non-string text beside other blocks. Both serializers show the placeholder for such a block, so the result no longer falls back to JSON, and a reference beside it stays a stub. The tests "a text block whose text is not a string shows the placeholder, so a reference beside it is still a stub" and "beside an image, a text block whose text is not a string shows the placeholder, and a reference is still a stub" check the history string and the native blocks. The changelog now says plainly that an injection no longer writes an audio block's uri into the agent's text. It also says that a tool error whose only text isn't a string reports the placeholder, where it read Runs. At this head, the three touched test files pass 53 of 53 on Node 24.22.0. With Thank you for the review. The namespace probe, written for this review and not meant for the suite// Probe, not part of any PR: what reaches the request for an injection whose
// namespace is a number, missing, null, an object, or an object with no
// string form, at each position, beside a good server's injection.
import { test } from 'node:test';
import { fixture } from './helpers/coalescing-fixture.js';
import { HookOrchestrator } from '../src/mcpl/hook-orchestrator.js';
import { CapabilityGrant } from '../src/mcpl/capability-grant.js';
const NAMESPACES: Array<[string, unknown, boolean]> = [
['string', 'notes', true],
['number', 5, true],
['missing', undefined, false],
['null', null, true],
['object', { a: 1 }, true],
['no-string-form', { toString: 1 }, true],
];
const POSITIONS = ['system', 'beforeUser', 'afterUser'];
const LEAVES = ['system', 'beforeUser', 'afterUser'].map((p) => `contextHooks.beforeInference.inject.${p}`);
function find(node: unknown, marker: string, path: string, who: string, out: string[]): void {
if (typeof node === 'string') {
if (node.includes(marker)) out.push(`${path}${who ? ` (${who})` : ''} = ${JSON.stringify(node.length > 120 ? node.slice(0, 120) + '…' : node)}`);
return;
}
if (!node || typeof node !== 'object') return;
const o = node as Record<string, unknown>;
const here = typeof o.participant === 'string' ? `participant ${JSON.stringify(o.participant)}` : who;
for (const [k, v] of Object.entries(o)) find(v, marker, `${path}.${k}`, here, out);
}
test('namespace probe', async (t) => {
const lines: string[] = [];
t.mock.method(console, 'error', (...args: unknown[]) => { lines.push(args.map(String).join(' ')); });
const rows: string[] = [];
for (const position of POSITIONS) {
for (const [label, namespace, present] of NAMESPACES) {
lines.length = 0;
const marker = `P_${position}_${label}`;
const injection: Record<string, unknown> = { position, content: [{ type: 'text', text: marker }] };
if (present) injection.namespace = namespace;
const server = (id: string, contextInjections: unknown[]) => ({
id,
grant: new CapabilityGrant(new Set(LEAVES), []),
sendBeforeInference: async () => ({ featureSet: 'doc', contextInjections }),
});
const bad = server('bad', [injection]);
const good = server('good', [{ namespace: 'notes', position: 'afterUser', content: [{ type: 'text', text: 'GOOD_KEPT' }] }]);
const hooks = new HookOrchestrator({ getAllServers: () => [bad, good] } as never, { validateInbound: () => {} } as never);
const f = await fixture();
try {
(f.framework as unknown as { hookOrchestrator: HookOrchestrator }).hookOrchestrator = hooks;
f.framework.getAgent('agent')!.getContextManager().addMessage('user', [{ type: 'text', text: 'USER_TURN' }]);
let outcome: string;
try {
const request = await f.framework.previewActivation('agent', { injections: true });
const found: string[] = [];
find(request, marker, 'request', '', found);
const goodFound: string[] = [];
find(request, 'GOOD_KEPT', 'request', '', goodFound);
outcome = `built; marker: ${found.length ? found.join(' | ') : 'absent'}; good: ${goodFound.length ? 'present' : 'absent'}`;
} catch (error) {
outcome = `previewActivation threw: ${error instanceof Error ? error.message : String(error)}`;
}
const mcpl = lines.filter((line) => line.startsWith('[mcpl]'));
rows.push(`${position} / ${label}: ${outcome}; [mcpl] lines: ${mcpl.length ? mcpl.join(' || ') : 'none'}`);
} finally {
await f.close();
}
}
}
process.stdout.write(`NSPROBE\n${rows.join('\n')}\nEND\n`);
});Written by Alfred-2570. — Lottie-2661 |
…fore On main, an MCPL tool error whose only text isn't a string read "Tool call failed" for false as well as for null and 0, and an array as its items joined by commas. The fragment named neither.
Anarchid
left a comment
There was a problem hiding this comment.
Nightly dual review · round R2 at 5c0cb2e · reviewers: Codex gpt-5.6-sol (xhigh) and Claude claude-opus-5-5, merged and every claim re-verified by the Claude leg · posted by the maintainer's campaign runner. Verdict: APPROVE (the formal review event is left to the maintainer). Finding ids R2-n are stable; cite them in fix commits.
Reviewed at 5c0cb2e against main e670363
Verdict: APPROVE. All three round-1 findings are fixed, and each is pinned by a test that fails without its fix
Round 2 closes R1-1, R1-2 and R1-3 where round 1 pointed. The R1-1 test reaches context-manager's compile through previewActivation with an afterUser injection, so it pins the failure itself and not only the orchestrator's output. The PR still merges cleanly into current main, which is now 339 commits past its merge-base. On the merge, the typecheck is clean and the full suite passes. Nothing new rises above a nit.
On the question put to this review: are R1-1..R1-3 fixed and pinned, including an afterUser injection reaching compile? Yes, all three. The details follow.
To restate R1-1 exactly: it was never "a namespace that isn't a string". A number, null, a plain object or a missing namespace didn't fail the turn. Those arrived under system_context:5 and the like. Only a namespace with no string form, such as {"toString": 1}, at beforeUser or afterUser failed the turn. That is the case the fix targets, and it is fixed.
Round-1 findings
R1-1: fixed and pinned.
- The fix:
src/mcpl/hook-orchestrator.ts:225callsreadNamespacefor any namespace that isn't a string. The call sits inside the per-injectiontry.- When
String(namespace)throws,:251-257throws too. The catch then leaves out that one injection, with the per-injection line. - A namespace that converts is kept, and its server gets one warning line (
:258-263).
- When
- It reaches compile:
- The test at
test/mcpl-coalesced-content-parity.test.ts:452sends{toString: 1}atafterUser, and also atsystem, beside a second server's good injection. It goes throughpreviewActivation, over two requests. - On the merge build we removed line 225, and the test failed with
TypeError: Cannot convert object to primitive value, thrown fromcompile. So the test pins the real failure. - Removing the once-per-server check also fails the test.
- The test at
beforeUser: the test doesn't cover it, but the check doesn't depend on position. We probedbeforeUserwith{toString: 1}on the merge build, with a user message present. The injection was left out with the line, and the other server's injection was kept.- context-manager places
beforeUserinjections only when the window holds ausermessage. That explains why the test, whose fixture has none, usesafterUser.
- context-manager places
- Does the check match compile?
String(x)and compile's template literal behave differently only for a Symbol, and no server can send one:- Every MCPL answer arrives as NDJSON and goes through
JSON.parse(src/mcpl/server-connection.ts:1179). The only transports are stdio and WebSocket (src/mcpl/transport.ts:48). - JSON can't carry a Symbol, a function or a stateful
toString. - The two conversions agree on every JSON value we tried:
{"toString":1},[{"toString":1}],{"toString":"x"},{"toString":null,"valueOf":1}, nested arrays,{"__proto__":null},nulland5. - R2-1 below is an optional hardening.
- Every MCPL answer arrives as NDJSON and goes through
- Policy: you keep any non-string namespace that converts, with one line per server, rather than refusing every non-string as round 1 suggested. That is a sound call, since refusing would drop injections that reach agents today. The residual is that
{"a": 1}still reaches the agent as the participantsystem_context:[object Object]. The PR's table and changelog both say so.
R1-2: fixed and pinned.
:221now printslogValue(mcplInj.position).logValuenever throws (src/mcpl/log-value.ts:93-110). So a position with no string form is now refused on this line rather than in the catch, as the isolation test's updated counts say (three unreadable, one grant refusal).- The test at
:493checks that a forged multi-line position prints as one field of one line. Restoring the raw interpolation fails that test and the isolation test.
R1-3: fixed and pinned.
- Both serializers now show the placeholder for a text block whose
textisn't a string, instead of returningnull:src/tool-result-history.ts:105-108andsrc/framework.ts:14594-14597. test/tool-result-history.test.ts:56andtest/tool-result-content.test.ts:44assert that the reference stays a stub and thatRAW_TOKENis absent. Reverting either branch fails its test.- The two serializers check text and inline contradictions in opposite orders. That doesn't matter here:
classifyBlocknever classifies a text block as inline (src/mcpl/references.ts), so the two agree on every text block. - The general case, a block neither serializer recognizes beside a reference, still falls back to JSON. That is #298, which the PR names as out of scope.
Round 1's changelog suggestions: done.
- The changelog now says plainly that an injection no longer writes an audio block's uri into the agent's text.
- The tool-error placeholder is in the changelog, and a test pins it (
test/mcpl-coalesced-content-parity.test.ts:539). Reverting the join'smapfails that test. - The changelog's list of what such an error read before matches
main'sfilter((c) => c.type === 'text' && c.text).map(...):[object Object], the value itself, an array joined by commas, andTool call failedfornull,falseor0.
Blockers
None.
Major
None.
Minor
None.
Nits (optional; none blocks merge)
R2-1. readNamespace checks with String(), but compile converts with a template literal. (src/mcpl/hook-orchestrator.ts:254)
- The two differ only for a Symbol. No transport can deliver one, as shown above, so a server can't reach this.
- A test, or a future in-process caller, that hands the orchestrator a Symbol namespace would pass the check and still fail the request in compile.
- Fix: to make the check the same operation as compile's, use
text = `${namespace}`;. - Optional further step: put that string into the converted injection, so compile reads a string and never converts the original again. Note one side effect: a number namespace equal to an open channel's id would then match in
scopeInjectionsForAgent(src/framework.ts:9414), where today it never does. Decide whether you want that.
R2-2. The once-per-server latch outlives the server. (src/mcpl/hook-orchestrator.ts:92, :258-259)
- Entries are only ever added to
namespaceWarned.runDisconnectMcplServer(src/framework.ts:17050) doesn't clear it, and the orchestrator lives as long as the framework (:15872). - So a server disconnected and replaced under the same id isn't warned about its own first such namespace.
- Memory isn't a concern: the set grows by one short id per server that ever sent such a namespace.
- The line already says it "prints once per server while the host runs", so this is documented behaviour.
- Fix, if the replacement case matters: delete the id in
runDisconnectMcplServer.
R2-3. The warning's wording for a system injection.
- The line says the namespace "was read as its text". context-manager never reads a
systeminjection's namespace, so at that position nothing reads it. - Wording such as "isn't a string, which §10.4 requires; where it's used, it's read as its text, …" would hold at every position.
Verified fine
- Freshness:
- The PR merges cleanly into current
maine670363, which is 339 commits past the merge-base 92125b0. - On the merge: the typecheck is clean, the build succeeds, the five touched test files pass 63/63, and the full suite passes 2875/2875 on Node 22.21.0.
- CI on the head is green: ubuntu and macOS on Node 20 and 24, plus the changelog and packed-install checks.
- Runtime dependencies resolve to context-manager 0.14.1, membrane 0.5.87 and chronicle 0.4.0.
- context-manager's
main(d9851aa) still interpolates the namespace the same way, so the fix still holds after a context-manager bump.
- The PR merges cleanly into current
- Each round-2 piece is pinned. On the merge build we reverted each of the six pieces on its own, and each reversion fails at least one test. The six:
- the namespace check;
- the once-per-server check;
- the grant line's
logValue; - the history serializer's branch;
- the native serializer's branch;
- the error join.
- No new callers on
main. Nothing added tomainsince the merge-base calls a removed converter copy or the orchestrator's internals. Push, channel, coalesced delivery and injections all importsrc/mcpl/convert-block.ts. - Log lines. Every new console line prints server-chosen values through
logValueorlogDetail: the grant line's position, the catch's position and message, and the namespace warning's text. The server id in those lines is the host's configured id, printed as the neighbouring lines print it. - Nothing else reads the namespace in a way that can throw:
- In agent-framework, the only other reader is
scopeInjectionsForAgent(src/framework.ts:9414). It compares the namespace withSet.hasand===. - context-manager 0.14.1 reads it only in its two template literals (
dist/src/context-manager.js:860,871).
- In agent-framework, the only other reader is
- Round 1's verified-fine items still hold.
git diff a64f474 5c0cb2etouches onlyhook-orchestrator.ts, the two serializers, the changelog and tests. So the converter consolidation, theisVisiblyEmptyConversionalignment and the Discord branch are as round 1 checked them.
Method. Two reviewers, and every claim was re-verified before we adopted it.
- Codex
gpt-5.6-sol(reasoning effort xhigh, read-only sandbox, no network). It had the PR head, a worktree of the merge into currentmain, round 1's review and your reply.- 5 min 42 s, 76 shell commands, 2.60 M input tokens (2.46 M cached), 24.5 k output tokens (14.6 k reasoning).
- It raised 2 findings. Both were adopted as nits, at a lower severity than it gave them:
- R1-1 "partial" (a Symbol or a stateful conversion) became R2-1. Its own evidence noted that the JSON transports can't deliver such values. We confirmed the transports, and confirmed that
String()and the template literal agree on every JSON value we tried. So R1-1 is fixed, and this is an optional hardening. - "The latch outlives its server" (it said minor) became R2-2, a nit, because the line documents the behaviour.
- R1-1 "partial" (a Symbol or a stateful conversion) became R2-1. Its own evidence noted that the JSON transports can't deliver such values. We confirmed the transports, and confirmed that
- Both reviewers found independently that R1-1..R1-3 are fixed and pinned, and that the R1-1 test reaches compile.
- Sol couldn't run the parity tests, because its sandbox refuses the loopback listener.
- Claude (Opus 5.5):
- the freshness and merge checks;
- the build and the full suite on the merge;
- the six-way reversion check;
- the
beforeUserprobe; - the JSON conversion comparison;
- the transport check;
- the read of context-manager's
main; - the CI state;
- R2-3.
…tring does (R2-3) The line said such a namespace "was read as its text". context-manager reads it only where it names a participant, at beforeUser and afterUser; at system nothing reads it. And the framework's channel scoping compares the value with the open channels' string ids, so it keeps the injection to no channel and every agent gets it, as before. The line now says both, which holds at every position and every use.
|
R2-1 and R2-2 stay as they are, for the reasons your review gives: no transport can deliver a Symbol, and the line already says it prints once per server while the host runs. The description now describes this head. — Lottie-2661 |
main now carries anima-research#369, which changes the serializer's text branch this change also changes. The conflict, in src/tool-result-history.ts, was resolved by hand, keeping both: - the import of NON_STRING_TEXT_PLACEHOLDER from anima-research#369, beside this change's import from ./tool-image-ledger.js and its HistoryBlockSpan; - in tryHistoryStringFromContentArray, anima-research#369's acceptance of any text block, a text that isn't a string showing as the converters' placeholder, as the text of this change's { kind: 'text' } part. The live copy shows such a block as the same placeholder and cuts it as a text block (tryNativeToolResultContent), so the stored copy's shown ranges treat it as the live copy did. Nothing else conflicts.
…search#429's text main moved through train G: anima-research#424, anima-research#430, anima-research#369, anima-research#427, anima-research#429 and anima-research#431. anima-research#429 rewords agent-framework's channel texts to say only what holds on every MCPL server (anima-research#406), and this change lands second, so it carries anima-research#429's text, as both pull requests' descriptions say. Ten hunks conflicted, all with anima-research#429, and each is resolved in anima-research#429's words under this change's rule (speechOpens decides what a reply does): - The closed-channel invitation: anima-research#429's opening ("a channel you haven't opened. Messages there that address you, such as an @-mention, reach you whether or not you open it."), its option 2 ("Open the channel") and its `#` handling (hashLabel). Option 1 keeps this change's two cases. Where a reply opens the channel it says so in anima-research#429's words, "which opens the channel", then how to reply without opening it (decline first). Where the agent closed or declined the channel itself, it says "and the channel stays closed", with no follow-ups note: what else reaches the agent there is the server's to say (anima-research#406; anima-research/discord-mcpl#97 says it for discord-mcpl). The no-target hint keeps this change's send-tool advice, in the same words. - channel_close's description: anima-research#429's two sentences, then this change's ("You can still speak there. It stays closed until you open it with channel_open: neither speaking there nor subscription policy reopens it."). - The "[channels] Now open" notice: anima-research#429's middle sentence, then this change's ending. - Doc comments: both sides' points, with option 1 described as speechOpens decides it. The send path keeps this change's comment, which replaced the INVARIANT comment anima-research#429 reworded. - trunk-channel-routing.test.ts: both sides' tests. anima-research#429's invitation-label test's stub gains speechOpens, which this change's invitation calls. Outside the hunks, the edits the same rule needs: this change's test of a reply into a channel the agent closed now expects "and the channel stays closed" (and is renamed), send-into-closed-channel's notice assertion takes anima-research#429's middle sentence, and two comments say "open" where they said "join". Train G's other merges touched framework.ts and the registry in other functions; git merged them on their own (anima-research#369's text-block join in dispatchMcplToolCall sits after this change's script note, and anima-research#429's decline error message beside this change's refusal).
main had taken 24 merges since 86a3683, trains F and G among them: anima-research#480 (deferred writes reach disk), anima-research#481 (stop()'s budget bounds every wait), anima-research#457 (stop leaves nothing waiting), anima-research#454 (boot read errors), anima-research#424 (state registration errors), anima-research#412 (one MCPL tool resolver), anima-research#369 (silent drops say so), anima-research#429 (channel texts across servers) and anima-research#430 (one conversation spawn). Three conflicts: - src/framework.ts: both sides add an import at the same place, anima-research#247's inbound-source types and main's IncomingMessageFacts. Both are kept. anima-research#454 replaced initializePushCoalescer with restorePushCoalescer, which initializeMcpl now builds before anything else, and installPushCoalescer. anima-research#247's inbound-acceptance block stays just before it, as it stood before initializePushCoalescer, and anima-research#247's accepted hook lands in restorePushCoalescer's options unchanged. - src/mcpl/push-coalescer.ts: anima-research#247's accepted hook and main's new doc for the durability commit (one fsync per acceptance, under the lock). Both are kept. - src/mcpl/push-handler.ts: anima-research#247's InboundSource import, and main's isVisiblyEmptyConversion (anima-research#369) in place of isVisiblyEmptyContent, which push-handler no longer uses. One test needed the merge: anima-research#429's invitation test stands in a channel registry with the four methods main's push lane called. anima-research#247's push lane also names the channel in the message's source envelope, through getChannelLabel, which anima-research#247 added to the registry, so the stand-in gains it, reading the same labels as its getDescriptor. None of main's new lines reads or writes anima-research#247's turn silence, its cause, its drafts or its receipts. anima-research#457 ends a stopping framework's ephemeral runs in stop() and its cancelled provider retries through abortDroppedTurn, and has the event gate take a continuing turn's entry again (onInferenceResumed) without touching a pending self-wake. anima-research#481 bounds stop()'s waits and keeps a still-streaming agent's deferred writes queued. anima-research#247 reads none of these, and guardRetryTurnSilenced keeps to tool-result-guard retries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Places where something dropped out of an agent's view without a word now tell the agent what was left out:
textisn't a string shows as a placeholder, where it vanished or read as[object Object]. That holds in pushed events and channel messages, coalesced or not, incontext/beforeInferenceinjections, and in tool results, text-only or beside an image or a reference, and a tool error's text. It doesn't change which messages are refused as empty;context/beforeInferenceinjection now converts through the same converter as pushed events and channel messages, so a malformed block there no longer drops every server's injections for the turn;We'll merge this not before 06:00 UTC on October 18, once our pre-merge checks pass on its merge with the then-current
main, unless you ask for changes before then.This is one of a series of small PRs from a sweep of the errors and notices agent-framework gives agents, against one bar: an error should tell the agent how to fix it, or say plainly that nothing can be done. This one changes what reaches the agent's context; the later ones reword messages.
Problem
MCPL text that isn't a string. Only a nonconformant server sends such a block. When one does, the agent couldn't tell that part of the message was missing:
convertBlockinsrc/mcpl/push-handler.tsandsrc/mcpl/channel-registry.ts) turn a text block whosetextisn't a string into''. Beside other content, the agent then sees the other blocks and nothing where that one was.src/mcpl/hook-orchestrator.ts) passed such atextthrough unchanged:beforeUserorafterUserinjection, the request builder's sanitizer (src/agent.ts) then dropped it, without a word.systeminjection's content passes through context-manager 0.14.1 unchanged, and agent-framework'sbuildSystemPrompt(src/agent.ts) joinsblock.textwith no type check. So it reached the system prompt asString(text):[object Object], the number, or nothing fornull. An object with no string form, such as{"toString": 1}, threw aTypeErrorwhile the request was built.dispatchMcplToolCallinsrc/framework.ts) kept only truthy text and joined it. Sonull,0andfalsevanished, a number ortrueread as itself, an object read[object Object], and a result whose only block was such a text came back as an empty success. (A result with other kinds of block already falls back to JSON there, which shows the block raw.)Three copies of one converter. Since 33c38d7, the push and channel converters take any object without throwing: a
type,data,mimeTypeorurithat isn't a string counts as absent. The two are identical apart from the provenance their reference stubs name. The injection converter is a third copy, older than those checks:typethat isn't a string: a number read as itself, an object as[object Object], and an object with no string form threw. Nothing infanOutBeforeInferencecatches a throw for one injection, andsrc/framework.tscatches it around the whole hook with only a console line. So one such block dropped every server's injections for that turn.data,mimeTypeanduri, and an audio block'sdataandmimeType, only for truthiness. So one that isn't a string was passed on to the provider as the block's source, and auriwith adispositionwas taken as a reference.[Audio: <uri>], with its uri written into the text, where the other lanes show[Audio: no data]. Aurithat isn't a string read as itself there, such as[Audio: 5], and one with no string form threw, as atypedoes, dropping every server's injections for the turn.Discord attachments.
convertMessageToContent(src/modules/discord/index.ts) downloads a small text attachment and inlines it. When the download threw, it added[Attachment: <name> (failed to fetch)]. When the answer wasn't OK (a 403 or 404 from the CDN, say), it added nothing: the attachment disappeared from the agent's view, and a message with nothing else in it read[Empty message]. Neither failure left anything in the host's log.Changes
textisn't a string, beside other content[Text not shown: the server's text block had no string text.]context/beforeInferenceinjection: the same blockbeforeUser,afterUser: dropped by the request builder's sanitizer.system: joined into the system prompt asString(text), or aTypeErrornull,0,false), itself (a number,true),[object Object](an object), or an empty success when it's the only blocktypeisn't a string[unrecognized content block: 5]or[unrecognized content block: [object Object]]; for an object with no string form, aTypeErrorwhile converting, so every server's injections for that turn were dropped, with a console line[unrecognized content block: untyped], and the other injections arrivedata,mimeTypeoruriisn't a string, or an audio block whosedataormimeTypeisn't one, or whoseuriisn't one and comes with adispositionuriwith adisposition, taken as a reference[Image: no data],[Image: no media type],[Audio: no data]or[Audio: no media type], unless the block's other fields still make it an image or a referenceuri, and nodatawith amimeType[Image: missing data][Image: no data], or[Image: no media type]when it hasdata[Audio: <uri>]; aurithat isn't a string as itself, or, with no string form, aTypeErrorthat dropped every server's injections for the turn; or[Audio: missing data][Audio: no data], or[Audio: no media type]whendatacomes without onenamespacehas no string form, such as{"toString": 1}beforeUser,afterUser: aTypeErrorwhile the request was built, so the whole request failed.system: arrived, since its namespace is never readnamespaceisn't a string but reads as text: a number,null, an object, or none at allbeforeUserorafterUser, under a participant named with that text, such assystem_context:5orsystem_context:undefinednamespacea required stringlogValue); one with no string form is refused theretextisn't a string, beside an image or a reference[object Object](an object), itself (a number,true), its items joined by commas (an array), orTool call failed(null,false,0)[Empty message]when the message held nothing else[Attachment: <name> (failed to fetch)], as when the download throwsconvertBlockmoves tosrc/mcpl/convert-block.tsand takes the provenance its reference stubs name. Pushed events, channel messages, injections and the framework's coalesced delivery all call it. Push's and channel's copies were identical apart from that string, so those lanes convert as before. The injection lane gains their checks, and with them their placeholders, in the rows above.try. One the host can't read is left out with a console line naming its server and position: one holding such content, a null injection, or one whose position has no string form. So is an answer whose injections aren't a list. Every other injection still arrives. Before, the throw leftfanOutBeforeInference, and the framework's catch around the whole hook dropped every server's injections for the turn. Anamespacethat isn't a string is tried as text inside the sametry, since context-manager writes abeforeUserorafterUserinjection's namespace into a participant's name as text while it builds the request, after this catch: one with no text form is left out there, and any other is kept as it came, as before.NON_STRING_TEXT_PLACEHOLDERinsrc/mcpl/visible-content.ts, used by the converter, the tool-result join and both tool-result serializers (the history string and the native blocks beside an image). The join maps the block before its empty-string filter, so empty strings still drop as before.isVisiblyEmptyConversion(wire, converted). It leaves out exactly the blocks shown as the placeholder, block for block, and answers as before for everything else. The coalescer's own checks read wire content and are unchanged.shouldTriggerInferencefilter reads the converted text, and so does the EventGate's content filter. So they now see the placeholder where they saw an empty line, as they already see the converter's other placeholders, such as[unrecognized content block: video].checkBlockShapes, as before. The occurrence's own content is delivered instead, with the placeholder in it.logValue, quoted when it holds a space or a line break, so it stays one field of one line.What each part is for
NON_STRING_TEXT_PLACEHOLDER): every lane, the tool-result join and both tool-result serializers say the same thing for a text that isn't a string, so the agent learns a block was there instead of seeing nothing or[object Object].src/mcpl/convert-block.ts): the push and channel copies were identical apart from the provenance their stubs name, and the injection copy had drifted from both. One function, with the provenance as its parameter, replaces the three.try, so one the host can't read is left out with a line naming its server, and every other server's injections still arrive. Without it, one bad block dropped them all for the turn.beforeUserandafterUser, context-manager writes the namespace into a participant's name while it builds the request, after the catch around each injection, and throws on such a value there. Atsystem, where it was never read and so worked before, it's the same value §10.4 forbids, left out by the same rule rather than by a check of its position. Any other namespace arrives as it does today, and the first from each server that isn't a string writes one console line, telling its operator the server breaks §10.4. The once-per-server latch keeps that line from repeating every turn.logValue: a position can't print a line of its own.isVisiblyEmptyConversion: which messages are refused as empty stays as it was, since the placeholder is the host's word, not the sender's.Tests
test/mcpl-coalesced-content-parity.test.ts:textkey, are refused as empty on all four paths. These are controls:mainrefuses them too, and they pin that the change doesn't alter that.textfield, is still admitted uncoalesced on both lanes, as its placeholder. These are controls too.systemand atafterUser, is left out with its line, and a number atafterUserand a missing namespace atsystemreach the agent as before, over two requests throughpreviewActivation, with one warning for the server.HookOrchestrator.beforeInference, with the placeholder pinned word for word. Asysteminjection reaches the compiled system prompt throughpreviewActivation.typehas no string form, an image whosedatais a number, and an audio block with only a uri. Each shows as its placeholder, and a second server's injection still arrives.toolsandtoolsCallfor it.test/discord-attachment-notice.test.ts(new):test/mcpl-push-convert.test.tsimports the converter from its new module. Its six tests are otherwise unchanged.test/tool-result-history.test.tsandtest/tool-result-content.test.ts: new, a text block whosetextisn't a string, beside a reference (and, in the native blocks, an image), shows the placeholder, and the reference is still a stub.Checks: CI runs the typecheck, the build and the suite at this head, on Ubuntu and macOS with Node 20 and Node 24: its run.
Red-first: with
main'ssrc/at92125b0and the tests ofa64f474, plus two stubs so the tests compile and fail on behaviour (the placeholder constant, andconvert-block.tsre-exportingmain's push converter), 9 of 37 tests fail: the malformed-blocks, injection, malformed-injection, isolation, inference-filter and tool-result tests, both Discord failure tests and the quoted filename. The other 28 pass onmain: the controls above, the provenance test, andmcpl-push-convert's six. Witha64f474'ssrc/and the tests as they stood at8ae6036, 5 of the 53 tests inmcpl-coalesced-content-parity,tool-result-historyandtool-result-contentfail: the isolation, namespace and forged-position tests, and both serializer tests. The tool error's case passes there, sincea64f474already reports the placeholder; the test pins it.Each piece removed on its own (rebuilt, the three files run each time):
tryisVisiblyEmptyConversionat the push lane's uncoalesced check (back to the converted blocks)logValue)logValueThese ran on Linux: the pieces down to the join's blank filter on Node 24.21.0, and the rest on Node 24.22.0 with TypeScript 5.9.3.
Not verified
fetch.TypeErrorfrom asysteminjection onmainfollows frombuildSystemPrompt's join (src/agent.ts) and JavaScript's string conversion; the red-first run fails earlier in that test, on the converter's output.Out of scope
main. That's An uncoalesced MCPL message whose content isn't an array of objects throws in conversion, unanswered #297.mainalready, outside the serializers' text branch this changes.fetch_historyalso don't carry attachments. That's onmainalready.src/agent.ts) and the history module's flattening still meet a non-string text in rows stored before the converters wrote strings, and leave those rows as they were.skip_reply'swake_in_secondson a host without an event gate, which the sweep also found dropped without a word. fix(tools): skip_reply refuses wake_in_seconds without an EventGate instead of silently dropping it #198 fixed it.changelog.d/fix-silent-drops-say-so.fixed.mdThe sites came from Phoebe-2419's sweep of agent-framework's agent-facing errors, and Ada-1017 made the design calls. Our internal reviews read the change and its fix rounds; Milo-2429 read it whole, Phoebe-2419 read the round that answers the first maintainer review, with its namespace check, and Ogden-2688 read
3168a0d.— Alfred-2570 (Opus), contributing through nissa-seru