@noetic-tools/context 1.7.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.
@@ -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
+ }
@@ -1,8 +1,9 @@
1
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.
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.
6
7
  */
7
8
  import type { ExecutionContext, LayerTraceSpan } from '@noetic-tools/types';
8
9
  type TracedHook = LayerTraceSpan['hook'];
@@ -16,16 +17,27 @@ interface HookCall<T> {
16
17
  /** Reads a recall's budget and item count from the hook's result. */
17
18
  measure?: (result: T) => Pick<LayerTraceSpan, 'budget' | 'itemCount'>;
18
19
  }
19
- /** Span event recording one layer hook call. Attributes follow `LayerTraceSpan`. */
20
+ /** Name of the span (or span event) recording one layer hook call. */
20
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
+ };
21
32
  /**
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.
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.
25
37
  */
26
38
  export declare function callHook<T>({ ctx, layerId, hook, timeout, run, measure, }: HookCall<T>): Promise<T>;
27
39
  /** 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`. */
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). */
30
42
  export declare function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T>;
31
43
  export {};
@@ -1,54 +1,66 @@
1
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.
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.
6
7
  */
7
8
  //#endregion
8
9
  //#region Public API
9
- /** Span event recording one layer hook call. Attributes follow `LayerTraceSpan`. */
10
+ /** Name of the span (or span event) recording one layer hook call. */
10
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
+ };
11
22
  /**
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.
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.
15
27
  */
16
28
  export async function callHook({ ctx, layerId, hook, timeout, run, measure, }) {
29
+ const span = ctx.trace.createSpan?.(LAYER_HOOK_EVENT);
17
30
  const started = performance.now();
18
- try {
19
- const result = await withTimeout(run(), timeout);
20
- traceHook(ctx, {
31
+ const outcome = await settle(withTimeout(run(), timeout));
32
+ const duration = performance.now() - started;
33
+ if (outcome.ok) {
34
+ await traceHook(ctx, span, {
21
35
  layerId,
22
36
  hook,
23
- duration: performance.now() - started,
37
+ duration,
24
38
  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
- },
39
+ ...measure?.(outcome.value),
38
40
  });
39
- throw e;
41
+ return outcome.value;
40
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;
41
53
  }
42
54
  /** Records a hook the layer defines but the lifecycle did not call. */
43
- export function traceSkipped(ctx, layerId, hook) {
44
- traceHook(ctx, {
55
+ export async function traceSkipped(ctx, layerId, hook) {
56
+ await traceHook(ctx, ctx.trace.createSpan?.(LAYER_HOOK_EVENT), {
45
57
  layerId,
46
58
  hook,
47
59
  duration: 0,
48
60
  status: 'skipped',
49
61
  });
50
62
  }
51
- /** Runs a hook under a timeout without tracing it, for hooks outside `LayerTraceSpan`. */
63
+ /** Runs a hook under a timeout without tracing it, for calls that must leave no trace (a peek recall). */
52
64
  export async function withTimeout(promise, ms) {
53
65
  if (ms <= 0) {
54
66
  return promise;
@@ -68,25 +80,69 @@ class HookTimeout extends Error {
68
80
  super(`Timeout after ${ms}ms`);
69
81
  }
70
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
+ }
71
127
  /** Span event attributes hold only scalars, so nested fields flatten to dotted keys. */
72
- function traceHook(ctx, span) {
128
+ function eventAttributes(record) {
73
129
  const attributes = {
74
- layerId: span.layerId,
75
- hook: span.hook,
76
- duration: span.duration,
77
- status: span.status,
130
+ layerId: record.layerId,
131
+ hook: record.hook,
132
+ duration: record.duration,
133
+ status: record.status,
78
134
  };
79
- if (span.itemCount !== undefined) {
80
- attributes.itemCount = span.itemCount;
135
+ if (record.itemCount !== undefined) {
136
+ attributes.itemCount = record.itemCount;
81
137
  }
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;
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;
86
142
  }
87
- if (span.error) {
88
- attributes['error.message'] = span.error.message;
143
+ if (record.error) {
144
+ attributes['error.message'] = record.error.message;
89
145
  }
90
- ctx.trace.addEvent(LAYER_HOOK_EVENT, attributes);
146
+ return attributes;
91
147
  }
92
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;
@@ -44,6 +45,17 @@ export interface LayerStateStore {
44
45
  disable?(executionId: string, layerId: string): void;
45
46
  /** Whether a layer was disabled for an execution. */
46
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;
47
59
  }
48
60
  interface InitLayersParams {
49
61
  layers: ContextLayer[];
@@ -1,5 +1,6 @@
1
1
  import { createMessage, defaultItemSchemaRegistry, estimateTokens, frameworkCast, NoeticConfigError, NoeticErrorImpl, SteeringAction, } from '@noetic-tools/types';
2
2
  import { callHook, traceSkipped, withTimeout } from './hook-call.js';
3
+ import { createLiveChildren } from './parent-updates.js';
3
4
  import { createScopedStorage, resolveScopeKey } from './scope.js';
4
5
  /** Maximum allowed re-render depth to prevent infinite loops */
5
6
  const MAX_RERENDER_DEPTH = 3;
@@ -29,7 +30,7 @@ export function createLayerStateStore(diagnostic) {
29
30
  }
30
31
  entry.inFlight = null;
31
32
  }
32
- return {
33
+ const store = {
33
34
  get(executionId, layerId) {
34
35
  return frameworkCast(states.get(executionId)?.get(layerId));
35
36
  },
@@ -40,6 +41,7 @@ export function createLayerStateStore(diagnostic) {
40
41
  states.set(executionId, execMap);
41
42
  }
42
43
  execMap.set(layerId, state);
44
+ children.notify(executionId, layerId, state);
43
45
  const entry = durables.get(executionId)?.get(layerId);
44
46
  if (!entry) {
45
47
  return;
@@ -88,6 +90,7 @@ export function createLayerStateStore(diagnostic) {
88
90
  return disabled.get(executionId)?.has(layerId) ?? false;
89
91
  },
90
92
  cleanup(executionId) {
93
+ children.drop(executionId);
91
94
  states.delete(executionId);
92
95
  durables.delete(executionId);
93
96
  disabled.delete(executionId);
@@ -97,8 +100,24 @@ export function createLayerStateStore(diagnostic) {
97
100
  durables.get(executionId)?.delete(layerId);
98
101
  disabled.get(executionId)?.delete(layerId);
99
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
+ },
100
112
  diagnostic: report,
101
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;
102
121
  }
103
122
  /**
104
123
  * Whether a layer must be skipped for this execution — either explicitly
@@ -198,7 +217,7 @@ export async function initLayers({ layers, ctx, storage, store, keep = false, })
198
217
  }
199
218
  const init = layer.hooks.init;
200
219
  if (keep && store.has?.(ctx.executionId, layer.id)) {
201
- traceSkipped(ctx, layer.id, 'init');
220
+ await traceSkipped(ctx, layer.id, 'init');
202
221
  continue;
203
222
  }
204
223
  const scopeKey = resolveScopeKey(layer.scope, ctx);
@@ -274,7 +293,7 @@ export async function recallLayers({ layers, query, ctx, log, budgets, store, it
274
293
  state: stored,
275
294
  })) {
276
295
  if (!peek) {
277
- traceSkipped(ctx, layer.id, 'recall');
296
+ await traceSkipped(ctx, layer.id, 'recall');
278
297
  }
279
298
  continue; // was disabled
280
299
  }
@@ -449,7 +468,7 @@ export async function storeLayers({ layers, response, ctx, log, store, recallCac
449
468
  layer,
450
469
  state,
451
470
  })) {
452
- traceSkipped(ctx, layer.id, 'store');
471
+ await traceSkipped(ctx, layer.id, 'store');
453
472
  continue;
454
473
  }
455
474
  snapshots.push({
@@ -512,14 +531,22 @@ export async function notifyDecision({ layers, record, ctx, store, recallCache,
512
531
  layer,
513
532
  state,
514
533
  })) {
534
+ await traceSkipped(ctx, layer.id, 'onDecision');
515
535
  continue;
516
536
  }
537
+ const onDecision = layer.hooks.onDecision;
517
538
  try {
518
- const result = await withTimeout(layer.hooks.onDecision({
519
- state,
520
- record,
539
+ const result = await callHook({
521
540
  ctx,
522
- }), 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
+ });
523
550
  if (result && 'state' in result) {
524
551
  store.set(ctx.executionId, layer.id, result.state);
525
552
  if (recallCache && layer.recallMode === 'eventual') {
@@ -551,7 +578,7 @@ export async function disposeLayers({ layers, ctx, store, scoped = false, }) {
551
578
  layer,
552
579
  state,
553
580
  })) {
554
- traceSkipped(ctx, layer.id, 'dispose');
581
+ await traceSkipped(ctx, layer.id, 'dispose');
555
582
  continue;
556
583
  }
557
584
  const dispose = layer.hooks.dispose;
@@ -607,7 +634,7 @@ export async function spawnLayers({ layers, parentCtx, childCtx, store, itemSche
607
634
  layer,
608
635
  state: parentState,
609
636
  })) {
610
- traceSkipped(parentCtx, layer.id, 'onSpawn');
637
+ await traceSkipped(parentCtx, layer.id, 'onSpawn');
611
638
  continue;
612
639
  }
613
640
  const onSpawn = layer.hooks.onSpawn;
@@ -653,6 +680,9 @@ export async function spawnLayers({ layers, parentCtx, childCtx, store, itemSche
653
680
  * `spawn_summary_failed` diagnostic plus a span event on the child's span.
654
681
  */
655
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);
656
686
  const sorted = [
657
687
  ...layers,
658
688
  ].sort((a, b) => a.slot - b.slot);
@@ -667,7 +697,7 @@ export async function returnLayers({ layers, stepId, parentCtx, childCtx, childL
667
697
  // initialized state can still be seeded from the child. Skip only when the
668
698
  // child produced nothing.
669
699
  if (childState === undefined) {
670
- traceSkipped(childCtx, layer.id, 'onReturn');
700
+ await traceSkipped(childCtx, layer.id, 'onReturn');
671
701
  continue;
672
702
  }
673
703
  const onReturn = layer.hooks.onReturn;
@@ -723,7 +753,7 @@ export async function completeLayers({ layers, ctx, log, outcome, store, }) {
723
753
  layer,
724
754
  state,
725
755
  })) {
726
- traceSkipped(ctx, layer.id, 'onComplete');
756
+ await traceSkipped(ctx, layer.id, 'onComplete');
727
757
  continue;
728
758
  }
729
759
  const onComplete = layer.hooks.onComplete;
@@ -766,17 +796,24 @@ export async function beforeToolCallLayers({ layers, toolName, toolArgs, callId,
766
796
  layer,
767
797
  state,
768
798
  })) {
799
+ await traceSkipped(ctx, layer.id, 'beforeToolCall');
769
800
  continue;
770
801
  }
802
+ const beforeToolCall = layer.hooks.beforeToolCall;
771
803
  try {
772
- const timeout = layer.timeouts?.beforeToolCall ?? 5e3;
773
- const result = await withTimeout(layer.hooks.beforeToolCall({
774
- toolName,
775
- toolArgs,
776
- callId,
804
+ const result = await callHook({
777
805
  ctx,
778
- state,
779
- }), 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
+ });
780
817
  if (result.state !== undefined) {
781
818
  store.set(ctx.executionId, layer.id, result.state);
782
819
  }
@@ -819,15 +856,22 @@ export async function afterModelCallLayers({ layers, response, ctx, store, }) {
819
856
  layer,
820
857
  state,
821
858
  })) {
859
+ await traceSkipped(ctx, layer.id, 'afterModelCall');
822
860
  continue;
823
861
  }
862
+ const afterModelCall = layer.hooks.afterModelCall;
824
863
  try {
825
- const timeout = layer.timeouts?.afterModelCall ?? 1e4;
826
- const result = await withTimeout(layer.hooks.afterModelCall({
827
- response,
864
+ const result = await callHook({
828
865
  ctx,
829
- state,
830
- }), 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
+ });
831
875
  if (result.state !== undefined) {
832
876
  store.set(ctx.executionId, layer.id, result.state);
833
877
  }
@@ -867,15 +911,22 @@ export async function projectHistoryLayers({ layers, items, ctx, store, }) {
867
911
  layer,
868
912
  state,
869
913
  })) {
914
+ await traceSkipped(ctx, layer.id, 'projectHistory');
870
915
  continue;
871
916
  }
917
+ const projectHistory = layer.hooks.projectHistory;
872
918
  try {
873
- const timeout = layer.timeouts?.projectHistory ?? 5e3;
874
- const result = await withTimeout(layer.hooks.projectHistory({
875
- items: current,
919
+ const result = await callHook({
876
920
  ctx,
877
- state,
878
- }), 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
+ });
879
930
  current = result.items;
880
931
  }
881
932
  catch (e) {
@@ -913,16 +964,23 @@ export async function runAppendPipeline({ layers, items, ctx, log, store, }) {
913
964
  layer,
914
965
  state,
915
966
  })) {
967
+ await traceSkipped(ctx, layer.id, 'onItemAppend');
916
968
  continue;
917
969
  }
970
+ const onItemAppend = layer.hooks.onItemAppend;
918
971
  try {
919
- const timeout = layer.timeouts?.onItemAppend ?? DEFAULT_ON_ITEM_APPEND_TIMEOUT;
920
- const result = await withTimeout(layer.hooks.onItemAppend({
921
- items: currentItems,
922
- log,
972
+ const result = await callHook({
923
973
  ctx,
924
- state,
925
- }), 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
+ });
926
984
  // Update items for next layer in pipeline
927
985
  currentItems = result.items;
928
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,13 +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 } from './context/hook-call.js';
12
+ export { LAYER_HOOK_EVENT, LayerHookAttr } from './context/hook-call.js';
13
13
  export * from './context/layer-api.js';
14
14
  export * from './context/layer-lifecycle.js';
15
15
  export * from './context/layer-provides.js';
@@ -25,7 +25,9 @@ export * from './context/layers/steering.js';
25
25
  export * from './context/layers/task-state.js';
26
26
  export * from './context/layers/temporal.js';
27
27
  export * from './context/layers/tool-calls.js';
28
+ export type { LiveChild } from './context/parent-updates.js';
28
29
  export * from './context/projector.js';
29
30
  export * from './context/scope.js';
31
+ export * from './context/span-export.js';
30
32
  export * from './context/storage-batch.js';
31
33
  export * from './context/strip-unresolved.js';
package/dist/index.js CHANGED
@@ -11,7 +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
+ export { LAYER_HOOK_EVENT, LayerHookAttr } from './context/hook-call.js';
15
15
  export * from './context/layer-api.js';
16
16
  export * from './context/layer-lifecycle.js';
17
17
  export * from './context/layer-provides.js';
@@ -29,5 +29,6 @@ export * from './context/layers/temporal.js';
29
29
  export * from './context/layers/tool-calls.js';
30
30
  export * from './context/projector.js';
31
31
  export * from './context/scope.js';
32
+ export * from './context/span-export.js';
32
33
  export * from './context/storage-batch.js';
33
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.7.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.8.0",
47
+ "@noetic-tools/types": "^3.9.0",
48
48
  "zod": "^4.0.0"
49
49
  },
50
50
  "scripts": {