@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.
- package/README.md +14 -9
- package/dist/agent-loop.d.ts +84 -204
- package/dist/agent-loop.js +416 -643
- package/dist/calibration.d.ts +14 -10
- package/dist/calibration.js +30 -22
- package/dist/capabilities.d.ts +140 -35
- package/dist/capabilities.js +345 -152
- package/dist/client.d.ts +104 -35
- package/dist/client.js +210 -78
- package/dist/compaction.d.ts +103 -60
- package/dist/compaction.js +163 -93
- package/dist/config.d.ts +48 -12
- package/dist/config.js +20 -1
- package/dist/continuation.d.ts +25 -17
- package/dist/continuation.js +70 -47
- package/dist/digest.d.ts +7 -0
- package/dist/digest.js +18 -0
- package/dist/errors.d.ts +24 -15
- package/dist/errors.js +23 -15
- package/dist/events.d.ts +108 -58
- package/dist/events.js +265 -128
- package/dist/guards.d.ts +53 -0
- package/dist/guards.js +55 -0
- package/dist/hook-events.d.ts +38 -0
- package/dist/hook-events.js +36 -0
- package/dist/hooks.d.ts +105 -83
- package/dist/hooks.js +224 -139
- package/dist/index.d.ts +30 -25
- package/dist/index.js +29 -23
- package/dist/ledger.d.ts +62 -30
- package/dist/ledger.js +163 -56
- package/dist/platform.d.ts +13 -0
- package/dist/platform.js +13 -0
- package/dist/preselect.d.ts +222 -0
- package/dist/preselect.js +290 -0
- package/dist/request-body.d.ts +32 -0
- package/dist/request-body.js +47 -0
- package/dist/reset.d.ts +1 -0
- package/dist/reset.js +7 -6
- package/dist/retry.d.ts +40 -143
- package/dist/retry.js +79 -285
- package/dist/run-calls.d.ts +123 -0
- package/dist/run-calls.js +351 -0
- package/dist/run-turn.d.ts +44 -16
- package/dist/run-turn.js +74 -37
- package/dist/runtime.d.ts +23 -17
- package/dist/runtime.js +34 -24
- package/dist/schema-compat.d.ts +51 -11
- package/dist/schema-compat.js +375 -193
- package/dist/scope.d.ts +28 -5
- package/dist/scope.js +37 -10
- package/dist/side-task.d.ts +127 -39
- package/dist/side-task.js +189 -100
- package/dist/snapshot.d.ts +12 -7
- package/dist/snapshot.js +87 -65
- package/dist/spec.d.ts +66 -36
- package/dist/spec.js +514 -265
- package/dist/stream.d.ts +73 -16
- package/dist/stream.js +228 -98
- package/dist/thinking.d.ts +23 -8
- package/dist/thinking.js +96 -46
- package/dist/tokens.d.ts +171 -6
- package/dist/tokens.js +326 -6
- package/dist/tool-calls.d.ts +89 -15
- package/dist/tool-calls.js +205 -85
- package/dist/tool-loading.d.ts +109 -187
- package/dist/tool-loading.js +286 -372
- package/dist/wire.d.ts +81 -0
- package/dist/wire.js +73 -0
- package/llms.txt +87 -43
- package/package.json +4 -4
package/dist/agent-loop.js
CHANGED
|
@@ -1,231 +1,30 @@
|
|
|
1
|
-
import { charsPerTokenFor } from
|
|
2
|
-
import { capabilitiesFor,
|
|
3
|
-
import { firstTokenMs, getClient,
|
|
4
|
-
import { compactTranscript, planCompaction, summariser
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
/** The
|
|
27
|
-
const
|
|
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,
|
|
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,
|
|
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:
|
|
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
|
-
/**
|
|
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 ===
|
|
150
|
+
if (typeof content === 'string') {
|
|
330
151
|
return content;
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
337
|
-
*
|
|
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
|
|
340
|
-
const { config,
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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 ===
|
|
373
|
-
const onDemand = proxied || (config.toolDiscovery ===
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
418
|
-
|
|
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) =>
|
|
442
|
+
const byName = (names) => definedAs(definitions, names);
|
|
426
443
|
let messages = [...options.messages];
|
|
427
|
-
|
|
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
|
-
|
|
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 ===
|
|
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 ?? [
|
|
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:
|
|
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
|
|
471
|
-
//
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
...
|
|
512
|
+
...reported,
|
|
514
513
|
limit: config.contextLength ?? 0,
|
|
515
514
|
ledger,
|
|
516
515
|
});
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
526
|
-
})
|
|
527
|
-
|
|
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:
|
|
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 !==
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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:
|
|
566
|
-
...withContext(messages,
|
|
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
|
-
|
|
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:
|
|
582
|
-
onOutput: (text) => onEvent({ kind:
|
|
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
|
|
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
|
-
|
|
625
|
-
onEvent(
|
|
626
|
-
|
|
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
|
-
|
|
640
|
-
|
|
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
|
|
658
|
-
|
|
659
|
-
const
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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 ===
|
|
650
|
+
outcome: turn.finishReason === FinishReason.Length ? RunOutcome.Truncated : RunOutcome.Answered,
|
|
720
651
|
},
|
|
721
652
|
ledger,
|
|
722
653
|
};
|
|
723
654
|
}
|
|
724
|
-
|
|
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
|
-
}
|