@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.
@@ -0,0 +1,412 @@
1
+ import { ArtifactAction, ArtifactSource, } from '../wire/tool-calls.js';
2
+ import { FUNCTION_TOOL, SchemaType } from '../wire/wire.js';
3
+ /**
4
+ * What a run left behind, from the two sources that take a guess: the model saying so, and a call
5
+ * that reads as a write. kanban-server and telos each grew these rules, and this is them in one
6
+ * place; a call's `links`, which take no guessing, are kept by the same ledger.
7
+ *
8
+ * A declaration is believed only where some call in the run was given or answered with that
9
+ * location. Without that, a model with no file tools "saves" its answer to a file it never wrote.
10
+ */
11
+ /** The name of the tool the loop declares and answers itself when `artifacts.declare` is on. */
12
+ export const RECORD_ARTIFACT = 'record_artifact';
13
+ /** The definition of `record_artifact`, as the loop declares it beside the host's own tools. */
14
+ export const RECORD_ARTIFACT_TOOL = {
15
+ type: FUNCTION_TOOL,
16
+ function: {
17
+ name: RECORD_ARTIFACT,
18
+ description: 'Name something a tool call in this run stored, so the run lists it: a file one of your tools ' +
19
+ 'wrote, a page it published, an object it uploaded. Only for a location a tool was actually ' +
20
+ 'given or answered with, and only after that call: one that no tool call in this run touched ' +
21
+ 'is refused. Your reply is already kept, so never record it, and never invent a file to put ' +
22
+ 'it in. Calling this adds the title and description a person would want.',
23
+ parameters: {
24
+ type: SchemaType.Object,
25
+ properties: {
26
+ location: {
27
+ type: SchemaType.String,
28
+ description: 'The path or URI, exactly as the tool that stored it was given it.',
29
+ },
30
+ title: { type: SchemaType.String, description: 'A short name for it.' },
31
+ description: { type: SchemaType.String, description: 'One sentence on what it is for.' },
32
+ mediaType: { type: SchemaType.String, description: 'e.g. text/markdown. Optional.' },
33
+ },
34
+ required: ['location'],
35
+ additionalProperties: false,
36
+ },
37
+ },
38
+ };
39
+ /** A verb at the front of a tool name that means something was written somewhere. */
40
+ const WRITE_VERB = /^(write|create|save|upload|put|append|edit|move|rename|copy|publish|delete|remove)(_|$)/;
41
+ /** Writes that leave nothing a person would call an artifact behind. */
42
+ const NOT_ARTIFACTS = new Set(['create_directory', 'create_dir', 'create_folder', 'mkdir']);
43
+ /** The argument names a location goes by, in the order they are believed. */
44
+ const LOCATION_KEYS = [
45
+ 'destination',
46
+ 'dest',
47
+ 'target',
48
+ 'path',
49
+ 'file_path',
50
+ 'filePath',
51
+ 'filename',
52
+ 'file',
53
+ 'uri',
54
+ 'url',
55
+ 'key',
56
+ ];
57
+ /** The argument names a write's content goes by, in the order they are believed. */
58
+ const CONTENT_KEYS = ['content', 'contents', 'text', 'body', 'data'];
59
+ /** The separator between a server's slug and its tool, in the names a pool hands the loop. */
60
+ const SLUG_SEPARATOR = '__';
61
+ const encoder = new TextEncoder();
62
+ /**
63
+ * What a write did, read off its verb.
64
+ *
65
+ * @param verb - The tool's own name, lowercased, after any slug.
66
+ * @returns `updated` for an edit or append, `moved` for a move or rename, `deleted` for a delete or
67
+ * remove, and `created` for everything else.
68
+ */
69
+ function actionOf(verb) {
70
+ if (/^(edit|append)/.test(verb)) {
71
+ return ArtifactAction.Updated;
72
+ }
73
+ if (/^(move|rename)/.test(verb)) {
74
+ return ArtifactAction.Moved;
75
+ }
76
+ if (/^(delete|remove)/.test(verb)) {
77
+ return ArtifactAction.Deleted;
78
+ }
79
+ return ArtifactAction.Created;
80
+ }
81
+ /**
82
+ * The first of these arguments that is a string, under the first key that holds one.
83
+ *
84
+ * @param args - The call's arguments.
85
+ * @param keys - The names to try, in order.
86
+ * @param [isBlankOk] - Whether an empty or all-space string counts. It does for content.
87
+ * @returns The string, or `undefined` where none of the keys held one.
88
+ */
89
+ function firstString(args, keys, isBlankOk = false) {
90
+ return keys
91
+ .map((key) => args[key])
92
+ .find((value) => typeof value === 'string' && (isBlankOk || value.trim() !== ''));
93
+ }
94
+ /**
95
+ * What a successful call wrote, if its name and arguments look like a write.
96
+ *
97
+ * @param name - The tool, as the call named it: `fs__write_file` and `write_file` read alike, the
98
+ * verb being the last `__` segment.
99
+ * @param args - The call's arguments.
100
+ * @returns The draft, or `null` where the name has no write verb, names a folder, or the arguments
101
+ * hold no location.
102
+ */
103
+ export function detectArtifact(name, args) {
104
+ const verb = name.split(SLUG_SEPARATOR).at(-1)?.toLowerCase() ?? '';
105
+ const isWrite = WRITE_VERB.test(verb) && NOT_ARTIFACTS.has(verb) === false;
106
+ const location = isWrite ? firstString(args, LOCATION_KEYS) : undefined;
107
+ if (location === undefined) {
108
+ return null;
109
+ }
110
+ const content = firstString(args, CONTENT_KEYS, true);
111
+ const mediaType = mediaTypeFor(location);
112
+ return {
113
+ location: location.trim(),
114
+ source: ArtifactSource.Detected,
115
+ action: actionOf(verb),
116
+ ...(mediaType ? { mediaType } : {}),
117
+ ...(content === undefined ? {} : { sizeBytes: encoder.encode(content).byteLength }),
118
+ };
119
+ }
120
+ /**
121
+ * What the model said through `record_artifact`.
122
+ *
123
+ * @param args - The call's arguments.
124
+ * @returns The draft, or `null` where it named no location.
125
+ */
126
+ export function declaredArtifact(args) {
127
+ const text = (key) => (typeof args[key] === 'string' ? args[key].trim() : '');
128
+ const location = text('location');
129
+ if (location === '') {
130
+ return null;
131
+ }
132
+ const mediaType = text('mediaType') || mediaTypeFor(location);
133
+ return {
134
+ location,
135
+ source: ArtifactSource.Declared,
136
+ action: ArtifactAction.Created,
137
+ ...(text('title') ? { title: text('title') } : {}),
138
+ ...(text('description') ? { description: text('description') } : {}),
139
+ ...(mediaType ? { mediaType } : {}),
140
+ };
141
+ }
142
+ const MEDIA_TYPES = {
143
+ md: 'text/markdown',
144
+ markdown: 'text/markdown',
145
+ txt: 'text/plain',
146
+ csv: 'text/csv',
147
+ html: 'text/html',
148
+ htm: 'text/html',
149
+ css: 'text/css',
150
+ json: 'application/json',
151
+ yaml: 'application/yaml',
152
+ yml: 'application/yaml',
153
+ xml: 'application/xml',
154
+ js: 'text/javascript',
155
+ ts: 'text/x-typescript',
156
+ tsx: 'text/x-typescript',
157
+ py: 'text/x-python',
158
+ sh: 'application/x-sh',
159
+ svg: 'image/svg+xml',
160
+ png: 'image/png',
161
+ jpg: 'image/jpeg',
162
+ jpeg: 'image/jpeg',
163
+ gif: 'image/gif',
164
+ webp: 'image/webp',
165
+ pdf: 'application/pdf',
166
+ zip: 'application/zip',
167
+ };
168
+ /**
169
+ * A media type guessed off a location's extension.
170
+ *
171
+ * @param location - The path or URI. A query or fragment is ignored.
172
+ * @returns The media type, or `null` for a name with no extension, a dotfile, or an extension this
173
+ * table does not know.
174
+ */
175
+ export function mediaTypeFor(location) {
176
+ const name = location.split(/[?#]/)[0].split(/[\\/]/).at(-1) ?? '';
177
+ const dot = name.lastIndexOf('.');
178
+ if (dot <= 0) {
179
+ return null;
180
+ }
181
+ return MEDIA_TYPES[name.slice(dot + 1).toLowerCase()] ?? null;
182
+ }
183
+ /**
184
+ * How an artifact came to be known, reading an absent `source` as the link it always was.
185
+ *
186
+ * @param artifact - One of a run's artifacts.
187
+ * @returns Its source, `linked` where it carries none.
188
+ */
189
+ export const artifactSource = (artifact) => artifact.source ?? ArtifactSource.Linked;
190
+ /**
191
+ * A location as it is compared: no scheme, no leading slashes, no trailing ones.
192
+ *
193
+ * @param location - The path or URI.
194
+ * @returns The bare form, so `/work/a.md`, `work/a.md` and `file:///work/a.md` compare equal.
195
+ */
196
+ function bare(location) {
197
+ return location
198
+ .trim()
199
+ .replace(/^[a-z][a-z0-9+.-]*:\/\//i, '')
200
+ .replace(/^\/+|\/+$/g, '');
201
+ }
202
+ /**
203
+ * A declared or detected location as a link, the shape every artifact is kept in.
204
+ *
205
+ * @param draft - What was said of it.
206
+ * @returns A link whose `uri` is the location and whose `name` is its last segment.
207
+ */
208
+ function linkOf(draft) {
209
+ const name = draft.location.split(/[?#]/)[0].split(/[\\/]/).filter(Boolean).at(-1) ?? draft.location;
210
+ return {
211
+ uri: draft.location,
212
+ name,
213
+ ...(draft.title ? { title: draft.title } : {}),
214
+ ...(draft.description ? { description: draft.description } : {}),
215
+ ...(draft.mediaType ? { mimeType: draft.mediaType } : {}),
216
+ };
217
+ }
218
+ /**
219
+ * What tells one call from another across a run: an id is distinct only within its step.
220
+ *
221
+ * @param from - The call.
222
+ * @returns Its step and id as one string.
223
+ */
224
+ const callKey = ({ callId, step }) => `${step}\0${callId}`;
225
+ /**
226
+ * A run's artifacts, one per location, and what its calls touched for a declaration to be checked
227
+ * against.
228
+ *
229
+ * @remarks
230
+ * The merge rules are kanban-server's. A later write moves `action` and `sizeBytes` on and leaves
231
+ * the rest; a declaration over a detection fills in what only the model could say and takes the
232
+ * source over, without turning an `updated` back into `created`. A link is the server's word, so
233
+ * a declaration over one fills only what it left out, and a link over either guess takes over.
234
+ * Each method that adds a location answers with the new artifact, for the caller to announce;
235
+ * a merge into one already known answers nothing, since the event says when a location was first
236
+ * seen and the result says what it ended as.
237
+ */
238
+ export class RunArtifacts {
239
+ #byLocation = new Map();
240
+ /** Each string a call was given or answered with, bare, beside the call. Kept only to declare. */
241
+ #witnessed = [];
242
+ /** The last call each artifact was counted for, so a call that links and writes one counts once. */
243
+ #countedFor = new WeakMap();
244
+ /**
245
+ * The artifacts, in the order first seen.
246
+ *
247
+ * @returns A new array of the artifacts themselves, which later merges keep writing to.
248
+ */
249
+ list() {
250
+ return [...this.#byLocation.values()];
251
+ }
252
+ /**
253
+ * Adds one call's links, counting a uri seen before and taking over a guess at the same one.
254
+ *
255
+ * @param links - What the call's answer linked to, a uri given twice counted once.
256
+ * @param from - The call they came from.
257
+ * @returns The artifacts this call added, in the order its links gave them.
258
+ */
259
+ link(links, from) {
260
+ const seen = new Set();
261
+ const added = [];
262
+ for (const link of links) {
263
+ if (seen.has(link.uri)) {
264
+ continue;
265
+ }
266
+ seen.add(link.uri);
267
+ const known = this.#byLocation.get(link.uri);
268
+ if (known === undefined) {
269
+ const artifact = { link, ...from, count: 1 };
270
+ this.#byLocation.set(link.uri, artifact);
271
+ this.#countedFor.set(artifact, callKey(from));
272
+ added.push(artifact);
273
+ continue;
274
+ }
275
+ this.#count(known, from);
276
+ if (known.source !== undefined) {
277
+ // What a guess said of it stays only where the server's own link says nothing.
278
+ const { title, description, mimeType } = known.link;
279
+ known.link = {
280
+ ...(title ? { title } : {}),
281
+ ...(description ? { description } : {}),
282
+ ...(mimeType ? { mimeType } : {}),
283
+ ...link,
284
+ };
285
+ delete known.source;
286
+ }
287
+ }
288
+ return added;
289
+ }
290
+ /**
291
+ * Keeps what a successful call was given and answered, so a declaration naming it is believed.
292
+ *
293
+ * @param args - The call's arguments. Every string in them, however deep, is kept bare.
294
+ * @param answer - What the call answered, kept whole.
295
+ * @param links - The uris its answer linked to.
296
+ * @param from - The call.
297
+ */
298
+ witness(args, answer, links, from) {
299
+ const strings = (value) => {
300
+ if (typeof value === 'string') {
301
+ this.#witnessed.push({ text: bare(value), from });
302
+ }
303
+ else if (Array.isArray(value)) {
304
+ value.forEach(strings);
305
+ }
306
+ else if (value && typeof value === 'object') {
307
+ Object.values(value).forEach(strings);
308
+ }
309
+ };
310
+ strings(args);
311
+ this.#witnessed.push({ text: answer, from });
312
+ for (const { uri } of links) {
313
+ this.#witnessed.push({ text: bare(uri), from });
314
+ }
315
+ }
316
+ /**
317
+ * The latest call in the run that was given or answered with `location`, which is telos's rule
318
+ * for believing a declaration.
319
+ *
320
+ * @param location - The path or URI a declaration names.
321
+ * @returns That call, or `undefined` where none was. A blank location was touched by none.
322
+ */
323
+ seen(location) {
324
+ const wanted = bare(location);
325
+ if (wanted === '') {
326
+ return undefined;
327
+ }
328
+ return this.#witnessed.findLast(({ text }) => text === wanted || text.includes(wanted))?.from;
329
+ }
330
+ /**
331
+ * Merges a declaration or a detection into the artifact at its location, or adds one.
332
+ *
333
+ * @param draft - What was said of the location.
334
+ * @param from - The call to put a new artifact down to: the write, or for a declaration the call
335
+ * that touched the location. A merge keeps the earlier one's.
336
+ * @returns The artifact where this added one, and `undefined` where it merged into one known.
337
+ *
338
+ * @remarks
339
+ * A declaration also finds an artifact whose location is the same bare, so a model's
340
+ * `/work/a.md` describes the `work/a.md` the write was given rather than standing beside it.
341
+ */
342
+ add(draft, from) {
343
+ const known = this.#find(draft);
344
+ if (known === undefined) {
345
+ const artifact = {
346
+ link: linkOf(draft),
347
+ ...from,
348
+ count: 1,
349
+ source: draft.source,
350
+ ...(draft.source === ArtifactSource.Detected ? { action: draft.action } : {}),
351
+ ...(draft.sizeBytes === undefined ? {} : { sizeBytes: draft.sizeBytes }),
352
+ };
353
+ this.#byLocation.set(draft.location, artifact);
354
+ this.#countedFor.set(artifact, callKey(from));
355
+ return artifact;
356
+ }
357
+ if (draft.source === ArtifactSource.Detected) {
358
+ this.#count(known, from);
359
+ known.action = draft.action;
360
+ if (draft.sizeBytes !== undefined) {
361
+ known.sizeBytes = draft.sizeBytes;
362
+ }
363
+ if (known.link.mimeType === undefined && draft.mediaType) {
364
+ known.link = { ...known.link, mimeType: draft.mediaType };
365
+ }
366
+ return undefined;
367
+ }
368
+ const isLinked = known.source === undefined;
369
+ // Over a link, only what it left out; over a guess, what the model said wins.
370
+ const fill = (field, value) => {
371
+ const isTaken = isLinked && Boolean(known.link[field]);
372
+ if (value && isTaken === false) {
373
+ known.link = { ...known.link, [field]: value };
374
+ }
375
+ };
376
+ fill('title', draft.title);
377
+ fill('description', draft.description);
378
+ fill('mimeType', draft.mediaType);
379
+ if (isLinked === false) {
380
+ known.source = ArtifactSource.Declared;
381
+ }
382
+ return undefined;
383
+ }
384
+ /**
385
+ * Counts one more call that made an artifact, unless that call was counted for it already.
386
+ *
387
+ * @param artifact - The artifact. Written to.
388
+ * @param from - The call.
389
+ */
390
+ #count(artifact, from) {
391
+ const key = callKey(from);
392
+ if (this.#countedFor.get(artifact) !== key) {
393
+ artifact.count++;
394
+ this.#countedFor.set(artifact, key);
395
+ }
396
+ }
397
+ /**
398
+ * The artifact a draft lands on: the one at its location, or for a declaration one that is the
399
+ * same location bare.
400
+ *
401
+ * @param draft - What was said.
402
+ * @returns The artifact, or `undefined` where the location is new.
403
+ */
404
+ #find(draft) {
405
+ const exact = this.#byLocation.get(draft.location);
406
+ if (exact !== undefined || draft.source === ArtifactSource.Detected) {
407
+ return exact;
408
+ }
409
+ const wanted = bare(draft.location);
410
+ return [...this.#byLocation.values()].find((artifact) => bare(artifact.link.uri) === wanted);
411
+ }
412
+ }
@@ -91,9 +91,11 @@ export declare const RunEventKind: {
91
91
  /** Something the runner did that is not the model's doing — a preselection, a retry. */
92
92
  readonly Notice: 'notice';
93
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.
94
+ * A tool call's answer linked to something the run had not produced before — or, where the run
95
+ * collects them, a write was detected or a declaration believed at a new location. `id` is the
96
+ * call's the artifact is put down to, `name` its tool, `text` the link's title or else its uri,
97
+ * and `link` the link itself. Once per location per run: a later call that links or writes it
98
+ * again only adds to its count, and a declaration over it only fills it in.
97
99
  */
98
100
  readonly Artifact: 'artifact';
99
101
  /**
@@ -58,9 +58,11 @@ export const RunEventKind = {
58
58
  /** Something the runner did that is not the model's doing — a preselection, a retry. */
59
59
  Notice: 'notice',
60
60
  /**
61
- * A tool call's answer linked to something the run had not produced before. `id` is the call's,
62
- * `name` the tool, `text` the link's title or else its uri, and `link` the link itself. Once
63
- * per uri per run: a later call that links the same uri again only adds to its count.
61
+ * A tool call's answer linked to something the run had not produced before — or, where the run
62
+ * collects them, a write was detected or a declaration believed at a new location. `id` is the
63
+ * call's the artifact is put down to, `name` its tool, `text` the link's title or else its uri,
64
+ * and `link` the link itself. Once per location per run: a later call that links or writes it
65
+ * again only adds to its count, and a declaration over it only fills it in.
64
66
  */
65
67
  Artifact: 'artifact',
66
68
  /**
@@ -1,7 +1,8 @@
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 EndedBy, type RunArtifact, type ToolAnswer, type ToolCall, type ToolCallOutcome, type ToolCallReport, type ToolCallRequest, type ToolCallResult } from '../wire/tool-calls.ts';
4
+ import { type EndedBy, type ToolAnswer, type ToolCall, type ToolCallOutcome, type ToolCallReport, type ToolCallRequest, type ToolCallResult } from '../wire/tool-calls.ts';
5
+ import { type RunArtifacts } from './artifacts.ts';
5
6
  import type { LoadCounts, RunEventInput } from './events.ts';
6
7
  /**
7
8
  * The tools between two turns: a step's calls read, run and written into the transcript.
@@ -31,12 +32,15 @@ export interface ReadCall {
31
32
  error?: unknown;
32
33
  /** The arguments as every later request replays them: the repaired JSON, or `{}` for none. */
33
34
  normal: string;
35
+ /** Whether `args` took a repair to read, as `ToolCallRequest.repaired` says. */
36
+ repaired: boolean;
34
37
  }
35
38
  /**
36
39
  * A turn's calls with their arguments read.
37
40
  *
38
41
  * @param calls - The turn's calls, in the order the model made them.
39
42
  * @param finishReason - Why the turn ended, which tells a call cut off from one written wrongly.
43
+ * @param [recovered] - Whether the calls were recovered from the reply's text.
40
44
  * @returns One entry per call, in the same order. Unreadable arguments do not throw: the entry
41
45
  * carries the error in place of them.
42
46
  *
@@ -44,7 +48,7 @@ export interface ReadCall {
44
48
  * A server that parses replayed arguments refuses the almost-JSON a model wrote, so each is
45
49
  * normalised here, and one that could not be read at all is replayed as no arguments.
46
50
  */
47
- export declare const readCalls: (calls: ToolCall[], finishReason: Turn['finishReason']) => ReadCall[];
51
+ export declare const readCalls: (calls: ToolCall[], finishReason: Turn['finishReason'], recovered?: boolean) => ReadCall[];
48
52
  /**
49
53
  * The assistant message a turn is written into the transcript as, its calls' arguments repaired.
50
54
  *
@@ -54,6 +58,15 @@ export declare const readCalls: (calls: ToolCall[], finishReason: Turn['finishRe
54
58
  * @returns A new message, each call on it a copy with `normal` for its arguments.
55
59
  */
56
60
  export declare const assistantMessage: (content: string, parsed: ReadCall[]) => OpenAI.ChatCompletionAssistantMessageParam;
61
+ /**
62
+ * The assistant message a turn is, as the model produced it: its text and calls as they streamed.
63
+ *
64
+ * @param content - The turn's text, recovered calls left in it. Empty is written as `null`.
65
+ * @param calls - The calls the model sent as calls, with their arguments as streamed. Not written
66
+ * to. None leaves `tool_calls` off the message rather than empty.
67
+ * @returns A new message, each call on it a copy.
68
+ */
69
+ export declare const writtenMessage: (content: string, calls: ToolCall[]) => OpenAI.ChatCompletionAssistantMessageParam;
57
70
  /**
58
71
  * The definitions the host gave for these names, in the order the names are given.
59
72
  *
@@ -92,8 +105,12 @@ export interface Calling {
92
105
  loads: LoadCounts;
93
106
  used: Set<string>;
94
107
  toolCalls: ToolCallOutcome[];
95
- /** What the run's calls linked to, by uri, in the order first seen. */
96
- artifacts: Map<string, RunArtifact>;
108
+ /** What the run's calls linked to, wrote, or were said to have stored, by location. */
109
+ artifacts: RunArtifacts;
110
+ /** Whether `record_artifact` is the loop's to answer, and every successful call is witnessed. */
111
+ declares: boolean;
112
+ /** Which successful calls are read for the shape of a write. Absent where none are. */
113
+ detects: ((call: ToolCallRequest) => boolean) | undefined;
97
114
  dedupable: (call: ToolCallRequest) => boolean;
98
115
  dispatch: ToolDispatch;
99
116
  /** All of a step's calls together, each alone, or together in runs of the calls it passes. */