Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Fixed

- Check the reserve budget before every normal model request, preserving pending inputs and complete tool batches through durable compaction without replaying the run.
- Bound unsuccessful proactive compaction to one operation per logical run, warn before continuing, and preserve independent cancellation, manual compaction, and failed-response recovery.
- Unified pre-prompt, post-answer, and displayed context estimates, using local estimates when provider usage no longer applies after compaction or context changes.
- Show compaction warnings alongside valid summaries or specific cancellation reasons, and write plain text warnings to stderr without changing successful answer output.
Expand Down
10 changes: 7 additions & 3 deletions packages/coding-agent/docs/compaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,19 @@ You can also trigger manually with `/compact [instructions]`, where optional ins

### Automatic Attempt Lifecycle

Pre-prompt and successful-final-answer checks each allow one eligible proactive operation, including the configured transient summarization retries. Afterward, Diffex estimates context again. A failure, independent cancellation, unavailable compaction, or result still above the reserve threshold warns and suppresses further proactive attempts for that logical run. Queued input and automatic continuations share that suppression. The next separately admitted idle prompt resets it before preflight; an idle custom-message run also resets it.
Pre-prompt, every normal model request after input and tool-result admission, and successful-final-answer checks each allow one eligible proactive operation, including the configured transient summarization retries. Afterward, Diffex estimates context again. A failure, independent cancellation, unavailable compaction, or result still above the reserve threshold warns and suppresses further proactive attempts for that logical run. Queued input and automatic continuations share that suppression. The next separately admitted idle prompt resets it before preflight; an idle custom-message run also resets it.

A valid summary remains committed even when it does not reduce context below the threshold. A successful reduction to or below the threshold permits another attempt after renewed growth. These are best-effort limits: local estimates and continuing after a warning do not guarantee a provider token limit.

Cancelling the prompt or session stops pending work and cancels compaction, including authentication and extension hooks. Cancelling compaction alone continues the pending work. Cancellable preflight retains its existing rollback behavior. Successful final answers, including answers exceeding the configured context window, are never retried merely because proactive compaction ran. Manual compaction and bounded failed/truncated-response recovery remain independently available.

Pending user and custom messages (including images), and the complete latest tool-call/result batch, survive proactive compaction exactly once. This also protects custom messages appended while idle before their first request. Standalone compaction and branch-summary requests bypass normal request scheduling.

An existing request-preparation hook runs once before the budget check. Its effective system prompt and tools remain in use; compaction callbacks can update them. If its messages cannot be matched to selected session history, compaction warns and continues with that effective context unchanged, suppressing further proactive attempts for the run. Unsafe extension cut points are rejected before saving.

### Current Context Accounting

Pre-prompt and post-answer checks and the context display share one estimate of the normal model-visible context. It includes system instructions, tool definitions, images, summaries, custom messages, and included bash output. Request-only extension transforms are not rerun to calculate it.
Pre-prompt, request-time, and post-answer checks and the context display share one estimate of the normal model-visible context. It includes system instructions, tool definitions, images, summaries, custom messages, and included bash output. Request-only extension transforms are not rerun to calculate it.

Provider usage is used only when an in-memory request baseline still matches the selected branch, model configuration, system prompt, tools, and preceding messages. Later messages are estimated locally; system instructions and tools already covered by usage are not added again. After compaction, retained older responses cannot supply usage, regardless of their timestamps. Loaded sessions and changed contexts without a matching baseline use local estimates until an applicable response arrives. Session token and cost totals still include all billed history.

Expand All @@ -60,7 +64,7 @@ Compaction warnings appear in the interactive transcript, including when a valid
2. **Extract messages**: Collect messages from the previous kept boundary (or session start) up to the cut point
3. **Generate summary**: Call LLM to summarize with structured format, passing the previous summary as iterative context when present
4. **Append entry**: Save `CompactionEntry` with summary and `firstKeptEntryId`
5. **Reload**: Session reloads, using summary + messages from `firstKeptEntryId` onwards
5. **Rebuild active context**: The current run continues with the saved summary + messages from `firstKeptEntryId` onwards

```
Before compaction:
Expand Down
149 changes: 128 additions & 21 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { basename, dirname } from "node:path";
import { isDeepStrictEqual } from "node:util";
import {
type Agent,
type AgentContext,
Expand Down Expand Up @@ -135,6 +136,7 @@
CURRENT_SESSION_VERSION,
getLatestCompactionEntry,
type SessionHeader,
sessionEntryToContextMessages,
} from "./session-manager.ts";
import type { SettingsManager } from "./settings-manager.ts";
import {
Expand Down Expand Up @@ -424,6 +426,12 @@
waitStarted: boolean;
}

interface ProactiveCompactionContext {
branchIds: string[];
messagesByEntryId: Map<string, AgentMessage>;
firstPendingEntryId: string | undefined;
}

type AutoCompactionOutcome =
| { status: "compacted"; result: CompactionResult; continueRun: boolean }
| { status: "failed" | "cancelled" | "unavailable"; errorMessage: string };
Expand Down Expand Up @@ -793,23 +801,79 @@

private readonly _contextEstimator = new SessionContextEstimator();
private _selectedRequestModel: string | undefined;
private _pendingRequestContextWindow: number | undefined;

private _installAgentRequestAccounting(): void {
const previousPrepareRequest = this.agent.prepareRequest;
this.agent.prepareRequest = async (context, signal) => {
const replacement = await previousPrepareRequest?.(context, signal);
signal?.throwIfAborted();
const effective = replacement ?? context;
this.agent.state.systemPrompt = effective.systemPrompt;
this.agent.state.tools = effective.tools ?? [];
this.agent.state.messages = effective.messages;
const selectedModel = this._selectedRequestModel
? (JSON.parse(this._selectedRequestModel) as Model<string>)
: this.model;
this._pendingRequestContextWindow = selectedModel?.contextWindow;
try {
await this._checkProactiveCompaction("threshold", signal);
} finally {
this._pendingRequestContextWindow = undefined;
}
signal?.throwIfAborted();
const prepared = {
...effective,
systemPrompt: this.agent.state.systemPrompt,
tools: this.agent.state.tools,
messages: this.agent.state.messages,
};
// Admission events and hooks can change public state after the loop selected
// its model. Such a response cannot prove the new configuration's usage.
const model = this._selectedRequestModel === JSON.stringify(this.model) ? this.model : undefined;
this._contextEstimator.capture(replacement ?? context, model);
return replacement;
this._contextEstimator.capture(prepared, model);
return prepared;
};
}

private _estimateCurrentContext(context: AgentContext = this.agent.state): number {
return this._contextEstimator.estimate(context, this.model, this.sessionManager.getBranch());
}

/** Match complete session semantics before rebuilding durable context. */
private _getProactiveCompactionContext(): ProactiveCompactionContext | undefined {
const messagesByEntryId = new Map<string, AgentMessage>();
let messageIndex = 0;
let firstPendingEntryId: string | undefined;
for (const entry of this.sessionManager.buildContextEntries()) {
const projected = sessionEntryToContextMessages(entry)[0];
if (!projected) continue;
const message = this.agent.state.messages[messageIndex++];
if (!message) return undefined;
if (entry.type === "message") {
if (!isDeepStrictEqual(message, projected)) return undefined;
} else {
// These timestamps are reconstructed from entry admission rather than the original message.
const { timestamp: _messageTimestamp, ...messageContent } = message;
const { timestamp: _entryTimestamp, ...entryContent } = projected;
if (!isDeepStrictEqual(messageContent, entryContent)) return undefined;
}
messagesByEntryId.set(entry.id, message);
// A completed response has observed earlier input; its pending tool batch remains indivisible.
if (message.role === "assistant") {
firstPendingEntryId = message.content.some((content) => content.type === "toolCall") ? entry.id : undefined;
} else if (message.role !== "compactionSummary" && message.role !== "branchSummary") {
firstPendingEntryId ??= entry.id;
}
}
if (messageIndex !== this.agent.state.messages.length) return undefined;
return {
branchIds: this.sessionManager.getBranch().map((entry) => entry.id),
messagesByEntryId,
firstPendingEntryId,
};
}

private _installAgentNextTurnRefresh(): void {
const previousPrepareNextTurnWithContext =
this.agent.prepareNextTurnWithContext ??
Expand Down Expand Up @@ -3066,7 +3130,7 @@

private async _abort(): Promise<void> {
this.abortRetry();
this._promptAbortController?.abort(new Error("Prompt cancelled"));

Check failure on line 3133 in packages/coding-agent/src/core/agent-session.ts

View workflow job for this annotation

GitHub Actions / coding-agent (1)

test/suite/regressions/2860-replaced-session-context.test.ts > regression #2860: replaced session callbacks > supports withSession for switchSession

Error: Prompt cancelled ❯ AgentSession._abort src/core/agent-session.ts:3133:38 ❯ src/core/agent-session.ts:3128:51 ❯ src/core/agent-session.ts:1981:27 ❯ src/core/telemetry.ts:348:68 ❯ Object.startNoopSpan [as startSpan] ../telemetry/src/noop.ts:5:26 ❯ startCodingAgentSpan src/core/telemetry.ts:348:26 ❯ AgentSession._traceOperation src/core/agent-session.ts:1967:21 ❯ AgentSession.abort src/core/agent-session.ts:3128:15 ❯ AgentSessionRuntime.teardownCurrent src/core/agent-session-runtime.ts:187:18 ❯ AgentSessionRuntime.switchSession src/core/agent-session-runtime.ts:251:14

Check failure on line 3133 in packages/coding-agent/src/core/agent-session.ts

View workflow job for this annotation

GitHub Actions / coding-agent (1)

test/suite/regressions/2860-replaced-session-context.test.ts > regression #2860: replaced session callbacks > supports withSession for fork

Error: Prompt cancelled ❯ AgentSession._abort src/core/agent-session.ts:3133:38 ❯ src/core/agent-session.ts:3128:51 ❯ src/core/agent-session.ts:1981:27 ❯ src/core/telemetry.ts:348:68 ❯ Object.startNoopSpan [as startSpan] ../telemetry/src/noop.ts:5:26 ❯ startCodingAgentSpan src/core/telemetry.ts:348:26 ❯ AgentSession._traceOperation src/core/agent-session.ts:1967:21 ❯ AgentSession.abort src/core/agent-session.ts:3128:15 ❯ AgentSessionRuntime.teardownCurrent src/core/agent-session-runtime.ts:187:18 ❯ AgentSessionRuntime.fork src/core/agent-session-runtime.ts:364:15

Check failure on line 3133 in packages/coding-agent/src/core/agent-session.ts

View workflow job for this annotation

GitHub Actions / coding-agent (1)

test/suite/regressions/2860-replaced-session-context.test.ts > regression #2860: replaced session callbacks > rebinds before withSession, targets the replacement session, and invalidates stale pi/ctx

Error: Prompt cancelled ❯ AgentSession._abort src/core/agent-session.ts:3133:38 ❯ src/core/agent-session.ts:3128:51 ❯ src/core/agent-session.ts:1981:27 ❯ src/core/telemetry.ts:348:68 ❯ Object.startNoopSpan [as startSpan] ../telemetry/src/noop.ts:5:26 ❯ startCodingAgentSpan src/core/telemetry.ts:348:26 ❯ AgentSession._traceOperation src/core/agent-session.ts:1967:21 ❯ AgentSession.abort src/core/agent-session.ts:3128:15 ❯ AgentSessionRuntime.teardownCurrent src/core/agent-session-runtime.ts:187:18 ❯ AgentSessionRuntime.newSession src/core/agent-session-runtime.ts:285:14
this._autoCompactionAbortController?.abort();
if (this._isAgentRunActive) this._agentRunAborted = true;
this._exitSubagentCoordination("cancellation");
Expand Down Expand Up @@ -3473,6 +3537,7 @@
willRetry: boolean;
signal?: AbortSignal;
deferCommit?: (transaction: CompactionAppendTransaction) => void;
preserveMessages?: Map<string, AgentMessage>;
}): Promise<CompactionResult> {
const transaction = appendCompactionTransaction(
this.sessionManager,
Expand All @@ -3483,8 +3548,10 @@
options.fromExtension,
options.usage,
);
const sessionContext = this.sessionManager.buildSessionContext();
this.agent.state.messages = sessionContext.messages;
this.agent.state.messages = this.sessionManager.buildContextEntries().flatMap((entry) => {
const preserved = options.preserveMessages?.get(entry.id);
return preserved ? [preserved] : sessionEntryToContextMessages(entry);
});
const estimatedTokensAfter = this._estimateCurrentContext();

if (this._extensionRunner) {
Expand Down Expand Up @@ -3583,10 +3650,7 @@
) {
const willRetry = assistantMessage.stopReason !== "stop";

if (!willRetry) {
if (this._proactiveCompactionSuppressed) return false;
return await this._runAutoCompaction("overflow", false, signal, deferCommit);
}
if (!willRetry) return await this._checkProactiveCompaction("overflow", signal, deferCommit);

if (this._overflowRecoveryAttempted) {
this._emit({
Expand All @@ -3611,17 +3675,24 @@
return await this._runAutoCompaction("overflow", willRetry, signal, deferCommit);
}

// Case 2: Threshold uses the same normal-context estimate as the footer and preflight.
const queuedContinuation = await this._checkProactiveCompaction("threshold", signal, deferCommit);
// Proactive compaction does not recover a provider failure.
return assistantMessage?.stopReason !== "error" && queuedContinuation;
}

private async _checkProactiveCompaction(
reason: "threshold" | "overflow",
signal?: AbortSignal,
deferCommit?: (transaction: CompactionAppendTransaction) => void,
): Promise<boolean> {
const contextWindow = this._pendingRequestContextWindow ?? this.model?.contextWindow ?? 0;
if (
!this._proactiveCompactionSuppressed &&
contextWindow > 0 &&
shouldCompact(contextTokens, contextWindow, settings)
) {
const queuedContinuation = await this._runAutoCompaction("threshold", false, signal, deferCommit);
// Threshold compaction does not recover a provider failure.
return assistantMessage?.stopReason !== "error" && queuedContinuation;
}
return false;
this._proactiveCompactionSuppressed ||
contextWindow <= 0 ||
!shouldCompact(this._estimateCurrentContext(), contextWindow, this.settingsManager.getCompactionSettings())
)
return false;
return this._runAutoCompaction(reason, false, signal, deferCommit);
}

/**
Expand All @@ -3641,7 +3712,7 @@
const result = outcome.status === "compacted" ? outcome.result : undefined;
let errorMessage = outcome.status === "compacted" ? undefined : outcome.errorMessage;
if (!willRetry) {
const contextWindow = this.model?.contextWindow ?? 0;
const contextWindow = this._pendingRequestContextWindow ?? this.model?.contextWindow ?? 0;
const tokens = this._estimateCurrentContext();
if (result) result.estimatedTokensAfter = tokens;
const stillAboveThreshold =
Expand Down Expand Up @@ -3694,8 +3765,14 @@
if (this._disposePromise) throw new Error("Compaction cancelled");

const pathEntries = this.sessionManager.getBranch();

const preparation = prepareCompaction(pathEntries, settings);
const proactiveContext = willRetry ? undefined : this._getProactiveCompactionContext();
if (!willRetry && !proactiveContext) {
return {
status: "unavailable",
errorMessage: "Auto-compaction unavailable: active messages do not match the selected session history.",
};
}
const preparation = prepareCompaction(pathEntries, settings, proactiveContext?.firstPendingEntryId);
if (!preparation) {
return {
status: "unavailable",
Expand Down Expand Up @@ -3729,6 +3806,7 @@
}
}

controller.signal.throwIfAborted();
let summary: string;
let firstKeptEntryId: string;
let tokensBefore: number;
Expand Down Expand Up @@ -3767,6 +3845,34 @@

controller.signal.throwIfAborted();

let preserveMessages: Map<string, AgentMessage> | undefined;
if (proactiveContext) {
const current = this._getProactiveCompactionContext();
const branch = this.sessionManager.getBranch();
if (
!current ||
proactiveContext.branchIds.some((id, index) => current.branchIds[index] !== id) ||
branch.slice(proactiveContext.branchIds.length).some((entry) => entry.type === "compaction")
) {
throw new Error("Selected session context changed during compaction.");
}
const keptIndex = pathEntries.findIndex((entry) => entry.id === firstKeptEntryId);
const protectedIndex =
current.firstPendingEntryId === undefined
? branch.length
: branch.findIndex((entry) => entry.id === current.firstPendingEntryId);
const firstKeptMessage = branch.slice(keptIndex).flatMap(sessionEntryToContextMessages)[0];
if (
keptIndex < 0 ||
pathEntries[keptIndex]?.type === "compaction" ||
keptIndex > protectedIndex ||
firstKeptMessage?.role === "toolResult" ||
!this.sessionManager.buildContextEntries().some((entry) => entry.id === firstKeptEntryId)
) {
throw new Error("Compaction cut would discard pending input or separate a tool result from its call.");
}
preserveMessages = current.messagesByEntryId;
}
const result = await this._recordSuccessfulCompaction({
reason,
summary,
Expand All @@ -3778,6 +3884,7 @@
willRetry,
signal: controller.signal,
deferCommit,
preserveMessages,
});

if (willRetry) {
Expand Down
22 changes: 21 additions & 1 deletion packages/coding-agent/src/core/compaction/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,7 @@ export interface CompactionPreparation {
export function prepareCompaction(
pathEntries: SessionEntry[],
settings: CompactionSettings,
firstPendingEntryId?: string,
): CompactionPreparation | undefined {
if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {
return undefined;
Expand All @@ -794,7 +795,26 @@ export function prepareCompaction(

const tokensBefore = estimateContextTokens(buildSessionContext(pathEntries).messages).tokens;

const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
let cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
const pendingIndex =
firstPendingEntryId === undefined ? -1 : pathEntries.findIndex((entry) => entry.id === firstPendingEntryId);
// A large trailing batch may have no valid cut after its results; retain its call frontier.
const pendingTokens =
pendingIndex < 0
? 0
: pathEntries
.slice(pendingIndex)
.flatMap(sessionEntryToContextMessages)
.reduce((sum, message) => sum + estimateTokens(message), 0);
if (
pendingIndex >= boundaryStart &&
(pendingIndex < cutPoint.firstKeptEntryIndex || pendingTokens >= settings.keepRecentTokens)
) {
const turnStartIndex = isTurnStartEntry(pathEntries[pendingIndex])
? -1
: findTurnStartIndex(pathEntries, pendingIndex, boundaryStart);
cutPoint = { firstKeptEntryIndex: pendingIndex, turnStartIndex, isSplitTurn: turnStartIndex !== -1 };
}

// Get UUID of first kept entry
const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex];
Expand Down
Loading
Loading