@cubicecho/agent-core 2.20.0 → 2.21.1

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
@@ -279,15 +279,24 @@ const turn = await runTurn(client, supports, build, {
279
279
  });
280
280
  ```
281
281
 
282
- `contextLimitFor` answers the operator's number when there is one. Otherwise it asks for the window
283
- the server is actually serving the model in (`servedWindow`): llama.cpp's `/props`
284
- (`default_generation_settings.n_ctx`) and LM Studio's `/api/v0/models` (`loaded_context_length`).
285
- That differs from the trained window in the case the guard exists for, a 256k model started at `-c
286
- 16384`. A server with neither route is latched and not asked again. Failing both, it reads the
287
- `/v1/models` listing: `max_model_len` from vLLM, `context_length` from OpenRouter, and llama.cpp's
288
- `meta.n_ctx_train`, which is only the trained window. Ollama reports no window on any route this
289
- reads, and truncates an over-long prompt rather than refusing it, so on Ollama pass `contextLength`
290
- or there is no guard at all.
282
+ `contextLimitFor` answers the operator's number when there is one. Otherwise it reads the
283
+ `/v1/models` listing first. A window at the top of the model's entry (`context_length`,
284
+ `max_context_window`, `max_model_len`, `context_window` or `n_ctx`: vLLM's `max_model_len`,
285
+ OpenRouter's `context_length`) is taken as the one being served, and nothing else is asked.
286
+
287
+ Where the listing does not settle it, the server's own API is asked (`servedWindow`): llama.cpp's
288
+ `/props` (`default_generation_settings.n_ctx`), then LM Studio's `/api/v0/models`
289
+ (`loaded_context_length`). That covers a model listed with no window, which is LM Studio, and one
290
+ listed with only `meta.n_ctx_train`, which is llama.cpp. The trained window differs from the served
291
+ one in the case the guard exists for, a 256k model started at `-c 16384`, so it is used only when
292
+ neither native route gives a number. A server with neither route is latched and not asked again.
293
+ `/api/v0/models` is also dropped on its own: for the life of the process once it answers 404, 405
294
+ or 501, and for `listingMissMs` after any other error.
295
+
296
+ A top-level listing key is trusted without a second opinion, so on a server that puts a trained
297
+ window there, pass `contextLength`. Ollama reports no window on any route this reads, and truncates
298
+ an over-long prompt rather than refusing it, so on Ollama pass `contextLength` or there is no guard
299
+ at all.
291
300
 
292
301
  What is weighed is the prompt plus the reply ceiling the body carries, under whichever spelling
293
302
  was chosen, because that is what the endpoint weighs: a 30k prompt into a 32k window with
@@ -673,6 +682,50 @@ with pre-turn work to overlap with the hooks — compaction, a preselection —
673
682
  applies `withContext` to the current question as well, calls `notify` after, and passes no `hooks`
674
683
  (see [Hooks](#hooks)).
675
684
 
685
+ A host that stores its transcript — a row per message, so a crash or a stop mid-run still leaves
686
+ readable history — gets each message as the loop appends it, and the run as it stood when the loop
687
+ throws:
688
+
689
+ ```ts
690
+ try {
691
+ await runAgentLoop({
692
+ ...options,
693
+ // Awaited: the row is written before the tools run, and before the next request is sent.
694
+ onMessage: (message, step, turn) =>
695
+ store.add(sessionId, turn?.reasoning ? { ...message, reasoning_content: turn.reasoning } : message),
696
+ });
697
+ } catch (error) {
698
+ const run = failedRun(error); // messages, usage, toolCalls, loaded, used — or undefined
699
+ if (run) await store.keep(sessionId, { usage: run.usage, loaded: run.loaded });
700
+ if (signal.aborted) return; // a stop, told by the signal as it always was
701
+ if (error instanceof ContextOverflow) return compactAndRetry(run?.messages);
702
+ if (error instanceof ToolIterationLimit) return outOfSteps();
703
+ throw error; // an AgentLoopError: the message is the cause's, and the cause is on `cause`
704
+ }
705
+ ```
706
+
707
+ `onMessage` hears the assistant message as it will be replayed — recovered calls folded in, and
708
+ arguments repaired, which the turn `onTurn` is handed does not show — together with that `Turn`,
709
+ and then each tool result, in transcript order. `onTurn` is not awaited and never sees a tool
710
+ result; this is the one to store from. What it returns is ignored, since `beforeStep` is the one way to rewrite
711
+ the transcript, and what it throws ends the run.
712
+
713
+ Everything the loop throws carries the run. A spent `maxToolIterations` is a `ToolIterationLimit`,
714
+ with the message it always had. A request too big for the window is an `AgentLoopOverflow`, which
715
+ is a `ContextOverflow`, so a `catch` written for one still catches it. Anything else — a stop, a
716
+ refused request, a dropped stream — is an `AgentLoopError` whose `cause` is what was caught and
717
+ whose message is the cause's own. It is a wrapper and not fields on the error that was caught
718
+ because a stop throws the signal's reason, one object for every run under that signal. A stop is
719
+ still recognised by the signal the host aborted.
720
+
721
+ A failure while a step's tools are running leaves a transcript that can be sent again. The results
722
+ that came back are kept, a call the run was stopped during is answered `Stopped before this call
723
+ finished.`, and one the loop never reached `Not run: the run stopped first.` — a call with no
724
+ result is a transcript an endpoint refuses. Those are announced through `onMessage` like any other
725
+ result. The reply of a request that was in flight when the run stopped is not kept: the host has
726
+ the deltas from its `output` and `thinking` events, and whether half an answer belongs in the
727
+ history is its call.
728
+
676
729
  `resolveApiKey` is exported and not applied, because which key an endpoint gets is a rule a
677
730
  consumer states and a library guessing it could send one where it was not meant to go. The rule
678
731
  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/client.d.ts CHANGED
@@ -18,7 +18,8 @@ export interface ClientPoolOptions {
18
18
  maxClients?: number;
19
19
  /**
20
20
  * How long, in milliseconds, a model an endpoint did not list — or a server that answered
21
- * without a served window — is taken at its word before the endpoint is asked again.
21
+ * without a served window, or whose `/api/v0/models` answered with an error other than a
22
+ * missing route — is taken at its word before the endpoint is asked again.
22
23
  */
23
24
  listingMissMs?: number;
24
25
  }
@@ -66,7 +67,12 @@ export declare const firstTokenMs: (config: Pick<Endpoint, "requestTimeoutSecond
66
67
  * @param config Where to send requests and how long to wait. An absent `apiKey` becomes `NO_KEY`.
67
68
  */
68
69
  export declare function getClient(config: Endpoint): OpenAI;
69
- /** A model an endpoint offers, and what it says the model will read. Zero means it did not say. */
70
+ /**
71
+ * A model an endpoint offers, and what it says the model will read. Zero means it did not say.
72
+ *
73
+ * The number is whatever the listing carried, which from llama.cpp is the trained window rather
74
+ * than the served one; `contextLimitFor` is the lookup that knows the difference.
75
+ */
70
76
  export interface ModelInfo {
71
77
  id: string;
72
78
  contextLength: number;
@@ -109,7 +115,8 @@ export declare const endpointId: (config: {
109
115
  apiKey?: string;
110
116
  }) => string;
111
117
  /**
112
- * The window a local server is actually serving a model in, which its listing does not say.
118
+ * The window a local server's own API says it is serving a model in, for the servers whose
119
+ * listing does not say.
113
120
  *
114
121
  * llama.cpp reports it on `/props` as `default_generation_settings.n_ctx`, per slot, and LM
115
122
  * Studio on `/api/v0/models` as `loaded_context_length` while the model is loaded. Both differ
@@ -117,8 +124,12 @@ export declare const endpointId: (config: {
117
124
  * built for, and reading the trained one lets an overflow through the guard meant to catch it.
118
125
  * Ollama reports nothing on either; an operator there has to declare the window.
119
126
  *
120
- * Zero where no answer was found. A server without either route, which is every hosted API, is
121
- * latched and not asked again; one that could not be reached is not remembered at all.
127
+ * Zero where no answer was found, which is remembered for `listingMissMs`. A server without
128
+ * either route is latched and not asked again. `/api/v0/models` is also dropped on its own: until
129
+ * `resetClients` once it answers as a route that is not there, and for `listingMissMs` after any
130
+ * other error. A server that could not be reached is not remembered at all.
131
+ *
132
+ * The listing is not read here; `contextLimitFor` reads it first and comes here after.
122
133
  *
123
134
  * @param config The endpoint, plus the model whose window is wanted.
124
135
  */
@@ -139,11 +150,18 @@ export declare function listModels(config: Endpoint): Promise<ModelInfo[]>;
139
150
  * happily load a 256k model at `-c 16384` and go on listing it as 256k — and a run refused on
140
151
  * the honest-looking number is a run that fails at the endpoint instead.
141
152
  *
142
- * Otherwise the window the server is actually serving the model in, where it has an API that
143
- * says (`servedWindow`), and failing that the endpoint's listing — asked once, and again whenever
144
- * it does not name this model, since a model can arrive after the first listing was taken. A
145
- * server that will not list models still has to be able to run a turn: a failure here is an
146
- * unknown window, not a failed run.
153
+ * Otherwise the endpoint's listing — asked once, and again whenever it does not name this model,
154
+ * since a model can arrive after the first listing was taken. A window stated at the top of the
155
+ * model's entry is taken as the one being served, and nothing else is asked. Where the listing
156
+ * did not settle it — no window, only llama.cpp's trained one, no entry, no listing — the
157
+ * server's own API is asked (`servedWindow`), and what it says wins; the trained window is what
158
+ * is left when it says nothing. A server that will not list models still has to be able to run a
159
+ * turn: a failure here is an unknown window, not a failed run.
160
+ *
161
+ * The listing goes first because it is the one route every server here has, and the native ones
162
+ * are two more requests that a server stating its window in the listing never needed. Asked
163
+ * first, they were also asked again every `listingMissMs` for the life of the process on a server
164
+ * whose `/props` answers without a window, by any host that sizes the window per turn.
147
165
  *
148
166
  * @param config The endpoint, plus the model whose window is wanted.
149
167
  * @param declared The operator's own number. Above zero it wins and the endpoint is not asked.
package/dist/client.js CHANGED
@@ -162,18 +162,26 @@ const CONTEXT_KEYS = [
162
162
  "n_ctx",
163
163
  ];
164
164
  const positive = (value) => (typeof value === "number" && value > 0 ? value : 0);
165
+ /**
166
+ * A listing entry's window, and whether it is only the one the model was trained with.
167
+ *
168
+ * The two are told apart because they are trusted differently: a top-level key is taken as the
169
+ * window being served and settles the lookup, and a trained one is a ceiling the server may be
170
+ * running well under, so `contextLimitFor` still asks the native routes before settling for it.
171
+ */
165
172
  function contextLengthOf(model) {
166
173
  const record = model;
167
174
  for (const key of CONTEXT_KEYS) {
168
175
  const value = positive(record[key]);
169
176
  if (value)
170
- return value;
177
+ return { contextLength: value, trained: false };
171
178
  }
172
179
  // llama.cpp's, and the window the model was trained with rather than the one it is served in:
173
180
  // a 256k model started at `-c 16384` lists 262144 here. Better than nothing, and why the
174
- // served window is asked for first. `meta` is `null` while the model loads.
181
+ // served window is still asked for after it. `meta` is `null` while the model loads.
175
182
  const meta = record.meta;
176
- return positive(meta?.n_ctx_train);
183
+ const contextLength = positive(meta?.n_ctx_train);
184
+ return { contextLength, trained: contextLength > 0 };
177
185
  }
178
186
  /**
179
187
  * The last listing from each endpoint, so a run can size its window without a round trip.
@@ -190,6 +198,13 @@ function contextLengthOf(model) {
190
198
  * whatever was there rather than emptying it.
191
199
  */
192
200
  const listings = new Map();
201
+ /**
202
+ * The models in each endpoint's last listing whose window is only the trained one, by id.
203
+ *
204
+ * Kept beside `listings` rather than on `ModelInfo`, so what `listModels` hands back is the shape
205
+ * it has always been. Replaced with each listing that lands, and left alone by one that fails.
206
+ */
207
+ const trainedOnly = new Map();
193
208
  /**
194
209
  * When an endpoint was last asked about a model it did not name, keyed on the two together.
195
210
  *
@@ -243,8 +258,25 @@ export const endpointId = (config) => createHash("sha256").update(endpointKey(co
243
258
  * is asked again after `listingMissMs`, like a listing that did not name the model.
244
259
  */
245
260
  const served = new Map();
246
- /** Endpoints that answered both probes with a refusal, and are not asked again. */
261
+ /** Endpoints with neither native route, which are not asked for either again. */
247
262
  const unserved = new Set();
263
+ /**
264
+ * Endpoints whose `/api/v0/models` answered with an error, and when.
265
+ *
266
+ * `gone` is a route the server does not have (`NOT_THERE`), which is a fact about the server and
267
+ * stands until `resetClients`. Any other error stands for `listingMissMs` only, because it may be
268
+ * one the server gets over, and LM Studio latched for good on a passing 5xx would have no window
269
+ * at all until the process restarted, its listing carrying none.
270
+ *
271
+ * Per endpoint and on its own, whatever `/props` said: a server whose `/props` answers without a
272
+ * window is never `unserved`, and without this its missing route was asked again with every miss.
273
+ */
274
+ const refusals = new Map();
275
+ /** Whether `/api/v0/models` is to be left alone on this endpoint for now. */
276
+ const refused = (endpoint) => {
277
+ const refusal = refusals.get(endpoint);
278
+ return (refusal !== undefined && (refusal.gone || Date.now() - refusal.at < poolLimits.listingMissMs));
279
+ };
248
280
  /** How long a probe may take before the window is taken from the listing instead. */
249
281
  const PROBE_TIMEOUT_MS = 10_000;
250
282
  /** The server root behind an OpenAI-compatible base URL, which is where the native APIs live. */
@@ -252,8 +284,9 @@ const rootOf = (baseUrl) => baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
252
284
  /** A route this server does not have, rather than one that failed to answer. */
253
285
  const NOT_THERE = new Set([404, 405, 501]);
254
286
  /**
255
- * Asks one native endpoint. `missing` is a server without the route; `body` is absent for that
256
- * and for any other refusal, and a server that could not be reached throws.
287
+ * Asks one native endpoint. `failed` is any answer that was not a 2xx and `missing` the ones among
288
+ * them that mean a server without the route; `body` is absent for both, and for a 2xx that was not
289
+ * JSON. A server that could not be reached throws.
257
290
  */
258
291
  async function probe(config, path) {
259
292
  const apiKey = config.apiKey || undefined;
@@ -262,16 +295,17 @@ async function probe(config, path) {
262
295
  signal: AbortSignal.timeout(timeoutMs(config) ?? PROBE_TIMEOUT_MS),
263
296
  });
264
297
  if (!response.ok)
265
- return { missing: NOT_THERE.has(response.status) };
298
+ return { failed: true, missing: NOT_THERE.has(response.status) };
266
299
  try {
267
- return { missing: false, body: await response.json() };
300
+ return { failed: false, missing: false, body: await response.json() };
268
301
  }
269
302
  catch {
270
- return { missing: false };
303
+ return { failed: false, missing: false };
271
304
  }
272
305
  }
273
306
  /**
274
- * The window a local server is actually serving a model in, which its listing does not say.
307
+ * The window a local server's own API says it is serving a model in, for the servers whose
308
+ * listing does not say.
275
309
  *
276
310
  * llama.cpp reports it on `/props` as `default_generation_settings.n_ctx`, per slot, and LM
277
311
  * Studio on `/api/v0/models` as `loaded_context_length` while the model is loaded. Both differ
@@ -279,8 +313,12 @@ async function probe(config, path) {
279
313
  * built for, and reading the trained one lets an overflow through the guard meant to catch it.
280
314
  * Ollama reports nothing on either; an operator there has to declare the window.
281
315
  *
282
- * Zero where no answer was found. A server without either route, which is every hosted API, is
283
- * latched and not asked again; one that could not be reached is not remembered at all.
316
+ * Zero where no answer was found, which is remembered for `listingMissMs`. A server without
317
+ * either route is latched and not asked again. `/api/v0/models` is also dropped on its own: until
318
+ * `resetClients` once it answers as a route that is not there, and for `listingMissMs` after any
319
+ * other error. A server that could not be reached is not remembered at all.
320
+ *
321
+ * The listing is not read here; `contextLimitFor` reads it first and comes here after.
284
322
  *
285
323
  * @param config The endpoint, plus the model whose window is wanted.
286
324
  */
@@ -300,13 +338,19 @@ export async function servedWindow(config) {
300
338
  ?.default_generation_settings;
301
339
  window = positive(settings?.n_ctx);
302
340
  if (!window) {
303
- const lmstudio = await probe(config, "/api/v0/models");
304
- const { data } = (lmstudio.body ?? {});
305
- const entry = Array.isArray(data)
306
- ? data.find((model) => model?.id === config.model)
307
- : undefined;
308
- window = positive(entry?.loaded_context_length);
309
- if (props.missing && lmstudio.missing) {
341
+ if (!refused(endpoint)) {
342
+ const lmstudio = await probe(config, "/api/v0/models");
343
+ if (lmstudio.failed)
344
+ refusals.set(endpoint, { at: Date.now(), gone: lmstudio.missing });
345
+ else
346
+ refusals.delete(endpoint);
347
+ const { data } = (lmstudio.body ?? {});
348
+ const entry = Array.isArray(data)
349
+ ? data.find((model) => model?.id === config.model)
350
+ : undefined;
351
+ window = positive(entry?.loaded_context_length);
352
+ }
353
+ if (props.missing && refusals.get(endpoint)?.gone) {
310
354
  unserved.add(endpoint);
311
355
  return 0;
312
356
  }
@@ -325,10 +369,18 @@ export async function servedWindow(config) {
325
369
  */
326
370
  export async function listModels(config) {
327
371
  const { data } = await getClient(config).models.list();
372
+ const trained = new Set();
328
373
  const models = data
329
- .map((model) => ({ id: model.id, contextLength: contextLengthOf(model) }))
374
+ .map((model) => {
375
+ const window = contextLengthOf(model);
376
+ if (window.trained)
377
+ trained.add(model.id);
378
+ return { id: model.id, contextLength: window.contextLength };
379
+ })
330
380
  .sort((a, b) => a.id.localeCompare(b.id));
331
- listings.set(endpointKey(config), models);
381
+ const key = endpointKey(config);
382
+ listings.set(key, models);
383
+ trainedOnly.set(key, trained);
332
384
  return models;
333
385
  }
334
386
  /**
@@ -339,11 +391,18 @@ export async function listModels(config) {
339
391
  * happily load a 256k model at `-c 16384` and go on listing it as 256k — and a run refused on
340
392
  * the honest-looking number is a run that fails at the endpoint instead.
341
393
  *
342
- * Otherwise the window the server is actually serving the model in, where it has an API that
343
- * says (`servedWindow`), and failing that the endpoint's listing — asked once, and again whenever
344
- * it does not name this model, since a model can arrive after the first listing was taken. A
345
- * server that will not list models still has to be able to run a turn: a failure here is an
346
- * unknown window, not a failed run.
394
+ * Otherwise the endpoint's listing — asked once, and again whenever it does not name this model,
395
+ * since a model can arrive after the first listing was taken. A window stated at the top of the
396
+ * model's entry is taken as the one being served, and nothing else is asked. Where the listing
397
+ * did not settle it — no window, only llama.cpp's trained one, no entry, no listing — the
398
+ * server's own API is asked (`servedWindow`), and what it says wins; the trained window is what
399
+ * is left when it says nothing. A server that will not list models still has to be able to run a
400
+ * turn: a failure here is an unknown window, not a failed run.
401
+ *
402
+ * The listing goes first because it is the one route every server here has, and the native ones
403
+ * are two more requests that a server stating its window in the listing never needed. Asked
404
+ * first, they were also asked again every `listingMissMs` for the life of the process on a server
405
+ * whose `/props` answers without a window, by any host that sizes the window per turn.
347
406
  *
348
407
  * @param config The endpoint, plus the model whose window is wanted.
349
408
  * @param declared The operator's own number. Above zero it wins and the endpoint is not asked.
@@ -351,9 +410,6 @@ export async function listModels(config) {
351
410
  export async function contextLimitFor(config, declared = 0) {
352
411
  if (declared > 0)
353
412
  return declared;
354
- const window = await servedWindow(config);
355
- if (window > 0)
356
- return window;
357
413
  const key = endpointKey(config);
358
414
  const listed = () => listings.get(key)?.find((model) => model.id === config.model);
359
415
  // The listing is asked for again when it does not name this model, rather than only when
@@ -369,26 +425,30 @@ export async function contextLimitFor(config, declared = 0) {
369
425
  // Asked again, but not on every call. A model that is never coming answers the same zero
370
426
  // however often the endpoint is asked, and a caller sizing a window per turn pays a round
371
427
  // trip for each of them; `listingMissMs` is how long that answer is allowed to stand.
372
- if (asked !== undefined && Date.now() - asked < poolLimits.listingMissMs)
373
- return 0;
374
- // A failure is not remembered: an endpoint that was down when the last run started is not
375
- // an endpoint with no models, and a window nobody could ask about is not a failed run.
376
- try {
377
- await listModels(config);
378
- }
379
- catch {
380
- return 0;
428
+ if (asked === undefined || Date.now() - asked >= poolLimits.listingMissMs) {
429
+ // A failure is not remembered: an endpoint that was down when the last run started is not
430
+ // an endpoint with no models, and a window nobody could ask about is not a failed run.
431
+ const answered = await listModels(config).then(() => true, () => false);
432
+ // Recorded where the endpoint was actually asked, and only there. Stamping it on the calls
433
+ // that skipped the request would push the deadline out ahead of any caller polling faster
434
+ // than the interval, which is a memory that never expires rather than one that expires in
435
+ // half a minute.
436
+ if (answered) {
437
+ if (listed())
438
+ misses.delete(missKey);
439
+ else
440
+ misses.set(missKey, Date.now());
441
+ }
381
442
  }
382
- // Recorded where the endpoint was actually asked, and only there. Stamping it on the calls
383
- // that skipped the request would push the deadline out ahead of any caller polling faster
384
- // than the interval, which is a memory that never expires rather than one that expires in
385
- // half a minute.
386
- if (listed())
387
- misses.delete(missKey);
388
- else
389
- misses.set(missKey, Date.now());
390
443
  }
391
- return listed()?.contextLength ?? 0;
444
+ const entry = listed();
445
+ const fromListing = entry?.contextLength ?? 0;
446
+ // A top-level key settles it. A trained window does not: llama.cpp lists the one the model was
447
+ // built with, and taking it over what `/props` says lets an overflow through the guard.
448
+ if (fromListing > 0 && !trainedOnly.get(key)?.has(config.model))
449
+ return fromListing;
450
+ const window = await servedWindow(config);
451
+ return window > 0 ? window : fromListing;
392
452
  }
393
453
  /**
394
454
  * Forgets every cached client and listing, and puts `configureClients` back to the defaults. For
@@ -399,6 +459,8 @@ export function resetClients() {
399
459
  clients.clear();
400
460
  listings.clear();
401
461
  misses.clear();
462
+ trainedOnly.clear();
402
463
  served.clear();
403
464
  unserved.clear();
465
+ refusals.clear();
404
466
  }
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
@@ -70,7 +70,7 @@ The contract between whatever holds the tools and the loop that offers them to a
70
70
  - `ModelInfo` (type) — A model an endpoint offers, and what it says the model will read.
71
71
  - `NO_KEY` — The SDK insists on a non-empty key even where the server will not look at it.
72
72
  - `resetClients` — Forgets every cached client and listing, and puts `configureClients` back to the defaults.
73
- - `servedWindow` — The window a local server is actually serving a model in, which its listing does not say.
73
+ - `servedWindow` — The window a local server's own API says it is serving a model in, for the servers whose listing does not say.
74
74
  - `timeoutMs` — Zero, less, or absent means no limit, which the SDK spells as `undefined`.
75
75
 
76
76
  ### compaction
@@ -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.1",
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",