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:
@@ -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
Reference in New Issue
Block a user