@ai-sdk/harness 0.0.0 → 1.0.0-beta.14

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