refactor(agents): consolidate compaction and context-engine ownership (#117482)

* refactor(agents): consolidate compaction and context-engine ownership

* fix(agents): preserve prior summaries during compaction fallback

* chore(ci): refresh merge proof after routing repair
This commit is contained in:
Peter Steinberger
2026-08-01 10:38:43 -07:00
committed by GitHub
parent fb6f60a704
commit bde1602e6e
22 changed files with 986 additions and 1753 deletions

View File

@@ -1,9 +1,6 @@
/** Tests compaction instruction defaults, precedence, and split-turn composition. */
import { describe, expect, it } from "vitest";
import {
resolveCompactionInstructions,
composeSplitTurnInstructions,
} from "./compaction-instructions.js";
import { resolveCompactionInstructions } from "./compaction-instructions.js";
const DEFAULT_COMPACTION_INSTRUCTIONS = resolveCompactionInstructions(undefined, undefined);
@@ -188,56 +185,3 @@ describe("resolveCompactionInstructions", () => {
});
});
});
describe("composeSplitTurnInstructions", () => {
it("joins turn prefix, separator, and resolved instructions with double newlines", () => {
const result = composeSplitTurnInstructions("Turn prefix here", "Resolved instructions here");
expect(result).toBe(
"Turn prefix here\n\nAdditional requirements:\n\nResolved instructions here",
);
});
it("output contains the turn prefix verbatim", () => {
const prefix = "Summarize the last 5 messages.";
const result = composeSplitTurnInstructions(prefix, "Keep it short.");
expect(result).toContain(prefix);
});
it("output contains the resolved instructions verbatim", () => {
const instructions = "Write in Korean. Preserve persona.";
const result = composeSplitTurnInstructions("prefix", instructions);
expect(result).toContain(instructions);
});
it("output contains 'Additional requirements:' separator", () => {
const result = composeSplitTurnInstructions("a", "b");
expect(result).toContain("Additional requirements:");
});
it("KNOWN_EDGE: empty turnPrefix produces leading blank line", () => {
const result = composeSplitTurnInstructions("", "instructions");
expect(result).toBe("\n\nAdditional requirements:\n\ninstructions");
expect(result.startsWith("\n")).toBe(true);
});
it("KNOWN_EDGE: empty resolvedInstructions produces trailing blank area", () => {
const result = composeSplitTurnInstructions("prefix", "");
expect(result).toBe("prefix\n\nAdditional requirements:\n\n");
expect(result.endsWith("\n\n")).toBe(true);
});
it("does not deduplicate if instructions already contain 'Additional requirements:'", () => {
const instructions = "Additional requirements: keep it short.";
const result = composeSplitTurnInstructions("prefix", instructions);
const count = (result.match(/Additional requirements:/g) || []).length;
expect(count).toBe(2);
});
it("preserves multiline content in both inputs", () => {
const prefix = "Line 1\nLine 2";
const instructions = "Rule A\nRule B\nRule C";
const result = composeSplitTurnInstructions(prefix, instructions);
expect(result).toContain("Line 1\nLine 2");
expect(result).toContain("Rule A\nRule B\nRule C");
});
});

View File

@@ -4,6 +4,7 @@
* Provides default language-preservation instructions and a precedence-based
* resolver for customInstructions used during context compaction summaries.
*/
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
/**
* Default instructions injected into every safeguard-mode compaction summary.
@@ -22,22 +23,6 @@ const DEFAULT_COMPACTION_INSTRUCTIONS =
*/
const MAX_INSTRUCTION_LENGTH = 800;
function truncateUnicodeSafe(s: string, maxCodePoints: number): string {
const chars = Array.from(s);
if (chars.length <= maxCodePoints) {
return s;
}
return chars.slice(0, maxCodePoints).join("");
}
function normalize(s: string | undefined): string | undefined {
if (s == null) {
return undefined;
}
const trimmed = s.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
/**
* Resolve compaction instructions with precedence:
* event (SDK) → runtime (config) → DEFAULT constant.
@@ -50,19 +35,8 @@ export function resolveCompactionInstructions(
runtimeInstructions: string | undefined,
): string {
const resolved =
normalize(eventInstructions) ??
normalize(runtimeInstructions) ??
normalizeOptionalString(eventInstructions) ??
normalizeOptionalString(runtimeInstructions) ??
DEFAULT_COMPACTION_INSTRUCTIONS;
return truncateUnicodeSafe(resolved, MAX_INSTRUCTION_LENGTH);
}
/**
* Compose split-turn instructions by combining the SDK's turn-prefix
* instructions with the resolved compaction instructions.
*/
export function composeSplitTurnInstructions(
turnPrefixInstructions: string,
resolvedInstructions: string,
): string {
return [turnPrefixInstructions, "Additional requirements:", resolvedInstructions].join("\n\n");
return Array.from(resolved).slice(0, MAX_INSTRUCTION_LENGTH).join("");
}

View File

@@ -94,10 +94,7 @@ function hasRequiredSummarySections(summary: string): boolean {
}
/** Return a structured fallback summary when model output is missing/invalid. */
export function buildStructuredFallbackSummary(
previousSummary: string | undefined,
_summarizationInstructions?: CompactionSummarizationInstructions,
): string {
export function buildStructuredFallbackSummary(previousSummary: string | undefined): string {
const trimmedPreviousSummary = previousSummary?.trim() ?? "";
if (trimmedPreviousSummary && hasRequiredSummarySections(trimmedPreviousSummary)) {
return trimmedPreviousSummary;
@@ -153,12 +150,10 @@ export function extractOpaqueIdentifiers(text: string): string[] {
text.match(
/([A-Fa-f0-9]{8,}|https?:\/\/\S+|\/[\w.-]{2,}(?:\/[\w.-]+)+|[A-Za-z]:\\[\w\\.-]+|[A-Za-z0-9._-]+\.[A-Za-z0-9._/-]+:\d{1,5}|\b\d{6,}\b)/g,
) ?? [];
return Array.from(
new Set(
matches
.map((value) => normalizeOpaqueIdentifier(sanitizeExtractedIdentifier(value)))
.filter((value) => value.length >= 4),
),
return uniqueStrings(
matches
.map((value) => normalizeOpaqueIdentifier(sanitizeExtractedIdentifier(value)))
.filter((value) => value.length >= 4),
).slice(0, MAX_EXTRACTED_IDENTIFIERS);
}

View File

@@ -49,14 +49,9 @@ export function setCompactionSafeguardCancelReason(
const current = getCompactionSafeguardRuntime(sessionManager);
const trimmed = reason?.trim();
if (!current) {
if (!trimmed) {
return;
}
setCompactionSafeguardRuntime(sessionManager, { cancelReason: trimmed });
if (!current && !trimmed) {
return;
}
const next = { ...current };
if (trimmed) {
next.cancelReason = trimmed;

View File

@@ -1439,9 +1439,7 @@ describe("compaction-safeguard recent-turn preservation", () => {
});
it("does not force policy-off marker in fallback exact identifiers section", () => {
const summary = buildStructuredFallbackSummary(undefined, {
identifierPolicy: "off",
});
const summary = buildStructuredFallbackSummary(undefined);
expect(summary).toContain("## Exact identifiers");
expect(summary).toContain("None captured.");
expect(summary).not.toContain("N/A (identifier policy off).");
@@ -2143,6 +2141,9 @@ describe("compaction-safeguard recent-turn preservation", () => {
expect(summary).toContain("latest ask status");
expect(summary).toContain("latest assistant reply");
expect(mockSummarizeInStages).toHaveBeenCalledTimes(3);
expect(requireRecord(mockCallArg(mockSummarizeInStages, 1)).customInstructions).toContain(
"Additional requirements:",
);
});
it("keeps required headings when all turns are preserved and history is carried forward", async () => {
@@ -2845,70 +2846,86 @@ describe("compaction-safeguard double-compaction guard", () => {
).toBe(true);
});
it("does not replay inter-session sessions_send branch turns as fallback history", async () => {
mockSummarizeInStages.mockReset();
mockSummarizeInStages.mockResolvedValue(summaryResult("branch summary"));
it.each([
{ assistantText: "bee reply", completed: true },
{ assistantText: " \t\n ", completed: false },
])(
"drops delegated branch turns only after meaningful terminal output ($completed)",
async ({ assistantText, completed }) => {
mockSummarizeInStages.mockReset();
mockSummarizeInStages.mockResolvedValue(summaryResult("branch summary"));
const now = Date.now();
const sessionManager = {
...stubSessionManager(),
getBranch: () => [
{
type: "message",
id: "user-1",
parentId: null,
timestamp: new Date(now).toISOString(),
message: {
role: "user",
content: "say bee",
provenance: {
kind: "inter_session",
sourceSessionKey: "agent:pm",
sourceTool: "sessions_send",
const now = Date.now();
const sessionManager = {
...stubSessionManager(),
getBranch: () => [
{
type: "message",
id: "user-1",
parentId: null,
timestamp: new Date(now).toISOString(),
message: {
role: "user",
content: "say bee",
provenance: {
kind: "inter_session",
sourceSessionKey: "agent:pm",
sourceTool: "sessions_send",
},
timestamp: now,
},
timestamp: now,
},
},
{
type: "message",
id: "assistant-1",
parentId: "user-1",
timestamp: new Date(now + 1).toISOString(),
message: {
role: "assistant",
content: [{ type: "text", text: "bee reply" }],
timestamp: now + 1,
{
type: "message",
id: "assistant-1",
parentId: "user-1",
timestamp: new Date(now + 1).toISOString(),
message: {
role: "assistant",
content: [{ type: "text", text: assistantText }],
timestamp: now + 1,
},
},
],
} as ExtensionContext["sessionManager"];
const model = createAnthropicModelFixture();
setCompactionSafeguardRuntime(sessionManager, { model, recentTurnsPreserve: 0 });
const mockEvent = {
preparation: {
messagesToSummarize: [] as AgentMessage[],
turnPrefixMessages: [] as AgentMessage[],
firstKeptEntryId: "entry-7",
tokensBefore: 38085,
fileOps: { read: [], edited: [], written: [] },
settings: { reserveTokens: 4000 },
isSplitTurn: true,
},
],
} as ExtensionContext["sessionManager"];
const model = createAnthropicModelFixture();
setCompactionSafeguardRuntime(sessionManager, { model, recentTurnsPreserve: 0 });
customInstructions: "",
signal: new AbortController().signal,
};
const { result, getApiKeyAndHeadersMock } = await runCompactionScenario({
sessionManager,
event: mockEvent,
apiKey: "dummy",
});
const mockEvent = {
preparation: {
messagesToSummarize: [] as AgentMessage[],
turnPrefixMessages: [] as AgentMessage[],
firstKeptEntryId: "entry-7",
tokensBefore: 38085,
fileOps: { read: [], edited: [], written: [] },
settings: { reserveTokens: 4000 },
isSplitTurn: true,
},
customInstructions: "",
signal: new AbortController().signal,
};
const { result, getApiKeyAndHeadersMock } = await runCompactionScenario({
sessionManager,
event: mockEvent,
apiKey: "dummy",
});
const compaction = expectCompactionResult(result);
expect(compaction.summary).toContain("No prior history.");
expect(mockSummarizeInStages).not.toHaveBeenCalled();
expect(getApiKeyAndHeadersMock).not.toHaveBeenCalled();
});
const compaction = expectCompactionResult(result);
if (completed) {
expect(compaction.summary).toContain("No prior history.");
expect(mockSummarizeInStages).not.toHaveBeenCalled();
expect(getApiKeyAndHeadersMock).not.toHaveBeenCalled();
return;
}
expect(compaction.summary).toContain("branch summary");
expect(mockSummarizeInStages).toHaveBeenCalledTimes(1);
expect(getApiKeyAndHeadersMock).toHaveBeenCalledTimes(1);
const summarizeCall = requireRecord(mockCallArg(mockSummarizeInStages));
expect(
requireArray(summarizeCall.messages).map((message) => requireRecord(message).role),
).toEqual(["user", "assistant"]);
},
);
it.each([
{ toolName: "read", expectedRoles: ["user", "assistant", "toolResult"] },

View File

@@ -2,6 +2,7 @@
import fs from "node:fs";
import path from "node:path";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { extractSections } from "../../auto-reply/reply/post-compaction-context.js";
import { isAbortError } from "../../infra/abort-signal.js";
@@ -14,10 +15,8 @@ import {
} from "../../plugins/compaction-provider.js";
import { normalizeInputProvenance } from "../../sessions/input-provenance.js";
import { normalizeAcceptedSessionSpawnResult } from "../accepted-session-spawn.js";
import {
buildHistoryPrunePlanWithWorker,
computeAdaptiveChunkRatioWithWorker,
} from "../compaction-planning-worker.js";
import { computeAdaptiveChunkRatioWithWorker } from "../compaction-planning-worker.js";
import { buildHistoryPrunePlan } from "../compaction-planning.js";
import {
hasMeaningfulConversationContent,
isRealConversationMessage,
@@ -44,10 +43,7 @@ import {
MAX_WORKSPACE_BOOTSTRAP_FILE_BYTES,
readWorkspaceBootstrapFile,
} from "../workspace-bootstrap-read.js";
import {
composeSplitTurnInstructions,
resolveCompactionInstructions,
} from "./compaction-instructions.js";
import { resolveCompactionInstructions } from "./compaction-instructions.js";
import {
appendSummarySection,
auditSummaryQuality,
@@ -87,19 +83,6 @@ const compactionSafeguardDeps = {
summarizeInStages,
};
function buildPreviousSummaryMessage(previousSummary: string): AgentMessage {
return {
role: "user",
content: [
{
type: "text",
text: `<previous-compaction-summary>\n${PREVIOUS_SUMMARY_REDISTILL_PREFIX}\n\n${previousSummary.trim()}\n</previous-compaction-summary>`,
},
],
timestamp: 0,
} as AgentMessage;
}
function prependPreviousSummaryForRedistill(params: {
messages: AgentMessage[];
previousSummary?: string;
@@ -108,27 +91,27 @@ function prependPreviousSummaryForRedistill(params: {
if (!previousSummary) {
return params.messages;
}
return [buildPreviousSummaryMessage(previousSummary), ...params.messages];
return [
{
role: "user",
content: [
{
type: "text",
text: `<previous-compaction-summary>\n${PREVIOUS_SUMMARY_REDISTILL_PREFIX}\n\n${previousSummary}\n</previous-compaction-summary>`,
},
],
timestamp: 0,
} as AgentMessage,
...params.messages,
];
}
type SessionBranchEntry = {
type?: unknown;
message?: unknown;
customType?: unknown;
content?: unknown;
display?: unknown;
details?: unknown;
timestamp?: unknown;
summary?: unknown;
fromId?: unknown;
};
function coerceTimestamp(value: unknown): number {
const timestamp = typeof value === "string" ? Date.parse(value) : value;
return typeof timestamp === "number" && Number.isFinite(timestamp) ? timestamp : 0;
}
function sessionBranchEntryToMessage(entry: SessionBranchEntry): unknown {
function sessionBranchEntryToMessage(entry: Record<string, unknown>): unknown {
if (entry.type === "message" && entry.message && typeof entry.message === "object") {
return entry.message;
}
@@ -154,17 +137,13 @@ function sessionBranchEntryToMessage(entry: SessionBranchEntry): unknown {
}
function collectSessionBranchMessages(sessionManager: unknown): AgentMessage[] {
const getBranch = (sessionManager as { getBranch?: unknown })?.getBranch;
if (typeof getBranch !== "function") {
return [];
}
try {
const entries: unknown = getBranch.call(sessionManager);
const entries: unknown = (sessionManager as { getBranch?: () => unknown })?.getBranch?.();
return Array.isArray(entries)
? entries.flatMap((entry) => {
const message =
entry && typeof entry === "object"
? sessionBranchEntryToMessage(entry as SessionBranchEntry)
? sessionBranchEntryToMessage(entry as Record<string, unknown>)
: undefined;
return message ? [message as AgentMessage] : [];
})
@@ -174,42 +153,27 @@ function collectSessionBranchMessages(sessionManager: unknown): AgentMessage[] {
}
}
function isReplayUnsafeInterSessionInput(message: AgentMessage): boolean {
if ((message as { role?: unknown }).role !== "user") {
return false;
}
const provenance = normalizeInputProvenance((message as { provenance?: unknown }).provenance);
return provenance?.kind === "inter_session" && provenance.sourceTool === "sessions_send";
}
function isSessionsSendToolName(value: unknown): boolean {
if (typeof value !== "string") {
return false;
}
return (
value
.trim()
.toLowerCase()
normalizeOptionalString(value)
?.toLowerCase()
.replace(/^(?:functions?|tools?)[./_-]/, "") === "sessions_send"
);
}
function sanitizeSourceSessionSends(messages: AgentMessage[]): AgentMessage[] {
const sendCallIds = new Set<string>();
const sendCallIds = new Set(
messages.flatMap((message) =>
message.role === "assistant"
? extractToolCallsFromAssistant(message)
.filter((call) => isSessionsSendToolName(call.name))
.map((call) => call.id.trim())
.filter(Boolean)
: [],
),
);
const resultTextByCallId = new Map<string, string>();
for (const message of messages) {
if (message.role !== "assistant") {
continue;
}
for (const call of extractToolCallsFromAssistant(message)) {
const callId = call.id.trim();
if (callId && isSessionsSendToolName(call.name)) {
sendCallIds.add(callId);
}
}
}
for (const message of messages) {
if (message.role !== "toolResult") {
continue;
@@ -249,14 +213,10 @@ function sanitizeSourceSessionSends(messages: AgentMessage[]): AgentMessage[] {
const resultText = callId ? resultTextByCallId.get(callId) : undefined;
const resolved = Boolean(callId && resultTextByCallId.has(callId));
const requestText = JSON.stringify({ callId: callId || undefined, args: record.arguments });
const resultSuffix = resolved ? `\nResult: ${resultText || "[empty]"}` : "";
return {
type: "text",
text:
resolved && resultText
? `sessions_send result received; delivery call omitted from replay.\nRequest: ${requestText}\nResult: ${resultText}`
: resolved
? `sessions_send result received; delivery call omitted from replay.\nRequest: ${requestText}\nResult: [empty]`
: `sessions_send result missing; delivery call omitted from replay.\nRequest: ${requestText}`,
text: `sessions_send result ${resolved ? "received" : "missing"}; delivery call omitted from replay.\nRequest: ${requestText}${resultSuffix}`,
};
});
return replaced ? [{ ...message, content } as AgentMessage] : [message];
@@ -296,6 +256,10 @@ function filterReplayUnsafeSessionBranchMessages(messages: AgentMessage[]): Agen
return typeof type === "string" && TOOL_CALL_BLOCK_TYPES.has(type);
}));
const activeInput = sanitizedMessages[turnStart - 1];
const activeInputProvenance =
activeInput?.role === "user"
? normalizeInputProvenance((activeInput as { provenance?: unknown }).provenance)
: undefined;
// A completed sessions_send target run is already delivered to its caller.
// Require terminal text so compaction after tool output can still recover unfinished work.
@@ -303,8 +267,8 @@ function filterReplayUnsafeSessionBranchMessages(messages: AgentMessage[]): Agen
endsWithTerminalAssistantText &&
turnStart < sanitizedMessages.length &&
turnStart > 0 &&
activeInput !== undefined &&
isReplayUnsafeInterSessionInput(activeInput)
activeInputProvenance?.kind === "inter_session" &&
activeInputProvenance.sourceTool === "sessions_send"
) {
return sanitizedMessages.slice(0, turnStart - 1);
}
@@ -348,16 +312,13 @@ function assembleSuffix(parts: {
fileOpsSummary?: string;
workspaceContext?: string;
}): string {
let suffix = Object.values(parts).reduce(
const suffix = Object.values(parts).reduce(
(summary, section) => appendSummarySection(summary, section ?? ""),
"",
);
// Ensure leading separator so suffix does not merge with body (e.g. when body
// ends without newline: "...## Exact identifiers## Tool Failures").
if (suffix && !/^\s/.test(suffix)) {
suffix = `\n\n${suffix}`;
}
return suffix;
return suffix && !/^\s/.test(suffix) ? `\n\n${suffix}` : suffix;
}
type ToolFailure = {
@@ -447,51 +408,42 @@ function buildCompactionSummaryHeaders(params: {
};
}
function clampNonNegativeInt(value: unknown, fallback: number): number {
function clampNonNegativeInt(
value: unknown,
fallback: number,
max = Number.POSITIVE_INFINITY,
): number {
const normalized = typeof value === "number" && Number.isFinite(value) ? value : fallback;
return Math.max(0, Math.floor(normalized));
return Math.min(max, Math.max(0, Math.floor(normalized)));
}
function resolveRecentTurnsPreserve(value: unknown): number {
return Math.min(
MAX_RECENT_TURNS_PRESERVE,
clampNonNegativeInt(value, DEFAULT_RECENT_TURNS_PRESERVE),
);
return clampNonNegativeInt(value, DEFAULT_RECENT_TURNS_PRESERVE, MAX_RECENT_TURNS_PRESERVE);
}
function resolveQualityGuardMaxRetries(value: unknown): number {
return Math.min(
return clampNonNegativeInt(
value,
DEFAULT_QUALITY_GUARD_MAX_RETRIES,
MAX_QUALITY_GUARD_MAX_RETRIES,
clampNonNegativeInt(value, DEFAULT_QUALITY_GUARD_MAX_RETRIES),
);
}
function normalizeFailureText(text: string): string {
return text.replace(/\s+/g, " ").trim();
}
function truncateFailureText(text: string, maxChars: number): string {
if (text.length <= maxChars) {
return text;
}
return `${truncateUtf16Safe(text, Math.max(0, maxChars - 3))}...`;
}
function formatToolFailureMeta(details: unknown): string | undefined {
if (!details || typeof details !== "object") {
return undefined;
}
const record = details as Record<string, unknown>;
const status = typeof record.status === "string" ? record.status : undefined;
const exitCode =
typeof record.exitCode === "number" && Number.isFinite(record.exitCode)
? record.exitCode
: undefined;
const parts = [
status ? `status=${status}` : "",
exitCode !== undefined ? `exitCode=${exitCode}` : "",
];
return parts.filter(Boolean).join(" ") || undefined;
return (
[
typeof record.status === "string" && record.status ? `status=${record.status}` : "",
typeof record.exitCode === "number" && Number.isFinite(record.exitCode)
? `exitCode=${record.exitCode}`
: "",
]
.filter(Boolean)
.join(" ") || undefined
);
}
function collectToolFailures(messages: AgentMessage[]): ToolFailure[] {
@@ -530,13 +482,14 @@ function collectToolFailures(messages: AgentMessage[]): ToolFailure[] {
typeof toolResult.toolName === "string" && toolResult.toolName.trim()
? toolResult.toolName
: "tool";
const rawText = collectTextContentBlocks(toolResult.content).join("\n");
const meta = formatToolFailureMeta(toolResult.details);
const normalized = normalizeFailureText(rawText);
const summary = truncateFailureText(
normalized || (meta ? "failed" : "failed (no output)"),
MAX_TOOL_FAILURE_CHARS,
);
const failureText =
collectTextContentBlocks(toolResult.content).join("\n").replace(/\s+/g, " ").trim() ||
(meta ? "failed" : "failed (no output)");
const summary =
failureText.length > MAX_TOOL_FAILURE_CHARS
? `${truncateUtf16Safe(failureText, MAX_TOOL_FAILURE_CHARS - 3)}...`
: failureText;
failures.push({ toolCallId, toolName, summary, meta });
}
@@ -671,10 +624,7 @@ function extractMessageText(message: AgentMessage): string {
}
function formatNonTextPlaceholder(content: unknown): string | null {
if (content === null || content === undefined) {
return null;
}
if (typeof content === "string") {
if (content == null || typeof content === "string") {
return null;
}
if (!Array.isArray(content)) {
@@ -692,22 +642,21 @@ function formatNonTextPlaceholder(content: unknown): string | null {
}
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(", ")}]`;
return typeCounts.size > 0
? `[non-text content: ${Array.from(typeCounts, ([type, count]) =>
count > 1 ? `${type} x${count}` : type,
).join(", ")}]`
: null;
}
function splitPreservedRecentTurns(params: {
messages: AgentMessage[];
recentTurnsPreserve: number;
}): { summarizableMessages: AgentMessage[]; preservedMessages: AgentMessage[] } {
const preserveTurns = Math.min(
const preserveTurns = clampNonNegativeInt(
params.recentTurnsPreserve,
0,
MAX_RECENT_TURNS_PRESERVE,
clampNonNegativeInt(params.recentTurnsPreserve, 0),
);
if (preserveTurns <= 0) {
return { summarizableMessages: params.messages, preservedMessages: [] };
@@ -722,18 +671,13 @@ function splitPreservedRecentTurns(params: {
const userIndexes = conversationIndexes.filter(
(index) => params.messages[index]?.role === "user",
);
const preservedIndexSet = new Set<number>();
if (userIndexes.length >= preserveTurns) {
const boundaryStartIndex = userIndexes[userIndexes.length - preserveTurns] ?? -1;
for (const index of conversationIndexes) {
if (index >= boundaryStartIndex) {
preservedIndexSet.add(index);
}
}
} else {
for (const userIndex of userIndexes) {
preservedIndexSet.add(userIndex);
}
const boundaryStartIndex = userIndexes.at(-preserveTurns);
const preservedIndexSet = new Set(
boundaryStartIndex === undefined
? userIndexes
: conversationIndexes.filter((index) => index >= boundaryStartIndex),
);
if (boundaryStartIndex === undefined) {
for (const index of conversationIndexes.toReversed()) {
preservedIndexSet.add(index);
if (preservedIndexSet.size >= preserveTurns * 2) {
@@ -791,12 +735,12 @@ function formatContextMessages(messages: AgentMessage[]): string[] {
} else {
return null;
}
const text = extractMessageText(message);
const nonTextPlaceholder = formatNonTextPlaceholder(
(message as { content?: unknown }).content,
);
const rendered =
text && nonTextPlaceholder ? `${text}\n${nonTextPlaceholder}` : text || nonTextPlaceholder;
const rendered = [
extractMessageText(message),
formatNonTextPlaceholder((message as { content?: unknown }).content),
]
.filter(Boolean)
.join("\n");
if (!rendered) {
return null;
}
@@ -1107,14 +1051,13 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
let droppedSummary: string | undefined;
if (tokensBefore !== undefined) {
const prunePlan = await buildHistoryPrunePlanWithWorker({
const prunePlan = buildHistoryPrunePlan({
messagesToSummarize,
turnPrefixMessages,
tokensBefore,
contextWindowTokens,
maxHistoryShare,
parts: 2,
signal,
});
const { newContentTokens, maxHistoryTokens, pruned } = prunePlan;
@@ -1171,18 +1114,15 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
});
messagesToSummarize = summaryTargetMessages;
const preservedTurnsSectionLocal = formatPreservedTurnsSection(preservedRecentMessages);
const latestUserAsk = extractLatestUserAsk([...messagesToSummarize, ...turnPrefixMessages]);
const identifierSeedText = [...messagesToSummarize, ...turnPrefixMessages]
.slice(-10)
.map((message) => extractMessageText(message))
.filter(Boolean)
.join("\n");
const identifiers = extractOpaqueIdentifiers(identifierSeedText);
const allMessages = [...messagesToSummarize, ...turnPrefixMessages];
const latestUserAsk = extractLatestUserAsk(allMessages);
const identifiers = extractOpaqueIdentifiers(
allMessages.slice(-10).map(extractMessageText).filter(Boolean).join("\n"),
);
// 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.
const allMessages = [...messagesToSummarize, ...turnPrefixMessages];
const adaptiveRatio = await computeAdaptiveChunkRatioWithWorker({
messages: allMessages,
contextWindow: contextWindowTokens,
@@ -1196,7 +1136,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
// incorporates context from pruned messages instead of losing it entirely.
const effectivePreviousSummary = droppedSummary ?? preparation.previousSummary;
let summary = "";
let lastHistorySummary = "";
let lastSplitTurnSection = "";
let currentInstructions = structuredInstructions;
@@ -1218,7 +1157,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
customInstructions: currentInstructions,
previousSummary: effectivePreviousSummary,
})
: buildStructuredFallbackSummary(effectivePreviousSummary, summarizationInstructions);
: buildStructuredFallbackSummary(effectivePreviousSummary);
summaryWithoutPreservedTurns = historySummary;
if (preparation.isSplitTurn && turnPrefixMessages.length > 0) {
@@ -1226,10 +1165,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
...llmSummaryParams,
messages: turnPrefixMessages,
maxChunkTokens,
customInstructions: composeSplitTurnInstructions(
TURN_PREFIX_INSTRUCTIONS,
currentInstructions,
),
customInstructions: `${TURN_PREFIX_INSTRUCTIONS}\n\nAdditional requirements:\n\n${currentInstructions}`,
previousSummary: undefined,
});
splitTurnSectionLocal = `**Turn Context (split turn):**\n\n${prefixSummary}`;
@@ -1247,7 +1183,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
`Compaction safeguard: quality retry failed on attempt ${attempt + 1}; ` +
`keeping last successful summary: ${formatErrorMessage(attemptError)}`,
);
summary = lastSuccessfulSummary;
break;
}
throw attemptError;
@@ -1260,7 +1195,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
messagesToSummarize.length > 0 ||
(preparation.isSplitTurn && turnPrefixMessages.length > 0);
if (!qualityGuardEnabled || !canRegenerate) {
summary = summaryWithPreservedTurns;
break;
}
const quality = auditSummaryQuality({
@@ -1269,7 +1203,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
latestAsk: latestUserAsk,
identifierPolicy,
});
summary = summaryWithPreservedTurns;
if (quality.ok || attempt >= totalAttempts - 1) {
break;
}
@@ -1288,7 +1221,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
}
// Cap history before suffixes so diagnostics and workspace rules survive.
return await finalizeSummary(lastHistorySummary || summary, {
return await finalizeSummary(lastHistorySummary || lastSuccessfulSummary || "", {
splitTurnSection: lastSplitTurnSection,
preservedTurnsSection: preservedTurnsSectionLocal,
});

View File

@@ -126,28 +126,21 @@ function jsonLengthWithin(
return length;
}
function projectToolArguments(
value: unknown,
budget: ProjectionBudget,
): { value: Record<string, never>; omittedChars: number; changed: boolean } {
function projectToolArguments(value: unknown, budget: ProjectionBudget): number | undefined {
const length = jsonLengthWithin(value, budget.remainingChars);
if (length !== undefined) {
budget.remainingChars -= length;
return { value: {}, omittedChars: 0, changed: false };
return undefined;
}
budget.remainingChars = 0;
return {
value: {},
// Unmeasurable arguments must force an oversized plan, never understate token pressure.
omittedChars:
jsonLengthWithin(value, MAX_ARGUMENT_ESTIMATE_CHARS) ?? UNMEASURABLE_ARGUMENT_OMITTED_CHARS,
changed: true,
};
// Unmeasurable arguments must force an oversized plan, never understate token pressure.
return (
jsonLengthWithin(value, MAX_ARGUMENT_ESTIMATE_CHARS) ?? UNMEASURABLE_ARGUMENT_OMITTED_CHARS
);
}
function projectContentBlock(
block: unknown,
projectTextContent: boolean,
budget: ProjectionBudget,
): { block: unknown; omittedChars: number; changed: boolean } {
if (!block || typeof block !== "object") {
@@ -162,9 +155,6 @@ function projectContentBlock(
changed: true,
};
}
if (!projectTextContent) {
return { block, omittedChars: 0, changed: false };
}
const hasText = typeof record.text === "string" && record.text.length > 0;
const textIsModelVisible =
type === "text" || ((type === "toolResult" || type === "tool_result") && hasText);
@@ -172,50 +162,41 @@ function projectContentBlock(
(type === "toolResult" || type === "tool_result") &&
!hasText &&
typeof record.content === "string";
const projectedText = typeof record.text === "string" ? projectText(record.text, budget) : null;
const projectedContent =
typeof record.content === "string" ? projectText(record.content, budget) : null;
const projectedThinking =
type === "thinking" && typeof record.thinking === "string"
? projectText(record.thinking, budget)
: null;
const projectedArguments =
type === "toolCall" ? projectToolArguments(record.arguments, budget) : undefined;
// Tool-call IDs are provider-generated and bounded in practice. Planning never uses them as
// durable keys, so malformed giant IDs do not justify an ID-remapping protocol here.
const hasPlanningIrrelevantSignature =
"textSignature" in record || "thinkingSignature" in record || "thoughtSignature" in record;
if (
!projectedText &&
!projectedContent &&
!projectedThinking &&
!projectedArguments?.changed &&
!hasPlanningIrrelevantSignature
) {
return { block, omittedChars: 0, changed: false };
}
const next = { ...record };
let next: Record<string, unknown> | undefined;
let omittedChars = 0;
if (projectedText) {
next.text = projectedText.text;
omittedChars += textIsModelVisible ? projectedText.omittedChars : 0;
for (const field of ["text", "content", "thinking"] as const) {
if (field === "thinking" && type !== "thinking") {
continue;
}
const value = record[field];
const projected = typeof value === "string" ? projectText(value, budget) : null;
if (!projected) {
continue;
}
next ??= { ...record };
next[field] = projected.text;
const modelVisible =
field === "thinking" || (field === "text" ? textIsModelVisible : contentIsModelVisible);
omittedChars += modelVisible ? projected.omittedChars : 0;
}
if (projectedContent) {
next.content = projectedContent.text;
omittedChars += contentIsModelVisible ? projectedContent.omittedChars : 0;
if (type === "toolCall") {
const omittedArguments = projectToolArguments(record.arguments, budget);
if (omittedArguments !== undefined) {
next ??= { ...record };
next.arguments = {};
omittedChars += omittedArguments;
}
}
if (projectedThinking) {
next.thinking = projectedThinking.text;
omittedChars += projectedThinking.omittedChars;
// Signatures never contribute model-visible compaction text and can dwarf the planning payload.
for (const signature of ["textSignature", "thinkingSignature", "thoughtSignature"]) {
if (signature in record) {
next ??= { ...record };
delete next[signature];
}
}
if (projectedArguments?.changed) {
next.arguments = projectedArguments.value;
omittedChars += projectedArguments.omittedChars;
}
delete next.textSignature;
delete next.thinkingSignature;
delete next.thoughtSignature;
return { block: next, omittedChars, changed: true };
return next
? { block: next, omittedChars, changed: true }
: { block, omittedChars, changed: false };
}
function projectStringFields(
@@ -245,43 +226,24 @@ function projectStringFields(
}
function projectMessage(message: AgentMessage, budget: ProjectionBudget): AgentMessage {
const source = (() => {
switch (message.role) {
case "assistant":
return {
role: message.role,
content: message.content,
stopReason: message.stopReason,
timestamp: message.timestamp,
} as AgentMessage;
case "bashExecution": {
const { fullOutputPath: _, ...rest } = message;
return rest as AgentMessage;
}
case "compactionSummary": {
const { details: _, ...rest } = message;
return rest as AgentMessage;
}
case "custom": {
const { details: _, ...rest } = message;
return rest as AgentMessage;
}
default:
return message;
}
})();
const currentOmittedChars = readCompactionPlanningOmittedChars(source);
let source = message;
if (message.role === "assistant") {
source = {
role: message.role,
content: message.content,
stopReason: message.stopReason,
timestamp: message.timestamp,
} as AgentMessage;
} else if (message.role === "bashExecution") {
const { fullOutputPath: _, ...rest } = message;
source = rest as AgentMessage;
} else if (message.role === "compactionSummary" || message.role === "custom") {
const { details: _, ...rest } = message;
source = rest as AgentMessage;
}
const content = (source as { content?: unknown }).content;
if (typeof content === "string") {
const projected = projectText(content, budget);
if (!projected) {
return source;
}
return {
...(source as unknown as Record<string, unknown>),
content: projected.text,
[OMITTED_CHARS_FIELD]: currentOmittedChars + projected.omittedChars,
} as unknown as AgentMessage;
return projectStringFields(source, ["content"], budget);
}
if (!Array.isArray(content)) {
switch (source.role) {
@@ -298,7 +260,7 @@ function projectMessage(message: AgentMessage, budget: ProjectionBudget): AgentM
let omittedChars = 0;
let changed = false;
const projectedContent = content.map((block) => {
const projected = projectContentBlock(block, true, budget);
const projected = projectContentBlock(block, budget);
omittedChars += projected.omittedChars;
changed ||= projected.changed;
return projected.block;
@@ -309,7 +271,7 @@ function projectMessage(message: AgentMessage, budget: ProjectionBudget): AgentM
return {
...(source as unknown as Record<string, unknown>),
content: projectedContent,
[OMITTED_CHARS_FIELD]: currentOmittedChars + omittedChars,
[OMITTED_CHARS_FIELD]: readCompactionPlanningOmittedChars(source) + omittedChars,
} as unknown as AgentMessage;
}

View File

@@ -8,14 +8,12 @@ import { Worker } from "node:worker_threads";
import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
import { toErrorObject } from "../infra/errors.js";
import {
buildHistoryPrunePlan,
buildOversizedFallbackPlan,
buildStageSplitPlan,
buildSummaryChunks,
computeAdaptiveChunkRatio,
projectCompactionMessagesForPlanning,
sanitizeCompactionMessages,
type HistoryPrunePlan,
type OversizedFallbackPlan,
type StageSplitPlan,
} from "./compaction-planning.js";
@@ -60,13 +58,13 @@ function runCompactionPlanningWorker(params: {
timeoutMs?: number;
workerUrl?: URL;
}): Promise<CompactionPlanningWorkerValue> {
if (params.signal?.aborted) {
return Promise.reject(
toErrorObject(
params.signal.reason ?? new Error("compaction planning aborted"),
"Non-Error rejection",
),
const abortError = () =>
toErrorObject(
params.signal?.reason ?? new Error("compaction planning aborted"),
"Non-Error rejection",
);
if (params.signal?.aborted) {
return Promise.reject(abortError());
}
const workerUrl = params.workerUrl ?? resolveCompactionPlanningWorkerUrl();
@@ -91,30 +89,11 @@ function runCompactionPlanningWorker(params: {
return new Promise<CompactionPlanningWorkerValue>((resolve, reject) => {
let settled = false;
const timeout = setTimeout(
() => {
settle(
() =>
reject(
new CompactionPlanningWorkerError("compaction planning worker timed out", "timeout"),
),
true,
);
},
() =>
fail(new CompactionPlanningWorkerError("compaction planning worker timed out", "timeout")),
resolveTimerTimeoutMs(params.timeoutMs, COMPACTION_PLANNING_WORKER_TIMEOUT_MS),
);
const abort = () => {
settle(
() =>
reject(
toErrorObject(
params.signal?.reason ?? new Error("compaction planning aborted"),
"Non-Error rejection",
),
),
true,
);
};
const abort = () => fail(abortError());
const settle = (finish: () => void, terminate: boolean) => {
if (settled) {
@@ -129,6 +108,7 @@ function runCompactionPlanningWorker(params: {
}
finish();
};
const fail = (error: Error, terminate = true) => settle(() => reject(error), terminate);
params.signal?.addEventListener("abort", abort, { once: true });
@@ -143,20 +123,17 @@ function runCompactionPlanningWorker(params: {
});
worker.once("error", (error) => {
const message = error instanceof Error ? error.message : String(error);
settle(() => reject(new CompactionPlanningWorkerError(message, "unavailable")), true);
fail(new CompactionPlanningWorkerError(message, "unavailable"));
});
worker.once("exit", (code) => {
if (code === 0) {
return;
}
settle(
() =>
reject(
new CompactionPlanningWorkerError(
`compaction planning worker exited with code ${code}`,
"unavailable",
),
),
fail(
new CompactionPlanningWorkerError(
`compaction planning worker exited with code ${code}`,
"unavailable",
),
false,
);
});
@@ -222,14 +199,11 @@ export async function buildSummaryChunksWithWorker(params: {
maxChunkTokens: number;
signal?: AbortSignal;
}): Promise<AgentMessage[][]> {
const { signal, ...planningInput } = params;
return runCompactionPlan({
input: {
kind: "summaryChunks",
messages: params.messages,
maxChunkTokens: params.maxChunkTokens,
},
signal: params.signal,
fallback: (messages) => buildSummaryChunks({ messages, maxChunkTokens: params.maxChunkTokens }),
input: { kind: "summaryChunks", ...planningInput },
signal,
fallback: (messages) => buildSummaryChunks({ ...planningInput, messages }),
restore: (value, messages) =>
value.chunkIndexes.map((indexes) => restoreIndexedMessages(messages, indexes)),
});
@@ -241,15 +215,11 @@ export async function buildOversizedFallbackPlanWithWorker(params: {
contextWindow: number;
signal?: AbortSignal;
}): Promise<OversizedFallbackPlan> {
const { signal, ...planningInput } = params;
return runCompactionPlan({
input: {
kind: "oversizedFallback",
messages: params.messages,
contextWindow: params.contextWindow,
},
signal: params.signal,
fallback: (messages) =>
buildOversizedFallbackPlan({ messages, contextWindow: params.contextWindow }),
input: { kind: "oversizedFallback", ...planningInput },
signal,
fallback: (messages) => buildOversizedFallbackPlan({ ...planningInput, messages }),
restore: (value, messages) => ({
smallMessages: restoreIndexedMessages(messages, value.smallMessageIndexes),
oversizedNotes: value.oversizedNotes,
@@ -265,22 +235,11 @@ export async function buildStageSplitPlanWithWorker(params: {
minMessagesForSplit?: number;
signal?: AbortSignal;
}): Promise<StageSplitPlan> {
const { signal, ...planningInput } = params;
return runCompactionPlan({
input: {
kind: "stageSplit",
messages: params.messages,
maxChunkTokens: params.maxChunkTokens,
parts: params.parts,
minMessagesForSplit: params.minMessagesForSplit,
},
signal: params.signal,
fallback: (messages) =>
buildStageSplitPlan({
messages,
maxChunkTokens: params.maxChunkTokens,
parts: params.parts,
minMessagesForSplit: params.minMessagesForSplit,
}),
input: { kind: "stageSplit", ...planningInput },
signal,
fallback: (messages) => buildStageSplitPlan({ ...planningInput, messages }),
restore: (value, messages) =>
value.mode === "split"
? {
@@ -291,38 +250,17 @@ export async function buildStageSplitPlanWithWorker(params: {
});
}
/**
* Builds a history-pruning plan on the owner thread.
*
* Pruning repairs tool-result pairs and returns exact retained/dropped messages,
* so a bounded selection projection cannot reconstruct every result faithfully.
*/
export async function buildHistoryPrunePlanWithWorker(params: {
messagesToSummarize: AgentMessage[];
turnPrefixMessages: AgentMessage[];
tokensBefore: number;
contextWindowTokens: number;
maxHistoryShare: number;
parts?: number;
signal?: AbortSignal;
}): Promise<HistoryPrunePlan> {
return buildHistoryPrunePlan(params);
}
/** Computes the adaptive compaction chunk ratio with worker fallback. */
export async function computeAdaptiveChunkRatioWithWorker(params: {
messages: AgentMessage[];
contextWindow: number;
signal?: AbortSignal;
}): Promise<number> {
const { signal, ...planningInput } = params;
return runCompactionPlan({
input: {
kind: "adaptiveChunkRatio",
messages: params.messages,
contextWindow: params.contextWindow,
},
signal: params.signal,
fallback: () => computeAdaptiveChunkRatio(params.messages, params.contextWindow),
input: { kind: "adaptiveChunkRatio", ...planningInput },
signal,
fallback: () => computeAdaptiveChunkRatio(planningInput.messages, planningInput.contextWindow),
restore: (value) => value.ratio,
});
}
@@ -330,7 +268,6 @@ export async function computeAdaptiveChunkRatioWithWorker(params: {
const compactionPlanningWorkerTesting = {
resolveCompactionPlanningWorkerUrl,
runCompactionPlanningWorker,
CompactionPlanningWorkerError,
};
if (process.env.VITEST || process.env.NODE_ENV === "test") {

View File

@@ -44,7 +44,7 @@ export type OversizedFallbackPlan = {
};
/** Token accounting and optional prune result for preserving context-window headroom. */
export type HistoryPrunePlan = {
type HistoryPrunePlan = {
summarizableTokens: number;
newContentTokens: number;
maxHistoryTokens: number;
@@ -80,11 +80,7 @@ export function sanitizeCompactionMessages(messages: AgentMessage[]): AgentMessa
}
function estimateCompactionPlanningTokens(message: AgentMessage): number {
const omittedChars = readCompactionPlanningOmittedChars(message);
if (omittedChars === 0) {
return estimateTokens(message);
}
return estimateTokens(message) + Math.ceil(omittedChars / 4);
return estimateTokens(message) + Math.ceil(readCompactionPlanningOmittedChars(message) / 4);
}
/** Builds a bounded planning projection that preserves token pressure accounting. */
@@ -115,23 +111,9 @@ function groupCompactionMessages(
let currentTokens = 0;
let pendingToolCallIds = new Set<string>();
const finishCurrentGroup = () => {
if (current.length === 0) {
return;
}
groups.push({ messages: current, tokens: currentTokens });
current = [];
currentTokens = 0;
};
for (const [index, message] of messages.entries()) {
const messageTokens = perMessageTokens.at(index);
if (messageTokens === undefined) {
throw new Error("Compaction token estimates are out of sync with messages");
}
current.push(message);
currentTokens += messageTokens;
currentTokens += perMessageTokens[index]!;
if (message.role === "assistant") {
const stopReason = (message as { stopReason?: unknown }).stopReason;
@@ -152,41 +134,34 @@ function groupCompactionMessages(
// A displaced user turn still belongs to an unfinished call/result batch;
// splitting it would make one of the resulting provider transcripts invalid.
if (pendingToolCallIds.size === 0) {
finishCurrentGroup();
groups.push({ messages: current, tokens: currentTokens });
current = [];
currentTokens = 0;
}
}
finishCurrentGroup();
if (current.length > 0) {
groups.push({ messages: current, tokens: currentTokens });
}
return groups;
}
/** Splits messages into roughly equal token-share chunks without separating active tool pairs. */
function splitMessagesByTokenShare(
/** Chunks atomic tool-call groups without splitting a provider-visible call/result pair. */
function chunkCompactionMessageGroups(
messages: AgentMessage[],
parts = DEFAULT_PARTS,
maxTokens: number,
perMessageTokens: number[],
maxChunks = Number.POSITIVE_INFINITY,
): AgentMessage[][] {
if (messages.length === 0) {
return [];
}
const normalizedParts = normalizeCompactionParts(parts, messages.length);
if (normalizedParts <= 1) {
return [messages];
}
// Sanitize the full array once and reuse per-message token counts; avoids the
// per-message [msg] wrap-and-clone that previously ran on every iteration.
const perMessageTokens = estimatePerMessageTokens(messages);
const totalTokens = perMessageTokens.reduce((sum, tokens) => sum + tokens, 0);
const targetTokens = totalTokens / normalizedParts;
const chunks: AgentMessage[][] = [];
let current: AgentMessage[] = [];
let currentTokens = 0;
for (const group of groupCompactionMessages(messages, perMessageTokens)) {
if (
chunks.length < normalizedParts - 1 &&
current.length > 0 &&
currentTokens + group.tokens > targetTokens
chunks.length < maxChunks - 1 &&
currentTokens + group.tokens > maxTokens
) {
chunks.push(current);
current = [];
@@ -204,47 +179,26 @@ function splitMessagesByTokenShare(
return chunks;
}
/** Chunks messages by a max-token budget while applying the shared estimator safety margin. */
function chunkMessagesByMaxTokens(messages: AgentMessage[], maxTokens: number): AgentMessage[][] {
/** Splits messages into roughly equal token-share chunks without separating active tool pairs. */
function splitMessagesByTokenShare(
messages: AgentMessage[],
parts = DEFAULT_PARTS,
): AgentMessage[][] {
if (messages.length === 0) {
return [];
}
// Apply safety margin to compensate for estimateTokens() underestimation
// (chars/4 heuristic misses multi-byte chars, special tokens, code tokens, etc.)
const effectiveMax = Math.max(1, Math.floor(maxTokens / SAFETY_MARGIN));
// Sanitize the full array once and reuse per-message token counts; avoids the
// per-message [msg] wrap-and-clone that previously ran on every iteration.
const normalizedParts = normalizeCompactionParts(parts, messages.length);
if (normalizedParts <= 1) {
return [messages];
}
const perMessageTokens = estimatePerMessageTokens(messages);
const chunks: AgentMessage[][] = [];
let currentChunk: AgentMessage[] = [];
let currentTokens = 0;
for (const group of groupCompactionMessages(messages, perMessageTokens)) {
if (currentChunk.length > 0 && currentTokens + group.tokens > effectiveMax) {
chunks.push(currentChunk);
currentChunk = [];
currentTokens = 0;
}
currentChunk.push(...group.messages);
currentTokens += group.tokens;
if (group.tokens > effectiveMax) {
// A tool batch is indivisible even above the heuristic budget; the
// oversized-summary fallback can handle it without orphaning its result.
chunks.push(currentChunk);
currentChunk = [];
currentTokens = 0;
}
}
if (currentChunk.length > 0) {
chunks.push(currentChunk);
}
return chunks;
const totalTokens = perMessageTokens.reduce((sum, tokens) => sum + tokens, 0);
return chunkCompactionMessageGroups(
messages,
totalTokens / normalizedParts,
perMessageTokens,
normalizedParts,
);
}
/**
@@ -256,12 +210,8 @@ export function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindo
return BASE_CHUNK_RATIO;
}
const totalTokens = estimateMessagesTokens(messages);
const avgTokens = totalTokens / messages.length;
// Apply safety margin to account for estimation inaccuracy
const safeAvgTokens = avgTokens * SAFETY_MARGIN;
const avgRatio = safeAvgTokens / contextWindow;
const avgRatio =
((estimateMessagesTokens(messages) / messages.length) * SAFETY_MARGIN) / contextWindow;
// If average message is > 10% of context, reduce chunk ratio
if (avgRatio > 0.1) {
@@ -285,7 +235,13 @@ export function buildSummaryChunks(params: {
}): AgentMessage[][] {
// SECURITY: never feed toolResult.details or runtime-context transcript entries into summarization prompts.
const safeMessages = sanitizeCompactionMessages(params.messages);
return chunkMessagesByMaxTokens(safeMessages, params.maxChunkTokens);
// The estimator can undercount Unicode/code tokens; indivisible tool batches may exceed this cap.
const effectiveMax = Math.max(1, Math.floor(params.maxChunkTokens / SAFETY_MARGIN));
return chunkCompactionMessageGroups(
safeMessages,
effectiveMax,
estimatePerMessageTokens(safeMessages),
);
}
/** Separates messages too large to summarize and emits compact placeholder notes for them. */
@@ -355,9 +311,8 @@ export function buildStageSplitPlan(params: {
function pruneHistoryForContextShare(params: {
messages: AgentMessage[];
maxContextTokens: number;
maxHistoryShare?: number;
maxHistoryShare: number;
parts?: number;
mode?: "share" | "handoff";
}): {
messages: AgentMessage[];
droppedMessagesList: AgentMessage[];
@@ -367,10 +322,7 @@ function pruneHistoryForContextShare(params: {
keptTokens: number;
budgetTokens: number;
} {
const isHandoff = params.mode === "handoff";
const defaultShare = isHandoff ? 0.2 : 0.5; // Stricter budget for handoff snapshots
const maxHistoryShare = params.maxHistoryShare ?? defaultShare;
const budgetTokens = Math.max(1, Math.floor(params.maxContextTokens * maxHistoryShare));
const budgetTokens = Math.max(1, Math.floor(params.maxContextTokens * params.maxHistoryShare));
let keptMessages = params.messages;
const allDroppedMessages: AgentMessage[] = [];
let droppedChunks = 0;
@@ -384,31 +336,15 @@ function pruneHistoryForContextShare(params: {
if (chunks.length <= 1) {
break;
}
const [dropped, ...rest] = chunks;
if (!dropped) {
break;
}
const flatRest = rest.flat();
// After dropping a chunk, repair tool_use/tool_result pairing to handle
// orphaned tool_results (whose tool_use was in the dropped chunk).
// repairToolUseResultPairing drops orphaned tool_results, preventing
// "unexpected tool_use_id" errors from Anthropic's API.
const repairReport = repairToolUseResultPairing(flatRest);
const repairedKept = repairReport.messages;
// Track orphaned tool_results as dropped (they were in kept but their tool_use was dropped)
const orphanedCount = repairReport.droppedOrphanCount;
const dropped = chunks[0]!;
// Dropping a call owner also drops orphaned results; providers reject replay without the pair.
const repairReport = repairToolUseResultPairing(chunks.slice(1).flat());
droppedChunks += 1;
droppedMessages += dropped.length + orphanedCount;
droppedMessages += dropped.length + repairReport.droppedOrphanCount;
droppedTokens += estimateMessagesTokens(dropped);
// Note: We don't have the actual orphaned messages to add to droppedMessagesList
// since repairToolUseResultPairing doesn't return them. This is acceptable since
// the dropped messages are used for summarization, and orphaned tool_results
// without their tool_use context aren't useful for summarization anyway.
allDroppedMessages.push(...dropped);
keptMessages = repairedKept;
keptMessages = repairReport.messages;
}
return {
@@ -440,23 +376,16 @@ export function buildHistoryPrunePlan(params: {
params.contextWindowTokens * params.maxHistoryShare * SAFETY_MARGIN,
);
if (newContentTokens <= maxHistoryTokens) {
return {
summarizableTokens,
newContentTokens,
maxHistoryTokens,
};
}
return {
summarizableTokens,
newContentTokens,
maxHistoryTokens,
pruned: pruneHistoryForContextShare({
messages: params.messagesToSummarize,
maxContextTokens: params.contextWindowTokens,
maxHistoryShare: params.maxHistoryShare,
parts: params.parts,
}),
};
const plan = { summarizableTokens, newContentTokens, maxHistoryTokens };
return newContentTokens <= maxHistoryTokens
? plan
: {
...plan,
pruned: pruneHistoryForContextShare({
messages: params.messagesToSummarize,
maxContextTokens: params.contextWindowTokens,
maxHistoryShare: params.maxHistoryShare,
parts: params.parts,
}),
};
}

View File

@@ -12,23 +12,9 @@ import type { AgentMessage } from "./runtime/index.js";
/** Serializable request accepted by the compaction planning worker. */
export type CompactionPlanningWorkerInput =
| {
kind: "summaryChunks";
messages: AgentMessage[];
maxChunkTokens: number;
}
| {
kind: "oversizedFallback";
messages: AgentMessage[];
contextWindow: number;
}
| {
kind: "stageSplit";
messages: AgentMessage[];
maxChunkTokens: number;
parts?: number;
minMessagesForSplit?: number;
}
| ({ kind: "summaryChunks" } & Parameters<typeof buildSummaryChunks>[0])
| ({ kind: "oversizedFallback" } & Parameters<typeof buildOversizedFallbackPlan>[0])
| ({ kind: "stageSplit" } & Parameters<typeof buildStageSplitPlan>[0])
| {
kind: "adaptiveChunkRatio";
messages: AgentMessage[];
@@ -46,15 +32,7 @@ export type CompactionPlanningWorkerValue =
smallMessageIndexes: number[];
oversizedNotes: string[];
}
| {
kind: "stageSplit";
mode: "single";
}
| {
kind: "stageSplit";
mode: "split";
chunkIndexes: number[][];
}
| ({ kind: "stageSplit" } & ({ mode: "single" } | { mode: "split"; chunkIndexes: number[][] }))
| {
kind: "adaptiveChunkRatio";
ratio: number;
@@ -142,19 +120,13 @@ function planCompactionWorkerInput(
/** Run one compaction planning request and return a serializable result. */
export function runCompactionPlanningWorkerInput(input: unknown): CompactionPlanningWorkerResult {
if (!isWorkerInput(input)) {
return {
status: "failed",
error: "invalid compaction planning worker input",
};
return { status: "failed", error: "invalid compaction planning worker input" };
}
try {
return { status: "ok", value: planCompactionWorkerInput(input) };
} catch (error) {
return {
status: "failed",
error: error instanceof Error ? error.message : String(error),
};
return { status: "failed", error: error instanceof Error ? error.message : String(error) };
}
}

View File

@@ -17,17 +17,11 @@ const NON_CONVERSATION_BLOCK_TYPES = new Set([
function hasMeaningfulText(text: string): boolean {
const trimmed = text.trim();
if (!trimmed) {
return false;
}
if (isSilentReplyText(trimmed)) {
if (!trimmed || isSilentReplyText(trimmed)) {
return false;
}
const heartbeat = stripHeartbeatToken(trimmed, { mode: "message" });
if (heartbeat.didStrip) {
return heartbeat.text.trim().length > 0;
}
return true;
return !heartbeat.didStrip || heartbeat.text.trim().length > 0;
}
function isSummaryRole(role: unknown): boolean {
@@ -73,22 +67,15 @@ function hasMeaningfulMessageContent(content: unknown): boolean {
if (!block || typeof block !== "object") {
continue;
}
const type = (block as { type?: unknown }).type;
if (type !== "text") {
const { type, text } = block as { type?: unknown; text?: unknown };
if (type === "text") {
if (typeof text === "string" && hasMeaningfulText(text)) {
return true;
}
} else if (typeof type !== "string" || !NON_CONVERSATION_BLOCK_TYPES.has(type)) {
// Tool-call metadata and internal reasoning blocks do not make a
// heartbeat-only transcript count as real conversation.
if (typeof type === "string" && NON_CONVERSATION_BLOCK_TYPES.has(type)) {
continue;
}
sawMeaningfulNonTextBlock = true;
continue;
}
const text = (block as { text?: unknown }).text;
if (typeof text !== "string") {
continue;
}
if (hasMeaningfulText(text)) {
return true;
}
}
return sawMeaningfulNonTextBlock;
@@ -123,10 +110,7 @@ export function isRealConversationMessage(
const start = Math.max(0, index - TOOL_RESULT_REAL_CONVERSATION_LOOKBACK);
for (let i = index - 1; i >= start; i -= 1) {
const candidate = messages[i];
if (!candidate) {
continue;
}
if (isToolResultConversationAnchor(candidate)) {
if (candidate && isToolResultConversationAnchor(candidate)) {
return true;
}
}

View File

@@ -6,16 +6,8 @@ import type { AgentMessage } from "./runtime/index.js";
import { makeZeroUsageSnapshot } from "./usage.js";
function parseCompactionUsageTimestamp(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
const timestamp = typeof value === "string" ? Date.parse(value) : value;
return typeof timestamp === "number" && Number.isFinite(timestamp) ? timestamp : null;
}
export function stripStaleAssistantUsageBeforeLatestCompaction<TMessage extends AgentMessage>(
@@ -25,56 +17,46 @@ export function stripStaleAssistantUsageBeforeLatestCompaction<TMessage extends
whenMissingCompactionSummary?: "preserve" | "zeroAssistantUsage";
} = {},
): TMessage[] {
let latestCompactionSummaryIndex = -1;
let latestCompactionTimestamp: number | null = null;
for (let i = 0; i < messages.length; i += 1) {
const entry = messages[i];
if (entry?.role !== "compactionSummary") {
continue;
}
latestCompactionSummaryIndex = i;
latestCompactionTimestamp = parseCompactionUsageTimestamp(
(entry as { timestamp?: unknown }).timestamp ?? null,
);
}
const latestCompactionSummaryIndex = messages.findLastIndex(
(entry) => entry?.role === "compactionSummary",
);
const hasCompactionSummary = latestCompactionSummaryIndex !== -1;
if (!hasCompactionSummary && options.whenMissingCompactionSummary !== "zeroAssistantUsage") {
return messages;
}
const out = options.mutate ? messages : [...messages];
let touched = false;
for (let i = 0; i < out.length; i += 1) {
const candidate = out[i] as
const latestCompactionTimestamp = parseCompactionUsageTimestamp(
(messages[latestCompactionSummaryIndex] as { timestamp?: unknown } | undefined)?.timestamp,
);
let out = messages;
for (let i = 0; i < messages.length; i += 1) {
const candidate = messages[i] as
| (AgentMessage & { usage?: unknown; timestamp?: unknown })
| undefined;
if (!candidate || candidate.role !== "assistant") {
continue;
}
if (!candidate.usage || typeof candidate.usage !== "object") {
if (
candidate?.role !== "assistant" ||
!candidate.usage ||
typeof candidate.usage !== "object"
) {
continue;
}
const messageTimestamp = parseCompactionUsageTimestamp(candidate.timestamp);
const compactionTimestamp = latestCompactionTimestamp;
const hasTimestampBoundary =
hasCompactionSummary && compactionTimestamp !== null && messageTimestamp !== null;
const staleByMissingSummary = !hasCompactionSummary;
const staleByTimestamp = hasTimestampBoundary && messageTimestamp <= compactionTimestamp;
const staleByLegacyOrdering =
hasCompactionSummary && !hasTimestampBoundary && i < latestCompactionSummaryIndex;
if (!staleByMissingSummary && !staleByTimestamp && !staleByLegacyOrdering) {
const stale =
!hasCompactionSummary ||
(latestCompactionTimestamp !== null && messageTimestamp !== null
? messageTimestamp <= latestCompactionTimestamp
: i < latestCompactionSummaryIndex);
if (!stale) {
continue;
}
// Session runtime expects assistant usage to stay structurally valid during
// accounting. Keep stale snapshots present, but zeroed after compaction.
const candidateRecord = candidate as unknown as Record<string, unknown>;
out[i] = {
...candidateRecord,
usage: makeZeroUsageSnapshot(),
} as unknown as TMessage;
touched = true;
if (out === messages && !options.mutate) {
out = [...messages];
}
out[i] = { ...candidate, usage: makeZeroUsageSnapshot() } as TMessage;
}
return touched ? out : messages;
return out;
}

View File

@@ -26,7 +26,7 @@ import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js";
import { isTimeoutError } from "./failover-error.js";
import type { AgentMessage, StreamFn, ThinkingLevel } from "./runtime/index.js";
import type { ExtensionContext } from "./sessions/index.js";
import { generateSummary as agentGenerateSummary } from "./sessions/index.js";
import { generateSummary } from "./sessions/index.js";
export {
BASE_CHUNK_RATIO,
@@ -71,18 +71,31 @@ export type CompactionSummarizationInstructions = {
identifierInstructions?: string;
};
type CompactionSummaryParams = {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
headers?: Record<string, string>;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
contextWindow: number;
customInstructions?: string;
summarizationInstructions?: CompactionSummarizationInstructions;
previousSummary?: string;
thinkingLevel?: ThinkingLevel;
streamFn?: StreamFn;
};
function resolveIdentifierPreservationInstructions(
instructions?: CompactionSummarizationInstructions,
): string | undefined {
const policy = instructions?.identifierPolicy ?? "strict";
if (policy === "off") {
if (instructions?.identifierPolicy === "off") {
return undefined;
}
if (policy === "custom") {
const custom = instructions?.identifierInstructions?.trim();
return custom && custom.length > 0 ? custom : IDENTIFIER_PRESERVATION_INSTRUCTIONS;
}
return IDENTIFIER_PRESERVATION_INSTRUCTIONS;
return instructions?.identifierPolicy === "custom"
? instructions.identifierInstructions?.trim() || IDENTIFIER_PRESERVATION_INSTRUCTIONS
: IDENTIFIER_PRESERVATION_INSTRUCTIONS;
}
/** Combines identifier-preservation and caller-provided compaction instructions. */
@@ -92,32 +105,15 @@ function buildCompactionSummarizationInstructions(
): string | undefined {
const custom = customInstructions?.trim();
const identifierPreservation = resolveIdentifierPreservationInstructions(instructions);
if (!identifierPreservation && !custom) {
return undefined;
}
if (!custom) {
return identifierPreservation;
}
if (!identifierPreservation) {
return `Additional focus:\n${custom}`;
}
return `${identifierPreservation}\n\nAdditional focus:\n${custom}`;
return identifierPreservation
? `${identifierPreservation}\n\nAdditional focus:\n${custom}`
: `Additional focus:\n${custom}`;
}
async function summarizeChunks(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
headers?: Record<string, string>;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
customInstructions?: string;
summarizationInstructions?: CompactionSummarizationInstructions;
previousSummary?: string;
thinkingLevel?: ThinkingLevel;
streamFn?: StreamFn;
}): Promise<string> {
async function summarizeChunks(params: CompactionSummaryParams): Promise<string> {
if (params.messages.length === 0) {
return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
}
@@ -132,8 +128,7 @@ async function summarizeChunks(params: {
params.customInstructions,
params.summarizationInstructions,
);
let hasGeneratedChunk = false;
for (const chunk of chunks) {
for (const [completedChunks, chunk] of chunks.entries()) {
try {
summary = await retryAsync(
() =>
@@ -158,43 +153,26 @@ async function summarizeChunks(params: {
// Backoff must honor caller cancellation; otherwise an abort during
// the sleep would stall compaction until the full delay elapses.
sleep: (ms) => sleepWithAbort(ms, params.signal),
shouldRetry: (err) => {
// Stop retrying when the caller explicitly cancelled.
if (params.signal.aborted) {
return false;
}
// Preserve existing non-retry policy for real network/transport
// timeouts (e.g. "fetch failed", ETIMEDOUT) that are not AbortErrors.
if (!isAbortError(err) && isTimeoutError(err)) {
return false;
}
// Provider-side AbortErrors with signal not yet aborted are
// transient disconnects — retrying is correct.
return true;
},
// Caller aborts and transport timeouts are terminal; provider-side
// AbortErrors without caller cancellation remain retryable.
shouldRetry: (err) =>
!params.signal.aborted && (isAbortError(err) || !isTimeoutError(err)),
},
);
hasGeneratedChunk = true;
} catch (err) {
// Propagate only when the caller explicitly cancelled. Provider-side
// AbortErrors (signal not aborted) fall through to partial/fallback paths.
if (params.signal.aborted) {
throw err;
}
// Real non-abort transport timeouts still propagate immediately.
if (!isAbortError(err) && isTimeoutError(err)) {
throw err;
}
// No chunk has succeeded yet — rethrow so summarizeWithFallback
// can run its existing "Context contained N messages" fallback.
if (!hasGeneratedChunk) {
// Caller aborts, transport timeouts, and failures before any completed
// chunk cannot produce a recoverable partial summary.
if (
params.signal.aborted ||
(!isAbortError(err) && isTimeoutError(err)) ||
completedChunks === 0
) {
throw err;
}
// At least one chunk succeeded — throw with the partial summary
// attached so summarizeWithFallback can try the oversized-message
// retry first and only fall back to the partial summary if that
// also fails.
const completedChunks = chunks.indexOf(chunk);
log.warn("chunk summarization failed after retries; partial summary available", {
err,
completedChunks,
@@ -210,65 +188,22 @@ async function summarizeChunks(params: {
return summary ?? DEFAULT_SUMMARY_FALLBACK;
}
function generateSummary(
currentMessages: AgentMessage[],
model: NonNullable<ExtensionContext["model"]>,
reserveTokens: number,
apiKey: string,
headers: Record<string, string> | undefined,
signal: AbortSignal,
customInstructions?: string,
previousSummary?: string,
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
): Promise<string> {
return agentGenerateSummary(
currentMessages,
model,
reserveTokens,
apiKey,
headers,
signal,
customInstructions,
previousSummary,
thinkingLevel,
streamFn,
);
}
/**
* Summarize with progressive fallback for handling oversized messages.
* If full summarization fails, tries partial summarization excluding oversized messages.
*/
async function summarizeWithFallbackResult(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
headers?: Record<string, string>;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
contextWindow: number;
customInstructions?: string;
summarizationInstructions?: CompactionSummarizationInstructions;
previousSummary?: string;
thinkingLevel?: ThinkingLevel;
streamFn?: StreamFn;
}): Promise<CompactionSummaryResult> {
async function summarizeWithFallback(params: CompactionSummaryParams): Promise<string> {
const { messages, contextWindow } = params;
if (messages.length === 0) {
return {
kind: "summary",
text: params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK,
};
return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
}
// Try full summarization first
let partialSummaryFallback: string | undefined;
let lastError: unknown;
try {
return { kind: "summary", text: await summarizeChunks(params) };
return await summarizeChunks(params);
} catch (err) {
lastError = err;
if (params.signal.aborted) {
@@ -284,6 +219,7 @@ async function summarizeWithFallbackResult(params: {
contextWindow,
signal: params.signal,
});
const oversizedSuffix = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : "";
// When nothing was oversized, `smallMessages` is the same transcript as the full attempt.
// Re-summarizing it would duplicate the same failing API work (and duplicate warn logs).
@@ -293,8 +229,7 @@ async function summarizeWithFallbackResult(params: {
...params,
messages: smallMessages,
});
const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : "";
return { kind: "summary", text: partialSummary + notes };
return partialSummary + oversizedSuffix;
} catch (partialError) {
lastError = partialError;
if (params.signal.aborted) {
@@ -306,15 +241,14 @@ async function summarizeWithFallbackResult(params: {
// so the model knows large content was filtered.
const retryPartial = (lastError as PartialSummaryError).partialSummary;
if (retryPartial) {
const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : "";
partialSummaryFallback = retryPartial + notes;
partialSummaryFallback = retryPartial + oversizedSuffix;
}
}
}
// Final fallback: use best available partial summary, otherwise throw error
if (partialSummaryFallback) {
return { kind: "summary", text: partialSummaryFallback };
return partialSummaryFallback;
}
// All summarization attempts failed — throw error so caller knows compaction
@@ -328,16 +262,10 @@ async function summarizeWithFallbackResult(params: {
);
}
async function summarizeWithFallback(
params: Parameters<typeof summarizeWithFallbackResult>[0],
): Promise<string> {
return (await summarizeWithFallbackResult(params)).text;
}
/** Extracts a compact timestamp range from a chunk of messages for merge metadata. */
function extractChunkTimeRange(chunk: AgentMessage[]): string {
let earliest: number | undefined;
let latest: number | undefined;
let earliest = Number.POSITIVE_INFINITY;
let latest = 0;
for (const message of chunk) {
const timestamp = message.timestamp;
if (
@@ -347,10 +275,10 @@ function extractChunkTimeRange(chunk: AgentMessage[]): string {
) {
continue;
}
earliest = earliest === undefined ? timestamp : Math.min(earliest, timestamp);
latest = latest === undefined ? timestamp : Math.max(latest, timestamp);
earliest = Math.min(earliest, timestamp);
latest = Math.max(latest, timestamp);
}
if (earliest === undefined || latest === undefined) {
if (!Number.isFinite(earliest)) {
return "";
}
const format = (timestamp: number) =>
@@ -360,29 +288,15 @@ function extractChunkTimeRange(chunk: AgentMessage[]): string {
}
/** Summarizes history in multiple stages when a single pass would be too large. */
export async function summarizeInStages(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
headers?: Record<string, string>;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
contextWindow: number;
customInstructions?: string;
summarizationInstructions?: CompactionSummarizationInstructions;
previousSummary?: string;
parts?: number;
minMessagesForSplit?: number;
thinkingLevel?: ThinkingLevel;
streamFn?: StreamFn;
}): Promise<CompactionSummaryResult> {
export async function summarizeInStages(
params: CompactionSummaryParams & {
parts?: number;
minMessagesForSplit?: number;
},
): Promise<CompactionSummaryResult> {
const { messages } = params;
if (messages.length === 0) {
return {
kind: "summary",
text: params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK,
};
return { kind: "summary", text: await summarizeWithFallback(params) };
}
const plan = await buildStageSplitPlanWithWorker({
@@ -394,18 +308,18 @@ export async function summarizeInStages(params: {
});
if (plan.mode === "single") {
return summarizeWithFallbackResult(params);
return { kind: "summary", text: await summarizeWithFallback(params) };
}
const partialSummaries: string[] = [];
for (const [index, chunk] of plan.chunks.entries()) {
try {
const result = await summarizeWithFallbackResult({
const summary = await summarizeWithFallback({
...params,
messages: chunk,
previousSummary: undefined,
});
partialSummaries.push(result.text);
partialSummaries.push(summary);
} catch (err) {
// A chunk summarization failed — fail the whole stages compaction.
// This prevents silent infinite retry loops where compaction reports
@@ -461,12 +375,14 @@ export async function summarizeInStages(params: {
? `${MERGE_SUMMARIES_INSTRUCTIONS}\n\n${custom}`
: MERGE_SUMMARIES_INSTRUCTIONS;
const mergedResult = await summarizeWithFallbackResult({
...params,
messages: summaryMessages,
customInstructions: mergeInstructions,
});
return mergedResult;
return {
kind: "summary",
text: await summarizeWithFallback({
...params,
messages: summaryMessages,
customInstructions: mergeInstructions,
}),
};
}
/** Resolves a positive context-window token count from model metadata. */

View File

@@ -50,18 +50,28 @@ export type LlmBoundaryTokenPressure = {
renderedChars?: number;
};
function estimateStringTokenPressure(text: string, charsPerToken = ESTIMATED_CHARS_PER_TOKEN) {
return Math.ceil(estimateStringChars(text) / charsPerToken);
type TokenPressureMode = "general" | "tool-result";
function estimateStringTokenPressure(
text: string,
charsPerToken = ESTIMATED_CHARS_PER_TOKEN,
mode: TokenPressureMode = "general",
) {
const estimatedTokens = Math.ceil(estimateStringChars(text) / charsPerToken);
return mode === "tool-result"
? Math.max(Math.ceil(text.length / TOOL_RESULT_CHARS_PER_TOKEN), estimatedTokens)
: estimatedTokens;
}
function estimateJsonPayloadTokenPressure(
value: unknown,
charsPerToken = JSON_PAYLOAD_CHARS_PER_TOKEN,
mode: TokenPressureMode = "general",
): number {
try {
const serialized = JSON.stringify(value);
return typeof serialized === "string"
? Math.ceil(estimateStringChars(serialized) / charsPerToken)
? estimateStringTokenPressure(serialized, charsPerToken, mode)
: 1;
} catch {
return 256;
@@ -89,75 +99,26 @@ function estimateIdentifierTokenPressure(
function estimateContentBlockTokenPressure(
block: unknown,
charsPerToken = ESTIMATED_CHARS_PER_TOKEN,
mode: TokenPressureMode = "general",
): number {
if (typeof block === "string") {
return estimateStringTokenPressure(block, charsPerToken);
return estimateStringTokenPressure(block, charsPerToken, mode);
}
if (!isRecord(block)) {
return estimateJsonPayloadTokenPressure(block, charsPerToken);
return estimateJsonPayloadTokenPressure(block, charsPerToken, mode);
}
const type = block.type;
if (type === "text" && typeof block.text === "string") {
return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateStringTokenPressure(block.text, charsPerToken);
}
if (type === "thinking" && typeof block.thinking === "string") {
return (
CONTENT_BLOCK_OVERHEAD_TOKENS + estimateStringTokenPressure(block.thinking, charsPerToken)
);
const text = type === "text" ? block.text : type === "thinking" ? block.thinking : undefined;
if (typeof text === "string") {
return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateStringTokenPressure(text, charsPerToken, mode);
}
if (type === "image") {
return IMAGE_BLOCK_TOKENS;
}
return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateJsonPayloadTokenPressure(block, charsPerToken);
}
function estimateToolResultStringTokenPressure(text: string): number {
const conservativeToolResultEstimate = Math.ceil(text.length / TOOL_RESULT_CHARS_PER_TOKEN);
const cjkAwareEstimate = estimateStringTokenPressure(text);
return Math.max(conservativeToolResultEstimate, cjkAwareEstimate);
}
function estimateToolResultJsonTokenPressure(value: unknown): number {
try {
const serialized = JSON.stringify(value);
return typeof serialized === "string" ? estimateToolResultStringTokenPressure(serialized) : 1;
} catch {
return 256;
}
}
function estimateToolResultBlockTokenPressure(block: unknown): number {
if (typeof block === "string") {
return estimateToolResultStringTokenPressure(block);
}
if (!isRecord(block)) {
return estimateToolResultJsonTokenPressure(block);
}
if (block.type === "text" && typeof block.text === "string") {
return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateToolResultStringTokenPressure(block.text);
}
if (block.type === "thinking" && typeof block.thinking === "string") {
return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateToolResultStringTokenPressure(block.thinking);
}
if (block.type === "image") {
return IMAGE_BLOCK_TOKENS;
}
return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateToolResultJsonTokenPressure(block);
}
function estimateToolResultContentTokenPressure(content: unknown): number {
if (typeof content === "string") {
return estimateToolResultStringTokenPressure(content);
}
if (Array.isArray(content)) {
return content.reduce((sum, block) => sum + estimateToolResultBlockTokenPressure(block), 0);
}
if (content !== undefined) {
return estimateToolResultJsonTokenPressure(content);
}
return 0;
return (
CONTENT_BLOCK_OVERHEAD_TOKENS + estimateJsonPayloadTokenPressure(block, charsPerToken, mode)
);
}
function estimateAssistantToolCallTokenPressure(block: Record<string, unknown>): number {
@@ -169,30 +130,36 @@ function estimateAssistantToolCallTokenPressure(block: Record<string, unknown>):
);
}
function estimateContentTokenPressure(content: unknown): number {
function estimateContentTokenPressure(
content: unknown,
mode: TokenPressureMode = "general",
): number {
if (typeof content === "string") {
return estimateStringTokenPressure(content);
return estimateStringTokenPressure(content, ESTIMATED_CHARS_PER_TOKEN, mode);
}
if (Array.isArray(content)) {
return content.reduce((sum, block) => sum + estimateContentBlockTokenPressure(block), 0);
return content.reduce(
(sum, block) =>
sum + estimateContentBlockTokenPressure(block, ESTIMATED_CHARS_PER_TOKEN, mode),
0,
);
}
if (content !== undefined) {
return estimateJsonPayloadTokenPressure(content);
return estimateJsonPayloadTokenPressure(
content,
mode === "tool-result" ? ESTIMATED_CHARS_PER_TOKEN : JSON_PAYLOAD_CHARS_PER_TOKEN,
mode,
);
}
return 0;
}
function isToolResultMessage(message: AgentMessage): boolean {
const record = message as unknown as { role?: unknown; type?: unknown };
return record.role === "toolResult" || record.role === "tool" || record.type === "toolResult";
}
function estimateMessageTokenPressure(message: AgentMessage): number {
const record = message as unknown as Record<string, unknown>;
let tokens = MESSAGE_BOUNDARY_OVERHEAD_TOKENS;
if (isToolResultMessage(message)) {
tokens += estimateToolResultContentTokenPressure(record.content);
if (record.role === "toolResult" || record.role === "tool" || record.type === "toolResult") {
tokens += estimateContentTokenPressure(record.content, "tool-result");
tokens += estimateIdentifierTokenPressure(record.toolName ?? record.tool_name);
return tokens;
}
@@ -207,18 +174,13 @@ function estimateMessageTokenPressure(message: AgentMessage): number {
return tokens;
}
if (record.role === "branchSummary") {
if (record.role === "branchSummary" || record.role === "compactionSummary") {
const summary = typeof record.summary === "string" ? record.summary : "";
tokens += estimateStringTokenPressure(BRANCH_SUMMARY_PREFIX + summary + BRANCH_SUMMARY_SUFFIX);
return tokens;
}
if (record.role === "compactionSummary") {
const summary = typeof record.summary === "string" ? record.summary : "";
tokens += estimateStringTokenPressure(
COMPACTION_SUMMARY_PREFIX + summary + COMPACTION_SUMMARY_SUFFIX,
);
return tokens;
const [prefix, suffix] =
record.role === "branchSummary"
? [BRANCH_SUMMARY_PREFIX, BRANCH_SUMMARY_SUFFIX]
: [COMPACTION_SUMMARY_PREFIX, COMPACTION_SUMMARY_SUFFIX];
return tokens + estimateStringTokenPressure(prefix + summary + suffix);
}
if (record.role === "assistant") {
@@ -255,6 +217,16 @@ function estimateMessageTokenPressure(message: AgentMessage): number {
* optional system prompt, and current prompt text. The result intentionally
* includes a safety margin because this path runs before provider tokenization.
*/
function estimateRenderedPromptTokens(params: { systemPrompt?: string; prompt: string }): number {
const systemTokens =
typeof params.systemPrompt === "string" && params.systemPrompt.trim().length > 0
? MESSAGE_BOUNDARY_OVERHEAD_TOKENS + estimateStringTokenPressure(params.systemPrompt)
: 0;
return (
systemTokens + MESSAGE_BOUNDARY_OVERHEAD_TOKENS + estimateStringTokenPressure(params.prompt)
);
}
export function estimateLlmBoundaryTokenPressure(params: {
messages: AgentMessage[];
systemPrompt?: string;
@@ -264,13 +236,10 @@ export function estimateLlmBoundaryTokenPressure(params: {
(sum, message) => sum + estimateMessageTokenPressure(message),
0,
);
const systemTokens =
typeof params.systemPrompt === "string" && params.systemPrompt.trim().length > 0
? MESSAGE_BOUNDARY_OVERHEAD_TOKENS + estimateStringTokenPressure(params.systemPrompt)
: 0;
const promptTokens =
MESSAGE_BOUNDARY_OVERHEAD_TOKENS + estimateStringTokenPressure(params.prompt);
return Math.max(0, Math.ceil((historyTokens + systemTokens + promptTokens) * SAFETY_MARGIN));
return Math.max(
0,
Math.ceil((historyTokens + estimateRenderedPromptTokens(params)) * SAFETY_MARGIN),
);
}
/** Estimates only the rendered prompt/system portion when history has already been accounted for. */
@@ -278,13 +247,7 @@ export function estimateRenderedLlmBoundaryTokenPressure(params: {
systemPrompt?: string;
prompt: string;
}): number {
const systemTokens =
typeof params.systemPrompt === "string" && params.systemPrompt.trim().length > 0
? MESSAGE_BOUNDARY_OVERHEAD_TOKENS + estimateStringTokenPressure(params.systemPrompt)
: 0;
const promptTokens =
MESSAGE_BOUNDARY_OVERHEAD_TOKENS + estimateStringTokenPressure(params.prompt);
return Math.max(0, Math.ceil((systemTokens + promptTokens) * SAFETY_MARGIN));
return Math.max(0, Math.ceil(estimateRenderedPromptTokens(params) * SAFETY_MARGIN));
}
function normalizeLlmBoundaryTokenPressure(

View File

@@ -64,7 +64,7 @@ export function resolveCacheTtlPruningSettings(
} catch {
// Invalid durations retain the shipped five-minute default.
}
const normalize = (value: string) => normalizeLowercaseStringOrEmpty(value);
const normalize = normalizeLowercaseStringOrEmpty;
const deny = compileGlobPatterns({ raw: config.tools?.deny, normalize });
const allow = compileGlobPatterns({ raw: config.tools?.allow, normalize });
return {
@@ -107,22 +107,26 @@ function cacheTtlMessageChars(message: AgentMessage): number {
}
const content = Array.isArray(message.content) ? message.content : [];
return content.reduce((chars, block) => {
if (!isRecord(block)) {
return chars;
}
const text = cacheTtlText(block, message.role !== "assistant");
if (text !== undefined) {
return chars + estimateStringChars(text);
}
if (isRecord(block) && block.type === "image") {
if (block.type === "image") {
return chars + CACHE_TTL_IMAGE_CHARS;
}
if (!isRecord(block) || message.role !== "assistant") {
if (message.role !== "assistant") {
return chars;
}
const record = block as Record<string, unknown>;
if (record.type === "thinking" || record.type === "redacted_thinking") {
const values = [record.thinking, record.thinkingSignature];
if (record.type === "redacted_thinking") {
values.push(record.data);
}
const values = [
record.thinking,
record.thinkingSignature,
...(record.type === "redacted_thinking" ? [record.data] : []),
];
return values.reduce<number>(
(sum, value) => sum + (typeof value === "string" ? estimateStringChars(value) : 0),
chars,
@@ -209,11 +213,12 @@ export function pruneExpiredCacheTtlToolResults(params: {
(next ??= messages.slice())[index] = projected;
}
}
if (totalChars / charWindow < 0.5 || !settings.hardClear) {
return next ?? messages;
}
const output = next ?? messages;
if (eligible.reduce((sum, index) => sum + cacheTtlMessageChars(output[index]!), 0) < 50_000) {
if (
totalChars / charWindow < 0.5 ||
!settings.hardClear ||
eligible.reduce((sum, index) => sum + cacheTtlMessageChars(output[index]!), 0) < 50_000
) {
return output;
}
for (const index of eligible) {
@@ -510,18 +515,10 @@ export function truncateToolResultMessage(
const preserveSmallBlocks = smallBlockChars + largeBlockNoticeChars <= maxChars;
const preservedChars = preserveSmallBlocks ? smallBlockChars : 0;
const remainingBudget = Math.max(0, maxChars - preservedChars);
const reducibleChars = blockTextChars.reduce(
(sum, chars) => sum + (preserveSmallBlocks && chars > 0 && chars <= minKeepChars ? 0 : chars),
0,
);
const reducibleNoticeChars = blockTextChars.reduce(
(sum, chars, index) =>
sum +
(preserveSmallBlocks && chars > 0 && chars <= minKeepChars
? 0
: (blockNoticeChars[index] ?? 0)),
0,
);
const reducibleChars = totalTextChars - preservedChars;
const reducibleNoticeChars = preserveSmallBlocks
? largeBlockNoticeChars
: blockNoticeChars.reduce((sum, chars) => sum + chars, 0);
const noticeScale =
reducibleNoticeChars > 0 ? Math.min(1, remainingBudget / reducibleNoticeChars) : 0;
const distributableBudget = Math.max(0, remainingBudget - reducibleNoticeChars);
@@ -566,13 +563,7 @@ function isToolResultTextBlock(
);
}
type ToolResultSpillDetails = {
path: string;
truncated: boolean;
chars?: number;
};
function getToolResultSpillDetails(message: AgentMessage): ToolResultSpillDetails | undefined {
function getToolResultSpillDetails(message: AgentMessage) {
const details = (message as { details?: unknown }).details;
if (!isRecord(details)) {
return undefined;
@@ -638,21 +629,6 @@ function resolveAggregateElisionMarkers(
};
}
function formatAggregateElisionText(
remainingTextBudget: number,
spillMarkers: AggregateElisionMarkers | undefined,
): string {
if (remainingTextBudget <= 0) {
return "";
}
for (const marker of [spillMarkers?.full, spillMarkers?.compact]) {
if (marker && estimateToolResultTextChars(marker) <= remainingTextBudget) {
return marker;
}
}
return sliceToolResultTextToBudget(AGGREGATE_ELISION_MARKER, remainingTextBudget);
}
/** Projects bounded tool-result history without mutating the transcript. */
export function truncateOversizedToolResultsInMessages(
messages: AgentMessage[],
@@ -667,50 +643,25 @@ export function truncateOversizedToolResultsInMessages(
aggregatePressureEngaged: boolean;
aggregateBudgetChars: number;
} {
const maxChars = Math.max(
1,
maxCharsOverride ?? calculateMaxToolResultChars(contextWindowTokens),
);
const aggregateBudgetChars = calculateRecoveryAggregateToolResultChars(
const { maxChars, aggregateBudgetChars } = resolveToolResultBudgets({
contextWindowTokens,
maxChars,
maxCharsOverride,
aggregateMaxCharsOverride,
);
const projectionKeys = projectionState
? getToolResultProjectionKeys(messages, projectionState)
: [];
const hasFrozenProjectionBaseline = (projectionState?.frozen.size ?? 0) > 0;
const branch = messages.map((message, index) => {
const projectionKey = projectionKeys[index];
const projectedMessage = projectionKey
? projectionState?.replacements.get(projectionKey)
: undefined;
if (projectionKey && projectionState && !projectionState.sourceTextByKey.has(projectionKey)) {
projectionState.sourceTextByKey.set(projectionKey, getToolResultTextBlocks(message));
}
const mergedMessage = projectedMessage
? mergeProjectedToolResultMessage(
message,
projectedMessage,
projectionState?.sourceTextByKey.get(projectionKey ?? ""),
)
: message;
return {
id: `message-${index}`,
type: "message",
message: mergedMessage,
aggregateEligible:
!projectionKey ||
!projectionState?.frozen.has(projectionKey) ||
(projectedMessage !== undefined && mergedMessage === message),
// Reduce frozen history first so steering cannot make fresh output disappear.
deferAggregateRecovery:
projectionKey !== undefined &&
projectionState !== undefined &&
hasFrozenProjectionBaseline &&
!projectionState.frozen.has(projectionKey),
};
});
const sourceBranch = messages.map((message, index) => ({
id: `message-${index}`,
type: "message",
message,
}));
const projection = projectionState
? projectToolResultBranch({
branch: sourceBranch,
projectionState,
recordSources: true,
})
: undefined;
const branch = projection?.branch ?? sourceBranch;
const projectionKeys = projection?.keys ?? [];
const plan = buildToolResultReplacementPlan({
branch,
maxChars,
@@ -718,7 +669,7 @@ export function truncateOversizedToolResultsInMessages(
minKeepChars: RECOVERY_MIN_KEEP_CHARS,
protectTrailingToolResults: Boolean(projectionState),
});
const replacedBranch = applyToolResultReplacementsToBranch(branch, plan.replacements);
const replacedBranch = plan.branch;
if (projectionState) {
for (const [index, originalMessage] of messages.entries()) {
const projectedMessage = replacedBranch[index]?.message;
@@ -745,19 +696,26 @@ export function truncateOversizedToolResultsInMessages(
};
}
function calculateRecoveryAggregateToolResultChars(
contextWindowTokens: number,
maxCharsOverride?: number,
aggregateMaxCharsOverride?: number,
): number {
return Math.max(
function resolveToolResultBudgets(params: {
contextWindowTokens: number;
maxCharsOverride?: number;
aggregateMaxCharsOverride?: number;
}): { maxChars: number; aggregateBudgetChars: number } {
const maxChars = Math.max(
1,
aggregateMaxCharsOverride ??
resolveLiveToolResultAggregateMaxChars({
contextWindowTokens,
perResultMaxChars: maxCharsOverride ?? calculateMaxToolResultChars(contextWindowTokens),
}),
params.maxCharsOverride ?? calculateMaxToolResultChars(params.contextWindowTokens),
);
return {
maxChars,
aggregateBudgetChars: Math.max(
1,
params.aggregateMaxCharsOverride ??
resolveLiveToolResultAggregateMaxChars({
contextWindowTokens: params.contextWindowTokens,
perResultMaxChars: maxChars,
}),
),
};
}
type ToolResultReductionPotential = {
@@ -849,78 +807,74 @@ function mergeProjectedToolResultMessage(
if (!Array.isArray(currentContent) || !Array.isArray(projectedContent)) {
return projectedMessage;
}
const projectedText = projectedContent.filter(
(block): block is { type: "text"; text: string } =>
Boolean(block) &&
typeof block === "object" &&
(block as { type?: unknown }).type === "text" &&
typeof (block as { text?: unknown }).text === "string",
const projectedText = projectedContent.flatMap((block) =>
isRecord(block) && block.type === "text" && typeof block.text === "string" ? [block.text] : [],
);
const currentText = getToolResultTextBlocks(message);
if (sourceText && currentText.some((text, index) => text !== sourceText[index])) {
return message;
}
const currentTextCount = currentContent.filter(
(block) =>
Boolean(block) && typeof block === "object" && (block as { type?: unknown }).type === "text",
).length;
if (currentTextCount !== projectedText.length) {
if (
(sourceText && currentText.some((text, index) => text !== sourceText[index])) ||
currentText.length !== projectedText.length
) {
return message;
}
let textIndex = 0;
const mergedContent = currentContent.map((block) => {
if (!block || typeof block !== "object" || (block as { type?: unknown }).type !== "text") {
if (!isRecord(block) || block.type !== "text") {
return block;
}
const projectedBlock = projectedText[textIndex++];
return projectedBlock ? Object.assign({}, block, { text: projectedBlock.text }) : block;
return Object.assign({}, block, { text: projectedText[textIndex++] });
});
return { ...message, content: mergedContent } as AgentMessage;
}
function seedRecoveryBranchFromFrozenProjection(params: {
function projectToolResultBranch(params: {
branch: ToolResultBranchEntry[];
projectionState: ToolResultPromptProjectionState;
}): ToolResultBranchEntry[] {
frozenOnly?: boolean;
recordSources?: boolean;
}): { branch: ToolResultBranchEntry[]; keys: Array<string | undefined> } {
const messageEntries = params.branch.filter(
(entry): entry is ToolResultBranchEntry & { message: AgentMessage } =>
entry.type === "message" && entry.message !== undefined,
);
const projectionKeys = getToolResultProjectionKeys(
const keys = getToolResultProjectionKeys(
messageEntries.map((entry) => entry.message),
params.projectionState,
);
const hasFrozenProjectionBaseline = params.projectionState.frozen.size > 0;
let messageIndex = 0;
return params.branch.map((entry) => {
if (entry.type !== "message" || !entry.message) {
return entry;
}
const projectionKey = projectionKeys[messageIndex++];
const projectedMessage =
projectionKey && params.projectionState.frozen.has(projectionKey)
? params.projectionState.replacements.get(projectionKey)
: undefined;
const message = projectedMessage
? mergeProjectedToolResultMessage(
entry.message,
projectedMessage,
projectionKey ? params.projectionState.sourceTextByKey.get(projectionKey) : undefined,
)
: entry.message;
return {
...entry,
message,
aggregateEligible:
!projectionKey ||
!params.projectionState.frozen.has(projectionKey) ||
(projectedMessage !== undefined && message === entry.message),
deferAggregateRecovery:
projectionKey !== undefined &&
hasFrozenProjectionBaseline &&
!params.projectionState.frozen.has(projectionKey),
};
});
return {
keys,
branch: params.branch.map((entry) => {
if (entry.type !== "message" || !entry.message) {
return entry;
}
const key = keys[messageIndex++];
const frozen = key !== undefined && params.projectionState.frozen.has(key);
const projected =
key && (!params.frozenOnly || frozen)
? params.projectionState.replacements.get(key)
: undefined;
if (key && params.recordSources && !params.projectionState.sourceTextByKey.has(key)) {
params.projectionState.sourceTextByKey.set(key, getToolResultTextBlocks(entry.message));
}
const message = projected
? mergeProjectedToolResultMessage(
entry.message,
projected,
key ? params.projectionState.sourceTextByKey.get(key) : undefined,
)
: entry.message;
return {
...entry,
message,
aggregateEligible:
!key || !frozen || (projected !== undefined && message === entry.message),
// Reduce frozen history first so steering cannot make fresh output disappear.
deferAggregateRecovery: key !== undefined && hasFrozenProjectionBaseline && !frozen,
};
}),
};
}
function getToolResultTextBlocks(message: AgentMessage): string[] {
@@ -939,26 +893,22 @@ function buildAggregateToolResultReplacements(params: {
spillSourceBranch?: ToolResultBranchEntry[];
aggregateBudgetChars: number;
minKeepChars?: number;
protectTrailingToolResults?: boolean;
protectedEntryIds?: Set<string>;
}): { replacements: ToolResultReplacement[]; pressureExceeded: boolean } {
const minKeepChars = params.minKeepChars ?? MIN_KEEP_CHARS;
const protectedEntryIds = params.protectTrailingToolResults
? getTrailingToolResultEntryIds(params.branch)
: new Set<string>();
const candidates = params.branch
.flatMap((entry, index) => {
const message = entry.message;
return entry.type === "message" && message?.role === "toolResult"
? [
{
index,
entryId: entry.id,
message,
spillSourceMessage: params.spillSourceBranch?.[index]?.message ?? message,
textLength: getToolResultTextBudget(message),
aggregateEligible: entry.aggregateEligible !== false,
deferredByFreshProjection: entry.deferAggregateRecovery === true,
protectedByTrailingBatch: protectedEntryIds.has(entry.id),
protectedByTrailingBatch: params.protectedEntryIds?.has(entry.id) ?? false,
},
]
: [];
@@ -983,68 +933,53 @@ function buildAggregateToolResultReplacements(params: {
let remainingReduction = totalChars - params.aggregateBudgetChars;
const replacements = new Map<string, ToolResultReplacement>();
const aggregateRecoveryCandidates = candidates
.filter((item) => !item.deferredByFreshProjection && !item.protectedByTrailingBatch)
.toSorted((a, b) => a.index - b.index);
const recoveryCandidates = [
...aggregateRecoveryCandidates.filter((item) => item.aggregateEligible),
// Reuse frozen projections first to keep reduction shrink-only and cache-stable.
...aggregateRecoveryCandidates.filter((item) => !item.aggregateEligible),
...candidates.filter(
(item) => item.deferredByFreshProjection && !item.protectedByTrailingBatch,
),
];
// Spend aggregate reduction on older entries first so fresh tool output stays intact.
for (const candidate of recoveryCandidates) {
if (remainingReduction <= 0) {
break;
}
const reducibleChars = Math.max(0, candidate.textLength - minTruncatedTextChars);
if (reducibleChars <= 0) {
continue;
}
const requestedReduction = Math.min(reducibleChars, remainingReduction);
const targetChars = Math.max(minTruncatedTextChars, candidate.textLength - requestedReduction);
const spillMarkers = resolveAggregateElisionMarkers(candidate.spillSourceMessage);
const candidateSuffixFactory = spillMarkers?.truncationSuffix ?? suffixFactory;
const candidateTargetChars = Math.max(
targetChars,
estimateToolResultTextChars(candidateSuffixFactory(1)),
// Frozen projections shrink first; stable sorting preserves the original oldest-first order.
const recoveryCandidates = candidates
.filter((candidate) => !candidate.protectedByTrailingBatch)
.toSorted(
(left, right) =>
Number(left.deferredByFreshProjection) - Number(right.deferredByFreshProjection) ||
Number(right.aggregateEligible) - Number(left.aggregateEligible),
);
const truncatedMessage = truncateToolResultMessage(candidate.message, candidateTargetChars, {
minKeepChars,
suffix: candidateSuffixFactory,
});
const newLength = getToolResultTextBudget(truncatedMessage);
const actualReduction = Math.max(0, candidate.textLength - newLength);
if (actualReduction <= 0) {
continue;
}
replacements.set(candidate.entryId, { entryId: candidate.entryId, message: truncatedMessage });
remainingReduction -= actualReduction;
}
for (const candidate of recoveryCandidates) {
if (remainingReduction <= 0) {
break;
// Trim all older entries before clearing any, so fresh output and spill pointers stay recoverable.
for (const clear of [false, true]) {
for (const candidate of recoveryCandidates) {
if (remainingReduction <= 0) {
break;
}
const baseMessage = replacements.get(candidate.entryId)?.message ?? candidate.message;
const baseTextLength = getToolResultTextBudget(baseMessage);
if (!clear && baseTextLength <= minTruncatedTextChars) {
continue;
}
const spillMarkers = resolveAggregateElisionMarkers(candidate.spillSourceMessage);
let message: AgentMessage;
if (clear) {
message = clearToolResultText(
candidate.message,
Math.max(0, baseTextLength - remainingReduction),
spillMarkers,
);
} else {
const suffix = spillMarkers?.truncationSuffix ?? suffixFactory;
const targetChars = Math.max(
minTruncatedTextChars,
baseTextLength - remainingReduction,
estimateToolResultTextChars(suffix(1)),
);
message = truncateToolResultMessage(candidate.message, targetChars, {
minKeepChars,
suffix,
});
}
const actualReduction = Math.max(0, baseTextLength - getToolResultTextBudget(message));
if (actualReduction <= 0 && (!clear || !spillMarkers)) {
continue;
}
replacements.set(candidate.entryId, { entryId: candidate.entryId, message });
remainingReduction -= actualReduction;
}
const baseMessage = replacements.get(candidate.entryId)?.message ?? candidate.message;
const baseTextLength = getToolResultTextBudget(baseMessage);
const spillMarkers = resolveAggregateElisionMarkers(candidate.spillSourceMessage);
const emptyMessage = clearToolResultText(
candidate.message,
Math.max(0, baseTextLength - remainingReduction),
spillMarkers,
);
const actualReduction = Math.max(0, baseTextLength - getToolResultTextBudget(emptyMessage));
if (actualReduction <= 0 && !spillMarkers) {
continue;
}
replacements.set(candidate.entryId, { entryId: candidate.entryId, message: emptyMessage });
remainingReduction -= actualReduction;
}
return { replacements: [...replacements.values()], pressureExceeded: true };
@@ -1052,16 +987,14 @@ function buildAggregateToolResultReplacements(params: {
function getTrailingToolResultEntryIds(branch: ToolResultBranchEntry[]): Set<string> {
const ids = new Set<string>();
let sawMessage = false;
for (let index = branch.length - 1; index >= 0; index--) {
const entry = branch[index];
if (entry?.type !== "message" || !entry.message) {
if (!sawMessage) {
if (ids.size === 0) {
continue;
}
break;
}
sawMessage = true;
if ((entry.message as { role?: string }).role !== "toolResult") {
break;
}
@@ -1094,7 +1027,12 @@ function clearToolResultText(
if (!isToolResultTextBlock(block)) {
return block;
}
const replacementText = formatAggregateElisionText(remainingTextBudget, spillMarkers);
const replacementText =
[spillMarkers?.full, spillMarkers?.compact].find(
(marker): marker is string =>
typeof marker === "string" &&
estimateToolResultTextChars(marker) <= remainingTextBudget,
) ?? sliceToolResultTextToBudget(AGGREGATE_ELISION_MARKER, remainingTextBudget);
remainingTextBudget = Math.max(
0,
remainingTextBudget - estimateToolResultTextChars(replacementText),
@@ -1107,82 +1045,27 @@ function clearToolResultText(
} as AgentMessage;
}
function buildOversizedToolResultReplacements(params: {
branch: ToolResultBranchEntry[];
maxChars: number;
minKeepChars?: number;
protectedEntryIds?: Set<string>;
}): ToolResultReplacement[] {
const minKeepChars = params.minKeepChars ?? MIN_KEEP_CHARS;
const replacements: ToolResultReplacement[] = [];
for (const entry of params.branch) {
if (entry.type !== "message" || !entry.message) {
continue;
}
const msg = entry.message;
if ((msg as { role?: string }).role !== "toolResult") {
continue;
}
if (getToolResultTextBudget(msg) <= params.maxChars) {
continue;
}
const replacementMinKeepChars = params.protectedEntryIds?.has(entry.id)
? Math.max(minKeepChars, MIN_KEEP_CHARS)
: minKeepChars;
const spillMarkers = resolveAggregateElisionMarkers(msg);
const suffixFactory = spillMarkers?.truncationSuffix;
const maxChars = Math.max(
params.maxChars,
suffixFactory ? estimateToolResultTextChars(suffixFactory(1)) : 0,
);
replacements.push({
entryId: entry.id,
message: truncateToolResultMessage(msg, maxChars, {
minKeepChars: replacementMinKeepChars,
...(suffixFactory ? { suffix: suffixFactory } : {}),
}),
});
}
return replacements;
}
function calculateReplacementReduction(
branch: ToolResultBranchEntry[],
replacements: ToolResultReplacement[],
): number {
if (replacements.length === 0) {
return 0;
}
const branchById = new Map(branch.map((entry) => [entry.id, entry]));
return replacements.reduce((reduction, replacement) => {
const entry = branchById.get(replacement.entryId);
if (!entry?.message) {
return reduction;
}
return (
reduction +
Math.max(
0,
getToolResultTextBudget(entry.message) - getToolResultTextBudget(replacement.message),
)
);
}, 0);
}
function applyToolResultReplacementsToBranch(
branch: ToolResultBranchEntry[],
replacements: ToolResultReplacement[],
): ToolResultBranchEntry[] {
): { branch: ToolResultBranchEntry[]; reducedChars: number } {
if (replacements.length === 0) {
return branch;
return { branch, reducedChars: 0 };
}
const replacementsById = new Map(replacements.map(({ entryId, message }) => [entryId, message]));
return branch.map((entry) => {
let reducedChars = 0;
const nextBranch = branch.map((entry) => {
const message = replacementsById.get(entry.id);
return message && entry.type === "message" ? { ...entry, message } : entry;
if (!message || entry.type !== "message" || !entry.message) {
return entry;
}
reducedChars += Math.max(
0,
getToolResultTextBudget(entry.message) - getToolResultTextBudget(message),
);
return { ...entry, message };
});
return { branch: nextBranch, reducedChars };
}
function buildToolResultReplacementPlan(params: {
@@ -1192,6 +1075,7 @@ function buildToolResultReplacementPlan(params: {
minKeepChars?: number;
protectTrailingToolResults?: boolean;
}): {
branch: ToolResultBranchEntry[];
replacements: ToolResultReplacement[];
oversizedReplacementCount: number;
aggregateReplacementCount: number;
@@ -1203,40 +1087,50 @@ function buildToolResultReplacementPlan(params: {
const protectedEntryIds = params.protectTrailingToolResults
? getTrailingToolResultEntryIds(params.branch)
: undefined;
const oversizedReplacements = buildOversizedToolResultReplacements({
branch: params.branch,
maxChars: params.maxChars,
minKeepChars,
protectedEntryIds,
const oversizedReplacements = params.branch.flatMap((entry): ToolResultReplacement[] => {
const message = entry.message;
if (
entry.type !== "message" ||
message?.role !== "toolResult" ||
getToolResultTextBudget(message) <= params.maxChars
) {
return [];
}
const suffix = resolveAggregateElisionMarkers(message)?.truncationSuffix;
const maxChars = Math.max(params.maxChars, suffix ? estimateToolResultTextChars(suffix(1)) : 0);
return [
{
entryId: entry.id,
message: truncateToolResultMessage(message, maxChars, {
minKeepChars: protectedEntryIds?.has(entry.id)
? Math.max(minKeepChars, MIN_KEEP_CHARS)
: minKeepChars,
...(suffix ? { suffix } : {}),
}),
},
];
});
const oversizedReducibleChars = calculateReplacementReduction(
params.branch,
oversizedReplacements,
);
const oversizedTrimmedBranch = applyToolResultReplacementsToBranch(
params.branch,
oversizedReplacements,
);
const oversizedPhase = applyToolResultReplacementsToBranch(params.branch, oversizedReplacements);
const aggregatePlan = buildAggregateToolResultReplacements({
branch: oversizedTrimmedBranch,
branch: oversizedPhase.branch,
spillSourceBranch: params.branch,
aggregateBudgetChars: params.aggregateBudgetChars,
minKeepChars,
protectTrailingToolResults: params.protectTrailingToolResults,
protectedEntryIds,
});
const aggregateReplacements = aggregatePlan.replacements;
const aggregateReducibleChars = calculateReplacementReduction(
oversizedTrimmedBranch,
aggregateReplacements,
const aggregatePhase = applyToolResultReplacementsToBranch(
oversizedPhase.branch,
aggregatePlan.replacements,
);
return {
replacements: [...oversizedReplacements, ...aggregateReplacements],
branch: aggregatePhase.branch,
replacements: [...oversizedReplacements, ...aggregatePlan.replacements],
oversizedReplacementCount: oversizedReplacements.length,
aggregateReplacementCount: aggregateReplacements.length,
aggregateReplacementCount: aggregatePlan.replacements.length,
aggregatePressureExceeded: aggregatePlan.pressureExceeded,
oversizedReducibleChars,
aggregateReducibleChars,
oversizedReducibleChars: oversizedPhase.reducedChars,
aggregateReducibleChars: aggregatePhase.reducedChars,
};
}
@@ -1252,20 +1146,13 @@ function buildRecoveryToolResultReplacementPlan(params: {
aggregateBudgetChars: number;
plan: ReturnType<typeof buildToolResultReplacementPlan>;
} {
const maxChars = Math.max(
1,
params.maxCharsOverride ?? calculateMaxToolResultChars(params.contextWindowTokens),
);
const aggregateBudgetChars = calculateRecoveryAggregateToolResultChars(
params.contextWindowTokens,
maxChars,
params.aggregateMaxCharsOverride,
);
const { maxChars, aggregateBudgetChars } = resolveToolResultBudgets(params);
const projectedBranch = params.projectionState
? seedRecoveryBranchFromFrozenProjection({
? projectToolResultBranch({
branch: params.branch,
projectionState: params.projectionState,
})
frozenOnly: true,
}).branch
: params.branch;
const plan = buildToolResultReplacementPlan({
branch: projectedBranch,
@@ -1274,9 +1161,8 @@ function buildRecoveryToolResultReplacementPlan(params: {
minKeepChars: RECOVERY_MIN_KEEP_CHARS,
protectTrailingToolResults: params.protectTrailingToolResults,
});
const finalBranch = applyToolResultReplacementsToBranch(projectedBranch, plan.replacements);
const replacements = params.branch.flatMap((entry, index) => {
const finalEntry = finalBranch[index];
const finalEntry = plan.branch[index];
if (
entry.type !== "message" ||
!entry.message ||
@@ -1304,16 +1190,8 @@ export function estimateToolResultReductionPotential(params: {
maxCharsOverride?: number;
aggregateMaxCharsOverride?: number;
}): ToolResultReductionPotential {
const { messages, contextWindowTokens } = params;
const maxChars = Math.max(
1,
params.maxCharsOverride ?? calculateMaxToolResultChars(contextWindowTokens),
);
const aggregateBudgetChars = calculateRecoveryAggregateToolResultChars(
contextWindowTokens,
maxChars,
params.aggregateMaxCharsOverride,
);
const { messages } = params;
const { maxChars, aggregateBudgetChars } = resolveToolResultBudgets(params);
const branch = messages.map((message, index) => ({
id: `message-${index}`,
type: "message",

View File

@@ -44,6 +44,48 @@ function compactionLogKind(reason: CompactionReason): string {
return reason === "manual" ? "manual compaction" : "auto-compaction";
}
function emitCompactionAgentEvent(
ctx: EmbeddedAgentSubscribeContext,
data: { phase: "start" } | { phase: "end"; willRetry: boolean; completed: boolean },
): void {
const event = { stream: "compaction" as const, data };
emitAgentEvent({ runId: ctx.params.runId, ...event });
runBestEffortCallback({
label: "compaction agent event",
log: ctx.log,
callback: () => ctx.params.onAgentEvent?.(event),
});
}
function runBestEffortCompactionHook(
ctx: EmbeddedAgentSubscribeContext,
phase: "before" | "after",
): void {
const hookRunner = getGlobalHookRunner();
const hookName = phase === "before" ? "before_compaction" : "after_compaction";
if (!hookRunner?.hasHooks(hookName)) {
return;
}
const metrics = {
messageCount: ctx.params.session.messages?.length ?? 0,
sessionFile: ctx.params.session.sessionFile,
};
const context = { sessionKey: ctx.params.sessionKey };
const hook =
phase === "before"
? hookRunner.runBeforeCompaction(
{ ...metrics, messages: ctx.params.session.messages },
context,
)
: hookRunner.runAfterCompaction(
{ ...metrics, compactedCount: ctx.getCompactionCount() },
context,
);
void hook.catch((err: unknown) => {
ctx.log.warn(`${hookName} hook failed: ${String(err)}`);
});
}
/** Handles compaction start events from an embedded agent session. */
export function handleCompactionStart(
ctx: EmbeddedAgentSubscribeContext,
@@ -60,40 +102,11 @@ export function handleCompactionStart(
reason,
consoleMessage: `embedded run ${kind} start: runId=${ctx.params.runId} reason=${reason}`,
});
emitAgentEvent({
runId: ctx.params.runId,
stream: "compaction",
data: { phase: "start" },
});
runBestEffortCallback({
label: "compaction agent event",
log: ctx.log,
callback: () =>
ctx.params.onAgentEvent?.({
stream: "compaction",
data: { phase: "start" },
}),
});
emitCompactionAgentEvent(ctx, { phase: "start" });
// Hooks are fire-and-forget so compaction state updates and liveness pauses
// cannot be delayed by plugin work.
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("before_compaction")) {
void hookRunner
.runBeforeCompaction(
{
messageCount: ctx.params.session.messages?.length ?? 0,
messages: ctx.params.session.messages,
sessionFile: ctx.params.session.sessionFile,
},
{
sessionKey: ctx.params.sessionKey,
},
)
.catch((err: unknown) => {
ctx.log.warn(`before_compaction hook failed: ${String(err)}`);
});
}
runBestEffortCompactionHook(ctx, "before");
}
/** Handles compaction completion, retry, and incomplete events. */
@@ -107,7 +120,8 @@ export function handleCompactionEnd(ctx: EmbeddedAgentSubscribeContext, evt: Com
// trimming context, and the persisted count must reflect that successful trim.
const hasResult = evt.result != null;
const wasAborted = Boolean(evt.aborted);
if (hasResult && !wasAborted) {
const completed = hasResult && !wasAborted;
if (completed) {
ctx.incrementCompactionCount();
const tokensAfter =
typeof evt.result === "object" && evt.result
@@ -130,14 +144,18 @@ export function handleCompactionEnd(ctx: EmbeddedAgentSubscribeContext, evt: Com
compactionCount: observedCompactionCount,
consoleMessage: `embedded run ${kind} complete: runId=${ctx.params.runId} reason=${reason} compactionCount=${observedCompactionCount} willRetry=${willRetry}`,
});
void reconcileSessionStoreCompactionCountAfterSuccess({
sessionKey: ctx.params.sessionKey,
agentId: ctx.params.agentId,
configStore: ctx.params.config?.session?.store,
observedCompactionCount,
}).catch((err: unknown) => {
ctx.log.warn(`late compaction count reconcile failed: ${String(err)}`);
});
void import("./embedded-agent-subscribe.handlers.compaction.runtime.js")
.then(({ default: reconcile }) =>
reconcile({
sessionKey: ctx.params.sessionKey,
agentId: ctx.params.agentId,
configStore: ctx.params.config?.session?.store,
observedCompactionCount,
}),
)
.catch((err: unknown) => {
ctx.log.warn(`late compaction count reconcile failed: ${String(err)}`);
});
}
if (willRetry) {
ctx.noteCompactionRetry();
@@ -148,9 +166,17 @@ export function handleCompactionEnd(ctx: EmbeddedAgentSubscribeContext, evt: Com
ctx.state.livenessState = "working";
}
ctx.maybeResolveCompactionWait();
clearStaleAssistantUsageOnSessionMessages(ctx);
const messages = ctx.params.session.messages;
if (Array.isArray(messages)) {
// Marker-free final compaction has no fresh boundary, so stale totals
// must be cleared before later context counters inspect assistant usage.
stripStaleAssistantUsageBeforeLatestCompaction(messages, {
mutate: true,
whenMissingCompactionSummary: "zeroAssistantUsage",
});
}
}
if (!hasResult || wasAborted) {
if (!completed) {
ctx.log.info(`embedded run ${kind} incomplete`, {
event: "embedded_run_compaction_end",
runId: ctx.params.runId,
@@ -161,65 +187,11 @@ export function handleCompactionEnd(ctx: EmbeddedAgentSubscribeContext, evt: Com
consoleMessage: `embedded run ${kind} incomplete: runId=${ctx.params.runId} reason=${reason} aborted=${wasAborted} willRetry=${willRetry}`,
});
}
emitAgentEvent({
runId: ctx.params.runId,
stream: "compaction",
data: { phase: "end", willRetry, completed: hasResult && !wasAborted },
});
runBestEffortCallback({
label: "compaction agent event",
log: ctx.log,
callback: () =>
ctx.params.onAgentEvent?.({
stream: "compaction",
data: { phase: "end", willRetry, completed: hasResult && !wasAborted },
}),
});
emitCompactionAgentEvent(ctx, { phase: "end", willRetry, completed });
// after_compaction runs only once the run will not retry, matching the visible
// post-compaction session state plugin authors observe.
if (!willRetry) {
const hookRunnerEnd = getGlobalHookRunner();
if (hookRunnerEnd?.hasHooks("after_compaction")) {
void hookRunnerEnd
.runAfterCompaction(
{
messageCount: ctx.params.session.messages?.length ?? 0,
compactedCount: ctx.getCompactionCount(),
sessionFile: ctx.params.session.sessionFile,
},
{ sessionKey: ctx.params.sessionKey },
)
.catch((err: unknown) => {
ctx.log.warn(`after_compaction hook failed: ${String(err)}`);
});
}
runBestEffortCompactionHook(ctx, "after");
}
}
/** Lazily reconciles persisted compaction count after a successful compaction. */
async function reconcileSessionStoreCompactionCountAfterSuccess(params: {
sessionKey?: string;
agentId?: string;
configStore?: string;
observedCompactionCount: number;
now?: number;
}): Promise<number | undefined> {
const { default: reconcile } =
await import("./embedded-agent-subscribe.handlers.compaction.runtime.js");
return reconcile(params);
}
function clearStaleAssistantUsageOnSessionMessages(ctx: EmbeddedAgentSubscribeContext): void {
const messages = ctx.params.session.messages;
if (!Array.isArray(messages)) {
return;
}
// Marker-free final compaction has no fresh boundary to compare against.
// Clear all assistant usage or stale pre-compaction totals keep driving the
// context counter after cleanup.
stripStaleAssistantUsageBeforeLatestCompaction(messages, {
mutate: true,
whenMissingCompactionSummary: "zeroAssistantUsage",
});
}

View File

@@ -1036,6 +1036,45 @@ describe("Invalid engine fallback", () => {
);
});
it("coalesces fallback initialization across concurrent lifecycle failures", async () => {
const defaultFactory = vi.fn(async () => new LegacyContextEngine());
registerContextEngineForOwner("legacy", defaultFactory, "core", {
allowSameOwnerRefresh: true,
});
const engineId = uniqueEngineId("concurrent-runtime-fail");
const assemble = vi.fn(async () => {
await Promise.resolve();
throw new Error("plugin context unavailable");
});
registerTestContextEngine(engineId, () => ({
info: { id: engineId, name: "Concurrent Context Engine" },
async ingest() {
return { ingested: true };
},
assemble,
async compact() {
return { ok: true, compacted: false };
},
}));
const engine = await resolveContextEngine(configWithSlot(engineId));
const messages = [makeMockMessage("user", "first"), makeMockMessage("user", "second")];
const results = await Promise.all(
messages.map((message, index) =>
engine.assemble({ sessionId: `session-${index}`, messages: [message] }),
),
);
expect(results.map(({ messages: assembled }) => assembled)).toEqual(
messages.map((message) => [message]),
);
expect(assemble).toHaveBeenCalledTimes(2);
expect(defaultFactory).toHaveBeenCalledTimes(1);
expect(listContextEngineQuarantines()).toEqual([
expect.objectContaining({ engineId, operation: "assemble" }),
]);
});
it("exposes fallback metadata on the same engine after lifecycle quarantine", async () => {
const engineId = uniqueEngineId("runtime-fail-metadata");
const assemble = vi.fn(async () => {

View File

@@ -54,7 +54,8 @@ function buildCompactionResultSessionTarget(params: {
const suppliedAgentId = targetAgentId ?? requestedAgentId;
const suppliedSessionId = normalizeOptionalString(params.sessionId);
const suppliedSessionKey = targetSessionKey ?? requestedSessionKey;
const callerAgentId = suppliedAgentId ?? parseAgentSessionKey(suppliedSessionKey)?.agentId;
const suppliedSessionKeyAgentId = parseAgentSessionKey(suppliedSessionKey)?.agentId;
const callerAgentId = suppliedAgentId ?? suppliedSessionKeyAgentId;
if (
(callerAgentId && marker && marker.agentId !== callerAgentId) ||
(targetStorePath && marker && path.resolve(marker.storePath) !== path.resolve(targetStorePath))
@@ -64,12 +65,11 @@ function buildCompactionResultSessionTarget(params: {
if (marker && suppliedSessionId && marker.sessionId !== suppliedSessionId) {
throw new Error("Context-engine successor identity is inconsistent");
}
const candidateSessionKey = suppliedSessionKey;
const candidateEntry =
marker && candidateSessionKey
marker && suppliedSessionKey
? loadSessionEntry({
agentId: marker.agentId,
sessionKey: candidateSessionKey,
sessionKey: suppliedSessionKey,
storePath: marker.storePath,
})
: undefined;
@@ -86,17 +86,12 @@ function buildCompactionResultSessionTarget(params: {
)
: undefined;
const callerAuthorizedMarkerKey = Boolean(
candidateSessionKey &&
suppliedSessionKey &&
candidateSessionKey === suppliedSessionKey &&
(!candidateEntry || candidateEntry.sessionId === callerSessionId),
suppliedSessionKey && (!candidateEntry || candidateEntry.sessionId === callerSessionId),
);
const markerSessionKey = marker
? callerAuthorizedMarkerKey
? candidateSessionKey
: candidateEntry?.sessionId === marker.sessionId
? candidateSessionKey
: (preferredMarkerSessionKey ?? (candidateEntry ? undefined : candidateSessionKey))
? callerAuthorizedMarkerKey || candidateEntry?.sessionId === marker.sessionId
? suppliedSessionKey
: (preferredMarkerSessionKey ?? (candidateEntry ? undefined : suppliedSessionKey))
: undefined;
if (sessionFile && !marker) {
throw new Error("Legacy context-engine file successors are unsupported");
@@ -106,7 +101,7 @@ function buildCompactionResultSessionTarget(params: {
}
if (
marker &&
candidateSessionKey &&
suppliedSessionKey &&
((candidateEntry &&
candidateEntry.sessionId !== marker.sessionId &&
!callerAuthorizedMarkerKey) ||
@@ -114,11 +109,7 @@ function buildCompactionResultSessionTarget(params: {
) {
throw new Error("Legacy context-engine successor session key is inconsistent");
}
if (
marker &&
parseAgentSessionKey(candidateSessionKey)?.agentId &&
parseAgentSessionKey(candidateSessionKey)?.agentId !== marker.agentId
) {
if (marker && suppliedSessionKeyAgentId && suppliedSessionKeyAgentId !== marker.agentId) {
throw new Error("Legacy context-engine successor identity is inconsistent");
}
const sessionId = marker?.sessionId ?? suppliedSessionId ?? targetSessionId ?? callerSessionId;
@@ -239,16 +230,7 @@ function renderMemorySystemPromptAddition(
params: MemoryPromptSectionParams,
prepared?: PreparedMemoryPromptSection,
): string | undefined {
const lines = buildMemoryPromptSection(
{
availableTools: params.availableTools,
citationsMode: params.citationsMode,
agentId: params.agentId,
agentSessionKey: params.agentSessionKey,
sandboxed: params.sandboxed,
},
prepared,
);
const lines = buildMemoryPromptSection(params, prepared);
if (lines.length === 0) {
return undefined;
}
@@ -277,12 +259,6 @@ export function buildMemorySystemPromptAddition(
export async function prepareMemorySystemPromptAddition(
params: MemoryPromptSectionParams,
): Promise<string | undefined> {
const prepared = await prepareMemoryPromptSection({
availableTools: params.availableTools,
citationsMode: params.citationsMode,
agentId: params.agentId,
agentSessionKey: params.agentSessionKey,
sandboxed: params.sandboxed,
});
const prepared = await prepareMemoryPromptSection(params);
return renderMemorySystemPromptAddition(params, prepared);
}

View File

@@ -1,17 +1,7 @@
// Legacy context engine wraps pre-plugin context behavior behind the pluggable interface.
import type { AgentMessage } from "../agents/runtime/index.js";
import type { MemoryCitationsMode } from "../config/types.memory.js";
import { delegateCompactionToRuntime } from "./delegate.js";
import { CONTEXT_ENGINE_HOST_PARAMS } from "./registry.js";
import type {
ContextEngine,
ContextEngineInfo,
AssembleResult,
CompactResult,
ContextEngineRuntimeContext,
ContextEngineSessionTarget,
IngestResult,
} from "./types.js";
import type { AssembleResult, ContextEngine, ContextEngineInfo } from "./types.js";
/**
* LegacyContextEngine wraps the existing compaction behavior behind the
@@ -29,25 +19,12 @@ export class LegacyContextEngine implements ContextEngine {
acceptedHostParams: [...CONTEXT_ENGINE_HOST_PARAMS],
};
async ingest(_params: {
sessionId: string;
sessionKey?: string;
message: AgentMessage;
isHeartbeat?: boolean;
}): Promise<IngestResult> {
async ingest(_params: Parameters<ContextEngine["ingest"]>[0]) {
// No-op: SessionManager handles message persistence in the legacy flow
return { ingested: false };
}
async assemble(params: {
sessionId: string;
sessionKey?: string;
messages: AgentMessage[];
tokenBudget?: number;
availableTools?: Set<string>;
citationsMode?: MemoryCitationsMode;
model?: string;
}): Promise<AssembleResult> {
async assemble(params: Parameters<ContextEngine["assemble"]>[0]): Promise<AssembleResult> {
// Pass-through: the existing sanitize -> validate -> limit -> repair pipeline
// in attempt.ts handles context assembly for the legacy engine.
// We just return the messages as-is with a rough token estimate.
@@ -57,33 +34,12 @@ export class LegacyContextEngine implements ContextEngine {
};
}
async afterTurn(_params: {
sessionId: string;
sessionKey?: string;
sessionFile: string;
messages: AgentMessage[];
prePromptMessageCount: number;
autoCompactionSummary?: string;
isHeartbeat?: boolean;
tokenBudget?: number;
runtimeContext?: ContextEngineRuntimeContext;
}): Promise<void> {
async afterTurn(_params: Parameters<NonNullable<ContextEngine["afterTurn"]>>[0]): Promise<void> {
// No-op: legacy flow persists context directly in SessionManager.
}
async compact(params: {
sessionId: string;
sessionKey: string;
agentId?: string;
sessionTarget?: ContextEngineSessionTarget;
tokenBudget?: number;
force?: boolean;
currentTokenCount?: number;
compactionTarget?: "budget" | "threshold";
customInstructions?: string;
runtimeContext?: ContextEngineRuntimeContext;
}): Promise<CompactResult> {
return await delegateCompactionToRuntime(params);
compact(params: Parameters<ContextEngine["compact"]>[0]) {
return delegateCompactionToRuntime(params);
}
async dispose(): Promise<void> {

View File

@@ -14,12 +14,8 @@ type PersistedContextEngineRuntimeQuarantine = {
failedAt: Date;
};
type PersistedContextEngineQuarantineRecord = RuntimeHealthRecordEnvelope & {
engineId: string;
owner?: string;
operation: string;
reason: string;
};
type PersistedContextEngineQuarantineRecord = RuntimeHealthRecordEnvelope &
Omit<PersistedContextEngineRuntimeQuarantine, "failedAt">;
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
@@ -56,10 +52,6 @@ const quarantineStore = createRuntimeHealthStore<PersistedContextEngineQuarantin
pick: "earliest",
});
function recordKey(record: Pick<PersistedContextEngineQuarantineRecord, "engineId" | "processId">) {
return JSON.stringify([record.engineId, record.processId]);
}
export function recordPersistedContextEngineQuarantine(
quarantine: PersistedContextEngineRuntimeQuarantine,
): void {
@@ -72,19 +64,19 @@ export function recordPersistedContextEngineQuarantine(
};
// The in-memory registry only records the first quarantine per engine, so
// this is called at most once per (engine, process) and overwrite is safe.
quarantineStore.register(recordKey(record), record);
quarantineStore.register(JSON.stringify([record.engineId, record.processId]), record);
}
export function listPersistedContextEngineQuarantines(): PersistedContextEngineRuntimeQuarantine[] {
return quarantineStore.list().map((record) => {
return quarantineStore.list().map(({ engineId, operation, reason, owner, failedAtMs }) => {
const quarantine: PersistedContextEngineRuntimeQuarantine = {
engineId: record.engineId,
operation: record.operation,
reason: record.reason,
failedAt: new Date(record.failedAtMs),
engineId,
operation,
reason,
failedAt: new Date(failedAtMs),
};
if (record.owner) {
quarantine.owner = record.owner;
if (owner) {
quarantine.owner = owner;
}
return quarantine;
});

View File

@@ -21,7 +21,6 @@ import {
import type {
BootstrapResult,
ContextEngine,
ContextEngineInfo,
ContextEngineMaintenanceResult,
IngestBatchResult,
IngestResult,
@@ -50,76 +49,130 @@ const GUARDED_CONTEXT_ENGINE_METHODS = new Set<PropertyKey>(
export const CONTEXT_ENGINE_HOST_PARAMS = new Set(
"sessionKey prompt runtimeSettings sessionTarget runtimeContext".split(" "),
);
function wrapContextEngineWithHostParamProjection(engine: ContextEngine): ContextEngine {
const removeAfter = getPluginCompatRecord("context-engine-legacy-host-param-default").removeAfter;
const accepted = engine.info.acceptedHostParams;
const engineRecord = engine as unknown as Record<PropertyKey, unknown>;
const wrappedRecord: Record<PropertyKey, unknown> = {};
Object.defineProperty(wrappedRecord, "info", { get: () => engine.info });
for (const methodName of GUARDED_CONTEXT_ENGINE_METHODS) {
const method = engineRecord[methodName];
if (typeof method !== "function") {
continue;
}
wrappedRecord[methodName] = (params: Record<string, unknown>) => {
// Removal(2026-08-12): undeclared engines get full params. Contract: context-engine-legacy-host-param-default.
const useLegacyDefault =
removeAfter !== undefined && new Date().toISOString().slice(0, 10) <= removeAfter;
const currentAccepted = accepted ?? (useLegacyDefault ? [] : undefined);
if (!currentAccepted) {
return method.call(engine, params);
}
const projected = Object.fromEntries(
Object.entries(params).filter(
([key]) => currentAccepted.includes(key) || !CONTEXT_ENGINE_HOST_PARAMS.has(key),
),
);
return method.call(engine, projected);
};
}
if (engine.dispose) {
wrappedRecord.dispose = engine.dispose.bind(engine);
}
return Object.create(engine, Object.getOwnPropertyDescriptors(wrappedRecord)) as ContextEngine;
}
type ResolvedContextEngineMetadata = {
owner: string;
};
type RuntimeQuarantineProxyState = {
engineId: string;
getResolvedFallbackEngine: () => ContextEngine | undefined;
};
const RESOLVED_CONTEXT_ENGINE_METADATA = new WeakMap<
ContextEngine,
ResolvedContextEngineMetadata
>();
const RUNTIME_QUARANTINE_PROXY_STATE = new WeakMap<ContextEngine, RuntimeQuarantineProxyState>();
const resolvedEngineMetadata = new WeakMap<ContextEngine, ResolvedContextEngineMetadata>();
function wrapResolvedContextEngine(
engine: ContextEngine,
metadata: {
owner: string;
engineId: string;
metadata: ResolvedContextEngineMetadata & {
defaultEngineId?: string;
factoryCtx?: ContextEngineFactoryContext;
},
): ContextEngine {
const projected = wrapContextEngineWithHostParamProjection(engine);
const wrapped =
const removeAfter = getPluginCompatRecord("context-engine-legacy-host-param-default").removeAfter;
const accepted = engine.info.acceptedHostParams;
const fallback =
metadata.defaultEngineId &&
metadata.factoryCtx &&
metadata.engineId !== metadata.defaultEngineId
? wrapContextEngineWithRuntimeQuarantine({
engine: projected,
engineId: metadata.engineId,
owner: metadata.owner,
defaultEngineId: metadata.defaultEngineId,
factoryCtx: metadata.factoryCtx,
})
: projected;
RESOLVED_CONTEXT_ENGINE_METADATA.set(wrapped, metadata);
? { defaultEngineId: metadata.defaultEngineId, factoryCtx: metadata.factoryCtx }
: undefined;
let fallbackEnginePromise: Promise<ContextEngine> | undefined;
let resolvedFallbackEngine: ContextEngine | undefined;
const getFallbackEngine = fallback
? () =>
(fallbackEnginePromise ??= resolveDefaultContextEngine(
fallback.defaultEngineId,
fallback.factoryCtx,
).then((resolved) => {
resolvedFallbackEngine = resolved;
return resolved;
}))
: undefined;
const projectParams = (params: Record<string, unknown>) => {
// Removal(2026-08-12): undeclared engines get full params. Contract: context-engine-legacy-host-param-default.
const useLegacyDefault =
removeAfter !== undefined && new Date().toISOString().slice(0, 10) <= removeAfter;
const currentAccepted = accepted ?? (useLegacyDefault ? [] : undefined);
return currentAccepted
? Object.fromEntries(
Object.entries(params).filter(
([key]) => currentAccepted.includes(key) || !CONTEXT_ENGINE_HOST_PARAMS.has(key),
),
)
: params;
};
// A fresh target keeps Proxy invariants compatible with frozen engines and private getters.
const wrapped = new Proxy(
Object.create(engine, { info: { get: () => engine.info } }) as ContextEngine,
{
get(_target, property) {
if (property === "info") {
if (!fallback || !getContextEngineQuarantine(metadata.engineId)) {
return engine.info;
}
return (
resolvedFallbackEngine?.info ?? {
id: fallback.defaultEngineId,
name:
fallback.defaultEngineId === "legacy"
? "Legacy Context Engine"
: `${fallback.defaultEngineId} Context Engine`,
}
);
}
const method = Reflect.get(engine, property, engine);
if (typeof method !== "function") {
return method;
}
if (!GUARDED_CONTEXT_ENGINE_METHODS.has(property)) {
return method.bind(engine);
}
if (!fallback || !getFallbackEngine) {
return (params: Record<string, unknown>) => method.call(engine, projectParams(params));
}
const methodName = property as GuardedContextEngineMethodName;
return async (methodParams: Record<string, unknown>) => {
const abortSignal = contextEngineAbortSignal(methodParams);
if (abortSignal?.aborted) {
const reason = abortSignal.reason;
throw reason instanceof Error
? reason
: createAbortError(
typeof reason === "string" && reason
? reason
: "Context engine operation aborted.",
);
}
const invokeFallback = () =>
invokeFallbackContextEngineMethod({ getFallbackEngine, methodName, methodParams });
if (getContextEngineQuarantine(metadata.engineId)) {
// Runtime failures downgrade future guarded calls for this process.
return await invokeFallback();
}
try {
return await method.call(engine, projectParams(methodParams));
} catch (error) {
if (isContextEngineAbortRejection(error, abortSignal)) {
// Abort is caller intent, not engine instability; never quarantine for it.
throw error;
}
recordContextEngineQuarantine({
engineId: metadata.engineId,
owner: metadata.owner,
operation: methodName,
error,
defaultEngineId: fallback.defaultEngineId,
});
if (methodName === "compact" || methodName === "prepareSubagentSpawn") {
throw error;
}
return await invokeFallback().catch(() => {
throw error;
});
}
};
},
},
);
resolvedEngineMetadata.set(wrapped, metadata);
return wrapped;
}
@@ -163,10 +216,6 @@ function requireContextEngineOwner(owner: string): string {
return normalizedOwner;
}
function formatContextEngineError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function recordContextEngineQuarantine(params: {
engineId: string;
owner?: string;
@@ -183,7 +232,7 @@ function recordContextEngineQuarantine(params: {
const quarantine: ContextEngineRuntimeQuarantine = {
engineId: params.engineId,
operation: params.operation,
reason: formatContextEngineError(params.error),
reason: params.error instanceof Error ? params.error.message : String(params.error),
failedAt: new Date(),
...(params.owner ? { owner: params.owner } : {}),
};
@@ -211,23 +260,13 @@ export function listContextEngineQuarantines(): ContextEngineRuntimeQuarantine[]
({ failedAt, ...quarantine }) => ({ ...quarantine, failedAt: new Date(failedAt) }),
);
const seenEngineIds = new Set(quarantines.map((entry) => entry.engineId));
for (const entry of listPersistedContextEngineQuarantines()) {
if (seenEngineIds.has(entry.engineId)) {
continue;
}
quarantines.push(entry);
seenEngineIds.add(entry.engineId);
}
return quarantines;
return quarantines.concat(
listPersistedContextEngineQuarantines().filter(({ engineId }) => !seenEngineIds.has(engineId)),
);
}
function clearContextEngineRuntimeQuarantine(engineId?: string): void {
const quarantinedEngines = contextEngineRegistryState.quarantinedEngines;
if (engineId === undefined) {
quarantinedEngines.clear();
} else {
quarantinedEngines.delete(engineId);
}
function clearContextEngineRuntimeQuarantine(engineId: string): void {
contextEngineRegistryState.quarantinedEngines.delete(engineId);
clearPersistedContextEngineQuarantineForProcess(engineId, process.pid);
}
@@ -324,7 +363,10 @@ export function clearContextEnginesForOwner(owner: string): void {
export function resolveContextEngineOwnerPluginId(
engine: ContextEngine | undefined | null,
): string | undefined {
const owner = engine && resolveEffectiveContextEngineMetadata(engine)?.owner;
const metadata = engine ? resolvedEngineMetadata.get(engine) : undefined;
// Quarantined work belongs to its core-owned fallback, never the disabled plugin.
const owner =
metadata && !getContextEngineQuarantine(metadata.engineId) ? metadata.owner : undefined;
if (!owner?.startsWith("plugin:")) {
return undefined;
}
@@ -332,23 +374,6 @@ export function resolveContextEngineOwnerPluginId(
return pluginId || undefined;
}
function resolveEffectiveContextEngineMetadata(
engine: ContextEngine,
): ResolvedContextEngineMetadata | undefined {
const quarantineState = RUNTIME_QUARANTINE_PROXY_STATE.get(engine);
if (quarantineState && getContextEngineQuarantine(quarantineState.engineId)) {
// After quarantine, metadata follows the resolved fallback so plugin-scoped operations do not
// keep attributing work to a disabled engine.
const fallbackEngine = quarantineState.getResolvedFallbackEngine();
return (
(fallbackEngine ? RESOLVED_CONTEXT_ENGINE_METADATA.get(fallbackEngine) : undefined) ?? {
owner: CORE_CONTEXT_ENGINE_OWNER,
}
);
}
return RESOLVED_CONTEXT_ENGINE_METADATA.get(engine);
}
function describeResolvedContextEngineContractError(
engineId: string,
engine: unknown,
@@ -405,14 +430,10 @@ const CONTEXT_ENGINE_FALLBACK_RESULTS = {
};
function contextEngineAbortSignal(methodParams: unknown): AbortSignal | undefined {
if (!methodParams || typeof methodParams !== "object") {
return undefined;
}
const signal = (methodParams as { abortSignal?: unknown }).abortSignal;
if (signal && typeof signal === "object" && "aborted" in signal) {
return signal as AbortSignal;
}
return undefined;
const signal = (methodParams as { abortSignal?: unknown } | null | undefined)?.abortSignal;
return signal && typeof signal === "object" && "aborted" in signal
? (signal as AbortSignal)
: undefined;
}
function isContextEngineAbortRejection(error: unknown, signal: AbortSignal | undefined): boolean {
@@ -450,93 +471,6 @@ async function invokeFallbackContextEngineMethod(params: {
return fallbackResult ? { ...fallbackResult } : undefined;
}
function wrapContextEngineWithRuntimeQuarantine(params: {
engine: ContextEngine;
engineId: string;
owner: string;
defaultEngineId: string;
factoryCtx: ContextEngineFactoryContext;
}): ContextEngine {
let fallbackEnginePromise: Promise<ContextEngine> | undefined;
let resolvedFallbackEngine: ContextEngine | undefined;
const getFallbackEngine = () => {
fallbackEnginePromise ??= resolveDefaultContextEngine(
params.defaultEngineId,
params.factoryCtx,
).then((engine) => {
resolvedFallbackEngine = engine;
return engine;
});
return fallbackEnginePromise;
};
const fallbackInfo = (): ContextEngineInfo =>
resolvedFallbackEngine?.info ?? {
id: params.defaultEngineId,
name:
params.defaultEngineId === "legacy"
? "Legacy Context Engine"
: `${params.defaultEngineId} Context Engine`,
};
const isQuarantined = () => Boolean(getContextEngineQuarantine(params.engineId));
const proxy = new Proxy(params.engine, {
get(target, property, receiver) {
if (property === "info" && isQuarantined()) {
return fallbackInfo();
}
const value = Reflect.get(target, property, receiver);
if (typeof value !== "function" || !GUARDED_CONTEXT_ENGINE_METHODS.has(property)) {
return typeof value === "function" ? value.bind(target) : value;
}
const methodName = property as GuardedContextEngineMethodName;
return async (methodParams: unknown) => {
const abortSignal = contextEngineAbortSignal(methodParams);
if (abortSignal?.aborted) {
const reason = abortSignal.reason;
throw reason instanceof Error
? reason
: createAbortError(
typeof reason === "string" && reason ? reason : "Context engine operation aborted.",
);
}
const invokeFallback = () =>
invokeFallbackContextEngineMethod({ getFallbackEngine, methodName, methodParams });
if (isQuarantined()) {
// Runtime failures downgrade future guarded calls for this process.
return await invokeFallback();
}
try {
return await (value as (methodParams: unknown) => unknown).call(target, methodParams);
} catch (error) {
if (isContextEngineAbortRejection(error, abortSignal)) {
// Abort is caller intent, not engine instability; never quarantine for it.
throw error;
}
recordContextEngineQuarantine({
engineId: params.engineId,
owner: params.owner,
operation: methodName,
error,
defaultEngineId: params.defaultEngineId,
});
if (methodName === "compact" || methodName === "prepareSubagentSpawn") {
throw error;
}
return await invokeFallback().catch(() => {
throw error;
});
}
};
},
});
RUNTIME_QUARANTINE_PROXY_STATE.set(proxy, {
engineId: params.engineId,
getResolvedFallbackEngine: () => resolvedFallbackEngine,
});
return proxy;
}
// ---------------------------------------------------------------------------
// Resolution
// ---------------------------------------------------------------------------

View File

@@ -1,3 +1,4 @@
import { normalizeNullableString } from "@openclaw/normalization-core/string-coerce";
import type { ContextEngineHostSupport } from "./host-compat.js";
import type {
ContextEngineRuntimeReasonCode,
@@ -7,7 +8,6 @@ import type {
} from "./types.js";
type OptionalString = string | null | undefined;
type OptionalReason = string | null | undefined;
const RUNTIME_REASON_CODES = new Set<ContextEngineRuntimeReasonCode>([
"provider_timeout",
@@ -17,20 +17,19 @@ const RUNTIME_REASON_CODES = new Set<ContextEngineRuntimeReasonCode>([
"runtime_unavailable",
"unknown",
]);
function normalizeNullableString(value: OptionalString): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
const RUNTIME_REASON_PATTERNS: Array<[ContextEngineRuntimeReasonCode, RegExp]> = [
["provider_timeout", /timeout/iu],
["rate_limited", /rate|limit|429/iu],
["context_overflow", /overflow|context|pressure/iu],
["runtime_unavailable", /runtime/iu],
["provider_unavailable", /provider|primary|unavailable/iu],
];
function normalizeNullableNumber(value: number | null | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function normalizeReasonCode(value: OptionalReason): ContextEngineRuntimeReasonCode | null {
function normalizeReasonCode(value: OptionalString): ContextEngineRuntimeReasonCode | null {
const normalized = normalizeNullableString(value);
if (!normalized) {
return null;
@@ -39,23 +38,7 @@ function normalizeReasonCode(value: OptionalReason): ContextEngineRuntimeReasonC
return normalized as ContextEngineRuntimeReasonCode;
}
const lower = normalized.toLowerCase();
if (lower.includes("timeout")) {
return "provider_timeout";
}
if (lower.includes("rate") || lower.includes("limit") || lower.includes("429")) {
return "rate_limited";
}
if (lower.includes("overflow") || lower.includes("context") || lower.includes("pressure")) {
return "context_overflow";
}
if (lower.includes("runtime")) {
return "runtime_unavailable";
}
if (lower.includes("provider") || lower.includes("primary") || lower.includes("unavailable")) {
return "provider_unavailable";
}
return "unknown";
return RUNTIME_REASON_PATTERNS.find(([, pattern]) => pattern.test(normalized))?.[0] ?? "unknown";
}
export function buildContextEngineRuntimeSettings(params: {
@@ -68,8 +51,8 @@ export function buildContextEngineRuntimeSettings(params: {
modelFamily?: OptionalString;
selectedContextEngineId?: OptionalString;
contextEngineSelectionSource?: ContextEngineSelectionSource;
fallbackReason?: OptionalReason;
degradedReason?: OptionalReason;
fallbackReason?: OptionalString;
degradedReason?: OptionalString;
promptTokenBudget?: number | null;
maxOutputTokens?: number | null;
contextEngineHost: ContextEngineHostSupport;