@cubicecho/agent-core 2.24.0 → 2.25.1

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.
Files changed (71) hide show
  1. package/README.md +10 -7
  2. package/dist/agent-loop.d.ts +84 -205
  3. package/dist/agent-loop.js +288 -597
  4. package/dist/calibration.d.ts +14 -10
  5. package/dist/calibration.js +30 -23
  6. package/dist/capabilities.d.ts +111 -42
  7. package/dist/capabilities.js +281 -138
  8. package/dist/client.d.ts +104 -35
  9. package/dist/client.js +205 -74
  10. package/dist/compaction.d.ts +103 -60
  11. package/dist/compaction.js +160 -86
  12. package/dist/config.d.ts +48 -12
  13. package/dist/config.js +20 -1
  14. package/dist/continuation.d.ts +24 -16
  15. package/dist/continuation.js +70 -44
  16. package/dist/digest.d.ts +7 -0
  17. package/dist/digest.js +18 -0
  18. package/dist/errors.d.ts +24 -15
  19. package/dist/errors.js +23 -15
  20. package/dist/events.d.ts +100 -63
  21. package/dist/events.js +250 -120
  22. package/dist/guards.d.ts +49 -4
  23. package/dist/guards.js +60 -5
  24. package/dist/hook-events.d.ts +21 -5
  25. package/dist/hook-events.js +23 -9
  26. package/dist/hooks.d.ts +96 -73
  27. package/dist/hooks.js +202 -116
  28. package/dist/index.d.ts +30 -26
  29. package/dist/index.js +29 -24
  30. package/dist/ledger.d.ts +62 -30
  31. package/dist/ledger.js +163 -56
  32. package/dist/platform.d.ts +15 -0
  33. package/dist/platform.js +15 -0
  34. package/dist/preselect.d.ts +223 -0
  35. package/dist/preselect.js +291 -0
  36. package/dist/request-body.d.ts +32 -0
  37. package/dist/request-body.js +47 -0
  38. package/dist/reset.d.ts +1 -0
  39. package/dist/reset.js +7 -6
  40. package/dist/retry.d.ts +44 -138
  41. package/dist/retry.js +83 -281
  42. package/dist/run-calls.d.ts +123 -0
  43. package/dist/run-calls.js +351 -0
  44. package/dist/run-turn.d.ts +36 -15
  45. package/dist/run-turn.js +74 -37
  46. package/dist/runtime.d.ts +23 -17
  47. package/dist/runtime.js +34 -24
  48. package/dist/schema-compat.d.ts +35 -18
  49. package/dist/schema-compat.js +346 -176
  50. package/dist/scope.d.ts +28 -5
  51. package/dist/scope.js +37 -10
  52. package/dist/side-task.d.ts +124 -40
  53. package/dist/side-task.js +186 -66
  54. package/dist/snapshot.d.ts +10 -5
  55. package/dist/snapshot.js +73 -30
  56. package/dist/spec.d.ts +56 -25
  57. package/dist/spec.js +494 -215
  58. package/dist/stream.d.ts +66 -16
  59. package/dist/stream.js +220 -97
  60. package/dist/thinking.d.ts +23 -8
  61. package/dist/thinking.js +92 -44
  62. package/dist/tokens.d.ts +164 -6
  63. package/dist/tokens.js +319 -6
  64. package/dist/tool-calls.d.ts +90 -17
  65. package/dist/tool-calls.js +207 -84
  66. package/dist/tool-loading.d.ts +101 -189
  67. package/dist/tool-loading.js +257 -371
  68. package/dist/wire.d.ts +81 -0
  69. package/dist/wire.js +73 -0
  70. package/llms.txt +76 -39
  71. package/package.json +4 -4
@@ -1,229 +1,30 @@
1
- import { charsPerTokenFor } from "./calibration.js";
2
- import { capabilitiesFor, ceilingAndTemperature, effortFor, modelCapabilitiesFor, } from "./capabilities.js";
3
- import { firstTokenMs, getClient, NO_KEY, timeoutMs } from "./client.js";
4
- import { compactTranscript, planCompaction, summariser, } from "./compaction.js";
5
- import { continueTurn } from "./continuation.js";
6
- import { AgentLoopError, AgentLoopOverflow, errorMessage, ToolIterationLimit, } from "./errors.js";
7
- import { runMetrics, stamp } from "./events.js";
8
- import { configureHooks, gather, notify, turnIndex, turnMessages, withContext, } from "./hooks.js";
9
- import { rebaseLedger, recordRequest } from "./ledger.js";
10
- import { ContextOverflow, contextTokens, toolsChars } from "./retry.js";
11
- import { runTurn } from "./run-turn.js";
12
- import { relaxTools, sanitizeTools } from "./schema-compat.js";
13
- import { askJson, tryAsk } from "./side-task.js";
14
- import { noUsage } from "./stream.js";
15
- import { parseToolArguments, recoverToolCalls } from "./tool-calls.js";
16
- import { CALL_TOOL, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PROXY_TOOLS, preselectByKeywords, preselectInput, preselection, preselectSystem, proxiedCall, proxyCatalogPrompt, proxyLoadResult, requestedNames, shownCall, toolName, } from "./tool-loading.js";
17
- /**
18
- * The loop above a turn: send, run the tools the model asked for, send again, until it stops
19
- * asking.
20
- *
21
- * Three servers wrote this loop separately after `runTurn` had already been pulled in here, and
22
- * the copies drifted the way the turn's own had — one noticed a turn cut off at the ceiling and
23
- * two did not, one tested the ceiling's spelling the other way round, one sent a reasoning effort
24
- * and two never did. What is here is the part that does not know what the run is for; the
25
- * prompts, the tools and what a run means stay with the caller.
26
- */
27
- /** The fields `extraBody` may not override, because the loop's request is built around them. */
28
- const RESERVED = new Set(["model", "messages", "stream", "tools"]);
29
- /**
30
- * The one place a streamed request's body is decided from a config and what the endpoint and
31
- * the model have refused.
32
- *
33
- * Every field that negotiates lives here: the ceiling's two spellings, a temperature only a
34
- * model that takes ours is sent, a reasoning effort only one that takes it is, `stream_options`
35
- * only where the server has heard of it, relaxed schemas only where it could not build a grammar,
36
- * and `extraBody` last, less whatever the model refused by name. The ceiling is tested
37
- * `=== false` — `modelCapabilitiesFor` starts a model at `legacyTokenLimit: true` and an absent
38
- * one has to read the same — which is the test one of the three copies had inverted.
39
- *
40
- * @param config What to ask for. `maxTokens` of zero or less sends no ceiling; `reasoningEffort`
41
- * absent or `"off"` sends no effort, and one the model has refused by value is stepped up to the
42
- * cheapest it takes by `effortFor`.
43
- * @param supports What the endpoint has refused, as `negotiate` hands it to `send`.
44
- * @param refused What the model has refused, as `negotiate` hands it over. Absent is a model
45
- * that has refused nothing.
46
- * @param messages The request's messages, system prompt included, sent as they are.
47
- * @param tools The tool definitions. Ordered by name, sanitised here — a lookup for a definition
48
- * seen before — and relaxed where the endpoint needs it. Empty sends no `tools` field at all.
49
- * @param order How to order them before sending. `true`, the default, is by name, which keeps the
50
- * cache when the caller's array is assembled differently from one request to the next. See
51
- * `orderTools`.
52
- */
53
- export function buildBody(config, supports, refused, messages, tools = [], order = true) {
54
- const sorted = orderTools(tools, order);
55
- const declared = supports.strictSchemas
56
- ? sanitizeTools(sorted)
57
- : relaxTools(sanitizeTools(sorted));
58
- const effort = effortFor(refused, config.reasoningEffort);
59
- const extra = Object.entries(config.extraBody ?? {}).filter(([field]) => !RESERVED.has(field) && !refused?.refusedFields.has(field));
60
- return {
61
- ...ceilingAndTemperature(refused, config.maxTokens, config.temperature),
62
- ...(effort ? { reasoning_effort: effort } : {}),
63
- ...(supports.usageInStream ? { stream_options: { include_usage: true } } : {}),
64
- ...Object.fromEntries(extra),
65
- model: config.model,
66
- messages,
67
- stream: true,
68
- ...(declared.length ? { tools: declared } : {}),
69
- };
70
- }
71
- /**
72
- * A long tool argument or result cut to what a watcher needs, with the full length said.
73
- *
74
- * For events, never for the transcript: the model reads the whole of what a tool returned.
75
- *
76
- * @param text What to show.
77
- * @param limit Characters kept, 2000 by default. Text at or under it comes back as it was.
78
- */
79
- export const preview = (text, limit = 2000) => text.length > limit ? `${text.slice(0, limit)}… (${text.length} chars)` : text;
80
- /** A count and its noun, plural unless the count is one: `1 tool`, `3 tools`. */
81
- const counted = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
82
- /** A base URL as two settings rows would agree on it: trimmed, without the trailing slash. */
83
- const sameUrl = (a, b) => a.trim().replace(/\/+$/, "") === b.trim().replace(/\/+$/, "");
84
- /**
85
- * The key to send, where an endpoint may inherit one from the settings it overrides.
86
- *
87
- * A credential issued for one endpoint has no business being posted to another. A profile that
88
- * names its own `baseUrl` and no key of its own is sent `NO_KEY` — not the operator's key, and
89
- * not `$OPENAI_API_KEY` — because "I pointed an agent at a friend's server and it sent my OpenAI
90
- * key" is not a mistake worth being able to make, and a local server wants no key anyway. One on
91
- * the same endpoint inherits the key as it inherits everything else, and the environment is the
92
- * last word on the endpoint that was configured rather than overridden.
93
- *
94
- * @param own The endpoint as the agent or profile states it. Its own key always wins. An empty or
95
- * absent `baseUrl` is one that inherits the endpoint too.
96
- * @param inherited The settings it overrides. Absent treats `own` as the configured endpoint, so
97
- * only its key and the environment's are in play.
98
- * @param env Where `OPENAI_API_KEY` is read from, `process.env` by default.
99
- */
100
- export function resolveApiKey(own, inherited, env = process.env) {
101
- if (own.apiKey)
102
- return own.apiKey;
103
- const baseUrl = own.baseUrl?.trim();
104
- if (inherited && baseUrl && !sameUrl(baseUrl, inherited.baseUrl))
105
- return NO_KEY;
106
- return inherited?.apiKey || env.OPENAI_API_KEY || NO_KEY;
107
- }
108
- /**
109
- * A side task's settings as the call that honours them: its endpoint, its model, and the options
110
- * `ask`, `askJson`, `summariser` and `preselect` take last.
111
- *
112
- * A helper rather than a second signature on each of the four, so a setting added to a task later
113
- * arrives through `options` without a host's call changing. What the task states wins over the
114
- * same field in `options`: the host's code is written once and the task is what an operator
115
- * configured over it, so a host passes its own ceiling as the value for a task that names none.
116
- * A setting the task leaves out and `options` does too is absent from the result, which is what
117
- * leaves it to the entry point's own default — 0.3, not the agent's temperature.
118
- *
119
- * A resolved spec carries no key, so a task on the agent's own endpoint would otherwise go out
120
- * unkeyed, and be remembered under a second capability entry beside the agent's. Given `agent`,
121
- * such a task is sent the agent's `baseUrl` as the agent spells it and, having no key of its own,
122
- * the agent's key, so `capabilitiesFor` answers both with one object; a task on another endpoint
123
- * is sent only a key of its own, as `resolveApiKey` has it. The environment is not read: the
124
- * agent's key is whatever the main turn is sent.
125
- *
126
- * @param task The task, ordinarily `resolved.tasks.<key>`.
127
- * @param options What the host adds — cancellation, notices, `keywords` — and its own defaults
128
- * for the settings a task may leave out.
129
- * @param agent The endpoint the main turn uses, key included. Absent sends the task's endpoint
130
- * as it stands.
131
- */
132
- export function taskCall(task, options, agent) {
133
- const own = task.endpoint;
134
- const shared = agent && (!own.baseUrl.trim() || sameUrl(own.baseUrl, agent.baseUrl));
135
- const stated = {};
136
- if (task.maxTokens !== undefined)
137
- stated.maxTokens = task.maxTokens;
138
- if (task.temperature !== undefined)
139
- stated.temperature = task.temperature;
140
- if (task.reasoningEffort !== undefined)
141
- stated.reasoningEffort = task.reasoningEffort;
142
- return {
143
- endpoint: agent
144
- ? {
145
- ...own,
146
- ...(shared ? { baseUrl: agent.baseUrl } : {}),
147
- apiKey: resolveApiKey(own, agent, {}),
148
- }
149
- : own,
150
- model: task.model,
151
- options: { ...options, ...stated },
152
- };
153
- }
154
- /**
155
- * The tools a request is likely to need, picked by a small model before the run starts, or none.
156
- *
157
- * On-demand loading otherwise spends a round trip on reading the catalogue and calling
158
- * `load_tools`; a small model reading the same catalogue usually names the right tools, and the
159
- * task model opens with them in hand. A wrong guess costs a few hundred tokens for one run, and
160
- * a failed one costs nothing — it is reported through `onNotice` and answered with an empty list,
161
- * since a side task is never worth failing the run. A stop still throws.
162
- *
163
- * With `keywords`, the request's own words are matched against the catalogue first and the model
164
- * is spent only on what they cannot settle, which on a local box is the difference between a run
165
- * starting now and starting in a few seconds. The words have to be clear about it; see
166
- * `preselectByKeywords` for what that means.
167
- *
168
- * @param config The endpoint the preselector is reached through.
169
- * @param model The preselector. An empty name picks nothing, which is what `toolSelectModel`
170
- * means by empty.
171
- * @param catalog The servers to choose from.
172
- * @param prompt The request being planned for. Only its head is read; see `preselectInput`.
173
- * @param options Cancellation, notices, the reply ceiling (256), the temperature and reasoning
174
- * effort as `ask` reads them (0.3 and none when absent), the cap the choice is held to
175
- * (`MAX_PER_LOAD`), and whether to try the words first.
176
- */
177
- export async function preselect(config, model, catalog, prompt, { signal, onNotice, maxTokens = 256, temperature, reasoningEffort, maxPerLoad = MAX_PER_LOAD, keywords, } = {}) {
178
- if (!model || !catalog.some((server) => server.tools.length > 0))
179
- return [];
180
- if (keywords) {
181
- const guess = preselectByKeywords(catalog, prompt, {
182
- maxPerLoad,
183
- ...(keywords === true ? {} : keywords),
184
- });
185
- if (guess.confident) {
186
- onNotice?.(`chose ${counted(guess.names.length, "tool")} by name`);
187
- return guess.names;
188
- }
189
- }
190
- const reply = await tryAsk("preselect", () => askJson(config, model, preselectSystem(maxPerLoad), preselectInput(catalog, prompt), PRESELECT_SCHEMA, { name: "preselection", maxTokens, temperature, reasoningEffort, signal, onNotice }), { onNotice });
191
- return preselection(reply, catalog, maxPerLoad);
192
- }
193
- /**
194
- * The four token counts of one usage added into another. Only those: the rest are measurements a
195
- * sum of would mean nothing, or would mean something only `runMetrics` knows how to weigh.
196
- */
197
- const accumulate = (total, turn) => {
198
- total.prompt += turn.prompt;
199
- total.completion += turn.completion;
200
- total.total += turn.total;
201
- total.cached += turn.cached;
202
- };
203
- /** Whether two messages say the same thing, by identity first since most of a transcript is. */
204
- const sameMessage = (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b);
205
- /** The system messages a request opens with, which a template renders ahead of the history. */
206
- const leadingSystem = (messages) => {
207
- const end = messages.findIndex((message) => message.role !== "system");
208
- return messages.slice(0, end === -1 ? messages.length : end);
209
- };
210
- /**
211
- * Where a request stopped matching the one before it, earliest in the rendered prompt first — the
212
- * tool block, then the system prompt, then the history — or `none-known` where it only appended.
213
- */
214
- function breakReason(previous, messages, tools) {
215
- if (previous.tools.length !== tools.length ||
216
- previous.tools.some((name, at) => name !== tools[at]))
217
- return "tools-changed";
218
- const before = leadingSystem(previous.messages);
219
- const now = leadingSystem(messages);
220
- if (before.length !== now.length || before.some((message, at) => !sameMessage(message, now[at])))
221
- return "system-changed";
222
- if (previous.messages.length > messages.length ||
223
- previous.messages.some((message, at) => !sameMessage(message, messages[at])))
224
- return "history-rewritten";
225
- return "none-known";
226
- }
1
+ import { charsPerTokenFor } from './calibration.js';
2
+ import { capabilitiesFor, modelCapabilitiesFor, } from './capabilities.js';
3
+ import { firstTokenMs, getClient, loadingMs, timeoutMs } from './client.js';
4
+ import { compactTranscript, planCompaction, summariser } from './compaction.js';
5
+ import { ToolDiscovery } from './config.js';
6
+ import { continueTurn } from './continuation.js';
7
+ import { AgentLoopError, AgentLoopOverflow, errorMessage, ToolIterationLimit, } from './errors.js';
8
+ import { RunEventKind, RunOutcome, runMetrics, stamp, } from './events.js';
9
+ import { counted } from './guards.js';
10
+ import { HookEvent } from './hook-events.js';
11
+ import { configureHooks, gather, notify, turnIndex, turnMessages, withContext, } from './hooks.js';
12
+ import { breakReason, rebaseLedger, recordRequest, } from './ledger.js';
13
+ import { preselect } from './preselect.js';
14
+ import { buildBody } from './request-body.js';
15
+ import { ContextOverflow } from './retry.js';
16
+ import { assistantMessage, definedAs, loadShortlist, readCalls, runCalls, } from './run-calls.js';
17
+ import { runTurn } from './run-turn.js';
18
+ import { taskCall, tryAsk } from './side-task.js';
19
+ import { addCounts, noUsage } from './stream.js';
20
+ import { contextTokens, toolsChars } from './tokens.js';
21
+ import { recoverToolCalls, } from './tool-calls.js';
22
+ import { CALL_TOOL, catalogPrompt, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, orderTools, PROXY_TOOLS, proxyCatalogPrompt, toolName, } from './tool-loading.js';
23
+ import { FinishReason, PartType, Role } from './wire.js';
24
+ /** The `preselectRouting` that sends the first step the shortlist alone. */
25
+ export const PRESELECT_EXCLUSIVE = 'exclusive';
26
+ /** The `preselectRouting` that loads the shortlist after what was carried, and sends the first step like any other. */
27
+ export const PRESELECT_APPEND = 'append';
227
28
  /**
228
29
  * The share of the previous prompt a cache that kept its prefix reports as hit. Short of it by
229
30
  * more than this is a break, not the few tokens a template re-renders at the join.
@@ -231,28 +32,45 @@ function breakReason(previous, messages, tools) {
231
32
  const CACHE_KEPT = 0.9;
232
33
  /**
233
34
  * What one turn's cache should have been and whether it was, against the request before it.
35
+ *
36
+ * @param previous - The request before this one, with what it reported. Absent, or with no prompt
37
+ * reported, diagnoses nothing.
38
+ * @param next - The request this turn sent, read only to say why a broken cache broke.
39
+ * @param usage - The turn's own report. Not written to. One with no `uncached` did not report its
40
+ * cache, and is told what to expect without being judged.
41
+ * @returns The fields to lay over the turn's usage: `cacheExpected`, the previous prompt plus its
42
+ * completion; `cacheBroken` where the cache was reported; and `cacheBreakReason` where it broke.
43
+ * Empty where nothing is diagnosed.
44
+ *
45
+ * @remarks
234
46
  * Nothing before the second request, or where the previous prompt was not reported.
235
47
  */
236
- function cacheDiagnosis(previous, messages, tools, usage) {
237
- if (!previous || !(previous.prompt > 0))
48
+ function cacheDiagnosis(previous, next, usage) {
49
+ if (!previous || !(previous.prompt > 0)) {
238
50
  return {};
51
+ }
239
52
  const found = { cacheExpected: previous.prompt + previous.completion };
240
- if (usage.uncached === undefined)
53
+ if (usage.uncached === undefined) {
241
54
  return found;
55
+ }
242
56
  found.cacheBroken = usage.cached < previous.prompt * CACHE_KEPT;
243
- if (found.cacheBroken)
244
- found.cacheBreakReason = breakReason(previous, messages, tools);
57
+ if (found.cacheBroken) {
58
+ found.cacheBreakReason = breakReason(previous, next);
59
+ }
245
60
  return found;
246
61
  }
247
- /** What a tool call the run was stopped during is answered with, in place of a result. */
248
- const STOPPED_CALL = "Stopped before this call finished.";
249
- /** The same for a call of that step the loop never got to. */
250
- const UNRUN_CALL = "Not run: the run stopped first.";
251
62
  /**
252
63
  * Runs a question to its answer: one `runTurn` per step, the tools it asks for between them,
253
64
  * until a turn asks for none. Throws when `maxToolIterations` is spent, when stopped, and on
254
65
  * whatever `runTurn` throws — `ContextOverflow` among them, however it was found out.
255
66
  *
67
+ * @param options - The config, transcript, tools and dispatcher, plus the optional hooks, events
68
+ * and cancellation. See `AgentLoopOptions`.
69
+ * @returns The turn that asked for no tools and the run around it: the transcript with what the
70
+ * run added, usage, every call, what is loaded and used, the hooks' notes and context, metrics
71
+ * and the ledger. See `AgentLoopResult`.
72
+ *
73
+ * @remarks
256
74
  * What it throws carries the run as it stood — `messages`, `usage`, `toolCalls`, `loaded` and
257
75
  * `used` — because the steps before a failure were real and a host that stores its transcript
258
76
  * has nothing else to store them from. A spent budget is a `ToolIterationLimit`, an overflow is
@@ -290,9 +108,6 @@ const UNRUN_CALL = "Not run: the run stopped first.";
290
108
  * `compact` has it fold the transcript, each by the task `config.tasks` carries for it and on
291
109
  * that task's own settings — a setting the task leaves out is the side task's default, never the
292
110
  * run's `temperature` or `maxTokens`.
293
- *
294
- * @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
295
- * and cancellation. See `AgentLoopOptions`.
296
111
  */
297
112
  export async function runAgentLoop(options) {
298
113
  // Until the steps have set themselves up there is only what the caller handed over.
@@ -317,37 +132,56 @@ export async function runAgentLoop(options) {
317
132
  ? new AgentLoopOverflow(error.message, standing.read(), { cause: error })
318
133
  : new AgentLoopError(errorMessage(error), standing.read(), { cause: error });
319
134
  }
320
- if (result)
135
+ if (result) {
321
136
  return result;
137
+ }
322
138
  throw new ToolIterationLimit(`Stopped after ${options.config.maxToolIterations} tool iterations.`, standing.read());
323
139
  }
324
- /** A message's words, as the preselector is asked about them: its string, or its text parts. */
140
+ /**
141
+ * A message's words, as the preselector is asked about them: its string, or its text parts.
142
+ *
143
+ * @param message - The message to read. Absent, or with content that is neither a string nor
144
+ * parts, reads as empty.
145
+ * @returns The string as it is, or the text parts joined by newlines with parts of any other kind
146
+ * left out. Not trimmed.
147
+ */
325
148
  const userText = (message) => {
326
149
  const content = message?.content;
327
- if (typeof content === "string")
150
+ if (typeof content === 'string') {
328
151
  return content;
329
- if (!Array.isArray(content))
330
- return "";
331
- return content.flatMap((part) => (part.type === "text" ? part.text : [])).join("\n");
152
+ }
153
+ if (!Array.isArray(content)) {
154
+ return '';
155
+ }
156
+ return content.flatMap((part) => (part.type === PartType.Text ? part.text : [])).join('\n');
332
157
  };
333
158
  /**
334
159
  * The loop's own preselection: by the task where the config carries one, and by the flattened
335
- * name on its own endpoint where it does not. `preselect` answers an empty model with nothing, so
336
- * a config that names neither asks nothing.
160
+ * name on its own endpoint where it does not.
161
+ *
162
+ * @param options - The run's options. Read for the config, the catalogue, the signal, what
163
+ * `preselect` passes through, and the last user message, whose text is what is asked about.
164
+ * @param notice - Hears the preselector's notices, a failure among them.
165
+ * @returns The names picked. Empty where there is no user message or its text is blank — nothing
166
+ * is asked — and where the preselection picked nothing or failed. An aborted request still rejects.
167
+ *
168
+ * @remarks
169
+ * `preselect` answers an empty model with nothing, so a config that names neither asks nothing.
337
170
  */
338
171
  async function chooseTools(options, notice) {
339
172
  const { config, catalog = [], signal } = options;
340
- const prompt = userText(options.messages.findLast((message) => message.role === "user"));
341
- if (!prompt.trim())
173
+ const prompt = userText(options.messages.findLast((message) => message.role === Role.User));
174
+ if (!prompt.trim()) {
342
175
  return [];
176
+ }
343
177
  const asked = {
344
- ...(typeof options.preselect === "object" ? options.preselect : {}),
178
+ ...(typeof options.preselect === 'object' ? options.preselect : {}),
345
179
  signal,
346
180
  onNotice: notice,
347
181
  };
348
182
  const selector = config.tasks?.toolSelect;
349
183
  if (!selector?.model) {
350
- return preselect(config, config.toolSelectModel ?? "", catalog, prompt, asked);
184
+ return preselect(config, config.toolSelectModel ?? '', catalog, prompt, asked);
351
185
  }
352
186
  const call = taskCall(selector, asked, config);
353
187
  return preselect(call.endpoint, call.model, catalog, prompt, call.options);
@@ -355,72 +189,70 @@ async function chooseTools(options, notice) {
355
189
  /**
356
190
  * The loop's own summariser, which is the compaction task's or nothing: a summary the main model
357
191
  * writes is a second full-price request the operator did not ask for.
192
+ *
193
+ * @param options - The run's options. Read for `compact`, `config.tasks.compaction` and the signal.
194
+ * @param notice - Hears the task's notices, and a summary that failed.
195
+ * @returns A summariser resolving to the summary, or to an empty string where writing it failed —
196
+ * an aborted request still rejects. `undefined` where `compact` is off or the task names no model.
358
197
  */
359
198
  function ownSummariser(options, notice) {
360
199
  const { config, signal } = options;
361
200
  const compactor = options.compact ? config.tasks?.compaction : undefined;
362
- if (!compactor?.model)
201
+ if (!compactor?.model) {
363
202
  return undefined;
203
+ }
364
204
  const call = taskCall(compactor, { signal, onNotice: notice }, config);
365
205
  const write = summariser(call.endpoint, call.model, call.options);
366
206
  // A summary that fails folds nothing, and the step goes out as it was.
367
- return async (text) => (await tryAsk("compaction", () => write(text), { onNotice: notice })) ?? "";
207
+ return async (text) => (await tryAsk('compaction', () => write(text), { onNotice: notice, signal })) ?? '';
368
208
  }
369
209
  /**
370
210
  * A step's transcript after the loop's own compaction: folded where a fold was due and its summary
371
211
  * came back, and otherwise the array it was given.
212
+ *
213
+ * @param transcript - The step's messages, after `beforeStep`. Not written to.
214
+ * @param reported - The last request's prompt tokens as `used`, where they still describe this
215
+ * transcript. Empty plans from an estimate instead.
216
+ * @param summarise - Writes the summary. Not called where no fold is due.
217
+ * @param context - The run's options, read for `compact`'s thresholds, the window, the compaction
218
+ * task's ceiling and the hooks; the characters per token an estimate is made at; and `notice`,
219
+ * told of a fold.
220
+ * @returns `transcript` itself where nothing was folded, otherwise a new array with the folded
221
+ * stretch replaced by its summary.
372
222
  */
373
- async function foldBeforeStep(transcript, reported, summarise, { options, charsPerToken, notice, }) {
223
+ async function foldBeforeStep(transcript, reported, summarise, { options, charsPerToken, notice }) {
374
224
  const { config, hooks } = options;
375
225
  const compactor = config.tasks?.compaction;
376
226
  const plan = planCompaction(transcript, {
377
- ...(typeof options.compact === "object" ? options.compact : {}),
227
+ ...(typeof options.compact === 'object' ? options.compact : {}),
378
228
  limit: config.contextLength ?? 0,
379
229
  ...reported,
380
230
  charsPerToken,
381
231
  ...((compactor?.maxTokens ?? 0) > 0 ? { summaryTokens: compactor?.maxTokens } : {}),
382
232
  });
383
- if (!plan)
233
+ if (!plan) {
384
234
  return transcript;
235
+ }
385
236
  const folded = await compactTranscript(transcript, plan, summarise, {
386
237
  ...(hooks ? { hooks: { run: hooks.run, context: hooks.context, onNote: hooks.onNote } } : {}),
387
238
  });
388
- if (folded !== transcript)
239
+ if (folded !== transcript) {
389
240
  notice(`compacted ${plan.cut - plan.from} messages into a summary`);
241
+ }
390
242
  return folded;
391
243
  }
392
- /**
393
- * A turn's calls with their arguments read. A server that parses replayed arguments refuses the
394
- * almost-JSON a model wrote, so each is normalised here, and one that could not be read at all is
395
- * replayed as no arguments.
396
- */
397
- const readCalls = (calls, finishReason) => calls.map((call) => {
398
- try {
399
- const args = parseToolArguments(call.function.arguments, { finishReason });
400
- return { call, args, normal: JSON.stringify(args) };
401
- }
402
- catch (error) {
403
- return { call, error, normal: "{}" };
404
- }
405
- });
406
- /** The assistant message a turn is written into the transcript as, its calls' arguments repaired. */
407
- const assistantMessage = (content, parsed) => ({
408
- role: "assistant",
409
- content: content || null,
410
- ...(parsed.length
411
- ? {
412
- tool_calls: parsed.map(({ call, normal }) => ({
413
- ...call,
414
- function: { ...call.function, arguments: normal },
415
- })),
416
- }
417
- : {}),
418
- });
419
244
  /**
420
245
  * Tells the `afterTurn` hooks what was answered, with the turn it closed where the question is
421
- * still in the transcript. Resolves to their notes — failures only, as `notify` returns them.
246
+ * still in the transcript.
247
+ *
248
+ * @param hooks - The run's hooks: the runner, the context the hooks are told, and who hears a note.
249
+ * @param messages - The transcript as the run ended, the answer on the end.
250
+ * @param at - Where the question is in `messages`. Negative — there was none, or it has been
251
+ * folded away — tells the hooks the reply and no `turn`.
252
+ * @param reply - The answer's text.
253
+ * @returns A promise of their notes — failures only, as `notify` returns them.
422
254
  */
423
- const tellAfterTurn = (hooks, messages, at, reply) => notify(hooks.run, "afterTurn", {
255
+ const tellAfterTurn = (hooks, messages, at, reply) => notify(hooks.run, HookEvent.AfterTurn, {
424
256
  ...hooks.context,
425
257
  reply,
426
258
  ...(at >= 0
@@ -432,263 +264,172 @@ const tellAfterTurn = (hooks, messages, at, reply) => notify(hooks.run, "afterTu
432
264
  }
433
265
  : {}),
434
266
  }, hooks.onNote);
435
- /** The definitions the host gave for these names, in the order the names are given. */
436
- const definedAs = (definitions, names) => [...names].flatMap((name) => definitions.get(name) ?? []);
437
267
  /**
438
- * What a call is shown, counted and dispatched as: a `call_tool` as the tool it names. Read from
439
- * the repaired arguments where there are any, since the model's own may be almost-JSON.
268
+ * The loop's own record of what it emitted, less the token deltas, for `runMetrics` at the end.
269
+ *
270
+ * @param heard - The host's `onEvent`, still told of every event, deltas included. Absent records
271
+ * all the same.
272
+ * @returns `recorded`, the array each event is added to as it is emitted, and `onEvent`, which the
273
+ * loop emits through in place of the host's.
274
+ *
275
+ * @remarks
276
+ * Stamped here rather than by the bus, which the loop does not know about.
440
277
  */
441
- const shownAs = (run, { call, args, normal }) => run.proxies && call.function.name === CALL_TOOL
442
- ? shownCall(CALL_TOOL, args ? normal : call.function.arguments)
443
- : { name: call.function.name, input: call.function.arguments };
444
- /** Answers a `load_tools`, counting what it loaded. Not ok where it named nothing loadable. */
445
- function answerLoad(run, args) {
446
- const { catalog, proxied, definitions, loaded, loads } = run;
447
- const resolved = expandNames(requestedNames(args), catalog);
448
- const content = proxied
449
- ? proxyLoadResult(resolved, catalog, definedAs(definitions, resolved.matched), loaded)
450
- : loadResult(resolved, catalog, loaded);
451
- // Proxied, a load is only of what the result could define: a catalogued name the host
452
- // gave no definition for was not loaded, and is not counted or remembered as though it was.
453
- const hits = proxied ? resolved.matched.filter((hit) => definitions.has(hit)) : resolved.matched;
454
- for (const hit of hits)
455
- loads[loaded.has(hit) ? "redundantLoads" : "toolsLoaded"]++;
456
- loads.unknownToolNames += resolved.unknown.length;
457
- for (const hit of hits)
458
- loaded.add(hit);
459
- return { content, ok: hits.length > 0 };
278
+ function recorder(heard) {
279
+ const recorded = [];
280
+ const onEvent = (input) => {
281
+ if (input.kind !== RunEventKind.Thinking && input.kind !== RunEventKind.Output) {
282
+ recorded.push(stamp(input, '', recorded.length + 1, Date.now()));
283
+ }
284
+ heard?.(input);
285
+ };
286
+ return { recorded, onEvent };
460
287
  }
461
288
  /**
462
- * Runs one call to its result, telling the host of both. A tool that throws is that call's answer
463
- * rather than the run's end, unless the run was stopped.
289
+ * How this run finds its tools, and what it starts with in hand: the loop's own preselection
290
+ * where it was asked for and the host has not already decided, and what an earlier question
291
+ * carried over.
464
292
  *
465
- * @param run The run the call belongs to.
466
- * @param entry The call, with its arguments read.
467
- * @param answered The step's calls already made, by tool and arguments, for `dedupeToolCalls`.
293
+ * @param options - The run's options. Read for the discovery mode, the catalogue, `loaded`,
294
+ * `preselected` and `preselect`.
295
+ * @param notice - Hears what the loop's own preselection has to say.
296
+ * @returns `proxied` and `onDemand`, the second true of both modes and both false where `catalog`
297
+ * is empty; `preselected`, the shortlist, empty in eager mode; and `loaded`, a new set of what was
298
+ * carried and then the shortlist — empty when eager or proxied.
468
299
  */
469
- async function runCall(run, entry, answered) {
470
- const { catalog, onDemand, proxied, signal } = run;
471
- const { call, args, error: unreadable, normal } = entry;
472
- const proxy = run.proxies && call.function.name === CALL_TOOL;
473
- const { name, input: raw } = shownAs(run, entry);
474
- // Looked through before the call is announced, so a host is told of the tool it names with
475
- // that tool's arguments. A name outside the catalogue is refused here, so `call_tool` reaches
476
- // nothing a load could not, and the refusal is the call's answer below.
477
- let inner = args ?? {};
478
- let refused;
479
- if (proxy && args) {
480
- try {
481
- inner = proxiedCall(args, catalog).input;
482
- }
483
- catch (error) {
484
- inner = {};
485
- refused = { error };
486
- }
300
+ async function discovery(options, notice) {
301
+ const { config, catalog = [] } = options;
302
+ // On demand, but with a tool array that never changes: definitions come back as `load_tools`
303
+ // results and run through `call_tool`. `onDemand` is true of both.
304
+ const proxied = config.toolDiscovery === ToolDiscovery.Proxy && catalog.length > 0;
305
+ const onDemand = proxied || (config.toolDiscovery === ToolDiscovery.OnDemand && catalog.length > 0);
306
+ // Proxied, `loaded` is what this run has put a definition in the history for, and starts empty.
307
+ const loaded = new Set(onDemand && !proxied ? (options.loaded ?? []) : []);
308
+ const chooses = options.preselect && options.preselected === undefined;
309
+ let preselected = [];
310
+ if (onDemand && chooses) {
311
+ preselected = await chooseTools(options, notice);
487
312
  }
488
- // Built for every call rather than only the dispatched ones, so a host hears of the calls
489
- // the loop answers itself in the same shape. Arguments that could not be read are none.
490
- const request = { id: call.id, name, args: inner, raw };
491
- run.onEvent({ kind: "tool-call", id: call.id, name, text: preview(raw) });
492
- run.onToolCall?.(request);
493
- let content;
494
- let ok = true;
495
- try {
496
- if (!args)
497
- throw unreadable;
498
- // By the name the model called, not the one a `call_tool` wraps: `load_tools` is not in
499
- // the catalogue, and one reached through `call_tool` is refused below like any other.
500
- if (onDemand && call.function.name === LOAD_TOOLS) {
501
- ({ content, ok } = answerLoad(run, args));
502
- }
503
- else {
504
- if (refused)
505
- throw refused.error;
506
- // A model that skips `load_tools` and calls a catalogued tool by name is right about
507
- // what it wants; load it and run it rather than refusing. Not proxied, where loaded
508
- // means its definition is in the history, and this call put none there.
509
- if (onDemand && !proxied && inCatalog(catalog, name))
510
- run.loaded.add(name);
511
- run.used.add(name);
512
- // Keyed on the inner call, so one made through `call_tool` and one made natively share.
513
- const key = `${name}\0${proxy ? JSON.stringify(inner) : normal}`;
514
- content = run.dedupable(request)
515
- ? await once(answered, key, () => run.dispatch(request, signal))
516
- : await run.dispatch(request, signal);
313
+ else if (onDemand) {
314
+ preselected = [...(options.preselected ?? [])];
315
+ }
316
+ if (!proxied) {
317
+ for (const name of preselected) {
318
+ loaded.add(name);
517
319
  }
518
320
  }
519
- catch (error) {
520
- if (signal?.aborted)
521
- throw error;
522
- content = errorMessage(error);
523
- ok = false;
321
+ return { proxied, onDemand, loaded, preselected };
322
+ }
323
+ /**
324
+ * The host's definitions by name, the first kept where two share one.
325
+ *
326
+ * @param tools - Every definition the host gave. One that is not a function has no name and is
327
+ * left out.
328
+ * @returns A new map, in the order of `tools`, holding the definitions themselves.
329
+ */
330
+ function definitionsByName(tools) {
331
+ const definitions = new Map();
332
+ for (const tool of tools) {
333
+ const name = toolName(tool);
334
+ if (name !== undefined && !definitions.has(name)) {
335
+ definitions.set(name, tool);
336
+ }
524
337
  }
525
- run.onEvent({ kind: "tool-result", id: call.id, name, ok, text: preview(content) });
526
- const result = { id: call.id, name, ok, content };
527
- run.onToolResult?.(result);
528
- return result;
338
+ return definitions;
529
339
  }
530
340
  /**
531
- * Runs a step's calls and writes each result into the transcript, in the order asked.
341
+ * Offers each message written into the transcript to the host.
532
342
  *
533
- * A failure part-way leaves the transcript well-formed before it is rethrown: a call with no
534
- * result is a transcript no endpoint takes back, so every call of the step is answered — with what
535
- * it returned where it had, and otherwise with a line saying it was stopped, or never run.
343
+ * @param onMessage - The host's callback, awaited. Absent offers nothing.
344
+ * @returns The offer, taking a message, its step and the turn it came with: it resolves once the
345
+ * host has taken the message, and rejects with what the host threw — once.
536
346
  *
537
- * @param run The run the step belongs to.
538
- * @param parsed The step's calls, with their arguments read.
539
- * @param messages The transcript the results are written into.
540
- * @param step Which step this is, for the host told of each result.
347
+ * @remarks
348
+ * Once `onMessage` has thrown the run is ending on that, and the results still to be written are
349
+ * not offered to a host that has just failed to take one.
541
350
  */
542
- async function runCalls(run, parsed, messages, step) {
543
- const { signal, toolCalls } = run;
544
- // Per step, not per run: the answer to a call made two steps ago was true before the tools in
545
- // between ran, and the file the model read may be the file it has since written.
546
- const answered = new Map();
547
- // By position, so a call that came back while another was being stopped is still found.
548
- const outcomes = parsed.map(() => undefined);
549
- // How many of the step's calls were handed off, and how many have their result in the
550
- // transcript. Both count from the front, since results are written in the order asked.
551
- let begun = 0;
552
- let kept = 0;
553
- const keep = async (id, content) => {
554
- const result = {
555
- role: "tool",
556
- tool_call_id: id,
557
- content,
558
- };
559
- messages.push(result);
560
- kept++;
561
- await run.announce(result, step);
562
- };
563
- try {
564
- if (run.parallel) {
565
- signal?.throwIfAborted();
566
- begun = parsed.length;
567
- await Promise.all(parsed.map(async (call, at) => {
568
- outcomes[at] = await runCall(run, call, answered);
569
- }));
570
- for (const { id, name, ok, content } of outcomes.flatMap((outcome) => outcome ?? [])) {
571
- toolCalls.push({ id, name, ok });
572
- await keep(id, content);
573
- }
351
+ function announcer(onMessage) {
352
+ let heard = true;
353
+ return async (message, step, turn) => {
354
+ if (!onMessage || !heard) {
355
+ return;
574
356
  }
575
- else {
576
- for (const [at, call] of parsed.entries()) {
577
- signal?.throwIfAborted();
578
- begun = at + 1;
579
- const { id, name, ok, content } = await runCall(run, call, answered);
580
- toolCalls.push({ id, name, ok });
581
- await keep(id, content);
582
- }
357
+ try {
358
+ await onMessage(message, step, turn);
583
359
  }
584
- }
585
- catch (error) {
586
- // Only a call that was made counts in `toolCalls`.
587
- for (const [at, entry] of parsed.entries()) {
588
- const { call } = entry;
589
- if (at < kept)
590
- continue;
591
- const outcome = outcomes[at];
592
- // Under the name it was announced by, which for a `call_tool` is the tool it names.
593
- if (at < begun) {
594
- toolCalls.push({ id: call.id, name: shownAs(run, entry).name, ok: outcome?.ok ?? false });
595
- }
596
- // The run is already ending on `error`; a host that cannot take this result does not
597
- // get to replace it.
598
- await keep(call.id, outcome?.content ?? (at < begun ? STOPPED_CALL : UNRUN_CALL)).catch(() => { });
360
+ catch (error) {
361
+ heard = false;
362
+ throw error;
599
363
  }
600
- throw error;
601
- }
364
+ };
602
365
  }
603
366
  /**
604
- * Writes a proxied preselection into the transcript as a `load_tools` exchange: the tool array is
605
- * fixed, so a shortlist has nowhere else to go, and it is answered as though the model had loaded
606
- * it. Told to the host as a call the model made would be, so one pairing calls with results by id
607
- * shows this one like the rest.
367
+ * The `usage` event a turn ends in: the run's totals so far, and the turn's own report.
608
368
  *
609
- * @param run The run it opens.
610
- * @param shortlist The definitions preselected, each of them in the catalogue.
611
- * @param messages The transcript, which the exchange is appended to.
369
+ * @param usage - The run's totals, this turn's already added.
370
+ * @param turn - The turn just finished, read for its usage and why it ended.
371
+ * @param ledger - The ledger as it stands. Carried on the event as it is, not copied.
372
+ * @returns The event, for the caller to emit.
612
373
  */
613
- async function loadShortlist(run, shortlist, messages) {
614
- const { catalog } = run;
615
- const names = shortlist.map((tool) => toolName(tool) ?? "");
616
- // Numbered by where the call lands, so two questions in one transcript do not share an id.
617
- const id = `preselect-${messages.length}`;
618
- const args = JSON.stringify({ names });
619
- // Held to its own length rather than `MAX_PER_LOAD`: that cap is for a model choosing, and a
620
- // host that shortlisted more has already chosen.
621
- const content = proxyLoadResult(expandNames(names, catalog, names.length), catalog, shortlist);
622
- run.onEvent({ kind: "tool-call", id, name: LOAD_TOOLS, text: preview(args) });
623
- run.onToolCall?.({ id, name: LOAD_TOOLS, args: { names }, raw: args });
624
- run.onEvent({ kind: "tool-result", id, name: LOAD_TOOLS, ok: true, text: preview(content) });
625
- run.onToolResult?.({ id, name: LOAD_TOOLS, ok: true, content });
626
- run.toolCalls.push({ id, name: LOAD_TOOLS, ok: true });
627
- run.loads.toolsLoaded += names.length;
628
- for (const name of names)
629
- run.loaded.add(name);
630
- const exchange = [
631
- {
632
- role: "assistant",
633
- content: null,
634
- tool_calls: [{ id, type: "function", function: { name: LOAD_TOOLS, arguments: args } }],
635
- },
636
- { role: "tool", tool_call_id: id, content },
637
- ];
638
- // Both written before either is announced, so a host that throws on the first leaves a call
639
- // with its result. Told as step zero's, with no turn: no request was made for them.
640
- messages.push(...exchange);
641
- for (const message of exchange)
642
- await run.announce(message, 0);
374
+ const usageEvent = (usage, turn, ledger) => ({
375
+ kind: RunEventKind.Usage,
376
+ usage: {
377
+ promptTokens: usage.prompt,
378
+ completionTokens: usage.completion,
379
+ totalTokens: usage.total,
380
+ cachedTokens: usage.cached,
381
+ turn: { ...turn.usage, finishReason: turn.finishReason },
382
+ ledger,
383
+ },
384
+ });
385
+ /**
386
+ * The calls a turn made and the answer beside them, with calls the model wrote as text taken out
387
+ * of the answer and counted as calls.
388
+ *
389
+ * @param turn - The turn as it came back. Not written to.
390
+ * @param recoverable - The names a call written as text may use. Absent recovers nothing, and
391
+ * nothing is looked for in a turn that made a named call of its own or has no text.
392
+ * @param notice - Told when calls were recovered, and how many.
393
+ * @returns `calls`, the turn's own less any with no name, and `content`, its text as it was — or,
394
+ * where calls were recovered, those calls and the text without them.
395
+ */
396
+ function turnCalls(turn, recoverable, notice) {
397
+ // A call with no name is a fragment the server never finished sending: nothing to run, and
398
+ // an assistant message naming it would be answered by nothing.
399
+ const calls = turn.toolCalls.filter((call) => call.function.name);
400
+ if (!recoverable || calls.length || !turn.content) {
401
+ return { calls, content: turn.content };
402
+ }
403
+ const recovered = recoverToolCalls(turn.content, { names: recoverable });
404
+ if (!recovered.toolCalls.length) {
405
+ return { calls, content: turn.content };
406
+ }
407
+ notice(`recovered ${counted(recovered.toolCalls.length, 'tool call')} the model wrote as text; the server's tool-call parser does not match this model's template`);
408
+ return { calls: recovered.toolCalls, content: recovered.content };
643
409
  }
644
410
  /**
645
- * The steps of `runAgentLoop`, throwing what they caught as it was caught. Resolves to nothing
646
- * when `maxToolIterations` is spent, and tells `standing` how to read the run either way.
411
+ * The steps of `runAgentLoop`, throwing what they caught as it was caught.
412
+ *
413
+ * @param options - What `runAgentLoop` was given.
414
+ * @param standing - Written to: once the steps have set themselves up, its `read` is theirs, so the
415
+ * caller can read the run as it stands whether they return or throw.
416
+ * @returns The result of the turn that asked for no tools, or `undefined` when `maxToolIterations`
417
+ * is spent.
647
418
  */
648
419
  async function runSteps(options, standing) {
649
- const { config, system = "", tools = [], catalog = [], dispatch, hooks, signal } = options;
650
- const { onRequest, onTurn, onMessage, onToolCall, onToolResult, beforeStep, parallel = false, recoverToolCalls: recover = true, maxContinuations = 0, toolOrder = true, dedupeToolCalls = true, preselectRouting = "exclusive", } = options;
651
- const dedupable = typeof dedupeToolCalls === "function" ? dedupeToolCalls : () => dedupeToolCalls;
420
+ const { config, system = '', tools = [], catalog = [], dispatch, hooks, signal } = options;
421
+ const { onRequest, onTurn, onMessage, onToolCall, onToolResult, beforeStep, parallel = false, recoverToolCalls: recover = true, maxContinuations = 0, toolOrder = true, dedupeToolCalls = true, preselectRouting = PRESELECT_EXCLUSIVE, } = options;
422
+ const dedupable = typeof dedupeToolCalls === 'function' ? dedupeToolCalls : () => dedupeToolCalls;
652
423
  const started = Date.now();
653
- // What the loop emitted, less the token deltas, for `runMetrics` at the end. Stamped here rather
654
- // than by the bus, which the loop does not know about.
655
- const recorded = [];
656
- const record = (input) => {
657
- if (input.kind === "thinking" || input.kind === "output")
658
- return;
659
- recorded.push(stamp(input, "", recorded.length + 1, Date.now()));
660
- };
661
- const onEvent = (input) => {
662
- record(input);
663
- options.onEvent?.(input);
664
- };
424
+ const { recorded, onEvent } = recorder(options.onEvent);
665
425
  const client = getClient(config);
666
426
  const supports = capabilitiesFor(config.baseUrl, config.apiKey);
667
427
  const maxRetries = Math.max(0, Number(config.maxRetries) || 0);
668
- const notice = (text) => onEvent({ kind: "notice", text });
669
- // On demand, but with a tool array that never changes: definitions come back as `load_tools`
670
- // results and run through `call_tool`. `onDemand` is true of both.
671
- const proxied = config.toolDiscovery === "proxy" && catalog.length > 0;
672
- const onDemand = proxied || (config.toolDiscovery === "ondemand" && catalog.length > 0);
673
- // Proxied, `loaded` is what this run has put a definition in the history for, and starts empty.
674
- const loaded = new Set(onDemand && !proxied ? (options.loaded ?? []) : []);
675
- // The loop's own preselection, where it was asked for and the host has not already decided.
676
- const preselected = !onDemand
677
- ? []
678
- : options.preselect && options.preselected === undefined
679
- ? await chooseTools(options, notice)
680
- : [...(options.preselected ?? [])];
681
- if (!proxied)
682
- for (const name of preselected)
683
- loaded.add(name);
428
+ const notice = (text) => onEvent({ kind: RunEventKind.Notice, text });
429
+ const { proxied, onDemand, loaded, preselected } = await discovery(options, notice);
684
430
  const summarise = ownSummariser(options, notice);
685
431
  const used = new Set();
686
- const definitions = new Map();
687
- for (const tool of tools) {
688
- const name = toolName(tool);
689
- if (name !== undefined && !definitions.has(name))
690
- definitions.set(name, tool);
691
- }
432
+ const definitions = definitionsByName(tools);
692
433
  // Looked through unless the host has a tool by that name, which is then the host's to answer.
693
434
  const proxies = onDemand && !definitions.has(CALL_TOOL);
694
435
  // What a call the model wrote as text may name: the host's tools, and the loop's own on demand.
@@ -700,7 +441,7 @@ async function runSteps(options, standing) {
700
441
  // in the order things were added, so a load appends and never reshuffles what went before.
701
442
  const byName = (names) => definedAs(definitions, names);
702
443
  let messages = [...options.messages];
703
- const usage = noUsage();
444
+ let usage = noUsage();
704
445
  const toolCalls = [];
705
446
  standing.read = () => ({
706
447
  messages,
@@ -710,32 +451,19 @@ async function runSteps(options, standing) {
710
451
  loaded: proxied ? [] : [...loaded],
711
452
  used: [...used],
712
453
  });
713
- // Once `onMessage` has thrown the run is ending on that, and the results still to be written
714
- // into the transcript are not offered to a host that has just failed to take one.
715
- let heard = true;
716
- const announce = async (message, step, turn) => {
717
- if (!onMessage || !heard)
718
- return;
719
- try {
720
- await onMessage(message, step, turn);
721
- }
722
- catch (error) {
723
- heard = false;
724
- throw error;
725
- }
726
- };
454
+ const announce = announcer(onMessage);
727
455
  // Held by reference rather than by index, so a `beforeStep` that folds the head into a summary
728
456
  // moves the question without losing it — and one that summarises the question away takes the
729
457
  // hooks' context with it, which is right.
730
- const question = messages.findLast((message) => message.role === "user");
458
+ const question = messages.findLast((message) => message.role === Role.User);
731
459
  const questionAt = () => (question ? messages.indexOf(question) : -1);
732
460
  const gathered = hooks
733
- ? await gather(hooks.run, hooks.events ?? ["beforeTurn"], hooks.context, {
461
+ ? await gather(hooks.run, hooks.events ?? [HookEvent.BeforeTurn], hooks.context, {
734
462
  signal,
735
463
  onNote: hooks.onNote,
736
464
  maxTokens: hooks.maxTokens,
737
465
  })
738
- : { context: "", notes: [] };
466
+ : { context: '', notes: [] };
739
467
  // Read once, so what the result hands back is what every step of this run said.
740
468
  const preface = hooks?.preface ?? configureHooks().preface;
741
469
  const loads = { toolsLoaded: 0, redundantLoads: 0, unknownToolNames: 0 };
@@ -771,8 +499,9 @@ async function runSteps(options, standing) {
771
499
  onToolResult,
772
500
  announce,
773
501
  };
774
- if (shortlist.length)
502
+ if (shortlist.length) {
775
503
  await loadShortlist(calling, shortlist, messages);
504
+ }
776
505
  for (let step = 0; step < config.maxToolIterations; step++) {
777
506
  // A stop aborts the request in flight, but a tool call already handed off runs to its own
778
507
  // end — so the signal is read between steps as well.
@@ -795,24 +524,29 @@ async function runSteps(options, standing) {
795
524
  : given;
796
525
  // A rewrite may have folded a definition away, and a load answered "already loaded" would
797
526
  // then point at nothing. Forgetting costs a definition sent twice at worst.
798
- if (proxied && next !== messages)
527
+ if (proxied && next !== messages) {
799
528
  loaded.clear();
529
+ }
800
530
  messages = next;
801
531
  ledger = rebaseLedger(ledger, before, messages);
802
- onEvent({ kind: "turn", text: `turn ${step + 1}` });
532
+ onEvent({ kind: RunEventKind.Turn, text: `turn ${step + 1}` });
803
533
  // Appended, the shortlist is already in `loaded` — after what was carried, and once — so the
804
534
  // first step needs nothing of its own. Proxied, it is in the history.
805
- const routed = !proxied && preselectRouting !== "append" && preselected.length > 0 && step === 0;
535
+ const routed = !proxied && preselectRouting !== PRESELECT_APPEND && preselected.length > 0 && step === 0;
806
536
  // Ordered here rather than left to `buildBody`, so `names` below is what the request actually
807
537
  // declared — a diagnosis reading an order the server never saw calls an untouched tool array
808
538
  // `tools-changed`.
809
- const declared = orderTools(routed
810
- ? byName(new Set(preselected))
811
- : proxied
812
- ? [...PROXY_TOOLS]
813
- : onDemand
814
- ? loadedTools([LOAD_TOOLS_DEFINITION], byName(loaded))
815
- : tools, toolOrder);
539
+ let offered = tools;
540
+ if (routed) {
541
+ offered = byName(new Set(preselected));
542
+ }
543
+ else if (proxied) {
544
+ offered = [...PROXY_TOOLS];
545
+ }
546
+ else if (onDemand) {
547
+ offered = loadedTools([LOAD_TOOLS_DEFINITION], byName(loaded));
548
+ }
549
+ const declared = orderTools(offered, toolOrder);
816
550
  // Unmarked, so the system prompt is the same text on every step and a load does not throw
817
551
  // away the cache for the whole transcript. What is loaded is said in `declared` and in the
818
552
  // `load_tools` result instead. The preselected first step is the one exception, by design,
@@ -820,7 +554,7 @@ async function runSteps(options, standing) {
820
554
  const catalogue = proxied ? proxyCatalogPrompt(catalog) : catalogPrompt(catalog);
821
555
  const prompt = onDemand && !routed ? `${system}\n\n${catalogue}`.trim() : system;
822
556
  const request = [
823
- ...(prompt ? [{ role: "system", content: prompt }] : []),
557
+ ...(prompt ? [{ role: Role.System, content: prompt }] : []),
824
558
  ...withContext(messages, questionAt(), gathered.context, preface),
825
559
  ];
826
560
  const build = (supported, refused) => buildBody(config, supported, refused, request, declared, toolOrder);
@@ -828,16 +562,14 @@ async function runSteps(options, standing) {
828
562
  model: config.model,
829
563
  droppable: Object.keys(config.extraBody ?? {}),
830
564
  maxRetries,
831
- ...(config.loadingTimeoutSeconds === undefined
832
- ? {}
833
- : { loadingTimeoutMs: Math.max(0, config.loadingTimeoutSeconds) * 1000 }),
565
+ loadingTimeoutMs: loadingMs(config),
834
566
  contextLimit: config.contextLength ?? 0,
835
567
  signal,
836
568
  idleMs: timeoutMs(config),
837
569
  firstChunkMs: firstTokenMs(config) ?? 0,
838
570
  onNotice: notice,
839
- onThinking: (text) => onEvent({ kind: "thinking", text }),
840
- onOutput: (text) => onEvent({ kind: "output", text }),
571
+ onThinking: (text) => onEvent({ kind: RunEventKind.Thinking, text }),
572
+ onOutput: (text) => onEvent({ kind: RunEventKind.Output, text }),
841
573
  };
842
574
  // Built the way `negotiate` is about to build the first attempt — the same flags, the same
843
575
  // model's refusals — so what the host is told is what is sent unless something is refused.
@@ -846,13 +578,13 @@ async function runSteps(options, standing) {
846
578
  onRequest({ messages: opening.messages, tools: opening.tools ?? [], step });
847
579
  }
848
580
  const first = await runTurn(client, supports, build, turnOptions);
849
- const names = declared.map((tool) => toolName(tool) ?? "");
581
+ const names = declared.map((tool) => toolName(tool) ?? '');
850
582
  // Compared before any continuation is joined on: the cache a request meets is the one its own
851
583
  // prompt found, and a continuation's prompt is this request's plus the reply so far.
852
584
  // The breakdown measures the array `toolSchemaTokens` does, so where no prompt was reported
853
585
  // the two give one number for the tool block rather than two.
854
586
  const charsPerToken = charsPerTokenFor(supports, config.model);
855
- Object.assign(first.usage, cacheDiagnosis(previous, request, names, first.usage), {
587
+ Object.assign(first.usage, cacheDiagnosis(previous, { messages: request, tools: names }, first.usage), {
856
588
  toolsDeclared: declared.length,
857
589
  toolSchemaTokens: Math.ceil(toolsChars(declared) / charsPerToken),
858
590
  context: contextTokens({ model: config.model, stream: true, messages: request, tools: declared }, { charsPerToken, promptTokens: first.usage.prompt }),
@@ -867,8 +599,9 @@ async function runSteps(options, standing) {
867
599
  through: messages.length - 1,
868
600
  };
869
601
  const entered = recordRequest(ledger, measured, sent);
870
- if (entered !== ledger)
602
+ if (entered !== ledger) {
871
603
  measured = sent;
604
+ }
872
605
  ledger = entered;
873
606
  const turn = maxContinuations > 0
874
607
  ? await continueTurn(client, supports, build, first, { ...turnOptions, maxContinuations })
@@ -879,33 +612,13 @@ async function runSteps(options, standing) {
879
612
  prompt: firstPrompt,
880
613
  completion: turn.usage.completion,
881
614
  };
882
- accumulate(usage, turn.usage);
883
- onEvent({
884
- kind: "usage",
885
- usage: {
886
- promptTokens: usage.prompt,
887
- completionTokens: usage.completion,
888
- totalTokens: usage.total,
889
- cachedTokens: usage.cached,
890
- turn: { ...turn.usage, finishReason: turn.finishReason },
891
- ledger,
892
- },
893
- });
894
- if (turn.finishReason === "length") {
615
+ usage = addCounts(usage, turn.usage);
616
+ onEvent(usageEvent(usage, turn, ledger));
617
+ if (turn.finishReason === FinishReason.Length) {
895
618
  notice(`the model stopped at maxTokens (${config.maxTokens}); this turn is cut short`);
896
619
  }
897
- // A call with no name is a fragment the server never finished sending: nothing to run, and
898
- // an assistant message naming it would be answered by nothing.
899
- let calls = turn.toolCalls.filter((call) => call.function.name);
900
- let content = turn.content;
901
- if (recover && !calls.length && content && (tools.length > 0 || onDemand)) {
902
- const recovered = recoverToolCalls(content, { names: recoverable });
903
- if (recovered.toolCalls.length) {
904
- calls = recovered.toolCalls;
905
- content = recovered.content;
906
- notice(`recovered ${counted(calls.length, "tool call")} the model wrote as text; the server's tool-call parser does not match this model's template`);
907
- }
908
- }
620
+ const canRecover = recover && (tools.length > 0 || onDemand);
621
+ const { calls, content } = turnCalls(turn, canRecover ? recoverable : undefined, notice);
909
622
  const shown = { ...turn, content, toolCalls: calls };
910
623
  onTurn?.(shown, step);
911
624
  // Read before the assistant message is written, so what is replayed on every later request
@@ -917,9 +630,7 @@ async function runSteps(options, standing) {
917
630
  if (!calls.length) {
918
631
  // Not awaited: the answer is ready, and remembering it is not something to hold it for.
919
632
  // Handed back instead, for a host that wants its notes.
920
- const afterTurn = hooks
921
- ? tellAfterTurn(hooks, messages, questionAt(), turn.content)
922
- : Promise.resolve([]);
633
+ const afterTurn = hooks ? tellAfterTurn(hooks, messages, questionAt(), turn.content) : Promise.resolve([]);
923
634
  const metrics = runMetrics(recorded, { contextLength: config.contextLength });
924
635
  return {
925
636
  turn: shown,
@@ -936,7 +647,7 @@ async function runSteps(options, standing) {
936
647
  ...metrics,
937
648
  ...(onDemand ? loads : {}),
938
649
  wallMs: Date.now() - started,
939
- outcome: turn.finishReason === "length" ? "truncated" : "answered",
650
+ outcome: turn.finishReason === FinishReason.Length ? RunOutcome.Truncated : RunOutcome.Answered,
940
651
  },
941
652
  ledger,
942
653
  };
@@ -945,23 +656,3 @@ async function runSteps(options, standing) {
945
656
  }
946
657
  return undefined;
947
658
  }
948
- /**
949
- * Makes a call at most once per key, sharing the in-flight promise so two identical calls in one
950
- * step make one request between them whether they run together or one after the other. A call
951
- * that rejected is forgotten, so asking again is a real retry rather than a replayed failure.
952
- */
953
- async function once(answered, key, make) {
954
- const previous = answered.get(key);
955
- if (previous)
956
- return previous;
957
- const pending = make();
958
- answered.set(key, pending);
959
- try {
960
- return await pending;
961
- }
962
- catch (error) {
963
- if (answered.get(key) === pending)
964
- answered.delete(key);
965
- throw error;
966
- }
967
- }