@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,352 @@
1
+ import type {
2
+ HarnessV1,
3
+ HarnessV1NetworkSandboxSession,
4
+ HarnessV1PendingToolApproval,
5
+ HarnessV1Prompt,
6
+ HarnessV1ResumeState,
7
+ HarnessV1SandboxProvider,
8
+ HarnessV1Session,
9
+ HarnessV1ToolSpec,
10
+ } from '../v1';
11
+ import type { Context, ToolSet } from '@ai-sdk/provider-utils';
12
+ import type { TelemetryOptions } from 'ai';
13
+ import type { HarnessAgentToolApprovalConfiguration } from './harness-agent-settings';
14
+ import type { HarnessAgentToolApprovalContinuation } from './harness-agent-tool-approval-continuation';
15
+ import { releaseBridgePort } from './internal/bridge-port-registry';
16
+ import { validateResumeStateData } from './internal/resume-state-validation';
17
+ import { runPrompt } from './internal/run-prompt';
18
+
19
+ /**
20
+ * Live harness session held by the caller.
21
+ *
22
+ * Created by {@link import('./harness-agent').HarnessAgent.createSession}.
23
+ * Owns the underlying `HarnessV1Session`, the network sandbox session, and the
24
+ * bridge-port lease (when the provider wraps a caller-provided sandbox with a
25
+ * port pool).
26
+ *
27
+ * Pass the instance back to `agent.generate` / `agent.stream` on every
28
+ * call; end the local handle with `detach()`, `stop()`, or `destroy()`.
29
+ *
30
+ * After any lifecycle method has resolved, the session is unusable — any
31
+ * subsequent `generate`/`stream` call against it throws.
32
+ */
33
+ export class HarnessAgentSession {
34
+ /**
35
+ * Stable identifier the harness adapter saw in `doStart`. The same
36
+ * string callers persist when they intend to resume the session in a
37
+ * future process.
38
+ */
39
+ readonly sessionId: string;
40
+
41
+ private readonly harness: HarnessV1;
42
+ private readonly sandboxProvider: HarnessV1SandboxProvider;
43
+ private readonly sessionWorkDir: string;
44
+ private underlyingSession: HarnessV1Session | undefined;
45
+ private sandboxSession: HarnessV1NetworkSandboxSession | undefined;
46
+ private leasedBridgePort: number | undefined;
47
+ private readonly toolApproval:
48
+ | HarnessAgentToolApprovalConfiguration
49
+ | undefined;
50
+ private readonly pendingToolApprovals = new Map<
51
+ string,
52
+ HarnessV1PendingToolApproval
53
+ >();
54
+ private stopped = false;
55
+
56
+ /**
57
+ * Whether this session was created from a resume payload. Captured at
58
+ * construction so it survives lifecycle cleanup.
59
+ */
60
+ readonly isResume: boolean;
61
+
62
+ constructor(options: {
63
+ sessionId: string;
64
+ harness: HarnessV1;
65
+ underlyingSession: HarnessV1Session;
66
+ sandboxSession: HarnessV1NetworkSandboxSession;
67
+ sandboxProvider: HarnessV1SandboxProvider;
68
+ leasedBridgePort?: number;
69
+ sessionWorkDir: string;
70
+ toolApproval: HarnessAgentToolApprovalConfiguration | undefined;
71
+ pendingToolApprovals?: readonly HarnessV1PendingToolApproval[];
72
+ }) {
73
+ this.sessionId = options.sessionId;
74
+ this.harness = options.harness;
75
+ this.underlyingSession = options.underlyingSession;
76
+ this.sandboxSession = options.sandboxSession;
77
+ this.sandboxProvider = options.sandboxProvider;
78
+ this.leasedBridgePort = options.leasedBridgePort;
79
+ this.sessionWorkDir = options.sessionWorkDir;
80
+ this.toolApproval = options.toolApproval;
81
+ for (const approval of options.pendingToolApprovals ?? []) {
82
+ this.pendingToolApprovals.set(approval.approvalId, approval);
83
+ }
84
+ this.isResume = options.underlyingSession.isResume;
85
+ }
86
+
87
+ /**
88
+ * Active network sandbox session.
89
+ *
90
+ * @internal — accessed by session turn and lifecycle drivers.
91
+ */
92
+ getSandboxSession(): HarnessV1NetworkSandboxSession {
93
+ if (this.stopped || this.sandboxSession == null) {
94
+ throw new Error(
95
+ `Harness session ${this.sessionId} has ended and cannot be reused.`,
96
+ );
97
+ }
98
+ return this.sandboxSession;
99
+ }
100
+
101
+ /**
102
+ * Working directory the agent runs in for this session. Used to strip the
103
+ * prefix from absolute paths in stream events before they reach consumers.
104
+ *
105
+ * @internal — accessed by session turn drivers.
106
+ */
107
+ getSessionWorkDir(): string {
108
+ return this.sessionWorkDir;
109
+ }
110
+
111
+ promptTurn<TOOLS extends ToolSet, RUNTIME_CONTEXT extends Context>(options: {
112
+ prompt: HarnessV1Prompt;
113
+ instructions: string | undefined;
114
+ tools: TOOLS;
115
+ toolSpecs: HarnessV1ToolSpec[];
116
+ runtimeContext: RUNTIME_CONTEXT;
117
+ abortSignal: AbortSignal | undefined;
118
+ telemetry: TelemetryOptions | undefined;
119
+ }): ReturnType<typeof runPrompt<TOOLS, RUNTIME_CONTEXT>> {
120
+ const session = this.requireReusableSession();
121
+ if (this.pendingToolApprovals.size > 0) {
122
+ throw new Error(
123
+ `Harness session ${this.sessionId} has pending tool approvals and must be continued with approval responses before accepting a new prompt.`,
124
+ );
125
+ }
126
+ const sandboxSession = this.getSandboxSession();
127
+ return runPrompt<TOOLS, RUNTIME_CONTEXT>({
128
+ harness: this.harness,
129
+ session,
130
+ prompt: options.prompt,
131
+ instructions: options.instructions,
132
+ tools: options.tools,
133
+ toolSpecs: options.toolSpecs,
134
+ sandboxSession: sandboxSession.restricted(),
135
+ sessionWorkDir: this.sessionWorkDir,
136
+ runtimeContext: options.runtimeContext,
137
+ abortSignal: options.abortSignal,
138
+ telemetry: options.telemetry,
139
+ toolApproval: this.toolApproval,
140
+ pendingToolApprovals: this.getPendingToolApprovals(),
141
+ onPendingToolApproval: approval => {
142
+ this.pendingToolApprovals.set(approval.approvalId, approval);
143
+ },
144
+ onToolApprovalSettled: approvalId => {
145
+ this.pendingToolApprovals.delete(approvalId);
146
+ },
147
+ });
148
+ }
149
+
150
+ continueTurn<
151
+ TOOLS extends ToolSet,
152
+ RUNTIME_CONTEXT extends Context,
153
+ >(options: {
154
+ instructions: string | undefined;
155
+ tools: TOOLS;
156
+ toolSpecs: HarnessV1ToolSpec[];
157
+ runtimeContext: RUNTIME_CONTEXT;
158
+ abortSignal: AbortSignal | undefined;
159
+ telemetry: TelemetryOptions | undefined;
160
+ toolApprovalContinuations?:
161
+ | readonly HarnessAgentToolApprovalContinuation[]
162
+ | undefined;
163
+ }): ReturnType<typeof runPrompt<TOOLS, RUNTIME_CONTEXT>> {
164
+ const session = this.requireReusableSession();
165
+ const sandboxSession = this.getSandboxSession();
166
+ return runPrompt<TOOLS, RUNTIME_CONTEXT>({
167
+ harness: this.harness,
168
+ session,
169
+ mode: 'continue',
170
+ instructions: options.instructions,
171
+ tools: options.tools,
172
+ toolSpecs: options.toolSpecs,
173
+ sandboxSession: sandboxSession.restricted(),
174
+ sessionWorkDir: this.sessionWorkDir,
175
+ runtimeContext: options.runtimeContext,
176
+ abortSignal: options.abortSignal,
177
+ telemetry: options.telemetry,
178
+ toolApproval: this.toolApproval,
179
+ pendingToolApprovals: this.getPendingToolApprovals(),
180
+ toolApprovalContinuations: options.toolApprovalContinuations,
181
+ onPendingToolApproval: approval => {
182
+ this.pendingToolApprovals.set(approval.approvalId, approval);
183
+ },
184
+ onToolApprovalSettled: approvalId => {
185
+ this.pendingToolApprovals.delete(approvalId);
186
+ },
187
+ });
188
+ }
189
+
190
+ /**
191
+ * Ask the underlying runtime to compact its context. The runtime performs
192
+ * the compaction itself; when it completes, a `compaction` part appears on
193
+ * the active (or next) turn's stream. Safe to call between turns for
194
+ * runtimes whose compaction is session-scoped (e.g. Pi).
195
+ *
196
+ * Throws `HarnessCapabilityUnsupportedError` for harnesses that cannot
197
+ * trigger compaction manually (e.g. Codex, which still auto-compacts under
198
+ * the hood). Throws if the session has ended.
199
+ */
200
+ async compact(customInstructions?: string): Promise<void> {
201
+ await this.requireReusableSession().doCompact(customInstructions);
202
+ }
203
+
204
+ /**
205
+ * Park the session, returning a payload the caller can persist and later
206
+ * pass to `agent.createSession({ sessionId, resumeFrom })` to reconnect.
207
+ * The runtime and sandbox keep running; this local session handle becomes
208
+ * unusable.
209
+ */
210
+ async detach(): Promise<HarnessV1ResumeState> {
211
+ if (this.stopped || this.underlyingSession == null) {
212
+ throw new Error(
213
+ `Harness session ${this.sessionId} is not active and cannot be detached.`,
214
+ );
215
+ }
216
+ const session = this.underlyingSession;
217
+ try {
218
+ const raw = await session.doDetach();
219
+ const validated = await validateResumeStateData({
220
+ harness: this.harness,
221
+ state: raw as HarnessV1ResumeState,
222
+ });
223
+ return this.withPendingToolApprovals(validated);
224
+ } finally {
225
+ this.endLocalHandle({ releasePortLease: false });
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Persist enough state to resume later, then stop the runtime and sandbox.
231
+ * Returns the resume state for a future
232
+ * `agent.createSession({ sessionId, resumeFrom })` call.
233
+ */
234
+ async stop(): Promise<HarnessV1ResumeState> {
235
+ if (this.stopped || this.underlyingSession == null) {
236
+ throw new Error(
237
+ `Harness session ${this.sessionId} is not active and cannot be stopped.`,
238
+ );
239
+ }
240
+ const session = this.underlyingSession;
241
+ const sandboxSession = this.getSandboxSession();
242
+ try {
243
+ const raw = await session.doStop();
244
+ const validated = await validateResumeStateData({
245
+ harness: this.harness,
246
+ state: raw as HarnessV1ResumeState,
247
+ });
248
+ return this.withPendingToolApprovals(validated);
249
+ } finally {
250
+ this.endLocalHandle({ releasePortLease: true });
251
+ await Promise.resolve(sandboxSession.stop()).catch(() => {});
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Stop the runtime and discard resumability. The sandbox is destroyed when
257
+ * the provider supports destruction; otherwise it is stopped.
258
+ */
259
+ async destroy(): Promise<void> {
260
+ if (this.stopped) return;
261
+ const session = this.underlyingSession;
262
+ const sandboxSession = this.getSandboxSession();
263
+ this.endLocalHandle({ releasePortLease: true });
264
+ if (session != null) {
265
+ await Promise.resolve(session.doDestroy()).catch(() => {});
266
+ }
267
+ await Promise.resolve(
268
+ sandboxSession.destroy?.() ?? sandboxSession.stop(),
269
+ ).catch(() => {});
270
+ }
271
+
272
+ /**
273
+ * Gracefully freeze the active turn at the slice boundary and return the
274
+ * resume payload, **leaving the sandbox/runtime running** so the next process
275
+ * can resume. Resolves once the in-flight `stream()`/`continueTurn()`
276
+ * has cleanly wound down at a precise cursor (see
277
+ * {@link HarnessV1Session.doSuspendTurn}).
278
+ *
279
+ * After this call the session is marked stopped. This in-process handle no
280
+ * longer drives turns; a future slice creates a fresh session from the
281
+ * returned state. The sandbox is **not** stopped and no port lease is
282
+ * released, because bridge-backed adapters may still have a live bridge on
283
+ * that port.
284
+ */
285
+ async suspendTurn(): Promise<HarnessV1ResumeState> {
286
+ if (this.stopped || this.underlyingSession == null) {
287
+ throw new Error(
288
+ `Harness session ${this.sessionId} is not active and cannot be suspended.`,
289
+ );
290
+ }
291
+ const session = this.underlyingSession;
292
+ const raw = await session.doSuspendTurn();
293
+ const validated = await validateResumeStateData({
294
+ harness: this.harness,
295
+ state: raw as HarnessV1ResumeState,
296
+ });
297
+ // Drop the in-process references without stopping the sandbox or releasing
298
+ // the port lease: bridge-backed adapters may keep using that port.
299
+ this.stopped = true;
300
+ this.underlyingSession = undefined;
301
+ this.sandboxSession = undefined;
302
+ return this.withPendingToolApprovals(validated);
303
+ }
304
+
305
+ private getPendingToolApprovals(): readonly HarnessV1PendingToolApproval[] {
306
+ return Array.from(this.pendingToolApprovals.values());
307
+ }
308
+
309
+ private withPendingToolApprovals(
310
+ state: HarnessV1ResumeState,
311
+ ): HarnessV1ResumeState {
312
+ const pendingToolApprovals = this.getPendingToolApprovals();
313
+ if (pendingToolApprovals.length === 0) {
314
+ return {
315
+ harnessId: state.harnessId,
316
+ specificationVersion: state.specificationVersion,
317
+ data: state.data,
318
+ };
319
+ }
320
+ return {
321
+ ...state,
322
+ pendingToolApprovals,
323
+ };
324
+ }
325
+
326
+ private endLocalHandle(options: { releasePortLease: boolean }): void {
327
+ this.stopped = true;
328
+ this.underlyingSession = undefined;
329
+ this.sandboxSession = undefined;
330
+ if (options.releasePortLease) {
331
+ this.releasePortLease();
332
+ }
333
+ }
334
+
335
+ private releasePortLease(): void {
336
+ if (this.leasedBridgePort == null) return;
337
+ releaseBridgePort({
338
+ poolKey: this.sandboxProvider,
339
+ sessionId: this.sessionId,
340
+ });
341
+ this.leasedBridgePort = undefined;
342
+ }
343
+
344
+ private requireReusableSession(): HarnessV1Session {
345
+ if (this.stopped || this.underlyingSession == null) {
346
+ throw new Error(
347
+ `Harness session ${this.sessionId} has ended and cannot be reused.`,
348
+ );
349
+ }
350
+ return this.underlyingSession;
351
+ }
352
+ }
@@ -0,0 +1,131 @@
1
+ import type {
2
+ HarnessV1,
3
+ HarnessV1PermissionMode,
4
+ HarnessV1SandboxProvider,
5
+ HarnessV1Skill,
6
+ } from '../v1';
7
+ import type {
8
+ HarnessDebugConfig,
9
+ HarnessDiagnostic,
10
+ } from './harness-diagnostics';
11
+ import type {
12
+ Experimental_SandboxSession as SandboxSession,
13
+ ToolSet,
14
+ } from '@ai-sdk/provider-utils';
15
+ import type { TelemetryOptions, ToolApprovalStatus } from 'ai';
16
+
17
+ export type HarnessAgentToolApprovalConfiguration = Readonly<
18
+ Record<string, ToolApprovalStatus>
19
+ >;
20
+
21
+ /**
22
+ * Construction-time settings for a `HarnessAgent`.
23
+ *
24
+ * Per-call settings (prompt, abortSignal, callbacks) belong on the
25
+ * `AgentCallParameters` / `AgentStreamParameters` passed to `generate` /
26
+ * `stream` and are not duplicated here.
27
+ */
28
+ export type HarnessAgentSettings<
29
+ THarness extends HarnessV1<any> = HarnessV1,
30
+ TUserTools extends ToolSet = {},
31
+ > = {
32
+ /**
33
+ * The harness adapter driving the underlying agent runtime. Its
34
+ * `builtinTools` are merged with the user-defined `tools` and exposed to
35
+ * AI SDK consumers in the typed `tool-call` stream.
36
+ */
37
+ readonly harness: THarness;
38
+
39
+ /**
40
+ * Stable identifier for this agent instance. Exposed via `agent.id`.
41
+ * If omitted, `agent.id` is `undefined`.
42
+ */
43
+ readonly id?: string;
44
+
45
+ /**
46
+ * Tools available to the underlying runtime in addition to the harness's
47
+ * own builtins. The agent forwards each tool to the harness as a
48
+ * `HarnessV1ToolSpec`; when the runtime calls one, the agent executes
49
+ * `tool.execute()` on the host and submits the result back to the harness.
50
+ *
51
+ * User tools take precedence over harness builtins on key collision —
52
+ * declare a tool with the same name as a builtin to override.
53
+ */
54
+ readonly tools?: TUserTools;
55
+
56
+ /**
57
+ * Skills made available to the underlying runtime for the lifetime of
58
+ * the session. Each adapter decides how to surface skills (file in the
59
+ * working tree, prompt prefix, …).
60
+ */
61
+ readonly skills?: ReadonlyArray<HarnessV1Skill>;
62
+
63
+ /**
64
+ * Instructions for the underlying agent runtime. Adapters prepend this to
65
+ * the first user message of a fresh session, once — it is not re-applied on
66
+ * later turns or when resuming a previously ended session.
67
+ */
68
+ readonly instructions?: string;
69
+
70
+ /**
71
+ * Built-in tool permission mode. Defaults to `'allow-all'`, preserving the
72
+ * existing bypass-permissions behavior unless users opt in.
73
+ */
74
+ readonly permissionMode?: HarnessV1PermissionMode;
75
+
76
+ /**
77
+ * Per custom-tool approval statuses. This mirrors AI SDK `toolApproval`
78
+ * object configuration for host-executed tools, without callback support.
79
+ *
80
+ * `not-applicable` and `approved` run the tool, `user-approval` pauses the
81
+ * turn for a user decision, and `denied` immediately submits an
82
+ * `execution-denied` result.
83
+ */
84
+ readonly toolApproval?: HarnessAgentToolApprovalConfiguration;
85
+
86
+ /**
87
+ * Sandbox provider whose `create()` produces the network sandbox session the
88
+ * harness runs against. Its `restricted()` view is also propagated to user
89
+ * tool `execute()` calls (as the `experimental_sandbox` field), typed as
90
+ * `Experimental_SandboxSession` so tools cannot reach the infra surface.
91
+ */
92
+ readonly sandbox: HarnessV1SandboxProvider;
93
+
94
+ /**
95
+ * Called after each sandbox session is acquired and the session work
96
+ * directory exists, before the harness adapter starts. Runs for fresh and
97
+ * resumed sessions.
98
+ *
99
+ * Use this to write per-session config, install lightweight tools, activate
100
+ * licenses, or prepare files in `sessionWorkDir`. Keep it idempotent if the
101
+ * agent may resume sessions.
102
+ */
103
+ readonly onSandboxSession?: (opts: {
104
+ readonly session: SandboxSession;
105
+ readonly sessionWorkDir: string;
106
+ readonly abortSignal?: AbortSignal;
107
+ }) => Promise<void>;
108
+
109
+ /**
110
+ * Telemetry configuration. The harness drives AI SDK's pluggable
111
+ * `Telemetry` integration contract from the turn lifecycle, so a harness turn
112
+ * appears in a consumer's traces with the same span shape as `streamText`.
113
+ * Register an integration here (e.g. `@ai-sdk/otel`) or globally via
114
+ * `registerTelemetry`. The harness itself stays OpenTelemetry-agnostic.
115
+ */
116
+ readonly telemetry?: TelemetryOptions;
117
+
118
+ /**
119
+ * Diagnostics configuration. Enables bridge log forwarding (sandbox
120
+ * console + structured `debug-event`s) and the `HARNESS_DEBUG` stderr default.
121
+ * Set `{ enabled: true }` to turn it on in code; env vars fill unset fields.
122
+ */
123
+ readonly debug?: HarnessDebugConfig;
124
+
125
+ /**
126
+ * Programmatic sink for forwarded bridge diagnostics. Receives every
127
+ * captured console line and structured event, normalized. Independent of the
128
+ * stderr default — wire this to capture diagnostics in code.
129
+ */
130
+ readonly onLog?: (event: HarnessDiagnostic) => void;
131
+ };
@@ -0,0 +1,94 @@
1
+ import { HarnessError } from '../errors/harness-error';
2
+ import type {
3
+ ModelMessage,
4
+ ToolApprovalRequest,
5
+ ToolApprovalResponse,
6
+ } from '@ai-sdk/provider-utils';
7
+
8
+ export type HarnessAgentToolApprovalContinuation = {
9
+ readonly approvalResponse: ToolApprovalResponse;
10
+ readonly toolCall: {
11
+ readonly type: 'tool-call';
12
+ readonly toolCallId: string;
13
+ readonly toolName: string;
14
+ readonly input: unknown;
15
+ readonly providerExecuted?: boolean;
16
+ };
17
+ };
18
+
19
+ /**
20
+ * Extract approval decisions that should continue a suspended harness turn.
21
+ *
22
+ * AI SDK clients send approval decisions as a trailing `role: "tool"` message
23
+ * containing `tool-approval-response` parts. The response only carries the
24
+ * approval id, so the harness has to recover the matching approval request
25
+ * locally to find the original tool call before it can resume the paused turn.
26
+ * Responses that already have a tool result are ignored, because those
27
+ * approvals were already consumed by a prior continuation.
28
+ */
29
+ export function collectHarnessAgentToolApprovalContinuations(input: {
30
+ messages: readonly ModelMessage[];
31
+ }): readonly HarnessAgentToolApprovalContinuation[] {
32
+ const lastMessage = input.messages.at(-1);
33
+ if (lastMessage?.role !== 'tool') return [];
34
+
35
+ const toolCallsByToolCallId = new Map<
36
+ string,
37
+ HarnessAgentToolApprovalContinuation['toolCall']
38
+ >();
39
+ const approvalRequestsByApprovalId = new Map<string, ToolApprovalRequest>();
40
+ for (const message of input.messages) {
41
+ if (message.role !== 'assistant' || typeof message.content === 'string') {
42
+ continue;
43
+ }
44
+ for (const part of message.content) {
45
+ if (part.type === 'tool-call') {
46
+ toolCallsByToolCallId.set(part.toolCallId, {
47
+ type: 'tool-call',
48
+ toolCallId: part.toolCallId,
49
+ toolName: part.toolName,
50
+ input: part.input,
51
+ ...(part.providerExecuted !== undefined
52
+ ? { providerExecuted: part.providerExecuted }
53
+ : {}),
54
+ });
55
+ } else if (part.type === 'tool-approval-request') {
56
+ approvalRequestsByApprovalId.set(part.approvalId, part);
57
+ }
58
+ }
59
+ }
60
+
61
+ const toolResultIds = new Set<string>();
62
+ for (const part of lastMessage.content) {
63
+ if (part.type === 'tool-result') {
64
+ toolResultIds.add(part.toolCallId);
65
+ }
66
+ }
67
+
68
+ const continuations: HarnessAgentToolApprovalContinuation[] = [];
69
+ for (const part of lastMessage.content) {
70
+ if (part.type !== 'tool-approval-response') continue;
71
+
72
+ const approvalRequest = approvalRequestsByApprovalId.get(part.approvalId);
73
+ if (approvalRequest == null) {
74
+ throw new HarnessError({
75
+ message: `Tool approval response '${part.approvalId}' does not match a prior tool approval request.`,
76
+ });
77
+ }
78
+ if (toolResultIds.has(approvalRequest.toolCallId)) continue;
79
+
80
+ const toolCall = toolCallsByToolCallId.get(approvalRequest.toolCallId);
81
+ if (toolCall == null) {
82
+ throw new HarnessError({
83
+ message: `Tool approval request '${approvalRequest.approvalId}' references unknown tool call '${approvalRequest.toolCallId}'.`,
84
+ });
85
+ }
86
+
87
+ continuations.push({
88
+ approvalResponse: part,
89
+ toolCall,
90
+ });
91
+ }
92
+
93
+ return continuations;
94
+ }