@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 +22 -1
- package/dist/run/agent-loop.d.ts +19 -4
- package/dist/run/run-calls.d.ts +2 -1
- package/dist/run/run-calls.js +99 -32
- package/package.json +1 -1
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,
|
|
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`,
|
package/dist/run/agent-loop.d.ts
CHANGED
|
@@ -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
|
|
234
|
-
*
|
|
235
|
-
|
|
236
|
-
|
|
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.
|
package/dist/run/run-calls.d.ts
CHANGED
|
@@ -93,7 +93,8 @@ export interface Calling {
|
|
|
93
93
|
artifacts: Map<string, RunArtifact>;
|
|
94
94
|
dedupable: (call: ToolCallRequest) => boolean;
|
|
95
95
|
dispatch: ToolDispatch;
|
|
96
|
-
|
|
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;
|
package/dist/run/run-calls.js
CHANGED
|
@@ -189,25 +189,21 @@ function collect(run, links, from) {
|
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
/**
|
|
192
|
-
*
|
|
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
|
-
* @
|
|
198
|
-
*
|
|
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
|
-
|
|
204
|
-
const { catalog, onDemand
|
|
205
|
-
const { call, args,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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.
|
|
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",
|