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