Skip to content

Commit 2fe0efc

Browse files
fix: compaction safeguard summary budget (openclaw#27727)
Merged via squash. Prepared head SHA: a7ab64e Co-authored-by: Pandadadadazxf <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
1 parent 7c520cc commit 2fe0efc

File tree

10 files changed

+310
-48
lines changed

10 files changed

+310
-48
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ Docs: https://docs.openclaw.ai
210210
- Config/startup: keep bundled web-search allowlist compatibility on a lightweight manifest path so config validation no longer pulls bundled web-search registry imports into startup, while still avoiding accidental auto-allow of config-loaded override plugins. (#51574) Thanks @RichardCao.
211211
- Gateway/chat.send: persist uploaded image references across reloads and compaction without delaying first-turn dispatch or double-submitting the same image to vision models. (#51324) Thanks @fuller-stack-dev.
212212
- Plugins/runtime state: share plugin-facing infra singleton state across duplicate module graphs and keep session-binding adapter ownership stable until the active owner unregisters. (#50725) thanks @huntharo.
213+
- Agents/compaction safeguard: preserve split-turn context and preserved recent turns when capped retry fallback reuses the last successful summary. (#27727) thanks @Pandadadadazxf.
213214

214215
### Breaking
215216

extensions/openai/media-understanding-provider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
type AudioTranscriptionRequest,
66
type MediaUnderstandingProvider,
77
} from "openclaw/plugin-sdk/media-understanding";
8-
import { OPENAI_DEFAULT_AUDIO_TRANSCRIPTION_MODEL } from "../../src/providers/openai-defaults.js";
8+
import { OPENAI_DEFAULT_AUDIO_TRANSCRIPTION_MODEL } from "openclaw/plugin-sdk/provider-models";
99

1010
export const DEFAULT_OPENAI_AUDIO_BASE_URL = "https://api.openai.com/v1";
1111

extensions/openai/openai-codex-provider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ import {
1515
DEFAULT_CONTEXT_TOKENS,
1616
normalizeModelCompat,
1717
normalizeProviderId,
18+
OPENAI_CODEX_DEFAULT_MODEL,
1819
type ProviderPlugin,
1920
} from "openclaw/plugin-sdk/provider-models";
2021
import { createOpenAIAttributionHeadersWrapper } from "openclaw/plugin-sdk/provider-stream";
2122
import { fetchCodexUsage } from "openclaw/plugin-sdk/provider-usage";
22-
import { OPENAI_CODEX_DEFAULT_MODEL } from "../../src/providers/openai-defaults.js";
2323
import { buildOpenAICodexProvider } from "./openai-codex-catalog.js";
2424
import {
2525
cloneFirstTemplateModel,

extensions/telegram/src/setup-surface.test.ts

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,40 +31,62 @@ async function runFinalize(cfg: OpenClawConfig, accountId: string) {
3131
return prompter.note;
3232
}
3333

34+
function expectPreparedResult(
35+
prepared: Awaited<ReturnType<typeof runPrepare>>,
36+
): { cfg: OpenClawConfig } & Exclude<Awaited<ReturnType<typeof runPrepare>>, void | undefined> {
37+
expect(prepared).toBeDefined();
38+
if (
39+
!prepared ||
40+
typeof prepared !== "object" ||
41+
!("cfg" in prepared) ||
42+
prepared.cfg === undefined
43+
) {
44+
throw new Error("Expected prepare result with cfg");
45+
}
46+
return prepared as { cfg: OpenClawConfig } & Exclude<
47+
Awaited<ReturnType<typeof runPrepare>>,
48+
void | undefined
49+
>;
50+
}
51+
3452
describe("telegramSetupWizard.prepare", () => {
3553
it('adds groups["*"].requireMention=true for fresh setups', async () => {
36-
const prepared = await runPrepare(
37-
{
38-
channels: {
39-
telegram: {
40-
botToken: "tok",
54+
const prepared = expectPreparedResult(
55+
await runPrepare(
56+
{
57+
channels: {
58+
telegram: {
59+
botToken: "tok",
60+
},
4161
},
4262
},
43-
},
44-
DEFAULT_ACCOUNT_ID,
63+
DEFAULT_ACCOUNT_ID,
64+
),
4565
);
4666

47-
expect(prepared?.cfg.channels?.telegram?.groups).toEqual({
67+
expect(prepared.cfg.channels?.telegram?.groups).toEqual({
4868
"*": { requireMention: true },
4969
});
5070
});
5171

5272
it("preserves an explicit wildcard group mention setting", async () => {
53-
const prepared = await runPrepare(
54-
{
55-
channels: {
56-
telegram: {
57-
botToken: "tok",
58-
groups: {
59-
"*": { requireMention: false },
73+
const prepared = expectPreparedResult(
74+
await runPrepare(
75+
{
76+
channels: {
77+
telegram: {
78+
botToken: "tok",
79+
groups: {
80+
"*": { requireMention: false },
81+
},
6082
},
6183
},
6284
},
63-
},
64-
DEFAULT_ACCOUNT_ID,
85+
DEFAULT_ACCOUNT_ID,
86+
),
6587
);
6688

67-
expect(prepared?.cfg.channels?.telegram?.groups).toEqual({
89+
expect(prepared.cfg.channels?.telegram?.groups).toEqual({
6890
"*": { requireMention: false },
6991
});
7092
});

src/agents/pi-extensions/compaction-safeguard.test.ts

Lines changed: 129 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,18 @@ const {
3737
resolveQualityGuardMaxRetries,
3838
extractOpaqueIdentifiers,
3939
auditSummaryQuality,
40+
capCompactionSummary,
41+
capCompactionSummaryPreservingSuffix,
42+
formatFileOperations,
4043
computeAdaptiveChunkRatio,
4144
isOversizedForSummary,
4245
readWorkspaceContextForSummary,
4346
BASE_CHUNK_RATIO,
4447
MIN_CHUNK_RATIO,
4548
SAFETY_MARGIN,
49+
MAX_COMPACTION_SUMMARY_CHARS,
50+
MAX_FILE_OPS_SECTION_CHARS,
51+
SUMMARY_TRUNCATED_MARKER,
4652
} = __testing;
4753

4854
function stubSessionManager(): ExtensionContext["sessionManager"] {
@@ -255,6 +261,104 @@ describe("compaction-safeguard tool failures", () => {
255261
});
256262
});
257263

264+
describe("compaction-safeguard summary budgets", () => {
265+
it("caps file operations summary and reports omitted entries", () => {
266+
const readFiles = Array.from(
267+
{ length: 200 },
268+
(_, i) => `docs/very/long/path/${i}-read-file.md`,
269+
);
270+
const modifiedFiles = Array.from(
271+
{ length: 200 },
272+
(_, i) => `src/features/${i}/nested/component/file-${i}.ts`,
273+
);
274+
275+
const section = formatFileOperations(readFiles, modifiedFiles);
276+
277+
expect(section).toContain("<read-files>");
278+
expect(section).toContain("<modified-files>");
279+
expect(section).toContain("...and ");
280+
expect(section.length).toBeLessThanOrEqual(MAX_FILE_OPS_SECTION_CHARS);
281+
});
282+
283+
it("caps final compaction summary with a truncation marker", () => {
284+
const oversized = "x".repeat(MAX_COMPACTION_SUMMARY_CHARS + 500);
285+
const capped = capCompactionSummary(oversized);
286+
287+
expect(capped.length).toBeLessThanOrEqual(MAX_COMPACTION_SUMMARY_CHARS);
288+
expect(capped).toContain(SUMMARY_TRUNCATED_MARKER.trim());
289+
expect(capped.endsWith(SUMMARY_TRUNCATED_MARKER)).toBe(true);
290+
});
291+
292+
it("preserves workspace critical rules suffix when capping", () => {
293+
const suffix =
294+
"\n\n<workspace-critical-rules>\n## Session Startup\nRead AGENTS.md\n</workspace-critical-rules>";
295+
const body = "x".repeat(MAX_COMPACTION_SUMMARY_CHARS);
296+
297+
const capped = capCompactionSummaryPreservingSuffix(body, suffix);
298+
299+
expect(capped.length).toBeLessThanOrEqual(MAX_COMPACTION_SUMMARY_CHARS);
300+
expect(capped).toContain("<workspace-critical-rules>");
301+
expect(capped).toContain("## Session Startup");
302+
expect(capped.endsWith(suffix)).toBe(true);
303+
});
304+
305+
it("preserves diagnostic sections (tool failures, file ops) when capping oversized body", () => {
306+
const diagnosticSuffix =
307+
"\n\n## Tool Failures\n- exec: failed\n\n<read-files>\nfoo.ts\n</read-files>\n\n" +
308+
"<workspace-critical-rules>\n## Session Startup\nRead AGENTS.md\n</workspace-critical-rules>";
309+
const body = "x".repeat(MAX_COMPACTION_SUMMARY_CHARS);
310+
311+
const capped = capCompactionSummaryPreservingSuffix(body, diagnosticSuffix);
312+
313+
expect(capped.length).toBeLessThanOrEqual(MAX_COMPACTION_SUMMARY_CHARS);
314+
expect(capped).toContain("## Tool Failures");
315+
expect(capped).toContain("<read-files>");
316+
expect(capped).toContain("<workspace-critical-rules>");
317+
expect(capped.endsWith(diagnosticSuffix)).toBe(true);
318+
});
319+
320+
it("keeps section separator when body ends without newline (e.g. buildStructuredFallbackSummary)", () => {
321+
const bodyNoNewline = "## Exact identifiers\nNone.";
322+
const suffixNoLeadingNewline = "## Tool Failures\n- exec: failed";
323+
324+
const capped = capCompactionSummaryPreservingSuffix(
325+
bodyNoNewline,
326+
`\n\n${suffixNoLeadingNewline}`,
327+
);
328+
329+
expect(capped).toContain("None.\n\n## Tool Failures");
330+
expect(capped).not.toMatch(/None\.## Tool Failures/);
331+
});
332+
333+
it("keeps body prefix when truncation marker cannot fit (tiny budget)", () => {
334+
const body = "## Decisions\nKeep flow.\n## Constraints\nFollow rules.";
335+
const tinyBudget = 10; // Smaller than SUMMARY_TRUNCATED_MARKER.length
336+
const capped = capCompactionSummary(body, tinyBudget);
337+
338+
expect(capped.length).toBeLessThanOrEqual(tinyBudget);
339+
expect(capped).toContain("## Decis");
340+
expect(capped).not.toContain("[Compaction summary truncated");
341+
});
342+
343+
it("preserves tail sections when suffix exceeds cap (workspace rules, diagnostics over preserved turns)", () => {
344+
const criticalTail =
345+
"\n\n## Tool Failures\n- exec: failed\n\n<read-files>\nfoo.ts\n</read-files>\n\n" +
346+
"<workspace-critical-rules>\n## Session Startup\nRead AGENTS.md\n</workspace-critical-rules>";
347+
const preservedTurns =
348+
"## Recent turns preserved verbatim\n- User: x\n- Assistant: y\n" +
349+
"x".repeat(MAX_COMPACTION_SUMMARY_CHARS);
350+
const oversizedSuffix = preservedTurns + criticalTail;
351+
352+
const capped = capCompactionSummaryPreservingSuffix("short body", oversizedSuffix);
353+
354+
expect(capped.length).toBeLessThanOrEqual(MAX_COMPACTION_SUMMARY_CHARS);
355+
expect(capped).toContain("<workspace-critical-rules>");
356+
expect(capped).toContain("## Tool Failures");
357+
expect(capped).toContain("<read-files>");
358+
expect(capped).toContain("## Session Startup");
359+
});
360+
});
361+
258362
describe("computeAdaptiveChunkRatio", () => {
259363
const CONTEXT_WINDOW = 200_000;
260364

@@ -1358,17 +1462,20 @@ describe("compaction-safeguard recent-turn preservation", () => {
13581462
expect(secondCall?.customInstructions).toContain("latest_user_ask_not_reflected");
13591463
});
13601464

1361-
it("keeps last successful summary when a quality retry call fails", async () => {
1465+
it("preserves split-turn and recent-turn suffixes when retry fallback is capped", async () => {
13621466
mockSummarizeInStages.mockReset();
1467+
const oversizedHistorySummary = "history detail ".repeat(MAX_COMPACTION_SUMMARY_CHARS);
1468+
const splitTurnPrefixSummary = "split-turn prefix context that must survive capping";
13631469
mockSummarizeInStages
1364-
.mockResolvedValueOnce("short summary missing headings")
1470+
.mockResolvedValueOnce(oversizedHistorySummary)
1471+
.mockResolvedValueOnce(splitTurnPrefixSummary)
13651472
.mockRejectedValueOnce(new Error("retry transient failure"));
13661473

13671474
const sessionManager = stubSessionManager();
13681475
const model = createAnthropicModelFixture();
13691476
setCompactionSafeguardRuntime(sessionManager, {
13701477
model,
1371-
recentTurnsPreserve: 0,
1478+
recentTurnsPreserve: 1,
13721479
qualityGuardEnabled: true,
13731480
qualityGuardMaxRetries: 1,
13741481
});
@@ -1384,8 +1491,16 @@ describe("compaction-safeguard recent-turn preservation", () => {
13841491
messagesToSummarize: [
13851492
{ role: "user", content: "older context", timestamp: 1 },
13861493
{ role: "assistant", content: "older reply", timestamp: 2 } as unknown as AgentMessage,
1494+
{ role: "user", content: "latest ask status", timestamp: 3 },
1495+
{
1496+
role: "assistant",
1497+
content: [{ type: "text", text: "latest assistant reply" }],
1498+
timestamp: 4,
1499+
} as unknown as AgentMessage,
1500+
],
1501+
turnPrefixMessages: [
1502+
{ role: "user", content: "prefix request that was split out", timestamp: 0 },
13871503
],
1388-
turnPrefixMessages: [],
13891504
firstKeptEntryId: "entry-1",
13901505
tokensBefore: 1_500,
13911506
fileOps: {
@@ -1395,7 +1510,7 @@ describe("compaction-safeguard recent-turn preservation", () => {
13951510
},
13961511
settings: { reserveTokens: 4_000 },
13971512
previousSummary: undefined,
1398-
isSplitTurn: false,
1513+
isSplitTurn: true,
13991514
},
14001515
customInstructions: "",
14011516
signal: new AbortController().signal,
@@ -1407,8 +1522,15 @@ describe("compaction-safeguard recent-turn preservation", () => {
14071522
};
14081523

14091524
expect(result.cancel).not.toBe(true);
1410-
expect(result.compaction?.summary).toContain("short summary missing headings");
1411-
expect(mockSummarizeInStages).toHaveBeenCalledTimes(2);
1525+
const summary = result.compaction?.summary ?? "";
1526+
expect(summary.length).toBeLessThanOrEqual(MAX_COMPACTION_SUMMARY_CHARS);
1527+
expect(summary).toContain(SUMMARY_TRUNCATED_MARKER);
1528+
expect(summary).toContain("**Turn Context (split turn):**");
1529+
expect(summary).toContain(splitTurnPrefixSummary);
1530+
expect(summary).toContain("## Recent turns preserved verbatim");
1531+
expect(summary).toContain("latest ask status");
1532+
expect(summary).toContain("latest assistant reply");
1533+
expect(mockSummarizeInStages).toHaveBeenCalledTimes(3);
14121534
});
14131535

14141536
it("keeps required headings when all turns are preserved and history is carried forward", async () => {

0 commit comments

Comments
 (0)