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.
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 +33 -1
  14. package/src/config/load.ts +13 -0
  15. package/src/config/schema.ts +27 -0
  16. package/src/config/types.ts +79 -5
  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 +32 -12
  23. package/src/cost/types.ts +15 -4
  24. package/src/router/candidates.ts +19 -8
  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 +51 -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 +86 -3
  48. package/test/tokens.test.ts +1 -0
  49. package/test/trust-attribution.test.ts +32 -8
  50. package/test/turn.test.ts +20 -10
  51. package/tools/agentdox-e2e.ts +123 -0
@@ -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, credit, or account-policy refusal (age confirmation,
86
- * 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.
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
- * Time-to-first-token, averaged over streamed, non-errored turns. TTFT (not
108
- * total latency) isolates model+provider responsiveness from answer length: a
109
- * model is "slow" when it takes a long time to START, not when it was asked for
110
- * a long answer. Errored/aborted rows and non-streaming rows (null ttft) are
111
- * excluded they carry no responsiveness signal.
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
- return { slug, samples: row.samples, ttftMs: row.ttft_ms };
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 time-to-first-token (ms), optionally scoped to a harness.
185
- * Null until `filters.latencyMinSamples` streamed samples exist. TTFT, not
186
- * total latency: it measures model+provider responsiveness independent of
187
- * how many tokens the answer happened to need.
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. */
@@ -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
- * Mean TTFT above `latencyReferenceMs` inflates the model's effective cost, the
77
- * same lever trust uses for flakiness, so a faster model of equal quality and
78
- * price outranks a sluggish one. Inert when the weight is 0 or the model has
79
- * too few streamed samples to judge.
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 excess = Math.max(0, (latency.ttftMs - filters.latencyReferenceMs) / filters.latencyReferenceMs);
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(`latency penalty ×${latencyMult.toFixed(2)} (ttft ${Math.round(latency.ttftMs)}ms over ${latency.samples} samples)`);
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 });
@@ -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,
@@ -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, updated_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, $updatedAtMs)
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
  },