Skip to content
Open
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
22 changes: 22 additions & 0 deletions packages/coding-agent/src/core/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# changes

## Provider-bound inline image budget (2026-07-22)

### What changed

- `messages.ts`: added a transport-only 24 MiB inline image budget. Provider-bound conversion keeps the newest image
block, counts it against the budget, and replaces images older than the hard recency cutoff with a re-read
placeholder while preserving all text and leaving the persisted session untouched.
- `sdk.ts`: routes the main agent loop through the shared transport conversion while preserving the dynamic
`images.blockImages` kill switch and its existing placeholder/deduplication behavior.
- `test/suite/harness.ts`: uses the same transport conversion and accepts a small injectable image budget for
deterministic first-request integration coverage.

### Why extension system couldn't handle this alone

- Inline images must be bounded after session messages are converted but before every main-loop provider request,
including resumed sessions and provider fallbacks. That conversion boundary is owned by the core Agent wiring.

### Expected merge conflict zones

- MEDIUM: `sdk.ts` around the Agent `convertToLlm` wiring.
- LOW: the transport helpers at the end of `messages.ts` and the Agent construction in `test/suite/harness.ts`.

## Streaming steer/followUp submissions bypass the session-work barrier (2026-07-21)

### What changed
Expand Down
129 changes: 129 additions & 0 deletions packages/coding-agent/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,132 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
})
.filter((m) => m !== undefined);
}

// ============================================================================
// Transport image budget
// ============================================================================

/**
* Cap on inline image base64 (characters approximately equal wire bytes) per
* provider request. Anthropic documents a 32 MB request limit; reserving the
* remainder for text and request overhead keeps the transport below that wall.
*/
export const TRANSPORT_IMAGE_BUDGET_BYTES = 24 * 1024 * 1024;

export const IMAGE_ELISION_PLACEHOLDER =
"[Image elided: an older image was removed to keep the request within the provider's size limit. Re-read the source file if you need to view it again.]";

export const BLOCKED_IMAGE_PLACEHOLDER = "Image reading is disabled.";

export interface ElideOldImagesOptions {
/** Cumulative inline image base64 budget. Defaults to TRANSPORT_IMAGE_BUDGET_BYTES. */
budgetBytes?: number;
/** Newest image blocks always kept regardless of budget. They still consume it. Defaults to 1. */
alwaysKeepNewest?: number;
}

export interface TransportConvertOptions extends ElideOldImagesOptions {
/** Replace every image when the images.blockImages setting is enabled. */
blockImages: boolean;
}

/** Drop consecutive duplicates of a placeholder produced by adjacent image replacements. */
export function dedupeConsecutivePlaceholder(
content: (TextContent | ImageContent)[],
placeholder: string,
): (TextContent | ImageContent)[] {
return content.filter(
(block, index, blocks) =>
!(
block.type === "text" &&
block.text === placeholder &&
index > 0 &&
blocks[index - 1].type === "text" &&
(blocks[index - 1] as TextContent).text === placeholder
),
);
}

/**
* Bound inline image payload at request-build time. Images are considered from
* newest to oldest. Once an image would exceed the budget, it and every older
* image are replaced by a text placeholder. The newest protected blocks are
* kept regardless of size, but still consume the budget.
*
* The input and persisted session remain untouched. If every image fits, the
* original array reference is returned.
*/
export function elideOldImages(messages: Message[], options?: ElideOldImagesOptions): Message[] {
const budgetBytes = options?.budgetBytes ?? TRANSPORT_IMAGE_BUDGET_BYTES;
const alwaysKeepNewest = options?.alwaysKeepNewest ?? 1;
const imagesToElide = new Set<string>();
let keptImages = 0;
let imageBytes = 0;
let cutoffReached = false;

for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex--) {
const message = messages[messageIndex];
if ((message.role !== "user" && message.role !== "toolResult") || !Array.isArray(message.content)) {
continue;
}

for (let blockIndex = message.content.length - 1; blockIndex >= 0; blockIndex--) {
const block = message.content[blockIndex];
if (block.type !== "image") continue;

if (cutoffReached) {
imagesToElide.add(`${messageIndex}:${blockIndex}`);
continue;
}

if (keptImages < alwaysKeepNewest || imageBytes + block.data.length <= budgetBytes) {
keptImages++;
imageBytes += block.data.length;
continue;
}

cutoffReached = true;
imagesToElide.add(`${messageIndex}:${blockIndex}`);
}
}

if (imagesToElide.size === 0) return messages;

return messages.map((message, messageIndex) => {
if ((message.role !== "user" && message.role !== "toolResult") || !Array.isArray(message.content)) {
return message;
}
if (!message.content.some((_block, blockIndex) => imagesToElide.has(`${messageIndex}:${blockIndex}`))) {
return message;
}

const replaced = message.content.map((block, blockIndex): TextContent | ImageContent =>
imagesToElide.has(`${messageIndex}:${blockIndex}`) ? { type: "text", text: IMAGE_ELISION_PLACEHOLDER } : block,
);
return {
...message,
content: dedupeConsecutivePlaceholder(replaced, IMAGE_ELISION_PLACEHOLDER) as typeof message.content,
};
});
}

/** Convert messages for the main provider transport and apply its image policy. */
export function convertToLlmForTransport(messages: AgentMessage[], options: TransportConvertOptions): Message[] {
const converted = convertToLlm(messages);
if (!options.blockImages) return elideOldImages(converted, options);

return converted.map((message) => {
if ((message.role !== "user" && message.role !== "toolResult") || !Array.isArray(message.content)) {
return message;
}
if (!message.content.some((block) => block.type === "image")) return message;

const replaced = message.content.map((block): TextContent | ImageContent =>
block.type === "image" ? { type: "text", text: BLOCKED_IMAGE_PLACEHOLDER } : block,
);
return {
...message,
content: dedupeConsecutivePlaceholder(replaced, BLOCKED_IMAGE_PLACEHOLDER) as typeof message.content,
};
});
}
43 changes: 7 additions & 36 deletions packages/coding-agent/src/core/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { AuthStorage } from "./auth-storage.ts";
import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
import type { ServiceTier } from "./extensions/builtin/service-tier.ts";
import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
import { convertToLlm } from "./messages.ts";
import { convertToLlmForTransport, TRANSPORT_IMAGE_BUDGET_BYTES } from "./messages.ts";
import { ModelRegistry } from "./model-registry.ts";
import { findInitialModel, getModelNarrowingPatterns, resolveModelScope } from "./model-resolver.ts";
import { ModelRuntime } from "./model-runtime.ts";
Expand Down Expand Up @@ -293,42 +293,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}

let agent: Agent;

// Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth)
const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] => {
const converted = convertToLlm(messages);
// Check setting dynamically so mid-session changes take effect
if (!settingsManager.getBlockImages()) {
return converted;
}
// Filter out ImageContent from all messages, replacing with text placeholder
return converted.map((msg) => {
if (msg.role === "user" || msg.role === "toolResult") {
const content = msg.content;
if (Array.isArray(content)) {
const hasImages = content.some((c) => c.type === "image");
if (hasImages) {
const filteredContent = content
.map((c) =>
c.type === "image" ? { type: "text" as const, text: "Image reading is disabled." } : c,
)
.filter(
(c, i, arr) =>
// Dedupe consecutive "Image reading is disabled." texts
!(
c.type === "text" &&
c.text === "Image reading is disabled." &&
i > 0 &&
arr[i - 1].type === "text" &&
(arr[i - 1] as { type: "text"; text: string }).text === "Image reading is disabled."
),
);
return { ...msg, content: filteredContent };
}
}
}
return msg;
// Read blockImages per request so a mid-session settings change takes effect.
const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] =>
convertToLlmForTransport(messages, {
blockImages: settingsManager.getBlockImages(),
budgetBytes: TRANSPORT_IMAGE_BUDGET_BYTES,
alwaysKeepNewest: 1,
});
};

const extensionRunnerRef: { current?: ExtensionRunner } = {};

Expand Down
Loading