diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index 48c4976870b..0657c5d2a95 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -162,10 +162,11 @@ Npm specs are registry-only (package name + optional exact version or dist-tag). | Hook | Events | What it does | | --------------------- | ------------------------------ | ----------------------------------------------------- | -| session-memory | `command:new`, `command:reset` | Saves session context to `/memory/` | -| bootstrap-extra-files | `agent:bootstrap` | Injects additional bootstrap files from glob patterns | -| command-logger | `command` | Logs all commands to `~/.openclaw/logs/commands.log` | -| boot-md | `gateway:startup` | Runs `BOOT.md` when the gateway starts | +| session-memory | `command:new`, `command:reset` | Saves session context to `/memory/` | +| bootstrap-extra-files | `agent:bootstrap` | Injects additional bootstrap files from glob patterns | +| command-logger | `command` | Logs all commands to `~/.openclaw/logs/commands.log` | +| compaction-notifier | `session:compact:before`, `session:compact:after` | Sends visible chat notices when session compaction starts/ends | +| boot-md | `gateway:startup` | Runs `BOOT.md` when the gateway starts | Enable any bundled hook: @@ -206,6 +207,12 @@ Paths resolve relative to workspace. Only recognized bootstrap basenames are loa Logs every slash command to `~/.openclaw/logs/commands.log`. + + +### compaction-notifier details + +Sends short status messages into the current conversation when OpenClaw starts and finishes compacting the session transcript. This makes long turns less confusing on chat surfaces because the user can see that the assistant is summarizing context and will continue after compaction. + ### boot-md details diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 0a4b9855ca6..f208c99c643 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -171,6 +171,8 @@ const MAX_SAME_MODEL_IDLE_TIMEOUT_RETRIES = 1; const EMBEDDED_RUN_LANE_TIMEOUT_GRACE_MS = 30_000; const MID_TURN_PRECHECK_CONTINUATION_PROMPT = "Continue from the current transcript after the latest tool result. Do not repeat the original user request, and do not rerun completed tools unless the transcript shows they are still needed."; +const COMPACTION_CONTINUATION_RETRY_INSTRUCTION = + "The previous attempt compacted the conversation context before producing a final user-visible answer. Continue from the compacted transcript and produce the final answer now. Do not restart from scratch, do not repeat completed work, and do not rerun tools unless the transcript clearly lacks required evidence."; type EmbeddedRunAttemptForRunner = Awaited>; function resolveEmbeddedRunLaneTimeoutMs(timeoutMs: number): number | undefined { @@ -769,6 +771,7 @@ export async function runEmbeddedPiAgent( let planningOnlyRetryAttempts = 0; let reasoningOnlyRetryAttempts = 0; let emptyResponseRetryAttempts = 0; + let compactionContinuationRetryAttempts = 0; let sameModelIdleTimeoutRetries = 0; // Cost-runaway breaker for #76293. State lives at the run-loop level // on purpose so it survives across attempt boundaries and across @@ -781,6 +784,7 @@ export async function runEmbeddedPiAgent( let planningOnlyRetryInstruction: string | null = null; let reasoningOnlyRetryInstruction: string | null = null; let emptyResponseRetryInstruction: string | null = null; + let compactionContinuationRetryInstruction: string | null = null; let nextAttemptPromptOverride: string | null = null; const ackExecutionFastPathInstruction = resolveAckExecutionFastPathInstruction({ provider, @@ -996,6 +1000,7 @@ export async function runEmbeddedPiAgent( planningOnlyRetryInstruction, reasoningOnlyRetryInstruction, emptyResponseRetryInstruction, + compactionContinuationRetryInstruction, ].filter( (value): value is string => typeof value === "string" && value.trim().length > 0, ); @@ -2347,6 +2352,29 @@ export async function runEmbeddedPiAgent( timedOut, attempt, }); + if ( + !emptyAssistantReplyIsSilent && + attemptCompactionCount > 0 && + payloadCount === 0 && + !aborted && + !promptError && + !timedOut && + !attempt.clientToolCalls && + !attempt.yieldDetected && + !attempt.didSendDeterministicApprovalPrompt && + !attempt.lastToolError && + !hasMessagingToolDeliveryEvidence(attempt) && + compactionContinuationRetryAttempts < 1 + ) { + compactionContinuationRetryAttempts += 1; + compactionContinuationRetryInstruction = COMPACTION_CONTINUATION_RETRY_INSTRUCTION; + log.warn( + `compaction interrupted visible final answer: runId=${params.runId} sessionId=${params.sessionId} ` + + `compactions=${attemptCompactionCount} — retrying ${compactionContinuationRetryAttempts}/1 with compacted-transcript continuation`, + ); + continue; + } + compactionContinuationRetryInstruction = null; if (reasoningOnlyRetriesExhausted && !finalAssistantVisibleText) { log.warn( `reasoning-only retries exhausted: runId=${params.runId} sessionId=${params.sessionId} ` + diff --git a/src/hooks/bundled/compaction-notifier/HOOK.md b/src/hooks/bundled/compaction-notifier/HOOK.md new file mode 100644 index 00000000000..b4ecd2f5749 --- /dev/null +++ b/src/hooks/bundled/compaction-notifier/HOOK.md @@ -0,0 +1,16 @@ +--- +name: compaction-notifier +description: "Send visible chat notices when session compaction starts and finishes." +metadata: + { "openclaw": { "emoji": "🧹", "events": ["session:compact:before", "session:compact:after"], "always": true } } +--- + +# Compaction Notifier + +Sends short user-visible status messages when OpenClaw compacts a session transcript. Enable with: + +```bash +openclaw hooks enable compaction-notifier +``` + +This is useful on chat surfaces where a long turn can otherwise look stalled while context is being summarized. diff --git a/src/hooks/bundled/compaction-notifier/handler.ts b/src/hooks/bundled/compaction-notifier/handler.ts new file mode 100644 index 00000000000..c47f356bb91 --- /dev/null +++ b/src/hooks/bundled/compaction-notifier/handler.ts @@ -0,0 +1,27 @@ +const handler = async (event: any) => { + try { + if (!event || !Array.isArray(event.messages)) return; + const context = event.context ?? {}; + + if (event.type === "session" && event.action === "compact:before") { + const messageCount = typeof context.messageCount === "number" && context.messageCount >= 0 + ? ` (${context.messageCount} messages)` + : ""; + event.messages.push(`🧹 Compacting context${messageCount} so I can continue without losing history…`); + return; + } + + if (event.type === "session" && event.action === "compact:after") { + const before = typeof context.tokensBefore === "number" ? context.tokensBefore : undefined; + const after = typeof context.tokensAfter === "number" ? context.tokensAfter : undefined; + const tokenDelta = before !== undefined && after !== undefined + ? ` (${before.toLocaleString()} → ${after.toLocaleString()} tokens)` + : ""; + event.messages.push(`✅ Context compacted${tokenDelta}. Continuing from where I left off.`); + } + } catch (error) { + console.warn(`[compaction-notifier] failed: ${error instanceof Error ? error.message : String(error)}`); + } +}; + +export default handler;