@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
@@ -77,6 +77,7 @@ export declare function executeToolCall(params: ExecuteToolCallParams): Promise<
77
77
  * @param apiKey - OpenRouter API key.
78
78
  * @param embeddingModel - Model identifier (default: `'openai/text-embedding-3-small'`).
79
79
  * @returns An `EmbedFn` that produces embedding vectors for the given texts.
80
+ * @throws `NoeticConfigError` with code `MISSING_API_KEY` if `apiKey` is empty or omitted.
80
81
  */
81
82
  export declare function createOpenRouterEmbed(apiKey?: string, embeddingModel?: string): EmbedFn;
82
83
  export {};
@@ -407,6 +407,7 @@ export async function executeToolCall(params) {
407
407
  * @param apiKey - OpenRouter API key.
408
408
  * @param embeddingModel - Model identifier (default: `'openai/text-embedding-3-small'`).
409
409
  * @returns An `EmbedFn` that produces embedding vectors for the given texts.
410
+ * @throws `NoeticConfigError` with code `MISSING_API_KEY` if `apiKey` is empty or omitted.
410
411
  */
411
412
  export function createOpenRouterEmbed(apiKey, embeddingModel) {
412
413
  if (!apiKey) {
@@ -12,6 +12,7 @@ import type { Step, StepReplay, StepWithContext } from '@noetic-tools/types';
12
12
  * @returns A `StepWithContext` step.
13
13
  * @throws `NoeticConfigError` with code `EMPTY_STEP_ID` if `id` is empty.
14
14
  * @throws `NoeticConfigError` with code `MISSING_CHILD_STEP` if `child` is not provided.
15
+ * @throws `NoeticConfigError` with code `MISSING_CONTEXT_LAYERS` if `context` is not provided.
15
16
  */
16
17
  export declare function withContext<TContext = ContextData, I = unknown, O = unknown>(opts: {
17
18
  id: string;
@@ -12,6 +12,7 @@ import { getDefaultRegistrar } from '../types/step-registrar.js';
12
12
  * @returns A `StepWithContext` step.
13
13
  * @throws `NoeticConfigError` with code `EMPTY_STEP_ID` if `id` is empty.
14
14
  * @throws `NoeticConfigError` with code `MISSING_CHILD_STEP` if `child` is not provided.
15
+ * @throws `NoeticConfigError` with code `MISSING_CONTEXT_LAYERS` if `context` is not provided.
15
16
  */
16
17
  export function withContext(opts) {
17
18
  if (!opts.id?.trim()) {
@@ -26,7 +26,6 @@ interface SpawnOpts<TContext, I, O> extends StepReplay<TContext, I> {
26
26
  * can dispatch either by id.
27
27
  * @throws `NoeticConfigError` with code `EMPTY_STEP_ID` if `id` is empty.
28
28
  * @throws `NoeticConfigError` with code `MISSING_CHILD_STEP` if `child` is not provided.
29
- * @throws `NoeticConfigError` with code `DUPLICATE_STEP_ID` if another step with the same id is already registered.
30
29
  */
31
30
  export declare function spawn<TContext = ContextData, I = unknown, O = unknown>(opts: SpawnOpts<TContext, I, O>): StepSpawn<TContext, I, O>;
32
31
  export {};
@@ -16,7 +16,6 @@ import { getDefaultRegistrar } from '../types/step-registrar.js';
16
16
  * can dispatch either by id.
17
17
  * @throws `NoeticConfigError` with code `EMPTY_STEP_ID` if `id` is empty.
18
18
  * @throws `NoeticConfigError` with code `MISSING_CHILD_STEP` if `child` is not provided.
19
- * @throws `NoeticConfigError` with code `DUPLICATE_STEP_ID` if another step with the same id is already registered.
20
19
  */
21
20
  export function spawn(opts) {
22
21
  if (!opts.id?.trim()) {
@@ -83,7 +83,6 @@ export interface StepAcpAgentOpts<TContext, O, I = unknown> extends StepReplay<T
83
83
  * adapter can dispatch it by id.
84
84
  * @throws `NoeticConfigError` with code `EMPTY_STEP_ID` if `id` is empty.
85
85
  * @throws `NoeticConfigError` with code `MISSING_EXECUTE_FUNCTION` if `execute` is not provided.
86
- * @throws `NoeticConfigError` with code `DUPLICATE_STEP_ID` if another step with the same id is already registered with a different body.
87
86
  */
88
87
  export declare function runCode<TContext = ContextData, I = unknown, O = unknown>(opts: RunCodeOpts<TContext, I, O>): StepRunCode<TContext, I, O>;
89
88
  /**
@@ -46,7 +46,6 @@ function validateAcpAgentOpts(opts) {
46
46
  * adapter can dispatch it by id.
47
47
  * @throws `NoeticConfigError` with code `EMPTY_STEP_ID` if `id` is empty.
48
48
  * @throws `NoeticConfigError` with code `MISSING_EXECUTE_FUNCTION` if `execute` is not provided.
49
- * @throws `NoeticConfigError` with code `DUPLICATE_STEP_ID` if another step with the same id is already registered with a different body.
50
49
  */
51
50
  export function runCode(opts) {
52
51
  if (!opts.id || opts.id.trim() === '') {
@@ -51,6 +51,8 @@ type GeneratorToolConfig<I extends StandardSchemaV1, E extends StandardSchemaV1,
51
51
  * Accepts Zod, Valibot, or any Standard Schema v1 implementation.
52
52
  *
53
53
  * @public
54
+ * @throws `NoeticConfigError` with code `EMPTY_TOOL_NAME` if `name` is empty.
55
+ * @throws `NoeticConfigError` with code `MISSING_EXECUTE_FUNCTION` if `execute` is not provided.
54
56
  */
55
57
  export declare function tool<I extends StandardSchemaV1, O extends StandardSchemaV1>(config: ToolConfig<I, O>): Tool<I, O>;
56
58
  /**
@@ -24,6 +24,8 @@ function validateToolConfig(name, execute) {
24
24
  * Accepts Zod, Valibot, or any Standard Schema v1 implementation.
25
25
  *
26
26
  * @public
27
+ * @throws `NoeticConfigError` with code `EMPTY_TOOL_NAME` if `name` is empty.
28
+ * @throws `NoeticConfigError` with code `MISSING_EXECUTE_FUNCTION` if `execute` is not provided.
27
29
  */
28
30
  export function tool(config) {
29
31
  validateToolConfig(config.name, config.execute);
@@ -16,6 +16,7 @@ import { loop } from './loop-builder.js';
16
16
  import { withContext } from './provide-builder.js';
17
17
  import { spawn } from './spawn-builder.js';
18
18
  import { callModel, runCode, step } from './step-builders.js';
19
+ import { workflowReplayKey } from './workflow-replay-key.js';
19
20
  //#endregion
20
21
  //#region Until Predicate Hydration
21
22
  function hydrateUntilPredicate(pred) {
@@ -99,13 +100,15 @@ function hydrateCallModelNode(node, ctx) {
99
100
  * runtime still returns the codec's real value. This is the same erasure the
100
101
  * rest of the hydrator applies via `frameworkCast`.
101
102
  */
102
- function availableLibraries(ctx) {
103
- if (!ctx.uiLibraries || ctx.uiLibraries.size === 0) {
104
- return '(none)';
103
+ /** A complete sentence naming what is registered, for a hint. */
104
+ function registeredSentence(noun, names) {
105
+ const list = [
106
+ ...names,
107
+ ];
108
+ if (list.length === 0) {
109
+ return `No ${noun} are registered.`;
105
110
  }
106
- return [
107
- ...ctx.uiLibraries.keys(),
108
- ].join(', ');
111
+ return `Registered ${noun}: ${list.join(', ')}.`;
109
112
  }
110
113
  function resolveOutputCodec(ref, nodeId, ctx) {
111
114
  if (!ref) {
@@ -118,7 +121,7 @@ function resolveOutputCodec(ref, nodeId, ctx) {
118
121
  throw new NoeticConfigError({
119
122
  code: 'UNKNOWN_UI_LIBRARY_REFERENCE',
120
123
  message: `UI library '${ref.library}' referenced in workflow node '${nodeId}' is not registered.`,
121
- hint: `Pass output codecs via HydrationContext.uiLibraries, e.g. new Map([['${ref.library}', openUi(myLibrary)]]) from @noetic-tools/openui. Available: ${availableLibraries(ctx)}`,
124
+ hint: `Pass output codecs via HydrationContext.uiLibraries, e.g. new Map([['${ref.library}', openUi(myLibrary)]]) from @noetic-tools/openui. ${registeredSentence('UI libraries', ctx.uiLibraries?.keys() ?? [])}`,
122
125
  });
123
126
  }
124
127
  function hydrateInvokeToolNode(node, ctx) {
@@ -130,9 +133,7 @@ function hydrateInvokeToolNode(node, ctx) {
130
133
  throw new NoeticConfigError({
131
134
  code: 'UNKNOWN_TOOL_REFERENCE',
132
135
  message: `Tool '${node.toolName}' referenced in workflow node '${node.id}' is not registered.`,
133
- hint: `Available tools: ${[
134
- ...ctx.tools.keys(),
135
- ].join(', ') || '(none)'}`,
136
+ hint: `Register '${node.toolName}' in HydrationContext.tools before hydrating. ${registeredSentence('tools', ctx.tools.keys())}`,
136
137
  });
137
138
  }
138
139
  return frameworkCast(runCode({
@@ -533,9 +534,7 @@ function resolveTools(toolNames, registry) {
533
534
  throw new NoeticConfigError({
534
535
  code: 'UNKNOWN_TOOL_REFERENCE',
535
536
  message: `Tool '${name}' referenced in workflow is not registered.`,
536
- hint: `Available tools: ${[
537
- ...registry.keys(),
538
- ].join(', ') || '(none)'}`,
537
+ hint: `Register '${name}' in HydrationContext.tools before hydrating. ${registeredSentence('tools', registry.keys())}`,
539
538
  });
540
539
  }
541
540
  return resolved;
@@ -772,6 +771,9 @@ export function hydrateNode(node, ctx) {
772
771
  if (node.durable !== undefined) {
773
772
  hydrated.durable = node.durable;
774
773
  }
774
+ if (node.replayKey !== undefined) {
775
+ hydrated.replayKey = workflowReplayKey(node.id, node.replayKey);
776
+ }
775
777
  return hydrated;
776
778
  }
777
779
  /**
@@ -0,0 +1,15 @@
1
+ import type { WorkflowReplayKey } from '@noetic-tools/types';
2
+ /**
3
+ * Build the `replayKey` function a JSON node's declarative `replayKey` describes
4
+ * (spec 26 § Replay keys). The key is `<node id>:<hash of the selected input>`: the
5
+ * node id keeps two different nodes that read the same input from fighting over one
6
+ * ledger entry, while nodes that share an id — the same worker leaf written into
7
+ * several paths — share entries by design.
8
+ *
9
+ * The interpreter turns a throwing `replayKey` into a warning and runs the step
10
+ * without replay, so the step itself never fails.
11
+ *
12
+ * @throws `NoeticConfigError` with code `INVALID_REPLAY_KEY_INPUT` from the returned
13
+ * function, when `from` meets an input that is not a JSON object.
14
+ */
15
+ export declare function workflowReplayKey(nodeId: string, spec: WorkflowReplayKey): (input: string) => string;
@@ -0,0 +1,46 @@
1
+ import { hashString, NoeticConfigError } from '@noetic-tools/types';
2
+ /**
3
+ * Build the `replayKey` function a JSON node's declarative `replayKey` describes
4
+ * (spec 26 § Replay keys). The key is `<node id>:<hash of the selected input>`: the
5
+ * node id keeps two different nodes that read the same input from fighting over one
6
+ * ledger entry, while nodes that share an id — the same worker leaf written into
7
+ * several paths — share entries by design.
8
+ *
9
+ * The interpreter turns a throwing `replayKey` into a warning and runs the step
10
+ * without replay, so the step itself never fails.
11
+ *
12
+ * @throws `NoeticConfigError` with code `INVALID_REPLAY_KEY_INPUT` from the returned
13
+ * function, when `from` meets an input that is not a JSON object.
14
+ */
15
+ export function workflowReplayKey(nodeId, spec) {
16
+ if (spec === true) {
17
+ return (input) => `${nodeId}:${hashString(asText(input))}`;
18
+ }
19
+ return (input) => {
20
+ const fields = inputObject(input, nodeId);
21
+ return `${nodeId}:${hashString(JSON.stringify(spec.from.map((key) => fields[key] ?? null)))}`;
22
+ };
23
+ }
24
+ /** JSON nodes pass strings, but a `decide` answer can arrive as an object. */
25
+ function asText(input) {
26
+ return typeof input === 'string' ? input : JSON.stringify(input);
27
+ }
28
+ function inputObject(input, nodeId) {
29
+ const parsed = typeof input === 'string' ? parseJson(input) : input;
30
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
31
+ throw new NoeticConfigError({
32
+ code: 'INVALID_REPLAY_KEY_INPUT',
33
+ message: `Node '${nodeId}' keys replay on input properties, but its input is not a JSON object.`,
34
+ hint: 'Use `"replayKey": true` to key on the whole input, or feed the node a JSON object.',
35
+ });
36
+ }
37
+ return Object.fromEntries(Object.entries(parsed));
38
+ }
39
+ function parseJson(text) {
40
+ try {
41
+ return JSON.parse(text);
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ }
@@ -223,6 +223,12 @@ export declare class AgentHarness<TParams extends Record<string, unknown> = Reco
223
223
  private readonly initializedExecutions;
224
224
  /** Executions restored from a checkpoint: their first layer init keeps the restored state. */
225
225
  private readonly restoredExecutions;
226
+ /**
227
+ * Layer hooks still running per context. `cancel()` waits for them before
228
+ * `onComplete`/`dispose`, so teardown never races a half-finished `store`
229
+ * or `recall`. Each hook is bounded by its own timeout.
230
+ */
231
+ private readonly runningHooks;
226
232
  readonly traceExporter: TraceExporter;
227
233
  /**
228
234
  * Long-lived shared cwd state. The same reference is seeded into every
@@ -261,6 +267,13 @@ export declare class AgentHarness<TParams extends Record<string, unknown> = Reco
261
267
  decide(request: DecideRequest): Promise<DecideResponse>;
262
268
  notifyDecision(layers: ContextLayer[], record: DecisionRecord, ctx: Context): Promise<void>;
263
269
  run<I, O>(s: Step<ContextData, I, O>, input: I, ctx: Context): Promise<O>;
270
+ /**
271
+ * Whether `step` calls a model this harness has no credentials for. Checked
272
+ * before any step runs so a missing provider fails fast instead of after
273
+ * earlier steps' side effects. Steps the walk cannot see (dynamic branches)
274
+ * still hit the same error lazily at their model call.
275
+ */
276
+ private lacksModelFor;
264
277
  /** Run all layer `init` hooks before the first step executes. Per spec 11,
265
278
  * init MUST complete before any recall fires so layer state is populated. */
266
279
  private initAndRun;
@@ -353,8 +366,11 @@ export declare class AgentHarness<TParams extends Record<string, unknown> = Reco
353
366
  */
354
367
  checkpoint(ctx: Context): Promise<void>;
355
368
  /**
356
- * Rebuild a `Context` from a previously-persisted snapshot. Returns `null`
357
- * if no snapshot is recorded for `executionId`. Surface a typed
369
+ * Rebuild a `Context` from what a previous run of `executionId` recorded: its
370
+ * checkpoint snapshot, its step-completion ledger, or both. Without a snapshot the
371
+ * context starts empty (no item log or layer state) but keeps the execution id and
372
+ * ledger, so recorded steps replay and the rest run fresh. Returns `null` when
373
+ * neither is recorded — start a fresh execution. Surface a typed
358
374
  * `NoeticConfigError(CHECKPOINT_SCHEMA_MISMATCH)` when the snapshot's
359
375
  * schema version is unrecognised — the caller is expected to discard the
360
376
  * checkpoint via `CheckpointStore.clear()` and start a fresh execution.
@@ -401,15 +417,16 @@ export declare class AgentHarness<TParams extends Record<string, unknown> = Reco
401
417
  * `cancelled`.
402
418
  *
403
419
  * Cancellation is cooperative: a layer hook already in flight is allowed to
404
- * settle, and step code that ignores `ctx.aborted` between `await`s runs to
420
+ * settle (up to its timeout) before that context's teardown, and step code that ignores `ctx.aborted` between `await`s runs to
405
421
  * its next boundary. Calling `cancel()` on an already-cancelled context is a
406
422
  * no-op.
407
423
  */
408
424
  cancel(ctx: Context, reason?: string): Promise<void>;
409
425
  /**
410
- * Run the layer lifecycle's abort path for one cancelled context:
411
- * `onComplete` with `outcome: 'aborted'`, then `dispose`. Both always run
412
- * under cancellation (spec 09, Cancellation item 5).
426
+ * Run the layer lifecycle's abort path for one cancelled context: wait for
427
+ * its in-flight layer hooks to settle, then `onComplete` with
428
+ * `outcome: 'aborted'`, then `dispose`. Both always run under cancellation
429
+ * (spec 09, Cancellation item 6).
413
430
  */
414
431
  private teardownCancelledContext;
415
432
  /**
@@ -425,6 +442,8 @@ export declare class AgentHarness<TParams extends Record<string, unknown> = Reco
425
442
  setLayerState<T>(executionId: string, layerId: string, state: T): void;
426
443
  beforeToolCall(layers: ContextLayer[], toolName: string, toolArgs: unknown, ctx: Context, callId?: string): Promise<SteeringDecision>;
427
444
  afterModelCall(layers: ContextLayer[], response: LLMResponse, ctx: Context): Promise<SteeringDecision>;
445
+ /** Record `hook` as running on `ctx` until it settles (see `runningHooks`). */
446
+ private trackHook;
428
447
  projectHistory(layers: ContextLayer[], items: ReadonlyArray<Item>, ctx: Context): Promise<ReadonlyArray<Item>>;
429
448
  runAppendPipeline(layers: ContextLayer[], items: Item[], ctx: Context): Promise<{
430
449
  items: Item[];
@@ -6,10 +6,10 @@ import { AcpSessionStore } from '../runtime/acp-session-store.js';
6
6
  import { callDecide } from './decide-call.js';
7
7
  import { createInMemoryFsAdapter, createInMemoryShellAdapter, createInMemorySubprocessAdapter, OpenRouter, OpenRouterSdk, } from './deps/adapters.js';
8
8
  import { afterModelCallLayers, allocateBudgets, assembleView, beforeToolCallLayers, completeLayers, contextToExecCtx, createContextCacheStore, createLayerStateStore, createRecallCache, DEFAULT_PROJECTION, disposeLayers, executeRerender, initLayers, notifyDecision, projectHistoryLayers, recallLayers, recallLayersAtomic, recallLayersEventual, resolveLayerTools, runAppendPipeline, storeLayers, } from './deps/context.js';
9
- import { collectAllTools, deduplicateTools, dispatchStepThroughAdapter, execute, prepareBandedView, } from './deps/interpreter.js';
9
+ import { collectAllTools, deduplicateTools, dispatchStepThroughAdapter, execute, prepareBandedView, someStep, } from './deps/interpreter.js';
10
10
  import { buildItemStream, ChannelStore, ContextImpl, captureCheckpoint, clearCheckpoint, collectContextTree, createInMemoryStorage, createStepLedgerStore, filterReasoningStream, filterTextStream, resolveStepLedgerRetention, restoreFromCheckpoint, rootIdOf, SessionRunner, StepLedger, snapshotCwdState, warnToConsole, } from './deps/runtime.js';
11
11
  import { SteeringAction } from './deps/types.js';
12
- import { AgentHarnessModelCaller } from './model-call.js';
12
+ import { AgentHarnessModelCaller, noLlmProviderError } from './model-call.js';
13
13
  export { createStreamIdleWatchdog } from './model-call.js';
14
14
  import { buildItemSchemaRegistry } from './model-schema.js';
15
15
  //#endregion
@@ -275,6 +275,12 @@ export class AgentHarness {
275
275
  initializedExecutions = new Set();
276
276
  /** Executions restored from a checkpoint: their first layer init keeps the restored state. */
277
277
  restoredExecutions = new Set();
278
+ /**
279
+ * Layer hooks still running per context. `cancel()` waits for them before
280
+ * `onComplete`/`dispose`, so teardown never races a half-finished `store`
281
+ * or `recall`. Each hook is bounded by its own timeout.
282
+ */
283
+ runningHooks = new WeakMap();
278
284
  traceExporter;
279
285
  /**
280
286
  * Long-lived shared cwd state. The same reference is seeded into every
@@ -363,6 +369,9 @@ export class AgentHarness {
363
369
  hint: 'Pass `agentGraph` in constructor options, or use run() directly.',
364
370
  }));
365
371
  }
372
+ if (this.lacksModelFor(this.agentGraph)) {
373
+ return Promise.reject(noLlmProviderError());
374
+ }
366
375
  const threadId = options?.threadId ?? DEFAULT_THREAD_ID;
367
376
  const deliveryMode = options?.deliveryMode ?? this.defaultDeliveryMode;
368
377
  const session = this.getOrCreateSession(threadId);
@@ -552,16 +561,19 @@ export class AgentHarness {
552
561
  });
553
562
  }
554
563
  async notifyDecision(layers, record, ctx) {
555
- await notifyDecision({
564
+ await this.trackHook(ctx, notifyDecision({
556
565
  layers,
557
566
  record,
558
567
  ctx: this.toExecCtx(ctx),
559
568
  store: this.layerStateStore,
560
569
  recallCache: this.recallCache,
561
- });
570
+ }));
562
571
  }
563
572
  //#endregion
564
573
  async run(s, input, ctx) {
574
+ if (this.lacksModelFor(s)) {
575
+ throw noLlmProviderError();
576
+ }
565
577
  // Populate the unified tool pool when an embedder drives a step directly on
566
578
  // a bare `createContext()` context (the turn pipeline does this per turn).
567
579
  // Without it, steps that resolve tools dynamically from `ctx.unifiedTools`
@@ -578,6 +590,18 @@ export class AgentHarness {
578
590
  await this.ensureLayersInit(ctx);
579
591
  return this.executeClosingChannels(s, input, ctx);
580
592
  }
593
+ /**
594
+ * Whether `step` calls a model this harness has no credentials for. Checked
595
+ * before any step runs so a missing provider fails fast instead of after
596
+ * earlier steps' side effects. Steps the walk cannot see (dynamic branches)
597
+ * still hit the same error lazily at their model call.
598
+ */
599
+ lacksModelFor(step) {
600
+ if (this.client || this.callModelOverride) {
601
+ return false;
602
+ }
603
+ return someStep(frameworkCast(step), (s) => s.kind === 'callModel' || (s.kind === 'decide' && !this.decideOverride));
604
+ }
581
605
  /** Run all layer `init` hooks before the first step executes. Per spec 11,
582
606
  * init MUST complete before any recall fires so layer state is populated. */
583
607
  async initAndRun(s, input, ctx) {
@@ -746,7 +770,7 @@ export class AgentHarness {
746
770
  });
747
771
  }
748
772
  async recallLayers(layers, input, ctx) {
749
- return recallLayers({
773
+ return this.trackHook(ctx, recallLayers({
750
774
  layers,
751
775
  query: input,
752
776
  ctx: this.toExecCtx(ctx),
@@ -754,7 +778,7 @@ export class AgentHarness {
754
778
  budgets: this.layerBudgets(layers),
755
779
  store: this.layerStateStore,
756
780
  itemSchemas: this.itemSchemas,
757
- });
781
+ }));
758
782
  }
759
783
  /** Allocate per-layer recall budgets from the harness projection policy. */
760
784
  layerBudgets(layers) {
@@ -771,7 +795,7 @@ export class AgentHarness {
771
795
  ]));
772
796
  }
773
797
  async recallLayersAtomic(layers, input, ctx, budgets, opts) {
774
- return recallLayersAtomic({
798
+ return this.trackHook(ctx, recallLayersAtomic({
775
799
  layers,
776
800
  query: input,
777
801
  ctx: this.toExecCtx(ctx),
@@ -781,10 +805,10 @@ export class AgentHarness {
781
805
  itemSchemas: this.itemSchemas,
782
806
  forceAtomic: this.config.forceAtomicRecall,
783
807
  peek: opts?.peek,
784
- });
808
+ }));
785
809
  }
786
810
  async recallLayersEventual(layers, input, ctx, budgets, opts) {
787
- return recallLayersEventual({
811
+ return this.trackHook(ctx, recallLayersEventual({
788
812
  layers,
789
813
  query: input,
790
814
  ctx: this.toExecCtx(ctx),
@@ -795,7 +819,7 @@ export class AgentHarness {
795
819
  forceAtomic: this.config.forceAtomicRecall,
796
820
  cache: this.recallCache,
797
821
  peek: opts?.peek,
798
- });
822
+ }));
799
823
  }
800
824
  /**
801
825
  * Compute the items array that would be sent to the model on the next turn —
@@ -870,7 +894,7 @@ export class AgentHarness {
870
894
  if (!storage) {
871
895
  return;
872
896
  }
873
- await storeLayers({
897
+ await this.trackHook(ctx, storeLayers({
874
898
  layers,
875
899
  response,
876
900
  ctx: this.toExecCtx(ctx),
@@ -878,7 +902,7 @@ export class AgentHarness {
878
902
  store: this.layerStateStore,
879
903
  storage,
880
904
  recallCache: this.recallCache,
881
- });
905
+ }));
882
906
  }
883
907
  async disposeLayers(layers, ctx) {
884
908
  // Drop the init guard so a deliberate dispose→run cycle re-hydrates from
@@ -914,8 +938,11 @@ export class AgentHarness {
914
938
  return captureCheckpoint(this, ctx);
915
939
  }
916
940
  /**
917
- * Rebuild a `Context` from a previously-persisted snapshot. Returns `null`
918
- * if no snapshot is recorded for `executionId`. Surface a typed
941
+ * Rebuild a `Context` from what a previous run of `executionId` recorded: its
942
+ * checkpoint snapshot, its step-completion ledger, or both. Without a snapshot the
943
+ * context starts empty (no item log or layer state) but keeps the execution id and
944
+ * ledger, so recorded steps replay and the rest run fresh. Returns `null` when
945
+ * neither is recorded — start a fresh execution. Surface a typed
919
946
  * `NoeticConfigError(CHECKPOINT_SCHEMA_MISMATCH)` when the snapshot's
920
947
  * schema version is unrecognised — the caller is expected to discard the
921
948
  * checkpoint via `CheckpointStore.clear()` and start a fresh execution.
@@ -933,11 +960,14 @@ export class AgentHarness {
933
960
  * whose `id` is already the original `executionId`.
934
961
  */
935
962
  async restore(executionId, opts) {
936
- const ctx = await restoreFromCheckpoint(this, executionId, opts);
937
- if (ctx) {
938
- this.restoredExecutions.add(ctx.id);
963
+ const restored = await restoreFromCheckpoint(this, executionId, opts);
964
+ if (!restored) {
965
+ return null;
966
+ }
967
+ if (restored.fromSnapshot) {
968
+ this.restoredExecutions.add(executionId);
939
969
  }
940
- return ctx;
970
+ return restored.ctx;
941
971
  }
942
972
  /**
943
973
  * Discard everything `restore(executionId)` would have used — the snapshot *and* the
@@ -971,7 +1001,7 @@ export class AgentHarness {
971
1001
  * `cancelled`.
972
1002
  *
973
1003
  * Cancellation is cooperative: a layer hook already in flight is allowed to
974
- * settle, and step code that ignores `ctx.aborted` between `await`s runs to
1004
+ * settle (up to its timeout) before that context's teardown, and step code that ignores `ctx.aborted` between `await`s runs to
975
1005
  * its next boundary. Calling `cancel()` on an already-cancelled context is a
976
1006
  * no-op.
977
1007
  */
@@ -993,15 +1023,17 @@ export class AgentHarness {
993
1023
  }
994
1024
  }
995
1025
  /**
996
- * Run the layer lifecycle's abort path for one cancelled context:
997
- * `onComplete` with `outcome: 'aborted'`, then `dispose`. Both always run
998
- * under cancellation (spec 09, Cancellation item 5).
1026
+ * Run the layer lifecycle's abort path for one cancelled context: wait for
1027
+ * its in-flight layer hooks to settle, then `onComplete` with
1028
+ * `outcome: 'aborted'`, then `dispose`. Both always run under cancellation
1029
+ * (spec 09, Cancellation item 6).
999
1030
  */
1000
1031
  async teardownCancelledContext(ctx) {
1001
1032
  const layers = ctx.layers ?? [];
1002
1033
  if (layers.length === 0) {
1003
1034
  return;
1004
1035
  }
1036
+ await Promise.allSettled(this.runningHooks.get(ctx) ?? []);
1005
1037
  await completeLayers({
1006
1038
  layers: [
1007
1039
  ...layers,
@@ -1054,14 +1086,14 @@ export class AgentHarness {
1054
1086
  action: SteeringAction.Allow,
1055
1087
  };
1056
1088
  }
1057
- return beforeToolCallLayers({
1089
+ return this.trackHook(ctx, beforeToolCallLayers({
1058
1090
  layers,
1059
1091
  toolName,
1060
1092
  toolArgs,
1061
1093
  callId,
1062
1094
  ctx: this.toExecCtx(ctx),
1063
1095
  store: this.layerStateStore,
1064
- });
1096
+ }));
1065
1097
  }
1066
1098
  async afterModelCall(layers, response, ctx) {
1067
1099
  const hasHook = layers.some((l) => l.hooks.afterModelCall);
@@ -1070,12 +1102,26 @@ export class AgentHarness {
1070
1102
  action: SteeringAction.Allow,
1071
1103
  };
1072
1104
  }
1073
- return afterModelCallLayers({
1105
+ return this.trackHook(ctx, afterModelCallLayers({
1074
1106
  layers,
1075
1107
  response,
1076
1108
  ctx: this.toExecCtx(ctx),
1077
1109
  store: this.layerStateStore,
1078
- });
1110
+ }));
1111
+ }
1112
+ /** Record `hook` as running on `ctx` until it settles (see `runningHooks`). */
1113
+ trackHook(ctx, hook) {
1114
+ let running = this.runningHooks.get(ctx);
1115
+ if (!running) {
1116
+ running = new Set();
1117
+ this.runningHooks.set(ctx, running);
1118
+ }
1119
+ running.add(hook);
1120
+ const forget = () => {
1121
+ running.delete(hook);
1122
+ };
1123
+ hook.then(forget, forget);
1124
+ return hook;
1079
1125
  }
1080
1126
  async projectHistory(layers, items, ctx) {
1081
1127
  const hasHook = layers.some((l) => l.hooks.projectHistory);
@@ -3,4 +3,5 @@ export { prepareBandedView } from '../../interpreter/context-assembly.js';
3
3
  export { execute } from '../../interpreter/execute.js';
4
4
  export type { DetachedSpawnOverrides } from '../../interpreter/step-dispatcher.js';
5
5
  export { dispatchStepThroughAdapter } from '../../interpreter/step-dispatcher.js';
6
+ export { someStep } from '../../interpreter/step-walk.js';
6
7
  export { isFunctionCall } from '../../interpreter/typeguards.js';
@@ -2,4 +2,5 @@ export { collectAllTools, deduplicateTools } from '../../interpreter/collect-too
2
2
  export { prepareBandedView } from '../../interpreter/context-assembly.js';
3
3
  export { execute } from '../../interpreter/execute.js';
4
4
  export { dispatchStepThroughAdapter } from '../../interpreter/step-dispatcher.js';
5
+ export { someStep } from '../../interpreter/step-walk.js';
5
6
  export { isFunctionCall } from '../../interpreter/typeguards.js';
@@ -1,4 +1,5 @@
1
1
  import type { AgentHarnessContract, CallModelRequest, ItemSchemaRegistry, LLMResponse, TraceExporter } from '@noetic-tools/types';
2
+ import { NoeticConfigError } from '@noetic-tools/types';
2
3
  import type { OpenRouter } from '@openrouter/agent';
3
4
  import type { MessageQueue } from '../runtime/message-queue.js';
4
5
  interface StreamIdleWatchdog {
@@ -7,6 +8,8 @@ interface StreamIdleWatchdog {
7
8
  /** Clear the pending timer. Safe to call multiple times. */
8
9
  stop: () => void;
9
10
  }
11
+ /** @internal The error for a model call on a harness with no LLM credentials. */
12
+ export declare function noLlmProviderError(): NoeticConfigError;
10
13
  /** @internal Create a watchdog that aborts `controller` when no
11
14
  * {@link StreamIdleWatchdog.reset reset} is called within `timeoutMs`. When
12
15
  * `timeoutMs <= 0`, returns an inert no-op so callers can always call `.reset()`
@@ -133,6 +133,14 @@ async function pipeStreamEventsToBroadcaster(opts) {
133
133
  throw err;
134
134
  }
135
135
  }
136
+ /** @internal The error for a model call on a harness with no LLM credentials. */
137
+ export function noLlmProviderError() {
138
+ return new NoeticConfigError({
139
+ code: 'NO_LLM_PROVIDER',
140
+ message: 'No LLM provider configured on this harness.',
141
+ hint: 'Pass `callModelDefaults: { apiKey: "..." }` (defaults to the Noetic platform) or set NOETIC_API_KEY. For direct OpenRouter, use `callModelDefaults: { provider: "openrouter", apiKey: "..." }` or set OPENROUTER_API_KEY.',
142
+ });
143
+ }
136
144
  /** @internal Create a watchdog that aborts `controller` when no
137
145
  * {@link StreamIdleWatchdog.reset reset} is called within `timeoutMs`. When
138
146
  * `timeoutMs <= 0`, returns an inert no-op so callers can always call `.reset()`
@@ -354,11 +362,7 @@ export class AgentHarnessModelCaller {
354
362
  if (this.opts.client) {
355
363
  return this.opts.client;
356
364
  }
357
- throw new NoeticConfigError({
358
- code: 'NO_LLM_PROVIDER',
359
- message: 'No LLM provider configured on this harness.',
360
- hint: 'Pass `callModelDefaults: { apiKey: "..." }` (defaults to the Noetic platform) or set NOETIC_API_KEY. For direct OpenRouter, use `callModelDefaults: { provider: "openrouter", apiKey: "..." }` or set OPENROUTER_API_KEY.',
361
- });
365
+ throw noLlmProviderError();
362
366
  }
363
367
  async callModel(request) {
364
368
  if (this.opts.callModelOverride) {
@@ -3,6 +3,6 @@ export { allocateBudgets, assembleView, churnFor, churnRate, commitLayerUsage, c
3
3
  export type { ItemSchemaRegistry } from '@noetic-tools/types';
4
4
  export { defaultItemSchemaRegistry } from '@noetic-tools/types';
5
5
  export { emitFrameworkEvent, getBroadcaster, shouldEmit } from '../runtime/broadcaster-utils.js';
6
- export { ContextImpl } from '../runtime/context-impl.js';
6
+ export { ContextImpl, laneBase } from '../runtime/context-impl.js';
7
7
  export { snapshotCwdState } from '../runtime/cwd-helpers.js';
8
8
  export { buildToolExecutionContext } from '../runtime/tool-context.js';
@@ -1,6 +1,6 @@
1
1
  export { allocateBudgets, assembleView, churnFor, churnRate, commitLayerUsage, computeLayerUsage, contextToExecCtx, createContextCacheStore, DECISION_PROJECTION, DEFAULT_PROJECTION, lineageKey, noteCacheOutcome, openEpoch, pin, pinKey, reanchorReason, resolveCacheConfig, resolveLayerTools, returnLayers, spawnLayers, } from '@noetic-tools/context';
2
2
  export { defaultItemSchemaRegistry } from '@noetic-tools/types';
3
3
  export { emitFrameworkEvent, getBroadcaster, shouldEmit } from '../runtime/broadcaster-utils.js';
4
- export { ContextImpl } from '../runtime/context-impl.js';
4
+ export { ContextImpl, laneBase } from '../runtime/context-impl.js';
5
5
  export { snapshotCwdState } from '../runtime/cwd-helpers.js';
6
6
  export { buildToolExecutionContext } from '../runtime/tool-context.js';