@cubicecho/agent-core 2.24.0 → 2.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +10 -7
  2. package/dist/agent-loop.d.ts +84 -205
  3. package/dist/agent-loop.js +288 -597
  4. package/dist/calibration.d.ts +14 -10
  5. package/dist/calibration.js +30 -23
  6. package/dist/capabilities.d.ts +111 -42
  7. package/dist/capabilities.js +281 -138
  8. package/dist/client.d.ts +104 -35
  9. package/dist/client.js +205 -74
  10. package/dist/compaction.d.ts +103 -60
  11. package/dist/compaction.js +160 -86
  12. package/dist/config.d.ts +48 -12
  13. package/dist/config.js +20 -1
  14. package/dist/continuation.d.ts +24 -16
  15. package/dist/continuation.js +70 -44
  16. package/dist/digest.d.ts +7 -0
  17. package/dist/digest.js +18 -0
  18. package/dist/errors.d.ts +24 -15
  19. package/dist/errors.js +23 -15
  20. package/dist/events.d.ts +100 -63
  21. package/dist/events.js +250 -120
  22. package/dist/guards.d.ts +35 -4
  23. package/dist/guards.js +38 -5
  24. package/dist/hook-events.d.ts +21 -5
  25. package/dist/hook-events.js +23 -9
  26. package/dist/hooks.d.ts +96 -73
  27. package/dist/hooks.js +202 -116
  28. package/dist/index.d.ts +30 -26
  29. package/dist/index.js +29 -24
  30. package/dist/ledger.d.ts +62 -30
  31. package/dist/ledger.js +163 -56
  32. package/dist/platform.d.ts +13 -0
  33. package/dist/platform.js +13 -0
  34. package/dist/preselect.d.ts +222 -0
  35. package/dist/preselect.js +290 -0
  36. package/dist/request-body.d.ts +32 -0
  37. package/dist/request-body.js +47 -0
  38. package/dist/reset.d.ts +1 -0
  39. package/dist/reset.js +7 -6
  40. package/dist/retry.d.ts +29 -138
  41. package/dist/retry.js +69 -282
  42. package/dist/run-calls.d.ts +123 -0
  43. package/dist/run-calls.js +351 -0
  44. package/dist/run-turn.d.ts +36 -15
  45. package/dist/run-turn.js +74 -37
  46. package/dist/runtime.d.ts +23 -17
  47. package/dist/runtime.js +34 -24
  48. package/dist/schema-compat.d.ts +35 -18
  49. package/dist/schema-compat.js +340 -175
  50. package/dist/scope.d.ts +28 -5
  51. package/dist/scope.js +37 -10
  52. package/dist/side-task.d.ts +123 -40
  53. package/dist/side-task.js +181 -63
  54. package/dist/snapshot.d.ts +10 -5
  55. package/dist/snapshot.js +73 -30
  56. package/dist/spec.d.ts +56 -25
  57. package/dist/spec.js +494 -215
  58. package/dist/stream.d.ts +66 -16
  59. package/dist/stream.js +220 -97
  60. package/dist/thinking.d.ts +23 -8
  61. package/dist/thinking.js +92 -44
  62. package/dist/tokens.d.ts +164 -6
  63. package/dist/tokens.js +319 -6
  64. package/dist/tool-calls.d.ts +82 -17
  65. package/dist/tool-calls.js +198 -83
  66. package/dist/tool-loading.d.ts +101 -189
  67. package/dist/tool-loading.js +277 -371
  68. package/dist/wire.d.ts +81 -0
  69. package/dist/wire.js +73 -0
  70. package/llms.txt +76 -39
  71. package/package.json +4 -4
@@ -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
+ }
@@ -1,6 +1,6 @@
1
- import type OpenAI from "openai";
2
- import { type Capabilities, type ModelCapabilities } from "./capabilities.ts";
3
- import { type StreamTurnOptions, type Turn } from "./stream.ts";
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, "produced"> {
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?: (message: string) => void;
42
+ onNotice?: OnNotice;
43
43
  /**
44
- * What the model will read, in tokens. Zero — the default — sends whatever it is given.
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, `LOADING_TIMEOUT_MS`
72
- * unless given; zero gives up on the first such answer like any other 503.
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 "./calibration.js";
2
- import { negotiate } from "./capabilities.js";
3
- import { errorMessage } from "./errors.js";
4
- import { backoffMs, ContextOverflow, compact, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
5
- import { streamTurn } from "./stream.js";
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
- const needed = requestTokens(body, {
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 ?? "the model"} is still loading — waiting up to ${compact(loadingTimeoutMs / 1000)}s`);
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
- // Reported in whatever unit reads as a number: the first backoff is under a second, and
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
  }