toolgz 0.1.1 → 0.2.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/README.md +126 -21
- package/dist/index.d.ts +20 -1
- package/dist/index.js +124 -23
- package/dist/policy.generated.d.ts +64 -0
- package/dist/policy.generated.js +27 -0
- package/dist/render/index.js +14 -1
- package/dist/types.d.ts +52 -13
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<h1>toolgz</h1>
|
|
2
2
|
|
|
3
|
-
<p><strong>Your agent spends 30–
|
|
3
|
+
<p><strong>Your agent spends 30–70k tokens of context on tool definitions before the user types a word. toolgz gets ~80% of it back.</strong></p>
|
|
4
4
|
|
|
5
5
|
<p>
|
|
6
6
|
<a href="#measured-results">420-run cross-provider sweep</a> ·
|
|
@@ -51,24 +51,77 @@ if (r.kind === "call") await myDispatch(r.name, r.args); // real name, real ar
|
|
|
51
51
|
Four frontier models, seven strategies, five tool-selection tasks, 3 reps —
|
|
52
52
|
**420 runs** on the current sweep, 1,200+ across all rounds. Every raw per-run record is
|
|
53
53
|
committed in [`bench/results/`](bench/results/); recompute any figure with
|
|
54
|
-
`npx tsx bench/analyze-multi.ts
|
|
54
|
+
`npx tsx bench/analyze-multi.ts --sweep=<timestamp>`.
|
|
55
|
+
|
|
56
|
+
**The table below uses a synthetic catalogue** — 100 realistic-but-invented MCP-style
|
|
57
|
+
tools across 9 namespaces — because it lets us build deliberately confusable clusters
|
|
58
|
+
that a real catalogue may not contain. Two things follow from that, and the first is
|
|
59
|
+
uncomfortable:
|
|
60
|
+
|
|
61
|
+
- Synthetic naming can flatter a compression style. One map style measured −21% on
|
|
62
|
+
this fixture because every tool name carried a `namespace_op` prefix to factor out.
|
|
63
|
+
On real MCP tools, which mostly do not, the same style was worth −1%.
|
|
64
|
+
- Real catalogues are **bigger**, so these numbers understate the problem. A corpus of
|
|
65
|
+
**149 tools harvested from 14 live MCP servers** measures **68,494 prompt tokens**
|
|
66
|
+
uncompressed on `claude-opus-5` — more than twice the synthetic fixture, and about a
|
|
67
|
+
third of a 200k context window before the user types anything.
|
|
68
|
+
|
|
69
|
+
The real corpus is committed at [`bench/fixtures/real-mcp-tools.json`](bench/fixtures/real-mcp-tools.json)
|
|
70
|
+
with its own scenario suite (`--suite=real`), and it is the corpus of record for any
|
|
71
|
+
claim about real deployments.
|
|
55
72
|
|
|
56
73
|
<picture>
|
|
57
74
|
<source media="(prefers-color-scheme: dark)" srcset="docs/img/savings-dark.svg">
|
|
58
75
|
<img src="docs/img/savings-light.svg" alt="Prompt tokens saved versus uncompressed tool definitions, by compression level, for each of four providers">
|
|
59
76
|
</picture>
|
|
60
77
|
|
|
61
|
-
| Provider | Model | Tool block | Prompt tokens |
|
|
62
|
-
|
|
63
|
-
| Anthropic | `claude-opus-5` | 9,242 → **1,284** | 30,817 → **4,628** (
|
|
64
|
-
| xAI | `grok-4.5` | 6,421 → **775** | 17,522 → **2,663** (
|
|
65
|
-
| Google | `gemini-3.1-pro-preview` | 5,264 → **732** | 10,948 → **2,302** (
|
|
66
|
-
| OpenAI | `gpt-5.6-sol` | 2,752 → **573** | 7,694 → **2,196** (
|
|
78
|
+
| Provider | Model | Tool block | Prompt tokens | Latency | Tasks |
|
|
79
|
+
|---|---|---:|---:|---:|:-:|
|
|
80
|
+
| Anthropic | `claude-opus-5` | 9,242 → **1,284** | 30,817 → **4,628** (**−85%**) | 15.0s → **12.1s** | 15/15 |
|
|
81
|
+
| xAI | `grok-4.5` | 6,421 → **775** | 17,522 → **2,663** (**−85%**) | 6.1s → **4.6s** | 15/15 |
|
|
82
|
+
| Google | `gemini-3.1-pro-preview` | 5,264 → **732** | 10,948 → **2,302** (**−79%**) | 5.6s → **5.5s** | 15/15 |
|
|
83
|
+
| OpenAI | `gpt-5.6-sol` | 2,752 → **573** | 7,694 → **2,196** (**−71%**) | 6.8s → **5.6s** | 15/15 |
|
|
67
84
|
|
|
68
85
|
Reasoning is enabled on all four at high effort, so this is a like-for-like frontier
|
|
69
86
|
comparison. **60/60 tasks completed, zero hallucinated tool names, zero malformed
|
|
70
87
|
arguments** — and it is faster than uncompressed on every provider.
|
|
71
88
|
|
|
89
|
+
Recompute any figure with
|
|
90
|
+
`npx tsx bench/analyze-multi.ts --sweep=2026-07-25T19-19` against the raw per-run
|
|
91
|
+
records in [`bench/results/`](bench/results).
|
|
92
|
+
|
|
93
|
+
### What about cost?
|
|
94
|
+
|
|
95
|
+
**Cost is not the claim, and we deliberately do not lead with it.** Prompt caching
|
|
96
|
+
already makes tool tokens cheap. What caching does not do is give you the *room* back,
|
|
97
|
+
and the room is what you run out of.
|
|
98
|
+
|
|
99
|
+
Cost does usually fall as a side effect, by an amount that depends on your provider
|
|
100
|
+
and reasoning settings — and on one of four providers we measured, it does not fall at
|
|
101
|
+
all. On `gpt-5.6-sol` the uncompressed cost distribution is heavily right-skewed (mean
|
|
102
|
+
$0.0172, median $0.0052), so a few expensive runs make compression look break-even
|
|
103
|
+
while the *typical* run gets about 2.5× dearer. We used to publish a "−7% on OpenAI"
|
|
104
|
+
figure. It was a mean over a skewed distribution and we withdrew it.
|
|
105
|
+
|
|
106
|
+
If you do want to optimise the bill, it is one option — and it is a trade, not a
|
|
107
|
+
freebie:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
compress(myTools, { level: 3, model: "gpt-5.6-sol", objective: "cost" });
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
| Model | Median cost vs default |
|
|
114
|
+
|---|---:|
|
|
115
|
+
| `gpt-5.6-sol` | **−20.7%** |
|
|
116
|
+
| `gemini-3.1-pro-preview` | **−15.4%** |
|
|
117
|
+
| `claude-opus-5` | **−9.0%** |
|
|
118
|
+
| `grok-4.5` | **+13.2%** — so it is not enabled there |
|
|
119
|
+
|
|
120
|
+
From 432 runs, 36 per style per provider, on the real 149-tool corpus. **What you give
|
|
121
|
+
up:** a slightly larger cached map (+275 characters), and on `grok-4.5` a worse bill —
|
|
122
|
+
which is why that row keeps the default rather than the cost-optimised style. Omit
|
|
123
|
+
`objective` and you get the conservative default everywhere, unchanged.
|
|
124
|
+
|
|
72
125
|
### It does not make the model worse
|
|
73
126
|
|
|
74
127
|
That was the thing to disprove, and we tried hard to. The task suite is built from
|
|
@@ -86,26 +139,28 @@ problem and looks up what it needs. The default map style exists because of the
|
|
|
86
139
|
bare tool names failed on `grok-4.5` **deterministically**, 3 of 3 attempts on one scenario,
|
|
87
140
|
answering with zero tool calls and no error raised. Naming the required arguments fixed it.
|
|
88
141
|
|
|
89
|
-
###
|
|
142
|
+
### How we found the cost story, and got it wrong twice
|
|
90
143
|
|
|
91
144
|
<picture>
|
|
92
145
|
<source media="(prefers-color-scheme: dark)" srcset="docs/img/cost-dark.svg">
|
|
93
|
-
<img src="docs/img/cost-light.svg" alt="Prompt tokens and cost
|
|
146
|
+
<img src="docs/img/cost-light.svg" alt="Prompt tokens and cost, by compression level, for each of four providers">
|
|
94
147
|
</picture>
|
|
95
148
|
|
|
96
|
-
The first cross-provider sweep found cost going **up 15% on OpenAI** even while context
|
|
97
|
-
69%. The dispatcher was spending extra turns, and on a reasoning model every turn
|
|
98
|
-
fresh round of thinking.
|
|
149
|
+
The first cross-provider sweep found cost going **up 15% on OpenAI** even while context
|
|
150
|
+
fell 69%. The dispatcher was spending extra turns, and on a reasoning model every turn
|
|
151
|
+
pays for a fresh round of thinking.
|
|
99
152
|
|
|
100
|
-
So we captured the calls
|
|
101
|
-
|
|
102
|
-
sometimes call the map code as the tool name, and they sometimes pass arguments flat
|
|
103
|
-
of nested. Fixing all three
|
|
104
|
-
|
|
153
|
+
So we captured the calls being rejected instead of guessing, and found three bugs in
|
|
154
|
+
*this library*: models pass `query` to a parameter named `q` (14 of 18 rejections), they
|
|
155
|
+
sometimes call the map code as the tool name, and they sometimes pass arguments flat
|
|
156
|
+
instead of nested. Fixing all three drove malformed arguments to **zero on every
|
|
157
|
+
provider**. Later rounds found three more of the same kind — a namespace joined with a
|
|
158
|
+
dot, the lookup tool routed through the dispatcher — all shipped in 0.1.2.
|
|
105
159
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
160
|
+
That is the honest shape of this work: **most of the wins came from accepting what
|
|
161
|
+
models actually send, not from making the map smaller.** One extra turn is worth ~3,300
|
|
162
|
+
prompt tokens; the best encoding change available was worth ~550. Six map styles were
|
|
163
|
+
tried and removed in 0.2.0 because they were smaller and still worse.
|
|
109
164
|
|
|
110
165
|
---
|
|
111
166
|
|
|
@@ -168,6 +223,56 @@ trip. A test asserts that file matches the code, so it cannot drift.
|
|
|
168
223
|
|
|
169
224
|
---
|
|
170
225
|
|
|
226
|
+
## Optional: let the library pick the map style for your model
|
|
227
|
+
|
|
228
|
+
Level 3 has several map styles. Which one is cheapest turns out to depend on the
|
|
229
|
+
model, so you can hand `compress()` a model id and let it use what was actually
|
|
230
|
+
measured:
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
compress(myTools, { level: 3, model: "gpt-5.6-sol", objective: "cost" });
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
| Model | Style chosen for `cost` | Measured against the default |
|
|
237
|
+
|---|---|---:|
|
|
238
|
+
| `gpt-5.6-sol` | `explicit` | **−20.7%** |
|
|
239
|
+
| `gemini-3.1-pro-preview` | `explicit` | **−15.4%** |
|
|
240
|
+
| `claude-opus-5` | `explicit` | **−9.0%** |
|
|
241
|
+
| `grok-4.5` | *default* | `explicit` measured **+13.2%** there |
|
|
242
|
+
|
|
243
|
+
From a 432-run sweep, 36 runs per style per provider, on the real 149-tool corpus.
|
|
244
|
+
`explicit` completed 144/144 tasks and cut lookups on all four providers; only the
|
|
245
|
+
*cost* consequence differs by model. The table lives in
|
|
246
|
+
[`src/policy.generated.ts`](src/policy.generated.ts), is generated from the committed
|
|
247
|
+
results, and a test fails if it drifts from them.
|
|
248
|
+
|
|
249
|
+
Four things worth knowing:
|
|
250
|
+
|
|
251
|
+
- **Omitting `model` changes nothing.** Existing behaviour is byte-identical; there
|
|
252
|
+
is a test asserting that.
|
|
253
|
+
- **`objective` defaults to `occupancy`, which has no table.** Every style we
|
|
254
|
+
measured landed within ±3.1% of the default on context occupancy — under our 5%
|
|
255
|
+
effect-size floor — so there is nothing to select. Only `cost` has entries.
|
|
256
|
+
- **An absent model gets the default.** That is an absence of evidence, not a
|
|
257
|
+
prediction. `gpt-5.6-sol` behaving one way says nothing certain about `gpt-5.7`.
|
|
258
|
+
- **`stats` always tells you what was actually used**, so nothing is substituted
|
|
259
|
+
silently:
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
const c = compress(myTools, { level: 3, model: "gpt-5.6-sol", objective: "cost" });
|
|
263
|
+
c.stats.mapStyle; // "explicit" — what was used
|
|
264
|
+
c.stats.requestedMapStyle; // undefined — you did not ask for a specific style
|
|
265
|
+
c.stats.fallbackReason; // undefined — nothing was substituted
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
There is also a safety valve: if a future sweep finds a `(model, style)` pair that
|
|
269
|
+
fails, it is refused and `stats.fallbackReason` says why. **That table is currently
|
|
270
|
+
empty** — the one pair ever measured unsafe was `nocode` on `grok-4.5` (19% of runs
|
|
271
|
+
answered with no tool call at all), and rather than document a footgun we deleted the
|
|
272
|
+
style in 0.2.0.
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
171
276
|
## Documentation
|
|
172
277
|
|
|
173
278
|
| | |
|
package/dist/index.d.ts
CHANGED
|
@@ -13,9 +13,28 @@
|
|
|
13
13
|
* if (r.kind === "call") await myDispatch(r.name, r.args);
|
|
14
14
|
* }
|
|
15
15
|
*/
|
|
16
|
-
import type { CompressOptions, CompressResult, Tool } from "./types.js";
|
|
16
|
+
import type { CompressOptions, CompressResult, Tool, MapStyle } from "./types.js";
|
|
17
|
+
import type { PolicyEntry, BrokenEntry } from "./policy.generated.js";
|
|
17
18
|
export * from "./types.js";
|
|
18
19
|
export { flattenSchema, signatureLine, countSchemaTokensApprox } from "./render/index.js";
|
|
20
|
+
export { POLICY, BROKEN, CONSERVATIVE_DEFAULT } from "./policy.generated.js";
|
|
21
|
+
export type { PolicyEntry, BrokenEntry, Objective } from "./policy.generated.js";
|
|
22
|
+
/**
|
|
23
|
+
* Choose the level-3 map style, and report anything it had to substitute.
|
|
24
|
+
*
|
|
25
|
+
* Pure and exported so the disallow path is testable against synthetic tables. It
|
|
26
|
+
* used to be inline in `compress()`, which meant it could only be exercised through
|
|
27
|
+
* a real BROKEN entry — and once the one unsafe style was removed from the library
|
|
28
|
+
* there were none, leaving the safety valve untested.
|
|
29
|
+
*
|
|
30
|
+
* Order: an explicit request wins unless measured unsafe for that model; then the
|
|
31
|
+
* measured policy for (model, objective); then the conservative default.
|
|
32
|
+
*/
|
|
33
|
+
export declare function selectMapStyle(options: Pick<CompressOptions, "mapStyle" | "model" | "objective">, policy?: readonly PolicyEntry[], broken?: readonly BrokenEntry[]): {
|
|
34
|
+
mapStyle: MapStyle;
|
|
35
|
+
requestedMapStyle?: MapStyle;
|
|
36
|
+
fallbackReason?: string;
|
|
37
|
+
};
|
|
19
38
|
export declare function compress(input: Tool[], options?: CompressOptions): CompressResult;
|
|
20
39
|
export { recommendLevel } from "./recommend.js";
|
|
21
40
|
export type { Recommendation } from "./recommend.js";
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import { countSchemaTokensApprox, defaultNamespaceOf, firstSentence, flattenSchema, normalize, signatureLine,
|
|
1
|
+
import { countSchemaTokensApprox, defaultNamespaceOf, firstSentence, flattenSchema, normalize, signatureLine, } from "./render/index.js";
|
|
2
2
|
import { validateArgs } from "./runtime/validate.js";
|
|
3
|
+
import { nearest } from "./runtime/similar.js";
|
|
4
|
+
import { POLICY, BROKEN, CONSERVATIVE_DEFAULT } from "./policy.generated.js";
|
|
3
5
|
export * from "./types.js";
|
|
4
6
|
export { flattenSchema, signatureLine, countSchemaTokensApprox } from "./render/index.js";
|
|
7
|
+
// Exported so consumers can see WHY a style was chosen. The generated file is not in
|
|
8
|
+
// the published tarball (only dist/ ships), so without this the policy is a black box
|
|
9
|
+
// and the README could point at something a user cannot read.
|
|
10
|
+
export { POLICY, BROKEN, CONSERVATIVE_DEFAULT } from "./policy.generated.js";
|
|
5
11
|
const CODE_CHARS = "abcdefghijklmnopqrstuvwxyz";
|
|
6
12
|
const LEVELS = [0, 1, 2, 3];
|
|
7
|
-
const MAP_STYLES = ["name
|
|
13
|
+
const MAP_STYLES = ["name+required", "explicit", "signature"];
|
|
8
14
|
const err = (message, recoverable = true) => ({
|
|
9
15
|
kind: "error",
|
|
10
16
|
message,
|
|
@@ -29,6 +35,41 @@ function asObject(v) {
|
|
|
29
35
|
function defaultAlias(ns) {
|
|
30
36
|
return ns;
|
|
31
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Choose the level-3 map style, and report anything it had to substitute.
|
|
40
|
+
*
|
|
41
|
+
* Pure and exported so the disallow path is testable against synthetic tables. It
|
|
42
|
+
* used to be inline in `compress()`, which meant it could only be exercised through
|
|
43
|
+
* a real BROKEN entry — and once the one unsafe style was removed from the library
|
|
44
|
+
* there were none, leaving the safety valve untested.
|
|
45
|
+
*
|
|
46
|
+
* Order: an explicit request wins unless measured unsafe for that model; then the
|
|
47
|
+
* measured policy for (model, objective); then the conservative default.
|
|
48
|
+
*/
|
|
49
|
+
export function selectMapStyle(options, policy = POLICY, broken = BROKEN) {
|
|
50
|
+
const objective = options.objective ?? "occupancy";
|
|
51
|
+
if (options.mapStyle) {
|
|
52
|
+
const unsafe = options.model
|
|
53
|
+
? broken.find((b) => b.model === options.model && b.mapStyle === options.mapStyle)
|
|
54
|
+
: undefined;
|
|
55
|
+
if (!unsafe)
|
|
56
|
+
return { mapStyle: options.mapStyle };
|
|
57
|
+
// Owner decision: disallow a measured-unsafe pair and fall back, rather than
|
|
58
|
+
// honour it with a warning. Reported, never silent.
|
|
59
|
+
return {
|
|
60
|
+
mapStyle: CONSERVATIVE_DEFAULT,
|
|
61
|
+
requestedMapStyle: options.mapStyle,
|
|
62
|
+
fallbackReason: `mapStyle "${options.mapStyle}" is measured unsafe on ${unsafe.model}: ` +
|
|
63
|
+
`${unsafe.reason} (n=${unsafe.n}, sweep ${unsafe.sweep}). ` +
|
|
64
|
+
`Using "${CONSERVATIVE_DEFAULT}" instead.`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (options.model) {
|
|
68
|
+
const hit = policy.find((e) => e.model === options.model && e.objective === objective);
|
|
69
|
+
return { mapStyle: hit ? hit.mapStyle : CONSERVATIVE_DEFAULT };
|
|
70
|
+
}
|
|
71
|
+
return { mapStyle: CONSERVATIVE_DEFAULT };
|
|
72
|
+
}
|
|
32
73
|
export function compress(input, options = {}) {
|
|
33
74
|
const level = (options.level ?? 1);
|
|
34
75
|
if (!LEVELS.includes(level)) {
|
|
@@ -43,7 +84,7 @@ export function compress(input, options = {}) {
|
|
|
43
84
|
// 60/60 tasks, fewer malformed arguments, fewer lookup round-trips, and
|
|
44
85
|
// faster wall-clock than uncompressed on every provider. The larger map pays
|
|
45
86
|
// for itself by removing a discovery turn.
|
|
46
|
-
const mapStyle = options
|
|
87
|
+
const { mapStyle, requestedMapStyle, fallbackReason, } = selectMapStyle(options, POLICY, BROKEN);
|
|
47
88
|
if (!MAP_STYLES.includes(mapStyle)) {
|
|
48
89
|
throw new Error(`unsupported mapStyle: ${mapStyle} (expected ${MAP_STYLES.join(", ")})`);
|
|
49
90
|
}
|
|
@@ -79,6 +120,44 @@ export function compress(input, options = {}) {
|
|
|
79
120
|
});
|
|
80
121
|
}
|
|
81
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Separator-insensitive lookup for level-3 map keys.
|
|
125
|
+
*
|
|
126
|
+
* Observed on gpt-5.6-sol against real MCP tools: the model reassembled a
|
|
127
|
+
* namespaced name with a DOT where the real tool uses an underscore —
|
|
128
|
+
* `gdrive.sheets_append_rows`, `coding.task_result`, `reverse.geocode`. Joining a
|
|
129
|
+
* namespace and an operation with `.` is the ordinary convention for qualified
|
|
130
|
+
* identifiers, so the inference is reasonable and ours was simply too strict.
|
|
131
|
+
* Rejecting it burned six turns per task.
|
|
132
|
+
*
|
|
133
|
+
* Keys are therefore compared with every separator stripped, which accepts `.`,
|
|
134
|
+
* `:`, `/`, `-`, a space, or nothing. Registered ONLY where the normalised form
|
|
135
|
+
* is unambiguous: if two tools normalise alike neither is aliased and the exact
|
|
136
|
+
* name is required, so this can never silently dispatch the wrong tool.
|
|
137
|
+
*/
|
|
138
|
+
const normKey = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
139
|
+
const normIndex = new Map();
|
|
140
|
+
const registerNorm = (key, t) => {
|
|
141
|
+
const k = normKey(key);
|
|
142
|
+
if (!k)
|
|
143
|
+
return;
|
|
144
|
+
const seen = normIndex.get(k);
|
|
145
|
+
if (seen === undefined)
|
|
146
|
+
normIndex.set(k, t); // first claim
|
|
147
|
+
else if (seen && seen.name !== t.name)
|
|
148
|
+
normIndex.set(k, null); // ambiguous
|
|
149
|
+
};
|
|
150
|
+
if (level === 3) {
|
|
151
|
+
for (const t of tools)
|
|
152
|
+
registerNorm(t.name, t);
|
|
153
|
+
for (const [code, t] of codeToTool)
|
|
154
|
+
registerNorm(code, t);
|
|
155
|
+
}
|
|
156
|
+
/** Exact map key first, then the separator-insensitive fallback. */
|
|
157
|
+
const lookupMapKey = (raw) => {
|
|
158
|
+
const key = String(raw ?? "");
|
|
159
|
+
return codeToTool.get(key) ?? normIndex.get(normKey(key)) ?? undefined;
|
|
160
|
+
};
|
|
82
161
|
const finish = (wire, systemPreamble, cachePreamble, resolve, encode) => {
|
|
83
162
|
const compressedChars = countSchemaTokensApprox(wire) + systemPreamble.length;
|
|
84
163
|
return {
|
|
@@ -95,6 +174,9 @@ export function compress(input, options = {}) {
|
|
|
95
174
|
encodeCallForTest: encode,
|
|
96
175
|
stats: {
|
|
97
176
|
level,
|
|
177
|
+
mapStyle,
|
|
178
|
+
requestedMapStyle: requestedMapStyle && requestedMapStyle !== mapStyle ? requestedMapStyle : undefined,
|
|
179
|
+
fallbackReason,
|
|
98
180
|
toolCount: tools.length,
|
|
99
181
|
wireToolCount: wire.length,
|
|
100
182
|
originalChars,
|
|
@@ -217,16 +299,16 @@ export function compress(input, options = {}) {
|
|
|
217
299
|
// against a full schema's ~400 — to reduce malformed arguments on models that
|
|
218
300
|
// fill the generic argument bag poorly.
|
|
219
301
|
const renderLine = (code, t) => {
|
|
220
|
-
|
|
221
|
-
return `${code} ${terseDescriptor(t.description) || t.name}`;
|
|
302
|
+
const req = t.schema.required ?? [];
|
|
222
303
|
// Full signature: optional params included, so the model rarely needs q().
|
|
223
304
|
if (mapStyle === "signature")
|
|
224
305
|
return `${code} ${signatureLine(t)}`;
|
|
225
|
-
if (
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
306
|
+
if (req.length)
|
|
307
|
+
return `${code} ${t.name} ${req.join(",")}`;
|
|
308
|
+
// No required parameters. `explicit` says so, because a bare name is
|
|
309
|
+
// indistinguishable from a tool whose parameters were omitted, and the model
|
|
310
|
+
// spends a lookup finding out it could have just called it.
|
|
311
|
+
return mapStyle === "explicit" ? `${code} ${t.name} ()` : `${code} ${t.name}`;
|
|
230
312
|
};
|
|
231
313
|
const lines = [...codeToTool.entries()].map(([code, t]) => renderLine(code, t));
|
|
232
314
|
const wire = [
|
|
@@ -251,17 +333,32 @@ export function compress(input, options = {}) {
|
|
|
251
333
|
},
|
|
252
334
|
},
|
|
253
335
|
];
|
|
254
|
-
const mapLegend = mapStyle === "
|
|
255
|
-
? "Each line is: code name
|
|
256
|
-
: mapStyle === "
|
|
257
|
-
? "Each line is: code name(
|
|
258
|
-
: "";
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
336
|
+
const mapLegend = mapStyle === "signature"
|
|
337
|
+
? "Each line is: code name(args), where ? marks optional. "
|
|
338
|
+
: mapStyle === "explicit"
|
|
339
|
+
? "Each line is: code name required-args. A line ending in () takes no required arguments and can be called with none. "
|
|
340
|
+
: "Each line is: code name required-args. ";
|
|
341
|
+
const invokeHint = "Invoke with t(f=<code>, a={…}). Use q to expand a code before calling if you are unsure of its parameters.";
|
|
342
|
+
const systemPreamble = `<toolmap>\n${lines.join("\n")}\n</toolmap>\n${mapLegend}${invokeHint}`;
|
|
343
|
+
return finish(wire, systemPreamble, true, (rawName, rawArgs) => {
|
|
344
|
+
let name = rawName;
|
|
345
|
+
let args = asObject(rawArgs);
|
|
346
|
+
// Observed on grok-4.5, on every level-3 map style: the model routes the
|
|
347
|
+
// lookup tool through the dispatcher — t(f="q", a={s:"…"}) instead of
|
|
348
|
+
// q(s="…"). The preamble invites it, saying "Invoke with t(f=<code>, a={…})"
|
|
349
|
+
// and then "Use q to expand a code", which reads as everything going through
|
|
350
|
+
// t. Tasks still completed, so this surfaced as wasted turns rather than as
|
|
351
|
+
// failure. `t` and `q` are our own reserved names, so the intent is
|
|
352
|
+
// unambiguous — nothing else could be meant.
|
|
353
|
+
if (name === "t" && (args.f === "q" || args.f === "t")) {
|
|
354
|
+
const nested = asObject(args.a);
|
|
355
|
+
const flat = Object.fromEntries(Object.entries(args).filter(([k]) => k !== "f" && k !== "a"));
|
|
356
|
+
name = String(args.f);
|
|
357
|
+
args = Object.keys(nested).length ? nested : flat;
|
|
358
|
+
}
|
|
262
359
|
if (name === "q") {
|
|
263
360
|
if (args.c !== undefined) {
|
|
264
|
-
const t =
|
|
361
|
+
const t = lookupMapKey(args.c);
|
|
265
362
|
if (!t)
|
|
266
363
|
return err(`No map code "${args.c}". Search with q(s=…).`);
|
|
267
364
|
return {
|
|
@@ -286,13 +383,17 @@ export function compress(input, options = {}) {
|
|
|
286
383
|
// claude-opus-5: name="b5", args={f:"b5", a:"{…}"}. Codes are unique and
|
|
287
384
|
// cannot collide with `t` or `q`, so this form is unambiguous and
|
|
288
385
|
// accepting it removes a whole class of wasted turn.
|
|
289
|
-
const viaCode = name !== "t" && name !== "q" ?
|
|
386
|
+
const viaCode = name !== "t" && name !== "q" ? lookupMapKey(name) : undefined;
|
|
290
387
|
if (!viaCode && name !== "t") {
|
|
291
388
|
return err(`No tool named "${name}". Invoke tools with t(f=<code>).`);
|
|
292
389
|
}
|
|
293
|
-
const t = viaCode ??
|
|
294
|
-
if (!t)
|
|
295
|
-
|
|
390
|
+
const t = viaCode ?? lookupMapKey(args.f);
|
|
391
|
+
if (!t) {
|
|
392
|
+
// A near miss is the common case, and a bare rejection costs another turn.
|
|
393
|
+
const guess = nearest(String(args.f ?? ""), [...codeToTool.keys()]);
|
|
394
|
+
return err(`No map code "${args.f}". Search with q(s=…).` +
|
|
395
|
+
(guess ? ` Did you mean "${guess}"? Use it exactly as written in <toolmap>.` : ""));
|
|
396
|
+
}
|
|
296
397
|
// Args may arrive nested under `a`, or flat alongside `f`. Prefer `a`
|
|
297
398
|
// when present rather than merging, so there is one source of truth.
|
|
298
399
|
const nested = asObject(args.a);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED — do not edit by hand. Regenerate with:
|
|
3
|
+
* npx tsx bench/generate-policy.ts --sweep=<timestamp>
|
|
4
|
+
*
|
|
5
|
+
* A record of measurements, not a theory of models. Every row was produced by
|
|
6
|
+
* `bench/analyze-multi.ts` scoped to one sweep, compared within a provider, and
|
|
7
|
+
* filtered to effects of at least 5% — same-arm figures moved by more than that
|
|
8
|
+
* between n=4 and n=96 during development, so smaller effects are noise.
|
|
9
|
+
*
|
|
10
|
+
* Hand-maintaining this would rot exactly the way `bench/` and `src/` diverged
|
|
11
|
+
* before. A drift test (tests/policy.test.ts) fails if it disagrees with the
|
|
12
|
+
* committed results.
|
|
13
|
+
*
|
|
14
|
+
* There is deliberately NO occupancy table. Every measured difference on that axis
|
|
15
|
+
* was within ±3.1%, under the floor — so occupancy gets the default, and this file
|
|
16
|
+
* exists only for the `cost` objective. That is a smaller feature than spec 002
|
|
17
|
+
* originally described, and it is what the data supports.
|
|
18
|
+
*/
|
|
19
|
+
import type { MapStyle } from "./types.js";
|
|
20
|
+
export type Objective = "occupancy" | "cost";
|
|
21
|
+
export type PolicyEntry = {
|
|
22
|
+
/** Exact model id. Never a family: gpt-5.6-sol says nothing about gpt-5.7. */
|
|
23
|
+
model: string;
|
|
24
|
+
objective: Objective;
|
|
25
|
+
mapStyle: MapStyle;
|
|
26
|
+
/** Percent change against the conservative default. Negative is better. */
|
|
27
|
+
effectPct: number;
|
|
28
|
+
/** Runs behind this row, per arm. */
|
|
29
|
+
n: number;
|
|
30
|
+
/** The sweep it came from, so any row can be re-derived. */
|
|
31
|
+
sweep: string;
|
|
32
|
+
measured: string;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Tier-3 sweep, 432 runs, 36 per arm per provider, 12 real MCP scenarios × 3 reps.
|
|
36
|
+
* `explicit` was 144/144 tasks; turns and lookups fell on all four providers.
|
|
37
|
+
* Only rows clearing the 5% floor appear.
|
|
38
|
+
*/
|
|
39
|
+
export declare const POLICY: readonly PolicyEntry[];
|
|
40
|
+
/**
|
|
41
|
+
* (model, mapStyle) pairs measured as unsafe. A caller asking for one is refused and
|
|
42
|
+
* falls back, per the owner's decision (#15) to disallow rather than warn.
|
|
43
|
+
*/
|
|
44
|
+
export type BrokenEntry = {
|
|
45
|
+
model: string;
|
|
46
|
+
mapStyle: MapStyle;
|
|
47
|
+
reason: string;
|
|
48
|
+
n: number;
|
|
49
|
+
sweep: string;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Empty, and that is the current truth rather than an oversight.
|
|
53
|
+
*
|
|
54
|
+
* The one pair ever measured unsafe was `nocode` on grok-4.5 — a 19% silent
|
|
55
|
+
* failure rate, the model answering with no tool call at all. In 0.2.0 that style
|
|
56
|
+
* was removed from the library outright, so the pair can no longer be requested.
|
|
57
|
+
* Deleting a footgun beats documenting it.
|
|
58
|
+
*
|
|
59
|
+
* The mechanism stays because it is the safety valve: if a future sweep finds a
|
|
60
|
+
* (model, style) pair that fails, it goes here and is refused at the boundary.
|
|
61
|
+
*/
|
|
62
|
+
export declare const BROKEN: readonly BrokenEntry[];
|
|
63
|
+
/** The style used when nothing is known about the model. */
|
|
64
|
+
export declare const CONSERVATIVE_DEFAULT: MapStyle;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier-3 sweep, 432 runs, 36 per arm per provider, 12 real MCP scenarios × 3 reps.
|
|
3
|
+
* `explicit` was 144/144 tasks; turns and lookups fell on all four providers.
|
|
4
|
+
* Only rows clearing the 5% floor appear.
|
|
5
|
+
*/
|
|
6
|
+
export const POLICY = [
|
|
7
|
+
{ model: "claude-opus-5", objective: "cost", mapStyle: "explicit", effectPct: -9.0, n: 36, sweep: "2026-07-26T03-07-25", measured: "2026-07-26" },
|
|
8
|
+
{ model: "gemini-3.1-pro-preview", objective: "cost", mapStyle: "explicit", effectPct: -15.4, n: 36, sweep: "2026-07-26T03-07-25", measured: "2026-07-26" },
|
|
9
|
+
{ model: "gpt-5.6-sol", objective: "cost", mapStyle: "explicit", effectPct: -20.7, n: 36, sweep: "2026-07-26T03-07-25", measured: "2026-07-26" },
|
|
10
|
+
// grok-4.5 is deliberately absent for `cost`: explicit measured +13.2% there, so it
|
|
11
|
+
// falls through to the conservative default. An absent row means "no measured
|
|
12
|
+
// improvement", never "untested".
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* Empty, and that is the current truth rather than an oversight.
|
|
16
|
+
*
|
|
17
|
+
* The one pair ever measured unsafe was `nocode` on grok-4.5 — a 19% silent
|
|
18
|
+
* failure rate, the model answering with no tool call at all. In 0.2.0 that style
|
|
19
|
+
* was removed from the library outright, so the pair can no longer be requested.
|
|
20
|
+
* Deleting a footgun beats documenting it.
|
|
21
|
+
*
|
|
22
|
+
* The mechanism stays because it is the safety valve: if a future sweep finds a
|
|
23
|
+
* (model, style) pair that fails, it goes here and is refused at the boundary.
|
|
24
|
+
*/
|
|
25
|
+
export const BROKEN = [];
|
|
26
|
+
/** The style used when nothing is known about the model. */
|
|
27
|
+
export const CONSERVATIVE_DEFAULT = "name+required";
|
package/dist/render/index.js
CHANGED
|
@@ -128,7 +128,20 @@ export function normalize(tools, namespaceOf) {
|
|
|
128
128
|
if (seen.has(t.name))
|
|
129
129
|
throw new Error(`duplicate tool name: ${t.name}`);
|
|
130
130
|
seen.add(t.name);
|
|
131
|
-
|
|
131
|
+
// Validate the callback's return rather than trusting it. The contract is
|
|
132
|
+
// easy to get wrong — it takes a name and returns {ns, op}, not a bare
|
|
133
|
+
// namespace string — and the failure was silent and remote: every tool
|
|
134
|
+
// collapsed into one `undefined` namespace, level 2 emitted a wire tool with
|
|
135
|
+
// an empty name, and the first symptom was the provider rejecting the request
|
|
136
|
+
// with "tools.0.custom.name: Field required", nowhere near the cause.
|
|
137
|
+
const parts = namespaceOf(t.name);
|
|
138
|
+
const ns = parts?.ns;
|
|
139
|
+
const op = parts?.op;
|
|
140
|
+
if (typeof ns !== "string" || !ns || typeof op !== "string" || !op) {
|
|
141
|
+
throw new Error(`namespaceOf("${t.name}") must return { ns, op } with non-empty strings, ` +
|
|
142
|
+
`got ${JSON.stringify(parts)}. ` +
|
|
143
|
+
`Example: (name) => ({ ns: serverOf(name), op: name }).`);
|
|
144
|
+
}
|
|
132
145
|
return {
|
|
133
146
|
name: t.name,
|
|
134
147
|
description: t.description ?? "",
|
package/dist/types.d.ts
CHANGED
|
@@ -34,27 +34,41 @@ export type NormalizedTool = {
|
|
|
34
34
|
* 3 minified — single dispatcher + opaque codes
|
|
35
35
|
*/
|
|
36
36
|
export type Level = 0 | 1 | 2 | 3;
|
|
37
|
+
import type { Objective } from "./policy.generated.js";
|
|
38
|
+
export type { Objective };
|
|
37
39
|
/**
|
|
38
40
|
* How each line of the level-3 `<toolmap>` is rendered.
|
|
39
41
|
*
|
|
40
|
-
* name
|
|
41
|
-
*
|
|
42
|
+
* name+required `a0 github_create_issue owner,repo,title` <- default
|
|
43
|
+
* explicit `a0 github_create_issue owner,repo,title`
|
|
44
|
+
* `a0 scorecard_lf_daily ()` <- () = takes no required args
|
|
42
45
|
* signature `a0 github_create_issue(owner,repo,title,body?,labels?)`
|
|
43
|
-
* terse `a0 create new issue in repository`
|
|
44
46
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
47
|
+
* Three styles, each with a measured reason to exist. Six others were tried and
|
|
48
|
+
* removed in 0.2.0 because they made things worse — see docs/RESULTS.md Round 6.
|
|
49
|
+
*
|
|
50
|
+
* `name+required` is the default: the only style perfect on all four providers with
|
|
51
|
+
* zero malformed arguments. It names required parameters because the dispatcher
|
|
52
|
+
* levels give up provider-side constrained decoding, and a few tokens per tool buys
|
|
53
|
+
* some of it back.
|
|
54
|
+
*
|
|
55
|
+
* `explicit` adds one thing: a `()` marker on tools that declare no required
|
|
56
|
+
* parameters. On a real catalogue 44% of tools are like that, and their line would
|
|
57
|
+
* otherwise be a bare name — indistinguishable from a tool whose parameters were
|
|
58
|
+
* omitted, so the model spends a lookup finding out it could just call it. Measured
|
|
59
|
+
* over 432 runs it cut lookups on all four providers and cost 9–21% less on three,
|
|
60
|
+
* but **13% more on grok-4.5**. Reach for it via `objective: "cost"` rather than
|
|
61
|
+
* setting it by hand.
|
|
62
|
+
*
|
|
63
|
+
* `signature` names optional parameters too, which removes most remaining lookups.
|
|
64
|
+
* A bigger cached map traded for fewer turns — worth it where every turn pays for a
|
|
65
|
+
* fresh round of reasoning, and measurably worse on xAI for reasons we cannot yet
|
|
66
|
+
* explain.
|
|
53
67
|
*/
|
|
54
|
-
export type MapStyle = "name
|
|
68
|
+
export type MapStyle = "name+required" | "explicit" | "signature";
|
|
55
69
|
export type CompressOptions = {
|
|
56
70
|
level?: Level;
|
|
57
|
-
/** Level 3 only. Ignored at levels 0–2, which emit no map. Default "name". */
|
|
71
|
+
/** Level 3 only. Ignored at levels 0–2, which emit no map. Default "name+required". */
|
|
58
72
|
mapStyle?: MapStyle;
|
|
59
73
|
/**
|
|
60
74
|
* Group tools into namespaces. Default splits on the first `_` or `.`,
|
|
@@ -70,6 +84,25 @@ export type CompressOptions = {
|
|
|
70
84
|
searchLimit?: number;
|
|
71
85
|
/** Validate arguments against the original schema before dispatch. Default true. */
|
|
72
86
|
validate?: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Exact model id, e.g. "gpt-5.6-sol". When given, level 3 selects the best
|
|
89
|
+
* *measured* map style for that model from `src/policy.generated.ts`.
|
|
90
|
+
*
|
|
91
|
+
* Never a family: `gpt-5.6-sol` failing says nothing certain about `gpt-5.7`. An
|
|
92
|
+
* unknown model gets the conservative default — an absence of evidence, not a
|
|
93
|
+
* prediction. Behaviour is unchanged if you omit this.
|
|
94
|
+
*/
|
|
95
|
+
model?: string;
|
|
96
|
+
/**
|
|
97
|
+
* What the selection optimises. Default "occupancy", because reclaiming context
|
|
98
|
+
* window is the point — prompt caching already makes tool tokens cheap but does not
|
|
99
|
+
* reclaim the room they take.
|
|
100
|
+
*
|
|
101
|
+
* As measured, "occupancy" currently selects the default on every known model: no
|
|
102
|
+
* style beat it by more than 3.1% on that axis, under the 5% floor. The table has
|
|
103
|
+
* real entries only for "cost".
|
|
104
|
+
*/
|
|
105
|
+
objective?: Objective;
|
|
73
106
|
};
|
|
74
107
|
export type Resolution = {
|
|
75
108
|
kind: "call";
|
|
@@ -86,6 +119,12 @@ export type Resolution = {
|
|
|
86
119
|
};
|
|
87
120
|
export type CompressStats = {
|
|
88
121
|
level: Level;
|
|
122
|
+
/** The map style actually used. */
|
|
123
|
+
mapStyle?: MapStyle;
|
|
124
|
+
/** What the caller asked for, when it differed from what was used. */
|
|
125
|
+
requestedMapStyle?: MapStyle;
|
|
126
|
+
/** Why a requested style was not used. Absent when nothing was substituted. */
|
|
127
|
+
fallbackReason?: string;
|
|
89
128
|
toolCount: number;
|
|
90
129
|
wireToolCount: number;
|
|
91
130
|
originalChars: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolgz",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Compress LLM tool definitions to reclaim context window. Measured across Anthropic, OpenAI, Google and xAI.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
"bench": "tsx bench/harness/run.ts",
|
|
10
10
|
"build": "tsc",
|
|
11
11
|
"prepublishOnly": "npm run build && npm test",
|
|
12
|
-
"test:live": "vitest run tests/integration"
|
|
12
|
+
"test:live": "vitest run tests/integration",
|
|
13
|
+
"bench:tier1": "tsx -r dotenv/config bench/harness/run-multi.ts --provider=all --suite=real --variants --reps=1 --scenario=real-daily-vs-weekly,real-overwrite-vs-append,real-status-vs-result,real-rollup-vs-detail",
|
|
14
|
+
"bench:tier2": "tsx -r dotenv/config bench/harness/run-multi.ts --provider=all --suite=real --variants --reps=2 --scenario=real-daily-vs-weekly,real-overwrite-vs-append,real-status-vs-result,real-rollup-vs-detail,real-two-step-scheduling,real-summary-vs-details",
|
|
15
|
+
"bench:tier3": "tsx -r dotenv/config bench/harness/run-multi.ts --provider=all --suite=real --variants --reps=3"
|
|
13
16
|
},
|
|
14
17
|
"keywords": [
|
|
15
18
|
"llm",
|