@bitbaum/ai-kit 1.3.0 → 1.4.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.
@@ -72,9 +72,11 @@ export interface ChatMessage {
72
72
  * actually answer on.
73
73
  *
74
74
  * Both exist in the default chain: of nine free models probed live, four
75
- * answered with native `tool_calls` and five only in text. Callers get the
76
- * native shape here; parsing the text protocol is the caller's business,
77
- * because its convention differs per app.
75
+ * answered with native `tool_calls` and five only in text and three of the
76
+ * seven models shipped in the chain today are in that second group. Since 1.4.0
77
+ * both are read here, so a caller no longer has to know which half of the chain
78
+ * answered. See `toolProtocol` on CompleteOptions, and tool-protocol.ts for why
79
+ * a native-only read is a fabrication path rather than a missing feature.
78
80
  */
79
81
  export interface ToolCall {
80
82
  id: string;
@@ -140,6 +142,23 @@ export interface CompleteOptions {
140
142
  temperature?: number;
141
143
  /** Tool definitions in the OpenAI shape; passed through untouched. */
142
144
  tools?: unknown[];
145
+ /**
146
+ * Which tool-call protocols to READ from the reply. Default `"both"`.
147
+ *
148
+ * `"both"` also parses the `TOOL:` / `ARGS:` line protocol out of ordinary
149
+ * content and strips those lines from `text`. This matters more than it
150
+ * sounds: three of the seven models in the default chain cannot emit a native
151
+ * tool call at all, and a native-only read hands their narration back as a
152
+ * finished answer. The turn then reports a lookup that never happened.
153
+ *
154
+ * Parsing is skipped entirely when no `tools` are supplied — a model does not
155
+ * narrate a call it was never offered — so this is inert for the callers who
156
+ * do not use tools, which today is all of them.
157
+ *
158
+ * Set `"native"` only if you parse the text protocol yourself and would
159
+ * otherwise execute each call twice.
160
+ */
161
+ toolProtocol?: "both" | "native";
143
162
  /** Extra body fields for a vendor-specific parameter. Merged last, so it can override. */
144
163
  extraBody?: Record<string, unknown>;
145
164
  /**
package/dist/complete.js CHANGED
@@ -59,6 +59,7 @@ import { chainFrom, freeChain, usableChain } from "./chain.js";
59
59
  import { ChainExhaustedError } from "./attempt.js";
60
60
  import { classifyRateLimit, retryAfterSeconds } from "./limits.js";
61
61
  import { readQuota, readingFromRefusal } from "./meter.js";
62
+ import { parseTextToolCalls, stripToolCallLines, toolNamesFrom } from "./tool-protocol.js";
62
63
  /**
63
64
  * A link failed in a way that says something about the WALK, not just this link.
64
65
  *
@@ -268,10 +269,28 @@ async function callLink(link, options, key) {
268
269
  }
269
270
  const choice = parsed
270
271
  ?.choices?.[0];
271
- const content = firstText(choice?.message);
272
- const toolCalls = toolCallsFrom(choice?.message);
272
+ const rawContent = firstText(choice?.message);
273
+ const native = toolCallsFrom(choice?.message);
274
+ // Read the line protocol out of the prose as well, unless the caller opted
275
+ // out or offered no tools. Native wins on a tie: a model that emits BOTH the
276
+ // real call and a prose echo of it must not run the tool twice — that wastes
277
+ // a round trip and can double-propose an action.
278
+ const wantsText = (options.toolProtocol ?? "both") === "both" && (options.tools?.length ?? 0) > 0;
279
+ const fromText = wantsText
280
+ ? parseTextToolCalls(rawContent, toolNamesFrom(options.tools)).filter((c) => {
281
+ const key = `${c.name}:${c.args}`;
282
+ return !native.some((n) => `${n.name}:${n.args}` === key);
283
+ })
284
+ : [];
285
+ const toolCalls = [...native, ...fromText];
286
+ // Strip the protocol lines only when they were actually read as calls.
287
+ // Removing them without parsing would delete the evidence and leave a shorter
288
+ // hallucination behind, which is worse than either extreme.
289
+ const content = fromText.length > 0 ? stripToolCallLines(rawContent) : rawContent;
273
290
  // A 200 that carries neither text nor a tool call is an outage wearing a
274
291
  // success code — see the header. Demote, so the chain gets its chance.
292
+ // Note the ordering: a reply that was ONLY a narrated call is now empty text
293
+ // WITH tool calls, which is a valid turn, not an outage.
275
294
  if (content.trim() === "" && toolCalls.length === 0) {
276
295
  throw new LinkFailure(link, `${linkId(link)}: 200 with empty content — model produced no output`, {
277
296
  status: res.status,
package/dist/index.d.ts CHANGED
@@ -57,5 +57,6 @@ export { type HealthStatus, type Health, type HealthTrackerOptions, type HealthT
57
57
  export { type LivenessResult, type LivenessOptions, type LivenessProbe, type AiHealthHandlerOptions, createLivenessProbe, createAiHealthHandler, } from "./liveness.js";
58
58
  export { type RateLimitKind, classifyRateLimit, retryAfterSeconds, humanizeWait, rateLimitMessage, } from "./limits.js";
59
59
  export { type QuotaScope, type QuotaWindow, type QuotaReading, type HeaderBag, readQuota, readingFromRefusal, parseResetAt, answersRemaining, } from "./meter.js";
60
+ export { type ParsedToolCall, TEXT_TOOL_PROTOCOL_HINT, parseTextToolCalls, stripToolCallLines, safeJsonObject, toolNamesFrom, } from "./tool-protocol.js";
60
61
  export { type PoolId, type RungId, type TierPolicy, type AiPolicy, type UserState, type WallOption, type Wall, type Decision, DEFAULT_LADDER, decide, shouldSurface, nextUtcReset, } from "./policy.js";
61
62
  export { DAY_SECONDS, DEFAULT_BURST, type ShareInput, type ShareReason, type ShareDecision, fairShare, utcDayElapsed, utcDayKey, } from "./fair-share.js";
package/dist/index.js CHANGED
@@ -57,6 +57,7 @@ export { createHealthTracker, } from "./health.js";
57
57
  export { createLivenessProbe, createAiHealthHandler, } from "./liveness.js";
58
58
  export { classifyRateLimit, retryAfterSeconds, humanizeWait, rateLimitMessage, } from "./limits.js";
59
59
  export { readQuota, readingFromRefusal, parseResetAt, answersRemaining, } from "./meter.js";
60
+ export { TEXT_TOOL_PROTOCOL_HINT, parseTextToolCalls, stripToolCallLines, safeJsonObject, toolNamesFrom, } from "./tool-protocol.js";
60
61
  export { DEFAULT_LADDER, decide, shouldSurface, nextUtcReset, } from "./policy.js";
61
62
  export { DAY_SECONDS, DEFAULT_BURST, fairShare, utcDayElapsed, utcDayKey, } from "./fair-share.js";
62
63
  // Form filling lives at `ai-kit/forms`, NOT here.
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The other way models ask for a tool: by writing it in the reply.
3
+ *
4
+ * ── WHY A NATIVE-ONLY CLIENT LOSES MOST OF THIS PACKAGE'S OWN CHAIN ──────────
5
+ *
6
+ * `chain.ts` carries a probe table from nine free models called with a real
7
+ * tool. Four answered with native `tool_calls`. Five answered only in TEXT.
8
+ * Of the seven models in the shipped default chain today, three are in that
9
+ * second group:
10
+ *
11
+ * google/gemma-4-26b-a4b-it:free text
12
+ * cohere/north-mini-code:free text
13
+ * openrouter/free text
14
+ *
15
+ * A client that reads only `message.tool_calls` gets ZERO tool calls from those
16
+ * three — and, far worse, gets the model's narration of the call as ordinary
17
+ * content. So a turn that should have looked up a record instead returns a
18
+ * confident sentence describing the lookup it did not perform, and every layer
19
+ * downstream treats it as an answer. That is not a missing feature; it is a
20
+ * fabrication path, and it is open on nearly half the chain this package ships.
21
+ *
22
+ * ── WHY LINE-BASED AND NOT NESTED JSON ───────────────────────────────────────
23
+ *
24
+ * The format is chosen for the WEAKEST model expected to run it, not the
25
+ * strongest. An 8B model reliably reproduces two flat lines:
26
+ *
27
+ * TOOL: search_people
28
+ * ARGS: {"query": "Elena"}
29
+ *
30
+ * The same model routinely breaks nested-JSON escaping. Every leniency in the
31
+ * parser below is a shape a small model actually emitted in production — bolded
32
+ * keys because it was writing markdown, a fenced ARGS block, the parentheses
33
+ * copied from the example, a missing ARGS line for a no-argument tool. Rejecting
34
+ * any of them would fail the turn over formatting, which is the exact failure
35
+ * this protocol exists to avoid.
36
+ */
37
+ /** The tool-call shape, matching `complete`'s. `args` stays a raw JSON string. */
38
+ export interface ParsedToolCall {
39
+ id: string;
40
+ name: string;
41
+ args: string;
42
+ }
43
+ /**
44
+ * The convention, as prompt text.
45
+ *
46
+ * Exported so apps stop writing their own wording and drifting from the parser
47
+ * that has to read it back. A prompt and its parser are one contract; keeping
48
+ * them in separate repos is how they diverge.
49
+ *
50
+ * Note what it does NOT say: it never tells the model to use this INSTEAD of
51
+ * native calling. A model with native support should use it, and this is the
52
+ * fallback for one that cannot — offering both costs a few tokens and covers
53
+ * both halves of the chain.
54
+ */
55
+ export declare const TEXT_TOOL_PROTOCOL_HINT: string;
56
+ /**
57
+ * Parse a JSON object, tolerating fences and trailing prose. Null if hopeless.
58
+ *
59
+ * `null` and `{}` must stay distinguishable. Returning `{}` for "nothing
60
+ * parseable here" satisfies a caller's `?? {}` fallback and silently discards
61
+ * arguments the model put on the NEXT line — which is exactly what a model does
62
+ * when it opens a ```json fence after `ARGS:`. That cost a debugging cycle once
63
+ * already; it is a test here now.
64
+ */
65
+ export declare function safeJsonObject(raw: string): Record<string, unknown> | null;
66
+ /**
67
+ * Extract text-protocol calls from a reply.
68
+ *
69
+ * `validNames` is the closed set the model was offered. A line naming anything
70
+ * else is left alone: without that check, a model writing the words "TOOL:
71
+ * whatever" in prose would manufacture a call to a tool that does not exist,
72
+ * and the executor would report a failure the model never asked for.
73
+ */
74
+ export declare function parseTextToolCalls(text: string, validNames: string[]): ParsedToolCall[];
75
+ /**
76
+ * Remove protocol lines from prose.
77
+ *
78
+ * A narrated call must never reach the user as though it were an answer, which
79
+ * is the whole failure this module exists to close. Stripping also means a reply
80
+ * that was ONLY a tool call ends up empty — and an empty reply carrying tool
81
+ * calls is a valid turn, while an empty reply carrying none is an outage. The
82
+ * caller has to keep those apart.
83
+ */
84
+ export declare function stripToolCallLines(text: string): string;
85
+ /**
86
+ * The tool names inside an OpenAI-shaped `tools` array.
87
+ *
88
+ * Derived from what the caller already passed rather than asked for separately:
89
+ * two lists that must agree are one list that cannot disagree, and the failure
90
+ * of the split version is silent — a name missing from the second list makes
91
+ * the parser ignore a real call.
92
+ */
93
+ export declare function toolNamesFrom(tools: unknown[] | undefined): string[];
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The other way models ask for a tool: by writing it in the reply.
3
+ *
4
+ * ── WHY A NATIVE-ONLY CLIENT LOSES MOST OF THIS PACKAGE'S OWN CHAIN ──────────
5
+ *
6
+ * `chain.ts` carries a probe table from nine free models called with a real
7
+ * tool. Four answered with native `tool_calls`. Five answered only in TEXT.
8
+ * Of the seven models in the shipped default chain today, three are in that
9
+ * second group:
10
+ *
11
+ * google/gemma-4-26b-a4b-it:free text
12
+ * cohere/north-mini-code:free text
13
+ * openrouter/free text
14
+ *
15
+ * A client that reads only `message.tool_calls` gets ZERO tool calls from those
16
+ * three — and, far worse, gets the model's narration of the call as ordinary
17
+ * content. So a turn that should have looked up a record instead returns a
18
+ * confident sentence describing the lookup it did not perform, and every layer
19
+ * downstream treats it as an answer. That is not a missing feature; it is a
20
+ * fabrication path, and it is open on nearly half the chain this package ships.
21
+ *
22
+ * ── WHY LINE-BASED AND NOT NESTED JSON ───────────────────────────────────────
23
+ *
24
+ * The format is chosen for the WEAKEST model expected to run it, not the
25
+ * strongest. An 8B model reliably reproduces two flat lines:
26
+ *
27
+ * TOOL: search_people
28
+ * ARGS: {"query": "Elena"}
29
+ *
30
+ * The same model routinely breaks nested-JSON escaping. Every leniency in the
31
+ * parser below is a shape a small model actually emitted in production — bolded
32
+ * keys because it was writing markdown, a fenced ARGS block, the parentheses
33
+ * copied from the example, a missing ARGS line for a no-argument tool. Rejecting
34
+ * any of them would fail the turn over formatting, which is the exact failure
35
+ * this protocol exists to avoid.
36
+ */
37
+ /**
38
+ * The convention, as prompt text.
39
+ *
40
+ * Exported so apps stop writing their own wording and drifting from the parser
41
+ * that has to read it back. A prompt and its parser are one contract; keeping
42
+ * them in separate repos is how they diverge.
43
+ *
44
+ * Note what it does NOT say: it never tells the model to use this INSTEAD of
45
+ * native calling. A model with native support should use it, and this is the
46
+ * fallback for one that cannot — offering both costs a few tokens and covers
47
+ * both halves of the chain.
48
+ */
49
+ export const TEXT_TOOL_PROTOCOL_HINT = [
50
+ "If you cannot emit a native tool call, call a tool by writing these two lines in your reply, exactly like this:",
51
+ "",
52
+ "TOOL: tool_name",
53
+ 'ARGS: {"argument": "value"}',
54
+ "",
55
+ "Write nothing else in a reply that calls tools — you will be given the results and asked again.",
56
+ ].join("\n");
57
+ /** Matches a TOOL: line, tolerating list bullets, markdown bold, and `=`. */
58
+ const TOOL_LINE = /^\s*(?:[-*>]\s*)?(?:\*\*)?TOOL(?:\*\*)?\s*[:=]\s*(.+?)\s*$/i;
59
+ /** Matches an ARGS: line, same tolerances. */
60
+ const ARGS_LINE = /^\s*(?:[-*>]\s*)?(?:\*\*)?ARGS(?:\*\*)?\s*[:=]\s*(.*)$/i;
61
+ /** Either key, for the strip pass. */
62
+ const EITHER_LINE = /^\s*(?:[-*>]\s*)?(?:\*\*)?(?:TOOL|ARGS)(?:\*\*)?\s*[:=]/i;
63
+ /** How far past a TOOL: line to look for its ARGS:, and how far to join a multi-line object. */
64
+ const ARGS_SEARCH_LINES = 4;
65
+ const ARGS_JOIN_LINES = 8;
66
+ /**
67
+ * Parse a JSON object, tolerating fences and trailing prose. Null if hopeless.
68
+ *
69
+ * `null` and `{}` must stay distinguishable. Returning `{}` for "nothing
70
+ * parseable here" satisfies a caller's `?? {}` fallback and silently discards
71
+ * arguments the model put on the NEXT line — which is exactly what a model does
72
+ * when it opens a ```json fence after `ARGS:`. That cost a debugging cycle once
73
+ * already; it is a test here now.
74
+ */
75
+ export function safeJsonObject(raw) {
76
+ const cleaned = raw
77
+ .trim()
78
+ .replace(/^```(?:json)?/i, "")
79
+ .replace(/```$/, "")
80
+ .trim();
81
+ if (!cleaned)
82
+ return null;
83
+ if (cleaned === "{}")
84
+ return {};
85
+ const start = cleaned.indexOf("{");
86
+ if (start === -1)
87
+ return null;
88
+ // Walk to the matching brace so trailing commentary does not break the parse.
89
+ // String-aware: a brace inside a quoted value must not change the depth, or
90
+ // an argument like {"q": "a } b"} truncates at the wrong place.
91
+ let depth = 0;
92
+ let inString = false;
93
+ let escaped = false;
94
+ for (let i = start; i < cleaned.length; i++) {
95
+ const ch = cleaned[i];
96
+ if (escaped) {
97
+ escaped = false;
98
+ continue;
99
+ }
100
+ if (ch === "\\") {
101
+ escaped = true;
102
+ continue;
103
+ }
104
+ if (ch === '"') {
105
+ inString = !inString;
106
+ continue;
107
+ }
108
+ if (inString)
109
+ continue;
110
+ if (ch === "{")
111
+ depth++;
112
+ else if (ch === "}") {
113
+ depth--;
114
+ if (depth === 0) {
115
+ try {
116
+ const parsed = JSON.parse(cleaned.slice(start, i + 1));
117
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
118
+ ? parsed
119
+ : null;
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ }
125
+ }
126
+ }
127
+ return null;
128
+ }
129
+ /**
130
+ * Extract text-protocol calls from a reply.
131
+ *
132
+ * `validNames` is the closed set the model was offered. A line naming anything
133
+ * else is left alone: without that check, a model writing the words "TOOL:
134
+ * whatever" in prose would manufacture a call to a tool that does not exist,
135
+ * and the executor would report a failure the model never asked for.
136
+ */
137
+ export function parseTextToolCalls(text, validNames) {
138
+ if (!text || validNames.length === 0)
139
+ return [];
140
+ const calls = [];
141
+ const valid = new Set(validNames);
142
+ const lines = text.split(/\r?\n/);
143
+ for (let i = 0; i < lines.length; i++) {
144
+ const line = lines[i] ?? "";
145
+ const m = TOOL_LINE.exec(line);
146
+ const rawName = m?.[1];
147
+ if (!rawName)
148
+ continue;
149
+ // Tolerate `name(...)`, backticks, and trailing punctuation copied from prose.
150
+ const name = rawName
151
+ .replace(/[`*]/g, "")
152
+ .replace(/\(.*$/, "")
153
+ .replace(/[.,;]$/, "")
154
+ .trim();
155
+ if (!valid.has(name))
156
+ continue;
157
+ let args = "{}";
158
+ for (let j = i + 1; j < Math.min(i + ARGS_SEARCH_LINES, lines.length); j++) {
159
+ const next = lines[j] ?? "";
160
+ const a = ARGS_LINE.exec(next);
161
+ if (a) {
162
+ // The object may continue past this line: a model opening a ```json
163
+ // fence puts the brace on the NEXT line, and a pretty-printed object
164
+ // spans several. Join forward and let the brace matcher find the end.
165
+ const rest = lines.slice(j, Math.min(j + ARGS_JOIN_LINES, lines.length)).join("\n");
166
+ const parsed = safeJsonObject(a[1] ?? "") ?? safeJsonObject(rest.replace(/^[^:=]*[:=]/, ""));
167
+ if (parsed)
168
+ args = JSON.stringify(parsed);
169
+ break;
170
+ }
171
+ // A new TOOL line means this call simply had no arguments.
172
+ if (TOOL_LINE.test(next))
173
+ break;
174
+ }
175
+ calls.push({ id: `text_${calls.length}_${name}`, name, args });
176
+ }
177
+ return calls;
178
+ }
179
+ /**
180
+ * Remove protocol lines from prose.
181
+ *
182
+ * A narrated call must never reach the user as though it were an answer, which
183
+ * is the whole failure this module exists to close. Stripping also means a reply
184
+ * that was ONLY a tool call ends up empty — and an empty reply carrying tool
185
+ * calls is a valid turn, while an empty reply carrying none is an outage. The
186
+ * caller has to keep those apart.
187
+ */
188
+ export function stripToolCallLines(text) {
189
+ return text
190
+ .split(/\r?\n/)
191
+ .filter((l) => !EITHER_LINE.test(l))
192
+ .join("\n")
193
+ .replace(/```(?:json)?\s*```/g, "")
194
+ .replace(/\n{3,}/g, "\n\n")
195
+ .trim();
196
+ }
197
+ /**
198
+ * The tool names inside an OpenAI-shaped `tools` array.
199
+ *
200
+ * Derived from what the caller already passed rather than asked for separately:
201
+ * two lists that must agree are one list that cannot disagree, and the failure
202
+ * of the split version is silent — a name missing from the second list makes
203
+ * the parser ignore a real call.
204
+ */
205
+ export function toolNamesFrom(tools) {
206
+ if (!Array.isArray(tools))
207
+ return [];
208
+ const names = [];
209
+ for (const entry of tools) {
210
+ const fn = entry?.function;
211
+ if (fn && typeof fn.name === "string" && fn.name)
212
+ names.push(fn.name);
213
+ }
214
+ return names;
215
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitbaum/ai-kit",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "license": "MIT",
5
5
  "author": "Mao Nakamoto",
6
6
  "homepage": "https://github.com/bitbaum/ai-kit#readme",
package/src/complete.ts CHANGED
@@ -61,6 +61,7 @@ import { ChainExhaustedError, type ChainAttemptFailure } from "./attempt.js";
61
61
  import type { HealthTracker } from "./health.js";
62
62
  import { classifyRateLimit, retryAfterSeconds, type RateLimitKind } from "./limits.js";
63
63
  import { readQuota, readingFromRefusal, type QuotaReading } from "./meter.js";
64
+ import { parseTextToolCalls, stripToolCallLines, toolNamesFrom } from "./tool-protocol.js";
64
65
 
65
66
  /** One message in the OpenAI chat-completions shape every provider here speaks. */
66
67
  export interface ChatMessage {
@@ -76,9 +77,11 @@ export interface ChatMessage {
76
77
  * actually answer on.
77
78
  *
78
79
  * Both exist in the default chain: of nine free models probed live, four
79
- * answered with native `tool_calls` and five only in text. Callers get the
80
- * native shape here; parsing the text protocol is the caller's business,
81
- * because its convention differs per app.
80
+ * answered with native `tool_calls` and five only in text and three of the
81
+ * seven models shipped in the chain today are in that second group. Since 1.4.0
82
+ * both are read here, so a caller no longer has to know which half of the chain
83
+ * answered. See `toolProtocol` on CompleteOptions, and tool-protocol.ts for why
84
+ * a native-only read is a fabrication path rather than a missing feature.
82
85
  */
83
86
  export interface ToolCall {
84
87
  id: string;
@@ -145,6 +148,23 @@ export interface CompleteOptions {
145
148
  temperature?: number;
146
149
  /** Tool definitions in the OpenAI shape; passed through untouched. */
147
150
  tools?: unknown[];
151
+ /**
152
+ * Which tool-call protocols to READ from the reply. Default `"both"`.
153
+ *
154
+ * `"both"` also parses the `TOOL:` / `ARGS:` line protocol out of ordinary
155
+ * content and strips those lines from `text`. This matters more than it
156
+ * sounds: three of the seven models in the default chain cannot emit a native
157
+ * tool call at all, and a native-only read hands their narration back as a
158
+ * finished answer. The turn then reports a lookup that never happened.
159
+ *
160
+ * Parsing is skipped entirely when no `tools` are supplied — a model does not
161
+ * narrate a call it was never offered — so this is inert for the callers who
162
+ * do not use tools, which today is all of them.
163
+ *
164
+ * Set `"native"` only if you parse the text protocol yourself and would
165
+ * otherwise execute each call twice.
166
+ */
167
+ toolProtocol?: "both" | "native";
148
168
  /** Extra body fields for a vendor-specific parameter. Merged last, so it can override. */
149
169
  extraBody?: Record<string, unknown>;
150
170
  /**
@@ -432,11 +452,31 @@ async function callLink(
432
452
 
433
453
  const choice = (parsed as { choices?: Array<{ message?: Record<string, unknown> }> })
434
454
  ?.choices?.[0];
435
- const content = firstText(choice?.message);
436
- const toolCalls = toolCallsFrom(choice?.message);
455
+ const rawContent = firstText(choice?.message);
456
+ const native = toolCallsFrom(choice?.message);
457
+
458
+ // Read the line protocol out of the prose as well, unless the caller opted
459
+ // out or offered no tools. Native wins on a tie: a model that emits BOTH the
460
+ // real call and a prose echo of it must not run the tool twice — that wastes
461
+ // a round trip and can double-propose an action.
462
+ const wantsText = (options.toolProtocol ?? "both") === "both" && (options.tools?.length ?? 0) > 0;
463
+ const fromText = wantsText
464
+ ? parseTextToolCalls(rawContent, toolNamesFrom(options.tools)).filter((c) => {
465
+ const key = `${c.name}:${c.args}`;
466
+ return !native.some((n) => `${n.name}:${n.args}` === key);
467
+ })
468
+ : [];
469
+
470
+ const toolCalls = [...native, ...fromText];
471
+ // Strip the protocol lines only when they were actually read as calls.
472
+ // Removing them without parsing would delete the evidence and leave a shorter
473
+ // hallucination behind, which is worse than either extreme.
474
+ const content = fromText.length > 0 ? stripToolCallLines(rawContent) : rawContent;
437
475
 
438
476
  // A 200 that carries neither text nor a tool call is an outage wearing a
439
477
  // success code — see the header. Demote, so the chain gets its chance.
478
+ // Note the ordering: a reply that was ONLY a narrated call is now empty text
479
+ // WITH tool calls, which is a valid turn, not an outage.
440
480
  if (content.trim() === "" && toolCalls.length === 0) {
441
481
  throw new LinkFailure(
442
482
  link,
package/src/index.ts CHANGED
@@ -128,6 +128,15 @@ export {
128
128
  answersRemaining,
129
129
  } from "./meter.js";
130
130
 
131
+ export {
132
+ type ParsedToolCall,
133
+ TEXT_TOOL_PROTOCOL_HINT,
134
+ parseTextToolCalls,
135
+ stripToolCallLines,
136
+ safeJsonObject,
137
+ toolNamesFrom,
138
+ } from "./tool-protocol.js";
139
+
131
140
  export {
132
141
  type PoolId,
133
142
  type RungId,
@@ -0,0 +1,223 @@
1
+ /**
2
+ * The other way models ask for a tool: by writing it in the reply.
3
+ *
4
+ * ── WHY A NATIVE-ONLY CLIENT LOSES MOST OF THIS PACKAGE'S OWN CHAIN ──────────
5
+ *
6
+ * `chain.ts` carries a probe table from nine free models called with a real
7
+ * tool. Four answered with native `tool_calls`. Five answered only in TEXT.
8
+ * Of the seven models in the shipped default chain today, three are in that
9
+ * second group:
10
+ *
11
+ * google/gemma-4-26b-a4b-it:free text
12
+ * cohere/north-mini-code:free text
13
+ * openrouter/free text
14
+ *
15
+ * A client that reads only `message.tool_calls` gets ZERO tool calls from those
16
+ * three — and, far worse, gets the model's narration of the call as ordinary
17
+ * content. So a turn that should have looked up a record instead returns a
18
+ * confident sentence describing the lookup it did not perform, and every layer
19
+ * downstream treats it as an answer. That is not a missing feature; it is a
20
+ * fabrication path, and it is open on nearly half the chain this package ships.
21
+ *
22
+ * ── WHY LINE-BASED AND NOT NESTED JSON ───────────────────────────────────────
23
+ *
24
+ * The format is chosen for the WEAKEST model expected to run it, not the
25
+ * strongest. An 8B model reliably reproduces two flat lines:
26
+ *
27
+ * TOOL: search_people
28
+ * ARGS: {"query": "Elena"}
29
+ *
30
+ * The same model routinely breaks nested-JSON escaping. Every leniency in the
31
+ * parser below is a shape a small model actually emitted in production — bolded
32
+ * keys because it was writing markdown, a fenced ARGS block, the parentheses
33
+ * copied from the example, a missing ARGS line for a no-argument tool. Rejecting
34
+ * any of them would fail the turn over formatting, which is the exact failure
35
+ * this protocol exists to avoid.
36
+ */
37
+
38
+ /** The tool-call shape, matching `complete`'s. `args` stays a raw JSON string. */
39
+ export interface ParsedToolCall {
40
+ id: string;
41
+ name: string;
42
+ args: string;
43
+ }
44
+
45
+ /**
46
+ * The convention, as prompt text.
47
+ *
48
+ * Exported so apps stop writing their own wording and drifting from the parser
49
+ * that has to read it back. A prompt and its parser are one contract; keeping
50
+ * them in separate repos is how they diverge.
51
+ *
52
+ * Note what it does NOT say: it never tells the model to use this INSTEAD of
53
+ * native calling. A model with native support should use it, and this is the
54
+ * fallback for one that cannot — offering both costs a few tokens and covers
55
+ * both halves of the chain.
56
+ */
57
+ export const TEXT_TOOL_PROTOCOL_HINT = [
58
+ "If you cannot emit a native tool call, call a tool by writing these two lines in your reply, exactly like this:",
59
+ "",
60
+ "TOOL: tool_name",
61
+ 'ARGS: {"argument": "value"}',
62
+ "",
63
+ "Write nothing else in a reply that calls tools — you will be given the results and asked again.",
64
+ ].join("\n");
65
+
66
+ /** Matches a TOOL: line, tolerating list bullets, markdown bold, and `=`. */
67
+ const TOOL_LINE = /^\s*(?:[-*>]\s*)?(?:\*\*)?TOOL(?:\*\*)?\s*[:=]\s*(.+?)\s*$/i;
68
+ /** Matches an ARGS: line, same tolerances. */
69
+ const ARGS_LINE = /^\s*(?:[-*>]\s*)?(?:\*\*)?ARGS(?:\*\*)?\s*[:=]\s*(.*)$/i;
70
+ /** Either key, for the strip pass. */
71
+ const EITHER_LINE = /^\s*(?:[-*>]\s*)?(?:\*\*)?(?:TOOL|ARGS)(?:\*\*)?\s*[:=]/i;
72
+
73
+ /** How far past a TOOL: line to look for its ARGS:, and how far to join a multi-line object. */
74
+ const ARGS_SEARCH_LINES = 4;
75
+ const ARGS_JOIN_LINES = 8;
76
+
77
+ /**
78
+ * Parse a JSON object, tolerating fences and trailing prose. Null if hopeless.
79
+ *
80
+ * `null` and `{}` must stay distinguishable. Returning `{}` for "nothing
81
+ * parseable here" satisfies a caller's `?? {}` fallback and silently discards
82
+ * arguments the model put on the NEXT line — which is exactly what a model does
83
+ * when it opens a ```json fence after `ARGS:`. That cost a debugging cycle once
84
+ * already; it is a test here now.
85
+ */
86
+ export function safeJsonObject(raw: string): Record<string, unknown> | null {
87
+ const cleaned = raw
88
+ .trim()
89
+ .replace(/^```(?:json)?/i, "")
90
+ .replace(/```$/, "")
91
+ .trim();
92
+ if (!cleaned) return null;
93
+ if (cleaned === "{}") return {};
94
+
95
+ const start = cleaned.indexOf("{");
96
+ if (start === -1) return null;
97
+
98
+ // Walk to the matching brace so trailing commentary does not break the parse.
99
+ // String-aware: a brace inside a quoted value must not change the depth, or
100
+ // an argument like {"q": "a } b"} truncates at the wrong place.
101
+ let depth = 0;
102
+ let inString = false;
103
+ let escaped = false;
104
+ for (let i = start; i < cleaned.length; i++) {
105
+ const ch = cleaned[i];
106
+ if (escaped) {
107
+ escaped = false;
108
+ continue;
109
+ }
110
+ if (ch === "\\") {
111
+ escaped = true;
112
+ continue;
113
+ }
114
+ if (ch === '"') {
115
+ inString = !inString;
116
+ continue;
117
+ }
118
+ if (inString) continue;
119
+ if (ch === "{") depth++;
120
+ else if (ch === "}") {
121
+ depth--;
122
+ if (depth === 0) {
123
+ try {
124
+ const parsed: unknown = JSON.parse(cleaned.slice(start, i + 1));
125
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
126
+ ? (parsed as Record<string, unknown>)
127
+ : null;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * Extract text-protocol calls from a reply.
139
+ *
140
+ * `validNames` is the closed set the model was offered. A line naming anything
141
+ * else is left alone: without that check, a model writing the words "TOOL:
142
+ * whatever" in prose would manufacture a call to a tool that does not exist,
143
+ * and the executor would report a failure the model never asked for.
144
+ */
145
+ export function parseTextToolCalls(text: string, validNames: string[]): ParsedToolCall[] {
146
+ if (!text || validNames.length === 0) return [];
147
+ const calls: ParsedToolCall[] = [];
148
+ const valid = new Set(validNames);
149
+ const lines = text.split(/\r?\n/);
150
+
151
+ for (let i = 0; i < lines.length; i++) {
152
+ const line = lines[i] ?? "";
153
+ const m = TOOL_LINE.exec(line);
154
+ const rawName = m?.[1];
155
+ if (!rawName) continue;
156
+
157
+ // Tolerate `name(...)`, backticks, and trailing punctuation copied from prose.
158
+ const name = rawName
159
+ .replace(/[`*]/g, "")
160
+ .replace(/\(.*$/, "")
161
+ .replace(/[.,;]$/, "")
162
+ .trim();
163
+ if (!valid.has(name)) continue;
164
+
165
+ let args = "{}";
166
+ for (let j = i + 1; j < Math.min(i + ARGS_SEARCH_LINES, lines.length); j++) {
167
+ const next = lines[j] ?? "";
168
+ const a = ARGS_LINE.exec(next);
169
+ if (a) {
170
+ // The object may continue past this line: a model opening a ```json
171
+ // fence puts the brace on the NEXT line, and a pretty-printed object
172
+ // spans several. Join forward and let the brace matcher find the end.
173
+ const rest = lines.slice(j, Math.min(j + ARGS_JOIN_LINES, lines.length)).join("\n");
174
+ const parsed =
175
+ safeJsonObject(a[1] ?? "") ?? safeJsonObject(rest.replace(/^[^:=]*[:=]/, ""));
176
+ if (parsed) args = JSON.stringify(parsed);
177
+ break;
178
+ }
179
+ // A new TOOL line means this call simply had no arguments.
180
+ if (TOOL_LINE.test(next)) break;
181
+ }
182
+
183
+ calls.push({ id: `text_${calls.length}_${name}`, name, args });
184
+ }
185
+ return calls;
186
+ }
187
+
188
+ /**
189
+ * Remove protocol lines from prose.
190
+ *
191
+ * A narrated call must never reach the user as though it were an answer, which
192
+ * is the whole failure this module exists to close. Stripping also means a reply
193
+ * that was ONLY a tool call ends up empty — and an empty reply carrying tool
194
+ * calls is a valid turn, while an empty reply carrying none is an outage. The
195
+ * caller has to keep those apart.
196
+ */
197
+ export function stripToolCallLines(text: string): string {
198
+ return text
199
+ .split(/\r?\n/)
200
+ .filter((l) => !EITHER_LINE.test(l))
201
+ .join("\n")
202
+ .replace(/```(?:json)?\s*```/g, "")
203
+ .replace(/\n{3,}/g, "\n\n")
204
+ .trim();
205
+ }
206
+
207
+ /**
208
+ * The tool names inside an OpenAI-shaped `tools` array.
209
+ *
210
+ * Derived from what the caller already passed rather than asked for separately:
211
+ * two lists that must agree are one list that cannot disagree, and the failure
212
+ * of the split version is silent — a name missing from the second list makes
213
+ * the parser ignore a real call.
214
+ */
215
+ export function toolNamesFrom(tools: unknown[] | undefined): string[] {
216
+ if (!Array.isArray(tools)) return [];
217
+ const names: string[] = [];
218
+ for (const entry of tools) {
219
+ const fn = (entry as { function?: { name?: unknown } } | null)?.function;
220
+ if (fn && typeof fn.name === "string" && fn.name) names.push(fn.name);
221
+ }
222
+ return names;
223
+ }