@warlock.js/ai 4.14.0 → 4.16.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 +182 -141
- package/cjs/index.cjs +640 -139
- package/cjs/index.cjs.map +1 -1
- package/esm/contracts/index.d.mts +3 -3
- 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/contracts/team/team-config.type.d.mts +9 -6
- package/esm/contracts/team/team-config.type.d.mts.map +1 -1
- package/esm/contracts/tool.contract.d.mts +35 -3
- package/esm/contracts/tool.contract.d.mts.map +1 -1
- package/esm/index.d.mts +8 -7
- 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/mock/index.d.mts +1 -1
- package/esm/mock/mock-config.type.d.mts +33 -4
- package/esm/mock/mock-config.type.d.mts.map +1 -1
- package/esm/mock/mock-model.d.mts +2 -1
- package/esm/mock/mock-model.d.mts.map +1 -1
- package/esm/mock/mock-model.mjs +5 -4
- package/esm/mock/mock-model.mjs.map +1 -1
- package/esm/mock/mock-sdk.d.mts +11 -3
- package/esm/mock/mock-sdk.d.mts.map +1 -1
- package/esm/mock/mock-sdk.mjs.map +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 +15 -4
- package/esm/planner/plan-schema.d.mts.map +1 -1
- package/esm/planner/plan-schema.mjs +27 -16
- 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 -12
- package/esm/supervisor/execution.d.mts.map +1 -1
- package/esm/supervisor/execution.mjs +47 -24
- 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 +185 -14
- package/llms.txt +5 -4
- package/package.json +24 -4
- package/skills/README.md +6 -2
- package/skills/attach-ai-middleware/SKILL.md +17 -1
- package/skills/generate-images/SKILL.md +11 -4
- 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
|
|
@@ -11846,7 +12094,8 @@ var MockModel = class {
|
|
|
11846
12094
|
}
|
|
11847
12095
|
/**
|
|
11848
12096
|
* Record the call, optionally delay, then emit the scripted response as a
|
|
11849
|
-
* sequence of stream chunks:
|
|
12097
|
+
* sequence of stream chunks: the scripted `deltas` when the entry
|
|
12098
|
+
* supplies them, otherwise content split word-by-word, as `delta`
|
|
11850
12099
|
* chunks, each scripted tool call as a `tool-call` chunk, and finally a
|
|
11851
12100
|
* `done` chunk with finish reason + usage. Throws eagerly if the scripted
|
|
11852
12101
|
* entry carries an `error`.
|
|
@@ -11859,10 +12108,10 @@ var MockModel = class {
|
|
|
11859
12108
|
const mock = this.nextResponse();
|
|
11860
12109
|
if (mock.delay) await new Promise((resolve) => setTimeout(resolve, mock.delay));
|
|
11861
12110
|
if (mock.error) throw mock.error;
|
|
11862
|
-
const
|
|
11863
|
-
for (const
|
|
12111
|
+
const chunks = mock.deltas ?? mock.content.split(" ").map((word) => word + " ");
|
|
12112
|
+
for (const chunk of chunks) yield {
|
|
11864
12113
|
type: "delta",
|
|
11865
|
-
content:
|
|
12114
|
+
content: chunk
|
|
11866
12115
|
};
|
|
11867
12116
|
if (mock.toolCalls) for (const toolCall of mock.toolCalls) yield {
|
|
11868
12117
|
type: "tool-call",
|
|
@@ -12324,20 +12573,34 @@ var FallbackRun = class {
|
|
|
12324
12573
|
* Mirrors `supervisor.asTool()` — same `compositeAsTool` composition and
|
|
12325
12574
|
* error normalization — and adds `sessionScope`.
|
|
12326
12575
|
*
|
|
12327
|
-
* The boundary is OPAQUE (§13, §18.6): the parent's `
|
|
12328
|
-
*
|
|
12329
|
-
*
|
|
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.
|
|
12330
12580
|
*
|
|
12331
12581
|
* Session continuity:
|
|
12332
12582
|
* - `"fresh"` (default) — each invocation gets a brand-new `sessionId`
|
|
12333
12583
|
* (a generated id) and empty history; the session lives only for this
|
|
12334
12584
|
* tool call. The whole validated payload is forwarded as the
|
|
12335
12585
|
* orchestrator's `execute(input)` argument.
|
|
12336
|
-
* - `"shared"` — the
|
|
12337
|
-
* `
|
|
12338
|
-
*
|
|
12339
|
-
*
|
|
12340
|
-
*
|
|
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.
|
|
12341
12604
|
*
|
|
12342
12605
|
* On `result.error`, the typed orchestrator error is thrown so the tool
|
|
12343
12606
|
* wrapper produces a `ToolExecutionError` with `cause` preserved — the
|
|
@@ -12345,22 +12608,36 @@ var FallbackRun = class {
|
|
|
12345
12608
|
*
|
|
12346
12609
|
* @example
|
|
12347
12610
|
* const support = ai.orchestrator({ name: "refund-support", intents });
|
|
12611
|
+
*
|
|
12612
|
+
* // Fresh session per call — no continuity, nothing to hijack.
|
|
12348
12613
|
* const supportTool = support.asTool({
|
|
12349
12614
|
* name: "handle_refund",
|
|
12350
12615
|
* description: "Handle a refund conversation end-to-end.",
|
|
12351
12616
|
* inputSchema: v.object({ message: v.string() }),
|
|
12352
12617
|
* });
|
|
12353
|
-
*
|
|
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
|
+
* });
|
|
12354
12629
|
*/
|
|
12355
12630
|
function asTool(orchestrator, options) {
|
|
12356
12631
|
if (!orchestrator.name || typeof orchestrator.name !== "string") throw new SupervisorFailedError("orchestrator.asTool(): orchestrator must have a `name` to be wrapped as a tool");
|
|
12357
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");
|
|
12358
12635
|
return compositeAsTool({
|
|
12359
12636
|
name: options.name ?? orchestrator.name,
|
|
12360
12637
|
description: options.description ?? `Invoke orchestrator "${orchestrator.name}" as a tool.`,
|
|
12361
12638
|
input: options.inputSchema,
|
|
12362
|
-
execute: async (input) => {
|
|
12363
|
-
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);
|
|
12364
12641
|
const result = await orchestrator.execute(executeInput, {
|
|
12365
12642
|
sessionId,
|
|
12366
12643
|
history
|
|
@@ -12376,21 +12653,41 @@ function asTool(orchestrator, options) {
|
|
|
12376
12653
|
}
|
|
12377
12654
|
/**
|
|
12378
12655
|
* Resolve the per-call `sessionId`, `history`, and the `execute(input)`
|
|
12379
|
-
* 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.
|
|
12380
12665
|
*/
|
|
12381
|
-
function resolveSession(sessionScope, input) {
|
|
12666
|
+
async function resolveSession(sessionScope, input, ctx, session, allowModelSessionId) {
|
|
12382
12667
|
if (sessionScope === "fresh") return {
|
|
12383
12668
|
sessionId: generateRunId("session"),
|
|
12384
12669
|
history: [],
|
|
12385
12670
|
executeInput: coerceInput$1(input)
|
|
12386
12671
|
};
|
|
12387
|
-
const
|
|
12388
|
-
|
|
12389
|
-
|
|
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");
|
|
12390
12687
|
return {
|
|
12391
|
-
sessionId,
|
|
12392
|
-
history: Array.isArray(
|
|
12393
|
-
executeInput
|
|
12688
|
+
sessionId: payloadSessionId,
|
|
12689
|
+
history: Array.isArray(payloadHistory) ? payloadHistory : [],
|
|
12690
|
+
executeInput
|
|
12394
12691
|
};
|
|
12395
12692
|
}
|
|
12396
12693
|
/**
|
|
@@ -12524,6 +12821,14 @@ function invoke$2(handler, payload) {
|
|
|
12524
12821
|
/** Default key the recalled memories are injected under in the context bag. */
|
|
12525
12822
|
const DEFAULT_INJECT_KEY = "memories";
|
|
12526
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
|
+
/**
|
|
12527
12832
|
* A `MemoryContract` is the bare-store form; anything carrying a `store`
|
|
12528
12833
|
* is the {@link OrchestratorMemoryConfig} wrapper. Distinguished by the
|
|
12529
12834
|
* presence of `recall` — a method on the contract, absent on the config
|
|
@@ -12543,6 +12848,7 @@ function resolveOrchestratorMemory(memory) {
|
|
|
12543
12848
|
if (isBareMemory(memory)) return {
|
|
12544
12849
|
store: memory,
|
|
12545
12850
|
remember: true,
|
|
12851
|
+
scope: DEFAULT_SCOPE,
|
|
12546
12852
|
injectKey: DEFAULT_INJECT_KEY
|
|
12547
12853
|
};
|
|
12548
12854
|
return {
|
|
@@ -12552,10 +12858,39 @@ function resolveOrchestratorMemory(memory) {
|
|
|
12552
12858
|
tier: memory.recall?.tier,
|
|
12553
12859
|
remember: memory.remember ?? true,
|
|
12554
12860
|
rememberTier: memory.rememberTier,
|
|
12861
|
+
scope: memory.scope ?? DEFAULT_SCOPE,
|
|
12555
12862
|
injectKey: memory.injectKey ?? DEFAULT_INJECT_KEY
|
|
12556
12863
|
};
|
|
12557
12864
|
}
|
|
12558
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
|
+
/**
|
|
12559
12894
|
* Coerce a turn's {@link SupervisorInput} (string or structured object)
|
|
12560
12895
|
* into the natural-language query the memory store recalls / embeds
|
|
12561
12896
|
* against. Strings pass through; objects are JSON-serialized — the same
|
|
@@ -12572,13 +12907,18 @@ function memoryQueryFromInput(input) {
|
|
|
12572
12907
|
* `memory.injectKey`. Returns an empty array — never throws on "no hits"
|
|
12573
12908
|
* — and short-circuits when `k === 0` (recall disabled / write-only
|
|
12574
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.
|
|
12575
12914
|
*/
|
|
12576
|
-
async function recallForTurn(memory, input) {
|
|
12915
|
+
async function recallForTurn(memory, input, sessionId) {
|
|
12577
12916
|
if (memory.k === 0) return [];
|
|
12578
12917
|
return memory.store.recall(memoryQueryFromInput(input), {
|
|
12579
12918
|
k: memory.k,
|
|
12580
12919
|
threshold: memory.threshold,
|
|
12581
|
-
tier: memory.tier
|
|
12920
|
+
tier: memory.tier,
|
|
12921
|
+
scope: memoryScopeFor(memory, sessionId)
|
|
12582
12922
|
});
|
|
12583
12923
|
}
|
|
12584
12924
|
/**
|
|
@@ -12609,14 +12949,19 @@ function injectMemories(context, memory, recalled) {
|
|
|
12609
12949
|
* The remembered text is the turn input followed by the model's textual
|
|
12610
12950
|
* outcome when one is available, so a later `recall` keyed on a similar
|
|
12611
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.
|
|
12612
12956
|
*/
|
|
12613
|
-
async function rememberTurnOutcome(memory, input, outcomeText) {
|
|
12957
|
+
async function rememberTurnOutcome(memory, input, outcomeText, sessionId) {
|
|
12614
12958
|
if (!memory.remember) return;
|
|
12615
12959
|
const text = buildOutcomeText(input, outcomeText);
|
|
12616
12960
|
if (!text) return;
|
|
12617
12961
|
const item = {
|
|
12618
12962
|
text,
|
|
12619
|
-
tier: memory.rememberTier
|
|
12963
|
+
tier: memory.rememberTier,
|
|
12964
|
+
scope: memoryScopeFor(memory, sessionId)
|
|
12620
12965
|
};
|
|
12621
12966
|
await memory.store.remember(item);
|
|
12622
12967
|
}
|
|
@@ -13320,6 +13665,72 @@ function isWorkflowResult(raw) {
|
|
|
13320
13665
|
return raw.type === "workflow";
|
|
13321
13666
|
}
|
|
13322
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
|
+
|
|
13323
13734
|
//#endregion
|
|
13324
13735
|
//#region ../ai/src/supervisor/cancellation.ts
|
|
13325
13736
|
/**
|
|
@@ -13431,7 +13842,7 @@ async function decideViaCallback(params) {
|
|
|
13431
13842
|
throw wrapRouteError(params.config.name, thrown);
|
|
13432
13843
|
}
|
|
13433
13844
|
const durationMs = performance.now() - started;
|
|
13434
|
-
return normalize(raw, params.entries, "route", durationMs);
|
|
13845
|
+
return normalize(raw, params.entries, "route", durationMs, resolveMaxFanOut(params.config));
|
|
13435
13846
|
}
|
|
13436
13847
|
async function decideViaRouter(params) {
|
|
13437
13848
|
const { agent, placeholders, inputOverride, historySlicer } = resolveRouterEntry(params.config.router);
|
|
@@ -13480,7 +13891,7 @@ async function decideViaRouter(params) {
|
|
|
13480
13891
|
availableKeys: [...params.entries.keys()]
|
|
13481
13892
|
});
|
|
13482
13893
|
return {
|
|
13483
|
-
...normalize(rawNext, params.entries, "router", durationMs),
|
|
13894
|
+
...normalize(rawNext, params.entries, "router", durationMs, resolveMaxFanOut(params.config)),
|
|
13484
13895
|
reasoning: typeof reasoning === "string" ? reasoning : void 0,
|
|
13485
13896
|
usage: routerResult.usage,
|
|
13486
13897
|
routerReport: routerResult.report
|
|
@@ -13520,7 +13931,7 @@ function resolveSupervisorPromptText(config) {
|
|
|
13520
13931
|
* `next` field) into a canonical `DispatchDecision`, validating every
|
|
13521
13932
|
* named intent against the supervisor's `intents` map.
|
|
13522
13933
|
*/
|
|
13523
|
-
function normalize(raw, entries, source, durationMs) {
|
|
13934
|
+
function normalize(raw, entries, source, durationMs, maxFanOut) {
|
|
13524
13935
|
if (isEnd(raw)) return {
|
|
13525
13936
|
kind: "end",
|
|
13526
13937
|
source,
|
|
@@ -13551,7 +13962,7 @@ function normalize(raw, entries, source, durationMs) {
|
|
|
13551
13962
|
}
|
|
13552
13963
|
return {
|
|
13553
13964
|
kind: "dispatch",
|
|
13554
|
-
intents: raw,
|
|
13965
|
+
intents: capFanOut(raw, entries, maxFanOut),
|
|
13555
13966
|
source,
|
|
13556
13967
|
raw,
|
|
13557
13968
|
durationMs
|
|
@@ -13562,6 +13973,53 @@ function normalize(raw, entries, source, durationMs) {
|
|
|
13562
13973
|
availableKeys: [...entries.keys()]
|
|
13563
13974
|
});
|
|
13564
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
|
+
}
|
|
13565
14023
|
function validateKey(intent, entries) {
|
|
13566
14024
|
if (!entries.has(intent)) throw new SupervisorRoutingError(`router returned unknown agent key "${intent}"`, {
|
|
13567
14025
|
returned: intent,
|
|
@@ -14127,9 +14585,17 @@ var SupervisorExecution = class {
|
|
|
14127
14585
|
* branch errors don't abort siblings — they're recorded on the
|
|
14128
14586
|
* branch snapshot and let evaluate (or default termination logic)
|
|
14129
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.
|
|
14130
14595
|
*/
|
|
14131
14596
|
async dispatchBranches(decision) {
|
|
14132
|
-
|
|
14597
|
+
const intents = capFanOut(decision.intents, this.entries, resolveMaxFanOut(this.config));
|
|
14598
|
+
return await Promise.all(intents.map((intent) => this.dispatchOne(intent)));
|
|
14133
14599
|
}
|
|
14134
14600
|
/**
|
|
14135
14601
|
* Execute a single branch — resolve the input, invoke the
|
|
@@ -14514,24 +14980,10 @@ var SupervisorExecution = class {
|
|
|
14514
14980
|
* through unchanged so structured inputs work.
|
|
14515
14981
|
*/
|
|
14516
14982
|
coerceInlineInput(executable, input) {
|
|
14517
|
-
if (
|
|
14983
|
+
if ("isAnonymous" in executable && typeof input !== "string") return safeStringify(input);
|
|
14518
14984
|
return input;
|
|
14519
14985
|
}
|
|
14520
14986
|
/**
|
|
14521
|
-
* Heuristic detection of `SupervisorContract` — the contract carries
|
|
14522
|
-
* a `signature` getter same as workflows, but supervisors expose
|
|
14523
|
-
* `resume()` while workflows expose `resume(runId, options)` too.
|
|
14524
|
-
* Cleanest distinguisher in the public surface: supervisors carry
|
|
14525
|
-
* the `asTool` method name `as` … unfortunately so do workflows.
|
|
14526
|
-
* Use the `streamableType` brand if we add one in v2; for now lean
|
|
14527
|
-
* on a duck-typed check that's good enough for the ctx.run path
|
|
14528
|
-
* (incorrect routing for workflows would still produce a runnable
|
|
14529
|
-
* call — workflow.execute accepts the same args either way).
|
|
14530
|
-
*/
|
|
14531
|
-
isSupervisor(executable) {
|
|
14532
|
-
return typeof executable.resume === "function" && typeof executable.signature === "string" && typeof executable.stream === "function";
|
|
14533
|
-
}
|
|
14534
|
-
/**
|
|
14535
14987
|
* Invoke the underlying dispatchable unit. Agents and workflows
|
|
14536
14988
|
* both satisfy `ExecutableContract<string, …>` so the call shape
|
|
14537
14989
|
* is uniform; the `type` discriminator picks which options get
|
|
@@ -14829,7 +15281,31 @@ var SupervisorExecution = class {
|
|
|
14829
15281
|
if (ackOutcome.error || !ackOutcome.output) return;
|
|
14830
15282
|
if (typeof ackOutcome.output !== "object" || ackOutcome.output === null) return;
|
|
14831
15283
|
const slice = ackOutcome.output;
|
|
14832
|
-
|
|
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
|
+
});
|
|
14833
15309
|
}
|
|
14834
15310
|
/**
|
|
14835
15311
|
* Run the iter-0 classifier prelude (Phase 7 / decisions §37).
|
|
@@ -14953,9 +15429,9 @@ var SupervisorExecution = class {
|
|
|
14953
15429
|
refined = interpretation.refined;
|
|
14954
15430
|
halted = interpretation.halted;
|
|
14955
15431
|
final = interpretation.final ?? raw;
|
|
14956
|
-
if (interpretation.sliceToMerge)
|
|
15432
|
+
if (interpretation.sliceToMerge) this.mergeIntoState(interpretation.sliceToMerge, "classifier.refine");
|
|
14957
15433
|
}
|
|
14958
|
-
|
|
15434
|
+
this.mergeIntoState(final, "classifier");
|
|
14959
15435
|
this.classifierSnapshot = {
|
|
14960
15436
|
intent: halted ? void 0 : final.intent,
|
|
14961
15437
|
reasoning: final.reasoning,
|
|
@@ -15152,7 +15628,7 @@ var SupervisorExecution = class {
|
|
|
15152
15628
|
const slice = {};
|
|
15153
15629
|
for (const [key, value] of Object.entries(record)) {
|
|
15154
15630
|
if (key === "intent") continue;
|
|
15155
|
-
slice
|
|
15631
|
+
assignSafeKey(slice, key, value);
|
|
15156
15632
|
}
|
|
15157
15633
|
const final = {
|
|
15158
15634
|
...raw,
|
|
@@ -15228,6 +15704,10 @@ var SupervisorExecution = class {
|
|
|
15228
15704
|
continue;
|
|
15229
15705
|
}
|
|
15230
15706
|
for (const [key, value] of Object.entries(slice)) {
|
|
15707
|
+
if (isUnsafeMergeKey(key)) {
|
|
15708
|
+
this.warnOnUnsafeKeys([key], `intent "${intent}"`);
|
|
15709
|
+
continue;
|
|
15710
|
+
}
|
|
15231
15711
|
const previousOwner = mergedKeys.get(key);
|
|
15232
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)`, {
|
|
15233
15713
|
key,
|
|
@@ -15279,9 +15759,9 @@ var SupervisorExecution = class {
|
|
|
15279
15759
|
const finalize = this.config.finalizeArtifacts;
|
|
15280
15760
|
if (finalize) {
|
|
15281
15761
|
const merged = finalize(this.state, artifacts);
|
|
15282
|
-
for (const key of Object.keys(this.state)) if (!(key
|
|
15283
|
-
|
|
15284
|
-
} 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");
|
|
15285
15765
|
this.currentArtifacts = {};
|
|
15286
15766
|
}
|
|
15287
15767
|
/**
|
|
@@ -15911,6 +16391,10 @@ function validateFactoryConfig$1(config) {
|
|
|
15911
16391
|
authoring: true,
|
|
15912
16392
|
maxIterations: config.maxIterations
|
|
15913
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
|
+
} });
|
|
15914
16398
|
}
|
|
15915
16399
|
function generateRunId$2() {
|
|
15916
16400
|
return `sup_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
@@ -16519,7 +17003,7 @@ async function runTurn(ctx, input, options) {
|
|
|
16519
17003
|
const seedState = applyStatePatch(loaded.state, options.state);
|
|
16520
17004
|
let turnContext = options.context;
|
|
16521
17005
|
if (ctx.memory) {
|
|
16522
|
-
const recalled = await recallForTurn(ctx.memory, input);
|
|
17006
|
+
const recalled = await recallForTurn(ctx.memory, input, sessionId);
|
|
16523
17007
|
turnContext = injectMemories(turnContext, ctx.memory, recalled);
|
|
16524
17008
|
}
|
|
16525
17009
|
const { result, state, turnSnapshot } = await dispatchTurn({
|
|
@@ -16559,7 +17043,7 @@ async function runTurn(ctx, input, options) {
|
|
|
16559
17043
|
lastRoute: summarizeRoute(turnSnapshot.decision.raw),
|
|
16560
17044
|
summarizedThrough: loaded.record?.summarized_through ?? null
|
|
16561
17045
|
});
|
|
16562
|
-
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);
|
|
16563
17047
|
let compaction;
|
|
16564
17048
|
if (shouldCompact(ctx, loaded.turnIndex)) {
|
|
16565
17049
|
const outcome = await runCompaction(ctx, sessionId, options.history);
|
|
@@ -17254,25 +17738,35 @@ function assertAcyclic(nodes, byId, plannerName) {
|
|
|
17254
17738
|
//#endregion
|
|
17255
17739
|
//#region ../ai/src/planner/plan-schema.ts
|
|
17256
17740
|
/**
|
|
17257
|
-
*
|
|
17258
|
-
*
|
|
17259
|
-
*
|
|
17260
|
-
*
|
|
17261
|
-
*
|
|
17262
|
-
*
|
|
17263
|
-
|
|
17264
|
-
|
|
17265
|
-
|
|
17266
|
-
*
|
|
17267
|
-
*
|
|
17268
|
-
*
|
|
17269
|
-
|
|
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.
|
|
17270
17757
|
*
|
|
17271
|
-
* `
|
|
17272
|
-
*
|
|
17273
|
-
*
|
|
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.
|
|
17274
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
|
+
}
|
|
17275
17768
|
function planSchema(capabilityNames, maxSteps) {
|
|
17769
|
+
const stepCeiling = parsedStepCeiling(maxSteps);
|
|
17276
17770
|
const jsonSchema = {
|
|
17277
17771
|
type: "object",
|
|
17278
17772
|
properties: {
|
|
@@ -17297,6 +17791,7 @@ function planSchema(capabilityNames, maxSteps) {
|
|
|
17297
17791
|
if (!value || typeof value !== "object") return { issues: [{ message: "plan must be an object" }] };
|
|
17298
17792
|
const record = value;
|
|
17299
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})` }] };
|
|
17300
17795
|
const steps = [];
|
|
17301
17796
|
for (const raw of record.steps) {
|
|
17302
17797
|
const normalized = normalizeStep(raw);
|
|
@@ -24268,6 +24763,7 @@ exports.SupervisorFailedError = SupervisorFailedError;
|
|
|
24268
24763
|
exports.SupervisorRoutingError = SupervisorRoutingError;
|
|
24269
24764
|
exports.SystemPrompt = SystemPrompt;
|
|
24270
24765
|
exports.ToolExecutionError = ToolExecutionError;
|
|
24766
|
+
exports.UNSAFE_MERGE_KEYS = UNSAFE_MERGE_KEYS;
|
|
24271
24767
|
exports.VcrCassetteMissError = VcrCassetteMissError;
|
|
24272
24768
|
exports.WorkflowCancelledError = WorkflowCancelledError;
|
|
24273
24769
|
exports.WorkflowDriftError = WorkflowDriftError;
|
|
@@ -24277,6 +24773,7 @@ exports.agent = agent;
|
|
|
24277
24773
|
exports.ai = ai;
|
|
24278
24774
|
exports.approximateTokenCount = approximateTokenCount;
|
|
24279
24775
|
exports.assertUrlAllowed = assertUrlAllowed;
|
|
24776
|
+
exports.assignSafeKey = assignSafeKey;
|
|
24280
24777
|
exports.audioFromBuffer = audioFromBuffer;
|
|
24281
24778
|
exports.audioFromFile = audioFromFile;
|
|
24282
24779
|
exports.audioMediaTypeForFilename = audioMediaTypeForFilename;
|
|
@@ -24346,6 +24843,7 @@ exports.interruptRedis = redis$1;
|
|
|
24346
24843
|
exports.isExecutableTool = isExecutableTool;
|
|
24347
24844
|
exports.isObserveAll = isObserveAll;
|
|
24348
24845
|
exports.isPrivateOrReservedIp = isPrivateOrReservedIp;
|
|
24846
|
+
exports.isUnsafeMergeKey = isUnsafeMergeKey;
|
|
24349
24847
|
exports.judge = judge;
|
|
24350
24848
|
exports.judgePromptBody = judgePromptBody;
|
|
24351
24849
|
exports.keywordReranker = keywordReranker;
|
|
@@ -24362,6 +24860,7 @@ exports.matchOutputShape = require_matcher_logic.matchOutputShape;
|
|
|
24362
24860
|
exports.matchPassStep = require_matcher_logic.matchPassStep;
|
|
24363
24861
|
exports.matchRouteTo = require_matcher_logic.matchRouteTo;
|
|
24364
24862
|
exports.memory = memory$2;
|
|
24863
|
+
exports.mergeSafely = mergeSafely;
|
|
24365
24864
|
exports.mergeUsage = mergeUsage;
|
|
24366
24865
|
exports.mockAgent = mockAgent;
|
|
24367
24866
|
exports.mockRouter = mockRouter;
|
|
@@ -24374,7 +24873,9 @@ exports.onConfigApplied = onConfigApplied;
|
|
|
24374
24873
|
exports.orchestrator = orchestrator;
|
|
24375
24874
|
exports.orchestratorAsTool = asTool;
|
|
24376
24875
|
exports.orchestratorMemoryQueryFromInput = memoryQueryFromInput;
|
|
24876
|
+
exports.orchestratorMemoryScopeFor = memoryScopeFor;
|
|
24377
24877
|
exports.orchestratorOutcomeTextFromTurn = outcomeTextFromTurn;
|
|
24878
|
+
exports.orchestratorSessionMemoryScope = sessionMemoryScope;
|
|
24378
24879
|
exports.parseFrontmatter = parseFrontmatter;
|
|
24379
24880
|
exports.parsePartialJson = parsePartialJson;
|
|
24380
24881
|
exports.parseTags = parseTags;
|