auto-model-router 0.2.1 → 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.
Files changed (51) hide show
  1. package/.claude/skills/agentdox/SKILL.md +143 -0
  2. package/.mcp.json +11 -0
  3. package/.omp-plugin/marketplace.json +2 -2
  4. package/CLAUDE.md +129 -0
  5. package/README.md +64 -0
  6. package/docs/AGENTDOX-BRIDGE.md +132 -0
  7. package/docs/context-optimization.md +362 -0
  8. package/omp-extension/embed-logic.ts +31 -0
  9. package/omp-extension/router-embed.ts +7 -1
  10. package/package.json +1 -1
  11. package/src/cli/config-cmd.ts +20 -5
  12. package/src/cli/explain.ts +1 -0
  13. package/src/config/defaults.ts +37 -0
  14. package/src/config/load.ts +13 -0
  15. package/src/config/schema.ts +30 -0
  16. package/src/config/types.ts +85 -0
  17. package/src/context/agentdox.ts +113 -0
  18. package/src/context/bridge.ts +166 -0
  19. package/src/context/index.ts +33 -0
  20. package/src/context/store.ts +82 -0
  21. package/src/context/types.ts +78 -0
  22. package/src/cost/ledger.ts +57 -6
  23. package/src/cost/types.ts +26 -0
  24. package/src/router/candidates.ts +41 -5
  25. package/src/router/classify.ts +26 -12
  26. package/src/router/compaction.ts +163 -0
  27. package/src/router/features.ts +26 -13
  28. package/src/router/select.ts +37 -4
  29. package/src/router/state.ts +12 -2
  30. package/src/router/types.ts +33 -1
  31. package/src/server/http.ts +18 -1
  32. package/src/server/turn.ts +88 -1
  33. package/src/upstream/openrouter.ts +8 -1
  34. package/src/util/sqlite.ts +34 -1
  35. package/src/wire/openai/request.ts +86 -1
  36. package/src/wire/types.ts +36 -0
  37. package/test/classify.test.ts +63 -5
  38. package/test/compaction.test.ts +148 -0
  39. package/test/context-bridge.test.ts +337 -0
  40. package/test/embed-logic.test.ts +32 -0
  41. package/test/escalate.test.ts +1 -0
  42. package/test/exploration.test.ts +6 -2
  43. package/test/failover.test.ts +52 -6
  44. package/test/features.test.ts +45 -0
  45. package/test/helpers/inject.ts +23 -0
  46. package/test/hold-exploration.test.ts +4 -2
  47. package/test/select.test.ts +129 -0
  48. package/test/tokens.test.ts +1 -0
  49. package/test/trust-attribution.test.ts +61 -4
  50. package/test/turn.test.ts +21 -10
  51. package/tools/agentdox-e2e.ts +123 -0
@@ -18,7 +18,7 @@ import type { RouterConfig } from "../config/types.ts";
18
18
  import { consumePendingEstimate } from "../tokens/estimate.ts";
19
19
  import { computeBlendedRate } from "./blended.ts";
20
20
  import { computeCost } from "./forecast.ts";
21
- import type { BlendedRate, Ledger, LedgerEntry, ModelTrust, UsageCounts } from "./types.ts";
21
+ import type { BlendedRate, Ledger, LedgerEntry, ModelLatency, ModelTrust, UsageCounts } from "./types.ts";
22
22
 
23
23
  /** Estimates below this many samples are noise; the default ratio is better. */
24
24
  const MIN_CALIBRATION_SAMPLES = 20;
@@ -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 {
@@ -67,6 +68,13 @@ interface TrustRow {
67
68
  mean_cost_error: number | null;
68
69
  }
69
70
 
71
+ interface LatencyRow {
72
+ samples: number;
73
+ ttft_ms: number | null;
74
+ ctok_sum: number | null;
75
+ elapsed_ms_sum: number | null;
76
+ }
77
+
70
78
  interface CalibrationRow {
71
79
  est_bytes: number;
72
80
  actual_tokens: number;
@@ -77,8 +85,11 @@ interface CalibrationRow {
77
85
  * Error kinds that say nothing about a MODEL's reliability, and so must not
78
86
  * count against its trust:
79
87
  * - `aborted`: the client hung up (user pressed escape mid-turn).
80
- * - `auth`: credential, credit, or account-policy refusal (age confirmation,
81
- * prompt-injection blocking) — identical for every model on the key.
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.
82
93
  * - `model_unavailable`: the guardrail or data policy excludes the endpoint;
83
94
  * an availability fact, not a quality one, and failover already handles it.
84
95
  *
@@ -87,7 +98,7 @@ interface CalibrationRow {
87
98
  * unclassifiable legacy row and stays attributable, preserving the old,
88
99
  * stricter behaviour rather than silently forgiving it.
89
100
  */
90
- const UNATTRIBUTABLE_KINDS = "('aborted', 'auth', 'model_unavailable')";
101
+ const UNATTRIBUTABLE_KINDS = "('aborted', 'auth', 'moderation', 'model_unavailable')";
91
102
 
92
103
  const ATTRIBUTABLE_ERROR = `error IS NOT NULL AND (error_kind IS NULL OR error_kind NOT IN ${UNATTRIBUTABLE_KINDS})`;
93
104
 
@@ -98,6 +109,24 @@ const TRUST_SELECT = `COUNT(*) AS attempts,
98
109
  AVG(CASE WHEN reported_usd IS NOT NULL AND reported_usd > 0
99
110
  THEN ABS(reported_usd - predicted_usd) / reported_usd END) AS mean_cost_error`;
100
111
 
112
+ /**
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.
120
+ */
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,
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`;
129
+
101
130
  /**
102
131
  * Recovers the `UpstreamErrorKind` from the text turn.ts stored.
103
132
  *
@@ -127,6 +156,15 @@ function toTrust(slug: string, row: TrustRow): ModelTrust {
127
156
  };
128
157
  }
129
158
 
159
+ function toLatency(slug: string, row: LatencyRow): ModelLatency | null {
160
+ if (row.samples <= 0 || row.ttft_ms === null) return null;
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 };
166
+ }
167
+
130
168
  function toEntry(row: LedgerRow): LedgerEntry {
131
169
  return {
132
170
  id: row.id,
@@ -160,6 +198,7 @@ function toEntry(row: LedgerRow): LedgerEntry {
160
198
  wasted: row.wasted === 1,
161
199
  upstreamGenerationId: row.upstream_generation_id,
162
200
  error: row.error,
201
+ promptTokensSaved: row.prompt_tokens_saved ?? 0,
163
202
  };
164
203
  }
165
204
 
@@ -170,8 +209,8 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
170
209
  id, created_at_ms, conversation_key, session_id, turn, requested_model, harness_id, omp_session_id, slug, served_slug,
171
210
  tier, classification_source, reasons, predicted_usd, reported_usd, usage, cost_breakdown,
172
211
  attempt, escalation_signal, latency_ms, ttft_ms, finish_reason, wasted, upstream_generation_id, error,
173
- error_kind, features, score, confidence, task, classifier_reasons, explored_from, hold_arm
174
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
212
+ error_kind, features, score, confidence, task, classifier_reasons, explored_from, hold_arm, prompt_tokens_saved
213
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
175
214
  );
176
215
  const calibrationStmt = db.query(
177
216
  `INSERT INTO token_calibration (tokenizer, est_bytes, actual_tokens, samples) VALUES (?, ?, ?, 1)
@@ -192,6 +231,8 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
192
231
  const trustStmt = db.query(`SELECT ${TRUST_SELECT} FROM ledger WHERE slug = ?`);
193
232
  const trustHarnessStmt = db.query(`SELECT ${TRUST_SELECT} FROM ledger WHERE slug = ? AND harness_id = ?`);
194
233
  const allTrustStmt = db.query(`SELECT slug, ${TRUST_SELECT} FROM ledger GROUP BY slug`);
234
+ const latencyStmt = db.query(`SELECT ${LATENCY_SELECT} FROM ledger WHERE slug = ?`);
235
+ const latencyHarnessStmt = db.query(`SELECT ${LATENCY_SELECT} FROM ledger WHERE slug = ? AND harness_id = ?`);
195
236
  const ratioStmt = db.query("SELECT est_bytes, actual_tokens, samples FROM token_calibration WHERE tokenizer = ?");
196
237
  const recentStmt = db.query("SELECT * FROM ledger ORDER BY created_at_ms DESC LIMIT ?");
197
238
  const cacheMetaStmt = db.query("SELECT fetched_at_ms FROM catalog_cache WHERE id = 1");
@@ -257,6 +298,7 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
257
298
  entry.classifierReasons === null ? null : JSON.stringify(entry.classifierReasons),
258
299
  entry.exploredFrom,
259
300
  entry.holdArm,
301
+ entry.promptTokensSaved,
260
302
  );
261
303
  // Always consume the pending estimate, even when the turn failed, so a
262
304
  // dead turn's bytes can never pair with a later turn's tokens. Only
@@ -301,6 +343,15 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
301
343
  return rows.map((row) => toTrust(row.slug, row));
302
344
  },
303
345
 
346
+ latency(slug: string, harnessId?: string): ModelLatency | null {
347
+ const row =
348
+ harnessId !== undefined && harnessId !== ""
349
+ ? (latencyHarnessStmt.get(slug, harnessId) as LatencyRow | null)
350
+ : (latencyStmt.get(slug) as LatencyRow | null);
351
+ if (row === null) return null;
352
+ return toLatency(slug, row);
353
+ },
354
+
304
355
  tokenRatio(tokenizer: string): number | null {
305
356
  const row = ratioStmt.get(tokenizer.trim().toLowerCase()) as CalibrationRow | null;
306
357
  if (row === null || row.samples < MIN_CALIBRATION_SAMPLES || row.actual_tokens <= 0) return null;
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. */
@@ -159,6 +161,23 @@ export interface ModelTrust {
159
161
  meanCostError: number;
160
162
  }
161
163
 
164
+ /** Per-model responsiveness learned from our own traffic. Feeds candidate scoring. */
165
+ export interface ModelLatency {
166
+ slug: string;
167
+ samples: number;
168
+ /** Mean time-to-first-token, ms, over streamed non-errored turns. */
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;
179
+ }
180
+
162
181
  export interface Ledger {
163
182
  record(entry: LedgerEntry): void;
164
183
  /** Total reported (or predicted, when reported is null) spend for a conversation. */
@@ -172,6 +191,13 @@ export interface Ledger {
172
191
  /** Per-model reliability over the ledger, optionally scoped to a harness. */
173
192
  trust(slug: string, harnessId?: string): ModelTrust | null;
174
193
  allTrust(): ModelTrust[];
194
+ /**
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.
199
+ */
200
+ latency(slug: string, harnessId?: string): ModelLatency | null;
175
201
  /** Observed chars-per-token ratio for a tokenizer family; null until calibrated. */
176
202
  tokenRatio(tokenizer: string): number | null;
177
203
  recentEntries(limit: number): LedgerEntry[];
@@ -5,9 +5,9 @@
5
5
  */
6
6
 
7
7
  import type { CatalogModel, CatalogSnapshot } from "../catalog/types.ts";
8
- import type { QualityAxis, RouterConfig } from "../config/types.ts";
8
+ import type { FilterConfig, QualityAxis, RouterConfig } from "../config/types.ts";
9
9
  import { forecast, priceAt } from "../cost/forecast.ts";
10
- import type { Ledger } from "../cost/types.ts";
10
+ import type { Ledger, ModelLatency } from "../cost/types.ts";
11
11
  import type { NormRequest } from "../wire/types.ts";
12
12
  import { effectivePriceCeiling, effectiveQualityFloor, tierPlanFor } from "./tier-plan.ts";
13
13
  import type { Candidate, Features, Rejection, TaskType, Tier } from "./types.ts";
@@ -68,6 +68,31 @@ function resolveQuality(model: CatalogModel, axis: QualityAxis): { score: number
68
68
  /** Neutral trust prior for models our ledger has never observed. */
69
69
  const UNMEASURED_TRUST = 0.9;
70
70
 
71
+ /** Excess-ratio cap so one very slow model cannot be penalised into oblivion. */
72
+ const LATENCY_EXCESS_CAP = 3;
73
+
74
+ /**
75
+ * Latency penalty as a multiplier on effective cost (>= 1; 1 = no penalty).
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.
86
+ */
87
+ function latencyMultiplier(latency: ModelLatency | null, filters: FilterConfig, expectedCompletionTokens: number): number {
88
+ if (latency === null || filters.latencyWeight <= 0 || latency.samples < filters.latencyMinSamples) return 1;
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;
93
+ return 1 + filters.latencyWeight * Math.min(excess, LATENCY_EXCESS_CAP);
94
+ }
95
+
71
96
  export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candidate[]; rejected: Rejection[] } {
72
97
  const { req, features, tier, task, snapshot, ledger, cfg, expectedCompletionTokens, warmSlug, relaxLevel = 0 } = args;
73
98
  // A Set only when non-empty: the common path allocates nothing.
@@ -215,9 +240,15 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
215
240
  const trustScore = trust !== null && trust.attempts > 0 ? trust.successRate : UNMEASURED_TRUST;
216
241
  const qualityScore = quality?.score ?? 0;
217
242
  // Shared scoring: trust converts flakiness into money — a model failing
218
- // 20% of the time really costs ~25% more in retries. qualityExponent 0
219
- // makes this "cheapest above the floor"; the floor does the quality work.
220
- const effectiveUsd = fc.expectedUsd / Math.max(trustScore, 0.5);
243
+ // 20% of the time really costs ~25% more in retries. Latency does the same
244
+ // for slowness (TTFT over the reference). qualityExponent 0 makes this
245
+ // "cheapest above the floor"; the floor does the quality work.
246
+ const latency =
247
+ filters.latencyWeight > 0
248
+ ? (ledger?.latency(slug, filters.trustScopedByHarness ? req.harnessId : undefined) ?? null)
249
+ : null;
250
+ const latencyMult = latencyMultiplier(latency, filters, expectedCompletionTokens);
251
+ const effectiveUsd = (fc.expectedUsd / Math.max(trustScore, 0.5)) * latencyMult;
221
252
  const score = Math.pow(qualityScore / 100, tierCfg.qualityExponent) / Math.max(effectiveUsd, 1e-9);
222
253
 
223
254
  const reasons: string[] = [
@@ -229,6 +260,11 @@ export function buildCandidates(args: BuildCandidatesArgs): { candidates: Candid
229
260
  : `trust ${trustScore.toFixed(2)} over ${trust.attempts} attempts`,
230
261
  `expected $${fc.expectedUsd.toFixed(6)}`,
231
262
  ];
263
+ if (latencyMult > 1 && latency !== null) {
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
+ );
267
+ }
232
268
  if (pinned) reasons.push("pinned into tier");
233
269
  candidates.push({ model, forecast: fc, qualityScore, trustScore, score, reasons });
234
270
  }
@@ -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 W_REPEATED_CALL = 0.14; // the model is stuck
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/hard loops climb out of trivial instead of scoring like a one-line edit.
52
- // Capped so pure depth tops out in `simple` (a competent-but-cheap model);
53
- // reaching `moderate`/`hard` still requires real complexity signals (tool
54
- // failure, repeated call, keywords) to stack on top.
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.34;
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.repeatedToolCall) add(W_REPEATED_CALL, "repeated tool call (model is stuck)");
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. Vision is the only
144
- * hard signal (image input); the rest are keyword/structural heuristics over
145
- * the newest user content. The task selects the quality axis and capability
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
- if (f.hasImages) return "vision";
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
+ }
@@ -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 two assistant tool calls, in conversation order.
140
- let lastName: string | null = null;
141
- let lastArgs = "";
142
- let prevName: string | null = null;
143
- let prevArgs = "";
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
- if (lastName === null) {
151
- lastName = tc.name;
152
- lastArgs = tc.argsJson;
153
- } else {
154
- prevName = tc.name;
155
- prevArgs = tc.argsJson;
156
- break scanCalls;
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),
@@ -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, features.promptTokens));
317
+ const newPrice = priceAt(chosen.model, Math.max(1, effFeatures.promptTokens));
287
318
  const stayCost = state.lastPromptTokens * (warmPrice.cacheRead ?? warmPrice.prompt);
288
- const switchCost = features.promptTokens * (newPrice.prompt + (newPrice.cacheWrite ?? 0));
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,