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 CHANGED
@@ -1,6 +1,6 @@
1
1
  <h1>toolgz</h1>
2
2
 
3
- <p><strong>Your agent spends 30–50k tokens of context on tool definitions before the user types a word. toolgz gets ~80% of it back.</strong></p>
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 | Cost | Latency | Tasks |
62
- |---|---|---:|---:|---:|---:|:-:|
63
- | Anthropic | `claude-opus-5` | 9,242 → **1,284** | 30,817 → **4,628** (85%) | **−78%** | 15.0s → **12.1s** | 15/15 |
64
- | xAI | `grok-4.5` | 6,421 → **775** | 17,522 → **2,663** (85%) | **−70%** | 6.1s → **4.6s** | 15/15 |
65
- | Google | `gemini-3.1-pro-preview` | 5,264 → **732** | 10,948 → **2,302** (79%) | **−62%** | 5.6s → **5.5s** | 15/15 |
66
- | OpenAI | `gpt-5.6-sol` | 2,752 → **573** | 7,694 → **2,196** (71%) | **−7%** | 6.8s → **5.6s** | 15/15 |
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
- ### Cost, and why it took two rounds to get right
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 both reduced on all four providers">
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 fell
97
- 69%. The dispatcher was spending extra turns, and on a reasoning model every turn pays for a
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 that were being rejected instead of guessing, and found three bugs
101
- in *this library*: models pass `query` to a parameter named `q` (14 of 18 rejections), they
102
- sometimes call the map code as the tool name, and they sometimes pass arguments flat instead
103
- of nested. Fixing all three took OpenAI from **+15% to −7%** and drove malformed arguments to
104
- **zero on every provider**.
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
- OpenAI's −7% is still the smallest saving, and honestly so: reasoning output dominates its
107
- bill, so a smaller prompt moves the total less. **Context-window occupancy remains the
108
- primary claim** cost follows from it, by an amount that depends on your reasoning settings.
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, terseDescriptor, } from "./render/index.js";
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", "name+required", "signature", "terse"];
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.mapStyle ?? "name+required";
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
- if (mapStyle === "terse")
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 (mapStyle === "name+required") {
226
- const req = t.schema.required ?? [];
227
- return req.length ? `${code} ${t.name} ${req.join(",")}` : `${code} ${t.name}`;
228
- }
229
- return `${code} ${t.name}`;
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 === "name+required"
255
- ? "Each line is: code name required-args. "
256
- : mapStyle === "signature"
257
- ? "Each line is: code name(args), where ? marks optional. "
258
- : "";
259
- const systemPreamble = `<toolmap>\n${lines.join("\n")}\n</toolmap>\n${mapLegend}Invoke with t(f=<code>, a={…}). Use q to expand a code before calling if you are unsure of its parameters.`;
260
- return finish(wire, systemPreamble, true, (name, rawArgs) => {
261
- const args = asObject(rawArgs);
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 = codeToTool.get(String(args.c));
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" ? codeToTool.get(name) : undefined;
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 ?? codeToTool.get(String(args.f));
294
- if (!t)
295
- return err(`No map code "${args.f}". Search with q(s=…).`);
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";
@@ -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
- const { ns, op } = namespaceOf(t.name);
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 `a0 github_create_issue`
41
- * name+required `a0 github_create_issue owner,repo,title`
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
- * `name` is the default and the smallest. `name+required` costs a few tokens
46
- * per tool and exists to cut malformed arguments on models that fill the
47
- * generic argument bag badly — the dispatcher levels give up provider-side
48
- * constrained decoding, and this buys some of it back cheaply. `signature` also
49
- * names the optional parameters, which removes most remaining `q()` lookups —
50
- * a bigger cached map traded for fewer turns, which matters on models where
51
- * every turn pays for a fresh round of reasoning. `terse` drops the real name
52
- * entirely; it is the most aggressive and the least legible.
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" | "name+required" | "signature" | "terse";
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.1.1",
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",