@ai-sdk/harness 1.0.41 → 1.0.43

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.
@@ -103,6 +103,11 @@ export function runPrompt<
103
103
  onToolResultSettled?: (toolCallId: string) => void;
104
104
  onTurnFinished?: () => void;
105
105
  onTurnFailed?: () => void;
106
+ /**
107
+ * Reports that the adapter stream closed because the host intentionally
108
+ * suspended the still-running turn at a workflow slice boundary.
109
+ */
110
+ isTurnSuspending?: () => boolean;
106
111
  onStopConditionMet?: () => Promise<void>;
107
112
  }): {
108
113
  result: HarnessStreamTextResult<TOOLS, RUNTIME_CONTEXT>;
@@ -140,10 +145,13 @@ export function runPrompt<
140
145
  * `toUIMessageStream` consumers observe an `abort` chunk and
141
146
  * `isAborted: true` instead of a spurious `onError`. Every other failure
142
147
  * stays a real `error` part. Both outcomes notify `onTurnFailed` so the
143
- * session's turn tracking returns to idle and the session stays usable.
148
+ * session's turn tracking returns to idle and the session stays usable,
149
+ * unless the turn is being suspended for a future continuation.
144
150
  */
145
151
  const settleFailure = (err: unknown) => {
146
- input.onTurnFailed?.();
152
+ if (!input.isTurnSuspending?.()) {
153
+ input.onTurnFailed?.();
154
+ }
147
155
  if (input.abortSignal?.aborted) {
148
156
  result.abort({
149
157
  error: err,
@@ -908,7 +916,18 @@ export function runPrompt<
908
916
  await telemetry.toolEnd(toolCall.toolCallId, execution.outcome);
909
917
  }
910
918
  }
911
- if (finalFinish != null) {
919
+ const isTurnSuspending = input.isTurnSuspending?.() === true;
920
+ if (isTurnSuspending) {
921
+ if (finalFinish == null) {
922
+ /*
923
+ * A timed slice may stop in the middle of a model step. Its partial
924
+ * content remains in the bridge replay log for the next slice, but it
925
+ * cannot form a valid StepResult in this slice because no finish-step
926
+ * has arrived yet.
927
+ */
928
+ result.discardCurrentStepContent();
929
+ }
930
+ } else if (finalFinish != null) {
912
931
  input.onTurnFinished?.();
913
932
  } else {
914
933
  input.onTurnFailed?.();
@@ -1,6 +1,6 @@
1
1
  import { generateId, type ModelMessage } from '@ai-sdk/provider-utils';
2
2
  import { createTelemetryDispatcher } from 'ai/internal';
3
- import type { TelemetryOptions } from 'ai';
3
+ import type { LanguageModelUsage, TelemetryOptions } from 'ai';
4
4
 
5
5
  /*
6
6
  * Drives AI SDK's pluggable `Telemetry` lifecycle from a harness turn.
@@ -92,6 +92,69 @@ const NOOP: TurnTelemetry = {
92
92
  async error() {},
93
93
  };
94
94
 
95
+ function normalizeFinishReason(finishReason: unknown): unknown {
96
+ if (
97
+ finishReason != null &&
98
+ typeof finishReason === 'object' &&
99
+ 'unified' in finishReason
100
+ ) {
101
+ return (finishReason as { unified: unknown }).unified;
102
+ }
103
+
104
+ return finishReason;
105
+ }
106
+
107
+ function addTokenCounts(
108
+ tokenCount1: number | undefined,
109
+ tokenCount2: number | undefined,
110
+ ): number | undefined {
111
+ return tokenCount1 == null && tokenCount2 == null
112
+ ? undefined
113
+ : (tokenCount1 ?? 0) + (tokenCount2 ?? 0);
114
+ }
115
+
116
+ function normalizeUsage(usage: unknown): LanguageModelUsage | unknown {
117
+ if (
118
+ usage == null ||
119
+ typeof usage !== 'object' ||
120
+ !('inputTokens' in usage) ||
121
+ !('outputTokens' in usage)
122
+ ) {
123
+ return usage;
124
+ }
125
+
126
+ const inputTokens = (usage as { inputTokens: unknown }).inputTokens;
127
+ const outputTokens = (usage as { outputTokens: unknown }).outputTokens;
128
+
129
+ if (
130
+ inputTokens == null ||
131
+ typeof inputTokens !== 'object' ||
132
+ outputTokens == null ||
133
+ typeof outputTokens !== 'object'
134
+ ) {
135
+ return usage;
136
+ }
137
+
138
+ const input = inputTokens as Record<string, number | undefined>;
139
+ const output = outputTokens as Record<string, number | undefined>;
140
+
141
+ return {
142
+ inputTokens: input.total,
143
+ inputTokenDetails: {
144
+ noCacheTokens: input.noCache,
145
+ cacheReadTokens: input.cacheRead,
146
+ cacheWriteTokens: input.cacheWrite,
147
+ },
148
+ outputTokens: output.total,
149
+ outputTokenDetails: {
150
+ textTokens: output.text,
151
+ reasoningTokens: output.reasoning,
152
+ },
153
+ totalTokens: addTokenCounts(input.total, output.total),
154
+ raw: (usage as { raw?: LanguageModelUsage['raw'] }).raw,
155
+ };
156
+ }
157
+
95
158
  export function createTurnTelemetry(opts: {
96
159
  telemetry: TelemetryOptions | undefined;
97
160
  harnessId: string;
@@ -119,6 +182,15 @@ export function createTurnTelemetry(opts: {
119
182
  let stepOpen = false;
120
183
  let stepNumber = 0;
121
184
  let ended = false;
185
+ let finalStepText = '';
186
+ let finalStepReasoning: Array<{ text: string }> = [];
187
+ let finalStepProviderMetadata: unknown;
188
+ let outputToolCalls: Array<{
189
+ type: 'tool-call';
190
+ toolCallId: string;
191
+ toolName: string;
192
+ input: unknown;
193
+ }> = [];
122
194
  /** Tool calls started in the current turn and not yet ended. */
123
195
  const openTools = new Map<
124
196
  string,
@@ -200,17 +272,40 @@ export function createTurnTelemetry(opts: {
200
272
  usage: unknown;
201
273
  content: TurnContentPart[];
202
274
  }): Promise<void> => {
275
+ const finishReason = normalizeFinishReason(info.finishReason);
276
+ const usage = normalizeUsage(info.usage);
277
+
203
278
  await dispatcher.onLanguageModelCallEnd?.(
204
279
  cast<'onLanguageModelCallEnd'>({
205
280
  callId,
206
- finishReason: info.finishReason,
281
+ finishReason,
207
282
  responseId: callId,
208
- usage: info.usage,
283
+ usage,
209
284
  content: info.content,
285
+ performance: {
286
+ responseTimeMs: undefined,
287
+ timeToFirstOutputMs: undefined,
288
+ timeBetweenOutputChunksMs: undefined,
289
+ },
210
290
  }),
211
291
  );
212
292
  };
213
293
 
294
+ const recordOutputContent = (content: TurnContentPart[]): void => {
295
+ finalStepText = '';
296
+ finalStepReasoning = [];
297
+
298
+ for (const part of content) {
299
+ if (part.type === 'text') {
300
+ finalStepText += part.text;
301
+ } else if (part.type === 'reasoning') {
302
+ finalStepReasoning.push({ text: part.text });
303
+ } else if (part.type === 'tool-call') {
304
+ outputToolCalls.push(part);
305
+ }
306
+ }
307
+ };
308
+
214
309
  const closeOpenTools = async (): Promise<void> => {
215
310
  for (const call of openTools.values()) {
216
311
  await dispatcher.onToolExecutionEnd?.(
@@ -240,18 +335,22 @@ export function createTurnTelemetry(opts: {
240
335
  async stepFinish(info) {
241
336
  if (!stepOpen) return;
242
337
  const content = info.content ?? [];
338
+ const finishReason = normalizeFinishReason(info.finishReason);
339
+ const usage = normalizeUsage(info.usage);
340
+ recordOutputContent(content);
341
+ finalStepProviderMetadata = info.providerMetadata;
243
342
  await closeOpenTools();
244
343
  await inferenceEnd({
245
- finishReason: info.finishReason,
246
- usage: info.usage,
344
+ finishReason,
345
+ usage,
247
346
  content,
248
347
  });
249
348
  await dispatcher.onStepEnd?.(
250
349
  cast<'onStepEnd'>({
251
350
  callId,
252
351
  stepNumber,
253
- finishReason: info.finishReason,
254
- usage: info.usage,
352
+ finishReason,
353
+ usage,
255
354
  providerMetadata: info.providerMetadata,
256
355
  content,
257
356
  response: {
@@ -293,6 +392,9 @@ export function createTurnTelemetry(opts: {
293
392
 
294
393
  async toolEnd(toolCallId, output) {
295
394
  const call = openTools.get(toolCallId);
395
+ const normalizedOutput = output.ok
396
+ ? { type: 'tool-result' as const, output: output.output }
397
+ : { type: 'error' as const, error: output.error };
296
398
  if (call == null) return;
297
399
  openTools.delete(toolCallId);
298
400
  await dispatcher.onToolExecutionEnd?.(
@@ -308,29 +410,29 @@ export function createTurnTelemetry(opts: {
308
410
  dynamic: true,
309
411
  },
310
412
  toolContext: undefined,
311
- toolOutput: output.ok
312
- ? { type: 'tool-result', output: output.output }
313
- : { type: 'error', error: output.error },
413
+ toolOutput: normalizedOutput,
314
414
  }),
315
415
  );
316
416
  },
317
417
 
318
418
  async end(info) {
319
419
  if (ended) return;
420
+ const finishReason = normalizeFinishReason(info.finishReason);
421
+ const usage = normalizeUsage(info.usage);
320
422
  if (!started) await fireStart();
321
423
  if (stepOpen) {
322
424
  await closeOpenTools();
323
425
  await inferenceEnd({
324
- finishReason: info.finishReason,
325
- usage: info.usage,
426
+ finishReason,
427
+ usage,
326
428
  content: [],
327
429
  });
328
430
  await dispatcher.onStepEnd?.(
329
431
  cast<'onStepEnd'>({
330
432
  callId,
331
433
  stepNumber,
332
- finishReason: info.finishReason,
333
- usage: info.usage,
434
+ finishReason,
435
+ usage,
334
436
  providerMetadata: undefined,
335
437
  content: [],
336
438
  response: {
@@ -348,10 +450,17 @@ export function createTurnTelemetry(opts: {
348
450
  cast<'onEnd'>({
349
451
  callId,
350
452
  operationId: 'ai.harness',
351
- finishReason: info.finishReason,
352
- usage: info.usage,
353
- totalUsage: info.usage,
453
+ finishReason,
454
+ usage,
455
+ totalUsage: usage,
354
456
  content: [],
457
+ text: finalStepText,
458
+ finalStep: {
459
+ reasoning: finalStepReasoning,
460
+ providerMetadata: finalStepProviderMetadata,
461
+ },
462
+ toolCalls: outputToolCalls,
463
+ files: [],
355
464
  steps: new Array(stepNumber),
356
465
  response: {
357
466
  id: callId,
@@ -120,13 +120,6 @@ export interface BridgeTurn {
120
120
  /** Aborts when the host sends `abort`. */
121
121
  readonly abortSignal: AbortSignal;
122
122
 
123
- /**
124
- * Register the runtime-specific interrupt hook for this active turn. The
125
- * shared bridge invokes it when the host sends `interrupt`, then acknowledges
126
- * only after the hook settles.
127
- */
128
- onInterrupt(handler: () => void | Promise<void>): void;
129
-
130
123
  /** True for the first turn since this bridge process started. */
131
124
  readonly firstTurn: boolean;
132
125
 
@@ -191,7 +184,6 @@ type InboundControl =
191
184
  }
192
185
  | { type: 'user-message'; text: string }
193
186
  | { type: 'abort' }
194
- | { type: 'interrupt' }
195
187
  | { type: 'shutdown' }
196
188
  | { type: 'detach' }
197
189
  | { type: 'resume'; lastSeenEventId: number };
@@ -238,7 +230,6 @@ export async function runBridge<TStart extends { type: 'start' }>(
238
230
  let isFirstTurn = true;
239
231
  let turnAbort: AbortController | undefined;
240
232
  let currentUserMessages: string[] | undefined;
241
- let currentInterruptHandler: (() => void | Promise<void>) | undefined;
242
233
 
243
234
  // Diagnostics. Resolved per turn from `start.debug` with a sandbox-side
244
235
  // env fallback; gates console capture + structured `debug-event`s.
@@ -259,7 +250,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
259
250
  * Disk mirror of the in-memory replay log. The in-memory log is lost when the
260
251
  * bridge process dies; the on-disk `event-log.ndjson` survives in the sandbox
261
252
  * filesystem so a respawned bridge (started with `BRIDGE_REPLAY_FROM_DISK=1`)
262
- * can reload the just-interrupted turn and serve a host's resume cursor —
253
+ * can reload the in-flight turn and serve a host's resume cursor —
263
254
  * `replay` recovery. Writes are batched on `setImmediate` (single-flight via
264
255
  * `flushPromise`) to keep `emit` off the disk hot path.
265
256
  */
@@ -537,7 +528,6 @@ export async function runBridge<TStart extends { type: 'start' }>(
537
528
  void writeFile(eventLogPath, '').catch(() => {});
538
529
  turnAbort = new AbortController();
539
530
  currentTurnState = 'running';
540
- currentInterruptHandler = undefined;
541
531
  void writeStartConfig(msg);
542
532
  void writeBridgeMeta('running');
543
533
  const startDebug = (msg as { debug?: BridgeDebugConfig }).debug;
@@ -565,9 +555,6 @@ export async function runBridge<TStart extends { type: 'start' }>(
565
555
  }),
566
556
  pendingUserMessages: [],
567
557
  abortSignal: turnAbort.signal,
568
- onInterrupt: handler => {
569
- currentInterruptHandler = handler;
570
- },
571
558
  firstTurn,
572
559
  bridgeLog: input => {
573
560
  const level = input.level ?? 'debug';
@@ -592,7 +579,6 @@ export async function runBridge<TStart extends { type: 'start' }>(
592
579
  } catch (err) {
593
580
  emitError({ error: err, message: 'bridge turn failed' });
594
581
  } finally {
595
- currentInterruptHandler = undefined;
596
582
  currentTurnState = 'waiting';
597
583
  void writeBridgeMeta('waiting');
598
584
  }
@@ -620,34 +606,6 @@ export async function runBridge<TStart extends { type: 'start' }>(
620
606
  case 'abort':
621
607
  turnAbort?.abort();
622
608
  return;
623
- case 'interrupt':
624
- try {
625
- /*
626
- * A bridge waiting for a host tool result or approval is already
627
- * paused at a resumable boundary. Interrupting the native runtime at
628
- * that point terminates the operation that owns the pending request,
629
- * so a later host process cannot satisfy it. Active turns without
630
- * pending host input are interrupted before suspension as usual.
631
- */
632
- if (
633
- pendingToolResults.size === 0 &&
634
- pendingToolApprovals.size === 0
635
- ) {
636
- if (currentInterruptHandler) {
637
- await currentInterruptHandler();
638
- } else {
639
- turnAbort?.abort();
640
- }
641
- }
642
- sendControl({ type: 'bridge-interrupted', ok: true });
643
- } catch (err) {
644
- sendControl({
645
- type: 'bridge-interrupted',
646
- ok: false,
647
- error: serialiseError(err),
648
- });
649
- }
650
- return;
651
609
  case 'resume':
652
610
  if (activeSocket !== ws) return;
653
611
  replay(ws, msg.lastSeenEventId);
@@ -277,62 +277,6 @@ export class SandboxChannel<
277
277
  this.enqueue(() => this.finalizeClose(1000, 'closed'));
278
278
  }
279
279
 
280
- interrupt(options?: { timeoutMs?: number }): Promise<void> {
281
- if (this.pinnedSuspensionCursor != null) {
282
- return Promise.resolve();
283
- }
284
- const timeoutMs = options?.timeoutMs ?? 5000;
285
- return new Promise<void>((resolve, reject) => {
286
- let settled = false;
287
- let unsub = (): void => {};
288
- const timer = setTimeout(() => {
289
- complete(
290
- new Error(
291
- `SandboxChannel: interrupt was not acknowledged within ${timeoutMs}ms.`,
292
- ),
293
- );
294
- }, timeoutMs);
295
- timer.unref?.();
296
-
297
- const complete = (error?: unknown): void => {
298
- if (settled) return;
299
- settled = true;
300
- clearTimeout(timer);
301
- unsub();
302
- if (error) {
303
- reject(error);
304
- } else {
305
- resolve();
306
- }
307
- };
308
-
309
- unsub = this.on('bridge-interrupted' as EventTypeOf<TOut>, event => {
310
- const response = event as unknown as {
311
- type: 'bridge-interrupted';
312
- ok: boolean;
313
- error?: unknown;
314
- };
315
- if (response.ok) {
316
- complete();
317
- return;
318
- }
319
- complete(
320
- new Error(
321
- `SandboxChannel: interrupt failed: ${formatControlError(
322
- response.error,
323
- )}`,
324
- ),
325
- );
326
- });
327
-
328
- try {
329
- this.send({ type: 'interrupt' } as TIn);
330
- } catch (err) {
331
- complete(err);
332
- }
333
- });
334
- }
335
-
336
280
  /**
337
281
  * Gracefully suspend at a slice boundary: stop processing inbound frames
338
282
  * (so the cursor freezes at the last delivered event), drain any frames
@@ -571,13 +515,3 @@ export class SandboxChannel<
571
515
  for (const h of this.onCloseHandlers) h(code, reason);
572
516
  }
573
517
  }
574
-
575
- function formatControlError(error: unknown): string {
576
- if (error instanceof Error) return error.message;
577
- if (error && typeof error === 'object') {
578
- const message = (error as { message?: unknown }).message;
579
- if (typeof message === 'string' && message.length > 0) return message;
580
- }
581
- if (typeof error === 'string' && error.length > 0) return error;
582
- return 'unknown error';
583
- }
@@ -140,17 +140,6 @@ export const harnessV1BridgeThreadSchema = z.object({
140
140
  threadId: z.string(),
141
141
  });
142
142
 
143
- /**
144
- * Acknowledgement for an inbound `interrupt` command. The host waits for this
145
- * before freezing its replay cursor so the adapter-specific interrupt has
146
- * actually reached the underlying runtime.
147
- */
148
- export const harnessV1BridgeInterruptedSchema = z.object({
149
- type: z.literal('bridge-interrupted'),
150
- ok: z.boolean(),
151
- error: z.unknown().optional(),
152
- });
153
-
154
143
  // --- Diagnostics frames (outbound, not consumer events) ---
155
144
 
156
145
  /**
@@ -211,7 +200,6 @@ export const harnessV1BridgeOutboundMessageSchema = z.discriminatedUnion(
211
200
  harnessV1BridgeHelloSchema,
212
201
  harnessV1BridgeDetachSchema,
213
202
  harnessV1BridgeThreadSchema,
214
- harnessV1BridgeInterruptedSchema,
215
203
  harnessV1BridgeSandboxLogSchema,
216
204
  harnessV1BridgeDebugEventSchema,
217
205
  ],
@@ -289,10 +277,6 @@ export const harnessV1BridgeAbortInboundSchema = z.object({
289
277
  type: z.literal('abort'),
290
278
  });
291
279
 
292
- export const harnessV1BridgeInterruptInboundSchema = z.object({
293
- type: z.literal('interrupt'),
294
- });
295
-
296
280
  export const harnessV1BridgeShutdownInboundSchema = z.object({
297
281
  type: z.literal('shutdown'),
298
282
  });
@@ -324,7 +308,6 @@ export const harnessV1BridgeInboundCommandSchemas = [
324
308
  harnessV1BridgeToolApprovalResponseInboundSchema,
325
309
  harnessV1BridgeUserMessageInboundSchema,
326
310
  harnessV1BridgeAbortInboundSchema,
327
- harnessV1BridgeInterruptInboundSchema,
328
311
  harnessV1BridgeShutdownInboundSchema,
329
312
  harnessV1BridgeResumeInboundSchema,
330
313
  harnessV1BridgeDetachInboundSchema,
@@ -53,7 +53,7 @@ export type HarnessV1ResumeSessionState = HarnessV1LifecycleStateBase & {
53
53
  /**
54
54
  * Opaque payload returned by `doSuspendTurn` and accepted by a future
55
55
  * `HarnessV1.doStart({ continueFrom })` to reconnect to the same session before
56
- * continuing the interrupted turn.
56
+ * continuing the suspended turn.
57
57
  */
58
58
  export type HarnessV1ContinueTurnState = HarnessV1LifecycleStateBase & {
59
59
  readonly type: 'continue-turn';
@@ -216,7 +216,7 @@ export type HarnessV1Session = {
216
216
  /**
217
217
  * Continue the in-flight turn **without a new user prompt**, returning the
218
218
  * same control surface as `doPromptTurn`. Used to keep consuming a turn that
219
- * was interrupted at a process boundary (the workflow slice loop), after the
219
+ * was suspended at a process boundary (the workflow slice loop), after the
220
220
  * session itself has been resumed via `doStart({ continueFrom })`:
221
221
  *
222
222
  * - When the runtime's turn is still live and reachable (bridge `attach` /
@@ -225,7 +225,7 @@ export type HarnessV1Session = {
225
225
  * - When the live turn is gone (bridge respawned `rerun`, or a host-resident
226
226
  * runtime like Pi whose turn cannot survive its process), the adapter
227
227
  * re-drives the runtime's own thread from its persisted state. Lossy: work
228
- * in flight at the interruption is recomputed.
228
+ * in flight at the suspension is recomputed.
229
229
  *
230
230
  * Required on every adapter. The behaviour an adapter can guarantee follows
231
231
  * from its architecture; the contract is uniform.
@@ -87,7 +87,7 @@ export type HarnessV1<TBuiltinTools extends ToolSet = ToolSet> = {
87
87
 
88
88
  /**
89
89
  * Start a fresh session, resume a parked session via `resumeFrom`, or resume
90
- * an interrupted turn via `continueFrom`. The host then issues prompts against
90
+ * a suspended turn via `continueFrom`. The host then issues prompts against
91
91
  * the returned session, ending with `doDetach`, `doStop`, or `doDestroy`.
92
92
  */
93
93
  doStart(options: HarnessV1StartOptions): PromiseLike<HarnessV1Session>;