fix(context-engine): guard compact() throw in overflow path + fire hooks for ownsCompaction engines

Bug 1: contextEngine.compact() in the overflow retry loop (run.ts) could
throw and kill the entire agent run. The LegacyContextEngine is safe
(internal try/catch), but plugin-provided engines are not. Wrap the call
in try/catch to convert thrown errors into a failed CompactResult.

Bug 2: When a context engine sets ownsCompaction: true, Pi's built-in
auto-compaction is disabled, but before_compaction/after_compaction hooks
are only fired from the built-in compaction path. Plugin hook subscribers
are silently broken. Fire hooks in compactEmbeddedPiSession() when the
engine owns compaction.

Also fixes pre-existing test failure in compact.hooks.test.ts where the
buildEmbeddedExtensionFactories mock returned [] instead of { factories: [] }.
This commit is contained in:
David Rudduck
2026-03-09 19:01:58 +10:00
committed by Josh Lehman
parent 5231277163
commit a2e96b77ec
3 changed files with 91 additions and 32 deletions

View File

@@ -160,7 +160,7 @@ vi.mock("../transcript-policy.js", () => ({
}));
vi.mock("./extensions.js", () => ({
buildEmbeddedExtensionFactories: vi.fn(() => []),
buildEmbeddedExtensionFactories: vi.fn(() => ({ factories: [] })),
}));
vi.mock("./history.js", () => ({

View File

@@ -936,6 +936,40 @@ export async function compactEmbeddedPiSession(
modelContextWindow: ceModel?.contextWindow,
defaultTokens: DEFAULT_CONTEXT_TOKENS,
});
// When the context engine owns compaction, its compact() implementation
// bypasses compactEmbeddedPiSessionDirect (which fires the hooks internally).
// Fire before_compaction / after_compaction hooks here so plugin subscribers
// are notified regardless of which engine is active.
const engineOwnsCompaction = contextEngine.info.ownsCompaction === true;
const hookRunner = engineOwnsCompaction ? getGlobalHookRunner() : null;
const hookSessionKey = params.sessionKey?.trim() || params.sessionId;
const { sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
});
const resolvedMessageProvider = params.messageChannel ?? params.messageProvider;
const hookCtx = {
sessionId: params.sessionId,
agentId: sessionAgentId,
sessionKey: hookSessionKey,
workspaceDir: params.workspaceDir,
messageProvider: resolvedMessageProvider,
};
if (hookRunner?.hasHooks("before_compaction")) {
try {
await hookRunner.runBeforeCompaction(
{
messageCount: 0,
sessionFile: params.sessionFile,
},
hookCtx,
);
} catch (err) {
log.warn("before_compaction hook failed", {
errorMessage: err instanceof Error ? err.message : String(err),
});
}
}
const result = await contextEngine.compact({
sessionId: params.sessionId,
sessionFile: params.sessionFile,
@@ -944,6 +978,23 @@ export async function compactEmbeddedPiSession(
force: params.trigger === "manual",
runtimeContext: params as Record<string, unknown>,
});
if (hookRunner?.hasHooks("after_compaction")) {
try {
await hookRunner.runAfterCompaction(
{
messageCount: 0,
compactedCount: 0,
tokenCount: result.result?.tokensAfter,
sessionFile: params.sessionFile,
},
hookCtx,
);
} catch (err) {
log.warn("after_compaction hook failed", {
errorMessage: err instanceof Error ? err.message : String(err),
});
}
}
return {
ok: result.ok,
compacted: result.compacted,

View File

@@ -1028,37 +1028,45 @@ export async function runEmbeddedPiAgent(
log.warn(
`context overflow detected (attempt ${overflowCompactionAttempts}/${MAX_OVERFLOW_COMPACTION_ATTEMPTS}); attempting auto-compaction for ${provider}/${modelId}`,
);
const compactResult = await contextEngine.compact({
sessionId: params.sessionId,
sessionFile: params.sessionFile,
tokenBudget: ctxInfo.tokens,
force: true,
compactionTarget: "budget",
runtimeContext: {
sessionKey: params.sessionKey,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
agentAccountId: params.agentAccountId,
authProfileId: lastProfileId,
workspaceDir: resolvedWorkspace,
agentDir,
config: params.config,
skillsSnapshot: params.skillsSnapshot,
senderIsOwner: params.senderIsOwner,
provider,
model: modelId,
runId: params.runId,
thinkLevel,
reasoningLevel: params.reasoningLevel,
bashElevated: params.bashElevated,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
trigger: "overflow",
diagId: overflowDiagId,
attempt: overflowCompactionAttempts,
maxAttempts: MAX_OVERFLOW_COMPACTION_ATTEMPTS,
},
});
let compactResult: Awaited<ReturnType<typeof contextEngine.compact>>;
try {
compactResult = await contextEngine.compact({
sessionId: params.sessionId,
sessionFile: params.sessionFile,
tokenBudget: ctxInfo.tokens,
force: true,
compactionTarget: "budget",
runtimeContext: {
sessionKey: params.sessionKey,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
agentAccountId: params.agentAccountId,
authProfileId: lastProfileId,
workspaceDir: resolvedWorkspace,
agentDir,
config: params.config,
skillsSnapshot: params.skillsSnapshot,
senderIsOwner: params.senderIsOwner,
provider,
model: modelId,
runId: params.runId,
thinkLevel,
reasoningLevel: params.reasoningLevel,
bashElevated: params.bashElevated,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
trigger: "overflow",
diagId: overflowDiagId,
attempt: overflowCompactionAttempts,
maxAttempts: MAX_OVERFLOW_COMPACTION_ATTEMPTS,
},
});
} catch (compactErr) {
log.warn(
`contextEngine.compact() threw during overflow recovery for ${provider}/${modelId}: ${String(compactErr)}`,
);
compactResult = { ok: false, compacted: false, reason: String(compactErr) };
}
if (compactResult.compacted) {
autoCompactionCount += 1;
log.info(`auto-compaction succeeded for ${provider}/${modelId}; retrying prompt`);