Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
80 changes: 80 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,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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve a placeholder for fully dropped tool-result images

When an older successful tool result contains only images, dropping all image parts here returns content: []. I checked the Anthropic/umans conversion path: buildToolResultBlock() only fills empty content for isError results, so once the provider budget is exceeded an image-only successful tool result is serialized as an empty tool_result rather than a textual omission marker. Add a placeholder text block when the clamp removes the last tool-result block so the request stays meaningful and protocol-safe.

Useful? React with 👍 / 👎.

}

/** 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 };
}
87 changes: 87 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,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);
});
});
Loading