@noetaris/harness 0.10.0 → 0.12.0

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/README.md CHANGED
@@ -140,6 +140,53 @@ h.store({
140
140
 
141
141
  The framework injects `ctx.sessionId` automatically on every run — no declaration in `Ctx` needed.
142
142
 
143
+ ### Turns and `requestId`
144
+
145
+ With a session store, a session is a conversation thread. Each `agent.run()` on it is one
146
+ **turn** (a request). A turn may span several runs — `run()`, then a `resume()` for each
147
+ interrupt — and every run of a turn is saved with the turn's `requestId`.
148
+
149
+ ```ts
150
+ // one turn per user message, on one thread
151
+ await agent.run({ messages: [msg1] }, { sessionId: 'chat-1', requestId: 'msg-1' })
152
+ await agent.run({ messages: [msg2] }, { sessionId: 'chat-1', requestId: 'msg-2' })
153
+ ```
154
+
155
+ A new turn continues from the state the last turn left: each input field is merged with its
156
+ `reduce` (or replaced when it has none), and the run starts again from the entry step.
157
+ `$error`, `$interrupt` and `$interruptResponses` start fresh for each new turn.
158
+
159
+ `requestId` is optional. It is the turn's idempotency key: a call with a `requestId` the
160
+ session already ran does **not** run it again. What `agent.run(input, { sessionId, requestId })`
161
+ does depends on the session's latest run:
162
+
163
+ | Latest run | No `requestId`, or a new one | The latest run's `requestId` (a retry) |
164
+ |---|---|---|
165
+ | none | runs from the entry step | – |
166
+ | completed | a new turn | returns its outcome, runs nothing |
167
+ | paused on an interrupt | resolves `$error` with `SessionPendingInterruptError` | returns its outcome (`signal: '$interrupt'`), runs nothing |
168
+ | paused by a stop or an unhandled error | merges the input and continues from where it paused | continues from where it paused, **without** merging the input again |
169
+
170
+ A retry of an earlier request (not the latest) is found with the store's optional
171
+ `findRequest` and its outcome is returned; it never runs again, since later turns have
172
+ moved on from it. A store without `findRequest` only recognises the latest run's
173
+ `requestId`, so a late retry of an earlier request runs as a new turn.
174
+
175
+ Notes:
176
+ - A retry returns the request's **current** outcome, not its first response: if the turn
177
+ paused and was then resumed to completion, the retry returns the completed outcome. A
178
+ retry that returns `signal: '$interrupt'` can be resumed from its handle.
179
+ - A returned (not re-run) outcome fires no observer events and saves nothing; its
180
+ `handle.runId` has no stored run and no trace.
181
+ - Without a `requestId`, every call is a new turn. For a session that serves one request,
182
+ pass `requestId: sessionId` to get "run once, then return the result".
183
+ - Duplicate calls at the same time are safe only with a store that supports claims: the
184
+ second call resolves `$error` with `SessionBusyError` and its retry then returns the
185
+ outcome. Without claims, both calls may run.
186
+ - Without a session store, `requestId` is ignored.
187
+ - `sessionId` and `requestId` must be non-empty strings when given; otherwise `agent.run()`
188
+ throws `InvalidRunResourceError`.
189
+
143
190
  ### Interrupts
144
191
 
145
192
  A run can be stopped or resumed:
@@ -252,14 +299,99 @@ h.loop(l =>
252
299
  If a fork fails fast while a sub agent is paused, that sub agent's own session stays
253
300
  paused in its store — clean it up or reuse it the next time the step runs.
254
301
 
302
+ ### Debugging a run
303
+
304
+ `agent.debug(initialState, resources)` starts a run held before its first step and returns a
305
+ `DebugSession`. Each command runs part of the run and resolves with the next **stop**: where the
306
+ run is held, the step that just settled, and a copy of the state. It takes the same arguments as
307
+ `agent.run()` and drives the whole run chain — an interrupt is answered inside the session.
308
+
309
+ ```typescript
310
+ const dbg = agent.debug({}, { sessionId: 's1' })
311
+
312
+ let stop = await dbg.current // held before the first step: kind 'step', reason 'start'
313
+ stop = await dbg.next() // run one step; stop.last is the step that settled
314
+ if (stop.kind === 'step') console.log(stop.last?.name, stop.last?.update?.values)
315
+
316
+ dbg.setBreakpoint('review', (state) => (state.score as number) < 0.5)
317
+ stop = await dbg.continue() // runs until a breakpoint, an interrupt or the end
318
+
319
+ if (stop.kind === 'step') dbg.setState({ score: 0.9 }) // edit state before 'review' runs
320
+ stop = await dbg.continue()
321
+ if (stop.kind === 'interrupt') stop = await dbg.answer(true) // resume; held before the re-run
322
+
323
+ stop = await dbg.continue() // { kind: 'end', outcome: { state, signal } }
324
+ ```
325
+
326
+ The session is local to this process. While it lasts, `run()`, `resume()` and `debug()` on its
327
+ session throw `SessionInDebugError` here; see [LIMITATIONS.md](./LIMITATIONS.md) for other processes.
328
+
329
+ **Stops** (`DebugStop`):
330
+
331
+ | `kind` | Fields | When |
332
+ |---|---|---|
333
+ | `step` | `reason`, `next` (the step about to run), `last` (the step that just settled: `name`, `outcome`, `update`, `signal`, `next`, `error`, `before`), `state`, `frame` | held at a step boundary. `reason` is `start`, `step`, `breakpoint`, `resume` (after `answer`), `fork-entry` or `branch-end` |
334
+ | `interrupt` | `interruptId`, `prompt`, `pending` (every interrupt the run waits on), `last`, `state`, `frame` | a step called `ctx.interrupt()` |
335
+ | `end` | `outcome` (`{ state, signal }`), `last` | the run completed, paused on an error, or was stopped |
336
+
337
+ `state`, `last.before` and `last.update` are `Snapshot`s: `{ values, shared }`. Each top-level key
338
+ is copied with `structuredClone`; a key that can't be cloned (a function, say) keeps its reference
339
+ and is listed in `shared`.
340
+
341
+ **Commands** (`DebugSession`):
342
+
343
+ | Command | Does |
344
+ |---|---|
345
+ | `current` | promise of the latest stop (the start stop at first) |
346
+ | `next()` | run exactly one step (a fork counts as one step) |
347
+ | `continue()` | run until a breakpoint hits, an interrupt, or the end |
348
+ | `setBreakpoint(step, when?)`, `clearBreakpoint(step)`, `breakpoints` | stop before `step` when `when(state)` is truthy (always without `when`); one breakpoint per step |
349
+ | `setState(patch)` | while held at a `step` stop: overwrite keys before the next step (no reducers; values are copied; `$` keys are refused). Observers get event `debug.setState` with `{ patch }` |
350
+ | `answer(response, interruptId?)` | at an `interrupt` stop: resume through the normal resume path and hold before the resumed step |
351
+ | `stop()` | end the session at any time; resolves with the `end` stop. At an interrupt stop the interrupt stays pending as saved |
352
+ | `done` | promise of the last run's outcome, as `await handle` gives it |
353
+
354
+ One command at a time: a second `next()`, `continue()` or `answer()` while one runs rejects with
355
+ `DebugCommandError` (`reason: 'busy'`). Other reasons: `not-held`, `not-a-fork`, `reserved-key`,
356
+ `ended`.
357
+
358
+ While the run is held, the session renews its claim on the session store (when the store supports
359
+ claims), so the lease doesn't expire during a pause. Aborting the `signal` resource ends the
360
+ session in any state; so does `stop()`. Step durations leave out the time held.
361
+
362
+ #### Fork branches
363
+
364
+ `next()` steps over a fork. To step inside it, call `stepInto()` when the next step is a fork:
365
+ every branch starts and is held at its first step, and the first branch is focused. Branches are
366
+ **frames**, named by their branch path (`[]` is the top level).
367
+
368
+ ```typescript
369
+ stop = await dbg.stepInto() // { frame: ['x'], reason: 'fork-entry', next: 'sx' }
370
+ dbg.frames // [{ frame: [], status: 'in-fork' }, { frame: ['x'], status: 'held' }, …]
371
+ stop = dbg.focus(['y']) // switch to another held branch
372
+ dbg.setState({ note: 'edited in y' }) // counts as written by branch y, so the join keeps it
373
+ stop = await dbg.continueAll() // release every branch; resolves with the first stop any frame makes
374
+ ```
375
+
376
+ `next()` and `continue()` drive only the focused frame; other branches wait. When the driven frame
377
+ ends, focus moves to the next held frame (`reason: 'branch-end'`, with `ended`). Breakpoints hit in
378
+ any frame. If a branch fails and the fork fails fast, held siblings are released and end.
379
+
255
380
  ### Observers
256
381
 
257
382
  An `Observer` receives telemetry hooks for a run. Pass it as `observer` in the resources
258
- of `agent.run()` or `agent.resume()`. Combine several with `composeObservers([a, b])`;
383
+ of `agent.run()` or `agent.resume()`, or once for every run of an agent in
384
+ `createAgent(id, h, slots, { observer })`. Combine several with `composeObservers([a, b])`;
259
385
  a hook that throws is reported to `onObserverError` (or `console.error`) and never stops
260
386
  the run. The observer that LLM adapters call is isolated the same way, so an observer that
261
387
  throws on an adapter event never fails a step. Every hook is optional.
262
388
 
389
+ An agent's observer serves `agent.run()`, `handle.resume()` and `agent.resume()`. A per-call
390
+ `observer` is composed on top of it, never replacing it: the agent's hooks fire first, its `wrap` is
391
+ outermost, and on a shared `carry` key the per-call value wins. Passing the agent's own observer per
392
+ call uses it once. Hook errors go to the per-call `onObserverError`, else the agent's
393
+ `onObserverError` option, else `console.error`.
394
+
263
395
  | Hook | Fires |
264
396
  |---|---|
265
397
  | `onRunStart(ctx)` | once when a run starts |
@@ -331,9 +463,13 @@ Some runs continue an earlier one. Their `RunContext` says which, so an observer
331
463
  |---|---|
332
464
  | the next run on a session that paused — on an interrupt, a stop, or an unhandled error — whether started by `agent.resume()`, `run.resume()` (with or without a store), or `agent.run()` on that session | `resumedFrom: { runId, carrier? }` — the run that paused |
333
465
  | the first run of a session created by `SessionStore.branch()` | `branchedFrom: { sessionId, runId, carrier? }` — the run it was branched from, in the source session |
466
+ | a new turn on a session whose last run completed (see [Turns and `requestId`](#turns-and-requestid)) | `continuedFrom: { runId, carrier? }` — the previous turn's last run |
467
+
468
+ A run has at most one of the three, only on its top-level context (never on a fork branch's). A
469
+ call that returns a request's outcome without running has none: it has no run.
334
470
 
335
- A run has at most one of the two, only on its top-level context (never on a fork branch's), and a
336
- session that already completed runs nothing, so it has neither.
471
+ Every context of a turn's runs (fork branches included) also carries `requestId` when the turn
472
+ has one.
337
473
 
338
474
  `carrier` is what the earlier run's observer returned from `carry(ctx)`: core calls `carry` once,
339
475
  right after `onRunStart`, keeps its string values, and saves them as `StoredRun.carrier` (or on the
@@ -411,14 +547,14 @@ The harness never trusts a wrapper. `fn` always runs exactly once, and the calle
411
547
  | Export | Description |
412
548
  |---|---|
413
549
  | `createHarness<Ctx>()(schema)` | Creates a harness. Fixes `Ctx`, infers `State` from schema. |
414
- | `createAgent(id, h, slots, options?)` | Assigns the agent an ID and fills `required()` slots. `options` (`AgentOptions`) takes `instanceId`, `stores` (for `required()` store slots) and `claimOptions` (this agent's default claim TTL; per-call `claimOptions` wins, then `HARNESS_CLAIM_TTL_MS`, then 30 000 ms). Returns an `Agent`. |
550
+ | `createAgent(id, h, slots, options?)` | Assigns the agent an ID and fills `required()` slots. `options` (`AgentOptions`) takes `instanceId`, `stores` (for `required()` store slots), `claimOptions` (this agent's default claim TTL; per-call `claimOptions` wins, then `HARNESS_CLAIM_TTL_MS`, then 30 000 ms), `observer` (for every run of the agent; see [Observers](#observers)) and `onObserverError` (the default sink for hook errors). Returns an `Agent`. |
415
551
  | `field<T>(opts)` | Declares a state field with a default and optional reduce function. |
416
552
  | `required()` | Marks a provider slot as required at `createAgent()`. |
417
553
  | `runtime()` | Marks a provider slot as required at `agent.run()`. |
418
554
  | `composeObservers([a, b], onObserverError?)` | Merges multiple `Observer` instances into one fan-out observer; a throwing observer is isolated and reported to `onObserverError`. |
419
- | `SessionStore` | Interface for session persistence backends. |
420
- | `StoredRun` | Type for a persisted run snapshot. Includes `agentId`, `runId`, `sessionId`, `phase`, state, and optionally `carrier` (the run's `carry` result) and `branchedFrom` (on a branch seed). |
421
- | `RunRef` | `{ runId, carrier? }` — points at an earlier run; used by `RunContext.resumedFrom` and `branchedFrom` (which adds `sessionId`). |
555
+ | `SessionStore` | Interface for session persistence backends. Optional extensions: `loadHistory`, `branch`, `claim` / `release` / `extendClaim`, and `findRequest` (the newest run of a request, for retries of earlier requests). |
556
+ | `StoredRun` | Type for a persisted run snapshot. Includes `agentId`, `runId`, `sessionId`, `phase`, state, and optionally `requestId` (the run's turn), `carrier` (the run's `carry` result) and `branchedFrom` (on a branch seed). |
557
+ | `RunRef` | `{ runId, carrier? }` — points at an earlier run; used by `RunContext.resumedFrom`, `continuedFrom` and `branchedFrom` (which adds `sessionId`). |
422
558
  | `RunCarrier` | `Readonly<Record<string, string>>` — the opaque map an observer's `carry` returns. |
423
559
  | `Observer` | Interface for telemetry hooks on run and step lifecycle events (see [Observers](#observers)). |
424
560
  | `StepSettledEvent` | Payload of `Observer.onStepSettled` — outcome, duration, update, signal, next, error, interrupt, fork. |
@@ -426,12 +562,22 @@ The harness never trusts a wrapper. `fn` always runs exactly once, and the calle
426
562
  | `ObserverAware` | Interface for provider objects that want per-step telemetry: the harness calls `withTelemetry(telemetry)` once per step and installs the returned view on `ctx`. |
427
563
  | `TelemetryContext` | What `withTelemetry` receives: `observer` (the run's observer, fault-isolated), `stepContext`, and `within(type, payload, fn)` bound to that step. |
428
564
  | `WrapScope` | What `Observer.wrap` is asked to enclose: `{ kind: 'step' }` or `{ kind: 'event', type, payload }`. |
429
- | `RunContext` | Context passed to run-level observer hooks — `agentId`, `sessionId`, `runId`, `parentRunId?`, `instanceId?`, `branchPath?`, `resumedFrom?`, `branchedFrom?` (see [Linking runs](#linking-runs)). |
565
+ | `RunContext` | Context passed to run-level observer hooks — `agentId`, `sessionId`, `runId`, `parentRunId?`, `instanceId?`, `branchPath?`, `resumedFrom?`, `branchedFrom?`, `continuedFrom?`, `requestId?` (see [Linking runs](#linking-runs)). |
430
566
  | `StepContext` | Context passed to step-level observer hooks — `agentId`, `sessionId`, `runId`, `stepName`, `branchPath?`. |
567
+ | `InvalidRunResourceError` | Thrown by `agent.run()` when `sessionId` or `requestId` is given but is not a non-empty string; `key` and `value` name it. |
431
568
  | `NoInterruptError` | Thrown when `resume()` is called but the session is not paused on a matching interrupt. |
432
569
  | `SessionInFlightError` | Thrown when a session is already running. |
433
- | `SessionPendingInterruptError` | Thrown when a session is paused on a pending interrupt — use `agent.resume()` instead of `agent.run()`. |
570
+ | `SessionPendingInterruptError` | `agent.run()` on a session paused on a pending interrupt — use `agent.resume()` instead. With a session store, the run resolves with `signal: '$error'` and this error in `state.$error`; without one, `agent.run()` throws it. |
434
571
  | `StoreLoadError` | Thrown when the session store fails to load state. |
572
+ | `InvalidAgentOptionError` | Thrown by `createAgent()` when `observer` is not an object or `onObserverError` is not a function; `key` and `value` name it. |
573
+ | `InvalidClaimOptionsError` | Thrown by `createAgent()` when `claimOptions.ttlMs` is not a finite number greater than 0. |
574
+ | `DebugSession` | Returned by `agent.debug()`: a run driven step by step (see [Debugging a run](#debugging-a-run)). |
575
+ | `DebugStop` | Where a debug session stopped: `step`, `interrupt` or `end`. |
576
+ | `SettledStep`, `Snapshot` | The step that just settled at a stop, and a per-key copy of a state or update. |
577
+ | `DebugFrame`, `EndedFrame`, `PendingInterrupt` | A fork branch held by a debug session, a frame that ended, an interrupt the run waits on. |
578
+ | `DebugBreakpoint` | `{ step, when? }`. |
579
+ | `DebugCommandError` | A debug command that can't run now; `reason` is `busy`, `not-held`, `not-a-fork`, `reserved-key` or `ended`. |
580
+ | `SessionInDebugError` | `run()`, `resume()` or `debug()` on a session that is being debugged in this process. |
435
581
 
436
582
  ## Design Principles
437
583
 
@@ -456,6 +602,8 @@ See [LIMITATIONS.md](LIMITATIONS.md) for known limitations.
456
602
  - [`@noetaris/harness-openai`](https://github.com/noetaris-lab/harness-openai) — OpenAI adapter
457
603
  - [`@noetaris/harness-google`](https://github.com/noetaris-lab/harness-google) — Google Gemini adapter
458
604
  - [`@noetaris/harness-otel`](https://github.com/noetaris-lab/harness-otel) — OpenTelemetry observer bridge
605
+ - [`@noetaris/harness-recorder`](https://github.com/noetaris-lab/harness-recorder) — records runs step by step and plays a finished session back
606
+ - [`@noetaris/harness-testing`](https://github.com/noetaris-lab/harness-testing) — `runStep`, `runRoute`, `MockObserver` for tests
459
607
  - [`@noetaris/harness-otel-genai`](https://github.com/noetaris-lab/harness-otel-genai) — GenAI semantic-conventions extension for the bridge
460
608
 
461
609
  ## License
package/dist/index.d.ts CHANGED
@@ -133,7 +133,8 @@ interface RunContext {
133
133
  /**
134
134
  * The run this one continues: the run that paused the session (on an interrupt, a stop or
135
135
  * an unhandled error), with the carrier its observer produced. Present on a resumed
136
- * top-level run only — never on a fork branch's context, never together with `branchedFrom`.
136
+ * top-level run only — never on a fork branch's context, never together with `branchedFrom` or
137
+ * `continuedFrom`.
137
138
  */
138
139
  readonly resumedFrom?: RunRef;
139
140
  /**
@@ -143,6 +144,18 @@ interface RunContext {
143
144
  readonly branchedFrom?: RunRef & {
144
145
  readonly sessionId: string;
145
146
  };
147
+ /**
148
+ * The previous turn's run, when this run starts a new turn on a session whose last run
149
+ * completed. Present on the top-level run only — never together with `resumedFrom` or
150
+ * `branchedFrom`.
151
+ */
152
+ readonly continuedFrom?: RunRef;
153
+ /**
154
+ * The request (turn) this run belongs to — the `requestId` passed to `agent.run()`, kept by
155
+ * every resume of that turn. Present on every context of the run, fork branches included.
156
+ * Absent for keyless turns and when the agent has no session store.
157
+ */
158
+ readonly requestId?: string;
146
159
  }
147
160
  /**
148
161
  * Identifies the agent, session, and current step for step-level observer callbacks.
@@ -321,7 +334,7 @@ interface Observer {
321
334
  * Called once per top-level run, right after `onRunStart` (never for a fork branch). Return
322
335
  * an opaque string map that lets a later run refer back to this one — e.g. the run's trace
323
336
  * context. Core saves it on the run's record and hands it back as `RunContext.resumedFrom.carrier`
324
- * (or `branchedFrom.carrier`) to the run that continues or branches from it. Return
337
+ * (or `branchedFrom.carrier`, `continuedFrom.carrier`) to the run that continues or branches from it. Return
325
338
  * `undefined` for nothing. Only string values are kept; a throw is reported via
326
339
  * `onObserverError` (hook name `'carry'`) and counts as `undefined`.
327
340
  */
@@ -537,7 +550,7 @@ interface SessionStore {
537
550
  * no `step`, both states equal to the source run's `finalState`, and `branchedFrom` set to the
538
551
  * source session, `runId` and the source run's `carrier` (when it has one). When the source is
539
552
  * itself such a seed, copy its `branchedFrom` instead (or leave it out if it has none): a seed's
540
- * own `runId` never ran. The seed has no `carrier` of its own.
553
+ * own `runId` never ran. The seed has no `carrier` and no `requestId` of its own.
541
554
  *
542
555
  * @throws {@link BranchNotFoundError} when `runId` is not found in history.
543
556
  */
@@ -574,6 +587,16 @@ interface SessionStore {
574
587
  * Optional — omit if your store does not support claim/release.
575
588
  */
576
589
  extendClaim?(lease: Lease, options: ClaimOptions): Promise<Lease>;
590
+ /**
591
+ * The newest run of the session whose `requestId` equals `requestId`, or `null` when there is none.
592
+ *
593
+ * Core calls it only from `agent.run()` with a `requestId` that the latest run does not carry,
594
+ * so a late retry of an earlier request is replayed instead of run again. Without it, core
595
+ * dedupes against the latest run only.
596
+ *
597
+ * Optional — omit if your store keeps no history.
598
+ */
599
+ findRequest?(agentId: string, sessionId: string, requestId: string): Promise<StoredRun | null>;
577
600
  }
578
601
  /**
579
602
  * Serializable snapshot of a single agent run, written to the store after
@@ -620,6 +643,12 @@ interface StoredRun {
620
643
  readonly branchedFrom?: RunRef & {
621
644
  readonly sessionId: string;
622
645
  };
646
+ /**
647
+ * The request (turn) this run belongs to: the `requestId` passed to `agent.run()`, inherited by
648
+ * every resume of that turn. Absent for keyless turns, branch seeds and runs saved before this
649
+ * field was added.
650
+ */
651
+ readonly requestId?: string;
623
652
  }
624
653
  /**
625
654
  * Discriminated union returned by {@link Agent.status} describing the lifecycle
@@ -648,13 +677,13 @@ type SessionPhase = {
648
677
  * caller discover and resolve each one — via `Agent.resume(response, sessionId, id)` —
649
678
  * independently; the session stays `'paused'` until every entry is resolved.
650
679
  */
651
- readonly pendingInterrupts: readonly PendingInterrupt[];
680
+ readonly pendingInterrupts: readonly PendingInterrupt$1[];
652
681
  } | {
653
682
  readonly phase: 'completed';
654
683
  readonly signal?: string;
655
684
  };
656
685
  /** One outstanding `ctx.interrupt()` call awaiting a response. See {@link SessionPhase}. */
657
- interface PendingInterrupt {
686
+ interface PendingInterrupt$1 {
658
687
  readonly interruptId: string;
659
688
  readonly prompt: unknown;
660
689
  /**
@@ -1032,6 +1061,169 @@ interface RunHandle extends PromiseLike<RunOutcome> {
1032
1061
  readonly currentStep: string | null;
1033
1062
  }
1034
1063
 
1064
+ /**
1065
+ * A copy of a state (or update) object taken when a debug stop was made. Each top-level key is
1066
+ * copied with `structuredClone`; a key whose value can't be cloned (a function, for example) keeps
1067
+ * its reference and is listed in `shared`, so its value may have changed since.
1068
+ */
1069
+ interface Snapshot {
1070
+ readonly values: Readonly<Record<string, unknown>>;
1071
+ readonly shared: readonly string[];
1072
+ }
1073
+ /** A step that has settled, as a debug stop reports it: what `onStepSettled` knows, plus state. */
1074
+ interface SettledStep {
1075
+ readonly name: string;
1076
+ readonly outcome: StepSettledEvent['outcome'];
1077
+ /** The step's return value, copied; absent when it did not return one (see {@link StepSettledEvent}). */
1078
+ readonly update?: Snapshot;
1079
+ readonly signal?: string;
1080
+ readonly next: StepSettledNext;
1081
+ readonly error?: Error;
1082
+ /** The state just before this step ran. The state after it is the stop's own `state` (or `outcome.state`). */
1083
+ readonly before: Snapshot;
1084
+ readonly durationMs: number;
1085
+ readonly fork?: StepSettledEvent['fork'];
1086
+ }
1087
+ /** A branch frame that ended, as a `branch-end` stop reports it. */
1088
+ interface EndedFrame {
1089
+ readonly frame: readonly string[];
1090
+ readonly outcome: 'done' | 'interrupt' | 'error' | 'stopped';
1091
+ readonly last?: SettledStep;
1092
+ }
1093
+ /** An interrupt the paused run is waiting on, and the frame that raised it. */
1094
+ interface PendingInterrupt {
1095
+ readonly interruptId: string;
1096
+ readonly prompt: unknown;
1097
+ readonly frame: readonly string[];
1098
+ }
1099
+ /**
1100
+ * Where a debug session stopped. `frame` is the frame the stop belongs to: `[]` for the top-level run,
1101
+ * the branch path for a fork branch.
1102
+ * - `step`: held at a step boundary of `frame`, before `next` runs. `last` is the step that just settled
1103
+ * in that frame (absent on its first stop). `reason` says why it stopped here; `branch-end` means the
1104
+ * frame the command was driving ended (see `ended`) and focus moved here.
1105
+ * - `interrupt`: the run paused on `ctx.interrupt()`; `pending` lists every interrupt it waits on
1106
+ * (depth-first, declaration order), the first one also in `interruptId` / `prompt` / `frame`.
1107
+ * Answer with {@link DebugSession.answer}.
1108
+ * - `end`: the session is over — the run completed, paused on an error, or was stopped.
1109
+ */
1110
+ type DebugStop = {
1111
+ readonly kind: 'step';
1112
+ readonly frame: readonly string[];
1113
+ readonly reason: 'start' | 'step' | 'breakpoint' | 'resume' | 'fork-entry' | 'branch-end';
1114
+ readonly runId: string;
1115
+ readonly next: string;
1116
+ readonly last?: SettledStep;
1117
+ readonly state: Snapshot;
1118
+ /** What a breakpoint condition threw, when that is why the session stopped here. */
1119
+ readonly conditionError?: unknown;
1120
+ /** With `reason: 'branch-end'`: the frame that ended. */
1121
+ readonly ended?: EndedFrame;
1122
+ } | {
1123
+ readonly kind: 'interrupt';
1124
+ readonly frame: readonly string[];
1125
+ readonly runId: string;
1126
+ readonly last: SettledStep;
1127
+ readonly interruptId: string;
1128
+ readonly prompt: unknown;
1129
+ readonly pending: readonly PendingInterrupt[];
1130
+ readonly state: Snapshot;
1131
+ } | {
1132
+ readonly kind: 'end';
1133
+ readonly runId: string;
1134
+ readonly last?: SettledStep;
1135
+ readonly outcome: RunOutcome;
1136
+ };
1137
+ /** A frame of a debug session, as {@link DebugSession.frames} lists it. */
1138
+ interface DebugFrame {
1139
+ readonly frame: readonly string[];
1140
+ /** `in-fork`: waiting for a fork it started to join. `ended`: a branch that has finished (listed until the join). */
1141
+ readonly status: 'held' | 'running' | 'in-fork' | 'ended';
1142
+ /** The step it is held before, when `held`. */
1143
+ readonly next?: string;
1144
+ }
1145
+ /** A breakpoint: stop before `step` runs, when `when` (if given) returns a truthy value. */
1146
+ interface DebugBreakpoint {
1147
+ readonly step: string;
1148
+ /** Gets the live state of the frame about to run the step, not a copy: it must not change it. */
1149
+ readonly when?: (state: Readonly<Record<string, unknown>>) => unknown;
1150
+ }
1151
+ /** Thrown (or rejected) by a {@link DebugSession} command that can't run in the session's current state. */
1152
+ declare class DebugCommandError extends Error {
1153
+ /**
1154
+ * - `busy`: another command is still running
1155
+ * - `not-held`: the focused frame is not held at a step stop (or, for `answer`, the session is not at
1156
+ * an interrupt stop; for `focus`, the frame is not held)
1157
+ * - `not-a-fork`: `stepInto` where the next step is not a fork node
1158
+ * - `reserved-key`: `setState` was given a framework key (`$…`)
1159
+ * - `ended`: the session is over
1160
+ */
1161
+ readonly reason: 'busy' | 'not-held' | 'not-a-fork' | 'reserved-key' | 'ended';
1162
+ /** The command that was refused. */
1163
+ readonly command: string;
1164
+ constructor(reason: DebugCommandError['reason'], command: string, detail?: string);
1165
+ }
1166
+ /**
1167
+ * A run of an agent driven step by step, returned by `agent.debug()`. The session starts held before
1168
+ * the first step; each command returns the next stop. It spans the whole run chain: a run that pauses
1169
+ * on an interrupt is answered with {@link answer} and stepping continues in the resumed run.
1170
+ *
1171
+ * Fork branches are frames. Stepping into a fork ({@link stepInto}) holds every branch at its first
1172
+ * step; only the focused frame runs, the others wait until focused or released with
1173
+ * {@link continueAll}. When the frame a command drives ends, focus moves to the next held frame.
1174
+ *
1175
+ * One command at a time: `next()`, `continue()`, `stepInto()`, `continueAll()` and `answer()` reject
1176
+ * with a `busy` {@link DebugCommandError} while another is running. `stop()` is valid at any time.
1177
+ *
1178
+ * While a frame is held, the session renews the run's claim (if the store can extend claims). Call
1179
+ * `stop()`, or abort the `signal` resource, to end a session you no longer drive: a held session
1180
+ * otherwise keeps the session fenced in this process.
1181
+ */
1182
+ interface DebugSession {
1183
+ readonly sessionId: string;
1184
+ /** The id of the current run; changes when an interrupt is answered. */
1185
+ readonly runId: string;
1186
+ /**
1187
+ * The focused frame's latest stop. At first, the start stop (`reason: 'start'`, `next` = the entry
1188
+ * step). While no frame is held, a promise of the next stop.
1189
+ */
1190
+ readonly current: Promise<DebugStop>;
1191
+ /** The final outcome of the last run, as `await handle` would give it. */
1192
+ readonly done: Promise<RunOutcome>;
1193
+ readonly breakpoints: ReadonlyArray<DebugBreakpoint>;
1194
+ /** The frames of the current run, depth-first in declaration order. */
1195
+ readonly frames: ReadonlyArray<DebugFrame>;
1196
+ /** Focus a held frame; returns its current stop. */
1197
+ focus(frame: readonly string[]): DebugStop;
1198
+ /** Run exactly one step of the focused frame (a whole fork, stepping over it). Ignores breakpoints at its own boundary. */
1199
+ next(): Promise<DebugStop>;
1200
+ /** At a fork: start every branch and hold each at its first step; focus the first branch. */
1201
+ stepInto(): Promise<DebugStop>;
1202
+ /** Run the focused frame until a breakpoint hits or the frame ends; other frames stay held. */
1203
+ continue(): Promise<DebugStop>;
1204
+ /** Release every held frame; resolves with the first stop any frame makes. */
1205
+ continueAll(): Promise<DebugStop>;
1206
+ /** At an `interrupt` stop: answer it (by default the stop's own interrupt) and hold before the resumed run's first step. */
1207
+ answer(response: unknown, interruptId?: string): Promise<DebugStop>;
1208
+ /**
1209
+ * While the focused frame is held at a `step` stop: set state keys of that frame, overwriting them as
1210
+ * given (no reducers). Values are copied. In a branch the keys count as written by the branch, so the
1211
+ * join keeps them (a key also written by a sibling then needs a reducer, as for any write). Reported to
1212
+ * the observer as event `debug.setState` with `{ patch }` before the frame's next step.
1213
+ */
1214
+ setState(patch: Record<string, unknown>): Snapshot;
1215
+ /** Stop before `step` when `when` returns a truthy value (always, without `when`). Replaces that step's breakpoint. */
1216
+ setBreakpoint(step: string, when?: DebugBreakpoint['when']): void;
1217
+ clearBreakpoint(step: string): void;
1218
+ /**
1219
+ * End the session. Held frames end (stopped) where they are; running steps are stopped like
1220
+ * `handle.stop()` and their frames end at the next boundary. At an interrupt stop: the session ends
1221
+ * and the interrupt stays pending as saved. Resolves with the `end` stop — the same one a pending
1222
+ * command resolves with, and again on every later call.
1223
+ */
1224
+ stop(): Promise<DebugStop>;
1225
+ }
1226
+
1035
1227
  /** Options passed to createAgent(). */
1036
1228
  interface AgentOptions {
1037
1229
  /**
@@ -1051,12 +1243,33 @@ interface AgentOptions {
1051
1243
  * `ttlMs` must be a finite number greater than 0; otherwise `createAgent` throws `InvalidClaimOptionsError`.
1052
1244
  */
1053
1245
  readonly claimOptions?: ClaimOptions;
1246
+ /**
1247
+ * Observer for every run of this agent: `agent.run()`, `handle.resume()` and `agent.resume()`.
1248
+ * A per-call `observer` is composed on top of it (this one first: its hooks fire first, its `wrap`
1249
+ * is outermost, and on a shared `carry` key the per-call value wins); it never replaces it. The
1250
+ * same object passed per call is used once. One object serves every run, concurrent runs included.
1251
+ * Must be an object; otherwise `createAgent` throws `InvalidAgentOptionError`.
1252
+ */
1253
+ readonly observer?: Observer;
1254
+ /**
1255
+ * Default sink for observer-hook errors, used when a call passes no `onObserverError`.
1256
+ * Must be a function; otherwise `createAgent` throws `InvalidAgentOptionError`.
1257
+ */
1258
+ readonly onObserverError?: ObserverErrorSink;
1054
1259
  }
1055
1260
  interface Agent {
1056
1261
  /** The agent's unique identifier, as provided to createAgent(). */
1057
1262
  readonly id: string;
1058
1263
  /**
1059
- * Start a new run. Returns a RunHandle synchronously before execution begins.
1264
+ * Start a run. Returns a RunHandle synchronously before execution begins.
1265
+ *
1266
+ * Reserved `resources` keys that shape the session:
1267
+ * - `sessionId?: string` — the conversation thread; a random UUID when absent
1268
+ * - `requestId?: string` — this turn and its idempotency key. With a session store, a call whose
1269
+ * `requestId` the session already ran returns that request's current outcome without running
1270
+ * it again; without one, every call is a new turn. Ignored when the agent has no session store
1271
+ *
1272
+ * Both must be non-empty strings when given; otherwise `run` throws `InvalidRunResourceError`.
1060
1273
  */
1061
1274
  run(initialState: Record<string, unknown>, resources: Record<string, unknown>): RunHandle;
1062
1275
  /**
@@ -1064,7 +1277,8 @@ interface Agent {
1064
1277
  * Returns a RunHandle synchronously; the execution promise performs the resume.
1065
1278
  *
1066
1279
  * The optional fourth argument `resources` accepts:
1067
- * - `observer?: Observer` — structured telemetry for the resumed run (see Observability)
1280
+ * - `observer?: Observer` — structured telemetry for the resumed run, composed on top of `AgentOptions.observer` (see Observability)
1281
+ * - `onObserverError?: ObserverErrorSink` — sink for observer-hook errors; falls back to `AgentOptions.onObserverError`
1068
1282
  * - `parentRunId?: string` — the caller's run id, as for `run()`; set on this resumed run's
1069
1283
  * `RunContext` only (a later `handle.resume()` on the returned handle does not inherit it)
1070
1284
  * - `events?.onStoreError?` — raw store-error callback (unchanged from prior shape)
@@ -1073,11 +1287,43 @@ interface Agent {
1073
1287
  * on the returned handle reuses it
1074
1288
  */
1075
1289
  resume(response: unknown, sessionId: string, interruptId: string, resources?: Record<string, unknown>): RunHandle;
1290
+ /**
1291
+ * Start a run held before its first step, to drive it step by step. Takes the same `initialState`
1292
+ * and `resources` as {@link run} and returns a {@link DebugSession}, whose `current` resolves to
1293
+ * the start stop. While the session lasts, `run()`, `resume()` and `debug()` on its session throw
1294
+ * `SessionInDebugError` (in this process only). The `signal` resource, when given, ends the session
1295
+ * when aborted, in any state.
1296
+ *
1297
+ * Step durations exclude the time held; a run's duration (`onRunEnd`) includes it.
1298
+ */
1299
+ debug(initialState: Record<string, unknown>, resources: Record<string, unknown>): DebugSession;
1076
1300
  /**
1077
1301
  * Query the session store for the current phase of a session.
1078
1302
  */
1079
1303
  status(sessionId: string): Promise<SessionPhase>;
1080
1304
  }
1305
+ /** Thrown by {@link createAgent} when `agentOptions.claimOptions.ttlMs` is not a finite number greater than 0. */
1306
+ declare class InvalidClaimOptionsError extends Error {
1307
+ /** The rejected `ttlMs` value, as given. */
1308
+ readonly ttlMs: unknown;
1309
+ constructor(ttlMs: unknown);
1310
+ }
1311
+ /** Thrown by {@link createAgent} when `agentOptions.observer` is not an object or `agentOptions.onObserverError` is not a function. */
1312
+ declare class InvalidAgentOptionError extends Error {
1313
+ /** The option that was rejected. */
1314
+ readonly key: 'observer' | 'onObserverError';
1315
+ /** The rejected value, as given. */
1316
+ readonly value: unknown;
1317
+ constructor(key: 'observer' | 'onObserverError', value: unknown);
1318
+ }
1319
+ /** Thrown by `agent.run()` when `sessionId` or `requestId` is given but is not a non-empty string. */
1320
+ declare class InvalidRunResourceError extends Error {
1321
+ /** The resource key that was rejected. */
1322
+ readonly key: 'sessionId' | 'requestId';
1323
+ /** The rejected value, as given. */
1324
+ readonly value: unknown;
1325
+ constructor(key: 'sessionId' | 'requestId', value: unknown);
1326
+ }
1081
1327
  /**
1082
1328
  * Instantiate an agent from a fully-configured {@link Harness}.
1083
1329
  *
@@ -1104,17 +1350,6 @@ interface Agent {
1104
1350
  */
1105
1351
  declare function createAgent<Ctx, State, Req extends keyof Ctx, Run extends keyof Ctx>(id: string, h: Harness<Ctx, State, Req, Run>, slots: Pick<Ctx, Req>, agentOptions?: AgentOptions): Agent;
1106
1352
 
1107
- /**
1108
- * Thrown by {@link RunHandle.resume} or {@link Agent.resume} when the session
1109
- * is not currently paused on a matching interrupt.
1110
- *
1111
- * Common causes: the run has not yet settled, the session does not exist,
1112
- * or the supplied `interruptId` does not match the pending interrupt.
1113
- */
1114
- declare class NoInterruptError extends Error {
1115
- constructor();
1116
- }
1117
-
1118
1353
  /**
1119
1354
  * Thrown by {@link Agent.run} or {@link Agent.resume} when the given session
1120
1355
  * is already executing a run in the same process.
@@ -1126,6 +1361,17 @@ declare class SessionInFlightError extends Error {
1126
1361
  readonly sessionId: string;
1127
1362
  constructor(sessionId: string);
1128
1363
  }
1364
+ /**
1365
+ * Thrown by {@link Agent.run}, {@link Agent.resume} or {@link Agent.debug} when the given session is
1366
+ * being debugged by a debug session of the same agent instance. The fence is in-process only.
1367
+ *
1368
+ * Stop the debug session (`stop()`) or let it end before running the session normally.
1369
+ */
1370
+ declare class SessionInDebugError extends Error {
1371
+ /** The session being debugged. */
1372
+ readonly sessionId: string;
1373
+ constructor(sessionId: string);
1374
+ }
1129
1375
  /**
1130
1376
  * Thrown by {@link Agent.run} when the session is paused on an unanswered
1131
1377
  * interrupt. Call {@link Agent.resume} (or `handle.resume()`) instead.
@@ -1174,4 +1420,15 @@ declare class LeaseExpiredError extends Error {
1174
1420
  constructor(sessionId: string);
1175
1421
  }
1176
1422
 
1177
- export { type Agent, type AgentOptions, type BranchCursor, type BranchDef, type ClaimOptions, type Cursor, type DeepWithMarkers, type FieldDefinition, type ForkCursor, type ForkDef, type FrameworkState, type Harness, type Lease, LeaseExpiredError, type LoopDefinition, type LoopNode, LoopNotDefinedError, NoInterruptError, type Observer, type ObserverAware, type ObserverErrorContext, type ObserverErrorSink, REQUIRED_TAG, RUNTIME_TAG, type RequiredMarker, type RouteFn, type RunCarrier, type RunContext, type RunFn, type RunRef, type RuntimeMarker, SessionBusyError, SessionInFlightError, SessionPendingInterruptError, type SessionStore, type SignalTransition, type StateFromSchema, type StepContext, type StepDef, type StepSettledEvent, type StepSettledNext, type StepState, StoreLoadError, type StoredRun, type StoredRunMetadata, type TelemetryContext, type TransitionTarget, type WrapScope, composeObservers, createAgent, createHarness, field, isForkCursor, isForkDef, isRequiredMarker, isRuntimeMarker, required, runtime };
1423
+ /**
1424
+ * Thrown by {@link RunHandle.resume} or {@link Agent.resume} when the session
1425
+ * is not currently paused on a matching interrupt.
1426
+ *
1427
+ * Common causes: the run has not yet settled, the session does not exist,
1428
+ * or the supplied `interruptId` does not match the pending interrupt.
1429
+ */
1430
+ declare class NoInterruptError extends Error {
1431
+ constructor();
1432
+ }
1433
+
1434
+ export { type Agent, type AgentOptions, type BranchCursor, type BranchDef, type ClaimOptions, type Cursor, type DebugBreakpoint, DebugCommandError, type DebugFrame, type DebugSession, type DebugStop, type DeepWithMarkers, type EndedFrame, type FieldDefinition, type ForkCursor, type ForkDef, type FrameworkState, type Harness, InvalidAgentOptionError, InvalidClaimOptionsError, InvalidRunResourceError, type Lease, LeaseExpiredError, type LoopDefinition, type LoopNode, LoopNotDefinedError, NoInterruptError, type Observer, type ObserverAware, type ObserverErrorContext, type ObserverErrorSink, type PendingInterrupt, REQUIRED_TAG, RUNTIME_TAG, type RequiredMarker, type RouteFn, type RunCarrier, type RunContext, type RunFn, type RunRef, type RuntimeMarker, SessionBusyError, SessionInDebugError, SessionInFlightError, SessionPendingInterruptError, type SessionStore, type SettledStep, type SignalTransition, type Snapshot, type StateFromSchema, type StepContext, type StepDef, type StepSettledEvent, type StepSettledNext, type StepState, StoreLoadError, type StoredRun, type StoredRunMetadata, type TelemetryContext, type TransitionTarget, type WrapScope, composeObservers, createAgent, createHarness, field, isForkCursor, isForkDef, isRequiredMarker, isRuntimeMarker, required, runtime };