auto-model-router 0.9.0 → 0.10.0

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.
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.9.0",
10
+ "version": "0.10.0",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.9.0",
17
+ "version": "0.10.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -1330,10 +1330,15 @@ short-lived key rotates underneath a running session.
1330
1330
  In remote mode omp sends `X-Agentdox-Scope` derived from the workspace folder, so one
1331
1331
  remote router serves every repo on the machine with that repo's shared context. The remote
1332
1332
  decides what to do with it: a team edition that pins a scope on the member's group
1333
- overrides it, and one that pins none follows the workspace. That header rides on the roles
1334
- the extensions register; the **main** model's handle comes from `models.yml`, which is
1335
- machine-wide, so it carries a scope only if you pass `--scope <slug>` to `connect` right
1336
- for a single-project machine, wrong for one with several repos.
1333
+ overrides it, and one that pins none follows the workspace. The roles the extensions
1334
+ register carry the header directly. The **main** model's handle comes from `models.yml`,
1335
+ which is machine-wide and resolved before extensions load, so `connect` writes that
1336
+ entry's header value as the *name* of an environment variable,
1337
+ `AUTO_MODEL_ROUTER_SCOPE`; omp resolves it from the environment on every request, and
1338
+ the embed extension sets it from the workspace as it loads. If the variable is unset omp
1339
+ sends the name itself, which the router does not accept as a scope (a scope is a
1340
+ lowercase slug) and falls back to its default. `--scope <slug>` pins one project for the
1341
+ whole machine instead; a refresh keeps a pin, and `connect --scope ""` removes it.
1337
1342
 
1338
1343
  ## Multiple coding harnesses, one router
1339
1344
 
@@ -1471,6 +1476,17 @@ move while the loop runs. So the router buffers the assistant's narration across
1471
1476
  the loop and writes it once, together with the closing synthesis, when the
1472
1477
  assistant actually yields back to the user.
1473
1478
 
1479
+ ### Utility calls get neither the block nor a transcript
1480
+
1481
+ A harness drives more than the agent's conversation through this provider: omp
1482
+ asks for a session title and a complexity rating with `model: auto`. Those
1483
+ calls answer *about* the conversation, carry no tool schemas, and gain nothing
1484
+ from the project block, yet each one paid the whole block — measured at ~6k
1485
+ prompt tokens per call, 42k across one turn's seven side calls. So the tool
1486
+ array is the discriminator for both directions: a request with no tools is
1487
+ neither recorded nor injected. `context.injectWithoutTools: true` restores
1488
+ injection into tool-less requests for a deliberately tool-less agent.
1489
+
1474
1490
  Write-backs are queued, bounded, and never awaited: agentdox is an enrichment,
1475
1491
  not a dependency. If it is unreachable the turn routes and dispatches normally,
1476
1492
  and a pinned block keeps being served.
@@ -169,6 +169,16 @@ scope now correct the bridge degrades to **inert** for other projects. Correct a
169
169
  means the bridge only helps projects the configured token actually grants. A multi-scope token
170
170
  would fix that, at the cost of one credential reaching every project.
171
171
 
172
+ ## 6b. FIXED — the same discriminator gates injection (0.10)
173
+
174
+ Recording skipped tool-less calls from 0.2.11, but injection did not: every scoped
175
+ request received the block, so omp's title and rating calls each carried ~6k tokens of
176
+ project context they could not use. Measured 2026-09-08 on one omp turn: seven side
177
+ calls, 23,412 chars each, 42,417 prompt tokens of context in total. `turn.ts` now
178
+ resolves the block only when `req.tools.length > 0`, the same test recording uses, and
179
+ leaves the conversation's pin untouched otherwise. `context.injectWithoutTools` (default
180
+ off) is the escape hatch for an agent that deliberately ships no tools.
181
+
172
182
  ## 7. Also worth doing
173
183
 
174
184
  - **Context pollution from test turns.** `context_assemble` includes recent session messages,
@@ -34,6 +34,19 @@ import type { RouterConfig } from "../src/config/types.ts";
34
34
  import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
35
35
 
36
36
  import { EMBED_DUMMY_API_KEY, EMBED_PROVIDER_ID, buildProviderConfig, deriveAgentdoxScope, embedPortPath, modelsYmlPort, probeEmbed, readEmbedPort, resolveEmbedPort, writeEmbedPort } from "./embed-logic.ts";
37
+ import { SCOPE_ENV } from "../src/context/scope.ts";
38
+
39
+ // The workspace's scope for the MAIN model. omp builds that handle from
40
+ // models.yml before this file loads, so its X-Agentdox-Scope cannot come from
41
+ // the provider we register; instead the managed entry names SCOPE_ENV as the
42
+ // header's value, omp resolves that from the environment on every request,
43
+ // and this module runs inside omp's process — so setting it here reaches
44
+ // every turn of this session, main and side roles alike. A workspace that
45
+ // derives no scope (no folder name) leaves whatever the shell set.
46
+ {
47
+ const workspaceScope = deriveAgentdoxScope(process.cwd());
48
+ if (workspaceScope !== "") process.env[SCOPE_ENV] = workspaceScope;
49
+ }
37
50
 
38
51
  /** omp's models.yml as text, or "" when it does not exist / cannot be read. */
39
52
  function readModelsYml(): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -15,6 +15,7 @@
15
15
  * keys survive.
16
16
  */
17
17
 
18
+ import { SCOPE_ENV } from "../context/scope.ts";
18
19
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
20
  import { homedir } from "node:os";
20
21
  import { dirname, join } from "node:path";
@@ -59,16 +60,20 @@ export interface SpliceResult {
59
60
  */
60
61
  /**
61
62
  * Headers omp attaches to every request through this provider. Both are
62
- * optional: absent harness id ⇒ single-harness defaults, absent agentdox scope
63
- * the router falls back to its own `context.defaultScope`.
63
+ * optional: absent harness id ⇒ single-harness defaults; the agentdox scope
64
+ * names `SCOPE_ENV`, which omp resolves from its environment per request and
65
+ * the embed extension sets from the workspace folder, so the MAIN model's turns
66
+ * carry the repository's own scope rather than one for the whole machine. When
67
+ * the variable is unset the router ignores the literal and falls back to its
68
+ * own `context.defaultScope`.
64
69
  */
65
70
  function providerHeaders(cfg: RouterConfig): Record<string, string> {
66
71
  const headers: Record<string, string> = {};
67
72
  if (cfg.server.harnessId !== undefined && cfg.server.harnessId !== "") {
68
73
  headers["X-Omp-Harness"] = cfg.server.harnessId;
69
74
  }
70
- if (cfg.context.enabled && cfg.context.defaultScope !== "") {
71
- headers["X-Agentdox-Scope"] = cfg.context.defaultScope;
75
+ if (cfg.context.enabled) {
76
+ headers["X-Agentdox-Scope"] = SCOPE_ENV;
72
77
  }
73
78
  return headers;
74
79
  }
@@ -301,6 +301,7 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
301
301
  { path: "context.sessionLimit", label: "Session items", kind: "number", min: 0 },
302
302
  { path: "context.briefChars", label: "Brief chars", kind: "number", min: 0 },
303
303
  { path: "context.recordTurns", label: "Record turns back", kind: "boolean" },
304
+ { path: "context.injectWithoutTools", label: "Inject into tool-less calls", kind: "boolean" },
304
305
  { path: "context.maxQueue", label: "Write-back queue", kind: "number", min: 1 },
305
306
  ],
306
307
  },
@@ -29,6 +29,7 @@ import { homedir } from "node:os";
29
29
  import { dirname, join, resolve } from "node:path";
30
30
  import { fileURLToPath } from "node:url";
31
31
  import { refreshAccountOf, remoteFilePath } from "../../omp-extension/remote-logic.ts";
32
+ import { SCOPE_ENV } from "../context/scope.ts";
32
33
  import { pickStore, saveRefreshToken, type StoreDeps, type StoreKind } from "./credential-store.ts";
33
34
  import { flagString, type CliArgs } from "./args.ts";
34
35
 
@@ -47,7 +48,7 @@ export interface ConnectOptions {
47
48
  packageDir: string;
48
49
  /** Cost figures omp shows for the remote's virtual models, USD per million tokens. */
49
50
  blend?: { inputPerMtok: number; outputPerMtok: number };
50
- /** Adds `X-Agentdox-Scope` to omp's models.yml entry. Machine-wide: only for a single-project machine. Undefined keeps what the managed block already has. */
51
+ /** Pins `X-Agentdox-Scope` in omp's models.yml entry to one slug, machine-wide. Without it the entry follows the workspace (see renderRemoteModelsYml). Undefined keeps what the managed block already has. */
51
52
  agentdoxScope?: string;
52
53
  /** Short-lived credential fields from a remote that issues them; absent for a permanent key. */
53
54
  refreshToken?: string;
@@ -141,10 +142,13 @@ const MODELS_YML_END = " # END auto-model-router (remote)";
141
142
  * neither problem — the URL and the key are stable — and the entry is what makes
142
143
  * omp's main model resolvable at startup, before extensions load.
143
144
  *
144
- * `scope` adds `X-Agentdox-Scope` to every request through this provider. It is
145
- * off by default on purpose: the file is machine-wide, so a scope here would
146
- * label turns from every workspace with one project. The extensions still send
147
- * the workspace's own scope on the roles that resolve after they load.
145
+ * `X-Agentdox-Scope` on this entry reaches the MAIN model's turns, which the
146
+ * extensions' own registration cannot (they load after the handle is built).
147
+ * The file is machine-wide, so a literal slug here would label every
148
+ * workspace's turns with one project; by default the value is the NAME of
149
+ * `SCOPE_ENV`, which omp resolves from its environment per request, and the
150
+ * embed extension sets that variable from the workspace folder as it loads.
151
+ * `scope` pins a literal slug instead, for a single-project machine.
148
152
  */
149
153
  export function renderRemoteModelsYml(url: string, key: string, blend: { inputPerMtok: number; outputPerMtok: number }, scope = ""): string {
150
154
  const round = (v: number): number => Math.round(v * 1e4) / 1e4;
@@ -162,7 +166,7 @@ export function renderRemoteModelsYml(url: string, key: string, blend: { inputPe
162
166
  " api: openai-completions",
163
167
  ` apiKey: ${key}`,
164
168
  ];
165
- if (scope !== "") lines.push(" headers:", ` X-Agentdox-Scope: ${scope}`);
169
+ lines.push(" headers:", ` X-Agentdox-Scope: ${scope !== "" ? scope : SCOPE_ENV}`);
166
170
  lines.push(" models:");
167
171
  for (const m of REMOTE_MODEL_ROWS) {
168
172
  lines.push(
@@ -201,14 +205,15 @@ export function mergeModelsYml(before: string, blockText: string): string {
201
205
  return `${body.replace(/\s*$/, "")}${eol}providers:${eol}${block}${eol}`;
202
206
  }
203
207
 
204
- /** The `X-Agentdox-Scope` the managed block carries, or "" when none. */
208
+ /** The literal `X-Agentdox-Scope` the managed block pins, or "" when it follows the workspace (or has none). */
205
209
  export function existingBlockScope(text: string): string {
206
210
  const begin = text.indexOf(MODELS_YML_BEGIN);
207
211
  if (begin < 0) return "";
208
212
  const end = text.indexOf(MODELS_YML_END, begin);
209
213
  const block = text.slice(begin, end < 0 ? text.length : end);
210
214
  const m = /X-Agentdox-Scope:\s*(\S+)/.exec(block);
211
- return m?.[1] ?? "";
215
+ const value = m?.[1] ?? "";
216
+ return value === SCOPE_ENV ? "" : value;
212
217
  }
213
218
 
214
219
  /** True when the file already defines our provider outside a block we manage. */
@@ -263,6 +263,14 @@ export const DEFAULT_CONFIG: RouterConfig = {
263
263
  // the query-relevant memory/docs tail. 0 omits the brief.
264
264
  briefChars: 12_000,
265
265
  recordTurns: true,
266
+ // A request with no tool schemas is a harness utility call — omp asks for
267
+ // a session title or a complexity rating through the same provider — and
268
+ // it answers ABOUT the conversation, so the project block cannot help it.
269
+ // Recording already skips those calls (turn.ts); injection did not, and
270
+ // one omp turn measured seven side calls at ~6k tokens of context each,
271
+ // 42k prompt tokens for nothing. Off: the block goes only to turns that
272
+ // ship tools. On: every scoped turn gets it, as before 0.10.
273
+ injectWithoutTools: false,
266
274
  maxQueue: 64,
267
275
  },
268
276
  compaction: {
@@ -192,6 +192,7 @@ const context = z.strictObject({
192
192
  sessionLimit: z.number().int().nonnegative().optional(),
193
193
  briefChars: z.number().int().nonnegative().optional(),
194
194
  recordTurns: z.boolean().optional(),
195
+ injectWithoutTools: z.boolean().optional(),
195
196
  maxQueue: z.number().int().positive().optional(),
196
197
  });
197
198
 
@@ -769,6 +769,13 @@ export interface ContextConfig {
769
769
  briefChars: number;
770
770
  /** Write settled turns back to agentdox sessions, tagged with the served model. */
771
771
  recordTurns: boolean;
772
+ /**
773
+ * Inject the block into turns that carry NO tool schemas too. Those are
774
+ * harness utility calls (titles, ratings), which recording already skips;
775
+ * default off, because each one paid the whole block for an answer that is
776
+ * about the conversation, not part of it.
777
+ */
778
+ injectWithoutTools: boolean;
772
779
  /** Bound on queued write-backs; excess turns are dropped, never buffered unbounded. */
773
780
  maxQueue: number;
774
781
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The agentdox scope a request names, and where omp's main model gets it from.
3
+ *
4
+ * omp resolves its MAIN model from `models.yml` at startup, before any extension
5
+ * loads, so the `X-Agentdox-Scope` header on that provider entry cannot be set
6
+ * by the extension per workspace — but omp resolves a header VALUE that names
7
+ * an environment variable from the environment on every request, and the
8
+ * extension runs inside omp's process. So `connect` (and the local sync) write
9
+ * the header's value as the NAME below, and the extension sets that variable
10
+ * from the workspace folder when it loads. One machine-wide file, one scope
11
+ * per repository.
12
+ */
13
+ export const SCOPE_ENV = "AUTO_MODEL_ROUTER_SCOPE";
14
+
15
+ /**
16
+ * A scope is an agentdox project slug: lowercase, starting alphanumeric. The
17
+ * shape matters because omp sends the header's literal value when the variable
18
+ * it names is unset — `AUTO_MODEL_ROUTER_SCOPE` itself — and that must never
19
+ * become a project. Uppercase never passes, so the sentinel cannot.
20
+ */
21
+ export function isScopeSlug(value: string): boolean {
22
+ return /^[a-z0-9][a-z0-9._-]{0,127}$/.test(value);
23
+ }
24
+
25
+ /** The scope to trust from a request header: a slug, or "" for anything else. */
26
+ export function acceptScope(raw: string | null | undefined): string {
27
+ const s = (raw ?? "").trim();
28
+ return isScopeSlug(s) ? s : "";
29
+ }
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@ Usage: auto-model-router <command> [options]
27
27
  stats Show routed spend, per-model share, and escalation rates
28
28
  report Usage analytics: providers, models, tiers, cost, speed, cache hit rate
29
29
  export One row per day, harness and model as CSV (--json for rows)
30
- connect Point this machine at a remote router (--url, --key[, --refresh-token]; --scope labels a single-project machine; --profile persists the environment)
30
+ connect Point this machine at a remote router (--url, --key[, --refresh-token]; --scope pins one project for the whole machine (default: each workspace's own); --profile persists the environment)
31
31
  refresh Trade the refresh token for a new access key and re-write every harness config (--force: even when not near expiry)
32
32
  token Print an access key that is good right now, refreshing first if needed (for a harness key-helper)
33
33
  models Show what each complexity tier would consider, and why
@@ -136,6 +136,12 @@ export async function runTurn(
136
136
  // Request header wins; the configured default covers harnesses that send none.
137
137
  const doxScope = req.agentdoxScope !== "" ? req.agentdoxScope : config.context.defaultScope;
138
138
  const doxActive = bridge.enabled && doxScope !== "";
139
+ // Injection shares recording's discriminator (explained at the record call
140
+ // below): a tool-less harness utility call answers ABOUT the conversation
141
+ // and gains nothing from the project block, yet paid its full ~6k tokens on
142
+ // every title and rating — 42k prompt tokens across one omp turn's seven
143
+ // side calls. `context.injectWithoutTools` restores the old behaviour.
144
+ const doxInject = doxActive && (req.tools.length > 0 || config.context.injectWithoutTools);
139
145
 
140
146
  // The trigger list is the source of truth for enabled signals, except
141
147
  // length_stop, which rides on its own toggle (escalation.escalateOnLengthStop).
@@ -209,7 +215,7 @@ export async function runTurn(
209
215
  // turn's prefix is already cold — a model switch or a retry — so the
210
216
  // injected bytes stay identical while the cache is worth keeping.
211
217
  let contextBlock: string | undefined;
212
- if (doxActive) {
218
+ if (doxInject) {
213
219
  const pin = await bridge.resolve({
214
220
  scope: doxScope,
215
221
  conversationKey: req.conversationKey,
@@ -233,6 +239,7 @@ export async function runTurn(
233
239
  log.debug("agentdox context", {
234
240
  active: doxActive,
235
241
  scope: doxScope === "" ? "(none)" : doxScope,
242
+ utilityCall: doxActive && !doxInject,
236
243
  injected: contextBlock !== undefined,
237
244
  chars: contextBlock?.length ?? 0,
238
245
  });
@@ -1,3 +1,4 @@
1
+ import { acceptScope } from "../../context/scope.ts";
1
2
  import type {
2
3
  RequestPolicy,
3
4
  CompactionEdit,
@@ -329,8 +330,10 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
329
330
  const ompSessionId = (headers.get("x-omp-session") ?? "").trim();
330
331
 
331
332
  // agentdox project scope. Selects whose shared context is injected; absent
332
- // the server falls back to its configured default scope.
333
- const agentdoxScope = (headers.get("x-agentdox-scope") ?? "").trim();
333
+ // or not a slug (omp sends the literal env-var NAME when the variable that
334
+ // models.yml names is unset — see src/context/scope.ts) the server falls
335
+ // back to its configured default scope.
336
+ const agentdoxScope = acceptScope(headers.get("x-agentdox-scope"));
334
337
 
335
338
  // Subagent marker from the embed extension (sessions without a UI).
336
339
  const isSubagent = (headers.get("x-omp-subagent") ?? "").trim() === "1";
@@ -73,7 +73,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
73
73
  hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false, switchHorizonTurns: 1, confirmUpgradesBelowConfidence: 0.6 },
74
74
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
75
75
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
76
- context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
76
+ context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, injectWithoutTools: false, maxQueue: 64 },
77
77
  compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1, digestToolResults: false, digestMaxPerTurn: 2 },
78
78
  budget: { onExceeded: "downgrade" },
79
79
  report: { baselines: [], dailySummary: false },
@@ -6,6 +6,7 @@ import { join } from "node:path";
6
6
  import { addExtensions, codexBlock, connectRemote, setDotenv, type ConnectOptions } from "../src/cli/connect.ts";
7
7
  import { parseRemoteRouter, readRemoteRouter, remoteProviderRegistration } from "../omp-extension/remote-logic.ts";
8
8
  import { existingBlockScope, hasForeignRouterProvider, mergeModelsYml, renderRemoteModelsYml } from "../src/cli/connect.ts";
9
+ import { SCOPE_ENV } from "../src/context/scope.ts";
9
10
  import { refreshAndRewrite, refreshCredential, RefreshError, resolveRefreshToken, shouldRefresh } from "../src/cli/refresh.ts";
10
11
  import { loadRefreshToken, pickStore, removeRefreshToken, saveRefreshToken } from "../src/cli/credential-store.ts";
11
12
  import { hasRefresh, refreshAccountOf } from "../omp-extension/remote-logic.ts";
@@ -114,7 +115,7 @@ describe("omp models.yml for a remote router", () => {
114
115
  const NL = String.fromCharCode(10);
115
116
  const yaml = (...lines: string[]): string => lines.join(NL) + NL;
116
117
 
117
- test("the block names the remote, the key and the three virtual models; a scope is opt-in", () => {
118
+ test("the block names the remote, the key and the three virtual models; the scope follows the workspace unless pinned", () => {
118
119
  const block = renderRemoteModelsYml("https://team.example/", "amrt_k", BLEND);
119
120
  expect(block).toContain("baseUrl: https://team.example/v1");
120
121
  expect(block).toContain("apiKey: amrt_k");
@@ -122,9 +123,15 @@ describe("omp models.yml for a remote router", () => {
122
123
  expect(block).toContain("- id: auto-cheap");
123
124
  expect(block).toContain("- id: auto-max");
124
125
  expect(block).toContain("cost: { input: 1.1, output: 4.4, cacheRead: 0.11, cacheWrite: 1.375 }");
125
- // Machine-wide file: no scope unless the caller asks for one.
126
- expect(block).not.toContain("X-Agentdox-Scope");
127
- expect(renderRemoteModelsYml("https://team.example", "k", BLEND, "omp-router")).toContain("X-Agentdox-Scope: omp-router");
126
+ // Machine-wide file: the header names the env var the extension sets per
127
+ // workspace, so the MAIN model's turns carry each repo's own scope.
128
+ expect(block).toContain(`X-Agentdox-Scope: ${SCOPE_ENV}`);
129
+ expect(existingBlockScope(mergeModelsYml("", block))).toBe("");
130
+ // --scope pins one slug for the whole machine.
131
+ const pinned = renderRemoteModelsYml("https://team.example", "k", BLEND, "omp-router");
132
+ expect(pinned).toContain("X-Agentdox-Scope: omp-router");
133
+ expect(pinned).not.toContain(SCOPE_ENV);
134
+ expect(existingBlockScope(mergeModelsYml("", pinned))).toBe("omp-router");
128
135
  });
129
136
 
130
137
  test("merging keeps other providers, replaces our own block, and is idempotent", () => {
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SCOPE_ENV, acceptScope, isScopeSlug } from "../src/context/scope.ts";
3
+ import { parseChatRequest } from "../src/wire/openai/request.ts";
4
+
5
+ describe("the scope a request may name", () => {
6
+ test("a slug passes; the env-var sentinel and other non-slugs do not", () => {
7
+ expect(isScopeSlug("omp-router")).toBe(true);
8
+ expect(isScopeSlug("ashlands")).toBe(true);
9
+ expect(isScopeSlug("my.app_v2")).toBe(true);
10
+ // omp sends the header's literal value when the variable it names is
11
+ // unset: the NAME must never become a project.
12
+ expect(isScopeSlug(SCOPE_ENV)).toBe(false);
13
+ expect(isScopeSlug("")).toBe(false);
14
+ expect(isScopeSlug("-leading")).toBe(false);
15
+ expect(isScopeSlug("Has Spaces")).toBe(false);
16
+ expect(isScopeSlug("a".repeat(129))).toBe(false);
17
+ });
18
+
19
+ test("acceptScope trims and rejects", () => {
20
+ expect(acceptScope(" omp-router ")).toBe("omp-router");
21
+ expect(acceptScope(SCOPE_ENV)).toBe("");
22
+ expect(acceptScope(null)).toBe("");
23
+ });
24
+
25
+ test("the wire parser drops a non-slug X-Agentdox-Scope", () => {
26
+ const body = { model: "auto", messages: [{ role: "user", content: "hi" }] };
27
+ const parse = (scope: string) => parseChatRequest(body, new Headers({ "x-agentdox-scope": scope }));
28
+ expect(parse("omp-router").agentdoxScope).toBe("omp-router");
29
+ expect(parse(SCOPE_ENV).agentdoxScope).toBe("");
30
+ });
31
+ });
package/test/turn.test.ts CHANGED
@@ -73,7 +73,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
73
73
  hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false, switchHorizonTurns: 1, confirmUpgradesBelowConfidence: 0.6 },
74
74
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
75
75
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
76
- context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
76
+ context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, injectWithoutTools: false, maxQueue: 64 },
77
77
  compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1, digestToolResults: false, digestMaxPerTurn: 2 },
78
78
  budget: { onExceeded: "downgrade" },
79
79
  report: { baselines: [], dailySummary: false },
@@ -660,6 +660,81 @@ describe("agentdox write-back sees the shape of the turn", () => {
660
660
  });
661
661
  });
662
662
 
663
+ describe("agentdox injection sees the shape of the turn", () => {
664
+ const AGENT_TOOL = { name: "read", description: "read a file", schemaBytes: 128 };
665
+
666
+ /** A bridge that always has a block, counting how often it is asked. */
667
+ function mkServingBridge(): { bridge: ContextBridge; resolves: number[] } {
668
+ const resolves: number[] = [];
669
+ return {
670
+ resolves,
671
+ bridge: {
672
+ enabled: true,
673
+ resolve: () => {
674
+ resolves.push(1);
675
+ return Promise.resolve({ block: "<project-context>ctx</project-context>", version: "v1", fetchedAtMs: 1 });
676
+ },
677
+ recordTurn: () => {},
678
+ flush: () => Promise.resolve(),
679
+ pruneBlocks: () => 0,
680
+ close: () => {},
681
+ },
682
+ };
683
+ }
684
+
685
+ function oneDispatch() {
686
+ const { router } = mkRouter([mkDecision("trivial", "cheap/model", { escalateTo: null })]);
687
+ const { upstream } = mkUpstream([
688
+ { kind: "chunks", chunks: [startChunk("cheap/model"), textChunk("high"), finishChunk("stop"), usageChunk({}, 0.0001)] },
689
+ ]);
690
+ const { ledger } = mkLedger();
691
+ const { store } = mkConversations();
692
+ const { sink, errors } = mkSink();
693
+ return { router, upstream, ledger, store, sink, errors };
694
+ }
695
+
696
+ test("a harness utility call gets no context block", async () => {
697
+ // Measured on one omp turn: seven tool-less side calls (title, ratings)
698
+ // each received the whole ~6k-token block — 42k prompt tokens for
699
+ // answers ABOUT the conversation. The tool array is the discriminator,
700
+ // exactly as for recording.
701
+ const { router, upstream, ledger, store, sink, errors } = oneDispatch();
702
+ const { bridge, resolves } = mkServingBridge();
703
+ const req: NormRequest = { ...mkReq(), agentdoxScope: "proj", tools: [] };
704
+
705
+ await runTurn(req, sink, { config: mkConfig({ enabled: false }), router, upstream, ledger, conversations: store, catalog, context: bridge }, new AbortController().signal);
706
+
707
+ expect(errors).toHaveLength(0);
708
+ expect(resolves).toHaveLength(0);
709
+ expect(store.load(req.conversationKey).contextVersion).toBeNull();
710
+ });
711
+
712
+ test("an agent turn with tools is injected", async () => {
713
+ const { router, upstream, ledger, store, sink, errors } = oneDispatch();
714
+ const { bridge, resolves } = mkServingBridge();
715
+ const req: NormRequest = { ...mkReq(), agentdoxScope: "proj", tools: [AGENT_TOOL] };
716
+
717
+ await runTurn(req, sink, { config: mkConfig({ enabled: false }), router, upstream, ledger, conversations: store, catalog, context: bridge }, new AbortController().signal);
718
+
719
+ expect(errors).toHaveLength(0);
720
+ expect(resolves).toHaveLength(1);
721
+ expect(store.load(req.conversationKey).contextVersion).toBe("v1");
722
+ });
723
+
724
+ test("context.injectWithoutTools restores injection into tool-less calls", async () => {
725
+ const { router, upstream, ledger, store, sink, errors } = oneDispatch();
726
+ const { bridge, resolves } = mkServingBridge();
727
+ const base = mkConfig({ enabled: false });
728
+ const config: RouterConfig = { ...base, context: { ...base.context, injectWithoutTools: true } };
729
+ const req: NormRequest = { ...mkReq(), agentdoxScope: "proj", tools: [] };
730
+
731
+ await runTurn(req, sink, { config, router, upstream, ledger, conversations: store, catalog, context: bridge }, new AbortController().signal);
732
+
733
+ expect(errors).toHaveLength(0);
734
+ expect(resolves).toHaveLength(1);
735
+ });
736
+ });
737
+
663
738
  describe("spend reaches the conversation total however the dispatch ends", () => {
664
739
  test("a dispatch that dies mid-stream still books what it was billed", async () => {
665
740
  // Live data: 152 aborted dispatches billed $0.9985 — 30% of all spend —