Skip to content
2 changes: 2 additions & 0 deletions packages/agent/src/harness/compaction/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ export interface CompactionSettings {
reserveTokens: number;
/** Approximate recent-context tokens to keep after compaction. */
keepRecentTokens: number;
/** Explicit active context ceiling override. */
maxContextTokens?: number;
Comment thread
codeg-dev marked this conversation as resolved.
}

/** Default compaction settings used by the harness. */
Expand Down
1 change: 0 additions & 1 deletion packages/ai/src/api/google-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,6 @@ export function mapStopReason(reason: FinishReason): StopReason {
case FinishReason.LANGUAGE:
case FinishReason.MALFORMED_FUNCTION_CALL:
case FinishReason.UNEXPECTED_TOOL_CALL:
case FinishReason.TOO_MANY_TOOL_CALLS:
case FinishReason.NO_IMAGE:
return "error";
default: {
Expand Down
8 changes: 7 additions & 1 deletion packages/coding-agent/src/core/compaction/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ export interface CompactionSettings {
enabled: boolean;
reserveTokens: number;
keepRecentTokens: number;
maxContextTokens?: number;
speculativeEnabled?: boolean;
speculativeFraction?: number;
speculativeCooldownMs?: number;
Expand Down Expand Up @@ -339,7 +340,12 @@ export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEst
*/
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
if (!settings.enabled) return false;
return contextTokens > contextWindow - settings.reserveTokens;
const isLarge = contextWindow >= 500_000;
const defaultCeiling = isLarge ? 384_000 : Math.max(0, contextWindow - settings.reserveTokens);
const configuredCeiling =
settings.maxContextTokens && settings.maxContextTokens > 0 ? settings.maxContextTokens : defaultCeiling;
const maxActive = Math.min(Math.max(0, contextWindow - settings.reserveTokens), configuredCeiling);
Comment thread
codeg-dev marked this conversation as resolved.
return contextTokens > maxActive;
}

// ============================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,19 +144,59 @@ export const BUILTIN_CONTEXT_REDUCTION_OPTIONS: ReduceContextOptions = {

export const BUILTIN_CONTEXT_REDUCTION_GATE_RATIO = 0.5;

export interface ContextPressure {
activeTokens: number | null;
requestBodyBytes: number | null;
nonReclaimableTokens?: number | null;
generation: number;
}

export interface ContextReductionLatch {
isLatched: () => boolean;
engage: () => void;
release: () => void;
getGeneration: () => number;
bumpGeneration: () => void;
}

export function createContextReductionLatch(): ContextReductionLatch {
let latched = false;
let generation = 0;
return {
isLatched: () => latched,
engage: () => {
latched = true;
},
release: () => {
latched = false;
},
getGeneration: () => generation,
bumpGeneration: () => {
generation += 1;
latched = false;
},
};
}

export interface ShouldApplyContextReductionInput {
usageTokens: number | null;
contextWindow: number;
gateRatio?: number;
isProviderNativeCompactionPath?: boolean;
latch?: ContextReductionLatch;
}

export function shouldApplyContextReduction(input: ShouldApplyContextReductionInput): boolean {
const gate = input.gateRatio ?? BUILTIN_CONTEXT_REDUCTION_GATE_RATIO;
if (input.isProviderNativeCompactionPath === true) return false;
if (input.latch?.isLatched()) return true;
if (input.usageTokens === null) return false;
if (input.contextWindow <= 0) return false;
return input.usageTokens >= input.contextWindow * gate;
const gate = input.gateRatio ?? BUILTIN_CONTEXT_REDUCTION_GATE_RATIO;
const shouldEngage = input.usageTokens >= input.contextWindow * gate;
if (shouldEngage && input.latch) {
input.latch.engage();
}
return shouldEngage;
}

function approxTextTokens(text: string): number {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as checkpointState from "./checkpoint-state.ts";
import * as breaker from "./circuit-breaker.ts";
import {
BUILTIN_CONTEXT_REDUCTION_OPTIONS,
createContextReductionLatch,
reduceContextMessages,
shouldApplyContextReduction,
} from "./context-reduction.ts";
Expand Down Expand Up @@ -193,6 +194,7 @@ export default function compactionExtension(
const lanePolicy = createCompactionLanePolicy();
const restorationDirectiveState = checkpointState.createRestorationDirectiveState();
const emergencyPruneLatch = createEmergencyPruneLatch();
const contextReductionLatch = createContextReductionLatch();
const degradationState = createDegradationMonitorState();
const restorationState = state.restoration ?? restoration.createRestorationTrackerState();
state = { ...state, restoration: restorationState };
Expand Down Expand Up @@ -750,6 +752,7 @@ export default function compactionExtension(
const compactEvent = event;
invalidateSpeculativeCompaction(ctx);
if (compactEvent.accepted) {
contextReductionLatch.bumpGeneration();
persistAcceptedMetadata(compactEvent.requestId);
const branchEntries = ctx.sessionManager.getBranch();
const firstKeptIndex = branchEntries.findIndex(
Expand Down Expand Up @@ -874,6 +877,7 @@ export default function compactionExtension(
contextWindow,
isProviderNativeCompactionPath:
isOpenAiRemoteCompactionModel(ctx.model) || lanePolicy.disablesSenpiCompaction(ctx),
latch: contextReductionLatch,
Comment thread
codeg-dev marked this conversation as resolved.
Comment thread
codeg-dev marked this conversation as resolved.
})
? reduceContextMessages(event.messages, BUILTIN_CONTEXT_REDUCTION_OPTIONS).messages
: event.messages;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,62 @@ const YIELD_ADJUSTMENT_RATIO = 0.05;
const MIN_EFFECTIVE_KEEP_RECENT_TOKENS = 1024;

export const SPECULATIVE_FRACTION = 0.75;
export const DEFAULT_1M_CEILING = 384_000;
export const DEFAULT_1M_KEEP_RECENT = 35_000;
export const DEFAULT_STANDARD_KEEP_RECENT = 20_000;
export const DEFAULT_WARMUP_FRACTION = 0.75;
export const DEFAULT_TARGET_ACTIVE_FRACTION = 0.6;
export const DEFAULT_RESERVE_TOKENS = 16_384;
export const LARGE_WINDOW_THRESHOLD = 500_000;

export interface ContextBudgetPolicy {
physicalContextWindow: number;
maxActiveContextTokens: number;
keepRecentTokens: number;
warmupFraction: number;
targetActiveFraction: number;
reserveTokens: number;
emergencyHardLimitTokens: number;
}

export interface CompactionYield {
savedTokens: number;
tokensBefore: number;
}

export function isLargeContextModel(contextWindow: number): boolean {
return contextWindow >= LARGE_WINDOW_THRESHOLD;
}

export function resolveContextBudgetPolicy(
contextWindow: number,
settings?: Partial<CompactionSettings>,
): ContextBudgetPolicy {
const isLarge = isLargeContextModel(contextWindow);
const reserveTokens = settings?.reserveTokens ?? DEFAULT_RESERVE_TOKENS;
const defaultCeiling = isLarge ? DEFAULT_1M_CEILING : Math.max(0, contextWindow - reserveTokens);
const configuredCeiling =
settings?.maxContextTokens && settings.maxContextTokens > 0 ? settings.maxContextTokens : defaultCeiling;
Comment thread
codeg-dev marked this conversation as resolved.

const maxActiveContextTokens = Math.min(Math.max(0, contextWindow - reserveTokens), configuredCeiling);

const defaultKeepRecent = isLarge ? DEFAULT_1M_KEEP_RECENT : DEFAULT_STANDARD_KEEP_RECENT;
const keepRecentTokens = settings?.keepRecentTokens ?? defaultKeepRecent;
const warmupFraction = settings?.speculativeFraction ?? DEFAULT_WARMUP_FRACTION;
const targetActiveFraction = DEFAULT_TARGET_ACTIVE_FRACTION;
const emergencyHardLimitTokens = Math.max(0, contextWindow - Math.floor(reserveTokens / 2));

return {
physicalContextWindow: contextWindow,
maxActiveContextTokens,
keepRecentTokens,
warmupFraction,
targetActiveFraction,
reserveTokens,
emergencyHardLimitTokens,
};
}

function clampThresholdRatio(ratio: number): number {
return Math.min(MAX_ADAPTIVE_THRESHOLD_RATIO, Math.max(MIN_ADAPTIVE_THRESHOLD_RATIO, ratio));
}
Expand Down Expand Up @@ -84,14 +134,32 @@ export function computeEffectiveThreshold(contextWindow: number, lastYield?: Com
return clampThresholdRatio(ratio);
}

export function computeEffectiveBlockingThresholdTokens(
contextWindow: number,
settings?: Partial<CompactionSettings>,
lastYield?: CompactionYield | number,
): number {
const ratio = computeEffectiveThreshold(contextWindow, lastYield);
const ratioTokens = Math.floor(contextWindow * ratio);
if (isLargeContextModel(contextWindow) || (settings?.maxContextTokens && settings.maxContextTokens > 0)) {
const policy = resolveContextBudgetPolicy(contextWindow, settings);
return Math.min(ratioTokens, policy.maxActiveContextTokens);
Comment thread
codeg-dev marked this conversation as resolved.
}
return ratioTokens;
}

export function computeEffectiveKeepRecentTokens(
setting: number,
contextWindow: number,
thresholdRatio: number,
margin = 0.05,
): number {
const isLarge = isLargeContextModel(contextWindow);
const defaultForModel = isLarge ? DEFAULT_1M_KEEP_RECENT : DEFAULT_STANDARD_KEEP_RECENT;
// If setting is passed and different from default fallback, respect it; otherwise use model default
const effectiveSetting = setting > 0 ? setting : defaultForModel;
Comment thread
codeg-dev marked this conversation as resolved.
Outdated
const capped = Math.floor(contextWindow * (1 - thresholdRatio - margin));
return Math.min(setting, Math.max(MIN_EFFECTIVE_KEEP_RECENT_TOKENS, capped));
return Math.min(effectiveSetting, Math.max(MIN_EFFECTIVE_KEEP_RECENT_TOKENS, capped));
}

export function shouldStartSpeculativeCompaction(
Expand All @@ -104,8 +172,10 @@ export function shouldStartSpeculativeCompaction(
return false;
}

const fraction = settings.speculativeFraction ?? SPECULATIVE_FRACTION;
return usage.tokens >= contextWindow * computeEffectiveThreshold(contextWindow, lastYield) * fraction;
const policy = resolveContextBudgetPolicy(contextWindow, settings);
const blockingThreshold = computeEffectiveBlockingThresholdTokens(contextWindow, settings, lastYield);
const warmupThreshold = Math.floor(blockingThreshold * policy.warmupFraction);
return usage.tokens >= warmupThreshold;
}

export function isAtHardLimit(
Expand All @@ -127,5 +197,6 @@ export function shouldTriggerCompaction(
return false;
}

return usage.tokens >= contextWindow * computeEffectiveThreshold(contextWindow, lastYield);
const blockingThreshold = computeEffectiveBlockingThresholdTokens(contextWindow, settings, lastYield);
return usage.tokens >= blockingThreshold;
}
52 changes: 52 additions & 0 deletions packages/coding-agent/test/compaction/context-reduction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,4 +335,56 @@ describe("compaction context reduction behavior", () => {
});
});
});

describe("Sticky generation latch and prefix hash stability", () => {
it("latches context reduction once engaged and preserves historical prefix shape across threshold oscillations", () => {
const {
createContextReductionLatch,
shouldApplyContextReduction,
} = require("../../src/core/extensions/builtin/compaction/context-reduction.ts");
const latch = createContextReductionLatch();
const contextWindow = 1_000_000;

// Turn 1: 501k (>= 500k gate) -> engages and latches
const engaged1 = shouldApplyContextReduction({
usageTokens: 501_000,
contextWindow,
latch,
});
expect(engaged1).toBe(true);
expect(latch.isLatched()).toBe(true);

// Turn 2: usage dips to 499k (< 500k gate), but latch is ON -> stays engaged
const engaged2 = shouldApplyContextReduction({
usageTokens: 499_000,
contextWindow,
latch,
});
expect(engaged2).toBe(true);
expect(latch.isLatched()).toBe(true);

// Turn 3: usage rises to 505k -> stays engaged
const engaged3 = shouldApplyContextReduction({
usageTokens: 505_000,
contextWindow,
latch,
});
expect(engaged3).toBe(true);
expect(latch.isLatched()).toBe(true);

// Compaction accepted & persisted -> generation bumps and latch resets
latch.bumpGeneration();
expect(latch.isLatched()).toBe(false);
expect(latch.getGeneration()).toBe(1);

// After compaction, usage is at 100k -> should NOT engage
const engagedAfter = shouldApplyContextReduction({
usageTokens: 100_000,
contextWindow,
latch,
});
expect(engagedAfter).toBe(false);
expect(latch.isLatched()).toBe(false);
});
});
});
Loading