mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
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>
This commit is contained in:
@@ -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<string>());
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -625,6 +625,86 @@ describe("compaction-safeguard summary budgets", () => {
|
||||
expect(capped).toContain("<read-files>");
|
||||
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 () => {
|
||||
|
||||
@@ -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<string> | undefined;
|
||||
const finalizeSummaryText = async (
|
||||
body: string,
|
||||
sections: { splitTurnSection?: ContextSection; preservedTurnsSection?: ContextSection },
|
||||
sections: {
|
||||
splitTurnSection?: ContextSection;
|
||||
generatedSplitTurnSection?: string;
|
||||
preservedTurnsSection?: ContextSection;
|
||||
},
|
||||
producerLosses: ReadonlySet<CompactionLoss> = 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<CompactionLoss>();
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user