@cubicecho/agent-core 2.19.0 → 2.21.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.
@@ -2,16 +2,16 @@ import { charsPerTokenFor } from "./calibration.js";
2
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
- import { errorMessage } from "./errors.js";
5
+ import { AgentLoopError, AgentLoopOverflow, errorMessage, ToolIterationLimit, } from "./errors.js";
6
6
  import { runMetrics } from "./events.js";
7
7
  import { configureHooks, gather, notify, turnIndex, turnMessages, withContext, } from "./hooks.js";
8
8
  import { rebaseLedger, recordRequest } from "./ledger.js";
9
- import { contextTokens, toolsChars } from "./retry.js";
9
+ import { ContextOverflow, contextTokens, toolsChars } from "./retry.js";
10
10
  import { runTurn } from "./run-turn.js";
11
11
  import { relaxTools, sanitizeTools } from "./schema-compat.js";
12
12
  import { askJson, tryAsk } from "./side-task.js";
13
13
  import { parseToolArguments, recoverToolCalls } from "./tool-calls.js";
14
- 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";
15
15
  /**
16
16
  * The loop above a turn: send, run the tools the model asked for, send again, until it stops
17
17
  * asking.
@@ -198,11 +198,24 @@ function cacheDiagnosis(previous, messages, tools, usage) {
198
198
  found.cacheBreakReason = breakReason(previous, messages, tools);
199
199
  return found;
200
200
  }
201
+ /** What a tool call the run was stopped during is answered with, in place of a result. */
202
+ const STOPPED_CALL = "Stopped before this call finished.";
203
+ /** The same for a call of that step the loop never got to. */
204
+ const UNRUN_CALL = "Not run: the run stopped first.";
201
205
  /**
202
206
  * Runs a question to its answer: one `runTurn` per step, the tools it asks for between them,
203
207
  * until a turn asks for none. Throws when `maxToolIterations` is spent, when stopped, and on
204
208
  * whatever `runTurn` throws — `ContextOverflow` among them, however it was found out.
205
209
  *
210
+ * What it throws carries the run as it stood — `messages`, `usage`, `toolCalls`, `loaded` and
211
+ * `used` — because the steps before a failure were real and a host that stores its transcript
212
+ * has nothing else to store them from. A spent budget is a `ToolIterationLimit`, an overflow is
213
+ * an `AgentLoopOverflow`, which is still a `ContextOverflow`, and anything else is an
214
+ * `AgentLoopError` with what was caught as its `cause` and its message as its own; `failedRun`
215
+ * reads the run off any of the three. A failure while a step's tools run leaves the transcript
216
+ * well-formed: the results that came back are kept, and each call without one is answered with a
217
+ * line saying it was stopped or never run, since a call with no result is refused on replay.
218
+ *
206
219
  * On-demand loading is handled here, `load_tools` and all: the catalogue rides on the system
207
220
  * prompt unchanged from step to step, a load adds to the tool array — which every request sends
208
221
  * in the stable order `toolOrder` asks for — and
@@ -220,12 +233,50 @@ function cacheDiagnosis(previous, messages, tools, usage) {
220
233
  * continuation, and a step that reported none, or reported a cache count above its prompt, is not
221
234
  * recorded. The ledger comes back in the result and on every `usage` event; see `tokensBetween`.
222
235
  *
236
+ * Proxied discovery is the same catalogue behind a tool array that never moves: every step
237
+ * declares `PROXY_TOOLS` and nothing else, a load answers with the definitions themselves, and a
238
+ * `call_tool` is dispatched, counted and reported as the tool it names. A preselection has no
239
+ * tool array to go into, so it is written into the transcript as a `load_tools` exchange after
240
+ * the question. `call_tool` is honoured on demand too, unless the host has a tool of that name —
241
+ * a session switched out of proxied mode still has it in its history, and the model copies it.
242
+ *
223
243
  * @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
224
244
  * and cancellation. See `AgentLoopOptions`.
225
245
  */
226
246
  export async function runAgentLoop(options) {
247
+ // Until the steps have set themselves up there is only what the caller handed over.
248
+ const standing = {
249
+ read: () => ({
250
+ messages: [...options.messages],
251
+ usage: { prompt: 0, completion: 0, total: 0, cached: 0 },
252
+ toolCalls: [],
253
+ loaded: [],
254
+ used: [],
255
+ }),
256
+ };
257
+ let result;
258
+ try {
259
+ result = await runSteps(options, standing);
260
+ }
261
+ catch (error) {
262
+ // Wrapped rather than annotated: an abort's reason is one object shared by every run under
263
+ // the signal, so the run cannot be hung on the error itself. An overflow keeps its class,
264
+ // because `instanceof ContextOverflow` is how a caller knows to compact and try again.
265
+ throw error instanceof ContextOverflow
266
+ ? new AgentLoopOverflow(error.message, standing.read(), { cause: error })
267
+ : new AgentLoopError(errorMessage(error), standing.read(), { cause: error });
268
+ }
269
+ if (result)
270
+ return result;
271
+ throw new ToolIterationLimit(`Stopped after ${options.config.maxToolIterations} tool iterations.`, standing.read());
272
+ }
273
+ /**
274
+ * The steps of `runAgentLoop`, throwing what they caught as it was caught. Resolves to nothing
275
+ * when `maxToolIterations` is spent, and tells `standing` how to read the run either way.
276
+ */
277
+ async function runSteps(options, standing) {
227
278
  const { config, system = "", tools = [], catalog = [], dispatch, hooks, signal } = options;
228
- const { onRequest, onTurn, onToolCall, onToolResult, beforeStep, parallel = false, recoverToolCalls: recover = true, maxContinuations = 0, toolOrder = true, dedupeToolCalls = true, preselectRouting = "exclusive", } = options;
279
+ const { onRequest, onTurn, onMessage, onToolCall, onToolResult, beforeStep, parallel = false, recoverToolCalls: recover = true, maxContinuations = 0, toolOrder = true, dedupeToolCalls = true, preselectRouting = "exclusive", } = options;
229
280
  const dedupable = typeof dedupeToolCalls === "function" ? dedupeToolCalls : () => dedupeToolCalls;
230
281
  const started = Date.now();
231
282
  // What the loop emitted, less the token deltas, for `runMetrics` at the end. Stamped here rather
@@ -254,11 +305,16 @@ export async function runAgentLoop(options) {
254
305
  const supports = capabilitiesFor(config.baseUrl, config.apiKey);
255
306
  const maxRetries = Math.max(0, Number(config.maxRetries) || 0);
256
307
  const notice = (text) => onEvent({ kind: "notice", text });
257
- const onDemand = config.toolDiscovery === "ondemand" && catalog.length > 0;
258
- const loaded = new Set(onDemand ? (options.loaded ?? []) : []);
308
+ // On demand, but with a tool array that never changes: definitions come back as `load_tools`
309
+ // results and run through `call_tool`. `onDemand` is true of both.
310
+ const proxied = config.toolDiscovery === "proxy" && catalog.length > 0;
311
+ const onDemand = proxied || (config.toolDiscovery === "ondemand" && catalog.length > 0);
312
+ // Proxied, `loaded` is what this run has put a definition in the history for, and starts empty.
313
+ const loaded = new Set(onDemand && !proxied ? (options.loaded ?? []) : []);
259
314
  const preselected = onDemand ? [...(options.preselected ?? [])] : [];
260
- for (const name of preselected)
261
- loaded.add(name);
315
+ if (!proxied)
316
+ for (const name of preselected)
317
+ loaded.add(name);
262
318
  const used = new Set();
263
319
  const definitions = new Map();
264
320
  for (const tool of tools) {
@@ -266,10 +322,36 @@ export async function runAgentLoop(options) {
266
322
  definitions.set(tool.function.name, tool);
267
323
  }
268
324
  }
325
+ // Looked through unless the host has a tool by that name, which is then the host's to answer.
326
+ const proxies = onDemand && !definitions.has(CALL_TOOL);
269
327
  // In the order the names are given, not the order of `tools`: `loaded` is a set, which iterates
270
328
  // in the order things were added, so a load appends and never reshuffles what went before.
271
329
  const byName = (names) => [...names].flatMap((name) => definitions.get(name) ?? []);
272
330
  let messages = [...options.messages];
331
+ const usage = { prompt: 0, completion: 0, total: 0, cached: 0 };
332
+ const toolCalls = [];
333
+ standing.read = () => ({
334
+ messages,
335
+ usage,
336
+ toolCalls,
337
+ // As the result would have said it: proxied, nothing is loaded that the history does not hold.
338
+ loaded: proxied ? [] : [...loaded],
339
+ used: [...used],
340
+ });
341
+ // Once `onMessage` has thrown the run is ending on that, and the results still to be written
342
+ // into the transcript are not offered to a host that has just failed to take one.
343
+ let heard = true;
344
+ const announce = async (message, step, turn) => {
345
+ if (!onMessage || !heard)
346
+ return;
347
+ try {
348
+ await onMessage(message, step, turn);
349
+ }
350
+ catch (error) {
351
+ heard = false;
352
+ throw error;
353
+ }
354
+ };
273
355
  // Held by reference rather than by index, so a `beforeStep` that folds the head into a summary
274
356
  // moves the question without losing it — and one that summarises the question away takes the
275
357
  // hooks' context with it, which is right.
@@ -283,43 +365,85 @@ export async function runAgentLoop(options) {
283
365
  : { context: "", notes: [] };
284
366
  // Read once, so what the result hands back is what every step of this run said.
285
367
  const preface = hooks?.preface ?? configureHooks().preface;
286
- const usage = { prompt: 0, completion: 0, total: 0, cached: 0 };
287
- const toolCalls = [];
288
368
  const loads = { toolsLoaded: 0, redundantLoads: 0, unknownToolNames: 0 };
289
369
  let previous;
290
370
  let ledger = options.ledger ?? [];
291
371
  // The request the ledger's last entry came from, which is what the next one is measured against.
292
372
  // Nothing before this run's first: an earlier run's head is not something the loop can see.
293
373
  let measured;
374
+ // Proxied, a shortlist has nowhere to go but the history: the tool array is fixed, so it is
375
+ // answered as though the model had loaded it, and the definitions sit after the question.
376
+ const shortlist = proxied
377
+ ? byName(new Set(preselected)).filter((tool) => tool.type === "function" && inCatalog(catalog, tool.function.name))
378
+ : [];
379
+ if (shortlist.length) {
380
+ const names = shortlist.map((tool) => (tool.type === "function" ? tool.function.name : ""));
381
+ // Numbered by where the call lands, so two questions in one transcript do not share an id.
382
+ const id = `preselect-${messages.length}`;
383
+ const args = JSON.stringify({ names });
384
+ // Held to its own length rather than `MAX_PER_LOAD`: that cap is for a model choosing, and a
385
+ // host that shortlisted more has already chosen.
386
+ const content = proxyLoadResult(expandNames(names, catalog, names.length), catalog, shortlist);
387
+ // Announced as a call the model made would be, so a host pairing calls with results by id
388
+ // shows this one like the rest.
389
+ onEvent({ kind: "tool-call", id, name: LOAD_TOOLS, text: preview(args) });
390
+ onToolCall?.({ id, name: LOAD_TOOLS, args: { names }, raw: args });
391
+ onEvent({ kind: "tool-result", id, name: LOAD_TOOLS, ok: true, text: preview(content) });
392
+ onToolResult?.({ id, name: LOAD_TOOLS, ok: true, content });
393
+ toolCalls.push({ id, name: LOAD_TOOLS, ok: true });
394
+ loads.toolsLoaded += names.length;
395
+ for (const name of names)
396
+ loaded.add(name);
397
+ const exchange = [
398
+ {
399
+ role: "assistant",
400
+ content: null,
401
+ tool_calls: [{ id, type: "function", function: { name: LOAD_TOOLS, arguments: args } }],
402
+ },
403
+ { role: "tool", tool_call_id: id, content },
404
+ ];
405
+ // Both written before either is announced, so a host that throws on the first leaves a call
406
+ // with its result. Told as step zero's, with no turn: no request was made for them.
407
+ messages.push(...exchange);
408
+ for (const message of exchange)
409
+ await announce(message, 0);
410
+ }
294
411
  for (let step = 0; step < config.maxToolIterations; step++) {
295
412
  // A stop aborts the request in flight, but a tool call already handed off runs to its own
296
413
  // end — so the signal is read between steps as well.
297
414
  signal?.throwIfAborted();
298
415
  const before = messages;
299
- messages =
300
- (await beforeStep?.(messages, step, {
301
- ...(previous && previous.prompt > 0 ? { used: previous.prompt } : {}),
302
- limit: config.contextLength ?? 0,
303
- ledger,
304
- })) ?? messages;
416
+ const rewritten = await beforeStep?.(messages, step, {
417
+ ...(previous && previous.prompt > 0 ? { used: previous.prompt } : {}),
418
+ limit: config.contextLength ?? 0,
419
+ ledger,
420
+ });
421
+ // A rewrite may have folded a definition away, and a load answered "already loaded" would
422
+ // then point at nothing. Forgetting costs a definition sent twice at worst.
423
+ if (proxied && rewritten && rewritten !== messages)
424
+ loaded.clear();
425
+ messages = rewritten ?? messages;
305
426
  ledger = rebaseLedger(ledger, before, messages);
306
427
  onEvent({ kind: "turn", text: `turn ${step + 1}` });
307
428
  // Appended, the shortlist is already in `loaded` — after what was carried, and once — so the
308
- // first step needs nothing of its own.
309
- const routed = preselectRouting !== "append" && preselected.length > 0 && step === 0;
429
+ // first step needs nothing of its own. Proxied, it is in the history.
430
+ const routed = !proxied && preselectRouting !== "append" && preselected.length > 0 && step === 0;
310
431
  // Ordered here rather than left to `buildBody`, so `names` below is what the request actually
311
432
  // declared — a diagnosis reading an order the server never saw calls an untouched tool array
312
433
  // `tools-changed`.
313
434
  const declared = orderTools(routed
314
435
  ? byName(new Set(preselected))
315
- : onDemand
316
- ? loadedTools([LOAD_TOOLS_DEFINITION], byName(loaded))
317
- : tools, toolOrder);
436
+ : proxied
437
+ ? [...PROXY_TOOLS]
438
+ : onDemand
439
+ ? loadedTools([LOAD_TOOLS_DEFINITION], byName(loaded))
440
+ : tools, toolOrder);
318
441
  // Unmarked, so the system prompt is the same text on every step and a load does not throw
319
442
  // away the cache for the whole transcript. What is loaded is said in `declared` and in the
320
443
  // `load_tools` result instead. The preselected first step is the one exception, by design,
321
444
  // and `preselectRouting: "append"` is the way out of it.
322
- const prompt = onDemand && !routed ? `${system}\n\n${catalogPrompt(catalog)}`.trim() : system;
445
+ const catalogue = proxied ? proxyCatalogPrompt(catalog) : catalogPrompt(catalog);
446
+ const prompt = onDemand && !routed ? `${system}\n\n${catalogue}`.trim() : system;
323
447
  const request = [
324
448
  ...(prompt ? [{ role: "system", content: prompt }] : []),
325
449
  ...withContext(messages, question ? messages.indexOf(question) : -1, gathered.context, preface),
@@ -402,7 +526,7 @@ export async function runAgentLoop(options) {
402
526
  if (recover && !calls.length && content && (tools.length > 0 || onDemand)) {
403
527
  const names = tools.flatMap((tool) => (tool.type === "function" ? [tool.function.name] : []));
404
528
  const recovered = recoverToolCalls(content, {
405
- names: onDemand ? [...names, LOAD_TOOLS] : names,
529
+ names: onDemand ? [...names, LOAD_TOOLS, ...(proxies ? [CALL_TOOL] : [])] : names,
406
530
  });
407
531
  if (recovered.toolCalls.length) {
408
532
  calls = recovered.toolCalls;
@@ -426,7 +550,7 @@ export async function runAgentLoop(options) {
426
550
  return { call, error, normal: "{}" };
427
551
  }
428
552
  });
429
- messages.push({
553
+ const assistant = {
430
554
  role: "assistant",
431
555
  content: content || null,
432
556
  ...(parsed.length
@@ -437,7 +561,9 @@ export async function runAgentLoop(options) {
437
561
  })),
438
562
  }
439
563
  : {}),
440
- });
564
+ };
565
+ messages.push(assistant);
566
+ await announce(assistant, step, shown);
441
567
  if (!calls.length) {
442
568
  let afterTurn = Promise.resolve([]);
443
569
  if (hooks) {
@@ -463,7 +589,7 @@ export async function runAgentLoop(options) {
463
589
  messages,
464
590
  usage,
465
591
  toolCalls,
466
- loaded: [...loaded],
592
+ loaded: proxied ? [] : [...loaded],
467
593
  used: [...used],
468
594
  notes: gathered.notes,
469
595
  context: gathered.context,
@@ -481,11 +607,32 @@ export async function runAgentLoop(options) {
481
607
  // Per step, not per run: the answer to a call made two steps ago was true before the tools in
482
608
  // between ran, and the file the model read may be the file it has since written.
483
609
  const answered = new Map();
484
- const run = async ({ call, args, error: unreadable, normal }) => {
485
- const { name, arguments: raw } = call.function;
610
+ // A `call_tool` is shown, counted and dispatched as the tool it names. Read from the repaired
611
+ // arguments where there are any, since the model's own may be almost-JSON.
612
+ const named = ({ call, args, normal }) => proxies && call.function.name === CALL_TOOL
613
+ ? shownCall(CALL_TOOL, args ? normal : call.function.arguments)
614
+ : { name: call.function.name, input: call.function.arguments };
615
+ const run = async (entry) => {
616
+ const { call, args, error: unreadable, normal } = entry;
617
+ const proxy = proxies && call.function.name === CALL_TOOL;
618
+ const { name, input: raw } = named(entry);
619
+ // Looked through before the call is announced, so a host is told of the tool it names with
620
+ // that tool's arguments. A name outside the catalogue is refused here, so `call_tool` reaches
621
+ // nothing a load could not, and the refusal is the call's answer below.
622
+ let inner = args ?? {};
623
+ let refused;
624
+ if (proxy && args) {
625
+ try {
626
+ inner = proxiedCall(args, catalog).input;
627
+ }
628
+ catch (error) {
629
+ inner = {};
630
+ refused = { error };
631
+ }
632
+ }
486
633
  // Built for every call rather than only the dispatched ones, so a host hears of the calls
487
634
  // the loop answers itself in the same shape. Arguments that could not be read are none.
488
- const request = { id: call.id, name, args: args ?? {}, raw };
635
+ const request = { id: call.id, name, args: inner, raw };
489
636
  onEvent({ kind: "tool-call", id: call.id, name, text: preview(raw) });
490
637
  onToolCall?.(request);
491
638
  let content;
@@ -493,24 +640,38 @@ export async function runAgentLoop(options) {
493
640
  try {
494
641
  if (!args)
495
642
  throw unreadable;
496
- if (onDemand && name === LOAD_TOOLS) {
643
+ // By the name the model called, not the one a `call_tool` wraps: `load_tools` is not in
644
+ // the catalogue, and one reached through `call_tool` is refused below like any other.
645
+ if (onDemand && call.function.name === LOAD_TOOLS) {
497
646
  const resolved = expandNames(requestedNames(args), catalog);
498
- content = loadResult(resolved, catalog, loaded);
499
- for (const hit of resolved.matched)
647
+ content = proxied
648
+ ? proxyLoadResult(resolved, catalog, byName(resolved.matched), loaded)
649
+ : loadResult(resolved, catalog, loaded);
650
+ // Proxied, a load is only of what the result could define: a catalogued name the host
651
+ // gave no definition for was not loaded, and is not counted or remembered as though it was.
652
+ const hits = proxied
653
+ ? resolved.matched.filter((hit) => definitions.has(hit))
654
+ : resolved.matched;
655
+ for (const hit of hits)
500
656
  loads[loaded.has(hit) ? "redundantLoads" : "toolsLoaded"]++;
501
657
  loads.unknownToolNames += resolved.unknown.length;
502
- for (const hit of resolved.matched)
658
+ for (const hit of hits)
503
659
  loaded.add(hit);
504
- ok = resolved.matched.length > 0;
660
+ ok = hits.length > 0;
505
661
  }
506
662
  else {
663
+ if (refused)
664
+ throw refused.error;
507
665
  // A model that skips `load_tools` and calls a catalogued tool by name is right about
508
- // what it wants; load it and run it rather than refusing.
509
- if (onDemand && inCatalog(catalog, name))
666
+ // what it wants; load it and run it rather than refusing. Not proxied, where loaded
667
+ // means its definition is in the history, and this call put none there.
668
+ if (onDemand && !proxied && inCatalog(catalog, name))
510
669
  loaded.add(name);
511
670
  used.add(name);
671
+ // Keyed on the inner call, so one made through `call_tool` and one made natively share.
672
+ const key = `${name}\0${proxy ? JSON.stringify(inner) : normal}`;
512
673
  content = dedupable(request)
513
- ? await once(answered, `${name}\0${normal}`, () => dispatch(request, signal))
674
+ ? await once(answered, key, () => dispatch(request, signal))
514
675
  : await dispatch(request, signal);
515
676
  }
516
677
  }
@@ -525,23 +686,65 @@ export async function runAgentLoop(options) {
525
686
  onToolResult?.(result);
526
687
  return result;
527
688
  };
528
- const outcomes = [];
529
- if (parallel) {
530
- signal?.throwIfAborted();
531
- outcomes.push(...(await Promise.all(parsed.map(run))));
532
- }
533
- else {
534
- for (const call of parsed) {
689
+ // By position, so a call that came back while another was being stopped is still found.
690
+ const outcomes = parsed.map(() => undefined);
691
+ // How many of the step's calls were handed off, and how many have their result in the
692
+ // transcript. Both count from the front, since results are written in the order asked.
693
+ let begun = 0;
694
+ let kept = 0;
695
+ const keep = async (id, content) => {
696
+ const result = {
697
+ role: "tool",
698
+ tool_call_id: id,
699
+ content,
700
+ };
701
+ messages.push(result);
702
+ kept++;
703
+ await announce(result, step);
704
+ };
705
+ try {
706
+ if (parallel) {
535
707
  signal?.throwIfAborted();
536
- outcomes.push(await run(call));
708
+ begun = parsed.length;
709
+ await Promise.all(parsed.map(async (call, at) => {
710
+ outcomes[at] = await run(call);
711
+ }));
712
+ for (const { id, name, ok, content } of outcomes.flatMap((outcome) => outcome ?? [])) {
713
+ toolCalls.push({ id, name, ok });
714
+ await keep(id, content);
715
+ }
716
+ }
717
+ else {
718
+ for (const [at, call] of parsed.entries()) {
719
+ signal?.throwIfAborted();
720
+ begun = at + 1;
721
+ const { id, name, ok, content } = await run(call);
722
+ toolCalls.push({ id, name, ok });
723
+ await keep(id, content);
724
+ }
537
725
  }
538
726
  }
539
- for (const { id, name, ok, content } of outcomes) {
540
- toolCalls.push({ id, name, ok });
541
- messages.push({ role: "tool", tool_call_id: id, content });
727
+ catch (error) {
728
+ // A call with no result is a transcript no endpoint takes back, so every call of the step
729
+ // is answered before the throw: with what it returned where it had, and otherwise with a
730
+ // line saying it was stopped, or never run. Only a call that was made counts in `toolCalls`.
731
+ for (const [at, entry] of parsed.entries()) {
732
+ const { call } = entry;
733
+ if (at < kept)
734
+ continue;
735
+ const outcome = outcomes[at];
736
+ // Under the name it was announced by, which for a `call_tool` is the tool it names.
737
+ if (at < begun) {
738
+ toolCalls.push({ id: call.id, name: named(entry).name, ok: outcome?.ok ?? false });
739
+ }
740
+ // The run is already ending on `error`; a host that cannot take this result does not
741
+ // get to replace it.
742
+ await keep(call.id, outcome?.content ?? (at < begun ? STOPPED_CALL : UNRUN_CALL)).catch(() => { });
743
+ }
744
+ throw error;
542
745
  }
543
746
  }
544
- throw new Error(`Stopped after ${config.maxToolIterations} tool iterations.`);
747
+ return undefined;
545
748
  }
546
749
  /**
547
750
  * Makes a call at most once per key, sharing the in-flight promise so two identical calls in one
@@ -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. Returns the same array when there was nothing to clear. Rewrites the prefix;
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.
@@ -1,6 +1,7 @@
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. */
@@ -65,7 +66,9 @@ const systemHead = (messages) => {
65
66
  * after it, and by then the model has usually taken what it wanted from it; the stub keeps the
66
67
  * call answered — a call with no result is a malformed transcript — and says how much was there,
67
68
  * so a model that does need it again knows to ask. Short results are kept, since a stub saves
68
- * nothing on them. Returns the same array when there was nothing to clear. Rewrites the prefix;
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;
69
72
  * see the module comment on when to run it.
70
73
  *
71
74
  * @param messages The transcript. Not written to.
@@ -83,6 +86,10 @@ export function pruneToolResults(messages, { keepLast = 5, maxChars = 256 } = {}
83
86
  const text = textOf(message.content);
84
87
  if (text.length <= maxChars || text.startsWith("[result cleared"))
85
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;
86
93
  out ??= [...messages];
87
94
  out[at] = {
88
95
  ...message,
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. See `tool-loading.ts`.
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/errors.d.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import type OpenAI from "openai";
2
+ import type { ToolCallOutcome } from "./agent-loop.ts";
3
+ import { ContextOverflow } from "./retry.ts";
4
+ import type { TurnUsage } from "./stream.ts";
1
5
  /**
2
6
  * What went wrong, as a string.
3
7
  *
@@ -8,3 +12,88 @@
8
12
  * @param error Whatever a `catch` bound. Anything that is not an `Error` is stringified.
9
13
  */
10
14
  export declare const errorMessage: (error: unknown) => string;
15
+ /**
16
+ * A run as it stood when `runAgentLoop` gave up on it.
17
+ *
18
+ * The loop works on its own copy of the transcript and hands it back only on success, so without
19
+ * this a spent budget, a stop or a refused request took every step before it along — steps whose
20
+ * assistant turns and tool results were real, and that a host storing its transcript has to keep.
21
+ */
22
+ export interface AgentLoopFailure {
23
+ /**
24
+ * The transcript, as the result's `messages` would have been: every assistant turn and tool
25
+ * result the run added, and no system prompt. Well-formed — a call the run was stopped during
26
+ * has a result saying so. The reply of a request that was in flight is not in it.
27
+ */
28
+ messages: OpenAI.ChatCompletionMessageParam[];
29
+ /** Summed over the turns that came back. A request that failed or was stopped adds nothing. */
30
+ usage: TurnUsage;
31
+ /** Every call that was made, in order. One the run was stopped during is `ok: false`. */
32
+ toolCalls: ToolCallOutcome[];
33
+ /** What was loaded when the run failed, for `carryOver`. Empty in eager and proxied modes. */
34
+ loaded: string[];
35
+ /** The tools the model had called, `load_tools` excluded. */
36
+ used: string[];
37
+ }
38
+ /**
39
+ * What `runAgentLoop` throws, carrying the run as it stood and what was caught as `cause`.
40
+ *
41
+ * Its own class rather than fields hung on the error that was caught, because a stop throws the
42
+ * signal's reason and that is one object for every run under the signal. The message is the
43
+ * cause's own, so a host that logs `errorMessage(error)` logs what it always did. A stop is told
44
+ * the way it was before — by the signal the host aborted, not by the class of what came out.
45
+ */
46
+ export declare class AgentLoopError extends Error implements AgentLoopFailure {
47
+ readonly name: string;
48
+ readonly messages: OpenAI.ChatCompletionMessageParam[];
49
+ readonly usage: TurnUsage;
50
+ readonly toolCalls: ToolCallOutcome[];
51
+ readonly loaded: string[];
52
+ readonly used: string[];
53
+ /**
54
+ * @param message What went wrong — the cause's own message, where there is a cause.
55
+ * @param run The run as it stood. Held, not copied.
56
+ * @param options The standard `cause`: what the loop caught, absent where the loop itself gave up.
57
+ */
58
+ constructor(message: string, run: AgentLoopFailure, options?: ErrorOptions);
59
+ }
60
+ /**
61
+ * `maxToolIterations` was spent with the model still asking for tools.
62
+ *
63
+ * Named so a host can tell it from a failure without matching the message, which is the same
64
+ * sentence it has always been. There is no `cause`: nothing was caught, the loop stopped itself.
65
+ */
66
+ export declare class ToolIterationLimit extends AgentLoopError {
67
+ readonly name: string;
68
+ }
69
+ /**
70
+ * A `ContextOverflow` out of `runAgentLoop`, carrying the run as it stood.
71
+ *
72
+ * A subclass of `ContextOverflow` rather than an `AgentLoopError` around one, because
73
+ * `instanceof ContextOverflow` is how a caller knows to compact and send again, and wrapping
74
+ * would have made that test quietly false. The overflow the loop caught is the `cause`, and the
75
+ * endpoint's own error, where there was one, is that one's `cause` in turn.
76
+ */
77
+ export declare class AgentLoopOverflow extends ContextOverflow implements AgentLoopFailure {
78
+ readonly messages: OpenAI.ChatCompletionMessageParam[];
79
+ readonly usage: TurnUsage;
80
+ readonly toolCalls: ToolCallOutcome[];
81
+ readonly loaded: string[];
82
+ readonly used: string[];
83
+ /**
84
+ * @param message The overflow's own message.
85
+ * @param run The run as it stood. Held, not copied.
86
+ * @param options The standard `cause`: the `ContextOverflow` the loop caught.
87
+ */
88
+ constructor(message: string, run: AgentLoopFailure, options?: ErrorOptions);
89
+ }
90
+ /**
91
+ * The run a failed `runAgentLoop` left behind, read off whatever it threw, or nothing.
92
+ *
93
+ * Two classes carry it — an overflow has to stay a `ContextOverflow` and so cannot also be an
94
+ * `AgentLoopError` — and a `catch` that only wants the transcript should not have to name both.
95
+ *
96
+ * @param error Whatever a `catch` around `runAgentLoop` bound. Anything the loop did not throw
97
+ * answers `undefined`.
98
+ */
99
+ export declare const failedRun: (error: unknown) => AgentLoopFailure | undefined;