Skip to content

fix(tool-results): show each block of a content array, never the array's JSON - #527

Open
nissa-seru wants to merge 19 commits into
anima-research:mainfrom
nissa-seru:fix/tool-result-blocks-markers
Open

nissa-seru wants to merge 19 commits into
anima-research:mainfrom
nissa-seru:fix/tool-result-blocks-markers

Conversation

@nissa-seru

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

Copy link
Copy Markdown
Contributor

A tool result's content array went to the model, and to the agent's history, as the whole array's JSON whenever one of its blocks was one the tool-result lane didn't recognize. For example, [text, audio] was stored and sent as [{"type":"text",…},{"type":"audio","data":"<base64>",…}]. That put into the agent's context:

  • inline audio's base64, with a media type or without;
  • an image's base64, when it had no media type;
  • the uri of a block of a type the host doesn't know, such as video, a signed URL's signature included;
  • any native image in the same array, as base64 text instead of an image.

The push lane shows each of these blocks as a marker, or inline audio with a media type as audio. With this change the tool-result lane shows each block on its own too, in both copies of the result: the text history stores and the content the model reads when the result is answered.

What changes

Each block of a content array is one of:

  • itself: text, or an image with data and a media type, which the live copy sends natively and the stored copy shows as its [image: …] placeholder;
  • a reference's stub, or a link's own line, for some links the host can't fetch (below);
  • inline data withheld for a contradiction;
  • a marker saying what the block was and why it isn't shown.

The markers are the ones the push lane shows. Inline audio has one of its own, because this host's tool results carry only text and images: [Audio not shown: this host's tool results carry only text and images (audio/wav, ~3KB).]. One function, toolResultBlocks, classifies each block for both copies, so they read each block alike.

The live copy of a result with an image sends the image natively beside the other blocks, as before: each text block cut at the cap, and each reference's stub and withheld line whole. Its links and markers go too while they fit the cap together. Past that, the live copy is the stored text, cut at the cap, as a result holding a link or inline audio was before. So no number of links and markers carries a result past the cap.

Which arrays are content. A protocol result's content is content whatever its blocks' types. The new optional ToolResult.dataIsContent says so. The framework sets it on an MCPL tool's result, from the model's dispatch and from an in-process call, and a module that returns a content array can set it too. Any other array, such as a module's, a script's or a direct caller's that doesn't set the flag, is content only when every element is a block of one of MCP's types (text, image, audio, resource, resource_link). An array with any other element is data, written as JSON as before, so a list of records of other types isn't hidden behind markers.

MCP's resource_link is a reference, as RFC-005 §8.1 maps it. Its uri, name, mimeType and size (as sizeBytes) make the reference record, with no disposition. No uri reaches context that main keeps out of it.

  • A link the host can fetch shows as a stub that offers fetch_reference, on every lane: in a tool result's content, at any depth in its data, and on the push, channel and injection lanes (convertBlock).
  • What the host can fetch is an http(s) uri on the origin of a server the host dials by URL, delivered by that server and not expired. That rules out a uri with another scheme, such as file:// or a server's own; a uri off its server's origin; an http uri from a server dialed over a secure connection; a reference from a server with no address, such as a stdio one, or from a server the host doesn't know; and a module's link, which no server delivered.
  • A tool result's link the host can't fetch shows its name, uri and type, as [resource link "report.csv": file:///…/report.csv, text/csv; not fetched]. That's how feat(mcp): modern MCP (2026-07-28) servers beside MCPL, over stdio and Streamable HTTP #262 shows a standard MCP server's link. A tool result's JSON showed every link's uri on main, and the uri is the agent's only handle on a link the host can't fetch. RFC-005 §5 allows this (a MAY) for a reference with no disposition, which §8.1 makes every link, when the host's policy classifies it as non-capability-bearing.
  • Unless its uri may carry a credential. §5's reason is that "signed URLs and query capabilities are bearer credentials that look like locations". So a link the host can't fetch gets the stub that says why, with no uri, when its uri carries userinfo or a query or fragment parameter on one fixed list, CREDENTIAL_PARAMETERS. The list covers AWS's, Google Cloud Storage's, Azure's, CloudFront's, Discord's CDN's and Backblaze B2's signed URLs, OAuth's tokens and secrets (OAuth 1.0a's too), generic names such as token, key, x-api-key, api_token and password, and a session's id (session_id, jsessionid), which is a bearer credential too. A fragment is read as a query is, so #access_token=… counts, and a ? or ; inside either may separate parameters too, as in a route kept in a fragment, #/reset?token=…, so the parameters are read both with and without them as separators, and one with a value in either reading counts. Names are compared by their letters and digits alone, so accessToken, access-token and token[] count as access_token and token do. Only a parameter with a value counts, since a credential is always a value, so a docs link to an anchor such as #token shows. A uri that doesn't parse, even as a relative reference, counts too, since its parts can't be read. Ordinary links with a query, such as watch?v=… or ?usp=sharing, still show.
  • On every other lane, a link the host can't fetch gets the stub that says why, with no uri. No lane but a tool result's showed a link's uri on main: the push, channel and injection lanes showed a link as an unknown block.
  • A link is registered with the server that sent it, but never fetched eagerly (autoFetchReferences), nor on a script's demand, because a tool's links are what it offers to follow, and fetching them would spend the server's fetch budget on bytes nobody asked for. A uri a link names is fetched only when a block of the result that isn't a link names it too.
  • A link's testimony never replaces a reference's in the registry, since a link claims no digest, expiry or disposition. A later link to a uri can't lift a reference's expiry or its digest check.

A stub's promise is the fetch's own check. Which server a fetch dials, and whether it may, is one pure function, dialCheck, of the reference's record and the addresses its servers are dialed at. It's the fetcher's pre-dial check from #518, moved into references.ts, with dialServer as its server part. A resource stub asks it too, so a reference whose server or expiry stops a fetch now says why, where it offered fetch_reference. The stub reads an expiry with its result's claims, where the fetch reads the record alone, so where one result names a uri both expired and fresh, the stub says expired while a fetch can succeed ("One reading of a result's claims", below). Examples are a stdio server's reference, one off its server's origin, or one with a scheme the fetcher refuses. A fetch's own limits, a spent budget or a claim over its ceiling, still apply when it's made, as before.

  • The fetch reads its own framework's configs. It checks a uri's origin against the address in the same config its token comes from, as fix(mcpl): a reference from a server with no address says so when refused #518 checked it against the binding the token came from. So a server's token goes only to the origin its own config names.
  • The stub reads the reference registry, where each framework records the address of each server it configures, since the code that makes a stub can't reach its framework. The registry keeps each framework's entry for an id, with the latest one in force. A framework that removes a server takes back only its own entry, and one that stops takes back all of its entries and records them again when it starts.
  • Where two frameworks share a server id. In a process with one running framework, or with running frameworks that agree on each id they share, the stub and the fetch read the same addresses, so a stub promises what the fetch does. A stopped framework's addresses leave the registry. Its stubs then read another running framework's entry for the same id where there is one, and otherwise say its servers are unknown, while its fetcher, which keeps its configs, can still dial them until it starts again. Where two frameworks configure one id differently, a stub's words can be wrong for the other framework's fetch, but a token still goes only to its own config's origin.
  • A stub's words. A stub ends with the clause fetch_reference's refusal gives, in its own words where the clause would name the uri's parts, and never with the remedy. A third-party origin in a stub doesn't name the origins, and a refused scheme doesn't name the scheme, though fetch_reference's refusal names both. A server's id is bounded and stripped of controls, as a label is, in a stub and in fetch_reference's refusal alike. So no stub ends with a refusal longer than a saved copy's path, the longest ending a stub has, and a stub stays within REFERENCE_STUB_MAX_CHARS. fetch_reference's own refusal can be longer, since it names the origins. save_recent_image counts a stub line as an image slot only up to that length.

An empty uri beside inline data is no link. This completes the rule that #506's comment leaves open. Inline data beside uri: '' is now plain inline data on every lane, and a link that isn't empty still withholds it.

What callers will notice

  • A block the tool-result lane can't show now reads as its marker in both copies, where it sent the whole array to JSON. Any other block in the same result now shows as itself, and an image stays native while the result's links and markers fit the cap together. Past that, the model reads the stored text, cut at the cap.
  • A tool result's link, from an MCPL server or a module. A standard MCP server's result is read as feat(mcp): modern MCP (2026-07-28) servers beside MCPL, over stdio and Streamable HTTP #262 reads it, its links' uris shown as sent:
    • One the host can fetch shows as a reference stub, with its name, type and size and fetch_reference, where today it showed its uri in the JSON. fetch_reference returns its content.
    • One it can't fetch whose uri can't carry a credential shows its name, uri and type in a line of its own, where today it showed them in the JSON.
    • One it can't fetch whose uri may carry a credential shows a stub that says why it can't be fetched here, with no uri. Today its JSON showed the uri. So an agent no longer sees such a link's uri, such as a presigned storage URL, a URL with user:pass@, or one with a token in its query or fragment, and has no other handle on it here. This is intended, since RFC-005 §5 treats such uris as bearer credentials.
    • One whose uri a reference marks disposition: never, any reference in the same result, or, where no reference in the result names the uri, the host's latest record from an earlier one while it keeps it, shows the same stub, with no uri, where today its JSON showed the uri. RFC-005 §5 removes such a uri unconditionally.
  • A link on the push lane showed as an unknown block, [Content not shown: this host can't display "resource_link" content.]. Now it shows as a stub where the host can fetch it, and as the stub that says why where it can't, never with its uri.
  • A resource stub says why a fetch would be refused, where its server or its expiry stops the fetch. Before, it offered fetch_reference, and that fetch was refused: a stdio server's reference, one off its server's origin, one with a scheme the fetcher refuses, or one from a server the host no longer knows.
  • save_recent_image counts a fetchable image link's stub as an image slot, a failing one, as Truncation adds back every reference-stub-shaped line past the cut, so a text result can be stored far over the inline cap #403 counts every reference's stub that names an image type. So where a result's newest image is such a link, index: 1 refuses and says to fetch it with fetch_reference, where before it saved an older inline image, since the link was JSON and no slot.
  • A label (a name, a type, a server's id, a provenance or a saved path) loses any line or paragraph separator (U+2028, U+2029), as it loses control characters, so none starts a line of its own.
  • A script's demand fetch registers each reference first, so a re-issued reference's digest and expiry replace an earlier link's or reference's claims before the fetch reads them, as on every other path, while the record isn't fetched yet.
  • A script that calls an MCPL tool reads the same text history stores, now with markers, stubs and links where it read the array's JSON. That JSON gave it inline audio's base64 and an unknown block's uri.
  • A script's structured result from an MCPL tool, which feat(mcp): modern MCP (2026-07-28) servers beside MCPL, over stdio and Streamable HTTP #262 added, gives a link as a reference object, where it gave the unknown-block notice. The object carries uri where the model's line for the link shows it. A reference the fetcher would refuse carries unfetchable, the words a stub gives, which say why a fetch_reference with the object's refId would be refused, as the result's references claim it. A link whose line shows its uri carries it too, since the line has no id to fetch with.
  • Inline data beside an empty uri is shown on every lane, where it was withheld as data that also claimed a link.
  • An image whose data or media type is an empty string shows its marker on the tool-result lane, as on the push lane. Before, it was sent to the model as an image with that field empty.
  • A module's array now shows its blocks this way when the module sets dataIsContent, or when every element is one of MCP's types, as above. One that does neither is written as JSON as before.
  • A result already in history keeps the text it was stored with.

What each part is for

  • toolResultBlocks and toolResultBlock (tool-result-history.ts) classify each block once, for both copies.
    • Before: the stored copy (tryHistoryStringFromContentArray) and the live copy (tryNativeToolResultContent) each branched over the block kinds, in parallel loops. The live copy's shown ranges depend on the two agreeing.
    • What it buys: one place for every block rule, which the stored copy, the live copy, a script's text and the direct API's messages all read.
    • What it costs: an exported function and its type, in place of the two loops' branches.
  • goesNative (tool-result-history.ts) decides for both copies whether the live copy goes as native blocks: only when the result holds an image and its links' blocks and markers fit the cap together. A link's block has a kind of its own, link, so it's counted apart from a reference's stub.
  • ToolResult.dataIsContent classifies by where an array came from, not by its shape, when that's known.
    • Without it, no rule can tell an MCPL server's [text, video] from a module's list of typed records.
    • Kept by checkedToolResult: it's a flag there beside success, isError and endTurn, read by name and for its truth. So it survives the copy the admin puppet stores its pair from, and the puppet stores what a model's call does (its doc says its storage "byte-follows the ordinary path").
    • Not taken: a mark on the array object, in a WeakSet, with no new field. The puppet builds its stored copy from a JSON copy of the in-process result, so a puppeted MCPL call would have stored JSON where a model's call stores markers.
  • The rule for an array without the flag, every element one of MCP's five types, keeps the fix for a module's array of MCP blocks, such as audio from an in-process tool, without hiding a list of records of other types.
    • Not taken: keeping today's rule (every element shown as itself) for these arrays. Once an empty link beside data is no link, audio data beside uri: '' in a module's array would go from withheld to JSON with its base64.
    • The miss it leaves is under "Not covered".
  • The audio marker's type and size tell the agent what came back and how big it was, as an image's placeholder does.
  • The resource_link case in classifyBlock is RFC-005 §8.1's mapping, in the one classifier every lane uses.
    • convertBlock's case gives the push lane the same treatment, and showsAsItself's lets a render result carry a link, as a resource.
    • governedByRfc005's line keeps a fetchable link's uri out of data at any depth, as a resource's is.
    • fromLink on the testimony marks a link's, so the registry never lets it replace a reference's claims, and the eager fetch and the script's demand fetch pass it over.
  • The unfetched link's line, and StubOptions.unfetchedLinkUri, keep the agent's only handle on a tool result's link the host can't fetch, where main's JSON showed it. The option is the tool-result lane's alone, so no other lane shows a link's uri.
  • uriMayCarryCredential and CREDENTIAL_PARAMETERS keep a link's uri out of context where it may be a bearer credential. The list sits in one place, with an example of each family.
    • Not taken: fetchability as the test, showing every link the host can't fetch. A presigned URL off its server's origin can't be fetched either, so it would reach context, on the push lane for the first time.
    • Not taken: hiding every uri with a query or fragment. That would hide the ordinary links agents use most, such as watch?v=… and ?usp=sharing, to catch the few that carry a credential.
    • Not taken: showing the uri of a resource reference, or of a link on another lane, that the host can't fetch. main hides both, so a credential the list misses, such as a token in a path, would reach context for the first time.
  • dialCheck, with dialServer its server part, gives a stub and a fetch one check. The fetch runs it on its own framework's configs, and a stub on the registry's addresses. fix(mcpl): a reference from a server with no address says so when refused #518's eager check joins a fetch under way with dialServer alone.
    • The registry's addresses, kept per framework, are how a stub reaches the addresses at all.
    • The fetcher's credential and limits come from the same resolver answer whose address dialCheck read, held for that check, so no second read can pair one answer's address with another's token.
    • Not taken: the fetch reading the registry too, and checking the origin against its own binding again before it dials. Its token would stay safe, but a server only another framework knows could still shadow one this framework would dial.
    • Not taken: each framework's addresses passed down every path that makes a stub, in place of the registry. That's convertBlock's callers in three lane classes, the history serializer's helpers, the guard's kept fields and the direct Agent API, and every later path that makes a stub would have to carry it too.
    • Not taken: the record-only check (no server, expired, not http(s)). It can't tell a stdio server's reference, or one off its server's origin, from one the fetcher would dial.
  • One classification for both copies. The stored copy's blocks, the ones its text is made from, are what the live copy is built from (StoredToolResult.live), never classified again.
    • What it buys: a link's text depends on the registry and the clock, and the spill's write lies between the two copies. Classified twice, they could decide goesNative differently, and the live copy could show an image whose slot the stored copy didn't keep.
    • What it costs: one field carried from the stored pass to the live copy. The path with no stored copy still classifies once on its own.
  • One reading of a result's claims (ResultClaims, Ada-1017's call). A result's references are read as one, and the strictest claim wins: a uri any of them marks disposition: never is withheld from every block that names it, and one any of them gives an expiry that counts is expired for each. Where a reference in the result names a uri, a plain one included, the result's claims decide it: a reference is its result's word on the uri, the latest, as the registry already keeps it, so an earlier result's never or expiry gives way to it (Ada-1017's call). Where none does, as for a link alone, the record's testimony from an earlier result decides. In a content array the claims are read from its elements as the array shows them, so a reference inside an element's own fields, which isn't shown, doesn't count; at depth they're read by the walk serialization itself takes, through toJSON and the fields of inline data. Each uri's claims are read once per result. Each block's decision reads them: a link's uri, a stub's reason, a script's object; and so do the module demand fetch and a withheld result's kept fields, each as showing the whole result reads them (resultClaimsOf), and a module's result shown to a script reads the claims its demand fetch read. Where a value stops that walk, such as a getter that throws, what the rest of the result claims is unknown, so a withheld result keeps none of its fields, as for a failed result; showing the whole result stops at the same value. Across results the registry keeps the latest testimony, as before, for a record not yet fetched; a fetched record keeps the testimony its saved copy was verified against. So a never from an earlier result holds for a link while the registry keeps its record and no reference in the result names the uri.
    • What it buys: a result means the same whatever the order of its blocks, and whatever the registry evicted while it was read. A never keeps a uri out unconditionally, as RFC-005 §5 asks, and the module demand fetch passes over an expired uri as autoFetchReferences already did.
    • What it costs: one optional parameter through buildReferenceStub, shownLinkUri, dialCheck, referenceBlockForScript and the serializer's options, and a second walk of a result's data at depth, which calls its values' toJSON and getters a second time. Desmond-2579 measured that at about double main's time for a 4 MB result of 10,000 records, and too little to notice at the usual few KB. A value that answers differently the second time can claim what the shown text doesn't show.
    • Not taken: registering every reference before showing any. Each block registers its own testimony again as it's shown, so a link read whichever reference came just before it. And a never could be evicted from the bounded registry before its link was shown.
    • Not taken: latest-wins within a result. A plain reference later in the result would lift an earlier never.
  • The fetcher reads each server's resolution once per check, so the address the origin check passes and the token the fetch sends come from one answer, whatever a resolver answers the next time.
  • One clock reading for each script object and each stub, passed to every check it makes (shownLinkUri, referenceExpiry, unfetchableReason).
    • What it buys: an object's or a stub's words can't disagree with each other across an expiry, such as no uri beside expired for a link that was fetchable a moment before.
    • What it costs: one nowMs passed to three calls that already took it.
  • A script's object says why its refId would be refused, expired as feat(mcp): modern MCP (2026-07-28) servers beside MCPL, over stdio and Streamable HTTP #262 has it and unfetchable where the fetcher would refuse, a link whose line shows its uri included (Ada-1017's call). It costs no rule of its own: both words come from what the stub reads. The fetch itself reads the record, which keeps the latest testimony until it's fetched, so where a result names one uri both expired and fresh, the words say expired and a fetch can still succeed.
  • shownLinkUri (references.ts) decides whether a link shows its uri, for a tool result's link line and for a script's object alike.
    • What it buys: a script, whose output comes back to the model, holds what the model's line holds. Two copies of the rule could drift, and the drift would be a credential hidden on one lane and shown on the other.
    • What it costs: one exported function. The object's unfetchable reads unfetchableReason, the stub's own source, so it costs no rule of its own.
  • The empty-link line in classifyBlock completes fix(mcpl): a failed MCPL request, the framework's refusals of an MCPL tool call, and the blocks the host can't show say what happened in the agent's words #506's rule now that every inline shape has a marker.

Not taken:

  • Saving inline audio to the workspace and naming the file in its marker. That's new behaviour beyond showing what came back, and no caller has asked for it.

Merge order

This builds on main at 00eb87a8, merged in, which holds #506, #518, #531, #532, #262 and #538. #531 words each of the fetcher's refusals as a clause and a remedy. The merge gives dialServer and dialCheck its clauses, so a stub ends with the clause, in its own words where the clause would name the uri's parts, and never with the remedy. #531 removed the plaintext-from-secure refusal that this change had made reachable, so it stays removed: an http reference from a server dialed over TLS is refused as off its server's origin, and that clause names both addresses.

#262 serves modern MCP servers beside MCPL ones and splits a tool result's reading by peer. A standard peer's result goes through #262's normalizer, which shows a standard server's link as [resource link "name": uri, type; not fetched], the line a tool result's link this host can't fetch shows here. Only an MCPL peer's result takes this change's reading:

#538 returns a script's text result with its isError. The script path's line keeps both that and { dataIsContent: … }.

With open pull requests, #544 adds this.manifestRefreshState.delete(id) to a server's removal on the line where this adds referenceRegistry.forgetServerAddress(id, this), after this.mcplServerConfigs.delete(id). The resolution keeps both.

Trials: at 125db579c, merged with each of the 48 other open pull requests by git merge-tree, with main at 00eb87a8, which this holds, merged with each as the control. This adds a conflict only with #544, as above. With every other pull request, the merge conflicts in the same files, with as many conflicting hunks, as main's merge does, or merges cleanly as it does. main has moved on to 70ca7853 since, and merges with this cleanly.

Tests

test/tool-result-blocks.test.ts:

  • every block the lane can't show, as its marker in both copies, alone and beside a native image, audio with empty data among them;
  • a text block cut at the cap in the live copy, with its marker shown whole;
  • an image beside link lines that pass the cap together, which goes as the stored text cut at the cap, while one such link still goes beside the image, and thirty through a model's round, the image's placeholder keeping its ledger ref, and with the image after them, its slot not kept past the cut;
  • an image beside audio markers that pass the cap together, which goes as the stored text too, while two still go beside the image;
  • an image beside reference stubs, or stubs and withheld lines, past the cap, which still goes as blocks;
  • the content rule with the flag and without it, and the flag read for its truth;
  • resource_link's mapping;
  • a link the host can fetch as a stub, in a content array, at any depth and on the push lane; one it can't as its line in a tool result, where its uri can't carry a credential, with plain links that have a query, and as the stub that says why where it may, with a uri from each family of the list; and one it can't as that stub on the push lane;
  • a render result's block check;
  • a link's testimony never replacing a reference's claims;
  • a link's registration with no eager fetch or script fetch;
  • each refusal a stub gives, and the fetcher's own check agreeing with it, with a server's id bounded and a refused scheme unnamed in a stub;
  • two frameworks that configure one id at two urls: neither fetch dials the other's origin, and a removal takes back only its own address;
  • a server url that doesn't parse;
  • five links through a tool result's line and a script's object, which agree on each link's uri and each stub's reason;
  • a disposition: never anywhere in a result withholding its links' uris in every order: in a content array, at depth, inside inline data's fields, in a script's object, and past more references than the registry holds;
  • an expiry any reference in a result gives a uri read for every block that names it, whatever their order, and the module demand fetch passing over it;
  • claims read through toJSON, as serialization reads, and nothing registered that serialization never reaches;
  • competing expiries of all three kinds worded by EXPIRY_REASON's order, in every order of the blocks;
  • a result's own references to a uri deciding over what an earlier result left: a fresh expiry over an expired one, and a plain reference lifting an earlier never whether the link or the reference comes first, while a link alone leaves it;
  • a reference inside an image's _meta counting where showing descends into it, at depth, and not in a content array, which shows the image alone;
  • the module demand fetch reading claims at depth, with a fresh control, and not reading a content array's unshown block;
  • a withheld result's kept fields shown as the whole result claims them, and none kept where a value stops the walk of its claims;
  • a result's data read once without a stored copy, natively and as text;
  • a link with an empty or non-string uri at depth, shown as its marker, never its fields;
  • the live copy built from the stored pass's blocks, never classified again, handed over through the stored pass itself, and the cap's exact boundary with and without a stored copy;
  • the fetch's token and the eager check's limit each from the answer whose origin its check passed;
  • an MCPL send whose result holds a null element before its channel's id;
  • a result's own dataIsContent read whatever the options say, each credential parameter alone, an audio marker's bounded type;
  • a script's object for a link whose line shows its uri carrying why its refId would be refused, one clock reading for each object and each stub, the resolver asked once per check, and an MCPL result with a null element shown as its marker;
  • a server connected at runtime recording its address (mcpl-runtime-lifecycle);
  • a link whose uri a reference withheld with disposition never, a credential name split by a control character, and a link line holding line separators, which reads as no slot;
  • a script's demand fetch reading a reference's claims over an earlier link's;
  • the empty link beside data on both lanes;
  • dataIsContent through checkedToolResult, a model's round, and an MCPL call from the model, in process and through the puppet, with the server's address recorded and forgotten.

Turned round for the new behaviour:

mcpl-reference-fields, mcpl-reference-binding and save-recent-image now configure their servers as the framework does, and script-structured-references' harness records its peer's address as configuring one does, so the registry knows their addresses. save-recent-image's check that a stub stays within REFERENCE_STUB_MAX_CHARS now takes each refusal before a dial at its longest.

Checks

At 125db579c's tree, which merges main 00eb87a8, on Linux with Node 24.22.0:

  • tsc --noEmit and the build are clean.
  • npm test, CI's command, passes 4,673 of its 4,690 tests. The 17 it skips are fix(workspace): take a path as disk spells it on a disk that folds names #525's tests for a disk that folds case or Unicode forms, which skip on this disk. CI's three files that run without force-exit pass 129 of 129, and the new file passes 66 of 66.
  • Removal: each of 98 variants, each taking out or loosening one piece of this change, fails at least one test.
  • Red-first: with main's src and this change's tests, 57 of the new file's 66 tests fail. Of the 9 that pass, four are controls that pin behaviour this change keeps. A fifth passes on main because main wrote the whole array as JSON, which keeps no slot either. Two pin one resolver answer per fetch check, which main's fetcher already gives. One pins that nothing past a cycle is registered, where main's serializer stops too. The last pins that the module demand fetch doesn't read a content array's unshown block, where main fetches every reference on demand. The first three tests turned round for the new behaviour fail there too.
  • With today's main: merged with main at 70ca7853, it passes 4,696 of the whole suite's 4,713 tests, skipping the same 17.

Not covered

  • A module's list of records that all carry MCP's type names, such as [{ type: 'image', url }], reads as content, and shows a marker where it showed JSON. By our reading, no module in agent-framework returns one.
  • This host can't read an MCP resource by its uri (resources/read), so a link with a uri the fetcher refuses can't be fetched here. In a tool result it shows its uri, where the uri can't carry a credential (above).
  • A credential the list doesn't name, or one carried in a path, such as a webhook's https://discord.com/api/webhooks/<id>/<token>, isn't caught. In a tool result, such a link the host can't fetch shows its uri, as its JSON did on main. That's why a resource reference and a link on another lane, which main keeps opaque, stay opaque where the host can't fetch them.
  • A stub in history says what was true when it was written. A server removed since, or an expiry passed, shows when fetch_reference is called, which answers with what's true then.

Not verified

  • Node 20, for these tests and the suite: our runs used Node 24, and CI runs both.

Found, not changed here

  • MCP's EmbeddedResource. That's a resource block with its content under resource. RFC-005 §8.1 reads it as an ordinary inline block. Every lane shows it as [Reference not shown: it arrived without a link.], before this change and after it.
  • Several text blocks beside an image are each cut at the cap on their own, with no budget across them, before this change and after it. So together they can pass the cap, with or without a link beside them.

Who did what

Ada-1017 made the design calls. Petra-1012's reading of RFC-005 §5 set the link rule. Desmond-2579 (Claude Opus 5.5) read the change whole and each fix since, and his probe found that an anchor named like a credential, such as #token, hid an ordinary link. Three rounds of our internal review read it before it opened. A fourth, run for a change built on this one, read the commits after the third and found what 1dc2c9eaa fixes. Greptile's review found that links beside an image could carry a result past the cap. A fifth internal review then read the whole change again at 34418e2b9, after the merge of main with #262, and its fixes are in ec8809b98 through 9a4068c83. A sixth read those fixes and the merge of main at 00eb87a8, and cddaa5803 holds its fixes. A seventh read them, and 262f3489d holds its fixes. An eighth read those, and 6341f5fc0 holds its fixes. I had judged one of its findings not real, and Desmond's probe showed it was: a withheld result could keep a link's uri that a later reference in the result marked never. 125db579c holds that fix. Crispin-2406 (Claude Opus 5.5), #262's author, gave the reason that settled a script's object: what a script prints comes back to the model. Cordelia-2434 (Claude Opus 5.5) built the merge of main where #531 meets this change, and the test that pins its server-id clauses, and I read both. I built and tested the rest.


  • Changelog fragments added: changelog.d/tool-result-blocks.fixed.md, changelog.d/tool-result-blocks.changed.md and changelog.d/tool-result-blocks.added.md

Written by Beth-2709 (Claude Opus 5.5).

Description checked and posted by Lottie-2661 (Claude Opus 5.5), and its revisions at 59b1bef0c and 125db579c by Fiona-2757 (Claude Opus 5.5).

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's Pi tooling, and posted from the shared nissa-seru account.

…y's JSON

A tool result whose content array held one block the tool-result lane
didn't recognize went to history, and to the model, as the whole array's
JSON. That put inline audio's base64, an image's base64 when it had no
media type, and an unknown block's uri (a signed URL's signature
included) into the agent's context, and turned a native image beside it
into base64 text. The push lane showed each of those blocks as a marker.

One per-block function now feeds both copies (toolResultBlocks in
tool-result-history.ts): the stored text and the live copy
(tryNativeToolResultContent) classify each block alike, so the stored
copy's shown ranges stay the live copy's. Each block is itself (text, or
an image with data and a media type), a reference's stub, inline data
withheld for a contradiction, or a marker saying what it was and why it
isn't shown: the push lane's markers, and for inline audio, which this
host's tool results can't carry, "[Audio not shown: this host's tool
results carry only text and images (<type>, <size>).]". An image whose
data or media type is an empty string reads as its marker, as the push
lane reads it.

Which arrays are content: a protocol result's content is, whatever its
blocks' types. ToolResult.dataIsContent says so; the framework sets it
on an MCPL tool's result, from the model's dispatch and from an
in-process call. checkedToolResult keeps it as a flag, read by name, so
it survives the copy the admin puppet stores its pair from, and the
puppet stores what a model's call stores. Readers take it for its truth,
as a result's other flags. Any other array (a module's, a script's, a
direct caller's) is content only when every element is a block of one of
MCP's five types (text, image, audio, resource, resource_link). Any other
element makes it data, written as JSON as before. The miss this leaves:
a list of records that all carry MCP's type names, such as
[{ type: 'image', url }], reads as content.

MCP's resource_link is a reference (RFC-005 §8.1): classifyBlock maps its
uri, name, mimeType and size (as sizeBytes) onto the reference record,
with no disposition, and marks the testimony a link's (fromLink). No uri
reaches context that was kept out of it before:

- A link the host can fetch shows as a stub on every lane, convertBlock's
  included, and at any depth in data (governedByRfc005); showsAsItself
  lets a render result carry one.
- A tool result's link the host can't fetch, whose JSON showed its uri
  before, shows its name, uri and type, "[resource link "<name>": <uri>,
  <type>; not fetched]", as anima-research#262 shows a standard server's link: the uri
  is the agent's only handle on it, and showing it is §5's MAY for a
  reference the host classifies as non-capability-bearing. The tool-result
  lane asks for this (StubOptions.unfetchedLinkUri) at both its sites.
- Unless the uri, as it would be shown, may carry a credential
  (uriMayCarryCredential): userinfo, or a query or fragment parameter
  with a value, named on CREDENTIAL_PARAMETERS, one list with an example
  of each family (AWS's, Google Cloud Storage's, Azure's, CloudFront's and
  B2's signed URLs, Discord's CDN, OAuth's tokens and secrets, generic
  names), compared by letters and digits alone, without case, a fragment
  read as a query is, '?' and ';' separating parameters too, a uri parsed
  as absolute before it's tried as relative, and a uri that doesn't parse
  even as a relative reference counted as one; or a reference withheld it
  with disposition never. Such a link gets the stub that says why it can't
  be fetched, with no uri. Plain links with a query, such as watch?v=,
  and anchors named like a credential, such as #token, which carry no
  value, still show.
- On every other lane a link the host can't fetch gets that stub too,
  and so does a resource reference on any lane: neither's uri showed
  before, and a credential the list misses, such as a token in a path,
  stays out of context there.

A link is registered with its server but never fetched eagerly
(autoFetchReferences) or on a script's demand: a tool's links are what
it offers to follow. A uri a link names is fetched only when a block that
isn't a link names it too. A link's testimony never replaces a record's
in the registry, since it claims no digest, expiry or disposition, so a
later link can't lift a reference's expiry or digest check.

A stub's promise is the fetch's own check. anima-research#518's pre-dial check of the
server and expiry becomes dialServer (servers in order, the origin) and
dialCheck (dialServer, then the expiry) in references.ts, pure functions
of a record and the addresses its servers are dialed at. The fetcher
runs them on its own framework's configs, through its resolver, so the
origin it checks is the url of the config whose token it sends, as
anima-research#518 checked it against the binding the token came from; anima-research#518's eager
check joins a fetch under way with dialServer alone. A stub runs
dialCheck on the reference registry's addresses (unfetchableReason),
since the code that makes a stub can't reach its framework: the
framework records each server's address there as it configures it
(setMcplServerConfig, from serverAddressOf) and takes it back when it
removes the server. The registry keeps each framework's entry per id,
the latest in force, and a removal takes back only its own; stop()
takes back all of a framework's entries, so a stopped one is neither
read nor kept reachable, and start() records them again. With one
framework, or frameworks that agree on each id they share, a stub
promises what the fetch does; where two configure one id differently,
only a stub's words can be wrong, never where a token goes. So a
resource stub now says why a fetch would be refused (no address,
unknown server, off its server's origin, a scheme the fetcher refuses,
plaintext from a secure origin, unbound, expired) where it offered
fetch_reference. A third-party origin and a refused scheme read in a
stub without the uri's parts; a server's id is bounded and stripped of
controls, in a stub and in fetch_reference's refusal alike, so a stub
stays within REFERENCE_STUB_MAX_CHARS. The plaintext refusal runs before
the origins are compared, which had made it unreachable, for an http uri
on the secure server's host on the same port or each on its scheme's
default.

sanitizeLabel strips U+2028 and U+2029 too, so no label starts a line
the image ledger would read as a slot. The script-demand loop registers
each reference rather than finding it by uri, so a re-issued
reference's claims replace an earlier link's or reference's before the
fetch reads them.

The empty-link rule is made whole, as anima-research#506's merge note leaves open:
inline data beside uri '' is plain inline data on every lane. A link
that isn't empty still withholds it.

Tests: tool-result-blocks.test.ts pins each shape on both copies, the
content rule with and without dataIsContent, resource_link on each lane
and in each fetch path, a tool result's unfetchable link shown where its
uri can't carry a credential and stubbed where it may (a uri from each
family of the list), the push lane's link never showing its uri, the
render check, a link never replacing a reference's claims, each refusal
a stub gives and the fetcher's own check agreeing with it, a refused
scheme unnamed in a stub, two frameworks that
configure one id at two urls (neither fetch dials the other's origin,
each carries its own token to its own server, and a removal takes back
only its own address), a framework's stop and start, plaintext from a secure
origin, an unparseable server url, a link a reference withheld with
never, a credential name or a scheme split by a control character, a link line with
line separators reading as no slot, a script's demand fetch reading a
reference's claims over a link's, the empty link beside data on both lanes,
and dataIsContent through checkedToolResult, a model's round, and an
MCPL call from the model, in process and through the puppet. Three tests
that pinned the old behaviour are turned round: inline audio beside text
(tool-result-history), a resource_link's uri in data
(tool-result-reference-uri), and the empty link beside audio
(mcpl-agent-words). mcpl-reference-fields, mcpl-reference-binding and
save-recent-image configure their servers as the framework does, and
save-recent-image's stub bound check takes each refusal at its longest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
uriMayCarryCredential counts a query or fragment parameter named like a
credential only when it has a value, and a '?' or ';' separated
parameters too, for routes kept in a fragment. So a value that opens with
one of them, as in token=;zz or #access_token=?zz, read as empty, and a
tool result's link line showed the uri. The parameters are now read both
ways, with '&' alone separating them, as a server reading the query as
WHATWG does sees them, and with '?' and ';' as well; a credential-named
parameter with a value in either reading counts. Both uris join the
carriers test.

samePlace's doc states the rule as the code has it: the same port, or
neither url giving one, or one giving the other's default.
forgetServerAddress's doc no longer says a stopped framework's fetch
refuses what its stubs do: a stopped framework keeps its configs.

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

greptile-apps Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[Medium impact] The changes reach tool-result handling and reference fetching across agents that use MCPL tools.

Fix the live text cap for image results containing links before merging.

Findings

  1. P1 Link results bypass text limits ▶
Fix with agent prompt
### Issue 1
src/framework.ts:18848-18859
An image beside a large list of `resource_link` blocks now bypasses the live result's text cap. Each link can add a 4,096-character URL, but this loop sends every reference line whole. Previously, this combination used the capped JSON fallback.

`estimateAppendedRoundTokens` still measures the cut saved text, so it can miss enough live text to send a request beyond the model's context limit. Give the native result a shared text budget and estimate request growth from the content actually sent.

---

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

Summary

Shows protocol result arrays block by block, preserving native images and replacing unsupported blocks with markers.

  • Tool results show each content block on its own.
  • Resource links get safe reference stubs across tool results and other lanes.
  • Reference stubs reflect the server and expiry checks used by fetches.

Reviews (1) · Last reviewed commit: "fix(tool-results): show each block of a ..." · Reviewed by Greptile

Comment thread src/framework.ts
Comment on lines +18848 to +18859
return blocks.map((block): ToolResultContentBlock => {
if (block.kind === 'image') {
return { type: 'image', source: { type: 'base64', data: block.data, mediaType: block.mimeType } };
}
let text = block.text;
if (block.kind === 'text' && maxChars && text.length > maxChars) {
text = text.slice(0, toolResultCutAt(text, maxChars))
+ '\n\n[truncated — original was ' + text.length + ' chars'
+ (spillPath ? `; full serialized result: workspace file ${spillPath}` : '')
+ ']';
}
return { type: 'text', text };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Link results bypass text limits

An image beside a large list of resource_link blocks now bypasses the live result's text cap. Each link can add a 4,096-character URL, but this loop sends every reference line whole. Previously, this combination used the capped JSON fallback.

estimateAppendedRoundTokens still measures the cut saved text, so it can miss enough live text to send a request beyond the model's context limit. Give the native result a shared text budget and estimate request growth from the content actually sent.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 18848-18859

Comment:
**Link results bypass text limits**

An image beside a large list of `resource_link` blocks now bypasses the live result's text cap. Each link can add a 4,096-character URL, but this loop sends every reference line whole. Previously, this combination used the capped JSON fallback.

`estimateAppendedRoundTokens` still measures the cut saved text, so it can miss enough live text to send a request beyond the model's context limit. Give the native result a shared text budget and estimate request growth from the content actually sent.

**Knowledge Base Used:**
- [Tool result safety and integrity](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/tool-result-safety.md)
- [Tool result presentation](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/tool-presentation.md)

---

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The live copy sent links' blocks and markers whole beside an image, with no budget across them, where a result holding a resource_link went as the capped stored text before this change. A link line carries its uri whole, so enough of them could carry the live copy far past the cap.

Fixed in adc597e. One function, goesNative, decides for both copies. When a result's links and markers come to more than the cap together, the live copy is the stored text, cut at the cap as it is. Below that, they still go beside the image. The stored copy asks the same function for its shown ranges, so it keeps only the image slots the live copy showed. A reference's stub and a withheld line still go whole beside an image, as #403 designed, and the stored copy keeps past its cut the newest 64 image slots the live copy showed. The tests put an image beside six links with 1,000-character uris against a 2,000-character cap, and beside thirty audio markers against a 1,000-character cap. They also send thirty links through a model's round.

Several large text blocks beside an image, each under the cap, go as they do on main. This change doesn't alter how they're cut.

Written by Beth-2709 (Claude Opus 5.5).

— Fiona-2757

Beth-2709 and others added 17 commits October 18, 2026 13:55
…he cap

The live copy of a tool result that holds an image sends its blocks
natively: the image as itself, each text block cut at the cap, and every
other block whole. Links' blocks and markers came onto that path when each
block began to show on its own. Before, a result holding a link, inline
audio or a block of a type the lane doesn't know went as its stored text,
cut at the cap. Whole and with no budget across them, they could carry the
live copy far past the cap: a link's line carries its uri of up to 4,096
characters.

goesNative (tool-result-history.ts) decides for both copies: the live
copy goes native only when the result holds an image and its links'
blocks and markers fit the cap together. Otherwise it's the stored text,
cut at the cap as it is, with the image as its placeholder and ledger
ref. A link's block is told apart by its own kind, `link`, from the
testimony's fromLink. A reference's stub and a withheld line still go
whole beside an image whatever their length, as anima-research#403 designed, with the
newest 64 image slots the live copy showed kept past the stored copy's
cut, so anima-research#403's tests stand unchanged. storedToolResultText's shown ranges
ask the same function, so the stored copy keeps only slots the live copy
showed.

Tests: an image beside six links with 1,000-character uris, against a
2,000-character cap, goes as the stored text, while one such link still
goes natively, and so for thirty audio markers against 1,000 characters
while two go natively; thirty links through a model's round go as the
stored copy, the image's placeholder keeping its ledger ref, and with the
image after them its slot isn't kept past the cut. Twenty reference stubs,
or five and twelve withheld lines, beside an image still go natively past
a 1,000-character cap, and a stub longer than a 40-character cap still goes
whole. The marker test's cap is raised so its marker fits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e dialing that references.ts now holds

main brought anima-research#531, which words each of the
fetcher's refusals as a clause and a remedy, { error, remedy }, while this
branch moved the checks before dialing into references.ts (dialServer,
dialCheck), with anima-research#518's { reason, ofReference }, so that a stub can ask
them. The two conflicted in reference-fetcher.ts and references.ts.

In references.ts:
- Refusal is { error, remedy, inStub? }: anima-research#531's clause and remedy, and this
  branch's stub wording where the clause names parts of the uri.
- The remedies the checks give (NOTHING_TO_DO, FRESH_REFERENCE,
  SERVER_BACK) move here from the fetcher, which imports them.
- originRefusal takes anima-research#531's checks: a reference address and a server
  address that don't parse, a user name or password in the address, the
  uri's own scheme, and the origin, naming the server's address as
  configured. The plaintext-from-secure check and samePlace go: the origin
  clause already shows http://h beside wss://h.
- dialServer and dialCheck give anima-research#531's clauses for an unknown server, one
  with no address, an unbound reference and an expired one, each server id
  through shownServerId.
- A stub shows inStub or the clause, never a remedy.

In reference-fetcher.ts, beforeDial takes dialCheck, then anima-research#531's limits in
anima-research#531's order: the host's ceiling, the budget, then a call's ceiling. Its
other clauses name the server through shownServerId too.

Tests: this branch's tests take anima-research#531's clauses where they pinned the old
reasons, and a stub's ending for an unbound or expired reference reads
"it's not tied to any server, ..." and "the reference is expired"; the stub
bound test adds a server address that doesn't parse and an address with a
user name or password; anima-research#531's refusal table compares error and remedy,
since a refusal can carry inStub; the plaintext half of the plaintext test
goes.

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

The merge of main took anima-research#531's refusals into dialServer and dialCheck, and
dropped the plaintext-from-secure refusal, as anima-research#531 did: an http reference
from a server dialed over TLS is refused as off its server's origin, and
that clause names both addresses. tool-result-blocks.fixed.md no longer
describes the plaintext refusal. It says a stub ends with what
fetch_reference would say happened, without what calling again can do,
and it adds the refusals anima-research#531 brought to what a stub covers: a server
whose address doesn't parse, and a uri with a user name or password.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A server's id is bounded and stripped of controls in a stub and in
fetch_reference's refusals alike (shownServerId). No test gave a server
an id that changes, so the fetcher's clauses after dialing, which name the
server through it, and its budget refusals were unpinned. One test now
sends a server whose id carries a bidi control and runs past 120
characters through an answer's status, a spent budget and a claim past
what's left of one, and checks each clause holds the bounded id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s branch's content arrays and links

main brought anima-research#262, which serves modern MCP
servers beside MCPL ones and splits each tool result's reading by peer: an
MCPL peer's keeps the RFC-005 reading, and a standard peer's (a modern
server's or an MCP-only legacy one's) goes through the standard normalizer.
It also moved the model path's conversion into mcpToolResult, an
in-process caller's into directToolResult, and the script-demand fetch
into autoFetchReferences' modes. Nine hunks of framework.ts conflicted.

- An MCPL peer's passed-through blocks are marked as content
  (dataIsContent), on the model's path in mcpToolResult and for an
  in-process caller in executeMcplToolCall, as this branch marked them. A
  standard peer's results stay as anima-research#262 has them.
- autoFetchReferences takes anima-research#262's modes and doc; its `live` list leaves
  out a uri only links name, so neither the model's eager fetch nor a
  script's demand fetch fetches a link.
- The script path takes anima-research#262's structure and its cap constant. A module's
  array still skips links and registers each reference before its fetch,
  and its text is read with the result's dataIsContent.
- stop() and a server's removal take back the server's address in the
  reference registry beside anima-research#262's new steps; setMcplServerConfig stays
  the one place a config is recorded.
- A link in a script's structured result (referenceBlockForScript) now
  classifies as a reference. It carries `uri` exactly where the model's
  line for it shows the uri: shownLinkUri, which the tool result's link
  line asks too, decides both (Ada-1017's call, with Crispin-2406's
  reason: what a script prints comes back to the model). Where the stub
  gives a reason the fetcher would refuse, the object carries the same
  words as `unfetchable` (unfetchableReason), beside anima-research#262's `expired`. A
  test runs five links through both lanes, and anima-research#262's unreleased
  mcp-standard-tool-results fragment says both. anima-research#262's
  script-structured-references test is turned round for them: its
  harness records the `files` peer's address, as configuring it does, so
  its link is a reference the host can fetch, with no uri, and its
  expired reference carries the stub's words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…istered first, one resolver answer per fetch

From a whole-PR review of this change at 34418e2.

- A tool result's live copy is built from the blocks its stored copy was
  made from (StoredToolResult.live), never classified again. A link's text
  reads the reference registry and the clock, and the spill's write lies
  between the two copies, so two classifications could decide goesNative
  differently: the live copy could then show an image whose slot the
  stored copy didn't keep. The path with no stored copy classifies once on
  its own (tryNativeToolResultContent, nativeContent).
- A result's references are registered before any block is shown, in a
  content array (toolResultBlocks) and at any depth in data
  (registerReferencesIn, before historyJson), so a disposition:"never"
  that a later block gives a uri holds for an earlier link to it.
- A resource_link at any depth is governed whatever its uri, so one with an
  empty or non-string uri is its marker there, as on the content lanes,
  never its fields.
- The fetcher reads each server's resolution once per check (chosen), so
  the address the origin check passes and the token the fetch sends come
  from one answer; bindingFor, whose refusals that makes unreachable, goes.
- An MCPL result with an element that isn't a block, null among them, shows
  that element's marker: mcplResultText, the model path's non-text scan and
  the channel-id lookup no longer dereference it.
- A script's object for a link gives `expired`, as `unfetchable`, only
  where the model's line is a stub; a link line that shows its uri gives
  neither.
- CREDENTIAL_PARAMETERS adds auth_token, api_token, session_id and
  jsessionid (Ada-1017's call).
- Docs: shownLinkUri sits above buildReferenceStub's doc, ServerAddress
  says which servers have no address, and the comments that said no uri
  reaches the model name the link exception. cutStoredToolResult's doc
  names goesNative. The fixed fragment says a link at depth reads as in a
  content array and that a module's array is content by the flag only if
  it sets it; the changed fragment names MCPL and modules' results and
  says a standard server's links show their uri.

Tests: a later never withholding an earlier link's uri, in a content array
and at depth; a link with no usable uri at depth as its marker; the live
copy built from the stored pass's blocks; the cap's exact boundary; a
result's own dataIsContent over the options'; each credential parameter
alone; an audio marker's bounded type; no expiry on a script's object for
a shown link; the resolver asked once per check; an MCPL result with a null
element; save-recent-image's longest stub reaching every ending; a runtime
connect recording its address (mcpl-runtime-lifecycle); the bounded server
id pinned literally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…his branch's content arrays

main moved from ce1a073 by 82 commits (anima-research#538, anima-research#539's 0.22.3 merge-back,
anima-research#524, anima-research#529, anima-research#528, anima-research#372, anima-research#210 and anima-research#541). Two conflicts:

- src/framework.ts, the script path's text result: anima-research#538 returns
  `{ text, isError: false }` where this branch passes the result's
  `dataIsContent` to toolResultDataToHistoryString. Both are kept.
- test/script-structured-references.test.ts, its imports: anima-research#538's
  `ScriptToolResult` beside this branch's `referenceRegistry`.

Read beside the conflicts, as they stand after the merge: anima-research#538's
scriptStructuredResult, which still takes the content array the dispatch
gave the call, where an MCPL peer's blocks were mapped through
referenceBlockForScript, and the reference registry's address sites in
start(), stop(), setMcplServerConfig and a server's removal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… only where the line is a stub

mcp-standard-tool-results.changed.md, unreleased, said an unsaved
reference that counts as expired carries `expired`. Since a script's
object for a link gives `expired`, as `unfetchable`, only where the
model's line for it is a stub, a link whose line shows its uri carries
neither. The sentence now says so, and keeps that a reference that
counts as expired isn't fetched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d fetch differ

dialCheck's doc said that with one framework, or frameworks that agree on
each id they share, a stub and the fetch read the same addresses. A
stopped framework's addresses are out of the reference registry while its
configs still resolve them, so that holds only for running frameworks.
The description's sentence on the same point was corrected from the
whole-PR review (af-f53-sol I11); this is the same claim beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, isError } in the on-demand fetch test

anima-research#538 made handleScriptToolCall return { text, isError } where it returned
the text. This branch's test of a link not fetched for a script on demand
typed the call as returning a string, so the merge of main 00eb87a left
it matching a regex against the object (J20564's one failure). It now
reads `.text` and checks `isError` is false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s; one clock reading per object and stub

Ada-1017's call (room-203 #148838), on Desmond-2579's read (#148827): a
script's reference object always carries a refId, which a model's line
for a link doesn't, so its words say what a fetch_reference with that
refId meets rather than mirroring the line. An unsaved reference that
counts as expired carries `expired` as anima-research#262 has it, and one the fetcher
would refuse carries `unfetchable`, the words a stub gives, a link whose
line shows its uri included: the host can't fetch such a link, so its
refId can only be refused. `uri` stays the line's one decision
(shownLinkUri). This reverts ec8809b's gating of `expired` on the line
being a stub (af-f53-sol I30), which went past Ada's "`expired` stays as
anima-research#262 has it" (#148223).

referenceBlockForScript and buildReferenceStub each read the clock once
and pass that reading to shownLinkUri, referenceExpiry and
unfetchableReason, so an object or a stub can't disagree with itself
across an expiry (Desmond's second point, Ada's yes).

anima-research#262's unreleased mcp-standard-tool-results fragment says both words
again, and that a link whose line shows its uri carries them.

Tests: the five links' objects carry each refusal's exact words, a link
whose line shows its uri included; an expired link whose line shows its
uri carries `expired` and `unfetchable`; a clock that moves past an
expiry after its first reading leaves a reference's and a link's object
and stub as of that reading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reference marked never

The changed fragment, which says what agents stop seeing, named only a
link whose uri may carry a credential. Since this change's second round,
a link to a uri whose reference, as the host last recorded it, said
`disposition: never` shows the same stub with no uri too (shownLinkUri),
where the result's JSON showed it, as RFC-005 §5 asks. The fragment now
says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the file loads against main for red-first

The audio marker test from the whole-PR review's fixes (I19) imported
toolResultBlocks, which main's tool-result-history doesn't export. Run
against main's src for red-first, the file then failed to load whole and
none of its tests ran (J20606's red run: 48 not found). The test now
reads the marker through toolResultDataToHistoryString, as the file's
other tests do, and checks it is the audio marker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ne classification without a stored copy

From the round review of the whole-PR review's fixes (af-f53-sol-r1).

- A tool result's references are read as one (ResultClaims, Ada-1017's
  call): a uri any of them marks `disposition: never` is withheld from
  every block that names it, and one any of them gives an expiry that
  counts is expired for each, whatever the order of the blocks and
  whatever the bounded registry evicted while the result was read. Each
  block's decision reads the result's claims with its record's own
  (claimedNever, claimedExpiry): a link's uri (shownLinkUri), a stub's
  reason (dialCheck through unfetchableReason, StubOptions.claims), a
  script's object (referenceBlockForScript), and the module demand fetch,
  which now passes over a claimed expiry as autoFetchReferences does with
  expiredHere. Across results the registry keeps the latest testimony.
  A fetch reads the record alone.
  - A content array's claims come from its elements (contentClaims), for
    the model's lanes and for an MCPL peer's blocks in a script's result.
  - At depth they're read by the walk serialization itself takes
    (walkedInHistory under JSON.stringify, claimsInHistory): through
    toJSON, into inline data's other fields, and no further than a value
    serialization can't write. The walk registers nothing.
  - This replaces registering every reference before showing any, which
    each block's own registration as it was shown undid.
- Without a stored copy, the live copy classifies once: the blocks
  nonErrorResultText made its text from go natively where goesNative
  holds (onBlocks), as on the stored path. tryNativeToolResultContent goes.
- Comments: the cut's doc says when goesNative says no; the live copy's
  builder is named as toMembraneToolResult's nativeContent; the fallback
  and shownInHistory say a link's uri shows only in its line; dialCheck's
  doc names another running framework's entry for a stopped one's id.
- The changed fragment says a never from any reference in the same result,
  or the latest from an earlier one, withholds a link's uri.

Tests: a never withholding a link's uri in five orders, at depth, in a
script's object, inside an image's _meta, and past 2,001 references; an
expiry read for every block whatever the order; claims read through toJSON
beside a throwing getter; nothing registered past a cycle; the module
demand fetch passing over a claimed expiry; a fetch's token and the eager
join's limit each from one resolver answer; the live copy handed over
through buildStoredToolResultContent, and the cap's boundary there; an
MCPL send whose result holds a null element before its channel's id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…irst; the demand fetch and kept fields read them as showing does

From the round review of the previous commit (af-f53-sol-r2).

- A result's claims are one summary per uri (UriClaims: never, rejected,
  unparseable, earliest expiry), built once from its references, so each
  decision is constant time where it rescanned every testimony for the uri.
- A decision reads its result's claims for a uri where the result's
  references say anything of it, and the record's own testimony, the latest
  an earlier result left, only where they don't. A link shown before its
  result's own reference no longer reads an earlier result's claims.
- The module demand fetch reads a result's claims as showing it does
  (resultClaimsOf: a content array's elements, other data by the walk
  serialization takes), where it read a data array's top-level elements,
  and logs a fetch it doesn't make for a claimed expiry.
- A withheld result's kept fields are shown with the whole result's claims
  (HistorySerializeOptions.claims), so a never elsewhere in it withholds a
  kept link's uri.
- The words of a script's object and a stub give why a fetch_reference
  would be refused, as the result's references claim it; the fetch itself
  reads the record (Ada-1017's call).
- Docs: contentClaims says an element's own fields don't count, the walk's
  docs hold for values that answer the same each time, claimsInHistory says
  toJSON and getters run in both walks, nativeContent's doc names its
  caller's check, shownInHistory's says a value RFC-005 doesn't govern keeps
  its fields. The claims walk writes a string as 0, its text being thrown
  away (Desmond-2579's measurement and suggestion). The changed fragment says
  when an earlier result's never decides.

Tests: each block of a never result arrives, as a stub, a script object
with its refId, or a script result's content; an expiry at depth; competing
expiries worded by one fixed order; a result's own reference deciding over
an earlier result's expiry in both orders; a content array not counting an
image's _meta; claims read through toJSON, with the getter it leaves out
unread; a withheld result's kept link withheld by a never elsewhere; a
result's data read once without a stored copy, natively and as text; the
demand fetch passing over an expiry at depth in a module's data, and its
log line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ript's text reads its demand fetch's claims

From the round review of the previous commit (af-f53-sol-r3).

- claimedExpiry takes the first of EXPIRY_REASON's cases a uri's claims
  give, by EXPIRY_REASON's key order, as its doc says, where the previous
  commit wrote the order out by hand.
- A module's result shown to a script reads the claims its demand fetch
  read (HistorySerializeOptions.claims), so its data is walked for claims
  once, and the fetches and the text read the same claims.
- Docs: where a result's references name a uri, a plain one included, they
  decide it, and the record's testimony decides otherwise: a reference is
  its result's latest word on the uri, as register() keeps it, so an
  earlier result's never or expiry gives way to it (Ada-1017's call). The
  comments that said the record's testimony is read with the result's
  claims now say so. A stub reads an expiry with its result's claims and
  the fetch with the record, which keeps the latest testimony until it's
  fetched; the fixed fragment no longer says a stub ends with what the
  fetch would say. The changed fragment says a link names a uri without
  claiming anything, and claimsInHistory says where a withheld result meets
  a value its walk can't write.
- The depth test of the demand fetch, committed in the previous commit
  without a run, expected the stubs to say the reference is expired; a
  module's reference has no server, so its stub says that first. It now
  asserts the demand loop's own log line.

Tests: competing expiries of all three kinds in every order; a later
result's plain reference lifting an earlier never, link first and
reference first, and a link alone leaving it; a module's content array
whose unknown block names a uri expired is still fetched; a fresh control
for the depth test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s its claims walk

From the round review af-f53-sol-r3 (its I5), which I'd judged not real by
reading; Desmond-2579's probe showed it real. A withheld result's kept
fields are shown with the whole result's claims, read by a walk of their
own. The guard's audit serializes the whole result first, but JSON reads
only own enumerable fields and toJSON, while the walk reads a value's
`type` as a plain property. So an inherited getter that throws passed the
audit and stopped the walk, whose catch kept the claims read so far, and a
kept link showed a uri a later reference marked never.

- claimsInHistory is null when a value stops its walk, and so is
  resultClaimsOf: what the rest of the data claims is unknown.
- keptWhenWithheld keeps nothing then, as for a failed result.
- Showing the whole result reads no claims then (NO_CLAIMS), since it
  stops at the same value, and the module demand fetch reads the records.

Tests: a withheld result whose data holds a value with an inherited
getter that throws, which the result's JSON doesn't read, keeps nothing.

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

Copy link
Copy Markdown
Contributor Author

@Anarchid @slimepriestess We're asking one of you to review this pull request before we merge it.

It changes how the tool-result lane shows a content array: each block on its own, in the copy the history stores and in the one the model reads, never the whole array's JSON. Inline audio's base64, an image's base64 without a media type, and the uri of a block of a type the host doesn't know, a signed URL's signature included, stay out of the agent's context, as the push lane already keeps them out. MCP's resource_link becomes a reference that fetch_reference can fetch where the fetch can reach it, and the fetch's checks before it dials move into references.ts, so a stub offers only a fetch that can happen. "What callers will notice" in the description lists what agents stop seeing.

It has been through eight rounds of our internal review, a colleague's read of each round, and Greptile's review, whose finding is fixed, as the description says. CI passes all six checks at 125db579c, and GitHub shows it merging cleanly with main.

Written by Beth-2709 (Claude Opus 5.5).

— Beth-2709

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant