@cubicecho/agent-core 2.18.1 → 2.20.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 +230 -7
- package/dist/agent-loop.d.ts +218 -18
- package/dist/agent-loop.js +180 -39
- package/dist/compaction.d.ts +30 -2
- package/dist/compaction.js +53 -13
- package/dist/config.d.ts +4 -2
- package/dist/events.d.ts +17 -4
- package/dist/hooks.d.ts +8 -2
- package/dist/hooks.js +8 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/ledger.d.ts +132 -0
- package/dist/ledger.js +166 -0
- package/dist/retry.d.ts +6 -0
- package/dist/retry.js +6 -2
- package/dist/spec.d.ts +5 -1
- package/dist/spec.js +4 -2
- package/dist/stream.d.ts +11 -0
- package/dist/tool-loading.d.ts +98 -0
- package/dist/tool-loading.js +215 -0
- package/llms.txt +24 -1
- package/package.json +1 -1
package/dist/agent-loop.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { charsPerTokenFor } from "./calibration.js";
|
|
2
|
-
import { capabilitiesFor, effortFor, } from "./capabilities.js";
|
|
2
|
+
import { capabilitiesFor, effortFor, modelCapabilitiesFor, } from "./capabilities.js";
|
|
3
3
|
import { firstTokenMs, getClient, NO_KEY, timeoutMs } from "./client.js";
|
|
4
4
|
import { continueTurn } from "./continuation.js";
|
|
5
5
|
import { errorMessage } from "./errors.js";
|
|
6
6
|
import { runMetrics } from "./events.js";
|
|
7
|
-
import { gather, notify, turnIndex, turnMessages, withContext, } from "./hooks.js";
|
|
8
|
-
import {
|
|
7
|
+
import { configureHooks, gather, notify, turnIndex, turnMessages, withContext, } from "./hooks.js";
|
|
8
|
+
import { rebaseLedger, recordRequest } from "./ledger.js";
|
|
9
|
+
import { contextTokens, toolsChars } from "./retry.js";
|
|
9
10
|
import { runTurn } from "./run-turn.js";
|
|
10
11
|
import { relaxTools, sanitizeTools } from "./schema-compat.js";
|
|
11
12
|
import { askJson, tryAsk } from "./side-task.js";
|
|
12
13
|
import { parseToolArguments, recoverToolCalls } from "./tool-calls.js";
|
|
13
|
-
import { catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, preselectByKeywords, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
|
|
14
|
+
import { CALL_TOOL, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PROXY_TOOLS, preselectByKeywords, preselectInput, preselection, preselectSystem, proxiedCall, proxyCatalogPrompt, proxyLoadResult, requestedNames, shownCall, } from "./tool-loading.js";
|
|
14
15
|
/**
|
|
15
16
|
* The loop above a turn: send, run the tools the model asked for, send again, until it stops
|
|
16
17
|
* asking.
|
|
@@ -206,17 +207,32 @@ function cacheDiagnosis(previous, messages, tools, usage) {
|
|
|
206
207
|
* prompt unchanged from step to step, a load adds to the tool array — which every request sends
|
|
207
208
|
* in the stable order `toolOrder` asks for — and
|
|
208
209
|
* a catalogued tool called without being loaded is loaded and run rather than refused, and a
|
|
209
|
-
* preselection shapes the first step
|
|
210
|
-
* because it otherwise reads exactly like a finished one — or, given `maxContinuations`, is
|
|
210
|
+
* preselection shapes the first step unless `preselectRouting` says to append it. A turn cut off
|
|
211
|
+
* at `maxTokens` is said so as a notice, because it otherwise reads exactly like a finished one — or, given `maxContinuations`, is
|
|
211
212
|
* continued first. Every turn ends in a `usage` event carrying the turn's own report, with the
|
|
212
|
-
* cache compared against the request before it
|
|
213
|
+
* cache compared against the request before it and the request broken down by what filled it; see
|
|
214
|
+
* `TurnUsage` and `runMetrics`.
|
|
215
|
+
*
|
|
216
|
+
* Each step's reported prompt goes on a token ledger, in the same epoch as the step before where
|
|
217
|
+
* the request only appended to it and in a new one where it did not — a `beforeStep` that rewrote
|
|
218
|
+
* the transcript, a tool array a load grew — so that what the messages between two steps cost can
|
|
219
|
+
* be read back as a subtraction. The prompt recorded is the first request's, before any
|
|
220
|
+
* continuation, and a step that reported none, or reported a cache count above its prompt, is not
|
|
221
|
+
* recorded. The ledger comes back in the result and on every `usage` event; see `tokensBetween`.
|
|
222
|
+
*
|
|
223
|
+
* Proxied discovery is the same catalogue behind a tool array that never moves: every step
|
|
224
|
+
* declares `PROXY_TOOLS` and nothing else, a load answers with the definitions themselves, and a
|
|
225
|
+
* `call_tool` is dispatched, counted and reported as the tool it names. A preselection has no
|
|
226
|
+
* tool array to go into, so it is written into the transcript as a `load_tools` exchange after
|
|
227
|
+
* the question. `call_tool` is honoured on demand too, unless the host has a tool of that name —
|
|
228
|
+
* a session switched out of proxied mode still has it in its history, and the model copies it.
|
|
213
229
|
*
|
|
214
230
|
* @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
|
|
215
231
|
* and cancellation. See `AgentLoopOptions`.
|
|
216
232
|
*/
|
|
217
233
|
export async function runAgentLoop(options) {
|
|
218
234
|
const { config, system = "", tools = [], catalog = [], dispatch, hooks, signal } = options;
|
|
219
|
-
const { onTurn, beforeStep, parallel = false, recoverToolCalls: recover = true, maxContinuations = 0, toolOrder = true, dedupeToolCalls = true, } = options;
|
|
235
|
+
const { onRequest, onTurn, onToolCall, onToolResult, beforeStep, parallel = false, recoverToolCalls: recover = true, maxContinuations = 0, toolOrder = true, dedupeToolCalls = true, preselectRouting = "exclusive", } = options;
|
|
220
236
|
const dedupable = typeof dedupeToolCalls === "function" ? dedupeToolCalls : () => dedupeToolCalls;
|
|
221
237
|
const started = Date.now();
|
|
222
238
|
// What the loop emitted, less the token deltas, for `runMetrics` at the end. Stamped here rather
|
|
@@ -245,11 +261,16 @@ export async function runAgentLoop(options) {
|
|
|
245
261
|
const supports = capabilitiesFor(config.baseUrl, config.apiKey);
|
|
246
262
|
const maxRetries = Math.max(0, Number(config.maxRetries) || 0);
|
|
247
263
|
const notice = (text) => onEvent({ kind: "notice", text });
|
|
248
|
-
|
|
249
|
-
|
|
264
|
+
// On demand, but with a tool array that never changes: definitions come back as `load_tools`
|
|
265
|
+
// results and run through `call_tool`. `onDemand` is true of both.
|
|
266
|
+
const proxied = config.toolDiscovery === "proxy" && catalog.length > 0;
|
|
267
|
+
const onDemand = proxied || (config.toolDiscovery === "ondemand" && catalog.length > 0);
|
|
268
|
+
// Proxied, `loaded` is what this run has put a definition in the history for, and starts empty.
|
|
269
|
+
const loaded = new Set(onDemand && !proxied ? (options.loaded ?? []) : []);
|
|
250
270
|
const preselected = onDemand ? [...(options.preselected ?? [])] : [];
|
|
251
|
-
|
|
252
|
-
|
|
271
|
+
if (!proxied)
|
|
272
|
+
for (const name of preselected)
|
|
273
|
+
loaded.add(name);
|
|
253
274
|
const used = new Set();
|
|
254
275
|
const definitions = new Map();
|
|
255
276
|
for (const tool of tools) {
|
|
@@ -257,6 +278,8 @@ export async function runAgentLoop(options) {
|
|
|
257
278
|
definitions.set(tool.function.name, tool);
|
|
258
279
|
}
|
|
259
280
|
}
|
|
281
|
+
// Looked through unless the host has a tool by that name, which is then the host's to answer.
|
|
282
|
+
const proxies = onDemand && !definitions.has(CALL_TOOL);
|
|
260
283
|
// In the order the names are given, not the order of `tools`: `loaded` is a set, which iterates
|
|
261
284
|
// in the order things were added, so a load appends and never reshuffles what went before.
|
|
262
285
|
const byName = (names) => [...names].flatMap((name) => definitions.get(name) ?? []);
|
|
@@ -272,32 +295,84 @@ export async function runAgentLoop(options) {
|
|
|
272
295
|
maxTokens: hooks.maxTokens,
|
|
273
296
|
})
|
|
274
297
|
: { context: "", notes: [] };
|
|
298
|
+
// Read once, so what the result hands back is what every step of this run said.
|
|
299
|
+
const preface = hooks?.preface ?? configureHooks().preface;
|
|
275
300
|
const usage = { prompt: 0, completion: 0, total: 0, cached: 0 };
|
|
276
301
|
const toolCalls = [];
|
|
277
302
|
const loads = { toolsLoaded: 0, redundantLoads: 0, unknownToolNames: 0 };
|
|
278
303
|
let previous;
|
|
304
|
+
let ledger = options.ledger ?? [];
|
|
305
|
+
// The request the ledger's last entry came from, which is what the next one is measured against.
|
|
306
|
+
// Nothing before this run's first: an earlier run's head is not something the loop can see.
|
|
307
|
+
let measured;
|
|
308
|
+
// Proxied, a shortlist has nowhere to go but the history: the tool array is fixed, so it is
|
|
309
|
+
// answered as though the model had loaded it, and the definitions sit after the question.
|
|
310
|
+
const shortlist = proxied
|
|
311
|
+
? byName(new Set(preselected)).filter((tool) => tool.type === "function" && inCatalog(catalog, tool.function.name))
|
|
312
|
+
: [];
|
|
313
|
+
if (shortlist.length) {
|
|
314
|
+
const names = shortlist.map((tool) => (tool.type === "function" ? tool.function.name : ""));
|
|
315
|
+
// Numbered by where the call lands, so two questions in one transcript do not share an id.
|
|
316
|
+
const id = `preselect-${messages.length}`;
|
|
317
|
+
const args = JSON.stringify({ names });
|
|
318
|
+
// Held to its own length rather than `MAX_PER_LOAD`: that cap is for a model choosing, and a
|
|
319
|
+
// host that shortlisted more has already chosen.
|
|
320
|
+
const content = proxyLoadResult(expandNames(names, catalog, names.length), catalog, shortlist);
|
|
321
|
+
// Announced as a call the model made would be, so a host pairing calls with results by id
|
|
322
|
+
// shows this one like the rest.
|
|
323
|
+
onEvent({ kind: "tool-call", id, name: LOAD_TOOLS, text: preview(args) });
|
|
324
|
+
onToolCall?.({ id, name: LOAD_TOOLS, args: { names }, raw: args });
|
|
325
|
+
onEvent({ kind: "tool-result", id, name: LOAD_TOOLS, ok: true, text: preview(content) });
|
|
326
|
+
onToolResult?.({ id, name: LOAD_TOOLS, ok: true, content });
|
|
327
|
+
toolCalls.push({ id, name: LOAD_TOOLS, ok: true });
|
|
328
|
+
loads.toolsLoaded += names.length;
|
|
329
|
+
for (const name of names)
|
|
330
|
+
loaded.add(name);
|
|
331
|
+
messages.push({
|
|
332
|
+
role: "assistant",
|
|
333
|
+
content: null,
|
|
334
|
+
tool_calls: [{ id, type: "function", function: { name: LOAD_TOOLS, arguments: args } }],
|
|
335
|
+
}, { role: "tool", tool_call_id: id, content });
|
|
336
|
+
}
|
|
279
337
|
for (let step = 0; step < config.maxToolIterations; step++) {
|
|
280
338
|
// A stop aborts the request in flight, but a tool call already handed off runs to its own
|
|
281
339
|
// end — so the signal is read between steps as well.
|
|
282
340
|
signal?.throwIfAborted();
|
|
283
|
-
|
|
341
|
+
const before = messages;
|
|
342
|
+
const rewritten = await beforeStep?.(messages, step, {
|
|
343
|
+
...(previous && previous.prompt > 0 ? { used: previous.prompt } : {}),
|
|
344
|
+
limit: config.contextLength ?? 0,
|
|
345
|
+
ledger,
|
|
346
|
+
});
|
|
347
|
+
// A rewrite may have folded a definition away, and a load answered "already loaded" would
|
|
348
|
+
// then point at nothing. Forgetting costs a definition sent twice at worst.
|
|
349
|
+
if (proxied && rewritten && rewritten !== messages)
|
|
350
|
+
loaded.clear();
|
|
351
|
+
messages = rewritten ?? messages;
|
|
352
|
+
ledger = rebaseLedger(ledger, before, messages);
|
|
284
353
|
onEvent({ kind: "turn", text: `turn ${step + 1}` });
|
|
285
|
-
|
|
354
|
+
// Appended, the shortlist is already in `loaded` — after what was carried, and once — so the
|
|
355
|
+
// first step needs nothing of its own. Proxied, it is in the history.
|
|
356
|
+
const routed = !proxied && preselectRouting !== "append" && preselected.length > 0 && step === 0;
|
|
286
357
|
// Ordered here rather than left to `buildBody`, so `names` below is what the request actually
|
|
287
358
|
// declared — a diagnosis reading an order the server never saw calls an untouched tool array
|
|
288
359
|
// `tools-changed`.
|
|
289
360
|
const declared = orderTools(routed
|
|
290
361
|
? byName(new Set(preselected))
|
|
291
|
-
:
|
|
292
|
-
?
|
|
293
|
-
:
|
|
362
|
+
: proxied
|
|
363
|
+
? [...PROXY_TOOLS]
|
|
364
|
+
: onDemand
|
|
365
|
+
? loadedTools([LOAD_TOOLS_DEFINITION], byName(loaded))
|
|
366
|
+
: tools, toolOrder);
|
|
294
367
|
// Unmarked, so the system prompt is the same text on every step and a load does not throw
|
|
295
368
|
// away the cache for the whole transcript. What is loaded is said in `declared` and in the
|
|
296
|
-
// `load_tools` result instead. The preselected first step is the one exception, by design
|
|
297
|
-
|
|
369
|
+
// `load_tools` result instead. The preselected first step is the one exception, by design,
|
|
370
|
+
// and `preselectRouting: "append"` is the way out of it.
|
|
371
|
+
const catalogue = proxied ? proxyCatalogPrompt(catalog) : catalogPrompt(catalog);
|
|
372
|
+
const prompt = onDemand && !routed ? `${system}\n\n${catalogue}`.trim() : system;
|
|
298
373
|
const request = [
|
|
299
374
|
...(prompt ? [{ role: "system", content: prompt }] : []),
|
|
300
|
-
...withContext(messages, question ? messages.indexOf(question) : -1, gathered.context,
|
|
375
|
+
...withContext(messages, question ? messages.indexOf(question) : -1, gathered.context, preface),
|
|
301
376
|
];
|
|
302
377
|
const build = (supported, refused) => buildBody(config, supported, refused, request, declared, toolOrder);
|
|
303
378
|
const turnOptions = {
|
|
@@ -315,15 +390,37 @@ export async function runAgentLoop(options) {
|
|
|
315
390
|
onThinking: (text) => onEvent({ kind: "thinking", text }),
|
|
316
391
|
onOutput: (text) => onEvent({ kind: "output", text }),
|
|
317
392
|
};
|
|
393
|
+
// Built the way `negotiate` is about to build the first attempt — the same flags, the same
|
|
394
|
+
// model's refusals — so what the host is told is what is sent unless something is refused.
|
|
395
|
+
if (onRequest) {
|
|
396
|
+
const opening = build(supports, modelCapabilitiesFor(supports, config.model));
|
|
397
|
+
onRequest({ messages: opening.messages, tools: opening.tools ?? [], step });
|
|
398
|
+
}
|
|
318
399
|
const first = await runTurn(client, supports, build, turnOptions);
|
|
319
400
|
const names = declared.map((tool) => (tool.type === "function" ? tool.function.name : ""));
|
|
320
401
|
// Compared before any continuation is joined on: the cache a request meets is the one its own
|
|
321
402
|
// prompt found, and a continuation's prompt is this request's plus the reply so far.
|
|
403
|
+
// The breakdown measures the array `toolSchemaTokens` does, so where no prompt was reported
|
|
404
|
+
// the two give one number for the tool block rather than two.
|
|
405
|
+
const charsPerToken = charsPerTokenFor(supports, config.model);
|
|
322
406
|
Object.assign(first.usage, cacheDiagnosis(previous, request, names, first.usage), {
|
|
323
407
|
toolsDeclared: declared.length,
|
|
324
|
-
toolSchemaTokens: Math.ceil(toolsChars(declared) /
|
|
408
|
+
toolSchemaTokens: Math.ceil(toolsChars(declared) / charsPerToken),
|
|
409
|
+
context: contextTokens({ model: config.model, stream: true, messages: request, tools: declared }, { charsPerToken, promptTokens: first.usage.prompt }),
|
|
325
410
|
});
|
|
326
411
|
const firstPrompt = first.usage.prompt;
|
|
412
|
+
// Recorded from the first request for the same reason. A cache count above the prompt is a
|
|
413
|
+
// server reporting its prompt net of the cache, whose differences would measure nothing.
|
|
414
|
+
const sent = {
|
|
415
|
+
messages: request,
|
|
416
|
+
tools: names,
|
|
417
|
+
prompt: first.usage.cached > firstPrompt ? 0 : firstPrompt,
|
|
418
|
+
through: messages.length - 1,
|
|
419
|
+
};
|
|
420
|
+
const entered = recordRequest(ledger, measured, sent);
|
|
421
|
+
if (entered !== ledger)
|
|
422
|
+
measured = sent;
|
|
423
|
+
ledger = entered;
|
|
327
424
|
const turn = maxContinuations > 0
|
|
328
425
|
? await continueTurn(client, supports, build, first, { ...turnOptions, maxContinuations })
|
|
329
426
|
: first;
|
|
@@ -342,6 +439,7 @@ export async function runAgentLoop(options) {
|
|
|
342
439
|
totalTokens: usage.total,
|
|
343
440
|
cachedTokens: usage.cached,
|
|
344
441
|
turn: { ...turn.usage, finishReason: turn.finishReason },
|
|
442
|
+
ledger,
|
|
345
443
|
},
|
|
346
444
|
});
|
|
347
445
|
if (turn.finishReason === "length") {
|
|
@@ -354,7 +452,7 @@ export async function runAgentLoop(options) {
|
|
|
354
452
|
if (recover && !calls.length && content && (tools.length > 0 || onDemand)) {
|
|
355
453
|
const names = tools.flatMap((tool) => (tool.type === "function" ? [tool.function.name] : []));
|
|
356
454
|
const recovered = recoverToolCalls(content, {
|
|
357
|
-
names: onDemand ? [...names, LOAD_TOOLS] : names,
|
|
455
|
+
names: onDemand ? [...names, LOAD_TOOLS, ...(proxies ? [CALL_TOOL] : [])] : names,
|
|
358
456
|
});
|
|
359
457
|
if (recovered.toolCalls.length) {
|
|
360
458
|
calls = recovered.toolCalls;
|
|
@@ -391,11 +489,12 @@ export async function runAgentLoop(options) {
|
|
|
391
489
|
: {}),
|
|
392
490
|
});
|
|
393
491
|
if (!calls.length) {
|
|
492
|
+
let afterTurn = Promise.resolve([]);
|
|
394
493
|
if (hooks) {
|
|
395
494
|
const at = question ? messages.indexOf(question) : -1;
|
|
396
495
|
// Not awaited: the answer is ready, and remembering it is not something to hold it for.
|
|
397
|
-
//
|
|
398
|
-
|
|
496
|
+
// Handed back instead, for a host that wants its notes.
|
|
497
|
+
afterTurn = notify(hooks.run, "afterTurn", {
|
|
399
498
|
...hooks.context,
|
|
400
499
|
reply: turn.content,
|
|
401
500
|
...(at >= 0
|
|
@@ -414,47 +513,87 @@ export async function runAgentLoop(options) {
|
|
|
414
513
|
messages,
|
|
415
514
|
usage,
|
|
416
515
|
toolCalls,
|
|
417
|
-
loaded: [...loaded],
|
|
516
|
+
loaded: proxied ? [] : [...loaded],
|
|
418
517
|
used: [...used],
|
|
419
518
|
notes: gathered.notes,
|
|
519
|
+
context: gathered.context,
|
|
520
|
+
preface,
|
|
521
|
+
afterTurn,
|
|
420
522
|
metrics: {
|
|
421
523
|
...metrics,
|
|
422
524
|
...(onDemand ? loads : {}),
|
|
423
525
|
wallMs: Date.now() - started,
|
|
424
526
|
outcome: turn.finishReason === "length" ? "truncated" : "answered",
|
|
425
527
|
},
|
|
528
|
+
ledger,
|
|
426
529
|
};
|
|
427
530
|
}
|
|
428
531
|
// Per step, not per run: the answer to a call made two steps ago was true before the tools in
|
|
429
532
|
// between ran, and the file the model read may be the file it has since written.
|
|
430
533
|
const answered = new Map();
|
|
431
534
|
const run = async ({ call, args, error: unreadable, normal }) => {
|
|
432
|
-
const
|
|
433
|
-
|
|
535
|
+
const proxy = proxies && call.function.name === CALL_TOOL;
|
|
536
|
+
// A `call_tool` is shown, counted and dispatched as the tool it names. Read from the repaired
|
|
537
|
+
// arguments where there are any, since the model's own may be almost-JSON.
|
|
538
|
+
const { name, input: raw } = proxy
|
|
539
|
+
? shownCall(CALL_TOOL, args ? normal : call.function.arguments)
|
|
540
|
+
: { name: call.function.name, input: call.function.arguments };
|
|
541
|
+
// Looked through before the call is announced, so a host is told of the tool it names with
|
|
542
|
+
// that tool's arguments. A name outside the catalogue is refused here, so `call_tool` reaches
|
|
543
|
+
// nothing a load could not, and the refusal is the call's answer below.
|
|
544
|
+
let inner = args ?? {};
|
|
545
|
+
let refused;
|
|
546
|
+
if (proxy && args) {
|
|
547
|
+
try {
|
|
548
|
+
inner = proxiedCall(args, catalog).input;
|
|
549
|
+
}
|
|
550
|
+
catch (error) {
|
|
551
|
+
inner = {};
|
|
552
|
+
refused = { error };
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
// Built for every call rather than only the dispatched ones, so a host hears of the calls
|
|
556
|
+
// the loop answers itself in the same shape. Arguments that could not be read are none.
|
|
557
|
+
const request = { id: call.id, name, args: inner, raw };
|
|
558
|
+
onEvent({ kind: "tool-call", id: call.id, name, text: preview(raw) });
|
|
559
|
+
onToolCall?.(request);
|
|
434
560
|
let content;
|
|
435
561
|
let ok = true;
|
|
436
562
|
try {
|
|
437
563
|
if (!args)
|
|
438
564
|
throw unreadable;
|
|
439
|
-
|
|
565
|
+
// By the name the model called, not the one a `call_tool` wraps: `load_tools` is not in
|
|
566
|
+
// the catalogue, and one reached through `call_tool` is refused below like any other.
|
|
567
|
+
if (onDemand && call.function.name === LOAD_TOOLS) {
|
|
440
568
|
const resolved = expandNames(requestedNames(args), catalog);
|
|
441
|
-
content =
|
|
442
|
-
|
|
569
|
+
content = proxied
|
|
570
|
+
? proxyLoadResult(resolved, catalog, byName(resolved.matched), loaded)
|
|
571
|
+
: loadResult(resolved, catalog, loaded);
|
|
572
|
+
// Proxied, a load is only of what the result could define: a catalogued name the host
|
|
573
|
+
// gave no definition for was not loaded, and is not counted or remembered as though it was.
|
|
574
|
+
const hits = proxied
|
|
575
|
+
? resolved.matched.filter((hit) => definitions.has(hit))
|
|
576
|
+
: resolved.matched;
|
|
577
|
+
for (const hit of hits)
|
|
443
578
|
loads[loaded.has(hit) ? "redundantLoads" : "toolsLoaded"]++;
|
|
444
579
|
loads.unknownToolNames += resolved.unknown.length;
|
|
445
|
-
for (const hit of
|
|
580
|
+
for (const hit of hits)
|
|
446
581
|
loaded.add(hit);
|
|
447
|
-
ok =
|
|
582
|
+
ok = hits.length > 0;
|
|
448
583
|
}
|
|
449
584
|
else {
|
|
585
|
+
if (refused)
|
|
586
|
+
throw refused.error;
|
|
450
587
|
// A model that skips `load_tools` and calls a catalogued tool by name is right about
|
|
451
|
-
// what it wants; load it and run it rather than refusing.
|
|
452
|
-
|
|
588
|
+
// what it wants; load it and run it rather than refusing. Not proxied, where loaded
|
|
589
|
+
// means its definition is in the history, and this call put none there.
|
|
590
|
+
if (onDemand && !proxied && inCatalog(catalog, name))
|
|
453
591
|
loaded.add(name);
|
|
454
592
|
used.add(name);
|
|
455
|
-
|
|
593
|
+
// Keyed on the inner call, so one made through `call_tool` and one made natively share.
|
|
594
|
+
const key = `${name}\0${proxy ? JSON.stringify(inner) : normal}`;
|
|
456
595
|
content = dedupable(request)
|
|
457
|
-
? await once(answered,
|
|
596
|
+
? await once(answered, key, () => dispatch(request, signal))
|
|
458
597
|
: await dispatch(request, signal);
|
|
459
598
|
}
|
|
460
599
|
}
|
|
@@ -464,8 +603,10 @@ export async function runAgentLoop(options) {
|
|
|
464
603
|
content = errorMessage(error);
|
|
465
604
|
ok = false;
|
|
466
605
|
}
|
|
467
|
-
onEvent({ kind: "tool-result", name, ok, text: preview(content) });
|
|
468
|
-
|
|
606
|
+
onEvent({ kind: "tool-result", id: call.id, name, ok, text: preview(content) });
|
|
607
|
+
const result = { id: call.id, name, ok, content };
|
|
608
|
+
onToolResult?.(result);
|
|
609
|
+
return result;
|
|
469
610
|
};
|
|
470
611
|
const outcomes = [];
|
|
471
612
|
if (parallel) {
|
|
@@ -479,7 +620,7 @@ export async function runAgentLoop(options) {
|
|
|
479
620
|
}
|
|
480
621
|
}
|
|
481
622
|
for (const { id, name, ok, content } of outcomes) {
|
|
482
|
-
toolCalls.push({ name, ok });
|
|
623
|
+
toolCalls.push({ id, name, ok });
|
|
483
624
|
messages.push({ role: "tool", tool_call_id: id, content });
|
|
484
625
|
}
|
|
485
626
|
}
|
package/dist/compaction.d.ts
CHANGED
|
@@ -42,7 +42,9 @@ export interface PruneOptions {
|
|
|
42
42
|
* after it, and by then the model has usually taken what it wanted from it; the stub keeps the
|
|
43
43
|
* call answered — a call with no result is a malformed transcript — and says how much was there,
|
|
44
44
|
* so a model that does need it again knows to ask. Short results are kept, since a stub saves
|
|
45
|
-
* nothing on them
|
|
45
|
+
* nothing on them, and so is a proxied `load_tools` result carrying definitions, which is the
|
|
46
|
+
* only copy of them the model has (`holdsDefinitions`); either still counts as one of the latest
|
|
47
|
+
* few. Returns the same array when there was nothing to clear. Rewrites the prefix;
|
|
46
48
|
* see the module comment on when to run it.
|
|
47
49
|
*
|
|
48
50
|
* @param messages The transcript. Not written to.
|
|
@@ -62,6 +64,17 @@ export interface CompactionOptions {
|
|
|
62
64
|
compactAt?: number;
|
|
63
65
|
/** The fraction of `limit` the kept tail may fill. `KEEP_RATIO` by default. */
|
|
64
66
|
keepRatio?: number;
|
|
67
|
+
/**
|
|
68
|
+
* The fraction of `limit` the whole request should come down to, in place of `keepRatio`: the
|
|
69
|
+
* cut is the earliest that leaves `used`, less what is folded, plus `summaryTokens`, at or under
|
|
70
|
+
* it. Absent, or not above zero, plans by `keepRatio` as before.
|
|
71
|
+
*/
|
|
72
|
+
target?: number;
|
|
73
|
+
/**
|
|
74
|
+
* What the summary is assumed to cost when planning to a `target` — the ceiling its writer is
|
|
75
|
+
* held to, since the plan is made before it is written. 1024 by default, which is `summariser`'s.
|
|
76
|
+
*/
|
|
77
|
+
summaryTokens?: number;
|
|
65
78
|
/** One message's tokens. `messageTokens` by default, divided by `charsPerToken`. */
|
|
66
79
|
estimate?: (message: Message) => number;
|
|
67
80
|
/**
|
|
@@ -91,6 +104,12 @@ export interface CompactionPlan {
|
|
|
91
104
|
toSummarise: Message[];
|
|
92
105
|
/** The summary an earlier compaction left, which this one continues. */
|
|
93
106
|
previous?: string;
|
|
107
|
+
/**
|
|
108
|
+
* What the request is expected to cost once folded, in tokens: what was in use, less what is
|
|
109
|
+
* summarised, plus the summary's ceiling. Only on a plan made to a `target`, and above the
|
|
110
|
+
* target where no legal cut reaches it.
|
|
111
|
+
*/
|
|
112
|
+
after?: number;
|
|
94
113
|
}
|
|
95
114
|
/**
|
|
96
115
|
* Where to fold a transcript that has grown into its window, or `undefined` when it should not be.
|
|
@@ -107,11 +126,20 @@ export interface CompactionPlan {
|
|
|
107
126
|
* transcript — its system prompt a separate argument, no summary message in the array at all —
|
|
108
127
|
* knows them exactly, and passes `from` and `previous` instead of hoping the scan agrees.
|
|
109
128
|
*
|
|
129
|
+
* `keepRatio` bounds the kept tail alone, and the system prompt, the tool schemas and the summary
|
|
130
|
+
* sit in the window on top of it uncounted. Given a `target` the plan is made against what the
|
|
131
|
+
* request will be afterwards instead: the cut is the first user message at which `used`, less the
|
|
132
|
+
* folded messages, plus the summary's ceiling, is at or under that share of the window. Where no
|
|
133
|
+
* cut gets there the plan folds up to the last user message, which is as near as a legal one
|
|
134
|
+
* comes, and `after` says how far short it fell. An earlier summary the new one replaces is not
|
|
135
|
+
* credited, so `after` errs high by at most that much. It is only as good as `estimate` — see
|
|
136
|
+
* `estimateFrom` for one that is measured.
|
|
137
|
+
*
|
|
110
138
|
* @param messages The transcript, system prompts included if the caller keeps them in it.
|
|
111
139
|
* @param options The window, what is in use, the ratios, and where the last fold ended. See
|
|
112
140
|
* `CompactionOptions`.
|
|
113
141
|
*/
|
|
114
|
-
export declare function planCompaction(messages: Message[], { limit, used, compactAt, keepRatio, charsPerToken, estimate, from: givenFrom, previous: givenPrevious, }: CompactionOptions): CompactionPlan | undefined;
|
|
142
|
+
export declare function planCompaction(messages: Message[], { limit, used, compactAt, keepRatio, target, summaryTokens, charsPerToken, estimate, from: givenFrom, previous: givenPrevious, }: CompactionOptions): CompactionPlan | undefined;
|
|
115
143
|
/**
|
|
116
144
|
* What the summariser is handed for a plan: the earlier summary if there was one, then each
|
|
117
145
|
* message as its role and at most 4000 characters of its text.
|
package/dist/compaction.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { consult, notify, turnMessages, } from "./hooks.js";
|
|
2
2
|
import { messageTokens } from "./retry.js";
|
|
3
3
|
import { ask } from "./side-task.js";
|
|
4
|
+
import { holdsDefinitions } from "./tool-loading.js";
|
|
4
5
|
/** The fraction of the window in use before a summary is worth its own round trip. */
|
|
5
6
|
export const COMPACT_AT = 0.75;
|
|
6
7
|
/** The fraction of the window the kept tail may fill, leaving room for the run to grow again. */
|
|
7
8
|
export const KEEP_RATIO = 0.35;
|
|
9
|
+
/** The ceiling `summariser` gives a summary, and what `planCompaction` assumes one will cost. */
|
|
10
|
+
const SUMMARY_TOKENS = 1024;
|
|
8
11
|
/** How much of any one message the summariser is shown. A pasted file is not worth it whole. */
|
|
9
12
|
const SUMMARY_SLICE = 4000;
|
|
10
13
|
/**
|
|
@@ -63,7 +66,9 @@ const systemHead = (messages) => {
|
|
|
63
66
|
* after it, and by then the model has usually taken what it wanted from it; the stub keeps the
|
|
64
67
|
* call answered — a call with no result is a malformed transcript — and says how much was there,
|
|
65
68
|
* so a model that does need it again knows to ask. Short results are kept, since a stub saves
|
|
66
|
-
* nothing on them
|
|
69
|
+
* nothing on them, and so is a proxied `load_tools` result carrying definitions, which is the
|
|
70
|
+
* only copy of them the model has (`holdsDefinitions`); either still counts as one of the latest
|
|
71
|
+
* few. Returns the same array when there was nothing to clear. Rewrites the prefix;
|
|
67
72
|
* see the module comment on when to run it.
|
|
68
73
|
*
|
|
69
74
|
* @param messages The transcript. Not written to.
|
|
@@ -81,6 +86,10 @@ export function pruneToolResults(messages, { keepLast = 5, maxChars = 256 } = {}
|
|
|
81
86
|
const text = textOf(message.content);
|
|
82
87
|
if (text.length <= maxChars || text.startsWith("[result cleared"))
|
|
83
88
|
continue;
|
|
89
|
+
// A proxied load's result is the schema itself; stubbed, the tool is one the model can still
|
|
90
|
+
// name and no longer call correctly.
|
|
91
|
+
if (holdsDefinitions(text))
|
|
92
|
+
continue;
|
|
84
93
|
out ??= [...messages];
|
|
85
94
|
out[at] = {
|
|
86
95
|
...message,
|
|
@@ -104,11 +113,20 @@ export function pruneToolResults(messages, { keepLast = 5, maxChars = 256 } = {}
|
|
|
104
113
|
* transcript — its system prompt a separate argument, no summary message in the array at all —
|
|
105
114
|
* knows them exactly, and passes `from` and `previous` instead of hoping the scan agrees.
|
|
106
115
|
*
|
|
116
|
+
* `keepRatio` bounds the kept tail alone, and the system prompt, the tool schemas and the summary
|
|
117
|
+
* sit in the window on top of it uncounted. Given a `target` the plan is made against what the
|
|
118
|
+
* request will be afterwards instead: the cut is the first user message at which `used`, less the
|
|
119
|
+
* folded messages, plus the summary's ceiling, is at or under that share of the window. Where no
|
|
120
|
+
* cut gets there the plan folds up to the last user message, which is as near as a legal one
|
|
121
|
+
* comes, and `after` says how far short it fell. An earlier summary the new one replaces is not
|
|
122
|
+
* credited, so `after` errs high by at most that much. It is only as good as `estimate` — see
|
|
123
|
+
* `estimateFrom` for one that is measured.
|
|
124
|
+
*
|
|
107
125
|
* @param messages The transcript, system prompts included if the caller keeps them in it.
|
|
108
126
|
* @param options The window, what is in use, the ratios, and where the last fold ended. See
|
|
109
127
|
* `CompactionOptions`.
|
|
110
128
|
*/
|
|
111
|
-
export function planCompaction(messages, { limit, used, compactAt = COMPACT_AT, keepRatio = KEEP_RATIO, charsPerToken, estimate = (message) => messageTokens(message, { charsPerToken }), from: givenFrom, previous: givenPrevious, }) {
|
|
129
|
+
export function planCompaction(messages, { limit, used, compactAt = COMPACT_AT, keepRatio = KEEP_RATIO, target, summaryTokens = SUMMARY_TOKENS, charsPerToken, estimate = (message) => messageTokens(message, { charsPerToken }), from: givenFrom, previous: givenPrevious, }) {
|
|
112
130
|
if (!(limit > 0))
|
|
113
131
|
return undefined;
|
|
114
132
|
const cost = used ?? messages.reduce((total, message) => total + estimate(message), 0);
|
|
@@ -117,20 +135,42 @@ export function planCompaction(messages, { limit, used, compactAt = COMPACT_AT,
|
|
|
117
135
|
const head = systemHead(messages);
|
|
118
136
|
const from = Math.min(Math.max(givenFrom ?? head.from, 0), messages.length);
|
|
119
137
|
const previous = givenPrevious ?? head.previous;
|
|
120
|
-
const budget = limit * keepRatio;
|
|
121
|
-
let kept = 0;
|
|
122
138
|
let cut = messages.length;
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
139
|
+
let after;
|
|
140
|
+
if (target !== undefined && target > 0) {
|
|
141
|
+
// Forward from the head, so the first cut that reaches the target is the one that folds least.
|
|
142
|
+
let folded = 0;
|
|
143
|
+
for (let at = from; at < messages.length; at++) {
|
|
144
|
+
if (messages[at].role === "user") {
|
|
145
|
+
cut = at;
|
|
146
|
+
after = cost - folded + summaryTokens;
|
|
147
|
+
if (after <= limit * target)
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
folded += estimate(messages[at]);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
const budget = limit * keepRatio;
|
|
155
|
+
let kept = 0;
|
|
156
|
+
for (let at = messages.length - 1; at > from; at--) {
|
|
157
|
+
kept += estimate(messages[at]);
|
|
158
|
+
if (kept > budget)
|
|
159
|
+
break;
|
|
160
|
+
cut = at;
|
|
161
|
+
}
|
|
162
|
+
while (cut < messages.length && messages[cut].role !== "user")
|
|
163
|
+
cut++;
|
|
128
164
|
}
|
|
129
|
-
while (cut < messages.length && messages[cut].role !== "user")
|
|
130
|
-
cut++;
|
|
131
165
|
if (cut >= messages.length || cut - from < 2)
|
|
132
166
|
return undefined;
|
|
133
|
-
return {
|
|
167
|
+
return {
|
|
168
|
+
from,
|
|
169
|
+
cut,
|
|
170
|
+
toSummarise: messages.slice(from, cut),
|
|
171
|
+
...(previous ? { previous } : {}),
|
|
172
|
+
...(after === undefined ? {} : { after: Math.round(after) }),
|
|
173
|
+
};
|
|
134
174
|
}
|
|
135
175
|
/**
|
|
136
176
|
* What the summariser is handed for a plan: the earlier summary if there was one, then each
|
|
@@ -158,7 +198,7 @@ export function summaryInput(plan) {
|
|
|
158
198
|
* @param options Cancellation and notices; the ceiling is 1024 and the instruction
|
|
159
199
|
* `SUMMARY_PROMPT` unless given.
|
|
160
200
|
*/
|
|
161
|
-
export const summariser = (config, model, { system = SUMMARY_PROMPT, maxTokens =
|
|
201
|
+
export const summariser = (config, model, { system = SUMMARY_PROMPT, maxTokens = SUMMARY_TOKENS, ...options } = {}) => (text) => ask(config, model, system, text, { maxTokens, ...options });
|
|
162
202
|
/**
|
|
163
203
|
* The hooks and the summariser for a plan, as a record to store rather than a transcript to send.
|
|
164
204
|
*
|
package/dist/config.d.ts
CHANGED
|
@@ -75,9 +75,11 @@ export interface ModelParams {
|
|
|
75
75
|
export interface ToolPolicy {
|
|
76
76
|
/**
|
|
77
77
|
* "eager" sends every tool definition on every request. "ondemand" sends a name-only
|
|
78
|
-
* catalogue and lets the model pull in the schemas it needs.
|
|
78
|
+
* catalogue and lets the model pull in the schemas it needs. "proxy" is the same catalogue
|
|
79
|
+
* behind a tool array that never changes — `load_tools` and `call_tool` — for a server whose
|
|
80
|
+
* prompt cache a growing tool array throws away. See `tool-loading.ts`.
|
|
79
81
|
*/
|
|
80
|
-
toolDiscovery: "eager" | "ondemand";
|
|
82
|
+
toolDiscovery: "eager" | "ondemand" | "proxy";
|
|
81
83
|
/** The model that does the preselection pass. Empty means don't preselect. */
|
|
82
84
|
toolSelectModel: string;
|
|
83
85
|
/** Hard stop on runaway tool loops. */
|
package/dist/events.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TokenLedger } from "./ledger.ts";
|
|
1
2
|
import type { TurnUsage } from "./stream.ts";
|
|
2
3
|
/**
|
|
3
4
|
* What a run is doing, while it is doing it.
|
|
@@ -76,9 +77,9 @@ export type RunEventKind =
|
|
|
76
77
|
| "thinking"
|
|
77
78
|
/** Reply tokens, as they arrive. */
|
|
78
79
|
| "output"
|
|
79
|
-
/** The model asked for a tool, with the arguments it chose. */
|
|
80
|
+
/** The model asked for a tool, with the arguments it chose. `id` is the call's. */
|
|
80
81
|
| "tool-call"
|
|
81
|
-
/** A tool came back, with what it said. */
|
|
82
|
+
/** A tool came back, with what it said. `id` is the id of the call it answers. */
|
|
82
83
|
| "tool-result"
|
|
83
84
|
/** Something the runner did that is not the model's doing — a preselection, a retry. */
|
|
84
85
|
| "notice"
|
|
@@ -110,6 +111,12 @@ export interface RunUsage {
|
|
|
110
111
|
* from a caller emitting usage of its own.
|
|
111
112
|
*/
|
|
112
113
|
turn?: TurnReport;
|
|
114
|
+
/**
|
|
115
|
+
* The run's token ledger as of this turn, whole rather than the entry it added — a host whose
|
|
116
|
+
* run then fails still holds the latest one, and a rewrite in `beforeStep` moves earlier entries.
|
|
117
|
+
* Absent from a caller emitting usage of its own.
|
|
118
|
+
*/
|
|
119
|
+
ledger?: TokenLedger;
|
|
113
120
|
}
|
|
114
121
|
/** One turn's usage and measurements as a `usage` event carries them. */
|
|
115
122
|
export interface TurnReport extends TurnUsage {
|
|
@@ -119,8 +126,8 @@ export interface TurnReport extends TurnUsage {
|
|
|
119
126
|
/**
|
|
120
127
|
* One thing that happened in a run, as a watcher receives it.
|
|
121
128
|
*
|
|
122
|
-
* Every field is always present — the empty ones are `""` or `null` rather than
|
|
123
|
-
* client reads it without guarding each key.
|
|
129
|
+
* Every field but `id` is always present — the empty ones are `""` or `null` rather than
|
|
130
|
+
* missing — so a client reads it without guarding each key.
|
|
124
131
|
*/
|
|
125
132
|
export interface RunEvent {
|
|
126
133
|
/** The run it belongs to. `emit` fills this in; a caller does not pass it. */
|
|
@@ -141,6 +148,12 @@ export interface RunEvent {
|
|
|
141
148
|
text: string;
|
|
142
149
|
/** Tool name on the tool kinds, otherwise empty. */
|
|
143
150
|
name: string;
|
|
151
|
+
/**
|
|
152
|
+
* The call's id on the tool kinds, which pairs a `tool-result` with its `tool-call` when one
|
|
153
|
+
* tool is called twice in a step. The one field `emit` does not fill in: it is there only where
|
|
154
|
+
* the emitter gave one, and optional so an event built before it existed still compiles.
|
|
155
|
+
*/
|
|
156
|
+
id?: string;
|
|
144
157
|
/**
|
|
145
158
|
* The caller's own flow step this happened inside, so a watcher can group a run the way the
|
|
146
159
|
* work is written. Empty for events that belong to the run rather than to any one step, and
|