diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index bff557c20..607bfc6ee 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,22 @@ ## [Unreleased] +### Breaking Changes + +- Removed the `convertToLlm` alias export from `compaction/messages` — it duplicated `defaultConvertToLlm` under a second name. Import `defaultConvertToLlm` (array form) or the new `convertMessageToLlm` (single-message form) instead + +### Added + +- Added `convertMessageToLlm()`: the single-message core transformer behind `defaultConvertToLlm()`. Embedders with app-specific message roles should handle their own roles and delegate every core role (`user`/`developer`/`assistant`/`toolResult`/`custom`/`hookMessage`/`branchSummary`/`compactionSummary`) to it instead of duplicating the conversion — a duplicated `compactionSummary` case is how snapcompact frames once silently dropped off provider requests +- Added `pruneSupersededToolResults()` and the opt-in `PruneConfig.supersedeKey` hook so harnesses can prune stale tool results superseded by a newer read of the same file; superseded results are pruned ahead of age-based victims during overflow pruning and replaced with a `[Superseded by a newer read of this file]` placeholder. Without the new config, `pruneToolOutputs()` behavior is unchanged. +- Added `readToolSupersedeKey()` implementing the read-tool path/selector grammar (selector-free reads supersede range reads of the same file; URL-scheme paths exempt). Pruning honors prompt-cache economics: per-turn prunes only fire when the post-candidate suffix is small or the cache is cold (idle gap). +- Added the `snapcompact` compaction strategy (`snapcompactCompact()` in `compaction/snapcompact.ts`): instead of an LLM summary, discarded history is printed onto dense 2576px PNG frames with the public-domain X.org `5x8` pixel font (ink cycles per sentence) and re-attached to the compaction summary message as image blocks. Fully local — no model call; ~7x cheaper than raw text at near-parity recall. `CompactionSummaryMessage` gains an optional `images` field, `estimateTokens()` charges per attached frame, and frames persist under `preserveData.snapcompact` with an 8-frame budget that evicts middle-out: the session-head frame is pinned, the oldest unpinned frames drop first, so head and tail both survive. Rasterization and PNG encoding run in native code (`renderSnapcompactPng()` from `@oh-my-pi/pi-natives`), emitting 4-bit indexed PNGs + +### Fixed + +- Fixed queued steering messages being drained into an externally aborted run: interrupting mid-tool execution (e.g. Enter with a pending steer) dequeued the steer into the dying run — it landed in history without a response and the post-abort resume saw an empty queue, so the agent stopped instead of continuing. Steering/follow-up/aside queue polls are now skipped once the run's abort signal fires, leaving the queue intact for `Agent.continue()`. +- Fixed `` compaction lists recording the same file once per line-range/raw selector (`src/foo.ts:50-200`, `:raw`, `:1-50:raw`, …): read-tool selectors are now stripped before tracking, so reads dedupe to the base path and match their write/edit path when splitting read-only vs modified lists. Selector-polluted lists stored by earlier compactions self-heal on the next compaction. `readToolSupersedeKey()` now shares the same splitter (`splitReadSelector()`), gaining the `..` range alias and `L`-prefix forms it previously missed. + ## [15.10.12] - 2026-06-10 ### Added diff --git a/packages/agent/src/compaction/branch-summarization.ts b/packages/agent/src/compaction/branch-summarization.ts index 3cb73de02..c2d10b630 100644 --- a/packages/agent/src/compaction/branch-summarization.ts +++ b/packages/agent/src/compaction/branch-summarization.ts @@ -13,10 +13,10 @@ import { estimateTokens } from "./compaction"; import type { ReadonlySessionManager, SessionEntry } from "./entries"; import { type ConvertToLlm, - convertToLlm, createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, + defaultConvertToLlm, } from "./messages"; import branchSummaryPrompt from "./prompts/branch-summary.md" with { type: "text" }; import branchSummaryPreamble from "./prompts/branch-summary-preamble.md" with { type: "text" }; @@ -288,7 +288,7 @@ export async function generateBranchSummary( // Transform to LLM-compatible messages, then serialize to text // Serialization prevents the model from treating it as a conversation to continue - const llmMessages = (options.convertToLlm ?? convertToLlm)(messages); + const llmMessages = (options.convertToLlm ?? defaultConvertToLlm)(messages); const conversationText = serializeConversation(llmMessages); // Build prompt diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index 53d0ad464..1b23c2669 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -22,7 +22,7 @@ import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry"; import { ThinkingLevel } from "../thinking"; import type { AgentMessage } from "../types"; import type { CompactionEntry, SessionEntry } from "./entries"; -import { type ConvertToLlm, convertToLlm, createBranchSummaryMessage, createCustomMessage } from "./messages"; +import { type ConvertToLlm, createBranchSummaryMessage, createCustomMessage, defaultConvertToLlm } from "./messages"; import { buildOpenAiNativeHistory, getPreservedOpenAiRemoteCompactionData, @@ -625,7 +625,7 @@ export async function generateSummary( // Serialize conversation to text so model doesn't try to continue it // Convert to LLM messages first (handles custom app messages when caller provides a transformer). - const llmMessages = (options?.convertToLlm ?? convertToLlm)(currentMessages); + const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(currentMessages); const conversationText = serializeConversation(llmMessages); // Build the prompt with conversation wrapped in tags @@ -724,7 +724,7 @@ export async function generateHandoff( options: HandoffOptions, signal?: AbortSignal, ): Promise { - const llmMessages = (options.convertToLlm ?? convertToLlm)(messages); + const llmMessages = (options.convertToLlm ?? defaultConvertToLlm)(messages); const requestMessages: Message[] = [ ...llmMessages, { @@ -773,7 +773,7 @@ async function generateShortSummary( options?: SummaryOptions, ): Promise { const maxTokens = Math.min(512, Math.floor(0.2 * reserveTokens)); - const llmMessages = (options?.convertToLlm ?? convertToLlm)(recentMessages); + const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(recentMessages); const conversationText = serializeConversation(llmMessages); let promptText = `\n${conversationText}\n\n\n`; @@ -1010,7 +1010,7 @@ export async function compact( ? previousRemoteCompaction.replacementHistory : undefined; const remoteHistory = buildOpenAiNativeHistory( - (summaryOptions.convertToLlm ?? convertToLlm)(remoteMessages), + (summaryOptions.convertToLlm ?? defaultConvertToLlm)(remoteMessages), model, previousReplacementHistory, ); @@ -1127,7 +1127,7 @@ async function generateTurnPrefixSummary( ): Promise { const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix - const llmMessages = (options?.convertToLlm ?? convertToLlm)(messages); + const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages); const conversationText = serializeConversation(llmMessages); const promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; const summarizationMessages = [ diff --git a/packages/agent/src/compaction/messages.ts b/packages/agent/src/compaction/messages.ts index 93ae21b4f..149996e79 100644 --- a/packages/agent/src/compaction/messages.ts +++ b/packages/agent/src/compaction/messages.ts @@ -51,6 +51,8 @@ export interface CompactionSummaryMessage { shortSummary?: string; tokensBefore: number; providerPayload?: ProviderPayload; + /** Snapcompact frames archived by this compaction; appended as image blocks after the summary text. */ + images?: ImageContent[]; timestamp: number; } @@ -98,6 +100,7 @@ export function createCompactionSummaryMessage( timestamp: string, shortSummary?: string, providerPayload?: ProviderPayload, + images?: ImageContent[], ): CompactionSummaryMessage { return { role: "compactionSummary", @@ -105,6 +108,7 @@ export function createCompactionSummaryMessage( shortSummary, tokensBefore, providerPayload, + images: images && images.length > 0 ? images : undefined, timestamp: new Date(timestamp).getTime(), }; } @@ -137,6 +141,79 @@ function isCoreCompactionMessage(message: AgentMessage): message is AgentMessage ); } +/** + * Transform a single core-domain agent message to its LLM form; `undefined` + * drops it from the provider request. + * + * Single source of truth for the core roles (user/developer/assistant/ + * toolResult) and the compaction messages owned by this package. Embedders + * with their own app messages (e.g. the coding agent) handle their custom + * roles and delegate every core role here — duplicating these cases is how + * snapcompact frames once silently fell off the provider request. + */ +export function convertMessageToLlm(message: AgentMessage): Message | undefined { + if (isCoreCompactionMessage(message)) { + switch (message.role) { + case "custom": + case "hookMessage": { + const content = + typeof message.content === "string" + ? [{ type: "text" as const, text: message.content }] + : message.content; + return { + role: "developer", + content, + attribution: message.attribution, + timestamp: message.timestamp, + }; + } + case "branchSummary": + return { + role: "user", + content: [ + { + type: "text" as const, + text: renderBranchSummaryContext(message.summary), + }, + ], + attribution: "agent", + timestamp: message.timestamp, + }; + case "compactionSummary": + return { + role: "user", + content: [ + { + type: "text" as const, + text: renderCompactionSummaryContext(message.summary), + }, + ...(message.images ?? []), + ], + attribution: "agent", + providerPayload: message.providerPayload, + timestamp: message.timestamp, + }; + } + } + + switch (message.role) { + case "user": + return { ...message, attribution: message.attribution ?? "user" }; + case "developer": + return { ...message, attribution: message.attribution ?? "agent" }; + case "assistant": + return message as AssistantMessage; + case "toolResult": + return { + ...message, + content: getPrunedToolResultContent(message as ToolResultMessage), + attribution: message.attribution ?? "agent", + }; + default: + return undefined; + } +} + /** * Default compaction-domain transformer. * @@ -145,68 +222,5 @@ function isCoreCompactionMessage(message: AgentMessage): message is AgentMessage * core LLM roles and the compaction messages owned by this package. */ export function defaultConvertToLlm(messages: AgentMessage[]): Message[] { - return messages - .map((message): Message | undefined => { - if (isCoreCompactionMessage(message)) { - switch (message.role) { - case "custom": - case "hookMessage": { - const content = - typeof message.content === "string" - ? [{ type: "text" as const, text: message.content }] - : message.content; - return { - role: "developer", - content, - attribution: message.attribution, - timestamp: message.timestamp, - }; - } - case "branchSummary": - return { - role: "user", - content: [ - { - type: "text" as const, - text: renderBranchSummaryContext(message.summary), - }, - ], - attribution: "agent", - timestamp: message.timestamp, - }; - case "compactionSummary": - return { - role: "user", - content: [ - { - type: "text" as const, - text: renderCompactionSummaryContext(message.summary), - }, - ], - attribution: "agent", - providerPayload: message.providerPayload, - timestamp: message.timestamp, - }; - } - } - - switch (message.role) { - case "user": - return { ...message, attribution: message.attribution ?? "user" }; - case "developer": - return { ...message, attribution: message.attribution ?? "agent" }; - case "assistant": - return message as AssistantMessage; - case "toolResult": - return { - ...message, - content: getPrunedToolResultContent(message as ToolResultMessage), - attribution: message.attribution ?? "agent", - }; - default: - return undefined; - } - }) - .filter(message => message !== undefined); + return messages.map(convertMessageToLlm).filter(message => message !== undefined); } -export const convertToLlm = defaultConvertToLlm;