fix(agent): clamped provider context images

Dropped oldest outgoing image blocks above the active provider budget so umans requests honor the shipped 10-image cap even when snapcompact is disabled. Added regression coverage for preserving text and newest images.\n\nFixes #3230
This commit is contained in:
roboomp
2026-06-22 05:57:30 +00:00
parent 320261fca9
commit eb88ecd7bb
4 changed files with 179 additions and 10 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed `umans` requests with more than 10 live context images still sending every image despite the provider budget; outgoing provider contexts now drop the oldest images above the active provider cap while preserving text and newest images ([#3230](https://github.com/can1357/oh-my-pi/issues/3230)).
## [16.1.14] - 2026-06-22
### Added
+8 -10
View File
@@ -115,6 +115,7 @@ import {
USER_INTERRUPT_LABEL,
wrapSteeringForModel,
} from "./session/messages";
import { clampProviderContextImages } from "./session/provider-image-budget";
import { getRestorableSessionModels } from "./session/session-context";
import { SessionManager } from "./session/session-manager";
import { SnapcompactInlineTransformer } from "./session/snapcompact-inline";
@@ -2420,8 +2421,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
return wrapSteeringForModel(withContext);
};
// Per-request provider-context transforms. Obfuscate FIRST so secrets are
// redacted from text before snapcompact rasterizes it into PNG frames.
// Both operate on the transient outgoing Context only — never persisted.
// redacted from text before snapcompact rasterizes it into PNG frames, then
// clamp images to the active provider budget before the request is sent.
const snapcompactSystemPromptMode = settings.get("snapcompact.systemPrompt");
const snapcompactInline =
snapcompactSystemPromptMode !== "none" || settings.get("snapcompact.toolResults")
@@ -2436,14 +2437,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
createSnapcompactSavingsRecorder(() => sessionManager.getSessionFile() ?? null),
)
: undefined;
const transformProviderContext =
obfuscator || snapcompactInline
? async (context: Context, transformModel: Model): Promise<Context> => {
let transformed = obfuscator ? obfuscateProviderContext(obfuscator, context) : context;
if (snapcompactInline) transformed = await snapcompactInline.transform(transformed, transformModel);
return transformed;
}
: undefined;
const transformProviderContext = async (context: Context, transformModel: Model): Promise<Context> => {
let transformed = obfuscator ? obfuscateProviderContext(obfuscator, context) : context;
if (snapcompactInline) transformed = await snapcompactInline.transform(transformed, transformModel);
return clampProviderContextImages(transformed, transformModel);
};
const onPayload = async (payload: unknown, _model?: Model) => {
return await extensionRunner.emitBeforeProviderRequest(payload);
};
@@ -0,0 +1,80 @@
import type {
Context,
DeveloperMessage,
ImageContent,
Model,
TextContent,
ToolResultMessage,
UserMessage,
} from "@oh-my-pi/pi-ai";
import { providerImageBudget } from "@oh-my-pi/snapcompact";
function countImages(context: Context): number {
let count = 0;
for (const message of context.messages) {
if (!Array.isArray(message.content)) continue;
for (const part of message.content) {
if (part.type === "image") count++;
}
}
return count;
}
function clampContent(
content: readonly (TextContent | ImageContent)[],
state: { remainingDrops: number },
): (TextContent | ImageContent)[] | undefined {
let changed = false;
const clamped: (TextContent | ImageContent)[] = [];
for (const part of content) {
if (part.type === "image" && state.remainingDrops > 0) {
state.remainingDrops--;
changed = true;
continue;
}
clamped.push(part);
}
return changed ? clamped : undefined;
}
function clampUserMessage(message: UserMessage, state: { remainingDrops: number }): UserMessage {
if (!Array.isArray(message.content) || state.remainingDrops <= 0) return message;
const content = clampContent(message.content, state);
return content ? { ...message, content } : message;
}
function clampDeveloperMessage(message: DeveloperMessage, state: { remainingDrops: number }): DeveloperMessage {
if (!Array.isArray(message.content) || state.remainingDrops <= 0) return message;
const content = clampContent(message.content, state);
return content ? { ...message, content } : message;
}
function clampToolResultMessage(message: ToolResultMessage, state: { remainingDrops: number }): ToolResultMessage {
if (state.remainingDrops <= 0) return message;
const content = clampContent(message.content, state);
return content ? { ...message, content } : message;
}
/** Drops oldest transient image blocks so outgoing vision requests fit the active provider's image cap. */
export function clampProviderContextImages(context: Context, model: Model): Context {
if (!model.input.includes("image")) return context;
const limit = providerImageBudget(model.provider);
const totalImages = countImages(context);
if (totalImages <= limit) return context;
const state = { remainingDrops: totalImages - limit };
const messages = context.messages.map(message => {
switch (message.role) {
case "user":
return clampUserMessage(message, state);
case "developer":
return clampDeveloperMessage(message, state);
case "toolResult":
return clampToolResultMessage(message, state);
case "assistant":
return message;
}
return message;
});
return { ...context, messages };
}
@@ -0,0 +1,87 @@
import { describe, expect, it } from "bun:test";
import type { Context, ImageContent, TextContent } from "@oh-my-pi/pi-ai";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { clampProviderContextImages } from "@oh-my-pi/pi-coding-agent/session/provider-image-budget";
const UMANS_MODEL = buildModel({
id: "umans-glm-5.2",
name: "umans-glm-5.2",
api: "anthropic-messages",
provider: "umans",
baseUrl: "https://api.code.umans.ai",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
});
function image(data: string): ImageContent {
return { type: "image", data, mimeType: "image/png" };
}
function text(value: string): TextContent {
return { type: "text", text: value };
}
function imageData(context: Context): string[] {
const data: string[] = [];
for (const message of context.messages) {
if (!Array.isArray(message.content)) continue;
for (const part of message.content) {
if (part.type === "image") data.push(part.data);
}
}
return data;
}
function textData(context: Context): string[] {
const data: string[] = [];
for (const message of context.messages) {
if (typeof message.content === "string") {
data.push(message.content);
continue;
}
for (const part of message.content) {
if (part.type === "text") data.push(part.text);
}
}
return data;
}
describe("provider context image budgets", () => {
it("drops oldest images above the active provider cap while preserving text", () => {
const context: Context = {
systemPrompt: ["system"],
tools: [],
messages: Array.from({ length: 31 }, (_, index) => ({
role: "user",
content: [text(`text-${index}`), image(`image-${index}`)],
timestamp: index,
})),
};
const clamped = clampProviderContextImages(context, UMANS_MODEL);
expect(imageData(clamped)).toEqual(Array.from({ length: 10 }, (_, index) => `image-${index + 21}`));
expect(textData(clamped)).toEqual(Array.from({ length: 31 }, (_, index) => `text-${index}`));
expect(clamped).not.toBe(context);
expect(imageData(context)).toEqual(Array.from({ length: 31 }, (_, index) => `image-${index}`));
});
it("preserves context identity when the provider cap is not exceeded", () => {
const context: Context = {
systemPrompt: [],
tools: [],
messages: [
{
role: "user",
content: [text("ok"), ...Array.from({ length: 10 }, (_, index) => image(`image-${index}`))],
timestamp: 1,
},
],
};
expect(clampProviderContextImages(context, UMANS_MODEL)).toBe(context);
});
});