@noetaris/harness 0.11.0 → 0.13.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
@@ -29,13 +29,15 @@ interface Ctx {
29
29
  }
30
30
 
31
31
  // 2. Create the harness — fixes Ctx and infers State from the field schema
32
- const h = createHarness<Ctx>()({
32
+ const base = createHarness<Ctx>()({
33
33
  messages: field<string[]>({ default: () => [], reduce: (a, b) => [...a, ...b] }),
34
34
  toolCalls: field<any[]> ({ default: () => [] }),
35
+ toolResults: field<any[]>({ default: () => [] }),
35
36
  })
36
37
 
37
- // 3. Define the loop — validated immediately at call time
38
- h.loop(l =>
38
+ // 3. Define the loop — validated immediately at call time.
39
+ // Each builder call returns a new harness: keep the result.
40
+ const withLoop = base.loop(l =>
39
41
  l.start()
40
42
  .step('think', {
41
43
  run: async (state, ctx) => {
@@ -57,9 +59,10 @@ h.loop(l =>
57
59
  )
58
60
 
59
61
  // 4. Declare providers
60
- h.provide('tools', { search: new MySearchTool() }) // hard-coded
61
- h.provide('prompts', required()) // must be supplied at createAgent()
62
- h.provide('model', runtime()) // must be supplied at agent.run()
62
+ const h = withLoop
63
+ .provide('tools', { search: new MySearchTool() }) // hard-coded
64
+ .provide('prompts', required()) // must be supplied at createAgent()
65
+ .provide('model', runtime()) // must be supplied at agent.run()
63
66
 
64
67
  // 5. Create an agent — assigns an ID and fills required() slots
65
68
  const agent = createAgent('my-agent', h, {
@@ -166,6 +169,7 @@ does depends on the session's latest run:
166
169
  | completed | a new turn | returns its outcome, runs nothing |
167
170
  | paused on an interrupt | resolves `$error` with `SessionPendingInterruptError` | returns its outcome (`signal: '$interrupt'`), runs nothing |
168
171
  | 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 |
172
+ | a checkpoint (`'running'`: its instance died mid-run, see [Checkpoints](#checkpoints)) | merges the input and continues from the last finished step | continues after the last finished step, **without** merging the input again |
169
173
 
170
174
  A retry of an earlier request (not the latest) is found with the store's optional
171
175
  `findRequest` and its outcome is returned; it never runs again, since later turns have
@@ -187,6 +191,106 @@ Notes:
187
191
  - `sessionId` and `requestId` must be non-empty strings when given; otherwise `agent.run()`
188
192
  throws `InvalidRunResourceError`.
189
193
 
194
+ ### Checkpoints
195
+
196
+ By default a session is saved only when a run settles. If the instance dies in the middle of a
197
+ long turn, the next run starts the whole turn again, and the side effects of the steps that
198
+ already finished happen again. With `checkpoint: 'step'`, the session is also saved after every
199
+ finished top-level step:
200
+
201
+ ```ts
202
+ const agent = createAgent('agent', h, {}, { checkpoint: 'step' }) // every run of this agent
203
+ agent.run(input, { sessionId, requestId, checkpoint: 'off' }) // or per call: 'off' | 'step'
204
+ ```
205
+
206
+ ```
207
+ turn ── a ── save ── b ── save ── c ──✕ crash
208
+ ↑
209
+ next run ─────────┘ continues from c; a and b do not run again
210
+ ```
211
+
212
+ - Each checkpoint is a new stored run with `phase: 'running'`, `step` = the next step to run,
213
+ and the state after the finished step. `agent.status()` reports it as
214
+ `{ phase: 'running', step, instanceId?, checkpointedAt }`.
215
+ - Running the session again continues from that step, like a stopped run (see the table above):
216
+ a retry with the same `requestId` does not merge its input again. With a store that supports
217
+ claims, another instance gets `SessionBusyError` while the owner's claim is alive, and can take
218
+ over once it expires.
219
+ - Steps run **at least once**: only the step that was running when the instance died runs
220
+ again, so only that step needs to be idempotent.
221
+ - A fork is saved as a whole: there is a checkpoint before it and after it, none inside. A crash
222
+ inside a fork runs the whole fork again.
223
+ - After each checkpoint the claim is extended. A single step that runs longer than the claim TTL
224
+ still needs `ctx.keepAlive()`.
225
+ - `onCheckpoint(ctx, { step, version, durationMs })` on an [observer](#observers) reports each
226
+ saved checkpoint. A checkpoint that is skipped (its state can't be copied with
227
+ `structuredClone`, or the store failed) is reported to `events.onStoreError` with phase
228
+ `'checkpoint'`, and the run goes on.
229
+ - When the store reports that another writer saved the session first, or that this instance's
230
+ claim was taken over, the run stops at once with `LeaseExpiredError` (reported with phase
231
+ `'claim'`) and saves nothing more. Detecting a taken-over claim needs a store that checks
232
+ `SaveOptions.lease` on `save()` (see [Fencing](#fencing)).
233
+ - Without a session store the option does nothing. Each checkpoint is one store write per step.
234
+
235
+ Limits:
236
+ - Nothing in core looks for runs whose instance died. The session continues only when something
237
+ runs it again: a retry with the same `requestId`, a queue that redelivers the request, or your
238
+ own sweep over sessions whose `agent.status()` is `'running'`.
239
+ - Every checkpoint is a stored run, and none are removed: `loadHistory()` grows by one run per
240
+ finished step. Filter by `phase` when you read it.
241
+ - `branch()` starts from the last stored run of a `runId`. It can't start from an earlier
242
+ checkpoint of that run.
243
+ - It is all top-level steps or none; you can't pick which steps save.
244
+ - A step has no fencing token to pass to other systems. A stale instance is stopped at its next
245
+ save to the session store, but what its step already wrote elsewhere stays written.
246
+ - If a save reached the store but the call still failed (a timeout, a dropped connection), the
247
+ next checkpoint conflicts with it and the run stops with `LeaseExpiredError`. The next run
248
+ continues from that save; steps still run at least once.
249
+
250
+ > **Upgrading.**
251
+ > - `StoredRun.phase` and `SessionPhase` have a new value, `'running'`. A `switch` that handles
252
+ > every phase needs a case for it.
253
+ > - `checkpoint` is now a reserved run resource. A `runtime()` slot named `checkpoint` no longer
254
+ > reaches `ctx`, and the value passed under that key must be `'off'` or `'step'`. Rename the slot.
255
+ > - `SessionStore.save` takes an optional fourth argument, `SaveOptions`. Existing stores keep
256
+ > working, without [fencing](#fencing).
257
+ > - With a store that fences, a run whose final save is rejected because it lost its claim now
258
+ > settles `$error` with `LeaseExpiredError`. Before, it returned its result although the store
259
+ > did not keep it.
260
+
261
+ ### Fencing
262
+
263
+ With a store that supports claims, a run holds a lease on its session. A lease can be lost while
264
+ the run still works: the claim expires (a step ran longer than the claim TTL without
265
+ `ctx.keepAlive()`), and another instance claims the session and runs it. The first instance is
266
+ now stale. Without fencing, its next save would overwrite the new owner's progress.
267
+
268
+ Core already stops a run at the next step boundary once its lease has expired by the local clock.
269
+ Fencing covers what that check can't see: a save made before that boundary, and clocks that differ
270
+ between instances.
271
+
272
+ So every save a run makes while it holds a claim (each checkpoint, the final save, and writing an
273
+ interrupt answer on `resume()`) passes the lease as `SaveOptions.lease`. A store that fences
274
+ checks, in the same atomic step as the version check, that the session's claim is still that
275
+ lease. If not, it writes nothing and throws an error named `LeaseLostError`.
276
+
277
+ ```
278
+ A (stale): save {lease A} ──► store: claim is now B's → LeaseLostError → A stops, $error
279
+ B (owner): save {lease B} ──► store: claim ok, version ok → saved
280
+ ```
281
+
282
+ What the caller of the stale run sees:
283
+ - The run settles `signal: '$error'` with `LeaseExpiredError` in `state.$error`. Its `cause` is the
284
+ store's `LeaseLostError`. `events.onStoreError` gets the same `LeaseExpiredError` with phase
285
+ `'claim'`.
286
+ - Nothing more is saved for that run.
287
+ - Any other error from the final save is still only reported (phase `'persist'`), and the run
288
+ keeps its signal.
289
+
290
+ `PostgresSessionStore` and `RedisSessionStore` fence. `InMemorySessionStore` and
291
+ `LocalFileSessionStore` have no claims, so there is nothing to fence. A custom store that ignores
292
+ `SaveOptions` keeps only the version check.
293
+
190
294
  ### Interrupts
191
295
 
192
296
  A run can be stopped or resumed:
@@ -299,14 +403,99 @@ h.loop(l =>
299
403
  If a fork fails fast while a sub agent is paused, that sub agent's own session stays
300
404
  paused in its store — clean it up or reuse it the next time the step runs.
301
405
 
406
+ ### Debugging a run
407
+
408
+ `agent.debug(initialState, resources)` starts a run held before its first step and returns a
409
+ `DebugSession`. Each command runs part of the run and resolves with the next **stop**: where the
410
+ run is held, the step that just settled, and a copy of the state. It takes the same arguments as
411
+ `agent.run()` and drives the whole run chain — an interrupt is answered inside the session.
412
+
413
+ ```typescript
414
+ const dbg = agent.debug({}, { sessionId: 's1' })
415
+
416
+ let stop = await dbg.current // held before the first step: kind 'step', reason 'start'
417
+ stop = await dbg.next() // run one step; stop.last is the step that settled
418
+ if (stop.kind === 'step') console.log(stop.last?.name, stop.last?.update?.values)
419
+
420
+ dbg.setBreakpoint('review', (state) => (state.score as number) < 0.5)
421
+ stop = await dbg.continue() // runs until a breakpoint, an interrupt or the end
422
+
423
+ if (stop.kind === 'step') dbg.setState({ score: 0.9 }) // edit state before 'review' runs
424
+ stop = await dbg.continue()
425
+ if (stop.kind === 'interrupt') stop = await dbg.answer(true) // resume; held before the re-run
426
+
427
+ stop = await dbg.continue() // { kind: 'end', outcome: { state, signal } }
428
+ ```
429
+
430
+ The session is local to this process. While it lasts, `run()`, `resume()` and `debug()` on its
431
+ session throw `SessionInDebugError` here; see [LIMITATIONS.md](./LIMITATIONS.md) for other processes.
432
+
433
+ **Stops** (`DebugStop`):
434
+
435
+ | `kind` | Fields | When |
436
+ |---|---|---|
437
+ | `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` |
438
+ | `interrupt` | `interruptId`, `prompt`, `pending` (every interrupt the run waits on), `last`, `state`, `frame` | a step called `ctx.interrupt()` |
439
+ | `end` | `outcome` (`{ state, signal }`), `last` | the run completed, paused on an error, or was stopped |
440
+
441
+ `state`, `last.before` and `last.update` are `Snapshot`s: `{ values, shared }`. Each top-level key
442
+ is copied with `structuredClone`; a key that can't be cloned (a function, say) keeps its reference
443
+ and is listed in `shared`.
444
+
445
+ **Commands** (`DebugSession`):
446
+
447
+ | Command | Does |
448
+ |---|---|
449
+ | `current` | promise of the latest stop (the start stop at first) |
450
+ | `next()` | run exactly one step (a fork counts as one step) |
451
+ | `continue()` | run until a breakpoint hits, an interrupt, or the end |
452
+ | `setBreakpoint(step, when?)`, `clearBreakpoint(step)`, `breakpoints` | stop before `step` when `when(state)` is truthy (always without `when`); one breakpoint per step |
453
+ | `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 }` |
454
+ | `answer(response, interruptId?)` | at an `interrupt` stop: resume through the normal resume path and hold before the resumed step |
455
+ | `stop()` | end the session at any time; resolves with the `end` stop. At an interrupt stop the interrupt stays pending as saved |
456
+ | `done` | promise of the last run's outcome, as `await handle` gives it |
457
+
458
+ One command at a time: a second `next()`, `continue()` or `answer()` while one runs rejects with
459
+ `DebugCommandError` (`reason: 'busy'`). Other reasons: `not-held`, `not-a-fork`, `reserved-key`,
460
+ `ended`.
461
+
462
+ While the run is held, the session renews its claim on the session store (when the store supports
463
+ claims), so the lease doesn't expire during a pause. Aborting the `signal` resource ends the
464
+ session in any state; so does `stop()`. Step durations leave out the time held.
465
+
466
+ #### Fork branches
467
+
468
+ `next()` steps over a fork. To step inside it, call `stepInto()` when the next step is a fork:
469
+ every branch starts and is held at its first step, and the first branch is focused. Branches are
470
+ **frames**, named by their branch path (`[]` is the top level).
471
+
472
+ ```typescript
473
+ stop = await dbg.stepInto() // { frame: ['x'], reason: 'fork-entry', next: 'sx' }
474
+ dbg.frames // [{ frame: [], status: 'in-fork' }, { frame: ['x'], status: 'held' }, …]
475
+ stop = dbg.focus(['y']) // switch to another held branch
476
+ dbg.setState({ note: 'edited in y' }) // counts as written by branch y, so the join keeps it
477
+ stop = await dbg.continueAll() // release every branch; resolves with the first stop any frame makes
478
+ ```
479
+
480
+ `next()` and `continue()` drive only the focused frame; other branches wait. When the driven frame
481
+ ends, focus moves to the next held frame (`reason: 'branch-end'`, with `ended`). Breakpoints hit in
482
+ any frame. If a branch fails and the fork fails fast, held siblings are released and end.
483
+
302
484
  ### Observers
303
485
 
304
486
  An `Observer` receives telemetry hooks for a run. Pass it as `observer` in the resources
305
- of `agent.run()` or `agent.resume()`. Combine several with `composeObservers([a, b])`;
487
+ of `agent.run()` or `agent.resume()`, or once for every run of an agent in
488
+ `createAgent(id, h, slots, { observer })`. Combine several with `composeObservers([a, b])`;
306
489
  a hook that throws is reported to `onObserverError` (or `console.error`) and never stops
307
490
  the run. The observer that LLM adapters call is isolated the same way, so an observer that
308
491
  throws on an adapter event never fails a step. Every hook is optional.
309
492
 
493
+ An agent's observer serves `agent.run()`, `handle.resume()` and `agent.resume()`. A per-call
494
+ `observer` is composed on top of it, never replacing it: the agent's hooks fire first, its `wrap` is
495
+ outermost, and on a shared `carry` key the per-call value wins. Passing the agent's own observer per
496
+ call uses it once. Hook errors go to the per-call `onObserverError`, else the agent's
497
+ `onObserverError` option, else `console.error`.
498
+
310
499
  | Hook | Fires |
311
500
  |---|---|
312
501
  | `onRunStart(ctx)` | once when a run starts |
@@ -319,6 +508,7 @@ throws on an adapter event never fails a step. Every hook is optional.
319
508
  | `onStepSettled(ctx, event)` | once per step, when its outcome and destination are final |
320
509
  | `wrap(ctx, scope, fn)` | around a step's `run`, and around calls made with `ctx.within` |
321
510
  | `carry(ctx)` | once per top-level run, right after `onRunStart`; returns a string map to keep on the run's record (see [Linking runs](#linking-runs)) |
511
+ | `onCheckpoint(ctx, { step, version, durationMs })` | after a checkpoint was saved, before `step` starts; top-level runs with `checkpoint: 'step'` only (see [Checkpoints](#checkpoints)) |
322
512
 
323
513
  Step hooks receive a `StepContext`: `agentId`, `sessionId`, `runId`, `stepName` and, inside a
324
514
  fork branch, `branchPath`. `runId` is the same value as `RunContext.runId`. Steps inside a branch
@@ -462,28 +652,41 @@ The harness never trusts a wrapper. `fn` always runs exactly once, and the calle
462
652
  | Export | Description |
463
653
  |---|---|
464
654
  | `createHarness<Ctx>()(schema)` | Creates a harness. Fixes `Ctx`, infers `State` from schema. |
465
- | `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`. |
655
+ | `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)), `onObserverError` (the default sink for hook errors) and `checkpoint` (`'off'` or `'step'`; see [Checkpoints](#checkpoints)). Returns an `Agent`. |
466
656
  | `field<T>(opts)` | Declares a state field with a default and optional reduce function. |
467
657
  | `required()` | Marks a provider slot as required at `createAgent()`. |
468
658
  | `runtime()` | Marks a provider slot as required at `agent.run()`. |
469
659
  | `composeObservers([a, b], onObserverError?)` | Merges multiple `Observer` instances into one fan-out observer; a throwing observer is isolated and reported to `onObserverError`. |
470
660
  | `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). |
471
- | `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). |
661
+ | `SaveOptions` | Last argument of `SessionStore.save`: `{ lease? }`, the lease the caller holds. A store that supports claims rejects a save whose lease is no longer the session's claim with an error named `LeaseLostError`. |
662
+ | `CheckpointMode` | `'off' \| 'step'` — the `checkpoint` option and resource. |
663
+ | `StoredRun` | Type for a persisted run snapshot. Includes `agentId`, `runId`, `sessionId`, `phase` (`'paused'`, `'completed'` or `'running'` for a checkpoint), state, and optionally `requestId` (the run's turn), `carrier` (the run's `carry` result) and `branchedFrom` (on a branch seed). |
472
664
  | `RunRef` | `{ runId, carrier? }` — points at an earlier run; used by `RunContext.resumedFrom`, `continuedFrom` and `branchedFrom` (which adds `sessionId`). |
473
665
  | `RunCarrier` | `Readonly<Record<string, string>>` — the opaque map an observer's `carry` returns. |
474
666
  | `Observer` | Interface for telemetry hooks on run and step lifecycle events (see [Observers](#observers)). |
475
667
  | `StepSettledEvent` | Payload of `Observer.onStepSettled` — outcome, duration, update, signal, next, error, interrupt, fork. |
668
+ | `CheckpointEvent` | Payload of `Observer.onCheckpoint` — `step`, `version`, `durationMs`. |
476
669
  | `StepSettledNext` | Where the run goes after a step settles: `step`, `end`, `pause`, or `throw`. |
477
670
  | `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`. |
478
671
  | `TelemetryContext` | What `withTelemetry` receives: `observer` (the run's observer, fault-isolated), `stepContext`, and `within(type, payload, fn)` bound to that step. |
479
672
  | `WrapScope` | What `Observer.wrap` is asked to enclose: `{ kind: 'step' }` or `{ kind: 'event', type, payload }`. |
480
673
  | `RunContext` | Context passed to run-level observer hooks — `agentId`, `sessionId`, `runId`, `parentRunId?`, `instanceId?`, `branchPath?`, `resumedFrom?`, `branchedFrom?`, `continuedFrom?`, `requestId?` (see [Linking runs](#linking-runs)). |
481
674
  | `StepContext` | Context passed to step-level observer hooks — `agentId`, `sessionId`, `runId`, `stepName`, `branchPath?`. |
482
- | `InvalidRunResourceError` | Thrown by `agent.run()` when `sessionId` or `requestId` is given but is not a non-empty string; `key` and `value` name it. |
675
+ | `InvalidRunResourceError` | Thrown by `agent.run()` when `sessionId` or `requestId` is given but is not a non-empty string, and by `run()`, `debug()` and `resume()` when `checkpoint` is not `'off'` or `'step'`; `key` and `value` name it. |
483
676
  | `NoInterruptError` | Thrown when `resume()` is called but the session is not paused on a matching interrupt. |
484
677
  | `SessionInFlightError` | Thrown when a session is already running. |
485
678
  | `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. |
486
679
  | `StoreLoadError` | Thrown when the session store fails to load state. |
680
+ | `LeaseExpiredError` | In `state.$error` when a run lost its claim: the lease expired, or the store rejected a save because the session's claim is no longer this run's lease (see [Fencing](#fencing)). `sessionId`; `cause` holds the store's error when there is one. |
681
+ | `InvalidAgentOptionError` | Thrown by `createAgent()` when `observer` is not an object, `onObserverError` is not a function, or `checkpoint` is not `'off'` or `'step'`; `key` and `value` name it. |
682
+ | `InvalidClaimOptionsError` | Thrown by `createAgent()` when `claimOptions.ttlMs` is not a finite number greater than 0. |
683
+ | `DebugSession` | Returned by `agent.debug()`: a run driven step by step (see [Debugging a run](#debugging-a-run)). |
684
+ | `DebugStop` | Where a debug session stopped: `step`, `interrupt` or `end`. |
685
+ | `SettledStep`, `Snapshot` | The step that just settled at a stop, and a per-key copy of a state or update. |
686
+ | `DebugFrame`, `EndedFrame`, `PendingInterrupt` | A fork branch held by a debug session, a frame that ended, an interrupt the run waits on. |
687
+ | `DebugBreakpoint` | `{ step, when? }`. |
688
+ | `DebugCommandError` | A debug command that can't run now; `reason` is `busy`, `not-held`, `not-a-fork`, `reserved-key` or `ended`. |
689
+ | `SessionInDebugError` | `run()`, `resume()` or `debug()` on a session that is being debugged in this process. |
487
690
 
488
691
  ## Design Principles
489
692
 
@@ -508,6 +711,8 @@ See [LIMITATIONS.md](LIMITATIONS.md) for known limitations.
508
711
  - [`@noetaris/harness-openai`](https://github.com/noetaris-lab/harness-openai) — OpenAI adapter
509
712
  - [`@noetaris/harness-google`](https://github.com/noetaris-lab/harness-google) — Google Gemini adapter
510
713
  - [`@noetaris/harness-otel`](https://github.com/noetaris-lab/harness-otel) — OpenTelemetry observer bridge
714
+ - [`@noetaris/harness-recorder`](https://github.com/noetaris-lab/harness-recorder) — records runs step by step and plays a finished session back
715
+ - [`@noetaris/harness-testing`](https://github.com/noetaris-lab/harness-testing) — `runStep`, `runRoute`, `MockObserver` for tests
511
716
  - [`@noetaris/harness-otel-genai`](https://github.com/noetaris-lab/harness-otel-genai) — GenAI semantic-conventions extension for the bridge
512
717
 
513
718
  ## License