fix: tighten context-engine loop compaction

This commit is contained in:
Josh Lehman
2026-04-13 07:42:07 -07:00
parent e0b82c3ff8
commit e2758fee9b
4 changed files with 103 additions and 12 deletions

View File

@@ -70,7 +70,7 @@ Docs: https://docs.openclaw.ai
- Hooks/session-memory: pass the resolved agent workspace into gateway `/new` and `/reset` session-memory hooks so reset snapshots stay scoped to the right agent workspace instead of leaking into the default workspace. (#64735) Thanks @suboss87 and @vincentkoc.
- CLI/approvals: raise the default `openclaw approvals get` gateway timeout and report config-load timeouts explicitly, so slow hosts stop showing a misleading `Config unavailable.` note when the approvals snapshot succeeds but the follow-up config RPC needs more time. (#66239) Thanks @neeravmakwana.
- Media/store: honor configured agent media limits when saving generated media and persisting outbound reply media, so the store no longer hard-stops those flows at 5 MB before the configured limit applies. (#66229) Thanks @neeravmakwana and @vincentkoc.
- Agents/context engine: compact engine-owned sessions from the first tool-loop delta and preserve ingest fallback when `afterTurn` is absent, so long-running tool loops can stay bounded without dropping engine state. (#63555) Thanks @Bikkies.
## 2026.4.12
### Changes
@@ -330,6 +330,7 @@ Docs: https://docs.openclaw.ai
- Agents/inbound metadata: strip NUL bytes from serialized inbound context blocks before they reach backend spawn args, so malformed message metadata cannot crash agent spawn with `ERR_INVALID_ARG_VALUE`. (#65389) Thanks @adminfedres and @vincentkoc.
- iMessage: retry transient `watch.subscribe` startup failures before tearing down the monitor, so brief local transport stalls do not immediately bounce the channel. (#65393) Thanks @vincentkoc.
- Status/session_status: move shared session status text into a neutral internal status module and keep the tool importing a local runtime shim, so built `session_status` no longer depends on reply command internals or a bundler-opaque runtime import. (#65807) Thanks @dutifulbob.
- QQBot/security: replace raw `fetch()` in the image-size probe with SSRF-guarded `fetchRemoteMedia`, fix `resolveRepoRoot()` to walk up to `.git` instead of hardcoding two parent levels, and refresh the raw-fetch allowlist to match the corrected scan. (#63495) Thanks @dims.
## 2026.4.9

View File

@@ -991,6 +991,7 @@ export async function runEmbeddedAttempt(
throw new Error("Embedded agent session missing");
}
const activeSession = session;
let prePromptMessageCount = activeSession.messages.length;
abortSessionForYield = () => {
yieldAbortSettled = Promise.resolve(activeSession.abort());
};
@@ -1016,6 +1017,7 @@ export async function runEmbeddedAttempt(
sessionFile: params.sessionFile,
tokenBudget: params.contextTokenBudget,
modelId: params.modelId,
getPrePromptMessageCount: () => prePromptMessageCount,
});
}
const cacheTrace = createCacheTrace({
@@ -1662,7 +1664,6 @@ export async function runEmbeddedAttempt(
let promptError: unknown = null;
let preflightRecovery: EmbeddedRunAttemptResult["preflightRecovery"];
let promptErrorSource: "prompt" | "compaction" | "precheck" | null = null;
let prePromptMessageCount = activeSession.messages.length;
let skipPromptSubmission = false;
try {
const promptStartedAt = Date.now();

View File

@@ -245,6 +245,8 @@ describe("installToolResultContextGuard", () => {
type MockedEngine = ContextEngine & {
afterTurn: ReturnType<typeof vi.fn>;
assemble: ReturnType<typeof vi.fn>;
ingest: ReturnType<typeof vi.fn>;
ingestBatch?: ReturnType<typeof vi.fn>;
};
function makeMockEngine(
@@ -254,6 +256,11 @@ function makeMockEngine(
) => Promise<{ messages: AgentMessage[]; estimatedTokens: number }>;
afterTurn?: (params: Parameters<NonNullable<ContextEngine["afterTurn"]>>[0]) => Promise<void>;
omitAfterTurn?: boolean;
ingest?: (params: Parameters<ContextEngine["ingest"]>[0]) => Promise<{ ingested: boolean }>;
ingestBatch?: (
params: Parameters<NonNullable<ContextEngine["ingestBatch"]>>[0],
) => Promise<{ ingestedCount: number }>;
omitIngestBatch?: boolean;
} = {},
): MockedEngine {
const defaultAfterTurn = vi.fn(async () => {});
@@ -261,12 +268,24 @@ function makeMockEngine(
messages: params.messages,
estimatedTokens: 0,
}));
const defaultIngest = vi.fn(async () => ({ ingested: true }));
const defaultIngestBatch = vi.fn(
async (params: Parameters<NonNullable<ContextEngine["ingestBatch"]>>[0]) => ({
ingestedCount: params.messages.length,
}),
);
const afterTurn = overrides.omitAfterTurn
? undefined
: overrides.afterTurn
? vi.fn(overrides.afterTurn)
: defaultAfterTurn;
const assemble = overrides.assemble ? vi.fn(overrides.assemble) : defaultAssemble;
const ingest = overrides.ingest ? vi.fn(overrides.ingest) : defaultIngest;
const ingestBatch = overrides.omitIngestBatch
? undefined
: overrides.ingestBatch
? vi.fn(overrides.ingestBatch)
: defaultIngestBatch;
const engine = {
info: {
id: "test-engine",
@@ -274,8 +293,9 @@ function makeMockEngine(
version: "0.0.1",
ownsCompaction: true,
},
ingest: async () => ({ ingested: true }),
ingest,
assemble,
...(ingestBatch ? { ingestBatch } : {}),
...(afterTurn ? { afterTurn } : {}),
} as unknown as MockedEngine;
return engine;
@@ -298,6 +318,7 @@ describe("installContextEngineLoopHook", () => {
function installHook(
agent: ReturnType<typeof makeGuardableAgent>,
engine: MockedEngine,
prePromptCount?: number,
): () => void {
return installContextEngineLoopHook({
agent,
@@ -307,13 +328,14 @@ describe("installContextEngineLoopHook", () => {
sessionFile,
tokenBudget,
modelId,
...(prePromptCount !== undefined ? { getPrePromptMessageCount: () => prePromptCount } : {}),
});
}
it("returns early on the first call without calling afterTurn or assemble", async () => {
it("returns early when the current messages match the pre-prompt baseline", async () => {
const agent = makeGuardableAgent();
const engine = makeMockEngine();
installHook(agent, engine);
installHook(agent, engine, 2);
const messages = [makeUser("first"), makeToolResult("call_1", "result")];
const transformed = await callTransform(agent, messages);
@@ -323,6 +345,22 @@ describe("installContextEngineLoopHook", () => {
expect(engine.assemble).not.toHaveBeenCalled();
});
it("processes the first call when messages already exceed the pre-prompt baseline", async () => {
const agent = makeGuardableAgent();
const engine = makeMockEngine();
installHook(agent, engine, 1);
const messages = [makeUser("first"), makeToolResult("call_1", "result")];
await callTransform(agent, messages);
expect(engine.afterTurn).toHaveBeenCalledTimes(1);
expect(engine.afterTurn.mock.calls[0]?.[0]).toMatchObject({
prePromptMessageCount: 1,
messages,
});
expect(engine.assemble).toHaveBeenCalledTimes(1);
});
it("calls afterTurn and assemble when new messages are appended after the first call", async () => {
const agent = makeGuardableAgent();
const engine = makeMockEngine();
@@ -442,7 +480,7 @@ describe("installContextEngineLoopHook", () => {
expect(sourceMessages).toEqual(sourceCopy);
});
it("still calls assemble when engine lacks afterTurn but new messages arrive", async () => {
it("ingests new messages in batches when afterTurn is absent", async () => {
const agent = makeGuardableAgent();
const engine = makeMockEngine({ omitAfterTurn: true });
installHook(agent, engine);
@@ -456,9 +494,33 @@ describe("installContextEngineLoopHook", () => {
const batch2 = [...batch1, makeUser("third"), makeToolResult("call_3", "r3")];
await callTransform(agent, batch2);
expect(engine.ingestBatch).toHaveBeenCalledTimes(2);
expect(engine.ingestBatch?.mock.calls[0]?.[0]).toMatchObject({
messages: [makeUser("second"), makeToolResult("call_2", "r2")],
});
expect(engine.ingestBatch?.mock.calls[1]?.[0]).toMatchObject({
messages: [makeUser("third"), makeToolResult("call_3", "r3")],
});
expect(engine.assemble).toHaveBeenCalledTimes(2);
});
it("falls back to per-message ingest when ingestBatch is absent", async () => {
const agent = makeGuardableAgent();
const engine = makeMockEngine({ omitAfterTurn: true, omitIngestBatch: true });
installHook(agent, engine, 1);
const messages = [makeUser("first"), makeToolResult("call_1", "r1")];
await callTransform(agent, messages);
expect(engine.ingest).toHaveBeenCalledTimes(1);
expect(engine.ingest.mock.calls[0]?.[0]).toMatchObject({
sessionId,
sessionKey,
message: makeToolResult("call_1", "r1"),
});
expect(engine.assemble).toHaveBeenCalledTimes(1);
});
it("falls through to source messages when engine.afterTurn throws", async () => {
const agent = makeGuardableAgent();
const engine = makeMockEngine({

View File

@@ -197,6 +197,7 @@ export function installContextEngineLoopHook(params: {
sessionFile: string;
tokenBudget?: number;
modelId: string;
getPrePromptMessageCount?: () => number;
}): () => void {
const { contextEngine, sessionId, sessionKey, sessionFile, tokenBudget, modelId } = params;
const mutableAgent = params.agent as GuardableAgentRecord;
@@ -210,26 +211,52 @@ export function installContextEngineLoopHook(params: {
: messages;
const sourceMessages = Array.isArray(transformed) ? transformed : messages;
if (lastSeenLength === null) {
lastSeenLength = sourceMessages.length;
}
// Seed the loop fence from the attempt's pre-prompt message count when available.
// This keeps the first real post-tool-call iteration eligible for compaction even
// if the hook's first observed call happens after tool results were appended.
const prePromptMessageCount = Math.max(
0,
Math.min(
sourceMessages.length,
lastSeenLength ?? params.getPrePromptMessageCount?.() ?? sourceMessages.length,
),
);
lastSeenLength = prePromptMessageCount;
const hasNewMessages = sourceMessages.length > lastSeenLength;
const hasNewMessages = sourceMessages.length > prePromptMessageCount;
if (!hasNewMessages) {
return lastAssembledView ?? sourceMessages;
}
try {
if (typeof contextEngine.afterTurn === "function") {
const prePromptCount = lastSeenLength;
await contextEngine.afterTurn({
sessionId,
sessionKey,
sessionFile,
messages: sourceMessages,
prePromptMessageCount: prePromptCount,
prePromptMessageCount,
tokenBudget,
});
} else {
const newMessages = sourceMessages.slice(prePromptMessageCount);
if (newMessages.length > 0) {
if (typeof contextEngine.ingestBatch === "function") {
await contextEngine.ingestBatch({
sessionId,
sessionKey,
messages: newMessages,
});
} else {
for (const message of newMessages) {
await contextEngine.ingest({
sessionId,
sessionKey,
message,
});
}
}
}
}
lastSeenLength = sourceMessages.length;
const assembled = await contextEngine.assemble({