@warlock.js/ai 4.15.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +183 -158
- package/cjs/index.cjs +637 -104
- package/cjs/index.cjs.map +1 -1
- package/esm/contracts/index.d.mts +2 -2
- package/esm/contracts/memory/index.d.mts +1 -1
- package/esm/contracts/memory/memory-config.type.d.mts +29 -3
- package/esm/contracts/memory/memory-config.type.d.mts.map +1 -1
- package/esm/contracts/memory/memory-item.type.d.mts +15 -1
- package/esm/contracts/memory/memory-item.type.d.mts.map +1 -1
- package/esm/contracts/memory/memory.contract.d.mts +15 -2
- package/esm/contracts/memory/memory.contract.d.mts.map +1 -1
- package/esm/contracts/memory/recall-options.type.d.mts +12 -0
- package/esm/contracts/memory/recall-options.type.d.mts.map +1 -1
- package/esm/contracts/orchestrator/index.d.mts +1 -1
- package/esm/contracts/orchestrator/orchestrator-config.type.d.mts +38 -1
- package/esm/contracts/orchestrator/orchestrator-config.type.d.mts.map +1 -1
- package/esm/contracts/orchestrator/orchestrator.contract.d.mts +67 -3
- package/esm/contracts/orchestrator/orchestrator.contract.d.mts.map +1 -1
- package/esm/contracts/supervisor/supervisor-config.type.d.mts +23 -0
- package/esm/contracts/supervisor/supervisor-config.type.d.mts.map +1 -1
- package/esm/index.d.mts +6 -5
- package/esm/index.mjs +3 -2
- package/esm/memory/episodic-memory.mjs +14 -6
- package/esm/memory/episodic-memory.mjs.map +1 -1
- package/esm/memory/index.d.mts +1 -1
- package/esm/memory/memory.d.mts +13 -1
- package/esm/memory/memory.d.mts.map +1 -1
- package/esm/memory/memory.mjs +41 -7
- package/esm/memory/memory.mjs.map +1 -1
- package/esm/memory/procedural-memory.mjs +20 -7
- package/esm/memory/procedural-memory.mjs.map +1 -1
- package/esm/memory/semantic-memory.mjs +27 -10
- package/esm/memory/semantic-memory.mjs.map +1 -1
- package/esm/memory/working-memory.mjs +70 -13
- package/esm/memory/working-memory.mjs.map +1 -1
- package/esm/middleware/builtins/semantic-cache.d.mts +46 -1
- package/esm/middleware/builtins/semantic-cache.d.mts.map +1 -1
- package/esm/middleware/builtins/semantic-cache.mjs +60 -15
- package/esm/middleware/builtins/semantic-cache.mjs.map +1 -1
- package/esm/middleware/index.d.mts +1 -1
- package/esm/orchestrator/as-tool.d.mts +35 -9
- package/esm/orchestrator/as-tool.d.mts.map +1 -1
- package/esm/orchestrator/as-tool.mjs +67 -19
- package/esm/orchestrator/as-tool.mjs.map +1 -1
- package/esm/orchestrator/execution.d.mts.map +1 -1
- package/esm/orchestrator/execution.mjs +2 -2
- package/esm/orchestrator/execution.mjs.map +1 -1
- package/esm/orchestrator/index.d.mts +1 -1
- package/esm/orchestrator/index.mjs +1 -1
- package/esm/orchestrator/memory.d.mts +41 -5
- package/esm/orchestrator/memory.d.mts.map +1 -1
- package/esm/orchestrator/memory.mjs +53 -5
- package/esm/orchestrator/memory.mjs.map +1 -1
- package/esm/planner/plan-schema.d.mts +3 -3
- package/esm/planner/plan-schema.d.mts.map +1 -1
- package/esm/planner/plan-schema.mjs +30 -0
- package/esm/planner/plan-schema.mjs.map +1 -1
- package/esm/security/index.mjs +1 -0
- package/esm/security/outbound-policy.d.mts +9 -0
- package/esm/security/outbound-policy.d.mts.map +1 -1
- package/esm/security/outbound-policy.mjs +79 -5
- package/esm/security/outbound-policy.mjs.map +1 -1
- package/esm/security/outbound-policy.type.d.mts +8 -0
- package/esm/security/outbound-policy.type.d.mts.map +1 -1
- package/esm/security/safe-merge.d.mts +52 -0
- package/esm/security/safe-merge.d.mts.map +1 -0
- package/esm/security/safe-merge.mjs +68 -0
- package/esm/security/safe-merge.mjs.map +1 -0
- package/esm/supervisor/decide.mjs +52 -5
- package/esm/supervisor/decide.mjs.map +1 -1
- package/esm/supervisor/execution.d.mts +22 -0
- package/esm/supervisor/execution.d.mts.map +1 -1
- package/esm/supervisor/execution.mjs +46 -9
- package/esm/supervisor/execution.mjs.map +1 -1
- package/esm/supervisor/supervisor.mjs +4 -0
- package/esm/supervisor/supervisor.mjs.map +1 -1
- package/llms-full.txt +174 -10
- package/llms.txt +4 -3
- package/package.json +4 -4
- package/skills/README.md +5 -1
- package/skills/attach-ai-middleware/SKILL.md +17 -1
- package/skills/rag-loaders-and-stores/SKILL.md +3 -0
- package/skills/run-ai-agent/SKILL.md +3 -0
- package/skills/run-orchestrator/SKILL.md +6 -1
- package/skills/run-planner/SKILL.md +7 -3
- package/skills/run-supervisor/SKILL.md +11 -1
- package/skills/secure-outbound-requests/SKILL.md +85 -0
- package/skills/use-ai-memory/SKILL.md +36 -3
- package/skills/use-runtime-skills/SKILL.md +2 -1
|
@@ -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 *
|
|
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":"
|
|
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
|
-
|
|
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
|
|
104
|
-
|
|
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
|
|
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
|
|
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 `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
21
|
-
* `
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
*
|
|
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":";;;;;;
|
|
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 `
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
23
|
-
* `
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
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(
|
|
79
|
-
executeInput
|
|
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;;
|
|
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);
|