openclaw - ✅(Solved) Fix installContextEngineLoopHook caches lastAssembledView indefinitely without invalidation [1 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#77968Fetched 2026-05-06 06:18:36
View on GitHub
Comments
1
Participants
2
Timeline
2
Reactions
2
Timeline (top)
commented ×1cross-referenced ×1

Error Message

} catch { // <-- empty catch leaves lastAssembledView stale on assemble error 2. No invalidation in the catch block (the assemble-error case). 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.

Change 3 — invalidate cache on assemble error

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.

Fix Action

Fix / Workaround

Empirical results from the downstream-applied patch

  • 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

PR fix notes

PR #78163: [AI-assisted] fix(agents): invalidate context engine cache

Description (problem / solution / changelog)

Summary

  • Clear the context-engine loop hook's cached assembled view when the source message history shrinks.
  • Clear that cache when context-engine assembly fails, so fallback uses the current source messages.
  • Add focused regressions for both stale-cache paths.

Fixes #77968.

Real behavior proof

Behavior or issue addressed: installContextEngineLoopHook could return a previously cached assembled context view after the live source history shrank or after a later assemble() failure, allowing stale pre-reset context to be reused.

Real environment tested: Local Windows checkout of this PR branch at commit 2663018ee2, using Node/pnpm from the repository toolchain.

Exact steps or command run after this patch: Inspected the patched hook and ran git diff --check, corepack pnpm exec oxfmt --check --threads=1 src/agents/pi-embedded-runner/tool-result-context-guard.ts src/agents/pi-embedded-runner/tool-result-context-guard.test.ts CHANGELOG.md, corepack pnpm test src/agents/pi-embedded-runner/tool-result-context-guard.test.ts, and corepack pnpm check:changed.

Evidence after fix: Focused regression tests now prove the cache invalidates in both reported failure modes:

clears the cached assembled view when the source history shrinks
clears the cached assembled view when assemble fails

The focused test shard passed:

Test Files  1 passed (1)
Tests       34 passed (34)

Observed result after fix: When source history shrinks, the hook clears the cached assembled view and returns the fresh source messages. When assemble() throws after a previous successful cached view, the hook clears that cache and subsequent unchanged iterations no longer reuse stale assembled messages.

What was not tested: I did not run a live Gateway session reset with a real context-engine plugin. This proof uses the core hook's focused regression tests and source-level behavior.

Validation

  • git diff --check
  • corepack pnpm exec oxfmt --check --threads=1 src/agents/pi-embedded-runner/tool-result-context-guard.ts src/agents/pi-embedded-runner/tool-result-context-guard.test.ts CHANGELOG.md
  • corepack pnpm test src/agents/pi-embedded-runner/tool-result-context-guard.test.ts
  • corepack pnpm check:changed

Changed files

  • CHANGELOG.md (modified, +1/-0)
  • src/agents/pi-embedded-runner/tool-result-context-guard.test.ts (modified, +45/-0)
  • src/agents/pi-embedded-runner/tool-result-context-guard.ts (modified, +16/-1)

Code Example

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; };
}

---

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;

---

if (lastAssembledView !== null && lastAssembledFromSourceLength !== null && sourceMessages.length < lastAssembledFromSourceLength) {
    lastAssembledView = null;
    lastAssembledFromSourceLength = null;
    lastSeenLength = null;
}

---

} catch {
    lastAssembledView = null;
    lastAssembledFromSourceLength = null;
}
RAW_BUFFERClick to expand / collapse

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.

extent analysis

TL;DR

To fix the issue, apply the three proposed changes to installContextEngineLoopHook in dist/selection-BfCSa_QL.js: track the source length of the cached view, invalidate the cache when the source shrinks, and invalidate the cache on assemble errors.

Guidance

  • Identify the installContextEngineLoopHook function in dist/selection-BfCSa_QL.js and locate the lines where lastAssembledView is set and returned.
  • Apply the three proposed changes:
    1. Add a lastAssembledFromSourceLength variable to track the source length of the cached view.
    2. Invalidate the cache when the source shrinks by checking sourceMessages.length against lastAssembledFromSourceLength.
    3. Invalidate the cache on assemble errors by replacing the empty catch block with explicit invalidation.
  • Verify the fix by testing the prompt.submitted.data.messages array across multiple turns and session resets.

Example

let lastAssembledFromSourceLength = null;
// ...
if (assembled && Array.isArray(assembled.messages) && assembled.messages !== sourceMessages) {
    lastAssembledView = assembled.messages;
    lastAssembledFromSourceLength = sourceMessages.length;
    return assembled.messages;
}
// ...
if (lastAssembledView !== null && lastAssembledFromSourceLength !== null && sourceMessages.length < lastAssembledFromSourceLength) {
    lastAssembledView = null;
    lastAssembledFromSourceLength = null;
    lastSeenLength = null;
}
// ...
} catch {
    lastAssembledView = null;
    lastAssembledFromSourceLength = null;
}

Notes

The proposed fix only addresses the runtime-side half of the "stale post-reset history" bug. The companion issue in Martian-Engineering/lossless-claw must also be fixed

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - ✅(Solved) Fix installContextEngineLoopHook caches lastAssembledView indefinitely without invalidation [1 pull requests, 1 comments, 2 participants]