Compare commits

...

2 Commits

Author SHA1 Message Date
Alex Knight
fd09d2e7d0 fix(compaction): anchor forced manual boundary off trailing tool results 2026-06-17 21:21:25 +10:00
Alex Knight
3aecc4ee9d Force manual compaction past empty kept-tail cuts 2026-06-17 21:21:25 +10:00
6 changed files with 248 additions and 4 deletions

View File

@@ -825,7 +825,9 @@ export class CoreAgentHarness<
throw new AgentHarnessError("auth", "No auth available for compaction");
}
const branchEntries = await this.session.getBranch();
const preparationResult = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS);
const preparationResult = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS, {
force: true,
});
if (!preparationResult.ok) {
throw preparationResult.error;
}

View File

@@ -1,7 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { createAssistantMessageEventStream } from "../../llm.js";
import type { AssistantMessage, Model, StreamFn } from "../../llm.js";
import { generateSummary } from "./compaction.js";
import { buildSessionContext } from "../session/session.js";
import type { SessionTreeEntry } from "../types.js";
import { DEFAULT_COMPACTION_SETTINGS, prepareCompaction, generateSummary } from "./compaction.js";
describe("generateSummary thinking options", () => {
it("maps explicit Fable off to low effort for compaction", async () => {
@@ -60,3 +62,197 @@ describe("generateSummary thinking options", () => {
expect(streamFn).toHaveBeenCalledOnce();
});
});
describe("prepareCompaction", () => {
function createHighUsageSmallTranscriptEntries(): SessionTreeEntry[] {
return [
{
type: "message",
id: "user-1",
parentId: null,
timestamp: "2026-06-17T08:45:00.000Z",
message: { role: "user", content: "What do you see in your history?", timestamp: 1 },
},
{
type: "message",
id: "assistant-1",
parentId: "user-1",
timestamp: "2026-06-17T08:45:10.000Z",
message: {
role: "assistant",
content: [{ type: "text", text: "Stored." }],
api: "openai-responses",
provider: "openai",
model: "gpt-test",
usage: {
input: 625,
output: 6,
cacheRead: 172_928,
cacheWrite: 0,
totalTokens: 173_559,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 2,
},
},
];
}
it("skips automatic no-op summaries when usage is high but transcript text is below the kept-tail budget", () => {
const entries = createHighUsageSmallTranscriptEntries();
const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS);
expect(preparation).toEqual({ ok: true, value: undefined });
});
it("forces manual preparation when usage is high but transcript text is below the kept-tail budget", () => {
const entries = createHighUsageSmallTranscriptEntries();
const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS, { force: true });
expect(preparation).toEqual({
ok: true,
value: expect.objectContaining({
firstKeptEntryId: "assistant-1",
messagesToSummarize: entries.map((entry) =>
entry.type === "message" ? entry.message : undefined,
),
tokensBefore: 173_559,
turnPrefixMessages: [],
}),
});
});
it("anchors a forced boundary on the assistant tool call, not a trailing tool result", () => {
const entries: SessionTreeEntry[] = [
{
type: "message",
id: "user-1",
parentId: null,
timestamp: "2026-06-17T08:45:00.000Z",
message: { role: "user", content: "Read the notes file.", timestamp: 1 },
},
{
type: "message",
id: "assistant-1",
parentId: "user-1",
timestamp: "2026-06-17T08:45:10.000Z",
message: {
role: "assistant",
content: [
{ type: "toolCall", id: "call-1", name: "read_file", arguments: { path: "notes.md" } },
],
api: "openai-responses",
provider: "openai",
model: "gpt-test",
usage: {
input: 625,
output: 6,
cacheRead: 172_928,
cacheWrite: 0,
totalTokens: 173_559,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: 2,
},
},
{
type: "message",
id: "tool-1",
parentId: "assistant-1",
timestamp: "2026-06-17T08:45:11.000Z",
message: {
role: "toolResult",
toolCallId: "call-1",
toolName: "read_file",
content: [{ type: "text", text: "notes body" }],
isError: false,
timestamp: 3,
},
},
];
const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS, { force: true });
// Anchor must be the assistant that owns the tool call, never the trailing
// tool result, or the rebuilt context would replay an orphaned tool result.
expect(preparation).toEqual({
ok: true,
value: expect.objectContaining({ firstKeptEntryId: "assistant-1" }),
});
const compactedContext = buildSessionContext([
...entries,
{
type: "compaction",
id: "compaction-1",
parentId: "tool-1",
timestamp: "2026-06-17T08:45:20.000Z",
summary: "Checkpoint of the file read.",
firstKeptEntryId: "assistant-1",
tokensBefore: 173_559,
},
]);
expect(compactedContext.messages.map((message) => message.role)).toEqual([
"compactionSummary",
"assistant",
"toolResult",
]);
});
it("shows why the old empty-summary compaction replayed the whole transcript", () => {
const entries: SessionTreeEntry[] = [
{
type: "message",
id: "user-1",
parentId: null,
timestamp: "2026-06-17T08:45:00.000Z",
message: { role: "user", content: "What do you see in your history?", timestamp: 1 },
},
{
type: "message",
id: "assistant-1",
parentId: "user-1",
timestamp: "2026-06-17T08:45:10.000Z",
message: {
role: "assistant",
content: [{ type: "text", text: "Stored." }],
api: "openai-responses",
provider: "openai",
model: "gpt-test",
usage: {
input: 625,
output: 6,
cacheRead: 172_928,
cacheWrite: 0,
totalTokens: 173_559,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 2,
},
},
];
const compactedContext = buildSessionContext([
...entries,
{
type: "compaction",
id: "compaction-1",
parentId: "assistant-1",
timestamp: "2026-06-17T08:45:20.000Z",
summary: "No prior conversation content provided.",
firstKeptEntryId: "user-1",
tokensBefore: 173_559,
},
]);
expect(compactedContext.messages.map((message) => message.role)).toEqual([
"compactionSummary",
"user",
"assistant",
]);
});
});

View File

@@ -626,10 +626,16 @@ export interface CompactionPreparation {
settings: CompactionSettings;
}
export interface CompactionPreparationOptions {
/** Prepare a real summary even when the kept-tail heuristic would otherwise summarize nothing. */
force?: boolean;
}
/** Prepare session entries for compaction, or return undefined when compaction is not applicable. */
export function prepareCompaction(
pathEntries: SessionTreeEntry[],
settings: CompactionSettings,
options: CompactionPreparationOptions = {},
): Result<CompactionPreparation | undefined, CompactionError> {
if (pathEntries.length === 0 || pathEntries[pathEntries.length - 1].type === "compaction") {
return ok(undefined);
@@ -686,6 +692,41 @@ export function prepareCompaction(
}
}
}
if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) {
if (options.force === true) {
const forcedMessagesToSummarize: AgentMessage[] = [];
for (let i = boundaryStart; i < boundaryEnd; i++) {
const msg = getMessageFromEntryForCompaction(pathEntries[i]);
if (msg) {
forcedMessagesToSummarize.push(msg);
}
}
// Anchor the kept tail on the last valid cut point, not the raw final entry.
// findValidCutPoints excludes tool results, so a forced boundary that is not
// collapsed to summary-only later never keeps an orphaned tool result.
const forcedCutPoints = findValidCutPoints(pathEntries, boundaryStart, boundaryEnd);
const forcedKeepIndex =
forcedCutPoints.length > 0 ? forcedCutPoints[forcedCutPoints.length - 1] : -1;
if (forcedMessagesToSummarize.length > 0 && forcedKeepIndex >= 0) {
const forcedFileOps = extractFileOperations(
forcedMessagesToSummarize,
pathEntries,
prevCompactionIndex,
);
return ok({
firstKeptEntryId: pathEntries[forcedKeepIndex].id,
messagesToSummarize: forcedMessagesToSummarize,
turnPrefixMessages: [],
isSplitTurn: false,
tokensBefore,
previousSummary,
fileOps: forcedFileOps,
settings,
});
}
}
return ok(undefined);
}
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
if (cutPoint.isSplitTurn) {
for (const msg of turnPrefixMessages) {

View File

@@ -46,6 +46,7 @@ export {
shouldCompact,
type CompactionDetails,
type CompactionPreparation,
type CompactionPreparationOptions,
type CompactionResult,
type CompactionSettings,
type ContextUsageEstimate,

View File

@@ -1920,7 +1920,9 @@ export class AgentSession {
}
const pathEntries = this.sessionManager.getBranch();
const preparation = unwrapCoreResult(prepareCompaction(pathEntries, options.settings));
const preparation = unwrapCoreResult(
prepareCompaction(pathEntries, options.settings, { force: isManual }),
);
if (!preparation) {
if (isManual) {
const lastEntry = pathEntries[pathEntries.length - 1];

View File

@@ -21,6 +21,7 @@ import {
openClawAgentCoreRuntime,
type CompactionDetails,
type CompactionPreparation,
type CompactionPreparationOptions,
type CompactionResult,
type CompactionSettings,
type ContextUsageEstimate,
@@ -58,8 +59,9 @@ function unwrapCompactionResult<T>(result: Result<T, Error>): T {
export function prepareCompaction(
pathEntries: SessionEntry[],
settings: CompactionSettings,
options?: CompactionPreparationOptions,
): CompactionPreparation | undefined {
return unwrapCompactionResult(prepareCompactionCore(pathEntries, settings));
return unwrapCompactionResult(prepareCompactionCore(pathEntries, settings, options));
}
/** Generates a compaction summary through the shared agent-core runtime. */