installContextEngineLoopHook caches lastAssembledView indefinitely without invalidation
Repo: openclaw/openclaw
Version observed: 2026.5.4 (also present in 2026.5.2)
Severity: High — cached pre-reset assembled context can be re-served as live params.messages on subsequent turns indefinitely. This is the runtime-side half of the broader "stale post-reset history" bug; the extension-side half is filed against Martian-Engineering/lossless-claw.
Symptom
In any agent that uses a context-engine plugin (e.g. lossless-claw) as the slotted context engine, installContextEngineLoopHook in dist/selection-BfCSa_QL.js caches the most recent successful assemble() result in a closure variable lastAssembledView and re-serves it on subsequent calls when no new tail messages are pending.
Without invalidation, the cached view survives:
- Session resets (
gateway sessions.reset, which truncates the live messages tail and triggers a fresh sessionId).
- JSONL truncations or external mutations that shrink the live messages tail.
assemble() errors that leave the cache holding a previously valid view while LCM is currently failing.
In the post-reset case, the cached lastAssembledView (built from the pre-reset live messages array) is returned as params.messages on the first post-reset transformContext call. The model then "sees" the pre-reset conversation history despite the JSONL having been rotated, and assemble() later in the same call mixes those stale messages and summaries into the post-reset prompt array.
Root cause
In dist/selection-BfCSa_QL.js (line 4276–4357 of the 2026.5.4 distribution; same shape in 2026.5.2):
function installContextEngineLoopHook(params) {
const { contextEngine, sessionId, sessionKey, sessionFile, tokenBudget, modelId } = params;
const mutableAgent = params.agent;
const originalTransformContext = mutableAgent.transformContext;
let lastSeenLength = null;
let lastAssembledView = null; // <-- cache only ever set, never cleared
mutableAgent.transformContext = (async (messages, signal) => {
const transformed = originalTransformContext ? await originalTransformContext.call(mutableAgent, messages, signal) : messages;
const sourceMessages = Array.isArray(transformed) ? transformed : messages;
const prePromptMessageCount = Math.max(0, Math.min(sourceMessages.length, lastSeenLength ?? params.getPrePromptMessageCount?.() ?? sourceMessages.length));
lastSeenLength = prePromptMessageCount;
if (!(sourceMessages.length > prePromptMessageCount)) return lastAssembledView ?? sourceMessages; // <-- returns cached view unconditionally
try {
// ... afterTurn / ingest / assemble ...
const assembled = await contextEngine.assemble({...});
if (assembled && Array.isArray(assembled.messages) && assembled.messages !== sourceMessages) {
lastAssembledView = assembled.messages; // <-- written here
return assembled.messages;
}
lastAssembledView = null;
} catch { // <-- empty catch leaves lastAssembledView stale on assemble error
}
return sourceMessages;
});
return () => { mutableAgent.transformContext = originalTransformContext; };
}
Three independent gaps:
- No invalidation when
sourceMessages.length shrinks vs. the source the cache was built from (the /reset, JSONL-truncation, and external-mutation case).
- No invalidation in the
catch block (the assemble-error case).
- The
if (!(sourceMessages.length > prePromptMessageCount)) short-circuit unconditionally returns lastAssembledView if any view has ever been cached.
Together these mean: once lastAssembledView is populated, subsequent transformContext calls can return the cached view forever, regardless of changes to the underlying live messages.
Reproducer
- Start a gateway with any context-engine plugin slotted (we used lossless-claw
0.2.2).
- Send a few user turns. Confirm
assemble() is producing a non-trivial assembled view (any conversation past the freshTailCount threshold or with summaries).
- Trigger
gateway sessions.reset for the active session.
- Send a single post-reset user turn.
- Inspect
prompt.submitted.data.messages for that post-reset turn. The pre-reset history will appear, despite the JSONL having been rotated.
For the assemble()-error case, simulate a transient error in the context-engine plugin (any exception during contextEngine.assemble). The cached view from the prior successful call continues to be returned.
Proposed fix
Three additive changes inside installContextEngineLoopHook. None of them affect the hot path where lastAssembledView is correctly serving the same source.
Change 1 — track source length the cached view was built from
Add a closure variable lastAssembledFromSourceLength that records sourceMessages.length whenever lastAssembledView is set:
let lastAssembledFromSourceLength = null;
// ...
if (assembled && Array.isArray(assembled.messages) && assembled.messages !== sourceMessages) {
lastAssembledView = assembled.messages;
lastAssembledFromSourceLength = sourceMessages.length;
return assembled.messages;
}
lastAssembledView = null;
lastAssembledFromSourceLength = null;
Change 2 — invalidate cache when source shrinks
At the top of the wrapped transformContext (after computing sourceMessages):
if (lastAssembledView !== null && lastAssembledFromSourceLength !== null && sourceMessages.length < lastAssembledFromSourceLength) {
lastAssembledView = null;
lastAssembledFromSourceLength = null;
lastSeenLength = null;
}
This catches /reset events, JSONL rewrites, and any other path that produces a fresh, shorter live history while a stale long view is still cached.
Change 3 — invalidate cache on assemble error
Replace the empty catch {} with explicit invalidation:
} catch {
lastAssembledView = null;
lastAssembledFromSourceLength = null;
}
This prevents serving a previously cached view when LCM is currently failing.
Empirical results from the downstream-applied patch
Verified against a 2026-05-05 build of OpenClaw 2026.5.4 with lossless-claw 0.2.2. Test session: agent:main:explicit:bug-b-verify-001, three sessionIds across two sessions.reset calls. 12 multi-turn calls total.
- All
prompt.submitted.data.messages arrays faithfully represent the live conversation tail.
- Post-reset
data.messages is [] on the first turn and grows naturally; no pre-reset content leaks.
- Recall, self-introspection, and cross-reset isolation tests all pass.
- No new errors in the gateway log; no regression in the cosmetic plugin warnings.
- This patch does not, by itself, fix the underlying lossless-claw issue (no
command:reset hook, no LCM-side cleanup at the reset boundary). The two halves are complementary.
Workaround currently in use
The downstream user has patched dist/selection-BfCSa_QL.js directly with the three additive changes above. Patch IDs: PATCH 2026-05-05 (bug-b-half-B) at lines 4282, 4294, and 4350 of the patched file. Backup at dist/selection-BfCSa_QL.js.orig.20260505-bug-b-half-b.
Anchor strings for finding the patch site after a future content-hash change to the bundled filename:
installContextEngineLoopHook function definition.
let lastAssembledView = null; immediately precedes the new lastAssembledFromSourceLength declaration.
- The empty
catch {} after the assemble() call is the third anchor.
Why this needs to land in core (not in extensions)
installContextEngineLoopHook is a core runtime construct. Every context-engine plugin uses it transparently; none of them have a way to invalidate the cache from outside without monkey-patching. Even after the lossless-claw extension lands its command:reset/command:new hook (which clears LCM-side state), the runtime cache will still serve stale data until the next prePromptMessageCount recomputation pushes a fresh assemble() call. The two fixes are complementary and both are required for correctness at the reset boundary.
References
dist/selection-BfCSa_QL.js:4276-4361 — installContextEngineLoopHook.
- Companion lossless-claw issue: "Missing
command:reset / command:new hook leaves stale LCM conversation rows after session reset" against Martian-Engineering/lossless-claw.
- Empirical evidence (downstream):
prompt.submitted.data.messages faithful across 12 turns / 3 sessionIds / 2 resets after both halves applied.
installContextEngineLoopHookcacheslastAssembledViewindefinitely without invalidationRepo:
openclaw/openclawVersion observed:
2026.5.4(also present in2026.5.2)Severity: High — cached pre-reset assembled context can be re-served as live
params.messageson subsequent turns indefinitely. This is the runtime-side half of the broader "stale post-reset history" bug; the extension-side half is filed againstMartian-Engineering/lossless-claw.Symptom
In any agent that uses a context-engine plugin (e.g. lossless-claw) as the slotted context engine,
installContextEngineLoopHookindist/selection-BfCSa_QL.jscaches the most recent successfulassemble()result in a closure variablelastAssembledViewand re-serves it on subsequent calls when no new tail messages are pending.Without invalidation, the cached view survives:
gateway sessions.reset, which truncates the live messages tail and triggers a freshsessionId).assemble()errors that leave the cache holding a previously valid view while LCM is currently failing.In the post-reset case, the cached
lastAssembledView(built from the pre-reset live messages array) is returned asparams.messageson the first post-resettransformContextcall. The model then "sees" the pre-reset conversation history despite the JSONL having been rotated, andassemble()later in the same call mixes those stale messages and summaries into the post-reset prompt array.Root cause
In
dist/selection-BfCSa_QL.js(line 4276–4357 of the2026.5.4distribution; same shape in2026.5.2):Three independent gaps:
sourceMessages.lengthshrinks vs. the source the cache was built from (the/reset, JSONL-truncation, and external-mutation case).catchblock (the assemble-error case).if (!(sourceMessages.length > prePromptMessageCount))short-circuit unconditionally returnslastAssembledViewif any view has ever been cached.Together these mean: once
lastAssembledViewis populated, subsequenttransformContextcalls can return the cached view forever, regardless of changes to the underlying live messages.Reproducer
0.2.2).assemble()is producing a non-trivial assembled view (any conversation past the freshTailCount threshold or with summaries).gateway sessions.resetfor the active session.prompt.submitted.data.messagesfor that post-reset turn. The pre-reset history will appear, despite the JSONL having been rotated.For the
assemble()-error case, simulate a transient error in the context-engine plugin (any exception duringcontextEngine.assemble). The cached view from the prior successful call continues to be returned.Proposed fix
Three additive changes inside
installContextEngineLoopHook. None of them affect the hot path wherelastAssembledViewis correctly serving the same source.Change 1 — track source length the cached view was built from
Add a closure variable
lastAssembledFromSourceLengththat recordssourceMessages.lengthwheneverlastAssembledViewis set:Change 2 — invalidate cache when source shrinks
At the top of the wrapped
transformContext(after computingsourceMessages):This catches
/resetevents, JSONL rewrites, and any other path that produces a fresh, shorter live history while a stale long view is still cached.Change 3 — invalidate cache on assemble error
Replace the empty
catch {}with explicit invalidation:This prevents serving a previously cached view when LCM is currently failing.
Empirical results from the downstream-applied patch
Verified against a 2026-05-05 build of OpenClaw
2026.5.4with lossless-claw0.2.2. Test session:agent:main:explicit:bug-b-verify-001, three sessionIds across twosessions.resetcalls. 12 multi-turn calls total.prompt.submitted.data.messagesarrays faithfully represent the live conversation tail.data.messagesis[]on the first turn and grows naturally; no pre-reset content leaks.command:resethook, no LCM-side cleanup at the reset boundary). The two halves are complementary.Workaround currently in use
The downstream user has patched
dist/selection-BfCSa_QL.jsdirectly with the three additive changes above. Patch IDs:PATCH 2026-05-05 (bug-b-half-B)at lines 4282, 4294, and 4350 of the patched file. Backup atdist/selection-BfCSa_QL.js.orig.20260505-bug-b-half-b.Anchor strings for finding the patch site after a future content-hash change to the bundled filename:
installContextEngineLoopHookfunction definition.let lastAssembledView = null;immediately precedes the newlastAssembledFromSourceLengthdeclaration.catch {}after theassemble()call is the third anchor.Why this needs to land in core (not in extensions)
installContextEngineLoopHookis a core runtime construct. Every context-engine plugin uses it transparently; none of them have a way to invalidate the cache from outside without monkey-patching. Even after the lossless-claw extension lands itscommand:reset/command:newhook (which clears LCM-side state), the runtime cache will still serve stale data until the nextprePromptMessageCountrecomputation pushes a freshassemble()call. The two fixes are complementary and both are required for correctness at the reset boundary.References
dist/selection-BfCSa_QL.js:4276-4361—installContextEngineLoopHook.command:reset/command:newhook leaves stale LCM conversation rows after session reset" againstMartian-Engineering/lossless-claw.prompt.submitted.data.messagesfaithful across 12 turns / 3 sessionIds / 2 resets after both halves applied.