@warlock.js/ai 4.15.0 → 5.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/CHANGELOG.md +183 -158
- package/cjs/index.cjs +637 -104
- package/cjs/index.cjs.map +1 -1
- package/esm/contracts/index.d.mts +2 -2
- package/esm/contracts/memory/index.d.mts +1 -1
- package/esm/contracts/memory/memory-config.type.d.mts +29 -3
- package/esm/contracts/memory/memory-config.type.d.mts.map +1 -1
- package/esm/contracts/memory/memory-item.type.d.mts +15 -1
- package/esm/contracts/memory/memory-item.type.d.mts.map +1 -1
- package/esm/contracts/memory/memory.contract.d.mts +15 -2
- package/esm/contracts/memory/memory.contract.d.mts.map +1 -1
- package/esm/contracts/memory/recall-options.type.d.mts +12 -0
- package/esm/contracts/memory/recall-options.type.d.mts.map +1 -1
- package/esm/contracts/orchestrator/index.d.mts +1 -1
- package/esm/contracts/orchestrator/orchestrator-config.type.d.mts +38 -1
- package/esm/contracts/orchestrator/orchestrator-config.type.d.mts.map +1 -1
- package/esm/contracts/orchestrator/orchestrator.contract.d.mts +67 -3
- package/esm/contracts/orchestrator/orchestrator.contract.d.mts.map +1 -1
- package/esm/contracts/supervisor/supervisor-config.type.d.mts +23 -0
- package/esm/contracts/supervisor/supervisor-config.type.d.mts.map +1 -1
- package/esm/index.d.mts +6 -5
- package/esm/index.mjs +3 -2
- package/esm/memory/episodic-memory.mjs +14 -6
- package/esm/memory/episodic-memory.mjs.map +1 -1
- package/esm/memory/index.d.mts +1 -1
- package/esm/memory/memory.d.mts +13 -1
- package/esm/memory/memory.d.mts.map +1 -1
- package/esm/memory/memory.mjs +41 -7
- package/esm/memory/memory.mjs.map +1 -1
- package/esm/memory/procedural-memory.mjs +20 -7
- package/esm/memory/procedural-memory.mjs.map +1 -1
- package/esm/memory/semantic-memory.mjs +27 -10
- package/esm/memory/semantic-memory.mjs.map +1 -1
- package/esm/memory/working-memory.mjs +70 -13
- package/esm/memory/working-memory.mjs.map +1 -1
- package/esm/middleware/builtins/semantic-cache.d.mts +46 -1
- package/esm/middleware/builtins/semantic-cache.d.mts.map +1 -1
- package/esm/middleware/builtins/semantic-cache.mjs +60 -15
- package/esm/middleware/builtins/semantic-cache.mjs.map +1 -1
- package/esm/middleware/index.d.mts +1 -1
- package/esm/orchestrator/as-tool.d.mts +35 -9
- package/esm/orchestrator/as-tool.d.mts.map +1 -1
- package/esm/orchestrator/as-tool.mjs +67 -19
- package/esm/orchestrator/as-tool.mjs.map +1 -1
- package/esm/orchestrator/execution.d.mts.map +1 -1
- package/esm/orchestrator/execution.mjs +2 -2
- package/esm/orchestrator/execution.mjs.map +1 -1
- package/esm/orchestrator/index.d.mts +1 -1
- package/esm/orchestrator/index.mjs +1 -1
- package/esm/orchestrator/memory.d.mts +41 -5
- package/esm/orchestrator/memory.d.mts.map +1 -1
- package/esm/orchestrator/memory.mjs +53 -5
- package/esm/orchestrator/memory.mjs.map +1 -1
- package/esm/planner/plan-schema.d.mts +3 -3
- package/esm/planner/plan-schema.d.mts.map +1 -1
- package/esm/planner/plan-schema.mjs +30 -0
- package/esm/planner/plan-schema.mjs.map +1 -1
- package/esm/security/index.mjs +1 -0
- package/esm/security/outbound-policy.d.mts +9 -0
- package/esm/security/outbound-policy.d.mts.map +1 -1
- package/esm/security/outbound-policy.mjs +79 -5
- package/esm/security/outbound-policy.mjs.map +1 -1
- package/esm/security/outbound-policy.type.d.mts +8 -0
- package/esm/security/outbound-policy.type.d.mts.map +1 -1
- package/esm/security/safe-merge.d.mts +52 -0
- package/esm/security/safe-merge.d.mts.map +1 -0
- package/esm/security/safe-merge.mjs +68 -0
- package/esm/security/safe-merge.mjs.map +1 -0
- package/esm/supervisor/decide.mjs +52 -5
- package/esm/supervisor/decide.mjs.map +1 -1
- package/esm/supervisor/execution.d.mts +22 -0
- package/esm/supervisor/execution.d.mts.map +1 -1
- package/esm/supervisor/execution.mjs +46 -9
- package/esm/supervisor/execution.mjs.map +1 -1
- package/esm/supervisor/supervisor.mjs +4 -0
- package/esm/supervisor/supervisor.mjs.map +1 -1
- package/llms-full.txt +174 -10
- package/llms.txt +4 -3
- package/package.json +4 -4
- package/skills/README.md +5 -1
- package/skills/attach-ai-middleware/SKILL.md +17 -1
- package/skills/rag-loaders-and-stores/SKILL.md +3 -0
- package/skills/run-ai-agent/SKILL.md +3 -0
- package/skills/run-orchestrator/SKILL.md +6 -1
- package/skills/run-planner/SKILL.md +7 -3
- package/skills/run-supervisor/SKILL.md +11 -1
- package/skills/secure-outbound-requests/SKILL.md +85 -0
- package/skills/use-ai-memory/SKILL.md +36 -3
- package/skills/use-runtime-skills/SKILL.md +2 -1
package/cjs/index.cjs
CHANGED
|
@@ -1487,6 +1487,22 @@ function isPrivateIpv6(ip) {
|
|
|
1487
1487
|
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
|
|
1488
1488
|
/** 10s — default per-request timeout. */
|
|
1489
1489
|
const DEFAULT_TIMEOUT_MS = 1e4;
|
|
1490
|
+
/** Default cap on the number of policy-validated redirect hops. */
|
|
1491
|
+
const DEFAULT_MAX_REDIRECTS = 5;
|
|
1492
|
+
/** 3xx statuses whose `Location` a follow re-issues. */
|
|
1493
|
+
const REDIRECT_STATUSES = new Set([
|
|
1494
|
+
301,
|
|
1495
|
+
302,
|
|
1496
|
+
303,
|
|
1497
|
+
307,
|
|
1498
|
+
308
|
|
1499
|
+
]);
|
|
1500
|
+
/** Credential headers that must not survive a cross-origin redirect. */
|
|
1501
|
+
const CROSS_ORIGIN_STRIP_HEADERS = [
|
|
1502
|
+
"authorization",
|
|
1503
|
+
"cookie",
|
|
1504
|
+
"proxy-authorization"
|
|
1505
|
+
];
|
|
1490
1506
|
/**
|
|
1491
1507
|
* Fill an {@link OutboundPolicy} with strict defaults: https-only,
|
|
1492
1508
|
* private-IP deny on, 10s timeout, 5 MiB cap, global `fetch`. Idempotent
|
|
@@ -1499,6 +1515,7 @@ function resolveOutboundPolicy(policy = {}) {
|
|
|
1499
1515
|
denyPrivateIPsAfterDNS: policy.denyPrivateIPsAfterDNS ?? true,
|
|
1500
1516
|
maxBytes: policy.maxBytes ?? DEFAULT_MAX_BYTES,
|
|
1501
1517
|
timeoutMs: policy.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
1518
|
+
maxRedirects: policy.maxRedirects ?? DEFAULT_MAX_REDIRECTS,
|
|
1502
1519
|
signal: policy.signal,
|
|
1503
1520
|
fetch: policy.fetch ?? globalThis.fetch
|
|
1504
1521
|
};
|
|
@@ -1585,16 +1602,33 @@ function mergeSignals(timeout, external) {
|
|
|
1585
1602
|
else external.addEventListener("abort", () => abort(external), { once: true });
|
|
1586
1603
|
return controller.signal;
|
|
1587
1604
|
}
|
|
1605
|
+
/** Flatten a headers init into a mutable lower-cased-key record. */
|
|
1606
|
+
function headersToRecord(headersInit) {
|
|
1607
|
+
const record = {};
|
|
1608
|
+
new Headers(headersInit).forEach((value, key) => {
|
|
1609
|
+
record[key] = value;
|
|
1610
|
+
});
|
|
1611
|
+
return record;
|
|
1612
|
+
}
|
|
1588
1613
|
/**
|
|
1589
1614
|
* Policy-guarded `fetch`: validates the URL ({@link assertUrlAllowed}),
|
|
1590
1615
|
* then performs the request with the policy's timeout and (optional)
|
|
1591
1616
|
* caller signal merged. Returns the raw `Response` — read its body via
|
|
1592
1617
|
* {@link readTextCapped} to enforce `maxBytes`. Throws
|
|
1593
1618
|
* {@link OutboundPolicyError} on a policy violation or timeout.
|
|
1619
|
+
*
|
|
1620
|
+
* Redirects are NEVER delegated to the platform: every hop is issued
|
|
1621
|
+
* with `redirect: "manual"` and its `Location` is re-run through
|
|
1622
|
+
* {@link assertUrlAllowed} before being followed (capped at
|
|
1623
|
+
* `maxRedirects`), so a 3xx from an allowed host cannot smuggle the
|
|
1624
|
+
* request to a private / metadata / off-allowlist target. Credential
|
|
1625
|
+
* headers are stripped when a hop crosses an origin boundary. Pass
|
|
1626
|
+
* `init.redirect: "manual"` to receive the raw 3xx, or `"error"` to
|
|
1627
|
+
* reject on any redirect.
|
|
1594
1628
|
*/
|
|
1595
1629
|
async function guardedFetch(rawUrl, policyInput, init) {
|
|
1596
1630
|
const policy = resolveOutboundPolicy(policyInput);
|
|
1597
|
-
|
|
1631
|
+
let url = await assertUrlAllowed(rawUrl, policy);
|
|
1598
1632
|
const timeoutController = new AbortController();
|
|
1599
1633
|
const timer = setTimeout(() => {
|
|
1600
1634
|
timeoutController.abort(new OutboundPolicyError(`outbound request timed out after ${policy.timeoutMs}ms`, { context: {
|
|
@@ -1602,11 +1636,51 @@ async function guardedFetch(rawUrl, policyInput, init) {
|
|
|
1602
1636
|
timeoutMs: policy.timeoutMs
|
|
1603
1637
|
} }));
|
|
1604
1638
|
}, policy.timeoutMs);
|
|
1639
|
+
const signal = mergeSignals(timeoutController.signal, policy.signal);
|
|
1640
|
+
const redirectMode = init?.redirect ?? "follow";
|
|
1641
|
+
const headers = headersToRecord(init?.headers);
|
|
1642
|
+
let method = init?.method ?? "GET";
|
|
1643
|
+
let body = init?.body ?? void 0;
|
|
1605
1644
|
try {
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1645
|
+
for (let hop = 0;; hop++) {
|
|
1646
|
+
const response = await policy.fetch(url, {
|
|
1647
|
+
...init,
|
|
1648
|
+
method,
|
|
1649
|
+
headers: { ...headers },
|
|
1650
|
+
body,
|
|
1651
|
+
redirect: "manual",
|
|
1652
|
+
signal
|
|
1653
|
+
});
|
|
1654
|
+
const location = response.headers.get("location");
|
|
1655
|
+
if (!REDIRECT_STATUSES.has(response.status) || location === null) return response;
|
|
1656
|
+
if (redirectMode === "manual") return response;
|
|
1657
|
+
if (redirectMode === "error") throw new OutboundPolicyError(`outbound request blocked — redirect received with redirect: "error" (${response.status} → ${location})`, { context: {
|
|
1658
|
+
url: url.toString(),
|
|
1659
|
+
location,
|
|
1660
|
+
status: response.status
|
|
1661
|
+
} });
|
|
1662
|
+
if (hop >= policy.maxRedirects) throw new OutboundPolicyError(`outbound request blocked — more than ${policy.maxRedirects} redirects`, { context: {
|
|
1663
|
+
url: rawUrl,
|
|
1664
|
+
maxRedirects: policy.maxRedirects
|
|
1665
|
+
} });
|
|
1666
|
+
let target;
|
|
1667
|
+
try {
|
|
1668
|
+
target = new URL(location, url);
|
|
1669
|
+
} catch {
|
|
1670
|
+
throw new OutboundPolicyError(`outbound request blocked — invalid redirect Location: ${location}`, { context: {
|
|
1671
|
+
url: url.toString(),
|
|
1672
|
+
location
|
|
1673
|
+
} });
|
|
1674
|
+
}
|
|
1675
|
+
const next = await assertUrlAllowed(target.toString(), policy);
|
|
1676
|
+
if (response.body) await response.body.cancel().catch(() => void 0);
|
|
1677
|
+
if (next.origin !== url.origin) for (const name of CROSS_ORIGIN_STRIP_HEADERS) delete headers[name];
|
|
1678
|
+
if (response.status === 303 || (response.status === 301 || response.status === 302) && method !== "GET" && method !== "HEAD") {
|
|
1679
|
+
method = "GET";
|
|
1680
|
+
body = void 0;
|
|
1681
|
+
}
|
|
1682
|
+
url = next;
|
|
1683
|
+
}
|
|
1610
1684
|
} finally {
|
|
1611
1685
|
clearTimeout(timer);
|
|
1612
1686
|
}
|
|
@@ -2872,6 +2946,13 @@ function resolveDefaultSnapshotStore() {
|
|
|
2872
2946
|
//#region ../ai/src/middleware/builtins/semantic-cache.ts
|
|
2873
2947
|
const DEFAULT_NAMESPACE = "ai.cache";
|
|
2874
2948
|
/**
|
|
2949
|
+
* Extra candidates pulled from `similar()` on a SCOPED lookup before the
|
|
2950
|
+
* scope filter runs. The driver ranks across every scope in the index,
|
|
2951
|
+
* so a bare `topK: 1` can come back as a foreign entry and mask this
|
|
2952
|
+
* scope's own legitimate hit. Mirrors the memory tiers' overscan.
|
|
2953
|
+
*/
|
|
2954
|
+
const SIMILAR_OVERSCAN = 5;
|
|
2955
|
+
/**
|
|
2875
2956
|
* Build a stable fingerprint for a prompt covering the full message
|
|
2876
2957
|
* list (system + history + user turn). Ensures two prompts sharing
|
|
2877
2958
|
* the user text but differing in prior context do not collide on
|
|
@@ -2883,9 +2964,12 @@ const DEFAULT_NAMESPACE = "ai.cache";
|
|
|
2883
2964
|
* trust model.
|
|
2884
2965
|
*/
|
|
2885
2966
|
function hashPrompt(messages) {
|
|
2886
|
-
|
|
2967
|
+
return fnv1a(messages.map((message) => {
|
|
2887
2968
|
return `${message.role}:${Array.isArray(message.content) ? message.content.filter((part) => part.type === "text").map((part) => part.text).join("|") : message.content}`;
|
|
2888
|
-
}).join("||");
|
|
2969
|
+
}).join("||"));
|
|
2970
|
+
}
|
|
2971
|
+
/** FNV-1a over a string — see {@link hashPrompt} for the caveats. */
|
|
2972
|
+
function fnv1a(serialized) {
|
|
2889
2973
|
let hash = 2166136261;
|
|
2890
2974
|
for (let index = 0; index < serialized.length; index++) {
|
|
2891
2975
|
hash ^= serialized.charCodeAt(index);
|
|
@@ -2893,6 +2977,28 @@ function hashPrompt(messages) {
|
|
|
2893
2977
|
}
|
|
2894
2978
|
return (hash >>> 0).toString(16);
|
|
2895
2979
|
}
|
|
2980
|
+
/**
|
|
2981
|
+
* Resolve the isolation key this trip reads and writes under.
|
|
2982
|
+
*
|
|
2983
|
+
* Derived from the run's own `sessionId` (or the developer's resolver) —
|
|
2984
|
+
* never from the prompt, the model's output, or anything the LLM can
|
|
2985
|
+
* write to. `"shared"` and an unidentified run both resolve to
|
|
2986
|
+
* `undefined`, i.e. the unscoped pool, which a scoped lookup can never
|
|
2987
|
+
* read.
|
|
2988
|
+
*/
|
|
2989
|
+
function resolveScope(scope, context) {
|
|
2990
|
+
if (scope === "shared") return;
|
|
2991
|
+
const key = typeof scope === "function" ? scope(context) : sessionScope(context.options?.sessionId);
|
|
2992
|
+
return key ? key : void 0;
|
|
2993
|
+
}
|
|
2994
|
+
/**
|
|
2995
|
+
* The default `"session"` key: the session id under a reserved prefix so
|
|
2996
|
+
* a custom resolver returning a bare tenant id can't collide with a
|
|
2997
|
+
* session pool. Mirrors the orchestrator's `sessionMemoryScope`.
|
|
2998
|
+
*/
|
|
2999
|
+
function sessionScope(sessionId) {
|
|
3000
|
+
return sessionId ? `session:${sessionId}` : void 0;
|
|
3001
|
+
}
|
|
2896
3002
|
function isFresh(entry, ttlMs) {
|
|
2897
3003
|
if (ttlMs === void 0) return true;
|
|
2898
3004
|
return Date.now() - entry.storedAt <= ttlMs;
|
|
@@ -2936,6 +3042,18 @@ function isFresh(entry, ttlMs) {
|
|
|
2936
3042
|
* `usage: { input: 0, output: 0, total: 0 }` so budget /
|
|
2937
3043
|
* observability correctly exclude the saved trip.
|
|
2938
3044
|
*
|
|
3045
|
+
* **Per-session scoping (4.15.0).** One `semanticCache` instance
|
|
3046
|
+
* normally serves every end user, and a hit is returned as the answer
|
|
3047
|
+
* with no model call in between — so entries are keyed by the run's
|
|
3048
|
+
* `sessionId` (`scope`, default `"session"`) and a lookup only ever
|
|
3049
|
+
* sees entries written under the same key. Runs made without a
|
|
3050
|
+
* `sessionId` share one unscoped pool; pass `sessionId` on
|
|
3051
|
+
* `agent.execute()` (composites thread their own through automatically)
|
|
3052
|
+
* to get the isolation, or set `scope: "shared"` to pool deliberately.
|
|
3053
|
+
* Note the cost/benefit shift: scoping trades cross-user hit rate for
|
|
3054
|
+
* isolation, so public-FAQ deployments where no response can carry a
|
|
3055
|
+
* caller's private context should opt into `"shared"` explicitly.
|
|
3056
|
+
*
|
|
2939
3057
|
* @example
|
|
2940
3058
|
* import { semanticCache } from "@warlock.js/ai";
|
|
2941
3059
|
* import { MemoryCacheDriver } from "@warlock.js/cache";
|
|
@@ -2955,10 +3073,11 @@ function isFresh(entry, ttlMs) {
|
|
|
2955
3073
|
function semanticCache(options) {
|
|
2956
3074
|
const name = options.name ?? "semantic-cache";
|
|
2957
3075
|
const namespace = options.namespace ?? DEFAULT_NAMESPACE;
|
|
3076
|
+
const scopeMode = options.scope ?? "session";
|
|
2958
3077
|
const pendingKey = `${name}.pending`;
|
|
2959
3078
|
const store = options.store ?? resolveDefaultStore();
|
|
2960
3079
|
if (!store) throw new Error(`semanticCache: no store supplied — pass \`store\` in options or call \`ai.config({ defaultStore })\` at app boot before constructing the middleware`);
|
|
2961
|
-
const keyFor = (hash) => `${namespace}.${hash}`;
|
|
3080
|
+
const keyFor = (hash, scope) => scope === void 0 ? `${namespace}.${hash}` : `${namespace}.${fnv1a(scope)}.${hash}`;
|
|
2962
3081
|
return {
|
|
2963
3082
|
name,
|
|
2964
3083
|
log: true,
|
|
@@ -2967,21 +3086,20 @@ function semanticCache(options) {
|
|
|
2967
3086
|
if (context.tripIndex !== 0) return;
|
|
2968
3087
|
const promptText = extractUserText(context.messages);
|
|
2969
3088
|
if (!promptText) return;
|
|
3089
|
+
const scope = resolveScope(scopeMode, context);
|
|
2970
3090
|
const promptKey = hashPrompt(context.messages);
|
|
2971
|
-
const
|
|
2972
|
-
|
|
2973
|
-
if (exact && isFresh(exact, options.ttlMs)) return toSyntheticResponse(exact.response);
|
|
3091
|
+
const exact = await store.get(keyFor(promptKey, scope));
|
|
3092
|
+
if (exact && exact.scope === scope && isFresh(exact, options.ttlMs)) return toSyntheticResponse(exact.response);
|
|
2974
3093
|
const query = await options.embedder.embed(promptText);
|
|
2975
|
-
const
|
|
2976
|
-
topK: 1,
|
|
3094
|
+
const hit = (await store.similar(query.vector, {
|
|
3095
|
+
topK: scope === void 0 ? 1 : SIMILAR_OVERSCAN,
|
|
2977
3096
|
threshold: options.threshold
|
|
2978
|
-
});
|
|
2979
|
-
if (hit
|
|
2980
|
-
if (hit.key.startsWith(`${namespace}.`)) return toSyntheticResponse(hit.value.response);
|
|
2981
|
-
}
|
|
3097
|
+
})).find((candidate) => candidate.key.startsWith(`${namespace}.`) && candidate.value?.scope === scope && isFresh(candidate.value, options.ttlMs));
|
|
3098
|
+
if (hit) return toSyntheticResponse(hit.value.response);
|
|
2982
3099
|
const pending = {
|
|
2983
3100
|
promptKey,
|
|
2984
|
-
vector: query.vector
|
|
3101
|
+
vector: query.vector,
|
|
3102
|
+
scope
|
|
2985
3103
|
};
|
|
2986
3104
|
context.state.set(pendingKey, pending);
|
|
2987
3105
|
},
|
|
@@ -2992,9 +3110,10 @@ function semanticCache(options) {
|
|
|
2992
3110
|
context.state.delete(pendingKey);
|
|
2993
3111
|
const entry = {
|
|
2994
3112
|
response,
|
|
2995
|
-
storedAt: Date.now()
|
|
3113
|
+
storedAt: Date.now(),
|
|
3114
|
+
scope: pending.scope
|
|
2996
3115
|
};
|
|
2997
|
-
await store.set(keyFor(pending.promptKey), entry, { vector: pending.vector });
|
|
3116
|
+
await store.set(keyFor(pending.promptKey, pending.scope), entry, { vector: pending.vector });
|
|
2998
3117
|
}
|
|
2999
3118
|
}
|
|
3000
3119
|
};
|
|
@@ -4318,7 +4437,7 @@ function deriveMemoryId(text) {
|
|
|
4318
4437
|
* the raw top-`k` by similarity alone would miss it — overscan, then
|
|
4319
4438
|
* re-rank.
|
|
4320
4439
|
*/
|
|
4321
|
-
const RECALL_OVERSCAN$
|
|
4440
|
+
const RECALL_OVERSCAN$2 = 5;
|
|
4322
4441
|
/**
|
|
4323
4442
|
* Episodic recall tier (memory core M2).
|
|
4324
4443
|
*
|
|
@@ -4359,9 +4478,10 @@ var EpisodicMemory = class {
|
|
|
4359
4478
|
id,
|
|
4360
4479
|
text: item.text,
|
|
4361
4480
|
ts: this.now(),
|
|
4481
|
+
scope: item.scope,
|
|
4362
4482
|
metadata: item.metadata
|
|
4363
4483
|
};
|
|
4364
|
-
await this.store.set(this.keyFor(id), value, { vector });
|
|
4484
|
+
await this.store.set(this.keyFor(id, item.scope), value, { vector });
|
|
4365
4485
|
}
|
|
4366
4486
|
/**
|
|
4367
4487
|
* Embed `query`, pull the nearest episodes clearing the similarity
|
|
@@ -4369,16 +4489,21 @@ var EpisodicMemory = class {
|
|
|
4369
4489
|
* returning the top `k`. The similarity floor still gates relevance —
|
|
4370
4490
|
* recency only reorders episodes that already cleared it, it never
|
|
4371
4491
|
* surfaces an irrelevant-but-recent one.
|
|
4492
|
+
*
|
|
4493
|
+
* Episodes written under a different `scope` (another tenant /
|
|
4494
|
+
* session) are dropped here, before scoring and slicing, so they can
|
|
4495
|
+
* neither leak nor consume a slot. An unscoped recall reads only
|
|
4496
|
+
* unscoped episodes.
|
|
4372
4497
|
*/
|
|
4373
|
-
async recall(query, k, threshold) {
|
|
4498
|
+
async recall(query, k, threshold, scope) {
|
|
4374
4499
|
const { vector } = await this.embedder.embed(query);
|
|
4375
4500
|
const hits = await this.store.similar(vector, {
|
|
4376
|
-
topK: Math.max(k * RECALL_OVERSCAN$
|
|
4501
|
+
topK: Math.max(k * RECALL_OVERSCAN$2, k),
|
|
4377
4502
|
threshold
|
|
4378
4503
|
});
|
|
4379
4504
|
const prefix = `${this.namespace}.`;
|
|
4380
4505
|
const now = this.now();
|
|
4381
|
-
return hits.filter((hit) => hit.key.startsWith(prefix)).map((hit) => ({
|
|
4506
|
+
return hits.filter((hit) => hit.key.startsWith(prefix) && hit.value?.scope === scope).map((hit) => ({
|
|
4382
4507
|
id: hit.value.id,
|
|
4383
4508
|
text: hit.value.text,
|
|
4384
4509
|
tier: "episodic",
|
|
@@ -4404,10 +4529,12 @@ var EpisodicMemory = class {
|
|
|
4404
4529
|
/**
|
|
4405
4530
|
* Namespaced key for an entry. Mirrors the semantic tier's dot
|
|
4406
4531
|
* separator so the prefix used here matches the `hit.key` the driver
|
|
4407
|
-
* returns from `similar()
|
|
4532
|
+
* returns from `similar()`, and its hashed scope segment so two
|
|
4533
|
+
* scopes never overwrite one another's identical text. Unscoped keys
|
|
4534
|
+
* keep their pre-4.15.0 shape.
|
|
4408
4535
|
*/
|
|
4409
|
-
keyFor(id) {
|
|
4410
|
-
return `${this.namespace}.${id}`;
|
|
4536
|
+
keyFor(id, scope) {
|
|
4537
|
+
return scope === void 0 ? `${this.namespace}.${id}` : `${this.namespace}.${deriveMemoryId(scope)}.${id}`;
|
|
4411
4538
|
}
|
|
4412
4539
|
};
|
|
4413
4540
|
|
|
@@ -4419,7 +4546,7 @@ var EpisodicMemory = class {
|
|
|
4419
4546
|
* promote a well-worn procedure past a slightly-closer one-off, which the
|
|
4420
4547
|
* raw top-`k` by similarity would miss.
|
|
4421
4548
|
*/
|
|
4422
|
-
const RECALL_OVERSCAN = 5;
|
|
4549
|
+
const RECALL_OVERSCAN$1 = 5;
|
|
4423
4550
|
/**
|
|
4424
4551
|
* Procedural recall tier (memory core M2).
|
|
4425
4552
|
*
|
|
@@ -4458,30 +4585,37 @@ var ProceduralMemory = class {
|
|
|
4458
4585
|
async remember(item) {
|
|
4459
4586
|
const id = item.id ?? deriveMemoryId(item.text);
|
|
4460
4587
|
const { vector } = await this.embedder.embed(item.text);
|
|
4461
|
-
const
|
|
4588
|
+
const key = this.keyFor(id, item.scope);
|
|
4589
|
+
const existing = await this.store.get(key);
|
|
4462
4590
|
const uses = (existing?.uses ?? 0) + 1;
|
|
4463
4591
|
const value = {
|
|
4464
4592
|
id,
|
|
4465
4593
|
text: item.text,
|
|
4466
4594
|
uses,
|
|
4595
|
+
scope: item.scope,
|
|
4467
4596
|
metadata: item.metadata ?? existing?.metadata
|
|
4468
4597
|
};
|
|
4469
|
-
await this.store.set(
|
|
4598
|
+
await this.store.set(key, value, { vector });
|
|
4470
4599
|
}
|
|
4471
4600
|
/**
|
|
4472
4601
|
* Embed `query`, pull the nearest procedures clearing the similarity
|
|
4473
4602
|
* `threshold`, then re-rank each by a reinforcement-blended score and
|
|
4474
4603
|
* return the top `k`. The similarity floor still gates relevance;
|
|
4475
4604
|
* reinforcement only reorders procedures that already cleared it.
|
|
4605
|
+
*
|
|
4606
|
+
* Procedures written under a different `scope` (another tenant /
|
|
4607
|
+
* session) are dropped here, before scoring and slicing, so they can
|
|
4608
|
+
* neither leak nor consume a slot. An unscoped recall reads only
|
|
4609
|
+
* unscoped procedures.
|
|
4476
4610
|
*/
|
|
4477
|
-
async recall(query, k, threshold) {
|
|
4611
|
+
async recall(query, k, threshold, scope) {
|
|
4478
4612
|
const { vector } = await this.embedder.embed(query);
|
|
4479
4613
|
const hits = await this.store.similar(vector, {
|
|
4480
|
-
topK: Math.max(k * RECALL_OVERSCAN, k),
|
|
4614
|
+
topK: Math.max(k * RECALL_OVERSCAN$1, k),
|
|
4481
4615
|
threshold
|
|
4482
4616
|
});
|
|
4483
4617
|
const prefix = `${this.namespace}.`;
|
|
4484
|
-
return hits.filter((hit) => hit.key.startsWith(prefix)).map((hit) => ({
|
|
4618
|
+
return hits.filter((hit) => hit.key.startsWith(prefix) && hit.value?.scope === scope).map((hit) => ({
|
|
4485
4619
|
id: hit.value.id,
|
|
4486
4620
|
text: hit.value.text,
|
|
4487
4621
|
tier: "procedural",
|
|
@@ -4504,15 +4638,28 @@ var ProceduralMemory = class {
|
|
|
4504
4638
|
const reinforcement = uses / (uses + 1);
|
|
4505
4639
|
return (1 - this.reinforcementWeight) * similarity + this.reinforcementWeight * reinforcement;
|
|
4506
4640
|
}
|
|
4507
|
-
/**
|
|
4508
|
-
|
|
4509
|
-
|
|
4641
|
+
/**
|
|
4642
|
+
* Namespaced key for an entry — dot separator, matching `similar()`
|
|
4643
|
+
* keys, plus a hashed scope segment so reinforcement counters never
|
|
4644
|
+
* cross a scope boundary (one tenant re-affirming a procedure must not
|
|
4645
|
+
* strengthen — or overwrite — another tenant's identical text).
|
|
4646
|
+
* Unscoped keys keep their pre-4.15.0 shape.
|
|
4647
|
+
*/
|
|
4648
|
+
keyFor(id, scope) {
|
|
4649
|
+
return scope === void 0 ? `${this.namespace}.${id}` : `${this.namespace}.${deriveMemoryId(scope)}.${id}`;
|
|
4510
4650
|
}
|
|
4511
4651
|
};
|
|
4512
4652
|
|
|
4513
4653
|
//#endregion
|
|
4514
4654
|
//#region ../ai/src/memory/semantic-memory.ts
|
|
4515
4655
|
/**
|
|
4656
|
+
* Extra candidates pulled from `similar()` on a SCOPED recall before the
|
|
4657
|
+
* scope filter runs — the driver's ranking spans every scope in the
|
|
4658
|
+
* index, so a bare top-`k` can come back entirely foreign. Mirrors the
|
|
4659
|
+
* episodic / procedural tiers' overscan constant.
|
|
4660
|
+
*/
|
|
4661
|
+
const RECALL_OVERSCAN = 5;
|
|
4662
|
+
/**
|
|
4516
4663
|
* Semantic recall tier (memory core M1).
|
|
4517
4664
|
*
|
|
4518
4665
|
* Owns: embedding remembered text, writing it to a `@warlock.js/cache`
|
|
@@ -4547,30 +4694,33 @@ var SemanticMemory = class {
|
|
|
4547
4694
|
const value = {
|
|
4548
4695
|
id,
|
|
4549
4696
|
text: item.text,
|
|
4697
|
+
scope: item.scope,
|
|
4550
4698
|
metadata: item.metadata
|
|
4551
4699
|
};
|
|
4552
|
-
await this.store.set(this.keyFor(id), value, { vector });
|
|
4700
|
+
await this.store.set(this.keyFor(id, item.scope), value, { vector });
|
|
4553
4701
|
}
|
|
4554
4702
|
/**
|
|
4555
4703
|
* Embed `query`, ask the driver for the `k` nearest entries clearing
|
|
4556
|
-
* `threshold`, and return those within this instance's namespace
|
|
4557
|
-
* scored {@link RecalledMemory}. Hits indexed
|
|
4558
|
-
* namespace (a shared driver)
|
|
4704
|
+
* `threshold`, and return those within this instance's namespace AND
|
|
4705
|
+
* this call's `scope` as scored {@link RecalledMemory}. Hits indexed
|
|
4706
|
+
* under a different namespace (a shared driver) or a different scope
|
|
4707
|
+
* (another tenant / session) are filtered out here, before the caller
|
|
4708
|
+
* ever sees them — an unscoped recall reads only unscoped entries.
|
|
4559
4709
|
*/
|
|
4560
|
-
async recall(query, k, threshold) {
|
|
4710
|
+
async recall(query, k, threshold, scope) {
|
|
4561
4711
|
const { vector } = await this.embedder.embed(query);
|
|
4562
4712
|
const hits = await this.store.similar(vector, {
|
|
4563
|
-
topK: k,
|
|
4713
|
+
topK: scope === void 0 ? k : Math.max(k * RECALL_OVERSCAN, k),
|
|
4564
4714
|
threshold
|
|
4565
4715
|
});
|
|
4566
4716
|
const prefix = `${this.namespace}.`;
|
|
4567
|
-
return hits.filter((hit) => hit.key.startsWith(prefix)).map((hit) => ({
|
|
4717
|
+
return hits.filter((hit) => hit.key.startsWith(prefix) && hit.value?.scope === scope).map((hit) => ({
|
|
4568
4718
|
id: hit.value.id,
|
|
4569
4719
|
text: hit.value.text,
|
|
4570
4720
|
tier: "semantic",
|
|
4571
4721
|
score: hit.score,
|
|
4572
4722
|
metadata: hit.value.metadata
|
|
4573
|
-
}));
|
|
4723
|
+
})).slice(0, Math.max(0, k));
|
|
4574
4724
|
}
|
|
4575
4725
|
/** Drop every semantic entry written under this instance's namespace. */
|
|
4576
4726
|
async clear() {
|
|
@@ -4580,9 +4730,16 @@ var SemanticMemory = class {
|
|
|
4580
4730
|
* Namespaced key for an entry. The cache's `parseKey` normalizes `:`
|
|
4581
4731
|
* to `.`, so a dot separator keeps the prefix used here aligned with
|
|
4582
4732
|
* the `hit.key` the driver returns from `similar()`.
|
|
4733
|
+
*
|
|
4734
|
+
* A scoped entry gets an extra hashed segment so two scopes writing
|
|
4735
|
+
* identical text (same derived id) don't overwrite each other; the
|
|
4736
|
+
* unscoped key shape is unchanged, so entries written before 4.15.0
|
|
4737
|
+
* still resolve. The hash is a write-separation device only — recall
|
|
4738
|
+
* authorization is the exact `value.scope` equality check, so even a
|
|
4739
|
+
* hash collision cannot widen what a scope can read.
|
|
4583
4740
|
*/
|
|
4584
|
-
keyFor(id) {
|
|
4585
|
-
return `${this.namespace}.${id}`;
|
|
4741
|
+
keyFor(id, scope) {
|
|
4742
|
+
return scope === void 0 ? `${this.namespace}.${id}` : `${this.namespace}.${deriveMemoryId(scope)}.${id}`;
|
|
4586
4743
|
}
|
|
4587
4744
|
};
|
|
4588
4745
|
|
|
@@ -4602,47 +4759,104 @@ var SemanticMemory = class {
|
|
|
4602
4759
|
* first, each scored on a `[0, 1]` recency proxy so a caller can merge
|
|
4603
4760
|
* working hits with semantic hits and sort on one `score` field.
|
|
4604
4761
|
*
|
|
4762
|
+
* **Bounded (4.15.0).** The buffer holds at most `maxItems` entries
|
|
4763
|
+
* across every scope; the oldest-written entry is evicted on overflow
|
|
4764
|
+
* (FIFO). The tier lives in process memory for the lifetime of the
|
|
4765
|
+
* `memory()` instance — which the orchestrator resolves once and reuses
|
|
4766
|
+
* for every session — so an unbounded buffer was a memory-exhaustion
|
|
4767
|
+
* vector for any long-lived, internet-reachable deployment.
|
|
4768
|
+
*
|
|
4605
4769
|
* Internal to the `memory()` factory — never exported on the package
|
|
4606
4770
|
* surface.
|
|
4607
4771
|
*/
|
|
4608
4772
|
var WorkingMemory = class {
|
|
4609
|
-
constructor() {
|
|
4773
|
+
constructor(maxItems) {
|
|
4610
4774
|
this.entries = /* @__PURE__ */ new Map();
|
|
4775
|
+
this.maxItems = maxItems;
|
|
4611
4776
|
}
|
|
4612
4777
|
/**
|
|
4613
4778
|
* Append an item to the buffer (or overwrite the entry sharing its
|
|
4614
|
-
* id). Re-inserting an existing
|
|
4615
|
-
* + set would move it to the end and lie
|
|
4616
|
-
* is updated in place.
|
|
4779
|
+
* id *within the same scope*). Re-inserting an existing key keeps its
|
|
4780
|
+
* original position; delete + set would move it to the end and lie
|
|
4781
|
+
* about recency, so the value is updated in place.
|
|
4782
|
+
*
|
|
4783
|
+
* Overflowing `maxItems` evicts from the front — see
|
|
4784
|
+
* {@link evictOverflow}.
|
|
4617
4785
|
*/
|
|
4618
4786
|
remember(item) {
|
|
4619
4787
|
const id = item.id ?? deriveMemoryId(item.text);
|
|
4620
|
-
this.entries.set(id, {
|
|
4788
|
+
this.entries.set(scopedKey(item.scope, id), {
|
|
4789
|
+
id,
|
|
4621
4790
|
text: item.text,
|
|
4791
|
+
scope: item.scope,
|
|
4622
4792
|
metadata: item.metadata
|
|
4623
4793
|
});
|
|
4794
|
+
this.evictOverflow();
|
|
4624
4795
|
}
|
|
4625
4796
|
/**
|
|
4626
|
-
*
|
|
4627
|
-
*
|
|
4628
|
-
*
|
|
4797
|
+
* Enforce the size bound by dropping oldest-written entries first
|
|
4798
|
+
* (FIFO over the `Map`'s insertion order).
|
|
4799
|
+
*
|
|
4800
|
+
* **Why FIFO, not LRU.** Recall here is a pure recency proxy — it
|
|
4801
|
+
* reverses insertion order and slices the newest `k` — and never
|
|
4802
|
+
* reorders anything, so the front of the buffer is by construction the
|
|
4803
|
+
* region recall reaches last. FIFO therefore evicts exactly the
|
|
4804
|
+
* entries a bounded recall would never have returned. True LRU would
|
|
4805
|
+
* need read-time reordering, which would also rewrite the `score`
|
|
4806
|
+
* every recall reports (a re-read entry would masquerade as freshly
|
|
4807
|
+
* remembered), trading a real correctness property for no gain.
|
|
4808
|
+
*
|
|
4809
|
+
* **Known limitation (documented, not a regression).** The bound is
|
|
4810
|
+
* global, not per-scope: a session writing heavily can push another
|
|
4811
|
+
* session's older entries out of the buffer. That is a recall-quality
|
|
4812
|
+
* degradation on a volatile scratch tier, never a disclosure — the
|
|
4813
|
+
* scope filter in {@link recall} still applies — and a per-scope quota
|
|
4814
|
+
* would not help anyway, since an attacker holding many sessions
|
|
4815
|
+
* evicts through the global bound regardless. Durable recall belongs
|
|
4816
|
+
* in the semantic / episodic tiers.
|
|
4817
|
+
*/
|
|
4818
|
+
evictOverflow() {
|
|
4819
|
+
while (this.entries.size > this.maxItems) {
|
|
4820
|
+
const oldest = this.entries.keys().next();
|
|
4821
|
+
if (oldest.done) return;
|
|
4822
|
+
this.entries.delete(oldest.value);
|
|
4823
|
+
}
|
|
4824
|
+
}
|
|
4825
|
+
/**
|
|
4826
|
+
* Return up to `k` most-recently-remembered items *within `scope`*,
|
|
4827
|
+
* newest first. The scope match is exact equality (an unscoped recall
|
|
4828
|
+
* sees only unscoped entries) and is applied BEFORE the slice, so a
|
|
4829
|
+
* foreign scope's entries can never consume a slot or leak out.
|
|
4830
|
+
*
|
|
4831
|
+
* The `score` is a linear recency proxy: the newest item scores `1`,
|
|
4832
|
+
* the oldest of the returned slice trends toward `0`. Working memory
|
|
4629
4833
|
* ignores any similarity threshold — it has no vector to compare.
|
|
4630
4834
|
*/
|
|
4631
|
-
recall(k) {
|
|
4632
|
-
const slice = [...this.entries.
|
|
4633
|
-
return slice.map((
|
|
4634
|
-
id,
|
|
4835
|
+
recall(k, scope) {
|
|
4836
|
+
const slice = [...this.entries.values()].reverse().filter((entry) => entry.scope === scope).slice(0, Math.max(0, k));
|
|
4837
|
+
return slice.map((entry, index) => ({
|
|
4838
|
+
id: entry.id,
|
|
4635
4839
|
text: entry.text,
|
|
4636
4840
|
tier: "working",
|
|
4637
4841
|
score: slice.length <= 1 ? 1 : 1 - index / slice.length,
|
|
4638
4842
|
metadata: entry.metadata
|
|
4639
4843
|
}));
|
|
4640
4844
|
}
|
|
4641
|
-
/** Drop every working-tier entry. */
|
|
4845
|
+
/** Drop every working-tier entry, across every scope. */
|
|
4642
4846
|
clear() {
|
|
4643
4847
|
this.entries.clear();
|
|
4644
4848
|
}
|
|
4645
4849
|
};
|
|
4850
|
+
/**
|
|
4851
|
+
* Map key for a buffer entry: the isolation `scope` (empty for the
|
|
4852
|
+
* unscoped pool) length-prefixed and joined to the logical id. The
|
|
4853
|
+
* length prefix makes the encoding injective — no crafted scope/id pair
|
|
4854
|
+
* can collide with a different scope's entry the way a plain `:` join
|
|
4855
|
+
* would allow.
|
|
4856
|
+
*/
|
|
4857
|
+
function scopedKey(scope, id) {
|
|
4858
|
+
return `${scope?.length ?? 0}:${scope ?? ""}:${id}`;
|
|
4859
|
+
}
|
|
4646
4860
|
|
|
4647
4861
|
//#endregion
|
|
4648
4862
|
//#region ../ai/src/memory/memory.ts
|
|
@@ -4656,6 +4870,14 @@ const DEFAULT_RECENCY_WEIGHT = .3;
|
|
|
4656
4870
|
const DEFAULT_HALF_LIFE_MS = 10080 * 60 * 1e3;
|
|
4657
4871
|
const DEFAULT_REINFORCEMENT_WEIGHT = .3;
|
|
4658
4872
|
/**
|
|
4873
|
+
* Entries the in-process working buffer holds before it starts evicting
|
|
4874
|
+
* its oldest (4.15.0 — security fix for unbounded growth). Sized to hold
|
|
4875
|
+
* a deep multi-session scratch history while capping the tier's worst
|
|
4876
|
+
* case at a few MB of resident text rather than "everything this process
|
|
4877
|
+
* has ever been told."
|
|
4878
|
+
*/
|
|
4879
|
+
const DEFAULT_WORKING_MAX_ITEMS = 1e3;
|
|
4880
|
+
/**
|
|
4659
4881
|
* Create an agent memory store (memory core M2).
|
|
4660
4882
|
*
|
|
4661
4883
|
* Wires up to four tiers behind the {@link MemoryContract}: **working**
|
|
@@ -4672,7 +4894,19 @@ const DEFAULT_REINFORCEMENT_WEIGHT = .3;
|
|
|
4672
4894
|
* no `ai.config({ defaultStore })` throws now; enabling no tier at all
|
|
4673
4895
|
* throws now.
|
|
4674
4896
|
*
|
|
4675
|
-
*
|
|
4897
|
+
* TTL-based decay / forgetting remains deferred. The working tier is
|
|
4898
|
+
* size-bounded (`working: { maxItems }`, default `1000`, oldest-written
|
|
4899
|
+
* evicted first) because it is the one tier that holds everything it is
|
|
4900
|
+
* told in process memory for the life of the instance; the durable tiers
|
|
4901
|
+
* delegate retention to their `CacheDriver`.
|
|
4902
|
+
*
|
|
4903
|
+
* **Isolation (4.15.0).** `remember({ scope })` / `recall(query, { scope })`
|
|
4904
|
+
* carry an opaque tenant / session key that every tier enforces as an
|
|
4905
|
+
* exact-equality filter before scoring — one scope's memories never
|
|
4906
|
+
* surface in another's recall, and identical text under two scopes stays
|
|
4907
|
+
* two entries. Unscoped writes form a shared pool that only an unscoped
|
|
4908
|
+
* recall can read; there is no "all scopes" query. `ai.orchestrator()`
|
|
4909
|
+
* derives this from the turn's `sessionId` automatically.
|
|
4676
4910
|
*
|
|
4677
4911
|
* @example
|
|
4678
4912
|
* import { ai } from "@warlock.js/ai";
|
|
@@ -4691,10 +4925,10 @@ const DEFAULT_REINFORCEMENT_WEIGHT = .3;
|
|
|
4691
4925
|
*/
|
|
4692
4926
|
function memory$2(config = {}) {
|
|
4693
4927
|
const name = config.name ?? DEFAULT_NAME$3;
|
|
4694
|
-
const
|
|
4928
|
+
const workingConfig = config.working ?? true;
|
|
4695
4929
|
const defaultK = config.k ?? DEFAULT_K;
|
|
4696
4930
|
const defaultThreshold = config.threshold ?? DEFAULT_THRESHOLD$1;
|
|
4697
|
-
const working =
|
|
4931
|
+
const working = workingConfig === false ? void 0 : new WorkingMemory(resolveWorkingMaxItems(workingConfig, name));
|
|
4698
4932
|
const semantic = config.semantic ? buildSemanticTier(config.semantic, name) : void 0;
|
|
4699
4933
|
const episodic = config.episodic ? buildEpisodicTier(config.episodic, name) : void 0;
|
|
4700
4934
|
const procedural = config.procedural ? buildProceduralTier(config.procedural, name) : void 0;
|
|
@@ -4736,11 +4970,12 @@ function memory$2(config = {}) {
|
|
|
4736
4970
|
const threshold = options.threshold ?? defaultThreshold;
|
|
4737
4971
|
if (options.tier) assertTierEnabled(options.tier, tiers, name);
|
|
4738
4972
|
const wants = (tier) => !options.tier || options.tier === tier;
|
|
4973
|
+
const scope = options.scope;
|
|
4739
4974
|
const [workingHits, semanticHits, episodicHits, proceduralHits] = await Promise.all([
|
|
4740
|
-
working && wants("working") ? Promise.resolve(working.recall(k)) : Promise.resolve([]),
|
|
4741
|
-
semantic && wants("semantic") ? semantic.recall(query, k, threshold) : Promise.resolve([]),
|
|
4742
|
-
episodic && wants("episodic") ? episodic.recall(query, k, threshold) : Promise.resolve([]),
|
|
4743
|
-
procedural && wants("procedural") ? procedural.recall(query, k, threshold) : Promise.resolve([])
|
|
4975
|
+
working && wants("working") ? Promise.resolve(working.recall(k, scope)) : Promise.resolve([]),
|
|
4976
|
+
semantic && wants("semantic") ? semantic.recall(query, k, threshold, scope) : Promise.resolve([]),
|
|
4977
|
+
episodic && wants("episodic") ? episodic.recall(query, k, threshold, scope) : Promise.resolve([]),
|
|
4978
|
+
procedural && wants("procedural") ? procedural.recall(query, k, threshold, scope) : Promise.resolve([])
|
|
4744
4979
|
]);
|
|
4745
4980
|
return [
|
|
4746
4981
|
...workingHits,
|
|
@@ -4760,6 +4995,19 @@ function memory$2(config = {}) {
|
|
|
4760
4995
|
};
|
|
4761
4996
|
}
|
|
4762
4997
|
/**
|
|
4998
|
+
* Resolve the working tier's size bound from the `working` config
|
|
4999
|
+
* (`true` / a `{ maxItems }` object), validating it at construction the
|
|
5000
|
+
* same way every other tier's wiring fails loud-and-now rather than on
|
|
5001
|
+
* first use. There is deliberately no unbounded setting — the buffer is
|
|
5002
|
+
* process-resident for the life of the memory instance, so "no cap" is
|
|
5003
|
+
* a memory-exhaustion vector, not a configuration choice.
|
|
5004
|
+
*/
|
|
5005
|
+
function resolveWorkingMaxItems(workingConfig, name) {
|
|
5006
|
+
const maxItems = workingConfig === true ? DEFAULT_WORKING_MAX_ITEMS : workingConfig.maxItems ?? DEFAULT_WORKING_MAX_ITEMS;
|
|
5007
|
+
if (!Number.isInteger(maxItems) || maxItems < 1) throw new Error(`memory("${name}"): working tier \`maxItems\` must be an integer >= 1 — received ${String(maxItems)}`);
|
|
5008
|
+
return maxItems;
|
|
5009
|
+
}
|
|
5010
|
+
/**
|
|
4763
5011
|
* Resolve the semantic tier's store (explicit `store` wins, else the
|
|
4764
5012
|
* global `ai.config({ defaultStore })`) and build the tier. Throws at
|
|
4765
5013
|
* construction when neither is available — the same loud-now contract
|
|
@@ -12325,20 +12573,34 @@ var FallbackRun = class {
|
|
|
12325
12573
|
* Mirrors `supervisor.asTool()` — same `compositeAsTool` composition and
|
|
12326
12574
|
* error normalization — and adds `sessionScope`.
|
|
12327
12575
|
*
|
|
12328
|
-
* The boundary is OPAQUE (§13, §18.6): the parent's `
|
|
12329
|
-
*
|
|
12330
|
-
*
|
|
12576
|
+
* The boundary is OPAQUE (§13, §18.6): the parent's `context` / events do
|
|
12577
|
+
* NOT auto-forward. Per-call data the wrapped orchestrator needs rides on
|
|
12578
|
+
* the tool's `inputSchema` payload — with ONE deliberate exception, the
|
|
12579
|
+
* session binding below, because the payload is written by an LLM.
|
|
12331
12580
|
*
|
|
12332
12581
|
* Session continuity:
|
|
12333
12582
|
* - `"fresh"` (default) — each invocation gets a brand-new `sessionId`
|
|
12334
12583
|
* (a generated id) and empty history; the session lives only for this
|
|
12335
12584
|
* tool call. The whole validated payload is forwarded as the
|
|
12336
12585
|
* orchestrator's `execute(input)` argument.
|
|
12337
|
-
* - `"shared"` — the
|
|
12338
|
-
* `
|
|
12339
|
-
*
|
|
12340
|
-
*
|
|
12341
|
-
*
|
|
12586
|
+
* - `"shared"` — the orchestrator joins an EXISTING session named by the
|
|
12587
|
+
* developer through `options.session`: either a literal id fixed at
|
|
12588
|
+
* construction, or a resolver that reads the invocation's
|
|
12589
|
+
* {@link ToolContext} (`ctx.artifacts`, the out-of-band bag the model
|
|
12590
|
+
* cannot write to). The whole validated payload is forwarded as
|
|
12591
|
+
* `execute(input)`. A `"shared"` tool built without `session` throws at
|
|
12592
|
+
* construction.
|
|
12593
|
+
*
|
|
12594
|
+
* **Why the session id is not a schema field (4.15.0 security fix).**
|
|
12595
|
+
* Before this release, `"shared"` scope read `sessionId` straight out of
|
|
12596
|
+
* the model-generated tool arguments. A `sessionId` is bearer-equivalent
|
|
12597
|
+
* — naming one grants read/write on that session's persisted state — so
|
|
12598
|
+
* any prompt injection reaching the outer agent ("continue session
|
|
12599
|
+
* `<victim-id>`") made the nested orchestrator load a stranger's
|
|
12600
|
+
* conversation, mutate it, and echo its content back into the attacker's
|
|
12601
|
+
* transcript. The binding now lives on channels the model has no access
|
|
12602
|
+
* to. The old behavior survives only behind the loudly-named
|
|
12603
|
+
* `unsafeAllowModelSessionId` opt-in.
|
|
12342
12604
|
*
|
|
12343
12605
|
* On `result.error`, the typed orchestrator error is thrown so the tool
|
|
12344
12606
|
* wrapper produces a `ToolExecutionError` with `cause` preserved — the
|
|
@@ -12346,22 +12608,36 @@ var FallbackRun = class {
|
|
|
12346
12608
|
*
|
|
12347
12609
|
* @example
|
|
12348
12610
|
* const support = ai.orchestrator({ name: "refund-support", intents });
|
|
12611
|
+
*
|
|
12612
|
+
* // Fresh session per call — no continuity, nothing to hijack.
|
|
12349
12613
|
* const supportTool = support.asTool({
|
|
12350
12614
|
* name: "handle_refund",
|
|
12351
12615
|
* description: "Handle a refund conversation end-to-end.",
|
|
12352
12616
|
* inputSchema: v.object({ message: v.string() }),
|
|
12353
12617
|
* });
|
|
12354
|
-
*
|
|
12618
|
+
*
|
|
12619
|
+
* // Continuous session — bound from the authenticated request, never
|
|
12620
|
+
* // from the model's arguments.
|
|
12621
|
+
* const continuousTool = support.asTool({
|
|
12622
|
+
* name: "handle_refund",
|
|
12623
|
+
* inputSchema: v.object({ message: v.string() }),
|
|
12624
|
+
* sessionScope: "shared",
|
|
12625
|
+
* session: (ctx) => ({
|
|
12626
|
+
* sessionId: String(ctx?.artifacts?.refundSessionId ?? ""),
|
|
12627
|
+
* }),
|
|
12628
|
+
* });
|
|
12355
12629
|
*/
|
|
12356
12630
|
function asTool(orchestrator, options) {
|
|
12357
12631
|
if (!orchestrator.name || typeof orchestrator.name !== "string") throw new SupervisorFailedError("orchestrator.asTool(): orchestrator must have a `name` to be wrapped as a tool");
|
|
12358
12632
|
const sessionScope = options.sessionScope ?? "fresh";
|
|
12633
|
+
const allowModelSessionId = options.unsafeAllowModelSessionId === true;
|
|
12634
|
+
if (sessionScope === "shared" && !options.session && !allowModelSessionId) throw new SupervisorFailedError("orchestrator.asTool(): sessionScope \"shared\" requires a `session` binding — a session id fixed at construction, or a `(ctx) => sessionId` resolver reading the tool context. A model-supplied `sessionId` in the tool payload is bearer-equivalent access to that session; pass `unsafeAllowModelSessionId: true` only if the outer agent's context is trusted and you verify session ownership yourself");
|
|
12359
12635
|
return compositeAsTool({
|
|
12360
12636
|
name: options.name ?? orchestrator.name,
|
|
12361
12637
|
description: options.description ?? `Invoke orchestrator "${orchestrator.name}" as a tool.`,
|
|
12362
12638
|
input: options.inputSchema,
|
|
12363
|
-
execute: async (input) => {
|
|
12364
|
-
const { sessionId, history, executeInput } = resolveSession(sessionScope, input);
|
|
12639
|
+
execute: async (input, ctx) => {
|
|
12640
|
+
const { sessionId, history, executeInput } = await resolveSession(sessionScope, input, ctx, options.session, allowModelSessionId);
|
|
12365
12641
|
const result = await orchestrator.execute(executeInput, {
|
|
12366
12642
|
sessionId,
|
|
12367
12643
|
history
|
|
@@ -12377,21 +12653,41 @@ function asTool(orchestrator, options) {
|
|
|
12377
12653
|
}
|
|
12378
12654
|
/**
|
|
12379
12655
|
* Resolve the per-call `sessionId`, `history`, and the `execute(input)`
|
|
12380
|
-
* argument
|
|
12656
|
+
* argument, according to `sessionScope`.
|
|
12657
|
+
*
|
|
12658
|
+
* For `"shared"` scope the session comes from the developer's `session`
|
|
12659
|
+
* binding (construction-time literal or `ToolContext` resolver) — the
|
|
12660
|
+
* validated payload is never consulted for it unless the caller opted
|
|
12661
|
+
* into `unsafeAllowModelSessionId`. Either way `sessionId` / `history`
|
|
12662
|
+
* are stripped from the payload before it is forwarded as
|
|
12663
|
+
* `execute(input)`, so a model-authored field of that name can't reach
|
|
12664
|
+
* the orchestrator's input under a misleading name.
|
|
12381
12665
|
*/
|
|
12382
|
-
function resolveSession(sessionScope, input) {
|
|
12666
|
+
async function resolveSession(sessionScope, input, ctx, session, allowModelSessionId) {
|
|
12383
12667
|
if (sessionScope === "fresh") return {
|
|
12384
12668
|
sessionId: generateRunId("session"),
|
|
12385
12669
|
history: [],
|
|
12386
12670
|
executeInput: coerceInput$1(input)
|
|
12387
12671
|
};
|
|
12388
|
-
const
|
|
12389
|
-
|
|
12390
|
-
|
|
12672
|
+
const { sessionId: payloadSessionId, history: payloadHistory, ...rest } = typeof input === "object" && input !== null ? input : {};
|
|
12673
|
+
const executeInput = coerceInput$1(rest);
|
|
12674
|
+
if (session !== void 0) {
|
|
12675
|
+
const bound = typeof session === "function" ? await session(ctx) : session;
|
|
12676
|
+
const sessionId = typeof bound === "string" ? bound : bound?.sessionId;
|
|
12677
|
+
const history = typeof bound === "string" ? void 0 : bound?.history;
|
|
12678
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) throw new SupervisorFailedError("orchestrator.asTool(): the `session` binding for sessionScope \"shared\" resolved to no session id — return a non-empty string (or `{ sessionId }`) from it, or throw to reject the call. The model's payload is never used as a fallback");
|
|
12679
|
+
return {
|
|
12680
|
+
sessionId,
|
|
12681
|
+
history: Array.isArray(history) ? history : [],
|
|
12682
|
+
executeInput
|
|
12683
|
+
};
|
|
12684
|
+
}
|
|
12685
|
+
if (!allowModelSessionId) throw new SupervisorFailedError("orchestrator.asTool(): sessionScope \"shared\" requires a `session` binding");
|
|
12686
|
+
if (typeof payloadSessionId !== "string" || payloadSessionId.length === 0) throw new SupervisorFailedError("orchestrator.asTool(): sessionScope \"shared\" requires a `sessionId` string in the tool input payload");
|
|
12391
12687
|
return {
|
|
12392
|
-
sessionId,
|
|
12393
|
-
history: Array.isArray(
|
|
12394
|
-
executeInput
|
|
12688
|
+
sessionId: payloadSessionId,
|
|
12689
|
+
history: Array.isArray(payloadHistory) ? payloadHistory : [],
|
|
12690
|
+
executeInput
|
|
12395
12691
|
};
|
|
12396
12692
|
}
|
|
12397
12693
|
/**
|
|
@@ -12525,6 +12821,14 @@ function invoke$2(handler, payload) {
|
|
|
12525
12821
|
/** Default key the recalled memories are injected under in the context bag. */
|
|
12526
12822
|
const DEFAULT_INJECT_KEY = "memories";
|
|
12527
12823
|
/**
|
|
12824
|
+
* Default isolation boundary: a turn recalls only what its own session
|
|
12825
|
+
* remembered. Cross-session pooling is opt-in (`scope: "shared"`) — the
|
|
12826
|
+
* default must not leak one user's remembered turns into another's
|
|
12827
|
+
* context, since one memory store backs every session of an
|
|
12828
|
+
* orchestrator instance.
|
|
12829
|
+
*/
|
|
12830
|
+
const DEFAULT_SCOPE = "session";
|
|
12831
|
+
/**
|
|
12528
12832
|
* A `MemoryContract` is the bare-store form; anything carrying a `store`
|
|
12529
12833
|
* is the {@link OrchestratorMemoryConfig} wrapper. Distinguished by the
|
|
12530
12834
|
* presence of `recall` — a method on the contract, absent on the config
|
|
@@ -12544,6 +12848,7 @@ function resolveOrchestratorMemory(memory) {
|
|
|
12544
12848
|
if (isBareMemory(memory)) return {
|
|
12545
12849
|
store: memory,
|
|
12546
12850
|
remember: true,
|
|
12851
|
+
scope: DEFAULT_SCOPE,
|
|
12547
12852
|
injectKey: DEFAULT_INJECT_KEY
|
|
12548
12853
|
};
|
|
12549
12854
|
return {
|
|
@@ -12553,10 +12858,39 @@ function resolveOrchestratorMemory(memory) {
|
|
|
12553
12858
|
tier: memory.recall?.tier,
|
|
12554
12859
|
remember: memory.remember ?? true,
|
|
12555
12860
|
rememberTier: memory.rememberTier,
|
|
12861
|
+
scope: memory.scope ?? DEFAULT_SCOPE,
|
|
12556
12862
|
injectKey: memory.injectKey ?? DEFAULT_INJECT_KEY
|
|
12557
12863
|
};
|
|
12558
12864
|
}
|
|
12559
12865
|
/**
|
|
12866
|
+
* Resolve the isolation key a turn reads and writes memories under
|
|
12867
|
+
* (4.15.0 — security fix for cross-session recall).
|
|
12868
|
+
*
|
|
12869
|
+
* The memory store is resolved once per orchestrator instance and reused
|
|
12870
|
+
* by every session, so this — not the store — is what keeps one session's
|
|
12871
|
+
* remembered turns out of another's recall. It is derived from the
|
|
12872
|
+
* execute-time `sessionId` by the engine and handed to every tier as an
|
|
12873
|
+
* exact-match filter; the model, the tool payload, and the per-call
|
|
12874
|
+
* `context` bag have no say in it.
|
|
12875
|
+
*
|
|
12876
|
+
* `"shared"` resolves to `undefined`, i.e. the store's unscoped pool —
|
|
12877
|
+
* the explicit opt-in back to pre-4.15.0 cross-session behavior, which
|
|
12878
|
+
* also keeps memories written before this release readable.
|
|
12879
|
+
*/
|
|
12880
|
+
function memoryScopeFor(memory, sessionId) {
|
|
12881
|
+
if (memory.scope === "shared") return;
|
|
12882
|
+
if (typeof memory.scope === "function") return memory.scope(sessionId);
|
|
12883
|
+
return sessionMemoryScope(sessionId);
|
|
12884
|
+
}
|
|
12885
|
+
/**
|
|
12886
|
+
* The default `"session"` scope key: the session id under a reserved
|
|
12887
|
+
* prefix, so a custom `scope` callback returning a bare tenant id can
|
|
12888
|
+
* never accidentally collide with a session-scoped pool.
|
|
12889
|
+
*/
|
|
12890
|
+
function sessionMemoryScope(sessionId) {
|
|
12891
|
+
return `session:${sessionId}`;
|
|
12892
|
+
}
|
|
12893
|
+
/**
|
|
12560
12894
|
* Coerce a turn's {@link SupervisorInput} (string or structured object)
|
|
12561
12895
|
* into the natural-language query the memory store recalls / embeds
|
|
12562
12896
|
* against. Strings pass through; objects are JSON-serialized — the same
|
|
@@ -12573,13 +12907,18 @@ function memoryQueryFromInput(input) {
|
|
|
12573
12907
|
* `memory.injectKey`. Returns an empty array — never throws on "no hits"
|
|
12574
12908
|
* — and short-circuits when `k === 0` (recall disabled / write-only
|
|
12575
12909
|
* memory) so a write-only config never round-trips the embedder.
|
|
12910
|
+
*
|
|
12911
|
+
* The recall is confined to the calling session's scope (see
|
|
12912
|
+
* {@link memoryScopeFor}) — `sessionId` is required, not optional, so a
|
|
12913
|
+
* new call site cannot silently recall across every session.
|
|
12576
12914
|
*/
|
|
12577
|
-
async function recallForTurn(memory, input) {
|
|
12915
|
+
async function recallForTurn(memory, input, sessionId) {
|
|
12578
12916
|
if (memory.k === 0) return [];
|
|
12579
12917
|
return memory.store.recall(memoryQueryFromInput(input), {
|
|
12580
12918
|
k: memory.k,
|
|
12581
12919
|
threshold: memory.threshold,
|
|
12582
|
-
tier: memory.tier
|
|
12920
|
+
tier: memory.tier,
|
|
12921
|
+
scope: memoryScopeFor(memory, sessionId)
|
|
12583
12922
|
});
|
|
12584
12923
|
}
|
|
12585
12924
|
/**
|
|
@@ -12610,14 +12949,19 @@ function injectMemories(context, memory, recalled) {
|
|
|
12610
12949
|
* The remembered text is the turn input followed by the model's textual
|
|
12611
12950
|
* outcome when one is available, so a later `recall` keyed on a similar
|
|
12612
12951
|
* input surfaces both the prior question and its answer.
|
|
12952
|
+
*
|
|
12953
|
+
* The write is tagged with the calling session's scope (see
|
|
12954
|
+
* {@link memoryScopeFor}) so only that session recalls it later —
|
|
12955
|
+
* turn text routinely contains one user's private content.
|
|
12613
12956
|
*/
|
|
12614
|
-
async function rememberTurnOutcome(memory, input, outcomeText) {
|
|
12957
|
+
async function rememberTurnOutcome(memory, input, outcomeText, sessionId) {
|
|
12615
12958
|
if (!memory.remember) return;
|
|
12616
12959
|
const text = buildOutcomeText(input, outcomeText);
|
|
12617
12960
|
if (!text) return;
|
|
12618
12961
|
const item = {
|
|
12619
12962
|
text,
|
|
12620
|
-
tier: memory.rememberTier
|
|
12963
|
+
tier: memory.rememberTier,
|
|
12964
|
+
scope: memoryScopeFor(memory, sessionId)
|
|
12621
12965
|
};
|
|
12622
12966
|
await memory.store.remember(item);
|
|
12623
12967
|
}
|
|
@@ -13321,6 +13665,72 @@ function isWorkflowResult(raw) {
|
|
|
13321
13665
|
return raw.type === "workflow";
|
|
13322
13666
|
}
|
|
13323
13667
|
|
|
13668
|
+
//#endregion
|
|
13669
|
+
//#region ../ai/src/security/safe-merge.ts
|
|
13670
|
+
/**
|
|
13671
|
+
* Prototype-key guard for merges of model-influenced data into plain
|
|
13672
|
+
* objects (supervisor `state`, artifact bags, refine slices, …).
|
|
13673
|
+
*
|
|
13674
|
+
* Any `target[key] = value` where `key` came from an LLM, a tool
|
|
13675
|
+
* result, or a permissively-schema'd agent output is a prototype-
|
|
13676
|
+
* tampering primitive: `state["__proto__"] = {...}` repoints that
|
|
13677
|
+
* object's prototype, and `state["constructor"]` shadows its
|
|
13678
|
+
* constructor. On a plain object literal the blast radius is contained
|
|
13679
|
+
* (the write lands on the one object, not on `Object.prototype`), but
|
|
13680
|
+
* it becomes real prototype pollution the moment anything downstream
|
|
13681
|
+
* uses `in`, `hasOwnProperty`, or a recursive deep-merge on the
|
|
13682
|
+
* tainted object — which is exactly the kind of change that gets added
|
|
13683
|
+
* later without re-auditing the merge sites.
|
|
13684
|
+
*
|
|
13685
|
+
* So: one shared guard, applied at every merge boundary, dropping the
|
|
13686
|
+
* dangerous keys instead of assigning them. Dropping (not throwing) is
|
|
13687
|
+
* deliberate — these keys are never legitimate state fields, and a
|
|
13688
|
+
* merge boundary in the middle of a settled iteration is the wrong
|
|
13689
|
+
* place to fail a run. Callers get the dropped keys back so they can
|
|
13690
|
+
* log the anomaly.
|
|
13691
|
+
*/
|
|
13692
|
+
/**
|
|
13693
|
+
* Keys that must never be written through a dynamic-key assignment.
|
|
13694
|
+
* `__proto__` repoints the prototype; `constructor` / `prototype`
|
|
13695
|
+
* are the standard escalation path from there.
|
|
13696
|
+
*/
|
|
13697
|
+
const UNSAFE_MERGE_KEYS = [
|
|
13698
|
+
"__proto__",
|
|
13699
|
+
"constructor",
|
|
13700
|
+
"prototype"
|
|
13701
|
+
];
|
|
13702
|
+
const UNSAFE_MERGE_KEY_SET = new Set(UNSAFE_MERGE_KEYS);
|
|
13703
|
+
/**
|
|
13704
|
+
* True when `key` must not be assigned onto an object built from
|
|
13705
|
+
* untrusted (model/tool-influenced) data.
|
|
13706
|
+
*/
|
|
13707
|
+
function isUnsafeMergeKey(key) {
|
|
13708
|
+
return UNSAFE_MERGE_KEY_SET.has(key);
|
|
13709
|
+
}
|
|
13710
|
+
/**
|
|
13711
|
+
* Assign one key onto `target`, skipping prototype-tampering keys.
|
|
13712
|
+
* Returns `true` when the value was written, `false` when the key was
|
|
13713
|
+
* refused.
|
|
13714
|
+
*/
|
|
13715
|
+
function assignSafeKey(target, key, value) {
|
|
13716
|
+
if (isUnsafeMergeKey(key)) return false;
|
|
13717
|
+
target[key] = value;
|
|
13718
|
+
return true;
|
|
13719
|
+
}
|
|
13720
|
+
/**
|
|
13721
|
+
* Shallow-merge every own enumerable key of `source` into `target`,
|
|
13722
|
+
* skipping prototype-tampering keys. Mutates `target` in place (call
|
|
13723
|
+
* sites rely on external references to the merged object staying
|
|
13724
|
+
* coherent) and returns the list of refused keys — empty in the
|
|
13725
|
+
* overwhelmingly common case, non-empty only when something upstream
|
|
13726
|
+
* tried to smuggle `__proto__`/`constructor`/`prototype` through.
|
|
13727
|
+
*/
|
|
13728
|
+
function mergeSafely(target, source) {
|
|
13729
|
+
const skipped = [];
|
|
13730
|
+
for (const [key, value] of Object.entries(source)) if (!assignSafeKey(target, key, value)) skipped.push(key);
|
|
13731
|
+
return skipped;
|
|
13732
|
+
}
|
|
13733
|
+
|
|
13324
13734
|
//#endregion
|
|
13325
13735
|
//#region ../ai/src/supervisor/cancellation.ts
|
|
13326
13736
|
/**
|
|
@@ -13432,7 +13842,7 @@ async function decideViaCallback(params) {
|
|
|
13432
13842
|
throw wrapRouteError(params.config.name, thrown);
|
|
13433
13843
|
}
|
|
13434
13844
|
const durationMs = performance.now() - started;
|
|
13435
|
-
return normalize(raw, params.entries, "route", durationMs);
|
|
13845
|
+
return normalize(raw, params.entries, "route", durationMs, resolveMaxFanOut(params.config));
|
|
13436
13846
|
}
|
|
13437
13847
|
async function decideViaRouter(params) {
|
|
13438
13848
|
const { agent, placeholders, inputOverride, historySlicer } = resolveRouterEntry(params.config.router);
|
|
@@ -13481,7 +13891,7 @@ async function decideViaRouter(params) {
|
|
|
13481
13891
|
availableKeys: [...params.entries.keys()]
|
|
13482
13892
|
});
|
|
13483
13893
|
return {
|
|
13484
|
-
...normalize(rawNext, params.entries, "router", durationMs),
|
|
13894
|
+
...normalize(rawNext, params.entries, "router", durationMs, resolveMaxFanOut(params.config)),
|
|
13485
13895
|
reasoning: typeof reasoning === "string" ? reasoning : void 0,
|
|
13486
13896
|
usage: routerResult.usage,
|
|
13487
13897
|
routerReport: routerResult.report
|
|
@@ -13521,7 +13931,7 @@ function resolveSupervisorPromptText(config) {
|
|
|
13521
13931
|
* `next` field) into a canonical `DispatchDecision`, validating every
|
|
13522
13932
|
* named intent against the supervisor's `intents` map.
|
|
13523
13933
|
*/
|
|
13524
|
-
function normalize(raw, entries, source, durationMs) {
|
|
13934
|
+
function normalize(raw, entries, source, durationMs, maxFanOut) {
|
|
13525
13935
|
if (isEnd(raw)) return {
|
|
13526
13936
|
kind: "end",
|
|
13527
13937
|
source,
|
|
@@ -13552,7 +13962,7 @@ function normalize(raw, entries, source, durationMs) {
|
|
|
13552
13962
|
}
|
|
13553
13963
|
return {
|
|
13554
13964
|
kind: "dispatch",
|
|
13555
|
-
intents: raw,
|
|
13965
|
+
intents: capFanOut(raw, entries, maxFanOut),
|
|
13556
13966
|
source,
|
|
13557
13967
|
raw,
|
|
13558
13968
|
durationMs
|
|
@@ -13563,6 +13973,53 @@ function normalize(raw, entries, source, durationMs) {
|
|
|
13563
13973
|
availableKeys: [...entries.keys()]
|
|
13564
13974
|
});
|
|
13565
13975
|
}
|
|
13976
|
+
/**
|
|
13977
|
+
* Default fan-out WIDTH ceiling — how many intents one dispatch
|
|
13978
|
+
* decision may run in parallel. `maxIterations` bounds depth; this
|
|
13979
|
+
* bounds width, so total work per run is bounded by the product
|
|
13980
|
+
* instead of by iterations alone.
|
|
13981
|
+
*/
|
|
13982
|
+
const DEFAULT_MAX_FAN_OUT = 10;
|
|
13983
|
+
/**
|
|
13984
|
+
* Resolve the configured width ceiling. Factory validation
|
|
13985
|
+
* (`supervisor.ts`) rejects non-integer / `< 1` values at authoring
|
|
13986
|
+
* time, so this only has to apply the default.
|
|
13987
|
+
*/
|
|
13988
|
+
function resolveMaxFanOut(config) {
|
|
13989
|
+
return config.maxFanOut ?? 10;
|
|
13990
|
+
}
|
|
13991
|
+
/**
|
|
13992
|
+
* Dedupe + width-cap a fan-out intent list before it reaches
|
|
13993
|
+
* `Promise.all(...dispatchOne)`.
|
|
13994
|
+
*
|
|
13995
|
+
* Duplicates are collapsed silently: running the same intent twice in
|
|
13996
|
+
* one decision is pure wasted spend (branch results are indexed by
|
|
13997
|
+
* intent downstream, so the extras can't change the outcome), and a
|
|
13998
|
+
* router that repeats itself is sloppy rather than hostile.
|
|
13999
|
+
*
|
|
14000
|
+
* Exceeding the cap *after* dedupe THROWS rather than truncating.
|
|
14001
|
+
* Truncation would silently hand an attacker-chosen subset of the
|
|
14002
|
+
* decision to the executor and hide the anomaly from the operator;
|
|
14003
|
+
* every other routing violation in this file (unknown key, empty
|
|
14004
|
+
* array, non-string element) already fails loudly as
|
|
14005
|
+
* `SupervisorRoutingError`, so a width violation surfaces in the same
|
|
14006
|
+
* place, with the same code, carrying the offending array.
|
|
14007
|
+
*
|
|
14008
|
+
* Threat model: the router's prompt embeds supervisor `state` and
|
|
14009
|
+
* prior branch outputs, both of which can carry attacker-controlled
|
|
14010
|
+
* text from tool results. Without a width bound, one injected
|
|
14011
|
+
* "always return this 200-element `next` array" turns a single
|
|
14012
|
+
* iteration into 200 real agent/workflow executions — no unknown
|
|
14013
|
+
* intent name required, so the existing allowlist check never fires.
|
|
14014
|
+
*/
|
|
14015
|
+
function capFanOut(intents, entries, maxFanOut) {
|
|
14016
|
+
const unique = [...new Set(intents)];
|
|
14017
|
+
if (unique.length > maxFanOut) throw new SupervisorRoutingError(`routing decision fanned out to ${unique.length} intents — exceeds maxFanOut=${maxFanOut}. Raise \`maxFanOut\` if this width is intended.`, {
|
|
14018
|
+
returned: intents,
|
|
14019
|
+
availableKeys: [...entries.keys()]
|
|
14020
|
+
});
|
|
14021
|
+
return unique;
|
|
14022
|
+
}
|
|
13566
14023
|
function validateKey(intent, entries) {
|
|
13567
14024
|
if (!entries.has(intent)) throw new SupervisorRoutingError(`router returned unknown agent key "${intent}"`, {
|
|
13568
14025
|
returned: intent,
|
|
@@ -14128,9 +14585,17 @@ var SupervisorExecution = class {
|
|
|
14128
14585
|
* branch errors don't abort siblings — they're recorded on the
|
|
14129
14586
|
* branch snapshot and let evaluate (or default termination logic)
|
|
14130
14587
|
* decide the response.
|
|
14588
|
+
*
|
|
14589
|
+
* `capFanOut` runs here as well as in `decide.ts` — this is the one
|
|
14590
|
+
* chokepoint every dispatch source funnels through (router/route
|
|
14591
|
+
* decisions, `evaluate.reassignTo`, classifier picks, per-intent
|
|
14592
|
+
* `next` unions), so the width bound holds even for the paths that
|
|
14593
|
+
* build a `DispatchDecision` without going through `normalize()`.
|
|
14594
|
+
* Idempotent for already-normalized decisions.
|
|
14131
14595
|
*/
|
|
14132
14596
|
async dispatchBranches(decision) {
|
|
14133
|
-
|
|
14597
|
+
const intents = capFanOut(decision.intents, this.entries, resolveMaxFanOut(this.config));
|
|
14598
|
+
return await Promise.all(intents.map((intent) => this.dispatchOne(intent)));
|
|
14134
14599
|
}
|
|
14135
14600
|
/**
|
|
14136
14601
|
* Execute a single branch — resolve the input, invoke the
|
|
@@ -14816,7 +15281,31 @@ var SupervisorExecution = class {
|
|
|
14816
15281
|
if (ackOutcome.error || !ackOutcome.output) return;
|
|
14817
15282
|
if (typeof ackOutcome.output !== "object" || ackOutcome.output === null) return;
|
|
14818
15283
|
const slice = ackOutcome.output;
|
|
14819
|
-
|
|
15284
|
+
this.mergeIntoState(slice, "ack");
|
|
15285
|
+
}
|
|
15286
|
+
/**
|
|
15287
|
+
* Single funnel for "shallow-merge a model-influenced slice into
|
|
15288
|
+
* `this.state`". Wraps the shared {@link mergeSafely} guard so no
|
|
15289
|
+
* merge site can assign `__proto__` / `constructor` / `prototype`
|
|
15290
|
+
* onto the run's state object, and logs when something tried.
|
|
15291
|
+
*
|
|
15292
|
+
* Every slice reaching state is model- or tool-influenced (agent
|
|
15293
|
+
* outputs validated against a DEVELOPER-supplied schema, which may
|
|
15294
|
+
* legitimately be permissive: `z.record()`, `.passthrough()`,
|
|
15295
|
+
* `z.any()`), so the key names are untrusted input even when the
|
|
15296
|
+
* values are shaped.
|
|
15297
|
+
*/
|
|
15298
|
+
mergeIntoState(slice, origin) {
|
|
15299
|
+
const skipped = mergeSafely(this.state, slice);
|
|
15300
|
+
this.warnOnUnsafeKeys(skipped, origin);
|
|
15301
|
+
}
|
|
15302
|
+
/** Shared logging for refused prototype-tampering keys. */
|
|
15303
|
+
warnOnUnsafeKeys(skipped, origin) {
|
|
15304
|
+
if (skipped.length === 0) return;
|
|
15305
|
+
this.logger.warn(this.logModule, "state.merge.unsafe-key", `dropped prototype-tampering key(s) from "${origin}" merge: ${skipped.join(", ")}`, {
|
|
15306
|
+
origin,
|
|
15307
|
+
keys: skipped
|
|
15308
|
+
});
|
|
14820
15309
|
}
|
|
14821
15310
|
/**
|
|
14822
15311
|
* Run the iter-0 classifier prelude (Phase 7 / decisions §37).
|
|
@@ -14940,9 +15429,9 @@ var SupervisorExecution = class {
|
|
|
14940
15429
|
refined = interpretation.refined;
|
|
14941
15430
|
halted = interpretation.halted;
|
|
14942
15431
|
final = interpretation.final ?? raw;
|
|
14943
|
-
if (interpretation.sliceToMerge)
|
|
15432
|
+
if (interpretation.sliceToMerge) this.mergeIntoState(interpretation.sliceToMerge, "classifier.refine");
|
|
14944
15433
|
}
|
|
14945
|
-
|
|
15434
|
+
this.mergeIntoState(final, "classifier");
|
|
14946
15435
|
this.classifierSnapshot = {
|
|
14947
15436
|
intent: halted ? void 0 : final.intent,
|
|
14948
15437
|
reasoning: final.reasoning,
|
|
@@ -15139,7 +15628,7 @@ var SupervisorExecution = class {
|
|
|
15139
15628
|
const slice = {};
|
|
15140
15629
|
for (const [key, value] of Object.entries(record)) {
|
|
15141
15630
|
if (key === "intent") continue;
|
|
15142
|
-
slice
|
|
15631
|
+
assignSafeKey(slice, key, value);
|
|
15143
15632
|
}
|
|
15144
15633
|
const final = {
|
|
15145
15634
|
...raw,
|
|
@@ -15215,6 +15704,10 @@ var SupervisorExecution = class {
|
|
|
15215
15704
|
continue;
|
|
15216
15705
|
}
|
|
15217
15706
|
for (const [key, value] of Object.entries(slice)) {
|
|
15707
|
+
if (isUnsafeMergeKey(key)) {
|
|
15708
|
+
this.warnOnUnsafeKeys([key], `intent "${intent}"`);
|
|
15709
|
+
continue;
|
|
15710
|
+
}
|
|
15218
15711
|
const previousOwner = mergedKeys.get(key);
|
|
15219
15712
|
if (previousOwner !== void 0 && previousOwner !== intent) this.logger.warn(this.logModule, "state.merge.conflict", `state key "${key}" written by both "${previousOwner}" and "${intent}" — last-in-decision-array wins (Q15)`, {
|
|
15220
15713
|
key,
|
|
@@ -15266,9 +15759,9 @@ var SupervisorExecution = class {
|
|
|
15266
15759
|
const finalize = this.config.finalizeArtifacts;
|
|
15267
15760
|
if (finalize) {
|
|
15268
15761
|
const merged = finalize(this.state, artifacts);
|
|
15269
|
-
for (const key of Object.keys(this.state)) if (!(key
|
|
15270
|
-
|
|
15271
|
-
} else
|
|
15762
|
+
for (const key of Object.keys(this.state)) if (!Object.hasOwn(merged, key)) delete this.state[key];
|
|
15763
|
+
this.mergeIntoState(merged, "finalizeArtifacts");
|
|
15764
|
+
} else this.mergeIntoState(artifacts, "artifacts");
|
|
15272
15765
|
this.currentArtifacts = {};
|
|
15273
15766
|
}
|
|
15274
15767
|
/**
|
|
@@ -15898,6 +16391,10 @@ function validateFactoryConfig$1(config) {
|
|
|
15898
16391
|
authoring: true,
|
|
15899
16392
|
maxIterations: config.maxIterations
|
|
15900
16393
|
} });
|
|
16394
|
+
if (config.maxFanOut !== void 0 && (!Number.isInteger(config.maxFanOut) || config.maxFanOut < 1)) throw new SupervisorFailedError(`ai.supervisor("${config.name}"): \`maxFanOut\` must be an integer >= 1`, { context: {
|
|
16395
|
+
authoring: true,
|
|
16396
|
+
maxFanOut: config.maxFanOut
|
|
16397
|
+
} });
|
|
15901
16398
|
}
|
|
15902
16399
|
function generateRunId$2() {
|
|
15903
16400
|
return `sup_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
@@ -16506,7 +17003,7 @@ async function runTurn(ctx, input, options) {
|
|
|
16506
17003
|
const seedState = applyStatePatch(loaded.state, options.state);
|
|
16507
17004
|
let turnContext = options.context;
|
|
16508
17005
|
if (ctx.memory) {
|
|
16509
|
-
const recalled = await recallForTurn(ctx.memory, input);
|
|
17006
|
+
const recalled = await recallForTurn(ctx.memory, input, sessionId);
|
|
16510
17007
|
turnContext = injectMemories(turnContext, ctx.memory, recalled);
|
|
16511
17008
|
}
|
|
16512
17009
|
const { result, state, turnSnapshot } = await dispatchTurn({
|
|
@@ -16546,7 +17043,7 @@ async function runTurn(ctx, input, options) {
|
|
|
16546
17043
|
lastRoute: summarizeRoute(turnSnapshot.decision.raw),
|
|
16547
17044
|
summarizedThrough: loaded.record?.summarized_through ?? null
|
|
16548
17045
|
});
|
|
16549
|
-
if (ctx.memory) await rememberTurnOutcome(ctx.memory, input, outcomeTextFromTurn(result.data, turnSnapshot));
|
|
17046
|
+
if (ctx.memory) await rememberTurnOutcome(ctx.memory, input, outcomeTextFromTurn(result.data, turnSnapshot), sessionId);
|
|
16550
17047
|
let compaction;
|
|
16551
17048
|
if (shouldCompact(ctx, loaded.turnIndex)) {
|
|
16552
17049
|
const outcome = await runCompaction(ctx, sessionId, options.history);
|
|
@@ -17240,7 +17737,36 @@ function assertAcyclic(nodes, byId, plannerName) {
|
|
|
17240
17737
|
|
|
17241
17738
|
//#endregion
|
|
17242
17739
|
//#region ../ai/src/planner/plan-schema.ts
|
|
17740
|
+
/**
|
|
17741
|
+
* Slack allowed over `maxSteps` before a returned plan is rejected
|
|
17742
|
+
* outright. A model that overshoots the prompt's "at most N steps" by a
|
|
17743
|
+
* little is normal and the runtime truncates the tail to `skipped`;
|
|
17744
|
+
* one that returns several times the budget is malfunctioning (or the
|
|
17745
|
+
* provider/proxy is not the one we think it is), and parsing it is
|
|
17746
|
+
* unbounded work on attacker-adjacent input.
|
|
17747
|
+
*/
|
|
17748
|
+
const STEP_CEILING_FACTOR = 4;
|
|
17749
|
+
/**
|
|
17750
|
+
* Ceiling used when `planSchema` is built without a `maxSteps` — direct
|
|
17751
|
+
* callers outside `PlannerRun`, which has no runtime truncation of its
|
|
17752
|
+
* own to fall back on.
|
|
17753
|
+
*/
|
|
17754
|
+
const DEFAULT_STEP_CEILING = 100;
|
|
17755
|
+
/**
|
|
17756
|
+
* Hard upper bound on the number of steps `validate()` will parse.
|
|
17757
|
+
*
|
|
17758
|
+
* Strict-mode JSON Schema can't carry `maxItems`, so nothing on the wire
|
|
17759
|
+
* stops a provider from returning an arbitrarily long `steps[]`; before
|
|
17760
|
+
* 4.15.0 the whole array was parsed, normalized and stored, and only the
|
|
17761
|
+
* execution loop truncated it. This is the parse-time backstop that
|
|
17762
|
+
* makes the bound hold regardless of what the provider honors.
|
|
17763
|
+
*/
|
|
17764
|
+
function parsedStepCeiling(maxSteps) {
|
|
17765
|
+
if (maxSteps === void 0) return DEFAULT_STEP_CEILING;
|
|
17766
|
+
return Math.max(1, Math.ceil(maxSteps)) * STEP_CEILING_FACTOR;
|
|
17767
|
+
}
|
|
17243
17768
|
function planSchema(capabilityNames, maxSteps) {
|
|
17769
|
+
const stepCeiling = parsedStepCeiling(maxSteps);
|
|
17244
17770
|
const jsonSchema = {
|
|
17245
17771
|
type: "object",
|
|
17246
17772
|
properties: {
|
|
@@ -17265,6 +17791,7 @@ function planSchema(capabilityNames, maxSteps) {
|
|
|
17265
17791
|
if (!value || typeof value !== "object") return { issues: [{ message: "plan must be an object" }] };
|
|
17266
17792
|
const record = value;
|
|
17267
17793
|
if (!Array.isArray(record.steps) || record.steps.length === 0) return { issues: [{ message: "plan `steps` must be a non-empty array" }] };
|
|
17794
|
+
if (record.steps.length > stepCeiling) return { issues: [{ message: `plan \`steps\` must not exceed ${stepCeiling} entries (received ${record.steps.length})` }] };
|
|
17268
17795
|
const steps = [];
|
|
17269
17796
|
for (const raw of record.steps) {
|
|
17270
17797
|
const normalized = normalizeStep(raw);
|
|
@@ -24236,6 +24763,7 @@ exports.SupervisorFailedError = SupervisorFailedError;
|
|
|
24236
24763
|
exports.SupervisorRoutingError = SupervisorRoutingError;
|
|
24237
24764
|
exports.SystemPrompt = SystemPrompt;
|
|
24238
24765
|
exports.ToolExecutionError = ToolExecutionError;
|
|
24766
|
+
exports.UNSAFE_MERGE_KEYS = UNSAFE_MERGE_KEYS;
|
|
24239
24767
|
exports.VcrCassetteMissError = VcrCassetteMissError;
|
|
24240
24768
|
exports.WorkflowCancelledError = WorkflowCancelledError;
|
|
24241
24769
|
exports.WorkflowDriftError = WorkflowDriftError;
|
|
@@ -24245,6 +24773,7 @@ exports.agent = agent;
|
|
|
24245
24773
|
exports.ai = ai;
|
|
24246
24774
|
exports.approximateTokenCount = approximateTokenCount;
|
|
24247
24775
|
exports.assertUrlAllowed = assertUrlAllowed;
|
|
24776
|
+
exports.assignSafeKey = assignSafeKey;
|
|
24248
24777
|
exports.audioFromBuffer = audioFromBuffer;
|
|
24249
24778
|
exports.audioFromFile = audioFromFile;
|
|
24250
24779
|
exports.audioMediaTypeForFilename = audioMediaTypeForFilename;
|
|
@@ -24314,6 +24843,7 @@ exports.interruptRedis = redis$1;
|
|
|
24314
24843
|
exports.isExecutableTool = isExecutableTool;
|
|
24315
24844
|
exports.isObserveAll = isObserveAll;
|
|
24316
24845
|
exports.isPrivateOrReservedIp = isPrivateOrReservedIp;
|
|
24846
|
+
exports.isUnsafeMergeKey = isUnsafeMergeKey;
|
|
24317
24847
|
exports.judge = judge;
|
|
24318
24848
|
exports.judgePromptBody = judgePromptBody;
|
|
24319
24849
|
exports.keywordReranker = keywordReranker;
|
|
@@ -24330,6 +24860,7 @@ exports.matchOutputShape = require_matcher_logic.matchOutputShape;
|
|
|
24330
24860
|
exports.matchPassStep = require_matcher_logic.matchPassStep;
|
|
24331
24861
|
exports.matchRouteTo = require_matcher_logic.matchRouteTo;
|
|
24332
24862
|
exports.memory = memory$2;
|
|
24863
|
+
exports.mergeSafely = mergeSafely;
|
|
24333
24864
|
exports.mergeUsage = mergeUsage;
|
|
24334
24865
|
exports.mockAgent = mockAgent;
|
|
24335
24866
|
exports.mockRouter = mockRouter;
|
|
@@ -24342,7 +24873,9 @@ exports.onConfigApplied = onConfigApplied;
|
|
|
24342
24873
|
exports.orchestrator = orchestrator;
|
|
24343
24874
|
exports.orchestratorAsTool = asTool;
|
|
24344
24875
|
exports.orchestratorMemoryQueryFromInput = memoryQueryFromInput;
|
|
24876
|
+
exports.orchestratorMemoryScopeFor = memoryScopeFor;
|
|
24345
24877
|
exports.orchestratorOutcomeTextFromTurn = outcomeTextFromTurn;
|
|
24878
|
+
exports.orchestratorSessionMemoryScope = sessionMemoryScope;
|
|
24346
24879
|
exports.parseFrontmatter = parseFrontmatter;
|
|
24347
24880
|
exports.parsePartialJson = parsePartialJson;
|
|
24348
24881
|
exports.parseTags = parseTags;
|