@cubicecho/agent-core 2.2.4 → 2.4.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.
@@ -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,31 @@ 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
+ - `configureHooks` — Changes what hooks are held to, for a process whose windows are not the size these defaults were chosen for.
77
+ - `Gathered` (type) — The context a set of outcomes adds to a request, and a note for each hook worth mentioning.
78
+ - `gather` — Runs the hooks ahead of a request and builds what they add to it.
79
+ - `HOOK_CONTEXT_TOKENS` — The most context all of a request's hooks add between them by default, in estimated tokens.
80
+ - `HOOK_EVENTS` — Every event a hook can be bound to, in the order a session meets them.
81
+ - `HOOK_PREFACE` — Said once, above the blocks, so the model reads them as background rather than instructions.
82
+ - `HookContext` (type) — What a host knows at an event.
83
+ - `HookEvent` (type) — A point in a session a hook can be bound to.
84
+ - `HookMessage` (type) — One message of a session as a hook is handed it.
85
+ - `HookNote` (type) — One hook's line for whoever is watching: the context it added, or why it added none.
86
+ - `HookOptions` (type) — What hooks are held to across a process.
87
+ - `HookOutcome` (type) — What one hook did.
88
+ - `HookRunner` (type) — Runs one event's hooks.
89
+ - `INJECT_EVENTS` — The events whose hooks run before a request, and so the only ones whose output can reach it.
90
+ - `notify` — Runs the hooks for an event that reads what happened and adds nothing to a request.
91
+ - `resetHooks` — Test seam: puts `configureHooks` back to the defaults, so one test's budget is not the next's.
92
+ - `turnIndex` — Which turn of a session begins at a point, from 0: the user messages ahead of it.
93
+ - `turnMessages` — A stretch of a transcript as a hook reads it: what the user and the assistant said, and nothing else.
94
+ - `withContext` — The request, with the hooks' context added to this turn's question.
95
+
69
96
  ### reset
70
97
 
71
98
  - `resetAll` — Forgets everything this package remembers between calls.
@@ -137,7 +164,8 @@ Reading one streamed turn back into a message.
137
164
  - `loadResult` — What `load_tools` reports back: the descriptions, now that they are worth their tokens.
138
165
  - `MAX_CARRIED` — The most a conversation carries between turns.
139
166
  - `MAX_PER_LOAD` — The most a single `load_tools` call may pull in.
140
- - `PRESELECT_SYSTEM` — Tool preselection.
167
+ - `PRESELECT_SYSTEM` — The preselection system prompt at the default cap, for a caller that never changes it.
141
168
  - `preselectInput` — The user message for a preselection call: the catalogue, then the request.
142
169
  - `preselection` — Resolves a preselection against the catalogue: unknown names dropped, count capped.
170
+ - `preselectSystem` — The system prompt a preselector is given, holding it to the cap its answer will be held to.
143
171
  - `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.4.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",