auto-model-router 0.2.10 → 0.2.11

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.2.10",
10
+ "version": "0.2.11",
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.2.10",
17
+ "version": "0.2.11",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -580,11 +580,19 @@ and brief:
580
580
  ```bash
581
581
  export AGENTDOX_URL=http://localhost:3003
582
582
  export AGENTDOX_TOKEN=<pat with read+write on the scope>
583
- export AGENTDOX_SCOPE=ashlands # optional; the omp extension derives it from the workspace
583
+ export AGENTDOX_SCOPE=ashlands # fallback only; see below
584
584
  ```
585
585
 
586
586
  Setting a URL and a token is enough to turn it on.
587
587
 
588
+ The scope is **derived per workspace** from the directory basename
589
+ (`E:/projects/ashlands` → `ashlands`), and that derivation wins. `AGENTDOX_SCOPE` /
590
+ `context.defaultScope` is only a fallback for workspaces it cannot resolve, because one router
591
+ install serves every project on the machine — a slug pinned there would be sent for all of
592
+ them, injecting one project's context into another's work. A single configured token also
593
+ grants only the scopes it was minted for; for any other project the bridge degrades to inert
594
+ rather than writing somewhere wrong.
595
+
588
596
  ### It does not cost you a cache miss per turn
589
597
 
590
598
  The context block sits at the front of the prompt, so re-fetching it every turn
@@ -1,7 +1,7 @@
1
1
  # agentdox bridge — handoff
2
2
 
3
- **Status:** implemented, typechecks clean, 438 tests pass, injection verified end-to-end
4
- through omp. The write-back bug in §5 is **fixed**; `context.recordTurns` is safe to enable.
3
+ **Status:** implemented, typechecks clean, 439 tests pass, injection verified end-to-end
4
+ through omp. The write-back faults in §5 and §6 are **fixed**; `context.recordTurns` is on.
5
5
 
6
6
  Design rationale (why it is built this way):
7
7
  `E:/projects/agentdox/docs/architecture/router-context-bridge.md`.
@@ -110,8 +110,8 @@ Each tool round-trip is its own dispatch, finishing with `tool_calls` and emitti
110
110
  record of a fragment rather than a truncated answer. Only the final `stop` dispatch carries
111
111
  the synthesis. `recordTurn` fired on all ~13, and the last writer won.
112
112
 
113
- The same root cause explains the §6 pollution: `lastUserText` walks back to the last `user`
114
- message, which does **not** move while a tool loop runs, so the identical user text was
113
+ The same root cause explains half the duplication in §7: `lastUserText` walks back to the last
114
+ `user` message, which does **not** move while a tool loop runs, so the identical user text was
115
115
  appended once per round-trip too.
116
116
 
117
117
  **Fix.** `TurnRecord` gained `turnEnded` (`finishReason !== "tool_calls"`, set in
@@ -128,13 +128,53 @@ nothing; interleaved conversations buffer independently) and `test/turn.test.ts`
128
128
  behavior. `tools/agentdox-e2e.ts` step 6 proves it against a live server: four dispatches →
129
129
  exactly one user and one assistant message.
130
130
 
131
- ## 6. Also worth doing
131
+ ## 6. FIXED harness utility calls, and the scope that leaked across projects
132
132
 
133
- - **Context pollution.** `context_assemble` includes recent session messages, so recorded
134
- test turns feed back into the next block (observed: a block containing `assistant:: high`
135
- from a prior run). The §5 fix removes the ~13×-per-turn duplication that made this acute,
136
- but noisy *test* turns still compound `tools/agentdox-e2e.ts` writes real sessions into
137
- the scope every run. Consider a `sessionLimit` override for the bridge, or excluding
133
+ Two further faults surfaced the moment `recordTurns` was first switched on, both found by
134
+ reading what actually landed in agentdox.
135
+
136
+ **Utility calls were recorded as turns.** omp drives more than the agent through this
137
+ provider: it asks for a conversation title and a complexity rating, with `model: auto`, over
138
+ the same embedded router. Those answer *about* a conversation rather than participating in
139
+ one, and they finish with `stop`, so `turnEnded` alone does not exclude them. Three junk
140
+ sessions appeared immediately:
141
+
142
+ | recorded assistant text | what it really was |
143
+ | --- | --- |
144
+ | `high` | omp's complexity rating — **this is the original `" high"`** |
145
+ | `<title>Read memory and resume work</title>` | omp's title generation |
146
+ | `<title>Resume settlement 2D slice 3 streaming</title>` | omp's title generation |
147
+
148
+ The discriminator is the tool array: an agent always ships its tool schemas (`toolCount` 12,
149
+ prompts of 60k–90k), while utility calls ship none (`toolCount` 0, prompts of 222–841,
150
+ `task=chat`). `src/server/turn.ts` therefore records only when `req.tools.length > 0`. A
151
+ deliberately tool-less session is not transcribed — silence beats garbage, because every junk
152
+ record is re-injected into every later turn.
153
+
154
+ **`defaultScope` leaked one project's slug to all of them.** `omp-extension/embed-logic.ts`
155
+ resolved the header as `defaultScope !== "" ? defaultScope : derive(cwd)`, so a *scope-agnostic
156
+ global* overrode the *per-workspace* derivation. One router install serves every workspace, so
157
+ with `defaultScope: omp-router` set, an **ashlands** session shipped
158
+ `X-Agentdox-Scope: omp-router`: it injected omp-router's context into ashlands work and filed
159
+ ashlands turns under omp-router. The server always treated the field as a fallback ("the
160
+ configured default covers harnesses that send none"), so the two sides disagreed about the same
161
+ field. Now the workspace derivation wins and `defaultScope` is its fallback, matching the name
162
+ and the server. Same failure class as the `.mcp.json` lesson: a scope-specific value must never
163
+ live in a scope-agnostic file.
164
+
165
+ One consequence worth knowing: `context.token` is a single PAT, but a machine-wide router
166
+ serves N scopes. The omp-router PAT gets `403 no read access to scope "ashlands"`, so with the
167
+ scope now correct the bridge degrades to **inert** for other projects. Correct and safe, but it
168
+ means the bridge only helps projects the configured token actually grants. A multi-scope token
169
+ would fix that, at the cost of one credential reaching every project.
170
+
171
+ ## 7. Also worth doing
172
+
173
+ - **Context pollution from test turns.** `context_assemble` includes recent session messages,
174
+ so router test turns feed back into the next block. The omp-router scope had accumulated 19
175
+ sessions of which 18 were noise (`hi`, `say hello`, `Reply with exactly the word: PONG`,
176
+ injection probes, `bridge e2e …`); they were deleted, and `tools/agentdox-e2e.ts` writes two
177
+ more every run. Consider a `sessionLimit` override for the bridge, or excluding
138
178
  router-authored sessions.
139
179
  - **`context.timeoutMs` is 3000ms** and failures degrade silently at `debug` level by design.
140
180
  If agentdox is cold this can no-op invisibly. Consider logging the first failure at `warn`.
@@ -65,8 +65,9 @@ export interface EmbedConfig {
65
65
  *
66
66
  * The workspace basename is the one identifier that is already stable, already
67
67
  * per-project, and requires no configuration — the same convention agentdox's
68
- * own `project_ensure` slugs follow. An explicitly configured
69
- * `context.defaultScope` always wins over this.
68
+ * own `project_ensure` slugs follow. It WINS over `context.defaultScope`,
69
+ * which is a fallback for workspaces it cannot resolve; see
70
+ * `buildProviderConfig`.
70
71
  */
71
72
  export function deriveAgentdoxScope(cwd: string): string {
72
73
  // Both separators: omp reports a Windows cwd with backslashes.
@@ -165,7 +166,16 @@ export function buildProviderConfig(
165
166
  out.harnessId = cfg.server.harnessId;
166
167
  }
167
168
  if (cfg.context?.enabled === true) {
168
- const scope = cfg.context.defaultScope !== "" ? cfg.context.defaultScope : deriveAgentdoxScope(cwd ?? "");
169
+ // The WORKSPACE wins. `defaultScope` is a scope-agnostic global one
170
+ // router install serves every project on the machine — so letting it
171
+ // override the per-workspace derivation sends one project's slug for all
172
+ // of them: an ashlands session shipped `X-Agentdox-Scope: omp-router`,
173
+ // which both injected the wrong project's context and filed its turns
174
+ // under the wrong scope. The server treats this field as a fallback too
175
+ // ("the configured default covers harnesses that send none"), so the two
176
+ // sides now agree: most specific signal first.
177
+ const derived = deriveAgentdoxScope(cwd ?? "");
178
+ const scope = derived !== "" ? derived : cfg.context.defaultScope;
169
179
  if (scope !== "") out.agentdoxScope = scope;
170
180
  }
171
181
  return out;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -451,7 +451,17 @@ export async function runTurn(
451
451
  // artifact of the turn, not a precondition for finishing it. A
452
452
  // `tool_calls` finish means the assistant is still working, so the bridge
453
453
  // buffers the fragment rather than writing a near-empty turn.
454
- if (doxActive) {
454
+ //
455
+ // Only the agent's WORKING conversation is transcribed. A harness also
456
+ // drives utility calls through this same provider with `model: auto` —
457
+ // omp asks for a conversation title and a complexity rating — and those
458
+ // answer ABOUT a conversation instead of participating in one, which is
459
+ // where the junk records (`high`, `<title>…</title>`) came from. They are
460
+ // single-shot and carry NO tool schemas, while an agent always ships its
461
+ // tools, so the tool array is the discriminator. A deliberately
462
+ // tool-less session is therefore not transcribed: silence beats garbage,
463
+ // because every junk record is re-injected into every later turn.
464
+ if (doxActive && req.tools.length > 0) {
455
465
  const userText = lastUserText(req);
456
466
  const turnEnded = finishReason !== "tool_calls";
457
467
  log.debug("agentdox record turn", {
@@ -115,7 +115,11 @@ describe("agentdox scope", () => {
115
115
  expect(deriveAgentdoxScope("")).toBe("");
116
116
  });
117
117
 
118
- test("an explicit defaultScope wins over the derived one", () => {
118
+ test("the workspace derivation wins over the scope-agnostic defaultScope", () => {
119
+ // Regression: one router install serves every project on the machine, so a
120
+ // global `defaultScope` overriding the derivation made an ashlands session
121
+ // ship `X-Agentdox-Scope: omp-router` — wrong context injected, turns
122
+ // filed under the wrong project.
119
123
  const base = {
120
124
  server: { host: "127.0.0.1" },
121
125
  profiles: [],
@@ -123,8 +127,11 @@ describe("agentdox scope", () => {
123
127
  };
124
128
  const derived = buildProviderConfig(1234, { ...base, context: { enabled: true, defaultScope: "" } }, "/x/ashlands");
125
129
  expect(derived.agentdoxScope).toBe("ashlands");
126
- const explicit = buildProviderConfig(1234, { ...base, context: { enabled: true, defaultScope: "pinned" } }, "/x/ashlands");
127
- expect(explicit.agentdoxScope).toBe("pinned");
130
+ const both = buildProviderConfig(1234, { ...base, context: { enabled: true, defaultScope: "omp-router" } }, "/x/ashlands");
131
+ expect(both.agentdoxScope).toBe("ashlands");
132
+ // The default only applies when the workspace yields nothing.
133
+ const fallback = buildProviderConfig(1234, { ...base, context: { enabled: true, defaultScope: "pinned" } }, "");
134
+ expect(fallback.agentdoxScope).toBe("pinned");
128
135
  });
129
136
 
130
137
  test("no scope header when the bridge is off", () => {
package/test/turn.test.ts CHANGED
@@ -556,6 +556,9 @@ describe("exploration reaches the ledger", () => {
556
556
  });
557
557
 
558
558
  describe("agentdox write-back sees the shape of the turn", () => {
559
+ /** An agent ships its tool schemas; a harness utility call does not. */
560
+ const AGENT_TOOL = { name: "read", description: "read a file", schemaBytes: 128 };
561
+
559
562
  function mkRecordingBridge(): { bridge: ContextBridge; records: TurnRecord[] } {
560
563
  const records: TurnRecord[] = [];
561
564
  return {
@@ -589,8 +592,9 @@ describe("agentdox write-back sees the shape of the turn", () => {
589
592
  const { store } = mkConversations();
590
593
  const { sink, errors } = mkSink();
591
594
  const { bridge, records } = mkRecordingBridge();
592
- // doxActive needs a scope; the request header supplies it.
593
- const req: NormRequest = { ...mkReq(), agentdoxScope: "proj" };
595
+ // doxActive needs a scope; the request header supplies it. The tool
596
+ // schemas mark this as the agent's working conversation.
597
+ const req: NormRequest = { ...mkReq(), agentdoxScope: "proj", tools: [AGENT_TOOL] };
594
598
  const deps = { config: mkConfig({ enabled: false }), router, upstream, ledger, conversations: store, catalog, context: bridge };
595
599
 
596
600
  await runTurn(req, sink, deps, new AbortController().signal);
@@ -603,4 +607,27 @@ describe("agentdox write-back sees the shape of the turn", () => {
603
607
  expect(records[1]?.turnEnded).toBe(true);
604
608
  expect(records[1]?.assistantText).toBe("all done");
605
609
  });
610
+
611
+ test("a harness utility call is never transcribed", async () => {
612
+ // omp drives title generation and complexity rating through this same
613
+ // provider with `model: auto`. They answer ABOUT the conversation
614
+ // ("high", "<title>…</title>") and carry NO tool schemas. Recording them
615
+ // created junk agentdox sessions that then fed back into every later
616
+ // context block.
617
+ const { router } = mkRouter([mkDecision("trivial", "cheap/model", { escalateTo: null })]);
618
+ const { upstream } = mkUpstream([
619
+ { kind: "chunks", chunks: [startChunk("cheap/model"), textChunk("high"), finishChunk("stop"), usageChunk({}, 0.0001)] },
620
+ ]);
621
+ const { ledger } = mkLedger();
622
+ const { store } = mkConversations();
623
+ const { sink, errors } = mkSink();
624
+ const { bridge, records } = mkRecordingBridge();
625
+ // Same scope, same provider — only the absent tool array differs.
626
+ const req: NormRequest = { ...mkReq(), agentdoxScope: "proj", tools: [] };
627
+
628
+ await runTurn(req, sink, { config: mkConfig({ enabled: false }), router, upstream, ledger, conversations: store, catalog, context: bridge }, new AbortController().signal);
629
+
630
+ expect(errors).toHaveLength(0);
631
+ expect(records).toHaveLength(0);
632
+ });
606
633
  });