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
8 changes: 8 additions & 0 deletions changelog.d/403-truncation-image-slots.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
- A tool result stored over its inline cap no longer keeps every line shaped like a reference stub from past the cut (#403). Every `[ref_…] …` line past the cut was added back after the truncation notice, whether or not it named an image and with no limit, so a text result listing channel attachments was stored far over the cap: a 25,980-character result at a cap of 2,000 was stored at 25,168 characters. The lines past the cut were kept so that `save_recent_image`'s index counts every image the resident saw, and now the stored copy keeps what the live copy showed.
- A result with no inline image keeps nothing past its cut. Its live copy is the stored text, so the resident never saw what lies past the cut either. A truncated text result no longer lists the image stubs past the cut: the spill file holds them, and without a writable workspace they aren't kept, like the rest of the text past the cut. Such a reference's record is still in the registry, so running its tool again brings its stub back.
- A result carrying an inline image went to the model as blocks: every image and every reference's stub, whatever the text's length, and each text block cut at the cap on its own. Past its cut it keeps the image slots those blocks showed, placeholders and image-typed stubs, the newest 64 of them, the most images the tool-image ledger holds bytes for. A slot in the part of a text block that the live copy cut away isn't kept. When more than 64 fell past the cut, a note says how many were left out, and `save_recent_image` doesn't count those.
- History's own truncation, which cuts a script's tool results and those on the direct Agent API, keeps nothing past its cut either: each is read as its text.
- The stored copy's cut, history's own truncation, and the live copy's cut of a text block beside an image never leave part of a slot before their notice. A cut that would split a placeholder moves back to the placeholder's start, and one that would split a stub-shaped line moves back to the line's start when at most 677 characters of the line lie before the cut, since that part could still read as an image; a longer part can't, and stays. The live copy and the stored copy cut alike, so the stored copy counts exactly the slots the live copy showed.
- `save_recent_image` counts a reference stub as an image by the type its stored text shows, as it did after a restart or an eviction, and no longer by the registry's record while one exists. A stub counts the same whether or not the process still holds its record, and a reference issued again with testimony of another type no longer changes how an earlier stub counts.
- A save in the same batch as a long result counts it as its stored copy will be cut, and an error result, or one with no data, which history stores as its error text, counts that text's slots before it is stored as it does after.
- A slot is no longer than its writer writes it: a stub-shaped line longer than the longest stub the framework writes (677 characters), or a placeholder whose type or subtype is over 127 characters or whose size's number, ledger nonce or sequence is over 16 characters, is quoted text and counts nowhere. A tool image whose type or subtype is over 127 characters, the most RFC 6838 allows, is labelled `application/octet-stream`. A placeholder stored before with such a type no longer counts; only a server that sent one could have produced it.
129 changes: 80 additions & 49 deletions src/framework.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ToolPresentation, presentationTools, isPresentationTool, renderCatalogue, type PresentationSnapshot } from "./tool-presentation.js";
import { dirname, join } from 'node:path';
import { INLINE_WITHHELD_TEXT, REFERENCE_LIMITS, classifyBlock, isInlineContradiction, isReferenceExpired, referenceRegistry, referenceStubOrNull, referenceTypeLabel, sanitizeLabel, unfetchableReason } from './mcpl/references.js';
import { INLINE_WITHHELD_TEXT, REFERENCE_LIMITS, classifyBlock, isInlineContradiction, isReferenceExpired, referenceRegistry, referenceStubOrNull, sanitizeLabel, unfetchableReason } from './mcpl/references.js';
import { ReferenceFetcher, DEFAULT_FETCH_MAX_BYTES, EAGER_FETCH_TIMEOUT_MS } from './mcpl/reference-fetcher.js';
import {
appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync,
Expand Down Expand Up @@ -110,15 +110,19 @@ import {
toolResultDataToHistoryString,
truncateForHistory,
DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS,
type HistoryBlockSpan,
} from './tool-result-history.js';
import {
TOOL_IMAGE_REF_RE,
ToolImageLedger,
formatPreservedImageSlots,
cutStoredToolResult,
formatKeptImageSlots,
parseImagePlaceholders,
sha256Hex,
splitPreservingImageSlots,
storedImageSlots,
toolResultCutAt,
type ParsedImagePlaceholder,
type ShownRanges,
} from './tool-image-ledger.js';
import { randomUUID, createHash } from 'node:crypto';
import { PyRunner, buildInjectedTools, formatLimit } from './code-execution/py-runner.js';
Expand Down Expand Up @@ -15909,23 +15913,9 @@ export class AgentFramework {
}> {
const spilled = new Map<string, { text: string; filePath: string | null }>();
const dateLabel = new Date().toISOString().slice(0, 10);
const ledger = this.toolImageLedgerFor(agentName);
for (const tc of toolResults) {
// Retain every image the result carries BEFORE the placeholder is
// written (issue #104): the placeholder embeds the ref, so the image
// stays reachable by provenance after the bytes leave the wire.
const raw = tc.result.isError
? tc.result.error ?? 'Unknown error'
: nonErrorResultText(tc.result, undefined, {
imageRef: (blockIndex, image) => ledger.retain({
toolCallId: tc.id,
toolName: tc.name,
blockIndex,
data: image.data,
mediaType: image.mimeType,
}).ref,
});
spilled.set(tc.id, await this.spillOrTruncate(raw, maxChars, `${dateLabel}-${tc.id}`));
const { text, shown } = this.storedToolResultText(agentName, tc, maxChars);
spilled.set(tc.id, await this.spillOrTruncate(text, maxChars, `${dateLabel}-${tc.id}`, shown));
}
const blocks: ContentBlock[] = toolResults.map(tc => ({
type: 'tool_result' as const,
Expand All @@ -15939,6 +15929,47 @@ export class AgentFramework {
return { blocks, spilled };
}

/**
* A tool result's text as its stored copy is written before any cut, and
* what its live copy showed beside that text (see cutStoredToolResult). An
* error result is its error text. Any other is serialized by
* nonErrorResultText, which retains each inline image BEFORE its
* placeholder is written (issue #104): the placeholder embeds the ref, so
* the image stays reachable by provenance after the bytes leave the wire.
*
* The live copy is the stored text itself unless the result is a content
* array carrying an inline image, which tryNativeToolResultContent sends
* as native blocks. It takes the same arrays the serializer takes block by
* block, and shows each image and each reference block's stub whole and
* each text block up to where it cuts that block at the cap (`maxChars`,
* toolResultCutAt), so those are the ranges of the text it showed.
*/
private storedToolResultText(
agentName: string,
tc: CompletedToolCall,
maxChars: number | undefined,
): { text: string; shown: ShownRanges | null } {
if (tc.result.isError) return { text: tc.result.error ?? 'Unknown error', shown: null };
const ledger = this.toolImageLedgerFor(agentName);
let blocks = null as HistoryBlockSpan[] | null;
const text = nonErrorResultText(tc.result, undefined, {
imageRef: (blockIndex, image) => ledger.retain({
toolCallId: tc.id,
toolName: tc.name,
blockIndex,
data: image.data,
mediaType: image.mimeType,
}).ref,
onBlocks: (spans) => { blocks = spans; },
});
if (!blocks?.some((block) => block.kind === 'image')) return { text, shown: null };
return {
text,
shown: blocks.map(({ kind, start, end }): [number, number] =>
[start, kind === 'text' && maxChars ? start + toolResultCutAt(text.slice(start, end), maxChars) : end]),
};
}

/** Where a tool result labelled `label` is written: `<first writable
* mount>/tool-results/<label>.txt`, or null when there is no writable
* workspace. */
Expand Down Expand Up @@ -16070,15 +16101,21 @@ export class AgentFramework {
content: string,
cap: number | undefined,
label: string,
/** What the live copy showed beside this text (storedToolResultText);
* null when the live copy was this text. */
shown: ShownRanges | null = null,
): Promise<{ text: string; filePath: string | null }> {
if (!cap || content.length <= cap) return { text: content, filePath: null };
// The stored text is the only place save_recent_image discovers tool
// images, and the live wire delivers every image regardless of text
// length — so an image slot dropped by the cut is an image the resident
// saw that the index would silently slide past (issue #104). Keep the
// slots at or past the cut, re-appended after the notice.
const { head, tail } = splitPreservingImageSlots(content, cap);
const kept = formatPreservedImageSlots(tail);
// images. When the live wire delivered them beside the text, whatever
// its length, an image slot dropped by the cut is an image the resident
// saw that the index would silently slide past (issue #104), so the
// newest slots past the cut that the live copy showed are re-appended
// after the notice. Otherwise the live copy is this text, and nothing
// past the cut is kept (#403).
const cut = cutStoredToolResult(content, cap, shown);
const { head } = cut;
const kept = formatKeptImageSlots(cut);

const written = await this.writeToolResultFile(label, content);
if (written && written.error === undefined) {
Expand Down Expand Up @@ -16312,7 +16349,9 @@ export class AgentFramework {
* convert to Membrane's native ToolResultContentBlock[]. Returns null when
* the array is text-only (let JSON path handle it; saves a code path).
* `maxChars`, when provided, caps each accompanying text block so an image
* inlined alongside an enormous text payload can't blow the context.
* inlined alongside an enormous text payload can't blow the context. A
* block is cut as the stored copy is (toolResultCutAt), never inside a
* slot, so the stored copy can keep exactly the slots this showed (#403).
*/
private tryNativeToolResultContent(
data: unknown,
Expand All @@ -16337,7 +16376,7 @@ export class AgentFramework {
// rather than sending the whole array to the JSON fallback.
let text = typeof b.text === 'string' ? b.text : NON_STRING_TEXT_PLACEHOLDER;
if (maxChars && text.length > maxChars) {
text = safeSlice(text, 0, maxChars)
text = text.slice(0, toolResultCutAt(text, maxChars))
+ '\n\n[truncated — original was ' + text.length + ' chars'
+ (spillPath ? `; full serialized result: workspace file ${spillPath}` : '')
+ ']';
Expand Down Expand Up @@ -20859,36 +20898,28 @@ export class AgentFramework {
consider({ kind: 'tool', ref: ph.ref, toolCallId, toolName, mediaType: ph.mediaType, messagesBack });
continue;
}
// Reference stub: the registry's claimed type (when this process
// still has the record), else the first image/ token in the
// stub's text, which can come from its name, says whether it was
// an image. Either is read as the stub shows a type, sanitized
// and bounded. Non-image references are not slots.
const claimed = referenceRegistry.get(ph.refId)?.testimony.mimeType ?? ph.mediaType ?? undefined;
const mediaType = referenceTypeLabel(claimed).toLowerCase();
if (!mediaType.startsWith('image/')) continue;
consider({ kind: 'reference', refId: ph.refId, mediaType, toolCallId, toolName, messagesBack });
// A reference stub whose text shows an image type: a slot that
// never saves (parseImagePlaceholders returns no other stub).
consider({ kind: 'reference', refId: ph.refId, mediaType: ph.mediaType, toolCallId, toolName, messagesBack });
}
};

// Same-batch results (e.g. a snapshot dispatched alongside the save)
// are not in the store yet but ARE the newest images. Classify them
// with the very serializer the commit path will run — contradiction
// blocks withheld, uri-form blocks as reference stubs, unknown shapes
// as no slot at all — so the inventory means the same thing before
// and after commit. Retention is idempotent per block, so the
// placeholder written at commit time carries the same ref.
// are not in the store yet but ARE the newest images. Count them as
// their stored copies will count: the very text the commit path will
// serialize (contradiction blocks withheld, uri-form blocks as
// reference stubs, unknown shapes as no slot at all), cut at this
// agent's inline cap as spillOrTruncate will cut it, so the inventory
// means the same thing before and after commit. Retention is
// idempotent per block, so the placeholder written at commit time
// carries the same ref.
const state = agent.state;
if (state.status === 'waiting_for_tools') {
const cap = this.resolveToolResultInlineCap(agent).cap;
for (let i = state.completed.length - 1; i >= 0 && collecting(); i--) {
const tc = state.completed[i]!;
if (tc.result.isError) continue;
const text = toolResultDataToHistoryString(tc.result.data, undefined, {
imageRef: (blockIndex, image) => ledger.retain({
toolCallId: tc.id, toolName: tc.name, blockIndex, data: image.data, mediaType: image.mimeType,
}).ref,
});
considerToolResultImages(tc.id, tc.name, parseImagePlaceholders(text), 0);
const { text, shown } = this.storedToolResultText(agentName, tc, cap);
considerToolResultImages(tc.id, tc.name, storedImageSlots(text, cap, shown), 0);
}
}

Expand Down
24 changes: 22 additions & 2 deletions src/mcpl/references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export const REFERENCE_LIMITS = { uri: 4096, mimeType: 255, name: 255, expiresAt
export const DIGEST_PATTERN = /^sha256:[A-Za-z0-9_-]{43}$/;
const MAX_SAFE = 9007199254740991; // 2^53 - 1
const STUB_FIELD_CHARS = 120;
/** A saved copy's path, as its stub shows it. */
const SAVED_PATH_CHARS = 256;
/** A reference id's random suffix (see ReferenceRegistry.register). */
const REF_ID_SUFFIX_CHARS = 4;

export type ReferenceDisposition = 'never' | 'ref';

Expand Down Expand Up @@ -261,7 +265,7 @@ export class ReferenceRegistry {
return existing;
}
}
const refId = `ref_${(++this.counter).toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
const refId = `ref_${(++this.counter).toString(36)}_${Math.random().toString(36).slice(2, 2 + REF_ID_SUFFIX_CHARS)}`;
const record: ReferenceRecord = { refId, serverId, testimony, receivedAt: Date.now() };
this.records.set(refId, record);
this.byKey.set(testimony.uri, refId);
Expand Down Expand Up @@ -299,6 +303,22 @@ export function referenceTypeLabel(mimeType: string | undefined): string {
return mimeType ? sanitizeLabel(mimeType, STUB_FIELD_CHARS) : '';
}

/**
* The longest line buildReferenceStub writes, in UTF-16 code units, from its
* own bounds: the longest id the registry mints, then the name, the type with
* the largest size it shows, and the origin, each at its bound, and the
* longest ending, a saved copy's path, with their separators. The other
* endings, the fetch hint and the reasons a fetch would be refused, are
* shorter. A line shaped like a stub but longer than this is quoted text the
* writer can't have written, and no image slot (tool-image-ledger.ts).
*/
export const REFERENCE_STUB_MAX_CHARS =
`[ref_${MAX_SAFE.toString(36)}_] `.length + REF_ID_SUFFIX_CHARS
+ STUB_FIELD_CHARS
+ ' — '.length + STUB_FIELD_CHARS + ', '.length + `${formatSizeBytes(MAX_SAFE)} claimed`.length
+ ' — '.length + STUB_FIELD_CHARS
+ ' — '.length + 'saved: '.length + SAVED_PATH_CHARS;

/**
* Build the model-visible stub for a reference. Output length is bounded by
* host constants and independent of every server-supplied field length.
Expand All @@ -322,7 +342,7 @@ export function buildReferenceStub(t: ReferenceTestimony, provenance?: string, s
if (record.fetchedPath) {
// Already materialized by the host-mediated fetcher: hand the model the
// workspace path (host-generated, §7.3) so existing tools just work.
parts.push(`saved: ${sanitizeLabel(record.fetchedPath, 256)}`);
parts.push(`saved: ${sanitizeLabel(record.fetchedPath, SAVED_PATH_CHARS)}`);
} else {
// A reference whose record shows the fetcher would refuse it says why,
// rather than inviting a fetch_reference call.
Expand Down
Loading
Loading