toolgz 0.2.9 → 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 +49 -10
- package/dist/recommend.d.ts +5 -0
- package/dist/recommend.js +28 -2
- package/llms.txt +211 -0
- package/package.json +4 -2
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> ·
|
|
@@ -15,6 +15,17 @@ zero runtime dependencies ·
|
|
|
15
15
|
npm install toolgz
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
> ### 🤖 If a coding agent is reading this
|
|
19
|
+
>
|
|
20
|
+
> Point it at **[`llms.txt`](llms.txt)** — prescriptive integration instructions written
|
|
21
|
+
> for an agent, shipped inside the package at `node_modules/toolgz/llms.txt`. It covers
|
|
22
|
+
> the three-step contract, the rules that are not optional, per-model behaviour, and a
|
|
23
|
+
> `Do not` list of the mistakes we have actually fielded.
|
|
24
|
+
>
|
|
25
|
+
> ```
|
|
26
|
+
> Read node_modules/toolgz/llms.txt before integrating toolgz.
|
|
27
|
+
> ```
|
|
28
|
+
|
|
18
29
|
---
|
|
19
30
|
|
|
20
31
|
## The problem
|
|
@@ -33,7 +44,7 @@ Reclaiming the room is what this does.
|
|
|
33
44
|
```ts
|
|
34
45
|
import { compress, recommendLevel, forAnthropic } from "toolgz";
|
|
35
46
|
|
|
36
|
-
const { level } = recommendLevel(myTools); // advice: 1
|
|
47
|
+
const { level } = recommendLevel(myTools); // advice: 0, 1 or 3 — see below
|
|
37
48
|
const c = compress(myTools, { level }); // your existing MCP/SDK tool array
|
|
38
49
|
const { tools, system } = forAnthropic(c); // send these instead
|
|
39
50
|
```
|
|
@@ -43,6 +54,11 @@ provider schema enforcement intact, and 13–39% smaller (13–32% on the synthe
|
|
|
43
54
|
**level 3**, which is what `recommendLevel` returns once a tool set is big enough to
|
|
44
55
|
amortise the dispatcher.
|
|
45
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
|
+
|
|
46
62
|
**Note the shape of those three lines: `recommendLevel` only advises, and you pass its
|
|
47
63
|
answer back in.** Nothing upgrades itself — `compress(myTools)` is level 1 whether you
|
|
48
64
|
have 2 tools or 500. See [Which level to use](#which-level-to-use) for why.
|
|
@@ -67,8 +83,10 @@ committed in [`bench/results/`](bench/results/); recompute any figure with
|
|
|
67
83
|
|
|
68
84
|
**The table below uses a synthetic catalogue** — 100 realistic-but-invented MCP-style
|
|
69
85
|
tools across 9 namespaces — because it lets us build deliberately confusable clusters
|
|
70
|
-
that a real catalogue may not contain.
|
|
71
|
-
|
|
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:
|
|
72
90
|
|
|
73
91
|
- Synthetic naming can flatter a compression style. One map style measured −21% on
|
|
74
92
|
this fixture because every tool name carried a `namespace_op` prefix to factor out.
|
|
@@ -195,7 +213,7 @@ the tool name that compression takes away.
|
|
|
195
213
|
|
|
196
214
|
<picture>
|
|
197
215
|
<source media="(prefers-color-scheme: dark)" srcset="docs/img/reliability-dark.svg">
|
|
198
|
-
<img src="docs/img/reliability-light.svg" alt="Task completion by level-3 map style and provider
|
|
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">
|
|
199
217
|
</picture>
|
|
200
218
|
|
|
201
219
|
The model doesn't lose the ability to choose — it converts a recall problem into a retrieval
|
|
@@ -203,6 +221,10 @@ problem and looks up what it needs. The default map style exists because of the
|
|
|
203
221
|
bare tool names failed on `grok-4.5` **deterministically**, 3 of 3 attempts on one scenario,
|
|
204
222
|
answering with zero tool calls and no error raised. Naming the required arguments fixed it.
|
|
205
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
|
+
|
|
206
228
|
### How we found the cost story, and got it wrong twice
|
|
207
229
|
|
|
208
230
|
<picture>
|
|
@@ -232,6 +254,11 @@ tried and removed in 0.2.0 because they were smaller and still worse.
|
|
|
232
254
|
|
|
233
255
|
### The whole idea, in plain English
|
|
234
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
|
+
|
|
235
262
|
Your tool definitions are a **menu** handed to the model at the start of every single
|
|
236
263
|
conversation. It is long, and most of it is flowery prose about each dish.
|
|
237
264
|
|
|
@@ -256,7 +283,7 @@ So: **level 1 is smaller. Level 3 is much smaller and you take over order-checki
|
|
|
256
283
|
compress(myTools) // level 1. ALWAYS — 2 tools or 500.
|
|
257
284
|
compress(myTools, { level: 3 }) // level 3, because you asked for it
|
|
258
285
|
|
|
259
|
-
const { level } = recommendLevel(myTools); // just advice: returns 1 or 3
|
|
286
|
+
const { level } = recommendLevel(myTools); // just advice: returns 0, 1 or 3
|
|
260
287
|
compress(myTools, { level }); // now it's 3, because you passed it in
|
|
261
288
|
```
|
|
262
289
|
|
|
@@ -286,7 +313,7 @@ saving.
|
|
|
286
313
|
|
|
287
314
|
### The levels in full
|
|
288
315
|
|
|
289
|
-
Ask the library. It returns 1 or 3
|
|
316
|
+
Ask the library. It returns 0, 1 or 3 — never 2 — and explains itself:
|
|
290
317
|
|
|
291
318
|
```ts
|
|
292
319
|
import { recommendLevel } from "toolgz";
|
|
@@ -421,6 +448,10 @@ than a bug report from you.
|
|
|
421
448
|
npx tsx examples/01-minimal.ts
|
|
422
449
|
```
|
|
423
450
|
|
|
451
|
+
**[`examples/README.md`](examples/README.md) walks through all five** with their real
|
|
452
|
+
output and what each one is trying to teach — start there if you would rather read than
|
|
453
|
+
run.
|
|
454
|
+
|
|
424
455
|
| File | Shows |
|
|
425
456
|
|---|---|
|
|
426
457
|
| [`01-minimal.ts`](examples/01-minimal.ts) | the smallest useful thing: `recommendLevel` → `compress` → `resolve` |
|
|
@@ -949,13 +980,21 @@ Returns:
|
|
|
949
980
|
> Use it as a local signal. For anything you publish, measure with your provider's token
|
|
950
981
|
> counter. `originalChars` and `compressedChars` are on `stats` for the raw counts.
|
|
951
982
|
>
|
|
952
|
-
> A negative value is possible and correct
|
|
953
|
-
>
|
|
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.
|
|
954
988
|
| `encodeCallForTest(name, args)` | `→ {name, args}` | build the raw call a model would emit; test aid |
|
|
955
989
|
|
|
956
990
|
### `recommendLevel(tools, namespaceOf?) → Recommendation`
|
|
957
991
|
|
|
958
|
-
`{ 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.
|
|
959
998
|
|
|
960
999
|
**It advises; it does not act.** Pass the answer back in yourself —
|
|
961
1000
|
`compress(tools, { level })`. Calling `compress(tools)` alone is level 1 regardless of
|
package/dist/recommend.d.ts
CHANGED
|
@@ -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
|
|
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
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# toolgz
|
|
2
|
+
|
|
3
|
+
> Compresses LLM tool definitions to reclaim context window. Zero runtime dependencies,
|
|
4
|
+
> TypeScript, Apache-2.0. Works with any model and any framework: it transforms a tool
|
|
5
|
+
> array before you send it, and translates the model's call back afterwards.
|
|
6
|
+
|
|
7
|
+
**This file is written for a coding agent integrating toolgz into a project.** It is
|
|
8
|
+
prescriptive on purpose. If you are contributing to toolgz itself, read `AGENTS.md` in the
|
|
9
|
+
repository instead — different audience, different rules.
|
|
10
|
+
|
|
11
|
+
Installed copy lives at `node_modules/toolgz/llms.txt`. Full prose docs: `README.md`.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## The whole integration, correctly
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { compress, recommendLevel, forAnthropic } from "toolgz";
|
|
19
|
+
|
|
20
|
+
// 1. Decide the level ONCE, at startup — not per request.
|
|
21
|
+
const { level } = recommendLevel(myTools);
|
|
22
|
+
const c = compress(myTools, { level });
|
|
23
|
+
|
|
24
|
+
// 2. Send c.tools instead of your tool array, and append the preamble to your system prompt.
|
|
25
|
+
const { tools, system } = forAnthropic(c);
|
|
26
|
+
|
|
27
|
+
// 3. Translate every tool call back before you dispatch.
|
|
28
|
+
for (const block of res.content.filter((b) => b.type === "tool_use")) {
|
|
29
|
+
const r = c.resolve(block.name, block.input);
|
|
30
|
+
if (r.kind === "call") {
|
|
31
|
+
const out = await myDispatch(r.name, r.args); // real name, real args
|
|
32
|
+
results.push({ type: "tool_result", tool_use_id: block.id, content: JSON.stringify(out) });
|
|
33
|
+
} else if (r.kind === "meta") {
|
|
34
|
+
// toolgz answered a lookup itself. DO NOT dispatch. Return its text to the model.
|
|
35
|
+
results.push({ type: "tool_result", tool_use_id: block.id, content: r.result });
|
|
36
|
+
} else {
|
|
37
|
+
// Recoverable by design: hand the message back and the model retries.
|
|
38
|
+
results.push({ type: "tool_result", tool_use_id: block.id, content: `Error: ${r.message}`, is_error: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Those three steps are the entire contract. `myDispatch` receives exactly what it received
|
|
44
|
+
before toolgz existed.
|
|
45
|
+
|
|
46
|
+
## Rules that are not optional
|
|
47
|
+
|
|
48
|
+
1. **`resolve()` every tool call.** At level 3 the model emits opaque codes (`t(f="a0")`),
|
|
49
|
+
so dispatching `block.name` directly will fail or dispatch the wrong tool. Route
|
|
50
|
+
everything through `resolve()` even at level 0, so the level becomes a one-line change.
|
|
51
|
+
|
|
52
|
+
2. **Handle all three `kind` values.** `call` → dispatch. `meta` → toolgz already answered
|
|
53
|
+
a lookup; return `r.result` to the model and dispatch nothing. `error` → return
|
|
54
|
+
`r.message` to the model as an error tool_result; do **not** throw. The `error` path is
|
|
55
|
+
the recovery mechanism, not a failure.
|
|
56
|
+
|
|
57
|
+
3. **Send the system preamble.** At level 3 the tool map lives in the system prompt. Drop
|
|
58
|
+
it and the model has no idea what any code means. It is `""` at levels 0–1, so
|
|
59
|
+
appending it unconditionally is always safe.
|
|
60
|
+
|
|
61
|
+
4. **`compress()` never changes level on its own.** `compress(tools)` is level 1 forever,
|
|
62
|
+
2 tools or 500. `recommendLevel()` *advises*; you pass its answer in as `{ level }`.
|
|
63
|
+
Deliberate: level 3 moves argument validation from the provider to toolgz, which is not
|
|
64
|
+
a change to make behind a caller's back.
|
|
65
|
+
|
|
66
|
+
5. **Leave `validate` on** (the default). Levels 2–3 give up the provider's constrained
|
|
67
|
+
decoding, so toolgz validating against the original schema is what catches malformed
|
|
68
|
+
arguments. On a weak model, disabling it converts roughly half of all runs from a
|
|
69
|
+
recovered retry into a bad dispatch.
|
|
70
|
+
|
|
71
|
+
6. **Compress once, reuse the result.** `compress()` is deterministic — same tools in,
|
|
72
|
+
byte-identical payload out — so the output is prompt-cache friendly. Recomputing per
|
|
73
|
+
request is wasted work; changing the tool list mid-conversation invalidates the cache
|
|
74
|
+
and strands codes the model has already seen.
|
|
75
|
+
|
|
76
|
+
## Choosing a level
|
|
77
|
+
|
|
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.
|
|
85
|
+
|
|
86
|
+
| Level | Wire | Names | Provider schema enforcement | When |
|
|
87
|
+
|:-:|---|:-:|:-:|---|
|
|
88
|
+
| 0 | unchanged | real | yes | A/B control only |
|
|
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** |
|
|
90
|
+
| 2 | one tool per namespace | real | no | only if you need real op names on the wire |
|
|
91
|
+
| **3** | one dispatcher + one lookup | codes | no | **large tool sets.** Up to ~85–96% |
|
|
92
|
+
|
|
93
|
+
The switch is the **size** of the level-1 block, not the tool count — threshold 10,000
|
|
94
|
+
tokens (~5% of a 200k window). A tool is 20 to 460 tokens depending on schema verbosity,
|
|
95
|
+
so 40 real MCP tools cross it while 72 one-parameter tools do not.
|
|
96
|
+
|
|
97
|
+
Level 2 is dominated by level 3 on accuracy (six times the malformed arguments) and is
|
|
98
|
+
never recommended. It stays in the API for callers who need real operation names.
|
|
99
|
+
|
|
100
|
+
## Provider adapters — pick the right one
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
forAnthropic(c, { cache?, ttl? }) // → { tools, system } Messages API
|
|
104
|
+
forOpenAI(c) // → { tools, systemPreamble } /v1/chat/completions
|
|
105
|
+
forOpenAIResponses(c) // → { tools, systemPreamble } /v1/responses
|
|
106
|
+
forGemini(c) // → { tools, systemPreamble } generateContent
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Two mistakes that produce confusing provider errors:
|
|
110
|
+
|
|
111
|
+
- **OpenAI has two incompatible tool shapes.** `/v1/chat/completions` wants nested
|
|
112
|
+
`{type, function:{…}}`; `/v1/responses` wants flat `{type, name, …}`. If you set
|
|
113
|
+
reasoning effort on a GPT-5.x model you must use `/v1/responses`, therefore
|
|
114
|
+
`forOpenAIResponses`. Using the wrong adapter is rejected at the API.
|
|
115
|
+
- **Gemini returns ONE wrapper object,** not one entry per tool. Read
|
|
116
|
+
`tools[0].functionDeclarations.length`; `tools.length` is always 1.
|
|
117
|
+
|
|
118
|
+
`forGemini` also repairs three schema forms Gemini rejects with a 400: an array-valued
|
|
119
|
+
`type`, `type: "array"` with no `items`, and a non-string `enum`. Constraints stripped to
|
|
120
|
+
satisfy Gemini are still enforced by `resolve()` against your original schema — nothing is
|
|
121
|
+
silently dropped.
|
|
122
|
+
|
|
123
|
+
xAI is OpenAI-compatible: use `forOpenAI` with `baseURL: "https://api.x.ai/v1"`. **xAI
|
|
124
|
+
caps tool arrays at 350**; other providers accept 1,200+. Level 3 sends 2 either way.
|
|
125
|
+
|
|
126
|
+
## Model-specific behaviour
|
|
127
|
+
|
|
128
|
+
Pass an exact model id and toolgz uses the best *measured* map style for it:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
compress(myTools, { level: 3, model: "gpt-5.6-sol", objective: "cost" });
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
| Model | `objective: "cost"` | Measured vs default |
|
|
135
|
+
|---|---|---:|
|
|
136
|
+
| `gpt-5.6-sol` | `explicit` | −20.7% |
|
|
137
|
+
| `gemini-3.1-pro-preview` | `explicit` | −15.4% |
|
|
138
|
+
| `claude-opus-5` | `explicit` | −9.0% |
|
|
139
|
+
| `grok-4.5` | *(no entry)* | `explicit` is **+13.2%** there |
|
|
140
|
+
|
|
141
|
+
- Model ids are **exact, never families**. A result for `gpt-5.6-sol` says nothing about
|
|
142
|
+
`gpt-5.7`. An unknown model gets the conservative default — absence of evidence, not a
|
|
143
|
+
prediction.
|
|
144
|
+
- The default objective is `occupancy`, which has **no** entries: nothing beat the default
|
|
145
|
+
by more than 3.1% on that axis, under the 5% effect-size floor. Only `cost` has rows.
|
|
146
|
+
- This is the one thing the library picks for you, and only when you pass `model`. A map
|
|
147
|
+
style is a pure encoding choice, so a better one cannot change results. The *level*
|
|
148
|
+
can — which is why that stays explicit.
|
|
149
|
+
- Do not set `mapStyle` by hand unless you have measured your own workload. Prefer
|
|
150
|
+
`model` + `objective`.
|
|
151
|
+
|
|
152
|
+
## MCP servers
|
|
153
|
+
|
|
154
|
+
`tools/list` returns `{ name, description, inputSchema }` per tool — exactly what
|
|
155
|
+
`compress()` accepts. **There is no adapter layer.** Merge the arrays from every server
|
|
156
|
+
and pass them in.
|
|
157
|
+
|
|
158
|
+
`input_schema` and `inputSchema` are both accepted, so Anthropic-shaped and MCP-shaped
|
|
159
|
+
tools can be mixed.
|
|
160
|
+
|
|
161
|
+
Watch for name collisions when merging catalogues from independent servers. If two tool
|
|
162
|
+
names normalise identically, toolgz refuses to alias either one rather than guess — the
|
|
163
|
+
map code still works.
|
|
164
|
+
|
|
165
|
+
## API surface
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
compress(tools, options?) → CompressResult
|
|
169
|
+
recommendLevel(tools, namespaceOf?) → { level, reason, toolCount, namespaceCount, opsPerNamespace }
|
|
170
|
+
selectMapStyle(options) → { mapStyle, requestedMapStyle?, fallbackReason? } // pure
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`CompressOptions`: `level`, `mapStyle`, `namespaceOf`, `aliasOf`, `searchLimit`,
|
|
174
|
+
`validate`, `model`, `objective`.
|
|
175
|
+
|
|
176
|
+
`CompressResult`: `tools`, `systemPreamble`, `cachePreamble`, `resolve()`, `codeFor()`,
|
|
177
|
+
`encodeCallForTest()`, `stats`.
|
|
178
|
+
|
|
179
|
+
`stats`: `level`, `mapStyle`, `requestedMapStyle`, `fallbackReason`, `toolCount`,
|
|
180
|
+
`wireToolCount`, `originalChars`, `compressedChars`, `savedPct`.
|
|
181
|
+
|
|
182
|
+
`namespaceOf` must return `{ ns, op }`, **not** a bare string. Returning a string throws
|
|
183
|
+
with a corrected example — it used to fail silently and produce a plausible-looking but
|
|
184
|
+
wrong map.
|
|
185
|
+
|
|
186
|
+
## Things to tell the user, not assume
|
|
187
|
+
|
|
188
|
+
- **`savedPct` is a CHARACTER saving**, a few points optimistic against tokens. Providers
|
|
189
|
+
charge a fixed framing cost per tool definition that character counting cannot see. For
|
|
190
|
+
any published figure, measure with the provider's own token counter.
|
|
191
|
+
- **Never use `tiktoken` to count Claude tokens** — it is OpenAI's tokenizer and is wrong
|
|
192
|
+
for Claude by 15–20%+. Use Anthropic's `count_tokens`.
|
|
193
|
+
- **Token saving ≠ cost saving.** Prompt caching already makes tool tokens cheap; the claim
|
|
194
|
+
here is context-window *occupancy*. Cost follows by a variable amount, and on OpenAI —
|
|
195
|
+
where reasoning output dominates the bill — it was as little as 7%.
|
|
196
|
+
- **Level 3 costs turns.** Roughly 0.3–1.7 lookup calls per task, about half an extra turn.
|
|
197
|
+
If latency matters more than context, stay at level 1.
|
|
198
|
+
- **Below the frontier tier, argument formatting degrades at level 3.** On Haiku 4.5, 17 of
|
|
199
|
+
30 runs produced a malformed argument — all caught by `validate` and recovered, no task
|
|
200
|
+
lost, but that is the known edge. Tool *choice* did not degrade.
|
|
201
|
+
|
|
202
|
+
## Do not
|
|
203
|
+
|
|
204
|
+
- Do not dispatch `block.name` without `resolve()`.
|
|
205
|
+
- Do not throw on `kind: "error"` — return it to the model.
|
|
206
|
+
- Do not dispatch on `kind: "meta"`.
|
|
207
|
+
- Do not omit `systemPreamble` at level 3.
|
|
208
|
+
- Do not set `validate: false` to silence a malformed-argument error; fix the schema.
|
|
209
|
+
- Do not call `compress()` inside the request loop.
|
|
210
|
+
- Do not report `savedPct` as a token or cost saving.
|
|
211
|
+
- Do not assume a level was chosen for you.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolgz",
|
|
3
|
-
"version": "0.2.
|
|
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",
|
|
@@ -45,6 +46,7 @@
|
|
|
45
46
|
"files": [
|
|
46
47
|
"dist",
|
|
47
48
|
"README.md",
|
|
49
|
+
"llms.txt",
|
|
48
50
|
"LICENSE",
|
|
49
51
|
"NOTICE"
|
|
50
52
|
],
|