Repository navigation
fix(tool results): keep an RFC-005 reference's uri out of JSON at any depth - #425
Merged
nissa-seru merged 3 commits intoOct 18, 2026
Merged
Conversation
… depth A tool result's data that isn't a content array of blocks the serializer recognizes was stored, and sent to the agent, as JSON.stringify of the data. An RFC-005 reference in it (a `resource` with a uri, or an `image` or `audio` given by uri) was written with its raw uri, signature and all, wherever it sat: beside a block of another type, under a key, inside another block, or as the whole data. RFC-005 keeps a reference's uri out of the agent's context, since a signed uri is a credential. Inside a content array of recognized blocks, the reference lanes already showed it as its stub. The fallback is now historyJson: JSON of the data in which each value RFC-005 governs is shown as the lanes show it, at any depth. A reference is its stub (an invalid one, such as MCP's embedded resource, the lanes' unrecognized-block line), and inline data that also claims a uri or a disposition is withheld. A uri or data that is null or absent counts as none, so a record that only shares a type name stays data. Everything else is written as JSON writes it, as before: inline media that claims neither, bytes included, and a resource_link's uri among the rest. The same text reaches every caller of the serializer: the stored copy, a script's tool call, the direct Agent API's tool result, a withheld result's kept fields, and the wire. toMembraneToolResult, given no stored copy, wrote a JSON.stringify of its own; it now makes its text with nonErrorResultText, as the stored copy is made. classifyBlock, which every lane consults: - counts any uri beside inline data, a string or any other value but null, as the vector-2 contradiction, so inline data beside an object uri is withheld in every lane rather than shown (and, with the fallback now writing clean inline media as JSON, never written with that uri); - says, for an image or audio block with neither string data nor a string uri, which of the two it has that isn't a string, where it said "neither data nor uri" for those too. The script path's comments say what it fetches (a content array's references, of which an in-process module's is refused for want of a server) and what a script gets. Not in this change: inline media that claims no uri is written as JSON with its bytes wherever the fallback writes it, as on main, and a nested reference's stub is not a save_recent_image slot, as the nested reference wasn't on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…de a uri that isn't a string, and a module's own media records are written as before From the internal review of 02807fc. The fallback governed an image or audio record whenever its data was any non-null value, so a module's own record with an array, object or number for data and no uri was replaced by an unrecognized-block line, losing fields such as a url the model needs. Data that isn't a string isn't inline data: such a record is written as JSON, as on main. A uri that is empty counts as none too. classifyBlock had counted a uri of any value beside inline data as the vector-2 contradiction in every lane. The lanes (content arrays, the native wire, the MCPL converters and the render check) never show that uri, so it exposed nothing there, and the change withheld or rejected blocks main showed. They go back to main's rule. The JSON fallback, which writes every field, withholds inline data beside a uri of any non-empty value itself. A withheld result's kept fields were picked out of its data into a new object, so data that is itself a reference lost the type that makes it one, and a kept uri was written as JSON. Such data now keeps its stub. The withheld notice named a bulk disposition for every cause; it now names a uri or a bulk disposition. Tests: module media records with non-string data, and a resource with an empty uri, written as JSON; inline data beside an object uri withheld in the JSON but shown as a placeholder in a content array; an image reference that is the whole data takes a save_recent_image slot, as in a content array; a bare withheld block is the notice, unquoted; a reference that is the whole data keeps its stub as its kept fields; the wire's cut with a cap matches the stored copy's; a media block with neither keeps its line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The PR appears safe to merge; no new actionable issue was found. SummaryExtends RFC-005 reference hiding to the JSON fallback at any depth.
Reviews (2) · Last reviewed commit: "fix(tool results): the JSON fallback alo..." · Reviewed by Greptile |
1 task done
Brings main 0756302 in. One conflict, in src/tool-result-guard.ts's imports: both sides' names are kept (ensureStateRegistered from main, rfc005TextOf from this change). One expected line in test/tool-result-reference-uri.test.ts takes main's wording for a reference with no server: "reference unbound: no server is recorded to fetch it from". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
1 task done
nissa-seru
pushed a commit
that referenced
this pull request
Oct 18, 2026
… tool call, and the blocks the host can't show say what happened in the agent's words (W4a) The first of W4's three parts, rebuilt on main 0756302 (Ada-1017, room-203 #139500, #139890). Each text says what happened, whether anything took effect, and what works from there, or that nothing does; method names, request ids, JSON-RPC codes, close codes and field names go to the host's log, where the agent's text no longer carries them. A failed MCPL request (server-connection.ts), the whole of what an agent reads for an MCPL tool call: - a connection that closes with a call pending, whether the server dropped or this host recycled the transport (reconnectAfterFailure): the outcome is unknown, check before trying again, and whether this host is reconnecting; the log has the close's code, signal and reason, or that this host closed it, and the requests left unanswered; - this host's own close() with a call pending: the outcome is unknown; - a request on a closed connection: it wasn't sent, nothing ran, and whether this host is reconnecting; - a server's error answer: in the server's words, or that it gave none; the log has the method, id and code; - a timeout: the outcome is unknown, the request was abandoned here and may still complete on the server; the log has the method, id and ms. The framework's MCPL tool calls (framework.ts, B1-B3 and C9 of the sweep's sample): a server missing from the registry, which this host won't bring back on its own; a tool its policy denies; an error result with no text, whose effect is unknown; a conversation's channel_open. Blocks the host can't show (references.ts, convert-block.ts, visible-content.ts): each marker says what kind of thing was there and why it can't be shown; classifyBlock's invalid kind carries the text shown in its place, and unknownBlockText reads the whole block; an image or audio block with an empty link reads as one with none on every lane. The expiry reasons and F22's unbound reason take the agent's words (Ada-1017 #109423 for the unbound one). Rebased onto main d6593a3, which carries #425. Its mediaWithoutStringReason gave the tool-result lane's stub a reason naming which field of a media block wasn't a string; with the markers here, such a block reads as one with no usable data or link, as the push lane already read it, so the helper goes and #425's tests take the markers' words. INLINE_WITHHELD_TEXT, which #425 reworded, takes this change's words. Rebased onto main 0d19f7c, after train J, two of whose pull requests met this change (Ada-1017 agreed the resolution, room-203 #140908): - #208 bounds a server's error code and message in the error a request rejects with. Here the message stays the server's words, bounded with boundedForLog as #208 set; the code goes to the log line, which logValue prints for any value without throwing, so #208's test of the code's bound reads the log line. - #490 makes a server's error result a failure on each path, through mcplToolFailure. Its text for an error result with no words is this change's, with the server and the tool's called name passed in, so a script's call and the model's read alike.
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 18, 2026
main at d9bd6dc brings anima-research#460, anima-research#462, anima-research#466, anima-research#424, anima-research#445 and anima-research#425 into code this change rewrites. The resolutions: - src/modules/workspace/index.ts: - anima-research#460: editablePath parses a path with parseFilePath, so edit and edit_many refuse a path in a nested mount as every file tool does, and applyEdits' pass names the paths it settles ({ named: true }), so a file the mount passes over by design is taken in as a named sync takes it. - anima-research#462: commitToolChanges answers each path's kept mode beside its refusal, as commitToolChange does for one path. edit's answer carries it as modeKept, and edit_many's in each file's entry. applyEdits leaves a mode taken in alone unsaid, as handleEdit did, and the edits keep the entry's mode, as before. - anima-research#466: applyEdits already words the conflict note's advice by what disk holds now, so this change's side stands. - anima-research#424: the imports keep both sides. - src/framework.ts, src/module-registry.ts and src/types/events.ts: anima-research#445's callerTurnId beside this change's resultMaxChars. The utils call carries both, the registry passes the call on whole, callerTurnId included, and events.ts documents both. anima-research#425's comment on a script's result keeps this change's SCRIPT_TOOL_RESULT_MAX_CHARS. - test/workspace-file-tools.test.ts: this change's expectations, which already carry anima-research#466's advice in the refusal's new wording. Also in this commit, as Anarchid's review asked (R1-4): edit_many cases in the two nested-mount tests, which assert the nested-mount refusal's exact text for edit and edit_many, since the named pass's note names the inner mount too, a test of edit_many naming files the mount passes over by design, and edit_many in the test of a push that keeps disk's mode, all in workspace-three-way. anima-research#460's ignore-list adoption test and anima-research#466's three deletion tests now expect the edit's refusal wording. The read-back's mode test calls the commit directly, since an overwrite now keeps the entry's mode and no tool sets the same bytes under another mode. planFile's doc gives the text's length as how long it is, not its characters, and the changelog says edit_many carries modeKept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 task done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR: a tool result's data that isn't a content array of blocks agent-framework recognizes is stored, and sent to the agent, as JSON. That JSON wrote an RFC-005 reference's raw uri wherever it sat in the data. It now shows, at any depth, each reference as its stub, and inline data that claims a uri or a disposition as withheld. Data that holds neither is written exactly as before.
Problem
On
main(14e25dc0), a module answered a model's call with[{"type":"zz-unknown",…},{"type":"resource","uri":"https://…?sig=…"}]. The model received, and the history stored, that array as JSON, signature and all. The same happened to a reference that was the whole data, sat under a key or sat inside another block, and to a withheld result's kept fields. MCP's embedded resource,{"type":"resource","resource":{"uri":…,"blob":…}}, was written with its uri and bytes wherever it sat outside a content array of recognized blocks. RFC-005 keeps a reference's uri out of the agent's context, since a signed uri is a credential, and withholds inline data that claims a uri or a disposition.Inside a content array of blocks the serializer recognizes,
mainalready showed a reference as its stub, and an embedded resource as[unrecognized content block: reference without string uri].The cause: the serializer reads a top-level array of blocks it recognizes, and for any other data its caller wrote
JSON.stringifyof the data. The wire repeats that stored copy whenever it doesn't carry native image blocks, and without a stored copy it wrote aJSON.stringifyof its own.!Fixes #298. That issue suggested giving each block the lanes don't recognize the unrecognized-content notice. This change keeps such blocks as JSON, since a module's records and a
resource_linkcarry fields the model needs, and replaces what RFC-005 governs wherever it sits, which also covers data that isn't an array.Changes
src/tool-result-history.ts: the fallback for data the serializer doesn't read as content blocks ishistoryJson, JSON of the data in which each value RFC-005 governs is shown as the reference lanes show it, wherever it sits.resourcewith a uri, at its top level or underresource, where MCP embeds one, and animageoraudiowith a uri or with data that is a string, which is inline data. A uri that is null, absent or empty counts as none, and data that isn't a string isn't inline data. So a record that only shares one of those type names is data, such as a module's own image record holding its bytes as an array.[unrecognized content block: …]with the lanes' reason when it's invalid: a uri that isn't a string, or an embedded resource, whose uri isn't at its top level. Inline data beside a uri or a disposition is withheld. Here a uri of any value but null or empty counts, since JSON writes every field. The lanes never show a uri beside inline data, so they withhold it beside a string uri only, as onmain. Inline data that claims neither is written as JSON writes it, bytes included, as before.resource_link's uri among them. Data that is itself shown as text, such as a bare reference, is that text rather than a quoted string, so a reference that is the whole data reads as one in a content array does, and an image reference takes asave_recent_imageslot, as there.src/mcpl/references.ts, read by every lane:classifyBlocksays that animageoraudioblock with neither string data nor a string uri has data or a uri that isn't a string, when it has one. The line said "with neither data nor uri" for those too.src/tool-result-guard.ts: a withheld result whose data is itself shown as text, such as a reference, keeps that text, its stub, rather than fields picked out of it, which would lose the type that makes it a reference.src/framework.ts:toMembraneToolResult, given no stored copy, makes its text withnonErrorResultText, the function that makes the stored copy. It had aJSON.stringifyof its own, which wrote the uri the same way. By reading, both production callers pass the stored copy. Direct callers, such as that function's own tests, now get the stored copy's text, so a failure not flagged as an error reads its error text there too. The script path's comments say what it fetches and what a script gets.What callers will notice
[ref_…] …, where it read with the raw uri. A reference this change newly stubs is registered with no server recorded, so its stub ends "unbound: no server is recorded to fetch it from", the wordingmaingives every such stub (see Out of scope).Agentbuilds withnonErrorResultText, and a withheld result's kept fields get the same text. When the data is itself a reference, what it keeps is its stub, whichever fields its tool marks.[inline content withheld: inline data that also claims a uri or a bulk disposition]in every lane, where it read[inline content withheld: nonconformant bulk disposition on inline data].imageoraudiowhose uri isn't a string is shown as an unrecognized block, as the lanes show one in a content array, and loses its other fields. One whose data isn't a string and that has no uri is written as before.src/modules/the only value of these types that a tool returns isread_image's image, in a top-level content array, which reads as before, and connectome-host'ssrc/at38b55dabuilds none.What each part is for
The JSON fallback must apply RFC-005's two rules wherever governed values occur in a tool result: hide a reference's uri and withhold inline data that claims a uri or disposition. Restricting that treatment to recognized content arrays left the other data shapes exposed.
historyJson(src/tool-result-history.ts) applies the rules recursively. Data with nothing RFC-005 governs keeps its previous serialization. We weighed An MCPL tool result with one block the tool-result lanes don't recognize shows the model every reference's URI #298's suggestion of an unrecognized-content notice for each block the lanes don't read. That would discard the fields of module records andresource_linkblocks in those arrays, and would leave non-array data uncovered.resourcewith a uri at the top level or underresource, and animageoraudiowith a uri or string data. Null, undefined, and empty uris count as absent. An image record with array-valued bytes and no uri remains ordinary data instead of becoming an unrecognized-block notice.save_recent_imageslot. This costs one branch. JSON-quoting the stub would lose that slot.src/mcpl/references.tsdescribe the failures this fallback exposes. The withheld notice names a uri as well as a disposition. The invalid-media reason names malformeddataorurifields rather than their values, which can hold credentials. Without these edits, URI-only inline contradictions would still be described as bulk-disposition contradictions, and present non-string media fields would still be described as absent. A fallback-only message would duplicate the lanes' text and could drift from it.src/tool-result-guard.ts). Selecting individual fields could discard the type that identifies a reference and expose its uri as ordinary data.toMembraneToolResultnow callsnonErrorResultTextinstead of its ownJSON.stringify. By reading the two production call sites at4ddc2000(src/framework.ts:8846and:12741), both pass a stored copy frombuildStoredToolResultContent, which creates one for every result. Those paths therefore bypass the changed fallback today. Sharing the serializer still pays: direct callers without a stored copy use the same serialization rules, rather than a second JSON implementation.The chosen scope leaves out broader changes to the lanes' treatment of unrecognized blocks and to their withholding of inline data beside non-string uris. Those lanes already omit the uri when rendering inline media, so that extra withholding would hide additional blocks without protecting that uri. This description pass cuts no further code.
Tests
The merge of
main,75a0b881: it bringsmain07563022in, with one conflict, insrc/tool-result-guard.ts's imports, where both sides' names are kept. One expected line intest/tool-result-reference-uri.test.tsnow takesmain's wording for a reference with no server, "reference unbound: no server is recorded to fetch it from". Its tree is11b34e61, the tree of the trial merge these runs used:tool-result-reference-uripasses 20/20 on Node 24 and Node 20.mcpl-reference-fetcherpasses 32/32,mcpl-reference-fields16/16 andworkspace-read-image15/15, on Node 24.The change's own runs, before the merge:
At
4ddc2000, onmain14e25dc0, on Linux, from a fresh install with no lockfile (context-manager 0.14.1, membrane 0.5.87, chronicle 0.4.0):The tests:
test/tool-result-reference-uri.test.ts, 20 cases, passes 20/20 on Node 24.22.0 and Node 20.20.2:save_recent_imageslot; media given by uri; MCP's embedded resource at any depth and in a content array; a withheld result's kept fields, including data that is itself a reference, which keeps its stub.main; inline data beside a null or empty uri, which isn't withheld; a media uri that isn't a string, and data and a uri that aren't, each named; a media block with neither, which keeps its line.resource_linkamong it, and records that only share a type name, each written exactly as JSON writes it. Those records include a module's own image and audio records whose data isn't a string, and a resource with an empty uri. Also left alone: an array of blocks the serializer recognizes, and a nested reference's stub, which isn't an inventory slot.The neighbouring files that read the same lanes pass too, on both Nodes:
tool-result-history18/18,save-recent-image29/29,tool-result-guard124/124,mcpl-reference-fields15/15,mcpl-push-convert6/6,mcpl-coalesced-content-parity20/20,mcpl-reference-fetcher30/30,tool-result-content4/4 andunconvertible-tool-result10/10.Red-first: against
main'ssrc/tool-result-history.ts,src/mcpl/references.ts,src/tool-result-guard.tsandsrc/framework.ts, 14 of the 20 cases fail. The 6 that pass pin whatmainalready does: inline media that claims nothing, data with nothing RFC-005 governs, records that only share a type name, an array of blocks the serializer recognizes, the wire's cut at a cap, and the script's in-process reference.Removals: each of 18 pieces, taken out or changed alone, fails at least one case:
JSON.stringify; only the top level governed; a reference that is the whole data written as a quoted string; a governed value written as it is, without the lanes; every record sent to the lanes;JSON.stringify.The full suite, each file in its own process, as npm installed it from this
package.json:--test-force-exit: 2,398/2,398 in 151 files.npm testpasses: 2,386/2,386. The flag cut three files short, as npm test's --test-force-exit can end a test file before its tests report, so a short run still passes #294 describes:agent-runtime-settings(53 of 55),history-module-realstore(24 of 28) andmcpl-tool-lifecycle(25 of 31). Each passes whole in the run without the flag.tool-result-guard's "fix(guard): a withheld tool result says where its original is (#277) #281: batches behind one stalled write cost one deadline: no write starts until it settles", is the wall-clock race test(guard): hold the clock still in three batch-deadline tests #407 fixes, in a test this change doesn't touch. Run on its own at this head, that file passed 124/124 on both Nodes. The flag cuthistory-moduleshort (47 of 49).Not verified
Out of scope
resource_link's uri, and any other uri in data that RFC-005 doesn't govern, is shown as before. RFC-005 governs references, and a tool's data can hold URLs the model needs.main. That includes an image in a content array that also holds a block the serializer doesn't recognize. Keeping such bytes out of context is its own change, outside RFC-005.[unrecognized content block: reference without string uri], so its embedded text isn't shown, as in a content array onmain. How the lanes should show one is its own change.save_recent_image's inventory, just as the nested reference wasn't onmain.dispositionof null beside inline data counts as a claim, in the lanes as onmain, and so in the fallback too. Whether a null claims nothing is its own change.fetch_referencerefuses it, and its stub says so, asmain's stubs do for any such reference. Recording the server a tool result came from on the references it holds is its own change.fetch_referenceanswers that the id may have been evicted.Read by Phoebe-2419 (Claude Opus 5.5), who read the change whole, both commits, at
4ddc2000. She ran the fallback on 21 shapes, none of which wrote a governed uri or withheld bytes, traced every route by which a tool result's data reaches the agent through it, and checked4ddc2000's receipts for the focused runs, red-first, the removals and Node 24's full runs. Ogden-2688 (Claude Opus 5.5) read the merge ofmainat tree11b34e61, its conflict and the test line both, and ran the three files above that the earlier runs didn't.We won't merge this before 06:00 UTC on October 18, or before our pre-merge checks pass on its merge with the then-current
main, and a line here asking us to wait holds it.Written by Ada-1017 (Claude Opus 5.5). The "What each part is for" section was checked and edited by Fiona-2757 (GPT-6), using the house's Pi tooling.