Skip to content

Commit 7ba4087

Browse files
committed
feat(session): gate anthropic prompt shrink behind settings
Cache expiry no longer compacts stored turns. The prompt transform runs only when anthropicCachePrompt is set.
1 parent e4744a0 commit 7ba4087

10 files changed

Lines changed: 64 additions & 136 deletions

File tree

‎src/agent/compaction.test.ts‎

Lines changed: 9 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,10 +1020,8 @@ describe("provider-aware idle recompress (CL-8745)", () => {
10201020
nowMs += MINUTE_MS + 1;
10211021
expect(
10221022
governor.interceptIdleContinuation(emptyMessage(), capabilities),
1023-
).toEqual(ttlCompact);
1024-
expect(continuations).toBe(1);
1025-
// Empty continuation adopts the shrunk turns without a new inference.
1026-
expect(governor.resumeAfterCompact(emptyMessage())).toBe("meter");
1023+
).toBeNull();
1024+
expect(continuations).toBe(0);
10271025
});
10281026

10291027
test("production LastCycleSource: anthropic fires at 5m, codex never does", () => {
@@ -1058,7 +1056,7 @@ describe("provider-aware idle recompress (CL-8745)", () => {
10581056
nowMs += 5 * MINUTE_MS + 1;
10591057
expect(
10601058
anthropic.interceptIdleContinuation(emptyMessage(), capabilities),
1061-
).toEqual(ttlCompact);
1059+
).toBeNull();
10621060
expect(
10631061
codex.interceptIdleContinuation(emptyMessage(), capabilities),
10641062
).toBeNull();
@@ -1181,7 +1179,7 @@ describe("provider-aware idle recompress (CL-8745)", () => {
11811179
nowMs += 5 * MINUTE_MS + 1;
11821180
expect(
11831181
governor.interceptIdleContinuation(emptyMessage(), capabilities),
1184-
).toEqual(ttlCompact);
1182+
).toBeNull();
11851183
});
11861184

11871185
test("after threshold compact and gap TTL, growth-armed compact stays blocked until a tool_call", () => {
@@ -1211,27 +1209,7 @@ describe("provider-aware idle recompress (CL-8745)", () => {
12111209
nowMs += 5 * MINUTE_MS + 1;
12121210
expect(
12131211
governor.interceptIdleContinuation(emptyMessage(), capabilities),
1214-
).toEqual(ttlCompact);
1215-
expect(governor.resumeAfterCompact(emptyMessage())).toBe("meter");
1216-
1217-
// Post-TTL snapshot, then growth past resumeDelta — pending re-arms,
1218-
// but the cap is full so interceptActions stays null.
1219-
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
1220-
governor.noteInferenceDone(
1221-
inferenceDone(overThreshold + resumeDelta),
1222-
tenTurns,
1223-
);
1224-
expect(
1225-
governor.interceptActions(toolDone(), inferAction, capabilities),
12261212
).toBeNull();
1227-
1228-
governor.noteInferenceDone(
1229-
inferenceDoneWithTools(overThreshold + 2 * resumeDelta),
1230-
tenTurns,
1231-
);
1232-
expect(
1233-
governor.interceptActions(toolDone(), inferAction, capabilities),
1234-
).not.toBeNull();
12351213
});
12361214

12371215
test("does not fold under an outstanding tool batch, fires once it settles", () => {
@@ -1257,8 +1235,8 @@ describe("provider-aware idle recompress (CL-8745)", () => {
12571235
).toBeNull();
12581236
expect(
12591237
governor.interceptIdleContinuation(emptyMessage(), capabilities),
1260-
).toEqual(ttlCompact);
1261-
expect(continuations).toBe(1);
1238+
).toBeNull();
1239+
expect(continuations).toBe(0);
12621240
});
12631241

12641242
test("one fire per window, then the shared consecutive-compact cap stops the spiral", () => {
@@ -1276,31 +1254,10 @@ describe("provider-aware idle recompress (CL-8745)", () => {
12761254
);
12771255

12781256
nowMs += 5 * MINUTE_MS + 1;
1279-
expect(
1280-
governor.interceptIdleContinuation(emptyMessage(), capabilities),
1281-
).toEqual(ttlCompact);
1282-
expect(governor.resumeAfterCompact(emptyMessage())).toBe("meter");
1283-
governor.notePostCompact(tenTurns);
1284-
1285-
// Inside the next window: no second fire.
1286-
nowMs += 4 * MINUTE_MS;
12871257
expect(
12881258
governor.interceptIdleContinuation(emptyMessage(), capabilities),
12891259
).toBeNull();
1290-
1291-
nowMs += MINUTE_MS + 1;
1292-
expect(
1293-
governor.interceptIdleContinuation(emptyMessage(), capabilities),
1294-
).toEqual(ttlCompact);
1295-
expect(governor.resumeAfterCompact(emptyMessage())).toBe("meter");
1296-
governor.notePostCompact(tenTurns);
1297-
1298-
// Cap reached: the third window stays quiet.
1299-
nowMs += 5 * MINUTE_MS + 1;
1300-
expect(
1301-
governor.interceptIdleContinuation(emptyMessage(), capabilities),
1302-
).toBeNull();
1303-
expect(continuations).toBe(2);
1260+
expect(continuations).toBe(0);
13041261
});
13051262

13061263
test("a raced operator message past the TTL still folds, then re-infers", () => {
@@ -1319,11 +1276,8 @@ describe("provider-aware idle recompress (CL-8745)", () => {
13191276
nowMs += 6 * MINUTE_MS;
13201277
expect(
13211278
governor.interceptIdleContinuation(racedMessage(), capabilities),
1322-
).toEqual(ttlCompact);
1323-
expect(continuations).toBe(1);
1324-
// The follow-up empty continuation carries the infer that answers the
1325-
// raced question (mirrors the threshold raced path).
1326-
expect(governor.resumeAfterCompact(emptyMessage())).toBe("infer");
1279+
).toBeNull();
1280+
expect(continuations).toBe(0);
13271281
});
13281282
});
13291283

‎src/agent/compaction.ts‎

Lines changed: 5 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
compactionThresholdFor,
1212
contextTokensFromUsage,
1313
} from "../provider/context-window.js";
14-
import { cacheTtlMsFor } from "../provider/cache-ttl.js";
1514
import {
1615
COMPACTOR_KEEP_RECENT_TURNS,
1716
assistantTextIsCompactSpacerEcho,
@@ -162,13 +161,7 @@ export function createCompactionGovernor(
162161
// can flag the number as approximate instead of implying provider-grade
163162
// precision.
164163
let usingEstimate = false;
165-
// Last-cycle source of the last inference.done, kept for live re-checks
166-
// between inference cycles (see interceptActions) where the event carries
167-
// no source. Threshold sizing still keys off `model`; TTL identity needs
168-
// `sourceId` / `provider` as well — production LastCycleSource stamps a
169-
// bare model, and Ollama is `openai-compatible` with id `ollama/…`.
170164
let lastModel: string | undefined;
171-
let lastCycleSource: LastCycleSource | undefined;
172165
let turnCount = 0;
173166
// Wall-clock of the last inference.done: the provider (re)wrote its prefix
174167
// cache for this session on that turn, so the provider TTL window in
@@ -284,7 +277,6 @@ export function createCompactionGovernor(
284277
consecutiveThresholdCompacts = 0;
285278
}
286279
syncFromTurns(turns);
287-
lastCycleSource = event.source;
288280
lastModel = event.source?.model;
289281
lastCacheWriteAt = now();
290282
// The terminal reply ends the previous tool batch (its results are
@@ -390,37 +382,6 @@ export function createCompactionGovernor(
390382
return true;
391383
}
392384

393-
// Provider-aware idle recompress (CL-8745): the fold is a re-compress, not
394-
// a cache play. Provider KV caches expire on their own schedule
395-
// (provider/cache-ttl.ts); compressing after that expiry makes the next
396-
// turn a cheaper write and later reads compound on the shrunk context. This
397-
// fires on any live re-entry once `now - lastCacheWrite >= ttl`, including
398-
// over-threshold sessions whose threshold path has disarmed (`pending` is
399-
// false via growth hysteresis — the threshold path owns only armed
400-
// over-threshold; the fold is still window- and cap-bounded). Guards, in
401-
// order: threshold arming defers (pending), the fresh-tail floor (turns at
402-
// or under it are all kept, so a fold would shrink nothing), the
403-
// consecutive-compact cap (existing death-spiral bound, shared with the
404-
// threshold path), providers with no TTL (undefined/empty model, local
405-
// inference), no observed cache write yet, the TTL window itself, and one
406-
// fire per window (a fresh fold rewrites the prefix; the summary call
407-
// bypasses this governor so lastCacheWriteAt cannot observe it —
408-
// lastCompactAt covers that). Never fires with a tool batch outstanding:
409-
// the stall ping that triggers this can arrive mid-work.
410-
function isTtlRecompressDue(nowMs: number): boolean {
411-
if (pending) return false;
412-
if (turnCount <= MIN_TURNS_TO_COMPACT) return false;
413-
if (atThresholdCompactCap()) return false;
414-
if (outstandingToolCalls > 0) return false;
415-
const ttl = cacheTtlMsFor(lastCycleSource);
416-
if (ttl === undefined) return false;
417-
if (lastCacheWriteAt === undefined) return false;
418-
if (nowMs - lastCacheWriteAt < ttl) return false;
419-
if (lastCompactAt !== undefined && nowMs - lastCompactAt < ttl)
420-
return false;
421-
return true;
422-
}
423-
424385
function inboundText(event: ReactorInboundEvent): string {
425386
if (event.type !== "message.received") return "";
426387
return typeof event.message.content === "string"
@@ -479,12 +440,11 @@ export function createCompactionGovernor(
479440
// In-flight `/compact` (manualPending without idlePending) waits on
480441
// interceptActions; do not steal that hop with a TTL fold.
481442
if (manualPending) return null;
482-
if (!isTtlRecompressDue(now())) return null;
483-
return issueIdleFold(
484-
inboundText(event),
485-
capabilities,
486-
"cache-ttl-recompress",
487-
);
443+
// Cache expiry is a prompt transform, not a fold. Compacting here rewrites
444+
// turns.jsonl and drops the history the transform is supposed to leave
445+
// stored. The Anthropic prompt transform stubs tool bodies on the request
446+
// when this stamp is expired.
447+
return null;
488448
}
489449

490450
// A context-overflow inference error would otherwise terminate the loop
@@ -594,7 +554,6 @@ export function createCompactionGovernor(
594554
}): void {
595555
syncFromTurns(args.turns);
596556
lastCacheWriteAt = args.at;
597-
lastCycleSource = args.source;
598557
lastModel = args.source.model;
599558
}
600559

‎src/config/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,9 @@ export interface Config {
576576
cwd: string;
577577
task: string;
578578
dangerouslySkipPermissions: boolean;
579+
// Experimental prompt shrink after an Anthropic cache expiry. Off unless
580+
// settings set anthropicCachePrompt.
581+
anthropicCachePrompt: boolean;
579582
// True when dangerouslySkipPermissions came from the persisted global
580583
// default rather than this invocation's CLI flag. Entry points use this to
581584
// surface a startup notice since the persisted default is otherwise silent.
@@ -1139,6 +1142,7 @@ export async function loadConfig(
11391142
cwd,
11401143
task: resumeTask,
11411144
dangerouslySkipPermissions,
1145+
anthropicCachePrompt: settings?.anthropicCachePrompt === true,
11421146
skipPermissionsFromSettings,
11431147
auto,
11441148
command,

‎src/config/settings.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,10 @@ export interface Settings {
182182
showPromptCost?: boolean;
183183
// User-global YOLO default; `/yolo` writes it.
184184
dangerouslySkipPermissions?: boolean;
185+
// Experimental. When true, an expired Anthropic prompt cache stubs old tool
186+
// results on the outgoing prompt only. Default off: sessions do not depend
187+
// on that shrink.
188+
anthropicCachePrompt?: boolean;
185189
}
186190

187191
function modelRefKey(ref: ModelRef): string {
@@ -594,6 +598,7 @@ const SettingsSchema = type({
594598
"favoriteModels?": ModelRefSchema.array(),
595599
"showPromptCost?": "boolean",
596600
"dangerouslySkipPermissions?": "boolean",
601+
"anthropicCachePrompt?": "boolean",
597602
});
598603

599604
// Per-entry MCP shape without the name key. The "exactly one transport" rule is
@@ -797,6 +802,7 @@ export const GLOBAL_SETTINGS_OPTIONAL_KEYS = [
797802
"recentModels",
798803
"favoriteModels",
799804
"dangerouslySkipPermissions",
805+
"anthropicCachePrompt",
800806
] as const satisfies readonly (keyof OptionalSettingsFields)[];
801807

802808
/** Optional local settings keys the load path is required to consider. */
@@ -964,6 +970,10 @@ function normalizeParsedSettings(path: string, parsed: unknown): Settings {
964970
s.dangerouslySkipPermissions !== undefined
965971
? Boolean(s.dangerouslySkipPermissions)
966972
: undefined,
973+
anthropicCachePrompt:
974+
s.anthropicCachePrompt !== undefined
975+
? Boolean(s.anthropicCachePrompt)
976+
: undefined,
967977
};
968978
return {
969979
providers: s.providers as Settings["providers"],

‎src/exec/runner.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -862,6 +862,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
862862
},
863863
getDefaultSource: () =>
864864
liveDefaultSource.length > 0 ? liveDefaultSource : liveSource.id,
865+
anthropicCachePrompt: () => config.anthropicCachePrompt,
865866
getCompactor: () =>
866867
createSessionPruningCompactor({
867868
summarize: summarizeForCompaction,

‎src/session/anthropic-cache-prompt.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,19 @@ function history(): ConversationTurn[] {
9292
}
9393

9494
describe("anthropic cache prompt transform", () => {
95+
test("settings off leaves an expired Anthropic prompt unchanged", async () => {
96+
const turns = history();
97+
const transform = createAnthropicCachePromptTransform({
98+
nowMs: () => NOW,
99+
cacheWriteAt: () => NOW - 6 * MINUTE_MS,
100+
protocol: () => "anthropic",
101+
enabled: () => false,
102+
});
103+
const result = await transform.apply(turns, CTX);
104+
expect(result.output).toBe(turns);
105+
expect(result.record.reason).toBe("disabled");
106+
});
107+
95108
test("expired or missing Anthropic stamp stubs old tool bodies and leaves stored turns", async () => {
96109
const dir = await mkdtemp(join(tmpdir(), "anthropic-cache-prompt-"));
97110
const store = await createOptimizedContextStore(dir);

‎src/session/anthropic-cache-prompt.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export type AnthropicCachePromptDeps = {
1818
cacheWriteAt: () => number | undefined;
1919
/** Live adapter protocol (`InferenceSource.provider`). */
2020
protocol: () => string | undefined;
21+
/** When omitted, the shrink is on. Settings pass false unless opted in. */
22+
enabled?: () => boolean;
2123
};
2224

2325
const STRATEGY = "anthropic-cache-prompt";
@@ -144,6 +146,9 @@ export function createAnthropicCachePromptTransform(
144146
name: STRATEGY,
145147
version: "1",
146148
async apply(turns, _ctx) {
149+
if (deps.enabled !== undefined && !deps.enabled()) {
150+
return passthrough(turns, "disabled");
151+
}
147152
const ttl = cacheTtlMsFor(deps.protocol());
148153
if (ttl === undefined) return passthrough(turns, "non-anthropic");
149154
const at = deps.cacheWriteAt();

‎src/session/assemble-runtime.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,8 @@ export interface ChatAgentWiring {
509509
getDefaultSource: () => string;
510510
/** Read at each build so a compaction-mode toggle is visible on rebuild. */
511511
getCompactor: () => Compactor;
512+
/** Experimental Anthropic prompt shrink. Default off when omitted. */
513+
anthropicCachePrompt?: () => boolean;
512514
/**
513515
* Present when a resumed run record has an Anthropic-protocol cache write
514516
* and the provider about to be called is the same protocol. Read at each
@@ -706,6 +708,7 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent {
706708
createAnthropicCachePromptTransform({
707709
nowMs: () => Date.now(),
708710
cacheWriteAt: () => getActiveRun()?.lastCacheWriteAt,
711+
enabled: () => wiring.anthropicCachePrompt?.() === true,
709712
protocol: () => {
710713
const sources = wiring.getSources();
711714
const preferred = wiring.getDefaultSource();

0 commit comments

Comments
 (0)