@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,901 @@
1
+ import {
2
+ getHarnessV1BuiltinToolFilteringDenialReason,
3
+ isHarnessV1BuiltinToolIncluded,
4
+ type HarnessV1,
5
+ type HarnessV1BuiltinToolFiltering,
6
+ type HarnessV1PendingToolApproval,
7
+ type HarnessV1Prompt,
8
+ type HarnessV1PromptControl,
9
+ type HarnessV1Session,
10
+ type HarnessV1StreamPart,
11
+ type HarnessV1ToolSpec,
12
+ } from '../../v1';
13
+ import { toHarnessStream } from './to-harness-stream';
14
+ import {
15
+ executeTool,
16
+ generateId,
17
+ isExecutableTool,
18
+ safeParseJSON,
19
+ type Context,
20
+ type Experimental_SandboxSession as SandboxSession,
21
+ type ToolSet,
22
+ } from '@ai-sdk/provider-utils';
23
+ import type {
24
+ LanguageModelV4FinishReason,
25
+ LanguageModelV4ToolCall,
26
+ LanguageModelV4Usage,
27
+ } from '@ai-sdk/provider';
28
+ import { parseToolCall } from 'ai/internal';
29
+ import type { ContentPart, TelemetryOptions, TextStreamPart } from 'ai';
30
+ import type { HarnessAgentToolApprovalContinuation } from '../harness-agent-tool-approval-continuation';
31
+ import type { HarnessAgentToolApprovalConfiguration } from '../harness-agent-settings';
32
+ import { HarnessStreamTextResult } from './harness-stream-text-result';
33
+ import { translateStreamPart } from './translate-stream-part';
34
+ import { stripWorkDir } from './strip-work-dir';
35
+ import { createTurnTelemetry, type TurnContentPart } from './turn-telemetry';
36
+ import { resolveCustomToolApproval } from './permission-mode';
37
+ import { logBridgeError } from '../../utils/bridge-diagnostics';
38
+
39
+ /**
40
+ * Drive one prompt turn end-to-end:
41
+ * - call `session.doPromptTurn` via `toHarnessStream`
42
+ * - translate harness events to AI SDK `TextStreamPart`s and push into the
43
+ * result object
44
+ * - execute host-side user tools when their `tool-call` events arrive and
45
+ * submit results back to the harness
46
+ * - close the result when the harness signals `finish` (or on error)
47
+ *
48
+ * Returns the result synchronously after the stream is wired up; callers
49
+ * await its `PromiseLike` accessors to observe completion.
50
+ */
51
+ export function runPrompt<
52
+ TOOLS extends ToolSet,
53
+ RUNTIME_CONTEXT extends Context,
54
+ >(input: {
55
+ harness: HarnessV1;
56
+ session: HarnessV1Session;
57
+ /**
58
+ * Turn entry point. `'prompt'` (default) starts a new turn from `prompt`;
59
+ * `'continue'` continues the in-flight turn via `doContinueTurn` and ignores
60
+ * `prompt`/`instructions`.
61
+ */
62
+ mode?: 'prompt' | 'continue';
63
+ /** Required for `mode: 'prompt'`; absent for `mode: 'continue'`. */
64
+ prompt?: HarnessV1Prompt;
65
+ instructions: string | undefined;
66
+ tools: TOOLS;
67
+ activeTools?: ToolSet;
68
+ toolSpecs: HarnessV1ToolSpec[];
69
+ builtinToolFiltering?: HarnessV1BuiltinToolFiltering | undefined;
70
+ sandboxSession: SandboxSession;
71
+ sessionWorkDir: string;
72
+ runtimeContext: RUNTIME_CONTEXT;
73
+ abortSignal: AbortSignal | undefined;
74
+ telemetry?: TelemetryOptions | undefined;
75
+ toolApproval?: HarnessAgentToolApprovalConfiguration | undefined;
76
+ pendingToolApprovals?: readonly HarnessV1PendingToolApproval[];
77
+ toolApprovalContinuations?:
78
+ | readonly HarnessAgentToolApprovalContinuation[]
79
+ | undefined;
80
+ onPendingToolApproval?: (approval: HarnessV1PendingToolApproval) => void;
81
+ onToolApprovalSettled?: (approvalId: string) => void;
82
+ onTurnFinished?: () => void;
83
+ }): {
84
+ result: HarnessStreamTextResult<TOOLS, RUNTIME_CONTEXT>;
85
+ done: Promise<void>;
86
+ } {
87
+ const result = new HarnessStreamTextResult<TOOLS, RUNTIME_CONTEXT>({
88
+ tools: input.tools,
89
+ runtimeContext: input.runtimeContext,
90
+ // toolsContext is not configurable for harnesses; pass undefined cast.
91
+ toolsContext: undefined as never,
92
+ harnessId: input.harness.harnessId,
93
+ sessionId: input.session.sessionId,
94
+ });
95
+ const pendingToolApprovals = input.pendingToolApprovals ?? [];
96
+ const onPendingToolApproval = input.onPendingToolApproval ?? (() => {});
97
+ const onToolApprovalSettled = input.onToolApprovalSettled ?? (() => {});
98
+ const activeTools = input.activeTools ?? input.tools;
99
+
100
+ const telemetry = createTurnTelemetry({
101
+ telemetry: input.telemetry,
102
+ harnessId: input.harness.harnessId,
103
+ modelId: input.session.modelId,
104
+ instructions: input.instructions,
105
+ promptText: input.prompt != null ? promptToText(input.prompt) : '',
106
+ runtimeContext: input.runtimeContext,
107
+ });
108
+
109
+ const done = (async () => {
110
+ let bridge: Awaited<ReturnType<typeof toHarnessStream>>;
111
+ try {
112
+ bridge = await toHarnessStream({
113
+ invoke:
114
+ input.mode === 'continue'
115
+ ? emit =>
116
+ input.session.doContinueTurn({
117
+ tools: input.toolSpecs,
118
+ abortSignal: input.abortSignal,
119
+ emit,
120
+ })
121
+ : emit => {
122
+ if (input.prompt == null) {
123
+ throw new Error(
124
+ 'runPrompt: `prompt` is required for mode "prompt".',
125
+ );
126
+ }
127
+ return input.session.doPromptTurn({
128
+ prompt: input.prompt,
129
+ tools: input.toolSpecs,
130
+ instructions: input.instructions,
131
+ abortSignal: input.abortSignal,
132
+ emit,
133
+ });
134
+ },
135
+ });
136
+ } catch (err) {
137
+ telemetry.error(err);
138
+ logBridgeError({
139
+ harnessId: input.harness.harnessId,
140
+ sessionId: input.session.sessionId,
141
+ context: 'failed to start harness turn',
142
+ error: err,
143
+ });
144
+ result.fail(err);
145
+ return;
146
+ }
147
+
148
+ const { stream, control } = bridge;
149
+ const reader = stream.getReader();
150
+ const toolCallsByToolCallId = new Map<string, ToolCallTextStreamPart>();
151
+ const rawToolCallsByToolCallId = new Map<
152
+ string,
153
+ Extract<HarnessV1StreamPart, { type: 'tool-call' }>
154
+ >();
155
+ const pendingApprovalsByApprovalId = new Map(
156
+ pendingToolApprovals.map(approval => [approval.approvalId, approval]),
157
+ );
158
+ const pendingApprovalsByToolCallId = new Map(
159
+ pendingToolApprovals.map(approval => [approval.toolCallId, approval]),
160
+ );
161
+ const continuationsByApprovalId = new Map(
162
+ (input.toolApprovalContinuations ?? []).map(continuation => [
163
+ continuation.approvalResponse.approvalId,
164
+ continuation,
165
+ ]),
166
+ );
167
+ const settledApprovalToolCallIds = new Set<string>();
168
+ let finalFinish:
169
+ | Extract<HarnessV1StreamPart, { type: 'finish' }>
170
+ | undefined;
171
+
172
+ // Accumulate the model's output content per step so telemetry can record
173
+ // `gen_ai.output.messages` and reporters can log what was actually said.
174
+ let stepText = '';
175
+ let stepReasoning = '';
176
+ let stepToolCalls: TurnContentPart[] = [];
177
+ const buildStepContent = (): TurnContentPart[] => {
178
+ const parts: TurnContentPart[] = [];
179
+ if (stepText) parts.push({ type: 'text', text: stepText });
180
+ if (stepReasoning) parts.push({ type: 'reasoning', text: stepReasoning });
181
+ parts.push(...stepToolCalls);
182
+ return parts;
183
+ };
184
+ const resetStepContent = (): void => {
185
+ stepText = '';
186
+ stepReasoning = '';
187
+ stepToolCalls = [];
188
+ };
189
+ const zeroUsage: LanguageModelV4Usage = {
190
+ inputTokens: {
191
+ total: undefined,
192
+ noCache: undefined,
193
+ cacheRead: undefined,
194
+ cacheWrite: undefined,
195
+ },
196
+ outputTokens: {
197
+ total: undefined,
198
+ text: undefined,
199
+ reasoning: undefined,
200
+ },
201
+ };
202
+ const toolCallsFinishReason: LanguageModelV4FinishReason = {
203
+ unified: 'tool-calls',
204
+ raw: undefined,
205
+ };
206
+ const finishForToolApprovalPause = async (): Promise<void> => {
207
+ telemetry.stepFinish({
208
+ finishReason: toolCallsFinishReason,
209
+ usage: zeroUsage,
210
+ content: buildStepContent(),
211
+ });
212
+ resetStepContent();
213
+ result.finishStep({
214
+ finishReason: toolCallsFinishReason,
215
+ usage: zeroUsage,
216
+ providerMetadata: undefined,
217
+ warnings: [],
218
+ });
219
+ telemetry.end({
220
+ finishReason: toolCallsFinishReason,
221
+ usage: zeroUsage,
222
+ });
223
+ await result.finish();
224
+ };
225
+ const enqueueApprovalRequest = (approval: {
226
+ approvalId: string;
227
+ toolCall: ToolCallTextStreamPart;
228
+ isAutomatic?: boolean;
229
+ }): void => {
230
+ result.enqueue({
231
+ type: 'tool-approval-request',
232
+ approvalId: approval.approvalId,
233
+ toolCall: approval.toolCall,
234
+ ...(approval.isAutomatic !== undefined
235
+ ? { isAutomatic: approval.isAutomatic }
236
+ : {}),
237
+ } as TextStreamPart<TOOLS>);
238
+ };
239
+ const enqueueAutomaticApprovalResponse = (input: {
240
+ approvalId: string;
241
+ toolCall: ToolCallTextStreamPart;
242
+ approved: boolean;
243
+ reason?: string;
244
+ providerExecuted?: boolean;
245
+ }): void => {
246
+ result.enqueue({
247
+ type: 'tool-approval-response',
248
+ approvalId: input.approvalId,
249
+ toolCall: input.toolCall,
250
+ approved: input.approved,
251
+ ...(input.reason !== undefined ? { reason: input.reason } : {}),
252
+ ...(input.providerExecuted !== undefined
253
+ ? { providerExecuted: input.providerExecuted }
254
+ : {}),
255
+ } as TextStreamPart<TOOLS>);
256
+ };
257
+ const enqueueApprovalResponse = (
258
+ approval: HarnessV1PendingToolApproval,
259
+ continuation: HarnessAgentToolApprovalContinuation,
260
+ ): void => {
261
+ result.enqueue({
262
+ type: 'tool-approval-response',
263
+ approvalId: approval.approvalId,
264
+ toolCall: continuation.toolCall,
265
+ approved: continuation.approvalResponse.approved,
266
+ ...(continuation.approvalResponse.reason !== undefined
267
+ ? { reason: continuation.approvalResponse.reason }
268
+ : {}),
269
+ ...(approval.providerExecuted !== undefined
270
+ ? { providerExecuted: approval.providerExecuted }
271
+ : {}),
272
+ } as TextStreamPart<TOOLS>);
273
+ };
274
+ const processPendingApprovalContinuation = async (
275
+ approval: HarnessV1PendingToolApproval,
276
+ continuation: HarnessAgentToolApprovalContinuation,
277
+ ): Promise<void> => {
278
+ enqueueApprovalResponse(approval, continuation);
279
+ onToolApprovalSettled(approval.approvalId);
280
+ pendingApprovalsByApprovalId.delete(approval.approvalId);
281
+ pendingApprovalsByToolCallId.delete(approval.toolCallId);
282
+ settledApprovalToolCallIds.add(approval.toolCallId);
283
+
284
+ if (approval.kind === 'builtin') {
285
+ if (control.submitToolApproval == null) {
286
+ throw new Error(
287
+ `Harness '${input.harness.harnessId}' emitted a built-in tool approval request but does not support approval responses.`,
288
+ );
289
+ }
290
+ await control.submitToolApproval({
291
+ approvalId: approval.approvalId,
292
+ approved: continuation.approvalResponse.approved,
293
+ reason: continuation.approvalResponse.reason,
294
+ });
295
+ return;
296
+ }
297
+
298
+ if (!continuation.approvalResponse.approved) {
299
+ await control.submitToolResult({
300
+ toolCallId: approval.toolCallId,
301
+ output: {
302
+ type: 'execution-denied',
303
+ reason: continuation.approvalResponse.reason,
304
+ },
305
+ });
306
+ return;
307
+ }
308
+
309
+ const rawToolCall =
310
+ rawToolCallsByToolCallId.get(approval.toolCallId) ??
311
+ ({
312
+ type: 'tool-call',
313
+ toolCallId: approval.toolCallId,
314
+ toolName: approval.toolName,
315
+ input: approval.input,
316
+ } satisfies Extract<HarnessV1StreamPart, { type: 'tool-call' }>);
317
+
318
+ const outcome = await maybeExecuteHostTool({
319
+ event: rawToolCall,
320
+ tools: activeTools,
321
+ sandboxSession: input.sandboxSession,
322
+ abortSignal: input.abortSignal,
323
+ control,
324
+ onPreliminaryResult: preliminaryOutput => {
325
+ const stripped = stripWorkDir(
326
+ {
327
+ type: 'tool-result',
328
+ toolCallId: rawToolCall.toolCallId,
329
+ toolName: rawToolCall.toolName,
330
+ result: preliminaryOutput as Extract<
331
+ HarnessV1StreamPart,
332
+ { type: 'tool-result' }
333
+ >['result'],
334
+ },
335
+ input.sessionWorkDir,
336
+ ) as Extract<HarnessV1StreamPart, { type: 'tool-result' }>;
337
+ result.enqueue({
338
+ type: 'tool-result',
339
+ toolCallId: rawToolCall.toolCallId,
340
+ toolName: rawToolCall.toolName,
341
+ input: undefined,
342
+ output: stripped.result,
343
+ preliminary: true,
344
+ } as TextStreamPart<TOOLS>);
345
+ },
346
+ });
347
+ telemetry.toolEnd(rawToolCall.toolCallId, outcome);
348
+ };
349
+
350
+ try {
351
+ for (const approval of pendingToolApprovals) {
352
+ const continuation = continuationsByApprovalId.get(approval.approvalId);
353
+ if (continuation != null) {
354
+ await processPendingApprovalContinuation(approval, continuation);
355
+ }
356
+ }
357
+
358
+ while (true) {
359
+ const { value, done } = await reader.read();
360
+ if (done) break;
361
+ if (value == null) continue;
362
+
363
+ // Begin the operation span on stream-start, using the runtime-resolved
364
+ // model the adapter reports (falling back to the session's model).
365
+ if (value.type === 'stream-start') {
366
+ telemetry.start(value.modelId ?? input.session.modelId);
367
+ }
368
+
369
+ // Open a step span lazily before the first content of each step.
370
+ if (
371
+ value.type !== 'stream-start' &&
372
+ value.type !== 'finish-step' &&
373
+ value.type !== 'finish' &&
374
+ value.type !== 'error'
375
+ ) {
376
+ telemetry.ensureStepOpen();
377
+ }
378
+
379
+ /*
380
+ * Strip the session working-directory prefix for everything the
381
+ * consumer sees. The original `value` is kept intact for host tool
382
+ * execution below — the tools need the absolute path to resolve
383
+ * against the sandbox root, so the strip is display-only.
384
+ */
385
+ const displayValue = stripWorkDir(value, input.sessionWorkDir);
386
+ const settledApprovalToolCallReplay =
387
+ displayValue.type === 'tool-call' &&
388
+ !displayValue.providerExecuted &&
389
+ settledApprovalToolCallIds.has(displayValue.toolCallId);
390
+
391
+ if (settledApprovalToolCallReplay) {
392
+ continue;
393
+ }
394
+
395
+ if (displayValue.type === 'tool-approval-request') {
396
+ const toolCall = toolCallsByToolCallId.get(displayValue.toolCallId);
397
+ if (toolCall == null) {
398
+ throw new Error(
399
+ `Harness '${input.harness.harnessId}' emitted approval request '${displayValue.approvalId}' for unknown tool call '${displayValue.toolCallId}'.`,
400
+ );
401
+ }
402
+ const rawToolCall = rawToolCallsByToolCallId.get(
403
+ displayValue.toolCallId,
404
+ );
405
+ const toolName = rawToolCall?.toolName ?? toolCall.toolName;
406
+ if (
407
+ !isHarnessV1BuiltinToolIncluded({
408
+ toolName,
409
+ toolFiltering: input.builtinToolFiltering,
410
+ })
411
+ ) {
412
+ if (control.submitToolApproval == null) {
413
+ throw new Error(
414
+ `Harness '${input.harness.harnessId}' emitted a built-in tool approval request but does not support approval responses.`,
415
+ );
416
+ }
417
+ await control.submitToolApproval({
418
+ approvalId: displayValue.approvalId,
419
+ approved: false,
420
+ reason: getHarnessV1BuiltinToolFilteringDenialReason({
421
+ toolName,
422
+ }),
423
+ });
424
+ continue;
425
+ }
426
+ }
427
+
428
+ // Forward to consumer as soon as possible.
429
+ for (const part of translateStreamPart<TOOLS>(displayValue)) {
430
+ result.enqueue(part);
431
+ }
432
+
433
+ // Tool-call validation lives here (not in translateStreamPart) because
434
+ // schema parsing is async and needs the merged tool set in scope.
435
+ if (displayValue.type === 'tool-call') {
436
+ const parsed = await validateToolCall<TOOLS>({
437
+ event: displayValue,
438
+ tools: input.tools,
439
+ });
440
+ const parsedToolCall = asToolCallTextStreamPart({ part: parsed });
441
+ rawToolCallsByToolCallId.set(displayValue.toolCallId, displayValue);
442
+ toolCallsByToolCallId.set(displayValue.toolCallId, parsedToolCall);
443
+ result.enqueue(parsed);
444
+ }
445
+
446
+ // Accumulate output content for telemetry / reporters.
447
+ if (value.type === 'text-delta') {
448
+ stepText += value.delta;
449
+ } else if (value.type === 'reasoning-delta') {
450
+ stepReasoning += value.delta;
451
+ }
452
+
453
+ // Telemetry: a tool execution begins on its `tool-call`.
454
+ if (value.type === 'tool-call') {
455
+ stepToolCalls.push({
456
+ type: 'tool-call',
457
+ toolCallId: value.toolCallId,
458
+ toolName: value.toolName,
459
+ input: value.input,
460
+ });
461
+ telemetry.toolStart({
462
+ toolCallId: value.toolCallId,
463
+ toolName: value.toolName,
464
+ input: value.input,
465
+ });
466
+ }
467
+
468
+ // Telemetry: close a tool span when its provider-executed result lands.
469
+ if (value.type === 'tool-result') {
470
+ telemetry.toolEnd(
471
+ value.toolCallId,
472
+ value.isError
473
+ ? { ok: false, error: value.result }
474
+ : { ok: true, output: value.result },
475
+ );
476
+ }
477
+
478
+ if (value.type === 'tool-approval-request') {
479
+ const toolCall = toolCallsByToolCallId.get(value.toolCallId);
480
+ if (toolCall == null) {
481
+ throw new Error(
482
+ `Harness '${input.harness.harnessId}' emitted approval request '${value.approvalId}' for unknown tool call '${value.toolCallId}'.`,
483
+ );
484
+ }
485
+
486
+ const rawToolCall = rawToolCallsByToolCallId.get(value.toolCallId);
487
+ const pendingApproval =
488
+ pendingApprovalsByApprovalId.get(value.approvalId) ??
489
+ ({
490
+ approvalId: value.approvalId,
491
+ toolCallId: value.toolCallId,
492
+ toolName: toolCall.toolName,
493
+ input: rawToolCall?.input ?? JSON.stringify(toolCall.input),
494
+ kind: 'builtin',
495
+ providerExecuted: rawToolCall?.providerExecuted ?? true,
496
+ ...(rawToolCall?.nativeName !== undefined
497
+ ? { nativeName: rawToolCall.nativeName }
498
+ : {}),
499
+ } satisfies HarnessV1PendingToolApproval);
500
+ pendingApprovalsByApprovalId.set(
501
+ pendingApproval.approvalId,
502
+ pendingApproval,
503
+ );
504
+ pendingApprovalsByToolCallId.set(
505
+ pendingApproval.toolCallId,
506
+ pendingApproval,
507
+ );
508
+
509
+ const continuation = continuationsByApprovalId.get(
510
+ pendingApproval.approvalId,
511
+ );
512
+ if (continuation != null) {
513
+ await processPendingApprovalContinuation(
514
+ pendingApproval,
515
+ continuation,
516
+ );
517
+ continue;
518
+ }
519
+
520
+ onPendingToolApproval(pendingApproval);
521
+ enqueueApprovalRequest({
522
+ approvalId: pendingApproval.approvalId,
523
+ toolCall,
524
+ });
525
+ await finishForToolApprovalPause();
526
+ return;
527
+ }
528
+
529
+ // Drive step boundaries.
530
+ if (value.type === 'finish-step') {
531
+ telemetry.stepFinish({
532
+ finishReason: value.finishReason,
533
+ usage: value.usage,
534
+ providerMetadata: value.harnessMetadata,
535
+ content: buildStepContent(),
536
+ });
537
+ resetStepContent();
538
+ result.finishStep({
539
+ finishReason: value.finishReason,
540
+ usage: value.usage,
541
+ providerMetadata: value.harnessMetadata,
542
+ warnings: [],
543
+ });
544
+ }
545
+
546
+ if (value.type === 'finish') {
547
+ finalFinish = value;
548
+ telemetry.end({
549
+ finishReason: value.finishReason,
550
+ usage: value.totalUsage,
551
+ });
552
+ }
553
+
554
+ // Execute host-side tools when the harness asks for one.
555
+ if (value.type === 'tool-call' && !value.providerExecuted) {
556
+ const toolCall = value;
557
+ const parsedToolCall = toolCallsByToolCallId.get(toolCall.toolCallId);
558
+ if (parsedToolCall == null) {
559
+ throw new Error(
560
+ `Harness '${input.harness.harnessId}' could not find parsed tool call '${toolCall.toolCallId}' for custom tool approval.`,
561
+ );
562
+ }
563
+ if (!hasTool({ tools: activeTools, toolName: toolCall.toolName })) {
564
+ const output = {
565
+ type: 'execution-denied',
566
+ reason: getHarnessV1BuiltinToolFilteringDenialReason({
567
+ toolName: toolCall.toolName,
568
+ }),
569
+ };
570
+ await control.submitToolResult({
571
+ toolCallId: toolCall.toolCallId,
572
+ output,
573
+ });
574
+ telemetry.toolEnd(toolCall.toolCallId, { ok: true, output });
575
+ continue;
576
+ }
577
+ const customToolApprovalDecision = resolveCustomToolApproval({
578
+ toolName: toolCall.toolName,
579
+ toolApproval: input.toolApproval,
580
+ });
581
+ if (customToolApprovalDecision.type === 'deny') {
582
+ const approvalId = generateId();
583
+ enqueueApprovalRequest({
584
+ approvalId,
585
+ toolCall: parsedToolCall,
586
+ isAutomatic: true,
587
+ });
588
+ enqueueAutomaticApprovalResponse({
589
+ approvalId,
590
+ toolCall: parsedToolCall,
591
+ approved: false,
592
+ reason: customToolApprovalDecision.reason,
593
+ providerExecuted: false,
594
+ });
595
+ const output = {
596
+ type: 'execution-denied',
597
+ reason: customToolApprovalDecision.reason,
598
+ };
599
+ await control.submitToolResult({
600
+ toolCallId: toolCall.toolCallId,
601
+ output,
602
+ });
603
+ telemetry.toolEnd(toolCall.toolCallId, { ok: true, output });
604
+ continue;
605
+ }
606
+ const pendingApproval =
607
+ pendingApprovalsByToolCallId.get(toolCall.toolCallId) ??
608
+ (customToolApprovalDecision.type === 'request'
609
+ ? ({
610
+ approvalId: generateId(),
611
+ toolCallId: toolCall.toolCallId,
612
+ toolName: toolCall.toolName,
613
+ input: toolCall.input,
614
+ kind: 'custom',
615
+ providerExecuted: false,
616
+ ...(toolCall.nativeName !== undefined
617
+ ? { nativeName: toolCall.nativeName }
618
+ : {}),
619
+ } satisfies HarnessV1PendingToolApproval)
620
+ : undefined);
621
+ if (pendingApproval != null) {
622
+ pendingApprovalsByApprovalId.set(
623
+ pendingApproval.approvalId,
624
+ pendingApproval,
625
+ );
626
+ pendingApprovalsByToolCallId.set(
627
+ pendingApproval.toolCallId,
628
+ pendingApproval,
629
+ );
630
+ const continuation = continuationsByApprovalId.get(
631
+ pendingApproval.approvalId,
632
+ );
633
+ if (continuation != null) {
634
+ await processPendingApprovalContinuation(
635
+ pendingApproval,
636
+ continuation,
637
+ );
638
+ continue;
639
+ }
640
+ const pendingParsedToolCall = toolCallsByToolCallId.get(
641
+ pendingApproval.toolCallId,
642
+ );
643
+ if (pendingParsedToolCall == null) {
644
+ throw new Error(
645
+ `Harness '${input.harness.harnessId}' could not find parsed tool call '${pendingApproval.toolCallId}' for approval request '${pendingApproval.approvalId}'.`,
646
+ );
647
+ }
648
+ onPendingToolApproval(pendingApproval);
649
+ enqueueApprovalRequest({
650
+ approvalId: pendingApproval.approvalId,
651
+ toolCall: pendingParsedToolCall,
652
+ });
653
+ await finishForToolApprovalPause();
654
+ return;
655
+ }
656
+ const outcome = await maybeExecuteHostTool({
657
+ event: toolCall,
658
+ tools: activeTools,
659
+ sandboxSession: input.sandboxSession,
660
+ abortSignal: input.abortSignal,
661
+ control,
662
+ onPreliminaryResult: preliminaryOutput => {
663
+ /*
664
+ * Project a `yield`ed value as a preliminary AI SDK
665
+ * `tool-result` part. Unlike the final result — which is
666
+ * submitted to the runtime, echoed back as a `tool-result`
667
+ * event, and stripped on its way through the loop above —
668
+ * preliminary values never reach the runtime, so strip the
669
+ * working directory here to match the final result's projection.
670
+ */
671
+ const stripped = stripWorkDir(
672
+ {
673
+ type: 'tool-result',
674
+ toolCallId: toolCall.toolCallId,
675
+ toolName: toolCall.toolName,
676
+ result: preliminaryOutput as Extract<
677
+ HarnessV1StreamPart,
678
+ { type: 'tool-result' }
679
+ >['result'],
680
+ },
681
+ input.sessionWorkDir,
682
+ ) as Extract<HarnessV1StreamPart, { type: 'tool-result' }>;
683
+ result.enqueue({
684
+ type: 'tool-result',
685
+ toolCallId: toolCall.toolCallId,
686
+ toolName: toolCall.toolName,
687
+ input: undefined,
688
+ output: stripped.result,
689
+ preliminary: true,
690
+ } as TextStreamPart<TOOLS>);
691
+ },
692
+ });
693
+ telemetry.toolEnd(toolCall.toolCallId, outcome);
694
+ }
695
+
696
+ if (value.type === 'error') {
697
+ telemetry.error(value.error);
698
+ logBridgeError({
699
+ harnessId: input.harness.harnessId,
700
+ sessionId: input.session.sessionId,
701
+ context: 'harness stream error',
702
+ error: value.error,
703
+ });
704
+ result.fail(value.error);
705
+ return;
706
+ }
707
+ }
708
+ input.onTurnFinished?.();
709
+ await result.finish(
710
+ finalFinish
711
+ ? {
712
+ finishReason: finalFinish.finishReason,
713
+ totalUsage: finalFinish.totalUsage,
714
+ providerMetadata: finalFinish.harnessMetadata,
715
+ }
716
+ : undefined,
717
+ );
718
+ } catch (err) {
719
+ telemetry.error(err);
720
+ logBridgeError({
721
+ harnessId: input.harness.harnessId,
722
+ sessionId: input.session.sessionId,
723
+ context: 'harness turn failed',
724
+ error: err,
725
+ });
726
+ result.fail(err);
727
+ } finally {
728
+ reader.releaseLock();
729
+ }
730
+ })();
731
+
732
+ // Swallow the loop's rejection at the top level — failures are observable
733
+ // via the result's `fullStream` `error` part and rejected promise
734
+ // accessors. We do not want the orphan promise to become an unhandled
735
+ // rejection.
736
+ done.catch(() => {});
737
+
738
+ return { result, done };
739
+ }
740
+
741
+ type HostToolOutcome =
742
+ | { ok: true; output: unknown }
743
+ | { ok: false; error: unknown };
744
+
745
+ function asToolCallTextStreamPart<TOOLS extends ToolSet>(input: {
746
+ part: TextStreamPart<TOOLS>;
747
+ }): ToolCallTextStreamPart {
748
+ if (input.part.type !== 'tool-call') {
749
+ throw new Error(
750
+ `Expected parsed tool-call stream part, got '${input.part.type}'.`,
751
+ );
752
+ }
753
+ return input.part as ToolCallTextStreamPart;
754
+ }
755
+
756
+ type ToolCallTextStreamPart = {
757
+ readonly type: 'tool-call';
758
+ readonly toolCallId: string;
759
+ readonly toolName: string;
760
+ readonly input: unknown;
761
+ readonly providerExecuted?: boolean;
762
+ readonly providerMetadata?: unknown;
763
+ readonly dynamic?: boolean;
764
+ readonly invalid?: boolean;
765
+ readonly error?: unknown;
766
+ readonly title?: string;
767
+ };
768
+
769
+ function hasTool(input: { tools: ToolSet; toolName: string }): boolean {
770
+ return Object.prototype.hasOwnProperty.call(input.tools, input.toolName);
771
+ }
772
+
773
+ async function maybeExecuteHostTool<TOOLS extends ToolSet>(input: {
774
+ event: { toolCallId: string; toolName: string; input: string };
775
+ tools: TOOLS;
776
+ sandboxSession: SandboxSession;
777
+ abortSignal: AbortSignal | undefined;
778
+ control: HarnessV1PromptControl;
779
+ /**
780
+ * Called for each value a generator `execute` `yield`s before its last. The
781
+ * caller surfaces these as preliminary `tool-result` parts on the consumer
782
+ * stream. Never called for a plain (non-generator) `execute`.
783
+ */
784
+ onPreliminaryResult: (output: unknown) => void;
785
+ }): Promise<HostToolOutcome> {
786
+ const tool = input.tools[input.event.toolName];
787
+
788
+ if (!isExecutableTool(tool)) return { ok: true, output: undefined };
789
+
790
+ const parsed = await safeParseJSON({ text: input.event.input });
791
+ const args = parsed.success ? parsed.value : input.event.input;
792
+
793
+ try {
794
+ /*
795
+ * Normalize the tool's return value through `executeTool`, the same helper
796
+ * the non-harness AI SDK uses, so generator `execute` functions behave
797
+ * identically here: each `yield`ed value arrives as a `preliminary` part
798
+ * and the last `yield` is re-emitted as the `final` part; a plain value or
799
+ * Promise arrives as a single `final` part. The underlying runtimes accept
800
+ * exactly one tool result per call, so only the final value is submitted
801
+ * back to the model — preliminary values are surfaced to the consumer
802
+ * stream alone, matching how the AI SDK treats `onPreliminaryToolResult`.
803
+ */
804
+ let output: unknown;
805
+ const stream = executeTool({
806
+ tool,
807
+ input: args as never,
808
+ options: {
809
+ toolCallId: input.event.toolCallId,
810
+ messages: [],
811
+ abortSignal: input.abortSignal,
812
+ context: undefined as never,
813
+ experimental_sandbox: input.sandboxSession,
814
+ },
815
+ });
816
+ for await (const part of stream) {
817
+ if (part.type === 'preliminary') {
818
+ input.onPreliminaryResult(part.output);
819
+ } else {
820
+ output = part.output;
821
+ }
822
+ }
823
+
824
+ await input.control.submitToolResult({
825
+ toolCallId: input.event.toolCallId,
826
+ output,
827
+ });
828
+ return { ok: true, output };
829
+ } catch (err) {
830
+ await input.control.submitToolResult({
831
+ toolCallId: input.event.toolCallId,
832
+ output: { error: String(err) },
833
+ isError: true,
834
+ });
835
+ return { ok: false, error: err };
836
+ }
837
+ }
838
+
839
+ /*
840
+ * Validate an inbound `tool-call` event against the merged tool set's schema
841
+ * using the AI SDK's canonical `parseToolCall`. Returns an AI SDK `tool-call`
842
+ * stream part with parsed input on success, or a `dynamic + invalid: true`
843
+ * part on failure (unknown tool, schema mismatch, malformed JSON).
844
+ *
845
+ * The harness `tool-call` event is structurally a `LanguageModelV4ToolCall`
846
+ * (plus an optional harness-only `nativeName`). `providerExecuted` already
847
+ * lives on the V4 type — `true` for adapter builtins (Claude Code's `Bash`,
848
+ * Codex's `shell`), false/undefined for host tools — and is passed through
849
+ * to the AI SDK part by `parseToolCall`.
850
+ */
851
+ export async function validateToolCall<TOOLS extends ToolSet>(args: {
852
+ event: Extract<HarnessV1StreamPart, { type: 'tool-call' }>;
853
+ tools: TOOLS;
854
+ }): Promise<TextStreamPart<TOOLS>> {
855
+ const { event, tools } = args;
856
+ const toolCall: LanguageModelV4ToolCall = {
857
+ type: 'tool-call',
858
+ toolCallId: event.toolCallId,
859
+ toolName: event.toolName,
860
+ input: event.input,
861
+ ...(event.providerExecuted !== undefined
862
+ ? { providerExecuted: event.providerExecuted }
863
+ : {}),
864
+ ...(event.providerMetadata !== undefined
865
+ ? { providerMetadata: event.providerMetadata }
866
+ : {}),
867
+ };
868
+
869
+ const parsed = await parseToolCall<TOOLS>({
870
+ toolCall,
871
+ tools,
872
+ repairToolCall: undefined,
873
+ refineToolInput: undefined,
874
+ instructions: undefined,
875
+ messages: [],
876
+ });
877
+
878
+ return parsed as TextStreamPart<TOOLS>;
879
+ }
880
+
881
+ /** Best-effort plain text of the turn's prompt, for telemetry input messages. */
882
+ function promptToText(prompt: HarnessV1Prompt): string {
883
+ if (typeof prompt === 'string') return prompt;
884
+ const content = (prompt as { content?: unknown }).content;
885
+ if (typeof content === 'string') return content;
886
+ if (Array.isArray(content)) {
887
+ return content
888
+ .filter(
889
+ (part): part is { type: 'text'; text: string } =>
890
+ typeof part === 'object' &&
891
+ part != null &&
892
+ (part as { type?: unknown }).type === 'text',
893
+ )
894
+ .map(part => part.text)
895
+ .join('');
896
+ }
897
+ return '';
898
+ }
899
+
900
+ // keep import bound so unused-but-needed type stays cited
901
+ export type _ContentPartMarker<T extends ToolSet> = ContentPart<T>;