@ai-sdk/harness 1.0.72 → 1.0.74

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.
@@ -1,10 +1,11 @@
1
1
  // Shared in-sandbox bridge runtime. Adapter `bridge.mjs` bundles re-bundle
2
2
  // this module (tsup inlines it; `ws` stays external and resolves from the
3
3
  // sandbox-installed node_modules). It owns everything generic to the bridge
4
- // transport — the WebSocket server, token auth, single-flight connection
5
- // replacement, the in-memory event log + monotonic `seq`, resume replay, and
6
- // the lifecycle/meta files. The adapter supplies only `onStart` (drive its
7
- // CLI/SDK and translate to wire events) and lifecycle-specific cleanup hooks.
4
+ // transport — the WebSocket server, token auth, the in-memory event log +
5
+ // monotonic `seq`, resume replay, and the lifecycle/meta files. Any number of
6
+ // hosts may be connected; exactly one of them owns the event stream, and
7
+ // `start`/`resume` transfer that ownership. The adapter supplies only `onStart`
8
+ // (drive its CLI/SDK and translate to wire events) and lifecycle cleanup hooks.
8
9
 
9
10
  import { appendFile, mkdir, writeFile } from 'node:fs/promises';
10
11
  import { existsSync, readFileSync } from 'node:fs';
@@ -234,8 +235,14 @@ export async function runBridge<TStart extends { type: 'start' }>(
234
235
  // ─── mutable runtime state ──────────────────────────────────────────
235
236
  let currentBoundPort = 0;
236
237
  let currentTurnState: BridgeState = 'init';
238
+ /*
239
+ * The one connection turn events stream to. A socket claims it by asking for
240
+ * work — `start` (a turn) or `resume` (a catch-up) — never by connecting:
241
+ * every event goes here alone, so claiming on connect would silence a turn
242
+ * already streaming to someone else. Any number of sockets may be connected;
243
+ * the others still exchange control frames, they just get no events.
244
+ */
237
245
  let activeSocket: WebSocket | undefined;
238
- let activeSocketReadyForLiveEvents = false;
239
246
  let isFirstTurn = true;
240
247
  let turnAbort: AbortController | undefined;
241
248
  let currentUserMessages: string[] | undefined;
@@ -374,26 +381,13 @@ export async function runBridge<TStart extends { type: 'start' }>(
374
381
  };
375
382
 
376
383
  // ─── wire send + replay ─────────────────────────────────────────────
377
- const sendControl = (msg: Record<string, unknown>): void => {
378
- if (activeSocket?.readyState === WS_OPEN) {
379
- try {
380
- activeSocket.send(JSON.stringify(msg));
381
- } catch {
382
- // best-effort
383
- }
384
- }
385
- };
386
-
387
384
  const emit = (event: BridgeEvent): void => {
388
385
  const seq = ++seqCounter;
389
386
  const line = JSON.stringify({ ...event, seq });
390
387
  eventLog.push({ seq, line });
391
388
  diskBuffer += `${line}\n`;
392
389
  scheduleEventFlush();
393
- if (
394
- activeSocketReadyForLiveEvents &&
395
- activeSocket?.readyState === WS_OPEN
396
- ) {
390
+ if (activeSocket?.readyState === WS_OPEN) {
397
391
  try {
398
392
  activeSocket.send(line);
399
393
  } catch {
@@ -526,8 +520,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
526
520
  ): Promise<void> => {
527
521
  switch (msg.type) {
528
522
  case 'start': {
529
- if (activeSocket !== ws) return;
530
- activeSocketReadyForLiveEvents = true;
523
+ activeSocket = ws; // asking for a turn claims the event stream
531
524
  const firstTurn = isFirstTurn;
532
525
  isFirstTurn = false;
533
526
  eventLog = []; // clear previous turn; keep seqCounter monotonic
@@ -616,9 +609,9 @@ export async function runBridge<TStart extends { type: 'start' }>(
616
609
  turnAbort?.abort();
617
610
  return;
618
611
  case 'resume':
619
- if (activeSocket !== ws) return;
612
+ activeSocket = ws; // asking for a catch-up claims it too
613
+ // Synchronous, so no event can slip out live ahead of the replayed tail.
620
614
  replay(ws, msg.lastSeenEventId);
621
- activeSocketReadyForLiveEvents = true;
622
615
  return;
623
616
  case 'destroy':
624
617
  currentTurnState = 'done';
@@ -630,7 +623,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
630
623
  currentTurnState = 'done';
631
624
  void writeBridgeMeta('done');
632
625
  const data = (await onStop?.()) ?? {};
633
- sendControl({ type: 'bridge-stop', data });
626
+ sendControl(ws, { type: 'bridge-stop', data });
634
627
  drainThenExit(ws, 1000, 'stop');
635
628
  return;
636
629
  }
@@ -693,16 +686,10 @@ export async function runBridge<TStart extends { type: 'start' }>(
693
686
  return;
694
687
  }
695
688
 
696
- // Single-flight: a fresh authorized connection *replaces* the active one
697
- // (the host reconnecting after a drop). The previous socket's close is a
698
- // no-op below because it is no longer `activeSocket`.
699
- activeSocket = ws;
700
- activeSocketReadyForLiveEvents = false;
701
-
702
689
  // Announce liveness the instant we accept. Some sandbox runtimes complete
703
690
  // the host-side WS handshake before the connection is forwarded here; the
704
691
  // host waits for this frame before sending `start`/`resume`.
705
- sendControl({
692
+ sendControl(ws, {
706
693
  type: 'bridge-hello',
707
694
  state: currentTurnState,
708
695
  lastSeq: seqCounter,
@@ -715,7 +702,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
715
702
  typeof raw === 'string' ? raw : Buffer.from(raw).toString('utf8');
716
703
  parsed = JSON.parse(text) as TStart | InboundControl;
717
704
  } catch (err) {
718
- sendControl({
705
+ sendControl(ws, {
719
706
  type: 'error',
720
707
  error: `protocol parse error: ${(err as Error).message}`,
721
708
  });
@@ -725,13 +712,12 @@ export async function runBridge<TStart extends { type: 'start' }>(
725
712
  });
726
713
 
727
714
  ws.on('close', () => {
728
- // Only the *current* socket's close matters. A stale socket (already
729
- // replaced by a reconnect) closing is a no-op. Crucially we do NOT abort
730
- // the in-flight turn it keeps running and its events accumulate in the
731
- // log for replay when the host reconnects.
715
+ // Only the stream owner's close matters; a socket that never claimed it,
716
+ // or that a later `start`/`resume` displaced, closes as a no-op.
717
+ // Crucially we do NOT abort the in-flight turn: it keeps running and its
718
+ // events accumulate in the log for replay on reconnect.
732
719
  if (activeSocket === ws) {
733
720
  activeSocket = undefined;
734
- activeSocketReadyForLiveEvents = false;
735
721
  }
736
722
  });
737
723
 
@@ -767,6 +753,24 @@ export async function runBridge<TStart extends { type: 'start' }>(
767
753
  };
768
754
  }
769
755
 
756
+ /*
757
+ * Control frames answer the socket that sent the frame they reply to, so the
758
+ * target is always explicit. Event streaming is the separate, stateful path
759
+ * (`emit` → `activeSocket`); this one carries no state at all.
760
+ */
761
+ function sendControl(
762
+ socket: WebSocket | undefined,
763
+ message: Record<string, unknown>,
764
+ ): void {
765
+ if (socket?.readyState === WS_OPEN) {
766
+ try {
767
+ socket.send(JSON.stringify(message));
768
+ } catch {
769
+ // best-effort
770
+ }
771
+ }
772
+ }
773
+
770
774
  function serialiseError(err: unknown): unknown {
771
775
  if (err instanceof Error) {
772
776
  return { name: err.name, message: err.message, stack: err.stack };
@@ -4,6 +4,7 @@ import {
4
4
  harnessV1DebugLevelSchema,
5
5
  type HarnessV1Diagnostic,
6
6
  } from './harness-v1-diagnostic';
7
+ import type { HarnessV1ResponseFormat } from './harness-v1-response-format';
7
8
  import {
8
9
  harnessV1CompactionPartSchema,
9
10
  harnessV1ErrorPartSchema,
@@ -87,6 +88,17 @@ export const harnessV1BridgeBuiltinToolFilteringSchema = z.discriminatedUnion(
87
88
  ],
88
89
  );
89
90
 
91
+ export const harnessV1BridgeResponseFormatSchema: z.ZodType<HarnessV1ResponseFormat> =
92
+ z.discriminatedUnion('type', [
93
+ z.object({ type: z.literal('text') }),
94
+ z.object({
95
+ type: z.literal('json'),
96
+ schema: z.record(z.string(), z.json()).optional(),
97
+ name: z.string().optional(),
98
+ description: z.string().optional(),
99
+ }),
100
+ ]);
101
+
90
102
  /**
91
103
  * Common fields of the inbound `start` message. Each adapter extends this with
92
104
  * its runtime-specific configuration (e.g. `thinking`/`continue` for Claude
@@ -105,6 +117,7 @@ export const harnessV1BridgeStartBaseSchema = z.object({
105
117
  debug: harnessV1DebugConfigSchema.optional(),
106
118
  permissionMode: harnessV1BridgePermissionModeSchema.optional(),
107
119
  builtinToolFiltering: harnessV1BridgeBuiltinToolFilteringSchema.optional(),
120
+ responseFormat: harnessV1BridgeResponseFormatSchema.optional(),
108
121
  });
109
122
 
110
123
  // --- Transport / control frames (outbound, not consumer events) ---
@@ -0,0 +1,32 @@
1
+ export type HarnessV1JSONValue =
2
+ | null
3
+ | boolean
4
+ | number
5
+ | string
6
+ | HarnessV1JSONArray
7
+ | HarnessV1JSONObject;
8
+
9
+ export interface HarnessV1JSONArray extends Array<HarnessV1JSONValue> {}
10
+
11
+ export interface HarnessV1JSONObject {
12
+ [key: string]: HarnessV1JSONValue | undefined;
13
+ }
14
+
15
+ export type HarnessV1JSONSchema = HarnessV1JSONObject;
16
+
17
+ /**
18
+ * Requested response format for one harness turn.
19
+ *
20
+ * This intentionally mirrors the AI SDK provider response-format shape
21
+ * without depending on `@ai-sdk/provider`. Harness implementations receive
22
+ * JSON Schema rather than the caller's original Zod or Standard Schema so the
23
+ * contract can cross process and package boundaries.
24
+ */
25
+ export type HarnessV1ResponseFormat =
26
+ | { readonly type: 'text' }
27
+ | {
28
+ readonly type: 'json';
29
+ readonly schema?: HarnessV1JSONSchema;
30
+ readonly name?: string;
31
+ readonly description?: string;
32
+ };
@@ -3,6 +3,7 @@ import type { HarnessV1Observability } from './harness-v1-observability';
3
3
  import type { HarnessV1PermissionMode } from './harness-v1-permission-mode';
4
4
  import type { HarnessV1Prompt } from './harness-v1-prompt';
5
5
  import type { HarnessV1PromptControl } from './harness-v1-prompt-control';
6
+ import type { HarnessV1ResponseFormat } from './harness-v1-response-format';
6
7
  import type {
7
8
  HarnessV1ContinueTurnState,
8
9
  HarnessV1ResumeSessionState,
@@ -105,6 +106,12 @@ export type HarnessV1PromptTurnOptions = {
105
106
  */
106
107
  readonly prompt: HarnessV1Prompt;
107
108
 
109
+ /**
110
+ * Response format requested for this turn. Adapters that cannot honor a
111
+ * JSON response format must throw `HarnessCapabilityUnsupportedError`.
112
+ */
113
+ readonly responseFormat?: HarnessV1ResponseFormat;
114
+
108
115
  /**
109
116
  * Host-defined tools to make available to the underlying runtime for this
110
117
  * turn. The harness emits `tool-call` events when the runtime calls one
@@ -144,6 +151,12 @@ export type HarnessV1PromptTurnOptions = {
144
151
  * that was previously suspended temporarily, e.g. by the workflow slice loop.
145
152
  */
146
153
  export type HarnessV1ContinueTurnOptions = {
154
+ /**
155
+ * Response format of the in-flight turn. Rerun-based adapters use this when
156
+ * reconstructing the turn; attach-based adapters may ignore it.
157
+ */
158
+ readonly responseFormat?: HarnessV1ResponseFormat;
159
+
147
160
  /**
148
161
  * Host-defined tools to make available for the continued turn. Same shape
149
162
  * as `doPromptTurn`'s `tools`. An adapter that purely attaches to a live turn
package/src/v1/index.ts CHANGED
@@ -25,6 +25,13 @@ export {
25
25
  } from './harness-v1-builtin-tool';
26
26
  export type { HarnessV1Metadata } from './harness-v1-metadata';
27
27
  export type { HarnessV1Prompt } from './harness-v1-prompt';
28
+ export type {
29
+ HarnessV1JSONSchema,
30
+ HarnessV1JSONArray,
31
+ HarnessV1JSONObject,
32
+ HarnessV1JSONValue,
33
+ HarnessV1ResponseFormat,
34
+ } from './harness-v1-response-format';
28
35
  export type { HarnessV1SandboxProvider } from './harness-v1-sandbox-provider';
29
36
  export type {
30
37
  HarnessV1ContinueTurnState,
@@ -68,6 +75,7 @@ export {
68
75
  harnessV1BridgeInboundCommandSchemas,
69
76
  harnessV1BridgeOutboundMessageSchema,
70
77
  harnessV1BridgeReadySchema,
78
+ harnessV1BridgeResponseFormatSchema,
71
79
  harnessV1BridgeResumeInboundSchema,
72
80
  harnessV1BridgeSandboxLogSchema,
73
81
  harnessV1BridgeStopInboundSchema,