@cubicecho/agent-core 2.25.0 → 2.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -204,7 +204,7 @@ function ownSummariser(options, notice) {
204
204
  const call = taskCall(compactor, { signal, onNotice: notice }, config);
205
205
  const write = summariser(call.endpoint, call.model, call.options);
206
206
  // A summary that fails folds nothing, and the step goes out as it was.
207
- return async (text) => (await tryAsk('compaction', () => write(text), { onNotice: notice })) ?? '';
207
+ return async (text) => (await tryAsk('compaction', () => write(text), { onNotice: notice, signal })) ?? '';
208
208
  }
209
209
  /**
210
210
  * A step's transcript after the loop's own compaction: folded where a fold was due and its summary
package/dist/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import OpenAI from 'openai';
2
2
  import { digestOf } from './digest.js';
3
- import { isPositive } from './guards.js';
3
+ import { byCodeUnit, isPositive } from './guards.js';
4
4
  import { MS_PER_SECOND } from './platform.js';
5
5
  import { assignSettings, scoped } from './scope.js';
6
6
  import { HttpStatus } from './wire.js';
@@ -466,7 +466,7 @@ export async function listModels(config) {
466
466
  }
467
467
  models.push({ id: model.id, contextLength: window.contextLength });
468
468
  }
469
- models.sort((a, b) => a.id.localeCompare(b.id));
469
+ models.sort((a, b) => byCodeUnit(a.id, b.id));
470
470
  const key = endpointKey(config);
471
471
  listings().set(key, models);
472
472
  trainedOnly().set(key, trained);
package/dist/guards.d.ts CHANGED
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * A leaf with no imports, because the modules that need these — a browser-safe `spec`, the schema
5
5
  * walk, the tool-call reader, the snapshot importer — otherwise share nothing, and each had grown
6
- * its own copy. `getOrCreate` and `counted` are here for the same reason and are the two things that
7
- * are not checks: every cache in the package fills itself on a miss, and each had written that
8
- * out, and a notice that counts something is written in more than one module.
6
+ * its own copy. `getOrCreate`, `counted` and `byCodeUnit` are here for the same reason and are the
7
+ * three things that are not checks: every cache in the package fills itself on a miss, and each had
8
+ * written that out; a notice that counts something is written in more than one module; and so is a
9
+ * sort whose order must not depend on the host.
9
10
  */
10
11
  /**
11
12
  * Whether a value is a plain keyed object: not null, and not an array.
@@ -50,4 +51,17 @@ export declare function getOrCreate<K, V>(map: Lookup<K, V>, key: K, create: ()
50
51
  * @returns The phrase. Only a count of exactly one is singular: zero reads `0 tools`.
51
52
  */
52
53
  export declare const counted: (count: number, noun: string) => string;
54
+ /**
55
+ * Orders two strings by code unit, which is the same on every host.
56
+ *
57
+ * @param a - The string on the left.
58
+ * @param b - The string on the right.
59
+ * @returns Negative where `a` sorts first, positive where `b` does, zero where they are the same.
60
+ *
61
+ * @remarks
62
+ * Rather than `localeCompare`, whose answer depends on the host's locale. An order the package
63
+ * relies on — the tool array a prompt cache matches, a tie between two ranked tools, a listing a
64
+ * host may key on — has to come out the same wherever it runs.
65
+ */
66
+ export declare function byCodeUnit(a: string, b: string): number;
53
67
  export {};
package/dist/guards.js CHANGED
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * A leaf with no imports, because the modules that need these — a browser-safe `spec`, the schema
5
5
  * walk, the tool-call reader, the snapshot importer — otherwise share nothing, and each had grown
6
- * its own copy. `getOrCreate` and `counted` are here for the same reason and are the two things that
7
- * are not checks: every cache in the package fills itself on a miss, and each had written that
8
- * out, and a notice that counts something is written in more than one module.
6
+ * its own copy. `getOrCreate`, `counted` and `byCodeUnit` are here for the same reason and are the
7
+ * three things that are not checks: every cache in the package fills itself on a miss, and each had
8
+ * written that out; a notice that counts something is written in more than one module; and so is a
9
+ * sort whose order must not depend on the host.
9
10
  */
10
11
  /**
11
12
  * Whether a value is a plain keyed object: not null, and not an array.
@@ -53,3 +54,24 @@ export function getOrCreate(map, key, create) {
53
54
  * @returns The phrase. Only a count of exactly one is singular: zero reads `0 tools`.
54
55
  */
55
56
  export const counted = (count, noun) => `${count} ${noun}${count === 1 ? '' : 's'}`;
57
+ /**
58
+ * Orders two strings by code unit, which is the same on every host.
59
+ *
60
+ * @param a - The string on the left.
61
+ * @param b - The string on the right.
62
+ * @returns Negative where `a` sorts first, positive where `b` does, zero where they are the same.
63
+ *
64
+ * @remarks
65
+ * Rather than `localeCompare`, whose answer depends on the host's locale. An order the package
66
+ * relies on — the tool array a prompt cache matches, a tie between two ranked tools, a listing a
67
+ * host may key on — has to come out the same wherever it runs.
68
+ */
69
+ export function byCodeUnit(a, b) {
70
+ if (a < b) {
71
+ return -1;
72
+ }
73
+ if (a > b) {
74
+ return 1;
75
+ }
76
+ return 0;
77
+ }
@@ -11,3 +11,5 @@ export declare const MS_PER_SECOND = 1000;
11
11
  export declare const MS_PER_MINUTE: number;
12
12
  /** The event an `AbortSignal` fires when it aborts. */
13
13
  export declare const ABORT_EVENT = "abort";
14
+ /** The name an error carries when it is what an aborted `AbortSignal` gave as its reason. */
15
+ export declare const ABORT_ERROR = "AbortError";
package/dist/platform.js CHANGED
@@ -11,3 +11,5 @@ export const MS_PER_SECOND = 1000;
11
11
  export const MS_PER_MINUTE = 60 * MS_PER_SECOND;
12
12
  /** The event an `AbortSignal` fires when it aborts. */
13
13
  export const ABORT_EVENT = 'abort';
14
+ /** The name an error carries when it is what an aborted `AbortSignal` gave as its reason. */
15
+ export const ABORT_ERROR = 'AbortError';
@@ -189,7 +189,8 @@ export interface PreselectOptions {
189
189
  maxPerLoad?: number;
190
190
  /**
191
191
  * Try `preselectByKeywords` first and spend the model only on what it cannot settle. `true`
192
- * takes its defaults; an object tunes the thresholds. An empty `model` still means no
192
+ * takes its defaults; an object tunes the thresholds. Its own `maxPerLoad` can ask the words
193
+ * for fewer than `maxPerLoad` above, never for more. An empty `model` still means no
193
194
  * preselection at all, words included — that is what `toolSelectModel: ""` asks for.
194
195
  */
195
196
  keywords?: boolean | KeywordPreselectOptions;
@@ -204,8 +205,8 @@ export interface PreselectOptions {
204
205
  * @param prompt - The request being planned for. Only its head is read; see `preselectInput`.
205
206
  * @param [options] - Cancellation, notices, the reply ceiling, the temperature and reasoning effort
206
207
  * as `ask` reads them, the cap the choice is held to, and whether to try the words first.
207
- * @returns Names as the catalogue spells them. Empty where `model` is empty, the catalogue has no
208
- * tools, the call failed, or its reply named nothing in the catalogue.
208
+ * @returns Names as the catalogue spells them, never more than `maxPerLoad`. Empty where `model` is
209
+ * empty, the catalogue has no tools, the call failed, or its reply named nothing in the catalogue.
209
210
  *
210
211
  * @remarks
211
212
  * On-demand loading otherwise spends a round trip on reading the catalogue and calling
package/dist/preselect.js CHANGED
@@ -1,4 +1,4 @@
1
- import { counted, isRecord } from './guards.js';
1
+ import { byCodeUnit, counted, isRecord } from './guards.js';
2
2
  import { askJson, tryAsk } from './side-task.js';
3
3
  import { catalogList, expandNames, MAX_PER_LOAD } from './tool-loading.js';
4
4
  import { SchemaType } from './wire.js';
@@ -230,7 +230,7 @@ export function preselectByKeywords(catalog, prompt, { maxPerLoad = MAX_PER_LOAD
230
230
  }
231
231
  // Ties break on the name, not on where the tool sat in the catalogue, so reconnecting a
232
232
  // server in a different order does not change what a run opens with.
233
- ranked.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
233
+ ranked.sort((a, b) => b.score - a.score || byCodeUnit(a.name, b.name));
234
234
  if (!ranked.length) {
235
235
  return empty;
236
236
  }
@@ -249,8 +249,8 @@ export function preselectByKeywords(catalog, prompt, { maxPerLoad = MAX_PER_LOAD
249
249
  * @param prompt - The request being planned for. Only its head is read; see `preselectInput`.
250
250
  * @param [options] - Cancellation, notices, the reply ceiling, the temperature and reasoning effort
251
251
  * as `ask` reads them, the cap the choice is held to, and whether to try the words first.
252
- * @returns Names as the catalogue spells them. Empty where `model` is empty, the catalogue has no
253
- * tools, the call failed, or its reply named nothing in the catalogue.
252
+ * @returns Names as the catalogue spells them, never more than `maxPerLoad`. Empty where `model` is
253
+ * empty, the catalogue has no tools, the call failed, or its reply named nothing in the catalogue.
254
254
  *
255
255
  * @remarks
256
256
  * On-demand loading otherwise spends a round trip on reading the catalogue and calling
@@ -269,9 +269,10 @@ export async function preselect(config, model, catalog, prompt, { signal, onNoti
269
269
  return [];
270
270
  }
271
271
  if (keywords) {
272
+ const tuned = keywords === true ? {} : keywords;
272
273
  const guess = preselectByKeywords(catalog, prompt, {
273
- maxPerLoad,
274
- ...(keywords === true ? {} : keywords),
274
+ ...tuned,
275
+ maxPerLoad: Math.min(maxPerLoad, tuned.maxPerLoad ?? maxPerLoad),
275
276
  });
276
277
  if (guess.confident) {
277
278
  onNotice?.(`chose ${counted(guess.names.length, 'tool')} by name`);
@@ -285,6 +286,6 @@ export async function preselect(config, model, catalog, prompt, { signal, onNoti
285
286
  reasoningEffort,
286
287
  signal,
287
288
  onNotice,
288
- }), { onNotice });
289
+ }), { onNotice, signal });
289
290
  return preselection(reply, catalog, maxPerLoad);
290
291
  }
package/dist/retry.d.ts CHANGED
@@ -82,6 +82,21 @@ export declare function isTransient(error: unknown): boolean;
82
82
  * latches off whatever the re-send left out.
83
83
  */
84
84
  export declare const refusesRequest: (error: unknown) => boolean;
85
+ /**
86
+ * Whether a failure is the caller cancelling, rather than anything going wrong.
87
+ *
88
+ * @param error - The rejection, as caught.
89
+ * @returns `true` for the SDK's `APIUserAbortError` and for any error named `AbortError`, which is
90
+ * what an aborted signal gives as its reason; `false` for everything else, a timeout included.
91
+ *
92
+ * @remarks
93
+ * An abort reaches a catch in two shapes. The SDK raises its own class when a request in flight is
94
+ * cancelled, and `sleep` rejects with the signal's reason when the cancel lands between attempts.
95
+ * A catch that knew only the first reported the second as a failure. A signal aborted with a
96
+ * reason of the caller's own making is not recognised, since only the error is looked at: a
97
+ * catch that holds the signal asks it as well, as `tryAsk` does.
98
+ */
99
+ export declare const isAbort: (error: unknown) => boolean;
85
100
  /**
86
101
  * Whether a failure is a local server still loading the model, rather than one failing to serve.
87
102
  *
package/dist/retry.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import OpenAI from 'openai';
2
- import { ABORT_EVENT } from './platform.js';
2
+ import { ABORT_ERROR, ABORT_EVENT } from './platform.js';
3
3
  import { HttpStatus } from './wire.js';
4
4
  /**
5
5
  * Everything about a request failing that is not about what the request said.
@@ -126,6 +126,21 @@ export function isTransient(error) {
126
126
  */
127
127
  export const refusesRequest = (error) => error instanceof OpenAI.APIError &&
128
128
  (error.status === HttpStatus.BadRequest || error.status === HttpStatus.UnprocessableEntity);
129
+ /**
130
+ * Whether a failure is the caller cancelling, rather than anything going wrong.
131
+ *
132
+ * @param error - The rejection, as caught.
133
+ * @returns `true` for the SDK's `APIUserAbortError` and for any error named `AbortError`, which is
134
+ * what an aborted signal gives as its reason; `false` for everything else, a timeout included.
135
+ *
136
+ * @remarks
137
+ * An abort reaches a catch in two shapes. The SDK raises its own class when a request in flight is
138
+ * cancelled, and `sleep` rejects with the signal's reason when the cancel lands between attempts.
139
+ * A catch that knew only the first reported the second as a failure. A signal aborted with a
140
+ * reason of the caller's own making is not recognised, since only the error is looked at: a
141
+ * catch that holds the signal asks it as well, as `tryAsk` does.
142
+ */
143
+ export const isAbort = (error) => error instanceof OpenAI.APIUserAbortError || (error instanceof Error && error.name === ABORT_ERROR);
129
144
  /** The error type llama.cpp gives a request it cannot serve yet. */
130
145
  const UNAVAILABLE_ERROR = 'unavailable_error';
131
146
  /**
@@ -381,7 +381,13 @@ function collectRefs(node, into) {
381
381
  * outside it still refers in.
382
382
  */
383
383
  function pruneDefs(schema) {
384
- const pools = POOL_KEYS.filter((key) => isRecord(schema[key]));
384
+ const pools = [];
385
+ for (const key of POOL_KEYS) {
386
+ const pool = schema[key];
387
+ if (isRecord(pool)) {
388
+ pools.push([key, pool]);
389
+ }
390
+ }
385
391
  if (!pools.length) {
386
392
  return schema;
387
393
  }
@@ -411,19 +417,18 @@ function pruneDefs(schema) {
411
417
  // The pools themselves are not roots: a definition is reached from the schema body, or by
412
418
  // another definition that was, or not at all.
413
419
  const body = { ...schema };
414
- for (const key of pools) {
420
+ for (const [key] of pools) {
415
421
  delete body[key];
416
422
  }
417
423
  visit(body);
418
424
  let pruned = schema;
419
- for (const key of pools) {
425
+ for (const [key, pool] of pools) {
420
426
  const names = live[key];
421
427
  if (!names?.size) {
422
428
  const { [key]: _unreached, ...rest } = pruned;
423
429
  pruned = rest;
424
430
  continue;
425
431
  }
426
- const pool = schema[key];
427
432
  if (names.size === Object.keys(pool).length) {
428
433
  continue;
429
434
  }
@@ -1,4 +1,4 @@
1
- import OpenAI from 'openai';
1
+ import type OpenAI from 'openai';
2
2
  import { type OnNotice } from './capabilities.ts';
3
3
  import type { Endpoint, EndpointIdentity } from './config.ts';
4
4
  /**
@@ -154,15 +154,16 @@ export declare function askJson<T>(config: Endpoint, model: string, system: stri
154
154
  *
155
155
  * @param label - Names the task in the notice when it fails.
156
156
  * @param run - The call to attempt. Anything it throws becomes `undefined`, an abort excepted.
157
- * @param [options] - `onNotice`, told what was given up on.
158
- * @returns What `run` resolved with, or `undefined` when it threw. An `APIUserAbortError` is thrown
159
- * on and raises no notice.
157
+ * @param [options] - `onNotice`, told what was given up on, and the `signal` that `run` is
158
+ * cancelled by.
159
+ * @returns What `run` resolved with, or `undefined` when it threw. Whatever it threw once the
160
+ * signal had aborted is thrown on and raises no notice, as is an error that is itself an abort.
160
161
  *
161
162
  * @remarks
162
163
  * A side task is never worth failing the work it supports. Callers that can carry on without
163
164
  * an answer use this and get `undefined` instead of an exception.
164
165
  */
165
- export declare function tryAsk<T>(label: string, run: () => Promise<T>, { onNotice }?: Pick<SideTaskOptions, 'onNotice'>): Promise<T | undefined>;
166
+ export declare function tryAsk<T>(label: string, run: () => Promise<T>, { onNotice, signal }?: Pick<SideTaskOptions, 'onNotice' | 'signal'>): Promise<T | undefined>;
166
167
  /**
167
168
  * The first array or object in a model's reply, parsed, whatever prose or fence surrounds it.
168
169
  *
package/dist/side-task.js CHANGED
@@ -1,12 +1,11 @@
1
- import OpenAI from 'openai';
2
1
  import { capabilitiesFor, ceilingAndTemperature, EFFORT_NONE, effortFor, modelCapabilitiesFor, negotiate, } from './capabilities.js';
3
2
  import { endpointId, getClient, modelKey, resolveApiKey, sameUrl } from './client.js';
4
3
  import { errorMessage } from './errors.js';
5
- import { refusesRequest } from './retry.js';
4
+ import { isAbort, refusesRequest } from './retry.js';
6
5
  import { relaxSchema, sanitizeSchema } from './schema-compat.js';
7
6
  import { scoped } from './scope.js';
8
7
  import { stripThinking } from './thinking.js';
9
- import { looseJson } from './tool-calls.js';
8
+ import { CODE_FENCE, looseJson } from './tool-calls.js';
10
9
  import { JSON_SCHEMA_FORMAT, Role } from './wire.js';
11
10
  /**
12
11
  * One-shot calls that support a run without being one: picking tools, naming a session,
@@ -234,22 +233,25 @@ export async function askJson(config, model, system, user, schema, { name = 'ans
234
233
  *
235
234
  * @param label - Names the task in the notice when it fails.
236
235
  * @param run - The call to attempt. Anything it throws becomes `undefined`, an abort excepted.
237
- * @param [options] - `onNotice`, told what was given up on.
238
- * @returns What `run` resolved with, or `undefined` when it threw. An `APIUserAbortError` is thrown
239
- * on and raises no notice.
236
+ * @param [options] - `onNotice`, told what was given up on, and the `signal` that `run` is
237
+ * cancelled by.
238
+ * @returns What `run` resolved with, or `undefined` when it threw. Whatever it threw once the
239
+ * signal had aborted is thrown on and raises no notice, as is an error that is itself an abort.
240
240
  *
241
241
  * @remarks
242
242
  * A side task is never worth failing the work it supports. Callers that can carry on without
243
243
  * an answer use this and get `undefined` instead of an exception.
244
244
  */
245
- export async function tryAsk(label, run, { onNotice } = {}) {
245
+ export async function tryAsk(label, run, { onNotice, signal } = {}) {
246
246
  try {
247
247
  return await run();
248
248
  }
249
249
  catch (error) {
250
250
  // A cancelled run is not a failed side task. Swallowing the abort made the two
251
- // indistinguishable and left the cancellation with nowhere to go.
252
- if (error instanceof OpenAI.APIUserAbortError) {
251
+ // indistinguishable and left the cancellation with nowhere to go. The signal is asked first:
252
+ // an abort with a reason of the caller's own rejects with that reason, which no class names.
253
+ const wasCancelled = signal?.aborted === true || isAbort(error);
254
+ if (wasCancelled) {
253
255
  throw error;
254
256
  }
255
257
  onNotice?.(`${label}: ${errorMessage(error)}`);
@@ -274,7 +276,7 @@ export async function tryAsk(label, run, { onNotice } = {}) {
274
276
  * a reply as in a call, and a side task that fails over one costs the run a second request.
275
277
  */
276
278
  export function parseJson(text) {
277
- const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
279
+ const [fenced] = text.matchAll(CODE_FENCE);
278
280
  const body = (fenced?.[1] ?? text).trim();
279
281
  const start = body.search(/[[{]/);
280
282
  if (start < 0) {
@@ -14,6 +14,14 @@ export declare const ARGUMENT_PREVIEW_CHARS = 200;
14
14
  export declare const ARGUMENTS_TRUNCATED: 'truncated';
15
15
  /** The `ToolArgumentsError` kind for a call the model wrote wrongly. */
16
16
  export declare const ARGUMENTS_MALFORMED: 'malformed';
17
+ /**
18
+ * A fenced block in a model's reply, tagged `json` or not tagged; the first group is what it holds.
19
+ *
20
+ * @remarks
21
+ * Global, for `matchAll`, which reads from a copy: nothing here keeps a place in the text between
22
+ * two uses. What it captures still wants trimming.
23
+ */
24
+ export declare const CODE_FENCE: RegExp;
17
25
  /** A tool call as the loop handles it, recovered or streamed. */
18
26
  export type ToolCall = OpenAI.ChatCompletionMessageFunctionToolCall;
19
27
  /**
@@ -16,6 +16,14 @@ export const ARGUMENT_PREVIEW_CHARS = 200;
16
16
  export const ARGUMENTS_TRUNCATED = 'truncated';
17
17
  /** The `ToolArgumentsError` kind for a call the model wrote wrongly. */
18
18
  export const ARGUMENTS_MALFORMED = 'malformed';
19
+ /**
20
+ * A fenced block in a model's reply, tagged `json` or not tagged; the first group is what it holds.
21
+ *
22
+ * @remarks
23
+ * Global, for `matchAll`, which reads from a copy: nothing here keeps a place in the text between
24
+ * two uses. What it captures still wants trimming.
25
+ */
26
+ export const CODE_FENCE = /```(?:json)?\s*([\s\S]*?)```/gi;
19
27
  /**
20
28
  * Tool arguments that could not be read as an object, with why.
21
29
  *
@@ -405,7 +413,7 @@ function bareCalls(text, names) {
405
413
  return [{ start, end: read.end, calls }];
406
414
  }
407
415
  }
408
- const fences = [...text.matchAll(/```(?:json)?[ \t]*\n?([\s\S]*?)```/gi)];
416
+ const fences = [...text.matchAll(CODE_FENCE)];
409
417
  if (fences.length !== 1) {
410
418
  return [];
411
419
  }
@@ -1,4 +1,4 @@
1
- import { isRecord } from './guards.js';
1
+ import { byCodeUnit, isRecord } from './guards.js';
2
2
  import { ARGUMENT_PREVIEW_CHARS } from './tool-calls.js';
3
3
  import { FUNCTION_TOOL, SchemaType } from './wire.js';
4
4
  /**
@@ -170,26 +170,6 @@ export function loadedTools(previous, matched) {
170
170
  }
171
171
  return tools;
172
172
  }
173
- /**
174
- * Orders two names by code unit.
175
- *
176
- * @param a - The name on the left.
177
- * @param b - The name on the right.
178
- * @returns Negative where `a` sorts first, positive where `b` does, zero where they are the same.
179
- *
180
- * @remarks
181
- * Rather than `localeCompare`, whose answer depends on the host's locale — which is the kind of
182
- * instability `orderTools` exists to remove.
183
- */
184
- function byCodeUnit(a, b) {
185
- if (a < b) {
186
- return -1;
187
- }
188
- if (a > b) {
189
- return 1;
190
- }
191
- return 0;
192
- }
193
173
  /**
194
174
  * The tool array in a stable order, so the same set of tools renders the same way twice.
195
175
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "2.25.0",
3
+ "version": "2.25.1",
4
4
  "description": "The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.",
5
5
  "keywords": [
6
6
  "openai",