Compaction/Safeguard: preserve recent turns verbatim (#25554)
Merged via squash. Prepared head SHA: 7fb33c411c4aaea2795e490fcd0e647cf7ea6fb8 Co-authored-by: rodrigouroz <384037+rodrigouroz@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman
This commit is contained in:
@@ -358,6 +358,7 @@ Docs: https://docs.openclaw.ai
|
||||
- fix(model): preserve reasoning in provider fallback resolution. (#29285) Fixes #25636. Thanks @vincentkoc.
|
||||
- Docker/Image permissions: normalize `/app/extensions`, `/app/.agent`, and `/app/.agents` to directory mode `755` and file mode `644` during image build so plugin discovery does not block inherited world-writable paths. (#30191) Fixes #30139. Thanks @edincampara.
|
||||
- OpenAI Responses/Compaction: rewrite and unify the OpenAI Responses store patches to treat empty `baseUrl` as non-direct, honor `compat.supportsStore=false`, and auto-inject server-side compaction `context_management` for compatible direct OpenAI models (with per-model opt-out/threshold overrides). Landed from contributor PRs #16930 (@OiPunk), #22441 (@EdwardWu7), and #25088 (@MoerAI). Thanks @OiPunk, @EdwardWu7, and @MoerAI.
|
||||
- Agents/Compaction safeguard: preserve recent turns verbatim with stable user/assistant pairing, keep multimodal and tool-result hints in preserved tails, and avoid empty-history fallback text in compacted output. (#25554) thanks @rodrigouroz.
|
||||
- Usage normalization: clamp negative prompt/input token values to zero (including `prompt_tokens` alias inputs) so `/usage` and TUI usage displays cannot show nonsensical negative counts. Landed from contributor PR #31211 by @scoootscooob. Thanks @scoootscooob.
|
||||
- Secrets/Auth profiles: normalize inline SecretRef `token`/`key` values to canonical `tokenRef`/`keyRef` before persistence, and keep explicit `keyRef` precedence when inline refs are also present. Landed from contributor PR #31047 by @minupla. Thanks @minupla.
|
||||
- Codex/Usage window: label weekly usage window as `Week` instead of `Day`. (#26267) Thanks @Sid-Qin.
|
||||
|
||||
@@ -13,6 +13,7 @@ export type CompactionSafeguardRuntimeValue = {
|
||||
* (extensionRunner.initialize() is never called in that path).
|
||||
*/
|
||||
model?: Model<Api>;
|
||||
recentTurnsPreserve?: number;
|
||||
};
|
||||
|
||||
const registry = createSessionManagerRuntimeRegistry<CompactionSafeguardRuntimeValue>();
|
||||
|
||||
@@ -15,6 +15,10 @@ import compactionSafeguardExtension, { __testing } from "./compaction-safeguard.
|
||||
const {
|
||||
collectToolFailures,
|
||||
formatToolFailuresSection,
|
||||
splitPreservedRecentTurns,
|
||||
formatPreservedTurnsSection,
|
||||
appendSummarySection,
|
||||
resolveRecentTurnsPreserve,
|
||||
computeAdaptiveChunkRatio,
|
||||
isOversizedForSummary,
|
||||
readWorkspaceContextForSummary,
|
||||
@@ -385,6 +389,259 @@ describe("compaction-safeguard runtime registry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("compaction-safeguard recent-turn preservation", () => {
|
||||
it("preserves the most recent user/assistant messages", () => {
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: "user", content: "older ask", timestamp: 1 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "older answer" }],
|
||||
timestamp: 2,
|
||||
} as unknown as AgentMessage,
|
||||
{ role: "user", content: "recent ask", timestamp: 3 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "recent answer" }],
|
||||
timestamp: 4,
|
||||
} as unknown as AgentMessage,
|
||||
];
|
||||
|
||||
const split = splitPreservedRecentTurns({
|
||||
messages,
|
||||
recentTurnsPreserve: 1,
|
||||
});
|
||||
|
||||
expect(split.preservedMessages).toHaveLength(2);
|
||||
expect(split.summarizableMessages).toHaveLength(2);
|
||||
expect(formatPreservedTurnsSection(split.preservedMessages)).toContain(
|
||||
"## Recent turns preserved verbatim",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops orphaned tool results from preserved assistant turns", () => {
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: "user", content: "older ask", timestamp: 1 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_old", name: "read", arguments: {} }],
|
||||
timestamp: 2,
|
||||
} as unknown as AgentMessage,
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_old",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "old result" }],
|
||||
timestamp: 3,
|
||||
} as unknown as AgentMessage,
|
||||
{ role: "user", content: "recent ask", timestamp: 4 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_recent", name: "read", arguments: {} }],
|
||||
timestamp: 5,
|
||||
} as unknown as AgentMessage,
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_recent",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "recent result" }],
|
||||
timestamp: 6,
|
||||
} as unknown as AgentMessage,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "recent final answer" }],
|
||||
timestamp: 7,
|
||||
} as unknown as AgentMessage,
|
||||
];
|
||||
|
||||
const split = splitPreservedRecentTurns({
|
||||
messages,
|
||||
recentTurnsPreserve: 1,
|
||||
});
|
||||
|
||||
expect(split.preservedMessages.map((msg) => msg.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
"assistant",
|
||||
]);
|
||||
expect(
|
||||
split.preservedMessages.some(
|
||||
(msg) => msg.role === "user" && (msg as { content?: unknown }).content === "recent ask",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const summarizableToolResultIds = split.summarizableMessages
|
||||
.filter((msg) => msg.role === "toolResult")
|
||||
.map((msg) => (msg as { toolCallId?: unknown }).toolCallId);
|
||||
expect(summarizableToolResultIds).toContain("call_old");
|
||||
expect(summarizableToolResultIds).not.toContain("call_recent");
|
||||
});
|
||||
|
||||
it("includes preserved tool results in the preserved-turns section", () => {
|
||||
const split = splitPreservedRecentTurns({
|
||||
messages: [
|
||||
{ role: "user", content: "older ask", timestamp: 1 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "older answer" }],
|
||||
timestamp: 2,
|
||||
} as unknown as AgentMessage,
|
||||
{ role: "user", content: "recent ask", timestamp: 3 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_recent", name: "read", arguments: {} }],
|
||||
timestamp: 4,
|
||||
} as unknown as AgentMessage,
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_recent",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "recent raw output" }],
|
||||
timestamp: 5,
|
||||
} as unknown as AgentMessage,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "recent final answer" }],
|
||||
timestamp: 6,
|
||||
} as unknown as AgentMessage,
|
||||
],
|
||||
recentTurnsPreserve: 1,
|
||||
});
|
||||
|
||||
const section = formatPreservedTurnsSection(split.preservedMessages);
|
||||
expect(section).toContain("- Tool result (read): recent raw output");
|
||||
expect(section).toContain("- User: recent ask");
|
||||
});
|
||||
|
||||
it("formats preserved non-text messages with placeholders", () => {
|
||||
const section = formatPreservedTurnsSection([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "image", data: "abc", mimeType: "image/png" }],
|
||||
timestamp: 1,
|
||||
} as unknown as AgentMessage,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_recent", name: "read", arguments: {} }],
|
||||
timestamp: 2,
|
||||
} as unknown as AgentMessage,
|
||||
]);
|
||||
|
||||
expect(section).toContain("- User: [non-text content: image]");
|
||||
expect(section).toContain("- Assistant: [non-text content: toolCall]");
|
||||
});
|
||||
|
||||
it("keeps non-text placeholders for mixed-content preserved messages", () => {
|
||||
const section = formatPreservedTurnsSection([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "caption text" },
|
||||
{ type: "image", data: "abc", mimeType: "image/png" },
|
||||
],
|
||||
timestamp: 1,
|
||||
} as unknown as AgentMessage,
|
||||
]);
|
||||
|
||||
expect(section).toContain("- User: caption text");
|
||||
expect(section).toContain("[non-text content: image]");
|
||||
});
|
||||
|
||||
it("does not add non-text placeholders for text-only content blocks", () => {
|
||||
const section = formatPreservedTurnsSection([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "plain text reply" }],
|
||||
timestamp: 1,
|
||||
} as unknown as AgentMessage,
|
||||
]);
|
||||
|
||||
expect(section).toContain("- Assistant: plain text reply");
|
||||
expect(section).not.toContain("[non-text content]");
|
||||
});
|
||||
|
||||
it("caps preserved tail when user turns are below preserve target", () => {
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: "user", content: "single user prompt", timestamp: 1 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "assistant-1" }],
|
||||
timestamp: 2,
|
||||
} as unknown as AgentMessage,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "assistant-2" }],
|
||||
timestamp: 3,
|
||||
} as unknown as AgentMessage,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "assistant-3" }],
|
||||
timestamp: 4,
|
||||
} as unknown as AgentMessage,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "assistant-4" }],
|
||||
timestamp: 5,
|
||||
} as unknown as AgentMessage,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "assistant-5" }],
|
||||
timestamp: 6,
|
||||
} as unknown as AgentMessage,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "assistant-6" }],
|
||||
timestamp: 7,
|
||||
} as unknown as AgentMessage,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "assistant-7" }],
|
||||
timestamp: 8,
|
||||
} as unknown as AgentMessage,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "assistant-8" }],
|
||||
timestamp: 9,
|
||||
} as unknown as AgentMessage,
|
||||
];
|
||||
|
||||
const split = splitPreservedRecentTurns({
|
||||
messages,
|
||||
recentTurnsPreserve: 3,
|
||||
});
|
||||
|
||||
// preserve target is 3 turns -> fallback should cap at 6 role messages
|
||||
expect(split.preservedMessages).toHaveLength(6);
|
||||
expect(
|
||||
split.preservedMessages.some(
|
||||
(msg) =>
|
||||
msg.role === "user" && (msg as { content?: unknown }).content === "single user prompt",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(formatPreservedTurnsSection(split.preservedMessages)).toContain("assistant-8");
|
||||
expect(formatPreservedTurnsSection(split.preservedMessages)).not.toContain("assistant-2");
|
||||
});
|
||||
|
||||
it("trim-starts preserved section when history summary is empty", () => {
|
||||
const summary = appendSummarySection(
|
||||
"",
|
||||
"\n\n## Recent turns preserved verbatim\n- User: hello",
|
||||
);
|
||||
expect(summary.startsWith("## Recent turns preserved verbatim")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not append empty summary sections", () => {
|
||||
expect(appendSummarySection("History", "")).toBe("History");
|
||||
expect(appendSummarySection("", "")).toBe("");
|
||||
});
|
||||
|
||||
it("clamps preserve count into a safe range", () => {
|
||||
expect(resolveRecentTurnsPreserve(undefined)).toBe(3);
|
||||
expect(resolveRecentTurnsPreserve(-1)).toBe(0);
|
||||
expect(resolveRecentTurnsPreserve(99)).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compaction-safeguard extension model fallback", () => {
|
||||
it("uses runtime.model when ctx.model is undefined (compact.ts workflow)", async () => {
|
||||
// This test verifies the root-cause fix: when extensionRunner.initialize() is not called
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
summarizeInStages,
|
||||
} from "../compaction.js";
|
||||
import { collectTextContentBlocks } from "../content-blocks.js";
|
||||
import { repairToolUseResultPairing } from "../session-transcript-repair.js";
|
||||
import { extractToolCallsFromAssistant, extractToolResultId } from "../tool-call-id.js";
|
||||
import { getCompactionSafeguardRuntime } from "./compaction-safeguard-runtime.js";
|
||||
|
||||
const log = createSubsystemLogger("compaction-safeguard");
|
||||
@@ -29,6 +31,9 @@ const TURN_PREFIX_INSTRUCTIONS =
|
||||
" early progress, and any details needed to understand the retained suffix.";
|
||||
const MAX_TOOL_FAILURES = 8;
|
||||
const MAX_TOOL_FAILURE_CHARS = 240;
|
||||
const DEFAULT_RECENT_TURNS_PRESERVE = 3;
|
||||
const MAX_RECENT_TURNS_PRESERVE = 12;
|
||||
const MAX_RECENT_TURN_TEXT_CHARS = 600;
|
||||
|
||||
type ToolFailure = {
|
||||
toolCallId: string;
|
||||
@@ -37,6 +42,18 @@ type ToolFailure = {
|
||||
meta?: string;
|
||||
};
|
||||
|
||||
function clampNonNegativeInt(value: unknown, fallback: number): number {
|
||||
const normalized = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
return Math.max(0, Math.floor(normalized));
|
||||
}
|
||||
|
||||
function resolveRecentTurnsPreserve(value: unknown): number {
|
||||
return Math.min(
|
||||
MAX_RECENT_TURNS_PRESERVE,
|
||||
clampNonNegativeInt(value, DEFAULT_RECENT_TURNS_PRESERVE),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeFailureText(text: string): string {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
@@ -159,6 +176,216 @@ function formatFileOperations(readFiles: string[], modifiedFiles: string[]): str
|
||||
return `\n\n${sections.join("\n\n")}`;
|
||||
}
|
||||
|
||||
function extractMessageText(message: AgentMessage): string {
|
||||
const content = (message as { content?: unknown }).content;
|
||||
if (typeof content === "string") {
|
||||
return content.trim();
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
const text = (block as { text?: unknown }).text;
|
||||
if (typeof text === "string" && text.trim().length > 0) {
|
||||
parts.push(text.trim());
|
||||
}
|
||||
}
|
||||
return parts.join("\n").trim();
|
||||
}
|
||||
|
||||
function formatNonTextPlaceholder(content: unknown): string | null {
|
||||
if (content === null || content === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof content === "string") {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "[non-text content]";
|
||||
}
|
||||
const typeCounts = new Map<string, number>();
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
const typeRaw = (block as { type?: unknown }).type;
|
||||
const type = typeof typeRaw === "string" && typeRaw.trim().length > 0 ? typeRaw : "unknown";
|
||||
if (type === "text") {
|
||||
continue;
|
||||
}
|
||||
typeCounts.set(type, (typeCounts.get(type) ?? 0) + 1);
|
||||
}
|
||||
if (typeCounts.size === 0) {
|
||||
return null;
|
||||
}
|
||||
const parts = [...typeCounts.entries()].map(([type, count]) =>
|
||||
count > 1 ? `${type} x${count}` : type,
|
||||
);
|
||||
return `[non-text content: ${parts.join(", ")}]`;
|
||||
}
|
||||
|
||||
function splitPreservedRecentTurns(params: {
|
||||
messages: AgentMessage[];
|
||||
recentTurnsPreserve: number;
|
||||
}): { summarizableMessages: AgentMessage[]; preservedMessages: AgentMessage[] } {
|
||||
const preserveTurns = Math.min(
|
||||
MAX_RECENT_TURNS_PRESERVE,
|
||||
clampNonNegativeInt(params.recentTurnsPreserve, 0),
|
||||
);
|
||||
if (preserveTurns <= 0) {
|
||||
return { summarizableMessages: params.messages, preservedMessages: [] };
|
||||
}
|
||||
const conversationIndexes: number[] = [];
|
||||
const userIndexes: number[] = [];
|
||||
for (let i = 0; i < params.messages.length; i += 1) {
|
||||
const role = (params.messages[i] as { role?: unknown }).role;
|
||||
if (role === "user" || role === "assistant") {
|
||||
conversationIndexes.push(i);
|
||||
if (role === "user") {
|
||||
userIndexes.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (conversationIndexes.length === 0) {
|
||||
return { summarizableMessages: params.messages, preservedMessages: [] };
|
||||
}
|
||||
|
||||
const preservedIndexSet = new Set<number>();
|
||||
if (userIndexes.length >= preserveTurns) {
|
||||
const boundaryStartIndex = userIndexes[userIndexes.length - preserveTurns] ?? -1;
|
||||
if (boundaryStartIndex >= 0) {
|
||||
for (const index of conversationIndexes) {
|
||||
if (index >= boundaryStartIndex) {
|
||||
preservedIndexSet.add(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const fallbackMessageCount = preserveTurns * 2;
|
||||
for (const userIndex of userIndexes) {
|
||||
preservedIndexSet.add(userIndex);
|
||||
}
|
||||
for (let i = conversationIndexes.length - 1; i >= 0; i -= 1) {
|
||||
const index = conversationIndexes[i];
|
||||
if (index === undefined) {
|
||||
continue;
|
||||
}
|
||||
preservedIndexSet.add(index);
|
||||
if (preservedIndexSet.size >= fallbackMessageCount) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (preservedIndexSet.size === 0) {
|
||||
return { summarizableMessages: params.messages, preservedMessages: [] };
|
||||
}
|
||||
const preservedToolCallIds = new Set<string>();
|
||||
for (let i = 0; i < params.messages.length; i += 1) {
|
||||
if (!preservedIndexSet.has(i)) {
|
||||
continue;
|
||||
}
|
||||
const message = params.messages[i];
|
||||
const role = (message as { role?: unknown }).role;
|
||||
if (role !== "assistant") {
|
||||
continue;
|
||||
}
|
||||
const toolCalls = extractToolCallsFromAssistant(
|
||||
message as Extract<AgentMessage, { role: "assistant" }>,
|
||||
);
|
||||
for (const toolCall of toolCalls) {
|
||||
preservedToolCallIds.add(toolCall.id);
|
||||
}
|
||||
}
|
||||
if (preservedToolCallIds.size > 0) {
|
||||
let preservedStartIndex = -1;
|
||||
for (let i = 0; i < params.messages.length; i += 1) {
|
||||
if (preservedIndexSet.has(i)) {
|
||||
preservedStartIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (preservedStartIndex >= 0) {
|
||||
for (let i = preservedStartIndex; i < params.messages.length; i += 1) {
|
||||
const message = params.messages[i];
|
||||
if ((message as { role?: unknown }).role !== "toolResult") {
|
||||
continue;
|
||||
}
|
||||
const toolResultId = extractToolResultId(
|
||||
message as Extract<AgentMessage, { role: "toolResult" }>,
|
||||
);
|
||||
if (toolResultId && preservedToolCallIds.has(toolResultId)) {
|
||||
preservedIndexSet.add(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const summarizableMessages = params.messages.filter((_, idx) => !preservedIndexSet.has(idx));
|
||||
// Preserving recent assistant turns can orphan downstream toolResult messages.
|
||||
// Repair pairings here so compaction summarization doesn't trip strict providers.
|
||||
const repairedSummarizableMessages = repairToolUseResultPairing(summarizableMessages).messages;
|
||||
const preservedMessages = params.messages
|
||||
.filter((_, idx) => preservedIndexSet.has(idx))
|
||||
.filter((msg) => {
|
||||
const role = (msg as { role?: unknown }).role;
|
||||
return role === "user" || role === "assistant" || role === "toolResult";
|
||||
});
|
||||
return { summarizableMessages: repairedSummarizableMessages, preservedMessages };
|
||||
}
|
||||
|
||||
function formatPreservedTurnsSection(messages: AgentMessage[]): string {
|
||||
if (messages.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const lines = messages
|
||||
.map((message) => {
|
||||
let roleLabel: string;
|
||||
if (message.role === "assistant") {
|
||||
roleLabel = "Assistant";
|
||||
} else if (message.role === "user") {
|
||||
roleLabel = "User";
|
||||
} else if (message.role === "toolResult") {
|
||||
const toolName = (message as { toolName?: unknown }).toolName;
|
||||
const safeToolName = typeof toolName === "string" && toolName.trim() ? toolName : "tool";
|
||||
roleLabel = `Tool result (${safeToolName})`;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
const text = extractMessageText(message);
|
||||
const nonTextPlaceholder = formatNonTextPlaceholder(
|
||||
(message as { content?: unknown }).content,
|
||||
);
|
||||
const rendered =
|
||||
text && nonTextPlaceholder ? `${text}\n${nonTextPlaceholder}` : text || nonTextPlaceholder;
|
||||
if (!rendered) {
|
||||
return null;
|
||||
}
|
||||
const trimmed =
|
||||
rendered.length > MAX_RECENT_TURN_TEXT_CHARS
|
||||
? `${rendered.slice(0, MAX_RECENT_TURN_TEXT_CHARS)}...`
|
||||
: rendered;
|
||||
return `- ${roleLabel}: ${trimmed}`;
|
||||
})
|
||||
.filter((line): line is string => Boolean(line));
|
||||
if (lines.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return `\n\n## Recent turns preserved verbatim\n${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
function appendSummarySection(summary: string, section: string): string {
|
||||
if (!section) {
|
||||
return summary;
|
||||
}
|
||||
if (!summary.trim()) {
|
||||
return section.trimStart();
|
||||
}
|
||||
return `${summary}${section}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and format critical workspace context for compaction summary.
|
||||
* Extracts "Session Startup" and "Red Lines" from AGENTS.md.
|
||||
@@ -256,6 +483,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
const contextWindowTokens = runtime?.contextWindowTokens ?? modelContextWindow;
|
||||
const turnPrefixMessages = preparation.turnPrefixMessages ?? [];
|
||||
let messagesToSummarize = preparation.messagesToSummarize;
|
||||
const recentTurnsPreserve = resolveRecentTurnsPreserve(runtime?.recentTurnsPreserve);
|
||||
|
||||
const maxHistoryShare = runtime?.maxHistoryShare ?? 0.5;
|
||||
|
||||
@@ -326,6 +554,16 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
summarizableMessages: summaryTargetMessages,
|
||||
preservedMessages: preservedRecentMessages,
|
||||
} = splitPreservedRecentTurns({
|
||||
messages: messagesToSummarize,
|
||||
recentTurnsPreserve,
|
||||
});
|
||||
messagesToSummarize = summaryTargetMessages;
|
||||
const preservedTurnsSection = formatPreservedTurnsSection(preservedRecentMessages);
|
||||
|
||||
// Use adaptive chunk ratio based on message sizes, reserving headroom for
|
||||
// the summarization prompt, system prompt, previous summary, and reasoning budget
|
||||
// that generateSummary adds on top of the serialized conversation chunk.
|
||||
@@ -341,18 +579,21 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
// incorporates context from pruned messages instead of losing it entirely.
|
||||
const effectivePreviousSummary = droppedSummary ?? preparation.previousSummary;
|
||||
|
||||
const historySummary = await summarizeInStages({
|
||||
messages: messagesToSummarize,
|
||||
model,
|
||||
apiKey,
|
||||
signal,
|
||||
reserveTokens,
|
||||
maxChunkTokens,
|
||||
contextWindow: contextWindowTokens,
|
||||
customInstructions,
|
||||
summarizationInstructions,
|
||||
previousSummary: effectivePreviousSummary,
|
||||
});
|
||||
const historySummary =
|
||||
messagesToSummarize.length > 0
|
||||
? await summarizeInStages({
|
||||
messages: messagesToSummarize,
|
||||
model,
|
||||
apiKey,
|
||||
signal,
|
||||
reserveTokens,
|
||||
maxChunkTokens,
|
||||
contextWindow: contextWindowTokens,
|
||||
customInstructions,
|
||||
summarizationInstructions,
|
||||
previousSummary: effectivePreviousSummary,
|
||||
})
|
||||
: (effectivePreviousSummary?.trim() ?? "");
|
||||
|
||||
let summary = historySummary;
|
||||
if (preparation.isSplitTurn && turnPrefixMessages.length > 0) {
|
||||
@@ -368,16 +609,20 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
summarizationInstructions,
|
||||
previousSummary: undefined,
|
||||
});
|
||||
summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`;
|
||||
const splitTurnSection = `**Turn Context (split turn):**\n\n${prefixSummary}`;
|
||||
summary = historySummary.trim()
|
||||
? `${historySummary}\n\n---\n\n${splitTurnSection}`
|
||||
: splitTurnSection;
|
||||
}
|
||||
summary = appendSummarySection(summary, preservedTurnsSection);
|
||||
|
||||
summary += toolFailureSection;
|
||||
summary += fileOpsSummary;
|
||||
summary = appendSummarySection(summary, toolFailureSection);
|
||||
summary = appendSummarySection(summary, fileOpsSummary);
|
||||
|
||||
// Append workspace critical context (Session Startup + Red Lines from AGENTS.md)
|
||||
const workspaceContext = await readWorkspaceContextForSummary();
|
||||
if (workspaceContext) {
|
||||
summary += workspaceContext;
|
||||
summary = appendSummarySection(summary, workspaceContext);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -402,6 +647,10 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
export const __testing = {
|
||||
collectToolFailures,
|
||||
formatToolFailuresSection,
|
||||
splitPreservedRecentTurns,
|
||||
formatPreservedTurnsSection,
|
||||
appendSummarySection,
|
||||
resolveRecentTurnsPreserve,
|
||||
computeAdaptiveChunkRatio,
|
||||
isOversizedForSummary,
|
||||
readWorkspaceContextForSummary,
|
||||
|
||||
Reference in New Issue
Block a user