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.
- package/.claude/skills/agentdox/SKILL.md +143 -0
- package/.mcp.json +11 -0
- package/.omp-plugin/marketplace.json +2 -2
- package/CLAUDE.md +129 -0
- package/README.md +64 -0
- package/docs/AGENTDOX-BRIDGE.md +132 -0
- package/docs/context-optimization.md +362 -0
- package/omp-extension/embed-logic.ts +31 -0
- package/omp-extension/router-embed.ts +7 -1
- package/package.json +1 -1
- package/src/cli/config-cmd.ts +20 -5
- package/src/cli/explain.ts +1 -0
- package/src/config/defaults.ts +33 -1
- package/src/config/load.ts +13 -0
- package/src/config/schema.ts +27 -0
- package/src/config/types.ts +79 -5
- package/src/context/agentdox.ts +113 -0
- package/src/context/bridge.ts +166 -0
- package/src/context/index.ts +33 -0
- package/src/context/store.ts +82 -0
- package/src/context/types.ts +78 -0
- package/src/cost/ledger.ts +32 -12
- package/src/cost/types.ts +15 -4
- package/src/router/candidates.ts +19 -8
- package/src/router/classify.ts +26 -12
- package/src/router/compaction.ts +163 -0
- package/src/router/features.ts +26 -13
- package/src/router/select.ts +37 -4
- package/src/router/state.ts +12 -2
- package/src/router/types.ts +33 -1
- package/src/server/http.ts +18 -1
- package/src/server/turn.ts +88 -1
- package/src/upstream/openrouter.ts +8 -1
- package/src/util/sqlite.ts +34 -1
- package/src/wire/openai/request.ts +86 -1
- package/src/wire/types.ts +36 -0
- package/test/classify.test.ts +63 -5
- package/test/compaction.test.ts +148 -0
- package/test/context-bridge.test.ts +337 -0
- package/test/embed-logic.test.ts +32 -0
- package/test/escalate.test.ts +1 -0
- package/test/exploration.test.ts +6 -2
- package/test/failover.test.ts +51 -6
- package/test/features.test.ts +45 -0
- package/test/helpers/inject.ts +23 -0
- package/test/hold-exploration.test.ts +4 -2
- package/test/select.test.ts +86 -3
- package/test/tokens.test.ts +1 -0
- package/test/trust-attribution.test.ts +32 -8
- package/test/turn.test.ts +20 -10
- package/tools/agentdox-e2e.ts +123 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# Context optimization (compaction)
|
|
2
|
+
|
|
3
|
+
Status: **design, not implemented.** Decisions locked 2026-08-28. Phase 1 is the
|
|
4
|
+
build target; Phase 2 is documented so the schema and pipeline reserve room for it.
|
|
5
|
+
|
|
6
|
+
## Goal
|
|
7
|
+
|
|
8
|
+
Before dispatching a turn to the chosen model, the router transforms the outgoing
|
|
9
|
+
message history to remove **stale, low-value bulk** — chiefly old tool output —
|
|
10
|
+
so that:
|
|
11
|
+
|
|
12
|
+
1. long agentic conversations keep **fitting the candidate pool** (a smaller
|
|
13
|
+
prompt stops `candidates.ts` from rejecting narrower-window models with
|
|
14
|
+
`context_too_small`, which today silently shrinks the pool as a conversation
|
|
15
|
+
grows), and
|
|
16
|
+
2. per-turn **spend drops** on the 100k-token loops we actually observe (glm-5.3-flash
|
|
17
|
+
routinely serves turns at 67k–105k prompt tokens; the bulk is re-sent every turn).
|
|
18
|
+
|
|
19
|
+
A smaller haystack is also a mild mitigation for the weak-model loop failure
|
|
20
|
+
(fewer tokens for a cheap model to lose the thread in), but that is a side effect,
|
|
21
|
+
not the objective.
|
|
22
|
+
|
|
23
|
+
## Non-goals
|
|
24
|
+
|
|
25
|
+
- Not a replacement for tier escalation on runaway loops (tracked separately).
|
|
26
|
+
- Not summarizing or dropping the user's actual current intent or recent turns.
|
|
27
|
+
- Not a lossy digest of the whole conversation (see Phase 2 for bounded, pinned
|
|
28
|
+
summarization; rolling whole-history digests are explicitly rejected — a coding
|
|
29
|
+
agent lives on exact tool state).
|
|
30
|
+
|
|
31
|
+
## Motivation (observed)
|
|
32
|
+
|
|
33
|
+
From the live ledger (post-restart window):
|
|
34
|
+
|
|
35
|
+
- Prompts of 67k–105k tokens are normal on deep loops; the dominant mass is tool
|
|
36
|
+
results (file reads, `bash`/grep dumps) that the model already consumed turns ago.
|
|
37
|
+
- Loops reach tool-loop depth 200+ on cheap tiers, re-sending the full transcript
|
|
38
|
+
each turn.
|
|
39
|
+
- `context_too_small` rejections remove otherwise-good models from long
|
|
40
|
+
conversations, forcing escalation or pool exhaustion on model switches.
|
|
41
|
+
|
|
42
|
+
## Constraints and invariants (from the codebase)
|
|
43
|
+
|
|
44
|
+
These are hard; the design is shaped by them.
|
|
45
|
+
|
|
46
|
+
- **Single hook.** `renderUpstreamBody` (`src/wire/openai/request.ts`) owns the exact
|
|
47
|
+
bytes sent upstream via `structuredClone(original)`. `NormMessage`
|
|
48
|
+
(`src/wire/types.ts`) is explicitly a *lossy* view "never dispatched." Compaction
|
|
49
|
+
therefore **decides** using `NormMessage` per-index metadata (`role`, `textBytes`,
|
|
50
|
+
`images`, `toolCalls`, `toolCallId`, `toolName`) and **applies** edits to the raw
|
|
51
|
+
original messages by index.
|
|
52
|
+
- **Tool-call pairing is load-bearing.** Every `role:"tool"` message must keep its
|
|
53
|
+
matching `assistant` `tool_call` id, or OpenRouter rejects the request (400).
|
|
54
|
+
Compaction may shrink a tool **result's content**, but must never remove a tool
|
|
55
|
+
result without its call, nor an assistant tool_call without its result, nor
|
|
56
|
+
reorder the pair.
|
|
57
|
+
- **Never touch:** system/developer messages (they carry the system prompt and the
|
|
58
|
+
agentdox-injected block), the **volatile tail** (newest user-authored run, or the
|
|
59
|
+
trailing tool-result run of the current loop — the same window
|
|
60
|
+
`features.ts`/`cache-control.ts` treat as fresh), and image parts (they are
|
|
61
|
+
capability-relevant; see Edge cases).
|
|
62
|
+
- **Determinism.** `auto-model-router explain` replays a past decision offline.
|
|
63
|
+
Phase 1 is a pure function of `(messages, target budget, model window)` →
|
|
64
|
+
replayable. Phase 2 breaks this unless the summary is **pinned and persisted**
|
|
65
|
+
(see Phase 2), exactly as the agentdox bridge already does.
|
|
66
|
+
- **Prompt cache.** Editing early messages busts the OpenRouter prompt-cache prefix.
|
|
67
|
+
The governing law is already stated for the agentdox bridge
|
|
68
|
+
(`src/context/bridge.ts`): *the same bytes are re-injected verbatim and the cache
|
|
69
|
+
survives; a refresh rides on a cache miss that was happening anyway.* Compaction
|
|
70
|
+
obeys it — Phase 1 rules are **stable** (identical input → identical elision each
|
|
71
|
+
turn), so a message elided last turn is elided byte-identically this turn and the
|
|
72
|
+
prefix does not churn; Phase 2 summaries are pinned per conversation and refreshed
|
|
73
|
+
only when the cache is already cold.
|
|
74
|
+
|
|
75
|
+
## Precedent to reuse
|
|
76
|
+
|
|
77
|
+
The agentdox bridge (`src/context/`) already manipulates context under exactly these
|
|
78
|
+
constraints: content-addressed blocks in `context_blocks`, a per-conversation
|
|
79
|
+
version in `conversations.context_version`, cache-cold-only refresh, and graceful
|
|
80
|
+
degradation to a no-op when unavailable. Phase 2 mirrors this machinery.
|
|
81
|
+
|
|
82
|
+
## Pipeline placement
|
|
83
|
+
|
|
84
|
+
Compaction follows the codebase's existing division of labour: **the core computes a
|
|
85
|
+
plan; the wire applies bytes.** `cacheBreakpointMessageIndices` and the agentdox
|
|
86
|
+
`contextBlock` are already computed during routing / in `turn.ts` and applied inside
|
|
87
|
+
`renderUpstreamBody`. Compaction is the same shape: a **`compactionPlan`** on the
|
|
88
|
+
`Decision` (which tool-result indices to shrink, and to what head/tail), computed
|
|
89
|
+
during routing, applied in `renderUpstreamBody`.
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
request → classify (on ORIGINAL turn)
|
|
93
|
+
→ compute compaction FLOOR (model-agnostic safe elision when over budget)
|
|
94
|
+
recomputes compactedPromptTokens
|
|
95
|
+
→ candidate selection + forecast ← use compactedPromptTokens
|
|
96
|
+
context_too_small filter uses the FLOOR, so a narrow-window model is
|
|
97
|
+
not rejected when safe compaction would make it fit
|
|
98
|
+
→ chosen model known → finalize compactionPlan (fit trigger may extend it)
|
|
99
|
+
→ renderUpstreamBody:
|
|
100
|
+
clone → APPLY compactionPlan (shrink tool-result content)
|
|
101
|
+
→ stripAssistantReasoning
|
|
102
|
+
→ injectContextBlock (agentdox, appends to last system msg)
|
|
103
|
+
→ apply cache_control at breakpoint indices
|
|
104
|
+
→ dispatch
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- **Classification runs on the original turn.** Compacting old tool output must not
|
|
108
|
+
change the tier: the human's intent and the loop/continuation structure are
|
|
109
|
+
unchanged. `promptTokens` shrinks, but the tier scorer keys on the tail
|
|
110
|
+
(`isToolResultContinuation`, `toolLoopDepth`, `newContentTokens`), so the effect
|
|
111
|
+
is negligible and intentionally not fed back into scoring.
|
|
112
|
+
- **Selection and forecast use `compactedPromptTokens`** so spend predictions and the
|
|
113
|
+
`context_too_small` filter reflect what is really sent — restoring narrower-window
|
|
114
|
+
models that fit *after* safe compaction. A model rejected against the floor is
|
|
115
|
+
genuinely too small (even max-safe compaction cannot fit it) and is correctly dropped.
|
|
116
|
+
- **Breakpoint indices are NOT recomputed for Phase 1.** They are computed in
|
|
117
|
+
`select()` on the original array and adjusted by `injectContextBlock`. Because
|
|
118
|
+
Phase 1 shrinks *content only* (never adds or removes a message), every index stays
|
|
119
|
+
valid — the same reason `injectContextBlock` appends instead of inserts. Phase 2,
|
|
120
|
+
which changes message count, MUST return adjusted indices (see Phase 2).
|
|
121
|
+
|
|
122
|
+
## Trigger (locked: fit + cost budget)
|
|
123
|
+
|
|
124
|
+
Two conditions arm compaction; both compact to the same safe floor, they differ only
|
|
125
|
+
in *whether* to bother:
|
|
126
|
+
|
|
127
|
+
- **Budget:** `estimateTokens(promptBytes + injectedBlockBytes) > compaction.budgetTokens`.
|
|
128
|
+
The injected agentdox block counts toward the budget (it is resolved before render
|
|
129
|
+
and bounded by `context.maxBlockChars`).
|
|
130
|
+
- **Fit:** the estimate exceeds `model.contextLength × filters.contextHeadroom` (minus
|
|
131
|
+
expected completion) for a model under consideration — so the `context_too_small`
|
|
132
|
+
filter tests each model against the compacted floor rather than the raw size.
|
|
133
|
+
|
|
134
|
+
Requests below `budgetTokens` and within every viable window are dispatched untouched —
|
|
135
|
+
the common small-prompt path allocates nothing.
|
|
136
|
+
|
|
137
|
+
## Interaction with the agentdox bridge
|
|
138
|
+
|
|
139
|
+
The router already has a shipped context subsystem (`src/context/`, see
|
|
140
|
+
`docs/AGENTDOX-BRIDGE.md`). Compaction must cooperate with it, not fight it.
|
|
141
|
+
|
|
142
|
+
- **Complementary regions.** agentdox *adds* a pinned project-context block to the
|
|
143
|
+
system prefix (`injectContextBlock` appends to the last system message); compaction
|
|
144
|
+
*shrinks* stale tool-result content in the history. Different regions — they compose.
|
|
145
|
+
- **Same cache law, one cold turn.** `bridge.ts shouldRefresh` fires on
|
|
146
|
+
`pin === null || modelSwitching || retrying || staleness`. Phase 2's summary refresh
|
|
147
|
+
MUST key off the same signals (already carried on `ContextResolveInput`) so the block
|
|
148
|
+
refresh and the summary refresh land on the *same* cache miss — "the refresh rides a
|
|
149
|
+
miss that was happening anyway," once, for both mutations.
|
|
150
|
+
- **Never touch the injected block.** It lives in a system message, which the safety
|
|
151
|
+
boundary already excludes. Reaffirmed.
|
|
152
|
+
- **Budget includes the block.** See Trigger.
|
|
153
|
+
- **Apply order in `renderUpstreamBody`:** clone → apply `compactionPlan` → strip
|
|
154
|
+
reasoning → `injectContextBlock` → cache_control. Phase 1 preserves message
|
|
155
|
+
count/order, so `injectContextBlock`'s breakpoint bookkeeping is unaffected.
|
|
156
|
+
- **Write-back is orthogonal.** agentdox records user+assistant prose, not tool
|
|
157
|
+
results, so compacting tool output does not degrade the recorded transcript. (The
|
|
158
|
+
bridge's open write-back bug, AGENTDOX-BRIDGE §5, is unrelated.)
|
|
159
|
+
- **Phase 3 synergy (flag only, not committed):** agentdox durably stores project
|
|
160
|
+
memory/history, so elided tool content could eventually be recoverable from project
|
|
161
|
+
memory rather than solely by re-running the tool.
|
|
162
|
+
|
|
163
|
+
## Phase 1 — deterministic pruning (build target)
|
|
164
|
+
|
|
165
|
+
A pure function `compact(messages, target, protectFrom) → { messages, saved, notes }`.
|
|
166
|
+
|
|
167
|
+
### Safety boundary
|
|
168
|
+
|
|
169
|
+
- `protectRecentTurns` (default 4): the last N user/assistant turns and the entire
|
|
170
|
+
volatile tail are never modified.
|
|
171
|
+
- System/developer messages are never modified.
|
|
172
|
+
- A message is never **removed**; only a tool **result's content** is shortened.
|
|
173
|
+
Structure (roles, ordering, tool_call↔result ids) is invariant.
|
|
174
|
+
|
|
175
|
+
### Rules, applied cheapest-and-safest first, stopping once under `target`
|
|
176
|
+
|
|
177
|
+
1. **Collapse duplicates** (`collapseDuplicateResults`): byte-identical tool results
|
|
178
|
+
for the same `toolName` → keep the first occurrence, replace later copies with a
|
|
179
|
+
one-line breadcrumb.
|
|
180
|
+
2. **Elide superseded reads** (`elideSupersededReads`): when the same resource is
|
|
181
|
+
fetched twice (same `toolName` + same primary argument, e.g. a `path`/`key`
|
|
182
|
+
parsed from the tool_call args JSON — schema-agnostic heuristic), the **older**
|
|
183
|
+
result's content is elided down to a breadcrumb pointing forward. The latest
|
|
184
|
+
fetch is authoritative.
|
|
185
|
+
3. **Truncate large stale results** (`maxToolResultBytes`): remaining tool results
|
|
186
|
+
outside the protected window whose content exceeds `maxToolResultBytes` are
|
|
187
|
+
reduced to `keepHeadBytes` + breadcrumb + `keepTailBytes`. Applied to the
|
|
188
|
+
**largest/oldest first** until under `target` or exhausted.
|
|
189
|
+
4. **Age-drop assistant reasoning:** reasoning fields on assistant messages outside
|
|
190
|
+
the protected window are dropped. (Largely redundant with the model-driven
|
|
191
|
+
`stripAssistantReasoning`; matters only for reasoning-replay authors.)
|
|
192
|
+
|
|
193
|
+
### Recoverability contract (the single most important safety rule)
|
|
194
|
+
|
|
195
|
+
Elisions are **never silent**. Every elision leaves a self-describing, in-band
|
|
196
|
+
breadcrumb in the tool result content, e.g.:
|
|
197
|
+
|
|
198
|
+
```
|
|
199
|
+
[omp-router: elided 1180 of 1200 lines to save context. Re-run this tool to restore.]
|
|
200
|
+
<first keepHeadBytes…>
|
|
201
|
+
…
|
|
202
|
+
<last keepTailBytes…>
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
This converts a hard context loss into a **recoverable** one: if the model actually
|
|
206
|
+
needs the elided content, it re-issues the tool call and the fresh full result
|
|
207
|
+
returns (uncompacted, because it is now in the protected recent window). A rising
|
|
208
|
+
"re-fetch after elision" rate is the tuning signal that compaction is too
|
|
209
|
+
aggressive.
|
|
210
|
+
|
|
211
|
+
### Idempotence / stability
|
|
212
|
+
|
|
213
|
+
Because omp re-sends the full original history every turn, Phase 1 re-derives the
|
|
214
|
+
same elisions deterministically each turn → the dispatched prefix is byte-identical
|
|
215
|
+
turn-over-turn → the prompt cache survives without pinning.
|
|
216
|
+
|
|
217
|
+
## Observability
|
|
218
|
+
|
|
219
|
+
- `decision.reasons` gains a line, e.g.
|
|
220
|
+
`compaction: 6 tool results elided, ~38k tokens saved (prompt 104k→66k)`, surfaced
|
|
221
|
+
by `explain` and the toast extension.
|
|
222
|
+
- Ledger migration (mirrors the `v7`/`v8` additive pattern in `src/util/sqlite.ts`):
|
|
223
|
+
add `prompt_tokens_saved INTEGER` (and optionally `messages_elided INTEGER`) so
|
|
224
|
+
savings, re-fetch rate, and any upstream 400s can be measured and tuned.
|
|
225
|
+
|
|
226
|
+
## Configuration (proposed; off by default)
|
|
227
|
+
|
|
228
|
+
A new top-level `compaction` section (distinct from `cache` and the agentdox
|
|
229
|
+
`context`). Off by default — like every behavior-changing feature here, it is never
|
|
230
|
+
implicit.
|
|
231
|
+
|
|
232
|
+
```yaml
|
|
233
|
+
compaction:
|
|
234
|
+
enabled: false
|
|
235
|
+
# Trigger
|
|
236
|
+
budgetTokens: 40000 # Stage 1: compact when estimated prompt exceeds this
|
|
237
|
+
fitToWindow: true # Stage 2: also compact to fit chosen model window×headroom
|
|
238
|
+
# Safety boundary
|
|
239
|
+
protectRecentTurns: 4 # never touch the last N user/assistant turns or the tail
|
|
240
|
+
# Rules
|
|
241
|
+
maxToolResultBytes: 4096 # stale tool results larger than this are truncated
|
|
242
|
+
keepHeadBytes: 512
|
|
243
|
+
keepTailBytes: 512
|
|
244
|
+
elideSupersededReads: true
|
|
245
|
+
collapseDuplicateResults: true
|
|
246
|
+
# Phase 2 (documented, not built)
|
|
247
|
+
summarize:
|
|
248
|
+
enabled: false
|
|
249
|
+
model: "" # cheap summarizer slug; empty ⇒ Phase 1 only
|
|
250
|
+
triggerTokens: 80000 # only after Phase-1 rules, still above this
|
|
251
|
+
maxSummaryTokens: 2000
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Schema lands in `src/config/schema.ts` (a `z.strictObject`, all fields optional),
|
|
255
|
+
types in `src/config/types.ts`, defaults in `src/config/defaults.ts`, and — per the
|
|
256
|
+
existing pattern — a small set of fields in the config wizard (`src/cli/config-wizard.ts`).
|
|
257
|
+
|
|
258
|
+
## Testing strategy
|
|
259
|
+
|
|
260
|
+
Unit (`test/compaction.test.ts`):
|
|
261
|
+
- tool_call↔result pairing preserved after every rule;
|
|
262
|
+
- breadcrumb present and content non-empty after truncation;
|
|
263
|
+
- deterministic + idempotent (compact(compact(x)) == compact(x) for stable input);
|
|
264
|
+
- `target` respected (result ≤ budget when achievable) and never over-shrinks the
|
|
265
|
+
protected window;
|
|
266
|
+
- superseded-read detection matches same-resource, spares different resources;
|
|
267
|
+
- images/multimodal parts are never elided.
|
|
268
|
+
|
|
269
|
+
Integration (extend `test/select.test.ts` / a new fixture):
|
|
270
|
+
- a 100k-token fixture compacts under `budgetTokens`;
|
|
271
|
+
- a narrow-window model that was `context_too_small` becomes eligible after Stage 1;
|
|
272
|
+
- `explain` replays byte-identical dispatched messages for the same input.
|
|
273
|
+
|
|
274
|
+
## Rollout
|
|
275
|
+
|
|
276
|
+
1. Ship Phase 1 off by default.
|
|
277
|
+
2. Enable **budget-only** first (`fitToWindow: false`) on the live install; watch
|
|
278
|
+
`prompt_tokens_saved`, re-fetch rate, upstream 400s (pairing regressions), and
|
|
279
|
+
loop/abort rates.
|
|
280
|
+
3. Enable `fitToWindow` once pairing is proven clean.
|
|
281
|
+
4. Reassess Phase 2 only if prose-heavy history (not tool output) is still the
|
|
282
|
+
dominant residual cost after Phase 1.
|
|
283
|
+
|
|
284
|
+
### Testing & deployment gotchas (from AGENTDOX-BRIDGE §3–4, §6)
|
|
285
|
+
|
|
286
|
+
Verifying compaction *through omp* hits the same traps that cost hours on the bridge:
|
|
287
|
+
|
|
288
|
+
- **Headless omp does not bind its own router.** `omp -p` reads
|
|
289
|
+
`$AUTO_MODEL_ROUTER_HOME/embed.port` and routes to whatever router already holds
|
|
290
|
+
that port — possibly stale code. To test *this* build, point `embed.port` at your
|
|
291
|
+
standalone `serve` and restore it after.
|
|
292
|
+
- **Windows port-kill:** `pkill -f "src/index.ts serve"` does not work in Git Bash;
|
|
293
|
+
free the port via `Get-NetTCPConnection -LocalPort <p> -State Listen` →
|
|
294
|
+
`Stop-Process -Force`.
|
|
295
|
+
- **Multiple installed copies exist** (repo, marketplace cache, global npm). This is
|
|
296
|
+
the same reason the deep-loop scorer fix "never landed" live. Before trusting any
|
|
297
|
+
compaction e2e result, confirm which router process is actually serving.
|
|
298
|
+
- Use `AUTO_MODEL_ROUTER_DB=<scratch>.db` so tests never touch the live ledger.
|
|
299
|
+
|
|
300
|
+
## Phase 2 — pinned, persisted LLM summarization (documented, not built)
|
|
301
|
+
|
|
302
|
+
Phase 1 cannot safely compress genuinely-needed long **prose** (dense reasoning,
|
|
303
|
+
multi-turn design discussion). Phase 2 adds bounded summarization, gated behind
|
|
304
|
+
Phase 1 and behind the same cache/determinism discipline the agentdox bridge uses.
|
|
305
|
+
|
|
306
|
+
### Mechanism
|
|
307
|
+
|
|
308
|
+
- Runs only when, **after** Phase-1 rules, the estimate still exceeds
|
|
309
|
+
`summarize.triggerTokens`.
|
|
310
|
+
- Summarizes the **oldest** compactable region (from just after the system/injected
|
|
311
|
+
block up to the edge of the protected window) into a single bounded note
|
|
312
|
+
(`maxSummaryTokens`), produced by `summarize.model` (a cheap slug).
|
|
313
|
+
- The note **replaces** that region's messages; tool_call↔result pairs inside the
|
|
314
|
+
region are summarized as facts ("read src/foo.ts (480 lines); edited lines 12–20"),
|
|
315
|
+
never left dangling.
|
|
316
|
+
|
|
317
|
+
### Determinism and cache — restored by pinning (the agentdox pattern)
|
|
318
|
+
|
|
319
|
+
- The summary is **content-addressed** (hash of the covered region) and **persisted**
|
|
320
|
+
in a new `compaction_summaries` table, with the active version pinned on the
|
|
321
|
+
conversation row (a `compaction_version` column, alongside `context_version`).
|
|
322
|
+
- Subsequent turns re-inject the **same** summary bytes verbatim → cache-stable and
|
|
323
|
+
`explain`-replayable (replay reads the pinned summary from the store; it never
|
|
324
|
+
re-summarizes).
|
|
325
|
+
- The summary is refreshed (recomputed to cover more history) **only when the cache
|
|
326
|
+
is already cold**, reusing the *same* cold-turn signal as `bridge.ts shouldRefresh`
|
|
327
|
+
(`pin === null || modelSwitching || retrying || staleness`) so the agentdox block
|
|
328
|
+
refresh and the summary refresh share one cache miss rather than each causing their own.
|
|
329
|
+
- **Breakpoint indices are adjusted, not stale.** A summary that replaces a message
|
|
330
|
+
range changes message count, so — exactly like `injectContextBlock` does when it
|
|
331
|
+
prepends a system message — Phase 2 returns adjusted `cacheBreakpointMessageIndices`
|
|
332
|
+
(shift the trailing indices by the count delta), or recomputes them on the collapsed
|
|
333
|
+
array. This is the one place Phase-1's "indices stay valid" guarantee does not hold.
|
|
334
|
+
|
|
335
|
+
### Degradation
|
|
336
|
+
|
|
337
|
+
Summarization is enrichment, not a dependency: if the summarizer errors, times out,
|
|
338
|
+
or is unconfigured, the turn falls back to Phase-1 output and dispatches normally —
|
|
339
|
+
mirroring the agentdox bridge's "degrade to null, never throw" contract.
|
|
340
|
+
|
|
341
|
+
### Risks and mitigations
|
|
342
|
+
|
|
343
|
+
- **A bad pinned summary persists** and poisons later turns → versioned + hashed so it
|
|
344
|
+
can be invalidated; never covers the protected recent window or tool structure;
|
|
345
|
+
Phase-1 breadcrumbs remain, so the model can still re-fetch elided specifics.
|
|
346
|
+
- **Detail loss induces re-derivation loops** → summaries state actions and outcomes,
|
|
347
|
+
never replace the *latest* authoritative tool results; recent window is untouched.
|
|
348
|
+
- **Added latency/cost** on the compute turn → amortized by pinning + cache-cold-only
|
|
349
|
+
refresh.
|
|
350
|
+
|
|
351
|
+
## Open questions
|
|
352
|
+
|
|
353
|
+
- Primary-argument extraction for superseded-read detection across arbitrary tool
|
|
354
|
+
schemas (heuristic: first string arg that looks like a path/id; configurable
|
|
355
|
+
per-tool later if needed).
|
|
356
|
+
- Token-estimate accuracy at the budget boundary (uses `estimateTokens` +
|
|
357
|
+
`token_calibration`; acceptable — the budget is a soft threshold, not a hard limit).
|
|
358
|
+
- Protocol coverage: OpenAI wire (`src/wire/openai/`) first; other protocols reuse the
|
|
359
|
+
same `compact()` core behind their own `renderUpstreamBody`.
|
|
360
|
+
- Interaction with `forcedToolChoice` turns (rare; compaction of history is still
|
|
361
|
+
safe, but verify the forced call is in the protected window).
|
|
362
|
+
```
|
|
@@ -52,6 +52,30 @@ export interface EmbedConfig {
|
|
|
52
52
|
baseUrl: string;
|
|
53
53
|
models: EmbedModelSpec[];
|
|
54
54
|
harnessId?: string;
|
|
55
|
+
/**
|
|
56
|
+
* agentdox project scope sent as `X-Agentdox-Scope`. Selects which
|
|
57
|
+
* project's shared context is injected into every turn, so switching
|
|
58
|
+
* models never loses the project's memory/docs/brief.
|
|
59
|
+
*/
|
|
60
|
+
agentdoxScope?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Derives an agentdox project slug from the omp workspace directory.
|
|
65
|
+
*
|
|
66
|
+
* The workspace basename is the one identifier that is already stable, already
|
|
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.
|
|
70
|
+
*/
|
|
71
|
+
export function deriveAgentdoxScope(cwd: string): string {
|
|
72
|
+
// Both separators: omp reports a Windows cwd with backslashes.
|
|
73
|
+
const cleaned = cwd.replace(/[\\/]+$/, "");
|
|
74
|
+
const base = cleaned.split(/[\\/]/).pop() ?? "";
|
|
75
|
+
return base
|
|
76
|
+
.toLowerCase()
|
|
77
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
78
|
+
.replace(/^-+|-+$/g, "");
|
|
55
79
|
}
|
|
56
80
|
|
|
57
81
|
/**
|
|
@@ -113,7 +137,10 @@ export function buildProviderConfig(
|
|
|
113
137
|
server: { host: string; harnessId?: string };
|
|
114
138
|
profiles: Array<{ id: string; name: string; contextWindow: number; maxTokens: number }>;
|
|
115
139
|
ledger: { fallbackBlend: { inputPerMtok: number; outputPerMtok: number } };
|
|
140
|
+
context?: { enabled: boolean; defaultScope: string };
|
|
116
141
|
},
|
|
142
|
+
/** omp's workspace directory, used to derive a scope when none is configured. */
|
|
143
|
+
cwd?: string,
|
|
117
144
|
): EmbedConfig {
|
|
118
145
|
const host = cfg.server.host === "0.0.0.0" || cfg.server.host === "::" ? "127.0.0.1" : cfg.server.host;
|
|
119
146
|
const round = (v: number): number => Math.round(v * 1e4) / 1e4;
|
|
@@ -137,5 +164,9 @@ export function buildProviderConfig(
|
|
|
137
164
|
if (cfg.server.harnessId !== undefined && cfg.server.harnessId !== "") {
|
|
138
165
|
out.harnessId = cfg.server.harnessId;
|
|
139
166
|
}
|
|
167
|
+
if (cfg.context?.enabled === true) {
|
|
168
|
+
const scope = cfg.context.defaultScope !== "" ? cfg.context.defaultScope : deriveAgentdoxScope(cwd ?? "");
|
|
169
|
+
if (scope !== "") out.agentdoxScope = scope;
|
|
170
|
+
}
|
|
140
171
|
return out;
|
|
141
172
|
}
|
|
@@ -44,7 +44,9 @@ import {
|
|
|
44
44
|
* registry at a specific bound port.
|
|
45
45
|
*/
|
|
46
46
|
function registerRouterProvider(pi: ExtensionAPI, port: number, cfg: RouterConfig, sessionId: string): void {
|
|
47
|
-
|
|
47
|
+
// cwd is omp's workspace, which is what the agentdox scope is derived from
|
|
48
|
+
// when none is configured explicitly.
|
|
49
|
+
const providerConfig = buildProviderConfig(port, cfg, process.cwd());
|
|
48
50
|
const headers: Record<string, string> = {};
|
|
49
51
|
if (providerConfig.harnessId !== undefined && providerConfig.harnessId !== "") {
|
|
50
52
|
headers["X-Omp-Harness"] = providerConfig.harnessId;
|
|
@@ -52,6 +54,10 @@ function registerRouterProvider(pi: ExtensionAPI, port: number, cfg: RouterConfi
|
|
|
52
54
|
// Per-session scoping: lets the toast surface only this session's decisions
|
|
53
55
|
// even when several omp sessions share one embedded router's ledger.
|
|
54
56
|
if (sessionId !== "") headers["X-Omp-Session"] = sessionId;
|
|
57
|
+
// Which agentdox project's shared context this workspace's turns draw on.
|
|
58
|
+
if (providerConfig.agentdoxScope !== undefined && providerConfig.agentdoxScope !== "") {
|
|
59
|
+
headers["X-Agentdox-Scope"] = providerConfig.agentdoxScope;
|
|
60
|
+
}
|
|
55
61
|
pi.registerProvider(EMBED_PROVIDER_ID, {
|
|
56
62
|
baseUrl: providerConfig.baseUrl,
|
|
57
63
|
api: "openai-completions",
|
package/package.json
CHANGED
package/src/cli/config-cmd.ts
CHANGED
|
@@ -57,6 +57,22 @@ export interface SpliceResult {
|
|
|
57
57
|
* `cost` is in omp's units: USD per MILLION tokens. The catalog works in
|
|
58
58
|
* per-token rates, so everything is scaled by 1e6 exactly once, here.
|
|
59
59
|
*/
|
|
60
|
+
/**
|
|
61
|
+
* 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`.
|
|
64
|
+
*/
|
|
65
|
+
function providerHeaders(cfg: RouterConfig): Record<string, string> {
|
|
66
|
+
const headers: Record<string, string> = {};
|
|
67
|
+
if (cfg.server.harnessId !== undefined && cfg.server.harnessId !== "") {
|
|
68
|
+
headers["X-Omp-Harness"] = cfg.server.harnessId;
|
|
69
|
+
}
|
|
70
|
+
if (cfg.context.enabled && cfg.context.defaultScope !== "") {
|
|
71
|
+
headers["X-Agentdox-Scope"] = cfg.context.defaultScope;
|
|
72
|
+
}
|
|
73
|
+
return headers;
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
export function renderProviderBlock(cfg: RouterConfig, blend: BlendedRate | null): string {
|
|
61
77
|
const input = blend?.inputPerMtok ?? cfg.ledger.fallbackBlend.inputPerMtok;
|
|
62
78
|
const output = blend?.outputPerMtok ?? cfg.ledger.fallbackBlend.outputPerMtok;
|
|
@@ -71,11 +87,10 @@ export function renderProviderBlock(cfg: RouterConfig, blend: BlendedRate | null
|
|
|
71
87
|
baseUrl: `http://${host}:${cfg.server.port}/v1`,
|
|
72
88
|
api: "openai-completions",
|
|
73
89
|
auth: "none",
|
|
74
|
-
//
|
|
75
|
-
// scope
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
: {}),
|
|
90
|
+
// Per-request tags. Harness id scopes daily budgets and toasts;
|
|
91
|
+
// agentdox scope selects which project's shared context is injected
|
|
92
|
+
// and which project's sessions the turns are recorded into.
|
|
93
|
+
...(Object.keys(providerHeaders(cfg)).length > 0 ? { headers: providerHeaders(cfg) } : {}),
|
|
79
94
|
models: cfg.profiles.map((p) => ({
|
|
80
95
|
id: p.id,
|
|
81
96
|
name: p.name,
|
package/src/cli/explain.ts
CHANGED
package/src/config/defaults.ts
CHANGED
|
@@ -69,9 +69,13 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
69
69
|
trustScopedByHarness: false,
|
|
70
70
|
contextHeadroom: 1.25,
|
|
71
71
|
// Latency scoring is off by default (weight 0): opt in after establishing a
|
|
72
|
-
// baseline.
|
|
72
|
+
// baseline. Expected total wait (TTFT + expected completion / throughput)
|
|
73
|
+
// above the references inflates a model's effective cost.
|
|
73
74
|
latencyWeight: 0,
|
|
74
75
|
latencyReferenceMs: 5000,
|
|
76
|
+
// 30 tok/s: below this a model streams noticeably slowly. Only genuinely
|
|
77
|
+
// slow models (e.g. deepseek-v4-flash ~20 tok/s) fall under it.
|
|
78
|
+
latencyReferenceTokensPerSec: 30,
|
|
75
79
|
latencyMinSamples: 20,
|
|
76
80
|
},
|
|
77
81
|
classifier: {
|
|
@@ -141,6 +145,34 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
141
145
|
maxBreakpoints: 4,
|
|
142
146
|
minPromptTokens: 2_048,
|
|
143
147
|
},
|
|
148
|
+
context: {
|
|
149
|
+
// Off until an agentdox URL + token are configured. Enabling this changes
|
|
150
|
+
// what every model sees, so it is never implicit.
|
|
151
|
+
enabled: false,
|
|
152
|
+
baseUrl: "",
|
|
153
|
+
token: "",
|
|
154
|
+
defaultScope: "",
|
|
155
|
+
timeoutMs: 3_000,
|
|
156
|
+
// Matches agentdox's own auto-context job cadence (900s): refreshing
|
|
157
|
+
// faster than the server reassembles buys nothing but cache misses.
|
|
158
|
+
maxStalenessMs: 900_000,
|
|
159
|
+
maxBlockChars: 24_000,
|
|
160
|
+
recordTurns: true,
|
|
161
|
+
maxQueue: 64,
|
|
162
|
+
},
|
|
163
|
+
compaction: {
|
|
164
|
+
// Off by default: shrinking context is behavior-changing, never implicit.
|
|
165
|
+
enabled: false,
|
|
166
|
+
// ~40k tokens: above this the prompt is dominated by re-sent tool output.
|
|
167
|
+
budgetTokens: 40_000,
|
|
168
|
+
fitToWindow: true,
|
|
169
|
+
protectRecentTurns: 4,
|
|
170
|
+
maxToolResultBytes: 4_096,
|
|
171
|
+
keepHeadBytes: 512,
|
|
172
|
+
keepTailBytes: 512,
|
|
173
|
+
elideSupersededReads: true,
|
|
174
|
+
collapseDuplicateResults: true,
|
|
175
|
+
},
|
|
144
176
|
budget: {
|
|
145
177
|
// No caps by default; at a configured ceiling, downgrade rather than fail.
|
|
146
178
|
onExceeded: "downgrade",
|
package/src/config/load.ts
CHANGED
|
@@ -108,6 +108,19 @@ export function loadConfig(opts?: { path?: string; overrides?: Partial<RouterCon
|
|
|
108
108
|
}
|
|
109
109
|
const envDb = process.env.AUTO_MODEL_ROUTER_DB;
|
|
110
110
|
if (envDb !== undefined && envDb !== "") putSection("ledger", "path", envDb);
|
|
111
|
+
|
|
112
|
+
// agentdox bridge. A URL + token are enough to turn it on: requiring
|
|
113
|
+
// `context.enabled` in a config file as well would make the common case
|
|
114
|
+
// (export two vars, restart) silently do nothing.
|
|
115
|
+
const envDoxUrl = process.env.AGENTDOX_URL;
|
|
116
|
+
const envDoxToken = process.env.AGENTDOX_TOKEN;
|
|
117
|
+
const envDoxScope = process.env.AGENTDOX_SCOPE;
|
|
118
|
+
if (envDoxUrl !== undefined && envDoxUrl !== "") putSection("context", "baseUrl", envDoxUrl);
|
|
119
|
+
if (envDoxToken !== undefined && envDoxToken !== "") putSection("context", "token", envDoxToken);
|
|
120
|
+
if (envDoxScope !== undefined && envDoxScope !== "") putSection("context", "defaultScope", envDoxScope);
|
|
121
|
+
if (envDoxUrl !== undefined && envDoxUrl !== "" && envDoxToken !== undefined && envDoxToken !== "") {
|
|
122
|
+
putSection("context", "enabled", true);
|
|
123
|
+
}
|
|
111
124
|
cfg = deepMerge(cfg, envInput);
|
|
112
125
|
|
|
113
126
|
// Explicit programmatic overrides win last.
|
package/src/config/schema.ts
CHANGED
|
@@ -67,6 +67,7 @@ const filters = z.strictObject({
|
|
|
67
67
|
contextHeadroom: z.number().positive().optional(),
|
|
68
68
|
latencyWeight: z.number().nonnegative().optional(),
|
|
69
69
|
latencyReferenceMs: z.number().positive().optional(),
|
|
70
|
+
latencyReferenceTokensPerSec: z.number().positive().optional(),
|
|
70
71
|
latencyMinSamples: z.number().int().nonnegative().optional(),
|
|
71
72
|
});
|
|
72
73
|
|
|
@@ -128,6 +129,30 @@ const cache = z.strictObject({
|
|
|
128
129
|
minPromptTokens: z.number().int().nonnegative().optional(),
|
|
129
130
|
});
|
|
130
131
|
|
|
132
|
+
const context = z.strictObject({
|
|
133
|
+
enabled: z.boolean().optional(),
|
|
134
|
+
baseUrl: z.string().optional(),
|
|
135
|
+
token: z.string().optional(),
|
|
136
|
+
defaultScope: z.string().optional(),
|
|
137
|
+
timeoutMs: z.number().int().positive().optional(),
|
|
138
|
+
maxStalenessMs: z.number().int().nonnegative().optional(),
|
|
139
|
+
maxBlockChars: z.number().int().positive().optional(),
|
|
140
|
+
recordTurns: z.boolean().optional(),
|
|
141
|
+
maxQueue: z.number().int().positive().optional(),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const compaction = z.strictObject({
|
|
145
|
+
enabled: z.boolean().optional(),
|
|
146
|
+
budgetTokens: z.number().int().positive().optional(),
|
|
147
|
+
fitToWindow: z.boolean().optional(),
|
|
148
|
+
protectRecentTurns: z.number().int().positive().optional(),
|
|
149
|
+
maxToolResultBytes: z.number().int().positive().optional(),
|
|
150
|
+
keepHeadBytes: z.number().int().nonnegative().optional(),
|
|
151
|
+
keepTailBytes: z.number().int().nonnegative().optional(),
|
|
152
|
+
elideSupersededReads: z.boolean().optional(),
|
|
153
|
+
collapseDuplicateResults: z.boolean().optional(),
|
|
154
|
+
});
|
|
155
|
+
|
|
131
156
|
const budget = z.strictObject({
|
|
132
157
|
perTurnUsd: z.number().nonnegative().optional(),
|
|
133
158
|
perConversationUsd: z.number().nonnegative().optional(),
|
|
@@ -187,6 +212,8 @@ export const configInputSchema = z.strictObject({
|
|
|
187
212
|
hysteresis: hysteresis.optional(),
|
|
188
213
|
exploration: exploration.optional(),
|
|
189
214
|
cache: cache.optional(),
|
|
215
|
+
context: context.optional(),
|
|
216
|
+
compaction: compaction.optional(),
|
|
190
217
|
budget: budget.optional(),
|
|
191
218
|
profiles: z.array(profile).optional(),
|
|
192
219
|
ledger: ledger.optional(),
|