@jangts/agent-core 0.2.1 → 0.2.5
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/CHANGELOG.md +7 -0
- package/development/docs/api/reference.md +290 -257
- package/development/docs/api/workflow.en.md +10 -0
- package/development/docs/api/workflow.md +22 -0
- package/development/skills/agent-core-app/SKILL.md +4 -0
- package/dist/cjs/execution/cache.js +162 -0
- package/dist/cjs/execution/engine.js +10 -0
- package/dist/cjs/kits/vector-search.js +4 -7
- package/dist/cjs/structs/execution/cache.js +6 -0
- package/dist/esm/execution/cache.d.ts +20 -0
- package/dist/esm/execution/cache.js +156 -0
- package/dist/esm/execution/engine.d.ts +5 -0
- package/dist/esm/execution/engine.js +10 -0
- package/dist/esm/kits/vector-search.js +4 -7
- package/dist/esm/structs/execution/cache.d.ts +54 -0
- package/dist/esm/structs/execution/cache.js +5 -0
- package/dist/esm/structs/execution/engine.d.ts +6 -1
- package/dist/esm/structs/execution/workflow.d.ts +2 -1
- package/dist/esm/structs/index.d.ts +1 -0
- package/package.json +2 -2
|
@@ -186,3 +186,13 @@ infers the output type from the object contract, and the same field also accepts
|
|
|
186
186
|
### Reports during node execution
|
|
187
187
|
|
|
188
188
|
`run(state, context)` can call `context.report({ usedModel, payload, subs })` to report progress. usedModel is optional boolean metadata about this node itself and remains unknown when not reported; payload and subs are read only when content is allowed. subs must be the instance created by `context.createSubFlowLogger()` for this execution, and a hand-written array cannot be submitted. write records the intent first and returns an ID, fix adds later results, and readAll returns an immutable snapshot in a fixed format; writes and completions reuse the existing node.progress, and a short circuit or throw still preserves the start record already sent. Reports do not submit business state and do not determine routing; the Loop adds node identity, time, and event ordinal. Late writes are not accepted after cancellation or node completion. The node's `report(update, context)` at completion continues to serve supplementary reporting when content is allowed. For the contract, see [Child log instances](observability.md#sub-log-instances-and-record-first-then-complete).
|
|
189
|
+
|
|
190
|
+
## Node caches
|
|
191
|
+
|
|
192
|
+
Engine option `cache: { maxEntries: 1024, ttlMs: 60000 }` configures bounded instance-local LRU memory. Capacity defaults to 1024 across all partitions; omitted TTL means no time-based expiry. Expiry is lazy and measured from writes. Values retain identity, including undefined; has distinguishes a stored undefined from a miss. delete returns a boolean; set and clear return void.
|
|
193
|
+
|
|
194
|
+
context.cache belongs to the current node. Only get(key, { nodeId }) may read another partition. Other operations only address the current partition, and clear never clears the entire Engine. Low-level run(step) uses `$step`. Runs on one Engine share memory; forks preserve bounds but start with empty memory.
|
|
195
|
+
|
|
196
|
+
Optional Engine option agentCache accepts an AgentCachePort. context.agentCache exposes asynchronous get/has/set/delete/clear, or is absent when no port is supplied. The App receives immutable addresses with nodeId, key, persistence and signal; clear receives an address without key. Port callbacks retain their receiver. Cancellation is checked before dispatch and after completion; it does not roll back an App write already started.
|
|
197
|
+
|
|
198
|
+
Persistence defaults to false. Passing true requires supportsPersistence: true; otherwise the call rejects. Volatile and persistent spaces are independent, with no fallback or mirroring. The App owns capacity, TTL, serialization, user isolation and storage. Cache partitions are not authorization boundaries. Fork resume may explicitly return agentCache to restore a borrowed port; it is never inherited implicitly. Cache data is not checkpoint state or durable business evidence, and captured views must not be used after cancellation.
|
|
@@ -187,3 +187,25 @@ Loop 将这些补充信息附在节点的完成或跳转记录上。报告失败
|
|
|
187
187
|
### 节点运行中的报告
|
|
188
188
|
|
|
189
189
|
`run(state, context)` 可调用 `context.report({ usedModel, payload, subs })` 报告进度。usedModel 是本次节点自身的可选布尔元数据,未上报保持未知;payload 和 subs 仅在内容获准时读取。subs 必须为 `context.createSubFlowLogger()` 创建的本次执行实例,不能提交手写数组。write 先记意图并返回 ID,fix 补后续结果,readAll 返回固定格式不可变快照;写入和补全复用现有 node.progress,短路或抛错也保留已送出的起始记录。报告不提交业务状态,也不决定路由;Loop 补齐节点身份、时间及事件序号。取消或节点结束后不接纳迟到写入。结束时的节点 `report(update, context)` 继续用于内容获准时的补充报告。合同见[子日志实例](observability.md#子日志实例与先记后补)。
|
|
190
|
+
|
|
191
|
+
## 节点缓存
|
|
192
|
+
|
|
193
|
+
Engine 构造参数 `cache: { maxEntries: 1024, ttlMs: 60000 }` 控制实例级内存缓存;maxEntries 缺省 1024,ttlMs 省略表示不按时间过期,采用全 Engine 的 LRU 淘汰,不使用后台定时器。值按引用保存,允许存 undefined,has 可区分未命中与已存 undefined。delete 返回是否删除,set/clear 返回 void。
|
|
194
|
+
|
|
195
|
+
每个节点通过 context.cache 访问自己的分区。get(key, { nodeId }) 可读取其他节点分区;has、set、delete、clear 只能操作当前节点,clear 不清空其他分区。run(step) 使用保留分区 `$step`。同一 Engine 跨 run 共享;fork 保留容量配置但不复制缓存数据。
|
|
196
|
+
|
|
197
|
+
可选构造参数 agentCache 接收 AgentCachePort。节点的 context.agentCache 是异步视图,未提供端口时不存在。端口用命名 address 接收 nodeId、key、persistence、signal;clear 接收不含 key 的分区地址。端口方法保留原对象 this。所有节点操作前后检查本次取消;取消不会回滚已开始的 App 存储写入。
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
// 在节点 run 中:
|
|
201
|
+
context.cache.set('selection', value);
|
|
202
|
+
const prior = context.cache.get('selection');
|
|
203
|
+
if (context.agentCache) {
|
|
204
|
+
await context.agentCache.set('summary', summary);
|
|
205
|
+
const saved = await context.agentCache.get('summary');
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
agentCache 每个操作默认 persistence: false;true 必须由 supportsPersistence: true 的 App 端口明确支持,否则拒绝。两种空间不回退、不镜像,delete/clear 仅影响指定空间及当前分区。App 负责容量、TTL、序列化、用户/租户隔离和持久化。节点分区不是授权边界,不复用权限结论。
|
|
210
|
+
|
|
211
|
+
缓存不进入 checkpoint/状态/Durable 恢复。fork 的 resume 可显式返回 agentCache 重新绑定 App 端口;默认不继承。需要重放或持久业务事实的数据仍使用原 state 补丁或 App 存储。取消后不可继续使用捕获的节点缓存视图。
|
|
@@ -70,3 +70,7 @@ Use `docs.acceptance` to select checks for concrete uncertainties, while followi
|
|
|
70
70
|
## Custom model transport boundaries
|
|
71
71
|
|
|
72
72
|
A custom response collector is synchronous and request-local. Its outputChars must be a nonnegative safe integer after creation, each push and finish. An explicit infinite maxOutputChars budget does not permit infinite or invalid collector counts. Core records the largest valid observed count; finish may release buffers. Optional call metadata is read once on a best-effort basis and cannot replace the actual result, original failure or cancellation. Prefer Support transports for standard native calls and JSON; custom transports must preserve complete parsing, budgets and cancellation before any tool execution.
|
|
73
|
+
|
|
74
|
+
## Node cache ownership
|
|
75
|
+
|
|
76
|
+
Use context.cache for bounded Engine memory; its default capacity is 1024, partitions are node-local and only get may name another node. The low-level step partition is `$step`. An App may explicitly inject an asynchronous AgentCachePort for context.agentCache. Persistence must be supported explicitly, is separate from volatile storage and never falls back. The App owns user isolation, serialization, capacity and expiry; do not cache authorization decisions. Forks start with empty Engine memory and only regain an App port when resume explicitly supplies agentCache. Keep durable facts in state/App storage, not caches.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EngineCache = void 0;
|
|
4
|
+
exports.cachePort = cachePort;
|
|
5
|
+
exports.cacheContext = cacheContext;
|
|
6
|
+
const cancellation_js_1 = require("../utils/cancellation.js");
|
|
7
|
+
/** Validate the small cache-specific boundary; business permissions remain App-owned. */
|
|
8
|
+
function textKey(value) {
|
|
9
|
+
if (typeof value !== 'string' || !value.length)
|
|
10
|
+
throw new TypeError('Cache keys must be nonempty strings');
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
/** Reject accidental cross-partition writes and unknown options instead of ignoring them. */
|
|
14
|
+
function optionsOf(value, allowed) {
|
|
15
|
+
if (value !== undefined &&
|
|
16
|
+
(!value ||
|
|
17
|
+
typeof value !== 'object' ||
|
|
18
|
+
Reflect.ownKeys(value).some((key) => typeof key !== 'string' || !allowed.includes(key))))
|
|
19
|
+
throw new TypeError('Invalid cache options');
|
|
20
|
+
}
|
|
21
|
+
/** Bounded LRU for one Engine; forks get new memory, and values are deliberately not cloned. */
|
|
22
|
+
class EngineCache {
|
|
23
|
+
entries = new Map();
|
|
24
|
+
limit;
|
|
25
|
+
ttl;
|
|
26
|
+
constructor(options = {}) {
|
|
27
|
+
this.limit = options.maxEntries ?? 1024;
|
|
28
|
+
this.ttl = options.ttlMs ?? Infinity;
|
|
29
|
+
if (!Number.isSafeInteger(this.limit) ||
|
|
30
|
+
this.limit < 1 ||
|
|
31
|
+
(this.ttl !== Infinity && (!Number.isSafeInteger(this.ttl) || this.ttl < 1)))
|
|
32
|
+
throw new TypeError('Invalid Engine cache bounds');
|
|
33
|
+
}
|
|
34
|
+
/** Remove expired entries lazily, without timers or background tasks. */
|
|
35
|
+
prune() {
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
for (const [key, item] of this.entries)
|
|
38
|
+
if (item.expires <= now)
|
|
39
|
+
this.entries.delete(key);
|
|
40
|
+
}
|
|
41
|
+
/** Capture an immutable partition and visit signal; no per-run cache is introduced. */
|
|
42
|
+
view(nodeId, signal) {
|
|
43
|
+
textKey(nodeId);
|
|
44
|
+
const prefix = JSON.stringify([nodeId]).slice(0, -1) + ',';
|
|
45
|
+
const address = (key, node = nodeId) => JSON.stringify([textKey(node), textKey(key)]);
|
|
46
|
+
const read = (key, node = nodeId) => {
|
|
47
|
+
signal.throwIfAborted();
|
|
48
|
+
const id = address(key, node);
|
|
49
|
+
const item = this.entries.get(id);
|
|
50
|
+
if (item && item.expires <= Date.now()) {
|
|
51
|
+
this.entries.delete(id);
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
if (item) {
|
|
55
|
+
this.entries.delete(id);
|
|
56
|
+
this.entries.set(id, item);
|
|
57
|
+
}
|
|
58
|
+
return item;
|
|
59
|
+
};
|
|
60
|
+
return Object.freeze({
|
|
61
|
+
get: (key, options) => {
|
|
62
|
+
optionsOf(options, ['nodeId']);
|
|
63
|
+
return read(key, options?.nodeId)?.value;
|
|
64
|
+
},
|
|
65
|
+
has: (key) => read(key) !== undefined,
|
|
66
|
+
set: (key, value) => {
|
|
67
|
+
signal.throwIfAborted();
|
|
68
|
+
const id = address(key);
|
|
69
|
+
this.prune();
|
|
70
|
+
this.entries.delete(id);
|
|
71
|
+
this.entries.set(id, { value, expires: Date.now() + this.ttl });
|
|
72
|
+
if (this.entries.size > this.limit)
|
|
73
|
+
this.entries.delete(this.entries.keys().next().value);
|
|
74
|
+
},
|
|
75
|
+
delete: (key) => {
|
|
76
|
+
signal.throwIfAborted();
|
|
77
|
+
return this.entries.delete(address(key));
|
|
78
|
+
},
|
|
79
|
+
clear: () => {
|
|
80
|
+
signal.throwIfAborted();
|
|
81
|
+
for (const key of this.entries.keys())
|
|
82
|
+
if (key.startsWith(prefix))
|
|
83
|
+
this.entries.delete(key);
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
exports.EngineCache = EngineCache;
|
|
89
|
+
/** Snapshot port callbacks with their receiver, rejecting incomplete App storage early. */
|
|
90
|
+
function cachePort(port) {
|
|
91
|
+
if (port === undefined)
|
|
92
|
+
return undefined;
|
|
93
|
+
const handlers = ['get', 'has', 'set', 'delete', 'clear'];
|
|
94
|
+
if (!port ||
|
|
95
|
+
handlers.some((key) => typeof port[key] !== 'function') ||
|
|
96
|
+
(port.supportsPersistence !== undefined && typeof port.supportsPersistence !== 'boolean'))
|
|
97
|
+
throw new TypeError('Invalid Agent cache port');
|
|
98
|
+
return Object.freeze({
|
|
99
|
+
supportsPersistence: port.supportsPersistence === true,
|
|
100
|
+
get: port.get.bind(port),
|
|
101
|
+
has: port.has.bind(port),
|
|
102
|
+
set: port.set.bind(port),
|
|
103
|
+
delete: port.delete.bind(port),
|
|
104
|
+
clear: port.clear.bind(port),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/** Build visit-local views. Persistent writes remain external side effects owned by the App. */
|
|
108
|
+
function cacheContext(memory, port, nodeId, signal) {
|
|
109
|
+
const partition = (options, crossRead = false) => {
|
|
110
|
+
signal.throwIfAborted();
|
|
111
|
+
optionsOf(options, crossRead ? ['nodeId', 'persistence'] : ['persistence']);
|
|
112
|
+
const persistence = options?.persistence ?? false;
|
|
113
|
+
if (typeof persistence !== 'boolean')
|
|
114
|
+
throw new TypeError('Invalid cache persistence');
|
|
115
|
+
if (persistence && !port?.supportsPersistence)
|
|
116
|
+
throw new TypeError('Agent cache persistence is unsupported');
|
|
117
|
+
return Object.freeze({
|
|
118
|
+
nodeId: textKey(crossRead ? (options?.nodeId ?? nodeId) : nodeId),
|
|
119
|
+
persistence,
|
|
120
|
+
signal,
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
const address = (key, options, crossRead = false) => Object.freeze({ ...partition(options, crossRead), key: textKey(key) });
|
|
124
|
+
const call = async (invoke) => {
|
|
125
|
+
signal.throwIfAborted();
|
|
126
|
+
const value = await (0, cancellation_js_1.awaitWithSignal)(invoke(), signal);
|
|
127
|
+
signal.throwIfAborted();
|
|
128
|
+
return value;
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
cache: memory.view(nodeId, signal),
|
|
132
|
+
...(port
|
|
133
|
+
? {
|
|
134
|
+
agentCache: Object.freeze({
|
|
135
|
+
get: (key, options) => call(() => port.get(address(key, options, true))),
|
|
136
|
+
has: (key, options) => call(async () => {
|
|
137
|
+
const result = await port.has(address(key, options));
|
|
138
|
+
if (typeof result !== 'boolean')
|
|
139
|
+
throw new TypeError('Agent cache has must return boolean');
|
|
140
|
+
return result;
|
|
141
|
+
}),
|
|
142
|
+
set: (key, value, options) => call(async () => {
|
|
143
|
+
const result = await port.set(address(key, options), value);
|
|
144
|
+
if (result !== undefined)
|
|
145
|
+
throw new TypeError('Agent cache set must return void');
|
|
146
|
+
}),
|
|
147
|
+
delete: (key, options) => call(async () => {
|
|
148
|
+
const result = await port.delete(address(key, options));
|
|
149
|
+
if (typeof result !== 'boolean')
|
|
150
|
+
throw new TypeError('Agent cache delete must return boolean');
|
|
151
|
+
return result;
|
|
152
|
+
}),
|
|
153
|
+
clear: (options) => call(async () => {
|
|
154
|
+
const result = await port.clear(partition(options));
|
|
155
|
+
if (result !== undefined)
|
|
156
|
+
throw new TypeError('Agent cache clear must return void');
|
|
157
|
+
}),
|
|
158
|
+
}),
|
|
159
|
+
}
|
|
160
|
+
: {}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -26,6 +26,7 @@ exports.AgentCoreEngine = void 0;
|
|
|
26
26
|
*
|
|
27
27
|
* Exports: `AgentCoreEngine` and the engine option/identity types.
|
|
28
28
|
*/
|
|
29
|
+
const cache_js_1 = require("./cache.js");
|
|
29
30
|
const call_budget_js_1 = require("../model/call-budget.js");
|
|
30
31
|
const runner_js_1 = require("../model/runner.js");
|
|
31
32
|
const binding_js_1 = require("../model/binding.js");
|
|
@@ -56,6 +57,8 @@ class AgentCoreEngine {
|
|
|
56
57
|
static AttemptBudget = workflow_js_1.AttemptBudget;
|
|
57
58
|
/** Factory for a validated workflow definition. */
|
|
58
59
|
static createWorkflow = workflow_js_1.defineWorkflow;
|
|
60
|
+
cacheMemory;
|
|
61
|
+
serviceCache;
|
|
59
62
|
maxRounds;
|
|
60
63
|
exhausted;
|
|
61
64
|
options;
|
|
@@ -306,6 +309,8 @@ class AgentCoreEngine {
|
|
|
306
309
|
(0, rules_js_1.breakWhile)(!!this.invokeTool && !dependencies?.tools, 'Fork requires a restored tool runtime');
|
|
307
310
|
const engine = new AgentCoreEngine({
|
|
308
311
|
maxRounds: this.maxRounds,
|
|
312
|
+
cache: this.options.cache,
|
|
313
|
+
agentCache: dependencies?.agentCache,
|
|
309
314
|
errors: { exhausted: this.exhausted },
|
|
310
315
|
execution: {
|
|
311
316
|
diagnostics: this.options.execution?.diagnostics,
|
|
@@ -350,6 +355,8 @@ class AgentCoreEngine {
|
|
|
350
355
|
['maxSteps', 'exhausted', 'signal', 'diagnostics', 'onWorkflowEvent', 'observe'].some((key) => key in settings)) {
|
|
351
356
|
throw new TypeError('Use maxRounds, execution (including observe) and errors');
|
|
352
357
|
}
|
|
358
|
+
this.cacheMemory = new cache_js_1.EngineCache(settings.cache);
|
|
359
|
+
this.serviceCache = (0, cache_js_1.cachePort)(settings.agentCache);
|
|
353
360
|
const execution = settings.execution;
|
|
354
361
|
if (settings.branch !== undefined)
|
|
355
362
|
this.branchIdentity = (0, branch_js_1.snapshotBranch)(settings.branch);
|
|
@@ -359,6 +366,7 @@ class AgentCoreEngine {
|
|
|
359
366
|
settings.errors?.exhausted?.bind(settings.errors) ??
|
|
360
367
|
(() => new errors_js_2.BudgetError('Agent round budget exhausted'));
|
|
361
368
|
this.options = {
|
|
369
|
+
cache: { ...settings.cache },
|
|
362
370
|
execution: {
|
|
363
371
|
signal: execution?.signal,
|
|
364
372
|
diagnostics: execution?.diagnostics,
|
|
@@ -478,6 +486,7 @@ class AgentCoreEngine {
|
|
|
478
486
|
phase = 'run';
|
|
479
487
|
signal.throwIfAborted();
|
|
480
488
|
const result = await (0, cancellation_js_1.awaitWithSignal)(step(index, {
|
|
489
|
+
...(0, cache_js_1.cacheContext)(this.cacheMemory, this.serviceCache, '$step', signal),
|
|
481
490
|
signal,
|
|
482
491
|
diagnostics,
|
|
483
492
|
report: reports.report.bind(reports),
|
|
@@ -673,6 +682,7 @@ class AgentCoreEngine {
|
|
|
673
682
|
});
|
|
674
683
|
const reports = new reporting_js_1.NodeReports(nodeDiagnostics, signal);
|
|
675
684
|
const context = {
|
|
685
|
+
...(0, cache_js_1.cacheContext)(this.cacheMemory, this.serviceCache, node.id, signal),
|
|
676
686
|
report: reports.report.bind(reports),
|
|
677
687
|
createSubFlowLogger: reports.createSubFlowLogger.bind(reports),
|
|
678
688
|
roundIndex: round,
|
|
@@ -80,11 +80,8 @@ class VectorSearch {
|
|
|
80
80
|
(!Number.isFinite(options.minScore) || options.minScore < -1 || options.minScore > 1)), 'Invalid vector query');
|
|
81
81
|
const query = normalize(vector, this.space.dimensions), groups = new Map();
|
|
82
82
|
for (const [id, { document, vector: value }] of this.entries) {
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
// removes the redundant clones without exposing the index.
|
|
86
|
-
const candidate = structuredClone(document);
|
|
87
|
-
const allowed = (0, synchronous_js_1.booleanResult)(options.filter(candidate), 'Vector filters must be synchronous', 'Vector filters must return boolean');
|
|
83
|
+
// Each callback receives its own snapshot; readonly types cannot prevent runtime mutation.
|
|
84
|
+
const allowed = (0, synchronous_js_1.booleanResult)(options.filter(structuredClone(document)), 'Vector filters must be synchronous', 'Vector filters must return boolean');
|
|
88
85
|
if (!allowed)
|
|
89
86
|
continue;
|
|
90
87
|
let score = 0;
|
|
@@ -93,12 +90,12 @@ class VectorSearch {
|
|
|
93
90
|
score = Math.max(-1, Math.min(1, score));
|
|
94
91
|
if (score < (options.minScore ?? -1))
|
|
95
92
|
continue;
|
|
96
|
-
const group = (0, synchronous_js_1.synchronous)(options.groupBy?.(
|
|
93
|
+
const group = (0, synchronous_js_1.synchronous)(options.groupBy?.(structuredClone(document)), 'Vector grouping must be synchronous') ?? id;
|
|
97
94
|
if (typeof group !== 'string')
|
|
98
95
|
throw new TypeError('Vector groups must be strings');
|
|
99
96
|
const prior = groups.get(group);
|
|
100
97
|
if (!prior || score > prior.score || (score === prior.score && id < prior.id))
|
|
101
|
-
groups.set(group, { id, score, document:
|
|
98
|
+
groups.set(group, { id, score, document: structuredClone(document) });
|
|
102
99
|
}
|
|
103
100
|
return [...groups.values()]
|
|
104
101
|
.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Node-partitioned cache contracts. Engine memory is synchronous; service-owned storage is
|
|
4
|
+
* asynchronous and explicitly selects volatile or persistent space. Neither is workflow state.
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine memory and borrowed service storage views. Partition ownership is captured per visit,
|
|
3
|
+
* never in a mutable service field. Cache calls do not create model budgets or execute tools.
|
|
4
|
+
*/
|
|
5
|
+
import type { AgentCachePort, CacheContext, EngineCacheOptions, NodeCache } from '../structs/execution/cache.js';
|
|
6
|
+
/** Bounded LRU for one Engine; forks get new memory, and values are deliberately not cloned. */
|
|
7
|
+
export declare class EngineCache {
|
|
8
|
+
private readonly entries;
|
|
9
|
+
private readonly limit;
|
|
10
|
+
private readonly ttl;
|
|
11
|
+
constructor(options?: EngineCacheOptions);
|
|
12
|
+
/** Remove expired entries lazily, without timers or background tasks. */
|
|
13
|
+
private prune;
|
|
14
|
+
/** Capture an immutable partition and visit signal; no per-run cache is introduced. */
|
|
15
|
+
view(nodeId: string, signal: AbortSignal): NodeCache;
|
|
16
|
+
}
|
|
17
|
+
/** Snapshot port callbacks with their receiver, rejecting incomplete App storage early. */
|
|
18
|
+
export declare function cachePort(port?: AgentCachePort): AgentCachePort | undefined;
|
|
19
|
+
/** Build visit-local views. Persistent writes remain external side effects owned by the App. */
|
|
20
|
+
export declare function cacheContext(memory: EngineCache, port: AgentCachePort | undefined, nodeId: string, signal: AbortSignal): CacheContext;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { awaitWithSignal } from '../utils/cancellation.js';
|
|
2
|
+
/** Validate the small cache-specific boundary; business permissions remain App-owned. */
|
|
3
|
+
function textKey(value) {
|
|
4
|
+
if (typeof value !== 'string' || !value.length)
|
|
5
|
+
throw new TypeError('Cache keys must be nonempty strings');
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
/** Reject accidental cross-partition writes and unknown options instead of ignoring them. */
|
|
9
|
+
function optionsOf(value, allowed) {
|
|
10
|
+
if (value !== undefined &&
|
|
11
|
+
(!value ||
|
|
12
|
+
typeof value !== 'object' ||
|
|
13
|
+
Reflect.ownKeys(value).some((key) => typeof key !== 'string' || !allowed.includes(key))))
|
|
14
|
+
throw new TypeError('Invalid cache options');
|
|
15
|
+
}
|
|
16
|
+
/** Bounded LRU for one Engine; forks get new memory, and values are deliberately not cloned. */
|
|
17
|
+
export class EngineCache {
|
|
18
|
+
entries = new Map();
|
|
19
|
+
limit;
|
|
20
|
+
ttl;
|
|
21
|
+
constructor(options = {}) {
|
|
22
|
+
this.limit = options.maxEntries ?? 1024;
|
|
23
|
+
this.ttl = options.ttlMs ?? Infinity;
|
|
24
|
+
if (!Number.isSafeInteger(this.limit) ||
|
|
25
|
+
this.limit < 1 ||
|
|
26
|
+
(this.ttl !== Infinity && (!Number.isSafeInteger(this.ttl) || this.ttl < 1)))
|
|
27
|
+
throw new TypeError('Invalid Engine cache bounds');
|
|
28
|
+
}
|
|
29
|
+
/** Remove expired entries lazily, without timers or background tasks. */
|
|
30
|
+
prune() {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
for (const [key, item] of this.entries)
|
|
33
|
+
if (item.expires <= now)
|
|
34
|
+
this.entries.delete(key);
|
|
35
|
+
}
|
|
36
|
+
/** Capture an immutable partition and visit signal; no per-run cache is introduced. */
|
|
37
|
+
view(nodeId, signal) {
|
|
38
|
+
textKey(nodeId);
|
|
39
|
+
const prefix = JSON.stringify([nodeId]).slice(0, -1) + ',';
|
|
40
|
+
const address = (key, node = nodeId) => JSON.stringify([textKey(node), textKey(key)]);
|
|
41
|
+
const read = (key, node = nodeId) => {
|
|
42
|
+
signal.throwIfAborted();
|
|
43
|
+
const id = address(key, node);
|
|
44
|
+
const item = this.entries.get(id);
|
|
45
|
+
if (item && item.expires <= Date.now()) {
|
|
46
|
+
this.entries.delete(id);
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
if (item) {
|
|
50
|
+
this.entries.delete(id);
|
|
51
|
+
this.entries.set(id, item);
|
|
52
|
+
}
|
|
53
|
+
return item;
|
|
54
|
+
};
|
|
55
|
+
return Object.freeze({
|
|
56
|
+
get: (key, options) => {
|
|
57
|
+
optionsOf(options, ['nodeId']);
|
|
58
|
+
return read(key, options?.nodeId)?.value;
|
|
59
|
+
},
|
|
60
|
+
has: (key) => read(key) !== undefined,
|
|
61
|
+
set: (key, value) => {
|
|
62
|
+
signal.throwIfAborted();
|
|
63
|
+
const id = address(key);
|
|
64
|
+
this.prune();
|
|
65
|
+
this.entries.delete(id);
|
|
66
|
+
this.entries.set(id, { value, expires: Date.now() + this.ttl });
|
|
67
|
+
if (this.entries.size > this.limit)
|
|
68
|
+
this.entries.delete(this.entries.keys().next().value);
|
|
69
|
+
},
|
|
70
|
+
delete: (key) => {
|
|
71
|
+
signal.throwIfAborted();
|
|
72
|
+
return this.entries.delete(address(key));
|
|
73
|
+
},
|
|
74
|
+
clear: () => {
|
|
75
|
+
signal.throwIfAborted();
|
|
76
|
+
for (const key of this.entries.keys())
|
|
77
|
+
if (key.startsWith(prefix))
|
|
78
|
+
this.entries.delete(key);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Snapshot port callbacks with their receiver, rejecting incomplete App storage early. */
|
|
84
|
+
export function cachePort(port) {
|
|
85
|
+
if (port === undefined)
|
|
86
|
+
return undefined;
|
|
87
|
+
const handlers = ['get', 'has', 'set', 'delete', 'clear'];
|
|
88
|
+
if (!port ||
|
|
89
|
+
handlers.some((key) => typeof port[key] !== 'function') ||
|
|
90
|
+
(port.supportsPersistence !== undefined && typeof port.supportsPersistence !== 'boolean'))
|
|
91
|
+
throw new TypeError('Invalid Agent cache port');
|
|
92
|
+
return Object.freeze({
|
|
93
|
+
supportsPersistence: port.supportsPersistence === true,
|
|
94
|
+
get: port.get.bind(port),
|
|
95
|
+
has: port.has.bind(port),
|
|
96
|
+
set: port.set.bind(port),
|
|
97
|
+
delete: port.delete.bind(port),
|
|
98
|
+
clear: port.clear.bind(port),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/** Build visit-local views. Persistent writes remain external side effects owned by the App. */
|
|
102
|
+
export function cacheContext(memory, port, nodeId, signal) {
|
|
103
|
+
const partition = (options, crossRead = false) => {
|
|
104
|
+
signal.throwIfAborted();
|
|
105
|
+
optionsOf(options, crossRead ? ['nodeId', 'persistence'] : ['persistence']);
|
|
106
|
+
const persistence = options?.persistence ?? false;
|
|
107
|
+
if (typeof persistence !== 'boolean')
|
|
108
|
+
throw new TypeError('Invalid cache persistence');
|
|
109
|
+
if (persistence && !port?.supportsPersistence)
|
|
110
|
+
throw new TypeError('Agent cache persistence is unsupported');
|
|
111
|
+
return Object.freeze({
|
|
112
|
+
nodeId: textKey(crossRead ? (options?.nodeId ?? nodeId) : nodeId),
|
|
113
|
+
persistence,
|
|
114
|
+
signal,
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
const address = (key, options, crossRead = false) => Object.freeze({ ...partition(options, crossRead), key: textKey(key) });
|
|
118
|
+
const call = async (invoke) => {
|
|
119
|
+
signal.throwIfAborted();
|
|
120
|
+
const value = await awaitWithSignal(invoke(), signal);
|
|
121
|
+
signal.throwIfAborted();
|
|
122
|
+
return value;
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
cache: memory.view(nodeId, signal),
|
|
126
|
+
...(port
|
|
127
|
+
? {
|
|
128
|
+
agentCache: Object.freeze({
|
|
129
|
+
get: (key, options) => call(() => port.get(address(key, options, true))),
|
|
130
|
+
has: (key, options) => call(async () => {
|
|
131
|
+
const result = await port.has(address(key, options));
|
|
132
|
+
if (typeof result !== 'boolean')
|
|
133
|
+
throw new TypeError('Agent cache has must return boolean');
|
|
134
|
+
return result;
|
|
135
|
+
}),
|
|
136
|
+
set: (key, value, options) => call(async () => {
|
|
137
|
+
const result = await port.set(address(key, options), value);
|
|
138
|
+
if (result !== undefined)
|
|
139
|
+
throw new TypeError('Agent cache set must return void');
|
|
140
|
+
}),
|
|
141
|
+
delete: (key, options) => call(async () => {
|
|
142
|
+
const result = await port.delete(address(key, options));
|
|
143
|
+
if (typeof result !== 'boolean')
|
|
144
|
+
throw new TypeError('Agent cache delete must return boolean');
|
|
145
|
+
return result;
|
|
146
|
+
}),
|
|
147
|
+
clear: (options) => call(async () => {
|
|
148
|
+
const result = await port.clear(partition(options));
|
|
149
|
+
if (result !== undefined)
|
|
150
|
+
throw new TypeError('Agent cache clear must return void');
|
|
151
|
+
}),
|
|
152
|
+
}),
|
|
153
|
+
}
|
|
154
|
+
: {}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AgentCachePort } from '../structs/execution/cache.js';
|
|
1
2
|
import { ModelRunner } from '../model/runner.js';
|
|
2
3
|
import type { ModelRunnerOptions } from '../structs/model/runtime.js';
|
|
3
4
|
import type { ToolRegistry } from '../capabilities/tools.js';
|
|
@@ -5,6 +6,7 @@ import type { AgentCoreEngineOptions, CoreStep } from '../structs/execution/engi
|
|
|
5
6
|
import { AgentWorkflow, AttemptBudget, defineWorkflow } from './workflow.js';
|
|
6
7
|
import type { WorkflowExtension } from '../structs/execution/workflow.js';
|
|
7
8
|
export * from './workflow.js';
|
|
9
|
+
export type * from '../structs/execution/cache.js';
|
|
8
10
|
type InvokeTool = (request: import('../structs/capabilities/tools.js').ToolInvocationRequest) => Promise<import('../structs/invocation.js').ToolResult<unknown>>;
|
|
9
11
|
type ToolBinding = {
|
|
10
12
|
invoke: InvokeTool;
|
|
@@ -109,6 +111,8 @@ export declare class AgentCoreEngine<Result, State = unknown, App = undefined> {
|
|
|
109
111
|
static readonly AttemptBudget: typeof AttemptBudget;
|
|
110
112
|
/** Factory for a validated workflow definition. */
|
|
111
113
|
static readonly createWorkflow: typeof defineWorkflow;
|
|
114
|
+
private readonly cacheMemory;
|
|
115
|
+
private readonly serviceCache?;
|
|
112
116
|
private readonly maxRounds;
|
|
113
117
|
private readonly exhausted;
|
|
114
118
|
private readonly options;
|
|
@@ -264,6 +268,7 @@ export declare class AgentCoreEngine<Result, State = unknown, App = undefined> {
|
|
|
264
268
|
app?: App;
|
|
265
269
|
tools?: ToolBinding;
|
|
266
270
|
model?: AgentModelRuntime;
|
|
271
|
+
agentCache?: AgentCachePort;
|
|
267
272
|
};
|
|
268
273
|
}): AgentCoreEngine<Result, State, App>;
|
|
269
274
|
/**
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Exports: `AgentCoreEngine` and the engine option/identity types.
|
|
11
11
|
*/
|
|
12
|
+
import { EngineCache, cacheContext, cachePort } from './cache.js';
|
|
12
13
|
import { ModelCallBudget, parentModelBudget } from '../model/call-budget.js';
|
|
13
14
|
import { ModelRunner } from '../model/runner.js';
|
|
14
15
|
import { compiledModelBinding, modelBindingId, snapshotModelBinding, } from '../model/binding.js';
|
|
@@ -39,6 +40,8 @@ export class AgentCoreEngine {
|
|
|
39
40
|
static AttemptBudget = AttemptBudget;
|
|
40
41
|
/** Factory for a validated workflow definition. */
|
|
41
42
|
static createWorkflow = defineWorkflow;
|
|
43
|
+
cacheMemory;
|
|
44
|
+
serviceCache;
|
|
42
45
|
maxRounds;
|
|
43
46
|
exhausted;
|
|
44
47
|
options;
|
|
@@ -289,6 +292,8 @@ export class AgentCoreEngine {
|
|
|
289
292
|
breakWhile(!!this.invokeTool && !dependencies?.tools, 'Fork requires a restored tool runtime');
|
|
290
293
|
const engine = new AgentCoreEngine({
|
|
291
294
|
maxRounds: this.maxRounds,
|
|
295
|
+
cache: this.options.cache,
|
|
296
|
+
agentCache: dependencies?.agentCache,
|
|
292
297
|
errors: { exhausted: this.exhausted },
|
|
293
298
|
execution: {
|
|
294
299
|
diagnostics: this.options.execution?.diagnostics,
|
|
@@ -333,6 +338,8 @@ export class AgentCoreEngine {
|
|
|
333
338
|
['maxSteps', 'exhausted', 'signal', 'diagnostics', 'onWorkflowEvent', 'observe'].some((key) => key in settings)) {
|
|
334
339
|
throw new TypeError('Use maxRounds, execution (including observe) and errors');
|
|
335
340
|
}
|
|
341
|
+
this.cacheMemory = new EngineCache(settings.cache);
|
|
342
|
+
this.serviceCache = cachePort(settings.agentCache);
|
|
336
343
|
const execution = settings.execution;
|
|
337
344
|
if (settings.branch !== undefined)
|
|
338
345
|
this.branchIdentity = snapshotBranch(settings.branch);
|
|
@@ -342,6 +349,7 @@ export class AgentCoreEngine {
|
|
|
342
349
|
settings.errors?.exhausted?.bind(settings.errors) ??
|
|
343
350
|
(() => new BudgetError('Agent round budget exhausted'));
|
|
344
351
|
this.options = {
|
|
352
|
+
cache: { ...settings.cache },
|
|
345
353
|
execution: {
|
|
346
354
|
signal: execution?.signal,
|
|
347
355
|
diagnostics: execution?.diagnostics,
|
|
@@ -461,6 +469,7 @@ export class AgentCoreEngine {
|
|
|
461
469
|
phase = 'run';
|
|
462
470
|
signal.throwIfAborted();
|
|
463
471
|
const result = await awaitWithSignal(step(index, {
|
|
472
|
+
...cacheContext(this.cacheMemory, this.serviceCache, '$step', signal),
|
|
464
473
|
signal,
|
|
465
474
|
diagnostics,
|
|
466
475
|
report: reports.report.bind(reports),
|
|
@@ -656,6 +665,7 @@ export class AgentCoreEngine {
|
|
|
656
665
|
});
|
|
657
666
|
const reports = new NodeReports(nodeDiagnostics, signal);
|
|
658
667
|
const context = {
|
|
668
|
+
...cacheContext(this.cacheMemory, this.serviceCache, node.id, signal),
|
|
659
669
|
report: reports.report.bind(reports),
|
|
660
670
|
createSubFlowLogger: reports.createSubFlowLogger.bind(reports),
|
|
661
671
|
roundIndex: round,
|
|
@@ -77,11 +77,8 @@ export class VectorSearch {
|
|
|
77
77
|
(!Number.isFinite(options.minScore) || options.minScore < -1 || options.minScore > 1)), 'Invalid vector query');
|
|
78
78
|
const query = normalize(vector, this.space.dimensions), groups = new Map();
|
|
79
79
|
for (const [id, { document, vector: value }] of this.entries) {
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
// removes the redundant clones without exposing the index.
|
|
83
|
-
const candidate = structuredClone(document);
|
|
84
|
-
const allowed = booleanResult(options.filter(candidate), 'Vector filters must be synchronous', 'Vector filters must return boolean');
|
|
80
|
+
// Each callback receives its own snapshot; readonly types cannot prevent runtime mutation.
|
|
81
|
+
const allowed = booleanResult(options.filter(structuredClone(document)), 'Vector filters must be synchronous', 'Vector filters must return boolean');
|
|
85
82
|
if (!allowed)
|
|
86
83
|
continue;
|
|
87
84
|
let score = 0;
|
|
@@ -90,12 +87,12 @@ export class VectorSearch {
|
|
|
90
87
|
score = Math.max(-1, Math.min(1, score));
|
|
91
88
|
if (score < (options.minScore ?? -1))
|
|
92
89
|
continue;
|
|
93
|
-
const group = synchronous(options.groupBy?.(
|
|
90
|
+
const group = synchronous(options.groupBy?.(structuredClone(document)), 'Vector grouping must be synchronous') ?? id;
|
|
94
91
|
if (typeof group !== 'string')
|
|
95
92
|
throw new TypeError('Vector groups must be strings');
|
|
96
93
|
const prior = groups.get(group);
|
|
97
94
|
if (!prior || score > prior.score || (score === prior.score && id < prior.id))
|
|
98
|
-
groups.set(group, { id, score, document:
|
|
95
|
+
groups.set(group, { id, score, document: structuredClone(document) });
|
|
99
96
|
}
|
|
100
97
|
return [...groups.values()]
|
|
101
98
|
.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
|