@cubicecho/agent-core 4.2.0 → 4.4.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
@@ -268,6 +268,24 @@ const page: SideTaskInput = [
268
268
  const text = await ask(config, visionModel, "You are an OCR engine.", page);
269
269
  ```
270
270
 
271
+ ### Telling a finished reply from a cut-off one
272
+
273
+ `ask` returns the text alone, so a reply that ran into `maxTokens` reads like one that finished.
274
+ Where the reply is the product, `askReply` takes the same arguments and also returns the choice's
275
+ `finish_reason` as the server sent it, or `undefined` where it sent none. A model looping on a page
276
+ writes until the ceiling stops it, and a transcript saved from that is worse than none:
277
+
278
+ ```ts
279
+ const { text, finishReason } = await askReply(config, visionModel, "You are an OCR engine.", page);
280
+ if (finishReason === FinishReason.Length) {
281
+ // cut off: retry with a higher ceiling, or keep the OCR text already there
282
+ }
283
+ ```
284
+
285
+ Nothing is thrown for a cut-off reply, since whether one is still worth keeping is the caller's
286
+ call. `askJson` needs no such variant: a reply cut off before its JSON closed does not parse, so it
287
+ comes back `undefined`, as a reply with no JSON in it does.
288
+
271
289
  ## Sizing a request before sending it
272
290
 
273
291
  `runTurn` will refuse a request that cannot fit rather than spending a round trip finding out:
@@ -588,7 +606,7 @@ one just over it means the ranking chose arbitrarily, which is the case a model
588
606
  on. Matching nothing is not confident either, since the words cannot tell a request that needs no
589
607
  tools from one whose words are not in the catalogue. An empty `toolSelectModel` still means no
590
608
  preselection at all, words included. `preselect` also takes a `temperature` and a
591
- `reasoningEffort`, read as `ask` reads them, and `runAgentLoop({ preselect: true })` makes the call
609
+ `reasoningEffort` and an `extraBody`, read as `ask` reads them, and `runAgentLoop({ preselect: true })` makes the call
592
610
  itself — see "Its side tasks" below.
593
611
 
594
612
  A turn cut off at `maxTokens` is said so as a notice, and with `maxContinuations` above zero it is
@@ -1196,7 +1214,7 @@ comes from.
1196
1214
  ### Its side tasks
1197
1215
 
1198
1216
  `tasks.<key>` configures a side task apart from the main model — its own model, endpoint, ceiling,
1199
- temperature and reasoning effort — and resolution hands each back as a `ResolvedTask` holding only
1217
+ temperature, reasoning effort and `extraBody` — and resolution hands each back as a `ResolvedTask` holding only
1200
1218
  what a layer stated. `taskCall` turns one into the arguments `ask`, `askJson`, `summariser` and
1201
1219
  `preselect` already take, so a setting added to a task later arrives without the host's call
1202
1220
  changing:
@@ -1217,6 +1235,13 @@ no-thinking hints by default, and absent or `"off"` keeps them; a level — `"lo
1217
1235
  sent as `reasoning_effort` in their place, for a compaction model an operator pointed at a model
1218
1236
  that should deliberate.
1219
1237
 
1238
+ `extraBody` carries the sampler fields a model card asks for, as it does on the main model: `ask`,
1239
+ `askJson`, `summariser` and `preselect` all take one in their options, merge it in after the
1240
+ temperature, and offer its keys to `negotiate` as `droppable`, so a field the endpoint refuses by
1241
+ name is latched off for that model and the request is sent again without it. The agent's own
1242
+ `model.extraBody` is never sent to a side task, and `taskCall` lays a task's over the host's key by
1243
+ key.
1244
+
1220
1245
  The third argument is the endpoint the main turn uses. Given it, a task on the same endpoint is
1221
1246
  sent the agent's spelling of the URL and, having no key of its own, the agent's key, so the two
1222
1247
  share one entry in `capabilitiesFor` and a refusal learned by either is known to both; a task on
@@ -1,4 +1,4 @@
1
- import type { EndpointIdentity } from '../core/config.ts';
1
+ import { type EndpointIdentity } from '../core/config.ts';
2
2
  import type { Produced } from '../wire/stream.ts';
3
3
  /**
4
4
  * What an endpoint turned out not to support, and answering it when it says so.
@@ -307,6 +307,20 @@ export declare const ceilingAndTemperature: (refused: ModelCapabilities | undefi
307
307
  } | {
308
308
  temperature?: number | undefined;
309
309
  };
310
+ /**
311
+ * The request fields an `extraBody` adds as this model takes them, to spread into a request body.
312
+ *
313
+ * @param refused - What the model has refused, as `negotiate` hands it over. Absent is a model that
314
+ * has refused nothing.
315
+ * @param [extraBody] - The fields to add. Absent adds none.
316
+ * @returns A fresh object of the fields, less the `RESERVED_BODY_FIELDS` and those the model has
317
+ * refused by name.
318
+ *
319
+ * @remarks
320
+ * The keys are what a caller hands `negotiate` as `droppable`, so a field the endpoint has never
321
+ * heard of is latched into `refusedFields` and left out of the next send.
322
+ */
323
+ export declare const extraFields: (refused: ModelCapabilities | undefined, extraBody?: Record<string, unknown>) => Record<string, unknown>;
310
324
  /** What `negotiate` takes besides the request. All optional. */
311
325
  export interface NegotiateOptions {
312
326
  /**
@@ -1,3 +1,4 @@
1
+ import { RESERVED_BODY_FIELDS } from '../core/config.js';
1
2
  import { getOrCreate } from '../core/guards.js';
2
3
  import { scoped } from '../core/scope.js';
3
4
  import { errorMessage } from '../wire/errors.js';
@@ -353,6 +354,20 @@ export const ceilingAndTemperature = (refused, maxTokens, temperature) => {
353
354
  ...(refused?.chosenTemperature === false ? {} : { temperature }),
354
355
  };
355
356
  };
357
+ /**
358
+ * The request fields an `extraBody` adds as this model takes them, to spread into a request body.
359
+ *
360
+ * @param refused - What the model has refused, as `negotiate` hands it over. Absent is a model that
361
+ * has refused nothing.
362
+ * @param [extraBody] - The fields to add. Absent adds none.
363
+ * @returns A fresh object of the fields, less the `RESERVED_BODY_FIELDS` and those the model has
364
+ * refused by name.
365
+ *
366
+ * @remarks
367
+ * The keys are what a caller hands `negotiate` as `droppable`, so a field the endpoint has never
368
+ * heard of is latched into `refusedFields` and left out of the next send.
369
+ */
370
+ export const extraFields = (refused, extraBody = {}) => Object.fromEntries(Object.entries(extraBody).filter(([field]) => !RESERVED_BODY_FIELDS.includes(field) && !refused?.refusedFields.has(field)));
356
371
  /**
357
372
  * How to answer a refused effort *value*, or `undefined` when there is nothing new to learn or
358
373
  * nowhere left to go — in which case the refusal is the caller's, as it was before this existed.
package/dist/index.d.ts CHANGED
@@ -31,7 +31,7 @@ export { type KeywordPreselection, type KeywordPreselectOptions, PRESELECT_SCHEM
31
31
  export { CALL_TOOL, carryOver, catalogList, catalogPrompt, expandNames, holdsDefinitions, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, orderTools, PROXY_TOOLS, proxiedCall, proxyCatalogPrompt, proxyLoadResult, requestedNames, shownCall, type ToolOrder, } from './tools/tool-loading.ts';
32
32
  export { type ContinueTurnOptions, continueTurn, } from './turn/continuation.ts';
33
33
  export { type RunTurnOptions, runTurn } from './turn/run-turn.ts';
34
- export { type AskJsonOptions, ask, askJson, clean, listLines, parseJson, resetHints, type SideTask, type SideTaskInput, type SideTaskOptions, taskCall, tryAsk, } from './turn/side-task.ts';
34
+ export { type AskJsonOptions, ask, askJson, askReply, clean, listLines, parseJson, resetHints, type SideTask, type SideTaskInput, type SideTaskOptions, type SideTaskReply, taskCall, tryAsk, } from './turn/side-task.ts';
35
35
  export { AgentLoopError, type AgentLoopFailure, AgentLoopOverflow, errorMessage, failedRun, ToolIterationLimit, } from './wire/errors.ts';
36
36
  export { backoffMs, ContextOverflow, EndpointSilent, isModelLoading, isOverflow, isTransient, SMALLEST_LIKELY_WINDOW, } from './wire/retry.ts';
37
37
  export { CacheBreakReason, type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from './wire/stream.ts';
package/dist/index.js CHANGED
@@ -30,7 +30,7 @@ export { PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselect, preselectByKeywords, pre
30
30
  export { CALL_TOOL, carryOver, catalogList, catalogPrompt, expandNames, holdsDefinitions, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, orderTools, PROXY_TOOLS, proxiedCall, proxyCatalogPrompt, proxyLoadResult, requestedNames, shownCall, } from './tools/tool-loading.js';
31
31
  export { continueTurn, } from './turn/continuation.js';
32
32
  export { runTurn } from './turn/run-turn.js';
33
- export { ask, askJson, clean, listLines, parseJson, resetHints, taskCall, tryAsk, } from './turn/side-task.js';
33
+ export { ask, askJson, askReply, clean, listLines, parseJson, resetHints, taskCall, tryAsk, } from './turn/side-task.js';
34
34
  export { AgentLoopError, AgentLoopOverflow, errorMessage, failedRun, ToolIterationLimit, } from './wire/errors.js';
35
35
  export { backoffMs, ContextOverflow, EndpointSilent, isModelLoading, isOverflow, isTransient, SMALLEST_LIKELY_WINDOW, } from './wire/retry.js';
36
36
  export { CacheBreakReason, streamTurn, } from './wire/stream.js';
@@ -1,5 +1,5 @@
1
1
  import type OpenAI from 'openai';
2
- import { type ModelParams } from '../core/config.ts';
2
+ import type { ModelParams } from '../core/config.ts';
3
3
  import { type Capabilities, type ModelCapabilities } from '../endpoint/capabilities.ts';
4
4
  import { type ToolOrder } from '../tools/tool-loading.ts';
5
5
  /**
@@ -1,5 +1,4 @@
1
- import { RESERVED_BODY_FIELDS } from '../core/config.js';
2
- import { ceilingAndTemperature, effortFor, } from '../endpoint/capabilities.js';
1
+ import { ceilingAndTemperature, effortFor, extraFields, } from '../endpoint/capabilities.js';
3
2
  import { relaxTools, sanitizeTools } from '../endpoint/schema-compat.js';
4
3
  import { orderTools } from '../tools/tool-loading.js';
5
4
  /**
@@ -33,12 +32,11 @@ export function buildBody(config, supports, refused, messages, tools = [], order
33
32
  const sorted = orderTools(tools, order);
34
33
  const declared = supports.strictSchemas ? sanitizeTools(sorted) : relaxTools(sanitizeTools(sorted));
35
34
  const effort = effortFor(refused, config.reasoningEffort);
36
- const extra = Object.entries(config.extraBody ?? {}).filter(([field]) => !RESERVED_BODY_FIELDS.includes(field) && !refused?.refusedFields.has(field));
37
35
  return {
38
36
  ...ceilingAndTemperature(refused, config.maxTokens, config.temperature),
39
37
  ...(effort ? { reasoning_effort: effort } : {}),
40
38
  ...(supports.usageInStream ? { stream_options: { include_usage: true } } : {}),
41
- ...Object.fromEntries(extra),
39
+ ...extraFields(refused, config.extraBody),
42
40
  model: config.model,
43
41
  messages,
44
42
  stream: true,
@@ -151,6 +151,8 @@ export interface TaskSpec {
151
151
  * side task is sent by default; see `SideTaskOptions.reasoningEffort`.
152
152
  */
153
153
  reasoningEffort?: string;
154
+ /** Request fields the interface cannot spell, as `model.extraBody` but for this task alone. */
155
+ extraBody?: Record<string, unknown>;
154
156
  }
155
157
  /** A tool call bound to a lifecycle event, run by the host rather than the model. */
156
158
  export interface AgentHook {
@@ -198,7 +200,7 @@ export interface SpecBundle {
198
200
  * One side task, resolved: a model, and the endpoint it is reached through.
199
201
  *
200
202
  * @remarks
201
- * The three settings are present only where a layer stated them, and nothing is filled in from
203
+ * The settings are present only where a layer stated them, and nothing is filled in from
202
204
  * the agent's own model — `taskCall` turns one into a call, and what is absent there is left to
203
205
  * the side task's default.
204
206
  */
@@ -209,6 +211,8 @@ export interface ResolvedTask {
209
211
  maxTokens?: number;
210
212
  temperature?: number;
211
213
  reasoningEffort?: string;
214
+ /** Merged key by key across the layers. `model.extraBody` is the agent's and never reaches it. */
215
+ extraBody?: Record<string, unknown>;
212
216
  }
213
217
  /**
214
218
  * What layered documents resolve to: the flat shape the loop already takes, plus the parts of an
package/dist/spec/spec.js CHANGED
@@ -241,10 +241,6 @@ const parseSampling = (report, path, value) => ({
241
241
  * @param value - What the document held under `model`.
242
242
  * @returns The fields that survived — an empty object where none did — or `undefined` where the
243
243
  * section is absent or is not an object.
244
- *
245
- * @remarks
246
- * `extraBody` comes back as a new object without the `RESERVED_BODY_FIELDS`, each of which is
247
- * warned about. An empty one survives as an empty one.
248
244
  */
249
245
  function parseModel(report, value) {
250
246
  if (absent(value)) {
@@ -253,31 +249,41 @@ function parseModel(report, value) {
253
249
  if (!isRecord(value)) {
254
250
  return void report.drop('model', 'must be an object');
255
251
  }
256
- let extraBody;
257
- if (!absent(value.extraBody)) {
258
- if (!isRecord(value.extraBody)) {
259
- report.drop('model.extraBody', 'must be an object');
260
- }
261
- else {
262
- extraBody = {};
263
- for (const [key, held] of Object.entries(value.extraBody)) {
264
- // `buildBody` would overwrite them anyway; better to say so on import.
265
- if (RESERVED_BODY_FIELDS.includes(key)) {
266
- report.drop(`model.extraBody.${key}`, "is the loop's to set");
267
- }
268
- else {
269
- extraBody[key] = held;
270
- }
271
- }
272
- }
273
- }
274
252
  return kept({
275
253
  model: report.string('model.model', value.model),
276
254
  ...parseSampling(report, 'model', value),
277
255
  contextLength: report.number('model.contextLength', value.contextLength, 0),
278
- extraBody,
256
+ extraBody: parseExtraBody(report, 'model.extraBody', value.extraBody),
279
257
  });
280
258
  }
259
+ /**
260
+ * Reads an `extraBody`, the agent's model's or a side task's.
261
+ *
262
+ * @param report - Where what is dropped is written.
263
+ * @param path - Where it sits in the document: `model.extraBody`, or `tasks.title.extraBody`.
264
+ * @param value - What the document held there.
265
+ * @returns A new object without the `RESERVED_BODY_FIELDS`, each of which is warned about, or
266
+ * `undefined` where it is absent or is not an object. An empty one survives as an empty one.
267
+ */
268
+ function parseExtraBody(report, path, value) {
269
+ if (absent(value)) {
270
+ return undefined;
271
+ }
272
+ if (!isRecord(value)) {
273
+ return void report.drop(path, 'must be an object');
274
+ }
275
+ const extraBody = {};
276
+ for (const [key, held] of Object.entries(value)) {
277
+ // The request is built around them and they would be overwritten anyway; better to say so on import.
278
+ if (RESERVED_BODY_FIELDS.includes(key)) {
279
+ report.drop(`${path}.${key}`, "is the loop's to set");
280
+ }
281
+ else {
282
+ extraBody[key] = held;
283
+ }
284
+ }
285
+ return extraBody;
286
+ }
281
287
  /**
282
288
  * Reads the `prompt` array, part by part.
283
289
  *
@@ -429,6 +435,7 @@ function parseTasks(report, value) {
429
435
  model: report.string(`${path}.model`, held.model),
430
436
  endpoint: parseEndpoint(report, `${path}.endpoint`, held.endpoint),
431
437
  ...parseSampling(report, path, held),
438
+ extraBody: parseExtraBody(report, `${path}.extraBody`, held.extraBody),
432
439
  });
433
440
  }
434
441
  return tasks;
@@ -767,16 +774,18 @@ function mergeById(items, idOf) {
767
774
  * @param layers - The documents, weakest first. None is written to.
768
775
  * @param endpoint - The agent's merged endpoint. Read, not written to.
769
776
  * @returns The tasks whose merged `model` is not empty, keyed as the documents keyed them — one a
770
- * layer declined with `model: ""` is not among them.
777
+ * layer declined with `model: ""` is not among them. A task's `extraBody` merges by key.
771
778
  */
772
779
  function resolveTasks(layers, endpoint) {
773
780
  const merged = new Map();
774
781
  for (const layer of layers) {
775
782
  for (const [key, task] of Object.entries(layer.tasks ?? {})) {
783
+ const below = merged.get(key);
776
784
  merged.set(key, {
777
- ...(merged.get(key) ?? {}),
785
+ ...below,
778
786
  ...task,
779
- endpoint: mergeLayers([merged.get(key)?.endpoint, task.endpoint]),
787
+ endpoint: mergeLayers([below?.endpoint, task.endpoint]),
788
+ extraBody: mergeLayers([below?.extraBody, task.extraBody]),
780
789
  });
781
790
  }
782
791
  }
@@ -793,6 +802,7 @@ function resolveTasks(layers, endpoint) {
793
802
  maxTokens: task.maxTokens,
794
803
  temperature: task.temperature,
795
804
  reasoningEffort: task.reasoningEffort,
805
+ extraBody: task.extraBody && said(task.extraBody) ? task.extraBody : undefined,
796
806
  });
797
807
  }
798
808
  return tasks;
@@ -158,6 +158,8 @@ export interface PreselectOptions {
158
158
  temperature?: number;
159
159
  /** As `SideTaskOptions.reasoningEffort`: absent keeps the no-thinking hints. */
160
160
  reasoningEffort?: string;
161
+ /** As `SideTaskOptions.extraBody`: sampler fields the options cannot spell. */
162
+ extraBody?: Record<string, unknown>;
161
163
  /**
162
164
  * The most tools the choice may name.
163
165
  *
@@ -181,8 +183,8 @@ export interface PreselectOptions {
181
183
  * `toolSelectModel: settings.toolSelectModel || settings.model`.
182
184
  * @param catalog - The servers to choose from.
183
185
  * @param prompt - The request being planned for. Only its head is read; see `preselectInput`.
184
- * @param [options] - Cancellation, notices, the reply ceiling, the temperature and reasoning effort
185
- * as `ask` reads them, the cap the choice is held to, and whether to try the words first.
186
+ * @param [options] - Cancellation, notices, the reply ceiling, the temperature, reasoning effort and
187
+ * extra fields as `ask` reads them, the cap the choice is held to, and whether to try the words first.
186
188
  * @returns Names as the catalogue spells them, never more than `maxPerLoad`. Empty where `model` is
187
189
  * empty, the catalogue has no tools, the call failed, or its reply named nothing in the catalogue.
188
190
  *
@@ -198,4 +200,4 @@ export interface PreselectOptions {
198
200
  * starting now and starting in a few seconds. The words have to be clear about it; see
199
201
  * `preselectByKeywords` for what that means.
200
202
  */
201
- export declare function preselect(config: Endpoint, model: string, catalog: CatalogServer[], prompt: string, { signal, onNotice, maxTokens, temperature, reasoningEffort, maxPerLoad, keywords, }?: PreselectOptions): Promise<string[]>;
203
+ export declare function preselect(config: Endpoint, model: string, catalog: CatalogServer[], prompt: string, { signal, onNotice, maxTokens, temperature, reasoningEffort, extraBody, maxPerLoad, keywords, }?: PreselectOptions): Promise<string[]>;
@@ -248,8 +248,8 @@ export function preselectByKeywords(catalog, prompt, { maxPerLoad = MAX_PER_LOAD
248
248
  * `toolSelectModel: settings.toolSelectModel || settings.model`.
249
249
  * @param catalog - The servers to choose from.
250
250
  * @param prompt - The request being planned for. Only its head is read; see `preselectInput`.
251
- * @param [options] - Cancellation, notices, the reply ceiling, the temperature and reasoning effort
252
- * as `ask` reads them, the cap the choice is held to, and whether to try the words first.
251
+ * @param [options] - Cancellation, notices, the reply ceiling, the temperature, reasoning effort and
252
+ * extra fields as `ask` reads them, the cap the choice is held to, and whether to try the words first.
253
253
  * @returns Names as the catalogue spells them, never more than `maxPerLoad`. Empty where `model` is
254
254
  * empty, the catalogue has no tools, the call failed, or its reply named nothing in the catalogue.
255
255
  *
@@ -265,7 +265,7 @@ export function preselectByKeywords(catalog, prompt, { maxPerLoad = MAX_PER_LOAD
265
265
  * starting now and starting in a few seconds. The words have to be clear about it; see
266
266
  * `preselectByKeywords` for what that means.
267
267
  */
268
- export async function preselect(config, model, catalog, prompt, { signal, onNotice, maxTokens = 256, temperature, reasoningEffort, maxPerLoad = MAX_PER_LOAD, keywords, } = {}) {
268
+ export async function preselect(config, model, catalog, prompt, { signal, onNotice, maxTokens = 256, temperature, reasoningEffort, extraBody, maxPerLoad = MAX_PER_LOAD, keywords, } = {}) {
269
269
  if (!model || !catalog.some((server) => server.tools.length > 0)) {
270
270
  return [];
271
271
  }
@@ -285,6 +285,7 @@ export async function preselect(config, model, catalog, prompt, { signal, onNoti
285
285
  maxTokens,
286
286
  temperature,
287
287
  reasoningEffort,
288
+ extraBody,
288
289
  signal,
289
290
  onNotice,
290
291
  }), { onNotice, signal });
@@ -55,6 +55,16 @@ export interface SideTaskOptions {
55
55
  * refused it by value, and `chat_template_kwargs` is left out of the request altogether.
56
56
  */
57
57
  reasoningEffort?: string;
58
+ /**
59
+ * Request fields these options cannot spell, as `ModelParams.extraBody` is for the main model:
60
+ * `top_p`, `top_k`, `min_p` and the penalties a model card asks for.
61
+ *
62
+ * @remarks
63
+ * Merged in after the ceiling, temperature and thinking fields, so it may override them, and
64
+ * before `askJson`'s `response_format`. `model`, `messages`, `stream` and `tools` are dropped. A
65
+ * field the endpoint refuses by name is latched for the model and the request sent again without it.
66
+ */
67
+ extraBody?: Record<string, unknown>;
58
68
  /** Abandons the call, usually because the run it supports has gone away. */
59
69
  signal?: AbortSignal;
60
70
  /**
@@ -97,6 +107,8 @@ export interface SideTask {
97
107
  temperature?: number;
98
108
  /** As `SideTaskOptions.reasoningEffort`: a level replaces the no-thinking hints. */
99
109
  reasoningEffort?: string;
110
+ /** As `SideTaskOptions.extraBody`: the sampler fields a model card asks for, sent as given. */
111
+ extraBody?: Record<string, unknown>;
100
112
  }
101
113
  /**
102
114
  * Runs a side task and returns the reply text, thinking stripped. Throws like any request.
@@ -105,11 +117,38 @@ export interface SideTask {
105
117
  * @param model - The model to ask, usually smaller than the one running the work.
106
118
  * @param system - The instruction.
107
119
  * @param user - The input it applies to. Content parts where the model is being shown an image.
108
- * @param [options] - Reply ceiling, temperature, a reasoning effort, cancellation, notices.
120
+ * @param [options] - Reply ceiling, temperature, a reasoning effort, extra fields, cancellation, notices.
109
121
  * @returns The reply's content, trimmed. Where that is empty once the thinking is stripped, a
110
122
  * `reasoning_content` the server sent beside it, stripped the same way, or an empty string.
111
123
  */
112
124
  export declare function ask(config: Endpoint, model: string, system: string, user: SideTaskInput, options?: SideTaskOptions): Promise<string>;
125
+ /** A side task's reply, with why the model stopped writing it. */
126
+ export interface SideTaskReply {
127
+ /** The reply's content as `ask` returns it: trimmed, thinking stripped. */
128
+ text: string;
129
+ /**
130
+ * The first choice's `finish_reason` as the server sent it, compared with `FinishReason`.
131
+ * `FinishReason.Length` means `text` was cut off at the ceiling. Undefined when none was sent.
132
+ */
133
+ finishReason: string | undefined;
134
+ }
135
+ /**
136
+ * Runs a side task as `ask` does, and says why the reply ended as well as what it said.
137
+ *
138
+ * @param config - Where to send it and how long to wait.
139
+ * @param model - The model to ask.
140
+ * @param system - The instruction.
141
+ * @param user - The input it applies to. Content parts where the model is being shown an image.
142
+ * @param [options] - Reply ceiling, temperature, a reasoning effort, extra fields, cancellation, notices.
143
+ * @returns The text `ask` would return, and the `finish_reason` it came with.
144
+ *
145
+ * @remarks
146
+ * For a caller whose reply is the product, such as a page's transcript: one that ran into the
147
+ * ceiling, often because the model was looping, reads like a finished one in its text alone.
148
+ * Nothing is thrown for it, because whether a cut-off reply is still worth keeping is the
149
+ * caller's call.
150
+ */
151
+ export declare function askReply(config: Endpoint, model: string, system: string, user: SideTaskInput, options?: SideTaskOptions): Promise<SideTaskReply>;
113
152
  /** What `askJson` takes besides a side task's options. */
114
153
  export interface AskJsonOptions extends SideTaskOptions {
115
154
  /**
@@ -138,7 +177,7 @@ export interface AskJsonOptions extends SideTaskOptions {
138
177
  * @param schema - The JSON Schema of the answer. Its root is held to an object, as a tool's is.
139
178
  * @param [options] - A side task's options, plus the schema's `name` and whether it is `strict`.
140
179
  * @returns What the reply parsed to, unchecked against the schema: `T` is the caller's claim.
141
- * `undefined` when `parseJson` found nothing in it.
180
+ * `undefined` when `parseJson` found nothing in it, as in a reply cut off before its JSON closed.
142
181
  *
143
182
  * @remarks
144
183
  * Sends `response_format` with the schema, which a llama.cpp server compiles into a grammar and
@@ -204,7 +243,7 @@ export declare const clean: (line: string) => string;
204
243
  export declare const listLines: (text: string, max: number, maxChars: number) => string[];
205
244
  /**
206
245
  * A side task's settings as the call that honours them: its endpoint, its model, and the options
207
- * `ask`, `askJson`, `summariser` and `preselect` take last.
246
+ * `ask`, `askReply`, `askJson`, `summariser` and `preselect` take last.
208
247
  *
209
248
  * @param task - The task, ordinarily `resolved.tasks.<key>`.
210
249
  * @param [options] - What the host adds — cancellation, notices, `keywords` — and its own defaults
@@ -215,10 +254,11 @@ export declare const listLines: (text: string, max: number, maxChars: number) =>
215
254
  * `endpoint` is the task's own object when no `agent` was given, a copy otherwise.
216
255
  *
217
256
  * @remarks
218
- * A helper rather than a second signature on each of the four, so a setting added to a task later
257
+ * A helper rather than a second signature on each entry point, so a setting added to a task later
219
258
  * arrives through `options` without a host's call changing. What the task states wins over the
220
259
  * same field in `options`: the host's code is written once and the task is what an operator
221
260
  * configured over it, so a host passes its own ceiling as the value for a task that names none.
261
+ * `extraBody` is merged key by key, the task's keys winning, as layers merge it.
222
262
  * A setting the task leaves out and `options` does too is absent from the result, which is what
223
263
  * leaves it to the entry point's own default — 0.3, not the agent's temperature.
224
264
  *
@@ -1,5 +1,5 @@
1
1
  import { scoped } from '../core/scope.js';
2
- import { capabilitiesFor, ceilingAndTemperature, EFFORT_NONE, effortFor, modelCapabilitiesFor, negotiate, } from '../endpoint/capabilities.js';
2
+ import { capabilitiesFor, ceilingAndTemperature, EFFORT_NONE, effortFor, extraFields, modelCapabilitiesFor, negotiate, } from '../endpoint/capabilities.js';
3
3
  import { endpointId, getClient, modelKey, resolveApiKey, sameUrl } from '../endpoint/client.js';
4
4
  import { relaxSchema, sanitizeSchema } from '../endpoint/schema-compat.js';
5
5
  import { errorMessage } from '../wire/errors.js';
@@ -68,11 +68,31 @@ export const resetHints = () => noHints().clear();
68
68
  * @param model - The model to ask, usually smaller than the one running the work.
69
69
  * @param system - The instruction.
70
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.
71
+ * @param [options] - Reply ceiling, temperature, a reasoning effort, extra fields, cancellation, notices.
72
72
  * @returns The reply's content, trimmed. Where that is empty once the thinking is stripped, a
73
73
  * `reasoning_content` the server sent beside it, stripped the same way, or an empty string.
74
74
  */
75
- export function ask(config, model, system, user, options = {}) {
75
+ export async function ask(config, model, system, user, options = {}) {
76
+ const { text } = await complete(config, model, system, user, options);
77
+ return text;
78
+ }
79
+ /**
80
+ * Runs a side task as `ask` does, and says why the reply ended as well as what it said.
81
+ *
82
+ * @param config - Where to send it and how long to wait.
83
+ * @param model - The model to ask.
84
+ * @param system - The instruction.
85
+ * @param user - The input it applies to. Content parts where the model is being shown an image.
86
+ * @param [options] - Reply ceiling, temperature, a reasoning effort, extra fields, cancellation, notices.
87
+ * @returns The text `ask` would return, and the `finish_reason` it came with.
88
+ *
89
+ * @remarks
90
+ * For a caller whose reply is the product, such as a page's transcript: one that ran into the
91
+ * ceiling, often because the model was looping, reads like a finished one in its text alone.
92
+ * Nothing is thrown for it, because whether a cut-off reply is still worth keeping is the
93
+ * caller's call.
94
+ */
95
+ export function askReply(config, model, system, user, options = {}) {
76
96
  return complete(config, model, system, user, options);
77
97
  }
78
98
  /**
@@ -86,13 +106,14 @@ export function ask(config, model, system, user, options = {}) {
86
106
  * @param user - The input, sent as the user message as it was given.
87
107
  * @param options - A side task's options. A `reasoningEffort` that is a level leaves the
88
108
  * no-thinking hints out; absent, `""` or `"off"` sends them unless this pair has refused them.
109
+ * An `extraBody` goes out less the fields this model has refused by name.
89
110
  * @param [format] - Builds the extra body fields from the endpoint's capabilities and the model's.
90
111
  * 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
112
+ * @returns The first choice's message as `answerOf` reads it, empty when there was no answer, and
113
+ * its `finish_reason`. Throws what the request throws, except that a 400 or 422 `negotiate` did not answer, on a
93
114
  * request that carried the hints or an effort, is retried once without either.
94
115
  */
95
- async function complete(config, model, system, user, { maxTokens = 512, temperature = 0.3, reasoningEffort, signal, onNotice }, format) {
116
+ async function complete(config, model, system, user, { maxTokens = 512, temperature = 0.3, reasoningEffort, extraBody, signal, onNotice }, format) {
96
117
  // The effort the caller chose, or empty where it chose none: `""` and `"off"` read as absent,
97
118
  // since off is what the hints below already ask for.
98
119
  const level = effortFor(undefined, reasoningEffort);
@@ -120,6 +141,7 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
120
141
  // Not gated on `hints`: a model that refuses `chat_template_kwargs` may still read the
121
142
  // effort, and the two latches would otherwise contradict each other.
122
143
  ...(sentEffort ? { reasoning_effort: asked } : {}),
144
+ ...extraFields(refused, extraBody),
123
145
  ...(format && refused ? format(supports, refused) : {}),
124
146
  }, { signal });
125
147
  };
@@ -131,6 +153,7 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
131
153
  const attempt = (hints, effort) => negotiate(supports, (latched, _produced, refused) => send(hints, effort, latched, refused), {
132
154
  model,
133
155
  onNotice,
156
+ droppable: Object.keys(extraBody ?? {}),
134
157
  });
135
158
  const key = modelKey(endpointId(config), model);
136
159
  // A chosen effort replaces the template hint rather than riding beside it: `enable_thinking:
@@ -165,7 +188,9 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
165
188
  modelCapabilitiesFor(supports, model).reasoningEffort = false;
166
189
  }
167
190
  }
168
- return answerOf(response.choices[0]?.message);
191
+ const [choice] = response.choices;
192
+ // Typed as always present, and sent as `null` or not at all by servers that have none to give.
193
+ return { text: answerOf(choice?.message), finishReason: choice?.finish_reason ?? undefined };
169
194
  }
170
195
  /**
171
196
  * A reply's answer, thinking stripped, or its scratchpad where the answer is nothing else.
@@ -201,7 +226,7 @@ function answerOf(message) {
201
226
  * @param schema - The JSON Schema of the answer. Its root is held to an object, as a tool's is.
202
227
  * @param [options] - A side task's options, plus the schema's `name` and whether it is `strict`.
203
228
  * @returns What the reply parsed to, unchecked against the schema: `T` is the caller's claim.
204
- * `undefined` when `parseJson` found nothing in it.
229
+ * `undefined` when `parseJson` found nothing in it, as in a reply cut off before its JSON closed.
205
230
  *
206
231
  * @remarks
207
232
  * Sends `response_format` with the schema, which a llama.cpp server compiles into a grammar and
@@ -214,7 +239,7 @@ function answerOf(message) {
214
239
  export async function askJson(config, model, system, user, schema, { name = 'answer', strict = true, ...options } = {}) {
215
240
  const sanitized = sanitizeSchema(schema);
216
241
  const instruction = `${system}\n\nReply with JSON alone, matching this JSON Schema:\n${JSON.stringify(sanitized)}`;
217
- const reply = await complete(config, model, instruction, user, options, (supports, refused) => refused.structuredOutput
242
+ const { text } = await complete(config, model, instruction, user, options, (supports, refused) => refused.structuredOutput
218
243
  ? {
219
244
  response_format: {
220
245
  type: JSON_SCHEMA_FORMAT,
@@ -226,7 +251,7 @@ export async function askJson(config, model, system, user, schema, { name = 'ans
226
251
  },
227
252
  }
228
253
  : {});
229
- return parseJson(reply);
254
+ return parseJson(text);
230
255
  }
231
256
  /**
232
257
  * Runs a side task and answers `undefined` where it fails, so the work it supports carries on.
@@ -318,7 +343,7 @@ export const listLines = (text, max, maxChars) => text
318
343
  .slice(0, max);
319
344
  /**
320
345
  * 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.
346
+ * `ask`, `askReply`, `askJson`, `summariser` and `preselect` take last.
322
347
  *
323
348
  * @param task - The task, ordinarily `resolved.tasks.<key>`.
324
349
  * @param [options] - What the host adds — cancellation, notices, `keywords` — and its own defaults
@@ -329,10 +354,11 @@ export const listLines = (text, max, maxChars) => text
329
354
  * `endpoint` is the task's own object when no `agent` was given, a copy otherwise.
330
355
  *
331
356
  * @remarks
332
- * A helper rather than a second signature on each of the four, so a setting added to a task later
357
+ * A helper rather than a second signature on each entry point, so a setting added to a task later
333
358
  * arrives through `options` without a host's call changing. What the task states wins over the
334
359
  * same field in `options`: the host's code is written once and the task is what an operator
335
360
  * configured over it, so a host passes its own ceiling as the value for a task that names none.
361
+ * `extraBody` is merged key by key, the task's keys winning, as layers merge it.
336
362
  * A setting the task leaves out and `options` does too is absent from the result, which is what
337
363
  * leaves it to the entry point's own default — 0.3, not the agent's temperature.
338
364
  *
@@ -356,6 +382,9 @@ export function taskCall(task, options, agent) {
356
382
  if (task.reasoningEffort !== undefined) {
357
383
  stated.reasoningEffort = task.reasoningEffort;
358
384
  }
385
+ if (task.extraBody !== undefined) {
386
+ stated.extraBody = { ...options?.extraBody, ...task.extraBody };
387
+ }
359
388
  return {
360
389
  endpoint: agent
361
390
  ? {
package/llms.txt CHANGED
@@ -305,6 +305,7 @@ One-shot calls that support a run without being one: picking tools, naming a ses
305
305
  - `AskJsonOptions` (type) — What `askJson` takes besides a side task's options.
306
306
  - `ask` — Runs a side task and returns the reply text, thinking stripped.
307
307
  - `askJson` — A side task whose answer is JSON matching a schema, parsed.
308
+ - `askReply` — Runs a side task as `ask` does, and says why the reply ended as well as what it said.
308
309
  - `clean` — Strips the quoting and list punctuation models decorate short answers with.
309
310
  - `listLines` — A list-shaped reply, one item per line, cleaned of the bullets and quotes models decorate them with.
310
311
  - `parseJson` — The first array or object in a model's reply, parsed, whatever prose or fence surrounds it.
@@ -312,7 +313,8 @@ One-shot calls that support a run without being one: picking tools, naming a ses
312
313
  - `SideTask` (type) — One side task's own settings, as a resolved agent spec carries them under `tasks.<key>`.
313
314
  - `SideTaskInput` (type) — The input a side task applies its instruction to: text, or the content parts a vision model reads.
314
315
  - `SideTaskOptions` (type) — What a side task may be given.
315
- - `taskCall` — A side task's settings as the call that honours them: its endpoint, its model, and the options `ask`, `askJson`, `summariser` and `preselect` take last.
316
+ - `SideTaskReply` (type) — A side task's reply, with why the model stopped writing it.
317
+ - `taskCall` — A side task's settings as the call that honours them: its endpoint, its model, and the options `ask`, `askReply`, `askJson`, `summariser` and `preselect` take last.
316
318
  - `tryAsk` — Runs a side task and answers `undefined` where it fails, so the work it supports carries on.
317
319
 
318
320
  ### errors
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "4.2.0",
3
+ "version": "4.4.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",