@cubicecho/agent-core 2.14.0 → 2.16.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.
- package/README.md +53 -25
- package/dist/agent-loop.d.ts +20 -5
- package/dist/agent-loop.js +13 -11
- package/dist/capabilities.d.ts +40 -0
- package/dist/capabilities.js +131 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/side-task.js +7 -3
- package/dist/snapshot.d.ts +8 -0
- package/dist/snapshot.js +20 -1
- package/llms.txt +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -132,28 +132,49 @@ again, with the setting still reading `high` and nothing anywhere saying it had
|
|
|
132
132
|
So they hang off the endpoint under the name the endpoint knows the model by. Pass `model` and
|
|
133
133
|
the same loop answers both levels; leave it out and nothing changes.
|
|
134
134
|
|
|
135
|
-
A refusal of the *value* is not one of these, however alike the two read:
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
135
|
+
A refusal of the *value* is not one of these, however alike the two read: a `max_tokens` larger
|
|
136
|
+
than the model's ceiling, a temperature out of range. Dropping the field answers those too — at
|
|
137
|
+
the model's own default, latched for the rest of the process, with the settings row still reading
|
|
138
|
+
what was typed and nothing saying it had stopped meaning it. They are passed to the caller
|
|
139
|
+
instead, where whoever typed the number can see it.
|
|
140
|
+
|
|
141
|
+
The effort is the exception, because there the model has said what it *would* take:
|
|
142
|
+
`Unsupported value: 'reasoning_effort' does not support 'none' with this model. Supported values
|
|
143
|
+
are: 'minimal', 'low', 'medium', and 'high'.` `negotiate` latches the rung that was refused and
|
|
144
|
+
re-sends at the cheapest one above it, which is a request rather than a failure. Where the refusal
|
|
145
|
+
lists nothing it walks `EFFORT_LADDER` — `none`, `minimal`, `low`, `medium`, `high` — a rung per
|
|
146
|
+
refusal. Both latch per `(endpoint, model)` and ride in the snapshot, so a restart does not walk
|
|
147
|
+
the ladder again.
|
|
148
|
+
|
|
149
|
+
Only ever upward, and only from a value on the ladder. Answering a refused `xhigh` with `high`
|
|
150
|
+
would quietly reason less than whoever typed it asked for; stepping up from `none` only costs
|
|
151
|
+
tokens, and says so in a notice. When the ladder runs out, or the value is not on it, the refusal
|
|
152
|
+
goes to the caller as it did before.
|
|
153
|
+
|
|
154
|
+
`effortFor(model, asked)` is what a body builder calls to get the value to send — `buildBody` and
|
|
155
|
+
the side tasks both do — and it answers the empty string for a model that takes no effort at all,
|
|
156
|
+
for `"off"` and for an absent setting.
|
|
140
157
|
|
|
141
158
|
```ts
|
|
142
|
-
const turn = await negotiate(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
159
|
+
const turn = await negotiate(
|
|
160
|
+
supports,
|
|
161
|
+
(supports, produced, model) => {
|
|
162
|
+
// Each rebuilt per attempt from what this model has already refused.
|
|
163
|
+
const asked = effortFor(model, effort);
|
|
164
|
+
return streamTurn(
|
|
165
|
+
getClient(config),
|
|
166
|
+
{
|
|
167
|
+
model: name, messages, stream: true,
|
|
168
|
+
...(asked ? { reasoning_effort: asked } : {}),
|
|
169
|
+
...(model?.legacyTokenLimit === false
|
|
170
|
+
? { max_completion_tokens: limit }
|
|
171
|
+
: { max_tokens: limit }),
|
|
172
|
+
...(model?.chosenTemperature ? { temperature } : {}),
|
|
173
|
+
tools: supports.strictSchemas ? declared : relaxTools(declared),
|
|
174
|
+
},
|
|
175
|
+
{ produced, signal },
|
|
176
|
+
);
|
|
177
|
+
},
|
|
157
178
|
{ model: name },
|
|
158
179
|
);
|
|
159
180
|
```
|
|
@@ -370,10 +391,17 @@ const { turn, messages, usage, loaded } = await runAgentLoop({
|
|
|
370
391
|
|
|
371
392
|
Each step is one `runTurn` with the body from `buildBody`, so everything `negotiate` answers is
|
|
372
393
|
answered here too, and a request that is too big throws `ContextOverflow` whichever side found
|
|
373
|
-
out. Between steps the loop runs the calls: sequentially by default, or together with
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
retry. What a tool throws is what the model
|
|
394
|
+
out. Between steps the loop runs the calls: sequentially by default, or together with `parallel: true`.
|
|
395
|
+
Either way an identical call — the same name and arguments, byte for byte — is made once and its
|
|
396
|
+
answer handed to the repeat, and two still in flight share the request. A call that threw is
|
|
397
|
+
forgotten rather than cached, so asking again is a real retry. What a tool throws is what the model
|
|
398
|
+
reads, and so are arguments that did not parse.
|
|
399
|
+
|
|
400
|
+
The scope is the step, not the run: between steps other tools have run, and the file the model read
|
|
401
|
+
may be the file it has since written. `dedupeToolCalls: false` dispatches everything, and a
|
|
402
|
+
predicate is asked per call — which is how `send_email` opts out, since twice is two emails and
|
|
403
|
+
nothing in an OpenAI tool definition says which tools those are. A pool that reads the MCP
|
|
404
|
+
`readOnlyHint` and `idempotentHint` annotations can answer it; this package cannot.
|
|
377
405
|
|
|
378
406
|
Arguments go through `parseToolArguments`, which is lenient where the model's meaning is plain:
|
|
379
407
|
JSON held in a string is opened, and the almost-JSON local models write — single quotes, Python's
|
|
@@ -382,7 +410,7 @@ string. What still is not an object throws a `ToolArgumentsError` whose `kind` i
|
|
|
382
410
|
the turn stopped at the ceiling, with a message telling the model so, and `malformed` otherwise.
|
|
383
411
|
The repaired JSON is what the transcript keeps, and an unreadable call is replayed as `{}`, because
|
|
384
412
|
a server that parses replayed arguments refuses the originals on every later request. `dispatch`
|
|
385
|
-
is still handed the model's own text as `raw`, and the
|
|
413
|
+
is still handed the model's own text as `raw`, and the dedupe compares repaired arguments,
|
|
386
414
|
so `{'a': 1}` and `{"a": 1}` are one call.
|
|
387
415
|
|
|
388
416
|
A server whose tool-call parser was written for another template streams the model's call as
|
package/dist/agent-loop.d.ts
CHANGED
|
@@ -18,7 +18,8 @@ import { type ToolOrder } from "./tool-loading.ts";
|
|
|
18
18
|
* one has to read the same — which is the test one of the three copies had inverted.
|
|
19
19
|
*
|
|
20
20
|
* @param config What to ask for. `maxTokens` of zero or less sends no ceiling; `reasoningEffort`
|
|
21
|
-
* absent or `"off"` sends no effort
|
|
21
|
+
* absent or `"off"` sends no effort, and one the model has refused by value is stepped up to the
|
|
22
|
+
* cheapest it takes by `effortFor`.
|
|
22
23
|
* @param supports What the endpoint has refused, as `negotiate` hands it to `send`.
|
|
23
24
|
* @param refused What the model has refused, as `negotiate` hands it over. Absent is a model
|
|
24
25
|
* that has refused nothing.
|
|
@@ -152,12 +153,26 @@ export interface AgentLoopOptions {
|
|
|
152
153
|
/** Runs one tool call and returns what the model reads. What it throws, the model reads too. */
|
|
153
154
|
dispatch: (call: ToolCallRequest, signal?: AbortSignal) => Promise<string>;
|
|
154
155
|
/**
|
|
155
|
-
* Runs a step's calls together rather than one after another
|
|
156
|
-
*
|
|
157
|
-
* answer. A call that threw is not an answer and is made again. Results still go into the
|
|
158
|
-
* transcript in the order the model asked.
|
|
156
|
+
* Runs a step's calls together rather than one after another. Results still go into the
|
|
157
|
+
* transcript in the order the model asked. See `dedupeToolCalls`, which applies either way.
|
|
159
158
|
*/
|
|
160
159
|
parallel?: boolean;
|
|
160
|
+
/**
|
|
161
|
+
* Answers an identical repeat of a call — the same name and the same arguments, word for word —
|
|
162
|
+
* within one step from the first one, rather than dispatching it again.
|
|
163
|
+
*
|
|
164
|
+
* On by default, and on whether or not the calls run in `parallel`: a model that asks the same
|
|
165
|
+
* question twice in one reply gets one answer, and two that are still in flight share the
|
|
166
|
+
* request. A call that threw is not an answer and is made again. The scope is the step and not
|
|
167
|
+
* the run, because between steps other tools have run and the file the model read may be the
|
|
168
|
+
* file it has since written.
|
|
169
|
+
*
|
|
170
|
+
* `false` dispatches every call. A predicate is asked per call and is how a tool that does
|
|
171
|
+
* something rather than reads something opts out — `send_email` twice is two emails, and this
|
|
172
|
+
* package cannot tell which tools those are. A pool that reads the MCP `readOnlyHint` and
|
|
173
|
+
* `idempotentHint` annotations can answer it; nothing in an OpenAI tool definition can.
|
|
174
|
+
*/
|
|
175
|
+
dedupeToolCalls?: boolean | ((call: ToolCallRequest) => boolean);
|
|
161
176
|
/** Hooks gathered onto the question before the first request, and told the reply after. */
|
|
162
177
|
hooks?: AgentLoopHooks;
|
|
163
178
|
/**
|
package/dist/agent-loop.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { charsPerTokenFor } from "./calibration.js";
|
|
2
|
-
import { capabilitiesFor } from "./capabilities.js";
|
|
2
|
+
import { capabilitiesFor, effortFor, } from "./capabilities.js";
|
|
3
3
|
import { firstTokenMs, getClient, NO_KEY, timeoutMs } from "./client.js";
|
|
4
4
|
import { continueTurn } from "./continuation.js";
|
|
5
5
|
import { errorMessage } from "./errors.js";
|
|
@@ -35,7 +35,8 @@ const RESERVED = new Set(["model", "messages", "stream", "tools"]);
|
|
|
35
35
|
* one has to read the same — which is the test one of the three copies had inverted.
|
|
36
36
|
*
|
|
37
37
|
* @param config What to ask for. `maxTokens` of zero or less sends no ceiling; `reasoningEffort`
|
|
38
|
-
* absent or `"off"` sends no effort
|
|
38
|
+
* absent or `"off"` sends no effort, and one the model has refused by value is stepped up to the
|
|
39
|
+
* cheapest it takes by `effortFor`.
|
|
39
40
|
* @param supports What the endpoint has refused, as `negotiate` hands it to `send`.
|
|
40
41
|
* @param refused What the model has refused, as `negotiate` hands it over. Absent is a model
|
|
41
42
|
* that has refused nothing.
|
|
@@ -51,7 +52,7 @@ export function buildBody(config, supports, refused, messages, tools = [], order
|
|
|
51
52
|
const declared = supports.strictSchemas
|
|
52
53
|
? sanitizeTools(sorted)
|
|
53
54
|
: relaxTools(sanitizeTools(sorted));
|
|
54
|
-
const effort = config.reasoningEffort;
|
|
55
|
+
const effort = effortFor(refused, config.reasoningEffort);
|
|
55
56
|
const extra = Object.entries(config.extraBody ?? {}).filter(([field]) => !RESERVED.has(field) && !refused?.refusedFields.has(field));
|
|
56
57
|
return {
|
|
57
58
|
...(config.maxTokens > 0
|
|
@@ -60,9 +61,7 @@ export function buildBody(config, supports, refused, messages, tools = [], order
|
|
|
60
61
|
: { max_tokens: config.maxTokens }
|
|
61
62
|
: {}),
|
|
62
63
|
...(refused?.chosenTemperature === false ? {} : { temperature: config.temperature }),
|
|
63
|
-
...(effort
|
|
64
|
-
? { reasoning_effort: effort }
|
|
65
|
-
: {}),
|
|
64
|
+
...(effort ? { reasoning_effort: effort } : {}),
|
|
66
65
|
...(supports.usageInStream ? { stream_options: { include_usage: true } } : {}),
|
|
67
66
|
...Object.fromEntries(extra),
|
|
68
67
|
model: config.model,
|
|
@@ -202,7 +201,8 @@ function cacheDiagnosis(previous, messages, tools, usage) {
|
|
|
202
201
|
*/
|
|
203
202
|
export async function runAgentLoop(options) {
|
|
204
203
|
const { config, system = "", tools = [], catalog = [], dispatch, hooks, signal } = options;
|
|
205
|
-
const { onTurn, beforeStep, parallel = false, recoverToolCalls: recover = true, maxContinuations = 0, toolOrder = true, } = options;
|
|
204
|
+
const { onTurn, beforeStep, parallel = false, recoverToolCalls: recover = true, maxContinuations = 0, toolOrder = true, dedupeToolCalls = true, } = options;
|
|
205
|
+
const dedupable = typeof dedupeToolCalls === "function" ? dedupeToolCalls : () => dedupeToolCalls;
|
|
206
206
|
const started = Date.now();
|
|
207
207
|
// What the loop emitted, less the token deltas, for `runMetrics` at the end. Stamped here rather
|
|
208
208
|
// than by the bus, which the loop does not know about.
|
|
@@ -259,7 +259,6 @@ export async function runAgentLoop(options) {
|
|
|
259
259
|
: { context: "", notes: [] };
|
|
260
260
|
const usage = { prompt: 0, completion: 0, total: 0, cached: 0 };
|
|
261
261
|
const toolCalls = [];
|
|
262
|
-
const answered = new Map();
|
|
263
262
|
const loads = { toolsLoaded: 0, redundantLoads: 0, unknownToolNames: 0 };
|
|
264
263
|
let previous;
|
|
265
264
|
for (let step = 0; step < config.maxToolIterations; step++) {
|
|
@@ -411,6 +410,9 @@ export async function runAgentLoop(options) {
|
|
|
411
410
|
},
|
|
412
411
|
};
|
|
413
412
|
}
|
|
413
|
+
// Per step, not per run: the answer to a call made two steps ago was true before the tools in
|
|
414
|
+
// between ran, and the file the model read may be the file it has since written.
|
|
415
|
+
const answered = new Map();
|
|
414
416
|
const run = async ({ call, args, error: unreadable, normal }) => {
|
|
415
417
|
const { name, arguments: raw } = call.function;
|
|
416
418
|
onEvent({ kind: "tool-call", name, text: preview(raw) });
|
|
@@ -436,7 +438,7 @@ export async function runAgentLoop(options) {
|
|
|
436
438
|
loaded.add(name);
|
|
437
439
|
used.add(name);
|
|
438
440
|
const request = { id: call.id, name, args, raw };
|
|
439
|
-
content =
|
|
441
|
+
content = dedupable(request)
|
|
440
442
|
? await once(answered, `${name}\0${normal}`, () => dispatch(request, signal))
|
|
441
443
|
: await dispatch(request, signal);
|
|
442
444
|
}
|
|
@@ -470,8 +472,8 @@ export async function runAgentLoop(options) {
|
|
|
470
472
|
}
|
|
471
473
|
/**
|
|
472
474
|
* Makes a call at most once per key, sharing the in-flight promise so two identical calls in one
|
|
473
|
-
* step make one request between them
|
|
474
|
-
* real retry rather than a replayed failure.
|
|
475
|
+
* step make one request between them whether they run together or one after the other. A call
|
|
476
|
+
* that rejected is forgotten, so asking again is a real retry rather than a replayed failure.
|
|
475
477
|
*/
|
|
476
478
|
async function once(answered, key, make) {
|
|
477
479
|
const previous = answered.get(key);
|
package/dist/capabilities.d.ts
CHANGED
|
@@ -93,6 +93,22 @@ export interface ModelCapabilities {
|
|
|
93
93
|
* endpoint's because one key reaches models that differ here, the way they differ on effort.
|
|
94
94
|
*/
|
|
95
95
|
structuredOutput: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Efforts this model refused by value rather than by field — a request that named `none` on a
|
|
98
|
+
* model whose list starts at `minimal`. Only ever grows, which is this set's way of latching.
|
|
99
|
+
*
|
|
100
|
+
* Separate from `reasoningEffort` because the two refusals mean opposite things: that one says
|
|
101
|
+
* the model cannot reason and the field must go, this one says it reasons and was handed an
|
|
102
|
+
* effort off a list this package does not know. Dropping the field there would run at the
|
|
103
|
+
* model's own default, which is neither what the caller asked for nor something it can see.
|
|
104
|
+
*/
|
|
105
|
+
refusedEfforts: Set<string>;
|
|
106
|
+
/**
|
|
107
|
+
* The efforts a refusal published as this model's, in ladder order, filtered to the ones
|
|
108
|
+
* `EFFORT_LADDER` can place. Absent until a refusal lists them, and a model that lists nothing
|
|
109
|
+
* is walked up the ladder a rung per refusal instead.
|
|
110
|
+
*/
|
|
111
|
+
supportedEfforts?: string[];
|
|
96
112
|
/**
|
|
97
113
|
* Continues a trailing assistant message rather than answering afresh, which `continueTurn`
|
|
98
114
|
* relies on. llama.cpp renders one as a prefill and picks up mid-word; hosted OpenAI takes the
|
|
@@ -170,6 +186,30 @@ export declare function resetCapabilities(endpoint?: {
|
|
|
170
186
|
* @returns How many endpoints were forgotten.
|
|
171
187
|
*/
|
|
172
188
|
export declare function expireCapabilities(maxAgeMs: number, now?: number): number;
|
|
189
|
+
/**
|
|
190
|
+
* The efforts this package can place, cheapest first. A substitution only ever walks *up* it.
|
|
191
|
+
*
|
|
192
|
+
* Not a list of what any model takes — `medium` is refused by a model whose list is
|
|
193
|
+
* `minimal, low, high`, and `xhigh` is real and deliberately absent. It is the order the values
|
|
194
|
+
* OpenAI has shipped stand in, which is all a step needs to know. A value not on it cannot be
|
|
195
|
+
* placed, and an unplaceable value is left alone rather than guessed at: stepping down from a
|
|
196
|
+
* refused `xhigh` to `high` would quietly answer a question with less deliberation than whoever
|
|
197
|
+
* typed it asked for, where stepping up from `none` to `minimal` only costs tokens and says so.
|
|
198
|
+
*/
|
|
199
|
+
export declare const EFFORT_LADDER: readonly ["none", "minimal", "low", "medium", "high"];
|
|
200
|
+
/**
|
|
201
|
+
* What to actually put in `reasoning_effort` for a model that has refused the value asked for.
|
|
202
|
+
*
|
|
203
|
+
* The caller's own value, until this model has said that value is not one of its own; then the
|
|
204
|
+
* cheapest it will take that is at least as much deliberation. A model that takes no effort at
|
|
205
|
+
* all, and an absent or `"off"` setting, both answer the empty string, which is the body
|
|
206
|
+
* builders' signal to send no field.
|
|
207
|
+
*
|
|
208
|
+
* @param refused What the model has refused, as `negotiate` hands it over. Absent is a model that
|
|
209
|
+
* has refused nothing.
|
|
210
|
+
* @param asked What the config asks for. `"off"` and absent mean no effort.
|
|
211
|
+
*/
|
|
212
|
+
export declare function effortFor(refused: ModelCapabilities | undefined, asked: string | undefined): string;
|
|
173
213
|
/** What `negotiate` takes besides the request. All optional. */
|
|
174
214
|
export interface NegotiateOptions {
|
|
175
215
|
/**
|
package/dist/capabilities.js
CHANGED
|
@@ -56,6 +56,7 @@ export function modelCapabilitiesFor(supports, model) {
|
|
|
56
56
|
chosenTemperature: true,
|
|
57
57
|
refusedFields: new Set(),
|
|
58
58
|
structuredOutput: true,
|
|
59
|
+
refusedEfforts: new Set(),
|
|
59
60
|
assistantPrefill: true,
|
|
60
61
|
};
|
|
61
62
|
supports.models.set(model, known);
|
|
@@ -154,7 +155,8 @@ const rejectsResponseFormat = (detail) => /response_format|json_schema/i.test(de
|
|
|
154
155
|
* perfectly well; it was handed an effort off a list this package does not know. Dropping the
|
|
155
156
|
* field succeeds, at the model's own default effort, which is neither what the caller asked for
|
|
156
157
|
* nor something it can see — and the drop latches, so every later turn on that model reasons at
|
|
157
|
-
* the default with the setting still reading what the operator typed.
|
|
158
|
+
* the default with the setting still reading what the operator typed. `stepEffort` answers this
|
|
159
|
+
* one instead, by naming an effort the model does take.
|
|
158
160
|
*/
|
|
159
161
|
const REFUSED_VALUE = /unsupported value|invalid value|supported values/i;
|
|
160
162
|
/**
|
|
@@ -165,6 +167,127 @@ const REFUSED_VALUE = /unsupported value|invalid value|supported values/i;
|
|
|
165
167
|
* latching.
|
|
166
168
|
*/
|
|
167
169
|
const rejectsEffort = (detail) => /reasoning_effort/i.test(detail) && !REFUSED_VALUE.test(detail);
|
|
170
|
+
/**
|
|
171
|
+
* The efforts this package can place, cheapest first. A substitution only ever walks *up* it.
|
|
172
|
+
*
|
|
173
|
+
* Not a list of what any model takes — `medium` is refused by a model whose list is
|
|
174
|
+
* `minimal, low, high`, and `xhigh` is real and deliberately absent. It is the order the values
|
|
175
|
+
* OpenAI has shipped stand in, which is all a step needs to know. A value not on it cannot be
|
|
176
|
+
* placed, and an unplaceable value is left alone rather than guessed at: stepping down from a
|
|
177
|
+
* refused `xhigh` to `high` would quietly answer a question with less deliberation than whoever
|
|
178
|
+
* typed it asked for, where stepping up from `none` to `minimal` only costs tokens and says so.
|
|
179
|
+
*/
|
|
180
|
+
export const EFFORT_LADDER = ["none", "minimal", "low", "medium", "high"];
|
|
181
|
+
const rankOf = (effort) => EFFORT_LADDER.indexOf(effort.toLowerCase());
|
|
182
|
+
/**
|
|
183
|
+
* The efforts a refusal lists as this model's: `Supported values are: 'minimal', 'low', 'medium',
|
|
184
|
+
* and 'high'.` Quoted or bare, separated by commas and a trailing `and` or `or`.
|
|
185
|
+
*/
|
|
186
|
+
function listedEfforts(detail) {
|
|
187
|
+
const listed = detail.match(/supported values(?:\s+\w+)?\s*(?:are|is|include)?\s*:?\s*([^\n.]+)/i)?.[1];
|
|
188
|
+
if (!listed)
|
|
189
|
+
return undefined;
|
|
190
|
+
const values = listed
|
|
191
|
+
.split(/,|\band\b|\bor\b/)
|
|
192
|
+
.map((value) => value
|
|
193
|
+
.trim()
|
|
194
|
+
.replace(/^['"`]+|['"`]+$/g, "")
|
|
195
|
+
.toLowerCase())
|
|
196
|
+
.filter((value) => rankOf(value) >= 0);
|
|
197
|
+
return values.length ? values : undefined;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Which effort a refusal says was refused, in the two shapes the wording takes: the field quoted
|
|
201
|
+
* then `does not support 'none'`, and `reasoning_effort: none`.
|
|
202
|
+
*
|
|
203
|
+
* Read rather than remembered, because `negotiate` builds no request and so does not know what
|
|
204
|
+
* went out — and a proxy that rewrites the value before passing it on is refusing the one it sent
|
|
205
|
+
* rather than the one it was given. A refusal that names no value steps nothing: guessing which
|
|
206
|
+
* rung was refused is how a ladder walks past the value that would have worked.
|
|
207
|
+
*
|
|
208
|
+
* @param detail The refusal.
|
|
209
|
+
* @param supported What the same refusal listed, which the value cannot be one of.
|
|
210
|
+
*/
|
|
211
|
+
function refusedEffortValue(detail, supported = []) {
|
|
212
|
+
const found = detail.match(/reasoning_effort['"`]?\s*(?:does not support|is not supported with|:)\s*['"`]?([\w-]+)/i)?.[1];
|
|
213
|
+
const value = found?.toLowerCase();
|
|
214
|
+
return value && !supported.includes(value) ? value : undefined;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The cheapest effort above this one that the model has not refused, or `undefined` when the
|
|
218
|
+
* ladder is out of rungs.
|
|
219
|
+
*
|
|
220
|
+
* Above, never below: see `EFFORT_LADDER`. Where a refusal published a list that is the whole of
|
|
221
|
+
* what is tried, so the step lands in one request; where it published none the ladder is walked a
|
|
222
|
+
* rung at a time, each refusal latching the rung it named.
|
|
223
|
+
*/
|
|
224
|
+
function nextEffort(refused, asked) {
|
|
225
|
+
const rank = rankOf(asked);
|
|
226
|
+
if (rank < 0)
|
|
227
|
+
return undefined;
|
|
228
|
+
let best;
|
|
229
|
+
for (const value of refused.supportedEfforts ?? EFFORT_LADDER) {
|
|
230
|
+
const at = rankOf(value);
|
|
231
|
+
if (at <= rank || refused.refusedEfforts.has(value))
|
|
232
|
+
continue;
|
|
233
|
+
if (best === undefined || at < rankOf(best))
|
|
234
|
+
best = value;
|
|
235
|
+
}
|
|
236
|
+
return best;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* What to actually put in `reasoning_effort` for a model that has refused the value asked for.
|
|
240
|
+
*
|
|
241
|
+
* The caller's own value, until this model has said that value is not one of its own; then the
|
|
242
|
+
* cheapest it will take that is at least as much deliberation. A model that takes no effort at
|
|
243
|
+
* all, and an absent or `"off"` setting, both answer the empty string, which is the body
|
|
244
|
+
* builders' signal to send no field.
|
|
245
|
+
*
|
|
246
|
+
* @param refused What the model has refused, as `negotiate` hands it over. Absent is a model that
|
|
247
|
+
* has refused nothing.
|
|
248
|
+
* @param asked What the config asks for. `"off"` and absent mean no effort.
|
|
249
|
+
*/
|
|
250
|
+
export function effortFor(refused, asked) {
|
|
251
|
+
if (!asked || asked === "off")
|
|
252
|
+
return "";
|
|
253
|
+
if (!refused)
|
|
254
|
+
return asked;
|
|
255
|
+
if (!refused.reasoningEffort)
|
|
256
|
+
return "";
|
|
257
|
+
const known = refused.supportedEfforts;
|
|
258
|
+
const listed = known ? known.includes(asked.toLowerCase()) : true;
|
|
259
|
+
if (listed && !refused.refusedEfforts.has(asked.toLowerCase()))
|
|
260
|
+
return asked;
|
|
261
|
+
return nextEffort(refused, asked) ?? asked;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* How to answer a refused effort *value*, or `undefined` when there is nothing new to learn or
|
|
265
|
+
* nowhere left to go — in which case the refusal is the caller's, as it was before this existed.
|
|
266
|
+
*
|
|
267
|
+
* Pure, so `negotiate` can work out whether this refusal is one of its own before deciding to
|
|
268
|
+
* answer it, and latch only in the branch it takes.
|
|
269
|
+
*/
|
|
270
|
+
function planEffortStep(detail, refused) {
|
|
271
|
+
if (!refused.reasoningEffort)
|
|
272
|
+
return undefined;
|
|
273
|
+
if (!/reasoning_effort/i.test(detail) || !REFUSED_VALUE.test(detail))
|
|
274
|
+
return undefined;
|
|
275
|
+
const supported = listedEfforts(detail);
|
|
276
|
+
const value = refusedEffortValue(detail, supported);
|
|
277
|
+
if (value === undefined)
|
|
278
|
+
return undefined;
|
|
279
|
+
// Something has to be new, or a server that answers every request by naming an effort nobody
|
|
280
|
+
// sent would have `negotiate` re-send for as long as it kept saying it.
|
|
281
|
+
const fresh = supported?.join() !== refused.supportedEfforts?.join();
|
|
282
|
+
if (refused.refusedEfforts.has(value) && !fresh)
|
|
283
|
+
return undefined;
|
|
284
|
+
const next = nextEffort({
|
|
285
|
+
...refused,
|
|
286
|
+
refusedEfforts: new Set(refused.refusedEfforts).add(value),
|
|
287
|
+
supportedEfforts: supported ?? refused.supportedEfforts,
|
|
288
|
+
}, value);
|
|
289
|
+
return next === undefined ? undefined : { value, next, ...(supported ? { supported } : {}) };
|
|
290
|
+
}
|
|
168
291
|
/**
|
|
169
292
|
* Read only alongside the name it is asking for: `'max_tokens' is not supported with this model.
|
|
170
293
|
* Use 'max_completion_tokens' instead.`
|
|
@@ -280,6 +403,7 @@ export async function negotiate(supports, send, { produced = { any: false }, onN
|
|
|
280
403
|
if (produced.any)
|
|
281
404
|
throw error;
|
|
282
405
|
const detail = errorMessage(error);
|
|
406
|
+
const stepped = named && planEffortStep(detail, named.refused);
|
|
283
407
|
if (supports.strictSchemas && isGrammarError(detail)) {
|
|
284
408
|
supports.strictSchemas = false;
|
|
285
409
|
onNotice?.("server could not build a grammar; retrying without pattern/format");
|
|
@@ -292,6 +416,12 @@ export async function negotiate(supports, send, { produced = { any: false }, onN
|
|
|
292
416
|
named.refused.reasoningEffort = false;
|
|
293
417
|
onNotice?.(`${named.name} does not take a reasoning effort; retrying without one`);
|
|
294
418
|
}
|
|
419
|
+
else if (named && stepped) {
|
|
420
|
+
named.refused.refusedEfforts.add(stepped.value);
|
|
421
|
+
if (stepped.supported)
|
|
422
|
+
named.refused.supportedEfforts = stepped.supported;
|
|
423
|
+
onNotice?.(`${named.name} does not reason at ${stepped.value}; retrying at ${stepped.next}`);
|
|
424
|
+
}
|
|
295
425
|
else if (named?.refused.legacyTokenLimit && wantsCompletionLimit(detail)) {
|
|
296
426
|
named.refused.legacyTokenLimit = false;
|
|
297
427
|
onNotice?.(`${named.name} wants max_completion_tokens; retrying with the limit spelled that way`);
|
package/dist/index.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopResult, buildBody, preselect, preview, resolveApiKey, runAgentLoop, type ToolCallOutcome, type ToolCallRequest, } from "./agent-loop.ts";
|
|
13
13
|
export { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.ts";
|
|
14
|
-
export { type Capabilities, capabilitiesFor, expireCapabilities, type ModelCapabilities, modelCapabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from "./capabilities.ts";
|
|
14
|
+
export { type Capabilities, capabilitiesFor, EFFORT_LADDER, effortFor, expireCapabilities, type ModelCapabilities, modelCapabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from "./capabilities.ts";
|
|
15
15
|
export type { CatalogServer } from "./catalog.ts";
|
|
16
16
|
export { type ClientPoolOptions, configureClients, contextLimitFor, endpointId, endpointKey, FIRST_TOKEN_FACTOR, firstTokenMs, getClient, listModels, type ModelInfo, NO_KEY, resetClients, servedWindow, timeoutMs, } from "./client.ts";
|
|
17
17
|
export { applyCompaction, COMPACT_AT, type CompactionOptions, type CompactionPlan, type CompactionRecord, type CompactionRunOptions, compactTranscript, KEEP_RATIO, type PruneOptions, planCompaction, pruneToolResults, requestIndex, runCompaction, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.ts";
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export { buildBody, preselect, preview, resolveApiKey, runAgentLoop, } from "./agent-loop.js";
|
|
13
13
|
export { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.js";
|
|
14
|
-
export { capabilitiesFor, expireCapabilities, modelCapabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.js";
|
|
14
|
+
export { capabilitiesFor, EFFORT_LADDER, effortFor, expireCapabilities, modelCapabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.js";
|
|
15
15
|
export { configureClients, contextLimitFor, endpointId, endpointKey, FIRST_TOKEN_FACTOR, firstTokenMs, getClient, listModels, NO_KEY, resetClients, servedWindow, timeoutMs, } from "./client.js";
|
|
16
16
|
export { applyCompaction, COMPACT_AT, compactTranscript, KEEP_RATIO, planCompaction, pruneToolResults, requestIndex, runCompaction, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.js";
|
|
17
17
|
export { continueTurn, isContinuable, } from "./continuation.js";
|
package/dist/side-task.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import OpenAI from "openai";
|
|
2
|
-
import { capabilitiesFor, modelCapabilitiesFor, negotiate, } from "./capabilities.js";
|
|
2
|
+
import { capabilitiesFor, effortFor, modelCapabilitiesFor, negotiate, } from "./capabilities.js";
|
|
3
3
|
import { endpointId, getClient } from "./client.js";
|
|
4
4
|
import { errorMessage } from "./errors.js";
|
|
5
5
|
import { isTransient } from "./retry.js";
|
|
@@ -85,7 +85,11 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
|
|
|
85
85
|
// Whether the last request carried an effort, which `negotiate` decides and not this function.
|
|
86
86
|
let sentEffort = false;
|
|
87
87
|
const send = (hints, effort, supports, refused) => {
|
|
88
|
-
|
|
88
|
+
// `none` is what a side task wants and not always what the model offers: OpenAI's reasoning
|
|
89
|
+
// models refuse it by value and list `minimal` as their floor. `effortFor` answers with the
|
|
90
|
+
// cheapest rung this one takes, which `negotiate` has been stepping up as it was refused.
|
|
91
|
+
const asked = effort ? effortFor(refused, "none") : "";
|
|
92
|
+
sentEffort = asked !== "";
|
|
89
93
|
return getClient(config).chat.completions.create({
|
|
90
94
|
model,
|
|
91
95
|
// The reasoning models want the ceiling spelled the other way, and they are exactly the
|
|
@@ -103,7 +107,7 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
|
|
|
103
107
|
...(hints ? NO_THINKING : {}),
|
|
104
108
|
// Not gated on `hints`: a model that refuses `chat_template_kwargs` may still read the
|
|
105
109
|
// effort, and the two latches would otherwise contradict each other.
|
|
106
|
-
...(sentEffort ? { reasoning_effort:
|
|
110
|
+
...(sentEffort ? { reasoning_effort: asked } : {}),
|
|
107
111
|
...(format && refused ? format(supports, refused) : {}),
|
|
108
112
|
}, { signal });
|
|
109
113
|
};
|
package/dist/snapshot.d.ts
CHANGED
|
@@ -22,6 +22,14 @@ export interface ModelSnapshot {
|
|
|
22
22
|
assistantPrefill: boolean;
|
|
23
23
|
/** Takes the no-thinking hints `ask` sends. */
|
|
24
24
|
thinkingHints: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Efforts refused by value rather than by field, so a restart does not spend a request per rung
|
|
27
|
+
* walking the ladder again. Absent in a snapshot taken before they were latched, which reads as
|
|
28
|
+
* none refused.
|
|
29
|
+
*/
|
|
30
|
+
refusedEfforts?: string[];
|
|
31
|
+
/** The efforts a refusal published as this model's, in ladder order. Absent is none published. */
|
|
32
|
+
supportedEfforts?: string[];
|
|
25
33
|
}
|
|
26
34
|
/** What one endpoint refused, and under it what each of its models did. */
|
|
27
35
|
export interface EndpointSnapshot {
|
package/dist/snapshot.js
CHANGED
|
@@ -18,6 +18,7 @@ const optimisticModel = () => ({
|
|
|
18
18
|
structuredOutput: true,
|
|
19
19
|
assistantPrefill: true,
|
|
20
20
|
thinkingHints: true,
|
|
21
|
+
refusedEfforts: [],
|
|
21
22
|
});
|
|
22
23
|
const refusedAnything = (model) => !model.reasoningEffort ||
|
|
23
24
|
!model.legacyTokenLimit ||
|
|
@@ -25,7 +26,8 @@ const refusedAnything = (model) => !model.reasoningEffort ||
|
|
|
25
26
|
!model.thinkingHints ||
|
|
26
27
|
!model.structuredOutput ||
|
|
27
28
|
!model.assistantPrefill ||
|
|
28
|
-
model.refusedFields.length > 0
|
|
29
|
+
model.refusedFields.length > 0 ||
|
|
30
|
+
(model.refusedEfforts?.length ?? 0) > 0;
|
|
29
31
|
/**
|
|
30
32
|
* Every refusal this process has latched, as a JSON-safe blob to store and hand back on boot.
|
|
31
33
|
*
|
|
@@ -59,6 +61,10 @@ export function exportCapabilities() {
|
|
|
59
61
|
refusedFields: [...refused.refusedFields].sort(),
|
|
60
62
|
structuredOutput: refused.structuredOutput,
|
|
61
63
|
assistantPrefill: refused.assistantPrefill,
|
|
64
|
+
refusedEfforts: [...refused.refusedEfforts].sort(),
|
|
65
|
+
// Only alongside a refusal, since on its own a published list latches nothing: the model
|
|
66
|
+
// named it while refusing a rung, and that rung is in `refusedEfforts`.
|
|
67
|
+
...(refused.supportedEfforts ? { supportedEfforts: [...refused.supportedEfforts] } : {}),
|
|
62
68
|
};
|
|
63
69
|
if (refusedAnything(model))
|
|
64
70
|
models[name] = model;
|
|
@@ -128,6 +134,19 @@ export function importCapabilities(snapshot) {
|
|
|
128
134
|
refused.structuredOutput = false;
|
|
129
135
|
if (model.assistantPrefill === false)
|
|
130
136
|
refused.assistantPrefill = false;
|
|
137
|
+
if (Array.isArray(model.refusedEfforts)) {
|
|
138
|
+
for (const effort of model.refusedEfforts) {
|
|
139
|
+
if (typeof effort === "string")
|
|
140
|
+
refused.refusedEfforts.add(effort);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Replaced rather than merged: two lists of what one model takes are two readings of the
|
|
144
|
+
// same fact, and the stored one is at least as recent as an empty absent.
|
|
145
|
+
if (Array.isArray(model.supportedEfforts)) {
|
|
146
|
+
const listed = model.supportedEfforts.filter((value) => typeof value === "string");
|
|
147
|
+
if (listed.length)
|
|
148
|
+
refused.supportedEfforts = listed;
|
|
149
|
+
}
|
|
131
150
|
if (Array.isArray(model.refusedFields)) {
|
|
132
151
|
for (const field of model.refusedFields) {
|
|
133
152
|
if (typeof field === "string")
|
package/llms.txt
CHANGED
|
@@ -38,6 +38,8 @@ What an endpoint turned out not to support, and answering it when it says so.
|
|
|
38
38
|
|
|
39
39
|
- `Capabilities` (type) — What one endpoint turned out not to support.
|
|
40
40
|
- `capabilitiesFor` — What this endpoint is known not to support.
|
|
41
|
+
- `EFFORT_LADDER` — The efforts this package can place, cheapest first.
|
|
42
|
+
- `effortFor` — What to actually put in `reasoning_effort` for a model that has refused the value asked for.
|
|
41
43
|
- `expireCapabilities` — Forgets every endpoint whose entry is older than this, so the next request finds out again.
|
|
42
44
|
- `ModelCapabilities` (type) — What one model on that endpoint turned out not to support.
|
|
43
45
|
- `modelCapabilitiesFor` — What this model on this endpoint is known not to support.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cubicecho/agent-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.0",
|
|
4
4
|
"description": "The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openai",
|