@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,327 @@
|
|
|
1
|
+
import { collectInputText, collectOutputText, createMessage, estimateTokens, Slot, } from '@noetic-tools/types';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { layerFn } from '../layer-provides.js';
|
|
4
|
+
function emptyState() {
|
|
5
|
+
return {
|
|
6
|
+
facts: {},
|
|
7
|
+
buffer: [],
|
|
8
|
+
bufferTokens: 0,
|
|
9
|
+
version: 0,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
const DEFAULT_BUFFER_THRESHOLD_TOKENS = 2_000;
|
|
13
|
+
const DEFAULT_MAX_FACTS = 200;
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region Date grounding
|
|
16
|
+
const WEEKDAYS = [
|
|
17
|
+
'Sun',
|
|
18
|
+
'Mon',
|
|
19
|
+
'Tue',
|
|
20
|
+
'Wed',
|
|
21
|
+
'Thu',
|
|
22
|
+
'Fri',
|
|
23
|
+
'Sat',
|
|
24
|
+
];
|
|
25
|
+
/** Formats an instant as `YYYY/MM/DD (Ddd) HH:MM` using local fields. */
|
|
26
|
+
function formatNow(date) {
|
|
27
|
+
const yyyy = date.getFullYear();
|
|
28
|
+
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
|
29
|
+
const dd = String(date.getDate()).padStart(2, '0');
|
|
30
|
+
const day = WEEKDAYS[date.getDay()];
|
|
31
|
+
const hh = String(date.getHours()).padStart(2, '0');
|
|
32
|
+
const min = String(date.getMinutes()).padStart(2, '0');
|
|
33
|
+
return `${yyyy}/${mm}/${dd} (${day}) ${hh}:${min}`;
|
|
34
|
+
}
|
|
35
|
+
function renderDateTimeBlock(date) {
|
|
36
|
+
return [
|
|
37
|
+
'<current_datetime>',
|
|
38
|
+
`Now: ${formatNow(date)}`,
|
|
39
|
+
'Resolve relative time ("N days/weeks/months ago", "last/next <weekday>",',
|
|
40
|
+
'"before/after", "first/earliest/most recent", "how long since/until") against',
|
|
41
|
+
'Now above and compute the difference explicitly before answering. Use the',
|
|
42
|
+
'temporal/searchMemory tool to look up when something happened.',
|
|
43
|
+
'</current_datetime>',
|
|
44
|
+
].join('\n');
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
//#region Ledger helpers
|
|
48
|
+
/** Flattens the KV ledger into a flat, chronologically sorted fact list. */
|
|
49
|
+
function ledgerToFacts(state) {
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const [ts, facts] of Object.entries(state.facts)) {
|
|
52
|
+
for (const fact of facts) {
|
|
53
|
+
out.push({
|
|
54
|
+
ts,
|
|
55
|
+
fact,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
out.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** Merges new facts into the KV ledger, capping total facts to `maxFacts` (oldest dropped). */
|
|
63
|
+
function mergeFacts(state, incoming, maxFacts) {
|
|
64
|
+
const facts = {};
|
|
65
|
+
for (const [ts, list] of Object.entries(state.facts)) {
|
|
66
|
+
facts[ts] = [
|
|
67
|
+
...list,
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
for (const { ts, fact } of incoming) {
|
|
71
|
+
const list = facts[ts] ?? [];
|
|
72
|
+
if (!list.includes(fact)) {
|
|
73
|
+
list.push(fact);
|
|
74
|
+
}
|
|
75
|
+
facts[ts] = list;
|
|
76
|
+
}
|
|
77
|
+
return capLedger(facts, maxFacts);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Caps the ledger at FACT granularity: when over `maxFacts`, drops the OLDEST
|
|
81
|
+
* facts and always retains the most recent `maxFacts`. Operating per-fact (not
|
|
82
|
+
* per-timestamp) prevents a single oversized extraction at one instant from
|
|
83
|
+
* evicting the just-added newest facts.
|
|
84
|
+
*/
|
|
85
|
+
function capLedger(facts, maxFacts) {
|
|
86
|
+
let total = 0;
|
|
87
|
+
for (const list of Object.values(facts)) {
|
|
88
|
+
total += list.length;
|
|
89
|
+
}
|
|
90
|
+
if (total <= maxFacts) {
|
|
91
|
+
return facts;
|
|
92
|
+
}
|
|
93
|
+
// Flatten chronologically (oldest first; stable within a timestamp), then keep
|
|
94
|
+
// only the most recent `maxFacts`.
|
|
95
|
+
const flat = [];
|
|
96
|
+
for (const [ts, list] of Object.entries(facts)) {
|
|
97
|
+
for (const fact of list) {
|
|
98
|
+
flat.push({
|
|
99
|
+
ts,
|
|
100
|
+
fact,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
flat.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
|
|
105
|
+
const kept = flat.slice(flat.length - maxFacts);
|
|
106
|
+
const capped = {};
|
|
107
|
+
for (const { ts, fact } of kept) {
|
|
108
|
+
const list = capped[ts] ?? [];
|
|
109
|
+
list.push(fact);
|
|
110
|
+
capped[ts] = list;
|
|
111
|
+
}
|
|
112
|
+
return capped;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Renders the `<remembered_facts>` block, greedily including the most recent
|
|
116
|
+
* facts first and stopping before the block would exceed `budget` tokens.
|
|
117
|
+
* Returns `null` when there are no facts or not even one fits.
|
|
118
|
+
*/
|
|
119
|
+
function renderLedgerBlock(facts, budget) {
|
|
120
|
+
if (facts.length === 0) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const open = '<remembered_facts>';
|
|
124
|
+
const close = '</remembered_facts>';
|
|
125
|
+
// Most recent first so the budget retains the freshest facts.
|
|
126
|
+
const ordered = [
|
|
127
|
+
...facts,
|
|
128
|
+
].reverse();
|
|
129
|
+
const selected = [];
|
|
130
|
+
for (const f of ordered) {
|
|
131
|
+
const line = `- [${f.ts}] ${f.fact}`;
|
|
132
|
+
const candidate = [
|
|
133
|
+
open,
|
|
134
|
+
...selected,
|
|
135
|
+
line,
|
|
136
|
+
close,
|
|
137
|
+
].join('\n');
|
|
138
|
+
if (estimateTokens(candidate) > budget) {
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
selected.push(line);
|
|
142
|
+
}
|
|
143
|
+
if (selected.length === 0) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
return [
|
|
147
|
+
open,
|
|
148
|
+
...selected,
|
|
149
|
+
close,
|
|
150
|
+
].join('\n');
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Appends `texts` into the buffer and, once the token threshold is crossed (and
|
|
154
|
+
* an extractor is configured), distills the buffer into ledger facts. Shared by
|
|
155
|
+
* `store` (assistant output) and `onItemAppend` (user/tool input).
|
|
156
|
+
*/
|
|
157
|
+
async function accumulate(s, texts, cfg) {
|
|
158
|
+
const newBuffer = [
|
|
159
|
+
...s.buffer,
|
|
160
|
+
...texts,
|
|
161
|
+
];
|
|
162
|
+
const newTokens = texts.reduce((sum, t) => sum + estimateTokens(t), 0);
|
|
163
|
+
const totalBufferTokens = s.bufferTokens + newTokens;
|
|
164
|
+
if (cfg.extract && totalBufferTokens >= cfg.threshold && newBuffer.length > 0) {
|
|
165
|
+
const extracted = await cfg.extract({
|
|
166
|
+
transcript: newBuffer.join('\n'),
|
|
167
|
+
now: formatNow(cfg.now()),
|
|
168
|
+
});
|
|
169
|
+
return {
|
|
170
|
+
facts: mergeFacts(s, extracted, cfg.maxFacts),
|
|
171
|
+
buffer: [],
|
|
172
|
+
bufferTokens: 0,
|
|
173
|
+
version: s.version + 1,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
...s,
|
|
178
|
+
buffer: newBuffer,
|
|
179
|
+
bufferTokens: totalBufferTokens,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
//#endregion
|
|
183
|
+
//#region Layer
|
|
184
|
+
/**
|
|
185
|
+
* A non-atomic, LLM-backed context layer that extracts timestamped facts from the
|
|
186
|
+
* conversation into a key-value ledger (key = ISO timestamp) and exposes a
|
|
187
|
+
* `temporal/searchMemory` tool for relative-time recall — given a fact/query it
|
|
188
|
+
* returns matching facts and, when applicable, a resolved (possibly fuzzy) date.
|
|
189
|
+
*
|
|
190
|
+
* The layer is LLM-agnostic: the host injects `extract` and `search` callbacks
|
|
191
|
+
* (mirrors `observationalContext`'s `observer`), keeping `context/` tree-shakable.
|
|
192
|
+
* Without them, the layer still buffers text and the search tool returns the raw
|
|
193
|
+
* ledger — it never fabricates facts. A `<current_datetime>` grounding block is
|
|
194
|
+
* injected on recall by default to anchor relative-time reasoning.
|
|
195
|
+
*
|
|
196
|
+
* @public
|
|
197
|
+
* @param config - Clock, scope, injected LLM callbacks, and tuning knobs.
|
|
198
|
+
* @returns A `ContextLayer` with a fact-ledger store + `searchMemory` tool.
|
|
199
|
+
*/
|
|
200
|
+
export function temporalContext(config) {
|
|
201
|
+
const now = config?.now ?? (() => new Date());
|
|
202
|
+
const threshold = config?.bufferThreshold ?? DEFAULT_BUFFER_THRESHOLD_TOKENS;
|
|
203
|
+
const maxFacts = config?.maxFacts ?? DEFAULT_MAX_FACTS;
|
|
204
|
+
const groundDateTime = config?.groundDateTime ?? true;
|
|
205
|
+
const injectLedger = config?.injectLedger ?? false;
|
|
206
|
+
const extract = config?.extract;
|
|
207
|
+
const search = config?.search;
|
|
208
|
+
return {
|
|
209
|
+
id: 'temporal',
|
|
210
|
+
name: 'Temporal Context',
|
|
211
|
+
// Grounding sits near the top of the window, before reasoning content.
|
|
212
|
+
slot: Slot.REMINDER,
|
|
213
|
+
scope: config?.scope ?? 'resource',
|
|
214
|
+
budget: {
|
|
215
|
+
min: 0,
|
|
216
|
+
max: injectLedger ? 800 : 200,
|
|
217
|
+
},
|
|
218
|
+
timeouts: {
|
|
219
|
+
store: 60_000,
|
|
220
|
+
// onItemAppend runs the same LLM-backed accumulate/extract path as
|
|
221
|
+
// store — it needs the same headroom, not the 5s pipeline default.
|
|
222
|
+
onItemAppend: 60_000,
|
|
223
|
+
},
|
|
224
|
+
provides: {
|
|
225
|
+
searchMemory: layerFn({
|
|
226
|
+
description: 'Search remembered facts about the user by topic or fact, and resolve relative time. Returns matching facts and, when the query implies a time, a resolved date (which may be approximate).',
|
|
227
|
+
input: z.object({
|
|
228
|
+
query: z.string(),
|
|
229
|
+
}),
|
|
230
|
+
output: z.object({
|
|
231
|
+
facts: z.array(z.string()),
|
|
232
|
+
date: z.string().optional(),
|
|
233
|
+
fuzzy: z.boolean().optional(),
|
|
234
|
+
}),
|
|
235
|
+
execute: async (args, state) => {
|
|
236
|
+
const facts = ledgerToFacts(state ?? emptyState());
|
|
237
|
+
if (!search) {
|
|
238
|
+
return {
|
|
239
|
+
result: {
|
|
240
|
+
facts: facts.map((f) => `[${f.ts}] ${f.fact}`),
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const result = await search({
|
|
245
|
+
query: args.query,
|
|
246
|
+
facts,
|
|
247
|
+
now: formatNow(now()),
|
|
248
|
+
});
|
|
249
|
+
return {
|
|
250
|
+
result,
|
|
251
|
+
};
|
|
252
|
+
},
|
|
253
|
+
}),
|
|
254
|
+
},
|
|
255
|
+
hooks: {
|
|
256
|
+
async init({ storage }) {
|
|
257
|
+
const saved = await storage.get('state');
|
|
258
|
+
return {
|
|
259
|
+
state: saved ?? emptyState(),
|
|
260
|
+
};
|
|
261
|
+
},
|
|
262
|
+
async recall({ state, budget }) {
|
|
263
|
+
const blocks = [];
|
|
264
|
+
if (groundDateTime) {
|
|
265
|
+
blocks.push(renderDateTimeBlock(now()));
|
|
266
|
+
}
|
|
267
|
+
if (injectLedger) {
|
|
268
|
+
// Reserve whatever the grounding block (plus its `\n\n` separator)
|
|
269
|
+
// already consumed so the rendered output stays within budget.
|
|
270
|
+
const used = blocks.length > 0 ? estimateTokens(`${blocks.join('\n\n')}\n\n`) : 0;
|
|
271
|
+
const ledgerBlock = renderLedgerBlock(ledgerToFacts(state ?? emptyState()), budget - used);
|
|
272
|
+
if (ledgerBlock !== null) {
|
|
273
|
+
blocks.push(ledgerBlock);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (blocks.length === 0) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
const text = blocks.join('\n\n');
|
|
280
|
+
return {
|
|
281
|
+
items: [
|
|
282
|
+
createMessage(text, 'developer'),
|
|
283
|
+
],
|
|
284
|
+
tokenCount: estimateTokens(text),
|
|
285
|
+
};
|
|
286
|
+
},
|
|
287
|
+
// Captures assistant output text.
|
|
288
|
+
async store({ newItems, state }) {
|
|
289
|
+
const s = state ?? emptyState();
|
|
290
|
+
const texts = collectOutputText(newItems);
|
|
291
|
+
return {
|
|
292
|
+
state: await accumulate(s, texts, {
|
|
293
|
+
threshold,
|
|
294
|
+
maxFacts,
|
|
295
|
+
now,
|
|
296
|
+
extract,
|
|
297
|
+
}),
|
|
298
|
+
};
|
|
299
|
+
},
|
|
300
|
+
// Captures user input and tool output text (pass-through; no transform).
|
|
301
|
+
async onItemAppend({ items, state }) {
|
|
302
|
+
const s = state ?? emptyState();
|
|
303
|
+
const texts = collectInputText(items);
|
|
304
|
+
if (texts.length === 0) {
|
|
305
|
+
return {
|
|
306
|
+
items,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
items,
|
|
311
|
+
state: await accumulate(s, texts, {
|
|
312
|
+
threshold,
|
|
313
|
+
maxFacts,
|
|
314
|
+
now,
|
|
315
|
+
extract,
|
|
316
|
+
}),
|
|
317
|
+
};
|
|
318
|
+
},
|
|
319
|
+
async onSpawn({ parentState }) {
|
|
320
|
+
return {
|
|
321
|
+
childState: structuredClone(parentState),
|
|
322
|
+
};
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
//#endregion
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ContextLayer, Tool } from '@noetic-tools/types';
|
|
2
|
+
interface ToolContextLayerOpts {
|
|
3
|
+
slot?: number;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Generates one ContextLayer per unique context id among the provided tools.
|
|
7
|
+
* Tools sharing the same `context.id` share a single layer (and thus state).
|
|
8
|
+
*
|
|
9
|
+
* @public
|
|
10
|
+
* @param tools - Array of tools to extract context declarations from.
|
|
11
|
+
* @param opts - Optional slot override for the generated layers.
|
|
12
|
+
* @returns An array of `ContextLayer` instances, one per unique tool context id.
|
|
13
|
+
*/
|
|
14
|
+
export declare function toolContextLayer(tools: ReadonlyArray<Tool>, opts?: ToolContextLayerOpts): ContextLayer[];
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Slot } from '@noetic-tools/types';
|
|
2
|
+
const EXECUTION_SCOPE = 'execution';
|
|
3
|
+
/**
|
|
4
|
+
* Generates one ContextLayer per unique context id among the provided tools.
|
|
5
|
+
* Tools sharing the same `context.id` share a single layer (and thus state).
|
|
6
|
+
*
|
|
7
|
+
* @public
|
|
8
|
+
* @param tools - Array of tools to extract context declarations from.
|
|
9
|
+
* @param opts - Optional slot override for the generated layers.
|
|
10
|
+
* @returns An array of `ContextLayer` instances, one per unique tool context id.
|
|
11
|
+
*/
|
|
12
|
+
export function toolContextLayer(tools, opts) {
|
|
13
|
+
const seen = new Map();
|
|
14
|
+
for (const t of tools) {
|
|
15
|
+
if (!t.context) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const layerId = t.context.id ?? t.name;
|
|
19
|
+
if (seen.has(layerId)) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
seen.set(layerId, t.context);
|
|
23
|
+
}
|
|
24
|
+
const slot = opts?.slot ?? Slot.WORKING_MEMORY + 10;
|
|
25
|
+
return [
|
|
26
|
+
...seen.entries(),
|
|
27
|
+
].map(([layerId, decl]) => ({
|
|
28
|
+
id: layerId,
|
|
29
|
+
name: layerId,
|
|
30
|
+
slot,
|
|
31
|
+
scope: EXECUTION_SCOPE,
|
|
32
|
+
hooks: {
|
|
33
|
+
async init() {
|
|
34
|
+
return {
|
|
35
|
+
state: decl.init(),
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
async recall({ state }) {
|
|
39
|
+
return decl.recall(state);
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ZodType } from 'zod';
|
|
2
|
+
export type WorkingMemoryContextState = string | Record<string, unknown>;
|
|
3
|
+
export interface WorkingMemoryContextConfig {
|
|
4
|
+
scope?: 'thread' | 'resource';
|
|
5
|
+
schema?: ZodType;
|
|
6
|
+
template?: string;
|
|
7
|
+
readOnly?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Creates a mutable working context layer that the model can update via the `working-context/update` tool.
|
|
11
|
+
*
|
|
12
|
+
* @public
|
|
13
|
+
* @param config - Optional configuration for scope, Zod schema, template, and read-only mode.
|
|
14
|
+
* @returns A `ContextLayer` providing scratchpad state the model can read and write.
|
|
15
|
+
*/
|
|
16
|
+
export declare function workingMemoryContext(config?: WorkingMemoryContextConfig): {
|
|
17
|
+
id: 'working-context';
|
|
18
|
+
name: string;
|
|
19
|
+
slot: 100;
|
|
20
|
+
scope: "resource" | "thread";
|
|
21
|
+
budget: {
|
|
22
|
+
min: number;
|
|
23
|
+
max: number;
|
|
24
|
+
};
|
|
25
|
+
provides: {
|
|
26
|
+
snapshot: import("@noetic-tools/types").LayerDataDecl<WorkingMemoryContextState, WorkingMemoryContextState>;
|
|
27
|
+
update: import("@noetic-tools/types").LayerFunctionDecl<Record<string, unknown>, void, WorkingMemoryContextState>;
|
|
28
|
+
};
|
|
29
|
+
hooks: {
|
|
30
|
+
init({ storage }: import("@noetic-tools/types").InitParams): Promise<{
|
|
31
|
+
state: {};
|
|
32
|
+
} | {
|
|
33
|
+
state: WorkingMemoryContextState;
|
|
34
|
+
}>;
|
|
35
|
+
recall({ state }: import("@noetic-tools/types").RecallParams<WorkingMemoryContextState>): Promise<{
|
|
36
|
+
items: import("@noetic-tools/types").InputMessageItem[];
|
|
37
|
+
tokenCount: number;
|
|
38
|
+
} | null>;
|
|
39
|
+
store({ newItems, state }: import("@noetic-tools/types").StoreParams<WorkingMemoryContextState>): Promise<{
|
|
40
|
+
state: WorkingMemoryContextState;
|
|
41
|
+
} | undefined>;
|
|
42
|
+
onSpawn({ parentState }: import("@noetic-tools/types").SpawnParams<WorkingMemoryContextState>): Promise<{
|
|
43
|
+
childState: WorkingMemoryContextState;
|
|
44
|
+
} | null>;
|
|
45
|
+
};
|
|
46
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createMessage, estimateTokens, Slot } from '@noetic-tools/types';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { findFunctionCall } from '../function-call-utils.js';
|
|
4
|
+
import { layerData, layerFn } from '../layer-provides.js';
|
|
5
|
+
function isPlainObject(value) {
|
|
6
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
/** Cap recursion depth so a cyclic or pathologically-nested update can't overflow the stack. */
|
|
9
|
+
const MAX_MERGE_DEPTH = 32;
|
|
10
|
+
/**
|
|
11
|
+
* Recursively merges `source` into `target`: object-valued keys are deep-merged,
|
|
12
|
+
* while arrays and primitives replace. Prototype-pollution keys (`__proto__`,
|
|
13
|
+
* `constructor`) are stripped at every depth. Beyond `MAX_MERGE_DEPTH` the source
|
|
14
|
+
* value replaces rather than recurses (cycle/over-nesting guard).
|
|
15
|
+
*/
|
|
16
|
+
function deepMerge(target, source, depth = 0) {
|
|
17
|
+
const out = {
|
|
18
|
+
...target,
|
|
19
|
+
};
|
|
20
|
+
for (const [key, value] of Object.entries(source)) {
|
|
21
|
+
if (key === '__proto__' || key === 'constructor') {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const existing = out[key];
|
|
25
|
+
if (isPlainObject(value) && depth < MAX_MERGE_DEPTH) {
|
|
26
|
+
out[key] = isPlainObject(existing)
|
|
27
|
+
? deepMerge(existing, value, depth + 1)
|
|
28
|
+
: deepMerge({}, value, depth + 1);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
out[key] = value;
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Validate a MERGED working-context state against the configured schema (a
|
|
37
|
+
* pure gate — the original value is returned, transforms are not applied).
|
|
38
|
+
* Validating the merged state (not the raw update) keeps partial updates
|
|
39
|
+
* legal under schemas with required fields. Throws on failure so the update
|
|
40
|
+
* is rejected and the prior state stays untouched.
|
|
41
|
+
*/
|
|
42
|
+
function applySchema(schema, merged) {
|
|
43
|
+
if (!schema) {
|
|
44
|
+
return merged;
|
|
45
|
+
}
|
|
46
|
+
const result = schema.safeParse(merged);
|
|
47
|
+
if (!result.success) {
|
|
48
|
+
throw new Error(`working-context update rejected by schema: ${result.error.message}`);
|
|
49
|
+
}
|
|
50
|
+
return merged;
|
|
51
|
+
}
|
|
52
|
+
function safeMerge(state, args) {
|
|
53
|
+
if (typeof state === 'object' && state !== null) {
|
|
54
|
+
return deepMerge(state, args);
|
|
55
|
+
}
|
|
56
|
+
// Freeform string state: an object update must not silently discard prior content.
|
|
57
|
+
const merged = deepMerge({}, args);
|
|
58
|
+
if (typeof state === 'string' && state.length > 0) {
|
|
59
|
+
// `_previous` last so the preserved prior content always wins over a
|
|
60
|
+
// (pathological) update key literally named `_previous`.
|
|
61
|
+
return {
|
|
62
|
+
...merged,
|
|
63
|
+
_previous: state,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return merged;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Creates a mutable working context layer that the model can update via the `working-context/update` tool.
|
|
70
|
+
*
|
|
71
|
+
* @public
|
|
72
|
+
* @param config - Optional configuration for scope, Zod schema, template, and read-only mode.
|
|
73
|
+
* @returns A `ContextLayer` providing scratchpad state the model can read and write.
|
|
74
|
+
*/
|
|
75
|
+
export function workingMemoryContext(config) {
|
|
76
|
+
const scope = config?.scope ?? 'thread';
|
|
77
|
+
return {
|
|
78
|
+
id: 'working-context',
|
|
79
|
+
name: 'Working Memory',
|
|
80
|
+
slot: Slot.WORKING_MEMORY,
|
|
81
|
+
scope,
|
|
82
|
+
budget: {
|
|
83
|
+
min: 200,
|
|
84
|
+
max: 1_500,
|
|
85
|
+
},
|
|
86
|
+
provides: {
|
|
87
|
+
snapshot: layerData({
|
|
88
|
+
read: (state) => state,
|
|
89
|
+
}),
|
|
90
|
+
update: layerFn({
|
|
91
|
+
description: 'Update the agent working context with new key-value pairs.',
|
|
92
|
+
input: z.record(z.string(), z.unknown()),
|
|
93
|
+
output: z.void(),
|
|
94
|
+
// Validation failure throws → surfaces as a tool error the model can
|
|
95
|
+
// see; the prior state is untouched.
|
|
96
|
+
execute: async (args, state) => ({
|
|
97
|
+
result: undefined,
|
|
98
|
+
state: applySchema(config?.schema, safeMerge(state, args)),
|
|
99
|
+
}),
|
|
100
|
+
}),
|
|
101
|
+
},
|
|
102
|
+
hooks: {
|
|
103
|
+
async init({ storage }) {
|
|
104
|
+
const saved = await storage.get('state');
|
|
105
|
+
// Corrupt persisted state (fails the configured schema) falls back to
|
|
106
|
+
// the default rather than aborting the execution.
|
|
107
|
+
if (saved !== null && config?.schema && !config.schema.safeParse(saved).success) {
|
|
108
|
+
return {
|
|
109
|
+
state: {},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const state = saved ?? (config?.schema ? {} : '');
|
|
113
|
+
return {
|
|
114
|
+
state,
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
async recall({ state }) {
|
|
118
|
+
if (!state || (typeof state === 'object' && Object.keys(state).length === 0)) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
const text = typeof state === 'string' ? state : JSON.stringify(state, null, 2);
|
|
122
|
+
const content = `<working_memory>\n${text}\n</working_memory>`;
|
|
123
|
+
return {
|
|
124
|
+
items: [
|
|
125
|
+
createMessage(content, 'developer'),
|
|
126
|
+
],
|
|
127
|
+
tokenCount: estimateTokens(content),
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
async store({ newItems, state }) {
|
|
131
|
+
if (config?.readOnly) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
// Backward compat: detect implicit updateWorkingMemory calls from LLMs.
|
|
135
|
+
// Schema-violating merges throw — storeLayers catches, reports a
|
|
136
|
+
// diagnostic, and drops the update (prior state preserved).
|
|
137
|
+
const args = findFunctionCall(newItems, 'updateWorkingMemory');
|
|
138
|
+
if (!args) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
state: applySchema(config?.schema, safeMerge(state, args)),
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
async onSpawn({ parentState }) {
|
|
146
|
+
if (scope === 'resource') {
|
|
147
|
+
return {
|
|
148
|
+
childState: structuredClone(parentState),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Item, ProjectionPolicy } from '@noetic-tools/types';
|
|
2
|
+
interface AssembleViewParams {
|
|
3
|
+
systemPromptItems: Item[];
|
|
4
|
+
layerOutputItems: Item[];
|
|
5
|
+
historyItems: Item[];
|
|
6
|
+
policy?: ProjectionPolicy;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Assemble the model's context window from system prompt items, context-layer
|
|
10
|
+
* recall output (slot-ascending), and conversation history.
|
|
11
|
+
*
|
|
12
|
+
* Without a `policy` the inputs are concatenated as-is (optionally sliding the
|
|
13
|
+
* history window by `windowSize`). With a `policy` the assembled view is held to
|
|
14
|
+
* a hard token budget: system items are always kept; layer output is kept
|
|
15
|
+
* low-slot-first (highest-slot dropped when tight); history takes the remainder,
|
|
16
|
+
* keeping the most recent turns.
|
|
17
|
+
*/
|
|
18
|
+
export declare function assembleView({ systemPromptItems, layerOutputItems, historyItems, policy, }: AssembleViewParams): Item[];
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { estimateTokens } from '@noetic-tools/types';
|
|
2
|
+
import { stripUnresolvedToolCalls } from './strip-unresolved.js';
|
|
3
|
+
//#endregion
|
|
4
|
+
//#region Helpers
|
|
5
|
+
/** Conservative per-item token estimate (serialized form ⇒ never under-counts). */
|
|
6
|
+
function itemTokens(item) {
|
|
7
|
+
return estimateTokens(JSON.stringify(item));
|
|
8
|
+
}
|
|
9
|
+
function totalTokens(items) {
|
|
10
|
+
let total = 0;
|
|
11
|
+
for (const item of items) {
|
|
12
|
+
total += itemTokens(item);
|
|
13
|
+
}
|
|
14
|
+
return total;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Keep items from a slot-ascending list within `budget`, considering items in
|
|
18
|
+
* slot order and dropping each non-fitting item INDIVIDUALLY. Layer-output
|
|
19
|
+
* items are independent contributions (no contiguity requirement, unlike
|
|
20
|
+
* history), so an oversized low-slot item must not evict later items that
|
|
21
|
+
* still fit — lower-slot output gets first claim on the budget, and
|
|
22
|
+
* higher-slot output is dropped first when space runs out.
|
|
23
|
+
*/
|
|
24
|
+
function keepFrontWithinBudget(items, budget) {
|
|
25
|
+
const kept = [];
|
|
26
|
+
let used = 0;
|
|
27
|
+
for (const item of items) {
|
|
28
|
+
const cost = itemTokens(item);
|
|
29
|
+
if (used + cost > budget) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
kept.push(item);
|
|
33
|
+
used += cost;
|
|
34
|
+
}
|
|
35
|
+
return kept;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Keep the MOST RECENT history items within `budget`, then strip any orphan
|
|
39
|
+
* tool calls/outputs left dangling at the slice boundary. An optional
|
|
40
|
+
* `windowSize` caps item count first (sliding-window overflow mode).
|
|
41
|
+
*/
|
|
42
|
+
function keepRecentWithinBudget(items, budget, windowSize) {
|
|
43
|
+
const windowed = windowSize ? items.slice(-windowSize) : items;
|
|
44
|
+
const kept = [];
|
|
45
|
+
let used = 0;
|
|
46
|
+
for (let i = windowed.length - 1; i >= 0; i--) {
|
|
47
|
+
const item = windowed[i];
|
|
48
|
+
const cost = itemTokens(item);
|
|
49
|
+
if (used + cost > budget) {
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
kept.unshift(item);
|
|
53
|
+
used += cost;
|
|
54
|
+
}
|
|
55
|
+
return stripUnresolvedToolCalls(kept);
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region Public API
|
|
59
|
+
/**
|
|
60
|
+
* Assemble the model's context window from system prompt items, context-layer
|
|
61
|
+
* recall output (slot-ascending), and conversation history.
|
|
62
|
+
*
|
|
63
|
+
* Without a `policy` the inputs are concatenated as-is (optionally sliding the
|
|
64
|
+
* history window by `windowSize`). With a `policy` the assembled view is held to
|
|
65
|
+
* a hard token budget: system items are always kept; layer output is kept
|
|
66
|
+
* low-slot-first (highest-slot dropped when tight); history takes the remainder,
|
|
67
|
+
* keeping the most recent turns.
|
|
68
|
+
*/
|
|
69
|
+
export function assembleView({ systemPromptItems, layerOutputItems, historyItems, policy, }) {
|
|
70
|
+
if (!policy) {
|
|
71
|
+
return [
|
|
72
|
+
...systemPromptItems,
|
|
73
|
+
...layerOutputItems,
|
|
74
|
+
...historyItems,
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
const budget = Math.max(0, policy.tokenBudget - policy.responseReserve);
|
|
78
|
+
// System items are never dropped — they anchor the conversation.
|
|
79
|
+
const afterSystem = Math.max(0, budget - totalTokens(systemPromptItems));
|
|
80
|
+
const keptLayers = keepFrontWithinBudget(layerOutputItems, afterSystem);
|
|
81
|
+
const afterLayers = Math.max(0, afterSystem - totalTokens(keptLayers));
|
|
82
|
+
const keptHistory = keepRecentWithinBudget(historyItems, afterLayers, policy.windowSize);
|
|
83
|
+
return [
|
|
84
|
+
...systemPromptItems,
|
|
85
|
+
...keptLayers,
|
|
86
|
+
...keptHistory,
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
//#endregion
|