@noetic-tools/context 1.6.0 → 1.8.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);
@@ -1,4 +1,5 @@
1
1
  import { estimateTokens } from '@noetic-tools/types';
2
+ import { exportSpans } from './span-export.js';
2
3
  /**
3
4
  * Maps a full `Context` to the lightweight `ExecutionContext` consumed by context layer hooks.
4
5
  * Centralised here so the mapping stays consistent across agent-harness, layer-api, and the
@@ -24,7 +25,22 @@ export function contextToExecCtx(ctx, overrides) {
24
25
  trace: {
25
26
  setAttribute: (key, value) => ctx.span.setAttribute(key, value),
26
27
  addEvent: (name, attributes) => ctx.span.addEvent(name, attributes),
28
+ createSpan: (name) => openSpan(ctx, name),
27
29
  },
28
30
  readLayerState: (layerId) => ctx.harness.getLayerState(readLayerStateId, layerId),
29
31
  };
30
32
  }
33
+ /** Opens a span under `ctx.span` that exports itself to the harness when it ends. */
34
+ function openSpan(ctx, name) {
35
+ const span = ctx.harness.createSpan(name, ctx.span);
36
+ return {
37
+ setAttribute: (key, value) => span.setAttribute(key, value),
38
+ addEvent: (event, attributes) => span.addEvent(event, attributes),
39
+ end: async () => {
40
+ span.end();
41
+ await exportSpans(ctx.harness.traceExporter, [
42
+ span,
43
+ ]);
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Timed, traced calls into context layer hooks. Each call of a traced hook is
3
+ * recorded as one `layer.hook` record shaped as a `LayerTraceSpan`: a child
4
+ * span of the context's span when the context can open one
5
+ * (`ctx.trace.createSpan`), otherwise an event on the context's span. Never
6
+ * both, so a backend sees each call once.
7
+ */
8
+ import type { ExecutionContext, LayerTraceSpan } from '@noetic-tools/types';
9
+ type TracedHook = LayerTraceSpan['hook'];
10
+ interface HookCall<T> {
11
+ ctx: ExecutionContext;
12
+ layerId: string;
13
+ hook: TracedHook;
14
+ /** Milliseconds before the call fails as a timeout; `0` or less waits forever. */
15
+ timeout: number;
16
+ run: () => Promise<T>;
17
+ /** Reads a recall's budget and item count from the hook's result. */
18
+ measure?: (result: T) => Pick<LayerTraceSpan, 'budget' | 'itemCount'>;
19
+ }
20
+ /** Name of the span (or span event) recording one layer hook call. */
21
+ export declare const LAYER_HOOK_EVENT = "layer.hook";
22
+ /** Attributes of a `layer.hook` span. A span times itself, so it carries no duration. */
23
+ export declare const LayerHookAttr: {
24
+ readonly LAYER_ID: "noetic.layer.id";
25
+ readonly HOOK: "noetic.layer.hook";
26
+ readonly STATUS: "noetic.layer.status";
27
+ readonly ITEM_COUNT: "noetic.layer.item_count";
28
+ readonly BUDGET_ALLOCATED: "noetic.layer.budget.allocated";
29
+ readonly BUDGET_USED: "noetic.layer.budget.used";
30
+ readonly BUDGET_YIELDED: "noetic.layer.budget.yielded";
31
+ };
32
+ /**
33
+ * Runs a layer hook under its timeout and records a `layer.hook` span (or
34
+ * event) for it. Rethrows whatever the hook threw (or the timeout), so callers
35
+ * keep their own failure handling. The span opens before the hook runs and ends
36
+ * once it settles.
37
+ */
38
+ export declare function callHook<T>({ ctx, layerId, hook, timeout, run, measure, }: HookCall<T>): Promise<T>;
39
+ /** Records a hook the layer defines but the lifecycle did not call. */
40
+ export declare function traceSkipped(ctx: ExecutionContext, layerId: string, hook: TracedHook): Promise<void>;
41
+ /** Runs a hook under a timeout without tracing it, for calls that must leave no trace (a peek recall). */
42
+ export declare function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T>;
43
+ export {};
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Timed, traced calls into context layer hooks. Each call of a traced hook is
3
+ * recorded as one `layer.hook` record shaped as a `LayerTraceSpan`: a child
4
+ * span of the context's span when the context can open one
5
+ * (`ctx.trace.createSpan`), otherwise an event on the context's span. Never
6
+ * both, so a backend sees each call once.
7
+ */
8
+ //#endregion
9
+ //#region Public API
10
+ /** Name of the span (or span event) recording one layer hook call. */
11
+ export const LAYER_HOOK_EVENT = 'layer.hook';
12
+ /** Attributes of a `layer.hook` span. A span times itself, so it carries no duration. */
13
+ export const LayerHookAttr = {
14
+ LAYER_ID: 'noetic.layer.id',
15
+ HOOK: 'noetic.layer.hook',
16
+ STATUS: 'noetic.layer.status',
17
+ ITEM_COUNT: 'noetic.layer.item_count',
18
+ BUDGET_ALLOCATED: 'noetic.layer.budget.allocated',
19
+ BUDGET_USED: 'noetic.layer.budget.used',
20
+ BUDGET_YIELDED: 'noetic.layer.budget.yielded',
21
+ };
22
+ /**
23
+ * Runs a layer hook under its timeout and records a `layer.hook` span (or
24
+ * event) for it. Rethrows whatever the hook threw (or the timeout), so callers
25
+ * keep their own failure handling. The span opens before the hook runs and ends
26
+ * once it settles.
27
+ */
28
+ export async function callHook({ ctx, layerId, hook, timeout, run, measure, }) {
29
+ const span = ctx.trace.createSpan?.(LAYER_HOOK_EVENT);
30
+ const started = performance.now();
31
+ const outcome = await settle(withTimeout(run(), timeout));
32
+ const duration = performance.now() - started;
33
+ if (outcome.ok) {
34
+ await traceHook(ctx, span, {
35
+ layerId,
36
+ hook,
37
+ duration,
38
+ status: 'ok',
39
+ ...measure?.(outcome.value),
40
+ });
41
+ return outcome.value;
42
+ }
43
+ await traceHook(ctx, span, {
44
+ layerId,
45
+ hook,
46
+ duration,
47
+ status: outcome.error instanceof HookTimeout ? 'timeout' : 'error',
48
+ error: {
49
+ message: outcome.error instanceof Error ? outcome.error.message : String(outcome.error),
50
+ },
51
+ });
52
+ throw outcome.error;
53
+ }
54
+ /** Records a hook the layer defines but the lifecycle did not call. */
55
+ export async function traceSkipped(ctx, layerId, hook) {
56
+ await traceHook(ctx, ctx.trace.createSpan?.(LAYER_HOOK_EVENT), {
57
+ layerId,
58
+ hook,
59
+ duration: 0,
60
+ status: 'skipped',
61
+ });
62
+ }
63
+ /** Runs a hook under a timeout without tracing it, for calls that must leave no trace (a peek recall). */
64
+ export async function withTimeout(promise, ms) {
65
+ if (ms <= 0) {
66
+ return promise;
67
+ }
68
+ let timer;
69
+ return Promise.race([
70
+ promise.finally(() => clearTimeout(timer)),
71
+ new Promise((_, reject) => {
72
+ timer = setTimeout(() => reject(new HookTimeout(ms)), ms);
73
+ }),
74
+ ]);
75
+ }
76
+ //#endregion
77
+ //#region Helpers
78
+ class HookTimeout extends Error {
79
+ constructor(ms) {
80
+ super(`Timeout after ${ms}ms`);
81
+ }
82
+ }
83
+ async function settle(promise) {
84
+ try {
85
+ return {
86
+ ok: true,
87
+ value: await promise,
88
+ };
89
+ }
90
+ catch (error) {
91
+ return {
92
+ ok: false,
93
+ error,
94
+ };
95
+ }
96
+ }
97
+ async function traceHook(ctx, span, record) {
98
+ if (!span) {
99
+ ctx.trace.addEvent(LAYER_HOOK_EVENT, eventAttributes(record));
100
+ return;
101
+ }
102
+ for (const [key, value] of Object.entries(spanAttributes(record))) {
103
+ span.setAttribute(key, value);
104
+ }
105
+ await span.end();
106
+ }
107
+ function spanAttributes(record) {
108
+ const attributes = {
109
+ [LayerHookAttr.LAYER_ID]: record.layerId,
110
+ [LayerHookAttr.HOOK]: record.hook,
111
+ [LayerHookAttr.STATUS]: record.status,
112
+ };
113
+ if (record.itemCount !== undefined) {
114
+ attributes[LayerHookAttr.ITEM_COUNT] = record.itemCount;
115
+ }
116
+ if (record.budget) {
117
+ attributes[LayerHookAttr.BUDGET_ALLOCATED] = record.budget.allocated;
118
+ attributes[LayerHookAttr.BUDGET_USED] = record.budget.used;
119
+ attributes[LayerHookAttr.BUDGET_YIELDED] = record.budget.yielded;
120
+ }
121
+ if (record.error) {
122
+ attributes.error = true;
123
+ attributes['error.message'] = record.error.message;
124
+ }
125
+ return attributes;
126
+ }
127
+ /** Span event attributes hold only scalars, so nested fields flatten to dotted keys. */
128
+ function eventAttributes(record) {
129
+ const attributes = {
130
+ layerId: record.layerId,
131
+ hook: record.hook,
132
+ duration: record.duration,
133
+ status: record.status,
134
+ };
135
+ if (record.itemCount !== undefined) {
136
+ attributes.itemCount = record.itemCount;
137
+ }
138
+ if (record.budget) {
139
+ attributes['budget.allocated'] = record.budget.allocated;
140
+ attributes['budget.used'] = record.budget.used;
141
+ attributes['budget.yielded'] = record.budget.yielded;
142
+ }
143
+ if (record.error) {
144
+ attributes['error.message'] = record.error.message;
145
+ }
146
+ return attributes;
147
+ }
148
+ //#endregion
@@ -1,4 +1,5 @@
1
1
  import type { ContextLayer, DecisionRecord, ExecutionContext, Item, ItemLog, ItemSchemaRegistry, LLMResponse, ScopedStorage, SteeringDecision, StorageAdapter } from '@noetic-tools/types';
2
+ import type { LiveChild } from './parent-updates.js';
2
3
  export interface LayerStateStore {
3
4
  get<T>(executionId: string, layerId: string): T | undefined;
4
5
  set<T>(executionId: string, layerId: string, state: T): void;
@@ -13,6 +14,13 @@ export interface LayerStateStore {
13
14
  */
14
15
  has?(executionId: string, layerId: string): boolean;
15
16
  cleanup(executionId: string): void;
17
+ /**
18
+ * Forget one layer's state, durable target, and disabled mark for an
19
+ * execution, leaving the execution's other layers alone. Optional so custom
20
+ * stores keep compiling; without it, a scoped dispose leaves the state in
21
+ * place for the next `init` to overwrite.
22
+ */
23
+ drop?(executionId: string, layerId: string): void;
16
24
  diagnostic: (layerId: string, hook: string, error: unknown) => void;
17
25
  /**
18
26
  * Register a durable write-through target for a layer's state under an
@@ -28,7 +36,8 @@ export interface LayerStateStore {
28
36
  flush?(executionId: string): Promise<void>;
29
37
  /**
30
38
  * Mark a layer disabled for an execution (its `init` failed with
31
- * `onInitError: 'disable'`). Disabled layers are skipped by every later
39
+ * `onInitError: 'disable'`, or its `onSpawn` returned `null` or failed for
40
+ * a child execution). Disabled layers are skipped by every later
32
41
  * hook. Optional so custom stores keep compiling; without explicit
33
42
  * tracking, the lifecycle falls back to the legacy sentinel (init-bearing
34
43
  * layer with no state).
@@ -36,6 +45,17 @@ export interface LayerStateStore {
36
45
  disable?(executionId: string, layerId: string): void;
37
46
  /** Whether a layer was disabled for an execution. */
38
47
  isDisabled?(executionId: string, layerId: string): boolean;
48
+ /**
49
+ * Register a running child. From now on every `set()` of the parent's state
50
+ * for a layer is delivered to that layer's `onParentUpdate` in the child, in
51
+ * the background. `cleanup(childExecutionId)` forgets the child. Optional so
52
+ * custom stores keep compiling; without it, children get no parent updates.
53
+ */
54
+ adopt?(child: LiveChild): void;
55
+ /** Stop parent updates to a child and wait for deliveries already running. */
56
+ release?(childExecutionId: string): Promise<void>;
57
+ /** Number of registered children, for leak checks. */
58
+ liveChildren?(): number;
39
59
  }
40
60
  interface InitLayersParams {
41
61
  layers: ContextLayer[];
@@ -113,6 +133,12 @@ interface DisposeLayersParams {
113
133
  layers: ContextLayer[];
114
134
  ctx: ExecutionContext;
115
135
  store: LayerStateStore;
136
+ /**
137
+ * The layers share their execution with layers that stay live (a
138
+ * `withContext` boundary closing). Drop only these layers' state instead of
139
+ * cleaning up the whole execution.
140
+ */
141
+ scoped?: boolean;
116
142
  }
117
143
  interface BeforeToolCallLayersParams {
118
144
  layers: ContextLayer[];
@@ -231,7 +257,7 @@ interface NotifyDecisionParams {
231
257
  * what an earlier one recorded.
232
258
  */
233
259
  export declare function notifyDecision({ layers, record, ctx, store, recallCache, }: NotifyDecisionParams): Promise<void>;
234
- export declare function disposeLayers({ layers, ctx, store }: DisposeLayersParams): Promise<void>;
260
+ export declare function disposeLayers({ layers, ctx, store, scoped, }: DisposeLayersParams): Promise<void>;
235
261
  export declare function spawnLayers({ layers, parentCtx, childCtx, store, itemSchemas, storage, }: SpawnLayersParams): Promise<{
236
262
  layerId: string;
237
263
  childState: unknown;
@@ -1,4 +1,6 @@
1
1
  import { createMessage, defaultItemSchemaRegistry, estimateTokens, frameworkCast, NoeticConfigError, NoeticErrorImpl, SteeringAction, } from '@noetic-tools/types';
2
+ import { callHook, traceSkipped, withTimeout } from './hook-call.js';
3
+ import { createLiveChildren } from './parent-updates.js';
2
4
  import { createScopedStorage, resolveScopeKey } from './scope.js';
3
5
  /** Maximum allowed re-render depth to prevent infinite loops */
4
6
  const MAX_RERENDER_DEPTH = 3;
@@ -28,7 +30,7 @@ export function createLayerStateStore(diagnostic) {
28
30
  }
29
31
  entry.inFlight = null;
30
32
  }
31
- return {
33
+ const store = {
32
34
  get(executionId, layerId) {
33
35
  return frameworkCast(states.get(executionId)?.get(layerId));
34
36
  },
@@ -39,6 +41,7 @@ export function createLayerStateStore(diagnostic) {
39
41
  states.set(executionId, execMap);
40
42
  }
41
43
  execMap.set(layerId, state);
44
+ children.notify(executionId, layerId, state);
42
45
  const entry = durables.get(executionId)?.get(layerId);
43
46
  if (!entry) {
44
47
  return;
@@ -87,12 +90,34 @@ export function createLayerStateStore(diagnostic) {
87
90
  return disabled.get(executionId)?.has(layerId) ?? false;
88
91
  },
89
92
  cleanup(executionId) {
93
+ children.drop(executionId);
90
94
  states.delete(executionId);
91
95
  durables.delete(executionId);
92
96
  disabled.delete(executionId);
93
97
  },
98
+ drop(executionId, layerId) {
99
+ states.get(executionId)?.delete(layerId);
100
+ durables.get(executionId)?.delete(layerId);
101
+ disabled.get(executionId)?.delete(layerId);
102
+ },
103
+ adopt(child) {
104
+ children.adopt(child);
105
+ },
106
+ release(childExecutionId) {
107
+ return children.release(childExecutionId);
108
+ },
109
+ liveChildren() {
110
+ return children.size;
111
+ },
94
112
  diagnostic: report,
95
113
  };
114
+ const children = createLiveChildren(store, (executionId, layer) => isLayerDisabled({
115
+ store,
116
+ executionId,
117
+ layer,
118
+ state: store.get(executionId, layer.id),
119
+ }));
120
+ return store;
96
121
  }
97
122
  /**
98
123
  * Whether a layer must be skipped for this execution — either explicitly
@@ -136,18 +161,6 @@ function recallCacheKey(layer, ctx) {
136
161
  return `${resolveScopeKey(layer.scope, ctx)}:${layer.id}`;
137
162
  }
138
163
  //#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
164
  export function mostRestrictive(decisions) {
152
165
  const guidances = [];
153
166
  let hasGuide = false;
@@ -202,18 +215,25 @@ export async function initLayers({ layers, ctx, storage, store, keep = false, })
202
215
  if (!layer.hooks.init) {
203
216
  continue;
204
217
  }
218
+ const init = layer.hooks.init;
205
219
  if (keep && store.has?.(ctx.executionId, layer.id)) {
220
+ await traceSkipped(ctx, layer.id, 'init');
206
221
  continue;
207
222
  }
208
223
  const scopeKey = resolveScopeKey(layer.scope, ctx);
209
224
  const scopedStorage = createScopedStorage(storage, layer.id, scopeKey);
210
225
  try {
211
- const timeout = layer.timeouts?.init ?? 1e4;
212
- const result = await withTimeout(layer.hooks.init({
213
- storage: scopedStorage,
214
- scopeKey,
226
+ const result = await callHook({
215
227
  ctx,
216
- }), timeout);
228
+ layerId: layer.id,
229
+ hook: 'init',
230
+ timeout: layer.timeouts?.init ?? 1e4,
231
+ run: () => init({
232
+ storage: scopedStorage,
233
+ scopeKey,
234
+ ctx,
235
+ }),
236
+ });
217
237
  store.set(ctx.executionId, layer.id, result.state);
218
238
  }
219
239
  catch (e) {
@@ -272,6 +292,9 @@ export async function recallLayers({ layers, query, ctx, log, budgets, store, it
272
292
  layer,
273
293
  state: stored,
274
294
  })) {
295
+ if (!peek) {
296
+ await traceSkipped(ctx, layer.id, 'recall');
297
+ }
275
298
  continue; // was disabled
276
299
  }
277
300
  // Skipping the write is not enough for a peek: a hook may mutate the
@@ -281,15 +304,27 @@ export async function recallLayers({ layers, query, ctx, log, budgets, store, it
281
304
  continue;
282
305
  }
283
306
  const budget = budgets.get(layer.id) ?? 0;
307
+ const recall = layer.hooks.recall;
308
+ const run = () => recall({
309
+ log,
310
+ query,
311
+ ctx,
312
+ state,
313
+ budget,
314
+ });
315
+ const timeout = layer.timeouts?.recall ?? 5e3;
284
316
  try {
285
- const timeout = layer.timeouts?.recall ?? 5e3;
286
- const result = await withTimeout(layer.hooks.recall({
287
- log,
288
- query,
289
- ctx,
290
- state,
291
- budget,
292
- }), timeout);
317
+ // A peek renders without leaving a trace, so it records no event either.
318
+ const result = peek
319
+ ? await withTimeout(run(), timeout)
320
+ : await callHook({
321
+ ctx,
322
+ layerId: layer.id,
323
+ hook: 'recall',
324
+ timeout,
325
+ run,
326
+ measure: (output) => measureRecall(output, budget),
327
+ });
293
328
  if (!result) {
294
329
  continue;
295
330
  }
@@ -324,6 +359,24 @@ export async function recallLayers({ layers, query, ctx, log, budgets, store, it
324
359
  return results;
325
360
  }
326
361
  const PEEK_UNSAFE = Symbol('peek-unsafe');
362
+ /** Budget and item count for a `layer.hook` recall event. */
363
+ function measureRecall(output, allocated) {
364
+ const measured = (used, itemCount) => ({
365
+ budget: {
366
+ allocated,
367
+ used,
368
+ yielded: Math.max(0, allocated - used),
369
+ },
370
+ itemCount,
371
+ });
372
+ if (!output) {
373
+ return measured(0, 0);
374
+ }
375
+ if (typeof output === 'string') {
376
+ return measured(estimateTokens(output), 1);
377
+ }
378
+ return measured(output.tokenCount, output.items.length);
379
+ }
327
380
  /** A deep copy, or `PEEK_UNSAFE` when the state cannot be copied (it holds functions). */
328
381
  function copyForPeek(state) {
329
382
  try {
@@ -415,6 +468,7 @@ export async function storeLayers({ layers, response, ctx, log, store, recallCac
415
468
  layer,
416
469
  state,
417
470
  })) {
471
+ await traceSkipped(ctx, layer.id, 'store');
418
472
  continue;
419
473
  }
420
474
  snapshots.push({
@@ -424,15 +478,20 @@ export async function storeLayers({ layers, response, ctx, log, store, recallCac
424
478
  });
425
479
  }
426
480
  await Promise.allSettled(snapshots.map(async ({ layer, state, storeFn }) => {
427
- const timeout = layer.timeouts?.store ?? 3e4;
428
481
  try {
429
- const result = await withTimeout(storeFn({
430
- newItems: response.items,
431
- log,
432
- response,
482
+ const result = await callHook({
433
483
  ctx,
434
- state,
435
- }), timeout);
484
+ layerId: layer.id,
485
+ hook: 'store',
486
+ timeout: layer.timeouts?.store ?? 3e4,
487
+ run: () => storeFn({
488
+ newItems: response.items,
489
+ log,
490
+ response,
491
+ ctx,
492
+ state,
493
+ }),
494
+ });
436
495
  // `'state' in result` (not `!== undefined`) so a layer can explicitly
437
496
  // clear its state by returning `{ state: undefined }`. The store's
438
497
  // durable write-through mirrors the write (clearing deletes the
@@ -472,14 +531,22 @@ export async function notifyDecision({ layers, record, ctx, store, recallCache,
472
531
  layer,
473
532
  state,
474
533
  })) {
534
+ await traceSkipped(ctx, layer.id, 'onDecision');
475
535
  continue;
476
536
  }
537
+ const onDecision = layer.hooks.onDecision;
477
538
  try {
478
- const result = await withTimeout(layer.hooks.onDecision({
479
- state,
480
- record,
539
+ const result = await callHook({
481
540
  ctx,
482
- }), layer.timeouts?.onDecision ?? 5e3);
541
+ layerId: layer.id,
542
+ hook: 'onDecision',
543
+ timeout: layer.timeouts?.onDecision ?? 5e3,
544
+ run: () => onDecision({
545
+ state,
546
+ record,
547
+ ctx,
548
+ }),
549
+ });
483
550
  if (result && 'state' in result) {
484
551
  store.set(ctx.executionId, layer.id, result.state);
485
552
  if (recallCache && layer.recallMode === 'eventual') {
@@ -493,7 +560,7 @@ export async function notifyDecision({ layers, record, ctx, store, recallCache,
493
560
  }
494
561
  await store.flush?.(ctx.executionId);
495
562
  }
496
- export async function disposeLayers({ layers, ctx, store }) {
563
+ export async function disposeLayers({ layers, ctx, store, scoped = false, }) {
497
564
  // Sequential, REVERSE array order
498
565
  const reversed = [
499
566
  ...layers,
@@ -511,13 +578,20 @@ export async function disposeLayers({ layers, ctx, store }) {
511
578
  layer,
512
579
  state,
513
580
  })) {
581
+ await traceSkipped(ctx, layer.id, 'dispose');
514
582
  continue;
515
583
  }
584
+ const dispose = layer.hooks.dispose;
516
585
  try {
517
- const timeout = layer.timeouts?.dispose ?? 5e3;
518
- await withTimeout(layer.hooks.dispose({
519
- state,
520
- }), timeout);
586
+ await callHook({
587
+ ctx,
588
+ layerId: layer.id,
589
+ hook: 'dispose',
590
+ timeout: layer.timeouts?.dispose ?? 5e3,
591
+ run: () => dispose({
592
+ state,
593
+ }),
594
+ });
521
595
  }
522
596
  catch (e) {
523
597
  store.diagnostic(layer.id, 'dispose', e);
@@ -525,7 +599,13 @@ export async function disposeLayers({ layers, ctx, store }) {
525
599
  }
526
600
  // Settle any in-flight durable mirror writes before dropping registrations.
527
601
  await store.flush?.(ctx.executionId);
528
- store.cleanup(ctx.executionId);
602
+ if (!scoped) {
603
+ store.cleanup(ctx.executionId);
604
+ return;
605
+ }
606
+ for (const layer of layers) {
607
+ store.drop?.(ctx.executionId, layer.id);
608
+ }
529
609
  }
530
610
  export async function spawnLayers({ layers, parentCtx, childCtx, store, itemSchemas = defaultItemSchemaRegistry, storage, }) {
531
611
  if (storage) {
@@ -554,25 +634,40 @@ export async function spawnLayers({ layers, parentCtx, childCtx, store, itemSche
554
634
  layer,
555
635
  state: parentState,
556
636
  })) {
637
+ await traceSkipped(parentCtx, layer.id, 'onSpawn');
557
638
  continue;
558
639
  }
640
+ const onSpawn = layer.hooks.onSpawn;
559
641
  try {
560
- const timeout = layer.timeouts?.onSpawn ?? 1e4;
561
- const result = await withTimeout(layer.hooks.onSpawn({
562
- parentState,
563
- childCtx,
564
- }), timeout);
565
- if (result && result.childState !== null) {
642
+ const result = await callHook({
643
+ ctx: parentCtx,
644
+ layerId: layer.id,
645
+ hook: 'onSpawn',
646
+ timeout: layer.timeouts?.onSpawn ?? 1e4,
647
+ run: () => onSpawn({
648
+ parentState,
649
+ childCtx,
650
+ }),
651
+ });
652
+ // A `null` result opts the layer out of this child: every later hook
653
+ // skips it for the child's execution.
654
+ if (!result) {
655
+ store.disable?.(childCtx.executionId, layer.id);
656
+ continue;
657
+ }
658
+ // `childState: null` still injects items; the child just gets no state.
659
+ if (result.childState !== null) {
566
660
  store.set(childCtx.executionId, layer.id, result.childState);
567
- results.push({
568
- layerId: layer.id,
569
- childState: result.childState,
570
- items: layerItemSchemas(layer, itemSchemas).parseMany(result.items ?? []),
571
- });
572
661
  }
662
+ results.push({
663
+ layerId: layer.id,
664
+ childState: result.childState,
665
+ items: layerItemSchemas(layer, itemSchemas).parseMany(result.items ?? []),
666
+ });
573
667
  }
574
668
  catch (e) {
575
669
  store.diagnostic(layer.id, 'onSpawn', e);
670
+ store.disable?.(childCtx.executionId, layer.id);
576
671
  }
577
672
  }
578
673
  return results;
@@ -585,6 +680,9 @@ export async function spawnLayers({ layers, parentCtx, childCtx, store, itemSche
585
680
  * `spawn_summary_failed` diagnostic plus a span event on the child's span.
586
681
  */
587
682
  export async function returnLayers({ layers, stepId, parentCtx, childCtx, childLog, result, store, }) {
683
+ // The child has finished: no more parent updates, and the ones in flight
684
+ // land before its state is merged.
685
+ await store.release?.(childCtx.executionId);
588
686
  const sorted = [
589
687
  ...layers,
590
688
  ].sort((a, b) => a.slot - b.slot);
@@ -599,17 +697,24 @@ export async function returnLayers({ layers, stepId, parentCtx, childCtx, childL
599
697
  // initialized state can still be seeded from the child. Skip only when the
600
698
  // child produced nothing.
601
699
  if (childState === undefined) {
700
+ await traceSkipped(childCtx, layer.id, 'onReturn');
602
701
  continue;
603
702
  }
703
+ const onReturn = layer.hooks.onReturn;
604
704
  try {
605
- const timeout = layer.timeouts?.onReturn ?? 1e4;
606
- const returnResult = await withTimeout(layer.hooks.onReturn({
607
- childState,
608
- childLog,
609
- parentState,
610
- result: currentResult,
611
- childCtx,
612
- }), timeout);
705
+ const returnResult = await callHook({
706
+ ctx: childCtx,
707
+ layerId: layer.id,
708
+ hook: 'onReturn',
709
+ timeout: layer.timeouts?.onReturn ?? 1e4,
710
+ run: () => onReturn({
711
+ childState,
712
+ childLog,
713
+ parentState,
714
+ result: currentResult,
715
+ childCtx,
716
+ }),
717
+ });
613
718
  if (returnResult?.parentState !== undefined) {
614
719
  store.set(parentCtx.executionId, layer.id, returnResult.parentState);
615
720
  }
@@ -648,16 +753,23 @@ export async function completeLayers({ layers, ctx, log, outcome, store, }) {
648
753
  layer,
649
754
  state,
650
755
  })) {
756
+ await traceSkipped(ctx, layer.id, 'onComplete');
651
757
  continue;
652
758
  }
759
+ const onComplete = layer.hooks.onComplete;
653
760
  try {
654
- const timeout = layer.timeouts?.onComplete ?? 3e4;
655
- const result = await withTimeout(layer.hooks.onComplete({
656
- log,
761
+ const result = await callHook({
657
762
  ctx,
658
- state,
659
- outcome,
660
- }), timeout);
763
+ layerId: layer.id,
764
+ hook: 'onComplete',
765
+ timeout: layer.timeouts?.onComplete ?? 3e4,
766
+ run: () => onComplete({
767
+ log,
768
+ ctx,
769
+ state,
770
+ outcome,
771
+ }),
772
+ });
661
773
  if (result && 'state' in result) {
662
774
  store.set(ctx.executionId, layer.id, result.state);
663
775
  }
@@ -684,17 +796,24 @@ export async function beforeToolCallLayers({ layers, toolName, toolArgs, callId,
684
796
  layer,
685
797
  state,
686
798
  })) {
799
+ await traceSkipped(ctx, layer.id, 'beforeToolCall');
687
800
  continue;
688
801
  }
802
+ const beforeToolCall = layer.hooks.beforeToolCall;
689
803
  try {
690
- const timeout = layer.timeouts?.beforeToolCall ?? 5e3;
691
- const result = await withTimeout(layer.hooks.beforeToolCall({
692
- toolName,
693
- toolArgs,
694
- callId,
804
+ const result = await callHook({
695
805
  ctx,
696
- state,
697
- }), timeout);
806
+ layerId: layer.id,
807
+ hook: 'beforeToolCall',
808
+ timeout: layer.timeouts?.beforeToolCall ?? 5e3,
809
+ run: () => beforeToolCall({
810
+ toolName,
811
+ toolArgs,
812
+ callId,
813
+ ctx,
814
+ state,
815
+ }),
816
+ });
698
817
  if (result.state !== undefined) {
699
818
  store.set(ctx.executionId, layer.id, result.state);
700
819
  }
@@ -737,15 +856,22 @@ export async function afterModelCallLayers({ layers, response, ctx, store, }) {
737
856
  layer,
738
857
  state,
739
858
  })) {
859
+ await traceSkipped(ctx, layer.id, 'afterModelCall');
740
860
  continue;
741
861
  }
862
+ const afterModelCall = layer.hooks.afterModelCall;
742
863
  try {
743
- const timeout = layer.timeouts?.afterModelCall ?? 1e4;
744
- const result = await withTimeout(layer.hooks.afterModelCall({
745
- response,
864
+ const result = await callHook({
746
865
  ctx,
747
- state,
748
- }), timeout);
866
+ layerId: layer.id,
867
+ hook: 'afterModelCall',
868
+ timeout: layer.timeouts?.afterModelCall ?? 1e4,
869
+ run: () => afterModelCall({
870
+ response,
871
+ ctx,
872
+ state,
873
+ }),
874
+ });
749
875
  if (result.state !== undefined) {
750
876
  store.set(ctx.executionId, layer.id, result.state);
751
877
  }
@@ -785,15 +911,22 @@ export async function projectHistoryLayers({ layers, items, ctx, store, }) {
785
911
  layer,
786
912
  state,
787
913
  })) {
914
+ await traceSkipped(ctx, layer.id, 'projectHistory');
788
915
  continue;
789
916
  }
917
+ const projectHistory = layer.hooks.projectHistory;
790
918
  try {
791
- const timeout = layer.timeouts?.projectHistory ?? 5e3;
792
- const result = await withTimeout(layer.hooks.projectHistory({
793
- items: current,
919
+ const result = await callHook({
794
920
  ctx,
795
- state,
796
- }), timeout);
921
+ layerId: layer.id,
922
+ hook: 'projectHistory',
923
+ timeout: layer.timeouts?.projectHistory ?? 5e3,
924
+ run: () => projectHistory({
925
+ items: current,
926
+ ctx,
927
+ state,
928
+ }),
929
+ });
797
930
  current = result.items;
798
931
  }
799
932
  catch (e) {
@@ -831,16 +964,23 @@ export async function runAppendPipeline({ layers, items, ctx, log, store, }) {
831
964
  layer,
832
965
  state,
833
966
  })) {
967
+ await traceSkipped(ctx, layer.id, 'onItemAppend');
834
968
  continue;
835
969
  }
970
+ const onItemAppend = layer.hooks.onItemAppend;
836
971
  try {
837
- const timeout = layer.timeouts?.onItemAppend ?? DEFAULT_ON_ITEM_APPEND_TIMEOUT;
838
- const result = await withTimeout(layer.hooks.onItemAppend({
839
- items: currentItems,
840
- log,
972
+ const result = await callHook({
841
973
  ctx,
842
- state,
843
- }), timeout);
974
+ layerId: layer.id,
975
+ hook: 'onItemAppend',
976
+ timeout: layer.timeouts?.onItemAppend ?? DEFAULT_ON_ITEM_APPEND_TIMEOUT,
977
+ run: () => onItemAppend({
978
+ items: currentItems,
979
+ log,
980
+ ctx,
981
+ state,
982
+ }),
983
+ });
844
984
  // Update items for next layer in pipeline
845
985
  currentItems = result.items;
846
986
  // Update state if provided
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Live children and `onParentUpdate` delivery. A child execution is live from
3
+ * the moment its spawn (or inParallel path) starts running until it returns or
4
+ * is cleaned up. While it is live, every write of a parent layer's state is
5
+ * handed to the same layer's `onParentUpdate` in the child.
6
+ */
7
+ import type { ContextLayer, ExecutionContext, ItemLog, ItemSchemaRegistry } from '@noetic-tools/types';
8
+ /** @public A running child execution that receives its parent's layer writes. */
9
+ export interface LiveChild {
10
+ /** Execution whose layer writes the child receives. */
11
+ parentExecutionId: string;
12
+ /** The child's execution context; hooks run on it and trace onto its span. */
13
+ ctx: ExecutionContext;
14
+ /** The layers active in the child. */
15
+ layers: ContextLayer[];
16
+ /** The child's item log, where returned items are appended. */
17
+ log: ItemLog;
18
+ itemSchemas?: ItemSchemaRegistry;
19
+ }
20
+ /** The slice of the layer state store that delivery reads and writes through. */
21
+ export interface ParentUpdateStore {
22
+ get<T>(executionId: string, layerId: string): T | undefined;
23
+ set<T>(executionId: string, layerId: string, state: T): void;
24
+ diagnostic: (layerId: string, hook: string, error: unknown) => void;
25
+ }
26
+ /** Whether a layer's hooks are switched off for an execution. */
27
+ type SkipCheck = (executionId: string, layer: ContextLayer) => boolean;
28
+ export interface LiveChildren {
29
+ adopt(child: LiveChild): void;
30
+ /** Hands a parent write to every live child of `parentExecutionId`. */
31
+ notify(parentExecutionId: string, layerId: string, state: unknown): void;
32
+ /** Stops new deliveries to a child and waits for the ones already running. */
33
+ release(childExecutionId: string): Promise<void>;
34
+ /** Forgets a child at once; a delivery still running discards its result. */
35
+ drop(childExecutionId: string): void;
36
+ /** Number of live children, for leak checks. */
37
+ readonly size: number;
38
+ }
39
+ /**
40
+ * Builds the live-child registry a layer state store dispatches through.
41
+ * `store` is read lazily, so it may be the object under construction.
42
+ */
43
+ export declare function createLiveChildren(store: ParentUpdateStore, skips: SkipCheck): LiveChildren;
44
+ export {};
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Live children and `onParentUpdate` delivery. A child execution is live from
3
+ * the moment its spawn (or inParallel path) starts running until it returns or
4
+ * is cleaned up. While it is live, every write of a parent layer's state is
5
+ * handed to the same layer's `onParentUpdate` in the child.
6
+ */
7
+ import { defaultItemSchemaRegistry } from '@noetic-tools/types';
8
+ import { callHook } from './hook-call.js';
9
+ //#endregion
10
+ //#region Public API
11
+ const DEFAULT_TIMEOUT = 5e3;
12
+ /**
13
+ * Builds the live-child registry a layer state store dispatches through.
14
+ * `store` is read lazily, so it may be the object under construction.
15
+ */
16
+ export function createLiveChildren(store, skips) {
17
+ const byChild = new Map();
18
+ const byParent = new Map();
19
+ function forget(childExecutionId) {
20
+ const entry = byChild.get(childExecutionId);
21
+ if (!entry) {
22
+ return undefined;
23
+ }
24
+ byChild.delete(childExecutionId);
25
+ const siblings = byParent.get(entry.child.parentExecutionId);
26
+ siblings?.delete(entry);
27
+ if (siblings?.size === 0) {
28
+ byParent.delete(entry.child.parentExecutionId);
29
+ }
30
+ entry.open = false;
31
+ return entry;
32
+ }
33
+ return {
34
+ adopt(child) {
35
+ const entry = {
36
+ child,
37
+ queue: Promise.resolve(),
38
+ open: true,
39
+ gone: false,
40
+ };
41
+ byChild.set(child.ctx.executionId, entry);
42
+ let siblings = byParent.get(child.parentExecutionId);
43
+ if (!siblings) {
44
+ siblings = new Set();
45
+ byParent.set(child.parentExecutionId, siblings);
46
+ }
47
+ siblings.add(entry);
48
+ },
49
+ notify(parentExecutionId, layerId, state) {
50
+ const siblings = byParent.get(parentExecutionId);
51
+ if (!siblings) {
52
+ return;
53
+ }
54
+ for (const entry of siblings) {
55
+ const layer = entry.child.layers.find((l) => l.id === layerId);
56
+ if (!layer?.hooks.onParentUpdate) {
57
+ continue;
58
+ }
59
+ entry.queue = entry.queue.then(() => deliver({
60
+ entry,
61
+ layer,
62
+ parentState: state,
63
+ store,
64
+ skips,
65
+ }));
66
+ }
67
+ },
68
+ async release(childExecutionId) {
69
+ const entry = forget(childExecutionId);
70
+ await entry?.queue;
71
+ },
72
+ drop(childExecutionId) {
73
+ const entry = forget(childExecutionId);
74
+ if (entry) {
75
+ entry.gone = true;
76
+ }
77
+ },
78
+ get size() {
79
+ return byChild.size;
80
+ },
81
+ };
82
+ }
83
+ /** Runs one `onParentUpdate` call. Never rejects: failures become diagnostics. */
84
+ async function deliver({ entry, layer, parentState, store, skips }) {
85
+ const { ctx, log, itemSchemas = defaultItemSchemaRegistry } = entry.child;
86
+ const onParentUpdate = layer.hooks.onParentUpdate;
87
+ if (!entry.open || !onParentUpdate || skips(ctx.executionId, layer)) {
88
+ return;
89
+ }
90
+ try {
91
+ const result = await callHook({
92
+ ctx,
93
+ layerId: layer.id,
94
+ hook: 'onParentUpdate',
95
+ timeout: layer.timeouts?.onParentUpdate ?? DEFAULT_TIMEOUT,
96
+ run: () => onParentUpdate({
97
+ parentState,
98
+ childState: store.get(ctx.executionId, layer.id),
99
+ childCtx: ctx,
100
+ }),
101
+ });
102
+ // A child cleaned up mid-call has no state left to write into.
103
+ if (!result || entry.gone) {
104
+ return;
105
+ }
106
+ if ('childState' in result) {
107
+ store.set(ctx.executionId, layer.id, result.childState);
108
+ }
109
+ for (const item of itemSchemas.extend(layer.itemSchemas).parseMany(result.items ?? [])) {
110
+ log.append(item);
111
+ }
112
+ }
113
+ catch (e) {
114
+ store.diagnostic(layer.id, 'onParentUpdate', e);
115
+ }
116
+ }
117
+ //#endregion
@@ -0,0 +1,7 @@
1
+ import type { Span, TraceExporter } from '@noetic-tools/types';
2
+ /**
3
+ * Hands ended spans to `exporter` without letting it fail the traced work: a
4
+ * rejecting (or throwing) exporter is reported once as a warning and
5
+ * swallowed, so tracing never changes what a hook, step, or call returns.
6
+ */
7
+ export declare function exportSpans(exporter: TraceExporter, spans: Span[]): Promise<void>;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Hands ended spans to `exporter` without letting it fail the traced work: a
3
+ * rejecting (or throwing) exporter is reported once as a warning and
4
+ * swallowed, so tracing never changes what a hook, step, or call returns.
5
+ */
6
+ export async function exportSpans(exporter, spans) {
7
+ try {
8
+ await exporter.export(spans);
9
+ }
10
+ catch (error) {
11
+ console.warn('[noetic] trace export failed; the traced work is unaffected:', error);
12
+ }
13
+ }
package/dist/index.d.ts CHANGED
@@ -3,12 +3,13 @@
3
3
  * ContextLayer contract, lifecycle/budget/projection machinery, and built-in
4
4
  * layer implementations.
5
5
  */
6
- export type { ContextScope, ExecutionContext, ExecutionOutcome, LayerCallModelRequest, } from '@noetic-tools/types';
6
+ export type { ContextScope, ExecutionContext, ExecutionOutcome, ExecutionSpan, LayerCallModelRequest, } from '@noetic-tools/types';
7
7
  export * from '@noetic-tools/types/contract';
8
8
  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, LayerHookAttr } 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';
@@ -24,7 +25,9 @@ export * from './context/layers/steering.js';
24
25
  export * from './context/layers/task-state.js';
25
26
  export * from './context/layers/temporal.js';
26
27
  export * from './context/layers/tool-calls.js';
28
+ export type { LiveChild } from './context/parent-updates.js';
27
29
  export * from './context/projector.js';
28
30
  export * from './context/scope.js';
31
+ export * from './context/span-export.js';
29
32
  export * from './context/storage-batch.js';
30
33
  export * from './context/strip-unresolved.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, LayerHookAttr } 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';
@@ -28,5 +29,6 @@ export * from './context/layers/temporal.js';
28
29
  export * from './context/layers/tool-calls.js';
29
30
  export * from './context/projector.js';
30
31
  export * from './context/scope.js';
32
+ export * from './context/span-export.js';
31
33
  export * from './context/storage-batch.js';
32
34
  export * from './context/strip-unresolved.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noetic-tools/context",
3
- "version": "1.6.0",
3
+ "version": "1.8.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.7.0",
47
+ "@noetic-tools/types": "^3.9.0",
48
48
  "zod": "^4.0.0"
49
49
  },
50
50
  "scripts": {