@cubicecho/agent-core 2.20.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.
package/README.md CHANGED
@@ -44,7 +44,7 @@ only, Node >=22.
44
44
  | `spec` | `parseSpec`, `resolveAgentSpec` and `exportSpec`: an agent as a versioned JSON document, layered into the flat config the loop takes. Imports nothing but types, and is published separately at `@cubicecho/agent-core/spec`. |
45
45
  | `reset` | `resetAll`: drops every cache and latch in one call, so a teardown cannot forget one. |
46
46
  | `tokens` | `estimateTokens`: characters over four, deliberately low, for everything here that has to guess at a window. |
47
- | `errors` | `errorMessage`: a caught `unknown` turned into something a run row can hold. |
47
+ | `errors` | `errorMessage`: a caught `unknown` turned into something a run row can hold. And what `runAgentLoop` throws, with the run as it stood: `AgentLoopError`, `ToolIterationLimit`, `AgentLoopOverflow`, and `failedRun` to read it off any of them. |
48
48
  | `catalog` | `CatalogServer`: the name-only shape `tool-loading` reads a connected server as. |
49
49
 
50
50
  What is **not** here is the work: orchestration, prompts, and whatever the run is about. That
@@ -673,6 +673,50 @@ with pre-turn work to overlap with the hooks — compaction, a preselection —
673
673
  applies `withContext` to the current question as well, calls `notify` after, and passes no `hooks`
674
674
  (see [Hooks](#hooks)).
675
675
 
676
+ A host that stores its transcript — a row per message, so a crash or a stop mid-run still leaves
677
+ readable history — gets each message as the loop appends it, and the run as it stood when the loop
678
+ throws:
679
+
680
+ ```ts
681
+ try {
682
+ await runAgentLoop({
683
+ ...options,
684
+ // Awaited: the row is written before the tools run, and before the next request is sent.
685
+ onMessage: (message, step, turn) =>
686
+ store.add(sessionId, turn?.reasoning ? { ...message, reasoning_content: turn.reasoning } : message),
687
+ });
688
+ } catch (error) {
689
+ const run = failedRun(error); // messages, usage, toolCalls, loaded, used — or undefined
690
+ if (run) await store.keep(sessionId, { usage: run.usage, loaded: run.loaded });
691
+ if (signal.aborted) return; // a stop, told by the signal as it always was
692
+ if (error instanceof ContextOverflow) return compactAndRetry(run?.messages);
693
+ if (error instanceof ToolIterationLimit) return outOfSteps();
694
+ throw error; // an AgentLoopError: the message is the cause's, and the cause is on `cause`
695
+ }
696
+ ```
697
+
698
+ `onMessage` hears the assistant message as it will be replayed — recovered calls folded in, and
699
+ arguments repaired, which the turn `onTurn` is handed does not show — together with that `Turn`,
700
+ and then each tool result, in transcript order. `onTurn` is not awaited and never sees a tool
701
+ result; this is the one to store from. What it returns is ignored, since `beforeStep` is the one way to rewrite
702
+ the transcript, and what it throws ends the run.
703
+
704
+ Everything the loop throws carries the run. A spent `maxToolIterations` is a `ToolIterationLimit`,
705
+ with the message it always had. A request too big for the window is an `AgentLoopOverflow`, which
706
+ is a `ContextOverflow`, so a `catch` written for one still catches it. Anything else — a stop, a
707
+ refused request, a dropped stream — is an `AgentLoopError` whose `cause` is what was caught and
708
+ whose message is the cause's own. It is a wrapper and not fields on the error that was caught
709
+ because a stop throws the signal's reason, one object for every run under that signal. A stop is
710
+ still recognised by the signal the host aborted.
711
+
712
+ A failure while a step's tools are running leaves a transcript that can be sent again. The results
713
+ that came back are kept, a call the run was stopped during is answered `Stopped before this call
714
+ finished.`, and one the loop never reached `Not run: the run stopped first.` — a call with no
715
+ result is a transcript an endpoint refuses. Those are announced through `onMessage` like any other
716
+ result. The reply of a request that was in flight when the run stopped is not kept: the host has
717
+ the deltas from its `output` and `thinking` events, and whether half an answer belongs in the
718
+ history is its call.
719
+
676
720
  `resolveApiKey` is exported and not applied, because which key an endpoint gets is a rule a
677
721
  consumer states and a library guessing it could send one where it was not meant to go. The rule
678
722
  it encodes is the conservative one: an endpoint's own key wins; one that names a base URL of its
@@ -354,6 +354,19 @@ export interface AgentLoopOptions {
354
354
  onRequest?: (request: AgentLoopRequest) => void;
355
355
  /** Each turn as it comes back, before its tools run. Recovered calls are in it as calls. */
356
356
  onTurn?: (turn: Turn, step: number) => void;
357
+ /**
358
+ * Each message as the loop appends it to the transcript, in transcript order, and awaited before
359
+ * the loop goes on — so a host that stores its transcript has written the assistant message
360
+ * before its tools run, and every tool result before the next request is sent.
361
+ *
362
+ * The assistant message is the one replayed on later requests — arguments repaired, recovered
363
+ * calls folded in — and comes with its `turn`, as `onTurn` was handed it, for the `reasoning` a
364
+ * host keeps beside the message. A tool result comes without one, and so do the two messages
365
+ * a proxied run writes for its `preselected` shortlist, told as step zero's before any request.
366
+ * What it returns is ignored: `beforeStep` is the one way to rewrite the transcript. What it
367
+ * throws ends the run, and it is not called again for that run.
368
+ */
369
+ onMessage?: (message: OpenAI.ChatCompletionMessageParam, step: number, turn?: Turn) => void | Promise<void>;
357
370
  /**
358
371
  * How many times an answer cut off at `maxTokens` is continued, zero — the default — for never.
359
372
  * Opt-in because it spends another request, and on a server that does not continue a trailing
@@ -423,6 +436,15 @@ export interface AgentLoopResult {
423
436
  * until a turn asks for none. Throws when `maxToolIterations` is spent, when stopped, and on
424
437
  * whatever `runTurn` throws — `ContextOverflow` among them, however it was found out.
425
438
  *
439
+ * What it throws carries the run as it stood — `messages`, `usage`, `toolCalls`, `loaded` and
440
+ * `used` — because the steps before a failure were real and a host that stores its transcript
441
+ * has nothing else to store them from. A spent budget is a `ToolIterationLimit`, an overflow is
442
+ * an `AgentLoopOverflow`, which is still a `ContextOverflow`, and anything else is an
443
+ * `AgentLoopError` with what was caught as its `cause` and its message as its own; `failedRun`
444
+ * reads the run off any of the three. A failure while a step's tools run leaves the transcript
445
+ * well-formed: the results that came back are kept, and each call without one is answered with a
446
+ * line saying it was stopped or never run, since a call with no result is refused on replay.
447
+ *
426
448
  * On-demand loading is handled here, `load_tools` and all: the catalogue rides on the system
427
449
  * prompt unchanged from step to step, a load adds to the tool array — which every request sends
428
450
  * in the stable order `toolOrder` asks for — and
@@ -2,11 +2,11 @@ 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";
@@ -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
@@ -231,8 +244,39 @@ function cacheDiagnosis(previous, messages, tools, usage) {
231
244
  * and cancellation. See `AgentLoopOptions`.
232
245
  */
233
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) {
234
278
  const { config, system = "", tools = [], catalog = [], dispatch, hooks, signal } = options;
235
- 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;
236
280
  const dedupable = typeof dedupeToolCalls === "function" ? dedupeToolCalls : () => dedupeToolCalls;
237
281
  const started = Date.now();
238
282
  // What the loop emitted, less the token deltas, for `runMetrics` at the end. Stamped here rather
@@ -284,6 +328,30 @@ export async function runAgentLoop(options) {
284
328
  // in the order things were added, so a load appends and never reshuffles what went before.
285
329
  const byName = (names) => [...names].flatMap((name) => definitions.get(name) ?? []);
286
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
+ };
287
355
  // Held by reference rather than by index, so a `beforeStep` that folds the head into a summary
288
356
  // moves the question without losing it — and one that summarises the question away takes the
289
357
  // hooks' context with it, which is right.
@@ -297,8 +365,6 @@ export async function runAgentLoop(options) {
297
365
  : { context: "", notes: [] };
298
366
  // Read once, so what the result hands back is what every step of this run said.
299
367
  const preface = hooks?.preface ?? configureHooks().preface;
300
- const usage = { prompt: 0, completion: 0, total: 0, cached: 0 };
301
- const toolCalls = [];
302
368
  const loads = { toolsLoaded: 0, redundantLoads: 0, unknownToolNames: 0 };
303
369
  let previous;
304
370
  let ledger = options.ledger ?? [];
@@ -328,11 +394,19 @@ export async function runAgentLoop(options) {
328
394
  loads.toolsLoaded += names.length;
329
395
  for (const name of names)
330
396
  loaded.add(name);
331
- messages.push({
332
- role: "assistant",
333
- content: null,
334
- tool_calls: [{ id, type: "function", function: { name: LOAD_TOOLS, arguments: args } }],
335
- }, { role: "tool", tool_call_id: id, content });
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);
336
410
  }
337
411
  for (let step = 0; step < config.maxToolIterations; step++) {
338
412
  // A stop aborts the request in flight, but a tool call already handed off runs to its own
@@ -476,7 +550,7 @@ export async function runAgentLoop(options) {
476
550
  return { call, error, normal: "{}" };
477
551
  }
478
552
  });
479
- messages.push({
553
+ const assistant = {
480
554
  role: "assistant",
481
555
  content: content || null,
482
556
  ...(parsed.length
@@ -487,7 +561,9 @@ export async function runAgentLoop(options) {
487
561
  })),
488
562
  }
489
563
  : {}),
490
- });
564
+ };
565
+ messages.push(assistant);
566
+ await announce(assistant, step, shown);
491
567
  if (!calls.length) {
492
568
  let afterTurn = Promise.resolve([]);
493
569
  if (hooks) {
@@ -531,13 +607,15 @@ export async function runAgentLoop(options) {
531
607
  // Per step, not per run: the answer to a call made two steps ago was true before the tools in
532
608
  // between ran, and the file the model read may be the file it has since written.
533
609
  const answered = new Map();
534
- const run = async ({ call, args, error: unreadable, normal }) => {
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;
535
617
  const proxy = proxies && call.function.name === CALL_TOOL;
536
- // A `call_tool` is shown, counted and dispatched as the tool it names. Read from the repaired
537
- // arguments where there are any, since the model's own may be almost-JSON.
538
- const { name, input: raw } = proxy
539
- ? shownCall(CALL_TOOL, args ? normal : call.function.arguments)
540
- : { name: call.function.name, input: call.function.arguments };
618
+ const { name, input: raw } = named(entry);
541
619
  // Looked through before the call is announced, so a host is told of the tool it names with
542
620
  // that tool's arguments. A name outside the catalogue is refused here, so `call_tool` reaches
543
621
  // nothing a load could not, and the refusal is the call's answer below.
@@ -608,23 +686,65 @@ export async function runAgentLoop(options) {
608
686
  onToolResult?.(result);
609
687
  return result;
610
688
  };
611
- const outcomes = [];
612
- if (parallel) {
613
- signal?.throwIfAborted();
614
- outcomes.push(...(await Promise.all(parsed.map(run))));
615
- }
616
- else {
617
- 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) {
618
707
  signal?.throwIfAborted();
619
- 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
+ }
620
725
  }
621
726
  }
622
- for (const { id, name, ok, content } of outcomes) {
623
- toolCalls.push({ id, name, ok });
624
- 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;
625
745
  }
626
746
  }
627
- throw new Error(`Stopped after ${config.maxToolIterations} tool iterations.`);
747
+ return undefined;
628
748
  }
629
749
  /**
630
750
  * Makes a call at most once per key, sharing the in-flight promise so two identical calls in one
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;
package/dist/errors.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { ContextOverflow } from "./retry.js";
1
2
  /**
2
3
  * What went wrong, as a string.
3
4
  *
@@ -8,3 +9,79 @@
8
9
  * @param error Whatever a `catch` bound. Anything that is not an `Error` is stringified.
9
10
  */
10
11
  export const errorMessage = (error) => error instanceof Error ? error.message : String(error);
12
+ /**
13
+ * What `runAgentLoop` throws, carrying the run as it stood and what was caught as `cause`.
14
+ *
15
+ * Its own class rather than fields hung on the error that was caught, because a stop throws the
16
+ * signal's reason and that is one object for every run under the signal. The message is the
17
+ * cause's own, so a host that logs `errorMessage(error)` logs what it always did. A stop is told
18
+ * the way it was before — by the signal the host aborted, not by the class of what came out.
19
+ */
20
+ export class AgentLoopError extends Error {
21
+ name = "AgentLoopError";
22
+ messages;
23
+ usage;
24
+ toolCalls;
25
+ loaded;
26
+ used;
27
+ /**
28
+ * @param message What went wrong — the cause's own message, where there is a cause.
29
+ * @param run The run as it stood. Held, not copied.
30
+ * @param options The standard `cause`: what the loop caught, absent where the loop itself gave up.
31
+ */
32
+ constructor(message, run, options) {
33
+ super(message, options);
34
+ this.messages = run.messages;
35
+ this.usage = run.usage;
36
+ this.toolCalls = run.toolCalls;
37
+ this.loaded = run.loaded;
38
+ this.used = run.used;
39
+ }
40
+ }
41
+ /**
42
+ * `maxToolIterations` was spent with the model still asking for tools.
43
+ *
44
+ * Named so a host can tell it from a failure without matching the message, which is the same
45
+ * sentence it has always been. There is no `cause`: nothing was caught, the loop stopped itself.
46
+ */
47
+ export class ToolIterationLimit extends AgentLoopError {
48
+ name = "ToolIterationLimit";
49
+ }
50
+ /**
51
+ * A `ContextOverflow` out of `runAgentLoop`, carrying the run as it stood.
52
+ *
53
+ * A subclass of `ContextOverflow` rather than an `AgentLoopError` around one, because
54
+ * `instanceof ContextOverflow` is how a caller knows to compact and send again, and wrapping
55
+ * would have made that test quietly false. The overflow the loop caught is the `cause`, and the
56
+ * endpoint's own error, where there was one, is that one's `cause` in turn.
57
+ */
58
+ export class AgentLoopOverflow extends ContextOverflow {
59
+ messages;
60
+ usage;
61
+ toolCalls;
62
+ loaded;
63
+ used;
64
+ /**
65
+ * @param message The overflow's own message.
66
+ * @param run The run as it stood. Held, not copied.
67
+ * @param options The standard `cause`: the `ContextOverflow` the loop caught.
68
+ */
69
+ constructor(message, run, options) {
70
+ super(message, options);
71
+ this.messages = run.messages;
72
+ this.usage = run.usage;
73
+ this.toolCalls = run.toolCalls;
74
+ this.loaded = run.loaded;
75
+ this.used = run.used;
76
+ }
77
+ }
78
+ /**
79
+ * The run a failed `runAgentLoop` left behind, read off whatever it threw, or nothing.
80
+ *
81
+ * Two classes carry it — an overflow has to stay a `ContextOverflow` and so cannot also be an
82
+ * `AgentLoopError` — and a `catch` that only wants the transcript should not have to name both.
83
+ *
84
+ * @param error Whatever a `catch` around `runAgentLoop` bound. Anything the loop did not throw
85
+ * answers `undefined`.
86
+ */
87
+ export const failedRun = (error) => error instanceof AgentLoopError || error instanceof AgentLoopOverflow ? error : undefined;
package/dist/index.d.ts CHANGED
@@ -17,7 +17,7 @@ export { type ClientPoolOptions, configureClients, contextLimitFor, endpointId,
17
17
  export { applyCompaction, COMPACT_AT, type CompactionOptions, type CompactionPlan, type CompactionRecord, type CompactionRunOptions, compactTranscript, KEEP_RATIO, type PruneOptions, planCompaction, pruneToolResults, requestIndex, runCompaction, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.ts";
18
18
  export type { AgentConfig, Endpoint, ModelParams, RetryPolicy, ToolPolicy, } from "./config.ts";
19
19
  export { type ContinueTurnOptions, continueTurn, isContinuable, } from "./continuation.ts";
20
- export { errorMessage } from "./errors.ts";
20
+ export { AgentLoopError, type AgentLoopFailure, AgentLoopOverflow, errorMessage, failedRun, ToolIterationLimit, } from "./errors.ts";
21
21
  export { configureEvents, type EventBusOptions, emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunMetrics, type RunMetricsOptions, type RunUsage, resetEvents, runMetrics, type TurnReport, watch, } from "./events.ts";
22
22
  export { assembleContext, configureHooks, consult, type Gathered, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, type HookContext, type HookEvent, type HookMessage, type HookNote, type HookOptions, type HookOutcome, type HookRunner, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from "./hooks.ts";
23
23
  export { estimateFrom, type LedgerEntry, type LedgerEstimateOptions, type LedgerRequest, rebaseLedger, recordRequest, type TokenLedger, tokensBetween, } from "./ledger.ts";
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ export { capabilitiesFor, EFFORT_LADDER, effortFor, expireCapabilities, modelCap
15
15
  export { configureClients, contextLimitFor, endpointId, endpointKey, FIRST_TOKEN_FACTOR, firstTokenMs, getClient, listModels, NO_KEY, resetClients, servedWindow, timeoutMs, } from "./client.js";
16
16
  export { applyCompaction, COMPACT_AT, compactTranscript, KEEP_RATIO, planCompaction, pruneToolResults, requestIndex, runCompaction, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.js";
17
17
  export { continueTurn, isContinuable, } from "./continuation.js";
18
- export { errorMessage } from "./errors.js";
18
+ export { AgentLoopError, AgentLoopOverflow, errorMessage, failedRun, ToolIterationLimit, } from "./errors.js";
19
19
  export { configureEvents, emit, endRun, fold, history, resetEvents, runMetrics, watch, } from "./events.js";
20
20
  export { assembleContext, configureHooks, consult, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from "./hooks.js";
21
21
  export { estimateFrom, rebaseLedger, recordRequest, tokensBetween, } from "./ledger.js";
package/llms.txt CHANGED
@@ -115,7 +115,12 @@ Picking up an answer the token ceiling cut off, instead of keeping half of it.
115
115
 
116
116
  ### errors
117
117
 
118
+ - `AgentLoopError` — What `runAgentLoop` throws, carrying the run as it stood and what was caught as `cause`.
119
+ - `AgentLoopFailure` (type) — A run as it stood when `runAgentLoop` gave up on it.
120
+ - `AgentLoopOverflow` — A `ContextOverflow` out of `runAgentLoop`, carrying the run as it stood.
118
121
  - `errorMessage` — What went wrong, as a string.
122
+ - `failedRun` — The run a failed `runAgentLoop` left behind, read off whatever it threw, or nothing.
123
+ - `ToolIterationLimit` — `maxToolIterations` was spent with the model still asking for tools.
119
124
 
120
125
  ### events
121
126
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "2.20.0",
3
+ "version": "2.21.0",
4
4
  "description": "The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.",
5
5
  "keywords": [
6
6
  "openai",