@cubicecho/agent-core 2.23.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.
- package/README.md +14 -9
- package/dist/agent-loop.d.ts +84 -204
- package/dist/agent-loop.js +416 -643
- package/dist/calibration.d.ts +14 -10
- package/dist/calibration.js +30 -22
- package/dist/capabilities.d.ts +140 -35
- package/dist/capabilities.js +345 -152
- package/dist/client.d.ts +104 -35
- package/dist/client.js +210 -78
- package/dist/compaction.d.ts +103 -60
- package/dist/compaction.js +163 -93
- package/dist/config.d.ts +48 -12
- package/dist/config.js +20 -1
- package/dist/continuation.d.ts +25 -17
- package/dist/continuation.js +70 -47
- 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 +108 -58
- package/dist/events.js +265 -128
- package/dist/guards.d.ts +53 -0
- package/dist/guards.js +55 -0
- package/dist/hook-events.d.ts +38 -0
- package/dist/hook-events.js +36 -0
- package/dist/hooks.d.ts +105 -83
- package/dist/hooks.js +224 -139
- package/dist/index.d.ts +30 -25
- package/dist/index.js +29 -23
- package/dist/ledger.d.ts +62 -30
- package/dist/ledger.js +163 -56
- package/dist/platform.d.ts +13 -0
- package/dist/platform.js +13 -0
- package/dist/preselect.d.ts +222 -0
- package/dist/preselect.js +290 -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 +40 -143
- package/dist/retry.js +79 -285
- package/dist/run-calls.d.ts +123 -0
- package/dist/run-calls.js +351 -0
- package/dist/run-turn.d.ts +44 -16
- 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 +51 -11
- package/dist/schema-compat.js +375 -193
- package/dist/scope.d.ts +28 -5
- package/dist/scope.js +37 -10
- package/dist/side-task.d.ts +127 -39
- package/dist/side-task.js +189 -100
- package/dist/snapshot.d.ts +12 -7
- package/dist/snapshot.js +87 -65
- package/dist/spec.d.ts +66 -36
- package/dist/spec.js +514 -265
- package/dist/stream.d.ts +73 -16
- package/dist/stream.js +228 -98
- package/dist/thinking.d.ts +23 -8
- package/dist/thinking.js +96 -46
- package/dist/tokens.d.ts +171 -6
- package/dist/tokens.js +326 -6
- package/dist/tool-calls.d.ts +89 -15
- package/dist/tool-calls.js +205 -85
- package/dist/tool-loading.d.ts +109 -187
- package/dist/tool-loading.js +286 -372
- package/dist/wire.d.ts +81 -0
- package/dist/wire.js +73 -0
- package/llms.txt +87 -43
- package/package.json +4 -4
package/dist/tool-calls.js
CHANGED
|
@@ -1,40 +1,62 @@
|
|
|
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';
|
|
1
19
|
/**
|
|
2
20
|
* Tool arguments that could not be read as an object, with why.
|
|
3
21
|
*
|
|
22
|
+
* @remarks
|
|
4
23
|
* `truncated` is a call cut off at the reply ceiling, which no repair can finish and the fix for
|
|
5
24
|
* is a larger `maxTokens`; `malformed` is one the model wrote wrongly, which it can be told about
|
|
6
25
|
* and try again.
|
|
7
26
|
*/
|
|
8
27
|
export class ToolArgumentsError extends Error {
|
|
28
|
+
name = 'ToolArgumentsError';
|
|
9
29
|
/** Whether the model ran out of room or wrote something unreadable. */
|
|
10
30
|
kind;
|
|
11
31
|
/**
|
|
12
|
-
* @param kind Why the arguments could not be read.
|
|
13
|
-
* @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.
|
|
14
34
|
*/
|
|
15
35
|
constructor(kind, message) {
|
|
16
36
|
super(message);
|
|
17
|
-
this.name = "ToolArgumentsError";
|
|
18
37
|
this.kind = kind;
|
|
19
38
|
}
|
|
20
39
|
}
|
|
21
|
-
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
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] ===
|
|
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:
|
|
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
|
-
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
65
86
|
out += python ?? word;
|
|
87
|
+
}
|
|
66
88
|
i += word.length;
|
|
67
89
|
continue;
|
|
68
90
|
}
|
|
@@ -71,16 +93,28 @@ function repairJson(text) {
|
|
|
71
93
|
}
|
|
72
94
|
return out;
|
|
73
95
|
}
|
|
74
|
-
/**
|
|
75
|
-
|
|
96
|
+
/**
|
|
97
|
+
* JSON as it was written, then repaired, then undefined. A string holding JSON is opened once.
|
|
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
|
|
104
|
+
* Read as written first, so the repair can only ever add to what parses: nothing valid is
|
|
105
|
+
* reinterpreted on its way through.
|
|
106
|
+
*/
|
|
107
|
+
export function looseJson(text) {
|
|
76
108
|
for (const candidate of [text, repairJson(text)]) {
|
|
77
109
|
try {
|
|
78
110
|
const value = JSON.parse(candidate);
|
|
79
|
-
if (typeof value !==
|
|
111
|
+
if (typeof value !== 'string') {
|
|
80
112
|
return value;
|
|
113
|
+
}
|
|
81
114
|
const inner = value.trim();
|
|
82
|
-
if (!/^[[{]/.test(inner))
|
|
115
|
+
if (!/^[[{]/.test(inner)) {
|
|
83
116
|
return value;
|
|
117
|
+
}
|
|
84
118
|
return looseJson(inner) ?? value;
|
|
85
119
|
}
|
|
86
120
|
catch {
|
|
@@ -92,97 +126,139 @@ function looseJson(text) {
|
|
|
92
126
|
/**
|
|
93
127
|
* A tool call's arguments as the object the tool is handed. Empty is no arguments.
|
|
94
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
|
|
95
137
|
* Lenient where the model's meaning is plain and strict where it is not: an object already
|
|
96
138
|
* parsed passes through, JSON inside a string is opened, and the almost-JSON local models write —
|
|
97
139
|
* single quotes, `True`, bare keys, a trailing comma — is repaired. What is still not an object
|
|
98
140
|
* throws a `ToolArgumentsError`, and the loop hands its message back to the model as the tool's
|
|
99
141
|
* result so it can try again.
|
|
100
|
-
*
|
|
101
|
-
* @param raw The arguments as the model sent them: usually the streamed string, sometimes an
|
|
102
|
-
* object a server parsed already. Null, absent or blank is no arguments.
|
|
103
|
-
* @param options `finishReason`, the turn's. A turn that stopped at `"length"` makes a failure
|
|
104
|
-
* `truncated`, since a call cut off at the ceiling reads exactly like a malformed one.
|
|
105
142
|
*/
|
|
106
143
|
export function parseToolArguments(raw, { finishReason } = {}) {
|
|
107
|
-
if (isRecord(raw))
|
|
144
|
+
if (isRecord(raw)) {
|
|
108
145
|
return raw;
|
|
109
|
-
|
|
146
|
+
}
|
|
147
|
+
if (raw === null || raw === undefined) {
|
|
110
148
|
return {};
|
|
111
|
-
|
|
112
|
-
|
|
149
|
+
}
|
|
150
|
+
const text = typeof raw === 'string' ? raw.trim() : JSON.stringify(raw);
|
|
151
|
+
if (!text) {
|
|
113
152
|
return {};
|
|
153
|
+
}
|
|
114
154
|
const parsed = looseJson(text);
|
|
115
|
-
if (isRecord(parsed))
|
|
155
|
+
if (isRecord(parsed)) {
|
|
116
156
|
return parsed;
|
|
117
|
-
if (finishReason === "length") {
|
|
118
|
-
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)}`);
|
|
119
157
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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)}`);
|
|
123
164
|
}
|
|
124
165
|
/**
|
|
125
166
|
* Where the JSON value opening at `start` closes, one past its last character, or -1 when it
|
|
126
|
-
* never does.
|
|
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.
|
|
127
175
|
*/
|
|
128
176
|
function valueEnd(text, start) {
|
|
129
177
|
let depth = 0;
|
|
130
|
-
let quote =
|
|
178
|
+
let quote = '';
|
|
131
179
|
for (let i = start; i < text.length; i++) {
|
|
132
180
|
const char = text[i];
|
|
133
181
|
if (quote) {
|
|
134
|
-
if (char ===
|
|
182
|
+
if (char === '\\') {
|
|
135
183
|
i++;
|
|
136
|
-
|
|
137
|
-
|
|
184
|
+
}
|
|
185
|
+
else if (char === quote) {
|
|
186
|
+
quote = '';
|
|
187
|
+
}
|
|
138
188
|
}
|
|
139
189
|
else if (char === '"' || char === "'") {
|
|
140
190
|
quote = char;
|
|
141
191
|
}
|
|
142
|
-
else if (char ===
|
|
192
|
+
else if (char === '{' || char === '[') {
|
|
143
193
|
depth++;
|
|
144
194
|
}
|
|
145
|
-
else if (char ===
|
|
195
|
+
else if (char === '}' || char === ']') {
|
|
146
196
|
depth--;
|
|
147
|
-
if (depth === 0)
|
|
197
|
+
if (depth === 0) {
|
|
148
198
|
return i + 1;
|
|
199
|
+
}
|
|
149
200
|
}
|
|
150
201
|
}
|
|
151
202
|
return -1;
|
|
152
203
|
}
|
|
153
204
|
/**
|
|
154
|
-
* The JSON value opening at or after `at`, past whitespace, and where it ends.
|
|
155
|
-
*
|
|
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.
|
|
156
214
|
*/
|
|
157
215
|
function readValue(text, at) {
|
|
158
216
|
const start = at + (text.slice(at).match(/^\s*/)?.[0].length ?? 0);
|
|
159
|
-
if (text[start] !==
|
|
217
|
+
if (text[start] !== '{' && text[start] !== '[') {
|
|
160
218
|
return undefined;
|
|
219
|
+
}
|
|
161
220
|
const closed = valueEnd(text, start);
|
|
162
221
|
const end = closed < 0 ? text.length : closed;
|
|
163
222
|
const value = looseJson(text.slice(start, end));
|
|
164
223
|
return value === undefined ? undefined : { value, end };
|
|
165
224
|
}
|
|
166
|
-
/**
|
|
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
|
+
*/
|
|
167
233
|
function toCall(entry) {
|
|
168
|
-
if (!isRecord(entry))
|
|
234
|
+
if (!isRecord(entry)) {
|
|
169
235
|
return undefined;
|
|
236
|
+
}
|
|
170
237
|
const inner = isRecord(entry.function) ? entry.function : entry;
|
|
171
238
|
const name = inner.name;
|
|
172
|
-
if (typeof name !==
|
|
239
|
+
if (typeof name !== 'string' || !name) {
|
|
173
240
|
return undefined;
|
|
241
|
+
}
|
|
174
242
|
const args = inner.arguments ?? inner.parameters ?? inner.args ?? {};
|
|
175
|
-
return { name, arguments: typeof args ===
|
|
243
|
+
return { name, arguments: typeof args === 'string' ? args : JSON.stringify(args) };
|
|
176
244
|
}
|
|
177
|
-
/**
|
|
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
|
+
*/
|
|
178
251
|
function toCalls(value) {
|
|
179
252
|
const entries = Array.isArray(value) ? value : [value];
|
|
180
|
-
const calls = entries.
|
|
181
|
-
return calls.length && calls.
|
|
182
|
-
? calls
|
|
183
|
-
: undefined;
|
|
253
|
+
const calls = entries.flatMap((entry) => toCall(entry) ?? []);
|
|
254
|
+
return calls.length && calls.length === entries.length ? calls : undefined;
|
|
184
255
|
}
|
|
185
|
-
/**
|
|
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
|
+
*/
|
|
186
262
|
function scalar(text) {
|
|
187
263
|
try {
|
|
188
264
|
return JSON.parse(text);
|
|
@@ -191,7 +267,14 @@ function scalar(text) {
|
|
|
191
267
|
return text;
|
|
192
268
|
}
|
|
193
269
|
}
|
|
194
|
-
/**
|
|
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
|
+
*/
|
|
195
278
|
function taggedCalls(text) {
|
|
196
279
|
const found = [];
|
|
197
280
|
for (const match of text.matchAll(/<tool_call>/g)) {
|
|
@@ -200,22 +283,24 @@ function taggedCalls(text) {
|
|
|
200
283
|
const xml = text.slice(at).match(/^\s*<function=([^>\s]+)>([\s\S]*?)<\/function>/);
|
|
201
284
|
if (xml) {
|
|
202
285
|
const args = {};
|
|
203
|
-
|
|
204
|
-
|
|
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);
|
|
205
289
|
}
|
|
206
|
-
closing.lastIndex = at +
|
|
207
|
-
const end = closing.test(text) ? closing.lastIndex : at +
|
|
290
|
+
closing.lastIndex = at + block.length;
|
|
291
|
+
const end = closing.test(text) ? closing.lastIndex : at + block.length;
|
|
208
292
|
found.push({
|
|
209
293
|
start: match.index,
|
|
210
294
|
end,
|
|
211
|
-
calls: [{ name
|
|
295
|
+
calls: [{ name, arguments: JSON.stringify(args) }],
|
|
212
296
|
});
|
|
213
297
|
continue;
|
|
214
298
|
}
|
|
215
299
|
const read = readValue(text, at);
|
|
216
300
|
const calls = read && toCalls(read.value);
|
|
217
|
-
if (!read || !calls)
|
|
301
|
+
if (!read || !calls) {
|
|
218
302
|
continue;
|
|
303
|
+
}
|
|
219
304
|
closing.lastIndex = read.end;
|
|
220
305
|
found.push({
|
|
221
306
|
start: match.index,
|
|
@@ -225,7 +310,14 @@ function taggedCalls(text) {
|
|
|
225
310
|
}
|
|
226
311
|
return found;
|
|
227
312
|
}
|
|
228
|
-
/**
|
|
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
|
+
*/
|
|
229
321
|
function mistralCalls(text) {
|
|
230
322
|
const found = [];
|
|
231
323
|
for (const match of text.matchAll(/\[TOOL_CALLS\]/g)) {
|
|
@@ -233,8 +325,9 @@ function mistralCalls(text) {
|
|
|
233
325
|
const named = text.slice(at).match(/^\s*([\w.-]+)\[ARGS\]/);
|
|
234
326
|
if (named) {
|
|
235
327
|
const read = readValue(text, at + named[0].length);
|
|
236
|
-
if (!read || !isRecord(read.value))
|
|
328
|
+
if (!read || !isRecord(read.value)) {
|
|
237
329
|
continue;
|
|
330
|
+
}
|
|
238
331
|
found.push({
|
|
239
332
|
start: match.index,
|
|
240
333
|
end: read.end,
|
|
@@ -244,12 +337,20 @@ function mistralCalls(text) {
|
|
|
244
337
|
}
|
|
245
338
|
const read = readValue(text, at);
|
|
246
339
|
const calls = read && toCalls(read.value);
|
|
247
|
-
if (read && calls)
|
|
340
|
+
if (read && calls) {
|
|
248
341
|
found.push({ start: match.index, end: read.end, calls });
|
|
342
|
+
}
|
|
249
343
|
}
|
|
250
344
|
return found;
|
|
251
345
|
}
|
|
252
|
-
/**
|
|
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
|
+
*/
|
|
253
354
|
function pythonTagCalls(text) {
|
|
254
355
|
const found = [];
|
|
255
356
|
for (const match of text.matchAll(/<\|python_tag\|>/g)) {
|
|
@@ -258,41 +359,56 @@ function pythonTagCalls(text) {
|
|
|
258
359
|
for (;;) {
|
|
259
360
|
const read = readValue(text, end);
|
|
260
361
|
const more = read && toCalls(read.value);
|
|
261
|
-
if (!read || !more)
|
|
362
|
+
if (!read || !more) {
|
|
262
363
|
break;
|
|
364
|
+
}
|
|
263
365
|
calls.push(...more);
|
|
264
366
|
end = read.end;
|
|
265
367
|
const separator = text.slice(end).match(/^\s*;/);
|
|
266
|
-
if (!separator)
|
|
368
|
+
if (!separator) {
|
|
267
369
|
break;
|
|
370
|
+
}
|
|
268
371
|
end += separator[0].length;
|
|
269
372
|
}
|
|
270
373
|
const eom = text.slice(end).match(/^\s*<\|eom_id\|>/);
|
|
271
|
-
if (eom)
|
|
374
|
+
if (eom) {
|
|
272
375
|
end += eom[0].length;
|
|
273
|
-
|
|
376
|
+
}
|
|
377
|
+
if (calls.length) {
|
|
274
378
|
found.push({ start: match.index, end, calls });
|
|
379
|
+
}
|
|
275
380
|
}
|
|
276
381
|
return found;
|
|
277
382
|
}
|
|
278
383
|
/**
|
|
279
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
|
|
280
393
|
* Without the names this is too easily an answer that happens to be JSON.
|
|
281
394
|
*/
|
|
282
395
|
function bareCalls(text, names) {
|
|
283
|
-
if (!names.size)
|
|
396
|
+
if (!names.size) {
|
|
284
397
|
return [];
|
|
398
|
+
}
|
|
285
399
|
const known = (calls) => calls?.every((call) => names.has(call.name)) ? calls : undefined;
|
|
286
400
|
const start = text.search(/\S/);
|
|
287
|
-
if (start >= 0 && (text[start] ===
|
|
401
|
+
if (start >= 0 && (text[start] === '{' || text[start] === '[')) {
|
|
288
402
|
const read = readValue(text, start);
|
|
289
403
|
const calls = read && !text.slice(read.end).trim() ? known(toCalls(read.value)) : undefined;
|
|
290
|
-
if (read && calls)
|
|
404
|
+
if (read && calls) {
|
|
291
405
|
return [{ start, end: read.end, calls }];
|
|
406
|
+
}
|
|
292
407
|
}
|
|
293
408
|
const fences = [...text.matchAll(/```(?:json)?[ \t]*\n?([\s\S]*?)```/gi)];
|
|
294
|
-
if (fences.length !== 1)
|
|
409
|
+
if (fences.length !== 1) {
|
|
295
410
|
return [];
|
|
411
|
+
}
|
|
296
412
|
const [fence] = fences;
|
|
297
413
|
const body = fence[1].trim();
|
|
298
414
|
const read = /^[[{]/.test(body) ? readValue(body, 0) : undefined;
|
|
@@ -302,6 +418,12 @@ function bareCalls(text, names) {
|
|
|
302
418
|
/**
|
|
303
419
|
* Tool calls a model wrote into its reply as text, taken out of it and made into calls.
|
|
304
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
|
|
305
427
|
* A server whose tool-call parser does not match the model's chat template streams the call as
|
|
306
428
|
* content, and the run ends on what reads like a finished answer. The templates' own markers are
|
|
307
429
|
* looked for — `<tool_call>` (Hermes, Qwen, Qwen3-Coder's markup included), `[TOOL_CALLS]`
|
|
@@ -309,34 +431,32 @@ function bareCalls(text, names) {
|
|
|
309
431
|
* nothing but a JSON call, or holds one fenced one, provided every name in it is in `names`. Only
|
|
310
432
|
* text after the last `</think>` is searched, since a model deliberating about a call is not making
|
|
311
433
|
* one.
|
|
312
|
-
*
|
|
313
|
-
* @param content The turn's text.
|
|
314
|
-
* @param options `names`, the tools that exist. Without them only the templates' markers count.
|
|
315
|
-
* @returns The text with the calls taken out, and the calls, numbered `call_recovered_0` onward.
|
|
316
|
-
* No calls leaves the text as it was.
|
|
317
434
|
*/
|
|
318
435
|
export function recoverToolCalls(content, { names = [] } = {}) {
|
|
319
|
-
const thought = content.toLowerCase().lastIndexOf(
|
|
320
|
-
const from = thought < 0 ? 0 : thought +
|
|
436
|
+
const thought = content.toLowerCase().lastIndexOf(THINK_FENCE.close);
|
|
437
|
+
const from = thought < 0 ? 0 : thought + THINK_FENCE.close.length;
|
|
321
438
|
const tail = content.slice(from);
|
|
322
439
|
let found = [...taggedCalls(tail), ...mistralCalls(tail), ...pythonTagCalls(tail)];
|
|
323
|
-
if (!found.length)
|
|
440
|
+
if (!found.length) {
|
|
324
441
|
found = bareCalls(tail, new Set(names));
|
|
325
|
-
|
|
442
|
+
}
|
|
443
|
+
if (!found.length) {
|
|
326
444
|
return { content, toolCalls: [] };
|
|
445
|
+
}
|
|
327
446
|
found.sort((a, b) => a.start - b.start);
|
|
328
|
-
let rest =
|
|
447
|
+
let rest = '';
|
|
329
448
|
let cursor = 0;
|
|
330
449
|
const toolCalls = [];
|
|
331
450
|
for (const span of found) {
|
|
332
|
-
if (span.start < cursor)
|
|
451
|
+
if (span.start < cursor) {
|
|
333
452
|
continue;
|
|
453
|
+
}
|
|
334
454
|
rest += tail.slice(cursor, span.start);
|
|
335
455
|
cursor = span.end;
|
|
336
456
|
for (const call of span.calls) {
|
|
337
457
|
toolCalls.push({
|
|
338
458
|
id: `call_recovered_${toolCalls.length}`,
|
|
339
|
-
type:
|
|
459
|
+
type: FUNCTION_TOOL,
|
|
340
460
|
function: call,
|
|
341
461
|
});
|
|
342
462
|
}
|