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