mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:50:49 +00:00
fix: tighten context-engine loop compaction
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user