@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 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: an effort off a list
136
- this package does not know, a `max_tokens` larger than the model's ceiling, a temperature out of
137
- range. Dropping the field answers those too — at the model's own default, latched for the rest of
138
- the process, with the settings row still reading what was typed and nothing saying it had stopped
139
- meaning it. They are passed to the caller instead, where whoever typed the number can see it.
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(supports, (supports, produced, model) =>
143
- streamTurn(
144
- getClient(config),
145
- {
146
- model: name, messages, stream: true,
147
- // Each rebuilt per attempt from what this model has already refused.
148
- ...(model?.reasoningEffort ? { reasoning_effort: effort } : {}),
149
- ...(model?.legacyTokenLimit === false
150
- ? { max_completion_tokens: limit }
151
- : { max_tokens: limit }),
152
- ...(model?.chosenTemperature ? { temperature } : {}),
153
- tools: supports.strictSchemas ? declared : relaxTools(declared),
154
- },
155
- { produced, signal },
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
- `parallel: true`, which also makes an identical call — the same name and arguments, byte for byte
375
- once for the run. A call that threw is forgotten rather than cached, so asking again is a real
376
- retry. What a tool throws is what the model reads, and so are arguments that did not parse.
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 parallel dedupe compares repaired arguments,
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
@@ -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, and makes an identical call —
156
- * the same name and arguments, word for word once for the run, handing a repeat the first
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
  /**
@@ -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 && effort !== "off" && refused?.reasoningEffort !== false
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 = parallel
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. A call that rejected is forgotten, so asking again is a
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);
@@ -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
  /**
@@ -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
- sentEffort = effort && refused?.reasoningEffort !== false;
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: "none" } : {}),
110
+ ...(sentEffort ? { reasoning_effort: asked } : {}),
107
111
  ...(format && refused ? format(supports, refused) : {}),
108
112
  }, { signal });
109
113
  };
@@ -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.14.0",
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",