@cubicecho/agent-core 2.2.4 → 2.3.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/index.d.ts CHANGED
@@ -4,8 +4,8 @@
4
4
  * What is here is everything that does not know what the agent is *for*: making a tool schema
5
5
  * a strict server will accept, getting tool definitions in front of a model without paying for
6
6
  * all of them, reading one streamed turn back into a message, answering an endpoint that
7
- * refuses one of those, one-shot calls that support a run, the event bus a watcher reads, a
8
- * pooled client, and the rules about retrying. What is not here is the work — orchestration,
7
+ * refuses one of those, one-shot calls that support a run, the host's side of lifecycle hooks,
8
+ * the event bus a watcher reads, a pooled client, and the rules about retrying. What is not here is the work — orchestration,
9
9
  * prompts, and whatever the run is about — because that is the caller's, and it is the part
10
10
  * that differs between one server and the next.
11
11
  */
@@ -14,7 +14,8 @@ export type { CatalogServer } from "./catalog.ts";
14
14
  export { contextLimitFor, getClient, listModels, type ModelInfo, NO_KEY, resetClients, timeoutMs, } from "./client.ts";
15
15
  export type { AgentConfig, Endpoint, ModelParams, RetryPolicy, ToolPolicy, } from "./config.ts";
16
16
  export { errorMessage } from "./errors.ts";
17
- export { emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunUsage, resetEvents, watch, } from "./events.ts";
17
+ export { configureEvents, type EventBusOptions, emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunUsage, resetEvents, watch, } from "./events.ts";
18
+ export { assembleContext, type Gathered, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, type HookContext, type HookEvent, type HookMessage, type HookNote, type HookOutcome, type HookRunner, INJECT_EVENTS, notify, turnIndex, turnMessages, withContext, } from "./hooks.ts";
18
19
  export { resetAll } from "./reset.ts";
19
20
  export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.ts";
20
21
  export { type RunTurnOptions, runTurn } from "./run-turn.ts";
@@ -22,4 +23,4 @@ export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.ts";
22
23
  export { ask, clean, listLines, parseJson, resetHints, type SideTaskOptions, tryAsk, } from "./side-task.ts";
23
24
  export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from "./stream.ts";
24
25
  export { estimateTokens } from "./tokens.ts";
25
- export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SYSTEM, preselectInput, preselection, requestedNames, } from "./tool-loading.ts";
26
+ export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.ts";
package/dist/index.js CHANGED
@@ -4,15 +4,16 @@
4
4
  * What is here is everything that does not know what the agent is *for*: making a tool schema
5
5
  * a strict server will accept, getting tool definitions in front of a model without paying for
6
6
  * all of them, reading one streamed turn back into a message, answering an endpoint that
7
- * refuses one of those, one-shot calls that support a run, the event bus a watcher reads, a
8
- * pooled client, and the rules about retrying. What is not here is the work — orchestration,
7
+ * refuses one of those, one-shot calls that support a run, the host's side of lifecycle hooks,
8
+ * the event bus a watcher reads, a pooled client, and the rules about retrying. What is not here is the work — orchestration,
9
9
  * prompts, and whatever the run is about — because that is the caller's, and it is the part
10
10
  * that differs between one server and the next.
11
11
  */
12
12
  export { capabilitiesFor, modelCapabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.js";
13
13
  export { contextLimitFor, getClient, listModels, NO_KEY, resetClients, timeoutMs, } from "./client.js";
14
14
  export { errorMessage } from "./errors.js";
15
- export { emit, endRun, fold, history, resetEvents, watch, } from "./events.js";
15
+ export { configureEvents, emit, endRun, fold, history, resetEvents, watch, } from "./events.js";
16
+ export { assembleContext, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, INJECT_EVENTS, notify, turnIndex, turnMessages, withContext, } from "./hooks.js";
16
17
  export { resetAll } from "./reset.js";
17
18
  export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
18
19
  export { runTurn } from "./run-turn.js";
@@ -20,4 +21,4 @@ export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.js";
20
21
  export { ask, clean, listLines, parseJson, resetHints, tryAsk, } from "./side-task.js";
21
22
  export { streamTurn, } from "./stream.js";
22
23
  export { estimateTokens } from "./tokens.js";
23
- export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SYSTEM, preselectInput, preselection, requestedNames, } from "./tool-loading.js";
24
+ export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
package/dist/retry.d.ts CHANGED
@@ -14,6 +14,11 @@ export declare class EndpointSilent extends Error {
14
14
  /**
15
15
  * The request was bigger than the model will read. Its own class so nothing retries it: sending
16
16
  * the same too-large request again is the same refusal, one round trip later.
17
+ *
18
+ * `runTurn` raises it from either side of the round trip — its own pre-flight guard, or the
19
+ * endpoint's refusal read back through `isOverflow` — so a caller has one thing to catch whether
20
+ * or not it gave a `contextLimit`. The second carries the endpoint's own message, and the error
21
+ * it was built from as `cause`.
17
22
  */
18
23
  export declare class ContextOverflow extends Error {
19
24
  readonly name = "ContextOverflow";
package/dist/retry.js CHANGED
@@ -15,6 +15,11 @@ export class EndpointSilent extends Error {
15
15
  /**
16
16
  * The request was bigger than the model will read. Its own class so nothing retries it: sending
17
17
  * the same too-large request again is the same refusal, one round trip later.
18
+ *
19
+ * `runTurn` raises it from either side of the round trip — its own pre-flight guard, or the
20
+ * endpoint's refusal read back through `isOverflow` — so a caller has one thing to catch whether
21
+ * or not it gave a `contextLimit`. The second carries the endpoint's own message, and the error
22
+ * it was built from as `cause`.
18
23
  */
19
24
  export class ContextOverflow extends Error {
20
25
  name = "ContextOverflow";
@@ -12,6 +12,11 @@ import { type StreamTurnOptions, type Turn } from "./stream.ts";
12
12
  * The outer one is the endpoint being unreachable, busy or silent, which is not about this
13
13
  * request at all and is worth simply waiting out.
14
14
  *
15
+ * A request too big for the window is neither, and comes out of here as `ContextOverflow`
16
+ * however it was found out about — by the `contextLimit` guard below before a round trip was
17
+ * spent, or by the endpoint's own refusal after one. The option decides how early the caller
18
+ * hears, not what it hears.
19
+ *
15
20
  * Both are bounded by the same rule: nothing is sent again once the model has started
16
21
  * answering. The tokens are already out and on their way to whoever is watching, and a second
17
22
  * attempt would say everything twice. That is what `produced` is, one box per attempt — set by
package/dist/run-turn.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { negotiate } from "./capabilities.js";
2
2
  import { errorMessage } from "./errors.js";
3
- import { backoffMs, ContextOverflow, compact, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
3
+ import { backoffMs, ContextOverflow, compact, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
4
4
  import { streamTurn } from "./stream.js";
5
5
  /**
6
6
  * `request` is a callback rather than a body because the body has to be rebuilt from whatever
@@ -46,6 +46,19 @@ export async function runTurn(client, supports, request, { maxRetries = 0, onNot
46
46
  // rules in `retry.ts` — so classifying first brings a cancelled run back from the dead.
47
47
  if (produced.any || stream.signal?.aborted)
48
48
  throw error;
49
+ // The endpoint's own refusal, classified here rather than left to the caller. One failure
50
+ // had two error types depending on an option about something else: a caller that gave a
51
+ // `contextLimit` got `ContextOverflow` from the guard above, and one that did not — the
52
+ // default — got a raw SDK error and had to know to run `isOverflow` over its message
53
+ // itself. `runTurn` is offered as the whole loop, so the classification this package
54
+ // already knows how to do belongs inside it.
55
+ //
56
+ // The original is kept as `cause`, because the endpoint's wording is the half that names
57
+ // the number. A rate limit borrows the same words and is not one of these — `isOverflow`
58
+ // rules it out, and it goes on to be retried below as the 429 it is.
59
+ if (!(error instanceof ContextOverflow) && isOverflow(errorMessage(error))) {
60
+ throw new ContextOverflow(errorMessage(error), { cause: error });
61
+ }
49
62
  if (attempt >= maxRetries || !isTransient(error))
50
63
  throw error;
51
64
  const wait = backoffMs(attempt);
package/dist/side-task.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import OpenAI from "openai";
2
2
  import { capabilitiesFor, negotiate } from "./capabilities.js";
3
- import { getClient } from "./client.js";
3
+ import { endpointKey, getClient } from "./client.js";
4
4
  import { errorMessage } from "./errors.js";
5
5
  import { isTransient } from "./retry.js";
6
6
  /**
@@ -24,10 +24,10 @@ const NO_THINKING = { chat_template_kwargs: { enable_thinking: false } };
24
24
  /**
25
25
  * The models that turned out not to take the hints, by endpoint and model.
26
26
  *
27
- * Keyed rather than global for the reason the client cache is keyed: a refusal is a fact about
28
- * what is on the other end, not about this process. A llama.cpp box and a cloud API are both
29
- * reachable from one consumer over its lifetime, and the first one's refusal must not stop the
30
- * second from ever being asked.
27
+ * Keyed rather than global for the reason the client cache is keyed, and on the same
28
+ * `endpointKey` it is: a refusal is a fact about what is on the other end, not about this
29
+ * process. A llama.cpp box and a cloud API are both reachable from one consumer over its
30
+ * lifetime, and the first one's refusal must not stop the second from ever being asked.
31
31
  *
32
32
  * The model belongs in the key for the same reason. One base URL is routinely many models —
33
33
  * OpenRouter, LiteLLM, vLLM serving several at once — and whether `chat_template_kwargs` reaches
@@ -38,7 +38,7 @@ const NO_THINKING = { chat_template_kwargs: { enable_thinking: false } };
38
38
  * the same (endpoint, model) pair, where a run on that model can read it too.
39
39
  */
40
40
  const noHints = new Set();
41
- const hintKey = (baseUrl, model) => JSON.stringify([baseUrl, model]);
41
+ const hintKey = (config, model) => JSON.stringify([endpointKey(config), model]);
42
42
  /** Test seam, alongside `resetClients` and `resetAll`: forget which models refused the hints. */
43
43
  export const resetHints = () => noHints.clear();
44
44
  /**
@@ -108,12 +108,12 @@ export async function ask(config, model, system, user, { maxTokens = 512, temper
108
108
  // whether a run or a side task found it out, and the point of latching it is that only one of
109
109
  // them has to pay for it. Nothing here sends tools or `stream_options`, so the two
110
110
  // endpoint-level flags are not in play — the model's three are the whole of what this meets.
111
- const supports = capabilitiesFor(config.baseUrl);
111
+ const supports = capabilitiesFor(config.baseUrl, config.apiKey);
112
112
  const attempt = (hints) => negotiate(supports, (_supports, _produced, refused) => send(hints, refused), {
113
113
  model,
114
114
  onNotice,
115
115
  });
116
- const key = hintKey(config.baseUrl, model);
116
+ const key = hintKey(config, model);
117
117
  const hints = !noHints.has(key);
118
118
  let response;
119
119
  try {
package/dist/stream.d.ts CHANGED
@@ -18,8 +18,26 @@ export interface TurnUsage {
18
18
  /** One streamed turn, put back together into the shape a loop and a transcript work with. */
19
19
  export interface Turn {
20
20
  content: string;
21
- toolCalls: OpenAI.ChatCompletionMessageToolCall[];
21
+ /**
22
+ * The narrower of the SDK's two tool-call shapes, because it is the only one built here — a
23
+ * streamed `tool_calls` delta carries a function and nothing else. Typed as the union it
24
+ * belongs to, every caller had to narrow before it could read `.function`, to rule out a
25
+ * custom call that this loop cannot produce. Still assignable wherever the union is wanted.
26
+ */
27
+ toolCalls: OpenAI.ChatCompletionMessageFunctionToolCall[];
22
28
  usage: TurnUsage;
29
+ /**
30
+ * Why the model stopped, in the endpoint's own words — `stop`, `length`, `tool_calls`, or `""`
31
+ * where it never said.
32
+ *
33
+ * Reported because `length` is otherwise invisible. A turn cut off at the token ceiling comes
34
+ * back as a well-formed `Turn` with truncated `content`, or with a tool call whose `arguments`
35
+ * stop mid-JSON — so the caller meets a parse failure with nothing to attribute it to. Being
36
+ * cut off looking whole is the same trap `throwIfAborted` below answers for the abort; this
37
+ * half is not an error, because the tokens are real and a caller may still want them, so it is
38
+ * handed over rather than raised.
39
+ */
40
+ finishReason: string;
23
41
  }
24
42
  /**
25
43
  * Whether the model has said anything a second attempt would say twice.
package/dist/stream.js CHANGED
@@ -51,6 +51,7 @@ export async function streamTurn(client, body, { signal, idleMs, produced, onThi
51
51
  const content = [];
52
52
  const calls = new Map();
53
53
  const usage = { prompt: 0, completion: 0, total: 0 };
54
+ let finishReason = "";
54
55
  for await (const chunk of stream) {
55
56
  // Rearmed on every chunk, latched below on only some: a priming chunk is the endpoint
56
57
  // being alive, which is all the watchdog is asking about.
@@ -64,7 +65,15 @@ export async function streamTurn(client, body, { signal, idleMs, produced, onThi
64
65
  usage.completion = chunk.usage.completion_tokens ?? 0;
65
66
  usage.total = chunk.usage.total_tokens ?? 0;
66
67
  }
67
- const delta = chunk.choices[0]?.delta;
68
+ // One choice, because that is what an agent loop asks for. A body with `n` above one
69
+ // keeps only the first; nothing here is built to reassemble several at once.
70
+ const choice = chunk.choices[0];
71
+ // Read before the delta guard rather than beside the content. The chunk that carries the
72
+ // reason usually carries an empty delta, and some servers send it with no delta at all —
73
+ // either of which the guard below skips, taking the reason with it.
74
+ if (choice?.finish_reason)
75
+ finishReason = choice.finish_reason;
76
+ const delta = choice?.delta;
68
77
  if (!delta)
69
78
  continue;
70
79
  const thinking = delta.reasoning_content || delta.reasoning || "";
@@ -112,6 +121,7 @@ export async function streamTurn(client, body, { signal, idleMs, produced, onThi
112
121
  function: { name: call.name, arguments: call.arguments },
113
122
  })),
114
123
  usage,
124
+ finishReason,
115
125
  };
116
126
  }
117
127
  }
@@ -52,6 +52,11 @@ export declare function catalogPrompt(catalog: CatalogServer[], loaded?: Readonl
52
52
  * in the position on-demand loading exists to avoid — a tool array too large to choose from.
53
53
  * Over-broad requests are refused with the matching names listed, so the next call can be
54
54
  * precise.
55
+ *
56
+ * The default rather than the rule: twelve is what a small model chooses well from, and a caller
57
+ * running a large one against a large window can say otherwise to `expandNames`, `preselection`
58
+ * and `preselectSystem`. Whatever it says is carried on the resolution, so the refusal the model
59
+ * reads names the number it was actually held to.
55
60
  */
56
61
  export declare const MAX_PER_LOAD = 12;
57
62
  /**
@@ -59,7 +64,7 @@ export declare const MAX_PER_LOAD = 12;
59
64
  * conversation runs; least-recently-used names fall off the front.
60
65
  *
61
66
  * Only a multi-turn caller needs this — a run that starts from nothing each time has nothing to
62
- * carry. See `carryOver`.
67
+ * carry. See `carryOver`, which takes another number if this one is not yours.
63
68
  */
64
69
  export declare const MAX_CARRIED = 16;
65
70
  /**
@@ -67,8 +72,10 @@ export declare const MAX_CARRIED = 16;
67
72
  *
68
73
  * @param previous Last turn's names, oldest first.
69
74
  * @param used What this turn called. Moved to the end, so the oldest unused fall off.
75
+ * @param max How many to carry, defaulting to `MAX_CARRIED`. At least one: a cap of zero is
76
+ * read as no cap, which is what `slice` does with it and not what anybody asking for zero meant.
70
77
  */
71
- export declare const carryOver: (previous: string[], used: Set<string>) => string[];
78
+ export declare const carryOver: (previous: string[], used: Set<string>, max?: number) => string[];
72
79
  /**
73
80
  * Resolves requested names against the catalogue, expanding trailing `*` wildcards.
74
81
  *
@@ -80,8 +87,10 @@ export declare const carryOver: (previous: string[], used: Set<string>) => strin
80
87
  *
81
88
  * @param requested What the model asked for. A trailing `*` expands.
82
89
  * @param catalog The servers to resolve against.
90
+ * @param maxPerLoad The most this call may load, defaulting to `MAX_PER_LOAD`. It comes back on
91
+ * the resolution so `loadResult` reports the same number rather than a second opinion of it.
83
92
  */
84
- export declare function expandNames(requested: string[], catalog: CatalogServer[]): {
93
+ export declare function expandNames(requested: string[], catalog: CatalogServer[], maxPerLoad?: number): {
85
94
  matched: string[];
86
95
  unknown: string[];
87
96
  overBroad: {
@@ -89,6 +98,7 @@ export declare function expandNames(requested: string[], catalog: CatalogServer[
89
98
  hits: string[];
90
99
  }[];
91
100
  deferred: string[];
101
+ maxPerLoad: number;
92
102
  };
93
103
  /**
94
104
  * What `load_tools` reports back: the descriptions, now that they are worth their tokens.
@@ -96,7 +106,7 @@ export declare function expandNames(requested: string[], catalog: CatalogServer[
96
106
  * @param expanded What `expandNames` resolved: the matches, the misses, and the over-broad asks.
97
107
  * @param catalog The servers, read for the descriptions now worth their tokens.
98
108
  */
99
- export declare function loadResult({ matched, unknown, overBroad, deferred }: ReturnType<typeof expandNames>, catalog: CatalogServer[]): string;
109
+ export declare function loadResult({ matched, unknown, overBroad, deferred, maxPerLoad }: ReturnType<typeof expandNames>, catalog: CatalogServer[]): string;
100
110
  /**
101
111
  * Whether the catalogue holds a tool by this name.
102
112
  *
@@ -111,7 +121,7 @@ export declare const inCatalog: (catalog: CatalogServer[], name: string) => bool
111
121
  */
112
122
  export declare function requestedNames(args: Record<string, unknown>): string[];
113
123
  /**
114
- * Tool preselection.
124
+ * The system prompt a preselector is given, holding it to the cap its answer will be held to.
115
125
  *
116
126
  * On-demand loading otherwise costs a round trip every run: the model reads the catalogue,
117
127
  * calls `load_tools`, and only then can do the work. A small model reading the same catalogue
@@ -119,22 +129,32 @@ export declare function requestedNames(args: Record<string, unknown>): string[];
119
129
  * starts working on its first step.
120
130
  *
121
131
  * A wrong guess is cheap — an unused definition is a few hundred tokens for one run — but a
122
- * broad guess is not, so the same `MAX_PER_LOAD` cap applies here as to a `load_tools` call.
132
+ * broad guess is not, so the same cap applies here as to a `load_tools` call.
133
+ *
134
+ * @param maxPerLoad The most to ask for, defaulting to `MAX_PER_LOAD`. Give `preselection` the
135
+ * same number: this one is what the preselector is told, and that one is what it is held to.
123
136
  */
137
+ export declare const preselectSystem: (maxPerLoad?: number) => string;
138
+ /** The preselection system prompt at the default cap, for a caller that never changes it. */
124
139
  export declare const PRESELECT_SYSTEM: string;
125
140
  /**
126
141
  * The user message for a preselection call: the catalogue, then the request.
127
142
  *
128
143
  * @param catalog The connected servers, rendered as the name-only listing.
129
- * @param prompt The request being planned for, truncated at 2000 characters choosing tools
130
- * needs the shape of the ask, not all of it.
144
+ * @param prompt The request being planned for, truncated choosing tools needs the shape of the
145
+ * ask, not all of it.
146
+ * @param maxPromptChars Where the request is cut, defaulting to 2000. A caller whose requests
147
+ * carry the part that names the work at the end wants a larger one, and pays for it in the
148
+ * preselector's prompt.
131
149
  */
132
- export declare const preselectInput: (catalog: CatalogServer[], prompt: string) => string;
150
+ export declare const preselectInput: (catalog: CatalogServer[], prompt: string, maxPromptChars?: number) => string;
133
151
  /**
134
152
  * Resolves a preselection against the catalogue: unknown names dropped, count capped.
135
153
  *
136
154
  * @param names What the preselector replied. Unvalidated: a non-array gives none, and entries
137
155
  * that are not strings are dropped.
138
156
  * @param catalog The servers to resolve against.
157
+ * @param maxPerLoad The most to keep, defaulting to `MAX_PER_LOAD`. The same number
158
+ * `preselectSystem` was given, or the model is being held to a cap it was never told about.
139
159
  */
140
- export declare function preselection(names: unknown, catalog: CatalogServer[]): string[];
160
+ export declare function preselection(names: unknown, catalog: CatalogServer[], maxPerLoad?: number): string[];
@@ -103,6 +103,11 @@ const flatten = (catalog) => catalog.flatMap((server) => server.tools);
103
103
  * in the position on-demand loading exists to avoid — a tool array too large to choose from.
104
104
  * Over-broad requests are refused with the matching names listed, so the next call can be
105
105
  * precise.
106
+ *
107
+ * The default rather than the rule: twelve is what a small model chooses well from, and a caller
108
+ * running a large one against a large window can say otherwise to `expandNames`, `preselection`
109
+ * and `preselectSystem`. Whatever it says is carried on the resolution, so the refusal the model
110
+ * reads names the number it was actually held to.
106
111
  */
107
112
  export const MAX_PER_LOAD = 12;
108
113
  /**
@@ -110,7 +115,7 @@ export const MAX_PER_LOAD = 12;
110
115
  * conversation runs; least-recently-used names fall off the front.
111
116
  *
112
117
  * Only a multi-turn caller needs this — a run that starts from nothing each time has nothing to
113
- * carry. See `carryOver`.
118
+ * carry. See `carryOver`, which takes another number if this one is not yours.
114
119
  */
115
120
  export const MAX_CARRIED = 16;
116
121
  /**
@@ -118,8 +123,10 @@ export const MAX_CARRIED = 16;
118
123
  *
119
124
  * @param previous Last turn's names, oldest first.
120
125
  * @param used What this turn called. Moved to the end, so the oldest unused fall off.
126
+ * @param max How many to carry, defaulting to `MAX_CARRIED`. At least one: a cap of zero is
127
+ * read as no cap, which is what `slice` does with it and not what anybody asking for zero meant.
121
128
  */
122
- export const carryOver = (previous, used) => [...previous.filter((name) => !used.has(name)), ...used].slice(-MAX_CARRIED);
129
+ export const carryOver = (previous, used, max = MAX_CARRIED) => [...previous.filter((name) => !used.has(name)), ...used].slice(-Math.max(1, max));
123
130
  /**
124
131
  * Resolves requested names against the catalogue, expanding trailing `*` wildcards.
125
132
  *
@@ -131,8 +138,10 @@ export const carryOver = (previous, used) => [...previous.filter((name) => !used
131
138
  *
132
139
  * @param requested What the model asked for. A trailing `*` expands.
133
140
  * @param catalog The servers to resolve against.
141
+ * @param maxPerLoad The most this call may load, defaulting to `MAX_PER_LOAD`. It comes back on
142
+ * the resolution so `loadResult` reports the same number rather than a second opinion of it.
134
143
  */
135
- export function expandNames(requested, catalog) {
144
+ export function expandNames(requested, catalog, maxPerLoad = MAX_PER_LOAD) {
136
145
  const all = flatten(catalog);
137
146
  const matched = new Set();
138
147
  const unknown = [];
@@ -179,15 +188,15 @@ export function expandNames(requested, catalog) {
179
188
  // enough already — telling a model that asked for one exact tool that it matches one tool,
180
189
  // "more than the twelve one call may load", and to choose from a list holding just that
181
190
  // name, leaves it nothing to do but send the identical call again.
182
- if (fresh.length > MAX_PER_LOAD)
191
+ if (fresh.length > maxPerLoad)
183
192
  overBroad.push({ name, hits });
184
- else if (matched.size + fresh.length > MAX_PER_LOAD)
193
+ else if (matched.size + fresh.length > maxPerLoad)
185
194
  deferred.push(name);
186
195
  else
187
196
  for (const hit of hits)
188
197
  matched.add(hit);
189
198
  }
190
- return { matched: [...matched], unknown, overBroad, deferred };
199
+ return { matched: [...matched], unknown, overBroad, deferred, maxPerLoad };
191
200
  }
192
201
  /**
193
202
  * What `load_tools` reports back: the descriptions, now that they are worth their tokens.
@@ -195,7 +204,7 @@ export function expandNames(requested, catalog) {
195
204
  * @param expanded What `expandNames` resolved: the matches, the misses, and the over-broad asks.
196
205
  * @param catalog The servers, read for the descriptions now worth their tokens.
197
206
  */
198
- export function loadResult({ matched, unknown, overBroad, deferred }, catalog) {
207
+ export function loadResult({ matched, unknown, overBroad, deferred, maxPerLoad }, catalog) {
199
208
  const byName = new Map(flatten(catalog).map((tool) => [tool.name, tool.description]));
200
209
  const lines = [];
201
210
  if (matched.length) {
@@ -206,12 +215,12 @@ export function loadResult({ matched, unknown, overBroad, deferred }, catalog) {
206
215
  for (const { name, hits } of overBroad) {
207
216
  if (lines.length)
208
217
  lines.push("");
209
- lines.push(`\`${name}\` matches ${hits.length} tools, more than the ${MAX_PER_LOAD} one call may load.`, "Name the ones you need from:", ...hits.map((hit) => ` ${hit}`));
218
+ lines.push(`\`${name}\` matches ${hits.length} tools, more than the ${maxPerLoad} one call may load.`, "Name the ones you need from:", ...hits.map((hit) => ` ${hit}`));
210
219
  }
211
220
  if (deferred.length) {
212
221
  if (lines.length)
213
222
  lines.push("");
214
- lines.push(`This call is full at ${MAX_PER_LOAD} tools, so these were not loaded: ${deferred.join(", ")}.`, "Ask for them on your next step.");
223
+ lines.push(`This call is full at ${maxPerLoad} tools, so these were not loaded: ${deferred.join(", ")}.`, "Ask for them on your next step.");
215
224
  }
216
225
  if (unknown.length) {
217
226
  if (lines.length)
@@ -241,7 +250,15 @@ export function requestedNames(args) {
241
250
  return [];
242
251
  }
243
252
  /**
244
- * Tool preselection.
253
+ * Where a request is cut for the preselector, in characters.
254
+ *
255
+ * A tool choice is made on what the work is, which is the top of a request rather than all of
256
+ * it — and the whole of a long one is paid for again in the preselection call. `preselectInput`
257
+ * takes another number for a caller whose requests are not shaped that way.
258
+ */
259
+ const PRESELECT_PROMPT_CHARS = 2000;
260
+ /**
261
+ * The system prompt a preselector is given, holding it to the cap its answer will be held to.
245
262
  *
246
263
  * On-demand loading otherwise costs a round trip every run: the model reads the catalogue,
247
264
  * calls `load_tools`, and only then can do the work. A small model reading the same catalogue
@@ -249,30 +266,40 @@ export function requestedNames(args) {
249
266
  * starts working on its first step.
250
267
  *
251
268
  * A wrong guess is cheap — an unused definition is a few hundred tokens for one run — but a
252
- * broad guess is not, so the same `MAX_PER_LOAD` cap applies here as to a `load_tools` call.
269
+ * broad guess is not, so the same cap applies here as to a `load_tools` call.
270
+ *
271
+ * @param maxPerLoad The most to ask for, defaulting to `MAX_PER_LOAD`. Give `preselection` the
272
+ * same number: this one is what the preselector is told, and that one is what it is held to.
253
273
  */
254
- export const PRESELECT_SYSTEM = "You choose tools. Below is a catalogue of tool names, then a request. Reply with a JSON " +
274
+ export const preselectSystem = (maxPerLoad = MAX_PER_LOAD) => "You choose tools. Below is a catalogue of tool names, then a request. Reply with a JSON " +
255
275
  "array of the names the request is likely to need — exact names from the catalogue, at most " +
256
- `${MAX_PER_LOAD}, and as few as could do the job. Reply with \`[]\` if the request can be ` +
276
+ `${maxPerLoad}, and as few as could do the job. Reply with \`[]\` if the request can be ` +
257
277
  "answered without tools. Reply with the array alone — no prose, no explanation.";
278
+ /** The preselection system prompt at the default cap, for a caller that never changes it. */
279
+ export const PRESELECT_SYSTEM = preselectSystem();
258
280
  /**
259
281
  * The user message for a preselection call: the catalogue, then the request.
260
282
  *
261
283
  * @param catalog The connected servers, rendered as the name-only listing.
262
- * @param prompt The request being planned for, truncated at 2000 characters choosing tools
263
- * needs the shape of the ask, not all of it.
284
+ * @param prompt The request being planned for, truncated choosing tools needs the shape of the
285
+ * ask, not all of it.
286
+ * @param maxPromptChars Where the request is cut, defaulting to 2000. A caller whose requests
287
+ * carry the part that names the work at the end wants a larger one, and pays for it in the
288
+ * preselector's prompt.
264
289
  */
265
- export const preselectInput = (catalog, prompt) => `# Tool catalogue\n\n${catalogList(catalog)}\n\n# Request\n\n${prompt.slice(0, 2000)}`;
290
+ export const preselectInput = (catalog, prompt, maxPromptChars = PRESELECT_PROMPT_CHARS) => `# Tool catalogue\n\n${catalogList(catalog)}\n\n# Request\n\n${prompt.slice(0, maxPromptChars)}`;
266
291
  /**
267
292
  * Resolves a preselection against the catalogue: unknown names dropped, count capped.
268
293
  *
269
294
  * @param names What the preselector replied. Unvalidated: a non-array gives none, and entries
270
295
  * that are not strings are dropped.
271
296
  * @param catalog The servers to resolve against.
297
+ * @param maxPerLoad The most to keep, defaulting to `MAX_PER_LOAD`. The same number
298
+ * `preselectSystem` was given, or the model is being held to a cap it was never told about.
272
299
  */
273
- export function preselection(names, catalog) {
300
+ export function preselection(names, catalog, maxPerLoad = MAX_PER_LOAD) {
274
301
  if (!Array.isArray(names))
275
302
  return [];
276
303
  const wanted = names.filter((name) => typeof name === "string");
277
- return expandNames(wanted, catalog).matched.slice(0, MAX_PER_LOAD);
304
+ return expandNames(wanted, catalog, maxPerLoad).matched.slice(0, maxPerLoad);
278
305
  }
package/llms.txt CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > 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.
4
4
 
5
- What is here is everything that does not know what the agent is *for*: making a tool schema a strict server will accept, getting tool definitions in front of a model without paying for all of them, reading one streamed turn back into a message, answering an endpoint that refuses one of those, one-shot calls that support a run, the event bus a watcher reads, a pooled client, and the rules about retrying. What is not here is the work — orchestration, prompts, and whatever the run is about — because that is the caller's, and it is the part that differs between one server and the next.
5
+ What is here is everything that does not know what the agent is *for*: making a tool schema a strict server will accept, getting tool definitions in front of a model without paying for all of them, reading one streamed turn back into a message, answering an endpoint that refuses one of those, one-shot calls that support a run, the host's side of lifecycle hooks, the event bus a watcher reads, a pooled client, and the rules about retrying. What is not here is the work — orchestration, prompts, and whatever the run is about — because that is the caller's, and it is the part that differs between one server and the next.
6
6
 
7
7
  Requires Node >=22, with `openai` >=6 as a peer dependency. ESM only.
8
8
  Full prose, worked examples and the reasoning behind each seam are in README.md; this file is the index.
@@ -55,6 +55,8 @@ What this package needs to know about a caller's configuration.
55
55
 
56
56
  What a run is doing, while it is doing it.
57
57
 
58
+ - `configureEvents` — Changes what the bus keeps, for a process whose runs are not shaped like the ones these defaults were chosen for.
59
+ - `EventBusOptions` (type) — What the bus keeps and for how long.
58
60
  - `emit` — Records one event and hands it to everyone watching that run.
59
61
  - `endRun` — Forgets a run that will not be emitting `done` — one whose process is tearing down, or whose loop threw where it could not be caught.
60
62
  - `fold` — Consecutive tokens of one kind are one thing being said, not hundreds of things.
@@ -66,6 +68,28 @@ What a run is doing, while it is doing it.
66
68
  - `resetEvents` — Test seam: forget every run, so one test's events cannot be read by the next.
67
69
  - `watch` — Everything that has happened on a run, then everything that happens next, until it ends.
68
70
 
71
+ ### hooks
72
+
73
+ Lifecycle hooks, from the host's side: what a session looks like to them, where their context lands in a request, and what is said about each one.
74
+
75
+ - `assembleContext` — Builds the context a set of outcomes adds and the notes that go with it.
76
+ - `Gathered` (type) — The context a set of outcomes adds to a request, and a note for each hook worth mentioning.
77
+ - `gather` — Runs the hooks ahead of a request and builds what they add to it.
78
+ - `HOOK_CONTEXT_TOKENS` — The most context all of a request's hooks add between them, in estimated tokens.
79
+ - `HOOK_EVENTS` — Every event a hook can be bound to, in the order a session meets them.
80
+ - `HOOK_PREFACE` — Said once, above the blocks, so the model reads them as background rather than instructions.
81
+ - `HookContext` (type) — What a host knows at an event.
82
+ - `HookEvent` (type) — A point in a session a hook can be bound to.
83
+ - `HookMessage` (type) — One message of a session as a hook is handed it.
84
+ - `HookNote` (type) — One hook's line for whoever is watching: the context it added, or why it added none.
85
+ - `HookOutcome` (type) — What one hook did.
86
+ - `HookRunner` (type) — Runs one event's hooks.
87
+ - `INJECT_EVENTS` — The events whose hooks run before a request, and so the only ones whose output can reach it.
88
+ - `notify` — Runs the hooks for an event that reads what happened and adds nothing to a request.
89
+ - `turnIndex` — Which turn of a session begins at a point, from 0: the user messages ahead of it.
90
+ - `turnMessages` — A stretch of a transcript as a hook reads it: what the user and the assistant said, and nothing else.
91
+ - `withContext` — The request, with the hooks' context added to this turn's question.
92
+
69
93
  ### reset
70
94
 
71
95
  - `resetAll` — Forgets everything this package remembers between calls.
@@ -137,7 +161,8 @@ Reading one streamed turn back into a message.
137
161
  - `loadResult` — What `load_tools` reports back: the descriptions, now that they are worth their tokens.
138
162
  - `MAX_CARRIED` — The most a conversation carries between turns.
139
163
  - `MAX_PER_LOAD` — The most a single `load_tools` call may pull in.
140
- - `PRESELECT_SYSTEM` — Tool preselection.
164
+ - `PRESELECT_SYSTEM` — The preselection system prompt at the default cap, for a caller that never changes it.
141
165
  - `preselectInput` — The user message for a preselection call: the catalogue, then the request.
142
166
  - `preselection` — Resolves a preselection against the catalogue: unknown names dropped, count capped.
167
+ - `preselectSystem` — The system prompt a preselector is given, holding it to the cap its answer will be held to.
143
168
  - `requestedNames` — `load_tools` arguments, defensively — a model may send a bare string or a nested object.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "2.2.4",
3
+ "version": "2.3.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",