toolgz 0.2.7 → 0.2.9

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 safe default (level 1) reclaims 13–39% and gives up nothing <code>recommendLevel()</code> picks for you.</em></p>
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>
6
6
 
7
7
  <p>
8
8
  <a href="#measured-results">420-run cross-provider sweep</a> ·
@@ -33,7 +33,7 @@ Reclaiming the room is what this does.
33
33
  ```ts
34
34
  import { compress, recommendLevel, forAnthropic } from "toolgz";
35
35
 
36
- const { level } = recommendLevel(myTools); // 1 for small sets, 3 for large ones
36
+ const { level } = recommendLevel(myTools); // advice: 1 for a small block, 3 for a big one
37
37
  const c = compress(myTools, { level }); // your existing MCP/SDK tool array
38
38
  const { tools, system } = forAnthropic(c); // send these instead
39
39
  ```
@@ -41,7 +41,11 @@ const { tools, system } = forAnthropic(c); // send these instead
41
41
  `compress(myTools)` with no `level` gives you **level 1** — safe, native tool calling,
42
42
  provider schema enforcement intact, and 13–39% smaller (13–32% on the synthetic benchmark, 39% on the real 149-tool corpus). The 71–85% figures below are
43
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.
44
+ amortise the dispatcher.
45
+
46
+ **Note the shape of those three lines: `recommendLevel` only advises, and you pass its
47
+ answer back in.** Nothing upgrades itself — `compress(myTools)` is level 1 whether you
48
+ have 2 tools or 500. See [Which level to use](#which-level-to-use) for why.
45
49
 
46
50
  Then translate the model's call back before you dispatch:
47
51
 
@@ -226,6 +230,62 @@ tried and removed in 0.2.0 because they were smaller and still worse.
226
230
 
227
231
  ## Which level to use
228
232
 
233
+ ### The whole idea, in plain English
234
+
235
+ Your tool definitions are a **menu** handed to the model at the start of every single
236
+ conversation. It is long, and most of it is flowery prose about each dish.
237
+
238
+ - **Level 1** — the same menu with the prose cut. It is still a real menu: the model
239
+ points at a dish **by name**, and *the kitchen checks the order makes sense* before
240
+ cooking. This is the default, and it gives up nothing.
241
+ - **Level 3** — throw the menu away. Hand the model a **numbered list** and one waiter.
242
+ It says "number 12, no onions," and the waiter knows what that means. The list is
243
+ tiny. But the kitchen no longer checks the order — **toolgz checks it instead**,
244
+ against your original schema, and hands back a readable error if it's wrong.
245
+
246
+ If the model needs to know what number 12 comes with, it asks. That's the `q()` lookup,
247
+ and it costs about half a turn.
248
+
249
+ So: **level 1 is smaller. Level 3 is much smaller and you take over order-checking.**
250
+
251
+ ### Two things that surprise people
252
+
253
+ **1. Nothing changes level on its own. You are always the one who picks.**
254
+
255
+ ```ts
256
+ compress(myTools) // level 1. ALWAYS — 2 tools or 500.
257
+ compress(myTools, { level: 3 }) // level 3, because you asked for it
258
+
259
+ const { level } = recommendLevel(myTools); // just advice: returns 1 or 3
260
+ compress(myTools, { level }); // now it's 3, because you passed it in
261
+ ```
262
+
263
+ `recommendLevel()` **advises**; it does not act. On our 149-tool corpus,
264
+ `compress(myTools)` saves 45.2% and `compress(myTools, { level: 3 })` saves 96.5% — so
265
+ forgetting to pass the level back in quietly leaves half the win on the table.
266
+
267
+ This is deliberate. Level 3 gives up provider-side schema enforcement, and silently
268
+ changing a caller's correctness guarantees because their tool array grew would be a
269
+ worse bug than the tokens are worth.
270
+
271
+ **2. It switches on how *big* the block is, not how *many* tools you have.**
272
+
273
+ A tool can be 20 tokens or 460, so counting them tells you very little:
274
+
275
+ | Tool set | Level 1 block | Recommends |
276
+ |---|---:|:-:|
277
+ | 72 tools, one parameter each | ~5,000 tokens | **1** |
278
+ | 200 tools, one parameter each | ~14,100 tokens | **3** |
279
+ | **40** real MCP tools | ~10,600 tokens | **3** |
280
+ | 149 real MCP tools | ~42,700 tokens | **3** |
281
+
282
+ Forty chatty tools cross the line while 72 terse ones don't. The threshold is **10,000
283
+ tokens** — about 5% of a 200k window. Below that, reclaiming the block doesn't change
284
+ what fits, so keeping the provider's own argument validation is worth more than the
285
+ saving.
286
+
287
+ ### The levels in full
288
+
229
289
  Ask the library. It returns 1 or 3, never 2, and explains itself:
230
290
 
231
291
  ```ts
@@ -302,6 +362,11 @@ Level 3 has several map styles. Which one is cheapest turns out to depend on the
302
362
  model, so you can hand `compress()` a model id and let it use what was actually
303
363
  measured:
304
364
 
365
+ > **This is the one thing the library does choose for you, and only if you pass
366
+ > `model`.** Note the difference from levels: a map style is a pure encoding choice, so
367
+ > picking a better one cannot change your results. The level *can* — level 3 hands
368
+ > argument validation from the provider to toolgz — so that stays your explicit call.
369
+
305
370
  ```ts
306
371
  compress(myTools, { level: 3, model: "gpt-5.6-sol", objective: "cost" });
307
372
  ```
@@ -346,6 +411,31 @@ style in 0.2.0.
346
411
 
347
412
  ---
348
413
 
414
+ ## Runnable examples
415
+
416
+ Five files in [`examples/`](examples), all offline — no API key, no cost. Every one is
417
+ **executed by the test suite**, so an example that stops working is a failing test rather
418
+ than a bug report from you.
419
+
420
+ ```bash
421
+ npx tsx examples/01-minimal.ts
422
+ ```
423
+
424
+ | File | Shows |
425
+ |---|---|
426
+ | [`01-minimal.ts`](examples/01-minimal.ts) | the smallest useful thing: `recommendLevel` → `compress` → `resolve` |
427
+ | [`02-agent-loop.ts`](examples/02-agent-loop.ts) | the full loop against a scripted model, covering all three `resolve()` outcomes including a recovery |
428
+ | [`03-mcp-servers.ts`](examples/03-mcp-servers.ts) | 149 real tools from 14 MCP servers, all four levels, and the name-collision hazard |
429
+ | [`04-providers.ts`](examples/04-providers.ts) | the four provider envelopes side by side, plus the Gemini schema repairs |
430
+ | [`05-per-model.ts`](examples/05-per-model.ts) | `model`/`objective` selection and reading `stats` to see what was actually used |
431
+
432
+ Two things `04-providers.ts` demonstrates rather than describes, because both have bitten
433
+ people: `/v1/responses` needs the **flat** tool shape when you set reasoning effort, and
434
+ Gemini returns **one** wrapper object containing all declarations, so you count
435
+ `tools[0].functionDeclarations.length`.
436
+
437
+ ---
438
+
349
439
  ## Using it: the full guide
350
440
 
351
441
  Everything below was a separate `docs/GUIDE.md`. It is inline now, deliberately: the
@@ -758,8 +848,13 @@ Use `forGemini`, which strips the keywords Gemini won't accept. If a new one
758
848
  appears, it's a one-line addition to the adapter.
759
849
 
760
850
  **Savings look small.**
761
- Check tool count and shape with `recommendLevel(myTools)`. Under ~15 tools
762
- there's little to reclaim. Also check `c.stats`:
851
+ First check you actually passed a level: `compress(myTools)` is **level 1**, and it
852
+ stays level 1 no matter how many tools you hand it. `recommendLevel` advises, it does
853
+ not act — you have to pass its answer in as `{ level }`.
854
+
855
+ If you did, ask `recommendLevel(myTools)` and read the `reason`. It reports the size of
856
+ your level-1 block, and under ~10,000 tokens there is little worth reclaiming. Also
857
+ check `c.stats`:
763
858
 
764
859
  ```ts
765
860
  console.log(c.stats);
@@ -767,8 +862,7 @@ console.log(c.stats);
767
862
  // originalChars: 61461, compressedChars: 2211, savedPct: 96.4 }
768
863
  ```
769
864
 
770
- `savedPct` estimates the token saving (calibrated against `count_tokens`, ~1–2pp
771
- accurate). `originalChars` and `compressedChars` give the raw character counts.
865
+ `savedPct` is a character saving, a few points optimistic against tokens. `originalChars` and `compressedChars` give the raw character counts.
772
866
 
773
867
  **I need the real token numbers.**
774
868
  Count them with the provider's own endpoint. Never use `tiktoken` for Claude —
@@ -842,21 +936,34 @@ Returns:
842
936
  | `codeFor(name)` | `→ string` | real name → level-3 code; throws below level 3 |
843
937
  | `stats` | `CompressStats` | `level`, `mapStyle`, `requestedMapStyle`, `fallbackReason`, `toolCount`, `wireToolCount`, `originalChars`, `compressedChars`, `savedPct` |
844
938
 
845
- > **`savedPct` estimates tokens, and is accurate to a point or two.** It used to be a raw
846
- > character ratio, which overstated by 7.6 points at level 1. Characters per token differs
847
- > between the two sides uncompressed JSON is punctuation-dense, a signature line reads
848
- > more like prose — so it now divides each side by a ratio calibrated against
849
- > `count_tokens` on two unrelated corpora. Current error on the real corpus: **+1.5pp at
850
- > level 1, −0.3pp at level 2, +0.1pp at level 3.**
939
+ > **`savedPct` is a character saving, and runs a few points optimistic against tokens.**
940
+ > On the real 149-tool corpus it reports 46.8% at level 1 where `count_tokens` measures
941
+ > 39.2%, and 96.6% at level 3 against 95.6%.
851
942
  >
852
- > It is still a local estimate with no API call. For a figure you publish, measure with
853
- > your provider's token counter; `originalChars` and `compressedChars` are also on `stats`
854
- > if you want the raw counts.
943
+ > We tried making it a token estimate in 0.2.7 and reverted it in 0.2.8. Providers charge a
944
+ > fixed framing cost per tool definition that character counting cannot see: at 149 tools
945
+ > it amortises away, at 2 tools it dominates, and the calibrated estimate was off by 44% on
946
+ > a small level-1 block while being within 1% at scale. The plain character ratio is the
947
+ > smaller and more predictable error.
948
+ >
949
+ > Use it as a local signal. For anything you publish, measure with your provider's token
950
+ > counter. `originalChars` and `compressedChars` are on `stats` for the raw counts.
951
+ >
952
+ > A negative value is possible and correct: on a very small tool set, level 1's signature
953
+ > line can exceed the per-property descriptions it strips.
855
954
  | `encodeCallForTest(name, args)` | `→ {name, args}` | build the raw call a model would emit; test aid |
856
955
 
857
956
  ### `recommendLevel(tools, namespaceOf?) → Recommendation`
858
957
 
859
- `{ level, reason, toolCount, namespaceCount, opsPerNamespace }`. Returns 1 or 3.
958
+ `{ level, reason, toolCount, namespaceCount, opsPerNamespace }`. Returns 1 or 3, never 2.
959
+
960
+ **It advises; it does not act.** Pass the answer back in yourself —
961
+ `compress(tools, { level })`. Calling `compress(tools)` alone is level 1 regardless of
962
+ how large `tools` is.
963
+
964
+ The decision is on the **size** of the level-1 block (threshold 10,000 tokens ≈ 5% of a
965
+ 200k window), not on `toolCount`. The three shape fields are reported for your own
966
+ logging; only block size drives the level.
860
967
 
861
968
  ### Provider adapters
862
969
 
@@ -945,8 +1052,13 @@ xAI is OpenAI-compatible — use `forOpenAI` with `baseURL: "https://api.x.ai/v1
945
1052
  - **It has not been measured on a non-frontier model at level 3.** On Haiku 4.5, argument
946
1053
  errors rose sharply (17 of 30 runs) — all caught and retried, no task lost, but that is the
947
1054
  known edge.
948
- - **It is not magic on ten tools.** Under ~15 tools there is little to reclaim;
949
- `recommendLevel()` will tell you so.
1055
+ - **It is not magic on a small tool block.** Under ~10,000 tokens at level 1 there is
1056
+ little worth reclaiming, and `recommendLevel()` will say so and keep you on level 1.
1057
+ That is usually a small number of tools, but not always — it depends on how verbose
1058
+ your schemas are, not on the count.
1059
+ - **It does not pick a level for you.** `compress()` defaults to level 1 and stays there;
1060
+ reaching level 3 is always an explicit `{ level }`. Deliberate — level 3 trades away
1061
+ provider-side schema enforcement, and that is not a trade to make behind your back.
950
1062
 
951
1063
  ## Determinism
952
1064
 
@@ -960,7 +1072,7 @@ and it does not get deleted.
960
1072
  ## Development
961
1073
 
962
1074
  ```bash
963
- npm test # 277 tests, offline, no cost
1075
+ npm test # 283 tests, offline, no cost
964
1076
  npm run build # tsc → dist/ with .d.ts
965
1077
 
966
1078
  npx tsx bench/harness/run-multi.ts --provider=all --reps=3 --variants # costs money
package/dist/index.js CHANGED
@@ -160,27 +160,19 @@ export function compress(input, options = {}) {
160
160
  };
161
161
  const finish = (wire, systemPreamble, cachePreamble, resolve, encode) => {
162
162
  const compressedChars = countSchemaTokensApprox(wire) + systemPreamble.length;
163
- // `savedPct` is meant to answer "how much of my tool block did I get back", which is
164
- // a TOKEN question. Deriving it from a raw character ratio answered a different one
165
- // and overstated: on the real corpus it reported 46.8% at level 1 where count_tokens
166
- // measures 39.2% — 7.6 points high, and the field was documented as "do not publish
167
- // this" rather than fixed.
163
+ // `savedPct` is a CHARACTER saving, deliberately.
168
164
  //
169
- // Characters per token is not constant across the two sides. Uncompressed JSON is
170
- // punctuation-dense; a signature line or a map row reads more like prose. Measured
171
- // against count_tokens on two unrelated corpora (149 real MCP tools and a 100-tool
172
- // synthetic fixture) the ratios are stable per level:
165
+ // 0.2.7 tried to make it a token estimate by dividing each side by a chars-per-token
166
+ // ratio calibrated against count_tokens. That was a mistake, and measurement caught
167
+ // it: providers charge a fixed framing cost per tool definition that character
168
+ // counting cannot see. At 149 tools it amortises away, at 2 tools it dominates — the
169
+ // ratio approach was off by 44% on a 2-tool level-1 block while being within 1% at
170
+ // 149. No local character-based calculation can span that range.
173
171
  //
174
- // uncompressed 2.39 / 2.45 level 2 2.20 / 1.99
175
- // level 1 2.15 / 2.19 level 3 1.92 / 1.91
176
- //
177
- // Dividing by these brings the error to ~1.5 points at level 1 and ~0.1 at level 3.
178
- // Still an estimate — it is a local calculation with no API call — but now an
179
- // estimate of the right quantity.
180
- const CHARS_PER_TOKEN = { 0: 2.42, 1: 2.17, 2: 2.1, 3: 1.92 };
181
- const estTokens = (chars, at) => chars / CHARS_PER_TOKEN[at];
182
- const originalTokens = estTokens(originalChars, 0);
183
- const compressedTokens = estTokens(compressedChars, level);
172
+ // The plain character ratio is the smaller and more predictable error: it runs a few
173
+ // points optimistic (−7.7% chars against −4.9% real tokens on a 2-tool set; 46.8%
174
+ // against 39.2% on 149 real tools). So it is reported as what it is, and the docs say
175
+ // to measure with your provider's counter for anything you publish.
184
176
  return {
185
177
  tools: wire,
186
178
  systemPreamble,
@@ -204,7 +196,7 @@ export function compress(input, options = {}) {
204
196
  compressedChars,
205
197
  savedPct: originalChars === 0
206
198
  ? 0
207
- : Math.round((1 - compressedTokens / originalTokens) * 1000) / 10,
199
+ : Math.round((1 - compressedChars / originalChars) * 1000) / 10,
208
200
  },
209
201
  };
210
202
  };
@@ -31,9 +31,19 @@ export type Recommendation = {
31
31
  * slower and dearer than level 3. It is never recommended. It stays in the
32
32
  * API for callers who need real operation names on the wire.
33
33
  *
34
- * The level 1 → 3 threshold is driven by tools-per-namespace, not tool count:
35
- * the compound-dispatcher overhead is paid per namespace, so a wide, sparse
36
- * tool set stays cheaper at level 1 even when the total count is large.
34
+ * The level 1 → 3 threshold is an absolute size test on the level-1 block
35
+ * neither tool count nor namespace shape. A tool is 20 to 460 tokens depending
36
+ * on how verbose its schema is, so 40 real MCP tools cross the line while 72
37
+ * one-parameter tools do not.
38
+ *
39
+ * An earlier version gated on tools-per-namespace. That is a *level 2* question
40
+ * (level 2 pays dispatcher overhead per namespace); level 3 uses one flat
41
+ * dispatcher and is indifferent to shape. See the comment on THRESHOLD_TOKENS.
42
+ *
43
+ * This function only ever *advises*. `compress()` defaults to level 1 and stays
44
+ * there unless the caller passes a level explicitly — level 3 trades away the
45
+ * provider's own schema enforcement, and that is not a trade to make on a
46
+ * caller's behalf because their tool array grew.
37
47
  */
38
48
  export declare function recommendLevel(tools: Tool[], namespaceOf?: (n: string) => {
39
49
  ns: string;
package/dist/recommend.js CHANGED
@@ -25,9 +25,19 @@ import { compress } from "./index.js";
25
25
  * slower and dearer than level 3. It is never recommended. It stays in the
26
26
  * API for callers who need real operation names on the wire.
27
27
  *
28
- * The level 1 → 3 threshold is driven by tools-per-namespace, not tool count:
29
- * the compound-dispatcher overhead is paid per namespace, so a wide, sparse
30
- * tool set stays cheaper at level 1 even when the total count is large.
28
+ * The level 1 → 3 threshold is an absolute size test on the level-1 block
29
+ * neither tool count nor namespace shape. A tool is 20 to 460 tokens depending
30
+ * on how verbose its schema is, so 40 real MCP tools cross the line while 72
31
+ * one-parameter tools do not.
32
+ *
33
+ * An earlier version gated on tools-per-namespace. That is a *level 2* question
34
+ * (level 2 pays dispatcher overhead per namespace); level 3 uses one flat
35
+ * dispatcher and is indifferent to shape. See the comment on THRESHOLD_TOKENS.
36
+ *
37
+ * This function only ever *advises*. `compress()` defaults to level 1 and stays
38
+ * there unless the caller passes a level explicitly — level 3 trades away the
39
+ * provider's own schema enforcement, and that is not a trade to make on a
40
+ * caller's behalf because their tool array grew.
31
41
  */
32
42
  export function recommendLevel(tools, namespaceOf = defaultNamespaceOf) {
33
43
  const namespaces = new Set(tools.map((t) => namespaceOf(t.name).ns));
@@ -57,7 +67,11 @@ export function recommendLevel(tools, namespaceOf = defaultNamespaceOf) {
57
67
  // provider's own schema enforcement, which levels 2-3 give up. That is worth more
58
68
  // than a saving you would not notice — hence an absolute threshold on the block,
59
69
  // not a shape test.
60
- const THRESHOLD_TOKENS = 4000;
70
+ // 10,000 tokens is ~5% of a 200k window. Below that, reclaiming the block does not
71
+ // change what fits, and level 1 keeps the provider's own argument validation — worth
72
+ // more than a saving you would not notice. An external reviewer flagged the earlier
73
+ // 4,000 threshold for pushing 14-tool sets into dispatcher mode; they were right.
74
+ const THRESHOLD_TOKENS = 10000;
61
75
  if (l1Tokens < THRESHOLD_TOKENS) {
62
76
  return {
63
77
  ...base,
@@ -68,6 +82,6 @@ export function recommendLevel(tools, namespaceOf = defaultNamespaceOf) {
68
82
  return {
69
83
  ...base,
70
84
  level: 3,
71
- reason: `${toolCount} tools take ~${l1Tokens.toLocaleString()} tokens at level 1; level 3 replaces them with two dispatcher tools plus a cached map. Measured on 149 real MCP tools: 41,648 tokens at level 1 against 2,980 at level 3, and 60/60 tasks completed across four frontier providers with zero hallucinated names. The cost is roughly 0.3-1.7 lookup calls per task — so about half an extra turn — plus the loss of provider-side argument checking keep \`validate\` on, which is what catches malformed arguments instead. If latency matters more than context, stay at level 1.`,
85
+ reason: `${toolCount} tools take ~${l1Tokens.toLocaleString()} tokens at level 1; level 3 replaces them with two dispatcher tools plus a cached map. Measured on 149 real MCP tools: 41,648 tokens at level 1 against 2,980 at level 3, and 60/60 tasks completed across four frontier providers with zero hallucinated names. The cost is roughly 0.3-1.7 lookup calls per task — so about half an extra turn — plus the loss of provider-side argument checking. Keep \`validate\` on; it is what catches malformed arguments instead. If latency matters more than context, stay at level 1.`,
72
86
  };
73
87
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolgz",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
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": {