@ai-sdk/harness 1.0.39 → 1.0.41

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.
@@ -42,9 +42,9 @@ export interface TurnTelemetry {
42
42
  * model the runtime resolved to (overriding the session's configured id).
43
43
  * Idempotent — the first call wins.
44
44
  */
45
- start(modelId?: string): void;
45
+ start(modelId?: string): Promise<void>;
46
46
  /** Open a step span lazily, before the first content of a step. */
47
- ensureStepOpen(): void;
47
+ ensureStepOpen(): Promise<void>;
48
48
  /** Close the current step (on a harness `finish-step`). */
49
49
  stepFinish(info: {
50
50
  finishReason: unknown;
@@ -52,13 +52,18 @@ export interface TurnTelemetry {
52
52
  providerMetadata?: unknown;
53
53
  /** The model's output content for this step (text/reasoning/tool-calls). */
54
54
  content?: TurnContentPart[];
55
- }): void;
55
+ }): Promise<void>;
56
56
  /** A tool execution began (on a `tool-call`). */
57
57
  toolStart(call: {
58
58
  toolCallId: string;
59
59
  toolName: string;
60
60
  input: unknown;
61
- }): void;
61
+ }): Promise<void>;
62
+ /** Execute a host tool through each telemetry integration's context wrapper. */
63
+ executeTool<T>(input: {
64
+ toolCallId: string;
65
+ execute: () => PromiseLike<T>;
66
+ }): Promise<T>;
62
67
  /**
63
68
  * A tool execution completed (on its `tool-result` or after host execution).
64
69
  * Idempotent per `toolCallId` — the first caller wins, so provider-executed
@@ -67,21 +72,24 @@ export interface TurnTelemetry {
67
72
  toolEnd(
68
73
  toolCallId: string,
69
74
  output: { ok: true; output: unknown } | { ok: false; error: unknown },
70
- ): void;
75
+ ): Promise<void>;
71
76
  /** The turn ended (on a harness `finish`). */
72
- end(info: { finishReason: unknown; usage: unknown }): void;
77
+ end(info: { finishReason: unknown; usage: unknown }): Promise<void>;
73
78
  /** The turn failed. */
74
- error(err: unknown): void;
79
+ error(err: unknown): Promise<void>;
75
80
  }
76
81
 
77
82
  const NOOP: TurnTelemetry = {
78
- start() {},
79
- ensureStepOpen() {},
80
- stepFinish() {},
81
- toolStart() {},
82
- toolEnd() {},
83
- end() {},
84
- error() {},
83
+ async start() {},
84
+ async ensureStepOpen() {},
85
+ async stepFinish() {},
86
+ async toolStart() {},
87
+ async executeTool({ execute }) {
88
+ return await execute();
89
+ },
90
+ async toolEnd() {},
91
+ async end() {},
92
+ async error() {},
85
93
  };
86
94
 
87
95
  export function createTurnTelemetry(opts: {
@@ -122,10 +130,10 @@ export function createTurnTelemetry(opts: {
122
130
 
123
131
  // onStart — open the operation (root) span. Deferred until `start()` so the
124
132
  // runtime-resolved model can be attached to the operation span + trace label.
125
- const fireStart = (): void => {
133
+ const fireStart = async (): Promise<void> => {
126
134
  if (started) return;
127
135
  started = true;
128
- dispatcher.onStart?.(
136
+ await dispatcher.onStart?.(
129
137
  cast<'onStart'>({
130
138
  callId,
131
139
  operationId: 'ai.harness',
@@ -147,17 +155,17 @@ export function createTurnTelemetry(opts: {
147
155
  );
148
156
  };
149
157
 
150
- const start = (overrideModelId?: string): void => {
158
+ const start = async (overrideModelId?: string): Promise<void> => {
151
159
  if (started) return;
152
160
  if (overrideModelId) modelId = overrideModelId;
153
- fireStart();
161
+ await fireStart();
154
162
  };
155
163
 
156
- const ensureStepOpen = (): void => {
157
- if (!started) fireStart();
164
+ const ensureStepOpen = async (): Promise<void> => {
165
+ if (!started) await fireStart();
158
166
  if (stepOpen || ended) return;
159
167
  stepOpen = true;
160
- dispatcher.onStepStart?.(
168
+ await dispatcher.onStepStart?.(
161
169
  cast<'onStepStart'>({
162
170
  callId,
163
171
  provider,
@@ -175,7 +183,7 @@ export function createTurnTelemetry(opts: {
175
183
  );
176
184
  // Open the inference (language-model call) span — the gen_ai home for the
177
185
  // step's input and (on end) output messages.
178
- dispatcher.onLanguageModelCallStart?.(
186
+ await dispatcher.onLanguageModelCallStart?.(
179
187
  cast<'onLanguageModelCallStart'>({
180
188
  callId,
181
189
  provider,
@@ -187,12 +195,12 @@ export function createTurnTelemetry(opts: {
187
195
  };
188
196
 
189
197
  /** Close the inference span with the step's output content. */
190
- const inferenceEnd = (info: {
198
+ const inferenceEnd = async (info: {
191
199
  finishReason: unknown;
192
200
  usage: unknown;
193
201
  content: TurnContentPart[];
194
- }): void => {
195
- dispatcher.onLanguageModelCallEnd?.(
202
+ }): Promise<void> => {
203
+ await dispatcher.onLanguageModelCallEnd?.(
196
204
  cast<'onLanguageModelCallEnd'>({
197
205
  callId,
198
206
  finishReason: info.finishReason,
@@ -203,9 +211,9 @@ export function createTurnTelemetry(opts: {
203
211
  );
204
212
  };
205
213
 
206
- const closeOpenTools = (): void => {
214
+ const closeOpenTools = async (): Promise<void> => {
207
215
  for (const call of openTools.values()) {
208
- dispatcher.onToolExecutionEnd?.(
216
+ await dispatcher.onToolExecutionEnd?.(
209
217
  cast<'onToolExecutionEnd'>({
210
218
  callId,
211
219
  toolExecutionMs: 0,
@@ -229,16 +237,16 @@ export function createTurnTelemetry(opts: {
229
237
  start,
230
238
  ensureStepOpen,
231
239
 
232
- stepFinish(info) {
240
+ async stepFinish(info) {
233
241
  if (!stepOpen) return;
234
242
  const content = info.content ?? [];
235
- closeOpenTools();
236
- inferenceEnd({
243
+ await closeOpenTools();
244
+ await inferenceEnd({
237
245
  finishReason: info.finishReason,
238
246
  usage: info.usage,
239
247
  content,
240
248
  });
241
- dispatcher.onStepEnd?.(
249
+ await dispatcher.onStepEnd?.(
242
250
  cast<'onStepEnd'>({
243
251
  callId,
244
252
  stepNumber,
@@ -258,10 +266,11 @@ export function createTurnTelemetry(opts: {
258
266
  stepNumber += 1;
259
267
  },
260
268
 
261
- toolStart(call) {
262
- ensureStepOpen();
269
+ async toolStart(call) {
270
+ await ensureStepOpen();
271
+ if (openTools.has(call.toolCallId)) return;
263
272
  openTools.set(call.toolCallId, call);
264
- dispatcher.onToolExecutionStart?.(
273
+ await dispatcher.onToolExecutionStart?.(
265
274
  cast<'onToolExecutionStart'>({
266
275
  callId,
267
276
  messages: [],
@@ -277,11 +286,16 @@ export function createTurnTelemetry(opts: {
277
286
  );
278
287
  },
279
288
 
280
- toolEnd(toolCallId, output) {
289
+ async executeTool({ toolCallId, execute }) {
290
+ if (dispatcher.executeTool == null) return await execute();
291
+ return await dispatcher.executeTool({ callId, toolCallId, execute });
292
+ },
293
+
294
+ async toolEnd(toolCallId, output) {
281
295
  const call = openTools.get(toolCallId);
282
296
  if (call == null) return;
283
297
  openTools.delete(toolCallId);
284
- dispatcher.onToolExecutionEnd?.(
298
+ await dispatcher.onToolExecutionEnd?.(
285
299
  cast<'onToolExecutionEnd'>({
286
300
  callId,
287
301
  toolExecutionMs: 0,
@@ -301,17 +315,17 @@ export function createTurnTelemetry(opts: {
301
315
  );
302
316
  },
303
317
 
304
- end(info) {
318
+ async end(info) {
305
319
  if (ended) return;
306
- if (!started) fireStart();
320
+ if (!started) await fireStart();
307
321
  if (stepOpen) {
308
- closeOpenTools();
309
- inferenceEnd({
322
+ await closeOpenTools();
323
+ await inferenceEnd({
310
324
  finishReason: info.finishReason,
311
325
  usage: info.usage,
312
326
  content: [],
313
327
  });
314
- dispatcher.onStepEnd?.(
328
+ await dispatcher.onStepEnd?.(
315
329
  cast<'onStepEnd'>({
316
330
  callId,
317
331
  stepNumber,
@@ -330,7 +344,7 @@ export function createTurnTelemetry(opts: {
330
344
  stepOpen = false;
331
345
  }
332
346
  ended = true;
333
- dispatcher.onEnd?.(
347
+ await dispatcher.onEnd?.(
334
348
  cast<'onEnd'>({
335
349
  callId,
336
350
  operationId: 'ai.harness',
@@ -350,12 +364,12 @@ export function createTurnTelemetry(opts: {
350
364
  );
351
365
  },
352
366
 
353
- error(err) {
367
+ async error(err) {
354
368
  if (ended) return;
355
- if (!started) fireStart();
356
- closeOpenTools();
369
+ if (!started) await fireStart();
370
+ await closeOpenTools();
357
371
  ended = true;
358
- dispatcher.onError?.(err);
372
+ await dispatcher.onError?.(err);
359
373
  },
360
374
  };
361
375
  }
@@ -234,6 +234,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
234
234
  let currentBoundPort = 0;
235
235
  let currentTurnState: BridgeState = 'init';
236
236
  let activeSocket: WebSocket | undefined;
237
+ let activeSocketReadyForLiveEvents = false;
237
238
  let isFirstTurn = true;
238
239
  let turnAbort: AbortController | undefined;
239
240
  let currentUserMessages: string[] | undefined;
@@ -389,7 +390,10 @@ export async function runBridge<TStart extends { type: 'start' }>(
389
390
  eventLog.push({ seq, line });
390
391
  diskBuffer += `${line}\n`;
391
392
  scheduleEventFlush();
392
- if (activeSocket?.readyState === WS_OPEN) {
393
+ if (
394
+ activeSocketReadyForLiveEvents &&
395
+ activeSocket?.readyState === WS_OPEN
396
+ ) {
393
397
  try {
394
398
  activeSocket.send(line);
395
399
  } catch {
@@ -522,6 +526,8 @@ export async function runBridge<TStart extends { type: 'start' }>(
522
526
  ): Promise<void> => {
523
527
  switch (msg.type) {
524
528
  case 'start': {
529
+ if (activeSocket !== ws) return;
530
+ activeSocketReadyForLiveEvents = true;
525
531
  const firstTurn = isFirstTurn;
526
532
  isFirstTurn = false;
527
533
  eventLog = []; // clear previous turn; keep seqCounter monotonic
@@ -643,7 +649,9 @@ export async function runBridge<TStart extends { type: 'start' }>(
643
649
  }
644
650
  return;
645
651
  case 'resume':
652
+ if (activeSocket !== ws) return;
646
653
  replay(ws, msg.lastSeenEventId);
654
+ activeSocketReadyForLiveEvents = true;
647
655
  return;
648
656
  case 'shutdown':
649
657
  currentTurnState = 'done';
@@ -721,6 +729,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
721
729
  // (the host reconnecting after a drop). The previous socket's close is a
722
730
  // no-op below because it is no longer `activeSocket`.
723
731
  activeSocket = ws;
732
+ activeSocketReadyForLiveEvents = false;
724
733
 
725
734
  // Announce liveness the instant we accept. Some sandbox runtimes complete
726
735
  // the host-side WS handshake before the connection is forwarded here; the
@@ -754,6 +763,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
754
763
  // log for replay when the host reconnects.
755
764
  if (activeSocket === ws) {
756
765
  activeSocket = undefined;
766
+ activeSocketReadyForLiveEvents = false;
757
767
  }
758
768
  });
759
769
 
@@ -75,6 +75,30 @@ type Listener<TOut extends { type: string }, T extends EventTypeOf<TOut>> = (
75
75
  event: Extract<TOut, { type: T }>,
76
76
  ) => void;
77
77
 
78
+ /*
79
+ * The agent and utilities entrypoints bundle this module separately. A global
80
+ * symbol lets the agent recognize metadata attached by the channel's bundle
81
+ * copy, while the non-enumerable property leaves protocol payloads unchanged.
82
+ */
83
+ const sandboxChannelEventCheckpointSymbol = Symbol.for(
84
+ 'vercel.ai.harness.sandboxChannelEventCheckpoint',
85
+ );
86
+
87
+ type SandboxChannelEventCheckpoint = {
88
+ pin: () => () => void;
89
+ };
90
+
91
+ export function pinSandboxChannelEventCheckpoint(
92
+ event: unknown,
93
+ ): (() => void) | undefined {
94
+ if (event == null || typeof event !== 'object') return undefined;
95
+ return (
96
+ event as {
97
+ [sandboxChannelEventCheckpointSymbol]?: SandboxChannelEventCheckpoint;
98
+ }
99
+ )[sandboxChannelEventCheckpointSymbol]?.pin();
100
+ }
101
+
78
102
  const sleep = (ms: number): Promise<void> =>
79
103
  new Promise(resolve => {
80
104
  const t = setTimeout(resolve, ms);
@@ -136,6 +160,9 @@ export class SandboxChannel<
136
160
  * replayed to the next process on `resume`.
137
161
  */
138
162
  private suspended = false;
163
+ private pinnedSuspensionCursor:
164
+ | { eventId: number; token: object }
165
+ | undefined;
139
166
  /** Channel is fully torn down; `send` throws and `onClose` has fired. */
140
167
  private terminal = false;
141
168
  private _lastSeenEventId = 0;
@@ -251,6 +278,9 @@ export class SandboxChannel<
251
278
  }
252
279
 
253
280
  interrupt(options?: { timeoutMs?: number }): Promise<void> {
281
+ if (this.pinnedSuspensionCursor != null) {
282
+ return Promise.resolve();
283
+ }
254
284
  const timeoutMs = options?.timeoutMs ?? 5000;
255
285
  return new Promise<void>((resolve, reject) => {
256
286
  let settled = false;
@@ -313,19 +343,24 @@ export class SandboxChannel<
313
343
  * aborts it) and accumulates events past the cursor for the next process to
314
344
  * `resume`. Unlike {@link close}, the consumer's active turn is wound down
315
345
  * cleanly — adapters distinguish a suspend from an unexpected drop via the
316
- * `'suspended'` close reason and resolve `done` successfully.
346
+ * `'suspended'` close reason and resolve `done` successfully. When an event
347
+ * checkpoint is pinned, the returned cursor points to that event so any
348
+ * already-dispatched tail is replayed by the next process.
317
349
  */
318
350
  suspend(): Promise<number> {
319
351
  return new Promise<number>(resolve => {
352
+ const pinnedSuspensionCursor = this.pinnedSuspensionCursor?.eventId;
320
353
  if (this.terminal) {
321
- resolve(this._lastSeenEventId);
354
+ resolve(pinnedSuspensionCursor ?? this._lastSeenEventId);
322
355
  return;
323
356
  }
324
357
  // Stop counting/dispatching further inbound frames immediately, and
325
358
  // suppress reconnect so the socket close finalises.
326
359
  this.suspended = true;
327
360
  this.closing = true;
328
- this.onClose(() => resolve(this._lastSeenEventId));
361
+ this.onClose(() =>
362
+ resolve(pinnedSuspensionCursor ?? this._lastSeenEventId),
363
+ );
329
364
  // Queue the close behind any already-dispatched frames so everything
330
365
  // delivered to the consumer is reflected in the final cursor.
331
366
  this.enqueue(() => {
@@ -465,6 +500,9 @@ export class SandboxChannel<
465
500
  schema: this.outboundSchema,
466
501
  });
467
502
  if (validated.success) {
503
+ if (seq !== undefined) {
504
+ this.attachEventCheckpoint({ event: validated.value, eventId: seq });
505
+ }
468
506
  this.dispatch(validated.value);
469
507
  } else {
470
508
  this.dispatch({
@@ -506,6 +544,26 @@ export class SandboxChannel<
506
544
  }
507
545
  }
508
546
 
547
+ private attachEventCheckpoint(options: {
548
+ event: TOut;
549
+ eventId: number;
550
+ }): void {
551
+ if (!Object.isExtensible(options.event)) return;
552
+ Object.defineProperty(options.event, sandboxChannelEventCheckpointSymbol, {
553
+ value: {
554
+ pin: () => {
555
+ const token = {};
556
+ this.pinnedSuspensionCursor = { eventId: options.eventId, token };
557
+ return () => {
558
+ if (this.pinnedSuspensionCursor?.token === token) {
559
+ this.pinnedSuspensionCursor = undefined;
560
+ }
561
+ };
562
+ },
563
+ } satisfies SandboxChannelEventCheckpoint,
564
+ });
565
+ }
566
+
509
567
  private finalizeClose(code: number, reason: string): void {
510
568
  if (this.terminal) return;
511
569
  this.terminal = true;