diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 7915e6f9..8996003c 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed context token estimates to count user image attachments consistently with tool result images ([#4983](https://github.com/earendil-works/pi/issues/4983)). + ## [0.75.5] - 2026-05-23 ## [0.75.4] - 2026-05-20 diff --git a/packages/agent/src/harness/compaction/compaction.ts b/packages/agent/src/harness/compaction/compaction.ts index aa7b32c6..a7ae33e9 100644 --- a/packages/agent/src/harness/compaction/compaction.ts +++ b/packages/agent/src/harness/compaction/compaction.ts @@ -198,22 +198,33 @@ export function shouldCompact(contextTokens: number, contextWindow: number, sett return contextTokens > contextWindow - settings.reserveTokens; } +const ESTIMATED_IMAGE_CHARS = 4800; + +function estimateTextAndImageContentChars(content: string | Array<{ type: string; text?: string }>): number { + if (typeof content === "string") { + return content.length; + } + + let chars = 0; + for (const block of content) { + if (block.type === "text" && block.text) { + chars += block.text.length; + } else if (block.type === "image") { + chars += ESTIMATED_IMAGE_CHARS; + } + } + return chars; +} + /** Estimate token count for one message using a conservative character heuristic. */ export function estimateTokens(message: AgentMessage): number { let chars = 0; switch (message.role) { case "user": { - const content = (message as { content: string | Array<{ type: string; text?: string }> }).content; - if (typeof content === "string") { - chars = content.length; - } else if (Array.isArray(content)) { - for (const block of content) { - if (block.type === "text" && block.text) { - chars += block.text.length; - } - } - } + chars = estimateTextAndImageContentChars( + (message as { content: string | Array<{ type: string; text?: string }> }).content, + ); return Math.ceil(chars / 4); } case "assistant": { @@ -231,18 +242,7 @@ export function estimateTokens(message: AgentMessage): number { } case "custom": case "toolResult": { - if (typeof message.content === "string") { - chars = message.content.length; - } else { - for (const block of message.content) { - if (block.type === "text" && block.text) { - chars += block.text.length; - } - if (block.type === "image") { - chars += 4800; - } - } - } + chars = estimateTextAndImageContentChars(message.content); return Math.ceil(chars / 4); } case "bashExecution": { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d8b9c62f..a5a0fd8d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed context token estimates to count user image attachments consistently with tool result images ([#4983](https://github.com/earendil-works/pi/issues/4983)). - Fixed `RpcClient` to reject pending requests and consume stdin pipe errors when the child process exits unexpectedly ([#4764](https://github.com/earendil-works/pi/issues/4764)). - Fixed managed npm extension updates to avoid package managers installing or resolving pi host packages as peer dependencies ([#4907](https://github.com/earendil-works/pi/issues/4907)). - Fixed RPC mode raw stdout writes to retry transient backpressure errors and flush queued protocol output during shutdown ([#4897](https://github.com/earendil-works/pi/issues/4897)). diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index b8339fba..eae19794 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -225,6 +225,24 @@ export function shouldCompact(contextTokens: number, contextWindow: number, sett // Cut point detection // ============================================================================ +const ESTIMATED_IMAGE_CHARS = 4800; + +function estimateTextAndImageContentChars(content: string | Array<{ type: string; text?: string }>): number { + if (typeof content === "string") { + return content.length; + } + + let chars = 0; + for (const block of content) { + if (block.type === "text" && block.text) { + chars += block.text.length; + } else if (block.type === "image") { + chars += ESTIMATED_IMAGE_CHARS; + } + } + return chars; +} + /** * Estimate token count for a message using chars/4 heuristic. * This is conservative (overestimates tokens). @@ -234,16 +252,9 @@ export function estimateTokens(message: AgentMessage): number { switch (message.role) { case "user": { - const content = (message as { content: string | Array<{ type: string; text?: string }> }).content; - if (typeof content === "string") { - chars = content.length; - } else if (Array.isArray(content)) { - for (const block of content) { - if (block.type === "text" && block.text) { - chars += block.text.length; - } - } - } + chars = estimateTextAndImageContentChars( + (message as { content: string | Array<{ type: string; text?: string }> }).content, + ); return Math.ceil(chars / 4); } case "assistant": { @@ -261,18 +272,7 @@ export function estimateTokens(message: AgentMessage): number { } case "custom": case "toolResult": { - if (typeof message.content === "string") { - chars = message.content.length; - } else { - for (const block of message.content) { - if (block.type === "text" && block.text) { - chars += block.text.length; - } - if (block.type === "image") { - chars += 4800; // Estimate images as 4000 chars, or 1200 tokens - } - } - } + chars = estimateTextAndImageContentChars(message.content); return Math.ceil(chars / 4); } case "bashExecution": {