@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.
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";
@@ -32,4 +32,4 @@ export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type Turn
32
32
  export { ALL_FENCES, DEFAULT_FENCES, type Fence, FenceSplitter, type FenceSplitterOptions, type Split, stripThinking, THINK_FENCE, } from "./thinking.ts";
33
33
  export { estimateTokens } from "./tokens.ts";
34
34
  export { parseToolArguments, recoverToolCalls, ToolArgumentsError, type ToolCall, } from "./tool-calls.ts";
35
- export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, KEYWORD_DROPOFF, KEYWORD_MIN_SCORE, type KeywordPreselection, type KeywordPreselectOptions, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectByKeywords, preselectInput, preselection, preselectSystem, requestedNames, type ToolMatch, type ToolOrder, } from "./tool-loading.ts";
35
+ export { CALL_TOOL, carryOver, catalogList, catalogPrompt, expandNames, holdsDefinitions, inCatalog, KEYWORD_DROPOFF, KEYWORD_MIN_SCORE, type KeywordPreselection, type KeywordPreselectOptions, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, PROXY_TOOLS, preselectByKeywords, preselectInput, preselection, preselectSystem, proxiedCall, proxyCatalogPrompt, proxyLoadResult, requestedNames, shownCall, type ToolMatch, type ToolOrder, } from "./tool-loading.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";
@@ -30,4 +30,4 @@ export { streamTurn, } from "./stream.js";
30
30
  export { ALL_FENCES, DEFAULT_FENCES, FenceSplitter, stripThinking, THINK_FENCE, } from "./thinking.js";
31
31
  export { estimateTokens } from "./tokens.js";
32
32
  export { parseToolArguments, recoverToolCalls, ToolArgumentsError, } from "./tool-calls.js";
33
- export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, KEYWORD_DROPOFF, KEYWORD_MIN_SCORE, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectByKeywords, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
33
+ export { CALL_TOOL, carryOver, catalogList, catalogPrompt, expandNames, holdsDefinitions, inCatalog, KEYWORD_DROPOFF, KEYWORD_MIN_SCORE, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, PROXY_TOOLS, preselectByKeywords, preselectInput, preselection, preselectSystem, proxiedCall, proxyCatalogPrompt, proxyLoadResult, requestedNames, shownCall, } from "./tool-loading.js";
package/dist/spec.d.ts CHANGED
@@ -105,7 +105,11 @@ export interface PromptPart {
105
105
  }
106
106
  /** How tools reach the model, and which servers this agent may reach at all. */
107
107
  export interface ToolsSpec {
108
- discovery?: "eager" | "ondemand";
108
+ /**
109
+ * How tool definitions reach the model: all of them on every request, a name-only catalogue
110
+ * the model loads from, or that catalogue behind a fixed `load_tools` and `call_tool`.
111
+ */
112
+ discovery?: "eager" | "ondemand" | "proxy";
109
113
  /** The hard stop on a tool loop. At least one. */
110
114
  maxIterations?: number;
111
115
  /**
package/dist/spec.js CHANGED
@@ -206,10 +206,12 @@ function parseTools(report, value) {
206
206
  return void report.drop("tools", "must be an object");
207
207
  let discovery;
208
208
  if (!absent(value.discovery)) {
209
- if (value.discovery === "eager" || value.discovery === "ondemand")
209
+ if (value.discovery === "eager" ||
210
+ value.discovery === "ondemand" ||
211
+ value.discovery === "proxy")
210
212
  discovery = value.discovery;
211
213
  else
212
- report.drop("tools.discovery", 'must be "eager" or "ondemand"');
214
+ report.drop("tools.discovery", 'must be "eager", "ondemand" or "proxy"');
213
215
  }
214
216
  let servers;
215
217
  if (!absent(value.servers)) {
@@ -180,6 +180,104 @@ export declare const inCatalog: (catalog: CatalogServer[], name: string) => bool
180
180
  * @param args The tool call's arguments, exactly as the model sent them.
181
181
  */
182
182
  export declare function requestedNames(args: Record<string, unknown>): string[];
183
+ /**
184
+ * The one tool a proxied run reaches every catalogued tool through.
185
+ *
186
+ * Proxied discovery is on-demand loading with a tool array that never changes. On-demand mode
187
+ * declares each tool as it is loaded, and a chat template renders the tool array in the system
188
+ * turn, ahead of the whole conversation — so appending one definition there moves every token
189
+ * after it, and on a model llama.cpp can only rewind to a saved checkpoint a single load
190
+ * re-prefills the transcript from the first token. Proxied, the array is `load_tools` and
191
+ * `call_tool` for the life of the session: a load answers with the definitions as its result, at
192
+ * the end of the history where the cache already stops, and the model runs a loaded tool through
193
+ * this one. The price is a level of indirection the model has to get right, which a small model
194
+ * does less reliably than a native call.
195
+ */
196
+ export declare const CALL_TOOL = "call_tool";
197
+ /**
198
+ * The whole tool array of a proxied run, `load_tools` then `call_tool`, frozen because it is
199
+ * shared.
200
+ *
201
+ * The load keeps `LOAD_TOOLS` as its name, so `requestedNames` and `expandNames` read its
202
+ * arguments as they do on demand; only what it promises differs, which is why it is not
203
+ * `LOAD_TOOLS_DEFINITION`. `call_tool`'s `arguments` says `additionalProperties: true` and has to:
204
+ * `sanitizeTools` gives an object with no properties an empty property list, a grammar-constrained
205
+ * server compiles that to `{}`, and without the keyword the model is left no way to pass an
206
+ * argument at all.
207
+ */
208
+ export declare const PROXY_TOOLS: readonly OpenAI.ChatCompletionTool[];
209
+ /**
210
+ * The catalogue block for a proxied run's system prompt, worded for `call_tool` rather than for a
211
+ * tool list.
212
+ *
213
+ * `catalogPrompt` tells the model a loaded tool is in its tool list, which proxied is never true —
214
+ * a model told so looks for it there and calls it natively. The listing itself is the same, and
215
+ * like that one it is never marked, so the head of the prompt is one text for the whole session.
216
+ *
217
+ * @param catalog The connected servers. A catalogue with no tools in it produces an empty string.
218
+ */
219
+ export declare function proxyCatalogPrompt(catalog: CatalogServer[]): string;
220
+ /**
221
+ * Whether a tool result is a proxied load carrying definitions, which is the only copy of them
222
+ * the model has.
223
+ *
224
+ * On demand the definitions are in the tool array and a load's result only repeats their
225
+ * descriptions, so clearing it loses nothing. Proxied, the result is the schema, and
226
+ * `pruneToolResults` asks this before it stubs one. Told by how `proxyLoadResult` opens, the way
227
+ * a summary is told by `SUMMARY_LEAD`, because a `tool` message carries no tool name of its own.
228
+ *
229
+ * @param result The tool message's text. A load that only pointed back or refused is not one.
230
+ */
231
+ export declare const holdsDefinitions: (result: string) => boolean;
232
+ /**
233
+ * What a proxied `load_tools` answers: each new tool's whole definition as a line of JSON, since
234
+ * the result is the only place the model will ever see it.
235
+ *
236
+ * A name loaded before this call is answered with a pointer back rather than its definition a
237
+ * second time, and one the catalogue lists but `definitions` does not hold is said to have none,
238
+ * so the model is not left waiting on a schema that is not coming. The refusals — too broad, over
239
+ * the per-call cap, not in the catalogue, nothing asked for — are `loadResult`'s, worded the same
240
+ * in both modes.
241
+ *
242
+ * @param resolved What the call asked for, from `expandNames`.
243
+ * @param catalog The servers, read for the refusals' wording.
244
+ * @param definitions The definitions of `resolved.matched`, as many as the host has. Ones that
245
+ * are not functions are skipped.
246
+ * @param loaded What was loaded before this call. Absent reports every match as newly loaded.
247
+ */
248
+ export declare function proxyLoadResult(resolved: ReturnType<typeof expandNames>, catalog: CatalogServer[], definitions: readonly OpenAI.ChatCompletionTool[], loaded?: ReadonlySet<string>): string;
249
+ /**
250
+ * The tool a `call_tool` names and the arguments to run it with, or a throw the model can read.
251
+ *
252
+ * `arguments` arrives as an object when the model follows the schema and as a JSON string when it
253
+ * copies the shape of a native call instead; both are taken, since the intent is the same. A name
254
+ * outside the catalogue is refused here rather than handed to a dispatcher, which is what keeps
255
+ * `call_tool` from reaching anything the catalogue does not offer.
256
+ *
257
+ * @param args The `call_tool` call's own arguments, parsed. An absent `arguments` is no arguments.
258
+ * @param catalog What may be called. The name is trimmed and then matched exactly.
259
+ */
260
+ export declare function proxiedCall(args: Record<string, unknown>, catalog: CatalogServer[]): {
261
+ name: string;
262
+ input: Record<string, unknown>;
263
+ };
264
+ /**
265
+ * A tool call as a watcher should see it: a `call_tool` as the tool it ran, anything else as it
266
+ * is.
267
+ *
268
+ * The transcript keeps the `call_tool` the model wrote, since the next request has to repeat it
269
+ * word for word to stay in the cache; only the display looks through it. Anything that does not
270
+ * parse as a proxied call is left as it came.
271
+ *
272
+ * @param name The name the model called.
273
+ * @param input The call's arguments as JSON text.
274
+ * @returns The inner name and its arguments as JSON text — the string itself where the model
275
+ * passed `arguments` as one.
276
+ */
277
+ export declare function shownCall(name: string, input: string): {
278
+ name: string;
279
+ input: string;
280
+ };
183
281
  /**
184
282
  * The system prompt a preselector is given, holding it to the cap its answer will be held to.
185
283
  *
@@ -340,6 +340,221 @@ export function requestedNames(args) {
340
340
  return value.filter((item) => typeof item === "string");
341
341
  return [];
342
342
  }
343
+ /**
344
+ * The one tool a proxied run reaches every catalogued tool through.
345
+ *
346
+ * Proxied discovery is on-demand loading with a tool array that never changes. On-demand mode
347
+ * declares each tool as it is loaded, and a chat template renders the tool array in the system
348
+ * turn, ahead of the whole conversation — so appending one definition there moves every token
349
+ * after it, and on a model llama.cpp can only rewind to a saved checkpoint a single load
350
+ * re-prefills the transcript from the first token. Proxied, the array is `load_tools` and
351
+ * `call_tool` for the life of the session: a load answers with the definitions as its result, at
352
+ * the end of the history where the cache already stops, and the model runs a loaded tool through
353
+ * this one. The price is a level of indirection the model has to get right, which a small model
354
+ * does less reliably than a native call.
355
+ */
356
+ export const CALL_TOOL = "call_tool";
357
+ /**
358
+ * The whole tool array of a proxied run, `load_tools` then `call_tool`, frozen because it is
359
+ * shared.
360
+ *
361
+ * The load keeps `LOAD_TOOLS` as its name, so `requestedNames` and `expandNames` read its
362
+ * arguments as they do on demand; only what it promises differs, which is why it is not
363
+ * `LOAD_TOOLS_DEFINITION`. `call_tool`'s `arguments` says `additionalProperties: true` and has to:
364
+ * `sanitizeTools` gives an object with no properties an empty property list, a grammar-constrained
365
+ * server compiles that to `{}`, and without the keyword the model is left no way to pass an
366
+ * argument at all.
367
+ */
368
+ export const PROXY_TOOLS = deepFreeze([
369
+ {
370
+ type: "function",
371
+ function: {
372
+ name: LOAD_TOOLS,
373
+ description: "Get the full definitions of tools listed in the tool catalogue: what each does and the " +
374
+ "arguments it takes. Pass the exact names you need, or a trailing wildcard like " +
375
+ "`server__group__*` for a whole group. Then run them with `call_tool`. Load only what " +
376
+ "the task actually needs.",
377
+ parameters: {
378
+ type: "object",
379
+ properties: {
380
+ names: {
381
+ type: "array",
382
+ items: { type: "string" },
383
+ description: "Tool names from the catalogue. Wildcards may end with `*`.",
384
+ },
385
+ },
386
+ required: ["names"],
387
+ additionalProperties: false,
388
+ },
389
+ },
390
+ },
391
+ {
392
+ type: "function",
393
+ function: {
394
+ name: CALL_TOOL,
395
+ description: "Run a tool from the catalogue. Load it with `load_tools` first to learn its arguments, " +
396
+ "then pass its exact name and an arguments object matching its parameters.",
397
+ parameters: {
398
+ type: "object",
399
+ properties: {
400
+ name: { type: "string", description: "The tool's exact name from the catalogue." },
401
+ arguments: {
402
+ type: "object",
403
+ description: "The tool's arguments, as its definition describes them.",
404
+ additionalProperties: true,
405
+ },
406
+ },
407
+ required: ["name", "arguments"],
408
+ },
409
+ },
410
+ },
411
+ ]);
412
+ /**
413
+ * The catalogue block for a proxied run's system prompt, worded for `call_tool` rather than for a
414
+ * tool list.
415
+ *
416
+ * `catalogPrompt` tells the model a loaded tool is in its tool list, which proxied is never true —
417
+ * a model told so looks for it there and calls it natively. The listing itself is the same, and
418
+ * like that one it is never marked, so the head of the prompt is one text for the whole session.
419
+ *
420
+ * @param catalog The connected servers. A catalogue with no tools in it produces an empty string.
421
+ */
422
+ export function proxyCatalogPrompt(catalog) {
423
+ const list = catalogList(catalog);
424
+ if (!list)
425
+ return "";
426
+ return [
427
+ "# Tool catalogue",
428
+ "",
429
+ "These tools exist. Call `load_tools` with the names you need to get their definitions, then",
430
+ "run them with `call_tool`. Names are descriptive; load a tool to see its parameters. A tool",
431
+ "whose definition is already in this conversation does not need loading again. Do not load",
432
+ "tools the task does not need, and do not mention this mechanism in your answer.",
433
+ "",
434
+ list,
435
+ ].join("\n");
436
+ }
437
+ /** How a proxied load result that carries definitions opens, with the count left open. */
438
+ const PROXY_LOADED = /^Loaded \d+ tool\(s\)\. Run them with `call_tool`\./;
439
+ /**
440
+ * Whether a tool result is a proxied load carrying definitions, which is the only copy of them
441
+ * the model has.
442
+ *
443
+ * On demand the definitions are in the tool array and a load's result only repeats their
444
+ * descriptions, so clearing it loses nothing. Proxied, the result is the schema, and
445
+ * `pruneToolResults` asks this before it stubs one. Told by how `proxyLoadResult` opens, the way
446
+ * a summary is told by `SUMMARY_LEAD`, because a `tool` message carries no tool name of its own.
447
+ *
448
+ * @param result The tool message's text. A load that only pointed back or refused is not one.
449
+ */
450
+ export const holdsDefinitions = (result) => PROXY_LOADED.test(result);
451
+ /**
452
+ * What a proxied `load_tools` answers: each new tool's whole definition as a line of JSON, since
453
+ * the result is the only place the model will ever see it.
454
+ *
455
+ * A name loaded before this call is answered with a pointer back rather than its definition a
456
+ * second time, and one the catalogue lists but `definitions` does not hold is said to have none,
457
+ * so the model is not left waiting on a schema that is not coming. The refusals — too broad, over
458
+ * the per-call cap, not in the catalogue, nothing asked for — are `loadResult`'s, worded the same
459
+ * in both modes.
460
+ *
461
+ * @param resolved What the call asked for, from `expandNames`.
462
+ * @param catalog The servers, read for the refusals' wording.
463
+ * @param definitions The definitions of `resolved.matched`, as many as the host has. Ones that
464
+ * are not functions are skipped.
465
+ * @param loaded What was loaded before this call. Absent reports every match as newly loaded.
466
+ */
467
+ export function proxyLoadResult(resolved, catalog, definitions, loaded) {
468
+ const lines = [];
469
+ const again = resolved.matched.filter((name) => loaded?.has(name));
470
+ const fresh = definitions.flatMap((tool) => tool.type === "function" && !loaded?.has(tool.function.name) ? [tool.function] : []);
471
+ const defined = new Set(fresh.map((tool) => tool.name));
472
+ const missing = resolved.matched.filter((name) => !loaded?.has(name) && !defined.has(name));
473
+ if (fresh.length) {
474
+ lines.push(`Loaded ${fresh.length} tool(s). Run them with \`${CALL_TOOL}\`.`);
475
+ for (const { name, description, parameters } of fresh)
476
+ lines.push("", JSON.stringify({ name, description, parameters }));
477
+ }
478
+ if (again.length) {
479
+ if (lines.length)
480
+ lines.push("");
481
+ lines.push(`Already loaded earlier in this conversation: ${again.join(", ")}. Run them with ` +
482
+ `\`${CALL_TOOL}\`; do not load them again.`);
483
+ }
484
+ if (missing.length) {
485
+ if (lines.length)
486
+ lines.push("");
487
+ lines.push(`No definition is available for: ${missing.join(", ")}.`);
488
+ }
489
+ const { overBroad, deferred, unknown, matched } = resolved;
490
+ if (overBroad.length || deferred.length || unknown.length || !matched.length) {
491
+ if (lines.length)
492
+ lines.push("");
493
+ lines.push(loadResult({ ...resolved, matched: [] }, catalog));
494
+ }
495
+ return lines.join("\n");
496
+ }
497
+ /**
498
+ * The tool a `call_tool` names and the arguments to run it with, or a throw the model can read.
499
+ *
500
+ * `arguments` arrives as an object when the model follows the schema and as a JSON string when it
501
+ * copies the shape of a native call instead; both are taken, since the intent is the same. A name
502
+ * outside the catalogue is refused here rather than handed to a dispatcher, which is what keeps
503
+ * `call_tool` from reaching anything the catalogue does not offer.
504
+ *
505
+ * @param args The `call_tool` call's own arguments, parsed. An absent `arguments` is no arguments.
506
+ * @param catalog What may be called. The name is trimmed and then matched exactly.
507
+ */
508
+ export function proxiedCall(args, catalog) {
509
+ const name = typeof args.name === "string" ? args.name.trim() : "";
510
+ if (!name)
511
+ throw new Error(`${CALL_TOOL} needs a name; pass one from the tool catalogue.`);
512
+ if (!inCatalog(catalog, name))
513
+ throw new Error(`Not in the catalogue: ${name}. Check the name and try again.`);
514
+ let input = args.arguments ?? {};
515
+ if (typeof input === "string") {
516
+ const text = input;
517
+ try {
518
+ input = text.trim() ? JSON.parse(text) : {};
519
+ }
520
+ catch {
521
+ throw new Error(`${CALL_TOOL} arguments for ${name} are not valid JSON: ${text.slice(0, 200)}`);
522
+ }
523
+ }
524
+ if (!input || typeof input !== "object" || Array.isArray(input))
525
+ throw new Error(`${CALL_TOOL} arguments for ${name} must be an object.`);
526
+ return { name, input: input };
527
+ }
528
+ /**
529
+ * A tool call as a watcher should see it: a `call_tool` as the tool it ran, anything else as it
530
+ * is.
531
+ *
532
+ * The transcript keeps the `call_tool` the model wrote, since the next request has to repeat it
533
+ * word for word to stay in the cache; only the display looks through it. Anything that does not
534
+ * parse as a proxied call is left as it came.
535
+ *
536
+ * @param name The name the model called.
537
+ * @param input The call's arguments as JSON text.
538
+ * @returns The inner name and its arguments as JSON text — the string itself where the model
539
+ * passed `arguments` as one.
540
+ */
541
+ export function shownCall(name, input) {
542
+ if (name !== CALL_TOOL)
543
+ return { name, input };
544
+ try {
545
+ const args = JSON.parse(input);
546
+ if (typeof args.name !== "string" || !args.name.trim())
547
+ return { name, input };
548
+ const inner = args.arguments ?? {};
549
+ return {
550
+ name: args.name.trim(),
551
+ input: typeof inner === "string" ? inner : JSON.stringify(inner),
552
+ };
553
+ }
554
+ catch {
555
+ return { name, input };
556
+ }
557
+ }
343
558
  /**
344
559
  * Where a request is cut for the preselector, in characters.
345
560
  *
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
 
@@ -316,10 +321,12 @@ Reading what a model meant by a tool call when it did not write one cleanly.
316
321
 
317
322
  ### tool-loading
318
323
 
324
+ - `CALL_TOOL` — The one tool a proxied run reaches every catalogued tool through.
319
325
  - `carryOver` — The tools to start the next turn with: last turn's where they were, then what this turn used.
320
326
  - `catalogList` — The catalogue as a plain grouped listing of names, loaded ones marked if asked.
321
327
  - `catalogPrompt` — The catalogue block appended to the system prompt.
322
328
  - `expandNames` — Resolves requested names against the catalogue, expanding trailing `*` wildcards.
329
+ - `holdsDefinitions` — Whether a tool result is a proxied load carrying definitions, which is the only copy of them the model has.
323
330
  - `inCatalog` — Whether the catalogue holds a tool by this name.
324
331
  - `KEYWORD_DROPOFF` — How far the best unpicked tool must fall below the last picked one for the cut to count clean.
325
332
  - `KEYWORD_MIN_SCORE` — The least a best match may score and still be acted on without a model.
@@ -334,10 +341,15 @@ Reading what a model meant by a tool call when it did not write one cleanly.
334
341
  - `orderTools` — The tool array in a stable order, so the same set of tools renders the same way twice.
335
342
  - `PRESELECT_SCHEMA` — The shape a preselector's answer is held to where the server takes a schema: `{ tools: [...] }`.
336
343
  - `PRESELECT_SYSTEM` — The preselection system prompt at the default cap, for a caller that never changes it.
344
+ - `PROXY_TOOLS` — The whole tool array of a proxied run, `load_tools` then `call_tool`, frozen because it is shared.
337
345
  - `preselectByKeywords` — The tools a request's own words point at, ranked, and whether they point clearly enough.
338
346
  - `preselectInput` — The user message for a preselection call: the catalogue, then the request.
339
347
  - `preselection` — Resolves a preselection against the catalogue: unknown names dropped, count capped.
340
348
  - `preselectSystem` — The system prompt a preselector is given, holding it to the cap its answer will be held to.
349
+ - `proxiedCall` — The tool a `call_tool` names and the arguments to run it with, or a throw the model can read.
350
+ - `proxyCatalogPrompt` — The catalogue block for a proxied run's system prompt, worded for `call_tool` rather than for a tool list.
351
+ - `proxyLoadResult` — What a proxied `load_tools` answers: each new tool's whole definition as a line of JSON, since the result is the only place the model will ever see it.
341
352
  - `requestedNames` — `load_tools` arguments, defensively — a model may send a bare string or a nested object.
353
+ - `shownCall` — A tool call as a watcher should see it: a `call_tool` as the tool it ran, anything else as it is.
342
354
  - `ToolMatch` (type) — One tool's score against a request.
343
355
  - `ToolOrder` (type) — How a tool array is ordered before it is sent: `true` by name, `false` as the caller built it, or a comparator over the two names.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "2.19.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",