@ai-sdk/harness 0.0.0 → 1.0.0-beta.14

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/LICENSE +13 -0
  3. package/README.md +142 -0
  4. package/agent/index.ts +47 -0
  5. package/bridge/index.ts +10 -0
  6. package/dist/agent/index.d.ts +1521 -0
  7. package/dist/agent/index.js +2958 -0
  8. package/dist/agent/index.js.map +1 -0
  9. package/dist/bridge/index.d.ts +111 -0
  10. package/dist/bridge/index.js +415 -0
  11. package/dist/bridge/index.js.map +1 -0
  12. package/dist/index.d.ts +1536 -0
  13. package/dist/index.js +15834 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/utils/index.d.ts +225 -0
  16. package/dist/utils/index.js +12148 -0
  17. package/dist/utils/index.js.map +1 -0
  18. package/package.json +99 -1
  19. package/src/agent/harness-agent-session.ts +509 -0
  20. package/src/agent/harness-agent-settings.ts +131 -0
  21. package/src/agent/harness-agent-tool-approval-continuation.ts +94 -0
  22. package/src/agent/harness-agent-types.ts +50 -0
  23. package/src/agent/harness-agent.ts +819 -0
  24. package/src/agent/internal/bootstrap-recipe.ts +124 -0
  25. package/src/agent/internal/bridge-port-registry.ts +52 -0
  26. package/src/agent/internal/harness-stream-text-result.ts +720 -0
  27. package/src/agent/internal/lifecycle-state-validation.ts +95 -0
  28. package/src/agent/internal/permission-mode.ts +50 -0
  29. package/src/agent/internal/resolve-observability.ts +128 -0
  30. package/src/agent/internal/run-prompt.ts +813 -0
  31. package/src/agent/internal/strip-work-dir.ts +68 -0
  32. package/src/agent/internal/to-harness-stream.ts +75 -0
  33. package/src/agent/internal/translate-stream-part.ts +221 -0
  34. package/src/agent/internal/turn-telemetry.ts +359 -0
  35. package/src/agent/observability/file-reporter.ts +206 -0
  36. package/src/agent/observability/index.ts +15 -0
  37. package/src/agent/observability/trace-tree-reporter.ts +122 -0
  38. package/src/agent/observability/types.ts +86 -0
  39. package/src/agent/prewarm.ts +47 -0
  40. package/src/bridge/index.ts +702 -0
  41. package/src/errors/harness-capability-unsupported-error.ts +41 -0
  42. package/src/errors/harness-error.ts +22 -0
  43. package/src/index.ts +3 -0
  44. package/src/utils/bridge-ready.ts +277 -0
  45. package/src/utils/classify-disk-log.ts +43 -0
  46. package/src/utils/index.ts +15 -0
  47. package/src/utils/sandbox-channel.ts +453 -0
  48. package/src/v1/harness-v1-bootstrap.ts +46 -0
  49. package/src/v1/harness-v1-bridge-protocol.ts +310 -0
  50. package/src/v1/harness-v1-builtin-tool.ts +138 -0
  51. package/src/v1/harness-v1-call-warning.ts +22 -0
  52. package/src/v1/harness-v1-diagnostic.ts +66 -0
  53. package/src/v1/harness-v1-lifecycle-state.ts +65 -0
  54. package/src/v1/harness-v1-metadata.ts +13 -0
  55. package/src/v1/harness-v1-network-sandbox-session.ts +123 -0
  56. package/src/v1/harness-v1-observability.ts +20 -0
  57. package/src/v1/harness-v1-permission-mode.ts +11 -0
  58. package/src/v1/harness-v1-prompt-control.ts +41 -0
  59. package/src/v1/harness-v1-prompt.ts +11 -0
  60. package/src/v1/harness-v1-sandbox-provider.ts +76 -0
  61. package/src/v1/harness-v1-session.ts +272 -0
  62. package/src/v1/harness-v1-skill.ts +36 -0
  63. package/src/v1/harness-v1-stream-part.ts +363 -0
  64. package/src/v1/harness-v1-tool-spec.ts +31 -0
  65. package/src/v1/harness-v1.ts +83 -0
  66. package/src/v1/index.ts +93 -0
  67. package/utils/index.ts +1 -0
package/package.json CHANGED
@@ -1 +1,99 @@
1
- {"name":"@ai-sdk/harness","version":"0.0.0"}
1
+ {
2
+ "name": "@ai-sdk/harness",
3
+ "version": "1.0.0-beta.14",
4
+ "type": "module",
5
+ "license": "Apache-2.0",
6
+ "sideEffects": false,
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "source": "./src/index.ts",
10
+ "files": [
11
+ "dist/**/*",
12
+ "src",
13
+ "agent",
14
+ "utils",
15
+ "bridge",
16
+ "!src/**/*.test.ts",
17
+ "!src/**/*.test-d.ts",
18
+ "!src/**/__snapshots__",
19
+ "!src/**/__fixtures__",
20
+ "CHANGELOG.md",
21
+ "README.md"
22
+ ],
23
+ "exports": {
24
+ "./package.json": "./package.json",
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js",
28
+ "default": "./dist/index.js"
29
+ },
30
+ "./agent": {
31
+ "types": "./dist/agent/index.d.ts",
32
+ "import": "./dist/agent/index.js",
33
+ "default": "./dist/agent/index.js"
34
+ },
35
+ "./utils": {
36
+ "types": "./dist/utils/index.d.ts",
37
+ "import": "./dist/utils/index.js",
38
+ "default": "./dist/utils/index.js"
39
+ },
40
+ "./bridge": {
41
+ "types": "./dist/bridge/index.d.ts",
42
+ "import": "./dist/bridge/index.js",
43
+ "default": "./dist/bridge/index.js"
44
+ }
45
+ },
46
+ "dependencies": {
47
+ "@ai-sdk/provider": "4.0.0-beta.19",
48
+ "@ai-sdk/provider-utils": "5.0.0-beta.49",
49
+ "ai": "7.0.0-beta.177"
50
+ },
51
+ "peerDependencies": {
52
+ "ws": "^8.20.1"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "ws": {
56
+ "optional": true
57
+ }
58
+ },
59
+ "devDependencies": {
60
+ "@types/node": "22.19.19",
61
+ "@types/ws": "^8.5.13",
62
+ "tsup": "^8.5.1",
63
+ "typescript": "5.8.3",
64
+ "ws": "^8.20.1",
65
+ "zod": "3.25.76",
66
+ "@vercel/ai-tsconfig": "0.0.0"
67
+ },
68
+ "engines": {
69
+ "node": ">=22"
70
+ },
71
+ "publishConfig": {
72
+ "access": "public",
73
+ "provenance": true
74
+ },
75
+ "homepage": "https://ai-sdk.dev/docs",
76
+ "repository": {
77
+ "type": "git",
78
+ "url": "https://github.com/vercel/ai",
79
+ "directory": "packages/harness"
80
+ },
81
+ "bugs": {
82
+ "url": "https://github.com/vercel/ai/issues"
83
+ },
84
+ "keywords": [
85
+ "ai",
86
+ "harness",
87
+ "agent"
88
+ ],
89
+ "scripts": {
90
+ "build": "pnpm clean && tsup --tsconfig tsconfig.build.json",
91
+ "build:watch": "pnpm clean && tsup --watch",
92
+ "clean": "del-cli dist *.tsbuildinfo",
93
+ "type-check": "tsc --build",
94
+ "test": "pnpm test:node && pnpm test:edge",
95
+ "test:watch": "vitest --config vitest.node.config.js",
96
+ "test:edge": "vitest --config vitest.edge.config.js --run",
97
+ "test:node": "vitest --config vitest.node.config.js --run"
98
+ }
99
+ }
@@ -0,0 +1,509 @@
1
+ import type { Context, ToolSet } from '@ai-sdk/provider-utils';
2
+ import type { StreamTextResult, TelemetryOptions } from 'ai';
3
+ import type { HarnessAgentToolApprovalConfiguration } from './harness-agent-settings';
4
+ import type {
5
+ HarnessV1NetworkSandboxSession,
6
+ HarnessV1SandboxProvider,
7
+ } from '../v1';
8
+ import type {
9
+ HarnessAgentAdapter,
10
+ HarnessAgentAdapterSession,
11
+ HarnessAgentContinueTurnState,
12
+ HarnessAgentPendingToolApproval,
13
+ HarnessAgentPrompt,
14
+ HarnessAgentResumeSessionState,
15
+ HarnessAgentToolSpec,
16
+ } from './harness-agent-types';
17
+ import type { HarnessAgentToolApprovalContinuation } from './harness-agent-tool-approval-continuation';
18
+ import { releaseBridgePort } from './internal/bridge-port-registry';
19
+ import { validateLifecycleStateData } from './internal/lifecycle-state-validation';
20
+ import { runPrompt } from './internal/run-prompt';
21
+
22
+ type HarnessAgentTurnResult<
23
+ TOOLS extends ToolSet,
24
+ RUNTIME_CONTEXT extends Context,
25
+ > = {
26
+ result: StreamTextResult<TOOLS, RUNTIME_CONTEXT, never>;
27
+ done: Promise<void>;
28
+ };
29
+
30
+ type HarnessAgentSessionState = 'active' | 'detached' | 'stopped' | 'destroyed';
31
+
32
+ type HarnessAgentTurnState =
33
+ | 'idle'
34
+ | 'running'
35
+ | 'awaiting-approval'
36
+ | 'suspended';
37
+
38
+ /**
39
+ * Live harness session held by the caller.
40
+ *
41
+ * Created by {@link import('./harness-agent').HarnessAgent.createSession}.
42
+ * Owns the underlying adapter session, the network sandbox session, and the
43
+ * bridge-port lease (when the provider wraps a caller-provided sandbox with a
44
+ * port pool).
45
+ *
46
+ * Pass the instance back to `agent.generate` / `agent.stream` on every
47
+ * call; end the local handle with `detach()`, `stop()`, or `destroy()`.
48
+ *
49
+ * After any lifecycle method has resolved, the session is unusable — any
50
+ * subsequent `generate`/`stream` call against it throws.
51
+ */
52
+ export class HarnessAgentSession {
53
+ /**
54
+ * Stable identifier the harness adapter saw in `doStart`. The same
55
+ * string callers persist when they intend to resume the session in a
56
+ * future process.
57
+ */
58
+ readonly sessionId: string;
59
+
60
+ private readonly harness: HarnessAgentAdapter;
61
+ private readonly sandboxProvider: HarnessV1SandboxProvider;
62
+ private readonly sessionWorkDir: string;
63
+ private underlyingSession: HarnessAgentAdapterSession | undefined;
64
+ private sandboxSession: HarnessV1NetworkSandboxSession | undefined;
65
+ private leasedBridgePort: number | undefined;
66
+ private readonly toolApproval:
67
+ | HarnessAgentToolApprovalConfiguration
68
+ | undefined;
69
+ private readonly pendingToolApprovals = new Map<
70
+ string,
71
+ HarnessAgentPendingToolApproval
72
+ >();
73
+ private sessionState: HarnessAgentSessionState = 'active';
74
+ private turnState: HarnessAgentTurnState;
75
+ private turnSequence = 0;
76
+ private activeTurnSequence = 0;
77
+
78
+ /**
79
+ * Whether this session was created from `resumeFrom` or `continueFrom`.
80
+ * Captured at construction so it survives lifecycle cleanup.
81
+ */
82
+ readonly isResume: boolean;
83
+
84
+ constructor(options: {
85
+ sessionId: string;
86
+ harness: HarnessAgentAdapter;
87
+ underlyingSession: HarnessAgentAdapterSession;
88
+ sandboxSession: HarnessV1NetworkSandboxSession;
89
+ sandboxProvider: HarnessV1SandboxProvider;
90
+ leasedBridgePort?: number;
91
+ sessionWorkDir: string;
92
+ toolApproval: HarnessAgentToolApprovalConfiguration | undefined;
93
+ pendingToolApprovals?: readonly HarnessAgentPendingToolApproval[];
94
+ turnState?: HarnessAgentTurnState;
95
+ }) {
96
+ this.sessionId = options.sessionId;
97
+ this.harness = options.harness;
98
+ this.underlyingSession = options.underlyingSession;
99
+ this.sandboxSession = options.sandboxSession;
100
+ this.sandboxProvider = options.sandboxProvider;
101
+ this.leasedBridgePort = options.leasedBridgePort;
102
+ this.sessionWorkDir = options.sessionWorkDir;
103
+ this.toolApproval = options.toolApproval;
104
+ for (const approval of options.pendingToolApprovals ?? []) {
105
+ this.pendingToolApprovals.set(approval.approvalId, approval);
106
+ }
107
+ this.turnState =
108
+ options.turnState ??
109
+ (this.pendingToolApprovals.size > 0 ? 'awaiting-approval' : 'idle');
110
+ this.isResume = options.underlyingSession.isResume;
111
+ }
112
+
113
+ /**
114
+ * Active network sandbox session.
115
+ *
116
+ * @internal — accessed by session turn and lifecycle drivers.
117
+ */
118
+ getSandboxSession(): HarnessV1NetworkSandboxSession {
119
+ if (this.sessionState !== 'active' || this.sandboxSession == null) {
120
+ throw new Error(
121
+ `Harness session ${this.sessionId} has ended and cannot be reused.`,
122
+ );
123
+ }
124
+ return this.sandboxSession;
125
+ }
126
+
127
+ /**
128
+ * Working directory the agent runs in for this session. Used to strip the
129
+ * prefix from absolute paths in stream events before they reach consumers.
130
+ *
131
+ * @internal — accessed by session turn drivers.
132
+ */
133
+ getSessionWorkDir(): string {
134
+ return this.sessionWorkDir;
135
+ }
136
+
137
+ promptTurn<TOOLS extends ToolSet, RUNTIME_CONTEXT extends Context>(options: {
138
+ prompt: HarnessAgentPrompt;
139
+ instructions: string | undefined;
140
+ tools: TOOLS;
141
+ toolSpecs: HarnessAgentToolSpec[];
142
+ runtimeContext: RUNTIME_CONTEXT;
143
+ abortSignal: AbortSignal | undefined;
144
+ telemetry: TelemetryOptions | undefined;
145
+ }): HarnessAgentTurnResult<TOOLS, RUNTIME_CONTEXT> {
146
+ const session = this.requireReusableSession();
147
+ this.requirePromptableTurn();
148
+ const sandboxSession = this.getSandboxSession();
149
+ const turnId = this.startTrackedTurn();
150
+ try {
151
+ const turn = runPrompt<TOOLS, RUNTIME_CONTEXT>({
152
+ harness: this.harness,
153
+ session,
154
+ prompt: options.prompt,
155
+ instructions: options.instructions,
156
+ tools: options.tools,
157
+ toolSpecs: options.toolSpecs,
158
+ sandboxSession: sandboxSession.restricted(),
159
+ sessionWorkDir: this.sessionWorkDir,
160
+ runtimeContext: options.runtimeContext,
161
+ abortSignal: options.abortSignal,
162
+ telemetry: options.telemetry,
163
+ toolApproval: this.toolApproval,
164
+ pendingToolApprovals: this.getPendingToolApprovals(),
165
+ onPendingToolApproval: approval => {
166
+ this.pendingToolApprovals.set(approval.approvalId, approval);
167
+ this.markAwaitingApprovalIfActive();
168
+ },
169
+ onToolApprovalSettled: approvalId => {
170
+ this.pendingToolApprovals.delete(approvalId);
171
+ },
172
+ onTurnFinished: () => {
173
+ this.finishTrackedTurn({ turnId });
174
+ },
175
+ });
176
+ this.trackTurnCompletion({ done: turn.done, turnId });
177
+ return turn;
178
+ } catch (error) {
179
+ this.finishTrackedTurn({ turnId });
180
+ throw error;
181
+ }
182
+ }
183
+
184
+ continueTurn<
185
+ TOOLS extends ToolSet,
186
+ RUNTIME_CONTEXT extends Context,
187
+ >(options: {
188
+ instructions: string | undefined;
189
+ tools: TOOLS;
190
+ toolSpecs: HarnessAgentToolSpec[];
191
+ runtimeContext: RUNTIME_CONTEXT;
192
+ abortSignal: AbortSignal | undefined;
193
+ telemetry: TelemetryOptions | undefined;
194
+ toolApprovalContinuations?:
195
+ | readonly HarnessAgentToolApprovalContinuation[]
196
+ | undefined;
197
+ }): HarnessAgentTurnResult<TOOLS, RUNTIME_CONTEXT> {
198
+ const session = this.requireReusableSession();
199
+ this.requireContinuableTurn();
200
+ const sandboxSession = this.getSandboxSession();
201
+ const turnId = this.startTrackedTurn();
202
+ try {
203
+ const turn = runPrompt<TOOLS, RUNTIME_CONTEXT>({
204
+ harness: this.harness,
205
+ session,
206
+ mode: 'continue',
207
+ instructions: options.instructions,
208
+ tools: options.tools,
209
+ toolSpecs: options.toolSpecs,
210
+ sandboxSession: sandboxSession.restricted(),
211
+ sessionWorkDir: this.sessionWorkDir,
212
+ runtimeContext: options.runtimeContext,
213
+ abortSignal: options.abortSignal,
214
+ telemetry: options.telemetry,
215
+ toolApproval: this.toolApproval,
216
+ pendingToolApprovals: this.getPendingToolApprovals(),
217
+ toolApprovalContinuations: options.toolApprovalContinuations,
218
+ onPendingToolApproval: approval => {
219
+ this.pendingToolApprovals.set(approval.approvalId, approval);
220
+ this.markAwaitingApprovalIfActive();
221
+ },
222
+ onToolApprovalSettled: approvalId => {
223
+ this.pendingToolApprovals.delete(approvalId);
224
+ },
225
+ onTurnFinished: () => {
226
+ this.finishTrackedTurn({ turnId });
227
+ },
228
+ });
229
+ this.trackTurnCompletion({ done: turn.done, turnId });
230
+ return turn;
231
+ } catch (error) {
232
+ this.finishTrackedTurn({ turnId });
233
+ throw error;
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Ask the underlying runtime to compact its context. The runtime performs
239
+ * the compaction itself; when it completes, a `compaction` part appears on
240
+ * the active (or next) turn's stream. Safe to call between turns for
241
+ * runtimes whose compaction is session-scoped (e.g. Pi).
242
+ *
243
+ * Throws `HarnessCapabilityUnsupportedError` for harnesses that cannot
244
+ * trigger compaction manually (e.g. Codex, which still auto-compacts under
245
+ * the hood). Throws if the session has ended.
246
+ */
247
+ async compact(customInstructions?: string): Promise<void> {
248
+ await this.requireReusableSession().doCompact(customInstructions);
249
+ }
250
+
251
+ /**
252
+ * Park the session, returning a payload the caller can persist and later
253
+ * pass to `agent.createSession({ sessionId, resumeFrom })` to reconnect.
254
+ * The runtime and sandbox keep running; this local session handle becomes
255
+ * unusable.
256
+ */
257
+ async detach(): Promise<HarnessAgentResumeSessionState> {
258
+ if (this.sessionState !== 'active' || this.underlyingSession == null) {
259
+ throw new Error(
260
+ `Harness session ${this.sessionId} is not active and cannot be detached.`,
261
+ );
262
+ }
263
+ const session = this.underlyingSession;
264
+ try {
265
+ if (this.turnState !== 'idle') {
266
+ return this.toResumeStateWithContinuation({
267
+ continueFrom: await this.suspendCurrentTurn({ session }),
268
+ });
269
+ }
270
+ const raw = await session.doDetach();
271
+ const validated = await validateLifecycleStateData({
272
+ harness: this.harness,
273
+ state: raw,
274
+ expectedType: 'resume-session',
275
+ });
276
+ return validated;
277
+ } finally {
278
+ this.endLocalHandle({
279
+ sessionState: 'detached',
280
+ releasePortLease: false,
281
+ });
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Persist enough state to resume later, then stop the runtime and sandbox.
287
+ * Returns the resume state for a future
288
+ * `agent.createSession({ sessionId, resumeFrom })` call.
289
+ */
290
+ async stop(): Promise<HarnessAgentResumeSessionState> {
291
+ if (this.sessionState !== 'active' || this.underlyingSession == null) {
292
+ throw new Error(
293
+ `Harness session ${this.sessionId} is not active and cannot be stopped.`,
294
+ );
295
+ }
296
+ const session = this.underlyingSession;
297
+ const sandboxSession = this.getSandboxSession();
298
+ try {
299
+ if (this.turnState !== 'idle') {
300
+ return this.toResumeStateWithContinuation({
301
+ continueFrom: await this.suspendCurrentTurn({ session }),
302
+ });
303
+ }
304
+ const raw = await session.doStop();
305
+ const validated = await validateLifecycleStateData({
306
+ harness: this.harness,
307
+ state: raw,
308
+ expectedType: 'resume-session',
309
+ });
310
+ return validated;
311
+ } finally {
312
+ this.endLocalHandle({
313
+ sessionState: 'stopped',
314
+ releasePortLease: true,
315
+ });
316
+ await Promise.resolve(sandboxSession.stop()).catch(() => {});
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Stop the runtime and discard resumability. The sandbox is destroyed when
322
+ * the provider supports destruction; otherwise it is stopped.
323
+ */
324
+ async destroy(): Promise<void> {
325
+ if (this.sessionState !== 'active') return;
326
+ const session = this.underlyingSession;
327
+ const sandboxSession = this.getSandboxSession();
328
+ this.endLocalHandle({ sessionState: 'destroyed', releasePortLease: true });
329
+ if (session != null) {
330
+ await Promise.resolve(session.doDestroy()).catch(() => {});
331
+ }
332
+ await Promise.resolve(
333
+ sandboxSession.destroy?.() ?? sandboxSession.stop(),
334
+ ).catch(() => {});
335
+ }
336
+
337
+ /**
338
+ * Gracefully freeze the active turn at the slice boundary and return the
339
+ * continuation payload, **leaving the sandbox/runtime running** so the next
340
+ * process can continue. Resolves once the in-flight `stream()` /
341
+ * `continueStream()` has cleanly wound down at a precise cursor (see
342
+ * `doSuspendTurn`).
343
+ *
344
+ * After this call the session is detached. This in-process handle no
345
+ * longer drives turns; a future slice creates a fresh session from the
346
+ * returned state. The sandbox is **not** stopped and no port lease is
347
+ * released, because bridge-backed adapters may still have a live bridge on
348
+ * that port.
349
+ */
350
+ async suspendTurn(): Promise<HarnessAgentContinueTurnState> {
351
+ if (this.sessionState !== 'active' || this.underlyingSession == null) {
352
+ throw new Error(
353
+ `Harness session ${this.sessionId} is not active and cannot be suspended.`,
354
+ );
355
+ }
356
+ if (this.turnState === 'idle') {
357
+ throw new Error(
358
+ `Harness session ${this.sessionId} has no unfinished turn to suspend.`,
359
+ );
360
+ }
361
+ const session = this.underlyingSession;
362
+ try {
363
+ return await this.suspendCurrentTurn({ session });
364
+ } finally {
365
+ this.endLocalHandle({
366
+ sessionState: 'detached',
367
+ releasePortLease: false,
368
+ });
369
+ }
370
+ }
371
+
372
+ private getPendingToolApprovals(): readonly HarnessAgentPendingToolApproval[] {
373
+ return Array.from(this.pendingToolApprovals.values());
374
+ }
375
+
376
+ private addPendingToolApprovals(
377
+ state: HarnessAgentContinueTurnState,
378
+ ): HarnessAgentContinueTurnState {
379
+ const pendingToolApprovals = this.getPendingToolApprovals();
380
+ if (pendingToolApprovals.length === 0) {
381
+ return {
382
+ type: state.type,
383
+ harnessId: state.harnessId,
384
+ specificationVersion: state.specificationVersion,
385
+ data: state.data,
386
+ };
387
+ }
388
+ return {
389
+ ...state,
390
+ pendingToolApprovals,
391
+ };
392
+ }
393
+
394
+ private async suspendCurrentTurn(options: {
395
+ session: HarnessAgentAdapterSession;
396
+ }): Promise<HarnessAgentContinueTurnState> {
397
+ const raw = await options.session.doSuspendTurn();
398
+ const validated = await validateLifecycleStateData({
399
+ harness: this.harness,
400
+ state: raw,
401
+ expectedType: 'continue-turn',
402
+ });
403
+ this.turnState = 'suspended';
404
+ return this.addPendingToolApprovals(validated);
405
+ }
406
+
407
+ private toResumeStateWithContinuation(options: {
408
+ continueFrom: HarnessAgentContinueTurnState;
409
+ }): HarnessAgentResumeSessionState {
410
+ const { continueFrom } = options;
411
+ return {
412
+ type: 'resume-session',
413
+ harnessId: continueFrom.harnessId,
414
+ specificationVersion: continueFrom.specificationVersion,
415
+ data: continueFrom.data,
416
+ continueFrom,
417
+ };
418
+ }
419
+
420
+ private requirePromptableTurn(): void {
421
+ if (this.turnState === 'idle') return;
422
+ if (this.turnState === 'running') {
423
+ throw new Error(
424
+ `Harness session ${this.sessionId} already has a turn in progress.`,
425
+ );
426
+ }
427
+ throw new Error(
428
+ `Harness session ${this.sessionId} has an unfinished turn and must be continued before accepting a new prompt.`,
429
+ );
430
+ }
431
+
432
+ private requireContinuableTurn(): void {
433
+ if (
434
+ this.turnState === 'awaiting-approval' ||
435
+ this.turnState === 'suspended'
436
+ ) {
437
+ return;
438
+ }
439
+ if (this.turnState === 'running') {
440
+ throw new Error(
441
+ `Harness session ${this.sessionId} already has a turn in progress.`,
442
+ );
443
+ }
444
+ throw new Error(
445
+ `Harness session ${this.sessionId} has no unfinished turn to continue.`,
446
+ );
447
+ }
448
+
449
+ private markAwaitingApprovalIfActive(): void {
450
+ if (this.sessionState === 'active') {
451
+ this.turnState = 'awaiting-approval';
452
+ }
453
+ }
454
+
455
+ private startTrackedTurn(): number {
456
+ const turnId = ++this.turnSequence;
457
+ this.activeTurnSequence = turnId;
458
+ this.turnState = 'running';
459
+ return turnId;
460
+ }
461
+
462
+ private trackTurnCompletion(options: {
463
+ done: Promise<void>;
464
+ turnId: number;
465
+ }): void {
466
+ void Promise.resolve(options.done)
467
+ .finally(() => {
468
+ this.finishTrackedTurn({ turnId: options.turnId });
469
+ })
470
+ .catch(() => {});
471
+ }
472
+
473
+ private finishTrackedTurn(options: { turnId: number }): void {
474
+ if (this.sessionState !== 'active') return;
475
+ if (this.activeTurnSequence !== options.turnId) return;
476
+ this.turnState =
477
+ this.pendingToolApprovals.size > 0 ? 'awaiting-approval' : 'idle';
478
+ }
479
+
480
+ private endLocalHandle(options: {
481
+ sessionState: Exclude<HarnessAgentSessionState, 'active'>;
482
+ releasePortLease: boolean;
483
+ }): void {
484
+ this.sessionState = options.sessionState;
485
+ this.underlyingSession = undefined;
486
+ this.sandboxSession = undefined;
487
+ if (options.releasePortLease) {
488
+ this.releasePortLease();
489
+ }
490
+ }
491
+
492
+ private releasePortLease(): void {
493
+ if (this.leasedBridgePort == null) return;
494
+ releaseBridgePort({
495
+ poolKey: this.sandboxProvider,
496
+ sessionId: this.sessionId,
497
+ });
498
+ this.leasedBridgePort = undefined;
499
+ }
500
+
501
+ private requireReusableSession(): HarnessAgentAdapterSession {
502
+ if (this.sessionState !== 'active' || this.underlyingSession == null) {
503
+ throw new Error(
504
+ `Harness session ${this.sessionId} has ended and cannot be reused.`,
505
+ );
506
+ }
507
+ return this.underlyingSession;
508
+ }
509
+ }