@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +183 -158
  2. package/cjs/index.cjs +637 -104
  3. package/cjs/index.cjs.map +1 -1
  4. package/esm/contracts/index.d.mts +2 -2
  5. package/esm/contracts/memory/index.d.mts +1 -1
  6. package/esm/contracts/memory/memory-config.type.d.mts +29 -3
  7. package/esm/contracts/memory/memory-config.type.d.mts.map +1 -1
  8. package/esm/contracts/memory/memory-item.type.d.mts +15 -1
  9. package/esm/contracts/memory/memory-item.type.d.mts.map +1 -1
  10. package/esm/contracts/memory/memory.contract.d.mts +15 -2
  11. package/esm/contracts/memory/memory.contract.d.mts.map +1 -1
  12. package/esm/contracts/memory/recall-options.type.d.mts +12 -0
  13. package/esm/contracts/memory/recall-options.type.d.mts.map +1 -1
  14. package/esm/contracts/orchestrator/index.d.mts +1 -1
  15. package/esm/contracts/orchestrator/orchestrator-config.type.d.mts +38 -1
  16. package/esm/contracts/orchestrator/orchestrator-config.type.d.mts.map +1 -1
  17. package/esm/contracts/orchestrator/orchestrator.contract.d.mts +67 -3
  18. package/esm/contracts/orchestrator/orchestrator.contract.d.mts.map +1 -1
  19. package/esm/contracts/supervisor/supervisor-config.type.d.mts +23 -0
  20. package/esm/contracts/supervisor/supervisor-config.type.d.mts.map +1 -1
  21. package/esm/index.d.mts +6 -5
  22. package/esm/index.mjs +3 -2
  23. package/esm/memory/episodic-memory.mjs +14 -6
  24. package/esm/memory/episodic-memory.mjs.map +1 -1
  25. package/esm/memory/index.d.mts +1 -1
  26. package/esm/memory/memory.d.mts +13 -1
  27. package/esm/memory/memory.d.mts.map +1 -1
  28. package/esm/memory/memory.mjs +41 -7
  29. package/esm/memory/memory.mjs.map +1 -1
  30. package/esm/memory/procedural-memory.mjs +20 -7
  31. package/esm/memory/procedural-memory.mjs.map +1 -1
  32. package/esm/memory/semantic-memory.mjs +27 -10
  33. package/esm/memory/semantic-memory.mjs.map +1 -1
  34. package/esm/memory/working-memory.mjs +70 -13
  35. package/esm/memory/working-memory.mjs.map +1 -1
  36. package/esm/middleware/builtins/semantic-cache.d.mts +46 -1
  37. package/esm/middleware/builtins/semantic-cache.d.mts.map +1 -1
  38. package/esm/middleware/builtins/semantic-cache.mjs +60 -15
  39. package/esm/middleware/builtins/semantic-cache.mjs.map +1 -1
  40. package/esm/middleware/index.d.mts +1 -1
  41. package/esm/orchestrator/as-tool.d.mts +35 -9
  42. package/esm/orchestrator/as-tool.d.mts.map +1 -1
  43. package/esm/orchestrator/as-tool.mjs +67 -19
  44. package/esm/orchestrator/as-tool.mjs.map +1 -1
  45. package/esm/orchestrator/execution.d.mts.map +1 -1
  46. package/esm/orchestrator/execution.mjs +2 -2
  47. package/esm/orchestrator/execution.mjs.map +1 -1
  48. package/esm/orchestrator/index.d.mts +1 -1
  49. package/esm/orchestrator/index.mjs +1 -1
  50. package/esm/orchestrator/memory.d.mts +41 -5
  51. package/esm/orchestrator/memory.d.mts.map +1 -1
  52. package/esm/orchestrator/memory.mjs +53 -5
  53. package/esm/orchestrator/memory.mjs.map +1 -1
  54. package/esm/planner/plan-schema.d.mts +3 -3
  55. package/esm/planner/plan-schema.d.mts.map +1 -1
  56. package/esm/planner/plan-schema.mjs +30 -0
  57. package/esm/planner/plan-schema.mjs.map +1 -1
  58. package/esm/security/index.mjs +1 -0
  59. package/esm/security/outbound-policy.d.mts +9 -0
  60. package/esm/security/outbound-policy.d.mts.map +1 -1
  61. package/esm/security/outbound-policy.mjs +79 -5
  62. package/esm/security/outbound-policy.mjs.map +1 -1
  63. package/esm/security/outbound-policy.type.d.mts +8 -0
  64. package/esm/security/outbound-policy.type.d.mts.map +1 -1
  65. package/esm/security/safe-merge.d.mts +52 -0
  66. package/esm/security/safe-merge.d.mts.map +1 -0
  67. package/esm/security/safe-merge.mjs +68 -0
  68. package/esm/security/safe-merge.mjs.map +1 -0
  69. package/esm/supervisor/decide.mjs +52 -5
  70. package/esm/supervisor/decide.mjs.map +1 -1
  71. package/esm/supervisor/execution.d.mts +22 -0
  72. package/esm/supervisor/execution.d.mts.map +1 -1
  73. package/esm/supervisor/execution.mjs +46 -9
  74. package/esm/supervisor/execution.mjs.map +1 -1
  75. package/esm/supervisor/supervisor.mjs +4 -0
  76. package/esm/supervisor/supervisor.mjs.map +1 -1
  77. package/llms-full.txt +174 -10
  78. package/llms.txt +4 -3
  79. package/package.json +4 -4
  80. package/skills/README.md +5 -1
  81. package/skills/attach-ai-middleware/SKILL.md +17 -1
  82. package/skills/rag-loaders-and-stores/SKILL.md +3 -0
  83. package/skills/run-ai-agent/SKILL.md +3 -0
  84. package/skills/run-orchestrator/SKILL.md +6 -1
  85. package/skills/run-planner/SKILL.md +7 -3
  86. package/skills/run-supervisor/SKILL.md +11 -1
  87. package/skills/secure-outbound-requests/SKILL.md +85 -0
  88. package/skills/use-ai-memory/SKILL.md +36 -3
  89. 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
- const url = await assertUrlAllowed(rawUrl, policy);
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
- return await policy.fetch(url, {
1607
- ...init,
1608
- signal: mergeSignals(timeoutController.signal, policy.signal)
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
- const serialized = messages.map((message) => {
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 scopedKey = keyFor(promptKey);
2972
- const exact = await store.get(scopedKey);
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 [hit] = await store.similar(query.vector, {
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 && isFresh(hit.value, options.ttlMs)) {
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$1 = 5;
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$1, k),
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 existing = await this.store.get(this.keyFor(id));
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(this.keyFor(id), value, { vector });
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
- /** Namespaced key for an entry — dot separator, matching `similar()` keys. */
4508
- keyFor(id) {
4509
- return `${this.namespace}.${id}`;
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 as
4557
- * scored {@link RecalledMemory}. Hits indexed under a different
4558
- * namespace (a shared driver) are filtered out.
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 id keeps its original position; delete
4615
- * + set would move it to the end and lie about recency, so the value
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
- * Return up to `k` most-recently-remembered items, newest first. The
4627
- * `score` is a linear recency proxy: the newest item scores `1`, the
4628
- * oldest of the returned slice trends toward `0`. Working memory
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.entries()].reverse().slice(0, Math.max(0, k));
4633
- return slice.map(([id, entry], index) => ({
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
- * Decay / forgetting (TTL-based falloff, eviction) remains deferred.
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 workingEnabled = config.working ?? true;
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 = workingEnabled ? new WorkingMemory() : void 0;
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 `signal` / `context`
12329
- * / events do NOT auto-forward. Anything the wrapped orchestrator needs
12330
- * per call must ride on the tool's `inputSchema` payload.
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 parent supplies `sessionId` (and optionally
12338
- * `history`) inside the validated payload; the orchestrator
12339
- * participates in that session. The remaining payload fields are
12340
- * forwarded as `execute(input)`. A missing/blank `sessionId` throws
12341
- * {@link SupervisorFailedError}.
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
- * const concierge = ai.agent({ model, tools: [supportTool] });
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 from the validated tool payload, according to `sessionScope`.
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 payload = typeof input === "object" && input !== null ? input : {};
12389
- if (typeof payload.sessionId !== "string" || payload.sessionId.length === 0) throw new SupervisorFailedError("orchestrator.asTool(): sessionScope \"shared\" requires a `sessionId` string in the tool input payload");
12390
- const { sessionId, history, ...rest } = payload;
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(history) ? history : [],
12394
- executeInput: coerceInput$1(rest)
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
- return await Promise.all(decision.intents.map((intent) => this.dispatchOne(intent)));
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
- for (const [key, value] of Object.entries(slice)) this.state[key] = value;
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) for (const [key, value] of Object.entries(interpretation.sliceToMerge)) this.state[key] = value;
15432
+ if (interpretation.sliceToMerge) this.mergeIntoState(interpretation.sliceToMerge, "classifier.refine");
14944
15433
  }
14945
- for (const [key, value] of Object.entries(final)) this.state[key] = value;
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[key] = value;
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 in merged)) delete this.state[key];
15270
- for (const [key, value] of Object.entries(merged)) this.state[key] = value;
15271
- } else for (const [key, value] of Object.entries(artifacts)) this.state[key] = value;
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;