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
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { CatalogModel } from "../catalog/types.ts";
9
9
  import type { CostForecast } from "../cost/types.ts";
10
- import type { NormRequest, ReasoningLevel } from "../wire/types.ts";
10
+ import type { CompactionEdit, NormRequest, ReasoningLevel } from "../wire/types.ts";
11
11
 
12
12
  export type Tier = "trivial" | "simple" | "moderate" | "hard";
13
13
 
@@ -53,7 +53,27 @@ export interface Features {
53
53
  lastToolFailed: boolean;
54
54
  /** The same tool was called with identical arguments twice in a row. */
55
55
  repeatedToolCall: boolean;
56
+ /**
57
+ * A byte-identical tool call (same name AND args) was re-issued within the
58
+ * last few assistant calls, adjacent or not — the agent is going in circles.
59
+ * Generalizes `repeatedToolCall`; the classifier scores THIS, so a stuck
60
+ * loop escalates even when the repeat is not back-to-back.
61
+ */
62
+ circularToolCall: boolean;
63
+ /**
64
+ * An image appears ANYWHERE in the conversation. Drives the model-capability
65
+ * filter: the payload still carries that image every turn, so a served model
66
+ * must accept image input even long after the image was introduced.
67
+ */
56
68
  hasImages: boolean;
69
+ /**
70
+ * An image appears in the VOLATILE TAIL — the newest user-authored run. This
71
+ * is what makes a turn genuinely visual WORK, as opposed to a mechanical
72
+ * tool-loop continuation that merely carries a stale screenshot in context.
73
+ * Task classification keys on this so an agentic coding loop is scored on the
74
+ * coding axis, not pinned to the vision (intelligence) axis by an old image.
75
+ */
76
+ hasNewImage: boolean;
57
77
  /** Fenced code blocks in the newest user content. */
58
78
  codeBlocks: number;
59
79
  /** Bytes inside fenced code blocks in the newest user content. */
@@ -144,6 +164,14 @@ export interface ConversationState {
144
164
  cacheWarmSlug: string | null;
145
165
  /** When that cache was last touched; OpenRouter sticky sessions expire in 5-10 min. */
146
166
  cacheWarmAtMs: number;
167
+ /**
168
+ * agentdox context block pinned to this conversation. Held stable across
169
+ * turns so the injected system prefix stays byte-identical and the prompt
170
+ * cache survives; refreshed only when the cache is already cold.
171
+ */
172
+ contextVersion: string | null;
173
+ /** When that block was fetched, for the staleness TTL. */
174
+ contextFetchedAtMs: number;
147
175
  updatedAtMs: number;
148
176
  }
149
177
 
@@ -193,6 +221,10 @@ export interface Decision {
193
221
  sticky: boolean;
194
222
  /** Message indices to mark with cache breakpoints. */
195
223
  cacheBreakpointMessageIndices: number[];
224
+ /** In-place tool-result shrink edits to apply before dispatch. Empty ⇒ none. */
225
+ compactionPlan: CompactionEdit[];
226
+ /** Estimated prompt tokens removed by `compactionPlan`, for the ledger. */
227
+ promptTokensSaved: number;
196
228
  reasoning: ReasoningLevel | undefined;
197
229
  maxTokens: number | undefined;
198
230
  stripAssistantReasoning: boolean;
@@ -2,6 +2,7 @@ import { mkdirSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import type { Server } from "bun";
4
4
  import { createCatalog } from "../catalog/openrouter-catalog.ts";
5
+ import { createBridgeFromConfig } from "../context/index.ts";
5
6
  import { createLedger } from "../cost/ledger.ts";
6
7
  import type { Ledger, ModelTrust } from "../cost/types.ts";
7
8
  import { createRouter } from "../router/index.ts";
@@ -173,7 +174,16 @@ export function startServer(cfg: RouterConfig): StartedServer {
173
174
  const catalog = createCatalog(cfg, upstream, db);
174
175
  const conversations = createConversationStore(db);
175
176
  const router = createRouter({ config: cfg, catalog, ledger, conversations, upstream });
176
- const turnDeps = { config: cfg, router, upstream, ledger, conversations, catalog };
177
+ const context = createBridgeFromConfig(cfg, db);
178
+ const turnDeps = { config: cfg, router, upstream, ledger, conversations, catalog, context };
179
+
180
+ if (context.enabled) {
181
+ log.info("agentdox context bridge enabled", {
182
+ url: cfg.context.baseUrl,
183
+ defaultScope: cfg.context.defaultScope === "" ? "(per-request header only)" : cfg.context.defaultScope,
184
+ recordTurns: cfg.context.recordTurns,
185
+ });
186
+ }
177
187
 
178
188
  if (cfg.openrouter.apiKey === "") {
179
189
  log.warn("OPENROUTER_API_KEY is not set; /v1/chat/completions will fail at dispatch time");
@@ -313,6 +323,10 @@ export function startServer(cfg: RouterConfig): StartedServer {
313
323
  apiKeyConfigured: cfg.openrouter.apiKey !== "",
314
324
  // Provenance only; never the key itself.
315
325
  apiKeySource: apiKeySource(cfg).source,
326
+ // Provenance only; never the agentdox token itself.
327
+ agentdox: context.enabled
328
+ ? { url: cfg.context.baseUrl, defaultScope: cfg.context.defaultScope, recordTurns: cfg.context.recordTurns }
329
+ : null,
316
330
  catalog: snap === null
317
331
  ? null
318
332
  : {
@@ -337,6 +351,9 @@ export function startServer(cfg: RouterConfig): StartedServer {
337
351
  clearInterval(pruneTimer);
338
352
  clearInterval(catalogRefreshTimer);
339
353
  await server.stop(true);
354
+ // Drain queued agentdox write-backs before the DB closes under them.
355
+ context.close();
356
+ await context.flush();
340
357
  db.close();
341
358
  },
342
359
  };
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { CatalogSource } from "../catalog/types.ts";
12
+ import type { ContextBridge } from "../context/types.ts";
12
13
  import type { RouterConfig } from "../config/types.ts";
13
14
  import { EMPTY_USAGE, type Ledger, type UsageCounts } from "../cost/types.ts";
14
15
  import { createProbe, type Probe } from "../router/escalate.ts";
@@ -42,6 +43,8 @@ export interface TurnDeps {
42
43
  ledger: Ledger;
43
44
  conversations: ConversationStore;
44
45
  catalog: CatalogSource;
46
+ /** agentdox bridge. The disabled bridge makes every call here a no-op. */
47
+ context: ContextBridge;
45
48
  }
46
49
 
47
50
  /** A dead client connection surfaces as the sink throwing mid-stream. */
@@ -52,16 +55,39 @@ class SinkError extends Error {
52
55
  }
53
56
  }
54
57
 
58
+ /** Latest user text, used to bias agentdox relevance ranking and as the recorded turn. */
59
+ function lastUserText(req: NormRequest): string {
60
+ for (let i = req.messages.length - 1; i >= 0; i--) {
61
+ const m = req.messages[i];
62
+ if (m !== undefined && m.role === "user") return m.text;
63
+ }
64
+ return "";
65
+ }
66
+
67
+ /** Session title: the conversation's opening ask, truncated. */
68
+ function sessionTitle(req: NormRequest): string {
69
+ for (const m of req.messages) {
70
+ if (m.role === "user" && m.text.trim() !== "") {
71
+ const t = m.text.trim().replace(/\s+/g, " ");
72
+ return t.length > 80 ? `${t.slice(0, 79)}…` : t;
73
+ }
74
+ }
75
+ return `omp ${req.conversationKey.slice(0, 8)}`;
76
+ }
77
+
55
78
  export async function runTurn(
56
79
  req: NormRequest,
57
80
  sink: ResponseSink,
58
81
  deps: TurnDeps,
59
82
  signal: AbortSignal,
60
83
  ): Promise<void> {
61
- const { config, router, upstream, ledger, conversations } = deps;
84
+ const { config, router, upstream, ledger, conversations, context: bridge } = deps;
62
85
  const log = createLogger(config.logLevel);
63
86
  const state = conversations.load(req.conversationKey);
64
87
  const turnNumber = state.turn + 1;
88
+ // Request header wins; the configured default covers harnesses that send none.
89
+ const doxScope = req.agentdoxScope !== "" ? req.agentdoxScope : config.context.defaultScope;
90
+ const doxActive = bridge.enabled && doxScope !== "";
65
91
 
66
92
  // The trigger list is the source of truth for enabled signals, except
67
93
  // length_stop, which rides on its own toggle (escalation.escalateOnLengthStop).
@@ -101,6 +127,37 @@ export async function runTurn(
101
127
  }
102
128
  }
103
129
 
130
+ // Resolve the shared context block. The bridge refreshes only when this
131
+ // turn's prefix is already cold — a model switch or a retry — so the
132
+ // injected bytes stay identical while the cache is worth keeping.
133
+ let contextBlock: string | undefined;
134
+ if (doxActive) {
135
+ const pin = await bridge.resolve({
136
+ scope: doxScope,
137
+ conversationKey: req.conversationKey,
138
+ pinnedVersion: state.contextVersion,
139
+ pinnedFetchedAtMs: state.contextFetchedAtMs,
140
+ modelSwitching: state.currentSlug !== null && state.currentSlug !== decision.slug,
141
+ retrying: attempt > 0,
142
+ query: lastUserText(req),
143
+ });
144
+ if (pin !== null) {
145
+ contextBlock = pin.block;
146
+ // Pin immediately, even if this attempt later fails: the block was
147
+ // dispatched, so the next turn must re-send the same bytes to hit
148
+ // whatever cache this attempt warmed.
149
+ state.contextVersion = pin.version;
150
+ state.contextFetchedAtMs = pin.fetchedAtMs;
151
+ }
152
+ }
153
+
154
+ log.debug("agentdox context", {
155
+ active: doxActive,
156
+ scope: doxScope === "" ? "(none)" : doxScope,
157
+ injected: contextBlock !== undefined,
158
+ chars: contextBlock?.length ?? 0,
159
+ });
160
+
104
161
  const body = req.renderUpstreamBody({
105
162
  slug: decision.slug,
106
163
  fallbacks: decision.fallbacks,
@@ -109,6 +166,8 @@ export async function runTurn(
109
166
  reasoning: decision.reasoning,
110
167
  maxTokens: decision.maxTokens,
111
168
  stripAssistantReasoning: decision.stripAssistantReasoning,
169
+ ...(contextBlock === undefined ? {} : { contextBlock }),
170
+ ...(decision.compactionPlan.length > 0 ? { compactionPlan: decision.compactionPlan } : {}),
112
171
  });
113
172
 
114
173
  // Our own abort composes with the client's: escalation teardown and
@@ -121,6 +180,9 @@ export async function runTurn(
121
180
  let servedSlug: string | null = null;
122
181
  let finishReason: string | null = null;
123
182
  let ttftMs: number | null = null;
183
+ // Accumulated only when the bridge is live; the transcript write-back is
184
+ // the sole consumer, and a dead bridge must cost nothing on the hot path.
185
+ let assistantText = "";
124
186
  let committed = false;
125
187
  let dispatch: Dispatch | null = null;
126
188
  let probe: Probe | null = null;
@@ -166,6 +228,7 @@ export async function runTurn(
166
228
  wasted: fields.wasted,
167
229
  upstreamGenerationId: generationId,
168
230
  error: fields.error,
231
+ promptTokensSaved: decision.promptTokensSaved,
169
232
  });
170
233
  };
171
234
 
@@ -262,6 +325,9 @@ export async function runTurn(
262
325
  if (ev.generationId !== null) generationId = ev.generationId;
263
326
  break;
264
327
  case "text":
328
+ if (ttftMs === null) ttftMs = Date.now() - startedAt;
329
+ if (doxActive) assistantText += ev.delta;
330
+ break;
265
331
  case "reasoning":
266
332
  if (ttftMs === null) ttftMs = Date.now() - startedAt;
267
333
  break;
@@ -380,6 +446,27 @@ export async function runTurn(
380
446
  state.updatedAtMs = Date.now();
381
447
  conversations.save(state);
382
448
 
449
+ // Record the settled turn into agentdox, attributed to the model that
450
+ // actually served it. Queued and never awaited: the transcript is an
451
+ // artifact of the turn, not a precondition for finishing it.
452
+ if (doxActive) {
453
+ log.debug("agentdox record turn", {
454
+ userChars: lastUserText(req).length,
455
+ assistantChars: assistantText.length,
456
+ messages: req.messages.length,
457
+ roles: req.messages.map((m) => m.role).join(","),
458
+ });
459
+ bridge.recordTurn({
460
+ scope: doxScope,
461
+ conversationKey: req.conversationKey,
462
+ title: sessionTitle(req),
463
+ userText: lastUserText(req),
464
+ assistantText,
465
+ slug: servedSlug ?? decision.slug,
466
+ tier: decision.tier,
467
+ });
468
+ }
469
+
383
470
  const summary: TurnSummary = {
384
471
  servedSlug: servedSlug ?? decision.slug,
385
472
  tier: decision.tier,
@@ -34,9 +34,16 @@ function classifyStatus(status: number, body: unknown): UpstreamError {
34
34
  const message = typeof msg === "string" && msg !== "" ? msg : `OpenRouter HTTP ${status}`;
35
35
  const fail = (kind: UpstreamErrorKind, retryable: boolean): UpstreamError =>
36
36
  new UpstreamError(kind, status, message, retryable, body);
37
- if (status === 401 || status === 403) return fail("auth", false);
37
+ // 401 = missing/invalid key. Key-wide, so every model fails identically;
38
+ // retrying is pointless.
39
+ if (status === 401) return fail("auth", false);
38
40
  // 402 = out of credits; retrying changes nothing, only topping up does.
39
41
  if (status === 402) return fail("auth", false);
42
+ // 403 = provider content-moderation or per-model policy gate (prompt-injection
43
+ // block, age/data-policy confirmation). This indicts the model/provider, NOT
44
+ // the key: siblings routinely serve the same content. Retryable so the turn
45
+ // fails over to a different model instead of dying on the client.
46
+ if (status === 403) return fail("moderation", true);
40
47
  if (status === 429) return fail("rate_limit", true);
41
48
  if (status === 400) {
42
49
  return CONTEXT_LENGTH_RE.test(message) ? fail("context_length", false) : fail("invalid_request", false);
@@ -18,7 +18,7 @@ import { mkdirSync } from "node:fs";
18
18
  import { dirname } from "node:path";
19
19
 
20
20
  /** Bump when a migration is added; guarded below so reopening never regresses it. */
21
- const USER_VERSION = 10;
21
+ const USER_VERSION = 12;
22
22
 
23
23
  const MIGRATIONS = `
24
24
  CREATE TABLE IF NOT EXISTS catalog_cache (
@@ -94,6 +94,23 @@ CREATE TABLE IF NOT EXISTS conversations (
94
94
  updated_at_ms INTEGER NOT NULL DEFAULT 0
95
95
  );
96
96
 
97
+ -- agentdox bridge. Blocks are content-addressed so many conversations on one
98
+ -- project share a single copy, and so a restart can re-inject the SAME bytes a
99
+ -- conversation was already using (OpenRouter's prompt cache outlives us).
100
+ CREATE TABLE IF NOT EXISTS context_blocks (
101
+ version TEXT PRIMARY KEY,
102
+ scope TEXT NOT NULL,
103
+ block TEXT NOT NULL,
104
+ fetched_at_ms INTEGER NOT NULL
105
+ );
106
+
107
+ CREATE TABLE IF NOT EXISTS agentdox_sessions (
108
+ conversation_key TEXT PRIMARY KEY,
109
+ scope TEXT NOT NULL,
110
+ session_id TEXT NOT NULL,
111
+ created_at_ms INTEGER NOT NULL
112
+ );
113
+
97
114
  -- v2: catalog_cache gains key_scoped provenance. ALTER TABLE ADD COLUMN is
98
115
  -- idempotent only via a guard; SQLite has no IF NOT EXISTS for columns, so
99
116
  -- probe pragma_table_info and add when absent.
@@ -181,6 +198,19 @@ const MIGRATE_V8 = `
181
198
  ALTER TABLE ledger ADD COLUMN hold_arm INTEGER;
182
199
  `;
183
200
 
201
+ // v11: conversations pin an agentdox context version. Storing the version (not
202
+ // the block) keeps the row small; the block itself lives once in context_blocks.
203
+ const MIGRATE_V11 = `
204
+ ALTER TABLE conversations ADD COLUMN context_version TEXT;
205
+ ALTER TABLE conversations ADD COLUMN context_fetched_at_ms INTEGER NOT NULL DEFAULT 0;
206
+ `;
207
+
208
+ // v12: ledger records prompt tokens removed by context compaction, so the
209
+ // savings (and any over-aggressive elision) can be measured and tuned.
210
+ const MIGRATE_V12 = `
211
+ ALTER TABLE ledger ADD COLUMN prompt_tokens_saved INTEGER;
212
+ `;
213
+
184
214
  // v9: benchmark_cache holds the external benchmark feeds (Artificial Analysis,
185
215
  // BenchLM) that backfill quality scores OpenRouter leaves unpublished. It is a
186
216
  // whole new table, created idempotently by the MIGRATIONS block above, so there
@@ -212,6 +242,9 @@ export function openDb(path: string): Database {
212
242
  if (!ledgerCols.some((c) => c.name === "features")) db.exec(MIGRATE_V6);
213
243
  if (!ledgerCols.some((c) => c.name === "explored_from")) db.exec(MIGRATE_V7);
214
244
  if (!ledgerCols.some((c) => c.name === "hold_arm")) db.exec(MIGRATE_V8);
245
+ const convCols = db.query("PRAGMA table_info(conversations)").all() as { name: string }[];
246
+ if (!convCols.some((c) => c.name === "context_version")) db.exec(MIGRATE_V11);
247
+ if (!ledgerCols.some((c) => c.name === "prompt_tokens_saved")) db.exec(MIGRATE_V12);
215
248
  db.exec(`PRAGMA user_version = ${USER_VERSION}`);
216
249
  }
217
250
  return db;
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ CompactionEdit,
2
3
  NormMessage,
3
4
  NormRequest,
4
5
  NormTool,
@@ -136,6 +137,78 @@ function parseReasoning(b: { reasoning?: unknown; reasoning_effort?: unknown }):
136
137
  return undefined;
137
138
  }
138
139
 
140
+ /**
141
+ * Folds the agentdox context block into the system prefix.
142
+ *
143
+ * Appends to the LAST system/developer message rather than inserting a new
144
+ * one. Inserting would shift every cache-breakpoint index the core computed
145
+ * against the client's own array; appending keeps them valid AND lands the
146
+ * block inside the prefix `planCacheBreakpoints` already marks as cacheable.
147
+ *
148
+ * When the request has no system message at all, one is prepended and the
149
+ * breakpoint indices are shifted by one to compensate.
150
+ *
151
+ * Returns the (possibly shifted) breakpoint indices.
152
+ */
153
+ function injectContextBlock(
154
+ messages: Record<string, unknown>[],
155
+ block: string,
156
+ breakpoints: number[],
157
+ ): number[] {
158
+ let lastSystem = -1;
159
+ for (let i = messages.length - 1; i >= 0; i--) {
160
+ const role = messages[i]?.role;
161
+ if (role === "system" || role === "developer") {
162
+ lastSystem = i;
163
+ break;
164
+ }
165
+ }
166
+
167
+ if (lastSystem === -1) {
168
+ messages.unshift({ role: "system", content: block });
169
+ return breakpoints.map((i) => i + 1);
170
+ }
171
+
172
+ const msg = messages[lastSystem];
173
+ if (msg === undefined) return breakpoints;
174
+ const content = msg.content;
175
+ if (typeof content === "string") {
176
+ msg.content = `${content}
177
+
178
+ ${block}`;
179
+ } else if (Array.isArray(content)) {
180
+ // Append as a trailing text part. A later cache_control pass marks the
181
+ // LAST text part, so the block stays inside the cached prefix.
182
+ content.push({ type: "text", text: block });
183
+ } else {
184
+ msg.content = block;
185
+ }
186
+ return breakpoints;
187
+ }
188
+
189
+ /**
190
+ * Applies compaction edits in place: shrinks each targeted tool-result's string
191
+ * content, leaving a self-describing breadcrumb so the model can re-run the tool
192
+ * to restore what was elided. Non-string content (rare for tool results) is left
193
+ * untouched. Message count and order are preserved, so breakpoint indices and
194
+ * the context-block append computed against this array stay valid.
195
+ */
196
+ function applyCompaction(messages: Record<string, unknown>[], edits: readonly CompactionEdit[]): void {
197
+ for (const edit of edits) {
198
+ const msg = messages[edit.index];
199
+ if (msg === undefined) continue;
200
+ const content = msg.content;
201
+ if (typeof content !== "string") continue;
202
+ if (edit.mode === "stub") {
203
+ msg.content = `[omp-router: ${edit.note} elided to save context; re-run the tool to restore]`;
204
+ continue;
205
+ }
206
+ if (content.length <= edit.keepHead + edit.keepTail) continue;
207
+ const elided = content.length - edit.keepHead - edit.keepTail;
208
+ msg.content = `${content.slice(0, edit.keepHead)}\n\n[omp-router: elided ${elided} chars — ${edit.note}; re-run the tool to restore]\n\n${content.slice(content.length - edit.keepTail)}`;
209
+ }
210
+ }
211
+
139
212
  function renderUpstreamBody(
140
213
  original: Record<string, unknown>,
141
214
  m: UpstreamMutations,
@@ -165,6 +238,7 @@ function renderUpstreamBody(
165
238
 
166
239
  // messages was validated to be an array of objects at parse time.
167
240
  const messages = body.messages as Record<string, unknown>[];
241
+ if (m.compactionPlan !== undefined && m.compactionPlan.length > 0) applyCompaction(messages, m.compactionPlan);
168
242
  if (m.stripAssistantReasoning) {
169
243
  // omp replays reasoning fields for what it believes is a local backend;
170
244
  // most OpenRouter upstreams reject every spelling.
@@ -175,7 +249,13 @@ function renderUpstreamBody(
175
249
  delete msg.reasoning_details;
176
250
  }
177
251
  }
178
- for (const idx of m.cacheBreakpointMessageIndices) {
252
+ // Context injection happens BEFORE breakpoints are applied, so the block is
253
+ // covered by the system-prefix breakpoint rather than left outside it.
254
+ let breakpoints = m.cacheBreakpointMessageIndices;
255
+ if (m.contextBlock !== undefined && m.contextBlock !== "") {
256
+ breakpoints = injectContextBlock(messages, m.contextBlock, breakpoints);
257
+ }
258
+ for (const idx of breakpoints) {
179
259
  const msg = messages[idx];
180
260
  if (!msg) continue;
181
261
  const content = msg.content;
@@ -208,6 +288,10 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
208
288
  // this header to ctx.sessionManager.getSessionId(); absent ⇒ unknown session.
209
289
  const ompSessionId = (headers.get("x-omp-session") ?? "").trim();
210
290
 
291
+ // agentdox project scope. Selects whose shared context is injected; absent
292
+ // ⇒ the server falls back to its configured default scope.
293
+ const agentdoxScope = (headers.get("x-agentdox-scope") ?? "").trim();
294
+
211
295
  if (typeof b.model !== "string" || b.model.length === 0) {
212
296
  throw invalidRequest("model must be a non-empty string");
213
297
  }
@@ -267,6 +351,7 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
267
351
  conversationKey,
268
352
  harnessId,
269
353
  ompSessionId,
354
+ agentdoxScope,
270
355
  requestedModel,
271
356
  messages,
272
357
  tools,
package/src/wire/types.ts CHANGED
@@ -75,6 +75,13 @@ export interface NormRequest {
75
75
  * the client sends no header.
76
76
  */
77
77
  ompSessionId: string;
78
+ /**
79
+ * agentdox project scope from the `X-Agentdox-Scope` request header. Selects
80
+ * which project's shared context is injected and which project's sessions
81
+ * the turn is recorded into. Empty ⇒ fall back to `context.defaultScope`,
82
+ * and if that is empty too the bridge stays inert for this request.
83
+ */
84
+ agentdoxScope: string;
78
85
  /** Virtual model the client selected, e.g. `auto`, `auto-cheap`, `auto-max`. */
79
86
  requestedModel: string;
80
87
  messages: NormMessage[];
@@ -112,6 +119,35 @@ export interface UpstreamMutations {
112
119
  maxTokens: number | undefined;
113
120
  /** Drop assistant reasoning-replay fields the target rejects. */
114
121
  stripAssistantReasoning: boolean;
122
+ /**
123
+ * agentdox project context to fold into the system prefix. Appended to the
124
+ * LAST system message rather than inserted as a new one: inserting would
125
+ * shift every `cacheBreakpointMessageIndices` entry, and appending puts the
126
+ * block inside the prefix that `planCacheBreakpoints` already marks.
127
+ * Undefined ⇒ inject nothing.
128
+ */
129
+ contextBlock?: string;
130
+ /**
131
+ * Deterministic compaction edits to apply to the message array before the
132
+ * other mutations. Each edit shrinks ONE tool-result's content in place;
133
+ * message count and order are preserved so cache-breakpoint indices and the
134
+ * context-block append stay valid. Empty ⇒ compact nothing.
135
+ */
136
+ compactionPlan?: CompactionEdit[];
137
+ }
138
+
139
+ /**
140
+ * One in-place shrink of a tool-result message's content. `stub` replaces the
141
+ * whole content with a breadcrumb; `truncate` keeps `keepHead`/`keepTail`
142
+ * characters around an elision breadcrumb. `note` describes why, for the
143
+ * breadcrumb the model reads.
144
+ */
145
+ export interface CompactionEdit {
146
+ index: number;
147
+ mode: "truncate" | "stub";
148
+ keepHead: number;
149
+ keepTail: number;
150
+ note: string;
115
151
  }
116
152
 
117
153
  export type FinishReason = "stop" | "length" | "tool_calls" | "content_filter" | "error";
@@ -50,7 +50,9 @@ function contFeatures(toolLoopDepth: number, over: Partial<Features> = {}): Feat
50
50
  distinctToolsUsed: 3,
51
51
  lastToolFailed: false,
52
52
  repeatedToolCall: false,
53
+ circularToolCall: false,
53
54
  hasImages: false,
55
+ hasNewImage: false,
54
56
  codeBlocks: 0,
55
57
  codeBytes: 0,
56
58
  looksLikeDiff: false,
@@ -211,11 +213,25 @@ describe("scoreHeuristic", () => {
211
213
  }
212
214
  });
213
215
 
214
- test("pure loop depth tops out at simple; complexity signals stack to moderate", () => {
215
- // Depth alone means competent-but-cheap (simple), not a frontier model.
216
- const veryDeep = scoreHeuristic(contFeatures(90), BASE);
217
- expect(tierIdx(veryDeep.tier)).toBeLessThanOrEqual(tierIdx("simple"));
218
- // A failing tool result on top of a deep loop is genuinely hard.
216
+ test("pure loop depth ramps to moderate in the mid-range and hard only when runaway", () => {
217
+ // A sustained-but-not-runaway loop tops out in moderate: the calibrated
218
+ // ramp ceiling for ordinary deep work.
219
+ const midRange = scoreHeuristic(contFeatures(30), BASE);
220
+ expect(midRange.tier).toBe("moderate");
221
+ // A runaway loop of pure continuations (no other signal) must reach hard.
222
+ // moderate never swaps the cheapest coding model off — it clears the 60
223
+ // floor even after the latency penalty — so only hard's 72 floor breaks it.
224
+ const runaway = scoreHeuristic(contFeatures(90), BASE);
225
+ expect(runaway.tier).toBe("hard");
226
+ });
227
+
228
+ test("a circular tool call on a deep loop escalates to hard", () => {
229
+ // The stuck signal raw depth misses: a prior call re-issued verbatim.
230
+ const deepCircular = scoreHeuristic(contFeatures(90, { circularToolCall: true }), BASE);
231
+ expect(deepCircular.tier).toBe("hard");
232
+ });
233
+
234
+ test("a failing tool result on a deep loop is at least moderate", () => {
219
235
  const deepAndFailing = scoreHeuristic(contFeatures(20, { lastToolFailed: true }), BASE);
220
236
  expect(tierIdx(deepAndFailing.tier)).toBeGreaterThanOrEqual(tierIdx("moderate"));
221
237
  });
@@ -309,6 +325,48 @@ describe("classifyTask", () => {
309
325
  expect(classifyTask(f)).toBe("vision");
310
326
  });
311
327
 
328
+ test("a stale image on a tool continuation is coding, not vision", () => {
329
+ const f = featuresFor(
330
+ [
331
+ SYSTEM,
332
+ {
333
+ role: "user",
334
+ content: [
335
+ { type: "text", text: "build this UI" },
336
+ { type: "image_url", image_url: { url: "data:image/png;base64,xxx" } },
337
+ ],
338
+ },
339
+ { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "read", arguments: "{}" } }] },
340
+ { role: "tool", tool_call_id: "c1", name: "read", content: "ok" },
341
+ ],
342
+ TOOLS,
343
+ );
344
+ expect(f.hasImages).toBe(true);
345
+ expect(f.hasNewImage).toBe(false);
346
+ expect(classifyTask(f)).toBe("coding");
347
+ });
348
+
349
+ test("a freshly supplied image mid-loop is vision", () => {
350
+ const f = featuresFor(
351
+ [
352
+ SYSTEM,
353
+ { role: "user", content: "start" },
354
+ { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "read", arguments: "{}" } }] },
355
+ { role: "tool", tool_call_id: "c1", name: "read", content: "ok" },
356
+ {
357
+ role: "user",
358
+ content: [
359
+ { type: "text", text: "here is the error" },
360
+ { type: "image_url", image_url: { url: "data:image/png;base64,xxx" } },
361
+ ],
362
+ },
363
+ ],
364
+ TOOLS,
365
+ );
366
+ expect(f.hasNewImage).toBe(true);
367
+ expect(classifyTask(f)).toBe("vision");
368
+ });
369
+
312
370
  test("code blocks and diffs are coding tasks", () => {
313
371
  expect(classifyTask(featuresFor([SYSTEM, { role: "user", content: "```ts\nconst x = 1;\n```" }], []))).toBe("coding");
314
372
  expect(classifyTask(featuresFor([SYSTEM, { role: "user", content: "diff --git a/x b/x\n@@ -1 +1 @@\n-old\n+new" }], []))).toBe("coding");