@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.
Files changed (104) hide show
  1. package/CHANGELOG.md +182 -141
  2. package/cjs/index.cjs +640 -139
  3. package/cjs/index.cjs.map +1 -1
  4. package/esm/contracts/index.d.mts +3 -3
  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/contracts/team/team-config.type.d.mts +9 -6
  22. package/esm/contracts/team/team-config.type.d.mts.map +1 -1
  23. package/esm/contracts/tool.contract.d.mts +35 -3
  24. package/esm/contracts/tool.contract.d.mts.map +1 -1
  25. package/esm/index.d.mts +8 -7
  26. package/esm/index.mjs +3 -2
  27. package/esm/memory/episodic-memory.mjs +14 -6
  28. package/esm/memory/episodic-memory.mjs.map +1 -1
  29. package/esm/memory/index.d.mts +1 -1
  30. package/esm/memory/memory.d.mts +13 -1
  31. package/esm/memory/memory.d.mts.map +1 -1
  32. package/esm/memory/memory.mjs +41 -7
  33. package/esm/memory/memory.mjs.map +1 -1
  34. package/esm/memory/procedural-memory.mjs +20 -7
  35. package/esm/memory/procedural-memory.mjs.map +1 -1
  36. package/esm/memory/semantic-memory.mjs +27 -10
  37. package/esm/memory/semantic-memory.mjs.map +1 -1
  38. package/esm/memory/working-memory.mjs +70 -13
  39. package/esm/memory/working-memory.mjs.map +1 -1
  40. package/esm/middleware/builtins/semantic-cache.d.mts +46 -1
  41. package/esm/middleware/builtins/semantic-cache.d.mts.map +1 -1
  42. package/esm/middleware/builtins/semantic-cache.mjs +60 -15
  43. package/esm/middleware/builtins/semantic-cache.mjs.map +1 -1
  44. package/esm/middleware/index.d.mts +1 -1
  45. package/esm/mock/index.d.mts +1 -1
  46. package/esm/mock/mock-config.type.d.mts +33 -4
  47. package/esm/mock/mock-config.type.d.mts.map +1 -1
  48. package/esm/mock/mock-model.d.mts +2 -1
  49. package/esm/mock/mock-model.d.mts.map +1 -1
  50. package/esm/mock/mock-model.mjs +5 -4
  51. package/esm/mock/mock-model.mjs.map +1 -1
  52. package/esm/mock/mock-sdk.d.mts +11 -3
  53. package/esm/mock/mock-sdk.d.mts.map +1 -1
  54. package/esm/mock/mock-sdk.mjs.map +1 -1
  55. package/esm/orchestrator/as-tool.d.mts +35 -9
  56. package/esm/orchestrator/as-tool.d.mts.map +1 -1
  57. package/esm/orchestrator/as-tool.mjs +67 -19
  58. package/esm/orchestrator/as-tool.mjs.map +1 -1
  59. package/esm/orchestrator/execution.d.mts.map +1 -1
  60. package/esm/orchestrator/execution.mjs +2 -2
  61. package/esm/orchestrator/execution.mjs.map +1 -1
  62. package/esm/orchestrator/index.d.mts +1 -1
  63. package/esm/orchestrator/index.mjs +1 -1
  64. package/esm/orchestrator/memory.d.mts +41 -5
  65. package/esm/orchestrator/memory.d.mts.map +1 -1
  66. package/esm/orchestrator/memory.mjs +53 -5
  67. package/esm/orchestrator/memory.mjs.map +1 -1
  68. package/esm/planner/plan-schema.d.mts +15 -4
  69. package/esm/planner/plan-schema.d.mts.map +1 -1
  70. package/esm/planner/plan-schema.mjs +27 -16
  71. package/esm/planner/plan-schema.mjs.map +1 -1
  72. package/esm/security/index.mjs +1 -0
  73. package/esm/security/outbound-policy.d.mts +9 -0
  74. package/esm/security/outbound-policy.d.mts.map +1 -1
  75. package/esm/security/outbound-policy.mjs +79 -5
  76. package/esm/security/outbound-policy.mjs.map +1 -1
  77. package/esm/security/outbound-policy.type.d.mts +8 -0
  78. package/esm/security/outbound-policy.type.d.mts.map +1 -1
  79. package/esm/security/safe-merge.d.mts +52 -0
  80. package/esm/security/safe-merge.d.mts.map +1 -0
  81. package/esm/security/safe-merge.mjs +68 -0
  82. package/esm/security/safe-merge.mjs.map +1 -0
  83. package/esm/supervisor/decide.mjs +52 -5
  84. package/esm/supervisor/decide.mjs.map +1 -1
  85. package/esm/supervisor/execution.d.mts +22 -12
  86. package/esm/supervisor/execution.d.mts.map +1 -1
  87. package/esm/supervisor/execution.mjs +47 -24
  88. package/esm/supervisor/execution.mjs.map +1 -1
  89. package/esm/supervisor/supervisor.mjs +4 -0
  90. package/esm/supervisor/supervisor.mjs.map +1 -1
  91. package/llms-full.txt +185 -14
  92. package/llms.txt +5 -4
  93. package/package.json +24 -4
  94. package/skills/README.md +6 -2
  95. package/skills/attach-ai-middleware/SKILL.md +17 -1
  96. package/skills/generate-images/SKILL.md +11 -4
  97. package/skills/rag-loaders-and-stores/SKILL.md +3 -0
  98. package/skills/run-ai-agent/SKILL.md +3 -0
  99. package/skills/run-orchestrator/SKILL.md +6 -1
  100. package/skills/run-planner/SKILL.md +7 -3
  101. package/skills/run-supervisor/SKILL.md +11 -1
  102. package/skills/secure-outbound-requests/SKILL.md +85 -0
  103. package/skills/use-ai-memory/SKILL.md +36 -3
  104. 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
@@ -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: content split word-by-word as `delta`
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 words = mock.content.split(" ");
11863
- for (const word of words) yield {
12111
+ const chunks = mock.deltas ?? mock.content.split(" ").map((word) => word + " ");
12112
+ for (const chunk of chunks) yield {
11864
12113
  type: "delta",
11865
- content: word + " "
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 `signal` / `context`
12328
- * / events do NOT auto-forward. Anything the wrapped orchestrator needs
12329
- * 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.
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 parent supplies `sessionId` (and optionally
12337
- * `history`) inside the validated payload; the orchestrator
12338
- * participates in that session. The remaining payload fields are
12339
- * forwarded as `execute(input)`. A missing/blank `sessionId` throws
12340
- * {@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.
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
- * 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
+ * });
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 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.
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 payload = typeof input === "object" && input !== null ? input : {};
12388
- 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");
12389
- 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");
12390
12687
  return {
12391
- sessionId,
12392
- history: Array.isArray(history) ? history : [],
12393
- executeInput: coerceInput$1(rest)
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
- 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)));
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 (!("signature" in executable) && typeof executable.execute === "function" && !this.isSupervisor(executable) && typeof input !== "string") return safeStringify(input);
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
- 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
+ });
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) for (const [key, value] of Object.entries(interpretation.sliceToMerge)) this.state[key] = value;
15432
+ if (interpretation.sliceToMerge) this.mergeIntoState(interpretation.sliceToMerge, "classifier.refine");
14957
15433
  }
14958
- for (const [key, value] of Object.entries(final)) this.state[key] = value;
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[key] = value;
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 in merged)) delete this.state[key];
15283
- for (const [key, value] of Object.entries(merged)) this.state[key] = value;
15284
- } 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");
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
- * Build the Standard Schema the planning agent emits an ordered
17258
- * `{ steps: [...], summary? }` plan whose every step references one of
17259
- * `capabilityNames` via the `capability` field.
17260
- *
17261
- * Mirrors the router's hand-built schema approach
17262
- * (`supervisor/router-factory.ts`): the JSON Schema extension carries
17263
- * the capability names as an `enum` so capable providers enforce the
17264
- * choice natively, while `validate()` still accepts the shape softly so
17265
- * providers without native structured output can pass a parsed object
17266
- * through. Validation is intentionally lenient on `capability` — an
17267
- * unknown name is surfaced later by the planner as a typed
17268
- * `PlannerPlanInvalidError`, with the full forensic context, rather
17269
- * than as an opaque schema issue here.
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
- * `maxSteps`, when provided, is emitted as the `steps` array's
17272
- * `maxItems` so capable providers refuse to over-produce up front
17273
- * (the planner still truncates the tail to `skipped` defensively).
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;