toolgz 0.2.10 → 0.2.11

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";
@@ -964,13 +980,21 @@ Returns:
964
980
  > Use it as a local signal. For anything you publish, measure with your provider's token
965
981
  > counter. `originalChars` and `compressedChars` are on `stats` for the raw counts.
966
982
  >
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.
983
+ > A negative value is possible and correct. Level 1 pays for a signature line and gets
984
+ > paid by the prose it strips, so on tools whose descriptions are already terse it comes
985
+ > out **~15% larger** — `recommendLevel` returns 0 in that case rather than recommending
986
+ > it. Even level 0 reports **−0.6%**: `c.tools` is Anthropic-shaped, and `input_schema` is
987
+ > one character longer than the `inputSchema` most callers pass in. Structural, not waste.
969
988
  | `encodeCallForTest(name, args)` | `→ {name, args}` | build the raw call a model would emit; test aid |
970
989
 
971
990
  ### `recommendLevel(tools, namespaceOf?) → Recommendation`
972
991
 
973
- `{ level, reason, toolCount, namespaceCount, opsPerNamespace }`. Returns 1 or 3, never 2.
992
+ `{ level, reason, toolCount, namespaceCount, opsPerNamespace }`. Returns 0, 1 or 3, never 2.
993
+
994
+ **`level: 0` means this library has nothing to offer your tool set.** Level 1 would
995
+ inflate it — terse descriptions leave no prose to strip, so the signature line is pure
996
+ addition — and the block is too small for level 3's trade to be worth it. Telling you that
997
+ is more useful than recommending a transform that makes the payload bigger.
974
998
 
975
999
  **It advises; it does not act.** Pass the answer back in yourself —
976
1000
  `compress(tools, { level })`. Calling `compress(tools)` alone is level 1 regardless of
@@ -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/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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolgz",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
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,8 @@
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"
16
17
  },
17
18
  "keywords": [
18
19
  "llm",