@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
@@ -0,0 +1,819 @@
1
+ import { HarnessCapabilityUnsupportedError } from '../errors/harness-capability-unsupported-error';
2
+ import type {
3
+ HarnessV1Bootstrap,
4
+ HarnessV1NetworkSandboxSession,
5
+ HarnessV1SandboxProvider,
6
+ } from '../v1';
7
+ import {
8
+ asSchema,
9
+ generateId,
10
+ type Context,
11
+ type ModelMessage,
12
+ type ToolSet,
13
+ } from '@ai-sdk/provider-utils';
14
+ import type {
15
+ Agent,
16
+ AgentCallParameters,
17
+ AgentStreamParameters,
18
+ GenerateTextResult,
19
+ ReasoningFileOutput,
20
+ ReasoningOutput,
21
+ StreamTextResult,
22
+ } from 'ai';
23
+ import type { HarnessAgentSettings } from './harness-agent-settings';
24
+ import { HarnessAgentSession } from './harness-agent-session';
25
+ import type {
26
+ HarnessAgentAdapter,
27
+ HarnessAgentContinueTurnState,
28
+ HarnessAgentPermissionMode,
29
+ HarnessAgentPrompt,
30
+ HarnessAgentResumeSessionState,
31
+ HarnessAgentToolSpec,
32
+ } from './harness-agent-types';
33
+ import {
34
+ collectHarnessAgentToolApprovalContinuations,
35
+ type HarnessAgentToolApprovalContinuation,
36
+ } from './harness-agent-tool-approval-continuation';
37
+ import {
38
+ applyBootstrapRecipe,
39
+ hashBootstrap,
40
+ } from './internal/bootstrap-recipe';
41
+ import {
42
+ acquireBridgePort,
43
+ releaseBridgePort,
44
+ } from './internal/bridge-port-registry';
45
+ import { buildObservability } from './internal/resolve-observability';
46
+ import { validateLifecycleStateData } from './internal/lifecycle-state-validation';
47
+ import {
48
+ permissionModeNeedsBuiltinSupport,
49
+ resolvePermissionMode,
50
+ } from './internal/permission-mode';
51
+
52
+ /** Extract the builtin tool set type from a harness adapter parameter. */
53
+ type BuiltinToolsOf<H> = H extends HarnessAgentAdapter<infer T> ? T : never;
54
+
55
+ /**
56
+ * Type-level merge of a harness's builtin tools with user-defined tools.
57
+ * User tools override builtins on key collision.
58
+ */
59
+ export type HarnessAllTools<
60
+ THarness extends HarnessAgentAdapter<any>,
61
+ TUserTools extends ToolSet,
62
+ > = Omit<BuiltinToolsOf<THarness>, keyof TUserTools> & TUserTools;
63
+
64
+ /**
65
+ * Required `session` extension on every `HarnessAgent.generate` /
66
+ * `HarnessAgent.stream` call. The agent operates exclusively on the
67
+ * `HarnessAgentSession` the caller passes in — it owns no session
68
+ * state of its own.
69
+ */
70
+ export interface HarnessAgentCallExtensions {
71
+ /**
72
+ * Active session returned by `agent.createSession(...)`. Drives the
73
+ * underlying harness adapter for this turn.
74
+ */
75
+ session: HarnessAgentSession;
76
+ }
77
+
78
+ /**
79
+ * AI SDK `Agent` implementation that drives a third-party agent runtime
80
+ * through a harness adapter (Claude Code, Codex, …).
81
+ *
82
+ * Behaviour summary:
83
+ * - **Stateless definition.** Construct once at module scope. The agent
84
+ * holds the harness adapter, the merged tool surface, the sandbox
85
+ * provider and other config — never a live session. Per-call data
86
+ * (prompt, abort signal, the `HarnessAgentSession`) lives on
87
+ * `generate()` / `stream()`.
88
+ * - **Explicit sessions.** Callers spawn sessions with
89
+ * `agent.createSession(...)`, pass the returned
90
+ * `HarnessAgentSession` on every `generate` / `stream`, and end it via
91
+ * `session.detach()`, `session.stop()`, or `session.destroy()`.
92
+ * - **Cross-process resume.** `createSession({ sessionId, resumeFrom })`
93
+ * resumes from state previously returned by `session.detach()` or
94
+ * `session.stop()`. The framework validates `resumeFrom` against the
95
+ * harness's `lifecycleStateSchema` before handing it to the adapter.
96
+ * `createSession({ sessionId, continueFrom })` resumes from state returned
97
+ * by `session.suspendTurn()` before `continueStream()` /
98
+ * `continueGenerate()`.
99
+ * - **Host tool execution.** User tools passed in `settings.tools` are
100
+ * executed on the host whenever the underlying runtime calls them;
101
+ * the result is fed back to the harness via `submitToolResult`.
102
+ * Adapter builtin tools (e.g. Claude Code's `Bash`) pass through
103
+ * untouched.
104
+ * - **Sandbox propagation.** `settings.sandbox` is a sandbox provider.
105
+ * On `createSession`, the agent calls `provider.createSession()` (or
106
+ * `resumeSession()`) and passes the resulting network sandbox session into
107
+ * `doStart`. Its `restricted()` view (a tool-safe
108
+ * `Experimental_SandboxSession`) is handed to user-tool `execute()` calls
109
+ * via `experimental_sandbox`.
110
+ */
111
+ export class HarnessAgent<
112
+ THarness extends HarnessAgentAdapter<any> = HarnessAgentAdapter,
113
+ TUserTools extends ToolSet = {},
114
+ RUNTIME_CONTEXT extends Context = Context,
115
+ > implements Agent<
116
+ never,
117
+ HarnessAllTools<THarness, TUserTools>,
118
+ RUNTIME_CONTEXT,
119
+ never
120
+ > {
121
+ readonly version = 'agent-v1' as const;
122
+ readonly id: string | undefined;
123
+
124
+ /**
125
+ * Merged tool set exposed to AI SDK consumers: harness builtins +
126
+ * user-defined tools, with user tools overriding builtins on key
127
+ * collision. Built once at construction time so the typed surface is
128
+ * stable across calls.
129
+ */
130
+ readonly tools: HarnessAllTools<THarness, TUserTools>;
131
+
132
+ private readonly settings: HarnessAgentSettings<THarness, TUserTools>;
133
+ private readonly userTools: TUserTools;
134
+ private readonly permissionMode: HarnessAgentPermissionMode;
135
+
136
+ constructor(settings: HarnessAgentSettings<THarness, TUserTools>) {
137
+ this.settings = settings;
138
+ this.id = settings.id;
139
+ this.userTools = settings.tools ?? ({} as TUserTools);
140
+ this.permissionMode = resolvePermissionMode({
141
+ permissionMode: settings.permissionMode,
142
+ });
143
+ if (
144
+ Object.keys(settings.harness.builtinTools).length > 0 &&
145
+ permissionModeNeedsBuiltinSupport({
146
+ permissionMode: this.permissionMode,
147
+ }) &&
148
+ settings.harness.supportsBuiltinToolApprovals !== true
149
+ ) {
150
+ throw new HarnessCapabilityUnsupportedError({
151
+ message: `Harness '${settings.harness.harnessId}' does not support built-in tool approval requests; use permissionMode: 'allow-all'.`,
152
+ harnessId: settings.harness.harnessId,
153
+ });
154
+ }
155
+ this.tools = {
156
+ ...settings.harness.builtinTools,
157
+ ...this.userTools,
158
+ } as HarnessAllTools<THarness, TUserTools>;
159
+ }
160
+
161
+ /** Identifier of the harness backing this agent. */
162
+ get harnessId(): string {
163
+ return this.settings.harness.harnessId;
164
+ }
165
+
166
+ /**
167
+ * Start a fresh session, or resume from state previously returned by
168
+ * `session.detach()` or `session.stop()`. The returned
169
+ * `HarnessAgentSession` must be passed to subsequent `generate` / `stream`
170
+ * calls; end it with `session.detach()`, `session.stop()`, or
171
+ * `session.destroy()`.
172
+ */
173
+ async createSession(options?: {
174
+ /**
175
+ * Optional stable identifier for the underlying sandbox/session.
176
+ * When omitted the agent generates one. Supply the original
177
+ * `session.sessionId` together with `resumeFrom` to reattach a
178
+ * previously ended session across processes.
179
+ */
180
+ sessionId?: string;
181
+ /**
182
+ * Resume payload returned by a prior `session.detach()` or
183
+ * `session.stop()`. Must be accompanied by the original `sessionId`; the
184
+ * framework validates it against `harness.lifecycleStateSchema` before
185
+ * handing it to the adapter.
186
+ */
187
+ resumeFrom?: HarnessAgentResumeSessionState;
188
+ /**
189
+ * Continuation payload returned by a prior `session.suspendTurn()`. Must be
190
+ * accompanied by the original `sessionId`; the framework validates it before
191
+ * handing it to the adapter.
192
+ */
193
+ continueFrom?: HarnessAgentContinueTurnState;
194
+ abortSignal?: AbortSignal;
195
+ }): Promise<HarnessAgentSession> {
196
+ const sessionId = options?.sessionId ?? generateId();
197
+ const resumeFrom = options?.resumeFrom;
198
+ const continueFrom = options?.continueFrom;
199
+ const abortSignal = options?.abortSignal;
200
+ const harness = this.settings.harness;
201
+ const sandboxProvider = this.settings.sandbox;
202
+
203
+ if (resumeFrom != null && continueFrom != null) {
204
+ throw new Error(
205
+ 'HarnessAgent.createSession: pass either `resumeFrom` or `continueFrom`, not both.',
206
+ );
207
+ }
208
+
209
+ let validatedResumeFrom: HarnessAgentResumeSessionState | undefined;
210
+ if (resumeFrom != null) {
211
+ validatedResumeFrom = await validateLifecycleStateData({
212
+ harness,
213
+ state: resumeFrom,
214
+ expectedType: 'resume-session',
215
+ });
216
+ }
217
+
218
+ let validatedContinueFrom: HarnessAgentContinueTurnState | undefined;
219
+ if (continueFrom != null) {
220
+ validatedContinueFrom = await validateLifecycleStateData({
221
+ harness,
222
+ state: continueFrom,
223
+ expectedType: 'continue-turn',
224
+ });
225
+ }
226
+
227
+ const effectiveContinueFrom =
228
+ validatedContinueFrom ?? validatedResumeFrom?.continueFrom;
229
+ const isResumedSession =
230
+ validatedResumeFrom != null || effectiveContinueFrom != null;
231
+
232
+ let recipe: HarnessV1Bootstrap | undefined;
233
+ let identity: string | undefined;
234
+
235
+ if (harness.getBootstrap != null) {
236
+ recipe = await harness.getBootstrap({ abortSignal });
237
+ identity = await hashBootstrap(recipe);
238
+ }
239
+
240
+ const acquiredSandboxSession = await this._acquireSandbox({
241
+ sandboxProvider,
242
+ sessionId,
243
+ isResume: isResumedSession,
244
+ recipe,
245
+ identity,
246
+ abortSignal,
247
+ });
248
+
249
+ const leased = applyPortLease({
250
+ provider: sandboxProvider,
251
+ sandboxSession: acquiredSandboxSession,
252
+ sessionId,
253
+ });
254
+ const sandboxSession = leased.sandboxSession;
255
+ const leasedBridgePort = leased.port;
256
+ const sessionWorkDir = `${sandboxSession.defaultWorkingDirectory}/${harness.harnessId}-${sessionId}`;
257
+
258
+ try {
259
+ /*
260
+ * Adapter bootstrap is one-time work for fresh sessions. The consumer
261
+ * hook runs for every acquired sandbox session after the work dir exists.
262
+ */
263
+ if (!isResumedSession && recipe != null && identity != null) {
264
+ await applyBootstrapRecipe(
265
+ sandboxSession.restricted(),
266
+ recipe,
267
+ identity,
268
+ { abortSignal },
269
+ );
270
+ }
271
+ await sandboxSession.run({
272
+ command: `mkdir -p ${sessionWorkDir}`,
273
+ abortSignal,
274
+ });
275
+ if (this.settings.onSandboxSession != null) {
276
+ await this.settings.onSandboxSession({
277
+ session: sandboxSession.restricted(),
278
+ sessionWorkDir,
279
+ abortSignal,
280
+ });
281
+ }
282
+ } catch (err) {
283
+ await cleanupAfterStartFailure({
284
+ sandboxProvider,
285
+ sandboxSession,
286
+ sessionId,
287
+ leasedBridgePort,
288
+ });
289
+ throw err;
290
+ }
291
+
292
+ try {
293
+ const baseStartOptions = {
294
+ sessionId,
295
+ skills: this.settings.skills,
296
+ resumeFrom: validatedResumeFrom,
297
+ continueFrom: effectiveContinueFrom,
298
+ permissionMode: this.permissionMode,
299
+ abortSignal,
300
+ observability: buildObservability({ settings: this.settings }),
301
+ };
302
+ const underlyingSession = await harness.doStart({
303
+ ...baseStartOptions,
304
+ sandboxSession,
305
+ sessionWorkDir,
306
+ });
307
+ return new HarnessAgentSession({
308
+ sessionId,
309
+ harness,
310
+ underlyingSession,
311
+ sandboxSession,
312
+ sandboxProvider,
313
+ leasedBridgePort,
314
+ sessionWorkDir,
315
+ toolApproval: this.settings.toolApproval,
316
+ pendingToolApprovals: effectiveContinueFrom?.pendingToolApprovals,
317
+ turnState:
318
+ effectiveContinueFrom == null
319
+ ? 'idle'
320
+ : effectiveContinueFrom.pendingToolApprovals != null &&
321
+ effectiveContinueFrom.pendingToolApprovals.length > 0
322
+ ? 'awaiting-approval'
323
+ : 'suspended',
324
+ });
325
+ } catch (error) {
326
+ await cleanupAfterStartFailure({
327
+ sandboxProvider,
328
+ sandboxSession,
329
+ sessionId,
330
+ leasedBridgePort,
331
+ });
332
+ throw error;
333
+ }
334
+ }
335
+
336
+ async generate(
337
+ options: AgentCallParameters<
338
+ never,
339
+ HarnessAllTools<THarness, TUserTools>,
340
+ RUNTIME_CONTEXT
341
+ > &
342
+ HarnessAgentCallExtensions,
343
+ ): Promise<
344
+ GenerateTextResult<
345
+ HarnessAllTools<THarness, TUserTools>,
346
+ RUNTIME_CONTEXT,
347
+ never
348
+ >
349
+ > {
350
+ const turnInput = this._resolveTurnInput(options);
351
+ const runtimeContext = {} as RUNTIME_CONTEXT;
352
+ const { result, done } = this._startTurn({
353
+ session: options.session,
354
+ turnInput,
355
+ runtimeContext,
356
+ abortSignal: options.abortSignal,
357
+ });
358
+ await done;
359
+ return this._toGenerateResult(result);
360
+ }
361
+
362
+ async stream(
363
+ options: AgentStreamParameters<
364
+ never,
365
+ HarnessAllTools<THarness, TUserTools>,
366
+ RUNTIME_CONTEXT
367
+ > &
368
+ HarnessAgentCallExtensions,
369
+ ): Promise<
370
+ StreamTextResult<
371
+ HarnessAllTools<THarness, TUserTools>,
372
+ RUNTIME_CONTEXT,
373
+ never
374
+ >
375
+ > {
376
+ const turnInput = this._resolveTurnInput(options);
377
+ const runtimeContext = {} as RUNTIME_CONTEXT;
378
+ const { result } = this._startTurn({
379
+ session: options.session,
380
+ turnInput,
381
+ runtimeContext,
382
+ abortSignal: options.abortSignal,
383
+ });
384
+ return result;
385
+ }
386
+
387
+ /**
388
+ * Continue the in-flight turn **without a new prompt**, draining it like
389
+ * {@link generate}. Used after `createSession({ continueFrom })` to finish
390
+ * consuming a turn that crossed a process boundary.
391
+ */
392
+ async continueGenerate(options: {
393
+ session: HarnessAgentSession;
394
+ toolApprovalContinuations?: readonly HarnessAgentToolApprovalContinuation[];
395
+ abortSignal?: AbortSignal;
396
+ }): Promise<
397
+ GenerateTextResult<
398
+ HarnessAllTools<THarness, TUserTools>,
399
+ RUNTIME_CONTEXT,
400
+ never
401
+ >
402
+ > {
403
+ const runtimeContext = {} as RUNTIME_CONTEXT;
404
+
405
+ const { result, done } = this._startTurn({
406
+ session: options.session,
407
+ turnInput: {
408
+ mode: 'continue',
409
+ toolApprovalContinuations: options.toolApprovalContinuations ?? [],
410
+ },
411
+ runtimeContext,
412
+ abortSignal: options.abortSignal,
413
+ });
414
+ await done;
415
+ return this._toGenerateResult(result);
416
+ }
417
+
418
+ /**
419
+ * Continue the in-flight turn **without a new prompt**, streaming its events
420
+ * like {@link stream}. Used to keep consuming a turn that is still running
421
+ * (or finished) in the runtime after a process boundary — the workflow slice
422
+ * loop calls this on every slice after the first. Routes through the adapter's
423
+ * `doContinueTurn`; what it can guarantee (lossless attach vs. lossy rerun)
424
+ * follows from how the adapter resumed the session.
425
+ */
426
+ async continueStream(options: {
427
+ session: HarnessAgentSession;
428
+ toolApprovalContinuations?: readonly HarnessAgentToolApprovalContinuation[];
429
+ abortSignal?: AbortSignal;
430
+ }): Promise<
431
+ StreamTextResult<
432
+ HarnessAllTools<THarness, TUserTools>,
433
+ RUNTIME_CONTEXT,
434
+ never
435
+ >
436
+ > {
437
+ const runtimeContext = {} as RUNTIME_CONTEXT;
438
+
439
+ const { result } = this._startTurn({
440
+ session: options.session,
441
+ turnInput: {
442
+ mode: 'continue',
443
+ toolApprovalContinuations: options.toolApprovalContinuations ?? [],
444
+ },
445
+ runtimeContext,
446
+ abortSignal: options.abortSignal,
447
+ });
448
+ return result;
449
+ }
450
+
451
+ // ─── Internals ──────────────────────────────────────────────────────
452
+
453
+ private _startTurn(input: {
454
+ session: HarnessAgentSession;
455
+ turnInput:
456
+ | { mode: 'prompt'; prompt: HarnessAgentPrompt }
457
+ | {
458
+ mode: 'continue';
459
+ toolApprovalContinuations: readonly HarnessAgentToolApprovalContinuation[];
460
+ };
461
+ runtimeContext: RUNTIME_CONTEXT;
462
+ abortSignal: AbortSignal | undefined;
463
+ }): {
464
+ result: StreamTextResult<
465
+ HarnessAllTools<THarness, TUserTools>,
466
+ RUNTIME_CONTEXT,
467
+ never
468
+ >;
469
+ done: Promise<void>;
470
+ } {
471
+ if (input.turnInput.mode === 'continue') {
472
+ return input.session.continueTurn<
473
+ HarnessAllTools<THarness, TUserTools>,
474
+ RUNTIME_CONTEXT
475
+ >({
476
+ instructions: this.settings.instructions,
477
+ tools: this.tools,
478
+ toolSpecs: this._toToolSpecs(),
479
+ runtimeContext: input.runtimeContext,
480
+ abortSignal: input.abortSignal,
481
+ telemetry: this.settings.telemetry,
482
+ toolApprovalContinuations: input.turnInput.toolApprovalContinuations,
483
+ });
484
+ }
485
+
486
+ return input.session.promptTurn<
487
+ HarnessAllTools<THarness, TUserTools>,
488
+ RUNTIME_CONTEXT
489
+ >({
490
+ prompt: input.turnInput.prompt,
491
+ instructions: this.settings.instructions,
492
+ tools: this.tools,
493
+ toolSpecs: this._toToolSpecs(),
494
+ runtimeContext: input.runtimeContext,
495
+ abortSignal: input.abortSignal,
496
+ telemetry: this.settings.telemetry,
497
+ });
498
+ }
499
+
500
+ private async _acquireSandbox(input: {
501
+ sandboxProvider: HarnessV1SandboxProvider;
502
+ sessionId: string;
503
+ isResume: boolean;
504
+ recipe: HarnessV1Bootstrap | undefined;
505
+ identity: string | undefined;
506
+ abortSignal: AbortSignal | undefined;
507
+ }): Promise<HarnessV1NetworkSandboxSession> {
508
+ const { sandboxProvider } = input;
509
+ if (input.isResume) {
510
+ if (sandboxProvider.resumeSession == null) {
511
+ throw new HarnessCapabilityUnsupportedError({
512
+ message: `Sandbox provider '${sandboxProvider.providerId}' does not support resume.`,
513
+ harnessId: this.settings.harness.harnessId,
514
+ });
515
+ }
516
+ return sandboxProvider.resumeSession({
517
+ sessionId: input.sessionId,
518
+ abortSignal: input.abortSignal,
519
+ });
520
+ }
521
+ return sandboxProvider.createSession({
522
+ sessionId: input.sessionId,
523
+ abortSignal: input.abortSignal,
524
+ identity: input.identity,
525
+ onFirstCreate:
526
+ input.recipe != null && input.identity != null
527
+ ? (session, opts) =>
528
+ applyBootstrapRecipe(
529
+ session,
530
+ input.recipe!,
531
+ input.identity!,
532
+ opts,
533
+ )
534
+ : undefined,
535
+ });
536
+ }
537
+
538
+ /*
539
+ * Reduce AI SDK input to the single user message the harness should run
540
+ * for this turn. The harness session owns prior-turn state (system
541
+ * prompt, assistant turns, tool results) — we never replay it. A bare
542
+ * string is forwarded as-is; a message array is collapsed to its last
543
+ * `role: 'user'` entry. Inputs whose only messages are non-user (system,
544
+ * assistant, tool) have no fresh user input and are rejected.
545
+ */
546
+ private _resolveTurnInput(options: {
547
+ prompt?: string | ModelMessage[];
548
+ messages?: ModelMessage[];
549
+ }):
550
+ | { mode: 'prompt'; prompt: HarnessAgentPrompt }
551
+ | {
552
+ mode: 'continue';
553
+ toolApprovalContinuations: readonly HarnessAgentToolApprovalContinuation[];
554
+ } {
555
+ if (typeof options.prompt === 'string') {
556
+ return { mode: 'prompt', prompt: options.prompt };
557
+ }
558
+ const messages = Array.isArray(options.prompt)
559
+ ? options.prompt
560
+ : options.messages;
561
+ if (Array.isArray(messages)) {
562
+ const toolApprovalContinuations =
563
+ collectHarnessAgentToolApprovalContinuations({ messages });
564
+ if (toolApprovalContinuations.length > 0) {
565
+ return {
566
+ mode: 'continue',
567
+ toolApprovalContinuations,
568
+ };
569
+ }
570
+ for (let i = messages.length - 1; i >= 0; i--) {
571
+ const message = messages[i];
572
+ if (message?.role === 'user')
573
+ return { mode: 'prompt', prompt: message };
574
+ }
575
+ throw new Error(
576
+ 'HarnessAgent: messages must contain at least one `role: "user"` entry.',
577
+ );
578
+ }
579
+ throw new Error('HarnessAgent: either `prompt` or `messages` is required.');
580
+ }
581
+
582
+ /*
583
+ * Wire-format projection of user-defined tools only. Harness builtins are
584
+ * executed by the runtime and the bridge already knows about them — we
585
+ * never re-declare them over the wire.
586
+ */
587
+ private _toToolSpecs(): HarnessAgentToolSpec[] {
588
+ const specs: HarnessAgentToolSpec[] = [];
589
+ for (const [name, tool] of Object.entries(
590
+ this.userTools as Record<string, unknown>,
591
+ )) {
592
+ const t = tool as {
593
+ description?: string;
594
+ inputSchema?: unknown;
595
+ };
596
+ let inputSchema: HarnessAgentToolSpec['inputSchema'];
597
+ if (t.inputSchema != null) {
598
+ try {
599
+ inputSchema = asSchema(
600
+ t.inputSchema as Parameters<typeof asSchema>[0],
601
+ ).jsonSchema as HarnessAgentToolSpec['inputSchema'];
602
+ } catch {
603
+ // tools without a usable schema are still forwarded by name
604
+ }
605
+ }
606
+ specs.push({ name, description: t.description, inputSchema });
607
+ }
608
+ return specs;
609
+ }
610
+
611
+ private async _toGenerateResult(
612
+ streamResult: StreamTextResult<
613
+ HarnessAllTools<THarness, TUserTools>,
614
+ RUNTIME_CONTEXT,
615
+ never
616
+ >,
617
+ ): Promise<
618
+ GenerateTextResult<
619
+ HarnessAllTools<THarness, TUserTools>,
620
+ RUNTIME_CONTEXT,
621
+ never
622
+ >
623
+ > {
624
+ // The stream is already drained by the time generate() calls this helper
625
+ // (done has resolved). `steps` is the single source of truth the result
626
+ // derives everything else from, mirroring core's `generateText` result.
627
+ const [steps, usage, responseMessages] = await Promise.all([
628
+ streamResult.steps,
629
+ streamResult.usage,
630
+ streamResult.responseMessages,
631
+ ]);
632
+
633
+ return new HarnessGenerateTextResult<
634
+ HarnessAllTools<THarness, TUserTools>,
635
+ RUNTIME_CONTEXT
636
+ >({ steps, usage, responseMessages });
637
+ }
638
+ }
639
+
640
+ /*
641
+ * `GenerateTextResult` view over a drained `streamText` run. Non-deprecated
642
+ * members derive from `steps` (the single source of truth), and the deprecated
643
+ * members are exposed as getters that delegate to `finalStep` / `usage`.
644
+ * Implementing the deprecated members as getters — rather than assigning them
645
+ * in an object literal — keeps construction free of deprecated-property usage,
646
+ * matching how core's `generateText` builds its result.
647
+ */
648
+ class HarnessGenerateTextResult<
649
+ TOOLS extends ToolSet,
650
+ RUNTIME_CONTEXT extends Context,
651
+ > implements GenerateTextResult<TOOLS, RUNTIME_CONTEXT, never> {
652
+ readonly steps: GenerateTextResult<TOOLS, RUNTIME_CONTEXT, never>['steps'];
653
+ readonly usage: GenerateTextResult<TOOLS, RUNTIME_CONTEXT, never>['usage'];
654
+ readonly responseMessages: GenerateTextResult<
655
+ TOOLS,
656
+ RUNTIME_CONTEXT,
657
+ never
658
+ >['responseMessages'];
659
+ readonly output = undefined as never;
660
+
661
+ constructor(options: {
662
+ steps: GenerateTextResult<TOOLS, RUNTIME_CONTEXT, never>['steps'];
663
+ usage: GenerateTextResult<TOOLS, RUNTIME_CONTEXT, never>['usage'];
664
+ responseMessages: GenerateTextResult<
665
+ TOOLS,
666
+ RUNTIME_CONTEXT,
667
+ never
668
+ >['responseMessages'];
669
+ }) {
670
+ this.steps = options.steps;
671
+ this.usage = options.usage;
672
+ this.responseMessages = options.responseMessages;
673
+ }
674
+
675
+ get finalStep() {
676
+ return this.steps.at(-1)!;
677
+ }
678
+
679
+ get content() {
680
+ return this.steps.flatMap(step => step.content);
681
+ }
682
+
683
+ get text() {
684
+ return this.finalStep.text;
685
+ }
686
+
687
+ get files() {
688
+ return this.steps.flatMap(step => step.files);
689
+ }
690
+
691
+ get sources() {
692
+ return this.steps.flatMap(step => step.sources);
693
+ }
694
+
695
+ get toolCalls() {
696
+ return this.steps.flatMap(step => step.toolCalls);
697
+ }
698
+
699
+ get staticToolCalls() {
700
+ return this.steps.flatMap(step => step.staticToolCalls);
701
+ }
702
+
703
+ get dynamicToolCalls() {
704
+ return this.steps.flatMap(step => step.dynamicToolCalls);
705
+ }
706
+
707
+ get toolResults() {
708
+ return this.steps.flatMap(step => step.toolResults);
709
+ }
710
+
711
+ get staticToolResults() {
712
+ return this.steps.flatMap(step => step.staticToolResults);
713
+ }
714
+
715
+ get dynamicToolResults() {
716
+ return this.steps.flatMap(step => step.dynamicToolResults);
717
+ }
718
+
719
+ get finishReason() {
720
+ return this.finalStep.finishReason;
721
+ }
722
+
723
+ get rawFinishReason() {
724
+ return this.finalStep.rawFinishReason;
725
+ }
726
+
727
+ get warnings() {
728
+ return this.steps.flatMap(step => step.warnings ?? []);
729
+ }
730
+
731
+ get reasoning() {
732
+ return this.finalStep.content.filter(
733
+ (part): part is ReasoningOutput | ReasoningFileOutput =>
734
+ part.type === 'reasoning' || part.type === 'reasoning-file',
735
+ );
736
+ }
737
+
738
+ get reasoningText() {
739
+ return this.finalStep.reasoningText;
740
+ }
741
+
742
+ get totalUsage() {
743
+ return this.usage;
744
+ }
745
+
746
+ get request() {
747
+ return this.finalStep.request;
748
+ }
749
+
750
+ get response() {
751
+ return this.finalStep.response;
752
+ }
753
+
754
+ get providerMetadata() {
755
+ return this.finalStep.providerMetadata;
756
+ }
757
+ }
758
+
759
+ /*
760
+ * Bridge-port leasing helper. Returns the port-narrowed network sandbox session
761
+ * plus the leased port (or `undefined` when the provider has no port pool). Kept here
762
+ * rather than on the session so the lease is established as part of session
763
+ * start — the session only needs to release it on close/detach.
764
+ */
765
+ function applyPortLease(input: {
766
+ provider: HarnessV1SandboxProvider;
767
+ sandboxSession: HarnessV1NetworkSandboxSession;
768
+ sessionId: string;
769
+ }): {
770
+ sandboxSession: HarnessV1NetworkSandboxSession;
771
+ port: number | undefined;
772
+ } {
773
+ const pool = input.provider.bridgePorts;
774
+ if (pool == null || pool.length === 0) {
775
+ return { sandboxSession: input.sandboxSession, port: undefined };
776
+ }
777
+ const port = acquireBridgePort({
778
+ poolKey: input.provider,
779
+ pool,
780
+ sessionId: input.sessionId,
781
+ });
782
+ return {
783
+ sandboxSession: narrowNetworkSessionPorts(input.sandboxSession, port),
784
+ port,
785
+ };
786
+ }
787
+
788
+ /*
789
+ * Derive a view of the network sandbox session that reports only the leased
790
+ * port. Implemented as a prototype-delegating overlay so every other member
791
+ * (file I/O, exec, spawn, lifecycle, `restricted`) forwards to the same live
792
+ * instance — only `ports` is shadowed.
793
+ */
794
+ function narrowNetworkSessionPorts(
795
+ sandboxSession: HarnessV1NetworkSandboxSession,
796
+ leasedPort: number,
797
+ ): HarnessV1NetworkSandboxSession {
798
+ return Object.create(sandboxSession, {
799
+ ports: {
800
+ value: [leasedPort] as ReadonlyArray<number>,
801
+ enumerable: true,
802
+ },
803
+ }) as HarnessV1NetworkSandboxSession;
804
+ }
805
+
806
+ async function cleanupAfterStartFailure(input: {
807
+ sandboxProvider: HarnessV1SandboxProvider;
808
+ sandboxSession: HarnessV1NetworkSandboxSession;
809
+ sessionId: string;
810
+ leasedBridgePort: number | undefined;
811
+ }): Promise<void> {
812
+ await Promise.resolve(input.sandboxSession.stop()).catch(() => {});
813
+ if (input.leasedBridgePort != null) {
814
+ releaseBridgePort({
815
+ poolKey: input.sandboxProvider,
816
+ sessionId: input.sessionId,
817
+ });
818
+ }
819
+ }