@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.
- 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 +35 -4
- package/dist/guards.js +38 -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 +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 +29 -138
- package/dist/retry.js +69 -282
- 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 +340 -175
- package/dist/scope.d.ts +28 -5
- package/dist/scope.js +37 -10
- package/dist/side-task.d.ts +123 -40
- package/dist/side-task.js +181 -63
- 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 +82 -17
- package/dist/tool-calls.js +198 -83
- package/dist/tool-loading.d.ts +101 -189
- package/dist/tool-loading.js +277 -371
- package/dist/wire.d.ts +81 -0
- package/dist/wire.js +73 -0
- package/llms.txt +76 -39
- package/package.json +4 -4
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { errorMessage } from './errors.js';
|
|
2
|
+
import { RunEventKind } from './events.js';
|
|
3
|
+
import { parseToolArguments, } from './tool-calls.js';
|
|
4
|
+
import { CALL_TOOL, expandNames, inCatalog, LOAD_TOOLS, loadResult, proxiedCall, proxyLoadResult, requestedNames, shownCall, toolName, } from './tool-loading.js';
|
|
5
|
+
import { FUNCTION_TOOL, Role } from './wire.js';
|
|
6
|
+
/**
|
|
7
|
+
* The tools between two turns: a step's calls read, run and written into the transcript.
|
|
8
|
+
*
|
|
9
|
+
* Apart from the loop because it is a different job with its own rules — a call is announced
|
|
10
|
+
* before it runs and answered whatever happens, `load_tools` and `call_tool` are answered here
|
|
11
|
+
* rather than dispatched, and a failure part-way has to leave a transcript an endpoint will take
|
|
12
|
+
* back — and none of them is about when to send the next request.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* A long tool argument or result cut to what a watcher needs, with the full length said.
|
|
16
|
+
*
|
|
17
|
+
* @param text - What to show.
|
|
18
|
+
* @param [limit] - Characters kept. Text at or under it comes back as it was.
|
|
19
|
+
* @returns The text to show. Cut, it ends in an ellipsis and the whole length, `… (N chars)`.
|
|
20
|
+
*
|
|
21
|
+
* @remarks
|
|
22
|
+
* For events, never for the transcript: the model reads the whole of what a tool returned.
|
|
23
|
+
*/
|
|
24
|
+
export const preview = (text, limit = 2000) => text.length > limit ? `${text.slice(0, limit)}… (${text.length} chars)` : text;
|
|
25
|
+
/** What a tool call the run was stopped during is answered with, in place of a result. */
|
|
26
|
+
const STOPPED_CALL = 'Stopped before this call finished.';
|
|
27
|
+
/** The same for a call of that step the loop never got to. */
|
|
28
|
+
const UNRUN_CALL = 'Not run: the run stopped first.';
|
|
29
|
+
/**
|
|
30
|
+
* One call with its arguments read, or with why they could not be.
|
|
31
|
+
*
|
|
32
|
+
* @param call - The call as the turn carried it.
|
|
33
|
+
* @param finishReason - Why the turn ended, which tells a call cut off from one written wrongly.
|
|
34
|
+
* @returns The call beside its arguments and their repaired JSON, or beside the error and `{}`.
|
|
35
|
+
*/
|
|
36
|
+
function readCall(call, finishReason) {
|
|
37
|
+
try {
|
|
38
|
+
const args = parseToolArguments(call.function.arguments, { finishReason });
|
|
39
|
+
return { call, args, normal: JSON.stringify(args) };
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
return { call, error, normal: '{}' };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A turn's calls with their arguments read.
|
|
47
|
+
*
|
|
48
|
+
* @param calls - The turn's calls, in the order the model made them.
|
|
49
|
+
* @param finishReason - Why the turn ended, which tells a call cut off from one written wrongly.
|
|
50
|
+
* @returns One entry per call, in the same order. Unreadable arguments do not throw: the entry
|
|
51
|
+
* carries the error in place of them.
|
|
52
|
+
*
|
|
53
|
+
* @remarks
|
|
54
|
+
* A server that parses replayed arguments refuses the almost-JSON a model wrote, so each is
|
|
55
|
+
* normalised here, and one that could not be read at all is replayed as no arguments.
|
|
56
|
+
*/
|
|
57
|
+
export const readCalls = (calls, finishReason) => calls.map((call) => readCall(call, finishReason));
|
|
58
|
+
/**
|
|
59
|
+
* The assistant message a turn is written into the transcript as, its calls' arguments repaired.
|
|
60
|
+
*
|
|
61
|
+
* @param content - The turn's text. Empty is written as `null`.
|
|
62
|
+
* @param parsed - The turn's calls as `readCalls` read them. Not written to. None leaves
|
|
63
|
+
* `tool_calls` off the message rather than empty.
|
|
64
|
+
* @returns A new message, each call on it a copy with `normal` for its arguments.
|
|
65
|
+
*/
|
|
66
|
+
export const assistantMessage = (content, parsed) => ({
|
|
67
|
+
role: Role.Assistant,
|
|
68
|
+
content: content || null,
|
|
69
|
+
...(parsed.length
|
|
70
|
+
? {
|
|
71
|
+
tool_calls: parsed.map(({ call, normal }) => ({
|
|
72
|
+
...call,
|
|
73
|
+
function: { ...call.function, arguments: normal },
|
|
74
|
+
})),
|
|
75
|
+
}
|
|
76
|
+
: {}),
|
|
77
|
+
});
|
|
78
|
+
/**
|
|
79
|
+
* The definitions the host gave for these names, in the order the names are given.
|
|
80
|
+
*
|
|
81
|
+
* @param definitions - The host's definitions by name.
|
|
82
|
+
* @param names - The names wanted. One the host gave no definition for is left out, and one given
|
|
83
|
+
* twice is answered twice.
|
|
84
|
+
* @returns A new array of the definitions themselves, not copies.
|
|
85
|
+
*/
|
|
86
|
+
export const definedAs = (definitions, names) => [...names].flatMap((name) => definitions.get(name) ?? []);
|
|
87
|
+
/**
|
|
88
|
+
* What a call is shown, counted and dispatched as: a `call_tool` as the tool it names.
|
|
89
|
+
*
|
|
90
|
+
* @param run - The run, read for whether a `call_tool` is looked through.
|
|
91
|
+
* @param entry - The call, with its arguments read.
|
|
92
|
+
* @returns The name, and the arguments as JSON text under `input`: the inner tool's for a
|
|
93
|
+
* `call_tool` that names one, and otherwise the call's own, as the model wrote them.
|
|
94
|
+
*
|
|
95
|
+
* @remarks
|
|
96
|
+
* Read from the repaired arguments where there are any, since the model's own may be almost-JSON.
|
|
97
|
+
*/
|
|
98
|
+
const shownAs = (run, { call, args, normal }) => run.proxies && call.function.name === CALL_TOOL
|
|
99
|
+
? shownCall(CALL_TOOL, args ? normal : call.function.arguments)
|
|
100
|
+
: { name: call.function.name, input: call.function.arguments };
|
|
101
|
+
/**
|
|
102
|
+
* Answers a `load_tools`, counting what it loaded.
|
|
103
|
+
*
|
|
104
|
+
* @param run - The run. Written to: the names this call loads are added to `loaded`, and `loads`
|
|
105
|
+
* counts the new ones, the ones already loaded and the ones not in the catalogue.
|
|
106
|
+
* @param args - The call's arguments, read as `requestedNames` reads them.
|
|
107
|
+
* @returns The text the model reads, and `ok`: false where nothing it named was loadable, true
|
|
108
|
+
* where it named even one tool that was loaded already.
|
|
109
|
+
*/
|
|
110
|
+
function answerLoad(run, args) {
|
|
111
|
+
const { catalog, proxied, definitions, loaded, loads } = run;
|
|
112
|
+
const resolved = expandNames(requestedNames(args), catalog);
|
|
113
|
+
const content = proxied
|
|
114
|
+
? proxyLoadResult(resolved, catalog, definedAs(definitions, resolved.matched), loaded)
|
|
115
|
+
: loadResult(resolved, catalog, loaded);
|
|
116
|
+
// Proxied, a load is only of what the result could define: a catalogued name the host
|
|
117
|
+
// gave no definition for was not loaded, and is not counted or remembered as though it was.
|
|
118
|
+
const hits = proxied ? resolved.matched.filter((hit) => definitions.has(hit)) : resolved.matched;
|
|
119
|
+
for (const hit of hits) {
|
|
120
|
+
loads[loaded.has(hit) ? 'redundantLoads' : 'toolsLoaded']++;
|
|
121
|
+
}
|
|
122
|
+
loads.unknownToolNames += resolved.unknown.length;
|
|
123
|
+
for (const hit of hits) {
|
|
124
|
+
loaded.add(hit);
|
|
125
|
+
}
|
|
126
|
+
return { content, ok: hits.length > 0 };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Makes a call at most once per key, sharing the in-flight promise so two identical calls in one
|
|
130
|
+
* step make one request between them whether they run together or one after the other.
|
|
131
|
+
*
|
|
132
|
+
* @param answered - The step's calls so far, by key. Written to.
|
|
133
|
+
* @param key - What makes two calls the same one.
|
|
134
|
+
* @param make - Makes the call. Not called where the key has an answer, or one on its way.
|
|
135
|
+
* @returns The answer, which for a repeat is the first call's. Rejects with what the call did, for
|
|
136
|
+
* every caller waiting on it.
|
|
137
|
+
*
|
|
138
|
+
* @remarks
|
|
139
|
+
* A call that rejected is forgotten, so asking again is a real retry rather than a replayed
|
|
140
|
+
* failure.
|
|
141
|
+
*/
|
|
142
|
+
async function once(answered, key, make) {
|
|
143
|
+
const previous = answered.get(key);
|
|
144
|
+
if (previous) {
|
|
145
|
+
return previous;
|
|
146
|
+
}
|
|
147
|
+
const pending = make();
|
|
148
|
+
answered.set(key, pending);
|
|
149
|
+
try {
|
|
150
|
+
return await pending;
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (answered.get(key) === pending) {
|
|
154
|
+
answered.delete(key);
|
|
155
|
+
}
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Runs one call to its result, telling the host of both. A tool that throws is that call's answer
|
|
161
|
+
* rather than the run's end, unless the run was stopped.
|
|
162
|
+
*
|
|
163
|
+
* @param run - The run the call belongs to.
|
|
164
|
+
* @param entry - The call, with its arguments read.
|
|
165
|
+
* @param answered - The step's calls already made, by tool and arguments, for `dedupeToolCalls`.
|
|
166
|
+
* @returns The call's id, the name it was shown under, whether it worked, and the whole of what
|
|
167
|
+
* the model reads. Rejects where the run was stopped or a host callback threw.
|
|
168
|
+
*/
|
|
169
|
+
async function runCall(run, entry, answered) {
|
|
170
|
+
const { catalog, onDemand, proxied, signal } = run;
|
|
171
|
+
const { call, args, error: unreadable, normal } = entry;
|
|
172
|
+
const proxy = run.proxies && call.function.name === CALL_TOOL;
|
|
173
|
+
const { name, input: raw } = shownAs(run, entry);
|
|
174
|
+
// Looked through before the call is announced, so a host is told of the tool it names with
|
|
175
|
+
// that tool's arguments. A name outside the catalogue is refused here, so `call_tool` reaches
|
|
176
|
+
// nothing a load could not, and the refusal is the call's answer below.
|
|
177
|
+
let inner = args ?? {};
|
|
178
|
+
let refused;
|
|
179
|
+
if (proxy && args) {
|
|
180
|
+
try {
|
|
181
|
+
inner = proxiedCall(args, catalog).input;
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
inner = {};
|
|
185
|
+
refused = { error };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Built for every call rather than only the dispatched ones, so a host hears of the calls
|
|
189
|
+
// the loop answers itself in the same shape. Arguments that could not be read are none.
|
|
190
|
+
const request = { id: call.id, name, args: inner, raw };
|
|
191
|
+
run.onEvent({ kind: RunEventKind.ToolCall, id: call.id, name, text: preview(raw) });
|
|
192
|
+
run.onToolCall?.(request);
|
|
193
|
+
let content;
|
|
194
|
+
let ok = true;
|
|
195
|
+
try {
|
|
196
|
+
if (!args) {
|
|
197
|
+
throw unreadable;
|
|
198
|
+
}
|
|
199
|
+
// By the name the model called, not the one a `call_tool` wraps: `load_tools` is not in
|
|
200
|
+
// the catalogue, and one reached through `call_tool` is refused below like any other.
|
|
201
|
+
if (onDemand && call.function.name === LOAD_TOOLS) {
|
|
202
|
+
({ content, ok } = answerLoad(run, args));
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
if (refused) {
|
|
206
|
+
throw refused.error;
|
|
207
|
+
}
|
|
208
|
+
// A model that skips `load_tools` and calls a catalogued tool by name is right about
|
|
209
|
+
// what it wants; load it and run it rather than refusing. Not proxied, where loaded
|
|
210
|
+
// means its definition is in the history, and this call put none there.
|
|
211
|
+
if (onDemand && !proxied && inCatalog(catalog, name)) {
|
|
212
|
+
run.loaded.add(name);
|
|
213
|
+
}
|
|
214
|
+
run.used.add(name);
|
|
215
|
+
// Keyed on the inner call, so one made through `call_tool` and one made natively share.
|
|
216
|
+
const key = `${name}\0${proxy ? JSON.stringify(inner) : normal}`;
|
|
217
|
+
content = run.dedupable(request)
|
|
218
|
+
? await once(answered, key, () => run.dispatch(request, signal))
|
|
219
|
+
: await run.dispatch(request, signal);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
if (signal?.aborted) {
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
content = errorMessage(error);
|
|
227
|
+
ok = false;
|
|
228
|
+
}
|
|
229
|
+
run.onEvent({ kind: RunEventKind.ToolResult, id: call.id, name, ok, text: preview(content) });
|
|
230
|
+
const result = { id: call.id, name, ok, content };
|
|
231
|
+
run.onToolResult?.(result);
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Runs a step's calls and writes each result into the transcript, in the order asked.
|
|
236
|
+
*
|
|
237
|
+
* @param run - The run the step belongs to.
|
|
238
|
+
* @param parsed - The step's calls, with their arguments read.
|
|
239
|
+
* @param messages - The transcript the results are written into.
|
|
240
|
+
* @param step - Which step this is, for the host told of each result.
|
|
241
|
+
*
|
|
242
|
+
* @remarks
|
|
243
|
+
* A failure part-way leaves the transcript well-formed before it is rethrown: a call with no
|
|
244
|
+
* result is a transcript no endpoint takes back, so every call of the step is answered — with what
|
|
245
|
+
* it returned where it had, and otherwise with a line saying it was stopped, or never run.
|
|
246
|
+
*/
|
|
247
|
+
export async function runCalls(run, parsed, messages, step) {
|
|
248
|
+
const { signal, toolCalls } = run;
|
|
249
|
+
// Per step, not per run: the answer to a call made two steps ago was true before the tools in
|
|
250
|
+
// between ran, and the file the model read may be the file it has since written.
|
|
251
|
+
const answered = new Map();
|
|
252
|
+
// By position, so a call that came back while another was being stopped is still found.
|
|
253
|
+
const outcomes = parsed.map(() => undefined);
|
|
254
|
+
// How many of the step's calls were handed off, and how many have their result in the
|
|
255
|
+
// transcript. Both count from the front, since results are written in the order asked.
|
|
256
|
+
let begun = 0;
|
|
257
|
+
let kept = 0;
|
|
258
|
+
const keep = async (id, content) => {
|
|
259
|
+
const result = {
|
|
260
|
+
role: Role.Tool,
|
|
261
|
+
tool_call_id: id,
|
|
262
|
+
content,
|
|
263
|
+
};
|
|
264
|
+
messages.push(result);
|
|
265
|
+
kept++;
|
|
266
|
+
await run.announce(result, step);
|
|
267
|
+
};
|
|
268
|
+
try {
|
|
269
|
+
if (run.parallel) {
|
|
270
|
+
signal?.throwIfAborted();
|
|
271
|
+
begun = parsed.length;
|
|
272
|
+
await Promise.all(parsed.map(async (call, at) => {
|
|
273
|
+
outcomes[at] = await runCall(run, call, answered);
|
|
274
|
+
}));
|
|
275
|
+
for (const { id, name, ok, content } of outcomes.flatMap((outcome) => outcome ?? [])) {
|
|
276
|
+
toolCalls.push({ id, name, ok });
|
|
277
|
+
await keep(id, content);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
for (const [at, call] of parsed.entries()) {
|
|
282
|
+
signal?.throwIfAborted();
|
|
283
|
+
begun = at + 1;
|
|
284
|
+
const { id, name, ok, content } = await runCall(run, call, answered);
|
|
285
|
+
toolCalls.push({ id, name, ok });
|
|
286
|
+
await keep(id, content);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
// Only a call that was made counts in `toolCalls`.
|
|
292
|
+
for (const [at, entry] of parsed.entries()) {
|
|
293
|
+
const { call } = entry;
|
|
294
|
+
if (at < kept) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const outcome = outcomes[at];
|
|
298
|
+
// Under the name it was announced by, which for a `call_tool` is the tool it names.
|
|
299
|
+
if (at < begun) {
|
|
300
|
+
toolCalls.push({ id: call.id, name: shownAs(run, entry).name, ok: outcome?.ok ?? false });
|
|
301
|
+
}
|
|
302
|
+
// The run is already ending on `error`; a host that cannot take this result does not
|
|
303
|
+
// get to replace it.
|
|
304
|
+
await keep(call.id, outcome?.content ?? (at < begun ? STOPPED_CALL : UNRUN_CALL)).catch(() => { });
|
|
305
|
+
}
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Writes a proxied preselection into the transcript as a `load_tools` exchange: the tool array is
|
|
311
|
+
* fixed, so a shortlist has nowhere else to go, and it is answered as though the model had loaded
|
|
312
|
+
* it. Told to the host as a call the model made would be, so one pairing calls with results by id
|
|
313
|
+
* shows this one like the rest.
|
|
314
|
+
*
|
|
315
|
+
* @param run - The run it opens.
|
|
316
|
+
* @param shortlist - The definitions preselected, each of them in the catalogue.
|
|
317
|
+
* @param messages - The transcript, which the exchange is appended to.
|
|
318
|
+
*/
|
|
319
|
+
export async function loadShortlist(run, shortlist, messages) {
|
|
320
|
+
const { catalog } = run;
|
|
321
|
+
const names = shortlist.map((tool) => toolName(tool) ?? '');
|
|
322
|
+
// Numbered by where the call lands, so two questions in one transcript do not share an id.
|
|
323
|
+
const id = `preselect-${messages.length}`;
|
|
324
|
+
const args = JSON.stringify({ names });
|
|
325
|
+
// Held to its own length rather than `MAX_PER_LOAD`: that cap is for a model choosing, and a
|
|
326
|
+
// host that shortlisted more has already chosen.
|
|
327
|
+
const content = proxyLoadResult(expandNames(names, catalog, names.length), catalog, shortlist);
|
|
328
|
+
run.onEvent({ kind: RunEventKind.ToolCall, id, name: LOAD_TOOLS, text: preview(args) });
|
|
329
|
+
run.onToolCall?.({ id, name: LOAD_TOOLS, args: { names }, raw: args });
|
|
330
|
+
run.onEvent({ kind: RunEventKind.ToolResult, id, name: LOAD_TOOLS, ok: true, text: preview(content) });
|
|
331
|
+
run.onToolResult?.({ id, name: LOAD_TOOLS, ok: true, content });
|
|
332
|
+
run.toolCalls.push({ id, name: LOAD_TOOLS, ok: true });
|
|
333
|
+
run.loads.toolsLoaded += names.length;
|
|
334
|
+
for (const name of names) {
|
|
335
|
+
run.loaded.add(name);
|
|
336
|
+
}
|
|
337
|
+
const exchange = [
|
|
338
|
+
{
|
|
339
|
+
role: Role.Assistant,
|
|
340
|
+
content: null,
|
|
341
|
+
tool_calls: [{ id, type: FUNCTION_TOOL, function: { name: LOAD_TOOLS, arguments: args } }],
|
|
342
|
+
},
|
|
343
|
+
{ role: Role.Tool, tool_call_id: id, content },
|
|
344
|
+
];
|
|
345
|
+
// Both written before either is announced, so a host that throws on the first leaves a call
|
|
346
|
+
// with its result. Told as step zero's, with no turn: no request was made for them.
|
|
347
|
+
messages.push(...exchange);
|
|
348
|
+
for (const message of exchange) {
|
|
349
|
+
await run.announce(message, 0);
|
|
350
|
+
}
|
|
351
|
+
}
|
package/dist/run-turn.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type OpenAI from
|
|
2
|
-
import { type Capabilities, type ModelCapabilities } from
|
|
3
|
-
import { type StreamTurnOptions, type Turn } from
|
|
1
|
+
import type OpenAI from 'openai';
|
|
2
|
+
import { type Capabilities, type ModelCapabilities, type OnNotice } from './capabilities.ts';
|
|
3
|
+
import { type StreamTurnOptions, type Turn } from './stream.ts';
|
|
4
4
|
/**
|
|
5
5
|
* One turn, given as many attempts as the caller allows.
|
|
6
6
|
*
|
|
@@ -28,7 +28,7 @@ import { type StreamTurnOptions, type Turn } from "./stream.ts";
|
|
|
28
28
|
* with. See `calibrate`.
|
|
29
29
|
*/
|
|
30
30
|
/** A retry is not the same event as a downgrade, but a watcher wants to be told about both. */
|
|
31
|
-
export interface RunTurnOptions extends Omit<StreamTurnOptions,
|
|
31
|
+
export interface RunTurnOptions extends Omit<StreamTurnOptions, 'produced'> {
|
|
32
32
|
/**
|
|
33
33
|
* How many times a lost request is worth sending again. Zero is one attempt, which is the
|
|
34
34
|
* default because a caller with no retry budget in its settings should not inherit one.
|
|
@@ -39,10 +39,13 @@ export interface RunTurnOptions extends Omit<StreamTurnOptions, "produced"> {
|
|
|
39
39
|
* Told what was given up on and what is being waited out, for a watcher who would otherwise
|
|
40
40
|
* see an unexplained pause. Carries both the capability notices and the retry notices.
|
|
41
41
|
*/
|
|
42
|
-
onNotice?:
|
|
42
|
+
onNotice?: OnNotice;
|
|
43
43
|
/**
|
|
44
|
-
* What the model will read, in tokens. Zero
|
|
44
|
+
* What the model will read, in tokens. Zero sends whatever it is given.
|
|
45
45
|
*
|
|
46
|
+
* @defaultValue `0`
|
|
47
|
+
*
|
|
48
|
+
* @remarks
|
|
46
49
|
* With a limit, the request is sized before it is sent — the prompt plus the reply ceiling
|
|
47
50
|
* the body carries, since that is what the endpoint weighs — and a `ContextOverflow` is raised
|
|
48
51
|
* here rather than by the endpoint one round trip later. It is opt-in because the number is the
|
|
@@ -58,6 +61,7 @@ export interface RunTurnOptions extends Omit<StreamTurnOptions, "produced"> {
|
|
|
58
61
|
* are negotiated too — a reasoning effort it does not take, a token ceiling it spells the
|
|
59
62
|
* other way, a temperature that is not ours to pick. Left out, only the endpoint's own are.
|
|
60
63
|
*
|
|
64
|
+
* @remarks
|
|
61
65
|
* It is given here rather than read off the body because the body is built from the answer:
|
|
62
66
|
* `request` has to know what this model refused before it can build one that avoids it.
|
|
63
67
|
*/
|
|
@@ -68,33 +72,50 @@ export interface RunTurnOptions extends Omit<StreamTurnOptions, "produced"> {
|
|
|
68
72
|
*/
|
|
69
73
|
droppable?: Iterable<string>;
|
|
70
74
|
/**
|
|
71
|
-
* How long to wait on a server answering that the model is still loading
|
|
72
|
-
*
|
|
75
|
+
* How long to wait on a server answering that the model is still loading; zero gives up on the
|
|
76
|
+
* first such answer like any other 503.
|
|
77
|
+
*
|
|
78
|
+
* @defaultValue `LOADING_TIMEOUT_MS`
|
|
73
79
|
*
|
|
80
|
+
* @remarks
|
|
74
81
|
* Polled every `LOADING_POLL_MS` without spending `maxRetries`, and announced once rather than
|
|
75
82
|
* per poll. A consumer that starts alongside its llama.cpp, or asks a router for a model it
|
|
76
83
|
* has to swap in, meets this on its first request every time.
|
|
77
84
|
*/
|
|
78
85
|
loadingTimeoutMs?: number;
|
|
79
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* What a notice calls the model, where the caller did not say which one it is.
|
|
89
|
+
*
|
|
90
|
+
* @param model - The model's name. Absent reads as "the model", which is all there is to say.
|
|
91
|
+
* @returns The name as given, an empty one included, or the stand-in.
|
|
92
|
+
*/
|
|
93
|
+
export declare const modelLabel: (model: string | undefined) => string;
|
|
80
94
|
/**
|
|
81
95
|
* Builds a turn's request body from what the endpoint and the model have refused so far.
|
|
82
96
|
*
|
|
97
|
+
* @remarks
|
|
83
98
|
* A function rather than a body because a downgrade changes what is sent, so it is called again
|
|
84
99
|
* per attempt. Its second argument is absent where no model was named.
|
|
85
100
|
*/
|
|
86
101
|
export type RequestBuilder = (supports: Capabilities, model: ModelCapabilities | undefined) => OpenAI.ChatCompletionCreateParamsStreaming;
|
|
87
102
|
/**
|
|
103
|
+
* One turn seen through to an answer: negotiated with the endpoint, retried where the failure is
|
|
104
|
+
* transient, and waited for where the model is still loading.
|
|
105
|
+
*
|
|
106
|
+
* @param client - The pooled client for this endpoint.
|
|
107
|
+
* @param supports - What the endpoint has already refused, threaded through the negotiation.
|
|
108
|
+
* @param request - Builds the body. Called again per attempt, since a downgrade changes it. Its
|
|
109
|
+
* second argument is what the model named in `options.model` has refused, absent when none was.
|
|
110
|
+
* @param [options] - Retry budget, context limit, the model to negotiate for, notices, and the
|
|
111
|
+
* stream's own callbacks.
|
|
112
|
+
* @returns The turn the attempt that got through produced, its usage carrying what every attempt
|
|
113
|
+
* together cost: `wallMs`, `retries` and `timeouts`.
|
|
114
|
+
*
|
|
115
|
+
* @remarks
|
|
88
116
|
* `request` is a callback rather than a body because the body has to be rebuilt from whatever
|
|
89
117
|
* the last attempt latched off: the tools it sends depend on `strictSchemas`, and `relaxTools`
|
|
90
118
|
* has to apply to the schemas that were just sanitised. It is handed the same `Capabilities`
|
|
91
119
|
* object throughout, and a caller that reads those from its own closure can ignore the argument.
|
|
92
|
-
*
|
|
93
|
-
* @param client The pooled client for this endpoint.
|
|
94
|
-
* @param supports What the endpoint has already refused, threaded through the negotiation.
|
|
95
|
-
* @param request Builds the body. Called again per attempt, since a downgrade changes it. Its
|
|
96
|
-
* second argument is what the model named in `options.model` has refused, absent when none was.
|
|
97
|
-
* @param options Retry budget, context limit, the model to negotiate for, notices, and the
|
|
98
|
-
* stream's own callbacks.
|
|
99
120
|
*/
|
|
100
121
|
export declare function runTurn(client: OpenAI, supports: Capabilities, request: RequestBuilder, { maxRetries, onNotice, contextLimit, model, droppable, loadingTimeoutMs, ...stream }?: RunTurnOptions): Promise<Turn>;
|
package/dist/run-turn.js
CHANGED
|
@@ -1,20 +1,71 @@
|
|
|
1
|
-
import { calibrate, charsPerTokenFor } from
|
|
2
|
-
import { negotiate } from
|
|
3
|
-
import { errorMessage } from
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
1
|
+
import { calibrate, charsPerTokenFor } from './calibration.js';
|
|
2
|
+
import { negotiate } from './capabilities.js';
|
|
3
|
+
import { errorMessage } from './errors.js';
|
|
4
|
+
import { MS_PER_SECOND } from './platform.js';
|
|
5
|
+
import { backoffMs, ContextOverflow, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, SMALLEST_LIKELY_WINDOW, sleep, } from './retry.js';
|
|
6
|
+
import { streamTurn } from './stream.js';
|
|
7
|
+
import { compact, requestTokens } from './tokens.js';
|
|
6
8
|
/**
|
|
9
|
+
* What a notice calls the model, where the caller did not say which one it is.
|
|
10
|
+
*
|
|
11
|
+
* @param model - The model's name. Absent reads as "the model", which is all there is to say.
|
|
12
|
+
* @returns The name as given, an empty one included, or the stand-in.
|
|
13
|
+
*/
|
|
14
|
+
export const modelLabel = (model) => model ?? 'the model';
|
|
15
|
+
/**
|
|
16
|
+
* Throws `ContextOverflow` for a request that will not fit its window, before it is sent.
|
|
17
|
+
*
|
|
18
|
+
* @param body - The request about to go out. Its prompt is estimated, and its reply ceiling read
|
|
19
|
+
* from `max_completion_tokens`, or failing that `max_tokens`.
|
|
20
|
+
* @param contextLimit - The model's window, in tokens. A request of exactly that many is let
|
|
21
|
+
* through.
|
|
22
|
+
* @param charsPerToken - The ratio the prompt is estimated at.
|
|
23
|
+
*
|
|
24
|
+
* @remarks
|
|
25
|
+
* The endpoint refuses on the prompt plus the reply — llama.cpp sizes the slot with `n_predict`
|
|
26
|
+
* in, OpenAI with the ceiling — so a prompt that fits the window but not the window less the
|
|
27
|
+
* ceiling was let through here to be refused one round trip later, which is the trip this guard
|
|
28
|
+
* exists to save. The reserve is read off the body under whichever spelling was chosen, and no
|
|
29
|
+
* ceiling reserves nothing: the server then gives the reply what is left.
|
|
30
|
+
*/
|
|
31
|
+
function refuseOversized(body, contextLimit, charsPerToken) {
|
|
32
|
+
const needed = requestTokens(body, { charsPerToken });
|
|
33
|
+
const reserve = Math.max(0, body.max_completion_tokens ?? body.max_tokens ?? 0);
|
|
34
|
+
if (needed + reserve <= contextLimit) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
// Not retried, and deliberately not a capability: `isTransient` refuses it and none of the
|
|
38
|
+
// words below are ones `negotiate` reads as a refusal it can answer, so this leaves both
|
|
39
|
+
// loops on the first attempt instead of being sent again to be refused again.
|
|
40
|
+
const reserved = reserve ? ` plus ${compact(reserve)} reserved for the reply` : '';
|
|
41
|
+
throw new ContextOverflow(`the request is about ${compact(needed)} tokens${reserved}, over this model's ${compact(contextLimit)}`);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A backoff in whatever unit reads as a number: the first is under a second, and "retrying in
|
|
45
|
+
* 0s" is what rounding it to seconds says.
|
|
46
|
+
*
|
|
47
|
+
* @param ms - The wait, in milliseconds.
|
|
48
|
+
* @returns Whole milliseconds, as `250ms`, under a second, and whole seconds, as `4s`, from there.
|
|
49
|
+
*/
|
|
50
|
+
const shownDelay = (ms) => (ms < MS_PER_SECOND ? `${Math.round(ms)}ms` : `${Math.round(ms / MS_PER_SECOND)}s`);
|
|
51
|
+
/**
|
|
52
|
+
* One turn seen through to an answer: negotiated with the endpoint, retried where the failure is
|
|
53
|
+
* transient, and waited for where the model is still loading.
|
|
54
|
+
*
|
|
55
|
+
* @param client - The pooled client for this endpoint.
|
|
56
|
+
* @param supports - What the endpoint has already refused, threaded through the negotiation.
|
|
57
|
+
* @param request - Builds the body. Called again per attempt, since a downgrade changes it. Its
|
|
58
|
+
* second argument is what the model named in `options.model` has refused, absent when none was.
|
|
59
|
+
* @param [options] - Retry budget, context limit, the model to negotiate for, notices, and the
|
|
60
|
+
* stream's own callbacks.
|
|
61
|
+
* @returns The turn the attempt that got through produced, its usage carrying what every attempt
|
|
62
|
+
* together cost: `wallMs`, `retries` and `timeouts`.
|
|
63
|
+
*
|
|
64
|
+
* @remarks
|
|
7
65
|
* `request` is a callback rather than a body because the body has to be rebuilt from whatever
|
|
8
66
|
* the last attempt latched off: the tools it sends depend on `strictSchemas`, and `relaxTools`
|
|
9
67
|
* has to apply to the schemas that were just sanitised. It is handed the same `Capabilities`
|
|
10
68
|
* object throughout, and a caller that reads those from its own closure can ignore the argument.
|
|
11
|
-
*
|
|
12
|
-
* @param client The pooled client for this endpoint.
|
|
13
|
-
* @param supports What the endpoint has already refused, threaded through the negotiation.
|
|
14
|
-
* @param request Builds the body. Called again per attempt, since a downgrade changes it. Its
|
|
15
|
-
* second argument is what the model named in `options.model` has refused, absent when none was.
|
|
16
|
-
* @param options Retry budget, context limit, the model to negotiate for, notices, and the
|
|
17
|
-
* stream's own callbacks.
|
|
18
69
|
*/
|
|
19
70
|
export async function runTurn(client, supports, request, { maxRetries = 0, onNotice, contextLimit = 0, model, droppable, loadingTimeoutMs = LOADING_TIMEOUT_MS, ...stream } = {}) {
|
|
20
71
|
// Sized once rather than per build. `request` is called again for every downgrade and every
|
|
@@ -29,22 +80,7 @@ export async function runTurn(client, supports, request, { maxRetries = 0, onNot
|
|
|
29
80
|
sent = body;
|
|
30
81
|
if (!sized && contextLimit >= SMALLEST_LIKELY_WINDOW) {
|
|
31
82
|
sized = true;
|
|
32
|
-
|
|
33
|
-
charsPerToken: charsPerTokenFor(supports, body.model),
|
|
34
|
-
});
|
|
35
|
-
// The endpoint refuses on the prompt plus the reply — llama.cpp sizes the slot with
|
|
36
|
-
// `n_predict` in, OpenAI with the ceiling — so a prompt that fits the window but not the
|
|
37
|
-
// window less the ceiling was let through here to be refused one round trip later, which
|
|
38
|
-
// is the trip this guard exists to save. Read off the body under whichever spelling was
|
|
39
|
-
// chosen. No ceiling reserves nothing: the server then gives the reply what is left.
|
|
40
|
-
const reserve = Math.max(0, body.max_completion_tokens ?? body.max_tokens ?? 0);
|
|
41
|
-
// Not retried, and deliberately not a capability: `isTransient` refuses it and none of the
|
|
42
|
-
// words below are ones `negotiate` reads as a refusal it can answer, so this leaves both
|
|
43
|
-
// loops on the first attempt instead of being sent again to be refused again.
|
|
44
|
-
if (needed + reserve > contextLimit) {
|
|
45
|
-
const reserved = reserve ? ` plus ${compact(reserve)} reserved for the reply` : "";
|
|
46
|
-
throw new ContextOverflow(`the request is about ${compact(needed)} tokens${reserved}, over this model's ${compact(contextLimit)}`);
|
|
47
|
-
}
|
|
83
|
+
refuseOversized(body, contextLimit, charsPerTokenFor(supports, body.model));
|
|
48
84
|
}
|
|
49
85
|
return body;
|
|
50
86
|
};
|
|
@@ -58,8 +94,9 @@ export async function runTurn(client, supports, request, { maxRetries = 0, onNot
|
|
|
58
94
|
const produced = { any: false };
|
|
59
95
|
try {
|
|
60
96
|
const turn = await negotiate(supports, (capabilities, box, forModel) => streamTurn(client, measured(capabilities, forModel), { ...stream, produced: box }), { produced, onNotice, model, droppable });
|
|
61
|
-
if (sent)
|
|
97
|
+
if (sent) {
|
|
62
98
|
calibrate(supports, sent, turn.usage.prompt);
|
|
99
|
+
}
|
|
63
100
|
Object.assign(turn.usage, { wallMs: Date.now() - started, retries, timeouts });
|
|
64
101
|
return turn;
|
|
65
102
|
}
|
|
@@ -67,8 +104,9 @@ export async function runTurn(client, supports, request, { maxRetries = 0, onNot
|
|
|
67
104
|
// The abort is read before the classification, not after. A run stopped by its operator
|
|
68
105
|
// can trip the idle watchdog on the way out, and `EndpointSilent` is transient by the
|
|
69
106
|
// rules in `retry.ts` — so classifying first brings a cancelled run back from the dead.
|
|
70
|
-
if (produced.any || stream.signal?.aborted)
|
|
107
|
+
if (produced.any || stream.signal?.aborted) {
|
|
71
108
|
throw error;
|
|
109
|
+
}
|
|
72
110
|
// The endpoint's own refusal, classified here rather than left to the caller. One failure
|
|
73
111
|
// had two error types depending on an option about something else: a caller that gave a
|
|
74
112
|
// `contextLimit` got `ContextOverflow` from the guard above, and one that did not — the
|
|
@@ -86,7 +124,7 @@ export async function runTurn(client, supports, request, { maxRetries = 0, onNot
|
|
|
86
124
|
const now = Date.now();
|
|
87
125
|
if (loadingSince === undefined) {
|
|
88
126
|
loadingSince = now;
|
|
89
|
-
onNotice?.(`${model
|
|
127
|
+
onNotice?.(`${modelLabel(model)} is still loading — waiting up to ${compact(loadingTimeoutMs / MS_PER_SECOND)}s`);
|
|
90
128
|
}
|
|
91
129
|
if (now - loadingSince < loadingTimeoutMs) {
|
|
92
130
|
// Not an attempt: the request was never looked at, and a two-minute load would
|
|
@@ -96,16 +134,15 @@ export async function runTurn(client, supports, request, { maxRetries = 0, onNot
|
|
|
96
134
|
continue;
|
|
97
135
|
}
|
|
98
136
|
}
|
|
99
|
-
if (attempt >= maxRetries || !isTransient(error))
|
|
137
|
+
if (attempt >= maxRetries || !isTransient(error)) {
|
|
100
138
|
throw error;
|
|
139
|
+
}
|
|
101
140
|
retries++;
|
|
102
|
-
if (error instanceof EndpointSilent)
|
|
141
|
+
if (error instanceof EndpointSilent) {
|
|
103
142
|
timeouts++;
|
|
143
|
+
}
|
|
104
144
|
const wait = backoffMs(attempt);
|
|
105
|
-
|
|
106
|
-
// "retrying in 0s" is what rounding it to seconds says.
|
|
107
|
-
const delay = wait < 1000 ? `${Math.round(wait)}ms` : `${Math.round(wait / 1000)}s`;
|
|
108
|
-
onNotice?.(`${errorMessage(error)} — retrying in ${delay} (${attempt + 1}/${maxRetries})`);
|
|
145
|
+
onNotice?.(`${errorMessage(error)} — retrying in ${shownDelay(wait)} (${attempt + 1}/${maxRetries})`);
|
|
109
146
|
await sleep(wait, stream.signal);
|
|
110
147
|
}
|
|
111
148
|
}
|