@ai-sdk/harness 1.0.22 → 1.0.23

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.
@@ -57,6 +57,14 @@ function formatBridgeError(err: unknown): {
57
57
  if (err instanceof Error) {
58
58
  return { name: err.name, message: err.message, stack: err.stack };
59
59
  }
60
+ if (typeof err === 'string') {
61
+ return { message: err };
62
+ }
63
+ if (err !== null && typeof err === 'object') {
64
+ try {
65
+ return { message: JSON.stringify(err) };
66
+ } catch {}
67
+ }
60
68
  return { message: String(err) };
61
69
  }
62
70
 
@@ -112,6 +120,13 @@ export interface BridgeTurn {
112
120
  /** Aborts when the host sends `abort`. */
113
121
  readonly abortSignal: AbortSignal;
114
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
+
115
130
  /** True for the first turn since this bridge process started. */
116
131
  readonly firstTurn: boolean;
117
132
 
@@ -128,6 +143,15 @@ export interface BridgeTurn {
128
143
  attrs?: Record<string, unknown>;
129
144
  error?: unknown;
130
145
  }): void;
146
+
147
+ /**
148
+ * Emit a non-fatal bridge warning to stderr using the runtime's harness
149
+ * prefix. This is diagnostic-only: it does not emit a stream event, does not
150
+ * consume a `seq`, and does not fail the turn.
151
+ */
152
+ emitWarning(input: { message: string }): void;
153
+
154
+ emitError(input: { error: unknown; message?: string }): void;
131
155
  }
132
156
 
133
157
  export interface RunBridgeOptions<TStart extends { type: 'start' }> {
@@ -167,6 +191,7 @@ type InboundControl =
167
191
  }
168
192
  | { type: 'user-message'; text: string }
169
193
  | { type: 'abort' }
194
+ | { type: 'interrupt' }
170
195
  | { type: 'shutdown' }
171
196
  | { type: 'detach' }
172
197
  | { type: 'resume'; lastSeenEventId: number };
@@ -212,6 +237,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
212
237
  let isFirstTurn = true;
213
238
  let turnAbort: AbortController | undefined;
214
239
  let currentUserMessages: string[] | undefined;
240
+ let currentInterruptHandler: (() => void | Promise<void>) | undefined;
215
241
 
216
242
  // Diagnostics. Resolved per turn from `start.debug` with a sandbox-side
217
243
  // env fallback; gates console capture + structured `debug-event`s.
@@ -402,6 +428,40 @@ export async function runBridge<TStart extends { type: 'start' }>(
402
428
  */
403
429
  const rawStdoutWrite = process.stdout.write.bind(process.stdout);
404
430
  const rawStderrWrite = process.stderr.write.bind(process.stderr);
431
+
432
+ const writeErrorToStderr = (input: {
433
+ message: string;
434
+ error: unknown;
435
+ }): void => {
436
+ try {
437
+ const formatted = formatBridgeError(input.error);
438
+ rawStderrWrite(
439
+ `[harness:${bridgeType}:error] ${input.message}: ${formatted.message}\n`,
440
+ );
441
+ if (formatted.stack) {
442
+ rawStderrWrite(`${formatted.stack}\n`);
443
+ }
444
+ } catch {}
445
+ };
446
+
447
+ const emitWarning = (input: { message: string }): void => {
448
+ try {
449
+ for (const line of input.message.split('\n')) {
450
+ if (line.trim().length > 0) {
451
+ rawStderrWrite(`[harness:${bridgeType}:warn] ${line}\n`);
452
+ }
453
+ }
454
+ } catch {}
455
+ };
456
+
457
+ const emitError = (input: { error: unknown; message?: string }): void => {
458
+ writeErrorToStderr({
459
+ message: input.message ?? 'bridge error',
460
+ error: input.error,
461
+ });
462
+ emit({ type: 'error', error: serialiseError(input.error) });
463
+ };
464
+
405
465
  const installConsoleCapture = (): void => {
406
466
  if (consoleCaptureInstalled) return;
407
467
  consoleCaptureInstalled = true;
@@ -471,6 +531,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
471
531
  void writeFile(eventLogPath, '').catch(() => {});
472
532
  turnAbort = new AbortController();
473
533
  currentTurnState = 'running';
534
+ currentInterruptHandler = undefined;
474
535
  void writeStartConfig(msg);
475
536
  void writeBridgeMeta('running');
476
537
  const startDebug = (msg as { debug?: BridgeDebugConfig }).debug;
@@ -498,6 +559,9 @@ export async function runBridge<TStart extends { type: 'start' }>(
498
559
  }),
499
560
  pendingUserMessages: [],
500
561
  abortSignal: turnAbort.signal,
562
+ onInterrupt: handler => {
563
+ currentInterruptHandler = handler;
564
+ },
501
565
  firstTurn,
502
566
  bridgeLog: input => {
503
567
  const level = input.level ?? 'debug';
@@ -513,13 +577,16 @@ export async function runBridge<TStart extends { type: 'start' }>(
513
577
  : {}),
514
578
  });
515
579
  },
580
+ emitWarning,
581
+ emitError,
516
582
  };
517
583
  currentUserMessages = turn.pendingUserMessages;
518
584
  try {
519
585
  await onStart(msg as TStart, turn);
520
586
  } catch (err) {
521
- emit({ type: 'error', error: serialiseError(err) });
587
+ emitError({ error: err, message: 'bridge turn failed' });
522
588
  } finally {
589
+ currentInterruptHandler = undefined;
523
590
  currentTurnState = 'waiting';
524
591
  void writeBridgeMeta('waiting');
525
592
  }
@@ -547,6 +614,22 @@ export async function runBridge<TStart extends { type: 'start' }>(
547
614
  case 'abort':
548
615
  turnAbort?.abort();
549
616
  return;
617
+ case 'interrupt':
618
+ try {
619
+ if (currentInterruptHandler) {
620
+ await currentInterruptHandler();
621
+ } else {
622
+ turnAbort?.abort();
623
+ }
624
+ sendControl({ type: 'bridge-interrupted', ok: true });
625
+ } catch (err) {
626
+ sendControl({
627
+ type: 'bridge-interrupted',
628
+ ok: false,
629
+ error: serialiseError(err),
630
+ });
631
+ }
632
+ return;
550
633
  case 'resume':
551
634
  replay(ws, msg.lastSeenEventId);
552
635
  return;
@@ -669,10 +752,10 @@ export async function runBridge<TStart extends { type: 'start' }>(
669
752
 
670
753
  // Surface bridge-internal crashes to the host instead of dying silently.
671
754
  process.on('uncaughtException', err => {
672
- emit({ type: 'error', error: serialiseError(err) });
755
+ emitError({ error: err, message: 'uncaught exception' });
673
756
  });
674
757
  process.on('unhandledRejection', err => {
675
- emit({ type: 'error', error: serialiseError(err) });
758
+ emitError({ error: err, message: 'unhandled rejection' });
676
759
  });
677
760
 
678
761
  await new Promise<void>((resolve, reject) => {
@@ -0,0 +1,213 @@
1
+ import type { Experimental_SandboxProcess } from '@ai-sdk/provider-utils';
2
+
3
+ const DEFAULT_TAIL_LIMIT = 20;
4
+
5
+ type BridgeProcessStreamName = 'stdout' | 'stderr';
6
+
7
+ type SerializedError = {
8
+ name?: string;
9
+ message: string;
10
+ stack?: string;
11
+ };
12
+
13
+ function isSerializedError(error: unknown): error is SerializedError {
14
+ return (
15
+ typeof error === 'object' &&
16
+ error != null &&
17
+ typeof (error as { message?: unknown }).message === 'string'
18
+ );
19
+ }
20
+
21
+ function stringifyUnknown(value: unknown): string {
22
+ if (typeof value === 'string') return value;
23
+ try {
24
+ return JSON.stringify(value);
25
+ } catch {
26
+ return String(value);
27
+ }
28
+ }
29
+
30
+ export function formatBridgeError(error: unknown): string {
31
+ if (error instanceof Error) {
32
+ return error.stack ?? `${error.name}: ${error.message}`;
33
+ }
34
+ if (isSerializedError(error)) {
35
+ return (
36
+ error.stack ?? `${error.name ? `${error.name}: ` : ''}${error.message}`
37
+ );
38
+ }
39
+ return stringifyUnknown(error);
40
+ }
41
+
42
+ function writeToStderr(line: string): void {
43
+ try {
44
+ process.stderr.write(line);
45
+ } catch {}
46
+ }
47
+
48
+ export function logBridgeError({
49
+ harnessId,
50
+ sessionId,
51
+ context,
52
+ error,
53
+ write = writeToStderr,
54
+ }: {
55
+ harnessId: string;
56
+ sessionId?: string;
57
+ context?: string;
58
+ error: unknown;
59
+ write?: (line: string) => void;
60
+ }): void {
61
+ const prefix = `[harness:${harnessId}:error${
62
+ sessionId ? ` session=${sessionId}` : ''
63
+ }]`;
64
+ const message = context
65
+ ? `${context}: ${formatBridgeError(error)}`
66
+ : formatBridgeError(error);
67
+ for (const line of message.split('\n')) {
68
+ if (line.trim().length > 0) {
69
+ write(`${prefix} ${line}\n`);
70
+ }
71
+ }
72
+ }
73
+
74
+ export function createBridgeErrorHandler({
75
+ harnessId,
76
+ sessionId,
77
+ }: {
78
+ harnessId: string;
79
+ sessionId?: string;
80
+ }): (event: { type: 'error'; error: unknown }) => void {
81
+ return event => {
82
+ logBridgeError({
83
+ harnessId,
84
+ sessionId,
85
+ context: 'bridge emitted an error frame',
86
+ error: event.error,
87
+ });
88
+ };
89
+ }
90
+
91
+ export async function createBridgeStartupError({
92
+ message,
93
+ proc,
94
+ stdoutTail,
95
+ stderrTail,
96
+ stderrDone,
97
+ }: {
98
+ message: string;
99
+ proc: Experimental_SandboxProcess;
100
+ stdoutTail: string[];
101
+ stderrTail: string[];
102
+ stderrDone?: Promise<void>;
103
+ }): Promise<Error> {
104
+ if (stderrDone) {
105
+ await Promise.race([stderrDone, sleep(250)]).catch(() => {});
106
+ }
107
+
108
+ let exitStatus = '';
109
+ try {
110
+ const result = (await Promise.race([
111
+ proc.wait(),
112
+ sleep(250).then(() => undefined),
113
+ ])) as { exitCode?: number } | undefined;
114
+ if (result?.exitCode !== undefined) {
115
+ exitStatus = ` Exit code: ${result.exitCode}.`;
116
+ }
117
+ } catch {}
118
+
119
+ const details: string[] = [];
120
+ if (stdoutTail.length > 0) {
121
+ details.push(`stdout:\n${stdoutTail.join('\n')}`);
122
+ }
123
+ if (stderrTail.length > 0) {
124
+ details.push(`stderr:\n${stderrTail.join('\n')}`);
125
+ }
126
+
127
+ return new Error(
128
+ `${message}${exitStatus}${
129
+ details.length > 0 ? `\n\n${details.join('\n\n')}` : ''
130
+ }`,
131
+ );
132
+ }
133
+
134
+ export async function forwardBridgeProcessStream({
135
+ stream,
136
+ streamName,
137
+ source = 'bridge',
138
+ collectTail,
139
+ tailLimit = DEFAULT_TAIL_LIMIT,
140
+ }: {
141
+ stream: ReadableStream<Uint8Array>;
142
+ streamName: BridgeProcessStreamName;
143
+ source?: string;
144
+ collectTail?: string[];
145
+ tailLimit?: number;
146
+ }): Promise<void> {
147
+ try {
148
+ const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
149
+ const decoder = lineDecoder();
150
+ while (true) {
151
+ const { value, done } = await reader.read();
152
+ const lines = done ? decoder.flush() : value ? decoder.push(value) : [];
153
+ for (const line of lines) {
154
+ recordTail({ lines: collectTail, line, limit: tailLimit });
155
+ writeToStderr(`[harness:${source}:${streamName}] ${line}\n`);
156
+ }
157
+ if (done) return;
158
+ }
159
+ } catch {}
160
+ }
161
+
162
+ export async function drainBridgeProcessStream(
163
+ stream: ReadableStream<Uint8Array>,
164
+ ): Promise<void> {
165
+ try {
166
+ const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
167
+ while (true) {
168
+ const { done } = await reader.read();
169
+ if (done) return;
170
+ }
171
+ } catch {}
172
+ }
173
+
174
+ function recordTail({
175
+ lines,
176
+ line,
177
+ limit,
178
+ }: {
179
+ lines: string[] | undefined;
180
+ line: string;
181
+ limit: number;
182
+ }): void {
183
+ if (!lines) return;
184
+ lines.push(line);
185
+ while (lines.length > limit) lines.shift();
186
+ }
187
+
188
+ function lineDecoder() {
189
+ let buffer = '';
190
+ return {
191
+ push(chunk: string): string[] {
192
+ buffer += chunk;
193
+ const lines: string[] = [];
194
+ let nl: number;
195
+ while ((nl = buffer.indexOf('\n')) !== -1) {
196
+ const raw = buffer.slice(0, nl);
197
+ buffer = buffer.slice(nl + 1);
198
+ const line = raw.replace(/\r$/, '').trim();
199
+ if (line.length > 0) lines.push(line);
200
+ }
201
+ return lines;
202
+ },
203
+ flush(): string[] {
204
+ const line = buffer.replace(/\r$/, '').trim();
205
+ buffer = '';
206
+ return line.length > 0 ? [line] : [];
207
+ },
208
+ };
209
+ }
210
+
211
+ function sleep(ms: number): Promise<void> {
212
+ return new Promise(resolve => setTimeout(resolve, ms));
213
+ }
@@ -21,3 +21,11 @@ export {
21
21
  type WaitForBridgeReadyOptions,
22
22
  type WaitForBridgeReadyResult,
23
23
  } from './bridge-ready';
24
+ export {
25
+ createBridgeErrorHandler,
26
+ createBridgeStartupError,
27
+ drainBridgeProcessStream,
28
+ formatBridgeError,
29
+ forwardBridgeProcessStream,
30
+ logBridgeError,
31
+ } from './bridge-diagnostics';
@@ -58,6 +58,8 @@ export interface SandboxChannelOptions<TOut> {
58
58
  event: Extract<TOut, { type: 'sandbox-log' | 'debug-event' }>,
59
59
  ) => void;
60
60
 
61
+ onBridgeError?: (event: Extract<TOut, { type: 'error' }>) => void;
62
+
61
63
  /**
62
64
  * Seed the host-side cursor before the first connect. Pass the
63
65
  * `lastSeenEventId` persisted from a prior process so the bridge replays only
@@ -116,6 +118,9 @@ export class SandboxChannel<
116
118
  private readonly onDiagnostic:
117
119
  | ((event: Extract<TOut, { type: 'sandbox-log' | 'debug-event' }>) => void)
118
120
  | undefined;
121
+ private readonly onBridgeError:
122
+ | ((event: Extract<TOut, { type: 'error' }>) => void)
123
+ | undefined;
119
124
  private readonly maxElapsedMs: number;
120
125
  private readonly initialDelayMs: number;
121
126
  private readonly maxDelayMs: number;
@@ -142,6 +147,7 @@ export class SandboxChannel<
142
147
  this.outboundSchema = options.outboundSchema;
143
148
  this.onDebug = options.onDebug;
144
149
  this.onDiagnostic = options.onDiagnostic;
150
+ this.onBridgeError = options.onBridgeError;
145
151
  this.maxElapsedMs = options.reconnect?.maxElapsedMs ?? 30_000;
146
152
  this.initialDelayMs = options.reconnect?.initialDelayMs ?? 50;
147
153
  this.maxDelayMs = options.reconnect?.maxDelayMs ?? 2_000;
@@ -244,6 +250,59 @@ export class SandboxChannel<
244
250
  this.enqueue(() => this.finalizeClose(1000, 'closed'));
245
251
  }
246
252
 
253
+ interrupt(options?: { timeoutMs?: number }): Promise<void> {
254
+ const timeoutMs = options?.timeoutMs ?? 5000;
255
+ return new Promise<void>((resolve, reject) => {
256
+ let settled = false;
257
+ let unsub = (): void => {};
258
+ const timer = setTimeout(() => {
259
+ complete(
260
+ new Error(
261
+ `SandboxChannel: interrupt was not acknowledged within ${timeoutMs}ms.`,
262
+ ),
263
+ );
264
+ }, timeoutMs);
265
+ timer.unref?.();
266
+
267
+ const complete = (error?: unknown): void => {
268
+ if (settled) return;
269
+ settled = true;
270
+ clearTimeout(timer);
271
+ unsub();
272
+ if (error) {
273
+ reject(error);
274
+ } else {
275
+ resolve();
276
+ }
277
+ };
278
+
279
+ unsub = this.on('bridge-interrupted' as EventTypeOf<TOut>, event => {
280
+ const response = event as unknown as {
281
+ type: 'bridge-interrupted';
282
+ ok: boolean;
283
+ error?: unknown;
284
+ };
285
+ if (response.ok) {
286
+ complete();
287
+ return;
288
+ }
289
+ complete(
290
+ new Error(
291
+ `SandboxChannel: interrupt failed: ${formatControlError(
292
+ response.error,
293
+ )}`,
294
+ ),
295
+ );
296
+ });
297
+
298
+ try {
299
+ this.send({ type: 'interrupt' } as TIn);
300
+ } catch (err) {
301
+ complete(err);
302
+ }
303
+ });
304
+ }
305
+
247
306
  /**
248
307
  * Gracefully suspend at a slice boundary: stop processing inbound frames
249
308
  * (so the cursor freezes at the last delivered event), drain any frames
@@ -428,6 +487,9 @@ export class SandboxChannel<
428
487
  );
429
488
  return;
430
489
  }
490
+ if (message.type === 'error') {
491
+ this.onBridgeError?.(message as Extract<TOut, { type: 'error' }>);
492
+ }
431
493
  const type = message.type as EventTypeOf<TOut>;
432
494
  const set = this.listeners.get(type);
433
495
  if (!set || set.size === 0) {
@@ -451,3 +513,13 @@ export class SandboxChannel<
451
513
  for (const h of this.onCloseHandlers) h(code, reason);
452
514
  }
453
515
  }
516
+
517
+ function formatControlError(error: unknown): string {
518
+ if (error instanceof Error) return error.message;
519
+ if (error && typeof error === 'object') {
520
+ const message = (error as { message?: unknown }).message;
521
+ if (typeof message === 'string' && message.length > 0) return message;
522
+ }
523
+ if (typeof error === 'string' && error.length > 0) return error;
524
+ return 'unknown error';
525
+ }
@@ -140,6 +140,17 @@ 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
+
143
154
  // --- Diagnostics frames (outbound, not consumer events) ---
144
155
 
145
156
  /**
@@ -200,6 +211,7 @@ export const harnessV1BridgeOutboundMessageSchema = z.discriminatedUnion(
200
211
  harnessV1BridgeHelloSchema,
201
212
  harnessV1BridgeDetachSchema,
202
213
  harnessV1BridgeThreadSchema,
214
+ harnessV1BridgeInterruptedSchema,
203
215
  harnessV1BridgeSandboxLogSchema,
204
216
  harnessV1BridgeDebugEventSchema,
205
217
  ],
@@ -277,6 +289,10 @@ export const harnessV1BridgeAbortInboundSchema = z.object({
277
289
  type: z.literal('abort'),
278
290
  });
279
291
 
292
+ export const harnessV1BridgeInterruptInboundSchema = z.object({
293
+ type: z.literal('interrupt'),
294
+ });
295
+
280
296
  export const harnessV1BridgeShutdownInboundSchema = z.object({
281
297
  type: z.literal('shutdown'),
282
298
  });
@@ -308,6 +324,7 @@ export const harnessV1BridgeInboundCommandSchemas = [
308
324
  harnessV1BridgeToolApprovalResponseInboundSchema,
309
325
  harnessV1BridgeUserMessageInboundSchema,
310
326
  harnessV1BridgeAbortInboundSchema,
327
+ harnessV1BridgeInterruptInboundSchema,
311
328
  harnessV1BridgeShutdownInboundSchema,
312
329
  harnessV1BridgeResumeInboundSchema,
313
330
  harnessV1BridgeDetachInboundSchema,