@ai-sdk/harness 1.0.39 → 1.0.40
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 +10 -0
- package/dist/agent/index.d.ts +19 -4
- package/dist/agent/index.js +140 -45
- package/dist/agent/index.js.map +1 -1
- package/dist/bridge/index.js +8 -1
- package/dist/bridge/index.js.map +1 -1
- package/dist/utils/index.d.ts +5 -1
- package/dist/utils/index.js +37 -5
- package/dist/utils/index.js.map +1 -1
- package/package.json +2 -2
- package/src/agent/harness-agent-session.ts +38 -8
- package/src/agent/harness-agent-settings.ts +25 -1
- package/src/agent/harness-agent.ts +17 -2
- package/src/agent/internal/run-prompt.ts +92 -21
- package/src/agent/internal/turn-telemetry.ts +18 -4
- package/src/bridge/index.ts +11 -1
- package/src/utils/sandbox-channel.ts +61 -3
|
@@ -9,10 +9,17 @@ import type {
|
|
|
9
9
|
HarnessAgentSkill,
|
|
10
10
|
} from './harness-agent-types';
|
|
11
11
|
import type {
|
|
12
|
+
Arrayable,
|
|
13
|
+
Context,
|
|
12
14
|
Experimental_SandboxSession as SandboxSession,
|
|
13
15
|
ToolSet,
|
|
14
16
|
} from '@ai-sdk/provider-utils';
|
|
15
|
-
import type {
|
|
17
|
+
import type {
|
|
18
|
+
ActiveTools,
|
|
19
|
+
StopCondition,
|
|
20
|
+
TelemetryOptions,
|
|
21
|
+
ToolApprovalStatus,
|
|
22
|
+
} from 'ai';
|
|
16
23
|
import type { HarnessAllTools } from './harness-agent-tool-types';
|
|
17
24
|
|
|
18
25
|
export type HarnessAgentToolApprovalConfiguration = Readonly<
|
|
@@ -91,6 +98,7 @@ type HarnessAgentToolFilteringSettings<TOOLS extends ToolSet> =
|
|
|
91
98
|
export type HarnessAgentSettings<
|
|
92
99
|
THarness extends HarnessAgentAdapter<any> = HarnessAgentAdapter,
|
|
93
100
|
TUserTools extends ToolSet = {},
|
|
101
|
+
RUNTIME_CONTEXT extends Context = Context,
|
|
94
102
|
> = {
|
|
95
103
|
/**
|
|
96
104
|
* The harness adapter driving the underlying agent runtime. Its
|
|
@@ -130,6 +138,22 @@ export type HarnessAgentSettings<
|
|
|
130
138
|
*/
|
|
131
139
|
readonly instructions?: string;
|
|
132
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Conditions that stop the current result after a completed harness tool
|
|
143
|
+
* step that can continue into another model step. The underlying turn remains
|
|
144
|
+
* unfinished and can be suspended and continued.
|
|
145
|
+
*
|
|
146
|
+
* A terminal text-only step finishes naturally and is not stopped early.
|
|
147
|
+
*
|
|
148
|
+
* When omitted, the harness runs until the turn naturally finishes or pauses.
|
|
149
|
+
*/
|
|
150
|
+
readonly stopWhen?: Arrayable<
|
|
151
|
+
StopCondition<
|
|
152
|
+
NoInfer<HarnessAllTools<THarness, TUserTools>>,
|
|
153
|
+
RUNTIME_CONTEXT
|
|
154
|
+
>
|
|
155
|
+
>;
|
|
156
|
+
|
|
133
157
|
/**
|
|
134
158
|
* Built-in tool permission mode. Defaults to `'allow-all'`, preserving the
|
|
135
159
|
* existing bypass-permissions behavior unless users opt in.
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
HarnessV1SandboxProvider,
|
|
7
7
|
} from '../v1';
|
|
8
8
|
import {
|
|
9
|
+
asArray,
|
|
9
10
|
asSchema,
|
|
10
11
|
generateId,
|
|
11
12
|
type Context,
|
|
@@ -19,6 +20,7 @@ import type {
|
|
|
19
20
|
GenerateTextResult,
|
|
20
21
|
ReasoningFileOutput,
|
|
21
22
|
ReasoningOutput,
|
|
23
|
+
StopCondition,
|
|
22
24
|
StreamTextResult,
|
|
23
25
|
} from 'ai';
|
|
24
26
|
import type {
|
|
@@ -133,7 +135,14 @@ export class HarnessAgent<
|
|
|
133
135
|
*/
|
|
134
136
|
readonly tools: HarnessAllTools<THarness, TUserTools>;
|
|
135
137
|
|
|
136
|
-
private readonly settings: HarnessAgentSettings<
|
|
138
|
+
private readonly settings: HarnessAgentSettings<
|
|
139
|
+
THarness,
|
|
140
|
+
TUserTools,
|
|
141
|
+
RUNTIME_CONTEXT
|
|
142
|
+
>;
|
|
143
|
+
private readonly stopConditions: Array<
|
|
144
|
+
StopCondition<HarnessAllTools<THarness, TUserTools>, RUNTIME_CONTEXT>
|
|
145
|
+
>;
|
|
137
146
|
private readonly sandboxConfig: HarnessAgentSandboxConfig;
|
|
138
147
|
private readonly activeUserTools: TUserTools;
|
|
139
148
|
private readonly builtinToolFiltering:
|
|
@@ -141,10 +150,14 @@ export class HarnessAgent<
|
|
|
141
150
|
| undefined;
|
|
142
151
|
private readonly permissionMode: HarnessAgentPermissionMode;
|
|
143
152
|
|
|
144
|
-
constructor(
|
|
153
|
+
constructor(
|
|
154
|
+
settings: HarnessAgentSettings<THarness, TUserTools, RUNTIME_CONTEXT>,
|
|
155
|
+
) {
|
|
145
156
|
const sandboxConfig = resolveSandboxConfig(settings);
|
|
146
157
|
validateSandboxBootstrapSettings(sandboxConfig);
|
|
147
158
|
this.settings = settings;
|
|
159
|
+
this.stopConditions =
|
|
160
|
+
settings.stopWhen == null ? [] : asArray(settings.stopWhen);
|
|
148
161
|
this.sandboxConfig = sandboxConfig;
|
|
149
162
|
this.id = settings.id;
|
|
150
163
|
const userTools = settings.tools ?? ({} as TUserTools);
|
|
@@ -528,6 +541,7 @@ export class HarnessAgent<
|
|
|
528
541
|
runtimeContext: input.runtimeContext,
|
|
529
542
|
abortSignal: input.abortSignal,
|
|
530
543
|
telemetry: this.settings.telemetry,
|
|
544
|
+
stopConditions: this.stopConditions,
|
|
531
545
|
toolApprovalContinuations: input.turnInput.toolApprovalContinuations,
|
|
532
546
|
toolResultContinuations: input.turnInput.toolResultContinuations,
|
|
533
547
|
});
|
|
@@ -546,6 +560,7 @@ export class HarnessAgent<
|
|
|
546
560
|
runtimeContext: input.runtimeContext,
|
|
547
561
|
abortSignal: input.abortSignal,
|
|
548
562
|
telemetry: this.settings.telemetry,
|
|
563
|
+
stopConditions: this.stopConditions,
|
|
549
564
|
});
|
|
550
565
|
}
|
|
551
566
|
|
|
@@ -32,6 +32,7 @@ import type {
|
|
|
32
32
|
ContentPart,
|
|
33
33
|
ProviderMetadata,
|
|
34
34
|
StepResult,
|
|
35
|
+
StopCondition,
|
|
35
36
|
TelemetryOptions,
|
|
36
37
|
TextStreamPart,
|
|
37
38
|
} from 'ai';
|
|
@@ -41,9 +42,14 @@ import type { HarnessAgentToolApprovalConfiguration } from '../harness-agent-set
|
|
|
41
42
|
import { HarnessStreamTextResult } from './harness-stream-text-result';
|
|
42
43
|
import { translateStreamPart } from './translate-stream-part';
|
|
43
44
|
import { stripWorkDir } from './strip-work-dir';
|
|
44
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
createTurnTelemetry,
|
|
47
|
+
type TurnContentPart,
|
|
48
|
+
type TurnTelemetry,
|
|
49
|
+
} from './turn-telemetry';
|
|
45
50
|
import { resolveCustomToolApproval } from './permission-mode';
|
|
46
51
|
import { logBridgeError } from '../../utils/bridge-diagnostics';
|
|
52
|
+
import { pinSandboxChannelEventCheckpoint } from '../../utils/sandbox-channel';
|
|
47
53
|
|
|
48
54
|
/**
|
|
49
55
|
* Drive one prompt turn end-to-end:
|
|
@@ -81,6 +87,7 @@ export function runPrompt<
|
|
|
81
87
|
runtimeContext: RUNTIME_CONTEXT;
|
|
82
88
|
abortSignal: AbortSignal | undefined;
|
|
83
89
|
telemetry?: TelemetryOptions | undefined;
|
|
90
|
+
stopConditions?: ReadonlyArray<StopCondition<TOOLS, RUNTIME_CONTEXT>>;
|
|
84
91
|
toolApproval?: HarnessAgentToolApprovalConfiguration | undefined;
|
|
85
92
|
pendingToolApprovals?: readonly HarnessV1PendingToolApproval[];
|
|
86
93
|
pendingToolResults?: readonly HarnessV1PendingToolResult[];
|
|
@@ -96,6 +103,7 @@ export function runPrompt<
|
|
|
96
103
|
onToolResultSettled?: (toolCallId: string) => void;
|
|
97
104
|
onTurnFinished?: () => void;
|
|
98
105
|
onTurnFailed?: () => void;
|
|
106
|
+
onStopConditionMet?: () => Promise<void>;
|
|
99
107
|
}): {
|
|
100
108
|
result: HarnessStreamTextResult<TOOLS, RUNTIME_CONTEXT>;
|
|
101
109
|
done: Promise<void>;
|
|
@@ -220,9 +228,21 @@ export function runPrompt<
|
|
|
220
228
|
);
|
|
221
229
|
const settledHostToolCallIds = new Set<string>();
|
|
222
230
|
let closingResumedStep = false;
|
|
231
|
+
let pendingStopBoundary:
|
|
232
|
+
| {
|
|
233
|
+
finishReason: LanguageModelV4FinishReason;
|
|
234
|
+
usage: LanguageModelV4Usage;
|
|
235
|
+
releaseCheckpoint: (() => void) | undefined;
|
|
236
|
+
}
|
|
237
|
+
| undefined;
|
|
223
238
|
let finalFinish:
|
|
224
239
|
| Extract<HarnessV1StreamPart, { type: 'finish' }>
|
|
225
240
|
| undefined;
|
|
241
|
+
const completedSteps: Array<StepResult<TOOLS, RUNTIME_CONTEXT>> = [];
|
|
242
|
+
const releasePendingStopBoundary = (): void => {
|
|
243
|
+
pendingStopBoundary?.releaseCheckpoint?.();
|
|
244
|
+
pendingStopBoundary = undefined;
|
|
245
|
+
};
|
|
226
246
|
|
|
227
247
|
// Accumulate the model's output content per step so telemetry can record
|
|
228
248
|
// `gen_ai.output.messages` and reporters can log what was actually said.
|
|
@@ -270,12 +290,14 @@ export function runPrompt<
|
|
|
270
290
|
content: buildStepContent(),
|
|
271
291
|
});
|
|
272
292
|
resetStepContent();
|
|
273
|
-
|
|
293
|
+
const step = result.finishStep({
|
|
274
294
|
finishReason: input.finishReason,
|
|
275
295
|
usage: input.usage,
|
|
276
296
|
providerMetadata: input.providerMetadata,
|
|
277
297
|
warnings: [],
|
|
278
298
|
});
|
|
299
|
+
completedSteps.push(step);
|
|
300
|
+
return step;
|
|
279
301
|
};
|
|
280
302
|
const finishForHostInputPause = async (options: {
|
|
281
303
|
completeCurrentStep: boolean;
|
|
@@ -413,9 +435,16 @@ export function runPrompt<
|
|
|
413
435
|
input: approval.input,
|
|
414
436
|
} satisfies Extract<HarnessV1StreamPart, { type: 'tool-call' }>);
|
|
415
437
|
|
|
438
|
+
telemetry.start(input.session.modelId);
|
|
439
|
+
await telemetry.toolStart({
|
|
440
|
+
toolCallId: rawToolCall.toolCallId,
|
|
441
|
+
toolName: rawToolCall.toolName,
|
|
442
|
+
input: rawToolCall.input,
|
|
443
|
+
});
|
|
416
444
|
const execution = await maybeExecuteHostTool({
|
|
417
445
|
event: rawToolCall,
|
|
418
446
|
tools: activeTools,
|
|
447
|
+
wrappedExecuteTool: telemetry.executeTool,
|
|
419
448
|
sandboxSession: input.sandboxSession,
|
|
420
449
|
abortSignal: input.abortSignal,
|
|
421
450
|
control,
|
|
@@ -478,9 +507,35 @@ export function runPrompt<
|
|
|
478
507
|
|
|
479
508
|
while (true) {
|
|
480
509
|
const { value, done } = await reader.read();
|
|
481
|
-
if (done)
|
|
510
|
+
if (done) {
|
|
511
|
+
releasePendingStopBoundary();
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
482
514
|
if (value == null) continue;
|
|
483
515
|
|
|
516
|
+
if (pendingStopBoundary != null) {
|
|
517
|
+
if (value.type === 'finish') {
|
|
518
|
+
releasePendingStopBoundary();
|
|
519
|
+
} else if (
|
|
520
|
+
(
|
|
521
|
+
await Promise.all(
|
|
522
|
+
input.stopConditions!.map(condition =>
|
|
523
|
+
condition({ steps: completedSteps }),
|
|
524
|
+
),
|
|
525
|
+
)
|
|
526
|
+
).some(Boolean)
|
|
527
|
+
) {
|
|
528
|
+
await input.onStopConditionMet?.();
|
|
529
|
+
const { finishReason, usage } = pendingStopBoundary;
|
|
530
|
+
releasePendingStopBoundary();
|
|
531
|
+
telemetry.end({ finishReason, usage });
|
|
532
|
+
await result.finish();
|
|
533
|
+
return;
|
|
534
|
+
} else {
|
|
535
|
+
releasePendingStopBoundary();
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
484
539
|
// Begin the operation span on stream-start, using the runtime-resolved
|
|
485
540
|
// model the adapter reports (falling back to the session's model).
|
|
486
541
|
if (value.type === 'stream-start') {
|
|
@@ -609,7 +664,7 @@ export function runPrompt<
|
|
|
609
664
|
toolName: value.toolName,
|
|
610
665
|
input: value.input,
|
|
611
666
|
});
|
|
612
|
-
telemetry.toolStart({
|
|
667
|
+
await telemetry.toolStart({
|
|
613
668
|
toolCallId: value.toolCallId,
|
|
614
669
|
toolName: value.toolName,
|
|
615
670
|
input: value.input,
|
|
@@ -686,6 +741,13 @@ export function runPrompt<
|
|
|
686
741
|
usage: value.usage,
|
|
687
742
|
providerMetadata: value.harnessMetadata,
|
|
688
743
|
});
|
|
744
|
+
if (input.stopConditions != null && input.stopConditions.length > 0) {
|
|
745
|
+
pendingStopBoundary = {
|
|
746
|
+
finishReason: value.finishReason,
|
|
747
|
+
usage: value.usage,
|
|
748
|
+
releaseCheckpoint: pinSandboxChannelEventCheckpoint(value),
|
|
749
|
+
};
|
|
750
|
+
}
|
|
689
751
|
}
|
|
690
752
|
|
|
691
753
|
if (value.type === 'finish') {
|
|
@@ -803,6 +865,7 @@ export function runPrompt<
|
|
|
803
865
|
const execution = await maybeExecuteHostTool({
|
|
804
866
|
event: toolCall,
|
|
805
867
|
tools: activeTools,
|
|
868
|
+
wrappedExecuteTool: telemetry.executeTool,
|
|
806
869
|
sandboxSession: input.sandboxSession,
|
|
807
870
|
abortSignal: input.abortSignal,
|
|
808
871
|
control,
|
|
@@ -869,6 +932,7 @@ export function runPrompt<
|
|
|
869
932
|
});
|
|
870
933
|
settleFailure(err);
|
|
871
934
|
} finally {
|
|
935
|
+
releasePendingStopBoundary();
|
|
872
936
|
reader.releaseLock();
|
|
873
937
|
}
|
|
874
938
|
})();
|
|
@@ -921,6 +985,7 @@ function hasTool(input: { tools: ToolSet; toolName: string }): boolean {
|
|
|
921
985
|
async function maybeExecuteHostTool<TOOLS extends ToolSet>(input: {
|
|
922
986
|
event: { toolCallId: string; toolName: string; input: string };
|
|
923
987
|
tools: TOOLS;
|
|
988
|
+
wrappedExecuteTool: TurnTelemetry['executeTool'];
|
|
924
989
|
sandboxSession: SandboxSession;
|
|
925
990
|
abortSignal: AbortSignal | undefined;
|
|
926
991
|
control: HarnessV1PromptControl;
|
|
@@ -949,25 +1014,31 @@ async function maybeExecuteHostTool<TOOLS extends ToolSet>(input: {
|
|
|
949
1014
|
* back to the model — preliminary values are surfaced to the consumer
|
|
950
1015
|
* stream alone, matching how the AI SDK treats `onPreliminaryToolResult`.
|
|
951
1016
|
*/
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1017
|
+
const output = await input.wrappedExecuteTool({
|
|
1018
|
+
toolCallId: input.event.toolCallId,
|
|
1019
|
+
execute: async () => {
|
|
1020
|
+
let output: unknown;
|
|
1021
|
+
const stream = executeTool({
|
|
1022
|
+
tool,
|
|
1023
|
+
input: args as never,
|
|
1024
|
+
options: {
|
|
1025
|
+
toolCallId: input.event.toolCallId,
|
|
1026
|
+
messages: [],
|
|
1027
|
+
abortSignal: input.abortSignal,
|
|
1028
|
+
context: undefined as never,
|
|
1029
|
+
experimental_sandbox: input.sandboxSession,
|
|
1030
|
+
},
|
|
1031
|
+
});
|
|
1032
|
+
for await (const part of stream) {
|
|
1033
|
+
if (part.type === 'preliminary') {
|
|
1034
|
+
input.onPreliminaryResult(part.output);
|
|
1035
|
+
} else {
|
|
1036
|
+
output = part.output;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
return output;
|
|
962
1040
|
},
|
|
963
1041
|
});
|
|
964
|
-
for await (const part of stream) {
|
|
965
|
-
if (part.type === 'preliminary') {
|
|
966
|
-
input.onPreliminaryResult(part.output);
|
|
967
|
-
} else {
|
|
968
|
-
output = part.output;
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
1042
|
|
|
972
1043
|
await input.control.submitToolResult({
|
|
973
1044
|
toolCallId: input.event.toolCallId,
|
|
@@ -58,7 +58,12 @@ export interface TurnTelemetry {
|
|
|
58
58
|
toolCallId: string;
|
|
59
59
|
toolName: string;
|
|
60
60
|
input: unknown;
|
|
61
|
-
}): void
|
|
61
|
+
}): void | Promise<void>;
|
|
62
|
+
/** Execute a host tool through each telemetry integration's context wrapper. */
|
|
63
|
+
executeTool<T>(input: {
|
|
64
|
+
toolCallId: string;
|
|
65
|
+
execute: () => PromiseLike<T>;
|
|
66
|
+
}): Promise<T>;
|
|
62
67
|
/**
|
|
63
68
|
* A tool execution completed (on its `tool-result` or after host execution).
|
|
64
69
|
* Idempotent per `toolCallId` — the first caller wins, so provider-executed
|
|
@@ -78,7 +83,10 @@ const NOOP: TurnTelemetry = {
|
|
|
78
83
|
start() {},
|
|
79
84
|
ensureStepOpen() {},
|
|
80
85
|
stepFinish() {},
|
|
81
|
-
toolStart() {},
|
|
86
|
+
async toolStart() {},
|
|
87
|
+
async executeTool({ execute }) {
|
|
88
|
+
return await execute();
|
|
89
|
+
},
|
|
82
90
|
toolEnd() {},
|
|
83
91
|
end() {},
|
|
84
92
|
error() {},
|
|
@@ -258,10 +266,11 @@ export function createTurnTelemetry(opts: {
|
|
|
258
266
|
stepNumber += 1;
|
|
259
267
|
},
|
|
260
268
|
|
|
261
|
-
toolStart(call) {
|
|
269
|
+
async toolStart(call) {
|
|
262
270
|
ensureStepOpen();
|
|
271
|
+
if (openTools.has(call.toolCallId)) return;
|
|
263
272
|
openTools.set(call.toolCallId, call);
|
|
264
|
-
dispatcher.onToolExecutionStart?.(
|
|
273
|
+
await dispatcher.onToolExecutionStart?.(
|
|
265
274
|
cast<'onToolExecutionStart'>({
|
|
266
275
|
callId,
|
|
267
276
|
messages: [],
|
|
@@ -277,6 +286,11 @@ export function createTurnTelemetry(opts: {
|
|
|
277
286
|
);
|
|
278
287
|
},
|
|
279
288
|
|
|
289
|
+
async executeTool({ toolCallId, execute }) {
|
|
290
|
+
if (dispatcher.executeTool == null) return await execute();
|
|
291
|
+
return await dispatcher.executeTool({ callId, toolCallId, execute });
|
|
292
|
+
},
|
|
293
|
+
|
|
280
294
|
toolEnd(toolCallId, output) {
|
|
281
295
|
const call = openTools.get(toolCallId);
|
|
282
296
|
if (call == null) return;
|
package/src/bridge/index.ts
CHANGED
|
@@ -234,6 +234,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
|
|
|
234
234
|
let currentBoundPort = 0;
|
|
235
235
|
let currentTurnState: BridgeState = 'init';
|
|
236
236
|
let activeSocket: WebSocket | undefined;
|
|
237
|
+
let activeSocketReadyForLiveEvents = false;
|
|
237
238
|
let isFirstTurn = true;
|
|
238
239
|
let turnAbort: AbortController | undefined;
|
|
239
240
|
let currentUserMessages: string[] | undefined;
|
|
@@ -389,7 +390,10 @@ export async function runBridge<TStart extends { type: 'start' }>(
|
|
|
389
390
|
eventLog.push({ seq, line });
|
|
390
391
|
diskBuffer += `${line}\n`;
|
|
391
392
|
scheduleEventFlush();
|
|
392
|
-
if (
|
|
393
|
+
if (
|
|
394
|
+
activeSocketReadyForLiveEvents &&
|
|
395
|
+
activeSocket?.readyState === WS_OPEN
|
|
396
|
+
) {
|
|
393
397
|
try {
|
|
394
398
|
activeSocket.send(line);
|
|
395
399
|
} catch {
|
|
@@ -522,6 +526,8 @@ export async function runBridge<TStart extends { type: 'start' }>(
|
|
|
522
526
|
): Promise<void> => {
|
|
523
527
|
switch (msg.type) {
|
|
524
528
|
case 'start': {
|
|
529
|
+
if (activeSocket !== ws) return;
|
|
530
|
+
activeSocketReadyForLiveEvents = true;
|
|
525
531
|
const firstTurn = isFirstTurn;
|
|
526
532
|
isFirstTurn = false;
|
|
527
533
|
eventLog = []; // clear previous turn; keep seqCounter monotonic
|
|
@@ -643,7 +649,9 @@ export async function runBridge<TStart extends { type: 'start' }>(
|
|
|
643
649
|
}
|
|
644
650
|
return;
|
|
645
651
|
case 'resume':
|
|
652
|
+
if (activeSocket !== ws) return;
|
|
646
653
|
replay(ws, msg.lastSeenEventId);
|
|
654
|
+
activeSocketReadyForLiveEvents = true;
|
|
647
655
|
return;
|
|
648
656
|
case 'shutdown':
|
|
649
657
|
currentTurnState = 'done';
|
|
@@ -721,6 +729,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
|
|
|
721
729
|
// (the host reconnecting after a drop). The previous socket's close is a
|
|
722
730
|
// no-op below because it is no longer `activeSocket`.
|
|
723
731
|
activeSocket = ws;
|
|
732
|
+
activeSocketReadyForLiveEvents = false;
|
|
724
733
|
|
|
725
734
|
// Announce liveness the instant we accept. Some sandbox runtimes complete
|
|
726
735
|
// the host-side WS handshake before the connection is forwarded here; the
|
|
@@ -754,6 +763,7 @@ export async function runBridge<TStart extends { type: 'start' }>(
|
|
|
754
763
|
// log for replay when the host reconnects.
|
|
755
764
|
if (activeSocket === ws) {
|
|
756
765
|
activeSocket = undefined;
|
|
766
|
+
activeSocketReadyForLiveEvents = false;
|
|
757
767
|
}
|
|
758
768
|
});
|
|
759
769
|
|
|
@@ -75,6 +75,30 @@ type Listener<TOut extends { type: string }, T extends EventTypeOf<TOut>> = (
|
|
|
75
75
|
event: Extract<TOut, { type: T }>,
|
|
76
76
|
) => void;
|
|
77
77
|
|
|
78
|
+
/*
|
|
79
|
+
* The agent and utilities entrypoints bundle this module separately. A global
|
|
80
|
+
* symbol lets the agent recognize metadata attached by the channel's bundle
|
|
81
|
+
* copy, while the non-enumerable property leaves protocol payloads unchanged.
|
|
82
|
+
*/
|
|
83
|
+
const sandboxChannelEventCheckpointSymbol = Symbol.for(
|
|
84
|
+
'vercel.ai.harness.sandboxChannelEventCheckpoint',
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
type SandboxChannelEventCheckpoint = {
|
|
88
|
+
pin: () => () => void;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export function pinSandboxChannelEventCheckpoint(
|
|
92
|
+
event: unknown,
|
|
93
|
+
): (() => void) | undefined {
|
|
94
|
+
if (event == null || typeof event !== 'object') return undefined;
|
|
95
|
+
return (
|
|
96
|
+
event as {
|
|
97
|
+
[sandboxChannelEventCheckpointSymbol]?: SandboxChannelEventCheckpoint;
|
|
98
|
+
}
|
|
99
|
+
)[sandboxChannelEventCheckpointSymbol]?.pin();
|
|
100
|
+
}
|
|
101
|
+
|
|
78
102
|
const sleep = (ms: number): Promise<void> =>
|
|
79
103
|
new Promise(resolve => {
|
|
80
104
|
const t = setTimeout(resolve, ms);
|
|
@@ -136,6 +160,9 @@ export class SandboxChannel<
|
|
|
136
160
|
* replayed to the next process on `resume`.
|
|
137
161
|
*/
|
|
138
162
|
private suspended = false;
|
|
163
|
+
private pinnedSuspensionCursor:
|
|
164
|
+
| { eventId: number; token: object }
|
|
165
|
+
| undefined;
|
|
139
166
|
/** Channel is fully torn down; `send` throws and `onClose` has fired. */
|
|
140
167
|
private terminal = false;
|
|
141
168
|
private _lastSeenEventId = 0;
|
|
@@ -251,6 +278,9 @@ export class SandboxChannel<
|
|
|
251
278
|
}
|
|
252
279
|
|
|
253
280
|
interrupt(options?: { timeoutMs?: number }): Promise<void> {
|
|
281
|
+
if (this.pinnedSuspensionCursor != null) {
|
|
282
|
+
return Promise.resolve();
|
|
283
|
+
}
|
|
254
284
|
const timeoutMs = options?.timeoutMs ?? 5000;
|
|
255
285
|
return new Promise<void>((resolve, reject) => {
|
|
256
286
|
let settled = false;
|
|
@@ -313,19 +343,24 @@ export class SandboxChannel<
|
|
|
313
343
|
* aborts it) and accumulates events past the cursor for the next process to
|
|
314
344
|
* `resume`. Unlike {@link close}, the consumer's active turn is wound down
|
|
315
345
|
* cleanly — adapters distinguish a suspend from an unexpected drop via the
|
|
316
|
-
* `'suspended'` close reason and resolve `done` successfully.
|
|
346
|
+
* `'suspended'` close reason and resolve `done` successfully. When an event
|
|
347
|
+
* checkpoint is pinned, the returned cursor points to that event so any
|
|
348
|
+
* already-dispatched tail is replayed by the next process.
|
|
317
349
|
*/
|
|
318
350
|
suspend(): Promise<number> {
|
|
319
351
|
return new Promise<number>(resolve => {
|
|
352
|
+
const pinnedSuspensionCursor = this.pinnedSuspensionCursor?.eventId;
|
|
320
353
|
if (this.terminal) {
|
|
321
|
-
resolve(this._lastSeenEventId);
|
|
354
|
+
resolve(pinnedSuspensionCursor ?? this._lastSeenEventId);
|
|
322
355
|
return;
|
|
323
356
|
}
|
|
324
357
|
// Stop counting/dispatching further inbound frames immediately, and
|
|
325
358
|
// suppress reconnect so the socket close finalises.
|
|
326
359
|
this.suspended = true;
|
|
327
360
|
this.closing = true;
|
|
328
|
-
this.onClose(() =>
|
|
361
|
+
this.onClose(() =>
|
|
362
|
+
resolve(pinnedSuspensionCursor ?? this._lastSeenEventId),
|
|
363
|
+
);
|
|
329
364
|
// Queue the close behind any already-dispatched frames so everything
|
|
330
365
|
// delivered to the consumer is reflected in the final cursor.
|
|
331
366
|
this.enqueue(() => {
|
|
@@ -465,6 +500,9 @@ export class SandboxChannel<
|
|
|
465
500
|
schema: this.outboundSchema,
|
|
466
501
|
});
|
|
467
502
|
if (validated.success) {
|
|
503
|
+
if (seq !== undefined) {
|
|
504
|
+
this.attachEventCheckpoint({ event: validated.value, eventId: seq });
|
|
505
|
+
}
|
|
468
506
|
this.dispatch(validated.value);
|
|
469
507
|
} else {
|
|
470
508
|
this.dispatch({
|
|
@@ -506,6 +544,26 @@ export class SandboxChannel<
|
|
|
506
544
|
}
|
|
507
545
|
}
|
|
508
546
|
|
|
547
|
+
private attachEventCheckpoint(options: {
|
|
548
|
+
event: TOut;
|
|
549
|
+
eventId: number;
|
|
550
|
+
}): void {
|
|
551
|
+
if (!Object.isExtensible(options.event)) return;
|
|
552
|
+
Object.defineProperty(options.event, sandboxChannelEventCheckpointSymbol, {
|
|
553
|
+
value: {
|
|
554
|
+
pin: () => {
|
|
555
|
+
const token = {};
|
|
556
|
+
this.pinnedSuspensionCursor = { eventId: options.eventId, token };
|
|
557
|
+
return () => {
|
|
558
|
+
if (this.pinnedSuspensionCursor?.token === token) {
|
|
559
|
+
this.pinnedSuspensionCursor = undefined;
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
},
|
|
563
|
+
} satisfies SandboxChannelEventCheckpoint,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
509
567
|
private finalizeClose(code: number, reason: string): void {
|
|
510
568
|
if (this.terminal) return;
|
|
511
569
|
this.terminal = true;
|