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