fix(extensions): emit compaction failed for extensions (#8241)

* fix(extensions): emit compaction failed

* changelog yeet
This commit is contained in:
Cristina Poncela Cubeiro 2026-08-17 13:10:08 +02:00 • committed by GitHub
parent df018b6020
commit a6b1dbceb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 139 additions and 10 deletions

View file

@ -4,6 +4,7 @@
### Fixed
- Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)).
- Fixed npm package update checks treating older registry versions as available updates, preventing `pi update` from downgrading already-newer installed packages ([#8226](https://github.com/earendil-works/pi/issues/8226)).
- Fixed built-in llama.cpp models disappearing from `/model` when `/llama` refreshed a configured server under `PI_OFFLINE`, and included idle-slept `sleeping` router models in the selectable catalog ([#8167](https://github.com/earendil-works/pi/issues/8167)).
- Fixed `pi.registerFlag()` accepting default values that do not match the declared flag type ([#8064](https://github.com/earendil-works/pi/issues/8064)).

View file

@ -346,6 +346,21 @@ pi.on("session_before_compact", async (event, ctx) => {
See [custom-compaction.ts](../examples/extensions/custom-compaction.ts) for a complete example using a different model.
### session_compact_failed
Fired when manual or automatic compaction fails or is aborted. This is useful for telemetry extensions that need to pair `session_before_compact` attempts with terminal outcomes.
```typescript
pi.on("session_compact_failed", async (event, ctx) => {
const { reason, errorMessage, aborted, willRetry, fromExtension } = event;
// reason - "manual" (/compact), "threshold", or "overflow"
// errorMessage - present for non-abort failures
// aborted - true for cancelled/aborted compactions
// willRetry - whether the aborted turn would have retried after compaction
// fromExtension - whether extension-provided compaction content was being used
});
```
### session_before_tree
Fired before `/tree` navigation. Always fires regardless of whether user chose to summarize. Can cancel navigation or provide custom summary.

View file

@ -330,7 +330,8 @@ user sends another prompt ◄─────────────────
/compact or auto-compaction
├─► session_before_compact (can cancel or customize)
└─► session_compact
├─► session_compact (success)
└─► session_compact_failed (failure or abort)
/tree navigation
├─► session_before_tree (can cancel or customize)
@ -448,7 +449,7 @@ pi.on("session_before_fork", async (event, ctx) => {
After a successful fork or clone, pi emits `session_shutdown` for the old extension instance, reloads and rebinds extensions for the new session, then emits `session_start` with `reason: "fork"` and `previousSessionFile`.
Do cleanup work in `session_shutdown`, then reestablish any in-memory state in `session_start`.
#### session_before_compact / session_compact
#### session_before_compact / session_compact / session_compact_failed
Fired on compaction. See [compaction.md](compaction.md) for details.
@ -479,6 +480,14 @@ pi.on("session_compact", async (event, ctx) => {
// event.reason - "manual" (/compact), "threshold", or "overflow"
// event.willRetry - whether the aborted turn is retried after compaction (overflow recovery)
});
pi.on("session_compact_failed", async (event, ctx) => {
// event.reason - "manual" (/compact), "threshold", or "overflow"
// event.errorMessage - present for non-abort failures
// event.aborted - true for cancelled/aborted compactions
// event.willRetry - whether the aborted turn would have retried after compaction
// event.fromExtension - whether extension-provided compaction content was being used
});
```
#### session_before_tree / session_tree

View file

@ -81,6 +81,7 @@ import {
type ReplacedSessionContext,
type SessionBeforeCompactResult,
type SessionBeforeTreeResult,
type SessionCompactFailedEvent,
type SessionStartEvent,
type ShutdownHandler,
type ToolDefinition,
@ -574,6 +575,12 @@ export class AgentSession {
});
}
private async _emitSessionCompactFailed(event: Omit<SessionCompactFailedEvent, "type">): Promise<void> {
if (this._extensionRunner.hasHandlers("session_compact_failed")) {
await this._extensionRunner.emit({ type: "session_compact_failed", ...event });
}
}
private _getIdleWaitPromise(): Promise<void> {
if (!this._idleWaitPromise) {
this._idleWaitPromise = new Promise((resolve) => {
@ -1801,6 +1808,7 @@ export class AgentSession {
await this.abort();
this._compactionAbortController = new AbortController();
this._emit({ type: "compaction_start", reason: "manual" });
let fromExtension = false;
try {
if (!this.model) {
@ -1823,7 +1831,6 @@ export class AgentSession {
}
let extensionCompaction: CompactionResult | undefined;
let fromExtension = false;
if (this._extensionRunner.hasHandlers("session_before_compact")) {
const result = (await this._extensionRunner.emit({
@ -1928,6 +1935,7 @@ export class AgentSession {
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const aborted = message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError");
const errorMessage = aborted ? undefined : `Compaction failed: ${message}`;
this._compactionAbortController = undefined;
this._emit({
type: "compaction_end",
@ -1935,7 +1943,14 @@ export class AgentSession {
result: undefined,
aborted,
willRetry: false,
errorMessage: aborted ? undefined : `Compaction failed: ${message}`,
errorMessage,
});
await this._emitSessionCompactFailed({
reason: "manual",
errorMessage,
aborted,
willRetry: false,
fromExtension,
});
throw error;
} finally {
@ -2031,6 +2046,13 @@ export class AgentSession {
willRetry: false,
errorMessage,
});
await this._emitSessionCompactFailed({
reason: "overflow",
errorMessage,
aborted: false,
willRetry: false,
fromExtension: false,
});
return false;
}
@ -2088,6 +2110,7 @@ export class AgentSession {
private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise<boolean> {
const settings = this.settingsManager.getCompactionSettings();
let started = false;
let fromExtension = false;
try {
if (!this.model) {
@ -2108,7 +2131,6 @@ export class AgentSession {
started = true;
let extensionCompaction: CompactionResult | undefined;
let fromExtension = false;
if (this._extensionRunner.hasHandlers("session_before_compact")) {
const extensionResult = (await this._extensionRunner.emit({
@ -2129,6 +2151,12 @@ export class AgentSession {
aborted: true,
willRetry: false,
});
await this._emitSessionCompactFailed({
reason,
aborted: true,
willRetry: false,
fromExtension: false,
});
return false;
}
@ -2182,6 +2210,12 @@ export class AgentSession {
aborted: true,
willRetry: false,
});
await this._emitSessionCompactFailed({
reason,
aborted: true,
willRetry: false,
fromExtension,
});
return false;
}
@ -2235,16 +2269,24 @@ export class AgentSession {
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "compaction failed";
if (started) {
const formattedErrorMessage =
reason === "overflow"
? `Context overflow recovery failed: ${errorMessage}`
: `Auto-compaction failed: ${errorMessage}`;
this._emit({
type: "compaction_end",
reason,
result: undefined,
aborted: false,
willRetry: false,
errorMessage:
reason === "overflow"
? `Context overflow recovery failed: ${errorMessage}`
: `Auto-compaction failed: ${errorMessage}`,
errorMessage: formattedErrorMessage,
});
await this._emitSessionCompactFailed({
reason,
errorMessage: formattedErrorMessage,
aborted: false,
willRetry: false,
fromExtension,
});
}
return false;

View file

@ -134,6 +134,7 @@ export type {
SessionBeforeTreeEvent,
SessionBeforeTreeResult,
SessionCompactEvent,
SessionCompactFailedEvent,
SessionEvent,
SessionInfoChangedEvent,
SessionShutdownEvent,

View file

@ -601,7 +601,7 @@ export interface SessionBeforeCompactEvent {
signal: AbortSignal;
}
/** Fired after context compaction */
/** Fired after context compaction succeeds */
export interface SessionCompactEvent {
type: "session_compact";
compactionEntry: CompactionEntry;
@ -612,6 +612,21 @@ export interface SessionCompactEvent {
willRetry: boolean;
}
/** Fired after context compaction fails or is aborted */
export interface SessionCompactFailedEvent {
type: "session_compact_failed";
/** What triggered the compaction: manual /compact, the context threshold, or context overflow recovery */
reason: "manual" | "threshold" | "overflow";
/** Error text when compaction failed for a non-abort reason. */
errorMessage?: string;
/** True when compaction was cancelled or aborted. */
aborted: boolean;
/** True when the aborted turn would have been retried after this compaction (overflow recovery) */
willRetry: boolean;
/** True when the failing compaction content came from a session_before_compact handler. */
fromExtension: boolean;
}
/** Fired before an extension runtime is torn down due to quit, reload, or session replacement. */
export interface SessionShutdownEvent {
type: "session_shutdown";
@ -658,6 +673,7 @@ export type SessionEvent =
| SessionBeforeForkEvent
| SessionBeforeCompactEvent
| SessionCompactEvent
| SessionCompactFailedEvent
| SessionShutdownEvent
| SessionBeforeTreeEvent
| SessionTreeEvent;
@ -1214,6 +1230,7 @@ export interface ExtensionAPI {
handler: ExtensionHandler<SessionBeforeCompactEvent, SessionBeforeCompactResult>,
): void;
on(event: "session_compact", handler: ExtensionHandler<SessionCompactEvent>): void;
on(event: "session_compact_failed", handler: ExtensionHandler<SessionCompactFailedEvent>): void;
on(event: "session_shutdown", handler: ExtensionHandler<SessionShutdownEvent>): void;
on(event: "session_before_tree", handler: ExtensionHandler<SessionBeforeTreeEvent, SessionBeforeTreeResult>): void;
on(event: "session_tree", handler: ExtensionHandler<SessionTreeEvent>): void;

View file

@ -278,6 +278,50 @@ describe("AgentSession compaction characterization", () => {
expect(getStreamCallCount()).toBe(1);
});
it("notifies extensions when auto-compaction fails", async () => {
const failedEvents: Array<{
reason: "manual" | "threshold" | "overflow";
errorMessage?: string;
aborted: boolean;
willRetry: boolean;
fromExtension: boolean;
}> = [];
const harness = await createHarness({
extensionFactories: [
(pi) => {
pi.on("session_compact_failed", async (event) => {
failedEvents.push(event);
});
},
],
});
harnesses.push(harness);
seedCompactableSession(harness);
harness.session.agent.streamFunction = () => {
throw new Error("summary generator blew up");
};
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
await expect(sessionInternals._runAutoCompaction("threshold", false)).resolves.toBe(false);
expect(harness.eventsOfType("compaction_end").at(-1)).toMatchObject({
reason: "threshold",
aborted: false,
willRetry: false,
errorMessage: "Auto-compaction failed: summary generator blew up",
});
expect(failedEvents).toEqual([
expect.objectContaining({
type: "session_compact_failed",
reason: "threshold",
aborted: false,
willRetry: false,
fromExtension: false,
errorMessage: "Auto-compaction failed: summary generator blew up",
}),
]);
});
it("compacts and resumes after a length stop below the desired output limit", async () => {
const harness = await createHarness({
models: [{ id: "faux-1", contextWindow: 1000, maxTokens: 100 }],