auto-model-router 0.2.20 → 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/.omp-plugin/marketplace.json +2 -2
- 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/hot-reload.ts +163 -0
- package/src/config/load.ts +1 -1
- 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/http.ts +24 -1
- 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/hot-reload.test.ts +131 -0
- package/test/select.test.ts +78 -0
- package/test/tier-plan.test.ts +6 -2
- 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` /
|
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
|
|
10
|
-
"version": "0.2.
|
|
10
|
+
"version": "0.2.21",
|
|
11
11
|
"pluginRoot": "."
|
|
12
12
|
},
|
|
13
13
|
"plugins": [
|
|
14
14
|
{
|
|
15
15
|
"name": "auto-model-router",
|
|
16
16
|
"description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
|
|
17
|
-
"version": "0.2.
|
|
17
|
+
"version": "0.2.21",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
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,
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hot reload for `config.yml`.
|
|
3
|
+
*
|
|
4
|
+
* The router is long-lived (an omp session embeds it), and the ranking knobs —
|
|
5
|
+
* tiers, filters, escalation, budgets, hysteresis — are exactly what a tuning
|
|
6
|
+
* session wants to change without restarting the harness. This module watches
|
|
7
|
+
* the config file, re-validates it through the same schema `loadConfig` uses,
|
|
8
|
+
* and mutates the SHARED config object in place.
|
|
9
|
+
*
|
|
10
|
+
* In-place mutation is the design: every consumer reads `cfg.tiers`,
|
|
11
|
+
* `cfg.filters`, `cfg.escalation` … at call time through the same object
|
|
12
|
+
* reference, so field assignment makes every per-turn read live with zero
|
|
13
|
+
* call-site changes. What is deliberately NOT reloaded is anything captured at
|
|
14
|
+
* construction — the listening socket (server.*), the OpenRouter client
|
|
15
|
+
* (openrouter.*), and the agentdox bridge (context.*). Those still require a
|
|
16
|
+
* restart; the watcher re-pinns them from the live object and reports skips.
|
|
17
|
+
*
|
|
18
|
+
* Safety properties:
|
|
19
|
+
* - Schema validation BEFORE any mutation; an invalid file leaves the running
|
|
20
|
+
* config untouched and logs the zod issues, exactly like loadConfig.
|
|
21
|
+
* - fs.watch fires several times per save; a trailing debounce collapses them.
|
|
22
|
+
* - A half-written or invalid file never throws into the watcher: the reload
|
|
23
|
+
* is skipped and the previous config keeps serving.
|
|
24
|
+
* - A deleted or emptied knob reverts to its DEFAULT, mirroring loadConfig's
|
|
25
|
+
* merge order (defaults <- file): the file is the source of truth, so
|
|
26
|
+
* disabling a feature by deleting its key works.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { existsSync, readFileSync, watch, type FSWatcher } from "node:fs";
|
|
30
|
+
import { parse as parseYaml } from "yaml";
|
|
31
|
+
import { configInputSchema } from "./schema.ts";
|
|
32
|
+
import { DEFAULT_CONFIG } from "./defaults.ts";
|
|
33
|
+
import { deepMerge, resolveTilde } from "./load.ts";
|
|
34
|
+
import type { RouterConfig } from "./types.ts";
|
|
35
|
+
|
|
36
|
+
/** Milliseconds of quiet after the last fs event before a reload actually runs. */
|
|
37
|
+
const DEBOUNCE_MS = 250;
|
|
38
|
+
|
|
39
|
+
/** The validated file input, or why it could not be used. */
|
|
40
|
+
export type ConfigRead =
|
|
41
|
+
| { ok: true; cfg: RouterConfig }
|
|
42
|
+
| { ok: false; error: string };
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Re-reads and schema-validates the config file. Exported for tests: this is
|
|
46
|
+
* the exact gate a file must pass before it may touch the running config.
|
|
47
|
+
*
|
|
48
|
+
* The result is merged over DEFAULT_CONFIG — the file is the full source of
|
|
49
|
+
* truth, so a knob REMOVED from the file reverts to its default, matching what
|
|
50
|
+
* a restart would do. `server`/`openrouter`/`context` come back too, but the
|
|
51
|
+
* applier re-pinns those blocks from the live object, since they were captured
|
|
52
|
+
* by construction.
|
|
53
|
+
*/
|
|
54
|
+
export function readValidatedConfig(path: string): ConfigRead {
|
|
55
|
+
try {
|
|
56
|
+
if (!existsSync(path)) return { ok: false, error: "file missing" };
|
|
57
|
+
const raw: unknown = parseYaml(readFileSync(path, "utf8"));
|
|
58
|
+
if (raw === null || raw === undefined) return { ok: false, error: "file empty" };
|
|
59
|
+
const parsed = configInputSchema.safeParse(raw);
|
|
60
|
+
if (!parsed.success) {
|
|
61
|
+
const lines = parsed.error.issues
|
|
62
|
+
.slice(0, 5)
|
|
63
|
+
.map((issue) => ` - ${issue.path.join(".")}: ${issue.message}`)
|
|
64
|
+
.join("\n");
|
|
65
|
+
return { ok: false, error: `schema validation failed:\n${lines}` };
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, cfg: deepMerge(DEFAULT_CONFIG, parsed.data) };
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What changed in a reload, for logging. */
|
|
74
|
+
export interface ReloadSummary {
|
|
75
|
+
changed: string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A live config file watcher. */
|
|
79
|
+
export interface ConfigWatcher {
|
|
80
|
+
/** Stops watching. Idempotent. */
|
|
81
|
+
close(): void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Options for watchConfig. */
|
|
85
|
+
export interface WatchConfigOptions {
|
|
86
|
+
/** Called after a successful in-place reload that changed something. */
|
|
87
|
+
onReload?: (summary: ReloadSummary) => void;
|
|
88
|
+
/** Called when a file could not be applied (invalid, unreadable). */
|
|
89
|
+
onError?: (message: string) => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Watches `path` and applies valid changes to `live` in place. `frozen` blocks
|
|
94
|
+
* (top-level names) are re-copied from `pinned` after every reload so file
|
|
95
|
+
* edits to construction-captured blocks cannot silently diverge.
|
|
96
|
+
*/
|
|
97
|
+
export function watchConfig(
|
|
98
|
+
path: string,
|
|
99
|
+
live: RouterConfig,
|
|
100
|
+
pinned: RouterConfig,
|
|
101
|
+
frozen: readonly (keyof RouterConfig)[],
|
|
102
|
+
opts: WatchConfigOptions = {},
|
|
103
|
+
): ConfigWatcher {
|
|
104
|
+
let closed = false;
|
|
105
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
106
|
+
let lastError = "";
|
|
107
|
+
|
|
108
|
+
const apply = (): void => {
|
|
109
|
+
if (closed) return;
|
|
110
|
+
const result = readValidatedConfig(path);
|
|
111
|
+
if (!result.ok) {
|
|
112
|
+
// A half-written file is normal (editors truncate-then-write): stay on
|
|
113
|
+
// the current config. Report each distinct error once.
|
|
114
|
+
if (result.error !== lastError) {
|
|
115
|
+
lastError = result.error;
|
|
116
|
+
opts.onError?.(result.error);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
lastError = "";
|
|
121
|
+
|
|
122
|
+
const frozenSet = new Set(frozen);
|
|
123
|
+
const changed: string[] = [];
|
|
124
|
+
const next = result.cfg as unknown as Record<string, unknown>;
|
|
125
|
+
for (const key of Object.keys(next)) {
|
|
126
|
+
// Frozen blocks belong to construction: keep the pinned values.
|
|
127
|
+
const value = frozenSet.has(key as keyof RouterConfig)
|
|
128
|
+
? (pinned as unknown as Record<string, unknown>)[key]
|
|
129
|
+
: next[key];
|
|
130
|
+
const before = JSON.stringify((live as unknown as Record<string, unknown>)[key]);
|
|
131
|
+
const after = JSON.stringify(value);
|
|
132
|
+
if (before !== after) changed.push(key);
|
|
133
|
+
(live as unknown as Record<string, unknown>)[key] = value;
|
|
134
|
+
}
|
|
135
|
+
if (changed.length > 0) opts.onReload?.({ changed });
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const schedule = (): void => {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
timer = setTimeout(() => {
|
|
141
|
+
timer = undefined;
|
|
142
|
+
apply();
|
|
143
|
+
}, DEBOUNCE_MS);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
let watcher: FSWatcher | null = null;
|
|
147
|
+
try {
|
|
148
|
+
watcher = watch(resolveTilde(path), { persistent: false }, schedule);
|
|
149
|
+
} catch {
|
|
150
|
+
// Unwatchable file is not fatal: the router keeps its boot config.
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
close() {
|
|
155
|
+
closed = true;
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
timer = undefined;
|
|
158
|
+
watcher?.close();
|
|
159
|
+
watcher = null;
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
package/src/config/load.ts
CHANGED
|
@@ -36,7 +36,7 @@ function mergeValue(base: unknown, override: unknown): unknown {
|
|
|
36
36
|
return override;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
function deepMerge(base: RouterConfig, override: unknown): RouterConfig {
|
|
39
|
+
export function deepMerge(base: RouterConfig, override: unknown): RouterConfig {
|
|
40
40
|
return mergeValue(base, override) as RouterConfig;
|
|
41
41
|
}
|
|
42
42
|
|
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/http.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { createConversationStore } from "../router/state.ts";
|
|
|
10
10
|
import { createOpenRouterClient } from "../upstream/openrouter.ts";
|
|
11
11
|
import { UpstreamError } from "../upstream/types.ts";
|
|
12
12
|
import { apiKeySource } from "../config/load.ts";
|
|
13
|
+
import { routerConfigPath } from "../cli/config-cmd.ts";
|
|
14
|
+
import { watchConfig } from "../config/hot-reload.ts";
|
|
13
15
|
import type { RouterConfig } from "../config/types.ts";
|
|
14
16
|
import { createLogger } from "../util/log.ts";
|
|
15
17
|
import { openDb } from "../util/sqlite.ts";
|
|
@@ -177,6 +179,27 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
177
179
|
const context = createBridgeFromConfig(cfg, db);
|
|
178
180
|
const turnDeps = { config: cfg, router, upstream, ledger, conversations, catalog, context };
|
|
179
181
|
|
|
182
|
+
// Hot reload: ranking knobs (tiers, filters, escalation, budgets, …) take
|
|
183
|
+
// effect on the next turn without a restart, because every consumer reads
|
|
184
|
+
// the shared config object at call time. Construction-captured blocks
|
|
185
|
+
// (server socket, OpenRouter client, agentdox bridge) are pinned — editing
|
|
186
|
+
// those still requires a restart, and the watcher says so explicitly.
|
|
187
|
+
const pinned = { ...cfg };
|
|
188
|
+
const configWatcher = watchConfig(
|
|
189
|
+
routerConfigPath(),
|
|
190
|
+
cfg,
|
|
191
|
+
pinned,
|
|
192
|
+
["server", "openrouter", "context", "ledger"],
|
|
193
|
+
{
|
|
194
|
+
onReload: ({ changed }) => {
|
|
195
|
+
log.info("config reloaded", { changed: changed.join(", ") });
|
|
196
|
+
},
|
|
197
|
+
onError: (message) => {
|
|
198
|
+
log.warn("config reload rejected; keeping the running config", { error: message });
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
|
|
180
203
|
if (context.enabled) {
|
|
181
204
|
log.info("agentdox context bridge enabled", {
|
|
182
205
|
url: cfg.context.baseUrl,
|
|
@@ -355,8 +378,8 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
355
378
|
return {
|
|
356
379
|
server,
|
|
357
380
|
stop: async () => {
|
|
381
|
+
configWatcher.close();
|
|
358
382
|
clearInterval(pruneTimer);
|
|
359
|
-
clearInterval(catalogRefreshTimer);
|
|
360
383
|
await server.stop(true);
|
|
361
384
|
// Drain queued agentdox write-backs before the DB closes under them.
|
|
362
385
|
context.close();
|
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);
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
5
|
+
import type { RouterConfig } from "../src/config/types.ts";
|
|
6
|
+
import { readValidatedConfig, watchConfig, type ConfigWatcher } from "../src/config/hot-reload.ts";
|
|
7
|
+
|
|
8
|
+
const DIR = join(import.meta.dir, ".tmp-hot-reload");
|
|
9
|
+
const CFG = join(DIR, "config.yml");
|
|
10
|
+
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
rmSync(DIR, { recursive: true, force: true });
|
|
13
|
+
mkdirSync(DIR, { recursive: true });
|
|
14
|
+
});
|
|
15
|
+
afterAll(() => {
|
|
16
|
+
rmSync(DIR, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** A clone of the shipped defaults serialized as YAML via JSON (the schema accepts JSON). */
|
|
20
|
+
function yamlOf(partial: Record<string, unknown>): string {
|
|
21
|
+
const lines: string[] = [];
|
|
22
|
+
for (const [k, v] of Object.entries(partial)) {
|
|
23
|
+
if (typeof v === "object" && v !== null) {
|
|
24
|
+
lines.push(`${k}:`);
|
|
25
|
+
for (const [k2, v2] of Object.entries(v)) {
|
|
26
|
+
lines.push(` ${k2}: ${JSON.stringify(v2).replaceAll('"', v2 === true || v2 === false || typeof v2 === "number" ? "" : '"')}`);
|
|
27
|
+
}
|
|
28
|
+
} else {
|
|
29
|
+
lines.push(`${k}: ${JSON.stringify(v)}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return lines.join("\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Waits out the watcher's debounce. */
|
|
36
|
+
const settle = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 400));
|
|
37
|
+
|
|
38
|
+
describe("readValidatedConfig", () => {
|
|
39
|
+
test("accepts a valid partial and merges over defaults (removed knobs revert)", () => {
|
|
40
|
+
writeFileSync(CFG, yamlOf({ filters: { latencyWeight: 0.5 } }));
|
|
41
|
+
const result = readValidatedConfig(CFG);
|
|
42
|
+
expect(result.ok).toBe(true);
|
|
43
|
+
if (!result.ok) return;
|
|
44
|
+
expect(result.cfg.filters.latencyWeight).toBe(0.5);
|
|
45
|
+
// Untouched knobs carry the shipped default, not garbage.
|
|
46
|
+
expect(result.cfg.filters.contextHeadroom).toBe(DEFAULT_CONFIG.filters.contextHeadroom);
|
|
47
|
+
// A tier not mentioned in the file keeps its default shape.
|
|
48
|
+
expect(result.cfg.tiers.simple).toEqual(DEFAULT_CONFIG.tiers.simple);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("rejects a schema violation and names the path", () => {
|
|
52
|
+
writeFileSync(CFG, yamlOf({ tiers: { hard: { capabilityFloorUsd: -5 } } }));
|
|
53
|
+
const result = readValidatedConfig(CFG);
|
|
54
|
+
expect(result.ok).toBe(false);
|
|
55
|
+
if (result.ok) return;
|
|
56
|
+
expect(result.error).toContain("capabilityFloorUsd");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("rejects malformed YAML", () => {
|
|
60
|
+
writeFileSync(CFG, "filters: [unclosed");
|
|
61
|
+
const result = readValidatedConfig(CFG);
|
|
62
|
+
expect(result.ok).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("reports a missing file", () => {
|
|
66
|
+
const result = readValidatedConfig(join(DIR, "nope.yml"));
|
|
67
|
+
expect(result.ok).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("watchConfig", () => {
|
|
72
|
+
const live: RouterConfig = structuredClone(DEFAULT_CONFIG);
|
|
73
|
+
let watcher: ConfigWatcher | null = null;
|
|
74
|
+
const reloads: string[][] = [];
|
|
75
|
+
const errors: string[] = [];
|
|
76
|
+
|
|
77
|
+
beforeAll(() => {
|
|
78
|
+
writeFileSync(CFG, "");
|
|
79
|
+
watcher = watchConfig(CFG, live, structuredClone(DEFAULT_CONFIG), ["server", "openrouter", "context", "ledger"], {
|
|
80
|
+
onReload: ({ changed }) => reloads.push(changed),
|
|
81
|
+
onError: (message) => errors.push(message),
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
afterAll(() => watcher?.close());
|
|
85
|
+
|
|
86
|
+
test("a valid edit mutates the live object in place, no restart", async () => {
|
|
87
|
+
writeFileSync(CFG, yamlOf({ tiers: { hard: { capabilityFloorUsd: 0.35 } } }));
|
|
88
|
+
await settle();
|
|
89
|
+
expect(live.tiers.hard.capabilityFloorUsd).toBe(0.35);
|
|
90
|
+
expect(reloads.flat()).toContain("tiers");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a second edit replaces the value and reverting restores the default", async () => {
|
|
94
|
+
writeFileSync(CFG, yamlOf({ tiers: { hard: { capabilityFloorUsd: 0.65 } } }));
|
|
95
|
+
await settle();
|
|
96
|
+
expect(live.tiers.hard.capabilityFloorUsd).toBe(0.65);
|
|
97
|
+
// Deleting the knob reverts to the shipped default, mirroring a restart.
|
|
98
|
+
writeFileSync(CFG, yamlOf({ filters: { latencyWeight: 0.4 } }));
|
|
99
|
+
await settle();
|
|
100
|
+
expect(live.tiers.hard.capabilityFloorUsd).toBeUndefined();
|
|
101
|
+
expect(live.filters.latencyWeight).toBe(0.4);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("frozen blocks are pinned: file edits to them cannot reach the live object", async () => {
|
|
105
|
+
writeFileSync(CFG, yamlOf({ server: { port: 1, host: "10.9.9.9" }, filters: { latencyWeight: 0.3 } }));
|
|
106
|
+
await settle();
|
|
107
|
+
expect(live.server.port).toBe(DEFAULT_CONFIG.server.port);
|
|
108
|
+
expect(live.server.host).toBe(DEFAULT_CONFIG.server.host);
|
|
109
|
+
// The non-frozen sibling still applied.
|
|
110
|
+
expect(live.filters.latencyWeight).toBe(0.3);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("an invalid file is rejected and the running config keeps serving", async () => {
|
|
114
|
+
const before = structuredClone(live.filters);
|
|
115
|
+
const tierBefore = structuredClone(live.tiers.hard);
|
|
116
|
+
// capabilityFloorUsd must be strictly positive: -1 is a schema violation.
|
|
117
|
+
writeFileSync(CFG, yamlOf({ tiers: { hard: { capabilityFloorUsd: -1 } } }));
|
|
118
|
+
await settle();
|
|
119
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
120
|
+
expect(errors.at(-1)).toContain("capabilityFloorUsd");
|
|
121
|
+
// The live object keeps the last-good values.
|
|
122
|
+
expect(live.tiers.hard.capabilityFloorUsd).toBe(tierBefore.capabilityFloorUsd);
|
|
123
|
+
expect(live.filters.latencyWeight).toBe(before.latencyWeight);
|
|
124
|
+
});
|
|
125
|
+
test("close() stops watching: later edits are ignored", async () => {
|
|
126
|
+
watcher?.close();
|
|
127
|
+
writeFileSync(CFG, yamlOf({ filters: { latencyWeight: 9.9 } }));
|
|
128
|
+
await settle();
|
|
129
|
+
expect(live.filters.latencyWeight).not.toBe(9.9);
|
|
130
|
+
});
|
|
131
|
+
});
|
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
|
});
|
package/test/tier-plan.test.ts
CHANGED
|
@@ -2,14 +2,18 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
|
|
3
3
|
import { joinBenchmarks, normalizeCatalogModel } from "../src/catalog/openrouter-catalog.ts";
|
|
4
4
|
import type { CatalogModel, CatalogSnapshot } from "../src/catalog/types.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
6
6
|
import { buildCandidates } from "../src/router/candidates.ts";
|
|
7
7
|
import { extractFeatures } from "../src/router/features.ts";
|
|
8
8
|
import { computeTierPlan, effectivePriceCeiling, effectiveQualityFloor, tierPlanFor } from "../src/router/tier-plan.ts";
|
|
9
9
|
import { TIER_ORDER } from "../src/router/types.ts";
|
|
10
10
|
import { parseChatRequest } from "../src/wire/openai/request.ts";
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// SHIPPED defaults, deliberately NOT loadConfig({}): that reads the developer's
|
|
13
|
+
// live ~/.auto-model-router/config.yml, so an enabled machine-wide knob (e.g.
|
|
14
|
+
// tiers.hard.capabilityFloorUsd during the 0.2.20 rollout) silently changed
|
|
15
|
+
// these expectations and made the suite machine-dependent.
|
|
16
|
+
const BASE = DEFAULT_CONFIG;
|
|
13
17
|
|
|
14
18
|
/** Raw `/models`-shaped record with a controllable coding score and price. */
|
|
15
19
|
function raw(id: string, coding: number | null, inPerMtok: number): Record<string, unknown> {
|
|
@@ -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);
|