diff --git a/changelog.d/reference-uri-at-depth.fixed.md b/changelog.d/reference-uri-at-depth.fixed.md new file mode 100644 index 000000000..083f5578e --- /dev/null +++ b/changelog.d/reference-uri-at-depth.fixed.md @@ -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. diff --git a/src/framework.ts b/src/framework.ts index 6d854a393..f128f5251 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -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) { @@ -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); } @@ -16287,8 +16294,8 @@ 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 }; @@ -16296,15 +16303,11 @@ export class AgentFramework { 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 }; } /** @@ -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; diff --git a/src/mcpl/references.ts b/src/mcpl/references.ts index 2043953a1..32313674a 100644 --- a/src/mcpl/references.ts +++ b/src/mcpl/references.ts @@ -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); @@ -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 { + 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): BlockClassification { if (typeof block.uri !== 'string' || block.uri.length === 0) { return { kind: 'invalid', reason: 'reference without string uri' }; @@ -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 diff --git a/src/tool-result-guard.ts b/src/tool-result-guard.ts index 2a71f2ab3..f4fffebdb 100644 --- a/src/tool-result-guard.ts +++ b/src/tool-result-guard.ts @@ -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.'; @@ -101,7 +101,10 @@ export function keptWhenWithheld(result: CompletedToolCall['result'], maxChars: const fields = data as Record; 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. diff --git a/src/tool-result-history.ts b/src/tool-result-history.ts index bbba8ea6c..9ff08f969 100644 --- a/src/tool-result-history.ts +++ b/src/tool-result-history.ts @@ -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'; @@ -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; } @@ -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; @@ -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): 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 { + 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; + 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`; diff --git a/test/tool-result-reference-uri.test.ts b/test/tool-result-reference-uri.test.ts new file mode 100644 index 000000000..27f36dabe --- /dev/null +++ b/test/tool-result-reference-uri.test.ts @@ -0,0 +1,342 @@ +/** + * A tool result's data never reaches the agent, or its history, with an + * RFC-005 reference's uri, wherever in the data it sits: data that isn't a + * content array of blocks the serializer recognizes is written as JSON with + * each reference as its stub and inline data claiming a uri or a disposition + * withheld. Everything else in it is written as JSON writes it, as before. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { AgentFramework } from '../src/index.js'; +import type { Module, ModuleContext, ToolCall, ToolResult, ToolDefinition } from '../src/types/index.js'; +import { nonErrorResultText, toolResultDataToHistoryString } from '../src/tool-result-history.js'; +import { parseImagePlaceholders } from '../src/tool-image-ledger.js'; +import { keptWhenWithheld } from '../src/tool-result-guard.js'; +import { classifyBlock } from '../src/mcpl/references.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; + +const SIGNATURE = 'zz-secret-signature'; +const URI = `https://zz-files.example/blob?sig=${SIGNATURE}`; +const RESOURCE = { type: 'resource', uri: URI, mimeType: 'application/pdf', name: 'zz.pdf' }; +const UNKNOWN = { type: 'zz-unknown', note: 'zz-kept' }; +const STUB = /\[ref_[^\]]+\] zz\.pdf — application\/pdf — from tool result/; +const BASE64 = 'aGVsbG8gd29ybGQgaGVsbG8gd29ybGQ='; +const WITHHELD = '[inline content withheld: inline data that also claims a uri or a bulk disposition]'; + +function parsed(text: string): unknown { + return JSON.parse(text); +} + +describe('data the serializer doesn\'t read as content blocks', () => { + it('shows a reference beside a block of another type as its stub, and keeps the rest as JSON', () => { + const text = toolResultDataToHistoryString([UNKNOWN, RESOURCE]); + assert.ok(!text.includes(SIGNATURE), text); + const [unknown, stub] = parsed(text) as [unknown, string]; + assert.deepEqual(unknown, UNKNOWN); + assert.match(stub, STUB); + }); + + it('shows a reference at any depth as its stub', () => { + const wrapped = toolResultDataToHistoryString({ content: [{ type: 'text', text: 'zz-text' }, RESOURCE], page: 2 }); + assert.ok(!wrapped.includes(SIGNATURE), wrapped); + const { content, page } = parsed(wrapped) as { content: [unknown, string]; page: number }; + assert.equal(page, 2); + assert.deepEqual(content[0], { type: 'text', text: 'zz-text' }); + assert.match(content[1], STUB); + const inside = toolResultDataToHistoryString([{ type: 'zz-unknown', attachment: RESOURCE }]); + assert.ok(!inside.includes(SIGNATURE), inside); + assert.match((parsed(inside) as [{ attachment: string }])[0].attachment, STUB); + }); + + it('shows a reference that is the whole data as its stub, as text, read as a content array\'s reference is', () => { + const text = toolResultDataToHistoryString(RESOURCE); + assert.match(text, STUB); + assert.ok(!text.startsWith('"'), text); + assert.ok(!text.includes(SIGNATURE), text); + // An image reference that is the whole data starts its line, so it takes + // a save_recent_image slot, as one in a content array does. + const image = { type: 'image', uri: URI, mimeType: 'image/png', name: 'zz.png' }; + const whole = toolResultDataToHistoryString(image); + assert.match(whole, /^\[ref_[^\]]+\] zz\.png — image\/png/); + assert.equal(parseImagePlaceholders(whole).length, 1); + assert.equal(parseImagePlaceholders(toolResultDataToHistoryString([image])).length, 1); + // Inline data withheld as the whole data is the notice, as text too. + assert.equal(toolResultDataToHistoryString({ type: 'image', data: BASE64, mimeType: 'image/png', uri: URI }), WITHHELD); + }); + + it('shows media given by uri as a reference too, an image or audio beside a block of another type', () => { + for (const type of ['image', 'audio']) { + const text = toolResultDataToHistoryString([UNKNOWN, { type, uri: URI, mimeType: `${type}/x-zz`, name: `zz.${type}` }]); + assert.ok(!text.includes(SIGNATURE), text); + assert.match((parsed(text) as [unknown, string])[1], /^\[ref_[^\]]+\] zz\./); + } + }); + + it('withholds what the reference lanes withhold, saying why', () => { + const notAString = parsed(toolResultDataToHistoryString([UNKNOWN, { type: 'resource', uri: { sig: SIGNATURE } }])) as [unknown, string]; + assert.equal(notAString[1], '[unrecognized content block: reference without string uri]'); + const tooLong = parsed(toolResultDataToHistoryString([UNKNOWN, { type: 'resource', uri: `${URI}${'x'.repeat(5000)}` }])) as [unknown, string]; + assert.equal(tooLong[1], '[unrecognized content block: uri exceeds schema maximum]'); + const objectUri = parsed(toolResultDataToHistoryString([UNKNOWN, { type: 'image', uri: { href: URI } }])) as [unknown, string]; + assert.equal(objectUri[1], "[unrecognized content block: image block whose uri isn't a string]"); + const smuggled = toolResultDataToHistoryString([UNKNOWN, { type: 'image', data: BASE64, mimeType: 'image/png', uri: URI }]); + assert.ok(!smuggled.includes(SIGNATURE) && !smuggled.includes(BASE64), smuggled); + assert.deepEqual(parsed(smuggled), [UNKNOWN, WITHHELD]); + const disposition = toolResultDataToHistoryString([UNKNOWN, { type: 'image', data: BASE64, mimeType: 'image/png', disposition: 'ref' }]); + assert.ok(!disposition.includes(BASE64), disposition); + assert.deepEqual(parsed(disposition), [UNKNOWN, WITHHELD]); + }); + + it('withholds inline data beside a uri that is not a string where JSON would write it, and leaves the lanes as they were', () => { + const block = { type: 'image', data: BASE64, mimeType: 'image/png', uri: { href: URI } }; + const nested = toolResultDataToHistoryString({ shot: block, page: 2 }); + assert.ok(!nested.includes(SIGNATURE) && !nested.includes(BASE64), nested); + assert.deepEqual(parsed(nested), { shot: WITHHELD, page: 2 }); + // A content array shows no uri, so the image there is a placeholder, as + // it is with no uri at all. + const array = toolResultDataToHistoryString([{ type: 'text', text: 'zz-text' }, block]); + assert.equal(array, toolResultDataToHistoryString([{ type: 'text', text: 'zz-text' }, { type: 'image', data: BASE64, mimeType: 'image/png' }])); + assert.ok(!array.includes(SIGNATURE), array); + assert.deepEqual(classifyBlock(block), { kind: 'inline', contradiction: false }); + // A null or empty uri is none: inline data beside it isn't a claim. + const nullUri = { type: 'audio', data: BASE64, mimeType: 'audio/wav', uri: null }; + assert.equal(toolResultDataToHistoryString({ clip: nullUri }), JSON.stringify({ clip: nullUri })); + const emptyUri = { type: 'image', data: BASE64, mimeType: 'image/png', uri: '' }; + assert.equal(toolResultDataToHistoryString({ shot: emptyUri }), JSON.stringify({ shot: emptyUri })); + }); + + it('writes inline media that claims no uri or disposition as JSON writes it, as before', () => { + // RFC-005 governs references; plain inline bytes are left as they were. + const data = { + shot: { type: 'image', data: BASE64, mimeType: 'image/png' }, + clip: [UNKNOWN, { type: 'audio', data: BASE64, mimeType: 'audio/wav' }], + link: { type: 'image', data: 'https://zz-images.example/a.png', caption: 'zz-caption' }, + }; + assert.equal(toolResultDataToHistoryString(data), JSON.stringify(data)); + assert.equal(toolResultDataToHistoryString(data.shot), JSON.stringify(data.shot)); + }); + + it('writes data with nothing RFC-005 governs exactly as JSON does', () => { + const plain = [{ id: 1, url: URI, type: 'zz-record' }, { type: 'resource_link', uri: URI, name: 'zz' }, 'zz', 7, null]; + assert.equal(toolResultDataToHistoryString(plain), JSON.stringify(plain)); + assert.equal(toolResultDataToHistoryString({ a: 1 }), '{"a":1}'); + }); + + it('writes a record that only shares a type name, with no uri and no string data, as JSON does', () => { + // A uri that is null, absent or empty is none, and data that isn't a + // string isn't inline data: nothing here is a reference or inline bytes. + const records = [ + UNKNOWN, + { type: 'image', uri: null, id: 1, name: 'zz-a' }, + { type: 'audio', data: null, uri: undefined, name: 'zz-b' }, + { type: 'resource', name: 'zz-cpu', value: 0.5 }, + { type: 'resource', uri: undefined, name: 'zz-c' }, + { type: 'resource', resource: { name: 'zz-d' } }, + { type: 'resource', uri: '', text: 'zz-e' }, + { type: 'image', data: [1, 2, 3], mimeType: 'image/png', url: 'https://zz-images.example/a.png' }, + { type: 'audio', data: { type: 'Buffer', data: [9, 8, 7] }, name: 'zz-f', samples: 3 }, + ]; + assert.equal(toolResultDataToHistoryString(records), JSON.stringify(records)); + assert.equal(toolResultDataToHistoryString({ record: records[1] }), JSON.stringify({ record: records[1] })); + }); + + it("governs MCP's embedded resource, whose uri sits under resource, at any depth", () => { + const embedded = { type: 'resource', resource: { uri: URI, mimeType: 'application/pdf', blob: BASE64 } }; + const line = '[unrecognized content block: reference without string uri]'; + const nested = toolResultDataToHistoryString({ result: { content: [embedded] } }); + assert.ok(!nested.includes(SIGNATURE) && !nested.includes(BASE64), nested); + assert.deepEqual(parsed(nested), { result: { content: [line] } }); + const beside = toolResultDataToHistoryString([UNKNOWN, embedded]); + assert.deepEqual(parsed(beside), [UNKNOWN, line]); + assert.equal(toolResultDataToHistoryString(embedded), line); + // The content-array path shows it the same way. + assert.equal(toolResultDataToHistoryString([{ type: 'text', text: 'zz-text' }, embedded]), `zz-text\n${line}`); + }); + + it('governs media given a uri that is not a string, failing closed and saying which', () => { + const text = toolResultDataToHistoryString({ + numeric: { type: 'image', uri: 42, name: 'zz-n' }, + both: { type: 'audio', data: [4, 5], uri: 43 }, + }); + assert.ok(!text.includes('4,5'), text); + assert.deepEqual(parsed(text), { + numeric: "[unrecognized content block: image block whose uri isn't a string]", + both: "[unrecognized content block: audio block whose data and uri aren't strings]", + }); + // In the lanes, a media block with neither keeps the line it had. + assert.deepEqual(classifyBlock({ type: 'image' }), { kind: 'invalid', reason: 'image block with neither data nor uri' }); + }); + + it("leaves a nested reference's stub out of save_recent_image's inventory, as main left the reference", () => { + // A nested image-typed reference is shown as its stub inside the JSON, + // and nested inline bytes stay JSON: neither is a slot, so no index moves. + const text = toolResultDataToHistoryString([ + UNKNOWN, + { type: 'resource', uri: URI, mimeType: 'image/png', name: 'zz.png' }, + { type: 'image', data: BASE64, mimeType: 'image/png' }, + ]); + assert.ok(!text.includes(SIGNATURE), text); + assert.match((parsed(text) as [unknown, string])[1], /^\[ref_[^\]]+\] zz\.png — image\/png/); + assert.deepEqual(parseImagePlaceholders(text), []); + }); + + it('leaves an array of blocks it recognizes as it was', () => { + assert.match(toolResultDataToHistoryString([{ type: 'text', text: 'zz-text' }, RESOURCE]), /^zz-text\n\[ref_/); + }); + + it('keeps a withheld result\'s kept fields free of the uri', () => { + const kept = keptWhenWithheld({ success: true, data: { files: ['zz.txt'], receipt: [UNKNOWN, RESOURCE] }, keepWhenWithheld: ['files', 'receipt'] }, undefined); + assert.ok(kept !== undefined && !kept.includes(SIGNATURE), kept); + assert.match((parsed(kept!) as { receipt: [unknown, string] }).receipt[1], STUB); + // Data that is itself a reference keeps its stub, whichever fields are marked. + const whole = keptWhenWithheld({ success: true, data: RESOURCE, keepWhenWithheld: ['uri', 'name'] }, undefined); + assert.ok(whole !== undefined && !whole.includes(SIGNATURE), whole); + assert.match(whole!, STUB); + }); +}); + +/** A module whose one tool answers with the content array it was given. */ +class ContentModule implements Module { + readonly name = 'content'; + constructor(private readonly data: unknown[]) {} + async start(_ctx: ModuleContext): Promise {} + async stop(): Promise {} + getTools(): ToolDefinition[] { + return [{ name: 'get', description: 'Answer with content.', inputSchema: { type: 'object', properties: {} } }]; + } + async handleToolCall(_call: ToolCall): Promise { + return { success: true, data: this.data }; + } + async onProcess(): Promise> { + return {}; + } +} + +/** The content of the tool result the model received for one call, and of what the agent's history stored. */ +async function answerModelCall(data: unknown[]): Promise<{ wire: string; stored: string }> { + const dir = mkdtempSync(join(tmpdir(), 'tool-result-reference-uri-')); + const membrane = new MockMembrane(); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', allowedTools: 'all' }], + modules: [new ContentModule(data)], + syncIntervalMs: 0, + maintenanceIntervalMs: 0, + }); + try { + membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 'zz-call', name: 'content--get', input: {} }], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'zz-done' }])); + framework.start(); + (framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ + agentName: 'agent', reason: 'external-message', source: 'test', timestamp: Date.now(), + }); + const stream = () => membrane.lastStream as unknown as { receivedToolResults: unknown[][] } | undefined; + const deadline = Date.now() + 5_000; + while ((stream()?.receivedToolResults.length ?? 0) < 1 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 25)); + const received = stream()?.receivedToolResults[0]; + assert.ok(received, 'the model was answered'); + const stored = framework.getAgent('agent')!.getContextManager().getAllMessages() + .flatMap((message) => message.content) + .filter((block) => (block as { type?: string }).type === 'tool_result'); + assert.equal(stored.length, 1, 'the history holds the tool result'); + // Each copy's content alone: the wire's result and the stored block wrap it differently. + const contentOf = (results: unknown[]) => JSON.stringify(results.map((r) => (r as { content?: unknown }).content)); + return { wire: contentOf(received), stored: contentOf(stored) }; + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } +} + +describe('the wire without the stored copy', () => { + it('makes its text as the stored copy is made', () => { + const fw = Object.create(AgentFramework.prototype) as { + toMembraneToolResult(callId: string, result: ToolResult, maxChars?: number): { content: unknown; isError: boolean }; + }; + for (const result of [ + { success: true, data: [UNKNOWN, RESOURCE] }, + { success: true, data: { content: [RESOURCE] } }, + { success: false, error: 'zz-failed', isError: false }, + { success: true, data: [{ type: 'text', text: 'zz-text' }] }, + ] as ToolResult[]) { + const { content, isError } = fw.toMembraneToolResult('zz-call', result); + assert.equal(isError, false); + if (result.error === 'zz-failed') assert.equal(content, 'zz-failed'); + const expected = nonErrorResultText(result); + // Each reference gets a fresh id, so compare with the ids taken out. + const ids = (s: string) => s.replace(/ref_[A-Za-z0-9_-]+/g, 'ref_ID'); + assert.equal(ids(content as string), ids(expected)); + assert.ok(!(content as string).includes(SIGNATURE), String(content)); + } + }); + + it('cuts its text at the cap as the stored copy is cut', () => { + const fw = Object.create(AgentFramework.prototype) as { + toMembraneToolResult(callId: string, result: ToolResult, maxChars?: number): { content: unknown; isError: boolean }; + }; + const result: ToolResult = { success: true, data: { note: 'zz-'.repeat(200), shot: RESOURCE, more: 'zz-tail' } }; + const { content } = fw.toMembraneToolResult('zz-call', result, 300); + const ids = (s: string) => s.replace(/ref_[A-Za-z0-9_-]+/g, 'ref_ID'); + assert.equal(ids(content as string), ids(nonErrorResultText(result, 300))); + assert.match(content as string, /\n\n\[truncated — original was \d+ chars\]$/); + assert.ok(!(content as string).includes(SIGNATURE), String(content)); + }); +}); + +describe('a model\'s call answered with such data', () => { + it('sends the stored copy, which holds the stub and never the uri', async () => { + const { wire, stored } = await answerModelCall([UNKNOWN, RESOURCE]); + assert.ok(!stored.includes(SIGNATURE), stored); + assert.ok(stored.includes('zz-kept'), stored); + assert.match(stored, /ref_/); + assert.equal(wire, stored); + }); + + it('with an image too, sends the stored copy, which holds the stub and the image as JSON writes it', async () => { + const { wire, stored } = await answerModelCall([{ type: 'image', data: BASE64, mimeType: 'image/png' }, UNKNOWN, RESOURCE]); + assert.ok(!stored.includes(SIGNATURE), stored); + assert.ok(stored.includes(BASE64), stored); + assert.match(stored, /ref_/); + assert.equal(wire, stored); + }); +}); + +describe('a script given such data', () => { + type ScriptFramework = { + dispatchScriptToolCall: (...args: unknown[]) => Promise; + handleScriptToolCall(agentName: string, toolName: string, args: Record): Promise; + }; + + it('gets the text history stores, with each reference as its stub and the rest as JSON writes it', async () => { + const fw = Object.create(AgentFramework.prototype) as ScriptFramework; + const shot = { type: 'image', data: BASE64, mimeType: 'image/png' }; + fw.dispatchScriptToolCall = async () => ({ success: true, data: { file: RESOURCE, shot, page: 2 } }); + const text = await fw.handleScriptToolCall('zz-agent', 'zz_tool', {}); + assert.ok(!text.includes(SIGNATURE), text); + const result = parsed(text) as { file: string; shot: unknown; page: number }; + assert.match(result.file, STUB); + assert.deepEqual(result.shot, shot); + assert.equal(result.page, 2); + }); + + it("gets an in-process module's reference in a content array as its stub, once its fetch is refused", async () => { + const fw = Object.create(AgentFramework.prototype) as ScriptFramework; + fw.dispatchScriptToolCall = async () => ({ success: true, data: [{ type: 'text', text: 'zz-text' }, RESOURCE] }); + const logged: string[] = []; + const error = console.error; + console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; + let text: string; + try { + text = await fw.handleScriptToolCall('zz-agent', 'zz_tool', {}); + } finally { + console.error = error; + } + assert.ok(!text.includes(SIGNATURE), text); + assert.match(text, /^zz-text\n\[ref_[^\]]+\] zz\.pdf — application\/pdf — from tool result/); + assert.ok(logged.some((line) => /\[rfc005\] script-demand fetch of ref_\S+ failed: reference unbound: no server is recorded to fetch it from/.test(line)), logged.join('\n')); + }); +});