@noetic-tools/context 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +28 -0
  3. package/dist/context/budget.d.ts +39 -0
  4. package/dist/context/budget.js +183 -0
  5. package/dist/context/exec-context-factory.d.ts +24 -0
  6. package/dist/context/exec-context-factory.js +30 -0
  7. package/dist/context/function-call-utils.d.ts +6 -0
  8. package/dist/context/function-call-utils.js +27 -0
  9. package/dist/context/layer-api.d.ts +11 -0
  10. package/dist/context/layer-api.js +91 -0
  11. package/dist/context/layer-lifecycle.d.ts +239 -0
  12. package/dist/context/layer-lifecycle.js +800 -0
  13. package/dist/context/layer-provides.d.ts +32 -0
  14. package/dist/context/layer-provides.js +33 -0
  15. package/dist/context/layer-usage.d.ts +13 -0
  16. package/dist/context/layer-usage.js +54 -0
  17. package/dist/context/layers/durable-task-state.d.ts +107 -0
  18. package/dist/context/layers/durable-task-state.js +250 -0
  19. package/dist/context/layers/file-reference.d.ts +55 -0
  20. package/dist/context/layers/file-reference.js +729 -0
  21. package/dist/context/layers/history-window.d.ts +31 -0
  22. package/dist/context/layers/history-window.js +150 -0
  23. package/dist/context/layers/observational-context.d.ts +56 -0
  24. package/dist/context/layers/observational-context.js +152 -0
  25. package/dist/context/layers/plan-context.d.ts +73 -0
  26. package/dist/context/layers/plan-context.js +749 -0
  27. package/dist/context/layers/static-content.d.ts +27 -0
  28. package/dist/context/layers/static-content.js +46 -0
  29. package/dist/context/layers/steering.d.ts +50 -0
  30. package/dist/context/layers/steering.js +291 -0
  31. package/dist/context/layers/temporal-context.d.ts +91 -0
  32. package/dist/context/layers/temporal-context.js +327 -0
  33. package/dist/context/layers/tool-context-layer.d.ts +15 -0
  34. package/dist/context/layers/tool-context-layer.js +43 -0
  35. package/dist/context/layers/working-memory-context.d.ts +46 -0
  36. package/dist/context/layers/working-memory-context.js +155 -0
  37. package/dist/context/projector.d.ts +19 -0
  38. package/dist/context/projector.js +89 -0
  39. package/dist/context/scope.d.ts +3 -0
  40. package/dist/context/scope.js +42 -0
  41. package/dist/context/storage-batch.d.ts +18 -0
  42. package/dist/context/storage-batch.js +35 -0
  43. package/dist/context/strip-unresolved.d.ts +13 -0
  44. package/dist/context/strip-unresolved.js +35 -0
  45. package/dist/deprecated.d.ts +43 -0
  46. package/dist/deprecated.js +28 -0
  47. package/dist/index.d.ts +29 -0
  48. package/dist/index.js +31 -0
  49. package/package.json +63 -0
@@ -0,0 +1,800 @@
1
+ import { createMessage, defaultItemSchemaRegistry, estimateTokens, frameworkCast, NoeticConfigError, SteeringAction, } from '@noetic-tools/types';
2
+ import { createScopedStorage, resolveScopeKey } from './scope.js';
3
+ /** Maximum allowed re-render depth to prevent infinite loops */
4
+ const MAX_RERENDER_DEPTH = 3;
5
+ export function createLayerStateStore(diagnostic) {
6
+ const states = new Map();
7
+ const durables = new Map();
8
+ const disabled = new Map();
9
+ const report = diagnostic ?? (() => { });
10
+ // One write loop per key: re-checks `dirty` after each write so a value set
11
+ // mid-write is persisted by the same loop (coalesced, latest wins). Failures
12
+ // are diagnostics only — durable mirroring must never break the agent.
13
+ async function writeThrough(entry) {
14
+ while (entry.dirty) {
15
+ entry.dirty = false;
16
+ const value = entry.latest;
17
+ try {
18
+ if (value === undefined) {
19
+ await entry.target.delete('state');
20
+ }
21
+ else {
22
+ await entry.target.set('state', value);
23
+ }
24
+ }
25
+ catch (e) {
26
+ report(entry.layerId, 'persist', e);
27
+ }
28
+ }
29
+ entry.inFlight = null;
30
+ }
31
+ return {
32
+ get(executionId, layerId) {
33
+ return frameworkCast(states.get(executionId)?.get(layerId));
34
+ },
35
+ set(executionId, layerId, state) {
36
+ let execMap = states.get(executionId);
37
+ if (!execMap) {
38
+ execMap = new Map();
39
+ states.set(executionId, execMap);
40
+ }
41
+ execMap.set(layerId, state);
42
+ const entry = durables.get(executionId)?.get(layerId);
43
+ if (!entry) {
44
+ return;
45
+ }
46
+ entry.latest = state;
47
+ entry.dirty = true;
48
+ if (!entry.inFlight) {
49
+ entry.inFlight = writeThrough(entry);
50
+ }
51
+ },
52
+ has(executionId, layerId) {
53
+ return states.get(executionId)?.has(layerId) ?? false;
54
+ },
55
+ registerDurable(executionId, layerId, target) {
56
+ let execMap = durables.get(executionId);
57
+ if (!execMap) {
58
+ execMap = new Map();
59
+ durables.set(executionId, execMap);
60
+ }
61
+ execMap.set(layerId, {
62
+ layerId,
63
+ target,
64
+ latest: undefined,
65
+ dirty: false,
66
+ inFlight: null,
67
+ });
68
+ },
69
+ async flush(executionId) {
70
+ const execMap = durables.get(executionId);
71
+ if (!execMap) {
72
+ return;
73
+ }
74
+ await Promise.all([
75
+ ...execMap.values(),
76
+ ].map((entry) => entry.inFlight ?? Promise.resolve()));
77
+ },
78
+ disable(executionId, layerId) {
79
+ let set = disabled.get(executionId);
80
+ if (!set) {
81
+ set = new Set();
82
+ disabled.set(executionId, set);
83
+ }
84
+ set.add(layerId);
85
+ },
86
+ isDisabled(executionId, layerId) {
87
+ return disabled.get(executionId)?.has(layerId) ?? false;
88
+ },
89
+ cleanup(executionId) {
90
+ states.delete(executionId);
91
+ durables.delete(executionId);
92
+ disabled.delete(executionId);
93
+ },
94
+ diagnostic: report,
95
+ };
96
+ }
97
+ /**
98
+ * Whether a layer must be skipped for this execution — either explicitly
99
+ * disabled (its `init` failed with `onInitError: 'disable'`) or never
100
+ * initialized (init-bearing layer whose `init` did not run for this
101
+ * execution, e.g. a bare `harness.run()` outside a session). A layer that
102
+ * legitimately cleared its state (`{ state: undefined }`) has a state ENTRY
103
+ * (see `LayerStateStore.has`) and keeps running with `undefined` state.
104
+ * Legacy stores without `has` fall back to the old sentinel — an init-bearing
105
+ * layer with no state — which cannot make that distinction.
106
+ */
107
+ function isLayerDisabled({ store, executionId, layer, state }) {
108
+ if (store.isDisabled?.(executionId, layer.id)) {
109
+ return true;
110
+ }
111
+ if (state !== undefined || layer.hooks.init === undefined) {
112
+ return false;
113
+ }
114
+ // Init-bearing layer with no state in hand: skip when no entry was ever
115
+ // written (uninitialized); keep running when the state was explicitly
116
+ // cleared. Stores without `has` cannot tell the two apart → legacy skip.
117
+ if (store.has) {
118
+ return !store.has(executionId, layer.id);
119
+ }
120
+ return true;
121
+ }
122
+ export function createRecallCache() {
123
+ return {
124
+ entries: new Map(),
125
+ stale: new Set(),
126
+ };
127
+ }
128
+ /**
129
+ * Cache key for eventual recall. Keyed by the layer's SCOPE key (not the
130
+ * per-turn executionId) so the cache survives across turns within a session —
131
+ * matching where the layer's state lives. An 'execution'-scoped layer's key
132
+ * rotates each turn (so its eventual recall behaves like atomic), which is
133
+ * correct since its state does not persist either.
134
+ */
135
+ function recallCacheKey(layer, ctx) {
136
+ return `${resolveScopeKey(layer.scope, ctx)}:${layer.id}`;
137
+ }
138
+ //#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
+ export function mostRestrictive(decisions) {
152
+ const guidances = [];
153
+ let hasGuide = false;
154
+ for (const d of decisions) {
155
+ if (d.action === SteeringAction.Deny) {
156
+ return d;
157
+ }
158
+ if (d.action === SteeringAction.Guide) {
159
+ hasGuide = true;
160
+ // Collect every non-empty guidance — a guidance-less Guide must never
161
+ // overwrite/drop guidance carried by another Guide.
162
+ if (d.guidance) {
163
+ guidances.push(d.guidance);
164
+ }
165
+ }
166
+ }
167
+ if (!hasGuide) {
168
+ return {
169
+ action: SteeringAction.Allow,
170
+ };
171
+ }
172
+ return {
173
+ action: SteeringAction.Guide,
174
+ guidance: guidances.length > 0 ? guidances.join('\n') : undefined,
175
+ };
176
+ }
177
+ function layerItemSchemas(layer, base = defaultItemSchemaRegistry) {
178
+ return base.extend(layer.itemSchemas);
179
+ }
180
+ //#endregion
181
+ //#region Public API
182
+ export async function initLayers({ layers, ctx, storage, store }) {
183
+ // Sequential, array order
184
+ for (const layer of layers) {
185
+ if (!layer.hooks.init) {
186
+ continue;
187
+ }
188
+ const scopeKey = resolveScopeKey(layer.scope, ctx);
189
+ const scopedStorage = createScopedStorage(storage, layer.id, scopeKey);
190
+ try {
191
+ const timeout = layer.timeouts?.init ?? 1e4;
192
+ const result = await withTimeout(layer.hooks.init({
193
+ storage: scopedStorage,
194
+ scopeKey,
195
+ ctx,
196
+ }), timeout);
197
+ store.set(ctx.executionId, layer.id, result.state);
198
+ }
199
+ catch (e) {
200
+ store.diagnostic(layer.id, 'init', e);
201
+ // Fail loud by default: a failed init silently disabling a load-bearing
202
+ // layer (e.g. steering → fail-open) hides real errors. Opt into graceful
203
+ // degradation per layer with `onInitError: 'disable'`.
204
+ if (layer.onInitError !== 'disable') {
205
+ throw e;
206
+ }
207
+ store.disable?.(ctx.executionId, layer.id);
208
+ }
209
+ }
210
+ registerDurableTargets({
211
+ layers,
212
+ ctx,
213
+ storage,
214
+ store,
215
+ });
216
+ }
217
+ /**
218
+ * Register durable write-through targets for every non-execution-scope layer.
219
+ * A separate pass, independent of `hooks.init`, run AFTER init's `set()` so
220
+ * freshly-rehydrated state is not immediately rewritten to storage. From this
221
+ * point on, ALL state writes through the store — provides functions,
222
+ * beforeToolCall/afterModelCall, onComplete, onReturn, the append pipeline,
223
+ * and `store()` hooks — are mirrored durably; `store()` is not special.
224
+ * 'execution' scope is excluded: its scope key rotates each run, so there is
225
+ * nothing durable to mirror.
226
+ */
227
+ function registerDurableTargets({ layers, ctx, storage, store, }) {
228
+ if (!store.registerDurable) {
229
+ return;
230
+ }
231
+ for (const layer of layers) {
232
+ if (layer.scope === 'execution') {
233
+ continue;
234
+ }
235
+ const scopeKey = resolveScopeKey(layer.scope, ctx);
236
+ store.registerDurable(ctx.executionId, layer.id, createScopedStorage(storage, layer.id, scopeKey));
237
+ }
238
+ }
239
+ export async function recallLayers({ layers, query, ctx, log, budgets, store, itemSchemas = defaultItemSchemaRegistry, }) {
240
+ const sorted = [
241
+ ...layers,
242
+ ].sort((a, b) => a.slot - b.slot);
243
+ const results = [];
244
+ for (const layer of sorted) {
245
+ if (!layer.hooks.recall) {
246
+ continue;
247
+ }
248
+ const state = store.get(ctx.executionId, layer.id);
249
+ if (isLayerDisabled({
250
+ store,
251
+ executionId: ctx.executionId,
252
+ layer,
253
+ state,
254
+ })) {
255
+ continue; // was disabled
256
+ }
257
+ const budget = budgets.get(layer.id) ?? 0;
258
+ try {
259
+ const timeout = layer.timeouts?.recall ?? 5e3;
260
+ const result = await withTimeout(layer.hooks.recall({
261
+ log,
262
+ query,
263
+ ctx,
264
+ state,
265
+ budget,
266
+ }), timeout);
267
+ if (!result) {
268
+ continue;
269
+ }
270
+ const layerSchemas = layerItemSchemas(layer, itemSchemas);
271
+ if (typeof result === 'string') {
272
+ results.push({
273
+ layerId: layer.id,
274
+ items: [
275
+ layerSchemas.parseWithCategory(createMessage(result, 'developer'), 'developerMessages'),
276
+ ],
277
+ tokenCount: estimateTokens(result),
278
+ });
279
+ continue;
280
+ }
281
+ results.push({
282
+ layerId: layer.id,
283
+ items: layerSchemas.parseMany(result.items),
284
+ tokenCount: result.tokenCount,
285
+ });
286
+ if (result.state !== undefined) {
287
+ store.set(ctx.executionId, layer.id, result.state);
288
+ }
289
+ }
290
+ catch (e) {
291
+ store.diagnostic(layer.id, 'recall', e);
292
+ }
293
+ }
294
+ return results;
295
+ }
296
+ /**
297
+ * Recall the layers that must complete before the model call: those with
298
+ * `recallMode !== 'eventual'`, or every layer when `forceAtomic` is set.
299
+ */
300
+ export async function recallLayersAtomic({ forceAtomic, ...params }) {
301
+ const atomicLayers = forceAtomic
302
+ ? params.layers
303
+ : params.layers.filter((l) => l.recallMode !== 'eventual');
304
+ return recallLayers({
305
+ ...params,
306
+ layers: atomicLayers,
307
+ });
308
+ }
309
+ /**
310
+ * Recall `recallMode: 'eventual'` layers, served from {@link RecallCache}.
311
+ * A cold or store-invalidated (stale) entry is recalled and cached; a warm
312
+ * entry is returned as-is, so an eventual layer's `recall()` runs only after
313
+ * its `store()` produces new state. Returns nothing when `forceAtomic` is set —
314
+ * those layers are then handled by {@link recallLayersAtomic} instead.
315
+ */
316
+ export async function recallLayersEventual({ cache, forceAtomic, ...params }) {
317
+ if (forceAtomic) {
318
+ return [];
319
+ }
320
+ const eventualLayers = params.layers.filter((l) => l.recallMode === 'eventual');
321
+ if (eventualLayers.length === 0) {
322
+ return [];
323
+ }
324
+ const results = [];
325
+ for (const layer of eventualLayers) {
326
+ const key = recallCacheKey(layer, params.ctx);
327
+ const cached = cache.entries.get(key);
328
+ const isStale = cache.stale.has(key);
329
+ if (!cached || isStale) {
330
+ const recalled = await recallLayers({
331
+ ...params,
332
+ layers: [
333
+ layer,
334
+ ],
335
+ });
336
+ for (const entry of recalled) {
337
+ cache.entries.set(key, entry);
338
+ results.push(entry);
339
+ }
340
+ cache.stale.delete(key);
341
+ continue;
342
+ }
343
+ results.push(cached);
344
+ }
345
+ return results;
346
+ }
347
+ export async function storeLayers({ layers, response, ctx, log, store, recallCache, }) {
348
+ // Concurrent via Promise.allSettled — each layer gets its own state snapshot
349
+ const snapshots = [];
350
+ for (const layer of layers) {
351
+ if (!layer.hooks.store) {
352
+ continue;
353
+ }
354
+ const state = store.get(ctx.executionId, layer.id);
355
+ if (isLayerDisabled({
356
+ store,
357
+ executionId: ctx.executionId,
358
+ layer,
359
+ state,
360
+ })) {
361
+ continue;
362
+ }
363
+ snapshots.push({
364
+ layer,
365
+ state,
366
+ storeFn: layer.hooks.store,
367
+ });
368
+ }
369
+ await Promise.allSettled(snapshots.map(async ({ layer, state, storeFn }) => {
370
+ const timeout = layer.timeouts?.store ?? 3e4;
371
+ try {
372
+ const result = await withTimeout(storeFn({
373
+ newItems: response.items,
374
+ log,
375
+ response,
376
+ ctx,
377
+ state,
378
+ }), timeout);
379
+ // `'state' in result` (not `!== undefined`) so a layer can explicitly
380
+ // clear its state by returning `{ state: undefined }`. The store's
381
+ // durable write-through mirrors the write (clearing deletes the
382
+ // durable key) — no inline mirror needed here.
383
+ if (result && 'state' in result) {
384
+ store.set(ctx.executionId, layer.id, result.state);
385
+ // Invalidate this layer's eventual-recall cache so the next turn
386
+ // re-runs recall() against the freshly stored state.
387
+ if (recallCache && layer.recallMode === 'eventual') {
388
+ recallCache.stale.add(recallCacheKey(layer, ctx));
389
+ }
390
+ }
391
+ }
392
+ catch (e) {
393
+ store.diagnostic(layer.id, 'store', e);
394
+ }
395
+ }));
396
+ await store.flush?.(ctx.executionId);
397
+ }
398
+ export async function disposeLayers({ layers, ctx, store }) {
399
+ // Sequential, REVERSE array order
400
+ const reversed = [
401
+ ...layers,
402
+ ].reverse();
403
+ for (const layer of reversed) {
404
+ if (!layer.hooks.dispose) {
405
+ continue;
406
+ }
407
+ const state = store.get(ctx.executionId, layer.id);
408
+ // Skip disabled layers (init hook present but no state) — consistent with
409
+ // recall/store; nothing was initialized, so there is nothing to dispose.
410
+ if (isLayerDisabled({
411
+ store,
412
+ executionId: ctx.executionId,
413
+ layer,
414
+ state,
415
+ })) {
416
+ continue;
417
+ }
418
+ try {
419
+ const timeout = layer.timeouts?.dispose ?? 5e3;
420
+ await withTimeout(layer.hooks.dispose({
421
+ state,
422
+ }), timeout);
423
+ }
424
+ catch (e) {
425
+ store.diagnostic(layer.id, 'dispose', e);
426
+ }
427
+ }
428
+ // Settle any in-flight durable mirror writes before dropping registrations.
429
+ await store.flush?.(ctx.executionId);
430
+ store.cleanup(ctx.executionId);
431
+ }
432
+ export async function spawnLayers({ layers, parentCtx, childCtx, store, itemSchemas = defaultItemSchemaRegistry, storage, }) {
433
+ if (storage) {
434
+ registerDurableTargets({
435
+ layers,
436
+ ctx: childCtx,
437
+ storage,
438
+ store,
439
+ });
440
+ }
441
+ const results = [];
442
+ const sorted = [
443
+ ...layers,
444
+ ].sort((a, b) => a.slot - b.slot);
445
+ for (const layer of sorted) {
446
+ if (!layer.hooks.onSpawn) {
447
+ continue;
448
+ }
449
+ const parentState = store.get(parentCtx.executionId, layer.id);
450
+ // Skip only disabled layers (init present but no state). An init-less layer
451
+ // has legitimately undefined state and should still spawn — consistent with
452
+ // how recall treats init-less layers.
453
+ if (isLayerDisabled({
454
+ store,
455
+ executionId: parentCtx.executionId,
456
+ layer,
457
+ state: parentState,
458
+ })) {
459
+ continue;
460
+ }
461
+ try {
462
+ const timeout = layer.timeouts?.onSpawn ?? 1e4;
463
+ const result = await withTimeout(layer.hooks.onSpawn({
464
+ parentState,
465
+ childCtx,
466
+ }), timeout);
467
+ if (result && result.childState !== null) {
468
+ store.set(childCtx.executionId, layer.id, result.childState);
469
+ results.push({
470
+ layerId: layer.id,
471
+ childState: result.childState,
472
+ items: layerItemSchemas(layer, itemSchemas).parseMany(result.items ?? []),
473
+ });
474
+ }
475
+ }
476
+ catch (e) {
477
+ store.diagnostic(layer.id, 'onSpawn', e);
478
+ }
479
+ }
480
+ return results;
481
+ }
482
+ export async function returnLayers({ layers, parentCtx, childCtx, childLog, result, store, }) {
483
+ const sorted = [
484
+ ...layers,
485
+ ].sort((a, b) => a.slot - b.slot);
486
+ let currentResult = result;
487
+ for (const layer of sorted) {
488
+ if (!layer.hooks.onReturn) {
489
+ continue;
490
+ }
491
+ const childState = store.get(childCtx.executionId, layer.id);
492
+ const parentState = store.get(parentCtx.executionId, layer.id);
493
+ // Only the child's contribution is required to merge; a parent that never
494
+ // initialized state can still be seeded from the child. Skip only when the
495
+ // child produced nothing.
496
+ if (childState === undefined) {
497
+ continue;
498
+ }
499
+ try {
500
+ const timeout = layer.timeouts?.onReturn ?? 1e4;
501
+ const returnResult = await withTimeout(layer.hooks.onReturn({
502
+ childState,
503
+ childLog,
504
+ parentState,
505
+ result: currentResult,
506
+ childCtx,
507
+ }), timeout);
508
+ if (returnResult?.parentState !== undefined) {
509
+ store.set(parentCtx.executionId, layer.id, returnResult.parentState);
510
+ }
511
+ if (returnResult?.result !== undefined) {
512
+ currentResult = returnResult.result;
513
+ }
514
+ }
515
+ catch (e) {
516
+ store.diagnostic(layer.id, 'onReturn', e);
517
+ }
518
+ }
519
+ // The child execution's store is typically cleaned up right after onReturn —
520
+ // settle both sides' durable mirror writes before that happens.
521
+ await store.flush?.(parentCtx.executionId);
522
+ await store.flush?.(childCtx.executionId);
523
+ return frameworkCast(currentResult);
524
+ }
525
+ export async function completeLayers({ layers, ctx, log, outcome, store, }) {
526
+ for (const layer of layers) {
527
+ if (!layer.hooks.onComplete) {
528
+ continue;
529
+ }
530
+ const state = store.get(ctx.executionId, layer.id);
531
+ // Skip disabled layers (init present but no state) — consistent with the
532
+ // rest of the lifecycle; avoids invoking onComplete with undefined state.
533
+ if (isLayerDisabled({
534
+ store,
535
+ executionId: ctx.executionId,
536
+ layer,
537
+ state,
538
+ })) {
539
+ continue;
540
+ }
541
+ try {
542
+ const timeout = layer.timeouts?.onComplete ?? 3e4;
543
+ const result = await withTimeout(layer.hooks.onComplete({
544
+ log,
545
+ ctx,
546
+ state,
547
+ outcome,
548
+ }), timeout);
549
+ if (result && 'state' in result) {
550
+ store.set(ctx.executionId, layer.id, result.state);
551
+ }
552
+ }
553
+ catch (e) {
554
+ store.diagnostic(layer.id, 'onComplete', e);
555
+ }
556
+ }
557
+ await store.flush?.(ctx.executionId);
558
+ }
559
+ export async function beforeToolCallLayers({ layers, toolName, toolArgs, ctx, store, }) {
560
+ const sorted = [
561
+ ...layers,
562
+ ].sort((a, b) => a.slot - b.slot);
563
+ const decisions = [];
564
+ for (const layer of sorted) {
565
+ if (!layer.hooks.beforeToolCall) {
566
+ continue;
567
+ }
568
+ const state = store.get(ctx.executionId, layer.id);
569
+ if (isLayerDisabled({
570
+ store,
571
+ executionId: ctx.executionId,
572
+ layer,
573
+ state,
574
+ })) {
575
+ continue;
576
+ }
577
+ try {
578
+ const timeout = layer.timeouts?.beforeToolCall ?? 5e3;
579
+ const result = await withTimeout(layer.hooks.beforeToolCall({
580
+ toolName,
581
+ toolArgs,
582
+ ctx,
583
+ state,
584
+ }), timeout);
585
+ if (result.state !== undefined) {
586
+ store.set(ctx.executionId, layer.id, result.state);
587
+ }
588
+ if (result.decision.action === SteeringAction.Deny) {
589
+ return result.decision;
590
+ }
591
+ decisions.push(result.decision);
592
+ }
593
+ catch (e) {
594
+ if (e instanceof NoeticConfigError) {
595
+ throw e;
596
+ }
597
+ store.diagnostic(layer.id, 'beforeToolCall', e);
598
+ }
599
+ }
600
+ return mostRestrictive(decisions);
601
+ }
602
+ export async function afterModelCallLayers({ layers, response, ctx, store, }) {
603
+ const sorted = [
604
+ ...layers,
605
+ ].sort((a, b) => a.slot - b.slot);
606
+ const decisions = [];
607
+ for (const layer of sorted) {
608
+ if (!layer.hooks.afterModelCall) {
609
+ continue;
610
+ }
611
+ const state = store.get(ctx.executionId, layer.id);
612
+ if (isLayerDisabled({
613
+ store,
614
+ executionId: ctx.executionId,
615
+ layer,
616
+ state,
617
+ })) {
618
+ continue;
619
+ }
620
+ try {
621
+ const timeout = layer.timeouts?.afterModelCall ?? 1e4;
622
+ const result = await withTimeout(layer.hooks.afterModelCall({
623
+ response,
624
+ ctx,
625
+ state,
626
+ }), timeout);
627
+ if (result.state !== undefined) {
628
+ store.set(ctx.executionId, layer.id, result.state);
629
+ }
630
+ if (result.decision.action === SteeringAction.Deny) {
631
+ return result.decision;
632
+ }
633
+ decisions.push(result.decision);
634
+ }
635
+ catch (e) {
636
+ if (e instanceof NoeticConfigError) {
637
+ throw e;
638
+ }
639
+ store.diagnostic(layer.id, 'afterModelCall', e);
640
+ }
641
+ }
642
+ return mostRestrictive(decisions);
643
+ }
644
+ /**
645
+ * Run history items through every layer's `projectHistory` hook in slot order.
646
+ * Each layer receives the previous layer's output. Storage (`itemLog`) is
647
+ * never mutated — this is a pure projection over the input array. Returns
648
+ * the projected items unchanged when no layer registers the hook.
649
+ */
650
+ export async function projectHistoryLayers({ layers, items, ctx, store, }) {
651
+ const sorted = [
652
+ ...layers,
653
+ ].sort((a, b) => a.slot - b.slot);
654
+ let current = items;
655
+ for (const layer of sorted) {
656
+ if (!layer.hooks.projectHistory) {
657
+ continue;
658
+ }
659
+ const state = store.get(ctx.executionId, layer.id);
660
+ if (isLayerDisabled({
661
+ store,
662
+ executionId: ctx.executionId,
663
+ layer,
664
+ state,
665
+ })) {
666
+ continue;
667
+ }
668
+ try {
669
+ const timeout = layer.timeouts?.projectHistory ?? 5e3;
670
+ const result = await withTimeout(layer.hooks.projectHistory({
671
+ items: current,
672
+ ctx,
673
+ state,
674
+ }), timeout);
675
+ current = result.items;
676
+ }
677
+ catch (e) {
678
+ store.diagnostic(layer.id, 'projectHistory', e);
679
+ }
680
+ }
681
+ return current;
682
+ }
683
+ const DEFAULT_ON_ITEM_APPEND_TIMEOUT = 5e3;
684
+ /**
685
+ * Run items through the onItemAppend pipeline.
686
+ * Each layer can filter, transform, or inject items.
687
+ * Returns final items to append and any re-render requests.
688
+ */
689
+ export async function runAppendPipeline({ layers, items, ctx, log, store, }) {
690
+ const requests = [];
691
+ let currentItems = items;
692
+ // Run pipeline in slot order
693
+ const sorted = [
694
+ ...layers,
695
+ ].sort((a, b) => a.slot - b.slot);
696
+ for (const layer of sorted) {
697
+ if (!layer.hooks.onItemAppend) {
698
+ continue;
699
+ }
700
+ // Skip if no items left (all filtered by previous layer)
701
+ if (currentItems.length === 0) {
702
+ break;
703
+ }
704
+ const state = store.get(ctx.executionId, layer.id);
705
+ // Skip if layer was disabled (has init but no state)
706
+ if (isLayerDisabled({
707
+ store,
708
+ executionId: ctx.executionId,
709
+ layer,
710
+ state,
711
+ })) {
712
+ continue;
713
+ }
714
+ try {
715
+ const timeout = layer.timeouts?.onItemAppend ?? DEFAULT_ON_ITEM_APPEND_TIMEOUT;
716
+ const result = await withTimeout(layer.hooks.onItemAppend({
717
+ items: currentItems,
718
+ log,
719
+ ctx,
720
+ state,
721
+ }), timeout);
722
+ // Update items for next layer in pipeline
723
+ currentItems = result.items;
724
+ // Update state if provided
725
+ if (result.state !== undefined) {
726
+ store.set(ctx.executionId, layer.id, result.state);
727
+ }
728
+ // Collect re-render request if present
729
+ if (result.rerender) {
730
+ requests.push({
731
+ layerId: layer.id,
732
+ slot: layer.slot,
733
+ timing: result.timing ?? layer.rerenderTiming ?? 'batched',
734
+ scope: result.scope ?? 'slot-after',
735
+ });
736
+ }
737
+ }
738
+ catch (e) {
739
+ store.diagnostic(layer.id, 'onItemAppend', e);
740
+ // On error, pass through items unchanged for this layer
741
+ }
742
+ }
743
+ return {
744
+ items: currentItems,
745
+ rerenderRequests: requests,
746
+ };
747
+ }
748
+ /**
749
+ * Execute re-render based on collected requests.
750
+ * Determines which layers need re-recall based on scope and runs them.
751
+ * Returns new recall results to merge into view.
752
+ */
753
+ export async function executeRerender({ requests, layers, ctx, log, budgets, store, itemSchemas, query = '', depth = 0, }) {
754
+ if (requests.length === 0) {
755
+ return [];
756
+ }
757
+ // Prevent infinite re-render loops
758
+ if (depth >= MAX_RERENDER_DEPTH) {
759
+ // Log warning and return empty - don't throw to avoid breaking the flow
760
+ console.warn(`[noetic] Re-render depth exceeded (${depth} >= ${MAX_RERENDER_DEPTH}). ` +
761
+ 'Possible infinite re-render loop detected. Stopping re-render cascade.');
762
+ return [];
763
+ }
764
+ // Determine which layers need re-recall based on scope
765
+ const layersToRecall = new Set();
766
+ for (const req of requests) {
767
+ switch (req.scope) {
768
+ case 'self':
769
+ layersToRecall.add(req.layerId);
770
+ break;
771
+ case 'slot-after':
772
+ for (const layer of layers) {
773
+ if (layer.slot >= req.slot) {
774
+ layersToRecall.add(layer.id);
775
+ }
776
+ }
777
+ break;
778
+ case 'all':
779
+ for (const layer of layers) {
780
+ layersToRecall.add(layer.id);
781
+ }
782
+ break;
783
+ }
784
+ }
785
+ // Re-run recall for affected layers
786
+ const affectedLayers = layers
787
+ .filter((l) => layersToRecall.has(l.id))
788
+ .sort((a, b) => a.slot - b.slot);
789
+ // Use existing recallLayers with filtered layers
790
+ return recallLayers({
791
+ layers: affectedLayers,
792
+ query,
793
+ ctx,
794
+ log,
795
+ budgets,
796
+ store,
797
+ itemSchemas,
798
+ });
799
+ }
800
+ //#endregion