@tekmidian/pai 0.42.0 → 0.42.2
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/README.md +1 -1
- package/dist/chain-B-DbR82m.mjs +164 -0
- package/dist/chain-B-DbR82m.mjs.map +1 -0
- package/dist/cli/index.mjs +8 -8
- package/dist/cli/program.mjs +8 -8
- package/dist/{config-DgzR5sfb.mjs → config-_lQI_W08.mjs} +116 -3
- package/dist/config-_lQI_W08.mjs.map +1 -0
- package/dist/{context-handover-cache-dUnSZwaA.mjs → context-handover-cache-pNFdBNrU.mjs} +27 -8
- package/dist/context-handover-cache-pNFdBNrU.mjs.map +1 -0
- package/dist/daemon/index.mjs +7 -7
- package/dist/{daemon-CKBkhOsZ.mjs → daemon-Awt_3UJp.mjs} +7 -7
- package/dist/{daemon-DsvP8KMy.mjs → daemon-Dq8A1uDF.mjs} +591 -370
- package/dist/daemon-Dq8A1uDF.mjs.map +1 -0
- package/dist/daemon-mcp/index.mjs +5 -5
- package/dist/{fallback-5UJO-RyX.mjs → fallback-vQPI1qxt.mjs} +15 -13
- package/dist/fallback-vQPI1qxt.mjs.map +1 -0
- package/dist/hooks/mcp-deferred-gate.mjs +286 -0
- package/dist/hooks/mcp-deferred-gate.mjs.map +7 -0
- package/dist/hooks/mcp-deferred-reminder.mjs +54 -0
- package/dist/hooks/mcp-deferred-reminder.mjs.map +7 -0
- package/dist/hooks/route-agents-to-worker.mjs +65 -2
- package/dist/hooks/route-agents-to-worker.mjs.map +2 -2
- package/dist/hooks/worker-proxy.mjs +64 -1
- package/dist/hooks/worker-proxy.mjs.map +2 -2
- package/dist/hooks/worker-status-line.mjs +68 -2
- package/dist/hooks/worker-status-line.mjs.map +2 -2
- package/dist/hooks/worker-supervision.mjs +64 -1
- package/dist/hooks/worker-supervision.mjs.map +2 -2
- package/dist/{main-resolver-DXGuygFG.mjs → main-resolver-BAfOMppW.mjs} +2 -2
- package/dist/{main-resolver-BgIrC-D9.mjs → main-resolver-DnkxPYkr.mjs} +111 -16
- package/dist/main-resolver-DnkxPYkr.mjs.map +1 -0
- package/dist/{planner-BLDJt3ZS.mjs → planner-BfEGcEs1.mjs} +5 -5
- package/dist/{planner-BLDJt3ZS.mjs.map → planner-BfEGcEs1.mjs.map} +1 -1
- package/dist/{program-CruRnLtc.mjs → program-BDmeZP-v.mjs} +17 -11
- package/dist/{program-CruRnLtc.mjs.map → program-BDmeZP-v.mjs.map} +1 -1
- package/dist/{chain-DGXNoTwc.mjs → run-CGXbhf2k.mjs} +422 -195
- package/dist/run-CGXbhf2k.mjs.map +1 -0
- package/dist/{server-Cp7tNzNC.mjs → server-D4vAieF4.mjs} +49 -8
- package/dist/server-D4vAieF4.mjs.map +1 -0
- package/dist/{state-BTa10SOM.mjs → state-BRdB-Bf1.mjs} +7 -2
- package/dist/state-BRdB-Bf1.mjs.map +1 -0
- package/dist/state-CY6I-Hnc.mjs +3 -0
- package/dist/{work-queue-worker-D1p2V-ae.mjs → work-queue-worker-AEmfVE0F.mjs} +5 -5
- package/dist/{work-queue-worker-7Vkli4Yh.mjs → work-queue-worker-DIT-VLSh.mjs} +3 -3
- package/dist/{work-queue-worker-7Vkli4Yh.mjs.map → work-queue-worker-DIT-VLSh.mjs.map} +1 -1
- package/docs/cache-keepalive.md +82 -0
- package/docs/commands/worker.md +1 -0
- package/docs/merge-demo.md +1 -0
- package/docs/provider-abstraction.md +52 -2
- package/package.json +1 -1
- package/plugins/core/hooks/hooks.json +12 -0
- package/src/hooks/ts/lib/mcp-deferred-gate.test.ts +258 -0
- package/src/hooks/ts/lib/mcp-deferred-gate.ts +261 -0
- package/src/hooks/ts/lib/mcp-deferred-reminder.test.ts +90 -0
- package/src/hooks/ts/lib/mcp-deferred-reminder.ts +66 -0
- package/src/hooks/ts/pre-tool-use/mcp-deferred-gate.ts +126 -0
- package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +6 -2
- package/src/hooks/ts/session-start/mcp-deferred-reminder.ts +52 -0
- package/statusline-command.sh +149 -72
- package/dist/chain-DGXNoTwc.mjs.map +0 -1
- package/dist/config-DgzR5sfb.mjs.map +0 -1
- package/dist/context-handover-cache-dUnSZwaA.mjs.map +0 -1
- package/dist/daemon-DsvP8KMy.mjs.map +0 -1
- package/dist/fallback-5UJO-RyX.mjs.map +0 -1
- package/dist/main-resolver-BgIrC-D9.mjs.map +0 -1
- package/dist/scope-NIwCGxRI.mjs +0 -351
- package/dist/scope-NIwCGxRI.mjs.map +0 -1
- package/dist/server-Cp7tNzNC.mjs.map +0 -1
- package/dist/state-BTa10SOM.mjs.map +0 -1
- package/dist/state-RXepvbnO.mjs +0 -3
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Worker prompt-cache keepalive
|
|
2
|
+
|
|
3
|
+
Why `workers.cacheKeepaliveSecs` exists, what the provider cache actually
|
|
4
|
+
does, and the measured numbers behind the default.
|
|
5
|
+
|
|
6
|
+
## Mechanism
|
|
7
|
+
|
|
8
|
+
Workers on an anthropic-compat GLM endpoint (`protocol: "anthropic"`) carry no
|
|
9
|
+
`cache_control` blocks, yet the endpoint caches implicitly: the final `result`
|
|
10
|
+
usage of a real worker run showed `cache_read_input_tokens: 1,129,984` with
|
|
11
|
+
`cache_creation_input_tokens: 0` (worker `20260918-120947-11956`, 2026-09-18).
|
|
12
|
+
|
|
13
|
+
The keepalive arms that implicit cache for future spawns with a trivial
|
|
14
|
+
single-turn worker (`src/workers/keepalive.ts`) driven by the daemon on
|
|
15
|
+
`workers.cacheKeepaliveSecs` (`src/daemon/daemon/scheduler.ts`,
|
|
16
|
+
`startCacheKeepalive`). The beat goes through `runWorker` itself — the
|
|
17
|
+
maximal sharing of prefix construction (env, tool grants, contract prompt) —
|
|
18
|
+
so a beat and a real worker differ in nothing but the user message. Beats run
|
|
19
|
+
in the workers logDir, adopt no session identity, notify nobody, and write one
|
|
20
|
+
`WORKER-KEEPALIVE` ledger line each; on a Postgres backend they also store one
|
|
21
|
+
observation row via `storeObservationWithProject`.
|
|
22
|
+
|
|
23
|
+
Measurement constraints (measured, not assumed): per-turn `assistant` events
|
|
24
|
+
on this endpoint always carry `usage: {input_tokens: 0, output_tokens: 0}`
|
|
25
|
+
with no cache fields; the only real usage is the final `result` event. A
|
|
26
|
+
single-turn worker therefore reads its first-turn usage straight off its
|
|
27
|
+
result line, and `duration_api_ms` is the TTFT proxy.
|
|
28
|
+
|
|
29
|
+
## Measured numbers (2026-09-18, probe `scripts/probe-cache-keepalive.ts`)
|
|
30
|
+
|
|
31
|
+
Every spawn is a fresh claude process and session; prompt identical; class
|
|
32
|
+
`simple` on glm-5.3-flash. "gap" = time between the two spawns of a pair.
|
|
33
|
+
|
|
34
|
+
| pair | gap | spawn | input_tokens | cache_read | cache_creation | duration_api_ms |
|
|
35
|
+
|------|-----|-------|--------------|-----------|----------------|-----------------|
|
|
36
|
+
| back-to-back | ~16 s | 1 | 17,906 | 0 | 0 | 12,911 |
|
|
37
|
+
| back-to-back | ~16 s | 2 | 15,282 | **2,624** | 0 | **7,316** |
|
|
38
|
+
| gap 120 s | ~2.2 min | 1 | 17,957 | 0 | 0 | 8,401 |
|
|
39
|
+
| gap 120 s | ~2.2 min | 2 | 17,909 | 0 | 0 | 9,812 |
|
|
40
|
+
| gap 240 s | ~4.4 min | 1 | 17,909 | 0 | 0 | 10,827 |
|
|
41
|
+
| gap 240 s | ~4.4 min | 2 | 17,881 | 0 | 0 | 10,037 |
|
|
42
|
+
| gap 360 s | 6 min | 1 | 17,949 | 0 | 0 | 10,132 |
|
|
43
|
+
| gap 360 s | 6 min | 2 | 17,951 | 0 | 0 | 9,714 |
|
|
44
|
+
|
|
45
|
+
## Armed-cadence proof (60 s beats through the real beat code, then a pair)
|
|
46
|
+
|
|
47
|
+
| run | after | input_tokens | cache_read | duration_api_ms |
|
|
48
|
+
|-----|-------|--------------|-----------|-----------------|
|
|
49
|
+
| beat 125003 | — | 14,445 | 0 | 5,307 |
|
|
50
|
+
| beat 125111 | ~68 s | 14,010 | 0 | 5,480 |
|
|
51
|
+
| beat 125220 | ~69 s | 14,010 | 0 | 8,577 |
|
|
52
|
+
| probe-1 125232 | ~12 s | 17,989 | 0 | 6,687 |
|
|
53
|
+
| probe-2 125242 | ~10 s | 17,736 | **256** | 10,034 |
|
|
54
|
+
|
|
55
|
+
Ledger lines: `WORKER-KEEPALIVE id=… provider=glm model=glm-5.3-flash
|
|
56
|
+
input_tokens=… cache_read_input_tokens=… duration_api_ms=…` land as designed.
|
|
57
|
+
|
|
58
|
+
## Decision
|
|
59
|
+
|
|
60
|
+
1. **Cross-session implicit caching exists, but only at seconds range.**
|
|
61
|
+
Spawn 2 of the back-to-back pair is a different process and session than
|
|
62
|
+
spawn 1 and still got `cache_read > 0` — but the only positive readings
|
|
63
|
+
across nine pairs came 10–16 s after the previous identical request
|
|
64
|
+
(2,624 tokens at ~16 s; 256 at ~10 s), and the hit size is unstable. At
|
|
65
|
+
every gap ≥ 60 s — including a 60 s heartbeat cadence proven live through
|
|
66
|
+
the shipped beat code — every spawn was cold.
|
|
67
|
+
2. **A keepalive therefore cannot hold this cache at any cadence the
|
|
68
|
+
constraints allow** ("cadence is minutes, not seconds"): holding it would
|
|
69
|
+
need a beat every few seconds, billing ~14–18k fresh input tokens per
|
|
70
|
+
beat around the clock, to serve at best a few hundred to ~2.6k cached
|
|
71
|
+
tokens to the next spawn. The drafted "default = TTL/2" rule assumed a
|
|
72
|
+
TTL in minutes; the measured TTL/2 ≈ 5–30 s, which no sane daemon
|
|
73
|
+
interval meets, and a 60 s default was disproven by the armed proof.
|
|
74
|
+
3. **Therefore `DEFAULT_CACHE_KEEPALIVE_SECS = 0`** (a deliberate deviation
|
|
75
|
+
from the drafted spec, per its own STOP-on-negative-evidence rule): the
|
|
76
|
+
instrument ships — knob, beat, daemon wiring, ledger, observations, tests
|
|
77
|
+
— and stays off unless the operator explicitly arms it
|
|
78
|
+
(`"cacheKeepaliveSecs": <secs>` in the workers section). The honest
|
|
79
|
+
reading of the measurement: this endpoint's implicit cache is a
|
|
80
|
+
short-range replay buffer, not a warmable baseline; new-worker cold starts
|
|
81
|
+
cannot be removed by a heartbeat, and the fix would have to live in the
|
|
82
|
+
provider (explicit `cache_control` support) rather than in the daemon.
|
package/docs/commands/worker.md
CHANGED
|
@@ -68,6 +68,7 @@ Grant MCP tools by naming mcp__server__tool in --allowedTools (the server loads
|
|
|
68
68
|
| `--agent <name>` | Run the agent definition ~/.claude/agents/<name>.md on a worker | |
|
|
69
69
|
| `--model <model>` | Override the provider's model for this run | |
|
|
70
70
|
| `--label <text>` | Short task label shown in ps / follow / status line | |
|
|
71
|
+
| `--cwd <dir>` | Directory the worker runs in (default: this process's cwd) | |
|
|
71
72
|
| `--mcp <names>` | MCP servers/sets this worker may use (comma-separated; see `pai worker mcp`) | |
|
|
72
73
|
| `--no-pane` | Do not open a follow pane for this worker | |
|
|
73
74
|
| `--worktree` | Run in a git worktree on branch worker/<id> (default for implement/complex/plan in a git repo) | |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Worker merge demo, created by a worktree agent.
|
|
@@ -7,6 +7,7 @@ The entire assistant stack — interactive sessions, workers, follow panes, hook
|
|
|
7
7
|
What that buys:
|
|
8
8
|
|
|
9
9
|
- **No single-vendor lock-in.** Providers are entries in a registry (`src/workers/config.ts`): protocol (`anthropic`/`openai`), engine (`claude`/`codex`), model ids, key files, cost tiers, capability tags, quota probes. Switching is `pai worker providers use <name>`; disabling routing entirely is `pai worker off`.
|
|
10
|
+
- **Plain Anthropic stays a first-class choice.** The reserved provider name `anthropic` needs no registry entry — it is Claude Code's own OAuth/Max-plan login, with no base URL override and no API key. `pai worker providers use anthropic` makes it the default; `--provider anthropic` or a class pointed at `anthropic` uses it for one run. `pai worker providers` lists it as `anthropic [built-in]`.
|
|
10
11
|
- **Plan/quota-aware instrumentation.** The statusline reads the *active provider's* real plan utilization (5-hour rolling window and weekly quota with reset times), not a hardcoded vendor endpoint.
|
|
11
12
|
- **Cost routing per task class.** Work is dispatched by class (`draft`, `spotcheck`, `complex`, …); each class maps to a provider and model, so cheap work lands on cheap models without touching code.
|
|
12
13
|
- **Provider swaps are configuration, not surgery.** Every seam that used to assume one vendor — daemon LLM spawns, compaction-history family detection, context-window defaults, tier aliases — has been patched at protocol level (see below), so a new provider is a registry entry plus a key file.
|
|
@@ -40,11 +41,60 @@ The `fast` slot is what cheap classes resolve to; the runner also honors the pla
|
|
|
40
41
|
|
|
41
42
|
`statusline-command.sh` derives everything it can from the session's model id instead of assuming one vendor:
|
|
42
43
|
|
|
43
|
-
- **
|
|
44
|
+
- **Session provider from the registry** — the session's model id (any `[…]` variant suffix stripped) is matched against every `workers.providers.<name>.models` value in the PAI config, so a session belongs to the provider that lists its model, not to a name prefix. No config, no `jq`, unreadable JSON or no match ⇒ the built-in `anthropic` path, silently.
|
|
45
|
+
- **Plan utilization** — a provider with a `usage` block has its quota fetched and rendered from that block (see below); the built-in `anthropic` provider keeps the OAuth path (5-hour, 1d pace, 7-day). Each provider caches to `/tmp/claude/statusline-usage-<provider>.json` for `ttlSeconds` (default 60) and refreshes in the background, so the line never waits on the network. On fetch failure the windows show `?`, and a provider with no `usage` block shows `<provider> usage n/a` — never another plan's numbers.
|
|
44
46
|
- **Context window from the model id** — a `[1m]` variant suffix means a 1,000,000-token window (`statusline-command.sh:126`); ids with no marker keep the previous default. The same derivation lives in TypeScript as `contextWindowFromModelId()` (`src/utils/model-window.ts`), so the session hooks and the worker meter agree with the statusline.
|
|
45
47
|
- **`(N% left)`** — remaining context until auto-compact, computed against that derived window.
|
|
46
48
|
- **Worker row** — line 4 lists the workers this session launched, running ones with their current step (`pai worker status-line`, `statusline-command.sh:634`).
|
|
47
49
|
|
|
50
|
+
#### Provider usage blocks
|
|
51
|
+
|
|
52
|
+
A provider's quota endpoint is configuration. `workers.providers.<name>.usage` (schema: `ProviderUsage` / `UsageWindow`, `src/workers/config.ts`) names a JSON `GET` endpoint plus one jq expression per window, and the statusline renders `<label> <window>: <pct>% → <reset>` for each:
|
|
53
|
+
|
|
54
|
+
| field | meaning |
|
|
55
|
+
| --- | --- |
|
|
56
|
+
| `url` | endpoint returning the usage JSON |
|
|
57
|
+
| `authHeader` | header template, default `Authorization: Bearer`; the key from the provider's `keyFile` is appended after a space. A value with no space (e.g. `x-api-key`) is sent as `<value>: <key>` |
|
|
58
|
+
| `label` | display label for the segments, default the provider name |
|
|
59
|
+
| `ttlSeconds` | cache TTL, default 60 |
|
|
60
|
+
| `windows[].name` | window label, e.g. `5h` |
|
|
61
|
+
| `windows[].percent` | jq expression yielding 0–100 — an arithmetic expression is fine, so a percentage can be computed from a used/limit pair |
|
|
62
|
+
| `windows[].resetAt` | jq expression yielding the reset time |
|
|
63
|
+
| `windows[].resetUnit` | `ms` (default), `s` or `iso` |
|
|
64
|
+
|
|
65
|
+
Reset times render as `HH:MM` within the next 24 hours and `<weekday> HH:MM` beyond it. The first window's `percent` doubles as the response probe: a reply that yields no number for it is not cached, so a rejected key answering `HTTP 200` with an empty body never masquerades as a valid zero reading.
|
|
66
|
+
|
|
67
|
+
Two working blocks. A Z.ai-style credit plan, whose two windows live in one array distinguished by `number`, with epoch-millisecond resets:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
"usage": {
|
|
71
|
+
"url": "https://api.z.ai/api/monitor/usage/quota/limit",
|
|
72
|
+
"authHeader": "Authorization: Bearer",
|
|
73
|
+
"label": "zai",
|
|
74
|
+
"ttlSeconds": 60,
|
|
75
|
+
"windows": [
|
|
76
|
+
{ "name": "5h", "percent": ".data.limits[]? | select(.number == 5) | .percentage", "resetAt": ".data.limits[]? | select(.number == 5) | .nextResetTime", "resetUnit": "ms" },
|
|
77
|
+
{ "name": "7d", "percent": ".data.limits[]? | select(.number == 1) | .percentage", "resetAt": ".data.limits[]? | select(.number == 1) | .nextResetTime", "resetUnit": "ms" }
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
And a coding plan that reports *fractions* (`0.0262` = 2.62%, hence the `* 100`), ISO-8601 resets, and a request-count window that has no percentage at all — computed here from its used/limit pair:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
"usage": {
|
|
86
|
+
"url": "https://api.kimi.ai/coding/v1/usages",
|
|
87
|
+
"authHeader": "Authorization: Bearer",
|
|
88
|
+
"label": "kimi",
|
|
89
|
+
"ttlSeconds": 60,
|
|
90
|
+
"windows": [
|
|
91
|
+
{ "name": "5h", "percent": ".usages.limit_5h.used_ratio * 100", "resetAt": ".usages.limit_5h.reset_time", "resetUnit": "iso" },
|
|
92
|
+
{ "name": "30d", "percent": ".usages.limit_month_total.used_ratio * 100", "resetAt": ".usages.limit_month_total.reset_time", "resetUnit": "iso" },
|
|
93
|
+
{ "name": "req", "percent": "(.limits[0].detail.used | tonumber) / (.limits[0].detail.limit | tonumber) * 100", "resetAt": ".limits[0].detail.resetTime", "resetUnit": "iso" }
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
48
98
|
### Seam patches
|
|
49
99
|
|
|
50
100
|
The seams that previously hardcoded one vendor, now patched:
|
|
@@ -82,6 +132,6 @@ Two further instruments are deployed configuration in the PAI config dir rather
|
|
|
82
132
|
|
|
83
133
|
## Current limits
|
|
84
134
|
|
|
85
|
-
The layer is proven against one non-Anthropic provider so far (the `glm` endpoint); other Anthropic-protocol providers are expected to work but have not been exercised end-to-end, and OpenAI-protocol providers additionally depend on the translating proxy.
|
|
135
|
+
The layer is proven against one non-Anthropic provider so far (the `glm` endpoint); other Anthropic-protocol providers are expected to work but have not been exercised end-to-end, and OpenAI-protocol providers additionally depend on the translating proxy. Quota endpoints are no longer special-cased in code — a provider's windows are its `usage` block — but a provider whose response needs more than a jq expression per window (paging, a second request, an auth flow other than a single header) would still need code. The seam audit that drove these patches lives outside this repository; new seams are patched as they surface, and each patch carries a unit test pinning the provider-neutral behaviour (`src/workers/daemon-llm.test.ts`, `src/workers/model.test.ts`, `src/workers/agents.test.ts`, `src/hooks/ts/lib/context-fill.test.ts`).
|
|
86
136
|
|
|
87
137
|
For the full worker command reference see [worker.md](worker.md) and [commands/worker.md](commands/worker.md).
|
package/package.json
CHANGED
|
@@ -17,12 +17,24 @@
|
|
|
17
17
|
"command": "${PAI_DIR}/Hooks/initialize-session.mjs",
|
|
18
18
|
"description": "Create numbered session note and register in PAI registry"
|
|
19
19
|
},
|
|
20
|
+
{
|
|
21
|
+
"event": "SessionStart",
|
|
22
|
+
"matcher": "resume|compact",
|
|
23
|
+
"command": "${PAI_DIR}/Hooks/mcp-deferred-reminder.mjs",
|
|
24
|
+
"description": "Heal stale deferred MCP tool handles on resumed sessions: ToolSearch-and-retry instead of trusting a stale disconnect conclusion"
|
|
25
|
+
},
|
|
20
26
|
{
|
|
21
27
|
"event": "PreToolUse",
|
|
22
28
|
"matcher": "Bash",
|
|
23
29
|
"command": "${PAI_DIR}/Hooks/security-validator.mjs",
|
|
24
30
|
"description": "Validate shell commands against security rules before execution"
|
|
25
31
|
},
|
|
32
|
+
{
|
|
33
|
+
"event": "PreToolUse",
|
|
34
|
+
"matcher": "mcp__",
|
|
35
|
+
"command": "${PAI_DIR}/Hooks/mcp-deferred-gate.mjs",
|
|
36
|
+
"description": "Correct deferred MCP tool calls: ToolSearch remedy instead of a false 'MCP server disconnected' report"
|
|
37
|
+
},
|
|
26
38
|
{
|
|
27
39
|
"event": "Stop",
|
|
28
40
|
"command": "${PAI_DIR}/Hooks/stop-hook.mjs",
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
decideMcpGate,
|
|
4
|
+
isMcpTool,
|
|
5
|
+
lastToolSearchSurfacingIndex,
|
|
6
|
+
scanTranscriptEvidence,
|
|
7
|
+
type GateHookInput,
|
|
8
|
+
} from "./mcp-deferred-gate.js";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Fixtures — the transcript shapes that matter for the gate
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const TOOL = "mcp__aibroker__aibroker_rename";
|
|
15
|
+
|
|
16
|
+
function hookInput(toolName = TOOL): GateHookInput {
|
|
17
|
+
return {
|
|
18
|
+
session_id: "test-session",
|
|
19
|
+
transcript_path: "/tmp/nonexistent-transcript.jsonl",
|
|
20
|
+
cwd: "/tmp/project",
|
|
21
|
+
tool_name: toolName,
|
|
22
|
+
tool_input: { from: "a", to: "b" },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A tool_use entry for `name` with the given input. */
|
|
27
|
+
function toolUseLine(id: string, name: string, input: unknown): string {
|
|
28
|
+
return JSON.stringify({
|
|
29
|
+
message: { role: "assistant", content: [{ type: "tool_use", id, name, input }] },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A tool_result for `id`; `errorText` marks it failed with that message. */
|
|
34
|
+
function toolResultLine(id: string, errorText?: string): string {
|
|
35
|
+
const block: Record<string, unknown> = {
|
|
36
|
+
type: "tool_result",
|
|
37
|
+
tool_use_id: id,
|
|
38
|
+
content: [{ type: "text", text: errorText ?? "ok" }],
|
|
39
|
+
};
|
|
40
|
+
if (errorText) block.is_error = true;
|
|
41
|
+
return JSON.stringify({ message: { role: "user", content: [block] } });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What the schema gate's rejection looks like in the transcript. */
|
|
45
|
+
const GATE_ERROR =
|
|
46
|
+
"InputValidationError: tool schema not loaded. The following deferred tools are " +
|
|
47
|
+
"available via ToolSearch. Their schemas are NOT loaded — calling them directly will " +
|
|
48
|
+
'fail. Use ToolSearch with query "select:mcp__aibroker__aibroker_rename" to load tool ' +
|
|
49
|
+
"schemas before calling them.";
|
|
50
|
+
|
|
51
|
+
/** What a mid-session re-registration's stale-handle rejection looks like (2026-09-18 incident). */
|
|
52
|
+
const STALE_ERROR = "Error: 411 deferred tools are no longer available: aibroker_rename";
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// isMcpTool
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
describe("isMcpTool", () => {
|
|
59
|
+
it("accepts mcp__server__tool names", () => {
|
|
60
|
+
expect(isMcpTool(TOOL)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("rejects core tools and bare mcp__ prefixes", () => {
|
|
64
|
+
expect(isMcpTool("Read")).toBe(false);
|
|
65
|
+
expect(isMcpTool("Bash")).toBe(false);
|
|
66
|
+
expect(isMcpTool("mcp__")).toBe(false);
|
|
67
|
+
expect(isMcpTool("mcp__server")).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// scanTranscriptEvidence
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
describe("scanTranscriptEvidence", () => {
|
|
76
|
+
it("sees a ToolSearch that surfaced the tool", () => {
|
|
77
|
+
const transcript = [
|
|
78
|
+
toolUseLine("t1", "ToolSearch", { query: `select:${TOOL}` }),
|
|
79
|
+
toolResultLine("t1"),
|
|
80
|
+
].join("\n");
|
|
81
|
+
expect(scanTranscriptEvidence(transcript, TOOL).surfacedByToolSearch).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("sees a prior successful call but not a gate-failed one", () => {
|
|
85
|
+
const failed = [
|
|
86
|
+
toolUseLine("t1", TOOL, { from: "a", to: "b" }),
|
|
87
|
+
toolResultLine("t1", GATE_ERROR),
|
|
88
|
+
].join("\n");
|
|
89
|
+
expect(scanTranscriptEvidence(failed, TOOL).succeededBefore).toBe(false);
|
|
90
|
+
|
|
91
|
+
const succeeded = [
|
|
92
|
+
toolUseLine("t1", TOOL, { from: "a", to: "b" }),
|
|
93
|
+
toolResultLine("t1"),
|
|
94
|
+
].join("\n");
|
|
95
|
+
expect(scanTranscriptEvidence(succeeded, TOOL).succeededBefore).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("ignores junk lines and empty input", () => {
|
|
99
|
+
expect(scanTranscriptEvidence("not json\n\n{broken", TOOL)).toEqual({
|
|
100
|
+
surfacedByToolSearch: false,
|
|
101
|
+
succeededBefore: false,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// decideMcpGate — (a) deferred/failure context gets the correction
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
describe("decideMcpGate: deferred and failed-call contexts", () => {
|
|
111
|
+
it("corrects a deferred tool with no transcript evidence (first attempt)", () => {
|
|
112
|
+
const decision = decideMcpGate(hookInput(), "");
|
|
113
|
+
expect(decision.action).toBe("correct");
|
|
114
|
+
|
|
115
|
+
const parsed = JSON.parse(decision.output);
|
|
116
|
+
expect(parsed.hookSpecificOutput.permissionDecision).toBe("deny");
|
|
117
|
+
const reason = parsed.hookSpecificOutput.permissionDecisionReason as string;
|
|
118
|
+
|
|
119
|
+
// The remedy: ToolSearch with the exact select: form of the full tool name.
|
|
120
|
+
expect(reason).toContain("ToolSearch");
|
|
121
|
+
expect(reason).toContain(`select:${TOOL}`);
|
|
122
|
+
// The misreport this hook exists to prevent, denied in plain words.
|
|
123
|
+
expect(reason.toLowerCase()).toContain("not disconnected");
|
|
124
|
+
expect(reason).toContain("do not report an outage");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("corrects the retry after a gate failure (observed incident shape)", () => {
|
|
128
|
+
const transcript = [
|
|
129
|
+
toolUseLine("t1", TOOL, { from: "a", to: "b" }),
|
|
130
|
+
toolResultLine("t1", GATE_ERROR),
|
|
131
|
+
].join("\n");
|
|
132
|
+
const decision = decideMcpGate(hookInput(), transcript);
|
|
133
|
+
|
|
134
|
+
expect(decision.action).toBe("correct");
|
|
135
|
+
const reason = JSON.parse(decision.output).hookSpecificOutput.permissionDecisionReason;
|
|
136
|
+
expect(reason).toContain(`select:${TOOL}`);
|
|
137
|
+
expect(reason.toLowerCase()).toContain("not disconnected");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("emits a countable observation for every correction", () => {
|
|
141
|
+
const decision = decideMcpGate(hookInput(), "");
|
|
142
|
+
expect(decision.observation).not.toBeNull();
|
|
143
|
+
expect(decision.observation?.type).toBe("decision");
|
|
144
|
+
expect(decision.observation?.tool_name).toBe(TOOL);
|
|
145
|
+
expect(decision.observation?.title).toContain("MCP deferred-tool gate");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// decideMcpGate — (a2) stale deferred-tool handles after a re-registration
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
describe("decideMcpGate: stale deferred-tool handles", () => {
|
|
154
|
+
it("corrects the retry after a re-registration invalidated the handle (2026-09-18 incident)", () => {
|
|
155
|
+
// The tool was surfaced by ToolSearch earlier — evidence would normally
|
|
156
|
+
// pass it through — but the handle is dead now, so the gate must deny.
|
|
157
|
+
const transcript = [
|
|
158
|
+
toolUseLine("t0", "ToolSearch", { query: `select:${TOOL}` }),
|
|
159
|
+
toolResultLine("t0"),
|
|
160
|
+
toolUseLine("t1", TOOL, { from: "a", to: "b" }),
|
|
161
|
+
toolResultLine("t1", STALE_ERROR),
|
|
162
|
+
].join("\n");
|
|
163
|
+
const decision = decideMcpGate(hookInput(), transcript);
|
|
164
|
+
|
|
165
|
+
expect(decision.action).toBe("correct");
|
|
166
|
+
const reason = JSON.parse(decision.output).hookSpecificOutput.permissionDecisionReason;
|
|
167
|
+
expect(reason).toContain("ToolSearch");
|
|
168
|
+
expect(reason).toContain(`select:${TOOL}`);
|
|
169
|
+
expect(reason.toLowerCase()).toContain("not disconnected");
|
|
170
|
+
expect(reason).toContain("do not report an outage");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("re-arms even when the tool ran successfully before the re-registration", () => {
|
|
174
|
+
const transcript = [
|
|
175
|
+
toolUseLine("t0", TOOL, { from: "a", to: "b" }),
|
|
176
|
+
toolResultLine("t0"),
|
|
177
|
+
toolUseLine("t1", TOOL, { from: "a", to: "b" }),
|
|
178
|
+
toolResultLine("t1", STALE_ERROR),
|
|
179
|
+
].join("\n");
|
|
180
|
+
expect(decideMcpGate(hookInput(), transcript).action).toBe("correct");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("passes once a ToolSearch after the invalidation reloaded the entry", () => {
|
|
184
|
+
const transcript = [
|
|
185
|
+
toolUseLine("t1", TOOL, { from: "a", to: "b" }),
|
|
186
|
+
toolResultLine("t1", STALE_ERROR),
|
|
187
|
+
toolUseLine("t2", "ToolSearch", { query: `select:${TOOL}` }),
|
|
188
|
+
toolResultLine("t2"),
|
|
189
|
+
].join("\n");
|
|
190
|
+
const decision = decideMcpGate(hookInput(), transcript);
|
|
191
|
+
expect(decision.action).toBe("pass");
|
|
192
|
+
expect(JSON.parse(decision.output).hookSpecificOutput.permissionDecision).toBe("allow");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("corrects when only the tool input carries the stale-handle text", () => {
|
|
196
|
+
const input = hookInput();
|
|
197
|
+
input.tool_input = { text: `411 ${"deferred tools are no longer available"}` };
|
|
198
|
+
expect(decideMcpGate(input, "").action).toBe("correct");
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("lastToolSearchSurfacingIndex", () => {
|
|
203
|
+
it("finds the last surfacing ToolSearch and returns -1 when absent", () => {
|
|
204
|
+
const transcript = [
|
|
205
|
+
toolUseLine("t1", TOOL, { from: "a", to: "b" }),
|
|
206
|
+
toolResultLine("t1", STALE_ERROR),
|
|
207
|
+
toolUseLine("t2", "ToolSearch", { query: `select:${TOOL}` }),
|
|
208
|
+
toolResultLine("t2"),
|
|
209
|
+
].join("\n");
|
|
210
|
+
// After the stale error, i.e. the remedy postdates the invalidation.
|
|
211
|
+
expect(lastToolSearchSurfacingIndex(transcript, TOOL)).toBeGreaterThan(
|
|
212
|
+
transcript.lastIndexOf("deferred tools are no longer available"),
|
|
213
|
+
);
|
|
214
|
+
expect(lastToolSearchSurfacingIndex("not json", TOOL)).toBe(-1);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// decideMcpGate — (b) normal contexts pass through untouched
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
describe("decideMcpGate: pass-through", () => {
|
|
223
|
+
it("passes through after ToolSearch surfaced the tool", () => {
|
|
224
|
+
const transcript = [
|
|
225
|
+
toolUseLine("t1", "ToolSearch", { query: `select:${TOOL}` }),
|
|
226
|
+
toolResultLine("t1"),
|
|
227
|
+
].join("\n");
|
|
228
|
+
const decision = decideMcpGate(hookInput(), transcript);
|
|
229
|
+
|
|
230
|
+
expect(decision.action).toBe("pass");
|
|
231
|
+
expect(decision.observation).toBeNull();
|
|
232
|
+
const parsed = JSON.parse(decision.output);
|
|
233
|
+
expect(parsed.hookSpecificOutput.permissionDecision).toBe("allow");
|
|
234
|
+
expect(decision.output).not.toContain("ToolSearch");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("passes through a tool that already ran successfully", () => {
|
|
238
|
+
const transcript = [
|
|
239
|
+
toolUseLine("t1", TOOL, { from: "a", to: "b" }),
|
|
240
|
+
toolResultLine("t1"),
|
|
241
|
+
].join("\n");
|
|
242
|
+
const decision = decideMcpGate(hookInput(), transcript);
|
|
243
|
+
|
|
244
|
+
expect(decision.action).toBe("pass");
|
|
245
|
+
expect(JSON.parse(decision.output).hookSpecificOutput.permissionDecision).toBe("allow");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("passes non-mcp tools through regardless of transcript", () => {
|
|
249
|
+
const decision = decideMcpGate(hookInput("Read"), "");
|
|
250
|
+
expect(decision.action).toBe("pass");
|
|
251
|
+
expect(decision.observation).toBeNull();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("passes on missing tool_name or junk input", () => {
|
|
255
|
+
expect(decideMcpGate({}, "").action).toBe("pass");
|
|
256
|
+
expect(decideMcpGate({ tool_name: "" }, "").action).toBe("pass");
|
|
257
|
+
});
|
|
258
|
+
});
|