toolgz 0.1.2 → 0.2.1

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,8 @@
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 up to ~85% of it back.</strong></p>
4
+
5
+ <p><em>On a large tool set, at level 3. The safe default (level 1) reclaims 13–32% and gives up nothing — <code>recommendLevel()</code> picks for you.</em></p>
4
6
 
5
7
  <p>
6
8
  <a href="#measured-results">420-run cross-provider sweep</a> ·
@@ -29,12 +31,18 @@ Reclaiming the room is what this does.
29
31
  ## The fix, in three lines
30
32
 
31
33
  ```ts
32
- import { compress, forAnthropic } from "toolgz";
34
+ import { compress, recommendLevel, forAnthropic } from "toolgz";
33
35
 
34
- const c = compress(myTools); // your existing MCP/SDK tool array
36
+ const { level } = recommendLevel(myTools); // 1 for small sets, 3 for large ones
37
+ const c = compress(myTools, { level }); // your existing MCP/SDK tool array
35
38
  const { tools, system } = forAnthropic(c); // send these instead
36
39
  ```
37
40
 
41
+ `compress(myTools)` with no `level` gives you **level 1** — safe, native tool calling,
42
+ provider schema enforcement intact, and 13–32% smaller. The 71–85% figures below are
43
+ **level 3**, which is what `recommendLevel` returns once a tool set is big enough to
44
+ amortise the dispatcher. Ask for it explicitly with `{ level: 3 }` if you prefer.
45
+
38
46
  Then translate the model's call back before you dispatch:
39
47
 
40
48
  ```ts
@@ -51,24 +59,80 @@ if (r.kind === "call") await myDispatch(r.name, r.args); // real name, real ar
51
59
  Four frontier models, seven strategies, five tool-selection tasks, 3 reps —
52
60
  **420 runs** on the current sweep, 1,200+ across all rounds. Every raw per-run record is
53
61
  committed in [`bench/results/`](bench/results/); recompute any figure with
54
- `npx tsx bench/analyze-multi.ts`.
62
+ `npx tsx bench/analyze-multi.ts --sweep=<timestamp>`.
63
+
64
+ **The table below uses a synthetic catalogue** — 100 realistic-but-invented MCP-style
65
+ tools across 9 namespaces — because it lets us build deliberately confusable clusters
66
+ that a real catalogue may not contain. Two things follow from that, and the first is
67
+ uncomfortable:
68
+
69
+ - Synthetic naming can flatter a compression style. One map style measured −21% on
70
+ this fixture because every tool name carried a `namespace_op` prefix to factor out.
71
+ On real MCP tools, which mostly do not, the same style was worth −1%.
72
+ - Real catalogues are **bigger**, so these numbers understate the problem. A corpus of
73
+ **149 tools harvested from 14 live MCP servers** measures **68,494 prompt tokens**
74
+ uncompressed on `claude-opus-5` — more than twice the synthetic fixture, and about a
75
+ third of a 200k context window before the user types anything.
76
+
77
+ The real corpus is committed at [`bench/fixtures/real-mcp-tools.json`](bench/fixtures/real-mcp-tools.json)
78
+ with its own scenario suite (`--suite=real`), and it is the corpus of record for any
79
+ claim about real deployments.
55
80
 
56
81
  <picture>
57
82
  <source media="(prefers-color-scheme: dark)" srcset="docs/img/savings-dark.svg">
58
83
  <img src="docs/img/savings-light.svg" alt="Prompt tokens saved versus uncompressed tool definitions, by compression level, for each of four providers">
59
84
  </picture>
60
85
 
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 |
86
+ **All figures below are level 3** (`minified-plus`, the shipped default map style).
87
+ Level 1 on the same sweep saves 13–32%; level 2 is dominated by level 3.
88
+
89
+ | Provider | Model | Tool block | Prompt tokens | Latency | Tasks |
90
+ |---|---|---:|---:|---:|:-:|
91
+ | Anthropic | `claude-opus-5` | 9,242 → **1,284** | 30,817 → **4,628** (**−85%**) | 15.0s → **12.1s** | 15/15 |
92
+ | xAI | `grok-4.5` | 6,421 → **775** | 17,522 → **2,663** (**−85%**) | 6.1s → **4.6s** | 15/15 |
93
+ | Google | `gemini-3.1-pro-preview` | 5,264 → **732** | 10,948 → **2,302** (**−79%**) | 5.6s → **5.5s** | 15/15 |
94
+ | OpenAI | `gpt-5.6-sol` | 2,752 → **573** | 7,694 → **2,196** (**−71%**) | 6.8s → **5.6s** | 15/15 |
67
95
 
68
96
  Reasoning is enabled on all four at high effort, so this is a like-for-like frontier
69
97
  comparison. **60/60 tasks completed, zero hallucinated tool names, zero malformed
70
98
  arguments** — and it is faster than uncompressed on every provider.
71
99
 
100
+ Recompute any figure with
101
+ `npx tsx bench/analyze-multi.ts --sweep=2026-07-25T19-19` against the raw per-run
102
+ records in [`bench/results/`](bench/results).
103
+
104
+ ### What about cost?
105
+
106
+ **Cost is not the claim, and we deliberately do not lead with it.** Prompt caching
107
+ already makes tool tokens cheap. What caching does not do is give you the *room* back,
108
+ and the room is what you run out of.
109
+
110
+ Cost does usually fall as a side effect, by an amount that depends on your provider
111
+ and reasoning settings — and on one of four providers we measured, it does not fall at
112
+ all. On `gpt-5.6-sol` the uncompressed cost distribution is heavily right-skewed (mean
113
+ $0.0172, median $0.0052), so a few expensive runs make compression look break-even
114
+ while the *typical* run gets about 2.5× dearer. We used to publish a "−7% on OpenAI"
115
+ figure. It was a mean over a skewed distribution and we withdrew it.
116
+
117
+ If you do want to optimise the bill, it is one option — and it is a trade, not a
118
+ freebie:
119
+
120
+ ```ts
121
+ compress(myTools, { level: 3, model: "gpt-5.6-sol", objective: "cost" });
122
+ ```
123
+
124
+ | Model | Median cost vs default |
125
+ |---|---:|
126
+ | `gpt-5.6-sol` | **−20.7%** |
127
+ | `gemini-3.1-pro-preview` | **−15.4%** |
128
+ | `claude-opus-5` | **−9.0%** |
129
+ | `grok-4.5` | **+13.2%** — so it is not enabled there |
130
+
131
+ From 432 runs, 36 per style per provider, on the real 149-tool corpus. **What you give
132
+ up:** a slightly larger cached map (+275 characters), and on `grok-4.5` a worse bill —
133
+ which is why that row keeps the default rather than the cost-optimised style. Omit
134
+ `objective` and you get the conservative default everywhere, unchanged.
135
+
72
136
  ### It does not make the model worse
73
137
 
74
138
  That was the thing to disprove, and we tried hard to. The task suite is built from
@@ -86,26 +150,28 @@ problem and looks up what it needs. The default map style exists because of the
86
150
  bare tool names failed on `grok-4.5` **deterministically**, 3 of 3 attempts on one scenario,
87
151
  answering with zero tool calls and no error raised. Naming the required arguments fixed it.
88
152
 
89
- ### Cost, and why it took two rounds to get right
153
+ ### How we found the cost story, and got it wrong twice
90
154
 
91
155
  <picture>
92
156
  <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">
157
+ <img src="docs/img/cost-light.svg" alt="Prompt tokens and cost, by compression level, for each of four providers">
94
158
  </picture>
95
159
 
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.
160
+ The first cross-provider sweep found cost going **up 15% on OpenAI** even while context
161
+ fell 69%. The dispatcher was spending extra turns, and on a reasoning model every turn
162
+ pays for a fresh round of thinking.
99
163
 
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**.
164
+ So we captured the calls being rejected instead of guessing, and found three bugs in
165
+ *this library*: models pass `query` to a parameter named `q` (14 of 18 rejections), they
166
+ sometimes call the map code as the tool name, and they sometimes pass arguments flat
167
+ instead of nested. Fixing all three drove malformed arguments to **zero on every
168
+ provider**. Later rounds found three more of the same kind — a namespace joined with a
169
+ dot, the lookup tool routed through the dispatcher — all shipped in 0.1.2.
105
170
 
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.
171
+ That is the honest shape of this work: **most of the wins came from accepting what
172
+ models actually send, not from making the map smaller.** One extra turn is worth ~3,300
173
+ prompt tokens; the best encoding change available was worth ~550. Six map styles were
174
+ tried and removed in 0.2.0 because they were smaller and still worse.
109
175
 
110
176
  ---
111
177
 
@@ -168,15 +234,576 @@ trip. A test asserts that file matches the code, so it cannot drift.
168
234
 
169
235
  ---
170
236
 
171
- ## Documentation
237
+ ## Optional: let the library pick the map style for your model
238
+
239
+ Level 3 has several map styles. Which one is cheapest turns out to depend on the
240
+ model, so you can hand `compress()` a model id and let it use what was actually
241
+ measured:
242
+
243
+ ```ts
244
+ compress(myTools, { level: 3, model: "gpt-5.6-sol", objective: "cost" });
245
+ ```
246
+
247
+ | Model | Style chosen for `cost` | Measured against the default |
248
+ |---|---|---:|
249
+ | `gpt-5.6-sol` | `explicit` | **−20.7%** |
250
+ | `gemini-3.1-pro-preview` | `explicit` | **−15.4%** |
251
+ | `claude-opus-5` | `explicit` | **−9.0%** |
252
+ | `grok-4.5` | *default* | `explicit` measured **+13.2%** there |
253
+
254
+ From a 432-run sweep, 36 runs per style per provider, on the real 149-tool corpus.
255
+ `explicit` completed 144/144 tasks and cut lookups on all four providers; only the
256
+ *cost* consequence differs by model. The table lives in
257
+ [`src/policy.generated.ts`](src/policy.generated.ts), is generated from the committed
258
+ results, and a test fails if it drifts from them.
259
+
260
+ Four things worth knowing:
261
+
262
+ - **Omitting `model` changes nothing.** Existing behaviour is byte-identical; there
263
+ is a test asserting that.
264
+ - **`objective` defaults to `occupancy`, which has no table.** Every style we
265
+ measured landed within ±3.1% of the default on context occupancy — under our 5%
266
+ effect-size floor — so there is nothing to select. Only `cost` has entries.
267
+ - **An absent model gets the default.** That is an absence of evidence, not a
268
+ prediction. `gpt-5.6-sol` behaving one way says nothing certain about `gpt-5.7`.
269
+ - **`stats` always tells you what was actually used**, so nothing is substituted
270
+ silently:
271
+
272
+ ```ts
273
+ const c = compress(myTools, { level: 3, model: "gpt-5.6-sol", objective: "cost" });
274
+ c.stats.mapStyle; // "explicit" — what was used
275
+ c.stats.requestedMapStyle; // undefined — you did not ask for a specific style
276
+ c.stats.fallbackReason; // undefined — nothing was substituted
277
+ ```
278
+
279
+ There is also a safety valve: if a future sweep finds a `(model, style)` pair that
280
+ fails, it is refused and `stats.fallbackReason` says why. **That table is currently
281
+ empty** — the one pair ever measured unsafe was `nocode` on `grok-4.5` (19% of runs
282
+ answered with no tool call at all), and rather than document a footgun we deleted the
283
+ style in 0.2.0.
284
+
285
+ ---
286
+
287
+ ## Using it: the full guide
288
+
289
+ Everything below was a separate `docs/GUIDE.md`. It is inline now, deliberately: the
290
+ same claims lived in both files and drifted apart — the README advertised level 3's
291
+ savings while its own quick-start example used level 1. One document cannot contradict
292
+ itself.
293
+
294
+ Reference material that is generated or historical stays separate, because it is not
295
+ hand-maintained prose:
296
+
297
+ - **[docs/RESULTS.md](docs/RESULTS.md)** — every benchmark round, the raw evidence log
298
+ - **[docs/BEFORE-AFTER.md](docs/BEFORE-AFTER.md)** — generated by executing the library
299
+ - **[docs/RELEASING.md](docs/RELEASING.md)** — release process
300
+
301
+ ---
302
+
303
+ ## 1. The agent loop, in full
304
+
305
+ A complete, working Anthropic loop. The only additions to a normal loop are the
306
+ `compress()` call at the top and the `resolve()` call before dispatch.
307
+
308
+ ```ts
309
+ import Anthropic from "@anthropic-ai/sdk";
310
+ import { compress, forAnthropic } from "toolgz";
311
+
312
+ const client = new Anthropic();
313
+ const SYSTEM = "You are a helpful operations agent. Use the tools available.";
314
+
315
+ // 1. Compress once, outside the loop. It is pure and deterministic.
316
+ const c = compress(myTools, { level: 3 });
317
+ const { tools, system } = forAnthropic(c);
318
+
319
+ export async function run(userMessage: string) {
320
+ const messages: any[] = [{ role: "user", content: userMessage }];
321
+
322
+ for (let turn = 0; turn < 12; turn++) {
323
+ const res = await client.messages.create({
324
+ model: "claude-opus-5",
325
+ max_tokens: 8000,
326
+ system: [{ type: "text", text: SYSTEM }, ...(system ?? [])],
327
+ tools: tools as any,
328
+ messages,
329
+ });
330
+
331
+ const calls = res.content.filter((b: any) => b.type === "tool_use");
332
+ if (!calls.length) return res; // model is done
333
+
334
+ // 2. Echo the assistant turn back verbatim. On Anthropic this matters:
335
+ // thinking blocks must be returned unchanged or the model re-reasons.
336
+ messages.push({ role: "assistant", content: res.content });
337
+
338
+ const results: any[] = [];
339
+ for (const call of calls) {
340
+ const r = c.resolve(call.name, (call as any).input);
341
+
342
+ if (r.kind === "call") {
343
+ const output = await myDispatch(r.name, r.args); // your real handler
344
+ results.push({
345
+ type: "tool_result",
346
+ tool_use_id: call.id,
347
+ content: JSON.stringify(output),
348
+ });
349
+ } else if (r.kind === "meta") {
350
+ // The model asked toolgz a lookup question. Answer it; don't dispatch.
351
+ results.push({
352
+ type: "tool_result",
353
+ tool_use_id: call.id,
354
+ content: r.result,
355
+ });
356
+ } else {
357
+ // Bad arguments or an unknown code. The message is written for the
358
+ // model to read — hand it straight back and let it retry.
359
+ results.push({
360
+ type: "tool_result",
361
+ tool_use_id: call.id,
362
+ content: `Error: ${r.message}`,
363
+ is_error: true,
364
+ });
365
+ }
366
+ }
367
+
368
+ messages.push({ role: "user", content: results });
369
+ }
370
+ }
371
+ ```
372
+
373
+ Three rules that matter:
374
+
375
+ 1. **Call `compress()` once**, outside the loop. Calling it per turn is wasteful
376
+ and, if you vary the options, breaks prompt caching.
377
+ 2. **Never dispatch on `meta` or `error`.** Only `kind === "call"` is a real
378
+ tool invocation.
379
+ 3. **Feed errors back rather than throwing.** Recovery is the design; the error
380
+ strings are written for the model.
381
+
382
+ ---
383
+
384
+ ## 2. Handling the three outcomes
385
+
386
+ `resolve()` returns a discriminated union. Handle all three.
387
+
388
+ ```ts
389
+ const r = c.resolve(rawName, rawArgs);
390
+
391
+ switch (r.kind) {
392
+ case "call":
393
+ // r.name → the original tool name, e.g. "github_create_issue"
394
+ // r.args → validated against your original schema
395
+ await myDispatch(r.name, r.args);
396
+ break;
397
+
398
+ case "meta":
399
+ // The model used a toolgz lookup tool (q / describe_op).
400
+ // r.result is text to return as the tool result. Do not dispatch.
401
+ reply(r.result);
402
+ break;
403
+
404
+ case "error":
405
+ // r.message → model-readable: names the tool, the parameter, the fix
406
+ // r.recoverable → true when retrying can plausibly succeed
407
+ reply(r.message, { isError: true });
408
+ break;
409
+ }
410
+ ```
411
+
412
+ What produces each:
413
+
414
+ | Outcome | Cause | You should |
415
+ |---|---|---|
416
+ | `call` | valid invocation | dispatch it |
417
+ | `meta` | model asked for a definition or searched the map | return `r.result` as the tool result |
418
+ | `error` | missing/unknown/wrong-typed argument, or an unknown code | return `r.message` with an error flag |
419
+
420
+ Argument validation runs against your **original** schema, not the compressed
421
+ one, so an `error` here means the call genuinely would not have worked.
422
+
423
+ ---
424
+
425
+ ## 3. Provider setup
426
+
427
+ The core is provider-neutral. Adapters handle wire shape and cache placement.
428
+ They are pure functions and never mutate what you pass them.
429
+
430
+ ### Anthropic
431
+
432
+ ```ts
433
+ import { compress, forAnthropic } from "toolgz";
434
+
435
+ const c = compress(myTools, { level: 3 });
436
+ const { tools, system } = forAnthropic(c); // or { ttl: "1h" }
437
+
438
+ await client.messages.create({
439
+ model: "claude-opus-5",
440
+ max_tokens: 8000,
441
+ system: [{ type: "text", text: SYSTEM }, ...(system ?? [])],
442
+ tools,
443
+ messages,
444
+ });
445
+ ```
446
+
447
+ `forAnthropic` places exactly one `cache_control` breakpoint on the last
448
+ eligible tool, and skips any tool carrying `defer_loading` because the API
449
+ rejects that combination.
450
+
451
+ ### OpenAI
452
+
453
+ OpenAI has **two endpoints with two different tool shapes**, and toolgz has an
454
+ adapter for each. Pick by whether you want reasoning.
455
+
456
+ **If you want tools *and* reasoning — use `/v1/responses`.** On the GPT-5.x
457
+ line, `/v1/chat/completions` refuses the combination outright:
458
+
459
+ ```
460
+ Function tools with reasoning_effort are not supported for gpt-5.6-sol in
461
+ /v1/chat/completions. To use function tools, use /v1/responses or set
462
+ reasoning_effort to 'none'.
463
+ ```
464
+
465
+ ```ts
466
+ import { compress, forOpenAIResponses } from "toolgz";
467
+
468
+ const c = compress(myTools, { level: 3 });
469
+ const { tools, systemPreamble } = forOpenAIResponses(c); // flat tool shape
470
+
471
+ const res = await client.responses.create({
472
+ model: "gpt-5.6-sol",
473
+ reasoning: { effort: "high" },
474
+ max_output_tokens: 8000,
475
+ tools,
476
+ input: [
477
+ { type: "message", role: "developer",
478
+ content: SYSTEM + (systemPreamble ? "\n\n" + systemPreamble : "") },
479
+ { type: "message", role: "user", content: userMessage },
480
+ ],
481
+ });
482
+ ```
483
+
484
+ Two things about `/v1/responses` that will bite you otherwise:
485
+
486
+ - History is a flat `input[]` list, not `messages`. A tool call round-trip is
487
+ `{type:"function_call", call_id, name, arguments}` followed by
488
+ `{type:"function_call_output", call_id, output}`.
489
+ - **Reasoning items must be echoed back** alongside tool outputs on the next
490
+ turn, or the model re-reasons from scratch every turn. Push the whole
491
+ `response.output` array back verbatim.
492
+
493
+ **If you don't need reasoning**, chat completions is fine and the tool shape is
494
+ the nested one:
495
+
496
+ ```ts
497
+ import { compress, forOpenAI } from "toolgz";
498
+
499
+ const { tools, systemPreamble } = forOpenAI(compress(myTools, { level: 3 }));
500
+
501
+ await client.chat.completions.create({
502
+ model: "gpt-5.6-sol",
503
+ messages: [
504
+ { role: "system", content: SYSTEM + (systemPreamble ? "\n\n" + systemPreamble : "") },
505
+ ...messages,
506
+ ],
507
+ tools,
508
+ });
509
+ ```
510
+
511
+ Either way, OpenAI's prefix caching is automatic with a ~1024-token floor, so
512
+ there is no breakpoint to place — just keep your prefix stable. Cached input
513
+ bills at roughly a tenth of the normal rate.
514
+
515
+ `forOpenAI` emits `{type:"function", function:{…}}`; `forOpenAIResponses` emits
516
+ the flat `{type:"function", name, …}`. Sending one shape to the other endpoint
517
+ is a validation error, so match them.
518
+
519
+ ### Google Gemini
520
+
521
+ ```ts
522
+ import { compress, forGemini } from "toolgz";
523
+
524
+ const c = compress(myTools, { level: 3 });
525
+ const { tools, systemPreamble } = forGemini(c);
526
+
527
+ await ai.models.generateContent({
528
+ model: "gemini-3.1-pro-preview",
529
+ contents,
530
+ config: {
531
+ systemInstruction: SYSTEM + (systemPreamble ? "\n\n" + systemPreamble : ""),
532
+ tools,
533
+ },
534
+ });
535
+ ```
536
+
537
+ `forGemini` strips the JSON Schema keywords Gemini rejects
538
+ (`additionalProperties`, `$schema`, `default`, `examples`) recursively.
539
+
540
+ ### xAI
541
+
542
+ xAI's API is OpenAI-compatible, so use `forOpenAI`:
543
+
544
+ ```ts
545
+ import OpenAI from "openai";
546
+ import { compress, forOpenAI } from "toolgz";
547
+
548
+ const client = new OpenAI({
549
+ apiKey: process.env.XAI_API_KEY,
550
+ baseURL: "https://api.x.ai/v1",
551
+ });
552
+ const { tools, systemPreamble } = forOpenAI(compress(myTools, { level: 3 }));
553
+ ```
172
554
 
173
- | | |
555
+ > **Note:** xAI excludes reasoning tokens from `completion_tokens`. If you're
556
+ > tracking spend, add `usage.completion_tokens_details.reasoning_tokens`.
557
+
558
+ ---
559
+
560
+ ## 4. Prompt caching
561
+
562
+ **Read this even if you skip everything else.** Prompt caching is what makes the
563
+ per-turn cost of your tool block collapse, and it is easy to break by accident.
564
+
565
+ Caching is a **prefix match**. Any byte that changes invalidates everything after
566
+ it. Tool definitions render first, so a single reordered tool re-bills your whole
567
+ prompt.
568
+
569
+ `compress()` is built for this: it sorts tools by name and emits byte-identical
570
+ output for identical input. There is a test asserting it, and that test does not
571
+ get deleted.
572
+
573
+ What breaks caching — check your own code for these:
574
+
575
+ | Mistake | Why it breaks |
174
576
  |---|---|
175
- | **[Complete guide](docs/GUIDE.md)** | Install working agent loop. Per-provider setup for all four, prompt caching, MCP aggregation, troubleshooting. Start here. |
176
- | **[Before / after](docs/BEFORE-AFTER.md)** | Generated, not illustrated. Both artifacts toolgz modifies, at every level. |
177
- | **[Full results](docs/RESULTS.md)** | Every number, the methodology, and what it does **not** establish. |
178
- | **[Announcement drafts](docs/ANNOUNCEMENT.md)** | Ready-to-post write-ups for HN and LinkedIn, plus claims *not* to make. |
179
- | **[Releasing](docs/RELEASING.md)** | Publishing to npm over OIDC, with no long-lived token. |
577
+ | `new Date().toISOString()` in the system prompt | prefix differs every request |
578
+ | a request id or UUID near the top | same |
579
+ | building tools from an unsorted `Set`/`Map` | serialization order drifts |
580
+ | calling `compress()` with different options per turn | different payload |
581
+ | adding or removing a tool mid-conversation | tool block is position 0 |
582
+
583
+ Verify it's working — don't assume:
584
+
585
+ ```ts
586
+ const res = await client.messages.create({ /* … */ });
587
+ console.log(res.usage.cache_read_input_tokens); // should be > 0 after turn 1
588
+ ```
589
+
590
+ If that stays `0` across turns with an identical prefix, something upstream is
591
+ varying. Diff two rendered request bodies to find it.
592
+
593
+ ---
594
+
595
+ ## 5. Composing with Anthropic's native tool search
596
+
597
+ Anthropic ships server-side tool search (`tool_search_tool_regex_20251119` plus
598
+ `defer_loading: true`). It defers *whole schemas*; toolgz shrinks *each schema*.
599
+ They're orthogonal and compose:
600
+
601
+ ```ts
602
+ const c = compress(myTools, { level: 1 });
603
+ const tools = [
604
+ { type: "tool_search_tool_regex_20251119", name: "tool_search_tool_regex" },
605
+ ...(c.tools as any[]).map((t, i) => (i < 5 ? t : { ...t, defer_loading: true })),
606
+ ];
607
+ ```
608
+
609
+ Two API constraints: at least one tool must stay non-deferred, and
610
+ `cache_control` cannot go on a deferred tool (`forAnthropic` handles the second).
611
+
612
+ **One measured caveat.** `defer_loading` hides tools until the model *elects to
613
+ search*. On Claude Opus 5 it elects reliably — 20/20 tasks. On Haiku 4.5 it
614
+ often does not: **6 of 30 tasks**, with four of five scenarios answered in a
615
+ single turn and zero tool calls. No error is raised; the request succeeds and the
616
+ answer is simply unaided.
617
+
618
+ A dispatcher doesn't share that failure mode, for a structural reason: `t` and
619
+ `q` are ordinary always-visible tools, so the model cannot forget to search —
620
+ searching is the only thing on offer. Deferred loading makes discovery
621
+ *optional*; a dispatcher makes it *the entry point*.
622
+
623
+ Rule of thumb: compose with native search on frontier models; prefer level 3
624
+ alone below that tier.
625
+
626
+ ---
627
+
628
+ ## 6. MCP servers
629
+
630
+ MCP tool definitions already match the input shape, so there's nothing to
631
+ convert:
632
+
633
+ ```ts
634
+ const { tools: mcpTools } = await mcpClient.listTools();
635
+ const c = compress(mcpTools, { level: 3 });
636
+ ```
637
+
638
+ Aggregating several servers is where the payoff is largest. Namespace the names
639
+ so the grouping is meaningful:
640
+
641
+ ```ts
642
+ const all = [];
643
+ for (const [serverName, client] of servers) {
644
+ const { tools } = await client.listTools();
645
+ all.push(...tools.map((t) => ({ ...t, name: `${serverName}_${t.name}` })));
646
+ }
647
+
648
+ const c = compress(all, { level: 3 });
649
+ ```
650
+
651
+ Default namespacing splits on the first `_` or `.`. Override it if your names
652
+ don't follow that convention:
653
+
654
+ ```ts
655
+ compress(all, {
656
+ level: 3,
657
+ namespaceOf: (name) => {
658
+ const [ns, ...rest] = name.split("::");
659
+ return { ns, op: rest.join("::") };
660
+ },
661
+ });
662
+ ```
663
+
664
+ ---
665
+
666
+ ## 7. Troubleshooting
667
+
668
+ **The model calls `t` or `q` and my dispatcher explodes.**
669
+ You're dispatching on every tool call. Only `kind === "call"` is real; `meta`
670
+ and `error` are toolgz's own tools and must be answered, not dispatched. See
671
+ [§5](#5-handling-the-three-outcomes).
672
+
673
+ **`resolve()` returns `error: unknown parameter "x"`.**
674
+ The model invented a parameter. Feed `r.message` back as an error tool result —
675
+ it names the accepted parameters and the model will retry. This is expected on
676
+ levels 2 and 3 and is exactly what validation is for.
677
+
678
+ **Lots of malformed arguments.**
679
+ First check you have not overridden `mapStyle` away from the default
680
+ `"name+required"` — on the current sweep that default produced **zero**
681
+ malformed arguments on all four providers, while the bare-name map still
682
+ produced them. If you are on the default and still seeing errors, try
683
+ `mapStyle: "signature"` (the model then has the optional parameters too), or
684
+ drop to level 1, which keeps provider-side schema enforcement.
685
+
686
+ The error messages are written to be actionable: a near-miss parameter name is
687
+ named explicitly ("You passed \"query\" — did you mean \"q\"? Rename it."), and a
688
+ wrong-case enum value shows the exact accepted spelling. Feed them straight back
689
+ and the retry usually lands first time.
690
+
691
+ **`cache_read_input_tokens` is always 0.**
692
+ Something in your prefix varies per request. See [§7](#7-prompt-caching).
693
+
694
+ **Gemini rejects my schema.**
695
+ Use `forGemini`, which strips the keywords Gemini won't accept. If a new one
696
+ appears, it's a one-line addition to the adapter.
697
+
698
+ **Savings look small.**
699
+ Check tool count and shape with `recommendLevel(myTools)`. Under ~15 tools
700
+ there's little to reclaim. Also check `c.stats`:
701
+
702
+ ```ts
703
+ console.log(c.stats);
704
+ // { level: 3, toolCount: 100, wireToolCount: 2,
705
+ // originalChars: 61461, compressedChars: 2211, savedPct: 96.4 }
706
+ ```
707
+
708
+ `savedPct` is a character-count proxy, not a token count — useful for a quick
709
+ sanity check, not for billing.
710
+
711
+ **I need the real token numbers.**
712
+ Count them with the provider's own endpoint. Never use `tiktoken` for Claude —
713
+ it's OpenAI's tokenizer and is wrong for Claude by 15–20%+.
714
+
715
+ ```ts
716
+ const { input_tokens } = await client.messages.countTokens({
717
+ model: "claude-opus-5",
718
+ tools: tools as any,
719
+ system: SYSTEM,
720
+ messages: [{ role: "user", content: "x" }],
721
+ });
722
+ ```
723
+
724
+ ---
725
+
726
+ ## 8. Benchmarking your own tools
727
+
728
+ Don't take our numbers for your workload — especially at level 3. The repo ships
729
+ the harness:
730
+
731
+ ```bash
732
+ git clone https://github.com/dperussina/toolgz
733
+ cd toolgz && npm install
734
+ cp .env.example .env # add your keys
735
+
736
+ npm test # 107 offline tests, no network, no cost
737
+
738
+ # one provider, one scenario, cheap
739
+ npx tsx bench/harness/run-multi.ts --provider=openai \
740
+ --scenario=acc-search-vs-list --reps=1
741
+
742
+ # the full comparison (costs money)
743
+ npx tsx bench/harness/run-multi.ts --provider=all --reps=3 --variants
744
+
745
+ npx tsx bench/analyze-multi.ts # per-arm table + ranking stability
746
+ ```
747
+
748
+ To use your own tools and tasks, edit `bench/fixtures/tools.ts` and
749
+ `bench/scenarios-accuracy.ts`. A scenario declares its tools, the prompt, and
750
+ the expected call, so grading is mechanical rather than model-judged.
751
+
752
+ Raw per-run JSONL lands in `bench/results/` and is committed, so every published
753
+ number can be recomputed rather than trusted.
754
+
755
+ ---
756
+
757
+ ## 9. API reference
758
+
759
+ ### `compress(tools, options?) → CompressResult`
760
+
761
+ | Option | Type | Default | Notes |
762
+ |---|---|---|---|
763
+ | `level` | `0 \| 1 \| 2 \| 3` | `1` | see [§3](#3-which-level-should-i-use) |
764
+ | `mapStyle` | `"name+required" \| "explicit" \| "signature"` | `"name+required"` | level 3 only |
765
+ | `namespaceOf` | `(name) => {ns, op}` | split on first `_`/`.` | levels 2–3 grouping |
766
+ | `aliasOf` | `(ns) => string` | identity | level 2 tool naming |
767
+ | `searchLimit` | `number` | `8` | max results from a `q` search |
768
+ | `validate` | `boolean` | `true` | **leave this on** |
769
+
770
+ Returns:
771
+
772
+ | Field | Type | Notes |
773
+ |---|---|---|
774
+ | `tools` | `unknown[]` | send these; Anthropic shape |
775
+ | `systemPreamble` | `string` | append to your system prompt; `""` at levels 0–1 |
776
+ | `cachePreamble` | `boolean` | whether the preamble should sit behind a breakpoint |
777
+ | `resolve(name, args)` | `→ Resolution` | translate a model call back |
778
+ | `codeFor(name)` | `→ string` | real name → level-3 code; throws below level 3 |
779
+ | `stats` | `CompressStats` | `level`, `toolCount`, `wireToolCount`, `savedPct`, … |
780
+
781
+ ### `recommendLevel(tools, namespaceOf?) → Recommendation`
782
+
783
+ `{ level, reason, toolCount, namespaceCount, opsPerNamespace }`. Returns 1 or 3.
784
+
785
+ ### Provider adapters
786
+
787
+ | Adapter | Endpoint | Tool shape |
788
+ |---|---|---|
789
+ | `forAnthropic(c, { cache?, ttl? })` → `{ tools, system }` | Messages API | Anthropic native; places one `cache_control` breakpoint |
790
+ | `forOpenAI(c)` → `{ tools, systemPreamble }` | `/v1/chat/completions` | nested `{type, function:{…}}` |
791
+ | `forOpenAIResponses(c)` → `{ tools, systemPreamble }` | `/v1/responses` | **flat** `{type, name, …}` — required for tools + reasoning |
792
+ | `forGemini(c)` → `{ tools, systemPreamble }` | `generateContent` | one `functionDeclarations` array |
793
+
794
+ ### Renderers (exported for tooling)
795
+
796
+ - `signatureLine(tool, nameOverride?)` → `"name(a,b?:x|y)"`
797
+ - `flattenSchema(schema)` → schema with prose and boilerplate removed
798
+ - `countSchemaTokensApprox(value)` → character-count proxy, **not** a token count
799
+
800
+ ---
801
+
802
+ Measured results and their limits: [RESULTS.md](RESULTS.md).
803
+ Methodology and repo conventions: [../AGENTS.md](../AGENTS.md).
804
+
805
+
806
+ ---
180
807
 
181
808
  ## Providers
182
809
 
@@ -224,7 +851,7 @@ and it does not get deleted.
224
851
  ## Development
225
852
 
226
853
  ```bash
227
- npm test # 131 tests, offline, no cost
854
+ npm test # 245 tests, offline, no cost
228
855
  npm run build # tsc → dist/ with .d.ts
229
856
 
230
857
  npx tsx bench/harness/run-multi.ts --provider=all --reps=3 --variants # costs money
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,11 +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
3
  import { nearest } from "./runtime/similar.js";
4
+ import { POLICY, BROKEN, CONSERVATIVE_DEFAULT } from "./policy.generated.js";
4
5
  export * from "./types.js";
5
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";
6
11
  const CODE_CHARS = "abcdefghijklmnopqrstuvwxyz";
7
12
  const LEVELS = [0, 1, 2, 3];
8
- const MAP_STYLES = ["name", "name+required", "signature", "terse"];
13
+ const MAP_STYLES = ["name+required", "explicit", "signature"];
9
14
  const err = (message, recoverable = true) => ({
10
15
  kind: "error",
11
16
  message,
@@ -30,6 +35,41 @@ function asObject(v) {
30
35
  function defaultAlias(ns) {
31
36
  return ns;
32
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
+ }
33
73
  export function compress(input, options = {}) {
34
74
  const level = (options.level ?? 1);
35
75
  if (!LEVELS.includes(level)) {
@@ -44,7 +84,7 @@ export function compress(input, options = {}) {
44
84
  // 60/60 tasks, fewer malformed arguments, fewer lookup round-trips, and
45
85
  // faster wall-clock than uncompressed on every provider. The larger map pays
46
86
  // for itself by removing a discovery turn.
47
- const mapStyle = options.mapStyle ?? "name+required";
87
+ const { mapStyle, requestedMapStyle, fallbackReason, } = selectMapStyle(options, POLICY, BROKEN);
48
88
  if (!MAP_STYLES.includes(mapStyle)) {
49
89
  throw new Error(`unsupported mapStyle: ${mapStyle} (expected ${MAP_STYLES.join(", ")})`);
50
90
  }
@@ -134,6 +174,9 @@ export function compress(input, options = {}) {
134
174
  encodeCallForTest: encode,
135
175
  stats: {
136
176
  level,
177
+ mapStyle,
178
+ requestedMapStyle: requestedMapStyle && requestedMapStyle !== mapStyle ? requestedMapStyle : undefined,
179
+ fallbackReason,
137
180
  toolCount: tools.length,
138
181
  wireToolCount: wire.length,
139
182
  originalChars,
@@ -256,16 +299,16 @@ export function compress(input, options = {}) {
256
299
  // against a full schema's ~400 — to reduce malformed arguments on models that
257
300
  // fill the generic argument bag poorly.
258
301
  const renderLine = (code, t) => {
259
- if (mapStyle === "terse")
260
- return `${code} ${terseDescriptor(t.description) || t.name}`;
302
+ const req = t.schema.required ?? [];
261
303
  // Full signature: optional params included, so the model rarely needs q().
262
304
  if (mapStyle === "signature")
263
305
  return `${code} ${signatureLine(t)}`;
264
- if (mapStyle === "name+required") {
265
- const req = t.schema.required ?? [];
266
- return req.length ? `${code} ${t.name} ${req.join(",")}` : `${code} ${t.name}`;
267
- }
268
- 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}`;
269
312
  };
270
313
  const lines = [...codeToTool.entries()].map(([code, t]) => renderLine(code, t));
271
314
  const wire = [
@@ -290,12 +333,13 @@ export function compress(input, options = {}) {
290
333
  },
291
334
  },
292
335
  ];
293
- const mapLegend = mapStyle === "name+required"
294
- ? "Each line is: code name required-args. "
295
- : mapStyle === "signature"
296
- ? "Each line is: code name(args), where ? marks optional. "
297
- : "";
298
- 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.`;
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}`;
299
343
  return finish(wire, systemPreamble, true, (rawName, rawArgs) => {
300
344
  let name = rawName;
301
345
  let args = asObject(rawArgs);
@@ -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/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.2",
3
+ "version": "0.2.1",
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",