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