@cubicecho/agent-core 2.24.0 → 2.25.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.
Files changed (71) hide show
  1. package/README.md +10 -7
  2. package/dist/agent-loop.d.ts +84 -205
  3. package/dist/agent-loop.js +288 -597
  4. package/dist/calibration.d.ts +14 -10
  5. package/dist/calibration.js +30 -23
  6. package/dist/capabilities.d.ts +111 -42
  7. package/dist/capabilities.js +281 -138
  8. package/dist/client.d.ts +104 -35
  9. package/dist/client.js +205 -74
  10. package/dist/compaction.d.ts +103 -60
  11. package/dist/compaction.js +160 -86
  12. package/dist/config.d.ts +48 -12
  13. package/dist/config.js +20 -1
  14. package/dist/continuation.d.ts +24 -16
  15. package/dist/continuation.js +70 -44
  16. package/dist/digest.d.ts +7 -0
  17. package/dist/digest.js +18 -0
  18. package/dist/errors.d.ts +24 -15
  19. package/dist/errors.js +23 -15
  20. package/dist/events.d.ts +100 -63
  21. package/dist/events.js +250 -120
  22. package/dist/guards.d.ts +35 -4
  23. package/dist/guards.js +38 -5
  24. package/dist/hook-events.d.ts +21 -5
  25. package/dist/hook-events.js +23 -9
  26. package/dist/hooks.d.ts +96 -73
  27. package/dist/hooks.js +202 -116
  28. package/dist/index.d.ts +30 -26
  29. package/dist/index.js +29 -24
  30. package/dist/ledger.d.ts +62 -30
  31. package/dist/ledger.js +163 -56
  32. package/dist/platform.d.ts +13 -0
  33. package/dist/platform.js +13 -0
  34. package/dist/preselect.d.ts +222 -0
  35. package/dist/preselect.js +290 -0
  36. package/dist/request-body.d.ts +32 -0
  37. package/dist/request-body.js +47 -0
  38. package/dist/reset.d.ts +1 -0
  39. package/dist/reset.js +7 -6
  40. package/dist/retry.d.ts +29 -138
  41. package/dist/retry.js +69 -282
  42. package/dist/run-calls.d.ts +123 -0
  43. package/dist/run-calls.js +351 -0
  44. package/dist/run-turn.d.ts +36 -15
  45. package/dist/run-turn.js +74 -37
  46. package/dist/runtime.d.ts +23 -17
  47. package/dist/runtime.js +34 -24
  48. package/dist/schema-compat.d.ts +35 -18
  49. package/dist/schema-compat.js +340 -175
  50. package/dist/scope.d.ts +28 -5
  51. package/dist/scope.js +37 -10
  52. package/dist/side-task.d.ts +123 -40
  53. package/dist/side-task.js +181 -63
  54. package/dist/snapshot.d.ts +10 -5
  55. package/dist/snapshot.js +73 -30
  56. package/dist/spec.d.ts +56 -25
  57. package/dist/spec.js +494 -215
  58. package/dist/stream.d.ts +66 -16
  59. package/dist/stream.js +220 -97
  60. package/dist/thinking.d.ts +23 -8
  61. package/dist/thinking.js +92 -44
  62. package/dist/tokens.d.ts +164 -6
  63. package/dist/tokens.js +319 -6
  64. package/dist/tool-calls.d.ts +82 -17
  65. package/dist/tool-calls.js +198 -83
  66. package/dist/tool-loading.d.ts +101 -189
  67. package/dist/tool-loading.js +277 -371
  68. package/dist/wire.d.ts +81 -0
  69. package/dist/wire.js +73 -0
  70. package/llms.txt +76 -39
  71. package/package.json +4 -4
@@ -1,40 +1,62 @@
1
- import { isRecord } from "./guards.js";
1
+ import { isRecord } from './guards.js';
2
+ import { THINK_FENCE } from './thinking.js';
3
+ import { FinishReason, FUNCTION_TOOL } from './wire.js';
4
+ /**
5
+ * Reading what a model meant by a tool call when it did not write one cleanly.
6
+ *
7
+ * Local models get tool calls wrong in two ways a hosted one rarely does. The arguments come back
8
+ * almost-JSON — single quotes, Python's `True`, a trailing comma, a string holding the JSON
9
+ * rather than the JSON — or cut off at the ceiling. And the call comes back not as a call at all
10
+ * but as text, in the model's own template, because the server's tool-call parser was written for
11
+ * a different one. Both were being handled in every consumer, differently, or not at all.
12
+ */
13
+ /** How much of a call's unreadable arguments an error quotes back. */
14
+ export const ARGUMENT_PREVIEW_CHARS = 200;
15
+ /** The `ToolArgumentsError` kind for a call cut off at the reply ceiling. */
16
+ export const ARGUMENTS_TRUNCATED = 'truncated';
17
+ /** The `ToolArgumentsError` kind for a call the model wrote wrongly. */
18
+ export const ARGUMENTS_MALFORMED = 'malformed';
2
19
  /**
3
20
  * Tool arguments that could not be read as an object, with why.
4
21
  *
22
+ * @remarks
5
23
  * `truncated` is a call cut off at the reply ceiling, which no repair can finish and the fix for
6
24
  * is a larger `maxTokens`; `malformed` is one the model wrote wrongly, which it can be told about
7
25
  * and try again.
8
26
  */
9
27
  export class ToolArgumentsError extends Error {
28
+ name = 'ToolArgumentsError';
10
29
  /** Whether the model ran out of room or wrote something unreadable. */
11
30
  kind;
12
31
  /**
13
- * @param kind Why the arguments could not be read.
14
- * @param message What the model is handed back as the tool's result.
32
+ * @param kind - Why the arguments could not be read.
33
+ * @param message - What the model is handed back as the tool's result.
15
34
  */
16
35
  constructor(kind, message) {
17
36
  super(message);
18
- this.name = "ToolArgumentsError";
19
37
  this.kind = kind;
20
38
  }
21
39
  }
22
40
  /**
23
41
  * Rewrites the almost-JSON local models write into JSON, in one pass that knows where strings are.
24
42
  *
43
+ * @param text - What the model wrote. A string left open is closed at the end of the text.
44
+ * @returns The rewritten text, which nothing here has checked parses.
45
+ *
46
+ * @remarks
25
47
  * Single-quoted strings become double-quoted, Python's `True`, `False` and `None` become their JSON
26
48
  * spellings, bare keys are quoted, and a comma before a closing bracket is dropped. Nothing inside
27
49
  * a string is touched, so an argument that happens to say `True` or `a, }` survives.
28
50
  */
29
51
  function repairJson(text) {
30
- let out = "";
52
+ let out = '';
31
53
  for (let i = 0; i < text.length;) {
32
54
  const char = text[i];
33
55
  if (char === '"' || char === "'") {
34
- let body = "";
56
+ let body = '';
35
57
  let j = i + 1;
36
58
  for (; j < text.length && text[j] !== char; j++) {
37
- if (text[j] === "\\" && j + 1 < text.length) {
59
+ if (text[j] === '\\' && j + 1 < text.length) {
38
60
  // `\'` means nothing in JSON; a quote that needed escaping in single quotes does not.
39
61
  body += char === "'" && text[j + 1] === "'" ? "'" : text[j] + text[j + 1];
40
62
  j++;
@@ -47,22 +69,22 @@ function repairJson(text) {
47
69
  i = j + 1;
48
70
  continue;
49
71
  }
50
- if (char === ",") {
72
+ if (char === ',') {
51
73
  const next = text.slice(i + 1).match(/^\s*([\]}])?/);
52
74
  if (next?.[1]) {
53
75
  i++;
54
76
  continue;
55
77
  }
56
78
  }
57
- const word = /[A-Za-z_$]/.test(char)
58
- ? text.slice(i).match(/^[A-Za-z_$][\w$]*/)?.[0]
59
- : undefined;
79
+ const word = /[A-Za-z_$]/.test(char) ? text.slice(i).match(/^[A-Za-z_$][\w$]*/)?.[0] : undefined;
60
80
  if (word) {
61
- const python = { True: "true", False: "false", None: "null" }[word];
62
- if (/^\s*:/.test(text.slice(i + word.length)))
81
+ const python = { True: 'true', False: 'false', None: 'null' }[word];
82
+ if (/^\s*:/.test(text.slice(i + word.length))) {
63
83
  out += `"${word}"`;
64
- else
84
+ }
85
+ else {
65
86
  out += python ?? word;
87
+ }
66
88
  i += word.length;
67
89
  continue;
68
90
  }
@@ -74,20 +96,25 @@ function repairJson(text) {
74
96
  /**
75
97
  * JSON as it was written, then repaired, then undefined. A string holding JSON is opened once.
76
98
  *
99
+ * @param text - What a model wrote where JSON was asked for, with no prose around it.
100
+ * @returns The value — `null` for a JSON `null` — or `undefined` where neither the text nor its
101
+ * repair parses. A string holding an object or array that does not parse stays the string.
102
+ *
103
+ * @remarks
77
104
  * Read as written first, so the repair can only ever add to what parses: nothing valid is
78
105
  * reinterpreted on its way through.
79
- *
80
- * @param text What a model wrote where JSON was asked for, with no prose around it.
81
106
  */
82
107
  export function looseJson(text) {
83
108
  for (const candidate of [text, repairJson(text)]) {
84
109
  try {
85
110
  const value = JSON.parse(candidate);
86
- if (typeof value !== "string")
111
+ if (typeof value !== 'string') {
87
112
  return value;
113
+ }
88
114
  const inner = value.trim();
89
- if (!/^[[{]/.test(inner))
115
+ if (!/^[[{]/.test(inner)) {
90
116
  return value;
117
+ }
91
118
  return looseJson(inner) ?? value;
92
119
  }
93
120
  catch {
@@ -99,95 +126,139 @@ export function looseJson(text) {
99
126
  /**
100
127
  * A tool call's arguments as the object the tool is handed. Empty is no arguments.
101
128
  *
129
+ * @param raw - The arguments as the model sent them: usually the streamed string, sometimes an
130
+ * object a server parsed already. Null, absent or blank is no arguments.
131
+ * @param [options] - `finishReason`, the turn's. A turn that stopped at `"length"` makes a failure
132
+ * `truncated`, since a call cut off at the ceiling reads exactly like a malformed one.
133
+ * @returns `raw` itself where it was already an object, otherwise what its text parsed to, and a
134
+ * new empty object for no arguments.
135
+ *
136
+ * @remarks
102
137
  * Lenient where the model's meaning is plain and strict where it is not: an object already
103
138
  * parsed passes through, JSON inside a string is opened, and the almost-JSON local models write —
104
139
  * single quotes, `True`, bare keys, a trailing comma — is repaired. What is still not an object
105
140
  * throws a `ToolArgumentsError`, and the loop hands its message back to the model as the tool's
106
141
  * result so it can try again.
107
- *
108
- * @param raw The arguments as the model sent them: usually the streamed string, sometimes an
109
- * object a server parsed already. Null, absent or blank is no arguments.
110
- * @param options `finishReason`, the turn's. A turn that stopped at `"length"` makes a failure
111
- * `truncated`, since a call cut off at the ceiling reads exactly like a malformed one.
112
142
  */
113
143
  export function parseToolArguments(raw, { finishReason } = {}) {
114
- if (isRecord(raw))
144
+ if (isRecord(raw)) {
115
145
  return raw;
116
- if (raw === null || raw === undefined)
146
+ }
147
+ if (raw === null || raw === undefined) {
117
148
  return {};
118
- const text = typeof raw === "string" ? raw.trim() : JSON.stringify(raw);
119
- if (!text)
149
+ }
150
+ const text = typeof raw === 'string' ? raw.trim() : JSON.stringify(raw);
151
+ if (!text) {
120
152
  return {};
153
+ }
121
154
  const parsed = looseJson(text);
122
- if (isRecord(parsed))
155
+ if (isRecord(parsed)) {
123
156
  return parsed;
124
- if (finishReason === "length") {
125
- throw new ToolArgumentsError("truncated", `the tool call was cut off at the reply ceiling before its arguments were complete; raise maxTokens: ${text.slice(0, 200)}`);
126
157
  }
127
- throw new ToolArgumentsError("malformed", parsed === undefined
128
- ? `model produced invalid tool arguments: ${text.slice(0, 200)}`
129
- : `model produced tool arguments that are not an object: ${text.slice(0, 200)}`);
158
+ if (finishReason === FinishReason.Length) {
159
+ throw new ToolArgumentsError(ARGUMENTS_TRUNCATED, `the tool call was cut off at the reply ceiling before its arguments were complete; raise maxTokens: ${text.slice(0, ARGUMENT_PREVIEW_CHARS)}`);
160
+ }
161
+ throw new ToolArgumentsError(ARGUMENTS_MALFORMED, parsed === undefined
162
+ ? `model produced invalid tool arguments: ${text.slice(0, ARGUMENT_PREVIEW_CHARS)}`
163
+ : `model produced tool arguments that are not an object: ${text.slice(0, ARGUMENT_PREVIEW_CHARS)}`);
130
164
  }
131
165
  /**
132
166
  * Where the JSON value opening at `start` closes, one past its last character, or -1 when it
133
- * never does. Brackets inside either kind of string are not counted.
167
+ * never does.
168
+ *
169
+ * @param text - The text the value is in.
170
+ * @param start - The index of the value's opening `{` or `[`, which the caller has checked.
171
+ * @returns An index to slice up to, or -1 for a value still open where the text ends.
172
+ *
173
+ * @remarks
174
+ * Brackets inside either kind of string are not counted.
134
175
  */
135
176
  function valueEnd(text, start) {
136
177
  let depth = 0;
137
- let quote = "";
178
+ let quote = '';
138
179
  for (let i = start; i < text.length; i++) {
139
180
  const char = text[i];
140
181
  if (quote) {
141
- if (char === "\\")
182
+ if (char === '\\') {
142
183
  i++;
143
- else if (char === quote)
144
- quote = "";
184
+ }
185
+ else if (char === quote) {
186
+ quote = '';
187
+ }
145
188
  }
146
189
  else if (char === '"' || char === "'") {
147
190
  quote = char;
148
191
  }
149
- else if (char === "{" || char === "[") {
192
+ else if (char === '{' || char === '[') {
150
193
  depth++;
151
194
  }
152
- else if (char === "}" || char === "]") {
195
+ else if (char === '}' || char === ']') {
153
196
  depth--;
154
- if (depth === 0)
197
+ if (depth === 0) {
155
198
  return i + 1;
199
+ }
156
200
  }
157
201
  }
158
202
  return -1;
159
203
  }
160
204
  /**
161
- * The JSON value opening at or after `at`, past whitespace, and where it ends. An unclosed one
162
- * runs to the end of the text; one that does not parse even repaired is undefined.
205
+ * The JSON value opening at or after `at`, past whitespace, and where it ends.
206
+ *
207
+ * @param text - The text to read from.
208
+ * @param at - Where to look. Only whitespace may sit between it and the value's `{` or `[`.
209
+ * @returns The value and the index one past it, or `undefined` where no object or array opens
210
+ * there.
211
+ *
212
+ * @remarks
213
+ * An unclosed one runs to the end of the text; one that does not parse even repaired is undefined.
163
214
  */
164
215
  function readValue(text, at) {
165
216
  const start = at + (text.slice(at).match(/^\s*/)?.[0].length ?? 0);
166
- if (text[start] !== "{" && text[start] !== "[")
217
+ if (text[start] !== '{' && text[start] !== '[') {
167
218
  return undefined;
219
+ }
168
220
  const closed = valueEnd(text, start);
169
221
  const end = closed < 0 ? text.length : closed;
170
222
  const value = looseJson(text.slice(start, end));
171
223
  return value === undefined ? undefined : { value, end };
172
224
  }
173
- /** One call in any of the shapes templates write: `{name, arguments}`, `{name, parameters}`, `{function: {...}}`. */
225
+ /**
226
+ * One call in any of the shapes templates write: `{name, arguments}`, `{name, parameters}`, `{function: {...}}`.
227
+ *
228
+ * @param entry - A parsed value. Its arguments are read from `arguments`, else `parameters`, else
229
+ * `args`, and none of them is no arguments.
230
+ * @returns The name, and the arguments as JSON text — a string kept as it was written, unchecked.
231
+ * `undefined` for anything that is not an object with a non-empty string for a name.
232
+ */
174
233
  function toCall(entry) {
175
- if (!isRecord(entry))
234
+ if (!isRecord(entry)) {
176
235
  return undefined;
236
+ }
177
237
  const inner = isRecord(entry.function) ? entry.function : entry;
178
238
  const name = inner.name;
179
- if (typeof name !== "string" || !name)
239
+ if (typeof name !== 'string' || !name) {
180
240
  return undefined;
241
+ }
181
242
  const args = inner.arguments ?? inner.parameters ?? inner.args ?? {};
182
- return { name, arguments: typeof args === "string" ? args : JSON.stringify(args) };
243
+ return { name, arguments: typeof args === 'string' ? args : JSON.stringify(args) };
183
244
  }
184
- /** Every call in a value that is one call or a list of them, or undefined if any entry is not one. */
245
+ /**
246
+ * Every call in a value that is one call or a list of them, or undefined if any entry is not one.
247
+ *
248
+ * @param value - A parsed value: one call, or an array of them, in the shapes `toCall` reads.
249
+ * @returns The calls in the order written. An empty array is `undefined` too.
250
+ */
185
251
  function toCalls(value) {
186
252
  const entries = Array.isArray(value) ? value : [value];
187
- const calls = entries.map(toCall);
188
- return calls.length && calls.every((call) => call) ? calls : undefined;
253
+ const calls = entries.flatMap((entry) => toCall(entry) ?? []);
254
+ return calls.length && calls.length === entries.length ? calls : undefined;
189
255
  }
190
- /** A value read as text: JSON where it parses, the string where it does not. */
256
+ /**
257
+ * A value read as text: JSON where it parses, the string where it does not.
258
+ *
259
+ * @param text - A parameter's body, as the markup held it.
260
+ * @returns The parsed value, so `3` is a number and `true` a boolean, or `text` as it was given.
261
+ */
191
262
  function scalar(text) {
192
263
  try {
193
264
  return JSON.parse(text);
@@ -196,7 +267,14 @@ function scalar(text) {
196
267
  return text;
197
268
  }
198
269
  }
199
- /** `<tool_call>` blocks: Hermes and Qwen's JSON, and Qwen3-Coder's `<function=…>` markup. */
270
+ /**
271
+ * `<tool_call>` blocks: Hermes and Qwen's JSON, and Qwen3-Coder's `<function=…>` markup.
272
+ *
273
+ * @param text - The reply to search.
274
+ * @returns One span per block that held a call, in the order they appear: where it starts and ends
275
+ * in `text`, the closing tag included where there is one, and its calls. A block whose JSON does
276
+ * not read as calls is left out. Empty for none.
277
+ */
200
278
  function taggedCalls(text) {
201
279
  const found = [];
202
280
  for (const match of text.matchAll(/<tool_call>/g)) {
@@ -205,22 +283,24 @@ function taggedCalls(text) {
205
283
  const xml = text.slice(at).match(/^\s*<function=([^>\s]+)>([\s\S]*?)<\/function>/);
206
284
  if (xml) {
207
285
  const args = {};
208
- for (const param of xml[2].matchAll(/<parameter=([^>\s]+)>\n?([\s\S]*?)\n?<\/parameter>/g)) {
209
- args[param[1]] = scalar(param[2]);
286
+ const [block, name, body] = xml;
287
+ for (const [, key, value] of body.matchAll(/<parameter=([^>\s]+)>\n?([\s\S]*?)\n?<\/parameter>/g)) {
288
+ args[key] = scalar(value);
210
289
  }
211
- closing.lastIndex = at + xml[0].length;
212
- const end = closing.test(text) ? closing.lastIndex : at + xml[0].length;
290
+ closing.lastIndex = at + block.length;
291
+ const end = closing.test(text) ? closing.lastIndex : at + block.length;
213
292
  found.push({
214
293
  start: match.index,
215
294
  end,
216
- calls: [{ name: xml[1], arguments: JSON.stringify(args) }],
295
+ calls: [{ name, arguments: JSON.stringify(args) }],
217
296
  });
218
297
  continue;
219
298
  }
220
299
  const read = readValue(text, at);
221
300
  const calls = read && toCalls(read.value);
222
- if (!read || !calls)
301
+ if (!read || !calls) {
223
302
  continue;
303
+ }
224
304
  closing.lastIndex = read.end;
225
305
  found.push({
226
306
  start: match.index,
@@ -230,7 +310,14 @@ function taggedCalls(text) {
230
310
  }
231
311
  return found;
232
312
  }
233
- /** Mistral's `[TOOL_CALLS] [...]`, and the newer `[TOOL_CALLS]name[ARGS]{...}`. */
313
+ /**
314
+ * Mistral's `[TOOL_CALLS] [...]`, and the newer `[TOOL_CALLS]name[ARGS]{...}`.
315
+ *
316
+ * @param text - The reply to search.
317
+ * @returns One span per marker with calls after it, in the order they appear, from the marker to
318
+ * the end of its JSON. A marker followed by anything else is left out — the newer spelling's
319
+ * arguments have to be an object. Empty for none.
320
+ */
234
321
  function mistralCalls(text) {
235
322
  const found = [];
236
323
  for (const match of text.matchAll(/\[TOOL_CALLS\]/g)) {
@@ -238,8 +325,9 @@ function mistralCalls(text) {
238
325
  const named = text.slice(at).match(/^\s*([\w.-]+)\[ARGS\]/);
239
326
  if (named) {
240
327
  const read = readValue(text, at + named[0].length);
241
- if (!read || !isRecord(read.value))
328
+ if (!read || !isRecord(read.value)) {
242
329
  continue;
330
+ }
243
331
  found.push({
244
332
  start: match.index,
245
333
  end: read.end,
@@ -249,12 +337,20 @@ function mistralCalls(text) {
249
337
  }
250
338
  const read = readValue(text, at);
251
339
  const calls = read && toCalls(read.value);
252
- if (read && calls)
340
+ if (read && calls) {
253
341
  found.push({ start: match.index, end: read.end, calls });
342
+ }
254
343
  }
255
344
  return found;
256
345
  }
257
- /** Llama 3's `<|python_tag|>{...}`, several calls separated by semicolons, up to `<|eom_id|>`. */
346
+ /**
347
+ * Llama 3's `<|python_tag|>{...}`, several calls separated by semicolons, up to `<|eom_id|>`.
348
+ *
349
+ * @param text - The reply to search.
350
+ * @returns One span per tag with at least one call after it, in the order they appear, from the
351
+ * tag through its last call, a semicolon and an `<|eom_id|>` straight after it included. Empty
352
+ * for none.
353
+ */
258
354
  function pythonTagCalls(text) {
259
355
  const found = [];
260
356
  for (const match of text.matchAll(/<\|python_tag\|>/g)) {
@@ -263,41 +359,56 @@ function pythonTagCalls(text) {
263
359
  for (;;) {
264
360
  const read = readValue(text, end);
265
361
  const more = read && toCalls(read.value);
266
- if (!read || !more)
362
+ if (!read || !more) {
267
363
  break;
364
+ }
268
365
  calls.push(...more);
269
366
  end = read.end;
270
367
  const separator = text.slice(end).match(/^\s*;/);
271
- if (!separator)
368
+ if (!separator) {
272
369
  break;
370
+ }
273
371
  end += separator[0].length;
274
372
  }
275
373
  const eom = text.slice(end).match(/^\s*<\|eom_id\|>/);
276
- if (eom)
374
+ if (eom) {
277
375
  end += eom[0].length;
278
- if (calls.length)
376
+ }
377
+ if (calls.length) {
279
378
  found.push({ start: match.index, end, calls });
379
+ }
280
380
  }
281
381
  return found;
282
382
  }
283
383
  /**
284
384
  * A bare JSON call — the whole reply, or its one fenced block — naming only tools that exist.
385
+ *
386
+ * @param text - The reply to search.
387
+ * @param names - The tools that exist. Empty finds nothing, and one call naming a tool outside it
388
+ * disqualifies the calls written beside it.
389
+ * @returns At most one span: the JSON where the reply is nothing else, or the fenced block, fences
390
+ * included, where the reply holds exactly one and it holds nothing but calls.
391
+ *
392
+ * @remarks
285
393
  * Without the names this is too easily an answer that happens to be JSON.
286
394
  */
287
395
  function bareCalls(text, names) {
288
- if (!names.size)
396
+ if (!names.size) {
289
397
  return [];
398
+ }
290
399
  const known = (calls) => calls?.every((call) => names.has(call.name)) ? calls : undefined;
291
400
  const start = text.search(/\S/);
292
- if (start >= 0 && (text[start] === "{" || text[start] === "[")) {
401
+ if (start >= 0 && (text[start] === '{' || text[start] === '[')) {
293
402
  const read = readValue(text, start);
294
403
  const calls = read && !text.slice(read.end).trim() ? known(toCalls(read.value)) : undefined;
295
- if (read && calls)
404
+ if (read && calls) {
296
405
  return [{ start, end: read.end, calls }];
406
+ }
297
407
  }
298
408
  const fences = [...text.matchAll(/```(?:json)?[ \t]*\n?([\s\S]*?)```/gi)];
299
- if (fences.length !== 1)
409
+ if (fences.length !== 1) {
300
410
  return [];
411
+ }
301
412
  const [fence] = fences;
302
413
  const body = fence[1].trim();
303
414
  const read = /^[[{]/.test(body) ? readValue(body, 0) : undefined;
@@ -307,6 +418,12 @@ function bareCalls(text, names) {
307
418
  /**
308
419
  * Tool calls a model wrote into its reply as text, taken out of it and made into calls.
309
420
  *
421
+ * @param content - The turn's text.
422
+ * @param [options] - `names`, the tools that exist. Without them only the templates' markers count.
423
+ * @returns The text with the calls taken out, and the calls, numbered `call_recovered_0` onward.
424
+ * No calls leaves the text as it was.
425
+ *
426
+ * @remarks
310
427
  * A server whose tool-call parser does not match the model's chat template streams the call as
311
428
  * content, and the run ends on what reads like a finished answer. The templates' own markers are
312
429
  * looked for — `<tool_call>` (Hermes, Qwen, Qwen3-Coder's markup included), `[TOOL_CALLS]`
@@ -314,34 +431,32 @@ function bareCalls(text, names) {
314
431
  * nothing but a JSON call, or holds one fenced one, provided every name in it is in `names`. Only
315
432
  * text after the last `</think>` is searched, since a model deliberating about a call is not making
316
433
  * one.
317
- *
318
- * @param content The turn's text.
319
- * @param options `names`, the tools that exist. Without them only the templates' markers count.
320
- * @returns The text with the calls taken out, and the calls, numbered `call_recovered_0` onward.
321
- * No calls leaves the text as it was.
322
434
  */
323
435
  export function recoverToolCalls(content, { names = [] } = {}) {
324
- const thought = content.toLowerCase().lastIndexOf("</think>");
325
- const from = thought < 0 ? 0 : thought + "</think>".length;
436
+ const thought = content.toLowerCase().lastIndexOf(THINK_FENCE.close);
437
+ const from = thought < 0 ? 0 : thought + THINK_FENCE.close.length;
326
438
  const tail = content.slice(from);
327
439
  let found = [...taggedCalls(tail), ...mistralCalls(tail), ...pythonTagCalls(tail)];
328
- if (!found.length)
440
+ if (!found.length) {
329
441
  found = bareCalls(tail, new Set(names));
330
- if (!found.length)
442
+ }
443
+ if (!found.length) {
331
444
  return { content, toolCalls: [] };
445
+ }
332
446
  found.sort((a, b) => a.start - b.start);
333
- let rest = "";
447
+ let rest = '';
334
448
  let cursor = 0;
335
449
  const toolCalls = [];
336
450
  for (const span of found) {
337
- if (span.start < cursor)
451
+ if (span.start < cursor) {
338
452
  continue;
453
+ }
339
454
  rest += tail.slice(cursor, span.start);
340
455
  cursor = span.end;
341
456
  for (const call of span.calls) {
342
457
  toolCalls.push({
343
458
  id: `call_recovered_${toolCalls.length}`,
344
- type: "function",
459
+ type: FUNCTION_TOOL,
345
460
  function: call,
346
461
  });
347
462
  }