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/config/types.ts
CHANGED
|
@@ -139,14 +139,21 @@ export interface FilterConfig {
|
|
|
139
139
|
*/
|
|
140
140
|
contextHeadroom: number;
|
|
141
141
|
/**
|
|
142
|
-
* How hard to penalise slow models in candidate scoring. A model's
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* equal quality and price wins.
|
|
142
|
+
* How hard to penalise slow models in candidate scoring. A model's expected
|
|
143
|
+
* total wait — TTFT plus streaming the expected completion at its measured
|
|
144
|
+
* throughput — above the reference inflates its effective cost, the same lever
|
|
145
|
+
* trust uses for flakiness, so a faster model of equal quality and price wins.
|
|
146
|
+
* 0 disables latency scoring entirely.
|
|
146
147
|
*/
|
|
147
148
|
latencyWeight: number;
|
|
148
|
-
/** TTFT (ms)
|
|
149
|
+
/** Reference TTFT (ms): the start-latency component that accrues no penalty. */
|
|
149
150
|
latencyReferenceMs: number;
|
|
151
|
+
/**
|
|
152
|
+
* Reference throughput (tokens/second): the streaming speed that accrues no
|
|
153
|
+
* penalty. Below it, a slow-streaming model's expected wait exceeds the
|
|
154
|
+
* reference and its effective cost is inflated.
|
|
155
|
+
*/
|
|
156
|
+
latencyReferenceTokensPerSec: number;
|
|
150
157
|
/** Streamed samples required before latency is scored against a model. */
|
|
151
158
|
latencyMinSamples: number;
|
|
152
159
|
}
|
|
@@ -326,6 +333,71 @@ export interface LedgerConfig {
|
|
|
326
333
|
conversationTtlMs: number;
|
|
327
334
|
}
|
|
328
335
|
|
|
336
|
+
/**
|
|
337
|
+
* agentdox bridge: one shared project context that follows a conversation
|
|
338
|
+
* across model switches, plus a model-attributed transcript written back.
|
|
339
|
+
*
|
|
340
|
+
* Off by default. Enabling it injects a project-context block into the system
|
|
341
|
+
* prefix; the block is PINNED per conversation and refreshed only when the
|
|
342
|
+
* prompt cache is already cold (see `src/context/bridge.ts`), so sharing
|
|
343
|
+
* context does not cost a cache miss every turn.
|
|
344
|
+
*/
|
|
345
|
+
export interface ContextConfig {
|
|
346
|
+
enabled: boolean;
|
|
347
|
+
/** agentdox REST base URL, e.g. `http://localhost:3003`. */
|
|
348
|
+
baseUrl: string;
|
|
349
|
+
/** Bearer token with read+write on the project scope. From `AGENTDOX_TOKEN`. */
|
|
350
|
+
token: string;
|
|
351
|
+
/**
|
|
352
|
+
* Fallback project scope when a request carries no `X-Agentdox-Scope`
|
|
353
|
+
* header. Empty ⇒ the bridge is inert for unlabelled requests rather than
|
|
354
|
+
* guessing, so one harness cannot leak context into another's project.
|
|
355
|
+
*/
|
|
356
|
+
defaultScope: string;
|
|
357
|
+
/** Per-request timeout against agentdox, ms. */
|
|
358
|
+
timeoutMs: number;
|
|
359
|
+
/**
|
|
360
|
+
* Upper bound on how stale a pinned context block may get, ms. Reaching it
|
|
361
|
+
* forces a refresh on the next turn even if the model did not change —
|
|
362
|
+
* the one case where the bridge knowingly spends a cache miss. 0 disables
|
|
363
|
+
* the TTL, refreshing only on turns whose cache is already forfeit.
|
|
364
|
+
*/
|
|
365
|
+
maxStalenessMs: number;
|
|
366
|
+
/** Hard cap on injected block size, characters. */
|
|
367
|
+
maxBlockChars: number;
|
|
368
|
+
/** Write settled turns back to agentdox sessions, tagged with the served model. */
|
|
369
|
+
recordTurns: boolean;
|
|
370
|
+
/** Bound on queued write-backs; excess turns are dropped, never buffered unbounded. */
|
|
371
|
+
maxQueue: number;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Context optimization (compaction): before dispatch, shrink stale, low-value
|
|
376
|
+
* bulk — chiefly old tool output — so long agentic conversations cost less and
|
|
377
|
+
* keep fitting narrower-window models. Deterministic and reversible-by-reference:
|
|
378
|
+
* every elision leaves an in-band breadcrumb so the model can re-run the tool.
|
|
379
|
+
* Off by default. See docs/context-optimization.md.
|
|
380
|
+
*/
|
|
381
|
+
export interface CompactionConfig {
|
|
382
|
+
enabled: boolean;
|
|
383
|
+
/** Compact when the estimated prompt exceeds this many tokens. */
|
|
384
|
+
budgetTokens: number;
|
|
385
|
+
/** Also compact when the prompt would overflow the profile's context window. */
|
|
386
|
+
fitToWindow: boolean;
|
|
387
|
+
/** Never touch the last N user/assistant turns or the volatile tail. */
|
|
388
|
+
protectRecentTurns: number;
|
|
389
|
+
/** Tool results larger than this (outside the protected window) are truncated. */
|
|
390
|
+
maxToolResultBytes: number;
|
|
391
|
+
/** Bytes of a truncated tool result's head to keep. */
|
|
392
|
+
keepHeadBytes: number;
|
|
393
|
+
/** Bytes of a truncated tool result's tail to keep. */
|
|
394
|
+
keepTailBytes: number;
|
|
395
|
+
/** Elide an older tool result when a newer call to the same resource supersedes it. */
|
|
396
|
+
elideSupersededReads: boolean;
|
|
397
|
+
/** Collapse byte-identical repeated tool results to a single copy. */
|
|
398
|
+
collapseDuplicateResults: boolean;
|
|
399
|
+
}
|
|
400
|
+
|
|
329
401
|
export interface RouterConfig {
|
|
330
402
|
server: ServerConfig;
|
|
331
403
|
openrouter: OpenRouterConfig;
|
|
@@ -338,6 +410,8 @@ export interface RouterConfig {
|
|
|
338
410
|
hysteresis: HysteresisConfig;
|
|
339
411
|
exploration: ExplorationConfig;
|
|
340
412
|
cache: CacheConfig;
|
|
413
|
+
context: ContextConfig;
|
|
414
|
+
compaction: CompactionConfig;
|
|
341
415
|
budget: BudgetConfig;
|
|
342
416
|
profiles: ProfileConfig[];
|
|
343
417
|
ledger: LedgerConfig;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentdox REST client.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately tiny and total: every method resolves to a value or null and
|
|
5
|
+
* never throws. agentdox is an ENRICHMENT, not a dependency — if it is down,
|
|
6
|
+
* slow, or unauthorized, the turn must still route and dispatch normally.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Logger } from "../util/log.ts";
|
|
10
|
+
|
|
11
|
+
export interface AgentDoxClientOptions {
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
token: string;
|
|
14
|
+
timeoutMs: number;
|
|
15
|
+
log: Logger;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AgentDoxClient {
|
|
19
|
+
/**
|
|
20
|
+
* Assembles a context slice for `scope`, biased by `query`. Falls back to
|
|
21
|
+
* the server's pre-assembled baseline when assembly is unavailable (older
|
|
22
|
+
* server, or no query-relevant content).
|
|
23
|
+
*/
|
|
24
|
+
assemble(scope: string, query: string): Promise<string | null>;
|
|
25
|
+
createSession(scope: string, title: string): Promise<string | null>;
|
|
26
|
+
append(sessionId: string, role: "user" | "assistant", content: string, refs: string[]): Promise<boolean>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createAgentDoxClient(opts: AgentDoxClientOptions): AgentDoxClient {
|
|
30
|
+
const { baseUrl, token, timeoutMs, log } = opts;
|
|
31
|
+
const root = baseUrl.replace(/\/+$/, "");
|
|
32
|
+
|
|
33
|
+
const request = async (
|
|
34
|
+
method: string,
|
|
35
|
+
path: string,
|
|
36
|
+
body?: unknown,
|
|
37
|
+
): Promise<{ status: number; json: unknown } | null> => {
|
|
38
|
+
const ctl = new AbortController();
|
|
39
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
40
|
+
try {
|
|
41
|
+
const headers: Record<string, string> = { authorization: `Bearer ${token}` };
|
|
42
|
+
if (body !== undefined) headers["content-type"] = "application/json";
|
|
43
|
+
const res = await fetch(`${root}${path}`, {
|
|
44
|
+
method,
|
|
45
|
+
headers,
|
|
46
|
+
signal: ctl.signal,
|
|
47
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
48
|
+
});
|
|
49
|
+
// A 404 is meaningful data (no snapshot/brief yet), not a failure.
|
|
50
|
+
const text = await res.text();
|
|
51
|
+
let parsed: unknown = null;
|
|
52
|
+
try {
|
|
53
|
+
parsed = text === "" ? null : JSON.parse(text);
|
|
54
|
+
} catch {
|
|
55
|
+
parsed = null;
|
|
56
|
+
}
|
|
57
|
+
return { status: res.status, json: parsed };
|
|
58
|
+
} catch (err) {
|
|
59
|
+
// Timeouts, connection refused, DNS — all the same to the caller.
|
|
60
|
+
log.debug("agentdox request failed", {
|
|
61
|
+
method,
|
|
62
|
+
path,
|
|
63
|
+
error: err instanceof Error ? err.message : String(err),
|
|
64
|
+
});
|
|
65
|
+
return null;
|
|
66
|
+
} finally {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const promptOf = (json: unknown): string | null => {
|
|
72
|
+
if (typeof json !== "object" || json === null) return null;
|
|
73
|
+
const p = (json as { prompt?: unknown }).prompt;
|
|
74
|
+
return typeof p === "string" && p.trim() !== "" ? p : null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
async assemble(scope, query) {
|
|
79
|
+
const res = await request("POST", "/context/assemble", { scope, query });
|
|
80
|
+
if (res !== null && res.status === 200) {
|
|
81
|
+
const prompt = promptOf(res.json);
|
|
82
|
+
if (prompt !== null) return prompt;
|
|
83
|
+
}
|
|
84
|
+
if (res !== null && (res.status === 401 || res.status === 403)) {
|
|
85
|
+
log.warn("agentdox rejected the router token; context injection is off for this scope", {
|
|
86
|
+
scope,
|
|
87
|
+
status: res.status,
|
|
88
|
+
});
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
// Baseline fallback: the server-side auto-context job keeps this fresh.
|
|
92
|
+
const snap = await request("GET", `/context/snapshot?scope=${encodeURIComponent(scope)}`);
|
|
93
|
+
if (snap === null || snap.status !== 200) return null;
|
|
94
|
+
return promptOf(snap.json);
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
async createSession(scope, title) {
|
|
98
|
+
const res = await request("POST", "/sessions", { scope, title });
|
|
99
|
+
if (res === null || (res.status !== 200 && res.status !== 201)) return null;
|
|
100
|
+
const id = (res.json as { id?: unknown } | null)?.id;
|
|
101
|
+
return typeof id === "string" ? id : null;
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async append(sessionId, role, content, refs) {
|
|
105
|
+
const res = await request("POST", `/sessions/${encodeURIComponent(sessionId)}/messages`, {
|
|
106
|
+
role,
|
|
107
|
+
content,
|
|
108
|
+
refs,
|
|
109
|
+
});
|
|
110
|
+
return res !== null && (res.status === 200 || res.status === 201);
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The refresh policy, which is the whole design.
|
|
3
|
+
*
|
|
4
|
+
* Injecting fresh context every turn would defeat the router's single largest
|
|
5
|
+
* cost lever: the prompt cache. The block sits at the front of the prefix, so
|
|
6
|
+
* changing it invalidates everything after it. The rule is therefore to
|
|
7
|
+
* re-fetch ONLY when the prefix is already being paid for:
|
|
8
|
+
*
|
|
9
|
+
* - no pin yet first turn; nothing is warm
|
|
10
|
+
* - model switching the router already decided to forfeit the cache
|
|
11
|
+
* - retrying an escalation/failover dispatch is cold by definition
|
|
12
|
+
* - staleness TTL a bounded upper limit on how old context may get
|
|
13
|
+
*
|
|
14
|
+
* Between those moments the same bytes are re-injected verbatim and the cache
|
|
15
|
+
* survives. This is what makes "context refreshes as I switch models" cheap
|
|
16
|
+
* rather than ruinous: the refresh rides on a cache miss that was happening
|
|
17
|
+
* anyway.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { sha256Hex } from "../util/hash.ts";
|
|
21
|
+
import type { Logger } from "../util/log.ts";
|
|
22
|
+
import type { AgentDoxClient } from "./agentdox.ts";
|
|
23
|
+
import type { ContextBlockStore, ContextBridge, ContextPin, ContextResolveInput, TurnRecord } from "./types.ts";
|
|
24
|
+
|
|
25
|
+
export interface BridgeOptions {
|
|
26
|
+
client: AgentDoxClient;
|
|
27
|
+
store: ContextBlockStore;
|
|
28
|
+
log: Logger;
|
|
29
|
+
/** Upper bound on pinned-context age, ms. 0 ⇒ refresh only on cache-cold turns. */
|
|
30
|
+
maxStalenessMs: number;
|
|
31
|
+
/** Hard cap on injected block size; a runaway context must not dominate the prompt. */
|
|
32
|
+
maxBlockChars: number;
|
|
33
|
+
/** Record settled turns back into agentdox sessions. */
|
|
34
|
+
recordTurns: boolean;
|
|
35
|
+
/** Bound on queued write-backs; excess is dropped rather than grown unbounded. */
|
|
36
|
+
maxQueue: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Wraps the raw agentdox slice in a delimiter the model can reason about. */
|
|
40
|
+
function renderBlock(raw: string, maxChars: number): string {
|
|
41
|
+
const body = raw.length > maxChars ? `${raw.slice(0, maxChars)}\n[...truncated]` : raw;
|
|
42
|
+
return [
|
|
43
|
+
"<project-context source=\"agentdox\">",
|
|
44
|
+
"Durable project memory, documentation, and history shared across every model that",
|
|
45
|
+
"serves this conversation. Treat it as established fact; prefer it over re-deriving.",
|
|
46
|
+
"",
|
|
47
|
+
body,
|
|
48
|
+
"</project-context>",
|
|
49
|
+
].join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createContextBridge(opts: BridgeOptions): ContextBridge {
|
|
53
|
+
const { client, store, log, maxStalenessMs, maxBlockChars, recordTurns, maxQueue } = opts;
|
|
54
|
+
|
|
55
|
+
// Serialized write-back queue. Session appends for one conversation must
|
|
56
|
+
// stay ordered, and agentdox is a local service — one worker is plenty.
|
|
57
|
+
let queue: Promise<void> = Promise.resolve();
|
|
58
|
+
let queued = 0;
|
|
59
|
+
let closed = false;
|
|
60
|
+
|
|
61
|
+
const shouldRefresh = (input: ContextResolveInput, pin: ContextPin | null): boolean => {
|
|
62
|
+
if (pin === null) return true;
|
|
63
|
+
if (input.modelSwitching || input.retrying) return true;
|
|
64
|
+
// Staleness is measured from when THIS conversation last refreshed, not
|
|
65
|
+
// from the shared block row: another conversation confirming the same
|
|
66
|
+
// content must not silently extend our TTL. The re-check is cheap
|
|
67
|
+
// anyway — identical content hashes to the same version, so a
|
|
68
|
+
// confirming refresh re-sends identical bytes and the cache survives.
|
|
69
|
+
if (maxStalenessMs > 0 && Date.now() - input.pinnedFetchedAtMs > maxStalenessMs) return true;
|
|
70
|
+
return false;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
enabled: true,
|
|
75
|
+
|
|
76
|
+
async resolve(input) {
|
|
77
|
+
if (input.scope === "") return null;
|
|
78
|
+
|
|
79
|
+
const pinned = input.pinnedVersion === null ? null : store.get(input.pinnedVersion);
|
|
80
|
+
if (!shouldRefresh(input, pinned) && pinned !== null) {
|
|
81
|
+
// Carry the conversation's own pin time forward, so the TTL keeps
|
|
82
|
+
// counting from its last real refresh rather than resetting to
|
|
83
|
+
// whenever some other conversation last touched this block.
|
|
84
|
+
return { ...pinned, fetchedAtMs: input.pinnedFetchedAtMs };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const raw = await client.assemble(input.scope, input.query);
|
|
88
|
+
if (raw === null) {
|
|
89
|
+
// agentdox unreachable or empty. Keep serving the pinned block if we
|
|
90
|
+
// have one: stale shared context beats none, and re-using it also
|
|
91
|
+
// keeps the prefix stable.
|
|
92
|
+
return pinned;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const block = renderBlock(raw, maxBlockChars);
|
|
96
|
+
// Content hash, not a timestamp: agentdox re-assembles on a timer, and
|
|
97
|
+
// an unchanged assembly MUST keep its version so the cache survives.
|
|
98
|
+
const version = sha256Hex(block).slice(0, 32);
|
|
99
|
+
const pin: ContextPin = { version, block, fetchedAtMs: Date.now() };
|
|
100
|
+
try {
|
|
101
|
+
store.put(input.scope, pin);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
log.debug("context block persist failed", { error: err instanceof Error ? err.message : String(err) });
|
|
104
|
+
}
|
|
105
|
+
if (pinned !== null && pinned.version !== version) {
|
|
106
|
+
log.debug("context refreshed", {
|
|
107
|
+
scope: input.scope,
|
|
108
|
+
from: pinned.version.slice(0, 8),
|
|
109
|
+
to: version.slice(0, 8),
|
|
110
|
+
reason: input.retrying ? "retry" : input.modelSwitching ? "model-switch" : "stale",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return pin;
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
recordTurn(rec: TurnRecord) {
|
|
117
|
+
if (!recordTurns || closed || rec.scope === "") return;
|
|
118
|
+
if (rec.userText === "" && rec.assistantText === "") return;
|
|
119
|
+
if (queued >= maxQueue) {
|
|
120
|
+
log.debug("agentdox write-back queue full; dropping turn record", { queued });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
queued++;
|
|
124
|
+
queue = queue
|
|
125
|
+
.then(async () => {
|
|
126
|
+
let sessionId = store.sessionFor(rec.conversationKey);
|
|
127
|
+
if (sessionId === null) {
|
|
128
|
+
sessionId = await client.createSession(rec.scope, rec.title);
|
|
129
|
+
if (sessionId === null) return;
|
|
130
|
+
store.bindSession(rec.conversationKey, rec.scope, sessionId);
|
|
131
|
+
}
|
|
132
|
+
// Model attribution rides on refs, which agentdox already carries
|
|
133
|
+
// per message. This is what makes the transcript newly useful:
|
|
134
|
+
// every turn shows WHICH model produced it.
|
|
135
|
+
const refs = [`model:${rec.slug}`, `tier:${rec.tier}`];
|
|
136
|
+
if (rec.userText !== "") await client.append(sessionId, "user", rec.userText, []);
|
|
137
|
+
if (rec.assistantText !== "") await client.append(sessionId, "assistant", rec.assistantText, refs);
|
|
138
|
+
})
|
|
139
|
+
.catch((err: unknown) => {
|
|
140
|
+
log.debug("agentdox write-back failed", { error: err instanceof Error ? err.message : String(err) });
|
|
141
|
+
})
|
|
142
|
+
.finally(() => {
|
|
143
|
+
queued--;
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
async flush() {
|
|
148
|
+
await queue;
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
close() {
|
|
152
|
+
closed = true;
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The inert bridge used when agentdox is not configured. Every call is free. */
|
|
158
|
+
export function createDisabledBridge(): ContextBridge {
|
|
159
|
+
return {
|
|
160
|
+
enabled: false,
|
|
161
|
+
resolve: async () => null,
|
|
162
|
+
recordTurn: () => {},
|
|
163
|
+
flush: async () => {},
|
|
164
|
+
close: () => {},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge factory. Returns the inert bridge unless agentdox is fully
|
|
3
|
+
* configured, so every call site can stay unconditional.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Database } from "bun:sqlite";
|
|
7
|
+
|
|
8
|
+
import type { RouterConfig } from "../config/types.ts";
|
|
9
|
+
import { createLogger } from "../util/log.ts";
|
|
10
|
+
import { createAgentDoxClient } from "./agentdox.ts";
|
|
11
|
+
import { createContextBridge, createDisabledBridge } from "./bridge.ts";
|
|
12
|
+
import { createContextStore } from "./store.ts";
|
|
13
|
+
import type { ContextBridge } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
export type { ContextBridge, ContextPin, ContextResolveInput, TurnRecord } from "./types.ts";
|
|
16
|
+
export { createContextBridge, createDisabledBridge } from "./bridge.ts";
|
|
17
|
+
export { createContextStore } from "./store.ts";
|
|
18
|
+
export { createAgentDoxClient } from "./agentdox.ts";
|
|
19
|
+
|
|
20
|
+
export function createBridgeFromConfig(cfg: RouterConfig, db: Database): ContextBridge {
|
|
21
|
+
const c = cfg.context;
|
|
22
|
+
if (!c.enabled || c.baseUrl === "" || c.token === "") return createDisabledBridge();
|
|
23
|
+
const log = createLogger(cfg.logLevel);
|
|
24
|
+
return createContextBridge({
|
|
25
|
+
client: createAgentDoxClient({ baseUrl: c.baseUrl, token: c.token, timeoutMs: c.timeoutMs, log }),
|
|
26
|
+
store: createContextStore(db),
|
|
27
|
+
log,
|
|
28
|
+
maxStalenessMs: c.maxStalenessMs,
|
|
29
|
+
maxBlockChars: c.maxBlockChars,
|
|
30
|
+
recordTurns: c.recordTurns,
|
|
31
|
+
maxQueue: c.maxQueue,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistence for the agentdox bridge.
|
|
3
|
+
*
|
|
4
|
+
* Blocks are content-addressed by version and shared across conversations, so
|
|
5
|
+
* ten conversations on one project store one copy. Persisting them (rather
|
|
6
|
+
* than caching in memory) means a router restart can re-inject the SAME bytes
|
|
7
|
+
* a conversation was already using — OpenRouter's prompt cache outlives our
|
|
8
|
+
* process, and re-fetching would needlessly change the prefix.
|
|
9
|
+
*
|
|
10
|
+
* Tables are created by `util/sqlite.ts`, the single migration path.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Database, Statement } from "bun:sqlite";
|
|
14
|
+
|
|
15
|
+
import type { ContextBlockStore, ContextPin } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
interface BlockRow {
|
|
18
|
+
version: string;
|
|
19
|
+
block: string;
|
|
20
|
+
fetched_at_ms: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface SessionRow {
|
|
24
|
+
session_id: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createContextStore(db: Database): ContextBlockStore {
|
|
28
|
+
// Hoisted: these run on the turn hot path.
|
|
29
|
+
const selectBlock: Statement<BlockRow, [string]> = db.query(
|
|
30
|
+
"SELECT version, block, fetched_at_ms FROM context_blocks WHERE version = ?",
|
|
31
|
+
);
|
|
32
|
+
const insertBlock = db.query(`
|
|
33
|
+
INSERT INTO context_blocks (version, scope, block, fetched_at_ms)
|
|
34
|
+
VALUES ($version, $scope, $block, $fetchedAtMs)
|
|
35
|
+
ON CONFLICT(version) DO UPDATE SET fetched_at_ms = excluded.fetched_at_ms
|
|
36
|
+
`);
|
|
37
|
+
const selectSession: Statement<SessionRow, [string]> = db.query(
|
|
38
|
+
"SELECT session_id FROM agentdox_sessions WHERE conversation_key = ?",
|
|
39
|
+
);
|
|
40
|
+
const insertSession = db.query(`
|
|
41
|
+
INSERT INTO agentdox_sessions (conversation_key, scope, session_id, created_at_ms)
|
|
42
|
+
VALUES ($key, $scope, $sessionId, $createdAtMs)
|
|
43
|
+
ON CONFLICT(conversation_key) DO UPDATE SET session_id = excluded.session_id
|
|
44
|
+
`);
|
|
45
|
+
const deleteStale: Statement<unknown, [number]> = db.query("DELETE FROM context_blocks WHERE fetched_at_ms < ?");
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
get(version) {
|
|
49
|
+
const row = selectBlock.get(version);
|
|
50
|
+
if (row === null) return null;
|
|
51
|
+
return { version: row.version, block: row.block, fetchedAtMs: row.fetched_at_ms };
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
put(scope, pin: ContextPin) {
|
|
55
|
+
insertBlock.run({
|
|
56
|
+
$version: pin.version,
|
|
57
|
+
$scope: scope,
|
|
58
|
+
$block: pin.block,
|
|
59
|
+
$fetchedAtMs: pin.fetchedAtMs,
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
sessionFor(conversationKey) {
|
|
64
|
+
const row = selectSession.get(conversationKey);
|
|
65
|
+
return row === null ? null : row.session_id;
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
bindSession(conversationKey, scope, sessionId) {
|
|
69
|
+
insertSession.run({
|
|
70
|
+
$key: conversationKey,
|
|
71
|
+
$scope: scope,
|
|
72
|
+
$sessionId: sessionId,
|
|
73
|
+
$createdAtMs: Date.now(),
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
prune(maxAgeMs) {
|
|
78
|
+
const res = deleteStale.run(Date.now() - maxAgeMs) as unknown as { changes?: number };
|
|
79
|
+
return res.changes ?? 0;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentdox bridge — one shared project context that survives model switches.
|
|
3
|
+
*
|
|
4
|
+
* The router is the only choke point that sees every model in every harness,
|
|
5
|
+
* so injecting project memory/docs/brief HERE gives every candidate model the
|
|
6
|
+
* same context without per-harness MCP wiring.
|
|
7
|
+
*
|
|
8
|
+
* The load-bearing constraint is the prompt cache. A context block that
|
|
9
|
+
* changes per turn sits at the front of the prefix and turns every turn into a
|
|
10
|
+
* full cache miss — which would cost far more than routing saves. So a block
|
|
11
|
+
* is PINNED per conversation and only re-fetched at moments the prefix is
|
|
12
|
+
* already forfeit (a model switch, an escalation retry) or after a staleness
|
|
13
|
+
* TTL. See `bridge.ts` for the policy.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A pinned, prompt-ready context block. */
|
|
17
|
+
export interface ContextPin {
|
|
18
|
+
/**
|
|
19
|
+
* Content hash of `block`. Deliberately NOT agentdox's `assembledAt`: the
|
|
20
|
+
* server re-assembles on a timer, so a timestamp changes even when nothing
|
|
21
|
+
* about the content did, and that would break a warm cache for no reason.
|
|
22
|
+
*/
|
|
23
|
+
version: string;
|
|
24
|
+
/** Text appended to the system prefix by the wire. */
|
|
25
|
+
block: string;
|
|
26
|
+
fetchedAtMs: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ContextResolveInput {
|
|
30
|
+
/** agentdox project slug. Empty ⇒ the bridge is inert for this turn. */
|
|
31
|
+
scope: string;
|
|
32
|
+
conversationKey: string;
|
|
33
|
+
/** Version pinned by the previous turn; null on a fresh conversation. */
|
|
34
|
+
pinnedVersion: string | null;
|
|
35
|
+
/** When that pin was fetched, for the staleness TTL. */
|
|
36
|
+
pinnedFetchedAtMs: number;
|
|
37
|
+
/** This turn dispatches to a different slug than the last committed turn. */
|
|
38
|
+
modelSwitching: boolean;
|
|
39
|
+
/** An escalation or failover retry — the prefix is cold either way. */
|
|
40
|
+
retrying: boolean;
|
|
41
|
+
/** Latest user text, used to bias agentdox relevance ranking. */
|
|
42
|
+
query: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One settled turn, recorded to agentdox with the model that served it. */
|
|
46
|
+
export interface TurnRecord {
|
|
47
|
+
scope: string;
|
|
48
|
+
conversationKey: string;
|
|
49
|
+
/** Title used if this is the first turn and a session must be created. */
|
|
50
|
+
title: string;
|
|
51
|
+
userText: string;
|
|
52
|
+
assistantText: string;
|
|
53
|
+
/** The slug that actually served the turn — the model attribution. */
|
|
54
|
+
slug: string;
|
|
55
|
+
tier: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ContextBridge {
|
|
59
|
+
/** False when no agentdox URL/token is configured; every call is then a no-op. */
|
|
60
|
+
readonly enabled: boolean;
|
|
61
|
+
/** Resolves the block to inject. Never throws: agentdox being down degrades to null. */
|
|
62
|
+
resolve(input: ContextResolveInput): Promise<ContextPin | null>;
|
|
63
|
+
/** Queues a turn for write-back. Returns immediately; never blocks the turn. */
|
|
64
|
+
recordTurn(rec: TurnRecord): void;
|
|
65
|
+
/** Drains the write queue. For tests and shutdown. */
|
|
66
|
+
flush(): Promise<void>;
|
|
67
|
+
close(): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Content-addressed store of fetched blocks, so a restart keeps a warm prefix. */
|
|
71
|
+
export interface ContextBlockStore {
|
|
72
|
+
get(version: string): ContextPin | null;
|
|
73
|
+
put(scope: string, pin: ContextPin): void;
|
|
74
|
+
/** agentdox session id previously opened for a conversation. */
|
|
75
|
+
sessionFor(conversationKey: string): string | null;
|
|
76
|
+
bindSession(conversationKey: string, scope: string, sessionId: string): void;
|
|
77
|
+
prune(maxAgeMs: number): number;
|
|
78
|
+
}
|