@noetic-tools/context 1.5.0 → 1.7.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.
@@ -27,6 +27,13 @@ export declare function pinKey(layer: ContextLayer): string;
27
27
  export declare function createContextCacheStore(): ContextCacheStore;
28
28
  /** Drop a lineage's epoch — used when a child execution ends. */
29
29
  export declare function dropLineage(store: ContextCacheStore, key: string): void;
30
+ /**
31
+ * Drop the pins of layers that left a lineage's layer set — a closing
32
+ * `withContext` boundary. A pin outlives its layer by design (dropping it
33
+ * shortens the cached prefix), so a scoped layer must be unpinned explicitly or
34
+ * its last render keeps replaying after its scope ends.
35
+ */
36
+ export declare function unpinLayers(store: ContextCacheStore, key: string, layers: ContextLayer[]): void;
30
37
  /** Read a layer's churn record, creating an empty one on first sight. */
31
38
  export declare function churnFor(store: ContextCacheStore, key: string): LayerChurn;
32
39
  /** Share of watched assemblies in which a layer's output changed, 0–1. */
@@ -66,6 +66,22 @@ export function dropLineage(store, key) {
66
66
  store.epochs.delete(key);
67
67
  store.pendingReanchor.delete(key);
68
68
  }
69
+ /**
70
+ * Drop the pins of layers that left a lineage's layer set — a closing
71
+ * `withContext` boundary. A pin outlives its layer by design (dropping it
72
+ * shortens the cached prefix), so a scoped layer must be unpinned explicitly or
73
+ * its last render keeps replaying after its scope ends.
74
+ */
75
+ export function unpinLayers(store, key, layers) {
76
+ const epoch = store.epochs.get(key);
77
+ if (!epoch) {
78
+ return;
79
+ }
80
+ for (const layer of layers) {
81
+ epoch.pins.delete(pinKey(layer));
82
+ epoch.autoBand.delete(pinKey(layer));
83
+ }
84
+ }
69
85
  /** Read a layer's churn record, creating an empty one on first sight. */
70
86
  export function churnFor(store, key) {
71
87
  const found = store.churn.get(key);
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Timed, traced calls into context layer hooks. Each call of a traced hook adds
3
+ * one `layer.hook` event to the span of the context it runs in (`ctx.trace`),
4
+ * shaped as a `LayerTraceSpan`. Events rather than child spans: an
5
+ * `ExecutionContext` can annotate its span but not open one.
6
+ */
7
+ import type { ExecutionContext, LayerTraceSpan } from '@noetic-tools/types';
8
+ type TracedHook = LayerTraceSpan['hook'];
9
+ interface HookCall<T> {
10
+ ctx: ExecutionContext;
11
+ layerId: string;
12
+ hook: TracedHook;
13
+ /** Milliseconds before the call fails as a timeout; `0` or less waits forever. */
14
+ timeout: number;
15
+ run: () => Promise<T>;
16
+ /** Reads a recall's budget and item count from the hook's result. */
17
+ measure?: (result: T) => Pick<LayerTraceSpan, 'budget' | 'itemCount'>;
18
+ }
19
+ /** Span event recording one layer hook call. Attributes follow `LayerTraceSpan`. */
20
+ export declare const LAYER_HOOK_EVENT = "layer.hook";
21
+ /**
22
+ * Runs a layer hook under its timeout and records a `layer.hook` event for it.
23
+ * Rethrows whatever the hook threw (or the timeout), so callers keep their own
24
+ * failure handling.
25
+ */
26
+ export declare function callHook<T>({ ctx, layerId, hook, timeout, run, measure, }: HookCall<T>): Promise<T>;
27
+ /** Records a hook the layer defines but the lifecycle did not call. */
28
+ export declare function traceSkipped(ctx: ExecutionContext, layerId: string, hook: TracedHook): void;
29
+ /** Runs a hook under a timeout without tracing it, for hooks outside `LayerTraceSpan`. */
30
+ export declare function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T>;
31
+ export {};
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Timed, traced calls into context layer hooks. Each call of a traced hook adds
3
+ * one `layer.hook` event to the span of the context it runs in (`ctx.trace`),
4
+ * shaped as a `LayerTraceSpan`. Events rather than child spans: an
5
+ * `ExecutionContext` can annotate its span but not open one.
6
+ */
7
+ //#endregion
8
+ //#region Public API
9
+ /** Span event recording one layer hook call. Attributes follow `LayerTraceSpan`. */
10
+ export const LAYER_HOOK_EVENT = 'layer.hook';
11
+ /**
12
+ * Runs a layer hook under its timeout and records a `layer.hook` event for it.
13
+ * Rethrows whatever the hook threw (or the timeout), so callers keep their own
14
+ * failure handling.
15
+ */
16
+ export async function callHook({ ctx, layerId, hook, timeout, run, measure, }) {
17
+ const started = performance.now();
18
+ try {
19
+ const result = await withTimeout(run(), timeout);
20
+ traceHook(ctx, {
21
+ layerId,
22
+ hook,
23
+ duration: performance.now() - started,
24
+ status: 'ok',
25
+ ...measure?.(result),
26
+ });
27
+ return result;
28
+ }
29
+ catch (e) {
30
+ traceHook(ctx, {
31
+ layerId,
32
+ hook,
33
+ duration: performance.now() - started,
34
+ status: e instanceof HookTimeout ? 'timeout' : 'error',
35
+ error: {
36
+ message: e instanceof Error ? e.message : String(e),
37
+ },
38
+ });
39
+ throw e;
40
+ }
41
+ }
42
+ /** Records a hook the layer defines but the lifecycle did not call. */
43
+ export function traceSkipped(ctx, layerId, hook) {
44
+ traceHook(ctx, {
45
+ layerId,
46
+ hook,
47
+ duration: 0,
48
+ status: 'skipped',
49
+ });
50
+ }
51
+ /** Runs a hook under a timeout without tracing it, for hooks outside `LayerTraceSpan`. */
52
+ export async function withTimeout(promise, ms) {
53
+ if (ms <= 0) {
54
+ return promise;
55
+ }
56
+ let timer;
57
+ return Promise.race([
58
+ promise.finally(() => clearTimeout(timer)),
59
+ new Promise((_, reject) => {
60
+ timer = setTimeout(() => reject(new HookTimeout(ms)), ms);
61
+ }),
62
+ ]);
63
+ }
64
+ //#endregion
65
+ //#region Helpers
66
+ class HookTimeout extends Error {
67
+ constructor(ms) {
68
+ super(`Timeout after ${ms}ms`);
69
+ }
70
+ }
71
+ /** Span event attributes hold only scalars, so nested fields flatten to dotted keys. */
72
+ function traceHook(ctx, span) {
73
+ const attributes = {
74
+ layerId: span.layerId,
75
+ hook: span.hook,
76
+ duration: span.duration,
77
+ status: span.status,
78
+ };
79
+ if (span.itemCount !== undefined) {
80
+ attributes.itemCount = span.itemCount;
81
+ }
82
+ if (span.budget) {
83
+ attributes['budget.allocated'] = span.budget.allocated;
84
+ attributes['budget.used'] = span.budget.used;
85
+ attributes['budget.yielded'] = span.budget.yielded;
86
+ }
87
+ if (span.error) {
88
+ attributes['error.message'] = span.error.message;
89
+ }
90
+ ctx.trace.addEvent(LAYER_HOOK_EVENT, attributes);
91
+ }
92
+ //#endregion
@@ -13,6 +13,13 @@ export interface LayerStateStore {
13
13
  */
14
14
  has?(executionId: string, layerId: string): boolean;
15
15
  cleanup(executionId: string): void;
16
+ /**
17
+ * Forget one layer's state, durable target, and disabled mark for an
18
+ * execution, leaving the execution's other layers alone. Optional so custom
19
+ * stores keep compiling; without it, a scoped dispose leaves the state in
20
+ * place for the next `init` to overwrite.
21
+ */
22
+ drop?(executionId: string, layerId: string): void;
16
23
  diagnostic: (layerId: string, hook: string, error: unknown) => void;
17
24
  /**
18
25
  * Register a durable write-through target for a layer's state under an
@@ -28,7 +35,8 @@ export interface LayerStateStore {
28
35
  flush?(executionId: string): Promise<void>;
29
36
  /**
30
37
  * Mark a layer disabled for an execution (its `init` failed with
31
- * `onInitError: 'disable'`). Disabled layers are skipped by every later
38
+ * `onInitError: 'disable'`, or its `onSpawn` returned `null` or failed for
39
+ * a child execution). Disabled layers are skipped by every later
32
40
  * hook. Optional so custom stores keep compiling; without explicit
33
41
  * tracking, the lifecycle falls back to the legacy sentinel (init-bearing
34
42
  * layer with no state).
@@ -90,6 +98,12 @@ interface SpawnLayersParams {
90
98
  }
91
99
  interface ReturnLayersParams<T = unknown> {
92
100
  layers: ContextLayer[];
101
+ /**
102
+ * Id of the spawn (or inParallel) step whose child is returning. Names the
103
+ * step in `spawn_summary_failed` diagnostics; defaults to the child's
104
+ * execution id.
105
+ */
106
+ stepId?: string;
93
107
  parentCtx: ExecutionContext;
94
108
  childCtx: ExecutionContext;
95
109
  childLog: ItemLog;
@@ -107,6 +121,12 @@ interface DisposeLayersParams {
107
121
  layers: ContextLayer[];
108
122
  ctx: ExecutionContext;
109
123
  store: LayerStateStore;
124
+ /**
125
+ * The layers share their execution with layers that stay live (a
126
+ * `withContext` boundary closing). Drop only these layers' state instead of
127
+ * cleaning up the whole execution.
128
+ */
129
+ scoped?: boolean;
110
130
  }
111
131
  interface BeforeToolCallLayersParams {
112
132
  layers: ContextLayer[];
@@ -180,6 +200,8 @@ export interface RecallCache {
180
200
  }
181
201
  export declare function createRecallCache(): RecallCache;
182
202
  export declare function mostRestrictive(decisions: SteeringDecision[]): SteeringDecision;
203
+ /** Span event added to the child's span when a layer's `onReturn` hook fails. */
204
+ export declare const SPAWN_SUMMARY_FAILED_EVENT = "spawn.summary_failed";
183
205
  export declare function initLayers({ layers, ctx, storage, store, keep, }: InitLayersParams): Promise<void>;
184
206
  export declare function recallLayers({ layers, query, ctx, log, budgets, store, itemSchemas, peek, }: RecallLayersParams): Promise<RecallEntry[]>;
185
207
  type RecallEntry = {
@@ -223,13 +245,20 @@ interface NotifyDecisionParams {
223
245
  * what an earlier one recorded.
224
246
  */
225
247
  export declare function notifyDecision({ layers, record, ctx, store, recallCache, }: NotifyDecisionParams): Promise<void>;
226
- export declare function disposeLayers({ layers, ctx, store }: DisposeLayersParams): Promise<void>;
248
+ export declare function disposeLayers({ layers, ctx, store, scoped, }: DisposeLayersParams): Promise<void>;
227
249
  export declare function spawnLayers({ layers, parentCtx, childCtx, store, itemSchemas, storage, }: SpawnLayersParams): Promise<{
228
250
  layerId: string;
229
251
  childState: unknown;
230
252
  items: Item[];
231
253
  }[]>;
232
- export declare function returnLayers<T>({ layers, parentCtx, childCtx, childLog, result, store, }: ReturnLayersParams<T>): Promise<T>;
254
+ /**
255
+ * Pipes a child's result through each layer's `onReturn` hook in slot order.
256
+ *
257
+ * A failing hook never fails the spawn: the child's work already succeeded,
258
+ * so the result passes on unchanged and the failure is reported as a
259
+ * `spawn_summary_failed` diagnostic plus a span event on the child's span.
260
+ */
261
+ export declare function returnLayers<T>({ layers, stepId, parentCtx, childCtx, childLog, result, store, }: ReturnLayersParams<T>): Promise<T>;
233
262
  export declare function completeLayers({ layers, ctx, log, outcome, store, }: CompleteLayersParams): Promise<void>;
234
263
  export declare function beforeToolCallLayers({ layers, toolName, toolArgs, callId, ctx, store, }: BeforeToolCallLayersParams): Promise<SteeringDecision>;
235
264
  export declare function afterModelCallLayers({ layers, response, ctx, store, }: AfterModelCallLayersParams): Promise<SteeringDecision>;
@@ -1,4 +1,5 @@
1
- import { createMessage, defaultItemSchemaRegistry, estimateTokens, frameworkCast, NoeticConfigError, SteeringAction, } from '@noetic-tools/types';
1
+ import { createMessage, defaultItemSchemaRegistry, estimateTokens, frameworkCast, NoeticConfigError, NoeticErrorImpl, SteeringAction, } from '@noetic-tools/types';
2
+ import { callHook, traceSkipped, withTimeout } from './hook-call.js';
2
3
  import { createScopedStorage, resolveScopeKey } from './scope.js';
3
4
  /** Maximum allowed re-render depth to prevent infinite loops */
4
5
  const MAX_RERENDER_DEPTH = 3;
@@ -91,6 +92,11 @@ export function createLayerStateStore(diagnostic) {
91
92
  durables.delete(executionId);
92
93
  disabled.delete(executionId);
93
94
  },
95
+ drop(executionId, layerId) {
96
+ states.get(executionId)?.delete(layerId);
97
+ durables.get(executionId)?.delete(layerId);
98
+ disabled.get(executionId)?.delete(layerId);
99
+ },
94
100
  diagnostic: report,
95
101
  };
96
102
  }
@@ -136,18 +142,6 @@ function recallCacheKey(layer, ctx) {
136
142
  return `${resolveScopeKey(layer.scope, ctx)}:${layer.id}`;
137
143
  }
138
144
  //#endregion
139
- async function withTimeout(promise, ms) {
140
- if (ms <= 0) {
141
- return promise;
142
- }
143
- let timer;
144
- return Promise.race([
145
- promise.finally(() => clearTimeout(timer)),
146
- new Promise((_, reject) => {
147
- timer = setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms);
148
- }),
149
- ]);
150
- }
151
145
  export function mostRestrictive(decisions) {
152
146
  const guidances = [];
153
147
  let hasGuide = false;
@@ -177,6 +171,23 @@ export function mostRestrictive(decisions) {
177
171
  function layerItemSchemas(layer, base = defaultItemSchemaRegistry) {
178
172
  return base.extend(layer.itemSchemas);
179
173
  }
174
+ /** Span event added to the child's span when a layer's `onReturn` hook fails. */
175
+ export const SPAWN_SUMMARY_FAILED_EVENT = 'spawn.summary_failed';
176
+ function reportSummaryFailure({ layerId, stepId, childOutput, cause, childCtx, store, }) {
177
+ const summaryCause = cause instanceof Error ? cause : new Error(String(cause));
178
+ const error = new NoeticErrorImpl({
179
+ kind: 'spawn_summary_failed',
180
+ stepId,
181
+ childOutput,
182
+ summaryCause,
183
+ });
184
+ childCtx.trace.addEvent(SPAWN_SUMMARY_FAILED_EVENT, {
185
+ stepId,
186
+ layerId,
187
+ message: summaryCause.message,
188
+ });
189
+ store.diagnostic(layerId, 'onReturn', error);
190
+ }
180
191
  //#endregion
181
192
  //#region Public API
182
193
  export async function initLayers({ layers, ctx, storage, store, keep = false, }) {
@@ -185,18 +196,25 @@ export async function initLayers({ layers, ctx, storage, store, keep = false, })
185
196
  if (!layer.hooks.init) {
186
197
  continue;
187
198
  }
199
+ const init = layer.hooks.init;
188
200
  if (keep && store.has?.(ctx.executionId, layer.id)) {
201
+ traceSkipped(ctx, layer.id, 'init');
189
202
  continue;
190
203
  }
191
204
  const scopeKey = resolveScopeKey(layer.scope, ctx);
192
205
  const scopedStorage = createScopedStorage(storage, layer.id, scopeKey);
193
206
  try {
194
- const timeout = layer.timeouts?.init ?? 1e4;
195
- const result = await withTimeout(layer.hooks.init({
196
- storage: scopedStorage,
197
- scopeKey,
207
+ const result = await callHook({
198
208
  ctx,
199
- }), timeout);
209
+ layerId: layer.id,
210
+ hook: 'init',
211
+ timeout: layer.timeouts?.init ?? 1e4,
212
+ run: () => init({
213
+ storage: scopedStorage,
214
+ scopeKey,
215
+ ctx,
216
+ }),
217
+ });
200
218
  store.set(ctx.executionId, layer.id, result.state);
201
219
  }
202
220
  catch (e) {
@@ -255,6 +273,9 @@ export async function recallLayers({ layers, query, ctx, log, budgets, store, it
255
273
  layer,
256
274
  state: stored,
257
275
  })) {
276
+ if (!peek) {
277
+ traceSkipped(ctx, layer.id, 'recall');
278
+ }
258
279
  continue; // was disabled
259
280
  }
260
281
  // Skipping the write is not enough for a peek: a hook may mutate the
@@ -264,15 +285,27 @@ export async function recallLayers({ layers, query, ctx, log, budgets, store, it
264
285
  continue;
265
286
  }
266
287
  const budget = budgets.get(layer.id) ?? 0;
288
+ const recall = layer.hooks.recall;
289
+ const run = () => recall({
290
+ log,
291
+ query,
292
+ ctx,
293
+ state,
294
+ budget,
295
+ });
296
+ const timeout = layer.timeouts?.recall ?? 5e3;
267
297
  try {
268
- const timeout = layer.timeouts?.recall ?? 5e3;
269
- const result = await withTimeout(layer.hooks.recall({
270
- log,
271
- query,
272
- ctx,
273
- state,
274
- budget,
275
- }), timeout);
298
+ // A peek renders without leaving a trace, so it records no event either.
299
+ const result = peek
300
+ ? await withTimeout(run(), timeout)
301
+ : await callHook({
302
+ ctx,
303
+ layerId: layer.id,
304
+ hook: 'recall',
305
+ timeout,
306
+ run,
307
+ measure: (output) => measureRecall(output, budget),
308
+ });
276
309
  if (!result) {
277
310
  continue;
278
311
  }
@@ -307,6 +340,24 @@ export async function recallLayers({ layers, query, ctx, log, budgets, store, it
307
340
  return results;
308
341
  }
309
342
  const PEEK_UNSAFE = Symbol('peek-unsafe');
343
+ /** Budget and item count for a `layer.hook` recall event. */
344
+ function measureRecall(output, allocated) {
345
+ const measured = (used, itemCount) => ({
346
+ budget: {
347
+ allocated,
348
+ used,
349
+ yielded: Math.max(0, allocated - used),
350
+ },
351
+ itemCount,
352
+ });
353
+ if (!output) {
354
+ return measured(0, 0);
355
+ }
356
+ if (typeof output === 'string') {
357
+ return measured(estimateTokens(output), 1);
358
+ }
359
+ return measured(output.tokenCount, output.items.length);
360
+ }
310
361
  /** A deep copy, or `PEEK_UNSAFE` when the state cannot be copied (it holds functions). */
311
362
  function copyForPeek(state) {
312
363
  try {
@@ -398,6 +449,7 @@ export async function storeLayers({ layers, response, ctx, log, store, recallCac
398
449
  layer,
399
450
  state,
400
451
  })) {
452
+ traceSkipped(ctx, layer.id, 'store');
401
453
  continue;
402
454
  }
403
455
  snapshots.push({
@@ -407,15 +459,20 @@ export async function storeLayers({ layers, response, ctx, log, store, recallCac
407
459
  });
408
460
  }
409
461
  await Promise.allSettled(snapshots.map(async ({ layer, state, storeFn }) => {
410
- const timeout = layer.timeouts?.store ?? 3e4;
411
462
  try {
412
- const result = await withTimeout(storeFn({
413
- newItems: response.items,
414
- log,
415
- response,
463
+ const result = await callHook({
416
464
  ctx,
417
- state,
418
- }), timeout);
465
+ layerId: layer.id,
466
+ hook: 'store',
467
+ timeout: layer.timeouts?.store ?? 3e4,
468
+ run: () => storeFn({
469
+ newItems: response.items,
470
+ log,
471
+ response,
472
+ ctx,
473
+ state,
474
+ }),
475
+ });
419
476
  // `'state' in result` (not `!== undefined`) so a layer can explicitly
420
477
  // clear its state by returning `{ state: undefined }`. The store's
421
478
  // durable write-through mirrors the write (clearing deletes the
@@ -476,7 +533,7 @@ export async function notifyDecision({ layers, record, ctx, store, recallCache,
476
533
  }
477
534
  await store.flush?.(ctx.executionId);
478
535
  }
479
- export async function disposeLayers({ layers, ctx, store }) {
536
+ export async function disposeLayers({ layers, ctx, store, scoped = false, }) {
480
537
  // Sequential, REVERSE array order
481
538
  const reversed = [
482
539
  ...layers,
@@ -494,13 +551,20 @@ export async function disposeLayers({ layers, ctx, store }) {
494
551
  layer,
495
552
  state,
496
553
  })) {
554
+ traceSkipped(ctx, layer.id, 'dispose');
497
555
  continue;
498
556
  }
557
+ const dispose = layer.hooks.dispose;
499
558
  try {
500
- const timeout = layer.timeouts?.dispose ?? 5e3;
501
- await withTimeout(layer.hooks.dispose({
502
- state,
503
- }), timeout);
559
+ await callHook({
560
+ ctx,
561
+ layerId: layer.id,
562
+ hook: 'dispose',
563
+ timeout: layer.timeouts?.dispose ?? 5e3,
564
+ run: () => dispose({
565
+ state,
566
+ }),
567
+ });
504
568
  }
505
569
  catch (e) {
506
570
  store.diagnostic(layer.id, 'dispose', e);
@@ -508,7 +572,13 @@ export async function disposeLayers({ layers, ctx, store }) {
508
572
  }
509
573
  // Settle any in-flight durable mirror writes before dropping registrations.
510
574
  await store.flush?.(ctx.executionId);
511
- store.cleanup(ctx.executionId);
575
+ if (!scoped) {
576
+ store.cleanup(ctx.executionId);
577
+ return;
578
+ }
579
+ for (const layer of layers) {
580
+ store.drop?.(ctx.executionId, layer.id);
581
+ }
512
582
  }
513
583
  export async function spawnLayers({ layers, parentCtx, childCtx, store, itemSchemas = defaultItemSchemaRegistry, storage, }) {
514
584
  if (storage) {
@@ -537,30 +607,52 @@ export async function spawnLayers({ layers, parentCtx, childCtx, store, itemSche
537
607
  layer,
538
608
  state: parentState,
539
609
  })) {
610
+ traceSkipped(parentCtx, layer.id, 'onSpawn');
540
611
  continue;
541
612
  }
613
+ const onSpawn = layer.hooks.onSpawn;
542
614
  try {
543
- const timeout = layer.timeouts?.onSpawn ?? 1e4;
544
- const result = await withTimeout(layer.hooks.onSpawn({
545
- parentState,
546
- childCtx,
547
- }), timeout);
548
- if (result && result.childState !== null) {
615
+ const result = await callHook({
616
+ ctx: parentCtx,
617
+ layerId: layer.id,
618
+ hook: 'onSpawn',
619
+ timeout: layer.timeouts?.onSpawn ?? 1e4,
620
+ run: () => onSpawn({
621
+ parentState,
622
+ childCtx,
623
+ }),
624
+ });
625
+ // A `null` result opts the layer out of this child: every later hook
626
+ // skips it for the child's execution.
627
+ if (!result) {
628
+ store.disable?.(childCtx.executionId, layer.id);
629
+ continue;
630
+ }
631
+ // `childState: null` still injects items; the child just gets no state.
632
+ if (result.childState !== null) {
549
633
  store.set(childCtx.executionId, layer.id, result.childState);
550
- results.push({
551
- layerId: layer.id,
552
- childState: result.childState,
553
- items: layerItemSchemas(layer, itemSchemas).parseMany(result.items ?? []),
554
- });
555
634
  }
635
+ results.push({
636
+ layerId: layer.id,
637
+ childState: result.childState,
638
+ items: layerItemSchemas(layer, itemSchemas).parseMany(result.items ?? []),
639
+ });
556
640
  }
557
641
  catch (e) {
558
642
  store.diagnostic(layer.id, 'onSpawn', e);
643
+ store.disable?.(childCtx.executionId, layer.id);
559
644
  }
560
645
  }
561
646
  return results;
562
647
  }
563
- export async function returnLayers({ layers, parentCtx, childCtx, childLog, result, store, }) {
648
+ /**
649
+ * Pipes a child's result through each layer's `onReturn` hook in slot order.
650
+ *
651
+ * A failing hook never fails the spawn: the child's work already succeeded,
652
+ * so the result passes on unchanged and the failure is reported as a
653
+ * `spawn_summary_failed` diagnostic plus a span event on the child's span.
654
+ */
655
+ export async function returnLayers({ layers, stepId, parentCtx, childCtx, childLog, result, store, }) {
564
656
  const sorted = [
565
657
  ...layers,
566
658
  ].sort((a, b) => a.slot - b.slot);
@@ -575,17 +667,24 @@ export async function returnLayers({ layers, parentCtx, childCtx, childLog, resu
575
667
  // initialized state can still be seeded from the child. Skip only when the
576
668
  // child produced nothing.
577
669
  if (childState === undefined) {
670
+ traceSkipped(childCtx, layer.id, 'onReturn');
578
671
  continue;
579
672
  }
673
+ const onReturn = layer.hooks.onReturn;
580
674
  try {
581
- const timeout = layer.timeouts?.onReturn ?? 1e4;
582
- const returnResult = await withTimeout(layer.hooks.onReturn({
583
- childState,
584
- childLog,
585
- parentState,
586
- result: currentResult,
587
- childCtx,
588
- }), timeout);
675
+ const returnResult = await callHook({
676
+ ctx: childCtx,
677
+ layerId: layer.id,
678
+ hook: 'onReturn',
679
+ timeout: layer.timeouts?.onReturn ?? 1e4,
680
+ run: () => onReturn({
681
+ childState,
682
+ childLog,
683
+ parentState,
684
+ result: currentResult,
685
+ childCtx,
686
+ }),
687
+ });
589
688
  if (returnResult?.parentState !== undefined) {
590
689
  store.set(parentCtx.executionId, layer.id, returnResult.parentState);
591
690
  }
@@ -594,7 +693,14 @@ export async function returnLayers({ layers, parentCtx, childCtx, childLog, resu
594
693
  }
595
694
  }
596
695
  catch (e) {
597
- store.diagnostic(layer.id, 'onReturn', e);
696
+ reportSummaryFailure({
697
+ layerId: layer.id,
698
+ stepId: stepId ?? childCtx.executionId,
699
+ childOutput: currentResult,
700
+ cause: e,
701
+ childCtx,
702
+ store,
703
+ });
598
704
  }
599
705
  }
600
706
  // The child execution's store is typically cleaned up right after onReturn —
@@ -617,16 +723,23 @@ export async function completeLayers({ layers, ctx, log, outcome, store, }) {
617
723
  layer,
618
724
  state,
619
725
  })) {
726
+ traceSkipped(ctx, layer.id, 'onComplete');
620
727
  continue;
621
728
  }
729
+ const onComplete = layer.hooks.onComplete;
622
730
  try {
623
- const timeout = layer.timeouts?.onComplete ?? 3e4;
624
- const result = await withTimeout(layer.hooks.onComplete({
625
- log,
731
+ const result = await callHook({
626
732
  ctx,
627
- state,
628
- outcome,
629
- }), timeout);
733
+ layerId: layer.id,
734
+ hook: 'onComplete',
735
+ timeout: layer.timeouts?.onComplete ?? 3e4,
736
+ run: () => onComplete({
737
+ log,
738
+ ctx,
739
+ state,
740
+ outcome,
741
+ }),
742
+ });
630
743
  if (result && 'state' in result) {
631
744
  store.set(ctx.executionId, layer.id, result.state);
632
745
  }
@@ -27,5 +27,6 @@ export interface HistoryConfig {
27
27
  * @public
28
28
  * @param config - Optional `{ maxItems }`. Defaults to 40.
29
29
  * @returns A `ContextLayer` that contributes only a `projectHistory` hook.
30
+ * @throws `NoeticConfigError` with code `INVALID_HISTORY_MAX_ITEMS` if `maxItems` is not an integer in the allowed range.
30
31
  */
31
32
  export declare function history(config?: HistoryConfig): ContextLayer<null>;
@@ -100,6 +100,7 @@ function findHeadAnchor(items, windowStart) {
100
100
  * @public
101
101
  * @param config - Optional `{ maxItems }`. Defaults to 40.
102
102
  * @returns A `ContextLayer` that contributes only a `projectHistory` hook.
103
+ * @throws `NoeticConfigError` with code `INVALID_HISTORY_MAX_ITEMS` if `maxItems` is not an integer in the allowed range.
103
104
  */
104
105
  export function history(config) {
105
106
  const maxItems = config?.maxItems ?? DEFAULT_MAX_ITEMS;
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export * from './context/budget.js';
9
9
  export * from './context/cache-anchoring.js';
10
10
  export * from './context/exec-context-factory.js';
11
11
  export * from './context/function-call-utils.js';
12
+ export { LAYER_HOOK_EVENT } from './context/hook-call.js';
12
13
  export * from './context/layer-api.js';
13
14
  export * from './context/layer-lifecycle.js';
14
15
  export * from './context/layer-provides.js';
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ export * from './context/budget.js';
11
11
  export * from './context/cache-anchoring.js';
12
12
  export * from './context/exec-context-factory.js';
13
13
  export * from './context/function-call-utils.js';
14
+ export { LAYER_HOOK_EVENT } from './context/hook-call.js';
14
15
  export * from './context/layer-api.js';
15
16
  export * from './context/layer-lifecycle.js';
16
17
  export * from './context/layer-provides.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noetic-tools/context",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "description": "Context layer system for Noetic agents: the ContextLayer contract, lifecycle, budget/projection machinery, and built-in layer implementations.",
6
6
  "license": "Apache-2.0",
@@ -44,7 +44,7 @@
44
44
  "zod": "^4.0.0"
45
45
  },
46
46
  "dependencies": {
47
- "@noetic-tools/types": "^3.6.0",
47
+ "@noetic-tools/types": "^3.8.0",
48
48
  "zod": "^4.0.0"
49
49
  },
50
50
  "scripts": {