auto-model-router 0.2.2 → 0.2.7
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 +143 -0
- package/.mcp.json +11 -0
- package/.omp-plugin/marketplace.json +2 -2
- package/CLAUDE.md +129 -0
- package/README.md +64 -0
- package/docs/AGENTDOX-BRIDGE.md +132 -0
- package/docs/context-optimization.md +362 -0
- package/omp-extension/embed-logic.ts +31 -0
- package/omp-extension/router-embed.ts +7 -1
- package/package.json +1 -1
- package/src/cli/config-cmd.ts +20 -5
- package/src/cli/explain.ts +1 -0
- package/src/config/defaults.ts +33 -1
- package/src/config/load.ts +13 -0
- package/src/config/schema.ts +27 -0
- package/src/config/types.ts +79 -5
- package/src/context/agentdox.ts +113 -0
- package/src/context/bridge.ts +166 -0
- package/src/context/index.ts +33 -0
- package/src/context/store.ts +82 -0
- package/src/context/types.ts +78 -0
- package/src/cost/ledger.ts +32 -12
- package/src/cost/types.ts +15 -4
- package/src/router/candidates.ts +19 -8
- package/src/router/classify.ts +26 -12
- package/src/router/compaction.ts +163 -0
- package/src/router/features.ts +26 -13
- package/src/router/select.ts +37 -4
- package/src/router/state.ts +12 -2
- package/src/router/types.ts +33 -1
- package/src/server/http.ts +18 -1
- package/src/server/turn.ts +88 -1
- package/src/upstream/openrouter.ts +8 -1
- package/src/util/sqlite.ts +34 -1
- package/src/wire/openai/request.ts +86 -1
- package/src/wire/types.ts +36 -0
- package/test/classify.test.ts +63 -5
- package/test/compaction.test.ts +148 -0
- package/test/context-bridge.test.ts +337 -0
- package/test/embed-logic.test.ts +32 -0
- package/test/escalate.test.ts +1 -0
- package/test/exploration.test.ts +6 -2
- package/test/failover.test.ts +51 -6
- package/test/features.test.ts +45 -0
- package/test/helpers/inject.ts +23 -0
- package/test/hold-exploration.test.ts +4 -2
- package/test/select.test.ts +86 -3
- package/test/tokens.test.ts +1 -0
- package/test/trust-attribution.test.ts +32 -8
- package/test/turn.test.ts +20 -10
- package/tools/agentdox-e2e.ts +123 -0
package/src/cost/ledger.ts
CHANGED
|
@@ -57,6 +57,7 @@ interface LedgerRow {
|
|
|
57
57
|
wasted: number;
|
|
58
58
|
upstream_generation_id: string | null;
|
|
59
59
|
error: string | null;
|
|
60
|
+
prompt_tokens_saved: number | null;
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
interface TrustRow {
|
|
@@ -70,6 +71,8 @@ interface TrustRow {
|
|
|
70
71
|
interface LatencyRow {
|
|
71
72
|
samples: number;
|
|
72
73
|
ttft_ms: number | null;
|
|
74
|
+
ctok_sum: number | null;
|
|
75
|
+
elapsed_ms_sum: number | null;
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
interface CalibrationRow {
|
|
@@ -82,8 +85,11 @@ interface CalibrationRow {
|
|
|
82
85
|
* Error kinds that say nothing about a MODEL's reliability, and so must not
|
|
83
86
|
* count against its trust:
|
|
84
87
|
* - `aborted`: the client hung up (user pressed escape mid-turn).
|
|
85
|
-
* - `auth`: credential
|
|
86
|
-
*
|
|
88
|
+
* - `auth`: credential or credit refusal (401 invalid key, 402 out of credits)
|
|
89
|
+
* — key-wide, identical for every model.
|
|
90
|
+
* - `moderation`: a provider content-moderation or per-model policy gate (403:
|
|
91
|
+
* prompt-injection block, age/data-policy confirmation). Per-model, not a
|
|
92
|
+
* quality signal, and failover already handles it.
|
|
87
93
|
* - `model_unavailable`: the guardrail or data policy excludes the endpoint;
|
|
88
94
|
* an availability fact, not a quality one, and failover already handles it.
|
|
89
95
|
*
|
|
@@ -92,7 +98,7 @@ interface CalibrationRow {
|
|
|
92
98
|
* unclassifiable legacy row and stays attributable, preserving the old,
|
|
93
99
|
* stricter behaviour rather than silently forgiving it.
|
|
94
100
|
*/
|
|
95
|
-
const UNATTRIBUTABLE_KINDS = "('aborted', 'auth', 'model_unavailable')";
|
|
101
|
+
const UNATTRIBUTABLE_KINDS = "('aborted', 'auth', 'moderation', 'model_unavailable')";
|
|
96
102
|
|
|
97
103
|
const ATTRIBUTABLE_ERROR = `error IS NOT NULL AND (error_kind IS NULL OR error_kind NOT IN ${UNATTRIBUTABLE_KINDS})`;
|
|
98
104
|
|
|
@@ -104,14 +110,22 @@ const TRUST_SELECT = `COUNT(*) AS attempts,
|
|
|
104
110
|
THEN ABS(reported_usd - predicted_usd) / reported_usd END) AS mean_cost_error`;
|
|
105
111
|
|
|
106
112
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
113
|
+
* Responsiveness over streamed, non-errored turns. TTFT (not total latency)
|
|
114
|
+
* isolates start latency from answer length: a model is "slow to start" when it
|
|
115
|
+
* takes a long time to emit the FIRST token. Throughput (aggregate completion
|
|
116
|
+
* tokens per post-TTFT second) captures the complementary axis — how fast the
|
|
117
|
+
* body streams once it starts. Errored/aborted and non-streaming rows (null
|
|
118
|
+
* ttft) are excluded; throughput additionally requires a positive completion
|
|
119
|
+
* count and elapsed time.
|
|
112
120
|
*/
|
|
113
121
|
const LATENCY_SELECT = `COUNT(CASE WHEN ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL THEN 1 END) AS samples,
|
|
114
|
-
AVG(CASE WHEN ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL THEN ttft_ms END) AS ttft_ms
|
|
122
|
+
AVG(CASE WHEN ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL THEN ttft_ms END) AS ttft_ms,
|
|
123
|
+
SUM(CASE WHEN ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL AND latency_ms > ttft_ms
|
|
124
|
+
AND json_extract(usage, '$.completionTokens') > 0
|
|
125
|
+
THEN json_extract(usage, '$.completionTokens') END) AS ctok_sum,
|
|
126
|
+
SUM(CASE WHEN ttft_ms IS NOT NULL AND ttft_ms > 0 AND error IS NULL AND latency_ms > ttft_ms
|
|
127
|
+
AND json_extract(usage, '$.completionTokens') > 0
|
|
128
|
+
THEN latency_ms - ttft_ms END) AS elapsed_ms_sum`;
|
|
115
129
|
|
|
116
130
|
/**
|
|
117
131
|
* Recovers the `UpstreamErrorKind` from the text turn.ts stored.
|
|
@@ -144,7 +158,11 @@ function toTrust(slug: string, row: TrustRow): ModelTrust {
|
|
|
144
158
|
|
|
145
159
|
function toLatency(slug: string, row: LatencyRow): ModelLatency | null {
|
|
146
160
|
if (row.samples <= 0 || row.ttft_ms === null) return null;
|
|
147
|
-
|
|
161
|
+
const tokensPerSec =
|
|
162
|
+
row.elapsed_ms_sum !== null && row.elapsed_ms_sum > 0 && row.ctok_sum !== null
|
|
163
|
+
? (row.ctok_sum * 1000) / row.elapsed_ms_sum
|
|
164
|
+
: 0;
|
|
165
|
+
return { slug, samples: row.samples, ttftMs: row.ttft_ms, tokensPerSec };
|
|
148
166
|
}
|
|
149
167
|
|
|
150
168
|
function toEntry(row: LedgerRow): LedgerEntry {
|
|
@@ -180,6 +198,7 @@ function toEntry(row: LedgerRow): LedgerEntry {
|
|
|
180
198
|
wasted: row.wasted === 1,
|
|
181
199
|
upstreamGenerationId: row.upstream_generation_id,
|
|
182
200
|
error: row.error,
|
|
201
|
+
promptTokensSaved: row.prompt_tokens_saved ?? 0,
|
|
183
202
|
};
|
|
184
203
|
}
|
|
185
204
|
|
|
@@ -190,8 +209,8 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
190
209
|
id, created_at_ms, conversation_key, session_id, turn, requested_model, harness_id, omp_session_id, slug, served_slug,
|
|
191
210
|
tier, classification_source, reasons, predicted_usd, reported_usd, usage, cost_breakdown,
|
|
192
211
|
attempt, escalation_signal, latency_ms, ttft_ms, finish_reason, wasted, upstream_generation_id, error,
|
|
193
|
-
error_kind, features, score, confidence, task, classifier_reasons, explored_from, hold_arm
|
|
194
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
212
|
+
error_kind, features, score, confidence, task, classifier_reasons, explored_from, hold_arm, prompt_tokens_saved
|
|
213
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
195
214
|
);
|
|
196
215
|
const calibrationStmt = db.query(
|
|
197
216
|
`INSERT INTO token_calibration (tokenizer, est_bytes, actual_tokens, samples) VALUES (?, ?, ?, 1)
|
|
@@ -279,6 +298,7 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
279
298
|
entry.classifierReasons === null ? null : JSON.stringify(entry.classifierReasons),
|
|
280
299
|
entry.exploredFrom,
|
|
281
300
|
entry.holdArm,
|
|
301
|
+
entry.promptTokensSaved,
|
|
282
302
|
);
|
|
283
303
|
// Always consume the pending estimate, even when the turn failed, so a
|
|
284
304
|
// dead turn's bytes can never pair with a later turn's tokens. Only
|
package/src/cost/types.ts
CHANGED
|
@@ -128,6 +128,8 @@ export interface LedgerEntry {
|
|
|
128
128
|
wasted: boolean;
|
|
129
129
|
upstreamGenerationId: string | null;
|
|
130
130
|
error: string | null;
|
|
131
|
+
/** Prompt tokens removed by compaction before dispatch. 0 when none. NULL before v12. */
|
|
132
|
+
promptTokensSaved: number;
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
/** Rolling blended rate used to keep omp's cost display honest. */
|
|
@@ -165,6 +167,15 @@ export interface ModelLatency {
|
|
|
165
167
|
samples: number;
|
|
166
168
|
/** Mean time-to-first-token, ms, over streamed non-errored turns. */
|
|
167
169
|
ttftMs: number;
|
|
170
|
+
/**
|
|
171
|
+
* Completion throughput, tokens/second, over streamed non-errored turns that
|
|
172
|
+
* emitted tokens (aggregate: total completion tokens / total post-TTFT time).
|
|
173
|
+
* 0 when no such row exists. Complements ttftMs: TTFT is how long the answer
|
|
174
|
+
* takes to START, throughput is how long it takes to FINISH — a model can be
|
|
175
|
+
* quick to first token yet stream the body slowly (e.g. deepseek-v4-flash:
|
|
176
|
+
* ~2s TTFT but ~20 tok/s and ~38s total).
|
|
177
|
+
*/
|
|
178
|
+
tokensPerSec: number;
|
|
168
179
|
}
|
|
169
180
|
|
|
170
181
|
export interface Ledger {
|
|
@@ -181,10 +192,10 @@ export interface Ledger {
|
|
|
181
192
|
trust(slug: string, harnessId?: string): ModelTrust | null;
|
|
182
193
|
allTrust(): ModelTrust[];
|
|
183
194
|
/**
|
|
184
|
-
* Per-model mean
|
|
185
|
-
* Null until `filters.latencyMinSamples` streamed samples
|
|
186
|
-
*
|
|
187
|
-
* how
|
|
195
|
+
* Per-model responsiveness (mean TTFT + completion throughput), optionally
|
|
196
|
+
* scoped to a harness. Null until `filters.latencyMinSamples` streamed samples
|
|
197
|
+
* exist. TTFT isolates start latency from answer length; throughput captures
|
|
198
|
+
* how fast the body streams once it starts.
|
|
188
199
|
*/
|
|
189
200
|
latency(slug: string, harnessId?: string): ModelLatency | null;
|
|
190
201
|
/** Observed chars-per-token ratio for a tokenizer family; null until calibrated. */
|
package/src/router/candidates.ts
CHANGED
|
@@ -73,14 +73,23 @@ const LATENCY_EXCESS_CAP = 3;
|
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
75
|
* Latency penalty as a multiplier on effective cost (>= 1; 1 = no penalty).
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
76
|
+
*
|
|
77
|
+
* Models the EXPECTED TOTAL WAIT the user experiences: time to first token, plus
|
|
78
|
+
* streaming the expected completion at the model's measured throughput. That wait
|
|
79
|
+
* above the reference (built from `latencyReferenceMs` + `latencyReferenceTokensPerSec`)
|
|
80
|
+
* inflates effective cost — the same lever trust uses for flakiness — so a faster
|
|
81
|
+
* model of equal quality and price outranks a sluggish one. Capturing throughput,
|
|
82
|
+
* not just TTFT, is what catches a model that starts fast but streams slowly
|
|
83
|
+
* (deepseek-v4-flash: ~2s TTFT yet ~20 tok/s → ~38s total). Inert when the weight
|
|
84
|
+
* is 0 or the model has too few streamed samples to judge; when throughput is
|
|
85
|
+
* unmeasured it degrades to a TTFT-only comparison against the reference wait.
|
|
80
86
|
*/
|
|
81
|
-
function latencyMultiplier(latency: ModelLatency | null, filters: FilterConfig): number {
|
|
87
|
+
function latencyMultiplier(latency: ModelLatency | null, filters: FilterConfig, expectedCompletionTokens: number): number {
|
|
82
88
|
if (latency === null || filters.latencyWeight <= 0 || latency.samples < filters.latencyMinSamples) return 1;
|
|
83
|
-
const
|
|
89
|
+
const streamMs = latency.tokensPerSec > 0 ? (expectedCompletionTokens / latency.tokensPerSec) * 1000 : 0;
|
|
90
|
+
const waitMs = latency.ttftMs + streamMs;
|
|
91
|
+
const refWaitMs = filters.latencyReferenceMs + (expectedCompletionTokens / filters.latencyReferenceTokensPerSec) * 1000;
|
|
92
|
+
const excess = refWaitMs > 0 ? Math.max(0, (waitMs - refWaitMs) / refWaitMs) : 0;
|
|
84
93
|
return 1 + filters.latencyWeight * Math.min(excess, LATENCY_EXCESS_CAP);
|
|
85
94
|
}
|
|
86
95
|
|
|
@@ -238,7 +247,7 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
238
247
|
filters.latencyWeight > 0
|
|
239
248
|
? (ledger?.latency(slug, filters.trustScopedByHarness ? req.harnessId : undefined) ?? null)
|
|
240
249
|
: null;
|
|
241
|
-
const latencyMult = latencyMultiplier(latency, filters);
|
|
250
|
+
const latencyMult = latencyMultiplier(latency, filters, expectedCompletionTokens);
|
|
242
251
|
const effectiveUsd = (fc.expectedUsd / Math.max(trustScore, 0.5)) * latencyMult;
|
|
243
252
|
const score = Math.pow(qualityScore / 100, tierCfg.qualityExponent) / Math.max(effectiveUsd, 1e-9);
|
|
244
253
|
|
|
@@ -252,7 +261,9 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
|
|
|
252
261
|
`expected $${fc.expectedUsd.toFixed(6)}`,
|
|
253
262
|
];
|
|
254
263
|
if (latencyMult > 1 && latency !== null) {
|
|
255
|
-
reasons.push(
|
|
264
|
+
reasons.push(
|
|
265
|
+
`latency penalty ×${latencyMult.toFixed(2)} (ttft ${Math.round(latency.ttftMs)}ms, ${latency.tokensPerSec.toFixed(0)} tok/s over ${latency.samples} samples)`,
|
|
266
|
+
);
|
|
256
267
|
}
|
|
257
268
|
if (pinned) reasons.push("pinned into tier");
|
|
258
269
|
candidates.push({ model, forecast: fc, qualityScore, trustScore, score, reasons });
|
package/src/router/classify.ts
CHANGED
|
@@ -32,7 +32,7 @@ const CAP_COMPLEXITY = 0.3;
|
|
|
32
32
|
const W_TRIVIALITY_KEYWORD = -0.09;
|
|
33
33
|
const CAP_TRIVIALITY = -0.27;
|
|
34
34
|
const W_TOOL_FAILED = 0.26; // dominant positive: retry loops are expensive
|
|
35
|
-
const
|
|
35
|
+
const W_CIRCULAR_LOOP = 0.24; // a re-issued (circular) tool call: the model is stuck
|
|
36
36
|
const W_TERSE = -0.1;
|
|
37
37
|
const W_CODE_BLOCK = 0.04;
|
|
38
38
|
const CAP_CODE = 0.08;
|
|
@@ -48,13 +48,20 @@ const CAP_LOOP_DEPTH = -0.06;
|
|
|
48
48
|
// A sustained autonomous loop is the signal the underlying task is substantial:
|
|
49
49
|
// the agent keeps grinding without human input. Unlike the mechanical-step
|
|
50
50
|
// penalty above, this term ACCUMULATES with depth past the agentic threshold so
|
|
51
|
-
// long
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
51
|
+
// long loops climb out of trivial. The ramp slope is calibrated on recorded
|
|
52
|
+
// coding turns; the cap governs the ceiling. `moderate` is NOT enough to break a
|
|
53
|
+
// distinct-read loop: on the coding axis the cheapest model above the moderate
|
|
54
|
+
// floor (deepseek-v4-flash, coding 69.1) also clears it after the latency
|
|
55
|
+
// penalty, so escalating to moderate never swaps the weak model off — live
|
|
56
|
+
// evidence (conv 888e5bddc1) is a loop grinding to depth 27+ on it. Only `hard`
|
|
57
|
+
// (floor 72) excludes that model and forces a materially stronger, faster one.
|
|
58
|
+
// So the cap lets a RUNAWAY loop (~depth 38 of pure continuations, no other
|
|
59
|
+
// signal) reach `hard`; the calibrated mid-range still tops out in `moderate`,
|
|
60
|
+
// and any corroborating stuck signal (circular call, tool failure, keywords)
|
|
61
|
+
// reaches `hard` far sooner.
|
|
55
62
|
const W_AUTONOMOUS_LOOP = 0.06; // base bonus at the threshold
|
|
56
63
|
const W_AUTONOMOUS_LOOP_PER_DEPTH = 0.018; // added per loop step beyond the threshold
|
|
57
|
-
const CAP_AUTONOMOUS_LOOP = 0.
|
|
64
|
+
const CAP_AUTONOMOUS_LOOP = 0.7; // pure depth ramps through moderate into hard for a runaway loop
|
|
58
65
|
const W_IMAGES = 0.04;
|
|
59
66
|
const W_TOOLS_OFFERED = 0.03;
|
|
60
67
|
|
|
@@ -97,7 +104,7 @@ export function scoreHeuristic(f: Features, cfg: RouterConfig): Classification {
|
|
|
97
104
|
`triviality keywords [${f.trivialityKeywords.join(", ")}]`,
|
|
98
105
|
);
|
|
99
106
|
if (f.lastToolFailed) add(W_TOOL_FAILED, "last tool result failed");
|
|
100
|
-
if (f.
|
|
107
|
+
if (f.circularToolCall) add(W_CIRCULAR_LOOP, "circular tool call (re-issued a prior call; stuck)");
|
|
101
108
|
const rw = reasoningWeight(f.requestedReasoning);
|
|
102
109
|
if (rw > 0) add(rw, `client requested reasoning=${f.requestedReasoning ?? ""}`);
|
|
103
110
|
if (f.isTerseInstruction) add(W_TERSE, "terse instruction");
|
|
@@ -140,13 +147,20 @@ export function pickQualityAxis(f: Features, cfg: RouterConfig): QualityAxis {
|
|
|
140
147
|
|
|
141
148
|
/**
|
|
142
149
|
* Task-type classification: the KIND of work, orthogonal to complexity tier.
|
|
143
|
-
* Cheap and deterministic — no tokenizer, no model call.
|
|
144
|
-
* hard signal (
|
|
145
|
-
* the newest user content. The task selects the quality axis
|
|
146
|
-
* filters; the tier still bounds cost.
|
|
150
|
+
* Cheap and deterministic — no tokenizer, no model call. A freshly supplied
|
|
151
|
+
* image is the only hard signal (vision); the rest are keyword/structural
|
|
152
|
+
* heuristics over the newest user content. The task selects the quality axis
|
|
153
|
+
* and capability filters; the tier still bounds cost.
|
|
147
154
|
*/
|
|
148
155
|
export function classifyTask(f: Features): TaskType {
|
|
149
|
-
|
|
156
|
+
// Vision is the KIND of work only when the human just supplied an image. A
|
|
157
|
+
// stale screenshot lingering in a long agent loop must not pin every
|
|
158
|
+
// mechanical tool-continuation to the vision (intelligence) axis, which
|
|
159
|
+
// systematically underscores cheap coding models and, at the moderate/hard
|
|
160
|
+
// floors, excludes them entirely in favour of frontier models. Capability
|
|
161
|
+
// (the payload still carries the image) is enforced separately via
|
|
162
|
+
// req.hasImages in buildCandidates, independent of the task axis.
|
|
163
|
+
if (f.hasNewImage) return "vision";
|
|
150
164
|
// Coding: code blocks, diffs, a tool loop, or tools offered — agent tool use
|
|
151
165
|
// is coding work. Bare chat (no tools, no code) falls through.
|
|
152
166
|
if (f.codeBlocks > 0 || f.looksLikeDiff || f.toolLoopDepth > 0 || f.toolCount > 0) return "coding";
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context compaction (Phase 1): deterministic pruning of stale, low-value bulk
|
|
3
|
+
* — chiefly old tool output — from a turn's history before dispatch.
|
|
4
|
+
*
|
|
5
|
+
* This module only DECIDES (a plan of per-message edits) from cheap NormMessage
|
|
6
|
+
* metadata; `renderUpstreamBody` APPLIES the edits to the raw message bytes.
|
|
7
|
+
* Every edit shrinks a single tool-result's CONTENT in place — never removes or
|
|
8
|
+
* reorders a message — so tool_call↔result pairing, cache-breakpoint indices,
|
|
9
|
+
* and the agentdox context-block append all stay valid. It is a pure function
|
|
10
|
+
* of its inputs, so `explain` still replays a past decision offline.
|
|
11
|
+
*
|
|
12
|
+
* See docs/context-optimization.md.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { CompactionConfig } from "../config/types.ts";
|
|
16
|
+
import type { CompactionEdit, NormMessage } from "../wire/types.ts";
|
|
17
|
+
|
|
18
|
+
export interface CompactionResult {
|
|
19
|
+
edits: CompactionEdit[];
|
|
20
|
+
/** Estimated prompt bytes removed by the plan. */
|
|
21
|
+
savedBytes: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const EMPTY: CompactionResult = { edits: [], savedBytes: 0 };
|
|
25
|
+
|
|
26
|
+
/** Approximate byte cost of an elision breadcrumb; savings are net of it. */
|
|
27
|
+
const BREADCRUMB_BYTES = 120;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* First string value in a tool call's argument JSON — a schema-agnostic proxy
|
|
31
|
+
* for the resource a call operates on (a `path`, `id`, `query`, ...). Used to
|
|
32
|
+
* detect when a later call supersedes an earlier read of the same resource.
|
|
33
|
+
*/
|
|
34
|
+
function primaryArg(argsJson: string): string | null {
|
|
35
|
+
try {
|
|
36
|
+
const parsed: unknown = JSON.parse(argsJson);
|
|
37
|
+
if (parsed !== null && typeof parsed === "object") {
|
|
38
|
+
for (const value of Object.values(parsed)) if (typeof value === "string" && value.length > 0) return value;
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// Malformed args carry no resource key; fall through.
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Index of the first message in the PROTECTED region: the last
|
|
48
|
+
* `protectRecentTurns` user/assistant turns and everything after them (the
|
|
49
|
+
* volatile tail). Messages before it are eligible for compaction. Returns 0
|
|
50
|
+
* (protect everything) when the conversation is shorter than the window.
|
|
51
|
+
*/
|
|
52
|
+
function protectFromIndex(messages: readonly NormMessage[], protectRecentTurns: number): number {
|
|
53
|
+
let turns = 0;
|
|
54
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
55
|
+
const role = messages[i]?.role;
|
|
56
|
+
if (role === "user" || role === "assistant") {
|
|
57
|
+
turns++;
|
|
58
|
+
if (turns >= protectRecentTurns) return i;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface ToolResult {
|
|
65
|
+
index: number;
|
|
66
|
+
name: string;
|
|
67
|
+
text: string;
|
|
68
|
+
bytes: number;
|
|
69
|
+
/** Primary argument of the originating call, for supersede detection. */
|
|
70
|
+
key: string | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Plans compaction for a turn's messages toward `targetBytes` of total prompt.
|
|
75
|
+
* Duplicate and superseded elisions (pure stale-data wins) are always applied;
|
|
76
|
+
* large-result truncation (more lossy) runs largest-first only until the target
|
|
77
|
+
* is met. `promptBytes` is the whole prompt (messages + system + tool schemas),
|
|
78
|
+
* so the target is compared against the real dispatched size.
|
|
79
|
+
*/
|
|
80
|
+
export function planCompaction(
|
|
81
|
+
messages: readonly NormMessage[],
|
|
82
|
+
cfg: CompactionConfig,
|
|
83
|
+
targetBytes: number,
|
|
84
|
+
promptBytes: number,
|
|
85
|
+
): CompactionResult {
|
|
86
|
+
if (!cfg.enabled) return EMPTY;
|
|
87
|
+
|
|
88
|
+
const protectStart = protectFromIndex(messages, cfg.protectRecentTurns);
|
|
89
|
+
if (protectStart <= 0) return EMPTY;
|
|
90
|
+
|
|
91
|
+
// Assistant tool_call id → name/args, to key tool results by their call.
|
|
92
|
+
const callById = new Map<string, { name: string; args: string }>();
|
|
93
|
+
for (const m of messages) {
|
|
94
|
+
if (m.role !== "assistant") continue;
|
|
95
|
+
for (const tc of m.toolCalls) callById.set(tc.id, { name: tc.name, args: tc.argsJson });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const tools: ToolResult[] = [];
|
|
99
|
+
for (let i = 0; i < protectStart; i++) {
|
|
100
|
+
const m = messages[i];
|
|
101
|
+
if (m === undefined || m.role !== "tool") continue;
|
|
102
|
+
const call = m.toolCallId === undefined ? undefined : callById.get(m.toolCallId);
|
|
103
|
+
tools.push({
|
|
104
|
+
index: i,
|
|
105
|
+
name: m.toolName ?? call?.name ?? "",
|
|
106
|
+
text: m.text,
|
|
107
|
+
bytes: m.textBytes,
|
|
108
|
+
key: call === undefined ? null : primaryArg(call.args),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (tools.length === 0) return EMPTY;
|
|
112
|
+
|
|
113
|
+
const edits: CompactionEdit[] = [];
|
|
114
|
+
const done = new Set<number>();
|
|
115
|
+
let saved = 0;
|
|
116
|
+
const stub = (t: ToolResult, note: string): void => {
|
|
117
|
+
if (done.has(t.index)) return;
|
|
118
|
+
const gain = t.bytes - BREADCRUMB_BYTES;
|
|
119
|
+
if (gain <= 0) return; // already smaller than a breadcrumb
|
|
120
|
+
edits.push({ index: t.index, mode: "stub", keepHead: 0, keepTail: 0, note });
|
|
121
|
+
done.add(t.index);
|
|
122
|
+
saved += gain;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Rule 1: collapse byte-identical duplicate results, keeping the LAST copy
|
|
126
|
+
// (consistent with supersede below, so a result that is both never loses
|
|
127
|
+
// every copy).
|
|
128
|
+
if (cfg.collapseDuplicateResults) {
|
|
129
|
+
const lastByContent = new Map<string, number>();
|
|
130
|
+
for (const t of tools) lastByContent.set(`${t.name}\u0000${t.text}`, t.index);
|
|
131
|
+
for (const t of tools) {
|
|
132
|
+
if (lastByContent.get(`${t.name}\u0000${t.text}`) !== t.index) stub(t, `identical repeated ${t.name || "tool"} result`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Rule 2: elide reads superseded by a newer call to the same resource,
|
|
137
|
+
// keeping the LAST (authoritative) one.
|
|
138
|
+
if (cfg.elideSupersededReads) {
|
|
139
|
+
const lastIndexByResource = new Map<string, number>();
|
|
140
|
+
for (const t of tools) if (t.key !== null) lastIndexByResource.set(`${t.name}\u0000${t.key}`, t.index);
|
|
141
|
+
for (const t of tools) {
|
|
142
|
+
if (t.key === null || done.has(t.index)) continue;
|
|
143
|
+
const last = lastIndexByResource.get(`${t.name}\u0000${t.key}`);
|
|
144
|
+
if (last !== undefined && last !== t.index) stub(t, `superseded by a newer ${t.name || "tool"} call`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Rule 3: truncate large stale results, largest first, until under target.
|
|
149
|
+
const keepBudget = cfg.keepHeadBytes + cfg.keepTailBytes + BREADCRUMB_BYTES;
|
|
150
|
+
const truncatable = tools
|
|
151
|
+
.filter((t) => !done.has(t.index) && t.bytes > cfg.maxToolResultBytes && t.bytes > keepBudget)
|
|
152
|
+
.sort((a, b) => b.bytes - a.bytes || a.index - b.index);
|
|
153
|
+
for (const t of truncatable) {
|
|
154
|
+
if (promptBytes - saved <= targetBytes) break;
|
|
155
|
+
edits.push({ index: t.index, mode: "truncate", keepHead: cfg.keepHeadBytes, keepTail: cfg.keepTailBytes, note: `large ${t.name || "tool"} result` });
|
|
156
|
+
done.add(t.index);
|
|
157
|
+
saved += t.bytes - keepBudget;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (edits.length === 0) return EMPTY;
|
|
161
|
+
edits.sort((a, b) => a.index - b.index);
|
|
162
|
+
return { edits, savedBytes: saved };
|
|
163
|
+
}
|
package/src/router/features.ts
CHANGED
|
@@ -95,6 +95,9 @@ export function extractFeatures(req: NormRequest, promptTokens: number): Feature
|
|
|
95
95
|
const isToolResultContinuation = tail?.role === "tool";
|
|
96
96
|
let newContentBytes = 0;
|
|
97
97
|
let newestUserText = "";
|
|
98
|
+
// Images in the volatile tail: an image the human just supplied is visual
|
|
99
|
+
// work; a tool-result continuation carries none of its own.
|
|
100
|
+
let newestRunImages = 0;
|
|
98
101
|
if (isToolResultContinuation) {
|
|
99
102
|
const start = trailingRunStart(messages, (m) => m.role === "tool");
|
|
100
103
|
for (let i = start; i < messages.length; i++) newContentBytes += messages[i]?.textBytes ?? 0;
|
|
@@ -105,6 +108,7 @@ export function extractFeatures(req: NormRequest, promptTokens: number): Feature
|
|
|
105
108
|
const m = messages[i];
|
|
106
109
|
if (m === undefined) continue;
|
|
107
110
|
newContentBytes += m.textBytes;
|
|
111
|
+
newestRunImages += m.images;
|
|
108
112
|
parts.push(m.text);
|
|
109
113
|
}
|
|
110
114
|
newestUserText = parts.join("\n");
|
|
@@ -136,28 +140,35 @@ export function extractFeatures(req: NormRequest, promptTokens: number): Feature
|
|
|
136
140
|
if (m.toolName !== undefined) toolNames.add(m.toolName);
|
|
137
141
|
}
|
|
138
142
|
|
|
139
|
-
// The last
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
143
|
+
// The last few assistant tool calls, most-recent first, for stuck-loop
|
|
144
|
+
// detection. A byte-identical call re-issued within this window means the
|
|
145
|
+
// agent is going in circles even when the repeat is not adjacent — the case
|
|
146
|
+
// the strict "last two identical" check misses.
|
|
147
|
+
const RECENT_CALLS = 4;
|
|
148
|
+
const recentCalls: { name: string; args: string }[] = [];
|
|
144
149
|
scanCalls: for (let i = messages.length - 1; i >= 0; i--) {
|
|
145
150
|
const m = messages[i];
|
|
146
151
|
if (m === undefined || m.role !== "assistant") continue;
|
|
147
152
|
for (let j = m.toolCalls.length - 1; j >= 0; j--) {
|
|
148
153
|
const tc = m.toolCalls[j];
|
|
149
154
|
if (tc === undefined) continue;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
recentCalls.push({ name: tc.name, args: tc.argsJson });
|
|
156
|
+
if (recentCalls.length >= RECENT_CALLS) break scanCalls;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const repeatedToolCall =
|
|
160
|
+
recentCalls.length >= 2 &&
|
|
161
|
+
recentCalls[0]?.name === recentCalls[1]?.name &&
|
|
162
|
+
recentCalls[0]?.args === recentCalls[1]?.args;
|
|
163
|
+
let circularToolCall = false;
|
|
164
|
+
for (let a = 0; a < recentCalls.length && !circularToolCall; a++) {
|
|
165
|
+
for (let b = a + 1; b < recentCalls.length; b++) {
|
|
166
|
+
if (recentCalls[a]?.name === recentCalls[b]?.name && recentCalls[a]?.args === recentCalls[b]?.args) {
|
|
167
|
+
circularToolCall = true;
|
|
168
|
+
break;
|
|
157
169
|
}
|
|
158
170
|
}
|
|
159
171
|
}
|
|
160
|
-
const repeatedToolCall = lastName !== null && lastName === prevName && lastArgs === prevArgs;
|
|
161
172
|
|
|
162
173
|
let lastToolFailed = false;
|
|
163
174
|
if (isToolResultContinuation) {
|
|
@@ -212,7 +223,9 @@ export function extractFeatures(req: NormRequest, promptTokens: number): Feature
|
|
|
212
223
|
distinctToolsUsed: toolNames.size,
|
|
213
224
|
lastToolFailed,
|
|
214
225
|
repeatedToolCall,
|
|
226
|
+
circularToolCall,
|
|
215
227
|
hasImages: req.hasImages,
|
|
228
|
+
hasNewImage: newestRunImages > 0,
|
|
216
229
|
codeBlocks,
|
|
217
230
|
codeBytes,
|
|
218
231
|
looksLikeDiff: DIFF_RE.test(newestUserText),
|
package/src/router/select.ts
CHANGED
|
@@ -11,7 +11,8 @@ import type { ProfileConfig, RouterConfig } from "../config/types.ts";
|
|
|
11
11
|
import { priceAt } from "../cost/forecast.ts";
|
|
12
12
|
import type { Ledger } from "../cost/types.ts";
|
|
13
13
|
import { explorationDraw } from "./explore.ts";
|
|
14
|
-
import type { NormRequest, ReasoningLevel } from "../wire/types.ts";
|
|
14
|
+
import type { CompactionEdit, NormRequest, ReasoningLevel } from "../wire/types.ts";
|
|
15
|
+
import { planCompaction } from "./compaction.ts";
|
|
15
16
|
import { planCacheBreakpoints } from "./cache-control.ts";
|
|
16
17
|
import { buildCandidates } from "./candidates.ts";
|
|
17
18
|
import {
|
|
@@ -147,6 +148,36 @@ export function select(args: SelectArgs): Decision {
|
|
|
147
148
|
// exploration (2c) and candidate building (3) so both agree on the term.
|
|
148
149
|
const cacheWarm = state.cacheWarmSlug !== null && nowMs - state.cacheWarmAtMs <= cfg.hysteresis.cacheWarmTtlMs;
|
|
149
150
|
|
|
151
|
+
// 2b. Context compaction: shrink stale tool output before dispatch when the
|
|
152
|
+
// prompt exceeds the token budget (or would overflow the profile window).
|
|
153
|
+
// Deterministic and content-only (never removes a message), so downstream
|
|
154
|
+
// forecasting, the context_too_small filter, cache breakpoints, and the
|
|
155
|
+
// agentdox block append all operate on the compacted size / stay valid.
|
|
156
|
+
let compactionPlan: CompactionEdit[] = [];
|
|
157
|
+
let promptTokensSaved = 0;
|
|
158
|
+
let effFeatures = features;
|
|
159
|
+
if (cfg.compaction.enabled && req.promptBytes > 0 && features.promptTokens > 0) {
|
|
160
|
+
const headroom = cfg.filters.contextHeadroom;
|
|
161
|
+
const overBudget = features.promptTokens > cfg.compaction.budgetTokens;
|
|
162
|
+
const overWindow =
|
|
163
|
+
cfg.compaction.fitToWindow && features.promptTokens * headroom + EXPECTED_COMPLETION_TOKENS > profile.contextWindow;
|
|
164
|
+
if (overBudget || overWindow) {
|
|
165
|
+
const targets: number[] = [];
|
|
166
|
+
if (overBudget) targets.push(cfg.compaction.budgetTokens);
|
|
167
|
+
if (overWindow) targets.push(Math.max(1, Math.floor((profile.contextWindow - EXPECTED_COMPLETION_TOKENS) / headroom)));
|
|
168
|
+
const targetBytes = Math.min(...targets) * (req.promptBytes / features.promptTokens);
|
|
169
|
+
const plan = planCompaction(req.messages, cfg.compaction, targetBytes, req.promptBytes);
|
|
170
|
+
if (plan.edits.length > 0) {
|
|
171
|
+
compactionPlan = plan.edits;
|
|
172
|
+
promptTokensSaved = Math.min(features.promptTokens - 1, Math.round(features.promptTokens * (plan.savedBytes / req.promptBytes)));
|
|
173
|
+
effFeatures = { ...features, promptTokens: features.promptTokens - promptTokensSaved };
|
|
174
|
+
reasons.push(
|
|
175
|
+
`compaction: ${plan.edits.length} tool result(s) shrunk, ~${promptTokensSaved} tokens saved (prompt ${features.promptTokens}→${effFeatures.promptTokens})`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
150
181
|
// 2c. Epsilon-greedy exploration: on a small deterministic fraction of
|
|
151
182
|
// turns, route one tier BELOW the tier we would otherwise use, so the
|
|
152
183
|
// ledger witnesses whether the cheaper tier would have sufficed.
|
|
@@ -201,7 +232,7 @@ export function select(args: SelectArgs): Decision {
|
|
|
201
232
|
const build = (t: Tier, relaxLevel = 0): { candidates: Candidate[]; rejected: Rejection[] } =>
|
|
202
233
|
buildCandidates({
|
|
203
234
|
req,
|
|
204
|
-
features,
|
|
235
|
+
features: effFeatures,
|
|
205
236
|
tier: t,
|
|
206
237
|
task: classification.task,
|
|
207
238
|
snapshot,
|
|
@@ -283,9 +314,9 @@ export function select(args: SelectArgs): Decision {
|
|
|
283
314
|
const warm = candidates.find((c) => c.model.slug === warmSlug);
|
|
284
315
|
if (warm !== undefined) {
|
|
285
316
|
const warmPrice = priceAt(warm.model, Math.max(1, state.lastPromptTokens));
|
|
286
|
-
const newPrice = priceAt(chosen.model, Math.max(1,
|
|
317
|
+
const newPrice = priceAt(chosen.model, Math.max(1, effFeatures.promptTokens));
|
|
287
318
|
const stayCost = state.lastPromptTokens * (warmPrice.cacheRead ?? warmPrice.prompt);
|
|
288
|
-
const switchCost =
|
|
319
|
+
const switchCost = effFeatures.promptTokens * (newPrice.prompt + (newPrice.cacheWrite ?? 0));
|
|
289
320
|
if (stayCost > switchCost * cfg.hysteresis.switchMargin) {
|
|
290
321
|
reasons.push(
|
|
291
322
|
`cache: switch ${warmSlug} → ${chosen.model.slug} (stay $${stayCost.toFixed(4)} > switch $${switchCost.toFixed(4)} × ${cfg.hysteresis.switchMargin})`,
|
|
@@ -402,6 +433,8 @@ export function select(args: SelectArgs): Decision {
|
|
|
402
433
|
sessionId: state.sessionId,
|
|
403
434
|
sticky,
|
|
404
435
|
cacheBreakpointMessageIndices,
|
|
436
|
+
compactionPlan,
|
|
437
|
+
promptTokensSaved,
|
|
405
438
|
reasoning,
|
|
406
439
|
maxTokens,
|
|
407
440
|
stripAssistantReasoning,
|
package/src/router/state.ts
CHANGED
|
@@ -26,6 +26,8 @@ interface Row {
|
|
|
26
26
|
last_prompt_tokens: number;
|
|
27
27
|
cache_warm_slug: string | null;
|
|
28
28
|
cache_warm_at_ms: number;
|
|
29
|
+
context_version: string | null;
|
|
30
|
+
context_fetched_at_ms: number;
|
|
29
31
|
updated_at_ms: number;
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -43,6 +45,8 @@ function toState(row: Row): ConversationState {
|
|
|
43
45
|
lastPromptTokens: row.last_prompt_tokens,
|
|
44
46
|
cacheWarmSlug: row.cache_warm_slug,
|
|
45
47
|
cacheWarmAtMs: row.cache_warm_at_ms,
|
|
48
|
+
contextVersion: row.context_version,
|
|
49
|
+
contextFetchedAtMs: row.context_fetched_at_ms,
|
|
46
50
|
updatedAtMs: row.updated_at_ms,
|
|
47
51
|
};
|
|
48
52
|
}
|
|
@@ -56,9 +60,11 @@ export function createConversationStore(db: Database): ConversationStore {
|
|
|
56
60
|
const upsert = db.query(`
|
|
57
61
|
INSERT INTO conversations (
|
|
58
62
|
key, session_id, turn, current_slug, current_tier, sticky_until_turn,
|
|
59
|
-
escalations, spent_usd, last_prompt_tokens, cache_warm_slug, cache_warm_at_ms,
|
|
63
|
+
escalations, spent_usd, last_prompt_tokens, cache_warm_slug, cache_warm_at_ms,
|
|
64
|
+
context_version, context_fetched_at_ms, updated_at_ms
|
|
60
65
|
) VALUES ($key, $sessionId, $turn, $currentSlug, $currentTier, $stickyUntilTurn,
|
|
61
|
-
$escalations, $spentUsd, $lastPromptTokens, $cacheWarmSlug, $cacheWarmAtMs,
|
|
66
|
+
$escalations, $spentUsd, $lastPromptTokens, $cacheWarmSlug, $cacheWarmAtMs,
|
|
67
|
+
$contextVersion, $contextFetchedAtMs, $updatedAtMs)
|
|
62
68
|
ON CONFLICT(key) DO UPDATE SET
|
|
63
69
|
session_id = excluded.session_id,
|
|
64
70
|
turn = excluded.turn,
|
|
@@ -70,6 +76,8 @@ export function createConversationStore(db: Database): ConversationStore {
|
|
|
70
76
|
last_prompt_tokens = excluded.last_prompt_tokens,
|
|
71
77
|
cache_warm_slug = excluded.cache_warm_slug,
|
|
72
78
|
cache_warm_at_ms = excluded.cache_warm_at_ms,
|
|
79
|
+
context_version = excluded.context_version,
|
|
80
|
+
context_fetched_at_ms = excluded.context_fetched_at_ms,
|
|
73
81
|
updated_at_ms = excluded.updated_at_ms
|
|
74
82
|
`);
|
|
75
83
|
const deleteStale: Statement<unknown, [number]> = db.query("DELETE FROM conversations WHERE updated_at_ms < ?");
|
|
@@ -107,6 +115,8 @@ export function createConversationStore(db: Database): ConversationStore {
|
|
|
107
115
|
$lastPromptTokens: state.lastPromptTokens,
|
|
108
116
|
$cacheWarmSlug: state.cacheWarmSlug,
|
|
109
117
|
$cacheWarmAtMs: state.cacheWarmAtMs,
|
|
118
|
+
$contextVersion: state.contextVersion,
|
|
119
|
+
$contextFetchedAtMs: state.contextFetchedAtMs,
|
|
110
120
|
$updatedAtMs: Date.now(),
|
|
111
121
|
});
|
|
112
122
|
},
|