@cubicecho/agent-core 2.21.0 → 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`. |
@@ -279,15 +279,24 @@ const turn = await runTurn(client, supports, build, {
279
279
  });
280
280
  ```
281
281
 
282
- `contextLimitFor` answers the operator's number when there is one. Otherwise it asks for the window
283
- the server is actually serving the model in (`servedWindow`): llama.cpp's `/props`
284
- (`default_generation_settings.n_ctx`) and LM Studio's `/api/v0/models` (`loaded_context_length`).
285
- That differs from the trained window in the case the guard exists for, a 256k model started at `-c
286
- 16384`. A server with neither route is latched and not asked again. Failing both, it reads the
287
- `/v1/models` listing: `max_model_len` from vLLM, `context_length` from OpenRouter, and llama.cpp's
288
- `meta.n_ctx_train`, which is only the trained window. Ollama reports no window on any route this
289
- reads, and truncates an over-long prompt rather than refusing it, so on Ollama pass `contextLength`
290
- or there is no guard at all.
282
+ `contextLimitFor` answers the operator's number when there is one. Otherwise it reads the
283
+ `/v1/models` listing first. A window at the top of the model's entry (`context_length`,
284
+ `max_context_window`, `max_model_len`, `context_window` or `n_ctx`: vLLM's `max_model_len`,
285
+ OpenRouter's `context_length`) is taken as the one being served, and nothing else is asked.
286
+
287
+ Where the listing does not settle it, the server's own API is asked (`servedWindow`): llama.cpp's
288
+ `/props` (`default_generation_settings.n_ctx`), then LM Studio's `/api/v0/models`
289
+ (`loaded_context_length`). That covers a model listed with no window, which is LM Studio, and one
290
+ listed with only `meta.n_ctx_train`, which is llama.cpp. The trained window differs from the served
291
+ one in the case the guard exists for, a 256k model started at `-c 16384`, so it is used only when
292
+ neither native route gives a number. A server with neither route is latched and not asked again.
293
+ `/api/v0/models` is also dropped on its own: for the life of the process once it answers 404, 405
294
+ or 501, and for `listingMissMs` after any other error.
295
+
296
+ A top-level listing key is trusted without a second opinion, so on a server that puts a trained
297
+ window there, pass `contextLength`. Ollama reports no window on any route this reads, and truncates
298
+ an over-long prompt rather than refusing it, so on Ollama pass `contextLength` or there is no guard
299
+ at all.
291
300
 
292
301
  What is weighed is the prompt plus the reply ceiling the body carries, under whichever spelling
293
302
  was chosen, because that is what the endpoint weighs: a 30k prompt into a 32k window with
@@ -555,7 +564,9 @@ have to score well below the ones it kept — a hit just under the line scoring
555
564
  one just over it means the ranking chose arbitrarily, which is the case a model is worth spending
556
565
  on. Matching nothing is not confident either, since the words cannot tell a request that needs no
557
566
  tools from one whose words are not in the catalogue. An empty `toolSelectModel` still means no
558
- 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.
559
570
 
560
571
  A turn cut off at `maxTokens` is said so as a notice, and with `maxContinuations` above zero it is
561
572
  continued first. `continueTurn` is the same thing for a caller with its own loop:
@@ -1141,6 +1152,47 @@ a form can load it. Nothing in `src/` imports it — the same direction the conf
1141
1152
  what lets a document travel between hosts without the loop growing an opinion about where an agent
1142
1153
  comes from.
1143
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
+
1144
1196
  ## What is kept for the life of the process
1145
1197
 
1146
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/client.d.ts CHANGED
@@ -18,7 +18,8 @@ export interface ClientPoolOptions {
18
18
  maxClients?: number;
19
19
  /**
20
20
  * How long, in milliseconds, a model an endpoint did not list — or a server that answered
21
- * without a served window — is taken at its word before the endpoint is asked again.
21
+ * without a served window, or whose `/api/v0/models` answered with an error other than a
22
+ * missing route — is taken at its word before the endpoint is asked again.
22
23
  */
23
24
  listingMissMs?: number;
24
25
  }
@@ -66,7 +67,12 @@ export declare const firstTokenMs: (config: Pick<Endpoint, "requestTimeoutSecond
66
67
  * @param config Where to send requests and how long to wait. An absent `apiKey` becomes `NO_KEY`.
67
68
  */
68
69
  export declare function getClient(config: Endpoint): OpenAI;
69
- /** A model an endpoint offers, and what it says the model will read. Zero means it did not say. */
70
+ /**
71
+ * A model an endpoint offers, and what it says the model will read. Zero means it did not say.
72
+ *
73
+ * The number is whatever the listing carried, which from llama.cpp is the trained window rather
74
+ * than the served one; `contextLimitFor` is the lookup that knows the difference.
75
+ */
70
76
  export interface ModelInfo {
71
77
  id: string;
72
78
  contextLength: number;
@@ -109,7 +115,8 @@ export declare const endpointId: (config: {
109
115
  apiKey?: string;
110
116
  }) => string;
111
117
  /**
112
- * The window a local server is actually serving a model in, which its listing does not say.
118
+ * The window a local server's own API says it is serving a model in, for the servers whose
119
+ * listing does not say.
113
120
  *
114
121
  * llama.cpp reports it on `/props` as `default_generation_settings.n_ctx`, per slot, and LM
115
122
  * Studio on `/api/v0/models` as `loaded_context_length` while the model is loaded. Both differ
@@ -117,8 +124,12 @@ export declare const endpointId: (config: {
117
124
  * built for, and reading the trained one lets an overflow through the guard meant to catch it.
118
125
  * Ollama reports nothing on either; an operator there has to declare the window.
119
126
  *
120
- * Zero where no answer was found. A server without either route, which is every hosted API, is
121
- * latched and not asked again; one that could not be reached is not remembered at all.
127
+ * Zero where no answer was found, which is remembered for `listingMissMs`. A server without
128
+ * either route is latched and not asked again. `/api/v0/models` is also dropped on its own: until
129
+ * `resetClients` once it answers as a route that is not there, and for `listingMissMs` after any
130
+ * other error. A server that could not be reached is not remembered at all.
131
+ *
132
+ * The listing is not read here; `contextLimitFor` reads it first and comes here after.
122
133
  *
123
134
  * @param config The endpoint, plus the model whose window is wanted.
124
135
  */
@@ -139,11 +150,18 @@ export declare function listModels(config: Endpoint): Promise<ModelInfo[]>;
139
150
  * happily load a 256k model at `-c 16384` and go on listing it as 256k — and a run refused on
140
151
  * the honest-looking number is a run that fails at the endpoint instead.
141
152
  *
142
- * Otherwise the window the server is actually serving the model in, where it has an API that
143
- * says (`servedWindow`), and failing that the endpoint's listing — asked once, and again whenever
144
- * it does not name this model, since a model can arrive after the first listing was taken. A
145
- * server that will not list models still has to be able to run a turn: a failure here is an
146
- * unknown window, not a failed run.
153
+ * Otherwise the endpoint's listing — asked once, and again whenever it does not name this model,
154
+ * since a model can arrive after the first listing was taken. A window stated at the top of the
155
+ * model's entry is taken as the one being served, and nothing else is asked. Where the listing
156
+ * did not settle it — no window, only llama.cpp's trained one, no entry, no listing — the
157
+ * server's own API is asked (`servedWindow`), and what it says wins; the trained window is what
158
+ * is left when it says nothing. A server that will not list models still has to be able to run a
159
+ * turn: a failure here is an unknown window, not a failed run.
160
+ *
161
+ * The listing goes first because it is the one route every server here has, and the native ones
162
+ * are two more requests that a server stating its window in the listing never needed. Asked
163
+ * first, they were also asked again every `listingMissMs` for the life of the process on a server
164
+ * whose `/props` answers without a window, by any host that sizes the window per turn.
147
165
  *
148
166
  * @param config The endpoint, plus the model whose window is wanted.
149
167
  * @param declared The operator's own number. Above zero it wins and the endpoint is not asked.
package/dist/client.js CHANGED
@@ -162,18 +162,26 @@ const CONTEXT_KEYS = [
162
162
  "n_ctx",
163
163
  ];
164
164
  const positive = (value) => (typeof value === "number" && value > 0 ? value : 0);
165
+ /**
166
+ * A listing entry's window, and whether it is only the one the model was trained with.
167
+ *
168
+ * The two are told apart because they are trusted differently: a top-level key is taken as the
169
+ * window being served and settles the lookup, and a trained one is a ceiling the server may be
170
+ * running well under, so `contextLimitFor` still asks the native routes before settling for it.
171
+ */
165
172
  function contextLengthOf(model) {
166
173
  const record = model;
167
174
  for (const key of CONTEXT_KEYS) {
168
175
  const value = positive(record[key]);
169
176
  if (value)
170
- return value;
177
+ return { contextLength: value, trained: false };
171
178
  }
172
179
  // llama.cpp's, and the window the model was trained with rather than the one it is served in:
173
180
  // a 256k model started at `-c 16384` lists 262144 here. Better than nothing, and why the
174
- // served window is asked for first. `meta` is `null` while the model loads.
181
+ // served window is still asked for after it. `meta` is `null` while the model loads.
175
182
  const meta = record.meta;
176
- return positive(meta?.n_ctx_train);
183
+ const contextLength = positive(meta?.n_ctx_train);
184
+ return { contextLength, trained: contextLength > 0 };
177
185
  }
178
186
  /**
179
187
  * The last listing from each endpoint, so a run can size its window without a round trip.
@@ -190,6 +198,13 @@ function contextLengthOf(model) {
190
198
  * whatever was there rather than emptying it.
191
199
  */
192
200
  const listings = new Map();
201
+ /**
202
+ * The models in each endpoint's last listing whose window is only the trained one, by id.
203
+ *
204
+ * Kept beside `listings` rather than on `ModelInfo`, so what `listModels` hands back is the shape
205
+ * it has always been. Replaced with each listing that lands, and left alone by one that fails.
206
+ */
207
+ const trainedOnly = new Map();
193
208
  /**
194
209
  * When an endpoint was last asked about a model it did not name, keyed on the two together.
195
210
  *
@@ -243,8 +258,25 @@ export const endpointId = (config) => createHash("sha256").update(endpointKey(co
243
258
  * is asked again after `listingMissMs`, like a listing that did not name the model.
244
259
  */
245
260
  const served = new Map();
246
- /** Endpoints that answered both probes with a refusal, and are not asked again. */
261
+ /** Endpoints with neither native route, which are not asked for either again. */
247
262
  const unserved = new Set();
263
+ /**
264
+ * Endpoints whose `/api/v0/models` answered with an error, and when.
265
+ *
266
+ * `gone` is a route the server does not have (`NOT_THERE`), which is a fact about the server and
267
+ * stands until `resetClients`. Any other error stands for `listingMissMs` only, because it may be
268
+ * one the server gets over, and LM Studio latched for good on a passing 5xx would have no window
269
+ * at all until the process restarted, its listing carrying none.
270
+ *
271
+ * Per endpoint and on its own, whatever `/props` said: a server whose `/props` answers without a
272
+ * window is never `unserved`, and without this its missing route was asked again with every miss.
273
+ */
274
+ const refusals = new Map();
275
+ /** Whether `/api/v0/models` is to be left alone on this endpoint for now. */
276
+ const refused = (endpoint) => {
277
+ const refusal = refusals.get(endpoint);
278
+ return (refusal !== undefined && (refusal.gone || Date.now() - refusal.at < poolLimits.listingMissMs));
279
+ };
248
280
  /** How long a probe may take before the window is taken from the listing instead. */
249
281
  const PROBE_TIMEOUT_MS = 10_000;
250
282
  /** The server root behind an OpenAI-compatible base URL, which is where the native APIs live. */
@@ -252,8 +284,9 @@ const rootOf = (baseUrl) => baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
252
284
  /** A route this server does not have, rather than one that failed to answer. */
253
285
  const NOT_THERE = new Set([404, 405, 501]);
254
286
  /**
255
- * Asks one native endpoint. `missing` is a server without the route; `body` is absent for that
256
- * and for any other refusal, and a server that could not be reached throws.
287
+ * Asks one native endpoint. `failed` is any answer that was not a 2xx and `missing` the ones among
288
+ * them that mean a server without the route; `body` is absent for both, and for a 2xx that was not
289
+ * JSON. A server that could not be reached throws.
257
290
  */
258
291
  async function probe(config, path) {
259
292
  const apiKey = config.apiKey || undefined;
@@ -262,16 +295,17 @@ async function probe(config, path) {
262
295
  signal: AbortSignal.timeout(timeoutMs(config) ?? PROBE_TIMEOUT_MS),
263
296
  });
264
297
  if (!response.ok)
265
- return { missing: NOT_THERE.has(response.status) };
298
+ return { failed: true, missing: NOT_THERE.has(response.status) };
266
299
  try {
267
- return { missing: false, body: await response.json() };
300
+ return { failed: false, missing: false, body: await response.json() };
268
301
  }
269
302
  catch {
270
- return { missing: false };
303
+ return { failed: false, missing: false };
271
304
  }
272
305
  }
273
306
  /**
274
- * The window a local server is actually serving a model in, which its listing does not say.
307
+ * The window a local server's own API says it is serving a model in, for the servers whose
308
+ * listing does not say.
275
309
  *
276
310
  * llama.cpp reports it on `/props` as `default_generation_settings.n_ctx`, per slot, and LM
277
311
  * Studio on `/api/v0/models` as `loaded_context_length` while the model is loaded. Both differ
@@ -279,8 +313,12 @@ async function probe(config, path) {
279
313
  * built for, and reading the trained one lets an overflow through the guard meant to catch it.
280
314
  * Ollama reports nothing on either; an operator there has to declare the window.
281
315
  *
282
- * Zero where no answer was found. A server without either route, which is every hosted API, is
283
- * latched and not asked again; one that could not be reached is not remembered at all.
316
+ * Zero where no answer was found, which is remembered for `listingMissMs`. A server without
317
+ * either route is latched and not asked again. `/api/v0/models` is also dropped on its own: until
318
+ * `resetClients` once it answers as a route that is not there, and for `listingMissMs` after any
319
+ * other error. A server that could not be reached is not remembered at all.
320
+ *
321
+ * The listing is not read here; `contextLimitFor` reads it first and comes here after.
284
322
  *
285
323
  * @param config The endpoint, plus the model whose window is wanted.
286
324
  */
@@ -300,13 +338,19 @@ export async function servedWindow(config) {
300
338
  ?.default_generation_settings;
301
339
  window = positive(settings?.n_ctx);
302
340
  if (!window) {
303
- const lmstudio = await probe(config, "/api/v0/models");
304
- const { data } = (lmstudio.body ?? {});
305
- const entry = Array.isArray(data)
306
- ? data.find((model) => model?.id === config.model)
307
- : undefined;
308
- window = positive(entry?.loaded_context_length);
309
- if (props.missing && lmstudio.missing) {
341
+ if (!refused(endpoint)) {
342
+ const lmstudio = await probe(config, "/api/v0/models");
343
+ if (lmstudio.failed)
344
+ refusals.set(endpoint, { at: Date.now(), gone: lmstudio.missing });
345
+ else
346
+ refusals.delete(endpoint);
347
+ const { data } = (lmstudio.body ?? {});
348
+ const entry = Array.isArray(data)
349
+ ? data.find((model) => model?.id === config.model)
350
+ : undefined;
351
+ window = positive(entry?.loaded_context_length);
352
+ }
353
+ if (props.missing && refusals.get(endpoint)?.gone) {
310
354
  unserved.add(endpoint);
311
355
  return 0;
312
356
  }
@@ -325,10 +369,18 @@ export async function servedWindow(config) {
325
369
  */
326
370
  export async function listModels(config) {
327
371
  const { data } = await getClient(config).models.list();
372
+ const trained = new Set();
328
373
  const models = data
329
- .map((model) => ({ id: model.id, contextLength: contextLengthOf(model) }))
374
+ .map((model) => {
375
+ const window = contextLengthOf(model);
376
+ if (window.trained)
377
+ trained.add(model.id);
378
+ return { id: model.id, contextLength: window.contextLength };
379
+ })
330
380
  .sort((a, b) => a.id.localeCompare(b.id));
331
- listings.set(endpointKey(config), models);
381
+ const key = endpointKey(config);
382
+ listings.set(key, models);
383
+ trainedOnly.set(key, trained);
332
384
  return models;
333
385
  }
334
386
  /**
@@ -339,11 +391,18 @@ export async function listModels(config) {
339
391
  * happily load a 256k model at `-c 16384` and go on listing it as 256k — and a run refused on
340
392
  * the honest-looking number is a run that fails at the endpoint instead.
341
393
  *
342
- * Otherwise the window the server is actually serving the model in, where it has an API that
343
- * says (`servedWindow`), and failing that the endpoint's listing — asked once, and again whenever
344
- * it does not name this model, since a model can arrive after the first listing was taken. A
345
- * server that will not list models still has to be able to run a turn: a failure here is an
346
- * unknown window, not a failed run.
394
+ * Otherwise the endpoint's listing — asked once, and again whenever it does not name this model,
395
+ * since a model can arrive after the first listing was taken. A window stated at the top of the
396
+ * model's entry is taken as the one being served, and nothing else is asked. Where the listing
397
+ * did not settle it — no window, only llama.cpp's trained one, no entry, no listing — the
398
+ * server's own API is asked (`servedWindow`), and what it says wins; the trained window is what
399
+ * is left when it says nothing. A server that will not list models still has to be able to run a
400
+ * turn: a failure here is an unknown window, not a failed run.
401
+ *
402
+ * The listing goes first because it is the one route every server here has, and the native ones
403
+ * are two more requests that a server stating its window in the listing never needed. Asked
404
+ * first, they were also asked again every `listingMissMs` for the life of the process on a server
405
+ * whose `/props` answers without a window, by any host that sizes the window per turn.
347
406
  *
348
407
  * @param config The endpoint, plus the model whose window is wanted.
349
408
  * @param declared The operator's own number. Above zero it wins and the endpoint is not asked.
@@ -351,9 +410,6 @@ export async function listModels(config) {
351
410
  export async function contextLimitFor(config, declared = 0) {
352
411
  if (declared > 0)
353
412
  return declared;
354
- const window = await servedWindow(config);
355
- if (window > 0)
356
- return window;
357
413
  const key = endpointKey(config);
358
414
  const listed = () => listings.get(key)?.find((model) => model.id === config.model);
359
415
  // The listing is asked for again when it does not name this model, rather than only when
@@ -369,26 +425,30 @@ export async function contextLimitFor(config, declared = 0) {
369
425
  // Asked again, but not on every call. A model that is never coming answers the same zero
370
426
  // however often the endpoint is asked, and a caller sizing a window per turn pays a round
371
427
  // trip for each of them; `listingMissMs` is how long that answer is allowed to stand.
372
- if (asked !== undefined && Date.now() - asked < poolLimits.listingMissMs)
373
- return 0;
374
- // A failure is not remembered: an endpoint that was down when the last run started is not
375
- // an endpoint with no models, and a window nobody could ask about is not a failed run.
376
- try {
377
- await listModels(config);
378
- }
379
- catch {
380
- return 0;
428
+ if (asked === undefined || Date.now() - asked >= poolLimits.listingMissMs) {
429
+ // A failure is not remembered: an endpoint that was down when the last run started is not
430
+ // an endpoint with no models, and a window nobody could ask about is not a failed run.
431
+ const answered = await listModels(config).then(() => true, () => false);
432
+ // Recorded where the endpoint was actually asked, and only there. Stamping it on the calls
433
+ // that skipped the request would push the deadline out ahead of any caller polling faster
434
+ // than the interval, which is a memory that never expires rather than one that expires in
435
+ // half a minute.
436
+ if (answered) {
437
+ if (listed())
438
+ misses.delete(missKey);
439
+ else
440
+ misses.set(missKey, Date.now());
441
+ }
381
442
  }
382
- // Recorded where the endpoint was actually asked, and only there. Stamping it on the calls
383
- // that skipped the request would push the deadline out ahead of any caller polling faster
384
- // than the interval, which is a memory that never expires rather than one that expires in
385
- // half a minute.
386
- if (listed())
387
- misses.delete(missKey);
388
- else
389
- misses.set(missKey, Date.now());
390
443
  }
391
- return listed()?.contextLength ?? 0;
444
+ const entry = listed();
445
+ const fromListing = entry?.contextLength ?? 0;
446
+ // A top-level key settles it. A trained window does not: llama.cpp lists the one the model was
447
+ // built with, and taking it over what `/props` says lets an overflow through the guard.
448
+ if (fromListing > 0 && !trainedOnly.get(key)?.has(config.model))
449
+ return fromListing;
450
+ const window = await servedWindow(config);
451
+ return window > 0 ? window : fromListing;
392
452
  }
393
453
  /**
394
454
  * Forgets every cached client and listing, and puts `configureClients` back to the defaults. For
@@ -399,6 +459,8 @@ export function resetClients() {
399
459
  clients.clear();
400
460
  listings.clear();
401
461
  misses.clear();
462
+ trainedOnly.clear();
402
463
  served.clear();
403
464
  unserved.clear();
465
+ refusals.clear();
404
466
  }
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
 
@@ -70,7 +71,7 @@ The contract between whatever holds the tools and the loop that offers them to a
70
71
  - `ModelInfo` (type) — A model an endpoint offers, and what it says the model will read.
71
72
  - `NO_KEY` — The SDK insists on a non-empty key even where the server will not look at it.
72
73
  - `resetClients` — Forgets every cached client and listing, and puts `configureClients` back to the defaults.
73
- - `servedWindow` — The window a local server is actually serving a model in, which its listing does not say.
74
+ - `servedWindow` — The window a local server's own API says it is serving a model in, for the servers whose listing does not say.
74
75
  - `timeoutMs` — Zero, less, or absent means no limit, which the SDK spells as `undefined`.
75
76
 
76
77
  ### compaction
@@ -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.0",
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",