← Back to PRs

#21117: feat(compaction): notify on start + idle-triggered proactive compaction (extends #18049)

by irchelper open 2026-02-19 17:51 View on GitHub →
docs size: L
Extends #18049 (triple-layer post-compaction context enforcement). ## Summary Two complementary improvements to the compaction subsystem: 1. **`notifyOnStart`** — send a message to the user the moment auto-compaction begins, eliminating the confusing silent period where the agent is unresponsive. 2. **Idle-triggered proactive compaction** (`idleTriggerMinutes` / `idleTriggerPercent`) — when the user goes quiet and context usage is above a configurable threshold, compact proactively while there is no active turn, rather than waiting for the window to overflow mid-conversation. Both features are **opt-in**; existing deployments see zero behaviour change. --- ## Problem ### Silent compaction window During auto-compaction the Pi agent is busy re-summarising history. From the user's perspective the assistant simply stops responding. Clients that surface error indicators (red dots, timeout warnings) show them even when nothing is actually wrong, leaving non-technical users confused. ### Reactive-only compaction causes latency spikes The default compaction policy fires only when the context is near-full — typically right when a new user message arrives. The compaction turn adds latency exactly at the worst moment. A proactive idle trigger can perform the same work during quiet periods, so the next user message starts with a freshly compacted context. --- ## Solution New optional config fields under `agents.defaults.compaction`: | Field | Type | Default | Description | |---|---|---|---| | `notifyOnStart` | `boolean` | `false` | Deliver a message when compaction starts | | `notifyOnStartText` | `string` | `"🧹 Context compacting, back in a moment…"` | Notification text | | `idleTriggerMinutes` | `number` (positive int) | `undefined` (disabled) | Idle duration before proactive compaction fires | | `idleTriggerPercent` | `number` (0.1 – 0.95) | `0.7` | Minimum context usage ratio for idle trigger to engage | ### Example config ```json { "agents": { "defaults": { "compaction": { "notifyOnStart": true, "notifyOnStartText": "🧹 Context compacting, back in a moment…", "idleTriggerMinutes": 10, "idleTriggerPercent": 0.7 } } } } ``` --- ## Design ### Feature 1 — Compaction start notification **Full delivery chain (every link typed):** ``` AgentCompactionConfig.notifyOnStart / notifyOnStartText (types.agent-defaults.ts) → AgentDefaultsSchema.compaction.notifyOnStart/Text (zod-schema.agent-defaults.ts, .strict()) → cfg.agents.defaults.compaction resolved at runtime (agent-runner.ts) → onCompactionStart: (() => Promise<void>) | undefined (agent-runner-execution.ts param type) → opts.onBlockReply({ text }) (inline callback, same path as streaming) ``` Key decisions: - **Bypass the block pipeline** — the callback calls `opts.onBlockReply` directly, with no chunking or coalescing delay. The notification reaches the client before the model goes quiet. - **Callback isolation** — `onCompactionStart` is declared on `runAgentTurnWithFallback` params only (not on the public `GetReplyOptions`), keeping the external API surface clean. - **Default text is user-friendly** — the fallback string is hardcoded so deployments that set `notifyOnStart: true` without specifying text still get a sensible message. ### Feature 2 — Idle-triggered proactive compaction **Full delivery chain (every link typed):** ``` AgentCompactionConfig.idleTriggerMinutes / idleTriggerPercent (types.agent-defaults.ts) → AgentDefaultsSchema.compaction.idleTriggerMinutes/Percent (zod-schema.agent-defaults.ts, .strict()) → cfg.agents.defaults.compaction resolved at runtime (agent-runner.ts) → scheduleIdleCompaction(ScheduleIdleCompactionParams) (idle-compaction.ts) → Map<sessionKey, NodeJS.Timeout> (module-level timer map) → compactEmbeddedPiSession(CompactEmbeddedPiSessionParams) (pi-embedded.ts re-export) ``` Key decisions: - **Threshold guard prevents premature compaction** — `scheduleIdleCompaction` computes `ratio = contextTokensUsed / contextTokensMax` and returns early if `ratio < idleTriggerPercent` (default 0.7). Sessions well below 70% context usage are never touched by idle compaction. - **`contextTokensUsed` is actual prompt tokens** — resolved from `runResult.meta.agentMeta?.promptTokens`, falling back to `usage.input + usage.cacheRead + usage.cacheWrite`. This is distinct from `contextTokensMax` (the window cap, confusingly named `contextTokensUsed` in the pre-existing code). - **Per-session timer map, replace semantics** — `pendingIdleTimers: Map<sessionKey, NodeJS.Timeout>` holds at most one timer per session. Each call to `scheduleIdleCompaction` cancels any previous timer before setting a new one, so the clock always resets to the last message. - **Inbound cancel on every turn** — `cancelIdleCompaction(sessionKey)` is called unconditionally at turn start (just before `runMemoryFlushIfNeeded`). A new user message always aborts any pending idle compaction. - **Concurrent-run safety** — if `compactEmbeddedPiSession` is called while a Pi agent run is active for the same session, the call is serialised through the existing per-session `enqueueCommandInLane` queue inside `compactEmbeddedPiSession` itself. No additional locking is needed here. - **Best-effort error handling** — errors from `compactEmbeddedPiSession` are caught and logged via `defaultRuntime.error`; they never propagate to the caller. Idle compaction is a background optimisation, not a correctness requirement. - **No timer leak** — a `process.on("exit")` handler (registered exactly once via a module-level `exitHandlerRegistered` flag) calls `clearTimeout` on all pending timers before the process exits. - **`trigger: "manual"`** — reuses the existing `/compact` command code path end-to-end, including session-lane queuing, safety timeout, and post-compaction token-count persistence. ### Boundary behaviour | Situation | Behaviour | |---|---| | `idleTriggerMinutes` not set | `scheduleIdleCompaction` returns immediately; no timer is ever created | | Context ratio exactly at `idleTriggerPercent` | Timer is scheduled (condition is `>=`) | | New user message arrives before timer fires | `cancelIdleCompaction` clears timer; compact is never called | | Pi agent run is active when timer fires | Call is serialised behind the active run via `enqueueCommandInLane` | | `compactEmbeddedPiSession` throws | Error silently caught and logged; next turn proceeds normally | | Process exits with pending timer | `process.on("exit")` handler clears all pending timers | --- ## Testing ### `src/config/config.compaction-settings.test.ts` (6 tests) - Preserves `notifyOnStart: true` and `notifyOnStartText` round-trip - Accepts `notifyOnStart: false` - Rejects non-boolean `notifyOnStart` - Rejects non-string `notifyOnStartText` ### `src/auto-reply/reply/agent-runner.compaction-notify.test.ts` (5 tests) - `notifyOnStart: true` → `onBlockReply` called with default text - `notifyOnStart: true` + custom text → custom text delivered - `notifyOnStart: false` → no notification - `notifyOnStart: undefined` (default) → no notification - Only `phase: "end"` fires (no start event) → no notification ### `src/auto-reply/reply/idle-compaction.test.ts` (18 tests) **No-op paths:** - `idleTriggerMinutes` not configured → no timer - Context below threshold (default 70%) → no timer - Context below custom threshold → no timer **Happy path:** - Context ≥ threshold + `idleTriggerMinutes` set → timer fires → `compactEmbeddedPiSession` called with correct `sessionId`, `sessionKey`, `trigger: "manual"` - Fires at exactly the threshold boundary (≥, not >) **Cancellation:** - `cancelIdleCompaction` before timer fires → compact not called - Re-schedule replaces previous timer; only latest sessionId fires - `cancelIdleCompaction` on unknown key → no-op, no throw **Error handling:** - `compactEmbeddedPiSession` rejects → silently caught, no throw from timer callback **Schema validation (boundary values):** - `idleTriggerMinutes: 0` → rejected (must be positive) - `idleTriggerMinutes: -1` → rejected - `idleTriggerMinutes: 1.5` → rejected (must be integer) - `idleTriggerPercent: 0.1` → accepted (min boundary) - `idleTriggerPercent: 0.95` → accepted (max boundary) - `idleTriggerPercent: 0.05` → rejected (< 0.1) - `idleTriggerPercent: 0.96` → rejected (> 0.95) --- ## Files Changed | File | Status | Description | |---|---|---| | `src/config/types.agent-defaults.ts` | modified | `AgentCompactionConfig` — adds `notifyOnStart`, `notifyOnStartText`, `idleTriggerMinutes`, `idleTriggerPercent` | | `src/config/zod-schema.agent-defaults.ts` | modified | Zod schema for all four new fields; `.strict()` preserved | | `src/auto-reply/reply/agent-runner-execution.ts` | modified | `onCompactionStart` param + invocation on `compaction:phase=start` | | `src/auto-reply/reply/agent-runner.ts` | modified | `onCompactionStart` callback wiring; `cancelIdleCompaction` on turn start; `scheduleIdleCompaction` after `persistRunSessionUsage` | | `src/auto-reply/reply/idle-compaction.ts` | **new** | Per-session idle compaction timer module (`scheduleIdleCompaction`, `cancelIdleCompaction`) | | `src/auto-reply/reply/idle-compaction.test.ts` | **new** | 18 tests for idle compaction logic and schema validation | | `src/config/config.compaction-settings.test.ts` | modified | Schema tests for `notifyOnStart` / `notifyOnStartText` | | `src/auto-reply/reply/agent-runner.compaction-notify.test.ts` | **new** | 5 integration tests for compaction start notification | | `docs/concepts/compaction.md` | modified | User-facing documentation for both features | | `docs/reference/session-management-compaction.md` | modified | Implementation reference for both features |

Most Similar PRs