@noetic-tools/core 5.8.0 → 5.9.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.
Files changed (41) hide show
  1. package/dist/adapters/openrouter.d.ts +1 -0
  2. package/dist/adapters/openrouter.js +1 -0
  3. package/dist/builders/provide-builder.d.ts +1 -0
  4. package/dist/builders/provide-builder.js +1 -0
  5. package/dist/builders/spawn-builder.d.ts +0 -1
  6. package/dist/builders/spawn-builder.js +0 -1
  7. package/dist/builders/step-builders.d.ts +0 -1
  8. package/dist/builders/step-builders.js +0 -1
  9. package/dist/builders/tool-builder.d.ts +2 -0
  10. package/dist/builders/tool-builder.js +2 -0
  11. package/dist/builders/workflow-hydrator.js +15 -13
  12. package/dist/builders/workflow-replay-key.d.ts +15 -0
  13. package/dist/builders/workflow-replay-key.js +46 -0
  14. package/dist/harness/agent-harness.d.ts +25 -6
  15. package/dist/harness/agent-harness.js +72 -26
  16. package/dist/harness/deps/interpreter.d.ts +1 -0
  17. package/dist/harness/deps/interpreter.js +1 -0
  18. package/dist/harness/model-call.d.ts +3 -0
  19. package/dist/harness/model-call.js +9 -5
  20. package/dist/interpreter/action-deps.d.ts +1 -1
  21. package/dist/interpreter/action-deps.js +1 -1
  22. package/dist/interpreter/collect-tools.js +19 -54
  23. package/dist/interpreter/execute-action.js +10 -2
  24. package/dist/interpreter/execute-control.js +3 -1
  25. package/dist/interpreter/execute-decide.js +9 -27
  26. package/dist/interpreter/execute.js +30 -36
  27. package/dist/interpreter/model-error.d.ts +9 -0
  28. package/dist/interpreter/model-error.js +32 -0
  29. package/dist/interpreter/step-walk.d.ts +11 -0
  30. package/dist/interpreter/step-walk.js +41 -0
  31. package/dist/observability/genai-attributes.d.ts +7 -0
  32. package/dist/observability/genai-attributes.js +8 -0
  33. package/dist/runtime/context-impl.d.ts +29 -31
  34. package/dist/runtime/context-impl.js +65 -45
  35. package/dist/runtime/durable/harness-checkpoints.d.ts +31 -18
  36. package/dist/runtime/durable/harness-checkpoints.js +70 -54
  37. package/dist/runtime/durable/step-ledger.d.ts +1 -1
  38. package/dist/runtime/durable/step-ledger.js +1 -1
  39. package/dist/schemas/workflow.d.ts +1 -1
  40. package/package.json +3 -3
  41. package/schema/noetic-workflow.schema.json +375 -0
@@ -99,30 +99,24 @@ export class ContextImpl {
99
99
  _children = new Set();
100
100
  _contextData;
101
101
  /**
102
- * Stack of steps currently in flight on this context, most-recent last.
103
- * Pushed by `enterStep` at the top of `execute()` and popped by
104
- * `leaveStep` when the step resolves (success or failure). The harness'
105
- * checkpoint writer serialises this stack as the execution frontier so
106
- * a restart can identify which step the context was paused inside.
102
+ * Steps in flight on this context, in dispatch order. `enterStep` adds a frame and
103
+ * `leaveStep` removes that same frame, so concurrent dispatches can settle in any
104
+ * order without disturbing each other. The checkpoint writer serialises this set as
105
+ * the execution frontier.
107
106
  * @internal
108
107
  */
109
- _frontier = [];
108
+ _inFlight = new Set();
110
109
  /**
111
- * Path segment for each in-flight frame, parallel to `_frontier`. Kept separate so
112
- * `serialiseFrontier()` keeps emitting the published `FrontierFrame` shape.
110
+ * Execution path of the step whose lane this is. On the context itself it is the
111
+ * prefix its steps hang under: empty on a root context, the parent's path plus a
112
+ * discriminator on an inParallel/spawn child, so a path stays unique across the whole
113
+ * step tree. A lane (see `enterStep`) reads its own step's path here instead.
113
114
  * @internal
114
115
  */
115
- _segments = [];
116
- /**
117
- * Path this context's steps hang under. Empty on a root context; an inParallel/spawn child
118
- * inherits its parent's current path plus a discriminator, so a path key stays unique
119
- * across the whole step tree rather than restarting per child context.
120
- * @internal
121
- */
122
- _pathPrefix;
116
+ _path;
123
117
  /**
124
118
  * Dispatch counts per `<parent path>/<step id>`, so a loop body re-entered on each
125
- * iteration gets `#0`, `#1`, … instead of colliding on one key.
119
+ * iteration gets `#0`, `#1`, … instead of colliding on one key. Shared by every lane.
126
120
  * @internal
127
121
  */
128
122
  _occurrences = new Map();
@@ -145,7 +139,7 @@ export class ContextImpl {
145
139
  this.resourceId = opts.resourceId;
146
140
  this.channelStore = opts.channelStore;
147
141
  this._checkpointFn = opts.checkpointFn;
148
- this._pathPrefix = opts.pathPrefix ?? '';
142
+ this._path = opts.pathPrefix ?? '';
149
143
  this.ledger = opts.ledger;
150
144
  this.layers = opts.layers;
151
145
  this.unifiedTools = opts.unifiedTools;
@@ -300,45 +294,35 @@ export class ContextImpl {
300
294
  }
301
295
  /**
302
296
  * @internal
303
- * Push a frame onto the execution frontier. Called by `execute()` at the
304
- * top of every step dispatch so that the frontier reflects exactly the
305
- * stack of steps currently in-flight on this context.
297
+ * Register `frame` as in flight and return the lane its step runs on: a view of
298
+ * this context that shares everything except its execution path. `execute()` hands
299
+ * the lane to the step, so steps the step dispatches hang under its path no matter
300
+ * how other dispatches on this context interleave. The occurrence ordinal is taken
301
+ * here, at dispatch, never at settle.
306
302
  */
307
303
  enterStep(frame) {
308
- const parent = this.currentPath();
309
- const key = `${parent}/${frame.stepId}`;
304
+ const key = `${this._path}/${frame.stepId}`;
310
305
  const occurrence = this._occurrences.get(key) ?? 0;
311
306
  this._occurrences.set(key, occurrence + 1);
312
- this._segments.push(`/${frame.stepId}#${occurrence}`);
313
- this._frontier.push(frame);
307
+ this._inFlight.add(frame);
308
+ return laneOf(this, `${key}#${occurrence}`);
314
309
  }
315
310
  /**
316
311
  * @internal
317
- * The execution path of the step currently on top of the frontier — the ledger key
318
- * identifying this exact dispatch. Stable across a replay given the same control
319
- * flow, which is what lets a resumed run line recorded outputs up with the steps
320
- * that produced them.
312
+ * The execution path of the step this lane belongs to — the ledger key identifying
313
+ * this exact dispatch. Stable across a replay given the same control flow, which is
314
+ * what lets a resumed run line recorded outputs up with the steps that produced them.
321
315
  */
322
316
  currentPath() {
323
- return this._pathPrefix + this._segments.join('');
317
+ return this._path;
324
318
  }
325
319
  /**
326
320
  * @internal
327
- * Pop the top frame. Called when a step resolves (success or failure)
328
- * so that the frontier unwinds cleanly. The value of `expectedStepId`
329
- * is used as a consistency check — if it does not match the top frame
330
- * the pop is still performed, but a best-effort warning is surfaced to
331
- * stderr rather than swallowed silently.
321
+ * Drop `frame` from the in-flight set. Called when its step resolves, success or
322
+ * failure.
332
323
  */
333
- leaveStep(expectedStepId) {
334
- const top = this._frontier[this._frontier.length - 1];
335
- if (top && top.stepId !== expectedStepId) {
336
- // A mismatch indicates bookkeeping drift. We unwind best-effort and
337
- // let the caller observe via `serialiseFrontier()` if needed.
338
- console.warn(`ContextImpl.leaveStep: expected "${expectedStepId}" on top of frontier but saw "${top.stepId}".`);
339
- }
340
- this._frontier.pop();
341
- this._segments.pop();
324
+ leaveStep(frame) {
325
+ this._inFlight.delete(frame);
342
326
  }
343
327
  /**
344
328
  * @internal
@@ -346,10 +330,46 @@ export class ContextImpl {
346
330
  * checkpoint writer — see `AgentHarness.checkpoint`.
347
331
  */
348
332
  serialiseFrontier() {
349
- return this._frontier.map((frame) => ({
333
+ return [
334
+ ...this._inFlight,
335
+ ].map((frame) => ({
350
336
  stepId: frame.stepId,
351
337
  input: frame.input,
352
338
  state: frame.state,
353
339
  }));
354
340
  }
355
341
  }
342
+ //#region Lanes
343
+ /** The context each lane views, so `laneOf` never stacks a proxy on a proxy. */
344
+ const laneTargets = new WeakMap();
345
+ /**
346
+ * A view of `ctx` that reads `path` as its execution path and forwards every other
347
+ * read and write to the context itself, so state, usage, abort, and the in-flight set
348
+ * stay shared. A Proxy rather than a subclass: steps assign `ctx.state` and friends
349
+ * directly, and a copy would keep those writes to itself.
350
+ */
351
+ function laneOf(ctx, path) {
352
+ const target = laneTargets.get(ctx) ?? ctx;
353
+ const lane = new Proxy(target, {
354
+ get(base, prop, receiver) {
355
+ if (prop === '_path') {
356
+ return path;
357
+ }
358
+ return Reflect.get(base, prop, receiver);
359
+ },
360
+ set(base, prop, value) {
361
+ return Reflect.set(base, prop, value);
362
+ },
363
+ });
364
+ laneTargets.set(lane, target);
365
+ return lane;
366
+ }
367
+ /**
368
+ * @internal
369
+ * The context a lane views, or `ctx` itself when it is not a lane. For per-execution
370
+ * caches keyed by context identity, which every lane of one context must share.
371
+ */
372
+ export function laneBase(ctx) {
373
+ return ctx instanceof ContextImpl ? (laneTargets.get(ctx) ?? ctx) : ctx;
374
+ }
375
+ //#endregion
@@ -49,29 +49,42 @@ export interface CheckpointHarnessHandle {
49
49
  */
50
50
  export declare function captureCheckpoint(h: CheckpointHarnessHandle, ctx: Context): Promise<void>;
51
51
  /**
52
- * Rebuild a `Context` from a previously-persisted snapshot. Returns
53
- * `null` if no snapshot is recorded for `executionId`. Layer state is
54
- * replayed into `layerStateStore` keyed by the original executionId so
55
- * the restored context observes the pre-crash state through
56
- * `readLayerState` and the context projectors. Channel values and queued
57
- * items are refilled into the channel store, for channels with no live
58
- * state.
52
+ * What `restoreFromCheckpoint` rebuilt. `fromSnapshot` is false for a ledger-only
53
+ * restore, whose layer state was never recovered — the harness must then let layer
54
+ * `init` hydrate normally instead of keeping restored state.
59
55
  *
60
- * `opts` carries the host's own context wiring (broadcaster, parent, state,
61
- * layer overrides) into the rebuilt context. A snapshot cannot round-trip live
62
- * objects, so a host that decorated the original context has to hand the same
63
- * decoration back here — otherwise the resumed run gets a bare context and
64
- * whatever depended on that wiring stops working silently. Snapshot-owned
65
- * fields always win: identity, item log, and cwd come from the persisted
66
- * record, never from `opts`.
56
+ * @internal
57
+ */
58
+ export interface RestoredExecution {
59
+ ctx: Context;
60
+ fromSnapshot: boolean;
61
+ }
62
+ /**
63
+ * Rebuild a `Context` for `executionId` from what a previous run recorded: the
64
+ * checkpoint snapshot and the step-completion ledger. Either is enough. Returns
65
+ * `null` only when neither exists, so the caller starts a fresh execution.
66
+ *
67
+ * - With a snapshot, the item log, layer state, channel state, cwd, and thread/resource
68
+ * ids come from it. Layer state is replayed into `layerStateStore` under the original
69
+ * id; channel values and queued items refill the channel store for channels with no
70
+ * live state.
71
+ * - Without one, the context starts like `createContext(opts)` would — empty item
72
+ * log, no layer or channel state, the harness's root cwd, fresh thread id — and only
73
+ * its identity and ledger are recovered. Recorded steps replay; the rest run fresh.
74
+ *
75
+ * `opts` carries the host's own context wiring (broadcaster, parent, state, layer
76
+ * overrides). A snapshot cannot round-trip live objects, so a host that decorated
77
+ * the original context has to hand the same decoration back here. Snapshot-owned
78
+ * fields always win: identity, item log, and cwd come from the persisted record,
79
+ * never from `opts`.
67
80
  *
68
- * Preserves the original executionId on the returned context via
69
- * `Object.defineProperty` — adapter correlation across crash/restart
70
- * requires a stable id.
81
+ * A snapshot whose schema version is unknown throws
82
+ * `NoeticConfigError(CHECKPOINT_SCHEMA_MISMATCH)` from the store; the ledger is not
83
+ * used as a fallback, because resuming onto it would drop state the snapshot held.
71
84
  *
72
85
  * @internal
73
86
  */
74
- export declare function restoreFromCheckpoint(h: CheckpointHarnessHandle, executionId: string, opts?: RestoreCheckpointOptions): Promise<Context | null>;
87
+ export declare function restoreFromCheckpoint(h: CheckpointHarnessHandle, executionId: string, opts?: RestoreCheckpointOptions): Promise<RestoredExecution | null>;
75
88
  /**
76
89
  * Discard every recovery record for one execution: the snapshot and the completion
77
90
  * ledger. Clearing the snapshot alone would strand the ledger's shards under
@@ -59,28 +59,28 @@ export async function captureCheckpoint(h, ctx) {
59
59
  console.warn(`AgentHarness.checkpoint: failed to persist snapshot for execution "${ctx.id}":`, err);
60
60
  }
61
61
  }
62
- //#endregion
63
- //#region restoreFromCheckpoint
64
62
  /**
65
- * Rebuild a `Context` from a previously-persisted snapshot. Returns
66
- * `null` if no snapshot is recorded for `executionId`. Layer state is
67
- * replayed into `layerStateStore` keyed by the original executionId so
68
- * the restored context observes the pre-crash state through
69
- * `readLayerState` and the context projectors. Channel values and queued
70
- * items are refilled into the channel store, for channels with no live
71
- * state.
63
+ * Rebuild a `Context` for `executionId` from what a previous run recorded: the
64
+ * checkpoint snapshot and the step-completion ledger. Either is enough. Returns
65
+ * `null` only when neither exists, so the caller starts a fresh execution.
66
+ *
67
+ * - With a snapshot, the item log, layer state, channel state, cwd, and thread/resource
68
+ * ids come from it. Layer state is replayed into `layerStateStore` under the original
69
+ * id; channel values and queued items refill the channel store for channels with no
70
+ * live state.
71
+ * - Without one, the context starts like `createContext(opts)` would — empty item
72
+ * log, no layer or channel state, the harness's root cwd, fresh thread id — and only
73
+ * its identity and ledger are recovered. Recorded steps replay; the rest run fresh.
72
74
  *
73
- * `opts` carries the host's own context wiring (broadcaster, parent, state,
74
- * layer overrides) into the rebuilt context. A snapshot cannot round-trip live
75
- * objects, so a host that decorated the original context has to hand the same
76
- * decoration back here — otherwise the resumed run gets a bare context and
77
- * whatever depended on that wiring stops working silently. Snapshot-owned
78
- * fields always win: identity, item log, and cwd come from the persisted
79
- * record, never from `opts`.
75
+ * `opts` carries the host's own context wiring (broadcaster, parent, state, layer
76
+ * overrides). A snapshot cannot round-trip live objects, so a host that decorated
77
+ * the original context has to hand the same decoration back here. Snapshot-owned
78
+ * fields always win: identity, item log, and cwd come from the persisted record,
79
+ * never from `opts`.
80
80
  *
81
- * Preserves the original executionId on the returned context via
82
- * `Object.defineProperty` — adapter correlation across crash/restart
83
- * requires a stable id.
81
+ * A snapshot whose schema version is unknown throws
82
+ * `NoeticConfigError(CHECKPOINT_SCHEMA_MISMATCH)` from the store; the ledger is not
83
+ * used as a fallback, because resuming onto it would drop state the snapshot held.
84
84
  *
85
85
  * @internal
86
86
  */
@@ -90,53 +90,69 @@ export async function restoreFromCheckpoint(h, executionId, opts) {
90
90
  return null;
91
91
  }
92
92
  const snapshot = await store.load(executionId);
93
- if (!snapshot) {
93
+ const recovered = await h.stepLedgerStore?.load(executionId);
94
+ if (!snapshot && (!recovered || recovered.entries.size === 0)) {
94
95
  return null;
95
96
  }
97
+ const ctx = snapshot ? contextFromSnapshot(h, snapshot, opts) : h.createContext(opts);
98
+ if (ctx instanceof ContextImpl) {
99
+ adoptExecution({
100
+ h,
101
+ ctx,
102
+ executionId,
103
+ recovered,
104
+ });
105
+ }
106
+ return {
107
+ ctx,
108
+ fromSnapshot: snapshot !== null,
109
+ };
110
+ }
111
+ /** Replay a snapshot's layer and channel state and build the context its item log and cwd describe. */
112
+ function contextFromSnapshot(h, snapshot, opts) {
96
113
  for (const [layerId, state] of Object.entries(snapshot.layers)) {
97
- h.layerStateStore.set(executionId, layerId, state);
114
+ h.layerStateStore.set(snapshot.executionId, layerId, state);
98
115
  }
99
- h.channelStore.restore(executionId, snapshot.channels ?? []);
100
- const items = h.itemSchemas.parseMany(snapshot.itemLog.items);
101
- const cwdInit = snapshot.cwd?.current ?? undefined;
116
+ h.channelStore.restore(snapshot.executionId, snapshot.channels ?? []);
102
117
  /* Caller wiring first, snapshot second: a host may legitimately swap the context
103
118
  * layers or hang the restored execution under a new parent, but it must never be
104
119
  * able to override the identity/history the snapshot is the record of. */
105
- const ctx = h.createContext({
120
+ return h.createContext({
106
121
  ...opts,
107
- items,
122
+ items: h.itemSchemas.parseMany(snapshot.itemLog.items),
108
123
  threadId: snapshot.threadId,
109
124
  resourceId: snapshot.resourceId,
110
- cwdInit,
125
+ cwdInit: snapshot.cwd?.current ?? undefined,
111
126
  });
112
- if (ctx instanceof ContextImpl) {
113
- Object.defineProperty(ctx, 'id', {
114
- value: executionId,
115
- configurable: false,
116
- writable: false,
117
- enumerable: true,
118
- });
119
- /* Attach the recovered completion ledger. `createContext` already built a fresh
120
- * one keyed to a throwaway id; the restored context needs the entries recorded
121
- * under THIS execution, or `execute()` would replay nothing and re-run the whole
122
- * tree. Assigned the same way `id` is, to keep `createContext`'s public options
123
- * free of resume-only fields. */
124
- if (h.stepLedgerStore) {
125
- const recovered = await h.stepLedgerStore.load(executionId);
126
- Object.defineProperty(ctx, 'ledger', {
127
- value: new StepLedger({
128
- executionId,
129
- store: h.stepLedgerStore,
130
- recovered,
131
- retention: h.stepLedgerRetention,
132
- }),
133
- configurable: false,
134
- writable: false,
135
- enumerable: true,
136
- });
137
- }
127
+ }
128
+ /**
129
+ * Give `ctx` the original execution id and the ledger recorded under it.
130
+ * `createContext` already built a fresh ledger keyed to a throwaway id; without the
131
+ * recovered one `execute()` would replay nothing and re-run the whole tree. Both are
132
+ * assigned with `defineProperty` to keep `createContext`'s public options free of
133
+ * resume-only fields — adapter correlation across a restart needs the stable id.
134
+ */
135
+ function adoptExecution({ h, ctx, executionId, recovered, }) {
136
+ Object.defineProperty(ctx, 'id', {
137
+ value: executionId,
138
+ configurable: false,
139
+ writable: false,
140
+ enumerable: true,
141
+ });
142
+ if (!h.stepLedgerStore || !recovered) {
143
+ return;
138
144
  }
139
- return ctx;
145
+ Object.defineProperty(ctx, 'ledger', {
146
+ value: new StepLedger({
147
+ executionId,
148
+ store: h.stepLedgerStore,
149
+ recovered,
150
+ retention: h.stepLedgerRetention,
151
+ }),
152
+ configurable: false,
153
+ writable: false,
154
+ enumerable: true,
155
+ });
140
156
  }
141
157
  //#endregion
142
158
  //#region clearCheckpoint
@@ -2,7 +2,7 @@
2
2
  * The step completion ledger: what a resumed execution replays instead of re-running.
3
3
  *
4
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
5
+ * about which ones finished, and a finished step leaves no trace once `leaveStep` drops
6
6
  * it. Resume therefore needs a separate record — and it must carry each step's OUTPUT,
7
7
  * not merely a "done" flag: steps consume the previous step's value, and a `callModel` step
8
8
  * re-run to catch up produces a different one than the rest of the run already saw.
@@ -2,7 +2,7 @@
2
2
  * The step completion ledger: what a resumed execution replays instead of re-running.
3
3
  *
4
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
5
+ * about which ones finished, and a finished step leaves no trace once `leaveStep` drops
6
6
  * it. Resume therefore needs a separate record — and it must carry each step's OUTPUT,
7
7
  * not merely a "done" flag: steps consume the previous step's value, and a `callModel` step
8
8
  * re-run to catch up produces a different one than the rest of the run already saw.
@@ -3,5 +3,5 @@
3
3
  * consumers can validate workflow documents without importing core.
4
4
  * This barrel keeps core's public and intra-core import paths unchanged.
5
5
  */
6
- export type { AcpAgentWorkflowNode, CallModelWorkflowNode, ConditionalRoute, ConditionalWorkflowNode, DecideWorkflowNode, InParallelWorkflowNode, InvokeToolWorkflowNode, LoopWorkflowNode, OutputCodecRef, RouteSwitchWorkflowNode, RunCodeWorkflowNode, ScheduleWorkflowNode, SequenceWorkflowNode, SpawnWorkflowNode, SubflowWorkflowNode, UntilPredicate, WithContextWorkflowNode, WorkflowDocument, WorkflowGraph, WorkflowNode, } from '@noetic-tools/types';
6
+ export type { AcpAgentWorkflowNode, CallModelWorkflowNode, ConditionalRoute, ConditionalWorkflowNode, DecideWorkflowNode, InParallelWorkflowNode, InvokeToolWorkflowNode, LoopWorkflowNode, OutputCodecRef, RouteSwitchWorkflowNode, RunCodeWorkflowNode, ScheduleWorkflowNode, SequenceWorkflowNode, SpawnWorkflowNode, SubflowWorkflowNode, UntilPredicate, WithContextWorkflowNode, WorkflowDocument, WorkflowGraph, WorkflowNode, WorkflowReplayKey, } from '@noetic-tools/types';
7
7
  export { MergeStrategy, MergeStrategySchema, UntilPredicateSchema, validateWorkflow, WorkflowDocumentSchema, WorkflowNodeSchema, walkWorkflow, workflowDepth, workflowGraph, } from '@noetic-tools/types';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noetic-tools/core",
3
- "version": "5.8.0",
3
+ "version": "5.9.0",
4
4
  "type": "module",
5
5
  "description": "Step primitives, interpreter, runtime, and context layer contract for Noetic agents.",
6
6
  "license": "Apache-2.0",
@@ -68,8 +68,8 @@
68
68
  }
69
69
  },
70
70
  "dependencies": {
71
- "@noetic-tools/context": "^1.5.0",
72
- "@noetic-tools/types": "^3.6.0",
71
+ "@noetic-tools/context": "^1.6.0",
72
+ "@noetic-tools/types": "^3.7.0",
73
73
  "@openrouter/agent": "^0.11.0",
74
74
  "@openrouter/sdk": "1.4.25",
75
75
  "zod": "^4.0.0"