#21117: feat(compaction): notify on start + idle-triggered proactive compaction (extends #18049)
docs
size: L
Cluster:
Compaction Enhancements and Features
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
#10505: feat(compaction): add timeout, model override, and diagnostic logging
by thebtf · 2026-02-06
79.0%
#14887: feat(compaction): configurable auto-compaction notifications with o...
by seilk · 2026-02-12
76.1%
#20038: (fix): Compaction: preserve recent context and sync session memory ...
by rodrigouroz · 2026-02-18
75.2%
#19593: feat(compaction): proactive handover before context overflow
by qualiobra · 2026-02-18
75.0%
#4042: agents: add proactive compaction before request
by freedomzt · 2026-01-29
74.1%
#21547: feat: add compaction.announce config to notify users of compaction ...
by jlwestsr · 2026-02-20
73.6%
#19923: feat: track held messages during compaction gate and split verifica...
by PrivacySmurf · 2026-02-18
73.3%
#9049: fix: prevent subagent stuck loops and ensure user feedback
by maxtongwang · 2026-02-04
73.3%
#19329: feat: add per-agent compaction and context pruning overrides
by curtismercier · 2026-02-17
73.3%
#11089: feat(compaction): support customInstructions and model override for...
by p697 · 2026-02-07
72.7%