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
package/src/router/types.ts
CHANGED
|
@@ -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;
|
package/src/server/http.ts
CHANGED
|
@@ -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
|
|
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
|
};
|
package/src/server/turn.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/util/sqlite.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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";
|
package/test/classify.test.ts
CHANGED
|
@@ -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
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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");
|