@cubicecho/agent-core 4.16.0 → 4.17.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
@@ -470,6 +470,7 @@ const { turn, messages, usage, loaded } = await runAgentLoop({
470
470
  messages: history, // ending in the question; not written to
471
471
  tools, // every tool the run may reach
472
472
  catalog, // the same, name-only, for on-demand and proxied loading
473
+ hostTools, // the host's own, declared in every mode and never catalogued
473
474
  serverInstructions: true, // the catalogue's `instructions` after `system`; off by default
474
475
  preselected, // from `preselect`, if a small model chose
475
476
  dispatch: ({ name, args }, signal) => pool.call(name, args, signal),
@@ -688,6 +689,27 @@ a `tool-call` and a `tool-result` event, told to `onToolCall` and `onToolResult`
688
689
  id is numbered by where the call lands in the transcript, so two questions do not share one. A name
689
690
  with no definition in `tools`, or outside the catalogue, is left out, and none left is no exchange.
690
691
 
692
+ A host with tools of its own — a question for a person, a resource read — passes them as
693
+ `hostTools`, and they are declared on every step in every mode: after `PROXY_TOOLS` proxied, after
694
+ `load_tools` and ahead of anything loaded on demand, after `tools` in eager mode, and after the
695
+ shortlist on a preselected first step. They reach `dispatch` like any other call and are kept out
696
+ of discovery: taken out of `catalog`, `loaded` and `preselected` before the run starts, never
697
+ counted as a load, and a `call_tool` naming one is refused with a pointer to call it directly.
698
+ Proxied and eager, the array is one set for the whole run, so the cache holds whatever `toolOrder`
699
+ does with it; on demand, `toolOrder: false` keeps them in the prefix every load appends after.
700
+
701
+ ```ts
702
+ await runAgentLoop({
703
+ config: { ...config, toolDiscovery: "proxy" },
704
+ messages: history,
705
+ tools: pool.tools(),
706
+ catalog: pool.catalog(),
707
+ hostTools: [ASK_PERSON_DEFINITION], // declared beside load_tools and call_tool, on every step
708
+ dispatch: (call, signal) =>
709
+ call.name === ASK_PERSON ? ask(call.args) : pool.call(call.name, call.args, signal),
710
+ });
711
+ ```
712
+
691
713
  ### What the servers said on the way
692
714
 
693
715
  A server can log while a call runs — a warning that the cache was stale, an error it recovered
@@ -152,6 +152,23 @@ export interface AgentLoopOptions {
152
152
  * loaded so far, by name; proxied mode declares none, and answers a load with them instead.
153
153
  */
154
154
  tools?: OpenAI.ChatCompletionTool[];
155
+ /**
156
+ * The host's own tools — a question for a person, a resource read — declared on every step in
157
+ * every discovery mode and dispatched like any other call, but never catalogued, loaded, or
158
+ * reached through `call_tool`.
159
+ *
160
+ * @remarks
161
+ * Each sits after the loop's own definitions: after `PROXY_TOOLS` proxied, after `load_tools`
162
+ * and ahead of anything loaded on demand, after `tools` in eager mode, and after the shortlist
163
+ * on a preselected first step; `toolOrder` then orders the array as it always does. Proxied and
164
+ * eager, the array is one set for the whole run, so the cache holds in either order; on demand,
165
+ * `toolOrder: false` keeps them in the prefix every load appends after.
166
+ *
167
+ * A name of one is taken out of `catalog`, `loaded` and `preselected` before the run starts, is
168
+ * never counted as a load, and is declared once where `tools` or the loop has it too. A
169
+ * `call_tool` naming one is refused with a pointer to call it directly.
170
+ */
171
+ hostTools?: OpenAI.ChatCompletionTool[];
155
172
  /**
156
173
  * The same tools as a name-only catalogue. On-demand and proxied modes need it, and are eager
157
174
  * without it.
@@ -595,6 +612,8 @@ export interface AgentLoopResult {
595
612
  * tool array to go into, so it is written into the transcript as a `load_tools` exchange after
596
613
  * the question. `call_tool` is honoured on demand too, unless the host has a tool of that name —
597
614
  * a session switched out of proxied mode still has it in its history, and the model copies it.
615
+ * The host's own `hostTools` are declared beside whatever the mode sends, in every mode, and are
616
+ * never catalogued.
598
617
  *
599
618
  * Neither side task is run unless asked for. `preselect` has the loop make the preselection and
600
619
  * `compact` has it fold the transcript, each by the task `config.tasks` carries for it and on
@@ -112,6 +112,8 @@ function cacheDiagnosis(previous, next, usage) {
112
112
  * tool array to go into, so it is written into the transcript as a `load_tools` exchange after
113
113
  * the question. `call_tool` is honoured on demand too, unless the host has a tool of that name —
114
114
  * a session switched out of proxied mode still has it in its history, and the model copies it.
115
+ * The host's own `hostTools` are declared beside whatever the mode sends, in every mode, and are
116
+ * never catalogued.
115
117
  *
116
118
  * Neither side task is run unless asked for. `preselect` has the loop make the preselection and
117
119
  * `compact` has it fold the transcript, each by the task `config.tasks` carries for it and on
@@ -133,9 +135,10 @@ export async function runAgentLoop(options) {
133
135
  }),
134
136
  };
135
137
  let result;
138
+ const apart = hostToolsApart(options);
136
139
  try {
137
- pin = await pinSession(options);
138
- result = await runSteps(options, standing, pin);
140
+ pin = await pinSession(apart);
141
+ result = await runSteps(apart, standing, pin);
139
142
  }
140
143
  catch (error) {
141
144
  // Wrapped rather than annotated: an abort's reason is one object shared by every run under
@@ -399,6 +402,43 @@ function recorder(heard) {
399
402
  };
400
403
  return { recorded, onEvent };
401
404
  }
405
+ /**
406
+ * The names of the host's own tools.
407
+ *
408
+ * @param [tools] - The host's definitions. One that is not a function has no name, and is left out.
409
+ * @returns A new set, empty where there are none.
410
+ */
411
+ const hostToolNames = (tools = []) => new Set(tools.flatMap((tool) => toolName(tool) ?? []));
412
+ /**
413
+ * The run's options with the host's own tools kept out of discovery: off the catalogue, and out of
414
+ * what was carried and what was picked.
415
+ *
416
+ * @param options - What `runAgentLoop` was given. Not written to.
417
+ * @returns `options` itself where there are no host tools, and otherwise a copy with `catalog`,
418
+ * `loaded` and `preselected` filtered, a server that offers none of them kept as it was.
419
+ *
420
+ * @remarks
421
+ * Done once, here, so that preselection, `load_tools`, `call_tool` and the catalogue prompt all
422
+ * read one catalogue, and a host tool is never loadable however a name of one reaches the loop.
423
+ */
424
+ function hostToolsApart(options) {
425
+ const own = hostToolNames(options.hostTools);
426
+ if (own.size === 0) {
427
+ return options;
428
+ }
429
+ const isCatalogued = (name) => own.has(name) === false;
430
+ const { catalog, loaded, preselected } = options;
431
+ const apart = (server) => server.tools.every((entry) => isCatalogued(entry.name))
432
+ ? server
433
+ : { ...server, tools: server.tools.filter((entry) => isCatalogued(entry.name)) };
434
+ return {
435
+ ...options,
436
+ ...(catalog ? { catalog: catalog.map(apart) } : {}),
437
+ ...(loaded ? { loaded: [...loaded].filter(isCatalogued) } : {}),
438
+ // Undefined stays undefined: an empty shortlist is a host saying it has already decided.
439
+ ...(preselected ? { preselected: preselected.filter(isCatalogued) } : {}),
440
+ };
441
+ }
402
442
  /** What opens the notice naming the loop's own picks, ahead of the names. */
403
443
  export const PRESELECTED_NOTICE = 'tools picked before the run: ';
404
444
  /**
@@ -570,11 +610,17 @@ async function runSteps(options, standing, pin) {
570
610
  const summarise = ownSummariser(options, notice);
571
611
  const used = new Set();
572
612
  const definitions = definitionsByName(tools);
613
+ // Each once, by name, so one given twice is declared once.
614
+ const hostTools = loadedTools([], options.hostTools ?? []);
615
+ const hostNames = hostToolNames(hostTools);
616
+ // After the head the mode declares, which a host tool of the same name does not displace.
617
+ const withHost = (head) => (hostTools.length ? loadedTools(head, hostTools) : head);
573
618
  // Looked through unless the host has a tool by that name, which is then the host's to answer.
574
- const proxies = onDemand && !definitions.has(CALL_TOOL);
619
+ const proxies = onDemand && !definitions.has(CALL_TOOL) && !hostNames.has(CALL_TOOL);
575
620
  // What a call the model wrote as text may name: the host's tools, and the loop's own on demand.
576
621
  const recoverable = [
577
622
  ...tools.map(toolName).filter((name) => name !== undefined),
623
+ ...hostNames,
578
624
  ...(onDemand ? [LOAD_TOOLS, ...(proxies ? [CALL_TOOL] : [])] : []),
579
625
  ];
580
626
  // In the order the names are given, not the order of `tools`: `loaded` is a set, which iterates
@@ -626,6 +672,7 @@ async function runSteps(options, standing, pin) {
626
672
  proxied,
627
673
  proxies,
628
674
  definitions,
675
+ hostNames,
629
676
  loaded,
630
677
  loads,
631
678
  used,
@@ -691,15 +738,15 @@ async function runSteps(options, standing, pin) {
691
738
  // Ordered here rather than left to `buildBody`, so `names` below is what the request actually
692
739
  // declared — a diagnosis reading an order the server never saw calls an untouched tool array
693
740
  // `tools-changed`.
694
- let offered = tools;
741
+ let offered = withHost(tools);
695
742
  if (routed) {
696
- offered = byName(new Set(preselected));
743
+ offered = withHost(byName(new Set(preselected)));
697
744
  }
698
745
  else if (proxied) {
699
- offered = [...PROXY_TOOLS];
746
+ offered = withHost([...PROXY_TOOLS]);
700
747
  }
701
748
  else if (onDemand) {
702
- offered = loadedTools([LOAD_TOOLS_DEFINITION], byName(loaded));
749
+ offered = loadedTools(withHost([LOAD_TOOLS_DEFINITION]), byName(loaded));
703
750
  }
704
751
  const declared = orderTools(offered, toolOrder);
705
752
  // Unmarked, so the system prompt is the same text on every step and a load does not throw
@@ -783,7 +830,7 @@ async function runSteps(options, standing, pin) {
783
830
  if (turn.finishReason === FinishReason.Length) {
784
831
  notice(`the model stopped at maxTokens (${config.maxTokens}); this turn is cut short`);
785
832
  }
786
- const canRecover = recover && (tools.length > 0 || onDemand);
833
+ const canRecover = recover && (tools.length > 0 || hostTools.length > 0 || onDemand);
787
834
  const { calls, content } = turnCalls(turn, canRecover ? recoverable : undefined, notice);
788
835
  const shown = { ...turn, content, toolCalls: calls };
789
836
  onTurn?.(shown, step);
@@ -86,6 +86,8 @@ export interface Calling {
86
86
  /** Whether a `call_tool` is looked through, which it is unless the host has a tool by that name. */
87
87
  proxies: boolean;
88
88
  definitions: ReadonlyMap<string, OpenAI.ChatCompletionTool>;
89
+ /** The host's own tools by name: declared on every step, and never reached through `call_tool`. */
90
+ hostNames: ReadonlySet<string>;
89
91
  loaded: Set<string>;
90
92
  loads: {
91
93
  toolsLoaded: number;
@@ -256,7 +256,12 @@ function prepare(run, entry) {
256
256
  // nothing a load could not, and the refusal is the call's answer.
257
257
  let inner = args ?? {};
258
258
  let refused;
259
- if (proxy && args) {
259
+ if (proxy && args && run.hostNames.has(name)) {
260
+ // Declared already, so the model is pointed at the call it can make rather than told the
261
+ // tool does not exist.
262
+ refused = { error: new Error(`${name} is not in the catalogue; call it directly, not through ${CALL_TOOL}.`) };
263
+ }
264
+ else if (proxy && args) {
260
265
  try {
261
266
  inner = proxiedCall(args, catalog).input;
262
267
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "4.16.0",
3
+ "version": "4.17.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",