@noetic-tools/core 2.3.0 → 2.5.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,4 +1,4 @@
1
- import type { FunctionCallItem, FunctionCallOutputItem, Item, ItemSchemaExtensions, Tool, ToolExecutionContext, ToolMemoryDeclaration, ToolResultExtensionItem } from '@noetic-tools/types';
1
+ import type { FunctionCallItem, FunctionCallOutputItem, Item, ItemSchemaExtensions, Tool, ToolExecutionContext, ToolMemoryDeclaration, ToolResultExtensionItem, ToolUiDeclaration } from '@noetic-tools/types';
2
2
  import type { ZodTypeAny, z } from 'zod';
3
3
  interface ToolConfig<I extends ZodTypeAny, O extends ZodTypeAny> {
4
4
  name: string;
@@ -18,6 +18,8 @@ interface ToolConfig<I extends ZodTypeAny, O extends ZodTypeAny> {
18
18
  needsApproval?: boolean;
19
19
  /** Optional memory declaration — the runtime generates a MemoryLayer from this via toolMemoryLayer(). */
20
20
  memory?: ToolMemoryDeclaration;
21
+ /** Optional UI declaration — the runtime emits the rendered fragments at call/progress/result points. */
22
+ ui?: ToolUiDeclaration<I, O>;
21
23
  }
22
24
  interface GeneratorToolConfig<I extends ZodTypeAny, E extends ZodTypeAny, O extends ZodTypeAny> {
23
25
  name: string;
@@ -38,6 +40,8 @@ interface GeneratorToolConfig<I extends ZodTypeAny, E extends ZodTypeAny, O exte
38
40
  needsApproval?: boolean;
39
41
  /** Optional memory declaration — the runtime generates a MemoryLayer from this via toolMemoryLayer(). */
40
42
  memory?: ToolMemoryDeclaration;
43
+ /** Optional UI declaration — the runtime emits the rendered fragments at call/progress/result points. */
44
+ ui?: ToolUiDeclaration<I, O, z.infer<E>>;
41
45
  }
42
46
  /**
43
47
  * Creates a typed Tool with Zod schema inference for input and output.
@@ -36,6 +36,7 @@ export function tool(config) {
36
36
  execute: config.execute,
37
37
  needsApproval: config.needsApproval,
38
38
  memory: config.memory,
39
+ ui: config.ui,
39
40
  };
40
41
  }
41
42
  /**
@@ -56,6 +57,7 @@ export function toolWithGenerator(config) {
56
57
  execute: config.execute,
57
58
  needsApproval: config.needsApproval,
58
59
  memory: config.memory,
60
+ ui: config.ui,
59
61
  };
60
62
  }
61
63
  //#endregion
@@ -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 } from './deps/runtime.js';
4
+ import type { CheckpointStore, EventBroadcaster, 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>> {
@@ -105,6 +105,12 @@ export declare class AgentHarness<TParams extends Record<string, unknown> = Reco
105
105
  * @internal
106
106
  */
107
107
  readonly checkpointStore?: CheckpointStore;
108
+ /**
109
+ * Append-only step ledger backing resume. Present exactly when durability is on
110
+ * (a `CheckpointStore` is configured), so a zero-config harness records nothing.
111
+ * @internal
112
+ */
113
+ readonly stepLedgerStore?: StepLedgerStore;
108
114
  private readonly initialStep?;
109
115
  /** Harness-wide tool pool merged into every context's `unifiedTools`. */
110
116
  private readonly harnessTools;
@@ -4,7 +4,7 @@ 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
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, filterReasoningStream, filterTextStream, restoreFromCheckpoint, SessionRunner, snapshotCwdState, } from './deps/runtime.js';
7
+ import { buildItemStream, ChannelStore, ContextImpl, captureCheckpoint, createInMemoryStorage, createStepLedgerStore, filterReasoningStream, filterTextStream, 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';
@@ -124,6 +124,12 @@ export class AgentHarness {
124
124
  * @internal
125
125
  */
126
126
  checkpointStore;
127
+ /**
128
+ * Append-only step ledger backing resume. Present exactly when durability is on
129
+ * (a `CheckpointStore` is configured), so a zero-config harness records nothing.
130
+ * @internal
131
+ */
132
+ stepLedgerStore;
127
133
  initialStep;
128
134
  /** Harness-wide tool pool merged into every context's `unifiedTools`. */
129
135
  harnessTools;
@@ -181,6 +187,11 @@ export class AgentHarness {
181
187
  this.shell = opts.shell ?? createInMemoryShellAdapter();
182
188
  this.subprocess = opts.subprocess ?? createInMemorySubprocessAdapter();
183
189
  this.checkpointStore = opts.checkpointStore;
190
+ this.stepLedgerStore = opts.checkpointStore
191
+ ? createStepLedgerStore({
192
+ storage: this.config.storage ?? createInMemoryStorage(),
193
+ })
194
+ : undefined;
184
195
  this.initialStep = opts.initialStep;
185
196
  this.harnessTools = opts.tools ?? [];
186
197
  this._memory = opts.memory;
@@ -433,6 +444,10 @@ export class AgentHarness {
433
444
  await this.initLayers(layers, ctx, storage);
434
445
  }
435
446
  detachedSpawn(s, input, parentCtx, overrides) {
447
+ /* NOT a checkpoint boundary, despite spec 23 listing one here: a DetachedHandle's
448
+ * settle is only observable via `await()`, and calling it internally would consume
449
+ * the result the caller is holding. The handle manifest is the adapter's own
450
+ * durability surface (`listLive`/`reattach`), so nothing is lost. */
436
451
  return dispatchStepThroughAdapter(this, s, input, parentCtx, overrides);
437
452
  }
438
453
  createContext(opts) {
@@ -442,13 +457,27 @@ export class AgentHarness {
442
457
  base: this.itemSchemas,
443
458
  layers: effectiveMemory,
444
459
  });
460
+ /* The execution id is chosen here rather than inside ContextImpl so the ledger
461
+ * can be keyed to it — the ledger's storage keys and `ctx.id` must agree for a
462
+ * later `restore()` to find what this run recorded. */
463
+ const executionId = crypto.randomUUID();
445
464
  return new ContextImpl({
446
465
  ...rest,
466
+ id: executionId,
447
467
  harness: this,
448
468
  channelStore: this.channelStore,
449
469
  layers: effectiveMemory,
450
470
  itemSchemas,
451
471
  cwdState: resolveContextCwdState(this.rootCwdState, opts?.parent, cwdInit),
472
+ // Without this `ctx.checkpoint()` is inert, and the boundaries below never
473
+ // persist anything (a no-op when no CheckpointStore is configured).
474
+ checkpointFn: (c) => this.checkpoint(c),
475
+ ledger: this.stepLedgerStore
476
+ ? new StepLedger({
477
+ executionId,
478
+ store: this.stepLedgerStore,
479
+ })
480
+ : undefined,
452
481
  });
453
482
  }
454
483
  send(channel, value, ctx) {
@@ -704,13 +733,16 @@ export class AgentHarness {
704
733
  rerenderRequests: [],
705
734
  };
706
735
  }
707
- return runAppendPipeline({
736
+ const piped = await runAppendPipeline({
708
737
  layers,
709
738
  items,
710
739
  ctx: this.toExecCtx(ctx),
711
740
  log: ctx.itemLog,
712
741
  store: this.layerStateStore,
713
742
  });
743
+ // Layer state can mutate as items land, so the snapshot must follow the fold.
744
+ await this.checkpoint(ctx);
745
+ return piped;
714
746
  }
715
747
  async executeRerender(requests, layers, ctx, budgets, query) {
716
748
  return executeRerender({
@@ -3,6 +3,8 @@ export { ContextImpl } 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
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';
6
8
  export type { EventBroadcaster } from '../../runtime/event-broadcaster.js';
7
9
  export { createInMemoryStorage } from '../../runtime/in-memory-storage.js';
8
10
  export type { QueuedMessage } from '../../runtime/message-queue.js';
@@ -2,6 +2,7 @@ export { ChannelStore } from '../../runtime/channel-store.js';
2
2
  export { ContextImpl } from '../../runtime/context-impl.js';
3
3
  export { snapshotCwdState } from '../../runtime/cwd-helpers.js';
4
4
  export { captureCheckpoint, restoreFromCheckpoint, } from '../../runtime/durable/harness-checkpoints.js';
5
+ export { createStepLedgerStore, StepLedger } from '../../runtime/durable/step-ledger.js';
5
6
  export { createInMemoryStorage } from '../../runtime/in-memory-storage.js';
6
7
  export { SessionRunner } from '../../runtime/session-runner.js';
7
8
  export { buildItemStream, filterReasoningStream, filterTextStream, } from '../../runtime/session-streams.js';
@@ -591,6 +591,10 @@ export async function executeSpawn(step, input, ctx, executeStep, opts) {
591
591
  unifiedTools: childUnifiedTools.length > 0 ? childUnifiedTools : undefined,
592
592
  cwdState: snapshotCwdState(baseCtx),
593
593
  id: childId,
594
+ // The spawn's own path prefixes the child's, so a resumed run lines its steps up
595
+ // with the ones recorded inside the same spawn rather than at the root.
596
+ pathPrefix: baseCtx instanceof ContextImpl ? baseCtx.currentPath() : '',
597
+ ledger: baseCtx instanceof ContextImpl ? baseCtx.ledger : undefined,
594
598
  });
595
599
  try {
596
600
  // Execute the child step
@@ -23,11 +23,18 @@ function createChildContexts(ctx, count, stepId) {
23
23
  const threadId = isContextImpl(ctx) ? ctx.threadId : crypto.randomUUID();
24
24
  const resourceId = isContextImpl(ctx) ? ctx.resourceId : undefined;
25
25
  const channelStore = getContextChannelStore(ctx);
26
+ const parentPath = isContextImpl(ctx) ? ctx.currentPath() : '';
27
+ const parentLedger = isContextImpl(ctx) ? ctx.ledger : undefined;
26
28
  return Array.from({
27
29
  length: count,
28
- }, () => new ContextImpl({
30
+ }, (_unused, index) => new ContextImpl({
29
31
  harness: ctx.harness,
30
32
  parent: ctx,
33
+ /* Ledger paths must stay unique across sibling paths: a child ContextImpl
34
+ * starts with an empty frontier, so without a prefix every path would
35
+ * restart at the root and collide with its siblings. */
36
+ pathPrefix: `${parentPath}[${index}]`,
37
+ ledger: parentLedger,
31
38
  items: [
32
39
  ...ctx.itemLog.items,
33
40
  ],
@@ -172,6 +172,32 @@ export async function execute(step, input, ctx) {
172
172
  input,
173
173
  });
174
174
  }
175
+ /* Resume: a previous run that completed this exact step replays its recorded
176
+ * output instead of re-running it. Captured before dispatch and held for the
177
+ * record below, because `leaveStep` pops the path on the way out. */
178
+ const ledgerPath = impl && step.id.length > 0 ? impl.currentPath() : undefined;
179
+ const ledger = impl?.ledger;
180
+ if (ledger && ledgerPath !== undefined && !ledger.isEmpty) {
181
+ const replayed = ledger.take(ledgerPath, {
182
+ id: step.id,
183
+ kind: step.kind,
184
+ });
185
+ if (replayed) {
186
+ if (impl && step.id.length > 0) {
187
+ impl.leaveStep(step.id);
188
+ }
189
+ emitFrameworkEvent({
190
+ broadcaster,
191
+ agentName,
192
+ eventType: 'step_replayed',
193
+ data: {
194
+ stepId: step.id,
195
+ kind: step.kind,
196
+ },
197
+ });
198
+ return frameworkCast(replayed.output);
199
+ }
200
+ }
175
201
  let result;
176
202
  try {
177
203
  switch (step.kind) {
@@ -243,5 +269,22 @@ export async function execute(step, input, ctx) {
243
269
  data: completedData,
244
270
  });
245
271
  }
272
+ /* Record the completed step so a resumed run replays this output rather than
273
+ * re-running the step. Only successes are recorded: a step that threw must run
274
+ * again. */
275
+ if (ledger && ledgerPath !== undefined) {
276
+ await ledger.record({
277
+ path: ledgerPath,
278
+ stepId: step.id,
279
+ kind: step.kind,
280
+ output: result,
281
+ completedAt: new Date().toISOString(),
282
+ });
283
+ }
284
+ /* Durability boundary (spec 23): snapshot after each completed step, so a crash
285
+ * lands on a checkpoint that includes the item-log and layer-state mutations this
286
+ * step produced. A no-op unless the harness has a CheckpointStore, and a failing
287
+ * save is logged rather than thrown — durability must never fail a good step. */
288
+ await baseCtx.checkpoint();
246
289
  return result;
247
290
  }
@@ -1,4 +1,5 @@
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
+ import type { StepLedger } from './durable/step-ledger.js';
2
3
  export declare class ContextImpl implements Context<ContextMemory> {
3
4
  readonly id: string;
4
5
  stepCount: number;
@@ -53,6 +54,31 @@ export declare class ContextImpl implements Context<ContextMemory> {
53
54
  * @internal
54
55
  */
55
56
  private readonly _frontier;
57
+ /**
58
+ * Path segment for each in-flight frame, parallel to `_frontier`. Kept separate so
59
+ * `serialiseFrontier()` keeps emitting the published `FrontierFrame` shape.
60
+ * @internal
61
+ */
62
+ private readonly _segments;
63
+ /**
64
+ * Path this context's steps hang under. Empty on a root context; a fork/spawn child
65
+ * inherits its parent's current path plus a discriminator, so a path key stays unique
66
+ * across the whole step tree rather than restarting per child context.
67
+ * @internal
68
+ */
69
+ private readonly _pathPrefix;
70
+ /**
71
+ * Dispatch counts per `<parent path>/<step id>`, so a loop body re-entered on each
72
+ * iteration gets `#0`, `#1`, … instead of colliding on one key.
73
+ * @internal
74
+ */
75
+ private readonly _occurrences;
76
+ /**
77
+ * Completion ledger for the execution this context belongs to. Shared by reference
78
+ * with fork/spawn children — one execution, one ledger.
79
+ * @internal
80
+ */
81
+ readonly ledger?: StepLedger;
56
82
  constructor(opts: {
57
83
  harness: AgentHarnessContract;
58
84
  parent?: Context;
@@ -62,7 +88,11 @@ export declare class ContextImpl implements Context<ContextMemory> {
62
88
  resourceId?: string;
63
89
  span?: Span;
64
90
  channelStore?: ChannelStore;
65
- checkpointFn?: () => Promise<void>;
91
+ checkpointFn?: (ctx: Context) => Promise<void>;
92
+ /** Path this context's steps hang under (fork/spawn children inherit one). */
93
+ pathPrefix?: string;
94
+ /** The execution's completion ledger, shared with children by reference. */
95
+ ledger?: StepLedger;
66
96
  layers?: MemoryLayer[];
67
97
  unifiedTools?: ReadonlyArray<Tool>;
68
98
  itemSchemas?: ItemSchemaRegistry;
@@ -108,6 +138,14 @@ export declare class ContextImpl implements Context<ContextMemory> {
108
138
  * stack of steps currently in-flight on this context.
109
139
  */
110
140
  enterStep(frame: FrontierFrame): void;
141
+ /**
142
+ * @internal
143
+ * The execution path of the step currently on top of the frontier — the ledger key
144
+ * identifying this exact dispatch. Stable across a replay given the same control
145
+ * flow, which is what lets a resumed run line recorded outputs up with the steps
146
+ * that produced them.
147
+ */
148
+ currentPath(): string;
111
149
  /**
112
150
  * @internal
113
151
  * Pop the top frame. Called when a step resolves (success or failure)
@@ -67,6 +67,31 @@ export class ContextImpl {
67
67
  * @internal
68
68
  */
69
69
  _frontier = [];
70
+ /**
71
+ * Path segment for each in-flight frame, parallel to `_frontier`. Kept separate so
72
+ * `serialiseFrontier()` keeps emitting the published `FrontierFrame` shape.
73
+ * @internal
74
+ */
75
+ _segments = [];
76
+ /**
77
+ * Path this context's steps hang under. Empty on a root context; a fork/spawn child
78
+ * inherits its parent's current path plus a discriminator, so a path key stays unique
79
+ * across the whole step tree rather than restarting per child context.
80
+ * @internal
81
+ */
82
+ _pathPrefix;
83
+ /**
84
+ * Dispatch counts per `<parent path>/<step id>`, so a loop body re-entered on each
85
+ * iteration gets `#0`, `#1`, … instead of colliding on one key.
86
+ * @internal
87
+ */
88
+ _occurrences = new Map();
89
+ /**
90
+ * Completion ledger for the execution this context belongs to. Shared by reference
91
+ * with fork/spawn children — one execution, one ledger.
92
+ * @internal
93
+ */
94
+ ledger;
70
95
  constructor(opts) {
71
96
  this.id = opts.id ?? crypto.randomUUID();
72
97
  this._createdAt = Date.now();
@@ -79,6 +104,8 @@ export class ContextImpl {
79
104
  this.resourceId = opts.resourceId;
80
105
  this.channelStore = opts.channelStore;
81
106
  this._checkpointFn = opts.checkpointFn;
107
+ this._pathPrefix = opts.pathPrefix ?? '';
108
+ this.ledger = opts.ledger;
82
109
  this.layers = opts.layers;
83
110
  this.unifiedTools = opts.unifiedTools;
84
111
  this.itemSchemas = opts.itemSchemas ?? defaultItemSchemaRegistry;
@@ -140,7 +167,7 @@ export class ContextImpl {
140
167
  }
141
168
  async checkpoint() {
142
169
  if (this._checkpointFn) {
143
- await this._checkpointFn();
170
+ await this._checkpointFn(this);
144
171
  }
145
172
  }
146
173
  complete(value) {
@@ -180,8 +207,23 @@ export class ContextImpl {
180
207
  * stack of steps currently in-flight on this context.
181
208
  */
182
209
  enterStep(frame) {
210
+ const parent = this.currentPath();
211
+ const key = `${parent}/${frame.stepId}`;
212
+ const occurrence = this._occurrences.get(key) ?? 0;
213
+ this._occurrences.set(key, occurrence + 1);
214
+ this._segments.push(`/${frame.stepId}#${occurrence}`);
183
215
  this._frontier.push(frame);
184
216
  }
217
+ /**
218
+ * @internal
219
+ * The execution path of the step currently on top of the frontier — the ledger key
220
+ * identifying this exact dispatch. Stable across a replay given the same control
221
+ * flow, which is what lets a resumed run line recorded outputs up with the steps
222
+ * that produced them.
223
+ */
224
+ currentPath() {
225
+ return this._pathPrefix + this._segments.join('');
226
+ }
185
227
  /**
186
228
  * @internal
187
229
  * Pop the top frame. Called when a step resolves (success or failure)
@@ -198,6 +240,7 @@ export class ContextImpl {
198
240
  console.warn(`ContextImpl.leaveStep: expected "${expectedStepId}" on top of frontier but saw "${top.stepId}".`);
199
241
  }
200
242
  this._frontier.pop();
243
+ this._segments.pop();
201
244
  }
202
245
  /**
203
246
  * @internal
@@ -1,6 +1,7 @@
1
1
  import type { LayerStateStore, MemoryLayer } from '@noetic-tools/memory';
2
2
  import type { Context, Item, ItemSchemaRegistry } from '@noetic-tools/types';
3
3
  import type { CheckpointStore } from './checkpoint-store.js';
4
+ import type { StepLedgerStore } from './step-ledger.js';
4
5
  /**
5
6
  * Minimum harness surface `captureCheckpoint` / `restoreFromCheckpoint`
6
7
  * require. `AgentHarness` already satisfies this shape via its
@@ -11,6 +12,7 @@ import type { CheckpointStore } from './checkpoint-store.js';
11
12
  */
12
13
  export interface CheckpointHarnessHandle {
13
14
  readonly checkpointStore?: CheckpointStore;
15
+ readonly stepLedgerStore?: StepLedgerStore;
14
16
  readonly layerStateStore: LayerStateStore;
15
17
  readonly itemSchemas: ItemSchemaRegistry;
16
18
  readonly _memory?: MemoryLayer[];
@@ -1,5 +1,6 @@
1
1
  import { CheckpointSchemaVersion } from '../../types/checkpoint.js';
2
2
  import { ContextImpl } from '../context-impl.js';
3
+ import { StepLedger } from './step-ledger.js';
3
4
  //#endregion
4
5
  //#region captureCheckpoint
5
6
  /**
@@ -98,6 +99,24 @@ export async function restoreFromCheckpoint(h, executionId) {
98
99
  writable: false,
99
100
  enumerable: true,
100
101
  });
102
+ /* Attach the recovered completion ledger. `createContext` already built a fresh
103
+ * one keyed to a throwaway id; the restored context needs the entries recorded
104
+ * under THIS execution, or `execute()` would replay nothing and re-run the whole
105
+ * tree. Assigned the same way `id` is, to keep `createContext`'s public options
106
+ * free of resume-only fields. */
107
+ if (h.stepLedgerStore) {
108
+ const recovered = await h.stepLedgerStore.load(executionId);
109
+ Object.defineProperty(ctx, 'ledger', {
110
+ value: new StepLedger({
111
+ executionId,
112
+ store: h.stepLedgerStore,
113
+ recovered,
114
+ }),
115
+ configurable: false,
116
+ writable: false,
117
+ enumerable: true,
118
+ });
119
+ }
101
120
  }
102
121
  return ctx;
103
122
  }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The step completion ledger: what a resumed execution replays instead of re-running.
3
+ *
4
+ * The frontier records which steps were IN FLIGHT at snapshot time; it says nothing
5
+ * about which ones finished, and a finished step leaves no trace once `leaveStep` pops
6
+ * it. Resume therefore needs a separate record — and it must carry each step's OUTPUT,
7
+ * not merely a "done" flag: steps consume the previous step's value, and an `llm` step
8
+ * re-run to catch up produces a different one than the rest of the run already saw.
9
+ *
10
+ * Entries are stored one key per step so appends are O(1). Folding the whole ledger
11
+ * into the single `execution:<id>:snapshot` key would rewrite a growing blob on every
12
+ * step — O(n²) bytes over a run.
13
+ */
14
+ import type { StorageAdapter } from '@noetic-tools/memory';
15
+ import { z } from 'zod';
16
+ /** @public One completed step, keyed by its execution path. */
17
+ export declare const StepLedgerEntrySchema: z.ZodObject<{
18
+ path: z.ZodString;
19
+ stepId: z.ZodString;
20
+ kind: z.ZodString;
21
+ output: z.ZodUnknown;
22
+ completedAt: z.ZodString;
23
+ }, z.core.$strip>;
24
+ /** @public A completed step recorded for replay. */
25
+ export type StepLedgerEntry = z.infer<typeof StepLedgerEntrySchema>;
26
+ /** Storage key prefix owning every ledger entry for one execution. */
27
+ export declare function stepLedgerPrefix(executionId: string): string;
28
+ /**
29
+ * @public
30
+ * Durable append-only ledger for one harness's executions. Backed by the same
31
+ * `StorageAdapter` as the checkpoint store; it reserves the `:ledger:` suffix that
32
+ * `CheckpointKeys` already sets aside.
33
+ */
34
+ export interface StepLedgerStore {
35
+ /** Record a completed step. Sequence numbers order entries within an execution. */
36
+ 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>>;
39
+ /** Drop an execution's ledger (paired with `CheckpointStore.clear`). */
40
+ clear: (executionId: string) => Promise<void>;
41
+ }
42
+ export declare function createStepLedgerStore(opts: {
43
+ storage: StorageAdapter;
44
+ }): StepLedgerStore;
45
+ /**
46
+ * @public
47
+ * The in-memory ledger a single execution carries: entries recovered from a previous
48
+ * run (available for replay) plus the sequence counter for newly recorded ones.
49
+ *
50
+ * 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.
52
+ */
53
+ export declare class StepLedger {
54
+ /** Recovered entries, by path. Consumed as replay proceeds. */
55
+ private readonly replayable;
56
+ private seq;
57
+ private readonly store?;
58
+ private readonly executionId;
59
+ constructor(opts: {
60
+ executionId: string;
61
+ store?: StepLedgerStore;
62
+ recovered?: Map<string, StepLedgerEntry>;
63
+ });
64
+ /** True when this execution has nothing recovered to replay (the common, fresh case). */
65
+ get isEmpty(): boolean;
66
+ /**
67
+ * The recorded output for `path`, when a previous run completed the same step there.
68
+ * A divergence (different step id or kind at this path) discards the entry and every
69
+ * entry recorded beneath it, then returns undefined so the step runs fresh — the
70
+ * subtree's recorded outputs belong to a branch that no longer exists.
71
+ */
72
+ take(path: string, step: {
73
+ id: string;
74
+ kind: string;
75
+ }): StepLedgerEntry | undefined;
76
+ /** Drop `path` and everything recorded below it. */
77
+ private discardSubtree;
78
+ /** Record a completed step. Best-effort: a failed write costs resumability, not the run. */
79
+ record(entry: StepLedgerEntry): Promise<void>;
80
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The step completion ledger: what a resumed execution replays instead of re-running.
3
+ *
4
+ * The frontier records which steps were IN FLIGHT at snapshot time; it says nothing
5
+ * about which ones finished, and a finished step leaves no trace once `leaveStep` pops
6
+ * it. Resume therefore needs a separate record — and it must carry each step's OUTPUT,
7
+ * not merely a "done" flag: steps consume the previous step's value, and an `llm` step
8
+ * re-run to catch up produces a different one than the rest of the run already saw.
9
+ *
10
+ * Entries are stored one key per step so appends are O(1). Folding the whole ledger
11
+ * into the single `execution:<id>:snapshot` key would rewrite a growing blob on every
12
+ * step — O(n²) bytes over a run.
13
+ */
14
+ import { z } from 'zod';
15
+ /** @public One completed step, keyed by its execution path. */
16
+ export const StepLedgerEntrySchema = z.object({
17
+ /** Execution path key — see `ContextImpl.currentPath()`. Unique per dispatch. */
18
+ path: z.string().min(1),
19
+ stepId: z.string(),
20
+ kind: z.string(),
21
+ /** Replayed verbatim in place of re-running the step. */
22
+ output: z.unknown(),
23
+ completedAt: z.string(),
24
+ });
25
+ /** Storage key prefix owning every ledger entry for one execution. */
26
+ export function stepLedgerPrefix(executionId) {
27
+ return `execution:${executionId}:ledger:`;
28
+ }
29
+ /** Zero-pad so lexicographic key order matches dispatch order under `list()`. */
30
+ function seqKey(executionId, seq) {
31
+ return `${stepLedgerPrefix(executionId)}${String(seq).padStart(8, '0')}`;
32
+ }
33
+ export function createStepLedgerStore(opts) {
34
+ const { storage } = opts;
35
+ return {
36
+ append: async (executionId, seq, entry) => {
37
+ await storage.set(seqKey(executionId, seq), entry);
38
+ },
39
+ load: async (executionId) => {
40
+ const keys = await storage.list(stepLedgerPrefix(executionId));
41
+ const byPath = new Map();
42
+ for (const key of keys) {
43
+ const raw = await storage.get(key);
44
+ if (raw === null || raw === undefined) {
45
+ continue;
46
+ }
47
+ const parsed = StepLedgerEntrySchema.safeParse(raw);
48
+ if (!parsed.success) {
49
+ /* A row we cannot read is a step we cannot replay — it simply re-runs.
50
+ * Dropping it is safe; failing the resume over it would not be. */
51
+ console.warn(`StepLedger: discarding unreadable entry "${key}": ${parsed.error.message}`);
52
+ continue;
53
+ }
54
+ byPath.set(parsed.data.path, parsed.data);
55
+ }
56
+ return byPath;
57
+ },
58
+ clear: async (executionId) => {
59
+ const keys = await storage.list(stepLedgerPrefix(executionId));
60
+ for (const key of keys) {
61
+ await storage.delete(key);
62
+ }
63
+ },
64
+ };
65
+ }
66
+ /**
67
+ * @public
68
+ * The in-memory ledger a single execution carries: entries recovered from a previous
69
+ * run (available for replay) plus the sequence counter for newly recorded ones.
70
+ *
71
+ * Shared by reference across fork/spawn children so one execution has one ledger —
72
+ * path keys are globally unique across the step tree, so a flat map is correct.
73
+ */
74
+ export class StepLedger {
75
+ /** Recovered entries, by path. Consumed as replay proceeds. */
76
+ replayable;
77
+ seq;
78
+ store;
79
+ executionId;
80
+ constructor(opts) {
81
+ this.executionId = opts.executionId;
82
+ this.store = opts.store;
83
+ this.replayable = opts.recovered ?? new Map();
84
+ this.seq = this.replayable.size;
85
+ }
86
+ /** True when this execution has nothing recovered to replay (the common, fresh case). */
87
+ get isEmpty() {
88
+ return this.replayable.size === 0;
89
+ }
90
+ /**
91
+ * The recorded output for `path`, when a previous run completed the same step there.
92
+ * A divergence (different step id or kind at this path) discards the entry and every
93
+ * entry recorded beneath it, then returns undefined so the step runs fresh — the
94
+ * subtree's recorded outputs belong to a branch that no longer exists.
95
+ */
96
+ take(path, step) {
97
+ const entry = this.replayable.get(path);
98
+ if (!entry) {
99
+ return undefined;
100
+ }
101
+ if (entry.stepId !== step.id || entry.kind !== step.kind) {
102
+ this.discardSubtree(path);
103
+ return undefined;
104
+ }
105
+ return entry;
106
+ }
107
+ /** Drop `path` and everything recorded below it. */
108
+ discardSubtree(path) {
109
+ for (const key of [
110
+ ...this.replayable.keys(),
111
+ ]) {
112
+ if (key === path || key.startsWith(`${path}/`)) {
113
+ this.replayable.delete(key);
114
+ }
115
+ }
116
+ }
117
+ /** Record a completed step. Best-effort: a failed write costs resumability, not the run. */
118
+ async record(entry) {
119
+ if (!this.store) {
120
+ return;
121
+ }
122
+ const seq = this.seq;
123
+ this.seq += 1;
124
+ try {
125
+ await this.store.append(this.executionId, seq, entry);
126
+ }
127
+ catch (error) {
128
+ console.warn(`StepLedger: failed to record "${entry.path}":`, error instanceof Error ? error.message : String(error));
129
+ }
130
+ }
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noetic-tools/core",
3
- "version": "2.3.0",
3
+ "version": "2.5.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",