toolgz 0.1.2 → 0.2.1
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 +658 -31
- package/dist/index.d.ts +20 -1
- package/dist/index.js +60 -16
- package/dist/policy.generated.d.ts +64 -0
- package/dist/policy.generated.js +27 -0
- package/dist/types.d.ts +52 -13
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<h1>toolgz</h1>
|
|
2
2
|
|
|
3
|
-
<p><strong>Your agent spends 30–
|
|
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
|
+
|
|
5
|
+
<p><em>On a large tool set, at level 3. The safe default (level 1) reclaims 13–32% and gives up nothing — <code>recommendLevel()</code> picks for you.</em></p>
|
|
4
6
|
|
|
5
7
|
<p>
|
|
6
8
|
<a href="#measured-results">420-run cross-provider sweep</a> ·
|
|
@@ -29,12 +31,18 @@ Reclaiming the room is what this does.
|
|
|
29
31
|
## The fix, in three lines
|
|
30
32
|
|
|
31
33
|
```ts
|
|
32
|
-
import { compress, forAnthropic } from "toolgz";
|
|
34
|
+
import { compress, recommendLevel, forAnthropic } from "toolgz";
|
|
33
35
|
|
|
34
|
-
const
|
|
36
|
+
const { level } = recommendLevel(myTools); // 1 for small sets, 3 for large ones
|
|
37
|
+
const c = compress(myTools, { level }); // your existing MCP/SDK tool array
|
|
35
38
|
const { tools, system } = forAnthropic(c); // send these instead
|
|
36
39
|
```
|
|
37
40
|
|
|
41
|
+
`compress(myTools)` with no `level` gives you **level 1** — safe, native tool calling,
|
|
42
|
+
provider schema enforcement intact, and 13–32% smaller. The 71–85% figures below are
|
|
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.
|
|
45
|
+
|
|
38
46
|
Then translate the model's call back before you dispatch:
|
|
39
47
|
|
|
40
48
|
```ts
|
|
@@ -51,24 +59,80 @@ if (r.kind === "call") await myDispatch(r.name, r.args); // real name, real ar
|
|
|
51
59
|
Four frontier models, seven strategies, five tool-selection tasks, 3 reps —
|
|
52
60
|
**420 runs** on the current sweep, 1,200+ across all rounds. Every raw per-run record is
|
|
53
61
|
committed in [`bench/results/`](bench/results/); recompute any figure with
|
|
54
|
-
`npx tsx bench/analyze-multi.ts
|
|
62
|
+
`npx tsx bench/analyze-multi.ts --sweep=<timestamp>`.
|
|
63
|
+
|
|
64
|
+
**The table below uses a synthetic catalogue** — 100 realistic-but-invented MCP-style
|
|
65
|
+
tools across 9 namespaces — because it lets us build deliberately confusable clusters
|
|
66
|
+
that a real catalogue may not contain. Two things follow from that, and the first is
|
|
67
|
+
uncomfortable:
|
|
68
|
+
|
|
69
|
+
- Synthetic naming can flatter a compression style. One map style measured −21% on
|
|
70
|
+
this fixture because every tool name carried a `namespace_op` prefix to factor out.
|
|
71
|
+
On real MCP tools, which mostly do not, the same style was worth −1%.
|
|
72
|
+
- Real catalogues are **bigger**, so these numbers understate the problem. A corpus of
|
|
73
|
+
**149 tools harvested from 14 live MCP servers** measures **68,494 prompt tokens**
|
|
74
|
+
uncompressed on `claude-opus-5` — more than twice the synthetic fixture, and about a
|
|
75
|
+
third of a 200k context window before the user types anything.
|
|
76
|
+
|
|
77
|
+
The real corpus is committed at [`bench/fixtures/real-mcp-tools.json`](bench/fixtures/real-mcp-tools.json)
|
|
78
|
+
with its own scenario suite (`--suite=real`), and it is the corpus of record for any
|
|
79
|
+
claim about real deployments.
|
|
55
80
|
|
|
56
81
|
<picture>
|
|
57
82
|
<source media="(prefers-color-scheme: dark)" srcset="docs/img/savings-dark.svg">
|
|
58
83
|
<img src="docs/img/savings-light.svg" alt="Prompt tokens saved versus uncompressed tool definitions, by compression level, for each of four providers">
|
|
59
84
|
</picture>
|
|
60
85
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
|
65
|
-
|
|
66
|
-
|
|
|
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.
|
|
88
|
+
|
|
89
|
+
| Provider | Model | Tool block | Prompt tokens | Latency | Tasks |
|
|
90
|
+
|---|---|---:|---:|---:|:-:|
|
|
91
|
+
| Anthropic | `claude-opus-5` | 9,242 → **1,284** | 30,817 → **4,628** (**−85%**) | 15.0s → **12.1s** | 15/15 |
|
|
92
|
+
| xAI | `grok-4.5` | 6,421 → **775** | 17,522 → **2,663** (**−85%**) | 6.1s → **4.6s** | 15/15 |
|
|
93
|
+
| Google | `gemini-3.1-pro-preview` | 5,264 → **732** | 10,948 → **2,302** (**−79%**) | 5.6s → **5.5s** | 15/15 |
|
|
94
|
+
| OpenAI | `gpt-5.6-sol` | 2,752 → **573** | 7,694 → **2,196** (**−71%**) | 6.8s → **5.6s** | 15/15 |
|
|
67
95
|
|
|
68
96
|
Reasoning is enabled on all four at high effort, so this is a like-for-like frontier
|
|
69
97
|
comparison. **60/60 tasks completed, zero hallucinated tool names, zero malformed
|
|
70
98
|
arguments** — and it is faster than uncompressed on every provider.
|
|
71
99
|
|
|
100
|
+
Recompute any figure with
|
|
101
|
+
`npx tsx bench/analyze-multi.ts --sweep=2026-07-25T19-19` against the raw per-run
|
|
102
|
+
records in [`bench/results/`](bench/results).
|
|
103
|
+
|
|
104
|
+
### What about cost?
|
|
105
|
+
|
|
106
|
+
**Cost is not the claim, and we deliberately do not lead with it.** Prompt caching
|
|
107
|
+
already makes tool tokens cheap. What caching does not do is give you the *room* back,
|
|
108
|
+
and the room is what you run out of.
|
|
109
|
+
|
|
110
|
+
Cost does usually fall as a side effect, by an amount that depends on your provider
|
|
111
|
+
and reasoning settings — and on one of four providers we measured, it does not fall at
|
|
112
|
+
all. On `gpt-5.6-sol` the uncompressed cost distribution is heavily right-skewed (mean
|
|
113
|
+
$0.0172, median $0.0052), so a few expensive runs make compression look break-even
|
|
114
|
+
while the *typical* run gets about 2.5× dearer. We used to publish a "−7% on OpenAI"
|
|
115
|
+
figure. It was a mean over a skewed distribution and we withdrew it.
|
|
116
|
+
|
|
117
|
+
If you do want to optimise the bill, it is one option — and it is a trade, not a
|
|
118
|
+
freebie:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
compress(myTools, { level: 3, model: "gpt-5.6-sol", objective: "cost" });
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
| Model | Median cost vs default |
|
|
125
|
+
|---|---:|
|
|
126
|
+
| `gpt-5.6-sol` | **−20.7%** |
|
|
127
|
+
| `gemini-3.1-pro-preview` | **−15.4%** |
|
|
128
|
+
| `claude-opus-5` | **−9.0%** |
|
|
129
|
+
| `grok-4.5` | **+13.2%** — so it is not enabled there |
|
|
130
|
+
|
|
131
|
+
From 432 runs, 36 per style per provider, on the real 149-tool corpus. **What you give
|
|
132
|
+
up:** a slightly larger cached map (+275 characters), and on `grok-4.5` a worse bill —
|
|
133
|
+
which is why that row keeps the default rather than the cost-optimised style. Omit
|
|
134
|
+
`objective` and you get the conservative default everywhere, unchanged.
|
|
135
|
+
|
|
72
136
|
### It does not make the model worse
|
|
73
137
|
|
|
74
138
|
That was the thing to disprove, and we tried hard to. The task suite is built from
|
|
@@ -86,26 +150,28 @@ problem and looks up what it needs. The default map style exists because of the
|
|
|
86
150
|
bare tool names failed on `grok-4.5` **deterministically**, 3 of 3 attempts on one scenario,
|
|
87
151
|
answering with zero tool calls and no error raised. Naming the required arguments fixed it.
|
|
88
152
|
|
|
89
|
-
###
|
|
153
|
+
### How we found the cost story, and got it wrong twice
|
|
90
154
|
|
|
91
155
|
<picture>
|
|
92
156
|
<source media="(prefers-color-scheme: dark)" srcset="docs/img/cost-dark.svg">
|
|
93
|
-
<img src="docs/img/cost-light.svg" alt="Prompt tokens and cost
|
|
157
|
+
<img src="docs/img/cost-light.svg" alt="Prompt tokens and cost, by compression level, for each of four providers">
|
|
94
158
|
</picture>
|
|
95
159
|
|
|
96
|
-
The first cross-provider sweep found cost going **up 15% on OpenAI** even while context
|
|
97
|
-
69%. The dispatcher was spending extra turns, and on a reasoning model every turn
|
|
98
|
-
fresh round of thinking.
|
|
160
|
+
The first cross-provider sweep found cost going **up 15% on OpenAI** even while context
|
|
161
|
+
fell 69%. The dispatcher was spending extra turns, and on a reasoning model every turn
|
|
162
|
+
pays for a fresh round of thinking.
|
|
99
163
|
|
|
100
|
-
So we captured the calls
|
|
101
|
-
|
|
102
|
-
sometimes call the map code as the tool name, and they sometimes pass arguments flat
|
|
103
|
-
of nested. Fixing all three
|
|
104
|
-
|
|
164
|
+
So we captured the calls being rejected instead of guessing, and found three bugs in
|
|
165
|
+
*this library*: models pass `query` to a parameter named `q` (14 of 18 rejections), they
|
|
166
|
+
sometimes call the map code as the tool name, and they sometimes pass arguments flat
|
|
167
|
+
instead of nested. Fixing all three drove malformed arguments to **zero on every
|
|
168
|
+
provider**. Later rounds found three more of the same kind — a namespace joined with a
|
|
169
|
+
dot, the lookup tool routed through the dispatcher — all shipped in 0.1.2.
|
|
105
170
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
171
|
+
That is the honest shape of this work: **most of the wins came from accepting what
|
|
172
|
+
models actually send, not from making the map smaller.** One extra turn is worth ~3,300
|
|
173
|
+
prompt tokens; the best encoding change available was worth ~550. Six map styles were
|
|
174
|
+
tried and removed in 0.2.0 because they were smaller and still worse.
|
|
109
175
|
|
|
110
176
|
---
|
|
111
177
|
|
|
@@ -168,15 +234,576 @@ trip. A test asserts that file matches the code, so it cannot drift.
|
|
|
168
234
|
|
|
169
235
|
---
|
|
170
236
|
|
|
171
|
-
##
|
|
237
|
+
## Optional: let the library pick the map style for your model
|
|
238
|
+
|
|
239
|
+
Level 3 has several map styles. Which one is cheapest turns out to depend on the
|
|
240
|
+
model, so you can hand `compress()` a model id and let it use what was actually
|
|
241
|
+
measured:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
compress(myTools, { level: 3, model: "gpt-5.6-sol", objective: "cost" });
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
| Model | Style chosen for `cost` | Measured against the default |
|
|
248
|
+
|---|---|---:|
|
|
249
|
+
| `gpt-5.6-sol` | `explicit` | **−20.7%** |
|
|
250
|
+
| `gemini-3.1-pro-preview` | `explicit` | **−15.4%** |
|
|
251
|
+
| `claude-opus-5` | `explicit` | **−9.0%** |
|
|
252
|
+
| `grok-4.5` | *default* | `explicit` measured **+13.2%** there |
|
|
253
|
+
|
|
254
|
+
From a 432-run sweep, 36 runs per style per provider, on the real 149-tool corpus.
|
|
255
|
+
`explicit` completed 144/144 tasks and cut lookups on all four providers; only the
|
|
256
|
+
*cost* consequence differs by model. The table lives in
|
|
257
|
+
[`src/policy.generated.ts`](src/policy.generated.ts), is generated from the committed
|
|
258
|
+
results, and a test fails if it drifts from them.
|
|
259
|
+
|
|
260
|
+
Four things worth knowing:
|
|
261
|
+
|
|
262
|
+
- **Omitting `model` changes nothing.** Existing behaviour is byte-identical; there
|
|
263
|
+
is a test asserting that.
|
|
264
|
+
- **`objective` defaults to `occupancy`, which has no table.** Every style we
|
|
265
|
+
measured landed within ±3.1% of the default on context occupancy — under our 5%
|
|
266
|
+
effect-size floor — so there is nothing to select. Only `cost` has entries.
|
|
267
|
+
- **An absent model gets the default.** That is an absence of evidence, not a
|
|
268
|
+
prediction. `gpt-5.6-sol` behaving one way says nothing certain about `gpt-5.7`.
|
|
269
|
+
- **`stats` always tells you what was actually used**, so nothing is substituted
|
|
270
|
+
silently:
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
const c = compress(myTools, { level: 3, model: "gpt-5.6-sol", objective: "cost" });
|
|
274
|
+
c.stats.mapStyle; // "explicit" — what was used
|
|
275
|
+
c.stats.requestedMapStyle; // undefined — you did not ask for a specific style
|
|
276
|
+
c.stats.fallbackReason; // undefined — nothing was substituted
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
There is also a safety valve: if a future sweep finds a `(model, style)` pair that
|
|
280
|
+
fails, it is refused and `stats.fallbackReason` says why. **That table is currently
|
|
281
|
+
empty** — the one pair ever measured unsafe was `nocode` on `grok-4.5` (19% of runs
|
|
282
|
+
answered with no tool call at all), and rather than document a footgun we deleted the
|
|
283
|
+
style in 0.2.0.
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Using it: the full guide
|
|
288
|
+
|
|
289
|
+
Everything below was a separate `docs/GUIDE.md`. It is inline now, deliberately: the
|
|
290
|
+
same claims lived in both files and drifted apart — the README advertised level 3's
|
|
291
|
+
savings while its own quick-start example used level 1. One document cannot contradict
|
|
292
|
+
itself.
|
|
293
|
+
|
|
294
|
+
Reference material that is generated or historical stays separate, because it is not
|
|
295
|
+
hand-maintained prose:
|
|
296
|
+
|
|
297
|
+
- **[docs/RESULTS.md](docs/RESULTS.md)** — every benchmark round, the raw evidence log
|
|
298
|
+
- **[docs/BEFORE-AFTER.md](docs/BEFORE-AFTER.md)** — generated by executing the library
|
|
299
|
+
- **[docs/RELEASING.md](docs/RELEASING.md)** — release process
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
## 1. The agent loop, in full
|
|
304
|
+
|
|
305
|
+
A complete, working Anthropic loop. The only additions to a normal loop are the
|
|
306
|
+
`compress()` call at the top and the `resolve()` call before dispatch.
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
310
|
+
import { compress, forAnthropic } from "toolgz";
|
|
311
|
+
|
|
312
|
+
const client = new Anthropic();
|
|
313
|
+
const SYSTEM = "You are a helpful operations agent. Use the tools available.";
|
|
314
|
+
|
|
315
|
+
// 1. Compress once, outside the loop. It is pure and deterministic.
|
|
316
|
+
const c = compress(myTools, { level: 3 });
|
|
317
|
+
const { tools, system } = forAnthropic(c);
|
|
318
|
+
|
|
319
|
+
export async function run(userMessage: string) {
|
|
320
|
+
const messages: any[] = [{ role: "user", content: userMessage }];
|
|
321
|
+
|
|
322
|
+
for (let turn = 0; turn < 12; turn++) {
|
|
323
|
+
const res = await client.messages.create({
|
|
324
|
+
model: "claude-opus-5",
|
|
325
|
+
max_tokens: 8000,
|
|
326
|
+
system: [{ type: "text", text: SYSTEM }, ...(system ?? [])],
|
|
327
|
+
tools: tools as any,
|
|
328
|
+
messages,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const calls = res.content.filter((b: any) => b.type === "tool_use");
|
|
332
|
+
if (!calls.length) return res; // model is done
|
|
333
|
+
|
|
334
|
+
// 2. Echo the assistant turn back verbatim. On Anthropic this matters:
|
|
335
|
+
// thinking blocks must be returned unchanged or the model re-reasons.
|
|
336
|
+
messages.push({ role: "assistant", content: res.content });
|
|
337
|
+
|
|
338
|
+
const results: any[] = [];
|
|
339
|
+
for (const call of calls) {
|
|
340
|
+
const r = c.resolve(call.name, (call as any).input);
|
|
341
|
+
|
|
342
|
+
if (r.kind === "call") {
|
|
343
|
+
const output = await myDispatch(r.name, r.args); // your real handler
|
|
344
|
+
results.push({
|
|
345
|
+
type: "tool_result",
|
|
346
|
+
tool_use_id: call.id,
|
|
347
|
+
content: JSON.stringify(output),
|
|
348
|
+
});
|
|
349
|
+
} else if (r.kind === "meta") {
|
|
350
|
+
// The model asked toolgz a lookup question. Answer it; don't dispatch.
|
|
351
|
+
results.push({
|
|
352
|
+
type: "tool_result",
|
|
353
|
+
tool_use_id: call.id,
|
|
354
|
+
content: r.result,
|
|
355
|
+
});
|
|
356
|
+
} else {
|
|
357
|
+
// Bad arguments or an unknown code. The message is written for the
|
|
358
|
+
// model to read — hand it straight back and let it retry.
|
|
359
|
+
results.push({
|
|
360
|
+
type: "tool_result",
|
|
361
|
+
tool_use_id: call.id,
|
|
362
|
+
content: `Error: ${r.message}`,
|
|
363
|
+
is_error: true,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
messages.push({ role: "user", content: results });
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Three rules that matter:
|
|
374
|
+
|
|
375
|
+
1. **Call `compress()` once**, outside the loop. Calling it per turn is wasteful
|
|
376
|
+
and, if you vary the options, breaks prompt caching.
|
|
377
|
+
2. **Never dispatch on `meta` or `error`.** Only `kind === "call"` is a real
|
|
378
|
+
tool invocation.
|
|
379
|
+
3. **Feed errors back rather than throwing.** Recovery is the design; the error
|
|
380
|
+
strings are written for the model.
|
|
381
|
+
|
|
382
|
+
---
|
|
383
|
+
|
|
384
|
+
## 2. Handling the three outcomes
|
|
385
|
+
|
|
386
|
+
`resolve()` returns a discriminated union. Handle all three.
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
const r = c.resolve(rawName, rawArgs);
|
|
390
|
+
|
|
391
|
+
switch (r.kind) {
|
|
392
|
+
case "call":
|
|
393
|
+
// r.name → the original tool name, e.g. "github_create_issue"
|
|
394
|
+
// r.args → validated against your original schema
|
|
395
|
+
await myDispatch(r.name, r.args);
|
|
396
|
+
break;
|
|
397
|
+
|
|
398
|
+
case "meta":
|
|
399
|
+
// The model used a toolgz lookup tool (q / describe_op).
|
|
400
|
+
// r.result is text to return as the tool result. Do not dispatch.
|
|
401
|
+
reply(r.result);
|
|
402
|
+
break;
|
|
403
|
+
|
|
404
|
+
case "error":
|
|
405
|
+
// r.message → model-readable: names the tool, the parameter, the fix
|
|
406
|
+
// r.recoverable → true when retrying can plausibly succeed
|
|
407
|
+
reply(r.message, { isError: true });
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
What produces each:
|
|
413
|
+
|
|
414
|
+
| Outcome | Cause | You should |
|
|
415
|
+
|---|---|---|
|
|
416
|
+
| `call` | valid invocation | dispatch it |
|
|
417
|
+
| `meta` | model asked for a definition or searched the map | return `r.result` as the tool result |
|
|
418
|
+
| `error` | missing/unknown/wrong-typed argument, or an unknown code | return `r.message` with an error flag |
|
|
419
|
+
|
|
420
|
+
Argument validation runs against your **original** schema, not the compressed
|
|
421
|
+
one, so an `error` here means the call genuinely would not have worked.
|
|
422
|
+
|
|
423
|
+
---
|
|
424
|
+
|
|
425
|
+
## 3. Provider setup
|
|
426
|
+
|
|
427
|
+
The core is provider-neutral. Adapters handle wire shape and cache placement.
|
|
428
|
+
They are pure functions and never mutate what you pass them.
|
|
429
|
+
|
|
430
|
+
### Anthropic
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
import { compress, forAnthropic } from "toolgz";
|
|
434
|
+
|
|
435
|
+
const c = compress(myTools, { level: 3 });
|
|
436
|
+
const { tools, system } = forAnthropic(c); // or { ttl: "1h" }
|
|
437
|
+
|
|
438
|
+
await client.messages.create({
|
|
439
|
+
model: "claude-opus-5",
|
|
440
|
+
max_tokens: 8000,
|
|
441
|
+
system: [{ type: "text", text: SYSTEM }, ...(system ?? [])],
|
|
442
|
+
tools,
|
|
443
|
+
messages,
|
|
444
|
+
});
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
`forAnthropic` places exactly one `cache_control` breakpoint on the last
|
|
448
|
+
eligible tool, and skips any tool carrying `defer_loading` because the API
|
|
449
|
+
rejects that combination.
|
|
450
|
+
|
|
451
|
+
### OpenAI
|
|
452
|
+
|
|
453
|
+
OpenAI has **two endpoints with two different tool shapes**, and toolgz has an
|
|
454
|
+
adapter for each. Pick by whether you want reasoning.
|
|
455
|
+
|
|
456
|
+
**If you want tools *and* reasoning — use `/v1/responses`.** On the GPT-5.x
|
|
457
|
+
line, `/v1/chat/completions` refuses the combination outright:
|
|
458
|
+
|
|
459
|
+
```
|
|
460
|
+
Function tools with reasoning_effort are not supported for gpt-5.6-sol in
|
|
461
|
+
/v1/chat/completions. To use function tools, use /v1/responses or set
|
|
462
|
+
reasoning_effort to 'none'.
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
```ts
|
|
466
|
+
import { compress, forOpenAIResponses } from "toolgz";
|
|
467
|
+
|
|
468
|
+
const c = compress(myTools, { level: 3 });
|
|
469
|
+
const { tools, systemPreamble } = forOpenAIResponses(c); // flat tool shape
|
|
470
|
+
|
|
471
|
+
const res = await client.responses.create({
|
|
472
|
+
model: "gpt-5.6-sol",
|
|
473
|
+
reasoning: { effort: "high" },
|
|
474
|
+
max_output_tokens: 8000,
|
|
475
|
+
tools,
|
|
476
|
+
input: [
|
|
477
|
+
{ type: "message", role: "developer",
|
|
478
|
+
content: SYSTEM + (systemPreamble ? "\n\n" + systemPreamble : "") },
|
|
479
|
+
{ type: "message", role: "user", content: userMessage },
|
|
480
|
+
],
|
|
481
|
+
});
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
Two things about `/v1/responses` that will bite you otherwise:
|
|
485
|
+
|
|
486
|
+
- History is a flat `input[]` list, not `messages`. A tool call round-trip is
|
|
487
|
+
`{type:"function_call", call_id, name, arguments}` followed by
|
|
488
|
+
`{type:"function_call_output", call_id, output}`.
|
|
489
|
+
- **Reasoning items must be echoed back** alongside tool outputs on the next
|
|
490
|
+
turn, or the model re-reasons from scratch every turn. Push the whole
|
|
491
|
+
`response.output` array back verbatim.
|
|
492
|
+
|
|
493
|
+
**If you don't need reasoning**, chat completions is fine and the tool shape is
|
|
494
|
+
the nested one:
|
|
495
|
+
|
|
496
|
+
```ts
|
|
497
|
+
import { compress, forOpenAI } from "toolgz";
|
|
498
|
+
|
|
499
|
+
const { tools, systemPreamble } = forOpenAI(compress(myTools, { level: 3 }));
|
|
500
|
+
|
|
501
|
+
await client.chat.completions.create({
|
|
502
|
+
model: "gpt-5.6-sol",
|
|
503
|
+
messages: [
|
|
504
|
+
{ role: "system", content: SYSTEM + (systemPreamble ? "\n\n" + systemPreamble : "") },
|
|
505
|
+
...messages,
|
|
506
|
+
],
|
|
507
|
+
tools,
|
|
508
|
+
});
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
Either way, OpenAI's prefix caching is automatic with a ~1024-token floor, so
|
|
512
|
+
there is no breakpoint to place — just keep your prefix stable. Cached input
|
|
513
|
+
bills at roughly a tenth of the normal rate.
|
|
514
|
+
|
|
515
|
+
`forOpenAI` emits `{type:"function", function:{…}}`; `forOpenAIResponses` emits
|
|
516
|
+
the flat `{type:"function", name, …}`. Sending one shape to the other endpoint
|
|
517
|
+
is a validation error, so match them.
|
|
518
|
+
|
|
519
|
+
### Google Gemini
|
|
520
|
+
|
|
521
|
+
```ts
|
|
522
|
+
import { compress, forGemini } from "toolgz";
|
|
523
|
+
|
|
524
|
+
const c = compress(myTools, { level: 3 });
|
|
525
|
+
const { tools, systemPreamble } = forGemini(c);
|
|
526
|
+
|
|
527
|
+
await ai.models.generateContent({
|
|
528
|
+
model: "gemini-3.1-pro-preview",
|
|
529
|
+
contents,
|
|
530
|
+
config: {
|
|
531
|
+
systemInstruction: SYSTEM + (systemPreamble ? "\n\n" + systemPreamble : ""),
|
|
532
|
+
tools,
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
`forGemini` strips the JSON Schema keywords Gemini rejects
|
|
538
|
+
(`additionalProperties`, `$schema`, `default`, `examples`) recursively.
|
|
539
|
+
|
|
540
|
+
### xAI
|
|
541
|
+
|
|
542
|
+
xAI's API is OpenAI-compatible, so use `forOpenAI`:
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
import OpenAI from "openai";
|
|
546
|
+
import { compress, forOpenAI } from "toolgz";
|
|
547
|
+
|
|
548
|
+
const client = new OpenAI({
|
|
549
|
+
apiKey: process.env.XAI_API_KEY,
|
|
550
|
+
baseURL: "https://api.x.ai/v1",
|
|
551
|
+
});
|
|
552
|
+
const { tools, systemPreamble } = forOpenAI(compress(myTools, { level: 3 }));
|
|
553
|
+
```
|
|
172
554
|
|
|
173
|
-
|
|
555
|
+
> **Note:** xAI excludes reasoning tokens from `completion_tokens`. If you're
|
|
556
|
+
> tracking spend, add `usage.completion_tokens_details.reasoning_tokens`.
|
|
557
|
+
|
|
558
|
+
---
|
|
559
|
+
|
|
560
|
+
## 4. Prompt caching
|
|
561
|
+
|
|
562
|
+
**Read this even if you skip everything else.** Prompt caching is what makes the
|
|
563
|
+
per-turn cost of your tool block collapse, and it is easy to break by accident.
|
|
564
|
+
|
|
565
|
+
Caching is a **prefix match**. Any byte that changes invalidates everything after
|
|
566
|
+
it. Tool definitions render first, so a single reordered tool re-bills your whole
|
|
567
|
+
prompt.
|
|
568
|
+
|
|
569
|
+
`compress()` is built for this: it sorts tools by name and emits byte-identical
|
|
570
|
+
output for identical input. There is a test asserting it, and that test does not
|
|
571
|
+
get deleted.
|
|
572
|
+
|
|
573
|
+
What breaks caching — check your own code for these:
|
|
574
|
+
|
|
575
|
+
| Mistake | Why it breaks |
|
|
174
576
|
|---|---|
|
|
175
|
-
|
|
|
176
|
-
|
|
|
177
|
-
|
|
|
178
|
-
|
|
|
179
|
-
|
|
|
577
|
+
| `new Date().toISOString()` in the system prompt | prefix differs every request |
|
|
578
|
+
| a request id or UUID near the top | same |
|
|
579
|
+
| building tools from an unsorted `Set`/`Map` | serialization order drifts |
|
|
580
|
+
| calling `compress()` with different options per turn | different payload |
|
|
581
|
+
| adding or removing a tool mid-conversation | tool block is position 0 |
|
|
582
|
+
|
|
583
|
+
Verify it's working — don't assume:
|
|
584
|
+
|
|
585
|
+
```ts
|
|
586
|
+
const res = await client.messages.create({ /* … */ });
|
|
587
|
+
console.log(res.usage.cache_read_input_tokens); // should be > 0 after turn 1
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
If that stays `0` across turns with an identical prefix, something upstream is
|
|
591
|
+
varying. Diff two rendered request bodies to find it.
|
|
592
|
+
|
|
593
|
+
---
|
|
594
|
+
|
|
595
|
+
## 5. Composing with Anthropic's native tool search
|
|
596
|
+
|
|
597
|
+
Anthropic ships server-side tool search (`tool_search_tool_regex_20251119` plus
|
|
598
|
+
`defer_loading: true`). It defers *whole schemas*; toolgz shrinks *each schema*.
|
|
599
|
+
They're orthogonal and compose:
|
|
600
|
+
|
|
601
|
+
```ts
|
|
602
|
+
const c = compress(myTools, { level: 1 });
|
|
603
|
+
const tools = [
|
|
604
|
+
{ type: "tool_search_tool_regex_20251119", name: "tool_search_tool_regex" },
|
|
605
|
+
...(c.tools as any[]).map((t, i) => (i < 5 ? t : { ...t, defer_loading: true })),
|
|
606
|
+
];
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
Two API constraints: at least one tool must stay non-deferred, and
|
|
610
|
+
`cache_control` cannot go on a deferred tool (`forAnthropic` handles the second).
|
|
611
|
+
|
|
612
|
+
**One measured caveat.** `defer_loading` hides tools until the model *elects to
|
|
613
|
+
search*. On Claude Opus 5 it elects reliably — 20/20 tasks. On Haiku 4.5 it
|
|
614
|
+
often does not: **6 of 30 tasks**, with four of five scenarios answered in a
|
|
615
|
+
single turn and zero tool calls. No error is raised; the request succeeds and the
|
|
616
|
+
answer is simply unaided.
|
|
617
|
+
|
|
618
|
+
A dispatcher doesn't share that failure mode, for a structural reason: `t` and
|
|
619
|
+
`q` are ordinary always-visible tools, so the model cannot forget to search —
|
|
620
|
+
searching is the only thing on offer. Deferred loading makes discovery
|
|
621
|
+
*optional*; a dispatcher makes it *the entry point*.
|
|
622
|
+
|
|
623
|
+
Rule of thumb: compose with native search on frontier models; prefer level 3
|
|
624
|
+
alone below that tier.
|
|
625
|
+
|
|
626
|
+
---
|
|
627
|
+
|
|
628
|
+
## 6. MCP servers
|
|
629
|
+
|
|
630
|
+
MCP tool definitions already match the input shape, so there's nothing to
|
|
631
|
+
convert:
|
|
632
|
+
|
|
633
|
+
```ts
|
|
634
|
+
const { tools: mcpTools } = await mcpClient.listTools();
|
|
635
|
+
const c = compress(mcpTools, { level: 3 });
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
Aggregating several servers is where the payoff is largest. Namespace the names
|
|
639
|
+
so the grouping is meaningful:
|
|
640
|
+
|
|
641
|
+
```ts
|
|
642
|
+
const all = [];
|
|
643
|
+
for (const [serverName, client] of servers) {
|
|
644
|
+
const { tools } = await client.listTools();
|
|
645
|
+
all.push(...tools.map((t) => ({ ...t, name: `${serverName}_${t.name}` })));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const c = compress(all, { level: 3 });
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
Default namespacing splits on the first `_` or `.`. Override it if your names
|
|
652
|
+
don't follow that convention:
|
|
653
|
+
|
|
654
|
+
```ts
|
|
655
|
+
compress(all, {
|
|
656
|
+
level: 3,
|
|
657
|
+
namespaceOf: (name) => {
|
|
658
|
+
const [ns, ...rest] = name.split("::");
|
|
659
|
+
return { ns, op: rest.join("::") };
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
---
|
|
665
|
+
|
|
666
|
+
## 7. Troubleshooting
|
|
667
|
+
|
|
668
|
+
**The model calls `t` or `q` and my dispatcher explodes.**
|
|
669
|
+
You're dispatching on every tool call. Only `kind === "call"` is real; `meta`
|
|
670
|
+
and `error` are toolgz's own tools and must be answered, not dispatched. See
|
|
671
|
+
[§5](#5-handling-the-three-outcomes).
|
|
672
|
+
|
|
673
|
+
**`resolve()` returns `error: unknown parameter "x"`.**
|
|
674
|
+
The model invented a parameter. Feed `r.message` back as an error tool result —
|
|
675
|
+
it names the accepted parameters and the model will retry. This is expected on
|
|
676
|
+
levels 2 and 3 and is exactly what validation is for.
|
|
677
|
+
|
|
678
|
+
**Lots of malformed arguments.**
|
|
679
|
+
First check you have not overridden `mapStyle` away from the default
|
|
680
|
+
`"name+required"` — on the current sweep that default produced **zero**
|
|
681
|
+
malformed arguments on all four providers, while the bare-name map still
|
|
682
|
+
produced them. If you are on the default and still seeing errors, try
|
|
683
|
+
`mapStyle: "signature"` (the model then has the optional parameters too), or
|
|
684
|
+
drop to level 1, which keeps provider-side schema enforcement.
|
|
685
|
+
|
|
686
|
+
The error messages are written to be actionable: a near-miss parameter name is
|
|
687
|
+
named explicitly ("You passed \"query\" — did you mean \"q\"? Rename it."), and a
|
|
688
|
+
wrong-case enum value shows the exact accepted spelling. Feed them straight back
|
|
689
|
+
and the retry usually lands first time.
|
|
690
|
+
|
|
691
|
+
**`cache_read_input_tokens` is always 0.**
|
|
692
|
+
Something in your prefix varies per request. See [§7](#7-prompt-caching).
|
|
693
|
+
|
|
694
|
+
**Gemini rejects my schema.**
|
|
695
|
+
Use `forGemini`, which strips the keywords Gemini won't accept. If a new one
|
|
696
|
+
appears, it's a one-line addition to the adapter.
|
|
697
|
+
|
|
698
|
+
**Savings look small.**
|
|
699
|
+
Check tool count and shape with `recommendLevel(myTools)`. Under ~15 tools
|
|
700
|
+
there's little to reclaim. Also check `c.stats`:
|
|
701
|
+
|
|
702
|
+
```ts
|
|
703
|
+
console.log(c.stats);
|
|
704
|
+
// { level: 3, toolCount: 100, wireToolCount: 2,
|
|
705
|
+
// originalChars: 61461, compressedChars: 2211, savedPct: 96.4 }
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
`savedPct` is a character-count proxy, not a token count — useful for a quick
|
|
709
|
+
sanity check, not for billing.
|
|
710
|
+
|
|
711
|
+
**I need the real token numbers.**
|
|
712
|
+
Count them with the provider's own endpoint. Never use `tiktoken` for Claude —
|
|
713
|
+
it's OpenAI's tokenizer and is wrong for Claude by 15–20%+.
|
|
714
|
+
|
|
715
|
+
```ts
|
|
716
|
+
const { input_tokens } = await client.messages.countTokens({
|
|
717
|
+
model: "claude-opus-5",
|
|
718
|
+
tools: tools as any,
|
|
719
|
+
system: SYSTEM,
|
|
720
|
+
messages: [{ role: "user", content: "x" }],
|
|
721
|
+
});
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
---
|
|
725
|
+
|
|
726
|
+
## 8. Benchmarking your own tools
|
|
727
|
+
|
|
728
|
+
Don't take our numbers for your workload — especially at level 3. The repo ships
|
|
729
|
+
the harness:
|
|
730
|
+
|
|
731
|
+
```bash
|
|
732
|
+
git clone https://github.com/dperussina/toolgz
|
|
733
|
+
cd toolgz && npm install
|
|
734
|
+
cp .env.example .env # add your keys
|
|
735
|
+
|
|
736
|
+
npm test # 107 offline tests, no network, no cost
|
|
737
|
+
|
|
738
|
+
# one provider, one scenario, cheap
|
|
739
|
+
npx tsx bench/harness/run-multi.ts --provider=openai \
|
|
740
|
+
--scenario=acc-search-vs-list --reps=1
|
|
741
|
+
|
|
742
|
+
# the full comparison (costs money)
|
|
743
|
+
npx tsx bench/harness/run-multi.ts --provider=all --reps=3 --variants
|
|
744
|
+
|
|
745
|
+
npx tsx bench/analyze-multi.ts # per-arm table + ranking stability
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
To use your own tools and tasks, edit `bench/fixtures/tools.ts` and
|
|
749
|
+
`bench/scenarios-accuracy.ts`. A scenario declares its tools, the prompt, and
|
|
750
|
+
the expected call, so grading is mechanical rather than model-judged.
|
|
751
|
+
|
|
752
|
+
Raw per-run JSONL lands in `bench/results/` and is committed, so every published
|
|
753
|
+
number can be recomputed rather than trusted.
|
|
754
|
+
|
|
755
|
+
---
|
|
756
|
+
|
|
757
|
+
## 9. API reference
|
|
758
|
+
|
|
759
|
+
### `compress(tools, options?) → CompressResult`
|
|
760
|
+
|
|
761
|
+
| Option | Type | Default | Notes |
|
|
762
|
+
|---|---|---|---|
|
|
763
|
+
| `level` | `0 \| 1 \| 2 \| 3` | `1` | see [§3](#3-which-level-should-i-use) |
|
|
764
|
+
| `mapStyle` | `"name+required" \| "explicit" \| "signature"` | `"name+required"` | level 3 only |
|
|
765
|
+
| `namespaceOf` | `(name) => {ns, op}` | split on first `_`/`.` | levels 2–3 grouping |
|
|
766
|
+
| `aliasOf` | `(ns) => string` | identity | level 2 tool naming |
|
|
767
|
+
| `searchLimit` | `number` | `8` | max results from a `q` search |
|
|
768
|
+
| `validate` | `boolean` | `true` | **leave this on** |
|
|
769
|
+
|
|
770
|
+
Returns:
|
|
771
|
+
|
|
772
|
+
| Field | Type | Notes |
|
|
773
|
+
|---|---|---|
|
|
774
|
+
| `tools` | `unknown[]` | send these; Anthropic shape |
|
|
775
|
+
| `systemPreamble` | `string` | append to your system prompt; `""` at levels 0–1 |
|
|
776
|
+
| `cachePreamble` | `boolean` | whether the preamble should sit behind a breakpoint |
|
|
777
|
+
| `resolve(name, args)` | `→ Resolution` | translate a model call back |
|
|
778
|
+
| `codeFor(name)` | `→ string` | real name → level-3 code; throws below level 3 |
|
|
779
|
+
| `stats` | `CompressStats` | `level`, `toolCount`, `wireToolCount`, `savedPct`, … |
|
|
780
|
+
|
|
781
|
+
### `recommendLevel(tools, namespaceOf?) → Recommendation`
|
|
782
|
+
|
|
783
|
+
`{ level, reason, toolCount, namespaceCount, opsPerNamespace }`. Returns 1 or 3.
|
|
784
|
+
|
|
785
|
+
### Provider adapters
|
|
786
|
+
|
|
787
|
+
| Adapter | Endpoint | Tool shape |
|
|
788
|
+
|---|---|---|
|
|
789
|
+
| `forAnthropic(c, { cache?, ttl? })` → `{ tools, system }` | Messages API | Anthropic native; places one `cache_control` breakpoint |
|
|
790
|
+
| `forOpenAI(c)` → `{ tools, systemPreamble }` | `/v1/chat/completions` | nested `{type, function:{…}}` |
|
|
791
|
+
| `forOpenAIResponses(c)` → `{ tools, systemPreamble }` | `/v1/responses` | **flat** `{type, name, …}` — required for tools + reasoning |
|
|
792
|
+
| `forGemini(c)` → `{ tools, systemPreamble }` | `generateContent` | one `functionDeclarations` array |
|
|
793
|
+
|
|
794
|
+
### Renderers (exported for tooling)
|
|
795
|
+
|
|
796
|
+
- `signatureLine(tool, nameOverride?)` → `"name(a,b?:x|y)"`
|
|
797
|
+
- `flattenSchema(schema)` → schema with prose and boilerplate removed
|
|
798
|
+
- `countSchemaTokensApprox(value)` → character-count proxy, **not** a token count
|
|
799
|
+
|
|
800
|
+
---
|
|
801
|
+
|
|
802
|
+
Measured results and their limits: [RESULTS.md](RESULTS.md).
|
|
803
|
+
Methodology and repo conventions: [../AGENTS.md](../AGENTS.md).
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
---
|
|
180
807
|
|
|
181
808
|
## Providers
|
|
182
809
|
|
|
@@ -224,7 +851,7 @@ and it does not get deleted.
|
|
|
224
851
|
## Development
|
|
225
852
|
|
|
226
853
|
```bash
|
|
227
|
-
npm test #
|
|
854
|
+
npm test # 245 tests, offline, no cost
|
|
228
855
|
npm run build # tsc → dist/ with .d.ts
|
|
229
856
|
|
|
230
857
|
npx tsx bench/harness/run-multi.ts --provider=all --reps=3 --variants # costs money
|
package/dist/index.d.ts
CHANGED
|
@@ -13,9 +13,28 @@
|
|
|
13
13
|
* if (r.kind === "call") await myDispatch(r.name, r.args);
|
|
14
14
|
* }
|
|
15
15
|
*/
|
|
16
|
-
import type { CompressOptions, CompressResult, Tool } from "./types.js";
|
|
16
|
+
import type { CompressOptions, CompressResult, Tool, MapStyle } from "./types.js";
|
|
17
|
+
import type { PolicyEntry, BrokenEntry } from "./policy.generated.js";
|
|
17
18
|
export * from "./types.js";
|
|
18
19
|
export { flattenSchema, signatureLine, countSchemaTokensApprox } from "./render/index.js";
|
|
20
|
+
export { POLICY, BROKEN, CONSERVATIVE_DEFAULT } from "./policy.generated.js";
|
|
21
|
+
export type { PolicyEntry, BrokenEntry, Objective } from "./policy.generated.js";
|
|
22
|
+
/**
|
|
23
|
+
* Choose the level-3 map style, and report anything it had to substitute.
|
|
24
|
+
*
|
|
25
|
+
* Pure and exported so the disallow path is testable against synthetic tables. It
|
|
26
|
+
* used to be inline in `compress()`, which meant it could only be exercised through
|
|
27
|
+
* a real BROKEN entry — and once the one unsafe style was removed from the library
|
|
28
|
+
* there were none, leaving the safety valve untested.
|
|
29
|
+
*
|
|
30
|
+
* Order: an explicit request wins unless measured unsafe for that model; then the
|
|
31
|
+
* measured policy for (model, objective); then the conservative default.
|
|
32
|
+
*/
|
|
33
|
+
export declare function selectMapStyle(options: Pick<CompressOptions, "mapStyle" | "model" | "objective">, policy?: readonly PolicyEntry[], broken?: readonly BrokenEntry[]): {
|
|
34
|
+
mapStyle: MapStyle;
|
|
35
|
+
requestedMapStyle?: MapStyle;
|
|
36
|
+
fallbackReason?: string;
|
|
37
|
+
};
|
|
19
38
|
export declare function compress(input: Tool[], options?: CompressOptions): CompressResult;
|
|
20
39
|
export { recommendLevel } from "./recommend.js";
|
|
21
40
|
export type { Recommendation } from "./recommend.js";
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import { countSchemaTokensApprox, defaultNamespaceOf, firstSentence, flattenSchema, normalize, signatureLine,
|
|
1
|
+
import { countSchemaTokensApprox, defaultNamespaceOf, firstSentence, flattenSchema, normalize, signatureLine, } from "./render/index.js";
|
|
2
2
|
import { validateArgs } from "./runtime/validate.js";
|
|
3
3
|
import { nearest } from "./runtime/similar.js";
|
|
4
|
+
import { POLICY, BROKEN, CONSERVATIVE_DEFAULT } from "./policy.generated.js";
|
|
4
5
|
export * from "./types.js";
|
|
5
6
|
export { flattenSchema, signatureLine, countSchemaTokensApprox } from "./render/index.js";
|
|
7
|
+
// Exported so consumers can see WHY a style was chosen. The generated file is not in
|
|
8
|
+
// the published tarball (only dist/ ships), so without this the policy is a black box
|
|
9
|
+
// and the README could point at something a user cannot read.
|
|
10
|
+
export { POLICY, BROKEN, CONSERVATIVE_DEFAULT } from "./policy.generated.js";
|
|
6
11
|
const CODE_CHARS = "abcdefghijklmnopqrstuvwxyz";
|
|
7
12
|
const LEVELS = [0, 1, 2, 3];
|
|
8
|
-
const MAP_STYLES = ["name
|
|
13
|
+
const MAP_STYLES = ["name+required", "explicit", "signature"];
|
|
9
14
|
const err = (message, recoverable = true) => ({
|
|
10
15
|
kind: "error",
|
|
11
16
|
message,
|
|
@@ -30,6 +35,41 @@ function asObject(v) {
|
|
|
30
35
|
function defaultAlias(ns) {
|
|
31
36
|
return ns;
|
|
32
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Choose the level-3 map style, and report anything it had to substitute.
|
|
40
|
+
*
|
|
41
|
+
* Pure and exported so the disallow path is testable against synthetic tables. It
|
|
42
|
+
* used to be inline in `compress()`, which meant it could only be exercised through
|
|
43
|
+
* a real BROKEN entry — and once the one unsafe style was removed from the library
|
|
44
|
+
* there were none, leaving the safety valve untested.
|
|
45
|
+
*
|
|
46
|
+
* Order: an explicit request wins unless measured unsafe for that model; then the
|
|
47
|
+
* measured policy for (model, objective); then the conservative default.
|
|
48
|
+
*/
|
|
49
|
+
export function selectMapStyle(options, policy = POLICY, broken = BROKEN) {
|
|
50
|
+
const objective = options.objective ?? "occupancy";
|
|
51
|
+
if (options.mapStyle) {
|
|
52
|
+
const unsafe = options.model
|
|
53
|
+
? broken.find((b) => b.model === options.model && b.mapStyle === options.mapStyle)
|
|
54
|
+
: undefined;
|
|
55
|
+
if (!unsafe)
|
|
56
|
+
return { mapStyle: options.mapStyle };
|
|
57
|
+
// Owner decision: disallow a measured-unsafe pair and fall back, rather than
|
|
58
|
+
// honour it with a warning. Reported, never silent.
|
|
59
|
+
return {
|
|
60
|
+
mapStyle: CONSERVATIVE_DEFAULT,
|
|
61
|
+
requestedMapStyle: options.mapStyle,
|
|
62
|
+
fallbackReason: `mapStyle "${options.mapStyle}" is measured unsafe on ${unsafe.model}: ` +
|
|
63
|
+
`${unsafe.reason} (n=${unsafe.n}, sweep ${unsafe.sweep}). ` +
|
|
64
|
+
`Using "${CONSERVATIVE_DEFAULT}" instead.`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (options.model) {
|
|
68
|
+
const hit = policy.find((e) => e.model === options.model && e.objective === objective);
|
|
69
|
+
return { mapStyle: hit ? hit.mapStyle : CONSERVATIVE_DEFAULT };
|
|
70
|
+
}
|
|
71
|
+
return { mapStyle: CONSERVATIVE_DEFAULT };
|
|
72
|
+
}
|
|
33
73
|
export function compress(input, options = {}) {
|
|
34
74
|
const level = (options.level ?? 1);
|
|
35
75
|
if (!LEVELS.includes(level)) {
|
|
@@ -44,7 +84,7 @@ export function compress(input, options = {}) {
|
|
|
44
84
|
// 60/60 tasks, fewer malformed arguments, fewer lookup round-trips, and
|
|
45
85
|
// faster wall-clock than uncompressed on every provider. The larger map pays
|
|
46
86
|
// for itself by removing a discovery turn.
|
|
47
|
-
const mapStyle = options
|
|
87
|
+
const { mapStyle, requestedMapStyle, fallbackReason, } = selectMapStyle(options, POLICY, BROKEN);
|
|
48
88
|
if (!MAP_STYLES.includes(mapStyle)) {
|
|
49
89
|
throw new Error(`unsupported mapStyle: ${mapStyle} (expected ${MAP_STYLES.join(", ")})`);
|
|
50
90
|
}
|
|
@@ -134,6 +174,9 @@ export function compress(input, options = {}) {
|
|
|
134
174
|
encodeCallForTest: encode,
|
|
135
175
|
stats: {
|
|
136
176
|
level,
|
|
177
|
+
mapStyle,
|
|
178
|
+
requestedMapStyle: requestedMapStyle && requestedMapStyle !== mapStyle ? requestedMapStyle : undefined,
|
|
179
|
+
fallbackReason,
|
|
137
180
|
toolCount: tools.length,
|
|
138
181
|
wireToolCount: wire.length,
|
|
139
182
|
originalChars,
|
|
@@ -256,16 +299,16 @@ export function compress(input, options = {}) {
|
|
|
256
299
|
// against a full schema's ~400 — to reduce malformed arguments on models that
|
|
257
300
|
// fill the generic argument bag poorly.
|
|
258
301
|
const renderLine = (code, t) => {
|
|
259
|
-
|
|
260
|
-
return `${code} ${terseDescriptor(t.description) || t.name}`;
|
|
302
|
+
const req = t.schema.required ?? [];
|
|
261
303
|
// Full signature: optional params included, so the model rarely needs q().
|
|
262
304
|
if (mapStyle === "signature")
|
|
263
305
|
return `${code} ${signatureLine(t)}`;
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
306
|
+
if (req.length)
|
|
307
|
+
return `${code} ${t.name} ${req.join(",")}`;
|
|
308
|
+
// No required parameters. `explicit` says so, because a bare name is
|
|
309
|
+
// indistinguishable from a tool whose parameters were omitted, and the model
|
|
310
|
+
// spends a lookup finding out it could have just called it.
|
|
311
|
+
return mapStyle === "explicit" ? `${code} ${t.name} ()` : `${code} ${t.name}`;
|
|
269
312
|
};
|
|
270
313
|
const lines = [...codeToTool.entries()].map(([code, t]) => renderLine(code, t));
|
|
271
314
|
const wire = [
|
|
@@ -290,12 +333,13 @@ export function compress(input, options = {}) {
|
|
|
290
333
|
},
|
|
291
334
|
},
|
|
292
335
|
];
|
|
293
|
-
const mapLegend = mapStyle === "
|
|
294
|
-
? "Each line is: code name
|
|
295
|
-
: mapStyle === "
|
|
296
|
-
? "Each line is: code name(
|
|
297
|
-
: "";
|
|
298
|
-
const
|
|
336
|
+
const mapLegend = mapStyle === "signature"
|
|
337
|
+
? "Each line is: code name(args), where ? marks optional. "
|
|
338
|
+
: mapStyle === "explicit"
|
|
339
|
+
? "Each line is: code name required-args. A line ending in () takes no required arguments and can be called with none. "
|
|
340
|
+
: "Each line is: code name required-args. ";
|
|
341
|
+
const invokeHint = "Invoke with t(f=<code>, a={…}). Use q to expand a code before calling if you are unsure of its parameters.";
|
|
342
|
+
const systemPreamble = `<toolmap>\n${lines.join("\n")}\n</toolmap>\n${mapLegend}${invokeHint}`;
|
|
299
343
|
return finish(wire, systemPreamble, true, (rawName, rawArgs) => {
|
|
300
344
|
let name = rawName;
|
|
301
345
|
let args = asObject(rawArgs);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED — do not edit by hand. Regenerate with:
|
|
3
|
+
* npx tsx bench/generate-policy.ts --sweep=<timestamp>
|
|
4
|
+
*
|
|
5
|
+
* A record of measurements, not a theory of models. Every row was produced by
|
|
6
|
+
* `bench/analyze-multi.ts` scoped to one sweep, compared within a provider, and
|
|
7
|
+
* filtered to effects of at least 5% — same-arm figures moved by more than that
|
|
8
|
+
* between n=4 and n=96 during development, so smaller effects are noise.
|
|
9
|
+
*
|
|
10
|
+
* Hand-maintaining this would rot exactly the way `bench/` and `src/` diverged
|
|
11
|
+
* before. A drift test (tests/policy.test.ts) fails if it disagrees with the
|
|
12
|
+
* committed results.
|
|
13
|
+
*
|
|
14
|
+
* There is deliberately NO occupancy table. Every measured difference on that axis
|
|
15
|
+
* was within ±3.1%, under the floor — so occupancy gets the default, and this file
|
|
16
|
+
* exists only for the `cost` objective. That is a smaller feature than spec 002
|
|
17
|
+
* originally described, and it is what the data supports.
|
|
18
|
+
*/
|
|
19
|
+
import type { MapStyle } from "./types.js";
|
|
20
|
+
export type Objective = "occupancy" | "cost";
|
|
21
|
+
export type PolicyEntry = {
|
|
22
|
+
/** Exact model id. Never a family: gpt-5.6-sol says nothing about gpt-5.7. */
|
|
23
|
+
model: string;
|
|
24
|
+
objective: Objective;
|
|
25
|
+
mapStyle: MapStyle;
|
|
26
|
+
/** Percent change against the conservative default. Negative is better. */
|
|
27
|
+
effectPct: number;
|
|
28
|
+
/** Runs behind this row, per arm. */
|
|
29
|
+
n: number;
|
|
30
|
+
/** The sweep it came from, so any row can be re-derived. */
|
|
31
|
+
sweep: string;
|
|
32
|
+
measured: string;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Tier-3 sweep, 432 runs, 36 per arm per provider, 12 real MCP scenarios × 3 reps.
|
|
36
|
+
* `explicit` was 144/144 tasks; turns and lookups fell on all four providers.
|
|
37
|
+
* Only rows clearing the 5% floor appear.
|
|
38
|
+
*/
|
|
39
|
+
export declare const POLICY: readonly PolicyEntry[];
|
|
40
|
+
/**
|
|
41
|
+
* (model, mapStyle) pairs measured as unsafe. A caller asking for one is refused and
|
|
42
|
+
* falls back, per the owner's decision (#15) to disallow rather than warn.
|
|
43
|
+
*/
|
|
44
|
+
export type BrokenEntry = {
|
|
45
|
+
model: string;
|
|
46
|
+
mapStyle: MapStyle;
|
|
47
|
+
reason: string;
|
|
48
|
+
n: number;
|
|
49
|
+
sweep: string;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Empty, and that is the current truth rather than an oversight.
|
|
53
|
+
*
|
|
54
|
+
* The one pair ever measured unsafe was `nocode` on grok-4.5 — a 19% silent
|
|
55
|
+
* failure rate, the model answering with no tool call at all. In 0.2.0 that style
|
|
56
|
+
* was removed from the library outright, so the pair can no longer be requested.
|
|
57
|
+
* Deleting a footgun beats documenting it.
|
|
58
|
+
*
|
|
59
|
+
* The mechanism stays because it is the safety valve: if a future sweep finds a
|
|
60
|
+
* (model, style) pair that fails, it goes here and is refused at the boundary.
|
|
61
|
+
*/
|
|
62
|
+
export declare const BROKEN: readonly BrokenEntry[];
|
|
63
|
+
/** The style used when nothing is known about the model. */
|
|
64
|
+
export declare const CONSERVATIVE_DEFAULT: MapStyle;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier-3 sweep, 432 runs, 36 per arm per provider, 12 real MCP scenarios × 3 reps.
|
|
3
|
+
* `explicit` was 144/144 tasks; turns and lookups fell on all four providers.
|
|
4
|
+
* Only rows clearing the 5% floor appear.
|
|
5
|
+
*/
|
|
6
|
+
export const POLICY = [
|
|
7
|
+
{ model: "claude-opus-5", objective: "cost", mapStyle: "explicit", effectPct: -9.0, n: 36, sweep: "2026-07-26T03-07-25", measured: "2026-07-26" },
|
|
8
|
+
{ model: "gemini-3.1-pro-preview", objective: "cost", mapStyle: "explicit", effectPct: -15.4, n: 36, sweep: "2026-07-26T03-07-25", measured: "2026-07-26" },
|
|
9
|
+
{ model: "gpt-5.6-sol", objective: "cost", mapStyle: "explicit", effectPct: -20.7, n: 36, sweep: "2026-07-26T03-07-25", measured: "2026-07-26" },
|
|
10
|
+
// grok-4.5 is deliberately absent for `cost`: explicit measured +13.2% there, so it
|
|
11
|
+
// falls through to the conservative default. An absent row means "no measured
|
|
12
|
+
// improvement", never "untested".
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* Empty, and that is the current truth rather than an oversight.
|
|
16
|
+
*
|
|
17
|
+
* The one pair ever measured unsafe was `nocode` on grok-4.5 — a 19% silent
|
|
18
|
+
* failure rate, the model answering with no tool call at all. In 0.2.0 that style
|
|
19
|
+
* was removed from the library outright, so the pair can no longer be requested.
|
|
20
|
+
* Deleting a footgun beats documenting it.
|
|
21
|
+
*
|
|
22
|
+
* The mechanism stays because it is the safety valve: if a future sweep finds a
|
|
23
|
+
* (model, style) pair that fails, it goes here and is refused at the boundary.
|
|
24
|
+
*/
|
|
25
|
+
export const BROKEN = [];
|
|
26
|
+
/** The style used when nothing is known about the model. */
|
|
27
|
+
export const CONSERVATIVE_DEFAULT = "name+required";
|
package/dist/types.d.ts
CHANGED
|
@@ -34,27 +34,41 @@ export type NormalizedTool = {
|
|
|
34
34
|
* 3 minified — single dispatcher + opaque codes
|
|
35
35
|
*/
|
|
36
36
|
export type Level = 0 | 1 | 2 | 3;
|
|
37
|
+
import type { Objective } from "./policy.generated.js";
|
|
38
|
+
export type { Objective };
|
|
37
39
|
/**
|
|
38
40
|
* How each line of the level-3 `<toolmap>` is rendered.
|
|
39
41
|
*
|
|
40
|
-
* name
|
|
41
|
-
*
|
|
42
|
+
* name+required `a0 github_create_issue owner,repo,title` <- default
|
|
43
|
+
* explicit `a0 github_create_issue owner,repo,title`
|
|
44
|
+
* `a0 scorecard_lf_daily ()` <- () = takes no required args
|
|
42
45
|
* signature `a0 github_create_issue(owner,repo,title,body?,labels?)`
|
|
43
|
-
* terse `a0 create new issue in repository`
|
|
44
46
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
47
|
+
* Three styles, each with a measured reason to exist. Six others were tried and
|
|
48
|
+
* removed in 0.2.0 because they made things worse — see docs/RESULTS.md Round 6.
|
|
49
|
+
*
|
|
50
|
+
* `name+required` is the default: the only style perfect on all four providers with
|
|
51
|
+
* zero malformed arguments. It names required parameters because the dispatcher
|
|
52
|
+
* levels give up provider-side constrained decoding, and a few tokens per tool buys
|
|
53
|
+
* some of it back.
|
|
54
|
+
*
|
|
55
|
+
* `explicit` adds one thing: a `()` marker on tools that declare no required
|
|
56
|
+
* parameters. On a real catalogue 44% of tools are like that, and their line would
|
|
57
|
+
* otherwise be a bare name — indistinguishable from a tool whose parameters were
|
|
58
|
+
* omitted, so the model spends a lookup finding out it could just call it. Measured
|
|
59
|
+
* over 432 runs it cut lookups on all four providers and cost 9–21% less on three,
|
|
60
|
+
* but **13% more on grok-4.5**. Reach for it via `objective: "cost"` rather than
|
|
61
|
+
* setting it by hand.
|
|
62
|
+
*
|
|
63
|
+
* `signature` names optional parameters too, which removes most remaining lookups.
|
|
64
|
+
* A bigger cached map traded for fewer turns — worth it where every turn pays for a
|
|
65
|
+
* fresh round of reasoning, and measurably worse on xAI for reasons we cannot yet
|
|
66
|
+
* explain.
|
|
53
67
|
*/
|
|
54
|
-
export type MapStyle = "name
|
|
68
|
+
export type MapStyle = "name+required" | "explicit" | "signature";
|
|
55
69
|
export type CompressOptions = {
|
|
56
70
|
level?: Level;
|
|
57
|
-
/** Level 3 only. Ignored at levels 0–2, which emit no map. Default "name". */
|
|
71
|
+
/** Level 3 only. Ignored at levels 0–2, which emit no map. Default "name+required". */
|
|
58
72
|
mapStyle?: MapStyle;
|
|
59
73
|
/**
|
|
60
74
|
* Group tools into namespaces. Default splits on the first `_` or `.`,
|
|
@@ -70,6 +84,25 @@ export type CompressOptions = {
|
|
|
70
84
|
searchLimit?: number;
|
|
71
85
|
/** Validate arguments against the original schema before dispatch. Default true. */
|
|
72
86
|
validate?: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Exact model id, e.g. "gpt-5.6-sol". When given, level 3 selects the best
|
|
89
|
+
* *measured* map style for that model from `src/policy.generated.ts`.
|
|
90
|
+
*
|
|
91
|
+
* Never a family: `gpt-5.6-sol` failing says nothing certain about `gpt-5.7`. An
|
|
92
|
+
* unknown model gets the conservative default — an absence of evidence, not a
|
|
93
|
+
* prediction. Behaviour is unchanged if you omit this.
|
|
94
|
+
*/
|
|
95
|
+
model?: string;
|
|
96
|
+
/**
|
|
97
|
+
* What the selection optimises. Default "occupancy", because reclaiming context
|
|
98
|
+
* window is the point — prompt caching already makes tool tokens cheap but does not
|
|
99
|
+
* reclaim the room they take.
|
|
100
|
+
*
|
|
101
|
+
* As measured, "occupancy" currently selects the default on every known model: no
|
|
102
|
+
* style beat it by more than 3.1% on that axis, under the 5% floor. The table has
|
|
103
|
+
* real entries only for "cost".
|
|
104
|
+
*/
|
|
105
|
+
objective?: Objective;
|
|
73
106
|
};
|
|
74
107
|
export type Resolution = {
|
|
75
108
|
kind: "call";
|
|
@@ -86,6 +119,12 @@ export type Resolution = {
|
|
|
86
119
|
};
|
|
87
120
|
export type CompressStats = {
|
|
88
121
|
level: Level;
|
|
122
|
+
/** The map style actually used. */
|
|
123
|
+
mapStyle?: MapStyle;
|
|
124
|
+
/** What the caller asked for, when it differed from what was used. */
|
|
125
|
+
requestedMapStyle?: MapStyle;
|
|
126
|
+
/** Why a requested style was not used. Absent when nothing was substituted. */
|
|
127
|
+
fallbackReason?: string;
|
|
89
128
|
toolCount: number;
|
|
90
129
|
wireToolCount: number;
|
|
91
130
|
originalChars: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolgz",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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": {
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
"bench": "tsx bench/harness/run.ts",
|
|
10
10
|
"build": "tsc",
|
|
11
11
|
"prepublishOnly": "npm run build && npm test",
|
|
12
|
-
"test:live": "vitest run tests/integration"
|
|
12
|
+
"test:live": "vitest run tests/integration",
|
|
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
|
+
"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"
|
|
13
16
|
},
|
|
14
17
|
"keywords": [
|
|
15
18
|
"llm",
|