@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.
package/README.md CHANGED
@@ -568,6 +568,38 @@ its calls made on the error too, read off with `failedRun`.
568
568
  A link meant for the model alone — one whose MCP `audience` leaves out `"user"` — is the
569
569
  dispatcher's to drop, since that is MCP's rule and this package knows nothing of MCP.
570
570
 
571
+ A link is the only source that takes no guessing, and the default. For servers that never send
572
+ one, `artifacts` turns on the two that do, each off unless asked for:
573
+
574
+ ```ts
575
+ const { artifacts } = await runAgentLoop({
576
+ // ...
577
+ artifacts: { declare: true, detect: true },
578
+ });
579
+ // [{ link, callId, tool, step, count, source: "declared", action: "updated", sizeBytes }]
580
+ ```
581
+
582
+ - **`declare`** puts `record_artifact` (`RECORD_ARTIFACT_TOOL`) beside `hostTools` on every step,
583
+ in every mode, and the loop answers it without `dispatch` seeing it. It takes `location`,
584
+ `title`, `description` and `mediaType`. A declaration is believed only where some successful
585
+ call in the run was given or answered with that location — otherwise a model with no file tools
586
+ "saves" its answer to a file it never wrote — and the refusal is the call's answer, for the model
587
+ to read. A believed one is put down to the latest call that touched the location. In a
588
+ `parallel` batch it waits for the batch's other calls, so a write beside it counts.
589
+ - **`detect`** reads every successful call for the shape of a write: a verb at the front of the
590
+ tool's own name (`write`, `create`, `save`, `upload`, `edit`, `move`, `delete`…) and a path in
591
+ its arguments (`destination`, `path`, `file_path`, `uri`…). Creating a folder is not an artifact.
592
+ A predicate in place of `true` reads only the calls it passes, which is how a host keeps its own
593
+ tools' writes off the list. `detectArtifact` is the same reading, for a host to call itself.
594
+
595
+ One artifact per location per run, whichever sources found it. A later write moves `action`
596
+ (`created`, `updated`, `moved`, `deleted`) and `sizeBytes` on; a declaration over a detection
597
+ fills in the title and description and takes the `source` over, and never turns an `updated` back
598
+ into `created`. A link is the server's word: a declaration over one fills only what it left out,
599
+ and a link over either guess takes over. `source` is absent on a linked artifact, so the shape
600
+ from before reads the same — `artifactSource` reads it as `linked`. The `artifact` event goes out
601
+ once per location; the result's `artifacts` say what each ended as.
602
+
571
603
  ### A call that ends the run
572
604
 
573
605
  A tool whose call is the end of the job — handing off, asking a person — says so with `end` on
@@ -610,8 +642,10 @@ string. What still is not an object throws a `ToolArgumentsError` whose `kind` i
610
642
  the turn stopped at the ceiling, with a message telling the model so, and `malformed` otherwise.
611
643
  The repaired JSON is what the transcript keeps, and an unreadable call is replayed as `{}`, because
612
644
  a server that parses replayed arguments refuses the originals on every later request. `dispatch`
613
- is still handed the model's own text as `raw`, and the dedupe compares repaired arguments,
614
- so `{'a': 1}` and `{"a": 1}` are one call.
645
+ is still handed the model's own text as `raw`, with `repaired` true where reading it took a repair
646
+ — whitespace and blank arguments do not count — and the dedupe compares repaired arguments, so
647
+ `{'a': 1}` and `{"a": 1}` are one call. A call recovered from text, below, is always `repaired`,
648
+ and its `raw` is the arguments recovery read out of the reply, as JSON.
615
649
 
616
650
  A server whose tool-call parser was written for another template streams the model's call as
617
651
  plain text, and the run ends on a reply that is nothing but a call nobody made. Unless
@@ -969,7 +1003,11 @@ try {
969
1003
 
970
1004
  `onMessage` hears the assistant message as it will be replayed — recovered calls folded in, and
971
1005
  arguments repaired, which the turn `onTurn` is handed does not show — together with that `Turn`,
972
- and then each tool result, in transcript order. `onTurn` is not awaited and never sees a tool
1006
+ and then each tool result, in transcript order. Beside an assistant message the model wrote comes a
1007
+ fourth argument, `{ raw }`: the same message as the model produced it, its text whole with any call
1008
+ written into it left there, and its calls' arguments exactly as streamed, under the same ids. Store
1009
+ or show `raw` to keep what the model said; replay `message` to keep what an endpoint will take back.
1010
+ The loop's own messages — a proxied run's preselected load — come without it. `onTurn` is not awaited and never sees a tool
973
1011
  result; this is the one to store from. What it returns is ignored, since `beforeStep` is the one way to rewrite
974
1012
  the transcript, and what it throws ends the run.
975
1013
 
package/dist/index.d.ts CHANGED
@@ -19,7 +19,8 @@ export { isGrammarError, relaxSchema, relaxTools, sanitizeSchema, sanitizeTools,
19
19
  export { CACHE_PROMPT_FIELD, resetSlots, SESSION_HEADER, SLOT_FIELD, type SlotAllocator, slotAllocator, } from './endpoint/slots.ts';
20
20
  export { HOOK_EVENTS, HookEvent, INJECT_EVENTS } from './hooks/hook-events.ts';
21
21
  export { assembleContext, configureHooks, consult, type Gathered, gather, HOOK_CONTEXT_TOKENS, HOOK_PREFACE, type HookContext, type HookMessage, type HookNote, type HookOptions, type HookOutcome, type HookRunner, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from './hooks/hooks.ts';
22
- export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopRequest, type AgentLoopResult, PRESELECT_APPEND, PRESELECT_EXCLUSIVE, PRESELECTED_NOTICE, runAgentLoop, type StepWindow, } from './run/agent-loop.ts';
22
+ export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopRequest, type AgentLoopResult, PRESELECT_APPEND, PRESELECT_EXCLUSIVE, PRESELECTED_NOTICE, runAgentLoop, type StepWindow, type WrittenMessage, } from './run/agent-loop.ts';
23
+ export { type ArtifactDraft, type ArtifactOptions, artifactSource, declaredArtifact, detectArtifact, mediaTypeFor, RECORD_ARTIFACT, RECORD_ARTIFACT_TOOL, } from './run/artifacts.ts';
23
24
  export { configureEvents, type EventBusOptions, emit, endRun, fold, history, type LoadCounts, type RunEvent, type RunEventInput, RunEventKind, type RunMetrics, type RunMetricsOptions, RunOutcome, type RunUsage, resetEvents, runMetrics, type TurnReport, watch, } from './run/events.ts';
24
25
  export { buildBody } from './run/request-body.ts';
25
26
  export { preview, type ToolDispatch } from './run/run-calls.ts';
@@ -40,5 +41,5 @@ export { backoffMs, ContextOverflow, EndpointSilent, isModelLoading, isOverflow,
40
41
  export { CacheBreakReason, type InFlight, type LoopUsage, type PartialTurn, type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, type UsageTotals, } from './wire/stream.ts';
41
42
  export { ALL_FENCES, DEFAULT_FENCES, type Fence, FenceSplitter, type FenceSplitterOptions, SPLIT_OUTPUT, SPLIT_REASONING, type Split, stripThinking, THINK_FENCE, } from './wire/thinking.ts';
42
43
  export { type ContextBreakdown, type ContextBreakdownOptions, compact, contextChars, contextTokens, estimateTokens, messageTokens, requestChars, requestTokens, type TokenEstimateOptions, } from './wire/tokens.ts';
43
- export { ARGUMENTS_MALFORMED, ARGUMENTS_TRUNCATED, type EndedBy, parseToolArguments, type ResourceLink, type RunArtifact, recoverToolCalls, type ServerLogEntry, ServerLogLevel, type ToolAnswer, ToolArgumentsError, type ToolCall, type ToolCallOutcome, type ToolCallReport, type ToolCallRequest, type ToolCallResult, } from './wire/tool-calls.ts';
44
+ export { ARGUMENTS_MALFORMED, ARGUMENTS_TRUNCATED, ArtifactAction, ArtifactSource, type EndedBy, parseToolArguments, type ResourceLink, type RunArtifact, recoverToolCalls, type ServerLogEntry, ServerLogLevel, type ToolAnswer, ToolArgumentsError, type ToolCall, type ToolCallOutcome, type ToolCallReport, type ToolCallRequest, type ToolCallResult, } from './wire/tool-calls.ts';
44
45
  export { FinishReason, FUNCTION_TOOL, JSON_SCHEMA_FORMAT, PartType, Role, SchemaType } from './wire/wire.ts';
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ export { CACHE_PROMPT_FIELD, resetSlots, SESSION_HEADER, SLOT_FIELD, slotAllocat
20
20
  export { HOOK_EVENTS, HookEvent, INJECT_EVENTS } from './hooks/hook-events.js';
21
21
  export { assembleContext, configureHooks, consult, gather, HOOK_CONTEXT_TOKENS, HOOK_PREFACE, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from './hooks/hooks.js';
22
22
  export { PRESELECT_APPEND, PRESELECT_EXCLUSIVE, PRESELECTED_NOTICE, runAgentLoop, } from './run/agent-loop.js';
23
+ export { artifactSource, declaredArtifact, detectArtifact, mediaTypeFor, RECORD_ARTIFACT, RECORD_ARTIFACT_TOOL, } from './run/artifacts.js';
23
24
  export { configureEvents, emit, endRun, fold, history, RunEventKind, RunOutcome, resetEvents, runMetrics, watch, } from './run/events.js';
24
25
  export { buildBody } from './run/request-body.js';
25
26
  export { preview } from './run/run-calls.js';
@@ -39,5 +40,5 @@ export { backoffMs, ContextOverflow, EndpointSilent, isModelLoading, isOverflow,
39
40
  export { CacheBreakReason, streamTurn, } from './wire/stream.js';
40
41
  export { ALL_FENCES, DEFAULT_FENCES, FenceSplitter, SPLIT_OUTPUT, SPLIT_REASONING, stripThinking, THINK_FENCE, } from './wire/thinking.js';
41
42
  export { compact, contextChars, contextTokens, estimateTokens, messageTokens, requestChars, requestTokens, } from './wire/tokens.js';
42
- export { ARGUMENTS_MALFORMED, ARGUMENTS_TRUNCATED, parseToolArguments, recoverToolCalls, ServerLogLevel, ToolArgumentsError, } from './wire/tool-calls.js';
43
+ export { ARGUMENTS_MALFORMED, ARGUMENTS_TRUNCATED, ArtifactAction, ArtifactSource, parseToolArguments, recoverToolCalls, ServerLogLevel, ToolArgumentsError, } from './wire/tool-calls.js';
43
44
  export { FinishReason, FUNCTION_TOOL, JSON_SCHEMA_FORMAT, PartType, Role, SchemaType } from './wire/wire.js';
@@ -11,6 +11,7 @@ import { type ToolOrder } from '../tools/tool-loading.ts';
11
11
  import { type SideTask } from '../turn/side-task.ts';
12
12
  import { type LoopUsage, type Turn } from '../wire/stream.ts';
13
13
  import { type EndedBy, type RunArtifact, type ToolCallOutcome, type ToolCallRequest, type ToolCallResult } from '../wire/tool-calls.ts';
14
+ import { type ArtifactOptions } from './artifacts.ts';
14
15
  import { type RunEventInput, type RunMetrics } from './events.ts';
15
16
  import { type ToolDispatch } from './run-calls.ts';
16
17
  /**
@@ -23,6 +24,24 @@ import { type ToolDispatch } from './run-calls.ts';
23
24
  * and two never did. What is here is the part that does not know what the run is for; the
24
25
  * prompts, the tools and what a run means stay with the caller.
25
26
  */
27
+ /**
28
+ * What `onMessage` is handed beside an assistant message the model wrote: that message as the model
29
+ * produced it.
30
+ *
31
+ * @remarks
32
+ * The message `onMessage` is handed is what later requests replay — arguments repaired, `{}` for
33
+ * none, recovered calls taken out of the text and made calls — which is right to send and quietly
34
+ * wrong to show as the model's. Absent for a message the loop wrote itself, such as a proxied
35
+ * run's preselected load.
36
+ */
37
+ export interface WrittenMessage {
38
+ /**
39
+ * The reply as it streamed: its text whole, a call written into it as text left there, and the
40
+ * calls it sent as calls with their arguments as streamed. A call that never got a name is left
41
+ * off, since nothing answers it. Ids are the turn's, so the tool results pair with either.
42
+ */
43
+ raw: OpenAI.ChatCompletionAssistantMessageParam;
44
+ }
26
45
  /**
27
46
  * One step's request as `onRequest` is handed it: what the model is about to read.
28
47
  *
@@ -361,6 +380,23 @@ export interface AgentLoopOptions {
361
380
  * `idempotentHint` annotations can answer it; nothing in an OpenAI tool definition can.
362
381
  */
363
382
  dedupeToolCalls?: boolean | ((call: ToolCallRequest) => boolean);
383
+ /**
384
+ * Collects artifacts from the two sources that take a guess, beside the `links` every run keeps:
385
+ * `declare` has the model name them through `record_artifact`, and `detect` reads each successful
386
+ * call for the shape of a write. Both off by default.
387
+ *
388
+ * @remarks
389
+ * For servers that never send a `resource_link`, which is the source to rely on. One artifact per
390
+ * location either way: a later write moves `action` on, a declaration over a detection fills in
391
+ * the title and description and takes the `source` over, and a link over either takes over too.
392
+ *
393
+ * `record_artifact` is declared beside `hostTools`, in every mode, and answered by the loop
394
+ * without `dispatch` seeing it. A declaration is believed only where some successful call in the
395
+ * run was given or answered with the location, and is otherwise refused for the model to read;
396
+ * run in a batch, it waits for the batch's other calls. `onToolCall` and `onToolResult` hear it
397
+ * as they hear `load_tools`, and it counts in `toolCalls` but not in `used`.
398
+ */
399
+ artifacts?: ArtifactOptions;
364
400
  /**
365
401
  * Told of every call the model made, as it starts — the ones `dispatch` never sees included:
366
402
  * `load_tools`, an identical repeat answered from the first, and a call whose arguments could
@@ -459,8 +495,12 @@ export interface AgentLoopOptions {
459
495
  * a proxied run writes for its `preselected` shortlist, told as step zero's before any request.
460
496
  * What it returns is ignored: `beforeStep` is the one way to rewrite the transcript. What it
461
497
  * throws ends the run, and it is not called again for that run.
498
+ *
499
+ * Beside an assistant message the model wrote comes `written`, whose `raw` is the message as the
500
+ * model produced it, for a host that shows or stores what was said rather than what is replayed.
501
+ * See `WrittenMessage`.
462
502
  */
463
- onMessage?: (message: OpenAI.ChatCompletionMessageParam, step: number, turn?: Turn) => void | Promise<void>;
503
+ onMessage?: (message: OpenAI.ChatCompletionMessageParam, step: number, turn?: Turn, written?: WrittenMessage) => void | Promise<void>;
464
504
  /**
465
505
  * How many times an answer cut off at `maxTokens` is continued, zero for never. Opt-in because
466
506
  * it spends another request, and on a server that does not continue a trailing assistant
@@ -510,7 +550,7 @@ export interface AgentLoopResult {
510
550
  /**
511
551
  * What the run's calls linked to, one per uri in the order first seen, each with the call and
512
552
  * step that first linked to it and how many calls did. Empty where `dispatch` answered with
513
- * strings only.
553
+ * strings only and `artifacts` collected nothing else.
514
554
  */
515
555
  artifacts: RunArtifact[];
516
556
  /**
@@ -20,9 +20,10 @@ import { addCounts, loopUsage, noUsage, reportsCache, } from '../wire/stream.js'
20
20
  import { contextTokens, toolsChars } from '../wire/tokens.js';
21
21
  import { recoverToolCalls, } from '../wire/tool-calls.js';
22
22
  import { FinishReason, PartType, Role } from '../wire/wire.js';
23
+ import { RECORD_ARTIFACT_TOOL, RunArtifacts } from './artifacts.js';
23
24
  import { RunEventKind, RunOutcome, runMetrics, stamp, } from './events.js';
24
25
  import { buildBody } from './request-body.js';
25
- import { assistantMessage, definedAs, loadShortlist, readCalls, runCalls, } from './run-calls.js';
26
+ import { assistantMessage, definedAs, loadShortlist, readCalls, runCalls, writtenMessage, } from './run-calls.js';
26
27
  /** The `preselectRouting` that sends the first step the shortlist alone. */
27
28
  export const PRESELECT_EXCLUSIVE = 'exclusive';
28
29
  /** The `preselectRouting` that loads the shortlist after what was carried, and sends the first step like any other. */
@@ -136,7 +137,7 @@ export async function runAgentLoop(options) {
136
137
  }),
137
138
  };
138
139
  let result;
139
- const apart = hostToolsApart(options);
140
+ const apart = hostToolsApart(withRecordTool(options));
140
141
  try {
141
142
  pin = await pinSession(apart);
142
143
  result = await runSteps(apart, standing, pin);
@@ -440,6 +441,23 @@ function hostToolsApart(options) {
440
441
  ...(preselected ? { preselected: preselected.filter(isCatalogued) } : {}),
441
442
  };
442
443
  }
444
+ /**
445
+ * The run's options with `record_artifact` among the host's tools, where the run declares.
446
+ *
447
+ * @param options - What `runAgentLoop` was given. Not written to.
448
+ * @returns `options` itself where `artifacts.declare` is off, and otherwise a copy whose `hostTools`
449
+ * open with the loop's definition, which a host tool of the same name does not displace.
450
+ *
451
+ * @remarks
452
+ * A host tool in all but who answers it, so it is declared in every mode, kept off the catalogue,
453
+ * recoverable from text, and refused through `call_tool` exactly as the host's own are.
454
+ */
455
+ function withRecordTool(options) {
456
+ if (options.artifacts?.declare !== true) {
457
+ return options;
458
+ }
459
+ return { ...options, hostTools: loadedTools([RECORD_ARTIFACT_TOOL], options.hostTools ?? []) };
460
+ }
443
461
  /** What opens the notice naming the loop's own picks, ahead of the names. */
444
462
  export const PRESELECTED_NOTICE = 'tools picked before the run: ';
445
463
  /**
@@ -502,8 +520,8 @@ function definitionsByName(tools) {
502
520
  * Offers each message written into the transcript to the host.
503
521
  *
504
522
  * @param onMessage - The host's callback, awaited. Absent offers nothing.
505
- * @returns The offer, taking a message, its step and the turn it came with: it resolves once the
506
- * host has taken the message, and rejects with what the host threw — once.
523
+ * @returns The offer, taking a message, its step, the turn it came with and what the model wrote:
524
+ * it resolves once the host has taken the message, and rejects with what the host threw — once.
507
525
  *
508
526
  * @remarks
509
527
  * Once `onMessage` has thrown the run is ending on that, and the results still to be written are
@@ -511,12 +529,12 @@ function definitionsByName(tools) {
511
529
  */
512
530
  function announcer(onMessage) {
513
531
  let heard = true;
514
- return async (message, step, turn) => {
532
+ return async (message, step, turn, written) => {
515
533
  if (!onMessage || !heard) {
516
534
  return;
517
535
  }
518
536
  try {
519
- await onMessage(message, step, turn);
537
+ await onMessage(message, step, turn, written);
520
538
  }
521
539
  catch (error) {
522
540
  heard = false;
@@ -558,21 +576,21 @@ const usageEvent = (usage, turn, ledger, contextLength) => {
558
576
  * nothing is looked for in a turn that made a named call of its own or has no text.
559
577
  * @param notice - Told when calls were recovered, and how many.
560
578
  * @returns `calls`, the turn's own less any with no name, and `content`, its text as it was — or,
561
- * where calls were recovered, those calls and the text without them.
579
+ * where calls were recovered, those calls and the text without them, and `recovered` true.
562
580
  */
563
581
  function turnCalls(turn, recoverable, notice) {
564
582
  // A call with no name is a fragment the server never finished sending: nothing to run, and
565
583
  // an assistant message naming it would be answered by nothing.
566
584
  const calls = turn.toolCalls.filter((call) => call.function.name);
567
585
  if (!recoverable || calls.length || !turn.content) {
568
- return { calls, content: turn.content };
586
+ return { calls, content: turn.content, recovered: false };
569
587
  }
570
588
  const recovered = recoverToolCalls(turn.content, { names: recoverable });
571
589
  if (!recovered.toolCalls.length) {
572
- return { calls, content: turn.content };
590
+ return { calls, content: turn.content, recovered: false };
573
591
  }
574
592
  notice(`recovered ${counted(recovered.toolCalls.length, 'tool call')} the model wrote as text; the server's tool-call parser does not match this model's template`);
575
- return { calls: recovered.toolCalls, content: recovered.content };
593
+ return { calls: recovered.toolCalls, content: recovered.content, recovered: true };
576
594
  }
577
595
  /**
578
596
  * The steps of `runAgentLoop`, throwing what they caught as it was caught.
@@ -586,7 +604,7 @@ function turnCalls(turn, recoverable, notice) {
586
604
  */
587
605
  async function runSteps(options, standing, pin) {
588
606
  const { config, system = '', tools = [], catalog = [], dispatch, hooks, signal } = options;
589
- const { onRequest, onTurn, onMessage, onToolCall, onToolResult, beforeStep, parallel = false, recoverToolCalls: recover = true, maxContinuations = 0, toolOrder = true, dedupeToolCalls = true, preselectRouting = PRESELECT_EXCLUSIVE, serverInstructions = false, } = options;
607
+ const { onRequest, onTurn, onMessage, onToolCall, onToolResult, beforeStep, parallel = false, recoverToolCalls: recover = true, maxContinuations = 0, toolOrder = true, dedupeToolCalls = true, preselectRouting = PRESELECT_EXCLUSIVE, serverInstructions = false, artifacts: collecting = {}, } = options;
590
608
  const dedupable = typeof dedupeToolCalls === 'function' ? dedupeToolCalls : () => dedupeToolCalls;
591
609
  const started = Date.now();
592
610
  const { recorded, onEvent } = recorder(options.onEvent);
@@ -637,7 +655,9 @@ async function runSteps(options, standing, pin) {
637
655
  let usage = noUsage();
638
656
  let cacheReported = false;
639
657
  const toolCalls = [];
640
- const artifacts = new Map();
658
+ const artifacts = new RunArtifacts();
659
+ const { detect = false } = collecting;
660
+ const detects = typeof detect === 'function' ? detect : detect ? () => true : undefined;
641
661
  // A box per request, emptied once its turn is in `messages`: a failure while the tools run had
642
662
  // no request in flight.
643
663
  let inFlight = {};
@@ -648,7 +668,7 @@ async function runSteps(options, standing, pin) {
648
668
  ...(partial ? { partial } : {}),
649
669
  usage: loopUsage(usage, cacheReported),
650
670
  toolCalls,
651
- artifacts: [...artifacts.values()],
671
+ artifacts: artifacts.list(),
652
672
  // As the result would have said it: proxied, nothing is loaded that the history does not hold.
653
673
  loaded: proxied ? [] : [...loaded],
654
674
  used: [...used],
@@ -693,6 +713,8 @@ async function runSteps(options, standing, pin) {
693
713
  used,
694
714
  toolCalls,
695
715
  artifacts,
716
+ declares: collecting.declare === true,
717
+ detects,
696
718
  dedupable,
697
719
  dispatch,
698
720
  parallel,
@@ -850,15 +872,16 @@ async function runSteps(options, standing, pin) {
850
872
  notice(`the model stopped at maxTokens (${config.maxTokens}); this turn is cut short`);
851
873
  }
852
874
  const canRecover = recover && (tools.length > 0 || hostTools.length > 0 || onDemand);
853
- const { calls, content } = turnCalls(turn, canRecover ? recoverable : undefined, notice);
875
+ const { calls, content, recovered } = turnCalls(turn, canRecover ? recoverable : undefined, notice);
854
876
  const shown = { ...turn, content, toolCalls: calls };
855
877
  onTurn?.(shown, step);
856
878
  // Read before the assistant message is written, so what is replayed on every later request
857
879
  // is the repaired JSON.
858
- const parsed = readCalls(calls, turn.finishReason);
880
+ const parsed = readCalls(calls, turn.finishReason, recovered);
859
881
  const assistant = assistantMessage(content, parsed);
860
882
  messages.push(assistant);
861
- await announce(assistant, step, shown);
883
+ // Recovered calls were never calls on the wire: what the model said is the text they came from.
884
+ await announce(assistant, step, shown, { raw: writtenMessage(turn.content, recovered ? [] : calls) });
862
885
  const isAnswer = calls.length === 0;
863
886
  const ended = isAnswer ? undefined : await runCalls(calling, parsed, messages, step);
864
887
  if (ended) {
@@ -883,7 +906,7 @@ async function runSteps(options, standing, pin) {
883
906
  messages,
884
907
  usage: loopUsage(usage, cacheReported),
885
908
  toolCalls,
886
- artifacts: [...artifacts.values()],
909
+ artifacts: artifacts.list(),
887
910
  loaded: proxied ? [] : [...loaded],
888
911
  used: [...used],
889
912
  preselected: [...preselected],
@@ -0,0 +1,145 @@
1
+ import type OpenAI from 'openai';
2
+ import { ArtifactAction, ArtifactSource, type ResourceLink, type RunArtifact, type ToolCallRequest } from '../wire/tool-calls.ts';
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 declare const RECORD_ARTIFACT = "record_artifact";
13
+ /** The definition of `record_artifact`, as the loop declares it beside the host's own tools. */
14
+ export declare const RECORD_ARTIFACT_TOOL: OpenAI.ChatCompletionFunctionTool;
15
+ /**
16
+ * Which of the guessing sources a run collects, beside the links every run keeps.
17
+ *
18
+ * @remarks
19
+ * Both are off by default: `linked` is the source to rely on, and these are for servers that
20
+ * never send a `resource_link`.
21
+ */
22
+ export interface ArtifactOptions {
23
+ /**
24
+ * Declares `record_artifact` on every step and answers it in the loop, never through `dispatch`.
25
+ * The host's own tool of that name, if it has one, is not dispatched while this is on.
26
+ *
27
+ * @defaultValue `false`
28
+ */
29
+ declare?: boolean;
30
+ /**
31
+ * Reads every successful call for the shape of a write. A predicate reads only the calls it
32
+ * passes, which is how a host keeps its own tools' writes — a note on its board — off the list.
33
+ *
34
+ * @defaultValue `false`
35
+ */
36
+ detect?: boolean | ((call: ToolCallRequest) => boolean);
37
+ }
38
+ /** What one declaration or detection said of a location, before it is merged into the run's. */
39
+ export interface ArtifactDraft {
40
+ /** The path or URI, trimmed. */
41
+ location: string;
42
+ source: typeof ArtifactSource.Declared | typeof ArtifactSource.Detected;
43
+ /** What was done to it. A declaration says `created`, and never moves an earlier write's. */
44
+ action: ArtifactAction;
45
+ title?: string;
46
+ description?: string;
47
+ /** As declared, or else a guess off the extension. */
48
+ mediaType?: string;
49
+ /** The bytes of the content the write carried, where its arguments held it. */
50
+ sizeBytes?: number;
51
+ }
52
+ /** The call an artifact is put down to: the one that linked it, wrote it, or touched it. */
53
+ export type ArtifactFrom = Pick<RunArtifact, 'callId' | 'tool' | 'step'>;
54
+ /**
55
+ * What a successful call wrote, if its name and arguments look like a write.
56
+ *
57
+ * @param name - The tool, as the call named it: `fs__write_file` and `write_file` read alike, the
58
+ * verb being the last `__` segment.
59
+ * @param args - The call's arguments.
60
+ * @returns The draft, or `null` where the name has no write verb, names a folder, or the arguments
61
+ * hold no location.
62
+ */
63
+ export declare function detectArtifact(name: string, args: Record<string, unknown>): ArtifactDraft | null;
64
+ /**
65
+ * What the model said through `record_artifact`.
66
+ *
67
+ * @param args - The call's arguments.
68
+ * @returns The draft, or `null` where it named no location.
69
+ */
70
+ export declare function declaredArtifact(args: Record<string, unknown>): ArtifactDraft | null;
71
+ /**
72
+ * A media type guessed off a location's extension.
73
+ *
74
+ * @param location - The path or URI. A query or fragment is ignored.
75
+ * @returns The media type, or `null` for a name with no extension, a dotfile, or an extension this
76
+ * table does not know.
77
+ */
78
+ export declare function mediaTypeFor(location: string): string | null;
79
+ /**
80
+ * How an artifact came to be known, reading an absent `source` as the link it always was.
81
+ *
82
+ * @param artifact - One of a run's artifacts.
83
+ * @returns Its source, `linked` where it carries none.
84
+ */
85
+ export declare const artifactSource: (artifact: Pick<RunArtifact, 'source'>) => ArtifactSource;
86
+ /**
87
+ * A run's artifacts, one per location, and what its calls touched for a declaration to be checked
88
+ * against.
89
+ *
90
+ * @remarks
91
+ * The merge rules are kanban-server's. A later write moves `action` and `sizeBytes` on and leaves
92
+ * the rest; a declaration over a detection fills in what only the model could say and takes the
93
+ * source over, without turning an `updated` back into `created`. A link is the server's word, so
94
+ * a declaration over one fills only what it left out, and a link over either guess takes over.
95
+ * Each method that adds a location answers with the new artifact, for the caller to announce;
96
+ * a merge into one already known answers nothing, since the event says when a location was first
97
+ * seen and the result says what it ended as.
98
+ */
99
+ export declare class RunArtifacts {
100
+ #private;
101
+ /**
102
+ * The artifacts, in the order first seen.
103
+ *
104
+ * @returns A new array of the artifacts themselves, which later merges keep writing to.
105
+ */
106
+ list(): RunArtifact[];
107
+ /**
108
+ * Adds one call's links, counting a uri seen before and taking over a guess at the same one.
109
+ *
110
+ * @param links - What the call's answer linked to, a uri given twice counted once.
111
+ * @param from - The call they came from.
112
+ * @returns The artifacts this call added, in the order its links gave them.
113
+ */
114
+ link(links: ResourceLink[], from: ArtifactFrom): RunArtifact[];
115
+ /**
116
+ * Keeps what a successful call was given and answered, so a declaration naming it is believed.
117
+ *
118
+ * @param args - The call's arguments. Every string in them, however deep, is kept bare.
119
+ * @param answer - What the call answered, kept whole.
120
+ * @param links - The uris its answer linked to.
121
+ * @param from - The call.
122
+ */
123
+ witness(args: Record<string, unknown>, answer: string, links: ResourceLink[], from: ArtifactFrom): void;
124
+ /**
125
+ * The latest call in the run that was given or answered with `location`, which is telos's rule
126
+ * for believing a declaration.
127
+ *
128
+ * @param location - The path or URI a declaration names.
129
+ * @returns That call, or `undefined` where none was. A blank location was touched by none.
130
+ */
131
+ seen(location: string): ArtifactFrom | undefined;
132
+ /**
133
+ * Merges a declaration or a detection into the artifact at its location, or adds one.
134
+ *
135
+ * @param draft - What was said of the location.
136
+ * @param from - The call to put a new artifact down to: the write, or for a declaration the call
137
+ * that touched the location. A merge keeps the earlier one's.
138
+ * @returns The artifact where this added one, and `undefined` where it merged into one known.
139
+ *
140
+ * @remarks
141
+ * A declaration also finds an artifact whose location is the same bare, so a model's
142
+ * `/work/a.md` describes the `work/a.md` the write was given rather than standing beside it.
143
+ */
144
+ add(draft: ArtifactDraft, from: ArtifactFrom): RunArtifact | undefined;
145
+ }