auto-model-router 0.2.21 → 0.2.22
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.
- package/.claude/skills/agentdox/SKILL.md +17 -0
- package/CLAUDE.md +12 -0
- package/docs/context-optimization.md +32 -6
- package/package.json +1 -1
- package/src/config/defaults.ts +14 -0
- package/src/config/schema.ts +1 -0
- package/src/config/types.ts +7 -0
- package/src/router/compaction.ts +52 -8
- package/src/router/select.ts +40 -14
- package/src/router/state.ts +8 -2
- package/src/router/types.ts +7 -0
- package/src/server/turn.ts +4 -0
- package/src/util/sqlite.ts +11 -2
- package/src/wire/types.ts +8 -0
- package/test/compaction.test.ts +89 -3
- package/test/exploration.test.ts +1 -0
- package/test/failover.test.ts +2 -1
- package/test/select.test.ts +78 -0
- package/test/trust-attribution.test.ts +2 -2
- package/test/turn.test.ts +2 -1
- package/tools/verify-plan-persist.ts +127 -0
|
@@ -180,12 +180,29 @@ while memory, docs, or the brief for the area you touched is stale.
|
|
|
180
180
|
| Architecture / conventions | `docs_update {id, content}` | `PATCH /docs/:id {title?, content?, tags?}` |
|
|
181
181
|
| A genuinely new doc | `docs_write {slug, title, content, scope}` | `POST /docs {slug, title, content, scope}` |
|
|
182
182
|
| List / read docs | `docs_read` · `docs_search` | `GET /docs?scope=<scope>` · `GET /docs/search?q=…` · `GET /docs/slug/:slug` |
|
|
183
|
+
| Find the *part* of a doc that answers something | `docs_passages` | `GET /docs/passages?q=…&scope=<scope>` |
|
|
183
184
|
| A decision you made | `context_brief_record {scope, title, decision, rationale}` | `POST /context/brief/decision {scope, title, decision, rationale}` |
|
|
184
185
|
| Edit brief sections | — | `PUT /context/brief {scope, overview?, repoLayout?, codeStyle?, buildTest?, assetConventions?, gotchas?}` |
|
|
185
186
|
|
|
186
187
|
**Search before you add.** Update the existing entry rather than leaving two contradictory
|
|
187
188
|
facts. Record the *why* of a decision, not just the *what*.
|
|
188
189
|
|
|
190
|
+
## Searching well
|
|
191
|
+
|
|
192
|
+
Retrieval is hybrid — keyword *and* meaning — so you do not have to guess the stored wording.
|
|
193
|
+
Ask in your own words; exact identifiers (`SettlementLayout.Build`, `AGENTDOX_TOKEN`) work too.
|
|
194
|
+
|
|
195
|
+
**Prefer `docs_passages` over `docs_search`** when you want the part of a doc that answers a
|
|
196
|
+
question. `docs_search` hands back whole documents, which then get truncated — and the
|
|
197
|
+
truncation is rarely the relevant part. A passage arrives with its slug and heading, so
|
|
198
|
+
`docs_read` the full doc when the passage is not enough.
|
|
199
|
+
|
|
200
|
+
If results look thin or stale, check `index_stats {scope}` before concluding the store is
|
|
201
|
+
empty: it reports how much of the scope is indexed and whether the embedding provider is
|
|
202
|
+
reachable. `embedded` far below `total`, or an unreachable provider, means you are getting
|
|
203
|
+
keyword-only results. `index_rebuild` fixes an index that has drifted; ordinary writes index
|
|
204
|
+
themselves, so you should rarely need it.
|
|
205
|
+
|
|
189
206
|
## Two inconsistencies that cause silent mistakes
|
|
190
207
|
|
|
191
208
|
1. **Memory uses `category`; everything else uses `scope`.** `memory_add` / `memory_search` /
|
package/CLAUDE.md
CHANGED
|
@@ -28,6 +28,18 @@ another project. Getting `omp-router` right is on you, not on RBAC.
|
|
|
28
28
|
| Server | `http://localhost:3003` — Docker container `agentdox-server` |
|
|
29
29
|
| Admin token (to re-mint the global PAT) | `E:/projects/agentdox/deploy/.env` |
|
|
30
30
|
|
|
31
|
+
**Searching agentdox.** Retrieval is hybrid — BM25 keyword matching fused with embeddings — and
|
|
32
|
+
runs over *passages* of docs, not whole files. So ask in your own words; exact identifiers work
|
|
33
|
+
too. Two habits worth having:
|
|
34
|
+
|
|
35
|
+
- **Prefer `docs_passages` over `docs_search`.** It returns the section that answers the
|
|
36
|
+
question. `docs_search` returns whole documents, which then get truncated, and the truncation
|
|
37
|
+
is rarely the relevant part.
|
|
38
|
+
- **If results look thin, run `index_stats {scope}` before concluding the store is empty.** It
|
|
39
|
+
reports how much of the scope is indexed and whether the embedding provider is reachable;
|
|
40
|
+
`embedded` far below `total`, or an unreachable provider, means you are getting keyword-only
|
|
41
|
+
results.
|
|
42
|
+
|
|
31
43
|
`.env.agentdox` is the durable record; the environment variable is what Claude Code actually
|
|
32
44
|
substitutes into `.mcp.json` at MCP-server startup. If agentdox MCP returns **401**, the
|
|
33
45
|
variable is missing from the environment — re-set it from `.env.agentdox` and restart Claude
|
|
@@ -130,14 +130,14 @@ request → classify (on ORIGINAL turn)
|
|
|
130
130
|
valid — the same reason `injectContextBlock` appends instead of inserts. Phase 2,
|
|
131
131
|
which changes message count, MUST return adjusted indices (see Phase 2).
|
|
132
132
|
|
|
133
|
-
## Trigger (locked: fit + cost budget)
|
|
133
|
+
## Trigger (locked: fit + cost budget), and plan hysteresis
|
|
134
134
|
|
|
135
|
-
Two conditions arm compaction;
|
|
136
|
-
in *whether* to bother:
|
|
135
|
+
Two conditions arm compaction; they differ only in *whether* to bother:
|
|
137
136
|
|
|
138
|
-
- **Budget:**
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
- **Budget:** the **compacted** estimate — i.e. the prompt as it would be dispatched
|
|
138
|
+
with the plan already carried from the previous turn — exceeds
|
|
139
|
+
`compaction.budgetTokens`. The injected agentdox block counts toward the budget (it
|
|
140
|
+
is resolved before render and bounded by `context.maxBlockChars`).
|
|
141
141
|
- **Fit:** the estimate exceeds `model.contextLength × filters.contextHeadroom` (minus
|
|
142
142
|
expected completion) for a model under consideration — so the `context_too_small`
|
|
143
143
|
filter tests each model against the compacted floor rather than the raw size.
|
|
@@ -145,6 +145,32 @@ in *whether* to bother:
|
|
|
145
145
|
Requests below `budgetTokens` and within every viable window are dispatched untouched —
|
|
146
146
|
the common small-prompt path allocates nothing.
|
|
147
147
|
|
|
148
|
+
### The plan is state, not a per-turn derivation
|
|
149
|
+
|
|
150
|
+
A prompt cache is a **byte-prefix** cache: change any byte and everything after it is
|
|
151
|
+
a miss. That makes the compaction plan cache-visible state, subject to two rules.
|
|
152
|
+
|
|
153
|
+
1. **A dispatched edit is permanent and verbatim.** `ConversationState.compactionPlan`
|
|
154
|
+
persists the plan (schema v13, `conversations.compaction_plan`); `select()` re-emits
|
|
155
|
+
it every turn and the planner is *seeded* with it (`planCompaction(..., carried)`),
|
|
156
|
+
so an existing edit is never re-derived into a different shape and never dropped
|
|
157
|
+
when the turn alone would not have triggered compaction. `validatePlan` first checks
|
|
158
|
+
each edit still lands on a tool message of the recorded byte length, so a
|
|
159
|
+
client-side history rewrite invalidates the edit instead of corrupting the prompt.
|
|
160
|
+
Re-applying is safe because omp re-sends the original bytes every turn.
|
|
161
|
+
2. **Re-planning is rationed.** The trigger compares the **compacted** size against the
|
|
162
|
+
budget, and when it fires the planner targets `budgetTokens × compaction.floorRatio`
|
|
163
|
+
rather than stopping just under the budget. Comparing the *raw* size re-planned every
|
|
164
|
+
single turn, so the plan gained one more edit per turn — a cache invalidation per turn
|
|
165
|
+
for a marginal saving.
|
|
166
|
+
|
|
167
|
+
Measured (`tools/verify-plan-persist.ts`, 20-turn agentic conversation): `floorRatio`
|
|
168
|
+
1.0 changes the plan on **10 of 10** compacting turns, 0.75 on **3**, 0.6 on **2**. On
|
|
169
|
+
live ledger data (7 long conversations, 894 compacted dispatches) a changed-plan
|
|
170
|
+
dispatch ran **15.4% cold** vs **8.9%** when the plan held, and a cold prompt costs
|
|
171
|
+
**4.34x** a warm one per token ($0.1839 vs $0.0424 per Mtok). `floorRatio` ships at 1
|
|
172
|
+
(today's behaviour, elision is never implicit); 0.75 is the recommended setting.
|
|
173
|
+
|
|
148
174
|
## Interaction with the agentdox bridge
|
|
149
175
|
|
|
150
176
|
The router already has a shipped context subsystem (`src/context/`, see
|
package/package.json
CHANGED
package/src/config/defaults.ts
CHANGED
|
@@ -193,6 +193,20 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
193
193
|
enabled: false,
|
|
194
194
|
// ~40k tokens: above this the prompt is dominated by re-sent tool output.
|
|
195
195
|
budgetTokens: 40_000,
|
|
196
|
+
// Once compaction fires, compact down to this fraction of the budget
|
|
197
|
+
// instead of stopping just under it. Below 1 the plan overshoots and then
|
|
198
|
+
// holds for several turns; at 1 it gains an edit almost every turn, and
|
|
199
|
+
// every plan change rewrites already-cached prompt bytes.
|
|
200
|
+
//
|
|
201
|
+
// Measured (tools/verify-plan-persist.ts, 20-turn agentic conversation):
|
|
202
|
+
// 1.0 changes the plan on 10 of 10 compacting turns, 0.75 on 3, 0.6 on 2.
|
|
203
|
+
// Live ledger: a changed-plan dispatch runs 15.4% cold vs 8.9% when the
|
|
204
|
+
// plan holds, and a cold prompt costs 4.34x a warm one per token.
|
|
205
|
+
//
|
|
206
|
+
// Ships at 1 because elision is lossy and compaction is never implicit
|
|
207
|
+
// here — the same reason `enabled` is false. 0.75 is the recommended
|
|
208
|
+
// setting once a deployment has watched its own ledger.
|
|
209
|
+
floorRatio: 1,
|
|
196
210
|
fitToWindow: true,
|
|
197
211
|
protectRecentTurns: 4,
|
|
198
212
|
maxToolResultBytes: 4_096,
|
package/src/config/schema.ts
CHANGED
|
@@ -151,6 +151,7 @@ const context = z.strictObject({
|
|
|
151
151
|
const compaction = z.strictObject({
|
|
152
152
|
enabled: z.boolean().optional(),
|
|
153
153
|
budgetTokens: z.number().int().positive().optional(),
|
|
154
|
+
floorRatio: z.number().positive().max(1).optional(),
|
|
154
155
|
fitToWindow: z.boolean().optional(),
|
|
155
156
|
protectRecentTurns: z.number().int().positive().optional(),
|
|
156
157
|
maxToolResultBytes: z.number().int().positive().optional(),
|
package/src/config/types.ts
CHANGED
|
@@ -432,6 +432,13 @@ export interface CompactionConfig {
|
|
|
432
432
|
enabled: boolean;
|
|
433
433
|
/** Compact when the estimated prompt exceeds this many tokens. */
|
|
434
434
|
budgetTokens: number;
|
|
435
|
+
/**
|
|
436
|
+
* Target fraction of `budgetTokens` to compact DOWN to once compaction
|
|
437
|
+
* fires. Below 1 the plan overshoots, so it stays byte-stable for several
|
|
438
|
+
* turns instead of gaining an edit per turn; every plan change rewrites
|
|
439
|
+
* already-cached prompt bytes, and a cold prompt costs ~4.3x a warm one.
|
|
440
|
+
*/
|
|
441
|
+
floorRatio: number;
|
|
435
442
|
/** Also compact when the prompt would overflow the profile's context window. */
|
|
436
443
|
fitToWindow: boolean;
|
|
437
444
|
/** Never touch the last N user/assistant turns or the volatile tail. */
|
package/src/router/compaction.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
}
|
package/src/router/select.ts
CHANGED
|
@@ -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 =
|
|
184
|
+
const overBudget = compactedTokens > cfg.compaction.budgetTokens;
|
|
162
185
|
const overWindow =
|
|
163
|
-
cfg.compaction.fitToWindow &&
|
|
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
|
-
|
|
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) *
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
package/src/router/state.ts
CHANGED
|
@@ -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,
|
package/src/router/types.ts
CHANGED
|
@@ -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;
|
package/src/server/turn.ts
CHANGED
|
@@ -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;
|
package/src/util/sqlite.ts
CHANGED
|
@@ -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 =
|
|
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 (!
|
|
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";
|
package/test/compaction.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/test/exploration.test.ts
CHANGED
package/test/failover.test.ts
CHANGED
|
@@ -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);
|
package/test/select.test.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end verification of compaction plan stability.
|
|
3
|
+
*
|
|
4
|
+
* Two properties, both measured against a real router process over a growing
|
|
5
|
+
* agentic conversation:
|
|
6
|
+
*
|
|
7
|
+
* 1. STABILITY — an edit applied on one turn is re-applied byte-identically on
|
|
8
|
+
* every later turn. Any change to already-sent bytes invalidates the
|
|
9
|
+
* upstream prompt cache from that message onward.
|
|
10
|
+
* 2. CHURN — how many turns change the plan at all. Each change is a cache
|
|
11
|
+
* invalidation; live ledger data puts a changed-plan turn at 15.4% cold vs
|
|
12
|
+
* 8.9% when the plan holds, and a cold prompt costs 4.34x a warm one per
|
|
13
|
+
* token. `compaction.floorRatio` trades a little extra elision for far
|
|
14
|
+
* fewer changes.
|
|
15
|
+
*
|
|
16
|
+
* Run: bun tools/verify-plan-persist.ts [floorRatio]
|
|
17
|
+
*/
|
|
18
|
+
import { mkdtempSync } from "node:fs";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
import { loadConfig } from "../src/config/load.ts";
|
|
23
|
+
import { startServer } from "../src/server/http.ts";
|
|
24
|
+
import { startMockOpenRouter } from "./mock-openrouter.ts";
|
|
25
|
+
|
|
26
|
+
const floorRatio = Number.parseFloat(process.argv[2] ?? "0.75");
|
|
27
|
+
const home = mkdtempSync(join(tmpdir(), "verify-plan-persist-"));
|
|
28
|
+
const mock = await startMockOpenRouter("test/fixtures/openrouter-models.json");
|
|
29
|
+
|
|
30
|
+
const cfg = loadConfig({});
|
|
31
|
+
cfg.server = { host: "127.0.0.1", port: 0 };
|
|
32
|
+
cfg.openrouter.baseUrl = `${mock.url}/api/v1`;
|
|
33
|
+
cfg.openrouter.apiKey = "sk-mock";
|
|
34
|
+
cfg.ledger.path = join(home, "router.db");
|
|
35
|
+
cfg.logLevel = "error";
|
|
36
|
+
cfg.classifier.ambiguityThreshold = 0;
|
|
37
|
+
cfg.benchmarks.enabled = false;
|
|
38
|
+
cfg.context.enabled = false;
|
|
39
|
+
// Scaled-down budget so the fixture behaves like a 40k-budget real conversation.
|
|
40
|
+
cfg.compaction.enabled = true;
|
|
41
|
+
cfg.compaction.budgetTokens = 1_500;
|
|
42
|
+
cfg.compaction.floorRatio = floorRatio;
|
|
43
|
+
cfg.compaction.maxToolResultBytes = 256;
|
|
44
|
+
cfg.compaction.keepHeadBytes = 16;
|
|
45
|
+
cfg.compaction.keepTailBytes = 16;
|
|
46
|
+
|
|
47
|
+
const app = startServer(cfg);
|
|
48
|
+
const base = `http://127.0.0.1:${app.server.port}`;
|
|
49
|
+
|
|
50
|
+
const big = (marker: string): string => `${marker}: ${"payload ".repeat(60)}`;
|
|
51
|
+
const call = (id: string, name: string, args: unknown): unknown => ({
|
|
52
|
+
role: "assistant",
|
|
53
|
+
content: null,
|
|
54
|
+
tool_calls: [{ id, type: "function", function: { name, arguments: JSON.stringify(args) } }],
|
|
55
|
+
});
|
|
56
|
+
const result = (id: string, content: string): unknown => ({ role: "tool", tool_call_id: id, content });
|
|
57
|
+
const cycle = (n: number): unknown[] => [
|
|
58
|
+
call(`c${n}`, "read", { path: `src/file${n}.ts` }),
|
|
59
|
+
result(`c${n}`, big(`READ${n}`)),
|
|
60
|
+
{ role: "assistant", content: `read file${n}` },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
async function dispatch(messages: unknown[]): Promise<{ role: string; content: unknown }[]> {
|
|
64
|
+
const res = await fetch(`${base}/v1/chat/completions`, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { "content-type": "application/json" },
|
|
67
|
+
body: JSON.stringify({ model: "auto", messages, stream: true }),
|
|
68
|
+
});
|
|
69
|
+
await res.text();
|
|
70
|
+
const body = mock.requests.at(-1)?.body as Record<string, unknown>;
|
|
71
|
+
return body.messages as { role: string; content: unknown }[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const fail = (label: string, detail?: unknown): never => {
|
|
75
|
+
console.error(`FAIL ${label}`, detail === undefined ? "" : JSON.stringify(detail).slice(0, 500));
|
|
76
|
+
process.exit(1);
|
|
77
|
+
};
|
|
78
|
+
const shrunkOf = (msgs: { content: unknown }[]): Map<number, string> => {
|
|
79
|
+
const out = new Map<number, string>();
|
|
80
|
+
msgs.forEach((m, i) => {
|
|
81
|
+
if (typeof m.content === "string" && m.content.includes("omp-router: elided")) out.set(i, m.content);
|
|
82
|
+
});
|
|
83
|
+
return out;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// A 20-cycle conversation, dispatched turn by turn exactly as omp would: the
|
|
87
|
+
// full history every time, one cycle longer each turn.
|
|
88
|
+
const TURNS = 20;
|
|
89
|
+
let history: unknown[] = [{ role: "user", content: "audit the project" }];
|
|
90
|
+
let prev = new Map<number, string>();
|
|
91
|
+
let changes = 0;
|
|
92
|
+
let firstPlanTurn = 0;
|
|
93
|
+
|
|
94
|
+
for (let n = 1; n <= TURNS; n++) {
|
|
95
|
+
history = [...history, ...cycle(n)];
|
|
96
|
+
const msgs = await dispatch(history);
|
|
97
|
+
const shrunk = shrunkOf(msgs);
|
|
98
|
+
|
|
99
|
+
// STABILITY: every previously-shrunk message must still be shrunk, with the
|
|
100
|
+
// same bytes. A dropped or altered edit rewrites the cached prefix.
|
|
101
|
+
for (const [i, content] of prev) {
|
|
102
|
+
const now = shrunk.get(i);
|
|
103
|
+
if (now === undefined) fail(`turn ${n}: edit at message ${i} was DROPPED (bytes re-inflated)`, { turn: n, i });
|
|
104
|
+
if (now !== content) fail(`turn ${n}: edit at message ${i} changed bytes`, { was: content.slice(0, 90), now: now.slice(0, 90) });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const added = [...shrunk.keys()].filter((i) => !prev.has(i));
|
|
108
|
+
if (added.length > 0) {
|
|
109
|
+
changes++;
|
|
110
|
+
if (firstPlanTurn === 0) firstPlanTurn = n;
|
|
111
|
+
console.log(`turn ${String(n).padStart(2)}: plan CHANGED (+${added.length} edits, ${shrunk.size} total)`);
|
|
112
|
+
} else if (shrunk.size > 0) {
|
|
113
|
+
console.log(`turn ${String(n).padStart(2)}: plan held (${shrunk.size} edits)`);
|
|
114
|
+
} else {
|
|
115
|
+
console.log(`turn ${String(n).padStart(2)}: no compaction`);
|
|
116
|
+
}
|
|
117
|
+
prev = shrunk;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const planningTurns = TURNS - firstPlanTurn + 1;
|
|
121
|
+
console.log(`\nfloorRatio ${floorRatio}`);
|
|
122
|
+
console.log(`PASS stability: no edit was ever dropped or rewritten across ${TURNS} turns`);
|
|
123
|
+
console.log(`plan changes: ${changes} over ${planningTurns} compacting turns (${((changes / planningTurns) * 100).toFixed(0)}% of turns invalidate cache)`);
|
|
124
|
+
|
|
125
|
+
app.stop(true);
|
|
126
|
+
await mock.stop();
|
|
127
|
+
process.exit(0);
|