@cubicecho/agent-core 2.21.1 → 2.22.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
@@ -36,7 +36,7 @@ only, Node >=22.
36
36
  | `continuation` | `continueTurn`: carries on an answer the token ceiling cut off, by prefilling it as a trailing assistant message. |
37
37
  | `config` | The structural interfaces every function here asks for. |
38
38
  | `run-turn` | `runTurn`: one turn with the retry loop around the negotiation around the stream. The whole loop, for a caller that wants it rather than its parts. Sizes the request against an opt-in `contextLimit`. |
39
- | `agent-loop` | `runAgentLoop`: the loop above a turn — `runTurn` per step, the tools between, `load_tools` and preselection handled, until the model stops asking. Plus the parts it is made of: `buildBody`, `preselect`, `preview`, and `resolveApiKey` for a caller deciding which key an endpoint gets. |
39
+ | `agent-loop` | `runAgentLoop`: the loop above a turn — `runTurn` per step, the tools between, `load_tools` and preselection handled, until the model stops asking. Plus the parts it is made of: `buildBody`, `preselect`, `preview`, `resolveApiKey` for a caller deciding which key an endpoint gets, and `taskCall` for running a side task on the settings an agent spec gives it. |
40
40
  | `tool-calls` | Reading what a model meant by a tool call it did not write cleanly: `parseToolArguments` repairs almost-JSON arguments and says when they were cut off, `recoverToolCalls` finds calls written into the reply as text. |
41
41
  | `compaction` | Keeping a long run inside its window: `pruneToolResults` clears stale tool results, `planCompaction` and `compactTranscript` fold the oldest stretch into a summary. |
42
42
  | `ledger` | What stretches of a transcript cost, read off the prompt counts the server reported rather than estimated: `recordRequest`, `tokensBetween`, `estimateFrom`, `rebaseLedger`. |
@@ -564,7 +564,9 @@ have to score well below the ones it kept — a hit just under the line scoring
564
564
  one just over it means the ranking chose arbitrarily, which is the case a model is worth spending
565
565
  on. Matching nothing is not confident either, since the words cannot tell a request that needs no
566
566
  tools from one whose words are not in the catalogue. An empty `toolSelectModel` still means no
567
- preselection at all, words included.
567
+ preselection at all, words included. `preselect` also takes a `temperature` and a
568
+ `reasoningEffort`, read as `ask` reads them, and `runAgentLoop({ preselect: true })` makes the call
569
+ itself — see "Its side tasks" below.
568
570
 
569
571
  A turn cut off at `maxTokens` is said so as a notice, and with `maxContinuations` above zero it is
570
572
  continued first. `continueTurn` is the same thing for a caller with its own loop:
@@ -1150,6 +1152,47 @@ a form can load it. Nothing in `src/` imports it — the same direction the conf
1150
1152
  what lets a document travel between hosts without the loop growing an opinion about where an agent
1151
1153
  comes from.
1152
1154
 
1155
+ ### Its side tasks
1156
+
1157
+ `tasks.<key>` configures a side task apart from the main model — its own model, endpoint, ceiling,
1158
+ temperature and reasoning effort — and resolution hands each back as a `ResolvedTask` holding only
1159
+ what a layer stated. `taskCall` turns one into the arguments `ask`, `askJson`, `summariser` and
1160
+ `preselect` already take, so a setting added to a task later arrives without the host's call
1161
+ changing:
1162
+
1163
+ ```ts
1164
+ const title = taskCall(config.tasks.title, { signal, onNotice }, config);
1165
+ const name = await ask(title.endpoint, title.model, "Name this chat.", firstMessage, title.options);
1166
+ ```
1167
+
1168
+ **An absent setting is the side task's own default, never the agent's.** A titler under an agent
1169
+ running at temperature 1.0 is still sent 0.3, and its ceiling is still `ask`'s 512, `summariser`'s
1170
+ 1024 or `preselect`'s 256. What the task does state wins over the same field in the options beside
1171
+ it, so the options are where a host puts its own default for a task that names none. `maxTokens: 0`
1172
+ sends no ceiling, as it does on the main model.
1173
+
1174
+ `reasoningEffort` is the one that reads differently from the main model's. A side task is sent the
1175
+ no-thinking hints by default, and absent or `"off"` keeps them; a level — `"low"`, `"high"` — is
1176
+ sent as `reasoning_effort` in their place, for a compaction model an operator pointed at a model
1177
+ that should deliberate.
1178
+
1179
+ The third argument is the endpoint the main turn uses. Given it, a task on the same endpoint is
1180
+ sent the agent's spelling of the URL and, having no key of its own, the agent's key, so the two
1181
+ share one entry in `capabilitiesFor` and a refusal learned by either is known to both; a task on
1182
+ another endpoint is sent only a key of its own, as `resolveApiKey` has it.
1183
+
1184
+ The loop reads two of them when asked to. `preselect: true` has `runAgentLoop` make the
1185
+ preselection itself with `tasks.toolSelect` — or with `toolSelectModel` on the agent's endpoint,
1186
+ which stays the flattened spelling for a host that is not on the spec — and `compact: true` has it
1187
+ fold the transcript before a step, once `planCompaction` says a `contextLength` window is filling,
1188
+ with `tasks.compaction` writing the summary and no falling back to the main model without it. Both
1189
+ are off by default: a host that already calls `preselect` or compacts in `beforeStep` is not sent
1190
+ a second request.
1191
+
1192
+ ```ts
1193
+ const result = await runAgentLoop({ config, preselect: { keywords: true }, compact: true, ... });
1194
+ ```
1195
+
1153
1196
  ## What is kept for the life of the process
1154
1197
 
1155
1198
  Four caches outlive any one run: the `OpenAI` clients, the model listings, the latched
@@ -1,10 +1,12 @@
1
1
  import type OpenAI from "openai";
2
2
  import { type Capabilities, type ModelCapabilities } from "./capabilities.ts";
3
3
  import type { CatalogServer } from "./catalog.ts";
4
+ import { type CompactionOptions } from "./compaction.ts";
4
5
  import type { Endpoint, ModelParams, RetryPolicy, ToolPolicy } from "./config.ts";
5
6
  import { type RunEventInput, type RunMetrics } from "./events.ts";
6
7
  import { type HookContext, type HookEvent, type HookNote, type HookRunner } from "./hooks.ts";
7
8
  import { type TokenLedger } from "./ledger.ts";
9
+ import { type SideTask, type SideTaskOptions } from "./side-task.ts";
8
10
  import type { Turn, TurnUsage } from "./stream.ts";
9
11
  import { type KeywordPreselectOptions, type ToolOrder } from "./tool-loading.ts";
10
12
  /**
@@ -64,6 +66,38 @@ export declare function resolveApiKey(own: {
64
66
  baseUrl: string;
65
67
  apiKey?: string;
66
68
  }, env?: Record<string, string | undefined>): string;
69
+ /**
70
+ * A side task's settings as the call that honours them: its endpoint, its model, and the options
71
+ * `ask`, `askJson`, `summariser` and `preselect` take last.
72
+ *
73
+ * A helper rather than a second signature on each of the four, so a setting added to a task later
74
+ * arrives through `options` without a host's call changing. What the task states wins over the
75
+ * same field in `options`: the host's code is written once and the task is what an operator
76
+ * configured over it, so a host passes its own ceiling as the value for a task that names none.
77
+ * A setting the task leaves out and `options` does too is absent from the result, which is what
78
+ * leaves it to the entry point's own default — 0.3, not the agent's temperature.
79
+ *
80
+ * A resolved spec carries no key, so a task on the agent's own endpoint would otherwise go out
81
+ * unkeyed, and be remembered under a second capability entry beside the agent's. Given `agent`,
82
+ * such a task is sent the agent's `baseUrl` as the agent spells it and, having no key of its own,
83
+ * the agent's key, so `capabilitiesFor` answers both with one object; a task on another endpoint
84
+ * is sent only a key of its own, as `resolveApiKey` has it. The environment is not read: the
85
+ * agent's key is whatever the main turn is sent.
86
+ *
87
+ * @param task The task, ordinarily `resolved.tasks.<key>`.
88
+ * @param options What the host adds — cancellation, notices, `keywords` — and its own defaults
89
+ * for the settings a task may leave out.
90
+ * @param agent The endpoint the main turn uses, key included. Absent sends the task's endpoint
91
+ * as it stands.
92
+ */
93
+ export declare function taskCall<Options extends SideTaskOptions = SideTaskOptions>(task: SideTask, options?: Options, agent?: {
94
+ baseUrl: string;
95
+ apiKey?: string;
96
+ }): {
97
+ endpoint: Endpoint;
98
+ model: string;
99
+ options: Options;
100
+ };
67
101
  /**
68
102
  * The tools a request is likely to need, picked by a small model before the run starts, or none.
69
103
  *
@@ -83,13 +117,18 @@ export declare function resolveApiKey(own: {
83
117
  * means by empty.
84
118
  * @param catalog The servers to choose from.
85
119
  * @param prompt The request being planned for. Only its head is read; see `preselectInput`.
86
- * @param options Cancellation, notices, the reply ceiling (256), the cap the choice is held to
120
+ * @param options Cancellation, notices, the reply ceiling (256), the temperature and reasoning
121
+ * effort as `ask` reads them (0.3 and none when absent), the cap the choice is held to
87
122
  * (`MAX_PER_LOAD`), and whether to try the words first.
88
123
  */
89
- export declare function preselect(config: Endpoint, model: string, catalog: CatalogServer[], prompt: string, { signal, onNotice, maxTokens, maxPerLoad, keywords, }?: {
124
+ export declare function preselect(config: Endpoint, model: string, catalog: CatalogServer[], prompt: string, { signal, onNotice, maxTokens, temperature, reasoningEffort, maxPerLoad, keywords, }?: {
90
125
  signal?: AbortSignal;
91
126
  onNotice?: (message: string) => void;
92
127
  maxTokens?: number;
128
+ /** As `SideTaskOptions.temperature`: absent is 0.3. */
129
+ temperature?: number;
130
+ /** As `SideTaskOptions.reasoningEffort`: absent keeps the no-thinking hints. */
131
+ reasoningEffort?: string;
93
132
  maxPerLoad?: number;
94
133
  /**
95
134
  * Try `preselectByKeywords` first and spend the model only on what it cannot settle. `true`
@@ -202,9 +241,14 @@ export interface AgentLoopOptions {
202
241
  * `toolDiscovery` absent is eager, `maxRetries` absent is none, and `contextLength` is handed
203
242
  * to `runTurn` as `contextLimit`, which sizes each request against the window before sending.
204
243
  * `"proxy"` is on-demand loading behind a tool array that never changes; see `PROXY_TOOLS`.
244
+ *
245
+ * `tasks` is a resolved agent's, and is read only for the side tasks the loop was asked to run:
246
+ * `tasks.toolSelect` under `preselect` and `tasks.compaction` under `compact`. `toolSelectModel`
247
+ * is the flattened spelling of the first, for a host that is not on the spec.
205
248
  */
206
- config: Endpoint & ModelParams & Pick<ToolPolicy, "maxToolIterations"> & Partial<Pick<ToolPolicy, "toolDiscovery">> & Partial<RetryPolicy> & {
249
+ config: Endpoint & ModelParams & Pick<ToolPolicy, "maxToolIterations"> & Partial<Pick<ToolPolicy, "toolDiscovery" | "toolSelectModel">> & Partial<RetryPolicy> & {
207
250
  contextLength?: number;
251
+ tasks?: Readonly<Record<string, SideTask>>;
208
252
  };
209
253
  /**
210
254
  * The standing instruction, sent as the first message. On-demand and proxied modes append the
@@ -260,6 +304,43 @@ export interface AgentLoopOptions {
260
304
  * mode, or proxied, where no step is routed either way.
261
305
  */
262
306
  preselectRouting?: "exclusive" | "append";
307
+ /**
308
+ * Has the loop make the preselection itself, before its first request, with the preselector
309
+ * the config names. Off by default: a host that passes a resolved agent and calls `preselect`
310
+ * on its own is not sent a second one.
311
+ *
312
+ * The preselector is `config.tasks.toolSelect` with everything it states — its endpoint, model,
313
+ * ceiling, temperature and reasoning effort, keyed as `taskCall` keys a task against `config` —
314
+ * and otherwise `config.toolSelectModel` on the config's own endpoint at `preselect`'s defaults.
315
+ * What it is asked about is the text of the last user message. Nothing is asked in eager mode,
316
+ * with neither preselector named, with no user message, or when `preselected` is given — an
317
+ * empty one included, which is how a host says it has already decided.
318
+ *
319
+ * `true` takes `preselect`'s defaults; an object passes `keywords` and `maxPerLoad` through.
320
+ * Its notices arrive as `notice` events, and a preselection that fails picks nothing rather
321
+ * than ending the run. It runs before the hooks gather, not beside them.
322
+ */
323
+ preselect?: boolean | {
324
+ keywords?: boolean | KeywordPreselectOptions;
325
+ maxPerLoad?: number;
326
+ };
327
+ /**
328
+ * Has the loop fold the transcript itself before a step, once `planCompaction` says the window
329
+ * is filling, with the summary written by `config.tasks.compaction` on its own settings. Off by
330
+ * default, and nothing is folded without that task — there is no falling back to the main model
331
+ * — or without a `config.contextLength` to plan against.
332
+ *
333
+ * It runs after `beforeStep`, on what that returned, and is `compactTranscript` and nothing
334
+ * more: the plan is made from the last request's reported prompt, or an estimate before the
335
+ * first step and after a `beforeStep` rewrite; `hooks`, when given, are told `beforeCompact`
336
+ * beside the summary; and a fold is said as a `notice` event. A summary that fails is a notice
337
+ * too and folds nothing. Tool results are not pruned — `beforeStep` is where a host does that.
338
+ *
339
+ * The result's `messages` then open with the summary in place of what was folded, so a host
340
+ * that keeps its transcript append-only wants `runCompaction` and a stored fold instead. `true`
341
+ * plans by `planCompaction`'s defaults; an object moves its thresholds.
342
+ */
343
+ compact?: boolean | Pick<CompactionOptions, "compactAt" | "keepRatio" | "target">;
263
344
  /**
264
345
  * Tools already loaded, carried from an earlier question. See `carryOver`. Not read in proxied
265
346
  * mode, where a tool is loaded only while its definition is in the history.
@@ -469,6 +550,11 @@ export interface AgentLoopResult {
469
550
  * the question. `call_tool` is honoured on demand too, unless the host has a tool of that name —
470
551
  * a session switched out of proxied mode still has it in its history, and the model copies it.
471
552
  *
553
+ * Neither side task is run unless asked for. `preselect` has the loop make the preselection and
554
+ * `compact` has it fold the transcript, each by the task `config.tasks` carries for it and on
555
+ * that task's own settings — a setting the task leaves out is the side task's default, never the
556
+ * run's `temperature` or `maxTokens`.
557
+ *
472
558
  * @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
473
559
  * and cancellation. See `AgentLoopOptions`.
474
560
  */
@@ -1,6 +1,7 @@
1
1
  import { charsPerTokenFor } from "./calibration.js";
2
2
  import { capabilitiesFor, effortFor, modelCapabilitiesFor, } from "./capabilities.js";
3
3
  import { firstTokenMs, getClient, NO_KEY, timeoutMs } from "./client.js";
4
+ import { compactTranscript, planCompaction, summariser, } from "./compaction.js";
4
5
  import { continueTurn } from "./continuation.js";
5
6
  import { AgentLoopError, AgentLoopOverflow, errorMessage, ToolIterationLimit, } from "./errors.js";
6
7
  import { runMetrics } from "./events.js";
@@ -106,6 +107,52 @@ export function resolveApiKey(own, inherited, env = process.env) {
106
107
  return NO_KEY;
107
108
  return inherited?.apiKey || env.OPENAI_API_KEY || NO_KEY;
108
109
  }
110
+ /**
111
+ * A side task's settings as the call that honours them: its endpoint, its model, and the options
112
+ * `ask`, `askJson`, `summariser` and `preselect` take last.
113
+ *
114
+ * A helper rather than a second signature on each of the four, so a setting added to a task later
115
+ * arrives through `options` without a host's call changing. What the task states wins over the
116
+ * same field in `options`: the host's code is written once and the task is what an operator
117
+ * configured over it, so a host passes its own ceiling as the value for a task that names none.
118
+ * A setting the task leaves out and `options` does too is absent from the result, which is what
119
+ * leaves it to the entry point's own default — 0.3, not the agent's temperature.
120
+ *
121
+ * A resolved spec carries no key, so a task on the agent's own endpoint would otherwise go out
122
+ * unkeyed, and be remembered under a second capability entry beside the agent's. Given `agent`,
123
+ * such a task is sent the agent's `baseUrl` as the agent spells it and, having no key of its own,
124
+ * the agent's key, so `capabilitiesFor` answers both with one object; a task on another endpoint
125
+ * is sent only a key of its own, as `resolveApiKey` has it. The environment is not read: the
126
+ * agent's key is whatever the main turn is sent.
127
+ *
128
+ * @param task The task, ordinarily `resolved.tasks.<key>`.
129
+ * @param options What the host adds — cancellation, notices, `keywords` — and its own defaults
130
+ * for the settings a task may leave out.
131
+ * @param agent The endpoint the main turn uses, key included. Absent sends the task's endpoint
132
+ * as it stands.
133
+ */
134
+ export function taskCall(task, options, agent) {
135
+ const own = task.endpoint;
136
+ const shared = agent && (!own.baseUrl.trim() || sameUrl(own.baseUrl, agent.baseUrl));
137
+ const stated = {};
138
+ if (task.maxTokens !== undefined)
139
+ stated.maxTokens = task.maxTokens;
140
+ if (task.temperature !== undefined)
141
+ stated.temperature = task.temperature;
142
+ if (task.reasoningEffort !== undefined)
143
+ stated.reasoningEffort = task.reasoningEffort;
144
+ return {
145
+ endpoint: agent
146
+ ? {
147
+ ...own,
148
+ ...(shared ? { baseUrl: agent.baseUrl } : {}),
149
+ apiKey: resolveApiKey(own, agent, {}),
150
+ }
151
+ : own,
152
+ model: task.model,
153
+ options: { ...options, ...stated },
154
+ };
155
+ }
109
156
  /**
110
157
  * The tools a request is likely to need, picked by a small model before the run starts, or none.
111
158
  *
@@ -125,10 +172,11 @@ export function resolveApiKey(own, inherited, env = process.env) {
125
172
  * means by empty.
126
173
  * @param catalog The servers to choose from.
127
174
  * @param prompt The request being planned for. Only its head is read; see `preselectInput`.
128
- * @param options Cancellation, notices, the reply ceiling (256), the cap the choice is held to
175
+ * @param options Cancellation, notices, the reply ceiling (256), the temperature and reasoning
176
+ * effort as `ask` reads them (0.3 and none when absent), the cap the choice is held to
129
177
  * (`MAX_PER_LOAD`), and whether to try the words first.
130
178
  */
131
- export async function preselect(config, model, catalog, prompt, { signal, onNotice, maxTokens = 256, maxPerLoad = MAX_PER_LOAD, keywords, } = {}) {
179
+ export async function preselect(config, model, catalog, prompt, { signal, onNotice, maxTokens = 256, temperature, reasoningEffort, maxPerLoad = MAX_PER_LOAD, keywords, } = {}) {
132
180
  if (!model || !catalog.some((server) => server.tools.length > 0))
133
181
  return [];
134
182
  if (keywords) {
@@ -141,7 +189,7 @@ export async function preselect(config, model, catalog, prompt, { signal, onNoti
141
189
  return guess.names;
142
190
  }
143
191
  }
144
- const reply = await tryAsk("preselect", () => askJson(config, model, preselectSystem(maxPerLoad), preselectInput(catalog, prompt), PRESELECT_SCHEMA, { name: "preselection", maxTokens, signal, onNotice }), { onNotice });
192
+ const reply = await tryAsk("preselect", () => askJson(config, model, preselectSystem(maxPerLoad), preselectInput(catalog, prompt), PRESELECT_SCHEMA, { name: "preselection", maxTokens, temperature, reasoningEffort, signal, onNotice }), { onNotice });
145
193
  return preselection(reply, catalog, maxPerLoad);
146
194
  }
147
195
  /**
@@ -240,6 +288,11 @@ const UNRUN_CALL = "Not run: the run stopped first.";
240
288
  * the question. `call_tool` is honoured on demand too, unless the host has a tool of that name —
241
289
  * a session switched out of proxied mode still has it in its history, and the model copies it.
242
290
  *
291
+ * Neither side task is run unless asked for. `preselect` has the loop make the preselection and
292
+ * `compact` has it fold the transcript, each by the task `config.tasks` carries for it and on
293
+ * that task's own settings — a setting the task leaves out is the side task's default, never the
294
+ * run's `temperature` or `maxTokens`.
295
+ *
243
296
  * @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
244
297
  * and cancellation. See `AgentLoopOptions`.
245
298
  */
@@ -270,6 +323,15 @@ export async function runAgentLoop(options) {
270
323
  return result;
271
324
  throw new ToolIterationLimit(`Stopped after ${options.config.maxToolIterations} tool iterations.`, standing.read());
272
325
  }
326
+ /** A message's words, as the preselector is asked about them: its string, or its text parts. */
327
+ const userText = (message) => {
328
+ const content = message?.content;
329
+ if (typeof content === "string")
330
+ return content;
331
+ if (!Array.isArray(content))
332
+ return "";
333
+ return content.flatMap((part) => (part.type === "text" ? part.text : [])).join("\n");
334
+ };
273
335
  /**
274
336
  * The steps of `runAgentLoop`, throwing what they caught as it was caught. Resolves to nothing
275
337
  * when `maxToolIterations` is spent, and tells `standing` how to read the run either way.
@@ -311,10 +373,44 @@ async function runSteps(options, standing) {
311
373
  const onDemand = proxied || (config.toolDiscovery === "ondemand" && catalog.length > 0);
312
374
  // Proxied, `loaded` is what this run has put a definition in the history for, and starts empty.
313
375
  const loaded = new Set(onDemand && !proxied ? (options.loaded ?? []) : []);
314
- const preselected = onDemand ? [...(options.preselected ?? [])] : [];
376
+ // The loop's own preselection, where it was asked for and the host has not already decided: by
377
+ // the task where the config carries one, and by the flattened name on its own endpoint where
378
+ // it does not. `preselect` answers an empty model with nothing, so neither named asks nothing.
379
+ const choose = async () => {
380
+ const prompt = userText(options.messages.findLast((message) => message.role === "user"));
381
+ if (!prompt.trim())
382
+ return [];
383
+ const asked = {
384
+ ...(typeof options.preselect === "object" ? options.preselect : {}),
385
+ signal,
386
+ onNotice: notice,
387
+ };
388
+ const selector = config.tasks?.toolSelect;
389
+ if (!selector?.model) {
390
+ return preselect(config, config.toolSelectModel ?? "", catalog, prompt, asked);
391
+ }
392
+ const call = taskCall(selector, asked, config);
393
+ return preselect(call.endpoint, call.model, catalog, prompt, call.options);
394
+ };
395
+ const preselected = !onDemand
396
+ ? []
397
+ : options.preselect && options.preselected === undefined
398
+ ? await choose()
399
+ : [...(options.preselected ?? [])];
315
400
  if (!proxied)
316
401
  for (const name of preselected)
317
402
  loaded.add(name);
403
+ // The loop's own compaction, which is the task's or nothing: a summary the main model writes is
404
+ // a second full-price request the operator did not ask for.
405
+ const compactor = options.compact ? config.tasks?.compaction : undefined;
406
+ const summarise = (() => {
407
+ if (!compactor?.model)
408
+ return undefined;
409
+ const call = taskCall(compactor, { signal, onNotice: notice }, config);
410
+ const write = summariser(call.endpoint, call.model, call.options);
411
+ // A summary that fails folds nothing, and the step goes out as it was.
412
+ return async (text) => (await tryAsk("compaction", () => write(text), { onNotice: notice })) ?? "";
413
+ })();
318
414
  const used = new Set();
319
415
  const definitions = new Map();
320
416
  for (const tool of tools) {
@@ -418,11 +514,32 @@ async function runSteps(options, standing) {
418
514
  limit: config.contextLength ?? 0,
419
515
  ledger,
420
516
  });
517
+ let next = rewritten ?? messages;
518
+ if (summarise) {
519
+ const plan = planCompaction(next, {
520
+ ...(typeof options.compact === "object" ? options.compact : {}),
521
+ limit: config.contextLength ?? 0,
522
+ // The last request's prompt describes this transcript only while nothing has rewritten it.
523
+ ...(next === messages && previous && previous.prompt > 0 ? { used: previous.prompt } : {}),
524
+ charsPerToken: charsPerTokenFor(supports, config.model),
525
+ ...((compactor?.maxTokens ?? 0) > 0 ? { summaryTokens: compactor?.maxTokens } : {}),
526
+ });
527
+ if (plan) {
528
+ const folded = await compactTranscript(next, plan, summarise, {
529
+ ...(hooks
530
+ ? { hooks: { run: hooks.run, context: hooks.context, onNote: hooks.onNote } }
531
+ : {}),
532
+ });
533
+ if (folded !== next)
534
+ notice(`compacted ${plan.cut - plan.from} messages into a summary`);
535
+ next = folded;
536
+ }
537
+ }
421
538
  // A rewrite may have folded a definition away, and a load answered "already loaded" would
422
539
  // then point at nothing. Forgetting costs a definition sent twice at worst.
423
- if (proxied && rewritten && rewritten !== messages)
540
+ if (proxied && next !== messages)
424
541
  loaded.clear();
425
- messages = rewritten ?? messages;
542
+ messages = next;
426
543
  ledger = rebaseLedger(ledger, before, messages);
427
544
  onEvent({ kind: "turn", text: `turn ${step + 1}` });
428
545
  // Appended, the shortlist is already in `loaded` — after what was carried, and once — so the
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * prompts, and whatever the run is about — because that is the caller's, and it is the part
10
10
  * that differs between one server and the next.
11
11
  */
12
- export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopRequest, type AgentLoopResult, buildBody, preselect, preview, resolveApiKey, runAgentLoop, type StepWindow, type ToolCallOutcome, type ToolCallRequest, type ToolCallResult, } from "./agent-loop.ts";
12
+ export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopRequest, type AgentLoopResult, buildBody, preselect, preview, resolveApiKey, runAgentLoop, type StepWindow, type ToolCallOutcome, type ToolCallRequest, type ToolCallResult, taskCall, } from "./agent-loop.ts";
13
13
  export { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.ts";
14
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";
@@ -25,7 +25,7 @@ export { resetAll } from "./reset.ts";
25
25
  export { backoffMs, CHARS_PER_TOKEN, type ContextBreakdown, type ContextBreakdownOptions, ContextOverflow, compact, contextChars, contextTokens, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestChars, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, type TokenEstimateOptions, toolsChars, } from "./retry.ts";
26
26
  export { type RunTurnOptions, runTurn } from "./run-turn.ts";
27
27
  export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.ts";
28
- export { type AskJsonOptions, ask, askJson, clean, listLines, parseJson, resetHints, type SideTaskInput, type SideTaskOptions, tryAsk, } from "./side-task.ts";
28
+ export { type AskJsonOptions, ask, askJson, clean, listLines, parseJson, resetHints, type SideTask, type SideTaskInput, type SideTaskOptions, tryAsk, } from "./side-task.ts";
29
29
  export { CAPABILITY_SNAPSHOT_VERSION, type CapabilitySnapshot, type EndpointSnapshot, exportCapabilities, importCapabilities, type ModelSnapshot, } from "./snapshot.ts";
30
30
  export { AGENT_SPEC, AGENT_SPEC_VERSION, AGENT_TASKS, type AgentHook, type AgentSpec, type EndpointSpec, type ExportSpecOptions, exportSpec, type ModelSpec, type ParsedSpec, type ParseSpecOptions, type PromptPart, parseSpec, RESOLVED_DEFAULTS, type ResolvedAgent, type ResolvedTask, type RetrySpec, resolveAgentSpec, SPEC_EVENTS, type SpecBundle, type SpecServer, type TaskSpec, type ToolsSpec, } from "./spec.ts";
31
31
  export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from "./stream.ts";
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * prompts, and whatever the run is about — because that is the caller's, and it is the part
10
10
  * that differs between one server and the next.
11
11
  */
12
- export { buildBody, preselect, preview, resolveApiKey, runAgentLoop, } from "./agent-loop.js";
12
+ export { buildBody, preselect, preview, resolveApiKey, runAgentLoop, taskCall, } from "./agent-loop.js";
13
13
  export { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.js";
14
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";
@@ -20,10 +20,22 @@ export declare const resetHints: () => void;
20
20
  export type SideTaskInput = string | OpenAI.ChatCompletionContentPart[];
21
21
  /** What a side task may be given. All optional — one given none of them still runs. */
22
22
  export interface SideTaskOptions {
23
- /** Ceiling on the reply, default 512. These answers are meant to be short. */
23
+ /**
24
+ * Ceiling on the reply, default 512. These answers are meant to be short. Zero or less sends
25
+ * none and leaves it to the server, which is what `maxTokens: 0` means on the main model too.
26
+ */
24
27
  maxTokens?: number;
25
28
  /** Sampling temperature, default 0.3. Naming and classifying want the same answer twice. */
26
29
  temperature?: number;
30
+ /**
31
+ * A reasoning effort to ask for in place of the no-thinking hints, for a side task an operator
32
+ * has pointed at a model that should deliberate — a compaction summary is the usual one.
33
+ *
34
+ * Absent, `""` and `"off"` all keep the hints, because off is what they already ask for. Any
35
+ * other value is sent as `reasoning_effort`, stepped up by `effortFor` where the model has
36
+ * refused it by value, and `chat_template_kwargs` is left out of the request altogether.
37
+ */
38
+ reasoningEffort?: string;
27
39
  /** Abandons the call, usually because the run it supports has gone away. */
28
40
  signal?: AbortSignal;
29
41
  /**
@@ -40,6 +52,27 @@ export interface SideTaskOptions {
40
52
  */
41
53
  onNotice?: (message: string) => void;
42
54
  }
55
+ /**
56
+ * One side task's own settings, as a resolved agent spec carries them under `tasks.<key>`.
57
+ *
58
+ * Declared here rather than imported from `spec.ts`, whose `ResolvedTask` it matches field for
59
+ * field: the spec module is one nothing else in `src/` imports, and a host that is not on the spec
60
+ * can write one of these by hand. An absent setting is the side task's own default — 0.3, the
61
+ * entry point's ceiling, thinking off — and never the main model's, so raising the agent's
62
+ * temperature for the chat does not make its titler creative. See `taskCall`.
63
+ */
64
+ export interface SideTask {
65
+ /** The model that does the task. */
66
+ model: string;
67
+ /** Where that model is reached, which need not be where the agent's is. */
68
+ endpoint: Endpoint;
69
+ /** The reply's ceiling. Absent is the entry point's default; zero sends none. */
70
+ maxTokens?: number;
71
+ /** Absent is 0.3, whatever the agent's own temperature is. Zero means zero. */
72
+ temperature?: number;
73
+ /** As `SideTaskOptions.reasoningEffort`: a level replaces the no-thinking hints. */
74
+ reasoningEffort?: string;
75
+ }
43
76
  /**
44
77
  * Runs a side task and returns the reply text, thinking stripped. Throws like any request.
45
78
  *
@@ -47,7 +80,7 @@ export interface SideTaskOptions {
47
80
  * @param model The model to ask, usually smaller than the one running the work.
48
81
  * @param system The instruction.
49
82
  * @param user The input it applies to. Content parts where the model is being shown an image.
50
- * @param options Reply ceiling, temperature, cancellation, notices.
83
+ * @param options Reply ceiling, temperature, a reasoning effort, cancellation, notices.
51
84
  */
52
85
  export declare function ask(config: Endpoint, model: string, system: string, user: SideTaskInput, options?: SideTaskOptions): Promise<string>;
53
86
  /** What `askJson` takes besides a side task's options. */
package/dist/side-task.js CHANGED
@@ -72,7 +72,7 @@ function rejectedTheRequest(error) {
72
72
  * @param model The model to ask, usually smaller than the one running the work.
73
73
  * @param system The instruction.
74
74
  * @param user The input it applies to. Content parts where the model is being shown an image.
75
- * @param options Reply ceiling, temperature, cancellation, notices.
75
+ * @param options Reply ceiling, temperature, a reasoning effort, cancellation, notices.
76
76
  */
77
77
  export function ask(config, model, system, user, options = {}) {
78
78
  return complete(config, model, system, user, options);
@@ -81,22 +81,29 @@ export function ask(config, model, system, user, options = {}) {
81
81
  * The request `ask` and `askJson` share, with `format` deciding the extra body fields from what
82
82
  * the model and the endpoint have refused, rebuilt on every re-send.
83
83
  */
84
- async function complete(config, model, system, user, { maxTokens = 512, temperature = 0.3, signal, onNotice }, format) {
84
+ async function complete(config, model, system, user, { maxTokens = 512, temperature = 0.3, reasoningEffort, signal, onNotice }, format) {
85
+ // The effort the caller chose, or empty where it chose none: `""` and `"off"` read as absent,
86
+ // since off is what the hints below already ask for.
87
+ const level = effortFor(undefined, reasoningEffort);
85
88
  // Whether the last request carried an effort, which `negotiate` decides and not this function.
86
89
  let sentEffort = false;
87
90
  const send = (hints, effort, supports, refused) => {
88
91
  // `none` is what a side task wants and not always what the model offers: OpenAI's reasoning
89
92
  // models refuse it by value and list `minimal` as their floor. `effortFor` answers with the
90
93
  // cheapest rung this one takes, which `negotiate` has been stepping up as it was refused.
91
- const asked = effort ? effortFor(refused, "none") : "";
94
+ // A level the caller chose goes through the same ladder, which only ever steps up from it.
95
+ const asked = effort ? effortFor(refused, level || "none") : "";
92
96
  sentEffort = asked !== "";
93
97
  return getClient(config).chat.completions.create({
94
98
  model,
95
99
  // The reasoning models want the ceiling spelled the other way, and they are exactly the
96
- // models a side task most wants to stop deliberating.
97
- ...(refused && !refused.legacyTokenLimit
98
- ? { max_completion_tokens: maxTokens }
99
- : { max_tokens: maxTokens }),
100
+ // models a side task most wants to stop deliberating. Zero is no ceiling, as it is on the
101
+ // main model: a spec's `maxTokens: 0` sent as `max_tokens: 0` asks for an empty reply.
102
+ ...(maxTokens > 0
103
+ ? refused && !refused.legacyTokenLimit
104
+ ? { max_completion_tokens: maxTokens }
105
+ : { max_tokens: maxTokens }
106
+ : {}),
100
107
  // One that will only run at the temperature it was built with is sent none: a side task
101
108
  // wants the same answer twice, and 1.0 from that model is as close as it gets.
102
109
  ...(refused && !refused.chosenTemperature ? {} : { temperature }),
@@ -121,7 +128,9 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
121
128
  onNotice,
122
129
  });
123
130
  const key = hintKey(endpointId(config), model);
124
- const hints = !noHints.has(key);
131
+ // A chosen effort replaces the template hint rather than riding beside it: `enable_thinking:
132
+ // false` next to `reasoning_effort: "high"` is a request that contradicts itself.
133
+ const hints = !level && !noHints.has(key);
125
134
  let response;
126
135
  try {
127
136
  response = await attempt(hints, true);
@@ -134,7 +143,9 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
134
143
  const effort = sentEffort;
135
144
  if (!(hints || effort) || !rejectedTheRequest(error))
136
145
  throw error;
137
- onNotice?.(`${model} rejected a request carrying the no-thinking hints; retrying without them`);
146
+ onNotice?.(level
147
+ ? `${model} rejected a request carrying a reasoning effort; retrying without it`
148
+ : `${model} rejected a request carrying the no-thinking hints; retrying without them`);
138
149
  response = await attempt(false, false);
139
150
  // Latched on the finding, not the hypothesis: a context overflow is a 400 `negotiate` does
140
151
  // not recognise too, and it fails the retry the same way, leaving the next call to try the
package/dist/spec.d.ts CHANGED
@@ -132,8 +132,14 @@ export interface TaskSpec {
132
132
  model?: string;
133
133
  /** Where that model lives, when it is not on the agent's endpoint. */
134
134
  endpoint?: EndpointSpec;
135
+ /** The reply's ceiling. Absent is the side task's own default, not the agent's `maxTokens`. */
135
136
  maxTokens?: number;
137
+ /** Absent is the side task's own default, 0.3, whatever the agent's temperature is. */
136
138
  temperature?: number;
139
+ /**
140
+ * A level has the task deliberate. Absent and `"off"` leave it with the no-thinking hints a
141
+ * side task is sent by default; see `SideTaskOptions.reasoningEffort`.
142
+ */
137
143
  reasoningEffort?: string;
138
144
  }
139
145
  /** A tool call bound to a lifecycle event, run by the host rather than the model. */
@@ -178,7 +184,13 @@ export interface SpecBundle {
178
184
  */
179
185
  mcpServers?: SpecServer[];
180
186
  }
181
- /** One side task, resolved: a model, and the endpoint it is reached through. */
187
+ /**
188
+ * One side task, resolved: a model, and the endpoint it is reached through.
189
+ *
190
+ * The three settings are present only where a layer stated them, and nothing is filled in from
191
+ * the agent's own model — `taskCall` turns one into a call, and what is absent there is left to
192
+ * the side task's default.
193
+ */
182
194
  export interface ResolvedTask {
183
195
  model: string;
184
196
  /** This task's endpoint, which is the agent's unless a layer named another. */
package/llms.txt CHANGED
@@ -26,6 +26,7 @@ The loop above a turn: send, run the tools the model asked for, send again, unti
26
26
  - `ToolCallOutcome` (type) — What one tool call did, in the order the model asked.
27
27
  - `ToolCallRequest` (type) — One call the model made, as `dispatch` and `onToolCall` are handed it.
28
28
  - `ToolCallResult` (type) — One call's whole answer, as `onToolResult` is handed it.
29
+ - `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.
29
30
 
30
31
  ### calibration
31
32
 
@@ -240,6 +241,7 @@ One-shot calls that support a run without being one: picking tools, naming a ses
240
241
  - `listLines` — A list-shaped reply, one item per line, cleaned of the bullets and quotes models decorate them with.
241
242
  - `parseJson` — Models are asked for JSON and often answer with prose around it, or a fenced block.
242
243
  - `resetHints` — Test seam, alongside `resetClients` and `resetAll`: forget which models refused the hints.
244
+ - `SideTask` (type) — One side task's own settings, as a resolved agent spec carries them under `tasks.<key>`.
243
245
  - `SideTaskInput` (type) — The input a side task applies its instruction to: text, or the content parts a vision model reads.
244
246
  - `SideTaskOptions` (type) — What a side task may be given.
245
247
  - `tryAsk` — A side task is never worth failing the work it supports.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "2.21.1",
3
+ "version": "2.22.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",