Skip to content

fix(tool results): keep an RFC-005 reference's uri out of JSON at any depth - #425

Merged
nissa-seru merged 3 commits into
anima-research:mainfrom
nissa-seru:fix/reference-uri-at-depth
Oct 18, 2026
Merged

nissa-seru merged 3 commits into
anima-research:mainfrom
nissa-seru:fix/reference-uri-at-depth

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

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, main already 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.stringify of the data. The wire repeats that stored copy whenever it doesn't carry native image blocks, and without a stored copy it wrote a JSON.stringify of 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_link carry 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 is historyJson, JSON of the data in which each value RFC-005 governs is shown as the reference lanes show it, wherever it sits.
    • What it governs: a resource with a uri, at its top level or under resource, where MCP embeds one, and an image or audio with 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.
    • How each shows: a reference as its stub, or as [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 on main. Inline data that claims neither is written as JSON writes it, bytes included, as before.
    • Every other value is written as JSON writes it, a 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 a save_recent_image slot, as there.
  • src/mcpl/references.ts, read by every lane:
    • classifyBlock says that an image or audio block 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.
    • The notice for withheld inline content says the inline data also claimed a uri or a bulk disposition. It named only a disposition, also when a uri was the cause.
  • 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 with nonErrorResultText, the function that makes the stored copy. It had a JSON.stringify of 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

  • An agent: such a result reads as JSON with each reference as its stub, [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 wording main gives every such stub (see Out of scope).
  • A script calling a tool gets the same text: each reference as its stub, and the rest as before. By reading, eager fetch still materializes the references of a top-level content array. An in-process module's reference there is refused for want of a server, and the script gets its stub.
  • The direct Agent API's tool results, which by reading Agent builds with nonErrorResultText, 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.
  • Anyone reading the withheld notice: it reads [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].
  • A module author:
    • Data that holds nothing RFC-005 governs is stored and sent exactly as before, inline media that claims no uri or disposition included, and so are arrays of blocks the serializer recognizes.
    • A reference is shown as its stub wherever it sits. A record typed image or audio whose 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.
    • By reading, in agent-framework's src/modules/ the only value of these types that a tool returns is read_image's image, in a top-level content array, which reads as before, and connectome-host's src/ at 38b55da builds 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 and resource_link blocks in those arrays, and would leave non-array data uncovered.
  • The predicate distinguishes references and inline data from type-name-only records. It governs a resource with a uri at the top level or under resource, and an image or audio with 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.
  • A reference returned as the whole data is unquoted text. It reads as a content-array reference does, and an image reference takes a save_recent_image slot. This costs one branch. JSON-quoting the stub would lose that slot.
  • The shared messages in src/mcpl/references.ts describe the failures this fallback exposes. The withheld notice names a uri as well as a disposition. The invalid-media reason names malformed data or uri fields 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.
  • The guard retains the stub when keeping fields from a withheld reference result (src/tool-result-guard.ts). Selecting individual fields could discard the type that identifies a reference and expose its uri as ordinary data.
  • The wire fallback reuses the stored-copy serializer. toMembraneToolResult now calls nonErrorResultText instead of its own JSON.stringify. By reading the two production call sites at 4ddc2000 (src/framework.ts:8846 and :12741), both pass a stored copy from buildStoredToolResultContent, 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 brings main 07563022 in, with one conflict, in src/tool-result-guard.ts's imports, where both sides' names are kept. One expected line in test/tool-result-reference-uri.test.ts now takes main's wording for a reference with no server, "reference unbound: no server is recorded to fetch it from". Its tree is 11b34e61, the tree of the trial merge these runs used:

  • tool-result-reference-uri passes 20/20 on Node 24 and Node 20.
  • Nine files that read the serializer or the reference lanes, this test among them, passed 256 of 257 on both Nodes on a trial whose tree differs only in that test line. The one failure was that line, still expecting the old wording.
  • mcpl-reference-fetcher passes 32/32, mcpl-reference-fields 16/16 and workspace-read-image 15/15, on Node 24.
  • A probe at this tree shows a nested reference's stub, and that of a reference that is the whole data, ending "unbound: no server is recorded to fetch it from".

The change's own runs, before the merge:

At 4ddc2000, on main 14e25dc0, 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:

    • References: beside a block of another type, at any depth, and as the whole data, which reads as a content array's reference does, an image reference taking a save_recent_image slot; 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.
    • What's withheld or invalid: what the reference lanes withhold, with their reasons, and a bare withheld block, which is the notice itself; inline data beside a uri that isn't a string, withheld at depth, while a content array shows it as a placeholder, as on 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.
    • What's left alone: inline media that claims no uri or disposition, data with nothing RFC-005 governs, a resource_link among 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 other paths: the wire's text without a stored copy, made as the stored copy's, with a failure's error text, and cut at a cap as the stored copy is; a model's call, with and without an image, whose wire content equals the stored copy's; a script's text, and a script's in-process reference in a content array, whose fetch is refused and logged.

    The neighbouring files that read the same lanes pass too, on both Nodes: tool-result-history 18/18, save-recent-image 29/29, tool-result-guard 124/124, mcpl-reference-fields 15/15, mcpl-push-convert 6/6, mcpl-coalesced-content-parity 20/20, mcpl-reference-fetcher 30/30, tool-result-content 4/4 and unconvertible-tool-result 10/10.

  • Red-first: against main's src/tool-result-history.ts, src/mcpl/references.ts, src/tool-result-guard.ts and src/framework.ts, 14 of the 20 cases fail. The 6 that pass pin what main already 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:

    • the fallback back to 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;
    • an embedded resource left ungoverned; an undefined, a null or an empty uri counted as one; any data but null counted as inline data;
    • the lanes withholding inline data beside a uri of any value; the fallback taking the lanes' rule for inline data; a disposition beside inline data written by the fallback;
    • the reason always saying "neither", or never naming both; the withheld notice naming only a disposition;
    • a withheld result's kept fields picked out of a reference;
    • the wire's fallback back to its own JSON.stringify.
  • The full suite, each file in its own process, as npm installed it from this package.json:

Not verified

  • This change's tests run no MCPL server and no live provider. A model's call goes through the suite's mock membrane to an in-process module, and a script's tests replace its tool dispatch with a stub.

Out of scope

  • A 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.
  • Inline media that claims no uri or disposition is written with its bytes wherever the fallback writes it, as on 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.
  • An MCP embedded resource is shown as the lanes show one in a content array, [unrecognized content block: reference without string uri], so its embedded text isn't shown, as in a content array on main. How the lanes should show one is its own change.
  • A nested reference's stub isn't a slot in save_recent_image's inventory, just as the nested reference wasn't on main.
  • By reading, a disposition of null beside inline data counts as a claim, in the lanes as on main, and so in the fallback too. Whether a null claims nothing is its own change.
  • A reference this change newly stubs, whether nested or returned by an in-process module, is registered with no server recorded, so fetch_reference refuses it, and its stub says so, as main's stubs do for any such reference. Recording the server a tool result came from on the references it holds is its own change.
  • By reading, results stored before this change keep the text they were stored with, any uri in it included, which goes on reaching the model until those results leave its context.
  • By reading, a stub's id lives in this process's memory, as every reference stub's does. After a restart, fetch_reference answers that the id may have been evicted.
  • By reading, the tool-result guard's audit state keeps each result's originals for the operator's audit, and is never read into context.

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 checked 4ddc2000'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 of main at tree 11b34e61, 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.

Ada-1017 and others added 2 commits October 16, 2026 14:51
… 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>
@greptile-apps

greptile-apps Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

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

Summary

Extends RFC-005 reference hiding to the JSON fallback at any depth.

  • Tool-result text replaces governed references and conflicting inline media with safe messages.

Reviews (2) · Last reviewed commit: "fix(tool results): the JSON fallback alo..." · Reviewed by Greptile

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
@nissa-seru
nissa-seru merged commit a35b994 into anima-research:main Oct 18, 2026
6 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An MCPL tool result with one block the tool-result lanes don't recognize shows the model every reference's URI

1 participant