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
24 changes: 20 additions & 4 deletions src/compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { createRenderRefsNode } from "./render-refs.js";
import type { RenderStrategy } from "./render-refs.js";
import {
collectLatestProtected,
hasMediaPayload,
isMessageLatestProtected,
isMessageProtected,
} from "./protected.js";
Expand Down Expand Up @@ -598,11 +599,15 @@ const assignRefsNode: PipelineNode = {
const latest = hasProtection
? collectLatestProtected(io.messages, ctx.config)
: undefined;
const protectedFn = hasProtection
? (m: CoreMessage) =>
isMessageProtected(m, ctx.config) ||
// Media payloads (image/file sidecars) ride outside msg.text; folding one
// destroys it permanently (#1188), so media messages always get a BLOCKED
// ref — never advertised, never folded — regardless of tool-protection config.
const protectedFn = (m: CoreMessage) =>
hasMediaPayload(m) ||
(hasProtection
? isMessageProtected(m, ctx.config) ||
(latest ? isMessageLatestProtected(m, latest) : false)
: undefined;
: false);
const refResult = assignRefs(io.messages, {
existing: io.state.messageRefs,
nextIndex: highestUsedIndex(io.state.messageRefs) + 1,
Expand Down Expand Up @@ -994,6 +999,16 @@ function applySingleRange(input: SingleRangeInput): SingleRangeOutcome {
}
}

const mediaExcluded = directMessageIds.filter((id) => {
const msg = input.messages.find((m) => m.id === id);
return !!msg && hasMediaPayload(msg);
});
if (mediaExcluded.length > 0) {
warnings.push(
`Excluded ${mediaExcluded.length} message(s) carrying image/attachment payload(s) from compression range — their bytes are unrecoverable once folded (billion-context#1188); enable stripImages to release old ones.`,
);
}

// SOFT PROTECTION: the recent-N / last-user-message zone is advisory-only at
// compress time. Instead of failing the whole range when it brushes protected
// messages, exclude those messages and proceed with the rest (so the model
Expand Down Expand Up @@ -1258,6 +1273,7 @@ function filterProtectedToolMessages(
const msg = messages.find((m) => m.id === id);
if (!msg) continue;
if (
hasMediaPayload(msg) ||
isMessageProtected(msg, config) ||
isMessageLatestProtected(msg, latest)
) {
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ export {
} from "./search.js";
export {
collectLatestProtected,
hasMediaPayload,
isMessageLatestProtected,
isMessageProtected,
matchToolPattern,
Expand Down
46 changes: 46 additions & 0 deletions src/protected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,49 @@ export function isMessageLatestProtected(
}
return false;
}

/** Wire-sidecar fields carrying media/attachment payloads whose bytes live
* OUTSIDE msg.text (images, image blocks inside a structured Anthropic
* tool_result, or opaque file refs such as DeepSeek Files API
* `{type:"file"}`). Folding such a message into a summary destroys the
* payload permanently: hosts rebuild requests from their own history and the
* kernel holds no server-side archive (billion-context#1188). Typed
* structurally rather than as BiliMessage because core modules must not
* import from src/wire/. */
interface MediaSidecar {
imageBase64?: string;
rawOpenaiContent?: unknown;
rawOpenaiContentParts?: unknown[];
rawAnthropicBlock?: unknown;
rawResponsesItem?: unknown;
}

export function hasMediaPayload(msg: CoreMessage): boolean {
const m = msg as CoreMessage & MediaSidecar;
if (typeof m.imageBase64 === "string" && m.imageBase64.length > 0) return true;
if (m.rawOpenaiContent != null) return true;
if (Array.isArray(m.rawOpenaiContentParts) && m.rawOpenaiContentParts.length > 0)
return true;
const ab = m.rawAnthropicBlock;
if (isObjWith(ab, "type", "image")) return true;
// A structured tool_result sidecar is media only when its content array
// holds non-text blocks (images); text-only structured results carry no
// bytes outside msg.text.
if (isObjWith(ab, "type", "tool_result")) {
const content = (ab as { content?: unknown }).content;
if (Array.isArray(content)) return content.some((p) => !isObjWith(p, "type", "text"));
}
const item = m.rawResponsesItem;
if (isObjWith(item, "type", "input_image")) return true;
if (item && typeof item === "object") {
const content = (item as { content?: unknown }).content;
if (Array.isArray(content)) {
return content.some((p) => isObjWith(p, "type", "input_image"));
}
}
return false;
}

function isObjWith(v: unknown, key: string, value: unknown): boolean {
return typeof v === "object" && v !== null && (v as Record<string, unknown>)[key] === value;
}
9 changes: 9 additions & 0 deletions src/recommend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { CompressionState } from "./types.js";
import {
collectLatestProtected,
collectProtectedToolCallIds,
hasMediaPayload,
isMessageLatestProtected,
isMessageProtectedWithPairing,
isNeverPreserveRecent,
Expand Down Expand Up @@ -198,6 +199,14 @@ export function buildCompressibleRanges(
continue;
}

// Media payloads are unrecoverable once folded (#1188): never advertised
// as compressible, and treated as a gap so no range spans them.
if (hasMediaPayload(msg)) {
skipSinceCompressible = true;
skipSinceProtected = true;
continue;
}

if (
isMessageProtectedWithPairing(msg, config, protectedCallIds) ||
isMessageLatestProtected(msg, latest)
Expand Down
12 changes: 10 additions & 2 deletions src/wire/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export type AnthropicToolUse = {
export type AnthropicToolResult = {
type: "tool_result";
tool_use_id: string;
content: string | AnthropicTextBlock[];
content: string | (AnthropicTextBlock | AnthropicImage)[];
is_error?: boolean;
cache_control?: unknown;
};
Expand Down Expand Up @@ -98,7 +98,10 @@ export function anthropicToCore(body: AnthropicRequestBody): Flat {
break;
}
case "tool_result": {
const text = typeof b.content === "string" ? b.content : b.content.map((c) => c.text).join("\n");
// Non-text blocks (image) carry bytes outside msg.text; ride
// the sidecar so coreToAnthropic re-emits them verbatim (#366).
const hasNonText = Array.isArray(b.content) && b.content.some((c) => c.type !== "text");
const text = typeof b.content === "string" ? b.content : b.content.map((c) => (c.type === "text" ? c.text : "")).join("\n");
const base = deriveMessageId("tool", "tool-result", text, { toolCallId: b.tool_use_id });
const id = clusters.next(base);
const name = toolNames.get(b.tool_use_id);
Expand All @@ -110,6 +113,7 @@ export function anthropicToCore(body: AnthropicRequestBody): Flat {
text,
...(name ? { toolName: name } : {}),
...(b.is_error === true ? { toolIsError: true } : {}),
...(hasNonText ? { rawAnthropicBlock: b } : {}),
});
if (b.cache_control) cacheControls.set(id, b.cache_control);
break;
Expand Down Expand Up @@ -181,6 +185,10 @@ export function coreToAnthropic(messages: BiliMessage[], cacheControls?: Map<str
});
break;
case "tool-result":
if (m.rawAnthropicBlock) {
current.blocks.push(m.rawAnthropicBlock as AnthropicBlock);
break;
}
current.blocks.push({
type: "tool_result",
tool_use_id: m.toolCallId ?? "",
Expand Down
29 changes: 26 additions & 3 deletions src/wire/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export function openaiToCore(body: OpenAIRequestBody): Flat {
// in the responses/anthropic codecs): ids derive from text alone
// and switching the seed would move every pre-existing shape.
const text = stringContent(m.content);
const nonText = allNonTextParts(m.content);
const imgs = allImageParts(m.content);
const firstImg = imgs[0];
const firstUrl = firstImg ? firstImg.image_url.url : undefined;
Expand All @@ -81,15 +82,21 @@ export function openaiToCore(body: OpenAIRequestBody): Flat {
role: "user",
contentType: "text",
text,
...(imgs.length === 1
// A lone string-URL image keeps the legacy singular sidecar
// (byte-stable for pre-existing shapes). Any other mix —
// multiple images, or any unknown part type such as a
// DeepSeek Files API `{type:"file"}` ref — rides the plural
// sidecar verbatim so coreToOpenai can re-emit it
// (billion-context#1205: unknown parts were silently dropped).
...(imgs.length === 1 && nonText.length === 1
? {
rawOpenaiContent: imgs[0],
// data: URLs only — remote URLs ride rawOpenaiContent
// verbatim (#187/#291)
...(firstParsed ? { imageMediaType: firstParsed.mediaType, imageBase64: firstParsed.base64 } : {}),
}
: imgs.length > 1
? { rawOpenaiContentParts: imgs }
: nonText.length > 0
? { rawOpenaiContentParts: nonText }
: {}),
});
break;
Expand Down Expand Up @@ -295,3 +302,19 @@ function allImageParts(content: OpenAIMessage["content"]): OpenAIImagePart[] {
}
return out;
}

/** Collect ALL non-text object parts of a user content array, in wire order —
* image_url parts plus any unknown part type. Unknown parts are opaque
* payloads (e.g. DeepSeek Files API `{type:"file",file_id}` refs) that the
* kernel must never drop: they ride rawOpenaiContentParts verbatim and
* coreToOpenai re-emits them unchanged (billion-context#1205). */
function allNonTextParts(content: OpenAIMessage["content"]): OpenAIContentPart[] {
if (!Array.isArray(content)) return [];
const out: OpenAIContentPart[] = [];
for (const p of content) {
if (typeof p !== "object" || p === null) continue;
if (p.type === "text") continue;
out.push(p);
}
return out;
}
Loading
Loading