@ai-sdk/workflow-harness 1.0.44 → 1.0.46
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 +13 -0
- package/README.md +78 -17
- package/dist/index.d.ts +52 -29
- package/dist/index.js +99 -39
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/harness-workflow-state.ts +18 -16
- package/src/index.ts +11 -1
- package/src/run-harness-agent-slice.ts +24 -584
- package/src/run-harness-agent-step.ts +24 -0
- package/src/run-harness-agent-time-slice.ts +37 -0
- package/src/run-harness-agent.ts +631 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HarnessV1ContinueTurnState,
|
|
3
|
+
HarnessV1Prompt,
|
|
4
|
+
HarnessV1ResumeSessionState,
|
|
5
|
+
} from '@ai-sdk/harness';
|
|
6
|
+
import type { HarnessAgentSession } from '@ai-sdk/harness/agent';
|
|
7
|
+
import type {
|
|
8
|
+
HarnessWorkflowModelMessage,
|
|
9
|
+
HarnessWorkflowSerializedChunk,
|
|
10
|
+
HarnessWorkflowState,
|
|
11
|
+
HarnessWorkflowStreamContext,
|
|
12
|
+
HarnessWorkflowUsageSummary,
|
|
13
|
+
} from './harness-workflow-state';
|
|
14
|
+
|
|
15
|
+
/** The non-string arm of {@link HarnessV1Prompt} — a single `UserModelMessage`. */
|
|
16
|
+
type HarnessV1UserMessage = Exclude<HarnessV1Prompt, string>;
|
|
17
|
+
|
|
18
|
+
/** A UI-message-stream chunk. Kept structural so this package need not depend on `ai`. */
|
|
19
|
+
export interface HarnessWorkflowChunk {
|
|
20
|
+
readonly type: string;
|
|
21
|
+
readonly [key: string]: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The subset of a harness `stream()` / `continueStream()` result the runner uses.
|
|
26
|
+
* `StreamTextResult` satisfies it structurally.
|
|
27
|
+
*/
|
|
28
|
+
export interface HarnessWorkflowStreamResult {
|
|
29
|
+
toUIMessageStream(): ReadableStream<HarnessWorkflowChunk>;
|
|
30
|
+
readonly finishReason: PromiseLike<unknown>;
|
|
31
|
+
readonly totalUsage: PromiseLike<unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The subset of `HarnessAgent` the runner drives. Declared structurally so
|
|
36
|
+
* the engine is decoupled from the concrete agent generics and easy to mock.
|
|
37
|
+
*/
|
|
38
|
+
export interface HarnessWorkflowAgent {
|
|
39
|
+
createSession(options?: {
|
|
40
|
+
sessionId?: string;
|
|
41
|
+
resumeFrom?: HarnessV1ResumeSessionState;
|
|
42
|
+
continueFrom?: HarnessV1ContinueTurnState;
|
|
43
|
+
}): Promise<HarnessAgentSession>;
|
|
44
|
+
stream(
|
|
45
|
+
options:
|
|
46
|
+
| {
|
|
47
|
+
session: HarnessAgentSession;
|
|
48
|
+
/**
|
|
49
|
+
* The new user turn. A string or an array of user messages — the shape
|
|
50
|
+
* `HarnessAgent.stream` accepts (it collapses an array to its last user
|
|
51
|
+
* entry). The engine passes the run's single {@link HarnessV1Prompt}.
|
|
52
|
+
*/
|
|
53
|
+
prompt: string | HarnessV1UserMessage[];
|
|
54
|
+
messages?: undefined;
|
|
55
|
+
}
|
|
56
|
+
| {
|
|
57
|
+
session: HarnessAgentSession;
|
|
58
|
+
prompt?: undefined;
|
|
59
|
+
messages: HarnessWorkflowModelMessage[];
|
|
60
|
+
},
|
|
61
|
+
): Promise<HarnessWorkflowStreamResult>;
|
|
62
|
+
continueStream(options: {
|
|
63
|
+
session: HarnessAgentSession;
|
|
64
|
+
}): Promise<HarnessWorkflowStreamResult>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface RunHarnessAgentOptions {
|
|
68
|
+
readonly agent: HarnessWorkflowAgent;
|
|
69
|
+
readonly state: HarnessWorkflowState;
|
|
70
|
+
readonly timeSliceSeconds?: number;
|
|
71
|
+
/**
|
|
72
|
+
* When the turn finishes, whether to destroy the sandbox. Defaults to `false`:
|
|
73
|
+
* the session is parked or stopped and a fresh resume state is returned in
|
|
74
|
+
* `resumeFrom`, so the next user turn reattaches to the same conversation
|
|
75
|
+
* (multi-turn chat). Set `true` for a one-shot run that should release the
|
|
76
|
+
* sandbox when the turn completes.
|
|
77
|
+
*/
|
|
78
|
+
readonly destroyOnFinish?: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Where to write the turn's UI-message chunks. Defaults to the workflow's
|
|
81
|
+
* output stream (`getWritable()` from `workflow`). Inject a stream in tests
|
|
82
|
+
* to run the engine without a workflow runtime.
|
|
83
|
+
*/
|
|
84
|
+
readonly writable?: WritableStream<HarnessWorkflowChunk>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Run one durable execution of a harness agent turn.
|
|
89
|
+
*
|
|
90
|
+
* Intended to be the body of a consumer's `'use step'`: it resumes (or starts)
|
|
91
|
+
* the session and streams the turn's chunks to the workflow output. When
|
|
92
|
+
* `timeSliceSeconds` is present, it also races the turn against that wall-clock
|
|
93
|
+
* budget and suspends the turn when the time slice completes.
|
|
94
|
+
*
|
|
95
|
+
* The returned {@link HarnessWorkflowState} is serializable and is meant to be
|
|
96
|
+
* the step's return value — the Workflow DevKit persists it as the durable
|
|
97
|
+
* checkpoint between workflow steps.
|
|
98
|
+
*/
|
|
99
|
+
export async function runHarnessAgent(
|
|
100
|
+
options: RunHarnessAgentOptions,
|
|
101
|
+
): Promise<HarnessWorkflowState> {
|
|
102
|
+
const { agent, state } = options;
|
|
103
|
+
const destroyOnFinish = options.destroyOnFinish ?? false;
|
|
104
|
+
|
|
105
|
+
const session =
|
|
106
|
+
state.continueFrom != null
|
|
107
|
+
? await agent.createSession({
|
|
108
|
+
sessionId: state.sessionId,
|
|
109
|
+
continueFrom: state.continueFrom,
|
|
110
|
+
})
|
|
111
|
+
: state.resumeFrom != null
|
|
112
|
+
? await agent.createSession({
|
|
113
|
+
sessionId: state.sessionId,
|
|
114
|
+
resumeFrom: state.resumeFrom,
|
|
115
|
+
})
|
|
116
|
+
: await agent.createSession({ sessionId: state.sessionId });
|
|
117
|
+
|
|
118
|
+
let result: HarnessWorkflowStreamResult;
|
|
119
|
+
try {
|
|
120
|
+
result =
|
|
121
|
+
state.messages != null
|
|
122
|
+
? await agent.stream({
|
|
123
|
+
session,
|
|
124
|
+
messages: state.messages,
|
|
125
|
+
})
|
|
126
|
+
: state.continueFrom != null
|
|
127
|
+
? await agent.continueStream({ session })
|
|
128
|
+
: await agent.stream({
|
|
129
|
+
session,
|
|
130
|
+
prompt:
|
|
131
|
+
typeof state.prompt === 'string'
|
|
132
|
+
? state.prompt
|
|
133
|
+
: [state.prompt],
|
|
134
|
+
});
|
|
135
|
+
} catch (err) {
|
|
136
|
+
await destroyQuietly(session);
|
|
137
|
+
return {
|
|
138
|
+
sessionId: state.sessionId,
|
|
139
|
+
prompt: state.prompt,
|
|
140
|
+
status: 'failed',
|
|
141
|
+
...(state.resumeFrom != null ? { resumeFrom: state.resumeFrom } : {}),
|
|
142
|
+
...(state.continueFrom != null
|
|
143
|
+
? { continueFrom: state.continueFrom }
|
|
144
|
+
: {}),
|
|
145
|
+
error: errorMessage(err),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const writable = options.writable ?? (await resolveWorkflowWritable());
|
|
150
|
+
const writer = writable.getWriter();
|
|
151
|
+
const streamContext = createMutableStreamContext(state.streamContext);
|
|
152
|
+
const executionPartState = createExecutionPartState();
|
|
153
|
+
|
|
154
|
+
let suspendPromise: Promise<HarnessV1ContinueTurnState> | undefined;
|
|
155
|
+
const timer =
|
|
156
|
+
options.timeSliceSeconds == null
|
|
157
|
+
? undefined
|
|
158
|
+
: setTimeout(() => {
|
|
159
|
+
suspendPromise = session.suspendTurn();
|
|
160
|
+
}, options.timeSliceSeconds * 1000);
|
|
161
|
+
(timer as { unref?: () => void } | undefined)?.unref?.();
|
|
162
|
+
|
|
163
|
+
let sawError = false;
|
|
164
|
+
// Tracks whether the writable was closed (only on a finished turn). The
|
|
165
|
+
// `finally` then releases the lock only when we did NOT close, since closing
|
|
166
|
+
// already releases it.
|
|
167
|
+
let writerClosed = false;
|
|
168
|
+
try {
|
|
169
|
+
const reader = result.toUIMessageStream().getReader();
|
|
170
|
+
try {
|
|
171
|
+
while (true) {
|
|
172
|
+
const { value, done } = await reader.read();
|
|
173
|
+
if (done) break;
|
|
174
|
+
if (value == null) continue;
|
|
175
|
+
/*
|
|
176
|
+
* One continuous assistant message per user turn: the execution that
|
|
177
|
+
* starts the turn keeps the opening `start`; executions that continue
|
|
178
|
+
* an already suspended turn drop it. Intermediate `finish` chunks are
|
|
179
|
+
* dropped and the loop writes a single terminal `finish` itself when
|
|
180
|
+
* the run completes. A new user turn keeps its `start` so the UI
|
|
181
|
+
* renders it as a fresh assistant message.
|
|
182
|
+
*/
|
|
183
|
+
if (value.type === 'start' && state.continueFrom != null) continue;
|
|
184
|
+
if (value.type === 'finish') continue;
|
|
185
|
+
if (value.type === 'error') {
|
|
186
|
+
const errorText = (value as { errorText?: unknown }).errorText;
|
|
187
|
+
/*
|
|
188
|
+
* When a suspend is in flight we tore the turn down at the execution
|
|
189
|
+
* boundary, so an *abort* error is the expected consequence —
|
|
190
|
+
* swallow it (surfacing it would replace the streamed content with
|
|
191
|
+
* an error, and the next execution continues the turn). Any
|
|
192
|
+
* non-abort error is unanticipated and must NOT be silenced: surface
|
|
193
|
+
* it and fail the execution, even mid-suspend.
|
|
194
|
+
*/
|
|
195
|
+
if (suspendPromise != null && isAbortError(errorText)) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
sawError = true;
|
|
199
|
+
}
|
|
200
|
+
await writeWorkflowChunk({
|
|
201
|
+
chunk: value,
|
|
202
|
+
writer,
|
|
203
|
+
streamContext,
|
|
204
|
+
executionPartState,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
} finally {
|
|
208
|
+
if (timer != null) clearTimeout(timer);
|
|
209
|
+
reader.releaseLock();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/*
|
|
213
|
+
* A non-abort error (anything `sawError` survived the abort filter for) is
|
|
214
|
+
* a real failure and takes priority over a completed time slice. Abort
|
|
215
|
+
* errors during suspension were already filtered above.
|
|
216
|
+
*/
|
|
217
|
+
if (sawError) {
|
|
218
|
+
if (suspendPromise != null) await suspendPromise.catch(() => {});
|
|
219
|
+
await destroyQuietly(session);
|
|
220
|
+
return {
|
|
221
|
+
sessionId: state.sessionId,
|
|
222
|
+
prompt: state.prompt,
|
|
223
|
+
status: 'failed',
|
|
224
|
+
...(state.resumeFrom != null ? { resumeFrom: state.resumeFrom } : {}),
|
|
225
|
+
...(state.continueFrom != null
|
|
226
|
+
? { continueFrom: state.continueFrom }
|
|
227
|
+
: {}),
|
|
228
|
+
error: 'harness turn emitted an error',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/*
|
|
233
|
+
* The time slice completed: the turn keeps running in the sandbox. Persist
|
|
234
|
+
* the cursor so the next time slice attaches to the same in-flight turn.
|
|
235
|
+
*/
|
|
236
|
+
if (suspendPromise != null) {
|
|
237
|
+
const continueFrom = await suspendPromise;
|
|
238
|
+
await closeOpenExecutionParts({
|
|
239
|
+
writer,
|
|
240
|
+
streamContext,
|
|
241
|
+
executionPartState,
|
|
242
|
+
});
|
|
243
|
+
return {
|
|
244
|
+
sessionId: state.sessionId,
|
|
245
|
+
prompt: state.prompt,
|
|
246
|
+
status: 'ready_for_next_step',
|
|
247
|
+
continueFrom,
|
|
248
|
+
...serializeStreamContextField(streamContext),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const [finishReason, usage] = await Promise.all([
|
|
253
|
+
Promise.resolve(result.finishReason).catch(() => undefined),
|
|
254
|
+
Promise.resolve(result.totalUsage).catch(() => undefined),
|
|
255
|
+
]);
|
|
256
|
+
const normalizedFinishReason = toFinishReasonString(finishReason);
|
|
257
|
+
|
|
258
|
+
if (session.hasUnfinishedTurn()) {
|
|
259
|
+
const continueFrom = await session.suspendTurn();
|
|
260
|
+
|
|
261
|
+
if (!hasPendingHostInput(continueFrom)) {
|
|
262
|
+
await closeOpenExecutionParts({
|
|
263
|
+
writer,
|
|
264
|
+
streamContext,
|
|
265
|
+
executionPartState,
|
|
266
|
+
});
|
|
267
|
+
return {
|
|
268
|
+
sessionId: state.sessionId,
|
|
269
|
+
prompt: state.prompt,
|
|
270
|
+
status: 'ready_for_next_step',
|
|
271
|
+
continueFrom,
|
|
272
|
+
...serializeStreamContextField(streamContext),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
await writer.write({
|
|
277
|
+
type: 'finish',
|
|
278
|
+
finishReason: normalizedFinishReason,
|
|
279
|
+
});
|
|
280
|
+
await writer.close();
|
|
281
|
+
writerClosed = true;
|
|
282
|
+
return {
|
|
283
|
+
sessionId: state.sessionId,
|
|
284
|
+
prompt: state.prompt,
|
|
285
|
+
status: 'awaiting_tool_approval',
|
|
286
|
+
continueFrom,
|
|
287
|
+
resumeFrom: toResumeState({ continueFrom }),
|
|
288
|
+
finalResult: {
|
|
289
|
+
sessionId: state.sessionId,
|
|
290
|
+
finishReason: normalizedFinishReason,
|
|
291
|
+
usage: toUsageSummary(usage),
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// The turn finished on its own: write the single terminal `finish` for the
|
|
297
|
+
// UI message, then CLOSE the writable. Closing matters: the workflow output
|
|
298
|
+
// stream (`getWritable()`) is what the run's `readable` is fed from, and the
|
|
299
|
+
// DevKit only marks that run stream "done" when the writable is closed — a
|
|
300
|
+
// released-but-open writer leaves it open forever, so a consumer piping
|
|
301
|
+
// `run.readable` into a UI-message-stream response never receives the
|
|
302
|
+
// terminal close and the client stays "streaming" indefinitely.
|
|
303
|
+
// `ready_for_next_step` and `failed` deliberately do NOT close — another
|
|
304
|
+
// execution keeps writing, or the failure propagates.
|
|
305
|
+
await writer.write({ type: 'finish' });
|
|
306
|
+
await writer.close();
|
|
307
|
+
writerClosed = true;
|
|
308
|
+
|
|
309
|
+
/*
|
|
310
|
+
* Capture resume state for the *next user turn* before ending this local
|
|
311
|
+
* session handle. `detach()` parks the session without stopping the sandbox;
|
|
312
|
+
* bridge-backed sessions usually resume by attach/replay, while
|
|
313
|
+
* host-resident sessions may resume by rerun. A one-shot consumer opts into
|
|
314
|
+
* `destroyOnFinish` to destroy the sandbox instead.
|
|
315
|
+
*/
|
|
316
|
+
let resumeFrom = state.resumeFrom;
|
|
317
|
+
if (destroyOnFinish) {
|
|
318
|
+
await destroyQuietly(session);
|
|
319
|
+
resumeFrom = undefined;
|
|
320
|
+
} else {
|
|
321
|
+
resumeFrom = await session.detach().catch(() => state.resumeFrom);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
sessionId: state.sessionId,
|
|
326
|
+
prompt: state.prompt,
|
|
327
|
+
status: 'finished',
|
|
328
|
+
...(resumeFrom != null ? { resumeFrom } : {}),
|
|
329
|
+
finalResult: {
|
|
330
|
+
sessionId: state.sessionId,
|
|
331
|
+
finishReason: normalizedFinishReason,
|
|
332
|
+
usage: toUsageSummary(usage),
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
} finally {
|
|
336
|
+
if (!writerClosed) writer.releaseLock();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
type MutableStreamContext = {
|
|
341
|
+
activeTextParts: Record<string, HarnessWorkflowSerializedChunk>;
|
|
342
|
+
activeReasoningParts: Record<string, HarnessWorkflowSerializedChunk>;
|
|
343
|
+
pendingToolInputs: Record<string, HarnessWorkflowSerializedChunk>;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
type ExecutionPartState = {
|
|
347
|
+
openedTextParts: Set<string>;
|
|
348
|
+
openedReasoningParts: Set<string>;
|
|
349
|
+
writtenToolInputs: Set<string>;
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
function createMutableStreamContext(
|
|
353
|
+
context: HarnessWorkflowStreamContext | undefined,
|
|
354
|
+
): MutableStreamContext {
|
|
355
|
+
return {
|
|
356
|
+
activeTextParts: { ...(context?.activeTextParts ?? {}) },
|
|
357
|
+
activeReasoningParts: { ...(context?.activeReasoningParts ?? {}) },
|
|
358
|
+
pendingToolInputs: { ...(context?.pendingToolInputs ?? {}) },
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function createExecutionPartState(): ExecutionPartState {
|
|
363
|
+
return {
|
|
364
|
+
openedTextParts: new Set(),
|
|
365
|
+
openedReasoningParts: new Set(),
|
|
366
|
+
writtenToolInputs: new Set(),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function writeWorkflowChunk(options: {
|
|
371
|
+
chunk: HarnessWorkflowChunk;
|
|
372
|
+
writer: WritableStreamDefaultWriter<HarnessWorkflowChunk>;
|
|
373
|
+
streamContext: MutableStreamContext;
|
|
374
|
+
executionPartState: ExecutionPartState;
|
|
375
|
+
}): Promise<void> {
|
|
376
|
+
await writeRequiredPrelude(options);
|
|
377
|
+
await options.writer.write(options.chunk);
|
|
378
|
+
recordWorkflowChunk(options);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function writeRequiredPrelude(options: {
|
|
382
|
+
chunk: HarnessWorkflowChunk;
|
|
383
|
+
writer: WritableStreamDefaultWriter<HarnessWorkflowChunk>;
|
|
384
|
+
streamContext: MutableStreamContext;
|
|
385
|
+
executionPartState: ExecutionPartState;
|
|
386
|
+
}): Promise<void> {
|
|
387
|
+
const { chunk, writer, streamContext, executionPartState } = options;
|
|
388
|
+
const id = stringProperty({ chunk, key: 'id' });
|
|
389
|
+
|
|
390
|
+
if (
|
|
391
|
+
(chunk.type === 'text-delta' || chunk.type === 'text-end') &&
|
|
392
|
+
id != null &&
|
|
393
|
+
streamContext.activeTextParts[id] != null &&
|
|
394
|
+
!executionPartState.openedTextParts.has(id)
|
|
395
|
+
) {
|
|
396
|
+
await writer.write(streamContext.activeTextParts[id]);
|
|
397
|
+
executionPartState.openedTextParts.add(id);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (
|
|
401
|
+
(chunk.type === 'reasoning-delta' || chunk.type === 'reasoning-end') &&
|
|
402
|
+
id != null &&
|
|
403
|
+
streamContext.activeReasoningParts[id] != null &&
|
|
404
|
+
!executionPartState.openedReasoningParts.has(id)
|
|
405
|
+
) {
|
|
406
|
+
await writer.write(streamContext.activeReasoningParts[id]);
|
|
407
|
+
executionPartState.openedReasoningParts.add(id);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const toolCallId = stringProperty({ chunk, key: 'toolCallId' });
|
|
411
|
+
if (
|
|
412
|
+
toolCallId != null &&
|
|
413
|
+
needsToolInputPrelude(chunk) &&
|
|
414
|
+
streamContext.pendingToolInputs[toolCallId] != null &&
|
|
415
|
+
!executionPartState.writtenToolInputs.has(toolCallId)
|
|
416
|
+
) {
|
|
417
|
+
await writer.write(streamContext.pendingToolInputs[toolCallId]);
|
|
418
|
+
executionPartState.writtenToolInputs.add(toolCallId);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function recordWorkflowChunk(options: {
|
|
423
|
+
chunk: HarnessWorkflowChunk;
|
|
424
|
+
streamContext: MutableStreamContext;
|
|
425
|
+
executionPartState: ExecutionPartState;
|
|
426
|
+
}): void {
|
|
427
|
+
const { chunk, streamContext, executionPartState } = options;
|
|
428
|
+
const id = stringProperty({ chunk, key: 'id' });
|
|
429
|
+
const toolCallId = stringProperty({ chunk, key: 'toolCallId' });
|
|
430
|
+
|
|
431
|
+
if (chunk.type === 'text-start' && id != null) {
|
|
432
|
+
streamContext.activeTextParts[id] = cloneChunk(chunk);
|
|
433
|
+
executionPartState.openedTextParts.add(id);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (chunk.type === 'text-end' && id != null) {
|
|
438
|
+
delete streamContext.activeTextParts[id];
|
|
439
|
+
executionPartState.openedTextParts.delete(id);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (chunk.type === 'reasoning-start' && id != null) {
|
|
444
|
+
streamContext.activeReasoningParts[id] = cloneChunk(chunk);
|
|
445
|
+
executionPartState.openedReasoningParts.add(id);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (chunk.type === 'reasoning-end' && id != null) {
|
|
450
|
+
delete streamContext.activeReasoningParts[id];
|
|
451
|
+
executionPartState.openedReasoningParts.delete(id);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (chunk.type === 'tool-input-available' && toolCallId != null) {
|
|
456
|
+
streamContext.pendingToolInputs[toolCallId] = cloneChunk(chunk);
|
|
457
|
+
executionPartState.writtenToolInputs.add(toolCallId);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (chunk.type === 'tool-input-error' && toolCallId != null) {
|
|
462
|
+
delete streamContext.pendingToolInputs[toolCallId];
|
|
463
|
+
executionPartState.writtenToolInputs.add(toolCallId);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (
|
|
468
|
+
(chunk.type === 'tool-output-error' ||
|
|
469
|
+
chunk.type === 'tool-output-denied' ||
|
|
470
|
+
(chunk.type === 'tool-output-available' &&
|
|
471
|
+
(chunk as { preliminary?: unknown }).preliminary !== true)) &&
|
|
472
|
+
toolCallId != null
|
|
473
|
+
) {
|
|
474
|
+
delete streamContext.pendingToolInputs[toolCallId];
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function closeOpenExecutionParts(options: {
|
|
479
|
+
writer: WritableStreamDefaultWriter<HarnessWorkflowChunk>;
|
|
480
|
+
streamContext: MutableStreamContext;
|
|
481
|
+
executionPartState: ExecutionPartState;
|
|
482
|
+
}): Promise<void> {
|
|
483
|
+
const { writer, streamContext, executionPartState } = options;
|
|
484
|
+
|
|
485
|
+
for (const id of executionPartState.openedTextParts) {
|
|
486
|
+
if (streamContext.activeTextParts[id] != null) {
|
|
487
|
+
await writer.write({ type: 'text-end', id });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
executionPartState.openedTextParts.clear();
|
|
491
|
+
|
|
492
|
+
for (const id of executionPartState.openedReasoningParts) {
|
|
493
|
+
if (streamContext.activeReasoningParts[id] != null) {
|
|
494
|
+
await writer.write({ type: 'reasoning-end', id });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
executionPartState.openedReasoningParts.clear();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function serializeStreamContextField(context: MutableStreamContext): {
|
|
501
|
+
streamContext?: HarnessWorkflowStreamContext;
|
|
502
|
+
} {
|
|
503
|
+
const streamContext: HarnessWorkflowStreamContext = {
|
|
504
|
+
...(Object.keys(context.activeTextParts).length > 0
|
|
505
|
+
? { activeTextParts: context.activeTextParts }
|
|
506
|
+
: {}),
|
|
507
|
+
...(Object.keys(context.activeReasoningParts).length > 0
|
|
508
|
+
? { activeReasoningParts: context.activeReasoningParts }
|
|
509
|
+
: {}),
|
|
510
|
+
...(Object.keys(context.pendingToolInputs).length > 0
|
|
511
|
+
? { pendingToolInputs: context.pendingToolInputs }
|
|
512
|
+
: {}),
|
|
513
|
+
};
|
|
514
|
+
return Object.keys(streamContext).length > 0 ? { streamContext } : {};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function needsToolInputPrelude(chunk: HarnessWorkflowChunk): boolean {
|
|
518
|
+
return (
|
|
519
|
+
chunk.type === 'tool-approval-request' ||
|
|
520
|
+
chunk.type === 'tool-output-available' ||
|
|
521
|
+
chunk.type === 'tool-output-error' ||
|
|
522
|
+
chunk.type === 'tool-output-denied'
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function hasPendingHostInput(state: HarnessV1ContinueTurnState): boolean {
|
|
527
|
+
return (
|
|
528
|
+
(state.pendingToolApprovals?.length ?? 0) > 0 ||
|
|
529
|
+
(state.pendingToolResults?.length ?? 0) > 0
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function toResumeState(options: {
|
|
534
|
+
continueFrom: HarnessV1ContinueTurnState;
|
|
535
|
+
}): HarnessV1ResumeSessionState {
|
|
536
|
+
const { continueFrom } = options;
|
|
537
|
+
return {
|
|
538
|
+
type: 'resume-session',
|
|
539
|
+
harnessId: continueFrom.harnessId,
|
|
540
|
+
specificationVersion: continueFrom.specificationVersion,
|
|
541
|
+
data: continueFrom.data,
|
|
542
|
+
continueFrom,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function stringProperty(options: {
|
|
547
|
+
chunk: HarnessWorkflowChunk;
|
|
548
|
+
key: string;
|
|
549
|
+
}): string | undefined {
|
|
550
|
+
const value = options.chunk[options.key];
|
|
551
|
+
return typeof value === 'string' ? value : undefined;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function cloneChunk(
|
|
555
|
+
chunk: HarnessWorkflowChunk,
|
|
556
|
+
): HarnessWorkflowSerializedChunk {
|
|
557
|
+
return { ...chunk };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Resolve the workflow's default output stream lazily, so importing this module
|
|
562
|
+
* never requires the `workflow` runtime (tests inject their own `writable`).
|
|
563
|
+
*/
|
|
564
|
+
async function resolveWorkflowWritable(): Promise<
|
|
565
|
+
WritableStream<HarnessWorkflowChunk>
|
|
566
|
+
> {
|
|
567
|
+
const { getWritable } = (await import('workflow')) as {
|
|
568
|
+
getWritable: <T>() => WritableStream<T>;
|
|
569
|
+
};
|
|
570
|
+
return getWritable<HarnessWorkflowChunk>();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function destroyQuietly(session: HarnessAgentSession): Promise<void> {
|
|
574
|
+
await session.destroy().catch(() => {});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function errorMessage(err: unknown): string {
|
|
578
|
+
return err instanceof Error ? err.message : String(err);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Whether an error (or UI `error` part's `errorText`) is an abort — the
|
|
583
|
+
* expected consequence of `suspendTurn()` tearing the turn down at a slice
|
|
584
|
+
* boundary. Only these are safe to swallow during a suspend; any other error is
|
|
585
|
+
* unanticipated and must surface.
|
|
586
|
+
*/
|
|
587
|
+
function isAbortError(value: unknown): boolean {
|
|
588
|
+
if (value == null) return false;
|
|
589
|
+
if (
|
|
590
|
+
typeof value === 'object' &&
|
|
591
|
+
(value as { name?: unknown }).name === 'AbortError'
|
|
592
|
+
) {
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
const text =
|
|
596
|
+
typeof value === 'string'
|
|
597
|
+
? value
|
|
598
|
+
: value instanceof Error
|
|
599
|
+
? value.message
|
|
600
|
+
: String(value);
|
|
601
|
+
return /\baborted\b|AbortError|operation was aborted/i.test(text);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function toFinishReasonString(finishReason: unknown): string {
|
|
605
|
+
if (typeof finishReason === 'string') return finishReason;
|
|
606
|
+
if (
|
|
607
|
+
finishReason != null &&
|
|
608
|
+
typeof finishReason === 'object' &&
|
|
609
|
+
typeof (finishReason as { unified?: unknown }).unified === 'string'
|
|
610
|
+
) {
|
|
611
|
+
return (finishReason as { unified: string }).unified;
|
|
612
|
+
}
|
|
613
|
+
return 'stop';
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function toUsageSummary(
|
|
617
|
+
usage: unknown,
|
|
618
|
+
): HarnessWorkflowUsageSummary | undefined {
|
|
619
|
+
if (usage == null || typeof usage !== 'object') return undefined;
|
|
620
|
+
const u = usage as {
|
|
621
|
+
inputTokens?: { total?: number };
|
|
622
|
+
outputTokens?: { total?: number };
|
|
623
|
+
};
|
|
624
|
+
const inputTokens = u.inputTokens?.total;
|
|
625
|
+
const outputTokens = u.outputTokens?.total;
|
|
626
|
+
if (inputTokens == null && outputTokens == null) return undefined;
|
|
627
|
+
return {
|
|
628
|
+
...(inputTokens != null ? { inputTokens } : {}),
|
|
629
|
+
...(outputTokens != null ? { outputTokens } : {}),
|
|
630
|
+
};
|
|
631
|
+
}
|