mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
fix(compaction): scope the transcript byte fuse to the latest compaction window (#130487)
* fix(compaction): scope the transcript byte fuse to the latest compaction window readVisibleTranscriptStats only closed its window at a reset boundary, so once a session compacted, maxActiveTranscriptBytes measured the whole active path and every following turn re-compacted. The preflight fuse now measures the window the model actually sees (since the latest compaction or reset, plus its kept tail); history readers keep their reset-only scope. * test(compaction): cover retained transcript accounting ## Worked on by - @VACInc Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com> --------- Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com> Co-authored-by: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com>
This commit is contained in:
@@ -673,9 +673,9 @@ An explicit request `agentId` always wins, followed by `systemAgent.agentId`, a
|
||||
- `postIndexSync`: post-compaction session-memory reindex mode. Default: `"async"`. Use `"await"` for strongest freshness, `"async"` for lower compaction latency, or `"off"` only when session-memory sync is handled elsewhere.
|
||||
- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Leave unset or use `[]` to disable.
|
||||
- `model`: optional `provider/model-id` or bare alias from `agents.defaults.models` for compaction summarization only. Bare aliases resolve before dispatch; configured literal model IDs retain precedence on collisions. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model.
|
||||
- `maxActiveTranscriptBytes`: byte threshold (`number` or strings like `"20mb"`) that opts in to normal local compaction before a run when transcript history reaches the threshold. For Codex app-server sessions, the same threshold caps native rollout transcripts and oversized native threads restart fresh. Disabled when unset or `0`. When a context engine returns an explicit compacted successor identity, OpenClaw adopts it; the built-in SQLite compactor keeps the current identity.
|
||||
- `maxActiveTranscriptBytes`: byte threshold (`number` or strings like `"20mb"`) that opts in to normal local compaction before a run when the transcript window the model sees (everything since the latest compaction or reset, plus its kept tail) reaches the threshold. For Codex app-server sessions, the same threshold caps native rollout transcripts and oversized native threads restart fresh. Disabled when unset or `0`. When a context engine returns an explicit compacted successor identity, OpenClaw adopts it; the built-in SQLite compactor keeps the current identity.
|
||||
- `notifyUser`: when `true`, sends brief context-maintenance notices to the user: when compaction starts and completes (for example, "Compacting context..." and "Compaction complete"), and when a pre-compaction memory flush is exhausted so the reply continues in a degraded state (for example, "Memory maintenance temporarily failed; continuing your reply."). Disabled by default to keep these notices silent.
|
||||
- `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Set `model` to an exact provider/model such as `ollama/qwen3:8b` when this housekeeping turn should stay on a local model; the override does not inherit the active session fallback chain. `forceFlushTranscriptBytes` forces the flush when transcript size reaches the threshold even if token counters are stale. Skipped when workspace is read-only.
|
||||
- `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Set `model` to an exact provider/model such as `ollama/qwen3:8b` when this housekeeping turn should stay on a local model; the override does not inherit the active session fallback chain. `forceFlushTranscriptBytes` forces the flush when the model-visible transcript window reaches the threshold even if token counters are stale; after compaction, that window includes the retained tail and subsequent turns rather than discarded history. Skipped when workspace is read-only.
|
||||
|
||||
Custom compaction instructions are code-owned. Implement a compaction provider
|
||||
plugin with `summarize()` for custom summary construction, and use
|
||||
|
||||
@@ -151,7 +151,7 @@ export const AGENT_FIELD_HELP: Record<string, string> = {
|
||||
"agents.defaults.compaction.model":
|
||||
"Optional provider/model or configured bare alias used only for compaction summarization. Bare aliases resolve before dispatch; a configured literal model ID wins if it collides with an alias. Leave unset to keep using the primary agent model.",
|
||||
"agents.defaults.compaction.maxActiveTranscriptBytes":
|
||||
'Byte threshold that triggers normal preflight local compaction when the active session transcript reaches this size (bytes or strings like "20mb"). Set to 0 or leave unset to disable. Also caps Codex app-server native rollout transcripts; oversized native threads restart fresh.',
|
||||
'Byte threshold that triggers normal preflight local compaction when the transcript window the model sees (since the latest compaction or reset) reaches this size (bytes or strings like "20mb"). Set to 0 or leave unset to disable. Also caps Codex app-server native rollout transcripts; oversized native threads restart fresh.',
|
||||
"agents.defaults.compaction.notifyUser":
|
||||
"When enabled, sends brief context-maintenance notices to the user: when compaction starts and completes (for example, '🧹 Compacting context...' and '🧹 Compaction complete'), and when a pre-compaction memory flush is exhausted so the reply continues in a degraded state (for example, '⚠️ Memory maintenance temporarily failed; continuing your reply.'). Disabled by default to keep context maintenance silent and non-intrusive.",
|
||||
"agents.defaults.compaction.memoryFlush":
|
||||
@@ -163,7 +163,7 @@ export const AGENT_FIELD_HELP: Record<string, string> = {
|
||||
"agents.defaults.compaction.memoryFlush.softThresholdTokens":
|
||||
"Threshold distance to compaction (in tokens) that triggers pre-compaction memory flush execution. Use earlier thresholds for safer persistence, or tighter thresholds for lower flush frequency.",
|
||||
"agents.defaults.compaction.memoryFlush.forceFlushTranscriptBytes":
|
||||
'Forces pre-compaction memory flush when active transcript size reaches this threshold (bytes or strings like "2mb"). Use this to prevent long-session hangs even when token counters are stale; set to 0 to disable.',
|
||||
'Forces pre-compaction memory flush when the model-visible transcript window reaches this threshold (bytes or strings like "2mb"). After compaction, this includes the retained tail and subsequent turns rather than discarded history. Use this to prevent long-session hangs even when token counters are stale; set to 0 to disable.',
|
||||
"agents.defaults.embeddedAgent":
|
||||
"Embedded OpenClaw runner hardening controls for how workspace-local agent settings are trusted and applied in OpenClaw sessions.",
|
||||
"agents.defaults.embeddedAgent.projectSettingsPolicy":
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
persistSessionTranscriptTurn,
|
||||
upsertSessionEntryCore,
|
||||
} from "./session-accessor.js";
|
||||
import { readSessionTranscriptBoundedActiveContextCore } from "./session-accessor.sqlite-active-events.js";
|
||||
import {
|
||||
readSessionTranscriptActiveStats,
|
||||
readSessionTranscriptBoundedActiveContextCore,
|
||||
} from "./session-accessor.sqlite-active-events.js";
|
||||
|
||||
async function withBoundedContextScope(
|
||||
run: (scope: {
|
||||
@@ -123,3 +126,52 @@ it("retains the latest compaction boundary before a truncated tail", async () =>
|
||||
expect(context.boundaryCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("counts the retained tail instead of compacted transcript bytes", async () => {
|
||||
await withBoundedContextScope(async (scope) => {
|
||||
await persistSessionTranscriptTurn(scope, {
|
||||
messages: [
|
||||
{
|
||||
eventId: "discarded-old",
|
||||
parentId: null,
|
||||
message: { role: "user", content: `discarded ${"x".repeat(20_000)}` },
|
||||
},
|
||||
{
|
||||
eventId: "kept-user",
|
||||
parentId: "discarded-old",
|
||||
message: { role: "user", content: `kept ${"k".repeat(3_000)}` },
|
||||
},
|
||||
{
|
||||
eventId: "kept-assistant",
|
||||
parentId: "kept-user",
|
||||
message: { role: "assistant", content: "kept answer" },
|
||||
},
|
||||
],
|
||||
touchSessionEntry: false,
|
||||
});
|
||||
await appendTranscriptEvent(scope, {
|
||||
type: "compaction",
|
||||
id: "compaction-boundary",
|
||||
parentId: "kept-assistant",
|
||||
timestamp: "2026-08-15T00:00:00.000Z",
|
||||
summary: `earlier ${"s".repeat(4_000)}`,
|
||||
firstKeptEntryId: "kept-user",
|
||||
tokensBefore: 10_000,
|
||||
});
|
||||
await persistSessionTranscriptTurn(scope, {
|
||||
messages: [
|
||||
{
|
||||
eventId: "post-compaction",
|
||||
parentId: "compaction-boundary",
|
||||
message: { role: "user", content: "fresh turn" },
|
||||
},
|
||||
],
|
||||
touchSessionEntry: false,
|
||||
});
|
||||
|
||||
const stats = readSessionTranscriptActiveStats(scope);
|
||||
expect(stats.eventCount).toBe(4);
|
||||
expect(stats.sizeBytes).toBeGreaterThan(7_000);
|
||||
expect(stats.sizeBytes).toBeLessThan(12_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,9 +43,9 @@ type ResetMessageWindow = {
|
||||
boundarySeq: number;
|
||||
generation: string | undefined;
|
||||
indexedSeq: number;
|
||||
keptContextEventCount: number;
|
||||
contextPrefixEventCount: number;
|
||||
keptMessagePositions: number[];
|
||||
keptContextSizeBytes: number;
|
||||
contextPrefixSizeBytes: number;
|
||||
postBoundaryMessagePosition: number;
|
||||
boundaryActivePosition: number;
|
||||
};
|
||||
@@ -56,6 +56,15 @@ type ResetMessageWindowCacheEntry = {
|
||||
window: ResetMessageWindow | null;
|
||||
};
|
||||
|
||||
// History readers span compactions (their window closes only at a reset). The preflight
|
||||
// fuse must measure the transcript the model will actually see, which a compaction rewrites
|
||||
// too; measuring it on the history scope keeps the fuse latched after the first compaction.
|
||||
type BoundaryWindowScope = "history" | "context";
|
||||
|
||||
function isWindowBoundary(eventType: unknown, scope: BoundaryWindowScope): boolean {
|
||||
return eventType === "reset" || (scope === "context" && eventType === "compaction");
|
||||
}
|
||||
|
||||
const resetMessageWindowCache = new Map<string, ResetMessageWindowCacheEntry>();
|
||||
const MAX_RESET_MESSAGE_WINDOW_CACHE = 64;
|
||||
|
||||
@@ -159,7 +168,11 @@ function readLatestActiveBoundaryMetadata(projection: ResetWindowProjection) {
|
||||
return reset.seq > compaction.seq ? reset : compaction;
|
||||
}
|
||||
|
||||
function readResetBoundary(projection: ResetWindowProjection, seq: number) {
|
||||
function readBoundaryPayload(
|
||||
projection: ResetWindowProjection,
|
||||
seq: number,
|
||||
scope: BoundaryWindowScope,
|
||||
) {
|
||||
const row = executeSqliteQueryTakeFirstSync(
|
||||
projection.database.db,
|
||||
getResetWindowKysely(projection.database)
|
||||
@@ -170,25 +183,30 @@ function readResetBoundary(projection: ResetWindowProjection, seq: number) {
|
||||
.limit(1),
|
||||
);
|
||||
if (!row) {
|
||||
throw new Error("Active transcript reset boundary is missing");
|
||||
throw new Error("Active transcript boundary is missing");
|
||||
}
|
||||
const parsed = JSON.parse(row.event_json) as { firstKeptEntryId?: unknown; type?: unknown };
|
||||
if (parsed.type !== "reset") {
|
||||
throw new Error("Active transcript reset boundary has invalid payload");
|
||||
if (!isWindowBoundary(parsed.type, scope)) {
|
||||
throw new Error("Active transcript boundary has invalid payload");
|
||||
}
|
||||
return parsed;
|
||||
return {
|
||||
event: parsed,
|
||||
sizeBytes: Buffer.byteLength(row.event_json, "utf8") + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function findLatestResetMessageWindow(
|
||||
projection: ResetWindowProjection,
|
||||
generation: string | undefined,
|
||||
scope: BoundaryWindowScope,
|
||||
): ResetMessageWindow | null {
|
||||
const db = getResetWindowKysely(projection.database);
|
||||
const latestBoundary = readLatestActiveBoundaryMetadata(projection);
|
||||
if (!latestBoundary || latestBoundary.event_type !== "reset") {
|
||||
if (!latestBoundary || !isWindowBoundary(latestBoundary.event_type, scope)) {
|
||||
return null;
|
||||
}
|
||||
const reset = readResetBoundary(projection, latestBoundary.seq);
|
||||
const boundaryPayload = readBoundaryPayload(projection, latestBoundary.seq, scope);
|
||||
const boundary = boundaryPayload.event;
|
||||
const postBoundaryMessagePosition =
|
||||
executeSqliteQueryTakeFirstSync(
|
||||
projection.database.db,
|
||||
@@ -202,9 +220,10 @@ function findLatestResetMessageWindow(
|
||||
.limit(1),
|
||||
)?.message_position ?? projection.state.activeMessageCount;
|
||||
let keptMessagePositions: number[] = [];
|
||||
let keptContextEventCount = 0;
|
||||
let keptContextSizeBytes = 0;
|
||||
if (typeof reset.firstKeptEntryId === "string") {
|
||||
const includesBoundary = latestBoundary.event_type === "compaction";
|
||||
let contextPrefixEventCount = includesBoundary ? 1 : 0;
|
||||
let contextPrefixSizeBytes = includesBoundary ? boundaryPayload.sizeBytes : 0;
|
||||
if (typeof boundary.firstKeptEntryId === "string") {
|
||||
const firstKept = executeSqliteQueryTakeFirstSync(
|
||||
projection.database.db,
|
||||
db
|
||||
@@ -216,7 +235,7 @@ function findLatestResetMessageWindow(
|
||||
)
|
||||
.select("active.active_position")
|
||||
.where("identity.session_id", "=", projection.resolved.sessionId)
|
||||
.where("identity.event_id", "=", reset.firstKeptEntryId),
|
||||
.where("identity.event_id", "=", boundary.firstKeptEntryId),
|
||||
);
|
||||
if (firstKept && firstKept.active_position < latestBoundary.active_position) {
|
||||
const candidates = executeSqliteQuerySync(
|
||||
@@ -241,10 +260,16 @@ function findLatestResetMessageWindow(
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const keptEntries = new Set(selectResetKeptEntries(candidates.map((row) => row.event)));
|
||||
const candidateEntries = candidates.map((row) => row.event);
|
||||
// A compaction keeps its whole tail; a reset replays only the paired subset.
|
||||
const keptEntries = new Set(
|
||||
latestBoundary.event_type === "reset"
|
||||
? selectResetKeptEntries(candidateEntries)
|
||||
: candidateEntries,
|
||||
);
|
||||
const keptRows = candidates.filter((row) => keptEntries.has(row.event));
|
||||
keptContextEventCount = keptRows.length;
|
||||
keptContextSizeBytes = keptRows.reduce(
|
||||
contextPrefixEventCount += keptRows.length;
|
||||
contextPrefixSizeBytes += keptRows.reduce(
|
||||
(total, row) => total + Buffer.byteLength(row.event_json, "utf8") + 1,
|
||||
0,
|
||||
);
|
||||
@@ -263,16 +288,19 @@ function findLatestResetMessageWindow(
|
||||
boundarySeq: latestBoundary.seq,
|
||||
generation,
|
||||
indexedSeq: projection.state.indexedSeq,
|
||||
keptContextEventCount,
|
||||
contextPrefixEventCount,
|
||||
keptMessagePositions,
|
||||
keptContextSizeBytes,
|
||||
contextPrefixSizeBytes,
|
||||
postBoundaryMessagePosition,
|
||||
boundaryActivePosition: latestBoundary.active_position,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveResetMessageWindow(projection: ResetWindowProjection): ResetMessageWindow | null {
|
||||
const key = resetMessageWindowCacheKey(projection);
|
||||
function resolveResetMessageWindow(
|
||||
projection: ResetWindowProjection,
|
||||
scope: BoundaryWindowScope = "history",
|
||||
): ResetMessageWindow | null {
|
||||
const key = `${resetMessageWindowCacheKey(projection)}\0${scope}`;
|
||||
const cached = resetMessageWindowCache.get(key);
|
||||
const generation = readTranscriptProjectionGeneration(projection);
|
||||
if (cached) {
|
||||
@@ -282,7 +310,8 @@ function resolveResetMessageWindow(projection: ResetWindowProjection): ResetMess
|
||||
if (cached.generation === generation && cached.window) {
|
||||
const latestBoundary = readLatestActiveBoundaryMetadata(projection);
|
||||
if (
|
||||
latestBoundary?.event_type === "reset" &&
|
||||
latestBoundary &&
|
||||
isWindowBoundary(latestBoundary.event_type, scope) &&
|
||||
latestBoundary.seq === cached.window.boundarySeq
|
||||
) {
|
||||
const window = { ...cached.window, indexedSeq: projection.state.indexedSeq };
|
||||
@@ -291,7 +320,7 @@ function resolveResetMessageWindow(projection: ResetWindowProjection): ResetMess
|
||||
}
|
||||
}
|
||||
}
|
||||
const window = findLatestResetMessageWindow(projection, generation);
|
||||
const window = findLatestResetMessageWindow(projection, generation, scope);
|
||||
cacheResetMessageWindow(key, {
|
||||
generation,
|
||||
indexedSeq: projection.state.indexedSeq,
|
||||
@@ -371,7 +400,7 @@ export function readVisibleTranscriptStats(projection: ResetWindowProjection): {
|
||||
eventCount: number;
|
||||
sizeBytes: number;
|
||||
} {
|
||||
const window = resolveResetMessageWindow(projection);
|
||||
const window = resolveResetMessageWindow(projection, "context");
|
||||
const db = getResetWindowKysely(projection.database);
|
||||
const base = db
|
||||
.selectFrom("session_transcript_active_events as active")
|
||||
@@ -392,7 +421,7 @@ export function readVisibleTranscriptStats(projection: ResetWindowProjection): {
|
||||
window ? base.where("active.active_position", ">", window.boundaryActivePosition) : base,
|
||||
);
|
||||
return {
|
||||
eventCount: (row?.event_count ?? 0) + (window?.keptContextEventCount ?? 0),
|
||||
sizeBytes: (row?.size_bytes ?? 0) + (window?.keptContextSizeBytes ?? 0),
|
||||
eventCount: (row?.event_count ?? 0) + (window?.contextPrefixEventCount ?? 0),
|
||||
sizeBytes: (row?.size_bytes ?? 0) + (window?.contextPrefixSizeBytes ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user