@noetic-tools/context 1.2.2 → 1.4.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.
@@ -9,6 +9,13 @@ export interface BudgetAllocation {
9
9
  * `step.projection` to match the target model's real context length.
10
10
  */
11
11
  export declare const DEFAULT_PROJECTION: ProjectionPolicy;
12
+ /**
13
+ * Default view budget for `decide` steps. Decision models degrade on long
14
+ * state — TypeSafe Jev drops to a slower, less accurate long-context mode above
15
+ * about 32 KB of text — so a decision sees a tighter window than a chat turn.
16
+ * @public
17
+ */
18
+ export declare const DECISION_PROJECTION: ProjectionPolicy;
12
19
  interface AllocateBudgetsOpts {
13
20
  layers: ContextLayer[];
14
21
  totalBudget: number;
@@ -9,6 +9,17 @@ export const DEFAULT_PROJECTION = {
9
9
  responseReserve: 4e3,
10
10
  overflow: 'sliding_window',
11
11
  };
12
+ /**
13
+ * Default view budget for `decide` steps. Decision models degrade on long
14
+ * state — TypeSafe Jev drops to a slower, less accurate long-context mode above
15
+ * about 32 KB of text — so a decision sees a tighter window than a chat turn.
16
+ * @public
17
+ */
18
+ export const DECISION_PROJECTION = {
19
+ tokenBudget: 8e3,
20
+ responseReserve: 0,
21
+ overflow: 'truncate',
22
+ };
12
23
  function extractMin(config) {
13
24
  if (config && typeof config === 'object' && 'min' in config) {
14
25
  return config.min;
@@ -1,4 +1,4 @@
1
- import type { ContextLayer, ExecutionContext, Item, ItemLog, ItemSchemaRegistry, LLMResponse, ScopedStorage, SteeringDecision, StorageAdapter } from '@noetic-tools/types';
1
+ import type { ContextLayer, DecisionRecord, ExecutionContext, Item, ItemLog, ItemSchemaRegistry, LLMResponse, ScopedStorage, SteeringDecision, StorageAdapter } from '@noetic-tools/types';
2
2
  export interface LayerStateStore {
3
3
  get<T>(executionId: string, layerId: string): T | undefined;
4
4
  set<T>(executionId: string, layerId: string, state: T): void;
@@ -42,6 +42,12 @@ interface InitLayersParams {
42
42
  ctx: ExecutionContext;
43
43
  storage: StorageAdapter;
44
44
  store: LayerStateStore;
45
+ /**
46
+ * Skip `init` for layers that already hold state for this execution. Set for
47
+ * an execution restored from a checkpoint, whose snapshot state `init` would
48
+ * otherwise overwrite with a fresh start.
49
+ */
50
+ keep?: boolean;
45
51
  }
46
52
  interface RecallLayersParams {
47
53
  layers: ContextLayer[];
@@ -51,6 +57,8 @@ interface RecallLayersParams {
51
57
  budgets: Map<string, number>;
52
58
  store: LayerStateStore;
53
59
  itemSchemas?: ItemSchemaRegistry;
60
+ /** Render without committing returned state. See `RecallOptions.peek`. */
61
+ peek?: boolean;
54
62
  }
55
63
  interface StoreLayersParams {
56
64
  layers: ContextLayer[];
@@ -104,6 +112,7 @@ interface BeforeToolCallLayersParams {
104
112
  layers: ContextLayer[];
105
113
  toolName: string;
106
114
  toolArgs: unknown;
115
+ callId?: string;
107
116
  ctx: ExecutionContext;
108
117
  store: LayerStateStore;
109
118
  }
@@ -171,8 +180,8 @@ export interface RecallCache {
171
180
  }
172
181
  export declare function createRecallCache(): RecallCache;
173
182
  export declare function mostRestrictive(decisions: SteeringDecision[]): SteeringDecision;
174
- export declare function initLayers({ layers, ctx, storage, store }: InitLayersParams): Promise<void>;
175
- export declare function recallLayers({ layers, query, ctx, log, budgets, store, itemSchemas, }: RecallLayersParams): Promise<RecallEntry[]>;
183
+ export declare function initLayers({ layers, ctx, storage, store, keep, }: InitLayersParams): Promise<void>;
184
+ export declare function recallLayers({ layers, query, ctx, log, budgets, store, itemSchemas, peek, }: RecallLayersParams): Promise<RecallEntry[]>;
176
185
  type RecallEntry = {
177
186
  layerId: string;
178
187
  items: Item[];
@@ -201,6 +210,19 @@ interface RecallLayersEventualParams extends RecallLayersModeParams {
201
210
  */
202
211
  export declare function recallLayersEventual({ cache, forceAtomic, ...params }: RecallLayersEventualParams): Promise<RecallEntry[]>;
203
212
  export declare function storeLayers({ layers, response, ctx, log, store, recallCache, }: StoreLayersParams): Promise<void>;
213
+ interface NotifyDecisionParams {
214
+ layers: ContextLayer[];
215
+ record: DecisionRecord;
216
+ ctx: ExecutionContext;
217
+ store: LayerStateStore;
218
+ recallCache?: RecallCache;
219
+ }
220
+ /**
221
+ * Run each layer's `onDecision` hook in slot order. Sequential, unlike
222
+ * `storeLayers`: decisions are small and ordered, and a later layer may read
223
+ * what an earlier one recorded.
224
+ */
225
+ export declare function notifyDecision({ layers, record, ctx, store, recallCache, }: NotifyDecisionParams): Promise<void>;
204
226
  export declare function disposeLayers({ layers, ctx, store }: DisposeLayersParams): Promise<void>;
205
227
  export declare function spawnLayers({ layers, parentCtx, childCtx, store, itemSchemas, storage, }: SpawnLayersParams): Promise<{
206
228
  layerId: string;
@@ -209,7 +231,7 @@ export declare function spawnLayers({ layers, parentCtx, childCtx, store, itemSc
209
231
  }[]>;
210
232
  export declare function returnLayers<T>({ layers, parentCtx, childCtx, childLog, result, store, }: ReturnLayersParams<T>): Promise<T>;
211
233
  export declare function completeLayers({ layers, ctx, log, outcome, store, }: CompleteLayersParams): Promise<void>;
212
- export declare function beforeToolCallLayers({ layers, toolName, toolArgs, ctx, store, }: BeforeToolCallLayersParams): Promise<SteeringDecision>;
234
+ export declare function beforeToolCallLayers({ layers, toolName, toolArgs, callId, ctx, store, }: BeforeToolCallLayersParams): Promise<SteeringDecision>;
213
235
  export declare function afterModelCallLayers({ layers, response, ctx, store, }: AfterModelCallLayersParams): Promise<SteeringDecision>;
214
236
  /**
215
237
  * Run history items through every layer's `projectHistory` hook in slot order.
@@ -179,12 +179,15 @@ function layerItemSchemas(layer, base = defaultItemSchemaRegistry) {
179
179
  }
180
180
  //#endregion
181
181
  //#region Public API
182
- export async function initLayers({ layers, ctx, storage, store }) {
182
+ export async function initLayers({ layers, ctx, storage, store, keep = false, }) {
183
183
  // Sequential, array order
184
184
  for (const layer of layers) {
185
185
  if (!layer.hooks.init) {
186
186
  continue;
187
187
  }
188
+ if (keep && store.has?.(ctx.executionId, layer.id)) {
189
+ continue;
190
+ }
188
191
  const scopeKey = resolveScopeKey(layer.scope, ctx);
189
192
  const scopedStorage = createScopedStorage(storage, layer.id, scopeKey);
190
193
  try {
@@ -236,7 +239,7 @@ function registerDurableTargets({ layers, ctx, storage, store, }) {
236
239
  store.registerDurable(ctx.executionId, layer.id, createScopedStorage(storage, layer.id, scopeKey));
237
240
  }
238
241
  }
239
- export async function recallLayers({ layers, query, ctx, log, budgets, store, itemSchemas = defaultItemSchemaRegistry, }) {
242
+ export async function recallLayers({ layers, query, ctx, log, budgets, store, itemSchemas = defaultItemSchemaRegistry, peek = false, }) {
240
243
  const sorted = [
241
244
  ...layers,
242
245
  ].sort((a, b) => a.slot - b.slot);
@@ -245,15 +248,21 @@ export async function recallLayers({ layers, query, ctx, log, budgets, store, it
245
248
  if (!layer.hooks.recall) {
246
249
  continue;
247
250
  }
248
- const state = store.get(ctx.executionId, layer.id);
251
+ const stored = store.get(ctx.executionId, layer.id);
249
252
  if (isLayerDisabled({
250
253
  store,
251
254
  executionId: ctx.executionId,
252
255
  layer,
253
- state,
256
+ state: stored,
254
257
  })) {
255
258
  continue; // was disabled
256
259
  }
260
+ // Skipping the write is not enough for a peek: a hook may mutate the
261
+ // stored object in place (steering splices its queue), so it gets a copy.
262
+ const state = peek ? copyForPeek(stored) : stored;
263
+ if (peek && state === PEEK_UNSAFE) {
264
+ continue;
265
+ }
257
266
  const budget = budgets.get(layer.id) ?? 0;
258
267
  try {
259
268
  const timeout = layer.timeouts?.recall ?? 5e3;
@@ -287,7 +296,7 @@ export async function recallLayers({ layers, query, ctx, log, budgets, store, it
287
296
  tokenCount: result.tokenCount,
288
297
  mutatedState,
289
298
  });
290
- if (mutatedState) {
299
+ if (mutatedState && !peek) {
291
300
  store.set(ctx.executionId, layer.id, result.state);
292
301
  }
293
302
  }
@@ -297,6 +306,16 @@ export async function recallLayers({ layers, query, ctx, log, budgets, store, it
297
306
  }
298
307
  return results;
299
308
  }
309
+ const PEEK_UNSAFE = Symbol('peek-unsafe');
310
+ /** A deep copy, or `PEEK_UNSAFE` when the state cannot be copied (it holds functions). */
311
+ function copyForPeek(state) {
312
+ try {
313
+ return structuredClone(state);
314
+ }
315
+ catch {
316
+ return PEEK_UNSAFE;
317
+ }
318
+ }
300
319
  /**
301
320
  * Recall the layers that must complete before the model call: those with
302
321
  * `recallMode !== 'eventual'`, or every layer when `forceAtomic` is set.
@@ -330,6 +349,17 @@ export async function recallLayersEventual({ cache, forceAtomic, ...params }) {
330
349
  const key = recallCacheKey(layer, params.ctx);
331
350
  const cached = cache.entries.get(key);
332
351
  const isStale = cache.stale.has(key);
352
+ // A peek renders a cold or stale layer but leaves the cache as it found it,
353
+ // so the next real recall still sees the entry as needing a render.
354
+ if (params.peek && (!cached || isStale)) {
355
+ results.push(...(await recallLayers({
356
+ ...params,
357
+ layers: [
358
+ layer,
359
+ ],
360
+ })));
361
+ continue;
362
+ }
333
363
  if (!cached || isStale) {
334
364
  const recalled = await recallLayers({
335
365
  ...params,
@@ -405,6 +435,47 @@ export async function storeLayers({ layers, response, ctx, log, store, recallCac
405
435
  }));
406
436
  await store.flush?.(ctx.executionId);
407
437
  }
438
+ /**
439
+ * Run each layer's `onDecision` hook in slot order. Sequential, unlike
440
+ * `storeLayers`: decisions are small and ordered, and a later layer may read
441
+ * what an earlier one recorded.
442
+ */
443
+ export async function notifyDecision({ layers, record, ctx, store, recallCache, }) {
444
+ const sorted = [
445
+ ...layers,
446
+ ].sort((a, b) => a.slot - b.slot);
447
+ for (const layer of sorted) {
448
+ if (!layer.hooks.onDecision) {
449
+ continue;
450
+ }
451
+ const state = store.get(ctx.executionId, layer.id);
452
+ if (isLayerDisabled({
453
+ store,
454
+ executionId: ctx.executionId,
455
+ layer,
456
+ state,
457
+ })) {
458
+ continue;
459
+ }
460
+ try {
461
+ const result = await withTimeout(layer.hooks.onDecision({
462
+ state,
463
+ record,
464
+ ctx,
465
+ }), layer.timeouts?.onDecision ?? 5e3);
466
+ if (result && 'state' in result) {
467
+ store.set(ctx.executionId, layer.id, result.state);
468
+ if (recallCache && layer.recallMode === 'eventual') {
469
+ recallCache.stale.add(recallCacheKey(layer, ctx));
470
+ }
471
+ }
472
+ }
473
+ catch (e) {
474
+ store.diagnostic(layer.id, 'onDecision', e);
475
+ }
476
+ }
477
+ await store.flush?.(ctx.executionId);
478
+ }
408
479
  export async function disposeLayers({ layers, ctx, store }) {
409
480
  // Sequential, REVERSE array order
410
481
  const reversed = [
@@ -566,7 +637,7 @@ export async function completeLayers({ layers, ctx, log, outcome, store, }) {
566
637
  }
567
638
  await store.flush?.(ctx.executionId);
568
639
  }
569
- export async function beforeToolCallLayers({ layers, toolName, toolArgs, ctx, store, }) {
640
+ export async function beforeToolCallLayers({ layers, toolName, toolArgs, callId, ctx, store, }) {
570
641
  const sorted = [
571
642
  ...layers,
572
643
  ].sort((a, b) => a.slot - b.slot);
@@ -589,6 +660,7 @@ export async function beforeToolCallLayers({ layers, toolName, toolArgs, ctx, st
589
660
  const result = await withTimeout(layer.hooks.beforeToolCall({
590
661
  toolName,
591
662
  toolArgs,
663
+ callId,
592
664
  ctx,
593
665
  state,
594
666
  }), timeout);
@@ -605,6 +677,15 @@ export async function beforeToolCallLayers({ layers, toolName, toolArgs, ctx, st
605
677
  throw e;
606
678
  }
607
679
  store.diagnostic(layer.id, 'beforeToolCall', e);
680
+ // A permission GATE must fail closed: a hook that threw or timed out is
681
+ // a check that did not run, and for a gating layer that means deny, not
682
+ // the silent approval an abstention would collapse into.
683
+ if (layer.onBeforeToolCallError === 'deny') {
684
+ return {
685
+ action: SteeringAction.Deny,
686
+ guidance: `tool call denied: gating layer '${layer.id}' failed (${e instanceof Error ? e.message : String(e)})`,
687
+ };
688
+ }
608
689
  }
609
690
  }
610
691
  return mostRestrictive(decisions);
@@ -0,0 +1,50 @@
1
+ import type { ContextScope, DecisionRecord, InitParams, InitResult, LayerDataDecl, OnDecisionParams, RecallParams, ReturnParams, ReturnResult, SpawnParams, SpawnResult } from '@noetic-tools/types';
2
+ /** @public Configuration for {@link decisionsHistoryLayer}. */
3
+ export interface DecisionsHistoryConfig<Id extends string = 'decisionsHistory'> {
4
+ /** Layer id, and so the `ctx.context` key. Default `'decisionsHistory'`. */
5
+ id?: Id;
6
+ /** Default `'execution'`. With `'thread'` or `'resource'`, history persists across runs. */
7
+ scope?: ContextScope;
8
+ /** Records kept; the oldest drop first. Default 50. */
9
+ maxRecords?: number;
10
+ /** Render a summary of the latest decisions into the view. Default true. */
11
+ recall?: boolean;
12
+ /** Default `Slot.WORKING_MEMORY + 10`. */
13
+ slot?: number;
14
+ }
15
+ /** @public State held by {@link decisionsHistoryLayer}: records, oldest first. */
16
+ export interface DecisionsHistoryState {
17
+ records: DecisionRecord[];
18
+ }
19
+ /** @public The layer {@link decisionsHistoryLayer} returns, with its literal id kept for `ctx.context` typing. */
20
+ export interface DecisionsHistoryLayer<Id extends string> {
21
+ readonly id: Id;
22
+ name: string;
23
+ slot: number;
24
+ scope: ContextScope;
25
+ provides: {
26
+ latest: LayerDataDecl<Readonly<Record<string, DecisionRecord>>, DecisionsHistoryState>;
27
+ all: LayerDataDecl<ReadonlyArray<DecisionRecord>, DecisionsHistoryState>;
28
+ };
29
+ hooks: {
30
+ init(params: InitParams): Promise<InitResult<DecisionsHistoryState>>;
31
+ recall(params: RecallParams<DecisionsHistoryState>): Promise<string | null>;
32
+ onDecision(params: OnDecisionParams<DecisionsHistoryState>): Promise<{
33
+ state: DecisionsHistoryState;
34
+ }>;
35
+ onSpawn(params: SpawnParams<DecisionsHistoryState>): Promise<SpawnResult<DecisionsHistoryState>>;
36
+ onReturn(params: ReturnParams<DecisionsHistoryState>): Promise<ReturnResult<DecisionsHistoryState>>;
37
+ };
38
+ }
39
+ /**
40
+ * A context layer that records every `decide` step's answers. Later steps read
41
+ * them through `ctx.context.decisionsHistory.latest[stepId]`, and later `callModel`
42
+ * steps see a one-line-per-decision summary in their view. inParallel paths and spawn
43
+ * children start with the parent's records and hand their new ones back.
44
+ *
45
+ * @public
46
+ */
47
+ export declare function decisionsHistoryLayer(config?: Omit<DecisionsHistoryConfig, 'id'>): DecisionsHistoryLayer<'decisionsHistory'>;
48
+ export declare function decisionsHistoryLayer<const Id extends string>(config: DecisionsHistoryConfig<Id> & {
49
+ id: Id;
50
+ }): DecisionsHistoryLayer<Id>;
@@ -0,0 +1,111 @@
1
+ import { frameworkCast, Slot } from '@noetic-tools/types';
2
+ import { layerData } from '../layer-provides.js';
3
+ //#endregion
4
+ //#region Public API
5
+ const DEFAULT_MAX_RECORDS = 50;
6
+ export function decisionsHistoryLayer(config) {
7
+ const maxRecords = config?.maxRecords ?? DEFAULT_MAX_RECORDS;
8
+ const renders = config?.recall ?? true;
9
+ const keep = (records) => ({
10
+ records: records.slice(-maxRecords),
11
+ });
12
+ return {
13
+ id: config?.id ?? 'decisionsHistory',
14
+ name: 'Decisions History',
15
+ slot: config?.slot ?? Slot.WORKING_MEMORY + 10,
16
+ scope: config?.scope ?? 'execution',
17
+ provides: {
18
+ latest: layerData({
19
+ read: (state) => latestByStep(state.records),
20
+ }),
21
+ all: layerData({
22
+ read: (state) => state.records,
23
+ }),
24
+ },
25
+ hooks: {
26
+ async init({ storage }) {
27
+ const saved = await storage.get('state');
28
+ return {
29
+ state: saved ?? {
30
+ records: [],
31
+ },
32
+ };
33
+ },
34
+ async recall({ state }) {
35
+ if (!renders || state.records.length === 0) {
36
+ return null;
37
+ }
38
+ return renderSummary(latestByStep(state.records));
39
+ },
40
+ async onDecision({ state, record }) {
41
+ return {
42
+ state: keep([
43
+ ...state.records,
44
+ record,
45
+ ]),
46
+ };
47
+ },
48
+ async onSpawn({ parentState }) {
49
+ return {
50
+ childState: {
51
+ records: [
52
+ ...parentState.records,
53
+ ],
54
+ },
55
+ };
56
+ },
57
+ // Concurrent fork paths each return; merging only the records the parent
58
+ // lacks keeps every sibling's decisions.
59
+ async onReturn({ parentState, childState }) {
60
+ const known = new Set(parentState.records.map(recordKey));
61
+ const added = childState.records.filter((r) => !known.has(recordKey(r)));
62
+ return {
63
+ parentState: keep([
64
+ ...parentState.records,
65
+ ...added,
66
+ ]),
67
+ };
68
+ },
69
+ },
70
+ };
71
+ }
72
+ //#endregion
73
+ //#region Helpers
74
+ function recordKey(record) {
75
+ return `${record.stepId}\u0000${record.at}\u0000${record.modelId}`;
76
+ }
77
+ function latestByStep(records) {
78
+ const latest = {};
79
+ for (const record of records) {
80
+ latest[record.stepId] = record;
81
+ }
82
+ return latest;
83
+ }
84
+ function renderSummary(latest) {
85
+ const lines = Object.values(latest).map((record) => {
86
+ const answers = Object.entries(record.answers)
87
+ .map(([id, answer]) => `${id}=${renderAnswer(answer)}`)
88
+ .join(', ');
89
+ return `- ${record.stepId}: ${answers}`;
90
+ });
91
+ return [
92
+ 'Decisions so far:',
93
+ ...lines,
94
+ ].join('\n');
95
+ }
96
+ function renderAnswer(answer) {
97
+ switch (answer.type) {
98
+ case 'boolean':
99
+ return `${answer.value ? 'yes' : 'no'} (${round(answer.probability)})`;
100
+ case 'choice':
101
+ return `${answer.value} (${round(frameworkCast(answer.probabilities)[answer.value] ?? 0)})`;
102
+ case 'score':
103
+ return `${answer.level} (${round(answer.value)})`;
104
+ case 'refusal':
105
+ return 'refused';
106
+ }
107
+ }
108
+ function round(n) {
109
+ return Math.round(n * 100) / 100;
110
+ }
111
+ //#endregion
package/dist/index.d.ts CHANGED
@@ -13,6 +13,7 @@ export * from './context/layer-api.js';
13
13
  export * from './context/layer-lifecycle.js';
14
14
  export * from './context/layer-provides.js';
15
15
  export * from './context/layer-usage.js';
16
+ export * from './context/layers/decisions-history.js';
16
17
  export * from './context/layers/filesystem.js';
17
18
  export * from './context/layers/history.js';
18
19
  export * from './context/layers/instructions.js';
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ export * from './context/layer-api.js';
15
15
  export * from './context/layer-lifecycle.js';
16
16
  export * from './context/layer-provides.js';
17
17
  export * from './context/layer-usage.js';
18
+ export * from './context/layers/decisions-history.js';
18
19
  export * from './context/layers/filesystem.js';
19
20
  export * from './context/layers/history.js';
20
21
  export * from './context/layers/instructions.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noetic-tools/context",
3
- "version": "1.2.2",
3
+ "version": "1.4.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.2.2",
47
+ "@noetic-tools/types": "^3.4.0",
48
48
  "zod": "^4.0.0"
49
49
  },
50
50
  "scripts": {