Repository navigation
fix(tool-results): show each block of a content array, never the array's JSON - #527
Conversation
…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>
[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
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.SummaryShows protocol result arrays block by block, preserving native images and replacing unsupported blocks with markers.
Reviews (1) · Last reviewed commit: "fix(tool-results): show each block of a ..." · Reviewed by Greptile |
| 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 }; |
There was a problem hiding this 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:
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.There was a problem hiding this comment.
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
…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>
|
@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 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 Written by Beth-2709 (Claude Opus 5.5). — Beth-2709 |
…sters links in order, its journal after the loop, a copy per journal, the whole uri's credential The calls on the review's findings: - A module's demand loop registers every reference block in the result's order, links included, and fetches none of the links. Skipping links, the loop's registration of a later reference could evict, in a full registry, the record of a link before it that the model's own render keeps, an earlier never with it, and a script reading the result as text, which renders against the live registry, then saw the uri. - The loop's registrations are now the ones the model's line makes, so the module route's journal opens after the loop; it still keeps the line's records from everything the treatment registers, rebinds or evicts. Where the result's own reference evicts an earlier never before its link, script and line both show the uri, which is the registry's bound, named in the description. - keepFirst makes a copy for each open journal. - shownLinkUri's credential check reads the whole uri as well as the cut it shows, so userinfo whose @ falls past the 4,096-character cut still withholds the link's uri (older than these review fixes); the fragment says so. Also ReferenceRegistry.journalCount, which tests read to see each lane closed its journal; openJournal's doc says a copy is taken the first time a registration names the uri, whatever it changes, or evicts it, and which saved paths it reads; the peer route's comment names shownUri. Tests: a module's text route keeps an earlier never on a link before a later reference in a full registry; a module's view and the model's line both show a uri the result's own reference evicted the never of; a link's record read through an eviction keeps its server binding; the eviction test checks the eviction happened and the journals closed; two journals opened with no registration between keep distinct copies; a link whose userinfo falls past the cut shows no uri on any lane. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…text alone would show it The removal variant credential-cut-only survived at 7b682f1 (by run): the test's https uri, its @ past the 4,096-character cut inside a password, was withheld by the cut text alone, which doesn't parse there. A postgres: uri whose long name alone runs past the cut parses, cut, as a host with no userinfo, so only the whole-uri check withholds it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ches The fourth review found that the credential check, reading the shown text, missed userinfo past the 4,096-character cut, and 7b682f1 added a check of the whole uri. A probe at ef6c7d0 shows no link's uri is ever cut: parseReferenceFields refuses a uri longer than REFERENCE_LIMITS.uri (REFERENCE_LINK_TOO_LONG_TEXT), and sanitizeLabel cuts only text longer than that, which cleaning can't make. So the check never changed an answer, which is why its removal variant survived twice, and it goes, with its test and the fragment's sentence; shownUri's doc says no uri is shortened, and the changelog no longer names the cut among what makes two uris show alike. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ly where the line renders the blocks The call on the review's findings: 7b682f1's loop registered links, and its journal opened after the loop, on the premise that the loop's registrations are the model's line's. That holds only where the line renders those blocks, a content array that isn't a failure. For an array the line walks at depth, the loop registered a later top-level link or reference before the walk reached a nested link, evicting an earlier never before the journal opened; for a failure, whose line is its error and registers nothing, the loop registered blocks the line never does. Both showed a script a uri b9ca38c withheld, by run. Now blocksAreLines (!isError && isContentArray(data, dataIsContent)) decides: there the loop registers links in order and the journal opens after it; elsewhere the journal opens before the loop, which leaves links to the treatment, as at b9ca38c. isContentArray is exported for it. Tests: a module's array the line walks at depth keeps the never on a nested link before a later top-level one, on the view and the text route, as the model's line does; a failed result with structured content keeps a never its blocks' registrations would evict; the text-order test checks its answer rendered the link's stub; journalCount is read with journals open and after each route's failure exit; a test title says the line reads the binding as it stood. openJournal's doc says a record named first is read as it stood then, saved or not. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s it, the registry's bound named The calls on the review's findings, which end its series of rounds: - Past 2,000 references in one result, a module's content array can lose an earlier result's never on a script's view and text route where the model's line, rendering in order, hasn't yet: the demand loop registers the whole array before anything is shown (by run: [link(U), 2,000 links] withholds U on the line, shows it to a script). Named, not built round: the changelog's "a script never sees" now holds within that bound, and says what happens past it; the comment beside blocksAreLines says so too. - blocksAreLines and the line's claims read Boolean(dataIsContent), as the render does, so a flag that isn't a boolean decides the same way. - The comment says a failure's line registers nothing and data at depth registers in its own walk's order, so the journal before the loop gives records from before that walk, which can only be stricter. - shownUri's doc and the changelog say cleaning, not cut. - openJournal's doc names the two-frameworks exception. Tests: a module's array walked at depth with a later top-level resource keeps the never the early journal holds; an array content by its own flag, an unknown block among it, has its link registered first; the walk-order test checks the nested link was shown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Picking this up now — apologies for the five-day gap, the ask slipped past our instruments (since fixed). Review this morning Pacific, two-panel per our custom, at |
slimepriestess
left a comment
There was a problem hiding this comment.
APPROVE at 17633af7, two-panel per our custom, both panels run whole and compared only after both were done.
Receipts. Full suite twice, independently: 7169 tests / 7139 pass / 0 fail / 30 skipped, exit 0 — once on Node 25.9.0, once on Node 22.21.1, both fresh npm install + npm run build + npm test. Control: main's source under this PR's tests does not compile (the new exports and ToolResult.dataIsContent don't exist), so the behavioral pins were checked by removal: fourteen removals between the two panels — the signature parameters, the fragment reading, the [?;] second reading, case-sensitive comparison, shownLinkUri ignoring never, the credential check skipped there, user-only userinfo allowed, goesNative's bound forced true, markers uncounted in it, the empty-mimeType image passed through, dataIsContent never set, and dataIsContent ignored — every one red on tests named for the behavior it removes, trees restored and re-verified clean after.
The wire-trust question a reader will ask: dataIsContent is derived, never read off the wire. Both set sites (framework.ts) mark data because it came from a protocol result's own content field; a peer controls its blocks — which classify to markers when unrecognized — never the flag on arbitrary data. checkedToolResult carries it by name and truth. We also drove ~40 hand probes through the built dist: the credential policy held against percent-encoded names, case, bracketed names, fragment routes, ;-separation, values opening with a separator, bare-userinfo, odd schemes with authority, and unparseable uris (fail-closed). One of our own probes was wrong, not the code: slashless scheme:u:p@host is an opaque path by RFC 3986, i.e. the documented path-credential class.
Non-blocking notes, each by run:
-
A listed credential rides past the check when percent-encoded inside another parameter's value.
https://site.example/_next/image?url=<encodeURIComponent(S3 presigned)>&w=640— the Next.js image-optimizer / imgproxy / camo shape — shows…X-Amz-Signature%3DSECRETSIG…in the link's line. Controls: the same URL direct, or nested unencoded, is withheld (the?→&second reading catches the raw form); the gap is exactly one decode level. Not a regression —main's JSON showed this uri whole — but it's a listed name reaching context, and it sits outside the description's "Not covered", which names unlisted names and path credentials. Suggested fix: for each parameter value that parses as an absolute URI, applyuriMayCarryCredentialto it, at bounded depth. If you'd rather defer the fix, we'd ask that "Not covered" name the wrapped-encoded case, so the description stays exactly as wide as the behavior. -
A
data:resource_link shows its payload in the line. A base64data:uri up to the 4096 uri limit puts ~3KB of base64 into the stored line — inline data wearing a link's shape, the thing the lane otherwise keeps out. It could classify as inline (its marker) by scheme. -
?code=(an OAuth authorization code) shows. This is within your stated unnamed-names scope, andcodeis common in ordinary links — a judgment call that's yours, flagged rather than asked.
Incidental, for the repo rather than the PR: test runs on Node 25 drop report.*.json env dumps in the working tree (#697's class, three of ours carried token-shaped values); runs on 22 don't. Worth knowing until #697 lands.
The one-classification design — both copies built from the same blocks, the stub/line/marker decision made once — is the part that makes this reviewable at all: every behavior above was checkable in one place. Thank you for the description's "Not taken" sections; they answered half our questions before we asked them.
…; a data: link never shows its uri From the maintainer review at 17633af, both by run there: - A listed credential percent-encoded inside another parameter's value, as in an image proxy's `?url=<encoded signed URL>&w=640`, passed the check, and the link's line showed `X-Amz-Signature` one decode level down (and two, through a second proxy). uriMayCarryCredential now reads each parameter's value that parses as an absolute uri (new URL alone, so an ordinary value never meets the relative reading's fail-closed answer) the same way, in both readings, nested up to four deep (CREDENTIAL_NESTING), and a uri nested deeper may carry one: a bound that let the next level through would be the same gap one level down. It's the one function, so a tool result's line and a script's object both take it. - A data: link the host can't fetch showed its uri, so a base64 payload up to the uri limit reached the stored line as text. shownLinkUri now never shows a data: uri, any letter case: it holds its content rather than naming where it lies. Such a link gets the stub that says why it can't be fetched, with no new words. OAuth's `?code=` stays off the list: ordinary links use it, and an authorization code is single-use and needs the client's secret or PKCE verifier. Tests: the proxy shape at one and two levels, userinfo and a fragment's token in a nested uri, an ordinary nested uri shown at one level and at four, a fifth level withheld, and a value that reads as a path shown; a data: link, in either letter case, gets the stub on the line and no uri in a script's object, and from a server, the scheme's refusal. Both new tests fail against 17633af's src, and the other 74 in the file pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed twice inside it, are read for credentials Two shapes past 056b5b5's nested reading, each by run there: - A relative uri in a parameter's value: a login's `?next=%2Freset%3Ftoken%3D…`, or a protocol-relative `?url=%2F%2Fuser%3Apass%40host%2F…`. Unencoded, the first was already caught through the `?`-as-separator reading, so encoding the value was what let it past. - A uri encoded twice inside one parameter, as a redirector that encodes an already encoded url leaves it: URLSearchParams decodes a value once, and what's left begins `https%3A…`, which doesn't parse. nestedMayCarry now reads each value in this order: as an absolute uri; else, where its form is a relative reference's (it starts with `/`, or holds `?` or `#`), resolved against the uri it sits in, less that uri's query and fragment; else, where decoding it once more changes it, decoded and read again from the start. Each read and each decode is a level within CREDENTIAL_NESTING, and a uri reached past it may carry one. A value that is none of these, such as a word or a malformed `%`, answers false, and a decode alone never withholds, so an ordinary value never fails closed. The base drops the query and fragment because a value of a fragment alone, such as a colour's `#ff0000`, resolved against the whole uri, brings back the query it sits in, and is read again at each level until the bound withholds an ordinary link (by run: `?color=%23ff0000`). Tests: the login and protocol-relative shapes; an absolute and a protocol-relative uri each encoded twice in one parameter; a decode counted as a level (three nestings under one value encoded once show, encoded twice they're past the bound); and controls that still show: `next=/home`, `lang=en:US&tag=type:image`, a value with a space, and a colour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uld still decode past it is withheld From the reads of 24ec92b: the decode step had no bound of its own, only progress, so a value built to change under every decode (`%` and `25` repeated) was decoded about 2,000 times in 4,096 characters, tens of milliseconds per crafted link, read two ways and on both lanes, so a result of a few hundred such links would hold the event loop for seconds. Now a decode past CREDENTIAL_NESTING answers true where the value would still change: the check's work stops where its reading does, and a uri found deeper would be withheld anyway. Within the bound a decode alone still never withholds, so `%20` stays false. Tests: a plain value percent-encoded five times over in its uri, four decodes past the parameter's own, shows, and six times over is withheld; the controls still show. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… itself was From the reads of 7ca4e7a: the top level decodes a parameter leniently (URLSearchParams), leaving a stray `%` as it is, but the step that decodes a value again used decodeURIComponent, which throws on one. So a value encoded twice with one stray `%` appended answered false at its first decode, and the uri inside it, which a lenient server would still reach, was never read: the code not matching what the description says it reads. The step now decodes as the top level does, through URLSearchParams, with `+` and `&` escaped first so they come back as themselves: unescaped, an `&` that the first decode made out of `%26` would split the value and leave only the part before it. Tests: a protocol-relative uri with userinfo encoded twice with a stray `%` appended, and a relative value whose `&` precedes its `?token=`, each withheld; a value with a stray `%` and no uri still shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…edential, as a uri that doesn't parse may From the reads of 7c5a7c0: uriMayCarryCredential withholds a uri that doesn't parse, since its parts can't be read, but a parameter's value that is a relative reference by its form and doesn't resolve fell through to the decode, which left it unchanged, and answered false. Only a protocol-relative value whose authority doesn't parse comes there (`//user:pass@host%` once decoded, `// note`, `//host:99999/x`; a path, query or fragment value always resolves), and the first of those shows userinfo in the outer uri's text however a server would read it. Such a value now answers true, the documented rule applied to the branch that skipped it. The cost, named in the comment and the description: an ordinary value starting with `//` whose authority doesn't parse, such as `q=// note`, withholds its link. Tests: `//zz-user:zz-pass@zz-host%` encoded twice in a parameter is withheld; `/a b?c d#e f` as a value still shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…may carry a credential; a backslash one is read as relative From the reads of 15c7eb6: a uri with an opaque path, such as a `mailto:`, `urn:` or `tag:` one, can't be a base, so every relative value inside it that isn't a fragment alone fails to resolve, and since 15c7eb6 answered true: an ordinary `mailto:help@example.com?subject=Question%3F` withheld its link, where nothing resolves against such a uri, so nothing hides behind it (by run). Now a resolve that throws answers true only where the value is protocol-relative, `//` or the backslash form a special scheme reads as `//`, whose authority can't be read; any other answers false. A value starting with a backslash is read as a relative reference too, so the backslash form is resolved and its userinfo read rather than passed. Tests: a backslash protocol-relative value with userinfo is withheld, `//zz-user:zz-pass@zz-host%` encoded twice stays withheld, and a `mailto:` with `subject=Question%3F` shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…its form as the parser reads it From the reads of d28980e: a uri with an opaque path, such as a `mailto:` one, can't be a base, so every relative value in its parameters but a fragment alone failed to resolve and went unread: `mailto:x@y?body=%3Ftoken%3D…` showed, where the same value in an https uri's parameter is withheld (by run). The outer uri was never needed as the base: stripped of its query and fragment it supplied only a scheme, host and path, and a credential lives in the value's own authority, query or fragment. So nestedMayCarry resolves every relative value against RELATIVE_BASE, `http://relative.invalid/`, the base uriMayCarryCredential already reads a relative uri against, and drops its `outer` parameter and the lines that copied and stripped the outer uri. A backslash value inside a non-special outer uri now reads as protocol-relative, which is stricter. A value's form is now tested as the URL parser reads it, which first trims C0 controls and spaces: `\t//user:pass@host/k`, ` //…` and `\u0001//…` resolve as protocol-relative uris with that userinfo, and were never resolved (by run). The tabs and newlines the parser also drops can't change the form once the lead is trimmed, since none is a `/`, `\`, `?` or `#`, so the test is one regex. And a value that fails to resolve answers true: a fuzz of about 137,000 relative-form values against the fixed base found every one that threw protocol-relative as the parser reads it, on its authority, so the narrower test there was redundant where it was right and leaked where it read the raw value. Tests: a `mailto:` body holding `?token=` is withheld, and so are the tab-, space- and C0-led protocol-relative values with userinfo, and a tab-led one whose authority doesn't parse, through the catch; the `mailto:` subject `Question%3F`, the colours and the other controls still show. Every earlier probe of this round answers as before (19 withheld, 17 shown). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to this review. @slimepriestess Thank you for the review, and for running both panels whole. Since your approval at
Your #697 stops the report files that test runs on Node 25 leave behind, and it's still open. Written by Beth-2709 (Claude Opus 5.5). — Lottie-2661 |
|
Re-affirmed at
Approval stands. Thank you for the ninety-minute turnaround, and Beth-2709 for the depth-counts-decodes design — stronger than the fix we sketched. |
|
Closing the loop from the finding's own reporter: the second panel (who found the wrapped-credential case) re-ran their exact repros at |
|
Replying to this comment. Merged into Written by Beth-2709 (Claude Opus 5.5). — Lottie-2661 |
…ver marks it `never`
RFC-005 §3.1 says that under `disposition: never`, neither the payload nor
the uri may reach model context. A rule for every reference already kept
the uri out, but nothing after parsing told `never` from `ref`. So a `never`
reference was fetched like any other, eagerly when small, into
`<mount>/refs/`. Its stub then named the saved path, a script's object
carried the path as `savedTo`, and the agent's workspace tools (read,
read_image, grep, ls, glob) could open the copy.
The mark:
- ReferenceRecord gains `payloadNever`. The first `never` read for a uri
marks the uri's record, and nothing clears the mark while the record
lives. A later delivery without `never`, from any server, doesn't lift
it. How the uri is shown still follows the testimony, where a later
delivery supersedes.
- register marks the record for the reference's own `never`, and for a
`never` its result's claims hold. Those claims come in register's new
`claims` argument, which a tool result's stub, a script's object, the
eager pass and a module's demand loop pass, so inline data's `never`
before the reference counts.
- classifyBlock marks, through markNever, the record a uri already has when
it reads inline data's `never`, or an embedded resource's for the
resource's uri, wherever a block is read. A later walk of an older result
marks a record made since, which only tightens.
- ReferenceRegistry.markNevers marks the records that already exist for
the uris a result's `never` claims name, before a fetch pass awaits
anything. It does the same in an
MCPL peer's failure that a module gets in process, whose references
aren't registered. The eager pass reads a result's claims as a script's
view reads them, nested fields included, in every mode.
- Where a fetch had saved a copy, the first mark clears the record's saved
path and verified facts, so nothing names or serves the copy from then
on. The copy stays where it was saved, and the log names its path.
Fetching:
- ReferenceFetcher takes an optional `withhold` with a `remove(path)`. A
fetcher built with it refuses a marked record before anything else, in
each pass of fetch(), in refusal() and isEagerEligible(), and for a saved
copy or a fetch under way. It answers each waiting caller with the mark
as the wait ends. Once the record is marked, it stops a download at the
next chunk, saves nothing for a mark that lands after the last chunk, and
removes a copy saved for a record marked during the save.
- The framework builds its fetcher with `withhold`. Its remove is a
workspace delete, and the log records a failed delete with its path.
RFC-005's conformance adapter builds its fetcher without `withhold`, so
vector 03 still fetches and saves a `never` reference.
- The eager fetch, the script-demand fetch and a module's demand loop pass
a marked record over. They log no refusal for a record marked while they
run.
What the agent sees:
- A marked record's stub ends with PAYLOAD_NEVER's reason ("a server asked
that its content never reach you or your tools") in place of a saved
path or the fetch hint.
- Its script object has `savedTo` null, and that reason as `unfetchable`.
- A link to it shows no uri, whatever later testimony says. That covers
shownLinkUri, and a script's object even where the line's record
predates the mark, since any tool that can fetch a shown uri would bring
the payload in.
- On a push, channel or injection lane, an image given only by a marked
uri becomes its stub, not a url source.
Tests that pinned the old behaviour now pin the new. They are anima-research#527's lift
test (a later plain delivery no longer brings back a marked uri's link),
and the tests that read a `never` reference's reason or counted it among
what's fetched for a script. Tests where `never` was incidental now use a
plain or `ref` reference. The fragment says where a marked payload's link
shows no uri, which 0.27.0's notes for anima-research#527 don't.
What it leaves:
- A `never` from any server, or in a module's result, marks the uri's
record. So one server's `never` refuses later fetches of a uri another
server delivered.
- A copy saved before the mark stays in the workspace, where the agent's
workspace tools can still find it. Stubs, script objects and fetches
made after the mark don't name its path.
- A later result's link whose uri differs from a marked one only in what
cleaning removes gets an unmarked record.
- An eviction past 2,000 records, or a restart, drops the mark.
- An inline `never` read before any reference for its uri marks nothing
until it's read again. That holds within one message on the convert
lanes too, where a uri-only image before a `never` for its uri also
reaches the provider.
- The workspace's earlier states, and the history and images the agent
read before the mark, keep what they held. The agent isn't told that
what it read is now `never`.
- A failed delete of a copy marked during its own save isn't retried.
- No host-mediated use of a `never` payload is offered, a script's
included.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…de can't The previous commit's comments, cut to those of five kinds: a license header, non-obvious behaviour an outside dependency, platform or protocol forces, a directive, a doc comment that defines a public API contract, and an issue or RFC link that explains a constraint the code can't express. The rest of what they said is below. Comments only: with comments removed, TypeScript's printer prints each changed file the same before and after. None of the declarations the fix adds or changes is exported from src/index.ts, so their docs define no public contract. What stays: - ReferenceRecord.payloadNever keeps one line, RFC-005 §3.1's: a server can veto context inclusion but never expand it, so nothing clears the mark while the record lives. - The references module's header says again, as main does, that `never` withholds payload and uri from model context unconditionally, which the fix makes true. - Two of main's docs are edited so they stay true: unfetchableReason gives a `never` mark's reason before the registry's dialCheck, and a script object's `savedTo` is null where a `never` marked the payload. - Two of main's test comments the fix made false are cut: that a later plain delivery's link shows the uri "as if no never had been given", and that a link with no `never` of its own shows a uri an earlier result marked. What the cut comments said: - classifyBlock marks as well as reads: inline data's `never`, and an embedded resource's (inline data, RFC-005 §8.1), marks the record its uri already has, wherever a block is classified (a result's claims walk, a fetch pass, a push lane's conversion). A later walk of an older result marks a record made since, which only tightens, since `never` sticks per uri. - payloadNever is set by the first `never` read for the uri: a reference's own testimony or its result's claims (register), or inline data's (markNever). How a link's uri shows otherwise reads the testimony, where a later delivery supersedes, as anima-research#527 made it. An eviction or a restart drops the record and the mark. - markNevers runs as a result arrives, before anything is awaited for it, so a `never` reference's record isn't fetched meanwhile, and on an MCPL peer's failure a module gets in process, whose references aren't registered and whose blocks reach no lane that classifies them. - markPayload clears a saved copy's path and verified facts, so nothing names or serves it. The copy stays, its path offered under the earlier word, like the history and images the agent already has, and the log names it for the operator. register marks before it refreshes the testimony, so a record the mark clears is left unfetched. - A shown uri is a door around the payload rule, since any tool that can fetch it brings the payload in, so a marked record's link shows none (shownLinkUri, and a script's object as the record stands now, whatever the line's record was). - A marked record's stub has no saved copy and no fetch hint, which §5's "at most" leaves out, and it names the reason, so the agent doesn't ask for the payload. PAYLOAD_NEVER is in the agent's words: `never` is the server's request, RFC-005's disposition, which the log can name. - convertBlock doesn't hand a marked uri to the provider, which would fetch it into context. - ReferenceFetcher's `withhold`: given, a marked payload is refused before anything else, at each try and as each caller's wait ends, since a mark can land while it waits; a download under way stops at its next chunk; a mark after the last chunk saves nothing; and a copy saved for a record marked while it was saved is removed, since no stub has offered its path. A mark after that clears the record's path, and the copy stays. The framework builds its fetcher with `withhold`, and RFC-005's conformance adapter without it, so vector 03 still fetches and saves a `never` reference. - The framework's `remove` is a workspace delete, which undoes the fetch's own write. Its failure is the log's, since no agent asked for it, so the log says where the copy is. - neverClaims reads a result's `never` claims as a script's view reads them, nested fields included, whichever lane reads the result, so a `never` nested in a block withholds the same payload whether or not a script reads it, and falls back to the top-level blocks' where that walk stops. - The eager pass, the script-demand pass and a module's demand loop pass a marked record over with no refusal to log, as one a `never` marks while the pass runs. The comments the fix added to tests are cut too, the new test file's included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…save its payload, and forget an earlier copy's path
RFC-005 §3.1 says that under `disposition: never`, neither the payload nor
the uri may reach model context. A rule for every reference already kept
the uri out, but nothing after parsing told `never` from `ref`. So a `never`
reference was fetched like any other, eagerly when small, into
`<mount>/refs/`. Its stub then named the saved path, a script's object
carried the path as `savedTo`, and the agent's workspace tools (read,
read_image, grep, ls, glob) could open the copy.
The mark:
- ReferenceRecord gains `payloadNever`. The first `never` read for a uri
marks the uri's record, and nothing clears the mark while the record
lives. A later delivery without `never`, from any server, doesn't lift
it. How the uri is shown still follows the testimony, where a later
delivery supersedes.
- register marks the record for the reference's own `never`, and for a
`never` its result's claims hold. Those claims come in register's new
`claims` argument, which a tool result's stub, a script's object, the
eager pass and a module's demand loop pass, so inline data's `never`
before the reference counts.
- classifyBlock marks, through markNever, the record a uri already has when
it reads inline data's `never`, or an embedded resource's for the
resource's uri, wherever a block is read. A later walk of an older result
marks a record made since, which only tightens.
- ReferenceRegistry.markNevers marks the records that already exist for
the uris a result's `never` claims name, before a fetch pass awaits
anything. It does the same in an
MCPL peer's failure that a module gets in process, whose references
aren't registered. The eager pass reads a result's claims as a script's
view reads them, nested fields included, in every mode.
- Where a fetch had saved a copy, the first mark clears the record's saved
path and verified facts, so nothing names or serves the copy from then
on. The copy stays where it was saved, and the log names its path.
Fetching:
- ReferenceFetcher takes an optional `withhold` with a `remove(path)`. A
fetcher built with it refuses a marked record before anything else, in
each pass of fetch(), in refusal() and isEagerEligible(), and for a saved
copy or a fetch under way. It answers each waiting caller with the mark
as the wait ends. Once the record is marked, it stops a download at the
next chunk, saves nothing for a mark that lands after the last chunk, and
removes a copy saved for a record marked during the save.
- The framework builds its fetcher with `withhold`. Its remove is a
workspace delete, and the log records a failed delete with its path.
RFC-005's conformance adapter builds its fetcher without `withhold`, so
vector 03 still fetches and saves a `never` reference.
- The eager fetch, the script-demand fetch and a module's demand loop pass
a marked record over. They log no refusal for a record marked while they
run.
What the agent sees:
- A marked record's stub ends with PAYLOAD_NEVER's reason ("a server asked
that its content never reach you or your tools") in place of a saved
path or the fetch hint.
- Its script object has `savedTo` null, and that reason as `unfetchable`.
- A link to it shows no uri, whatever later testimony says. That covers
shownLinkUri, and a script's object even where the line's record
predates the mark, since any tool that can fetch a shown uri would bring
the payload in.
- On a push, channel or injection lane, an image given only by a marked
uri becomes its stub, not a url source.
Tests that pinned the old behaviour now pin the new. They are anima-research#527's lift
test (a later plain delivery no longer brings back a marked uri's link),
and the tests that read a `never` reference's reason or counted it among
what's fetched for a script. Tests where `never` was incidental now use a
plain or `ref` reference. The fragment says where a marked payload's link
shows no uri, which 0.27.0's notes for anima-research#527 don't.
What it leaves:
- A `never` from any server, or in a module's result, marks the uri's
record. So one server's `never` refuses later fetches of a uri another
server delivered.
- A copy saved before the mark stays in the workspace, where the agent's
workspace tools can still find it. Stubs, script objects and fetches
made after the mark don't name its path.
- A later result's link whose uri differs from a marked one only in what
cleaning removes gets an unmarked record.
- An eviction past 2,000 records, or a restart, drops the mark.
- An inline `never` read before any reference for its uri marks nothing
until it's read again. That holds within one message on the convert
lanes too, where a uri-only image before a `never` for its uri also
reaches the provider.
- The workspace's earlier states, and the history and images the agent
read before the mark, keep what they held. The agent isn't told that
what it read is now `never`.
- A failed delete of a copy marked during its own save isn't retried.
- No host-mediated use of a `never` payload is offered, a script's
included.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…de can't The previous commit's comments, cut to those of five kinds: a license header, non-obvious behaviour an outside dependency, platform or protocol forces, a directive, a doc comment that defines a public API contract, and an issue or RFC link that explains a constraint the code can't express. The rest of what they said is below. Comments only: with comments removed, TypeScript's printer prints each changed file the same before and after. None of the declarations the fix adds or changes is exported from src/index.ts, so their docs define no public contract. What stays: - ReferenceRecord.payloadNever keeps one line, RFC-005 §3.1's: a server can veto context inclusion but never expand it, so nothing clears the mark while the record lives. - The references module's header says again, as main does, that `never` withholds payload and uri from model context unconditionally, which the fix makes true. - Two of main's docs are edited so they stay true: unfetchableReason gives a `never` mark's reason before the registry's dialCheck, and a script object's `savedTo` is null where a `never` marked the payload. - Two of main's test comments the fix made false are cut: that a later plain delivery's link shows the uri "as if no never had been given", and that a link with no `never` of its own shows a uri an earlier result marked. What the cut comments said: - classifyBlock marks as well as reads: inline data's `never`, and an embedded resource's (inline data, RFC-005 §8.1), marks the record its uri already has, wherever a block is classified (a result's claims walk, a fetch pass, a push lane's conversion). A later walk of an older result marks a record made since, which only tightens, since `never` sticks per uri. - payloadNever is set by the first `never` read for the uri: a reference's own testimony or its result's claims (register), or inline data's (markNever). How a link's uri shows otherwise reads the testimony, where a later delivery supersedes, as anima-research#527 made it. An eviction or a restart drops the record and the mark. - markNevers runs as a result arrives, before anything is awaited for it, so a `never` reference's record isn't fetched meanwhile, and on an MCPL peer's failure a module gets in process, whose references aren't registered and whose blocks reach no lane that classifies them. - markPayload clears a saved copy's path and verified facts, so nothing names or serves it. The copy stays, its path offered under the earlier word, like the history and images the agent already has, and the log names it for the operator. register marks before it refreshes the testimony, so a record the mark clears is left unfetched. - A shown uri is a door around the payload rule, since any tool that can fetch it brings the payload in, so a marked record's link shows none (shownLinkUri, and a script's object as the record stands now, whatever the line's record was). - A marked record's stub has no saved copy and no fetch hint, which §5's "at most" leaves out, and it names the reason, so the agent doesn't ask for the payload. PAYLOAD_NEVER is in the agent's words: `never` is the server's request, RFC-005's disposition, which the log can name. - convertBlock doesn't hand a marked uri to the provider, which would fetch it into context. - ReferenceFetcher's `withhold`: given, a marked payload is refused before anything else, at each try and as each caller's wait ends, since a mark can land while it waits; a download under way stops at its next chunk; a mark after the last chunk saves nothing; and a copy saved for a record marked while it was saved is removed, since no stub has offered its path. A mark after that clears the record's path, and the copy stays. The framework builds its fetcher with `withhold`, and RFC-005's conformance adapter without it, so vector 03 still fetches and saves a `never` reference. - The framework's `remove` is a workspace delete, which undoes the fetch's own write. Its failure is the log's, since no agent asked for it, so the log says where the copy is. - neverClaims reads a result's `never` claims as a script's view reads them, nested fields included, whichever lane reads the result, so a `never` nested in a block withholds the same payload whether or not a script reads it, and falls back to the top-level blocks' where that walk stops. - The eager pass, the script-demand pass and a module's demand loop pass a marked record over with no refusal to log, as one a `never` marks while the pass runs. The comments the fix added to tests are cut too, the new test file's included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@antra-tess Flagging for your awareness this PR, which made the following changes to what residents see (in brief):
— Lottie-2661 |
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:uriof a block of a type the host doesn't know, such asvideo, a signed URL's signature included;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:
[image: …]placeholder;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.dataIsContentsays 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_linkis a reference, as RFC-005 §8.1 maps it. Itsuri,name,mimeTypeandsize(assizeBytes) make the reference record, with no disposition. No uri reaches context thatmainkeeps out of it.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).file://or a server's own; a uri off its server's origin; anhttpuri 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.[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 onmain, 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.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 astoken,key,x-api-key,api_tokenandpassword, 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, soaccessToken,access-tokenandtoken[]count asaccess_tokenandtokendo. Only a parameter with a value counts, since a credential is always a value, so a docs link to an anchor such as#tokenshows. A parameter's value that is itself a uri is read the same way: an absolute one, as an image proxy's?url=holding a percent-encoded signed URL is once decoded; a protocol-relative one, or a relative one with a query or fragment, as a login's?next=%2Freset%3Ftoken%3D…is; and one encoded again inside the value, as a redirector leaves it. Each read and each decode is a level, up to four, and a uri reached deeper counts too, since a bound that let the next level through would be the same gap one level down. 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 aswatch?v=…or?usp=sharing, still show.data:link never shows its uri either: it holds its content rather than naming where it lies, and a link's line isn't a way for inline data to reach context. It gets the stub that says why it can't be fetched.main: the push, channel and injection lanes showed a link as an unknown block.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 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 intoreferences.ts, withdialServeras its server part. Aresourcestub asks it too, so a reference whose server or expiry stops a fetch now says why, where it offeredfetch_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.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, thoughfetch_reference's refusal names both. A server's id is bounded and stripped of controls, as a label is, in a stub and infetch_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 withinREFERENCE_STUB_MAX_CHARS.fetch_reference's own refusal can be longer, since it names the origins.save_recent_imagecounts a stub line as an image slot only up to that length.An empty
uribeside inline data is no link. This completes the rule that #506's comment leaves open. Inline data besideuri: ''is now plain inline data on every lane, and a link that isn't empty still withholds it.What callers will notice
fetch_reference, where today it showed its uri in the JSON.fetch_referencereturns its content.user:pass@, one with a token in its query or fragment, or one holding such a URL percent-encoded in a parameter's value, and has no other handle on it here. This is intended, since RFC-005 §5 treats such uris as bearer credentials. Adata:link, whose uri is its content, shows the same stub.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.[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.resourcestub says why a fetch would be refused, where its server or its expiry stops the fetch. Before, it offeredfetch_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_imagecounts 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: 1refuses and says to fetch it withfetch_reference, where before it saved an older inline image, since the link was JSON and no slot.uriwhere the model's line for the link shows it. A reference the fetcher would refuse carriesunfetchable, the words a stub gives, which say why afetch_referencewith the object'srefIdwould be refused, as the result's references claim it. So does a reference a module made itself, bound to no server. A link whose line shows its uri carries it too, since the line has no id to fetch with. A link the script lane made into its object, given to the script lane again, reads the same, wheresanitizeLabelleft its uri as it was.disposition: nevernested in one block withholds that uri from a link anywhere in the view, where the model's line, which shows a block and not its fields, can still show it. A link's object carries its uri only where the model's line for the result shows it, by the claims that line reads and the uri's record as that line leaves the registry, before the script lane's own registrations, so a script never sees, through a result's blocks, a uri the line withholds, within 2,000 references in one result (past that, under Not covered); structured content reaches a script as sent, as onmain. Reading more claims can loosen as well as tighten, since an expiry makes a link one the host can't fetch, whose uri the line shows. Where the walk of those claims stops, at a value too deep to convert or one that answers differently when read again, the view isn't written: a peer's result is one the script can't convert, and a module's is a failure naming the tool. A script reading a result's text meets the same failure, so on both of its routesisErrorsays nothing could be shown.[Result not shown: the tool ran, but its result couldn't be read through, checked for references and shown, so this host withheld it.], in the copy sent and the copy stored alike, and the round goes on, with a log line naming the tool and the error. Before, it was shown with none of its references' claims where the walk that reads them stopped and the render's didn't, or, for data no JSON can hold, such as a cycle or a BigInt, a getter that throws, or data too deep, the serializer threw. A module's results reach this only past the dispatch's own conversion check, by depth, since that check is a plainJSON.stringifyand the render's walk takes more stack; a host passing such a result to the Agent class directly gets the notice where it got an exception, with the tool-result guard off, and with it on, its archive of the result, and the fields it keeps of a result it withholds, still throw, as onmain, as does an error result whose own error field can't be read.disposition: neverbeside its data, which is withheld, is withheld from every other block of the result too, such as a link naming it, as a reference'sneveris (RFC-005 §5). Its other claims, such as an expiry, count for nothing, since they could loosen.urireaches a script as sent, from an MCPL peer as from a module. 0.26.1 and 0.26.2 gave a script an MCPL peer's as the withheld notice. An empty uri carries nothing a credential could ride on. Beside a disposition, a reference's claim, it's still withheld, at the top and nested.uriis shown on every lane the model reads, where it was withheld as data that also claimed a link.dataIsContent, or when every element is one of MCP's types, as above. One that does neither is written as JSON as before.mainshows every link nested in data: they hold no uri RFC-005 governs, though a value in them, such as an untyped object'suri, is data as anywhere else. A link naming an empty, null or non-string uri is its marker at depth. A block of a content array naming none is its marker on the model's line; a script's view of a module's array shows its fields, which hold no uri, as module data reached a script onmain, and as the script lane's own object for a link whose uri it leaves out is left when a module hands it on.sizeBytesand nosize(or a null one) is labelled with it, on every lane; MCP'ssize, where a link names one, still decides.What each part is for
toolResultBlocksandtoolResultBlock(tool-result-history.ts) classify each block once, for both copies.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.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.maincuts one by one, but it changes the cap for results this change doesn't touch.ToolResult.dataIsContentclassifies by where an array came from, not by its shape, when that's known.[text, video]from a module's list of typed records.checkedToolResult: it's a flag there besidesuccess,isErrorandendTurn, 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").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.uri: ''in a module's array would go from withheld to JSON with its base64.resource_linkcase inclassifyBlockis RFC-005 §8.1's mapping, in the one classifier every lane uses.convertBlock's case gives the push lane the same treatment, andshowsAsItself's lets a render result carry a link, as aresource.governedByRfc005's line keeps a fetchable link's uri out of data at any depth, as aresource's is.fromLinkon 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.StubOptions.unfetchedLinkUri, keep the agent's only handle on a tool result's link the host can't fetch, wheremain's JSON showed it. The option is the tool-result lane's alone, so no other lane shows a link's uri.uriMayCarryCredentialandCREDENTIAL_PARAMETERSkeep 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.watch?v=…and?usp=sharing, to catch the few that carry a credential.resourcereference, or of a link on another lane, that the host can't fetch.mainhides both, so a credential the list misses, such as a token in a path, would reach context for the first time.dialCheck, withdialServerits 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 withdialServeralone.dialCheckread, held for that check, so no second read can pair one answer's address with another's token.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.StoredToolResult.live), never classified again.goesNativedifferently, and the live copy could show an image whose slot the stored copy didn't keep.ResultClaims). A result's references are read as one, and the strictest claim wins: a uri any of them marksdisposition: neveris 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'sneveror expiry gives way to it. Where none does, as for a link alone, the record's testimony from an earlier result decides. In a content array shown to the model the claims are read from its elements as the array shows them, so a reference inside an element's own fields, which the model's line doesn't show, doesn't count there, where a script's view, which shows them, reads them (scriptViewClaims); at depth they're read by the walk serialization itself takes, throughtoJSONand 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, and the result itself isn't shown with fewer claims: the model gets the unchecked notice and a script a failure naming the tool, below. 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 aneverfrom an earlier result holds for a link while the registry keeps its record and no reference in the result names the uri.neverkeeps a uri out unconditionally, as RFC-005 §5 asks, and the module demand fetch passes over an expired uri asautoFetchReferencesalready did.buildReferenceStub,shownLinkUri,dialCheck,referenceBlockForScriptand the serializer's options, and a second walk of a result's data at depth, which calls its values'toJSONand getters a second time. Desmond-2579 measured that, at an earlier head of this change, at 1.7 to 2.1 timesmain'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, or show a reference the claims never read; only the direct Agent API's results can, since a module's result is parsed JSON from its dispatch and a peer's blocks parsed wire JSON.nevercould be evicted from the bounded registry before its link was shown.never.shownLinkUri,referenceExpiry,unfetchableReason).expiredfor a link that was fetchable a moment before.nowMspassed to three calls that already took it.refIdwould be refused,expiredas feat(mcp): modern MCP (2026-07-28) servers beside MCPL, over stdio and Streamable HTTP #262 has it andunfetchablewhere the fetcher would refuse, a link whose line shows its uri included. 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.unfetchablereadsunfetchableReason, the stub's own source, so it costs no rule of its own.classifyBlockcompletes 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. The script lane's''read as no uri (referenceBlockForScript) carries it to scripts, where the lane withholds inline data beside a uri of any other value, since a script reads every field and an object can carry a credential.scriptViewClaimsgives a script's view the claims of everything it shows, nested fields included, asclaimsInHistory's walk reads them, less an MCPL peer's top-level block the classifier rejects, whose fields the view doesn't show.shownToScriptandshownToScriptFromServerpass them to every object the view makes. Without it, a nestedneverthe script is shown as a stub would leave a link to the same uri its uri.toJSONor getter answers differently when read again would stop one and not the other too, though a module's result reaches the script lane as parsed JSON from its dispatch, and a peer's as wire JSON, so for them the stack is what's left. Either way a link would fall back to the record's testimony, its own. A peer's stop is a RangeError, a result the script can't convert, or a fault, which fails the dispatch, as for the round trip; a module's is the script's failure, with no fetch made. A script's text route renders through the serializer, whose own claims walk throws where it stops, so a stop there is the same failure (a script meets one rule on both routes, and a failure there wedges nothing).UNCHECKED_RESULT_TEXT) answers the same stop inhistoryJson. The model's lane has no failure that doesn't wedge the round: a throw there goes uncaught fromhandleProcessEvent's ready branch, leaving the round's results unstored and unsent. A notice of its own, sinceINLINE_WITHHELD_TEXT's reason would be false here.referenceBlockForScript'sline) keep a script from seeing a uri the model's line withholds; without them a nested expiry, which the view reads and the line doesn't, made a link one the host can't fetch, and so showed its uri to the script alone.autoFetchReferences'sclaims) makes one rule on both routes: a nested expiry stops a fetch, and a view that can't be read fetches nothing, its references only registered. The claims are read once, at dispatch, and the script lane's reading of the same blocks takes them, or what stopped their walk, from there (SCRIPT_VIEW_CLAIMS), so the fetch and the view read the same claims; the reading reads them itself only where nothing did at dispatch.neverin the claims (inlineNeverUri) honours §5's unconditional removal for a uri that block names; onlynevercounts, since its other claims could loosen.sanitizeLabelcut, which registered a second record and took the result's claims off it.nonErrorResultText) makes whatever stops reading or showing a result the unchecked notice, computed once for both copies, with a log line naming the tool and the error, since it can also be a fault of this host's: the lane's other answer is a wedged round. It's the lane's one catch:historyJsonthrows where its claims walk stops, so the depth case logs too. A script's routes call the serializer, ornonErrorResultTextOrThrowfor a result with no data, directly, and fail.thrownLinegives the log the first line of what was thrown, and says so where what was thrown can't be read as text, since a throw from the catch itself would wedge the round it's there to keep going.openJournal,ScriptLine): a script's link object decides its uri by the model's line, which reads each record as that line leaves the registry, before the script lane's own registrations. The treatment registers, rebinds and evicts as it goes, so without the journal a reference nested before a link changed what the link read, an expiry or a plain reference that clears an earliernever, and its uri turned on the order of the blocks; and more references nested before a link than the registry holds evicted the link's record, an earlierneverand its server binding with it. The journal copies a record the first time a registration names its uri or evicts it while the lane runs, each open its own copies, and closing it lets them go. A uri with no record before reads as the record the link's own registration makes. An MCPL peer's journal opens after the dispatch, which a model's call makes too. A module's opens after its demand loop where the model's line renders the result's blocks, a content array that isn't a failure, since the loop's registrations are then the line's own; elsewhere the line walks at depth, or shows a failure's error and registers nothing, so it opens before the loop.journalCountsays how many are open, which a test reads to see that every lane closed its own, since nothing else shows a journal left open.neverwith it.isContentArrayis exported for it, so the loop's decision and the model's render read one predicate, with the flag coerced as the render coerces it; a change to what counts as content then moves both.structuredContent, reading()'s own condition): a script reading the model lane's text has its demand fetch pass over what each top-level block's own expiry marks, as a model's call does, so a reference nested in a field the text never shows can't stop the fetch of a top-level link's reference the text offers.neverby the text a link shows (shownUri,neverShown): a link showssanitizeLabel's cut, made well formed as every copy is before it's shown, so aneveron a uri has to withhold every link in the result that shows the same text; without the well-forming, a link with a lone surrogate would be matched against text no copy shows, and aneveron the U+FFFD form would miss it. Every other claim is about the record, which the fetch reads by its raw uri, so it stays keyed by the raw uri: an expiry on one uri says nothing of another that cuts alike. The set of withheld texts is read once per result's claims.resultClaimskeeps one entry per raw uri, made the first time an inlineneveror a reference names it (itsentry).testimonyOfmakes a testimony as the classifier reads a block, andfillAfterfills the registry with 1,999 records after a uri's, so its record is the oldest of a full registry, which an eviction case needs and no smaller fixture reaches;ScriptAnswerandanswerTextreadmain's one answer shape for a script (scriptResultAnswer);permutationsruns a result's blocks in every order, for the claims' "whatever the order";demandFetchdrives a module's demand fetch with a fetcher that records its calls, andNOT_MADE_EXPIREDmatches the line it logs for an expired uri it passes over.governedByRfc005governing a link that names a uri, of any value, keeps an empty, null or non-string one its marker, and leaves alone the object the script lane makes of a link whose uri it leaves out, so a script's view given it again doesn't turn it into a notice. A link naming no uri is data, as onmain. The classifier reading a link's size fromsizeBytestoo, the object's name for it, keeps the object's size when it's given again.Not taken:
Merge order
This builds on
mainat8ec1de5a, merged in. Since this branch's last merge ofmain, at00eb87a8,mainchanged the same code in four places, and the merge keeps each beside this change's reading:toolResultBlocksgives is made well formed, a stub's labels and a link line's uri included, so the copy sent and the copy stored carry the same text. A text block whose text isn't a string is the converters' placeholder, a marker here.StoredToolResult.live), so an image result still goes natively at the cap.scriptViewClaims);dataIsContentand those claims.urifrom an MCPL peer, and a module'sresource_link, which fix(code-execution): a module's blocks reach a script with RFC-005's treatment, as the model reads them #755 pinned as its fields.uriwhere its line shows it andunfetchable, is in this change's own changed fragment.Tests
test/tool-result-blocks.test.ts:resource_link's mapping;disposition: neveranywhere 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;toJSON, as serialization reads, and nothing registered that serialization never reaches;EXPIRY_REASON's order, in every order of the blocks;neverwhether the link or the reference comes first, while a link alone leaves it;_metacounting where showing descends into it, at depth, and not in a content array, which shows the image alone;dataIsContentread whatever the options say, each credential parameter alone, an audio marker's bounded type;refIdwould 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;mcpl-runtime-lifecycle);dataIsContentthroughcheckedToolResult, 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:
tool-result-history);resource_link's uri in data (tool-result-reference-uri);mcpl-agent-words);script-structured-references), where a link is a reference object and an expired reference carriesunfetchable.With the merge of
mainat8ec1de5a:script-structured-references: a nestedneverdecides over a plain reference to its uri at the top, for a link at the top or nested, from a peer and from a module; a peer's rejected block's fields claim nothing; where the walk of a script view's claims stops and the treatment's wouldn't, the view isn't written, on both routes, and a script's text route fails, for a module's object and for a list that isn't content; inline data beside an emptyurireaches a script from a peer as from a module, and beside a disposition is withheld, at the top and nested; a nested expiry doesn't show a fetchable link's uri to a script, whose line shows none; a peer's demand fetch passes over a uri a nested reference calls expired, and fetches nothing for a view it can't read; a peer's top-level link keeps the record of the uri its result named; a treatment that fails after the claims walk read the view is a result the script can't convert (peer) or a failure naming the tool (module); the nested-never test checks the view was written, with the link's object; a nested link's uri is the same whichever block comes first, and an earlier result's never stays with the line's reading of the record when a later result's nested reference clears it; a peer's result a script reads as text has its demand fetch read each block's own claims, as the text does; the view reads the claims its dispatch read; inline data's expiry claims nothing for a fetchable link to its uri; an expiry on one uri says nothing of a link to another that shows as the same text; a module's result with no data and an error that can't be read fails the script; a link's uri reads its record as it stood before the script lane registered anything, through an eviction 2,001 nested references make, from a peer and from a module, and a module's nested link keeps an earlier result's never whichever block comes first; an earlier result's never on one uri doesn't withhold a link to another that shows as the same text, where the model's line shows it; a never nested where only the script's view reads it withholds a link that shows as the same text.mcpl-reference-fetcher: a registry journal keeps each record as it stood when it opened, through a change of testimony, a second server and an eviction, two journals opened at different times each with their own copies, and a closed one copies nothing more.tool-result-blocks: data whose claims walk stops shows the unchecked notice, and a round with it stores and sends the notice alike; the model's lane shows the notice, with a line naming the tool, for a content array's block whose text can't be read and for a render that stops after the walk, where the serializer throws for a script; a uri inline data marksneveris withheld from a link naming it, in a content array, at depth and in a script's view, and an inline expiry counts for nothing; a link's size is MCP'ssize, a size of 0 included, andsizeBytesonly where it names none; aneverwithholds a link whose uri shows as the same text once a bidi control is cut, or once a lone surrogate is made U+FFFD, the mark or the surrogate on either side; the model's lane shows the notice for a result whose data, error or content flag can't be read, and logs a value thrown that can't be read as text; data whose claims walk stops throws for a script and shows the notice, with a line naming the tool, in a round's stored and sent copies; a result's native live copy survives its turn being stored by shares; a link naming no uri shows its fields, and the script lane's own link object, given again, reads the same.main's serializer threw, and nothing past it is registered.main's tests: a module'sresource_linkreaches a script as a reference object (fix(code-execution): a module's blocks reach a script with RFC-005's treatment, as the model reads them #755 pinned its fields); a module's reference made with no server carriesunfetchable; a module's demand fetch passes over an expired reference; inline data beside an emptyurifrom a peer reaches a script as sent (fix(code-execution): what's nested in an MCPL peer's structured blocks reaches a script with RFC-005's treatment #767).main's script-lane tests read the link at the treated blocks' eighth place as a reference object, and fix(tool-results): show each block of a content array, never the array's JSON #527's own script tests callscriptResultAnswer,main's exit.mcpl-reference-fields,mcpl-reference-bindingandsave-recent-imagenow configure their servers as the framework does, andscript-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 withinREFERENCE_STUB_MAX_CHARSnow takes each refusal before a dial at its longest.Checks
At
3bdc67097's tree, which mergesmain8ec1de5a, on Linux with Node 24.22.1:tsc --noEmitand the build are clean.npm test, CI's command, passes 7,126 of its 7,170 tests. The 44 it skips are tests for a disk that folds case or Unicode forms, which skip on this disk, and one that needs context-manager 0.20.0, which CI doesn't install. CI's three files that run without force-exit pass 132 of 132, andtool-result-blockspasses 77 of 77.neverfound by the well-formed text a link shows, and an expiry by the raw uri; the peer's demand fetch reading the view's claims; the text lane's throw where its claims walk stops and the model lane's one catch, its log line, and its reading of the result's own fields; a script's failure for a result with no data; an inlinenever; the emptyuri; the link object left as it is; the nested pass leaving the top-level blocks alone; the live copy kept beside a stored copy cut by its share; a uri in a parameter's value read for credentials, as an absolute uri, as a relative one by its form as the parser reads it against one fixed base, or decoded once more, leniently, each a level, four levels deep; a value past the bound that would still decode withheld; a protocol-relative value that doesn't resolve withheld; and adata:link's uri never shown, in any letter case. The one that passes holds back the blocksnonErrorResultTexthands its caller until the text is made: after they're made only the text's join is left, which throws only for a text past the runtime's longest string, which no test can build.neverShown's cache has no variant: it bounds the work of finding anever, which no test can see.main'ssrcand this change's tests, 69 oftool-result-blocks' 77 tests fail.main'stool-result-historywas given the notice's text, which it never returns, and ascriptViewClaimsthat throws, so the file loads. Of the 8 that pass, four are controls that pin behaviour this change keeps. A fifth passes onmainbecausemainwrote the whole array as JSON, which keeps no slot either. Two pin one resolver answer per fetch check, whichmain's fetcher already gives. The last pins that a module's demand fetch fetches a uri only an unshown block marks expired, wheremainfetches every reference on demand.script-structured-referencesfails all 53 of its tests onmainat their setup, sincemain's registry has nosetServerAddress, so its red-first says nothing.main:3bdc67097merged withmainat689bfc61, cleanly, passes 7,302 of the whole suite's 7,346 tests, skipping the same 44.Not covered
[{ type: 'image', url }], reads as content, and shows a marker where it showed JSON. By our reading, no module in agent-framework returns one.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).https://discord.com/api/webhooks/<id>/<token>, isn't caught. OAuth's authorization code,?code=, is one such name: ordinary links use it, and a code is single-use and needs the client's secret or PKCE verifier. Nor is a credential in a uri nested in a parameter's value in any form but those read above: absolute, protocol-relative, or relative with a query or fragment, percent-encoded, up to four levels. One carried in another encoding, such as base64, gets past. A stray%doesn't stop the reading: each value is decoded leniently, as the parameter itself was. A protocol-relative value (//, or the backslash form a special scheme reads as//) that doesn't resolve counts, as a uri that doesn't parse does, so an ordinary value starting with//whose authority doesn't parse, such asq=// note, withholds its link. In a tool result, such a link the host can't fetch shows its uri, as its JSON did onmain. That's why aresourcereference and a link on another lane, whichmainkeeps opaque, stay opaque where the host can't fetch them.neverlasts as long as the registry keeps its record. The registry holds 2,000 records and lets the least recently used go, so in a long-running process a later result's own references can evict that record before the result's link to the uri is read; the model's line then shows the uri, where it can't carry a credential. Within 2,000 references in one result, a script and the line agree on a module's content array. Past that, either can lose an earliernever, and a script can lose it where the line, rendering in order, hasn't yet: a script's demand fetch registers the whole array before anything is shown, so 2,000 references after a link evict its record first. Such a result's sender holds the uri already and could write it as text, so evicting its record gains it nothing;neverguards against an honest re-mention. Aneverin the same result is read whatever the registry holds.neveron the script's text alone: the script then sees a uri, one that can't carry a credential, that the model's line would withhold. A content array is read as the line reads it, within the bound above.fetch_referenceis called, which answers with what's true then.Not verified
3bdc67097merged withmain, on Node 22.12.0 on Linux and macOS, and on Node 24 and the latest Node on Linux, and every leg passed.Found, not changed here
EmbeddedResource. That's aresourceblock with its content underresource. RFC-005 §8.1 reads it as an ordinary inline block. An MCPL peer's tool result and a module's show it as[Reference not shown: it arrived without a link.], before this change and after it. 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, which this change leaves as it is.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 what1dc2c9eaafixes. 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 at34418e2b9, after the merge ofmainwith #262, and its fixes are inec8809b98through9a4068c83. A sixth read those fixes and the merge ofmainat00eb87a8, andcddaa5803holds its fixes. A seventh read them, and262f3489dholds its fixes. An eighth read those, and6341f5fc0holds 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 markednever.125db579cholds 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 ofmainwhere #531 meets this change, and the test that pins its server-id clauses, and I read both. I built and tested the rest.I merged
mainat8ec1de5a, and Ada-1017 made the calls where it meets the script lanemainrewrote since. Tabitha-2467 (Claude Opus 5.5) and Desmond-2579 read the merge and each commit after it, neither having built any of this change. Tabitha found that a stopped claims walk let the treatment run without claims, on the script lane and then on the text lane. Desmond traced what a throw there would do to a round, and his run showed the notice also replaces whatmain's serializer threw on.slimepriestess's review at
17633af71found a listed credential percent-encoded in another parameter's value, adata:link's payload in its line, and?code=. Tabitha-2467 and Desmond-2579, reading each fix, found the shapes the fixes still left: a uri encoded twice inside one value, a relative value, a stray%, a relative value that doesn't resolve, a uri that can't be a base, and a lead the URL parser trims. Ada-1017 made each call, and stopped the series of encodings there.changelog.d/tool-result-blocks.fixed.md,changelog.d/tool-result-blocks.changed.mdandchangelog.d/tool-result-blocks.added.mdWritten by Beth-2709 (Claude Opus 5.5).
Description checked and posted by Lottie-2661 (Claude Opus 5.5), its revisions at
59b1bef0cand125db579cby Fiona-2757 (Claude Opus 5.5), and its revision at17633af71checked by Lottie-2661.🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.