feat(coding-agent): added async compaction and in-place handoff

- Added compaction.asyncEnabled (Async Compaction, default on): when
  context enters the pre-threshold band [threshold - lead, threshold)
  with lead = clamp(threshold * 0.125, 8192, 32000), maintenance
  speculatively summarizes in the background off a branch snapshot
  (first configured LLM-backed method: remote, handoff, or soft) using
  a side session id isolated from the live turn. Crossing the threshold
  splices the armed result in instantly instead of blocking on a
  summarization round-trip. Armed results are invalidated by branch
  changes, reset boundaries, model switches that strand provider-native
  replay payloads, and context growth past keepRecentTokens (which
  re-speculates); extensions registering session_before_compact keep
  exact blocking semantics (speculation disabled).
- Reworked handoff to commit in place: /handoff and the auto handoff
  method now write the generated document as a regular compaction entry
  on the current session (summary = document + <files> tag, cut from
  prepareCompaction) instead of starting a new session. SessionHandoff
  shrank to a document generator; session_before_switch/session_switch
  no longer fire with reason "handoff"; mid-turn maintenance no longer
  suppresses the handoff preference; overflow recovery can apply an
  armed handoff result.
- Extracted the shared auto-compaction commit tail
  (#commitAutoCompactionResult / #commitCompactionEntry) used by the
  blocking production path, the armed speculative apply, manual
  compaction, and manual handoff.
- Status line pulses the auto-compact icon while a speculation runs and
  holds it in accent once a result is armed.
- Exported remotePreserveReusable from pi-agent-core/compaction for
  apply-time validation of speculative remote results.
This commit is contained in:
can1357
2026-08-20 03:46:34 +02:00
parent eced7ab08a
commit 2f54e760c1
26 changed files with 1218 additions and 1043 deletions
@@ -336,7 +336,6 @@ export interface HandoffResult {
export interface SessionHandoffOptions {
autoTriggered?: boolean;
signal?: AbortSignal;
onSwitchCancelled?: () => void;
}
/** Result from cycleModel(). */
@@ -1567,7 +1567,8 @@ export class AgentSession {
getContextUsage: options => this.getContextUsage(options),
shake: (mode, options) => this.shake(mode, options),
dropImages: () => this.dropImages(),
runHandoff: (customInstructions, options) => this.handoff(customInstructions, options),
generateHandoffDocument: (customInstructions, options) =>
this.#handoff.generateDocument(customInstructions, options),
removeAssistantMessageFromActiveContext: message =>
this.#recovery.removeAssistantMessageFromActiveContext(message),
dropPersistedAssistantTurn: message => this.#recovery.dropPersistedAssistantTurn(message),
@@ -1585,15 +1586,12 @@ export class AgentSession {
sessionManager: this.sessionManager,
settings: this.settings,
modelRegistry: this.#modelRegistry,
extensionRunner: this.#extensionRunner,
sideStreamFn: this.#sideStreamFn,
obfuscator: this.#obfuscator,
model: () => this.model,
thinkingLevel: () => this.thinkingLevel,
sessionId: () => this.sessionId,
sessionFile: () => this.sessionFile,
baseSystemPrompt: () => this.#tools.baseSystemPrompt,
assertVibeSessionTransitionAllowed: action => this.#assertVibeSessionTransitionAllowed(action),
setSkipPostTurnMaintenance: timestamp => {
this.#maintenance.skipPostTurnMaintenanceAssistantTimestamp = timestamp;
},
@@ -1602,32 +1600,6 @@ export class AgentSession {
convertMessagesToLlm: (messages, signal) => this.convertMessagesToLlm(messages, signal),
prepareSimpleStreamOptions: (options, provider) => this.prepareSimpleStreamOptions(options, provider),
effectiveServiceTier: model => this.#models.effectiveServiceTier(model),
flushPendingBash: () => this.#bash.flushPending(),
beginBashSessionTransition: () => this.#bash.beginSessionTransition(),
markBashSessionTransition: transition => this.#bash.markSessionTransition(transition),
finishBashSessionTransition: (transition, success) => this.#bash.finishSessionTransition(transition, success),
cancelOwnAsyncJobs: () => this.#cancelOwnAsyncJobs(),
clearCheckpointRuntimeState: () => this.#clearCheckpointRuntimeState(),
clearSessionScopedToolState: () => this.#clearSessionScopedToolState(),
clearFreshProviderSessionId: () => {
this.#freshProviderSessionId = undefined;
},
syncAgentSessionId: () => this.#syncAgentSessionId(),
rekeyMemoryForCurrentSessionId: () => {
this.#memory.rekeyForCurrentSessionId();
},
resetMemoryContextForNewTranscript: () => this.#memory.resetContextForNewTranscript(),
clearPendingNextTurnMessages: () => {
this.#pendingNextTurnMessages = [];
this.#scheduledHiddenNextTurnGeneration = undefined;
},
resetTodoCycle: () => this.#todo.resetCycle(),
buildDisplaySessionContext: () => this.buildDisplaySessionContext(),
resetAdvisorSessionState: () => this.#advisors.resetSessionState(),
drainAndDetachAdvisorRecorders: () => this.#advisors.drainAndDetachRecorders(),
reattachAdvisorRecorderFeeds: () => this.#advisors.reattachRecorderFeeds(),
clearAdvisorCost: () => this.#advisors.clearCost(),
syncTodoPhasesFromBranch: () => this.#todo.syncFromBranch(),
};
this.#handoff = new SessionHandoff(handoffHost);
@@ -4089,6 +4061,7 @@ export class AgentSession {
await cleanupEmptyMoveSession(this.sessionManager, this.#movedFromEmptySessionFile);
this.#movedFromEmptySessionFile = undefined;
this.#closeAllProviderSessions("dispose");
this.#maintenance.cancelSpeculation();
this.setHindsightSessionState(undefined);
hindsightState?.dispose();
this.#disconnectFromAgent();
@@ -4711,6 +4684,10 @@ export class AgentSession {
return this.#maintenance.isCompacting;
}
/** Background speculative-compaction state, for UI indicators. */
get compactionSpeculation(): "idle" | "running" | "armed" {
return this.#maintenance.speculationState;
}
/** Strip image content from the current branch and persist the rewrite. */
dropImages(): Promise<{ removed: number }> {
return this.#maintenance.dropImages();
@@ -7121,14 +7098,16 @@ export class AgentSession {
}
/**
* Generate a handoff document with a oneshot LLM call, then start a new session with it.
* Generate a handoff document with a oneshot LLM call and commit it as a
* compaction entry on the current session (the document becomes the summary;
* recent history is kept).
*
* @param customInstructions Optional focus for the handoff document
* @param options Handoff execution options
* @returns The handoff document text, or undefined if cancelled/failed
*/
handoff(customInstructions?: string, options?: SessionHandoffOptions): Promise<HandoffResult | undefined> {
return this.#handoff.handoff(customInstructions, options);
return this.#maintenance.handoff(customInstructions, options);
}
#isTerminalYieldToolResult(event: { toolName: string; isError?: boolean; result?: { details?: unknown } }): boolean {
@@ -9316,9 +9295,9 @@ export class AgentSession {
}
/**
* Get text content of the most recent visible handoff message.
* Fresh handoff sessions store the handoff context as a custom message, not
* an assistant message, so callers that copy the "last" message can use this
* as a fallback before the new session has an assistant response.
* Sessions created by older versions injected the handoff document as a
* custom message at the top of a fresh session; callers that copy the
* "last" message use this as a fallback while no assistant response exists.
*/
getLastVisibleHandoffText(): string | undefined {
for (let i = this.messages.length - 1; i >= 0; i--) {
@@ -15,7 +15,7 @@ export const COMPACTION_METHOD_CHOICES = [
{
value: "handoff",
label: "Handoff",
description: "Generate a handoff document and continue in a new session",
description: "Generate a handoff document and continue from it as the compaction summary",
},
{
value: "soft",
@@ -1,4 +1,4 @@
/** Handoff generation and session transition orchestration. */
/** Handoff document generation. Committing the document as a compaction entry is owned by SessionMaintenance. */
import * as path from "node:path";
import {
@@ -13,19 +13,11 @@ import type { Message, Model, ServiceTier, SimpleStreamOptions } from "@oh-my-pi
import { logger, Snowflake } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../config/model-registry";
import type { Settings } from "../config/settings";
import type { ExtensionRunner, SessionBeforeSwitchResult } from "../extensibility/extensions";
import { copyLocalArtifacts, resolveLocalUrlToPath } from "../internal-urls";
import { obfuscateProviderContext } from "../secrets/message-transform";
import type { SecretObfuscator } from "../secrets/obfuscator";
import type { HandoffResult, SessionHandoffOptions } from "./agent-session-types";
import type { BashSessionTransition } from "./bash-runner";
import type { SessionContext } from "./session-context";
import type { SessionManager } from "./session-manager";
function createHandoffContext(document: string): string {
return `<handoff-context>\n${document}\n</handoff-context>\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`;
}
function createHandoffFileName(date = new Date()): string {
const fileTimestamp = date.toISOString().replace(/[:.]/g, "-");
return `handoff-${fileTimestamp}.md`;
@@ -48,43 +40,21 @@ export interface SessionHandoffHost {
sessionManager: SessionManager;
settings: Settings;
modelRegistry: ModelRegistry;
extensionRunner: ExtensionRunner | undefined;
sideStreamFn: StreamFn;
obfuscator: SecretObfuscator | undefined;
model(): Model | undefined;
thinkingLevel(): ThinkingLevel | undefined;
sessionId(): string;
sessionFile(): string | undefined;
baseSystemPrompt(): string[];
assertVibeSessionTransitionAllowed(action: string): void;
setSkipPostTurnMaintenance(timestamp: number | undefined): void;
obfuscateTextForProvider(text: string | undefined): string | undefined;
deobfuscateFromProvider(text: string): string;
convertMessagesToLlm(messages: AgentMessage[], signal?: AbortSignal): Promise<Message[]>;
prepareSimpleStreamOptions(options: SimpleStreamOptions, provider?: string): SimpleStreamOptions;
effectiveServiceTier(model: Model | undefined): ServiceTier | undefined;
flushPendingBash(): Promise<void>;
beginBashSessionTransition(): BashSessionTransition;
markBashSessionTransition(transition: BashSessionTransition): void;
finishBashSessionTransition(transition: BashSessionTransition, success: boolean): void;
cancelOwnAsyncJobs(): void;
clearCheckpointRuntimeState(): void;
clearSessionScopedToolState(): void;
clearFreshProviderSessionId(): void;
syncAgentSessionId(): void;
rekeyMemoryForCurrentSessionId(): void;
resetMemoryContextForNewTranscript(): Promise<void>;
clearPendingNextTurnMessages(): void;
resetTodoCycle(): void;
buildDisplaySessionContext(): SessionContext;
resetAdvisorSessionState(): void;
drainAndDetachAdvisorRecorders(): Promise<void>;
reattachAdvisorRecorderFeeds(): void;
clearAdvisorCost(): void;
syncTodoPhasesFromBranch(): void;
}
/** Generates handoff documents and owns the handoff session transition. */
/** Generates handoff documents with a cache-friendly oneshot LLM call. */
export class SessionHandoff {
#handoffAbortController: AbortController | undefined;
readonly #host: SessionHandoffHost;
@@ -107,21 +77,22 @@ export class SessionHandoff {
}
/**
* Generate a handoff document with a oneshot LLM call, then start a new session with it.
* Generate a handoff document with a oneshot LLM call.
*
* The request is built through the same pipeline a live turn uses so the
* oneshot reads the provider prompt cache the main turn populated. The
* caller (SessionMaintenance) commits the returned document as a compaction
* entry; this method rewrites no history.
*
* @param customInstructions Optional focus for the handoff document
* @param options Handoff execution options
* @returns The handoff document text, or undefined if cancelled/failed
* @returns The handoff document text, or undefined when an auto-triggered
* generation produced no content (manual generation throws instead)
*/
async handoff(customInstructions?: string, options?: SessionHandoffOptions): Promise<HandoffResult | undefined> {
this.#host.assertVibeSessionTransitionAllowed("handoff to a new session");
const entries = this.#host.sessionManager.getBranch();
const messageCount = entries.filter(e => e.type === "message").length;
if (messageCount < 2) {
throw new Error("Nothing to hand off (no messages yet)");
}
async generateDocument(
customInstructions?: string,
options?: SessionHandoffOptions,
): Promise<HandoffResult | undefined> {
this.#host.setSkipPostTurnMaintenance(undefined);
this.#handoffAbortController = new AbortController();
@@ -140,8 +111,6 @@ export class SessionHandoff {
}
}
let advisorRecordersDetached = false;
let sessionTransitioned = false;
try {
throwIfHandoffAborted(handoffSignal);
@@ -180,8 +149,8 @@ export class SessionHandoff {
];
const handoffLlmMessages = await this.#host.convertMessagesToLlm(handoffSnapshot, handoffSignal);
// Base system prompt, not a per-turn `before_agent_start` hook override —
// the handoff seeds a fresh session and must not carry prompt-specific
// hook state. Matches the prompt the old handoff path sent.
// the document seeds the post-compaction context and must not carry
// prompt-specific hook state.
const handoffContext = await this.#host.agent.buildSideRequestContext(
handoffLlmMessages,
this.#host.baseSystemPrompt(),
@@ -229,94 +198,14 @@ export class SessionHandoff {
autoTriggered: options?.autoTriggered ?? false,
});
// Auto-handoff is best-effort: returning undefined lets maintenance fall
// back to context-full compaction. A user-initiated handoff must surface
// the failure instead of a silent, misleading "cancelled".
// back to the next compaction method. A user-initiated handoff must
// surface the failure instead of a silent, misleading "cancelled".
if (options?.autoTriggered) {
return undefined;
}
throw new Error("Handoff generation produced no content");
}
// Start a new session
const previousSessionFile = this.#host.sessionFile();
if (this.#host.extensionRunner?.hasHandlers("session_before_switch")) {
const result = (await this.#host.extensionRunner.emit({
type: "session_before_switch",
reason: "handoff",
})) as SessionBeforeSwitchResult | undefined;
if (result?.cancel) {
options?.onSwitchCancelled?.();
return undefined;
}
}
await this.#host.flushPendingBash();
await this.#host.sessionManager.flush();
advisorRecordersDetached = true;
// Stop and settle in-flight advisors while the old-session feeds can still
// observe message_end, then mute before opening the replacement session.
await this.#host.drainAndDetachAdvisorRecorders();
// Snapshot the outgoing session's local:// root BEFORE newSession() mints a
// fresh session id (and therefore a fresh, empty local root). The handoff
// document routinely references plans/scratch files under local://, so those
// artifacts must follow the session switch or every reference dangles.
const localProtocolOptions = {
getArtifactsDir: () => this.#host.sessionManager.getArtifactsDir(),
getSessionId: () => this.#host.sessionManager.getSessionId(),
};
const previousLocalRoot = resolveLocalUrlToPath("local://", localProtocolOptions);
const bashTransition = this.#host.beginBashSessionTransition();
this.#host.cancelOwnAsyncJobs();
try {
await this.#host.sessionManager.newSession(
previousSessionFile ? { parentSession: previousSessionFile } : undefined,
);
this.#host.markBashSessionTransition(bashTransition);
// The handoff opens a fresh conversation, so the spend of the one it
// summarizes stays with it. Clearing here, at the commit point, keeps the
// status line honest even if a later step throws.
this.#host.clearAdvisorCost();
sessionTransitioned = true;
} finally {
this.#host.finishBashSessionTransition(bashTransition, sessionTransitioned);
}
this.#host.clearSessionScopedToolState();
this.#host.clearCheckpointRuntimeState();
// agent.reset() clears the core steering/follow-up queues. Preserve any queued
// steers/follow-ups (RPC/SDK steer()/followUp() issued during the handoff, or a
// pre-loader TUI steer) so they survive into the post-handoff session instead of
// being silently dropped. Capture is synchronous immediately before reset and
// restore is synchronous immediately after — no await gap — so a steer arriving
// later (during ensureOnDisk/Bun.write below) appends to the restored queue
// rather than being clobbered.
const preservedSteering = this.#host.agent.peekSteeringQueue().slice();
const preservedFollowUp = this.#host.agent.peekFollowUpQueue().slice();
this.#host.agent.reset();
this.#host.agent.replaceQueues(preservedSteering, preservedFollowUp);
this.#host.clearFreshProviderSessionId();
this.#host.syncAgentSessionId();
this.#host.rekeyMemoryForCurrentSessionId();
await this.#host.resetMemoryContextForNewTranscript();
this.#host.clearPendingNextTurnMessages();
this.#host.resetTodoCycle();
// Carry local:// artifacts into the replacement session (best-effort: the
// switch is already committed, so a copy failure must not fail the handoff).
try {
const newLocalRoot = resolveLocalUrlToPath("local://", localProtocolOptions);
await copyLocalArtifacts(previousLocalRoot, newLocalRoot);
} catch (error) {
logger.warn("Failed to copy local artifacts into handoff session", {
error: error instanceof Error ? error.message : String(error),
});
}
// Inject the handoff document as a custom message
const handoffContent = createHandoffContext(handoffText);
this.#host.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true, undefined, "agent");
await this.#host.sessionManager.ensureOnDisk();
let savedPath: string | undefined;
if (options?.autoTriggered && this.#host.settings.get("compaction.handoffSaveToDisk")) {
const artifactsDir = this.#host.sessionManager.getArtifactsDir();
@@ -336,20 +225,6 @@ export class SessionHandoff {
}
}
// Rebuild agent messages from session
const sessionContext = this.#host.buildDisplaySessionContext();
this.#host.agent.replaceMessages(sessionContext.messages);
this.#host.resetAdvisorSessionState();
advisorRecordersDetached = false;
this.#host.syncTodoPhasesFromBranch();
if (this.#host.extensionRunner) {
await this.#host.extensionRunner.emit({
type: "session_switch",
reason: "handoff",
previousSessionFile,
});
}
return { document: handoffText, savedPath };
} catch (error) {
// Only a genuine cancellation (user Esc or an unreasoned source-signal
@@ -358,10 +233,6 @@ export class SessionHandoff {
throwIfHandoffAborted(handoffSignal);
throw error;
} finally {
if (advisorRecordersDetached) {
if (sessionTransitioned) this.#host.resetAdvisorSessionState();
else this.#host.reattachAdvisorRecorderFeeds();
}
sourceSignal?.removeEventListener("abort", onSourceAbort);
this.#handoffAbortController = undefined;
}
File diff suppressed because it is too large Load Diff