refactor(agent): extracted convertMessageToLlm and removed convertToLlm alias

Splits the array-form defaultConvertToLlm into a single-message convertMessageToLlm that embedders can delegate every core role to instead of duplicating the conversion. Adds the optional images field on CompactionSummaryMessage so the converter attaches snapcompact frames after the summary text (snapcompact strategy lands next). Renames every convertToLlm call site in compaction.ts and branch-summarization.ts to the canonical defaultConvertToLlm.\n\nNote: the bundled CHANGELOG entries also cover the supersede-reads, snapcompact, and steering-queue fixes that follow in this batch (the entries land in directly adjacent lines and cannot be split by diff).
This commit is contained in:
can1357
2026-06-10 17:42:48 +02:00
parent 67ddc7a17f
commit bc5af8d9be
4 changed files with 102 additions and 72 deletions
+16
View File
@@ -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 `<read-files>` 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
@@ -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
+6 -6
View File
@@ -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<string> {
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<string> {
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 = `<conversation>\n${conversationText}\n</conversation>\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<string> {
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 = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
const summarizationMessages = [
+78 -64
View File
@@ -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;