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