@nylorun/harness 0.5.0-beta.1 → 0.8.0-beta.1
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 +63 -0
- package/README.md +27 -108
- package/dist/build/agent.d.ts +6 -7
- package/dist/build/agent.js +16 -7
- package/dist/build/assemble.d.ts +5 -6
- package/dist/build/assemble.js +10 -43
- package/dist/build/bind-tool.d.ts +2 -3
- package/dist/build/bind-tool.js +6 -4
- package/dist/build/builder.d.ts +30 -15
- package/dist/build/builder.js +89 -38
- package/dist/build/helpers.d.ts +2 -3
- package/dist/build/helpers.js +0 -1
- package/dist/build/manifest.d.ts +2 -4
- package/dist/build/manifest.js +2 -8
- package/dist/errors.d.ts +1 -1
- package/dist/index.d.ts +7 -6
- package/dist/index.js +2 -2
- package/dist/session/capability-state.d.ts +14 -0
- package/dist/session/capability-state.js +67 -0
- package/dist/session/input-queue.d.ts +11 -2
- package/dist/session/input-queue.js +5 -1
- package/dist/session/record.d.ts +11 -0
- package/dist/session/record.js +26 -0
- package/dist/session/scheduler.d.ts +17 -3
- package/dist/session/scheduler.js +181 -48
- package/dist/session/seed.d.ts +10 -0
- package/dist/session/seed.js +219 -0
- package/dist/session/session.d.ts +3 -2
- package/dist/session/session.js +15 -3
- package/dist/session/state.d.ts +8 -3
- package/dist/session/state.js +16 -4
- package/dist/session/submission-stream.d.ts +2 -0
- package/dist/session/submission-stream.js +11 -1
- package/dist/step/model-configuration.d.ts +1 -3
- package/dist/step/model-configuration.js +2 -4
- package/dist/step/project.js +4 -2
- package/dist/step/run.d.ts +6 -1
- package/dist/step/run.js +35 -13
- package/dist/step/seal.d.ts +6 -2
- package/dist/step/seal.js +4 -3
- package/dist/step/step-context.d.ts +5 -2
- package/dist/step/step-context.js +19 -11
- package/dist/turn/plan-runner.d.ts +28 -13
- package/dist/turn/plan-runner.js +165 -182
- package/dist/turn/runner.d.ts +18 -4
- package/dist/turn/runner.js +73 -45
- package/dist/types/manifest.d.ts +2 -6
- package/dist/types/middleware.d.ts +25 -4
- package/dist/types/model.d.ts +3 -2
- package/dist/types/session.d.ts +86 -2
- package/dist/types/shared.d.ts +65 -9
- package/dist/types/tool.d.ts +37 -39
- package/dist/utils/immutable.js +16 -2
- package/package.json +1 -2
- package/dist/build/adapters.d.ts +0 -11
- package/dist/build/adapters.js +0 -91
- package/docs/loop.md +0 -47
- package/docs/model-call-projection.md +0 -112
- package/docs/reference.md +0 -122
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { HarnessError } from "../errors.js";
|
|
2
2
|
import { createId } from "../utils/ids.js";
|
|
3
3
|
import { createObserverRegistry } from "../utils/observe.js";
|
|
4
|
-
import { InputQueue, isInteractionReply,
|
|
4
|
+
import { InputQueue, isInteractionReply, snapshotWork, watchInputAbort, } from "./input-queue.js";
|
|
5
5
|
import { SessionEventLog } from "./event-log.js";
|
|
6
6
|
import { SubmissionStream } from "./submission-stream.js";
|
|
7
|
-
import { commitToolResults, initialState, withStatus } from "./state.js";
|
|
7
|
+
import { commitInput, commitToolResults, initialState, withStatus } from "./state.js";
|
|
8
8
|
import {} from "../build/agent.js";
|
|
9
9
|
import { TurnRunner } from "../turn/runner.js";
|
|
10
|
+
import { sessionRecord } from "./record.js";
|
|
11
|
+
import { CapabilityStateRegistry } from "./capability-state.js";
|
|
10
12
|
/** Coordinates one active turn at a time; TurnRunner owns the turn's internal state machine. */
|
|
11
13
|
export class SessionScheduler {
|
|
12
14
|
id;
|
|
@@ -20,16 +22,38 @@ export class SessionScheduler {
|
|
|
20
22
|
pending;
|
|
21
23
|
inFlightPlan;
|
|
22
24
|
running = false;
|
|
25
|
+
stopping = false;
|
|
23
26
|
stopped = false;
|
|
27
|
+
suspended = false;
|
|
24
28
|
generation = 0;
|
|
25
29
|
stopPromise;
|
|
30
|
+
recordFailure;
|
|
26
31
|
observers = createObserverRegistry();
|
|
27
32
|
turns;
|
|
28
|
-
|
|
33
|
+
states;
|
|
34
|
+
constructor(id, agent, session, options = {}) {
|
|
29
35
|
this.id = id;
|
|
30
|
-
this.snapshotValue = initialState(id);
|
|
36
|
+
this.snapshotValue = initialState(id, options.seed);
|
|
31
37
|
this.turns = new TurnRunner(agent, id, session);
|
|
38
|
+
this.session = session;
|
|
39
|
+
this.recorder = options.recorder;
|
|
40
|
+
this.states = new CapabilityStateRegistry(agent.middleware, Object.freeze({ id, ...session }), (event) => this.emitObserve(event));
|
|
41
|
+
if (options.seed) {
|
|
42
|
+
const event = Object.freeze({
|
|
43
|
+
type: "session.seeded",
|
|
44
|
+
revision: options.seed.revision,
|
|
45
|
+
transcriptEntries: options.seed.transcript.length,
|
|
46
|
+
});
|
|
47
|
+
// Construction precedes public observer registration. Defer this live-only fact
|
|
48
|
+
// by one microtask so callers can subscribe immediately after run({ seed }).
|
|
49
|
+
queueMicrotask(() => {
|
|
50
|
+
if (!this.stopped)
|
|
51
|
+
this.emitObserve(event);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
32
54
|
}
|
|
55
|
+
session;
|
|
56
|
+
recorder;
|
|
33
57
|
get snapshot() {
|
|
34
58
|
return this.snapshotValue;
|
|
35
59
|
}
|
|
@@ -39,9 +63,15 @@ export class SessionScheduler {
|
|
|
39
63
|
return this.observers.observe(listener);
|
|
40
64
|
}
|
|
41
65
|
submit(event, options) {
|
|
66
|
+
return this.submitWork(event, options);
|
|
67
|
+
}
|
|
68
|
+
continue(options) {
|
|
69
|
+
return this.submitWork({ kind: "continue" }, options);
|
|
70
|
+
}
|
|
71
|
+
submitWork(event, options) {
|
|
42
72
|
const stream = new SubmissionStream(createId("input"));
|
|
43
73
|
const submission = {
|
|
44
|
-
event:
|
|
74
|
+
event: snapshotWork(event),
|
|
45
75
|
options,
|
|
46
76
|
stream,
|
|
47
77
|
cancelled: false,
|
|
@@ -50,7 +80,7 @@ export class SessionScheduler {
|
|
|
50
80
|
return this.finishStopped(stream);
|
|
51
81
|
if (options?.signal?.aborted)
|
|
52
82
|
return this.finishCancelled(stream, cancellationMessage(options.signal));
|
|
53
|
-
if (isInteractionReply(event)) {
|
|
83
|
+
if (event.kind !== "continue" && isInteractionReply(event)) {
|
|
54
84
|
if (!this.enqueueReply(submission, event))
|
|
55
85
|
return stream;
|
|
56
86
|
}
|
|
@@ -68,11 +98,39 @@ export class SessionScheduler {
|
|
|
68
98
|
stop(reason = "Session stopped") {
|
|
69
99
|
if (this.stopPromise)
|
|
70
100
|
return this.stopPromise;
|
|
71
|
-
this.
|
|
101
|
+
this.stopping = true;
|
|
102
|
+
this.generation += 1;
|
|
103
|
+
const stopError = new HarnessError("session.stale-result", reason);
|
|
104
|
+
this.sessionController.abort(stopError);
|
|
105
|
+
this.activeController?.abort(stopError);
|
|
106
|
+
this.states.abort(stopError);
|
|
72
107
|
const activeWork = this.activeWork;
|
|
108
|
+
const activeStream = this.activeSubmission?.stream;
|
|
109
|
+
const pending = this.pending;
|
|
110
|
+
this.pending = undefined;
|
|
73
111
|
this.stopPromise = (async () => {
|
|
74
112
|
if (activeWork)
|
|
75
113
|
await activeWork;
|
|
114
|
+
if (pending && !this.recordFailure)
|
|
115
|
+
await this.commitCancelledPlan(pending, reason);
|
|
116
|
+
const stopped = await this.commit(withStatus(this.snapshotValue, "stopped"), "stopped");
|
|
117
|
+
this.snapshotValue = stopped;
|
|
118
|
+
this.stopped = true;
|
|
119
|
+
this.stopping = false;
|
|
120
|
+
const event = { type: "session.stopped", sessionId: this.id };
|
|
121
|
+
this.events.emit(event);
|
|
122
|
+
this.events.finish();
|
|
123
|
+
if (activeStream) {
|
|
124
|
+
activeStream.emit(event);
|
|
125
|
+
activeStream.finish("stopped");
|
|
126
|
+
}
|
|
127
|
+
for (const item of this.queue.drain()) {
|
|
128
|
+
item.stream.emit(event);
|
|
129
|
+
item.stream.finish("stopped");
|
|
130
|
+
}
|
|
131
|
+
this.emitObserve({ type: "session.stopped", reason });
|
|
132
|
+
await this.states.shutdown(stopError);
|
|
133
|
+
this.observers.clear();
|
|
76
134
|
})();
|
|
77
135
|
return this.stopPromise;
|
|
78
136
|
}
|
|
@@ -147,7 +205,7 @@ export class SessionScheduler {
|
|
|
147
205
|
this.events.emit(event);
|
|
148
206
|
}
|
|
149
207
|
pump() {
|
|
150
|
-
if (this.running || this.stopped)
|
|
208
|
+
if (this.running || this.stopping || this.stopped || this.suspended)
|
|
151
209
|
return;
|
|
152
210
|
const next = this.queue.take(Boolean(this.pending));
|
|
153
211
|
if (!next)
|
|
@@ -172,8 +230,6 @@ export class SessionScheduler {
|
|
|
172
230
|
this.activeController = undefined;
|
|
173
231
|
if (this.activeWork === work)
|
|
174
232
|
this.activeWork = undefined;
|
|
175
|
-
if (!this.stopped && !this.pending)
|
|
176
|
-
this.snapshotValue = withStatus(this.snapshotValue, "idle");
|
|
177
233
|
this.pump();
|
|
178
234
|
});
|
|
179
235
|
this.activeWork = work;
|
|
@@ -183,27 +239,40 @@ export class SessionScheduler {
|
|
|
183
239
|
try {
|
|
184
240
|
const context = {
|
|
185
241
|
signal,
|
|
242
|
+
states: this.states,
|
|
186
243
|
observe: ((event) => this.emitObserve(() => withInputId(typeof event === "function" ? event() : event, submission.stream.inputId))),
|
|
187
244
|
assertCurrent: () => this.assertCurrent(generation, signal),
|
|
188
245
|
onPlanActive: (plan) => {
|
|
189
246
|
this.inFlightPlan = plan;
|
|
190
247
|
},
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
248
|
+
commit: (state, transition, active) => this.commit(state, transition, active).then((committed) => {
|
|
249
|
+
if (transition === "model-requested" && submission.event.kind === "continue")
|
|
250
|
+
this.emitObserve({ type: "session.continued", inputId: submission.stream.inputId });
|
|
251
|
+
return committed;
|
|
252
|
+
}),
|
|
194
253
|
onConversation: (event) => this.publish(submission.stream, event),
|
|
195
|
-
claimInterrupts: (turnId) => this.claimInterrupts(turnId),
|
|
254
|
+
claimInterrupts: (state, turnId) => this.claimInterrupts(state, turnId),
|
|
196
255
|
};
|
|
197
256
|
const outcome = this.pending
|
|
198
257
|
? await this.resumeTurn(submission, context)
|
|
199
|
-
:
|
|
258
|
+
: submission.event.kind === "continue"
|
|
259
|
+
? await this.turns.continue(this.snapshotValue, context)
|
|
260
|
+
: await this.turns.start(this.snapshotValue, submission.event, context);
|
|
200
261
|
this.applyTurnOutcome(submission.stream, outcome);
|
|
201
262
|
}
|
|
202
263
|
catch (error) {
|
|
203
|
-
if (this.inFlightPlan) {
|
|
204
|
-
|
|
264
|
+
if (this.inFlightPlan && !this.recordFailure) {
|
|
265
|
+
try {
|
|
266
|
+
await this.commitCancelledPlan(this.inFlightPlan, message(error));
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
if (this.recordFailure)
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
205
272
|
this.inFlightPlan = undefined;
|
|
206
273
|
}
|
|
274
|
+
if (this.recordFailure || this.stopping)
|
|
275
|
+
return;
|
|
207
276
|
if (this.stopped)
|
|
208
277
|
this.finishStopped(submission.stream);
|
|
209
278
|
else {
|
|
@@ -216,13 +285,14 @@ export class SessionScheduler {
|
|
|
216
285
|
async resumeTurn(submission, context) {
|
|
217
286
|
const pending = this.pending;
|
|
218
287
|
this.pending = undefined;
|
|
219
|
-
|
|
220
|
-
|
|
288
|
+
if (submission.event.kind === "continue")
|
|
289
|
+
throw new HarnessError("interaction.uncorrelated-resume", "Continue cannot answer an interaction");
|
|
290
|
+
return this.turns.resume(withStatus(this.snapshotValue, "running"), pending, submission.event, context);
|
|
221
291
|
}
|
|
222
292
|
applyTurnOutcome(stream, outcome) {
|
|
223
293
|
switch (outcome.kind) {
|
|
224
294
|
case "final":
|
|
225
|
-
this.snapshotValue =
|
|
295
|
+
this.snapshotValue = outcome.state;
|
|
226
296
|
this.publish(stream, { type: "final", output: outcome.output, turnId: outcome.turnId });
|
|
227
297
|
this.emitObserve({
|
|
228
298
|
type: "turn.completed",
|
|
@@ -235,7 +305,7 @@ export class SessionScheduler {
|
|
|
235
305
|
return;
|
|
236
306
|
case "interaction-required":
|
|
237
307
|
this.pending = outcome.pending;
|
|
238
|
-
this.snapshotValue =
|
|
308
|
+
this.snapshotValue = outcome.state;
|
|
239
309
|
this.emitObserve({
|
|
240
310
|
type: "interaction.required",
|
|
241
311
|
turnId: outcome.pending.turnId,
|
|
@@ -249,9 +319,7 @@ export class SessionScheduler {
|
|
|
249
319
|
...(outcome.pending.plan.interactionToolName === undefined
|
|
250
320
|
? {}
|
|
251
321
|
: { toolName: outcome.pending.plan.interactionToolName }),
|
|
252
|
-
|
|
253
|
-
? {}
|
|
254
|
-
: { phase: outcome.pending.plan.interactionPhase }),
|
|
322
|
+
phase: "interaction",
|
|
255
323
|
attributes: {
|
|
256
324
|
prompt: outcome.interaction.prompt,
|
|
257
325
|
...(outcome.interaction.metadata === undefined
|
|
@@ -266,6 +334,25 @@ export class SessionScheduler {
|
|
|
266
334
|
});
|
|
267
335
|
stream.finish("waiting");
|
|
268
336
|
return;
|
|
337
|
+
case "deferred":
|
|
338
|
+
this.suspended = true;
|
|
339
|
+
this.snapshotValue = outcome.state;
|
|
340
|
+
if (outcome.active.kind === "model")
|
|
341
|
+
this.emitObserve({
|
|
342
|
+
type: "model.deferred",
|
|
343
|
+
turnId: outcome.turnId,
|
|
344
|
+
stepId: outcome.stepId,
|
|
345
|
+
inputId: stream.inputId,
|
|
346
|
+
invocationId: outcome.active.invocationId,
|
|
347
|
+
attributes: outcome.active.token === undefined ? {} : { token: outcome.active.token },
|
|
348
|
+
});
|
|
349
|
+
this.publish(stream, {
|
|
350
|
+
type: "execution.deferred",
|
|
351
|
+
active: outcome.active,
|
|
352
|
+
turnId: outcome.turnId,
|
|
353
|
+
});
|
|
354
|
+
stream.finish("waiting");
|
|
355
|
+
return;
|
|
269
356
|
case "tripwire":
|
|
270
357
|
this.snapshotValue = outcome.state;
|
|
271
358
|
this.publish(stream, {
|
|
@@ -283,40 +370,24 @@ export class SessionScheduler {
|
|
|
283
370
|
attributes: { message: outcome.tripwire.message },
|
|
284
371
|
});
|
|
285
372
|
if (outcome.tripwire.scope === "session")
|
|
286
|
-
this.
|
|
373
|
+
void this.stop("Session policy tripwire");
|
|
287
374
|
else
|
|
288
375
|
this.snapshotValue = withStatus(this.snapshotValue, "idle");
|
|
289
376
|
stream.finish("completed");
|
|
290
377
|
}
|
|
291
378
|
}
|
|
292
|
-
beginStop(reason) {
|
|
293
|
-
if (this.stopped)
|
|
294
|
-
return;
|
|
295
|
-
this.stopped = true;
|
|
296
|
-
this.generation += 1;
|
|
297
|
-
this.sessionController.abort(new HarnessError("session.stale-result", reason));
|
|
298
|
-
this.activeController?.abort(new HarnessError("session.stale-result", reason));
|
|
299
|
-
if (this.pending)
|
|
300
|
-
this.commitCancelledPlan(this.pending, reason);
|
|
301
|
-
this.pending = undefined;
|
|
302
|
-
this.snapshotValue = withStatus(this.snapshotValue, "stopped");
|
|
303
|
-
this.events.emit({ type: "session.stopped", sessionId: this.id });
|
|
304
|
-
this.events.finish();
|
|
305
|
-
this.emitObserve({ type: "session.stopped", reason });
|
|
306
|
-
this.observers.clear();
|
|
307
|
-
for (const item of this.queue.drain())
|
|
308
|
-
this.finishStopped(item.stream);
|
|
309
|
-
}
|
|
310
379
|
emitObserve(event) {
|
|
311
380
|
this.observers.emit(event);
|
|
312
381
|
}
|
|
313
|
-
commitCancelledPlan(pending, reason) {
|
|
314
|
-
this.snapshotValue = commitToolResults(this.snapshotValue, pending.turnId, pending.stepId, pending.plan.cancelledResults(reason));
|
|
382
|
+
async commitCancelledPlan(pending, reason) {
|
|
383
|
+
this.snapshotValue = await this.commit(commitToolResults(this.snapshotValue, pending.turnId, pending.stepId, pending.plan.cancelledResults(reason)), "tool-results");
|
|
315
384
|
}
|
|
316
|
-
claimInterrupts(turnId) {
|
|
385
|
+
async claimInterrupts(state, turnId) {
|
|
317
386
|
const claimed = this.queue.takeInterrupts();
|
|
318
387
|
const events = [];
|
|
388
|
+
let nextState = state;
|
|
319
389
|
for (const item of claimed) {
|
|
390
|
+
nextState = await this.commit(commitInput(nextState, turnId, item.event), "input");
|
|
320
391
|
const conversation = Object.freeze({
|
|
321
392
|
type: "input",
|
|
322
393
|
event: item.event,
|
|
@@ -328,10 +399,72 @@ export class SessionScheduler {
|
|
|
328
399
|
item.stream.finish("completed");
|
|
329
400
|
events.push(item.event);
|
|
330
401
|
}
|
|
331
|
-
return Object.freeze(events);
|
|
402
|
+
return Object.freeze({ state: nextState, arrivals: Object.freeze(events) });
|
|
403
|
+
}
|
|
404
|
+
async commit(state, transition, active) {
|
|
405
|
+
this.assertRecordable();
|
|
406
|
+
const revision = this.snapshotValue.revision + 1;
|
|
407
|
+
const { active: _oldActive, ...rest } = state;
|
|
408
|
+
const next = Object.freeze({
|
|
409
|
+
...rest,
|
|
410
|
+
revision,
|
|
411
|
+
...(state.pendingInteraction === undefined
|
|
412
|
+
? {}
|
|
413
|
+
: { pendingInteraction: state.pendingInteraction }),
|
|
414
|
+
...(active === undefined ? {} : { active }),
|
|
415
|
+
});
|
|
416
|
+
if (this.recorder) {
|
|
417
|
+
const value = sessionRecord({ state: next, transition, session: this.session, active });
|
|
418
|
+
try {
|
|
419
|
+
await this.recorder.record(value);
|
|
420
|
+
}
|
|
421
|
+
catch (cause) {
|
|
422
|
+
const error = new HarnessError("session.record-failed", "Session recorder failed", {
|
|
423
|
+
cause,
|
|
424
|
+
});
|
|
425
|
+
this.failRecording(error);
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
this.snapshotValue = next;
|
|
430
|
+
return next;
|
|
431
|
+
}
|
|
432
|
+
assertRecordable() {
|
|
433
|
+
if (this.recordFailure)
|
|
434
|
+
throw this.recordFailure;
|
|
435
|
+
if (this.stopped && !this.stopping)
|
|
436
|
+
throw new HarnessError("session.stale-result", "Stopped Session cannot record state");
|
|
437
|
+
}
|
|
438
|
+
failRecording(error) {
|
|
439
|
+
if (this.recordFailure)
|
|
440
|
+
return;
|
|
441
|
+
this.recordFailure = error;
|
|
442
|
+
this.stopping = false;
|
|
443
|
+
this.stopped = true;
|
|
444
|
+
this.generation += 1;
|
|
445
|
+
this.sessionController.abort(error);
|
|
446
|
+
this.activeController?.abort(error);
|
|
447
|
+
void this.states.shutdown(error);
|
|
448
|
+
this.pending = undefined;
|
|
449
|
+
this.inFlightPlan = undefined;
|
|
450
|
+
this.snapshotValue = withStatus(this.snapshotValue, "stopped");
|
|
451
|
+
this.emitObserve({
|
|
452
|
+
type: "session.record.failed",
|
|
453
|
+
code: error.code,
|
|
454
|
+
attributes: { message: error.message },
|
|
455
|
+
});
|
|
456
|
+
const event = { type: "session.stopped", sessionId: this.id };
|
|
457
|
+
this.events.emit(event);
|
|
458
|
+
this.events.finish();
|
|
459
|
+
this.activeSubmission?.stream.emit(event);
|
|
460
|
+
this.activeSubmission?.stream.fail(error);
|
|
461
|
+
for (const item of this.queue.drain()) {
|
|
462
|
+
item.stream.emit(event);
|
|
463
|
+
item.stream.finish("stopped");
|
|
464
|
+
}
|
|
332
465
|
}
|
|
333
466
|
assertCurrent(generation, signal) {
|
|
334
|
-
// An abort-ignoring
|
|
467
|
+
// An abort-ignoring model or tool may resolve late; it must never re-enter this Session.
|
|
335
468
|
if (this.stopped || generation !== this.generation || signal.aborted)
|
|
336
469
|
throw (signal.reason ??
|
|
337
470
|
new HarnessError("session.stale-result", "Stale Session result quarantined"));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SessionSeed, TranscriptEntry } from "../types/session.js";
|
|
2
|
+
export interface NormalizedSessionSeed {
|
|
3
|
+
readonly id?: string;
|
|
4
|
+
readonly userId?: string;
|
|
5
|
+
readonly context?: import("../types/shared.js").JsonObject;
|
|
6
|
+
readonly turnCount: number;
|
|
7
|
+
readonly revision: number;
|
|
8
|
+
readonly transcript: readonly TranscriptEntry[];
|
|
9
|
+
}
|
|
10
|
+
export declare function normalizeSessionSeed(seed: SessionSeed): NormalizedSessionSeed;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { HarnessError } from "../errors.js";
|
|
2
|
+
import { normalizeCandidate } from "../model-normalize.js";
|
|
3
|
+
import { assertJson, copyJson, copyJsonObject } from "../utils/immutable.js";
|
|
4
|
+
export function normalizeSessionSeed(seed) {
|
|
5
|
+
try {
|
|
6
|
+
return normalizeSeed(seed);
|
|
7
|
+
}
|
|
8
|
+
catch (cause) {
|
|
9
|
+
if (cause instanceof HarnessError && cause.code === "session.invalid-seed")
|
|
10
|
+
throw cause;
|
|
11
|
+
throw new HarnessError("session.invalid-seed", "Session seed contains invalid typed JSON", {
|
|
12
|
+
cause,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function normalizeSeed(seed) {
|
|
17
|
+
if (!seed || typeof seed !== "object")
|
|
18
|
+
fail("Session seed must be an object");
|
|
19
|
+
if (!Array.isArray(seed.transcript))
|
|
20
|
+
fail("Session seed transcript must be an array");
|
|
21
|
+
optionalString(seed.id, "Session seed id");
|
|
22
|
+
optionalString(seed.userId, "Session seed userId");
|
|
23
|
+
const turnCount = count(seed.turnCount, "turnCount");
|
|
24
|
+
const revision = count(seed.revision, "revision");
|
|
25
|
+
const transcript = Object.freeze(seed.transcript.map((entry, index) => entryAt(entry, index)));
|
|
26
|
+
return Object.freeze({
|
|
27
|
+
...(seed.id === undefined ? {} : { id: seed.id }),
|
|
28
|
+
...(seed.userId === undefined ? {} : { userId: seed.userId }),
|
|
29
|
+
...(seed.context === undefined
|
|
30
|
+
? {}
|
|
31
|
+
: { context: copyJsonObject(seed.context, "session seed context") }),
|
|
32
|
+
turnCount,
|
|
33
|
+
revision,
|
|
34
|
+
transcript,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function entryAt(value, index) {
|
|
38
|
+
if (!value || typeof value !== "object")
|
|
39
|
+
fail(`Transcript entry ${index} must be an object`);
|
|
40
|
+
const entry = value;
|
|
41
|
+
requiredString(entry.kind, `Transcript entry ${index} kind`);
|
|
42
|
+
requiredString(entry.turnId, `Transcript entry ${index} turnId`);
|
|
43
|
+
switch (entry.kind) {
|
|
44
|
+
case "input":
|
|
45
|
+
exactKeys(entry, ["kind", "turnId", "event"], `Transcript entry ${index}`);
|
|
46
|
+
return Object.freeze({
|
|
47
|
+
kind: "input",
|
|
48
|
+
turnId: entry.turnId,
|
|
49
|
+
event: inputEvent(entry.event, index),
|
|
50
|
+
});
|
|
51
|
+
case "candidate": {
|
|
52
|
+
exactKeys(entry, ["kind", "turnId", "stepId", "candidate"], `Transcript entry ${index}`);
|
|
53
|
+
requiredString(entry.stepId, `Transcript entry ${index} stepId`);
|
|
54
|
+
try {
|
|
55
|
+
validateSeedCandidate(entry.candidate, index);
|
|
56
|
+
return Object.freeze({
|
|
57
|
+
kind: "candidate",
|
|
58
|
+
turnId: entry.turnId,
|
|
59
|
+
stepId: entry.stepId,
|
|
60
|
+
candidate: normalizeCandidate(entry.candidate),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch (cause) {
|
|
64
|
+
throw new HarnessError("session.invalid-seed", `Invalid candidate at transcript ${index}`, {
|
|
65
|
+
cause,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
case "tool-results": {
|
|
70
|
+
exactKeys(entry, ["kind", "turnId", "stepId", "results"], `Transcript entry ${index}`);
|
|
71
|
+
requiredString(entry.stepId, `Transcript entry ${index} stepId`);
|
|
72
|
+
if (!Array.isArray(entry.results))
|
|
73
|
+
fail(`Transcript entry ${index} tool results must be an array`);
|
|
74
|
+
const result = Object.freeze({
|
|
75
|
+
kind: "tool-results",
|
|
76
|
+
turnId: entry.turnId,
|
|
77
|
+
stepId: entry.stepId,
|
|
78
|
+
results: Object.freeze(entry.results.map((item, resultIndex) => toolResult(item, resultIndex))),
|
|
79
|
+
});
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
case "final":
|
|
83
|
+
exactKeys(entry, ["kind", "turnId", "stepId", "output"], `Transcript entry ${index}`);
|
|
84
|
+
requiredString(entry.stepId, `Transcript entry ${index} stepId`);
|
|
85
|
+
if (typeof entry.output !== "string")
|
|
86
|
+
fail(`Transcript entry ${index} output must be a string`);
|
|
87
|
+
return Object.freeze({
|
|
88
|
+
kind: "final",
|
|
89
|
+
turnId: entry.turnId,
|
|
90
|
+
stepId: entry.stepId,
|
|
91
|
+
output: entry.output,
|
|
92
|
+
});
|
|
93
|
+
default:
|
|
94
|
+
fail(`Transcript entry ${index} has unknown kind '${String(entry.kind)}'`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function inputEvent(value, index) {
|
|
98
|
+
if (!value || typeof value !== "object")
|
|
99
|
+
fail(`Transcript input ${index} must be an object`);
|
|
100
|
+
const event = value;
|
|
101
|
+
switch (event.kind) {
|
|
102
|
+
case "user-message":
|
|
103
|
+
case "interrupt":
|
|
104
|
+
exactKeys(event, ["kind", "text", "metadata"], `Transcript input ${index}`);
|
|
105
|
+
if (typeof event.text !== "string")
|
|
106
|
+
fail(`Transcript input ${index} text must be a string`);
|
|
107
|
+
return Object.freeze({
|
|
108
|
+
kind: event.kind,
|
|
109
|
+
text: event.text,
|
|
110
|
+
...(event.metadata === undefined
|
|
111
|
+
? {}
|
|
112
|
+
: { metadata: copyJsonObject(event.metadata, `transcript input ${index} metadata`) }),
|
|
113
|
+
});
|
|
114
|
+
case "approve":
|
|
115
|
+
exactKeys(event, ["kind", "interactionId", "approved"], `Transcript input ${index}`);
|
|
116
|
+
requiredString(event.interactionId, `Transcript input ${index} interactionId`);
|
|
117
|
+
if (typeof event.approved !== "boolean")
|
|
118
|
+
fail(`Transcript input ${index} approved must be a boolean`);
|
|
119
|
+
return Object.freeze({
|
|
120
|
+
kind: "approve",
|
|
121
|
+
interactionId: event.interactionId,
|
|
122
|
+
approved: event.approved,
|
|
123
|
+
});
|
|
124
|
+
case "respond":
|
|
125
|
+
exactKeys(event, ["kind", "interactionId", "value"], `Transcript input ${index}`);
|
|
126
|
+
requiredString(event.interactionId, `Transcript input ${index} interactionId`);
|
|
127
|
+
assertJson(event.value, `transcript input ${index} value`);
|
|
128
|
+
return Object.freeze({
|
|
129
|
+
kind: "respond",
|
|
130
|
+
interactionId: event.interactionId,
|
|
131
|
+
value: copyJson(event.value),
|
|
132
|
+
});
|
|
133
|
+
default:
|
|
134
|
+
fail(`Transcript input ${index} has unknown kind '${String(event.kind)}'`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function toolResult(value, index) {
|
|
138
|
+
if (!value || typeof value !== "object")
|
|
139
|
+
fail(`Tool result ${index} must be an object`);
|
|
140
|
+
const result = value;
|
|
141
|
+
requiredString(result.callId, `Tool result ${index} callId`);
|
|
142
|
+
requiredString(result.toolName, `Tool result ${index} toolName`);
|
|
143
|
+
const base = { callId: result.callId, toolName: result.toolName };
|
|
144
|
+
switch (result.kind) {
|
|
145
|
+
case "completed":
|
|
146
|
+
exactKeys(result, ["callId", "toolName", "kind", "output"], `Tool result ${index}`);
|
|
147
|
+
assertJson(result.output, `tool result ${index} output`);
|
|
148
|
+
return Object.freeze({ ...base, kind: "completed", output: copyJson(result.output) });
|
|
149
|
+
case "denied":
|
|
150
|
+
exactKeys(result, ["callId", "toolName", "kind", "reason"], `Tool result ${index}`);
|
|
151
|
+
if (typeof result.reason !== "string")
|
|
152
|
+
fail(`Tool result ${index} reason must be a string`);
|
|
153
|
+
return Object.freeze({ ...base, kind: "denied", reason: result.reason });
|
|
154
|
+
case "failed":
|
|
155
|
+
exactKeys(result, ["callId", "toolName", "kind", "code", "message"], `Tool result ${index}`);
|
|
156
|
+
if (typeof result.code !== "string" || typeof result.message !== "string")
|
|
157
|
+
fail(`Tool result ${index} code and message must be strings`);
|
|
158
|
+
return Object.freeze({
|
|
159
|
+
...base,
|
|
160
|
+
kind: "failed",
|
|
161
|
+
code: result.code,
|
|
162
|
+
message: result.message,
|
|
163
|
+
});
|
|
164
|
+
default:
|
|
165
|
+
fail(`Tool result ${index} has unknown kind '${String(result.kind)}'`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function validateSeedCandidate(value, index) {
|
|
169
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
170
|
+
fail(`Candidate at transcript ${index} must be an object`);
|
|
171
|
+
const candidate = value;
|
|
172
|
+
exactKeys(candidate, ["output", "finishReason", "usage", "evidence"], `Candidate at transcript ${index}`);
|
|
173
|
+
if (!Array.isArray(candidate.output))
|
|
174
|
+
fail(`Candidate at transcript ${index} output must be an array`);
|
|
175
|
+
candidate.output.forEach((value, blockIndex) => {
|
|
176
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
177
|
+
fail(`Candidate output ${blockIndex} at transcript ${index} must be an object`);
|
|
178
|
+
const block = value;
|
|
179
|
+
if (block.type === "text" || block.type === "reasoning") {
|
|
180
|
+
exactKeys(block, ["type", "text"], `Candidate output ${blockIndex}`);
|
|
181
|
+
if (typeof block.text !== "string")
|
|
182
|
+
fail(`Candidate output ${blockIndex} text must be a string`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (block.type === "tool-call") {
|
|
186
|
+
exactKeys(block, ["type", "id", "name", "args", "raw"], `Candidate output ${blockIndex}`);
|
|
187
|
+
requiredString(block.id, `Candidate output ${blockIndex} id`);
|
|
188
|
+
requiredString(block.name, `Candidate output ${blockIndex} name`);
|
|
189
|
+
if (block.raw !== undefined && typeof block.raw !== "string")
|
|
190
|
+
fail(`Candidate output ${blockIndex} raw must be a string`);
|
|
191
|
+
copyJsonObject(block.args, `candidate output ${blockIndex} args`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
fail(`Candidate output ${blockIndex} has unknown kind '${String(block.type)}'`);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
function exactKeys(value, allowed, label) {
|
|
198
|
+
for (const key of Object.keys(value))
|
|
199
|
+
if (!allowed.includes(key))
|
|
200
|
+
fail(`${label} has unknown field '${key}'`);
|
|
201
|
+
}
|
|
202
|
+
function count(value, label) {
|
|
203
|
+
if (value === undefined)
|
|
204
|
+
return 0;
|
|
205
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
206
|
+
fail(`Session seed ${label} must be a non-negative safe integer`);
|
|
207
|
+
return value;
|
|
208
|
+
}
|
|
209
|
+
function optionalString(value, label) {
|
|
210
|
+
if (value !== undefined)
|
|
211
|
+
requiredString(value, label);
|
|
212
|
+
}
|
|
213
|
+
function requiredString(value, label) {
|
|
214
|
+
if (typeof value !== "string" || value.length === 0)
|
|
215
|
+
fail(`${label} must be a non-empty string`);
|
|
216
|
+
}
|
|
217
|
+
function fail(message) {
|
|
218
|
+
throw new HarnessError("session.invalid-seed", message);
|
|
219
|
+
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import type { InputHandle, InputOptions, MessageInput, Session, SessionEvent,
|
|
1
|
+
import type { InputHandle, InputOptions, MessageInput, Session, SessionEvent, SessionRunOptions, SessionSnapshot, SessionInput } from "../types/session.js";
|
|
2
2
|
import type { Observer } from "../types/shared.js";
|
|
3
3
|
import type { LoopAgent } from "../build/agent.js";
|
|
4
4
|
export declare class LiveSession implements Session {
|
|
5
5
|
readonly id: string;
|
|
6
6
|
private readonly scheduler;
|
|
7
|
-
constructor(id: string, agent: LoopAgent, options:
|
|
7
|
+
constructor(id: string, agent: LoopAgent, options: SessionRunOptions);
|
|
8
8
|
get state(): SessionSnapshot;
|
|
9
9
|
input(event: SessionInput, options?: InputOptions): InputHandle;
|
|
10
10
|
interrupt(event: MessageInput, options?: InputOptions): InputHandle;
|
|
11
|
+
continue(options?: InputOptions): InputHandle;
|
|
11
12
|
stream(): AsyncIterable<SessionEvent>;
|
|
12
13
|
observe(listener: Observer): () => void;
|
|
13
14
|
stop(reason?: string): Promise<void>;
|
package/dist/session/session.js
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import { SessionScheduler } from "./scheduler.js";
|
|
2
2
|
import { copyJsonObject } from "../utils/immutable.js";
|
|
3
|
+
import { normalizeSessionSeed } from "./seed.js";
|
|
3
4
|
export class LiveSession {
|
|
4
5
|
id;
|
|
5
6
|
scheduler;
|
|
6
7
|
constructor(id, agent, options) {
|
|
7
8
|
this.id = id;
|
|
9
|
+
const seed = "seed" in options && options.seed ? normalizeSessionSeed(options.seed) : undefined;
|
|
10
|
+
const userId = seed?.userId ?? ("userId" in options ? options.userId : undefined);
|
|
11
|
+
const suppliedContext = seed?.context ?? ("context" in options ? options.context : undefined);
|
|
8
12
|
const session = Object.freeze({
|
|
9
|
-
...(
|
|
10
|
-
...(
|
|
13
|
+
...(userId === undefined ? {} : { userId }),
|
|
14
|
+
...(suppliedContext === undefined
|
|
15
|
+
? {}
|
|
16
|
+
: { context: copyJsonObject(suppliedContext, "session context") }),
|
|
17
|
+
});
|
|
18
|
+
this.scheduler = new SessionScheduler(id, agent, session, {
|
|
19
|
+
...(seed === undefined ? {} : { seed }),
|
|
20
|
+
...(options.recorder === undefined ? {} : { recorder: options.recorder }),
|
|
11
21
|
});
|
|
12
|
-
this.scheduler = new SessionScheduler(id, agent, session);
|
|
13
22
|
}
|
|
14
23
|
get state() {
|
|
15
24
|
return this.scheduler.snapshot;
|
|
@@ -20,6 +29,9 @@ export class LiveSession {
|
|
|
20
29
|
interrupt(event, options) {
|
|
21
30
|
return this.scheduler.submit(normalizeMessage("interrupt", event), options);
|
|
22
31
|
}
|
|
32
|
+
continue(options) {
|
|
33
|
+
return this.scheduler.continue(options);
|
|
34
|
+
}
|
|
23
35
|
stream() {
|
|
24
36
|
return this.scheduler.events;
|
|
25
37
|
}
|