@cubicecho/agent-core 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -307,7 +307,10 @@ Found calls are run as `call_recovered_0` onward, the text is what is left, `onT
307
307
  result see the turn that way, and a notice says so, since the real fix is the server's parser.
308
308
 
309
309
  With `toolDiscovery: "ondemand"` and a catalogue, the request declares `load_tools` and what has
310
- been loaded, and the catalogue rides on the system prompt marked with what is. A model that calls
310
+ been loaded, appended in the order it was loaded, and the catalogue rides on the system prompt
311
+ unmarked, the same text on every step. Marking loads there rewrote the head of the prompt and lost
312
+ the prompt cache for the whole transcript on each one; a model that loads a tool twice is told in
313
+ the `load_tools` result that it already has it. A model that calls
311
314
  a catalogued tool without loading it first is right about what it wants, and gets it loaded and
312
315
  run. A preselection shapes the first step alone: those tools, no catalogue, no `load_tools` —
313
316
  a model with the menu still in front of it shops, reloading what it has or picking a sibling —
@@ -501,6 +504,18 @@ Either one given something that is not a number above zero keeps what was there,
501
504
  `configureEvents` does, so a `0` threaded through for "no opinion" does not switch recall off.
502
505
  `resetHooks` (and `resetAll`) puts the default back.
503
506
 
507
+ The preface said above the blocks moves the same way. `HOOK_PREFACE` names no host, so a host that
508
+ wants its own name says so once with `configureHooks({ preface })`, and a `preface` passed to
509
+ `withContext` (or on `runAgentLoop`'s `hooks`) wins over it for one request. An empty string is a
510
+ preface of nothing — the blocks lead the question on their own, with no blank line above them —
511
+ and anything that is not a string keeps what was there:
512
+
513
+ ```ts
514
+ configureHooks({ preface: "Added by my-host's hooks — background, not the user's words:" });
515
+ const request = withContext(messages, messages.length - 1, gathered.context); // says it
516
+ withContext(messages, messages.length - 1, gathered.context, ""); // says nothing
517
+ ```
518
+
504
519
  Neither function rejects. A hook failing is an outcome, and a runner that throws outright is
505
520
  noted once for its event and costs only that event's context. `notify` takes no signal: a reader
506
521
  who leaves once the turn is answered has not asked for it not to be remembered.
@@ -105,7 +105,7 @@ export interface AgentLoopHooks {
105
105
  events?: readonly HookEvent[];
106
106
  /** The shared context budget. Absent is `configureHooks`'s. */
107
107
  maxTokens?: number;
108
- /** Said above the context blocks. Absent is `HOOK_PREFACE`. */
108
+ /** Said above the context blocks. Absent is `configureHooks`'s; empty is none. */
109
109
  preface?: string;
110
110
  /** Hears each note, from before the request and from `afterTurn`. */
111
111
  onNote?: (note: HookNote) => void;
@@ -192,9 +192,10 @@ export interface AgentLoopResult {
192
192
  * whatever `runTurn` throws — `ContextOverflow` among them, however it was found out.
193
193
  *
194
194
  * On-demand loading is handled here, `load_tools` and all: the catalogue rides on the system
195
- * prompt and marks what is loaded, a catalogued tool called without being loaded is loaded and
196
- * run rather than refused, and a preselection shapes the first step. A turn cut off at
197
- * `maxTokens` is said so as a notice, because it otherwise reads exactly like a finished one.
195
+ * prompt unchanged from step to step, loaded tools are appended to the tool array in load order,
196
+ * a catalogued tool called without being loaded is loaded and run rather than refused, and a
197
+ * preselection shapes the first step. A turn cut off at `maxTokens` is said so as a notice,
198
+ * because it otherwise reads exactly like a finished one.
198
199
  *
199
200
  * @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
200
201
  * and cancellation. See `AgentLoopOptions`.
@@ -6,7 +6,7 @@ import { runTurn } from "./run-turn.js";
6
6
  import { relaxTools, sanitizeTools } from "./schema-compat.js";
7
7
  import { askJson, tryAsk } from "./side-task.js";
8
8
  import { parseToolArguments, recoverToolCalls } from "./tool-calls.js";
9
- import { catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_PER_LOAD, PRESELECT_SCHEMA, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
9
+ import { catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_PER_LOAD, PRESELECT_SCHEMA, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
10
10
  /**
11
11
  * The loop above a turn: send, run the tools the model asked for, send again, until it stops
12
12
  * asking.
@@ -130,9 +130,10 @@ const accumulate = (total, turn) => {
130
130
  * whatever `runTurn` throws — `ContextOverflow` among them, however it was found out.
131
131
  *
132
132
  * On-demand loading is handled here, `load_tools` and all: the catalogue rides on the system
133
- * prompt and marks what is loaded, a catalogued tool called without being loaded is loaded and
134
- * run rather than refused, and a preselection shapes the first step. A turn cut off at
135
- * `maxTokens` is said so as a notice, because it otherwise reads exactly like a finished one.
133
+ * prompt unchanged from step to step, loaded tools are appended to the tool array in load order,
134
+ * a catalogued tool called without being loaded is loaded and run rather than refused, and a
135
+ * preselection shapes the first step. A turn cut off at `maxTokens` is said so as a notice,
136
+ * because it otherwise reads exactly like a finished one.
136
137
  *
137
138
  * @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
138
139
  * and cancellation. See `AgentLoopOptions`.
@@ -150,7 +151,15 @@ export async function runAgentLoop(options) {
150
151
  for (const name of preselected)
151
152
  loaded.add(name);
152
153
  const used = new Set();
153
- const byName = (names) => tools.filter((tool) => tool.type === "function" && names.has(tool.function.name));
154
+ const definitions = new Map();
155
+ for (const tool of tools) {
156
+ if (tool.type === "function" && !definitions.has(tool.function.name)) {
157
+ definitions.set(tool.function.name, tool);
158
+ }
159
+ }
160
+ // In the order the names are given, not the order of `tools`: `loaded` is a set, which iterates
161
+ // in the order things were added, so a load appends and never reshuffles what went before.
162
+ const byName = (names) => [...names].flatMap((name) => definitions.get(name) ?? []);
154
163
  let messages = [...options.messages];
155
164
  // Held by reference rather than by index, so a `beforeStep` that folds the head into a summary
156
165
  // moves the question without losing it — and one that summarises the question away takes the
@@ -176,9 +185,12 @@ export async function runAgentLoop(options) {
176
185
  const declared = routed
177
186
  ? byName(new Set(preselected))
178
187
  : onDemand
179
- ? [LOAD_TOOLS_DEFINITION, ...byName(loaded)]
188
+ ? loadedTools([LOAD_TOOLS_DEFINITION], byName(loaded))
180
189
  : tools;
181
- const prompt = onDemand && !routed ? `${system}\n\n${catalogPrompt(catalog, loaded)}`.trim() : system;
190
+ // Unmarked, so the system prompt is the same text on every step and a load does not throw
191
+ // away the cache for the whole transcript. What is loaded is said in `declared` and in the
192
+ // `load_tools` result instead. The preselected first step is the one exception, by design.
193
+ const prompt = onDemand && !routed ? `${system}\n\n${catalogPrompt(catalog)}`.trim() : system;
182
194
  const request = [
183
195
  ...(prompt ? [{ role: "system", content: prompt }] : []),
184
196
  ...withContext(messages, question ? messages.indexOf(question) : -1, gathered.context, hooks?.preface),
@@ -293,9 +305,9 @@ export async function runAgentLoop(options) {
293
305
  throw unreadable;
294
306
  if (onDemand && name === LOAD_TOOLS) {
295
307
  const resolved = expandNames(requestedNames(args), catalog);
308
+ content = loadResult(resolved, catalog, loaded);
296
309
  for (const hit of resolved.matched)
297
310
  loaded.add(hit);
298
- content = loadResult(resolved, catalog);
299
311
  ok = resolved.matched.length > 0;
300
312
  }
301
313
  else {
package/dist/hooks.d.ts CHANGED
@@ -129,6 +129,12 @@ export interface Gathered {
129
129
  * `configureHooks` moves it for a process, and `gather` and `assembleContext` for one request.
130
130
  */
131
131
  export declare const HOOK_CONTEXT_TOKENS = 2000;
132
+ /**
133
+ * Said once, above the blocks, so the model reads them as background rather than instructions.
134
+ * Names no host; `configureHooks` sets another for a process that wants to, and `withContext`
135
+ * for one request.
136
+ */
137
+ export declare const HOOK_PREFACE: string;
132
138
  /** What hooks are held to across a process. Every field optional; see `configureHooks`. */
133
139
  export interface HookOptions {
134
140
  /**
@@ -136,31 +142,34 @@ export interface HookOptions {
136
142
  * held to its own `maxTokens` inside it.
137
143
  */
138
144
  contextTokens?: number;
145
+ /**
146
+ * Said above the context blocks, when a call does not give its own. Empty says nothing, and the
147
+ * blocks lead the question on their own.
148
+ */
149
+ preface?: string;
139
150
  }
140
151
  /**
141
152
  * Changes what hooks are held to, for a process whose windows are not the size these defaults
142
- * were chosen for.
153
+ * were chosen for, or whose host wants its own name above the context.
143
154
  *
144
- * Module-level for the same reason `configureEvents` is: a budget is a deployment's setting, said
145
- * once at startup. A caller that sizes it per model or per agent — a 128k window can afford more
146
- * recall than an 8k one — passes `maxTokens` to `gather` instead, which wins over this.
155
+ * Module-level for the same reason `configureEvents` is: a budget and a preface are a deployment's
156
+ * settings, said once at startup. A caller that sizes the budget per model or per agent — a 128k
157
+ * window can afford more recall than an 8k one — passes `maxTokens` to `gather` instead, and a
158
+ * `preface` passed to `withContext` or `runAgentLoop`'s hooks wins over this one the same way.
147
159
  *
148
- * @param options The limits to change. A field left out or given anything that is not a number
149
- * above zero keeps what it has, so a half-built config narrows nothing. `Infinity` is a number
150
- * above zero, and lifts the shared budget entirely.
160
+ * @param options The settings to change. A field left out keeps what it has, and so does one given
161
+ * the wrong kind of value `contextTokens` anything but a number above zero, `preface` anything
162
+ * but a string so a half-built config narrows nothing. `Infinity` is a number above zero, and
163
+ * lifts the shared budget entirely. An empty `preface` is a string, and turns the preface off.
151
164
  * @returns Everything in force afterwards, including what this call did not change.
152
165
  */
153
166
  export declare function configureHooks(options?: HookOptions): Required<HookOptions>;
154
167
  /**
155
- * Test seam: puts `configureHooks` back to the defaults, so one test's budget is not the next's.
168
+ * Test seam: puts `configureHooks` back to the defaults, so one test's budget or preface is not the
169
+ * next's.
156
170
  * `resetAll` calls it.
157
171
  */
158
172
  export declare const resetHooks: () => void;
159
- /**
160
- * Said once, above the blocks, so the model reads them as background rather than instructions.
161
- * Names no host; `withContext` takes another for one that wants to.
162
- */
163
- export declare const HOOK_PREFACE: string;
164
173
  /**
165
174
  * Builds the context a set of outcomes adds and the notes that go with it.
166
175
  *
@@ -192,7 +201,8 @@ export declare function assembleContext(outcomes: readonly HookOutcome[], maxTok
192
201
  * session once a compaction has folded the head into a summary. Anything but a user message there
193
202
  * leaves the request as it was.
194
203
  * @param context What `assembleContext` built. Empty returns `history` itself.
195
- * @param preface Said above the blocks. Defaults to `HOOK_PREFACE`.
204
+ * @param preface Said above the blocks. Absent is what `configureHooks` last set — `HOOK_PREFACE`
205
+ * unless something moved it. Empty says nothing, rather than leaving a blank line where it was.
196
206
  * @returns `history` when there was nothing to add or nowhere to add it, otherwise a new array.
197
207
  */
198
208
  export declare function withContext(history: OpenAI.ChatCompletionMessageParam[], index: number, context: string, preface?: string): OpenAI.ChatCompletionMessageParam[];
package/dist/hooks.js CHANGED
@@ -23,49 +23,58 @@ export const INJECT_EVENTS = new Set(["sessionStart", "beforeTurn"]);
23
23
  * `configureHooks` moves it for a process, and `gather` and `assembleContext` for one request.
24
24
  */
25
25
  export const HOOK_CONTEXT_TOKENS = 2000;
26
- /** The numbers this module was written with. */
27
- const HOOK_DEFAULTS = { contextTokens: HOOK_CONTEXT_TOKENS };
26
+ /**
27
+ * Said once, above the blocks, so the model reads them as background rather than instructions.
28
+ * Names no host; `configureHooks` sets another for a process that wants to, and `withContext`
29
+ * for one request.
30
+ */
31
+ export const HOOK_PREFACE = "The <context> blocks below were added for this message by the host's hooks. They are " +
32
+ "background the user did not write and may not be relevant. The user's message follows them.";
33
+ /** The settings this module was written with. */
34
+ const HOOK_DEFAULTS = {
35
+ contextTokens: HOOK_CONTEXT_TOKENS,
36
+ preface: HOOK_PREFACE,
37
+ };
28
38
  /** What is in force now. Read where it is used, so a change applies from the next request. */
29
- let hookLimits = { ...HOOK_DEFAULTS };
39
+ let hookSettings = { ...HOOK_DEFAULTS };
30
40
  /**
31
41
  * Changes what hooks are held to, for a process whose windows are not the size these defaults
32
- * were chosen for.
42
+ * were chosen for, or whose host wants its own name above the context.
33
43
  *
34
- * Module-level for the same reason `configureEvents` is: a budget is a deployment's setting, said
35
- * once at startup. A caller that sizes it per model or per agent — a 128k window can afford more
36
- * recall than an 8k one — passes `maxTokens` to `gather` instead, which wins over this.
44
+ * Module-level for the same reason `configureEvents` is: a budget and a preface are a deployment's
45
+ * settings, said once at startup. A caller that sizes the budget per model or per agent — a 128k
46
+ * window can afford more recall than an 8k one — passes `maxTokens` to `gather` instead, and a
47
+ * `preface` passed to `withContext` or `runAgentLoop`'s hooks wins over this one the same way.
37
48
  *
38
- * @param options The limits to change. A field left out or given anything that is not a number
39
- * above zero keeps what it has, so a half-built config narrows nothing. `Infinity` is a number
40
- * above zero, and lifts the shared budget entirely.
49
+ * @param options The settings to change. A field left out keeps what it has, and so does one given
50
+ * the wrong kind of value `contextTokens` anything but a number above zero, `preface` anything
51
+ * but a string so a half-built config narrows nothing. `Infinity` is a number above zero, and
52
+ * lifts the shared budget entirely. An empty `preface` is a string, and turns the preface off.
41
53
  * @returns Everything in force afterwards, including what this call did not change.
42
54
  */
43
55
  export function configureHooks(options = {}) {
44
- for (const [name, value] of Object.entries(options)) {
45
- if (typeof value === "number" && value > 0)
46
- hookLimits[name] = value;
56
+ const { contextTokens, preface } = options;
57
+ if (typeof contextTokens === "number" && contextTokens > 0) {
58
+ hookSettings.contextTokens = contextTokens;
47
59
  }
48
- return { ...hookLimits };
60
+ if (typeof preface === "string")
61
+ hookSettings.preface = preface;
62
+ return { ...hookSettings };
49
63
  }
50
64
  /**
51
- * Test seam: puts `configureHooks` back to the defaults, so one test's budget is not the next's.
65
+ * Test seam: puts `configureHooks` back to the defaults, so one test's budget or preface is not the
66
+ * next's.
52
67
  * `resetAll` calls it.
53
68
  */
54
69
  export const resetHooks = () => {
55
- hookLimits = { ...HOOK_DEFAULTS };
70
+ hookSettings = { ...HOOK_DEFAULTS };
56
71
  };
57
72
  /**
58
73
  * The budget a call is held to: its own when it gave a usable one, the process's otherwise. The
59
74
  * same rule `configureHooks` applies, so a `0` threaded through for "no opinion" does not quietly
60
75
  * turn every hook's context off.
61
76
  */
62
- const budget = (given) => typeof given === "number" && given > 0 ? given : hookLimits.contextTokens;
63
- /**
64
- * Said once, above the blocks, so the model reads them as background rather than instructions.
65
- * Names no host; `withContext` takes another for one that wants to.
66
- */
67
- export const HOOK_PREFACE = "The <context> blocks below were added for this message by the host's hooks. They are " +
68
- "background the user did not write and may not be relevant. The user's message follows them.";
77
+ const budget = (given) => typeof given === "number" && given > 0 ? given : hookSettings.contextTokens;
69
78
  const attribute = (text) => text.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
70
79
  /**
71
80
  * Builds the context a set of outcomes adds and the notes that go with it.
@@ -124,14 +133,15 @@ export function assembleContext(outcomes, maxTokens) {
124
133
  * session once a compaction has folded the head into a summary. Anything but a user message there
125
134
  * leaves the request as it was.
126
135
  * @param context What `assembleContext` built. Empty returns `history` itself.
127
- * @param preface Said above the blocks. Defaults to `HOOK_PREFACE`.
136
+ * @param preface Said above the blocks. Absent is what `configureHooks` last set — `HOOK_PREFACE`
137
+ * unless something moved it. Empty says nothing, rather than leaving a blank line where it was.
128
138
  * @returns `history` when there was nothing to add or nowhere to add it, otherwise a new array.
129
139
  */
130
- export function withContext(history, index, context, preface = HOOK_PREFACE) {
140
+ export function withContext(history, index, context, preface = hookSettings.preface) {
131
141
  const message = history[index];
132
142
  if (!context || message?.role !== "user")
133
143
  return history;
134
- const lead = `${preface}\n\n${context}\n\n`;
144
+ const lead = preface ? `${preface}\n\n${context}\n\n` : `${context}\n\n`;
135
145
  const content = typeof message.content === "string"
136
146
  ? `${lead}${message.content}`
137
147
  : [{ type: "text", text: lead }, ...message.content];
package/dist/index.d.ts CHANGED
@@ -28,4 +28,4 @@ export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type Turn
28
28
  export { ALL_FENCES, DEFAULT_FENCES, type Fence, FenceSplitter, type FenceSplitterOptions, type Split, stripThinking, THINK_FENCE, } from "./thinking.ts";
29
29
  export { estimateTokens } from "./tokens.ts";
30
30
  export { parseToolArguments, recoverToolCalls, ToolArgumentsError, type ToolCall, } from "./tool-calls.ts";
31
- export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.ts";
31
+ export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.ts";
package/dist/index.js CHANGED
@@ -26,4 +26,4 @@ export { streamTurn, } from "./stream.js";
26
26
  export { ALL_FENCES, DEFAULT_FENCES, FenceSplitter, stripThinking, THINK_FENCE, } from "./thinking.js";
27
27
  export { estimateTokens } from "./tokens.js";
28
28
  export { parseToolArguments, recoverToolCalls, ToolArgumentsError, } from "./tool-calls.js";
29
- export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
29
+ export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
package/dist/reset.d.ts CHANGED
@@ -4,9 +4,9 @@
4
4
  * Five modules here keep state for the life of the process, each for a good reason and each
5
5
  * with its own seam: the pooled clients and their model listings, the endpoints that turned
6
6
  * out not to take `stream_options` or a grammar, the models that refused the no-thinking
7
- * hints, the event bus, and the hooks' configured budget. `resetClients`, `resetCapabilities`,
8
- * `resetHints`, `resetEvents` and `resetHooks` stay exported, because a test that means to clear
9
- * one thing should say so.
7
+ * hints, the event bus, and the hooks' configured budget and preface. `resetClients`,
8
+ * `resetCapabilities`, `resetHints`, `resetEvents` and `resetHooks` stay exported, because a
9
+ * test that means to clear one thing should say so.
10
10
  *
11
11
  * This is for the other case, which is every teardown. What they hold is *latched
12
12
  * refusals* — a fact one test taught the process about an endpoint, still true as far as the
package/dist/reset.js CHANGED
@@ -9,9 +9,9 @@ import { resetHints } from "./side-task.js";
9
9
  * Five modules here keep state for the life of the process, each for a good reason and each
10
10
  * with its own seam: the pooled clients and their model listings, the endpoints that turned
11
11
  * out not to take `stream_options` or a grammar, the models that refused the no-thinking
12
- * hints, the event bus, and the hooks' configured budget. `resetClients`, `resetCapabilities`,
13
- * `resetHints`, `resetEvents` and `resetHooks` stay exported, because a test that means to clear
14
- * one thing should say so.
12
+ * hints, the event bus, and the hooks' configured budget and preface. `resetClients`,
13
+ * `resetCapabilities`, `resetHints`, `resetEvents` and `resetHooks` stay exported, because a
14
+ * test that means to clear one thing should say so.
15
15
  *
16
16
  * This is for the other case, which is every teardown. What they hold is *latched
17
17
  * refusals* — a fact one test taught the process about an endpoint, still true as far as the
@@ -23,28 +23,49 @@ export declare const LOAD_TOOLS = "load_tools";
23
23
  */
24
24
  export declare const LOAD_TOOLS_DEFINITION: OpenAI.ChatCompletionTool;
25
25
  /**
26
- * The catalogue as a plain grouped listing of names, loaded ones marked.
26
+ * The catalogue as a plain grouped listing of names, loaded ones marked if asked.
27
27
  *
28
28
  * A server with no tools is dropped rather than titled: a pool hands one over whenever a
29
29
  * server is connected but has nothing to offer, and a label with nothing under it reads as a
30
30
  * listing that got cut off.
31
31
  *
32
32
  * @param catalog The connected servers. Ones with no tools are dropped.
33
- * @param loaded Names already loaded, marked in the listing rather than removed from it.
33
+ * @param loaded Names to mark `(loaded)` rather than remove. Absent marks nothing, which keeps the
34
+ * listing the same text for the whole run.
34
35
  */
35
36
  export declare function catalogList(catalog: CatalogServer[], loaded?: ReadonlySet<string>): string;
36
37
  /**
37
38
  * The catalogue block appended to the system prompt. Names only — descriptions arrive on load.
38
39
  *
39
- * Loaded tools stay in the list, marked. Removing them reads as the tool having vanished the
40
- * moment it was loaded, and the model loads again to get it back; hoisting them into a separate
41
- * "already loaded" section splits a server's tools apart, and the model picks a sibling from
42
- * the longer list instead.
40
+ * `runAgentLoop` passes no `loaded`, so the block is the same text on every step. The system
41
+ * prompt is the head of the request, and marking each load there threw away the prompt cache for
42
+ * the whole transcript on every `load_tools` call. What is loaded is said where it does not move
43
+ * the prefix instead: in the tool array, appended in load order (`loadedTools`), and in the
44
+ * `load_tools` result, which answers a repeat load with "already loaded" (`loadResult`).
45
+ *
46
+ * Loaded tools are never removed from the list. That reads as the tool having vanished the moment
47
+ * it was loaded, and the model loads again to get it back; hoisting them into a separate "already
48
+ * loaded" section splits a server's tools apart, and the model picks a sibling from the longer
49
+ * list instead.
43
50
  *
44
51
  * @param catalog The connected servers. A catalogue with no tools in it produces an empty string.
45
- * @param loaded Names already loaded, marked in the listing.
52
+ * @param loaded Names to mark `(loaded)`, for a caller that rebuilds its prompt per load and does
53
+ * not mind the cache. Absent marks nothing.
46
54
  */
47
55
  export declare function catalogPrompt(catalog: CatalogServer[], loaded?: ReadonlySet<string>): string;
56
+ /**
57
+ * A tool array with newly loaded definitions appended, in the order they were loaded.
58
+ *
59
+ * Never re-sorted and never rebuilt from a set. A template renders the tool array into the
60
+ * prompt near its head, and a load that moved an earlier definition moved everything after it,
61
+ * so the cache was lost from there on every load; appended, the definitions already sent stay
62
+ * a prefix of the new array.
63
+ *
64
+ * @param previous What the last request declared, `load_tools` included. Not written to.
65
+ * @param matched The definitions to add. Ones whose name is already declared, here or earlier in
66
+ * this list, are skipped rather than moved.
67
+ */
68
+ export declare function loadedTools(previous: readonly OpenAI.ChatCompletionTool[], matched: readonly OpenAI.ChatCompletionTool[]): OpenAI.ChatCompletionTool[];
48
69
  /**
49
70
  * The most a single `load_tools` call may pull in.
50
71
  *
@@ -103,10 +124,15 @@ export declare function expandNames(requested: string[], catalog: CatalogServer[
103
124
  /**
104
125
  * What `load_tools` reports back: the descriptions, now that they are worth their tokens.
105
126
  *
127
+ * A name that was loaded before this call is reported as already loaded rather than loaded
128
+ * again. The catalogue no longer marks what is loaded — see `catalogPrompt` — so this is where a
129
+ * model that asks twice finds out it need not have, and is told to call the tool instead.
130
+ *
106
131
  * @param expanded What `expandNames` resolved: the matches, the misses, and the over-broad asks.
107
132
  * @param catalog The servers, read for the descriptions now worth their tokens.
133
+ * @param loaded What was loaded before this call. Absent reports every match as newly loaded.
108
134
  */
109
- export declare function loadResult({ matched, unknown, overBroad, deferred, maxPerLoad }: ReturnType<typeof expandNames>, catalog: CatalogServer[]): string;
135
+ export declare function loadResult({ matched, unknown, overBroad, deferred, maxPerLoad }: ReturnType<typeof expandNames>, catalog: CatalogServer[], loaded?: ReadonlySet<string>): string;
110
136
  /**
111
137
  * Whether the catalogue holds a tool by this name.
112
138
  *
@@ -49,14 +49,15 @@ export const LOAD_TOOLS_DEFINITION = deepFreeze({
49
49
  },
50
50
  });
51
51
  /**
52
- * The catalogue as a plain grouped listing of names, loaded ones marked.
52
+ * The catalogue as a plain grouped listing of names, loaded ones marked if asked.
53
53
  *
54
54
  * A server with no tools is dropped rather than titled: a pool hands one over whenever a
55
55
  * server is connected but has nothing to offer, and a label with nothing under it reads as a
56
56
  * listing that got cut off.
57
57
  *
58
58
  * @param catalog The connected servers. Ones with no tools are dropped.
59
- * @param loaded Names already loaded, marked in the listing rather than removed from it.
59
+ * @param loaded Names to mark `(loaded)` rather than remove. Absent marks nothing, which keeps the
60
+ * listing the same text for the whole run.
60
61
  */
61
62
  export function catalogList(catalog, loaded) {
62
63
  return catalog
@@ -70,13 +71,20 @@ export function catalogList(catalog, loaded) {
70
71
  /**
71
72
  * The catalogue block appended to the system prompt. Names only — descriptions arrive on load.
72
73
  *
73
- * Loaded tools stay in the list, marked. Removing them reads as the tool having vanished the
74
- * moment it was loaded, and the model loads again to get it back; hoisting them into a separate
75
- * "already loaded" section splits a server's tools apart, and the model picks a sibling from
76
- * the longer list instead.
74
+ * `runAgentLoop` passes no `loaded`, so the block is the same text on every step. The system
75
+ * prompt is the head of the request, and marking each load there threw away the prompt cache for
76
+ * the whole transcript on every `load_tools` call. What is loaded is said where it does not move
77
+ * the prefix instead: in the tool array, appended in load order (`loadedTools`), and in the
78
+ * `load_tools` result, which answers a repeat load with "already loaded" (`loadResult`).
79
+ *
80
+ * Loaded tools are never removed from the list. That reads as the tool having vanished the moment
81
+ * it was loaded, and the model loads again to get it back; hoisting them into a separate "already
82
+ * loaded" section splits a server's tools apart, and the model picks a sibling from the longer
83
+ * list instead.
77
84
  *
78
85
  * @param catalog The connected servers. A catalogue with no tools in it produces an empty string.
79
- * @param loaded Names already loaded, marked in the listing.
86
+ * @param loaded Names to mark `(loaded)`, for a caller that rebuilds its prompt per load and does
87
+ * not mind the cache. Absent marks nothing.
80
88
  */
81
89
  export function catalogPrompt(catalog, loaded) {
82
90
  const list = catalogList(catalog, loaded);
@@ -88,14 +96,39 @@ export function catalogPrompt(catalog, loaded) {
88
96
  "# Tool catalogue",
89
97
  "",
90
98
  "These tools exist but are not loaded. Call `load_tools` with the names you need, then call",
91
- "them on the step after. Names are descriptive; load a tool to see its parameters. A name",
92
- "marked `(loaded)` is already in your tool list — call it directly, do not load it again. Do",
93
- "not load tools the task does not need, and do not mention this mechanism in your answer.",
99
+ "them on the step after. Names are descriptive; load a tool to see its parameters. A tool",
100
+ "already in your tool list is loaded — call it directly, do not load it again. Do not load",
101
+ "tools the task does not need, and do not mention this mechanism in your answer.",
94
102
  "",
95
103
  list,
96
104
  ].join("\n");
97
105
  }
98
106
  const flatten = (catalog) => catalog.flatMap((server) => server.tools);
107
+ /**
108
+ * A tool array with newly loaded definitions appended, in the order they were loaded.
109
+ *
110
+ * Never re-sorted and never rebuilt from a set. A template renders the tool array into the
111
+ * prompt near its head, and a load that moved an earlier definition moved everything after it,
112
+ * so the cache was lost from there on every load; appended, the definitions already sent stay
113
+ * a prefix of the new array.
114
+ *
115
+ * @param previous What the last request declared, `load_tools` included. Not written to.
116
+ * @param matched The definitions to add. Ones whose name is already declared, here or earlier in
117
+ * this list, are skipped rather than moved.
118
+ */
119
+ export function loadedTools(previous, matched) {
120
+ const nameOf = (tool) => tool.type === "function" ? tool.function.name : undefined;
121
+ const declared = new Set(previous.map(nameOf));
122
+ const tools = [...previous];
123
+ for (const tool of matched) {
124
+ const name = nameOf(tool);
125
+ if (name !== undefined && declared.has(name))
126
+ continue;
127
+ declared.add(name);
128
+ tools.push(tool);
129
+ }
130
+ return tools;
131
+ }
99
132
  /**
100
133
  * The most a single `load_tools` call may pull in.
101
134
  *
@@ -201,17 +234,29 @@ export function expandNames(requested, catalog, maxPerLoad = MAX_PER_LOAD) {
201
234
  /**
202
235
  * What `load_tools` reports back: the descriptions, now that they are worth their tokens.
203
236
  *
237
+ * A name that was loaded before this call is reported as already loaded rather than loaded
238
+ * again. The catalogue no longer marks what is loaded — see `catalogPrompt` — so this is where a
239
+ * model that asks twice finds out it need not have, and is told to call the tool instead.
240
+ *
204
241
  * @param expanded What `expandNames` resolved: the matches, the misses, and the over-broad asks.
205
242
  * @param catalog The servers, read for the descriptions now worth their tokens.
243
+ * @param loaded What was loaded before this call. Absent reports every match as newly loaded.
206
244
  */
207
- export function loadResult({ matched, unknown, overBroad, deferred, maxPerLoad }, catalog) {
245
+ export function loadResult({ matched, unknown, overBroad, deferred, maxPerLoad }, catalog, loaded) {
208
246
  const byName = new Map(flatten(catalog).map((tool) => [tool.name, tool.description]));
209
247
  const lines = [];
210
- if (matched.length) {
211
- lines.push(`Loaded ${matched.length} tool(s); they are callable on your next step.`, "");
212
- for (const name of matched)
248
+ const fresh = matched.filter((name) => !loaded?.has(name));
249
+ const again = matched.filter((name) => loaded?.has(name));
250
+ if (fresh.length) {
251
+ lines.push(`Loaded ${fresh.length} tool(s); they are callable on your next step.`, "");
252
+ for (const name of fresh)
213
253
  lines.push(`${name}: ${byName.get(name) ?? ""}`.trim());
214
254
  }
255
+ if (again.length) {
256
+ if (lines.length)
257
+ lines.push("");
258
+ lines.push(`Already loaded and in your tool list: ${again.join(", ")}. Call them directly; do not load them again.`);
259
+ }
215
260
  for (const { name, hits } of overBroad) {
216
261
  if (lines.length)
217
262
  lines.push("");
package/llms.txt CHANGED
@@ -108,7 +108,7 @@ What a run is doing, while it is doing it.
108
108
  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.
109
109
 
110
110
  - `assembleContext` — Builds the context a set of outcomes adds and the notes that go with it.
111
- - `configureHooks` — Changes what hooks are held to, for a process whose windows are not the size these defaults were chosen for.
111
+ - `configureHooks` — Changes what hooks are held to, for a process whose windows are not the size these defaults were chosen for, or whose host wants its own name above the context.
112
112
  - `Gathered` (type) — The context a set of outcomes adds to a request, and a note for each hook worth mentioning.
113
113
  - `gather` — Runs the hooks ahead of a request and builds what they add to it.
114
114
  - `HOOK_CONTEXT_TOKENS` — The most context all of a request's hooks add between them by default, in estimated tokens.
@@ -123,7 +123,7 @@ Lifecycle hooks, from the host's side: what a session looks like to them, where
123
123
  - `HookRunner` (type) — Runs one event's hooks.
124
124
  - `INJECT_EVENTS` — The events whose hooks run before a request, and so the only ones whose output can reach it.
125
125
  - `notify` — Runs the hooks for an event that reads what happened and adds nothing to a request.
126
- - `resetHooks` — Test seam: puts `configureHooks` back to the defaults, so one test's budget is not the next's.
126
+ - `resetHooks` — Test seam: puts `configureHooks` back to the defaults, so one test's budget or preface is not the next's.
127
127
  - `turnIndex` — Which turn of a session begins at a point, from 0: the user messages ahead of it.
128
128
  - `turnMessages` — A stretch of a transcript as a hook reads it: what the user and the assistant said, and nothing else.
129
129
  - `UNTRUSTED_PREFACE` — One sentence for a system prompt, saying what an `untrusted` block is.
@@ -231,12 +231,13 @@ Reading what a model meant by a tool call when it did not write one cleanly.
231
231
  ### tool-loading
232
232
 
233
233
  - `carryOver` — The tools to start the next turn with: recently used, newest last, capped.
234
- - `catalogList` — The catalogue as a plain grouped listing of names, loaded ones marked.
234
+ - `catalogList` — The catalogue as a plain grouped listing of names, loaded ones marked if asked.
235
235
  - `catalogPrompt` — The catalogue block appended to the system prompt.
236
236
  - `expandNames` — Resolves requested names against the catalogue, expanding trailing `*` wildcards.
237
237
  - `inCatalog` — Whether the catalogue holds a tool by this name.
238
238
  - `LOAD_TOOLS` — On-demand tool loading.
239
239
  - `LOAD_TOOLS_DEFINITION` — One object for the life of the process — the agent loop asks for it on every iteration.
240
+ - `loadedTools` — A tool array with newly loaded definitions appended, in the order they were loaded.
240
241
  - `loadResult` — What `load_tools` reports back: the descriptions, now that they are worth their tokens.
241
242
  - `MAX_CARRIED` — The most a conversation carries between turns.
242
243
  - `MAX_PER_LOAD` — The most a single `load_tools` call may pull in.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "2.8.0",
3
+ "version": "2.9.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",