From 97cd06b946fed5860bbb59eaa43c686b48998ab1 Mon Sep 17 00:00:00 2001 From: RoboClaw Date: Tue, 25 Aug 2026 03:44:07 -0700 Subject: [PATCH] fix(compaction): retain audited facts when summaries exceed cap (#128968) * fix(compaction): retain audited facts under summary cap * fix(compaction): retain bounded ask context --------- Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> --- .../compaction-safeguard-quality.ts | 146 ++++++++++++- .../agent-hooks/compaction-safeguard.test.ts | 191 +++++++++++++++++- .../agent-hooks/compaction-safeguard.ts | 108 +++++++++- 3 files changed, 426 insertions(+), 19 deletions(-) diff --git a/src/agents/agent-hooks/compaction-safeguard-quality.ts b/src/agents/agent-hooks/compaction-safeguard-quality.ts index f49deff45ce1..f497a790e451 100644 --- a/src/agents/agent-hooks/compaction-safeguard-quality.ts +++ b/src/agents/agent-hooks/compaction-safeguard-quality.ts @@ -1,6 +1,7 @@ /** Quality contract, fallback, and audit helpers for compaction safeguard summaries. */ import { localeLowercasePreservingWhitespace } from "@openclaw/normalization-core/string-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { extractKeywords, isQueryStopWordToken } from "../../memory-host-sdk/query.js"; import type { CompactionSummarizationInstructions } from "../compaction.js"; import { wrapUntrustedPromptDataBlock } from "../sanitize-for-prompt.js"; @@ -18,6 +19,7 @@ const REQUIRED_SUMMARY_SECTIONS = [ "## Pending user asks", "## Exact identifiers", ] as const; +const QUALITY_PROTECTED_SECTION_START = 3; const STRICT_EXACT_IDENTIFIERS_INSTRUCTION = "For ## Exact identifiers, preserve literal values exactly as seen (IDs, URLs, file paths, ports, hashes, dates, times)."; const POLICY_OFF_EXACT_IDENTIFIERS_INSTRUCTION = @@ -93,6 +95,128 @@ function hasRequiredSummarySections(summary: string): boolean { return true; } +type SummaryQualityRetentionPlan = { + minimumChars: number; + render: (maxChars: number) => string | null; +}; + +function parseRequiredSummarySectionContents(summary: string): string[] | null { + const contents = REQUIRED_SUMMARY_SECTIONS.map(() => new Array()); + const preamble: string[] = []; + let sectionIndex = -1; + + for (const line of summary.split(/\r?\n/u)) { + const nextHeading = REQUIRED_SUMMARY_SECTIONS[sectionIndex + 1]; + if (nextHeading && line.trim() === nextHeading) { + sectionIndex += 1; + continue; + } + (sectionIndex < 0 ? preamble : contents[sectionIndex])?.push(line); + } + if (sectionIndex !== REQUIRED_SUMMARY_SECTIONS.length - 1) { + return null; + } + contents[0]?.unshift(...preamble); + return contents.map((lines) => lines.join("\n").trim()); +} + +/** Plan truncation that keeps audit-required headings, pending asks, and exact identifiers. */ +export function createSummaryQualityRetentionPlan( + summary: string, + truncatedMarker: string, + params: { + auditSummary?: string; + identifiers: string[]; + latestAsk: string | null; + requiredAskContext?: string; + identifierPolicy?: CompactionSummarizationInstructions["identifierPolicy"]; + }, +): SummaryQualityRetentionPlan | null { + const contents = parseRequiredSummarySectionContents(summary); + if (!contents) { + return null; + } + const enforceIdentifiers = (params.identifierPolicy ?? "strict") === "strict"; + const auditSummary = params.auditSummary ?? summary; + if ( + enforceIdentifiers && + params.identifiers.some((identifier) => !summaryIncludesIdentifier(auditSummary, identifier)) + ) { + return null; + } + if (!hasAskOverlap(auditSummary, params.latestAsk)) { + return null; + } + const pendingAsk = contents[QUALITY_PROTECTED_SECTION_START] ?? ""; + const requiredAskContext = params.requiredAskContext?.trim() ?? ""; + const exactIdentifiers = contents[QUALITY_PROTECTED_SECTION_START + 1] ?? ""; + const missingIdentifiers = enforceIdentifiers + ? params.identifiers.filter( + (identifier) => !summaryIncludesIdentifier(exactIdentifiers, identifier), + ) + : []; + const protectedContents = [ + [ + pendingAsk, + requiredAskContext && !pendingAsk.includes(requiredAskContext) ? requiredAskContext : "", + ] + .filter(Boolean) + .join("\n"), + [exactIdentifiers, ...missingIdentifiers].filter(Boolean).join("\n"), + ]; + const marker = truncatedMarker.trim(); + const protectedBlocks = REQUIRED_SUMMARY_SECTIONS.slice(QUALITY_PROTECTED_SECTION_START).map( + (heading, index) => { + const content = protectedContents[index]; + return content ? `${heading}\n${content}` : heading; + }, + ); + const optionalHeadings = REQUIRED_SUMMARY_SECTIONS.slice(0, QUALITY_PROTECTED_SECTION_START); + const optionalContents = contents.slice(0, QUALITY_PROTECTED_SECTION_START); + const optionalScaffolds = optionalHeadings.map((heading, index) => + optionalContents[index] ? `${heading}\n` : heading, + ); + const minimumSummary = [...optionalScaffolds, marker, ...protectedBlocks].join("\n\n"); + + return { + minimumChars: minimumSummary.length, + render(maxChars) { + const bodyHasRequiredAskContext = !requiredAskContext || summary.includes(requiredAskContext); + const bodyHasIdentifiers = + !enforceIdentifiers || + params.identifiers.every((identifier) => summaryIncludesIdentifier(summary, identifier)); + if (summary.length <= maxChars && bodyHasRequiredAskContext && bodyHasIdentifiers) { + return summary; + } + if (maxChars < minimumSummary.length) { + return null; + } + const contentBudget = maxChars - minimumSummary.length; + const totalContentChars = optionalContents.reduce( + (total, content) => total + content.length, + 0, + ); + const allocations = optionalContents.map((content) => + totalContentChars > 0 + ? Math.floor((contentBudget * content.length) / totalContentChars) + : 0, + ); + let remainder = contentBudget - allocations.reduce((total, chars) => total + chars, 0); + for (const [index, content] of optionalContents.entries()) { + const allocation = allocations[index] ?? 0; + const extra = Math.min(remainder, Math.max(0, content.length - allocation)); + allocations[index] = allocation + extra; + remainder -= extra; + } + const optionalBlocks = optionalHeadings.map((heading, index) => { + const content = truncateUtf16Safe(optionalContents[index] ?? "", allocations[index] ?? 0); + return content ? `${heading}\n${content}` : heading; + }); + return [...optionalBlocks, marker, ...protectedBlocks].join("\n\n"); + }, + }; +} + /** Return a structured fallback summary when model output is missing/invalid. */ export function buildStructuredFallbackSummary(previousSummary: string | undefined): string { const trimmedPreviousSummary = previousSummary?.trim() ?? ""; @@ -174,24 +298,36 @@ function tokenizeAskOverlapText(text: string): string[] { .filter((token) => token.length > 0); } -function hasAskOverlap(summary: string, latestAsk: string | null): boolean { +function resolveAskOverlapRequirement(latestAsk: string | null): { + tokens: string[]; + requiredMatches: number; +} | null { if (!latestAsk) { - return true; + return null; } const askTokens = uniqueStrings(tokenizeAskOverlapText(latestAsk)).slice( 0, MAX_ASK_OVERLAP_TOKENS, ); if (askTokens.length === 0) { - return true; + return null; } const meaningfulAskTokens = askTokens.filter( (token) => token.length > 1 && !isQueryStopWordToken(token), ); const tokensToCheck = meaningfulAskTokens.length > 0 ? meaningfulAskTokens : askTokens; - const summaryTokens = new Set(tokenizeAskOverlapText(summary)); - const overlapCount = tokensToCheck.filter((token) => summaryTokens.has(token)).length; const requiredMatches = tokensToCheck.length >= MIN_ASK_OVERLAP_TOKENS_FOR_DOUBLE_MATCH ? 2 : 1; + return { tokens: tokensToCheck, requiredMatches }; +} + +function hasAskOverlap(summary: string, latestAsk: string | null): boolean { + const requirement = resolveAskOverlapRequirement(latestAsk); + if (!requirement) { + return true; + } + const summaryTokens = new Set(tokenizeAskOverlapText(summary)); + const overlapCount = requirement.tokens.filter((token) => summaryTokens.has(token)).length; + const { requiredMatches } = requirement; return overlapCount >= requiredMatches; } diff --git a/src/agents/agent-hooks/compaction-safeguard.test.ts b/src/agents/agent-hooks/compaction-safeguard.test.ts index ea23f7ac9109..b114deb176d9 100644 --- a/src/agents/agent-hooks/compaction-safeguard.test.ts +++ b/src/agents/agent-hooks/compaction-safeguard.test.ts @@ -625,6 +625,86 @@ describe("compaction-safeguard summary budgets", () => { expect(capped).toContain(""); expect(capped).toContain("## Session Startup"); }); + + it("moves split-turn quality facts into a short body before suffix pressure", () => { + const latestAsk = "delete production only after verified backup"; + const carriedIdentifier = "/tmp/carried-forward.log"; + const identifier = "/tmp/split-turn-short-body.log"; + const body = [ + "## Decisions", + "Keep current flow.", + "## Open TODOs", + "None.", + "## Constraints/Rules", + "Preserve exact context.", + "## Pending user asks", + "Continue the active work.", + "## Exact identifiers", + carriedIdentifier, + ].join("\n"); + const suffix = `\n\n**Turn Context (split turn):**\n${latestAsk}\n${identifier}\n${"z".repeat( + MAX_COMPACTION_SUMMARY_CHARS, + )}`; + const auditSummary = `${body}${suffix}`; + const finalized = requireRecord( + budgetCompactionSummary(body, suffix, MAX_COMPACTION_SUMMARY_CHARS, { + auditSummary, + identifiers: [identifier], + latestAsk, + requiredAskContext: latestAsk, + identifierPolicy: "strict", + }), + ); + if (typeof finalized.summary !== "string" || typeof finalized.structuralSummary !== "string") { + throw new Error("expected finalized summary strings"); + } + const summary = finalized.summary; + const structuralSummary = finalized.structuralSummary; + + expect(summary.length).toBeLessThanOrEqual(MAX_COMPACTION_SUMMARY_CHARS); + expect(structuralSummary).toContain(latestAsk); + expect(structuralSummary).toContain(identifier); + expect(structuralSummary).toContain(carriedIdentifier); + expect(auditSummaryQuality({ summary, identifiers: [identifier], latestAsk }).ok).toBe(true); + }); + + it("moves a normal latest ask out of prose that final budgeting trims", () => { + const latestAsk = "delete production only after verified backup"; + const identifier = "/tmp/normal-turn-retention.log"; + const body = [ + "## Decisions", + latestAsk, + "x".repeat(MAX_COMPACTION_SUMMARY_CHARS), + "## Open TODOs", + "None.", + "## Constraints/Rules", + "Preserve exact context.", + "## Pending user asks", + "Continue the active work.", + "## Exact identifiers", + identifier, + ].join("\n"); + const finalized = requireRecord( + budgetCompactionSummary(body, "", MAX_COMPACTION_SUMMARY_CHARS, { + auditSummary: body, + identifiers: [identifier], + latestAsk, + requiredAskContext: latestAsk, + identifierPolicy: "strict", + }), + ); + if (typeof finalized.summary !== "string" || typeof finalized.structuralSummary !== "string") { + throw new Error("expected finalized summary strings"); + } + + expect(finalized.summary.length).toBeLessThanOrEqual(MAX_COMPACTION_SUMMARY_CHARS); + expect(finalized.structuralSummary).toContain( + `## Pending user asks\nContinue the active work.\n${latestAsk}`, + ); + expect( + auditSummaryQuality({ summary: finalized.summary, identifiers: [identifier], latestAsk }).ok, + ).toBe(true); + }); }); describe("computeAdaptiveChunkRatio", () => { @@ -2012,7 +2092,7 @@ describe("compaction-safeguard recent-turn preservation", () => { expect(warning).not.toContain(sensitiveSentinel); }); - it("rejects a summary whose finalized bytes fail the quality audit", async () => { + it("preserves audit-required tail sections when an earlier section exhausts the budget", async () => { mockSummarizeInStages.mockReset(); const latestAsk = "preserve the pending deployment status"; const identifier = "/tmp/compaction-final-audit.log"; @@ -2058,12 +2138,113 @@ describe("compaction-safeguard recent-turn preservation", () => { const { result } = await runCompactionScenario({ sessionManager, event, apiKey: "test-key" }); + const summary = expectCompactionResult(result).summary; + expect(summary.length).toBeLessThanOrEqual(MAX_COMPACTION_SUMMARY_CHARS); + expect(summary).toContain(SUMMARY_TRUNCATED_MARKER.trim()); + expect(summary).toContain("## Open TODOs"); + expect(summary).toContain("## Constraints/Rules"); + expect(summary).toContain(`## Pending user asks\n${latestAsk}`); + expect(summary).toContain(`## Exact identifiers\n${identifier}`); + expect(mockSummarizeInStages).toHaveBeenCalledTimes(1); + expect(consumeCompactionSafeguardCancelReason(sessionManager)).toBeNull(); + }); + + it("fails closed when audit-required tail sections cannot fit the artifact cap", async () => { + mockSummarizeInStages.mockReset(); + const latestAsk = "preserve the pending deployment status"; + const identifier = `https://example.com/${"a".repeat(MAX_COMPACTION_SUMMARY_CHARS)}`; + const oversizedRequiredTail = [ + "## Decisions", + "Keep current flow.", + "## Open TODOs", + "None.", + "## Constraints/Rules", + "Preserve exact context.", + "## Pending user asks", + latestAsk, + "## Exact identifiers", + identifier, + ].join("\n"); + mockSummarizeInStages.mockResolvedValue(summaryResult(oversizedRequiredTail)); + + const sessionManager = stubSessionManager(); + setCompactionSafeguardRuntime(sessionManager, { + model: createAnthropicModelFixture(), + recentTurnsPreserve: 0, + qualityGuardEnabled: true, + qualityGuardMaxRetries: 0, + }); + const event = createCompactionEvent({ + messageText: `${latestAsk} ${identifier}`, + tokensBefore: 1_500, + }); + ( + event.preparation as { settings?: { reserveTokens: number }; isSplitTurn?: boolean } + ).settings = { reserveTokens: 4_000 }; + (event.preparation as { isSplitTurn?: boolean }).isSplitTurn = false; + + const { result } = await runCompactionScenario({ sessionManager, event, apiKey: "test-key" }); + expect(result).toEqual({ cancel: true }); + expect(mockSummarizeInStages).toHaveBeenCalledTimes(1); + expect(consumeCompactionSafeguardCancelReason(sessionManager)).toBe( + "Compaction safeguard required facts exceed the finalized summary budget.", + ); + }); + + it("reserves split-turn ask evidence and identifiers before optional split context", async () => { + mockSummarizeInStages.mockReset(); + const latestAsk = "preserve the deployment status"; + const identifier = "/tmp/split-turn-retention.log"; + const historySummary = [ + "## Decisions", + "x".repeat(MAX_COMPACTION_SUMMARY_CHARS), + "## Open TODOs", + "None.", + "## Constraints/Rules", + "Preserve exact context.", + "## Pending user asks", + "Continue the active work.", + "## Exact identifiers", + "None.", + ].join("\n"); + const splitSummary = `${latestAsk} ${identifier} ${"z".repeat(MAX_COMPACTION_SUMMARY_CHARS)}`; + mockSummarizeInStages + .mockResolvedValueOnce(summaryResult(historySummary)) + .mockResolvedValueOnce(summaryResult(splitSummary)); + + const sessionManager = stubSessionManager(); + setCompactionSafeguardRuntime(sessionManager, { + model: createAnthropicModelFixture(), + recentTurnsPreserve: 0, + qualityGuardEnabled: true, + qualityGuardMaxRetries: 1, + }); + const event = { + preparation: { + messagesToSummarize: [ + { role: "user", content: "summarize earlier work", timestamp: 1 }, + ] as AgentMessage[], + turnPrefixMessages: [ + { role: "user", content: `${latestAsk} ${identifier}`, timestamp: 2 }, + ] as AgentMessage[], + firstKeptEntryId: "entry-1", + tokensBefore: 1_500, + fileOps: { read: [], edited: [], written: [] }, + settings: { reserveTokens: 4_000 }, + isSplitTurn: true, + }, + customInstructions: "", + signal: new AbortController().signal, + }; + + const { result } = await runCompactionScenario({ sessionManager, event, apiKey: "test-key" }); + + const summary = expectCompactionResult(result).summary; + expect(summary.length).toBeLessThanOrEqual(MAX_COMPACTION_SUMMARY_CHARS); + expect(summary).toContain(identifier); + expect(auditSummaryQuality({ summary, identifiers: [identifier], latestAsk }).ok).toBe(true); expect(mockSummarizeInStages).toHaveBeenCalledTimes(2); - const reason = consumeCompactionSafeguardCancelReason(sessionManager); - expect(reason).toContain("finalized summary failed quality checks"); - expect(reason).not.toContain(identifier); - expect(reason).not.toContain(latestAsk); }); it("returns the first finalized retry that passes the source audit", async () => { diff --git a/src/agents/agent-hooks/compaction-safeguard.ts b/src/agents/agent-hooks/compaction-safeguard.ts index 0f9f89e528d8..2b41e32c1c95 100644 --- a/src/agents/agent-hooks/compaction-safeguard.ts +++ b/src/agents/agent-hooks/compaction-safeguard.ts @@ -62,6 +62,7 @@ import { auditSummaryQuality, buildCompactionStructureInstructions, buildStructuredFallbackSummary, + createSummaryQualityRetentionPlan, extractOpaqueIdentifiers, wrapUntrustedInstructionBlock, } from "./compaction-safeguard-quality.js"; @@ -90,6 +91,8 @@ const DEFAULT_QUALITY_GUARD_MAX_RETRIES = 1; const MAX_RECENT_TURNS_PRESERVE = 12; const MAX_QUALITY_GUARD_MAX_RETRIES = 3; const MAX_RECENT_TURN_TEXT_CHARS = 600; +const MAX_REQUIRED_ASK_CONTEXT_CHARS = 2_000; +const REQUIRED_ASK_CONTEXT_TRUNCATED_MARKER = "\n[... split-turn ask context truncated ...]\n"; const TOOL_CALL_BLOCK_TYPES = new Set(["toolCall", "toolUse", "functionCall"]); const PREVIOUS_SUMMARY_REDISTILL_PREFIX = "Previous compaction summary to re-distill with the current conversation. " + @@ -334,8 +337,17 @@ type CompactionSuffix = { contextRanges: Array<{ start: number; end: number; segmentStarts: number[] }>; }; +type SummaryQualityRetention = { + auditSummary: string; + identifiers: string[]; + latestAsk: string | null; + requiredAskContext: string; + identifierPolicy: "strict" | "off" | "custom"; +}; + function assembleSuffix(parts: { splitTurnSection?: ContextSection; + generatedSplitTurnSection?: string; preservedTurnsSection?: ContextSection; toolFailureSection?: string; fileOpsSummary?: string; @@ -605,6 +617,7 @@ function budgetCompactionSummary( summaryBody: string, suffixInput: string | CompactionSuffix, maxChars: number, + qualityRetention?: SummaryQualityRetention, ) { const suffix = normalizeCompactionSuffix(suffixInput); const joined = `${summaryBody}${suffix.text}`; @@ -615,13 +628,22 @@ function budgetCompactionSummary( bodyBudget: maxChars, bodyTrimmed: false, suffixTrimmed: false, + qualityRetentionInfeasible: false, }; } - const bodyFloor = Math.min(summaryBody.length, Math.max(1, Math.ceil(maxChars / 2))); + const retentionPlan = qualityRetention + ? createSummaryQualityRetentionPlan(summaryBody, SUMMARY_TRUNCATED_MARKER, qualityRetention) + : null; + const bodyCapacity = retentionPlan ? maxChars : summaryBody.length; + const bodyFloor = Math.min( + bodyCapacity, + maxChars, + Math.max(1, Math.ceil(maxChars / 2), retentionPlan?.minimumChars ?? 0), + ); const suffixReservation = Math.min(suffix.text.length, maxChars); - const bodySlot = Math.min(summaryBody.length, Math.max(bodyFloor, maxChars - suffixReservation)); - const cappedBody = capCompactionSummary(summaryBody, bodySlot); + const bodySlot = Math.min(bodyCapacity, Math.max(bodyFloor, maxChars - suffixReservation)); + const cappedBody = retentionPlan?.render(bodySlot) ?? capCompactionSummary(summaryBody, bodySlot); const suffixBudget = Math.max(0, maxChars - cappedBody.length); const cappedSuffix = capCompactionSuffix(suffix, suffixBudget); return { @@ -630,6 +652,7 @@ function budgetCompactionSummary( bodyBudget: bodySlot, bodyTrimmed: cappedBody.length < summaryBody.length, suffixTrimmed: cappedSuffix.length < suffix.text.length, + qualityRetentionInfeasible: retentionPlan !== null && retentionPlan.minimumChars > maxChars, }; } @@ -904,6 +927,33 @@ function formatGeneratedSplitTurnSection(summary: string, onTruncated?: () => vo return `${heading}${cappedSummary}`; } +function formatRequiredAskContext(summary: string): string { + const originalRequestHeading = "## Original Request"; + const earlyProgressHeading = "## Early Progress"; + const originalRequestStart = summary.indexOf(originalRequestHeading); + const originalRequestEnd = + originalRequestStart >= 0 + ? summary.indexOf(earlyProgressHeading, originalRequestStart + originalRequestHeading.length) + : -1; + const source = + originalRequestStart >= 0 + ? summary + .slice( + originalRequestStart + originalRequestHeading.length, + originalRequestEnd >= 0 ? originalRequestEnd : undefined, + ) + .trim() + : summary.trim(); + if (source.length <= MAX_REQUIRED_ASK_CONTEXT_CHARS) { + return source; + } + const contentBudget = + MAX_REQUIRED_ASK_CONTEXT_CHARS - REQUIRED_ASK_CONTEXT_TRUNCATED_MARKER.length; + const headBudget = Math.floor(contentBudget / 2); + const tailBudget = contentBudget - headBudget; + return `${truncateUtf16Safe(source, headBudget)}${REQUIRED_ASK_CONTEXT_TRUNCATED_MARKER}${sliceUtf16Safe(source, -tailBudget)}`; +} + function extractLatestUserAsk(messages: AgentMessage[]): string | null { for (const message of messages.toReversed()) { if (message.role !== "user") { @@ -1061,6 +1111,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { identifierInstructions: runtime?.identifierInstructions, }; const identifierPolicy = runtime?.identifierPolicy ?? "strict"; + const qualityGuardEnabled = runtime?.qualityGuardEnabled ?? false; const providerId = runtime?.provider; const turnPrefixMessages = baseTurnPrefixMessages; const recentTurnsPreserve = resolveRecentTurnsPreserve(runtime?.recentTurnsPreserve); @@ -1071,23 +1122,35 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { let workspaceContextPromise: Promise | undefined; const finalizeSummaryText = async ( body: string, - sections: { splitTurnSection?: ContextSection; preservedTurnsSection?: ContextSection }, + sections: { + splitTurnSection?: ContextSection; + generatedSplitTurnSection?: string; + preservedTurnsSection?: ContextSection; + }, producerLosses: ReadonlySet = new Set(), + qualityRetention?: SummaryQualityRetention, ) => { workspaceContextPromise ??= readWorkspaceContextForSummary( runtime?.postCompactionSections, runtime?.workspaceDir, ); const suffix = assembleSuffix({ - ...sections, + splitTurnSection: sections.splitTurnSection, + generatedSplitTurnSection: sections.generatedSplitTurnSection, + preservedTurnsSection: sections.preservedTurnsSection, toolFailureSection, fileOpsSummary, workspaceContext: await workspaceContextPromise, }); - const finalized = budgetCompactionSummary(body, suffix, MAX_COMPACTION_SUMMARY_CHARS); + const finalized = budgetCompactionSummary( + body, + suffix, + MAX_COMPACTION_SUMMARY_CHARS, + qualityRetention, + ); const losses = new Set(producerLosses); for (const section of Object.values(sections)) { - if (section?.truncatedLoss) { + if (typeof section !== "string" && section?.truncatedLoss) { losses.add(section.truncatedLoss); } } @@ -1205,7 +1268,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { thinkingLevel, streamFn, }; - const qualityGuardEnabled = runtime?.qualityGuardEnabled ?? false; const qualityGuardMaxRetries = resolveQualityGuardMaxRetries(runtime?.qualityGuardMaxRetries); const maxHistoryShare = runtime?.maxHistoryShare ?? 0.5; @@ -1309,6 +1371,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { for (let attempt = 0; attempt < totalAttempts; attempt += 1) { let splitTurnSectionLocal = ""; + let splitTurnAskContextLocal = ""; let historySummary = ""; const producerLosses = new Set(); try { @@ -1334,6 +1397,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { splitTurnSectionLocal = formatGeneratedSplitTurnSection(prefixSummary, () => { producerLosses.add("split-turn-tail"); }); + splitTurnAskContextLocal = formatRequiredAskContext(prefixSummary); } } catch (attemptError) { if (signal?.aborted) { @@ -1352,16 +1416,31 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { } throw attemptError; } - const structuralSummary = appendSummarySection( + const unbudgetedSummary = appendSummarySection( historySummary, splitTurnSectionLocal ? `\n\n${splitTurnSectionLocal}` : "", ); + const structuralSummary = qualityGuardEnabled ? historySummary : unbudgetedSummary; const finalized = await finalizeSummaryText( structuralSummary, { + generatedSplitTurnSection: + qualityGuardEnabled && splitTurnSectionLocal + ? `\n\n${splitTurnSectionLocal}` + : undefined, preservedTurnsSection: preservedTurnsSectionLocal, }, producerLosses, + qualityGuardEnabled + ? { + auditSummary: unbudgetedSummary, + identifiers, + latestAsk: latestUserAsk, + requiredAskContext: + splitTurnAskContextLocal || formatRequiredAskContext(latestUserAsk ?? ""), + identifierPolicy, + } + : undefined, ); const canRegenerate = @@ -1370,6 +1449,17 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { if (!qualityGuardEnabled) { return compactionResult(finalized.summary); } + if (finalized.qualityRetentionInfeasible) { + log.warn( + "Compaction safeguard: required quality facts exceed finalized artifact budget; " + + `requiredChars>${MAX_COMPACTION_SUMMARY_CHARS} identifierCount=${identifiers.length}`, + ); + setCompactionSafeguardCancelReason( + ctx.sessionManager, + "Compaction safeguard required facts exceed the finalized summary budget.", + ); + return { cancel: true }; + } const quality = auditSummaryQuality({ summary: finalized.summary, structuralSummary: finalized.structuralSummary,