Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
* Drives the real senpi CLI from source over RPC with a local fake Anthropic
* server and proves the post-#728 admission policy end to end:
* - compactions past the former per-turn soft cap (3) are admitted and
* accepted up to the absolute session cap (10),
* - the 11th compaction is rejected with the absolute-session-cap message
* accepted up to the absolute runtime cap (10),
* - the 11th compaction is rejected with the absolute-runtime-cap message
* (not the misleading per-turn wording), and
* - the rejection is non-fatal: the session keeps serving prompts.
*
Expand All @@ -29,7 +29,8 @@ import { checkRealAuthUnchanged, hermeticEnv } from "../lib/mock-loop-support.mj
const SUMMARY_MARKER = "context summarization assistant";
const ABSOLUTE_CAP = 10;
const FORMER_SOFT_CAP = 3;
const REJECTION_NEEDLE = "absolute compaction cap reached for this session";
const REJECTION_NEEDLE =
"Compaction rejected: the absolute compaction cap was reached for this runtime. Restart the CLI to resume this session, or start a new session.";
const CONTEXT_WINDOW = 128_000;

function flag(name) {
Expand Down Expand Up @@ -249,7 +250,7 @@ async function main() {
observed.rejection = { success: rejected.success, error: rejected.error ?? null };
checks.ok("compaction past the absolute cap is rejected", rejected.success === false);
checks.ok(
"the rejection names the absolute session cap, not the per-turn cap",
"the rejection names the absolute runtime cap, not the per-turn cap",
String(rejected.error ?? "").includes(REJECTION_NEEDLE),
`error=${String(rejected.error ?? "").slice(0, 160)}`,
);
Expand Down
5 changes: 5 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

### Fixed

- Required compaction now reports when the runtime's absolute compaction cap is exhausted and explains how to
recover by restarting the CLI before resuming the session or by starting a new session. Previously prompt
admission replaced that actionable rejection with the generic `compaction did not complete` error, while the
builtin extension described the in-memory cap as session-scoped.

### New Features

### Breaking Changes
Expand Down
139 changes: 115 additions & 24 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,10 @@ type PendingCompactionAdmission = {
outcome?: "completed" | "failed" | "aborted";
};

type RequiredCompactionRejectionCapture = {
rejectionCause?: CompactionRejectionCause;
};

function isCompactionOwnedPreCompactDiagnostic(message: AgentMessage, requestId: string): boolean {
if (message.role !== "custom" || message.customType !== "senpi.hook") return false;
const details = message.details;
Expand All @@ -388,8 +392,8 @@ function describeCompactionRejection(cause: CompactionRejectionCause): string {
return "Compaction rejected: the compaction circuit breaker is open after repeated failures. Wait for the cooldown and retry.";
case "per-turn-cap":
// Historical cause identifier kept for extension-API stability; since the
// per-turn soft cap was removed it fires only at the absolute session cap.
return "Compaction rejected: absolute compaction cap reached for this session.";
// per-turn soft cap was removed it fires only at the absolute runtime cap.
return "Compaction rejected: the absolute compaction cap was reached for this runtime. Restart the CLI to resume this session, or start a new session.";
case "stale-revision":
return "Compaction rejected: the session changed while the summary was being prepared. Retry compaction against the latest context.";
}
Expand Down Expand Up @@ -446,8 +450,12 @@ function isCompactionExecutionAborted(error: unknown): boolean {
}

class RequiredCompactionError extends Error {
constructor() {
super("Context remains above the compaction threshold because compaction did not complete");
constructor(rejectionCause?: CompactionRejectionCause) {
super(
rejectionCause === undefined
? "Context remains above the compaction threshold because compaction did not complete"
: `Context remains above the compaction threshold because compaction did not complete. ${describeCompactionRejection(rejectionCause)}`,
);
this.name = "RequiredCompactionError";
}
}
Expand Down Expand Up @@ -633,7 +641,13 @@ export class AgentSession {
// A retry continuation immediately follows an accepted compaction. Its first
// response must not retrigger threshold compaction from stale provider usage.
private _skipNextPostRetryCompactionCheck = false;
private _blockedPostCompactionAssistant: { assistant: AssistantMessage; revision: number } | undefined;
private _blockedPostCompactionAssistant:
| {
assistant: AssistantMessage;
revision: number;
rejectionCause?: CompactionRejectionCause;
}
| undefined;
private _skipNextPostCompactionAssistantCheck = false;
private _scheduledContinuationRecompacted = false;
private readonly _assistantsPendingAtCompaction = new WeakSet<AssistantMessage>();
Expand Down Expand Up @@ -1779,13 +1793,21 @@ export class AgentSession {
this.settingsManager.getRetrySettings().enabled &&
(retryableError || hardErrorFallbackEligible);
let compactedBeforeRetry = false;
const retryCompactionRejectionCapture: RequiredCompactionRejectionCapture = {};
if (
retryCanAdmitProvider &&
requiredAutoCompaction &&
!(requiredAutoCompaction === "threshold" && this._hasPendingPostCompactionUsageExemption(msg))
) {
this._retireFailedRetryAssistant(msg);
compactedBeforeRetry = await this._runPrePromptCompaction(msg, true, "threshold", true);
compactedBeforeRetry = await this._runPrePromptCompaction(
msg,
true,
"threshold",
true,
false,
retryCompactionRejectionCapture,
);
retryContinuationBlocked = !compactedBeforeRetry && !this._isCompactionDelegated();
}

Expand Down Expand Up @@ -1819,7 +1841,13 @@ export class AgentSession {
this._scheduleContinuationAfterCurrentEvent();
launchedContinuation = true;
} else {
launchedContinuation = await this._checkCompaction(msg, true, undefined, retryAfterRequiredCompaction);
launchedContinuation = await this._checkCompaction(
msg,
true,
undefined,
retryAfterRequiredCompaction,
retryCompactionRejectionCapture,
);
if (launchedContinuation && this.agent.hasQueuedMessages()) {
// Same supersession on the post-check path: an accepted recovery
// compaction owns the continuation now.
Expand All @@ -1838,7 +1866,9 @@ export class AgentSession {
this.agent.hasQueuedMessages() &&
this._getRequiredAutoCompactionReason(msg) !== undefined
) {
this._requiredCompactionAdmissionError = new RequiredCompactionError();
this._requiredCompactionAdmissionError = new RequiredCompactionError(
retryCompactionRejectionCapture.rejectionCause,
);
}
}
}
Expand Down Expand Up @@ -4597,16 +4627,23 @@ export class AgentSession {
blockedAdmission.assistant === assistantMessage &&
blockedAdmission.revision === this._messageRevision
) {
throw new RequiredCompactionError();
throw new RequiredCompactionError(blockedAdmission.rejectionCause);
}

const rejectionCapture: RequiredCompactionRejectionCapture = {};
const settings = this.settingsManager.getCompactionSettings();
const model = this.model;
const contextTokens = estimateContextTokens(
filterContextExcludedMessages(this.sessionManager.buildSessionContext().messages),
).tokens;
const compacted = assistantMessage
? await this._checkCompaction(assistantMessage, skipAbortedCheck, inlineReason, retryAfterCompaction)
? await this._checkCompaction(
assistantMessage,
skipAbortedCheck,
inlineReason,
retryAfterCompaction,
rejectionCapture,
)
: false;
if (compacted || (assistantMessage && this._postCompactionUsageExemptAssistants.has(assistantMessage))) {
return compacted;
Expand All @@ -4629,11 +4666,18 @@ export class AgentSession {
return false;
}
if (assistantBeforeLatestCompaction && assistantMessage) {
const compacted = await this._runPrePromptCompaction(assistantMessage, skipAbortedCheck, inlineReason);
const compacted = await this._runPrePromptCompaction(
assistantMessage,
skipAbortedCheck,
inlineReason,
false,
false,
rejectionCapture,
);
if (compacted) return true;
}
if (this._isCompactionOnCooldown() || this._isCompactionDelegated()) return false;
throw new RequiredCompactionError();
throw new RequiredCompactionError(rejectionCapture.rejectionCause);
}

/**
Expand Down Expand Up @@ -4673,11 +4717,19 @@ export class AgentSession {
throw new RequiredCompactionError();
}

const compacted = await this._runPrePromptCompaction(lastAssistantMessage, false, "pre_prompt");
const rejectionCapture: RequiredCompactionRejectionCapture = {};
const compacted = await this._runPrePromptCompaction(
lastAssistantMessage,
false,
"pre_prompt",
false,
false,
rejectionCapture,
);
if (!compacted && this._isCompactionDelegated()) return;
if (!compacted && !isOversized() && this._isCompactionOnCooldown()) return;
if (!compacted || isOversized()) {
throw new RequiredCompactionError();
throw new RequiredCompactionError(rejectionCapture.rejectionCause);
}
}

Expand All @@ -4686,6 +4738,7 @@ export class AgentSession {
skipAbortedCheck = true,
inlineReason?: "pre_prompt" | "threshold",
retryAfterCompaction = false,
rejectionCapture: RequiredCompactionRejectionCapture = {},
): Promise<boolean> {
const settings = this.settingsManager.getCompactionSettings();
if (!settings.enabled) return false;
Expand Down Expand Up @@ -4738,7 +4791,7 @@ export class AgentSession {
const willRetry = retryAfterCompaction || assistantMessage.stopReason !== "stop";

if (!willRetry) {
const compacted = await this._runAutoCompaction("overflow", false);
const compacted = await this._runAutoCompaction("overflow", false, rejectionCapture);
if (
!compacted &&
this._compactionLifecycle.state.status === "failed" &&
Expand All @@ -4748,6 +4801,7 @@ export class AgentSession {
this._blockedPostCompactionAssistant = {
assistant: assistantMessage,
revision: this._messageRevision,
rejectionCause: rejectionCapture.rejectionCause,
};
}
return compacted;
Expand Down Expand Up @@ -4781,14 +4835,21 @@ export class AgentSession {
this._incrementMessageRevision();
}
const compacted = inlineReason
? await this._runPrePromptCompaction(assistantMessage, skipAbortedCheck, "overflow", willRetry)
: await this._runAutoCompaction("overflow", willRetry);
? await this._runPrePromptCompaction(
assistantMessage,
skipAbortedCheck,
"overflow",
willRetry,
false,
rejectionCapture,
)
: await this._runAutoCompaction("overflow", willRetry, rejectionCapture);
if (!compacted && removedOverflowAssistant) {
this._restoreAgentMessagesFromSession();
this._incrementMessageRevision();
}
if (!compacted && inlineReason && !this._isCompactionDelegated()) {
throw new RequiredCompactionError();
throw new RequiredCompactionError(rejectionCapture.rejectionCause);
}
return compacted;
}
Expand Down Expand Up @@ -4838,9 +4899,11 @@ export class AgentSession {
skipAbortedCheck,
inlineReason,
retryAfterCompaction,
false,
rejectionCapture,
);
} else {
const compacted = await this._runAutoCompaction("threshold", retryAfterCompaction);
const compacted = await this._runAutoCompaction("threshold", retryAfterCompaction, rejectionCapture);
if (
!compacted &&
this._compactionLifecycle.state.status === "failed" &&
Expand All @@ -4850,6 +4913,7 @@ export class AgentSession {
this._blockedPostCompactionAssistant = {
assistant: assistantMessage,
revision: this._messageRevision,
rejectionCause: rejectionCapture.rejectionCause,
};
}
return compacted;
Expand Down Expand Up @@ -4881,6 +4945,7 @@ export class AgentSession {
reason: "pre_prompt" | "overflow" | "threshold" = "pre_prompt",
willRetry = false,
allowSummaryOnly = false,
rejectionCapture?: RequiredCompactionRejectionCapture,
): Promise<boolean> {
const controller = new AbortController();
const requestId = randomUUID();
Expand All @@ -4905,6 +4970,9 @@ export class AgentSession {
) {
this._overflowRecoveryAttempted = false;
}
if (!execution.accepted && execution.rejectionCause === "per-turn-cap") {
if (rejectionCapture) rejectionCapture.rejectionCause = execution.rejectionCause;
}
return execution.accepted;
} catch (error) {
if (!compactionExecutionOwnsTerminalTransition(error)) {
Expand Down Expand Up @@ -4946,10 +5014,18 @@ export class AgentSession {
: estimate.tokens;
if (!shouldCompact(contextTokens, model.contextWindow, settings)) return;

const compacted = await this._runPrePromptCompaction(this._findLastAssistantMessage(), true, "pre_prompt");
const rejectionCapture: RequiredCompactionRejectionCapture = {};
const compacted = await this._runPrePromptCompaction(
this._findLastAssistantMessage(),
true,
"pre_prompt",
false,
false,
rejectionCapture,
);
if (!compacted) {
if (this._isCompactionOnCooldown() || this._isCompactionDelegated()) return;
throw new RequiredCompactionError();
throw new RequiredCompactionError(rejectionCapture.rejectionCause);
}
this._scheduledContinuationRecompacted = true;
}
Expand Down Expand Up @@ -5046,7 +5122,11 @@ export class AgentSession {
/**
* Internal: Run auto-compaction with events.
*/
private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise<boolean> {
private async _runAutoCompaction(
reason: "overflow" | "threshold",
willRetry: boolean,
rejectionCapture?: RequiredCompactionRejectionCapture,
): Promise<boolean> {
const finishCompactionWork = this._sessionWorkBarrier.begin();
const agentMessagesAtStart = this.agent.state.messages.slice();
const autoCompactionController = new AbortController();
Expand Down Expand Up @@ -5112,6 +5192,9 @@ export class AgentSession {
});
if (!execution.accepted) {
if (reason === "overflow") this._overflowRecoveryAttempted = false;
if (rejectionCapture && execution.rejectionCause === "per-turn-cap") {
rejectionCapture.rejectionCause = execution.rejectionCause;
}
return false;
}
if (this._autoCompactionAbortController === autoCompactionController) {
Expand Down Expand Up @@ -6302,7 +6385,15 @@ export class AgentSession {
model &&
shouldCompact(contextTokens, model.contextWindow, compactionSettings)
) {
const preRetryCompaction = await this._runPrePromptCompaction(message, true, "threshold", true, true);
const rejectionCapture: RequiredCompactionRejectionCapture = {};
const preRetryCompaction = await this._runPrePromptCompaction(
message,
true,
"threshold",
true,
true,
rejectionCapture,
);
if (!preRetryCompaction && !this._isCompactionOnCooldown() && !this._isCompactionDelegated()) {
const attempt = this._retryAttempt;
this._retryAttempt = 0;
Expand All @@ -6311,7 +6402,7 @@ export class AgentSession {
type: "auto_retry_end",
success: false,
attempt,
finalError: new RequiredCompactionError().message,
finalError: new RequiredCompactionError(rejectionCapture.rejectionCause).message,
});
this._resolveRetry();
return "blocked";
Expand Down
Loading