@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.
- package/LICENSE +201 -0
- package/README.md +28 -0
- package/dist/context/budget.d.ts +39 -0
- package/dist/context/budget.js +183 -0
- package/dist/context/exec-context-factory.d.ts +24 -0
- package/dist/context/exec-context-factory.js +30 -0
- package/dist/context/function-call-utils.d.ts +6 -0
- package/dist/context/function-call-utils.js +27 -0
- package/dist/context/layer-api.d.ts +11 -0
- package/dist/context/layer-api.js +91 -0
- package/dist/context/layer-lifecycle.d.ts +239 -0
- package/dist/context/layer-lifecycle.js +800 -0
- package/dist/context/layer-provides.d.ts +32 -0
- package/dist/context/layer-provides.js +33 -0
- package/dist/context/layer-usage.d.ts +13 -0
- package/dist/context/layer-usage.js +54 -0
- package/dist/context/layers/durable-task-state.d.ts +107 -0
- package/dist/context/layers/durable-task-state.js +250 -0
- package/dist/context/layers/file-reference.d.ts +55 -0
- package/dist/context/layers/file-reference.js +729 -0
- package/dist/context/layers/history-window.d.ts +31 -0
- package/dist/context/layers/history-window.js +150 -0
- package/dist/context/layers/observational-context.d.ts +56 -0
- package/dist/context/layers/observational-context.js +152 -0
- package/dist/context/layers/plan-context.d.ts +73 -0
- package/dist/context/layers/plan-context.js +749 -0
- package/dist/context/layers/static-content.d.ts +27 -0
- package/dist/context/layers/static-content.js +46 -0
- package/dist/context/layers/steering.d.ts +50 -0
- package/dist/context/layers/steering.js +291 -0
- package/dist/context/layers/temporal-context.d.ts +91 -0
- package/dist/context/layers/temporal-context.js +327 -0
- package/dist/context/layers/tool-context-layer.d.ts +15 -0
- package/dist/context/layers/tool-context-layer.js +43 -0
- package/dist/context/layers/working-memory-context.d.ts +46 -0
- package/dist/context/layers/working-memory-context.js +155 -0
- package/dist/context/projector.d.ts +19 -0
- package/dist/context/projector.js +89 -0
- package/dist/context/scope.d.ts +3 -0
- package/dist/context/scope.js +42 -0
- package/dist/context/storage-batch.d.ts +18 -0
- package/dist/context/storage-batch.js +35 -0
- package/dist/context/strip-unresolved.d.ts +13 -0
- package/dist/context/strip-unresolved.js +35 -0
- package/dist/deprecated.d.ts +43 -0
- package/dist/deprecated.js +28 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +31 -0
- 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
|