toolgz 0.2.0 → 0.2.2
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 +541 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
<h1>toolgz</h1>
|
|
2
2
|
|
|
3
|
-
<p><strong>Your agent spends 30–70k tokens of context on tool definitions before the user types a word. toolgz gets ~
|
|
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
|
|
@@ -75,6 +83,9 @@ claim about real deployments.
|
|
|
75
83
|
<img src="docs/img/savings-light.svg" alt="Prompt tokens saved versus uncompressed tool definitions, by compression level, for each of four providers">
|
|
76
84
|
</picture>
|
|
77
85
|
|
|
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
|
+
|
|
78
89
|
| Provider | Model | Tool block | Prompt tokens | Latency | Tasks |
|
|
79
90
|
|---|---|---:|---:|---:|:-:|
|
|
80
91
|
| Anthropic | `claude-opus-5` | 9,242 → **1,284** | 30,817 → **4,628** (**−85%**) | 15.0s → **12.1s** | 15/15 |
|
|
@@ -273,15 +284,534 @@ style in 0.2.0.
|
|
|
273
284
|
|
|
274
285
|
---
|
|
275
286
|
|
|
276
|
-
##
|
|
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.
|
|
277
450
|
|
|
278
|
-
|
|
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
|
+
```
|
|
554
|
+
|
|
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 |
|
|
279
576
|
|---|---|
|
|
280
|
-
|
|
|
281
|
-
|
|
|
282
|
-
|
|
|
283
|
-
|
|
|
284
|
-
|
|
|
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
|
+
[§2](#2-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 [§4](#4-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 [Which level to use](#which-level-to-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
|
+
| `model` | `string` | — | exact model id; picks the measured style. Omit and nothing changes |
|
|
770
|
+
| `objective` | `"occupancy" \| "cost"` | `"occupancy"` | what the pick optimises. Only `cost` has entries |
|
|
771
|
+
|
|
772
|
+
Returns:
|
|
773
|
+
|
|
774
|
+
| Field | Type | Notes |
|
|
775
|
+
|---|---|---|
|
|
776
|
+
| `tools` | `unknown[]` | send these; Anthropic shape |
|
|
777
|
+
| `systemPreamble` | `string` | append to your system prompt; `""` at levels 0–1 |
|
|
778
|
+
| `cachePreamble` | `boolean` | whether the preamble should sit behind a breakpoint |
|
|
779
|
+
| `resolve(name, args)` | `→ Resolution` | translate a model call back |
|
|
780
|
+
| `codeFor(name)` | `→ string` | real name → level-3 code; throws below level 3 |
|
|
781
|
+
| `stats` | `CompressStats` | `level`, `mapStyle`, `requestedMapStyle`, `fallbackReason`, `toolCount`, `wireToolCount`, `originalChars`, `compressedChars`, `savedPct` |
|
|
782
|
+
| `encodeCallForTest(name, args)` | `→ {name, args}` | build the raw call a model would emit; test aid |
|
|
783
|
+
|
|
784
|
+
### `recommendLevel(tools, namespaceOf?) → Recommendation`
|
|
785
|
+
|
|
786
|
+
`{ level, reason, toolCount, namespaceCount, opsPerNamespace }`. Returns 1 or 3.
|
|
787
|
+
|
|
788
|
+
### Provider adapters
|
|
789
|
+
|
|
790
|
+
| Adapter | Endpoint | Tool shape |
|
|
791
|
+
|---|---|---|
|
|
792
|
+
| `forAnthropic(c, { cache?, ttl? })` → `{ tools, system }` | Messages API | Anthropic native; places one `cache_control` breakpoint |
|
|
793
|
+
| `forOpenAI(c)` → `{ tools, systemPreamble }` | `/v1/chat/completions` | nested `{type, function:{…}}` |
|
|
794
|
+
| `forOpenAIResponses(c)` → `{ tools, systemPreamble }` | `/v1/responses` | **flat** `{type, name, …}` — required for tools + reasoning |
|
|
795
|
+
| `forGemini(c)` → `{ tools, systemPreamble }` | `generateContent` | one `functionDeclarations` array |
|
|
796
|
+
|
|
797
|
+
Also exported: `recommendLevel(tools)` → `{ level, reason }`; `selectMapStyle(options)`
|
|
798
|
+
→ `{ mapStyle, requestedMapStyle?, fallbackReason? }` (pure, so you can see a pick
|
|
799
|
+
without compressing); and `POLICY`, `BROKEN`, `CONSERVATIVE_DEFAULT` — the measured
|
|
800
|
+
table, so a style choice is never a black box.
|
|
801
|
+
|
|
802
|
+
### Renderers (exported for tooling)
|
|
803
|
+
|
|
804
|
+
- `signatureLine(tool, nameOverride?)` → `"name(a,b?:x|y)"`
|
|
805
|
+
- `flattenSchema(schema)` → schema with prose and boilerplate removed
|
|
806
|
+
- `countSchemaTokensApprox(value)` → character-count proxy, **not** a token count
|
|
807
|
+
|
|
808
|
+
---
|
|
809
|
+
|
|
810
|
+
Measured results and their limits: [RESULTS.md](RESULTS.md).
|
|
811
|
+
Methodology and repo conventions: [../AGENTS.md](../AGENTS.md).
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
---
|
|
285
815
|
|
|
286
816
|
## Providers
|
|
287
817
|
|
|
@@ -329,7 +859,7 @@ and it does not get deleted.
|
|
|
329
859
|
## Development
|
|
330
860
|
|
|
331
861
|
```bash
|
|
332
|
-
npm test #
|
|
862
|
+
npm test # 251 tests, offline, no cost
|
|
333
863
|
npm run build # tsc → dist/ with .d.ts
|
|
334
864
|
|
|
335
865
|
npx tsx bench/harness/run-multi.ts --provider=all --reps=3 --variants # costs money
|