@noetic-tools/core 3.0.0 → 3.2.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.
@@ -1,7 +1,7 @@
1
1
  import { ItemSchemaRegistry } from '@noetic-tools/types';
2
2
  import type { DetachedSpawnOverrides } from './deps/interpreter.js';
3
3
  import type { LayerStateStore, RecallCache } from './deps/memory.js';
4
- import type { CheckpointStore, EventBroadcaster, StepLedgerStore } from './deps/runtime.js';
4
+ import type { CheckpointStore, EventBroadcaster, RestoreCheckpointOptions, StepLedgerRetention, StepLedgerStore } from './deps/runtime.js';
5
5
  import type { AgentConfig, AgentHarnessContract, AgentHooks, CallModelRequest, Channel, ChannelHandle, Context, ContextMemory, CwdState, DeliveryMode, DetachedHandle, ExecuteInput, ExecuteOptions, ExternalChannel, FsAdapter, HarnessResponse, HarnessStatus, Item, ItemSchemaExtensions, LLMResponse, LlmProviderConfig, MemoryLayer, ProjectionPolicy, RecallLayerOutput, SessionScope, ShellAdapter, Span, SteeringDecision, Step, StorageAdapter, StreamEvent, StreamingItem, SubHarnessSession, SubprocessAdapter, Tool, TraceExporter, ZodType } from './deps/types.js';
6
6
  export { createStreamIdleWatchdog } from './model-call.js';
7
7
  interface AgentHarnessOpts<TParams extends Record<string, unknown> = Record<string, unknown>> {
@@ -33,6 +33,14 @@ interface AgentHarnessOpts<TParams extends Record<string, unknown> = Record<stri
33
33
  * to enable durable execution.
34
34
  */
35
35
  checkpointStore?: CheckpointStore;
36
+ /**
37
+ * Bounds on the step-completion ledger that backs step-level resume. Only meaningful
38
+ * alongside a `checkpointStore`. Defaults to `DEFAULT_STEP_LEDGER_RETENTION` — 128 KiB
39
+ * per entry, 1000 entries per execution — so an unbounded run cannot grow an unbounded
40
+ * ledger. Exceeding either cap costs resumability for the affected steps (they re-run),
41
+ * never correctness.
42
+ */
43
+ stepLedgerRetention?: StepLedgerRetention;
36
44
  llm?: LlmProviderConfig;
37
45
  /** Harness-wide item schema extensions. */
38
46
  itemSchemas?: ItemSchemaExtensions;
@@ -111,6 +119,12 @@ export declare class AgentHarness<TParams extends Record<string, unknown> = Reco
111
119
  * @internal
112
120
  */
113
121
  readonly stepLedgerStore?: StepLedgerStore;
122
+ /**
123
+ * Resolved retention bounds for that ledger. Validated at construction so a bad cap
124
+ * is a loud config error rather than a run that silently records nothing.
125
+ * @internal
126
+ */
127
+ readonly stepLedgerRetention: Required<StepLedgerRetention>;
114
128
  private readonly initialStep?;
115
129
  /** Harness-wide tool pool merged into every context's `unifiedTools`. */
116
130
  private readonly harnessTools;
@@ -256,9 +270,60 @@ export declare class AgentHarness<TParams extends Record<string, unknown> = Reco
256
270
  * `NoeticConfigError(CHECKPOINT_SCHEMA_MISMATCH)` when the snapshot's
257
271
  * schema version is unrecognised — the caller is expected to discard the
258
272
  * checkpoint via `CheckpointStore.clear()` and start a fresh execution.
273
+ *
274
+ * `opts` is the `createContext`-shaped wiring the host attached to the
275
+ * original context (broadcaster, parent, state, memory overrides). A
276
+ * snapshot recovers data, not live objects — pass the same wiring here or
277
+ * the resumed run gets an undecorated context and whatever depended on that
278
+ * wiring (event streaming, mid-turn injection) stops working without
279
+ * failing. Fields the snapshot owns — items, threadId, resourceId, cwd —
280
+ * are not accepted; they always come from the persisted record.
281
+ *
282
+ * Decoration applied *after* construction (`Object.assign`ed fields, abort
283
+ * registration) stays the host's job: apply it to the context this returns,
284
+ * whose `id` is already the original `executionId`.
285
+ */
286
+ restore(executionId: string, opts?: RestoreCheckpointOptions): Promise<Context | null>;
287
+ /**
288
+ * Discard everything `restore(executionId)` would have used — the snapshot *and* the
289
+ * step-completion ledger — so the next run of that execution starts clean.
290
+ *
291
+ * Hosts must call this rather than resume when the workflow itself changed. A resumed
292
+ * run replays at the coarsest completed granularity: a parent step that finished
293
+ * replays wholesale, so an edit to one of its children is never noticed and the stale
294
+ * output wins. Divergence detection only catches a changed step *at* a recorded path.
295
+ *
296
+ * Also the right call after a terminal outcome (the execution finished, or the user
297
+ * abandoned it) — `CheckpointStore.clear` alone leaves the ledger's per-step keys
298
+ * behind, and nothing else enumerates them.
299
+ */
300
+ clearCheckpoint(executionId: string): Promise<void>;
301
+ /**
302
+ * Cancel an execution: abort `ctx` and every live descendant context, then
303
+ * run the memory-layer teardown for each (spec 09, Cancellation).
304
+ *
305
+ * The abort itself fans out top-down and synchronously — `ctx.abort()`
306
+ * cascades into every live fork path and spawn child, so no corner of the
307
+ * tree keeps working while cleanup is in progress. Cleanup then runs
308
+ * bottom-up: a child's `onComplete` / `dispose` fires before its parent's.
309
+ *
310
+ * Aborting a context rejects everything blocked on it (channel `recv`
311
+ * waiters, parked senders) with `{ kind: 'cancelled' }`, stops its in-flight
312
+ * model call and sub-harness turn, and makes the next step boundary throw
313
+ * `cancelled`.
314
+ *
315
+ * Cancellation is cooperative: a layer hook already in flight is allowed to
316
+ * settle, and step code that ignores `ctx.aborted` between `await`s runs to
317
+ * its next boundary. Calling `cancel()` on an already-cancelled context is a
318
+ * no-op.
319
+ */
320
+ cancel(ctx: Context, reason?: string): Promise<void>;
321
+ /**
322
+ * Run the layer lifecycle's abort path for one cancelled context:
323
+ * `onComplete` with `outcome: 'aborted'`, then `dispose`. Both always run
324
+ * under cancellation (spec 09, Cancellation item 5).
259
325
  */
260
- restore(executionId: string): Promise<Context | null>;
261
- cancel(_ctx: Context, _reason?: string): Promise<void>;
326
+ private teardownCancelledContext;
262
327
  createSpan(name: string, parent: Span | null): Span;
263
328
  getLayerState<T>(executionId: string, layerId: string): T | undefined;
264
329
  setLayerState<T>(executionId: string, layerId: string, state: T): void;
@@ -3,8 +3,8 @@ import { SpanImpl } from '../observability/span-impl.js';
3
3
  import { NoopExporter } from '../observability/trace-exporter.js';
4
4
  import { createInMemoryFsAdapter, createInMemoryShellAdapter, createInMemorySubprocessAdapter, OpenRouter, } from './deps/adapters.js';
5
5
  import { collectAllTools, deduplicateTools, dispatchStepThroughAdapter, execute, } from './deps/interpreter.js';
6
- import { afterModelCallLayers, allocateBudgets, assembleView, beforeToolCallLayers, contextToExecCtx, createLayerStateStore, createRecallCache, DEFAULT_PROJECTION, disposeLayers, executeRerender, initLayers, projectHistoryLayers, recallLayers, recallLayersAtomic, recallLayersEventual, resolveLayerTools, runAppendPipeline, storeLayers, } from './deps/memory.js';
7
- import { buildItemStream, ChannelStore, ContextImpl, captureCheckpoint, createInMemoryStorage, createStepLedgerStore, filterReasoningStream, filterTextStream, restoreFromCheckpoint, SessionRunner, StepLedger, snapshotCwdState, } from './deps/runtime.js';
6
+ import { afterModelCallLayers, allocateBudgets, assembleView, beforeToolCallLayers, completeLayers, contextToExecCtx, createLayerStateStore, createRecallCache, DEFAULT_PROJECTION, disposeLayers, executeRerender, initLayers, projectHistoryLayers, recallLayers, recallLayersAtomic, recallLayersEventual, resolveLayerTools, runAppendPipeline, storeLayers, } from './deps/memory.js';
7
+ import { buildItemStream, ChannelStore, ContextImpl, captureCheckpoint, clearCheckpoint, collectContextTree, createInMemoryStorage, createStepLedgerStore, filterReasoningStream, filterTextStream, resolveStepLedgerRetention, restoreFromCheckpoint, SessionRunner, StepLedger, snapshotCwdState, } from './deps/runtime.js';
8
8
  import { SteeringAction } from './deps/types.js';
9
9
  import { AgentHarnessModelCaller } from './model-call.js';
10
10
  export { createStreamIdleWatchdog } from './model-call.js';
@@ -130,6 +130,12 @@ export class AgentHarness {
130
130
  * @internal
131
131
  */
132
132
  stepLedgerStore;
133
+ /**
134
+ * Resolved retention bounds for that ledger. Validated at construction so a bad cap
135
+ * is a loud config error rather than a run that silently records nothing.
136
+ * @internal
137
+ */
138
+ stepLedgerRetention;
133
139
  initialStep;
134
140
  /** Harness-wide tool pool merged into every context's `unifiedTools`. */
135
141
  harnessTools;
@@ -192,6 +198,7 @@ export class AgentHarness {
192
198
  storage: this.config.storage ?? createInMemoryStorage(),
193
199
  })
194
200
  : undefined;
201
+ this.stepLedgerRetention = resolveStepLedgerRetention(opts.stepLedgerRetention);
195
202
  this.initialStep = opts.initialStep;
196
203
  this.harnessTools = opts.tools ?? [];
197
204
  this._memory = opts.memory;
@@ -476,6 +483,7 @@ export class AgentHarness {
476
483
  ? new StepLedger({
477
484
  executionId,
478
485
  store: this.stepLedgerStore,
486
+ retention: this.stepLedgerRetention,
479
487
  })
480
488
  : undefined,
481
489
  });
@@ -669,12 +677,93 @@ export class AgentHarness {
669
677
  * `NoeticConfigError(CHECKPOINT_SCHEMA_MISMATCH)` when the snapshot's
670
678
  * schema version is unrecognised — the caller is expected to discard the
671
679
  * checkpoint via `CheckpointStore.clear()` and start a fresh execution.
680
+ *
681
+ * `opts` is the `createContext`-shaped wiring the host attached to the
682
+ * original context (broadcaster, parent, state, memory overrides). A
683
+ * snapshot recovers data, not live objects — pass the same wiring here or
684
+ * the resumed run gets an undecorated context and whatever depended on that
685
+ * wiring (event streaming, mid-turn injection) stops working without
686
+ * failing. Fields the snapshot owns — items, threadId, resourceId, cwd —
687
+ * are not accepted; they always come from the persisted record.
688
+ *
689
+ * Decoration applied *after* construction (`Object.assign`ed fields, abort
690
+ * registration) stays the host's job: apply it to the context this returns,
691
+ * whose `id` is already the original `executionId`.
672
692
  */
673
- async restore(executionId) {
674
- return restoreFromCheckpoint(this, executionId);
693
+ async restore(executionId, opts) {
694
+ return restoreFromCheckpoint(this, executionId, opts);
695
+ }
696
+ /**
697
+ * Discard everything `restore(executionId)` would have used — the snapshot *and* the
698
+ * step-completion ledger — so the next run of that execution starts clean.
699
+ *
700
+ * Hosts must call this rather than resume when the workflow itself changed. A resumed
701
+ * run replays at the coarsest completed granularity: a parent step that finished
702
+ * replays wholesale, so an edit to one of its children is never noticed and the stale
703
+ * output wins. Divergence detection only catches a changed step *at* a recorded path.
704
+ *
705
+ * Also the right call after a terminal outcome (the execution finished, or the user
706
+ * abandoned it) — `CheckpointStore.clear` alone leaves the ledger's per-step keys
707
+ * behind, and nothing else enumerates them.
708
+ */
709
+ async clearCheckpoint(executionId) {
710
+ return clearCheckpoint(this, executionId);
675
711
  }
676
712
  //#endregion
677
- async cancel(_ctx, _reason) { }
713
+ /**
714
+ * Cancel an execution: abort `ctx` and every live descendant context, then
715
+ * run the memory-layer teardown for each (spec 09, Cancellation).
716
+ *
717
+ * The abort itself fans out top-down and synchronously — `ctx.abort()`
718
+ * cascades into every live fork path and spawn child, so no corner of the
719
+ * tree keeps working while cleanup is in progress. Cleanup then runs
720
+ * bottom-up: a child's `onComplete` / `dispose` fires before its parent's.
721
+ *
722
+ * Aborting a context rejects everything blocked on it (channel `recv`
723
+ * waiters, parked senders) with `{ kind: 'cancelled' }`, stops its in-flight
724
+ * model call and sub-harness turn, and makes the next step boundary throw
725
+ * `cancelled`.
726
+ *
727
+ * Cancellation is cooperative: a layer hook already in flight is allowed to
728
+ * settle, and step code that ignores `ctx.aborted` between `await`s runs to
729
+ * its next boundary. Calling `cancel()` on an already-cancelled context is a
730
+ * no-op.
731
+ */
732
+ async cancel(ctx, reason) {
733
+ if (ctx.aborted) {
734
+ return;
735
+ }
736
+ // Snapshot the tree first: `abort()` cascades and clears each context's
737
+ // child registry, so collecting afterwards would find nothing.
738
+ const tree = collectContextTree(ctx);
739
+ ctx.abort(reason);
740
+ for (const node of tree.reverse()) {
741
+ await this.teardownCancelledContext(node);
742
+ }
743
+ }
744
+ /**
745
+ * Run the layer lifecycle's abort path for one cancelled context:
746
+ * `onComplete` with `outcome: 'aborted'`, then `dispose`. Both always run
747
+ * under cancellation (spec 09, Cancellation item 5).
748
+ */
749
+ async teardownCancelledContext(ctx) {
750
+ const layers = ctx.layers ?? [];
751
+ if (layers.length === 0) {
752
+ return;
753
+ }
754
+ await completeLayers({
755
+ layers: [
756
+ ...layers,
757
+ ],
758
+ ctx: this.toExecCtx(ctx),
759
+ log: ctx.itemLog,
760
+ outcome: 'aborted',
761
+ store: this.layerStateStore,
762
+ });
763
+ await this.disposeLayers([
764
+ ...layers,
765
+ ], ctx);
766
+ }
678
767
  createSpan(name, parent) {
679
768
  return new SpanImpl(name, parent);
680
769
  }
@@ -1,2 +1,2 @@
1
1
  export type { LayerStateStore, RecallCache } from '@noetic-tools/memory';
2
- export { afterModelCallLayers, allocateBudgets, assembleView, beforeToolCallLayers, contextToExecCtx, createLayerStateStore, createRecallCache, DEFAULT_PROJECTION, disposeLayers, executeRerender, initLayers, projectHistoryLayers, recallLayers, recallLayersAtomic, recallLayersEventual, resolveLayerTools, runAppendPipeline, storeLayers, } from '@noetic-tools/memory';
2
+ export { afterModelCallLayers, allocateBudgets, assembleView, beforeToolCallLayers, completeLayers, contextToExecCtx, createLayerStateStore, createRecallCache, DEFAULT_PROJECTION, disposeLayers, executeRerender, initLayers, projectHistoryLayers, recallLayers, recallLayersAtomic, recallLayersEventual, resolveLayerTools, runAppendPipeline, storeLayers, } from '@noetic-tools/memory';
@@ -1 +1 @@
1
- export { afterModelCallLayers, allocateBudgets, assembleView, beforeToolCallLayers, contextToExecCtx, createLayerStateStore, createRecallCache, DEFAULT_PROJECTION, disposeLayers, executeRerender, initLayers, projectHistoryLayers, recallLayers, recallLayersAtomic, recallLayersEventual, resolveLayerTools, runAppendPipeline, storeLayers, } from '@noetic-tools/memory';
1
+ export { afterModelCallLayers, allocateBudgets, assembleView, beforeToolCallLayers, completeLayers, contextToExecCtx, createLayerStateStore, createRecallCache, DEFAULT_PROJECTION, disposeLayers, executeRerender, initLayers, projectHistoryLayers, recallLayers, recallLayersAtomic, recallLayersEventual, resolveLayerTools, runAppendPipeline, storeLayers, } from '@noetic-tools/memory';
@@ -1,10 +1,11 @@
1
1
  export { ChannelStore } from '../../runtime/channel-store.js';
2
- export { ContextImpl } from '../../runtime/context-impl.js';
2
+ export { ContextImpl, collectContextTree } from '../../runtime/context-impl.js';
3
3
  export { snapshotCwdState } from '../../runtime/cwd-helpers.js';
4
4
  export type { CheckpointStore } from '../../runtime/durable/index.js';
5
- export { captureCheckpoint, restoreFromCheckpoint, } from '../../runtime/durable/harness-checkpoints.js';
6
- export type { StepLedgerEntry, StepLedgerStore } from '../../runtime/durable/step-ledger.js';
7
- export { createStepLedgerStore, StepLedger } from '../../runtime/durable/step-ledger.js';
5
+ export type { RestoreCheckpointOptions } from '../../runtime/durable/harness-checkpoints.js';
6
+ export { captureCheckpoint, clearCheckpoint, restoreFromCheckpoint, } from '../../runtime/durable/harness-checkpoints.js';
7
+ export type { StepLedgerEntry, StepLedgerRetention, StepLedgerStore, } from '../../runtime/durable/step-ledger.js';
8
+ export { createStepLedgerStore, resolveStepLedgerRetention, StepLedger, } from '../../runtime/durable/step-ledger.js';
8
9
  export type { EventBroadcaster } from '../../runtime/event-broadcaster.js';
9
10
  export { createInMemoryStorage } from '../../runtime/in-memory-storage.js';
10
11
  export type { QueuedMessage } from '../../runtime/message-queue.js';
@@ -1,8 +1,8 @@
1
1
  export { ChannelStore } from '../../runtime/channel-store.js';
2
- export { ContextImpl } from '../../runtime/context-impl.js';
2
+ export { ContextImpl, collectContextTree } from '../../runtime/context-impl.js';
3
3
  export { snapshotCwdState } from '../../runtime/cwd-helpers.js';
4
- export { captureCheckpoint, restoreFromCheckpoint, } from '../../runtime/durable/harness-checkpoints.js';
5
- export { createStepLedgerStore, StepLedger } from '../../runtime/durable/step-ledger.js';
4
+ export { captureCheckpoint, clearCheckpoint, restoreFromCheckpoint, } from '../../runtime/durable/harness-checkpoints.js';
5
+ export { createStepLedgerStore, resolveStepLedgerRetention, StepLedger, } from '../../runtime/durable/step-ledger.js';
6
6
  export { createInMemoryStorage } from '../../runtime/in-memory-storage.js';
7
7
  export { SessionRunner } from '../../runtime/session-runner.js';
8
8
  export { buildItemStream, filterReasoningStream, filterTextStream, } from '../../runtime/session-streams.js';
package/dist/index.d.ts CHANGED
@@ -100,9 +100,9 @@ export { react } from './patterns/react.js';
100
100
  /** @public */
101
101
  export { AgentHarness } from './harness/agent-harness.js';
102
102
  /** @public */
103
- export type { AfterFirstTurnContext, CheckpointStore, CreateCheckpointStoreOptions, CreateNudgeMessageOpts, DetachedSignal, RunnableLoopHarness, RunnableLoopOpts, SessionSeedHarness, StallNudgeOpts, } from './runtime/durable/index.js';
103
+ export type { AfterFirstTurnContext, CheckpointStore, CreateCheckpointStoreOptions, CreateNudgeMessageOpts, DetachedSignal, RunnableLoopHarness, RunnableLoopOpts, SessionSeedHarness, StallNudgeOpts, StepLedgerRetention, StepLedgerStats, } from './runtime/durable/index.js';
104
104
  /** @public */
105
- export { CheckpointKeys, createCheckpointStore, createDetachedSignal, createNudgeMessage, createStallNudgeHook, DEFAULT_NUDGE_MESSAGE_TEXT, runnableLoop, seedFromItems, } from './runtime/durable/index.js';
105
+ export { CheckpointKeys, createCheckpointStore, createDetachedSignal, createNudgeMessage, createStallNudgeHook, DEFAULT_NUDGE_MESSAGE_TEXT, DEFAULT_STEP_LEDGER_RETENTION, runnableLoop, seedFromItems, } from './runtime/durable/index.js';
106
106
  /** @public */
107
107
  export { getRegistry, lookupStep, registerStep } from './runtime/step-registry.js';
108
108
  /** @public */
@@ -121,7 +121,7 @@ export { isServerToolSpec } from '@noetic-tools/types';
121
121
  /** @public */
122
122
  /** @public */
123
123
  /** @public */
124
- export type { Context, ContextHarness, CwdState, ItemLog, LastLayerUsage, LayerUsageEntry, } from '@noetic-tools/types';
124
+ export type { Context, ContextHarness, CwdState, ItemLog, LastLayerUsage, LayerUsageEntry, RestoreContextOptions, } from '@noetic-tools/types';
125
125
  /** @public */
126
126
  export { getToolCwd, setToolCwd, snapshotCwdState } from './runtime/cwd-helpers.js';
127
127
  /** @public */
package/dist/index.js CHANGED
@@ -88,7 +88,7 @@ export { react } from './patterns/react.js';
88
88
  /** @public */
89
89
  export { AgentHarness } from './harness/agent-harness.js';
90
90
  /** @public */
91
- export { CheckpointKeys, createCheckpointStore, createDetachedSignal, createNudgeMessage, createStallNudgeHook, DEFAULT_NUDGE_MESSAGE_TEXT, runnableLoop, seedFromItems, } from './runtime/durable/index.js';
91
+ export { CheckpointKeys, createCheckpointStore, createDetachedSignal, createNudgeMessage, createStallNudgeHook, DEFAULT_NUDGE_MESSAGE_TEXT, DEFAULT_STEP_LEDGER_RETENTION, runnableLoop, seedFromItems, } from './runtime/durable/index.js';
92
92
  /** @public */
93
93
  export { getRegistry, lookupStep, registerStep } from './runtime/step-registry.js';
94
94
  //#endregion
@@ -9,7 +9,7 @@ import { isServerToolSpec, SteeringAction } from './action-types.js';
9
9
  import { cloneWithGuard } from './clone-guard.js';
10
10
  import { collectAllTools, deduplicateTools } from './collect-tools.js';
11
11
  import { trackUsage } from './message-helpers.js';
12
- import { getContextChannelStore, isFunctionCall, isMutableContext } from './typeguards.js';
12
+ import { getContextChannelStore, isContextImpl, isFunctionCall, isMutableContext, } from './typeguards.js';
13
13
  //#region run
14
14
  export async function executeRun(step, input, ctx) {
15
15
  const retry = step.retry;
@@ -400,6 +400,11 @@ export async function executeLLM(step, input, ctx, layers) {
400
400
  ];
401
401
  }
402
402
  const _serverTools = serverToolSpecs.length > 0 ? serverToolSpecs : undefined;
403
+ // Cancellation reaches INSIDE the model call, not just the step boundary:
404
+ // the caller's abort signal stops the stream and the tool-round loop mid
405
+ // flight, so `ctx.abort()` / `harness.cancel()` don't wait out a long
406
+ // generation before taking effect (spec 09, Cancellation item 2).
407
+ const signal = isContextImpl(baseCtx) ? baseCtx.abortSignal : undefined;
403
408
  const request = resolvedTools
404
409
  ? {
405
410
  model: resolvedModel,
@@ -415,6 +420,7 @@ export async function executeLLM(step, input, ctx, layers) {
415
420
  allowedToolNames,
416
421
  nodeId: step.id,
417
422
  parentSpan: baseCtx.span,
423
+ signal,
418
424
  }
419
425
  : {
420
426
  model: resolvedModel,
@@ -426,6 +432,7 @@ export async function executeLLM(step, input, ctx, layers) {
426
432
  _serverTools,
427
433
  nodeId: step.id,
428
434
  parentSpan: baseCtx.span,
435
+ signal,
429
436
  };
430
437
  const response = await baseCtx.harness.callModel(request);
431
438
  // Track tokens/cost for EVERY model call, including responses steering
@@ -433,6 +440,15 @@ export async function executeLLM(step, input, ctx, layers) {
433
440
  // real, and until.maxCost / HarnessResponse.usage must see it (spec 07:
434
441
  // tokens accumulate across all LLM calls).
435
442
  trackUsage(baseCtx, response);
443
+ // An abort that landed mid-call returns whatever rounds completed before
444
+ // the signal fired. The spend is charged above, but a truncated generation
445
+ // is not this step's output — surface cancellation instead.
446
+ if (baseCtx.aborted) {
447
+ throw new NoeticErrorImpl({
448
+ kind: 'cancelled',
449
+ reason: baseCtx.abortReason ?? 'context aborted',
450
+ });
451
+ }
436
452
  if (hasLayers) {
437
453
  const decision = await baseCtx.harness.afterModelCall(layers, response, baseCtx);
438
454
  if (decision.action === SteeringAction.Deny) {
@@ -624,6 +640,9 @@ export async function executeSpawn(step, input, ctx, executeStep, opts) {
624
640
  if (hasLayers) {
625
641
  layerStore.cleanup(childExecutionCtx.executionId);
626
642
  }
643
+ // The child is settled: leave the parent's abort cascade so a parent that
644
+ // outlives many spawns does not retain every finished child.
645
+ childCtx.detachFromParent();
627
646
  }
628
647
  }
629
648
  //#endregion
@@ -143,21 +143,30 @@ export async function executeFork(step, input, ctx, rawExecuteStep, opts) {
143
143
  childContexts,
144
144
  opts,
145
145
  }));
146
- switch (step.mode) {
147
- case 'all':
148
- return executeAll(step, paths, input, ctx, childContexts, executeStep, concurrency);
149
- case 'race':
150
- return executeRace(step, paths, input, ctx, childContexts, executeStep, concurrency);
151
- case 'settle':
152
- return executeSettle(step, paths, input, ctx, childContexts, executeStep, concurrency);
153
- default: {
154
- const _exhaustive = step;
155
- throw new NoeticErrorImpl({
156
- kind: 'step_failed',
157
- stepId: 'unknown',
158
- cause: new Error('Unknown fork mode'),
159
- retriesExhausted: false,
160
- });
146
+ try {
147
+ switch (step.mode) {
148
+ case 'all':
149
+ return await executeAll(step, paths, input, ctx, childContexts, executeStep, concurrency);
150
+ case 'race':
151
+ return await executeRace(step, paths, input, ctx, childContexts, executeStep, concurrency);
152
+ case 'settle':
153
+ return await executeSettle(step, paths, input, ctx, childContexts, executeStep, concurrency);
154
+ default: {
155
+ const _exhaustive = step;
156
+ throw new NoeticErrorImpl({
157
+ kind: 'step_failed',
158
+ stepId: 'unknown',
159
+ cause: new Error('Unknown fork mode'),
160
+ retriesExhausted: false,
161
+ });
162
+ }
163
+ }
164
+ }
165
+ finally {
166
+ // Every path has settled (or been abandoned in `race`): leave the parent's
167
+ // abort cascade so it retains only children that are still cancellable.
168
+ for (const child of childContexts) {
169
+ child.detachFromParent();
161
170
  }
162
171
  }
163
172
  }
@@ -9,7 +9,7 @@ import { ZodError } from 'zod';
9
9
  import { resolveLazy } from './execute-action.js';
10
10
  import { trackUsage } from './message-helpers.js';
11
11
  import { SubHarnessEventBridge } from './sub-harness-events.js';
12
- import { isFunctionCall, isMutableContext } from './typeguards.js';
12
+ import { isContextImpl, isFunctionCall, isMutableContext } from './typeguards.js';
13
13
  //#endregion
14
14
  //#region Helpers
15
15
  function sessionStore(ctx) {
@@ -39,6 +39,14 @@ function resolveTurnText(resolvedPrompt, input) {
39
39
  }
40
40
  return typeof input === 'string' ? input : '';
41
41
  }
42
+ /**
43
+ * Abort signal of the executing context, when it is a framework context. Handed
44
+ * to the adapter so `ctx.abort()` / `harness.cancel()` stops the coding agent's
45
+ * in-flight turn instead of leaving a sub-process running until it finishes.
46
+ */
47
+ function abortSignalOf(ctx) {
48
+ return isContextImpl(ctx) ? ctx.abortSignal : undefined;
49
+ }
42
50
  function buildRunContext(ctx) {
43
51
  return {
44
52
  cwd: ctx.cwdState.cwd,
@@ -46,6 +54,7 @@ function buildRunContext(ctx) {
46
54
  shell: ctx.shell,
47
55
  subprocess: ctx.subprocess,
48
56
  threadId: ctx.threadId,
57
+ signal: abortSignalOf(ctx),
49
58
  };
50
59
  }
51
60
  function toLlmResponse(result) {
@@ -108,6 +117,7 @@ async function startOrReuseSession(step, harness, ctx, baseCtx, history) {
108
117
  instructions: await resolveLazy(step.instructions, ctx),
109
118
  history,
110
119
  ctx: buildRunContext(baseCtx),
120
+ signal: abortSignalOf(baseCtx),
111
121
  });
112
122
  if (reuseKey) {
113
123
  store.set(reuseKey, session);
@@ -167,6 +177,9 @@ export async function executeSubHarness(step, input, ctx) {
167
177
  result = await resolution.session.doPromptTurn({
168
178
  prompt: turnText,
169
179
  emit: (part) => bridge.forward(part),
180
+ // Per-turn signal, so a session reused across turns is cancelled by the
181
+ // context running the CURRENT turn rather than the one that started it.
182
+ signal: abortSignalOf(baseCtx),
170
183
  });
171
184
  }
172
185
  catch (e) {
@@ -178,6 +191,14 @@ export async function executeSubHarness(step, input, ctx) {
178
191
  if (e instanceof NoeticErrorImpl) {
179
192
  throw e;
180
193
  }
194
+ // An adapter interrupted by the abort signal rejects with whatever its SDK
195
+ // throws; the context is the authority on why the turn ended.
196
+ if (baseCtx.aborted) {
197
+ throw new NoeticErrorImpl({
198
+ kind: 'cancelled',
199
+ reason: baseCtx.abortReason ?? 'context aborted',
200
+ });
201
+ }
181
202
  throw new NoeticErrorImpl({
182
203
  kind: 'step_failed',
183
204
  stepId: step.id,
@@ -1,5 +1,12 @@
1
1
  import type { AgentHarnessContract, Channel, ChannelStore, Context, ContextMemory, CwdState, EventBroadcaster, FrontierFrame, FsAdapter, Item, ItemLog, ItemSchemaRegistry, LastLayerUsage, MemoryLayer, Span, StepMeta, TokenUsage, Tool } from './context-deps.js';
2
2
  import type { StepLedger } from './durable/step-ledger.js';
3
+ /**
4
+ * @internal
5
+ * The cancellation tree rooted at `ctx`, in pre-order (a parent before its
6
+ * children). Reverse it to walk deepest-first. Only framework contexts track
7
+ * children, so a foreign `Context` implementation yields just itself.
8
+ */
9
+ export declare function collectContextTree(ctx: Context): Context[];
3
10
  export declare class ContextImpl implements Context<ContextMemory> {
4
11
  readonly id: string;
5
12
  stepCount: number;
@@ -41,9 +48,18 @@ export declare class ContextImpl implements Context<ContextMemory> {
41
48
  * reject promptly with `{ kind: 'cancelled' }` instead of hanging until
42
49
  * their timeout (spec 09, Cancellation item 2). Each fork/spawn child
43
50
  * constructs its own ContextImpl and therefore its own controller —
44
- * aborting a child never rejects the parent's waiters.
51
+ * aborting a child never rejects the parent's waiters, while aborting a
52
+ * parent DOES cascade down to every live child (see `_children`).
45
53
  */
46
54
  private readonly _abortController;
55
+ /**
56
+ * Live child contexts (fork paths, spawn children) registered for the abort
57
+ * cascade. A child adds itself at construction and removes itself via
58
+ * `detachFromParent()` when its execution settles, so a long-lived parent
59
+ * driving a loop of spawns does not accumulate finished children.
60
+ * @internal
61
+ */
62
+ private readonly _children;
47
63
  private _memory?;
48
64
  /**
49
65
  * Stack of steps currently in flight on this context, most-recent last.
@@ -107,6 +123,27 @@ export declare class ContextImpl implements Context<ContextMemory> {
107
123
  */
108
124
  id?: string;
109
125
  });
126
+ /**
127
+ * @internal
128
+ * Register `child` for the abort cascade. A child constructed under an
129
+ * already-aborted parent is aborted immediately — otherwise a spawn issued
130
+ * in the window between `abort()` and the interpreter noticing would run a
131
+ * whole sub-agent that nothing can stop.
132
+ */
133
+ private adoptChild;
134
+ /**
135
+ * @internal
136
+ * Leave the parent's abort cascade. Called by the interpreter when a spawn
137
+ * child or fork path settles — a finished child has nothing left to cancel,
138
+ * and holding it would leak for the remaining life of the parent.
139
+ */
140
+ detachFromParent(): void;
141
+ /**
142
+ * @internal
143
+ * Live child contexts, in registration order. Consumed by
144
+ * `AgentHarness.cancel` to walk the execution tree depth-first.
145
+ */
146
+ get children(): ReadonlyArray<ContextImpl>;
110
147
  get elapsed(): number;
111
148
  get aborted(): boolean;
112
149
  get fs(): FsAdapter;
@@ -9,6 +9,24 @@ class NoopSpan {
9
9
  addEvent(_name, _attributes) { }
10
10
  end() { }
11
11
  }
12
+ /**
13
+ * @internal
14
+ * The cancellation tree rooted at `ctx`, in pre-order (a parent before its
15
+ * children). Reverse it to walk deepest-first. Only framework contexts track
16
+ * children, so a foreign `Context` implementation yields just itself.
17
+ */
18
+ export function collectContextTree(ctx) {
19
+ const tree = [
20
+ ctx,
21
+ ];
22
+ if (!(ctx instanceof ContextImpl)) {
23
+ return tree;
24
+ }
25
+ for (const child of ctx.children) {
26
+ tree.push(...collectContextTree(child));
27
+ }
28
+ return tree;
29
+ }
12
30
  export class ContextImpl {
13
31
  id;
14
32
  stepCount = 0;
@@ -54,9 +72,18 @@ export class ContextImpl {
54
72
  * reject promptly with `{ kind: 'cancelled' }` instead of hanging until
55
73
  * their timeout (spec 09, Cancellation item 2). Each fork/spawn child
56
74
  * constructs its own ContextImpl and therefore its own controller —
57
- * aborting a child never rejects the parent's waiters.
75
+ * aborting a child never rejects the parent's waiters, while aborting a
76
+ * parent DOES cascade down to every live child (see `_children`).
58
77
  */
59
78
  _abortController = new AbortController();
79
+ /**
80
+ * Live child contexts (fork paths, spawn children) registered for the abort
81
+ * cascade. A child adds itself at construction and removes itself via
82
+ * `detachFromParent()` when its execution settles, so a long-lived parent
83
+ * driving a loop of spawns does not accumulate finished children.
84
+ * @internal
85
+ */
86
+ _children = new Set();
60
87
  _memory;
61
88
  /**
62
89
  * Stack of steps currently in flight on this context, most-recent last.
@@ -120,6 +147,46 @@ export class ContextImpl {
120
147
  }
121
148
  }
122
149
  this.itemLog = log;
150
+ // Join the parent's abort cascade last, so the child is fully constructed
151
+ // before an already-aborted parent aborts it.
152
+ if (this.parent instanceof ContextImpl) {
153
+ this.parent.adoptChild(this);
154
+ }
155
+ }
156
+ /**
157
+ * @internal
158
+ * Register `child` for the abort cascade. A child constructed under an
159
+ * already-aborted parent is aborted immediately — otherwise a spawn issued
160
+ * in the window between `abort()` and the interpreter noticing would run a
161
+ * whole sub-agent that nothing can stop.
162
+ */
163
+ adoptChild(child) {
164
+ if (this._aborted) {
165
+ child.abort(this._abortReason ?? 'parent context aborted');
166
+ return;
167
+ }
168
+ this._children.add(child);
169
+ }
170
+ /**
171
+ * @internal
172
+ * Leave the parent's abort cascade. Called by the interpreter when a spawn
173
+ * child or fork path settles — a finished child has nothing left to cancel,
174
+ * and holding it would leak for the remaining life of the parent.
175
+ */
176
+ detachFromParent() {
177
+ if (this.parent instanceof ContextImpl) {
178
+ this.parent._children.delete(this);
179
+ }
180
+ }
181
+ /**
182
+ * @internal
183
+ * Live child contexts, in registration order. Consumed by
184
+ * `AgentHarness.cancel` to walk the execution tree depth-first.
185
+ */
186
+ get children() {
187
+ return [
188
+ ...this._children,
189
+ ];
123
190
  }
124
191
  get elapsed() {
125
192
  return Date.now() - this._createdAt;
@@ -184,12 +251,26 @@ export class ContextImpl {
184
251
  return this._abortReason;
185
252
  }
186
253
  abort(reason) {
254
+ if (this._aborted) {
255
+ // Idempotent (spec 09): the first abort owns the reason, and the signal
256
+ // has already fired — re-firing it would be a no-op anyway.
257
+ return;
258
+ }
187
259
  this._aborted = true;
188
260
  this._abortReason = reason;
189
261
  // Reject everything blocked on this context (channel recv waiters,
190
- // parked back-pressure senders) with { kind: 'cancelled' }. Idempotent —
191
- // a second abort() leaves the already-aborted signal untouched.
262
+ // parked back-pressure senders) with { kind: 'cancelled' }.
192
263
  this._abortController.abort(reason ?? 'context aborted');
264
+ // Cascade to live children (fork paths, spawn children). The registry is
265
+ // cleared first so a child's own `detachFromParent()` during its unwind
266
+ // cannot mutate the set we are iterating.
267
+ const children = [
268
+ ...this._children,
269
+ ];
270
+ this._children.clear();
271
+ for (const child of children) {
272
+ child.abort(reason ?? 'parent context aborted');
273
+ }
193
274
  }
194
275
  /**
195
276
  * @internal
@@ -1,7 +1,20 @@
1
1
  import type { LayerStateStore, MemoryLayer } from '@noetic-tools/memory';
2
- import type { Context, Item, ItemSchemaRegistry } from '@noetic-tools/types';
2
+ import type { Context, Item, ItemSchemaRegistry, RestoreContextOptions } from '@noetic-tools/types';
3
+ import type { EventBroadcaster } from '../event-broadcaster.js';
3
4
  import type { CheckpointStore } from './checkpoint-store.js';
4
- import type { StepLedgerStore } from './step-ledger.js';
5
+ import type { StepLedgerRetention, StepLedgerStore } from './step-ledger.js';
6
+ /**
7
+ * Host wiring forwarded into the context `restoreFromCheckpoint` builds.
8
+ * `RestoreContextOptions` covers the portable fields; core widens it with the
9
+ * internal `_broadcaster` seam so a resumed turn keeps streaming framework UI
10
+ * events.
11
+ *
12
+ * @internal
13
+ */
14
+ export type RestoreCheckpointOptions = RestoreContextOptions & {
15
+ /** @internal Event broadcaster the host attached to the original context. */
16
+ _broadcaster?: EventBroadcaster;
17
+ };
5
18
  /**
6
19
  * Minimum harness surface `captureCheckpoint` / `restoreFromCheckpoint`
7
20
  * require. `AgentHarness` already satisfies this shape via its
@@ -13,15 +26,15 @@ import type { StepLedgerStore } from './step-ledger.js';
13
26
  export interface CheckpointHarnessHandle {
14
27
  readonly checkpointStore?: CheckpointStore;
15
28
  readonly stepLedgerStore?: StepLedgerStore;
29
+ readonly stepLedgerRetention?: StepLedgerRetention;
16
30
  readonly layerStateStore: LayerStateStore;
17
31
  readonly itemSchemas: ItemSchemaRegistry;
18
32
  readonly _memory?: MemoryLayer[];
19
- createContext(opts?: {
33
+ createContext(opts?: RestoreCheckpointOptions & {
20
34
  items?: Item[];
21
35
  threadId?: string;
22
36
  resourceId?: string;
23
37
  cwdInit?: string;
24
- memory?: MemoryLayer[];
25
38
  }): Context;
26
39
  }
27
40
  /**
@@ -41,10 +54,31 @@ export declare function captureCheckpoint(h: CheckpointHarnessHandle, ctx: Conte
41
54
  * the restored context observes the pre-crash state through
42
55
  * `readLayerState` and the memory projectors.
43
56
  *
57
+ * `opts` carries the host's own context wiring (broadcaster, parent, state,
58
+ * layer overrides) into the rebuilt context. A snapshot cannot round-trip live
59
+ * objects, so a host that decorated the original context has to hand the same
60
+ * decoration back here — otherwise the resumed run gets a bare context and
61
+ * whatever depended on that wiring stops working silently. Snapshot-owned
62
+ * fields always win: identity, item log, and cwd come from the persisted
63
+ * record, never from `opts`.
64
+ *
44
65
  * Preserves the original executionId on the returned context via
45
66
  * `Object.defineProperty` — adapter correlation across crash/restart
46
67
  * requires a stable id.
47
68
  *
48
69
  * @internal
49
70
  */
50
- export declare function restoreFromCheckpoint(h: CheckpointHarnessHandle, executionId: string): Promise<Context | null>;
71
+ export declare function restoreFromCheckpoint(h: CheckpointHarnessHandle, executionId: string, opts?: RestoreCheckpointOptions): Promise<Context | null>;
72
+ /**
73
+ * Discard every recovery record for one execution: the snapshot and the completion
74
+ * ledger. Clearing the snapshot alone would strand the ledger's shards under
75
+ * `execution:<id>:ledger:*` forever, since nothing else enumerates them.
76
+ *
77
+ * This is the operation a host performs when resume is no longer valid — most often
78
+ * because the workflow changed. Replay happens at the coarsest completed granularity,
79
+ * so a step edited *beneath* a recorded parent is invisible to divergence detection and
80
+ * the old output would be replayed over the new tree. Clear, then start fresh.
81
+ *
82
+ * @internal
83
+ */
84
+ export declare function clearCheckpoint(h: CheckpointHarnessHandle, executionId: string): Promise<void>;
@@ -65,13 +65,21 @@ export async function captureCheckpoint(h, ctx) {
65
65
  * the restored context observes the pre-crash state through
66
66
  * `readLayerState` and the memory projectors.
67
67
  *
68
+ * `opts` carries the host's own context wiring (broadcaster, parent, state,
69
+ * layer overrides) into the rebuilt context. A snapshot cannot round-trip live
70
+ * objects, so a host that decorated the original context has to hand the same
71
+ * decoration back here — otherwise the resumed run gets a bare context and
72
+ * whatever depended on that wiring stops working silently. Snapshot-owned
73
+ * fields always win: identity, item log, and cwd come from the persisted
74
+ * record, never from `opts`.
75
+ *
68
76
  * Preserves the original executionId on the returned context via
69
77
  * `Object.defineProperty` — adapter correlation across crash/restart
70
78
  * requires a stable id.
71
79
  *
72
80
  * @internal
73
81
  */
74
- export async function restoreFromCheckpoint(h, executionId) {
82
+ export async function restoreFromCheckpoint(h, executionId, opts) {
75
83
  const store = h.checkpointStore;
76
84
  if (!store) {
77
85
  return null;
@@ -85,12 +93,16 @@ export async function restoreFromCheckpoint(h, executionId) {
85
93
  }
86
94
  const items = h.itemSchemas.parseMany(snapshot.itemLog.items);
87
95
  const cwdInit = snapshot.cwd?.current ?? undefined;
96
+ /* Caller wiring first, snapshot second: a host may legitimately swap the memory
97
+ * layers or hang the restored execution under a new parent, but it must never be
98
+ * able to override the identity/history the snapshot is the record of. */
88
99
  const ctx = h.createContext({
100
+ ...opts,
89
101
  items,
90
102
  threadId: snapshot.threadId,
91
103
  resourceId: snapshot.resourceId,
92
104
  cwdInit,
93
- memory: h._memory,
105
+ memory: opts?.memory ?? h._memory,
94
106
  });
95
107
  if (ctx instanceof ContextImpl) {
96
108
  Object.defineProperty(ctx, 'id', {
@@ -111,6 +123,7 @@ export async function restoreFromCheckpoint(h, executionId) {
111
123
  executionId,
112
124
  store: h.stepLedgerStore,
113
125
  recovered,
126
+ retention: h.stepLedgerRetention,
114
127
  }),
115
128
  configurable: false,
116
129
  writable: false,
@@ -121,3 +134,21 @@ export async function restoreFromCheckpoint(h, executionId) {
121
134
  return ctx;
122
135
  }
123
136
  //#endregion
137
+ //#region clearCheckpoint
138
+ /**
139
+ * Discard every recovery record for one execution: the snapshot and the completion
140
+ * ledger. Clearing the snapshot alone would strand the ledger's shards under
141
+ * `execution:<id>:ledger:*` forever, since nothing else enumerates them.
142
+ *
143
+ * This is the operation a host performs when resume is no longer valid — most often
144
+ * because the workflow changed. Replay happens at the coarsest completed granularity,
145
+ * so a step edited *beneath* a recorded parent is invisible to divergence detection and
146
+ * the old output would be replayed over the new tree. Clear, then start fresh.
147
+ *
148
+ * @internal
149
+ */
150
+ export async function clearCheckpoint(h, executionId) {
151
+ await h.checkpointStore?.clear(executionId);
152
+ await h.stepLedgerStore?.clear(executionId);
153
+ }
154
+ //#endregion
@@ -8,3 +8,5 @@ export type { SessionSeedHarness } from './session-seed.js';
8
8
  export { seedFromItems } from './session-seed.js';
9
9
  export type { CreateNudgeMessageOpts, StallNudgeOpts } from './stall-nudge.js';
10
10
  export { createNudgeMessage, createStallNudgeHook, DEFAULT_NUDGE_MESSAGE_TEXT, } from './stall-nudge.js';
11
+ export type { StepLedgerRetention, StepLedgerStats } from './step-ledger.js';
12
+ export { DEFAULT_STEP_LEDGER_RETENTION } from './step-ledger.js';
@@ -3,3 +3,4 @@ export { createDetachedSignal } from './detached-signal.js';
3
3
  export { runnableLoop } from './runnable-loop.js';
4
4
  export { seedFromItems } from './session-seed.js';
5
5
  export { createNudgeMessage, createStallNudgeHook, DEFAULT_NUDGE_MESSAGE_TEXT, } from './stall-nudge.js';
6
+ export { DEFAULT_STEP_LEDGER_RETENTION } from './step-ledger.js';
@@ -10,6 +10,12 @@
10
10
  * Entries are stored one key per step so appends are O(1). Folding the whole ledger
11
11
  * into the single `execution:<id>:snapshot` key would rewrite a growing blob on every
12
12
  * step — O(n²) bytes over a run.
13
+ *
14
+ * Sharding bounds the cost of one append but not the total, so retention is bounded on
15
+ * two axes (`StepLedgerRetention`): an oversized entry is dropped rather than written,
16
+ * and past `maxEntries` the oldest entry is evicted. Both degradations are safe by
17
+ * construction — a missing entry re-runs its step, which costs work rather than
18
+ * correctness.
13
19
  */
14
20
  import type { StorageAdapter } from '@noetic-tools/memory';
15
21
  import { z } from 'zod';
@@ -23,46 +29,128 @@ export declare const StepLedgerEntrySchema: z.ZodObject<{
23
29
  }, z.core.$strip>;
24
30
  /** @public A completed step recorded for replay. */
25
31
  export type StepLedgerEntry = z.infer<typeof StepLedgerEntrySchema>;
32
+ /**
33
+ * @public
34
+ * Bounds on what one execution's ledger retains. Both caps degrade resume rather than
35
+ * break it: a step with no entry simply runs again, so the worst case is repeated work
36
+ * (and repeated side effects — see `specs/23a-step-level-resume` § "The side-effect
37
+ * boundary"), never a replayed value that disagrees with the recorded run.
38
+ */
39
+ export interface StepLedgerRetention {
40
+ /**
41
+ * Largest output, in UTF-8 bytes of its JSON encoding, that will be recorded. A step
42
+ * whose output exceeds this is not recorded at all and re-runs on resume. Keeps one
43
+ * entry inside the per-value limit real storage backends impose (128 KiB on several)
44
+ * instead of failing the write. Pass `Infinity` to record any size.
45
+ *
46
+ * Defaults to `DEFAULT_STEP_LEDGER_RETENTION.maxEntryBytes` (128 KiB).
47
+ */
48
+ maxEntryBytes?: number;
49
+ /**
50
+ * Most entries retained for one execution. Recording past the cap evicts the oldest
51
+ * entry, so resume is best-effort over a bounded *suffix* of the run: the tail of the
52
+ * work is skipped, the head runs again. Pass `Infinity` to retain every entry.
53
+ *
54
+ * Defaults to `DEFAULT_STEP_LEDGER_RETENTION.maxEntries` (1000).
55
+ */
56
+ maxEntries?: number;
57
+ }
58
+ /** @public Retention applied when a harness does not configure `stepLedgerRetention`. */
59
+ export declare const DEFAULT_STEP_LEDGER_RETENTION: {
60
+ readonly maxEntryBytes: number;
61
+ readonly maxEntries: 1000;
62
+ };
63
+ /**
64
+ * @public
65
+ * Fill in defaults and reject nonsense. Validating loudly matters because every
66
+ * misconfiguration here fails *silently* at runtime — a negative cap would drop every
67
+ * entry and the run would look fine while resuming nothing.
68
+ *
69
+ * @throws NoeticConfigError `code: 'STEP_LEDGER_RETENTION_INVALID'`
70
+ */
71
+ export declare function resolveStepLedgerRetention(retention?: StepLedgerRetention): Required<StepLedgerRetention>;
26
72
  /** Storage key prefix owning every ledger entry for one execution. */
27
73
  export declare function stepLedgerPrefix(executionId: string): string;
74
+ /**
75
+ * @public
76
+ * Everything one execution's ledger recovered from storage: the entries available for
77
+ * replay plus the sequence bookkeeping a further append needs. `nextSeq` cannot be
78
+ * derived from `entries.size` — retention leaves gaps, and reusing a live sequence
79
+ * number would overwrite an entry the resumed run still has to replay.
80
+ */
81
+ export interface StepLedgerWindow {
82
+ /** Recovered entries, keyed by execution path. Corrupt rows are skipped. */
83
+ entries: Map<string, StepLedgerEntry>;
84
+ /** Sequence number the next append takes: highest recorded, plus one. */
85
+ nextSeq: number;
86
+ /** Lowest sequence number still in storage — where eviction resumes. */
87
+ oldestSeq: number;
88
+ }
28
89
  /**
29
90
  * @public
30
91
  * Durable append-only ledger for one harness's executions. Backed by the same
31
92
  * `StorageAdapter` as the checkpoint store; it reserves the `:ledger:` suffix that
32
93
  * `CheckpointKeys` already sets aside.
94
+ *
95
+ * The store owns the key layout only. Retention policy lives in `StepLedger`, which
96
+ * holds the per-execution sequence cursor that eviction needs.
33
97
  */
34
98
  export interface StepLedgerStore {
35
99
  /** Record a completed step. Sequence numbers order entries within an execution. */
36
100
  append: (executionId: string, seq: number, entry: StepLedgerEntry) => Promise<void>;
37
- /** Every recorded entry for an execution, keyed by path. Corrupt rows are skipped. */
38
- load: (executionId: string) => Promise<Map<string, StepLedgerEntry>>;
101
+ /** Drop one entry by sequence number. No-op when the key is absent. */
102
+ delete: (executionId: string, seq: number) => Promise<void>;
103
+ /** Everything recorded for an execution, plus its sequence bounds. */
104
+ load: (executionId: string) => Promise<StepLedgerWindow>;
39
105
  /** Drop an execution's ledger (paired with `CheckpointStore.clear`). */
40
106
  clear: (executionId: string) => Promise<void>;
41
107
  }
42
108
  export declare function createStepLedgerStore(opts: {
43
109
  storage: StorageAdapter;
44
110
  }): StepLedgerStore;
111
+ /** @public What retention did to one execution's ledger. Observable for host logging. */
112
+ export interface StepLedgerStats {
113
+ /** Entries written. */
114
+ recorded: number;
115
+ /** Entries not written because their output exceeded `maxEntryBytes`. */
116
+ droppedOversize: number;
117
+ /** Entries not written because their output does not survive JSON encoding. */
118
+ droppedUnserialisable: number;
119
+ /** Entries deleted to stay within `maxEntries`. */
120
+ evicted: number;
121
+ }
45
122
  /**
46
123
  * @public
47
124
  * The in-memory ledger a single execution carries: entries recovered from a previous
48
125
  * run (available for replay) plus the sequence counter for newly recorded ones.
49
126
  *
50
127
  * Shared by reference across fork/spawn children so one execution has one ledger —
51
- * path keys are globally unique across the step tree, so a flat map is correct.
128
+ * path keys are globally unique across the step tree, so a flat map is correct. That
129
+ * sharing is also why retention lives here rather than in the store: the sequence
130
+ * cursor eviction walks belongs to the execution, not to the harness.
52
131
  */
53
132
  export declare class StepLedger {
54
133
  /** Recovered entries, by path. Consumed as replay proceeds. */
55
134
  private readonly replayable;
135
+ /** Sequence number the next recorded entry takes. */
56
136
  private seq;
137
+ /** Lowest sequence number still in storage — advances as eviction proceeds. */
138
+ private oldestSeq;
57
139
  private readonly store?;
58
140
  private readonly executionId;
141
+ private readonly retention;
142
+ private readonly counts;
59
143
  constructor(opts: {
60
144
  executionId: string;
61
145
  store?: StepLedgerStore;
62
- recovered?: Map<string, StepLedgerEntry>;
146
+ /** What `StepLedgerStore.load` recovered. Absent on a fresh execution. */
147
+ recovered?: StepLedgerWindow;
148
+ retention?: StepLedgerRetention;
63
149
  });
64
150
  /** True when this execution has nothing recovered to replay (the common, fresh case). */
65
151
  get isEmpty(): boolean;
152
+ /** What retention has done so far. A snapshot — safe to hold. */
153
+ get stats(): StepLedgerStats;
66
154
  /**
67
155
  * The recorded output for `path`, when a previous run completed the same step there.
68
156
  * A divergence (different step id or kind at this path) discards the entry and every
@@ -77,4 +165,18 @@ export declare class StepLedger {
77
165
  private discardSubtree;
78
166
  /** Record a completed step. Best-effort: a failed write costs resumability, not the run. */
79
167
  record(entry: StepLedgerEntry): Promise<void>;
168
+ /**
169
+ * Whether this entry is small enough — and encodable at all — to be worth a write.
170
+ * An output no adapter could serialise is treated exactly like an oversized one:
171
+ * not recorded, so the step re-runs.
172
+ */
173
+ private retainable;
174
+ /**
175
+ * Delete oldest-first until the execution is back inside `maxEntries`. The bound is on
176
+ * the sequence *window* rather than an exact row count, which is what keeps this
177
+ * correct without a `list()` per append.
178
+ */
179
+ private evictOverflow;
180
+ /** One warning per reason per execution — a long run would otherwise flood the log. */
181
+ private warnOnFirstDrop;
80
182
  }
@@ -10,9 +10,17 @@
10
10
  * Entries are stored one key per step so appends are O(1). Folding the whole ledger
11
11
  * into the single `execution:<id>:snapshot` key would rewrite a growing blob on every
12
12
  * step — O(n²) bytes over a run.
13
+ *
14
+ * Sharding bounds the cost of one append but not the total, so retention is bounded on
15
+ * two axes (`StepLedgerRetention`): an oversized entry is dropped rather than written,
16
+ * and past `maxEntries` the oldest entry is evicted. Both degradations are safe by
17
+ * construction — a missing entry re-runs its step, which costs work rather than
18
+ * correctness.
13
19
  */
14
20
  import { storageGetMany } from '@noetic-tools/memory';
21
+ import { NoeticConfigError } from '@noetic-tools/types';
15
22
  import { z } from 'zod';
23
+ //#region Entry
16
24
  /** @public One completed step, keyed by its execution path. */
17
25
  export const StepLedgerEntrySchema = z.object({
18
26
  /** Execution path key — see `ContextImpl.currentPath()`. Unique per dispatch. */
@@ -23,6 +31,55 @@ export const StepLedgerEntrySchema = z.object({
23
31
  output: z.unknown(),
24
32
  completedAt: z.string(),
25
33
  });
34
+ /** @public Retention applied when a harness does not configure `stepLedgerRetention`. */
35
+ export const DEFAULT_STEP_LEDGER_RETENTION = {
36
+ maxEntryBytes: 128 * 1024,
37
+ maxEntries: 1e3,
38
+ };
39
+ /**
40
+ * @public
41
+ * Fill in defaults and reject nonsense. Validating loudly matters because every
42
+ * misconfiguration here fails *silently* at runtime — a negative cap would drop every
43
+ * entry and the run would look fine while resuming nothing.
44
+ *
45
+ * @throws NoeticConfigError `code: 'STEP_LEDGER_RETENTION_INVALID'`
46
+ */
47
+ export function resolveStepLedgerRetention(retention) {
48
+ const resolved = {
49
+ maxEntryBytes: retention?.maxEntryBytes ?? DEFAULT_STEP_LEDGER_RETENTION.maxEntryBytes,
50
+ maxEntries: retention?.maxEntries ?? DEFAULT_STEP_LEDGER_RETENTION.maxEntries,
51
+ };
52
+ for (const [field, value] of Object.entries(resolved)) {
53
+ if (value === Number.POSITIVE_INFINITY) {
54
+ continue;
55
+ }
56
+ if (!Number.isFinite(value) || value < 1) {
57
+ throw new NoeticConfigError({
58
+ code: 'STEP_LEDGER_RETENTION_INVALID',
59
+ message: `stepLedgerRetention.${field} must be a positive number or Infinity (received ${String(value)}).`,
60
+ hint: 'Omit the field to accept the default, or pass Infinity to disable the cap.',
61
+ });
62
+ }
63
+ }
64
+ return resolved;
65
+ }
66
+ /**
67
+ * Whether `json` fits `cap` UTF-8 bytes. A UTF-16 code unit encodes to between 1 and 3
68
+ * UTF-8 bytes (a surrogate pair is 2 units → 4 bytes, i.e. 2 per unit), so string
69
+ * length brackets the byte count and decides most calls without encoding a megabyte
70
+ * of output just to measure it.
71
+ */
72
+ function fitsByteCap(json, cap) {
73
+ if (json.length > cap) {
74
+ return false;
75
+ }
76
+ if (json.length * 3 <= cap) {
77
+ return true;
78
+ }
79
+ return new TextEncoder().encode(json).length <= cap;
80
+ }
81
+ //#endregion
82
+ //#region Store
26
83
  /** Storage key prefix owning every ledger entry for one execution. */
27
84
  export function stepLedgerPrefix(executionId) {
28
85
  return `execution:${executionId}:ledger:`;
@@ -31,22 +88,43 @@ export function stepLedgerPrefix(executionId) {
31
88
  function seqKey(executionId, seq) {
32
89
  return `${stepLedgerPrefix(executionId)}${String(seq).padStart(8, '0')}`;
33
90
  }
91
+ /** The sequence number a ledger key carries, or null when the suffix is not one. */
92
+ function seqFromKey(executionId, key) {
93
+ const prefix = stepLedgerPrefix(executionId);
94
+ if (!key.startsWith(prefix)) {
95
+ return null;
96
+ }
97
+ const seq = Number(key.slice(prefix.length));
98
+ return Number.isInteger(seq) && seq >= 0 ? seq : null;
99
+ }
34
100
  export function createStepLedgerStore(opts) {
35
101
  const { storage } = opts;
36
102
  return {
37
103
  append: async (executionId, seq, entry) => {
38
104
  await storage.set(seqKey(executionId, seq), entry);
39
105
  },
106
+ delete: async (executionId, seq) => {
107
+ await storage.delete(seqKey(executionId, seq));
108
+ },
40
109
  load: async (executionId) => {
41
110
  const keys = await storage.list(stepLedgerPrefix(executionId));
42
111
  /* One batch read, not one round trip per completed step. Recovery is the
43
112
  * moment a network-backed adapter can least afford an N+1. */
44
- const entries = await storageGetMany(storage, keys);
45
- const byPath = new Map();
113
+ const raws = await storageGetMany(storage, keys);
114
+ const entries = new Map();
115
+ /* Sequence bounds come from the KEYS, not the parsed rows: a row that fails to
116
+ * parse still occupies its key, so it must not be handed back out as reusable. */
117
+ let highestSeq = -1;
118
+ let oldestSeq = -1;
46
119
  // Walk `keys`, not the map: `seqKey`'s zero-padding makes `list()` order
47
120
  // dispatch order, and a later entry at a path must win over an earlier one.
48
121
  for (const key of keys) {
49
- const raw = entries.get(key);
122
+ const seq = seqFromKey(executionId, key);
123
+ if (seq !== null) {
124
+ highestSeq = Math.max(highestSeq, seq);
125
+ oldestSeq = oldestSeq === -1 ? seq : Math.min(oldestSeq, seq);
126
+ }
127
+ const raw = raws.get(key);
50
128
  if (raw === undefined) {
51
129
  continue;
52
130
  }
@@ -57,9 +135,13 @@ export function createStepLedgerStore(opts) {
57
135
  console.warn(`StepLedger: discarding unreadable entry "${key}": ${parsed.error.message}`);
58
136
  continue;
59
137
  }
60
- byPath.set(parsed.data.path, parsed.data);
138
+ entries.set(parsed.data.path, parsed.data);
61
139
  }
62
- return byPath;
140
+ return {
141
+ entries,
142
+ nextSeq: highestSeq + 1,
143
+ oldestSeq: oldestSeq === -1 ? 0 : oldestSeq,
144
+ };
63
145
  },
64
146
  clear: async (executionId) => {
65
147
  const keys = await storage.list(stepLedgerPrefix(executionId));
@@ -75,24 +157,44 @@ export function createStepLedgerStore(opts) {
75
157
  * run (available for replay) plus the sequence counter for newly recorded ones.
76
158
  *
77
159
  * Shared by reference across fork/spawn children so one execution has one ledger —
78
- * path keys are globally unique across the step tree, so a flat map is correct.
160
+ * path keys are globally unique across the step tree, so a flat map is correct. That
161
+ * sharing is also why retention lives here rather than in the store: the sequence
162
+ * cursor eviction walks belongs to the execution, not to the harness.
79
163
  */
80
164
  export class StepLedger {
81
165
  /** Recovered entries, by path. Consumed as replay proceeds. */
82
166
  replayable;
167
+ /** Sequence number the next recorded entry takes. */
83
168
  seq;
169
+ /** Lowest sequence number still in storage — advances as eviction proceeds. */
170
+ oldestSeq;
84
171
  store;
85
172
  executionId;
173
+ retention;
174
+ counts = {
175
+ recorded: 0,
176
+ droppedOversize: 0,
177
+ droppedUnserialisable: 0,
178
+ evicted: 0,
179
+ };
86
180
  constructor(opts) {
87
181
  this.executionId = opts.executionId;
88
182
  this.store = opts.store;
89
- this.replayable = opts.recovered ?? new Map();
90
- this.seq = this.replayable.size;
183
+ this.retention = resolveStepLedgerRetention(opts.retention);
184
+ this.replayable = opts.recovered?.entries ?? new Map();
185
+ this.seq = opts.recovered?.nextSeq ?? 0;
186
+ this.oldestSeq = opts.recovered?.oldestSeq ?? 0;
91
187
  }
92
188
  /** True when this execution has nothing recovered to replay (the common, fresh case). */
93
189
  get isEmpty() {
94
190
  return this.replayable.size === 0;
95
191
  }
192
+ /** What retention has done so far. A snapshot — safe to hold. */
193
+ get stats() {
194
+ return {
195
+ ...this.counts,
196
+ };
197
+ }
96
198
  /**
97
199
  * The recorded output for `path`, when a previous run completed the same step there.
98
200
  * A divergence (different step id or kind at this path) discards the entry and every
@@ -125,13 +227,80 @@ export class StepLedger {
125
227
  if (!this.store) {
126
228
  return;
127
229
  }
230
+ if (!this.retainable(entry)) {
231
+ return;
232
+ }
233
+ /* Reserve the sequence number BEFORE awaiting: concurrent fork legs record through
234
+ * the one shared ledger, and two of them reading the same counter would write the
235
+ * same key, silently overwriting a sibling's entry. A write that then fails leaves a
236
+ * gap, which only makes the window below a slight over-estimate of the live count —
237
+ * eviction runs marginally early, never late. */
128
238
  const seq = this.seq;
129
- this.seq += 1;
239
+ this.seq = seq + 1;
130
240
  try {
131
241
  await this.store.append(this.executionId, seq, entry);
132
242
  }
133
243
  catch (error) {
134
244
  console.warn(`StepLedger: failed to record "${entry.path}":`, error instanceof Error ? error.message : String(error));
245
+ return;
246
+ }
247
+ this.counts.recorded += 1;
248
+ await this.evictOverflow();
249
+ }
250
+ /**
251
+ * Whether this entry is small enough — and encodable at all — to be worth a write.
252
+ * An output no adapter could serialise is treated exactly like an oversized one:
253
+ * not recorded, so the step re-runs.
254
+ */
255
+ retainable(entry) {
256
+ let json;
257
+ try {
258
+ json = JSON.stringify(entry.output);
259
+ }
260
+ catch {
261
+ this.counts.droppedUnserialisable += 1;
262
+ this.warnOnFirstDrop(this.counts.droppedUnserialisable, `StepLedger: not recording "${entry.path}" — its output does not survive JSON encoding.`);
263
+ return false;
264
+ }
265
+ // `undefined` stringifies to nothing; there is no size to bound.
266
+ if (json !== undefined && !fitsByteCap(json, this.retention.maxEntryBytes)) {
267
+ this.counts.droppedOversize += 1;
268
+ this.warnOnFirstDrop(this.counts.droppedOversize, `StepLedger: not recording "${entry.path}" — output exceeds maxEntryBytes (${this.retention.maxEntryBytes}).`);
269
+ return false;
270
+ }
271
+ return true;
272
+ }
273
+ /**
274
+ * Delete oldest-first until the execution is back inside `maxEntries`. The bound is on
275
+ * the sequence *window* rather than an exact row count, which is what keeps this
276
+ * correct without a `list()` per append.
277
+ */
278
+ async evictOverflow() {
279
+ const store = this.store;
280
+ if (!store) {
281
+ return;
282
+ }
283
+ while (this.seq - this.oldestSeq > this.retention.maxEntries) {
284
+ const victim = this.oldestSeq;
285
+ try {
286
+ await store.delete(this.executionId, victim);
287
+ }
288
+ catch (error) {
289
+ /* Leave the cursor where it is and stop: the next append retries this victim
290
+ * rather than spinning on a store that is currently refusing deletes. */
291
+ console.warn(`StepLedger: failed to evict entry ${victim} of "${this.executionId}":`, error instanceof Error ? error.message : String(error));
292
+ return;
293
+ }
294
+ this.oldestSeq = victim + 1;
295
+ this.counts.evicted += 1;
296
+ }
297
+ }
298
+ /** One warning per reason per execution — a long run would otherwise flood the log. */
299
+ warnOnFirstDrop(count, message) {
300
+ if (count > 1) {
301
+ return;
135
302
  }
303
+ console.warn(`${message} The step will re-run on resume; further drops are silent.`);
136
304
  }
137
305
  }
306
+ //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noetic-tools/core",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "type": "module",
5
5
  "description": "Step primitives, interpreter, runtime, and memory layer contract for Noetic agents.",
6
6
  "license": "Apache-2.0",
@@ -67,7 +67,7 @@
67
67
  },
68
68
  "dependencies": {
69
69
  "@noetic-tools/memory": "^2.0.0",
70
- "@noetic-tools/types": "^2.0.0",
70
+ "@noetic-tools/types": "^2.1.0",
71
71
  "@openrouter/agent": "^0.6.0",
72
72
  "zod": "^4.0.0"
73
73
  },