@cubicecho/agent-core 4.6.0 → 4.7.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
@@ -449,7 +449,8 @@ const { turn, messages, usage, loaded } = await runAgentLoop({
449
449
 
450
450
  Each step is one `runTurn` with the body from `buildBody`, so everything `negotiate` answers is
451
451
  answered here too, and a request that is too big throws `ContextOverflow` whichever side found
452
- out. Between steps the loop runs the calls: sequentially by default, or together with `parallel: true`.
452
+ out. Between steps the loop runs the calls: sequentially by default, together with `parallel: true`, or
453
+ reads together and writes in order with a predicate (below).
453
454
  Either way an identical call — the same name and arguments, byte for byte — is made once and its
454
455
  answer handed to the repeat, and two still in flight share the request. A call that threw is
455
456
  forgotten rather than cached, so asking again is a real retry. What a tool throws is what the model
@@ -461,6 +462,26 @@ predicate is asked per call — which is how `send_email` opts out, since twice
461
462
  nothing in an OpenAI tool definition says which tools those are. A pool that reads the MCP
462
463
  `readOnlyHint` and `idempotentHint` annotations can answer it; this package cannot.
463
464
 
465
+ `parallel` takes a predicate for the same reason. Consecutive calls it passes run together, and a
466
+ call it fails is a barrier: it waits for every call before it and every call after waits for it,
467
+ so a read asked for after a write sees what the write did.
468
+
469
+ ```
470
+ read A, read B, write C, read D, read E → [A ‖ B] → C → [D ‖ E]
471
+ ```
472
+
473
+ Results go into the transcript in the order the model asked, and inside a batch `onToolResult` and
474
+ the events arrive as calls finish, as with `parallel: true`. The calls the loop answers itself —
475
+ `load_tools`, unreadable arguments, an identical repeat — are not put to the predicate and count
476
+ as reads. `@cubicecho/agent-mcp-pool` (3.10.0 and later) answers both predicates from the
477
+ annotations of a server whose row sets `trustAnnotations`, and the worst case — run alone, send
478
+ every time — for everything else:
479
+
480
+ ```ts
481
+ const reads = (call: ToolCallRequest) => pool.readsOnly(call.name, { servers });
482
+ await runAgentLoop({ ...options, parallel: reads, dedupeToolCalls: reads });
483
+ ```
484
+
464
485
  Three kinds of call never reach `dispatch`: `load_tools`, which the loop answers itself, a call
465
486
  whose arguments could not be read, and that identical repeat. A host that draws each call as a
466
487
  card and fills it in when the result lands hears of those through `onToolCall` and `onToolResult`,
@@ -230,10 +230,25 @@ export interface AgentLoopOptions {
230
230
  */
231
231
  dispatch: ToolDispatch;
232
232
  /**
233
- * Runs a step's calls together rather than one after another. Results still go into the
234
- * transcript in the order the model asked. See `dedupeToolCalls`, which applies either way.
235
- */
236
- parallel?: boolean;
233
+ * Runs a step's calls together rather than one after another, or — given a predicate — runs
234
+ * together the consecutive calls it passes, and each one it fails alone, in the order asked.
235
+ *
236
+ * @defaultValue `false`
237
+ *
238
+ * @remarks
239
+ * Results go into the transcript in the order the model asked, whichever way they ran. See
240
+ * `dedupeToolCalls`, which applies either way.
241
+ *
242
+ * A predicate is how a host runs reads together while its writes keep their order: a call it
243
+ * fails is a barrier, waiting for every call before it, and every call after waits for it. Read
244
+ * A, read B, write C, read D, read E runs as `[A ‖ B] → C → [D ‖ E]`, so a read asked for after
245
+ * a write sees what the write did. Calls the loop answers itself — `load_tools`, a call whose
246
+ * arguments could not be read, an identical repeat `dedupeToolCalls` answers — are not put to
247
+ * it, and count as reads. Inside a batch, `onToolResult` and the events arrive as calls finish.
248
+ * A pool that reads the MCP `readOnlyHint` annotation can answer it; nothing in an OpenAI tool
249
+ * definition can.
250
+ */
251
+ parallel?: boolean | ((call: ToolCallRequest) => boolean);
237
252
  /**
238
253
  * Answers an identical repeat of a call — the same name and the same arguments, word for word —
239
254
  * within one step from the first one, rather than dispatching it again.
@@ -93,7 +93,8 @@ export interface Calling {
93
93
  artifacts: Map<string, RunArtifact>;
94
94
  dedupable: (call: ToolCallRequest) => boolean;
95
95
  dispatch: ToolDispatch;
96
- parallel: boolean;
96
+ /** All of a step's calls together, each alone, or together in runs of the calls it passes. */
97
+ parallel: boolean | ((call: ToolCallRequest) => boolean);
97
98
  signal: AbortSignal | undefined;
98
99
  onEvent: (input: RunEventInput) => void;
99
100
  onToolCall: ((call: ToolCallRequest) => void) | undefined;
@@ -189,25 +189,21 @@ function collect(run, links, from) {
189
189
  }
190
190
  }
191
191
  /**
192
- * Runs one call to its result, telling the host of both. A tool that throws is that call's answer
193
- * rather than the run's end, unless the run was stopped.
192
+ * A call looked through and built into the request every host callback is handed.
194
193
  *
195
- * @param run - The run the call belongs to.
194
+ * @param run - The run the call belongs to. Read, never written to.
196
195
  * @param entry - The call, with its arguments read.
197
- * @param answered - The step's calls already made, by tool and arguments, for `dedupeToolCalls`.
198
- * @param step - Which step this is, for the artifacts the call's links become.
199
- * @returns The call's id, the name it was shown under, whether it worked, the whole of what the
200
- * model reads, and the links its answer carried, where it carried any. Rejects where the run was
201
- * stopped or a host callback threw.
196
+ * @returns The call, its request, and whether the loop answers it itself or may answer it from
197
+ * an identical call. `dedupeToolCalls` is asked only of a call that would reach `dispatch`.
202
198
  */
203
- async function runCall(run, entry, answered, step) {
204
- const { catalog, onDemand, proxied, signal } = run;
205
- const { call, args, error: unreadable, normal } = entry;
199
+ function prepare(run, entry) {
200
+ const { catalog, onDemand } = run;
201
+ const { call, args, normal } = entry;
206
202
  const proxy = run.proxies && call.function.name === CALL_TOOL;
207
203
  const { name, input: raw } = shownAs(run, entry);
208
204
  // Looked through before the call is announced, so a host is told of the tool it names with
209
205
  // that tool's arguments. A name outside the catalogue is refused here, so `call_tool` reaches
210
- // nothing a load could not, and the refusal is the call's answer below.
206
+ // nothing a load could not, and the refusal is the call's answer.
211
207
  let inner = args ?? {};
212
208
  let refused;
213
209
  if (proxy && args) {
@@ -222,6 +218,79 @@ async function runCall(run, entry, answered, step) {
222
218
  // Built for every call rather than only the dispatched ones, so a host hears of the calls
223
219
  // the loop answers itself in the same shape. Arguments that could not be read are none.
224
220
  const request = { id: call.id, name, args: inner, raw };
221
+ // By the name the model called, not the one a `call_tool` wraps: `load_tools` is not in
222
+ // the catalogue, and one reached through `call_tool` is refused like any other.
223
+ const load = onDemand && call.function.name === LOAD_TOOLS;
224
+ const dispatched = args !== undefined && load === false && refused === undefined;
225
+ // Keyed on the inner call, so one made through `call_tool` and one made natively share.
226
+ const key = `${name}\0${proxy ? JSON.stringify(inner) : normal}`;
227
+ return { entry, request, load, refused, dedupable: dispatched && run.dedupable(request), key };
228
+ }
229
+ /**
230
+ * Whether a call is answered without `dispatch` seeing it, so it can run beside anything.
231
+ *
232
+ * @param prepared - The call.
233
+ * @param answered - The step's calls already made, by key. A call that threw is not in it.
234
+ * @param batch - The keys of the calls in the batch being gathered, which a repeat shares.
235
+ * @returns True for a `load_tools`, a call whose arguments could not be read, a `call_tool` that
236
+ * was refused, and an identical repeat that `dedupeToolCalls` answers from the first.
237
+ */
238
+ const answersItself = ({ entry, load, refused, dedupable, key }, answered, batch) => entry.args === undefined || load || refused !== undefined || (dedupable && (answered.has(key) || batch.has(key)));
239
+ /**
240
+ * Where the batch of calls starting at `from` ends: every call, one call, or the calls up to the
241
+ * first one the host's predicate does not pass.
242
+ *
243
+ * @param run - The run, read for `parallel`.
244
+ * @param ready - The step's calls by position, prepared as they are first asked for.
245
+ * @param from - Where the batch starts.
246
+ * @param total - How many calls the step has.
247
+ * @param answered - The step's calls already made, by key.
248
+ * @returns The position after the batch's last call; always past `from`.
249
+ *
250
+ * @remarks
251
+ * A call the predicate fails is a barrier: it waits for every call before it, and runs alone, so
252
+ * a read asked for after a write sees what the write did. Calls the loop answers itself are not
253
+ * put to the predicate — they touch nothing a tool could — and count as reads.
254
+ */
255
+ function batchEnd(run, ready, from, total, answered) {
256
+ const { parallel } = run;
257
+ if (parallel === true) {
258
+ return total;
259
+ }
260
+ if (parallel === false) {
261
+ return from + 1;
262
+ }
263
+ const batch = new Set();
264
+ for (let at = from; at < total; at++) {
265
+ const prepared = ready(at);
266
+ prepared.passed ??= answersItself(prepared, answered, batch) || parallel(prepared.request);
267
+ const reads = prepared.passed;
268
+ if (reads === false) {
269
+ return at === from ? at + 1 : at;
270
+ }
271
+ if (prepared.dedupable) {
272
+ batch.add(prepared.key);
273
+ }
274
+ }
275
+ return total;
276
+ }
277
+ /**
278
+ * Runs one call to its result, telling the host of both. A tool that throws is that call's answer
279
+ * rather than the run's end, unless the run was stopped.
280
+ *
281
+ * @param run - The run the call belongs to.
282
+ * @param prepared - The call, with its request built.
283
+ * @param answered - The step's calls already made, by tool and arguments, for `dedupeToolCalls`.
284
+ * @param step - Which step this is, for the artifacts the call's links become.
285
+ * @returns The call's id, the name it was shown under, whether it worked, the whole of what the
286
+ * model reads, and the links its answer carried, where it carried any. Rejects where the run was
287
+ * stopped or a host callback threw.
288
+ */
289
+ async function runCall(run, prepared, answered, step) {
290
+ const { catalog, onDemand, proxied, signal } = run;
291
+ const { entry, request, load, refused, dedupable, key } = prepared;
292
+ const { call, args, error: unreadable } = entry;
293
+ const { name, raw } = request;
225
294
  run.onEvent({ kind: RunEventKind.ToolCall, id: call.id, name, text: preview(raw) });
226
295
  run.onToolCall?.(request);
227
296
  let content;
@@ -233,9 +302,7 @@ async function runCall(run, entry, answered, step) {
233
302
  if (!args) {
234
303
  throw unreadable;
235
304
  }
236
- // By the name the model called, not the one a `call_tool` wraps: `load_tools` is not in
237
- // the catalogue, and one reached through `call_tool` is refused below like any other.
238
- if (onDemand && call.function.name === LOAD_TOOLS) {
305
+ if (load) {
239
306
  ({ content, ok } = answerLoad(run, args));
240
307
  }
241
308
  else {
@@ -249,9 +316,6 @@ async function runCall(run, entry, answered, step) {
249
316
  run.loaded.add(name);
250
317
  }
251
318
  run.used.add(name);
252
- // Keyed on the inner call, so one made through `call_tool` and one made natively share.
253
- const key = `${name}\0${proxy ? JSON.stringify(inner) : normal}`;
254
- const dedupable = run.dedupable(request);
255
319
  // Read before `once` looks, which it does before it awaits anything.
256
320
  shared = dedupable && answered.has(key);
257
321
  const answer = dedupable
@@ -310,26 +374,29 @@ export async function runCalls(run, parsed, messages, step) {
310
374
  kept++;
311
375
  await run.announce(result, step);
312
376
  };
377
+ // Prepared when first asked for, so a predicate is put only calls the step got to.
378
+ const prepared = [];
379
+ const ready = (at) => {
380
+ prepared[at] ??= prepare(run, parsed[at]);
381
+ return prepared[at];
382
+ };
313
383
  try {
314
- if (run.parallel) {
384
+ // One batch of every call, a batch per call, or batches split at the calls the host's
385
+ // predicate does not pass; a batch's calls run together and the next waits for all of them.
386
+ let from = 0;
387
+ while (from < parsed.length) {
315
388
  signal?.throwIfAborted();
316
- begun = parsed.length;
317
- await Promise.all(parsed.map(async (call, at) => {
318
- outcomes[at] = await runCall(run, call, answered, step);
389
+ const start = from;
390
+ const to = batchEnd(run, ready, start, parsed.length, answered);
391
+ begun = to;
392
+ await Promise.all(parsed.slice(start, to).map(async (_, offset) => {
393
+ outcomes[start + offset] = await runCall(run, ready(start + offset), answered, step);
319
394
  }));
320
- for (const { id, name, ok, content } of outcomes.flatMap((outcome) => outcome ?? [])) {
321
- toolCalls.push({ id, name, ok });
322
- await keep(id, content);
323
- }
324
- }
325
- else {
326
- for (const [at, call] of parsed.entries()) {
327
- signal?.throwIfAborted();
328
- begun = at + 1;
329
- const { id, name, ok, content } = await runCall(run, call, answered, step);
395
+ for (const { id, name, ok, content } of outcomes.slice(from, to).flatMap((outcome) => outcome ?? [])) {
330
396
  toolCalls.push({ id, name, ok });
331
397
  await keep(id, content);
332
398
  }
399
+ from = to;
333
400
  }
334
401
  }
335
402
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "4.6.0",
3
+ "version": "4.7.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",