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
4 changes: 4 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 8 additions & 10 deletions packages/coding-agent/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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")
Expand All @@ -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);
};
Expand Down
86 changes: 86 additions & 0 deletions packages/coding-agent/src/session/provider-image-budget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type {
Context,
DeveloperMessage,
ImageContent,
Model,
TextContent,
ToolResultMessage,
UserMessage,
} from "@oh-my-pi/pi-ai";
import { providerImageBudget } from "@oh-my-pi/snapcompact";

const TOOL_RESULT_IMAGE_OMISSION: TextContent = {
type: "text",
text: "[image omitted: provider image limit]",
};

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);
if (!content) return message;
return { ...message, content: content.length > 0 ? content : [TOOL_RESULT_IMAGE_OMISSION] };
}

/** 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 };
}
109 changes: 109 additions & 0 deletions packages/coding-agent/test/session/provider-image-budget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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("keeps image-only tool results meaningful when every image block is dropped", () => {
const context: Context = {
systemPrompt: [],
tools: [],
messages: Array.from({ length: 11 }, (_, index) => ({
role: "toolResult",
toolCallId: `call-${index}`,
toolName: "inspect_image",
content: [image(`image-${index}`)],
isError: false,
timestamp: index,
})),
};

const clamped = clampProviderContextImages(context, UMANS_MODEL);
const firstMessage = clamped.messages[0];

expect(imageData(clamped)).toEqual(Array.from({ length: 10 }, (_, index) => `image-${index + 1}`));
expect(firstMessage?.role).toBe("toolResult");
expect(firstMessage?.content).toEqual([text("[image omitted: provider image limit]")]);
});

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);
});
});
Loading