@ai-sdk/harness 0.0.0-6b196531-20260710185421

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