@nylorun/harness 0.5.0-beta.1 → 0.7.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +22 -108
  3. package/dist/build/agent.d.ts +4 -7
  4. package/dist/build/agent.js +12 -7
  5. package/dist/build/assemble.d.ts +2 -6
  6. package/dist/build/assemble.js +4 -47
  7. package/dist/build/bind-tool.d.ts +2 -3
  8. package/dist/build/bind-tool.js +6 -4
  9. package/dist/build/builder.d.ts +5 -8
  10. package/dist/build/builder.js +34 -13
  11. package/dist/build/helpers.d.ts +2 -3
  12. package/dist/build/helpers.js +0 -1
  13. package/dist/build/manifest.d.ts +0 -4
  14. package/dist/build/manifest.js +0 -8
  15. package/dist/errors.d.ts +1 -1
  16. package/dist/index.d.ts +5 -5
  17. package/dist/index.js +1 -1
  18. package/dist/session/capability-state.d.ts +14 -0
  19. package/dist/session/capability-state.js +67 -0
  20. package/dist/session/input-queue.d.ts +11 -2
  21. package/dist/session/input-queue.js +5 -1
  22. package/dist/session/record.d.ts +11 -0
  23. package/dist/session/record.js +26 -0
  24. package/dist/session/scheduler.d.ts +17 -3
  25. package/dist/session/scheduler.js +181 -48
  26. package/dist/session/seed.d.ts +10 -0
  27. package/dist/session/seed.js +219 -0
  28. package/dist/session/session.d.ts +3 -2
  29. package/dist/session/session.js +15 -3
  30. package/dist/session/state.d.ts +8 -3
  31. package/dist/session/state.js +16 -4
  32. package/dist/session/submission-stream.d.ts +2 -0
  33. package/dist/session/submission-stream.js +11 -1
  34. package/dist/step/model-configuration.d.ts +1 -3
  35. package/dist/step/model-configuration.js +2 -4
  36. package/dist/step/project.js +4 -2
  37. package/dist/step/run.d.ts +6 -1
  38. package/dist/step/run.js +35 -13
  39. package/dist/step/seal.d.ts +6 -2
  40. package/dist/step/seal.js +4 -3
  41. package/dist/step/step-context.d.ts +5 -2
  42. package/dist/step/step-context.js +19 -11
  43. package/dist/turn/plan-runner.d.ts +28 -13
  44. package/dist/turn/plan-runner.js +165 -182
  45. package/dist/turn/runner.d.ts +18 -4
  46. package/dist/turn/runner.js +73 -45
  47. package/dist/types/manifest.d.ts +0 -6
  48. package/dist/types/middleware.d.ts +25 -4
  49. package/dist/types/model.d.ts +3 -2
  50. package/dist/types/session.d.ts +86 -2
  51. package/dist/types/shared.d.ts +65 -9
  52. package/dist/types/tool.d.ts +37 -39
  53. package/dist/utils/immutable.js +16 -2
  54. package/package.json +1 -2
  55. package/dist/build/adapters.d.ts +0 -11
  56. package/dist/build/adapters.js +0 -91
  57. package/docs/loop.md +0 -47
  58. package/docs/model-call-projection.md +0 -112
  59. package/docs/reference.md +0 -122
@@ -0,0 +1,67 @@
1
+ import { HarnessError } from "../errors.js";
2
+ /** Owns declared, process-local state for exactly one Session. */
3
+ export class CapabilityStateRegistry {
4
+ session;
5
+ observe;
6
+ #entries = new Map();
7
+ #controller = new AbortController();
8
+ #shutdown;
9
+ constructor(middleware, session, observe) {
10
+ this.session = session;
11
+ this.observe = observe;
12
+ for (const middlewareEntry of middleware) {
13
+ if (middlewareEntry.state)
14
+ this.#entries.set(middlewareEntry.id, {
15
+ id: middlewareEntry.id,
16
+ state: middlewareEntry.state,
17
+ });
18
+ }
19
+ }
20
+ has(id) {
21
+ return this.#entries.has(id);
22
+ }
23
+ get(id) {
24
+ const entry = this.#entries.get(id);
25
+ if (!entry)
26
+ return Promise.reject(new HarnessError("capability.state.undeclared", `Capability '${id}' did not declare session state`));
27
+ if (!entry.promise) {
28
+ entry.promise = Promise.resolve()
29
+ .then(() => entry.state.create(this.session, this.#controller.signal))
30
+ .then((value) => {
31
+ entry.value = value;
32
+ entry.created = true;
33
+ return value;
34
+ }, (cause) => {
35
+ throw new HarnessError("capability.state.create-failed", `Capability '${id}' failed to create session state`, { cause });
36
+ });
37
+ }
38
+ return entry.promise;
39
+ }
40
+ abort(reason) {
41
+ this.#controller.abort(reason);
42
+ }
43
+ shutdown(reason) {
44
+ if (this.#shutdown)
45
+ return this.#shutdown;
46
+ this.abort(reason);
47
+ this.#shutdown = (async () => {
48
+ const created = [...this.#entries.values()].filter((entry) => entry.promise);
49
+ await Promise.allSettled(created.map((entry) => entry.promise));
50
+ for (const entry of [...created].reverse()) {
51
+ if (!entry.created || !entry.state.dispose)
52
+ continue;
53
+ try {
54
+ await entry.state.dispose(entry.value);
55
+ }
56
+ catch (error) {
57
+ this.observe({
58
+ type: "capability.state.dispose.failed",
59
+ capabilityId: entry.id,
60
+ attributes: { message: error instanceof Error ? error.message : String(error) },
61
+ });
62
+ }
63
+ }
64
+ })();
65
+ return this.#shutdown;
66
+ }
67
+ }
@@ -1,11 +1,19 @@
1
1
  import type { InputEvent, InputOptions } from "../types/session.js";
2
2
  import { SubmissionStream } from "./submission-stream.js";
3
+ export type WorkEvent = InputEvent | {
4
+ readonly kind: "continue";
5
+ };
3
6
  export interface QueuedInput {
4
- readonly event: InputEvent;
7
+ readonly event: WorkEvent;
5
8
  readonly options?: InputOptions;
6
9
  readonly stream: SubmissionStream;
7
10
  cancelled: boolean;
8
11
  }
12
+ export type QueuedInterrupt = Omit<QueuedInput, "event"> & {
13
+ readonly event: Extract<InputEvent, {
14
+ kind: "interrupt";
15
+ }>;
16
+ };
9
17
  export interface QueueAbortHandlers {
10
18
  readonly isActive: () => boolean;
11
19
  readonly abortActive: (reason: unknown) => void;
@@ -15,13 +23,14 @@ export declare function isInteractionReply(event: InputEvent): event is Extract<
15
23
  kind: "approve" | "respond";
16
24
  }>;
17
25
  export declare function snapshotInput(event: InputEvent): InputEvent;
26
+ export declare function snapshotWork(event: WorkEvent): WorkEvent;
18
27
  /** Serializes ordinary input while allowing a matching interaction reply to resume immediately. */
19
28
  export declare class InputQueue {
20
29
  private readonly values;
21
30
  get size(): number;
22
31
  add(input: QueuedInput, priority?: boolean): void;
23
32
  take(waitingForInteraction: boolean): QueuedInput | undefined;
24
- takeInterrupts(): QueuedInput[];
33
+ takeInterrupts(): QueuedInterrupt[];
25
34
  remove(input: QueuedInput): boolean;
26
35
  drain(): readonly QueuedInput[];
27
36
  }
@@ -18,6 +18,9 @@ export function snapshotInput(event) {
18
18
  return Object.freeze({ ...event, value: copyJson(event.value) });
19
19
  }
20
20
  }
21
+ export function snapshotWork(event) {
22
+ return event.kind === "continue" ? Object.freeze({ kind: "continue" }) : snapshotInput(event);
23
+ }
21
24
  /** Serializes ordinary input while allowing a matching interaction reply to resume immediately. */
22
25
  export class InputQueue {
23
26
  values = [];
@@ -32,7 +35,8 @@ export class InputQueue {
32
35
  }
33
36
  take(waitingForInteraction) {
34
37
  const next = this.values[0];
35
- if (!next || (waitingForInteraction && !isInteractionReply(next.event)))
38
+ if (!next ||
39
+ (waitingForInteraction && (next.event.kind === "continue" || !isInteractionReply(next.event))))
36
40
  return undefined;
37
41
  return this.values.shift();
38
42
  }
@@ -0,0 +1,11 @@
1
+ import type { ActiveExecutionRecord, SessionRecord, SessionSnapshot } from "../types/session.js";
2
+ import type { JsonObject } from "../types/shared.js";
3
+ export declare function sessionRecord(input: {
4
+ readonly state: SessionSnapshot;
5
+ readonly transition: SessionRecord["transition"];
6
+ readonly session: Readonly<{
7
+ readonly userId?: string;
8
+ readonly context?: JsonObject;
9
+ }>;
10
+ readonly active?: ActiveExecutionRecord;
11
+ }): SessionRecord;
@@ -0,0 +1,26 @@
1
+ import { copyJson } from "../utils/immutable.js";
2
+ export function sessionRecord(input) {
3
+ const value = {
4
+ version: 1,
5
+ revision: input.state.revision,
6
+ transition: input.transition,
7
+ session: {
8
+ id: input.state.id,
9
+ ...(input.session.userId === undefined ? {} : { userId: input.session.userId }),
10
+ ...(input.session.context === undefined ? {} : { context: copyJson(input.session.context) }),
11
+ turnCount: input.state.turnCount,
12
+ status: input.state.status,
13
+ },
14
+ transcript: copyJson(input.state.transcript),
15
+ ...(input.active === undefined ? {} : { active: copyJson(input.active) }),
16
+ };
17
+ return deepFreeze(value);
18
+ }
19
+ function deepFreeze(value, seen = new WeakSet()) {
20
+ if (!value || typeof value !== "object" || seen.has(value))
21
+ return value;
22
+ seen.add(value);
23
+ for (const child of Object.values(value))
24
+ deepFreeze(child, seen);
25
+ return Object.freeze(value);
26
+ }
@@ -1,8 +1,9 @@
1
- import type { InputEvent, InputOptions, SessionSnapshot } from "../types/session.js";
1
+ import type { InputEvent, InputOptions, SessionRecorder, SessionSnapshot } from "../types/session.js";
2
2
  import type { Observer } from "../types/shared.js";
3
3
  import { SessionEventLog } from "./event-log.js";
4
4
  import { SubmissionStream } from "./submission-stream.js";
5
5
  import { type LoopAgent } from "../build/agent.js";
6
+ import type { NormalizedSessionSeed } from "./seed.js";
6
7
  /** Coordinates one active turn at a time; TurnRunner owns the turn's internal state machine. */
7
8
  export declare class SessionScheduler {
8
9
  readonly id: string;
@@ -16,18 +17,29 @@ export declare class SessionScheduler {
16
17
  private pending?;
17
18
  private inFlightPlan?;
18
19
  private running;
20
+ private stopping;
19
21
  private stopped;
22
+ private suspended;
20
23
  private generation;
21
24
  private stopPromise?;
25
+ private recordFailure?;
22
26
  private readonly observers;
23
27
  private readonly turns;
28
+ private readonly states;
24
29
  constructor(id: string, agent: LoopAgent, session: Readonly<{
25
30
  readonly userId?: string;
26
31
  readonly context?: import("../types/shared.js").JsonObject;
27
- }>);
32
+ }>, options?: {
33
+ readonly seed?: NormalizedSessionSeed;
34
+ readonly recorder?: SessionRecorder;
35
+ });
36
+ private readonly session;
37
+ private readonly recorder?;
28
38
  get snapshot(): SessionSnapshot;
29
39
  observe(listener: Observer): () => void;
30
40
  submit(event: InputEvent, options?: InputOptions): SubmissionStream;
41
+ continue(options?: InputOptions): SubmissionStream;
42
+ private submitWork;
31
43
  stop(reason?: string): Promise<void>;
32
44
  private enqueueReply;
33
45
  private enqueueOrdinaryInput;
@@ -40,9 +52,11 @@ export declare class SessionScheduler {
40
52
  private run;
41
53
  private resumeTurn;
42
54
  private applyTurnOutcome;
43
- private beginStop;
44
55
  private emitObserve;
45
56
  private commitCancelledPlan;
46
57
  private claimInterrupts;
58
+ private commit;
59
+ private assertRecordable;
60
+ private failRecording;
47
61
  private assertCurrent;
48
62
  }
@@ -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, snapshotInput, watchInputAbort, } from "./input-queue.js";
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
- constructor(id, agent, session) {
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: snapshotInput(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.beginStop(reason);
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
- onState: (state) => {
192
- this.snapshotValue = state;
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
- : await this.turns.start(this.snapshotValue, submission.event, context);
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
- this.commitCancelledPlan(this.inFlightPlan, message(error));
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
- this.snapshotValue = withStatus(this.snapshotValue, "running");
220
- return this.turns.resume(this.snapshotValue, pending, submission.event, context);
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 = withStatus(outcome.state, "idle");
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 = withStatus(outcome.state, "waiting", outcome.interaction);
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
- ...(outcome.pending.plan.interactionPhase === undefined
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.beginStop("Session policy tripwire");
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 adapter may resolve late; its state must never re-enter this Session.
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;