@purista/harness 1.2.1 → 1.2.3
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/dist/agents/index.d.ts +1 -0
- package/dist/agents/index.js +276 -141
- package/dist/errors/catalog.d.ts +4 -3
- package/dist/harness/defineHarness.d.ts +45 -4
- package/dist/harness/defineHarness.js +51 -2
- package/dist/index.d.ts +1 -1
- package/dist/memory/sandbox/index.js +7 -1
- package/dist/models/registry.d.ts +10 -3
- package/dist/models/registry.js +45 -3
- package/dist/ports/base-model-provider.js +2 -0
- package/dist/ports/capabilities.d.ts +2 -0
- package/dist/ports/harness-context.d.ts +1 -0
- package/dist/ports/model-provider.d.ts +4 -0
- package/dist/ports/state.d.ts +6 -0
- package/dist/runtime/abort.d.ts +5 -0
- package/dist/runtime/abort.js +33 -0
- package/dist/runtime/durable.d.ts +2 -0
- package/dist/runtime/durable.js +6 -2
- package/dist/runtime/sessionDurable.d.ts +49 -0
- package/dist/runtime/sessionDurable.js +135 -0
- package/dist/runtime/steps.d.ts +19 -1
- package/dist/runtime/steps.js +21 -3
- package/dist/sandbox/index.d.ts +34 -0
- package/dist/sandbox/index.js +40 -3
- package/dist/sessions/index.d.ts +15 -2
- package/dist/sessions/index.js +336 -105
- package/dist/skills/index.js +19 -6
- package/dist/state/in-memory.d.ts +1 -0
- package/dist/state/in-memory.js +15 -0
- package/dist/telemetry/shim.js +9 -4
- package/dist/testing/durableWorkspaceStoreContract.d.ts +1 -1
- package/dist/testing/durableWorkspaceStoreContract.js +64 -28
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.js +15 -1
- package/dist/tools/mcp/runner.js +11 -6
- package/dist/tools/mcp/stdio.js +170 -1
- package/dist/ulid/index.d.ts +6 -1
- package/dist/ulid/index.js +31 -13
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/workflows/index.js +7 -1
- package/dist/workspace/in-memory.d.ts +9 -10
- package/dist/workspace/in-memory.js +191 -48
- package/package.json +1 -1
- package/dist/harness/errors.d.ts +0 -62
- package/dist/harness/errors.js +0 -67
package/dist/sessions/index.js
CHANGED
|
@@ -3,6 +3,8 @@ import { ulid } from '../ulid/index.js';
|
|
|
3
3
|
import { runDefaultAgent } from '../agents/index.js';
|
|
4
4
|
import { runWorkflow } from '../workflows/index.js';
|
|
5
5
|
import { createMemoryFacade, createSessionMemory } from '../ports/memory.js';
|
|
6
|
+
import { beginDurableWorkflow, DURABLE_RUN_ID_PATTERN, isExecutableDurableRuntime } from '../runtime/sessionDurable.js';
|
|
7
|
+
import { HarnessConfigError } from '../errors/catalog.js';
|
|
6
8
|
import { loadSkillsSync } from '../skills/index.js';
|
|
7
9
|
import { createModelRegistry } from '../models/registry.js';
|
|
8
10
|
import { createMetrics, createTelemetryShim } from '../telemetry/index.js';
|
|
@@ -11,6 +13,82 @@ const NEVER_ABORT_SIGNAL = new AbortController().signal;
|
|
|
11
13
|
function now() {
|
|
12
14
|
return new Date().toISOString();
|
|
13
15
|
}
|
|
16
|
+
const STREAM_MAX_BUFFERED_EVENTS = 1024;
|
|
17
|
+
const STREAM_TERMINAL_EVENT_TYPES = new Set(['run.finished', 'agent.finished']);
|
|
18
|
+
/**
|
|
19
|
+
* Relay run events from an in-process run to a stream consumer.
|
|
20
|
+
*
|
|
21
|
+
* The unread events live in a bounded queue: consumed events are removed (no
|
|
22
|
+
* growing cursor over a shared array), and on overflow the oldest non-terminal
|
|
23
|
+
* unread event is dropped and counted, so a slow consumer never silently skips
|
|
24
|
+
* an unread event. Delivery is promise-notified rather than time-polled, so
|
|
25
|
+
* there is no fixed per-event latency or periodic timer.
|
|
26
|
+
*/
|
|
27
|
+
export async function* relayRunEvents(run) {
|
|
28
|
+
const queue = [];
|
|
29
|
+
let dropped = 0;
|
|
30
|
+
let liveRunId = 'unknown';
|
|
31
|
+
let done = false;
|
|
32
|
+
let failure;
|
|
33
|
+
let wake;
|
|
34
|
+
const notify = () => {
|
|
35
|
+
const resolve = wake;
|
|
36
|
+
wake = undefined;
|
|
37
|
+
resolve?.();
|
|
38
|
+
};
|
|
39
|
+
const result = run((event) => {
|
|
40
|
+
if ('runId' in event)
|
|
41
|
+
liveRunId = event.runId;
|
|
42
|
+
if (queue.length >= STREAM_MAX_BUFFERED_EVENTS) {
|
|
43
|
+
const dropIndex = queue.findIndex((candidate) => !STREAM_TERMINAL_EVENT_TYPES.has(candidate.type));
|
|
44
|
+
if (dropIndex >= 0) {
|
|
45
|
+
queue.splice(dropIndex, 1);
|
|
46
|
+
dropped += 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
queue.push(event);
|
|
50
|
+
notify();
|
|
51
|
+
return Promise.resolve();
|
|
52
|
+
})
|
|
53
|
+
.catch((error) => {
|
|
54
|
+
failure = error;
|
|
55
|
+
return undefined;
|
|
56
|
+
})
|
|
57
|
+
.finally(() => {
|
|
58
|
+
done = true;
|
|
59
|
+
notify();
|
|
60
|
+
});
|
|
61
|
+
try {
|
|
62
|
+
while (true) {
|
|
63
|
+
if (dropped > 0) {
|
|
64
|
+
const droppedCount = dropped;
|
|
65
|
+
dropped = 0;
|
|
66
|
+
yield { type: 'stream.overflow', runId: liveRunId, at: now(), dropped: droppedCount };
|
|
67
|
+
}
|
|
68
|
+
while (queue.length > 0) {
|
|
69
|
+
yield queue.shift();
|
|
70
|
+
// Surface a fresh overflow notice promptly between events.
|
|
71
|
+
if (dropped > 0)
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
if (queue.length === 0 && dropped === 0) {
|
|
75
|
+
if (done) {
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
// No await between the empty check and installing `wake`, so a producer
|
|
79
|
+
// push cannot be lost between them.
|
|
80
|
+
await new Promise((resolve) => {
|
|
81
|
+
wake = resolve;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await result.catch(() => undefined);
|
|
88
|
+
}
|
|
89
|
+
if (failure)
|
|
90
|
+
throw failure;
|
|
91
|
+
}
|
|
14
92
|
function validateInvokeOptions(opts) {
|
|
15
93
|
if (opts?.historyWindow !== undefined && opts.historyWindow < 0) {
|
|
16
94
|
throw new ValidationError('Invoke options are invalid.', { where: 'invoke_options', issues: { historyWindow: opts.historyWindow } });
|
|
@@ -30,6 +108,12 @@ function normalizeMessage(message, sessionId) {
|
|
|
30
108
|
export function createSessionHarness(definition) {
|
|
31
109
|
const resolvedSkills = loadSkillsSync(definition.skills);
|
|
32
110
|
const sessionStates = new Map();
|
|
111
|
+
// In-flight session-state creations, memoized so concurrent first-time callers
|
|
112
|
+
// share one sandbox open (no orphaned sessions) and one SessionState object
|
|
113
|
+
// (so the synchronous busy check/set below serializes runs correctly).
|
|
114
|
+
const sessionStateOpenings = new Map();
|
|
115
|
+
// Stable per-harness-instance worker id used as the default durable lease owner.
|
|
116
|
+
const durableWorkerId = `worker_${ulid()}`;
|
|
33
117
|
const contentCaptureMode = resolveContentCaptureMode(definition.telemetry);
|
|
34
118
|
const telemetry = withTelemetryFlavor(definition.telemetryShim ?? createTelemetryShim(), definition.telemetry);
|
|
35
119
|
const adapterMetrics = createMetrics(telemetry, { 'harness.name': definition.name });
|
|
@@ -45,6 +129,7 @@ export function createSessionHarness(definition) {
|
|
|
45
129
|
toolTimeoutMs: definition.defaults.toolTimeoutMs ?? 120_000,
|
|
46
130
|
skillTimeoutMs: definition.defaults.skillTimeoutMs ?? 60_000,
|
|
47
131
|
modelTimeoutMs: definition.defaults.modelTimeoutMs ?? 300_000,
|
|
132
|
+
maxParallelToolCalls: definition.defaults.maxParallelToolCalls ?? 8,
|
|
48
133
|
...(definition.defaults.historyWindow !== undefined ? { historyWindow: definition.defaults.historyWindow } : {})
|
|
49
134
|
}
|
|
50
135
|
};
|
|
@@ -56,24 +141,36 @@ export function createSessionHarness(definition) {
|
|
|
56
141
|
if (existing) {
|
|
57
142
|
return existing;
|
|
58
143
|
}
|
|
144
|
+
const createdAt = now();
|
|
59
145
|
const created = {
|
|
60
146
|
id: sessionId,
|
|
61
|
-
createdAt
|
|
62
|
-
updatedAt:
|
|
147
|
+
createdAt,
|
|
148
|
+
updatedAt: createdAt,
|
|
63
149
|
runCount: 0
|
|
64
150
|
};
|
|
65
151
|
await definition.state.upsertSession(created);
|
|
66
152
|
return created;
|
|
67
153
|
}
|
|
68
|
-
|
|
154
|
+
function getSessionState(sessionId) {
|
|
69
155
|
const existing = sessionStates.get(sessionId);
|
|
70
156
|
if (existing) {
|
|
71
|
-
return existing;
|
|
157
|
+
return Promise.resolve(existing);
|
|
72
158
|
}
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
159
|
+
const pending = sessionStateOpenings.get(sessionId);
|
|
160
|
+
if (pending) {
|
|
161
|
+
return pending;
|
|
162
|
+
}
|
|
163
|
+
const opening = (async () => {
|
|
164
|
+
const sandboxSession = await definition.sandbox.open({ sessionId, runId: `init_${ulid()}` });
|
|
165
|
+
const created = { busy: false, sandboxSession, mountedSkills: new Set() };
|
|
166
|
+
sessionStates.set(sessionId, created);
|
|
167
|
+
sessionStateOpenings.delete(sessionId);
|
|
168
|
+
return created;
|
|
169
|
+
})();
|
|
170
|
+
// Let a failed open be retried instead of caching the rejection forever.
|
|
171
|
+
opening.catch(() => sessionStateOpenings.delete(sessionId));
|
|
172
|
+
sessionStateOpenings.set(sessionId, opening);
|
|
173
|
+
return opening;
|
|
77
174
|
}
|
|
78
175
|
async function appendEvents(runId, events) {
|
|
79
176
|
try {
|
|
@@ -141,6 +238,21 @@ export function createSessionHarness(definition) {
|
|
|
141
238
|
function memoryFacade(opts) {
|
|
142
239
|
return createMemoryFacade(memoryOptions(opts.sessionId, opts.sandboxSession, opts.signal, opts));
|
|
143
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* Validates `opts.durable` and returns the executable durable runtime, or
|
|
243
|
+
* `undefined` for an ephemeral run. Throws before any run record is created.
|
|
244
|
+
*/
|
|
245
|
+
function resolveDurableRuntime(opts) {
|
|
246
|
+
if (!opts?.durable)
|
|
247
|
+
return undefined;
|
|
248
|
+
if (!DURABLE_RUN_ID_PATTERN.test(opts.durable.runId)) {
|
|
249
|
+
throw new ValidationError('Durable run id is invalid.', { where: 'invoke_options', issues: { 'durable.runId': opts.durable.runId } });
|
|
250
|
+
}
|
|
251
|
+
if (!isExecutableDurableRuntime(definition.runtime)) {
|
|
252
|
+
throw new HarnessConfigError('Durable execution requires an executable .runtime(...) adapter.', { reason: 'durable_runtime_required', path: 'runtime' });
|
|
253
|
+
}
|
|
254
|
+
return definition.runtime;
|
|
255
|
+
}
|
|
144
256
|
return {
|
|
145
257
|
inspect() {
|
|
146
258
|
return definition.inspection;
|
|
@@ -202,14 +314,21 @@ export function createSessionHarness(definition) {
|
|
|
202
314
|
throw new ValidationError('Session history replacement failed validation.', { where: 'session_history', issues: { message } }, error);
|
|
203
315
|
}
|
|
204
316
|
});
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
317
|
+
if (definition.state.replaceMessages) {
|
|
318
|
+
await definition.state.replaceMessages(sessionId, parsed);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
// Non-atomic fallback for adapters without atomic replace.
|
|
322
|
+
await definition.state.clearMessages(sessionId);
|
|
323
|
+
if (parsed.length > 0) {
|
|
324
|
+
await definition.state.appendMessages(sessionId, parsed);
|
|
325
|
+
}
|
|
208
326
|
}
|
|
209
327
|
},
|
|
210
328
|
async close() {
|
|
211
329
|
await definition.state.closeSession(sessionId);
|
|
212
330
|
sessionStates.delete(sessionId);
|
|
331
|
+
sessionStateOpenings.delete(sessionId);
|
|
213
332
|
await state.sandboxSession.close();
|
|
214
333
|
}
|
|
215
334
|
};
|
|
@@ -248,51 +367,13 @@ export function createSessionHarness(definition) {
|
|
|
248
367
|
$infer: {}
|
|
249
368
|
};
|
|
250
369
|
async function* streamAgentCall(sessionId, agentId, agent, input, opts) {
|
|
251
|
-
|
|
252
|
-
const maxBufferedEvents = 1024;
|
|
253
|
-
let dropped = 0;
|
|
254
|
-
let done = false;
|
|
255
|
-
let failure;
|
|
256
|
-
let liveRunId = 'unknown';
|
|
257
|
-
const result = runAgentCall(sessionId, agentId, agent, input, opts, (event) => {
|
|
258
|
-
if ('runId' in event)
|
|
259
|
-
liveRunId = event.runId;
|
|
260
|
-
if (buffer.length >= maxBufferedEvents) {
|
|
261
|
-
const dropIndex = buffer.findIndex((candidate) => candidate.type !== 'run.finished');
|
|
262
|
-
if (dropIndex >= 0) {
|
|
263
|
-
buffer.splice(dropIndex, 1);
|
|
264
|
-
dropped += 1;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
buffer.push(event);
|
|
268
|
-
return Promise.resolve();
|
|
269
|
-
}).catch((error) => {
|
|
270
|
-
failure = error;
|
|
271
|
-
return undefined;
|
|
272
|
-
}).finally(() => {
|
|
273
|
-
done = true;
|
|
274
|
-
});
|
|
275
|
-
let cursor = 0;
|
|
276
|
-
while (true) {
|
|
277
|
-
if (dropped > 0) {
|
|
278
|
-
yield { type: 'stream.overflow', runId: liveRunId, at: now(), dropped };
|
|
279
|
-
dropped = 0;
|
|
280
|
-
}
|
|
281
|
-
while (cursor < buffer.length) {
|
|
282
|
-
yield buffer[cursor];
|
|
283
|
-
cursor += 1;
|
|
284
|
-
}
|
|
285
|
-
if (done) {
|
|
286
|
-
await result.catch(() => undefined);
|
|
287
|
-
if (failure)
|
|
288
|
-
throw failure;
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
291
|
-
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
292
|
-
}
|
|
370
|
+
yield* relayRunEvents((onEvent) => runAgentCall(sessionId, agentId, agent, input, opts, onEvent));
|
|
293
371
|
}
|
|
294
372
|
async function runAgentCall(sessionId, agentId, agent, input, opts, onEvent) {
|
|
295
373
|
validateInvokeOptions(opts);
|
|
374
|
+
if (opts?.durable) {
|
|
375
|
+
throw new ValidationError('Durable execution is only supported for workflow runs.', { where: 'invoke_options', issues: { durable: 'agent_run' } });
|
|
376
|
+
}
|
|
296
377
|
if (opts?.signal?.aborted) {
|
|
297
378
|
throw new OperationCancelledError('Run was cancelled before start.', { scope: 'run' });
|
|
298
379
|
}
|
|
@@ -352,7 +433,12 @@ export function createSessionHarness(definition) {
|
|
|
352
433
|
input,
|
|
353
434
|
history: await definition.state.listMessages(sessionId),
|
|
354
435
|
agent,
|
|
355
|
-
models: modelRegistry,
|
|
436
|
+
models: withRunEventModelRegistry(modelRegistry, {
|
|
437
|
+
harnessName: definition.name,
|
|
438
|
+
sessionId,
|
|
439
|
+
runId,
|
|
440
|
+
agentId
|
|
441
|
+
}, emit),
|
|
356
442
|
skills: resolvedSkills,
|
|
357
443
|
customTools: definition.tools,
|
|
358
444
|
mcpRegistry,
|
|
@@ -363,6 +449,7 @@ export function createSessionHarness(definition) {
|
|
|
363
449
|
maxSteps: definition.defaults.agentMaxIterations ?? 16,
|
|
364
450
|
signal: runSignal.signal,
|
|
365
451
|
toolTimeoutMs: definition.defaults.toolTimeoutMs ?? 120_000,
|
|
452
|
+
maxParallelToolCalls: definition.defaults.maxParallelToolCalls ?? 8,
|
|
366
453
|
logger: definition.logger,
|
|
367
454
|
telemetry,
|
|
368
455
|
emitEvent: emit,
|
|
@@ -418,51 +505,11 @@ export function createSessionHarness(definition) {
|
|
|
418
505
|
}
|
|
419
506
|
}
|
|
420
507
|
async function* streamWorkflowCall(sessionId, workflowId, workflow, input, opts) {
|
|
421
|
-
|
|
422
|
-
const maxBufferedEvents = 1024;
|
|
423
|
-
let dropped = 0;
|
|
424
|
-
let done = false;
|
|
425
|
-
let failure;
|
|
426
|
-
let liveRunId = 'unknown';
|
|
427
|
-
const result = runWorkflowCall(sessionId, workflowId, workflow, input, opts, (event) => {
|
|
428
|
-
if ('runId' in event)
|
|
429
|
-
liveRunId = event.runId;
|
|
430
|
-
if (buffer.length >= maxBufferedEvents) {
|
|
431
|
-
const dropIndex = buffer.findIndex((candidate) => candidate.type !== 'run.finished');
|
|
432
|
-
if (dropIndex >= 0) {
|
|
433
|
-
buffer.splice(dropIndex, 1);
|
|
434
|
-
dropped += 1;
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
buffer.push(event);
|
|
438
|
-
return Promise.resolve();
|
|
439
|
-
}).catch((error) => {
|
|
440
|
-
failure = error;
|
|
441
|
-
return undefined;
|
|
442
|
-
}).finally(() => {
|
|
443
|
-
done = true;
|
|
444
|
-
});
|
|
445
|
-
let cursor = 0;
|
|
446
|
-
while (true) {
|
|
447
|
-
if (dropped > 0) {
|
|
448
|
-
yield { type: 'stream.overflow', runId: liveRunId, at: now(), dropped };
|
|
449
|
-
dropped = 0;
|
|
450
|
-
}
|
|
451
|
-
while (cursor < buffer.length) {
|
|
452
|
-
yield buffer[cursor];
|
|
453
|
-
cursor += 1;
|
|
454
|
-
}
|
|
455
|
-
if (done) {
|
|
456
|
-
await result.catch(() => undefined);
|
|
457
|
-
if (failure)
|
|
458
|
-
throw failure;
|
|
459
|
-
return;
|
|
460
|
-
}
|
|
461
|
-
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
462
|
-
}
|
|
508
|
+
yield* relayRunEvents((onEvent) => runWorkflowCall(sessionId, workflowId, workflow, input, opts, onEvent));
|
|
463
509
|
}
|
|
464
510
|
async function runWorkflowCall(sessionId, workflowId, workflow, input, opts, onEvent) {
|
|
465
511
|
validateInvokeOptions(opts);
|
|
512
|
+
const durableRuntime = resolveDurableRuntime(opts);
|
|
466
513
|
if (opts?.signal?.aborted) {
|
|
467
514
|
throw new OperationCancelledError('Run was cancelled before start.', { scope: 'run' });
|
|
468
515
|
}
|
|
@@ -473,7 +520,7 @@ export function createSessionHarness(definition) {
|
|
|
473
520
|
}
|
|
474
521
|
state.busy = true;
|
|
475
522
|
const startedAt = now();
|
|
476
|
-
const runId = ulid();
|
|
523
|
+
const runId = opts?.durable ? opts.durable.runId : ulid();
|
|
477
524
|
const memory = memoryFacade({
|
|
478
525
|
sessionId,
|
|
479
526
|
runId,
|
|
@@ -503,7 +550,22 @@ export function createSessionHarness(definition) {
|
|
|
503
550
|
state.busy = false;
|
|
504
551
|
throw error;
|
|
505
552
|
}
|
|
553
|
+
let durableBinding;
|
|
506
554
|
try {
|
|
555
|
+
if (durableRuntime && opts?.durable) {
|
|
556
|
+
durableBinding = await beginDurableWorkflow({
|
|
557
|
+
runtime: durableRuntime,
|
|
558
|
+
...(definition.workspaceStore ? { workspaceStore: definition.workspaceStore } : {}),
|
|
559
|
+
durable: opts.durable,
|
|
560
|
+
defaultWorkerId: durableWorkerId,
|
|
561
|
+
sessionId,
|
|
562
|
+
workflowId,
|
|
563
|
+
input: input,
|
|
564
|
+
signal: runSignal.signal,
|
|
565
|
+
logger: definition.logger,
|
|
566
|
+
harnessName: definition.name
|
|
567
|
+
});
|
|
568
|
+
}
|
|
507
569
|
const result = await withIncomingTraceContext(telemetry, opts, definition.logger, async () => telemetry.span('harness.session.prompt', {
|
|
508
570
|
'harness.name': definition.name,
|
|
509
571
|
'harness.session.id': sessionId,
|
|
@@ -528,10 +590,16 @@ export function createSessionHarness(definition) {
|
|
|
528
590
|
signal: runSignal.signal,
|
|
529
591
|
runId,
|
|
530
592
|
sessionId,
|
|
531
|
-
models: modelRegistry,
|
|
593
|
+
models: withRunEventModelRegistry(modelRegistry, {
|
|
594
|
+
harnessName: definition.name,
|
|
595
|
+
sessionId,
|
|
596
|
+
runId,
|
|
597
|
+
workflowId
|
|
598
|
+
}, emit),
|
|
532
599
|
metadata: opts?.metadata ?? {},
|
|
533
600
|
metrics: workflowMetrics,
|
|
534
601
|
memory,
|
|
602
|
+
step: durableBinding ? durableBinding.step : passthroughStep,
|
|
535
603
|
agents: Object.fromEntries(Object.entries(definition.agents).map(([agentId, agent]) => [
|
|
536
604
|
agentId,
|
|
537
605
|
async (agentInput, agentOpts) => {
|
|
@@ -557,7 +625,13 @@ export function createSessionHarness(definition) {
|
|
|
557
625
|
input: agentInput,
|
|
558
626
|
history: await definition.state.listMessages(sessionId),
|
|
559
627
|
agent: agent,
|
|
560
|
-
models: modelRegistry,
|
|
628
|
+
models: withRunEventModelRegistry(modelRegistry, {
|
|
629
|
+
harnessName: definition.name,
|
|
630
|
+
sessionId,
|
|
631
|
+
runId,
|
|
632
|
+
workflowId,
|
|
633
|
+
agentId
|
|
634
|
+
}, emit),
|
|
561
635
|
skills: resolvedSkills,
|
|
562
636
|
customTools: definition.tools,
|
|
563
637
|
mcpRegistry,
|
|
@@ -568,6 +642,7 @@ export function createSessionHarness(definition) {
|
|
|
568
642
|
maxSteps: definition.defaults.agentMaxIterations ?? 16,
|
|
569
643
|
signal: agentSignal.signal,
|
|
570
644
|
toolTimeoutMs: definition.defaults.toolTimeoutMs ?? 120_000,
|
|
645
|
+
maxParallelToolCalls: definition.defaults.maxParallelToolCalls ?? 8,
|
|
571
646
|
logger: definition.logger,
|
|
572
647
|
telemetry,
|
|
573
648
|
emitEvent: emit,
|
|
@@ -597,6 +672,9 @@ export function createSessionHarness(definition) {
|
|
|
597
672
|
}));
|
|
598
673
|
}));
|
|
599
674
|
const finishedAt = now();
|
|
675
|
+
if (durableBinding) {
|
|
676
|
+
await guardDurableStep({ sessionId, runId, workflowId, operation: 'finish_success' }, () => durableBinding.finishSuccess(result));
|
|
677
|
+
}
|
|
600
678
|
const runFinished = { type: 'run.finished', runId, at: finishedAt, output: result };
|
|
601
679
|
await emit(runFinished);
|
|
602
680
|
await definition.state.finishRun(runId, { status: 'succeeded', finishedAt, output: result });
|
|
@@ -608,6 +686,9 @@ export function createSessionHarness(definition) {
|
|
|
608
686
|
const finalError = normalizeRunError(error, runSignal.signal);
|
|
609
687
|
const finishedAt = now();
|
|
610
688
|
const serialized = serializeError(finalError);
|
|
689
|
+
if (durableBinding && finalError instanceof OperationCancelledError) {
|
|
690
|
+
await guardDurableStep({ sessionId, runId, workflowId, operation: 'finish_cancelled' }, () => durableBinding.finishCancelled(finalError));
|
|
691
|
+
}
|
|
611
692
|
const log = finalError instanceof OperationCancelledError ? definition.logger.warn.bind(definition.logger) : definition.logger.error.bind(definition.logger);
|
|
612
693
|
log('Harness workflow run failed.', {
|
|
613
694
|
harness: definition.name,
|
|
@@ -637,10 +718,41 @@ export function createSessionHarness(definition) {
|
|
|
637
718
|
throw finalError;
|
|
638
719
|
}
|
|
639
720
|
finally {
|
|
721
|
+
// Releases the lease for a non-cancel failure so a retry with the same run
|
|
722
|
+
// id can resume; a no-op once the run was settled (success/cancel).
|
|
723
|
+
if (durableBinding)
|
|
724
|
+
await durableBinding.dispose();
|
|
640
725
|
runSignal.cleanup();
|
|
641
726
|
state.busy = false;
|
|
642
727
|
}
|
|
643
728
|
}
|
|
729
|
+
/** Pass-through step used when a workflow runs without durable execution. */
|
|
730
|
+
function passthroughStep(_stepId, fn) {
|
|
731
|
+
return fn();
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Runs a durable finalization side effect (runtime finish / workspace lifecycle)
|
|
735
|
+
* without ever masking the primary run outcome (spec 21 §16.1 step 7).
|
|
736
|
+
*/
|
|
737
|
+
async function guardDurableStep(args, step) {
|
|
738
|
+
try {
|
|
739
|
+
await step();
|
|
740
|
+
}
|
|
741
|
+
catch (error) {
|
|
742
|
+
telemetry.recordCounter('harness.runs.durable_errors', 1, {
|
|
743
|
+
harness: definition.name,
|
|
744
|
+
'harness.run.durable.operation': args.operation
|
|
745
|
+
});
|
|
746
|
+
definition.logger.error('Durable finalization step failed; preserving run outcome.', {
|
|
747
|
+
harness: definition.name,
|
|
748
|
+
session_id: args.sessionId,
|
|
749
|
+
run_id: args.runId,
|
|
750
|
+
workflow_id: args.workflowId,
|
|
751
|
+
operation: args.operation,
|
|
752
|
+
error: serializeError(error)
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
}
|
|
644
756
|
async function terminalizeFailedRun(args) {
|
|
645
757
|
await runFailureTerminalizationStep(args, 'emit_run_finished', args.emitRunFinished);
|
|
646
758
|
await runFailureTerminalizationStep(args, 'finish_run', args.finishRun);
|
|
@@ -668,6 +780,100 @@ export function createSessionHarness(definition) {
|
|
|
668
780
|
}
|
|
669
781
|
}
|
|
670
782
|
}
|
|
783
|
+
function withRunEventModelRegistry(models, context, emitEvent) {
|
|
784
|
+
return Object.fromEntries(Object.entries(models).map(([alias, handle]) => [alias, withRunEventModelHandle(alias, handle, context, emitEvent)]));
|
|
785
|
+
}
|
|
786
|
+
function withRunEventModelHandle(alias, handle, context, emitEvent) {
|
|
787
|
+
if (!handle || typeof handle !== 'object')
|
|
788
|
+
return handle;
|
|
789
|
+
const source = handle;
|
|
790
|
+
const wrapped = { ...source };
|
|
791
|
+
for (const method of ['text', 'object', 'embed', 'rerank']) {
|
|
792
|
+
const fn = source[method];
|
|
793
|
+
if (typeof fn !== 'function')
|
|
794
|
+
continue;
|
|
795
|
+
wrapped[method] = (req, signal, ctx) => fn.call(source, req, signal, mergeModelRunContext(context, ctx));
|
|
796
|
+
}
|
|
797
|
+
const textStream = source['textStream'];
|
|
798
|
+
if (typeof textStream === 'function') {
|
|
799
|
+
wrapped['textStream'] = (req, signal, ctx) => {
|
|
800
|
+
const streamContext = modelStreamRunContext(context, ctx, alias);
|
|
801
|
+
return emitTextStreamRunEvents(textStream.call(source, req, signal, streamContext), streamContext, emitEvent);
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
const objectStream = source['objectStream'];
|
|
805
|
+
if (typeof objectStream === 'function') {
|
|
806
|
+
wrapped['objectStream'] = (req, signal, ctx) => {
|
|
807
|
+
const streamContext = modelStreamRunContext(context, ctx, alias);
|
|
808
|
+
return emitObjectStreamRunEvents(objectStream.call(source, req, signal, streamContext), streamContext, emitEvent);
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
return wrapped;
|
|
812
|
+
}
|
|
813
|
+
function mergeModelRunContext(context, override) {
|
|
814
|
+
return { ...context, ...(override ?? {}) };
|
|
815
|
+
}
|
|
816
|
+
function modelStreamRunContext(context, override, alias) {
|
|
817
|
+
const merged = mergeModelRunContext(context, override);
|
|
818
|
+
return {
|
|
819
|
+
...merged,
|
|
820
|
+
modelAlias: alias,
|
|
821
|
+
...(merged.emitRunEvents === true ? { streamId: `model_${ulid()}` } : {})
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
async function* emitTextStreamRunEvents(stream, context, emitEvent) {
|
|
825
|
+
for await (const chunk of stream) {
|
|
826
|
+
if (context.emitRunEvents === true && isTextDeltaChunk(chunk)) {
|
|
827
|
+
await emitEvent({
|
|
828
|
+
type: 'model.delta',
|
|
829
|
+
runId: context.runId,
|
|
830
|
+
...(context.agentId ? { agentId: context.agentId } : {}),
|
|
831
|
+
...(context.workflowId ? { workflowId: context.workflowId } : {}),
|
|
832
|
+
...(context.modelAlias ? { modelAlias: context.modelAlias } : {}),
|
|
833
|
+
streamId: context.streamId,
|
|
834
|
+
delta: chunk.text
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
yield chunk;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
async function* emitObjectStreamRunEvents(stream, context, emitEvent) {
|
|
841
|
+
for await (const chunk of stream) {
|
|
842
|
+
if (context.emitRunEvents === true && isObjectPartialChunk(chunk)) {
|
|
843
|
+
await emitEvent({
|
|
844
|
+
type: 'model.object.partial',
|
|
845
|
+
runId: context.runId,
|
|
846
|
+
...(context.agentId ? { agentId: context.agentId } : {}),
|
|
847
|
+
...(context.workflowId ? { workflowId: context.workflowId } : {}),
|
|
848
|
+
...(context.modelAlias ? { modelAlias: context.modelAlias } : {}),
|
|
849
|
+
streamId: context.streamId,
|
|
850
|
+
partial: chunk.partial
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
else if (context.emitRunEvents === true && isObjectFinishChunk(chunk)) {
|
|
854
|
+
await emitEvent({
|
|
855
|
+
type: 'model.object',
|
|
856
|
+
runId: context.runId,
|
|
857
|
+
...(context.agentId ? { agentId: context.agentId } : {}),
|
|
858
|
+
...(context.workflowId ? { workflowId: context.workflowId } : {}),
|
|
859
|
+
...(context.modelAlias ? { modelAlias: context.modelAlias } : {}),
|
|
860
|
+
...(context.streamId ? { streamId: context.streamId } : {}),
|
|
861
|
+
object: chunk.object,
|
|
862
|
+
...(chunk.usage ? { usage: chunk.usage } : {})
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
yield chunk;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
function isTextDeltaChunk(chunk) {
|
|
869
|
+
return Boolean(chunk && typeof chunk === 'object' && chunk.kind === 'delta' && typeof chunk.text === 'string');
|
|
870
|
+
}
|
|
871
|
+
function isObjectPartialChunk(chunk) {
|
|
872
|
+
return Boolean(chunk && typeof chunk === 'object' && chunk.kind === 'partial');
|
|
873
|
+
}
|
|
874
|
+
function isObjectFinishChunk(chunk) {
|
|
875
|
+
return Boolean(chunk && typeof chunk === 'object' && chunk.kind === 'finish' && Object.prototype.hasOwnProperty.call(chunk, 'object'));
|
|
876
|
+
}
|
|
671
877
|
function configureHarnessAdapters(context, models, state, sandbox, memory, tools) {
|
|
672
878
|
const seen = new Set();
|
|
673
879
|
for (const alias of Object.values(models)) {
|
|
@@ -844,12 +1050,12 @@ function sanitizeEventForPersistence(event) {
|
|
|
844
1050
|
case 'model.message':
|
|
845
1051
|
return { agentId: event.agentId, message: '[redacted]' };
|
|
846
1052
|
case 'model.delta':
|
|
847
|
-
return {
|
|
1053
|
+
return { ...modelStreamEventMeta(event), delta: '[redacted]' };
|
|
848
1054
|
case 'model.object.partial':
|
|
849
|
-
return { ...(event
|
|
1055
|
+
return { ...modelStreamEventMeta(event), partial: '[redacted]' };
|
|
850
1056
|
case 'model.object':
|
|
851
1057
|
return {
|
|
852
|
-
...(event
|
|
1058
|
+
...modelStreamEventMeta(event),
|
|
853
1059
|
object: '[redacted]',
|
|
854
1060
|
...(event.usage ? { usage: event.usage } : {})
|
|
855
1061
|
};
|
|
@@ -869,8 +1075,22 @@ function sanitizeEventForPersistence(event) {
|
|
|
869
1075
|
};
|
|
870
1076
|
case 'stream.overflow':
|
|
871
1077
|
return { dropped: event.dropped };
|
|
1078
|
+
default: {
|
|
1079
|
+
// Exhaustiveness guard: adding a RunEvent variant without updating this
|
|
1080
|
+
// sanitizer becomes a compile error instead of silently persisting undefined.
|
|
1081
|
+
event;
|
|
1082
|
+
return {};
|
|
1083
|
+
}
|
|
872
1084
|
}
|
|
873
1085
|
}
|
|
1086
|
+
function modelStreamEventMeta(event) {
|
|
1087
|
+
return {
|
|
1088
|
+
...(event.agentId ? { agentId: event.agentId } : {}),
|
|
1089
|
+
...(event.workflowId ? { workflowId: event.workflowId } : {}),
|
|
1090
|
+
...(event.modelAlias ? { modelAlias: event.modelAlias } : {}),
|
|
1091
|
+
...(event.streamId ? { streamId: event.streamId } : {})
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
874
1094
|
function isJsonRecord(value) {
|
|
875
1095
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
876
1096
|
}
|
|
@@ -891,9 +1111,11 @@ function normalizeSerializedRunError(error) {
|
|
|
891
1111
|
}
|
|
892
1112
|
function createRunSignal(parent, timeoutMs) {
|
|
893
1113
|
const controller = new AbortController();
|
|
894
|
-
const relay = () => controller.abort(parent?.reason);
|
|
1114
|
+
const relay = () => controller.abort(runAbortReason(parent?.reason));
|
|
895
1115
|
if (parent)
|
|
896
1116
|
parent.addEventListener('abort', relay, { once: true });
|
|
1117
|
+
if (parent?.aborted)
|
|
1118
|
+
relay();
|
|
897
1119
|
const timeout = timeoutMs && timeoutMs > 0
|
|
898
1120
|
? setTimeout(() => controller.abort(new OperationTimeoutError('Run timed out.', { scope: 'run', timeout_ms: timeoutMs })), timeoutMs)
|
|
899
1121
|
: undefined;
|
|
@@ -911,10 +1133,14 @@ function combineSignals(primary, secondary) {
|
|
|
911
1133
|
if (!secondary)
|
|
912
1134
|
return { signal: primary, cleanup: () => undefined };
|
|
913
1135
|
const controller = new AbortController();
|
|
914
|
-
const relayPrimary = () => controller.abort(primary.reason);
|
|
915
|
-
const relaySecondary = () => controller.abort(secondary.reason);
|
|
1136
|
+
const relayPrimary = () => controller.abort(runAbortReason(primary.reason));
|
|
1137
|
+
const relaySecondary = () => controller.abort(runAbortReason(secondary.reason));
|
|
916
1138
|
primary.addEventListener('abort', relayPrimary, { once: true });
|
|
917
1139
|
secondary.addEventListener('abort', relaySecondary, { once: true });
|
|
1140
|
+
if (primary.aborted)
|
|
1141
|
+
relayPrimary();
|
|
1142
|
+
else if (secondary.aborted)
|
|
1143
|
+
relaySecondary();
|
|
918
1144
|
return {
|
|
919
1145
|
signal: controller.signal,
|
|
920
1146
|
cleanup: () => {
|
|
@@ -923,3 +1149,8 @@ function combineSignals(primary, secondary) {
|
|
|
923
1149
|
}
|
|
924
1150
|
};
|
|
925
1151
|
}
|
|
1152
|
+
function runAbortReason(reason) {
|
|
1153
|
+
if (reason instanceof OperationCancelledError || reason instanceof OperationTimeoutError)
|
|
1154
|
+
return reason;
|
|
1155
|
+
return new OperationCancelledError('Run was cancelled.', { scope: 'run' }, reason);
|
|
1156
|
+
}
|