@cubicecho/agent-core 2.23.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.
@@ -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 > 0
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} tool${guess.names.length === 1 ? "" : "s"} by name`);
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: { prompt: 0, completion: 0, total: 0, cached: 0 },
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: by
377
- // the task where the config carries one, and by the flattened name on its own endpoint where
378
- // it does not. `preselect` answers an empty model with nothing, so neither named asks nothing.
379
- const choose = async () => {
380
- const prompt = userText(options.messages.findLast((message) => message.role === "user"));
381
- if (!prompt.trim())
382
- return [];
383
- const asked = {
384
- ...(typeof options.preselect === "object" ? options.preselect : {}),
385
- signal,
386
- onNotice: notice,
387
- };
388
- const selector = config.tasks?.toolSelect;
389
- if (!selector?.model) {
390
- return preselect(config, config.toolSelectModel ?? "", catalog, prompt, asked);
391
- }
392
- const call = taskCall(selector, asked, config);
393
- return preselect(call.endpoint, call.model, catalog, prompt, call.options);
394
- };
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 choose()
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
- // The loop's own compaction, which is the task's or nothing: a summary the main model writes is
404
- // a second full-price request the operator did not ask for.
405
- const compactor = options.compact ? config.tasks?.compaction : undefined;
406
- const summarise = (() => {
407
- if (!compactor?.model)
408
- return undefined;
409
- const call = taskCall(compactor, { signal, onNotice: notice }, config);
410
- const write = summariser(call.endpoint, call.model, call.options);
411
- // A summary that fails folds nothing, and the step goes out as it was.
412
- return async (text) => (await tryAsk("compaction", () => write(text), { onNotice: notice })) ?? "";
413
- })();
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
- if (tool.type === "function" && !definitions.has(tool.function.name)) {
418
- definitions.set(tool.function.name, tool);
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) => [...names].flatMap((name) => definitions.get(name) ?? []);
701
+ const byName = (names) => definedAs(definitions, names);
426
702
  let messages = [...options.messages];
427
- const usage = { prompt: 0, completion: 0, total: 0, cached: 0 };
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: the tool array is fixed, so it is
471
- // answered as though the model had loaded it, and the definitions sit after the question.
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) => tool.type === "function" && inCatalog(catalog, tool.function.name))
750
+ ? byName(new Set(preselected)).filter((tool) => {
751
+ const name = toolName(tool);
752
+ return name !== undefined && inCatalog(catalog, name);
753
+ })
474
754
  : [];
475
- if (shortlist.length) {
476
- const names = shortlist.map((tool) => (tool.type === "function" ? tool.function.name : ""));
477
- // Numbered by where the call lands, so two questions in one transcript do not share an id.
478
- const id = `preselect-${messages.length}`;
479
- const args = JSON.stringify({ names });
480
- // Held to its own length rather than `MAX_PER_LOAD`: that cap is for a model choosing, and a
481
- // host that shortlisted more has already chosen.
482
- const content = proxyLoadResult(expandNames(names, catalog, names.length), catalog, shortlist);
483
- // Announced as a call the model made would be, so a host pairing calls with results by id
484
- // shows this one like the rest.
485
- onEvent({ kind: "tool-call", id, name: LOAD_TOOLS, text: preview(args) });
486
- onToolCall?.({ id, name: LOAD_TOOLS, args: { names }, raw: args });
487
- onEvent({ kind: "tool-result", id, name: LOAD_TOOLS, ok: true, text: preview(content) });
488
- onToolResult?.({ id, name: LOAD_TOOLS, ok: true, content });
489
- toolCalls.push({ id, name: LOAD_TOOLS, ok: true });
490
- loads.toolsLoaded += names.length;
491
- for (const name of names)
492
- loaded.add(name);
493
- const exchange = [
494
- {
495
- role: "assistant",
496
- content: null,
497
- tool_calls: [{ id, type: "function", function: { name: LOAD_TOOLS, arguments: args } }],
498
- },
499
- { role: "tool", tool_call_id: id, content },
500
- ];
501
- // Both written before either is announced, so a host that throws on the first leaves a call
502
- // with its result. Told as step zero's, with no turn: no request was made for them.
503
- messages.push(...exchange);
504
- for (const message of exchange)
505
- await announce(message, 0);
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
- ...(previous && previous.prompt > 0 ? { used: previous.prompt } : {}),
783
+ ...reported,
514
784
  limit: config.contextLength ?? 0,
515
785
  ledger,
516
786
  });
517
- let next = rewritten ?? messages;
518
- if (summarise) {
519
- const plan = planCompaction(next, {
520
- ...(typeof options.compact === "object" ? options.compact : {}),
521
- limit: config.contextLength ?? 0,
522
- // The last request's prompt describes this transcript only while nothing has rewritten it.
523
- ...(next === messages && previous && previous.prompt > 0 ? { used: previous.prompt } : {}),
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
- ...((compactor?.maxTokens ?? 0) > 0 ? { summaryTokens: compactor?.maxTokens } : {}),
526
- });
527
- if (plan) {
528
- const folded = await compactTranscript(next, plan, summarise, {
529
- ...(hooks
530
- ? { hooks: { run: hooks.run, context: hooks.context, onNote: hooks.onNote } }
531
- : {}),
532
- });
533
- if (folded !== next)
534
- notice(`compacted ${plan.cut - plan.from} messages into a summary`);
535
- next = folded;
536
- }
537
- }
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, question ? messages.indexOf(question) : -1, gathered.context, preface),
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.type === "function" ? tool.function.name : ""));
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 names = tools.flatMap((tool) => (tool.type === "function" ? [tool.function.name] : []));
645
- const recovered = recoverToolCalls(content, {
646
- names: onDemand ? [...names, LOAD_TOOLS, ...(proxies ? [CALL_TOOL] : [])] : names,
647
- });
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} tool call${calls.length === 1 ? "" : "s"} the model wrote as text; the server's tool-call parser does not match this model's template`);
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: a server that parses replayed arguments refuses the almost-JSON, and
658
- // one that could not be read at all is replayed as no arguments.
659
- const parsed = calls.map((call) => {
660
- try {
661
- const args = parseToolArguments(call.function.arguments, {
662
- finishReason: turn.finishReason,
663
- });
664
- return { call, args, normal: JSON.stringify(args) };
665
- }
666
- catch (error) {
667
- return { call, error, normal: "{}" };
668
- }
669
- });
670
- const assistant = {
671
- role: "assistant",
672
- content: content || null,
673
- ...(parsed.length
674
- ? {
675
- tool_calls: parsed.map(({ call, normal }) => ({
676
- ...call,
677
- function: { ...call.function, arguments: normal },
678
- })),
679
- }
680
- : {}),
681
- };
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
- let afterTurn = Promise.resolve([]);
686
- if (hooks) {
687
- const at = question ? messages.indexOf(question) : -1;
688
- // Not awaited: the answer is ready, and remembering it is not something to hold it for.
689
- // Handed back instead, for a host that wants its notes.
690
- afterTurn = notify(hooks.run, "afterTurn", {
691
- ...hooks.context,
692
- reply: turn.content,
693
- ...(at >= 0
694
- ? {
695
- turn: {
696
- index: turnIndex(messages, at),
697
- messages: turnMessages(hooks.context.session.id, messages, at),
698
- },
699
- }
700
- : {}),
701
- }, hooks.onNote);
702
- }
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
- // Per step, not per run: the answer to a call made two steps ago was true before the tools in
725
- // between ran, and the file the model read may be the file it has since written.
726
- const answered = new Map();
727
- // A `call_tool` is shown, counted and dispatched as the tool it names. Read from the repaired
728
- // arguments where there are any, since the model's own may be almost-JSON.
729
- const named = ({ call, args, normal }) => proxies && call.function.name === CALL_TOOL
730
- ? shownCall(CALL_TOOL, args ? normal : call.function.arguments)
731
- : { name: call.function.name, input: call.function.arguments };
732
- const run = async (entry) => {
733
- const { call, args, error: unreadable, normal } = entry;
734
- const proxy = proxies && call.function.name === CALL_TOOL;
735
- const { name, input: raw } = named(entry);
736
- // Looked through before the call is announced, so a host is told of the tool it names with
737
- // that tool's arguments. A name outside the catalogue is refused here, so `call_tool` reaches
738
- // nothing a load could not, and the refusal is the call's answer below.
739
- let inner = args ?? {};
740
- let refused;
741
- if (proxy && args) {
742
- try {
743
- inner = proxiedCall(args, catalog).input;
744
- }
745
- catch (error) {
746
- inner = {};
747
- refused = { error };
748
- }
749
- }
750
- // Built for every call rather than only the dispatched ones, so a host hears of the calls
751
- // the loop answers itself in the same shape. Arguments that could not be read are none.
752
- const request = { id: call.id, name, args: inner, raw };
753
- onEvent({ kind: "tool-call", id: call.id, name, text: preview(raw) });
754
- onToolCall?.(request);
755
- let content;
756
- let ok = true;
757
- try {
758
- if (!args)
759
- throw unreadable;
760
- // By the name the model called, not the one a `call_tool` wraps: `load_tools` is not in
761
- // the catalogue, and one reached through `call_tool` is refused below like any other.
762
- if (onDemand && call.function.name === LOAD_TOOLS) {
763
- const resolved = expandNames(requestedNames(args), catalog);
764
- content = proxied
765
- ? proxyLoadResult(resolved, catalog, byName(resolved.matched), loaded)
766
- : loadResult(resolved, catalog, loaded);
767
- // Proxied, a load is only of what the result could define: a catalogued name the host
768
- // gave no definition for was not loaded, and is not counted or remembered as though it was.
769
- const hits = proxied
770
- ? resolved.matched.filter((hit) => definitions.has(hit))
771
- : resolved.matched;
772
- for (const hit of hits)
773
- loads[loaded.has(hit) ? "redundantLoads" : "toolsLoaded"]++;
774
- loads.unknownToolNames += resolved.unknown.length;
775
- for (const hit of hits)
776
- loaded.add(hit);
777
- ok = hits.length > 0;
778
- }
779
- else {
780
- if (refused)
781
- throw refused.error;
782
- // A model that skips `load_tools` and calls a catalogued tool by name is right about
783
- // what it wants; load it and run it rather than refusing. Not proxied, where loaded
784
- // means its definition is in the history, and this call put none there.
785
- if (onDemand && !proxied && inCatalog(catalog, name))
786
- loaded.add(name);
787
- used.add(name);
788
- // Keyed on the inner call, so one made through `call_tool` and one made natively share.
789
- const key = `${name}\0${proxy ? JSON.stringify(inner) : normal}`;
790
- content = dedupable(request)
791
- ? await once(answered, key, () => dispatch(request, signal))
792
- : await dispatch(request, signal);
793
- }
794
- }
795
- catch (error) {
796
- if (signal?.aborted)
797
- throw error;
798
- content = errorMessage(error);
799
- ok = false;
800
- }
801
- onEvent({ kind: "tool-result", id: call.id, name, ok, text: preview(content) });
802
- const result = { id: call.id, name, ok, content };
803
- onToolResult?.(result);
804
- return result;
805
- };
806
- // By position, so a call that came back while another was being stopped is still found.
807
- const outcomes = parsed.map(() => undefined);
808
- // How many of the step's calls were handed off, and how many have their result in the
809
- // transcript. Both count from the front, since results are written in the order asked.
810
- let begun = 0;
811
- let kept = 0;
812
- const keep = async (id, content) => {
813
- const result = {
814
- role: "tool",
815
- tool_call_id: id,
816
- content,
817
- };
818
- messages.push(result);
819
- kept++;
820
- await announce(result, step);
821
- };
822
- try {
823
- if (parallel) {
824
- signal?.throwIfAborted();
825
- begun = parsed.length;
826
- await Promise.all(parsed.map(async (call, at) => {
827
- outcomes[at] = await run(call);
828
- }));
829
- for (const { id, name, ok, content } of outcomes.flatMap((outcome) => outcome ?? [])) {
830
- toolCalls.push({ id, name, ok });
831
- await keep(id, content);
832
- }
833
- }
834
- else {
835
- for (const [at, call] of parsed.entries()) {
836
- signal?.throwIfAborted();
837
- begun = at + 1;
838
- const { id, name, ok, content } = await run(call);
839
- toolCalls.push({ id, name, ok });
840
- await keep(id, content);
841
- }
842
- }
843
- }
844
- catch (error) {
845
- // A call with no result is a transcript no endpoint takes back, so every call of the step
846
- // is answered before the throw: with what it returned where it had, and otherwise with a
847
- // line saying it was stopped, or never run. Only a call that was made counts in `toolCalls`.
848
- for (const [at, entry] of parsed.entries()) {
849
- const { call } = entry;
850
- if (at < kept)
851
- continue;
852
- const outcome = outcomes[at];
853
- // Under the name it was announced by, which for a `call_tool` is the tool it names.
854
- if (at < begun) {
855
- toolCalls.push({ id: call.id, name: named(entry).name, ok: outcome?.ok ?? false });
856
- }
857
- // The run is already ending on `error`; a host that cannot take this result does not
858
- // get to replace it.
859
- await keep(call.id, outcome?.content ?? (at < begun ? STOPPED_CALL : UNRUN_CALL)).catch(() => { });
860
- }
861
- throw error;
862
- }
944
+ await runCalls(calling, parsed, messages, step);
863
945
  }
864
946
  return undefined;
865
947
  }