toolgz 0.2.4 → 0.2.6
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 +51 -4
- package/dist/providers/index.js +44 -0
- package/dist/recommend.js +27 -10
- package/package.json +1 -1
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–
|
|
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>
|
|
6
6
|
|
|
7
7
|
<p>
|
|
8
8
|
<a href="#measured-results">420-run cross-provider sweep</a> ·
|
|
@@ -39,7 +39,7 @@ const { tools, system } = forAnthropic(c); // send these instead
|
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
`compress(myTools)` with no `level` gives you **level 1** — safe, native tool calling,
|
|
42
|
-
provider schema enforcement intact, and 13–32%
|
|
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
44
|
amortise the dispatcher. Ask for it explicitly with `{ level: 3 }` if you prefer.
|
|
45
45
|
|
|
@@ -84,7 +84,7 @@ claim about real deployments.
|
|
|
84
84
|
</picture>
|
|
85
85
|
|
|
86
86
|
**All figures below are level 3** (`minified-plus`, the shipped default map style).
|
|
87
|
-
Level 1 on the same sweep saves 13–32%; level 2 is dominated by level 3.
|
|
87
|
+
Level 1 on the same sweep saves 13–32%; on the real 149-tool corpus it saves 39%; level 2 is dominated by level 3.
|
|
88
88
|
|
|
89
89
|
| Provider | Model | Tool block | Prompt tokens | Latency | Tasks |
|
|
90
90
|
|---|---|---:|---:|---:|:-:|
|
|
@@ -283,6 +283,19 @@ trip. A test asserts that file matches the code, so it cannot drift.
|
|
|
283
283
|
|
|
284
284
|
---
|
|
285
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
|
+
|
|
286
299
|
## Optional: let the library pick the map style for your model
|
|
287
300
|
|
|
288
301
|
Level 3 has several map styles. Which one is cheapest turns out to depend on the
|
|
@@ -828,6 +841,12 @@ Returns:
|
|
|
828
841
|
| `resolve(name, args)` | `→ Resolution` | translate a model call back |
|
|
829
842
|
| `codeFor(name)` | `→ string` | real name → level-3 code; throws below level 3 |
|
|
830
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.
|
|
831
850
|
| `encodeCallForTest(name, args)` | `→ {name, args}` | build the raw call a model would emit; test aid |
|
|
832
851
|
|
|
833
852
|
### `recommendLevel(tools, namespaceOf?) → Recommendation`
|
|
@@ -864,6 +883,34 @@ Methodology and repo conventions: [../AGENTS.md](../AGENTS.md).
|
|
|
864
883
|
|
|
865
884
|
## Providers
|
|
866
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
|
+
|
|
867
914
|
```ts
|
|
868
915
|
import { forAnthropic, forOpenAI, forOpenAIResponses, forGemini } from "toolgz";
|
|
869
916
|
```
|
|
@@ -908,7 +955,7 @@ and it does not get deleted.
|
|
|
908
955
|
## Development
|
|
909
956
|
|
|
910
957
|
```bash
|
|
911
|
-
npm test #
|
|
958
|
+
npm test # 272 tests, offline, no cost
|
|
912
959
|
npm run build # tsc → dist/ with .d.ts
|
|
913
960
|
|
|
914
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
|
package/dist/recommend.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defaultNamespaceOf } from "./render/index.js";
|
|
2
|
+
import { compress } from "./index.js";
|
|
2
3
|
/**
|
|
3
4
|
* Pick a level for a given tool set.
|
|
4
5
|
*
|
|
@@ -34,23 +35,39 @@ export function recommendLevel(tools, namespaceOf = defaultNamespaceOf) {
|
|
|
34
35
|
const namespaceCount = namespaces.size;
|
|
35
36
|
const opsPerNamespace = namespaceCount ? toolCount / namespaceCount : 0;
|
|
36
37
|
const base = { toolCount, namespaceCount, opsPerNamespace };
|
|
37
|
-
|
|
38
|
+
// Size the level-1 block by asking the library what it would actually emit, rather
|
|
39
|
+
// than reconstructing it — an earlier version rebuilt the schema by hand, kept the
|
|
40
|
+
// full descriptions where level 1 emits a signature line, and overestimated by ~34%.
|
|
41
|
+
//
|
|
42
|
+
// `countSchemaTokensApprox` counts characters. Measured against `count_tokens` on
|
|
43
|
+
// real MCP tools the ratio is a stable ~2.1 chars/token (1.98 at 10 tools, 2.15 at
|
|
44
|
+
// 149), so dividing gives a fair local estimate with no API call.
|
|
45
|
+
const CHARS_PER_TOKEN = 2.1;
|
|
46
|
+
const l1Tokens = Math.round(compress(tools, { level: 1 }).stats.compressedChars / CHARS_PER_TOKEN);
|
|
47
|
+
// The old heuristic gated level 3 on `opsPerNamespace >= 4`. That is a LEVEL-2
|
|
48
|
+
// question: level 2 pays dispatcher overhead per namespace, so its shape matters
|
|
49
|
+
// there. Level 3 uses one flat dispatcher and does not care about namespaces at all.
|
|
50
|
+
//
|
|
51
|
+
// The consequence was concrete: on the 149-tool real corpus (63 namespaces, 2.4 ops
|
|
52
|
+
// each) it recommended level 1 at 41,648 tokens when level 3 measures 2,980 — leaving
|
|
53
|
+
// ~38,700 tokens on the table. Measured on real tools, level 3 is smaller at EVERY
|
|
54
|
+
// count tested, down to 5 tools (635 vs 1,178).
|
|
55
|
+
//
|
|
56
|
+
// So size never argues for level 1. What argues for level 1 is that it keeps the
|
|
57
|
+
// provider's own schema enforcement, which levels 2-3 give up. That is worth more
|
|
58
|
+
// than a saving you would not notice — hence an absolute threshold on the block,
|
|
59
|
+
// not a shape test.
|
|
60
|
+
const THRESHOLD_TOKENS = 4000;
|
|
61
|
+
if (l1Tokens < THRESHOLD_TOKENS) {
|
|
38
62
|
return {
|
|
39
63
|
...base,
|
|
40
64
|
level: 1,
|
|
41
|
-
reason: `
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
if (opsPerNamespace < 4) {
|
|
45
|
-
return {
|
|
46
|
-
...base,
|
|
47
|
-
level: 1,
|
|
48
|
-
reason: `${toolCount} tools spread across ${namespaceCount} namespaces (${opsPerNamespace.toFixed(1)} ops each). The compound-dispatcher overhead is paid per namespace, so a set this sparse stays smaller at level 1.`,
|
|
65
|
+
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.`,
|
|
49
66
|
};
|
|
50
67
|
}
|
|
51
68
|
return {
|
|
52
69
|
...base,
|
|
53
70
|
level: 3,
|
|
54
|
-
reason: `${toolCount} tools
|
|
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.`,
|
|
55
72
|
};
|
|
56
73
|
}
|