Skip to content

fix: preserve unpersisted volatile live input in LCM assembly#688

Merged
jalehman merged 2 commits into
Martian-Engineering:mainfrom
jetd1:fix/assemble-live-tail-clean
May 18, 2026
Merged

fix: preserve unpersisted volatile live input in LCM assembly#688
jalehman merged 2 commits into
Martian-Engineering:mainfrom
jetd1:fix/assemble-live-tail-clean

Conversation

@jetd1

@jetd1 jetd1 commented May 15, 2026

Copy link
Copy Markdown
Contributor

Problem

When contextEngine.assemble() rebuilds context from the DB context_items table, messages that were never persisted to DB are absent from the assembled output. This includes:

  • Inter-session subagent announcements (suppressPromptPersistence=true for inputProvenance.kind === "inter_session")
  • Retry/overflow retry prompts (suppressPromptPersistenceOnRetry)
  • Other internal runtime events that bypass DB persistence

The DB frontier lags behind params.messages, so the model never sees the current turn's volatile event — e.g., a subagent completion announcement that should trigger a response.

Root Cause

LCM's assembler rebuilds from DB only. OpenClaw's shouldSuppressAgentPromptPersistence() marks certain messages as non-persistable, so they never enter context_items. The assembled output has no way to include them.

Fix

After DB assembly, detect volatile live input in params.messages that is not represented in the assembled output and append it within the token budget.

Key design choices

  1. Volatile inputs are never covered by summary substring matches. A summary containing similar text captures a past turn; the current volatile event is a distinct occurrence the model must see explicitly. Only exact assembled-message matches count as coverage for volatile inputs.

  2. Occurrence-level bipartite maximum matching (DFS augmenting-path) for deduplication. Two-layer slot allocation: exact-match slots first, then normalized-overlap generic slots.

  3. Live input takes priority. If appending volatile input exceeds the token budget, evict from the front of assembled DB context. Protected fresh-tail messages and exact live anchors are never evicted.

  4. Tool-result/tool-call pair integrity. expandProtectedToolPairIndexes() ensures protected toolResults and their paired assistant tool-call messages are both preserved during budget trimming.

  5. Tool name normalization. normalizeToolNameForCoverage() treats null/undefined/""/"unknown" as equivalent in coverage signatures, fixing anchor mismatches when the assembler fills missing tool names with "unknown".

  6. System volatile normalization. System-role volatile messages are projected to user representation before coverage/anchor/append logic.

  7. Fresh-tail hash protection. Assembler provides per-message hashes for fresh-tail messages; engine protects these alongside exact live anchors during volatile trimming.

Files Changed

  • src/engine.ts — core reconciliation logic (appendUncoveredVolatileLiveInputsWithinBudget, coverage matching, budget eviction)
  • src/assembler.ts — fresh-tail message hash export for protection
  • test/engine.test.ts — 931 tests (278 engine-specific), including regression tests for volatile coverage, budget eviction, tool-pair integrity, and tool-name normalization
  • .changeset/assemble-live-tail.md — patch bump
  • .gitignore — added node_modules

Related

@jetd1

jetd1 commented May 15, 2026

Copy link
Copy Markdown
Contributor Author

Upstream RFC for the proper contract fix: openclaw/openclaw#82137

To frame what this PR is and isn't:

This is a Track 1 hot fix. The current LCM assemble() has no structured signal about which entries in params.messages are durable user/assistant turns vs runtime-injected volatile live input — synthesized subagent announces, inter-session bridge messages, internal-runtime-context blocks. So we detect them by string-matching the formatted prompt bodies ([Inter-session message], <<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>, [Internal task completion event]) — i.e. inverse text-matching against the same OpenClaw code paths that emitted those messages.

That's a real production fix and we should ship it, but the underlying issue is a contract gap, not an LCM bug. OpenClaw already has the structured metadata internally:

  • InputProvenance (src/sessions/input-provenance.ts) — third-party_user / inter_session / internal_system with sourceTool discriminator (e.g. subagent_announce).
  • AgentInternalEvent (src/agents/internal-event-contract.ts, internal-events.ts) — AGENT_INTERNAL_EVENT_TYPE_TASK_COMPLETION etc., already used by OpenClaw's own shouldSuppressAgentPromptPersistence().
  • prePromptMessageCount — already passed to afterTurn, not to assemble.

The RFC proposes plumbing these three (existing) signals through to ContextEngine.assemble() as optional, non-breaking params. Once it lands, the engine can replace the marker-string sniff in this PR with structured detection:

// Subagent / task-completion notification — currently we detect via
// "[Internal task completion event]" text match.
if (params.internalEvents?.[i]?.some(e =>
  e.type === AGENT_INTERNAL_EVENT_TYPE_TASK_COMPLETION
)) {
  // structured, robust against marker renames
}

Same for inter_session bridge detection (currently [Inter-session message] prefix match → params.inputProvenance[i].kind === "inter_session") and for the <<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>> block (currently delimiter sniff → structured marker or provenance).

When the RFC lands and a version of OpenClaw ships the signals, we'll feature-detect and migrate. The marker constants in this PR stay as a fallback until the floor OpenClaw version in the wild has the new fields, then we delete them.

Tracking that migration as Track 3 in the three-track plan referenced in the PR description.

@jetd1 jetd1 force-pushed the fix/assemble-live-tail-clean branch 2 times, most recently from 45f76a5 to 5e8abf7 Compare May 17, 2026 17:31
When OpenClaw's LCM context engine assembles context from the durable DB
frontier, messages that were never persisted (inter-session subagent
announcements, retry/overflow prompts with suppressPromptPersistence) are
absent from the DB. The assembled output therefore omits them entirely,
causing the model to miss live input events like subagent completions.

This patch adds a reconciliation step after DB assembly: detect volatile
live input in params.messages that is not represented in the assembled
output, and append it within the token budget. Key design choices:

- Volatile live inputs are never covered by summary substring matches.
  A summary containing similar text captures a *past* turn; the current
  volatile event is a distinct occurrence the model must see explicitly.
- Only exact assembled-message matches count as coverage for volatile
  inputs. This prevents stale summaries from consuming live events.
- Occurrence-level bipartite maximum matching (DFS augmenting-path)
  deduplicates volatile inputs against assembled context.
- Live input takes priority: if appending volatile input exceeds the
  token budget, evict from the front of assembled DB context first.
- Tool-result/tool-call pairs are kept intact during budget trimming;
  protected fresh-tail messages and exact live anchors are never evicted.
- Tool names that are null/undefined/""/"unknown" are normalized to
  equivalent in coverage signatures, fixing anchor mismatches when the
  assembler fills missing tool names with "unknown".

Fixes: subagent completion announcements lost from model context
Related: openclaw/openclaw#80760, openclaw/openclaw#81164
@jetd1 jetd1 force-pushed the fix/assemble-live-tail-clean branch from 5e8abf7 to 5965151 Compare May 17, 2026 17:41
@jalehman

Copy link
Copy Markdown
Contributor

Thank you!

@jalehman jalehman merged commit d1bef05 into Martian-Engineering:main May 18, 2026
2 checks passed
@github-actions github-actions Bot mentioned this pull request May 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants