From 5dfa44f2a3d477f45ae39c64e656d5b7fb88fa82 Mon Sep 17 00:00:00 2001 From: DeprecatedLuke <16418011+DeprecatedLuke@users.noreply.github.com> Date: Sat, 4 Apr 2026 11:16:12 +0100 Subject: [PATCH] feat(coding-agent): add idle compaction flow --- packages/coding-agent/CHANGELOG.md | 1 + .../src/config/settings-schema.ts | 37 +++++++++++++ .../src/extensibility/custom-tools/types.ts | 2 +- .../src/extensibility/extensions/types.ts | 2 +- .../src/extensibility/hooks/types.ts | 2 +- .../src/modes/components/settings-defs.ts | 19 +++++++ .../src/modes/controllers/event-controller.ts | 54 ++++++++++++++++++- .../coding-agent/src/session/agent-session.ts | 20 +++++-- 8 files changed, 127 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8daef5ebd..3d47ac6d2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 32a8a1406..b1340a90f 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -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 { diff --git a/packages/coding-agent/src/extensibility/custom-tools/types.ts b/packages/coding-agent/src/extensibility/custom-tools/types.ts index 415c1cbcc..4ac99f039 100644 --- a/packages/coding-agent/src/extensibility/custom-tools/types.ts +++ b/packages/coding-agent/src/extensibility/custom-tools/types.ts @@ -94,7 +94,7 @@ export type CustomToolSessionEvent = } | { reason: "auto_compaction_start"; - trigger: "threshold" | "overflow"; + trigger: "threshold" | "overflow" | "idle"; action: "context-full" | "handoff"; } | { diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 5014e5b03..0823280df 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -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"; } diff --git a/packages/coding-agent/src/extensibility/hooks/types.ts b/packages/coding-agent/src/extensibility/hooks/types.ts index 5cde85f4a..2a6b2f120 100644 --- a/packages/coding-agent/src/extensibility/hooks/types.ts +++ b/packages/coding-agent/src/extensibility/hooks/types.ts @@ -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"; } diff --git a/packages/coding-agent/src/modes/components/settings-defs.ts b/packages/coding-agent/src/modes/components/settings-defs.ts index 6c1c800ef..2b981507d 100644 --- a/packages/coding-agent/src/modes/components/settings-defs.ts +++ b/packages/coding-agent/src/modes/components/settings-defs.ts @@ -109,6 +109,25 @@ const OPTION_PROVIDERS: Partial> = { { 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" }, diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 7e18e4986..919b0949a 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -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>(); #readToolCallAssistantComponents = new Map(); #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"); diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index c6bf87d62..e5843e67f 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -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 { + 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 { + async #runAutoCompaction( + reason: "overflow" | "threshold" | "idle", + willRetry: boolean, + deferred = false, + ): Promise { 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( {