@cubicecho/agent-core 4.5.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`,
@@ -481,6 +502,34 @@ await runAgentLoop({
481
502
  });
482
503
  ```
483
504
 
505
+ ### What the calls made
506
+
507
+ A tool that wrote a document or saved a file can say so, and `dispatch` can pass that on: it
508
+ resolves to a `ToolAnswer`, `{ text, links }`, in place of the string. `text` is the whole of
509
+ what goes into the transcript, so the request, the prompt cache, pruning and compaction never see
510
+ the links. `links` are `ResourceLink`s, MCP's own shape declared structurally, so what
511
+ agent-mcp-pool's `call` answers with `links: true` is a dispatcher's answer as it stands.
512
+
513
+ ```ts
514
+ const { artifacts } = await runAgentLoop({
515
+ // ...
516
+ dispatch: ({ name, args }, signal) => pool.call(name, args, { signal, links: true }),
517
+ });
518
+ // [{ link: { uri, name, title?, ... }, callId, tool, step, count }]
519
+ ```
520
+
521
+ The loop collects them into the run's `artifacts`: one per uri in the order first seen, each with
522
+ the call, tool and step that first linked to it and the number of calls that did. A link is what
523
+ one call answered with; an artifact is what the run made. `onToolResult` hears a call's `links`
524
+ beside its `content`, an `artifact` event goes out the first time each uri appears — `name` the
525
+ tool, `text` the title or else the uri, and `link` the link — and `runMetrics` counts those events
526
+ as `artifacts`. A deduplicated repeat shares the first call's answer and links but adds nothing to
527
+ the count, since nothing was made twice. A call that threw made nothing. A failed run carries what
528
+ its calls made on the error too, read off with `failedRun`.
529
+
530
+ A link meant for the model alone — one whose MCP `audience` leaves out `"user"` — is the
531
+ dispatcher's to drop, since that is MCP's rule and this package knows nothing of MCP.
532
+
484
533
  Arguments go through `parseToolArguments`, which is lenient where the model's meaning is plain:
485
534
  JSON held in a string is opened, and the almost-JSON local models write — single quotes, Python's
486
535
  `True` and `None`, bare keys, a trailing comma — is repaired, without touching what is inside a
package/dist/index.d.ts CHANGED
@@ -21,7 +21,7 @@ export { assembleContext, configureHooks, consult, type Gathered, gather, HOOK_C
21
21
  export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopRequest, type AgentLoopResult, PRESELECT_APPEND, PRESELECT_EXCLUSIVE, PRESELECTED_NOTICE, runAgentLoop, type StepWindow, } from './run/agent-loop.ts';
22
22
  export { configureEvents, type EventBusOptions, emit, endRun, fold, history, type RunEvent, type RunEventInput, RunEventKind, type RunMetrics, type RunMetricsOptions, RunOutcome, type RunUsage, resetEvents, runMetrics, type TurnReport, watch, } from './run/events.ts';
23
23
  export { buildBody } from './run/request-body.ts';
24
- export { preview } from './run/run-calls.ts';
24
+ export { preview, type ToolDispatch } from './run/run-calls.ts';
25
25
  export { resetAll } from './runtime/reset.ts';
26
26
  export { createRuntime, defaultRuntime, type Runtime, type RuntimeOptions } from './runtime/runtime.ts';
27
27
  export { CAPABILITY_SNAPSHOT_VERSION, type CapabilitySnapshot, type EndpointSnapshot, exportCapabilities, importCapabilities, type ModelSnapshot, } from './runtime/snapshot.ts';
@@ -37,5 +37,5 @@ export { backoffMs, ContextOverflow, EndpointSilent, isModelLoading, isOverflow,
37
37
  export { CacheBreakReason, type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from './wire/stream.ts';
38
38
  export { ALL_FENCES, DEFAULT_FENCES, type Fence, FenceSplitter, type FenceSplitterOptions, SPLIT_OUTPUT, SPLIT_REASONING, type Split, stripThinking, THINK_FENCE, } from './wire/thinking.ts';
39
39
  export { type ContextBreakdown, type ContextBreakdownOptions, compact, contextChars, contextTokens, estimateTokens, messageTokens, requestChars, requestTokens, type TokenEstimateOptions, } from './wire/tokens.ts';
40
- export { ARGUMENTS_MALFORMED, ARGUMENTS_TRUNCATED, parseToolArguments, recoverToolCalls, ToolArgumentsError, type ToolCall, type ToolCallOutcome, type ToolCallRequest, type ToolCallResult, } from './wire/tool-calls.ts';
40
+ export { ARGUMENTS_MALFORMED, ARGUMENTS_TRUNCATED, parseToolArguments, type ResourceLink, type RunArtifact, recoverToolCalls, type ToolAnswer, ToolArgumentsError, type ToolCall, type ToolCallOutcome, type ToolCallRequest, type ToolCallResult, } from './wire/tool-calls.ts';
41
41
  export { FinishReason, FUNCTION_TOOL, JSON_SCHEMA_FORMAT, PartType, Role, SchemaType } from './wire/wire.ts';
@@ -9,7 +9,7 @@ import { type KeywordPreselectOptions } from '../tools/preselect.ts';
9
9
  import { type ToolOrder } from '../tools/tool-loading.ts';
10
10
  import { type SideTask } from '../turn/side-task.ts';
11
11
  import { type Turn, type TurnUsage } from '../wire/stream.ts';
12
- import { type ToolCallOutcome, type ToolCallRequest, type ToolCallResult } from '../wire/tool-calls.ts';
12
+ import { type RunArtifact, type ToolCallOutcome, type ToolCallRequest, type ToolCallResult } from '../wire/tool-calls.ts';
13
13
  import { type RunEventInput, type RunMetrics } from './events.ts';
14
14
  import { type ToolDispatch } from './run-calls.ts';
15
15
  /**
@@ -220,13 +220,35 @@ export interface AgentLoopOptions {
220
220
  * mode, where a tool is loaded only while its definition is in the history.
221
221
  */
222
222
  loaded?: Iterable<string>;
223
- /** Runs one tool call and returns what the model reads. What it throws, the model reads too. */
223
+ /**
224
+ * Runs one tool call and returns what the model reads. What it throws, the model reads too.
225
+ *
226
+ * @remarks
227
+ * A `ToolAnswer` in place of the string adds what the call produced: its `text` is the whole of
228
+ * what goes into the transcript, and its `links` become the result's `artifacts`, an `artifact`
229
+ * event the first time each uri is seen, and `links` on what `onToolResult` is handed.
230
+ */
224
231
  dispatch: ToolDispatch;
225
232
  /**
226
- * Runs a step's calls together rather than one after another. Results still go into the
227
- * transcript in the order the model asked. See `dedupeToolCalls`, which applies either way.
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.
228
250
  */
229
- parallel?: boolean;
251
+ parallel?: boolean | ((call: ToolCallRequest) => boolean);
230
252
  /**
231
253
  * Answers an identical repeat of a call — the same name and the same arguments, word for word —
232
254
  * within one step from the first one, rather than dispatching it again.
@@ -354,6 +376,12 @@ export interface AgentLoopResult {
354
376
  * proxied preselection's among them, first.
355
377
  */
356
378
  toolCalls: ToolCallOutcome[];
379
+ /**
380
+ * What the run's calls linked to, one per uri in the order first seen, each with the call and
381
+ * step that first linked to it and how many calls did. Empty where `dispatch` answered with
382
+ * strings only.
383
+ */
384
+ artifacts: RunArtifact[];
357
385
  /**
358
386
  * What is loaded at the end, for `carryOver`. Empty in eager mode, and in proxied mode: a
359
387
  * definition loaded there is already in the history, and one a compaction folded away has to be
@@ -116,6 +116,7 @@ export async function runAgentLoop(options) {
116
116
  messages: [...options.messages],
117
117
  usage: noUsage(),
118
118
  toolCalls: [],
119
+ artifacts: [],
119
120
  loaded: [],
120
121
  used: [],
121
122
  preselected: [],
@@ -450,10 +451,12 @@ async function runSteps(options, standing) {
450
451
  let messages = [...options.messages];
451
452
  let usage = noUsage();
452
453
  const toolCalls = [];
454
+ const artifacts = new Map();
453
455
  standing.read = () => ({
454
456
  messages,
455
457
  usage,
456
458
  toolCalls,
459
+ artifacts: [...artifacts.values()],
457
460
  // As the result would have said it: proxied, nothing is loaded that the history does not hold.
458
461
  loaded: proxied ? [] : [...loaded],
459
462
  used: [...used],
@@ -498,6 +501,7 @@ async function runSteps(options, standing) {
498
501
  loads,
499
502
  used,
500
503
  toolCalls,
504
+ artifacts,
501
505
  dedupable,
502
506
  dispatch,
503
507
  parallel,
@@ -645,6 +649,7 @@ async function runSteps(options, standing) {
645
649
  messages,
646
650
  usage,
647
651
  toolCalls,
652
+ artifacts: [...artifacts.values()],
648
653
  loaded: proxied ? [] : [...loaded],
649
654
  used: [...used],
650
655
  preselected: [...preselected],
@@ -1,6 +1,7 @@
1
1
  import type { TokenLedger } from '../context/ledger.ts';
2
2
  import type { TurnUsage } from '../wire/stream.ts';
3
3
  import { CacheBreakReason } from '../wire/stream.ts';
4
+ import type { ResourceLink } from '../wire/tool-calls.ts';
4
5
  /**
5
6
  * What a run is doing, while it is doing it.
6
7
  *
@@ -89,6 +90,12 @@ export declare const RunEventKind: {
89
90
  readonly ToolResult: 'tool-result';
90
91
  /** Something the runner did that is not the model's doing — a preselection, a retry. */
91
92
  readonly Notice: 'notice';
93
+ /**
94
+ * A tool call's answer linked to something the run had not produced before. `id` is the call's,
95
+ * `name` the tool, `text` the link's title or else its uri, and `link` the link itself. Once
96
+ * per uri per run: a later call that links the same uri again only adds to its count.
97
+ */
98
+ readonly Artifact: 'artifact';
92
99
  /**
93
100
  * What the run has cost so far, at the end of every turn, and what that one turn did. Sent
94
101
  * whether or not the endpoint reported tokens, since the timings and the cache comparison are
@@ -136,8 +143,8 @@ export interface TurnReport extends TurnUsage {
136
143
  * One thing that happened in a run, as a watcher receives it.
137
144
  *
138
145
  * @remarks
139
- * Every field but `id` is always present — the empty ones are `""` or `null` rather than
140
- * missing — so a client reads it without guarding each key.
146
+ * Every field but `id` and `link` is always present — the empty ones are `""` or `null` rather
147
+ * than missing — so a client reads it without guarding each key.
141
148
  */
142
149
  export interface RunEvent {
143
150
  /** The run it belongs to. `emit` fills this in; a caller does not pass it. */
@@ -175,6 +182,11 @@ export interface RunEvent {
175
182
  ok: boolean | null;
176
183
  /** Running totals on `usage`, otherwise null. */
177
184
  usage: RunUsage | null;
185
+ /**
186
+ * What the call produced, on `artifact` and on no other kind. Optional for the reason `id` is:
187
+ * an event built before it existed still compiles.
188
+ */
189
+ link?: ResourceLink;
178
190
  }
179
191
  /**
180
192
  * What `emit` is given: the run and the sequence are the bus's to assign.
@@ -300,6 +312,8 @@ export interface RunMetrics {
300
312
  toolErrors: Record<string, number>;
301
313
  /** `load_tools` calls. */
302
314
  loadCalls: number;
315
+ /** `artifact` events: the distinct things the run's tool calls linked to. */
316
+ artifacts: number;
303
317
  /**
304
318
  * Tools `load_tools` loaded that were not loaded already. Filled by `runAgentLoop`, which sees
305
319
  * the resolution; the events carry only its text.
@@ -56,6 +56,12 @@ export const RunEventKind = {
56
56
  ToolResult: 'tool-result',
57
57
  /** Something the runner did that is not the model's doing — a preselection, a retry. */
58
58
  Notice: 'notice',
59
+ /**
60
+ * A tool call's answer linked to something the run had not produced before. `id` is the call's,
61
+ * `name` the tool, `text` the link's title or else its uri, and `link` the link itself. Once
62
+ * per uri per run: a later call that links the same uri again only adds to its count.
63
+ */
64
+ Artifact: 'artifact',
59
65
  /**
60
66
  * What the run has cost so far, at the end of every turn, and what that one turn did. Sent
61
67
  * whether or not the endpoint reported tokens, since the timings and the cache comparison are
@@ -500,6 +506,7 @@ export function runMetrics(events, { contextLength } = {}) {
500
506
  toolCalls: 0,
501
507
  toolErrors: {},
502
508
  loadCalls: 0,
509
+ artifacts: 0,
503
510
  promptTokens: 0,
504
511
  completionTokens: 0,
505
512
  cachedTokens: 0,
@@ -540,6 +547,9 @@ export function runMetrics(events, { contextLength } = {}) {
540
547
  add('toolMs', event.at - called);
541
548
  }
542
549
  }
550
+ else if (event.kind === RunEventKind.Artifact) {
551
+ metrics.artifacts++;
552
+ }
543
553
  else if (event.kind === RunEventKind.Usage && event.usage?.turn) {
544
554
  const turn = event.usage.turn;
545
555
  last = turn;
@@ -1,7 +1,7 @@
1
1
  import type OpenAI from 'openai';
2
2
  import type { CatalogServer } from '../tools/catalog.ts';
3
3
  import type { Turn } from '../wire/stream.ts';
4
- import { type ToolCall, type ToolCallOutcome, type ToolCallRequest, type ToolCallResult } from '../wire/tool-calls.ts';
4
+ import { type RunArtifact, type ToolAnswer, type ToolCall, type ToolCallOutcome, type ToolCallRequest, type ToolCallResult } from '../wire/tool-calls.ts';
5
5
  import type { RunEventInput } from './events.ts';
6
6
  /**
7
7
  * The tools between two turns: a step's calls read, run and written into the transcript.
@@ -63,8 +63,11 @@ export declare const assistantMessage: (content: string, parsed: ReadCall[]) =>
63
63
  * @returns A new array of the definitions themselves, not copies.
64
64
  */
65
65
  export declare const definedAs: (definitions: ReadonlyMap<string, OpenAI.ChatCompletionTool>, names: Iterable<string>) => (OpenAI.ChatCompletionCustomTool | OpenAI.ChatCompletionFunctionTool)[];
66
- /** Runs one tool call and resolves to what the model reads; what it throws, the model reads too. */
67
- export type ToolDispatch = (call: ToolCallRequest, signal?: AbortSignal) => Promise<string>;
66
+ /**
67
+ * Runs one tool call and resolves to what the model reads, alone or with what the call produced;
68
+ * what it throws, the model reads too.
69
+ */
70
+ export type ToolDispatch = (call: ToolCallRequest, signal?: AbortSignal) => Promise<string | ToolAnswer>;
68
71
  /**
69
72
  * What running a step's calls reads of the run and writes back into it. One per run, shared by
70
73
  * every step: the sets and counts here are the run's own, not copies.
@@ -86,9 +89,12 @@ export interface Calling {
86
89
  };
87
90
  used: Set<string>;
88
91
  toolCalls: ToolCallOutcome[];
92
+ /** What the run's calls linked to, by uri, in the order first seen. */
93
+ artifacts: Map<string, RunArtifact>;
89
94
  dedupable: (call: ToolCallRequest) => boolean;
90
95
  dispatch: ToolDispatch;
91
- 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);
92
98
  signal: AbortSignal | undefined;
93
99
  onEvent: (input: RunEventInput) => void;
94
100
  onToolCall: ((call: ToolCallRequest) => void) | undefined;
@@ -84,6 +84,13 @@ export const assistantMessage = (content, parsed) => ({
84
84
  * @returns A new array of the definitions themselves, not copies.
85
85
  */
86
86
  export const definedAs = (definitions, names) => [...names].flatMap((name) => definitions.get(name) ?? []);
87
+ /**
88
+ * A dispatcher's answer in the one shape, whichever it gave.
89
+ *
90
+ * @param answer - What `dispatch` resolved to.
91
+ * @returns The text and the links, a string answer's being none.
92
+ */
93
+ const answerOf = (answer) => typeof answer === 'string' ? { text: answer, links: [] } : { text: answer.text, links: answer.links ?? [] };
87
94
  /**
88
95
  * What a call is shown, counted and dispatched as: a `call_tool` as the tool it names.
89
96
  *
@@ -129,6 +136,7 @@ function answerLoad(run, args) {
129
136
  * Makes a call at most once per key, sharing the in-flight promise so two identical calls in one
130
137
  * step make one request between them whether they run together or one after the other.
131
138
  *
139
+ * @typeParam T - What a call answers with.
132
140
  * @param answered - The step's calls so far, by key. Written to.
133
141
  * @param key - What makes two calls the same one.
134
142
  * @param make - Makes the call. Not called where the key has an answer, or one on its way.
@@ -157,23 +165,45 @@ async function once(answered, key, make) {
157
165
  }
158
166
  }
159
167
  /**
160
- * Runs one call to its result, telling the host of both. A tool that throws is that call's answer
161
- * rather than the run's end, unless the run was stopped.
168
+ * Adds one call's links to the run's artifacts, telling watchers of each uri the first time.
162
169
  *
163
- * @param run - The run the call belongs to.
170
+ * @param run - The run. Written to: a new uri is added to `artifacts`, and a known one counted.
171
+ * @param links - What the call's answer linked to.
172
+ * @param from - The call they came from.
173
+ */
174
+ function collect(run, links, from) {
175
+ // Per call, so a tool that lists one uri twice in an answer is counted as making it once.
176
+ const seen = new Set();
177
+ for (const link of links) {
178
+ if (seen.has(link.uri)) {
179
+ continue;
180
+ }
181
+ seen.add(link.uri);
182
+ const known = run.artifacts.get(link.uri);
183
+ if (known) {
184
+ known.count++;
185
+ continue;
186
+ }
187
+ run.artifacts.set(link.uri, { link, ...from, count: 1 });
188
+ run.onEvent({ kind: RunEventKind.Artifact, id: from.callId, name: from.tool, text: link.title || link.uri, link });
189
+ }
190
+ }
191
+ /**
192
+ * A call looked through and built into the request every host callback is handed.
193
+ *
194
+ * @param run - The run the call belongs to. Read, never written to.
164
195
  * @param entry - The call, with its arguments read.
165
- * @param answered - The step's calls already made, by tool and arguments, for `dedupeToolCalls`.
166
- * @returns The call's id, the name it was shown under, whether it worked, and the whole of what
167
- * the model reads. Rejects where the run was 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`.
168
198
  */
169
- async function runCall(run, entry, answered) {
170
- const { catalog, onDemand, proxied, signal } = run;
171
- const { call, args, error: unreadable, normal } = entry;
199
+ function prepare(run, entry) {
200
+ const { catalog, onDemand } = run;
201
+ const { call, args, normal } = entry;
172
202
  const proxy = run.proxies && call.function.name === CALL_TOOL;
173
203
  const { name, input: raw } = shownAs(run, entry);
174
204
  // Looked through before the call is announced, so a host is told of the tool it names with
175
205
  // that tool's arguments. A name outside the catalogue is refused here, so `call_tool` reaches
176
- // 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.
177
207
  let inner = args ?? {};
178
208
  let refused;
179
209
  if (proxy && args) {
@@ -188,17 +218,91 @@ async function runCall(run, entry, answered) {
188
218
  // Built for every call rather than only the dispatched ones, so a host hears of the calls
189
219
  // the loop answers itself in the same shape. Arguments that could not be read are none.
190
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;
191
294
  run.onEvent({ kind: RunEventKind.ToolCall, id: call.id, name, text: preview(raw) });
192
295
  run.onToolCall?.(request);
193
296
  let content;
297
+ let links = [];
194
298
  let ok = true;
299
+ // Answered from an earlier identical call of the step, whose links are already counted.
300
+ let shared = false;
195
301
  try {
196
302
  if (!args) {
197
303
  throw unreadable;
198
304
  }
199
- // By the name the model called, not the one a `call_tool` wraps: `load_tools` is not in
200
- // the catalogue, and one reached through `call_tool` is refused below like any other.
201
- if (onDemand && call.function.name === LOAD_TOOLS) {
305
+ if (load) {
202
306
  ({ content, ok } = answerLoad(run, args));
203
307
  }
204
308
  else {
@@ -212,11 +316,12 @@ async function runCall(run, entry, answered) {
212
316
  run.loaded.add(name);
213
317
  }
214
318
  run.used.add(name);
215
- // Keyed on the inner call, so one made through `call_tool` and one made natively share.
216
- const key = `${name}\0${proxy ? JSON.stringify(inner) : normal}`;
217
- content = run.dedupable(request)
319
+ // Read before `once` looks, which it does before it awaits anything.
320
+ shared = dedupable && answered.has(key);
321
+ const answer = dedupable
218
322
  ? await once(answered, key, () => run.dispatch(request, signal))
219
323
  : await run.dispatch(request, signal);
324
+ ({ text: content, links } = answerOf(answer));
220
325
  }
221
326
  }
222
327
  catch (error) {
@@ -227,7 +332,11 @@ async function runCall(run, entry, answered) {
227
332
  ok = false;
228
333
  }
229
334
  run.onEvent({ kind: RunEventKind.ToolResult, id: call.id, name, ok, text: preview(content) });
230
- const result = { id: call.id, name, ok, content };
335
+ const isFirstAnswer = shared === false;
336
+ if (isFirstAnswer) {
337
+ collect(run, links, { callId: call.id, tool: name, step });
338
+ }
339
+ const result = { id: call.id, name, ok, content, ...(links.length ? { links } : {}) };
231
340
  run.onToolResult?.(result);
232
341
  return result;
233
342
  }
@@ -265,26 +374,29 @@ export async function runCalls(run, parsed, messages, step) {
265
374
  kept++;
266
375
  await run.announce(result, step);
267
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
+ };
268
383
  try {
269
- 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) {
270
388
  signal?.throwIfAborted();
271
- begun = parsed.length;
272
- await Promise.all(parsed.map(async (call, at) => {
273
- outcomes[at] = await runCall(run, call, answered);
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);
274
394
  }));
275
- for (const { id, name, ok, content } of outcomes.flatMap((outcome) => outcome ?? [])) {
276
- toolCalls.push({ id, name, ok });
277
- await keep(id, content);
278
- }
279
- }
280
- else {
281
- for (const [at, call] of parsed.entries()) {
282
- signal?.throwIfAborted();
283
- begun = at + 1;
284
- const { id, name, ok, content } = await runCall(run, call, answered);
395
+ for (const { id, name, ok, content } of outcomes.slice(from, to).flatMap((outcome) => outcome ?? [])) {
285
396
  toolCalls.push({ id, name, ok });
286
397
  await keep(id, content);
287
398
  }
399
+ from = to;
288
400
  }
289
401
  }
290
402
  catch (error) {
@@ -1,7 +1,7 @@
1
1
  import type OpenAI from 'openai';
2
2
  import { ContextOverflow } from './retry.ts';
3
3
  import type { TurnUsage } from './stream.ts';
4
- import type { ToolCallOutcome } from './tool-calls.ts';
4
+ import type { RunArtifact, ToolCallOutcome } from './tool-calls.ts';
5
5
  /**
6
6
  * What went wrong, as a string.
7
7
  *
@@ -34,6 +34,12 @@ export interface AgentLoopFailure {
34
34
  usage: TurnUsage;
35
35
  /** Every call that was made, in order. One the run was stopped during is `ok: false`. */
36
36
  toolCalls: ToolCallOutcome[];
37
+ /**
38
+ * What the calls that came back linked to, as the result's `artifacts` would have said it: a
39
+ * failed run's writes were still made. `runAgentLoop` always sets it; optional so a failure
40
+ * built before it existed still compiles.
41
+ */
42
+ artifacts?: RunArtifact[];
37
43
  /** What was loaded when the run failed, for `carryOver`. Empty in eager and proxied modes. */
38
44
  loaded: string[];
39
45
  /** The tools the model had called, `load_tools` excluded. */
@@ -55,6 +61,7 @@ export declare class AgentLoopError extends Error implements AgentLoopFailure {
55
61
  readonly messages: OpenAI.ChatCompletionMessageParam[];
56
62
  readonly usage: TurnUsage;
57
63
  readonly toolCalls: ToolCallOutcome[];
64
+ readonly artifacts: RunArtifact[];
58
65
  readonly loaded: string[];
59
66
  readonly used: string[];
60
67
  readonly preselected: string[];
@@ -88,6 +95,7 @@ export declare class AgentLoopOverflow extends ContextOverflow implements AgentL
88
95
  readonly messages: OpenAI.ChatCompletionMessageParam[];
89
96
  readonly usage: TurnUsage;
90
97
  readonly toolCalls: ToolCallOutcome[];
98
+ readonly artifacts: RunArtifact[];
91
99
  readonly loaded: string[];
92
100
  readonly used: string[];
93
101
  readonly preselected: string[];
@@ -26,6 +26,7 @@ export class AgentLoopError extends Error {
26
26
  messages;
27
27
  usage;
28
28
  toolCalls;
29
+ artifacts;
29
30
  loaded;
30
31
  used;
31
32
  preselected;
@@ -39,6 +40,7 @@ export class AgentLoopError extends Error {
39
40
  this.messages = run.messages;
40
41
  this.usage = run.usage;
41
42
  this.toolCalls = run.toolCalls;
43
+ this.artifacts = run.artifacts ?? [];
42
44
  this.loaded = run.loaded;
43
45
  this.used = run.used;
44
46
  this.preselected = run.preselected;
@@ -67,6 +69,7 @@ export class AgentLoopOverflow extends ContextOverflow {
67
69
  messages;
68
70
  usage;
69
71
  toolCalls;
72
+ artifacts;
70
73
  loaded;
71
74
  used;
72
75
  preselected;
@@ -80,6 +83,7 @@ export class AgentLoopOverflow extends ContextOverflow {
80
83
  this.messages = run.messages;
81
84
  this.usage = run.usage;
82
85
  this.toolCalls = run.toolCalls;
86
+ this.artifacts = run.artifacts ?? [];
83
87
  this.loaded = run.loaded;
84
88
  this.used = run.used;
85
89
  this.preselected = run.preselected;
@@ -146,4 +146,68 @@ export interface ToolCallResult extends ToolCallOutcome {
146
146
  * `load_tools` answer, or why the arguments could not be read.
147
147
  */
148
148
  content: string;
149
+ /**
150
+ * What the dispatcher's answer said the call produced, as it said it. Absent where it said
151
+ * nothing, and always for a call that threw. A deduplicated repeat carries the first call's.
152
+ */
153
+ links?: ResourceLink[];
154
+ }
155
+ /**
156
+ * A handle to something a tool call made or found — a document it wrote, a file it saved.
157
+ *
158
+ * @remarks
159
+ * MCP's `ResourceLink` less its `type`, declared structurally rather than imported, the way
160
+ * agent-mcp-pool declares `ToolDefinition` against `ChatCompletionTool`: the pool's own
161
+ * `ResourceLink` has these field names exactly, so its answer is assignable here and neither
162
+ * package depends on the other.
163
+ */
164
+ export interface ResourceLink {
165
+ uri: string;
166
+ name: string;
167
+ title?: string;
168
+ mimeType?: string;
169
+ description?: string;
170
+ /** Who the link is for, how much it matters, and when it last changed — the server's word. */
171
+ annotations?: {
172
+ audience?: ('user' | 'assistant')[];
173
+ priority?: number;
174
+ lastModified?: string;
175
+ };
176
+ _meta?: Record<string, unknown>;
177
+ }
178
+ /**
179
+ * A dispatcher's answer when it has more to say than the text: what the model reads, and what the
180
+ * call produced for the host.
181
+ *
182
+ * @remarks
183
+ * A link meant for the model alone — one whose MCP `audience` leaves out `"user"` — is the
184
+ * dispatcher's to drop, since that is MCP's rule and this package knows nothing of MCP.
185
+ */
186
+ export interface ToolAnswer {
187
+ /** What goes into the transcript, exactly as a string answer does. */
188
+ text: string;
189
+ /** What the call produced, in the order the tool gave them. Never sent to the model. */
190
+ links?: ResourceLink[];
191
+ }
192
+ /**
193
+ * One thing a run's tool calls produced: the first link to a uri, and where it came from.
194
+ *
195
+ * @remarks
196
+ * A link is what one call answered with; an artifact is what the run made, one per uri however
197
+ * many calls linked to it.
198
+ */
199
+ export interface RunArtifact {
200
+ /** The link as the first call to give its uri gave it. A later call's says nothing new here. */
201
+ link: ResourceLink;
202
+ /** The call that first linked to it. */
203
+ callId: string;
204
+ /** That call's tool. For a `call_tool`, the tool it named. */
205
+ tool: string;
206
+ /** The loop's step that call was made in, from 0. */
207
+ step: number;
208
+ /**
209
+ * How many calls linked to it. A deduplicated repeat does not count, since nothing was made
210
+ * twice, and a call that links one uri twice counts once.
211
+ */
212
+ count: number;
149
213
  }
package/llms.txt CHANGED
@@ -185,6 +185,7 @@ What a run is doing, while it is doing it.
185
185
  The tools between two turns: a step's calls read, run and written into the transcript.
186
186
 
187
187
  - `preview` — A long tool argument or result cut to what a watcher needs, with the full length said.
188
+ - `ToolDispatch` (type) — Runs one tool call and resolves to what the model reads, alone or with what the call produced; what it throws, the model reads too.
188
189
 
189
190
  ### reset
190
191
 
@@ -384,7 +385,10 @@ Reading what a model meant by a tool call when it did not write one cleanly.
384
385
  - `ARGUMENTS_MALFORMED` — The `ToolArgumentsError` kind for a call the model wrote wrongly.
385
386
  - `ARGUMENTS_TRUNCATED` — The `ToolArgumentsError` kind for a call cut off at the reply ceiling.
386
387
  - `parseToolArguments` — A tool call's arguments as the object the tool is handed.
388
+ - `ResourceLink` (type) — A handle to something a tool call made or found — a document it wrote, a file it saved.
389
+ - `RunArtifact` (type) — One thing a run's tool calls produced: the first link to a uri, and where it came from.
387
390
  - `recoverToolCalls` — Tool calls a model wrote into its reply as text, taken out of it and made into calls.
391
+ - `ToolAnswer` (type) — A dispatcher's answer when it has more to say than the text: what the model reads, and what the call produced for the host.
388
392
  - `ToolArgumentsError` — Tool arguments that could not be read as an object, with why.
389
393
  - `ToolCall` (type) — A tool call as the loop handles it, recovered or streamed.
390
394
  - `ToolCallOutcome` (type) — What one tool call did, in the order the model asked.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "4.5.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",