@warlock.js/ai 4.14.0 → 4.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +182 -141
  2. package/cjs/index.cjs +640 -139
  3. package/cjs/index.cjs.map +1 -1
  4. package/esm/contracts/index.d.mts +3 -3
  5. package/esm/contracts/memory/index.d.mts +1 -1
  6. package/esm/contracts/memory/memory-config.type.d.mts +29 -3
  7. package/esm/contracts/memory/memory-config.type.d.mts.map +1 -1
  8. package/esm/contracts/memory/memory-item.type.d.mts +15 -1
  9. package/esm/contracts/memory/memory-item.type.d.mts.map +1 -1
  10. package/esm/contracts/memory/memory.contract.d.mts +15 -2
  11. package/esm/contracts/memory/memory.contract.d.mts.map +1 -1
  12. package/esm/contracts/memory/recall-options.type.d.mts +12 -0
  13. package/esm/contracts/memory/recall-options.type.d.mts.map +1 -1
  14. package/esm/contracts/orchestrator/index.d.mts +1 -1
  15. package/esm/contracts/orchestrator/orchestrator-config.type.d.mts +38 -1
  16. package/esm/contracts/orchestrator/orchestrator-config.type.d.mts.map +1 -1
  17. package/esm/contracts/orchestrator/orchestrator.contract.d.mts +67 -3
  18. package/esm/contracts/orchestrator/orchestrator.contract.d.mts.map +1 -1
  19. package/esm/contracts/supervisor/supervisor-config.type.d.mts +23 -0
  20. package/esm/contracts/supervisor/supervisor-config.type.d.mts.map +1 -1
  21. package/esm/contracts/team/team-config.type.d.mts +9 -6
  22. package/esm/contracts/team/team-config.type.d.mts.map +1 -1
  23. package/esm/contracts/tool.contract.d.mts +35 -3
  24. package/esm/contracts/tool.contract.d.mts.map +1 -1
  25. package/esm/index.d.mts +8 -7
  26. package/esm/index.mjs +3 -2
  27. package/esm/memory/episodic-memory.mjs +14 -6
  28. package/esm/memory/episodic-memory.mjs.map +1 -1
  29. package/esm/memory/index.d.mts +1 -1
  30. package/esm/memory/memory.d.mts +13 -1
  31. package/esm/memory/memory.d.mts.map +1 -1
  32. package/esm/memory/memory.mjs +41 -7
  33. package/esm/memory/memory.mjs.map +1 -1
  34. package/esm/memory/procedural-memory.mjs +20 -7
  35. package/esm/memory/procedural-memory.mjs.map +1 -1
  36. package/esm/memory/semantic-memory.mjs +27 -10
  37. package/esm/memory/semantic-memory.mjs.map +1 -1
  38. package/esm/memory/working-memory.mjs +70 -13
  39. package/esm/memory/working-memory.mjs.map +1 -1
  40. package/esm/middleware/builtins/semantic-cache.d.mts +46 -1
  41. package/esm/middleware/builtins/semantic-cache.d.mts.map +1 -1
  42. package/esm/middleware/builtins/semantic-cache.mjs +60 -15
  43. package/esm/middleware/builtins/semantic-cache.mjs.map +1 -1
  44. package/esm/middleware/index.d.mts +1 -1
  45. package/esm/mock/index.d.mts +1 -1
  46. package/esm/mock/mock-config.type.d.mts +33 -4
  47. package/esm/mock/mock-config.type.d.mts.map +1 -1
  48. package/esm/mock/mock-model.d.mts +2 -1
  49. package/esm/mock/mock-model.d.mts.map +1 -1
  50. package/esm/mock/mock-model.mjs +5 -4
  51. package/esm/mock/mock-model.mjs.map +1 -1
  52. package/esm/mock/mock-sdk.d.mts +11 -3
  53. package/esm/mock/mock-sdk.d.mts.map +1 -1
  54. package/esm/mock/mock-sdk.mjs.map +1 -1
  55. package/esm/orchestrator/as-tool.d.mts +35 -9
  56. package/esm/orchestrator/as-tool.d.mts.map +1 -1
  57. package/esm/orchestrator/as-tool.mjs +67 -19
  58. package/esm/orchestrator/as-tool.mjs.map +1 -1
  59. package/esm/orchestrator/execution.d.mts.map +1 -1
  60. package/esm/orchestrator/execution.mjs +2 -2
  61. package/esm/orchestrator/execution.mjs.map +1 -1
  62. package/esm/orchestrator/index.d.mts +1 -1
  63. package/esm/orchestrator/index.mjs +1 -1
  64. package/esm/orchestrator/memory.d.mts +41 -5
  65. package/esm/orchestrator/memory.d.mts.map +1 -1
  66. package/esm/orchestrator/memory.mjs +53 -5
  67. package/esm/orchestrator/memory.mjs.map +1 -1
  68. package/esm/planner/plan-schema.d.mts +15 -4
  69. package/esm/planner/plan-schema.d.mts.map +1 -1
  70. package/esm/planner/plan-schema.mjs +27 -16
  71. package/esm/planner/plan-schema.mjs.map +1 -1
  72. package/esm/security/index.mjs +1 -0
  73. package/esm/security/outbound-policy.d.mts +9 -0
  74. package/esm/security/outbound-policy.d.mts.map +1 -1
  75. package/esm/security/outbound-policy.mjs +79 -5
  76. package/esm/security/outbound-policy.mjs.map +1 -1
  77. package/esm/security/outbound-policy.type.d.mts +8 -0
  78. package/esm/security/outbound-policy.type.d.mts.map +1 -1
  79. package/esm/security/safe-merge.d.mts +52 -0
  80. package/esm/security/safe-merge.d.mts.map +1 -0
  81. package/esm/security/safe-merge.mjs +68 -0
  82. package/esm/security/safe-merge.mjs.map +1 -0
  83. package/esm/supervisor/decide.mjs +52 -5
  84. package/esm/supervisor/decide.mjs.map +1 -1
  85. package/esm/supervisor/execution.d.mts +22 -12
  86. package/esm/supervisor/execution.d.mts.map +1 -1
  87. package/esm/supervisor/execution.mjs +47 -24
  88. package/esm/supervisor/execution.mjs.map +1 -1
  89. package/esm/supervisor/supervisor.mjs +4 -0
  90. package/esm/supervisor/supervisor.mjs.map +1 -1
  91. package/llms-full.txt +185 -14
  92. package/llms.txt +5 -4
  93. package/package.json +24 -4
  94. package/skills/README.md +6 -2
  95. package/skills/attach-ai-middleware/SKILL.md +17 -1
  96. package/skills/generate-images/SKILL.md +11 -4
  97. package/skills/rag-loaders-and-stores/SKILL.md +3 -0
  98. package/skills/run-ai-agent/SKILL.md +3 -0
  99. package/skills/run-orchestrator/SKILL.md +6 -1
  100. package/skills/run-planner/SKILL.md +7 -3
  101. package/skills/run-supervisor/SKILL.md +11 -1
  102. package/skills/secure-outbound-requests/SKILL.md +85 -0
  103. package/skills/use-ai-memory/SKILL.md +36 -3
  104. package/skills/use-runtime-skills/SKILL.md +2 -1
@@ -2,7 +2,7 @@ import { SupervisorInput } from "../contracts/supervisor/supervisor-input.type.m
2
2
  import { TurnSnapshot } from "../contracts/result/orchestrator-result.type.mjs";
3
3
  import { RecalledMemory } from "../contracts/memory/memory-item.type.mjs";
4
4
  import { MemoryContract } from "../contracts/memory/memory.contract.mjs";
5
- import { OrchestratorMemoryConfig } from "../contracts/orchestrator/orchestrator-config.type.mjs";
5
+ import { OrchestratorMemoryConfig, OrchestratorMemoryScope } from "../contracts/orchestrator/orchestrator-config.type.mjs";
6
6
 
7
7
  //#region ../ai/src/orchestrator/memory.d.ts
8
8
  /**
@@ -18,7 +18,13 @@ type ResolvedOrchestratorMemory = {
18
18
  threshold?: number; /** Single-tier recall restriction. */
19
19
  tier?: ResolvedTier; /** Whether a clean turn writes its outcome back. Default `true`. */
20
20
  remember: boolean; /** Tier the remembered outcome lands in. Omit for the memory's `defaultTier`. */
21
- rememberTier?: ResolvedTier; /** Context-bag key the recalled memories are injected under. */
21
+ rememberTier?: ResolvedTier;
22
+ /**
23
+ * Isolation boundary for recall + write-back. Default `"session"` —
24
+ * the turn's `sessionId` keys every read and write, so one session
25
+ * cannot recall another's memories out of the shared store.
26
+ */
27
+ scope: OrchestratorMemoryScope; /** Context-bag key the recalled memories are injected under. */
22
28
  injectKey: string;
23
29
  };
24
30
  type ResolvedTier = NonNullable<OrchestratorMemoryConfig["recall"]>["tier"];
@@ -29,6 +35,28 @@ type ResolvedTier = NonNullable<OrchestratorMemoryConfig["recall"]>["tier"];
29
35
  * shape and the lifecycle phase stays branch-free.
30
36
  */
31
37
  declare function resolveOrchestratorMemory(memory: MemoryContract | OrchestratorMemoryConfig | undefined): ResolvedOrchestratorMemory | undefined;
38
+ /**
39
+ * Resolve the isolation key a turn reads and writes memories under
40
+ * (4.15.0 — security fix for cross-session recall).
41
+ *
42
+ * The memory store is resolved once per orchestrator instance and reused
43
+ * by every session, so this — not the store — is what keeps one session's
44
+ * remembered turns out of another's recall. It is derived from the
45
+ * execute-time `sessionId` by the engine and handed to every tier as an
46
+ * exact-match filter; the model, the tool payload, and the per-call
47
+ * `context` bag have no say in it.
48
+ *
49
+ * `"shared"` resolves to `undefined`, i.e. the store's unscoped pool —
50
+ * the explicit opt-in back to pre-4.15.0 cross-session behavior, which
51
+ * also keeps memories written before this release readable.
52
+ */
53
+ declare function memoryScopeFor(memory: ResolvedOrchestratorMemory, sessionId: string): string | undefined;
54
+ /**
55
+ * The default `"session"` scope key: the session id under a reserved
56
+ * prefix, so a custom `scope` callback returning a bare tenant id can
57
+ * never accidentally collide with a session-scoped pool.
58
+ */
59
+ declare function sessionMemoryScope(sessionId: string): string;
32
60
  /**
33
61
  * Coerce a turn's {@link SupervisorInput} (string or structured object)
34
62
  * into the natural-language query the memory store recalls / embeds
@@ -44,8 +72,12 @@ declare function memoryQueryFromInput(input: SupervisorInput): string;
44
72
  * `memory.injectKey`. Returns an empty array — never throws on "no hits"
45
73
  * — and short-circuits when `k === 0` (recall disabled / write-only
46
74
  * memory) so a write-only config never round-trips the embedder.
75
+ *
76
+ * The recall is confined to the calling session's scope (see
77
+ * {@link memoryScopeFor}) — `sessionId` is required, not optional, so a
78
+ * new call site cannot silently recall across every session.
47
79
  */
48
- declare function recallForTurn(memory: ResolvedOrchestratorMemory, input: SupervisorInput): Promise<RecalledMemory[]>;
80
+ declare function recallForTurn(memory: ResolvedOrchestratorMemory, input: SupervisorInput, sessionId: string): Promise<RecalledMemory[]>;
49
81
  /**
50
82
  * Merge the recalled memories into a fresh per-turn context bag under
51
83
  * `memory.injectKey` (memory core M2 — the injection half). Never
@@ -68,8 +100,12 @@ declare function injectMemories(context: Record<string, unknown> | undefined, me
68
100
  * The remembered text is the turn input followed by the model's textual
69
101
  * outcome when one is available, so a later `recall` keyed on a similar
70
102
  * input surfaces both the prior question and its answer.
103
+ *
104
+ * The write is tagged with the calling session's scope (see
105
+ * {@link memoryScopeFor}) so only that session recalls it later —
106
+ * turn text routinely contains one user's private content.
71
107
  */
72
- declare function rememberTurnOutcome(memory: ResolvedOrchestratorMemory, input: SupervisorInput, outcomeText: string | undefined): Promise<void>;
108
+ declare function rememberTurnOutcome(memory: ResolvedOrchestratorMemory, input: SupervisorInput, outcomeText: string | undefined, sessionId: string): Promise<void>;
73
109
  /**
74
110
  * Derive a turn's textual outcome for remembering (memory core M2).
75
111
  * Prefers the validated `result.data` (an `output` schema reshaped it);
@@ -80,5 +116,5 @@ declare function rememberTurnOutcome(memory: ResolvedOrchestratorMemory, input:
80
116
  */
81
117
  declare function outcomeTextFromTurn(data: unknown, turnSnapshot: TurnSnapshot): string | undefined;
82
118
  //#endregion
83
- export { ResolvedOrchestratorMemory, injectMemories, memoryQueryFromInput, outcomeTextFromTurn, recallForTurn, rememberTurnOutcome, resolveOrchestratorMemory };
119
+ export { ResolvedOrchestratorMemory, injectMemories, memoryQueryFromInput, memoryScopeFor, outcomeTextFromTurn, recallForTurn, rememberTurnOutcome, resolveOrchestratorMemory, sessionMemoryScope };
84
120
  //# sourceMappingURL=memory.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"memory.d.mts","names":[],"sources":["../../../../../../../ai/src/orchestrator/memory.ts"],"mappings":";;;;;;;;;AAmBA;;;;;KAAY,0BAAA;EAYiB,yEAV3B,KAAA,EAAO,cAAA,EAAP;EAEA,CAAA;EAEA,SAAA,WAEA;EAAA,IAAA,GAAO,YAAA,EAEP;EAAA,QAAA,WAEe;EAAf,YAAA,GAAe,YAAA,EAEN;EAAT,SAAA;AAAA;AAAA,KAGG,YAAA,GAAe,WAAW,CAAC,wBAAA;;;AAAwB;AAoBxD;;;iBAAgB,yBAAA,CACd,MAAA,EAAQ,cAAA,GAAiB,wBAAA,eACxB,0BAAA;;;;;;;;iBA+Ba,oBAAA,CAAqB,KAAsB,EAAf,eAAe;;AA/B9B;AA+B7B;;;;AAA2D;AAY3D;iBAAsB,aAAA,CACpB,MAAA,EAAQ,0BAAA,EACR,KAAA,EAAO,eAAA,GACN,OAAA,CAAQ,cAAA;;;;;;;;;;;;;iBAwBK,cAAA,CACd,OAAA,EAAS,MAAA,+BACT,MAAA,EAAQ,0BAAA,EACR,QAAA,EAAU,cAAA,KACT,MAAA;;AA5BsB;AAwBzB;;;;;;;;iBAsBsB,mBAAA,CACpB,MAAA,EAAQ,0BAAA,EACR,KAAA,EAAO,eAAA,EACP,WAAA,uBACC,OAAA;;;;;;;;;iBA4Ca,mBAAA,CACd,IAAA,WACA,YAAA,EAAc,YAAY"}
1
+ {"version":3,"file":"memory.d.mts","names":[],"sources":["../../../../../../../ai/src/orchestrator/memory.ts"],"mappings":";;;;;;;;;AA+BA;;;;;KAAY,0BAAA;EAkBH,yEAhBP,KAAA,EAAO,cAAA,EAgBuB;EAd9B,CAAA,WAFO;EAIP,SAAA;EAEA,IAAA,GAAO,YAAA,EAAA;EAEP,QAAA,WAEA;EAAA,YAAA,GAAe,YAAA;EAMf;;;;AAES;EAFT,KAAA,EAAO,uBAAA,EAKQ;EAHf,SAAA;AAAA;AAAA,KAGG,YAAA,GAAe,WAAW,CAAC,wBAAA;AAoBhC;;;;;;AAAA,iBAAgB,yBAAA,CACd,MAAA,EAAQ,cAAA,GAAiB,wBAAA,eACxB,0BAAA;;;;;;;AAA0B;AAyC7B;;;;;;;;iBAAgB,cAAA,CACd,MAAA,EAAQ,0BAA0B,EAClC,SAAA;AAkBF;;;;AAAoD;AAApD,iBAAgB,kBAAA,CAAmB,SAAiB;;;;AAWO;AAgB3D;;;iBAhBgB,oBAAA,CAAqB,KAAsB,EAAf,eAAe;;;;;;;;;;;;;iBAgBrC,aAAA,CACpB,MAAA,EAAQ,0BAAA,EACR,KAAA,EAAO,eAAA,EACP,SAAA,WACC,OAAA,CAAQ,cAAA;AAAc;AAyBzB;;;;;;;;;;;AAzByB,iBAyBT,cAAA,CACd,OAAA,EAAS,MAAA,+BACT,MAAA,EAAQ,0BAAA,EACR,QAAA,EAAU,cAAA,KACT,MAAA;;;;;;AAAM;AAsBT;;;;;;;;iBAAsB,mBAAA,CACpB,MAAA,EAAQ,0BAAA,EACR,KAAA,EAAO,eAAA,EACP,WAAA,sBACA,SAAA,WACC,OAAA;;;;;;;;AAAO;iBAgDM,mBAAA,CACd,IAAA,WACA,YAAA,EAAc,YAAY"}
@@ -2,6 +2,14 @@
2
2
  /** Default key the recalled memories are injected under in the context bag. */
3
3
  const DEFAULT_INJECT_KEY = "memories";
4
4
  /**
5
+ * Default isolation boundary: a turn recalls only what its own session
6
+ * remembered. Cross-session pooling is opt-in (`scope: "shared"`) — the
7
+ * default must not leak one user's remembered turns into another's
8
+ * context, since one memory store backs every session of an
9
+ * orchestrator instance.
10
+ */
11
+ const DEFAULT_SCOPE = "session";
12
+ /**
5
13
  * A `MemoryContract` is the bare-store form; anything carrying a `store`
6
14
  * is the {@link OrchestratorMemoryConfig} wrapper. Distinguished by the
7
15
  * presence of `recall` — a method on the contract, absent on the config
@@ -21,6 +29,7 @@ function resolveOrchestratorMemory(memory) {
21
29
  if (isBareMemory(memory)) return {
22
30
  store: memory,
23
31
  remember: true,
32
+ scope: DEFAULT_SCOPE,
24
33
  injectKey: DEFAULT_INJECT_KEY
25
34
  };
26
35
  return {
@@ -30,10 +39,39 @@ function resolveOrchestratorMemory(memory) {
30
39
  tier: memory.recall?.tier,
31
40
  remember: memory.remember ?? true,
32
41
  rememberTier: memory.rememberTier,
42
+ scope: memory.scope ?? DEFAULT_SCOPE,
33
43
  injectKey: memory.injectKey ?? DEFAULT_INJECT_KEY
34
44
  };
35
45
  }
36
46
  /**
47
+ * Resolve the isolation key a turn reads and writes memories under
48
+ * (4.15.0 — security fix for cross-session recall).
49
+ *
50
+ * The memory store is resolved once per orchestrator instance and reused
51
+ * by every session, so this — not the store — is what keeps one session's
52
+ * remembered turns out of another's recall. It is derived from the
53
+ * execute-time `sessionId` by the engine and handed to every tier as an
54
+ * exact-match filter; the model, the tool payload, and the per-call
55
+ * `context` bag have no say in it.
56
+ *
57
+ * `"shared"` resolves to `undefined`, i.e. the store's unscoped pool —
58
+ * the explicit opt-in back to pre-4.15.0 cross-session behavior, which
59
+ * also keeps memories written before this release readable.
60
+ */
61
+ function memoryScopeFor(memory, sessionId) {
62
+ if (memory.scope === "shared") return;
63
+ if (typeof memory.scope === "function") return memory.scope(sessionId);
64
+ return sessionMemoryScope(sessionId);
65
+ }
66
+ /**
67
+ * The default `"session"` scope key: the session id under a reserved
68
+ * prefix, so a custom `scope` callback returning a bare tenant id can
69
+ * never accidentally collide with a session-scoped pool.
70
+ */
71
+ function sessionMemoryScope(sessionId) {
72
+ return `session:${sessionId}`;
73
+ }
74
+ /**
37
75
  * Coerce a turn's {@link SupervisorInput} (string or structured object)
38
76
  * into the natural-language query the memory store recalls / embeds
39
77
  * against. Strings pass through; objects are JSON-serialized — the same
@@ -50,13 +88,18 @@ function memoryQueryFromInput(input) {
50
88
  * `memory.injectKey`. Returns an empty array — never throws on "no hits"
51
89
  * — and short-circuits when `k === 0` (recall disabled / write-only
52
90
  * memory) so a write-only config never round-trips the embedder.
91
+ *
92
+ * The recall is confined to the calling session's scope (see
93
+ * {@link memoryScopeFor}) — `sessionId` is required, not optional, so a
94
+ * new call site cannot silently recall across every session.
53
95
  */
54
- async function recallForTurn(memory, input) {
96
+ async function recallForTurn(memory, input, sessionId) {
55
97
  if (memory.k === 0) return [];
56
98
  return memory.store.recall(memoryQueryFromInput(input), {
57
99
  k: memory.k,
58
100
  threshold: memory.threshold,
59
- tier: memory.tier
101
+ tier: memory.tier,
102
+ scope: memoryScopeFor(memory, sessionId)
60
103
  });
61
104
  }
62
105
  /**
@@ -87,14 +130,19 @@ function injectMemories(context, memory, recalled) {
87
130
  * The remembered text is the turn input followed by the model's textual
88
131
  * outcome when one is available, so a later `recall` keyed on a similar
89
132
  * input surfaces both the prior question and its answer.
133
+ *
134
+ * The write is tagged with the calling session's scope (see
135
+ * {@link memoryScopeFor}) so only that session recalls it later —
136
+ * turn text routinely contains one user's private content.
90
137
  */
91
- async function rememberTurnOutcome(memory, input, outcomeText) {
138
+ async function rememberTurnOutcome(memory, input, outcomeText, sessionId) {
92
139
  if (!memory.remember) return;
93
140
  const text = buildOutcomeText(input, outcomeText);
94
141
  if (!text) return;
95
142
  const item = {
96
143
  text,
97
- tier: memory.rememberTier
144
+ tier: memory.rememberTier,
145
+ scope: memoryScopeFor(memory, sessionId)
98
146
  };
99
147
  await memory.store.remember(item);
100
148
  }
@@ -137,5 +185,5 @@ function stringifyOutcome(value) {
137
185
  }
138
186
 
139
187
  //#endregion
140
- export { injectMemories, memoryQueryFromInput, outcomeTextFromTurn, recallForTurn, rememberTurnOutcome, resolveOrchestratorMemory };
188
+ export { injectMemories, memoryQueryFromInput, memoryScopeFor, outcomeTextFromTurn, recallForTurn, rememberTurnOutcome, resolveOrchestratorMemory, sessionMemoryScope };
141
189
  //# sourceMappingURL=memory.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"memory.mjs","names":[],"sources":["../../../../../../../ai/src/orchestrator/memory.ts"],"sourcesContent":["import type { MemoryContract } from \"../contracts/memory/memory.contract\";\nimport type {\n MemoryItem,\n RecalledMemory,\n} from \"../contracts/memory/memory-item.type\";\nimport type { OrchestratorMemoryConfig } from \"../contracts/orchestrator/orchestrator-config.type\";\nimport type { TurnSnapshot } from \"../contracts/result/orchestrator-result.type\";\nimport type { SupervisorInput } from \"../contracts/supervisor/supervisor-input.type\";\n\n/** Default key the recalled memories are injected under in the context bag. */\nconst DEFAULT_INJECT_KEY = \"memories\";\n\n/**\n * Memory wiring resolved once per turn from `OrchestratorConfig.memory`\n * (memory core M2). Normalizes the two accepted config shapes — a bare\n * {@link MemoryContract} or the richer {@link OrchestratorMemoryConfig} —\n * into a single flat record the lifecycle phase reads, so `runTurn` never\n * branches on which form the dev supplied.\n */\nexport type ResolvedOrchestratorMemory = {\n /** The store recalled-from before dispatch and remembered-into after. */\n store: MemoryContract;\n /** Recall count cap; `0` disables recall (write-only memory). */\n k?: number;\n /** Semantic-similarity floor for recall. */\n threshold?: number;\n /** Single-tier recall restriction. */\n tier?: ResolvedTier;\n /** Whether a clean turn writes its outcome back. Default `true`. */\n remember: boolean;\n /** Tier the remembered outcome lands in. Omit for the memory's `defaultTier`. */\n rememberTier?: ResolvedTier;\n /** Context-bag key the recalled memories are injected under. */\n injectKey: string;\n};\n\ntype ResolvedTier = NonNullable<OrchestratorMemoryConfig[\"recall\"]>[\"tier\"];\n\n/**\n * A `MemoryContract` is the bare-store form; anything carrying a `store`\n * is the {@link OrchestratorMemoryConfig} wrapper. Distinguished by the\n * presence of `recall` — a method on the contract, absent on the config\n * (whose own `recall` is a plain options object, never a function).\n */\nfunction isBareMemory(\n value: MemoryContract | OrchestratorMemoryConfig,\n): value is MemoryContract {\n return typeof (value as MemoryContract).recall === \"function\";\n}\n\n/**\n * Normalize `OrchestratorConfig.memory` into {@link ResolvedOrchestratorMemory},\n * or `undefined` when no memory is configured. Centralizes the\n * bare-store-vs-config distinction so the engine context carries one\n * shape and the lifecycle phase stays branch-free.\n */\nexport function resolveOrchestratorMemory(\n memory: MemoryContract | OrchestratorMemoryConfig | undefined,\n): ResolvedOrchestratorMemory | undefined {\n if (!memory) {\n return undefined;\n }\n\n if (isBareMemory(memory)) {\n return {\n store: memory,\n remember: true,\n injectKey: DEFAULT_INJECT_KEY,\n };\n }\n\n return {\n store: memory.store,\n k: memory.recall?.k,\n threshold: memory.recall?.threshold,\n tier: memory.recall?.tier,\n remember: memory.remember ?? true,\n rememberTier: memory.rememberTier,\n injectKey: memory.injectKey ?? DEFAULT_INJECT_KEY,\n };\n}\n\n/**\n * Coerce a turn's {@link SupervisorInput} (string or structured object)\n * into the natural-language query the memory store recalls / embeds\n * against. Strings pass through; objects are JSON-serialized — the same\n * coercion the supervisor applies when forwarding an object input to a\n * child agent without an explicit `input(ctx)` override.\n */\nexport function memoryQueryFromInput(input: SupervisorInput): string {\n return typeof input === \"string\" ? input : JSON.stringify(input);\n}\n\n/**\n * Recall the memories relevant to a turn's input (memory core M2 — the\n * pre-dispatch half). Returns the scored {@link RecalledMemory}[] the\n * lifecycle injects into the turn's `context` bag under\n * `memory.injectKey`. Returns an empty array — never throws on \"no hits\"\n * — and short-circuits when `k === 0` (recall disabled / write-only\n * memory) so a write-only config never round-trips the embedder.\n */\nexport async function recallForTurn(\n memory: ResolvedOrchestratorMemory,\n input: SupervisorInput,\n): Promise<RecalledMemory[]> {\n if (memory.k === 0) {\n return [];\n }\n\n return memory.store.recall(memoryQueryFromInput(input), {\n k: memory.k,\n threshold: memory.threshold,\n tier: memory.tier,\n });\n}\n\n/**\n * Merge the recalled memories into a fresh per-turn context bag under\n * `memory.injectKey` (memory core M2 — the injection half). Never\n * mutates the caller's `context` object — returns a new bag (or the\n * original when there is nothing to inject) so the request-scoped input\n * stays immutable, and the supervisor's intake (which freezes a\n * shallow copy) sees the recalled set on every `ctx.context[injectKey]`.\n *\n * A pre-existing value at `injectKey` is preserved when recall produced\n * nothing, and overwritten with the recalled set otherwise — the\n * orchestrator owns that key once memory is configured.\n */\nexport function injectMemories(\n context: Record<string, unknown> | undefined,\n memory: ResolvedOrchestratorMemory,\n recalled: RecalledMemory[],\n): Record<string, unknown> | undefined {\n if (recalled.length === 0) {\n return context;\n }\n\n return { ...(context ?? {}), [memory.injectKey]: recalled };\n}\n\n/**\n * Remember a settled turn's outcome (memory core M2 — the post-dispatch\n * half). Called only after a clean turn (cancelled / failed turns revert\n * and never remember — §17). No-ops when `remember` is `false`\n * (read-only memory) or when the produced text is empty.\n *\n * The remembered text is the turn input followed by the model's textual\n * outcome when one is available, so a later `recall` keyed on a similar\n * input surfaces both the prior question and its answer.\n */\nexport async function rememberTurnOutcome(\n memory: ResolvedOrchestratorMemory,\n input: SupervisorInput,\n outcomeText: string | undefined,\n): Promise<void> {\n if (!memory.remember) {\n return;\n }\n\n const text = buildOutcomeText(input, outcomeText);\n\n if (!text) {\n return;\n }\n\n const item: MemoryItem = { text, tier: memory.rememberTier };\n\n await memory.store.remember(item);\n}\n\n/**\n * Compose the text written to memory for a turn: the input query, plus\n * the outcome text on a following line when the dispatch produced one.\n * Returns `undefined` when neither side carries content so an empty turn\n * never pollutes the store.\n */\nfunction buildOutcomeText(\n input: SupervisorInput,\n outcomeText: string | undefined,\n): string | undefined {\n const query = memoryQueryFromInput(input).trim();\n const outcome = outcomeText?.trim();\n\n if (query && outcome) {\n return `${query}\\n${outcome}`;\n }\n\n return query || outcome || undefined;\n}\n\n/**\n * Derive a turn's textual outcome for remembering (memory core M2).\n * Prefers the validated `result.data` (an `output` schema reshaped it);\n * otherwise stringifies the dispatched intents' branch outputs from the\n * turn snapshot, joined newline-wise so a multi-branch fan-out\n * contributes every output. Returns `undefined` when the turn produced\n * no usable text — the caller then remembers the input alone.\n */\nexport function outcomeTextFromTurn(\n data: unknown,\n turnSnapshot: TurnSnapshot,\n): string | undefined {\n const fromData = stringifyOutcome(data);\n\n if (fromData) {\n return fromData;\n }\n\n const outputs = Object.values(turnSnapshot.result)\n .map((branch) => stringifyOutcome(branch.output))\n .filter((text): text is string => Boolean(text));\n\n return outputs.length > 0 ? outputs.join(\"\\n\") : undefined;\n}\n\n/**\n * Coerce one outcome value to text: strings pass through; everything\n * else (objects, numbers) is JSON-serialized. `undefined` / `null` and\n * empty strings collapse to `undefined` so they don't masquerade as\n * content.\n */\nfunction stringifyOutcome(value: unknown): string | undefined {\n if (value === undefined || value === null) {\n return undefined;\n }\n\n const text = typeof value === \"string\" ? value : JSON.stringify(value);\n\n return text.trim() ? text : undefined;\n}\n"],"mappings":";;AAUA,MAAM,qBAAqB;;;;;;;AAkC3B,SAAS,aACP,OACyB;CACzB,OAAO,OAAQ,MAAyB,WAAW;AACrD;;;;;;;AAQA,SAAgB,0BACd,QACwC;CACxC,IAAI,CAAC,QACH;CAGF,IAAI,aAAa,MAAM,GACrB,OAAO;EACL,OAAO;EACP,UAAU;EACV,WAAW;CACb;CAGF,OAAO;EACL,OAAO,OAAO;EACd,GAAG,OAAO,QAAQ;EAClB,WAAW,OAAO,QAAQ;EAC1B,MAAM,OAAO,QAAQ;EACrB,UAAU,OAAO,YAAY;EAC7B,cAAc,OAAO;EACrB,WAAW,OAAO,aAAa;CACjC;AACF;;;;;;;;AASA,SAAgB,qBAAqB,OAAgC;CACnE,OAAO,OAAO,UAAU,WAAW,QAAQ,KAAK,UAAU,KAAK;AACjE;;;;;;;;;AAUA,eAAsB,cACpB,QACA,OAC2B;CAC3B,IAAI,OAAO,MAAM,GACf,OAAO,CAAC;CAGV,OAAO,OAAO,MAAM,OAAO,qBAAqB,KAAK,GAAG;EACtD,GAAG,OAAO;EACV,WAAW,OAAO;EAClB,MAAM,OAAO;CACf,CAAC;AACH;;;;;;;;;;;;;AAcA,SAAgB,eACd,SACA,QACA,UACqC;CACrC,IAAI,SAAS,WAAW,GACtB,OAAO;CAGT,OAAO;EAAE,GAAI,WAAW,CAAC;GAAK,OAAO,YAAY;CAAS;AAC5D;;;;;;;;;;;AAYA,eAAsB,oBACpB,QACA,OACA,aACe;CACf,IAAI,CAAC,OAAO,UACV;CAGF,MAAM,OAAO,iBAAiB,OAAO,WAAW;CAEhD,IAAI,CAAC,MACH;CAGF,MAAM,OAAmB;EAAE;EAAM,MAAM,OAAO;CAAa;CAE3D,MAAM,OAAO,MAAM,SAAS,IAAI;AAClC;;;;;;;AAQA,SAAS,iBACP,OACA,aACoB;CACpB,MAAM,QAAQ,qBAAqB,KAAK,CAAC,CAAC,KAAK;CAC/C,MAAM,UAAU,aAAa,KAAK;CAElC,IAAI,SAAS,SACX,OAAO,GAAG,MAAM,IAAI;CAGtB,OAAO,SAAS,WAAW;AAC7B;;;;;;;;;AAUA,SAAgB,oBACd,MACA,cACoB;CACpB,MAAM,WAAW,iBAAiB,IAAI;CAEtC,IAAI,UACF,OAAO;CAGT,MAAM,UAAU,OAAO,OAAO,aAAa,MAAM,CAAC,CAC/C,KAAK,WAAW,iBAAiB,OAAO,MAAM,CAAC,CAAC,CAChD,QAAQ,SAAyB,QAAQ,IAAI,CAAC;CAEjD,OAAO,QAAQ,SAAS,IAAI,QAAQ,KAAK,IAAI,IAAI;AACnD;;;;;;;AAQA,SAAS,iBAAiB,OAAoC;CAC5D,IAAI,UAAU,UAAa,UAAU,MACnC;CAGF,MAAM,OAAO,OAAO,UAAU,WAAW,QAAQ,KAAK,UAAU,KAAK;CAErE,OAAO,KAAK,KAAK,IAAI,OAAO;AAC9B"}
1
+ {"version":3,"file":"memory.mjs","names":[],"sources":["../../../../../../../ai/src/orchestrator/memory.ts"],"sourcesContent":["import type { MemoryContract } from \"../contracts/memory/memory.contract\";\nimport type {\n MemoryItem,\n RecalledMemory,\n} from \"../contracts/memory/memory-item.type\";\nimport type {\n OrchestratorMemoryConfig,\n OrchestratorMemoryScope,\n} from \"../contracts/orchestrator/orchestrator-config.type\";\nimport type { TurnSnapshot } from \"../contracts/result/orchestrator-result.type\";\nimport type { SupervisorInput } from \"../contracts/supervisor/supervisor-input.type\";\n\n/** Default key the recalled memories are injected under in the context bag. */\nconst DEFAULT_INJECT_KEY = \"memories\";\n\n/**\n * Default isolation boundary: a turn recalls only what its own session\n * remembered. Cross-session pooling is opt-in (`scope: \"shared\"`) — the\n * default must not leak one user's remembered turns into another's\n * context, since one memory store backs every session of an\n * orchestrator instance.\n */\nconst DEFAULT_SCOPE = \"session\" as const;\n\n/**\n * Memory wiring resolved once per turn from `OrchestratorConfig.memory`\n * (memory core M2). Normalizes the two accepted config shapes — a bare\n * {@link MemoryContract} or the richer {@link OrchestratorMemoryConfig} —\n * into a single flat record the lifecycle phase reads, so `runTurn` never\n * branches on which form the dev supplied.\n */\nexport type ResolvedOrchestratorMemory = {\n /** The store recalled-from before dispatch and remembered-into after. */\n store: MemoryContract;\n /** Recall count cap; `0` disables recall (write-only memory). */\n k?: number;\n /** Semantic-similarity floor for recall. */\n threshold?: number;\n /** Single-tier recall restriction. */\n tier?: ResolvedTier;\n /** Whether a clean turn writes its outcome back. Default `true`. */\n remember: boolean;\n /** Tier the remembered outcome lands in. Omit for the memory's `defaultTier`. */\n rememberTier?: ResolvedTier;\n /**\n * Isolation boundary for recall + write-back. Default `\"session\"` —\n * the turn's `sessionId` keys every read and write, so one session\n * cannot recall another's memories out of the shared store.\n */\n scope: OrchestratorMemoryScope;\n /** Context-bag key the recalled memories are injected under. */\n injectKey: string;\n};\n\ntype ResolvedTier = NonNullable<OrchestratorMemoryConfig[\"recall\"]>[\"tier\"];\n\n/**\n * A `MemoryContract` is the bare-store form; anything carrying a `store`\n * is the {@link OrchestratorMemoryConfig} wrapper. Distinguished by the\n * presence of `recall` — a method on the contract, absent on the config\n * (whose own `recall` is a plain options object, never a function).\n */\nfunction isBareMemory(\n value: MemoryContract | OrchestratorMemoryConfig,\n): value is MemoryContract {\n return typeof (value as MemoryContract).recall === \"function\";\n}\n\n/**\n * Normalize `OrchestratorConfig.memory` into {@link ResolvedOrchestratorMemory},\n * or `undefined` when no memory is configured. Centralizes the\n * bare-store-vs-config distinction so the engine context carries one\n * shape and the lifecycle phase stays branch-free.\n */\nexport function resolveOrchestratorMemory(\n memory: MemoryContract | OrchestratorMemoryConfig | undefined,\n): ResolvedOrchestratorMemory | undefined {\n if (!memory) {\n return undefined;\n }\n\n if (isBareMemory(memory)) {\n return {\n store: memory,\n remember: true,\n scope: DEFAULT_SCOPE,\n injectKey: DEFAULT_INJECT_KEY,\n };\n }\n\n return {\n store: memory.store,\n k: memory.recall?.k,\n threshold: memory.recall?.threshold,\n tier: memory.recall?.tier,\n remember: memory.remember ?? true,\n rememberTier: memory.rememberTier,\n scope: memory.scope ?? DEFAULT_SCOPE,\n injectKey: memory.injectKey ?? DEFAULT_INJECT_KEY,\n };\n}\n\n/**\n * Resolve the isolation key a turn reads and writes memories under\n * (4.15.0 — security fix for cross-session recall).\n *\n * The memory store is resolved once per orchestrator instance and reused\n * by every session, so this — not the store — is what keeps one session's\n * remembered turns out of another's recall. It is derived from the\n * execute-time `sessionId` by the engine and handed to every tier as an\n * exact-match filter; the model, the tool payload, and the per-call\n * `context` bag have no say in it.\n *\n * `\"shared\"` resolves to `undefined`, i.e. the store's unscoped pool —\n * the explicit opt-in back to pre-4.15.0 cross-session behavior, which\n * also keeps memories written before this release readable.\n */\nexport function memoryScopeFor(\n memory: ResolvedOrchestratorMemory,\n sessionId: string,\n): string | undefined {\n if (memory.scope === \"shared\") {\n return undefined;\n }\n\n if (typeof memory.scope === \"function\") {\n return memory.scope(sessionId);\n }\n\n return sessionMemoryScope(sessionId);\n}\n\n/**\n * The default `\"session\"` scope key: the session id under a reserved\n * prefix, so a custom `scope` callback returning a bare tenant id can\n * never accidentally collide with a session-scoped pool.\n */\nexport function sessionMemoryScope(sessionId: string): string {\n return `session:${sessionId}`;\n}\n\n/**\n * Coerce a turn's {@link SupervisorInput} (string or structured object)\n * into the natural-language query the memory store recalls / embeds\n * against. Strings pass through; objects are JSON-serialized — the same\n * coercion the supervisor applies when forwarding an object input to a\n * child agent without an explicit `input(ctx)` override.\n */\nexport function memoryQueryFromInput(input: SupervisorInput): string {\n return typeof input === \"string\" ? input : JSON.stringify(input);\n}\n\n/**\n * Recall the memories relevant to a turn's input (memory core M2 — the\n * pre-dispatch half). Returns the scored {@link RecalledMemory}[] the\n * lifecycle injects into the turn's `context` bag under\n * `memory.injectKey`. Returns an empty array — never throws on \"no hits\"\n * — and short-circuits when `k === 0` (recall disabled / write-only\n * memory) so a write-only config never round-trips the embedder.\n *\n * The recall is confined to the calling session's scope (see\n * {@link memoryScopeFor}) — `sessionId` is required, not optional, so a\n * new call site cannot silently recall across every session.\n */\nexport async function recallForTurn(\n memory: ResolvedOrchestratorMemory,\n input: SupervisorInput,\n sessionId: string,\n): Promise<RecalledMemory[]> {\n if (memory.k === 0) {\n return [];\n }\n\n return memory.store.recall(memoryQueryFromInput(input), {\n k: memory.k,\n threshold: memory.threshold,\n tier: memory.tier,\n scope: memoryScopeFor(memory, sessionId),\n });\n}\n\n/**\n * Merge the recalled memories into a fresh per-turn context bag under\n * `memory.injectKey` (memory core M2 — the injection half). Never\n * mutates the caller's `context` object — returns a new bag (or the\n * original when there is nothing to inject) so the request-scoped input\n * stays immutable, and the supervisor's intake (which freezes a\n * shallow copy) sees the recalled set on every `ctx.context[injectKey]`.\n *\n * A pre-existing value at `injectKey` is preserved when recall produced\n * nothing, and overwritten with the recalled set otherwise — the\n * orchestrator owns that key once memory is configured.\n */\nexport function injectMemories(\n context: Record<string, unknown> | undefined,\n memory: ResolvedOrchestratorMemory,\n recalled: RecalledMemory[],\n): Record<string, unknown> | undefined {\n if (recalled.length === 0) {\n return context;\n }\n\n return { ...(context ?? {}), [memory.injectKey]: recalled };\n}\n\n/**\n * Remember a settled turn's outcome (memory core M2 — the post-dispatch\n * half). Called only after a clean turn (cancelled / failed turns revert\n * and never remember — §17). No-ops when `remember` is `false`\n * (read-only memory) or when the produced text is empty.\n *\n * The remembered text is the turn input followed by the model's textual\n * outcome when one is available, so a later `recall` keyed on a similar\n * input surfaces both the prior question and its answer.\n *\n * The write is tagged with the calling session's scope (see\n * {@link memoryScopeFor}) so only that session recalls it later —\n * turn text routinely contains one user's private content.\n */\nexport async function rememberTurnOutcome(\n memory: ResolvedOrchestratorMemory,\n input: SupervisorInput,\n outcomeText: string | undefined,\n sessionId: string,\n): Promise<void> {\n if (!memory.remember) {\n return;\n }\n\n const text = buildOutcomeText(input, outcomeText);\n\n if (!text) {\n return;\n }\n\n const item: MemoryItem = {\n text,\n tier: memory.rememberTier,\n scope: memoryScopeFor(memory, sessionId),\n };\n\n await memory.store.remember(item);\n}\n\n/**\n * Compose the text written to memory for a turn: the input query, plus\n * the outcome text on a following line when the dispatch produced one.\n * Returns `undefined` when neither side carries content so an empty turn\n * never pollutes the store.\n */\nfunction buildOutcomeText(\n input: SupervisorInput,\n outcomeText: string | undefined,\n): string | undefined {\n const query = memoryQueryFromInput(input).trim();\n const outcome = outcomeText?.trim();\n\n if (query && outcome) {\n return `${query}\\n${outcome}`;\n }\n\n return query || outcome || undefined;\n}\n\n/**\n * Derive a turn's textual outcome for remembering (memory core M2).\n * Prefers the validated `result.data` (an `output` schema reshaped it);\n * otherwise stringifies the dispatched intents' branch outputs from the\n * turn snapshot, joined newline-wise so a multi-branch fan-out\n * contributes every output. Returns `undefined` when the turn produced\n * no usable text — the caller then remembers the input alone.\n */\nexport function outcomeTextFromTurn(\n data: unknown,\n turnSnapshot: TurnSnapshot,\n): string | undefined {\n const fromData = stringifyOutcome(data);\n\n if (fromData) {\n return fromData;\n }\n\n const outputs = Object.values(turnSnapshot.result)\n .map((branch) => stringifyOutcome(branch.output))\n .filter((text): text is string => Boolean(text));\n\n return outputs.length > 0 ? outputs.join(\"\\n\") : undefined;\n}\n\n/**\n * Coerce one outcome value to text: strings pass through; everything\n * else (objects, numbers) is JSON-serialized. `undefined` / `null` and\n * empty strings collapse to `undefined` so they don't masquerade as\n * content.\n */\nfunction stringifyOutcome(value: unknown): string | undefined {\n if (value === undefined || value === null) {\n return undefined;\n }\n\n const text = typeof value === \"string\" ? value : JSON.stringify(value);\n\n return text.trim() ? text : undefined;\n}\n"],"mappings":";;AAaA,MAAM,qBAAqB;;;;;;;;AAS3B,MAAM,gBAAgB;;;;;;;AAwCtB,SAAS,aACP,OACyB;CACzB,OAAO,OAAQ,MAAyB,WAAW;AACrD;;;;;;;AAQA,SAAgB,0BACd,QACwC;CACxC,IAAI,CAAC,QACH;CAGF,IAAI,aAAa,MAAM,GACrB,OAAO;EACL,OAAO;EACP,UAAU;EACV,OAAO;EACP,WAAW;CACb;CAGF,OAAO;EACL,OAAO,OAAO;EACd,GAAG,OAAO,QAAQ;EAClB,WAAW,OAAO,QAAQ;EAC1B,MAAM,OAAO,QAAQ;EACrB,UAAU,OAAO,YAAY;EAC7B,cAAc,OAAO;EACrB,OAAO,OAAO,SAAS;EACvB,WAAW,OAAO,aAAa;CACjC;AACF;;;;;;;;;;;;;;;;AAiBA,SAAgB,eACd,QACA,WACoB;CACpB,IAAI,OAAO,UAAU,UACnB;CAGF,IAAI,OAAO,OAAO,UAAU,YAC1B,OAAO,OAAO,MAAM,SAAS;CAG/B,OAAO,mBAAmB,SAAS;AACrC;;;;;;AAOA,SAAgB,mBAAmB,WAA2B;CAC5D,OAAO,WAAW;AACpB;;;;;;;;AASA,SAAgB,qBAAqB,OAAgC;CACnE,OAAO,OAAO,UAAU,WAAW,QAAQ,KAAK,UAAU,KAAK;AACjE;;;;;;;;;;;;;AAcA,eAAsB,cACpB,QACA,OACA,WAC2B;CAC3B,IAAI,OAAO,MAAM,GACf,OAAO,CAAC;CAGV,OAAO,OAAO,MAAM,OAAO,qBAAqB,KAAK,GAAG;EACtD,GAAG,OAAO;EACV,WAAW,OAAO;EAClB,MAAM,OAAO;EACb,OAAO,eAAe,QAAQ,SAAS;CACzC,CAAC;AACH;;;;;;;;;;;;;AAcA,SAAgB,eACd,SACA,QACA,UACqC;CACrC,IAAI,SAAS,WAAW,GACtB,OAAO;CAGT,OAAO;EAAE,GAAI,WAAW,CAAC;GAAK,OAAO,YAAY;CAAS;AAC5D;;;;;;;;;;;;;;;AAgBA,eAAsB,oBACpB,QACA,OACA,aACA,WACe;CACf,IAAI,CAAC,OAAO,UACV;CAGF,MAAM,OAAO,iBAAiB,OAAO,WAAW;CAEhD,IAAI,CAAC,MACH;CAGF,MAAM,OAAmB;EACvB;EACA,MAAM,OAAO;EACb,OAAO,eAAe,QAAQ,SAAS;CACzC;CAEA,MAAM,OAAO,MAAM,SAAS,IAAI;AAClC;;;;;;;AAQA,SAAS,iBACP,OACA,aACoB;CACpB,MAAM,QAAQ,qBAAqB,KAAK,CAAC,CAAC,KAAK;CAC/C,MAAM,UAAU,aAAa,KAAK;CAElC,IAAI,SAAS,SACX,OAAO,GAAG,MAAM,IAAI;CAGtB,OAAO,SAAS,WAAW;AAC7B;;;;;;;;;AAUA,SAAgB,oBACd,MACA,cACoB;CACpB,MAAM,WAAW,iBAAiB,IAAI;CAEtC,IAAI,UACF,OAAO;CAGT,MAAM,UAAU,OAAO,OAAO,aAAa,MAAM,CAAC,CAC/C,KAAK,WAAW,iBAAiB,OAAO,MAAM,CAAC,CAAC,CAChD,QAAQ,SAAyB,QAAQ,IAAI,CAAC;CAEjD,OAAO,QAAQ,SAAS,IAAI,QAAQ,KAAK,IAAI,IAAI;AACnD;;;;;;;AAQA,SAAS,iBAAiB,OAAoC;CAC5D,IAAI,UAAU,UAAa,UAAU,MACnC;CAGF,MAAM,OAAO,OAAO,UAAU,WAAW,QAAQ,KAAK,UAAU,KAAK;CAErE,OAAO,KAAK,KAAK,IAAI,OAAO;AAC9B"}
@@ -17,11 +17,22 @@ import { StandardSchemaV1 } from "@standard-schema/spec";
17
17
  * `PlannerPlanInvalidError`, with the full forensic context, rather
18
18
  * than as an opaque schema issue here.
19
19
  *
20
- * `maxSteps`, when provided, is emitted as the `steps` array's
21
- * `maxItems` so capable providers refuse to over-produce up front
22
- * (the planner still truncates the tail to `skipped` defensively).
20
+ * `maxSteps` cannot be expressed on the wire (strict mode rejects
21
+ * `maxItems`), so `validate()` enforces a hard parse-time ceiling
22
+ * derived from it see {@link parsedStepCeiling}.
23
23
  */
24
- declare function planSchema(capabilityNames: string[], maxSteps?: number): StandardSchemaV1<PlannerPlan>;
24
+ type PlanSchema = StandardSchemaV1<PlannerPlan> & {
25
+ "~standard": {
26
+ /**
27
+ * JSON Schema extension read by the native structured-output path.
28
+ * Part of the declared type so callers don't have to re-assert it.
29
+ */
30
+ jsonSchema: {
31
+ input: () => Record<string, unknown>;
32
+ };
33
+ };
34
+ };
35
+ declare function planSchema(capabilityNames: string[], maxSteps?: number): PlanSchema;
25
36
  //#endregion
26
37
  export { planSchema };
27
38
  //# sourceMappingURL=plan-schema.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"plan-schema.d.mts","names":[],"sources":["../../../../../../../ai/src/planner/plan-schema.ts"],"mappings":";;;;;;AAsBA;;;;;;;;;AAG+B;;;;;;;;iBAHf,UAAA,CACd,eAAA,YACA,QAAA,YACC,gBAAgB,CAAC,WAAA"}
1
+ {"version":3,"file":"plan-schema.d.mts","names":[],"sources":["../../../../../../../ai/src/planner/plan-schema.ts"],"mappings":";;;;;;AAsBA;;;;;;;;;;;;;;;AAMqC;AAsCrC;KA5CY,UAAA,GAAa,gBAAA,CAAiB,WAAA;EACxC,WAAA;IA2CkF;;;;IAtChF,UAAA;MAAc,KAAA,QAAa,MAAA;IAAA;EAAA;AAAA;AAAA,iBAsCf,UAAA,CAAW,eAAA,YAA2B,QAAA,YAAoB,UAAU"}
@@ -1,24 +1,34 @@
1
1
  //#region ../ai/src/planner/plan-schema.ts
2
2
  /**
3
- * Build the Standard Schema the planning agent emits an ordered
4
- * `{ steps: [...], summary? }` plan whose every step references one of
5
- * `capabilityNames` via the `capability` field.
6
- *
7
- * Mirrors the router's hand-built schema approach
8
- * (`supervisor/router-factory.ts`): the JSON Schema extension carries
9
- * the capability names as an `enum` so capable providers enforce the
10
- * choice natively, while `validate()` still accepts the shape softly so
11
- * providers without native structured output can pass a parsed object
12
- * through. Validation is intentionally lenient on `capability` — an
13
- * unknown name is surfaced later by the planner as a typed
14
- * `PlannerPlanInvalidError`, with the full forensic context, rather
15
- * than as an opaque schema issue here.
3
+ * Slack allowed over `maxSteps` before a returned plan is rejected
4
+ * outright. A model that overshoots the prompt's "at most N steps" by a
5
+ * little is normal and the runtime truncates the tail to `skipped`;
6
+ * one that returns several times the budget is malfunctioning (or the
7
+ * provider/proxy is not the one we think it is), and parsing it is
8
+ * unbounded work on attacker-adjacent input.
9
+ */
10
+ const STEP_CEILING_FACTOR = 4;
11
+ /**
12
+ * Ceiling used when `planSchema` is built without a `maxSteps` — direct
13
+ * callers outside `PlannerRun`, which has no runtime truncation of its
14
+ * own to fall back on.
15
+ */
16
+ const DEFAULT_STEP_CEILING = 100;
17
+ /**
18
+ * Hard upper bound on the number of steps `validate()` will parse.
16
19
  *
17
- * `maxSteps`, when provided, is emitted as the `steps` array's
18
- * `maxItems` so capable providers refuse to over-produce up front
19
- * (the planner still truncates the tail to `skipped` defensively).
20
+ * Strict-mode JSON Schema can't carry `maxItems`, so nothing on the wire
21
+ * stops a provider from returning an arbitrarily long `steps[]`; before
22
+ * 4.15.0 the whole array was parsed, normalized and stored, and only the
23
+ * execution loop truncated it. This is the parse-time backstop that
24
+ * makes the bound hold regardless of what the provider honors.
20
25
  */
26
+ function parsedStepCeiling(maxSteps) {
27
+ if (maxSteps === void 0) return DEFAULT_STEP_CEILING;
28
+ return Math.max(1, Math.ceil(maxSteps)) * STEP_CEILING_FACTOR;
29
+ }
21
30
  function planSchema(capabilityNames, maxSteps) {
31
+ const stepCeiling = parsedStepCeiling(maxSteps);
22
32
  const jsonSchema = {
23
33
  type: "object",
24
34
  properties: {
@@ -43,6 +53,7 @@ function planSchema(capabilityNames, maxSteps) {
43
53
  if (!value || typeof value !== "object") return { issues: [{ message: "plan must be an object" }] };
44
54
  const record = value;
45
55
  if (!Array.isArray(record.steps) || record.steps.length === 0) return { issues: [{ message: "plan `steps` must be a non-empty array" }] };
56
+ if (record.steps.length > stepCeiling) return { issues: [{ message: `plan \`steps\` must not exceed ${stepCeiling} entries (received ${record.steps.length})` }] };
46
57
  const steps = [];
47
58
  for (const raw of record.steps) {
48
59
  const normalized = normalizeStep(raw);
@@ -1 +1 @@
1
- {"version":3,"file":"plan-schema.mjs","names":[],"sources":["../../../../../../../ai/src/planner/plan-schema.ts"],"sourcesContent":["import type { StandardSchemaV1 } from \"@standard-schema/spec\";\nimport type { PlannerPlan, PlannerStep } from \"../contracts/planner/planner-plan.type\";\n\n/**\n * Build the Standard Schema the planning agent emits — an ordered\n * `{ steps: [...], summary? }` plan whose every step references one of\n * `capabilityNames` via the `capability` field.\n *\n * Mirrors the router's hand-built schema approach\n * (`supervisor/router-factory.ts`): the JSON Schema extension carries\n * the capability names as an `enum` so capable providers enforce the\n * choice natively, while `validate()` still accepts the shape softly so\n * providers without native structured output can pass a parsed object\n * through. Validation is intentionally lenient on `capability` — an\n * unknown name is surfaced later by the planner as a typed\n * `PlannerPlanInvalidError`, with the full forensic context, rather\n * than as an opaque schema issue here.\n *\n * `maxSteps`, when provided, is emitted as the `steps` array's\n * `maxItems` so capable providers refuse to over-produce up front\n * (the planner still truncates the tail to `skipped` defensively).\n */\nexport function planSchema(\n capabilityNames: string[],\n maxSteps?: number,\n): StandardSchemaV1<PlannerPlan> {\n // OpenAI strict `json_schema` mode (and other native structured-output\n // providers) require EVERY property to appear in `required` — with truly\n // optional fields expressed as nullable — and reject array `minItems` /\n // `maxItems`. So the schema is strict-shaped: all keys required, the\n // optional ones nullable, no item-count bounds. A non-empty plan is\n // enforced in `validate()`, and `maxSteps` by the runtime's tail\n // truncation, so neither bound is needed on the wire.\n void maxSteps;\n\n const jsonSchema = {\n type: \"object\",\n properties: {\n summary: {\n type: [\"string\", \"null\"],\n description: \"One-line summary of the overall strategy.\",\n },\n steps: {\n type: \"array\",\n description: \"Ordered steps to execute, one capability dispatch each.\",\n items: stepItemsSchema(capabilityNames),\n },\n },\n required: [\"summary\", \"steps\"],\n additionalProperties: false,\n };\n\n return {\n \"~standard\": {\n version: 1,\n vendor: \"warlock-planner\",\n jsonSchema: {\n input: () => jsonSchema,\n },\n validate(value: unknown): StandardSchemaV1.Result<PlannerPlan> {\n if (!value || typeof value !== \"object\") {\n return { issues: [{ message: \"plan must be an object\" }] };\n }\n\n const record = value as { steps?: unknown; summary?: unknown };\n\n if (!Array.isArray(record.steps) || record.steps.length === 0) {\n return { issues: [{ message: \"plan `steps` must be a non-empty array\" }] };\n }\n\n const steps: PlannerStep[] = [];\n\n for (const raw of record.steps) {\n const normalized = normalizeStep(raw);\n\n if (!normalized) {\n return {\n issues: [{ message: \"each plan step must carry a string `capability` and `input`\" }],\n };\n }\n\n steps.push(normalized);\n }\n\n const summary = typeof record.summary === \"string\" ? record.summary : undefined;\n\n return { value: summary !== undefined ? { steps, summary } : { steps } };\n },\n } as StandardSchemaV1<PlannerPlan>[\"~standard\"] & {\n jsonSchema: { input: () => Record<string, unknown> };\n },\n };\n}\n\n/** Per-step JSON Schema object — one capability dispatch. */\nfunction stepItemsSchema(capabilityNames: string[]): Record<string, unknown> {\n return {\n type: \"object\",\n properties: {\n id: {\n type: [\"string\", \"null\"],\n description: \"Stable step id, referenced by dependsOn.\",\n },\n capability: {\n type: \"string\",\n enum: capabilityNames,\n description: \"Name of the capability to dispatch for this step.\",\n },\n input: {\n type: \"string\",\n description: \"Concrete input passed to the capability's execute().\",\n },\n reason: { type: [\"string\", \"null\"], description: \"Why this step exists.\" },\n dependsOn: {\n type: [\"array\", \"null\"],\n items: { type: \"string\" },\n description: \"Ids of steps this one conceptually follows.\",\n },\n },\n // Strict mode: every property required; the genuinely-optional ones\n // (id / reason / dependsOn) are nullable. `validate()` treats null and\n // missing identically, so a model emitting `null` round-trips fine.\n required: [\"id\", \"capability\", \"input\", \"reason\", \"dependsOn\"],\n additionalProperties: false,\n };\n}\n\n/**\n * Coerce one raw step object into a {@link PlannerStep}, returning\n * `undefined` when the mandatory `capability` / `input` strings are\n * missing. Optional fields are copied only when well-typed.\n */\nfunction normalizeStep(raw: unknown): PlannerStep | undefined {\n if (!raw || typeof raw !== \"object\") {\n return undefined;\n }\n\n const record = raw as {\n id?: unknown;\n capability?: unknown;\n input?: unknown;\n reason?: unknown;\n dependsOn?: unknown;\n };\n\n if (typeof record.capability !== \"string\" || record.capability.length === 0) {\n return undefined;\n }\n\n if (typeof record.input !== \"string\") {\n return undefined;\n }\n\n const step: PlannerStep = {\n capability: record.capability,\n input: record.input,\n };\n\n if (typeof record.id === \"string\") {\n step.id = record.id;\n }\n\n if (typeof record.reason === \"string\") {\n step.reason = record.reason;\n }\n\n if (Array.isArray(record.dependsOn) && record.dependsOn.every((entry) => typeof entry === \"string\")) {\n step.dependsOn = record.dependsOn as string[];\n }\n\n return step;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAsBA,SAAgB,WACd,iBACA,UAC+B;CAU/B,MAAM,aAAa;EACjB,MAAM;EACN,YAAY;GACV,SAAS;IACP,MAAM,CAAC,UAAU,MAAM;IACvB,aAAa;GACf;GACA,OAAO;IACL,MAAM;IACN,aAAa;IACb,OAAO,gBAAgB,eAAe;GACxC;EACF;EACA,UAAU,CAAC,WAAW,OAAO;EAC7B,sBAAsB;CACxB;CAEA,OAAO,EACL,aAAa;EACX,SAAS;EACT,QAAQ;EACR,YAAY,EACV,aAAa,WACf;EACA,SAAS,OAAsD;GAC7D,IAAI,CAAC,SAAS,OAAO,UAAU,UAC7B,OAAO,EAAE,QAAQ,CAAC,EAAE,SAAS,yBAAyB,CAAC,EAAE;GAG3D,MAAM,SAAS;GAEf,IAAI,CAAC,MAAM,QAAQ,OAAO,KAAK,KAAK,OAAO,MAAM,WAAW,GAC1D,OAAO,EAAE,QAAQ,CAAC,EAAE,SAAS,yCAAyC,CAAC,EAAE;GAG3E,MAAM,QAAuB,CAAC;GAE9B,KAAK,MAAM,OAAO,OAAO,OAAO;IAC9B,MAAM,aAAa,cAAc,GAAG;IAEpC,IAAI,CAAC,YACH,OAAO,EACL,QAAQ,CAAC,EAAE,SAAS,8DAA8D,CAAC,EACrF;IAGF,MAAM,KAAK,UAAU;GACvB;GAEA,MAAM,UAAU,OAAO,OAAO,YAAY,WAAW,OAAO,UAAU;GAEtE,OAAO,EAAE,OAAO,YAAY,SAAY;IAAE;IAAO;GAAQ,IAAI,EAAE,MAAM,EAAE;EACzE;CACF,EAGF;AACF;;AAGA,SAAS,gBAAgB,iBAAoD;CAC3E,OAAO;EACL,MAAM;EACN,YAAY;GACV,IAAI;IACF,MAAM,CAAC,UAAU,MAAM;IACvB,aAAa;GACf;GACA,YAAY;IACV,MAAM;IACN,MAAM;IACN,aAAa;GACf;GACA,OAAO;IACL,MAAM;IACN,aAAa;GACf;GACA,QAAQ;IAAE,MAAM,CAAC,UAAU,MAAM;IAAG,aAAa;GAAwB;GACzE,WAAW;IACT,MAAM,CAAC,SAAS,MAAM;IACtB,OAAO,EAAE,MAAM,SAAS;IACxB,aAAa;GACf;EACF;EAIA,UAAU;GAAC;GAAM;GAAc;GAAS;GAAU;EAAW;EAC7D,sBAAsB;CACxB;AACF;;;;;;AAOA,SAAS,cAAc,KAAuC;CAC5D,IAAI,CAAC,OAAO,OAAO,QAAQ,UACzB;CAGF,MAAM,SAAS;CAQf,IAAI,OAAO,OAAO,eAAe,YAAY,OAAO,WAAW,WAAW,GACxE;CAGF,IAAI,OAAO,OAAO,UAAU,UAC1B;CAGF,MAAM,OAAoB;EACxB,YAAY,OAAO;EACnB,OAAO,OAAO;CAChB;CAEA,IAAI,OAAO,OAAO,OAAO,UACvB,KAAK,KAAK,OAAO;CAGnB,IAAI,OAAO,OAAO,WAAW,UAC3B,KAAK,SAAS,OAAO;CAGvB,IAAI,MAAM,QAAQ,OAAO,SAAS,KAAK,OAAO,UAAU,OAAO,UAAU,OAAO,UAAU,QAAQ,GAChG,KAAK,YAAY,OAAO;CAG1B,OAAO;AACT"}
1
+ {"version":3,"file":"plan-schema.mjs","names":[],"sources":["../../../../../../../ai/src/planner/plan-schema.ts"],"sourcesContent":["import type { StandardSchemaV1 } from \"@standard-schema/spec\";\nimport type { PlannerPlan, PlannerStep } from \"../contracts/planner/planner-plan.type\";\n\n/**\n * Build the Standard Schema the planning agent emits — an ordered\n * `{ steps: [...], summary? }` plan whose every step references one of\n * `capabilityNames` via the `capability` field.\n *\n * Mirrors the router's hand-built schema approach\n * (`supervisor/router-factory.ts`): the JSON Schema extension carries\n * the capability names as an `enum` so capable providers enforce the\n * choice natively, while `validate()` still accepts the shape softly so\n * providers without native structured output can pass a parsed object\n * through. Validation is intentionally lenient on `capability` — an\n * unknown name is surfaced later by the planner as a typed\n * `PlannerPlanInvalidError`, with the full forensic context, rather\n * than as an opaque schema issue here.\n *\n * `maxSteps` cannot be expressed on the wire (strict mode rejects\n * `maxItems`), so `validate()` enforces a hard parse-time ceiling\n * derived from it — see {@link parsedStepCeiling}.\n */\nexport type PlanSchema = StandardSchemaV1<PlannerPlan> & {\n \"~standard\": {\n /**\n * JSON Schema extension read by the native structured-output path.\n * Part of the declared type so callers don't have to re-assert it.\n */\n jsonSchema: { input: () => Record<string, unknown> };\n };\n};\n\n/**\n * Slack allowed over `maxSteps` before a returned plan is rejected\n * outright. A model that overshoots the prompt's \"at most N steps\" by a\n * little is normal and the runtime truncates the tail to `skipped`;\n * one that returns several times the budget is malfunctioning (or the\n * provider/proxy is not the one we think it is), and parsing it is\n * unbounded work on attacker-adjacent input.\n */\nconst STEP_CEILING_FACTOR = 4;\n\n/**\n * Ceiling used when `planSchema` is built without a `maxSteps` — direct\n * callers outside `PlannerRun`, which has no runtime truncation of its\n * own to fall back on.\n */\nconst DEFAULT_STEP_CEILING = 100;\n\n/**\n * Hard upper bound on the number of steps `validate()` will parse.\n *\n * Strict-mode JSON Schema can't carry `maxItems`, so nothing on the wire\n * stops a provider from returning an arbitrarily long `steps[]`; before\n * 4.15.0 the whole array was parsed, normalized and stored, and only the\n * execution loop truncated it. This is the parse-time backstop that\n * makes the bound hold regardless of what the provider honors.\n */\nexport function parsedStepCeiling(maxSteps?: number): number {\n if (maxSteps === undefined) {\n return DEFAULT_STEP_CEILING;\n }\n\n return Math.max(1, Math.ceil(maxSteps)) * STEP_CEILING_FACTOR;\n}\n\nexport function planSchema(capabilityNames: string[], maxSteps?: number): PlanSchema {\n // OpenAI strict `json_schema` mode (and other native structured-output\n // providers) require EVERY property to appear in `required` — with truly\n // optional fields expressed as nullable — and reject array `minItems` /\n // `maxItems`. So the schema is strict-shaped: all keys required, the\n // optional ones nullable, no item-count bounds on the wire. Both bounds\n // live in `validate()` instead: non-empty below, and the over-long\n // ceiling that `maxItems` would have expressed.\n const stepCeiling = parsedStepCeiling(maxSteps);\n\n const jsonSchema = {\n type: \"object\",\n properties: {\n summary: {\n type: [\"string\", \"null\"],\n description: \"One-line summary of the overall strategy.\",\n },\n steps: {\n type: \"array\",\n description: \"Ordered steps to execute, one capability dispatch each.\",\n items: stepItemsSchema(capabilityNames),\n },\n },\n required: [\"summary\", \"steps\"],\n additionalProperties: false,\n };\n\n return {\n \"~standard\": {\n version: 1,\n vendor: \"warlock-planner\",\n jsonSchema: {\n input: () => jsonSchema,\n },\n validate(value: unknown): StandardSchemaV1.Result<PlannerPlan> {\n if (!value || typeof value !== \"object\") {\n return { issues: [{ message: \"plan must be an object\" }] };\n }\n\n const record = value as { steps?: unknown; summary?: unknown };\n\n if (!Array.isArray(record.steps) || record.steps.length === 0) {\n return { issues: [{ message: \"plan `steps` must be a non-empty array\" }] };\n }\n\n // Reject an over-long plan HERE, before a single step is\n // normalized — the runtime's tail truncation runs after the whole\n // array has been parsed and stored, so it bounds execution but\n // not the parsing cost of a pathological response. Rejecting\n // rather than truncating is deliberate: a plan several times its\n // budget is a malfunction worth surfacing as\n // `PlannerPlanInvalidError`, not something to silently trim into\n // a plausible-looking prefix.\n if (record.steps.length > stepCeiling) {\n return {\n issues: [\n {\n message: `plan \\`steps\\` must not exceed ${stepCeiling} entries (received ${record.steps.length})`,\n },\n ],\n };\n }\n\n const steps: PlannerStep[] = [];\n\n for (const raw of record.steps) {\n const normalized = normalizeStep(raw);\n\n if (!normalized) {\n return {\n issues: [{ message: \"each plan step must carry a string `capability` and `input`\" }],\n };\n }\n\n steps.push(normalized);\n }\n\n const summary = typeof record.summary === \"string\" ? record.summary : undefined;\n\n return { value: summary !== undefined ? { steps, summary } : { steps } };\n },\n } as StandardSchemaV1<PlannerPlan>[\"~standard\"] & {\n jsonSchema: { input: () => Record<string, unknown> };\n },\n };\n}\n\n/** Per-step JSON Schema object — one capability dispatch. */\nfunction stepItemsSchema(capabilityNames: string[]): Record<string, unknown> {\n return {\n type: \"object\",\n properties: {\n id: {\n type: [\"string\", \"null\"],\n description: \"Stable step id, referenced by dependsOn.\",\n },\n capability: {\n type: \"string\",\n enum: capabilityNames,\n description: \"Name of the capability to dispatch for this step.\",\n },\n input: {\n type: \"string\",\n description: \"Concrete input passed to the capability's execute().\",\n },\n reason: { type: [\"string\", \"null\"], description: \"Why this step exists.\" },\n dependsOn: {\n type: [\"array\", \"null\"],\n items: { type: \"string\" },\n description: \"Ids of steps this one conceptually follows.\",\n },\n },\n // Strict mode: every property required; the genuinely-optional ones\n // (id / reason / dependsOn) are nullable. `validate()` treats null and\n // missing identically, so a model emitting `null` round-trips fine.\n required: [\"id\", \"capability\", \"input\", \"reason\", \"dependsOn\"],\n additionalProperties: false,\n };\n}\n\n/**\n * Coerce one raw step object into a {@link PlannerStep}, returning\n * `undefined` when the mandatory `capability` / `input` strings are\n * missing. Optional fields are copied only when well-typed.\n */\nfunction normalizeStep(raw: unknown): PlannerStep | undefined {\n if (!raw || typeof raw !== \"object\") {\n return undefined;\n }\n\n const record = raw as {\n id?: unknown;\n capability?: unknown;\n input?: unknown;\n reason?: unknown;\n dependsOn?: unknown;\n };\n\n if (typeof record.capability !== \"string\" || record.capability.length === 0) {\n return undefined;\n }\n\n if (typeof record.input !== \"string\") {\n return undefined;\n }\n\n const step: PlannerStep = {\n capability: record.capability,\n input: record.input,\n };\n\n if (typeof record.id === \"string\") {\n step.id = record.id;\n }\n\n if (typeof record.reason === \"string\") {\n step.reason = record.reason;\n }\n\n if (Array.isArray(record.dependsOn) && record.dependsOn.every((entry) => typeof entry === \"string\")) {\n step.dependsOn = record.dependsOn as string[];\n }\n\n return step;\n}\n"],"mappings":";;;;;;;;;AAwCA,MAAM,sBAAsB;;;;;;AAO5B,MAAM,uBAAuB;;;;;;;;;;AAW7B,SAAgB,kBAAkB,UAA2B;CAC3D,IAAI,aAAa,QACf,OAAO;CAGT,OAAO,KAAK,IAAI,GAAG,KAAK,KAAK,QAAQ,CAAC,IAAI;AAC5C;AAEA,SAAgB,WAAW,iBAA2B,UAA+B;CAQnF,MAAM,cAAc,kBAAkB,QAAQ;CAE9C,MAAM,aAAa;EACjB,MAAM;EACN,YAAY;GACV,SAAS;IACP,MAAM,CAAC,UAAU,MAAM;IACvB,aAAa;GACf;GACA,OAAO;IACL,MAAM;IACN,aAAa;IACb,OAAO,gBAAgB,eAAe;GACxC;EACF;EACA,UAAU,CAAC,WAAW,OAAO;EAC7B,sBAAsB;CACxB;CAEA,OAAO,EACL,aAAa;EACX,SAAS;EACT,QAAQ;EACR,YAAY,EACV,aAAa,WACf;EACA,SAAS,OAAsD;GAC7D,IAAI,CAAC,SAAS,OAAO,UAAU,UAC7B,OAAO,EAAE,QAAQ,CAAC,EAAE,SAAS,yBAAyB,CAAC,EAAE;GAG3D,MAAM,SAAS;GAEf,IAAI,CAAC,MAAM,QAAQ,OAAO,KAAK,KAAK,OAAO,MAAM,WAAW,GAC1D,OAAO,EAAE,QAAQ,CAAC,EAAE,SAAS,yCAAyC,CAAC,EAAE;GAW3E,IAAI,OAAO,MAAM,SAAS,aACxB,OAAO,EACL,QAAQ,CACN,EACE,SAAS,kCAAkC,YAAY,qBAAqB,OAAO,MAAM,OAAO,GAClG,CACF,EACF;GAGF,MAAM,QAAuB,CAAC;GAE9B,KAAK,MAAM,OAAO,OAAO,OAAO;IAC9B,MAAM,aAAa,cAAc,GAAG;IAEpC,IAAI,CAAC,YACH,OAAO,EACL,QAAQ,CAAC,EAAE,SAAS,8DAA8D,CAAC,EACrF;IAGF,MAAM,KAAK,UAAU;GACvB;GAEA,MAAM,UAAU,OAAO,OAAO,YAAY,WAAW,OAAO,UAAU;GAEtE,OAAO,EAAE,OAAO,YAAY,SAAY;IAAE;IAAO;GAAQ,IAAI,EAAE,MAAM,EAAE;EACzE;CACF,EAGF;AACF;;AAGA,SAAS,gBAAgB,iBAAoD;CAC3E,OAAO;EACL,MAAM;EACN,YAAY;GACV,IAAI;IACF,MAAM,CAAC,UAAU,MAAM;IACvB,aAAa;GACf;GACA,YAAY;IACV,MAAM;IACN,MAAM;IACN,aAAa;GACf;GACA,OAAO;IACL,MAAM;IACN,aAAa;GACf;GACA,QAAQ;IAAE,MAAM,CAAC,UAAU,MAAM;IAAG,aAAa;GAAwB;GACzE,WAAW;IACT,MAAM,CAAC,SAAS,MAAM;IACtB,OAAO,EAAE,MAAM,SAAS;IACxB,aAAa;GACf;EACF;EAIA,UAAU;GAAC;GAAM;GAAc;GAAS;GAAU;EAAW;EAC7D,sBAAsB;CACxB;AACF;;;;;;AAOA,SAAS,cAAc,KAAuC;CAC5D,IAAI,CAAC,OAAO,OAAO,QAAQ,UACzB;CAGF,MAAM,SAAS;CAQf,IAAI,OAAO,OAAO,eAAe,YAAY,OAAO,WAAW,WAAW,GACxE;CAGF,IAAI,OAAO,OAAO,UAAU,UAC1B;CAGF,MAAM,OAAoB;EACxB,YAAY,OAAO;EACnB,OAAO,OAAO;CAChB;CAEA,IAAI,OAAO,OAAO,OAAO,UACvB,KAAK,KAAK,OAAO;CAGnB,IAAI,OAAO,OAAO,WAAW,UAC3B,KAAK,SAAS,OAAO;CAGvB,IAAI,MAAM,QAAQ,OAAO,SAAS,KAAK,OAAO,UAAU,OAAO,UAAU,OAAO,UAAU,QAAQ,GAChG,KAAK,YAAY,OAAO;CAG1B,OAAO;AACT"}
@@ -1,5 +1,6 @@
1
1
  import { isPrivateOrReservedIp } from "./private-ip.mjs";
2
2
  import { assertUrlAllowed, fetchTextWithPolicy, guardedFetch, readTextCapped, resolveOutboundPolicy } from "./outbound-policy.mjs";
3
+ import { UNSAFE_MERGE_KEYS, assignSafeKey, isUnsafeMergeKey, mergeSafely } from "./safe-merge.mjs";
3
4
  import { DEFAULT_SENSITIVE_KEYS, SENSITIVE_HEADERS, redact, redactError, redactHeaders, scrubSecrets } from "./redact.mjs";
4
5
 
5
6
  export { };
@@ -21,6 +21,15 @@ declare function assertUrlAllowed(rawUrl: string, policy: ResolvedOutboundPolicy
21
21
  * caller signal merged. Returns the raw `Response` — read its body via
22
22
  * {@link readTextCapped} to enforce `maxBytes`. Throws
23
23
  * {@link OutboundPolicyError} on a policy violation or timeout.
24
+ *
25
+ * Redirects are NEVER delegated to the platform: every hop is issued
26
+ * with `redirect: "manual"` and its `Location` is re-run through
27
+ * {@link assertUrlAllowed} before being followed (capped at
28
+ * `maxRedirects`), so a 3xx from an allowed host cannot smuggle the
29
+ * request to a private / metadata / off-allowlist target. Credential
30
+ * headers are stripped when a hop crosses an origin boundary. Pass
31
+ * `init.redirect: "manual"` to receive the raw 3xx, or `"error"` to
32
+ * reject on any redirect.
24
33
  */
25
34
  declare function guardedFetch(rawUrl: string, policyInput: OutboundPolicy, init?: RequestInit): Promise<Response>;
26
35
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"outbound-policy.d.mts","names":[],"sources":["../../../../../../../ai/src/security/outbound-policy.ts"],"mappings":";;;;;AAmBA;;;iBAAgB,qBAAA,CACd,MAAA,GAAQ,cAAA,GACP,sBAAsB;;;;;AAAA;AAiCzB;;iBAAsB,gBAAA,CACpB,MAAA,UACA,MAAA,EAAQ,sBAAA,GACP,OAAA,CAAQ,GAAA;;;;;;;;iBAiGW,YAAA,CACpB,MAAA,UACA,WAAA,EAAa,cAAA,EACb,IAAA,GAAO,WAAA,GACN,OAAA,CAAQ,QAAA;;;;AArGG;AAiGd;;iBAkCsB,cAAA,CACpB,QAAA,EAAU,QAAA,EACV,QAAA,WACC,OAAO;;;;;;iBAgDY,mBAAA,CACpB,MAAA,UACA,WAAA,EAAa,cAAA,EACb,IAAA,GAAO,WAAA,GACN,OAAA;EAAU,EAAA;EAAa,MAAA;EAAgB,UAAA;EAAoB,IAAA;AAAA"}
1
+ {"version":3,"file":"outbound-policy.d.mts","names":[],"sources":["../../../../../../../ai/src/security/outbound-policy.ts"],"mappings":";;;;;AA+BA;;;iBAAgB,qBAAA,CACd,MAAA,GAAQ,cAAA,GACP,sBAAsB;;;;;AAAA;AAkCzB;;iBAAsB,gBAAA,CACpB,MAAA,UACA,MAAA,EAAQ,sBAAA,GACP,OAAA,CAAQ,GAAA;;;;;;;;;;;;AAAG;AAqHd;;;;iBAAsB,YAAA,CACpB,MAAA,UACA,WAAA,EAAa,cAAA,EACb,IAAA,GAAO,WAAA,GACN,OAAA,CAAQ,QAAA;;;;;;;iBAwGW,cAAA,CACpB,QAAA,EAAU,QAAA,EACV,QAAA,WACC,OAAO;;;;;;iBAgDY,mBAAA,CACpB,MAAA,UACA,WAAA,EAAa,cAAA,EACb,IAAA,GAAO,WAAA,GACN,OAAA;EAAU,EAAA;EAAa,MAAA;EAAgB,UAAA;EAAoB,IAAA;AAAA"}
@@ -9,6 +9,22 @@ import { isIP } from "node:net";
9
9
  const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
10
10
  /** 10s — default per-request timeout. */
11
11
  const DEFAULT_TIMEOUT_MS = 1e4;
12
+ /** Default cap on the number of policy-validated redirect hops. */
13
+ const DEFAULT_MAX_REDIRECTS = 5;
14
+ /** 3xx statuses whose `Location` a follow re-issues. */
15
+ const REDIRECT_STATUSES = new Set([
16
+ 301,
17
+ 302,
18
+ 303,
19
+ 307,
20
+ 308
21
+ ]);
22
+ /** Credential headers that must not survive a cross-origin redirect. */
23
+ const CROSS_ORIGIN_STRIP_HEADERS = [
24
+ "authorization",
25
+ "cookie",
26
+ "proxy-authorization"
27
+ ];
12
28
  /**
13
29
  * Fill an {@link OutboundPolicy} with strict defaults: https-only,
14
30
  * private-IP deny on, 10s timeout, 5 MiB cap, global `fetch`. Idempotent
@@ -21,6 +37,7 @@ function resolveOutboundPolicy(policy = {}) {
21
37
  denyPrivateIPsAfterDNS: policy.denyPrivateIPsAfterDNS ?? true,
22
38
  maxBytes: policy.maxBytes ?? DEFAULT_MAX_BYTES,
23
39
  timeoutMs: policy.timeoutMs ?? DEFAULT_TIMEOUT_MS,
40
+ maxRedirects: policy.maxRedirects ?? DEFAULT_MAX_REDIRECTS,
24
41
  signal: policy.signal,
25
42
  fetch: policy.fetch ?? globalThis.fetch
26
43
  };
@@ -107,16 +124,33 @@ function mergeSignals(timeout, external) {
107
124
  else external.addEventListener("abort", () => abort(external), { once: true });
108
125
  return controller.signal;
109
126
  }
127
+ /** Flatten a headers init into a mutable lower-cased-key record. */
128
+ function headersToRecord(headersInit) {
129
+ const record = {};
130
+ new Headers(headersInit).forEach((value, key) => {
131
+ record[key] = value;
132
+ });
133
+ return record;
134
+ }
110
135
  /**
111
136
  * Policy-guarded `fetch`: validates the URL ({@link assertUrlAllowed}),
112
137
  * then performs the request with the policy's timeout and (optional)
113
138
  * caller signal merged. Returns the raw `Response` — read its body via
114
139
  * {@link readTextCapped} to enforce `maxBytes`. Throws
115
140
  * {@link OutboundPolicyError} on a policy violation or timeout.
141
+ *
142
+ * Redirects are NEVER delegated to the platform: every hop is issued
143
+ * with `redirect: "manual"` and its `Location` is re-run through
144
+ * {@link assertUrlAllowed} before being followed (capped at
145
+ * `maxRedirects`), so a 3xx from an allowed host cannot smuggle the
146
+ * request to a private / metadata / off-allowlist target. Credential
147
+ * headers are stripped when a hop crosses an origin boundary. Pass
148
+ * `init.redirect: "manual"` to receive the raw 3xx, or `"error"` to
149
+ * reject on any redirect.
116
150
  */
117
151
  async function guardedFetch(rawUrl, policyInput, init) {
118
152
  const policy = resolveOutboundPolicy(policyInput);
119
- const url = await assertUrlAllowed(rawUrl, policy);
153
+ let url = await assertUrlAllowed(rawUrl, policy);
120
154
  const timeoutController = new AbortController();
121
155
  const timer = setTimeout(() => {
122
156
  timeoutController.abort(new OutboundPolicyError(`outbound request timed out after ${policy.timeoutMs}ms`, { context: {
@@ -124,11 +158,51 @@ async function guardedFetch(rawUrl, policyInput, init) {
124
158
  timeoutMs: policy.timeoutMs
125
159
  } }));
126
160
  }, policy.timeoutMs);
161
+ const signal = mergeSignals(timeoutController.signal, policy.signal);
162
+ const redirectMode = init?.redirect ?? "follow";
163
+ const headers = headersToRecord(init?.headers);
164
+ let method = init?.method ?? "GET";
165
+ let body = init?.body ?? void 0;
127
166
  try {
128
- return await policy.fetch(url, {
129
- ...init,
130
- signal: mergeSignals(timeoutController.signal, policy.signal)
131
- });
167
+ for (let hop = 0;; hop++) {
168
+ const response = await policy.fetch(url, {
169
+ ...init,
170
+ method,
171
+ headers: { ...headers },
172
+ body,
173
+ redirect: "manual",
174
+ signal
175
+ });
176
+ const location = response.headers.get("location");
177
+ if (!REDIRECT_STATUSES.has(response.status) || location === null) return response;
178
+ if (redirectMode === "manual") return response;
179
+ if (redirectMode === "error") throw new OutboundPolicyError(`outbound request blocked — redirect received with redirect: "error" (${response.status} → ${location})`, { context: {
180
+ url: url.toString(),
181
+ location,
182
+ status: response.status
183
+ } });
184
+ if (hop >= policy.maxRedirects) throw new OutboundPolicyError(`outbound request blocked — more than ${policy.maxRedirects} redirects`, { context: {
185
+ url: rawUrl,
186
+ maxRedirects: policy.maxRedirects
187
+ } });
188
+ let target;
189
+ try {
190
+ target = new URL(location, url);
191
+ } catch {
192
+ throw new OutboundPolicyError(`outbound request blocked — invalid redirect Location: ${location}`, { context: {
193
+ url: url.toString(),
194
+ location
195
+ } });
196
+ }
197
+ const next = await assertUrlAllowed(target.toString(), policy);
198
+ if (response.body) await response.body.cancel().catch(() => void 0);
199
+ if (next.origin !== url.origin) for (const name of CROSS_ORIGIN_STRIP_HEADERS) delete headers[name];
200
+ if (response.status === 303 || (response.status === 301 || response.status === 302) && method !== "GET" && method !== "HEAD") {
201
+ method = "GET";
202
+ body = void 0;
203
+ }
204
+ url = next;
205
+ }
132
206
  } finally {
133
207
  clearTimeout(timer);
134
208
  }