auto-model-router 0.14.0 → 0.16.0
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 +30 -3
- package/package.json +1 -1
- package/src/cli/config-wizard.ts +1 -0
- package/src/config/defaults.ts +4 -0
- package/src/config/schema.ts +1 -0
- package/src/config/types.ts +9 -0
- package/src/context/agentdox.ts +25 -4
- package/src/context/bridge.ts +34 -9
- package/src/context/index.ts +1 -0
- package/src/context/types.ts +20 -0
- package/src/cost/ledger.ts +2 -2
- package/src/cost/views.ts +64 -0
- package/src/lib.ts +1 -1
- package/src/server/advise.ts +2 -0
- package/src/server/digest.ts +2 -0
- package/src/server/http.ts +16 -5
- package/src/server/turn.ts +6 -0
- package/src/wire/openai/request.ts +8 -0
- package/src/wire/types.ts +13 -0
- package/test/anthropic-wire.test.ts +16 -0
- package/test/context-bridge.test.ts +148 -3
- package/test/escalate.test.ts +2 -0
- package/test/failover.test.ts +3 -1
- package/test/scope.test.ts +20 -0
- package/test/turn.test.ts +39 -2
- package/test/views.test.ts +31 -0
- package/tools/agentdox-e2e.ts +6 -0
- package/tools/replay.ts +2 -0
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.16.0",
|
|
11
11
|
"pluginRoot": "."
|
|
12
12
|
},
|
|
13
13
|
"plugins": [
|
|
14
14
|
{
|
|
15
15
|
"name": "auto-model-router",
|
|
16
16
|
"description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "0.16.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -710,8 +710,12 @@ escalation signal, error. Three views aggregate it, all from the same
|
|
|
710
710
|
and model (dispatches, tokens, spend, escalations, errors) as CSV. Also
|
|
711
711
|
`GET /v1/router/export?days=&harness=[&format=json]`; `GET /v1/router/spend?sinceMs=&harness=`
|
|
712
712
|
gives spend over a harness set since an instant, and `GET /v1/router/feedback?days=&harness=`
|
|
713
|
-
lists verdicts by model and the recent ones with the harness that gave them
|
|
714
|
-
|
|
713
|
+
lists verdicts by model and the recent ones with the harness that gave them, and
|
|
714
|
+
`GET /v1/router/decisions?harness=&days=|since=&slug=&tier=&limit=` is the decision trail
|
|
715
|
+
itself, newest first, each turn with its reasons, the classifier's view, forecast against
|
|
716
|
+
bill, escalation signal and verdicts (`?session=` narrows to one omp session, as `/router
|
|
717
|
+
why` does). These are what a front door such as the team edition reads instead of the
|
|
718
|
+
ledger file.
|
|
715
719
|
- `GET /v1/router/report?days=7&harness=<id>` for dashboards (`harness` may be
|
|
716
720
|
a comma-separated set of ids, for a group).
|
|
717
721
|
- `GET /v1/router/summary?harness=<id>` — the daily summary as JSON (`auto=1`
|
|
@@ -1515,6 +1519,28 @@ them, injecting one project's context into another's work. A single configured t
|
|
|
1515
1519
|
grants only the scopes it was minted for; for any other project the bridge degrades to inert
|
|
1516
1520
|
rather than writing somewhere wrong.
|
|
1517
1521
|
|
|
1522
|
+
### Layers a team names around the project scope
|
|
1523
|
+
|
|
1524
|
+
A front door such as the team edition can wrap the project scope in two more
|
|
1525
|
+
layers, named per request in headers next to `X-Agentdox-Scope`:
|
|
1526
|
+
|
|
1527
|
+
| Header | What agentdox renders |
|
|
1528
|
+
| --- | --- |
|
|
1529
|
+
| `X-Agentdox-Group` | the group's context — its brief and top memory — **first**, as `# Group context: <group>` |
|
|
1530
|
+
| `X-Agentdox-Personal` | the member's own thread in the project — the entry tagged `handoff` whole, then the rest — **last**, as `# Your thread in <scope>` |
|
|
1531
|
+
|
|
1532
|
+
Both go through the same slug rule as the scope (absent or invalid ⇒ empty ⇒ not
|
|
1533
|
+
sent). The member is the harness id (`X-Omp-Harness`, which the team sets from the
|
|
1534
|
+
authenticated key): when either layer is named it is sent as `user` too, and agentdox
|
|
1535
|
+
keeps the project layer's recent tail to that member's own turns. It is never sent on
|
|
1536
|
+
its own — a router without a team still has a harness id (Claude Code's is derived,
|
|
1537
|
+
omp's is configured) and must not have its tail filtered to one harness. Every
|
|
1538
|
+
recorded message carries a `user:<harness id>` ref alongside `model:` and `tier:`
|
|
1539
|
+
whenever there is a harness id, team or not; it is just another ref. A router without
|
|
1540
|
+
a team posts exactly what it always did, so its block is byte-identical; an older
|
|
1541
|
+
agentdox ignores the keys it does not know. `context.layers: false` is the kill
|
|
1542
|
+
switch — the headers are still parsed but nothing new goes to agentdox.
|
|
1543
|
+
|
|
1518
1544
|
### It does not cost you a cache miss per turn
|
|
1519
1545
|
|
|
1520
1546
|
The context block sits at the front of the prompt, so re-fetching it every turn
|
|
@@ -1545,7 +1571,8 @@ block lands inside the prefix `planCacheBreakpoints` already marks.
|
|
|
1545
1571
|
### Turns are recorded back, attributed to the model that served them
|
|
1546
1572
|
|
|
1547
1573
|
With `context.recordTurns` (default on), each settled turn is written to an
|
|
1548
|
-
agentdox session tagged `model:<slug>` and `tier:<tier>`
|
|
1574
|
+
agentdox session tagged `model:<slug>` and `tier:<tier>` (plus `user:<harness id>`
|
|
1575
|
+
when the request carried one) — a transcript that
|
|
1549
1576
|
shows which model produced which turn. Those messages feed back into the next
|
|
1550
1577
|
`context_assemble`, so the model you switch *to* inherits what the model you
|
|
1551
1578
|
switched *from* actually did.
|
package/package.json
CHANGED
package/src/cli/config-wizard.ts
CHANGED
|
@@ -300,6 +300,7 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
|
|
|
300
300
|
{ path: "context.docsLimit", label: "Doc items", kind: "number", min: 0 },
|
|
301
301
|
{ path: "context.sessionLimit", label: "Session items", kind: "number", min: 0 },
|
|
302
302
|
{ path: "context.briefChars", label: "Brief chars", kind: "number", min: 0 },
|
|
303
|
+
{ path: "context.layers", label: "Send team context layers", kind: "boolean" },
|
|
303
304
|
{ path: "context.recordTurns", label: "Record turns back", kind: "boolean" },
|
|
304
305
|
{ path: "context.injectWithoutTools", label: "Inject into tool-less calls", kind: "boolean" },
|
|
305
306
|
{ path: "context.maxQueue", label: "Write-back queue", kind: "number", min: 1 },
|
package/src/config/defaults.ts
CHANGED
|
@@ -265,6 +265,10 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
265
265
|
// statics + all 14 decisions) inside a 24k block while leaving ~15k for
|
|
266
266
|
// the query-relevant memory/docs tail. 0 omits the brief.
|
|
267
267
|
briefChars: 12_000,
|
|
268
|
+
// Group / personal / user layers named by a team front door. On, because
|
|
269
|
+
// a router that is not behind one has no layer names to send and posts
|
|
270
|
+
// exactly what it did before; off only sends none when a team does.
|
|
271
|
+
layers: true,
|
|
268
272
|
recordTurns: true,
|
|
269
273
|
// A request with no tool schemas is a harness utility call — omp asks for
|
|
270
274
|
// a session title or a complexity rating through the same provider — and
|
package/src/config/schema.ts
CHANGED
|
@@ -228,6 +228,7 @@ const context = z.strictObject({
|
|
|
228
228
|
docsLimit: z.number().int().nonnegative().optional(),
|
|
229
229
|
sessionLimit: z.number().int().nonnegative().optional(),
|
|
230
230
|
briefChars: z.number().int().nonnegative().optional(),
|
|
231
|
+
layers: z.boolean().optional(),
|
|
231
232
|
recordTurns: z.boolean().optional(),
|
|
232
233
|
injectWithoutTools: z.boolean().optional(),
|
|
233
234
|
maxQueue: z.number().int().positive().optional(),
|
package/src/config/types.ts
CHANGED
|
@@ -767,6 +767,15 @@ export interface ContextConfig {
|
|
|
767
767
|
* query-relevant tail always fits too.
|
|
768
768
|
*/
|
|
769
769
|
briefChars: number;
|
|
770
|
+
/**
|
|
771
|
+
* Send the context layers a front door names on the request — the group
|
|
772
|
+
* scope (`X-Agentdox-Group`, rendered first), the personal scope
|
|
773
|
+
* (`X-Agentdox-Personal`, rendered last) and the member (the harness id,
|
|
774
|
+
* which filters the project's recent tail and tags recorded turns). A
|
|
775
|
+
* router without a team has none to send and its block is unchanged; this
|
|
776
|
+
* is the kill switch for a team that wants single-scope blocks back.
|
|
777
|
+
*/
|
|
778
|
+
layers: boolean;
|
|
770
779
|
/** Write settled turns back to agentdox sessions, tagged with the served model. */
|
|
771
780
|
recordTurns: boolean;
|
|
772
781
|
/**
|
package/src/context/agentdox.ts
CHANGED
|
@@ -24,13 +24,29 @@ export interface AssembleLimits {
|
|
|
24
24
|
briefChars: number;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The layers around the project scope (project memory, phase one). Each is a
|
|
29
|
+
* name the team derives; empty means "not present" and is NOT sent, so a lone
|
|
30
|
+
* router's request body — and therefore its block — is byte-identical to
|
|
31
|
+
* before. An agentdox that predates layers ignores the keys it does not know.
|
|
32
|
+
*/
|
|
33
|
+
export interface AssembleLayers {
|
|
34
|
+
/** Scope whose brief and top memory render first, as group context. */
|
|
35
|
+
group: string;
|
|
36
|
+
/** Scope whose memory (handoff note first) renders last, as the member's own thread. */
|
|
37
|
+
personal: string;
|
|
38
|
+
/** The member: the project layer's recent tail is filtered to messages tagged `user:<user>`. */
|
|
39
|
+
user: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
27
42
|
export interface AgentDoxClient {
|
|
28
43
|
/**
|
|
29
44
|
* Assembles a context slice for `scope`, biased by `query`. Falls back to
|
|
30
45
|
* the server's pre-assembled baseline when assembly is unavailable (older
|
|
31
|
-
* server, or no query-relevant content).
|
|
46
|
+
* server, or no query-relevant content). `layers` adds the group and
|
|
47
|
+
* personal scopes around it; absent or all-empty sends nothing extra.
|
|
32
48
|
*/
|
|
33
|
-
assemble(scope: string, query: string, limits: AssembleLimits): Promise<string | null>;
|
|
49
|
+
assemble(scope: string, query: string, limits: AssembleLimits, layers?: AssembleLayers): Promise<string | null>;
|
|
34
50
|
createSession(scope: string, title: string): Promise<string | null>;
|
|
35
51
|
append(sessionId: string, role: "user" | "assistant", content: string, refs: string[]): Promise<boolean>;
|
|
36
52
|
}
|
|
@@ -84,11 +100,13 @@ export function createAgentDoxClient(opts: AgentDoxClientOptions): AgentDoxClien
|
|
|
84
100
|
};
|
|
85
101
|
|
|
86
102
|
return {
|
|
87
|
-
async assemble(scope, query, limits) {
|
|
103
|
+
async assemble(scope, query, limits, layers) {
|
|
88
104
|
// camelCase: the REST endpoint ignores snake_case limit keys entirely,
|
|
89
105
|
// which silently reads as "unbounded". briefChars is sent even when 0:
|
|
90
106
|
// an older server ignores the unknown key, and 0 is the documented
|
|
91
|
-
// "no brief" value there.
|
|
107
|
+
// "no brief" value there. The layer keys are the opposite: only sent
|
|
108
|
+
// when non-empty, so a router without a team posts exactly what it
|
|
109
|
+
// always did.
|
|
92
110
|
const res = await request("POST", "/context/assemble", {
|
|
93
111
|
scope,
|
|
94
112
|
query,
|
|
@@ -96,6 +114,9 @@ export function createAgentDoxClient(opts: AgentDoxClientOptions): AgentDoxClien
|
|
|
96
114
|
docsLimit: limits.docsLimit,
|
|
97
115
|
sessionLimit: limits.sessionLimit,
|
|
98
116
|
briefChars: limits.briefChars,
|
|
117
|
+
...(layers === undefined || layers.group === "" ? {} : { group: layers.group }),
|
|
118
|
+
...(layers === undefined || layers.personal === "" ? {} : { personal: layers.personal }),
|
|
119
|
+
...(layers === undefined || layers.user === "" ? {} : { user: layers.user }),
|
|
99
120
|
});
|
|
100
121
|
if (res !== null && res.status === 200) {
|
|
101
122
|
const prompt = promptOf(res.json);
|
package/src/context/bridge.ts
CHANGED
|
@@ -40,6 +40,12 @@ export interface BridgeOptions {
|
|
|
40
40
|
sessionLimit: number;
|
|
41
41
|
/** Character budget for the project brief rendered first in the block; 0 omits it. */
|
|
42
42
|
briefChars: number;
|
|
43
|
+
/**
|
|
44
|
+
* Send the group / personal / user layers a front door names (project
|
|
45
|
+
* memory, phase one). False is the kill switch: none of the new assemble
|
|
46
|
+
* fields go out, whatever the request headers say.
|
|
47
|
+
*/
|
|
48
|
+
layers: boolean;
|
|
43
49
|
/** Record settled turns back into agentdox sessions. */
|
|
44
50
|
recordTurns: boolean;
|
|
45
51
|
/** Bound on queued write-backs; excess is dropped rather than grown unbounded. */
|
|
@@ -83,7 +89,7 @@ function appendFragment(prior: string, next: string): string {
|
|
|
83
89
|
}
|
|
84
90
|
|
|
85
91
|
export function createContextBridge(opts: BridgeOptions): ContextBridge {
|
|
86
|
-
const { client, store, log, maxStalenessMs, maxBlockChars, memoryLimit, docsLimit, sessionLimit, briefChars, recordTurns, maxQueue } = opts;
|
|
92
|
+
const { client, store, log, maxStalenessMs, maxBlockChars, memoryLimit, docsLimit, sessionLimit, briefChars, layers, recordTurns, maxQueue } = opts;
|
|
87
93
|
|
|
88
94
|
// Serialized write-back queue. Session appends for one conversation must
|
|
89
95
|
// stay ordered, and agentdox is a local service — one worker is plenty.
|
|
@@ -124,12 +130,26 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
|
|
|
124
130
|
// Recent sessions only on a conversation's first block: after that they
|
|
125
131
|
// are this conversation's own recorded turns, duplicating the prompt
|
|
126
132
|
// and changing on every refresh (measured 1.4-1.9k chars per block).
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
+
// The layers around the project scope ride along only while enabled.
|
|
134
|
+
// The block's version is a hash of its content, so a personal layer
|
|
135
|
+
// pins a different block per member and never shares one across them.
|
|
136
|
+
// `user` goes only behind a front door that named a layer: a router
|
|
137
|
+
// on its own still has a harness id (Claude Code's is derived, omp's
|
|
138
|
+
// is configured), and against a new agentdox that would filter the
|
|
139
|
+
// project's recent tail to that harness — a change the lone install
|
|
140
|
+
// never asked for.
|
|
141
|
+
const fronted = input.group !== "" || input.personal !== "";
|
|
142
|
+
const raw = await client.assemble(
|
|
143
|
+
input.scope,
|
|
144
|
+
input.query,
|
|
145
|
+
{
|
|
146
|
+
memoryLimit,
|
|
147
|
+
docsLimit,
|
|
148
|
+
sessionLimit: input.firstFetch ? sessionLimit : 0,
|
|
149
|
+
briefChars,
|
|
150
|
+
},
|
|
151
|
+
layers ? { group: input.group, personal: input.personal, user: fronted ? input.user : "" } : undefined,
|
|
152
|
+
);
|
|
133
153
|
if (raw === null) {
|
|
134
154
|
// agentdox unreachable or empty. Keep serving the pinned block if we
|
|
135
155
|
// have one: stale shared context beats none, and re-using it also
|
|
@@ -204,8 +224,13 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
|
|
|
204
224
|
// Model attribution rides on refs, which agentdox already carries
|
|
205
225
|
// per message. This is what makes the transcript newly useful:
|
|
206
226
|
// every turn shows WHICH model produced it.
|
|
207
|
-
|
|
208
|
-
|
|
227
|
+
// The member rides on refs too, on BOTH messages: agentdox filters
|
|
228
|
+
// a project's recent tail to `user:<id>`, and a tail is user and
|
|
229
|
+
// assistant turns alike. A lone router has no harness id and
|
|
230
|
+
// adds nothing.
|
|
231
|
+
const who = rec.harnessId === "" ? [] : [`user:${rec.harnessId}`];
|
|
232
|
+
const refs = [`model:${rec.slug}`, `tier:${rec.tier}`, ...who];
|
|
233
|
+
if (rec.userText !== "") await client.append(sessionId, "user", rec.userText, who);
|
|
209
234
|
if (assistantText !== "") await client.append(sessionId, "assistant", assistantText, refs);
|
|
210
235
|
})
|
|
211
236
|
.catch((err: unknown) => {
|
package/src/context/index.ts
CHANGED
package/src/context/types.ts
CHANGED
|
@@ -47,12 +47,32 @@ export interface ContextResolveInput {
|
|
|
47
47
|
* the prompt already holds.
|
|
48
48
|
*/
|
|
49
49
|
firstFetch: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Group-context scope (rendered first) from `X-Agentdox-Group`. Empty ⇒ no
|
|
52
|
+
* group layer is requested and nothing new is sent.
|
|
53
|
+
*/
|
|
54
|
+
group: string;
|
|
55
|
+
/** Personal scope (rendered last) from `X-Agentdox-Personal`. Empty ⇒ no personal layer. */
|
|
56
|
+
personal: string;
|
|
57
|
+
/**
|
|
58
|
+
* The member this turn belongs to — the harness id, which a team front door
|
|
59
|
+
* sets per user. agentdox filters the project layer's recent tail to that
|
|
60
|
+
* member's own turns. Sent only when `group` or `personal` is also named:
|
|
61
|
+
* a lone router has a harness id too and must not filter its own tail.
|
|
62
|
+
*/
|
|
63
|
+
user: string;
|
|
50
64
|
}
|
|
51
65
|
|
|
52
66
|
/** One settled turn, recorded to agentdox with the model that served it. */
|
|
53
67
|
export interface TurnRecord {
|
|
54
68
|
scope: string;
|
|
55
69
|
conversationKey: string;
|
|
70
|
+
/**
|
|
71
|
+
* The harness id, which a team front door sets per user. Non-empty ⇒ every
|
|
72
|
+
* recorded message also carries a `user:<harnessId>` ref, so the member's
|
|
73
|
+
* own turns can be told apart in a shared project session.
|
|
74
|
+
*/
|
|
75
|
+
harnessId: string;
|
|
56
76
|
/** Title used if this is the first turn and a session must be created. */
|
|
57
77
|
title: string;
|
|
58
78
|
userText: string;
|
package/src/cost/ledger.ts
CHANGED
|
@@ -65,7 +65,7 @@ const CACHE_RELIABILITY_MEMO_MS = 60_000;
|
|
|
65
65
|
const DAY_MS = 86_400_000;
|
|
66
66
|
|
|
67
67
|
// Row shapes below are fixed by our own schema in util/sqlite.ts.
|
|
68
|
-
interface LedgerRow {
|
|
68
|
+
export interface LedgerRow {
|
|
69
69
|
id: string;
|
|
70
70
|
created_at_ms: number;
|
|
71
71
|
conversation_key: string;
|
|
@@ -240,7 +240,7 @@ function toLatency(slug: string, row: LatencyRow): ModelLatency | null {
|
|
|
240
240
|
return { slug, samples: row.samples, ttftMs: row.ttft_ms, tokensPerSec };
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
function toEntry(row: LedgerRow): LedgerEntry {
|
|
243
|
+
export function toEntry(row: LedgerRow): LedgerEntry {
|
|
244
244
|
return {
|
|
245
245
|
id: row.id,
|
|
246
246
|
createdAtMs: row.created_at_ms,
|
package/src/cost/views.ts
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
import { providerOfSlug } from "./report.ts";
|
|
12
12
|
import type { Database } from "bun:sqlite";
|
|
13
|
+
import { toEntry, type LedgerRow } from "./ledger.ts";
|
|
13
14
|
import { harnessFilter } from "./report.ts";
|
|
15
|
+
import type { LedgerEntry } from "./types.ts";
|
|
14
16
|
|
|
15
17
|
/** `null` means every harness; an empty set matches nothing. */
|
|
16
18
|
export type HarnessScope = readonly string[] | null;
|
|
@@ -61,6 +63,68 @@ function scope(harness: HarnessScope, column: string): { sql: string[]; bind: Re
|
|
|
61
63
|
return { sql: f.sql.map((s) => s.replace(/^harness_id/, column)), bind: f.bind };
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
/** A ledger entry as the decision explorer shows it: the entry itself plus the verdicts given on it. */
|
|
67
|
+
export type DecisionEntry = LedgerEntry & { feedback: { verdict: "good" | "bad"; note: string; createdAtMs: number }[] };
|
|
68
|
+
|
|
69
|
+
export interface DecisionFilter {
|
|
70
|
+
/** Entries at or after this instant; 0 for everything the ledger still holds. */
|
|
71
|
+
sinceMs: number;
|
|
72
|
+
/** The harness set; null for every harness, an empty list for none. */
|
|
73
|
+
harness: HarnessScope;
|
|
74
|
+
/** At most this many, newest first; 1..1000. */
|
|
75
|
+
limit?: number;
|
|
76
|
+
/** Only turns dispatched to (or served by) this slug. */
|
|
77
|
+
slug?: string;
|
|
78
|
+
/** Only turns classified at this tier. */
|
|
79
|
+
tier?: string;
|
|
80
|
+
/** Only one omp session (`/router why`). */
|
|
81
|
+
ompSessionId?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Turns, newest first, over a harness set: what `GET /v1/router/decisions` serves and what a
|
|
86
|
+
* front door reads from the ledger file. Every field the decision trail needs is here — the
|
|
87
|
+
* reasons, the classifier's view, the cost forecast against the bill, the escalation signal —
|
|
88
|
+
* and the verdicts `/router good|bad` recorded against each turn ride along.
|
|
89
|
+
*/
|
|
90
|
+
export function decisionEntries(db: Database, filter: DecisionFilter): DecisionEntry[] {
|
|
91
|
+
const s = scope(filter.harness, "harness_id");
|
|
92
|
+
if (s === null) return [];
|
|
93
|
+
const where = ["created_at_ms >= $since", ...s.sql];
|
|
94
|
+
const bind: Record<string, string | number> = { $since: filter.sinceMs, ...s.bind };
|
|
95
|
+
if (filter.slug !== undefined && filter.slug !== "") {
|
|
96
|
+
where.push("(slug = $slug OR served_slug = $slug)");
|
|
97
|
+
bind.$slug = filter.slug;
|
|
98
|
+
}
|
|
99
|
+
if (filter.tier !== undefined && filter.tier !== "") {
|
|
100
|
+
where.push("tier = $tier");
|
|
101
|
+
bind.$tier = filter.tier;
|
|
102
|
+
}
|
|
103
|
+
if (filter.ompSessionId !== undefined && filter.ompSessionId !== "") {
|
|
104
|
+
where.push("omp_session_id = $session");
|
|
105
|
+
bind.$session = filter.ompSessionId;
|
|
106
|
+
}
|
|
107
|
+
const limit = Math.min(Math.max(filter.limit ?? 50, 1), 1_000);
|
|
108
|
+
const rows = db.query(`SELECT * FROM ledger WHERE ${where.join(" AND ")} ORDER BY created_at_ms DESC LIMIT ${limit}`).all(bind) as LedgerRow[];
|
|
109
|
+
const entries = rows.map(toEntry);
|
|
110
|
+
if (entries.length === 0) return [];
|
|
111
|
+
// Verdicts, when the feedback table exists (it does not on a ledger no one has judged).
|
|
112
|
+
const hasFeedback = (db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'feedback'").get() as { name: string } | null) !== null;
|
|
113
|
+
const verdicts = new Map<string, DecisionEntry["feedback"]>();
|
|
114
|
+
if (hasFeedback) {
|
|
115
|
+
const ids = entries.map((e) => e.id);
|
|
116
|
+
const marks = ids.map((_, i) => `$f${i}`).join(", ");
|
|
117
|
+
const fb: Record<string, string> = {};
|
|
118
|
+
ids.forEach((id, i) => (fb[`$f${i}`] = id));
|
|
119
|
+
for (const r of db.query(`SELECT ledger_id, verdict, note, created_at_ms FROM feedback WHERE ledger_id IN (${marks}) ORDER BY created_at_ms ASC`).all(fb) as { ledger_id: string; verdict: string; note: string; created_at_ms: number }[]) {
|
|
120
|
+
const list = verdicts.get(r.ledger_id) ?? [];
|
|
121
|
+
list.push({ verdict: r.verdict === "good" ? "good" : "bad", note: r.note, createdAtMs: r.created_at_ms });
|
|
122
|
+
verdicts.set(r.ledger_id, list);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return entries.map((e) => ({ ...e, feedback: verdicts.get(e.id) ?? [] }));
|
|
126
|
+
}
|
|
127
|
+
|
|
64
128
|
/** Spend (reported where present, predicted otherwise) since `sinceMs`, digest calls included as the ledger counts them. */
|
|
65
129
|
export function spendUsdSince(db: Database, sinceMs: number, harness: HarnessScope): number {
|
|
66
130
|
const s = scope(harness, "harness_id");
|
package/src/lib.ts
CHANGED
|
@@ -22,7 +22,7 @@ export type { DeepPartial } from "./config/load.ts";
|
|
|
22
22
|
export { buildUsageReport, renderUsageReport, type UsageReport, type ReportTotals } from "./cost/report.ts";
|
|
23
23
|
export { buildDailySummary, renderDailySummary, type DailySummary } from "./cost/summary.ts";
|
|
24
24
|
export { openDb } from "./util/sqlite.ts";
|
|
25
|
-
export { spendUsdSince, feedbackView, exportRows, exportCsv, harnessScopeParam, type HarnessScope, type ExportRow, type FeedbackRow, type FeedbackByModel, type FeedbackView } from "./cost/views.ts";
|
|
25
|
+
export { spendUsdSince, feedbackView, exportRows, exportCsv, decisionEntries, harnessScopeParam, type HarnessScope, type ExportRow, type FeedbackRow, type FeedbackByModel, type FeedbackView, type DecisionEntry, type DecisionFilter } from "./cost/views.ts";
|
|
26
26
|
export { createLedger } from "./cost/ledger.ts";
|
|
27
27
|
export { createFeedbackStore, type FeedbackStore, type FeedbackRecord } from "./cost/feedback.ts";
|
|
28
28
|
export { buildExecutable, collectPackageFiles, executableFileName, hostTarget, isExecutableTarget, EXECUTABLE_TARGETS, type ExecutableTarget, type BuildExecutableResult } from "./cli/build-executable.ts";
|
package/src/server/advise.ts
CHANGED
package/src/server/digest.ts
CHANGED
|
@@ -99,6 +99,8 @@ function syntheticRequest(req: DigestRequest, promptText: string): NormRequest {
|
|
|
99
99
|
harnessId: req.harnessId,
|
|
100
100
|
ompSessionId: req.ompSessionId,
|
|
101
101
|
agentdoxScope: "",
|
|
102
|
+
agentdoxGroup: "",
|
|
103
|
+
agentdoxPersonal: "",
|
|
102
104
|
isSubagent: true,
|
|
103
105
|
requestedModel: "digest",
|
|
104
106
|
messages: [
|
package/src/server/http.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { createDigester } from "./digest.ts";
|
|
|
10
10
|
import { advise } from "./advise.ts";
|
|
11
11
|
import { TIER_ORDER, type Tier } from "../router/types.ts";
|
|
12
12
|
import { baselinePrices, buildUsageReport, renderUsageReport } from "../cost/report.ts";
|
|
13
|
-
import { exportCsv, exportRows, feedbackView, harnessScopeParam, spendUsdSince } from "../cost/views.ts";
|
|
13
|
+
import { decisionEntries, exportCsv, exportRows, feedbackView, harnessScopeParam, spendUsdSince } from "../cost/views.ts";
|
|
14
14
|
import { anthropicErrorResponse, countAnthropicTokens, createMessagesWire } from "../wire/anthropic/messages.ts";
|
|
15
15
|
import { buildDailySummary, createKv, markSummaryShown, renderDailySummary, summaryDue, summaryHasNews, type SummaryOllama } from "../cost/summary.ts";
|
|
16
16
|
import type { Ledger, ModelTrust } from "../cost/types.ts";
|
|
@@ -555,13 +555,24 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
555
555
|
return json({ due: true, summary });
|
|
556
556
|
}
|
|
557
557
|
if (req.method === "GET" && url.pathname === "/v1/router/decisions") {
|
|
558
|
+
// The decision trail, newest first. ?session=<omp session id> narrows to one
|
|
559
|
+
// session (/router why); ?harness=a,b to a harness set (a team's user or group),
|
|
560
|
+
// ?since=<ms> or ?days=N to a window, ?slug= and ?tier= to a model or a tier.
|
|
558
561
|
const rawLimit = url.searchParams.get("limit");
|
|
559
562
|
const parsed = rawLimit === null ? 50 : Number.parseInt(rawLimit, 10);
|
|
560
563
|
const limit = Number.isInteger(parsed) ? Math.min(Math.max(parsed, 1), 1_000) : 50;
|
|
561
|
-
|
|
562
|
-
const
|
|
563
|
-
const
|
|
564
|
-
|
|
564
|
+
const sinceRaw = Number.parseInt(url.searchParams.get("since") ?? "", 10);
|
|
565
|
+
const daysRaw = url.searchParams.get("days");
|
|
566
|
+
const sinceMs = Number.isFinite(sinceRaw) ? sinceRaw : daysRaw === null ? 0 : Date.now() - clampDays(daysRaw, 30) * 86_400_000;
|
|
567
|
+
const entries = decisionEntries(db, {
|
|
568
|
+
sinceMs,
|
|
569
|
+
harness: harnessScopeParam(url.searchParams.get("harness")),
|
|
570
|
+
limit,
|
|
571
|
+
slug: url.searchParams.get("slug") ?? "",
|
|
572
|
+
tier: url.searchParams.get("tier") ?? "",
|
|
573
|
+
ompSessionId: url.searchParams.get("session") ?? "",
|
|
574
|
+
});
|
|
575
|
+
return json({ entries });
|
|
565
576
|
}
|
|
566
577
|
if (url.pathname === "/v1/router/override") {
|
|
567
578
|
// Per-session pin / tier overrides from omp. GET shows, POST sets or clears.
|
package/src/server/turn.ts
CHANGED
|
@@ -225,6 +225,11 @@ export async function runTurn(
|
|
|
225
225
|
retrying: attempt > 0,
|
|
226
226
|
query: relevanceQuery(req),
|
|
227
227
|
firstFetch: state.contextVersion === null,
|
|
228
|
+
// Layer names come from the front door's headers; the member is the
|
|
229
|
+
// harness id it set. All empty on a router without a team.
|
|
230
|
+
group: req.agentdoxGroup,
|
|
231
|
+
personal: req.agentdoxPersonal,
|
|
232
|
+
user: req.harnessId,
|
|
228
233
|
});
|
|
229
234
|
if (pin !== null) {
|
|
230
235
|
contextBlock = pin.block;
|
|
@@ -673,6 +678,7 @@ export async function runTurn(
|
|
|
673
678
|
bridge.recordTurn({
|
|
674
679
|
scope: doxScope,
|
|
675
680
|
conversationKey: req.conversationKey,
|
|
681
|
+
harnessId: req.harnessId,
|
|
676
682
|
title: sessionTitle(req),
|
|
677
683
|
userText,
|
|
678
684
|
assistantText,
|
|
@@ -335,6 +335,12 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
|
|
|
335
335
|
// back to its configured default scope.
|
|
336
336
|
const agentdoxScope = acceptScope(headers.get("x-agentdox-scope"));
|
|
337
337
|
|
|
338
|
+
// Layered project memory (team edition): the group-context scope rendered
|
|
339
|
+
// first and the member's personal scope rendered last. A lone router never
|
|
340
|
+
// sees these headers; empty means "not sent", so the block is unchanged.
|
|
341
|
+
const agentdoxGroup = acceptScope(headers.get("x-agentdox-group"));
|
|
342
|
+
const agentdoxPersonal = acceptScope(headers.get("x-agentdox-personal"));
|
|
343
|
+
|
|
338
344
|
// Subagent marker from the embed extension (sessions without a UI).
|
|
339
345
|
const isSubagent = (headers.get("x-omp-subagent") ?? "").trim() === "1";
|
|
340
346
|
|
|
@@ -401,6 +407,8 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
|
|
|
401
407
|
harnessId,
|
|
402
408
|
ompSessionId,
|
|
403
409
|
agentdoxScope,
|
|
410
|
+
agentdoxGroup,
|
|
411
|
+
agentdoxPersonal,
|
|
404
412
|
isSubagent,
|
|
405
413
|
...(policy === undefined ? {} : { policy }),
|
|
406
414
|
requestedModel,
|
package/src/wire/types.ts
CHANGED
|
@@ -96,6 +96,19 @@ export interface NormRequest {
|
|
|
96
96
|
* and if that is empty too the bridge stays inert for this request.
|
|
97
97
|
*/
|
|
98
98
|
agentdoxScope: string;
|
|
99
|
+
/**
|
|
100
|
+
* agentdox group-context scope from the `X-Agentdox-Group` request header,
|
|
101
|
+
* set by a front door such as the team edition: the scope whose brief and
|
|
102
|
+
* top memory render FIRST in the block. Empty when absent or not a slug,
|
|
103
|
+
* which sends nothing, so a lone router's block is unchanged.
|
|
104
|
+
*/
|
|
105
|
+
agentdoxGroup: string;
|
|
106
|
+
/**
|
|
107
|
+
* agentdox personal scope from the `X-Agentdox-Personal` request header:
|
|
108
|
+
* one member's own thread in the project (their handoff note first), which
|
|
109
|
+
* renders LAST. Empty when absent or not a slug ⇒ no personal layer.
|
|
110
|
+
*/
|
|
111
|
+
agentdoxPersonal: string;
|
|
99
112
|
/** `X-Omp-Subagent: 1`: the caller is an omp subagent (a session without a UI). */
|
|
100
113
|
isSubagent: boolean;
|
|
101
114
|
/**
|
|
@@ -138,6 +138,22 @@ describe("parseMessagesRequest", () => {
|
|
|
138
138
|
expect(anthropicIdentityHeaders({}, new Headers({ "user-agent": "python-requests" })).get("x-omp-harness")).toBe("anthropic");
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
test("the agentdox scope and layer headers reach the request on the Anthropic path too", () => {
|
|
142
|
+
const plain = parseMessagesRequest(CLAUDE_CODE_BODY, new Headers({ "user-agent": "claude-cli/2.1.263" }));
|
|
143
|
+
expect(plain.agentdoxScope).toBe("");
|
|
144
|
+
expect(plain.agentdoxGroup).toBe("");
|
|
145
|
+
expect(plain.agentdoxPersonal).toBe("");
|
|
146
|
+
const team = parseMessagesRequest(
|
|
147
|
+
CLAUDE_CODE_BODY,
|
|
148
|
+
new Headers({ "user-agent": "claude-cli/2.1.263", "x-omp-harness": "u_ada", "x-agentdox-scope": "proj", "x-agentdox-group": "group.g1", "x-agentdox-personal": "proj.u.u_ada" }),
|
|
149
|
+
);
|
|
150
|
+
expect(team.harnessId).toBe("u_ada");
|
|
151
|
+
expect(team.agentdoxScope).toBe("proj");
|
|
152
|
+
expect(team.agentdoxGroup).toBe("group.g1");
|
|
153
|
+
expect(team.agentdoxPersonal).toBe("proj.u.u_ada");
|
|
154
|
+
expect(parseMessagesRequest(CLAUDE_CODE_BODY, new Headers({ "x-agentdox-group": "Not A Slug" })).agentdoxGroup).toBe("");
|
|
155
|
+
});
|
|
156
|
+
|
|
141
157
|
test("a request captured from Claude Code 2.1: system inside messages, JSON user_id, adaptive thinking with effort, 23 custom tools", () => {
|
|
142
158
|
const fixture = JSON.parse(readFileSync("test/fixtures/harness/claude-code.json", "utf8")) as { headers: Record<string, string>; body: Record<string, unknown> };
|
|
143
159
|
const norm = parseMessagesRequest(fixture.body, new Headers(fixture.headers));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import type
|
|
3
|
+
import { type AgentDoxClient, type AssembleLayers, type AssembleLimits, createAgentDoxClient } from "../src/context/agentdox.ts";
|
|
4
4
|
import { createContextBridge } from "../src/context/bridge.ts";
|
|
5
5
|
import { createContextStore } from "../src/context/store.ts";
|
|
6
6
|
import type { ContextResolveInput, TurnRecord } from "../src/context/types.ts";
|
|
@@ -16,19 +16,24 @@ interface FakeClient extends AgentDoxClient {
|
|
|
16
16
|
sessionsCreated: number;
|
|
17
17
|
prompt: string;
|
|
18
18
|
lastLimits: AssembleLimits | null;
|
|
19
|
+
/** The layers of the last assemble: undefined when the bridge sent none. */
|
|
20
|
+
lastLayers: AssembleLayers | undefined;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
function mkClient(prompt = "MEMORY: player digs in 3/4 top-down"): FakeClient {
|
|
22
24
|
const c: FakeClient = {
|
|
23
25
|
assembleCalls: 0,
|
|
24
26
|
lastLimits: null,
|
|
27
|
+
lastLayers: undefined,
|
|
25
28
|
appended: [],
|
|
26
29
|
sessionsCreated: 0,
|
|
27
30
|
prompt,
|
|
28
|
-
async assemble(
|
|
31
|
+
async assemble(scope, _query, limits, layers) {
|
|
29
32
|
c.assembleCalls++;
|
|
30
33
|
c.lastLimits = limits;
|
|
31
|
-
|
|
34
|
+
c.lastLayers = layers;
|
|
35
|
+
// Like the server: a personal layer renders last, so members differ.
|
|
36
|
+
return layers !== undefined && layers.personal !== "" ? `${c.prompt}\n\n# Your thread in ${scope}\n${layers.personal}` : c.prompt;
|
|
32
37
|
},
|
|
33
38
|
async createSession() {
|
|
34
39
|
c.sessionsCreated++;
|
|
@@ -56,6 +61,7 @@ function mkBridge(client: AgentDoxClient, over: Partial<BridgeOpts> = {}) {
|
|
|
56
61
|
docsLimit: 2,
|
|
57
62
|
sessionLimit: 6,
|
|
58
63
|
briefChars: 0,
|
|
64
|
+
layers: true,
|
|
59
65
|
recordTurns: true,
|
|
60
66
|
maxQueue: 64,
|
|
61
67
|
...over,
|
|
@@ -72,6 +78,9 @@ function input(over: Partial<ContextResolveInput> = {}): ContextResolveInput {
|
|
|
72
78
|
modelSwitching: false,
|
|
73
79
|
retrying: false,
|
|
74
80
|
query: "movement rules",
|
|
81
|
+
group: "",
|
|
82
|
+
personal: "",
|
|
83
|
+
user: "",
|
|
75
84
|
firstFetch: true,
|
|
76
85
|
...over,
|
|
77
86
|
};
|
|
@@ -258,6 +267,7 @@ describe("context bridge refresh policy", () => {
|
|
|
258
267
|
docsLimit: 2,
|
|
259
268
|
sessionLimit: 6,
|
|
260
269
|
briefChars: 0,
|
|
270
|
+
layers: true,
|
|
261
271
|
recordTurns: true,
|
|
262
272
|
maxQueue: 64,
|
|
263
273
|
};
|
|
@@ -282,6 +292,7 @@ describe("context bridge write-back", () => {
|
|
|
282
292
|
bridge.recordTurn({
|
|
283
293
|
scope: "ashlands",
|
|
284
294
|
conversationKey: "k1",
|
|
295
|
+
harnessId: "",
|
|
285
296
|
title: "movement fix",
|
|
286
297
|
userText: "fix movement",
|
|
287
298
|
assistantText: "done",
|
|
@@ -292,6 +303,7 @@ describe("context bridge write-back", () => {
|
|
|
292
303
|
bridge.recordTurn({
|
|
293
304
|
scope: "ashlands",
|
|
294
305
|
conversationKey: "k1",
|
|
306
|
+
harnessId: "",
|
|
295
307
|
title: "movement fix",
|
|
296
308
|
userText: "now the camera",
|
|
297
309
|
assistantText: "ok",
|
|
@@ -318,6 +330,7 @@ describe("context bridge write-back", () => {
|
|
|
318
330
|
bridge.recordTurn({
|
|
319
331
|
scope: "ashlands",
|
|
320
332
|
conversationKey: "k1",
|
|
333
|
+
harnessId: "",
|
|
321
334
|
title: "t",
|
|
322
335
|
userText: "u",
|
|
323
336
|
assistantText: "a",
|
|
@@ -337,6 +350,7 @@ describe("context bridge write-back", () => {
|
|
|
337
350
|
return {
|
|
338
351
|
scope: "ashlands",
|
|
339
352
|
conversationKey: "k1",
|
|
353
|
+
harnessId: "",
|
|
340
354
|
title: "movement fix",
|
|
341
355
|
userText: "fix movement",
|
|
342
356
|
assistantText: "",
|
|
@@ -423,6 +437,137 @@ describe("context bridge write-back", () => {
|
|
|
423
437
|
});
|
|
424
438
|
});
|
|
425
439
|
|
|
440
|
+
describe("context layers a team names (project memory, phase one)", () => {
|
|
441
|
+
// A router without a team has no group or personal scope and no harness id:
|
|
442
|
+
// the bridge must then send exactly what it did before, so a block on a
|
|
443
|
+
// lone install is byte-identical to 0.15. A team front door names the
|
|
444
|
+
// layers in headers and the member in the harness id.
|
|
445
|
+
test("resolve passes the group, personal and user layers to assemble; a lone router passes empties", async () => {
|
|
446
|
+
const client = mkClient();
|
|
447
|
+
const { bridge, db } = mkBridge(client);
|
|
448
|
+
try {
|
|
449
|
+
await bridge.resolve(input({ group: "group.g1", personal: "ashlands.u.u1", user: "u1" }));
|
|
450
|
+
expect(client.lastLayers).toEqual({ group: "group.g1", personal: "ashlands.u.u1", user: "u1" });
|
|
451
|
+
await bridge.resolve(input({ conversationKey: "k2" }));
|
|
452
|
+
expect(client.lastLayers).toEqual({ group: "", personal: "", user: "" });
|
|
453
|
+
// Either layer alone is a front door; the member goes with it.
|
|
454
|
+
await bridge.resolve(input({ conversationKey: "k3", group: "group.g1", user: "u1" }));
|
|
455
|
+
expect(client.lastLayers).toEqual({ group: "group.g1", personal: "", user: "u1" });
|
|
456
|
+
} finally {
|
|
457
|
+
db.close();
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("a router on its own sends no user, even though it has a harness id", async () => {
|
|
462
|
+
// Claude Code's wire path derives a harness id and omp sends one, so a
|
|
463
|
+
// lone router is NOT harness-less. Sending it as `user` would make a new
|
|
464
|
+
// agentdox filter the project's recent tail to that harness, dropping
|
|
465
|
+
// every pre-0.16 message and every other harness's turns.
|
|
466
|
+
const client = mkClient();
|
|
467
|
+
const { bridge, db } = mkBridge(client);
|
|
468
|
+
try {
|
|
469
|
+
await bridge.resolve(input({ user: "claude-code" }));
|
|
470
|
+
expect(client.lastLayers).toEqual({ group: "", personal: "", user: "" });
|
|
471
|
+
} finally {
|
|
472
|
+
db.close();
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("layers: false sends none, whatever the request names", async () => {
|
|
477
|
+
const client = mkClient();
|
|
478
|
+
const { bridge, db } = mkBridge(client, { layers: false });
|
|
479
|
+
try {
|
|
480
|
+
const pin = await bridge.resolve(input({ group: "group.g1", personal: "ashlands.u.u1", user: "u1" }));
|
|
481
|
+
expect(pin).not.toBeNull();
|
|
482
|
+
expect(client.assembleCalls).toBe(1);
|
|
483
|
+
expect(client.lastLayers).toBeUndefined();
|
|
484
|
+
} finally {
|
|
485
|
+
db.close();
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("a personal layer pins a different block per member, and the same block for the same member", async () => {
|
|
490
|
+
// The version is a hash of the block's content, so two members' blocks
|
|
491
|
+
// never share a version even in one scope; the shared store keys on it.
|
|
492
|
+
const client = mkClient();
|
|
493
|
+
const { bridge, db } = mkBridge(client);
|
|
494
|
+
try {
|
|
495
|
+
const ada = await bridge.resolve(input({ conversationKey: "ada", personal: "ashlands.u.ada", user: "ada" }));
|
|
496
|
+
const bob = await bridge.resolve(input({ conversationKey: "bob", personal: "ashlands.u.bob", user: "bob" }));
|
|
497
|
+
const adaAgain = await bridge.resolve(input({ conversationKey: "ada-2", personal: "ashlands.u.ada", user: "ada" }));
|
|
498
|
+
const nobody = await bridge.resolve(input({ conversationKey: "solo" }));
|
|
499
|
+
expect(ada?.block).toContain("ashlands.u.ada");
|
|
500
|
+
expect(bob?.block).toContain("ashlands.u.bob");
|
|
501
|
+
expect(ada?.version).not.toBe(bob?.version);
|
|
502
|
+
expect(adaAgain?.version).toBe(ada?.version ?? "");
|
|
503
|
+
expect(nobody?.version).not.toBe(ada?.version);
|
|
504
|
+
expect(nobody?.block).not.toContain("Your thread");
|
|
505
|
+
} finally {
|
|
506
|
+
db.close();
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("the recorded turn carries user:<harnessId> on both messages only when the harness is set", async () => {
|
|
511
|
+
const client = mkClient();
|
|
512
|
+
const { bridge, db } = mkBridge(client);
|
|
513
|
+
try {
|
|
514
|
+
const rec = {
|
|
515
|
+
scope: "ashlands",
|
|
516
|
+
title: "movement fix",
|
|
517
|
+
userText: "fix movement",
|
|
518
|
+
assistantText: "done",
|
|
519
|
+
slug: "anthropic/claude-haiku-4.5",
|
|
520
|
+
tier: "simple",
|
|
521
|
+
turnEnded: true,
|
|
522
|
+
};
|
|
523
|
+
bridge.recordTurn({ ...rec, conversationKey: "member", harnessId: "u_ada" });
|
|
524
|
+
bridge.recordTurn({ ...rec, conversationKey: "solo", harnessId: "" });
|
|
525
|
+
await bridge.flush();
|
|
526
|
+
|
|
527
|
+
const member = client.appended.filter((m) => m.sessionId === "ses_1");
|
|
528
|
+
expect(member.map((m) => m.role)).toEqual(["user", "assistant"]);
|
|
529
|
+
expect(member[0]?.refs).toEqual(["user:u_ada"]);
|
|
530
|
+
expect(member[1]?.refs).toEqual(["model:anthropic/claude-haiku-4.5", "tier:simple", "user:u_ada"]);
|
|
531
|
+
|
|
532
|
+
// No harness id: the refs are exactly what 0.15 wrote.
|
|
533
|
+
const solo = client.appended.filter((m) => m.sessionId === "ses_2");
|
|
534
|
+
expect(solo[0]?.refs).toEqual([]);
|
|
535
|
+
expect(solo[1]?.refs).toEqual(["model:anthropic/claude-haiku-4.5", "tier:simple"]);
|
|
536
|
+
} finally {
|
|
537
|
+
db.close();
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("the REST client posts the layer keys only when they are non-empty", async () => {
|
|
542
|
+
// An older agentdox ignores unknown keys, so sending them is harmless —
|
|
543
|
+
// but a lone router must post the same body as before, key for key, and
|
|
544
|
+
// a half-named layer set must not post empty strings the server would
|
|
545
|
+
// have to special-case.
|
|
546
|
+
const bodies: Record<string, unknown>[] = [];
|
|
547
|
+
const realFetch = globalThis.fetch;
|
|
548
|
+
globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
|
|
549
|
+
bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
|
|
550
|
+
return new Response(JSON.stringify({ prompt: "P" }), { status: 200, headers: { "content-type": "application/json" } });
|
|
551
|
+
}) as unknown as typeof fetch;
|
|
552
|
+
try {
|
|
553
|
+
const client = createAgentDoxClient({ baseUrl: "http://agentdox.test", token: "t", timeoutMs: 1000, log });
|
|
554
|
+
const limits = { memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0 };
|
|
555
|
+
await client.assemble("ashlands", "q", limits);
|
|
556
|
+
await client.assemble("ashlands", "q", limits, { group: "", personal: "", user: "" });
|
|
557
|
+
await client.assemble("ashlands", "q", limits, { group: "group.g1", personal: "", user: "u1" });
|
|
558
|
+
await client.assemble("ashlands", "q", limits, { group: "group.g1", personal: "ashlands.u.u1", user: "u1" });
|
|
559
|
+
|
|
560
|
+
const before = { scope: "ashlands", query: "q", ...limits };
|
|
561
|
+
expect(bodies[0]).toEqual(before);
|
|
562
|
+
expect(bodies[1]).toEqual(before);
|
|
563
|
+
expect(bodies[2]).toEqual({ ...before, group: "group.g1", user: "u1" });
|
|
564
|
+
expect(bodies[3]).toEqual({ ...before, group: "group.g1", personal: "ashlands.u.u1", user: "u1" });
|
|
565
|
+
} finally {
|
|
566
|
+
globalThis.fetch = realFetch;
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
426
571
|
describe("context injection into the wire body", () => {
|
|
427
572
|
test("appends to the last system message, leaving breakpoint indices valid", () => {
|
|
428
573
|
const body = {
|
package/test/escalate.test.ts
CHANGED
package/test/failover.test.ts
CHANGED
|
@@ -74,7 +74,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
74
74
|
hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false, switchHorizonTurns: 1, confirmUpgradesBelowConfidence: 0.6 },
|
|
75
75
|
exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
|
|
76
76
|
cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
|
|
77
|
-
context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, injectWithoutTools: false, maxQueue: 64 },
|
|
77
|
+
context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, layers: true, recordTurns: false, injectWithoutTools: false, maxQueue: 64 },
|
|
78
78
|
compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1, digestToolResults: false, digestMaxPerTurn: 2 },
|
|
79
79
|
budget: { onExceeded: "downgrade" },
|
|
80
80
|
report: { baselines: [], dailySummary: false },
|
|
@@ -96,6 +96,8 @@ function mkReq(): NormRequest {
|
|
|
96
96
|
harnessId: "",
|
|
97
97
|
ompSessionId: "",
|
|
98
98
|
agentdoxScope: "",
|
|
99
|
+
agentdoxGroup: "",
|
|
100
|
+
agentdoxPersonal: "",
|
|
99
101
|
isSubagent: false,
|
|
100
102
|
requestedModel: "auto",
|
|
101
103
|
messages: [{ role: "user", text: "hi", images: 0, textBytes: 2, toolCalls: [] }],
|
package/test/scope.test.ts
CHANGED
|
@@ -28,4 +28,24 @@ describe("the scope a request may name", () => {
|
|
|
28
28
|
expect(parse("omp-router").agentdoxScope).toBe("omp-router");
|
|
29
29
|
expect(parse(SCOPE_ENV).agentdoxScope).toBe("");
|
|
30
30
|
});
|
|
31
|
+
|
|
32
|
+
test("the wire parser reads X-Agentdox-Group and X-Agentdox-Personal by the same rule", () => {
|
|
33
|
+
// A team front door names the group-context and personal scopes; a
|
|
34
|
+
// lone router never sees the headers and must end up with empties, so
|
|
35
|
+
// nothing new is sent to agentdox.
|
|
36
|
+
const body = { model: "auto", messages: [{ role: "user", content: "hi" }] };
|
|
37
|
+
const parse = (h: Record<string, string>) => parseChatRequest(body, new Headers(h));
|
|
38
|
+
const none = parse({ "x-agentdox-scope": "omp-router" });
|
|
39
|
+
expect(none.agentdoxGroup).toBe("");
|
|
40
|
+
expect(none.agentdoxPersonal).toBe("");
|
|
41
|
+
const both = parse({ "x-agentdox-scope": "omp-router", "x-agentdox-group": " group.g1 ", "x-agentdox-personal": "omp-router.u.u_ada" });
|
|
42
|
+
expect(both.agentdoxGroup).toBe("group.g1");
|
|
43
|
+
expect(both.agentdoxPersonal).toBe("omp-router.u.u_ada");
|
|
44
|
+
// Not slugs: dropped, never passed through.
|
|
45
|
+
const bad = parse({ "x-agentdox-group": "Has Spaces", "x-agentdox-personal": SCOPE_ENV });
|
|
46
|
+
expect(bad.agentdoxGroup).toBe("");
|
|
47
|
+
expect(bad.agentdoxPersonal).toBe("");
|
|
48
|
+
// Layers without a project scope are still parsed; the turn decides what to do.
|
|
49
|
+
expect(parse({ "x-agentdox-group": "group.g1" }).agentdoxGroup).toBe("group.g1");
|
|
50
|
+
});
|
|
31
51
|
});
|
package/test/turn.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { createDisabledBridge } from "../src/context/bridge.ts";
|
|
3
|
-
import type { ContextBridge, TurnRecord } from "../src/context/types.ts";
|
|
3
|
+
import type { ContextBridge, ContextResolveInput, TurnRecord } from "../src/context/types.ts";
|
|
4
4
|
import type { CatalogModel, CatalogSource } from "../src/catalog/types.ts";
|
|
5
5
|
import type { EscalationConfig, RouterConfig } from "../src/config/types.ts";
|
|
6
6
|
import { EMPTY_USAGE, type Ledger, type LedgerEntry, type UsageCounts } from "../src/cost/types.ts";
|
|
@@ -74,7 +74,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
74
74
|
hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false, switchHorizonTurns: 1, confirmUpgradesBelowConfidence: 0.6 },
|
|
75
75
|
exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
|
|
76
76
|
cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
|
|
77
|
-
context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, injectWithoutTools: false, maxQueue: 64 },
|
|
77
|
+
context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, layers: true, recordTurns: false, injectWithoutTools: false, maxQueue: 64 },
|
|
78
78
|
compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1, digestToolResults: false, digestMaxPerTurn: 2 },
|
|
79
79
|
budget: { onExceeded: "downgrade" },
|
|
80
80
|
report: { baselines: [], dailySummary: false },
|
|
@@ -96,6 +96,8 @@ function mkReq(): NormRequest {
|
|
|
96
96
|
harnessId: "",
|
|
97
97
|
ompSessionId: "",
|
|
98
98
|
agentdoxScope: "",
|
|
99
|
+
agentdoxGroup: "",
|
|
100
|
+
agentdoxPersonal: "",
|
|
99
101
|
isSubagent: false,
|
|
100
102
|
requestedModel: "auto",
|
|
101
103
|
messages: [{ role: "user", text: "hi", images: 0, textBytes: 2, toolCalls: [] }],
|
|
@@ -722,6 +724,41 @@ describe("agentdox injection sees the shape of the turn", () => {
|
|
|
722
724
|
expect(store.load(req.conversationKey).contextVersion).toBe("v1");
|
|
723
725
|
});
|
|
724
726
|
|
|
727
|
+
test("the layer headers and the harness id reach resolve, and the harness id reaches the record", async () => {
|
|
728
|
+
// The team front door names the group and personal scopes in headers and
|
|
729
|
+
// the member in X-Omp-Harness; the turn carries all three through
|
|
730
|
+
// unchanged. A router without a team passes empties (see the other tests
|
|
731
|
+
// in this file, whose mkReq has none).
|
|
732
|
+
const { router, upstream, ledger, store, sink, errors } = oneDispatch();
|
|
733
|
+
const inputs: ContextResolveInput[] = [];
|
|
734
|
+
const records: TurnRecord[] = [];
|
|
735
|
+
const bridge: ContextBridge = {
|
|
736
|
+
enabled: true,
|
|
737
|
+
resolve: (input) => {
|
|
738
|
+
inputs.push(input);
|
|
739
|
+
return Promise.resolve(null);
|
|
740
|
+
},
|
|
741
|
+
recordTurn: (rec) => {
|
|
742
|
+
records.push(rec);
|
|
743
|
+
},
|
|
744
|
+
flush: () => Promise.resolve(),
|
|
745
|
+
pruneBlocks: () => 0,
|
|
746
|
+
close: () => {},
|
|
747
|
+
};
|
|
748
|
+
const req: NormRequest = { ...mkReq(), harnessId: "u_ada", agentdoxScope: "proj", agentdoxGroup: "group.g1", agentdoxPersonal: "proj.u.u_ada", tools: [AGENT_TOOL] };
|
|
749
|
+
|
|
750
|
+
await runTurn(req, sink, { config: mkConfig({ enabled: false }), router, upstream, ledger, conversations: store, catalog, context: bridge }, new AbortController().signal);
|
|
751
|
+
|
|
752
|
+
expect(errors).toHaveLength(0);
|
|
753
|
+
expect(inputs).toHaveLength(1);
|
|
754
|
+
expect(inputs[0]?.scope).toBe("proj");
|
|
755
|
+
expect(inputs[0]?.group).toBe("group.g1");
|
|
756
|
+
expect(inputs[0]?.personal).toBe("proj.u.u_ada");
|
|
757
|
+
expect(inputs[0]?.user).toBe("u_ada");
|
|
758
|
+
expect(records).toHaveLength(1);
|
|
759
|
+
expect(records[0]?.harnessId).toBe("u_ada");
|
|
760
|
+
});
|
|
761
|
+
|
|
725
762
|
test("context.injectWithoutTools restores injection into tool-less calls", async () => {
|
|
726
763
|
const { router, upstream, ledger, store, sink, errors } = oneDispatch();
|
|
727
764
|
const { bridge, resolves } = mkServingBridge();
|
package/test/views.test.ts
CHANGED
|
@@ -148,6 +148,12 @@ describe("view routes", () => {
|
|
|
148
148
|
expect((await fetch(`http://127.0.0.1:${handle.server.port}/v1/router/spend?sinceMs=0`)).status).toBe(401);
|
|
149
149
|
expect((await get("/v1/router/spend")).status).toBe(400);
|
|
150
150
|
expect(((await (await get(`/v1/router/spend?sinceMs=${Date.now() - DAY}&harness=u_x`)).json()) as { usd: number }).usd).toBeCloseTo(0.25, 6);
|
|
151
|
+
// The decision trail over a harness set: a team asks with its user or group ids and sees only theirs.
|
|
152
|
+
const mine = (await (await get("/v1/router/decisions?harness=u_x&days=1")).json()) as { entries: { id: string; feedback: unknown[] }[] };
|
|
153
|
+
expect(mine.entries.map((e) => e.id)).toEqual(["r1"]);
|
|
154
|
+
expect(mine.entries[0]?.feedback).toEqual([]);
|
|
155
|
+
expect((((await (await get("/v1/router/decisions?harness=u_other&days=1")).json()) as { entries: unknown[] }).entries)).toEqual([]);
|
|
156
|
+
expect((((await (await get("/v1/router/decisions?limit=1")).json()) as { entries: { id: string }[] }).entries.map((e) => e.id))).toEqual(["r1"]); // no filter: everything, as before
|
|
151
157
|
expect(((await (await get(`/v1/router/spend?sinceMs=${Date.now() - DAY}&harness=u_other`)).json()) as { usd: number }).usd).toBe(0);
|
|
152
158
|
const fb = (await (await get("/v1/router/feedback?days=7")).json()) as { days: number; byModel: unknown[]; recent: unknown[] };
|
|
153
159
|
expect(fb).toEqual({ days: 7, byModel: [], recent: [] });
|
|
@@ -159,3 +165,28 @@ describe("view routes", () => {
|
|
|
159
165
|
expect(js.rows[0]?.harnessId).toBe("u_x");
|
|
160
166
|
});
|
|
161
167
|
});
|
|
168
|
+
|
|
169
|
+
describe("decision entries", () => {
|
|
170
|
+
const db = seeded();
|
|
171
|
+
const since = NOW - DAY;
|
|
172
|
+
|
|
173
|
+
test("newest first over a harness set, with the verdicts given on each turn", () => {
|
|
174
|
+
const { decisionEntries } = require("../src/cost/views.ts") as typeof import("../src/cost/views.ts");
|
|
175
|
+
const ada = decisionEntries(db, { sinceMs: since, harness: ["u_ada"] });
|
|
176
|
+
expect(ada.map((e) => e.id)).toEqual(["l1", "l2"]); // same instant in the fixture; insertion order within it is stable
|
|
177
|
+
expect(ada.find((e) => e.id === "l1")?.feedback).toEqual([{ verdict: "good", note: "", createdAtMs: NOW - 1000 }]);
|
|
178
|
+
expect(ada.find((e) => e.id === "l2")?.escalationSignal).toBe("circular");
|
|
179
|
+
// Everyone, within the window: the 40-day-old row stays out; the digest row is a turn like any other.
|
|
180
|
+
expect(decisionEntries(db, { sinceMs: since, harness: null }).map((e) => e.id).sort()).toEqual(["l1", "l2", "l3", "l4"]);
|
|
181
|
+
expect(decisionEntries(db, { sinceMs: 0, harness: null }).length).toBe(5);
|
|
182
|
+
// A model, a tier, nobody, and a cap.
|
|
183
|
+
expect(decisionEntries(db, { sinceMs: since, harness: null, slug: "ollama/glm-5.3-flash" }).map((e) => e.id).sort()).toEqual(["l3", "l4"]);
|
|
184
|
+
expect(decisionEntries(db, { sinceMs: since, harness: null, tier: "hard" })).toEqual([]);
|
|
185
|
+
expect(decisionEntries(db, { sinceMs: since, harness: [] })).toEqual([]);
|
|
186
|
+
expect(decisionEntries(db, { sinceMs: since, harness: null, limit: 1 }).length).toBe(1);
|
|
187
|
+
// The error on l3 and its note ride along, so an explorer can show why a turn went wrong.
|
|
188
|
+
const bob = decisionEntries(db, { sinceMs: since, harness: ["u_bob"], slug: "ollama/glm-5.3-flash" });
|
|
189
|
+
expect(bob.find((e) => e.id === "l3")?.error).toBe("boom");
|
|
190
|
+
expect(bob.find((e) => e.id === "l3")?.feedback[0]?.note).toBe("looped");
|
|
191
|
+
});
|
|
192
|
+
});
|
package/tools/agentdox-e2e.ts
CHANGED
|
@@ -44,6 +44,7 @@ const bridge = createContextBridge({
|
|
|
44
44
|
docsLimit: 0,
|
|
45
45
|
sessionLimit: 6,
|
|
46
46
|
briefChars: 12_000,
|
|
47
|
+
layers: true,
|
|
47
48
|
recordTurns: true,
|
|
48
49
|
maxQueue: 64,
|
|
49
50
|
});
|
|
@@ -58,6 +59,9 @@ function input(over: Partial<ContextResolveInput> = {}): ContextResolveInput {
|
|
|
58
59
|
modelSwitching: false,
|
|
59
60
|
retrying: false,
|
|
60
61
|
query: "cache prompt context injection",
|
|
62
|
+
group: "",
|
|
63
|
+
personal: "",
|
|
64
|
+
user: "",
|
|
61
65
|
firstFetch: true,
|
|
62
66
|
...over,
|
|
63
67
|
};
|
|
@@ -93,6 +97,7 @@ check(
|
|
|
93
97
|
bridge.recordTurn({
|
|
94
98
|
scope,
|
|
95
99
|
conversationKey,
|
|
100
|
+
harnessId: "",
|
|
96
101
|
title: `bridge e2e ${conversationKey}`,
|
|
97
102
|
userText: "does the bridge record which model served this turn?",
|
|
98
103
|
assistantText: "yes - refs carry model: and tier:.",
|
|
@@ -135,6 +140,7 @@ function loopDispatch(assistantText: string, turnEnded: boolean): void {
|
|
|
135
140
|
bridge.recordTurn({
|
|
136
141
|
scope,
|
|
137
142
|
conversationKey: loopKey,
|
|
143
|
+
harnessId: "",
|
|
138
144
|
title: loopTitle,
|
|
139
145
|
userText: "why did cache read fall?",
|
|
140
146
|
assistantText,
|
package/tools/replay.ts
CHANGED
|
@@ -197,6 +197,8 @@ function requestOf(row: Row, f: Features): NormRequest {
|
|
|
197
197
|
harnessId: row.harness_id,
|
|
198
198
|
ompSessionId: "",
|
|
199
199
|
agentdoxScope: "",
|
|
200
|
+
agentdoxGroup: "",
|
|
201
|
+
agentdoxPersonal: "",
|
|
200
202
|
isSubagent: false,
|
|
201
203
|
requestedModel: row.requested_model,
|
|
202
204
|
messages,
|