@cubicecho/agent-core 2.8.0 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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 —
@@ -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/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";
@@ -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
@@ -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.8.1",
4
4
  "description": "The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.",
5
5
  "keywords": [
6
6
  "openai",