@cubicecho/agent-core 2.24.0 → 2.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +49 -4
  23. package/dist/guards.js +60 -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 +15 -0
  33. package/dist/platform.js +15 -0
  34. package/dist/preselect.d.ts +223 -0
  35. package/dist/preselect.js +291 -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 +44 -138
  41. package/dist/retry.js +83 -281
  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 +346 -176
  50. package/dist/scope.d.ts +28 -5
  51. package/dist/scope.js +37 -10
  52. package/dist/side-task.d.ts +124 -40
  53. package/dist/side-task.js +186 -66
  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 +90 -17
  65. package/dist/tool-calls.js +207 -84
  66. package/dist/tool-loading.d.ts +101 -189
  67. package/dist/tool-loading.js +257 -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
package/dist/side-task.js CHANGED
@@ -1,12 +1,12 @@
1
- import OpenAI from "openai";
2
- import { capabilitiesFor, ceilingAndTemperature, effortFor, modelCapabilitiesFor, negotiate, } from "./capabilities.js";
3
- import { endpointId, getClient } from "./client.js";
4
- import { errorMessage } from "./errors.js";
5
- import { refusesRequest } from "./retry.js";
6
- import { relaxSchema, sanitizeSchema } from "./schema-compat.js";
7
- import { scoped } from "./scope.js";
8
- import { stripThinking } from "./thinking.js";
9
- import { looseJson } from "./tool-calls.js";
1
+ import { capabilitiesFor, ceilingAndTemperature, EFFORT_NONE, effortFor, modelCapabilitiesFor, negotiate, } from './capabilities.js';
2
+ import { endpointId, getClient, modelKey, resolveApiKey, sameUrl } from './client.js';
3
+ import { errorMessage } from './errors.js';
4
+ import { isAbort, refusesRequest } from './retry.js';
5
+ import { relaxSchema, sanitizeSchema } from './schema-compat.js';
6
+ import { scoped } from './scope.js';
7
+ import { stripThinking } from './thinking.js';
8
+ import { CODE_FENCE, looseJson } from './tool-calls.js';
9
+ import { JSON_SCHEMA_FORMAT, Role } from './wire.js';
10
10
  /**
11
11
  * One-shot calls that support a run without being one: picking tools, naming a session,
12
12
  * summarising a transcript, proposing follow-ups. They share a shape — small prompt, short
@@ -19,6 +19,7 @@ import { looseJson } from "./tool-calls.js";
19
19
  * disagree about which they take, so send both. One that rejects the unknown fields gets a
20
20
  * single retry without them, and is not offered them again.
21
21
  *
22
+ * @remarks
22
23
  * Only the second half is latched here. `reasoning_effort` is a field `negotiate` already knows
23
24
  * how to be refused, so it is sent under `ModelCapabilities.reasoningEffort` instead — which
24
25
  * both narrows the fallback below to the field it is really about, and shares the answer with
@@ -28,6 +29,7 @@ const NO_THINKING = { chat_template_kwargs: { enable_thinking: false } };
28
29
  /**
29
30
  * The models that turned out not to take the hints, by endpoint and model.
30
31
  *
32
+ * @remarks
31
33
  * Keyed rather than global for the reason the client cache is keyed, and on the same
32
34
  * endpoint it is, by `endpointId`: a refusal is a fact about what is on the other end, not about this
33
35
  * process. A llama.cpp box and a cloud API are both reachable from one consumer over its
@@ -39,23 +41,36 @@ const NO_THINKING = { chat_template_kwargs: { enable_thinking: false } };
39
41
  * Keyed on the host alone, the first model to refuse spoke for every model on it.
40
42
  *
41
43
  * Only the `chat_template_kwargs` half is here. `reasoning_effort` is `negotiate`'s to latch, on
42
- * the same (endpoint, model) pair, where a run on that model can read it too.
44
+ * the same (endpoint, model) pair, where a run on that model can read it too. A pair is held as
45
+ * `modelKey(endpointId, model)`, which is what `importCapabilities` adds to it by.
43
46
  */
44
47
  const noHints = scoped(() => new Set());
45
- /** An (endpoint, model) pair as `noHints` holds it: `[endpointId, model]`, stringified. */
46
- export const hintKey = (endpoint, model) => JSON.stringify([endpoint, model]);
47
- /** The pairs that refused the hints, the live set, for `exportCapabilities` and `importCapabilities`. */
48
+ /**
49
+ * The pairs that refused the hints, the live set, for `exportCapabilities` and
50
+ * `importCapabilities`.
51
+ *
52
+ * @returns The current scope's set itself and not a copy, each pair a
53
+ * `modelKey(endpointId, model)`. Adding to it stops the hints being sent to that pair from the
54
+ * next call.
55
+ */
48
56
  export const refusedHints = () => noHints();
49
- /** Test seam, alongside `resetClients` and `resetAll`: forget which models refused the hints. */
57
+ /**
58
+ * Test seam, alongside `resetClients` and `resetAll`: forget which models refused the hints.
59
+ *
60
+ * @remarks
61
+ * The current scope's set is emptied in place, so a holder of it sees it empty.
62
+ */
50
63
  export const resetHints = () => noHints().clear();
51
64
  /**
52
65
  * Runs a side task and returns the reply text, thinking stripped. Throws like any request.
53
66
  *
54
- * @param config Where to send it and how long to wait.
55
- * @param model The model to ask, usually smaller than the one running the work.
56
- * @param system The instruction.
57
- * @param user The input it applies to. Content parts where the model is being shown an image.
58
- * @param options Reply ceiling, temperature, a reasoning effort, cancellation, notices.
67
+ * @param config - Where to send it and how long to wait.
68
+ * @param model - The model to ask, usually smaller than the one running the work.
69
+ * @param system - The instruction.
70
+ * @param user - The input it applies to. Content parts where the model is being shown an image.
71
+ * @param [options] - Reply ceiling, temperature, a reasoning effort, cancellation, notices.
72
+ * @returns The reply's content, trimmed. Where that is empty once the thinking is stripped, a
73
+ * `reasoning_content` the server sent beside it, stripped the same way, or an empty string.
59
74
  */
60
75
  export function ask(config, model, system, user, options = {}) {
61
76
  return complete(config, model, system, user, options);
@@ -63,6 +78,19 @@ export function ask(config, model, system, user, options = {}) {
63
78
  /**
64
79
  * The request `ask` and `askJson` share, with `format` deciding the extra body fields from what
65
80
  * the model and the endpoint have refused, rebuilt on every re-send.
81
+ *
82
+ * @param config - Where to send it. Its `baseUrl` and key also pick the capabilities the request is
83
+ * negotiated against, and with `model` the pair a refusal of the hints is latched on.
84
+ * @param model - The model to ask.
85
+ * @param system - The instruction, sent as the system message.
86
+ * @param user - The input, sent as the user message as it was given.
87
+ * @param options - A side task's options. A `reasoningEffort` that is a level leaves the
88
+ * no-thinking hints out; absent, `""` or `"off"` sends them unless this pair has refused them.
89
+ * @param [format] - Builds the extra body fields from the endpoint's capabilities and the model's.
90
+ * Called on every send, so a re-send is built from what has been latched since. Absent adds none.
91
+ * @returns The first choice's message as `answerOf` reads it, empty when there was no answer.
92
+ * Throws what the request throws, except that a 400 or 422 `negotiate` did not answer, on a
93
+ * request that carried the hints or an effort, is retried once without either.
66
94
  */
67
95
  async function complete(config, model, system, user, { maxTokens = 512, temperature = 0.3, reasoningEffort, signal, onNotice }, format) {
68
96
  // The effort the caller chose, or empty where it chose none: `""` and `"off"` read as absent,
@@ -75,8 +103,8 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
75
103
  // models refuse it by value and list `minimal` as their floor. `effortFor` answers with the
76
104
  // cheapest rung this one takes, which `negotiate` has been stepping up as it was refused.
77
105
  // A level the caller chose goes through the same ladder, which only ever steps up from it.
78
- const asked = effort ? effortFor(refused, level || "none") : "";
79
- sentEffort = asked !== "";
106
+ const asked = effort ? effortFor(refused, level || EFFORT_NONE) : '';
107
+ sentEffort = asked !== '';
80
108
  return getClient(config).chat.completions.create({
81
109
  model,
82
110
  // The reasoning models want the ceiling spelled the other way, and they are exactly the
@@ -85,8 +113,8 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
85
113
  // model is as close as it gets.
86
114
  ...ceilingAndTemperature(refused, maxTokens, temperature),
87
115
  messages: [
88
- { role: "system", content: system },
89
- { role: "user", content: user },
116
+ { role: Role.System, content: system },
117
+ { role: Role.User, content: user },
90
118
  ],
91
119
  ...(hints ? NO_THINKING : {}),
92
120
  // Not gated on `hints`: a model that refuses `chat_template_kwargs` may still read the
@@ -104,7 +132,7 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
104
132
  model,
105
133
  onNotice,
106
134
  });
107
- const key = hintKey(endpointId(config), model);
135
+ const key = modelKey(endpointId(config), model);
108
136
  // A chosen effort replaces the template hint rather than riding beside it: `enable_thinking:
109
137
  // false` next to `reasoning_effort: "high"` is a request that contradicts itself.
110
138
  const hints = !level && !noHints().has(key);
@@ -118,8 +146,9 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
118
146
  // does not offer as `none`. Or a 400 about something else entirely, which is why the
119
147
  // notice says what was tried rather than what was wrong.
120
148
  const effort = sentEffort;
121
- if (!(hints || effort) || !refusesRequest(error))
149
+ if (!(hints || effort) || !refusesRequest(error)) {
122
150
  throw error;
151
+ }
123
152
  onNotice?.(level
124
153
  ? `${model} rejected a request carrying a reasoning effort; retrying without it`
125
154
  : `${model} rejected a request carrying the no-thinking hints; retrying without them`);
@@ -129,48 +158,66 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
129
158
  // hints again. When both went out the refusal cannot say which, so the one `negotiate`
130
159
  // cannot latch is blamed; if it was the effort after all, the next call is left with only
131
160
  // the effort to drop and latches that instead.
132
- if (hints)
161
+ if (hints) {
133
162
  noHints().add(key);
134
- else
163
+ }
164
+ else {
135
165
  modelCapabilitiesFor(supports, model).reasoningEffort = false;
166
+ }
167
+ }
168
+ return answerOf(response.choices[0]?.message);
169
+ }
170
+ /**
171
+ * A reply's answer, thinking stripped, or its scratchpad where the answer is nothing else.
172
+ *
173
+ * @param message - The first choice's message. Absent, as when the reply held no choices, reads as
174
+ * an empty one.
175
+ * @returns The content, trimmed, with its thinking fences stripped. Where that leaves nothing, a
176
+ * string `reasoning_content` read the same way, and an empty string where there is none.
177
+ *
178
+ * @remarks
179
+ * Reasoning models that ignore the hints still fence their scratchpad. A side task answers under
180
+ * a small ceiling, so the fence often never closes, and a template that opened it in the prompt
181
+ * leaves only the close; either way the deliberation used to come back as the answer.
182
+ */
183
+ function answerOf(message) {
184
+ const answer = stripThinking(message?.content ?? '').trim();
185
+ if (answer) {
186
+ return answer;
136
187
  }
137
- const message = response.choices[0]?.message;
138
- // Reasoning models that ignore the hints still fence their scratchpad. A side task answers
139
- // under a small ceiling, so the fence often never closes, and a template that opened it in
140
- // the prompt leaves only the close; either way the deliberation used to come back as the answer.
141
- const answer = stripThinking(message?.content ?? "").trim();
142
188
  // Nothing but scratchpad. Some servers put the deliberation in its own field and leave the
143
189
  // content genuinely empty, in which case there is no answer to find anywhere else.
144
- if (answer)
145
- return answer;
146
190
  const reasoning = message?.reasoning_content;
147
- return typeof reasoning === "string" ? stripThinking(reasoning).trim() : "";
191
+ return typeof reasoning === 'string' ? stripThinking(reasoning).trim() : '';
148
192
  }
149
193
  /**
150
194
  * A side task whose answer is JSON matching a schema, parsed. Undefined when no JSON came back.
151
195
  * Throws like any request.
152
196
  *
197
+ * @param config - Where to send it and how long to wait.
198
+ * @param model - The model to ask.
199
+ * @param system - The instruction. The schema is appended to it.
200
+ * @param user - The input it applies to. Content parts where the model is being shown an image.
201
+ * @param schema - The JSON Schema of the answer. Its root is held to an object, as a tool's is.
202
+ * @param [options] - A side task's options, plus the schema's `name` and whether it is `strict`.
203
+ * @returns What the reply parsed to, unchecked against the schema: `T` is the caller's claim.
204
+ * `undefined` when `parseJson` found nothing in it.
205
+ *
206
+ * @remarks
153
207
  * Sends `response_format` with the schema, which a llama.cpp server compiles into a grammar and
154
208
  * vLLM, LM Studio, Ollama and OpenAI each hold the reply to, so a small model that wraps JSON in
155
209
  * prose cannot. The schema is normalised as a tool's parameters are, and relaxed where the endpoint
156
210
  * could not build a grammar, since the same converter reads both. A model that refuses the field
157
211
  * latches it off and is asked in words: the schema rides on the system prompt either way, and
158
212
  * the reply goes through `parseJson`, which finds the JSON in whatever came back.
159
- *
160
- * @param config Where to send it and how long to wait.
161
- * @param model The model to ask.
162
- * @param system The instruction. The schema is appended to it.
163
- * @param user The input it applies to. Content parts where the model is being shown an image.
164
- * @param schema The JSON Schema of the answer. Its root is held to an object, as a tool's is.
165
- * @param options A side task's options, plus the schema's `name` and whether it is `strict`.
166
213
  */
167
- export async function askJson(config, model, system, user, schema, { name = "answer", strict = true, ...options } = {}) {
214
+ export async function askJson(config, model, system, user, schema, { name = 'answer', strict = true, ...options } = {}) {
168
215
  const sanitized = sanitizeSchema(schema);
169
216
  const instruction = `${system}\n\nReply with JSON alone, matching this JSON Schema:\n${JSON.stringify(sanitized)}`;
170
217
  const reply = await complete(config, model, instruction, user, options, (supports, refused) => refused.structuredOutput
171
218
  ? {
172
219
  response_format: {
173
- type: "json_schema",
220
+ type: JSON_SCHEMA_FORMAT,
174
221
  json_schema: {
175
222
  name,
176
223
  strict,
@@ -182,69 +229,142 @@ export async function askJson(config, model, system, user, schema, { name = "ans
182
229
  return parseJson(reply);
183
230
  }
184
231
  /**
232
+ * Runs a side task and answers `undefined` where it fails, so the work it supports carries on.
233
+ *
234
+ * @param label - Names the task in the notice when it fails.
235
+ * @param run - The call to attempt. Anything it throws becomes `undefined`, an abort excepted.
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
+ *
241
+ * @remarks
185
242
  * A side task is never worth failing the work it supports. Callers that can carry on without
186
243
  * an answer use this and get `undefined` instead of an exception.
187
- *
188
- * @param label Names the task in the notice when it fails.
189
- * @param run The call to attempt. Anything it throws becomes `undefined`, an abort excepted.
190
- * @param options `onNotice`, told what was given up on.
191
244
  */
192
- export async function tryAsk(label, run, { onNotice } = {}) {
245
+ export async function tryAsk(label, run, { onNotice, signal } = {}) {
193
246
  try {
194
247
  return await run();
195
248
  }
196
249
  catch (error) {
197
250
  // A cancelled run is not a failed side task. Swallowing the abort made the two
198
- // indistinguishable and left the cancellation with nowhere to go.
199
- 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) {
200
255
  throw error;
256
+ }
201
257
  onNotice?.(`${label}: ${errorMessage(error)}`);
202
258
  return undefined;
203
259
  }
204
260
  }
205
261
  /**
206
- * Models are asked for JSON and often answer with prose around it, or a fenced block. Pull out
207
- * the first array or object rather than failing the task over a wrapper.
262
+ * The first array or object in a model's reply, parsed, whatever prose or fence surrounds it.
263
+ *
264
+ * @param text - The reply, fences and prose included. Nothing parseable gives `undefined`.
265
+ * @returns What lies between the first `[` or `{` and the last `]` or `}`, parsed — inside the
266
+ * first fenced block where there is one. Unchecked: `T` is the caller's claim. `undefined` when
267
+ * there is no such stretch or it will not parse even repaired.
268
+ *
269
+ * @remarks
270
+ * Models are asked for JSON and often answer with prose around it, or a fenced block, and a
271
+ * wrapper is not worth failing the task over.
208
272
  *
209
273
  * What is pulled out is read the way a tool call's arguments are: as written where that parses,
210
274
  * and otherwise with the almost-JSON a local model writes repaired — single quotes, `True` and
211
275
  * `None`, bare keys, a trailing comma. A small model asked for a list gets these wrong as often in
212
276
  * a reply as in a call, and a side task that fails over one costs the run a second request.
213
- *
214
- * @param text The reply, fences and prose included. Nothing parseable gives `undefined`.
215
277
  */
216
278
  export function parseJson(text) {
217
- const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
279
+ const [fenced] = text.matchAll(CODE_FENCE);
218
280
  const body = (fenced?.[1] ?? text).trim();
219
281
  const start = body.search(/[[{]/);
220
- if (start < 0)
282
+ if (start < 0) {
221
283
  return undefined;
222
- const end = Math.max(body.lastIndexOf("]"), body.lastIndexOf("}"));
223
- if (end <= start)
284
+ }
285
+ const end = Math.max(body.lastIndexOf(']'), body.lastIndexOf('}'));
286
+ if (end <= start) {
224
287
  return undefined;
288
+ }
225
289
  return looseJson(body.slice(start, end + 1));
226
290
  }
227
291
  /**
228
292
  * Strips the quoting and list punctuation models decorate short answers with.
229
293
  *
230
- * @param line One line of a reply.
294
+ * @param line - One line of a reply.
295
+ * @returns The line trimmed, without one leading bullet or number, any quotes or backticks at
296
+ * either end, or trailing full stops. Empty when nothing else was there.
231
297
  */
232
298
  export const clean = (line) => line
233
299
  .trim()
234
- .replace(/^(?:[-*•]|\d+[.)])\s*/, "")
235
- .replace(/^["'`]+|["'`.]+$/g, "")
300
+ .replace(/^(?:[-*•]|\d+[.)])\s*/, '')
301
+ .replace(/^["'`]+|["'`.]+$/g, '')
236
302
  .trim();
237
303
  /**
238
304
  * A list-shaped reply, one item per line, cleaned of the bullets and quotes models decorate
239
305
  * them with. Overlong items are dropped rather than truncated — a suggestion that has to be
240
306
  * squinted at is worse than one fewer suggestion.
241
307
  *
242
- * @param text The reply, one item per line.
243
- * @param max How many items to keep.
244
- * @param maxChars Longest item kept. Longer ones are dropped, not truncated.
308
+ * @param text - The reply, one item per line.
309
+ * @param max - How many items to keep.
310
+ * @param maxChars - Longest item kept. Longer ones are dropped, not truncated.
311
+ * @returns The first `max` items that survive, in reply order. Lines that clean to nothing are not
312
+ * items, and an overlong one does not count towards `max`.
245
313
  */
246
314
  export const listLines = (text, max, maxChars) => text
247
- .split("\n")
315
+ .split('\n')
248
316
  .map(clean)
249
317
  .filter((line) => line.length > 0 && line.length <= maxChars)
250
318
  .slice(0, max);
319
+ /**
320
+ * A side task's settings as the call that honours them: its endpoint, its model, and the options
321
+ * `ask`, `askJson`, `summariser` and `preselect` take last.
322
+ *
323
+ * @param task - The task, ordinarily `resolved.tasks.<key>`.
324
+ * @param [options] - What the host adds — cancellation, notices, `keywords` — and its own defaults
325
+ * for the settings a task may leave out.
326
+ * @param [agent] - The endpoint the main turn uses, key included. Absent sends the task's endpoint
327
+ * as it stands.
328
+ * @returns The three arguments a side-task entry point takes. `options` is a new object, and
329
+ * `endpoint` is the task's own object when no `agent` was given, a copy otherwise.
330
+ *
331
+ * @remarks
332
+ * A helper rather than a second signature on each of the four, so a setting added to a task later
333
+ * arrives through `options` without a host's call changing. What the task states wins over the
334
+ * same field in `options`: the host's code is written once and the task is what an operator
335
+ * configured over it, so a host passes its own ceiling as the value for a task that names none.
336
+ * A setting the task leaves out and `options` does too is absent from the result, which is what
337
+ * leaves it to the entry point's own default — 0.3, not the agent's temperature.
338
+ *
339
+ * A resolved spec carries no key, so a task on the agent's own endpoint would otherwise go out
340
+ * unkeyed, and be remembered under a second capability entry beside the agent's. Given `agent`,
341
+ * such a task is sent the agent's `baseUrl` as the agent spells it and, having no key of its own,
342
+ * the agent's key, so `capabilitiesFor` answers both with one object; a task on another endpoint
343
+ * is sent only a key of its own, as `resolveApiKey` has it. The environment is not read: the
344
+ * agent's key is whatever the main turn is sent.
345
+ */
346
+ export function taskCall(task, options, agent) {
347
+ const own = task.endpoint;
348
+ const shared = agent && (!own.baseUrl.trim() || sameUrl(own.baseUrl, agent.baseUrl));
349
+ const stated = {};
350
+ if (task.maxTokens !== undefined) {
351
+ stated.maxTokens = task.maxTokens;
352
+ }
353
+ if (task.temperature !== undefined) {
354
+ stated.temperature = task.temperature;
355
+ }
356
+ if (task.reasoningEffort !== undefined) {
357
+ stated.reasoningEffort = task.reasoningEffort;
358
+ }
359
+ return {
360
+ endpoint: agent
361
+ ? {
362
+ ...own,
363
+ ...(shared ? { baseUrl: agent.baseUrl } : {}),
364
+ apiKey: resolveApiKey(own, agent, {}),
365
+ }
366
+ : own,
367
+ model: task.model,
368
+ options: { ...options, ...stated },
369
+ };
370
+ }
@@ -28,7 +28,7 @@ export interface ModelSnapshot {
28
28
  * none refused.
29
29
  */
30
30
  refusedEfforts?: string[];
31
- /** The efforts a refusal published as this model's, in ladder order. Absent is none published. */
31
+ /** The efforts a refusal published as this model's, in the order it gave them. Absent is none published. */
32
32
  supportedEfforts?: string[];
33
33
  }
34
34
  /** What one endpoint refused, and under it what each of its models did. */
@@ -55,6 +55,10 @@ export interface CapabilitySnapshot {
55
55
  /**
56
56
  * Every refusal this process has latched, as a JSON-safe blob to store and hand back on boot.
57
57
  *
58
+ * @returns A fresh object that shares nothing with the live latches, its lists copied and the two
59
+ * refused ones sorted.
60
+ *
61
+ * @remarks
58
62
  * Covers what `negotiate` latches on endpoints and models and the models `ask` found refusing the
59
63
  * no-thinking hints. Only what was actually refused is in it, so a snapshot of a process that met
60
64
  * no refusals has no endpoints. Endpoints are named by digest rather than URL and key, since the
@@ -65,15 +69,16 @@ export declare function exportCapabilities(): CapabilitySnapshot;
65
69
  /**
66
70
  * Latches what a stored snapshot says was refused, on top of whatever this process has learned.
67
71
  *
72
+ * @param snapshot - What `exportCapabilities` returned, as stored. Read defensively: a field of the
73
+ * wrong type is skipped rather than trusted.
74
+ * @returns Whether the snapshot was of this version and applied.
75
+ *
76
+ * @remarks
68
77
  * Refusals only ever latch off, so importing merges rather than replaces: a flag already off stays
69
78
  * off whatever the snapshot says, and one the snapshot has off is turned off. A snapshot of another
70
79
  * version, or anything that is not one, is ignored — a stale shape costs the refused requests it
71
80
  * would have saved, which is what a restart cost before. How old is too old is the consumer's call,
72
81
  * made on `savedAt` before importing, or afterwards per endpoint with `expireCapabilities`, since a
73
82
  * server behind a URL can be upgraded between boots.
74
- *
75
- * @param snapshot What `exportCapabilities` returned, as stored. Read defensively: a field of the
76
- * wrong type is skipped rather than trusted.
77
- * @returns Whether the snapshot was of this version and applied.
78
83
  */
79
84
  export declare function importCapabilities(snapshot: unknown): boolean;
package/dist/snapshot.js CHANGED
@@ -1,6 +1,7 @@
1
- import { capabilitiesById, knownCapabilities, MODEL_FLAGS, modelCapabilitiesFor, OPTIMISTIC_MODEL, } from "./capabilities.js";
2
- import { isRecord } from "./guards.js";
3
- import { hintKey, refusedHints } from "./side-task.js";
1
+ import { capabilitiesById, knownCapabilities, MODEL_FLAGS, modelCapabilitiesFor, OPTIMISTIC_MODEL, } from './capabilities.js';
2
+ import { modelKey } from './client.js';
3
+ import { isRecord } from './guards.js';
4
+ import { refusedHints } from './side-task.js';
4
5
  /**
5
6
  * What endpoints and models refused, carried across a restart.
6
7
  *
@@ -11,27 +12,52 @@ import { hintKey, refusedHints } from "./side-task.js";
11
12
  */
12
13
  /** The version `importCapabilities` accepts. Raised when what is latched changes shape. */
13
14
  export const CAPABILITY_SNAPSHOT_VERSION = 1;
15
+ /**
16
+ * A model's snapshot as it starts out: every flag on, and both refused lists empty.
17
+ *
18
+ * @returns A fresh object each time, since `exportCapabilities` writes onto what it is given.
19
+ */
14
20
  const optimisticModel = () => ({
15
21
  ...OPTIMISTIC_MODEL,
16
22
  thinkingHints: true,
17
23
  refusedFields: [],
18
24
  refusedEfforts: [],
19
25
  });
26
+ /**
27
+ * Whether a model's snapshot holds a refusal, which is what earns it a place in the blob.
28
+ *
29
+ * @param model - The snapshot. Its `supportedEfforts` is not read: a published list latches
30
+ * nothing.
31
+ * @returns `true` where a flag is off, the hints were refused, or either refused list has an entry.
32
+ */
20
33
  const refusedAnything = (model) => MODEL_FLAGS.some((flag) => !model[flag]) ||
21
34
  !model.thinkingHints ||
22
35
  model.refusedFields.length > 0 ||
23
36
  (model.refusedEfforts?.length ?? 0) > 0;
24
- /** Latches every string in a stored list into a live set, skipping what is not a list of them. */
37
+ /**
38
+ * Latches every string in a stored list into a live set, skipping what is not a list of them.
39
+ *
40
+ * @param held - The live set. Written to, and only ever added to.
41
+ * @param stored - The list as the snapshot holds it. Anything but an array is skipped whole, and
42
+ * an entry that is not a string is skipped on its own.
43
+ */
25
44
  const latchInto = (held, stored) => {
26
- if (!Array.isArray(stored))
45
+ if (!Array.isArray(stored)) {
27
46
  return;
28
- for (const value of stored)
29
- if (typeof value === "string")
47
+ }
48
+ for (const value of stored) {
49
+ if (typeof value === 'string') {
30
50
  held.add(value);
51
+ }
52
+ }
31
53
  };
32
54
  /**
33
55
  * Every refusal this process has latched, as a JSON-safe blob to store and hand back on boot.
34
56
  *
57
+ * @returns A fresh object that shares nothing with the live latches, its lists copied and the two
58
+ * refused ones sorted.
59
+ *
60
+ * @remarks
35
61
  * Covers what `negotiate` latches on endpoints and models and the models `ask` found refusing the
36
62
  * no-thinking hints. Only what was actually refused is in it, so a snapshot of a process that met
37
63
  * no refusals has no endpoints. Endpoints are named by digest rather than URL and key, since the
@@ -42,12 +68,14 @@ export function exportCapabilities() {
42
68
  const endpoints = {};
43
69
  const entry = (id) => {
44
70
  const held = endpoints[id];
45
- if (held)
71
+ if (held) {
46
72
  return held;
73
+ }
47
74
  const fresh = { strictSchemas: true, usageInStream: true, models: {} };
48
75
  const since = knownCapabilities().get(id)?.since;
49
- if (since !== undefined)
76
+ if (since !== undefined) {
50
77
  fresh.since = since;
78
+ }
51
79
  endpoints[id] = fresh;
52
80
  return fresh;
53
81
  };
@@ -55,16 +83,19 @@ export function exportCapabilities() {
55
83
  const models = {};
56
84
  for (const [name, refused] of supports.models) {
57
85
  const model = optimisticModel();
58
- for (const flag of MODEL_FLAGS)
86
+ for (const flag of MODEL_FLAGS) {
59
87
  model[flag] = refused[flag];
88
+ }
60
89
  model.refusedFields = [...refused.refusedFields].sort();
61
90
  model.refusedEfforts = [...refused.refusedEfforts].sort();
62
91
  // Only alongside a refusal, since on its own a published list latches nothing: the model
63
92
  // named it while refusing a rung, and that rung is in `refusedEfforts`.
64
- if (refused.supportedEfforts)
93
+ if (refused.supportedEfforts) {
65
94
  model.supportedEfforts = [...refused.supportedEfforts];
66
- if (refusedAnything(model))
95
+ }
96
+ if (refusedAnything(model)) {
67
97
  models[name] = model;
98
+ }
68
99
  }
69
100
  if (!supports.strictSchemas || !supports.usageInStream || Object.keys(models).length) {
70
101
  Object.assign(entry(id), {
@@ -85,55 +116,67 @@ export function exportCapabilities() {
85
116
  /**
86
117
  * Latches what a stored snapshot says was refused, on top of whatever this process has learned.
87
118
  *
119
+ * @param snapshot - What `exportCapabilities` returned, as stored. Read defensively: a field of the
120
+ * wrong type is skipped rather than trusted.
121
+ * @returns Whether the snapshot was of this version and applied.
122
+ *
123
+ * @remarks
88
124
  * Refusals only ever latch off, so importing merges rather than replaces: a flag already off stays
89
125
  * off whatever the snapshot says, and one the snapshot has off is turned off. A snapshot of another
90
126
  * version, or anything that is not one, is ignored — a stale shape costs the refused requests it
91
127
  * would have saved, which is what a restart cost before. How old is too old is the consumer's call,
92
128
  * made on `savedAt` before importing, or afterwards per endpoint with `expireCapabilities`, since a
93
129
  * server behind a URL can be upgraded between boots.
94
- *
95
- * @param snapshot What `exportCapabilities` returned, as stored. Read defensively: a field of the
96
- * wrong type is skipped rather than trusted.
97
- * @returns Whether the snapshot was of this version and applied.
98
130
  */
99
131
  export function importCapabilities(snapshot) {
100
- if (!isRecord(snapshot) || snapshot.version !== CAPABILITY_SNAPSHOT_VERSION)
132
+ if (!isRecord(snapshot) || snapshot.version !== CAPABILITY_SNAPSHOT_VERSION) {
101
133
  return false;
102
- if (!isRecord(snapshot.endpoints))
134
+ }
135
+ if (!isRecord(snapshot.endpoints)) {
103
136
  return false;
137
+ }
104
138
  for (const [id, endpoint] of Object.entries(snapshot.endpoints)) {
105
- if (!isRecord(endpoint))
139
+ if (!isRecord(endpoint)) {
106
140
  continue;
141
+ }
107
142
  const supports = capabilitiesById(id);
108
- if (endpoint.strictSchemas === false)
143
+ if (endpoint.strictSchemas === false) {
109
144
  supports.strictSchemas = false;
110
- if (endpoint.usageInStream === false)
145
+ }
146
+ if (endpoint.usageInStream === false) {
111
147
  supports.usageInStream = false;
148
+ }
112
149
  // Older of the two, so a snapshot ages an entry and never rejuvenates one: importing must not
113
150
  // be a way to keep a latch from ever reaching `expireCapabilities`.
114
- if (typeof endpoint.since === "number" && endpoint.since < supports.since) {
151
+ if (typeof endpoint.since === 'number' && endpoint.since < supports.since) {
115
152
  supports.since = endpoint.since;
116
153
  }
117
- if (!isRecord(endpoint.models))
154
+ if (!isRecord(endpoint.models)) {
118
155
  continue;
156
+ }
119
157
  for (const [name, model] of Object.entries(endpoint.models)) {
120
- if (!isRecord(model))
158
+ if (!isRecord(model)) {
121
159
  continue;
160
+ }
122
161
  const refused = modelCapabilitiesFor(supports, name);
123
- for (const flag of MODEL_FLAGS)
124
- if (model[flag] === false)
162
+ for (const flag of MODEL_FLAGS) {
163
+ if (model[flag] === false) {
125
164
  refused[flag] = false;
165
+ }
166
+ }
126
167
  latchInto(refused.refusedEfforts, model.refusedEfforts);
127
168
  // Replaced rather than merged: two lists of what one model takes are two readings of the
128
169
  // same fact, and the stored one is at least as recent as an empty absent.
129
170
  if (Array.isArray(model.supportedEfforts)) {
130
- const listed = model.supportedEfforts.filter((value) => typeof value === "string");
131
- if (listed.length)
171
+ const listed = model.supportedEfforts.filter((value) => typeof value === 'string');
172
+ if (listed.length) {
132
173
  refused.supportedEfforts = listed;
174
+ }
133
175
  }
134
176
  latchInto(refused.refusedFields, model.refusedFields);
135
- if (model.thinkingHints === false)
136
- refusedHints().add(hintKey(id, name));
177
+ if (model.thinkingHints === false) {
178
+ refusedHints().add(modelKey(id, name));
179
+ }
137
180
  }
138
181
  }
139
182
  return true;