auto-model-router 0.2.28 → 0.2.30
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.md +3 -2
- package/README.md +4 -3
- package/docs/AGENTDOX-BRIDGE.md +4 -3
- package/package.json +1 -1
- package/src/config/defaults.ts +7 -0
- package/src/config/schema.ts +9 -0
- package/src/config/types.ts +41 -0
- package/src/router/classify.ts +14 -7
- package/src/router/select.ts +14 -1
- package/test/classify.test.ts +30 -0
- package/test/failover.test.ts +2 -1
- package/test/select.test.ts +78 -0
- package/test/turn.test.ts +2 -1
- package/tools/replay.ts +16 -4
package/CLAUDE.md
CHANGED
|
@@ -119,8 +119,9 @@ read/write client is `src/context/agentdox.ts` (assemble / createSession / appen
|
|
|
119
119
|
Beyond consuming agentdox as an agent, `src/context/` is the **router↔agentdox bridge**: it
|
|
120
120
|
injects shared project context into every routed turn and records turns back, attributed to
|
|
121
121
|
the model that served them. See `docs/AGENTDOX-BRIDGE.md` for the current state, how to run
|
|
122
|
-
it, and the open issue. Design rationale
|
|
123
|
-
`
|
|
122
|
+
it, and the open issue. Design rationale is the **decision log in the agentdox project brief**
|
|
123
|
+
for scope `omp-router` (read it with `context_brief`) — the bridge decisions and the evidence
|
|
124
|
+
behind them are recorded there as they are made.
|
|
124
125
|
|
|
125
126
|
Turning the bridge on for the router itself (distinct from the MCP wiring above):
|
|
126
127
|
|
package/README.md
CHANGED
|
@@ -677,7 +677,7 @@ has none of the project knowledge the last one built up. Because every harness
|
|
|
677
677
|
routes through this one provider, the router is the single place that can fix
|
|
678
678
|
that for all of them at once.
|
|
679
679
|
|
|
680
|
-
Point it at an [agentdox](https://github.com
|
|
680
|
+
Point it at an [agentdox](https://github.com/drewappling/agentdox) server and every turn —
|
|
681
681
|
whatever model wins the routing decision — carries the same project memory, docs,
|
|
682
682
|
and brief:
|
|
683
683
|
|
|
@@ -744,8 +744,9 @@ not a dependency. If it is unreachable the turn routes and dispatches normally,
|
|
|
744
744
|
and a pinned block keeps being served.
|
|
745
745
|
|
|
746
746
|
`GET /health` reports the bridge's URL, default scope, and `recordTurns` — never
|
|
747
|
-
the token. Design notes: `docs/
|
|
748
|
-
agentdox repo.
|
|
747
|
+
the token. Design notes: [`docs/AGENTDOX-BRIDGE.md`](docs/AGENTDOX-BRIDGE.md).
|
|
748
|
+
Server side: the [agentdox repo](https://github.com/drewappling/agentdox).
|
|
749
|
+
Live check: `bun tools/agentdox-e2e.ts`.
|
|
749
750
|
|
|
750
751
|
---
|
|
751
752
|
|
package/docs/AGENTDOX-BRIDGE.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# agentdox bridge — handoff
|
|
2
2
|
|
|
3
|
-
**Status:** implemented, typechecks clean,
|
|
3
|
+
**Status:** implemented, typechecks clean, 502 tests pass, injection verified end-to-end
|
|
4
4
|
through omp. The write-back faults in §5 and §6 are **fixed**; `context.recordTurns` is on.
|
|
5
5
|
|
|
6
|
-
Design rationale (why it is built this way)
|
|
7
|
-
`
|
|
6
|
+
Design rationale (why it is built this way) is the decision log in the agentdox project
|
|
7
|
+
brief for scope `omp-router` — read it with `context_brief`. The agentdox server itself:
|
|
8
|
+
[github.com/drewappling/agentdox](https://github.com/drewappling/agentdox).
|
|
8
9
|
|
|
9
10
|
---
|
|
10
11
|
|
package/package.json
CHANGED
package/src/config/defaults.ts
CHANGED
|
@@ -98,6 +98,10 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
98
98
|
toolAxis: "coding",
|
|
99
99
|
chatAxis: "intelligence",
|
|
100
100
|
agenticLoopDepth: 3,
|
|
101
|
+
// Shipped values, unchanged. See ClassifierConfig.reasoningWeights: a
|
|
102
|
+
// harness that pins the level for a whole session turns these into a
|
|
103
|
+
// constant tier offset, in which case `medium` belongs near 0.
|
|
104
|
+
reasoningWeights: { medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 },
|
|
101
105
|
},
|
|
102
106
|
escalation: {
|
|
103
107
|
enabled: true,
|
|
@@ -131,6 +135,9 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
131
135
|
// OpenRouter sticky sessions expire in 5-10 minutes.
|
|
132
136
|
cacheWarmTtlMs: 300_000,
|
|
133
137
|
maxDowngradePerTurn: 1,
|
|
138
|
+
// Off: breaking a hold means a model switch, which costs a cache write.
|
|
139
|
+
// Enable where the held tier is expensive; see HysteresisConfig.
|
|
140
|
+
breakHoldOnMechanical: false,
|
|
134
141
|
},
|
|
135
142
|
exploration: {
|
|
136
143
|
// Opt-in. Exploration knowingly routes some turns below the tier that
|
package/src/config/schema.ts
CHANGED
|
@@ -85,6 +85,14 @@ const classifier = z.strictObject({
|
|
|
85
85
|
toolAxis: qualityAxis.optional(),
|
|
86
86
|
chatAxis: qualityAxis.optional(),
|
|
87
87
|
agenticLoopDepth: z.number().int().nonnegative().optional(),
|
|
88
|
+
reasoningWeights: z
|
|
89
|
+
.strictObject({
|
|
90
|
+
medium: z.number().nonnegative().optional(),
|
|
91
|
+
high: z.number().nonnegative().optional(),
|
|
92
|
+
xhigh: z.number().nonnegative().optional(),
|
|
93
|
+
max: z.number().nonnegative().optional(),
|
|
94
|
+
})
|
|
95
|
+
.optional(),
|
|
88
96
|
});
|
|
89
97
|
|
|
90
98
|
const escalation = z.strictObject({
|
|
@@ -103,6 +111,7 @@ const hysteresis = z.strictObject({
|
|
|
103
111
|
switchMargin: z.number().positive().optional(),
|
|
104
112
|
cacheWarmTtlMs: z.number().nonnegative().optional(),
|
|
105
113
|
maxDowngradePerTurn: z.number().int().nonnegative().optional(),
|
|
114
|
+
breakHoldOnMechanical: z.boolean().optional(),
|
|
106
115
|
});
|
|
107
116
|
|
|
108
117
|
const exploration = z.strictObject({
|
package/src/config/types.ts
CHANGED
|
@@ -229,6 +229,28 @@ export interface ClassifierConfig {
|
|
|
229
229
|
chatAxis: QualityAxis;
|
|
230
230
|
/** Tool-loop depth above which the agentic axis takes over. */
|
|
231
231
|
agenticLoopDepth: number;
|
|
232
|
+
/**
|
|
233
|
+
* Score added when the CLIENT asks for a reasoning effort, per level. The
|
|
234
|
+
* premise is that asking for reasoning states expected difficulty directly.
|
|
235
|
+
*
|
|
236
|
+
* That premise fails when a harness sets the level once for a whole session:
|
|
237
|
+
* a constant cannot discriminate difficulty between turns, but it still
|
|
238
|
+
* shifts every turn's score. Measured on a live day: the requested level
|
|
239
|
+
* never changed within 111 of 115 conversations, `medium` (+0.14, over half
|
|
240
|
+
* of a 0.25-wide tier band) rode on 41.6% of dispatches, and 64 of 119
|
|
241
|
+
* `hard` dispatches reached that tier ONLY because of it — $6.66 billed
|
|
242
|
+
* against $0.16 for the same tokens on the moderate pick.
|
|
243
|
+
*
|
|
244
|
+
* Tune per deployment: a harness that raises the level deliberately for hard
|
|
245
|
+
* turns wants these weights, one that pins it session-wide wants `medium`
|
|
246
|
+
* near zero. Defaults preserve the shipped behaviour.
|
|
247
|
+
*/
|
|
248
|
+
reasoningWeights: {
|
|
249
|
+
medium: number;
|
|
250
|
+
high: number;
|
|
251
|
+
xhigh: number;
|
|
252
|
+
max: number;
|
|
253
|
+
};
|
|
232
254
|
}
|
|
233
255
|
|
|
234
256
|
export interface EscalationConfig {
|
|
@@ -265,6 +287,25 @@ export interface HysteresisConfig {
|
|
|
265
287
|
cacheWarmTtlMs: number;
|
|
266
288
|
/** Downgrade at most this many tiers per turn, so quality never falls off a cliff. */
|
|
267
289
|
maxDowngradePerTurn: number;
|
|
290
|
+
/**
|
|
291
|
+
* Let a mechanical tool-result continuation escape a hold that sits above its
|
|
292
|
+
* own classification.
|
|
293
|
+
*
|
|
294
|
+
* A hold bets that the next turn resembles the one that armed it, and it is
|
|
295
|
+
* usually right — flapping cold-starts prompt caches. But a continuation the
|
|
296
|
+
* classifier has already docked for being a mechanical next step, and whose
|
|
297
|
+
* score lands below the held tier, is evidence against that bet. Measured on
|
|
298
|
+
* 24h of live traffic: 37 of 44 sticky `hard` dispatches were exactly that,
|
|
299
|
+
* one scoring 0.154 (trivial) yet served by claude-opus-5 — $2.66 billed
|
|
300
|
+
* against $0.05 for the identical tokens on the moderate pick.
|
|
301
|
+
*
|
|
302
|
+
* Off by default: breaking a hold means a model switch, and switching costs a
|
|
303
|
+
* cache write. Worth it when the held tier is expensive, not obviously worth
|
|
304
|
+
* it when the tiers are close, so it is opt-in per deployment.
|
|
305
|
+
* `maxDowngradePerTurn` still applies, so quality steps down rather than
|
|
306
|
+
* falling off a cliff.
|
|
307
|
+
*/
|
|
308
|
+
breakHoldOnMechanical: boolean;
|
|
268
309
|
}
|
|
269
310
|
|
|
270
311
|
/**
|
package/src/router/classify.ts
CHANGED
|
@@ -76,17 +76,24 @@ const W_TOOLS_OFFERED = 0.03;
|
|
|
76
76
|
/** Score bucket boundaries: [trivial, simple, moderate, hard]. */
|
|
77
77
|
const BOUNDARIES: readonly [number, number, number] = [0.25, 0.5, 0.75];
|
|
78
78
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Score for a client-stated reasoning effort. The premise is that asking for
|
|
81
|
+
* reasoning states expected difficulty — true when a harness raises the level
|
|
82
|
+
* for a hard turn, false when it pins one level for the whole session, where the
|
|
83
|
+
* "signal" is a constant that lifts every turn's score. Weights are therefore
|
|
84
|
+
* configurable per deployment; see ClassifierConfig.reasoningWeights.
|
|
85
|
+
*/
|
|
86
|
+
function reasoningWeight(level: ReasoningLevel | undefined, cfg: RouterConfig): number {
|
|
87
|
+
const w = cfg.classifier.reasoningWeights;
|
|
81
88
|
switch (level) {
|
|
82
89
|
case "medium":
|
|
83
|
-
return
|
|
90
|
+
return w.medium;
|
|
84
91
|
case "high":
|
|
85
|
-
return
|
|
92
|
+
return w.high;
|
|
86
93
|
case "xhigh":
|
|
87
|
-
return
|
|
94
|
+
return w.xhigh;
|
|
88
95
|
case "max":
|
|
89
|
-
return
|
|
96
|
+
return w.max;
|
|
90
97
|
default:
|
|
91
98
|
// off/minimal/low/undefined: no stated difficulty above the baseline.
|
|
92
99
|
return 0;
|
|
@@ -113,7 +120,7 @@ export function scoreHeuristic(f: Features, cfg: RouterConfig): Classification {
|
|
|
113
120
|
);
|
|
114
121
|
if (f.lastToolFailed) add(W_TOOL_FAILED, "last tool result failed");
|
|
115
122
|
if (f.circularToolCall) add(W_CIRCULAR_LOOP, "circular tool call (re-issued a prior call; stuck)");
|
|
116
|
-
const rw = reasoningWeight(f.requestedReasoning);
|
|
123
|
+
const rw = reasoningWeight(f.requestedReasoning, cfg);
|
|
117
124
|
if (rw > 0) add(rw, `client requested reasoning=${f.requestedReasoning ?? ""}`);
|
|
118
125
|
if (f.isTerseInstruction) add(W_TERSE, "terse instruction");
|
|
119
126
|
add(Math.min(f.codeBlocks * W_CODE_BLOCK, CAP_CODE), `${f.codeBlocks} code block(s) in new content`);
|
package/src/router/select.ts
CHANGED
|
@@ -117,10 +117,23 @@ export function select(args: SelectArgs): Decision {
|
|
|
117
117
|
|
|
118
118
|
// 2. Hysteresis: while the sticky window is open, never route below the
|
|
119
119
|
// held tier — per-turn flapping would repeatedly cold-start prompt caches.
|
|
120
|
+
//
|
|
121
|
+
// Exception, when `breakHoldOnMechanical` is on: a hold is a bet that the
|
|
122
|
+
// NEXT turn resembles the one that armed it. A tool-result continuation
|
|
123
|
+
// whose own score lands below the held tier is direct evidence against
|
|
124
|
+
// that bet — the classifier already docks it for being a mechanical next
|
|
125
|
+
// step — so paying the held tier for it buys nothing. Measured on 24h of
|
|
126
|
+
// live traffic: 37 of 44 sticky `hard` dispatches were exactly this shape,
|
|
127
|
+
// one scoring 0.154 (trivial) yet served by claude-opus-5; $2.66 billed
|
|
128
|
+
// against $0.05 for the identical tokens on the moderate pick.
|
|
120
129
|
let cls = classification;
|
|
130
|
+
const mechanicalOverride =
|
|
131
|
+
cfg.hysteresis.breakHoldOnMechanical && features.isToolResultContinuation && tierIdx(effective) < tierIdx(clampTier(state.currentTier ?? effective));
|
|
121
132
|
if (state.stickyUntilTurn > state.turn && state.currentTier !== null && tierIdx(state.currentTier) >= tierIdx(effective)) {
|
|
122
133
|
const held = clampTier(state.currentTier);
|
|
123
|
-
if (
|
|
134
|
+
if (mechanicalOverride) {
|
|
135
|
+
reasons.push(`hysteresis hold ${held} broken: mechanical tool-result continuation classified ${effective}`);
|
|
136
|
+
} else if (held !== effective) {
|
|
124
137
|
reasons.push(`hysteresis: holding ${held} until turn ${state.stickyUntilTurn} (classified ${effective})`);
|
|
125
138
|
cls = {
|
|
126
139
|
...classification,
|
package/test/classify.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
+
import { DEFAULT_CONFIG } from "../src/config/defaults.ts";
|
|
3
4
|
import { loadConfig } from "../src/config/load.ts";
|
|
4
5
|
import type { RouterConfig } from "../src/config/types.ts";
|
|
5
6
|
import { classify, classifyTask, pickQualityAxis, scoreHeuristic } from "../src/router/classify.ts";
|
|
@@ -178,6 +179,35 @@ describe("scoreHeuristic", () => {
|
|
|
178
179
|
expect(thinking.score).toBeGreaterThan(plain.score);
|
|
179
180
|
});
|
|
180
181
|
|
|
182
|
+
test("the reasoning weight is configurable, so a session-wide level can be discounted", () => {
|
|
183
|
+
// A harness that pins one reasoning level for a whole session turns this
|
|
184
|
+
// "signal" into a constant that lifts every turn's score. Measured live:
|
|
185
|
+
// the level never changed within 111 of 115 conversations, and 64 of 119
|
|
186
|
+
// hard dispatches reached that tier ONLY via the weight — $6.66 billed
|
|
187
|
+
// against $0.16 for the same tokens on the moderate pick.
|
|
188
|
+
//
|
|
189
|
+
// DEFAULT_CONFIG, not BASE: BASE is loadConfig({}), which reads this
|
|
190
|
+
// machine's real config.yml, and this assertion is about shipped values.
|
|
191
|
+
const features = extractFeatures(
|
|
192
|
+
parseChatRequest(
|
|
193
|
+
{ model: "auto", tools: TOOLS, reasoning_effort: "medium", messages: [SYSTEM, { role: "user", content: "tidy this up" }] },
|
|
194
|
+
new Headers(),
|
|
195
|
+
),
|
|
196
|
+
5000,
|
|
197
|
+
);
|
|
198
|
+
const shipped = scoreHeuristic(features, DEFAULT_CONFIG);
|
|
199
|
+
const discounted = scoreHeuristic(features, {
|
|
200
|
+
...DEFAULT_CONFIG,
|
|
201
|
+
classifier: { ...DEFAULT_CONFIG.classifier, reasoningWeights: { ...DEFAULT_CONFIG.classifier.reasoningWeights, medium: 0 } },
|
|
202
|
+
});
|
|
203
|
+
expect(shipped.score - discounted.score).toBeCloseTo(DEFAULT_CONFIG.classifier.reasoningWeights.medium, 5);
|
|
204
|
+
expect(discounted.reasons.some((r) => /requested reasoning/.test(r))).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("ships with the historical weights, so enabling a discount is opt-in", () => {
|
|
208
|
+
expect(DEFAULT_CONFIG.classifier.reasoningWeights).toEqual({ medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 });
|
|
209
|
+
});
|
|
210
|
+
|
|
181
211
|
test("always produces a bounded score, a real tier, and its reasoning", () => {
|
|
182
212
|
const c = scoreHeuristic(featuresFor([SYSTEM, { role: "user", content: "hello" }]), BASE);
|
|
183
213
|
expect(c.score).toBeGreaterThanOrEqual(0);
|
package/test/failover.test.ts
CHANGED
|
@@ -55,6 +55,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
55
55
|
toolAxis: "coding",
|
|
56
56
|
chatAxis: "intelligence",
|
|
57
57
|
agenticLoopDepth: 3,
|
|
58
|
+
reasoningWeights: { medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 },
|
|
58
59
|
},
|
|
59
60
|
escalation: {
|
|
60
61
|
enabled: true,
|
|
@@ -66,7 +67,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
66
67
|
escalateOnLengthStop: false,
|
|
67
68
|
...escalation,
|
|
68
69
|
},
|
|
69
|
-
hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
|
|
70
|
+
hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false },
|
|
70
71
|
exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
|
|
71
72
|
cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
|
|
72
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 },
|
package/test/select.test.ts
CHANGED
|
@@ -743,3 +743,81 @@ describe("context compaction", () => {
|
|
|
743
743
|
expect(tight.promptTokensSaved).toBeGreaterThan(loose.promptTokensSaved);
|
|
744
744
|
});
|
|
745
745
|
});
|
|
746
|
+
|
|
747
|
+
describe("hysteresis.breakHoldOnMechanical", () => {
|
|
748
|
+
// A hold bets the next turn resembles the one that armed it. A tool-result
|
|
749
|
+
// continuation the classifier has already docked, scoring below the held
|
|
750
|
+
// tier, is evidence against that bet. Measured on 24h of live traffic: 37 of
|
|
751
|
+
// 44 sticky `hard` dispatches were exactly that shape — one scoring 0.154
|
|
752
|
+
// (trivial) yet served by claude-opus-5 — $2.66 billed against $0.05 for the
|
|
753
|
+
// same tokens on the moderate pick.
|
|
754
|
+
function continuation(): NormRequest {
|
|
755
|
+
return parseChatRequest(
|
|
756
|
+
{
|
|
757
|
+
model: "auto",
|
|
758
|
+
tools: TOOLS,
|
|
759
|
+
messages: [
|
|
760
|
+
{ role: "system", content: "You are a coding agent." },
|
|
761
|
+
{ role: "user", content: "read the file" },
|
|
762
|
+
{ role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "read", arguments: '{"path":"a.ts"}' } }] },
|
|
763
|
+
{ role: "tool", tool_call_id: "c1", content: "export const x = 1;" },
|
|
764
|
+
],
|
|
765
|
+
},
|
|
766
|
+
new Headers(),
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const held = state({ currentTier: "hard", currentSlug: "x-ai/grok-4.6", stickyUntilTurn: 9, turn: 1 });
|
|
771
|
+
|
|
772
|
+
function decide(req: NormRequest, breakHold: boolean) {
|
|
773
|
+
const cfg: RouterConfig = { ...BASE, hysteresis: { ...BASE.hysteresis, breakHoldOnMechanical: breakHold } };
|
|
774
|
+
const features = extractFeatures(req, 4_000);
|
|
775
|
+
return { d: select({ req, features, classification: scoreHeuristic(features, cfg), profile: PROFILE, state: held, snapshot: SNAPSHOT, ledger: null, cfg, nowMs: Date.now() }), features };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
test("off by default, so a hold still pins the tier", () => {
|
|
779
|
+
expect(BASE.hysteresis.breakHoldOnMechanical).toBe(false);
|
|
780
|
+
const { d, features } = decide(continuation(), false);
|
|
781
|
+
expect(features.isToolResultContinuation).toBe(true);
|
|
782
|
+
expect(d.tier).toBe("hard");
|
|
783
|
+
expect(d.classification.source).toBe("sticky");
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test("on, a mechanical continuation escapes the hold", () => {
|
|
787
|
+
const { d } = decide(continuation(), true);
|
|
788
|
+
expect(d.tier).not.toBe("hard");
|
|
789
|
+
expect(d.classification.source).not.toBe("sticky");
|
|
790
|
+
expect(d.reasons.some((r) => /hold hard broken/.test(r))).toBe(true);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
test("a NON-mechanical turn still gets the hold, so flap protection survives", () => {
|
|
794
|
+
// This is the case hysteresis exists for: fresh user work mid-conversation
|
|
795
|
+
// must not bounce the model and cold-start its cache.
|
|
796
|
+
const { d, features } = decide(request("now refactor the retry helper"), true);
|
|
797
|
+
expect(features.isToolResultContinuation).toBe(false);
|
|
798
|
+
expect(d.tier).toBe("hard");
|
|
799
|
+
expect(d.classification.source).toBe("sticky");
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
test("the downgrade clamp still applies, so quality steps rather than falls", () => {
|
|
803
|
+
const cfg: RouterConfig = {
|
|
804
|
+
...BASE,
|
|
805
|
+
hysteresis: { ...BASE.hysteresis, breakHoldOnMechanical: true, maxDowngradePerTurn: 1 },
|
|
806
|
+
};
|
|
807
|
+
const req = continuation();
|
|
808
|
+
const features = extractFeatures(req, 4_000);
|
|
809
|
+
// Force the fresh classification far below the hold to exercise the clamp.
|
|
810
|
+
const d = select({
|
|
811
|
+
req,
|
|
812
|
+
features,
|
|
813
|
+
classification: { ...scoreHeuristic(features, cfg), tier: "trivial" },
|
|
814
|
+
profile: PROFILE,
|
|
815
|
+
state: held,
|
|
816
|
+
snapshot: SNAPSHOT,
|
|
817
|
+
ledger: null,
|
|
818
|
+
cfg,
|
|
819
|
+
nowMs: Date.now(),
|
|
820
|
+
});
|
|
821
|
+
expect(d.tier).toBe("moderate");
|
|
822
|
+
});
|
|
823
|
+
});
|
package/test/turn.test.ts
CHANGED
|
@@ -56,6 +56,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
56
56
|
toolAxis: "coding",
|
|
57
57
|
chatAxis: "intelligence",
|
|
58
58
|
agenticLoopDepth: 3,
|
|
59
|
+
reasoningWeights: { medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 },
|
|
59
60
|
},
|
|
60
61
|
escalation: {
|
|
61
62
|
enabled: true,
|
|
@@ -67,7 +68,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
67
68
|
escalateOnLengthStop: false,
|
|
68
69
|
...escalation,
|
|
69
70
|
},
|
|
70
|
-
hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
|
|
71
|
+
hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false },
|
|
71
72
|
exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
|
|
72
73
|
cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
|
|
73
74
|
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 },
|
package/tools/replay.ts
CHANGED
|
@@ -37,8 +37,10 @@
|
|
|
37
37
|
* size (`usage.promptTokens`), i.e. the prompt selection actually saw.
|
|
38
38
|
* - `stickyUntilTurn` was never persisted per turn, so the hysteresis hold
|
|
39
39
|
* window is absent. This is the main residual gap.
|
|
40
|
-
* - `requestedReasoning`
|
|
41
|
-
*
|
|
40
|
+
* - `requestedReasoning` IS recorded and is now used. It was previously forced
|
|
41
|
+
* to undefined here on the belief the ledger omitted it, which under-scored
|
|
42
|
+
* ~42% of dispatches and reproduced 27 hard decisions against 120 served.
|
|
43
|
+
* Treat replay numbers produced before that fix as biased toward cheap tiers.
|
|
42
44
|
* - Module constants are not config, so things like CAP_AUTONOMOUS_LOOP cannot
|
|
43
45
|
* be A/B'd via `--set` — only `RouterConfig` paths can.
|
|
44
46
|
*
|
|
@@ -140,10 +142,20 @@ interface Row {
|
|
|
140
142
|
created_at_ms: number;
|
|
141
143
|
}
|
|
142
144
|
|
|
143
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* Rebuilds the classifier input from the recorded blob.
|
|
147
|
+
*
|
|
148
|
+
* `requestedReasoning` IS recorded (JSON.stringify only drops it when the client
|
|
149
|
+
* sent no level), and it must be used: it is worth up to +0.34 of score, rides
|
|
150
|
+
* on ~42% of dispatches, and forcing it to undefined — as this did, on the
|
|
151
|
+
* assumption the ledger omitted it — under-scored every one of those rows.
|
|
152
|
+
* Measured effect of the bug: replay reproduced 27 hard-tier decisions against
|
|
153
|
+
* 120 actually served, i.e. it silently biased every comparison toward cheaper
|
|
154
|
+
* tiers and made reasoning-weight changes look like no-ops.
|
|
155
|
+
*/
|
|
144
156
|
function featuresOf(row: Row, promptTokens: number): Features {
|
|
145
157
|
const f = JSON.parse(row.features) as Partial<Features>;
|
|
146
|
-
return { ...(f as Features), promptTokens
|
|
158
|
+
return { ...(f as Features), promptTokens };
|
|
147
159
|
}
|
|
148
160
|
|
|
149
161
|
/**
|