@ai-sdk/harness 0.0.0 → 1.0.0-canary.3

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