Agents: send compaction start and completion notices

This commit is contained in:
Sebastian Otaegui
2026-04-16 18:27:18 -03:00
committed by Josh Lehman
parent 1603577dfd
commit 176adc30c7
7 changed files with 107 additions and 32 deletions

View File

@@ -132,10 +132,10 @@ capable model for better summaries:
}
```
## Compaction start notice
## Compaction notices
By default, compaction runs silently. To show a brief notice when compaction
starts, enable `notifyUser`:
By default, compaction runs silently. To show brief notices when compaction
starts and when it completes, enable `notifyUser`:
```json5
{
@@ -149,8 +149,8 @@ starts, enable `notifyUser`:
}
```
When enabled, the user sees a short message (for example, "Compacting
context...") at the start of each compaction run.
When enabled, the user sees short status messages around each compaction run
(for example, "Compacting context..." and "Compaction complete").
## Compaction vs pruning

View File

@@ -1392,7 +1392,7 @@ Periodic heartbeat runs.
identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom
postCompactionSections: ["Session Startup", "Red Lines"], // [] disables reinjection
model: "openrouter/anthropic/claude-sonnet-4-6", // optional compaction-only model override
notifyUser: true, // send a brief notice when compaction starts (default: false)
notifyUser: true, // send brief notices when compaction starts and completes (default: false)
memoryFlush: {
enabled: true,
softThresholdTokens: 6000,
@@ -1412,7 +1412,7 @@ Periodic heartbeat runs.
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.
- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Defaults to `["Session Startup", "Red Lines"]`; set `[]` to disable reinjection. When unset or explicitly set to that default pair, older `Every Session`/`Safety` headings are also accepted as a legacy fallback.
- `model`: optional `provider/model-id` override for compaction summarization only. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model.
- `notifyUser`: when `true`, sends a brief notice to the user when compaction starts (for example, "Compacting context..."). Disabled by default to keep compaction silent.
- `notifyUser`: when `true`, sends brief notices to the user when compaction starts and when it completes (for example, "Compacting context..." and "Compaction complete"). Disabled by default to keep compaction silent.
- `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Skipped when workspace is read-only.
### `agents.defaults.contextPruning`

View File

@@ -863,6 +863,74 @@ describe("runAgentTurnWithFallback", () => {
);
});
it("emits a compaction completion notice when notifyUser is enabled", async () => {
const onBlockReply = vi.fn();
state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => {
await params.onAgentEvent?.({ stream: "compaction", data: { phase: "start" } });
await params.onAgentEvent?.({
stream: "compaction",
data: { phase: "end", completed: true },
});
return { payloads: [{ text: "final" }], meta: {} };
});
const followupRun = createFollowupRun();
followupRun.run.config = {
agents: {
defaults: {
compaction: {
notifyUser: true,
},
},
},
};
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const result = await runAgentTurnWithFallback({
commandBody: "hello",
followupRun,
sessionCtx: {
Provider: "whatsapp",
MessageSid: "msg",
} as unknown as TemplateContext,
opts: { onBlockReply },
typingSignals: createMockTypingSignaler(),
blockReplyPipeline: null,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
applyReplyToMode: (payload) => payload,
shouldEmitToolResult: () => true,
shouldEmitToolOutput: () => false,
pendingToolTasks: new Set(),
resetSessionAfterCompactionFailure: async () => false,
resetSessionAfterRoleOrderingConflict: async () => false,
isHeartbeat: false,
sessionKey: "main",
getActiveSessionEntry: () => undefined,
resolvedVerboseLevel: "off",
});
expect(result.kind).toBe("success");
expect(onBlockReply).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
text: "🧹 Compacting context...",
replyToId: "msg",
replyToCurrent: true,
isCompactionNotice: true,
}),
);
expect(onBlockReply).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
text: "🧹 Compaction complete",
replyToId: "msg",
replyToCurrent: true,
isCompactionNotice: true,
}),
);
});
it("does not show a rate-limit countdown for mixed-cause fallback exhaustion", async () => {
state.runWithModelFallbackMock.mockRejectedValueOnce(
Object.assign(

View File

@@ -623,6 +623,27 @@ export async function runAgentTurnWithFallback(params: {
didNotifyAgentRunStart = true;
params.opts?.onAgentRunStart?.(runId);
};
const currentMessageId = params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid;
const shouldNotifyUserAboutCompaction =
runtimeConfig?.agents?.defaults?.compaction?.notifyUser === true;
const sendCompactionNotice = async (phase: "start" | "end") => {
if (!params.opts?.onBlockReply) {
return;
}
const noticePayload = params.applyReplyToMode({
text: phase === "start" ? "🧹 Compacting context..." : "🧹 Compaction complete",
replyToId: currentMessageId,
replyToCurrent: true,
isCompactionNotice: true,
});
try {
await params.opts.onBlockReply(noticePayload);
} catch (err) {
// Non-critical notice delivery failure should not bubble out of the
// fire-and-forget event handler.
logVerbose(`compaction ${phase} notice delivery failed (non-fatal): ${String(err)}`);
}
};
const shouldSurfaceToControlUi = isInternalMessageChannel(
params.followupRun.run.messageProvider ??
params.sessionCtx.Surface ??
@@ -1142,37 +1163,23 @@ export async function runAgentTurnWithFallback(params: {
if (phase === "start") {
// Keep custom compaction callbacks active, but gate the
// fallback user-facing notice behind explicit opt-in.
const notifyUser =
runtimeConfig?.agents?.defaults?.compaction?.notifyUser === true;
if (params.opts?.onCompactionStart) {
await params.opts.onCompactionStart();
} else if (notifyUser && params.opts?.onBlockReply) {
} else if (shouldNotifyUserAboutCompaction) {
// Send directly via opts.onBlockReply (bypassing the
// pipeline) so the notice does not cause final payloads
// to be discarded on non-streaming model paths.
const currentMessageId =
params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid;
const noticePayload = params.applyReplyToMode({
text: "🧹 Compacting context...",
replyToId: currentMessageId,
replyToCurrent: true,
isCompactionNotice: true,
});
try {
await params.opts.onBlockReply(noticePayload);
} catch (err) {
// Non-critical notice delivery failure should not
// bubble out of the fire-and-forget event handler.
logVerbose(
`compaction start notice delivery failed (non-fatal): ${String(err)}`,
);
}
await sendCompactionNotice("start");
}
}
const completed = evt.data?.completed === true;
if (phase === "end" && completed) {
attemptCompactionCount += 1;
await params.opts?.onCompactionEnd?.();
if (params.opts?.onCompactionEnd) {
await params.opts.onCompactionEnd();
} else if (shouldNotifyUserAboutCompaction) {
await sendCompactionNotice("end");
}
}
}
},

View File

@@ -4590,7 +4590,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
type: "boolean",
title: "Compaction Notify User",
description:
"When enabled, sends a brief compaction notice to the user (e.g. '🧹 Compacting context...') when compaction starts. Disabled by default to keep compaction silent and non-intrusive.",
"When enabled, sends brief compaction notices to the user when compaction starts and when it completes (for example, '🧹 Compacting context...' and '🧹 Compaction complete'). Disabled by default to keep compaction silent and non-intrusive.",
},
},
additionalProperties: false,
@@ -25716,7 +25716,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
},
"agents.defaults.compaction.notifyUser": {
label: "Compaction Notify User",
help: "When enabled, sends a brief compaction notice to the user (e.g. '🧹 Compacting context...') when compaction starts. Disabled by default to keep compaction silent and non-intrusive.",
help: "When enabled, sends brief compaction notices to the user when compaction starts and when it completes (for example, '🧹 Compacting context...' and '🧹 Compaction complete'). Disabled by default to keep compaction silent and non-intrusive.",
tags: ["advanced"],
},
"agents.defaults.compaction.memoryFlush": {

View File

@@ -1216,7 +1216,7 @@ export const FIELD_HELP: Record<string, string> = {
"agents.defaults.compaction.truncateAfterCompaction":
"When enabled, rewrites the session JSONL file after compaction to remove entries that were summarized. Prevents unbounded file growth in long-running sessions with many compaction cycles. Default: false.",
"agents.defaults.compaction.notifyUser":
"When enabled, sends a brief compaction notice to the user (e.g. '🧹 Compacting context...') when compaction starts. Disabled by default to keep compaction silent and non-intrusive.",
"When enabled, sends brief compaction notices to the user when compaction starts and when it completes (for example, '🧹 Compacting context...' and '🧹 Compaction complete'). Disabled by default to keep compaction silent and non-intrusive.",
"agents.defaults.compaction.memoryFlush":
"Pre-compaction memory flush settings that run an agentic memory write before heavy compaction. Keep enabled for long sessions so salient context is persisted before aggressive trimming.",
"agents.defaults.compaction.memoryFlush.enabled":

View File

@@ -450,7 +450,7 @@ export type AgentCompactionConfig = {
*/
truncateAfterCompaction?: boolean;
/**
* Send a "🧹 Compacting context..." notice to the user when compaction starts.
* Send brief compaction notices to the user when compaction starts and completes.
* Default: false (silent by default).
*/
notifyUser?: boolean;