@noetic-tools/context 1.1.0 → 1.2.1
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/README.md +2 -3
- package/dist/context/cache-anchoring.d.ts +103 -0
- package/dist/context/cache-anchoring.js +273 -0
- package/dist/context/layer-lifecycle.d.ts +3 -5
- package/dist/context/layer-lifecycle.js +12 -2
- package/dist/context/layer-provides.d.ts +1 -1
- package/dist/context/layer-provides.js +1 -1
- package/dist/context/layer-usage.d.ts +18 -3
- package/dist/context/layer-usage.js +10 -1
- package/dist/context/layers/{file-reference.d.ts → filesystem.d.ts} +1 -1
- package/dist/context/layers/{file-reference.js → filesystem.js} +60 -3
- package/dist/context/layers/{history-window.d.ts → history.d.ts} +4 -4
- package/dist/context/layers/{history-window.js → history.js} +6 -6
- package/dist/context/layers/{static-content.d.ts → instructions.d.ts} +3 -2
- package/dist/context/layers/{static-content.js → instructions.js} +5 -2
- package/dist/context/layers/{observational-context.d.ts → observations.d.ts} +12 -12
- package/dist/context/layers/{observational-context.js → observations.js} +7 -7
- package/dist/context/layers/plan-prompts.d.ts +1 -1
- package/dist/context/layers/plan-prompts.js +16 -16
- package/dist/context/layers/plan-state.d.ts +31 -0
- package/dist/context/layers/plan-state.js +12 -0
- package/dist/context/layers/{plan-context.d.ts → plan.d.ts} +6 -28
- package/dist/context/layers/plan.js +697 -0
- package/dist/context/layers/{working-memory-context.d.ts → scratchpad.d.ts} +13 -13
- package/dist/context/layers/{working-memory-context.js → scratchpad.js} +8 -8
- package/dist/context/layers/steering.d.ts +1 -0
- package/dist/context/layers/steering.js +6 -1
- package/dist/context/layers/{durable-task-state.d.ts → task-state.d.ts} +20 -20
- package/dist/context/layers/{durable-task-state.js → task-state.js} +9 -9
- package/dist/context/layers/{temporal-context.d.ts → temporal.d.ts} +5 -5
- package/dist/context/layers/{temporal-context.js → temporal.js} +11 -5
- package/dist/context/layers/{tool-context-layer.d.ts → tool-calls.d.ts} +2 -2
- package/dist/context/layers/{tool-context-layer.js → tool-calls.js} +1 -1
- package/dist/context/projector.d.ts +27 -8
- package/dist/context/projector.js +42 -13
- package/dist/context/strip-unresolved.d.ts +1 -1
- package/dist/context/strip-unresolved.js +1 -1
- package/dist/index.d.ts +10 -10
- package/dist/index.js +10 -10
- package/package.json +2 -2
- package/dist/context/layers/plan-context.js +0 -683
- package/dist/deprecated.d.ts +0 -43
- package/dist/deprecated.js +0 -28
package/README.md
CHANGED
|
@@ -11,9 +11,8 @@ provides:
|
|
|
11
11
|
- The **lifecycle, budget, and projection machinery** that converges layer
|
|
12
12
|
outputs into the assembled LLM context (`assembleView`, `allocateBudgets`,
|
|
13
13
|
layer state stores, scoping).
|
|
14
|
-
- The **built-in layers**:
|
|
15
|
-
|
|
16
|
-
task state, and the tool context layer.
|
|
14
|
+
- The **built-in layers**: instructions, history, scratchpad, observations,
|
|
15
|
+
temporal, filesystem, plan, task state, tool calls, and steering.
|
|
17
16
|
|
|
18
17
|
It depends only on [`@noetic-tools/types`](https://www.npmjs.com/package/@noetic-tools/types).
|
|
19
18
|
[`@noetic-tools/core`](https://www.npmjs.com/package/@noetic-tools/core) builds
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { AnchorPin, ContextCacheConfig, ContextCacheStore, ContextEpoch, ContextLayer, ExecutionContext, LayerChurn, LLMResponse, ReanchorReason } from '@noetic-tools/types';
|
|
2
|
+
export type { AnchorPin, ContextCacheConfig, ContextCacheStore, ContextEpoch, LayerChurn, ReanchorReason, } from '@noetic-tools/types';
|
|
3
|
+
/** Defaults for prompt-cache anchoring. See `ContextCacheConfig` for what each one does. */
|
|
4
|
+
export declare const DEFAULT_CONTEXT_CACHE: Required<ContextCacheConfig>;
|
|
5
|
+
/** Fills a partial config with the defaults. */
|
|
6
|
+
export declare function resolveCacheConfig(config?: ContextCacheConfig): Required<ContextCacheConfig>;
|
|
7
|
+
/**
|
|
8
|
+
* The run of assemblies that share a cacheable prompt prefix.
|
|
9
|
+
*
|
|
10
|
+
* A thread at top level, because its turns build one growing conversation. A
|
|
11
|
+
* child execution keys on itself instead: it inherits its parent's `threadId`
|
|
12
|
+
* but assembles a different view, so sharing pins would replay a prefix that was
|
|
13
|
+
* never sent on its behalf.
|
|
14
|
+
*/
|
|
15
|
+
export declare function lineageKey(ctx: ExecutionContext): string;
|
|
16
|
+
/**
|
|
17
|
+
* Where a layer's pin lives within its epoch.
|
|
18
|
+
*
|
|
19
|
+
* The layer id alone, deliberately — the epoch is already scoped to one cache
|
|
20
|
+
* lineage, and layer ids are unique within a layer set. Folding the layer's
|
|
21
|
+
* `scope` into the key (as the eventual-recall cache does, where one map spans
|
|
22
|
+
* every thread) would rotate it every turn for an `'execution'`-scoped layer,
|
|
23
|
+
* so each turn would retract the "old" pin and add a "new" one for the very
|
|
24
|
+
* same layer — republishing its content in full, forever.
|
|
25
|
+
*/
|
|
26
|
+
export declare function pinKey(layer: ContextLayer): string;
|
|
27
|
+
export declare function createContextCacheStore(): ContextCacheStore;
|
|
28
|
+
/** Drop a lineage's epoch — used when a child execution ends. */
|
|
29
|
+
export declare function dropLineage(store: ContextCacheStore, key: string): void;
|
|
30
|
+
/** Read a layer's churn record, creating an empty one on first sight. */
|
|
31
|
+
export declare function churnFor(store: ContextCacheStore, key: string): LayerChurn;
|
|
32
|
+
/** Share of watched assemblies in which a layer's output changed, 0–1. */
|
|
33
|
+
export declare function churnRate(churn: LayerChurn): number;
|
|
34
|
+
interface OpenEpochParams {
|
|
35
|
+
store: ContextCacheStore;
|
|
36
|
+
key: string;
|
|
37
|
+
reason: ReanchorReason;
|
|
38
|
+
instructionsHash: string;
|
|
39
|
+
layers: ReadonlyArray<ContextLayer>;
|
|
40
|
+
config: Required<ContextCacheConfig>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Start a fresh epoch for a lineage, dropping every pin so the next assembly
|
|
44
|
+
* re-anchors against current recall output.
|
|
45
|
+
*
|
|
46
|
+
* This is the only point at which `'auto'` layers change band: placement is held
|
|
47
|
+
* still inside an epoch so the prefix cannot shift under the model mid-run.
|
|
48
|
+
* Churn carries across, decayed, so a layer's band is not relearnt from nothing
|
|
49
|
+
* each time.
|
|
50
|
+
*/
|
|
51
|
+
export declare function openEpoch({ store, key, reason, instructionsHash, layers, config, }: OpenEpochParams): ContextEpoch;
|
|
52
|
+
/**
|
|
53
|
+
* Record a lineage as most recently used.
|
|
54
|
+
*
|
|
55
|
+
* `Map` keeps first-insertion order, so re-setting an existing key would leave
|
|
56
|
+
* it where it was and let eviction drop the busiest lineage. Deleting first
|
|
57
|
+
* moves it to the end, which is what makes eviction least-recently-used.
|
|
58
|
+
*/
|
|
59
|
+
export declare function touchLineage(store: ContextCacheStore, key: string, epoch?: ContextEpoch): void;
|
|
60
|
+
/**
|
|
61
|
+
* Why the coming assembly must re-anchor, or `undefined` to carry the epoch on.
|
|
62
|
+
* Ordered cheapest-check first; delta pressure is decided later, once the
|
|
63
|
+
* supersedes are actually built.
|
|
64
|
+
*/
|
|
65
|
+
export declare function reanchorReason({ epoch, key, instructionsHash, store, config, }: {
|
|
66
|
+
epoch: ContextEpoch | undefined;
|
|
67
|
+
key: string;
|
|
68
|
+
instructionsHash: string;
|
|
69
|
+
store: ContextCacheStore;
|
|
70
|
+
config: Required<ContextCacheConfig>;
|
|
71
|
+
}): ReanchorReason | undefined;
|
|
72
|
+
interface NoteCacheOutcomeParams {
|
|
73
|
+
store: ContextCacheStore;
|
|
74
|
+
key: string;
|
|
75
|
+
response: LLMResponse;
|
|
76
|
+
config: Required<ContextCacheConfig>;
|
|
77
|
+
/** Tokens the prefix was expected to have cached — system plus anchor band. */
|
|
78
|
+
expectedTokens: number;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Judge, from the model's own token report, whether the prompt prefix survived,
|
|
82
|
+
* and record a re-anchor for the next assembly if it did not.
|
|
83
|
+
*
|
|
84
|
+
* Records intent only; pins are never touched here, so a response that steering
|
|
85
|
+
* later rejects cannot leave the epoch half-rebuilt.
|
|
86
|
+
*
|
|
87
|
+
* Three things stop this eating itself:
|
|
88
|
+
*
|
|
89
|
+
* - Only the first round counts. Later rounds replay the same view plus tool
|
|
90
|
+
* traffic and so hit the cache whatever the first round did — summing them
|
|
91
|
+
* hides a total miss behind a busy tool loop.
|
|
92
|
+
* - Young epochs are spared. The assembly right after a re-anchor writes the
|
|
93
|
+
* cache rather than reading it, so its near-zero read is expected, not a miss.
|
|
94
|
+
* - A provider that reports nothing, or misses persistently, is marked blind and
|
|
95
|
+
* stops being consulted. Age and delta pressure still bound the epoch.
|
|
96
|
+
*/
|
|
97
|
+
export declare function noteCacheOutcome({ store, key, response, config, expectedTokens, }: NoteCacheOutcomeParams): void;
|
|
98
|
+
/**
|
|
99
|
+
* Record a layer's rendered output as the bytes to replay for the rest of the
|
|
100
|
+
* epoch. The caller sets `epoch.anchorTokens` from the assembled band, which is
|
|
101
|
+
* measured the same way as the supersedes it gets compared against.
|
|
102
|
+
*/
|
|
103
|
+
export declare function pin(epoch: ContextEpoch, key: string, entry: AnchorPin): void;
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
//#region Config
|
|
2
|
+
/** Defaults for prompt-cache anchoring. See `ContextCacheConfig` for what each one does. */
|
|
3
|
+
export const DEFAULT_CONTEXT_CACHE = {
|
|
4
|
+
enabled: true,
|
|
5
|
+
minCachedTokens: 100,
|
|
6
|
+
minEpochAssemblies: 2,
|
|
7
|
+
maxEpochAssemblies: 50,
|
|
8
|
+
deltaBudgetFraction: 0.15,
|
|
9
|
+
autoDemoteChurn: 0.5,
|
|
10
|
+
autoPromoteChurn: 0.2,
|
|
11
|
+
minChurnSamples: 3,
|
|
12
|
+
churnDecay: 0.5,
|
|
13
|
+
};
|
|
14
|
+
/** Fills a partial config with the defaults. */
|
|
15
|
+
export function resolveCacheConfig(config) {
|
|
16
|
+
return {
|
|
17
|
+
...DEFAULT_CONTEXT_CACHE,
|
|
18
|
+
...config,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Judged assemblies that may come back a miss before the runtime stops steering
|
|
23
|
+
* on the provider's cache figures. A provider that never caches would otherwise
|
|
24
|
+
* re-anchor on every turn forever, for no gain.
|
|
25
|
+
*/
|
|
26
|
+
const MAX_CONSECUTIVE_MISSES = 3;
|
|
27
|
+
/** Lineages retained per harness before the least recently used is dropped. */
|
|
28
|
+
const MAX_LINEAGES = 256;
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region Keys
|
|
31
|
+
/**
|
|
32
|
+
* The run of assemblies that share a cacheable prompt prefix.
|
|
33
|
+
*
|
|
34
|
+
* A thread at top level, because its turns build one growing conversation. A
|
|
35
|
+
* child execution keys on itself instead: it inherits its parent's `threadId`
|
|
36
|
+
* but assembles a different view, so sharing pins would replay a prefix that was
|
|
37
|
+
* never sent on its behalf.
|
|
38
|
+
*/
|
|
39
|
+
export function lineageKey(ctx) {
|
|
40
|
+
return ctx.depth === 0 ? `t:${ctx.threadId}` : `x:${ctx.executionId}`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Where a layer's pin lives within its epoch.
|
|
44
|
+
*
|
|
45
|
+
* The layer id alone, deliberately — the epoch is already scoped to one cache
|
|
46
|
+
* lineage, and layer ids are unique within a layer set. Folding the layer's
|
|
47
|
+
* `scope` into the key (as the eventual-recall cache does, where one map spans
|
|
48
|
+
* every thread) would rotate it every turn for an `'execution'`-scoped layer,
|
|
49
|
+
* so each turn would retract the "old" pin and add a "new" one for the very
|
|
50
|
+
* same layer — republishing its content in full, forever.
|
|
51
|
+
*/
|
|
52
|
+
export function pinKey(layer) {
|
|
53
|
+
return layer.id;
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region Store
|
|
57
|
+
export function createContextCacheStore() {
|
|
58
|
+
return {
|
|
59
|
+
epochs: new Map(),
|
|
60
|
+
churn: new Map(),
|
|
61
|
+
pendingReanchor: new Map(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** Drop a lineage's epoch — used when a child execution ends. */
|
|
65
|
+
export function dropLineage(store, key) {
|
|
66
|
+
store.epochs.delete(key);
|
|
67
|
+
store.pendingReanchor.delete(key);
|
|
68
|
+
}
|
|
69
|
+
/** Read a layer's churn record, creating an empty one on first sight. */
|
|
70
|
+
export function churnFor(store, key) {
|
|
71
|
+
const found = store.churn.get(key);
|
|
72
|
+
if (found) {
|
|
73
|
+
return found;
|
|
74
|
+
}
|
|
75
|
+
const fresh = {
|
|
76
|
+
observed: 0,
|
|
77
|
+
changed: 0,
|
|
78
|
+
rebillTokens: 0,
|
|
79
|
+
};
|
|
80
|
+
store.churn.set(key, fresh);
|
|
81
|
+
return fresh;
|
|
82
|
+
}
|
|
83
|
+
/** Share of watched assemblies in which a layer's output changed, 0–1. */
|
|
84
|
+
export function churnRate(churn) {
|
|
85
|
+
return churn.observed === 0 ? 0 : churn.changed / churn.observed;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Start a fresh epoch for a lineage, dropping every pin so the next assembly
|
|
89
|
+
* re-anchors against current recall output.
|
|
90
|
+
*
|
|
91
|
+
* This is the only point at which `'auto'` layers change band: placement is held
|
|
92
|
+
* still inside an epoch so the prefix cannot shift under the model mid-run.
|
|
93
|
+
* Churn carries across, decayed, so a layer's band is not relearnt from nothing
|
|
94
|
+
* each time.
|
|
95
|
+
*/
|
|
96
|
+
export function openEpoch({ store, key, reason, instructionsHash, layers, config, }) {
|
|
97
|
+
const previous = store.epochs.get(key);
|
|
98
|
+
const generation = previous ? Number(previous.id.split('#')[1] ?? 0) + 1 : 0;
|
|
99
|
+
const autoBand = new Map();
|
|
100
|
+
for (const layer of layers) {
|
|
101
|
+
const k = pinKey(layer);
|
|
102
|
+
autoBand.set(k, chooseBand({
|
|
103
|
+
store,
|
|
104
|
+
key: k,
|
|
105
|
+
current: previous?.autoBand.get(k),
|
|
106
|
+
config,
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
decayChurn({
|
|
110
|
+
store,
|
|
111
|
+
layers,
|
|
112
|
+
decay: config.churnDecay,
|
|
113
|
+
});
|
|
114
|
+
const epoch = {
|
|
115
|
+
id: `${key}#${generation}`,
|
|
116
|
+
pins: new Map(),
|
|
117
|
+
autoBand,
|
|
118
|
+
instructionsHash,
|
|
119
|
+
assemblies: 0,
|
|
120
|
+
anchorTokens: 0,
|
|
121
|
+
deltaTokens: 0,
|
|
122
|
+
// A provider that has already shown it reports nothing stays untrusted
|
|
123
|
+
// across epochs; there is no reason to expect it to start.
|
|
124
|
+
cacheBlind: previous?.cacheBlind ?? false,
|
|
125
|
+
// Misses carry across epochs. A miss is what CAUSED this re-anchor, so
|
|
126
|
+
// resetting here would make the give-up threshold unreachable and leave a
|
|
127
|
+
// never-caching provider re-anchoring on every turn forever.
|
|
128
|
+
misses: previous?.misses ?? 0,
|
|
129
|
+
lastReanchorReason: reason,
|
|
130
|
+
};
|
|
131
|
+
store.pendingReanchor.delete(key);
|
|
132
|
+
touchLineage(store, key, epoch);
|
|
133
|
+
evictOldestLineages(store);
|
|
134
|
+
return epoch;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Record a lineage as most recently used.
|
|
138
|
+
*
|
|
139
|
+
* `Map` keeps first-insertion order, so re-setting an existing key would leave
|
|
140
|
+
* it where it was and let eviction drop the busiest lineage. Deleting first
|
|
141
|
+
* moves it to the end, which is what makes eviction least-recently-used.
|
|
142
|
+
*/
|
|
143
|
+
export function touchLineage(store, key, epoch) {
|
|
144
|
+
const current = epoch ?? store.epochs.get(key);
|
|
145
|
+
if (!current) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
store.epochs.delete(key);
|
|
149
|
+
store.epochs.set(key, current);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The band an `'auto'` layer takes for the coming epoch.
|
|
153
|
+
*
|
|
154
|
+
* The promote and demote thresholds leave a gap on purpose: a layer sitting
|
|
155
|
+
* between them keeps the band it already had, so one hovering near the boundary
|
|
156
|
+
* does not flip every epoch and undo the stability the bands exist to provide.
|
|
157
|
+
*/
|
|
158
|
+
function chooseBand({ store, key, current, config, }) {
|
|
159
|
+
const churn = store.churn.get(key);
|
|
160
|
+
const band = current ?? 'anchor';
|
|
161
|
+
if (!churn || churn.observed < config.minChurnSamples) {
|
|
162
|
+
return band;
|
|
163
|
+
}
|
|
164
|
+
const rate = churnRate(churn);
|
|
165
|
+
if (rate >= config.autoDemoteChurn) {
|
|
166
|
+
return 'live';
|
|
167
|
+
}
|
|
168
|
+
if (rate <= config.autoPromoteChurn) {
|
|
169
|
+
return 'anchor';
|
|
170
|
+
}
|
|
171
|
+
return band;
|
|
172
|
+
}
|
|
173
|
+
function decayChurn({ store, layers, decay, }) {
|
|
174
|
+
for (const layer of layers) {
|
|
175
|
+
const churn = store.churn.get(pinKey(layer));
|
|
176
|
+
if (!churn) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
churn.observed *= decay;
|
|
180
|
+
churn.changed *= decay;
|
|
181
|
+
churn.rebillTokens *= decay;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** Keep the lineage map bounded on a long-lived harness that spawns many children. */
|
|
185
|
+
function evictOldestLineages(store) {
|
|
186
|
+
while (store.epochs.size > MAX_LINEAGES) {
|
|
187
|
+
const oldest = store.epochs.keys().next();
|
|
188
|
+
if (oldest.done) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
dropLineage(store, oldest.value);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Why the coming assembly must re-anchor, or `undefined` to carry the epoch on.
|
|
196
|
+
* Ordered cheapest-check first; delta pressure is decided later, once the
|
|
197
|
+
* supersedes are actually built.
|
|
198
|
+
*/
|
|
199
|
+
export function reanchorReason({ epoch, key, instructionsHash, store, config, }) {
|
|
200
|
+
if (!epoch) {
|
|
201
|
+
return 'cold-start';
|
|
202
|
+
}
|
|
203
|
+
if (epoch.instructionsHash !== instructionsHash) {
|
|
204
|
+
return 'instructions-changed';
|
|
205
|
+
}
|
|
206
|
+
const pending = store.pendingReanchor.get(key);
|
|
207
|
+
if (pending) {
|
|
208
|
+
return pending;
|
|
209
|
+
}
|
|
210
|
+
if (epoch.assemblies >= config.maxEpochAssemblies) {
|
|
211
|
+
return 'max-age';
|
|
212
|
+
}
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Judge, from the model's own token report, whether the prompt prefix survived,
|
|
217
|
+
* and record a re-anchor for the next assembly if it did not.
|
|
218
|
+
*
|
|
219
|
+
* Records intent only; pins are never touched here, so a response that steering
|
|
220
|
+
* later rejects cannot leave the epoch half-rebuilt.
|
|
221
|
+
*
|
|
222
|
+
* Three things stop this eating itself:
|
|
223
|
+
*
|
|
224
|
+
* - Only the first round counts. Later rounds replay the same view plus tool
|
|
225
|
+
* traffic and so hit the cache whatever the first round did — summing them
|
|
226
|
+
* hides a total miss behind a busy tool loop.
|
|
227
|
+
* - Young epochs are spared. The assembly right after a re-anchor writes the
|
|
228
|
+
* cache rather than reading it, so its near-zero read is expected, not a miss.
|
|
229
|
+
* - A provider that reports nothing, or misses persistently, is marked blind and
|
|
230
|
+
* stops being consulted. Age and delta pressure still bound the epoch.
|
|
231
|
+
*/
|
|
232
|
+
export function noteCacheOutcome({ store, key, response, config, expectedTokens, }) {
|
|
233
|
+
const epoch = store.epochs.get(key);
|
|
234
|
+
if (!epoch || epoch.cacheBlind) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const round = response.rounds?.[0] ?? response.usage;
|
|
238
|
+
if (!round || round.cachedTokens === undefined) {
|
|
239
|
+
epoch.cacheBlind = true;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
// Reads of zero alongside a write mean the prefix was just stored, not missed.
|
|
243
|
+
if ((round.cacheWriteTokens ?? 0) > 0 && round.cachedTokens === 0) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (epoch.assemblies < config.minEpochAssemblies) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
// Judge against what there actually was to cache: a short prompt can never
|
|
250
|
+
// reach a fixed floor, and holding it to one would re-anchor forever.
|
|
251
|
+
const floor = Math.min(config.minCachedTokens, expectedTokens * 0.5);
|
|
252
|
+
if (round.cachedTokens >= floor) {
|
|
253
|
+
epoch.misses = 0;
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
epoch.misses += 1;
|
|
257
|
+
if (epoch.misses >= MAX_CONSECUTIVE_MISSES) {
|
|
258
|
+
epoch.cacheBlind = true;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
store.pendingReanchor.set(key, 'cache-miss');
|
|
262
|
+
}
|
|
263
|
+
//#endregion
|
|
264
|
+
//#region Pins
|
|
265
|
+
/**
|
|
266
|
+
* Record a layer's rendered output as the bytes to replay for the rest of the
|
|
267
|
+
* epoch. The caller sets `epoch.anchorTokens` from the assembled band, which is
|
|
268
|
+
* measured the same way as the supersedes it gets compared against.
|
|
269
|
+
*/
|
|
270
|
+
export function pin(epoch, key, entry) {
|
|
271
|
+
epoch.pins.set(key, entry);
|
|
272
|
+
}
|
|
273
|
+
//#endregion
|
|
@@ -172,15 +172,13 @@ export interface RecallCache {
|
|
|
172
172
|
export declare function createRecallCache(): RecallCache;
|
|
173
173
|
export declare function mostRestrictive(decisions: SteeringDecision[]): SteeringDecision;
|
|
174
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<
|
|
176
|
-
layerId: string;
|
|
177
|
-
items: Item[];
|
|
178
|
-
tokenCount: number;
|
|
179
|
-
}[]>;
|
|
175
|
+
export declare function recallLayers({ layers, query, ctx, log, budgets, store, itemSchemas, }: RecallLayersParams): Promise<RecallEntry[]>;
|
|
180
176
|
type RecallEntry = {
|
|
181
177
|
layerId: string;
|
|
182
178
|
items: Item[];
|
|
183
179
|
tokenCount: number;
|
|
180
|
+
/** See `RecallLayerOutput.mutatedState` — set when `recall` returned new state. */
|
|
181
|
+
mutatedState?: boolean;
|
|
184
182
|
};
|
|
185
183
|
interface RecallLayersModeParams extends RecallLayersParams {
|
|
186
184
|
/** When true, treat every layer as atomic regardless of its `recallMode`. */
|
|
@@ -278,12 +278,16 @@ export async function recallLayers({ layers, query, ctx, log, budgets, store, it
|
|
|
278
278
|
});
|
|
279
279
|
continue;
|
|
280
280
|
}
|
|
281
|
+
// Returning state means the render changed something, so it cannot be
|
|
282
|
+
// replayed from a pin — flag it so view assembly never anchors it.
|
|
283
|
+
const mutatedState = result.state !== undefined;
|
|
281
284
|
results.push({
|
|
282
285
|
layerId: layer.id,
|
|
283
286
|
items: layerSchemas.parseMany(result.items),
|
|
284
287
|
tokenCount: result.tokenCount,
|
|
288
|
+
mutatedState,
|
|
285
289
|
});
|
|
286
|
-
if (
|
|
290
|
+
if (mutatedState) {
|
|
287
291
|
store.set(ctx.executionId, layer.id, result.state);
|
|
288
292
|
}
|
|
289
293
|
}
|
|
@@ -340,7 +344,13 @@ export async function recallLayersEventual({ cache, forceAtomic, ...params }) {
|
|
|
340
344
|
cache.stale.delete(key);
|
|
341
345
|
continue;
|
|
342
346
|
}
|
|
343
|
-
|
|
347
|
+
// Serving from cache commits nothing, so the flag from the render that
|
|
348
|
+
// populated it must not ride along — it would force the layer out of the
|
|
349
|
+
// anchor band for good, on turns where its recall never even ran.
|
|
350
|
+
results.push({
|
|
351
|
+
...cached,
|
|
352
|
+
mutatedState: false,
|
|
353
|
+
});
|
|
344
354
|
}
|
|
345
355
|
return results;
|
|
346
356
|
}
|
|
@@ -21,7 +21,7 @@ export declare function layerData<T, TState>(opts: {
|
|
|
21
21
|
* @param opts.execute - Async function receiving args, current state, and execution context.
|
|
22
22
|
* @returns A `LayerFunctionDecl` to include in a layer's `provides`.
|
|
23
23
|
*/
|
|
24
|
-
export declare function
|
|
24
|
+
export declare function layerFunction<TInput, TOutput, TState>(opts: {
|
|
25
25
|
description: string;
|
|
26
26
|
input: ZodType<TInput>;
|
|
27
27
|
output: ZodType<TOutput>;
|
|
@@ -22,7 +22,7 @@ export function layerData(opts) {
|
|
|
22
22
|
* @param opts.execute - Async function receiving args, current state, and execution context.
|
|
23
23
|
* @returns A `LayerFunctionDecl` to include in a layer's `provides`.
|
|
24
24
|
*/
|
|
25
|
-
export function
|
|
25
|
+
export function layerFunction(opts) {
|
|
26
26
|
return {
|
|
27
27
|
kind: 'function',
|
|
28
28
|
description: opts.description,
|
|
@@ -1,13 +1,28 @@
|
|
|
1
|
-
import type { Context, LastLayerUsage, RecallLayerOutput, Tool } from '@noetic-tools/types';
|
|
1
|
+
import type { Context, EpochUsage, LastLayerUsage, RecallLayerOutput, Tool } from '@noetic-tools/types';
|
|
2
|
+
/** How a layer's output was banded and pinned for this assembly. */
|
|
3
|
+
export interface LayerServeInfo {
|
|
4
|
+
placement: 'anchor' | 'live';
|
|
5
|
+
served: 'fresh' | 'pinned';
|
|
6
|
+
changed: boolean;
|
|
7
|
+
churnRate: number;
|
|
8
|
+
rebillTokens: number;
|
|
9
|
+
}
|
|
2
10
|
interface ComputeLayerUsageParams {
|
|
3
11
|
ctx: Context;
|
|
4
12
|
modelId: string;
|
|
5
13
|
instructions?: string;
|
|
6
14
|
tools?: ReadonlyArray<Tool>;
|
|
7
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Per-layer output as it was ACTUALLY served into the view — pinned replays
|
|
17
|
+
* included. Passing raw recall output here would report content the model
|
|
18
|
+
* never saw.
|
|
19
|
+
*/
|
|
8
20
|
recallResults: ReadonlyArray<RecallLayerOutput>;
|
|
21
|
+
/** Banding and pin status per layer id. Absent when context caching is off. */
|
|
22
|
+
serveInfo?: ReadonlyMap<string, LayerServeInfo>;
|
|
23
|
+
epoch?: EpochUsage;
|
|
9
24
|
}
|
|
10
|
-
export declare function computeLayerUsage({ ctx, modelId, instructions, tools, recallResults, }: ComputeLayerUsageParams): LastLayerUsage;
|
|
25
|
+
export declare function computeLayerUsage({ ctx, modelId, instructions, tools, recallResults, serveInfo, epoch, }: ComputeLayerUsageParams): LastLayerUsage;
|
|
11
26
|
/** Commit computed usage to the Context, replacing any prior snapshot. */
|
|
12
27
|
export declare function commitLayerUsage(ctx: Context, usage: LastLayerUsage): void;
|
|
13
28
|
export {};
|
|
@@ -6,6 +6,13 @@ function canWriteLayerUsage(ctx) {
|
|
|
6
6
|
}
|
|
7
7
|
return desc.writable !== false;
|
|
8
8
|
}
|
|
9
|
+
const SERVED_FRESH_ANCHOR = {
|
|
10
|
+
placement: 'anchor',
|
|
11
|
+
served: 'fresh',
|
|
12
|
+
changed: false,
|
|
13
|
+
churnRate: 0,
|
|
14
|
+
rebillTokens: 0,
|
|
15
|
+
};
|
|
9
16
|
//#endregion
|
|
10
17
|
//#region Helpers
|
|
11
18
|
function estimateJsonTokens(value) {
|
|
@@ -18,12 +25,13 @@ function estimateJsonTokens(value) {
|
|
|
18
25
|
}
|
|
19
26
|
//#endregion
|
|
20
27
|
//#region Public API
|
|
21
|
-
export function computeLayerUsage({ ctx, modelId, instructions, tools, recallResults, }) {
|
|
28
|
+
export function computeLayerUsage({ ctx, modelId, instructions, tools, recallResults, serveInfo, epoch, }) {
|
|
22
29
|
const layers = recallResults
|
|
23
30
|
.map((r) => ({
|
|
24
31
|
layerId: r.layerId,
|
|
25
32
|
tokenCount: r.tokenCount,
|
|
26
33
|
items: r.items,
|
|
34
|
+
...(serveInfo?.get(r.layerId) ?? SERVED_FRESH_ANCHOR),
|
|
27
35
|
}))
|
|
28
36
|
.sort((a, b) => a.layerId.localeCompare(b.layerId));
|
|
29
37
|
let historyTokens = 0;
|
|
@@ -42,6 +50,7 @@ export function computeLayerUsage({ ctx, modelId, instructions, tools, recallRes
|
|
|
42
50
|
toolsTokens,
|
|
43
51
|
historyTokens,
|
|
44
52
|
totalUsedTokens,
|
|
53
|
+
epoch,
|
|
45
54
|
};
|
|
46
55
|
}
|
|
47
56
|
/** Commit computed usage to the Context, replacing any prior snapshot. */
|
|
@@ -51,5 +51,5 @@ interface FileReferenceOptions {
|
|
|
51
51
|
*
|
|
52
52
|
* @public
|
|
53
53
|
*/
|
|
54
|
-
export declare function
|
|
54
|
+
export declare function filesystem(opts?: FileReferenceOptions): ContextLayer<FileReferenceState>;
|
|
55
55
|
export {};
|
|
@@ -688,6 +688,59 @@ async function recallFileReferences({ state, budget, }) {
|
|
|
688
688
|
tokenCount: estimateTokens(content),
|
|
689
689
|
};
|
|
690
690
|
}
|
|
691
|
+
/**
|
|
692
|
+
* Splits a rendered `# Referenced Files` block back into one entry per file,
|
|
693
|
+
* keyed by the `## <path>` heading.
|
|
694
|
+
*/
|
|
695
|
+
function splitFileBlocks(items) {
|
|
696
|
+
const blocks = new Map();
|
|
697
|
+
for (const item of items) {
|
|
698
|
+
if (item.type !== 'message' || !('content' in item)) {
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
for (const part of item.content ?? []) {
|
|
702
|
+
if (!('text' in part) || typeof part.text !== 'string') {
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
for (const chunk of part.text.split(/\n(?=## )/)) {
|
|
706
|
+
const heading = chunk.match(/^## (.+)$/m);
|
|
707
|
+
if (heading?.[1]) {
|
|
708
|
+
blocks.set(heading[1].trim(), chunk.trim());
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return blocks;
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Describes what changed about the tracked files rather than re-sending all of
|
|
717
|
+
* them. The whole set is usually large and usually stable, so a change to one
|
|
718
|
+
* file would otherwise cost a full republish.
|
|
719
|
+
*/
|
|
720
|
+
async function renderFileReferenceDelta({ prev, next, }) {
|
|
721
|
+
const before = splitFileBlocks(prev);
|
|
722
|
+
const after = splitFileBlocks(next);
|
|
723
|
+
const changed = [];
|
|
724
|
+
for (const [path, block] of after) {
|
|
725
|
+
if (before.get(path) !== block) {
|
|
726
|
+
changed.push(block);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const dropped = [
|
|
730
|
+
...before.keys(),
|
|
731
|
+
].filter((path) => !after.has(path));
|
|
732
|
+
if (changed.length === 0 && dropped.length === 0) {
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
const parts = [];
|
|
736
|
+
if (changed.length > 0) {
|
|
737
|
+
parts.push(changed.join('\n\n'));
|
|
738
|
+
}
|
|
739
|
+
if (dropped.length > 0) {
|
|
740
|
+
parts.push(`No longer referenced: ${dropped.join(', ')}`);
|
|
741
|
+
}
|
|
742
|
+
return parts.join('\n\n');
|
|
743
|
+
}
|
|
691
744
|
//#endregion
|
|
692
745
|
//#region Layer Factory
|
|
693
746
|
/**
|
|
@@ -704,15 +757,18 @@ async function recallFileReferences({ state, budget, }) {
|
|
|
704
757
|
*
|
|
705
758
|
* @public
|
|
706
759
|
*/
|
|
707
|
-
export function
|
|
760
|
+
export function filesystem(opts) {
|
|
708
761
|
const runtime = createFileReferenceRuntime(opts);
|
|
709
762
|
return {
|
|
710
|
-
id: '
|
|
711
|
-
name: '
|
|
763
|
+
id: 'filesystem',
|
|
764
|
+
name: 'Filesystem',
|
|
712
765
|
slot: runtime.slot,
|
|
713
766
|
scope: 'thread',
|
|
714
767
|
budget: 'auto',
|
|
715
768
|
rerenderTiming: 'immediate',
|
|
769
|
+
// A large payload that changes rarely, and only a file at a time — the case
|
|
770
|
+
// anchoring plus a compact supersede is worth the most on.
|
|
771
|
+
placement: 'anchor',
|
|
716
772
|
timeouts: {
|
|
717
773
|
// onItemAppend reads files AND runs an LLM scoring call per new
|
|
718
774
|
// reference (parallelized) — the 5s pipeline default silently drops the
|
|
@@ -723,6 +779,7 @@ export function fileReference(opts) {
|
|
|
723
779
|
init: () => initFileReferenceState(runtime.baseDir),
|
|
724
780
|
onItemAppend: (args) => onFileReferenceItemAppend(args, runtime),
|
|
725
781
|
recall: recallFileReferences,
|
|
782
|
+
renderDelta: renderFileReferenceDelta,
|
|
726
783
|
},
|
|
727
784
|
};
|
|
728
785
|
}
|
|
@@ -15,17 +15,17 @@
|
|
|
15
15
|
* slice boundary via `stripUnresolvedToolCalls`.
|
|
16
16
|
*/
|
|
17
17
|
import type { ContextLayer } from '@noetic-tools/types';
|
|
18
|
-
/** @public Configuration for the {@link
|
|
19
|
-
export interface
|
|
18
|
+
/** @public Configuration for the {@link history} layer. */
|
|
19
|
+
export interface HistoryConfig {
|
|
20
20
|
/** Cap on the number of trailing items projected to the LLM. Defaults to 40. */
|
|
21
21
|
maxItems?: number;
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
|
-
* Creates a history
|
|
24
|
+
* Creates a history context layer that caps the trailing items projected
|
|
25
25
|
* to the LLM. Storage stays unbounded; only the wire payload is narrowed.
|
|
26
26
|
*
|
|
27
27
|
* @public
|
|
28
28
|
* @param config - Optional `{ maxItems }`. Defaults to 40.
|
|
29
29
|
* @returns A `ContextLayer` that contributes only a `projectHistory` hook.
|
|
30
30
|
*/
|
|
31
|
-
export declare function
|
|
31
|
+
export declare function history(config?: HistoryConfig): ContextLayer<null>;
|