toolgz 0.2.3 → 0.2.5
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 +97 -1
- package/dist/providers/index.js +44 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -101,6 +101,55 @@ Recompute any figure with
|
|
|
101
101
|
`npx tsx bench/analyze-multi.ts --sweep=2026-07-25T19-19` against the raw per-run
|
|
102
102
|
records in [`bench/results/`](bench/results).
|
|
103
103
|
|
|
104
|
+
### How it scales, measured on real tools
|
|
105
|
+
|
|
106
|
+
The table above is a synthetic 100-tool fixture. This is the real one: **149 tools
|
|
107
|
+
harvested from 14 live MCP servers**, scaled by replicating the corpus, measured with
|
|
108
|
+
Anthropic's `count_tokens` and confirmed against live API calls.
|
|
109
|
+
|
|
110
|
+
| Tools | Uncompressed | Level 3 | Reclaimed |
|
|
111
|
+
|---:|---:|---:|---:|
|
|
112
|
+
| 149 (the real corpus) | 68,536 | **3,022** | **95.6%** |
|
|
113
|
+
| 435 | 199,822 | **8,690** | **95.7%** |
|
|
114
|
+
| 800 | 368,826 | **16,006** | **95.7%** |
|
|
115
|
+
| 1,200 | 552,795 | **23,880** | **95.7%** |
|
|
116
|
+
|
|
117
|
+
**The ratio does not decay with scale.** Both the uncompressed block and the map grow
|
|
118
|
+
linearly, so 95.7% holds from 149 tools to 1,200. Real MCP tools measure ~460 tokens
|
|
119
|
+
each — 17% heavier than the ~393 in the published academic benchmark, so a real
|
|
120
|
+
catalogue hits limits sooner than synthetic ones suggest.
|
|
121
|
+
|
|
122
|
+
Which gives a practical ceiling per context window:
|
|
123
|
+
|
|
124
|
+
| Context window | Tools that fit uncompressed | With level 3 |
|
|
125
|
+
|---|---:|---:|
|
|
126
|
+
| 8K (small/local models) | **17** | ~409 |
|
|
127
|
+
| 32K | 71 | ~1,638 |
|
|
128
|
+
| 200K (typical frontier cap) | **434** | ~10,000 |
|
|
129
|
+
|
|
130
|
+
That 200K row is the one to note: **most deployments cap at 200k, making ~434 real
|
|
131
|
+
tools a hard ceiling.** An independent study ([Sakizli 2026](https://github.com/SKZL-AI/tscg))
|
|
132
|
+
measured the same threshold at ~494 tools using tools 17% lighter than ours — two
|
|
133
|
+
separate measurements agreeing within about 15%.
|
|
134
|
+
|
|
135
|
+
### What we could not test
|
|
136
|
+
|
|
137
|
+
**We have not demonstrated that compression improves accuracy, and we do not claim it.**
|
|
138
|
+
|
|
139
|
+
The published study above finds a *binary enablement* effect: at 8K with 28 tools,
|
|
140
|
+
uncompressed schemas overflow the window and exact-match accuracy collapses to 2.6%,
|
|
141
|
+
while compression restores it (+20.5pp average). At 32K, where both fit, four of five
|
|
142
|
+
models show ≤1pp difference — the effect is **budget-driven, not intrinsic**.
|
|
143
|
+
|
|
144
|
+
We tried to reproduce it and could not, for an honest reason: every provider we test
|
|
145
|
+
against has a window far larger than our corpus needs. Uncompressed requests ran
|
|
146
|
+
successfully at 149, 435 and **800 tools (368,826 tokens)** on `claude-opus-5`, picking
|
|
147
|
+
the correct tool each time. Reaching overflow on a 1M window would take ~2,173 tools.
|
|
148
|
+
|
|
149
|
+
So the enablement regime — where this stops being an optimisation and becomes a
|
|
150
|
+
prerequisite — lives on **small-context models we do not currently test**. If you run
|
|
151
|
+
local models at 8K–32K, that study is more relevant to you than our benchmarks are.
|
|
152
|
+
|
|
104
153
|
### What about cost?
|
|
105
154
|
|
|
106
155
|
**Cost is not the claim, and we deliberately do not lead with it.** Prompt caching
|
|
@@ -234,6 +283,19 @@ trip. A test asserts that file matches the code, so it cannot drift.
|
|
|
234
283
|
|
|
235
284
|
---
|
|
236
285
|
|
|
286
|
+
### `signature`: the level-3 style with no lookups
|
|
287
|
+
|
|
288
|
+
Worth calling out because it trades differently from the others. `signature` puts the full
|
|
289
|
+
parameter list in the map, so the model never needs a `q()` lookup — **measured 0.0 lookups
|
|
290
|
+
on all four providers**, against 0.1–0.5 for the default.
|
|
291
|
+
|
|
292
|
+
That makes it the fastest and cheapest option on OpenAI (4.0s vs 5.6s; median cost $0.0106
|
|
293
|
+
vs $0.0129) at the price of a larger cached map. But it is **not** universally better: on
|
|
294
|
+
grok-4.5 it was slower (7.4s vs 4.6s), dearer, and produced the one malformed argument in
|
|
295
|
+
that arm. Reach for it if lookups are your bottleneck and you are not on xAI.
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
237
299
|
## Optional: let the library pick the map style for your model
|
|
238
300
|
|
|
239
301
|
Level 3 has several map styles. Which one is cheapest turns out to depend on the
|
|
@@ -779,6 +841,12 @@ Returns:
|
|
|
779
841
|
| `resolve(name, args)` | `→ Resolution` | translate a model call back |
|
|
780
842
|
| `codeFor(name)` | `→ string` | real name → level-3 code; throws below level 3 |
|
|
781
843
|
| `stats` | `CompressStats` | `level`, `mapStyle`, `requestedMapStyle`, `fallbackReason`, `toolCount`, `wireToolCount`, `originalChars`, `compressedChars`, `savedPct` |
|
|
844
|
+
|
|
845
|
+
> **`savedPct` counts characters, not tokens — and it overstates.** It is derived from
|
|
846
|
+
> `countSchemaTokensApprox`, which is just `JSON.stringify(x).length`. On our real corpus
|
|
847
|
+
> it reports ~97.8% where `count_tokens` measures **95.6%**. Use it as a cheap sanity
|
|
848
|
+
> signal, never as a published figure; for anything you quote, measure with your
|
|
849
|
+
> provider's token counter.
|
|
782
850
|
| `encodeCallForTest(name, args)` | `→ {name, args}` | build the raw call a model would emit; test aid |
|
|
783
851
|
|
|
784
852
|
### `recommendLevel(tools, namespaceOf?) → Recommendation`
|
|
@@ -815,6 +883,34 @@ Methodology and repo conventions: [../AGENTS.md](../AGENTS.md).
|
|
|
815
883
|
|
|
816
884
|
## Providers
|
|
817
885
|
|
|
886
|
+
### Why `forGemini` returns one tool where the others return two
|
|
887
|
+
|
|
888
|
+
It isn't sending less. Gemini's API nests *all* function declarations inside a single
|
|
889
|
+
tool object — `[{ functionDeclarations: [...] }]` — where Anthropic and OpenAI take a
|
|
890
|
+
flat array of tools. At level 3 the two dispatcher tools (`t` and `q`) are both present;
|
|
891
|
+
they are just both inside that one wrapper. Count `tools[0].functionDeclarations.length`,
|
|
892
|
+
not `tools.length`.
|
|
893
|
+
|
|
894
|
+
### `forGemini` repairs three schema forms Gemini rejects
|
|
895
|
+
|
|
896
|
+
Gemini rejects the **whole request** if any single declaration is invalid, so one
|
|
897
|
+
non-conforming tool anywhere in your catalogue breaks every call. Three forms occur in
|
|
898
|
+
real MCP servers, and the adapter repairs all three:
|
|
899
|
+
|
|
900
|
+
| Form | Found in the real corpus | Repair |
|
|
901
|
+
|---|---|---|
|
|
902
|
+
| array with no `items` | 7 of 149 tools | adds `items: {}` |
|
|
903
|
+
| `enum` on a non-string type | `{type:"number", enum:[1,2,3]}` | drops the `enum`, keeps the type |
|
|
904
|
+
| union type `["string","array"]` | 1 tool | takes the first type |
|
|
905
|
+
|
|
906
|
+
**The dropped `enum` is not a lost constraint.** `validateArgs` checks arguments against
|
|
907
|
+
your *original* schema before dispatch at every level, so an out-of-range value is still
|
|
908
|
+
caught — the check moves from provider-side to library-side. We deliberately do not coerce
|
|
909
|
+
a numeric enum into a string enum: Gemini would accept it, and the model would then send
|
|
910
|
+
`"1"` where your API wants `1`, turning a caught error into silent bad data.
|
|
911
|
+
|
|
912
|
+
All 149 tools in the committed corpus pass Gemini at levels 0–3.
|
|
913
|
+
|
|
818
914
|
```ts
|
|
819
915
|
import { forAnthropic, forOpenAI, forOpenAIResponses, forGemini } from "toolgz";
|
|
820
916
|
```
|
|
@@ -859,7 +955,7 @@ and it does not get deleted.
|
|
|
859
955
|
## Development
|
|
860
956
|
|
|
861
957
|
```bash
|
|
862
|
-
npm test #
|
|
958
|
+
npm test # 268 tests, offline, no cost
|
|
863
959
|
npm run build # tsc → dist/ with .d.ts
|
|
864
960
|
|
|
865
961
|
npx tsx bench/harness/run-multi.ts --provider=all --reps=3 --variants # costs money
|
package/dist/providers/index.js
CHANGED
|
@@ -95,6 +95,50 @@ export function forGemini(c) {
|
|
|
95
95
|
? clean(v)
|
|
96
96
|
: v;
|
|
97
97
|
}
|
|
98
|
+
// Gemini rejects an array without `items`, and rejects the WHOLE request:
|
|
99
|
+
// 400 GenerateContentRequest.tools[0].function_declarations[4]
|
|
100
|
+
// .parameters.properties[shipments].items: missing field
|
|
101
|
+
//
|
|
102
|
+
// Real MCP servers ship these — 7 of the 149 tools in bench/fixtures have an
|
|
103
|
+
// untyped array parameter (analyze_consolidation.shipments,
|
|
104
|
+
// compute_route.intermediates, optimize_waypoints.waypoints among them). One such
|
|
105
|
+
// tool anywhere in the catalogue used to fail every Gemini call.
|
|
106
|
+
//
|
|
107
|
+
// `items: {}` is what Gemini accepts and is the only honest repair: the source
|
|
108
|
+
// schema does not say what the items are, so we do not invent a type. Guessing
|
|
109
|
+
// `{type:"string"}` would also be accepted and would make the model send strings
|
|
110
|
+
// where the real API may want objects — a silent wrong-data bug in place of a loud
|
|
111
|
+
// rejection. Verified empirically: omitted is rejected; `{}`, `{type:"string"}` and
|
|
112
|
+
// `{type:"object"}` are all accepted.
|
|
113
|
+
// JSON Schema permits a union type (`"type": ["string","array"]`, as
|
|
114
|
+
// send_email_with_attachments.cc uses). Gemini requires a single type string and
|
|
115
|
+
// rejects the whole request otherwise. Take the first — deterministic, and the
|
|
116
|
+
// prompt-cache prefix must be byte-stable — and drop `items` if the pick is not an
|
|
117
|
+
// array, where it would be meaningless.
|
|
118
|
+
//
|
|
119
|
+
// Narrowing is safe because the ORIGINAL schema still accepts the narrowed form, so
|
|
120
|
+
// a value the model sends against it remains valid; validateArgs checks against
|
|
121
|
+
// that original before dispatch.
|
|
122
|
+
if (Array.isArray(out.type)) {
|
|
123
|
+
out.type = out.type[0];
|
|
124
|
+
if (out.type !== "array")
|
|
125
|
+
delete out.items;
|
|
126
|
+
}
|
|
127
|
+
if (out.type === "array" && out.items === undefined)
|
|
128
|
+
out.items = {};
|
|
129
|
+
// Gemini accepts `enum` only on strings, and rejects the whole request otherwise:
|
|
130
|
+
// 400 Invalid value at tools[0].function_declarations[30].parameters.properties[2]
|
|
131
|
+
// Real MCP servers ship numeric enums — deep_research.depth is
|
|
132
|
+
// {type:"number", enum:[1,2,3]}. Verified: string+enum accepted, number+enum and
|
|
133
|
+
// integer+enum rejected, `oneOf` accepted (so it needs no repair).
|
|
134
|
+
//
|
|
135
|
+
// We drop the enum and keep the type. The constraint is not lost: validateArgs
|
|
136
|
+
// checks arguments against the ORIGINAL schema before dispatch at every level, so
|
|
137
|
+
// an out-of-range value is still caught — the check moves from provider-side to
|
|
138
|
+
// library-side. Coercing to a string enum instead would make the model send "1"
|
|
139
|
+
// where the API wants 1, which is a silent type error rather than a caught one.
|
|
140
|
+
if (out.enum && out.type && out.type !== "string")
|
|
141
|
+
delete out.enum;
|
|
98
142
|
return out;
|
|
99
143
|
};
|
|
100
144
|
const functionDeclarations = c.tools
|