@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.
- package/README.md +10 -7
- package/dist/agent-loop.d.ts +84 -205
- package/dist/agent-loop.js +288 -597
- package/dist/calibration.d.ts +14 -10
- package/dist/calibration.js +30 -23
- package/dist/capabilities.d.ts +111 -42
- package/dist/capabilities.js +281 -138
- package/dist/client.d.ts +104 -35
- package/dist/client.js +205 -74
- package/dist/compaction.d.ts +103 -60
- package/dist/compaction.js +160 -86
- package/dist/config.d.ts +48 -12
- package/dist/config.js +20 -1
- package/dist/continuation.d.ts +24 -16
- package/dist/continuation.js +70 -44
- package/dist/digest.d.ts +7 -0
- package/dist/digest.js +18 -0
- package/dist/errors.d.ts +24 -15
- package/dist/errors.js +23 -15
- package/dist/events.d.ts +100 -63
- package/dist/events.js +250 -120
- package/dist/guards.d.ts +49 -4
- package/dist/guards.js +60 -5
- package/dist/hook-events.d.ts +21 -5
- package/dist/hook-events.js +23 -9
- package/dist/hooks.d.ts +96 -73
- package/dist/hooks.js +202 -116
- package/dist/index.d.ts +30 -26
- package/dist/index.js +29 -24
- package/dist/ledger.d.ts +62 -30
- package/dist/ledger.js +163 -56
- package/dist/platform.d.ts +15 -0
- package/dist/platform.js +15 -0
- package/dist/preselect.d.ts +223 -0
- package/dist/preselect.js +291 -0
- package/dist/request-body.d.ts +32 -0
- package/dist/request-body.js +47 -0
- package/dist/reset.d.ts +1 -0
- package/dist/reset.js +7 -6
- package/dist/retry.d.ts +44 -138
- package/dist/retry.js +83 -281
- package/dist/run-calls.d.ts +123 -0
- package/dist/run-calls.js +351 -0
- package/dist/run-turn.d.ts +36 -15
- package/dist/run-turn.js +74 -37
- package/dist/runtime.d.ts +23 -17
- package/dist/runtime.js +34 -24
- package/dist/schema-compat.d.ts +35 -18
- package/dist/schema-compat.js +346 -176
- package/dist/scope.d.ts +28 -5
- package/dist/scope.js +37 -10
- package/dist/side-task.d.ts +124 -40
- package/dist/side-task.js +186 -66
- package/dist/snapshot.d.ts +10 -5
- package/dist/snapshot.js +73 -30
- package/dist/spec.d.ts +56 -25
- package/dist/spec.js +494 -215
- package/dist/stream.d.ts +66 -16
- package/dist/stream.js +220 -97
- package/dist/thinking.d.ts +23 -8
- package/dist/thinking.js +92 -44
- package/dist/tokens.d.ts +164 -6
- package/dist/tokens.js +319 -6
- package/dist/tool-calls.d.ts +90 -17
- package/dist/tool-calls.js +207 -84
- package/dist/tool-loading.d.ts +101 -189
- package/dist/tool-loading.js +257 -371
- package/dist/wire.d.ts +81 -0
- package/dist/wire.js +73 -0
- package/llms.txt +76 -39
- package/package.json +4 -4
package/dist/side-task.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
/**
|
|
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 ||
|
|
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:
|
|
89
|
-
{ role:
|
|
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 =
|
|
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
|
-
|
|
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 ===
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
*
|
|
207
|
-
*
|
|
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.
|
|
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
|
-
|
|
223
|
-
|
|
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(
|
|
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
|
+
}
|
package/dist/snapshot.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
/**
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
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 ===
|
|
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(
|
|
177
|
+
if (model.thinkingHints === false) {
|
|
178
|
+
refusedHints().add(modelKey(id, name));
|
|
179
|
+
}
|
|
137
180
|
}
|
|
138
181
|
}
|
|
139
182
|
return true;
|