feat(coding-agent): added snapcompact savings estimation to context reporting
- Added optional snapcompact savings calculation in context breakdown with caller opt-in and setting gate. - Added /context command integration to surface snapcompact savings and related skip reasons. - Added context report sections for vision capability, tool-result imaging counts, and next-request token totals. - Added shared inline-swap planning/savings estimation and transformer use for planned frame swaps.
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
|
||||
### Added
|
||||
|
||||
- `/context` (TUI panel and ACP report) now shows estimated snapcompact wire savings when `snapcompact.systemPrompt` or `snapcompact.toolResults` is enabled — per-feature text → frames token deltas, the reason a swap does not apply (savings margin, image budget, or text-only model), and the estimated size of the next request. The estimate and the live provider-request transform share one planner (`planInlineSwaps`) so displayed numbers cannot drift from wire behavior.
|
||||
- Added `/debug dump-request` and `/debug next-request` as aliases for `/debug dump-next-request` when arming a one-shot AI provider request dump
|
||||
- Added `/debug dump-next-request <path>` to dump the next AI provider HTTP request JSON to a chosen file.
|
||||
|
||||
@@ -11,6 +12,7 @@
|
||||
|
||||
- Changed `/debug` handling in interactive mode so `/debug` with arguments now executes the requested debug subcommand instead of always opening the debug selector
|
||||
- Changed `/debug dump-next-request` path handling to expand `~` and resolve relative paths against the current working directory
|
||||
- Changed the task tool's TUI block: the header now shows the task dispatch glyph (`tool.task`) while agents are in flight instead of a spinner (async spawns return immediately, so a spinner misread the call as blocking), and per-agent rows use one static dot for every state — completed rows keep the same dot and settle from accent to the plain foreground color instead of switching to a different status glyph
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -456,7 +456,7 @@ export class CommandController {
|
||||
}
|
||||
|
||||
handleContextCommand(): void {
|
||||
const breakdown = computeContextBreakdown(this.ctx.session);
|
||||
const breakdown = computeContextBreakdown(this.ctx.session, { snapcompactSavings: true });
|
||||
if (breakdown.contextWindow <= 0) {
|
||||
this.ctx.showWarning("Context usage is unavailable: no model is selected for this session.");
|
||||
return;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { countTokens } from "@oh-my-pi/pi-natives";
|
||||
import { formatNumber } from "@oh-my-pi/pi-utils";
|
||||
import type { Skill } from "../../extensibility/skills";
|
||||
import type { AgentSession } from "../../session/agent-session";
|
||||
import { estimateInlineSavings, type SnapcompactSavingsEstimate } from "../../session/snapcompact-inline";
|
||||
import type { Tool } from "../../tools";
|
||||
import type { theme as Theme } from "../theme/theme";
|
||||
|
||||
@@ -36,6 +37,8 @@ export interface ContextBreakdown {
|
||||
usedTokens: number;
|
||||
autoCompactBufferTokens: number;
|
||||
freeTokens: number;
|
||||
/** Estimated snapcompact wire savings; set when requested and a snapcompact.* setting is enabled. */
|
||||
snapcompact?: SnapcompactSavingsEstimate;
|
||||
}
|
||||
|
||||
const EMPTY_STRING_PARTS: readonly string[] = [];
|
||||
@@ -109,7 +112,10 @@ function computeNonMessageBreakdown(session: AgentSession): {
|
||||
* Compute a breakdown of estimated context usage by category for the active
|
||||
* session and model.
|
||||
*/
|
||||
export function computeContextBreakdown(session: AgentSession): ContextBreakdown {
|
||||
export function computeContextBreakdown(
|
||||
session: AgentSession,
|
||||
options?: { snapcompactSavings?: boolean },
|
||||
): ContextBreakdown {
|
||||
const model = session.model;
|
||||
const contextWindow = model?.contextWindow ?? 0;
|
||||
|
||||
@@ -169,6 +175,22 @@ export function computeContextBreakdown(session: AgentSession): ContextBreakdown
|
||||
|
||||
const freeTokens = Math.max(0, contextWindow - usedTokens - autoCompactBufferTokens);
|
||||
|
||||
// Estimated wire savings from snapcompact inline imaging. Opt-in: only the
|
||||
// /context surfaces need it; other callers skip the extra token counting.
|
||||
let snapcompactSavings: SnapcompactSavingsEstimate | undefined;
|
||||
if (options?.snapcompactSavings) {
|
||||
const renderSystemPrompt = session.settings.get("snapcompact.systemPrompt");
|
||||
const renderToolResults = session.settings.get("snapcompact.toolResults");
|
||||
if (renderSystemPrompt || renderToolResults) {
|
||||
snapcompactSavings = estimateInlineSavings({
|
||||
options: { renderSystemPrompt, renderToolResults },
|
||||
model,
|
||||
systemPrompt: session.systemPrompt ?? [],
|
||||
messages: session.messages ?? [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
model,
|
||||
contextWindow,
|
||||
@@ -176,6 +198,7 @@ export function computeContextBreakdown(session: AgentSession): ContextBreakdown
|
||||
usedTokens,
|
||||
autoCompactBufferTokens,
|
||||
freeTokens,
|
||||
snapcompact: snapcompactSavings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -298,6 +321,55 @@ function buildLegendLines(breakdown: ContextBreakdown, theme: typeof Theme): str
|
||||
);
|
||||
}
|
||||
|
||||
const snap = breakdown.snapcompact;
|
||||
if (snap) {
|
||||
lines.push("");
|
||||
if (!snap.visionCapable) {
|
||||
lines.push(theme.fg("muted", "Snapcompact: inactive (model has no image input)"));
|
||||
} else {
|
||||
lines.push(theme.fg("muted", "Snapcompact (estimated wire savings)"));
|
||||
if (snap.systemPrompt) {
|
||||
const sp = snap.systemPrompt;
|
||||
if (sp.applied) {
|
||||
lines.push(
|
||||
` System prompt: saves ${theme.bold(`~${formatNumber(sp.savedTokens)}`)} ` +
|
||||
theme.fg(
|
||||
"dim",
|
||||
`(${formatNumber(sp.textTokens)} text → ${sp.frames} frame${sp.frames === 1 ? "" : "s"} ≈ ${formatNumber(sp.imageTokens)})`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const reason =
|
||||
sp.reason === "budget"
|
||||
? "image budget exhausted"
|
||||
: sp.reason === "empty"
|
||||
? "nothing to image"
|
||||
: "frames would not save tokens";
|
||||
lines.push(` System prompt: ${theme.fg("dim", `stays text (${reason})`)}`);
|
||||
}
|
||||
}
|
||||
if (snap.toolResults) {
|
||||
const tr = snap.toolResults;
|
||||
if (tr.swapped > 0) {
|
||||
lines.push(
|
||||
` Tool results: saves ${theme.bold(`~${formatNumber(tr.savedTokens)}`)} ` +
|
||||
theme.fg(
|
||||
"dim",
|
||||
`(${tr.swapped}/${tr.total} imaged, ${formatNumber(tr.textTokens)} text → ${tr.frames} frames ≈ ${formatNumber(tr.imageTokens)})`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
lines.push(` Tool results: ${theme.fg("dim", `none imaged (${tr.total} in history)`)}`);
|
||||
}
|
||||
}
|
||||
if (snap.savedTokens > 0) {
|
||||
lines.push(
|
||||
` Next request: ${theme.bold(`~${formatNumber(Math.max(0, usedTokens - snap.savedTokens))}`)} ${theme.fg("dim", "tokens on the wire")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
* input context shares `content` array references with the persisted
|
||||
* `SessionMessageEntry` messages, so mutation would leak rendered images
|
||||
* into session.jsonl.
|
||||
*
|
||||
* The swap policy (budget, savings gate, skip rules) lives in
|
||||
* `planInlineSwaps`, shared by the transform and the `/context` savings
|
||||
* estimate (`estimateInlineSavings`) so the two can never disagree.
|
||||
*/
|
||||
import type { Context, ImageContent, Model, TextContent, ToolResultMessage, UserMessage } from "@oh-my-pi/pi-ai";
|
||||
import { countTokens } from "@oh-my-pi/pi-natives";
|
||||
@@ -65,6 +69,262 @@ function passesSavingsGate(frames: number, shape: snapcompact.Shape, textTokens:
|
||||
return frames * shape.frameTokenEstimate <= textTokens * SAVINGS_MARGIN;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Swap planning (shared by the live transform and /context estimation)
|
||||
// ============================================================================
|
||||
|
||||
/** Tool-result swap candidate, in context order. */
|
||||
export interface InlineToolResultCandidate {
|
||||
/** toolCallId — stable identity for render caching and application. */
|
||||
id: string;
|
||||
/** Token count of the joined text blocks (0 when empty or image-carrying). */
|
||||
textTokens: number;
|
||||
/** Frames needed to render the text (0 = empty or below the token floor). */
|
||||
frames: number;
|
||||
/** Already carries an image (screenshot etc.) — never re-imaged. */
|
||||
hasImage: boolean;
|
||||
}
|
||||
|
||||
export interface InlineSystemPromptCandidate {
|
||||
textTokens: number;
|
||||
frames: number;
|
||||
}
|
||||
|
||||
export interface InlinePlanInput {
|
||||
options: SnapcompactInlineOptions;
|
||||
shape: snapcompact.Shape;
|
||||
/** Provider image-count budget minus images already present in the context. */
|
||||
budget: number;
|
||||
/** All tool results in context order, INCLUDING the most recent one. */
|
||||
toolResults: readonly InlineToolResultCandidate[];
|
||||
/** Joined system prompt; undefined when absent or system-prompt imaging is off. */
|
||||
systemPrompt: InlineSystemPromptCandidate | undefined;
|
||||
/** Whether a user message exists to carry the system-prompt frames. */
|
||||
hasUserMessage: boolean;
|
||||
}
|
||||
|
||||
export interface InlineSwapPlan {
|
||||
/** Tool results to swap, oldest first. */
|
||||
toolResults: Array<{ id: string; textTokens: number; frames: number }>;
|
||||
/** Set when the system prompt should swap to frames (uses leftover budget). */
|
||||
systemPrompt: InlineSystemPromptCandidate | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which content gets swapped for frames. Pure — the same rules drive
|
||||
* the provider-request transform and the /context savings estimate.
|
||||
*/
|
||||
export function planInlineSwaps(input: InlinePlanInput): InlineSwapPlan {
|
||||
let budget = input.budget;
|
||||
|
||||
const toolResults: InlineSwapPlan["toolResults"] = [];
|
||||
if (input.options.renderToolResults) {
|
||||
// Oldest-first for cache-stable bytes; skip the LAST tool result so the
|
||||
// freshest output stays crisp text. A candidate too big for the
|
||||
// remaining budget is skipped, not a stop — later smaller ones may fit.
|
||||
for (let k = 0; k < input.toolResults.length - 1 && budget > 0; k++) {
|
||||
const candidate = input.toolResults[k];
|
||||
if (candidate.hasImage) continue;
|
||||
if (candidate.textTokens < MIN_TOOL_RESULT_TOKENS) continue;
|
||||
if (candidate.frames === 0 || candidate.frames > budget) continue;
|
||||
if (!passesSavingsGate(candidate.frames, input.shape, candidate.textTokens)) continue;
|
||||
toolResults.push({ id: candidate.id, textTokens: candidate.textTokens, frames: candidate.frames });
|
||||
budget -= candidate.frames;
|
||||
}
|
||||
}
|
||||
|
||||
let systemPrompt: InlineSystemPromptCandidate | undefined;
|
||||
if (
|
||||
input.options.renderSystemPrompt &&
|
||||
input.systemPrompt &&
|
||||
budget > 0 &&
|
||||
input.systemPrompt.frames > 0 &&
|
||||
input.systemPrompt.frames <= Math.min(budget, MAX_SYSTEM_PROMPT_FRAMES) &&
|
||||
passesSavingsGate(input.systemPrompt.frames, input.shape, input.systemPrompt.textTokens) &&
|
||||
// No user message to carry the frames → leave the prompt as text.
|
||||
input.hasUserMessage
|
||||
) {
|
||||
systemPrompt = input.systemPrompt;
|
||||
}
|
||||
|
||||
return { toolResults, systemPrompt };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// /context savings estimation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Minimal structural view of a history message — both pi-ai `Message`s (the
|
||||
* outgoing context) and agent-core `AgentMessage`s (the live session) satisfy
|
||||
* it, so the estimator can read session state without conversion.
|
||||
*/
|
||||
export interface InlineMessageView {
|
||||
role: string;
|
||||
toolCallId?: string;
|
||||
content?: unknown;
|
||||
}
|
||||
|
||||
export interface SnapcompactSavingsEstimate {
|
||||
/** Frames only ship on models that accept image input. */
|
||||
visionCapable: boolean;
|
||||
/** Present iff system-prompt imaging is enabled. */
|
||||
systemPrompt?: {
|
||||
applied: boolean;
|
||||
/** Why the prompt stays text when `applied` is false. */
|
||||
reason?: "empty" | "margin" | "budget";
|
||||
textTokens: number;
|
||||
frames: number;
|
||||
/** Estimated billed tokens for the frames (0 when there are none). */
|
||||
imageTokens: number;
|
||||
savedTokens: number;
|
||||
};
|
||||
/** Present iff tool-result imaging is enabled. */
|
||||
toolResults?: {
|
||||
/** Tool results currently in history. */
|
||||
total: number;
|
||||
swapped: number;
|
||||
/** Text tokens of the swapped results only. */
|
||||
textTokens: number;
|
||||
frames: number;
|
||||
imageTokens: number;
|
||||
savedTokens: number;
|
||||
};
|
||||
/** Net estimated wire savings for the next request. */
|
||||
savedTokens: number;
|
||||
}
|
||||
|
||||
/** Loose block-array view of unknown message content. */
|
||||
type BlockViews = ReadonlyArray<{ type?: unknown; text?: unknown }>;
|
||||
|
||||
/**
|
||||
* Estimate what `SnapcompactInlineTransformer.transform` would save on the
|
||||
* NEXT request, given the session's live system prompt and message history.
|
||||
*
|
||||
* Mirrors the transform exactly via `planInlineSwaps`, with one deliberate
|
||||
* difference: `hasUserMessage` is assumed true, because the request being
|
||||
* estimated is always triggered by a user prompt — even when the current
|
||||
* history is still empty.
|
||||
*/
|
||||
export function estimateInlineSavings(input: {
|
||||
options: SnapcompactInlineOptions;
|
||||
model: Model | undefined;
|
||||
systemPrompt: readonly string[];
|
||||
messages: readonly InlineMessageView[];
|
||||
}): SnapcompactSavingsEstimate {
|
||||
const { options, model } = input;
|
||||
if (!model?.input.includes("image")) {
|
||||
return { visionCapable: false, savedTokens: 0 };
|
||||
}
|
||||
|
||||
const shape = snapcompact.resolveShape(model.api);
|
||||
let existingImages = 0;
|
||||
for (const message of input.messages) {
|
||||
if (!Array.isArray(message.content)) continue;
|
||||
for (const block of message.content as BlockViews) {
|
||||
if (block.type === "image") existingImages++;
|
||||
}
|
||||
}
|
||||
const budget = (INLINE_IMAGE_BUDGET_BY_PROVIDER[model.provider] ?? DEFAULT_INLINE_IMAGE_BUDGET) - existingImages;
|
||||
|
||||
const candidates: InlineToolResultCandidate[] = [];
|
||||
if (options.renderToolResults) {
|
||||
for (const message of input.messages) {
|
||||
if (message.role !== "toolResult" || typeof message.toolCallId !== "string") continue;
|
||||
const blocks: BlockViews = Array.isArray(message.content) ? (message.content as BlockViews) : [];
|
||||
const hasImage = blocks.some(block => block.type === "image");
|
||||
const text = hasImage
|
||||
? ""
|
||||
: blocks
|
||||
.filter(block => block.type === "text" && typeof block.text === "string")
|
||||
.map(block => block.text as string)
|
||||
.join("\n");
|
||||
const textTokens = text.length > 0 ? countTokens(text) : 0;
|
||||
candidates.push({
|
||||
id: message.toolCallId,
|
||||
textTokens,
|
||||
frames: textTokens >= MIN_TOOL_RESULT_TOKENS ? snapcompact.frames(text, { shape }) : 0,
|
||||
hasImage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let systemPromptCandidate: InlineSystemPromptCandidate | undefined;
|
||||
if (options.renderSystemPrompt && input.systemPrompt.length > 0) {
|
||||
const joined = input.systemPrompt.join("\n\n");
|
||||
systemPromptCandidate = {
|
||||
textTokens: countTokens(joined),
|
||||
frames: snapcompact.frames(joined, { shape }),
|
||||
};
|
||||
}
|
||||
|
||||
const plan = planInlineSwaps({
|
||||
options,
|
||||
shape,
|
||||
budget,
|
||||
toolResults: candidates,
|
||||
systemPrompt: systemPromptCandidate,
|
||||
hasUserMessage: true,
|
||||
});
|
||||
|
||||
let savedTokens = 0;
|
||||
let systemPromptEstimate: SnapcompactSavingsEstimate["systemPrompt"];
|
||||
if (options.renderSystemPrompt) {
|
||||
const candidate = systemPromptCandidate ?? { textTokens: 0, frames: 0 };
|
||||
const applied = plan.systemPrompt !== undefined;
|
||||
const imageTokens = candidate.frames * shape.frameTokenEstimate;
|
||||
const saved = applied ? Math.max(0, candidate.textTokens - imageTokens) : 0;
|
||||
let reason: "empty" | "margin" | "budget" | undefined;
|
||||
if (!applied) {
|
||||
const leftover = budget - plan.toolResults.reduce((sum, swap) => sum + swap.frames, 0);
|
||||
if (candidate.frames === 0) reason = "empty";
|
||||
else if (candidate.frames > Math.min(leftover, MAX_SYSTEM_PROMPT_FRAMES)) reason = "budget";
|
||||
else reason = "margin";
|
||||
}
|
||||
systemPromptEstimate = {
|
||||
applied,
|
||||
...(reason ? { reason } : {}),
|
||||
textTokens: candidate.textTokens,
|
||||
frames: candidate.frames,
|
||||
imageTokens,
|
||||
savedTokens: saved,
|
||||
};
|
||||
savedTokens += saved;
|
||||
}
|
||||
|
||||
let toolResultsEstimate: SnapcompactSavingsEstimate["toolResults"];
|
||||
if (options.renderToolResults) {
|
||||
let textTokens = 0;
|
||||
let frames = 0;
|
||||
for (const swap of plan.toolResults) {
|
||||
textTokens += swap.textTokens;
|
||||
frames += swap.frames;
|
||||
}
|
||||
const imageTokens = frames * shape.frameTokenEstimate;
|
||||
const saved = Math.max(0, textTokens - imageTokens);
|
||||
toolResultsEstimate = {
|
||||
total: candidates.length,
|
||||
swapped: plan.toolResults.length,
|
||||
textTokens,
|
||||
frames,
|
||||
imageTokens,
|
||||
savedTokens: saved,
|
||||
};
|
||||
savedTokens += saved;
|
||||
}
|
||||
|
||||
return {
|
||||
visionCapable: true,
|
||||
...(systemPromptEstimate ? { systemPrompt: systemPromptEstimate } : {}),
|
||||
...(toolResultsEstimate ? { toolResults: toolResultsEstimate } : {}),
|
||||
savedTokens,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Provider-request transform
|
||||
// ============================================================================
|
||||
|
||||
interface FrameCacheEntry {
|
||||
hash: number | bigint;
|
||||
frames: ImageContent[];
|
||||
@@ -88,43 +348,70 @@ export class SnapcompactInlineTransformer {
|
||||
if (!model.input.includes("image")) return context;
|
||||
|
||||
const shape = snapcompact.resolveShape(model.api);
|
||||
let budget =
|
||||
const budget =
|
||||
(INLINE_IMAGE_BUDGET_BY_PROVIDER[model.provider] ?? DEFAULT_INLINE_IMAGE_BUDGET) - countContextImages(context);
|
||||
if (budget <= 0) return context;
|
||||
|
||||
const messages = [...context.messages];
|
||||
let changed = false;
|
||||
|
||||
// Collect tool-result candidates (in order) for the planner, plus the
|
||||
// text/index needed to apply swaps and the live ids for cache eviction.
|
||||
const candidates: InlineToolResultCandidate[] = [];
|
||||
const targets = new Map<string, { index: number; message: ToolResultMessage; text: string }>();
|
||||
const liveToolCallIds = new Set<string>();
|
||||
if (this.options.renderToolResults) {
|
||||
const toolResultIndices: number[] = [];
|
||||
const liveToolCallIds = new Set<string>();
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
if (message.role !== "toolResult") continue;
|
||||
toolResultIndices.push(i);
|
||||
liveToolCallIds.add(message.toolCallId);
|
||||
}
|
||||
// Oldest-first for cache-stable bytes; skip the LAST tool result so
|
||||
// the freshest output stays crisp text.
|
||||
for (let k = 0; k < toolResultIndices.length - 1 && budget > 0; k++) {
|
||||
const index = toolResultIndices[k];
|
||||
const message = messages[index] as ToolResultMessage;
|
||||
// Don't re-image results that already carry images (screenshots etc.).
|
||||
if (message.content.some(block => block.type === "image")) continue;
|
||||
const text = message.content
|
||||
.filter(isTextContent)
|
||||
.map(block => block.text)
|
||||
.join("\n");
|
||||
const textTokens = countTokens(text);
|
||||
if (textTokens < MIN_TOOL_RESULT_TOKENS) continue;
|
||||
const needed = snapcompact.frames(text, { shape });
|
||||
if (needed === 0 || needed > budget) continue;
|
||||
if (!passesSavingsGate(needed, shape, textTokens)) continue;
|
||||
const frames = this.#framesFor(this.#toolCache, message.toolCallId, text, shape);
|
||||
messages[index] = { ...message, content: [{ type: "text", text: toolResultNote }, ...frames] };
|
||||
budget -= frames.length;
|
||||
changed = true;
|
||||
const hasImage = message.content.some(block => block.type === "image");
|
||||
const text = hasImage
|
||||
? ""
|
||||
: message.content
|
||||
.filter(isTextContent)
|
||||
.map(block => block.text)
|
||||
.join("\n");
|
||||
const textTokens = text.length > 0 ? countTokens(text) : 0;
|
||||
candidates.push({
|
||||
id: message.toolCallId,
|
||||
textTokens,
|
||||
frames: textTokens >= MIN_TOOL_RESULT_TOKENS ? snapcompact.frames(text, { shape }) : 0,
|
||||
hasImage,
|
||||
});
|
||||
targets.set(message.toolCallId, { index: i, message, text });
|
||||
}
|
||||
}
|
||||
|
||||
let systemPromptCandidate: InlineSystemPromptCandidate | undefined;
|
||||
let joinedSystemPrompt = "";
|
||||
if (this.options.renderSystemPrompt && context.systemPrompt?.length) {
|
||||
joinedSystemPrompt = context.systemPrompt.join("\n\n");
|
||||
systemPromptCandidate = {
|
||||
textTokens: countTokens(joinedSystemPrompt),
|
||||
frames: snapcompact.frames(joinedSystemPrompt, { shape }),
|
||||
};
|
||||
}
|
||||
|
||||
const userIndex = messages.findIndex(message => message.role === "user");
|
||||
const plan = planInlineSwaps({
|
||||
options: this.options,
|
||||
shape,
|
||||
budget,
|
||||
toolResults: candidates,
|
||||
systemPrompt: systemPromptCandidate,
|
||||
hasUserMessage: userIndex >= 0,
|
||||
});
|
||||
|
||||
let changed = false;
|
||||
for (const swap of plan.toolResults) {
|
||||
const target = targets.get(swap.id);
|
||||
if (!target) continue;
|
||||
const frames = this.#framesFor(this.#toolCache, swap.id, target.text, shape);
|
||||
messages[target.index] = { ...target.message, content: [{ type: "text", text: toolResultNote }, ...frames] };
|
||||
changed = true;
|
||||
}
|
||||
if (this.options.renderToolResults) {
|
||||
// Drop cache entries for tool calls no longer in the context
|
||||
// (compacted away) so the cache stays bounded by live history.
|
||||
for (const key of this.#toolCache.keys()) {
|
||||
@@ -133,38 +420,26 @@ export class SnapcompactInlineTransformer {
|
||||
}
|
||||
|
||||
let systemPrompt = context.systemPrompt;
|
||||
if (this.options.renderSystemPrompt && context.systemPrompt?.length && budget > 0) {
|
||||
const joined = context.systemPrompt.join("\n\n");
|
||||
const needed = snapcompact.frames(joined, { shape });
|
||||
const userIndex = messages.findIndex(message => message.role === "user");
|
||||
if (
|
||||
needed > 0 &&
|
||||
needed <= Math.min(budget, MAX_SYSTEM_PROMPT_FRAMES) &&
|
||||
passesSavingsGate(needed, shape, countTokens(joined)) &&
|
||||
// No user message to carry the frames → leave the prompt as text.
|
||||
userIndex >= 0
|
||||
) {
|
||||
const hash = Bun.hash(joined);
|
||||
let cached = this.#systemCache;
|
||||
if (!cached || cached.hash !== hash) {
|
||||
cached = {
|
||||
hash,
|
||||
frames: snapcompact.renderMany(joined, { shape, maxFrames: MAX_SYSTEM_PROMPT_FRAMES }),
|
||||
};
|
||||
this.#systemCache = cached;
|
||||
}
|
||||
const frames = cached.frames;
|
||||
const original = messages[userIndex] as UserMessage;
|
||||
const originalContent: (TextContent | ImageContent)[] =
|
||||
typeof original.content === "string" ? [{ type: "text", text: original.content }] : original.content;
|
||||
messages[userIndex] = {
|
||||
...original,
|
||||
content: [{ type: "text", text: systemFramesNote }, ...frames, ...originalContent],
|
||||
if (plan.systemPrompt && userIndex >= 0) {
|
||||
const hash = Bun.hash(joinedSystemPrompt);
|
||||
let cached = this.#systemCache;
|
||||
if (!cached || cached.hash !== hash) {
|
||||
cached = {
|
||||
hash,
|
||||
frames: snapcompact.renderMany(joinedSystemPrompt, { shape, maxFrames: MAX_SYSTEM_PROMPT_FRAMES }),
|
||||
};
|
||||
systemPrompt = [systemStub];
|
||||
budget -= frames.length;
|
||||
changed = true;
|
||||
this.#systemCache = cached;
|
||||
}
|
||||
const frames = cached.frames;
|
||||
const original = messages[userIndex] as UserMessage;
|
||||
const originalContent: (TextContent | ImageContent)[] =
|
||||
typeof original.content === "string" ? [{ type: "text", text: original.content }] : original.content;
|
||||
messages[userIndex] = {
|
||||
...original,
|
||||
content: [{ type: "text", text: systemFramesNote }, ...frames, ...originalContent],
|
||||
};
|
||||
systemPrompt = [systemStub];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) return context;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { renderAsciiBar } from "./format";
|
||||
*/
|
||||
export function buildContextReportText(runtime: SlashCommandRuntime): string {
|
||||
try {
|
||||
const breakdown = computeContextBreakdown(runtime.session);
|
||||
const breakdown = computeContextBreakdown(runtime.session, { snapcompactSavings: true });
|
||||
if (breakdown.contextWindow <= 0) {
|
||||
return "Context usage is unavailable: no model is selected for this session.";
|
||||
}
|
||||
@@ -30,6 +30,33 @@ export function buildContextReportText(runtime: SlashCommandRuntime): string {
|
||||
const fraction = breakdown.freeTokens / breakdown.contextWindow;
|
||||
lines.push(` ${"Free".padEnd(16)} ${renderAsciiBar(fraction)} ${breakdown.freeTokens} tokens`);
|
||||
}
|
||||
const snap = breakdown.snapcompact;
|
||||
if (snap) {
|
||||
if (!snap.visionCapable) {
|
||||
lines.push("Snapcompact: inactive (model has no image input)");
|
||||
} else {
|
||||
lines.push("Snapcompact (estimated wire savings):");
|
||||
if (snap.systemPrompt) {
|
||||
const sp = snap.systemPrompt;
|
||||
lines.push(
|
||||
sp.applied
|
||||
? ` System prompt: ${sp.textTokens} text tokens → ${sp.frames} frame${sp.frames === 1 ? "" : "s"} ≈ ${sp.imageTokens} tokens (saves ~${sp.savedTokens})`
|
||||
: " System prompt: stays text (no net savings)",
|
||||
);
|
||||
}
|
||||
if (snap.toolResults) {
|
||||
const tr = snap.toolResults;
|
||||
lines.push(
|
||||
tr.swapped > 0
|
||||
? ` Tool results: ${tr.swapped} of ${tr.total} imaged, ${tr.textTokens} text tokens → ${tr.frames} frames ≈ ${tr.imageTokens} tokens (saves ~${tr.savedTokens})`
|
||||
: ` Tool results: none imaged (${tr.total} in history)`,
|
||||
);
|
||||
}
|
||||
if (snap.savedTokens > 0) {
|
||||
lines.push(` Estimated next request: ~${breakdown.usedTokens - snap.savedTokens} tokens on the wire`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
} catch {
|
||||
const fallback = runtime.session.getContextUsage();
|
||||
|
||||
+10
-2
@@ -128,7 +128,11 @@ describe("SettingsSelectorComponent memory tab", () => {
|
||||
comp.handleInput("b");
|
||||
const strip = (line: string): string => line.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
const searching = comp.render(120).map(strip).join("\n");
|
||||
const banner = comp.render(120).map(strip).find(line => /\d+ match/.test(line)) ?? "";
|
||||
const banner =
|
||||
comp
|
||||
.render(120)
|
||||
.map(strip)
|
||||
.find(line => /\d+ match/.test(line)) ?? "";
|
||||
expect(banner).toContain(" b ");
|
||||
expect(searching).toMatch(/\d+ match/);
|
||||
|
||||
@@ -162,7 +166,11 @@ describe("SettingsSelectorComponent memory tab", () => {
|
||||
it("supports editor hotkeys in the global search bar", () => {
|
||||
const comp = createSelector();
|
||||
const strip = (line: string): string => line.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
const banner = (): string => comp.render(120).map(strip).find(line => /\d+ match/.test(line)) ?? "";
|
||||
const banner = (): string =>
|
||||
comp
|
||||
.render(120)
|
||||
.map(strip)
|
||||
.find(line => /\d+ match/.test(line)) ?? "";
|
||||
|
||||
// alt+backspace deletes the trailing word from the query.
|
||||
for (const ch of "image provider") comp.handleInput(ch);
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
*/
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
|
||||
import { estimateToolSchemaTokens } from "@oh-my-pi/pi-coding-agent/modes/utils/context-usage";
|
||||
import {
|
||||
type ContextBreakdown,
|
||||
estimateToolSchemaTokens,
|
||||
renderContextUsage,
|
||||
} from "@oh-my-pi/pi-coding-agent/modes/utils/context-usage";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
describe("estimateToolSchemaTokens", () => {
|
||||
@@ -25,3 +29,53 @@ describe("estimateToolSchemaTokens", () => {
|
||||
expect(zodEstimate).toBe(wireEstimate);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Contract: the /context panel surfaces estimated snapcompact wire savings —
|
||||
* applied swaps show "saves" figures, inactive states say why.
|
||||
*/
|
||||
describe("renderContextUsage snapcompact section", () => {
|
||||
const themeStub = {
|
||||
fg: (_color: string, text: string) => text,
|
||||
bold: (text: string) => text,
|
||||
} as never;
|
||||
|
||||
function breakdownWith(snapcompact: ContextBreakdown["snapcompact"]): ContextBreakdown {
|
||||
return {
|
||||
model: { id: "test-model", name: "Test Model", contextWindow: 200000 } as never,
|
||||
contextWindow: 200000,
|
||||
categories: [],
|
||||
usedTokens: 27929,
|
||||
autoCompactBufferTokens: 0,
|
||||
freeTokens: 172071,
|
||||
snapcompact,
|
||||
};
|
||||
}
|
||||
|
||||
it("renders savings, skip reasons, and the wire total", () => {
|
||||
const output = renderContextUsage(
|
||||
breakdownWith({
|
||||
visionCapable: true,
|
||||
systemPrompt: { applied: true, textTokens: 9768, frames: 2, imageTokens: 6600, savedTokens: 3168 },
|
||||
toolResults: { total: 3, swapped: 0, textTokens: 0, frames: 0, imageTokens: 0, savedTokens: 0 },
|
||||
savedTokens: 3168,
|
||||
}),
|
||||
themeStub,
|
||||
);
|
||||
expect(output).toContain("Snapcompact (estimated wire savings)");
|
||||
expect(output).toContain("System prompt: saves ~3.2K (9.8K text → 2 frames ≈ 6.6K)");
|
||||
expect(output).toContain("Tool results: none imaged (3 in history)");
|
||||
// 27929 logical − 3168 saved ≈ 25K on the wire.
|
||||
expect(output).toContain("Next request: ~25K tokens on the wire");
|
||||
});
|
||||
|
||||
it("reports text-only models as inactive", () => {
|
||||
const output = renderContextUsage(breakdownWith({ visionCapable: false, savedTokens: 0 }), themeStub);
|
||||
expect(output).toContain("Snapcompact: inactive (model has no image input)");
|
||||
});
|
||||
|
||||
it("omits the section entirely when no snapcompact setting is on", () => {
|
||||
const output = renderContextUsage(breakdownWith(undefined), themeStub);
|
||||
expect(output).not.toContain("Snapcompact");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it, spyOn } from "bun:test";
|
||||
import type { Context, ImageContent, Message, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
|
||||
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
||||
import { SnapcompactInlineTransformer } from "@oh-my-pi/pi-coding-agent/session/snapcompact-inline";
|
||||
import {
|
||||
estimateInlineSavings,
|
||||
planInlineSwaps,
|
||||
SnapcompactInlineTransformer,
|
||||
} from "@oh-my-pi/pi-coding-agent/session/snapcompact-inline";
|
||||
import * as snapcompact from "@oh-my-pi/snapcompact";
|
||||
|
||||
/**
|
||||
@@ -225,3 +229,178 @@ describe("SnapcompactInlineTransformer", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("planInlineSwaps", () => {
|
||||
const shape = snapcompact.resolveShape("anthropic-messages");
|
||||
const toolOnly = { renderSystemPrompt: false, renderToolResults: true };
|
||||
const promptOnly = { renderSystemPrompt: true, renderToolResults: false };
|
||||
|
||||
it("never swaps the most recent tool result", () => {
|
||||
const plan = planInlineSwaps({
|
||||
options: toolOnly,
|
||||
shape,
|
||||
budget: 90,
|
||||
toolResults: [
|
||||
{ id: "a", textTokens: 10000, frames: 2, hasImage: false },
|
||||
{ id: "z", textTokens: 10000, frames: 2, hasImage: false },
|
||||
],
|
||||
systemPrompt: undefined,
|
||||
hasUserMessage: true,
|
||||
});
|
||||
expect(plan.toolResults.map(swap => swap.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("skips image-carrying, below-floor, and below-margin candidates", () => {
|
||||
const plan = planInlineSwaps({
|
||||
options: toolOnly,
|
||||
shape,
|
||||
budget: 90,
|
||||
toolResults: [
|
||||
{ id: "img", textTokens: 0, frames: 0, hasImage: true },
|
||||
{ id: "small", textTokens: 2999, frames: 1, hasImage: false },
|
||||
// 2 frames ≈ 6600 image tokens > 7000 * 0.9 — margin gate rejects.
|
||||
{ id: "margin", textTokens: 7000, frames: 2, hasImage: false },
|
||||
{ id: "ok", textTokens: 10000, frames: 2, hasImage: false },
|
||||
{ id: "last", textTokens: 10000, frames: 2, hasImage: false },
|
||||
],
|
||||
systemPrompt: undefined,
|
||||
hasUserMessage: true,
|
||||
});
|
||||
expect(plan.toolResults.map(swap => swap.id)).toEqual(["ok"]);
|
||||
});
|
||||
|
||||
it("skips candidates over the remaining budget but keeps trying smaller ones", () => {
|
||||
const plan = planInlineSwaps({
|
||||
options: toolOnly,
|
||||
shape,
|
||||
budget: 3,
|
||||
toolResults: [
|
||||
{ id: "a", textTokens: 10000, frames: 2, hasImage: false },
|
||||
{ id: "b", textTokens: 10000, frames: 2, hasImage: false },
|
||||
{ id: "c", textTokens: 5000, frames: 1, hasImage: false },
|
||||
{ id: "last", textTokens: 10000, frames: 2, hasImage: false },
|
||||
],
|
||||
systemPrompt: undefined,
|
||||
hasUserMessage: true,
|
||||
});
|
||||
expect(plan.toolResults.map(swap => swap.id)).toEqual(["a", "c"]);
|
||||
});
|
||||
|
||||
it("gives the system prompt only the budget tool results left over", () => {
|
||||
const input = {
|
||||
options: { renderSystemPrompt: true, renderToolResults: true },
|
||||
shape,
|
||||
budget: 2,
|
||||
toolResults: [
|
||||
{ id: "a", textTokens: 10000, frames: 2, hasImage: false },
|
||||
{ id: "last", textTokens: 10000, frames: 2, hasImage: false },
|
||||
],
|
||||
systemPrompt: { textTokens: 10000, frames: 2 },
|
||||
hasUserMessage: true,
|
||||
};
|
||||
const contested = planInlineSwaps(input);
|
||||
expect(contested.toolResults.map(swap => swap.id)).toEqual(["a"]);
|
||||
expect(contested.systemPrompt).toBeUndefined();
|
||||
|
||||
const uncontested = planInlineSwaps({ ...input, options: promptOnly });
|
||||
expect(uncontested.toolResults).toEqual([]);
|
||||
expect(uncontested.systemPrompt).toEqual({ textTokens: 10000, frames: 2 });
|
||||
});
|
||||
|
||||
it("gates the system prompt on frame cap, savings margin, and a carrier user message", () => {
|
||||
const base = {
|
||||
options: promptOnly,
|
||||
shape,
|
||||
budget: 90,
|
||||
toolResults: [],
|
||||
hasUserMessage: true,
|
||||
};
|
||||
// 7 frames exceeds the 6-frame system prompt cap.
|
||||
expect(
|
||||
planInlineSwaps({ ...base, systemPrompt: { textTokens: 100000, frames: 7 } }).systemPrompt,
|
||||
).toBeUndefined();
|
||||
// 6 frames ≈ 19800 ≤ 30000 * 0.9 — fits.
|
||||
expect(planInlineSwaps({ ...base, systemPrompt: { textTokens: 30000, frames: 6 } }).systemPrompt).toBeDefined();
|
||||
// 2 frames ≈ 6600 > 7000 * 0.9 — margin gate rejects.
|
||||
expect(planInlineSwaps({ ...base, systemPrompt: { textTokens: 7000, frames: 2 } }).systemPrompt).toBeUndefined();
|
||||
// No user message to carry the frames.
|
||||
expect(
|
||||
planInlineSwaps({ ...base, hasUserMessage: false, systemPrompt: { textTokens: 30000, frames: 6 } })
|
||||
.systemPrompt,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateInlineSavings", () => {
|
||||
it("reports vision-incapable models as inactive with zero savings", () => {
|
||||
const estimate = estimateInlineSavings({
|
||||
options: { renderSystemPrompt: true, renderToolResults: true },
|
||||
model: makeModel({ input: ["text"] }),
|
||||
systemPrompt: [LARGE],
|
||||
messages: [],
|
||||
});
|
||||
expect(estimate.visionCapable).toBe(false);
|
||||
expect(estimate.savedTokens).toBe(0);
|
||||
expect(estimate.systemPrompt).toBeUndefined();
|
||||
expect(estimate.toolResults).toBeUndefined();
|
||||
});
|
||||
|
||||
it("assumes the next request carries a user message even with empty history", () => {
|
||||
const estimate = estimateInlineSavings({
|
||||
options: { renderSystemPrompt: true, renderToolResults: false },
|
||||
model: makeModel(),
|
||||
systemPrompt: [LARGE],
|
||||
messages: [],
|
||||
});
|
||||
expect(estimate.visionCapable).toBe(true);
|
||||
expect(estimate.systemPrompt?.applied).toBe(true);
|
||||
expect(estimate.systemPrompt?.frames).toBe(2);
|
||||
expect(estimate.systemPrompt?.imageTokens).toBe(2 * 3300);
|
||||
expect(estimate.systemPrompt?.savedTokens).toBe(
|
||||
estimate.systemPrompt!.textTokens - estimate.systemPrompt!.imageTokens,
|
||||
);
|
||||
expect(estimate.savedTokens).toBe(estimate.systemPrompt!.savedTokens);
|
||||
expect(estimate.savedTokens).toBeGreaterThan(0);
|
||||
expect(estimate.toolResults).toBeUndefined();
|
||||
});
|
||||
|
||||
it("explains why a small system prompt stays text", () => {
|
||||
const estimate = estimateInlineSavings({
|
||||
options: { renderSystemPrompt: true, renderToolResults: false },
|
||||
model: makeModel(),
|
||||
systemPrompt: ["Be terse."],
|
||||
messages: [],
|
||||
});
|
||||
expect(estimate.systemPrompt?.applied).toBe(false);
|
||||
expect(estimate.systemPrompt?.reason).toBe("margin");
|
||||
expect(estimate.savedTokens).toBe(0);
|
||||
});
|
||||
|
||||
it("matches what the transform actually swaps on the same context", () => {
|
||||
const options = { renderSystemPrompt: true, renderToolResults: true };
|
||||
const context = makeContext();
|
||||
const model = makeModel();
|
||||
|
||||
const estimate = estimateInlineSavings({
|
||||
options,
|
||||
model,
|
||||
systemPrompt: context.systemPrompt!,
|
||||
messages: context.messages,
|
||||
});
|
||||
const result = new SnapcompactInlineTransformer(options).transform(context, model);
|
||||
|
||||
let imaged = 0;
|
||||
for (const message of result.messages) {
|
||||
if (message.role !== "toolResult") continue;
|
||||
if (message.content.some(block => block.type === "image")) imaged++;
|
||||
}
|
||||
expect(estimate.toolResults?.total).toBe(3);
|
||||
expect(estimate.toolResults?.swapped).toBe(imaged);
|
||||
expect(estimate.toolResults!.savedTokens).toBe(
|
||||
estimate.toolResults!.textTokens - estimate.toolResults!.imageTokens,
|
||||
);
|
||||
// The tiny two-part system prompt stays text in both paths.
|
||||
expect(estimate.systemPrompt?.applied).toBe(false);
|
||||
expect(result.systemPrompt).toBe(context.systemPrompt);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user