@noetaris/harness 0.9.0 → 0.11.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:
@@ -157,11 +204,12 @@ const resumed = agent.resume(response, sessionId, interruptId)
157
204
  ```
158
205
 
159
206
  `agent.resume()` takes an optional fourth argument with these reserved resources (a subset of
160
- `run()`'s): `observer`, `onObserverError`, `events: { onStoreError }`, and `parentRunId` (the caller's run id,
161
- for a sub agent resumed from a step). `parentRunId` applies to that resumed run only: calling
207
+ `run()`'s): `observer`, `onObserverError`, `events: { onStoreError }`, `parentRunId` (the caller's run id,
208
+ for a sub agent resumed from a step), and `claimOptions`. `parentRunId` applies to that resumed run only: calling
162
209
  `resume()` again on the handle it returns keeps the other resources but not `parentRunId`, which
163
210
  is stale by then. With a store, both `agent.resume()` and `run.resume()` record the agent's
164
- `instanceId` in the resumed run's `metadata`.
211
+ `instanceId` in the resumed run's `metadata` and, when the store supports claims, claim the
212
+ session for the resumed run; a `resume()` on the returned handle reuses the same claim TTL.
165
213
 
166
214
  **Resume replays the interrupted step from the top.** On resume, the step that called
167
215
  `ctx.interrupt()` runs again from its first line; each `ctx.interrupt()` call it reaches
@@ -330,9 +378,13 @@ Some runs continue an earlier one. Their `RunContext` says which, so an observer
330
378
  |---|---|
331
379
  | 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 |
332
380
  | the first run of a session created by `SessionStore.branch()` | `branchedFrom: { sessionId, runId, carrier? }` — the run it was branched from, in the source session |
381
+ | 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 |
382
+
383
+ A run has at most one of the three, only on its top-level context (never on a fork branch's). A
384
+ call that returns a request's outcome without running has none: it has no run.
333
385
 
334
- A run has at most one of the two, only on its top-level context (never on a fork branch's), and a
335
- session that already completed runs nothing, so it has neither.
386
+ Every context of a turn's runs (fork branches included) also carries `requestId` when the turn
387
+ has one.
336
388
 
337
389
  `carrier` is what the earlier run's observer returned from `carry(ctx)`: core calls `carry` once,
338
390
  right after `onRunStart`, keeps its string values, and saves them as `StoredRun.carrier` (or on the
@@ -410,14 +462,14 @@ The harness never trusts a wrapper. `fn` always runs exactly once, and the calle
410
462
  | Export | Description |
411
463
  |---|---|
412
464
  | `createHarness<Ctx>()(schema)` | Creates a harness. Fixes `Ctx`, infers `State` from schema. |
413
- | `createAgent(id, h, slots)` | Assigns the agent an ID and fills `required()` slots. Returns an `Agent`. |
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`. |
414
466
  | `field<T>(opts)` | Declares a state field with a default and optional reduce function. |
415
467
  | `required()` | Marks a provider slot as required at `createAgent()`. |
416
468
  | `runtime()` | Marks a provider slot as required at `agent.run()`. |
417
469
  | `composeObservers([a, b], onObserverError?)` | Merges multiple `Observer` instances into one fan-out observer; a throwing observer is isolated and reported to `onObserverError`. |
418
- | `SessionStore` | Interface for session persistence backends. |
419
- | `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). |
420
- | `RunRef` | `{ runId, carrier? }` — points at an earlier run; used by `RunContext.resumedFrom` and `branchedFrom` (which adds `sessionId`). |
470
+ | `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). |
472
+ | `RunRef` | `{ runId, carrier? }` — points at an earlier run; used by `RunContext.resumedFrom`, `continuedFrom` and `branchedFrom` (which adds `sessionId`). |
421
473
  | `RunCarrier` | `Readonly<Record<string, string>>` — the opaque map an observer's `carry` returns. |
422
474
  | `Observer` | Interface for telemetry hooks on run and step lifecycle events (see [Observers](#observers)). |
423
475
  | `StepSettledEvent` | Payload of `Observer.onStepSettled` — outcome, duration, update, signal, next, error, interrupt, fork. |
@@ -425,11 +477,12 @@ The harness never trusts a wrapper. `fn` always runs exactly once, and the calle
425
477
  | `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`. |
426
478
  | `TelemetryContext` | What `withTelemetry` receives: `observer` (the run's observer, fault-isolated), `stepContext`, and `within(type, payload, fn)` bound to that step. |
427
479
  | `WrapScope` | What `Observer.wrap` is asked to enclose: `{ kind: 'step' }` or `{ kind: 'event', type, payload }`. |
428
- | `RunContext` | Context passed to run-level observer hooks — `agentId`, `sessionId`, `runId`, `parentRunId?`, `instanceId?`, `branchPath?`, `resumedFrom?`, `branchedFrom?` (see [Linking runs](#linking-runs)). |
480
+ | `RunContext` | Context passed to run-level observer hooks — `agentId`, `sessionId`, `runId`, `parentRunId?`, `instanceId?`, `branchPath?`, `resumedFrom?`, `branchedFrom?`, `continuedFrom?`, `requestId?` (see [Linking runs](#linking-runs)). |
429
481
  | `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. |
430
483
  | `NoInterruptError` | Thrown when `resume()` is called but the session is not paused on a matching interrupt. |
431
484
  | `SessionInFlightError` | Thrown when a session is already running. |
432
- | `SessionPendingInterruptError` | Thrown when a session is paused on a pending interrupt — use `agent.resume()` instead of `agent.run()`. |
485
+ | `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. |
433
486
  | `StoreLoadError` | Thrown when the session store fails to load state. |
434
487
 
435
488
  ## Design Principles
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
@@ -1045,12 +1074,26 @@ interface AgentOptions {
1045
1074
  * Keyed by the store key used in the harness (e.g. `{ session: myStore }`).
1046
1075
  */
1047
1076
  readonly stores?: Record<string, unknown>;
1077
+ /**
1078
+ * Default claim options for every run and resume of this agent, used when the call passes none.
1079
+ * Resolution: per-call `claimOptions` → this → `HARNESS_CLAIM_TTL_MS` → 30 000 ms.
1080
+ * `ttlMs` must be a finite number greater than 0; otherwise `createAgent` throws `InvalidClaimOptionsError`.
1081
+ */
1082
+ readonly claimOptions?: ClaimOptions;
1048
1083
  }
1049
1084
  interface Agent {
1050
1085
  /** The agent's unique identifier, as provided to createAgent(). */
1051
1086
  readonly id: string;
1052
1087
  /**
1053
- * Start a new run. Returns a RunHandle synchronously before execution begins.
1088
+ * Start a run. Returns a RunHandle synchronously before execution begins.
1089
+ *
1090
+ * Reserved `resources` keys that shape the session:
1091
+ * - `sessionId?: string` — the conversation thread; a random UUID when absent
1092
+ * - `requestId?: string` — this turn and its idempotency key. With a session store, a call whose
1093
+ * `requestId` the session already ran returns that request's current outcome without running
1094
+ * it again; without one, every call is a new turn. Ignored when the agent has no session store
1095
+ *
1096
+ * Both must be non-empty strings when given; otherwise `run` throws `InvalidRunResourceError`.
1054
1097
  */
1055
1098
  run(initialState: Record<string, unknown>, resources: Record<string, unknown>): RunHandle;
1056
1099
  /**
@@ -1062,6 +1105,9 @@ interface Agent {
1062
1105
  * - `parentRunId?: string` — the caller's run id, as for `run()`; set on this resumed run's
1063
1106
  * `RunContext` only (a later `handle.resume()` on the returned handle does not inherit it)
1064
1107
  * - `events?.onStoreError?` — raw store-error callback (unchanged from prior shape)
1108
+ * - `claimOptions?: ClaimOptions` — claim TTL for this resume, parsed like `agent.run()`'s; falls back
1109
+ * to `AgentOptions.claimOptions`, then `HARNESS_CLAIM_TTL_MS`, then 30 000 ms. A later `handle.resume()`
1110
+ * on the returned handle reuses it
1065
1111
  */
1066
1112
  resume(response: unknown, sessionId: string, interruptId: string, resources?: Record<string, unknown>): RunHandle;
1067
1113
  /**
@@ -1069,6 +1115,14 @@ interface Agent {
1069
1115
  */
1070
1116
  status(sessionId: string): Promise<SessionPhase>;
1071
1117
  }
1118
+ /** Thrown by `agent.run()` when `sessionId` or `requestId` is given but is not a non-empty string. */
1119
+ declare class InvalidRunResourceError extends Error {
1120
+ /** The resource key that was rejected. */
1121
+ readonly key: 'sessionId' | 'requestId';
1122
+ /** The rejected value, as given. */
1123
+ readonly value: unknown;
1124
+ constructor(key: 'sessionId' | 'requestId', value: unknown);
1125
+ }
1072
1126
  /**
1073
1127
  * Instantiate an agent from a fully-configured {@link Harness}.
1074
1128
  *
@@ -1095,17 +1149,6 @@ interface Agent {
1095
1149
  */
1096
1150
  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;
1097
1151
 
1098
- /**
1099
- * Thrown by {@link RunHandle.resume} or {@link Agent.resume} when the session
1100
- * is not currently paused on a matching interrupt.
1101
- *
1102
- * Common causes: the run has not yet settled, the session does not exist,
1103
- * or the supplied `interruptId` does not match the pending interrupt.
1104
- */
1105
- declare class NoInterruptError extends Error {
1106
- constructor();
1107
- }
1108
-
1109
1152
  /**
1110
1153
  * Thrown by {@link Agent.run} or {@link Agent.resume} when the given session
1111
1154
  * is already executing a run in the same process.
@@ -1165,4 +1208,15 @@ declare class LeaseExpiredError extends Error {
1165
1208
  constructor(sessionId: string);
1166
1209
  }
1167
1210
 
1168
- export { type Agent, 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 };
1211
+ /**
1212
+ * Thrown by {@link RunHandle.resume} or {@link Agent.resume} when the session
1213
+ * is not currently paused on a matching interrupt.
1214
+ *
1215
+ * Common causes: the run has not yet settled, the session does not exist,
1216
+ * or the supplied `interruptId` does not match the pending interrupt.
1217
+ */
1218
+ declare class NoInterruptError extends Error {
1219
+ constructor();
1220
+ }
1221
+
1222
+ 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, InvalidRunResourceError, 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 };