import type {
CanonicalMessage,
CanonicalToolResultBlock,
} from "../../model/index.js";
import { COMPACTABLE_TOOL_NAMES } from "./CachedMicroCompactionEngine.js";
export const MICROCOMPACT_CLEARED = "[Old tool result content cleared]";
export type MicroCompactionInput = {
messages: CanonicalMessage[];
nowMs?: number;
idleMs?: number;
trimToBytes?: number;
};
export type MicroCompactionResult = {
messages: CanonicalMessage[];
rewritten: number;
rewrittenBytes: number;
toolCallIds: string[];
appliedTrigger: "time_based" | "skipped";
};
* Phase 5 microcompact (time-based path only — decision §3.1 #5):
* directly rewrites tool_result content in older messages so subsequent turns
* carry less context. Only targets tool_results whose originating tool_call
* is in COMPACTABLE_TOOL_NAMES. Properly accounts for multimodal content
* size (base64 data length) rather than relying on the text-only fallback.
*/
export class MicroCompactionEngine {
constructor(private readonly options: { keepLatest?: number; trimToBytes?: number } = {}) {}
apply(input: MicroCompactionInput): MicroCompactionResult {
const trimToBytes = input.trimToBytes ?? this.options.trimToBytes ?? 1536;
const keepLatest = this.options.keepLatest ?? 1;
const compactableCallIds = this.collectCompactableToolCallIds(input.messages);
const toolResultIndices = this.collectCompactableToolResultIndices(input.messages, compactableCallIds);
if (toolResultIndices.length <= keepLatest) {
return {
messages: input.messages,
rewritten: 0,
rewrittenBytes: 0,
toolCallIds: [],
appliedTrigger: "skipped",
};
}
const rewriteUntil = toolResultIndices[toolResultIndices.length - keepLatest]! - 1;
const rewrittenIds: string[] = [];
let rewrittenBytes = 0;
const messages = input.messages.map((message, index) => {
if (index > rewriteUntil) {
return message;
}
if (message.role !== "user") {
return message;
}
let touched = false;
const newContent = message.content.map((block) => {
if (block.type !== "tool_result") {
if (block.type === "image" || block.type === "pdf") {
touched = true;
rewrittenBytes += "data" in block ? (block as { data: string }).data.length : 0;
return {
type: "text" as const,
text: block.type === "image" ? "[image cleared]" : "[document cleared]",
};
}
return block;
}
if (!compactableCallIds.has(block.toolCallId)) {
return block;
}
const size = this.estimateToolResultSize(block as CanonicalToolResultBlock);
if (size <= trimToBytes) {
return block;
}
touched = true;
rewrittenIds.push(block.toolCallId);
rewrittenBytes += size;
return {
...block,
content: [
{
type: "text" as const,
text: MICROCOMPACT_CLEARED,
},
],
};
});
return touched ? { ...message, content: newContent } : message;
});
return {
messages,
rewritten: rewrittenIds.length,
rewrittenBytes,
toolCallIds: rewrittenIds,
appliedTrigger: rewrittenIds.length > 0 ? "time_based" : "skipped",
};
}
private collectCompactableToolCallIds(messages: CanonicalMessage[]): Set<string> {
const ids = new Set<string>();
for (const message of messages) {
if (message.role !== "assistant") continue;
for (const block of message.content) {
if (block.type === "tool_call" && COMPACTABLE_TOOL_NAMES.has(block.name)) {
ids.add(block.id);
}
}
}
return ids;
}
private collectCompactableToolResultIndices(
messages: CanonicalMessage[],
compactableCallIds: Set<string>,
): number[] {
const indices: number[] = [];
messages.forEach((message, index) => {
if (message.role !== "user") return;
const hasCompactable = message.content.some(
(block) => block.type === "tool_result" && compactableCallIds.has(block.toolCallId),
);
if (hasCompactable) indices.push(index);
});
return indices;
}
private estimateToolResultSize(block: CanonicalToolResultBlock): number {
let size = 0;
for (const item of block.content) {
if (item.type === "text") {
size += item.text.length;
} else if (item.type === "image" || item.type === "pdf") {
size += item.data.length;
}
}
return size;
}
}