Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions changelog.d/reference-uri-at-depth.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
- A tool result's data that isn't a content array of blocks agent-framework
recognizes was stored, and sent to the agent, as plain JSON, so an RFC-005
reference in it carried its raw uri into the agent's context and history.
That JSON now shows, wherever in the data it sits, each reference as its
stub (or as an unrecognized-block line when it's invalid), and inline data
that also claims a uri or a disposition as withheld. A reference is a
`resource` with a uri, at its top level or under `resource` where MCP
embeds one, or an `image` or `audio` block given by `uri`; inline data is
an `image` or `audio` block whose data is a string. Since JSON writes every
field, inline data beside a uri of any value is withheld there, not only
beside a string one. A uri that is null, absent or empty counts as none,
and data that isn't a string isn't inline data, so a record that only
shares one of those type names is written as before, as is everything else
in the data: inline media that claims neither, with its bytes, and a
`resource_link`'s fields among it. A reference that is the whole data is
its stub, read as a content array's reference is, a `save_recent_image`
slot included. The same text reaches a script that calls the tool, the
direct Agent API's tool result, and a withheld result's kept fields, where
data that is itself a reference keeps its stub whichever fields its tool
marks. Results stored before the upgrade keep the text they were stored
with, any uri in it included, which goes on reaching the model until those
results leave its context.
- The notice for withheld inline content now says the inline data also
claimed a uri or a bulk disposition. It named only a disposition, also
when a uri was the cause. An `image` or `audio` block with neither string
data nor a string uri is now said to have data or a uri that isn't a
string when it has one, where the unrecognized-block line said it had
neither.
35 changes: 19 additions & 16 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16174,8 +16174,13 @@ export class AgentFramework {
if (result.data === undefined || result.data === null) return nonErrorResultText(result, 5_000_000);
// RFC-005 "eagerly lazy": a script receiving a result is the strongest
// available signal that the bytes are wanted (scripts process, they do
// not browse) — materialize every referenced payload now, so the stub
// the script receives carries a workspace path it can open directly.
// not browse) — materialize the references of a content array now, so
// the stub the script receives carries a workspace path it can open
// directly. Only a reference registered against its server can be
// fetched: an MCPL server's result content is, on arrival. An in-process
// module's reference has no server, so its fetch is refused (and logged
// here) and the script gets its stub, as it does for a reference nested
// in other data, which isn't fetched.
if (Array.isArray(result.data)) {
const fetcher = this.getReferenceFetcher();
for (const block of result.data) {
Expand All @@ -16188,8 +16193,10 @@ export class AgentFramework {
}
}
// Scripts get (nearly) full data — the script environment IS the spill:
// filtering big results in code is the whole point. 5MB protocol safety
// cap only; no context-cap truncation, no file spill.
// filtering big results in code is the whole point. As in history, a
// reference is its stub at any depth and inline data claiming a uri or a
// disposition is withheld (RFC-005). 5MB protocol safety cap only; no
// context-cap truncation, no file spill.
return toolResultDataToHistoryString(result.data, 5_000_000);
}

Expand Down Expand Up @@ -16287,24 +16294,20 @@ export class AgentFramework {
}
// MCPL tool results arrive as `data: McpToolResultContent[]` — preserve image
// blocks natively rather than JSON-stringifying them away. Anything else
// (objects, scalars) falls through to JSON. The error path was handled
// above, so isError is always false on these return paths.
// goes as text. The error path was handled above, so isError is always
// false on these return paths.
const blocks = this.tryNativeToolResultContent(afResult.data, maxChars, precomputed?.filePath ?? null);
if (blocks) {
return { toolUseId: callId, content: blocks, isError: false };
}
if (precomputed !== undefined) {
return { toolUseId: callId, content: precomputed.text, isError: false };
}
// JSON.stringify returns the VALUE undefined (not a string) for undefined
// input — a module tool returning `{ success: true }` with no data would
// otherwise make `content.length` below throw a TypeError mid-turn.
let content = JSON.stringify(afResult.data) ?? '';
if (maxChars && content.length > maxChars) {
content = safeSlice(content, 0, maxChars)
+ '\n\n[truncated — original was ' + content.length + ' chars]';
}
return { toolUseId: callId, content, isError: false };
// Without the history path's copy, the text is made as that copy is
// (nonErrorResultText), so a reference's uri stays out of it at any depth
// (RFC-005), a result with no data reads its error text, or no text, and
// never the value undefined.
return { toolUseId: callId, content: nonErrorResultText(afResult, maxChars), isError: false };
}

/**
Expand Down Expand Up @@ -16357,7 +16360,7 @@ export class AgentFramework {
if (stub === null) return null;
blocks.push({ type: 'text', text: stub });
} else {
return null; // unknown shape — bail to JSON path
return null; // unknown shape: the stored copy's text, which shows any reference in it as its stub
}
}
return hasImage ? blocks : null;
Expand Down
12 changes: 10 additions & 2 deletions src/mcpl/references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export function classifyBlock(block: unknown): BlockClassification {
return { kind: 'inline', contradiction: block.disposition !== undefined || hasUri };
}
if (hasUri) return parseReferenceFields(block);
return { kind: 'invalid', reason: `${block.type} block with neither data nor uri` };
return { kind: 'invalid', reason: `${block.type} block ${mediaWithoutStringReason(block)}` };
}
case 'resource':
return parseReferenceFields(block);
Expand All @@ -102,6 +102,14 @@ export function classifyBlock(block: unknown): BlockClassification {
}
}

/** What a media block with neither string data nor a string uri has instead:
* nothing, or a field that isn't a string. */
function mediaWithoutStringReason(block: Record<string, unknown>): string {
const notString = (['data', 'uri'] as const).filter((f) => block[f] !== undefined && block[f] !== null);
if (notString.length === 0) return 'with neither data nor uri';
return notString.length === 2 ? "whose data and uri aren't strings" : `whose ${notString[0]} isn't a string`;
}

function parseReferenceFields(block: Record<string, unknown>): BlockClassification {
if (typeof block.uri !== 'string' || block.uri.length === 0) {
return { kind: 'invalid', reason: 'reference without string uri' };
Expand Down Expand Up @@ -140,7 +148,7 @@ export function isInlineContradiction(block: unknown): boolean {
}

export const INLINE_WITHHELD_TEXT =
'[inline content withheld: nonconformant bulk disposition on inline data]';
'[inline content withheld: inline data that also claims a uri or a bulk disposition]';

/**
* Why a reference counts as expired, or null when it doesn't: its expiresAt
Expand Down
7 changes: 5 additions & 2 deletions src/tool-result-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { ContextManager, MessageId, MessageMetadata } from '@animalabs/cont
import type { ContentBlock, NormalizedMessage, ToolResult } from '@animalabs/membrane';
import type { CompletedToolCall } from './types/index.js';
import { ensureStateRegistered } from './module-registry.js';
import { toolResultDataToHistoryString } from './tool-result-history.js';
import { rfc005TextOf, toolResultDataToHistoryString } from './tool-result-history.js';
import { safeSlice } from './safe-slice.js';

export const TOOL_RESULT_GUARD_NOTICE = 'Tool result withheld by the guard. The tool has already executed.';
Expand Down Expand Up @@ -101,7 +101,10 @@ export function keptWhenWithheld(result: CompletedToolCall['result'], maxChars:
const fields = data as Record<string, unknown>;
const kept = Object.fromEntries(keys.filter((key) => Object.hasOwn(fields, key)).map((key) => [key, fields[key]]));
if (Object.keys(kept).length === 0) return undefined;
const whole = toolResultDataToHistoryString(kept);
// Data that RFC-005 shows as text, such as a reference's stub, keeps that
// text: picked out of it, a kept uri would lose the type that makes it a
// reference, and be written out of the stub that hides it.
const whole = rfc005TextOf(fields) ?? toolResultDataToHistoryString(kept);
if (!maxChars || whole.length <= maxChars) return whole;
// Never half a surrogate pair: the stub goes to the provider as text, so
// the cut can be one character short of the cap.
Expand Down
79 changes: 76 additions & 3 deletions src/tool-result-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
*
* For MCP-shaped content arrays, this helper keeps text blocks verbatim and
* replaces image blocks with a short `[image: mimeType, ~NKB]` placeholder.
* For anything else it falls back to JSON.
* For anything else it falls back to JSON, written by RFC-005's rules at
* every depth (historyJson): a reference is shown as its stub wherever it
* sits in the data, so its uri never reaches history, and inline data that
* claims a uri or a disposition is withheld.
*/

import { INLINE_WITHHELD_TEXT, isInlineContradiction, referenceStubOrNull } from './mcpl/references.js';
Expand Down Expand Up @@ -55,7 +58,7 @@ export function toolResultDataToHistoryString(
const fromArray = tryHistoryStringFromContentArray(data, options);
// JSON leaves out undefined, a function and a symbol: a result with no data
// is stored as no text, as an empty content array is.
const str = fromArray ?? (JSON.stringify(data) as string | undefined) ?? '';
const str = fromArray ?? historyJson(data) ?? '';
return maxChars ? truncateForHistory(str, maxChars) : str;
}

Expand Down Expand Up @@ -94,7 +97,8 @@ export function truncateForHistory(str: string, maxChars: number): string {
/**
* If `data` is an MCP content array whose every block has a shape we
* recognize, return a history-safe string (images → placeholders). Otherwise
* return `null` to defer to the caller's fallback (JSON, usually).
* return `null` to defer to the fallback, historyJson, which shows a
* reference anywhere in the data as its stub.
*/
function tryHistoryStringFromContentArray(data: unknown, options?: HistorySerializeOptions): string | null {
if (!Array.isArray(data)) return null;
Expand Down Expand Up @@ -131,6 +135,75 @@ function tryHistoryStringFromContentArray(data: unknown, options?: HistorySerial
return parts.join('\n');
}

/**
* Whether RFC-005 governs a value of a tool result's data, wherever it sits.
* A reference is a `resource` with a uri, at its top level or under
* `resource` (where MCP embeds a resource's uri), or an `image` or `audio`
* given by uri; inline data is an `image` or `audio` whose data is a string.
* A uri that is null, absent or empty counts as none, and data that isn't a
* string isn't inline data, so a record that only shares one of those type
* names is data. A uri of any other value, string or not, counts, so a
* credential fails closed.
*/
function governedByRfc005(b: Record<string, unknown>): boolean {
if (b.type === 'resource') return hasUri(b.uri) || (isPlainRecord(b.resource) && hasUri(b.resource.uri));
if (b.type === 'image' || b.type === 'audio') return typeof b.data === 'string' || hasUri(b.uri);
return false;
}

function hasUri(value: unknown): boolean {
return value !== undefined && value !== null && value !== '';
}

function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/**
* A value of a tool result's data as history shows it, by RFC-005's rules,
* wherever it sits in the data. A value RFC-005 governs (governedByRfc005) is
* shown as the reference lanes show it: a reference as its stub, an invalid
* one (such as a uri that isn't a string, or MCP's embedded resource, whose
* uri isn't at its top level) as an unrecognized-block line, and inline data
* that also claims a uri or a disposition as withheld, so no uri is ever
* shown. Inline data that claims neither is itself, as is any other value:
* a block of another type keeps its fields, a `resource_link`'s uri among
* them, since RFC-005 governs references.
*/
function shownInHistory(value: unknown): unknown {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
const b = value as Record<string, unknown>;
if (!governedByRfc005(b)) return value;
if ((b.type === 'image' || b.type === 'audio') && typeof b.data === 'string') {
// JSON writes every field, so here inline data beside a uri of any value
// is withheld, not only beside a string one as in the lanes, which show
// no uri: an object can carry a credential. An empty uri carries none.
return hasUri(b.uri) || b.disposition !== undefined ? INLINE_WITHHELD_TEXT : value;
}
return referenceStubOrNull(b, 'from tool result') ?? value;
}

/**
* The text RFC-005 shows a value of a tool result's data as, when it shows it
* as text rather than as its fields: a reference's stub, an invalid one's
* unrecognized-block line, or inline data withheld. Null for any other value.
*/
export function rfc005TextOf(value: unknown): string | null {
const shown = shownInHistory(value);
return shown !== value && typeof shown === 'string' ? shown : null;
}

/**
* JSON of a tool result's data with each value in it shown as history shows
* it (shownInHistory), at any depth. Data that is itself shown as text, such
* as a bare reference, is that text.
*/
function historyJson(data: unknown): string | undefined {
const root = shownInHistory(data);
if (root !== data && typeof root === 'string') return root;
return JSON.stringify(data, (_key, value: unknown) => shownInHistory(value)) as string | undefined;
}

function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `~${Math.round(bytes / 1024)}KB`;
Expand Down
Loading
Loading