@cubicecho/agent-core 2.21.1 → 2.23.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,13 +36,14 @@ 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`. |
43
43
  | `snapshot` | `exportCapabilities` and `importCapabilities`: the latched refusals as a JSON blob a consumer stores, so a restart need not learn them again. |
44
44
  | `spec` | `parseSpec`, `resolveAgentSpec` and `exportSpec`: an agent as a versioned JSON document, layered into the flat config the loop takes. Imports nothing but types, and is published separately at `@cubicecho/agent-core/spec`. |
45
45
  | `reset` | `resetAll`: drops every cache and latch in one call, so a teardown cannot forget one. |
46
+ | `runtime` | `createRuntime`: a second set of those caches, with the functions that use them as methods — for a host whose tenants must not share a client pool, a latch or an event bus. `defaultRuntime` is the one the top-level functions use. |
46
47
  | `tokens` | `estimateTokens`: characters over four, deliberately low, for everything here that has to guess at a window. |
47
48
  | `errors` | `errorMessage`: a caught `unknown` turned into something a run row can hold. And what `runAgentLoop` throws, with the run as it stood: `AgentLoopError`, `ToolIterationLimit`, `AgentLoopOverflow`, and `failedRun` to read it off any of them. |
48
49
  | `catalog` | `CatalogServer`: the name-only shape `tool-loading` reads a connected server as. |
@@ -564,7 +565,9 @@ have to score well below the ones it kept — a hit just under the line scoring
564
565
  one just over it means the ranking chose arbitrarily, which is the case a model is worth spending
565
566
  on. Matching nothing is not confident either, since the words cannot tell a request that needs no
566
567
  tools from one whose words are not in the catalogue. An empty `toolSelectModel` still means no
567
- preselection at all, words included.
568
+ preselection at all, words included. `preselect` also takes a `temperature` and a
569
+ `reasoningEffort`, read as `ask` reads them, and `runAgentLoop({ preselect: true })` makes the call
570
+ itself — see "Its side tasks" below.
568
571
 
569
572
  A turn cut off at `maxTokens` is said so as a notice, and with `maxContinuations` above zero it is
570
573
  continued first. `continueTurn` is the same thing for a caller with its own loop:
@@ -1150,12 +1153,54 @@ a form can load it. Nothing in `src/` imports it — the same direction the conf
1150
1153
  what lets a document travel between hosts without the loop growing an opinion about where an agent
1151
1154
  comes from.
1152
1155
 
1156
+ ### Its side tasks
1157
+
1158
+ `tasks.<key>` configures a side task apart from the main model — its own model, endpoint, ceiling,
1159
+ temperature and reasoning effort — and resolution hands each back as a `ResolvedTask` holding only
1160
+ what a layer stated. `taskCall` turns one into the arguments `ask`, `askJson`, `summariser` and
1161
+ `preselect` already take, so a setting added to a task later arrives without the host's call
1162
+ changing:
1163
+
1164
+ ```ts
1165
+ const title = taskCall(config.tasks.title, { signal, onNotice }, config);
1166
+ const name = await ask(title.endpoint, title.model, "Name this chat.", firstMessage, title.options);
1167
+ ```
1168
+
1169
+ **An absent setting is the side task's own default, never the agent's.** A titler under an agent
1170
+ running at temperature 1.0 is still sent 0.3, and its ceiling is still `ask`'s 512, `summariser`'s
1171
+ 1024 or `preselect`'s 256. What the task does state wins over the same field in the options beside
1172
+ it, so the options are where a host puts its own default for a task that names none. `maxTokens: 0`
1173
+ sends no ceiling, as it does on the main model.
1174
+
1175
+ `reasoningEffort` is the one that reads differently from the main model's. A side task is sent the
1176
+ no-thinking hints by default, and absent or `"off"` keeps them; a level — `"low"`, `"high"` — is
1177
+ sent as `reasoning_effort` in their place, for a compaction model an operator pointed at a model
1178
+ that should deliberate.
1179
+
1180
+ The third argument is the endpoint the main turn uses. Given it, a task on the same endpoint is
1181
+ sent the agent's spelling of the URL and, having no key of its own, the agent's key, so the two
1182
+ share one entry in `capabilitiesFor` and a refusal learned by either is known to both; a task on
1183
+ another endpoint is sent only a key of its own, as `resolveApiKey` has it.
1184
+
1185
+ The loop reads two of them when asked to. `preselect: true` has `runAgentLoop` make the
1186
+ preselection itself with `tasks.toolSelect` — or with `toolSelectModel` on the agent's endpoint,
1187
+ which stays the flattened spelling for a host that is not on the spec — and `compact: true` has it
1188
+ fold the transcript before a step, once `planCompaction` says a `contextLength` window is filling,
1189
+ with `tasks.compaction` writing the summary and no falling back to the main model without it. Both
1190
+ are off by default: a host that already calls `preselect` or compacts in `beforeStep` is not sent
1191
+ a second request.
1192
+
1193
+ ```ts
1194
+ const result = await runAgentLoop({ config, preselect: { keywords: true }, compact: true, ... });
1195
+ ```
1196
+
1153
1197
  ## What is kept for the life of the process
1154
1198
 
1155
1199
  Four caches outlive any one run: the `OpenAI` clients, the model listings, the latched
1156
1200
  capabilities, and `side-task`'s no-thinking hints. A fifth, the characters per token each model was
1157
- measured at, is keyed on the endpoint's capabilities object, so it goes with them. All four are module-level and keyed on the same
1158
- notion of an endpoint — its base URL and its API key — and the clients' key carries the request
1201
+ measured at, is keyed on the endpoint's capabilities object, so it goes with them. All four are
1202
+ held once per runtime — once per process, unless you make another, [below](#a-runtime-of-its-own)
1203
+ — and keyed on the same notion of an endpoint — its base URL and its API key — and the clients' key carries the request
1159
1204
  timeout as well, since that changes how a request is sent.
1160
1205
 
1161
1206
  **They are keyed per deployment, not per request.** What belongs in them is an endpoint an
@@ -1166,8 +1211,8 @@ bounding them assumes that, and a consumer that mints an API key per *user* brea
1166
1211
 
1167
1212
  That is bounded rather than unbounded: the client pool is a 32-entry LRU, and an evicted client
1168
1213
  costs its connection pool and nothing else, since the next request through that endpoint builds
1169
- another. But churning connection pools is not sharing them, and at that point a client of your
1170
- own, built and held per tenant, is the better answer than this.
1214
+ another. But churning connection pools is not sharing them, and at that point a runtime per
1215
+ tenant, [below](#a-runtime-of-its-own), is the better answer than this.
1171
1216
 
1172
1217
  The listings cache also remembers, for half a minute, that an endpoint did not name a model — the
1173
1218
  case where a configured name never matches anything the server serves (a llama.cpp `-a` alias, an
@@ -1241,6 +1286,57 @@ carries it in the snapshot so an imported latch keeps its real age instead of be
1241
1286
  every boot. Importing takes the older of the two ages, and a snapshot written before this field
1242
1287
  existed reads as met now.
1243
1288
 
1289
+ ### A runtime of its own
1290
+
1291
+ Everything above is the *default runtime*: one set of caches, shared by every caller in the
1292
+ process. `createRuntime` makes another, with its own clients, listings, latches, hints, measured
1293
+ ratios, event bus and hook settings, and the functions that use them as methods of the same names
1294
+ and signatures:
1295
+
1296
+ ```ts
1297
+ import { createRuntime } from "@cubicecho/agent-core";
1298
+
1299
+ const runtime = createRuntime({ clients: { maxClients: 8 }, events: { maxEvents: 200 } });
1300
+
1301
+ const run = await runtime.runAgentLoop({ config, messages, dispatch });
1302
+ for await (const event of runtime.watch(runId)) send(event);
1303
+ runtime.resetAll(); // this runtime's caches, and nobody else's
1304
+ ```
1305
+
1306
+ It is for the host the section above warns off — one that mints a key per tenant, where a shared
1307
+ pool has tenants evicting each other's clients and a shared bus makes one tenant's run id readable
1308
+ by another — and for tests, where a runtime per case needs no `resetAll` between cases and lets
1309
+ them run in parallel. A host with one deployment's worth of endpoints needs none: the top-level
1310
+ functions are the default runtime's methods, and nothing about them has changed.
1311
+
1312
+ The runtime travels by async context (`AsyncLocalStorage`), not by argument. A method runs its
1313
+ function with the runtime current, and it stays current across every `await` beneath it — so
1314
+ `runtime.runAgentLoop` uses the runtime's clients and latches all the way down, and so does a
1315
+ top-level function your code calls from inside it: an `emit` in `onEvent`, an `ask` in `dispatch`,
1316
+ a `gather` in a hook. `runtime.run(fn)` does the same for code that calls the top-level functions
1317
+ itself:
1318
+
1319
+ ```ts
1320
+ await runtime.run(async () => {
1321
+ const limit = await contextLimitFor(config); // the runtime's listing cache
1322
+ emit(runId, { kind: "output", text: "…" }); // the runtime's bus
1323
+ });
1324
+ ```
1325
+
1326
+ Outside a method or `run`, a top-level function is the default runtime's again. That is the one
1327
+ thing to keep straight: a request handler that watches or emits to a run a runtime started is not
1328
+ inside it, and calls `runtime.watch` or `runtime.emit`. What a method hands back keeps its runtime
1329
+ — the iterator from `runtime.watch`, the function from `runtime.summariser` — wherever it is read.
1330
+
1331
+ A new runtime starts from the package's defaults plus what `createRuntime` was given; it does not
1332
+ inherit what `configureClients` and the others set on the default one. Nothing needs closing: a
1333
+ runtime is garbage once nothing holds it. `defaultRuntime` is the process's own as an object, for
1334
+ code that takes a `Runtime` and should be handed the shared one.
1335
+
1336
+ The two schema caches (`sanitizeTools`, `relaxTools`) and `requestTokens`' tool-length cache stay
1337
+ process-wide. They are memoised pure functions keyed on the caller's own objects, so there is
1338
+ nothing in them for one tenant to learn about another.
1339
+
1244
1340
  ## Where the merged behaviour came from
1245
1341
 
1246
1342
  - `schema-compat` — `kanban_server`/`task_server`'s version, which strips **every** sibling of a
@@ -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
@@ -1,4 +1,5 @@
1
1
  import { CHARS_PER_TOKEN, requestChars, toolsChars } from "./retry.js";
2
+ import { scoped } from "./scope.js";
2
3
  /**
3
4
  * How many characters a token is worth on one model, learned from what its endpoint reports.
4
5
  *
@@ -27,7 +28,7 @@ const PLAUSIBLE = { least: 1, most: 8 };
27
28
  * `resetCapabilities` takes its readings with it. Replaced rather than cleared by `resetCalibration`,
28
29
  * since a `WeakMap` has no `clear`.
29
30
  */
30
- let readings = new WeakMap();
31
+ const readings = scoped(() => new WeakMap());
31
32
  /**
32
33
  * The characters per token to size a request to this model with, `CHARS_PER_TOKEN` until a turn
33
34
  * has reported one.
@@ -43,7 +44,7 @@ let readings = new WeakMap();
43
44
  * @param model The name the endpoint knows the model as, as it goes in the body.
44
45
  */
45
46
  export function charsPerTokenFor(supports, model) {
46
- const known = readings.get(supports)?.get(model);
47
+ const known = readings().get(supports)?.get(model);
47
48
  return known?.length ? Math.max(...known) : CHARS_PER_TOKEN;
48
49
  }
49
50
  /** Whether any message carries a part whose tokens its characters do not count. */
@@ -69,10 +70,10 @@ export function calibrate(supports, body, promptTokens) {
69
70
  if (ratio < PLAUSIBLE.least || ratio > PLAUSIBLE.most) {
70
71
  return charsPerTokenFor(supports, body.model);
71
72
  }
72
- let models = readings.get(supports);
73
+ let models = readings().get(supports);
73
74
  if (!models) {
74
75
  models = new Map();
75
- readings.set(supports, models);
76
+ readings().set(supports, models);
76
77
  }
77
78
  const known = models.get(body.model) ?? [];
78
79
  known.push(ratio);
@@ -83,5 +84,5 @@ export function calibrate(supports, body, promptTokens) {
83
84
  }
84
85
  /** Forgets every reading, so the next request is sized at `CHARS_PER_TOKEN` again. */
85
86
  export function resetCalibration() {
86
- readings = new WeakMap();
87
+ readings.reset();
87
88
  }
@@ -1,6 +1,7 @@
1
1
  import { endpointId } from "./client.js";
2
2
  import { errorMessage } from "./errors.js";
3
3
  import { isGrammarError } from "./schema-compat.js";
4
+ import { scoped } from "./scope.js";
4
5
  /**
5
6
  * What each endpoint cannot do, remembered for the life of the process.
6
7
  *
@@ -19,7 +20,7 @@ import { isGrammarError } from "./schema-compat.js";
19
20
  * behind it, including the `models` map underneath, which is the level where two keys through
20
21
  * one host are most likely to differ at all.
21
22
  */
22
- const capabilities = new Map();
23
+ const capabilities = scoped(() => new Map());
23
24
  /**
24
25
  * What this endpoint is known not to support. The same object every time, so what `negotiate`
25
26
  * latches off stays off.
@@ -64,16 +65,16 @@ export function modelCapabilitiesFor(supports, model) {
64
65
  return known;
65
66
  }
66
67
  /** Every endpoint's capabilities by `endpointId`, the live objects, for `exportCapabilities`. */
67
- export const knownCapabilities = () => capabilities;
68
+ export const knownCapabilities = () => capabilities();
68
69
  /**
69
70
  * The capabilities of the endpoint with this `endpointId`, created optimistic if unseen — the way
70
71
  * `importCapabilities` reaches an endpoint it has only a digest for.
71
72
  */
72
73
  export function capabilitiesById(id) {
73
- let known = capabilities.get(id);
74
+ let known = capabilities().get(id);
74
75
  if (!known) {
75
76
  known = { strictSchemas: true, usageInStream: true, models: new Map(), since: Date.now() };
76
- capabilities.set(id, known);
77
+ capabilities().set(id, known);
77
78
  }
78
79
  return known;
79
80
  }
@@ -92,11 +93,11 @@ export function capabilitiesById(id) {
92
93
  */
93
94
  export function resetCapabilities(endpoint) {
94
95
  if (!endpoint) {
95
- const held = capabilities.size > 0;
96
- capabilities.clear();
96
+ const held = capabilities().size > 0;
97
+ capabilities().clear();
97
98
  return held;
98
99
  }
99
- return capabilities.delete(endpointId(endpoint));
100
+ return capabilities().delete(endpointId(endpoint));
100
101
  }
101
102
  /**
102
103
  * Forgets every endpoint whose entry is older than this, so the next request finds out again.
@@ -117,10 +118,10 @@ export function resetCapabilities(endpoint) {
117
118
  */
118
119
  export function expireCapabilities(maxAgeMs, now = Date.now()) {
119
120
  let dropped = 0;
120
- for (const [id, known] of capabilities) {
121
+ for (const [id, known] of capabilities()) {
121
122
  if (now - known.since < maxAgeMs)
122
123
  continue;
123
- capabilities.delete(id);
124
+ capabilities().delete(id);
124
125
  dropped++;
125
126
  }
126
127
  return dropped;
package/dist/client.d.ts CHANGED
@@ -27,10 +27,11 @@ export interface ClientPoolOptions {
27
27
  * Changes what the client pool is held to, for a process whose endpoints are not shaped like the
28
28
  * deployments these defaults were chosen for.
29
29
  *
30
- * Module-level for the same reason `configureEvents` is: the pool is one module-level thing, and
31
- * its size is a deployment's setting, said once at startup. A multi-tenant host keying on a key
32
- * per user raises `maxClients` so its tenants stop evicting each other's connection pools; a dev
33
- * box shortens `listingMissMs` so a model it has just pulled is seen sooner.
30
+ * One setting per runtime for the same reason `configureEvents` is: the pool is one thing per
31
+ * runtime, and its size is a deployment's setting, said once at startup. A multi-tenant host
32
+ * keying on a key per user raises `maxClients` so its tenants stop evicting each other's
33
+ * connection pools, or gives each tenant a `createRuntime` of its own; a dev box shortens
34
+ * `listingMissMs` so a model it has just pulled is seen sooner.
34
35
  *
35
36
  * A `maxClients` below the pool's current size evicts down to it at once, least recently asked
36
37
  * for first, as the next `getClient` would have. A shorter `listingMissMs` applies to misses