Skip to content

installContextEngineLoopHook caches lastAssembledView indefinitely without invalidation #77968

@ChrisBot2026

Description

@ChrisBot2026

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:

  1. No invalidation when sourceMessages.length shrinks vs. the source the cache was built from (the /reset, JSONL-truncation, and external-mutation case).
  2. No invalidation in the catch block (the assemble-error case).
  3. 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

  1. Start a gateway with any context-engine plugin slotted (we used lossless-claw 0.2.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).
  3. Trigger gateway sessions.reset for the active session.
  4. Send a single post-reset user turn.
  5. 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-4361installContextEngineLoopHook.
  • 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions