Merge pull request #617 from DeprecatedLuke/feat/idle-compaction
feat(coding-agent): add idle compaction flow
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
- Added idle auto-compaction settings and scheduling so sessions can compact after inactive turns without auto-continuing.
|
||||
- Added `onExternalEditor` callback to extension UI dialog options for handling external editor shortcut in select dialogs
|
||||
- Added external editor shortcut support in plan review selector, allowing users to open and edit the plan in their configured editor
|
||||
- Added `matchesAppExternalEditor` keybinding matcher to detect external editor shortcut (Ctrl+G or configured binding)
|
||||
|
||||
@@ -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",
|
||||
@@ -1701,6 +1735,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,51 @@ 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;
|
||||
|
||||
const threshold = idleSettings.idleThresholdTokens;
|
||||
if (threshold <= 0) return;
|
||||
if (this.#currentContextTokens() < threshold) return;
|
||||
|
||||
const timeoutMs = Math.max(60, Math.min(3600, idleSettings.idleTimeoutSeconds)) * 1000;
|
||||
this.#idleCompactionTimer = setTimeout(() => {
|
||||
this.#idleCompactionTimer = undefined;
|
||||
// Re-check conditions before firing. Pruning may have run between arming
|
||||
// the timer and now, dropping usage back below the idle threshold.
|
||||
if (this.ctx.session.isStreaming) return;
|
||||
if (this.ctx.session.isCompacting) return;
|
||||
if (this.ctx.editor.getText().trim()) return;
|
||||
if (this.#currentContextTokens() < threshold) return;
|
||||
void this.ctx.session.runIdleCompaction();
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
#currentContextTokens(): number {
|
||||
const lastAssistant = this.ctx.session.agent.state.messages
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((m): m is AssistantMessage => m.role === "assistant" && m.stopReason !== "aborted");
|
||||
return lastAssistant?.usage ? calculatePromptTokens(lastAssistant.usage) : 0;
|
||||
}
|
||||
|
||||
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,16 @@ 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 (compactionSettings.strategy === "off") return;
|
||||
if (reason !== "idle" && !compactionSettings.enabled) 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 +4728,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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user