auto-model-router 0.2.21 → 0.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,6 +37,27 @@ export function compactedBytes(originalBytes: number, edit: CompactionEdit | und
37
37
  return Math.min(originalBytes, kept);
38
38
  }
39
39
 
40
+ /**
41
+ * Validates a persisted compaction plan against this turn's messages before it
42
+ * is re-applied. Every edit must land on a tool-role message whose string
43
+ * content still has the original byte length the edit was planned against.
44
+ * A failed check means the client rewrote or truncated its history — the edit
45
+ * is dropped rather than applied to the wrong bytes.
46
+ */
47
+ export function validatePlan(
48
+ plan: readonly CompactionEdit[],
49
+ messages: readonly NormMessage[],
50
+ ): CompactionEdit[] {
51
+ const valid: CompactionEdit[] = [];
52
+ for (const e of plan) {
53
+ const m = messages[e.index];
54
+ if (m === undefined || m.role !== "tool") continue;
55
+ if (m.textBytes !== e.bytes) continue;
56
+ valid.push(e);
57
+ }
58
+ return valid;
59
+ }
60
+
40
61
  /**
41
62
  * First string value in a tool call's argument JSON — a schema-agnostic proxy
42
63
  * for the resource a call operates on (a `path`, `id`, `query`, ...). Used to
@@ -81,6 +102,16 @@ interface ToolResult {
81
102
  key: string | null;
82
103
  }
83
104
 
105
+ /**
106
+ * Result for a turn that adds nothing: the carried plan alone, with its
107
+ * savings recomputed against this turn's messages.
108
+ */
109
+ function carriedOnly(carried: readonly CompactionEdit[]): CompactionResult {
110
+ const edits = [...carried].sort((a, b) => a.index - b.index);
111
+ const savedBytes = edits.reduce((sum, e) => sum + (e.bytes - compactedBytes(e.bytes, e)), 0);
112
+ return { edits, savedBytes };
113
+ }
114
+
84
115
  /**
85
116
  * Plans compaction for a turn's messages toward `targetBytes` of total prompt.
86
117
  * Duplicate and superseded elisions (pure stale-data wins) are always applied;
@@ -96,17 +127,27 @@ interface ToolResult {
96
127
  * fresh edits at arbitrarily early indices on later turns, rewriting history
97
128
  * the upstream had already cached and collapsing cache reads to the system
98
129
  * prefix (measured: 61% cache read, bimodal, vs 76-82% before compaction).
130
+ *
131
+ * `carried` is the plan already applied to this conversation on a previous
132
+ * dispatch (validated by `validatePlan`). It is re-emitted verbatim and its
133
+ * savings count toward the target, so an existing edit is never re-derived
134
+ * differently and the planner only ever ADDS. Re-applying it costs nothing:
135
+ * the client re-sends the original bytes every turn, so the same edit produces
136
+ * the same output.
99
137
  */
100
138
  export function planCompaction(
101
139
  messages: readonly NormMessage[],
102
140
  cfg: CompactionConfig,
103
141
  targetBytes: number,
104
142
  promptBytes: number,
143
+ carried: readonly CompactionEdit[] = [],
105
144
  ): CompactionResult {
106
145
  if (!cfg.enabled) return EMPTY;
107
146
 
108
147
  const protectStart = protectFromIndex(messages, cfg.protectRecentTurns);
109
- if (protectStart <= 0) return EMPTY;
148
+ // Carried edits still apply even when nothing new is eligible this turn:
149
+ // dropping them would re-inflate bytes the upstream has already cached.
150
+ if (protectStart <= 0) return carriedOnly(carried);
110
151
 
111
152
  // Assistant tool_call id → name/args, to key tool results by their call.
112
153
  const callById = new Map<string, { name: string; args: string }>();
@@ -128,16 +169,19 @@ export function planCompaction(
128
169
  key: call === undefined ? null : primaryArg(call.args),
129
170
  });
130
171
  }
131
- if (tools.length === 0) return EMPTY;
132
-
133
- const edits: CompactionEdit[] = [];
134
- const done = new Set<number>();
135
- let saved = 0;
172
+ if (tools.length === 0) return carriedOnly(carried);
173
+
174
+ // Seed with the carried plan: those indices are settled, and their savings
175
+ // already count against the target, so the target math asks "how much MORE
176
+ // is needed" rather than re-deriving the whole plan.
177
+ const edits: CompactionEdit[] = [...carried];
178
+ const done = new Set<number>(carried.map((e) => e.index));
179
+ let saved = carried.reduce((sum, e) => sum + (e.bytes - compactedBytes(e.bytes, e)), 0);
136
180
  const stub = (t: ToolResult, note: string): void => {
137
181
  if (done.has(t.index)) return;
138
182
  const gain = t.bytes - BREADCRUMB_BYTES;
139
183
  if (gain <= 0) return; // already smaller than a breadcrumb
140
- edits.push({ index: t.index, mode: "stub", keepHead: 0, keepTail: 0, note });
184
+ edits.push({ index: t.index, mode: "stub", keepHead: 0, keepTail: 0, note, bytes: t.bytes });
141
185
  done.add(t.index);
142
186
  saved += gain;
143
187
  };
@@ -172,7 +216,7 @@ export function planCompaction(
172
216
  const truncatable = tools.filter((t) => !done.has(t.index) && t.bytes > cfg.maxToolResultBytes && t.bytes > keepBudget);
173
217
  for (const t of truncatable) {
174
218
  if (promptBytes - saved <= targetBytes) break;
175
- edits.push({ index: t.index, mode: "truncate", keepHead: cfg.keepHeadBytes, keepTail: cfg.keepTailBytes, note: `large ${t.name || "tool"} result` });
219
+ edits.push({ index: t.index, mode: "truncate", keepHead: cfg.keepHeadBytes, keepTail: cfg.keepTailBytes, note: `large ${t.name || "tool"} result`, bytes: t.bytes });
176
220
  done.add(t.index);
177
221
  saved += t.bytes - keepBudget;
178
222
  }
@@ -12,7 +12,7 @@ import { priceAt } from "../cost/forecast.ts";
12
12
  import type { Ledger } from "../cost/types.ts";
13
13
  import { explorationDraw } from "./explore.ts";
14
14
  import type { CompactionEdit, NormRequest, ReasoningLevel } from "../wire/types.ts";
15
- import { planCompaction } from "./compaction.ts";
15
+ import { compactedBytes, planCompaction, validatePlan, type CompactionResult } from "./compaction.ts";
16
16
  import { planCacheBreakpoints } from "./cache-control.ts";
17
17
  import { buildCandidates } from "./candidates.ts";
18
18
  import {
@@ -153,28 +153,54 @@ export function select(args: SelectArgs): Decision {
153
153
  // Deterministic and content-only (never removes a message), so downstream
154
154
  // forecasting, the context_too_small filter, cache breakpoints, and the
155
155
  // agentdox block append all operate on the compacted size / stay valid.
156
+ //
157
+ // Two properties make this cache-safe, and both are load-bearing:
158
+ //
159
+ // 1. The plan is PERSISTED per conversation and re-applied verbatim. omp
160
+ // re-sends the original bytes every turn, so a re-applied edit yields
161
+ // byte-identical output; a plan re-derived from scratch could differ
162
+ // (a looser target, a re-tuned knob) and rewrite already-cached bytes.
163
+ // 2. Compaction is triggered on the COMPACTED size and then overshoots
164
+ // to `floorRatio` of the budget. Comparing the RAW prompt against the
165
+ // budget re-planned on every single turn, so the plan gained one more
166
+ // edit per turn — and each plan change rewrites cached prompt bytes.
167
+ // Measured on live ledger data (7 long conversations, 894 compacted
168
+ // dispatches): a turn whose plan changed ran 15.4% cold vs 8.9% when
169
+ // the plan held, and a cold prompt costs 4.34x a warm one per token.
170
+ // Overshooting buys several byte-stable turns per plan change.
156
171
  let compactionPlan: CompactionEdit[] = [];
157
172
  let promptTokensSaved = 0;
158
173
  let effFeatures = features;
159
174
  if (cfg.compaction.enabled && req.promptBytes > 0 && features.promptTokens > 0) {
175
+ const bytesPerToken = req.promptBytes / features.promptTokens;
176
+ const carried = validatePlan(state.compactionPlan ?? [], req.messages);
177
+ const carriedSavedBytes = carried.reduce((sum, e) => sum + (e.bytes - compactedBytes(e.bytes, e)), 0);
178
+ const tokensOf = (savedBytes: number): number =>
179
+ Math.min(features.promptTokens - 1, Math.round(features.promptTokens * (savedBytes / req.promptBytes)));
180
+ // What the upstream would actually receive if nothing new were planned.
181
+ const compactedTokens = features.promptTokens - tokensOf(carriedSavedBytes);
182
+
160
183
  const headroom = cfg.filters.contextHeadroom;
161
- const overBudget = features.promptTokens > cfg.compaction.budgetTokens;
184
+ const overBudget = compactedTokens > cfg.compaction.budgetTokens;
162
185
  const overWindow =
163
- cfg.compaction.fitToWindow && features.promptTokens * headroom + EXPECTED_COMPLETION_TOKENS > profile.contextWindow;
186
+ cfg.compaction.fitToWindow && compactedTokens * headroom + EXPECTED_COMPLETION_TOKENS > profile.contextWindow;
187
+ let plan: CompactionResult = { edits: carried, savedBytes: carriedSavedBytes };
164
188
  if (overBudget || overWindow) {
165
189
  const targets: number[] = [];
166
- if (overBudget) targets.push(cfg.compaction.budgetTokens);
190
+ // Overshoot the budget so the next re-plan is several turns away.
191
+ if (overBudget) targets.push(Math.max(1, Math.floor(cfg.compaction.budgetTokens * cfg.compaction.floorRatio)));
167
192
  if (overWindow) targets.push(Math.max(1, Math.floor((profile.contextWindow - EXPECTED_COMPLETION_TOKENS) / headroom)));
168
- const targetBytes = Math.min(...targets) * (req.promptBytes / features.promptTokens);
169
- const plan = planCompaction(req.messages, cfg.compaction, targetBytes, req.promptBytes);
170
- if (plan.edits.length > 0) {
171
- compactionPlan = plan.edits;
172
- promptTokensSaved = Math.min(features.promptTokens - 1, Math.round(features.promptTokens * (plan.savedBytes / req.promptBytes)));
173
- effFeatures = { ...features, promptTokens: features.promptTokens - promptTokensSaved };
174
- reasons.push(
175
- `compaction: ${plan.edits.length} tool result(s) shrunk, ~${promptTokensSaved} tokens saved (prompt ${features.promptTokens}→${effFeatures.promptTokens})`,
176
- );
177
- }
193
+ const targetBytes = Math.min(...targets) * bytesPerToken;
194
+ plan = planCompaction(req.messages, cfg.compaction, targetBytes, req.promptBytes, carried);
195
+ }
196
+ if (plan.edits.length > 0) {
197
+ compactionPlan = [...plan.edits];
198
+ promptTokensSaved = tokensOf(plan.savedBytes);
199
+ effFeatures = { ...features, promptTokens: features.promptTokens - promptTokensSaved };
200
+ const added = plan.edits.length - carried.length;
201
+ reasons.push(
202
+ `compaction: ${plan.edits.length} tool result(s) shrunk (${carried.length} carried, ${added} new), ~${promptTokensSaved} tokens saved (prompt ${features.promptTokens}→${effFeatures.promptTokens})`,
203
+ );
178
204
  }
179
205
  }
180
206
 
@@ -11,8 +11,10 @@
11
11
 
12
12
  import type { Database, Statement } from "bun:sqlite";
13
13
 
14
+ import type { CompactionEdit } from "../wire/types.ts";
14
15
  import type { ConversationState, ConversationStore, Tier } from "./types.ts";
15
16
 
17
+
16
18
  /** Row shape as stored; column names are snake_case per the schema. */
17
19
  interface Row {
18
20
  key: string;
@@ -28,6 +30,7 @@ interface Row {
28
30
  cache_warm_at_ms: number;
29
31
  context_version: string | null;
30
32
  context_fetched_at_ms: number;
33
+ compaction_plan: string | null;
31
34
  updated_at_ms: number;
32
35
  }
33
36
 
@@ -47,6 +50,7 @@ function toState(row: Row): ConversationState {
47
50
  cacheWarmAtMs: row.cache_warm_at_ms,
48
51
  contextVersion: row.context_version,
49
52
  contextFetchedAtMs: row.context_fetched_at_ms,
53
+ compactionPlan: row.compaction_plan === null ? null : (JSON.parse(row.compaction_plan) as CompactionEdit[]),
50
54
  updatedAtMs: row.updated_at_ms,
51
55
  };
52
56
  }
@@ -65,10 +69,10 @@ export function createConversationStore(db: Database): ConversationStore {
65
69
  INSERT INTO conversations (
66
70
  key, session_id, turn, current_slug, current_tier, sticky_until_turn,
67
71
  last_prompt_tokens, cache_warm_slug, cache_warm_at_ms,
68
- context_version, context_fetched_at_ms, updated_at_ms
72
+ context_version, context_fetched_at_ms, compaction_plan, updated_at_ms
69
73
  ) VALUES ($key, $sessionId, $turn, $currentSlug, $currentTier, $stickyUntilTurn,
70
74
  $lastPromptTokens, $cacheWarmSlug, $cacheWarmAtMs,
71
- $contextVersion, $contextFetchedAtMs, $updatedAtMs)
75
+ $contextVersion, $contextFetchedAtMs, $compactionPlan, $updatedAtMs)
72
76
  ON CONFLICT(key) DO UPDATE SET
73
77
  session_id = excluded.session_id,
74
78
  turn = excluded.turn,
@@ -80,6 +84,7 @@ export function createConversationStore(db: Database): ConversationStore {
80
84
  cache_warm_at_ms = excluded.cache_warm_at_ms,
81
85
  context_version = excluded.context_version,
82
86
  context_fetched_at_ms = excluded.context_fetched_at_ms,
87
+ compaction_plan = excluded.compaction_plan,
83
88
  updated_at_ms = excluded.updated_at_ms
84
89
  `);
85
90
  // Read-modify-write in JS lost money: an aborted or failed dispatch is still
@@ -127,6 +132,7 @@ export function createConversationStore(db: Database): ConversationStore {
127
132
  $currentTier: state.currentTier,
128
133
  $stickyUntilTurn: state.stickyUntilTurn,
129
134
  $lastPromptTokens: state.lastPromptTokens,
135
+ $compactionPlan: state.compactionPlan === null ? null : JSON.stringify(state.compactionPlan),
130
136
  $cacheWarmSlug: state.cacheWarmSlug,
131
137
  $cacheWarmAtMs: state.cacheWarmAtMs,
132
138
  $contextVersion: state.contextVersion,
@@ -170,6 +170,13 @@ export interface ConversationState {
170
170
  * cache survives; refreshed only when the cache is already cold.
171
171
  */
172
172
  contextVersion: string | null;
173
+ /**
174
+ * The compaction plan applied on the previous dispatch. Re-applied verbatim
175
+ * each turn (after byte-length validation) so already-shrunk tool results
176
+ * stay shrunk: dropping them re-inflates mid-prefix bytes, which both breaks
177
+ * the prompt cache and un-saves the tokens. Fresh planning only extends it.
178
+ */
179
+ compactionPlan: CompactionEdit[] | null;
173
180
  /** When that block was fetched, for the staleness TTL. */
174
181
  contextFetchedAtMs: number;
175
182
  updatedAtMs: number;
@@ -457,6 +457,10 @@ export async function runTurn(
457
457
  state.spentUsd += reportedUsd ?? decision.forecast.expectedUsd;
458
458
  state.escalations += escalations;
459
459
  state.lastPromptTokens = usage.promptTokens;
460
+ // Persist the plan that was actually dispatched. The next turn re-applies
461
+ // it verbatim (after byte-length validation), keeping shrunk tool results
462
+ // shrunk so the prompt cache survives and the savings compound.
463
+ state.compactionPlan = decision.compactionPlan.length > 0 ? decision.compactionPlan : null;
460
464
  if (usage.cachedTokens > 0 || usage.cacheWriteTokens > 0) {
461
465
  // Non-zero cache traffic is direct evidence the upstream cache exists.
462
466
  state.cacheWarmSlug = servedSlug ?? decision.slug;
@@ -18,7 +18,7 @@ import { mkdirSync } from "node:fs";
18
18
  import { dirname } from "node:path";
19
19
 
20
20
  /** Bump when a migration is added; guarded below so reopening never regresses it. */
21
- const USER_VERSION = 12;
21
+ const USER_VERSION = 13;
22
22
 
23
23
  const MIGRATIONS = `
24
24
  CREATE TABLE IF NOT EXISTS catalog_cache (
@@ -211,6 +211,14 @@ const MIGRATE_V12 = `
211
211
  ALTER TABLE ledger ADD COLUMN prompt_tokens_saved INTEGER;
212
212
  `;
213
213
 
214
+ // v13: conversations persist the last compaction plan (JSON array of
215
+ // CompactionEdit). Re-applying it verbatim each turn keeps already-shrunk tool
216
+ // results shrunk — without it, protectRecentTurns drift drops edits and
217
+ // re-inflates mid-prefix bytes, breaking the prompt cache for zero savings.
218
+ const MIGRATE_V13 = `
219
+ ALTER TABLE conversations ADD COLUMN compaction_plan TEXT;
220
+ `;
221
+
214
222
  // v9: benchmark_cache holds the external benchmark feeds (Artificial Analysis,
215
223
  // BenchLM) that backfill quality scores OpenRouter leaves unpublished. It is a
216
224
  // whole new table, created idempotently by the MIGRATIONS block above, so there
@@ -242,9 +250,10 @@ export function openDb(path: string): Database {
242
250
  if (!ledgerCols.some((c) => c.name === "features")) db.exec(MIGRATE_V6);
243
251
  if (!ledgerCols.some((c) => c.name === "explored_from")) db.exec(MIGRATE_V7);
244
252
  if (!ledgerCols.some((c) => c.name === "hold_arm")) db.exec(MIGRATE_V8);
253
+ if (!ledgerCols.some((c) => c.name === "prompt_tokens_saved")) db.exec(MIGRATE_V12);
245
254
  const convCols = db.query("PRAGMA table_info(conversations)").all() as { name: string }[];
246
255
  if (!convCols.some((c) => c.name === "context_version")) db.exec(MIGRATE_V11);
247
- if (!ledgerCols.some((c) => c.name === "prompt_tokens_saved")) db.exec(MIGRATE_V12);
256
+ if (!convCols.some((c) => c.name === "compaction_plan")) db.exec(MIGRATE_V13);
248
257
  db.exec(`PRAGMA user_version = ${USER_VERSION}`);
249
258
  }
250
259
  return db;
package/src/wire/types.ts CHANGED
@@ -148,6 +148,14 @@ export interface CompactionEdit {
148
148
  keepHead: number;
149
149
  keepTail: number;
150
150
  note: string;
151
+ /**
152
+ * Original (pre-edit) byte length of the targeted message's string content,
153
+ * captured when the edit was planned. Persisted with the plan so a later
154
+ * turn can verify the history it is re-applying to is byte-identical before
155
+ * re-applying — a client-side rewrite or an upstream difference invalidates
156
+ * the edit instead of corrupting the prompt.
157
+ */
158
+ bytes: number;
151
159
  }
152
160
 
153
161
  export type FinishReason = "stop" | "length" | "tool_calls" | "content_filter" | "error";
@@ -1,13 +1,14 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import type { CompactionConfig } from "../src/config/types.ts";
4
- import { planCompaction } from "../src/router/compaction.ts";
4
+ import { planCompaction, validatePlan } from "../src/router/compaction.ts";
5
5
  import { parseChatRequest } from "../src/wire/openai/request.ts";
6
6
  import type { NormMessage } from "../src/wire/types.ts";
7
7
 
8
8
  const CFG: CompactionConfig = {
9
9
  enabled: true,
10
10
  budgetTokens: 1,
11
+ floorRatio: 1,
11
12
  fitToWindow: false,
12
13
  protectRecentTurns: 2,
13
14
  maxToolResultBytes: 50,
@@ -159,7 +160,7 @@ describe("renderUpstreamBody applies compaction", () => {
159
160
  { role: "tool", tool_call_id: "c1", content: "HEAD" + "x".repeat(500) + "TAIL" },
160
161
  ];
161
162
  const req = parseChatRequest(bodyWith(raw), new Headers());
162
- const out = req.renderUpstreamBody({ ...MUT, compactionPlan: [{ index: 2, mode: "truncate", keepHead: 4, keepTail: 4, note: "large read result" }] });
163
+ const out = req.renderUpstreamBody({ ...MUT, compactionPlan: [{ index: 2, mode: "truncate", keepHead: 4, keepTail: 4, note: "large read result", bytes: 508 }] });
163
164
  const messages = out.messages as { role: string; content: unknown }[];
164
165
  expect(messages).toHaveLength(3); // no message removed → pairing intact
165
166
  const content = messages[2]?.content;
@@ -178,8 +179,93 @@ describe("renderUpstreamBody applies compaction", () => {
178
179
  { role: "tool", tool_call_id: "c1", content: "a".repeat(300) },
179
180
  ];
180
181
  const req = parseChatRequest(bodyWith(raw), new Headers());
181
- const out = req.renderUpstreamBody({ ...MUT, compactionPlan: [{ index: 2, mode: "stub", keepHead: 0, keepTail: 0, note: "identical repeated read result" }] });
182
+ const out = req.renderUpstreamBody({ ...MUT, compactionPlan: [{ index: 2, mode: "stub", keepHead: 0, keepTail: 0, note: "identical repeated read result", bytes: 300 }] });
182
183
  const messages = out.messages as { content: string }[];
183
184
  expect(messages[2]?.content).toBe("[omp-router: identical repeated read result elided to save context; re-run the tool to restore]");
184
185
  });
185
186
  });
187
+
188
+ describe("plan byte-stability across turns", () => {
189
+ // The prompt cache is a byte-prefix cache: changing any already-sent byte
190
+ // invalidates everything after it. So an edit, once dispatched, must be
191
+ // re-emitted identically on every later turn — which means the planner has
192
+ // to be told what it already did rather than re-deriving it.
193
+ test("edits carry their original byte length for persistence", () => {
194
+ const msgs = [user("go"), asst("c1", "read", '{"path":"a.ts"}'), toolMsg("c1", "read", big("A")), ...PAD];
195
+ const { edits } = planCompaction(msgs, CFG, 1, 10_000);
196
+ expect(edits).toHaveLength(1);
197
+ expect(edits[0]?.bytes).toBe(Buffer.byteLength(big("A")));
198
+ });
199
+
200
+ test("validatePlan keeps edits whose target is byte-identical and role-correct", () => {
201
+ const msgs = [user("go"), asst("c1", "read", '{"path":"a.ts"}'), toolMsg("c1", "read", big("A")), ...PAD];
202
+ const { edits } = planCompaction(msgs, CFG, 1, 10_000);
203
+ expect(validatePlan(edits, msgs)).toEqual(edits);
204
+ });
205
+
206
+ test("validatePlan drops edits when history changed under them", () => {
207
+ const msgs = [user("go"), asst("c1", "read", '{"path":"a.ts"}'), toolMsg("c1", "read", big("A")), ...PAD];
208
+ const { edits } = planCompaction(msgs, CFG, 1, 10_000);
209
+ // Client re-wrote history: the tool result is a different length now.
210
+ const rewritten = [user("go"), asst("c1", "read", '{"path":"a.ts"}'), toolMsg("c1", "read", "short"), ...PAD];
211
+ expect(validatePlan(edits, rewritten)).toEqual([]);
212
+ });
213
+
214
+ test("validatePlan drops edits that fall off the message array", () => {
215
+ const msgs = [user("go"), asst("c1", "read", '{"path":"a.ts"}'), toolMsg("c1", "read", big("A")), ...PAD];
216
+ const { edits } = planCompaction(msgs, CFG, 1, 10_000);
217
+ // Conversation compacted away client-side: index 2 no longer exists.
218
+ expect(validatePlan(edits, [user("go"), ...PAD.slice(1)])).toEqual([]);
219
+ });
220
+
221
+ test("a carried plan produces identical edits to a fresh plan over the same bytes", () => {
222
+ // Determinism contract: re-planning over unchanged bytes re-derives the
223
+ // persisted plan, so the merge in select.ts is a no-op, not a rewrite.
224
+ const msgs = [
225
+ user("go"),
226
+ asst("c1", "read", '{"path":"a.ts"}'),
227
+ toolMsg("c1", "read", big("A")),
228
+ asst("c2", "read", '{"path":"b.ts"}'),
229
+ toolMsg("c2", "read", big("B")),
230
+ ...PAD,
231
+ ];
232
+ const first = planCompaction(msgs, CFG, 1, 10_000);
233
+ const again = planCompaction(msgs, CFG, 1, 10_000);
234
+ expect(again.edits).toEqual(first.edits);
235
+ });
236
+
237
+ test("a carried edit is re-emitted verbatim even when nothing new is eligible", () => {
238
+ const msgs = [user("go"), asst("c1", "read", '{"path":"a.ts"}'), toolMsg("c1", "read", big("A")), ...PAD];
239
+ const carried = planCompaction(msgs, CFG, 1, 10_000).edits;
240
+ // Target already met, so a stateless planner would emit nothing at all.
241
+ const next = planCompaction(msgs, CFG, 1_000_000, 10_000, carried);
242
+ expect(next.edits).toEqual(carried);
243
+ expect(next.savedBytes).toBeGreaterThan(0);
244
+ });
245
+
246
+ test("carried savings count toward the target, so the planner only adds what is still needed", () => {
247
+ const msgs = [
248
+ user("go"),
249
+ asst("c1", "read", '{"path":"a.ts"}'),
250
+ toolMsg("c1", "read", big("A")),
251
+ asst("c2", "read", '{"path":"b.ts"}'),
252
+ toolMsg("c2", "read", big("B")),
253
+ ...PAD,
254
+ ];
255
+ const promptBytes = 10_000;
256
+ // Carry the first edit, then re-plan with a target the carried edit alone
257
+ // already satisfies: no second edit may be added.
258
+ const carried = [planCompaction(msgs, CFG, 1, promptBytes).edits[0]!];
259
+ const target = promptBytes - (carried[0]!.bytes - CFG.keepHeadBytes - CFG.keepTailBytes - 120);
260
+ const next = planCompaction(msgs, CFG, target, promptBytes, carried);
261
+ expect(next.edits.map((e) => e.index)).toEqual(carried.map((e) => e.index));
262
+ });
263
+
264
+ test("a carried edit is never re-planned into a different shape", () => {
265
+ const msgs = [user("go"), asst("c1", "read", '{"path":"a.ts"}'), toolMsg("c1", "read", big("A")), ...PAD];
266
+ // Carried as a stub; a fresh plan would have chosen truncate.
267
+ const carried = [{ index: 2, mode: "stub" as const, keepHead: 0, keepTail: 0, note: "carried", bytes: Buffer.byteLength(big("A")) }];
268
+ const next = planCompaction(msgs, CFG, 1, 10_000, carried);
269
+ expect(next.edits.filter((e) => e.index === 2)).toEqual(carried);
270
+ });
271
+ });
@@ -16,7 +16,7 @@ import {
16
16
  } from "../omp-extension/embed-logic.ts";
17
17
 
18
18
  describe("resolveEmbedPort", () => {
19
- test("returns 0 (let the OS assign a free port) when AUTO_MODEL_ROUTER_PORT is absent", () => {
19
+ test("returns 0 (let the OS assign a free port) when nothing is configured", () => {
20
20
  expect(resolveEmbedPort(undefined)).toBe(0);
21
21
  expect(resolveEmbedPort("")).toBe(0);
22
22
  });
@@ -31,6 +31,28 @@ describe("resolveEmbedPort", () => {
31
31
  expect(resolveEmbedPort("-1")).toBe(0);
32
32
  expect(resolveEmbedPort("70000")).toBe(0);
33
33
  });
34
+
35
+ // A stable port is what keeps omp's PRE-extension model resolution correct:
36
+ // it reads models.yml before this extension can bind and rewrite it, so an
37
+ // ephemeral port leaves that block naming the previous session's dead port.
38
+ test("uses the configured server.port when no env override is set", () => {
39
+ expect(resolveEmbedPort(undefined, 8788)).toBe(8788);
40
+ expect(resolveEmbedPort("", 8788)).toBe(8788);
41
+ });
42
+
43
+ test("the env var wins over the configured port", () => {
44
+ expect(resolveEmbedPort("8812", 8788)).toBe(8812);
45
+ });
46
+
47
+ test("an explicit env 0 wins, so an ephemeral port stays requestable", () => {
48
+ expect(resolveEmbedPort("0", 8788)).toBe(0);
49
+ });
50
+
51
+ test("ignores a nonsense configured port rather than binding it", () => {
52
+ expect(resolveEmbedPort(undefined, 0)).toBe(0);
53
+ expect(resolveEmbedPort(undefined, -5)).toBe(0);
54
+ expect(resolveEmbedPort(undefined, 70_000)).toBe(0);
55
+ });
34
56
  });
35
57
 
36
58
  describe("embed port file", () => {
@@ -56,6 +56,7 @@ function state(over: Partial<ConversationState> = {}): ConversationState {
56
56
  cacheWarmAtMs: 0,
57
57
  contextVersion: null,
58
58
  contextFetchedAtMs: 0,
59
+ compactionPlan: null,
59
60
  updatedAtMs: NOW,
60
61
  ...over,
61
62
  };
@@ -70,7 +70,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
70
70
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
71
71
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
72
72
  context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
73
- compaction: { enabled: false, budgetTokens: 40_000, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
73
+ compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
74
74
  budget: { onExceeded: "downgrade" },
75
75
  profiles: [],
76
76
  ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
@@ -267,6 +267,7 @@ function mkConversations(): { store: ConversationStore; map: Map<string, Convers
267
267
  cacheWarmAtMs: 0,
268
268
  contextVersion: null,
269
269
  contextFetchedAtMs: 0,
270
+ compactionPlan: null,
270
271
  updatedAtMs: 0,
271
272
  };
272
273
  map.set(k, fresh);
@@ -66,6 +66,7 @@ function state(over: Partial<ConversationState> = {}): ConversationState {
66
66
  cacheWarmAtMs: 0,
67
67
  contextVersion: null,
68
68
  contextFetchedAtMs: 0,
69
+ compactionPlan: null,
69
70
  updatedAtMs: Date.now(),
70
71
  ...over,
71
72
  };
@@ -601,6 +602,7 @@ describe("context compaction", () => {
601
602
  compaction: {
602
603
  enabled: true,
603
604
  budgetTokens: 1_000,
605
+ floorRatio: 1,
604
606
  fitToWindow: false,
605
607
  protectRecentTurns: 1,
606
608
  maxToolResultBytes: 100,
@@ -664,4 +666,80 @@ describe("context compaction", () => {
664
666
  expect(d.compactionPlan).toEqual([]);
665
667
  expect(d.promptTokensSaved).toBe(0);
666
668
  });
669
+
670
+ test("a carried plan is re-applied even when the turn is now under budget", () => {
671
+ // The prompt cache is a byte-prefix cache: dropping an edit that was
672
+ // already dispatched rewrites history the upstream had cached, and
673
+ // re-sends the tokens the edit saved. So a carried plan survives a turn
674
+ // that would not have triggered compaction on its own.
675
+ const req = loopReq();
676
+ const over = extractFeatures(req, 5_000);
677
+ const first = select({
678
+ req,
679
+ features: over,
680
+ classification: scoreHeuristic(over, COMPACT_CFG),
681
+ profile: PROFILE,
682
+ state: state(),
683
+ snapshot: SNAPSHOT,
684
+ ledger: null,
685
+ cfg: COMPACT_CFG,
686
+ nowMs: Date.now(),
687
+ });
688
+ expect(first.compactionPlan.length).toBeGreaterThan(0);
689
+
690
+ const under = extractFeatures(req, 500); // under budgetTokens=1000
691
+ const second = select({
692
+ req,
693
+ features: under,
694
+ classification: scoreHeuristic(under, COMPACT_CFG),
695
+ profile: PROFILE,
696
+ state: state({ compactionPlan: first.compactionPlan }),
697
+ snapshot: SNAPSHOT,
698
+ ledger: null,
699
+ cfg: COMPACT_CFG,
700
+ nowMs: Date.now(),
701
+ });
702
+ expect(second.compactionPlan).toEqual(first.compactionPlan);
703
+ expect(second.promptTokensSaved).toBeGreaterThan(0);
704
+ });
705
+
706
+ test("floorRatio below 1 compacts strictly past the budget so the plan holds longer", () => {
707
+ // Each plan change rewrites cached prompt bytes, so compaction overshoots
708
+ // deliberately: eliding more now buys byte-stable turns later.
709
+ const req = parseChatRequest(
710
+ {
711
+ model: "auto",
712
+ tools: TOOLS,
713
+ messages: [
714
+ { role: "system", content: "You are a coding agent." },
715
+ { role: "user", content: "read the files" },
716
+ ...[1, 2, 3, 4, 5, 6].flatMap((n) => [
717
+ { role: "assistant", content: null, tool_calls: [{ id: `c${n}`, type: "function", function: { name: "read", arguments: `{"path":"f${n}.ts"}` } }] },
718
+ { role: "tool", tool_call_id: `c${n}`, content: `F${n}${"x".repeat(2000)}` },
719
+ ]),
720
+ { role: "user", content: "continue" },
721
+ ],
722
+ },
723
+ new Headers(),
724
+ );
725
+ // Prompt is ~12k bytes; claim 4000 tokens against a 1000-token budget, so
726
+ // floorRatio 1 targets 1000 and floorRatio 0.5 targets 500.
727
+ const features = extractFeatures(req, 4_000);
728
+ const run = (floorRatio: number) =>
729
+ select({
730
+ req,
731
+ features,
732
+ classification: scoreHeuristic(features, COMPACT_CFG),
733
+ profile: PROFILE,
734
+ state: state(),
735
+ snapshot: SNAPSHOT,
736
+ ledger: null,
737
+ cfg: { ...COMPACT_CFG, compaction: { ...COMPACT_CFG.compaction, floorRatio } },
738
+ nowMs: Date.now(),
739
+ });
740
+ const tight = run(0.5);
741
+ const loose = run(1);
742
+ expect(tight.compactionPlan.length).toBeGreaterThan(loose.compactionPlan.length);
743
+ expect(tight.promptTokensSaved).toBeGreaterThan(loose.promptTokensSaved);
744
+ });
667
745
  });
@@ -244,11 +244,11 @@ describe("v4 migration", () => {
244
244
  }
245
245
  });
246
246
 
247
- test("schema is at user_version 12", () => {
247
+ test("schema is at user_version 13", () => {
248
248
  const db = openDb(":memory:");
249
249
  try {
250
250
  const row = db.query("PRAGMA user_version").get() as { user_version: number };
251
- expect(row.user_version).toBe(12);
251
+ expect(row.user_version).toBe(13);
252
252
  } finally {
253
253
  db.close();
254
254
  }
package/test/turn.test.ts CHANGED
@@ -71,7 +71,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
71
71
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
72
72
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
73
73
  context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
74
- compaction: { enabled: false, budgetTokens: 40_000, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
74
+ compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
75
75
  budget: { onExceeded: "downgrade" },
76
76
  profiles: [],
77
77
  ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
@@ -268,6 +268,7 @@ function mkConversations(): {
268
268
  cacheWarmAtMs: 0,
269
269
  contextVersion: null,
270
270
  contextFetchedAtMs: 0,
271
+ compactionPlan: null,
271
272
  updatedAtMs: 0,
272
273
  };
273
274
  map.set(k, fresh);