auto-model-router 0.15.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 +24 -1
- 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/server/advise.ts +2 -0
- package/src/server/digest.ts +2 -0
- 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/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
|
@@ -1519,6 +1519,28 @@ them, injecting one project's context into another's work. A single configured t
|
|
|
1519
1519
|
grants only the scopes it was minted for; for any other project the bridge degrades to inert
|
|
1520
1520
|
rather than writing somewhere wrong.
|
|
1521
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
|
+
|
|
1522
1544
|
### It does not cost you a cache miss per turn
|
|
1523
1545
|
|
|
1524
1546
|
The context block sits at the front of the prompt, so re-fetching it every turn
|
|
@@ -1549,7 +1571,8 @@ block lands inside the prefix `planCacheBreakpoints` already marks.
|
|
|
1549
1571
|
### Turns are recorded back, attributed to the model that served them
|
|
1550
1572
|
|
|
1551
1573
|
With `context.recordTurns` (default on), each settled turn is written to an
|
|
1552
|
-
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
|
|
1553
1576
|
shows which model produced which turn. Those messages feed back into the next
|
|
1554
1577
|
`context_assemble`, so the model you switch *to* inherits what the model you
|
|
1555
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/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/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/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,
|