@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
@@ -1 +1 @@
1
- {"version":3,"file":"working-memory.mjs","names":[],"sources":["../../../../../../../ai/src/memory/working-memory.ts"],"sourcesContent":["import type {\n MemoryItem,\n RecalledMemory,\n} from \"../contracts/memory/memory-item.type\";\nimport { deriveMemoryId } from \"./derive-id\";\n\n/**\n * In-run working memory — the volatile scratch tier (memory core M1).\n *\n * Owns: an insertion-ordered buffer of remembered items keyed by id,\n * with overwrite-in-place on a repeated id. Does NOT own: durability,\n * cross-process sharing, embeddings, or similarity — working memory is\n * a plain in-process buffer the orchestrator threads across the turns of\n * a single run.\n *\n * Recall here is not semantic: with no vector index, \"relevant\" reduces\n * to \"recent.\" `recall()` returns the most-recently-remembered items\n * first, each scored on a `[0, 1]` recency proxy so a caller can merge\n * working hits with semantic hits and sort on one `score` field.\n *\n * Internal to the `memory()` factory — never exported on the package\n * surface.\n */\nexport class WorkingMemory {\n /**\n * Idtext/metadata. A `Map` preserves insertion order, so iteration\n * yields oldest-first; recall reverses it for most-recent-first.\n */\n private readonly entries = new Map<\n string,\n { text: string; metadata?: Record<string, unknown> }\n >();\n\n /**\n * Append an item to the buffer (or overwrite the entry sharing its\n * id). Re-inserting an existing id keeps its original position; delete\n * + set would move it to the end and lie about recency, so the value\n * is updated in place.\n */\n public remember(item: MemoryItem): void {\n const id = item.id ?? deriveMemoryId(item.text);\n\n this.entries.set(id, { text: item.text, metadata: item.metadata });\n }\n\n /**\n * Return up to `k` most-recently-remembered items, newest first. The\n * `score` is a linear recency proxy: the newest item scores `1`, the\n * oldest of the returned slice trends toward `0`. Working memory\n * ignores any similarity threshold — it has no vector to compare.\n */\n public recall(k: number): RecalledMemory[] {\n const ordered = [...this.entries.entries()].reverse();\n const slice = ordered.slice(0, Math.max(0, k));\n\n return slice.map(([id, entry], index) => ({\n id,\n text: entry.text,\n tier: \"working\" as const,\n score: slice.length <= 1 ? 1 : 1 - index / slice.length,\n metadata: entry.metadata,\n }));\n }\n\n /** Drop every working-tier entry. */\n public clear(): void {\n this.entries.clear();\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAuBA,IAAa,gBAAb,MAA2B;;iCAKE,IAAI,IAG7B;;;;;;;;CAQF,AAAO,SAAS,MAAwB;EACtC,MAAM,KAAK,KAAK,MAAM,eAAe,KAAK,IAAI;EAE9C,KAAK,QAAQ,IAAI,IAAI;GAAE,MAAM,KAAK;GAAM,UAAU,KAAK;EAAS,CAAC;CACnE;;;;;;;CAQA,AAAO,OAAO,GAA6B;EAEzC,MAAM,QADU,CAAC,GAAG,KAAK,QAAQ,QAAQ,CAAC,CAAC,CAAC,QACxB,CAAC,CAAC,MAAM,GAAG,KAAK,IAAI,GAAG,CAAC,CAAC;EAE7C,OAAO,MAAM,KAAK,CAAC,IAAI,QAAQ,WAAW;GACxC;GACA,MAAM,MAAM;GACZ,MAAM;GACN,OAAO,MAAM,UAAU,IAAI,IAAI,IAAI,QAAQ,MAAM;GACjD,UAAU,MAAM;EAClB,EAAE;CACJ;;CAGA,AAAO,QAAc;EACnB,KAAK,QAAQ,MAAM;CACrB;AACF"}
1
+ {"version":3,"file":"working-memory.mjs","names":[],"sources":["../../../../../../../ai/src/memory/working-memory.ts"],"sourcesContent":["import type {\n MemoryItem,\n RecalledMemory,\n} from \"../contracts/memory/memory-item.type\";\nimport { deriveMemoryId } from \"./derive-id\";\n\n/**\n * In-run working memory — the volatile scratch tier (memory core M1).\n *\n * Owns: an insertion-ordered buffer of remembered items keyed by id,\n * with overwrite-in-place on a repeated id. Does NOT own: durability,\n * cross-process sharing, embeddings, or similarity — working memory is\n * a plain in-process buffer the orchestrator threads across the turns of\n * a single run.\n *\n * Recall here is not semantic: with no vector index, \"relevant\" reduces\n * to \"recent.\" `recall()` returns the most-recently-remembered items\n * first, each scored on a `[0, 1]` recency proxy so a caller can merge\n * working hits with semantic hits and sort on one `score` field.\n *\n * **Bounded (4.15.0).** The buffer holds at most `maxItems` entries\n * across every scope; the oldest-written entry is evicted on overflow\n * (FIFO). The tier lives in process memory for the lifetime of the\n * `memory()` instance — which the orchestrator resolves once and reuses\n * for every session — so an unbounded buffer was a memory-exhaustion\n * vector for any long-lived, internet-reachable deployment.\n *\n * Internal to the `memory()` factory — never exported on the package\n * surface.\n */\nexport class WorkingMemory {\n /**\n * Hard ceiling on buffered entries, across all scopes. Enforced on\n * every `remember()`; see {@link evictOverflow} for the policy.\n */\n private readonly maxItems: number;\n\n public constructor(maxItems: number) {\n this.maxItems = maxItems;\n }\n\n /**\n * Scoped key entry. A `Map` preserves insertion order, so iteration\n * yields oldest-first; recall reverses it for most-recent-first.\n *\n * The map key folds in the item's `scope` so two scopes remembering\n * identical text (same derived id) stay two independent entries\n * instead of clobbering one another; the entry keeps its logical `id`\n * and its `scope` so recall can filter and still report the id the\n * caller knows.\n */\n private readonly entries = new Map<\n string,\n {\n id: string;\n text: string;\n scope?: string;\n metadata?: Record<string, unknown>;\n }\n >();\n\n /**\n * Append an item to the buffer (or overwrite the entry sharing its\n * id *within the same scope*). Re-inserting an existing key keeps its\n * original position; delete + set would move it to the end and lie\n * about recency, so the value is updated in place.\n *\n * Overflowing `maxItems` evicts from the front — see\n * {@link evictOverflow}.\n */\n public remember(item: MemoryItem): void {\n const id = item.id ?? deriveMemoryId(item.text);\n\n this.entries.set(scopedKey(item.scope, id), {\n id,\n text: item.text,\n scope: item.scope,\n metadata: item.metadata,\n });\n\n this.evictOverflow();\n }\n\n /**\n * Enforce the size bound by dropping oldest-written entries first\n * (FIFO over the `Map`'s insertion order).\n *\n * **Why FIFO, not LRU.** Recall here is a pure recency proxy — it\n * reverses insertion order and slices the newest `k` — and never\n * reorders anything, so the front of the buffer is by construction the\n * region recall reaches last. FIFO therefore evicts exactly the\n * entries a bounded recall would never have returned. True LRU would\n * need read-time reordering, which would also rewrite the `score`\n * every recall reports (a re-read entry would masquerade as freshly\n * remembered), trading a real correctness property for no gain.\n *\n * **Known limitation (documented, not a regression).** The bound is\n * global, not per-scope: a session writing heavily can push another\n * session's older entries out of the buffer. That is a recall-quality\n * degradation on a volatile scratch tier, never a disclosure — the\n * scope filter in {@link recall} still applies — and a per-scope quota\n * would not help anyway, since an attacker holding many sessions\n * evicts through the global bound regardless. Durable recall belongs\n * in the semantic / episodic tiers.\n */\n private evictOverflow(): void {\n while (this.entries.size > this.maxItems) {\n const oldest = this.entries.keys().next();\n\n if (oldest.done) {\n return;\n }\n\n this.entries.delete(oldest.value);\n }\n }\n\n /**\n * Return up to `k` most-recently-remembered items *within `scope`*,\n * newest first. The scope match is exact equality (an unscoped recall\n * sees only unscoped entries) and is applied BEFORE the slice, so a\n * foreign scope's entries can never consume a slot or leak out.\n *\n * The `score` is a linear recency proxy: the newest item scores `1`,\n * the oldest of the returned slice trends toward `0`. Working memory\n * ignores any similarity threshold — it has no vector to compare.\n */\n public recall(k: number, scope?: string): RecalledMemory[] {\n const ordered = [...this.entries.values()]\n .reverse()\n .filter((entry) => entry.scope === scope);\n\n const slice = ordered.slice(0, Math.max(0, k));\n\n return slice.map((entry, index) => ({\n id: entry.id,\n text: entry.text,\n tier: \"working\" as const,\n score: slice.length <= 1 ? 1 : 1 - index / slice.length,\n metadata: entry.metadata,\n }));\n }\n\n /** Drop every working-tier entry, across every scope. */\n public clear(): void {\n this.entries.clear();\n }\n}\n\n/**\n * Map key for a buffer entry: the isolation `scope` (empty for the\n * unscoped pool) length-prefixed and joined to the logical id. The\n * length prefix makes the encoding injective — no crafted scope/id pair\n * can collide with a different scope's entry the way a plain `:` join\n * would allow.\n */\nfunction scopedKey(scope: string | undefined, id: string): string {\n return `${scope?.length ?? 0}:${scope ?? \"\"}:${id}`;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,IAAa,gBAAb,MAA2B;CAOzB,AAAO,YAAY,UAAkB;iCAcV,IAAI,IAQ7B;EArBA,KAAK,WAAW;CAClB;;;;;;;;;;CA+BA,AAAO,SAAS,MAAwB;EACtC,MAAM,KAAK,KAAK,MAAM,eAAe,KAAK,IAAI;EAE9C,KAAK,QAAQ,IAAI,UAAU,KAAK,OAAO,EAAE,GAAG;GAC1C;GACA,MAAM,KAAK;GACX,OAAO,KAAK;GACZ,UAAU,KAAK;EACjB,CAAC;EAED,KAAK,cAAc;CACrB;;;;;;;;;;;;;;;;;;;;;;;CAwBA,AAAQ,gBAAsB;EAC5B,OAAO,KAAK,QAAQ,OAAO,KAAK,UAAU;GACxC,MAAM,SAAS,KAAK,QAAQ,KAAK,CAAC,CAAC,KAAK;GAExC,IAAI,OAAO,MACT;GAGF,KAAK,QAAQ,OAAO,OAAO,KAAK;EAClC;CACF;;;;;;;;;;;CAYA,AAAO,OAAO,GAAW,OAAkC;EAKzD,MAAM,QAJU,CAAC,GAAG,KAAK,QAAQ,OAAO,CAAC,CAAC,CACvC,QAAQ,CAAC,CACT,QAAQ,UAAU,MAAM,UAAU,KAEjB,CAAC,CAAC,MAAM,GAAG,KAAK,IAAI,GAAG,CAAC,CAAC;EAE7C,OAAO,MAAM,KAAK,OAAO,WAAW;GAClC,IAAI,MAAM;GACV,MAAM,MAAM;GACZ,MAAM;GACN,OAAO,MAAM,UAAU,IAAI,IAAI,IAAI,QAAQ,MAAM;GACjD,UAAU,MAAM;EAClB,EAAE;CACJ;;CAGA,AAAO,QAAc;EACnB,KAAK,QAAQ,MAAM;CACrB;AACF;;;;;;;;AASA,SAAS,UAAU,OAA2B,IAAoB;CAChE,OAAO,GAAG,OAAO,UAAU,EAAE,GAAG,SAAS,GAAG,GAAG;AACjD"}
@@ -1,8 +1,27 @@
1
+ import { MiddlewareTripContext } from "../../contracts/middleware/middleware-context.type.mjs";
1
2
  import { AgentMiddleware } from "../../contracts/middleware/middleware.contract.mjs";
2
3
  import { EmbedderContract } from "../../contracts/embedder.contract.mjs";
3
4
  import { CacheDriver } from "@warlock.js/cache";
4
5
 
5
6
  //#region ../ai/src/middleware/builtins/semantic-cache.d.ts
7
+ /**
8
+ * Isolation boundary for cache reads and writes.
9
+ *
10
+ * - `"session"` (default) — key every entry off the run's
11
+ * `AgentExecuteOptions.sessionId`, so one session never receives a
12
+ * response cached for another. Calls made WITHOUT a `sessionId` share
13
+ * one unscoped pool (the pre-4.15.0 behavior); an unscoped read never
14
+ * sees a session-scoped entry and vice versa.
15
+ * - `"shared"` — one pool for every caller, regardless of session. The
16
+ * explicit opt-in for genuinely public Q&A (docs bots, FAQ) where the
17
+ * cross-user hit rate is the point and no response can carry one
18
+ * caller's private context.
19
+ * - a resolver — derive the key yourself, e.g. per tenant
20
+ * (`ctx => ctx.options?.toolCtx?.tenantId`). Returning `undefined`
21
+ * falls back to the unscoped pool, so return a constant sentinel (or
22
+ * throw) if you need the call to fail closed instead.
23
+ */
24
+ type SemanticCacheScope = "session" | "shared" | ((context: MiddlewareTripContext) => string | undefined);
6
25
  /**
7
26
  * Configuration for `semanticCache()`.
8
27
  */
@@ -39,6 +58,20 @@ type SemanticCacheOptions = {
39
58
  * `"ai.cache"`.
40
59
  */
41
60
  namespace?: string;
61
+ /**
62
+ * Per-caller isolation boundary. Default `"session"` — a cached
63
+ * response is served back only to the session that produced it.
64
+ *
65
+ * A `semanticCache` is normally built once at app boot and shared by
66
+ * every end user, and a hit is returned as the model's answer with no
67
+ * LLM call in between; without a scope that pools every caller's Q&A
68
+ * pairs into one namespace, which is both a disclosure path (user B's
69
+ * near-enough prompt gets served user A's answer, personal context
70
+ * included) and a poisoning path (an attacker seeds an entry near a
71
+ * predictable future query). Set `"shared"` to opt back into pooling
72
+ * where that is actually desirable. See {@link SemanticCacheScope}.
73
+ */
74
+ scope?: SemanticCacheScope;
42
75
  /**
43
76
  * Middleware name — also the state-bag key prefix inside a single
44
77
  * execution. Default `"semantic-cache"`.
@@ -84,6 +117,18 @@ type SemanticCacheOptions = {
84
117
  * `usage: { input: 0, output: 0, total: 0 }` so budget /
85
118
  * observability correctly exclude the saved trip.
86
119
  *
120
+ * **Per-session scoping (4.15.0).** One `semanticCache` instance
121
+ * normally serves every end user, and a hit is returned as the answer
122
+ * with no model call in between — so entries are keyed by the run's
123
+ * `sessionId` (`scope`, default `"session"`) and a lookup only ever
124
+ * sees entries written under the same key. Runs made without a
125
+ * `sessionId` share one unscoped pool; pass `sessionId` on
126
+ * `agent.execute()` (composites thread their own through automatically)
127
+ * to get the isolation, or set `scope: "shared"` to pool deliberately.
128
+ * Note the cost/benefit shift: scoping trades cross-user hit rate for
129
+ * isolation, so public-FAQ deployments where no response can carry a
130
+ * caller's private context should opt into `"shared"` explicitly.
131
+ *
87
132
  * @example
88
133
  * import { semanticCache } from "@warlock.js/ai";
89
134
  * import { MemoryCacheDriver } from "@warlock.js/cache";
@@ -102,5 +147,5 @@ type SemanticCacheOptions = {
102
147
  */
103
148
  declare function semanticCache(options: SemanticCacheOptions): AgentMiddleware;
104
149
  //#endregion
105
- export { SemanticCacheOptions, semanticCache };
150
+ export { SemanticCacheOptions, SemanticCacheScope, semanticCache };
106
151
  //# sourceMappingURL=semantic-cache.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"semantic-cache.d.mts","names":[],"sources":["../../../../../../../../ai/src/middleware/builtins/semantic-cache.ts"],"mappings":";;;;;;;;KAWY,oBAAA;EAAoB,sEAE9B,QAAA,EAAU,gBAAA;EAaS;;;;;;;;;;AAuBf;AAkHN;EAzIE,KAAA,GAAQ,WAAW;;;;;EAKnB,SAAA;EAoI2E;AAAA;;;;;EA7H3E,KAAA;;;;;;EAMA,SAAA;;;;;EAKA,IAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAkHc,aAAA,CAAc,OAAA,EAAS,oBAAA,GAAuB,eAAe"}
1
+ {"version":3,"file":"semantic-cache.d.mts","names":[],"sources":["../../../../../../../../ai/src/middleware/builtins/semantic-cache.ts"],"mappings":";;;;;;;;;AA0BA;;;;AAGoC;AAKpC;;;;;;;;;KARY,kBAAA,4BAGN,OAA8B,EAArB,qBAAqB;;;;KAKxB,oBAAA;EAiCV,sEA/BA,QAAA,EAAU,gBAAA;EA6CF;;;AAKJ;AAmLN;;;;;;;;EAxNE,KAAA,GAAQ,WAAA;;;;;EAKR,SAAA;;;;;;;EAOA,KAAA;;;;;;EAMA,SAAA;;;;;;;;;;;;;;EAcA,KAAA,GAAQ,kBAAA;;;;;EAKR,IAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAmLc,aAAA,CAAc,OAAA,EAAS,oBAAA,GAAuB,eAAe"}
@@ -4,6 +4,13 @@ import { resolveDefaultStore } from "../../config.mjs";
4
4
  //#region ../ai/src/middleware/builtins/semantic-cache.ts
5
5
  const DEFAULT_NAMESPACE = "ai.cache";
6
6
  /**
7
+ * Extra candidates pulled from `similar()` on a SCOPED lookup before the
8
+ * scope filter runs. The driver ranks across every scope in the index,
9
+ * so a bare `topK: 1` can come back as a foreign entry and mask this
10
+ * scope's own legitimate hit. Mirrors the memory tiers' overscan.
11
+ */
12
+ const SIMILAR_OVERSCAN = 5;
13
+ /**
7
14
  * Build a stable fingerprint for a prompt covering the full message
8
15
  * list (system + history + user turn). Ensures two prompts sharing
9
16
  * the user text but differing in prior context do not collide on
@@ -15,9 +22,12 @@ const DEFAULT_NAMESPACE = "ai.cache";
15
22
  * trust model.
16
23
  */
17
24
  function hashPrompt(messages) {
18
- const serialized = messages.map((message) => {
25
+ return fnv1a(messages.map((message) => {
19
26
  return `${message.role}:${Array.isArray(message.content) ? message.content.filter((part) => part.type === "text").map((part) => part.text).join("|") : message.content}`;
20
- }).join("||");
27
+ }).join("||"));
28
+ }
29
+ /** FNV-1a over a string — see {@link hashPrompt} for the caveats. */
30
+ function fnv1a(serialized) {
21
31
  let hash = 2166136261;
22
32
  for (let index = 0; index < serialized.length; index++) {
23
33
  hash ^= serialized.charCodeAt(index);
@@ -25,6 +35,28 @@ function hashPrompt(messages) {
25
35
  }
26
36
  return (hash >>> 0).toString(16);
27
37
  }
38
+ /**
39
+ * Resolve the isolation key this trip reads and writes under.
40
+ *
41
+ * Derived from the run's own `sessionId` (or the developer's resolver) —
42
+ * never from the prompt, the model's output, or anything the LLM can
43
+ * write to. `"shared"` and an unidentified run both resolve to
44
+ * `undefined`, i.e. the unscoped pool, which a scoped lookup can never
45
+ * read.
46
+ */
47
+ function resolveScope(scope, context) {
48
+ if (scope === "shared") return;
49
+ const key = typeof scope === "function" ? scope(context) : sessionScope(context.options?.sessionId);
50
+ return key ? key : void 0;
51
+ }
52
+ /**
53
+ * The default `"session"` key: the session id under a reserved prefix so
54
+ * a custom resolver returning a bare tenant id can't collide with a
55
+ * session pool. Mirrors the orchestrator's `sessionMemoryScope`.
56
+ */
57
+ function sessionScope(sessionId) {
58
+ return sessionId ? `session:${sessionId}` : void 0;
59
+ }
28
60
  function isFresh(entry, ttlMs) {
29
61
  if (ttlMs === void 0) return true;
30
62
  return Date.now() - entry.storedAt <= ttlMs;
@@ -68,6 +100,18 @@ function isFresh(entry, ttlMs) {
68
100
  * `usage: { input: 0, output: 0, total: 0 }` so budget /
69
101
  * observability correctly exclude the saved trip.
70
102
  *
103
+ * **Per-session scoping (4.15.0).** One `semanticCache` instance
104
+ * normally serves every end user, and a hit is returned as the answer
105
+ * with no model call in between — so entries are keyed by the run's
106
+ * `sessionId` (`scope`, default `"session"`) and a lookup only ever
107
+ * sees entries written under the same key. Runs made without a
108
+ * `sessionId` share one unscoped pool; pass `sessionId` on
109
+ * `agent.execute()` (composites thread their own through automatically)
110
+ * to get the isolation, or set `scope: "shared"` to pool deliberately.
111
+ * Note the cost/benefit shift: scoping trades cross-user hit rate for
112
+ * isolation, so public-FAQ deployments where no response can carry a
113
+ * caller's private context should opt into `"shared"` explicitly.
114
+ *
71
115
  * @example
72
116
  * import { semanticCache } from "@warlock.js/ai";
73
117
  * import { MemoryCacheDriver } from "@warlock.js/cache";
@@ -87,10 +131,11 @@ function isFresh(entry, ttlMs) {
87
131
  function semanticCache(options) {
88
132
  const name = options.name ?? "semantic-cache";
89
133
  const namespace = options.namespace ?? DEFAULT_NAMESPACE;
134
+ const scopeMode = options.scope ?? "session";
90
135
  const pendingKey = `${name}.pending`;
91
136
  const store = options.store ?? resolveDefaultStore();
92
137
  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`);
93
- const keyFor = (hash) => `${namespace}.${hash}`;
138
+ const keyFor = (hash, scope) => scope === void 0 ? `${namespace}.${hash}` : `${namespace}.${fnv1a(scope)}.${hash}`;
94
139
  return {
95
140
  name,
96
141
  log: true,
@@ -99,21 +144,20 @@ function semanticCache(options) {
99
144
  if (context.tripIndex !== 0) return;
100
145
  const promptText = extractUserText(context.messages);
101
146
  if (!promptText) return;
147
+ const scope = resolveScope(scopeMode, context);
102
148
  const promptKey = hashPrompt(context.messages);
103
- const scopedKey = keyFor(promptKey);
104
- const exact = await store.get(scopedKey);
105
- if (exact && isFresh(exact, options.ttlMs)) return toSyntheticResponse(exact.response);
149
+ const exact = await store.get(keyFor(promptKey, scope));
150
+ if (exact && exact.scope === scope && isFresh(exact, options.ttlMs)) return toSyntheticResponse(exact.response);
106
151
  const query = await options.embedder.embed(promptText);
107
- const [hit] = await store.similar(query.vector, {
108
- topK: 1,
152
+ const hit = (await store.similar(query.vector, {
153
+ topK: scope === void 0 ? 1 : SIMILAR_OVERSCAN,
109
154
  threshold: options.threshold
110
- });
111
- if (hit && isFresh(hit.value, options.ttlMs)) {
112
- if (hit.key.startsWith(`${namespace}.`)) return toSyntheticResponse(hit.value.response);
113
- }
155
+ })).find((candidate) => candidate.key.startsWith(`${namespace}.`) && candidate.value?.scope === scope && isFresh(candidate.value, options.ttlMs));
156
+ if (hit) return toSyntheticResponse(hit.value.response);
114
157
  const pending = {
115
158
  promptKey,
116
- vector: query.vector
159
+ vector: query.vector,
160
+ scope
117
161
  };
118
162
  context.state.set(pendingKey, pending);
119
163
  },
@@ -124,9 +168,10 @@ function semanticCache(options) {
124
168
  context.state.delete(pendingKey);
125
169
  const entry = {
126
170
  response,
127
- storedAt: Date.now()
171
+ storedAt: Date.now(),
172
+ scope: pending.scope
128
173
  };
129
- await store.set(keyFor(pending.promptKey), entry, { vector: pending.vector });
174
+ await store.set(keyFor(pending.promptKey, pending.scope), entry, { vector: pending.vector });
130
175
  }
131
176
  }
132
177
  };
@@ -1 +1 @@
1
- {"version":3,"file":"semantic-cache.mjs","names":[],"sources":["../../../../../../../../ai/src/middleware/builtins/semantic-cache.ts"],"sourcesContent":["import type { CacheDriver } from \"@warlock.js/cache\";\nimport { resolveDefaultStore } from \"../../config\";\nimport type { Message } from \"../../contracts/conversation-message.type\";\nimport type { EmbedderContract } from \"../../contracts/embedder.contract\";\nimport type { AgentMiddleware } from \"../../contracts/middleware\";\nimport type { ModelResponse } from \"../../contracts/model.contract\";\nimport { extractUserText } from \"../utils\";\n\n/**\n * Configuration for `semanticCache()`.\n */\nexport type SemanticCacheOptions = {\n /** Embedder used to produce the query vector from the prompt text. */\n embedder: EmbedderContract;\n /**\n * Vector-capable cache driver from `@warlock.js/cache`. Production\n * deployments pick a driver with a real ANN index (`pg` with\n * pgvector, `redis` with RediSearch). Dev / test environments use\n * `new MemoryCacheDriver()` — zero config, correct, but O(N) per\n * query. Drivers without similarity support throw\n * `CacheUnsupportedError` from `set({ vector })` / `similar()`.\n *\n * Falls back to `ai.config({ defaultStore })` when omitted. When\n * neither is set, the factory throws at construction time —\n * semantic cache cannot operate without a store.\n */\n store?: CacheDriver<any, any>;\n /**\n * Minimum cosine similarity for a vector hit. Between 0 and 1 —\n * 0.95 is a solid default for question-answering caches.\n */\n threshold: number;\n /**\n * Optional TTL in milliseconds. Entries whose `storedAt` is older\n * than this are treated as misses on read and overwritten on the\n * next write. Default: no expiry — entries live until the store\n * evicts them (per its own TTL/eviction policy).\n */\n ttlMs?: number;\n /**\n * Namespace prefix applied to every key the cache writes. Lets\n * multiple agents share one driver without collision. Default\n * `\"ai.cache\"`.\n */\n namespace?: string;\n /**\n * Middleware name — also the state-bag key prefix inside a single\n * execution. Default `\"semantic-cache\"`.\n */\n name?: string;\n};\n\ntype CachedEntry = {\n response: ModelResponse;\n storedAt: number;\n};\n\ntype PendingWrite = {\n promptKey: string;\n vector: number[];\n};\n\nconst DEFAULT_NAMESPACE = \"ai.cache\";\n\n/**\n * Build a stable fingerprint for a prompt covering the full message\n * list (system + history + user turn). Ensures two prompts sharing\n * the user text but differing in prior context do not collide on\n * the exact-match fast path.\n *\n * FNV-1a variant — cheap, collision-resistant enough for a cache,\n * dependency-free. NOT a cryptographic hash: collisions would\n * surface as wrong cache hits, not a security issue in the current\n * trust model.\n */\nfunction hashPrompt(messages: ReadonlyArray<Message>): string {\n const serialized = messages\n .map((message) => {\n const role = message.role;\n const content = Array.isArray(message.content)\n ? message.content\n .filter((part) => part.type === \"text\")\n .map((part) => (part as { text: string }).text)\n .join(\"|\")\n : message.content;\n\n return `${role}:${content}`;\n })\n .join(\"||\");\n\n let hash = 0x811c9dc5;\n\n for (let index = 0; index < serialized.length; index++) {\n hash ^= serialized.charCodeAt(index);\n hash = Math.imul(hash, 0x01000193);\n }\n\n return (hash >>> 0).toString(16);\n}\n\nfunction isFresh(entry: CachedEntry, ttlMs: number | undefined): boolean {\n if (ttlMs === undefined) {\n return true;\n }\n\n return Date.now() - entry.storedAt <= ttlMs;\n}\n\n/**\n * Semantic-similarity response cache for an agent run.\n *\n * **Role.** Skips LLM round-trips when the current prompt is\n * semantically close to one the agent has already answered. For\n * FAQ / support-style traffic this often eliminates 60–80% of\n * model calls — the production win is massive for cost and\n * latency.\n *\n * **Delegation to `@warlock.js/cache`.** This middleware does NOT\n * implement similarity search itself. It delegates to the supplied\n * `CacheDriver`. Production deployments pick a driver with an ANN\n * index (`pg` + pgvector, `redis` + RediSearch). Dev / test\n * environments pass `new MemoryCacheDriver()` — zero config, correct,\n * but O(N) per query. Drivers without similarity support throw\n * `CacheUnsupportedError` from `set({ vector })` / `similar()`.\n *\n * **Two-tier lookup.**\n * 1. *Exact-match key* — a cheap FNV hash over the entire message\n * list. `store.get(hash)` returns the entry without an embedding\n * round trip when the prompt hasn't changed at all.\n * 2. *Vector-match* — on exact-match miss, embed the prompt and\n * call `store.similar(vector, { topK: 1, threshold })`. The\n * driver uses its native similarity index; anything clearing\n * `threshold` is returned as a hit.\n *\n * **Write-on-miss.** When both tiers miss, `trip.before` stashes\n * the prompt hash + vector in `ctx.state`; `trip.after` reads back\n * the pending entry and calls\n * `store.set(hash, entry, { vector })`. If an outer middleware\n * (guardrail) throws in `trip.after` before the cache's `trip.after`\n * runs, the pending entry is never written — bad responses stay out\n * of the cache **as long as the canonical install order is followed**\n * (cache outermost).\n *\n * **Synthetic-response on hit.** Returns a `ModelResponse` with\n * `usage: { input: 0, output: 0, total: 0 }` so budget /\n * observability correctly exclude the saved trip.\n *\n * @example\n * import { semanticCache } from \"@warlock.js/ai\";\n * import { MemoryCacheDriver } from \"@warlock.js/cache\";\n *\n * const store = new MemoryCacheDriver();\n * store.setOptions({});\n *\n * const cache = semanticCache({\n * embedder: openai.embedder({ name: \"text-embedding-3-small\" }),\n * store,\n * threshold: 0.95,\n * ttlMs: 60 * 60 * 1000,\n * });\n *\n * const myAgent = agent({ model, middleware: [cache] });\n */\nexport function semanticCache(options: SemanticCacheOptions): AgentMiddleware {\n const name = options.name ?? \"semantic-cache\";\n const namespace = options.namespace ?? DEFAULT_NAMESPACE;\n const pendingKey = `${name}.pending`;\n\n // Resolve the effective store at factory time, not per-call. Every\n // subsequent hook closes over `store` so the resolution happens once.\n // Throws now (loud, at construction) instead of later during the\n // first trip (silent until the agent actually runs).\n const store = options.store ?? resolveDefaultStore();\n\n if (!store) {\n throw new Error(\n `semanticCache: no store supplied — pass \\`store\\` in options or call \\`ai.config({ defaultStore })\\` at app boot before constructing the middleware`,\n );\n }\n\n // Cache's parseKey replaces \":\" with \".\" so the namespace boundary\n // matches what `similar()` actually returns in `hit.key`. Using a\n // dot here keeps prefix checks aligned with stored keys.\n const keyFor = (hash: string): string => `${namespace}.${hash}`;\n\n return {\n name,\n log: true,\n trip: {\n async before(context) {\n // Only cache the first trip's response. Subsequent trips\n // happen because the previous trip requested tool calls — the\n // message list now carries tool results the original prompt\n // never saw, so a semantic match on the unchanged user text\n // would serve back the prior `tool_calls` response and loop\n // the agent forever. The first turn is also the only one\n // where a \"same question → same final answer\" caching story\n // is sound.\n if (context.tripIndex !== 0) {\n return;\n }\n\n const promptText = extractUserText(context.messages);\n\n if (!promptText) {\n return;\n }\n\n const promptKey = hashPrompt(context.messages);\n const scopedKey = keyFor(promptKey);\n\n const exact = await store.get<CachedEntry>(scopedKey);\n\n if (exact && isFresh(exact, options.ttlMs)) {\n return toSyntheticResponse(exact.response);\n }\n\n const query = await options.embedder.embed(promptText);\n\n const [hit] = await store.similar<CachedEntry>(query.vector, {\n topK: 1,\n threshold: options.threshold,\n });\n\n if (hit && isFresh(hit.value, options.ttlMs)) {\n // Only return hits whose stored key is within this cache's\n // namespace. Drivers shared across namespaces would otherwise\n // leak foreign entries into queries.\n if (hit.key.startsWith(`${namespace}.`)) {\n return toSyntheticResponse(hit.value.response);\n }\n }\n\n const pending: PendingWrite = { promptKey, vector: query.vector };\n context.state.set(pendingKey, pending);\n\n return;\n },\n async after(context, response) {\n const pending = context.state.get(pendingKey) as PendingWrite | undefined;\n\n if (!pending) {\n return;\n }\n\n // Mid-stream tool-call responses must not be cached — the\n // useful answer comes from the trip *after* the tool returns.\n // Crucially, leave the pending entry in place so a later trip\n // (the one that actually finishes with `stop`) can read it\n // and write the final response under the *original* trip-0\n // prompt key. Deleting here would orphan the pending and the\n // post-tool answer would never make it into the store.\n if (response.finishReason === \"tool_calls\") {\n return;\n }\n\n context.state.delete(pendingKey);\n\n const entry: CachedEntry = { response, storedAt: Date.now() };\n\n await store.set(keyFor(pending.promptKey), entry, {\n vector: pending.vector,\n });\n\n return;\n },\n },\n };\n}\n\nfunction toSyntheticResponse(response: ModelResponse): ModelResponse {\n return {\n content: response.content,\n finishReason: response.finishReason,\n usage: { input: 0, output: 0, total: 0 },\n toolCalls: response.toolCalls,\n };\n}\n"],"mappings":";;;;AA8DA,MAAM,oBAAoB;;;;;;;;;;;;AAa1B,SAAS,WAAW,UAA0C;CAC5D,MAAM,aAAa,SAChB,KAAK,YAAY;EAShB,OAAO,GARM,QAAQ,KAQN,GAPC,MAAM,QAAQ,QAAQ,OAAO,IACzC,QAAQ,QACL,QAAQ,SAAS,KAAK,SAAS,MAAM,CAAC,CACtC,KAAK,SAAU,KAA0B,IAAI,CAAC,CAC9C,KAAK,GAAG,IACX,QAAQ;CAGd,CAAC,CAAC,CACD,KAAK,IAAI;CAEZ,IAAI,OAAO;CAEX,KAAK,IAAI,QAAQ,GAAG,QAAQ,WAAW,QAAQ,SAAS;EACtD,QAAQ,WAAW,WAAW,KAAK;EACnC,OAAO,KAAK,KAAK,MAAM,QAAU;CACnC;CAEA,QAAQ,SAAS,EAAC,CAAE,SAAS,EAAE;AACjC;AAEA,SAAS,QAAQ,OAAoB,OAAoC;CACvE,IAAI,UAAU,QACZ,OAAO;CAGT,OAAO,KAAK,IAAI,IAAI,MAAM,YAAY;AACxC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyDA,SAAgB,cAAc,SAAgD;CAC5E,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,YAAY,QAAQ,aAAa;CACvC,MAAM,aAAa,GAAG,KAAK;CAM3B,MAAM,QAAQ,QAAQ,SAAS,oBAAoB;CAEnD,IAAI,CAAC,OACH,MAAM,IAAI,MACR,qJACF;CAMF,MAAM,UAAU,SAAyB,GAAG,UAAU,GAAG;CAEzD,OAAO;EACL;EACA,KAAK;EACL,MAAM;GACJ,MAAM,OAAO,SAAS;IASpB,IAAI,QAAQ,cAAc,GACxB;IAGF,MAAM,aAAa,gBAAgB,QAAQ,QAAQ;IAEnD,IAAI,CAAC,YACH;IAGF,MAAM,YAAY,WAAW,QAAQ,QAAQ;IAC7C,MAAM,YAAY,OAAO,SAAS;IAElC,MAAM,QAAQ,MAAM,MAAM,IAAiB,SAAS;IAEpD,IAAI,SAAS,QAAQ,OAAO,QAAQ,KAAK,GACvC,OAAO,oBAAoB,MAAM,QAAQ;IAG3C,MAAM,QAAQ,MAAM,QAAQ,SAAS,MAAM,UAAU;IAErD,MAAM,CAAC,OAAO,MAAM,MAAM,QAAqB,MAAM,QAAQ;KAC3D,MAAM;KACN,WAAW,QAAQ;IACrB,CAAC;IAED,IAAI,OAAO,QAAQ,IAAI,OAAO,QAAQ,KAAK,GAIzC;SAAI,IAAI,IAAI,WAAW,GAAG,UAAU,EAAE,GACpC,OAAO,oBAAoB,IAAI,MAAM,QAAQ;IAC/C;IAGF,MAAM,UAAwB;KAAE;KAAW,QAAQ,MAAM;IAAO;IAChE,QAAQ,MAAM,IAAI,YAAY,OAAO;GAGvC;GACA,MAAM,MAAM,SAAS,UAAU;IAC7B,MAAM,UAAU,QAAQ,MAAM,IAAI,UAAU;IAE5C,IAAI,CAAC,SACH;IAUF,IAAI,SAAS,iBAAiB,cAC5B;IAGF,QAAQ,MAAM,OAAO,UAAU;IAE/B,MAAM,QAAqB;KAAE;KAAU,UAAU,KAAK,IAAI;IAAE;IAE5D,MAAM,MAAM,IAAI,OAAO,QAAQ,SAAS,GAAG,OAAO,EAChD,QAAQ,QAAQ,OAClB,CAAC;GAGH;EACF;CACF;AACF;AAEA,SAAS,oBAAoB,UAAwC;CACnE,OAAO;EACL,SAAS,SAAS;EAClB,cAAc,SAAS;EACvB,OAAO;GAAE,OAAO;GAAG,QAAQ;GAAG,OAAO;EAAE;EACvC,WAAW,SAAS;CACtB;AACF"}
1
+ {"version":3,"file":"semantic-cache.mjs","names":[],"sources":["../../../../../../../../ai/src/middleware/builtins/semantic-cache.ts"],"sourcesContent":["import type { CacheDriver } from \"@warlock.js/cache\";\nimport { resolveDefaultStore } from \"../../config\";\nimport type { Message } from \"../../contracts/conversation-message.type\";\nimport type { EmbedderContract } from \"../../contracts/embedder.contract\";\nimport type { AgentMiddleware } from \"../../contracts/middleware\";\nimport type { MiddlewareTripContext } from \"../../contracts/middleware/middleware-context.type\";\nimport type { ModelResponse } from \"../../contracts/model.contract\";\nimport { extractUserText } from \"../utils\";\n\n/**\n * Isolation boundary for cache reads and writes.\n *\n * - `\"session\"` (default) — key every entry off the run's\n * `AgentExecuteOptions.sessionId`, so one session never receives a\n * response cached for another. Calls made WITHOUT a `sessionId` share\n * one unscoped pool (the pre-4.15.0 behavior); an unscoped read never\n * sees a session-scoped entry and vice versa.\n * - `\"shared\"` — one pool for every caller, regardless of session. The\n * explicit opt-in for genuinely public Q&A (docs bots, FAQ) where the\n * cross-user hit rate is the point and no response can carry one\n * caller's private context.\n * - a resolver — derive the key yourself, e.g. per tenant\n * (`ctx => ctx.options?.toolCtx?.tenantId`). Returning `undefined`\n * falls back to the unscoped pool, so return a constant sentinel (or\n * throw) if you need the call to fail closed instead.\n */\nexport type SemanticCacheScope =\n | \"session\"\n | \"shared\"\n | ((context: MiddlewareTripContext) => string | undefined);\n\n/**\n * Configuration for `semanticCache()`.\n */\nexport type SemanticCacheOptions = {\n /** Embedder used to produce the query vector from the prompt text. */\n embedder: EmbedderContract;\n /**\n * Vector-capable cache driver from `@warlock.js/cache`. Production\n * deployments pick a driver with a real ANN index (`pg` with\n * pgvector, `redis` with RediSearch). Dev / test environments use\n * `new MemoryCacheDriver()` — zero config, correct, but O(N) per\n * query. Drivers without similarity support throw\n * `CacheUnsupportedError` from `set({ vector })` / `similar()`.\n *\n * Falls back to `ai.config({ defaultStore })` when omitted. When\n * neither is set, the factory throws at construction time —\n * semantic cache cannot operate without a store.\n */\n store?: CacheDriver<any, any>;\n /**\n * Minimum cosine similarity for a vector hit. Between 0 and 1 —\n * 0.95 is a solid default for question-answering caches.\n */\n threshold: number;\n /**\n * Optional TTL in milliseconds. Entries whose `storedAt` is older\n * than this are treated as misses on read and overwritten on the\n * next write. Default: no expiry — entries live until the store\n * evicts them (per its own TTL/eviction policy).\n */\n ttlMs?: number;\n /**\n * Namespace prefix applied to every key the cache writes. Lets\n * multiple agents share one driver without collision. Default\n * `\"ai.cache\"`.\n */\n namespace?: string;\n /**\n * Per-caller isolation boundary. Default `\"session\"` — a cached\n * response is served back only to the session that produced it.\n *\n * A `semanticCache` is normally built once at app boot and shared by\n * every end user, and a hit is returned as the model's answer with no\n * LLM call in between; without a scope that pools every caller's Q&A\n * pairs into one namespace, which is both a disclosure path (user B's\n * near-enough prompt gets served user A's answer, personal context\n * included) and a poisoning path (an attacker seeds an entry near a\n * predictable future query). Set `\"shared\"` to opt back into pooling\n * where that is actually desirable. See {@link SemanticCacheScope}.\n */\n scope?: SemanticCacheScope;\n /**\n * Middleware name — also the state-bag key prefix inside a single\n * execution. Default `\"semantic-cache\"`.\n */\n name?: string;\n};\n\ntype CachedEntry = {\n response: ModelResponse;\n storedAt: number;\n /**\n * Isolation key the entry was written under; absent = the unscoped\n * pool (also the shape of every entry written before 4.15.0).\n */\n scope?: string;\n};\n\ntype PendingWrite = {\n promptKey: string;\n vector: number[];\n scope?: string;\n};\n\nconst DEFAULT_NAMESPACE = \"ai.cache\";\n\n/**\n * Extra candidates pulled from `similar()` on a SCOPED lookup before the\n * scope filter runs. The driver ranks across every scope in the index,\n * so a bare `topK: 1` can come back as a foreign entry and mask this\n * scope's own legitimate hit. Mirrors the memory tiers' overscan.\n */\nconst SIMILAR_OVERSCAN = 5;\n\n/**\n * Build a stable fingerprint for a prompt covering the full message\n * list (system + history + user turn). Ensures two prompts sharing\n * the user text but differing in prior context do not collide on\n * the exact-match fast path.\n *\n * FNV-1a variant — cheap, collision-resistant enough for a cache,\n * dependency-free. NOT a cryptographic hash: collisions would\n * surface as wrong cache hits, not a security issue in the current\n * trust model.\n */\nfunction hashPrompt(messages: ReadonlyArray<Message>): string {\n return fnv1a(\n messages\n .map((message) => {\n const role = message.role;\n const content = Array.isArray(message.content)\n ? message.content\n .filter((part) => part.type === \"text\")\n .map((part) => (part as { text: string }).text)\n .join(\"|\")\n : message.content;\n\n return `${role}:${content}`;\n })\n .join(\"||\"),\n );\n}\n\n/** FNV-1a over a string — see {@link hashPrompt} for the caveats. */\nfunction fnv1a(serialized: string): string {\n let hash = 0x811c9dc5;\n\n for (let index = 0; index < serialized.length; index++) {\n hash ^= serialized.charCodeAt(index);\n hash = Math.imul(hash, 0x01000193);\n }\n\n return (hash >>> 0).toString(16);\n}\n\n/**\n * Resolve the isolation key this trip reads and writes under.\n *\n * Derived from the run's own `sessionId` (or the developer's resolver) —\n * never from the prompt, the model's output, or anything the LLM can\n * write to. `\"shared\"` and an unidentified run both resolve to\n * `undefined`, i.e. the unscoped pool, which a scoped lookup can never\n * read.\n */\nfunction resolveScope(\n scope: SemanticCacheScope,\n context: MiddlewareTripContext,\n): string | undefined {\n if (scope === \"shared\") {\n return undefined;\n }\n\n const key =\n typeof scope === \"function\"\n ? scope(context)\n : sessionScope(context.options?.sessionId);\n\n return key ? key : undefined;\n}\n\n/**\n * The default `\"session\"` key: the session id under a reserved prefix so\n * a custom resolver returning a bare tenant id can't collide with a\n * session pool. Mirrors the orchestrator's `sessionMemoryScope`.\n */\nfunction sessionScope(sessionId: string | undefined): string | undefined {\n return sessionId ? `session:${sessionId}` : undefined;\n}\n\nfunction isFresh(entry: CachedEntry, ttlMs: number | undefined): boolean {\n if (ttlMs === undefined) {\n return true;\n }\n\n return Date.now() - entry.storedAt <= ttlMs;\n}\n\n/**\n * Semantic-similarity response cache for an agent run.\n *\n * **Role.** Skips LLM round-trips when the current prompt is\n * semantically close to one the agent has already answered. For\n * FAQ / support-style traffic this often eliminates 60–80% of\n * model calls — the production win is massive for cost and\n * latency.\n *\n * **Delegation to `@warlock.js/cache`.** This middleware does NOT\n * implement similarity search itself. It delegates to the supplied\n * `CacheDriver`. Production deployments pick a driver with an ANN\n * index (`pg` + pgvector, `redis` + RediSearch). Dev / test\n * environments pass `new MemoryCacheDriver()` — zero config, correct,\n * but O(N) per query. Drivers without similarity support throw\n * `CacheUnsupportedError` from `set({ vector })` / `similar()`.\n *\n * **Two-tier lookup.**\n * 1. *Exact-match key* — a cheap FNV hash over the entire message\n * list. `store.get(hash)` returns the entry without an embedding\n * round trip when the prompt hasn't changed at all.\n * 2. *Vector-match* — on exact-match miss, embed the prompt and\n * call `store.similar(vector, { topK: 1, threshold })`. The\n * driver uses its native similarity index; anything clearing\n * `threshold` is returned as a hit.\n *\n * **Write-on-miss.** When both tiers miss, `trip.before` stashes\n * the prompt hash + vector in `ctx.state`; `trip.after` reads back\n * the pending entry and calls\n * `store.set(hash, entry, { vector })`. If an outer middleware\n * (guardrail) throws in `trip.after` before the cache's `trip.after`\n * runs, the pending entry is never written — bad responses stay out\n * of the cache **as long as the canonical install order is followed**\n * (cache outermost).\n *\n * **Synthetic-response on hit.** Returns a `ModelResponse` with\n * `usage: { input: 0, output: 0, total: 0 }` so budget /\n * observability correctly exclude the saved trip.\n *\n * **Per-session scoping (4.15.0).** One `semanticCache` instance\n * normally serves every end user, and a hit is returned as the answer\n * with no model call in between — so entries are keyed by the run's\n * `sessionId` (`scope`, default `\"session\"`) and a lookup only ever\n * sees entries written under the same key. Runs made without a\n * `sessionId` share one unscoped pool; pass `sessionId` on\n * `agent.execute()` (composites thread their own through automatically)\n * to get the isolation, or set `scope: \"shared\"` to pool deliberately.\n * Note the cost/benefit shift: scoping trades cross-user hit rate for\n * isolation, so public-FAQ deployments where no response can carry a\n * caller's private context should opt into `\"shared\"` explicitly.\n *\n * @example\n * import { semanticCache } from \"@warlock.js/ai\";\n * import { MemoryCacheDriver } from \"@warlock.js/cache\";\n *\n * const store = new MemoryCacheDriver();\n * store.setOptions({});\n *\n * const cache = semanticCache({\n * embedder: openai.embedder({ name: \"text-embedding-3-small\" }),\n * store,\n * threshold: 0.95,\n * ttlMs: 60 * 60 * 1000,\n * });\n *\n * const myAgent = agent({ model, middleware: [cache] });\n */\nexport function semanticCache(options: SemanticCacheOptions): AgentMiddleware {\n const name = options.name ?? \"semantic-cache\";\n const namespace = options.namespace ?? DEFAULT_NAMESPACE;\n const scopeMode: SemanticCacheScope = options.scope ?? \"session\";\n const pendingKey = `${name}.pending`;\n\n // Resolve the effective store at factory time, not per-call. Every\n // subsequent hook closes over `store` so the resolution happens once.\n // Throws now (loud, at construction) instead of later during the\n // first trip (silent until the agent actually runs).\n const store = options.store ?? resolveDefaultStore();\n\n if (!store) {\n throw new Error(\n `semanticCache: no store supplied — pass \\`store\\` in options or call \\`ai.config({ defaultStore })\\` at app boot before constructing the middleware`,\n );\n }\n\n // Cache's parseKey replaces \":\" with \".\" so the namespace boundary\n // matches what `similar()` actually returns in `hit.key`. Using a\n // dot here keeps prefix checks aligned with stored keys.\n //\n // A scoped entry gets an extra hashed segment, so two sessions asking\n // the identical question stay two entries instead of overwriting each\n // other; the scope is hashed because a `sessionId` is caller-supplied\n // and may contain the key delimiter. The unscoped key shape is\n // unchanged, so pre-4.15.0 entries still resolve. The hash is a\n // write-separation device only — a read is authorized by the exact\n // `entry.scope` equality check below, so even a hash collision cannot\n // widen what a session can read.\n const keyFor = (hash: string, scope: string | undefined): string =>\n scope === undefined\n ? `${namespace}.${hash}`\n : `${namespace}.${fnv1a(scope)}.${hash}`;\n\n return {\n name,\n log: true,\n trip: {\n async before(context) {\n // Only cache the first trip's response. Subsequent trips\n // happen because the previous trip requested tool calls — the\n // message list now carries tool results the original prompt\n // never saw, so a semantic match on the unchanged user text\n // would serve back the prior `tool_calls` response and loop\n // the agent forever. The first turn is also the only one\n // where a \"same question → same final answer\" caching story\n // is sound.\n if (context.tripIndex !== 0) {\n return;\n }\n\n const promptText = extractUserText(context.messages);\n\n if (!promptText) {\n return;\n }\n\n const scope = resolveScope(scopeMode, context);\n const promptKey = hashPrompt(context.messages);\n\n const exact = await store.get<CachedEntry>(keyFor(promptKey, scope));\n\n // The key already carries the scope; re-checking the stored\n // `scope` is the actual authorization step, so a key collision\n // or a hand-written entry can't serve across the boundary.\n if (exact && exact.scope === scope && isFresh(exact, options.ttlMs)) {\n return toSyntheticResponse(exact.response);\n }\n\n const query = await options.embedder.embed(promptText);\n\n const hits = await store.similar<CachedEntry>(query.vector, {\n topK: scope === undefined ? 1 : SIMILAR_OVERSCAN,\n threshold: options.threshold,\n });\n\n // Only entries written inside this cache's namespace AND this\n // caller's scope are eligible. A shared driver would otherwise\n // leak a foreign namespace's entries; a shared namespace would\n // leak another session's answer to this one.\n const hit = hits.find(\n (candidate) =>\n candidate.key.startsWith(`${namespace}.`) &&\n candidate.value?.scope === scope &&\n isFresh(candidate.value, options.ttlMs),\n );\n\n if (hit) {\n return toSyntheticResponse(hit.value.response);\n }\n\n const pending: PendingWrite = {\n promptKey,\n vector: query.vector,\n scope,\n };\n context.state.set(pendingKey, pending);\n\n return;\n },\n async after(context, response) {\n const pending = context.state.get(pendingKey) as PendingWrite | undefined;\n\n if (!pending) {\n return;\n }\n\n // Mid-stream tool-call responses must not be cached — the\n // useful answer comes from the trip *after* the tool returns.\n // Crucially, leave the pending entry in place so a later trip\n // (the one that actually finishes with `stop`) can read it\n // and write the final response under the *original* trip-0\n // prompt key. Deleting here would orphan the pending and the\n // post-tool answer would never make it into the store.\n if (response.finishReason === \"tool_calls\") {\n return;\n }\n\n context.state.delete(pendingKey);\n\n const entry: CachedEntry = {\n response,\n storedAt: Date.now(),\n scope: pending.scope,\n };\n\n await store.set(keyFor(pending.promptKey, pending.scope), entry, {\n vector: pending.vector,\n });\n\n return;\n },\n },\n };\n}\n\nfunction toSyntheticResponse(response: ModelResponse): ModelResponse {\n return {\n content: response.content,\n finishReason: response.finishReason,\n usage: { input: 0, output: 0, total: 0 },\n toolCalls: response.toolCalls,\n };\n}\n"],"mappings":";;;;AAyGA,MAAM,oBAAoB;;;;;;;AAQ1B,MAAM,mBAAmB;;;;;;;;;;;;AAazB,SAAS,WAAW,UAA0C;CAC5D,OAAO,MACL,SACG,KAAK,YAAY;EAShB,OAAO,GARM,QAAQ,KAQN,GAPC,MAAM,QAAQ,QAAQ,OAAO,IACzC,QAAQ,QACL,QAAQ,SAAS,KAAK,SAAS,MAAM,CAAC,CACtC,KAAK,SAAU,KAA0B,IAAI,CAAC,CAC9C,KAAK,GAAG,IACX,QAAQ;CAGd,CAAC,CAAC,CACD,KAAK,IAAI,CACd;AACF;;AAGA,SAAS,MAAM,YAA4B;CACzC,IAAI,OAAO;CAEX,KAAK,IAAI,QAAQ,GAAG,QAAQ,WAAW,QAAQ,SAAS;EACtD,QAAQ,WAAW,WAAW,KAAK;EACnC,OAAO,KAAK,KAAK,MAAM,QAAU;CACnC;CAEA,QAAQ,SAAS,EAAC,CAAE,SAAS,EAAE;AACjC;;;;;;;;;;AAWA,SAAS,aACP,OACA,SACoB;CACpB,IAAI,UAAU,UACZ;CAGF,MAAM,MACJ,OAAO,UAAU,aACb,MAAM,OAAO,IACb,aAAa,QAAQ,SAAS,SAAS;CAE7C,OAAO,MAAM,MAAM;AACrB;;;;;;AAOA,SAAS,aAAa,WAAmD;CACvE,OAAO,YAAY,WAAW,cAAc;AAC9C;AAEA,SAAS,QAAQ,OAAoB,OAAoC;CACvE,IAAI,UAAU,QACZ,OAAO;CAGT,OAAO,KAAK,IAAI,IAAI,MAAM,YAAY;AACxC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqEA,SAAgB,cAAc,SAAgD;CAC5E,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,YAAY,QAAQ,aAAa;CACvC,MAAM,YAAgC,QAAQ,SAAS;CACvD,MAAM,aAAa,GAAG,KAAK;CAM3B,MAAM,QAAQ,QAAQ,SAAS,oBAAoB;CAEnD,IAAI,CAAC,OACH,MAAM,IAAI,MACR,qJACF;CAeF,MAAM,UAAU,MAAc,UAC5B,UAAU,SACN,GAAG,UAAU,GAAG,SAChB,GAAG,UAAU,GAAG,MAAM,KAAK,EAAE,GAAG;CAEtC,OAAO;EACL;EACA,KAAK;EACL,MAAM;GACJ,MAAM,OAAO,SAAS;IASpB,IAAI,QAAQ,cAAc,GACxB;IAGF,MAAM,aAAa,gBAAgB,QAAQ,QAAQ;IAEnD,IAAI,CAAC,YACH;IAGF,MAAM,QAAQ,aAAa,WAAW,OAAO;IAC7C,MAAM,YAAY,WAAW,QAAQ,QAAQ;IAE7C,MAAM,QAAQ,MAAM,MAAM,IAAiB,OAAO,WAAW,KAAK,CAAC;IAKnE,IAAI,SAAS,MAAM,UAAU,SAAS,QAAQ,OAAO,QAAQ,KAAK,GAChE,OAAO,oBAAoB,MAAM,QAAQ;IAG3C,MAAM,QAAQ,MAAM,QAAQ,SAAS,MAAM,UAAU;IAWrD,MAAM,OAAM,MATO,MAAM,QAAqB,MAAM,QAAQ;KAC1D,MAAM,UAAU,SAAY,IAAI;KAChC,WAAW,QAAQ;IACrB,CAAC,EAMe,CAAC,MACd,cACC,UAAU,IAAI,WAAW,GAAG,UAAU,EAAE,KACxC,UAAU,OAAO,UAAU,SAC3B,QAAQ,UAAU,OAAO,QAAQ,KAAK,CAC1C;IAEA,IAAI,KACF,OAAO,oBAAoB,IAAI,MAAM,QAAQ;IAG/C,MAAM,UAAwB;KAC5B;KACA,QAAQ,MAAM;KACd;IACF;IACA,QAAQ,MAAM,IAAI,YAAY,OAAO;GAGvC;GACA,MAAM,MAAM,SAAS,UAAU;IAC7B,MAAM,UAAU,QAAQ,MAAM,IAAI,UAAU;IAE5C,IAAI,CAAC,SACH;IAUF,IAAI,SAAS,iBAAiB,cAC5B;IAGF,QAAQ,MAAM,OAAO,UAAU;IAE/B,MAAM,QAAqB;KACzB;KACA,UAAU,KAAK,IAAI;KACnB,OAAO,QAAQ;IACjB;IAEA,MAAM,MAAM,IAAI,OAAO,QAAQ,WAAW,QAAQ,KAAK,GAAG,OAAO,EAC/D,QAAQ,QAAQ,OAClB,CAAC;GAGH;EACF;CACF;AACF;AAEA,SAAS,oBAAoB,UAAwC;CACnE,OAAO;EACL,SAAS,SAAS;EAClB,cAAc,SAAS;EACvB,OAAO;GAAE,OAAO;GAAG,QAAQ;GAAG,OAAO;EAAE;EACvC,WAAW,SAAS;CACtB;AACF"}
@@ -1,7 +1,7 @@
1
1
  import { BudgetContract, BudgetContractDimension, BudgetContractFallback, BudgetContractViolation, BudgetContractViolationMode } from "./builtins/budget-contract.type.mjs";
2
2
  import { BudgetFallbackSignal, BudgetOptions, BudgetPricing, budget, readBudgetFallbackSignal } from "./builtins/budget.mjs";
3
3
  import { GuardrailCheck, GuardrailCheckResult, GuardrailOptions, guardrail } from "./builtins/guardrail.mjs";
4
- import { SemanticCacheOptions, semanticCache } from "./builtins/semantic-cache.mjs";
4
+ import { SemanticCacheOptions, SemanticCacheScope, semanticCache } from "./builtins/semantic-cache.mjs";
5
5
  import { composeMiddleware } from "./helpers/compose.mjs";
6
6
  import { forTool } from "./helpers/for-tool.mjs";
7
7
  import { MiddlewareContextByLevel, MiddlewareLevel, runPipeline } from "./pipeline.mjs";
@@ -8,20 +8,34 @@ import { OrchestratorAsToolOptions, OrchestratorContract } from "../contracts/or
8
8
  * Mirrors `supervisor.asTool()` — same `compositeAsTool` composition and
9
9
  * error normalization — and adds `sessionScope`.
10
10
  *
11
- * The boundary is OPAQUE (§13, §18.6): the parent's `signal` / `context`
12
- * / events do NOT auto-forward. Anything the wrapped orchestrator needs
13
- * per call must ride on the tool's `inputSchema` payload.
11
+ * The boundary is OPAQUE (§13, §18.6): the parent's `context` / events do
12
+ * NOT auto-forward. Per-call data the wrapped orchestrator needs rides on
13
+ * the tool's `inputSchema` payload — with ONE deliberate exception, the
14
+ * session binding below, because the payload is written by an LLM.
14
15
  *
15
16
  * Session continuity:
16
17
  * - `"fresh"` (default) — each invocation gets a brand-new `sessionId`
17
18
  * (a generated id) and empty history; the session lives only for this
18
19
  * tool call. The whole validated payload is forwarded as the
19
20
  * orchestrator's `execute(input)` argument.
20
- * - `"shared"` — the parent supplies `sessionId` (and optionally
21
- * `history`) inside the validated payload; the orchestrator
22
- * participates in that session. The remaining payload fields are
23
- * forwarded as `execute(input)`. A missing/blank `sessionId` throws
24
- * {@link SupervisorFailedError}.
21
+ * - `"shared"` — the orchestrator joins an EXISTING session named by the
22
+ * developer through `options.session`: either a literal id fixed at
23
+ * construction, or a resolver that reads the invocation's
24
+ * {@link ToolContext} (`ctx.artifacts`, the out-of-band bag the model
25
+ * cannot write to). The whole validated payload is forwarded as
26
+ * `execute(input)`. A `"shared"` tool built without `session` throws at
27
+ * construction.
28
+ *
29
+ * **Why the session id is not a schema field (4.15.0 security fix).**
30
+ * Before this release, `"shared"` scope read `sessionId` straight out of
31
+ * the model-generated tool arguments. A `sessionId` is bearer-equivalent
32
+ * — naming one grants read/write on that session's persisted state — so
33
+ * any prompt injection reaching the outer agent ("continue session
34
+ * `<victim-id>`") made the nested orchestrator load a stranger's
35
+ * conversation, mutate it, and echo its content back into the attacker's
36
+ * transcript. The binding now lives on channels the model has no access
37
+ * to. The old behavior survives only behind the loudly-named
38
+ * `unsafeAllowModelSessionId` opt-in.
25
39
  *
26
40
  * On `result.error`, the typed orchestrator error is thrown so the tool
27
41
  * wrapper produces a `ToolExecutionError` with `cause` preserved — the
@@ -29,12 +43,24 @@ import { OrchestratorAsToolOptions, OrchestratorContract } from "../contracts/or
29
43
  *
30
44
  * @example
31
45
  * const support = ai.orchestrator({ name: "refund-support", intents });
46
+ *
47
+ * // Fresh session per call — no continuity, nothing to hijack.
32
48
  * const supportTool = support.asTool({
33
49
  * name: "handle_refund",
34
50
  * description: "Handle a refund conversation end-to-end.",
35
51
  * inputSchema: v.object({ message: v.string() }),
36
52
  * });
37
- * const concierge = ai.agent({ model, tools: [supportTool] });
53
+ *
54
+ * // Continuous session — bound from the authenticated request, never
55
+ * // from the model's arguments.
56
+ * const continuousTool = support.asTool({
57
+ * name: "handle_refund",
58
+ * inputSchema: v.object({ message: v.string() }),
59
+ * sessionScope: "shared",
60
+ * session: (ctx) => ({
61
+ * sessionId: String(ctx?.artifacts?.refundSessionId ?? ""),
62
+ * }),
63
+ * });
38
64
  */
39
65
  declare function asTool<TOutput, TState, TToolInput>(orchestrator: OrchestratorContract<TOutput, TState>, options: OrchestratorAsToolOptions<TToolInput>): ToolContract<TToolInput, TOutput>;
40
66
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"as-tool.d.mts","names":[],"sources":["../../../../../../../ai/src/orchestrator/as-tool.ts"],"mappings":";;;;;;AAyDA;;;;;;;;;;;;;;;;;;;;;;;;;;;AAGmC;;;;;iBAHnB,MAAA,8BACd,YAAA,EAAc,oBAAA,CAAqB,OAAA,EAAS,MAAA,GAC5C,OAAA,EAAS,yBAAA,CAA0B,UAAA,IAClC,YAAA,CAAa,UAAA,EAAY,OAAA"}
1
+ {"version":3,"file":"as-tool.d.mts","names":[],"sources":["../../../../../../../ai/src/orchestrator/as-tool.ts"],"mappings":";;;;;;AA2FA;;;;;;;;;;;;;;;;;;;;;;;;;;;AAGmC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAHnB,MAAA,8BACd,YAAA,EAAc,oBAAA,CAAqB,OAAA,EAAS,MAAA,GAC5C,OAAA,EAAS,yBAAA,CAA0B,UAAA,IAClC,YAAA,CAAa,UAAA,EAAY,OAAA"}
@@ -10,20 +10,34 @@ import { compositeAsTool } from "../tool/tool.mjs";
10
10
  * Mirrors `supervisor.asTool()` — same `compositeAsTool` composition and
11
11
  * error normalization — and adds `sessionScope`.
12
12
  *
13
- * The boundary is OPAQUE (§13, §18.6): the parent's `signal` / `context`
14
- * / events do NOT auto-forward. Anything the wrapped orchestrator needs
15
- * per call must ride on the tool's `inputSchema` payload.
13
+ * The boundary is OPAQUE (§13, §18.6): the parent's `context` / events do
14
+ * NOT auto-forward. Per-call data the wrapped orchestrator needs rides on
15
+ * the tool's `inputSchema` payload — with ONE deliberate exception, the
16
+ * session binding below, because the payload is written by an LLM.
16
17
  *
17
18
  * Session continuity:
18
19
  * - `"fresh"` (default) — each invocation gets a brand-new `sessionId`
19
20
  * (a generated id) and empty history; the session lives only for this
20
21
  * tool call. The whole validated payload is forwarded as the
21
22
  * orchestrator's `execute(input)` argument.
22
- * - `"shared"` — the parent supplies `sessionId` (and optionally
23
- * `history`) inside the validated payload; the orchestrator
24
- * participates in that session. The remaining payload fields are
25
- * forwarded as `execute(input)`. A missing/blank `sessionId` throws
26
- * {@link SupervisorFailedError}.
23
+ * - `"shared"` — the orchestrator joins an EXISTING session named by the
24
+ * developer through `options.session`: either a literal id fixed at
25
+ * construction, or a resolver that reads the invocation's
26
+ * {@link ToolContext} (`ctx.artifacts`, the out-of-band bag the model
27
+ * cannot write to). The whole validated payload is forwarded as
28
+ * `execute(input)`. A `"shared"` tool built without `session` throws at
29
+ * construction.
30
+ *
31
+ * **Why the session id is not a schema field (4.15.0 security fix).**
32
+ * Before this release, `"shared"` scope read `sessionId` straight out of
33
+ * the model-generated tool arguments. A `sessionId` is bearer-equivalent
34
+ * — naming one grants read/write on that session's persisted state — so
35
+ * any prompt injection reaching the outer agent ("continue session
36
+ * `<victim-id>`") made the nested orchestrator load a stranger's
37
+ * conversation, mutate it, and echo its content back into the attacker's
38
+ * transcript. The binding now lives on channels the model has no access
39
+ * to. The old behavior survives only behind the loudly-named
40
+ * `unsafeAllowModelSessionId` opt-in.
27
41
  *
28
42
  * On `result.error`, the typed orchestrator error is thrown so the tool
29
43
  * wrapper produces a `ToolExecutionError` with `cause` preserved — the
@@ -31,22 +45,36 @@ import { compositeAsTool } from "../tool/tool.mjs";
31
45
  *
32
46
  * @example
33
47
  * const support = ai.orchestrator({ name: "refund-support", intents });
48
+ *
49
+ * // Fresh session per call — no continuity, nothing to hijack.
34
50
  * const supportTool = support.asTool({
35
51
  * name: "handle_refund",
36
52
  * description: "Handle a refund conversation end-to-end.",
37
53
  * inputSchema: v.object({ message: v.string() }),
38
54
  * });
39
- * const concierge = ai.agent({ model, tools: [supportTool] });
55
+ *
56
+ * // Continuous session — bound from the authenticated request, never
57
+ * // from the model's arguments.
58
+ * const continuousTool = support.asTool({
59
+ * name: "handle_refund",
60
+ * inputSchema: v.object({ message: v.string() }),
61
+ * sessionScope: "shared",
62
+ * session: (ctx) => ({
63
+ * sessionId: String(ctx?.artifacts?.refundSessionId ?? ""),
64
+ * }),
65
+ * });
40
66
  */
41
67
  function asTool(orchestrator, options) {
42
68
  if (!orchestrator.name || typeof orchestrator.name !== "string") throw new SupervisorFailedError("orchestrator.asTool(): orchestrator must have a `name` to be wrapped as a tool");
43
69
  const sessionScope = options.sessionScope ?? "fresh";
70
+ const allowModelSessionId = options.unsafeAllowModelSessionId === true;
71
+ 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");
44
72
  return compositeAsTool({
45
73
  name: options.name ?? orchestrator.name,
46
74
  description: options.description ?? `Invoke orchestrator "${orchestrator.name}" as a tool.`,
47
75
  input: options.inputSchema,
48
- execute: async (input) => {
49
- const { sessionId, history, executeInput } = resolveSession(sessionScope, input);
76
+ execute: async (input, ctx) => {
77
+ const { sessionId, history, executeInput } = await resolveSession(sessionScope, input, ctx, options.session, allowModelSessionId);
50
78
  const result = await orchestrator.execute(executeInput, {
51
79
  sessionId,
52
80
  history
@@ -62,21 +90,41 @@ function asTool(orchestrator, options) {
62
90
  }
63
91
  /**
64
92
  * Resolve the per-call `sessionId`, `history`, and the `execute(input)`
65
- * argument from the validated tool payload, according to `sessionScope`.
93
+ * argument, according to `sessionScope`.
94
+ *
95
+ * For `"shared"` scope the session comes from the developer's `session`
96
+ * binding (construction-time literal or `ToolContext` resolver) — the
97
+ * validated payload is never consulted for it unless the caller opted
98
+ * into `unsafeAllowModelSessionId`. Either way `sessionId` / `history`
99
+ * are stripped from the payload before it is forwarded as
100
+ * `execute(input)`, so a model-authored field of that name can't reach
101
+ * the orchestrator's input under a misleading name.
66
102
  */
67
- function resolveSession(sessionScope, input) {
103
+ async function resolveSession(sessionScope, input, ctx, session, allowModelSessionId) {
68
104
  if (sessionScope === "fresh") return {
69
105
  sessionId: generateRunId("session"),
70
106
  history: [],
71
107
  executeInput: coerceInput(input)
72
108
  };
73
- const payload = typeof input === "object" && input !== null ? input : {};
74
- 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");
75
- const { sessionId, history, ...rest } = payload;
109
+ const { sessionId: payloadSessionId, history: payloadHistory, ...rest } = typeof input === "object" && input !== null ? input : {};
110
+ const executeInput = coerceInput(rest);
111
+ if (session !== void 0) {
112
+ const bound = typeof session === "function" ? await session(ctx) : session;
113
+ const sessionId = typeof bound === "string" ? bound : bound?.sessionId;
114
+ const history = typeof bound === "string" ? void 0 : bound?.history;
115
+ 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");
116
+ return {
117
+ sessionId,
118
+ history: Array.isArray(history) ? history : [],
119
+ executeInput
120
+ };
121
+ }
122
+ if (!allowModelSessionId) throw new SupervisorFailedError("orchestrator.asTool(): sessionScope \"shared\" requires a `session` binding");
123
+ if (typeof payloadSessionId !== "string" || payloadSessionId.length === 0) throw new SupervisorFailedError("orchestrator.asTool(): sessionScope \"shared\" requires a `sessionId` string in the tool input payload");
76
124
  return {
77
- sessionId,
78
- history: Array.isArray(history) ? history : [],
79
- executeInput: coerceInput(rest)
125
+ sessionId: payloadSessionId,
126
+ history: Array.isArray(payloadHistory) ? payloadHistory : [],
127
+ executeInput
80
128
  };
81
129
  }
82
130
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"as-tool.mjs","names":[],"sources":["../../../../../../../ai/src/orchestrator/as-tool.ts"],"sourcesContent":["import type { Message } from \"../contracts/conversation-message.type\";\nimport type {\n OrchestratorAsToolOptions,\n OrchestratorContract,\n} from \"../contracts/orchestrator/orchestrator.contract\";\nimport type { SupervisorInput } from \"../contracts/supervisor/supervisor-input.type\";\nimport { SupervisorFailedError } from \"../errors\";\nimport { compositeAsTool, type ToolContract } from \"../tool/tool\";\nimport { generateRunId } from \"../utils/generate-run-id\";\n\n/**\n * Shape the orchestrator looks for inside the validated tool input when\n * `sessionScope: \"shared\"` — the parent must thread the session id (and\n * optionally the prior history) through the tool's `inputSchema`\n * payload, since the boundary is opaque (§13). Everything else on the\n * payload becomes the orchestrator's `execute(input)` argument.\n */\ntype SharedScopePayload = {\n sessionId?: unknown;\n history?: unknown;\n [key: string]: unknown;\n};\n\n/**\n * Wrap an {@link OrchestratorContract} as a {@link ToolContract} so an\n * outer agent can invoke it from its tool-call loop (design §13).\n * Mirrors `supervisor.asTool()` — same `compositeAsTool` composition and\n * error normalization — and adds `sessionScope`.\n *\n * The boundary is OPAQUE (§13, §18.6): the parent's `signal` / `context`\n * / events do NOT auto-forward. Anything the wrapped orchestrator needs\n * per call must ride on the tool's `inputSchema` payload.\n *\n * Session continuity:\n * - `\"fresh\"` (default) — each invocation gets a brand-new `sessionId`\n * (a generated id) and empty history; the session lives only for this\n * tool call. The whole validated payload is forwarded as the\n * orchestrator's `execute(input)` argument.\n * - `\"shared\"` — the parent supplies `sessionId` (and optionally\n * `history`) inside the validated payload; the orchestrator\n * participates in that session. The remaining payload fields are\n * forwarded as `execute(input)`. A missing/blank `sessionId` throws\n * {@link SupervisorFailedError}.\n *\n * On `result.error`, the typed orchestrator error is thrown so the tool\n * wrapper produces a `ToolExecutionError` with `cause` preserved — the\n * outer agent sees one uniform error class.\n *\n * @example\n * const support = ai.orchestrator({ name: \"refund-support\", intents });\n * const supportTool = support.asTool({\n * name: \"handle_refund\",\n * description: \"Handle a refund conversation end-to-end.\",\n * inputSchema: v.object({ message: v.string() }),\n * });\n * const concierge = ai.agent({ model, tools: [supportTool] });\n */\nexport function asTool<TOutput, TState, TToolInput>(\n orchestrator: OrchestratorContract<TOutput, TState>,\n options: OrchestratorAsToolOptions<TToolInput>,\n): ToolContract<TToolInput, TOutput> {\n if (!orchestrator.name || typeof orchestrator.name !== \"string\") {\n throw new SupervisorFailedError(\n \"orchestrator.asTool(): orchestrator must have a `name` to be wrapped as a tool\",\n );\n }\n\n const sessionScope = options.sessionScope ?? \"fresh\";\n\n return compositeAsTool<TToolInput, TOutput>({\n name: options.name ?? orchestrator.name,\n description:\n options.description ??\n `Invoke orchestrator \"${orchestrator.name}\" as a tool.`,\n input: options.inputSchema,\n execute: async (input) => {\n const { sessionId, history, executeInput } = resolveSession(\n sessionScope,\n input,\n );\n\n const result = await orchestrator.execute(executeInput, {\n sessionId,\n history,\n });\n\n if (result.error) {\n // Surface the typed orchestrator error — the outer ToolContract\n // wraps it as a ToolExecutionError with `cause` preserved.\n throw result.error;\n }\n\n return {\n data: result.data as TOutput,\n usage: result.usage,\n report: result.report,\n };\n },\n });\n}\n\n/**\n * Resolve the per-call `sessionId`, `history`, and the `execute(input)`\n * argument from the validated tool payload, according to `sessionScope`.\n */\nfunction resolveSession(\n sessionScope: \"fresh\" | \"shared\",\n input: unknown,\n): { sessionId: string; history: Message[]; executeInput: SupervisorInput } {\n if (sessionScope === \"fresh\") {\n return {\n sessionId: generateRunId(\"session\"),\n history: [],\n executeInput: coerceInput(input),\n };\n }\n\n const payload = (\n typeof input === \"object\" && input !== null ? input : {}\n ) as SharedScopePayload;\n\n if (typeof payload.sessionId !== \"string\" || payload.sessionId.length === 0) {\n throw new SupervisorFailedError(\n 'orchestrator.asTool(): sessionScope \"shared\" requires a `sessionId` string in the tool input payload',\n );\n }\n\n const { sessionId, history, ...rest } = payload;\n\n return {\n sessionId,\n history: Array.isArray(history) ? (history as Message[]) : [],\n executeInput: coerceInput(rest),\n };\n}\n\n/**\n * Coerce a tool-input value into the `SupervisorInput` shape the\n * orchestrator's `execute()` accepts (`string | Record<string,\n * unknown>`). Strings and plain objects pass through; everything else\n * is JSON-stringified so the orchestrator receives a predictable input\n * regardless of how the outer agent shaped its call.\n */\nfunction coerceInput(value: unknown): SupervisorInput {\n if (typeof value === \"string\") {\n return value;\n }\n\n if (typeof value === \"object\" && value !== null) {\n return value as Record<string, unknown>;\n }\n\n if (value === undefined || value === null) {\n return \"\";\n }\n\n return String(value);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyDA,SAAgB,OACd,cACA,SACmC;CACnC,IAAI,CAAC,aAAa,QAAQ,OAAO,aAAa,SAAS,UACrD,MAAM,IAAI,sBACR,gFACF;CAGF,MAAM,eAAe,QAAQ,gBAAgB;CAE7C,OAAO,gBAAqC;EAC1C,MAAM,QAAQ,QAAQ,aAAa;EACnC,aACE,QAAQ,eACR,wBAAwB,aAAa,KAAK;EAC5C,OAAO,QAAQ;EACf,SAAS,OAAO,UAAU;GACxB,MAAM,EAAE,WAAW,SAAS,iBAAiB,eAC3C,cACA,KACF;GAEA,MAAM,SAAS,MAAM,aAAa,QAAQ,cAAc;IACtD;IACA;GACF,CAAC;GAED,IAAI,OAAO,OAGT,MAAM,OAAO;GAGf,OAAO;IACL,MAAM,OAAO;IACb,OAAO,OAAO;IACd,QAAQ,OAAO;GACjB;EACF;CACF,CAAC;AACH;;;;;AAMA,SAAS,eACP,cACA,OAC0E;CAC1E,IAAI,iBAAiB,SACnB,OAAO;EACL,WAAW,cAAc,SAAS;EAClC,SAAS,CAAC;EACV,cAAc,YAAY,KAAK;CACjC;CAGF,MAAM,UACJ,OAAO,UAAU,YAAY,UAAU,OAAO,QAAQ,CAAC;CAGzD,IAAI,OAAO,QAAQ,cAAc,YAAY,QAAQ,UAAU,WAAW,GACxE,MAAM,IAAI,sBACR,wGACF;CAGF,MAAM,EAAE,WAAW,SAAS,GAAG,SAAS;CAExC,OAAO;EACL;EACA,SAAS,MAAM,QAAQ,OAAO,IAAK,UAAwB,CAAC;EAC5D,cAAc,YAAY,IAAI;CAChC;AACF;;;;;;;;AASA,SAAS,YAAY,OAAiC;CACpD,IAAI,OAAO,UAAU,UACnB,OAAO;CAGT,IAAI,OAAO,UAAU,YAAY,UAAU,MACzC,OAAO;CAGT,IAAI,UAAU,UAAa,UAAU,MACnC,OAAO;CAGT,OAAO,OAAO,KAAK;AACrB"}
1
+ {"version":3,"file":"as-tool.mjs","names":[],"sources":["../../../../../../../ai/src/orchestrator/as-tool.ts"],"sourcesContent":["import type { Message } from \"../contracts/conversation-message.type\";\nimport type {\n OrchestratorAsToolOptions,\n OrchestratorContract,\n OrchestratorToolSession,\n} from \"../contracts/orchestrator/orchestrator.contract\";\nimport type { SupervisorInput } from \"../contracts/supervisor/supervisor-input.type\";\nimport type { ToolContext } from \"../contracts/tool.contract\";\nimport { SupervisorFailedError } from \"../errors\";\nimport { compositeAsTool, type ToolContract } from \"../tool/tool\";\nimport { generateRunId } from \"../utils/generate-run-id\";\n\n/**\n * Shape read out of the validated tool input ONLY under the\n * `unsafeAllowModelSessionId` opt-in — the legacy, model-chosen session\n * path. Everything else on the payload becomes the orchestrator's\n * `execute(input)` argument.\n */\ntype SharedScopePayload = {\n sessionId?: unknown;\n history?: unknown;\n [key: string]: unknown;\n};\n\n/** Resolved per-call session binding for one tool invocation. */\ntype ResolvedToolSession = {\n sessionId: string;\n history: Message[];\n executeInput: SupervisorInput;\n};\n\n/**\n * Wrap an {@link OrchestratorContract} as a {@link ToolContract} so an\n * outer agent can invoke it from its tool-call loop (design §13).\n * Mirrors `supervisor.asTool()` — same `compositeAsTool` composition and\n * error normalization — and adds `sessionScope`.\n *\n * The boundary is OPAQUE (§13, §18.6): the parent's `context` / events do\n * NOT auto-forward. Per-call data the wrapped orchestrator needs rides on\n * the tool's `inputSchema` payload — with ONE deliberate exception, the\n * session binding below, because the payload is written by an LLM.\n *\n * Session continuity:\n * - `\"fresh\"` (default) — each invocation gets a brand-new `sessionId`\n * (a generated id) and empty history; the session lives only for this\n * tool call. The whole validated payload is forwarded as the\n * orchestrator's `execute(input)` argument.\n * - `\"shared\"` — the orchestrator joins an EXISTING session named by the\n * developer through `options.session`: either a literal id fixed at\n * construction, or a resolver that reads the invocation's\n * {@link ToolContext} (`ctx.artifacts`, the out-of-band bag the model\n * cannot write to). The whole validated payload is forwarded as\n * `execute(input)`. A `\"shared\"` tool built without `session` throws at\n * construction.\n *\n * **Why the session id is not a schema field (4.15.0 security fix).**\n * Before this release, `\"shared\"` scope read `sessionId` straight out of\n * the model-generated tool arguments. A `sessionId` is bearer-equivalent\n * — naming one grants read/write on that session's persisted state — so\n * any prompt injection reaching the outer agent (\"continue session\n * `<victim-id>`\") made the nested orchestrator load a stranger's\n * conversation, mutate it, and echo its content back into the attacker's\n * transcript. The binding now lives on channels the model has no access\n * to. The old behavior survives only behind the loudly-named\n * `unsafeAllowModelSessionId` opt-in.\n *\n * On `result.error`, the typed orchestrator error is thrown so the tool\n * wrapper produces a `ToolExecutionError` with `cause` preserved — the\n * outer agent sees one uniform error class.\n *\n * @example\n * const support = ai.orchestrator({ name: \"refund-support\", intents });\n *\n * // Fresh session per call — no continuity, nothing to hijack.\n * const supportTool = support.asTool({\n * name: \"handle_refund\",\n * description: \"Handle a refund conversation end-to-end.\",\n * inputSchema: v.object({ message: v.string() }),\n * });\n *\n * // Continuous session — bound from the authenticated request, never\n * // from the model's arguments.\n * const continuousTool = support.asTool({\n * name: \"handle_refund\",\n * inputSchema: v.object({ message: v.string() }),\n * sessionScope: \"shared\",\n * session: (ctx) => ({\n * sessionId: String(ctx?.artifacts?.refundSessionId ?? \"\"),\n * }),\n * });\n */\nexport function asTool<TOutput, TState, TToolInput>(\n orchestrator: OrchestratorContract<TOutput, TState>,\n options: OrchestratorAsToolOptions<TToolInput>,\n): ToolContract<TToolInput, TOutput> {\n if (!orchestrator.name || typeof orchestrator.name !== \"string\") {\n throw new SupervisorFailedError(\n \"orchestrator.asTool(): orchestrator must have a `name` to be wrapped as a tool\",\n );\n }\n\n const sessionScope = options.sessionScope ?? \"fresh\";\n const allowModelSessionId = options.unsafeAllowModelSessionId === true;\n\n // Fail closed at construction, not at the first hostile tool call: a\n // \"shared\" tool with no developer-supplied binding would have to fall\n // back to the model's payload, which is exactly the hijack path.\n if (sessionScope === \"shared\" && !options.session && !allowModelSessionId) {\n throw new SupervisorFailedError(\n 'orchestrator.asTool(): sessionScope \"shared\" requires a `session` binding — ' +\n \"a session id fixed at construction, or a `(ctx) => sessionId` resolver reading the \" +\n \"tool context. A model-supplied `sessionId` in the tool payload is bearer-equivalent \" +\n \"access to that session; pass `unsafeAllowModelSessionId: true` only if the outer \" +\n \"agent's context is trusted and you verify session ownership yourself\",\n );\n }\n\n return compositeAsTool<TToolInput, TOutput>({\n name: options.name ?? orchestrator.name,\n description:\n options.description ??\n `Invoke orchestrator \"${orchestrator.name}\" as a tool.`,\n input: options.inputSchema,\n execute: async (input, ctx) => {\n const { sessionId, history, executeInput } = await resolveSession(\n sessionScope,\n input,\n ctx,\n options.session,\n allowModelSessionId,\n );\n\n const result = await orchestrator.execute(executeInput, {\n sessionId,\n history,\n });\n\n if (result.error) {\n // Surface the typed orchestrator error — the outer ToolContract\n // wraps it as a ToolExecutionError with `cause` preserved.\n throw result.error;\n }\n\n return {\n data: result.data as TOutput,\n usage: result.usage,\n report: result.report,\n };\n },\n });\n}\n\n/**\n * Resolve the per-call `sessionId`, `history`, and the `execute(input)`\n * argument, according to `sessionScope`.\n *\n * For `\"shared\"` scope the session comes from the developer's `session`\n * binding (construction-time literal or `ToolContext` resolver) — the\n * validated payload is never consulted for it unless the caller opted\n * into `unsafeAllowModelSessionId`. Either way `sessionId` / `history`\n * are stripped from the payload before it is forwarded as\n * `execute(input)`, so a model-authored field of that name can't reach\n * the orchestrator's input under a misleading name.\n */\nasync function resolveSession(\n sessionScope: \"fresh\" | \"shared\",\n input: unknown,\n ctx: ToolContext | undefined,\n session: OrchestratorToolSession | undefined,\n allowModelSessionId: boolean,\n): Promise<ResolvedToolSession> {\n if (sessionScope === \"fresh\") {\n return {\n sessionId: generateRunId(\"session\"),\n history: [],\n executeInput: coerceInput(input),\n };\n }\n\n const payload = (\n typeof input === \"object\" && input !== null ? input : {}\n ) as SharedScopePayload;\n\n const { sessionId: payloadSessionId, history: payloadHistory, ...rest } = payload;\n const executeInput = coerceInput(rest);\n\n if (session !== undefined) {\n const bound = typeof session === \"function\" ? await session(ctx) : session;\n\n const sessionId = typeof bound === \"string\" ? bound : bound?.sessionId;\n const history = typeof bound === \"string\" ? undefined : bound?.history;\n\n if (typeof sessionId !== \"string\" || sessionId.length === 0) {\n throw new SupervisorFailedError(\n 'orchestrator.asTool(): the `session` binding for sessionScope \"shared\" resolved to no ' +\n \"session id — return a non-empty string (or `{ sessionId }`) from it, or throw to \" +\n \"reject the call. The model's payload is never used as a fallback\",\n );\n }\n\n return {\n sessionId,\n history: Array.isArray(history) ? history : [],\n executeInput,\n };\n }\n\n // Legacy, explicitly opted-in path: the session id is whatever the\n // calling model wrote. Anything that can influence that model chooses\n // the session — see `unsafeAllowModelSessionId`.\n if (!allowModelSessionId) {\n throw new SupervisorFailedError(\n 'orchestrator.asTool(): sessionScope \"shared\" requires a `session` binding',\n );\n }\n\n if (typeof payloadSessionId !== \"string\" || payloadSessionId.length === 0) {\n throw new SupervisorFailedError(\n 'orchestrator.asTool(): sessionScope \"shared\" requires a `sessionId` string in the tool input payload',\n );\n }\n\n return {\n sessionId: payloadSessionId,\n history: Array.isArray(payloadHistory) ? (payloadHistory as Message[]) : [],\n executeInput,\n };\n}\n\n/**\n * Coerce a tool-input value into the `SupervisorInput` shape the\n * orchestrator's `execute()` accepts (`string | Record<string,\n * unknown>`). Strings and plain objects pass through; everything else\n * is JSON-stringified so the orchestrator receives a predictable input\n * regardless of how the outer agent shaped its call.\n */\nfunction coerceInput(value: unknown): SupervisorInput {\n if (typeof value === \"string\") {\n return value;\n }\n\n if (typeof value === \"object\" && value !== null) {\n return value as Record<string, unknown>;\n }\n\n if (value === undefined || value === null) {\n return \"\";\n }\n\n return String(value);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2FA,SAAgB,OACd,cACA,SACmC;CACnC,IAAI,CAAC,aAAa,QAAQ,OAAO,aAAa,SAAS,UACrD,MAAM,IAAI,sBACR,gFACF;CAGF,MAAM,eAAe,QAAQ,gBAAgB;CAC7C,MAAM,sBAAsB,QAAQ,8BAA8B;CAKlE,IAAI,iBAAiB,YAAY,CAAC,QAAQ,WAAW,CAAC,qBACpD,MAAM,IAAI,sBACR,4YAKF;CAGF,OAAO,gBAAqC;EAC1C,MAAM,QAAQ,QAAQ,aAAa;EACnC,aACE,QAAQ,eACR,wBAAwB,aAAa,KAAK;EAC5C,OAAO,QAAQ;EACf,SAAS,OAAO,OAAO,QAAQ;GAC7B,MAAM,EAAE,WAAW,SAAS,iBAAiB,MAAM,eACjD,cACA,OACA,KACA,QAAQ,SACR,mBACF;GAEA,MAAM,SAAS,MAAM,aAAa,QAAQ,cAAc;IACtD;IACA;GACF,CAAC;GAED,IAAI,OAAO,OAGT,MAAM,OAAO;GAGf,OAAO;IACL,MAAM,OAAO;IACb,OAAO,OAAO;IACd,QAAQ,OAAO;GACjB;EACF;CACF,CAAC;AACH;;;;;;;;;;;;;AAcA,eAAe,eACb,cACA,OACA,KACA,SACA,qBAC8B;CAC9B,IAAI,iBAAiB,SACnB,OAAO;EACL,WAAW,cAAc,SAAS;EAClC,SAAS,CAAC;EACV,cAAc,YAAY,KAAK;CACjC;CAOF,MAAM,EAAE,WAAW,kBAAkB,SAAS,gBAAgB,GAAG,SAH/D,OAAO,UAAU,YAAY,UAAU,OAAO,QAAQ,CAAC;CAIzD,MAAM,eAAe,YAAY,IAAI;CAErC,IAAI,YAAY,QAAW;EACzB,MAAM,QAAQ,OAAO,YAAY,aAAa,MAAM,QAAQ,GAAG,IAAI;EAEnE,MAAM,YAAY,OAAO,UAAU,WAAW,QAAQ,OAAO;EAC7D,MAAM,UAAU,OAAO,UAAU,WAAW,SAAY,OAAO;EAE/D,IAAI,OAAO,cAAc,YAAY,UAAU,WAAW,GACxD,MAAM,IAAI,sBACR,2OAGF;EAGF,OAAO;GACL;GACA,SAAS,MAAM,QAAQ,OAAO,IAAI,UAAU,CAAC;GAC7C;EACF;CACF;CAKA,IAAI,CAAC,qBACH,MAAM,IAAI,sBACR,6EACF;CAGF,IAAI,OAAO,qBAAqB,YAAY,iBAAiB,WAAW,GACtE,MAAM,IAAI,sBACR,wGACF;CAGF,OAAO;EACL,WAAW;EACX,SAAS,MAAM,QAAQ,cAAc,IAAK,iBAA+B,CAAC;EAC1E;CACF;AACF;;;;;;;;AASA,SAAS,YAAY,OAAiC;CACpD,IAAI,OAAO,UAAU,UACnB,OAAO;CAGT,IAAI,OAAO,UAAU,YAAY,UAAU,MACzC,OAAO;CAGT,IAAI,UAAU,UAAa,UAAU,MACnC,OAAO;CAGT,OAAO,OAAO,KAAK;AACrB"}
@@ -1 +1 @@
1
- {"version":3,"file":"execution.d.mts","names":[],"sources":["../../../../../../../ai/src/orchestrator/execution.ts"],"mappings":";;;;;;;;;;;;;;AAqEA;;;;;;;;;;;KAAY,2BAAA;EACV,MAAA,EAAQ,kBAAA,CAAmB,OAAA,EAAS,MAAA,GAOY;EALhD,OAAA,GAAU,GAAA,SAAY,mBAAA;EACtB,SAAA;EACA,OAAA,EAAS,mBAAA;EACT,KAAA,GAAQ,eAAA;EACR,OAAA,GAAU,0BAAA,CAA2B,MAAA;EACrC,gBAAA,GAAmB,4BAAA,CAA6B,kBAAA,CAAmB,OAAA;EACnE,eAAA;EACA,aAAA,GAAgB,yBAAA;AAAA;;;;;;;;;;;;;;;;;;;;cAsBL,qBAAA;EAAA,iBACM,MAAA;EAAA,iBACA,GAAA;EAAA,iBACA,gBAAA;cAIE,MAAA,EAAQ,2BAAA,CAA4B,OAAA,EAAS,MAAA;EAAT;;;;;;EAyB1C,GAAA,IAAO,OAAA,CAAQ,kBAAA,CAAmB,OAAA;EAiChB;;;;EAAlB,MAAA,IAAU,OAAA,CAAQ,kBAAA,CAAmB,OAAA;EAiBxC;;;;;EAFG,OAAA,CACX,IAAA,EAAM,oBAAA,sBACL,OAAA,CAAQ,oBAAA;AAAA;;;;;;;;;;;;iBAuPS,OAAA,kBACpB,GAAA,EAAK,yBAAA,CAA0B,OAAA,EAAS,MAAA,GACxC,KAAA,EAAO,eAAA,EACP,OAAA,EAAS,0BAAA,CAA2B,MAAA,IACnC,OAAA,CAAQ,kBAAA,CAAmB,OAAA;;;;;;;;AA3PG;AAuPjC;;iBA4MsB,SAAA,kBACpB,GAAA,EAAK,yBAAA,CAA0B,OAAA,EAAS,MAAA,GACxC,SAAA,UACA,OAAA,GAAU,yBAAA,GACT,OAAA,CAAQ,kBAAA,CAAmB,OAAA;;;;;;;;;;;iBAiDR,UAAA,kBACpB,GAAA,EAAK,yBAAA,CAA0B,OAAA,EAAS,MAAA,GACxC,KAAA,EAAO,eAAA,EACP,OAAA,EAAS,0BAAA,CAA2B,MAAA,IACnC,OAAA,CAAQ,kBAAA,CAAmB,OAAA"}
1
+ {"version":3,"file":"execution.d.mts","names":[],"sources":["../../../../../../../ai/src/orchestrator/execution.ts"],"mappings":";;;;;;;;;;;;;;AAqEA;;;;;;;;;;;KAAY,2BAAA;EACV,MAAA,EAAQ,kBAAA,CAAmB,OAAA,EAAS,MAAA,GAOY;EALhD,OAAA,GAAU,GAAA,SAAY,mBAAA;EACtB,SAAA;EACA,OAAA,EAAS,mBAAA;EACT,KAAA,GAAQ,eAAA;EACR,OAAA,GAAU,0BAAA,CAA2B,MAAA;EACrC,gBAAA,GAAmB,4BAAA,CAA6B,kBAAA,CAAmB,OAAA;EACnE,eAAA;EACA,aAAA,GAAgB,yBAAA;AAAA;;;;;;;;;;;;;;;;;;;;cAsBL,qBAAA;EAAA,iBACM,MAAA;EAAA,iBACA,GAAA;EAAA,iBACA,gBAAA;cAIE,MAAA,EAAQ,2BAAA,CAA4B,OAAA,EAAS,MAAA;EAAT;;;;;;EAyB1C,GAAA,IAAO,OAAA,CAAQ,kBAAA,CAAmB,OAAA;EAiChB;;;;EAAlB,MAAA,IAAU,OAAA,CAAQ,kBAAA,CAAmB,OAAA;EAiBxC;;;;;EAFG,OAAA,CACX,IAAA,EAAM,oBAAA,sBACL,OAAA,CAAQ,oBAAA;AAAA;;;;;;;;;;;;iBAuPS,OAAA,kBACpB,GAAA,EAAK,yBAAA,CAA0B,OAAA,EAAS,MAAA,GACxC,KAAA,EAAO,eAAA,EACP,OAAA,EAAS,0BAAA,CAA2B,MAAA,IACnC,OAAA,CAAQ,kBAAA,CAAmB,OAAA;;;;;;;;AA3PG;AAuPjC;;iBAiNsB,SAAA,kBACpB,GAAA,EAAK,yBAAA,CAA0B,OAAA,EAAS,MAAA,GACxC,SAAA,UACA,OAAA,GAAU,yBAAA,GACT,OAAA,CAAQ,kBAAA,CAAmB,OAAA;;;;;;;;;;;iBAiDR,UAAA,kBACpB,GAAA,EAAK,yBAAA,CAA0B,OAAA,EAAS,MAAA,GACxC,KAAA,EAAO,eAAA,EACP,OAAA,EAAS,0BAAA,CAA2B,MAAA,IACnC,OAAA,CAAQ,kBAAA,CAAmB,OAAA"}
@@ -280,7 +280,7 @@ async function runTurn(ctx, input, options) {
280
280
  const seedState = applyStatePatch(loaded.state, options.state);
281
281
  let turnContext = options.context;
282
282
  if (ctx.memory) {
283
- const recalled = await recallForTurn(ctx.memory, input);
283
+ const recalled = await recallForTurn(ctx.memory, input, sessionId);
284
284
  turnContext = injectMemories(turnContext, ctx.memory, recalled);
285
285
  }
286
286
  const { result, state, turnSnapshot } = await dispatchTurn({
@@ -320,7 +320,7 @@ async function runTurn(ctx, input, options) {
320
320
  lastRoute: summarizeRoute(turnSnapshot.decision.raw),
321
321
  summarizedThrough: loaded.record?.summarized_through ?? null
322
322
  });
323
- if (ctx.memory) await rememberTurnOutcome(ctx.memory, input, outcomeTextFromTurn(result.data, turnSnapshot));
323
+ if (ctx.memory) await rememberTurnOutcome(ctx.memory, input, outcomeTextFromTurn(result.data, turnSnapshot), sessionId);
324
324
  let compaction;
325
325
  if (shouldCompact(ctx, loaded.turnIndex)) {
326
326
  const outcome = await runCompaction(ctx, sessionId, options.history);