@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.
- package/CHANGELOG.md +110 -0
- package/LICENSE +13 -0
- package/README.md +142 -0
- package/agent/index.ts +47 -0
- package/bridge/index.ts +10 -0
- package/dist/agent/index.d.ts +1521 -0
- package/dist/agent/index.js +2958 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/bridge/index.d.ts +111 -0
- package/dist/bridge/index.js +415 -0
- package/dist/bridge/index.js.map +1 -0
- package/dist/index.d.ts +1536 -0
- package/dist/index.js +15834 -0
- package/dist/index.js.map +1 -0
- package/dist/utils/index.d.ts +225 -0
- package/dist/utils/index.js +12148 -0
- package/dist/utils/index.js.map +1 -0
- package/package.json +99 -1
- package/src/agent/harness-agent-session.ts +509 -0
- package/src/agent/harness-agent-settings.ts +131 -0
- package/src/agent/harness-agent-tool-approval-continuation.ts +94 -0
- package/src/agent/harness-agent-types.ts +50 -0
- package/src/agent/harness-agent.ts +819 -0
- package/src/agent/internal/bootstrap-recipe.ts +124 -0
- package/src/agent/internal/bridge-port-registry.ts +52 -0
- package/src/agent/internal/harness-stream-text-result.ts +720 -0
- package/src/agent/internal/lifecycle-state-validation.ts +95 -0
- package/src/agent/internal/permission-mode.ts +50 -0
- package/src/agent/internal/resolve-observability.ts +128 -0
- package/src/agent/internal/run-prompt.ts +813 -0
- package/src/agent/internal/strip-work-dir.ts +68 -0
- package/src/agent/internal/to-harness-stream.ts +75 -0
- package/src/agent/internal/translate-stream-part.ts +221 -0
- package/src/agent/internal/turn-telemetry.ts +359 -0
- package/src/agent/observability/file-reporter.ts +206 -0
- package/src/agent/observability/index.ts +15 -0
- package/src/agent/observability/trace-tree-reporter.ts +122 -0
- package/src/agent/observability/types.ts +86 -0
- package/src/agent/prewarm.ts +47 -0
- package/src/bridge/index.ts +702 -0
- package/src/errors/harness-capability-unsupported-error.ts +41 -0
- package/src/errors/harness-error.ts +22 -0
- package/src/index.ts +3 -0
- package/src/utils/bridge-ready.ts +277 -0
- package/src/utils/classify-disk-log.ts +43 -0
- package/src/utils/index.ts +15 -0
- package/src/utils/sandbox-channel.ts +453 -0
- package/src/v1/harness-v1-bootstrap.ts +46 -0
- package/src/v1/harness-v1-bridge-protocol.ts +310 -0
- package/src/v1/harness-v1-builtin-tool.ts +138 -0
- package/src/v1/harness-v1-call-warning.ts +22 -0
- package/src/v1/harness-v1-diagnostic.ts +66 -0
- package/src/v1/harness-v1-lifecycle-state.ts +65 -0
- package/src/v1/harness-v1-metadata.ts +13 -0
- package/src/v1/harness-v1-network-sandbox-session.ts +123 -0
- package/src/v1/harness-v1-observability.ts +20 -0
- package/src/v1/harness-v1-permission-mode.ts +11 -0
- package/src/v1/harness-v1-prompt-control.ts +41 -0
- package/src/v1/harness-v1-prompt.ts +11 -0
- package/src/v1/harness-v1-sandbox-provider.ts +76 -0
- package/src/v1/harness-v1-session.ts +272 -0
- package/src/v1/harness-v1-skill.ts +36 -0
- package/src/v1/harness-v1-stream-part.ts +363 -0
- package/src/v1/harness-v1-tool-spec.ts +31 -0
- package/src/v1/harness-v1.ts +83 -0
- package/src/v1/index.ts +93 -0
- 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>;
|