Skip to content

Instantly share code, notes, and snippets.

@pybe
Last active January 28, 2026 17:22
Show Gist options
  • Select an option

  • Save pybe/0a0f3f6329362aa3f927f5fd21b27b75 to your computer and use it in GitHub Desktop.

Select an option

Save pybe/0a0f3f6329362aa3f927f5fd21b27b75 to your computer and use it in GitHub Desktop.
Clawdbot/Moltbot compaction summary bug investigation

Compaction Summary Bug Investigation — 28 Jan 2026

Executive Summary

Compaction summaries were not appearing in agent context due to three independent bugs that all needed fixing:

Bug Location Effect Status
1. Model resolution failure compaction-safeguard.js Summary generation silently fails ✅ Fixed
2. Missing compactionEntryId compact.js Can't chain notification correctly ✅ Fixed
3. Double notification write commands-compact.js Orphan notification bypasses compaction ✅ Fixed

All three fixes are required. Without any one of them, summaries will not appear correctly.


The Problem

After compaction, the agent says "I've lost context" despite the summary being generated correctly. The 5KB summary exists in the session file but is not visible to the agent.


Root Causes (All Three Required)

Bug 1: Model Resolution Failure

Location: compaction-safeguard.js (line ~112)

Issue: In the embedded runner context, ctx.model is undefined because extensionRunner.initialize() is never called. The code silently falls back to "Summary unavailable" without attempting summarization.

Evidence:

"summary": "Summary unavailable due to context limits..."

Fix: Add model fallback to resolve from registry:

let model = ctx.model;
if (!model && ctx.modelRegistry) {
    for (const m of ctx.modelRegistry.getAll()) {
        const key = await ctx.modelRegistry.getApiKey(m);
        if (key) {
            model = m;
            break;
        }
    }
}

Bug 2: Missing compactionEntryId

Location: compact.js (line ~377)

Issue: The compaction result doesn't include the entry ID, so the notification code can't chain from it correctly.

Fix: Return the entry ID:

return {
    ok: true,
    compacted: true,
    result: {
        // ... existing fields ...
        compactionEntryId: sessionManager.getLeafId(),
    },
};

Bug 3: Double Notification Write (Critical!)

Location: commands-compact.js (line ~158)

Issue: After writing the notification directly to the session file with correct parentId, the code ALSO returned { reply: { text: notificationText } }. This caused the calling code to write a SECOND notification message without parentId, creating an orphan that bypassed the compaction entry.

Evidence (session file):

compaction   1ffbb0dd (parentId: 81d07474, summary: 5090 chars) ✅
notification 0400d47f (parentId: 1ffbb0dd) ✅ ← Written by our patch
notification c42e7618 (parentId: null) ❌     ← Written by caller due to returned reply
cache-ttl    297bba58 (parentId: c42e7618)   ← Chains from orphan!
user message 410a94fe (parentId: 297bba58)   ← Continues from orphan

The user message chain goes through the orphan c42e7618, completely bypassing the compaction entry 1ffbb0dd.

Fix: Don't return a reply after writing directly:

// BEFORE (broken):
return { shouldContinue: false, reply: { text: notificationText } };

// AFTER (fixed):
// Don't return a reply - we've already written the session entry
// Returning a reply would cause the caller to write a SECOND (orphan) notification
return { shouldContinue: false };

Complete Patches

Patch 1: compaction-safeguard.js

File: /opt/homebrew/lib/node_modules/clawdbot/dist/agents/pi-extensions/compaction-safeguard.js

Lines ~112-125:

let model = ctx.model;
// Fallback: if ctx.model is undefined (extensionRunner.initialize() not called),
// try to resolve a model from the registry. Fixes moltbot/moltbot#2656.
if (!model && ctx.modelRegistry) {
    for (const m of ctx.modelRegistry.getAll()) {
        const key = await ctx.modelRegistry.getApiKey(m);
        if (key) {
            model = m;
            break;
        }
    }
}
if (!model) {
    console.warn("Compaction safeguard: no model available for summarization");
    // ... return fallback summary
}

Patch 2: compact.js

File: /opt/homebrew/lib/node_modules/clawdbot/dist/agents/pi-embedded-runner/compact.js

Lines ~365-380:

const leafIdForCompaction = sessionManager.getLeafId();
console.log(`[compact-debug] sessionManager.getLeafId()=${leafIdForCompaction}`);
console.log(`[compact-debug] result.summary length=${result.summary?.length || 0}`);

return {
    ok: true,
    compacted: true,
    result: {
        summary: result.summary,
        firstKeptEntryId: result.firstKeptEntryId,
        tokensBefore: result.tokensBefore,
        tokensAfter,
        details: result.details,
        // Include compaction entry id so notification can chain from it
        compactionEntryId: leafIdForCompaction,
    },
};

Patch 3: commands-compact.js

File: /opt/homebrew/lib/node_modules/clawdbot/dist/auto-reply/reply/commands-compact.js

Add imports at top:

import fs from "node:fs";
import crypto from "node:crypto";

Lines ~125-170 (end of handleCompactCommand):

// Write notification directly to session file with correct parentId
// so it chains from the compaction entry (fixes summary not appearing)
const notificationText = `⚙️ ${line}${summaryHint}`;
const compactionEntryId = result.result?.compactionEntryId;
const sessionFile = resolveSessionFilePath(sessionId, params.sessionEntry);

// DEBUG: Log values to diagnose why parentId chain might be failing
console.log(`[compaction-debug] result.ok=${result.ok}, result.compacted=${result.compacted}`);
console.log(`[compaction-debug] result.result exists=${!!result.result}`);
console.log(`[compaction-debug] compactionEntryId=${compactionEntryId}`);
console.log(`[compaction-debug] sessionFile=${sessionFile}`);
console.log(`[compaction-debug] sessionFile exists=${sessionFile ? fs.existsSync(sessionFile) : 'N/A'}`);

if (compactionEntryId && sessionFile && fs.existsSync(sessionFile)) {
    console.log(`[compaction-debug] Writing notification with parentId=${compactionEntryId}`);
    const now = Date.now();
    const messageId = crypto.randomUUID().slice(0, 8);
    const notificationEntry = {
        type: "message",
        id: messageId,
        parentId: compactionEntryId, // Chain from compaction entry!
        timestamp: new Date(now).toISOString(),
        message: {
            role: "assistant",
            content: [{ type: "text", text: notificationText }],
            timestamp: now,
            stopReason: "injected",
            usage: { input: 0, output: 0, totalTokens: 0 },
        },
    };
    try {
        fs.appendFileSync(sessionFile, `${JSON.stringify(notificationEntry)}\n`, "utf-8");
        // Don't return a reply - we've already written the session entry
        // Returning a reply would cause the caller to write a SECOND (orphan) notification
        return { shouldContinue: false };
    } catch (err) {
        logVerbose(`Failed to write compaction notification to session: ${err}`);
    }
}

// Fallback: return reply to be written normally (without correct parentId)
return { shouldContinue: false, reply: { text: notificationText } };

Verification

After applying all three patches and restarting Gateway:

Log output:

[compact-debug] sessionManager.getLeafId()=831f06de ✅
[compact-debug] result.summary length=5090 ✅
[compaction-debug] result.ok=true, result.compacted=true ✅
[compaction-debug] result.result exists=true ✅
[compaction-debug] compactionEntryId=831f06de ✅
[compaction-debug] sessionFile exists=true ✅
[compaction-debug] Writing notification with parentId=831f06de ✅

Session file structure (correct):

compaction   831f06de (parentId: afedb4ae, summary: 5090 chars)
notification 218759cc (parentId: 831f06de) ← Single notification, correct chain!
cache-ttl    ...      (parentId: 218759cc)
user message ...      (parentId: ...)      ← Follows through compaction

Agent receives full summary with goals, progress, decisions, and next steps.


Investigation Timeline

Time Event
15:48 Root cause identified — orphan notification
15:55 Applied patches 1-3, gateway restarted
16:05 False positive — thought it was working
16:12 Test FAILED — patches weren't loaded (Node module cache)
16:19 Added debug logging, restarted gateway
16:22 SUCCESS — all logs show correct behavior
16:38 FAILED AGAIN — double notification bug discovered
16:45 Fixed double notification — removed reply from return
16:53 Full validation of all patches confirmed

Key Lessons

  1. Node caches modules — Editing files on disk doesn't affect running processes. Must restart gateway.

  2. All three bugs are independent — Each one alone causes summary loss:

    • Bug 1: No summary generated
    • Bug 2: Can't chain notification
    • Bug 3: Orphan bypasses compaction
  3. Returning reply after direct write = double notification — If you write to session file yourself, don't also return a reply.

  4. Session tree structure mattersbuildSessionContext() walks from leaf to root. If compaction isn't in that path, summary is invisible.


Files Modified

File Change
dist/agents/pi-extensions/compaction-safeguard.js Model fallback
dist/agents/pi-embedded-runner/compact.js Return compactionEntryId
dist/auto-reply/reply/commands-compact.js Write notification with parentId, no double reply

GitHub Issue

openclaw/openclaw#2656 — Compaction silently falls back to empty summary

Posted investigation with findings. Gist with full details: https://gist.github.com/pybe/0a0f3f6329362aa3f927f5fd21b27b75


UPDATE: 17:15 — User Visibility Issue

Problem

After fixing the double notification bug by removing reply, the user sees no output in TUI after /compact. The enqueueSystemEvent mechanism sends events TO the agent (prepended to next user message), not back to the user.

Investigation

  • TUI receives "final" chat events via broadcast
  • When we return { shouldContinue: false } without reply, no final event is broadcast
  • User sees nothing

UPDATE: 17:30 — setImmediate Approach

Insight

The SDK determines leafId from the last entry in the session file. If we write our notification AFTER the orphan, ours becomes last, and subsequent messages chain from ours.

New Approach

Use setImmediate to delay writing our notification until after we return. Sequence:

  1. Return { reply: {...} } → user sees output
  2. Caller writes orphan notification (no parentId)
  3. setImmediate fires → we write our notification (with correct parentId)
  4. Our notification is now LAST in file
  5. Subsequent messages chain from ours → context preserved

Code Change (commands-compact.js)

BEFORE (write first, no reply):

fs.appendFileSync(sessionFile, `${JSON.stringify(notificationEntry)}\n`, "utf-8");
return { shouldContinue: false };

AFTER (reply first, write via setImmediate):

// Write our notification AFTER returning, so it comes after the orphan
// that the caller creates. This makes ours the last entry, so subsequent
// messages chain from our correctly-parentId'd notification.
setImmediate(() => {
    try {
        fs.appendFileSync(sessionFile, `${JSON.stringify(notificationEntry)}\n`, "utf-8");
        console.log(`[compaction-debug] Wrote notification ${messageId} with parentId=${compactionEntryId}`);
    } catch (err) {
        console.error(`[compaction-debug] Failed to write notification: ${err}`);
    }
});
// Return reply so user sees output - orphan will be written, then our notification
return { shouldContinue: false, reply: { text: notificationText } };

To Revert (if it doesn't work)

Change back to synchronous write before return, no reply:

fs.appendFileSync(sessionFile, `${JSON.stringify(notificationEntry)}\n`, "utf-8");
return { shouldContinue: false };

Expected Outcome

  • ✅ User sees output (via returned reply)
  • ✅ Agent sees summary (our notification is last, becomes chain root)
  • ⚠️ Orphan exists but is bypassed (not in active chain)

Risk

If setImmediate fires before the caller writes the orphan, we're back to the original problem. Timing-dependent.


UPDATE: 17:35 — Rich Summary Preview

Problem

User only saw truncated 80-char goal line in notification. Wanted to see key sections.

Solution

Extract and display multiple sections from the summary:

  • 🎯 Goal (first line, 100 chars)
  • ✅ Done (up to 3 items, 120 chars)
  • 💡 Decisions (up to 2 items, 100 chars)
  • 📋 Next (up to 2 items, 100 chars)

Code Change (commands-compact.js)

Regex patterns to extract sections:

// Goal
const goalMatch = summary.match(/^##?\s*Goal\s*\n+([\s\S]*?)(?=\n##?\s|\n---|\n\*\*|$)/im);

// Done items
const doneMatch = summary.match(/###?\s*Done\s*\n+([\s\S]*?)(?=\n###?\s|\n##?\s|\n---|\n\*\*|$)/im);

// Key Decisions
const decisionsMatch = summary.match(/##?\s*Key Decisions\s*\n+([\s\S]*?)(?=\n##?\s|\n---|\n\*\*|$)/im);

// Next Steps
const nextMatch = summary.match(/##?\s*Next Steps\s*\n+([\s\S]*?)(?=\n##?\s|\n---|\n\*\*|$)/im);

Output format:

⚙️ Compacted (104k → 21k) • Context 21k/200k (11%)
🎯 Debug and fix Clawdbot's compaction summary issue...
✅ Done: Model fallback fixed; compactionEntryId added; double notification identified
💡 Decisions: Model Fallback; Context Preservation
📋 Next: Test notification display; Clean up patches

To Revert

Replace rich extraction with original single-line preview:

const goalMatch = summary.match(/^##?\s*Goal\s*\n+[-*]?\s*(.+?)(?:\n|$)/im);
if (goalMatch) {
    const goalText = goalMatch[1].trim().slice(0, 80);
    summaryHint = `\n📝 Summary: "${goalText}${goalText.length >= 80 ? "…" : ""}"`;
}

Status

  • ✅ Syntax validated
  • ✅ Gateway restarted (pid 488)
  • ✅ TUI shows single rich summary — WORKING

UPDATE: 17:45 — Webchat Duplicate Issue (Low Priority)

Finding

After implementing setImmediate approach + rich summary preview:

  • TUI: Shows ONE rich summary notification ✅
  • Webchat: Shows TWO identical rich summary notifications ⚠️

Cause

Two notification mechanisms are both firing:

  1. enqueueSystemEvent(notificationText, ...) — sends to UI
  2. return { reply: { text: notificationText } } — also gets rendered

TUI apparently only displays one of these; webchat displays both.

Impact

  • TUI users: Not affected (user's primary interface)
  • Webchat users: See duplicate notifications (cosmetic issue)

Potential Fix

Could conditionally suppress one mechanism based on client type, or investigate why webchat renders both while TUI doesn't. Not blocking since TUI is the primary interface.

Status

  • ⚠️ Known issue, low priority
  • User primarily uses TUI, which works correctly

Final Status

Component TUI Webchat
Summary in agent context
Rich notification to user ✅ (duplicate)
Single notification ⚠️ Shows two

Primary workflow (TUI) is fully functional.


Investigation completed 2026-01-28 Last updated: 2026-01-28 17:45 GMT

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment