@animalabs/agent-framework 0.1.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 (212) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -0
  3. package/dist/src/agent.d.ts +130 -0
  4. package/dist/src/agent.d.ts.map +1 -0
  5. package/dist/src/agent.js +406 -0
  6. package/dist/src/agent.js.map +1 -0
  7. package/dist/src/api/mcp-server.d.ts +37 -0
  8. package/dist/src/api/mcp-server.d.ts.map +1 -0
  9. package/dist/src/api/mcp-server.js +598 -0
  10. package/dist/src/api/mcp-server.js.map +1 -0
  11. package/dist/src/api/server.d.ts +83 -0
  12. package/dist/src/api/server.d.ts.map +1 -0
  13. package/dist/src/api/server.js +785 -0
  14. package/dist/src/api/server.js.map +1 -0
  15. package/dist/src/api/types.d.ts +395 -0
  16. package/dist/src/api/types.d.ts.map +1 -0
  17. package/dist/src/api/types.js +5 -0
  18. package/dist/src/api/types.js.map +1 -0
  19. package/dist/src/framework.d.ts +309 -0
  20. package/dist/src/framework.d.ts.map +1 -0
  21. package/dist/src/framework.js +2309 -0
  22. package/dist/src/framework.js.map +1 -0
  23. package/dist/src/gate/event-gate.d.ts +75 -0
  24. package/dist/src/gate/event-gate.d.ts.map +1 -0
  25. package/dist/src/gate/event-gate.js +503 -0
  26. package/dist/src/gate/event-gate.js.map +1 -0
  27. package/dist/src/gate/index.d.ts +3 -0
  28. package/dist/src/gate/index.d.ts.map +1 -0
  29. package/dist/src/gate/index.js +2 -0
  30. package/dist/src/gate/index.js.map +1 -0
  31. package/dist/src/gate/types.d.ts +82 -0
  32. package/dist/src/gate/types.d.ts.map +1 -0
  33. package/dist/src/gate/types.js +9 -0
  34. package/dist/src/gate/types.js.map +1 -0
  35. package/dist/src/index.d.ts +17 -0
  36. package/dist/src/index.d.ts.map +1 -0
  37. package/dist/src/index.js +18 -0
  38. package/dist/src/index.js.map +1 -0
  39. package/dist/src/mcpl/channel-registry.d.ts +141 -0
  40. package/dist/src/mcpl/channel-registry.d.ts.map +1 -0
  41. package/dist/src/mcpl/channel-registry.js +647 -0
  42. package/dist/src/mcpl/channel-registry.js.map +1 -0
  43. package/dist/src/mcpl/checkpoint-manager.d.ts +79 -0
  44. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -0
  45. package/dist/src/mcpl/checkpoint-manager.js +430 -0
  46. package/dist/src/mcpl/checkpoint-manager.js.map +1 -0
  47. package/dist/src/mcpl/errors.d.ts +52 -0
  48. package/dist/src/mcpl/errors.d.ts.map +1 -0
  49. package/dist/src/mcpl/errors.js +93 -0
  50. package/dist/src/mcpl/errors.js.map +1 -0
  51. package/dist/src/mcpl/feature-set-manager.d.ts +91 -0
  52. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -0
  53. package/dist/src/mcpl/feature-set-manager.js +250 -0
  54. package/dist/src/mcpl/feature-set-manager.js.map +1 -0
  55. package/dist/src/mcpl/hook-orchestrator.d.ts +51 -0
  56. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -0
  57. package/dist/src/mcpl/hook-orchestrator.js +217 -0
  58. package/dist/src/mcpl/hook-orchestrator.js.map +1 -0
  59. package/dist/src/mcpl/index.d.ts +14 -0
  60. package/dist/src/mcpl/index.d.ts.map +1 -0
  61. package/dist/src/mcpl/index.js +21 -0
  62. package/dist/src/mcpl/index.js.map +1 -0
  63. package/dist/src/mcpl/inference-router.d.ts +67 -0
  64. package/dist/src/mcpl/inference-router.d.ts.map +1 -0
  65. package/dist/src/mcpl/inference-router.js +249 -0
  66. package/dist/src/mcpl/inference-router.js.map +1 -0
  67. package/dist/src/mcpl/push-handler.d.ts +60 -0
  68. package/dist/src/mcpl/push-handler.d.ts.map +1 -0
  69. package/dist/src/mcpl/push-handler.js +162 -0
  70. package/dist/src/mcpl/push-handler.js.map +1 -0
  71. package/dist/src/mcpl/scope-manager.d.ts +76 -0
  72. package/dist/src/mcpl/scope-manager.d.ts.map +1 -0
  73. package/dist/src/mcpl/scope-manager.js +213 -0
  74. package/dist/src/mcpl/scope-manager.js.map +1 -0
  75. package/dist/src/mcpl/server-connection.d.ts +174 -0
  76. package/dist/src/mcpl/server-connection.d.ts.map +1 -0
  77. package/dist/src/mcpl/server-connection.js +559 -0
  78. package/dist/src/mcpl/server-connection.js.map +1 -0
  79. package/dist/src/mcpl/server-registry.d.ts +63 -0
  80. package/dist/src/mcpl/server-registry.d.ts.map +1 -0
  81. package/dist/src/mcpl/server-registry.js +119 -0
  82. package/dist/src/mcpl/server-registry.js.map +1 -0
  83. package/dist/src/mcpl/types.d.ts +755 -0
  84. package/dist/src/mcpl/types.d.ts.map +1 -0
  85. package/dist/src/mcpl/types.js +48 -0
  86. package/dist/src/mcpl/types.js.map +1 -0
  87. package/dist/src/module-registry.d.ts +93 -0
  88. package/dist/src/module-registry.d.ts.map +1 -0
  89. package/dist/src/module-registry.js +386 -0
  90. package/dist/src/module-registry.js.map +1 -0
  91. package/dist/src/modules/api/index.d.ts +22 -0
  92. package/dist/src/modules/api/index.d.ts.map +1 -0
  93. package/dist/src/modules/api/index.js +63 -0
  94. package/dist/src/modules/api/index.js.map +1 -0
  95. package/dist/src/modules/discord/discord-js-client.d.ts +58 -0
  96. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -0
  97. package/dist/src/modules/discord/discord-js-client.js +273 -0
  98. package/dist/src/modules/discord/discord-js-client.js.map +1 -0
  99. package/dist/src/modules/discord/index.d.ts +82 -0
  100. package/dist/src/modules/discord/index.d.ts.map +1 -0
  101. package/dist/src/modules/discord/index.js +959 -0
  102. package/dist/src/modules/discord/index.js.map +1 -0
  103. package/dist/src/modules/discord/types.d.ts +327 -0
  104. package/dist/src/modules/discord/types.d.ts.map +1 -0
  105. package/dist/src/modules/discord/types.js +5 -0
  106. package/dist/src/modules/discord/types.js.map +1 -0
  107. package/dist/src/modules/index.d.ts +11 -0
  108. package/dist/src/modules/index.d.ts.map +1 -0
  109. package/dist/src/modules/index.js +7 -0
  110. package/dist/src/modules/index.js.map +1 -0
  111. package/dist/src/modules/workspace/index.d.ts +59 -0
  112. package/dist/src/modules/workspace/index.d.ts.map +1 -0
  113. package/dist/src/modules/workspace/index.js +831 -0
  114. package/dist/src/modules/workspace/index.js.map +1 -0
  115. package/dist/src/modules/workspace/sync.d.ts +48 -0
  116. package/dist/src/modules/workspace/sync.d.ts.map +1 -0
  117. package/dist/src/modules/workspace/sync.js +253 -0
  118. package/dist/src/modules/workspace/sync.js.map +1 -0
  119. package/dist/src/modules/workspace/types.d.ts +166 -0
  120. package/dist/src/modules/workspace/types.d.ts.map +1 -0
  121. package/dist/src/modules/workspace/types.js +5 -0
  122. package/dist/src/modules/workspace/types.js.map +1 -0
  123. package/dist/src/modules/workspace/watcher.d.ts +43 -0
  124. package/dist/src/modules/workspace/watcher.d.ts.map +1 -0
  125. package/dist/src/modules/workspace/watcher.js +120 -0
  126. package/dist/src/modules/workspace/watcher.js.map +1 -0
  127. package/dist/src/queue.d.ts +50 -0
  128. package/dist/src/queue.d.ts.map +1 -0
  129. package/dist/src/queue.js +95 -0
  130. package/dist/src/queue.js.map +1 -0
  131. package/dist/src/safe-slice.d.ts +8 -0
  132. package/dist/src/safe-slice.d.ts.map +1 -0
  133. package/dist/src/safe-slice.js +18 -0
  134. package/dist/src/safe-slice.js.map +1 -0
  135. package/dist/src/types/agent.d.ts +118 -0
  136. package/dist/src/types/agent.d.ts.map +1 -0
  137. package/dist/src/types/agent.js +2 -0
  138. package/dist/src/types/agent.js.map +1 -0
  139. package/dist/src/types/events.d.ts +220 -0
  140. package/dist/src/types/events.d.ts.map +1 -0
  141. package/dist/src/types/events.js +2 -0
  142. package/dist/src/types/events.js.map +1 -0
  143. package/dist/src/types/framework.d.ts +184 -0
  144. package/dist/src/types/framework.d.ts.map +1 -0
  145. package/dist/src/types/framework.js +2 -0
  146. package/dist/src/types/framework.js.map +1 -0
  147. package/dist/src/types/index.d.ts +7 -0
  148. package/dist/src/types/index.d.ts.map +1 -0
  149. package/dist/src/types/index.js +7 -0
  150. package/dist/src/types/index.js.map +1 -0
  151. package/dist/src/types/inference-log.d.ts +64 -0
  152. package/dist/src/types/inference-log.d.ts.map +1 -0
  153. package/dist/src/types/inference-log.js +5 -0
  154. package/dist/src/types/inference-log.js.map +1 -0
  155. package/dist/src/types/module.d.ts +302 -0
  156. package/dist/src/types/module.d.ts.map +1 -0
  157. package/dist/src/types/module.js +2 -0
  158. package/dist/src/types/module.js.map +1 -0
  159. package/dist/src/types/trace.d.ts +173 -0
  160. package/dist/src/types/trace.d.ts.map +1 -0
  161. package/dist/src/types/trace.js +2 -0
  162. package/dist/src/types/trace.js.map +1 -0
  163. package/dist/test/event-gate.test.d.ts +2 -0
  164. package/dist/test/event-gate.test.d.ts.map +1 -0
  165. package/dist/test/event-gate.test.js +535 -0
  166. package/dist/test/event-gate.test.js.map +1 -0
  167. package/dist/test/framework.test.d.ts +5 -0
  168. package/dist/test/framework.test.d.ts.map +1 -0
  169. package/dist/test/framework.test.js +738 -0
  170. package/dist/test/framework.test.js.map +1 -0
  171. package/dist/tsconfig.tsbuildinfo +1 -0
  172. package/package.json +58 -0
  173. package/src/agent.ts +512 -0
  174. package/src/api/mcp-server.ts +687 -0
  175. package/src/api/server.ts +1023 -0
  176. package/src/api/types.ts +542 -0
  177. package/src/framework.ts +2784 -0
  178. package/src/gate/event-gate.ts +623 -0
  179. package/src/gate/index.ts +12 -0
  180. package/src/gate/types.ts +112 -0
  181. package/src/index.ts +63 -0
  182. package/src/mcpl/channel-registry.ts +824 -0
  183. package/src/mcpl/checkpoint-manager.ts +545 -0
  184. package/src/mcpl/errors.ts +114 -0
  185. package/src/mcpl/feature-set-manager.ts +333 -0
  186. package/src/mcpl/hook-orchestrator.ts +281 -0
  187. package/src/mcpl/index.ts +134 -0
  188. package/src/mcpl/inference-router.ts +335 -0
  189. package/src/mcpl/push-handler.ts +232 -0
  190. package/src/mcpl/scope-manager.ts +262 -0
  191. package/src/mcpl/server-connection.ts +694 -0
  192. package/src/mcpl/server-registry.ts +144 -0
  193. package/src/mcpl/types.ts +991 -0
  194. package/src/module-registry.ts +547 -0
  195. package/src/modules/api/index.ts +94 -0
  196. package/src/modules/discord/discord-js-client.ts +353 -0
  197. package/src/modules/discord/index.ts +1146 -0
  198. package/src/modules/discord/types.ts +465 -0
  199. package/src/modules/index.ts +43 -0
  200. package/src/modules/workspace/index.ts +969 -0
  201. package/src/modules/workspace/sync.ts +314 -0
  202. package/src/modules/workspace/types.ts +202 -0
  203. package/src/modules/workspace/watcher.ts +142 -0
  204. package/src/queue.ts +106 -0
  205. package/src/safe-slice.ts +18 -0
  206. package/src/types/agent.ts +119 -0
  207. package/src/types/events.ts +252 -0
  208. package/src/types/framework.ts +211 -0
  209. package/src/types/index.ts +6 -0
  210. package/src/types/inference-log.ts +64 -0
  211. package/src/types/module.ts +367 -0
  212. package/src/types/trace.ts +196 -0
@@ -0,0 +1,333 @@
1
+ /**
2
+ * FeatureSetManager — Permission gate for MCPL feature sets.
3
+ *
4
+ * Manages per-server feature set state: which feature sets each server declares,
5
+ * which are enabled/disabled, and validates inbound messages against that state.
6
+ *
7
+ * Spec references: Section 6 (Feature Sets), Appendix A (Error Codes).
8
+ */
9
+
10
+ import type {
11
+ McplCapabilities,
12
+ FeatureSetDeclaration,
13
+ FeatureSetsUpdateParams,
14
+ FeatureSetsChangedParams,
15
+ } from './types.js';
16
+
17
+ import {
18
+ FEATURE_SET_NOT_ENABLED,
19
+ UNKNOWN_FEATURE_SET,
20
+ } from './errors.js';
21
+
22
+ // ============================================================================
23
+ // Error Class
24
+ // ============================================================================
25
+
26
+ /**
27
+ * Error thrown when a feature set validation fails.
28
+ * Carries the JSON-RPC error code and the offending feature set name.
29
+ */
30
+ export class McplFeatureSetError extends Error {
31
+ constructor(
32
+ public readonly code: number,
33
+ public readonly featureSet: string,
34
+ message: string
35
+ ) {
36
+ super(message);
37
+ this.name = 'McplFeatureSetError';
38
+ }
39
+ }
40
+
41
+ // ============================================================================
42
+ // Internal Types
43
+ // ============================================================================
44
+
45
+ /** Per-server tracking state. */
46
+ interface ServerState {
47
+ /** All declared feature sets (keyed by name). */
48
+ declared: Record<string, FeatureSetDeclaration>;
49
+ /** Set of currently enabled feature set names. */
50
+ enabled: Set<string>;
51
+ }
52
+
53
+ // ============================================================================
54
+ // Wildcard Matching
55
+ // ============================================================================
56
+
57
+ /**
58
+ * Check whether a wildcard pattern matches a feature set name.
59
+ *
60
+ * Rules:
61
+ * - Split both pattern and name on `.`
62
+ * - `*` in a pattern segment matches exactly one segment (any value)
63
+ * - Literal segments must match exactly
64
+ * - Pattern and name must have the same number of segments
65
+ * (unless the last pattern segment is `*`, which matches one segment)
66
+ *
67
+ * Examples:
68
+ * `memory.*` matches `memory.retrieval` but not `memory.a.b`
69
+ * `memory.retrieval` matches only `memory.retrieval`
70
+ */
71
+ function wildcardMatch(pattern: string, name: string): boolean {
72
+ const patternParts = pattern.split('.');
73
+ const nameParts = name.split('.');
74
+
75
+ if (patternParts.length !== nameParts.length) {
76
+ return false;
77
+ }
78
+
79
+ for (let i = 0; i < patternParts.length; i++) {
80
+ const p = patternParts[i];
81
+ if (p === '*') {
82
+ // Wildcard segment matches any single segment
83
+ continue;
84
+ }
85
+ if (p !== nameParts[i]) {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ return true;
91
+ }
92
+
93
+ /**
94
+ * Find all declared feature set names that match any of the given patterns.
95
+ * Patterns may contain wildcards (e.g., `memory.*`).
96
+ */
97
+ function resolvePatterns(
98
+ patterns: string[],
99
+ declared: Record<string, FeatureSetDeclaration>
100
+ ): string[] {
101
+ const matched = new Set<string>();
102
+ const declaredNames = Object.keys(declared);
103
+
104
+ for (const pattern of patterns) {
105
+ for (const name of declaredNames) {
106
+ if (wildcardMatch(pattern, name)) {
107
+ matched.add(name);
108
+ }
109
+ }
110
+ }
111
+
112
+ return [...matched];
113
+ }
114
+
115
+ // ============================================================================
116
+ // FeatureSetManager
117
+ // ============================================================================
118
+
119
+ /**
120
+ * Manages feature set state across all MCPL servers.
121
+ *
122
+ * This is the permission gate that all MCPL interactions pass through.
123
+ * Every inbound server message tagged with a `featureSet` must be validated
124
+ * through this manager before processing.
125
+ */
126
+ export class FeatureSetManager {
127
+ /** Per-server state, keyed by server ID. */
128
+ private servers = new Map<string, ServerState>();
129
+
130
+ /**
131
+ * Initialize a server's feature set state from its capabilities and host config.
132
+ *
133
+ * Called after a server connects and advertises its capabilities.
134
+ * Returns the `FeatureSetsUpdateParams` that should be sent to the server
135
+ * via `featureSets/update`.
136
+ *
137
+ * Initialization logic:
138
+ * 1. Store all declared feature sets from capabilities.
139
+ * 2. If `config.enabledFeatureSets` is provided, enable matching sets (supports wildcards).
140
+ * 3. If `config.disabledFeatureSets` is provided, disable matching sets (supports wildcards).
141
+ * 4. Feature sets not mentioned in either list default to disabled.
142
+ */
143
+ initializeServer(
144
+ serverId: string,
145
+ capabilities: McplCapabilities,
146
+ config?: { enabledFeatureSets?: string[]; disabledFeatureSets?: string[] }
147
+ ): FeatureSetsUpdateParams {
148
+ const declared = capabilities.featureSets ?? {};
149
+
150
+ const state: ServerState = {
151
+ declared: { ...declared },
152
+ enabled: new Set<string>(),
153
+ };
154
+
155
+ // Resolve which feature sets to enable
156
+ const enablePatterns = config?.enabledFeatureSets ?? [];
157
+ const disablePatterns = config?.disabledFeatureSets ?? [];
158
+
159
+ const toEnable = resolvePatterns(enablePatterns, declared);
160
+ const toDisable = resolvePatterns(disablePatterns, declared);
161
+
162
+ // Enable matching sets
163
+ for (const name of toEnable) {
164
+ state.enabled.add(name);
165
+ }
166
+
167
+ // Disable explicitly overrides enable (disable wins on conflict)
168
+ for (const name of toDisable) {
169
+ state.enabled.delete(name);
170
+ }
171
+
172
+ this.servers.set(serverId, state);
173
+
174
+ // Build the update params to send to the server
175
+ const enabled = [...state.enabled];
176
+ const allDeclared = Object.keys(declared);
177
+ const disabled = allDeclared.filter((n) => !state.enabled.has(n));
178
+
179
+ const params: FeatureSetsUpdateParams = {};
180
+ if (enabled.length > 0) {
181
+ params.enabled = enabled;
182
+ }
183
+ if (disabled.length > 0) {
184
+ params.disabled = disabled;
185
+ }
186
+
187
+ return params;
188
+ }
189
+
190
+ /**
191
+ * Remove all tracking state for a server.
192
+ * Called when a server disconnects.
193
+ */
194
+ removeServer(serverId: string): void {
195
+ this.servers.delete(serverId);
196
+ }
197
+
198
+ /**
199
+ * Check whether a feature set is enabled for a server.
200
+ * Returns false if the server is unknown or the feature set is not enabled.
201
+ */
202
+ isEnabled(serverId: string, featureSet: string): boolean {
203
+ const state = this.servers.get(serverId);
204
+ if (!state) {
205
+ return false;
206
+ }
207
+ return state.enabled.has(featureSet);
208
+ }
209
+
210
+ /**
211
+ * Validate that an inbound message's feature set is declared and enabled.
212
+ *
213
+ * @throws McplFeatureSetError with code -32003 if the feature set is unknown
214
+ * @throws McplFeatureSetError with code -32001 if the feature set is not enabled
215
+ */
216
+ validateInbound(serverId: string, featureSet: string): void {
217
+ const state = this.servers.get(serverId);
218
+
219
+ if (!state) {
220
+ throw new McplFeatureSetError(
221
+ UNKNOWN_FEATURE_SET,
222
+ featureSet,
223
+ `Unknown server: ${serverId}`
224
+ );
225
+ }
226
+
227
+ if (!(featureSet in state.declared)) {
228
+ throw new McplFeatureSetError(
229
+ UNKNOWN_FEATURE_SET,
230
+ featureSet,
231
+ `Unknown feature set: ${featureSet}`
232
+ );
233
+ }
234
+
235
+ if (!state.enabled.has(featureSet)) {
236
+ throw new McplFeatureSetError(
237
+ FEATURE_SET_NOT_ENABLED,
238
+ featureSet,
239
+ `Feature set not enabled: ${featureSet}`
240
+ );
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Enable feature sets for a server (supports wildcard patterns).
246
+ * Only declared feature sets can be enabled.
247
+ */
248
+ enable(serverId: string, featureSets: string[]): void {
249
+ const state = this.servers.get(serverId);
250
+ if (!state) {
251
+ return;
252
+ }
253
+
254
+ const resolved = resolvePatterns(featureSets, state.declared);
255
+ for (const name of resolved) {
256
+ state.enabled.add(name);
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Disable feature sets for a server (supports wildcard patterns).
262
+ * Only declared feature sets can be disabled.
263
+ */
264
+ disable(serverId: string, featureSets: string[]): void {
265
+ const state = this.servers.get(serverId);
266
+ if (!state) {
267
+ return;
268
+ }
269
+
270
+ const resolved = resolvePatterns(featureSets, state.declared);
271
+ for (const name of resolved) {
272
+ state.enabled.delete(name);
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Handle a `featureSets/changed` notification from a server.
278
+ *
279
+ * - Adds new feature sets from `params.added` (default to disabled).
280
+ * - Removes feature sets listed in `params.removed`.
281
+ */
282
+ handleFeatureSetsChanged(
283
+ serverId: string,
284
+ params: FeatureSetsChangedParams
285
+ ): void {
286
+ const state = this.servers.get(serverId);
287
+ if (!state) {
288
+ return;
289
+ }
290
+
291
+ // Add new feature sets (default to disabled)
292
+ if (params.added) {
293
+ for (const [name, declaration] of Object.entries(params.added)) {
294
+ state.declared[name] = declaration;
295
+ // New feature sets default to disabled — do not add to enabled set
296
+ }
297
+ }
298
+
299
+ // Remove feature sets
300
+ if (params.removed) {
301
+ for (const name of params.removed) {
302
+ delete state.declared[name];
303
+ state.enabled.delete(name);
304
+ }
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Get all declared feature sets for a server.
310
+ * Returns null if the server is unknown.
311
+ */
312
+ getDeclaredFeatureSets(
313
+ serverId: string
314
+ ): Record<string, FeatureSetDeclaration> | null {
315
+ const state = this.servers.get(serverId);
316
+ if (!state) {
317
+ return null;
318
+ }
319
+ return { ...state.declared };
320
+ }
321
+
322
+ /**
323
+ * Get the names of all enabled feature sets for a server.
324
+ * Returns an empty array if the server is unknown.
325
+ */
326
+ getEnabledFeatureSets(serverId: string): string[] {
327
+ const state = this.servers.get(serverId);
328
+ if (!state) {
329
+ return [];
330
+ }
331
+ return [...state.enabled];
332
+ }
333
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * HookOrchestrator — manages before/after inference fan-out to MCPL servers.
3
+ *
4
+ * This is the bridge between the MCPL protocol and the agent-framework inference
5
+ * pipeline. It collects ContextInjection[] from MCPL servers via beforeInference
6
+ * and feeds them into context-manager's compile(). After inference, it notifies
7
+ * servers of the result via afterInference.
8
+ *
9
+ * Design principles:
10
+ * - Fail-open: timeouts and errors never block inference
11
+ * - Parallel fan-out with per-server timeouts
12
+ * - Loop prevention: rejects inference/request while inside a hook
13
+ */
14
+
15
+ import type { ContentBlock } from '@animalabs/membrane';
16
+ import type { ContextInjection } from '@animalabs/context-manager';
17
+
18
+ import type {
19
+ McplContentBlock,
20
+ McplContextInjection,
21
+ BeforeInferenceParams,
22
+ BeforeInferenceResult,
23
+ AfterInferenceParams,
24
+ AfterInferenceResult,
25
+ } from './types.js';
26
+ import type { McplServerRegistry } from './server-registry.js';
27
+ import type { McplServerConnection } from './server-connection.js';
28
+ import type { FeatureSetManager } from './feature-set-manager.js';
29
+
30
+ /** Timeout for beforeInference per server (fail-open). */
31
+ const BEFORE_INFERENCE_TIMEOUT_MS = 5_000;
32
+
33
+ /** Timeout for blocking afterInference per server. */
34
+ const AFTER_INFERENCE_BLOCKING_TIMEOUT_MS = 10_000;
35
+
36
+ /**
37
+ * Races a promise against a timeout. Rejects with a descriptive error on timeout.
38
+ */
39
+ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
40
+ return Promise.race([
41
+ promise,
42
+ new Promise<never>((_resolve, reject) =>
43
+ setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms),
44
+ ),
45
+ ]);
46
+ }
47
+
48
+ // ============================================================================
49
+ // Content conversion: McplContentBlock / McplContextInjection → membrane types
50
+ // ============================================================================
51
+
52
+ /**
53
+ * Convert a single MCPL wire-format content block to a membrane ContentBlock.
54
+ */
55
+ function convertBlock(block: McplContentBlock): ContentBlock {
56
+ switch (block.type) {
57
+ case 'text':
58
+ return { type: 'text', text: block.text };
59
+
60
+ case 'image':
61
+ if (block.data && block.mimeType) {
62
+ return {
63
+ type: 'image',
64
+ source: { type: 'base64', data: block.data, mediaType: block.mimeType },
65
+ };
66
+ }
67
+ if (block.uri) {
68
+ return {
69
+ type: 'image',
70
+ source: { type: 'url', url: block.uri },
71
+ };
72
+ }
73
+ // Malformed image block — degrade to text
74
+ return { type: 'text', text: '[Image: missing data]' };
75
+
76
+ case 'audio':
77
+ if (block.data && block.mimeType) {
78
+ return {
79
+ type: 'audio',
80
+ source: { type: 'base64', data: block.data, mediaType: block.mimeType },
81
+ };
82
+ }
83
+ // Audio without inline data — degrade to text
84
+ return { type: 'text', text: `[Audio: ${block.uri ?? 'missing data'}]` };
85
+
86
+ case 'resource':
87
+ // Resources don't have a direct membrane equivalent — degrade to text
88
+ return { type: 'text', text: `[Resource: ${block.uri}]` };
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Convert MCPL injection content (string shorthand or block array) to membrane ContentBlock[].
94
+ */
95
+ function convertContent(content: string | McplContentBlock[]): ContentBlock[] {
96
+ if (typeof content === 'string') {
97
+ return [{ type: 'text', text: content }];
98
+ }
99
+ return content.map(convertBlock);
100
+ }
101
+
102
+ /**
103
+ * Convert an MCPL wire-format context injection to a context-manager ContextInjection.
104
+ */
105
+ function convertMcplInjection(mcplInj: McplContextInjection): ContextInjection {
106
+ return {
107
+ namespace: mcplInj.namespace,
108
+ position: mcplInj.position,
109
+ content: convertContent(mcplInj.content),
110
+ metadata: mcplInj.metadata,
111
+ };
112
+ }
113
+
114
+ // ============================================================================
115
+ // HookOrchestrator
116
+ // ============================================================================
117
+
118
+ export class HookOrchestrator {
119
+ private registry: McplServerRegistry;
120
+ private featureSetManager: FeatureSetManager;
121
+ private _isInHook = false;
122
+
123
+ constructor(registry: McplServerRegistry, featureSetManager: FeatureSetManager) {
124
+ this.registry = registry;
125
+ this.featureSetManager = featureSetManager;
126
+ }
127
+
128
+ /**
129
+ * Whether a hook is currently executing.
130
+ * Used for loop prevention: inference/request from servers should be rejected
131
+ * while this is true (enforced by Step 6's InferenceRouter).
132
+ */
133
+ get isInHook(): boolean {
134
+ return this._isInHook;
135
+ }
136
+
137
+ /**
138
+ * Fan out `context/beforeInference` to all capable MCPL servers in parallel.
139
+ *
140
+ * Returns aggregated ContextInjection[] ready for context-manager's compile().
141
+ * Fail-open: servers that time out or error are silently skipped.
142
+ */
143
+ async beforeInference(params: BeforeInferenceParams): Promise<ContextInjection[]> {
144
+ const servers = this.registry.getServersWithCapability('contextHooks.beforeInference');
145
+ if (servers.length === 0) {
146
+ return [];
147
+ }
148
+
149
+ this._isInHook = true;
150
+ try {
151
+ return await this.fanOutBeforeInference(servers, params);
152
+ } finally {
153
+ this._isInHook = false;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Fan out `context/afterInference` to all capable MCPL servers.
159
+ *
160
+ * Non-blocking servers receive a notification (fire-and-forget).
161
+ * Blocking servers receive a request with a 10s timeout.
162
+ * Returns the first valid AfterInferenceResult from a blocking server, or null.
163
+ */
164
+ async afterInference(params: AfterInferenceParams): Promise<AfterInferenceResult | null> {
165
+ const servers = this.registry.getServersWithCapability('contextHooks.afterInference');
166
+ if (servers.length === 0) {
167
+ return null;
168
+ }
169
+
170
+ this._isInHook = true;
171
+ try {
172
+ return await this.fanOutAfterInference(servers, params);
173
+ } finally {
174
+ this._isInHook = false;
175
+ }
176
+ }
177
+
178
+ // ==========================================================================
179
+ // Private: beforeInference fan-out
180
+ // ==========================================================================
181
+
182
+ private async fanOutBeforeInference(
183
+ servers: McplServerConnection[],
184
+ params: BeforeInferenceParams,
185
+ ): Promise<ContextInjection[]> {
186
+ const results = await Promise.allSettled(
187
+ servers.map((server) =>
188
+ withTimeout(
189
+ server.sendBeforeInference(params),
190
+ BEFORE_INFERENCE_TIMEOUT_MS,
191
+ `beforeInference to "${server.id}"`,
192
+ ).then((result) => ({ server, result })),
193
+ ),
194
+ );
195
+
196
+ const injections: ContextInjection[] = [];
197
+
198
+ for (const settled of results) {
199
+ if (settled.status === 'rejected') {
200
+ // Fail-open: timeout or transport error — skip this server
201
+ continue;
202
+ }
203
+
204
+ const { server, result } = settled.value;
205
+
206
+ // Validate feature set
207
+ try {
208
+ this.featureSetManager.validateInbound(server.id, result.featureSet);
209
+ } catch {
210
+ // Feature set not enabled or unknown — skip injections from this server
211
+ continue;
212
+ }
213
+
214
+ // Convert wire-format injections to context-manager format
215
+ if (result.contextInjections && result.contextInjections.length > 0) {
216
+ for (const mcplInj of result.contextInjections) {
217
+ injections.push(convertMcplInjection(mcplInj));
218
+ }
219
+ }
220
+ }
221
+
222
+ return injections;
223
+ }
224
+
225
+ // ==========================================================================
226
+ // Private: afterInference fan-out
227
+ // ==========================================================================
228
+
229
+ private async fanOutAfterInference(
230
+ servers: McplServerConnection[],
231
+ params: AfterInferenceParams,
232
+ ): Promise<AfterInferenceResult | null> {
233
+ const blockingPromises: Promise<AfterInferenceResult | null>[] = [];
234
+
235
+ for (const server of servers) {
236
+ const isBlocking = this.isBlockingAfterInference(server);
237
+
238
+ if (isBlocking) {
239
+ // Send as request, await response with timeout
240
+ const promise = withTimeout(
241
+ server.sendAfterInference(params, true) as Promise<AfterInferenceResult>,
242
+ AFTER_INFERENCE_BLOCKING_TIMEOUT_MS,
243
+ `afterInference (blocking) to "${server.id}"`,
244
+ ).catch((): null => {
245
+ // Fail-open: timeout or error — treat as no modification
246
+ return null;
247
+ });
248
+ blockingPromises.push(promise);
249
+ } else {
250
+ // Send as notification (fire-and-forget)
251
+ server.sendAfterInference(params, false);
252
+ }
253
+ }
254
+
255
+ if (blockingPromises.length === 0) {
256
+ return null;
257
+ }
258
+
259
+ // Await all blocking responses and return the first valid result
260
+ const results = await Promise.all(blockingPromises);
261
+ for (const result of results) {
262
+ if (result && result.modifiedResponse) {
263
+ return result;
264
+ }
265
+ }
266
+
267
+ return null;
268
+ }
269
+
270
+ /**
271
+ * Check if a server's afterInference capability is blocking.
272
+ */
273
+ private isBlockingAfterInference(server: McplServerConnection): boolean {
274
+ const caps = server.capabilities;
275
+ if (!caps?.contextHooks?.afterInference) {
276
+ return false;
277
+ }
278
+ const afterCap = caps.contextHooks.afterInference;
279
+ return typeof afterCap === 'object' && afterCap !== null && 'blocking' in afterCap && afterCap.blocking === true;
280
+ }
281
+ }