auto-model-router 0.2.21 → 0.2.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/agentdox/SKILL.md +20 -2
- package/CLAUDE.md +12 -0
- package/README.md +87 -0
- package/docs/context-optimization.md +32 -6
- package/docs/routing-benchmark-findings.md +81 -40
- package/omp-extension/embed-logic.ts +18 -6
- package/omp-extension/router-embed.ts +36 -12
- 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/embed-logic.test.ts +23 -1
- 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-embed-port.ts +91 -0
- package/tools/verify-plan-persist.ts +127 -0
|
@@ -43,7 +43,8 @@ Deterministic, so the same folder always resolves to the same slug:
|
|
|
43
43
|
stop and ask for a distinguishing slug.
|
|
44
44
|
- **Near match** (`my-app` vs `my-app-v2`) — a stop sign: ask, rather than fork a second
|
|
45
45
|
namespace for one project.
|
|
46
|
-
4. `project_ensure {slug
|
|
46
|
+
4. `project_ensure {slug}` — `name` is optional and only used if the project is new; it
|
|
47
|
+
defaults to the slug, so pass one only when the readable form differs.
|
|
47
48
|
5. **Make `.env.agentdox` un-committable *before* writing it.** Run `git check-ignore -v
|
|
48
49
|
.env.agentdox`. If it is not ignored, **add `.env.agentdox` to `.gitignore`** (create that
|
|
49
50
|
file if there is none) — do not merely check and move on. Patterns like `.env` and
|
|
@@ -136,7 +137,7 @@ await fetch("http://localhost:3003/memory", { method: "POST", headers: H,
|
|
|
136
137
|
|
|
137
138
|
| Step | MCP | REST |
|
|
138
139
|
| --- | --- | --- |
|
|
139
|
-
| Ensure the project | `project_ensure {slug
|
|
140
|
+
| Ensure the project | `project_ensure {slug}` | `POST /projects {slug}` (idempotent) |
|
|
140
141
|
| Read the brief | `context_brief {scope}` | `GET /context/brief?scope=<scope>` |
|
|
141
142
|
|
|
142
143
|
The brief is the cumulative on-ramp: overview, repo layout, code style, build/test,
|
|
@@ -180,12 +181,29 @@ while memory, docs, or the brief for the area you touched is stale.
|
|
|
180
181
|
| Architecture / conventions | `docs_update {id, content}` | `PATCH /docs/:id {title?, content?, tags?}` |
|
|
181
182
|
| A genuinely new doc | `docs_write {slug, title, content, scope}` | `POST /docs {slug, title, content, scope}` |
|
|
182
183
|
| List / read docs | `docs_read` · `docs_search` | `GET /docs?scope=<scope>` · `GET /docs/search?q=…` · `GET /docs/slug/:slug` |
|
|
184
|
+
| Find the *part* of a doc that answers something | `docs_passages` | `GET /docs/passages?q=…&scope=<scope>` |
|
|
183
185
|
| A decision you made | `context_brief_record {scope, title, decision, rationale}` | `POST /context/brief/decision {scope, title, decision, rationale}` |
|
|
184
186
|
| Edit brief sections | — | `PUT /context/brief {scope, overview?, repoLayout?, codeStyle?, buildTest?, assetConventions?, gotchas?}` |
|
|
185
187
|
|
|
186
188
|
**Search before you add.** Update the existing entry rather than leaving two contradictory
|
|
187
189
|
facts. Record the *why* of a decision, not just the *what*.
|
|
188
190
|
|
|
191
|
+
## Searching well
|
|
192
|
+
|
|
193
|
+
Retrieval is hybrid — keyword *and* meaning — so you do not have to guess the stored wording.
|
|
194
|
+
Ask in your own words; exact identifiers (`SettlementLayout.Build`, `AGENTDOX_TOKEN`) work too.
|
|
195
|
+
|
|
196
|
+
**Prefer `docs_passages` over `docs_search`** when you want the part of a doc that answers a
|
|
197
|
+
question. `docs_search` hands back whole documents, which then get truncated — and the
|
|
198
|
+
truncation is rarely the relevant part. A passage arrives with its slug and heading, so
|
|
199
|
+
`docs_read` the full doc when the passage is not enough.
|
|
200
|
+
|
|
201
|
+
If results look thin or stale, check `index_stats {scope}` before concluding the store is
|
|
202
|
+
empty: it reports how much of the scope is indexed and whether the embedding provider is
|
|
203
|
+
reachable. `embedded` far below `total`, or an unreachable provider, means you are getting
|
|
204
|
+
keyword-only results. `index_rebuild` fixes an index that has drifted; ordinary writes index
|
|
205
|
+
themselves, so you should rarely need it.
|
|
206
|
+
|
|
189
207
|
## Two inconsistencies that cause silent mistakes
|
|
190
208
|
|
|
191
209
|
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
|
package/README.md
CHANGED
|
@@ -32,6 +32,93 @@ This router exists for the things a prompt classifier structurally cannot do:
|
|
|
32
32
|
| **Closed-loop trust** | Per-model escalation and error rates from *your* traffic demote cheap-but-flaky models automatically. |
|
|
33
33
|
| **Explainability** | Every decision — candidates, rejections, forecasts, reasons — is persisted and replayable via `auto-model-router explain`. |
|
|
34
34
|
|
|
35
|
+
## Measured against Claude Opus 5
|
|
36
|
+
|
|
37
|
+
Five benchmark runs, 88 graded task runs, 2026-08-29. Each task is a real omp
|
|
38
|
+
session working in a pristine git workspace from a written spec. Hidden tests are
|
|
39
|
+
copied in only *after* the agent exits, so they cannot be read or edited by it;
|
|
40
|
+
every task is verified to fail an untouched workspace and to pass a reference
|
|
41
|
+
solution. Both arms are metered from omp's own event stream, run under an
|
|
42
|
+
identical tool surface, and are checked per turn against their expected provider.
|
|
43
|
+
The router arm routes freely — nothing pinned. The baseline is `claude-opus-5`
|
|
44
|
+
on Anthropic first-party.
|
|
45
|
+
|
|
46
|
+
### Core suite — 10 coding tasks × 3 trials
|
|
47
|
+
|
|
48
|
+
| | auto-model-router | Claude Opus 5 |
|
|
49
|
+
| --- | --- | --- |
|
|
50
|
+
| Tasks solved | **30 / 30** | 30 / 30 |
|
|
51
|
+
| Total cost | **$0.63** | $16.61 |
|
|
52
|
+
| Cost per solved task | **$0.0209** | $0.5538 |
|
|
53
|
+
| Turns to finish | **278** | 303 |
|
|
54
|
+
| Tool calls | **265** | 337 |
|
|
55
|
+
| Wall clock | **2 057 s** | 3 185 s |
|
|
56
|
+
| Median time to first token | 5 776 ms | **1 490 ms** |
|
|
57
|
+
|
|
58
|
+
**26.5× cheaper at identical correctness** — and in fewer turns, fewer tool
|
|
59
|
+
calls, and 19 minutes less wall clock. The saving is not bought by grinding out
|
|
60
|
+
extra turns. The one regression is time to first token: a routed turn pays for
|
|
61
|
+
classification and dispatch before anything streams back.
|
|
62
|
+
|
|
63
|
+
Per task the ratio ranges from 9× to 264×. The widest gaps are tasks where the
|
|
64
|
+
single-model baseline entered long tool loops — `semver` and `queue-order` cost
|
|
65
|
+
it $2.99 each across three trials against a $1.32 median, 36% of its entire bill.
|
|
66
|
+
|
|
67
|
+
### Difficulty ladder — 7 rungs, run twice
|
|
68
|
+
|
|
69
|
+
A second suite of deliberately escalating difficulty, ending in npm semver range
|
|
70
|
+
semantics and a minimal diff with a specified tie-break.
|
|
71
|
+
|
|
72
|
+
| | auto-model-router | Claude Opus 5 |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| Run 1 | 5 / 7 · $0.30 | 5 / 7 · $6.25 |
|
|
75
|
+
| Run 2 | 5 / 7 · $0.46 | **6 / 7** · $6.60 |
|
|
76
|
+
|
|
77
|
+
At the top of the ladder the engines separate: they fail different rungs, and on
|
|
78
|
+
the second run the single-model baseline finished one more. Both arms timed out
|
|
79
|
+
on the semver rung at the 10-minute cap.
|
|
80
|
+
|
|
81
|
+
### What it routed to
|
|
82
|
+
|
|
83
|
+
Across 464 routed turns in all five runs:
|
|
84
|
+
|
|
85
|
+
| Model | Turns | Input price | Role |
|
|
86
|
+
| --- | --- | --- | --- |
|
|
87
|
+
| `z-ai/glm-5.3-flash` | 389 (84%) | $0.07 / MTok | default |
|
|
88
|
+
| `google/gemini-3.7-flash` | 56 (12%) | $0.75 / MTok | escalation target |
|
|
89
|
+
| `x-ai/grok-4.6` | 18 (4%) | $2.00 / MTok | escalation target |
|
|
90
|
+
|
|
91
|
+
**Tier escalation converts to a costlier model roughly one-for-one**: on the
|
|
92
|
+
ladder, the count of turns classified `hard` matched the count served by
|
|
93
|
+
something other than the default (6/6, 4/4, 3/3, 5/5, 7/7, 1/1 across rungs and
|
|
94
|
+
runs). The escalation *target* is chosen live from trust and latency history, so
|
|
95
|
+
it differs between runs on the same catalog — run 1 stepped up to
|
|
96
|
+
`gemini-3.7-flash`, run 2 to `grok-4.6`.
|
|
97
|
+
|
|
98
|
+
Escalation stays inside the cheaper half of the catalog. A model priced above a
|
|
99
|
+
tier's `maxInputPerMtok` is excluded before ranking, and at `hard` the
|
|
100
|
+
`(quality/100)^qualityExponent ÷ expected cost` score favours cheaper models that
|
|
101
|
+
score nearly as well. If your workload needs a frontier model on hard turns,
|
|
102
|
+
raise the tier price ceiling and `qualityExponent` — measured thresholds are in
|
|
103
|
+
[`docs/routing-benchmark-findings.md`](docs/routing-benchmark-findings.md).
|
|
104
|
+
|
|
105
|
+
### Scope
|
|
106
|
+
|
|
107
|
+
These are small, self-contained tasks of one to three files, solved in under 25
|
|
108
|
+
turns. On the core suite both engines solved everything, so it measures cost at
|
|
109
|
+
equal correctness rather than capability; the ladder is where capability
|
|
110
|
+
separates. The cost multiple varied between 14× and 32× across runs depending on
|
|
111
|
+
which task the baseline stalled on — treat "well over an order of magnitude" as
|
|
112
|
+
the claim, not a specific figure.
|
|
113
|
+
|
|
114
|
+
For sustained work on a large codebase the economics differ: cost there is
|
|
115
|
+
dominated by the conversation being resent each turn rather than by per-token
|
|
116
|
+
price. Replaying a week of real omp traffic (6 918 billed turns, 410:1
|
|
117
|
+
input-to-output) against a single-model baseline gives **≈15×**.
|
|
118
|
+
|
|
119
|
+
Harness, tasks and raw per-turn data:
|
|
120
|
+
[`docs/routing-benchmark-findings.md`](docs/routing-benchmark-findings.md).
|
|
121
|
+
|
|
35
122
|
## Architecture
|
|
36
123
|
|
|
37
124
|
```mermaid
|
|
@@ -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
|
|
@@ -13,15 +13,18 @@ of my own earlier conclusions were wrong and are corrected in place.
|
|
|
13
13
|
|
|
14
14
|
1. **The router is 20–32× cheaper at equal solve rate.** On 10 easy tasks both
|
|
15
15
|
arms solved 20/20; on a 7-rung difficulty ladder both solved 5/7.
|
|
16
|
-
2. **
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
`
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
16
|
+
2. **Tier escalation works.** A turn classified `hard` reliably selects a
|
|
17
|
+
costlier model — the hard-turn count matches the non-flash turn count ~1:1
|
|
18
|
+
across two independent ladder runs (to `gemini-3.7-flash` in one, to
|
|
19
|
+
`grok-4.6` in the other). *An earlier version of this document claimed the
|
|
20
|
+
opposite; that was my error, corrected in §3.*
|
|
21
|
+
3. **What never happens is reaching `claude-opus-5`.** Two gates: the price
|
|
22
|
+
ceiling excludes it below `hard`, and at `hard` it is outranked by cheaper
|
|
23
|
+
high-quality models. Whether that is a defect depends on whether
|
|
24
|
+
`grok-4.6`-class is "good enough" — this benchmark cannot say.
|
|
25
|
+
4. **Mid-stream escalation signals never fired** — `escalation_signal` was empty
|
|
26
|
+
on every one of ~190 routed ladder turns. That is a different mechanism from
|
|
27
|
+
tier selection and may simply mean nothing malformed occurred.
|
|
25
28
|
5. **The standalone `serve` process died 4 times inside one normally-completing
|
|
26
29
|
run.** The crash is real and mid-run; the *trigger* is unidentified — my first
|
|
27
30
|
explanation (client disconnect mid-stream) does not reproduce. Details in §7.
|
|
@@ -33,6 +36,11 @@ of my own earlier conclusions were wrong and are corrected in place.
|
|
|
33
36
|
- **Harness**: omp in `-p --mode=json` print mode. Every turn's tokens,
|
|
34
37
|
duration, TTFT and tool calls are read off omp's own event stream, so both
|
|
35
38
|
arms are measured by the same instrument.
|
|
39
|
+
- **Provider guard**: each arm declares its expected omp provider and every
|
|
40
|
+
completion turn is checked against it — `anthropic` + `claude-opus-5` for the
|
|
41
|
+
baseline (first-party, never OpenRouter), `benchrouter` for the router arm.
|
|
42
|
+
Zero violations across the re-runs. Added after an early pilot silently
|
|
43
|
+
resolved `--model auto` to OpenRouter's own `auto` meta-router.
|
|
36
44
|
- **Router arm**: the standalone `auto-model-router serve` endpoint registered
|
|
37
45
|
as a plain OpenAI-compatible provider — the documented non-omp path (README
|
|
38
46
|
§ "Standalone alternative"). Nothing pinned, nothing stubbed; the router
|
|
@@ -46,22 +54,29 @@ of my own earlier conclusions were wrong and are corrected in place.
|
|
|
46
54
|
verified to fail an untouched workspace and to pass a reference solution
|
|
47
55
|
before any run (`bench/validate-ladder.ts`).
|
|
48
56
|
|
|
49
|
-
> **
|
|
50
|
-
>
|
|
51
|
-
>
|
|
52
|
-
> `
|
|
53
|
-
>
|
|
54
|
-
>
|
|
55
|
-
> path
|
|
57
|
+
> **Print mode — resolved.** During the benchmark the embedded extension only
|
|
58
|
+
> registered the provider inside a session with a UI, so `omp -p` could not see
|
|
59
|
+
> `auto-model-router/auto` at all; that is why the harness drives the standalone
|
|
60
|
+
> `serve` endpoint instead. Fixed upstream in **0.2.20** (`fix(embed): resolve the
|
|
61
|
+
> router in print mode and subagents`) — verified: `omp -p --model
|
|
62
|
+
> auto-model-router/auto` now resolves natively with no standalone server. The
|
|
63
|
+
> harness keeps the `serve` path because it gives the benchmark its own isolated
|
|
64
|
+
> ledger, not because the embedded path is broken.
|
|
56
65
|
|
|
57
66
|
---
|
|
58
67
|
|
|
59
68
|
## 2. Cost result
|
|
60
69
|
|
|
61
|
-
| Suite | Router | Opus 5 | Ratio |
|
|
62
|
-
|
|
63
|
-
| 10 easy tasks
|
|
64
|
-
|
|
|
70
|
+
| Suite | Run | Router | Opus 5 | Ratio |
|
|
71
|
+
|---|---|---|---|---|
|
|
72
|
+
| 10 easy tasks | trials 1+2 | 20/20 · $0.3577 | 20/20 · $11.4040 | 31.9× |
|
|
73
|
+
| 10 easy tasks | re-run | 10/10 · $0.2695 | 10/10 · $5.2092 | 19.3× |
|
|
74
|
+
| 7-rung ladder | first | 5/7 · $0.3026 | 5/7 · $6.2506 | 20.7× |
|
|
75
|
+
| 7-rung ladder | re-run | 5/7 · $0.4619 | **6/7** · $6.5967 | 14.3× |
|
|
76
|
+
|
|
77
|
+
Across four independent runs the ratio lands between **14× and 32×**. On the
|
|
78
|
+
ladder re-run Opus 5 edged the router on correctness for the first time (6/7 vs
|
|
79
|
+
5/7), which is the outcome the ladder was built to be able to detect.
|
|
65
80
|
|
|
66
81
|
Turn counts are comparable (184 vs 217 on the easy suite; 86 vs 91 on the
|
|
67
82
|
ladder), so the saving is not bought with extra turns. Median TTFT is the
|
|
@@ -74,30 +89,56 @@ magnitude" is defensible; a precise figure is not.
|
|
|
74
89
|
|
|
75
90
|
---
|
|
76
91
|
|
|
77
|
-
## 3.
|
|
78
|
-
|
|
79
|
-
The ladder was built specifically to force an escalation decision: seven rungs
|
|
80
|
-
ending in npm semver range semantics and a minimal-diff with a specified
|
|
81
|
-
tie-break.
|
|
92
|
+
## 3. Escalation — corrected
|
|
82
93
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
| 3 | csv-document | PASS | PASS | no |
|
|
88
|
-
| 4 | sliding-limiter | PASS | **FAIL** | no |
|
|
89
|
-
| 5 | savepoints | **FAIL** | PASS | **no** |
|
|
90
|
-
| 6 | semver-ranges | timeout | timeout | no |
|
|
91
|
-
| 7 | minimal-diff | PASS | PASS | no |
|
|
94
|
+
**I got this wrong first time.** I reported that the router "never escalates",
|
|
95
|
+
having looked at the served-model list, seen only `glm-5.3-flash` and
|
|
96
|
+
`gemini-3.7-flash`, and mentally filed both as "flash models, therefore no
|
|
97
|
+
escalation". I never checked whether the counts lined up with the tier. They do.
|
|
92
98
|
|
|
93
|
-
|
|
94
|
-
capability either way.
|
|
99
|
+
### Tier escalation works, and is close to deterministic
|
|
95
100
|
|
|
96
|
-
|
|
97
|
-
classifier is not blind — the tier simply never converts into a more capable
|
|
98
|
-
model. On rung 5 it failed while continuing to dispatch to `glm-5.3-flash`.
|
|
101
|
+
Hard-tier turns versus turns served by something other than the cheap default:
|
|
99
102
|
|
|
100
|
-
|
|
103
|
+
| Rung | ladder-1 hard / non-flash | v2-ladder hard / non-flash |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| L1 in-range | 0 / 0 | 0 / 0 |
|
|
106
|
+
| L2 round-half-even | 6 / 6 | 0 / 0 |
|
|
107
|
+
| L3 csv-document | 4 / 4 | 0 / 0 |
|
|
108
|
+
| L4 sliding-limiter | 3 / 6 | 5 / 5 |
|
|
109
|
+
| L5 savepoints | 3 / 3 | 7 / 7 |
|
|
110
|
+
| L6 semver-ranges | 2 / 3 | 0 / 0 |
|
|
111
|
+
| L7 minimal-diff | 3 / 4 | 1 / 1 |
|
|
112
|
+
|
|
113
|
+
The escalation *target* differs by run, which is itself worth knowing:
|
|
114
|
+
`ladder-1` stepped up to `gemini-3.7-flash` ($0.75/MTok, ~10× the default);
|
|
115
|
+
`v2-ladder` stepped up to `grok-4.6` ($2.00/MTok, ~28×). Same config, same
|
|
116
|
+
catalog — the choice moves with trust and latency history, which are live inputs
|
|
117
|
+
to the ranking. Escalation behaviour is therefore **not reproducible run to run**,
|
|
118
|
+
even though it is reliable *within* a run.
|
|
119
|
+
|
|
120
|
+
### What genuinely never happens
|
|
121
|
+
|
|
122
|
+
`anthropic/claude-opus-5` was never selected in any run. §4 explains why, and
|
|
123
|
+
that analysis stands: the price ceiling excludes it below `hard`, and at `hard`
|
|
124
|
+
it loses the quality-per-dollar ranking to cheaper models scoring nearly as well.
|
|
125
|
+
|
|
126
|
+
Whether that is a *problem* is not something this benchmark answers. If
|
|
127
|
+
`grok-4.6` is good enough for the hard turns, the router is behaving correctly
|
|
128
|
+
and cheaply. The one weak signal available: rung 5 failed in `ladder-1` when it
|
|
129
|
+
escalated only as far as `gemini-3.7-flash` (3 turns) and passed in `v2-ladder`
|
|
130
|
+
when it escalated to `grok-4.6` (7 turns). Rung 4 pushes the other way — it
|
|
131
|
+
passed in `ladder-1` without much escalation and failed in `v2-ladder` *with*
|
|
132
|
+
`grok-4.6` on 5 turns. With n=1 per cell these cancel out; nothing is
|
|
133
|
+
established.
|
|
134
|
+
|
|
135
|
+
### Mid-stream escalation is a separate mechanism, and it never fired
|
|
136
|
+
|
|
137
|
+
`escalation_signal` — the abort-and-redispatch-upward path for malformed tool
|
|
138
|
+
calls, empty completions and repeated calls — was empty on every routed ladder
|
|
139
|
+
turn, as was `attempt > 0`. That is consistent with "no turn ever came back
|
|
140
|
+
malformed" rather than evidence of a defect; the ladder gave it nothing to react
|
|
141
|
+
to.
|
|
101
142
|
|
|
102
143
|
## 4. Root cause: two gates, both hard
|
|
103
144
|
|
|
@@ -80,17 +80,29 @@ export function deriveAgentdoxScope(cwd: string): string {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
|
-
* Resolves the
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
83
|
+
* Resolves the port the embedded router should serve on, in precedence order:
|
|
84
|
+
* an explicit `AUTO_MODEL_ROUTER_PORT`, else the configured `server.port`, else
|
|
85
|
+
* 0 (let the OS pick a free one).
|
|
86
|
+
*
|
|
87
|
+
* A STABLE port is what keeps omp's model resolution honest. omp resolves
|
|
88
|
+
* `modelRoles.default` from `models.yml` during startup — BEFORE extensions
|
|
89
|
+
* load, so before this session can bind and rewrite that file. With an
|
|
90
|
+
* ephemeral port the block names the PREVIOUS session's port, which is dead
|
|
91
|
+
* once that session exits, and every main-agent turn fails with "Unable to
|
|
92
|
+
* connect" while utility calls (resolved later, from the live registration)
|
|
93
|
+
* still work. A deterministic port makes the pre-bind block correct by
|
|
94
|
+
* construction. Sessions sharing that port share one router, which is already
|
|
95
|
+
* how subagents behave.
|
|
96
|
+
*
|
|
97
|
+
* `0` is still honoured when asked for explicitly, and remains the fallback
|
|
98
|
+
* when the desired port is occupied by something that is not our router.
|
|
88
99
|
*/
|
|
89
|
-
export function resolveEmbedPort(envPort: string | undefined): number {
|
|
100
|
+
export function resolveEmbedPort(envPort: string | undefined, configuredPort = 0): number {
|
|
90
101
|
if (envPort !== undefined && envPort !== "") {
|
|
91
102
|
const port = Number.parseInt(envPort, 10);
|
|
92
103
|
if (Number.isInteger(port) && port >= 0 && port <= 65_535) return port;
|
|
93
104
|
}
|
|
105
|
+
if (Number.isInteger(configuredPort) && configuredPort > 0 && configuredPort <= 65_535) return configuredPort;
|
|
94
106
|
return 0;
|
|
95
107
|
}
|
|
96
108
|
|
|
@@ -86,8 +86,6 @@ function registerRouterProvider(pi: ExtensionAPI, port: number, cfg: RouterConfi
|
|
|
86
86
|
export default function (pi: ExtensionAPI): void {
|
|
87
87
|
pi.setLabel("auto-model-router embed");
|
|
88
88
|
|
|
89
|
-
const requestedPort = resolveEmbedPort(process.env.AUTO_MODEL_ROUTER_PORT);
|
|
90
|
-
|
|
91
89
|
// Shared port file, written only by the main session's router.
|
|
92
90
|
const homeRaw = process.env.AUTO_MODEL_ROUTER_HOME ?? join(homedir(), ".auto-model-router");
|
|
93
91
|
const home =
|
|
@@ -95,11 +93,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
95
93
|
? join(homedir(), homeRaw.slice(1))
|
|
96
94
|
: homeRaw;
|
|
97
95
|
const portFile = embedPortPath(home);
|
|
98
|
-
|
|
96
|
+
// Load first WITHOUT a port override so `server.port` from config.yml is
|
|
97
|
+
// visible, then let it (or the env var) decide the bind port.
|
|
98
|
+
const cfg = loadConfig({ overrides: { server: { host: "127.0.0.1" } } });
|
|
99
|
+
const requestedPort = resolveEmbedPort(process.env.AUTO_MODEL_ROUTER_PORT, cfg.server.port);
|
|
100
|
+
cfg.server.port = requestedPort;
|
|
99
101
|
|
|
100
102
|
let app: StartedServer | null = null;
|
|
101
103
|
|
|
102
|
-
pi.on("session_start", (_event, ctx) => {
|
|
104
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
103
105
|
// The omp UI session id tags every request so the toast can scope its
|
|
104
106
|
// notifications to that exact session (see router-toast.ts).
|
|
105
107
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
@@ -108,7 +110,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
108
110
|
// shared router: one process, one ledger, one place to inspect.
|
|
109
111
|
// The main writes the port file before spawning subagents.
|
|
110
112
|
const shared = readEmbedPort(portFile);
|
|
111
|
-
if (shared !== null && probeEmbed(shared)) {
|
|
113
|
+
if (shared !== null && (await probeEmbed(shared))) {
|
|
112
114
|
registerRouterProvider(pi, shared, cfg, sessionId);
|
|
113
115
|
return;
|
|
114
116
|
}
|
|
@@ -128,12 +130,35 @@ export default function (pi: ExtensionAPI): void {
|
|
|
128
130
|
return;
|
|
129
131
|
}
|
|
130
132
|
|
|
131
|
-
// Main interactive session
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
133
|
+
// Main interactive session. The port is deterministic (see
|
|
134
|
+
// resolveEmbedPort), which matters because omp resolves
|
|
135
|
+
// `modelRoles.default` from models.yml BEFORE this extension loads: the
|
|
136
|
+
// URL that block names must be one this session will actually serve.
|
|
135
137
|
if (app) return;
|
|
136
|
-
|
|
138
|
+
|
|
139
|
+
// Another live session already serving this port? Share it rather than
|
|
140
|
+
// fighting over the socket — subagents already share one router, and the
|
|
141
|
+
// ledger and DB are shared regardless.
|
|
142
|
+
if (requestedPort !== 0 && (await probeEmbed(requestedPort))) {
|
|
143
|
+
writeEmbedPort(portFile, requestedPort);
|
|
144
|
+
syncModelsYml(cfg, requestedPort);
|
|
145
|
+
registerRouterProvider(pi, requestedPort, cfg, sessionId);
|
|
146
|
+
pi.setLabel(`auto-model-router embed (shared :${requestedPort})`);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Bind the desired port; if something that is NOT our router holds it,
|
|
151
|
+
// fall back to an ephemeral port rather than leaving the session with no
|
|
152
|
+
// provider at all. models.yml is rewritten either way, so headless runs
|
|
153
|
+
// and subagents still resolve.
|
|
154
|
+
let started: StartedServer;
|
|
155
|
+
try {
|
|
156
|
+
started = startServer(cfg);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (requestedPort === 0) throw err;
|
|
159
|
+
cfg.server.port = 0;
|
|
160
|
+
started = startServer(cfg);
|
|
161
|
+
}
|
|
137
162
|
const actualPort = started.server.port;
|
|
138
163
|
if (actualPort === undefined) return;
|
|
139
164
|
app = started;
|
|
@@ -145,8 +170,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
145
170
|
// subagent processes resolve models from models.yml in a FRESH registry
|
|
146
171
|
// — extension registration does not reach them — so without this they
|
|
147
172
|
// fail with "Model not found" when no interactive session is live
|
|
148
|
-
// (the print-mode gap the external benchmark hit).
|
|
149
|
-
// wins at runtime, so a live session always overrides a stale block.
|
|
173
|
+
// (the print-mode gap the external benchmark hit).
|
|
150
174
|
const syncAction = syncModelsYml(cfg, actualPort);
|
|
151
175
|
if (syncAction !== null) pi.setLabel(`auto-model-router embed (models.yml ${syncAction})`);
|
|
152
176
|
registerRouterProvider(pi, actualPort, cfg, sessionId);
|
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. */
|