feat(coding-agent): add idle compaction flow

This commit is contained in:
DeprecatedLuke
2026-04-04 11:16:12 +01:00
parent f044f3ba75
commit 5dfa44f2a3
8 changed files with 127 additions and 10 deletions
+1
View File
@@ -3,6 +3,7 @@
## [Unreleased]
### Added
- Added idle auto-compaction settings and scheduling so sessions can compact after inactive turns without auto-continuing.
- Added `edit.manageImports` setting to enable/disable post-edit import and include management
- Added import/include management for TypeScript, JavaScript, Python, Rust, Go, and C/C++ in edit operations
- Added `imports` parameter to patch, hashline, and replace edit operations to declare imports/includes needed after code changes
@@ -797,6 +797,40 @@ export const SETTINGS_SCHEMA = {
"compaction.remoteEndpoint": { type: "string", default: undefined },
// Idle compaction
"compaction.idleEnabled": {
type: "boolean",
default: false,
ui: {
tab: "context",
label: "Idle Compaction",
description: "Compact context while idle when token count exceeds threshold",
},
},
"compaction.idleThresholdTokens": {
type: "enum",
values: [100000, 200000, 300000, 400000, 500000, 600000, 700000, 800000, 900000] as const,
default: 200000,
ui: {
tab: "context",
label: "Idle Compaction Threshold",
description: "Token count above which idle compaction triggers",
submenu: true,
},
},
"compaction.idleTimeoutSeconds": {
type: "enum",
values: [60, 120, 300, 600, 1800, 3600] as const,
default: 300,
ui: {
tab: "context",
label: "Idle Compaction Delay",
description: "Seconds to wait while idle before compacting",
submenu: true,
},
},
// Branch summaries
"branchSummary.enabled": {
type: "boolean",
@@ -1711,6 +1745,9 @@ export interface CompactionSettings {
autoContinue: boolean;
remoteEnabled: boolean;
remoteEndpoint: string | undefined;
idleEnabled: boolean;
idleThresholdTokens: number;
idleTimeoutSeconds: number;
}
export interface ContextPromotionSettings {
@@ -94,7 +94,7 @@ export type CustomToolSessionEvent =
}
| {
reason: "auto_compaction_start";
trigger: "threshold" | "overflow";
trigger: "threshold" | "overflow" | "idle";
action: "context-full" | "handoff";
}
| {
@@ -568,7 +568,7 @@ export interface ToolExecutionEndEvent {
/** Fired when auto-compaction starts */
export interface AutoCompactionStartEvent {
type: "auto_compaction_start";
reason: "threshold" | "overflow";
reason: "threshold" | "overflow" | "idle";
action: "context-full" | "handoff";
}
@@ -394,7 +394,7 @@ export interface TurnEndEvent {
/** Event data for auto_compaction_start event. */
export interface AutoCompactionStartEvent {
type: "auto_compaction_start";
reason: "threshold" | "overflow";
reason: "threshold" | "overflow" | "idle";
action: "context-full" | "handoff";
}
@@ -109,6 +109,25 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
{ value: "300000", label: "300K tokens", description: "Large context window" },
{ value: "500000", label: "500K tokens", description: "Very large context window" },
],
"compaction.idleThresholdTokens": [
{ value: "100000", label: "100K tokens" },
{ value: "200000", label: "200K tokens" },
{ value: "300000", label: "300K tokens" },
{ value: "400000", label: "400K tokens" },
{ value: "500000", label: "500K tokens" },
{ value: "600000", label: "600K tokens" },
{ value: "700000", label: "700K tokens" },
{ value: "800000", label: "800K tokens" },
{ value: "900000", label: "900K tokens" },
],
"compaction.idleTimeoutSeconds": [
{ value: "60", label: "1 minute" },
{ value: "120", label: "2 minutes" },
{ value: "300", label: "5 minutes" },
{ value: "600", label: "10 minutes" },
{ value: "1800", label: "30 minutes" },
{ value: "3600", label: "1 hour" },
],
// Retry max retries
"retry.maxRetries": [
{ value: "1", label: "1 retry" },
@@ -1,5 +1,5 @@
import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
import type { ImageContent } from "@oh-my-pi/pi-ai";
import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
import { Loader, TERMINAL, Text } from "@oh-my-pi/pi-tui";
import { settings } from "../../config/settings";
import { AssistantMessageComponent } from "../../modes/components/assistant-message";
@@ -10,6 +10,7 @@ import { TtsrNotificationComponent } from "../../modes/components/ttsr-notificat
import { getSymbolTheme, theme } from "../../modes/theme/theme";
import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
import type { AgentSessionEvent } from "../../session/agent-session";
import { calculatePromptTokens } from "../../session/compaction/compaction";
import type { ExitPlanModeDetails } from "../../tools";
export class EventController {
@@ -21,6 +22,7 @@ export class EventController {
#readToolCallArgs = new Map<string, Record<string, unknown>>();
#readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
#lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
#idleCompactionTimer?: NodeJS.Timeout;
constructor(private ctx: InteractiveModeContext) {}
#resetReadGroup(): void {
@@ -107,6 +109,7 @@ export class EventController {
this.ctx.retryLoader = undefined;
this.ctx.statusContainer.clear();
}
this.#cancelIdleCompaction();
this.ctx.ensureLoadingAnimation();
this.ctx.ui.requestRender();
break;
@@ -434,16 +437,19 @@ export class EventController {
this.#readToolCallAssistantComponents.clear();
this.#lastAssistantComponent = undefined;
this.ctx.ui.requestRender();
this.#scheduleIdleCompaction();
this.sendCompletionNotification();
break;
case "auto_compaction_start": {
this.#cancelIdleCompaction();
this.ctx.autoCompactionEscapeHandler = this.ctx.editor.onEscape;
this.ctx.editor.onEscape = () => {
this.ctx.session.abortCompaction();
};
this.ctx.statusContainer.clear();
const reasonText = event.reason === "overflow" ? "Context overflow detected, " : "";
const reasonText =
event.reason === "overflow" ? "Context overflow detected, " : event.reason === "idle" ? "Idle " : "";
const actionLabel = event.action === "handoff" ? "Auto-handoff" : "Auto context-full maintenance";
this.ctx.autoCompactionLoader = new Loader(
this.ctx.ui,
@@ -458,6 +464,7 @@ export class EventController {
}
case "auto_compaction_end": {
this.#cancelIdleCompaction();
if (this.ctx.autoCompactionEscapeHandler) {
this.ctx.editor.onEscape = this.ctx.autoCompactionEscapeHandler;
this.ctx.autoCompactionEscapeHandler = undefined;
@@ -565,6 +572,49 @@ export class EventController {
}
}
#cancelIdleCompaction(): void {
if (this.#idleCompactionTimer) {
clearTimeout(this.#idleCompactionTimer);
this.#idleCompactionTimer = undefined;
}
}
#scheduleIdleCompaction(): void {
this.#cancelIdleCompaction();
// Don't schedule while compaction/handoff is already running — the agent_end from a
// handoff agent turn still has the old session's bloated token counts, and scheduling
// here would fire after the session resets, trying to handoff an empty session.
if (this.ctx.session.isCompacting) return;
const idleSettings = settings.getGroup("compaction");
if (!idleSettings.idleEnabled) return;
// Only if input is empty
if (this.ctx.editor.getText().trim()) return;
// Check token count against idle threshold
const messages = this.ctx.session.agent.state.messages;
const lastAssistant = messages
.slice()
.reverse()
.find((m): m is AssistantMessage => m.role === "assistant" && m.stopReason !== "aborted");
if (!lastAssistant?.usage) return;
const contextTokens = calculatePromptTokens(lastAssistant.usage);
const threshold = idleSettings.idleThresholdTokens;
if (threshold <= 0 || contextTokens < threshold) return;
const timeoutMs = Math.max(60, Math.min(3600, idleSettings.idleTimeoutSeconds)) * 1000;
this.#idleCompactionTimer = setTimeout(() => {
this.#idleCompactionTimer = undefined;
// Re-check conditions before firing
if (this.ctx.session.isStreaming) return;
if (this.ctx.session.isCompacting) return;
if (this.ctx.editor.getText().trim()) return;
void this.ctx.session.runIdleCompaction();
}, timeoutMs);
}
sendCompletionNotification(): void {
if (this.ctx.isBackgrounded === false) return;
const notify = settings.get("completion.notify");
@@ -162,7 +162,7 @@ import { getLatestCompactionEntry } from "./session-manager";
/** Session-specific events that extend the core AgentEvent */
export type AgentSessionEvent =
| AgentEvent
| { type: "auto_compaction_start"; reason: "threshold" | "overflow"; action: "context-full" | "handoff" }
| { type: "auto_compaction_start"; reason: "threshold" | "overflow" | "idle"; action: "context-full" | "handoff" }
| {
type: "auto_compaction_end";
action: "context-full" | "handoff";
@@ -3646,6 +3646,12 @@ export class AgentSession {
this.#handoffAbortController?.abort();
}
/** Trigger idle compaction through the auto-compaction flow (with UI events). */
async runIdleCompaction(): Promise<void> {
if (this.isStreaming || this.isCompacting) return;
await this.#runAutoCompaction("idle", false, true);
}
/**
* Cancel in-progress branch summarization.
*/
@@ -4410,11 +4416,15 @@ export class AgentSession {
/**
* Internal: Run auto-compaction with events.
*/
async #runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean, deferred = false): Promise<void> {
async #runAutoCompaction(
reason: "overflow" | "threshold" | "idle",
willRetry: boolean,
deferred = false,
): Promise<void> {
const compactionSettings = this.settings.getGroup("compaction");
if (!compactionSettings.enabled || compactionSettings.strategy === "off") return;
if (reason !== "idle" && (!compactionSettings.enabled || compactionSettings.strategy === "off")) return;
const generation = this.#promptGeneration;
if (!deferred && reason !== "overflow" && compactionSettings.strategy === "handoff") {
if (!deferred && reason !== "overflow" && reason !== "idle" && compactionSettings.strategy === "handoff") {
this.#schedulePostPromptTask(
async signal => {
await Promise.resolve();
@@ -4717,7 +4727,7 @@ export class AgentSession {
};
await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry });
if (!willRetry && compactionSettings.autoContinue !== false) {
if (!willRetry && reason !== "idle" && compactionSettings.autoContinue !== false) {
const continuePrompt = async () => {
await this.#promptWithMessage(
{