auto-model-router 0.4.2 → 0.4.4
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/.gitattributes +2 -0
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +31 -3
- package/bunfig.toml +2 -0
- package/omp-extension/report-logic.ts +46 -0
- package/omp-extension/router-configure.ts +55 -3
- package/package.json +1 -1
- package/src/catalog/composite.ts +4 -1
- package/src/cli/config-wizard.ts +8 -1
- package/src/config/defaults.ts +8 -0
- package/src/config/hot-reload.ts +58 -9
- package/src/config/schema.ts +5 -1
- package/src/config/types.ts +34 -0
- package/src/cost/ledger.ts +84 -8
- package/src/cost/report.ts +34 -4
- package/src/cost/summary.ts +231 -0
- package/src/cost/types.ts +35 -3
- package/src/router/candidates.ts +1 -1
- package/src/router/classify.ts +2 -2
- package/src/router/compaction.ts +2 -1
- package/src/router/learned.ts +11 -1
- package/src/router/select.ts +27 -3
- package/src/server/compaction-digest.ts +129 -0
- package/src/server/digest.ts +68 -4
- package/src/server/http.ts +50 -7
- package/src/server/providers.ts +1 -0
- package/src/server/turn.ts +38 -1
- package/src/util/sqlite.ts +7 -0
- package/src/wire/openai/request.ts +4 -0
- package/src/wire/types.ts +7 -0
- package/test/cache-control.test.ts +1 -1
- package/test/compaction.test.ts +40 -3
- package/test/digest.test.ts +44 -0
- package/test/failover.test.ts +4 -4
- package/test/hot-reload.test.ts +37 -1
- package/test/learned.test.ts +21 -1
- package/test/migrations.test.ts +84 -0
- package/test/report-hub.test.ts +1 -1
- package/test/report-logic.test.ts +11 -1
- package/test/report.test.ts +29 -1
- package/test/select.test.ts +26 -2
- package/test/summary.test.ts +171 -0
- package/test/support/preload.ts +19 -0
- package/test/tokens.test.ts +68 -0
- package/test/trust-attribution.test.ts +37 -0
- package/test/turn.test.ts +69 -4
- package/tools/gen-migration-fixtures.ts +69 -0
- package/tools/train-classifier.ts +75 -20
package/src/router/select.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { CatalogSnapshot } from "../catalog/types.ts";
|
|
10
10
|
import type { ProfileConfig, RouterConfig } from "../config/types.ts";
|
|
11
|
-
import { priceAt } from "../cost/forecast.ts";
|
|
11
|
+
import { forecast, priceAt } from "../cost/forecast.ts";
|
|
12
12
|
import type { Ledger } from "../cost/types.ts";
|
|
13
13
|
import { explorationDraw } from "./explore.ts";
|
|
14
14
|
import type { CompactionEdit, NormRequest, ReasoningLevel } from "../wire/types.ts";
|
|
@@ -361,7 +361,11 @@ export function select(args: SelectArgs): Decision {
|
|
|
361
361
|
// query per signal kind, instead of per-model individual lookups.
|
|
362
362
|
const candidateSignals =
|
|
363
363
|
ledger !== null && snapshot.models.length > 0
|
|
364
|
-
? ledger.signals?.(
|
|
364
|
+
? ledger.signals?.(
|
|
365
|
+
snapshot.models.map((m) => m.slug),
|
|
366
|
+
cfg.filters.trustScopedByHarness ? req.harnessId : undefined,
|
|
367
|
+
cfg.filters.feedbackByTask ? classification.task : undefined,
|
|
368
|
+
)
|
|
365
369
|
: undefined;
|
|
366
370
|
// What an escalated retry has actually been billing per prompt token, for
|
|
367
371
|
// the escalation-cost term in candidate scoring. Read once per turn; null
|
|
@@ -606,13 +610,33 @@ export function select(args: SelectArgs): Decision {
|
|
|
606
610
|
}
|
|
607
611
|
const stripAssistantReasoning = !(chosen.model.supportsReasoning && REASONING_REPLAY_AUTHORS[chosen.model.author] === true);
|
|
608
612
|
|
|
613
|
+
// The recorded forecast is the EXPECTED price of this dispatch, not the
|
|
614
|
+
// cold worst case candidates are ranked on. When the chosen model's cache
|
|
615
|
+
// is warm, the previous prompt's tokens are priced as cache reads at the
|
|
616
|
+
// model's measured hit rate; coldUsd stays the cold figure the budget
|
|
617
|
+
// guards used. Before this every recorded forecast was cold while nine
|
|
618
|
+
// turns in ten were warm: 89% over-predicted, median error 220%.
|
|
619
|
+
let expectedForecast = chosen.forecast;
|
|
620
|
+
if (warmSlug !== null && chosen.model.slug === warmSlug && effFeatures.promptTokens > 0 && state.lastPromptTokens > 0) {
|
|
621
|
+
const cachedShare = Math.min(1, state.lastPromptTokens / effFeatures.promptTokens);
|
|
622
|
+
let images = 0;
|
|
623
|
+
if (req.hasImages) for (const m of req.messages) images += m.images;
|
|
624
|
+
const warm = forecast(chosen.model, {
|
|
625
|
+
promptTokens: effFeatures.promptTokens,
|
|
626
|
+
completionTokens: EXPECTED_COMPLETION_TOKENS,
|
|
627
|
+
cacheHitRate: cacheHitExpectation(chosen.model.slug).rate * cachedShare,
|
|
628
|
+
images,
|
|
629
|
+
});
|
|
630
|
+
expectedForecast = { ...warm, coldUsd: chosen.forecast.coldUsd };
|
|
631
|
+
}
|
|
632
|
+
|
|
609
633
|
return {
|
|
610
634
|
slug: chosen.model.slug,
|
|
611
635
|
fallbacks,
|
|
612
636
|
tier: chosenTier,
|
|
613
637
|
classification: cls,
|
|
614
638
|
features,
|
|
615
|
-
forecast:
|
|
639
|
+
forecast: expectedForecast,
|
|
616
640
|
sessionId: state.sessionId,
|
|
617
641
|
sticky,
|
|
618
642
|
cacheBreakpointMessageIndices,
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Summarising compaction: when the compaction plan gains new edits, a cheap
|
|
3
|
+
* model digests the tool results those edits would otherwise truncate or stub,
|
|
4
|
+
* and the digest rides in the persisted plan in place of the breadcrumb.
|
|
5
|
+
*
|
|
6
|
+
* Plain compaction keeps a head and a tail of a stale tool result; a digest
|
|
7
|
+
* keeps what the task needs from all of it (paths, identifiers, errors, the
|
|
8
|
+
* code the agent will edit) in a few hundred chars. The digest is stored on
|
|
9
|
+
* the edit, so the bytes sent stay identical on every later turn until the
|
|
10
|
+
* plan changes — the same byte-stability plain edits have, which is what keeps
|
|
11
|
+
* the prompt cache warm.
|
|
12
|
+
*
|
|
13
|
+
* Bounded: at most `compaction.digestMaxPerTurn` digests per turn, each under
|
|
14
|
+
* the digest's own cost guard and timeout, largest results first. A digest
|
|
15
|
+
* that fails or declines leaves the plain edit in place; nothing is lost.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { RouterConfig } from "../config/types.ts";
|
|
19
|
+
import { compactedBytes } from "../router/compaction.ts";
|
|
20
|
+
import type { Logger } from "../util/log.ts";
|
|
21
|
+
import type { CompactionEdit, NormRequest } from "../wire/types.ts";
|
|
22
|
+
import type { DigestRequest, DigestResult } from "./digest.ts";
|
|
23
|
+
|
|
24
|
+
export interface CompactionDigester {
|
|
25
|
+
digest(req: DigestRequest): Promise<DigestResult>;
|
|
26
|
+
/** See Digester.noteToolCalls; optional so a fake need not implement it. */
|
|
27
|
+
noteToolCalls?(ompSessionId: string, calls: readonly { name: string; argsJson: string }[], nowMs?: number): number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DigestCompactionArgs {
|
|
31
|
+
req: NormRequest;
|
|
32
|
+
/** The plan this turn dispatches with; edits gain `digest` in place. */
|
|
33
|
+
plan: CompactionEdit[];
|
|
34
|
+
/** The tier this turn routed to: the digest pays off only above `digest.fromTier`. */
|
|
35
|
+
tier: string;
|
|
36
|
+
cfg: RouterConfig;
|
|
37
|
+
digester: CompactionDigester;
|
|
38
|
+
/** Per-turn memo (index:bytes → digest) so a retry does not pay twice. */
|
|
39
|
+
memo: Map<string, string>;
|
|
40
|
+
/** The user's current ask, steering what the digest keeps. */
|
|
41
|
+
query: string;
|
|
42
|
+
log: Logger;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Tool name and parsed arguments for a tool-result message, via its call id. */
|
|
46
|
+
function toolOf(req: NormRequest, index: number): { name: string; input: Record<string, unknown> } | null {
|
|
47
|
+
const m = req.messages[index];
|
|
48
|
+
if (m === undefined || m.role !== "tool") return null;
|
|
49
|
+
let name = m.toolName ?? "";
|
|
50
|
+
let input: Record<string, unknown> = {};
|
|
51
|
+
if (m.toolCallId !== undefined) {
|
|
52
|
+
for (const a of req.messages) {
|
|
53
|
+
if (a.role !== "assistant") continue;
|
|
54
|
+
const tc = a.toolCalls.find((c) => c.id === m.toolCallId);
|
|
55
|
+
if (tc === undefined) continue;
|
|
56
|
+
if (name === "") name = tc.name;
|
|
57
|
+
try {
|
|
58
|
+
const parsed: unknown = JSON.parse(tc.argsJson);
|
|
59
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) input = parsed as Record<string, unknown>;
|
|
60
|
+
} catch {
|
|
61
|
+
// Unparseable args: the marker just names the tool.
|
|
62
|
+
}
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return name === "" ? null : { name, input };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Digests the plan's new (undigested) edits, largest first, up to the per-turn
|
|
71
|
+
* cap. Returns the change in bytes saved versus the plain edits: positive when
|
|
72
|
+
* the digests are smaller than what truncation would have kept, negative when
|
|
73
|
+
* a digest keeps more than head+tail did (it usually does, and that is the point).
|
|
74
|
+
*/
|
|
75
|
+
export async function digestCompactionEdits(args: DigestCompactionArgs): Promise<number> {
|
|
76
|
+
const { req, plan, tier, cfg, digester, memo, query, log } = args;
|
|
77
|
+
const max = cfg.compaction.digestMaxPerTurn;
|
|
78
|
+
if (max <= 0) return 0;
|
|
79
|
+
const pending = plan.filter((e) => e.digest === undefined).sort((a, b) => b.bytes - a.bytes);
|
|
80
|
+
let delta = 0;
|
|
81
|
+
const work: CompactionEdit[] = [];
|
|
82
|
+
for (const e of pending) {
|
|
83
|
+
const key = `${e.index}:${e.bytes}`;
|
|
84
|
+
const remembered = memo.get(key);
|
|
85
|
+
if (remembered !== undefined) {
|
|
86
|
+
delta += compactedBytes(e.bytes, e) - Buffer.byteLength(remembered);
|
|
87
|
+
e.digest = remembered;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (work.length >= max) break;
|
|
91
|
+
work.push(e);
|
|
92
|
+
}
|
|
93
|
+
if (work.length === 0) return delta;
|
|
94
|
+
|
|
95
|
+
const results = await Promise.all(
|
|
96
|
+
work.map(async (e): Promise<{ edit: CompactionEdit; result: DigestResult }> => {
|
|
97
|
+
const tool = toolOf(req, e.index);
|
|
98
|
+
const m = req.messages[e.index];
|
|
99
|
+
if (tool === null || m === undefined) return { edit: e, result: { digested: false, reason: "tool result has no tool name" } };
|
|
100
|
+
try {
|
|
101
|
+
const result = await digester.digest({
|
|
102
|
+
ompSessionId: req.ompSessionId,
|
|
103
|
+
harnessId: req.harnessId,
|
|
104
|
+
toolName: tool.name,
|
|
105
|
+
input: tool.input,
|
|
106
|
+
content: m.text,
|
|
107
|
+
query,
|
|
108
|
+
tier,
|
|
109
|
+
source: "compaction",
|
|
110
|
+
});
|
|
111
|
+
return { edit: e, result };
|
|
112
|
+
} catch (err) {
|
|
113
|
+
return { edit: e, result: { digested: false, reason: err instanceof Error ? err.message : String(err) } };
|
|
114
|
+
}
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
for (const { edit, result } of results) {
|
|
118
|
+
if (!result.digested) {
|
|
119
|
+
log.debug("compaction digest declined", { index: edit.index, bytes: edit.bytes, reason: result.reason });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const plain = compactedBytes(edit.bytes, edit);
|
|
123
|
+
edit.digest = result.text;
|
|
124
|
+
memo.set(`${edit.index}:${edit.bytes}`, result.text);
|
|
125
|
+
delta += plain - Buffer.byteLength(result.text);
|
|
126
|
+
log.info("compaction digest", { index: edit.index, bytes: edit.bytes, chars: result.outputChars, model: result.model, usd: result.usd });
|
|
127
|
+
}
|
|
128
|
+
return delta;
|
|
129
|
+
}
|
package/src/server/digest.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type { DigestConfig, RouterConfig } from "../config/types.ts";
|
|
|
23
23
|
import { computeCost, forecast } from "../cost/forecast.ts";
|
|
24
24
|
import type { Ledger, LedgerEntry } from "../cost/types.ts";
|
|
25
25
|
import { buildCandidates } from "../router/candidates.ts";
|
|
26
|
+
import { primaryArg } from "../router/compaction.ts";
|
|
26
27
|
import { extractFeatures } from "../router/features.ts";
|
|
27
28
|
import { TIER_ORDER, type Tier } from "../router/types.ts";
|
|
28
29
|
import { estimateTokens } from "../tokens/estimate.ts";
|
|
@@ -39,6 +40,14 @@ export interface DigestRequest {
|
|
|
39
40
|
content: string;
|
|
40
41
|
/** The user's current ask, so the digest keeps what matters for it. */
|
|
41
42
|
query: string;
|
|
43
|
+
/** The tier to judge `digest.fromTier` against; default: the session's last routed tier. */
|
|
44
|
+
tier?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Who asked. `tool_result` (default) is the omp extension and is gated on
|
|
47
|
+
* `digest.enabled`; `compaction` is summarising compaction inside a turn
|
|
48
|
+
* and is gated on `compaction.digestToolResults` instead.
|
|
49
|
+
*/
|
|
50
|
+
source?: "tool_result" | "compaction";
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
export type DigestResult =
|
|
@@ -99,8 +108,31 @@ function syntheticRequest(req: DigestRequest, promptText: string): NormRequest {
|
|
|
99
108
|
};
|
|
100
109
|
}
|
|
101
110
|
|
|
102
|
-
|
|
111
|
+
/** A digest the agent may still go back on: same tool, same primary argument, within RERUN_WINDOW_MS. */
|
|
112
|
+
interface RecentDigest {
|
|
113
|
+
tool: string;
|
|
114
|
+
arg: string | null;
|
|
115
|
+
atMs: number;
|
|
116
|
+
ledgerId: string;
|
|
117
|
+
rerun: boolean;
|
|
118
|
+
}
|
|
119
|
+
const RERUN_WINDOW_MS = 2 * 3_600_000;
|
|
120
|
+
const RECENT_PER_SESSION = 50;
|
|
121
|
+
|
|
122
|
+
export interface Digester {
|
|
123
|
+
digest(req: DigestRequest): Promise<DigestResult>;
|
|
124
|
+
/**
|
|
125
|
+
* Quality signal: the tool calls a session just made. One that repeats a
|
|
126
|
+
* recent digest (same tool, same primary argument) means the agent went
|
|
127
|
+
* back for the full output; that digest's ledger row is marked wasted and
|
|
128
|
+
* the report shows the re-run rate. Returns how many were marked.
|
|
129
|
+
*/
|
|
130
|
+
noteToolCalls(ompSessionId: string, calls: readonly { name: string; argsJson: string }[], nowMs?: number): number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function createDigester(deps: DigesterDeps): Digester {
|
|
103
134
|
const { cfg, catalog, ledger, upstream, log } = deps;
|
|
135
|
+
const recent = new Map<string, RecentDigest[]>();
|
|
104
136
|
|
|
105
137
|
/** Cheapest simple-tier model that fits the prompt, or the configured one. */
|
|
106
138
|
async function pickModel(req: NormRequest, promptTokens: number): Promise<CatalogModel | null> {
|
|
@@ -129,8 +161,10 @@ export function createDigester(deps: DigesterDeps): { digest(req: DigestRequest)
|
|
|
129
161
|
return {
|
|
130
162
|
async digest(req) {
|
|
131
163
|
const inputBytes = Buffer.byteLength(req.content);
|
|
132
|
-
const
|
|
133
|
-
const
|
|
164
|
+
const source = req.source ?? "tool_result";
|
|
165
|
+
const currentTier = req.tier ?? ledger.latestForSession?.(req.ompSessionId)?.tier ?? null;
|
|
166
|
+
const gate = source === "compaction" ? { ...cfg.digest, enabled: cfg.compaction.digestToolResults } : cfg.digest;
|
|
167
|
+
const applies = digestApplies(gate, req.toolName, inputBytes, false, currentTier);
|
|
134
168
|
if (!applies.ok) return { digested: false, reason: applies.reason };
|
|
135
169
|
|
|
136
170
|
const promptText = `Task: ${req.query === "" ? "(unknown)" : req.query}\nTool: ${req.toolName} ${JSON.stringify(req.input)}\n--- output ---\n${req.content}`;
|
|
@@ -190,7 +224,7 @@ export function createDigester(deps: DigesterDeps): { digest(req: DigestRequest)
|
|
|
190
224
|
servedSlug: model.slug,
|
|
191
225
|
tier: cfg.digest.tier,
|
|
192
226
|
classificationSource: "forced",
|
|
193
|
-
reasons: [`digest: ${req.toolName} ${inputBytes} bytes → ${text.length} chars for a ${currentTier} session`],
|
|
227
|
+
reasons: [`digest (${source}): ${req.toolName} ${inputBytes} bytes → ${text.length} chars for a ${currentTier} ${source === "compaction" ? "turn" : "session"}`],
|
|
194
228
|
features: null,
|
|
195
229
|
score: null,
|
|
196
230
|
confidence: null,
|
|
@@ -219,6 +253,11 @@ export function createDigester(deps: DigesterDeps): { digest(req: DigestRequest)
|
|
|
219
253
|
}
|
|
220
254
|
if (error !== null) return { digested: false, reason: `digest model failed: ${error}` };
|
|
221
255
|
if (text === "" || text.length >= inputBytes * 0.9) return { digested: false, reason: "digest did not shrink the output" };
|
|
256
|
+
if (req.ompSessionId !== "") {
|
|
257
|
+
const list = recent.get(req.ompSessionId) ?? [];
|
|
258
|
+
list.push({ tool: req.toolName.toLowerCase(), arg: primaryArg(JSON.stringify(req.input)), atMs: startedAt, ledgerId: entry.id, rerun: false });
|
|
259
|
+
recent.set(req.ompSessionId, list.slice(-RECENT_PER_SESSION));
|
|
260
|
+
}
|
|
222
261
|
return {
|
|
223
262
|
digested: true,
|
|
224
263
|
text: `${digestMarker(req.toolName, req.input, model.slug, inputBytes, text.length)}\n${text}`,
|
|
@@ -229,5 +268,30 @@ export function createDigester(deps: DigesterDeps): { digest(req: DigestRequest)
|
|
|
229
268
|
ms,
|
|
230
269
|
};
|
|
231
270
|
},
|
|
271
|
+
noteToolCalls(ompSessionId, calls, nowMs = Date.now()) {
|
|
272
|
+
const list = recent.get(ompSessionId);
|
|
273
|
+
if (list === undefined || list.length === 0) return 0;
|
|
274
|
+
let marked = 0;
|
|
275
|
+
for (const c of calls) {
|
|
276
|
+
const tool = c.name.toLowerCase();
|
|
277
|
+
const arg = primaryArg(c.argsJson);
|
|
278
|
+
if (arg === null) continue;
|
|
279
|
+
for (const d of list) {
|
|
280
|
+
if (d.rerun || d.tool !== tool || d.arg !== arg || nowMs - d.atMs > RERUN_WINDOW_MS) continue;
|
|
281
|
+
d.rerun = true;
|
|
282
|
+
marked++;
|
|
283
|
+
try {
|
|
284
|
+
ledger.markWasted?.(d.ledgerId);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
log.debug("digest re-run mark failed", { error: err instanceof Error ? err.message : String(err) });
|
|
287
|
+
}
|
|
288
|
+
log.info("digest re-run: the agent fetched the full output after all", { tool, arg: arg.slice(0, 80) });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const kept = list.filter((d) => nowMs - d.atMs <= RERUN_WINDOW_MS);
|
|
292
|
+
if (kept.length === 0) recent.delete(ompSessionId);
|
|
293
|
+
else recent.set(ompSessionId, kept);
|
|
294
|
+
return marked;
|
|
295
|
+
},
|
|
232
296
|
};
|
|
233
297
|
}
|
package/src/server/http.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { createSessionOverrides } from "./overrides.ts";
|
|
|
9
9
|
import { createDigester } from "./digest.ts";
|
|
10
10
|
import { TIER_ORDER, type Tier } from "../router/types.ts";
|
|
11
11
|
import { baselinePrices, buildUsageReport } from "../cost/report.ts";
|
|
12
|
+
import { buildDailySummary, createKv, markSummaryShown, summaryDue, summaryHasNews, type SummaryOllama } from "../cost/summary.ts";
|
|
12
13
|
import type { Ledger, ModelTrust } from "../cost/types.ts";
|
|
13
14
|
import { createRouter } from "../router/index.ts";
|
|
14
15
|
import { createConversationStore } from "../router/state.ts";
|
|
@@ -16,7 +17,7 @@ import { UpstreamError } from "../upstream/types.ts";
|
|
|
16
17
|
import { apiKeySource, ollamaKeySource } from "../config/load.ts";
|
|
17
18
|
import { ollamaMeter } from "../upstream/ollama-usage.ts";
|
|
18
19
|
import { routerConfigPath } from "../cli/config-cmd.ts";
|
|
19
|
-
import { watchConfig } from "../config/hot-reload.ts";
|
|
20
|
+
import { PINNED_CONFIG_PATHS, watchConfig } from "../config/hot-reload.ts";
|
|
20
21
|
import type { RouterConfig } from "../config/types.ts";
|
|
21
22
|
import { createLogger } from "../util/log.ts";
|
|
22
23
|
import { openDb } from "../util/sqlite.ts";
|
|
@@ -195,20 +196,22 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
195
196
|
const context = createBridgeFromConfig(cfg, db);
|
|
196
197
|
const overrides = createSessionOverrides();
|
|
197
198
|
const feedback = createFeedbackStore(db);
|
|
199
|
+
const kv = createKv(db);
|
|
198
200
|
const digester = createDigester({ cfg, catalog, ledger, upstream, log });
|
|
199
|
-
const turnDeps = { config: cfg, router, upstream, ledger, conversations, catalog, context, overrides, ollamaCostScale };
|
|
201
|
+
const turnDeps = { config: cfg, router, upstream, ledger, conversations, catalog, context, overrides, ollamaCostScale, digester };
|
|
200
202
|
|
|
201
203
|
// Hot reload: ranking knobs (tiers, filters, escalation, budgets, …) take
|
|
202
204
|
// effect on the next turn without a restart, because every consumer reads
|
|
203
|
-
// the shared config object at call time. Construction-captured
|
|
204
|
-
// (server socket,
|
|
205
|
-
//
|
|
206
|
-
|
|
205
|
+
// the shared config object at call time. Construction-captured settings
|
|
206
|
+
// (server socket, upstream clients, agentdox bridge, ledger file) are
|
|
207
|
+
// pinned by path (PINNED_CONFIG_PATHS); editing those still requires a
|
|
208
|
+
// restart. The blocks are deep-copied so a reload cannot mutate the pin.
|
|
209
|
+
const pinned = structuredClone(cfg);
|
|
207
210
|
const configWatcher = watchConfig(
|
|
208
211
|
routerConfigPath(),
|
|
209
212
|
cfg,
|
|
210
213
|
pinned,
|
|
211
|
-
|
|
214
|
+
PINNED_CONFIG_PATHS,
|
|
212
215
|
{
|
|
213
216
|
onReload: ({ changed }) => {
|
|
214
217
|
log.info("config reloaded", { changed: changed.join(", ") });
|
|
@@ -267,6 +270,20 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
267
270
|
}, 60_000);
|
|
268
271
|
pruneTimer.unref();
|
|
269
272
|
|
|
273
|
+
// Ledger retention: hourly, and once at boot so a lowered setting takes
|
|
274
|
+
// effect without waiting. Reads the live config, so it hot-reloads.
|
|
275
|
+
const retain = (): void => {
|
|
276
|
+
try {
|
|
277
|
+
const dropped = ledger.prune?.(cfg.ledger.retentionDays) ?? 0;
|
|
278
|
+
if (dropped > 0) log.info("pruned ledger rows past retention", { dropped, retentionDays: cfg.ledger.retentionDays });
|
|
279
|
+
} catch (err) {
|
|
280
|
+
log.warn("ledger retention prune failed", { error: err instanceof Error ? err.message : String(err) });
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
const retentionTimer = setInterval(retain, 3_600_000);
|
|
284
|
+
retentionTimer.unref();
|
|
285
|
+
setTimeout(retain, 5_000).unref();
|
|
286
|
+
|
|
270
287
|
// Periodically refetch the (key-scoped) catalog in the background so
|
|
271
288
|
// guardrail/preference changes are picked up without needing traffic and a
|
|
272
289
|
// TTL expiry. catalogRefreshMs === 0 disables this.
|
|
@@ -398,6 +415,29 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
398
415
|
const harnessId = url.searchParams.get("harness") ?? "";
|
|
399
416
|
return json(buildUsageReport(db, { windowDays, harnessId, baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)) }));
|
|
400
417
|
}
|
|
418
|
+
if (req.method === "GET" && url.pathname === "/v1/router/summary") {
|
|
419
|
+
// The last 24 hours in a few lines. `auto=1` is the session-start
|
|
420
|
+
// caller: it gets `due: false` unless report.dailySummary is on, no
|
|
421
|
+
// summary was posted for this harness in the last 20h, and there is
|
|
422
|
+
// something to say; posting is then marked so other windows skip it.
|
|
423
|
+
const harnessId = url.searchParams.get("harness") ?? "";
|
|
424
|
+
const auto = url.searchParams.get("auto") === "1";
|
|
425
|
+
if (auto && !cfg.report.dailySummary) return json({ due: false, reason: "report.dailySummary is off", summary: null });
|
|
426
|
+
if (auto && !summaryDue(kv, harnessId)) return json({ due: false, reason: "posted in the last 20h", summary: null });
|
|
427
|
+
const meter = ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd);
|
|
428
|
+
const runway = ollamaRunway(meter, ledger.providerSpendSince?.("ollama/", Date.now() - 7 * 86_400_000) ?? 0, ollamaUsage.calibration()?.factor ?? 1);
|
|
429
|
+
const ollamaSummary: SummaryOllama | null =
|
|
430
|
+
ollama === null || meter === null ? null : { plan: meter.plan ?? null, usedUsd: meter.usedUsd, creditsUsd: meter.creditsUsd, runwayDays: runway?.days ?? null };
|
|
431
|
+
const summary = buildDailySummary(db, {
|
|
432
|
+
harnessId,
|
|
433
|
+
baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)),
|
|
434
|
+
spikes: ledger.softFailureSpikes?.() ?? [],
|
|
435
|
+
ollama: ollamaSummary,
|
|
436
|
+
});
|
|
437
|
+
if (auto && !summaryHasNews(summary)) return json({ due: false, reason: "nothing to report", summary: null });
|
|
438
|
+
if (auto) markSummaryShown(kv, harnessId);
|
|
439
|
+
return json({ due: true, summary });
|
|
440
|
+
}
|
|
401
441
|
if (req.method === "GET" && url.pathname === "/v1/router/decisions") {
|
|
402
442
|
const rawLimit = url.searchParams.get("limit");
|
|
403
443
|
const parsed = rawLimit === null ? 50 : Number.parseInt(rawLimit, 10);
|
|
@@ -514,6 +554,9 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
514
554
|
runway: ollamaRunway(ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd), ledger.providerSpendSince?.("ollama/", Date.now() - 7 * 86_400_000) ?? 0, ollamaUsage.calibration()?.factor ?? 1),
|
|
515
555
|
costBias: { configured: cfg.ollama.costBias, effective: catalog.ollamaBias?.() ?? cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage },
|
|
516
556
|
},
|
|
557
|
+
// Models failing well above their own baseline in the last hour.
|
|
558
|
+
// Visibility only: nothing routes around a spike.
|
|
559
|
+
softFailures: { recentMs: 3_600_000, baselineDays: 7, spikes: ledger.softFailureSpikes?.() ?? [] },
|
|
517
560
|
catalog: snap === null
|
|
518
561
|
? null
|
|
519
562
|
: {
|
package/src/server/providers.ts
CHANGED
|
@@ -54,6 +54,7 @@ export function createProviders(cfg: RouterConfig, db: Database, log: Logger = c
|
|
|
54
54
|
costBias: cfg.ollama.costBias,
|
|
55
55
|
biasUntilUsage: cfg.ollama.biasUntilUsage,
|
|
56
56
|
usage: ollamaUsage,
|
|
57
|
+
live: () => ({ costBias: cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage }),
|
|
57
58
|
}),
|
|
58
59
|
ollama,
|
|
59
60
|
ollamaUsage,
|
package/src/server/turn.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "../router/types.ts";
|
|
29
29
|
import { UpstreamError, type Dispatch, type UpstreamClient } from "../upstream/types.ts";
|
|
30
30
|
import type { SessionOverrides } from "./overrides.ts";
|
|
31
|
+
import { digestCompactionEdits, type CompactionDigester } from "./compaction-digest.ts";
|
|
31
32
|
import { createLogger } from "../util/log.ts";
|
|
32
33
|
import type { NormRequest, ResponseSink, TurnSummary, UpstreamChunk } from "../wire/types.ts";
|
|
33
34
|
|
|
@@ -71,6 +72,8 @@ export interface TurnDeps {
|
|
|
71
72
|
overrides?: SessionOverrides;
|
|
72
73
|
/** Ledger-vs-meter calibration for Ollama's estimated costs; absent ⇒ 1. */
|
|
73
74
|
ollamaCostScale?: () => number;
|
|
75
|
+
/** Cheap-model digester for summarising compaction (`compaction.digestToolResults`). Absent ⇒ plain edits. */
|
|
76
|
+
digester?: CompactionDigester;
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
/** A dead client connection surfaces as the sink throwing mid-stream. */
|
|
@@ -111,6 +114,17 @@ export async function runTurn(
|
|
|
111
114
|
const log = createLogger(config.logLevel);
|
|
112
115
|
const state = conversations.load(req.conversationKey);
|
|
113
116
|
const turnNumber = state.turn + 1;
|
|
117
|
+
// Digest quality signal: the calls the agent just made, matched against
|
|
118
|
+
// recent digests of this session (a re-run of a digested read means the
|
|
119
|
+
// digest was not enough). The last assistant message holds this turn's calls.
|
|
120
|
+
if (deps.digester?.noteToolCalls !== undefined && req.ompSessionId !== "") {
|
|
121
|
+
for (let i = req.messages.length - 1; i >= 0; i--) {
|
|
122
|
+
const m = req.messages[i];
|
|
123
|
+
if (m === undefined || m.role !== "assistant") continue;
|
|
124
|
+
if (m.toolCalls.length > 0) deps.digester.noteToolCalls(req.ompSessionId, m.toolCalls.map((c) => ({ name: c.name, argsJson: c.argsJson })));
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
114
128
|
// Request header wins; the configured default covers harnesses that send none.
|
|
115
129
|
const doxScope = req.agentdoxScope !== "" ? req.agentdoxScope : config.context.defaultScope;
|
|
116
130
|
const doxActive = bridge.enabled && doxScope !== "";
|
|
@@ -135,6 +149,8 @@ export async function runTurn(
|
|
|
135
149
|
// A failover decision already routed inside onUpstreamError; the next
|
|
136
150
|
// loop iteration dispatches it instead of routing again.
|
|
137
151
|
let pendingDecision: Decision | null = null;
|
|
152
|
+
// Digests made this turn, by edit; a retry re-plans and must not pay twice.
|
|
153
|
+
const digestMemo = new Map<string, string>();
|
|
138
154
|
|
|
139
155
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
140
156
|
// Client disconnected before anything was dispatched: spend nothing.
|
|
@@ -160,6 +176,27 @@ export async function runTurn(
|
|
|
160
176
|
}
|
|
161
177
|
}
|
|
162
178
|
|
|
179
|
+
// Summarising compaction: new edits get a cheap-model digest before the
|
|
180
|
+
// plan is applied and persisted. Bounded per turn; a decline leaves the
|
|
181
|
+
// plain edit. Accounted in the estimate adjustment below.
|
|
182
|
+
let compactionSavedBytes = decision.compactionSavedBytes;
|
|
183
|
+
if (deps.digester !== undefined && config.compaction.digestToolResults && decision.compactionPlan.length > 0) {
|
|
184
|
+
try {
|
|
185
|
+
compactionSavedBytes += await digestCompactionEdits({
|
|
186
|
+
req,
|
|
187
|
+
plan: decision.compactionPlan,
|
|
188
|
+
tier: decision.tier,
|
|
189
|
+
cfg: config,
|
|
190
|
+
digester: deps.digester,
|
|
191
|
+
memo: digestMemo,
|
|
192
|
+
query: relevanceQuery(req),
|
|
193
|
+
log,
|
|
194
|
+
});
|
|
195
|
+
} catch (err) {
|
|
196
|
+
log.warn("compaction digest failed; dispatching plain edits", { error: err instanceof Error ? err.message : String(err) });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
163
200
|
// Resolve the shared context block. The bridge refreshes only when this
|
|
164
201
|
// turn's prefix is already cold — a model switch or a retry — so the
|
|
165
202
|
// injected bytes stay identical while the cache is worth keeping.
|
|
@@ -209,7 +246,7 @@ export async function runTurn(
|
|
|
209
246
|
// — not the raw request the estimate was taken from.
|
|
210
247
|
adjustPendingEstimate(
|
|
211
248
|
req.conversationKey,
|
|
212
|
-
req.promptBytes -
|
|
249
|
+
req.promptBytes - compactionSavedBytes + (contextBlock === undefined ? 0 : Buffer.byteLength(contextBlock)),
|
|
213
250
|
);
|
|
214
251
|
|
|
215
252
|
// Our own abort composes with the client's: escalation teardown and
|
package/src/util/sqlite.ts
CHANGED
|
@@ -141,6 +141,13 @@ CREATE TABLE IF NOT EXISTS feedback (
|
|
|
141
141
|
CREATE INDEX IF NOT EXISTS idx_feedback_created ON feedback (created_at_ms);
|
|
142
142
|
CREATE INDEX IF NOT EXISTS idx_feedback_ledger ON feedback (ledger_id);
|
|
143
143
|
|
|
144
|
+
-- Small durable markers (e.g. when the daily summary was last posted, per harness).
|
|
145
|
+
CREATE TABLE IF NOT EXISTS router_kv (
|
|
146
|
+
key TEXT PRIMARY KEY,
|
|
147
|
+
value TEXT NOT NULL,
|
|
148
|
+
updated_at_ms INTEGER NOT NULL
|
|
149
|
+
);
|
|
150
|
+
|
|
144
151
|
CREATE TABLE IF NOT EXISTS agentdox_sessions (
|
|
145
152
|
conversation_key TEXT PRIMARY KEY,
|
|
146
153
|
scope TEXT NOT NULL,
|
|
@@ -199,6 +199,10 @@ function applyCompaction(messages: Record<string, unknown>[], edits: readonly Co
|
|
|
199
199
|
if (msg === undefined) continue;
|
|
200
200
|
const content = msg.content;
|
|
201
201
|
if (typeof content !== "string") continue;
|
|
202
|
+
if (edit.digest !== undefined) {
|
|
203
|
+
msg.content = edit.digest;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
202
206
|
if (edit.mode === "stub") {
|
|
203
207
|
msg.content = `[omp-router: ${edit.note} elided to save context; re-run the tool to restore]`;
|
|
204
208
|
continue;
|
package/src/wire/types.ts
CHANGED
|
@@ -158,6 +158,13 @@ export interface CompactionEdit {
|
|
|
158
158
|
* the edit instead of corrupting the prompt.
|
|
159
159
|
*/
|
|
160
160
|
bytes: number;
|
|
161
|
+
/**
|
|
162
|
+
* A cheap-model digest of the original content (marker line first), set
|
|
163
|
+
* by summarising compaction. When present it replaces the content outright
|
|
164
|
+
* instead of the head/tail or stub breadcrumb, and persists with the plan
|
|
165
|
+
* so the dispatched bytes stay identical turn to turn.
|
|
166
|
+
*/
|
|
167
|
+
digest?: string;
|
|
161
168
|
}
|
|
162
169
|
|
|
163
170
|
export type FinishReason = "stop" | "length" | "tool_calls" | "content_filter" | "error";
|
|
@@ -93,7 +93,7 @@ describe("planCacheBreakpoints", () => {
|
|
|
93
93
|
test("milestones follow post-compaction sizes", () => {
|
|
94
94
|
const req = loop(30);
|
|
95
95
|
const tail = req.messages.length - 1;
|
|
96
|
-
const plan = planCompaction(req.messages, BASE.compaction, req.promptBytes * 0.3, req.promptBytes);
|
|
96
|
+
const plan = planCompaction(req.messages, { ...BASE.compaction, enabled: true }, req.promptBytes * 0.3, req.promptBytes);
|
|
97
97
|
expect(plan.edits.length).toBeGreaterThan(0);
|
|
98
98
|
const options = cfg({ maxBreakpoints: 64, milestoneTokens: 4_000 });
|
|
99
99
|
const raw = planCacheBreakpoints(req, MODEL, options).filter((i) => i !== 0 && i !== tail);
|
package/test/compaction.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
3
|
import type { CompactionConfig } from "../src/config/types.ts";
|
|
4
|
-
import { planCompaction, validatePlan } from "../src/router/compaction.ts";
|
|
4
|
+
import { compactedBytes, planCompaction, validatePlan } from "../src/router/compaction.ts";
|
|
5
5
|
import { parseChatRequest } from "../src/wire/openai/request.ts";
|
|
6
6
|
import type { NormMessage } from "../src/wire/types.ts";
|
|
7
7
|
|
|
@@ -16,7 +16,7 @@ const CFG: CompactionConfig = {
|
|
|
16
16
|
keepHeadBytes: 10,
|
|
17
17
|
keepTailBytes: 10,
|
|
18
18
|
elideSupersededReads: true,
|
|
19
|
-
collapseDuplicateResults: true,
|
|
19
|
+
collapseDuplicateResults: true, digestToolResults: false, digestMaxPerTurn: 2,
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
function user(text: string): NormMessage {
|
|
@@ -78,7 +78,7 @@ describe("planCompaction", () => {
|
|
|
78
78
|
toolMsg("c2", "read", big("V2")), // different content, same path → supersedes c1
|
|
79
79
|
...PAD,
|
|
80
80
|
];
|
|
81
|
-
const { edits } = planCompaction(msgs, { ...CFG, collapseDuplicateResults: false }, 10_000, 10_000);
|
|
81
|
+
const { edits } = planCompaction(msgs, { ...CFG, collapseDuplicateResults: false, digestToolResults: false, digestMaxPerTurn: 2, }, 10_000, 10_000);
|
|
82
82
|
expect(edits.map((e) => e.index)).toEqual([2]);
|
|
83
83
|
expect(edits[0]?.mode).toBe("stub");
|
|
84
84
|
});
|
|
@@ -270,3 +270,40 @@ describe("plan byte-stability across turns", () => {
|
|
|
270
270
|
expect(next.edits.filter((e) => e.index === 2)).toEqual(carried);
|
|
271
271
|
});
|
|
272
272
|
});
|
|
273
|
+
|
|
274
|
+
describe("summarising compaction (edit.digest)", () => {
|
|
275
|
+
const bodyWith = (messages: unknown[]): Record<string, unknown> => ({ model: "auto", messages });
|
|
276
|
+
const MUT = { slug: "x/y", fallbacks: [], sessionId: "s", cacheBreakpointMessageIndices: [], reasoning: undefined, maxTokens: undefined, stripAssistantReasoning: false };
|
|
277
|
+
const DIGEST = "[digest: read output 208 bytes → 40 chars by cheap/model. Full output: re-run read {}]\nA: two hundred x's.";
|
|
278
|
+
|
|
279
|
+
test("a digested edit replaces the content with the digest, whatever its mode", () => {
|
|
280
|
+
const raw = [
|
|
281
|
+
{ role: "user", content: "go" },
|
|
282
|
+
{ role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "read", arguments: "{}" } }] },
|
|
283
|
+
{ role: "tool", tool_call_id: "c1", content: "HEAD" + "x".repeat(500) + "TAIL" },
|
|
284
|
+
];
|
|
285
|
+
const req = parseChatRequest(bodyWith(raw), new Headers());
|
|
286
|
+
for (const mode of ["truncate", "stub"] as const) {
|
|
287
|
+
const out = req.renderUpstreamBody({ ...MUT, compactionPlan: [{ index: 2, mode, keepHead: 4, keepTail: 4, note: "large read result", bytes: 508, digest: DIGEST }] });
|
|
288
|
+
expect((out.messages as { content: string }[])[2]?.content).toBe(DIGEST);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("compactedBytes sizes a digested edit by its digest, never above the original", () => {
|
|
293
|
+
const plain = { index: 2, mode: "truncate" as const, keepHead: 10, keepTail: 10, note: "n", bytes: 5_000 };
|
|
294
|
+
expect(compactedBytes(5_000, { ...plain, digest: DIGEST })).toBe(Buffer.byteLength(DIGEST));
|
|
295
|
+
expect(compactedBytes(5_000, { ...plain, digest: "y".repeat(9_000) })).toBe(5_000);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("the digest survives validation and a re-plan, so the bytes stay stable", () => {
|
|
299
|
+
const msgs = [user("go"), asst("c1", "read", '{"path":"a.ts"}'), toolMsg("c1", "read", big("A")), ...PAD];
|
|
300
|
+
const { edits } = planCompaction(msgs, CFG, 1, 10_000);
|
|
301
|
+
const digested = edits.map((e) => ({ ...e, digest: DIGEST }));
|
|
302
|
+
expect(validatePlan(digested, msgs)[0]?.digest).toBe(DIGEST);
|
|
303
|
+
const replanned = planCompaction(msgs, CFG, 1, 10_000, validatePlan(digested, msgs));
|
|
304
|
+
expect(replanned.edits).toHaveLength(1);
|
|
305
|
+
expect(replanned.edits[0]?.digest).toBe(DIGEST);
|
|
306
|
+
// Savings count the digest's size, not the head+tail the plain edit would have kept.
|
|
307
|
+
expect(replanned.savedBytes).toBe(Buffer.byteLength(big("A")) - Buffer.byteLength(DIGEST));
|
|
308
|
+
});
|
|
309
|
+
});
|