@cubicecho/agent-core 4.19.0 → 4.21.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.
@@ -1,7 +1,9 @@
1
+ import { isRecord } from '../core/guards.js';
1
2
  import { CALL_TOOL, expandNames, inCatalog, LOAD_TOOLS, loadResult, proxiedCall, proxyLoadResult, requestedNames, shownCall, toolName, } from '../tools/tool-loading.js';
2
3
  import { errorMessage } from '../wire/errors.js';
3
4
  import { parseToolArguments, } from '../wire/tool-calls.js';
4
5
  import { FUNCTION_TOOL, Role } from '../wire/wire.js';
6
+ import { declaredArtifact, detectArtifact, RECORD_ARTIFACT, } from './artifacts.js';
5
7
  import { RunEventKind } from './events.js';
6
8
  /**
7
9
  * The tools between two turns: a step's calls read, run and written into the transcript.
@@ -26,20 +28,40 @@ export const preview = (text, limit = 2000) => text.length > limit ? `${text.sli
26
28
  const STOPPED_CALL = 'Stopped before this call finished.';
27
29
  /** The same for a call of that step the loop never got to. */
28
30
  const UNRUN_CALL = 'Not run: the run stopped first.';
31
+ /**
32
+ * Whether a call's arguments are, as written, the JSON of an object — or no arguments at all.
33
+ *
34
+ * @param raw - The arguments as the turn carried them: a string, or an object a server parsed.
35
+ * @returns False where reading them took `parseToolArguments`' repairs.
36
+ */
37
+ function readsAsWritten(raw) {
38
+ if (typeof raw !== 'string' || !raw.trim()) {
39
+ return true;
40
+ }
41
+ try {
42
+ return isRecord(JSON.parse(raw));
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
29
48
  /**
30
49
  * One call with its arguments read, or with why they could not be.
31
50
  *
32
51
  * @param call - The call as the turn carried it.
33
52
  * @param finishReason - Why the turn ended, which tells a call cut off from one written wrongly.
53
+ * @param recovered - Whether the call was recovered from the reply's text, which makes it repaired
54
+ * whatever its arguments.
34
55
  * @returns The call beside its arguments and their repaired JSON, or beside the error and `{}`.
35
56
  */
36
- function readCall(call, finishReason) {
57
+ function readCall(call, finishReason, recovered) {
37
58
  try {
38
59
  const args = parseToolArguments(call.function.arguments, { finishReason });
39
- return { call, args, normal: JSON.stringify(args) };
60
+ const repaired = recovered || !readsAsWritten(call.function.arguments);
61
+ return { call, args, normal: JSON.stringify(args), repaired };
40
62
  }
41
63
  catch (error) {
42
- return { call, error, normal: '{}' };
64
+ return { call, error, normal: '{}', repaired: false };
43
65
  }
44
66
  }
45
67
  /**
@@ -47,6 +69,7 @@ function readCall(call, finishReason) {
47
69
  *
48
70
  * @param calls - The turn's calls, in the order the model made them.
49
71
  * @param finishReason - Why the turn ended, which tells a call cut off from one written wrongly.
72
+ * @param [recovered] - Whether the calls were recovered from the reply's text.
50
73
  * @returns One entry per call, in the same order. Unreadable arguments do not throw: the entry
51
74
  * carries the error in place of them.
52
75
  *
@@ -54,7 +77,7 @@ function readCall(call, finishReason) {
54
77
  * A server that parses replayed arguments refuses the almost-JSON a model wrote, so each is
55
78
  * normalised here, and one that could not be read at all is replayed as no arguments.
56
79
  */
57
- export const readCalls = (calls, finishReason) => calls.map((call) => readCall(call, finishReason));
80
+ export const readCalls = (calls, finishReason, recovered = false) => calls.map((call) => readCall(call, finishReason, recovered));
58
81
  /**
59
82
  * The assistant message a turn is written into the transcript as, its calls' arguments repaired.
60
83
  *
@@ -75,6 +98,19 @@ export const assistantMessage = (content, parsed) => ({
75
98
  }
76
99
  : {}),
77
100
  });
101
+ /**
102
+ * The assistant message a turn is, as the model produced it: its text and calls as they streamed.
103
+ *
104
+ * @param content - The turn's text, recovered calls left in it. Empty is written as `null`.
105
+ * @param calls - The calls the model sent as calls, with their arguments as streamed. Not written
106
+ * to. None leaves `tool_calls` off the message rather than empty.
107
+ * @returns A new message, each call on it a copy.
108
+ */
109
+ export const writtenMessage = (content, calls) => ({
110
+ role: Role.Assistant,
111
+ content: content || null,
112
+ ...(calls.length ? { tool_calls: calls.map((call) => ({ ...call, function: { ...call.function } })) } : {}),
113
+ });
78
114
  /**
79
115
  * The definitions the host gave for these names, in the order the names are given.
80
116
  *
@@ -227,28 +263,57 @@ async function once(answered, key, make) {
227
263
  }
228
264
  }
229
265
  /**
230
- * Adds one call's links to the run's artifacts, telling watchers of each uri the first time.
266
+ * Tells watchers of artifacts the run had not produced before.
231
267
  *
232
- * @param run - The run. Written to: a new uri is added to `artifacts`, and a known one counted.
233
- * @param links - What the call's answer linked to.
234
- * @param from - The call they came from.
268
+ * @param run - The run, whose watchers hear of them.
269
+ * @param added - The new artifacts, each told once.
235
270
  */
236
- function collect(run, links, from) {
237
- // Per call, so a tool that lists one uri twice in an answer is counted as making it once.
238
- const seen = new Set();
239
- for (const link of links) {
240
- if (seen.has(link.uri)) {
241
- continue;
242
- }
243
- seen.add(link.uri);
244
- const known = run.artifacts.get(link.uri);
245
- if (known) {
246
- known.count++;
247
- continue;
248
- }
249
- run.artifacts.set(link.uri, { link, ...from, count: 1 });
250
- run.onEvent({ kind: RunEventKind.Artifact, id: from.callId, name: from.tool, text: link.title || link.uri, link });
271
+ function announceArtifacts(run, added) {
272
+ for (const { link, callId, tool } of added) {
273
+ run.onEvent({ kind: RunEventKind.Artifact, id: callId, name: tool, text: link.title || link.uri, link });
274
+ }
275
+ }
276
+ /**
277
+ * Adds what one successful call produced to the run's artifacts: its links, and — where the run
278
+ * collects them — what it was given and answered, and the write it reads as.
279
+ *
280
+ * @param run - The run. Written to: its `artifacts`.
281
+ * @param request - The call, under the tool it was shown as.
282
+ * @param content - What it answered.
283
+ * @param links - What its answer linked to.
284
+ * @param from - The call, as an artifact is put down to it.
285
+ */
286
+ function collect(run, request, content, links, from) {
287
+ announceArtifacts(run, run.artifacts.link(links, from));
288
+ if (run.declares) {
289
+ run.artifacts.witness(request.args, content, links, from);
251
290
  }
291
+ const draft = run.detects?.(request) ? detectArtifact(request.name, request.args) : null;
292
+ const added = draft ? run.artifacts.add(draft, from) : undefined;
293
+ announceArtifacts(run, added ? [added] : []);
294
+ }
295
+ /**
296
+ * Answers a `record_artifact`, keeping the declaration where some call in the run touched the
297
+ * location.
298
+ *
299
+ * @param run - The run. Written to: its `artifacts`, where the declaration is believed.
300
+ * @param args - The call's arguments.
301
+ * @returns What the model reads. Throws, which the model reads instead, for a declaration with no
302
+ * location or one no call in the run was given or answered with.
303
+ */
304
+ function answerRecord(run, args) {
305
+ const draft = declaredArtifact(args);
306
+ if (draft === null) {
307
+ throw new Error(`${RECORD_ARTIFACT} needs a location.`);
308
+ }
309
+ const from = run.artifacts.seen(draft.location);
310
+ if (from === undefined) {
311
+ throw new Error(`No tool call in this run touched ${draft.location}, so it was not recorded. ` +
312
+ 'Record only what one of your tools stored, after the call that stored it.');
313
+ }
314
+ const added = run.artifacts.add(draft, from);
315
+ announceArtifacts(run, added ? [added] : []);
316
+ return `Recorded ${draft.location}.`;
252
317
  }
253
318
  /**
254
319
  * A call looked through and built into the request every host callback is handed.
@@ -284,14 +349,15 @@ function prepare(run, entry) {
284
349
  }
285
350
  // Built for every call rather than only the dispatched ones, so a host hears of the calls
286
351
  // the loop answers itself in the same shape. Arguments that could not be read are none.
287
- const request = { id: call.id, name, args: inner, raw };
352
+ const request = { id: call.id, name, args: inner, raw, repaired: entry.repaired };
288
353
  // By the name the model called, not the one a `call_tool` wraps: `load_tools` is not in
289
354
  // the catalogue, and one reached through `call_tool` is refused like any other.
290
355
  const load = onDemand && call.function.name === LOAD_TOOLS;
291
- const dispatched = args !== undefined && load === false && refused === undefined;
356
+ const record = run.declares && call.function.name === RECORD_ARTIFACT;
357
+ const dispatched = args !== undefined && load === false && record === false && refused === undefined;
292
358
  // Keyed on the inner call, so one made through `call_tool` and one made natively share.
293
359
  const key = `${name}\0${proxy ? JSON.stringify(inner) : normal}`;
294
- return { entry, request, load, refused, dedupable: dispatched && run.dedupable(request), key };
360
+ return { entry, request, load, record, refused, dedupable: dispatched && run.dedupable(request), key };
295
361
  }
296
362
  /**
297
363
  * Whether a call is answered without `dispatch` seeing it, so it can run beside anything.
@@ -299,10 +365,15 @@ function prepare(run, entry) {
299
365
  * @param prepared - The call.
300
366
  * @param answered - The step's calls already made, by key. A call that threw is not in it.
301
367
  * @param batch - The keys of the calls in the batch being gathered, which a repeat shares.
302
- * @returns True for a `load_tools`, a call whose arguments could not be read, a `call_tool` that
303
- * was refused, and an identical repeat that `dedupeToolCalls` answers from the first.
368
+ * @returns True for a `load_tools`, a `record_artifact`, a call whose arguments could not be read,
369
+ * a `call_tool` that was refused, and an identical repeat that `dedupeToolCalls` answers from the
370
+ * first.
304
371
  */
305
- const answersItself = ({ entry, load, refused, dedupable, key }, answered, batch) => entry.args === undefined || load || refused !== undefined || (dedupable && (answered.has(key) || batch.has(key)));
372
+ const answersItself = ({ entry, load, record, refused, dedupable, key }, answered, batch) => entry.args === undefined ||
373
+ load ||
374
+ record ||
375
+ refused !== undefined ||
376
+ (dedupable && (answered.has(key) || batch.has(key)));
306
377
  /**
307
378
  * Where the batch of calls starting at `from` ends: every call, one call, or the calls up to the
308
379
  * first one the host's predicate does not pass.
@@ -355,7 +426,7 @@ function batchEnd(run, ready, from, total, answered) {
355
426
  */
356
427
  async function runCall(run, prepared, answered, step) {
357
428
  const { catalog, onDemand, proxied, signal } = run;
358
- const { entry, request, load, refused, dedupable, key } = prepared;
429
+ const { entry, request, load, record, refused, dedupable, key } = prepared;
359
430
  const { call, args, error: unreadable } = entry;
360
431
  const { name, raw } = request;
361
432
  run.onEvent({ kind: RunEventKind.ToolCall, id: call.id, name, text: preview(raw) });
@@ -375,6 +446,9 @@ async function runCall(run, prepared, answered, step) {
375
446
  if (load) {
376
447
  ({ content, ok, counts: loads } = answerLoad(run, args));
377
448
  }
449
+ else if (record) {
450
+ content = answerRecord(run, args);
451
+ }
378
452
  else {
379
453
  if (refused) {
380
454
  throw refused.error;
@@ -414,9 +488,10 @@ async function runCall(run, prepared, answered, step) {
414
488
  text: preview(content),
415
489
  ...(loads ? { loads } : {}),
416
490
  });
417
- const isFirstAnswer = shared === false;
418
- if (isFirstAnswer) {
419
- collect(run, links, { callId: call.id, tool: name, step });
491
+ // Only what `dispatch` answered made anything, and a repeat made nothing the first did not.
492
+ const isMade = ok && load === false && record === false && shared === false;
493
+ if (isMade) {
494
+ collect(run, request, content, links, { callId: call.id, tool: name, step });
420
495
  }
421
496
  const result = { id: call.id, name, ok, content, ...(links.length ? { links } : {}) };
422
497
  run.onToolResult?.(result);
@@ -480,11 +555,17 @@ export async function runCalls(run, parsed, messages, step) {
480
555
  const start = from;
481
556
  const to = batchEnd(run, ready, start, parsed.length, answered);
482
557
  begun = to;
483
- await Promise.all(parsed.slice(start, to).map(async (_, offset) => {
484
- const { result, end } = await runCall(run, ready(start + offset), answered, step);
485
- outcomes[start + offset] = result;
486
- ends[start + offset] = end;
487
- }));
558
+ const runAt = async (at) => {
559
+ const { result, end } = await runCall(run, ready(at), answered, step);
560
+ outcomes[at] = result;
561
+ ends[at] = end;
562
+ };
563
+ // A declaration describes what the batch beside it stored, so it waits for the rest of the
564
+ // batch: run together, it would be checked before the write it names had answered.
565
+ const positions = parsed.slice(start, to).map((_, offset) => start + offset);
566
+ const isRecord = (at) => ready(at).record;
567
+ await Promise.all(positions.filter((at) => isRecord(at) === false).map(runAt));
568
+ await Promise.all(positions.filter(isRecord).map(runAt));
488
569
  for (const { id, name, ok, content } of outcomes.slice(from, to).flatMap((outcome) => outcome ?? [])) {
489
570
  toolCalls.push({ id, name, ok });
490
571
  await keep(id, content);
@@ -540,7 +621,7 @@ export async function loadShortlist(run, shortlist, messages) {
540
621
  // host that shortlisted more has already chosen.
541
622
  const content = proxyLoadResult(expandNames(names, catalog, names.length), catalog, shortlist);
542
623
  run.onEvent({ kind: RunEventKind.ToolCall, id, name: LOAD_TOOLS, text: preview(args) });
543
- run.onToolCall?.({ id, name: LOAD_TOOLS, args: { names }, raw: args });
624
+ run.onToolCall?.({ id, name: LOAD_TOOLS, args: { names }, raw: args, repaired: false });
544
625
  const loads = { toolsLoaded: names.length, redundantLoads: 0, unknownToolNames: 0 };
545
626
  run.onEvent({ kind: RunEventKind.ToolResult, id, name: LOAD_TOOLS, ok: true, text: preview(content), loads });
546
627
  run.onToolResult?.({ id, name: LOAD_TOOLS, ok: true, content });
@@ -114,9 +114,24 @@ export interface ToolCallRequest {
114
114
  args: Record<string, unknown>;
115
115
  /**
116
116
  * The arguments as the model wrote them, before any repair. Through `call_tool`, the inner
117
- * arguments as JSON text.
117
+ * arguments as JSON text. For a call recovered from the reply's text, the arguments recovery read
118
+ * out of it, as JSON text: the words the model wrote are the reply's content, which `onMessage`'s
119
+ * `raw` message carries.
118
120
  */
119
121
  raw: string;
122
+ /**
123
+ * Whether `args` took a repair to read: true where `raw` does not parse, as written, to the JSON
124
+ * object `args` holds — near-JSON, or JSON inside a string — and for every call recovered from
125
+ * text, which the model never sent as a call. False for blank arguments, which every reader takes
126
+ * as none, and for arguments that could not be read at all. Through `call_tool`, whether the
127
+ * `call_tool`'s own arguments did.
128
+ *
129
+ * @remarks
130
+ * A host that shows or stores what the model said reads `raw` where this is true. Whitespace is
131
+ * not a repair, though the replayed arguments are re-serialised either way. `runAgentLoop` always
132
+ * sets it; optional so a request built before it existed still compiles.
133
+ */
134
+ repaired?: boolean;
120
135
  }
121
136
  /** What one tool call did, in the order the model asked. */
122
137
  export interface ToolCallOutcome {
@@ -263,25 +278,68 @@ export interface ToolCallReport {
263
278
  */
264
279
  log: (entry: ServerLogEntry) => void;
265
280
  }
281
+ /**
282
+ * How the loop came to know of an artifact, most trusted first.
283
+ *
284
+ * @remarks
285
+ * `linked` is a call's answer saying so, and is the only one that takes no guessing. `declared` is
286
+ * the model naming it through `record_artifact`, believed only where some call in the run touched
287
+ * the location. `detected` is the loop reading a successful call as a write: a verb at the front
288
+ * of its name and a path in its arguments. The last two are opt-in, through `artifacts` on the
289
+ * loop's options.
290
+ */
291
+ export declare const ArtifactSource: {
292
+ readonly Linked: 'linked';
293
+ readonly Declared: 'declared';
294
+ readonly Detected: 'detected';
295
+ };
296
+ /** Any one of the sources in `ArtifactSource`. */
297
+ export type ArtifactSource = (typeof ArtifactSource)[keyof typeof ArtifactSource];
298
+ /** What the run's last write did to an artifact's location, as a detection read it. */
299
+ export declare const ArtifactAction: {
300
+ readonly Created: 'created';
301
+ readonly Updated: 'updated';
302
+ readonly Moved: 'moved';
303
+ readonly Deleted: 'deleted';
304
+ };
305
+ /** Any one of the actions in `ArtifactAction`. */
306
+ export type ArtifactAction = (typeof ArtifactAction)[keyof typeof ArtifactAction];
266
307
  /**
267
308
  * One thing a run's tool calls produced: the first link to a uri, and where it came from.
268
309
  *
269
310
  * @remarks
270
311
  * A link is what one call answered with; an artifact is what the run made, one per uri however
271
- * many calls linked to it.
312
+ * many calls linked to it. A declared or detected artifact is one per location the same way, and
313
+ * its `link` is built from what was said of it: the location as `uri`, its last segment as `name`.
272
314
  */
273
315
  export interface RunArtifact {
274
- /** The link as the first call to give its uri gave it. A later call's says nothing new here. */
316
+ /**
317
+ * The link as the first call to give its uri gave it. A later call's says nothing new here, and
318
+ * a declaration only fills in a `title` or `description` it left out.
319
+ */
275
320
  link: ResourceLink;
276
- /** The call that first linked to it. */
321
+ /** The call that first linked to it, wrote it, or — for a declaration alone — last touched it. */
277
322
  callId: string;
278
323
  /** That call's tool. For a `call_tool`, the tool it named. */
279
324
  tool: string;
280
325
  /** The loop's step that call was made in, from 0. */
281
326
  step: number;
282
327
  /**
283
- * How many calls linked to it. A deduplicated repeat does not count, since nothing was made
284
- * twice, and a call that links one uri twice counts once.
328
+ * How many calls linked to it or wrote it. A deduplicated repeat does not count, since nothing
329
+ * was made twice, a call that links one uri twice counts once, and a declaration counts none.
285
330
  */
286
331
  count: number;
332
+ /**
333
+ * How it came to be known: `linked` where absent, which is every artifact a call's answer
334
+ * linked to, so the shape from before the other two sources still reads the same. A declaration
335
+ * over a detection takes the source over; a link over either does too.
336
+ */
337
+ source?: ArtifactSource;
338
+ /**
339
+ * What the latest write a detection read did to it. Absent where no detection did: a link or a
340
+ * declaration says a thing exists, not what was done to it.
341
+ */
342
+ action?: ArtifactAction;
343
+ /** The bytes the latest detected write carried, where its arguments held the content. */
344
+ sizeBytes?: number;
287
345
  }
@@ -489,3 +489,25 @@ export const ServerLogLevel = {
489
489
  Alert: 'alert',
490
490
  Emergency: 'emergency',
491
491
  };
492
+ /**
493
+ * How the loop came to know of an artifact, most trusted first.
494
+ *
495
+ * @remarks
496
+ * `linked` is a call's answer saying so, and is the only one that takes no guessing. `declared` is
497
+ * the model naming it through `record_artifact`, believed only where some call in the run touched
498
+ * the location. `detected` is the loop reading a successful call as a write: a verb at the front
499
+ * of its name and a path in its arguments. The last two are opt-in, through `artifacts` on the
500
+ * loop's options.
501
+ */
502
+ export const ArtifactSource = {
503
+ Linked: 'linked',
504
+ Declared: 'declared',
505
+ Detected: 'detected',
506
+ };
507
+ /** What the run's last write did to an artifact's location, as a detection read it. */
508
+ export const ArtifactAction = {
509
+ Created: 'created',
510
+ Updated: 'updated',
511
+ Moved: 'moved',
512
+ Deleted: 'deleted',
513
+ };
package/llms.txt CHANGED
@@ -176,6 +176,20 @@ The loop above a turn: send, run the tools the model asked for, send again, unti
176
176
  - `PRESELECTED_NOTICE` — What opens the notice naming the loop's own picks, ahead of the names.
177
177
  - `runAgentLoop` — Runs a question to its answer: one `runTurn` per step, the tools it asks for between them, until a turn asks for none or a call's answer ends the run.
178
178
  - `StepWindow` (type) — How full the window is as a step is about to be sent, as `beforeStep` is told it.
179
+ - `WrittenMessage` (type) — What `onMessage` is handed beside an assistant message the model wrote: that message as the model produced it.
180
+
181
+ ### artifacts
182
+
183
+ What a run left behind, from the two sources that take a guess: the model saying so, and a call that reads as a write.
184
+
185
+ - `ArtifactDraft` (type) — What one declaration or detection said of a location, before it is merged into the run's.
186
+ - `ArtifactOptions` (type) — Which of the guessing sources a run collects, beside the links every run keeps.
187
+ - `artifactSource` — How an artifact came to be known, reading an absent `source` as the link it always was.
188
+ - `declaredArtifact` — What the model said through `record_artifact`.
189
+ - `detectArtifact` — What a successful call wrote, if its name and arguments look like a write.
190
+ - `mediaTypeFor` — A media type guessed off a location's extension.
191
+ - `RECORD_ARTIFACT` — The name of the tool the loop declares and answers itself when `artifacts.declare` is on.
192
+ - `RECORD_ARTIFACT_TOOL` — The definition of `record_artifact`, as the loop declares it beside the host's own tools.
179
193
 
180
194
  ### events
181
195
 
@@ -432,6 +446,8 @@ Reading what a model meant by a tool call when it did not write one cleanly.
432
446
 
433
447
  - `ARGUMENTS_MALFORMED` — The `ToolArgumentsError` kind for a call the model wrote wrongly.
434
448
  - `ARGUMENTS_TRUNCATED` — The `ToolArgumentsError` kind for a call cut off at the reply ceiling.
449
+ - `ArtifactAction` — Any one of the actions in `ArtifactAction`.
450
+ - `ArtifactSource` — Any one of the sources in `ArtifactSource`.
435
451
  - `EndedBy` (type) — The call that ended a run through its answer's `end`, as `AgentLoopResult.ended` says it.
436
452
  - `parseToolArguments` — A tool call's arguments as the object the tool is handed.
437
453
  - `ResourceLink` (type) — A handle to something a tool call made or found — a document it wrote, a file it saved.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "4.19.0",
3
+ "version": "4.21.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",