auto-model-router 0.2.13 → 0.2.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/agentdox/SKILL.md +98 -12
- package/.mcp.json +1 -1
- package/.omp-plugin/marketplace.json +2 -2
- package/CLAUDE.md +10 -7
- package/package.json +1 -1
- package/src/config/defaults.ts +9 -0
- package/src/config/schema.ts +2 -0
- package/src/config/types.ts +4 -0
- package/src/context/agentdox.ts +16 -3
- package/src/context/bridge.ts +9 -2
- package/src/context/index.ts +2 -0
- package/src/router/classify.ts +19 -11
- package/test/classify.test.ts +13 -5
- package/test/context-bridge.test.ts +25 -2
- package/test/failover.test.ts +1 -1
- package/test/turn.test.ts +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: agentdox
|
|
3
|
-
description: "Use agentdox — the shared memory, docs, and context server — the same way every session. Trigger on connect in any repo
|
|
3
|
+
description: "Use agentdox — the shared memory, docs, and context server — the same way every session. Trigger on connect in any repo — the scope comes from .env.agentdox, CLAUDE.md, or the repo folder name, and you create it if it has never been set — and whenever the user mentions agentdox, project memory, remembering/recalling facts, project docs, the project brief, decisions, or session history. Also trigger BEFORE asking the user something they may have already told you, and BEFORE finishing any task that changed architecture, conventions, or decisions."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# agentdox — the standard interaction protocol
|
|
@@ -13,16 +13,93 @@ store collapses if each session writes it differently.
|
|
|
13
13
|
|
|
14
14
|
## 0. Resolve the scope before anything else
|
|
15
15
|
|
|
16
|
-
Everything is namespaced by a **scope** = the project slug
|
|
16
|
+
Everything is namespaced by a **scope** = the project slug, and **the scope is derived from the
|
|
17
|
+
project folder you are working in — never from your credential.** Resolve it in this order and
|
|
18
|
+
stop at the first hit:
|
|
17
19
|
|
|
18
20
|
1. `AGENTDOX_SCOPE` in the repo's `.env.agentdox`
|
|
19
21
|
2. The slug named in the repo's `CLAUDE.md`
|
|
20
|
-
3.
|
|
22
|
+
3. `project_list` — an existing project whose slug matches the repo folder name
|
|
23
|
+
4. **Nothing yet → derive it from the folder name and create it** (below). Don't ask first;
|
|
24
|
+
a folder with no scope is just a project that hasn't been onboarded, and the global token
|
|
25
|
+
already covers it.
|
|
21
26
|
|
|
22
27
|
Known scopes: `ashlands` (E:/projects/ashlands/ashlands), `omp-router` (E:/projects/omp-router).
|
|
23
28
|
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
### Creating the scope for a folder that has never had one
|
|
30
|
+
|
|
31
|
+
Deterministic, so the same folder always resolves to the same slug:
|
|
32
|
+
|
|
33
|
+
1. Take the **repo root** folder name — `git rev-parse --show-toplevel`, not the cwd. A
|
|
34
|
+
subdirectory must never become its own project.
|
|
35
|
+
2. Slugify it: lowercase, every run of non-alphanumerics → a single `-`, trim leading/trailing
|
|
36
|
+
`-`. `E:/projects/My_App` → `my-app`.
|
|
37
|
+
3. `project_list` **before creating.**
|
|
38
|
+
- **Exact match** — usually this repo, already onboarded elsewhere; adopt it. But an exact
|
|
39
|
+
match reached from a folder that has never been onboarded can also be a *collision*: two
|
|
40
|
+
unrelated repos with the same folder name (`E:/projects/foo/api` and `E:/work/api`) both
|
|
41
|
+
slugify to `api`, and adopting blindly merges two projects into one namespace. Check the
|
|
42
|
+
existing project's brief/description first; if it clearly describes a different codebase,
|
|
43
|
+
stop and ask for a distinguishing slug.
|
|
44
|
+
- **Near match** (`my-app` vs `my-app-v2`) — a stop sign: ask, rather than fork a second
|
|
45
|
+
namespace for one project.
|
|
46
|
+
4. `project_ensure {slug, name}` — `name` is the readable form of the folder.
|
|
47
|
+
5. **Make `.env.agentdox` un-committable *before* writing it.** Run `git check-ignore -v
|
|
48
|
+
.env.agentdox`. If it is not ignored, **add `.env.agentdox` to `.gitignore`** (create that
|
|
49
|
+
file if there is none) — do not merely check and move on. Patterns like `.env` and
|
|
50
|
+
`.env.*.local` do **not** match `.env.agentdox`; that exact gap existed in the agentdox repo
|
|
51
|
+
itself. This file carries the global token, which is instance admin: committing it is the
|
|
52
|
+
worst outcome available here.
|
|
53
|
+
6. **Persist the scope, or the next session redoes all of this.** Write `.env.agentdox` at the
|
|
54
|
+
repo root:
|
|
55
|
+
|
|
56
|
+
```ini
|
|
57
|
+
AGENTDOX_URL=http://localhost:3003
|
|
58
|
+
AGENTDOX_SCOPE=<slug>
|
|
59
|
+
AGENTDOX_TOKEN=<the global PAT>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Take the token value from the `AGENTDOX_TOKEN` environment variable (`$env:AGENTDOX_TOKEN` on
|
|
63
|
+
Windows), or copy it from another agentdox repo's `.env.agentdox`. If neither exists, this
|
|
64
|
+
machine has no global PAT yet — stop and ask the user to mint one. Never invent a token.
|
|
65
|
+
7. Add the `.mcp.json` from §1 if the repo has none. **It does not take effect this session** —
|
|
66
|
+
MCP config is read once, at harness startup. Finish *this* session over REST, and tell the
|
|
67
|
+
user to restart the harness to get the MCP tools.
|
|
68
|
+
8. Copy the skill into the repo: `~/.claude/skills/agentdox/SKILL.md` →
|
|
69
|
+
`.claude/skills/agentdox/SKILL.md`. Claude Code finds the user-level copy anyway, but omp and
|
|
70
|
+
other harnesses only discover **project-relative** skills — without this, the protocol
|
|
71
|
+
silently stops applying there.
|
|
72
|
+
9. Add an agentdox section to the repo's `CLAUDE.md` (create the file if absent). This is
|
|
73
|
+
resolution step 2 above, and it is what makes the *next* agent, in any harness, follow the
|
|
74
|
+
protocol:
|
|
75
|
+
|
|
76
|
+
> ## agentdox — shared context/memory (**MANDATORY to keep updated**)
|
|
77
|
+
>
|
|
78
|
+
> agentdox is this repo's memory + docs + live-conversation system. The project slug is
|
|
79
|
+
> **`<slug>`** — ALWAYS scope agentdox writes to it. `.mcp.json` uses **`AGENTDOX_TOKEN`**,
|
|
80
|
+
> one global bearer token shared by every agentdox-wired repo; the scope comes from *this
|
|
81
|
+
> folder* (`AGENTDOX_SCOPE` in `.env.agentdox`, gitignored), not from the token. That token
|
|
82
|
+
> grants every scope, so a wrong slug is **not** rejected — it silently writes into another
|
|
83
|
+
> project.
|
|
84
|
+
>
|
|
85
|
+
> Keeping agentdox current is part of completing a task, not optional. Full protocol:
|
|
86
|
+
> `.claude/skills/agentdox/SKILL.md`.
|
|
87
|
+
10. Give the brief an overview. `context_brief_seed {scope}` builds one **from existing memory
|
|
88
|
+
and docs**, so on a scope you just created it returns 200 and an *empty* brief — there is
|
|
89
|
+
nothing to seed from yet. Write it directly instead:
|
|
90
|
+
`PUT /context/brief {scope, overview, repoLayout?, buildTest?, gotchas?}`. Seed later, once
|
|
91
|
+
the scope has material.
|
|
92
|
+
11. **Verify before you claim it works:** write one memory in the new scope and read it back
|
|
93
|
+
(`memory_add` → `memory_search`, or `POST /memory` → `GET /memory?category=<slug>`). A 401
|
|
94
|
+
here means `AGENTDOX_TOKEN` is missing from the environment, not that onboarding failed.
|
|
95
|
+
|
|
96
|
+
Then tell the user, briefly: the scope you created, the files you added, and that the MCP tools
|
|
97
|
+
need a harness restart. Don't make them discover that a new project appeared.
|
|
98
|
+
|
|
99
|
+
**Never write outside your scope.** The bearer token is global (see §1) and grants *every*
|
|
100
|
+
scope, so a wrong slug will **not** be rejected — it will silently succeed and file this
|
|
101
|
+
project's data under another project's namespace. Nothing catches that but you. If you cannot
|
|
102
|
+
determine the scope, ask — do not guess, and do not fall back to a default.
|
|
26
103
|
|
|
27
104
|
## 1. Pick your transport — MCP or REST
|
|
28
105
|
|
|
@@ -35,13 +112,15 @@ Both hit the same live store with the same RBAC. **Check which you have, then us
|
|
|
35
112
|
- **No such tools** — **use the REST API directly.** Never skip recording just because MCP
|
|
36
113
|
tools are absent; that is the most likely way this protocol silently stops happening.
|
|
37
114
|
|
|
38
|
-
If you expected MCP tools and don't have them, the usual cause is
|
|
115
|
+
If you expected MCP tools and don't have them, the usual cause is `AGENTDOX_TOKEN` missing
|
|
39
116
|
from the **launching shell's** environment. A Windows *User*-scope variable only reaches
|
|
40
117
|
processes started after it was set, so an already-running terminal won't have it. Either
|
|
41
118
|
restart the shell/harness or fall back to REST for this session — don't just skip the writes.
|
|
42
119
|
|
|
43
|
-
REST auth: `Authorization: Bearer <token>`, where the token is `AGENTDOX_TOKEN`
|
|
44
|
-
repo
|
|
120
|
+
REST auth: `Authorization: Bearer <token>`, where the token is `AGENTDOX_TOKEN` — **one
|
|
121
|
+
global PAT shared by every repo** (non-expiring, wildcard grants), held in the Windows User
|
|
122
|
+
environment and mirrored into each repo's `.env.agentdox`. It is deliberately *not*
|
|
123
|
+
project-scoped: a new project folder needs no new token, only its own `AGENTDOX_SCOPE`.
|
|
45
124
|
|
|
46
125
|
Cleanest REST call path — a throwaway `bun` script, which avoids PowerShell mangling `$` in
|
|
47
126
|
inline JSON and avoids quoting pain in `curl`:
|
|
@@ -128,10 +207,11 @@ facts. Record the *why* of a decision, not just the *what*.
|
|
|
128
207
|
|
|
129
208
|
## When agentdox fails
|
|
130
209
|
|
|
131
|
-
- **401** →
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
- **403** →
|
|
210
|
+
- **401** → `AGENTDOX_TOKEN` is missing from the launching shell's environment. Re-set it from
|
|
211
|
+
the repo's `.env.agentdox` and restart the harness (`${VAR}` substitution happens once, at
|
|
212
|
+
MCP-server startup).
|
|
213
|
+
- **403** → should not happen with the global token. If it does, that token was revoked or
|
|
214
|
+
replaced by a scoped one — check `.env.agentdox` against agentdox's `/auth/tokens` list.
|
|
135
215
|
- **Connection refused** → the `agentdox-server` Docker container is not running.
|
|
136
216
|
|
|
137
217
|
Report the failure rather than proceeding as if the store were up to date.
|
|
@@ -141,3 +221,9 @@ Report the failure rather than proceeding as if the store were up to date.
|
|
|
141
221
|
*Canonical copy: `~/.claude/skills/agentdox/SKILL.md`. omp only discovers skills from
|
|
142
222
|
**project-relative** dirs (`.claude/skills/`, `.omp/skills`, `.agent/skills`, …), so this file
|
|
143
223
|
is copied into each participating repo. Edit the canonical copy, then re-copy.*
|
|
224
|
+
|
|
225
|
+
*A short pointer also lives in `~/.omp/agent/AGENTS.md` — omp's global instruction file, loaded
|
|
226
|
+
in every session in every directory (empirically the only user-level path omp reads: `~/.agent`,
|
|
227
|
+
`~/.agents`, `~/.codex`, `~/.config/opencode`, `~/.omp`, and `~/AGENTS.md` were all ignored).
|
|
228
|
+
Keep it a **pointer**: the protocol lives here, and that file is charged to the context window of
|
|
229
|
+
every omp session, including ones with nothing to do with agentdox.*
|
package/.mcp.json
CHANGED
|
@@ -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.2.
|
|
10
|
+
"version": "0.2.15",
|
|
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.2.
|
|
17
|
+
"version": "0.2.15",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/CLAUDE.md
CHANGED
|
@@ -13,17 +13,20 @@ It presents one keyless OpenAI-compatible provider and picks a concrete OpenRout
|
|
|
13
13
|
|
|
14
14
|
agentdox is this repo's memory + docs + live-conversation system. The project slug is
|
|
15
15
|
**`omp-router`** — ALWAYS scope agentdox writes to it. The HTTP MCP server in `.mcp.json`
|
|
16
|
-
uses **`
|
|
16
|
+
uses **`AGENTDOX_TOKEN`**, one **global** bearer token shared by every agentdox-wired repo.
|
|
17
|
+
The scope comes from *this folder* (`AGENTDOX_SCOPE` in `.env.agentdox`), not from the token:
|
|
18
|
+
the token grants every scope, so a wrong slug is **not** rejected — it silently writes into
|
|
19
|
+
another project. Getting `omp-router` right is on you, not on RBAC.
|
|
17
20
|
|
|
18
21
|
**Where the credentials live:**
|
|
19
22
|
|
|
20
23
|
| What | Where |
|
|
21
24
|
| --- | --- |
|
|
22
25
|
| Token + URL + scope | `.env.agentdox` in this repo root (**gitignored** via `.env.*` — never commit) |
|
|
23
|
-
| What `.mcp.json` reads | the `
|
|
24
|
-
| Persisted env value | Windows **User** environment (`[Environment]::GetEnvironmentVariable('
|
|
26
|
+
| What `.mcp.json` reads | the `AGENTDOX_TOKEN` **environment variable**, not the file |
|
|
27
|
+
| Persisted env value | Windows **User** environment (`[Environment]::GetEnvironmentVariable('AGENTDOX_TOKEN','User')`) |
|
|
25
28
|
| Server | `http://localhost:3003` — Docker container `agentdox-server` |
|
|
26
|
-
| Admin token (to re-mint
|
|
29
|
+
| Admin token (to re-mint the global PAT) | `E:/projects/agentdox/deploy/.env` |
|
|
27
30
|
|
|
28
31
|
`.env.agentdox` is the durable record; the environment variable is what Claude Code actually
|
|
29
32
|
substitutes into `.mcp.json` at MCP-server startup. If agentdox MCP returns **401**, the
|
|
@@ -60,7 +63,7 @@ served from this repo's `.mcp.json`.
|
|
|
60
63
|
headers. It mounts them **prefixed**: `agentdox_memory_add`, `agentdox_context_assemble`, …
|
|
61
64
|
(fully qualified `mcp__agentdox_*`). All 17 tools load.
|
|
62
65
|
|
|
63
|
-
The one prerequisite is `
|
|
66
|
+
The one prerequisite is `AGENTDOX_TOKEN` being present in the **launching shell's**
|
|
64
67
|
environment. It is persisted at Windows *User* scope, so only shells started afterwards
|
|
65
68
|
inherit it — an already-open terminal will show no agentdox tools until restarted.
|
|
66
69
|
|
|
@@ -69,7 +72,7 @@ inherited) MUST use the REST API directly** — same live store, same RBAC. Don'
|
|
|
69
72
|
just because the MCP tools are absent.
|
|
70
73
|
|
|
71
74
|
REST basics: base `http://localhost:3003`, header `Authorization: Bearer <token>` where the token
|
|
72
|
-
is `AGENTDOX_TOKEN` from `.env.agentdox` (
|
|
75
|
+
is `AGENTDOX_TOKEN` from `.env.agentdox` (global; grants every scope). **memory uses
|
|
73
76
|
`category`, everything else uses `scope`; both are always `"omp-router"`.** MCP-tool → REST map:
|
|
74
77
|
|
|
75
78
|
| Duty / MCP tool | REST |
|
|
@@ -111,7 +114,7 @@ Turning the bridge on for the router itself (distinct from the MCP wiring above)
|
|
|
111
114
|
|
|
112
115
|
```bash
|
|
113
116
|
export AGENTDOX_URL=http://localhost:3003
|
|
114
|
-
export AGENTDOX_TOKEN=<
|
|
117
|
+
export AGENTDOX_TOKEN=<the global PAT, same value .mcp.json uses>
|
|
115
118
|
export AGENTDOX_SCOPE=omp-router
|
|
116
119
|
```
|
|
117
120
|
|
package/package.json
CHANGED
package/src/config/defaults.ts
CHANGED
|
@@ -161,6 +161,15 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
161
161
|
// faster than the server reassembles buys nothing but cache misses.
|
|
162
162
|
maxStalenessMs: 900_000,
|
|
163
163
|
maxBlockChars: 24_000,
|
|
164
|
+
// Bound what agentdox SELECTS, rather than letting the block grow and then
|
|
165
|
+
// slicing it at `maxBlockChars`. Byte truncation cuts an entry mid-sentence
|
|
166
|
+
// and is blind to relevance; a limit lets the server rank first. Left
|
|
167
|
+
// unbounded, this scope reached 15 memory entries = 23.5k chars (~5.9k
|
|
168
|
+
// tokens) injected into every turn, against a 24k cap it was about to hit.
|
|
169
|
+
memoryLimit: 8,
|
|
170
|
+
// Session messages are cheap today but grow once recordTurns is on, and
|
|
171
|
+
// they feed straight back into the next assembly.
|
|
172
|
+
sessionLimit: 6,
|
|
164
173
|
recordTurns: true,
|
|
165
174
|
maxQueue: 64,
|
|
166
175
|
},
|
package/src/config/schema.ts
CHANGED
|
@@ -138,6 +138,8 @@ const context = z.strictObject({
|
|
|
138
138
|
timeoutMs: z.number().int().positive().optional(),
|
|
139
139
|
maxStalenessMs: z.number().int().nonnegative().optional(),
|
|
140
140
|
maxBlockChars: z.number().int().positive().optional(),
|
|
141
|
+
memoryLimit: z.number().int().positive().optional(),
|
|
142
|
+
sessionLimit: z.number().int().nonnegative().optional(),
|
|
141
143
|
recordTurns: z.boolean().optional(),
|
|
142
144
|
maxQueue: z.number().int().positive().optional(),
|
|
143
145
|
});
|
package/src/config/types.ts
CHANGED
|
@@ -372,6 +372,10 @@ export interface ContextConfig {
|
|
|
372
372
|
maxStalenessMs: number;
|
|
373
373
|
/** Hard cap on injected block size, characters. */
|
|
374
374
|
maxBlockChars: number;
|
|
375
|
+
/** Max memory entries agentdox may select for the block. */
|
|
376
|
+
memoryLimit: number;
|
|
377
|
+
/** Max recent session messages agentdox may select for the block. */
|
|
378
|
+
sessionLimit: number;
|
|
375
379
|
/** Write settled turns back to agentdox sessions, tagged with the served model. */
|
|
376
380
|
recordTurns: boolean;
|
|
377
381
|
/** Bound on queued write-backs; excess turns are dropped, never buffered unbounded. */
|
package/src/context/agentdox.ts
CHANGED
|
@@ -15,13 +15,19 @@ export interface AgentDoxClientOptions {
|
|
|
15
15
|
log: Logger;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** Bounds on what agentdox may select for one block. */
|
|
19
|
+
export interface AssembleLimits {
|
|
20
|
+
memoryLimit: number;
|
|
21
|
+
sessionLimit: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
export interface AgentDoxClient {
|
|
19
25
|
/**
|
|
20
26
|
* Assembles a context slice for `scope`, biased by `query`. Falls back to
|
|
21
27
|
* the server's pre-assembled baseline when assembly is unavailable (older
|
|
22
28
|
* server, or no query-relevant content).
|
|
23
29
|
*/
|
|
24
|
-
assemble(scope: string, query: string): Promise<string | null>;
|
|
30
|
+
assemble(scope: string, query: string, limits: AssembleLimits): Promise<string | null>;
|
|
25
31
|
createSession(scope: string, title: string): Promise<string | null>;
|
|
26
32
|
append(sessionId: string, role: "user" | "assistant", content: string, refs: string[]): Promise<boolean>;
|
|
27
33
|
}
|
|
@@ -75,8 +81,15 @@ export function createAgentDoxClient(opts: AgentDoxClientOptions): AgentDoxClien
|
|
|
75
81
|
};
|
|
76
82
|
|
|
77
83
|
return {
|
|
78
|
-
async assemble(scope, query) {
|
|
79
|
-
|
|
84
|
+
async assemble(scope, query, limits) {
|
|
85
|
+
// camelCase: the REST endpoint ignores snake_case limit keys entirely,
|
|
86
|
+
// which silently reads as "unbounded".
|
|
87
|
+
const res = await request("POST", "/context/assemble", {
|
|
88
|
+
scope,
|
|
89
|
+
query,
|
|
90
|
+
memoryLimit: limits.memoryLimit,
|
|
91
|
+
sessionLimit: limits.sessionLimit,
|
|
92
|
+
});
|
|
80
93
|
if (res !== null && res.status === 200) {
|
|
81
94
|
const prompt = promptOf(res.json);
|
|
82
95
|
if (prompt !== null) return prompt;
|
package/src/context/bridge.ts
CHANGED
|
@@ -30,6 +30,13 @@ export interface BridgeOptions {
|
|
|
30
30
|
maxStalenessMs: number;
|
|
31
31
|
/** Hard cap on injected block size; a runaway context must not dominate the prompt. */
|
|
32
32
|
maxBlockChars: number;
|
|
33
|
+
/**
|
|
34
|
+
* Bounds on what agentdox SELECTS. Preferred over `maxBlockChars`, which can
|
|
35
|
+
* only slice bytes: the server ranks by relevance, so a limit drops the least
|
|
36
|
+
* useful entry instead of severing whatever straddles the cap.
|
|
37
|
+
*/
|
|
38
|
+
memoryLimit: number;
|
|
39
|
+
sessionLimit: number;
|
|
33
40
|
/** Record settled turns back into agentdox sessions. */
|
|
34
41
|
recordTurns: boolean;
|
|
35
42
|
/** Bound on queued write-backs; excess is dropped rather than grown unbounded. */
|
|
@@ -73,7 +80,7 @@ function appendFragment(prior: string, next: string): string {
|
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
export function createContextBridge(opts: BridgeOptions): ContextBridge {
|
|
76
|
-
const { client, store, log, maxStalenessMs, maxBlockChars, recordTurns, maxQueue } = opts;
|
|
83
|
+
const { client, store, log, maxStalenessMs, maxBlockChars, memoryLimit, sessionLimit, recordTurns, maxQueue } = opts;
|
|
77
84
|
|
|
78
85
|
// Serialized write-back queue. Session appends for one conversation must
|
|
79
86
|
// stay ordered, and agentdox is a local service — one worker is plenty.
|
|
@@ -111,7 +118,7 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
|
|
|
111
118
|
return { ...pinned, fetchedAtMs: input.pinnedFetchedAtMs };
|
|
112
119
|
}
|
|
113
120
|
|
|
114
|
-
const raw = await client.assemble(input.scope, input.query);
|
|
121
|
+
const raw = await client.assemble(input.scope, input.query, { memoryLimit, sessionLimit });
|
|
115
122
|
if (raw === null) {
|
|
116
123
|
// agentdox unreachable or empty. Keep serving the pinned block if we
|
|
117
124
|
// have one: stale shared context beats none, and re-using it also
|
package/src/context/index.ts
CHANGED
|
@@ -27,6 +27,8 @@ export function createBridgeFromConfig(cfg: RouterConfig, db: Database): Context
|
|
|
27
27
|
log,
|
|
28
28
|
maxStalenessMs: c.maxStalenessMs,
|
|
29
29
|
maxBlockChars: c.maxBlockChars,
|
|
30
|
+
memoryLimit: c.memoryLimit,
|
|
31
|
+
sessionLimit: c.sessionLimit,
|
|
30
32
|
recordTurns: c.recordTurns,
|
|
31
33
|
maxQueue: c.maxQueue,
|
|
32
34
|
});
|
package/src/router/classify.ts
CHANGED
|
@@ -49,19 +49,27 @@ const CAP_LOOP_DEPTH = -0.06;
|
|
|
49
49
|
// the agent keeps grinding without human input. Unlike the mechanical-step
|
|
50
50
|
// penalty above, this term ACCUMULATES with depth past the agentic threshold so
|
|
51
51
|
// long loops climb out of trivial. The ramp slope is calibrated on recorded
|
|
52
|
-
// coding turns; the cap governs the ceiling.
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
52
|
+
// coding turns; the cap governs the ceiling.
|
|
53
|
+
//
|
|
54
|
+
// The cap was briefly 0.70, which let pure depth reach `hard`. That was aimed at
|
|
55
|
+
// a real problem — a distinct-read loop grinding at depth 27+ on
|
|
56
|
+
// deepseek-v4-flash (coding 69.1), which clears the moderate floor, so escalating
|
|
57
|
+
// to moderate swapped nothing. But it overcorrected: on live data 152 of 155
|
|
58
|
+
// `hard` dispatches were depth-driven and carried 63.5% of ALL spend, and those
|
|
59
|
+
// same rows also carry the `-0.28 tool-result continuation (mechanical)` term —
|
|
60
|
+
// the classifier already knew the work was mechanical and the depth bonus
|
|
61
|
+
// overrode it. `hard` (floor 72, no price ceiling) forces gemini-3.7-flash at
|
|
62
|
+
// roughly 8x the cost of the model moderate picks.
|
|
63
|
+
//
|
|
64
|
+
// The original grinding failure is now covered independently: windowed latency
|
|
65
|
+
// scoring inflates a chronically slow model's effective cost, and it removed
|
|
66
|
+
// deepseek-v4-flash from selection entirely. So depth alone tops out at
|
|
67
|
+
// `moderate` again, and `hard` stays reachable via a CORROBORATING stuck signal
|
|
68
|
+
// (circular call, tool failure, keywords) — which is what should buy a $2/Mtok
|
|
69
|
+
// model, not depth by itself.
|
|
62
70
|
const W_AUTONOMOUS_LOOP = 0.06; // base bonus at the threshold
|
|
63
71
|
const W_AUTONOMOUS_LOOP_PER_DEPTH = 0.018; // added per loop step beyond the threshold
|
|
64
|
-
const CAP_AUTONOMOUS_LOOP = 0.
|
|
72
|
+
const CAP_AUTONOMOUS_LOOP = 0.45; // pure depth ramps into moderate, never alone into hard
|
|
65
73
|
const W_IMAGES = 0.04;
|
|
66
74
|
const W_TOOLS_OFFERED = 0.03;
|
|
67
75
|
|
package/test/classify.test.ts
CHANGED
|
@@ -213,16 +213,24 @@ describe("scoreHeuristic", () => {
|
|
|
213
213
|
}
|
|
214
214
|
});
|
|
215
215
|
|
|
216
|
-
test("pure loop depth
|
|
216
|
+
test("pure loop depth never reaches hard on its own, however runaway", () => {
|
|
217
217
|
// A sustained-but-not-runaway loop tops out in moderate: the calibrated
|
|
218
218
|
// ramp ceiling for ordinary deep work.
|
|
219
219
|
const midRange = scoreHeuristic(contFeatures(30), BASE);
|
|
220
220
|
expect(midRange.tier).toBe("moderate");
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
221
|
+
// And so does a runaway one. This reverses an earlier cap of 0.70 that let
|
|
222
|
+
// raw depth buy `hard`: on live data 152 of 155 hard dispatches were
|
|
223
|
+
// depth-driven and carried 63.5% of ALL spend, while those same rows also
|
|
224
|
+
// scored the mechanical tool-result-continuation penalty. `hard` has no
|
|
225
|
+
// price ceiling, so depth alone was buying a ~8x model for work the
|
|
226
|
+
// classifier already knew was mechanical. Depth is a weak signal of
|
|
227
|
+
// DIFFICULTY; hard must be bought by a corroborating stuck signal (see the
|
|
228
|
+
// circular-tool-call test below), which is the case that actually needs a
|
|
229
|
+
// stronger model.
|
|
224
230
|
const runaway = scoreHeuristic(contFeatures(90), BASE);
|
|
225
|
-
expect(runaway.tier).toBe("
|
|
231
|
+
expect(runaway.tier).toBe("moderate");
|
|
232
|
+
// The ceiling must still be a real ceiling, not an accident of the ramp.
|
|
233
|
+
expect(scoreHeuristic(contFeatures(400), BASE).tier).toBe("moderate");
|
|
226
234
|
});
|
|
227
235
|
|
|
228
236
|
test("a circular tool call on a deep loop escalates to hard", () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import type { AgentDoxClient } from "../src/context/agentdox.ts";
|
|
3
|
+
import type { AgentDoxClient, AssembleLimits } 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";
|
|
@@ -15,16 +15,19 @@ interface FakeClient extends AgentDoxClient {
|
|
|
15
15
|
appended: { sessionId: string; role: string; content: string; refs: string[] }[];
|
|
16
16
|
sessionsCreated: number;
|
|
17
17
|
prompt: string;
|
|
18
|
+
lastLimits: AssembleLimits | null;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
function mkClient(prompt = "MEMORY: player digs in 3/4 top-down"): FakeClient {
|
|
21
22
|
const c: FakeClient = {
|
|
22
23
|
assembleCalls: 0,
|
|
24
|
+
lastLimits: null,
|
|
23
25
|
appended: [],
|
|
24
26
|
sessionsCreated: 0,
|
|
25
27
|
prompt,
|
|
26
|
-
async assemble() {
|
|
28
|
+
async assemble(_scope, _query, limits) {
|
|
27
29
|
c.assembleCalls++;
|
|
30
|
+
c.lastLimits = limits;
|
|
28
31
|
return c.prompt;
|
|
29
32
|
},
|
|
30
33
|
async createSession() {
|
|
@@ -49,6 +52,8 @@ function mkBridge(client: AgentDoxClient, over: Partial<BridgeOpts> = {}) {
|
|
|
49
52
|
log,
|
|
50
53
|
maxStalenessMs: 900_000,
|
|
51
54
|
maxBlockChars: 24_000,
|
|
55
|
+
memoryLimit: 8,
|
|
56
|
+
sessionLimit: 6,
|
|
52
57
|
recordTurns: true,
|
|
53
58
|
maxQueue: 64,
|
|
54
59
|
...over,
|
|
@@ -89,6 +94,22 @@ describe("context bridge refresh policy", () => {
|
|
|
89
94
|
}
|
|
90
95
|
});
|
|
91
96
|
|
|
97
|
+
test("assembly is bounded, so the block cannot grow until bytes get severed", async () => {
|
|
98
|
+
// The block reached 23.5k chars (~5.9k tokens, 15 memory entries) against a
|
|
99
|
+
// 24k maxBlockChars cap, at which point renderBlock slices mid-entry. Byte
|
|
100
|
+
// truncation is blind to relevance, so the server must be told to rank and
|
|
101
|
+
// select instead. The REST endpoint ignores snake_case limit keys, which
|
|
102
|
+
// silently reads as unbounded — hence pinning that the limits are passed.
|
|
103
|
+
const client = mkClient();
|
|
104
|
+
const { bridge, db } = mkBridge(client, { memoryLimit: 5, sessionLimit: 2 });
|
|
105
|
+
try {
|
|
106
|
+
await bridge.resolve(input());
|
|
107
|
+
expect(client.lastLimits).toEqual({ memoryLimit: 5, sessionLimit: 2 });
|
|
108
|
+
} finally {
|
|
109
|
+
db.close();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
92
113
|
test("refreshes when the model switches, because the cache is already forfeit", async () => {
|
|
93
114
|
const client = mkClient();
|
|
94
115
|
const { bridge, db } = mkBridge(client);
|
|
@@ -217,6 +238,8 @@ describe("context bridge refresh policy", () => {
|
|
|
217
238
|
log,
|
|
218
239
|
maxStalenessMs: 900_000,
|
|
219
240
|
maxBlockChars: 24_000,
|
|
241
|
+
memoryLimit: 8,
|
|
242
|
+
sessionLimit: 6,
|
|
220
243
|
recordTurns: true,
|
|
221
244
|
maxQueue: 64,
|
|
222
245
|
};
|
package/test/failover.test.ts
CHANGED
|
@@ -69,7 +69,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
69
69
|
hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
|
|
70
70
|
exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
|
|
71
71
|
cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
|
|
72
|
-
context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, recordTurns: false, maxQueue: 64 },
|
|
72
|
+
context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, sessionLimit: 6, recordTurns: false, maxQueue: 64 },
|
|
73
73
|
compaction: { enabled: false, budgetTokens: 40_000, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
|
|
74
74
|
budget: { onExceeded: "downgrade" },
|
|
75
75
|
profiles: [],
|
package/test/turn.test.ts
CHANGED
|
@@ -70,7 +70,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
|
|
|
70
70
|
hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1 },
|
|
71
71
|
exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
|
|
72
72
|
cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
|
|
73
|
-
context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, recordTurns: false, maxQueue: 64 },
|
|
73
|
+
context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, sessionLimit: 6, recordTurns: false, maxQueue: 64 },
|
|
74
74
|
compaction: { enabled: false, budgetTokens: 40_000, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
|
|
75
75
|
budget: { onExceeded: "downgrade" },
|
|
76
76
|
profiles: [],
|