toolgz 0.2.10 → 0.2.12

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
@@ -2,7 +2,7 @@
2
2
 
3
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
4
 
5
- <p><em>On a large tool set, at level 3. The default (level 1) reclaims 13–39% and gives up nothing. <code>recommendLevel()</code> tells you which one your tools want; you pass its answer in.</em></p>
5
+ <p><em>On a large tool set, at level 3. The default (level 1) reclaims 13–39% on real-world tool definitions and gives up nothing. <code>recommendLevel()</code> tells you which one your tools want; you pass its answer in.</em></p>
6
6
 
7
7
  <p>
8
8
  <a href="#measured-results">420-run cross-provider sweep</a> ·
@@ -44,7 +44,7 @@ Reclaiming the room is what this does.
44
44
  ```ts
45
45
  import { compress, recommendLevel, forAnthropic } from "toolgz";
46
46
 
47
- const { level } = recommendLevel(myTools); // advice: 1 for a small block, 3 for a big one
47
+ const { level } = recommendLevel(myTools); // advice: 0, 1 or 3 see below
48
48
  const c = compress(myTools, { level }); // your existing MCP/SDK tool array
49
49
  const { tools, system } = forAnthropic(c); // send these instead
50
50
  ```
@@ -54,6 +54,11 @@ provider schema enforcement intact, and 13–39% smaller (13–32% on the synthe
54
54
  **level 3**, which is what `recommendLevel` returns once a tool set is big enough to
55
55
  amortise the dispatcher.
56
56
 
57
+ Level 1 earns that by stripping prose, so it needs prose to strip: if your descriptions
58
+ are already one short sentence, it **adds** ~15% instead and `recommendLevel` returns
59
+ **0** — leave the tools alone. Real MCP catalogues are verbose, which is why they land at
60
+ the top of that range.
61
+
57
62
  **Note the shape of those three lines: `recommendLevel` only advises, and you pass its
58
63
  answer back in.** Nothing upgrades itself — `compress(myTools)` is level 1 whether you
59
64
  have 2 tools or 500. See [Which level to use](#which-level-to-use) for why.
@@ -78,8 +83,10 @@ committed in [`bench/results/`](bench/results/); recompute any figure with
78
83
 
79
84
  **The table below uses a synthetic catalogue** — 100 realistic-but-invented MCP-style
80
85
  tools across 9 namespaces — because it lets us build deliberately confusable clusters
81
- that a real catalogue may not contain. Two things follow from that, and the first is
82
- uncomfortable:
86
+ that a real catalogue may not contain. Four of the five scenarios draw a 30-tool
87
+ confusable subset from it; the fifth (`acc-haystack`) uses all 100.
88
+
89
+ Two things follow from that, and the first is uncomfortable:
83
90
 
84
91
  - Synthetic naming can flatter a compression style. One map style measured −21% on
85
92
  this fixture because every tool name carried a `namespace_op` prefix to factor out.
@@ -206,7 +213,7 @@ the tool name that compression takes away.
206
213
 
207
214
  <picture>
208
215
  <source media="(prefers-color-scheme: dark)" srcset="docs/img/reliability-dark.svg">
209
- <img src="docs/img/reliability-light.svg" alt="Task completion by level-3 map style and provider, showing bare names failing on grok-4.5">
216
+ <img src="docs/img/reliability-light.svg" alt="Task completion by level-3 map style and provider. Bare names fail on grok-4.5; that style and the terse-description style were removed in 0.2.0 and are marked as such">
210
217
  </picture>
211
218
 
212
219
  The model doesn't lose the ability to choose — it converts a recall problem into a retrieval
@@ -214,6 +221,10 @@ problem and looks up what it needs. The default map style exists because of the
214
221
  bare tool names failed on `grok-4.5` **deterministically**, 3 of 3 attempts on one scenario,
215
222
  answering with zero tool calls and no error raised. Naming the required arguments fixed it.
216
223
 
224
+ Two of the four rows are marked **(removed)**: those styles were deleted in 0.2.0 and you
225
+ cannot select them. They are charted anyway because they *are* the evidence for the
226
+ default — deleting the losing arms would delete the reason.
227
+
217
228
  ### How we found the cost story, and got it wrong twice
218
229
 
219
230
  <picture>
@@ -243,6 +254,11 @@ tried and removed in 0.2.0 because they were smaller and still worse.
243
254
 
244
255
  ### The whole idea, in plain English
245
256
 
257
+ <picture>
258
+ <source media="(prefers-color-scheme: dark)" srcset="docs/img/metaphor-dark.svg">
259
+ <img src="docs/img/metaphor-light.svg" alt="Three panels. Level 0: the full menu, 149 tools on the wire, nothing saved, kitchen checks the order. Level 1: the same menu with prose cut, 149 tools on the wire, 45.2% smaller, kitchen still checks the order. Level 3: a small numbered card and one waiter, 2 tools on the wire, 96.5% smaller, toolgz checks the order instead of the provider.">
260
+ </picture>
261
+
246
262
  Your tool definitions are a **menu** handed to the model at the start of every single
247
263
  conversation. It is long, and most of it is flowery prose about each dish.
248
264
 
@@ -267,7 +283,7 @@ So: **level 1 is smaller. Level 3 is much smaller and you take over order-checki
267
283
  compress(myTools) // level 1. ALWAYS — 2 tools or 500.
268
284
  compress(myTools, { level: 3 }) // level 3, because you asked for it
269
285
 
270
- const { level } = recommendLevel(myTools); // just advice: returns 1 or 3
286
+ const { level } = recommendLevel(myTools); // just advice: returns 0, 1 or 3
271
287
  compress(myTools, { level }); // now it's 3, because you passed it in
272
288
  ```
273
289
 
@@ -297,7 +313,7 @@ saving.
297
313
 
298
314
  ### The levels in full
299
315
 
300
- Ask the library. It returns 1 or 3, never 2, and explains itself:
316
+ Ask the library. It returns 0, 1 or 3 never 2 and explains itself:
301
317
 
302
318
  ```ts
303
319
  import { recommendLevel } from "toolgz";
@@ -422,6 +438,35 @@ style in 0.2.0.
422
438
 
423
439
  ---
424
440
 
441
+ ## See it run
442
+
443
+ A demo you can put on a screen. It prints every step — the tool definitions going in,
444
+ what `compress()` turns them into, the request, the model's actual response, the
445
+ `resolve()` translation, and your dispatcher running unchanged.
446
+
447
+ ```bash
448
+ npm run demo -- --level=3 # one level, every step
449
+ npm run demo -- --compare # levels 0, 1 and 3 back to back
450
+ npm run demo -- --level=3 --offline # no API key: scripted model, real library
451
+ ```
452
+
453
+ `--compare` ends with the side-by-side:
454
+
455
+ ```
456
+ level wire tools definition chars reclaimed turns argument checking
457
+ 0 6 → 6 3,283 → 3,289 -0.2% 2 provider
458
+ 1 6 → 6 3,283 → 1,999 39.1% 2 provider
459
+ 3 6 → 2 3,283 → 807 75.4% 2 toolgz
460
+ ```
461
+
462
+ It really runs the loop against a real model unless you pass `--offline`, and it says
463
+ which it did. Level 3 also demonstrates the two outcomes a happy path never shows —
464
+ a bad code coming back `kind: "error"` and recoverable, and a `q()` lookup coming back
465
+ `kind: "meta"` with nothing dispatched.
466
+
467
+ Not shipped in the npm package: it imports a provider SDK, which is a devDependency,
468
+ and the library's zero-runtime-dependency guarantee is not negotiable.
469
+
425
470
  ## Runnable examples
426
471
 
427
472
  Five files in [`examples/`](examples), all offline — no API key, no cost. Every one is
@@ -935,11 +980,37 @@ number can be recomputed rather than trusted.
935
980
  | `mapStyle` | `"name+required" \| "explicit" \| "signature"` | `"name+required"` | level 3 only |
936
981
  | `namespaceOf` | `(name) => {ns, op}` | split on first `_`/`.` | levels 2–3 grouping |
937
982
  | `aliasOf` | `(ns) => string` | identity | level 2 tool naming |
983
+ | `signaturePrefix` | `boolean` | `true` | level 1 only; see below |
938
984
  | `searchLimit` | `number` | `8` | max results from a `q` search |
939
985
  | `validate` | `boolean` | `true` | **leave this on** |
940
986
  | `model` | `string` | — | exact model id; picks the measured style. Omit and nothing changes |
941
987
  | `objective` | `"occupancy" \| "cost"` | `"occupancy"` | what the pick optimises. Only `cost` has entries |
942
988
 
989
+ #### `signaturePrefix` — measured, and not the default for a reason
990
+
991
+ Level 1 prepends `name(a,b?) — ` to each description while keeping the full
992
+ `input_schema`. That prefix restates the tool name, property names, required list, enums
993
+ and item types that the schema already carries, and it is **18.5% of the level-1
994
+ payload** on the real corpus. Setting it `false` makes level 1 strictly smaller — 45.2%
995
+ → **55.3%** on 149 real tools — and removes the case where level 1 *inflates* a terse
996
+ catalogue at all (−14.4% → −0.6%, which is level 0's own floor).
997
+
998
+ It stays `true` by default because size was not the question. Over 64 live runs, removing
999
+ it was a clear win on three providers and **not** on the fourth:
1000
+
1001
+ | Provider | Block | Median cost | Turns |
1002
+ |---|---:|---:|---|
1003
+ | `gemini-3.1-pro-preview` | −18.5% | −15.3% | 2.00 → 2.00 |
1004
+ | `gpt-5.6-sol` | −26.0% | −13.8% | 2.38 → 2.13 |
1005
+ | `grok-4.5` | −18.2% | −17.2% | 3.00 → 3.00 |
1006
+ | `claude-opus-5` | −18.3% | **+3.8%** | **3.88 → 4.63** |
1007
+
1008
+ On `claude-opus-5` the smaller block is spent on extra turns, and one turn is worth
1009
+ ~3,300 prompt tokens here. **Zero malformed arguments and zero hallucinated names on both
1010
+ arms, 64/64 tasks.** So it is safe either way; it is just not universally cheaper. Try it
1011
+ if you are not on Anthropic, and measure. Full write-up in
1012
+ [docs/RESULTS.md](docs/RESULTS.md) Round 7.
1013
+
943
1014
  Returns:
944
1015
 
945
1016
  | Field | Type | Notes |
@@ -964,13 +1035,21 @@ Returns:
964
1035
  > Use it as a local signal. For anything you publish, measure with your provider's token
965
1036
  > counter. `originalChars` and `compressedChars` are on `stats` for the raw counts.
966
1037
  >
967
- > A negative value is possible and correct: on a very small tool set, level 1's signature
968
- > line can exceed the per-property descriptions it strips.
1038
+ > A negative value is possible and correct. Level 1 pays for a signature line and gets
1039
+ > paid by the prose it strips, so on tools whose descriptions are already terse it comes
1040
+ > out **~15% larger** — `recommendLevel` returns 0 in that case rather than recommending
1041
+ > it. Even level 0 reports **−0.6%**: `c.tools` is Anthropic-shaped, and `input_schema` is
1042
+ > one character longer than the `inputSchema` most callers pass in. Structural, not waste.
969
1043
  | `encodeCallForTest(name, args)` | `→ {name, args}` | build the raw call a model would emit; test aid |
970
1044
 
971
1045
  ### `recommendLevel(tools, namespaceOf?) → Recommendation`
972
1046
 
973
- `{ level, reason, toolCount, namespaceCount, opsPerNamespace }`. Returns 1 or 3, never 2.
1047
+ `{ level, reason, toolCount, namespaceCount, opsPerNamespace }`. Returns 0, 1 or 3, never 2.
1048
+
1049
+ **`level: 0` means this library has nothing to offer your tool set.** Level 1 would
1050
+ inflate it — terse descriptions leave no prose to strip, so the signature line is pure
1051
+ addition — and the block is too small for level 3's trade to be worth it. Telling you that
1052
+ is more useful than recommending a transform that makes the payload bigger.
974
1053
 
975
1054
  **It advises; it does not act.** Pass the answer back in yourself —
976
1055
  `compress(tools, { level })`. Calling `compress(tools)` alone is level 1 regardless of
package/dist/index.js CHANGED
@@ -228,11 +228,14 @@ export function compress(input, options = {}) {
228
228
  // Level 1 — signature flattening, native tools, real names
229
229
  // -------------------------------------------------------------------------
230
230
  if (level === 1) {
231
+ const withSig = options.signaturePrefix ?? true;
231
232
  const wire = tools.map((t) => {
232
233
  const d = firstSentence(t.description);
234
+ // With no description the signature is the only content there is, so it stays
235
+ // regardless — an empty description would leave the model nothing to read.
233
236
  return {
234
237
  name: t.name,
235
- description: d ? `${signatureLine(t)} — ${d}` : signatureLine(t),
238
+ description: withSig || !d ? (d ? `${signatureLine(t)} — ${d}` : signatureLine(t)) : d,
236
239
  input_schema: flattenSchema(t.schema),
237
240
  };
238
241
  });
@@ -44,6 +44,11 @@ export type Recommendation = {
44
44
  * there unless the caller passes a level explicitly — level 3 trades away the
45
45
  * provider's own schema enforcement, and that is not a trade to make on a
46
46
  * caller's behalf because their tool array grew.
47
+ *
48
+ * Returns 0, 1 or 3 — never 2. **0 means "this library has nothing to offer
49
+ * here"**: level 1 would inflate the block, and it is too small for level 3's
50
+ * trade to be worth it. Saying so is more useful than recommending a transform
51
+ * that makes the payload bigger.
47
52
  */
48
53
  export declare function recommendLevel(tools: Tool[], namespaceOf?: (n: string) => {
49
54
  ns: string;
package/dist/recommend.js CHANGED
@@ -38,6 +38,11 @@ import { compress } from "./index.js";
38
38
  * there unless the caller passes a level explicitly — level 3 trades away the
39
39
  * provider's own schema enforcement, and that is not a trade to make on a
40
40
  * caller's behalf because their tool array grew.
41
+ *
42
+ * Returns 0, 1 or 3 — never 2. **0 means "this library has nothing to offer
43
+ * here"**: level 1 would inflate the block, and it is too small for level 3's
44
+ * trade to be worth it. Saying so is more useful than recommending a transform
45
+ * that makes the payload bigger.
41
46
  */
42
47
  export function recommendLevel(tools, namespaceOf = defaultNamespaceOf) {
43
48
  const namespaces = new Set(tools.map((t) => namespaceOf(t.name).ns));
@@ -53,7 +58,8 @@ export function recommendLevel(tools, namespaceOf = defaultNamespaceOf) {
53
58
  // real MCP tools the ratio is a stable ~2.1 chars/token (1.98 at 10 tools, 2.15 at
54
59
  // 149), so dividing gives a fair local estimate with no API call.
55
60
  const CHARS_PER_TOKEN = 2.1;
56
- const l1Tokens = Math.round(compress(tools, { level: 1 }).stats.compressedChars / CHARS_PER_TOKEN);
61
+ const l1 = compress(tools, { level: 1 });
62
+ const l1Tokens = Math.round(l1.stats.compressedChars / CHARS_PER_TOKEN);
57
63
  // The old heuristic gated level 3 on `opsPerNamespace >= 4`. That is a LEVEL-2
58
64
  // question: level 2 pays dispatcher overhead per namespace, so its shape matters
59
65
  // there. Level 3 uses one flat dispatcher and does not care about namespaces at all.
@@ -73,10 +79,30 @@ export function recommendLevel(tools, namespaceOf = defaultNamespaceOf) {
73
79
  // 4,000 threshold for pushing 14-tool sets into dispatcher mode; they were right.
74
80
  const THRESHOLD_TOKENS = 10000;
75
81
  if (l1Tokens < THRESHOLD_TOKENS) {
82
+ // Level 1 is not unconditionally free, and this function used to assume it was.
83
+ //
84
+ // Level 1 saves by truncating each description to its first sentence and stripping
85
+ // per-property prose from the schema, and it spends by prepending a signature line.
86
+ // On a real MCP catalogue the prose dominates and it nets ~45%. On tools whose
87
+ // descriptions are already one short sentence there is nothing to strip, the
88
+ // signature is pure addition, and level 1 comes out ~15% LARGER than doing nothing.
89
+ //
90
+ // Reported by an external reviewer at 25-1,000 generated tools; they read it as
91
+ // correct behaviour for level 1, which it is. What is not correct is *recommending*
92
+ // it: a recommendation that inflates the payload is worse than no recommendation.
93
+ // So say so, and name level 3 as the real alternative rather than implying the
94
+ // choice is between level 1 and nothing.
95
+ if (l1.stats.savedPct <= 0) {
96
+ return {
97
+ ...base,
98
+ level: 0,
99
+ reason: `Level 1 would make this block ${Math.abs(l1.stats.savedPct).toFixed(1)}% LARGER, not smaller — your descriptions are already terse, so there is no prose to strip and level 1's signature line is pure overhead. Leave the tools alone. If you want the room anyway, level 3 does compress this (${compress(tools, { level: 3 }).stats.savedPct.toFixed(1)}%), at the cost of the provider's own argument validation; at ~${l1Tokens.toLocaleString()} tokens that trade is usually not worth it.`,
100
+ };
101
+ }
76
102
  return {
77
103
  ...base,
78
104
  level: 1,
79
- reason: `The tool block is only ~${l1Tokens.toLocaleString()} tokens at level 1. Level 3 would be smaller, but not by enough to be worth giving up the provider's own argument validation, which level 1 keeps. Reach for level 3 when the block is large enough that reclaiming it changes what fits in your context.`,
105
+ reason: `The tool block is only ~${l1Tokens.toLocaleString()} tokens at level 1, which saves ${l1.stats.savedPct.toFixed(1)}%. Level 3 would be smaller, but not by enough to be worth giving up the provider's own argument validation, which level 1 keeps. Reach for level 3 when the block is large enough that reclaiming it changes what fits in your context.`,
80
106
  };
81
107
  }
82
108
  return {
package/dist/types.d.ts CHANGED
@@ -80,6 +80,24 @@ export type CompressOptions = {
80
80
  };
81
81
  /** Override the short alias used for a namespace at level 2. */
82
82
  aliasOf?: (ns: string) => string;
83
+ /**
84
+ * Level 1 only. Prepend `name(a,b?)` to each description. Default true, which is
85
+ * the historical behaviour and what every published level-1 figure was measured with.
86
+ *
87
+ * **Experimental, and measured before it may become a default.** The prefix is fully
88
+ * redundant with the `input_schema` level 1 retains — the model already has the tool
89
+ * name, the property names, the required list, the enums and the item types — and it
90
+ * costs 18.5% of the level-1 payload on the real 149-tool corpus. Setting this false
91
+ * makes level 1 strictly smaller and removes the case where level 1 *inflates* a
92
+ * terse catalogue.
93
+ *
94
+ * It is not the default because "smaller" is not the question. Every arm that
95
+ * measured clean — zero malformed arguments, no extra turns — had the prefix, and a
96
+ * one-line signature may be easier for a model to read than the equivalent JSON.
97
+ * Whether that matters at level 1, where the schema is present anyway, is a
98
+ * benchmark result and not yet in hand.
99
+ */
100
+ signaturePrefix?: boolean;
83
101
  /** Cap how many results a search/query meta-call returns. Default 8. */
84
102
  searchLimit?: number;
85
103
  /** Validate arguments against the original schema before dispatch. Default true. */
package/llms.txt CHANGED
@@ -75,12 +75,18 @@ before toolgz existed.
75
75
 
76
76
  ## Choosing a level
77
77
 
78
- Ask, don't guess: `recommendLevel(myTools)` returns `{ level, reason }` — 1 or 3, never 2.
78
+ Ask, don't guess: `recommendLevel(myTools)` returns `{ level, reason }` — **0, 1 or 3**,
79
+ never 2.
80
+
81
+ `level: 0` means *leave the tools alone*: level 1 would make this block larger, and it is
82
+ too small for level 3 to be worth giving up provider-side validation. Level 1 is not
83
+ unconditionally free — it saves by stripping prose, so on tools whose descriptions are
84
+ already one short sentence it adds ~15% instead. Do not override a 0 without measuring.
79
85
 
80
86
  | Level | Wire | Names | Provider schema enforcement | When |
81
87
  |:-:|---|:-:|:-:|---|
82
88
  | 0 | unchanged | real | yes | A/B control only |
83
- | **1** | one native tool each, flattened schemas | real | **yes** | **default.** Reclaims 13–39%, gives up nothing |
89
+ | **1** | one native tool each, flattened schemas | real | **yes** | **default.** Reclaims 13–39% on verbose real-world schemas; **inflates ~15% on already-terse ones** |
84
90
  | 2 | one tool per namespace | real | no | only if you need real op names on the wire |
85
91
  | **3** | one dispatcher + one lookup | codes | no | **large tool sets.** Up to ~85–96% |
86
92
 
@@ -164,8 +170,15 @@ recommendLevel(tools, namespaceOf?) → { level, reason, toolCount, namespaceCou
164
170
  selectMapStyle(options) → { mapStyle, requestedMapStyle?, fallbackReason? } // pure
165
171
  ```
166
172
 
167
- `CompressOptions`: `level`, `mapStyle`, `namespaceOf`, `aliasOf`, `searchLimit`,
168
- `validate`, `model`, `objective`.
173
+ `CompressOptions`: `level`, `mapStyle`, `namespaceOf`, `aliasOf`, `signaturePrefix`,
174
+ `searchLimit`, `validate`, `model`, `objective`.
175
+
176
+ `signaturePrefix` (level 1, default true) controls the `name(a,b?) — ` prefix on each
177
+ description. Setting it false makes level 1 smaller — 45.2% → 55.3% on real MCP tools —
178
+ and measured cheaper on Gemini, OpenAI and xAI over 64 runs. On `claude-opus-5` it was
179
+ **+3.8% dearer** because turns rose 3.88 → 4.63. Safe either way (zero malformed
180
+ arguments on both arms); not universally cheaper. Do not set it without measuring your
181
+ own workload.
169
182
 
170
183
  `CompressResult`: `tools`, `systemPreamble`, `cachePreamble`, `resolve()`, `codeFor()`,
171
184
  `encodeCallForTest()`, `stats`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolgz",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
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": {
@@ -12,7 +12,10 @@
12
12
  "test:live": "vitest run tests/integration",
13
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
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"
15
+ "bench:tier3": "tsx -r dotenv/config bench/harness/run-multi.ts --provider=all --suite=real --variants --reps=3",
16
+ "img:metaphor": "tsx bench/metaphor.ts",
17
+ "demo": "tsx demo/cli.ts",
18
+ "bench:noprefix": "tsx -r dotenv/config bench/harness/run-multi.ts --provider=all --suite=real --variants --arms=signatures,signatures-noprefix --reps=2 --scenario=real-daily-vs-weekly,real-overwrite-vs-append,real-status-vs-result,real-rollup-vs-detail"
16
19
  },
17
20
  "keywords": [
18
21
  "llm",