auto-model-router 0.4.1 → 0.4.3
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/.omp-plugin/marketplace.json +2 -2
- package/README.md +48 -2
- package/omp-extension/digest-logic.ts +53 -0
- package/omp-extension/pi-coding-agent.d.ts +14 -1
- package/omp-extension/report-logic.ts +46 -0
- package/omp-extension/router-configure.ts +55 -3
- package/omp-extension/router-digest.ts +93 -0
- package/package.json +1 -1
- package/src/cli/config-wizard.ts +22 -1
- package/src/config/defaults.ts +20 -0
- package/src/config/schema.ts +18 -1
- package/src/config/types.ts +54 -0
- package/src/cost/ledger.ts +77 -9
- package/src/cost/report.ts +24 -3
- package/src/cost/summary.ts +231 -0
- package/src/cost/types.ts +31 -3
- package/src/router/candidates.ts +1 -1
- package/src/router/classify.ts +2 -2
- package/src/router/compaction.ts +1 -0
- package/src/router/learned.ts +11 -1
- package/src/router/select.ts +5 -1
- package/src/server/compaction-digest.ts +127 -0
- package/src/server/digest.ts +243 -0
- package/src/server/http.ts +51 -1
- package/src/server/turn.ts +27 -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/compaction.test.ts +40 -3
- package/test/controls.test.ts +34 -0
- package/test/digest.test.ts +229 -0
- package/test/embed-lifecycle.test.ts +1 -1
- package/test/failover.test.ts +4 -3
- package/test/learned.test.ts +21 -1
- package/test/report-hub.test.ts +3 -0
- package/test/report-logic.test.ts +11 -1
- package/test/report.test.ts +3 -0
- package/test/select.test.ts +2 -2
- package/test/summary.test.ts +171 -0
- package/test/tokens.test.ts +44 -0
- package/test/trust-attribution.test.ts +37 -0
- package/test/turn.test.ts +69 -3
- package/tools/train-classifier.ts +75 -20
package/src/router/select.ts
CHANGED
|
@@ -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
|
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DigestCompactionArgs {
|
|
29
|
+
req: NormRequest;
|
|
30
|
+
/** The plan this turn dispatches with; edits gain `digest` in place. */
|
|
31
|
+
plan: CompactionEdit[];
|
|
32
|
+
/** The tier this turn routed to: the digest pays off only above `digest.fromTier`. */
|
|
33
|
+
tier: string;
|
|
34
|
+
cfg: RouterConfig;
|
|
35
|
+
digester: CompactionDigester;
|
|
36
|
+
/** Per-turn memo (index:bytes → digest) so a retry does not pay twice. */
|
|
37
|
+
memo: Map<string, string>;
|
|
38
|
+
/** The user's current ask, steering what the digest keeps. */
|
|
39
|
+
query: string;
|
|
40
|
+
log: Logger;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Tool name and parsed arguments for a tool-result message, via its call id. */
|
|
44
|
+
function toolOf(req: NormRequest, index: number): { name: string; input: Record<string, unknown> } | null {
|
|
45
|
+
const m = req.messages[index];
|
|
46
|
+
if (m === undefined || m.role !== "tool") return null;
|
|
47
|
+
let name = m.toolName ?? "";
|
|
48
|
+
let input: Record<string, unknown> = {};
|
|
49
|
+
if (m.toolCallId !== undefined) {
|
|
50
|
+
for (const a of req.messages) {
|
|
51
|
+
if (a.role !== "assistant") continue;
|
|
52
|
+
const tc = a.toolCalls.find((c) => c.id === m.toolCallId);
|
|
53
|
+
if (tc === undefined) continue;
|
|
54
|
+
if (name === "") name = tc.name;
|
|
55
|
+
try {
|
|
56
|
+
const parsed: unknown = JSON.parse(tc.argsJson);
|
|
57
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) input = parsed as Record<string, unknown>;
|
|
58
|
+
} catch {
|
|
59
|
+
// Unparseable args: the marker just names the tool.
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return name === "" ? null : { name, input };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Digests the plan's new (undigested) edits, largest first, up to the per-turn
|
|
69
|
+
* cap. Returns the change in bytes saved versus the plain edits: positive when
|
|
70
|
+
* the digests are smaller than what truncation would have kept, negative when
|
|
71
|
+
* a digest keeps more than head+tail did (it usually does, and that is the point).
|
|
72
|
+
*/
|
|
73
|
+
export async function digestCompactionEdits(args: DigestCompactionArgs): Promise<number> {
|
|
74
|
+
const { req, plan, tier, cfg, digester, memo, query, log } = args;
|
|
75
|
+
const max = cfg.compaction.digestMaxPerTurn;
|
|
76
|
+
if (max <= 0) return 0;
|
|
77
|
+
const pending = plan.filter((e) => e.digest === undefined).sort((a, b) => b.bytes - a.bytes);
|
|
78
|
+
let delta = 0;
|
|
79
|
+
const work: CompactionEdit[] = [];
|
|
80
|
+
for (const e of pending) {
|
|
81
|
+
const key = `${e.index}:${e.bytes}`;
|
|
82
|
+
const remembered = memo.get(key);
|
|
83
|
+
if (remembered !== undefined) {
|
|
84
|
+
delta += compactedBytes(e.bytes, e) - Buffer.byteLength(remembered);
|
|
85
|
+
e.digest = remembered;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (work.length >= max) break;
|
|
89
|
+
work.push(e);
|
|
90
|
+
}
|
|
91
|
+
if (work.length === 0) return delta;
|
|
92
|
+
|
|
93
|
+
const results = await Promise.all(
|
|
94
|
+
work.map(async (e): Promise<{ edit: CompactionEdit; result: DigestResult }> => {
|
|
95
|
+
const tool = toolOf(req, e.index);
|
|
96
|
+
const m = req.messages[e.index];
|
|
97
|
+
if (tool === null || m === undefined) return { edit: e, result: { digested: false, reason: "tool result has no tool name" } };
|
|
98
|
+
try {
|
|
99
|
+
const result = await digester.digest({
|
|
100
|
+
ompSessionId: req.ompSessionId,
|
|
101
|
+
harnessId: req.harnessId,
|
|
102
|
+
toolName: tool.name,
|
|
103
|
+
input: tool.input,
|
|
104
|
+
content: m.text,
|
|
105
|
+
query,
|
|
106
|
+
tier,
|
|
107
|
+
source: "compaction",
|
|
108
|
+
});
|
|
109
|
+
return { edit: e, result };
|
|
110
|
+
} catch (err) {
|
|
111
|
+
return { edit: e, result: { digested: false, reason: err instanceof Error ? err.message : String(err) } };
|
|
112
|
+
}
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
for (const { edit, result } of results) {
|
|
116
|
+
if (!result.digested) {
|
|
117
|
+
log.debug("compaction digest declined", { index: edit.index, bytes: edit.bytes, reason: result.reason });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const plain = compactedBytes(edit.bytes, edit);
|
|
121
|
+
edit.digest = result.text;
|
|
122
|
+
memo.set(`${edit.index}:${edit.bytes}`, result.text);
|
|
123
|
+
delta += plain - Buffer.byteLength(result.text);
|
|
124
|
+
log.info("compaction digest", { index: edit.index, bytes: edit.bytes, chars: result.outputChars, model: result.model, usd: result.usd });
|
|
125
|
+
}
|
|
126
|
+
return delta;
|
|
127
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-result digest: a cheap model condenses a large tool output before it
|
|
3
|
+
* reaches an expensive one.
|
|
4
|
+
*
|
|
5
|
+
* Prompt anatomy showed tool results are the bulk of every prompt, and a
|
|
6
|
+
* prompt is ~96% of spend. A 60KB file read on a hard-tier turn is re-read
|
|
7
|
+
* by that model on every later turn of the conversation, cached or not.
|
|
8
|
+
* When the omp extension sees a large read/grep/glob/bash result while the
|
|
9
|
+
* session's current model sits at or above `digest.fromTier`, it sends the
|
|
10
|
+
* text here; a simple-tier model rewrites it to what the task needs — exact
|
|
11
|
+
* paths, line numbers, names, errors, code that would be edited — and the
|
|
12
|
+
* digest replaces the tool result. The marker on top says how to get the
|
|
13
|
+
* full output back (re-run the tool, or read a line range), so nothing is
|
|
14
|
+
* lost, only deferred.
|
|
15
|
+
*
|
|
16
|
+
* Guarded: never on errors, never below `minBytes`, never above `maxBytes`,
|
|
17
|
+
* never past `maxCostUsd`, and every digest is a ledger row
|
|
18
|
+
* (requestedModel "digest") so the report shows what it cost and saved.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { CatalogModel, CatalogSource } from "../catalog/types.ts";
|
|
22
|
+
import type { DigestConfig, RouterConfig } from "../config/types.ts";
|
|
23
|
+
import { computeCost, forecast } from "../cost/forecast.ts";
|
|
24
|
+
import type { Ledger, LedgerEntry } from "../cost/types.ts";
|
|
25
|
+
import { buildCandidates } from "../router/candidates.ts";
|
|
26
|
+
import { extractFeatures } from "../router/features.ts";
|
|
27
|
+
import { TIER_ORDER, type Tier } from "../router/types.ts";
|
|
28
|
+
import { estimateTokens } from "../tokens/estimate.ts";
|
|
29
|
+
import type { UpstreamClient } from "../upstream/types.ts";
|
|
30
|
+
import type { Logger } from "../util/log.ts";
|
|
31
|
+
import type { NormRequest } from "../wire/types.ts";
|
|
32
|
+
|
|
33
|
+
export interface DigestRequest {
|
|
34
|
+
ompSessionId: string;
|
|
35
|
+
harnessId: string;
|
|
36
|
+
toolName: string;
|
|
37
|
+
/** The tool's arguments, echoed into the marker so the model can re-run it. */
|
|
38
|
+
input: Record<string, unknown>;
|
|
39
|
+
content: string;
|
|
40
|
+
/** The user's current ask, so the digest keeps what matters for it. */
|
|
41
|
+
query: string;
|
|
42
|
+
/** The tier to judge `digest.fromTier` against; default: the session's last routed tier. */
|
|
43
|
+
tier?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Who asked. `tool_result` (default) is the omp extension and is gated on
|
|
46
|
+
* `digest.enabled`; `compaction` is summarising compaction inside a turn
|
|
47
|
+
* and is gated on `compaction.digestToolResults` instead.
|
|
48
|
+
*/
|
|
49
|
+
source?: "tool_result" | "compaction";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type DigestResult =
|
|
53
|
+
| { digested: true; text: string; model: string; usd: number; inputBytes: number; outputChars: number; ms: number }
|
|
54
|
+
| { digested: false; reason: string };
|
|
55
|
+
|
|
56
|
+
export interface DigesterDeps {
|
|
57
|
+
cfg: RouterConfig;
|
|
58
|
+
catalog: CatalogSource;
|
|
59
|
+
ledger: Ledger;
|
|
60
|
+
upstream: UpstreamClient;
|
|
61
|
+
log: Logger;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const DIGEST_SYSTEM = `You condense tool output for a coding agent that is mid-task. Keep everything the task could need: exact file paths, line numbers, identifiers, signatures, error text, counts and values. Quote verbatim, with line numbers, any code the agent is likely to edit or reference. Drop repetition, boilerplate, generated noise and unrelated regions. Never invent content. Plain text only, no preamble. First line: one sentence saying what was omitted and roughly how much.`;
|
|
65
|
+
|
|
66
|
+
const tierIdx = (t: string): number => TIER_ORDER.indexOf(t as Tier);
|
|
67
|
+
|
|
68
|
+
/** Whether a session's current model is expensive enough for a digest to pay off. */
|
|
69
|
+
export function digestApplies(cfg: DigestConfig, toolName: string, bytes: number, isError: boolean, currentTier: string | null): { ok: true } | { ok: false; reason: string } {
|
|
70
|
+
if (!cfg.enabled) return { ok: false, reason: "digest disabled" };
|
|
71
|
+
if (isError) return { ok: false, reason: "error results are never digested" };
|
|
72
|
+
if (!cfg.tools.includes(toolName.toLowerCase())) return { ok: false, reason: `tool ${toolName} not in digest.tools` };
|
|
73
|
+
if (bytes < cfg.minBytes) return { ok: false, reason: `${bytes} bytes < minBytes ${cfg.minBytes}` };
|
|
74
|
+
if (bytes > cfg.maxBytes) return { ok: false, reason: `${bytes} bytes > maxBytes ${cfg.maxBytes}` };
|
|
75
|
+
if (currentTier === null) return { ok: false, reason: "no routed turn in this session yet" };
|
|
76
|
+
if (tierIdx(currentTier) < tierIdx(cfg.fromTier)) return { ok: false, reason: `session is on ${currentTier}, below digest.fromTier ${cfg.fromTier}` };
|
|
77
|
+
return { ok: true };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The line that replaces the raw output's head: what happened and how to undo it. */
|
|
81
|
+
export function digestMarker(toolName: string, input: Record<string, unknown>, model: string, inputBytes: number, outputChars: number): string {
|
|
82
|
+
const args = JSON.stringify(input);
|
|
83
|
+
const shownArgs = args.length > 160 ? `${args.slice(0, 159)}…` : args;
|
|
84
|
+
return `[digest: ${toolName} output ${inputBytes.toLocaleString("en-US")} bytes → ${outputChars.toLocaleString("en-US")} chars by ${model}. Full output: re-run ${toolName} ${shownArgs}${toolName === "read" ? " (offset/limit for a range)" : ""}]`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function syntheticRequest(req: DigestRequest, promptText: string): NormRequest {
|
|
88
|
+
const bytes = Buffer.byteLength(promptText);
|
|
89
|
+
return {
|
|
90
|
+
protocol: "openai-chat",
|
|
91
|
+
conversationKey: `digest:${req.ompSessionId}`,
|
|
92
|
+
harnessId: req.harnessId,
|
|
93
|
+
ompSessionId: req.ompSessionId,
|
|
94
|
+
agentdoxScope: "",
|
|
95
|
+
isSubagent: true,
|
|
96
|
+
requestedModel: "digest",
|
|
97
|
+
messages: [
|
|
98
|
+
{ role: "system", text: DIGEST_SYSTEM, images: 0, textBytes: Buffer.byteLength(DIGEST_SYSTEM), toolCalls: [] },
|
|
99
|
+
{ role: "user", text: promptText, images: 0, textBytes: bytes, toolCalls: [] },
|
|
100
|
+
],
|
|
101
|
+
tools: [],
|
|
102
|
+
forcedToolChoice: false,
|
|
103
|
+
stream: false,
|
|
104
|
+
hasImages: false,
|
|
105
|
+
promptBytes: bytes + Buffer.byteLength(DIGEST_SYSTEM),
|
|
106
|
+
renderUpstreamBody: () => ({}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function createDigester(deps: DigesterDeps): { digest(req: DigestRequest): Promise<DigestResult> } {
|
|
111
|
+
const { cfg, catalog, ledger, upstream, log } = deps;
|
|
112
|
+
|
|
113
|
+
/** Cheapest simple-tier model that fits the prompt, or the configured one. */
|
|
114
|
+
async function pickModel(req: NormRequest, promptTokens: number): Promise<CatalogModel | null> {
|
|
115
|
+
const snapshot = await catalog.get();
|
|
116
|
+
if (cfg.digest.model !== "") return snapshot.models.find((m) => m.slug === cfg.digest.model) ?? null;
|
|
117
|
+
const features = extractFeatures(req, promptTokens);
|
|
118
|
+
for (const relaxLevel of [0, 1, 2]) {
|
|
119
|
+
const built = buildCandidates({
|
|
120
|
+
req,
|
|
121
|
+
features,
|
|
122
|
+
tier: cfg.digest.tier,
|
|
123
|
+
task: "documentation",
|
|
124
|
+
snapshot,
|
|
125
|
+
ledger,
|
|
126
|
+
cfg,
|
|
127
|
+
expectedCompletionTokens: cfg.digest.maxOutputTokens,
|
|
128
|
+
warmSlug: null,
|
|
129
|
+
relaxLevel,
|
|
130
|
+
});
|
|
131
|
+
const first = built.candidates[0];
|
|
132
|
+
if (first !== undefined) return first.model;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
async digest(req) {
|
|
139
|
+
const inputBytes = Buffer.byteLength(req.content);
|
|
140
|
+
const source = req.source ?? "tool_result";
|
|
141
|
+
const currentTier = req.tier ?? ledger.latestForSession?.(req.ompSessionId)?.tier ?? null;
|
|
142
|
+
const gate = source === "compaction" ? { ...cfg.digest, enabled: cfg.compaction.digestToolResults } : cfg.digest;
|
|
143
|
+
const applies = digestApplies(gate, req.toolName, inputBytes, false, currentTier);
|
|
144
|
+
if (!applies.ok) return { digested: false, reason: applies.reason };
|
|
145
|
+
|
|
146
|
+
const promptText = `Task: ${req.query === "" ? "(unknown)" : req.query}\nTool: ${req.toolName} ${JSON.stringify(req.input)}\n--- output ---\n${req.content}`;
|
|
147
|
+
const synthetic = syntheticRequest(req, promptText);
|
|
148
|
+
const promptTokens = estimateTokens(synthetic.promptBytes, "unknown", ledger);
|
|
149
|
+
const model = await pickModel(synthetic, promptTokens);
|
|
150
|
+
if (model === null) return { digested: false, reason: "no digest model available" };
|
|
151
|
+
const est = forecast(model, { promptTokens, completionTokens: cfg.digest.maxOutputTokens, cacheHitRate: 0, images: 0 });
|
|
152
|
+
if (est.coldUsd > cfg.digest.maxCostUsd) {
|
|
153
|
+
return { digested: false, reason: `estimated $${est.coldUsd.toFixed(4)} on ${model.slug} exceeds digest.maxCostUsd $${cfg.digest.maxCostUsd}` };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const controller = new AbortController();
|
|
157
|
+
const timer = setTimeout(() => controller.abort(), cfg.digest.timeoutMs);
|
|
158
|
+
const startedAt = Date.now();
|
|
159
|
+
let text = "";
|
|
160
|
+
let costUsd: number | null = null;
|
|
161
|
+
let error: string | null = null;
|
|
162
|
+
try {
|
|
163
|
+
const out = await upstream.complete(
|
|
164
|
+
{
|
|
165
|
+
model: model.slug,
|
|
166
|
+
stream: false,
|
|
167
|
+
max_tokens: cfg.digest.maxOutputTokens,
|
|
168
|
+
temperature: 0,
|
|
169
|
+
messages: [
|
|
170
|
+
{ role: "system", content: DIGEST_SYSTEM },
|
|
171
|
+
{ role: "user", content: promptText },
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
controller.signal,
|
|
175
|
+
);
|
|
176
|
+
text = out.text.trim();
|
|
177
|
+
costUsd = out.costUsd;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
error = err instanceof Error ? err.message : String(err);
|
|
180
|
+
} finally {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
}
|
|
183
|
+
const ms = Date.now() - startedAt;
|
|
184
|
+
const completionTokens = estimateTokens(Buffer.byteLength(text), model.tokenizer, ledger);
|
|
185
|
+
const usage = { promptTokens, cachedTokens: 0, cacheWriteTokens: 0, completionTokens, reasoningTokens: 0, images: 0 };
|
|
186
|
+
const usd = costUsd ?? computeCost(model, usage).total;
|
|
187
|
+
|
|
188
|
+
// Every digest is a ledger row: the report shows its cost beside the
|
|
189
|
+
// prompt tokens it kept out of the expensive model.
|
|
190
|
+
const entry: LedgerEntry = {
|
|
191
|
+
id: crypto.randomUUID(),
|
|
192
|
+
createdAtMs: startedAt,
|
|
193
|
+
conversationKey: synthetic.conversationKey,
|
|
194
|
+
sessionId: `digest-${req.ompSessionId}`,
|
|
195
|
+
turn: 1,
|
|
196
|
+
requestedModel: "digest",
|
|
197
|
+
harnessId: req.harnessId,
|
|
198
|
+
ompSessionId: req.ompSessionId,
|
|
199
|
+
slug: model.slug,
|
|
200
|
+
servedSlug: model.slug,
|
|
201
|
+
tier: cfg.digest.tier,
|
|
202
|
+
classificationSource: "forced",
|
|
203
|
+
reasons: [`digest (${source}): ${req.toolName} ${inputBytes} bytes → ${text.length} chars for a ${currentTier} ${source === "compaction" ? "turn" : "session"}`],
|
|
204
|
+
features: null,
|
|
205
|
+
score: null,
|
|
206
|
+
confidence: null,
|
|
207
|
+
task: "documentation",
|
|
208
|
+
classifierReasons: null,
|
|
209
|
+
exploredFrom: null,
|
|
210
|
+
holdArm: null,
|
|
211
|
+
predictedUsd: est.expectedUsd,
|
|
212
|
+
reportedUsd: error === null ? usd : null,
|
|
213
|
+
usage,
|
|
214
|
+
attempt: 0,
|
|
215
|
+
escalationSignal: null,
|
|
216
|
+
latencyMs: ms,
|
|
217
|
+
ttftMs: null,
|
|
218
|
+
finishReason: error === null ? "stop" : null,
|
|
219
|
+
wasted: false,
|
|
220
|
+
upstreamGenerationId: null,
|
|
221
|
+
error,
|
|
222
|
+
promptTokensSaved: 0,
|
|
223
|
+
priceModel: model,
|
|
224
|
+
};
|
|
225
|
+
try {
|
|
226
|
+
ledger.record(entry);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
log.debug("digest ledger record failed", { error: err instanceof Error ? err.message : String(err) });
|
|
229
|
+
}
|
|
230
|
+
if (error !== null) return { digested: false, reason: `digest model failed: ${error}` };
|
|
231
|
+
if (text === "" || text.length >= inputBytes * 0.9) return { digested: false, reason: "digest did not shrink the output" };
|
|
232
|
+
return {
|
|
233
|
+
digested: true,
|
|
234
|
+
text: `${digestMarker(req.toolName, req.input, model.slug, inputBytes, text.length)}\n${text}`,
|
|
235
|
+
model: model.slug,
|
|
236
|
+
usd,
|
|
237
|
+
inputBytes,
|
|
238
|
+
outputChars: text.length,
|
|
239
|
+
ms,
|
|
240
|
+
};
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
package/src/server/http.ts
CHANGED
|
@@ -6,8 +6,10 @@ import { createBridgeFromConfig } from "../context/index.ts";
|
|
|
6
6
|
import { createFeedbackStore, type Verdict } from "../cost/feedback.ts";
|
|
7
7
|
import { createLedger } from "../cost/ledger.ts";
|
|
8
8
|
import { createSessionOverrides } from "./overrides.ts";
|
|
9
|
+
import { createDigester } from "./digest.ts";
|
|
9
10
|
import { TIER_ORDER, type Tier } from "../router/types.ts";
|
|
10
11
|
import { baselinePrices, buildUsageReport } from "../cost/report.ts";
|
|
12
|
+
import { buildDailySummary, createKv, markSummaryShown, summaryDue, summaryHasNews, type SummaryOllama } from "../cost/summary.ts";
|
|
11
13
|
import type { Ledger, ModelTrust } from "../cost/types.ts";
|
|
12
14
|
import { createRouter } from "../router/index.ts";
|
|
13
15
|
import { createConversationStore } from "../router/state.ts";
|
|
@@ -194,7 +196,9 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
194
196
|
const context = createBridgeFromConfig(cfg, db);
|
|
195
197
|
const overrides = createSessionOverrides();
|
|
196
198
|
const feedback = createFeedbackStore(db);
|
|
197
|
-
const
|
|
199
|
+
const kv = createKv(db);
|
|
200
|
+
const digester = createDigester({ cfg, catalog, ledger, upstream, log });
|
|
201
|
+
const turnDeps = { config: cfg, router, upstream, ledger, conversations, catalog, context, overrides, ollamaCostScale, digester };
|
|
198
202
|
|
|
199
203
|
// Hot reload: ranking knobs (tiers, filters, escalation, budgets, …) take
|
|
200
204
|
// effect on the next turn without a restart, because every consumer reads
|
|
@@ -396,6 +400,29 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
396
400
|
const harnessId = url.searchParams.get("harness") ?? "";
|
|
397
401
|
return json(buildUsageReport(db, { windowDays, harnessId, baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)) }));
|
|
398
402
|
}
|
|
403
|
+
if (req.method === "GET" && url.pathname === "/v1/router/summary") {
|
|
404
|
+
// The last 24 hours in a few lines. `auto=1` is the session-start
|
|
405
|
+
// caller: it gets `due: false` unless report.dailySummary is on, no
|
|
406
|
+
// summary was posted for this harness in the last 20h, and there is
|
|
407
|
+
// something to say; posting is then marked so other windows skip it.
|
|
408
|
+
const harnessId = url.searchParams.get("harness") ?? "";
|
|
409
|
+
const auto = url.searchParams.get("auto") === "1";
|
|
410
|
+
if (auto && !cfg.report.dailySummary) return json({ due: false, reason: "report.dailySummary is off", summary: null });
|
|
411
|
+
if (auto && !summaryDue(kv, harnessId)) return json({ due: false, reason: "posted in the last 20h", summary: null });
|
|
412
|
+
const meter = ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd);
|
|
413
|
+
const runway = ollamaRunway(meter, ledger.providerSpendSince?.("ollama/", Date.now() - 7 * 86_400_000) ?? 0, ollamaUsage.calibration()?.factor ?? 1);
|
|
414
|
+
const ollamaSummary: SummaryOllama | null =
|
|
415
|
+
ollama === null || meter === null ? null : { plan: meter.plan ?? null, usedUsd: meter.usedUsd, creditsUsd: meter.creditsUsd, runwayDays: runway?.days ?? null };
|
|
416
|
+
const summary = buildDailySummary(db, {
|
|
417
|
+
harnessId,
|
|
418
|
+
baselines: baselinePrices(cfg.report.baselines, (s) => catalog.find(s)),
|
|
419
|
+
spikes: ledger.softFailureSpikes?.() ?? [],
|
|
420
|
+
ollama: ollamaSummary,
|
|
421
|
+
});
|
|
422
|
+
if (auto && !summaryHasNews(summary)) return json({ due: false, reason: "nothing to report", summary: null });
|
|
423
|
+
if (auto) markSummaryShown(kv, harnessId);
|
|
424
|
+
return json({ due: true, summary });
|
|
425
|
+
}
|
|
399
426
|
if (req.method === "GET" && url.pathname === "/v1/router/decisions") {
|
|
400
427
|
const rawLimit = url.searchParams.get("limit");
|
|
401
428
|
const parsed = rawLimit === null ? 50 : Number.parseInt(rawLimit, 10);
|
|
@@ -436,6 +463,26 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
436
463
|
return json({ override: set });
|
|
437
464
|
}
|
|
438
465
|
}
|
|
466
|
+
if (req.method === "GET" && url.pathname === "/v1/router/digest/policy") {
|
|
467
|
+
const d = cfg.digest;
|
|
468
|
+
return json({ enabled: d.enabled, minBytes: d.minBytes, maxBytes: d.maxBytes, tools: d.tools, fromTier: d.fromTier });
|
|
469
|
+
}
|
|
470
|
+
if (req.method === "POST" && url.pathname === "/v1/router/digest") {
|
|
471
|
+
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
|
|
472
|
+
if (body === null || typeof body.content !== "string" || typeof body.toolName !== "string") {
|
|
473
|
+
return wireErrorResponse({ status: 400, code: "invalid_request_error", message: "toolName and content required" });
|
|
474
|
+
}
|
|
475
|
+
return json(
|
|
476
|
+
await digester.digest({
|
|
477
|
+
ompSessionId: typeof body.ompSessionId === "string" ? body.ompSessionId : "",
|
|
478
|
+
harnessId: typeof body.harnessId === "string" ? body.harnessId : "",
|
|
479
|
+
toolName: body.toolName,
|
|
480
|
+
input: typeof body.input === "object" && body.input !== null ? (body.input as Record<string, unknown>) : {},
|
|
481
|
+
content: body.content,
|
|
482
|
+
query: typeof body.query === "string" ? body.query : "",
|
|
483
|
+
}),
|
|
484
|
+
);
|
|
485
|
+
}
|
|
439
486
|
if (req.method === "POST" && url.pathname === "/v1/router/feedback") {
|
|
440
487
|
// A user verdict on the newest routed turn of an omp session.
|
|
441
488
|
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
|
|
@@ -492,6 +539,9 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
492
539
|
runway: ollamaRunway(ollamaMeter(ollamaUsage.peek(), cfg.ollama.planCreditsUsd), ledger.providerSpendSince?.("ollama/", Date.now() - 7 * 86_400_000) ?? 0, ollamaUsage.calibration()?.factor ?? 1),
|
|
493
540
|
costBias: { configured: cfg.ollama.costBias, effective: catalog.ollamaBias?.() ?? cfg.ollama.costBias, biasUntilUsage: cfg.ollama.biasUntilUsage },
|
|
494
541
|
},
|
|
542
|
+
// Models failing well above their own baseline in the last hour.
|
|
543
|
+
// Visibility only: nothing routes around a spike.
|
|
544
|
+
softFailures: { recentMs: 3_600_000, baselineDays: 7, spikes: ledger.softFailureSpikes?.() ?? [] },
|
|
495
545
|
catalog: snap === null
|
|
496
546
|
? null
|
|
497
547
|
: {
|
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. */
|
|
@@ -135,6 +138,8 @@ export async function runTurn(
|
|
|
135
138
|
// A failover decision already routed inside onUpstreamError; the next
|
|
136
139
|
// loop iteration dispatches it instead of routing again.
|
|
137
140
|
let pendingDecision: Decision | null = null;
|
|
141
|
+
// Digests made this turn, by edit; a retry re-plans and must not pay twice.
|
|
142
|
+
const digestMemo = new Map<string, string>();
|
|
138
143
|
|
|
139
144
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
140
145
|
// Client disconnected before anything was dispatched: spend nothing.
|
|
@@ -160,6 +165,27 @@ export async function runTurn(
|
|
|
160
165
|
}
|
|
161
166
|
}
|
|
162
167
|
|
|
168
|
+
// Summarising compaction: new edits get a cheap-model digest before the
|
|
169
|
+
// plan is applied and persisted. Bounded per turn; a decline leaves the
|
|
170
|
+
// plain edit. Accounted in the estimate adjustment below.
|
|
171
|
+
let compactionSavedBytes = decision.compactionSavedBytes;
|
|
172
|
+
if (deps.digester !== undefined && config.compaction.digestToolResults && decision.compactionPlan.length > 0) {
|
|
173
|
+
try {
|
|
174
|
+
compactionSavedBytes += await digestCompactionEdits({
|
|
175
|
+
req,
|
|
176
|
+
plan: decision.compactionPlan,
|
|
177
|
+
tier: decision.tier,
|
|
178
|
+
cfg: config,
|
|
179
|
+
digester: deps.digester,
|
|
180
|
+
memo: digestMemo,
|
|
181
|
+
query: relevanceQuery(req),
|
|
182
|
+
log,
|
|
183
|
+
});
|
|
184
|
+
} catch (err) {
|
|
185
|
+
log.warn("compaction digest failed; dispatching plain edits", { error: err instanceof Error ? err.message : String(err) });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
163
189
|
// Resolve the shared context block. The bridge refreshes only when this
|
|
164
190
|
// turn's prefix is already cold — a model switch or a retry — so the
|
|
165
191
|
// injected bytes stay identical while the cache is worth keeping.
|
|
@@ -209,7 +235,7 @@ export async function runTurn(
|
|
|
209
235
|
// — not the raw request the estimate was taken from.
|
|
210
236
|
adjustPendingEstimate(
|
|
211
237
|
req.conversationKey,
|
|
212
|
-
req.promptBytes -
|
|
238
|
+
req.promptBytes - compactionSavedBytes + (contextBlock === undefined ? 0 : Buffer.byteLength(contextBlock)),
|
|
213
239
|
);
|
|
214
240
|
|
|
215
241
|
// 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";
|
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
|
+
});
|