toolgz 0.1.0
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/LICENSE +201 -0
- package/NOTICE +16 -0
- package/README.md +245 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +309 -0
- package/dist/providers/index.d.ts +69 -0
- package/dist/providers/index.js +108 -0
- package/dist/recommend.d.ts +41 -0
- package/dist/recommend.js +56 -0
- package/dist/render/index.d.ts +34 -0
- package/dist/render/index.js +143 -0
- package/dist/runtime/similar.d.ts +26 -0
- package/dist/runtime/similar.js +86 -0
- package/dist/runtime/validate.d.ts +11 -0
- package/dist/runtime/validate.js +87 -0
- package/dist/types.d.ts +112 -0
- package/dist/types.js +2 -0
- package/package.json +71 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { countSchemaTokensApprox, defaultNamespaceOf, firstSentence, flattenSchema, normalize, signatureLine, terseDescriptor, } from "./render/index.js";
|
|
2
|
+
import { validateArgs } from "./runtime/validate.js";
|
|
3
|
+
export * from "./types.js";
|
|
4
|
+
export { flattenSchema, signatureLine, countSchemaTokensApprox } from "./render/index.js";
|
|
5
|
+
const CODE_CHARS = "abcdefghijklmnopqrstuvwxyz";
|
|
6
|
+
const LEVELS = [0, 1, 2, 3];
|
|
7
|
+
const MAP_STYLES = ["name", "name+required", "signature", "terse"];
|
|
8
|
+
const err = (message, recoverable = true) => ({
|
|
9
|
+
kind: "error",
|
|
10
|
+
message,
|
|
11
|
+
recoverable,
|
|
12
|
+
});
|
|
13
|
+
/** Models sometimes emit a nested arg bag as a JSON string. Accept both. */
|
|
14
|
+
function asObject(v) {
|
|
15
|
+
if (v && typeof v === "object" && !Array.isArray(v))
|
|
16
|
+
return v;
|
|
17
|
+
if (typeof v === "string") {
|
|
18
|
+
try {
|
|
19
|
+
const p = JSON.parse(v);
|
|
20
|
+
if (p && typeof p === "object")
|
|
21
|
+
return p;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
/* fall through */
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
function defaultAlias(ns) {
|
|
30
|
+
return ns;
|
|
31
|
+
}
|
|
32
|
+
export function compress(input, options = {}) {
|
|
33
|
+
const level = (options.level ?? 1);
|
|
34
|
+
if (!LEVELS.includes(level)) {
|
|
35
|
+
throw new Error(`unsupported level: ${level} (expected 0, 1, 2 or 3)`);
|
|
36
|
+
}
|
|
37
|
+
// Default is "name+required", chosen on measurement rather than taste.
|
|
38
|
+
//
|
|
39
|
+
// With bare names, grok-4.5 answered acc-cross-product with ZERO tool calls
|
|
40
|
+
// on 3/3 reps — a silent failure, no error, just an unaided answer. Adding
|
|
41
|
+
// the required parameter names fixed it 3/3, and across all four providers
|
|
42
|
+
// (360 runs) "name+required" was the only level-3 style perfect everywhere:
|
|
43
|
+
// 60/60 tasks, fewer malformed arguments, fewer lookup round-trips, and
|
|
44
|
+
// faster wall-clock than uncompressed on every provider. The larger map pays
|
|
45
|
+
// for itself by removing a discovery turn.
|
|
46
|
+
const mapStyle = options.mapStyle ?? "name+required";
|
|
47
|
+
if (!MAP_STYLES.includes(mapStyle)) {
|
|
48
|
+
throw new Error(`unsupported mapStyle: ${mapStyle} (expected ${MAP_STYLES.join(", ")})`);
|
|
49
|
+
}
|
|
50
|
+
const namespaceOf = options.namespaceOf ?? defaultNamespaceOf;
|
|
51
|
+
const aliasOf = options.aliasOf ?? defaultAlias;
|
|
52
|
+
const searchLimit = options.searchLimit ?? 8;
|
|
53
|
+
const doValidate = options.validate ?? true;
|
|
54
|
+
const tools = normalize(input, namespaceOf);
|
|
55
|
+
const byName = new Map(tools.map((t) => [t.name, t]));
|
|
56
|
+
const originalChars = countSchemaTokensApprox(input);
|
|
57
|
+
// -- namespace grouping (levels 2 and 3) ----------------------------------
|
|
58
|
+
const groups = new Map();
|
|
59
|
+
for (const t of tools) {
|
|
60
|
+
if (!groups.has(t.ns))
|
|
61
|
+
groups.set(t.ns, []);
|
|
62
|
+
groups.get(t.ns).push(t);
|
|
63
|
+
}
|
|
64
|
+
// -- level 3 code assignment ----------------------------------------------
|
|
65
|
+
const codeToTool = new Map();
|
|
66
|
+
const toolToCode = new Map();
|
|
67
|
+
if (level === 3) {
|
|
68
|
+
let ni = 0;
|
|
69
|
+
for (const [, list] of groups) {
|
|
70
|
+
// Two chars past 26 namespaces so codes stay unique and short.
|
|
71
|
+
const prefix = ni < 26
|
|
72
|
+
? CODE_CHARS[ni]
|
|
73
|
+
: CODE_CHARS[Math.floor(ni / 26) - 1] + CODE_CHARS[ni % 26];
|
|
74
|
+
ni++;
|
|
75
|
+
list.forEach((t, oi) => {
|
|
76
|
+
const code = `${prefix}${oi}`;
|
|
77
|
+
codeToTool.set(code, t);
|
|
78
|
+
toolToCode.set(t.name, code);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const finish = (wire, systemPreamble, cachePreamble, resolve, encode) => {
|
|
83
|
+
const compressedChars = countSchemaTokensApprox(wire) + systemPreamble.length;
|
|
84
|
+
return {
|
|
85
|
+
tools: wire,
|
|
86
|
+
systemPreamble,
|
|
87
|
+
cachePreamble,
|
|
88
|
+
resolve,
|
|
89
|
+
codeFor(name) {
|
|
90
|
+
const c = toolToCode.get(name);
|
|
91
|
+
if (!c)
|
|
92
|
+
throw new Error(`no code for ${name} (level ${level})`);
|
|
93
|
+
return c;
|
|
94
|
+
},
|
|
95
|
+
encodeCallForTest: encode,
|
|
96
|
+
stats: {
|
|
97
|
+
level,
|
|
98
|
+
toolCount: tools.length,
|
|
99
|
+
wireToolCount: wire.length,
|
|
100
|
+
originalChars,
|
|
101
|
+
compressedChars,
|
|
102
|
+
savedPct: originalChars === 0
|
|
103
|
+
? 0
|
|
104
|
+
: Math.round((1 - compressedChars / originalChars) * 1000) / 10,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
const finalize = (t, args) => {
|
|
109
|
+
if (doValidate) {
|
|
110
|
+
const problem = validateArgs(t, args);
|
|
111
|
+
if (problem)
|
|
112
|
+
return err(problem);
|
|
113
|
+
}
|
|
114
|
+
return { kind: "call", name: t.name, args };
|
|
115
|
+
};
|
|
116
|
+
// -------------------------------------------------------------------------
|
|
117
|
+
// Level 0 — passthrough
|
|
118
|
+
// -------------------------------------------------------------------------
|
|
119
|
+
if (level === 0) {
|
|
120
|
+
const wire = tools.map((t) => ({
|
|
121
|
+
name: t.name,
|
|
122
|
+
description: t.description,
|
|
123
|
+
input_schema: t.schema,
|
|
124
|
+
}));
|
|
125
|
+
return finish(wire, "", false, (name, args) => {
|
|
126
|
+
const t = byName.get(name);
|
|
127
|
+
if (!t)
|
|
128
|
+
return err(`No tool named "${name}".`);
|
|
129
|
+
return finalize(t, asObject(args));
|
|
130
|
+
}, (name, args) => ({ name, args }));
|
|
131
|
+
}
|
|
132
|
+
// -------------------------------------------------------------------------
|
|
133
|
+
// Level 1 — signature flattening, native tools, real names
|
|
134
|
+
// -------------------------------------------------------------------------
|
|
135
|
+
if (level === 1) {
|
|
136
|
+
const wire = tools.map((t) => {
|
|
137
|
+
const d = firstSentence(t.description);
|
|
138
|
+
return {
|
|
139
|
+
name: t.name,
|
|
140
|
+
description: d ? `${signatureLine(t)} — ${d}` : signatureLine(t),
|
|
141
|
+
input_schema: flattenSchema(t.schema),
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
return finish(wire, "", false, (name, args) => {
|
|
145
|
+
const t = byName.get(name);
|
|
146
|
+
if (!t)
|
|
147
|
+
return err(`No tool named "${name}".`);
|
|
148
|
+
return finalize(t, asObject(args));
|
|
149
|
+
}, (name, args) => ({ name, args }));
|
|
150
|
+
}
|
|
151
|
+
// -------------------------------------------------------------------------
|
|
152
|
+
// Level 2 — namespace collapse, semantic op names
|
|
153
|
+
// -------------------------------------------------------------------------
|
|
154
|
+
if (level === 2) {
|
|
155
|
+
const aliasToNs = new Map();
|
|
156
|
+
const wire = [];
|
|
157
|
+
for (const [ns, list] of groups) {
|
|
158
|
+
const alias = aliasOf(ns);
|
|
159
|
+
aliasToNs.set(alias, ns);
|
|
160
|
+
wire.push({
|
|
161
|
+
name: alias,
|
|
162
|
+
description: `${ns} operations. Call describe_op first if unsure of an op's parameters.`,
|
|
163
|
+
input_schema: {
|
|
164
|
+
type: "object",
|
|
165
|
+
properties: {
|
|
166
|
+
op: { type: "string", enum: list.map((t) => t.op) },
|
|
167
|
+
args: { type: "object" },
|
|
168
|
+
},
|
|
169
|
+
required: ["op", "args"],
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
wire.push({
|
|
174
|
+
name: "describe_op",
|
|
175
|
+
description: "Return the full parameter signature and description for one operation.",
|
|
176
|
+
input_schema: {
|
|
177
|
+
type: "object",
|
|
178
|
+
properties: { ns: { type: "string" }, op: { type: "string" } },
|
|
179
|
+
required: ["ns", "op"],
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
const lookup = (nsOrAlias, op) => {
|
|
183
|
+
const ns = aliasToNs.get(nsOrAlias) ?? nsOrAlias;
|
|
184
|
+
return (groups.get(ns) ?? []).find((t) => t.op === op);
|
|
185
|
+
};
|
|
186
|
+
return finish(wire, "", false, (name, rawArgs) => {
|
|
187
|
+
const args = asObject(rawArgs);
|
|
188
|
+
if (name === "describe_op") {
|
|
189
|
+
const t = lookup(String(args.ns), String(args.op));
|
|
190
|
+
if (!t)
|
|
191
|
+
return err(`No operation "${args.ns}.${args.op}".`);
|
|
192
|
+
return {
|
|
193
|
+
kind: "meta",
|
|
194
|
+
name,
|
|
195
|
+
result: `${signatureLine(t)} — ${t.description}`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const t = lookup(name, String(args.op));
|
|
199
|
+
if (!t) {
|
|
200
|
+
return err(`No operation "${name}.${args.op}". Call describe_op, or check the op enum on the "${name}" tool.`);
|
|
201
|
+
}
|
|
202
|
+
return finalize(t, asObject(args.args));
|
|
203
|
+
}, (name, args) => {
|
|
204
|
+
const t = byName.get(name);
|
|
205
|
+
return { name: aliasOf(t.ns), args: { op: t.op, args } };
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
// -------------------------------------------------------------------------
|
|
209
|
+
// Level 3 — minified dispatcher + opaque codes
|
|
210
|
+
// -------------------------------------------------------------------------
|
|
211
|
+
// Map line rendering. Default is `code name`: a prose descriptor reintroduces
|
|
212
|
+
// per-tool text into the cached prefix and, measured at 60 tools, made level 3
|
|
213
|
+
// LARGER than level 2. The name is the densest useful selector, and full
|
|
214
|
+
// descriptions stay one q() call away.
|
|
215
|
+
//
|
|
216
|
+
// `name+required` adds the required parameter names — a few tokens per tool
|
|
217
|
+
// against a full schema's ~400 — to reduce malformed arguments on models that
|
|
218
|
+
// fill the generic argument bag poorly.
|
|
219
|
+
const renderLine = (code, t) => {
|
|
220
|
+
if (mapStyle === "terse")
|
|
221
|
+
return `${code} ${terseDescriptor(t.description) || t.name}`;
|
|
222
|
+
// Full signature: optional params included, so the model rarely needs q().
|
|
223
|
+
if (mapStyle === "signature")
|
|
224
|
+
return `${code} ${signatureLine(t)}`;
|
|
225
|
+
if (mapStyle === "name+required") {
|
|
226
|
+
const req = t.schema.required ?? [];
|
|
227
|
+
return req.length ? `${code} ${t.name} ${req.join(",")}` : `${code} ${t.name}`;
|
|
228
|
+
}
|
|
229
|
+
return `${code} ${t.name}`;
|
|
230
|
+
};
|
|
231
|
+
const lines = [...codeToTool.entries()].map(([code, t]) => renderLine(code, t));
|
|
232
|
+
const wire = [
|
|
233
|
+
{
|
|
234
|
+
name: "t",
|
|
235
|
+
description: "Invoke a tool by its map code. Codes are listed in <toolmap> in the system prompt.",
|
|
236
|
+
input_schema: {
|
|
237
|
+
type: "object",
|
|
238
|
+
properties: {
|
|
239
|
+
f: { type: "string" },
|
|
240
|
+
a: { type: "object" },
|
|
241
|
+
},
|
|
242
|
+
required: ["f"],
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
name: "q",
|
|
247
|
+
description: "Expand a map code to its full name, description and parameter signature (c), or search the map by keyword (s).",
|
|
248
|
+
input_schema: {
|
|
249
|
+
type: "object",
|
|
250
|
+
properties: { c: { type: "string" }, s: { type: "string" } },
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
];
|
|
254
|
+
const mapLegend = mapStyle === "name+required"
|
|
255
|
+
? "Each line is: code name required-args. "
|
|
256
|
+
: mapStyle === "signature"
|
|
257
|
+
? "Each line is: code name(args), where ? marks optional. "
|
|
258
|
+
: "";
|
|
259
|
+
const systemPreamble = `<toolmap>\n${lines.join("\n")}\n</toolmap>\n${mapLegend}Invoke with t(f=<code>, a={…}). Use q to expand a code before calling if you are unsure of its parameters.`;
|
|
260
|
+
return finish(wire, systemPreamble, true, (name, rawArgs) => {
|
|
261
|
+
const args = asObject(rawArgs);
|
|
262
|
+
if (name === "q") {
|
|
263
|
+
if (args.c !== undefined) {
|
|
264
|
+
const t = codeToTool.get(String(args.c));
|
|
265
|
+
if (!t)
|
|
266
|
+
return err(`No map code "${args.c}". Search with q(s=…).`);
|
|
267
|
+
return {
|
|
268
|
+
kind: "meta",
|
|
269
|
+
name,
|
|
270
|
+
result: `${args.c} = ${signatureLine(t, t.name)} — ${t.description}`,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const q = String(args.s ?? "").toLowerCase();
|
|
274
|
+
const hits = [...codeToTool.entries()]
|
|
275
|
+
.filter(([, t]) => t.name.toLowerCase().includes(q) ||
|
|
276
|
+
t.description.toLowerCase().includes(q))
|
|
277
|
+
.slice(0, searchLimit)
|
|
278
|
+
.map(([c, t]) => `${c} = ${signatureLine(t, t.name)}`);
|
|
279
|
+
return {
|
|
280
|
+
kind: "meta",
|
|
281
|
+
name,
|
|
282
|
+
result: hits.length ? hits.join("\n") : `No matches for "${args.s}".`,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
// Models sometimes call the map code as the tool name — observed on
|
|
286
|
+
// claude-opus-5: name="b5", args={f:"b5", a:"{…}"}. Codes are unique and
|
|
287
|
+
// cannot collide with `t` or `q`, so this form is unambiguous and
|
|
288
|
+
// accepting it removes a whole class of wasted turn.
|
|
289
|
+
const viaCode = name !== "t" && name !== "q" ? codeToTool.get(name) : undefined;
|
|
290
|
+
if (!viaCode && name !== "t") {
|
|
291
|
+
return err(`No tool named "${name}". Invoke tools with t(f=<code>).`);
|
|
292
|
+
}
|
|
293
|
+
const t = viaCode ?? codeToTool.get(String(args.f));
|
|
294
|
+
if (!t)
|
|
295
|
+
return err(`No map code "${args.f}". Search with q(s=…).`);
|
|
296
|
+
// Args may arrive nested under `a`, or flat alongside `f`. Prefer `a`
|
|
297
|
+
// when present rather than merging, so there is one source of truth.
|
|
298
|
+
const nested = asObject(args.a);
|
|
299
|
+
const flat = Object.fromEntries(Object.entries(args).filter(([k]) => k !== "f" && k !== "a"));
|
|
300
|
+
const callArgs = args.a !== undefined && Object.keys(nested).length
|
|
301
|
+
? nested
|
|
302
|
+
: Object.keys(flat).length
|
|
303
|
+
? flat
|
|
304
|
+
: nested;
|
|
305
|
+
return finalize(t, callArgs);
|
|
306
|
+
}, (name, args) => ({ name: "t", args: { f: toolToCode.get(name), a: args } }));
|
|
307
|
+
}
|
|
308
|
+
export { recommendLevel } from "./recommend.js";
|
|
309
|
+
export { forAnthropic, forOpenAI, forOpenAIResponses, forGemini } from "./providers/index.js";
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider adapters.
|
|
3
|
+
*
|
|
4
|
+
* The core emits Anthropic-shaped tool definitions because that is the shape
|
|
5
|
+
* MCP and most SDKs already produce. These adapters reshape for other
|
|
6
|
+
* providers and place cache breakpoints where each provider expects them.
|
|
7
|
+
*
|
|
8
|
+
* Adapters are pure functions. They make no network calls.
|
|
9
|
+
*/
|
|
10
|
+
import type { CompressResult } from "../types.js";
|
|
11
|
+
export type CacheTtl = "5m" | "1h";
|
|
12
|
+
/**
|
|
13
|
+
* Anthropic: `tools` render before `system`, which renders before `messages`.
|
|
14
|
+
* A breakpoint on the last tool caches the whole tool block.
|
|
15
|
+
*
|
|
16
|
+
* Two constraints the API enforces:
|
|
17
|
+
* - `cache_control` is rejected on a tool carrying `defer_loading`
|
|
18
|
+
* - the minimum cacheable prefix is model-dependent (512 tokens on Opus 5,
|
|
19
|
+
* 1024 on Sonnet 5); below it the marker is silently ignored
|
|
20
|
+
*/
|
|
21
|
+
export declare function forAnthropic(c: CompressResult, opts?: {
|
|
22
|
+
cache?: boolean;
|
|
23
|
+
ttl?: CacheTtl;
|
|
24
|
+
}): {
|
|
25
|
+
tools: any[];
|
|
26
|
+
system: any;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* OpenAI: tools are `{type:"function", function:{name, description, parameters}}`.
|
|
30
|
+
* Prefix caching is automatic with a 1024-token floor — there is no breakpoint
|
|
31
|
+
* to place, so `systemPreamble` is returned for the caller to prepend to their
|
|
32
|
+
* own system message.
|
|
33
|
+
*/
|
|
34
|
+
export declare function forOpenAI(c: CompressResult): {
|
|
35
|
+
tools: any[];
|
|
36
|
+
systemPreamble: string;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* OpenAI `/v1/responses` — the endpoint you need if you want tools *and*
|
|
40
|
+
* reasoning.
|
|
41
|
+
*
|
|
42
|
+
* The tool shape here is **flat**: `{type, name, description, parameters}`,
|
|
43
|
+
* with no `function:` envelope. This is not cosmetic — on the GPT-5.x line,
|
|
44
|
+
* `/v1/chat/completions` rejects function tools combined with reasoning
|
|
45
|
+
* ("To use function tools, use /v1/responses or set reasoning_effort to
|
|
46
|
+
* 'none'"), so a reasoning agent must be on this endpoint, and sending the
|
|
47
|
+
* nested chat-completions shape here is invalid.
|
|
48
|
+
*
|
|
49
|
+
* Reasoning effort is a request field (`reasoning: { effort }`), not something
|
|
50
|
+
* this adapter sets — it is yours to choose.
|
|
51
|
+
*/
|
|
52
|
+
export declare function forOpenAIResponses(c: CompressResult): {
|
|
53
|
+
tools: {
|
|
54
|
+
type: "function";
|
|
55
|
+
name: string;
|
|
56
|
+
description?: string;
|
|
57
|
+
parameters?: any;
|
|
58
|
+
}[];
|
|
59
|
+
systemPreamble: string;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Google Gemini: a single `functionDeclarations` array. Gemini rejects
|
|
63
|
+
* several JSON Schema keywords, so unknown keys are dropped rather than
|
|
64
|
+
* passed through.
|
|
65
|
+
*/
|
|
66
|
+
export declare function forGemini(c: CompressResult): {
|
|
67
|
+
tools: any[];
|
|
68
|
+
systemPreamble: string;
|
|
69
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic: `tools` render before `system`, which renders before `messages`.
|
|
3
|
+
* A breakpoint on the last tool caches the whole tool block.
|
|
4
|
+
*
|
|
5
|
+
* Two constraints the API enforces:
|
|
6
|
+
* - `cache_control` is rejected on a tool carrying `defer_loading`
|
|
7
|
+
* - the minimum cacheable prefix is model-dependent (512 tokens on Opus 5,
|
|
8
|
+
* 1024 on Sonnet 5); below it the marker is silently ignored
|
|
9
|
+
*/
|
|
10
|
+
export function forAnthropic(c, opts = {}) {
|
|
11
|
+
const cache = opts.cache ?? true;
|
|
12
|
+
const cacheControl = { type: "ephemeral", ...(opts.ttl ? { ttl: opts.ttl } : {}) };
|
|
13
|
+
const tools = c.tools.map((t) => ({ ...t }));
|
|
14
|
+
if (cache && tools.length) {
|
|
15
|
+
// Last tool that can legally carry a breakpoint.
|
|
16
|
+
for (let i = tools.length - 1; i >= 0; i--) {
|
|
17
|
+
if (!tools[i].defer_loading && !String(tools[i].type ?? "").startsWith("tool_search_")) {
|
|
18
|
+
tools[i].cache_control = cacheControl;
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const system = c.systemPreamble
|
|
24
|
+
? [
|
|
25
|
+
{
|
|
26
|
+
type: "text",
|
|
27
|
+
text: c.systemPreamble,
|
|
28
|
+
...(cache && c.cachePreamble ? { cache_control: cacheControl } : {}),
|
|
29
|
+
},
|
|
30
|
+
]
|
|
31
|
+
: undefined;
|
|
32
|
+
return { tools, system };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* OpenAI: tools are `{type:"function", function:{name, description, parameters}}`.
|
|
36
|
+
* Prefix caching is automatic with a 1024-token floor — there is no breakpoint
|
|
37
|
+
* to place, so `systemPreamble` is returned for the caller to prepend to their
|
|
38
|
+
* own system message.
|
|
39
|
+
*/
|
|
40
|
+
export function forOpenAI(c) {
|
|
41
|
+
const tools = c.tools
|
|
42
|
+
.filter((t) => !String(t.type ?? "").startsWith("tool_search_"))
|
|
43
|
+
.map((t) => ({
|
|
44
|
+
type: "function",
|
|
45
|
+
function: {
|
|
46
|
+
name: t.name,
|
|
47
|
+
description: t.description,
|
|
48
|
+
parameters: t.input_schema,
|
|
49
|
+
},
|
|
50
|
+
}));
|
|
51
|
+
return { tools, systemPreamble: c.systemPreamble };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* OpenAI `/v1/responses` — the endpoint you need if you want tools *and*
|
|
55
|
+
* reasoning.
|
|
56
|
+
*
|
|
57
|
+
* The tool shape here is **flat**: `{type, name, description, parameters}`,
|
|
58
|
+
* with no `function:` envelope. This is not cosmetic — on the GPT-5.x line,
|
|
59
|
+
* `/v1/chat/completions` rejects function tools combined with reasoning
|
|
60
|
+
* ("To use function tools, use /v1/responses or set reasoning_effort to
|
|
61
|
+
* 'none'"), so a reasoning agent must be on this endpoint, and sending the
|
|
62
|
+
* nested chat-completions shape here is invalid.
|
|
63
|
+
*
|
|
64
|
+
* Reasoning effort is a request field (`reasoning: { effort }`), not something
|
|
65
|
+
* this adapter sets — it is yours to choose.
|
|
66
|
+
*/
|
|
67
|
+
export function forOpenAIResponses(c) {
|
|
68
|
+
const tools = c.tools
|
|
69
|
+
.filter((t) => !String(t.type ?? "").startsWith("tool_search_"))
|
|
70
|
+
.map((t) => ({
|
|
71
|
+
type: "function",
|
|
72
|
+
name: t.name,
|
|
73
|
+
description: t.description,
|
|
74
|
+
parameters: t.input_schema,
|
|
75
|
+
}));
|
|
76
|
+
return { tools, systemPreamble: c.systemPreamble };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Google Gemini: a single `functionDeclarations` array. Gemini rejects
|
|
80
|
+
* several JSON Schema keywords, so unknown keys are dropped rather than
|
|
81
|
+
* passed through.
|
|
82
|
+
*/
|
|
83
|
+
export function forGemini(c) {
|
|
84
|
+
const clean = (s) => {
|
|
85
|
+
if (!s || typeof s !== "object")
|
|
86
|
+
return s;
|
|
87
|
+
const out = {};
|
|
88
|
+
for (const [k, v] of Object.entries(s)) {
|
|
89
|
+
if (["additionalProperties", "$schema", "default", "examples"].includes(k))
|
|
90
|
+
continue;
|
|
91
|
+
out[k] =
|
|
92
|
+
k === "properties"
|
|
93
|
+
? Object.fromEntries(Object.entries(v).map(([pk, pv]) => [pk, clean(pv)]))
|
|
94
|
+
: k === "items"
|
|
95
|
+
? clean(v)
|
|
96
|
+
: v;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
};
|
|
100
|
+
const functionDeclarations = c.tools
|
|
101
|
+
.filter((t) => !String(t.type ?? "").startsWith("tool_search_"))
|
|
102
|
+
.map((t) => ({
|
|
103
|
+
name: t.name,
|
|
104
|
+
description: t.description,
|
|
105
|
+
parameters: clean(t.input_schema),
|
|
106
|
+
}));
|
|
107
|
+
return { tools: [{ functionDeclarations }], systemPreamble: c.systemPreamble };
|
|
108
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Level, Tool } from "./types.js";
|
|
2
|
+
export type Recommendation = {
|
|
3
|
+
level: Level;
|
|
4
|
+
reason: string;
|
|
5
|
+
toolCount: number;
|
|
6
|
+
namespaceCount: number;
|
|
7
|
+
opsPerNamespace: number;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Pick a level for a given tool set.
|
|
11
|
+
*
|
|
12
|
+
* Thresholds here are measured, not assumed — see docs/RESULTS.md.
|
|
13
|
+
*
|
|
14
|
+
* Two findings shape this function, and both contradicted the design's
|
|
15
|
+
* starting assumptions:
|
|
16
|
+
*
|
|
17
|
+
* - **Level 3 does not cost accuracy.** 150 runs over 10 scenarios, including
|
|
18
|
+
* clusters built specifically to confuse a minified name map: 48/48 correct
|
|
19
|
+
* calls, zero hallucinated names. It was also the fastest and cheapest arm.
|
|
20
|
+
* The real cost is turns (+0.6) and lookup round-trips (~1.7/run), so it is
|
|
21
|
+
* recommended by size, not withheld by superstition.
|
|
22
|
+
*
|
|
23
|
+
* Re-tested on Sonnet 5 and Haiku 4.5: 60/60 correct, still zero
|
|
24
|
+
* hallucinated codes. What degrades on weaker models is argument
|
|
25
|
+
* *formatting*, not tool *choice*: malformed arguments went 0-in-20 on
|
|
26
|
+
* Opus, 3-in-10 on Sonnet, 17-in-30 on Haiku, and every one was caught by
|
|
27
|
+
* `validate` and recovered — all tasks still completed. That is precisely
|
|
28
|
+
* why validation defaults to on. On a weak model, disabling it converts
|
|
29
|
+
* roughly half of all runs from a recovered retry into a bad dispatch.
|
|
30
|
+
* - **Level 2 is dominated.** More tokens, six times the malformed arguments,
|
|
31
|
+
* slower and dearer than level 3. It is never recommended. It stays in the
|
|
32
|
+
* API for callers who need real operation names on the wire.
|
|
33
|
+
*
|
|
34
|
+
* The level 1 → 3 threshold is driven by tools-per-namespace, not tool count:
|
|
35
|
+
* the compound-dispatcher overhead is paid per namespace, so a wide, sparse
|
|
36
|
+
* tool set stays cheaper at level 1 even when the total count is large.
|
|
37
|
+
*/
|
|
38
|
+
export declare function recommendLevel(tools: Tool[], namespaceOf?: (n: string) => {
|
|
39
|
+
ns: string;
|
|
40
|
+
op: string;
|
|
41
|
+
}): Recommendation;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { defaultNamespaceOf } from "./render/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Pick a level for a given tool set.
|
|
4
|
+
*
|
|
5
|
+
* Thresholds here are measured, not assumed — see docs/RESULTS.md.
|
|
6
|
+
*
|
|
7
|
+
* Two findings shape this function, and both contradicted the design's
|
|
8
|
+
* starting assumptions:
|
|
9
|
+
*
|
|
10
|
+
* - **Level 3 does not cost accuracy.** 150 runs over 10 scenarios, including
|
|
11
|
+
* clusters built specifically to confuse a minified name map: 48/48 correct
|
|
12
|
+
* calls, zero hallucinated names. It was also the fastest and cheapest arm.
|
|
13
|
+
* The real cost is turns (+0.6) and lookup round-trips (~1.7/run), so it is
|
|
14
|
+
* recommended by size, not withheld by superstition.
|
|
15
|
+
*
|
|
16
|
+
* Re-tested on Sonnet 5 and Haiku 4.5: 60/60 correct, still zero
|
|
17
|
+
* hallucinated codes. What degrades on weaker models is argument
|
|
18
|
+
* *formatting*, not tool *choice*: malformed arguments went 0-in-20 on
|
|
19
|
+
* Opus, 3-in-10 on Sonnet, 17-in-30 on Haiku, and every one was caught by
|
|
20
|
+
* `validate` and recovered — all tasks still completed. That is precisely
|
|
21
|
+
* why validation defaults to on. On a weak model, disabling it converts
|
|
22
|
+
* roughly half of all runs from a recovered retry into a bad dispatch.
|
|
23
|
+
* - **Level 2 is dominated.** More tokens, six times the malformed arguments,
|
|
24
|
+
* slower and dearer than level 3. It is never recommended. It stays in the
|
|
25
|
+
* API for callers who need real operation names on the wire.
|
|
26
|
+
*
|
|
27
|
+
* The level 1 → 3 threshold is driven by tools-per-namespace, not tool count:
|
|
28
|
+
* the compound-dispatcher overhead is paid per namespace, so a wide, sparse
|
|
29
|
+
* tool set stays cheaper at level 1 even when the total count is large.
|
|
30
|
+
*/
|
|
31
|
+
export function recommendLevel(tools, namespaceOf = defaultNamespaceOf) {
|
|
32
|
+
const namespaces = new Set(tools.map((t) => namespaceOf(t.name).ns));
|
|
33
|
+
const toolCount = tools.length;
|
|
34
|
+
const namespaceCount = namespaces.size;
|
|
35
|
+
const opsPerNamespace = namespaceCount ? toolCount / namespaceCount : 0;
|
|
36
|
+
const base = { toolCount, namespaceCount, opsPerNamespace };
|
|
37
|
+
if (toolCount < 15) {
|
|
38
|
+
return {
|
|
39
|
+
...base,
|
|
40
|
+
level: 1,
|
|
41
|
+
reason: `Only ${toolCount} tools — flattening schemas captures nearly all the available saving, and namespace collapse would add dispatcher overhead for little return.`,
|
|
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.`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
...base,
|
|
53
|
+
level: 3,
|
|
54
|
+
reason: `${toolCount} tools across ${namespaceCount} namespaces (${opsPerNamespace.toFixed(1)} ops each) — deep enough that a single dispatcher plus a cached code map beats per-tool definitions. Measured at ~82% fewer prompt tokens with no accuracy penalty across Opus 5, Sonnet 5 and Haiku 4.5, at the cost of roughly 0.6 extra turns and 1.7 lookup calls per task. Keep argument validation on — on weaker models malformed arguments rise and validation is what catches them. If your workload is latency-critical rather than context-critical, drop to level 1.`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { JsonSchema, Tool, NormalizedTool } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Strip prose and boilerplate from a JSON Schema while preserving everything
|
|
4
|
+
* that constrains decoding: types, enums, required, item types, nesting.
|
|
5
|
+
*
|
|
6
|
+
* This is the single highest-leverage transform in the library — per-property
|
|
7
|
+
* `description` strings are the bulk of a real MCP tool definition.
|
|
8
|
+
*/
|
|
9
|
+
export declare function flattenSchema(schema: JsonSchema | undefined): JsonSchema;
|
|
10
|
+
/**
|
|
11
|
+
* `name(required, optional?:a|b, list?:string[])`
|
|
12
|
+
*
|
|
13
|
+
* A model reads this natively. A 400-token JSON Schema becomes one line.
|
|
14
|
+
*/
|
|
15
|
+
export declare function signatureLine(tool: Tool | NormalizedTool, nameOverride?: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Aggressively shortened descriptor: first sentence, stop-words dropped,
|
|
18
|
+
* lowercased. Used by the `terse` map style, which trades legibility (and the
|
|
19
|
+
* real tool name) for a few tokens per line.
|
|
20
|
+
*/
|
|
21
|
+
export declare function terseDescriptor(s: string): string;
|
|
22
|
+
/** First sentence only — the rest is almost always restatement. */
|
|
23
|
+
export declare function firstSentence(s: string): string;
|
|
24
|
+
/** Cheap size proxy for stats. Not a token count — never used for billing. */
|
|
25
|
+
export declare function countSchemaTokensApprox(x: unknown): number;
|
|
26
|
+
/** Default namespace split: `github_create_issue` → github / create_issue. */
|
|
27
|
+
export declare function defaultNamespaceOf(name: string): {
|
|
28
|
+
ns: string;
|
|
29
|
+
op: string;
|
|
30
|
+
};
|
|
31
|
+
export declare function normalize(tools: Tool[], namespaceOf: (n: string) => {
|
|
32
|
+
ns: string;
|
|
33
|
+
op: string;
|
|
34
|
+
}): NormalizedTool[];
|