toolgz 0.1.1 → 0.1.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/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { countSchemaTokensApprox, defaultNamespaceOf, firstSentence, flattenSchema, normalize, signatureLine, terseDescriptor, } from "./render/index.js";
2
2
  import { validateArgs } from "./runtime/validate.js";
3
+ import { nearest } from "./runtime/similar.js";
3
4
  export * from "./types.js";
4
5
  export { flattenSchema, signatureLine, countSchemaTokensApprox } from "./render/index.js";
5
6
  const CODE_CHARS = "abcdefghijklmnopqrstuvwxyz";
@@ -79,6 +80,44 @@ export function compress(input, options = {}) {
79
80
  });
80
81
  }
81
82
  }
83
+ /**
84
+ * Separator-insensitive lookup for level-3 map keys.
85
+ *
86
+ * Observed on gpt-5.6-sol against real MCP tools: the model reassembled a
87
+ * namespaced name with a DOT where the real tool uses an underscore —
88
+ * `gdrive.sheets_append_rows`, `coding.task_result`, `reverse.geocode`. Joining a
89
+ * namespace and an operation with `.` is the ordinary convention for qualified
90
+ * identifiers, so the inference is reasonable and ours was simply too strict.
91
+ * Rejecting it burned six turns per task.
92
+ *
93
+ * Keys are therefore compared with every separator stripped, which accepts `.`,
94
+ * `:`, `/`, `-`, a space, or nothing. Registered ONLY where the normalised form
95
+ * is unambiguous: if two tools normalise alike neither is aliased and the exact
96
+ * name is required, so this can never silently dispatch the wrong tool.
97
+ */
98
+ const normKey = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
99
+ const normIndex = new Map();
100
+ const registerNorm = (key, t) => {
101
+ const k = normKey(key);
102
+ if (!k)
103
+ return;
104
+ const seen = normIndex.get(k);
105
+ if (seen === undefined)
106
+ normIndex.set(k, t); // first claim
107
+ else if (seen && seen.name !== t.name)
108
+ normIndex.set(k, null); // ambiguous
109
+ };
110
+ if (level === 3) {
111
+ for (const t of tools)
112
+ registerNorm(t.name, t);
113
+ for (const [code, t] of codeToTool)
114
+ registerNorm(code, t);
115
+ }
116
+ /** Exact map key first, then the separator-insensitive fallback. */
117
+ const lookupMapKey = (raw) => {
118
+ const key = String(raw ?? "");
119
+ return codeToTool.get(key) ?? normIndex.get(normKey(key)) ?? undefined;
120
+ };
82
121
  const finish = (wire, systemPreamble, cachePreamble, resolve, encode) => {
83
122
  const compressedChars = countSchemaTokensApprox(wire) + systemPreamble.length;
84
123
  return {
@@ -257,11 +296,25 @@ export function compress(input, options = {}) {
257
296
  ? "Each line is: code name(args), where ? marks optional. "
258
297
  : "";
259
298
  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);
299
+ return finish(wire, systemPreamble, true, (rawName, rawArgs) => {
300
+ let name = rawName;
301
+ let args = asObject(rawArgs);
302
+ // Observed on grok-4.5, on every level-3 map style: the model routes the
303
+ // lookup tool through the dispatcher — t(f="q", a={s:"…"}) instead of
304
+ // q(s="…"). The preamble invites it, saying "Invoke with t(f=<code>, a={…})"
305
+ // and then "Use q to expand a code", which reads as everything going through
306
+ // t. Tasks still completed, so this surfaced as wasted turns rather than as
307
+ // failure. `t` and `q` are our own reserved names, so the intent is
308
+ // unambiguous — nothing else could be meant.
309
+ if (name === "t" && (args.f === "q" || args.f === "t")) {
310
+ const nested = asObject(args.a);
311
+ const flat = Object.fromEntries(Object.entries(args).filter(([k]) => k !== "f" && k !== "a"));
312
+ name = String(args.f);
313
+ args = Object.keys(nested).length ? nested : flat;
314
+ }
262
315
  if (name === "q") {
263
316
  if (args.c !== undefined) {
264
- const t = codeToTool.get(String(args.c));
317
+ const t = lookupMapKey(args.c);
265
318
  if (!t)
266
319
  return err(`No map code "${args.c}". Search with q(s=…).`);
267
320
  return {
@@ -286,13 +339,17 @@ export function compress(input, options = {}) {
286
339
  // claude-opus-5: name="b5", args={f:"b5", a:"{…}"}. Codes are unique and
287
340
  // cannot collide with `t` or `q`, so this form is unambiguous and
288
341
  // accepting it removes a whole class of wasted turn.
289
- const viaCode = name !== "t" && name !== "q" ? codeToTool.get(name) : undefined;
342
+ const viaCode = name !== "t" && name !== "q" ? lookupMapKey(name) : undefined;
290
343
  if (!viaCode && name !== "t") {
291
344
  return err(`No tool named "${name}". Invoke tools with t(f=<code>).`);
292
345
  }
293
- const t = viaCode ?? codeToTool.get(String(args.f));
294
- if (!t)
295
- return err(`No map code "${args.f}". Search with q(s=…).`);
346
+ const t = viaCode ?? lookupMapKey(args.f);
347
+ if (!t) {
348
+ // A near miss is the common case, and a bare rejection costs another turn.
349
+ const guess = nearest(String(args.f ?? ""), [...codeToTool.keys()]);
350
+ return err(`No map code "${args.f}". Search with q(s=…).` +
351
+ (guess ? ` Did you mean "${guess}"? Use it exactly as written in <toolmap>.` : ""));
352
+ }
296
353
  // Args may arrive nested under `a`, or flat alongside `f`. Prefer `a`
297
354
  // when present rather than merging, so there is one source of truth.
298
355
  const nested = asObject(args.a);
@@ -128,7 +128,20 @@ export function normalize(tools, namespaceOf) {
128
128
  if (seen.has(t.name))
129
129
  throw new Error(`duplicate tool name: ${t.name}`);
130
130
  seen.add(t.name);
131
- const { ns, op } = namespaceOf(t.name);
131
+ // Validate the callback's return rather than trusting it. The contract is
132
+ // easy to get wrong — it takes a name and returns {ns, op}, not a bare
133
+ // namespace string — and the failure was silent and remote: every tool
134
+ // collapsed into one `undefined` namespace, level 2 emitted a wire tool with
135
+ // an empty name, and the first symptom was the provider rejecting the request
136
+ // with "tools.0.custom.name: Field required", nowhere near the cause.
137
+ const parts = namespaceOf(t.name);
138
+ const ns = parts?.ns;
139
+ const op = parts?.op;
140
+ if (typeof ns !== "string" || !ns || typeof op !== "string" || !op) {
141
+ throw new Error(`namespaceOf("${t.name}") must return { ns, op } with non-empty strings, ` +
142
+ `got ${JSON.stringify(parts)}. ` +
143
+ `Example: (name) => ({ ns: serverOf(name), op: name }).`);
144
+ }
132
145
  return {
133
146
  name: t.name,
134
147
  description: t.description ?? "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolgz",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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": {