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:
Rodrigo Uroz
2026-03-03 12:00:49 -03:00
committed by GitHub
parent 171f305c3d
commit c8b45a4c5c
4 changed files with 524 additions and 16 deletions

View File

@@ -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.

View File

@@ -13,6 +13,7 @@ export type CompactionSafeguardRuntimeValue = {
* (extensionRunner.initialize() is never called in that path).
*/
model?: Model<Api>;
recentTurnsPreserve?: number;
};
const registry = createSessionManagerRuntimeRegistry<CompactionSafeguardRuntimeValue>();

View File

@@ -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

View File

@@ -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,