@ai-sdk/harness 1.0.117 → 1.0.118

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/harness",
3
- "version": "1.0.117",
3
+ "version": "1.0.118",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -46,7 +46,7 @@
46
46
  "dependencies": {
47
47
  "@ai-sdk/provider": "4.0.17",
48
48
  "@ai-sdk/provider-utils": "5.0.45",
49
- "ai": "7.0.107"
49
+ "ai": "7.0.108"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "ws": "^8.21.0",
@@ -58,7 +58,7 @@
58
58
  }
59
59
  },
60
60
  "devDependencies": {
61
- "@ai-sdk/otel": "1.0.107",
61
+ "@ai-sdk/otel": "1.0.108",
62
62
  "@opentelemetry/sdk-trace-base": "2.7.1",
63
63
  "@types/node": "22.19.19",
64
64
  "@types/ws": "^8.5.13",
@@ -12,36 +12,63 @@ export function createToolInputWorkDirStripper({
12
12
  }: {
13
13
  sessionWorkDir: string;
14
14
  }): (part: ToolInputStreamPart) => ToolInputStreamPart[] {
15
- const pendingByToolCallId = new Map<string, string>();
15
+ const stateByToolCallId = new Map<
16
+ string,
17
+ {
18
+ pending: string;
19
+ precedingCharacter: string | undefined;
20
+ }
21
+ >();
16
22
 
17
23
  return part => {
18
24
  if (sessionWorkDir.length === 0) return [part];
19
25
 
20
26
  if (part.type === 'tool-input-start') {
21
- pendingByToolCallId.set(part.id, '');
27
+ stateByToolCallId.set(part.id, {
28
+ pending: '',
29
+ precedingCharacter: undefined,
30
+ });
22
31
  return [part];
23
32
  }
24
33
 
25
34
  if (part.type === 'tool-input-delta') {
35
+ const state = stateByToolCallId.get(part.id) ?? {
36
+ pending: '',
37
+ precedingCharacter: undefined,
38
+ };
39
+ const value = state.pending + part.delta;
26
40
  const stripped = stripStreamingString({
27
- value: (pendingByToolCallId.get(part.id) ?? '') + part.delta,
41
+ value,
28
42
  workDir: sessionWorkDir,
29
43
  final: false,
44
+ precedingCharacter: state.precedingCharacter,
45
+ });
46
+ const pendingStart = value.length - stripped.pending.length;
47
+ stateByToolCallId.set(part.id, {
48
+ pending: stripped.pending,
49
+ precedingCharacter:
50
+ stripped.pending.length > 0
51
+ ? pendingStart > 0
52
+ ? value[pendingStart - 1]
53
+ : state.precedingCharacter
54
+ : value.length > 0
55
+ ? value[value.length - 1]
56
+ : state.precedingCharacter,
30
57
  });
31
- pendingByToolCallId.set(part.id, stripped.pending);
32
58
  return stripped.output.length === 0
33
59
  ? []
34
60
  : [{ ...part, delta: stripped.output }];
35
61
  }
36
62
 
37
- const pending = pendingByToolCallId.get(part.id);
38
- pendingByToolCallId.delete(part.id);
39
- if (pending == null || pending.length === 0) return [part];
63
+ const state = stateByToolCallId.get(part.id);
64
+ stateByToolCallId.delete(part.id);
65
+ if (state == null || state.pending.length === 0) return [part];
40
66
 
41
67
  const stripped = stripStreamingString({
42
- value: pending,
68
+ value: state.pending,
43
69
  workDir: sessionWorkDir,
44
70
  final: true,
71
+ precedingCharacter: state.precedingCharacter,
45
72
  });
46
73
  return stripped.output.length === 0
47
74
  ? [part]
@@ -62,11 +89,10 @@ export function createToolInputWorkDirStripper({
62
89
  * operates. The absolute paths are correct but noisy in a UI, so this strips
63
90
  * the prefix for the consumer-facing projection only.
64
91
  *
65
- * Blanket prefix replacement (rather than rewriting known path fields) is used
66
- * deliberately: `tool-result` results are free-form text — command stdout, grep
67
- * output — where paths can appear anywhere and field-aware rewriting is
68
- * impossible. The prefix is long and contains the session id, so it is unique
69
- * enough that replacing every occurrence is safe.
92
+ * Boundary-aware prefix replacement (rather than rewriting known path fields)
93
+ * is used deliberately: `tool-result` results are free-form text — command
94
+ * stdout, grep output — where paths can appear anywhere and field-aware
95
+ * rewriting is impossible.
70
96
  */
71
97
  export function stripWorkDir(
72
98
  part: HarnessV1StreamPart,
@@ -101,33 +127,60 @@ export function stripWorkDir(
101
127
  * becomes `.`.
102
128
  */
103
129
  function stripString(value: string, workDir: string): string {
104
- return value.split(`${workDir}/`).join('').split(workDir).join('.');
130
+ return stripStreamingString({
131
+ value,
132
+ workDir,
133
+ final: true,
134
+ precedingCharacter: undefined,
135
+ }).output;
105
136
  }
106
137
 
107
138
  function stripStreamingString({
108
139
  value,
109
140
  workDir,
110
141
  final,
142
+ precedingCharacter,
111
143
  }: {
112
144
  value: string;
113
145
  workDir: string;
114
146
  final: boolean;
147
+ precedingCharacter: string | undefined;
115
148
  }): { output: string; pending: string } {
116
149
  let remaining = value;
117
150
  let output = '';
151
+ let characterBeforeRemaining = precedingCharacter;
118
152
 
119
153
  while (remaining.length > 0) {
120
154
  const matchIndex = remaining.indexOf(workDir);
121
155
  if (matchIndex >= 0) {
122
- output += remaining.slice(0, matchIndex);
123
156
  const followingIndex = matchIndex + workDir.length;
124
- if (followingIndex === remaining.length && !final) {
125
- return { output, pending: remaining.slice(matchIndex) };
157
+ const hasPathBoundary = isPathBoundary(
158
+ remaining,
159
+ matchIndex,
160
+ characterBeforeRemaining,
161
+ );
162
+ if (hasPathBoundary && followingIndex === remaining.length && !final) {
163
+ return {
164
+ output: output + remaining.slice(0, matchIndex),
165
+ pending: remaining.slice(matchIndex),
166
+ };
126
167
  }
127
- if (remaining[followingIndex] === '/') {
168
+
169
+ const isPath =
170
+ hasPathBoundary &&
171
+ (followingIndex === remaining.length ||
172
+ remaining[followingIndex] === '/' ||
173
+ isPathTerminator(remaining[followingIndex]));
174
+ if (!isPath) {
175
+ output += remaining.slice(0, followingIndex);
176
+ characterBeforeRemaining = remaining[followingIndex - 1];
177
+ remaining = remaining.slice(followingIndex);
178
+ } else if (remaining[followingIndex] === '/') {
179
+ output += remaining.slice(0, matchIndex);
180
+ characterBeforeRemaining = remaining[followingIndex];
128
181
  remaining = remaining.slice(followingIndex + 1);
129
182
  } else {
130
- output += '.';
183
+ output += remaining.slice(0, matchIndex) + '.';
131
184
  remaining = remaining.slice(followingIndex);
132
185
  }
133
186
  continue;
@@ -138,7 +191,12 @@ function stripStreamingString({
138
191
  let pendingLength = Math.min(remaining.length, workDir.length - 1);
139
192
  while (
140
193
  pendingLength > 0 &&
141
- !workDir.startsWith(remaining.slice(-pendingLength))
194
+ (!workDir.startsWith(remaining.slice(-pendingLength)) ||
195
+ !isPathBoundary(
196
+ remaining,
197
+ remaining.length - pendingLength,
198
+ characterBeforeRemaining,
199
+ ))
142
200
  ) {
143
201
  pendingLength -= 1;
144
202
  }
@@ -152,6 +210,19 @@ function stripStreamingString({
152
210
  return { output, pending: '' };
153
211
  }
154
212
 
213
+ function isPathBoundary(
214
+ value: string,
215
+ index: number,
216
+ precedingCharacter?: string,
217
+ ): boolean {
218
+ const character = index === 0 ? precedingCharacter : value[index - 1]!;
219
+ return character === undefined || /[\s"'`=]/.test(character);
220
+ }
221
+
222
+ function isPathTerminator(character: string | undefined): boolean {
223
+ return character === undefined || /[\s"'`;|&<>()]/.test(character);
224
+ }
225
+
155
226
  /**
156
227
  * Recursively strip the working directory from every string nested in an
157
228
  * arbitrary JSON-like value. Non-string leaves are returned unchanged.
@@ -1,5 +1,6 @@
1
1
  export {
2
2
  SandboxChannel,
3
+ type SandboxChannelConnectOptions,
3
4
  type SandboxChannelDebugEvent,
4
5
  type SandboxChannelOptions,
5
6
  type SandboxChannelReconnectOptions,
@@ -69,3 +70,4 @@ export {
69
70
  } from './bridge-diagnostics';
70
71
  export { resolveSandboxDefaultWorkingDirectory } from './resolve-sandbox-default-working-directory';
71
72
  export { getRestrictedSandboxSession } from './get-restricted-sandbox-session';
73
+ export { sleep } from './sleep';
@@ -4,6 +4,7 @@ import {
4
4
  type FlexibleSchema,
5
5
  } from '@ai-sdk/provider-utils';
6
6
  import type { WebSocket } from 'ws';
7
+ import { sleep } from './sleep';
7
8
 
8
9
  /**
9
10
  * Diagnostic event surfaced by {@link SandboxChannel} during its connection
@@ -22,7 +23,10 @@ export type SandboxChannelDebugEvent =
22
23
  };
23
24
 
24
25
  export interface SandboxChannelReconnectOptions {
25
- /** Give up reconnecting after this many milliseconds. Default 30_000. */
26
+ /**
27
+ * Give up reconnecting after this many milliseconds, including connection
28
+ * establishment and backoff delays. Default 30_000.
29
+ */
26
30
  readonly maxElapsedMs?: number;
27
31
  /** First backoff delay. Default 50. */
28
32
  readonly initialDelayMs?: number;
@@ -30,6 +34,10 @@ export interface SandboxChannelReconnectOptions {
30
34
  readonly maxDelayMs?: number;
31
35
  }
32
36
 
37
+ export interface SandboxChannelConnectOptions {
38
+ readonly abortSignal: AbortSignal;
39
+ }
40
+
33
41
  export interface SandboxChannelOptions<TOut> {
34
42
  /**
35
43
  * Open a fresh WebSocket to the bridge and resolve once it is ready to carry
@@ -38,7 +46,7 @@ export interface SandboxChannelOptions<TOut> {
38
46
  * every transient reconnect. Must reject if the connection cannot be
39
47
  * established.
40
48
  */
41
- connect: () => Promise<WebSocket>;
49
+ connect: (options: SandboxChannelConnectOptions) => Promise<WebSocket>;
42
50
 
43
51
  /** Schema validating inbound (bridge → host) frames. */
44
52
  outboundSchema: FlexibleSchema<TOut>;
@@ -99,12 +107,68 @@ export function pinSandboxChannelEventCheckpoint(
99
107
  )[sandboxChannelEventCheckpointSymbol]?.pin();
100
108
  }
101
109
 
102
- const sleep = (ms: number): Promise<void> =>
103
- new Promise(resolve => {
104
- const t = setTimeout(resolve, ms);
105
- (t as { unref?: () => void }).unref?.();
110
+ function getAbortReason({
111
+ abortSignal,
112
+ }: {
113
+ abortSignal: AbortSignal;
114
+ }): unknown {
115
+ return abortSignal.reason ?? new Error('SandboxChannel connection aborted');
116
+ }
117
+
118
+ function closeWebSocket({ ws }: { ws: WebSocket }): void {
119
+ try {
120
+ const terminable = ws as WebSocket & { terminate?: () => void };
121
+ if (terminable.terminate != null) {
122
+ terminable.terminate();
123
+ } else {
124
+ ws.close();
125
+ }
126
+ } catch {
127
+ try {
128
+ ws.close();
129
+ } catch {
130
+ // best-effort
131
+ }
132
+ }
133
+ }
134
+
135
+ async function awaitWebSocketConnection({
136
+ connection,
137
+ abortSignal,
138
+ }: {
139
+ connection: Promise<WebSocket>;
140
+ abortSignal: AbortSignal;
141
+ }): Promise<WebSocket> {
142
+ if (abortSignal.aborted) {
143
+ void connection.then(ws => closeWebSocket({ ws })).catch(() => {});
144
+ throw getAbortReason({ abortSignal });
145
+ }
146
+
147
+ let onAbort: (() => void) | undefined;
148
+ const aborted = new Promise<never>((_, reject) => {
149
+ onAbort = () => reject(getAbortReason({ abortSignal }));
150
+ abortSignal.addEventListener('abort', onAbort, { once: true });
106
151
  });
107
152
 
153
+ try {
154
+ const ws = await Promise.race([connection, aborted]);
155
+ if (abortSignal.aborted) {
156
+ closeWebSocket({ ws });
157
+ throw getAbortReason({ abortSignal });
158
+ }
159
+ return ws;
160
+ } catch (error) {
161
+ if (abortSignal.aborted) {
162
+ void connection.then(ws => closeWebSocket({ ws })).catch(() => {});
163
+ }
164
+ throw error;
165
+ } finally {
166
+ if (onAbort != null) {
167
+ abortSignal.removeEventListener('abort', onAbort);
168
+ }
169
+ }
170
+ }
171
+
108
172
  /**
109
173
  * Host-side typed wrapper around the bridge WebSocket connection.
110
174
  *
@@ -135,7 +199,9 @@ export class SandboxChannel<
135
199
  >();
136
200
  private readonly onReconnectHandlers = new Set<() => void>();
137
201
 
138
- private readonly connectThunk: () => Promise<WebSocket>;
202
+ private readonly connectThunk: (
203
+ options: SandboxChannelConnectOptions,
204
+ ) => Promise<WebSocket>;
139
205
  private readonly outboundSchema: FlexibleSchema<TOut>;
140
206
  private readonly onDebug:
141
207
  | ((event: SandboxChannelDebugEvent) => void)
@@ -169,6 +235,8 @@ export class SandboxChannel<
169
235
  private _lastSeenEventId = 0;
170
236
  private readonly pendingSends: string[] = [];
171
237
  private dispatchChain: Promise<void> = Promise.resolve();
238
+ private activeConnectAbortController: AbortController | undefined;
239
+ private reconnectAbortController: AbortController | undefined;
172
240
 
173
241
  constructor(options: SandboxChannelOptions<TOut>) {
174
242
  this.connectThunk = options.connect;
@@ -206,17 +274,27 @@ export class SandboxChannel<
206
274
  if (this.terminal) {
207
275
  throw new Error('SandboxChannel: cannot open a closed channel.');
208
276
  }
209
- const ws = await this.connectThunk();
210
- this.wire(ws);
211
- this.ws = ws;
212
- this.connected = true;
213
- if (opts?.resume) {
214
- this.rawSend(
215
- JSON.stringify({
216
- type: 'resume',
217
- lastSeenEventId: this._lastSeenEventId,
218
- }),
219
- );
277
+ const abortController = new AbortController();
278
+ this.activeConnectAbortController = abortController;
279
+ try {
280
+ const ws = await this.connect({
281
+ abortSignal: abortController.signal,
282
+ });
283
+ this.wire(ws);
284
+ this.ws = ws;
285
+ this.connected = true;
286
+ if (opts?.resume) {
287
+ this.rawSend(
288
+ JSON.stringify({
289
+ type: 'resume',
290
+ lastSeenEventId: this._lastSeenEventId,
291
+ }),
292
+ );
293
+ }
294
+ } finally {
295
+ if (this.activeConnectAbortController === abortController) {
296
+ this.activeConnectAbortController = undefined;
297
+ }
220
298
  }
221
299
  }
222
300
 
@@ -272,11 +350,13 @@ export class SandboxChannel<
272
350
  */
273
351
  beginClose(): void {
274
352
  this.closing = true;
353
+ this.abortActiveConnect();
275
354
  }
276
355
 
277
356
  close(): void {
278
357
  if (this.terminal) return;
279
358
  this.closing = true;
359
+ this.abortActiveConnect();
280
360
  try {
281
361
  this.ws?.close();
282
362
  } catch {
@@ -310,6 +390,7 @@ export class SandboxChannel<
310
390
  // suppress reconnect so the socket close finalises.
311
391
  this.suspended = true;
312
392
  this.closing = true;
393
+ this.abortActiveConnect();
313
394
  this.onClose(() =>
314
395
  resolve(pinnedSuspensionCursor ?? this._lastSeenEventId),
315
396
  );
@@ -362,62 +443,143 @@ export class SandboxChannel<
362
443
 
363
444
  private async reconnectLoop(): Promise<void> {
364
445
  if (this.terminal || this.closing) return;
365
- const start = Date.now();
446
+ const deadline = Date.now() + this.maxElapsedMs;
366
447
  let attempt = 0;
367
448
  let delay = this.initialDelayMs;
368
- while (!this.terminal && !this.closing) {
369
- attempt++;
370
- this.onDebug?.({
371
- event: 'reconnect-attempt',
372
- attempt,
373
- lastSeenEventId: this._lastSeenEventId,
374
- });
375
- try {
376
- const ws = await this.connectThunk();
377
- if (this.terminal || this.closing) {
378
- try {
379
- ws.close();
380
- } catch {
381
- // best-effort
382
- }
449
+ const reconnectAbortController = new AbortController();
450
+ this.reconnectAbortController = reconnectAbortController;
451
+ try {
452
+ while (!this.terminal && !this.closing) {
453
+ const remaining = deadline - Date.now();
454
+ if (remaining <= 0) {
455
+ this.failReconnect({
456
+ attempts: attempt,
457
+ cause: new Error('Reconnect deadline expired'),
458
+ });
383
459
  return;
384
460
  }
385
- this.wire(ws);
386
- this.ws = ws;
387
- this.connected = true;
388
- // Ask the bridge to replay everything we have not seen, then flush any
389
- // host → bridge frames produced while we were disconnected.
390
- this.rawSend(
391
- JSON.stringify({
392
- type: 'resume',
393
- lastSeenEventId: this._lastSeenEventId,
394
- }),
395
- );
396
- this.flushPending();
397
- for (const handler of this.onReconnectHandlers) handler();
461
+
462
+ attempt++;
398
463
  this.onDebug?.({
399
- event: 'reconnected',
464
+ event: 'reconnect-attempt',
400
465
  attempt,
401
466
  lastSeenEventId: this._lastSeenEventId,
402
467
  });
403
- return;
404
- } catch (cause) {
405
- if (Date.now() - start >= this.maxElapsedMs) {
406
- this.finalizeClose(1006, 'reconnect failed');
468
+ const abortController = new AbortController();
469
+ this.activeConnectAbortController = abortController;
470
+ const abortConnect = () =>
471
+ abortController.abort(reconnectAbortController.signal.reason);
472
+ reconnectAbortController.signal.addEventListener(
473
+ 'abort',
474
+ abortConnect,
475
+ {
476
+ once: true,
477
+ },
478
+ );
479
+ const timeout = setTimeout(() => {
480
+ abortController.abort(new Error('Reconnect deadline expired'));
481
+ }, remaining);
482
+ (timeout as { unref?: () => void }).unref?.();
483
+
484
+ try {
485
+ const ws = await this.connect({
486
+ abortSignal: abortController.signal,
487
+ });
488
+ if (this.terminal || this.closing) {
489
+ closeWebSocket({ ws });
490
+ return;
491
+ }
492
+ this.wire(ws);
493
+ this.ws = ws;
494
+ this.connected = true;
495
+ // Ask the bridge to replay everything we have not seen, then flush any
496
+ // host → bridge frames produced while we were disconnected.
497
+ this.rawSend(
498
+ JSON.stringify({
499
+ type: 'resume',
500
+ lastSeenEventId: this._lastSeenEventId,
501
+ }),
502
+ );
503
+ this.flushPending();
504
+ for (const handler of this.onReconnectHandlers) handler();
407
505
  this.onDebug?.({
408
- event: 'reconnect-failed',
409
- attempts: attempt,
506
+ event: 'reconnected',
507
+ attempt,
410
508
  lastSeenEventId: this._lastSeenEventId,
411
- cause,
412
509
  });
413
510
  return;
511
+ } catch (cause) {
512
+ if (
513
+ this.terminal ||
514
+ this.closing ||
515
+ reconnectAbortController.signal.aborted
516
+ ) {
517
+ return;
518
+ }
519
+ const remainingAfterFailure = deadline - Date.now();
520
+ if (remainingAfterFailure <= 0) {
521
+ this.failReconnect({ attempts: attempt, cause });
522
+ return;
523
+ }
524
+ await sleep({
525
+ ms: Math.min(delay, remainingAfterFailure),
526
+ abortSignal: reconnectAbortController.signal,
527
+ });
528
+ delay = Math.min(delay * 1.5, this.maxDelayMs);
529
+ } finally {
530
+ clearTimeout(timeout);
531
+ reconnectAbortController.signal.removeEventListener(
532
+ 'abort',
533
+ abortConnect,
534
+ );
535
+ if (this.activeConnectAbortController === abortController) {
536
+ this.activeConnectAbortController = undefined;
537
+ }
414
538
  }
415
- await sleep(delay);
416
- delay = Math.min(delay * 1.5, this.maxDelayMs);
539
+ }
540
+ } finally {
541
+ if (this.reconnectAbortController === reconnectAbortController) {
542
+ this.reconnectAbortController = undefined;
417
543
  }
418
544
  }
419
545
  }
420
546
 
547
+ private connect({
548
+ abortSignal,
549
+ }: SandboxChannelConnectOptions): Promise<WebSocket> {
550
+ return awaitWebSocketConnection({
551
+ connection: Promise.resolve().then(() =>
552
+ this.connectThunk({ abortSignal }),
553
+ ),
554
+ abortSignal,
555
+ });
556
+ }
557
+
558
+ private abortActiveConnect(): void {
559
+ const controller = this.activeConnectAbortController;
560
+ this.activeConnectAbortController = undefined;
561
+ controller?.abort(new Error('SandboxChannel connection aborted'));
562
+ this.reconnectAbortController?.abort(
563
+ new Error('SandboxChannel connection aborted'),
564
+ );
565
+ }
566
+
567
+ private failReconnect({
568
+ attempts,
569
+ cause,
570
+ }: {
571
+ attempts: number;
572
+ cause: unknown;
573
+ }): void {
574
+ this.finalizeClose(1006, 'reconnect failed');
575
+ this.onDebug?.({
576
+ event: 'reconnect-failed',
577
+ attempts,
578
+ lastSeenEventId: this._lastSeenEventId,
579
+ cause,
580
+ });
581
+ }
582
+
421
583
  private rawSend(text: string): void {
422
584
  if (this.connected && this.ws) {
423
585
  this.ws.send(text);
@@ -519,6 +681,7 @@ export class SandboxChannel<
519
681
 
520
682
  private finalizeClose(code: number, reason: string): void {
521
683
  if (this.terminal) return;
684
+ this.abortActiveConnect();
522
685
  this.terminal = true;
523
686
  this.connected = false;
524
687
  for (const h of this.onCloseHandlers) h(code, reason);
@@ -0,0 +1,30 @@
1
+ export function sleep({
2
+ ms,
3
+ abortSignal,
4
+ }: {
5
+ ms: number;
6
+ abortSignal?: AbortSignal;
7
+ }): Promise<void> {
8
+ return new Promise(resolve => {
9
+ if (abortSignal?.aborted) {
10
+ resolve();
11
+ return;
12
+ }
13
+
14
+ let settled = false;
15
+ let onAbort = () => {};
16
+ const finish = () => {
17
+ if (settled) return;
18
+ settled = true;
19
+ abortSignal?.removeEventListener('abort', onAbort);
20
+ resolve();
21
+ };
22
+ const timer = setTimeout(finish, ms);
23
+ (timer as { unref?: () => void }).unref?.();
24
+ onAbort = () => {
25
+ clearTimeout(timer);
26
+ finish();
27
+ };
28
+ abortSignal?.addEventListener('abort', onAbort, { once: true });
29
+ });
30
+ }