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
25 changes: 25 additions & 0 deletions packages/coding-agent/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
# Local fork changes

## 2026-08-16 — Stabilize request-local context reduction

### What changed

- Context reduction now stays engaged after crossing the 50% usage gate until
an accepted persisted compaction or session-tree navigation changes the active
stored history.
- Provider-native compaction lanes still bypass Senpi context reduction.
- Added a deterministic sanitized threshold-control regression covering 507
request-local evaluations and 319 threshold crossings against a
one-million-token window.
- Added a separate payload-scale extension regression with 1,510 eligible tool
results and more than one megabyte of serialized history. It proves rejected
compaction preserves the latch and accepted compaction resets it.

### Why

- The builtin context hook rebuilds outgoing messages from unchanged stored
history on every request. A stateless gate alternated reduced and unreduced
payload shapes when reported usage moved across 50%, invalidating stable
prefix reuse.
- Numeric release hysteresis cannot guarantee stability because a successful
request-local reduction can move the next reported usage below a release
band without changing stored history.

## 2026-08-14 — RPC stream regression suites for multi-session compaction

### What changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,12 +151,30 @@ export interface ShouldApplyContextReductionInput {
isProviderNativeCompactionPath?: boolean;
}

export function shouldApplyContextReduction(input: ShouldApplyContextReductionInput): boolean {
const gate = input.gateRatio ?? BUILTIN_CONTEXT_REDUCTION_GATE_RATIO;
export interface ContextReductionLatch {
engaged: boolean;
}

export function createContextReductionLatch(): ContextReductionLatch {
return { engaged: false };
}

export function resetContextReductionLatch(latch: ContextReductionLatch): void {
latch.engaged = false;
}

export function shouldApplyContextReduction(
input: ShouldApplyContextReductionInput,
latch?: ContextReductionLatch,
): boolean {
if (input.isProviderNativeCompactionPath === true) return false;
if (latch?.engaged === true) 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 shouldApply = input.usageTokens >= input.contextWindow * gate;
if (shouldApply && latch) latch.engaged = true;
return shouldApply;
}

function approxTextTokens(text: string): number {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import * as checkpointState from "./checkpoint-state.ts";
import * as breaker from "./circuit-breaker.ts";
import {
BUILTIN_CONTEXT_REDUCTION_OPTIONS,
createContextReductionLatch,
reduceContextMessages,
resetContextReductionLatch,
shouldApplyContextReduction,
} from "./context-reduction.ts";
import {
Expand Down Expand Up @@ -193,6 +195,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 @@ -741,10 +744,15 @@ export default function compactionExtension(
}
});

pi.on("session_tree", () => {
resetContextReductionLatch(contextReductionLatch);
});

pi.on("session_compact", async (event: SessionCompactEvent, ctx) => {
const compactEvent = event;
invalidateSpeculativeCompaction(ctx);
if (compactEvent.accepted) {
resetContextReductionLatch(contextReductionLatch);
Comment thread
codeg-dev marked this conversation as resolved.
persistAcceptedMetadata(compactEvent.requestId);
const branchEntries = ctx.sessionManager.getBranch();
const firstKeptIndex = branchEntries.findIndex(
Expand Down Expand Up @@ -864,12 +872,15 @@ export default function compactionExtension(
const usage = ctx.getContextUsage();
const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
const promptContextWindow = getPromptContextWindow(contextWindow, ctx.model?.maxTokens);
const sourceMessages = shouldApplyContextReduction({
usageTokens: usage?.tokens ?? null,
contextWindow,
isProviderNativeCompactionPath:
isOpenAiRemoteCompactionModel(ctx.model) || lanePolicy.disablesSenpiCompaction(ctx),
})
const sourceMessages = shouldApplyContextReduction(
{
usageTokens: usage?.tokens ?? null,
contextWindow,
isProviderNativeCompactionPath:
isOpenAiRemoteCompactionModel(ctx.model) || lanePolicy.disablesSenpiCompaction(ctx),
},
contextReductionLatch,
)
? reduceContextMessages(event.messages, BUILTIN_CONTEXT_REDUCTION_OPTIONS).messages
: event.messages;
// The claude-sdk-oauth lane stands down from senpi compaction entirely:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { createHash } from "node:crypto";
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, ToolResultMessage, Usage, UserMessage } from "@earendil-works/pi-ai";
import { describe, expect, it } from "vitest";
import {
BUILTIN_CONTEXT_REDUCTION_OPTIONS,
type ContextReductionLatch,
createContextReductionLatch,
reduceContextMessages,
resetContextReductionLatch,
shouldApplyContextReduction,
} from "../../../src/core/extensions/builtin/compaction/context-reduction.ts";

const CONTEXT_WINDOW = 1_000_000;
const EXPOSURE_REQUESTS = 507;
const OBSERVED_THRESHOLD_CROSSINGS = 319;

function emptyUsage(): Usage {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}

function assistantToolCall(id: string, timestamp: number): AssistantMessage {
return {
role: "assistant",
content: [{ type: "toolCall", id, name: "bash", arguments: { command: `probe-${id}` } }],
api: "faux-completion",
provider: "faux",
model: "faux-model",
usage: emptyUsage(),
stopReason: "toolUse",
timestamp,
};
}

function toolResult(id: string, timestamp: number): ToolResultMessage {
return {
role: "toolResult",
toolCallId: id,
toolName: "bash",
content: [{ type: "text", text: `result-${id}-${"x".repeat(4_000)}` }],
isError: false,
timestamp,
};
}

function userMessage(text: string, timestamp: number): UserMessage {
return { role: "user", content: text, timestamp };
}

function thresholdControlHistory(): AgentMessage[] {
const messages: AgentMessage[] = [userMessage("sanitized threshold-control fixture", 1)];
for (let index = 0; index < 12; index += 1) {
const id = `call-${index}`;
messages.push(assistantToolCall(id, index * 2 + 2), toolResult(id, index * 2 + 3));
}
messages.push(userMessage("latest request", 100));
return messages;
}

function payloadHash(messages: AgentMessage[]): string {
return createHash("sha256").update(JSON.stringify(messages)).digest("hex");
}

describe("request-local context reduction cache stability", () => {
it("keeps one payload shape across the 507-request threshold-control fixture", () => {
// Given: the incident had a one-million-token window, 507 proxy-exposed
// requests, and 319 observed threshold crossings. This intentionally small
// control fixture isolates state-machine oscillation without claiming to
// reproduce payload scale or that the proxy cohort measures caused overhead.
// Payload-scale reducer behavior is pinned by the companion lifecycle test.
const messages = thresholdControlHistory();
const latch = { engaged: false } satisfies ContextReductionLatch;
const usageSeries = Array.from({ length: EXPOSURE_REQUESTS }, (_, index) => {
if (index > OBSERVED_THRESHOLD_CROSSINGS) return 499_000;
return index % 2 === 0 ? 501_000 : 499_000;
});

// When: every provider request independently assembles context from the
// same stored history while computed usage crosses the 50% gate.
const hashes = usageSeries.map((usageTokens) => {
const shouldReduce = shouldApplyContextReduction({ usageTokens, contextWindow: CONTEXT_WINDOW }, latch);
const outgoing = shouldReduce
? reduceContextMessages(messages, BUILTIN_CONTEXT_REDUCTION_OPTIONS).messages
: messages;
return payloadHash(outgoing);
});

// Then: once reduction engages, request-local payloads retain one stable
// cacheable shape until persisted compaction resets the latch.
expect(new Set(hashes).size).toBe(1);
expect(hashes.every((hash) => hash !== payloadHash(messages))).toBe(true);
});

it("resets the sticky reduction state after accepted persisted compaction", () => {
const latch = createContextReductionLatch();

expect(shouldApplyContextReduction({ usageTokens: 501_000, contextWindow: CONTEXT_WINDOW }, latch)).toBe(true);
expect(shouldApplyContextReduction({ usageTokens: 499_000, contextWindow: CONTEXT_WINDOW }, latch)).toBe(true);

resetContextReductionLatch(latch);

expect(shouldApplyContextReduction({ usageTokens: 499_000, contextWindow: CONTEXT_WINDOW }, latch)).toBe(false);
});

it("preserves the provider-native compaction bypass while sticky", () => {
const latch = { engaged: true } satisfies ContextReductionLatch;
const shouldReduce = shouldApplyContextReduction(
{
usageTokens: 900_000,
contextWindow: CONTEXT_WINDOW,
isProviderNativeCompactionPath: true,
},
latch,
);
expect(shouldReduce).toBe(false);
expect(latch.engaged).toBe(true);
});
});
Loading