@cubicecho/agent-core 2.22.0 → 2.24.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 +61 -6
- package/dist/agent-loop.d.ts +3 -2
- package/dist/agent-loop.js +385 -303
- package/dist/calibration.js +8 -6
- package/dist/capabilities.d.ts +36 -0
- package/dist/capabilities.js +116 -65
- package/dist/client.d.ts +5 -4
- package/dist/client.js +62 -57
- package/dist/compaction.js +5 -9
- package/dist/continuation.d.ts +4 -4
- package/dist/continuation.js +1 -4
- package/dist/events.d.ts +16 -1
- package/dist/events.js +64 -44
- package/dist/guards.d.ts +22 -0
- package/dist/guards.js +22 -0
- package/dist/hook-events.d.ts +22 -0
- package/dist/hook-events.js +22 -0
- package/dist/hooks.d.ts +15 -16
- package/dist/hooks.js +44 -44
- package/dist/index.d.ts +6 -4
- package/dist/index.js +6 -4
- package/dist/reset.d.ts +6 -2
- package/dist/reset.js +6 -2
- package/dist/retry.d.ts +13 -7
- package/dist/retry.js +14 -7
- package/dist/run-turn.d.ts +8 -1
- package/dist/runtime.d.ts +120 -0
- package/dist/runtime.js +113 -0
- package/dist/schema-compat.d.ts +23 -0
- package/dist/schema-compat.js +76 -59
- package/dist/scope.d.ts +35 -0
- package/dist/scope.js +42 -0
- package/dist/side-task.d.ts +5 -0
- package/dist/side-task.js +24 -52
- package/dist/snapshot.d.ts +2 -2
- package/dist/snapshot.js +27 -48
- package/dist/spec.d.ts +11 -12
- package/dist/spec.js +36 -66
- package/dist/stream.d.ts +7 -0
- package/dist/stream.js +8 -1
- package/dist/thinking.js +6 -4
- package/dist/tokens.d.ts +9 -2
- package/dist/tokens.js +10 -3
- package/dist/tool-calls.d.ts +9 -0
- package/dist/tool-calls.js +11 -6
- package/dist/tool-loading.d.ts +10 -0
- package/dist/tool-loading.js +15 -7
- package/llms.txt +19 -5
- package/package.json +1 -1
package/dist/agent-loop.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import { charsPerTokenFor } from "./calibration.js";
|
|
2
|
-
import { capabilitiesFor, effortFor, modelCapabilitiesFor, } from "./capabilities.js";
|
|
2
|
+
import { capabilitiesFor, ceilingAndTemperature, effortFor, modelCapabilitiesFor, } from "./capabilities.js";
|
|
3
3
|
import { firstTokenMs, getClient, NO_KEY, timeoutMs } from "./client.js";
|
|
4
4
|
import { compactTranscript, planCompaction, summariser, } from "./compaction.js";
|
|
5
5
|
import { continueTurn } from "./continuation.js";
|
|
6
6
|
import { AgentLoopError, AgentLoopOverflow, errorMessage, ToolIterationLimit, } from "./errors.js";
|
|
7
|
-
import { runMetrics } from "./events.js";
|
|
7
|
+
import { runMetrics, stamp } from "./events.js";
|
|
8
8
|
import { configureHooks, gather, notify, turnIndex, turnMessages, withContext, } from "./hooks.js";
|
|
9
9
|
import { rebaseLedger, recordRequest } from "./ledger.js";
|
|
10
10
|
import { ContextOverflow, contextTokens, toolsChars } from "./retry.js";
|
|
11
11
|
import { runTurn } from "./run-turn.js";
|
|
12
12
|
import { relaxTools, sanitizeTools } from "./schema-compat.js";
|
|
13
13
|
import { askJson, tryAsk } from "./side-task.js";
|
|
14
|
+
import { noUsage } from "./stream.js";
|
|
14
15
|
import { parseToolArguments, recoverToolCalls } from "./tool-calls.js";
|
|
15
|
-
import { CALL_TOOL, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PROXY_TOOLS, preselectByKeywords, preselectInput, preselection, preselectSystem, proxiedCall, proxyCatalogPrompt, proxyLoadResult, requestedNames, shownCall, } from "./tool-loading.js";
|
|
16
|
+
import { CALL_TOOL, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PROXY_TOOLS, preselectByKeywords, preselectInput, preselection, preselectSystem, proxiedCall, proxyCatalogPrompt, proxyLoadResult, requestedNames, shownCall, toolName, } from "./tool-loading.js";
|
|
16
17
|
/**
|
|
17
18
|
* The loop above a turn: send, run the tools the model asked for, send again, until it stops
|
|
18
19
|
* asking.
|
|
@@ -57,12 +58,7 @@ export function buildBody(config, supports, refused, messages, tools = [], order
|
|
|
57
58
|
const effort = effortFor(refused, config.reasoningEffort);
|
|
58
59
|
const extra = Object.entries(config.extraBody ?? {}).filter(([field]) => !RESERVED.has(field) && !refused?.refusedFields.has(field));
|
|
59
60
|
return {
|
|
60
|
-
...(config.maxTokens
|
|
61
|
-
? refused?.legacyTokenLimit === false
|
|
62
|
-
? { max_completion_tokens: config.maxTokens }
|
|
63
|
-
: { max_tokens: config.maxTokens }
|
|
64
|
-
: {}),
|
|
65
|
-
...(refused?.chosenTemperature === false ? {} : { temperature: config.temperature }),
|
|
61
|
+
...ceilingAndTemperature(refused, config.maxTokens, config.temperature),
|
|
66
62
|
...(effort ? { reasoning_effort: effort } : {}),
|
|
67
63
|
...(supports.usageInStream ? { stream_options: { include_usage: true } } : {}),
|
|
68
64
|
...Object.fromEntries(extra),
|
|
@@ -81,6 +77,8 @@ export function buildBody(config, supports, refused, messages, tools = [], order
|
|
|
81
77
|
* @param limit Characters kept, 2000 by default. Text at or under it comes back as it was.
|
|
82
78
|
*/
|
|
83
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"}`;
|
|
84
82
|
/** A base URL as two settings rows would agree on it: trimmed, without the trailing slash. */
|
|
85
83
|
const sameUrl = (a, b) => a.trim().replace(/\/+$/, "") === b.trim().replace(/\/+$/, "");
|
|
86
84
|
/**
|
|
@@ -185,7 +183,7 @@ export async function preselect(config, model, catalog, prompt, { signal, onNoti
|
|
|
185
183
|
...(keywords === true ? {} : keywords),
|
|
186
184
|
});
|
|
187
185
|
if (guess.confident) {
|
|
188
|
-
onNotice?.(`chose ${guess.names.length
|
|
186
|
+
onNotice?.(`chose ${counted(guess.names.length, "tool")} by name`);
|
|
189
187
|
return guess.names;
|
|
190
188
|
}
|
|
191
189
|
}
|
|
@@ -301,7 +299,7 @@ export async function runAgentLoop(options) {
|
|
|
301
299
|
const standing = {
|
|
302
300
|
read: () => ({
|
|
303
301
|
messages: [...options.messages],
|
|
304
|
-
usage:
|
|
302
|
+
usage: noUsage(),
|
|
305
303
|
toolCalls: [],
|
|
306
304
|
loaded: [],
|
|
307
305
|
used: [],
|
|
@@ -332,6 +330,317 @@ const userText = (message) => {
|
|
|
332
330
|
return "";
|
|
333
331
|
return content.flatMap((part) => (part.type === "text" ? part.text : [])).join("\n");
|
|
334
332
|
};
|
|
333
|
+
/**
|
|
334
|
+
* The loop's own preselection: by the task where the config carries one, and by the flattened
|
|
335
|
+
* name on its own endpoint where it does not. `preselect` answers an empty model with nothing, so
|
|
336
|
+
* a config that names neither asks nothing.
|
|
337
|
+
*/
|
|
338
|
+
async function chooseTools(options, notice) {
|
|
339
|
+
const { config, catalog = [], signal } = options;
|
|
340
|
+
const prompt = userText(options.messages.findLast((message) => message.role === "user"));
|
|
341
|
+
if (!prompt.trim())
|
|
342
|
+
return [];
|
|
343
|
+
const asked = {
|
|
344
|
+
...(typeof options.preselect === "object" ? options.preselect : {}),
|
|
345
|
+
signal,
|
|
346
|
+
onNotice: notice,
|
|
347
|
+
};
|
|
348
|
+
const selector = config.tasks?.toolSelect;
|
|
349
|
+
if (!selector?.model) {
|
|
350
|
+
return preselect(config, config.toolSelectModel ?? "", catalog, prompt, asked);
|
|
351
|
+
}
|
|
352
|
+
const call = taskCall(selector, asked, config);
|
|
353
|
+
return preselect(call.endpoint, call.model, catalog, prompt, call.options);
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* The loop's own summariser, which is the compaction task's or nothing: a summary the main model
|
|
357
|
+
* writes is a second full-price request the operator did not ask for.
|
|
358
|
+
*/
|
|
359
|
+
function ownSummariser(options, notice) {
|
|
360
|
+
const { config, signal } = options;
|
|
361
|
+
const compactor = options.compact ? config.tasks?.compaction : undefined;
|
|
362
|
+
if (!compactor?.model)
|
|
363
|
+
return undefined;
|
|
364
|
+
const call = taskCall(compactor, { signal, onNotice: notice }, config);
|
|
365
|
+
const write = summariser(call.endpoint, call.model, call.options);
|
|
366
|
+
// A summary that fails folds nothing, and the step goes out as it was.
|
|
367
|
+
return async (text) => (await tryAsk("compaction", () => write(text), { onNotice: notice })) ?? "";
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* A step's transcript after the loop's own compaction: folded where a fold was due and its summary
|
|
371
|
+
* came back, and otherwise the array it was given.
|
|
372
|
+
*/
|
|
373
|
+
async function foldBeforeStep(transcript, reported, summarise, { options, charsPerToken, notice, }) {
|
|
374
|
+
const { config, hooks } = options;
|
|
375
|
+
const compactor = config.tasks?.compaction;
|
|
376
|
+
const plan = planCompaction(transcript, {
|
|
377
|
+
...(typeof options.compact === "object" ? options.compact : {}),
|
|
378
|
+
limit: config.contextLength ?? 0,
|
|
379
|
+
...reported,
|
|
380
|
+
charsPerToken,
|
|
381
|
+
...((compactor?.maxTokens ?? 0) > 0 ? { summaryTokens: compactor?.maxTokens } : {}),
|
|
382
|
+
});
|
|
383
|
+
if (!plan)
|
|
384
|
+
return transcript;
|
|
385
|
+
const folded = await compactTranscript(transcript, plan, summarise, {
|
|
386
|
+
...(hooks ? { hooks: { run: hooks.run, context: hooks.context, onNote: hooks.onNote } } : {}),
|
|
387
|
+
});
|
|
388
|
+
if (folded !== transcript)
|
|
389
|
+
notice(`compacted ${plan.cut - plan.from} messages into a summary`);
|
|
390
|
+
return folded;
|
|
391
|
+
}
|
|
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
|
+
/**
|
|
420
|
+
* Tells the `afterTurn` hooks what was answered, with the turn it closed where the question is
|
|
421
|
+
* still in the transcript. Resolves to their notes — failures only, as `notify` returns them.
|
|
422
|
+
*/
|
|
423
|
+
const tellAfterTurn = (hooks, messages, at, reply) => notify(hooks.run, "afterTurn", {
|
|
424
|
+
...hooks.context,
|
|
425
|
+
reply,
|
|
426
|
+
...(at >= 0
|
|
427
|
+
? {
|
|
428
|
+
turn: {
|
|
429
|
+
index: turnIndex(messages, at),
|
|
430
|
+
messages: turnMessages(hooks.context.session.id, messages, at),
|
|
431
|
+
},
|
|
432
|
+
}
|
|
433
|
+
: {}),
|
|
434
|
+
}, 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
|
+
/**
|
|
438
|
+
* What a call is shown, counted and dispatched as: a `call_tool` as the tool it names. Read from
|
|
439
|
+
* the repaired arguments where there are any, since the model's own may be almost-JSON.
|
|
440
|
+
*/
|
|
441
|
+
const shownAs = (run, { call, args, normal }) => run.proxies && call.function.name === CALL_TOOL
|
|
442
|
+
? shownCall(CALL_TOOL, args ? normal : call.function.arguments)
|
|
443
|
+
: { name: call.function.name, input: call.function.arguments };
|
|
444
|
+
/** Answers a `load_tools`, counting what it loaded. Not ok where it named nothing loadable. */
|
|
445
|
+
function answerLoad(run, args) {
|
|
446
|
+
const { catalog, proxied, definitions, loaded, loads } = run;
|
|
447
|
+
const resolved = expandNames(requestedNames(args), catalog);
|
|
448
|
+
const content = proxied
|
|
449
|
+
? proxyLoadResult(resolved, catalog, definedAs(definitions, resolved.matched), loaded)
|
|
450
|
+
: loadResult(resolved, catalog, loaded);
|
|
451
|
+
// Proxied, a load is only of what the result could define: a catalogued name the host
|
|
452
|
+
// gave no definition for was not loaded, and is not counted or remembered as though it was.
|
|
453
|
+
const hits = proxied ? resolved.matched.filter((hit) => definitions.has(hit)) : resolved.matched;
|
|
454
|
+
for (const hit of hits)
|
|
455
|
+
loads[loaded.has(hit) ? "redundantLoads" : "toolsLoaded"]++;
|
|
456
|
+
loads.unknownToolNames += resolved.unknown.length;
|
|
457
|
+
for (const hit of hits)
|
|
458
|
+
loaded.add(hit);
|
|
459
|
+
return { content, ok: hits.length > 0 };
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Runs one call to its result, telling the host of both. A tool that throws is that call's answer
|
|
463
|
+
* rather than the run's end, unless the run was stopped.
|
|
464
|
+
*
|
|
465
|
+
* @param run The run the call belongs to.
|
|
466
|
+
* @param entry The call, with its arguments read.
|
|
467
|
+
* @param answered The step's calls already made, by tool and arguments, for `dedupeToolCalls`.
|
|
468
|
+
*/
|
|
469
|
+
async function runCall(run, entry, answered) {
|
|
470
|
+
const { catalog, onDemand, proxied, signal } = run;
|
|
471
|
+
const { call, args, error: unreadable, normal } = entry;
|
|
472
|
+
const proxy = run.proxies && call.function.name === CALL_TOOL;
|
|
473
|
+
const { name, input: raw } = shownAs(run, entry);
|
|
474
|
+
// Looked through before the call is announced, so a host is told of the tool it names with
|
|
475
|
+
// that tool's arguments. A name outside the catalogue is refused here, so `call_tool` reaches
|
|
476
|
+
// nothing a load could not, and the refusal is the call's answer below.
|
|
477
|
+
let inner = args ?? {};
|
|
478
|
+
let refused;
|
|
479
|
+
if (proxy && args) {
|
|
480
|
+
try {
|
|
481
|
+
inner = proxiedCall(args, catalog).input;
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
inner = {};
|
|
485
|
+
refused = { error };
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// Built for every call rather than only the dispatched ones, so a host hears of the calls
|
|
489
|
+
// the loop answers itself in the same shape. Arguments that could not be read are none.
|
|
490
|
+
const request = { id: call.id, name, args: inner, raw };
|
|
491
|
+
run.onEvent({ kind: "tool-call", id: call.id, name, text: preview(raw) });
|
|
492
|
+
run.onToolCall?.(request);
|
|
493
|
+
let content;
|
|
494
|
+
let ok = true;
|
|
495
|
+
try {
|
|
496
|
+
if (!args)
|
|
497
|
+
throw unreadable;
|
|
498
|
+
// By the name the model called, not the one a `call_tool` wraps: `load_tools` is not in
|
|
499
|
+
// the catalogue, and one reached through `call_tool` is refused below like any other.
|
|
500
|
+
if (onDemand && call.function.name === LOAD_TOOLS) {
|
|
501
|
+
({ content, ok } = answerLoad(run, args));
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
if (refused)
|
|
505
|
+
throw refused.error;
|
|
506
|
+
// A model that skips `load_tools` and calls a catalogued tool by name is right about
|
|
507
|
+
// what it wants; load it and run it rather than refusing. Not proxied, where loaded
|
|
508
|
+
// means its definition is in the history, and this call put none there.
|
|
509
|
+
if (onDemand && !proxied && inCatalog(catalog, name))
|
|
510
|
+
run.loaded.add(name);
|
|
511
|
+
run.used.add(name);
|
|
512
|
+
// Keyed on the inner call, so one made through `call_tool` and one made natively share.
|
|
513
|
+
const key = `${name}\0${proxy ? JSON.stringify(inner) : normal}`;
|
|
514
|
+
content = run.dedupable(request)
|
|
515
|
+
? await once(answered, key, () => run.dispatch(request, signal))
|
|
516
|
+
: await run.dispatch(request, signal);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
if (signal?.aborted)
|
|
521
|
+
throw error;
|
|
522
|
+
content = errorMessage(error);
|
|
523
|
+
ok = false;
|
|
524
|
+
}
|
|
525
|
+
run.onEvent({ kind: "tool-result", id: call.id, name, ok, text: preview(content) });
|
|
526
|
+
const result = { id: call.id, name, ok, content };
|
|
527
|
+
run.onToolResult?.(result);
|
|
528
|
+
return result;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Runs a step's calls and writes each result into the transcript, in the order asked.
|
|
532
|
+
*
|
|
533
|
+
* A failure part-way leaves the transcript well-formed before it is rethrown: a call with no
|
|
534
|
+
* result is a transcript no endpoint takes back, so every call of the step is answered — with what
|
|
535
|
+
* it returned where it had, and otherwise with a line saying it was stopped, or never run.
|
|
536
|
+
*
|
|
537
|
+
* @param run The run the step belongs to.
|
|
538
|
+
* @param parsed The step's calls, with their arguments read.
|
|
539
|
+
* @param messages The transcript the results are written into.
|
|
540
|
+
* @param step Which step this is, for the host told of each result.
|
|
541
|
+
*/
|
|
542
|
+
async function runCalls(run, parsed, messages, step) {
|
|
543
|
+
const { signal, toolCalls } = run;
|
|
544
|
+
// Per step, not per run: the answer to a call made two steps ago was true before the tools in
|
|
545
|
+
// between ran, and the file the model read may be the file it has since written.
|
|
546
|
+
const answered = new Map();
|
|
547
|
+
// By position, so a call that came back while another was being stopped is still found.
|
|
548
|
+
const outcomes = parsed.map(() => undefined);
|
|
549
|
+
// How many of the step's calls were handed off, and how many have their result in the
|
|
550
|
+
// transcript. Both count from the front, since results are written in the order asked.
|
|
551
|
+
let begun = 0;
|
|
552
|
+
let kept = 0;
|
|
553
|
+
const keep = async (id, content) => {
|
|
554
|
+
const result = {
|
|
555
|
+
role: "tool",
|
|
556
|
+
tool_call_id: id,
|
|
557
|
+
content,
|
|
558
|
+
};
|
|
559
|
+
messages.push(result);
|
|
560
|
+
kept++;
|
|
561
|
+
await run.announce(result, step);
|
|
562
|
+
};
|
|
563
|
+
try {
|
|
564
|
+
if (run.parallel) {
|
|
565
|
+
signal?.throwIfAborted();
|
|
566
|
+
begun = parsed.length;
|
|
567
|
+
await Promise.all(parsed.map(async (call, at) => {
|
|
568
|
+
outcomes[at] = await runCall(run, call, answered);
|
|
569
|
+
}));
|
|
570
|
+
for (const { id, name, ok, content } of outcomes.flatMap((outcome) => outcome ?? [])) {
|
|
571
|
+
toolCalls.push({ id, name, ok });
|
|
572
|
+
await keep(id, content);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
for (const [at, call] of parsed.entries()) {
|
|
577
|
+
signal?.throwIfAborted();
|
|
578
|
+
begun = at + 1;
|
|
579
|
+
const { id, name, ok, content } = await runCall(run, call, answered);
|
|
580
|
+
toolCalls.push({ id, name, ok });
|
|
581
|
+
await keep(id, content);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
catch (error) {
|
|
586
|
+
// Only a call that was made counts in `toolCalls`.
|
|
587
|
+
for (const [at, entry] of parsed.entries()) {
|
|
588
|
+
const { call } = entry;
|
|
589
|
+
if (at < kept)
|
|
590
|
+
continue;
|
|
591
|
+
const outcome = outcomes[at];
|
|
592
|
+
// Under the name it was announced by, which for a `call_tool` is the tool it names.
|
|
593
|
+
if (at < begun) {
|
|
594
|
+
toolCalls.push({ id: call.id, name: shownAs(run, entry).name, ok: outcome?.ok ?? false });
|
|
595
|
+
}
|
|
596
|
+
// The run is already ending on `error`; a host that cannot take this result does not
|
|
597
|
+
// get to replace it.
|
|
598
|
+
await keep(call.id, outcome?.content ?? (at < begun ? STOPPED_CALL : UNRUN_CALL)).catch(() => { });
|
|
599
|
+
}
|
|
600
|
+
throw error;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Writes a proxied preselection into the transcript as a `load_tools` exchange: the tool array is
|
|
605
|
+
* fixed, so a shortlist has nowhere else to go, and it is answered as though the model had loaded
|
|
606
|
+
* it. Told to the host as a call the model made would be, so one pairing calls with results by id
|
|
607
|
+
* shows this one like the rest.
|
|
608
|
+
*
|
|
609
|
+
* @param run The run it opens.
|
|
610
|
+
* @param shortlist The definitions preselected, each of them in the catalogue.
|
|
611
|
+
* @param messages The transcript, which the exchange is appended to.
|
|
612
|
+
*/
|
|
613
|
+
async function loadShortlist(run, shortlist, messages) {
|
|
614
|
+
const { catalog } = run;
|
|
615
|
+
const names = shortlist.map((tool) => toolName(tool) ?? "");
|
|
616
|
+
// Numbered by where the call lands, so two questions in one transcript do not share an id.
|
|
617
|
+
const id = `preselect-${messages.length}`;
|
|
618
|
+
const args = JSON.stringify({ names });
|
|
619
|
+
// Held to its own length rather than `MAX_PER_LOAD`: that cap is for a model choosing, and a
|
|
620
|
+
// host that shortlisted more has already chosen.
|
|
621
|
+
const content = proxyLoadResult(expandNames(names, catalog, names.length), catalog, shortlist);
|
|
622
|
+
run.onEvent({ kind: "tool-call", id, name: LOAD_TOOLS, text: preview(args) });
|
|
623
|
+
run.onToolCall?.({ id, name: LOAD_TOOLS, args: { names }, raw: args });
|
|
624
|
+
run.onEvent({ kind: "tool-result", id, name: LOAD_TOOLS, ok: true, text: preview(content) });
|
|
625
|
+
run.onToolResult?.({ id, name: LOAD_TOOLS, ok: true, content });
|
|
626
|
+
run.toolCalls.push({ id, name: LOAD_TOOLS, ok: true });
|
|
627
|
+
run.loads.toolsLoaded += names.length;
|
|
628
|
+
for (const name of names)
|
|
629
|
+
run.loaded.add(name);
|
|
630
|
+
const exchange = [
|
|
631
|
+
{
|
|
632
|
+
role: "assistant",
|
|
633
|
+
content: null,
|
|
634
|
+
tool_calls: [{ id, type: "function", function: { name: LOAD_TOOLS, arguments: args } }],
|
|
635
|
+
},
|
|
636
|
+
{ role: "tool", tool_call_id: id, content },
|
|
637
|
+
];
|
|
638
|
+
// Both written before either is announced, so a host that throws on the first leaves a call
|
|
639
|
+
// with its result. Told as step zero's, with no turn: no request was made for them.
|
|
640
|
+
messages.push(...exchange);
|
|
641
|
+
for (const message of exchange)
|
|
642
|
+
await run.announce(message, 0);
|
|
643
|
+
}
|
|
335
644
|
/**
|
|
336
645
|
* The steps of `runAgentLoop`, throwing what they caught as it was caught. Resolves to nothing
|
|
337
646
|
* when `maxToolIterations` is spent, and tells `standing` how to read the run either way.
|
|
@@ -347,17 +656,7 @@ async function runSteps(options, standing) {
|
|
|
347
656
|
const record = (input) => {
|
|
348
657
|
if (input.kind === "thinking" || input.kind === "output")
|
|
349
658
|
return;
|
|
350
|
-
recorded.push(
|
|
351
|
-
runId: "",
|
|
352
|
-
seq: recorded.length + 1,
|
|
353
|
-
at: Date.now(),
|
|
354
|
-
text: "",
|
|
355
|
-
name: "",
|
|
356
|
-
step: "",
|
|
357
|
-
ok: null,
|
|
358
|
-
usage: null,
|
|
359
|
-
...input,
|
|
360
|
-
});
|
|
659
|
+
recorded.push(stamp(input, "", recorded.length + 1, Date.now()));
|
|
361
660
|
};
|
|
362
661
|
const onEvent = (input) => {
|
|
363
662
|
record(input);
|
|
@@ -373,58 +672,35 @@ async function runSteps(options, standing) {
|
|
|
373
672
|
const onDemand = proxied || (config.toolDiscovery === "ondemand" && catalog.length > 0);
|
|
374
673
|
// Proxied, `loaded` is what this run has put a definition in the history for, and starts empty.
|
|
375
674
|
const loaded = new Set(onDemand && !proxied ? (options.loaded ?? []) : []);
|
|
376
|
-
// The loop's own preselection, where it was asked for and the host has not already decided
|
|
377
|
-
// the task where the config carries one, and by the flattened name on its own endpoint where
|
|
378
|
-
// it does not. `preselect` answers an empty model with nothing, so neither named asks nothing.
|
|
379
|
-
const choose = async () => {
|
|
380
|
-
const prompt = userText(options.messages.findLast((message) => message.role === "user"));
|
|
381
|
-
if (!prompt.trim())
|
|
382
|
-
return [];
|
|
383
|
-
const asked = {
|
|
384
|
-
...(typeof options.preselect === "object" ? options.preselect : {}),
|
|
385
|
-
signal,
|
|
386
|
-
onNotice: notice,
|
|
387
|
-
};
|
|
388
|
-
const selector = config.tasks?.toolSelect;
|
|
389
|
-
if (!selector?.model) {
|
|
390
|
-
return preselect(config, config.toolSelectModel ?? "", catalog, prompt, asked);
|
|
391
|
-
}
|
|
392
|
-
const call = taskCall(selector, asked, config);
|
|
393
|
-
return preselect(call.endpoint, call.model, catalog, prompt, call.options);
|
|
394
|
-
};
|
|
675
|
+
// The loop's own preselection, where it was asked for and the host has not already decided.
|
|
395
676
|
const preselected = !onDemand
|
|
396
677
|
? []
|
|
397
678
|
: options.preselect && options.preselected === undefined
|
|
398
|
-
? await
|
|
679
|
+
? await chooseTools(options, notice)
|
|
399
680
|
: [...(options.preselected ?? [])];
|
|
400
681
|
if (!proxied)
|
|
401
682
|
for (const name of preselected)
|
|
402
683
|
loaded.add(name);
|
|
403
|
-
|
|
404
|
-
// a second full-price request the operator did not ask for.
|
|
405
|
-
const compactor = options.compact ? config.tasks?.compaction : undefined;
|
|
406
|
-
const summarise = (() => {
|
|
407
|
-
if (!compactor?.model)
|
|
408
|
-
return undefined;
|
|
409
|
-
const call = taskCall(compactor, { signal, onNotice: notice }, config);
|
|
410
|
-
const write = summariser(call.endpoint, call.model, call.options);
|
|
411
|
-
// A summary that fails folds nothing, and the step goes out as it was.
|
|
412
|
-
return async (text) => (await tryAsk("compaction", () => write(text), { onNotice: notice })) ?? "";
|
|
413
|
-
})();
|
|
684
|
+
const summarise = ownSummariser(options, notice);
|
|
414
685
|
const used = new Set();
|
|
415
686
|
const definitions = new Map();
|
|
416
687
|
for (const tool of tools) {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
688
|
+
const name = toolName(tool);
|
|
689
|
+
if (name !== undefined && !definitions.has(name))
|
|
690
|
+
definitions.set(name, tool);
|
|
420
691
|
}
|
|
421
692
|
// Looked through unless the host has a tool by that name, which is then the host's to answer.
|
|
422
693
|
const proxies = onDemand && !definitions.has(CALL_TOOL);
|
|
694
|
+
// What a call the model wrote as text may name: the host's tools, and the loop's own on demand.
|
|
695
|
+
const recoverable = [
|
|
696
|
+
...tools.map(toolName).filter((name) => name !== undefined),
|
|
697
|
+
...(onDemand ? [LOAD_TOOLS, ...(proxies ? [CALL_TOOL] : [])] : []),
|
|
698
|
+
];
|
|
423
699
|
// In the order the names are given, not the order of `tools`: `loaded` is a set, which iterates
|
|
424
700
|
// in the order things were added, so a load appends and never reshuffles what went before.
|
|
425
|
-
const byName = (names) =>
|
|
701
|
+
const byName = (names) => definedAs(definitions, names);
|
|
426
702
|
let messages = [...options.messages];
|
|
427
|
-
const usage =
|
|
703
|
+
const usage = noUsage();
|
|
428
704
|
const toolCalls = [];
|
|
429
705
|
standing.read = () => ({
|
|
430
706
|
messages,
|
|
@@ -452,6 +728,7 @@ async function runSteps(options, standing) {
|
|
|
452
728
|
// moves the question without losing it — and one that summarises the question away takes the
|
|
453
729
|
// hooks' context with it, which is right.
|
|
454
730
|
const question = messages.findLast((message) => message.role === "user");
|
|
731
|
+
const questionAt = () => (question ? messages.indexOf(question) : -1);
|
|
455
732
|
const gathered = hooks
|
|
456
733
|
? await gather(hooks.run, hooks.events ?? ["beforeTurn"], hooks.context, {
|
|
457
734
|
signal,
|
|
@@ -467,74 +744,55 @@ async function runSteps(options, standing) {
|
|
|
467
744
|
// The request the ledger's last entry came from, which is what the next one is measured against.
|
|
468
745
|
// Nothing before this run's first: an earlier run's head is not something the loop can see.
|
|
469
746
|
let measured;
|
|
470
|
-
// Proxied, a shortlist has nowhere to go but the history
|
|
471
|
-
//
|
|
747
|
+
// Proxied, a shortlist has nowhere to go but the history, where its definitions sit after the
|
|
748
|
+
// question.
|
|
472
749
|
const shortlist = proxied
|
|
473
|
-
? byName(new Set(preselected)).filter((tool) =>
|
|
750
|
+
? byName(new Set(preselected)).filter((tool) => {
|
|
751
|
+
const name = toolName(tool);
|
|
752
|
+
return name !== undefined && inCatalog(catalog, name);
|
|
753
|
+
})
|
|
474
754
|
: [];
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
content: null,
|
|
497
|
-
tool_calls: [{ id, type: "function", function: { name: LOAD_TOOLS, arguments: args } }],
|
|
498
|
-
},
|
|
499
|
-
{ role: "tool", tool_call_id: id, content },
|
|
500
|
-
];
|
|
501
|
-
// Both written before either is announced, so a host that throws on the first leaves a call
|
|
502
|
-
// with its result. Told as step zero's, with no turn: no request was made for them.
|
|
503
|
-
messages.push(...exchange);
|
|
504
|
-
for (const message of exchange)
|
|
505
|
-
await announce(message, 0);
|
|
506
|
-
}
|
|
755
|
+
const calling = {
|
|
756
|
+
catalog,
|
|
757
|
+
onDemand,
|
|
758
|
+
proxied,
|
|
759
|
+
proxies,
|
|
760
|
+
definitions,
|
|
761
|
+
loaded,
|
|
762
|
+
loads,
|
|
763
|
+
used,
|
|
764
|
+
toolCalls,
|
|
765
|
+
dedupable,
|
|
766
|
+
dispatch,
|
|
767
|
+
parallel,
|
|
768
|
+
signal,
|
|
769
|
+
onEvent,
|
|
770
|
+
onToolCall,
|
|
771
|
+
onToolResult,
|
|
772
|
+
announce,
|
|
773
|
+
};
|
|
774
|
+
if (shortlist.length)
|
|
775
|
+
await loadShortlist(calling, shortlist, messages);
|
|
507
776
|
for (let step = 0; step < config.maxToolIterations; step++) {
|
|
508
777
|
// A stop aborts the request in flight, but a tool call already handed off runs to its own
|
|
509
778
|
// end — so the signal is read between steps as well.
|
|
510
779
|
signal?.throwIfAborted();
|
|
511
780
|
const before = messages;
|
|
781
|
+
const reported = previous && previous.prompt > 0 ? { used: previous.prompt } : {};
|
|
512
782
|
const rewritten = await beforeStep?.(messages, step, {
|
|
513
|
-
...
|
|
783
|
+
...reported,
|
|
514
784
|
limit: config.contextLength ?? 0,
|
|
515
785
|
ledger,
|
|
516
786
|
});
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
// The last request's prompt describes this transcript only while nothing has rewritten it.
|
|
523
|
-
...(next === messages && previous && previous.prompt > 0 ? { used: previous.prompt } : {}),
|
|
787
|
+
const given = rewritten ?? messages;
|
|
788
|
+
// The last request's prompt describes this transcript only while nothing has rewritten it.
|
|
789
|
+
const next = summarise
|
|
790
|
+
? await foldBeforeStep(given, given === messages ? reported : {}, summarise, {
|
|
791
|
+
options,
|
|
524
792
|
charsPerToken: charsPerTokenFor(supports, config.model),
|
|
525
|
-
|
|
526
|
-
})
|
|
527
|
-
|
|
528
|
-
const folded = await compactTranscript(next, plan, summarise, {
|
|
529
|
-
...(hooks
|
|
530
|
-
? { hooks: { run: hooks.run, context: hooks.context, onNote: hooks.onNote } }
|
|
531
|
-
: {}),
|
|
532
|
-
});
|
|
533
|
-
if (folded !== next)
|
|
534
|
-
notice(`compacted ${plan.cut - plan.from} messages into a summary`);
|
|
535
|
-
next = folded;
|
|
536
|
-
}
|
|
537
|
-
}
|
|
793
|
+
notice,
|
|
794
|
+
})
|
|
795
|
+
: given;
|
|
538
796
|
// A rewrite may have folded a definition away, and a load answered "already loaded" would
|
|
539
797
|
// then point at nothing. Forgetting costs a definition sent twice at worst.
|
|
540
798
|
if (proxied && next !== messages)
|
|
@@ -563,7 +821,7 @@ async function runSteps(options, standing) {
|
|
|
563
821
|
const prompt = onDemand && !routed ? `${system}\n\n${catalogue}`.trim() : system;
|
|
564
822
|
const request = [
|
|
565
823
|
...(prompt ? [{ role: "system", content: prompt }] : []),
|
|
566
|
-
...withContext(messages,
|
|
824
|
+
...withContext(messages, questionAt(), gathered.context, preface),
|
|
567
825
|
];
|
|
568
826
|
const build = (supported, refused) => buildBody(config, supported, refused, request, declared, toolOrder);
|
|
569
827
|
const turnOptions = {
|
|
@@ -588,7 +846,7 @@ async function runSteps(options, standing) {
|
|
|
588
846
|
onRequest({ messages: opening.messages, tools: opening.tools ?? [], step });
|
|
589
847
|
}
|
|
590
848
|
const first = await runTurn(client, supports, build, turnOptions);
|
|
591
|
-
const names = declared.map((tool) => (tool
|
|
849
|
+
const names = declared.map((tool) => toolName(tool) ?? "");
|
|
592
850
|
// Compared before any continuation is joined on: the cache a request meets is the one its own
|
|
593
851
|
// prompt found, and a continuation's prompt is this request's plus the reply so far.
|
|
594
852
|
// The breakdown measures the array `toolSchemaTokens` does, so where no prompt was reported
|
|
@@ -641,65 +899,27 @@ async function runSteps(options, standing) {
|
|
|
641
899
|
let calls = turn.toolCalls.filter((call) => call.function.name);
|
|
642
900
|
let content = turn.content;
|
|
643
901
|
if (recover && !calls.length && content && (tools.length > 0 || onDemand)) {
|
|
644
|
-
const
|
|
645
|
-
const recovered = recoverToolCalls(content, {
|
|
646
|
-
names: onDemand ? [...names, LOAD_TOOLS, ...(proxies ? [CALL_TOOL] : [])] : names,
|
|
647
|
-
});
|
|
902
|
+
const recovered = recoverToolCalls(content, { names: recoverable });
|
|
648
903
|
if (recovered.toolCalls.length) {
|
|
649
904
|
calls = recovered.toolCalls;
|
|
650
905
|
content = recovered.content;
|
|
651
|
-
notice(`recovered ${calls.length
|
|
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`);
|
|
652
907
|
}
|
|
653
908
|
}
|
|
654
909
|
const shown = { ...turn, content, toolCalls: calls };
|
|
655
910
|
onTurn?.(shown, step);
|
|
656
911
|
// Read before the assistant message is written, so what is replayed on every later request
|
|
657
|
-
// is the repaired JSON
|
|
658
|
-
|
|
659
|
-
const
|
|
660
|
-
try {
|
|
661
|
-
const args = parseToolArguments(call.function.arguments, {
|
|
662
|
-
finishReason: turn.finishReason,
|
|
663
|
-
});
|
|
664
|
-
return { call, args, normal: JSON.stringify(args) };
|
|
665
|
-
}
|
|
666
|
-
catch (error) {
|
|
667
|
-
return { call, error, normal: "{}" };
|
|
668
|
-
}
|
|
669
|
-
});
|
|
670
|
-
const assistant = {
|
|
671
|
-
role: "assistant",
|
|
672
|
-
content: content || null,
|
|
673
|
-
...(parsed.length
|
|
674
|
-
? {
|
|
675
|
-
tool_calls: parsed.map(({ call, normal }) => ({
|
|
676
|
-
...call,
|
|
677
|
-
function: { ...call.function, arguments: normal },
|
|
678
|
-
})),
|
|
679
|
-
}
|
|
680
|
-
: {}),
|
|
681
|
-
};
|
|
912
|
+
// is the repaired JSON.
|
|
913
|
+
const parsed = readCalls(calls, turn.finishReason);
|
|
914
|
+
const assistant = assistantMessage(content, parsed);
|
|
682
915
|
messages.push(assistant);
|
|
683
916
|
await announce(assistant, step, shown);
|
|
684
917
|
if (!calls.length) {
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
afterTurn = notify(hooks.run, "afterTurn", {
|
|
691
|
-
...hooks.context,
|
|
692
|
-
reply: turn.content,
|
|
693
|
-
...(at >= 0
|
|
694
|
-
? {
|
|
695
|
-
turn: {
|
|
696
|
-
index: turnIndex(messages, at),
|
|
697
|
-
messages: turnMessages(hooks.context.session.id, messages, at),
|
|
698
|
-
},
|
|
699
|
-
}
|
|
700
|
-
: {}),
|
|
701
|
-
}, hooks.onNote);
|
|
702
|
-
}
|
|
918
|
+
// Not awaited: the answer is ready, and remembering it is not something to hold it for.
|
|
919
|
+
// Handed back instead, for a host that wants its notes.
|
|
920
|
+
const afterTurn = hooks
|
|
921
|
+
? tellAfterTurn(hooks, messages, questionAt(), turn.content)
|
|
922
|
+
: Promise.resolve([]);
|
|
703
923
|
const metrics = runMetrics(recorded, { contextLength: config.contextLength });
|
|
704
924
|
return {
|
|
705
925
|
turn: shown,
|
|
@@ -721,145 +941,7 @@ async function runSteps(options, standing) {
|
|
|
721
941
|
ledger,
|
|
722
942
|
};
|
|
723
943
|
}
|
|
724
|
-
|
|
725
|
-
// between ran, and the file the model read may be the file it has since written.
|
|
726
|
-
const answered = new Map();
|
|
727
|
-
// A `call_tool` is shown, counted and dispatched as the tool it names. Read from the repaired
|
|
728
|
-
// arguments where there are any, since the model's own may be almost-JSON.
|
|
729
|
-
const named = ({ call, args, normal }) => proxies && call.function.name === CALL_TOOL
|
|
730
|
-
? shownCall(CALL_TOOL, args ? normal : call.function.arguments)
|
|
731
|
-
: { name: call.function.name, input: call.function.arguments };
|
|
732
|
-
const run = async (entry) => {
|
|
733
|
-
const { call, args, error: unreadable, normal } = entry;
|
|
734
|
-
const proxy = proxies && call.function.name === CALL_TOOL;
|
|
735
|
-
const { name, input: raw } = named(entry);
|
|
736
|
-
// Looked through before the call is announced, so a host is told of the tool it names with
|
|
737
|
-
// that tool's arguments. A name outside the catalogue is refused here, so `call_tool` reaches
|
|
738
|
-
// nothing a load could not, and the refusal is the call's answer below.
|
|
739
|
-
let inner = args ?? {};
|
|
740
|
-
let refused;
|
|
741
|
-
if (proxy && args) {
|
|
742
|
-
try {
|
|
743
|
-
inner = proxiedCall(args, catalog).input;
|
|
744
|
-
}
|
|
745
|
-
catch (error) {
|
|
746
|
-
inner = {};
|
|
747
|
-
refused = { error };
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
// Built for every call rather than only the dispatched ones, so a host hears of the calls
|
|
751
|
-
// the loop answers itself in the same shape. Arguments that could not be read are none.
|
|
752
|
-
const request = { id: call.id, name, args: inner, raw };
|
|
753
|
-
onEvent({ kind: "tool-call", id: call.id, name, text: preview(raw) });
|
|
754
|
-
onToolCall?.(request);
|
|
755
|
-
let content;
|
|
756
|
-
let ok = true;
|
|
757
|
-
try {
|
|
758
|
-
if (!args)
|
|
759
|
-
throw unreadable;
|
|
760
|
-
// By the name the model called, not the one a `call_tool` wraps: `load_tools` is not in
|
|
761
|
-
// the catalogue, and one reached through `call_tool` is refused below like any other.
|
|
762
|
-
if (onDemand && call.function.name === LOAD_TOOLS) {
|
|
763
|
-
const resolved = expandNames(requestedNames(args), catalog);
|
|
764
|
-
content = proxied
|
|
765
|
-
? proxyLoadResult(resolved, catalog, byName(resolved.matched), loaded)
|
|
766
|
-
: loadResult(resolved, catalog, loaded);
|
|
767
|
-
// Proxied, a load is only of what the result could define: a catalogued name the host
|
|
768
|
-
// gave no definition for was not loaded, and is not counted or remembered as though it was.
|
|
769
|
-
const hits = proxied
|
|
770
|
-
? resolved.matched.filter((hit) => definitions.has(hit))
|
|
771
|
-
: resolved.matched;
|
|
772
|
-
for (const hit of hits)
|
|
773
|
-
loads[loaded.has(hit) ? "redundantLoads" : "toolsLoaded"]++;
|
|
774
|
-
loads.unknownToolNames += resolved.unknown.length;
|
|
775
|
-
for (const hit of hits)
|
|
776
|
-
loaded.add(hit);
|
|
777
|
-
ok = hits.length > 0;
|
|
778
|
-
}
|
|
779
|
-
else {
|
|
780
|
-
if (refused)
|
|
781
|
-
throw refused.error;
|
|
782
|
-
// A model that skips `load_tools` and calls a catalogued tool by name is right about
|
|
783
|
-
// what it wants; load it and run it rather than refusing. Not proxied, where loaded
|
|
784
|
-
// means its definition is in the history, and this call put none there.
|
|
785
|
-
if (onDemand && !proxied && inCatalog(catalog, name))
|
|
786
|
-
loaded.add(name);
|
|
787
|
-
used.add(name);
|
|
788
|
-
// Keyed on the inner call, so one made through `call_tool` and one made natively share.
|
|
789
|
-
const key = `${name}\0${proxy ? JSON.stringify(inner) : normal}`;
|
|
790
|
-
content = dedupable(request)
|
|
791
|
-
? await once(answered, key, () => dispatch(request, signal))
|
|
792
|
-
: await dispatch(request, signal);
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
catch (error) {
|
|
796
|
-
if (signal?.aborted)
|
|
797
|
-
throw error;
|
|
798
|
-
content = errorMessage(error);
|
|
799
|
-
ok = false;
|
|
800
|
-
}
|
|
801
|
-
onEvent({ kind: "tool-result", id: call.id, name, ok, text: preview(content) });
|
|
802
|
-
const result = { id: call.id, name, ok, content };
|
|
803
|
-
onToolResult?.(result);
|
|
804
|
-
return result;
|
|
805
|
-
};
|
|
806
|
-
// By position, so a call that came back while another was being stopped is still found.
|
|
807
|
-
const outcomes = parsed.map(() => undefined);
|
|
808
|
-
// How many of the step's calls were handed off, and how many have their result in the
|
|
809
|
-
// transcript. Both count from the front, since results are written in the order asked.
|
|
810
|
-
let begun = 0;
|
|
811
|
-
let kept = 0;
|
|
812
|
-
const keep = async (id, content) => {
|
|
813
|
-
const result = {
|
|
814
|
-
role: "tool",
|
|
815
|
-
tool_call_id: id,
|
|
816
|
-
content,
|
|
817
|
-
};
|
|
818
|
-
messages.push(result);
|
|
819
|
-
kept++;
|
|
820
|
-
await announce(result, step);
|
|
821
|
-
};
|
|
822
|
-
try {
|
|
823
|
-
if (parallel) {
|
|
824
|
-
signal?.throwIfAborted();
|
|
825
|
-
begun = parsed.length;
|
|
826
|
-
await Promise.all(parsed.map(async (call, at) => {
|
|
827
|
-
outcomes[at] = await run(call);
|
|
828
|
-
}));
|
|
829
|
-
for (const { id, name, ok, content } of outcomes.flatMap((outcome) => outcome ?? [])) {
|
|
830
|
-
toolCalls.push({ id, name, ok });
|
|
831
|
-
await keep(id, content);
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
else {
|
|
835
|
-
for (const [at, call] of parsed.entries()) {
|
|
836
|
-
signal?.throwIfAborted();
|
|
837
|
-
begun = at + 1;
|
|
838
|
-
const { id, name, ok, content } = await run(call);
|
|
839
|
-
toolCalls.push({ id, name, ok });
|
|
840
|
-
await keep(id, content);
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
catch (error) {
|
|
845
|
-
// A call with no result is a transcript no endpoint takes back, so every call of the step
|
|
846
|
-
// is answered before the throw: with what it returned where it had, and otherwise with a
|
|
847
|
-
// line saying it was stopped, or never run. Only a call that was made counts in `toolCalls`.
|
|
848
|
-
for (const [at, entry] of parsed.entries()) {
|
|
849
|
-
const { call } = entry;
|
|
850
|
-
if (at < kept)
|
|
851
|
-
continue;
|
|
852
|
-
const outcome = outcomes[at];
|
|
853
|
-
// Under the name it was announced by, which for a `call_tool` is the tool it names.
|
|
854
|
-
if (at < begun) {
|
|
855
|
-
toolCalls.push({ id: call.id, name: named(entry).name, ok: outcome?.ok ?? false });
|
|
856
|
-
}
|
|
857
|
-
// The run is already ending on `error`; a host that cannot take this result does not
|
|
858
|
-
// get to replace it.
|
|
859
|
-
await keep(call.id, outcome?.content ?? (at < begun ? STOPPED_CALL : UNRUN_CALL)).catch(() => { });
|
|
860
|
-
}
|
|
861
|
-
throw error;
|
|
862
|
-
}
|
|
944
|
+
await runCalls(calling, parsed, messages, step);
|
|
863
945
|
}
|
|
864
946
|
return undefined;
|
|
865
947
|
}
|