@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.
- package/CHANGELOG.md +8 -0
- package/README.md +59 -49
- package/dist/agent/index.js +63 -0
- package/dist/agent/index.js.map +1 -1
- package/dist/bridge/index.d.ts +18 -0
- package/dist/bridge/index.js +69 -4
- package/dist/bridge/index.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/index.d.ts +40 -1
- package/dist/utils/index.js +240 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/agent/internal/run-prompt.ts +19 -0
- package/src/bridge/index.ts +86 -3
- package/src/utils/bridge-diagnostics.ts +213 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/sandbox-channel.ts +72 -0
- package/src/v1/harness-v1-bridge-protocol.ts +17 -0
package/src/bridge/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
755
|
+
emitError({ error: err, message: 'uncaught exception' });
|
|
673
756
|
});
|
|
674
757
|
process.on('unhandledRejection', err => {
|
|
675
|
-
|
|
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
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -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,
|