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.
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.
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;
}
}
}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(),
},
};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 };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
}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,
},
};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 } };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.
| 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 |
-
Node caches modules — Editing files on disk doesn't affect running processes. Must restart gateway.
-
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
-
Returning
replyafter direct write = double notification — If you write to session file yourself, don't also return a reply. -
Session tree structure matters —
buildSessionContext()walks from leaf to root. If compaction isn't in that path, summary is invisible.
| 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 |
openclaw/openclaw#2656 — Compaction silently falls back to empty summary
Posted investigation with findings. Gist with full details: https://gist.github.com/pybe/0a0f3f6329362aa3f927f5fd21b27b75
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.
- TUI receives "final" chat events via broadcast
- When we return
{ shouldContinue: false }withoutreply, no final event is broadcast - User sees nothing
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.
Use setImmediate to delay writing our notification until after we return. Sequence:
- Return
{ reply: {...} }→ user sees output - Caller writes orphan notification (no parentId)
setImmediatefires → we write our notification (with correct parentId)- Our notification is now LAST in file
- Subsequent messages chain from ours → context preserved
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 } };Change back to synchronous write before return, no reply:
fs.appendFileSync(sessionFile, `${JSON.stringify(notificationEntry)}\n`, "utf-8");
return { shouldContinue: false };- ✅ 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)
If setImmediate fires before the caller writes the orphan, we're back to the original problem. Timing-dependent.
User only saw truncated 80-char goal line in notification. Wanted to see key sections.
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)
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
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 ? "…" : ""}"`;
}- ✅ Syntax validated
- ✅ Gateway restarted (pid 488)
- ✅ TUI shows single rich summary — WORKING
After implementing setImmediate approach + rich summary preview:
- TUI: Shows ONE rich summary notification ✅
- Webchat: Shows TWO identical rich summary notifications
⚠️
Two notification mechanisms are both firing:
enqueueSystemEvent(notificationText, ...)— sends to UIreturn { reply: { text: notificationText } }— also gets rendered
TUI apparently only displays one of these; webchat displays both.
- TUI users: Not affected (user's primary interface)
- Webchat users: See duplicate notifications (cosmetic issue)
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.
⚠️ Known issue, low priority- User primarily uses TUI, which works correctly
| Component | TUI | Webchat |
|---|---|---|
| Summary in agent context | ✅ | ✅ |
| Rich notification to user | ✅ | ✅ (duplicate) |
| Single notification | ✅ |
Primary workflow (TUI) is fully functional.
Investigation completed 2026-01-28 Last updated: 2026-01-28 17:45 GMT