@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,2309 @@
1
+ import { join } from 'node:path';
2
+ import { JsStore } from '@animalabs/chronicle';
3
+ import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager';
4
+ import { ProcessQueueImpl } from './queue.js';
5
+ import { Agent } from './agent.js';
6
+ import { ModuleRegistry } from './module-registry.js';
7
+ import { McplServerRegistry } from './mcpl/server-registry.js';
8
+ import { FeatureSetManager } from './mcpl/feature-set-manager.js';
9
+ import { ScopeManager } from './mcpl/scope-manager.js';
10
+ import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
11
+ import { PushHandler } from './mcpl/push-handler.js';
12
+ import { InferenceRouter } from './mcpl/inference-router.js';
13
+ import { ChannelRegistry } from './mcpl/channel-registry.js';
14
+ import { safeSlice } from './safe-slice.js';
15
+ import { CheckpointManager } from './mcpl/checkpoint-manager.js';
16
+ import { EventGate } from './gate/event-gate.js';
17
+ const FRAMEWORK_STATE_ID = 'framework/state';
18
+ const INFERENCE_LOG_ID = 'framework/inference-log';
19
+ const PROCESS_LOG_ID = 'framework/process-log';
20
+ const TURN_CHECKPOINTS_ID = 'framework/turn-checkpoints';
21
+ /** Maximum number of turn checkpoints to keep per agent. */
22
+ const MAX_TURN_CHECKPOINTS = 20;
23
+ /**
24
+ * Default inference policy - infer if any request exists for the agent.
25
+ */
26
+ class DefaultInferencePolicy {
27
+ shouldInfer(agentName, requests, _state) {
28
+ return requests.some((r) => r.agentName === agentName);
29
+ }
30
+ }
31
+ /**
32
+ * Default error policy - retry with exponential backoff.
33
+ */
34
+ class DefaultErrorPolicy {
35
+ maxRetries = 3;
36
+ onInferenceError(error, _agentName, attempt) {
37
+ if (attempt < this.maxRetries) {
38
+ return { retry: true, delayMs: Math.pow(2, attempt) * 1000 };
39
+ }
40
+ return { retry: false };
41
+ }
42
+ }
43
+ /** Default sync interval in milliseconds */
44
+ const DEFAULT_SYNC_INTERVAL_MS = 1000;
45
+ /**
46
+ * The main agent framework.
47
+ */
48
+ export class AgentFramework {
49
+ store;
50
+ ownsStore;
51
+ membrane;
52
+ queue;
53
+ agents = new Map();
54
+ moduleRegistry;
55
+ inferencePolicy;
56
+ errorPolicy;
57
+ pendingRequests = [];
58
+ running = false;
59
+ loopPromise = null;
60
+ traceListeners = [];
61
+ syncIntervalMs;
62
+ syncTimer = null;
63
+ processLoggingPersist;
64
+ processLoggingBroadcast;
65
+ activeStreams = new Map();
66
+ pendingAssistantBlocks = new Map();
67
+ /** Name of the primary (non-ephemeral) agent for routing framework messages. */
68
+ primaryAgentName = null;
69
+ // Messages deferred while an agent is waiting_for_tools (to preserve
70
+ // tool_use → tool_result adjacency required by the Anthropic API).
71
+ deferredMessages = [];
72
+ // Undo/redo state
73
+ turnCounters = new Map(); // agentName → next turnIndex
74
+ redoStacks = new Map(); // agentName → redo entries
75
+ // MCPL subsystems (null when no mcplServers configured)
76
+ mcplServerRegistry = null;
77
+ featureSetManager = null;
78
+ scopeManager = null;
79
+ hookOrchestrator = null;
80
+ pushHandler = null;
81
+ inferenceRouter = null;
82
+ channelRegistry = null;
83
+ checkpointManager = null;
84
+ mcplTools = [];
85
+ mcplToolRefreshInFlight = false;
86
+ mcplToolRefreshPending = false;
87
+ /** Maps tool prefix → serverId for dispatch routing. */
88
+ mcplPrefixMap = new Map();
89
+ /** Maps serverId → McplServerConfig for prefix lookup. */
90
+ mcplServerConfigs = new Map();
91
+ // EventGate (null when FrameworkConfig.gate is omitted)
92
+ eventGate = null;
93
+ constructor(store, ownsStore, membrane, inferencePolicy, errorPolicy, syncIntervalMs, processLoggingPersist, processLoggingBroadcast) {
94
+ this.store = store;
95
+ this.ownsStore = ownsStore;
96
+ this.membrane = membrane;
97
+ this.inferencePolicy = inferencePolicy;
98
+ this.errorPolicy = errorPolicy;
99
+ this.syncIntervalMs = syncIntervalMs;
100
+ this.processLoggingPersist = processLoggingPersist;
101
+ this.processLoggingBroadcast = processLoggingBroadcast;
102
+ this.queue = new ProcessQueueImpl();
103
+ // Initialize module registry with callbacks
104
+ this.moduleRegistry = new ModuleRegistry(store, this.queue, {
105
+ getAgents: () => Array.from(this.agents.values()),
106
+ addMessage: (p, c, m) => this.addMessage(p, c, m),
107
+ editMessage: (id, c) => this.editMessage(id, c),
108
+ removeMessage: (id) => this.removeMessage(id),
109
+ getMessage: (id) => this.getMessage(id),
110
+ queryMessages: (filter) => this.queryMessages(filter),
111
+ pushEvent: (event) => this.pushEvent(event),
112
+ });
113
+ }
114
+ /**
115
+ * Create and start the framework.
116
+ */
117
+ static async create(config) {
118
+ // Create or use existing store
119
+ let store;
120
+ let ownsStore;
121
+ if (config.store) {
122
+ store = config.store;
123
+ ownsStore = false;
124
+ }
125
+ else if (config.storePath) {
126
+ store = JsStore.openOrCreate({ path: config.storePath });
127
+ ownsStore = true;
128
+ }
129
+ else {
130
+ throw new Error('Either storePath or store must be provided');
131
+ }
132
+ // Register framework states
133
+ try {
134
+ store.registerState({ id: FRAMEWORK_STATE_ID, strategy: 'snapshot' });
135
+ }
136
+ catch {
137
+ // Already registered
138
+ }
139
+ try {
140
+ store.registerState({
141
+ id: INFERENCE_LOG_ID,
142
+ strategy: 'append_log',
143
+ deltaSnapshotEvery: 100,
144
+ fullSnapshotEvery: 20,
145
+ });
146
+ }
147
+ catch {
148
+ // Already registered
149
+ }
150
+ try {
151
+ store.registerState({ id: TURN_CHECKPOINTS_ID, strategy: 'snapshot' });
152
+ }
153
+ catch {
154
+ // Already registered
155
+ }
156
+ // Process logging config (default: disabled)
157
+ const processLoggingPersist = config.processLogging?.persist ?? false;
158
+ const processLoggingBroadcast = config.processLogging?.broadcast ?? false;
159
+ // Register process log state only if persistence is enabled
160
+ if (processLoggingPersist) {
161
+ try {
162
+ store.registerState({
163
+ id: PROCESS_LOG_ID,
164
+ strategy: 'append_log',
165
+ deltaSnapshotEvery: 100,
166
+ fullSnapshotEvery: 20,
167
+ });
168
+ }
169
+ catch {
170
+ // Already registered
171
+ }
172
+ }
173
+ const framework = new AgentFramework(store, ownsStore, config.membrane, config.inferencePolicy ?? new DefaultInferencePolicy(), config.errorPolicy ?? new DefaultErrorPolicy(), config.syncIntervalMs ?? DEFAULT_SYNC_INTERVAL_MS, processLoggingPersist, processLoggingBroadcast);
174
+ // Create agents
175
+ for (const agentConfig of config.agents) {
176
+ await framework.createAgent(agentConfig);
177
+ }
178
+ // Add modules
179
+ for (const module of config.modules) {
180
+ await framework.addModule(module);
181
+ }
182
+ // Initialize EventGate if configured (before MCPL so it can be wired as trigger filter)
183
+ if (config.gate) {
184
+ const configPath = config.gate.configPath
185
+ ?? (config.storePath
186
+ ? join(config.storePath, 'config', 'gate.json')
187
+ : './data/gate.json');
188
+ framework.eventGate = new EventGate({
189
+ configPath,
190
+ initialConfig: config.gate.config,
191
+ emitTrace: (e) => framework.emitTrace(e),
192
+ addMessage: (p, c, m) => framework.addMessage(p, c, m),
193
+ requestInference: (agentName, reason, source) => {
194
+ framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
195
+ },
196
+ getAgentNames: () => [...framework.agents.keys()],
197
+ });
198
+ }
199
+ // Initialize MCPL subsystems if configured
200
+ if (config.mcplServers && config.mcplServers.length > 0) {
201
+ // Validate tool prefixes: no collisions with module names or between servers
202
+ const moduleNames = new Set(config.modules.map(m => m.name));
203
+ const prefixesSeen = new Map(); // prefix → serverId
204
+ for (const serverConfig of config.mcplServers) {
205
+ const prefix = serverConfig.toolPrefix ?? `mcpl--${serverConfig.id}`;
206
+ if (moduleNames.has(prefix)) {
207
+ throw new Error(`MCPL server "${serverConfig.id}" toolPrefix "${prefix}" collides with module "${prefix}"`);
208
+ }
209
+ const existing = prefixesSeen.get(prefix);
210
+ if (existing) {
211
+ throw new Error(`MCPL server "${serverConfig.id}" toolPrefix "${prefix}" collides with server "${existing}"`);
212
+ }
213
+ prefixesSeen.set(prefix, serverConfig.id);
214
+ }
215
+ await framework.initializeMcpl(config.mcplServers, config.inferenceRouting);
216
+ }
217
+ return framework;
218
+ }
219
+ /**
220
+ * Start the event loop.
221
+ */
222
+ start() {
223
+ if (this.running) {
224
+ return;
225
+ }
226
+ this.running = true;
227
+ this.loopPromise = this.runLoop();
228
+ // Start periodic sync timer (if enabled)
229
+ if (this.syncIntervalMs > 0) {
230
+ this.syncTimer = setInterval(() => {
231
+ try {
232
+ this.store.sync();
233
+ }
234
+ catch (error) {
235
+ console.error('Periodic sync error:', error);
236
+ }
237
+ }, this.syncIntervalMs);
238
+ }
239
+ }
240
+ /**
241
+ * Stop the event loop.
242
+ */
243
+ async stop() {
244
+ this.running = false;
245
+ this.queue.close();
246
+ // Cancel all active streams
247
+ for (const agent of this.agents.values()) {
248
+ if (agent.state.status === 'streaming' ||
249
+ (agent.state.status === 'waiting_for_tools' && agent.state.stream)) {
250
+ agent.cancelStream();
251
+ }
252
+ }
253
+ // Wait for all stream iteration handles to settle
254
+ if (this.activeStreams.size > 0) {
255
+ await Promise.allSettled(this.activeStreams.values());
256
+ }
257
+ // Stop sync timer
258
+ if (this.syncTimer) {
259
+ clearInterval(this.syncTimer);
260
+ this.syncTimer = null;
261
+ }
262
+ if (this.loopPromise) {
263
+ await this.loopPromise;
264
+ }
265
+ // Stop typing indicators
266
+ this.channelRegistry?.stopAll();
267
+ // Dispose EventGate (clear debounce timers)
268
+ this.eventGate?.dispose();
269
+ // Stop modules and MCPL servers in parallel
270
+ const shutdownPromises = [this.moduleRegistry.stopAll()];
271
+ if (this.mcplServerRegistry) {
272
+ shutdownPromises.push(this.mcplServerRegistry.closeAll());
273
+ }
274
+ await Promise.all(shutdownPromises);
275
+ // Final sync before closing
276
+ try {
277
+ this.store.sync();
278
+ }
279
+ catch (error) {
280
+ console.error('Final sync error:', error);
281
+ }
282
+ if (this.ownsStore) {
283
+ this.store.close();
284
+ }
285
+ }
286
+ /**
287
+ * Push a process event to the queue.
288
+ */
289
+ pushEvent(event) {
290
+ this.queue.push(event);
291
+ this.emitTrace({ type: 'process:received', processEvent: event });
292
+ }
293
+ /**
294
+ * Add a trace event listener for observability.
295
+ */
296
+ onTrace(listener) {
297
+ this.traceListeners.push(listener);
298
+ }
299
+ /**
300
+ * Remove a trace event listener.
301
+ */
302
+ offTrace(listener) {
303
+ const index = this.traceListeners.indexOf(listener);
304
+ if (index >= 0) {
305
+ this.traceListeners.splice(index, 1);
306
+ }
307
+ }
308
+ /**
309
+ * Add a module at runtime.
310
+ */
311
+ async addModule(module) {
312
+ await this.moduleRegistry.addModule(module);
313
+ this.emitTrace({ type: 'module:added', moduleName: module.name });
314
+ }
315
+ /**
316
+ * Remove a module at runtime.
317
+ */
318
+ async removeModule(name) {
319
+ await this.moduleRegistry.removeModule(name);
320
+ this.emitTrace({ type: 'module:removed', moduleName: name });
321
+ }
322
+ /**
323
+ * Get an agent by name.
324
+ */
325
+ getAgent(name) {
326
+ return this.agents.get(name) ?? null;
327
+ }
328
+ /**
329
+ * Get all agents.
330
+ */
331
+ getAllAgents() {
332
+ return Array.from(this.agents.values());
333
+ }
334
+ /**
335
+ * Abort an in-flight inference for an agent.
336
+ */
337
+ abortInference(agentName, reason) {
338
+ const agent = this.agents.get(agentName);
339
+ if (!agent) {
340
+ return false;
341
+ }
342
+ const result = agent.abortInference(reason);
343
+ if (result) {
344
+ this.emitTrace({ type: 'inference:aborted', agentName, reason, durationMs: result.durationMs });
345
+ }
346
+ return !!result;
347
+ }
348
+ /**
349
+ * Get all registered modules.
350
+ */
351
+ getAllModules() {
352
+ return this.moduleRegistry.getAllModules();
353
+ }
354
+ /**
355
+ * Get all available tools from all modules and MCPL servers.
356
+ */
357
+ getAllTools() {
358
+ const moduleTools = this.moduleRegistry.getAllTools();
359
+ const channelTools = this.channelRegistry?.getChannelTools() ?? [];
360
+ const gateTools = this.eventGate ? [this.eventGate.getToolDefinition()] : [];
361
+ if (this.mcplTools.length === 0 && channelTools.length === 0 && gateTools.length === 0) {
362
+ return moduleTools;
363
+ }
364
+ return [...moduleTools, ...this.mcplTools, ...channelTools, ...gateTools];
365
+ }
366
+ /**
367
+ * Check if process logging is enabled.
368
+ */
369
+ isProcessLoggingEnabled() {
370
+ return {
371
+ persist: this.processLoggingPersist,
372
+ broadcast: this.processLoggingBroadcast,
373
+ };
374
+ }
375
+ /**
376
+ * Get the underlying store.
377
+ */
378
+ getStore() {
379
+ return this.store;
380
+ }
381
+ /**
382
+ * Get a registered module by name.
383
+ */
384
+ getModule(name) {
385
+ return this.moduleRegistry.getModule(name);
386
+ }
387
+ /**
388
+ * Get the Membrane instance.
389
+ */
390
+ getMembrane() {
391
+ return this.membrane;
392
+ }
393
+ /**
394
+ * Create an ephemeral agent that is NOT registered in the main event loop.
395
+ *
396
+ * Used by SubagentModule (and similar) to create short-lived agents
397
+ * that are driven externally (not by the framework's event loop).
398
+ * The returned agent has its own ContextManager on a namespaced state
399
+ * within the framework's shared Chronicle store — messages go to
400
+ * `{namespace}/messages`, context log to `{namespace}/context`.
401
+ *
402
+ * Data persists after cleanup for investigation and cross-revert.
403
+ * Call cleanup() when done to release the ContextManager.
404
+ */
405
+ async createEphemeralAgent(config) {
406
+ const namespace = `subagent/${config.name}`;
407
+ const contextManager = await ContextManager.open({
408
+ store: this.store,
409
+ namespace,
410
+ isolate: true,
411
+ strategy: config.strategy ?? new PassthroughStrategy(),
412
+ membrane: this.membrane,
413
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
414
+ });
415
+ const agent = new Agent(config, contextManager, this.membrane);
416
+ const cleanup = () => {
417
+ // Don't close the store — it's shared. Just release the CM.
418
+ // Data persists in the store under the namespace for investigation.
419
+ };
420
+ return { agent, contextManager, cleanup };
421
+ }
422
+ /**
423
+ * Run an ephemeral agent to completion through the framework's event loop.
424
+ *
425
+ * The agent is temporarily registered, inference is triggered, and the
426
+ * framework drives the stream (emitting traces, logging, dispatching tools).
427
+ * Returns the agent's speech output when it finishes (no more tool calls).
428
+ *
429
+ * The caller provides a pre-created agent + contextManager (from createEphemeralAgent).
430
+ * The task message should already be in the context manager.
431
+ */
432
+ async runEphemeralToCompletion(agent, contextManager) {
433
+ // Register temporarily so the event loop can drive it
434
+ this.agents.set(agent.name, agent);
435
+ return new Promise((resolve, reject) => {
436
+ let speech = '';
437
+ let toolCallsCount = 0;
438
+ let settled = false;
439
+ let inferenceStarted = false;
440
+ const STARTUP_TIMEOUT_MS = 30_000;
441
+ const cleanup = () => {
442
+ if (settled)
443
+ return;
444
+ settled = true;
445
+ clearTimeout(startupWatchdog);
446
+ this.offTrace(traceListener);
447
+ this.agents.delete(agent.name);
448
+ };
449
+ // Watchdog: if inference never starts within 30s, the agent is a zombie.
450
+ // This catches cases where the event loop stalled, the agent was
451
+ // deregistered, or processInferenceRequests() skipped the request.
452
+ const startupWatchdog = setTimeout(() => {
453
+ if (!inferenceStarted && !settled) {
454
+ cleanup();
455
+ reject(new Error(`Ephemeral agent "${agent.name}" failed to start inference within ` +
456
+ `${STARTUP_TIMEOUT_MS}ms — zombie detected. The event loop may have ` +
457
+ `stalled or the inference request was dropped.`));
458
+ }
459
+ }, STARTUP_TIMEOUT_MS);
460
+ const traceListener = (event) => {
461
+ if (settled)
462
+ return;
463
+ // Only track events for our ephemeral agent
464
+ const agentName = 'agentName' in event ? event.agentName : null;
465
+ if (agentName !== agent.name)
466
+ return;
467
+ switch (event.type) {
468
+ case 'inference:started':
469
+ inferenceStarted = true;
470
+ break;
471
+ case 'inference:tokens': {
472
+ inferenceStarted = true;
473
+ const content = event.content;
474
+ if (content)
475
+ speech += content;
476
+ break;
477
+ }
478
+ case 'inference:tool_calls_yielded': {
479
+ const calls = event.calls;
480
+ if (calls)
481
+ toolCallsCount += calls.length;
482
+ // Reset speech buffer — tool calls break the text
483
+ speech = '';
484
+ break;
485
+ }
486
+ case 'inference:stream_resumed':
487
+ speech = '';
488
+ break;
489
+ case 'inference:completed': {
490
+ // Check if agent is idle (no pending tool calls = truly done)
491
+ // If tools were called, the agent will cycle through waiting_for_tools → ready → streaming
492
+ // and eventually complete again. We only resolve when there are no more tool calls.
493
+ if (agent.state.status === 'idle') {
494
+ cleanup();
495
+ resolve({ speech, toolCallsCount });
496
+ }
497
+ break;
498
+ }
499
+ case 'inference:turn_ended': {
500
+ // endTurn from a tool result — agent is done
501
+ cleanup();
502
+ resolve({ speech, toolCallsCount });
503
+ break;
504
+ }
505
+ case 'inference:exhausted': {
506
+ // Retries exhausted — reject only after the error policy gives up.
507
+ // Earlier inference:failed events are left alone so the framework
508
+ // retry path in driveStream/startAgentStream can restart the stream
509
+ // while this listener stays alive.
510
+ const error = event.error ?? 'Unknown error';
511
+ cleanup();
512
+ reject(new Error(error));
513
+ break;
514
+ }
515
+ }
516
+ };
517
+ this.onTrace(traceListener);
518
+ // Trigger inference
519
+ this.pendingRequests.push({
520
+ agentName: agent.name,
521
+ reason: 'ephemeral',
522
+ source: 'subagent',
523
+ timestamp: Date.now(),
524
+ });
525
+ });
526
+ }
527
+ /**
528
+ * Get queue depth.
529
+ */
530
+ getQueueDepth() {
531
+ return this.queue.depth;
532
+ }
533
+ /**
534
+ * Query inference logs.
535
+ * Returns entries with summary info (doesn't resolve blobs).
536
+ */
537
+ queryInferenceLogs(query) {
538
+ const limit = query?.limit ?? 50;
539
+ const offset = query?.offset ?? 0;
540
+ const pattern = query?.pattern ? new RegExp(query.pattern, 'i') : null;
541
+ // Get all entries from the append log
542
+ const allEntries = [];
543
+ const stateInfo = this.store.listStates().find((s) => s.id === INFERENCE_LOG_ID);
544
+ if (stateInfo) {
545
+ // Query the append log - get raw data
546
+ const data = this.store.getStateJson(INFERENCE_LOG_ID);
547
+ if (Array.isArray(data)) {
548
+ for (let i = 0; i < data.length; i++) {
549
+ const entry = data[i];
550
+ // Build summary (without resolving blobs)
551
+ const requestIsBlob = !!(entry.request && typeof entry.request === 'object' && 'blobId' in entry.request);
552
+ const responseIsBlob = !!(entry.response && typeof entry.response === 'object' && 'blobId' in entry.response);
553
+ const summary = {
554
+ timestamp: entry.timestamp,
555
+ agentName: entry.agentName,
556
+ requestId: entry.requestId,
557
+ success: entry.success,
558
+ error: entry.error,
559
+ durationMs: entry.durationMs,
560
+ tokenUsage: entry.tokenUsage,
561
+ stopReason: entry.stopReason,
562
+ requestIsBlob,
563
+ responseIsBlob,
564
+ };
565
+ allEntries.push({ sequence: i, entry, summary });
566
+ }
567
+ }
568
+ }
569
+ // Filter entries
570
+ let filtered = allEntries;
571
+ if (query?.agentName) {
572
+ filtered = filtered.filter((e) => e.entry.agentName === query.agentName);
573
+ }
574
+ if (query?.errorsOnly) {
575
+ filtered = filtered.filter((e) => !e.entry.success);
576
+ }
577
+ if (pattern) {
578
+ filtered = filtered.filter((e) => {
579
+ // Search in summary fields only (not blob content)
580
+ const content = JSON.stringify(e.summary);
581
+ return pattern.test(content);
582
+ });
583
+ }
584
+ // Reverse to get most recent first
585
+ filtered = filtered.reverse();
586
+ // Paginate
587
+ const total = filtered.length;
588
+ const paged = filtered.slice(offset, offset + limit);
589
+ return {
590
+ entries: paged,
591
+ total,
592
+ hasMore: offset + limit < total,
593
+ };
594
+ }
595
+ /**
596
+ * Get a specific inference log entry by sequence number.
597
+ * Resolves blob references to full content.
598
+ */
599
+ getInferenceLog(sequence, resolveBlobs = true) {
600
+ const data = this.store.getStateJson(INFERENCE_LOG_ID);
601
+ if (Array.isArray(data) && sequence >= 0 && sequence < data.length) {
602
+ const entry = data[sequence];
603
+ if (resolveBlobs) {
604
+ // Resolve blob references
605
+ const resolved = { ...entry };
606
+ if (entry.request && typeof entry.request === 'object' && 'blobId' in entry.request) {
607
+ const blob = this.store.getBlob(entry.request.blobId);
608
+ if (blob) {
609
+ try {
610
+ resolved.request = JSON.parse(blob.toString());
611
+ }
612
+ catch {
613
+ resolved.request = { error: 'Failed to parse blob', blobId: entry.request.blobId };
614
+ }
615
+ }
616
+ }
617
+ if (entry.response && typeof entry.response === 'object' && 'blobId' in entry.response) {
618
+ const blob = this.store.getBlob(entry.response.blobId);
619
+ if (blob) {
620
+ try {
621
+ resolved.response = JSON.parse(blob.toString());
622
+ }
623
+ catch {
624
+ resolved.response = { error: 'Failed to parse blob', blobId: entry.response.blobId };
625
+ }
626
+ }
627
+ }
628
+ return { sequence, entry: resolved };
629
+ }
630
+ return { sequence, entry };
631
+ }
632
+ return null;
633
+ }
634
+ /**
635
+ * Get the most recent inference logs (tail).
636
+ */
637
+ tailInferenceLogs(count = 10, agentName) {
638
+ const result = this.queryInferenceLogs({
639
+ limit: count,
640
+ agentName,
641
+ });
642
+ return result.entries;
643
+ }
644
+ /**
645
+ * Query process logs.
646
+ * Returns entries with summary info (doesn't resolve blobs).
647
+ */
648
+ queryProcessLogs(query) {
649
+ const limit = query?.limit ?? 50;
650
+ const offset = query?.offset ?? 0;
651
+ const pattern = query?.pattern ? new RegExp(query.pattern, 'i') : null;
652
+ const allEntries = [];
653
+ const stateInfo = this.store.listStates().find((s) => s.id === PROCESS_LOG_ID);
654
+ if (stateInfo) {
655
+ const data = this.store.getStateJson(PROCESS_LOG_ID);
656
+ if (Array.isArray(data)) {
657
+ for (let i = 0; i < data.length; i++) {
658
+ const entry = data[i];
659
+ // Build summary
660
+ const responsesIsBlob = !!(entry.responses &&
661
+ typeof entry.responses === 'object' &&
662
+ 'blobId' in entry.responses);
663
+ // Extract summary info from responses if not a blob
664
+ let moduleCount = 0;
665
+ const modulesRequestingInference = [];
666
+ const modulesAddingMessages = [];
667
+ if (!responsesIsBlob && Array.isArray(entry.responses)) {
668
+ moduleCount = entry.responses.length;
669
+ for (const { moduleName, response } of entry.responses) {
670
+ if (response.requestInference) {
671
+ modulesRequestingInference.push(moduleName);
672
+ }
673
+ if (response.addMessages?.length) {
674
+ modulesAddingMessages.push(moduleName);
675
+ }
676
+ }
677
+ }
678
+ const summary = {
679
+ timestamp: entry.timestamp,
680
+ eventType: entry.processEvent.type,
681
+ moduleCount,
682
+ modulesRequestingInference,
683
+ modulesAddingMessages,
684
+ responsesIsBlob,
685
+ };
686
+ allEntries.push({ sequence: i, entry, summary });
687
+ }
688
+ }
689
+ }
690
+ // Filter entries
691
+ let filtered = allEntries;
692
+ if (query?.eventType) {
693
+ filtered = filtered.filter((e) => e.entry.processEvent.type === query.eventType);
694
+ }
695
+ if (query?.moduleName) {
696
+ filtered = filtered.filter((e) => {
697
+ if (e.summary?.responsesIsBlob)
698
+ return false;
699
+ const responses = e.entry.responses;
700
+ return responses.some((r) => r.moduleName === query.moduleName);
701
+ });
702
+ }
703
+ if (pattern) {
704
+ filtered = filtered.filter((e) => {
705
+ const content = JSON.stringify(e.summary);
706
+ return pattern.test(content);
707
+ });
708
+ }
709
+ // Reverse to get most recent first
710
+ filtered = filtered.reverse();
711
+ // Paginate
712
+ const total = filtered.length;
713
+ const paged = filtered.slice(offset, offset + limit);
714
+ return {
715
+ entries: paged,
716
+ total,
717
+ hasMore: offset + limit < total,
718
+ };
719
+ }
720
+ /**
721
+ * Get a specific process log entry by sequence number.
722
+ * Resolves blob references to full content.
723
+ */
724
+ getProcessLog(sequence, resolveBlobs = true) {
725
+ const data = this.store.getStateJson(PROCESS_LOG_ID);
726
+ if (Array.isArray(data) && sequence >= 0 && sequence < data.length) {
727
+ const entry = data[sequence];
728
+ if (resolveBlobs && entry.responses && typeof entry.responses === 'object' && 'blobId' in entry.responses) {
729
+ const resolved = { ...entry };
730
+ const blob = this.store.getBlob(entry.responses.blobId);
731
+ if (blob) {
732
+ try {
733
+ resolved.responses = JSON.parse(blob.toString());
734
+ }
735
+ catch {
736
+ resolved.responses = [];
737
+ }
738
+ }
739
+ return { sequence, entry: resolved };
740
+ }
741
+ return { sequence, entry };
742
+ }
743
+ return null;
744
+ }
745
+ /**
746
+ * Get the most recent process logs (tail).
747
+ */
748
+ tailProcessLogs(count = 10, eventType) {
749
+ const result = this.queryProcessLogs({
750
+ limit: count,
751
+ eventType,
752
+ });
753
+ return result.entries;
754
+ }
755
+ // ==========================================================================
756
+ // Undo / Redo
757
+ // ==========================================================================
758
+ /**
759
+ * Undo the last inference turn for an agent.
760
+ *
761
+ * Creates a new branch at the Chronicle sequence recorded before that turn
762
+ * and switches to it, atomically rolling back all state (messages, context
763
+ * log, inference log, MCPL checkpoints).
764
+ *
765
+ * The undone branch is saved so `redo()` can restore it.
766
+ * Returns the checkpoint that was undone, or null if nothing to undo.
767
+ */
768
+ undoLastTurn(agentName) {
769
+ const agent = this.agents.get(agentName);
770
+ if (!agent) {
771
+ throw new Error(`Unknown agent: ${agentName}`);
772
+ }
773
+ if (agent.state.status !== 'idle') {
774
+ throw new Error(`Cannot undo while agent is ${agent.state.status}`);
775
+ }
776
+ const checkpoints = this.getTurnCheckpoints(agentName);
777
+ if (checkpoints.length === 0) {
778
+ return { undone: false };
779
+ }
780
+ const checkpoint = checkpoints.pop();
781
+ this.saveTurnCheckpoints(agentName, checkpoints);
782
+ const currentBranch = this.store.currentBranch();
783
+ const undoBranchName = `undo/${agentName}/${checkpoint.turnIndex}-${Date.now()}`;
784
+ this.store.createBranchAt(undoBranchName, currentBranch.name, checkpoint.sequenceBefore);
785
+ this.store.switchBranch(undoBranchName);
786
+ // Materialize config files from the new branch (fire-and-forget; gate picks up via mtime)
787
+ const wsUndo = this.moduleRegistry.getModule('workspace');
788
+ if (wsUndo && 'materializeMount' in wsUndo) {
789
+ wsUndo.materializeMount('_config').catch(() => { });
790
+ }
791
+ // Push onto redo stack
792
+ let redoStack = this.redoStacks.get(agentName);
793
+ if (!redoStack) {
794
+ redoStack = [];
795
+ this.redoStacks.set(agentName, redoStack);
796
+ }
797
+ redoStack.push({ branchName: currentBranch.name, checkpoint });
798
+ this.emitTrace({
799
+ type: 'undo:completed',
800
+ agentName,
801
+ turnIndex: checkpoint.turnIndex,
802
+ fromBranch: currentBranch.name,
803
+ toBranch: undoBranchName,
804
+ });
805
+ return {
806
+ undone: true,
807
+ turnIndex: checkpoint.turnIndex,
808
+ fromBranch: currentBranch.name,
809
+ toBranch: undoBranchName,
810
+ };
811
+ }
812
+ /**
813
+ * Redo a previously undone turn for an agent.
814
+ *
815
+ * Switches back to the branch that was active before the last undo.
816
+ * Returns false if there's nothing to redo.
817
+ */
818
+ redo(agentName) {
819
+ const agent = this.agents.get(agentName);
820
+ if (!agent) {
821
+ throw new Error(`Unknown agent: ${agentName}`);
822
+ }
823
+ if (agent.state.status !== 'idle') {
824
+ throw new Error(`Cannot redo while agent is ${agent.state.status}`);
825
+ }
826
+ const redoStack = this.redoStacks.get(agentName);
827
+ if (!redoStack || redoStack.length === 0) {
828
+ return { redone: false };
829
+ }
830
+ const { branchName, checkpoint } = redoStack.pop();
831
+ const currentBranch = this.store.currentBranch();
832
+ this.store.switchBranch(branchName);
833
+ // Materialize config files from the restored branch (fire-and-forget; gate picks up via mtime)
834
+ const wsRedo = this.moduleRegistry.getModule('workspace');
835
+ if (wsRedo && 'materializeMount' in wsRedo) {
836
+ wsRedo.materializeMount('_config').catch(() => { });
837
+ }
838
+ // Restore the checkpoint
839
+ const checkpoints = this.getTurnCheckpoints(agentName);
840
+ checkpoints.push(checkpoint);
841
+ this.saveTurnCheckpoints(agentName, checkpoints);
842
+ this.emitTrace({
843
+ type: 'redo:completed',
844
+ agentName,
845
+ fromBranch: currentBranch.name,
846
+ toBranch: branchName,
847
+ });
848
+ return {
849
+ redone: true,
850
+ fromBranch: currentBranch.name,
851
+ toBranch: branchName,
852
+ };
853
+ }
854
+ /**
855
+ * Check if undo/redo is available for an agent.
856
+ */
857
+ getUndoRedoState(agentName) {
858
+ const checkpoints = this.getTurnCheckpoints(agentName);
859
+ const redoStack = this.redoStacks.get(agentName);
860
+ return {
861
+ canUndo: checkpoints.length > 0,
862
+ canRedo: (redoStack?.length ?? 0) > 0,
863
+ undoDepth: checkpoints.length,
864
+ redoDepth: redoStack?.length ?? 0,
865
+ };
866
+ }
867
+ // ==========================================================================
868
+ // Turn checkpoint internals
869
+ // ==========================================================================
870
+ recordTurnCheckpoint(agentName) {
871
+ const turnIndex = this.turnCounters.get(agentName) ?? 0;
872
+ this.turnCounters.set(agentName, turnIndex + 1);
873
+ const checkpoint = {
874
+ agentName,
875
+ turnIndex,
876
+ sequenceBefore: this.store.currentSequence(),
877
+ branchName: this.store.currentBranch().name,
878
+ timestamp: Date.now(),
879
+ };
880
+ const checkpoints = this.getTurnCheckpoints(agentName);
881
+ checkpoints.push(checkpoint);
882
+ // Trim to max depth
883
+ if (checkpoints.length > MAX_TURN_CHECKPOINTS) {
884
+ checkpoints.splice(0, checkpoints.length - MAX_TURN_CHECKPOINTS);
885
+ }
886
+ this.saveTurnCheckpoints(agentName, checkpoints);
887
+ }
888
+ getTurnCheckpoints(agentName) {
889
+ const data = this.store.getStateJson(TURN_CHECKPOINTS_ID);
890
+ if (!data || typeof data !== 'object')
891
+ return [];
892
+ const allCheckpoints = data;
893
+ return Array.isArray(allCheckpoints[agentName]) ? [...allCheckpoints[agentName]] : [];
894
+ }
895
+ saveTurnCheckpoints(agentName, checkpoints) {
896
+ const data = this.store.getStateJson(TURN_CHECKPOINTS_ID);
897
+ const allCheckpoints = (data && typeof data === 'object' ? data : {});
898
+ allCheckpoints[agentName] = checkpoints;
899
+ this.store.setStateJson(TURN_CHECKPOINTS_ID, allCheckpoints);
900
+ }
901
+ /**
902
+ * Run until the queue is empty and all agents are idle.
903
+ * Useful for testing.
904
+ */
905
+ async runUntilIdle() {
906
+ while (!this.queue.isEmpty ||
907
+ this.activeStreams.size > 0 ||
908
+ Array.from(this.agents.values()).some((a) => a.state.status !== 'idle')) {
909
+ await this.processNextEvent();
910
+ }
911
+ }
912
+ async createAgent(config) {
913
+ // Create context manager for this agent
914
+ const contextManager = await ContextManager.open({
915
+ store: this.store,
916
+ namespace: `agents/${config.name}`,
917
+ strategy: config.strategy ?? new PassthroughStrategy(),
918
+ membrane: this.membrane,
919
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
920
+ });
921
+ const agent = new Agent(config, contextManager, this.membrane);
922
+ this.agents.set(config.name, agent);
923
+ // First non-ephemeral agent becomes the primary for message routing
924
+ if (!this.primaryAgentName) {
925
+ this.primaryAgentName = config.name;
926
+ }
927
+ return agent;
928
+ }
929
+ async runLoop() {
930
+ while (this.running) {
931
+ try {
932
+ await this.processNextEvent();
933
+ }
934
+ catch (error) {
935
+ console.error('Error in event loop:', error);
936
+ }
937
+ }
938
+ }
939
+ async processNextEvent() {
940
+ // Try to get next process event (with timeout to check running flag)
941
+ const event = this.queue.tryPop();
942
+ if (event) {
943
+ await this.handleProcessEvent(event);
944
+ }
945
+ // Check for inference requests
946
+ await this.processInferenceRequests();
947
+ // Yield to the event loop between iterations.
948
+ // Full 10ms sleep when truly idle; minimal yield when streams are active
949
+ // (needed to let stream microtasks and tool-call callbacks execute).
950
+ if (!event && this.pendingRequests.length === 0) {
951
+ await new Promise((resolve) => setTimeout(resolve, 10));
952
+ }
953
+ else if (this.activeStreams.size > 0) {
954
+ await new Promise((resolve) => setTimeout(resolve, 0));
955
+ }
956
+ }
957
+ async handleProcessEvent(event) {
958
+ const startTime = Date.now();
959
+ // Handle tool results FIRST — before any module dispatch or MCPL handling.
960
+ // This ensures the tool_use → tool_result message adjacency required by the
961
+ // Anthropic API. If we let modules or MCPL handlers run first they may add
962
+ // messages between tool_use and tool_result, causing a 400 error.
963
+ if (event.type === 'tool-result') {
964
+ const agent = this.agents.get(event.agentName);
965
+ if (!agent) {
966
+ console.warn(`[framework] Dropping tool-result for unknown agent '${event.agentName}' (callId=${event.callId}). ` +
967
+ `Agent may have been destroyed while tool was executing.`);
968
+ }
969
+ else if (agent.state.status !== 'waiting_for_tools') {
970
+ console.warn(`[framework] Dropping tool-result for agent '${event.agentName}' — ` +
971
+ `expected status 'waiting_for_tools' but got '${agent.state.status}' (callId=${event.callId}). ` +
972
+ `Agent may have been reset/cancelled while tool was executing.`);
973
+ this.emitTrace({
974
+ type: 'tool:result_dropped',
975
+ agentName: event.agentName,
976
+ callId: event.callId,
977
+ agentStatus: agent.state.status,
978
+ result: event.result,
979
+ });
980
+ }
981
+ if (agent && agent.state.status === 'waiting_for_tools') {
982
+ agent.provideToolResult(event.callId, event.result);
983
+ // Check if agent is now ready (state may have changed after provideToolResult)
984
+ // Cast to AgentState to bypass TypeScript's control flow narrowing
985
+ const currentState = agent.state;
986
+ if (currentState.status === 'ready') {
987
+ // Flush pending assistant blocks (tool_use + preamble text) to context
988
+ const pendingBlocks = this.pendingAssistantBlocks.get(agent.name);
989
+ if (pendingBlocks) {
990
+ agent.addAssistantResponse(pendingBlocks);
991
+ this.pendingAssistantBlocks.delete(agent.name);
992
+ }
993
+ // Compute truncation limit from agent's strategy (maxMessageTokens * 4 chars)
994
+ const maxChars = this.getMaxToolResultChars(agent);
995
+ // Store tool results as a user message (tool_result blocks)
996
+ const toolResultContent = currentState.toolResults.map(tc => {
997
+ let content;
998
+ if (tc.result.isError) {
999
+ content = tc.result.error ?? 'Unknown error';
1000
+ }
1001
+ else {
1002
+ content = JSON.stringify(tc.result.data);
1003
+ if (maxChars && content.length > maxChars) {
1004
+ content = safeSlice(content, 0, maxChars)
1005
+ + '\n\n[truncated — original was ' + content.length + ' chars]';
1006
+ }
1007
+ }
1008
+ return {
1009
+ type: 'tool_result',
1010
+ toolUseId: tc.id,
1011
+ content,
1012
+ isError: tc.result.isError,
1013
+ };
1014
+ });
1015
+ agent.getContextManager().addMessage('user', toolResultContent);
1016
+ // Flush any messages that were deferred while waiting for tool results.
1017
+ // Route to the PRIMARY agent — deferred messages are framework-level
1018
+ // (e.g. subagent return notifications) meant for the main conversation.
1019
+ if (this.deferredMessages.length > 0) {
1020
+ const deferred = this.deferredMessages.splice(0);
1021
+ const primary = this.primaryAgentName
1022
+ ? this.agents.get(this.primaryAgentName)
1023
+ : agent;
1024
+ for (const msg of deferred) {
1025
+ (primary ?? agent).getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
1026
+ }
1027
+ }
1028
+ // Check if any tool result requested endTurn
1029
+ const shouldEndTurn = currentState.toolResults.some(tc => tc.result.endTurn);
1030
+ // Check if accumulated input tokens exceed the agent's budget
1031
+ const overBudget = currentState.stream
1032
+ && agent.lastStreamInputTokens > 0
1033
+ && agent.lastStreamInputTokens > agent.maxStreamTokens;
1034
+ if (shouldEndTurn) {
1035
+ // endTurn: messages already stored above, cancel stream, reset to idle.
1036
+ if (currentState.stream) {
1037
+ agent.cancelStream();
1038
+ }
1039
+ agent.reset();
1040
+ this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
1041
+ }
1042
+ else if (overBudget) {
1043
+ // Context budget exceeded: break the stream, let compile() compress
1044
+ agent.cancelStream();
1045
+ this.emitTrace({
1046
+ type: 'inference:stream_restarted',
1047
+ agentName: agent.name,
1048
+ reason: 'context_budget',
1049
+ inputTokens: agent.lastStreamInputTokens,
1050
+ budget: agent.maxStreamTokens,
1051
+ });
1052
+ this.pendingRequests.push({
1053
+ agentName: agent.name,
1054
+ reason: 'context_budget_restart',
1055
+ source: 'framework',
1056
+ timestamp: Date.now(),
1057
+ });
1058
+ }
1059
+ else if (currentState.stream) {
1060
+ // Streaming path: convert results and resume the stream
1061
+ const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars));
1062
+ currentState.stream.provideToolResults(membraneResults);
1063
+ agent.setStreaming(currentState.stream);
1064
+ this.emitTrace({ type: 'inference:stream_resumed', agentName: agent.name });
1065
+ }
1066
+ else {
1067
+ // Non-streaming fallback: schedule re-inference
1068
+ this.pendingRequests.push({
1069
+ agentName: agent.name,
1070
+ reason: 'tool_results_ready',
1071
+ source: 'framework',
1072
+ timestamp: Date.now(),
1073
+ });
1074
+ }
1075
+ }
1076
+ }
1077
+ }
1078
+ // Built-in: convert MCPL events to context messages.
1079
+ // These events are protocol-level (spec Sections 9 & 14) and always
1080
+ // represent content intended for the model's context window.
1081
+ if (event.type === 'mcpl:channel-incoming') {
1082
+ this.handleMcplChannelIncoming(event);
1083
+ }
1084
+ else if (event.type === 'mcpl:push-event') {
1085
+ this.handleMcplPushEvent(event);
1086
+ }
1087
+ // Dispatch to all modules, tracking responses with module names
1088
+ const responses = [];
1089
+ for (const module of this.moduleRegistry.getAllModules()) {
1090
+ try {
1091
+ const processState = this.moduleRegistry.createProcessState(module.name);
1092
+ const response = await module.onProcess(event, processState);
1093
+ responses.push({ moduleName: module.name, response });
1094
+ }
1095
+ catch (error) {
1096
+ console.error(`Module ${module.name} error handling process event:`, error);
1097
+ }
1098
+ }
1099
+ // Apply responses
1100
+ for (const { moduleName, response } of responses) {
1101
+ await this.applyProcessResponse(response, event, moduleName);
1102
+ }
1103
+ // Handle tool calls specially
1104
+ if (event.type === 'tool-call') {
1105
+ this.dispatchToolCallEvent(event);
1106
+ }
1107
+ const durationMs = Date.now() - startTime;
1108
+ // Always emit trace for observability (UI needs this)
1109
+ this.emitTrace({ type: 'process:completed', processEvent: event, responses, durationMs });
1110
+ // Log to Chronicle (if enabled)
1111
+ if (this.processLoggingPersist) {
1112
+ this.logProcessEvent(event, responses);
1113
+ }
1114
+ }
1115
+ async applyProcessResponse(response, event, moduleName) {
1116
+ // Add messages
1117
+ if (response.addMessages) {
1118
+ for (const msg of response.addMessages) {
1119
+ const id = this.addMessage(msg.participant, msg.content, msg.metadata);
1120
+ this.emitTrace({ type: 'message:added', messageId: id, source: event.type });
1121
+ }
1122
+ }
1123
+ // Edit messages
1124
+ if (response.editMessages) {
1125
+ for (const edit of response.editMessages) {
1126
+ this.editMessage(edit.messageId, edit.content);
1127
+ }
1128
+ }
1129
+ // Remove messages
1130
+ if (response.removeMessages) {
1131
+ for (const id of response.removeMessages) {
1132
+ this.removeMessage(id);
1133
+ }
1134
+ }
1135
+ // Apply module state update atomically with message operations
1136
+ if (response.stateUpdate !== undefined) {
1137
+ this.moduleRegistry.setModuleState(moduleName, response.stateUpdate);
1138
+ }
1139
+ // Queue inference requests
1140
+ if (response.requestInference) {
1141
+ const source = 'source' in event ? event.source : 'unknown';
1142
+ // Gate non-MCPL events (MCPL events are already gated in PushHandler/ChannelRegistry)
1143
+ if (this.eventGate && event.type !== 'mcpl:push-event' && event.type !== 'mcpl:channel-incoming') {
1144
+ const content = 'content' in event ? String(event.content) : '';
1145
+ const decision = this.eventGate.evaluate({
1146
+ content,
1147
+ eventType: event.type,
1148
+ serverId: source,
1149
+ channelId: '',
1150
+ metadata: 'metadata' in event ? event.metadata : {},
1151
+ });
1152
+ if (!decision.trigger)
1153
+ return;
1154
+ }
1155
+ const targetAgents = response.requestInference === true
1156
+ ? Array.from(this.agents.keys())
1157
+ : response.requestInference;
1158
+ for (const agentName of targetAgents) {
1159
+ const agent = this.agents.get(agentName);
1160
+ if (agent && agent.canBeTriggeredBy(source)) {
1161
+ this.pendingRequests.push({
1162
+ agentName,
1163
+ reason: event.type,
1164
+ source,
1165
+ timestamp: Date.now(),
1166
+ });
1167
+ }
1168
+ }
1169
+ }
1170
+ }
1171
+ // ==========================================================================
1172
+ // Built-in MCPL event → context message conversion
1173
+ // ==========================================================================
1174
+ /**
1175
+ * Convert an incoming MCPL channel message to a context message.
1176
+ * This replaces the old MCPLModule.onProcess() message conversion.
1177
+ */
1178
+ handleMcplChannelIncoming(event) {
1179
+ const metadata = {
1180
+ ...event.metadata,
1181
+ channelId: event.channelId,
1182
+ messageId: event.messageId,
1183
+ author: event.author,
1184
+ triggered: event.triggerInference ?? false,
1185
+ serverId: event.serverId,
1186
+ };
1187
+ if (event.threadId)
1188
+ metadata.threadId = event.threadId;
1189
+ const id = this.addMessage('user', event.content, metadata);
1190
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
1191
+ if (event.triggerInference) {
1192
+ for (const agentName of this.agents.keys()) {
1193
+ this.pendingRequests.push({
1194
+ agentName,
1195
+ reason: 'mcpl:channel-incoming',
1196
+ source: event.serverId,
1197
+ timestamp: Date.now(),
1198
+ });
1199
+ }
1200
+ }
1201
+ }
1202
+ /**
1203
+ * Convert an MCPL push event to a context message.
1204
+ */
1205
+ handleMcplPushEvent(event) {
1206
+ const metadata = {
1207
+ ...event.origin,
1208
+ serverId: event.serverId,
1209
+ featureSet: event.featureSet,
1210
+ eventId: event.eventId,
1211
+ triggered: event.triggerInference ?? false,
1212
+ };
1213
+ const id = this.addMessage('user', event.content, metadata);
1214
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
1215
+ if (event.triggerInference) {
1216
+ const targetAgents = event.targetAgents ?? [...this.agents.keys()];
1217
+ for (const agentName of targetAgents) {
1218
+ this.pendingRequests.push({
1219
+ agentName,
1220
+ reason: 'mcpl:push-event',
1221
+ source: event.serverId,
1222
+ timestamp: Date.now(),
1223
+ });
1224
+ }
1225
+ }
1226
+ }
1227
+ async processInferenceRequests() {
1228
+ if (this.pendingRequests.length === 0) {
1229
+ return;
1230
+ }
1231
+ const STALE_REQUEST_MS = 30_000;
1232
+ const now = Date.now();
1233
+ const state = this.createFrameworkState();
1234
+ // Group requests by agent
1235
+ const requestsByAgent = new Map();
1236
+ for (const req of this.pendingRequests) {
1237
+ const existing = requestsByAgent.get(req.agentName) ?? [];
1238
+ existing.push(req);
1239
+ requestsByAgent.set(req.agentName, existing);
1240
+ }
1241
+ // Clear pending (we'll re-add if inference doesn't run)
1242
+ this.pendingRequests = [];
1243
+ // Check each agent
1244
+ for (const [agentName, requests] of requestsByAgent) {
1245
+ const agent = this.agents.get(agentName);
1246
+ if (!agent) {
1247
+ // Agent not found — request is orphaned. Emit warning and drop.
1248
+ const oldest = Math.min(...requests.map(r => r.timestamp));
1249
+ this.emitTrace({
1250
+ type: 'inference:request_dropped',
1251
+ agentName,
1252
+ reason: 'agent_not_found',
1253
+ requestCount: requests.length,
1254
+ oldestRequestAge: now - oldest,
1255
+ });
1256
+ continue;
1257
+ }
1258
+ // Skip if agent is busy (inferring, streaming, or waiting for tools)
1259
+ if (agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
1260
+ // Re-queue requests, but warn if they've been pending too long
1261
+ const oldest = Math.min(...requests.map(r => r.timestamp));
1262
+ if (now - oldest > STALE_REQUEST_MS) {
1263
+ this.emitTrace({
1264
+ type: 'inference:request_stale',
1265
+ agentName,
1266
+ agentStatus: agent.state.status,
1267
+ requestCount: requests.length,
1268
+ oldestRequestAge: now - oldest,
1269
+ });
1270
+ }
1271
+ this.pendingRequests.push(...requests);
1272
+ continue;
1273
+ }
1274
+ // Check policy
1275
+ if (!this.inferencePolicy.shouldInfer(agentName, requests, state)) {
1276
+ continue;
1277
+ }
1278
+ // Start streaming inference (non-blocking — driveStream runs in background)
1279
+ const trigger = requests[0];
1280
+ await this.startAgentStream(agent, trigger);
1281
+ }
1282
+ }
1283
+ async startAgentStream(agent, trigger, attempt = 0) {
1284
+ // Record turn checkpoint before inference (only on first attempt, not retries)
1285
+ if (attempt === 0) {
1286
+ this.recordTurnCheckpoint(agent.name);
1287
+ this.redoStacks.delete(agent.name); // new work invalidates redo
1288
+ }
1289
+ this.emitTrace({ type: 'inference:started', agentName: agent.name });
1290
+ this.eventGate?.onInferenceStarted(agent.name);
1291
+ try {
1292
+ const allTools = this.getAllTools();
1293
+ const tools = allTools.filter((t) => agent.canUseTool(t.name));
1294
+ // Gather context from modules (pull-based) and MCPL hooks (push-based)
1295
+ // Both produce ContextInjection[] that get merged before inference.
1296
+ let injections;
1297
+ // Module gatherContext (fail-open, 5s timeout per module)
1298
+ try {
1299
+ const moduleInjections = await this.moduleRegistry.gatherContext(agent.name);
1300
+ if (moduleInjections.length > 0) {
1301
+ injections = moduleInjections;
1302
+ }
1303
+ }
1304
+ catch (error) {
1305
+ console.error('Module gatherContext error:', error);
1306
+ }
1307
+ // MCPL beforeInference hooks (fail-open)
1308
+ if (this.hookOrchestrator) {
1309
+ try {
1310
+ const hookParams = this.buildBeforeInferenceParams(agent, trigger);
1311
+ const hookInjections = await this.hookOrchestrator.beforeInference(hookParams);
1312
+ if (hookInjections.length > 0) {
1313
+ injections = injections ? [...injections, ...hookInjections] : hookInjections;
1314
+ }
1315
+ }
1316
+ catch (error) {
1317
+ console.error('beforeInference hook error:', error);
1318
+ }
1319
+ }
1320
+ const { stream, request: compiledRequest } = await agent.startStreamWithInjections(tools, injections);
1321
+ const handle = this.driveStream(agent, stream, trigger, attempt, compiledRequest);
1322
+ this.activeStreams.set(agent.name, handle);
1323
+ }
1324
+ catch (error) {
1325
+ const err = error instanceof Error ? error : new Error(String(error));
1326
+ this.emitTrace({
1327
+ type: 'inference:failed',
1328
+ agentName: agent.name,
1329
+ error: err.message,
1330
+ stack: err.stack,
1331
+ });
1332
+ agent.reset();
1333
+ const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
1334
+ if (action.retry) {
1335
+ await new Promise((resolve) => setTimeout(resolve, action.delayMs));
1336
+ await this.startAgentStream(agent, trigger, attempt + 1);
1337
+ }
1338
+ else {
1339
+ this.emitTrace({
1340
+ type: 'inference:exhausted',
1341
+ agentName: agent.name,
1342
+ error: err.message,
1343
+ });
1344
+ this.eventGate?.onInferenceEnded(agent.name);
1345
+ if (action.emit) {
1346
+ this.pushEvent(action.emit);
1347
+ }
1348
+ }
1349
+ }
1350
+ }
1351
+ async driveStream(agent, stream, trigger, attempt = 0, compiledRequest) {
1352
+ const startTime = Date.now();
1353
+ const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
1354
+ const myStreamId = agent.streamId;
1355
+ let hadToolCalls = false;
1356
+ try {
1357
+ for await (const event of stream) {
1358
+ switch (event.type) {
1359
+ case 'tokens':
1360
+ this.emitTrace({
1361
+ type: 'inference:tokens',
1362
+ agentName: agent.name,
1363
+ content: event.content,
1364
+ });
1365
+ break;
1366
+ case 'tool-calls': {
1367
+ hadToolCalls = true;
1368
+ this.emitTrace({
1369
+ type: 'inference:tool_calls_yielded',
1370
+ agentName: agent.name,
1371
+ calls: event.calls.map((c) => ({ id: c.id, name: c.name, input: c.input })),
1372
+ });
1373
+ // Build assistant content blocks from preamble text + tool_use blocks
1374
+ const assistantBlocks = [];
1375
+ if (event.context.preamble) {
1376
+ assistantBlocks.push({ type: 'text', text: event.context.preamble });
1377
+ }
1378
+ for (const c of event.calls) {
1379
+ assistantBlocks.push({ type: 'tool_use', id: c.id, name: c.name, input: c.input });
1380
+ }
1381
+ this.pendingAssistantBlocks.set(agent.name, assistantBlocks);
1382
+ // Note: max_tokens truncation cannot produce tool-calls events here.
1383
+ // The membrane only yields 'tool-calls' when stop_reason is 'tool_use'
1384
+ // (native API) or the closing </function_calls> tag is found (text parser).
1385
+ // Truncated responses yield 'complete' with stop_reason 'max_tokens' directly.
1386
+ // So all calls here have valid input — {} is legitimate for parameterless tools.
1387
+ agent.enterWaitingForTools(event.calls, stream);
1388
+ for (const call of event.calls) {
1389
+ this.dispatchToolCall(agent.name, call);
1390
+ }
1391
+ // Stream's async iterator blocks on next() until provideToolResults() is called
1392
+ break;
1393
+ }
1394
+ case 'complete': {
1395
+ const durationMs = Date.now() - startTime;
1396
+ const response = event.response;
1397
+ // If the agent is still waiting_for_tools when 'complete' fires
1398
+ // (shouldn't happen after incomplete-tool-call fix, but guard anyway),
1399
+ // flush pending blocks + inject error results for any remaining pending tools.
1400
+ if (agent.state.status === 'waiting_for_tools') {
1401
+ console.warn(`[framework] 'complete' event fired while agent '${agent.name}' is still waiting_for_tools. ` +
1402
+ `Injecting error results for ${agent.getPendingToolIds().length} pending tool(s).`);
1403
+ // Inject error results for all remaining pending tools
1404
+ for (const pendingId of agent.getPendingToolIds()) {
1405
+ agent.provideToolResult(pendingId, {
1406
+ success: false,
1407
+ error: 'Stream completed before tool result was received (likely max_tokens truncation).',
1408
+ isError: true,
1409
+ });
1410
+ }
1411
+ // Flush pending assistant blocks
1412
+ const pending = this.pendingAssistantBlocks.get(agent.name);
1413
+ if (pending) {
1414
+ agent.addAssistantResponse(pending);
1415
+ this.pendingAssistantBlocks.delete(agent.name);
1416
+ }
1417
+ // Store tool results
1418
+ const readyState = agent.state;
1419
+ if (readyState.status === 'ready') {
1420
+ const toolResultContent = readyState.toolResults.map(tc => ({
1421
+ type: 'tool_result',
1422
+ toolUseId: tc.id,
1423
+ content: tc.result.isError ? (tc.result.error ?? 'Unknown error') : JSON.stringify(tc.result.data),
1424
+ isError: tc.result.isError,
1425
+ }));
1426
+ agent.getContextManager().addMessage('user', toolResultContent);
1427
+ }
1428
+ }
1429
+ // Add assistant response to context.
1430
+ // If we had tool calls, the tool_use blocks were already stored as
1431
+ // pendingAssistantBlocks and flushed when tool results arrived.
1432
+ // Only store trailing content (text after the last tool round) to
1433
+ // avoid double-storing tool_use blocks.
1434
+ const hadToolCalls = response.content.some((b) => b.type === 'tool_use' || b.type === 'tool_result');
1435
+ if (hadToolCalls) {
1436
+ // Filter to only trailing text/thinking blocks (no tool_use — those are already stored)
1437
+ const trailingContent = response.content.filter((block) => block.type !== 'tool_use' && block.type !== 'tool_result');
1438
+ if (trailingContent.length > 0) {
1439
+ agent.addAssistantResponse(trailingContent);
1440
+ }
1441
+ }
1442
+ else {
1443
+ agent.addAssistantResponse(response.content);
1444
+ }
1445
+ // Run afterInference hooks (no-op if no MCPL servers)
1446
+ if (this.hookOrchestrator) {
1447
+ try {
1448
+ const speechText = response.content
1449
+ .filter((block) => block.type === 'text')
1450
+ .map((b) => b.text)
1451
+ .join('\n');
1452
+ const afterParams = {
1453
+ inferenceId: requestId,
1454
+ conversationId: agent.name,
1455
+ turnIndex: 0,
1456
+ userMessage: null,
1457
+ assistantMessage: speechText,
1458
+ model: {
1459
+ id: agent.model,
1460
+ vendor: 'unknown',
1461
+ contextWindow: 200000,
1462
+ capabilities: ['tools'],
1463
+ },
1464
+ usage: {
1465
+ inputTokens: response.usage?.inputTokens ?? 0,
1466
+ outputTokens: response.usage?.outputTokens ?? 0,
1467
+ cacheCreationTokens: response.details?.usage?.cacheCreationTokens,
1468
+ cacheReadTokens: response.details?.usage?.cacheReadTokens,
1469
+ },
1470
+ };
1471
+ await this.hookOrchestrator.afterInference(afterParams);
1472
+ }
1473
+ catch (error) {
1474
+ // Fail-open: continue with speech dispatch
1475
+ console.error('afterInference hook error:', error);
1476
+ }
1477
+ }
1478
+ // Separate speech from thoughts.
1479
+ // When tools were used, ALL text is thoughts (the tools themselves are
1480
+ // the agent's actions — surrounding text is just reasoning).
1481
+ // When no tools were used, all text is speech.
1482
+ const isTextBlock = (block) => block.type === 'text';
1483
+ const allText = response.content.filter(isTextBlock);
1484
+ const speechContent = hadToolCalls ? [] : allText;
1485
+ const thoughts = hadToolCalls ? allText : [];
1486
+ const tokenUsage = response.usage
1487
+ ? {
1488
+ input: response.usage.inputTokens,
1489
+ output: response.usage.outputTokens,
1490
+ cacheCreation: response.details?.usage?.cacheCreationTokens,
1491
+ cacheRead: response.details?.usage?.cacheReadTokens,
1492
+ }
1493
+ : undefined;
1494
+ this.emitTrace({
1495
+ type: 'inference:completed',
1496
+ agentName: agent.name,
1497
+ durationMs,
1498
+ tokenUsage,
1499
+ });
1500
+ // Log inference
1501
+ this.logInference({
1502
+ timestamp: startTime,
1503
+ agentName: agent.name,
1504
+ requestId,
1505
+ success: true,
1506
+ request: compiledRequest ?? { note: 'streaming request' },
1507
+ response: response.raw ?? { note: 'streaming response' },
1508
+ durationMs,
1509
+ tokenUsage,
1510
+ stopReason: response.stopReason,
1511
+ });
1512
+ // Dispatch speech (and thoughts if any)
1513
+ if (speechContent.length > 0 || thoughts.length > 0) {
1514
+ const speechContext = {
1515
+ turnComplete: true,
1516
+ trigger: trigger ?? {
1517
+ reason: 'unknown',
1518
+ source: 'unknown',
1519
+ timestamp: Date.now(),
1520
+ },
1521
+ thoughts: thoughts.length > 0 ? thoughts : undefined,
1522
+ };
1523
+ await this.moduleRegistry.dispatchSpeech(agent.name, speechContent, speechContext);
1524
+ }
1525
+ // Done — reset to idle
1526
+ agent.reset();
1527
+ this.eventGate?.onInferenceEnded(agent.name);
1528
+ break;
1529
+ }
1530
+ case 'error': {
1531
+ const err = event.error;
1532
+ const durationMs = Date.now() - startTime;
1533
+ this.emitTrace({
1534
+ type: 'inference:failed',
1535
+ agentName: agent.name,
1536
+ error: err.message,
1537
+ stack: err.stack,
1538
+ });
1539
+ this.logInference({
1540
+ timestamp: startTime,
1541
+ agentName: agent.name,
1542
+ requestId,
1543
+ success: false,
1544
+ error: err.message,
1545
+ request: compiledRequest ?? { note: 'streaming request failed' },
1546
+ durationMs,
1547
+ });
1548
+ // Only reset + retry if this is still the active stream
1549
+ if (agent.streamId !== myStreamId)
1550
+ break;
1551
+ agent.reset();
1552
+ const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
1553
+ if (action.retry) {
1554
+ await new Promise((resolve) => setTimeout(resolve, action.delayMs));
1555
+ await this.startAgentStream(agent, trigger, attempt + 1);
1556
+ }
1557
+ else {
1558
+ this.emitTrace({
1559
+ type: 'inference:exhausted',
1560
+ agentName: agent.name,
1561
+ error: err.message,
1562
+ });
1563
+ this.eventGate?.onInferenceEnded(agent.name);
1564
+ if (action.emit) {
1565
+ this.pushEvent(action.emit);
1566
+ }
1567
+ }
1568
+ break;
1569
+ }
1570
+ case 'aborted': {
1571
+ const reason = event.reason ?? 'unknown';
1572
+ // Only reset if this is still the active stream (a budget restart
1573
+ // may have already started a new stream, bumping streamId)
1574
+ if (agent.streamId === myStreamId) {
1575
+ agent.reset();
1576
+ this.emitTrace({
1577
+ type: 'inference:exhausted',
1578
+ agentName: agent.name,
1579
+ error: `Stream aborted: ${reason}`,
1580
+ });
1581
+ this.eventGate?.onInferenceEnded(agent.name);
1582
+ }
1583
+ break;
1584
+ }
1585
+ case 'usage':
1586
+ agent.lastStreamInputTokens = event.usage.inputTokens;
1587
+ this.emitTrace({
1588
+ type: 'inference:usage',
1589
+ agentName: agent.name,
1590
+ tokenUsage: {
1591
+ input: event.usage.inputTokens,
1592
+ output: event.usage.outputTokens,
1593
+ },
1594
+ });
1595
+ break;
1596
+ }
1597
+ }
1598
+ }
1599
+ catch (error) {
1600
+ // Stream itself threw (unexpected) — no retry path here, so also emit
1601
+ // inference:exhausted so ephemeral agent promises can settle.
1602
+ const err = error instanceof Error ? error : new Error(String(error));
1603
+ this.emitTrace({
1604
+ type: 'inference:failed',
1605
+ agentName: agent.name,
1606
+ error: err.message,
1607
+ stack: err.stack,
1608
+ });
1609
+ this.emitTrace({
1610
+ type: 'inference:exhausted',
1611
+ agentName: agent.name,
1612
+ error: err.message,
1613
+ });
1614
+ agent.reset();
1615
+ this.eventGate?.onInferenceEnded(agent.name);
1616
+ }
1617
+ finally {
1618
+ this.activeStreams.delete(agent.name);
1619
+ this.pendingAssistantBlocks.delete(agent.name);
1620
+ // Flush any deferred messages (e.g. if stream failed while tools were pending)
1621
+ if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
1622
+ const deferred = this.deferredMessages.splice(0);
1623
+ for (const msg of deferred) {
1624
+ this.addMessage(msg.participant, msg.content, msg.metadata);
1625
+ }
1626
+ }
1627
+ }
1628
+ }
1629
+ /**
1630
+ * Execute a tool call and return the result.
1631
+ * Routes to the appropriate handler (module registry or MCPL).
1632
+ * Used by SubagentModule to dispatch tool calls for ephemeral agents.
1633
+ */
1634
+ async executeToolCall(call) {
1635
+ // MCPL tools are dispatched via the MCPL subsystem
1636
+ if (this.mcplServerRegistry) {
1637
+ // Check if this is an MCPL-prefixed tool
1638
+ const prefix = call.name.split('--').slice(0, -1).join('--');
1639
+ const serverConfigs = this.mcplServerConfigs;
1640
+ for (const [, config] of serverConfigs) {
1641
+ const toolPrefix = config.toolPrefix ?? `mcpl--${config.id}`;
1642
+ if (call.name.startsWith(toolPrefix + '--')) {
1643
+ return this.executeMcplToolCall(call, config);
1644
+ }
1645
+ }
1646
+ }
1647
+ // Module tools
1648
+ return this.moduleRegistry.handleToolCall(call);
1649
+ }
1650
+ async executeMcplToolCall(call, config) {
1651
+ if (!this.mcplServerRegistry) {
1652
+ return { success: false, error: 'MCPL not initialized', isError: true };
1653
+ }
1654
+ const server = this.mcplServerRegistry.getServer(config.id);
1655
+ if (!server) {
1656
+ return { success: false, error: `MCPL server ${config.id} not found`, isError: true };
1657
+ }
1658
+ const prefix = config.toolPrefix ?? `mcpl--${config.id}`;
1659
+ const toolName = call.name.slice(prefix.length + 2); // Strip prefix + '--'
1660
+ try {
1661
+ const result = await server.sendToolsCall(toolName, call.input);
1662
+ return {
1663
+ success: true,
1664
+ data: result.content,
1665
+ };
1666
+ }
1667
+ catch (err) {
1668
+ return {
1669
+ success: false,
1670
+ error: err instanceof Error ? err.message : String(err),
1671
+ isError: true,
1672
+ };
1673
+ }
1674
+ }
1675
+ toMembraneToolResult(callId, afResult, maxChars) {
1676
+ let content;
1677
+ if (afResult.isError) {
1678
+ content = afResult.error ?? 'Unknown error';
1679
+ }
1680
+ else {
1681
+ content = JSON.stringify(afResult.data);
1682
+ if (maxChars && content.length > maxChars) {
1683
+ content = safeSlice(content, 0, maxChars)
1684
+ + '\n\n[truncated — original was ' + content.length + ' chars]';
1685
+ }
1686
+ }
1687
+ return { toolUseId: callId, content, isError: afResult.isError };
1688
+ }
1689
+ getMaxToolResultChars(agent) {
1690
+ const strategy = agent.getContextManager().getStrategy();
1691
+ const maxTokens = strategy.maxMessageTokens;
1692
+ if (maxTokens && maxTokens > 0)
1693
+ return maxTokens * 4;
1694
+ return undefined;
1695
+ }
1696
+ logInference(entry) {
1697
+ // Store large request/response as blobs
1698
+ const entryToStore = { ...entry };
1699
+ // Blob threshold: 10KB - typical context-heavy requests exceed this
1700
+ const BLOB_THRESHOLD = 10000;
1701
+ if (entry.request && typeof entry.request === 'object') {
1702
+ const requestJson = JSON.stringify(entry.request);
1703
+ if (requestJson.length > BLOB_THRESHOLD) {
1704
+ const blobId = this.store.storeBlob(Buffer.from(requestJson), 'application/json');
1705
+ entryToStore.request = { blobId };
1706
+ }
1707
+ }
1708
+ if (entry.response && typeof entry.response === 'object') {
1709
+ const responseJson = JSON.stringify(entry.response);
1710
+ if (responseJson.length > BLOB_THRESHOLD) {
1711
+ const blobId = this.store.storeBlob(Buffer.from(responseJson), 'application/json');
1712
+ entryToStore.response = { blobId };
1713
+ }
1714
+ }
1715
+ // Append to the inference log state
1716
+ const data = this.store.getStateJson(INFERENCE_LOG_ID);
1717
+ const entries = Array.isArray(data) ? data : [];
1718
+ entries.push(entryToStore);
1719
+ this.store.setStateJson(INFERENCE_LOG_ID, entries);
1720
+ }
1721
+ logProcessEvent(event, responses) {
1722
+ const entry = {
1723
+ timestamp: Date.now(),
1724
+ processEvent: event,
1725
+ responses,
1726
+ };
1727
+ // Blob threshold: 10KB
1728
+ const BLOB_THRESHOLD = 10000;
1729
+ const entryToStore = { ...entry };
1730
+ const responsesJson = JSON.stringify(responses);
1731
+ if (responsesJson.length > BLOB_THRESHOLD) {
1732
+ const blobId = this.store.storeBlob(Buffer.from(responsesJson), 'application/json');
1733
+ entryToStore.responses = { blobId };
1734
+ }
1735
+ // Append to the process log state
1736
+ const data = this.store.getStateJson(PROCESS_LOG_ID);
1737
+ const entries = Array.isArray(data) ? data : [];
1738
+ entries.push(entryToStore);
1739
+ this.store.setStateJson(PROCESS_LOG_ID, entries);
1740
+ }
1741
+ /**
1742
+ * Find the MCPL server for a tool call by checking against the prefix map.
1743
+ * Returns [serverId, prefix] if found, null otherwise.
1744
+ */
1745
+ resolveMcplTool(toolName) {
1746
+ for (const [prefix, serverId] of this.mcplPrefixMap) {
1747
+ if (toolName.startsWith(prefix + '--')) {
1748
+ return [serverId, prefix];
1749
+ }
1750
+ }
1751
+ return null;
1752
+ }
1753
+ dispatchToolCall(agentName, call) {
1754
+ // Enrich call with caller identity so modules can resolve the calling agent
1755
+ const enrichedCall = { ...call, callerAgentName: agentName };
1756
+ // Route MCPL tool calls to the appropriate server via prefix map
1757
+ const mcplMatch = this.resolveMcplTool(enrichedCall.name);
1758
+ if (mcplMatch && this.mcplServerRegistry) {
1759
+ this.dispatchMcplToolCall(agentName, enrichedCall, mcplMatch[0], mcplMatch[1]);
1760
+ return;
1761
+ }
1762
+ // Route synthesized channel tools
1763
+ if (enrichedCall.name.startsWith('channel_') && this.channelRegistry) {
1764
+ this.dispatchChannelToolCall(agentName, enrichedCall);
1765
+ return;
1766
+ }
1767
+ // Route gate:status tool
1768
+ if (enrichedCall.name === 'gate:status' && this.eventGate) {
1769
+ this.dispatchGateToolCall(agentName, enrichedCall);
1770
+ return;
1771
+ }
1772
+ const sepIndex = enrichedCall.name.indexOf('--');
1773
+ const moduleName = sepIndex >= 0 ? enrichedCall.name.substring(0, sepIndex) : 'unknown';
1774
+ const toolName = sepIndex >= 0 ? call.name.substring(sepIndex + 2) : call.name;
1775
+ this.pushEvent({
1776
+ type: 'tool-call',
1777
+ callId: call.id,
1778
+ agentName,
1779
+ moduleName,
1780
+ toolName,
1781
+ call,
1782
+ });
1783
+ }
1784
+ dispatchToolCallEvent(event) {
1785
+ const { call, agentName, moduleName } = event;
1786
+ this.emitTrace({
1787
+ type: 'tool:started',
1788
+ module: moduleName,
1789
+ tool: call.name,
1790
+ callId: call.id,
1791
+ input: call.input,
1792
+ });
1793
+ const startTime = Date.now();
1794
+ this.moduleRegistry
1795
+ .handleToolCall(call)
1796
+ .then((result) => {
1797
+ const durationMs = Date.now() - startTime;
1798
+ this.emitTrace({
1799
+ type: 'tool:completed',
1800
+ module: moduleName,
1801
+ tool: call.name,
1802
+ callId: call.id,
1803
+ durationMs,
1804
+ });
1805
+ this.pushEvent({
1806
+ type: 'tool-result',
1807
+ callId: call.id,
1808
+ agentName,
1809
+ moduleName,
1810
+ result,
1811
+ });
1812
+ })
1813
+ .catch((error) => {
1814
+ const err = error instanceof Error ? error : new Error(String(error));
1815
+ this.emitTrace({
1816
+ type: 'tool:failed',
1817
+ module: moduleName,
1818
+ tool: call.name,
1819
+ callId: call.id,
1820
+ error: err.message,
1821
+ stack: err.stack,
1822
+ });
1823
+ this.pushEvent({
1824
+ type: 'tool-result',
1825
+ callId: call.id,
1826
+ agentName,
1827
+ moduleName,
1828
+ result: {
1829
+ success: false,
1830
+ error: err.message,
1831
+ isError: true,
1832
+ },
1833
+ });
1834
+ });
1835
+ }
1836
+ addMessage(participant, content, metadata) {
1837
+ // Route to the primary agent's context manager (not ephemeral subagents).
1838
+ const agent = this.primaryAgentName
1839
+ ? this.agents.get(this.primaryAgentName)
1840
+ : this.agents.values().next().value;
1841
+ if (!agent) {
1842
+ throw new Error('No agents configured');
1843
+ }
1844
+ // If any agent has pending assistant blocks (tool_use not yet flushed),
1845
+ // defer non-tool_result messages to preserve tool_use → tool_result adjacency.
1846
+ const hasToolResult = content.some(b => b.type === 'tool_result');
1847
+ if (!hasToolResult && this.pendingAssistantBlocks.size > 0) {
1848
+ this.deferredMessages.push({ participant, content, metadata });
1849
+ return ''; // Deferred — will be added after tool_result flush
1850
+ }
1851
+ return agent.getContextManager().addMessage(participant, content, metadata);
1852
+ }
1853
+ editMessage(id, content) {
1854
+ const agent = this.primaryAgentName
1855
+ ? this.agents.get(this.primaryAgentName)
1856
+ : this.agents.values().next().value;
1857
+ if (!agent) {
1858
+ throw new Error('No agents configured');
1859
+ }
1860
+ agent.getContextManager().editMessage(id, content);
1861
+ }
1862
+ removeMessage(id) {
1863
+ const agent = this.primaryAgentName
1864
+ ? this.agents.get(this.primaryAgentName)
1865
+ : this.agents.values().next().value;
1866
+ if (!agent) {
1867
+ throw new Error('No agents configured');
1868
+ }
1869
+ agent.getContextManager().removeMessage(id);
1870
+ }
1871
+ getMessage(id) {
1872
+ const agent = this.primaryAgentName
1873
+ ? this.agents.get(this.primaryAgentName)
1874
+ : this.agents.values().next().value;
1875
+ if (!agent) {
1876
+ return null;
1877
+ }
1878
+ return agent.getContextManager().getMessage(id);
1879
+ }
1880
+ queryMessages(filter) {
1881
+ const agent = this.primaryAgentName
1882
+ ? this.agents.get(this.primaryAgentName)
1883
+ : this.agents.values().next().value;
1884
+ if (!agent) {
1885
+ return { messages: [], totalCount: 0 };
1886
+ }
1887
+ return agent.getContextManager().queryMessages(filter);
1888
+ }
1889
+ createFrameworkState() {
1890
+ return {
1891
+ getAgentStatus: (name) => {
1892
+ const agent = this.agents.get(name);
1893
+ return agent?.state ?? null;
1894
+ },
1895
+ getModule: (name) => {
1896
+ return this.moduleRegistry.getModule(name);
1897
+ },
1898
+ getPendingRequests: () => {
1899
+ return [...this.pendingRequests];
1900
+ },
1901
+ queueDepth: this.queue.depth,
1902
+ };
1903
+ }
1904
+ emitTrace(event) {
1905
+ const traceEvent = {
1906
+ ...event,
1907
+ timestamp: Date.now(),
1908
+ };
1909
+ for (const listener of this.traceListeners) {
1910
+ try {
1911
+ listener(traceEvent);
1912
+ }
1913
+ catch (error) {
1914
+ console.error('Trace listener error:', error);
1915
+ }
1916
+ }
1917
+ }
1918
+ // ==========================================================================
1919
+ // MCPL subsystem wiring
1920
+ // ==========================================================================
1921
+ /**
1922
+ * Initialize all MCPL subsystems and connect configured servers.
1923
+ * Fail-open: individual server connection failures don't prevent framework startup.
1924
+ */
1925
+ async initializeMcpl(serverConfigs, inferenceRouting) {
1926
+ this.mcplServerRegistry = new McplServerRegistry();
1927
+ this.featureSetManager = new FeatureSetManager();
1928
+ this.scopeManager = new ScopeManager();
1929
+ this.hookOrchestrator = new HookOrchestrator(this.mcplServerRegistry, this.featureSetManager);
1930
+ // Build prefix map and store configs for tool routing
1931
+ for (const config of serverConfigs) {
1932
+ const prefix = config.toolPrefix ?? `mcpl--${config.id}`;
1933
+ this.mcplPrefixMap.set(prefix, config.id);
1934
+ this.mcplServerConfigs.set(config.id, config);
1935
+ }
1936
+ // Find shouldTriggerInference callback:
1937
+ // Per-server callback takes precedence; fall back to EventGate; fall back to no filter.
1938
+ const triggerFilter = serverConfigs.find(c => c.shouldTriggerInference)?.shouldTriggerInference
1939
+ ?? (this.eventGate ? this.eventGate.asShouldTriggerCallback() : undefined);
1940
+ // Push events handler (Step 6)
1941
+ this.pushHandler = new PushHandler(this.featureSetManager, (event) => this.pushEvent(event), (event) => this.emitTrace(event), triggerFilter);
1942
+ // Server-initiated inference router (Step 6)
1943
+ this.inferenceRouter = new InferenceRouter(this.membrane, this.hookOrchestrator, this.featureSetManager, inferenceRouting ?? null, (event) => this.emitTrace(event), (serverId, params) => {
1944
+ const server = this.mcplServerRegistry.getServer(serverId);
1945
+ server?.sendInferenceChunk(params);
1946
+ });
1947
+ // Checkpoint manager (Step 8)
1948
+ this.checkpointManager = new CheckpointManager(this.store, (event) => this.emitTrace(event));
1949
+ // Channel registry (Step 7)
1950
+ this.channelRegistry = new ChannelRegistry(this.mcplServerRegistry, this.featureSetManager, (event) => this.pushEvent(event), (event) => this.emitTrace(event), {
1951
+ sendTypingFn: (serverId, channelId) => {
1952
+ const server = this.mcplServerRegistry.getServer(serverId);
1953
+ if (server) {
1954
+ server.sendChannelsTyping(channelId);
1955
+ }
1956
+ },
1957
+ shouldTriggerInference: triggerFilter,
1958
+ });
1959
+ // Host capabilities advertised during the MCP handshake
1960
+ const hostCapabilities = {
1961
+ version: '0.4',
1962
+ pushEvents: true,
1963
+ contextHooks: {
1964
+ beforeInference: true,
1965
+ afterInference: { blocking: true },
1966
+ },
1967
+ featureSets: true,
1968
+ };
1969
+ for (const config of serverConfigs) {
1970
+ try {
1971
+ const connection = await this.mcplServerRegistry.addServer(config, hostCapabilities);
1972
+ // Wire event listeners then flush any events that arrived during the
1973
+ // handshake window (between setupMessageRouting and now).
1974
+ this.wireMcplEvents(connection);
1975
+ connection.ready();
1976
+ // Initialize feature sets if server advertises MCPL capabilities
1977
+ if (connection.capabilities) {
1978
+ const updateParams = this.featureSetManager.initializeServer(config.id, connection.capabilities, {
1979
+ enabledFeatureSets: config.enabledFeatureSets,
1980
+ disabledFeatureSets: config.disabledFeatureSets,
1981
+ });
1982
+ // Inform server which feature sets are enabled/disabled
1983
+ if (updateParams.enabled?.length || updateParams.disabled?.length) {
1984
+ connection.sendFeatureSetsUpdate(updateParams);
1985
+ }
1986
+ // Configure scope whitelist/blacklist patterns
1987
+ if (config.scopes) {
1988
+ this.scopeManager.configureAll(config.scopes);
1989
+ }
1990
+ // Register stateful feature sets with checkpoint manager (Step 8)
1991
+ if (this.checkpointManager) {
1992
+ const declared = this.featureSetManager.getDeclaredFeatureSets(config.id);
1993
+ if (declared) {
1994
+ for (const [fsName, fsDecl] of Object.entries(declared)) {
1995
+ if (fsDecl.rollback || fsDecl.hostState) {
1996
+ this.checkpointManager.registerFeatureSet(config.id, fsName, {
1997
+ hostState: fsDecl.hostState ?? false,
1998
+ rollback: fsDecl.rollback ?? false,
1999
+ });
2000
+ }
2001
+ }
2002
+ }
2003
+ }
2004
+ }
2005
+ this.emitTrace({ type: 'module:added', moduleName: `mcpl:${config.id}` });
2006
+ }
2007
+ catch (error) {
2008
+ // Fail-open: log and continue with remaining servers
2009
+ const err = error instanceof Error ? error : new Error(String(error));
2010
+ console.error(`Failed to connect MCPL server "${config.id}":`, err.message);
2011
+ }
2012
+ }
2013
+ // Discover tools from all connected servers
2014
+ await this.refreshMcplTools();
2015
+ }
2016
+ /**
2017
+ * Wire event listeners on an MCPL server connection.
2018
+ * Push events and inference requests are deferred to Steps 6/7.
2019
+ */
2020
+ wireMcplEvents(connection) {
2021
+ // Handle dynamic feature set changes from server
2022
+ connection.on('feature-sets-changed', (params) => {
2023
+ this.featureSetManager?.handleFeatureSetsChanged(connection.id, params);
2024
+ });
2025
+ // Handle scope elevation requests
2026
+ connection.on('scope-elevate', async (params, responder) => {
2027
+ if (this.scopeManager && responder) {
2028
+ try {
2029
+ const result = await this.scopeManager.handleElevation(params);
2030
+ responder.respond(result);
2031
+ }
2032
+ catch (error) {
2033
+ const err = error instanceof Error ? error : new Error(String(error));
2034
+ responder.respondError(-32603, err.message);
2035
+ }
2036
+ }
2037
+ });
2038
+ // Handle push events (Step 6)
2039
+ connection.on('push-event', (params, responder) => {
2040
+ this.pushHandler?.handlePushEvent(connection.id, params, responder);
2041
+ });
2042
+ // Handle server-initiated inference requests (Step 6)
2043
+ connection.on('inference-request', async (params, responder) => {
2044
+ if (this.inferenceRouter && responder) {
2045
+ await this.inferenceRouter.handleInferenceRequest(connection.id, params, {
2046
+ respond: responder.respond,
2047
+ respondError: responder.respondError,
2048
+ requestId: responder.id,
2049
+ });
2050
+ }
2051
+ });
2052
+ // Handle channel registration (Step 7)
2053
+ connection.on('channels-register', async (params, responder) => {
2054
+ await this.channelRegistry?.handleRegister(connection.id, params, responder);
2055
+ });
2056
+ // Handle channel changes (Step 7)
2057
+ connection.on('channels-changed', async (params) => {
2058
+ await this.channelRegistry?.handleChanged(connection.id, params);
2059
+ });
2060
+ // Handle incoming channel messages (Step 7)
2061
+ connection.on('channels-incoming', (params, responder) => {
2062
+ this.channelRegistry?.handleIncoming(connection.id, params, responder);
2063
+ });
2064
+ // Handle dynamic tool list changes (notifications/tools/list_changed)
2065
+ connection.on('tools-list-changed', () => {
2066
+ this.handleToolsListChanged(connection.id);
2067
+ });
2068
+ // Also refresh tools on reconnect (server may have different tools)
2069
+ connection.on('reconnect', () => {
2070
+ this.handleToolsListChanged(connection.id);
2071
+ });
2072
+ // Cleanup on disconnect
2073
+ connection.on('close', () => {
2074
+ this.featureSetManager?.removeServer(connection.id);
2075
+ this.checkpointManager?.removeServer(connection.id);
2076
+ this.emitTrace({ type: 'module:removed', moduleName: `mcpl:${connection.id}` });
2077
+ });
2078
+ }
2079
+ /**
2080
+ * Discover tools from all connected MCPL servers and cache them.
2081
+ * Tools are namespaced as `{toolPrefix}--{toolName}` per server config.
2082
+ */
2083
+ async refreshMcplTools() {
2084
+ if (!this.mcplServerRegistry)
2085
+ return;
2086
+ const tools = [];
2087
+ for (const server of this.mcplServerRegistry.getAllServers()) {
2088
+ const config = this.mcplServerConfigs.get(server.id);
2089
+ const prefix = config?.toolPrefix ?? `mcpl--${server.id}`;
2090
+ try {
2091
+ const result = await server.sendToolsList();
2092
+ for (const tool of result.tools) {
2093
+ // MCP tool schemas are generic JSON Schema; cast to membrane's ToolDefinition format
2094
+ const schema = tool.inputSchema;
2095
+ tools.push({
2096
+ name: `${prefix}--${tool.name}`,
2097
+ description: tool.description ?? '',
2098
+ inputSchema: schema,
2099
+ });
2100
+ }
2101
+ }
2102
+ catch {
2103
+ // Server may not support tools/list — skip silently
2104
+ }
2105
+ }
2106
+ this.mcplTools = tools;
2107
+ }
2108
+ /**
2109
+ * Handle a tools/list_changed notification with collapse logic.
2110
+ * At most 2 refresh cycles can be in-flight: one running and one pending.
2111
+ */
2112
+ handleToolsListChanged(serverId) {
2113
+ if (this.mcplToolRefreshInFlight) {
2114
+ this.mcplToolRefreshPending = true;
2115
+ return;
2116
+ }
2117
+ this.mcplToolRefreshInFlight = true;
2118
+ const oldToolNames = new Set(this.mcplTools.map(t => t.name));
2119
+ this.refreshMcplTools()
2120
+ .then(() => {
2121
+ this.emitMcplToolDiff(oldToolNames, serverId);
2122
+ })
2123
+ .catch((error) => {
2124
+ console.error('MCPL tool refresh error:', error);
2125
+ })
2126
+ .finally(() => {
2127
+ this.mcplToolRefreshInFlight = false;
2128
+ if (this.mcplToolRefreshPending) {
2129
+ this.mcplToolRefreshPending = false;
2130
+ this.handleToolsListChanged(serverId);
2131
+ }
2132
+ });
2133
+ }
2134
+ /**
2135
+ * Emit a trace event and push an external-message listing newly added tools.
2136
+ */
2137
+ emitMcplToolDiff(oldToolNames, serverId) {
2138
+ const newTools = this.mcplTools.filter(t => !oldToolNames.has(t.name));
2139
+ const removedTools = [...oldToolNames].filter(name => !this.mcplTools.some(t => t.name === name));
2140
+ if (newTools.length === 0 && removedTools.length === 0)
2141
+ return;
2142
+ this.emitTrace({
2143
+ type: 'module:added',
2144
+ moduleName: `mcpl:${serverId}:tools-refreshed`,
2145
+ });
2146
+ if (newTools.length > 0) {
2147
+ const toolList = newTools.map(t => t.name).join(', ');
2148
+ this.pushEvent({
2149
+ type: 'external-message',
2150
+ source: `mcpl:${serverId}`,
2151
+ content: `New tools available: ${toolList}`,
2152
+ metadata: { newTools: newTools.map(t => t.name), removedTools },
2153
+ });
2154
+ }
2155
+ }
2156
+ /**
2157
+ * Dispatch a tool call to an MCPL server.
2158
+ * Strips the configured toolPrefix and routes to the server.
2159
+ */
2160
+ dispatchMcplToolCall(agentName, call, serverId, prefix) {
2161
+ const toolName = call.name.slice(prefix.length + 2); // strip "{prefix}--"
2162
+ const server = this.mcplServerRegistry.getServer(serverId);
2163
+ if (!server) {
2164
+ this.pushEvent({
2165
+ type: 'tool-result',
2166
+ callId: call.id,
2167
+ agentName,
2168
+ moduleName: `mcpl:${serverId}`,
2169
+ result: { success: false, error: `MCPL server not found: ${serverId}`, isError: true },
2170
+ });
2171
+ return;
2172
+ }
2173
+ this.emitTrace({ type: 'tool:started', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, input: call.input });
2174
+ const startTime = Date.now();
2175
+ const args = (call.input && typeof call.input === 'object') ? call.input : {};
2176
+ // Build state params for stateful tools (Step 8)
2177
+ let stateParams;
2178
+ if (this.checkpointManager) {
2179
+ const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
2180
+ if (fs) {
2181
+ if (this.checkpointManager.isHostManaged(serverId, fs)) {
2182
+ stateParams = { state: this.checkpointManager.getCurrentState(serverId, fs) };
2183
+ }
2184
+ else {
2185
+ const cp = this.checkpointManager.getCurrentCheckpoint(serverId, fs);
2186
+ if (cp)
2187
+ stateParams = { checkpoint: cp };
2188
+ }
2189
+ }
2190
+ }
2191
+ server.sendToolsCall(toolName, args, stateParams)
2192
+ .then((result) => {
2193
+ const durationMs = Date.now() - startTime;
2194
+ this.emitTrace({ type: 'tool:completed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, durationMs });
2195
+ // Record checkpoint from stateful tool response (Step 8)
2196
+ if (result.state && this.checkpointManager) {
2197
+ const fs = this.checkpointManager.getStatefulFeatureSet(serverId);
2198
+ if (fs) {
2199
+ this.checkpointManager.recordCheckpoint(serverId, fs, result.state);
2200
+ }
2201
+ }
2202
+ // Convert MCP tool result to framework ToolResult
2203
+ const textContent = result.content
2204
+ ?.filter((c) => c.type === 'text' && c.text)
2205
+ .map((c) => c.text)
2206
+ .join('\n');
2207
+ this.pushEvent({
2208
+ type: 'tool-result',
2209
+ callId: call.id,
2210
+ agentName,
2211
+ moduleName: `mcpl:${serverId}`,
2212
+ result: {
2213
+ success: !result.isError,
2214
+ data: result.isError ? undefined : textContent || undefined,
2215
+ error: result.isError ? (textContent || 'Tool call failed') : undefined,
2216
+ isError: result.isError ?? false,
2217
+ },
2218
+ });
2219
+ })
2220
+ .catch((error) => {
2221
+ const err = error instanceof Error ? error : new Error(String(error));
2222
+ this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, error: err.message, stack: err.stack });
2223
+ this.pushEvent({
2224
+ type: 'tool-result',
2225
+ callId: call.id,
2226
+ agentName,
2227
+ moduleName: `mcpl:${serverId}`,
2228
+ result: { success: false, error: err.message, isError: true },
2229
+ });
2230
+ });
2231
+ }
2232
+ /**
2233
+ * Build BeforeInferenceParams from agent state and trigger context.
2234
+ */
2235
+ /**
2236
+ * Dispatch a synthesized channel tool call.
2237
+ */
2238
+ dispatchChannelToolCall(agentName, call) {
2239
+ this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input });
2240
+ const startTime = Date.now();
2241
+ this.channelRegistry.handleChannelToolCall(call.name, call.input)
2242
+ .then((result) => {
2243
+ const durationMs = Date.now() - startTime;
2244
+ this.emitTrace({ type: 'tool:completed', module: 'channels', tool: call.name, callId: call.id, durationMs });
2245
+ this.pushEvent({
2246
+ type: 'tool-result',
2247
+ callId: call.id,
2248
+ agentName,
2249
+ moduleName: 'channels',
2250
+ result,
2251
+ });
2252
+ })
2253
+ .catch((error) => {
2254
+ const err = error instanceof Error ? error : new Error(String(error));
2255
+ this.emitTrace({ type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id, error: err.message });
2256
+ this.pushEvent({
2257
+ type: 'tool-result',
2258
+ callId: call.id,
2259
+ agentName,
2260
+ moduleName: 'channels',
2261
+ result: { success: false, error: err.message, isError: true },
2262
+ });
2263
+ });
2264
+ }
2265
+ dispatchGateToolCall(agentName, call) {
2266
+ this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
2267
+ const startTime = Date.now();
2268
+ this.eventGate.handleToolCall()
2269
+ .then((result) => {
2270
+ const durationMs = Date.now() - startTime;
2271
+ this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs });
2272
+ this.pushEvent({
2273
+ type: 'tool-result',
2274
+ callId: call.id,
2275
+ agentName,
2276
+ moduleName: 'gate',
2277
+ result,
2278
+ });
2279
+ })
2280
+ .catch((error) => {
2281
+ const err = error instanceof Error ? error : new Error(String(error));
2282
+ this.emitTrace({ type: 'tool:failed', module: 'gate', tool: call.name, callId: call.id, error: err.message });
2283
+ this.pushEvent({
2284
+ type: 'tool-result',
2285
+ callId: call.id,
2286
+ agentName,
2287
+ moduleName: 'gate',
2288
+ result: { success: false, error: err.message, isError: true },
2289
+ });
2290
+ });
2291
+ }
2292
+ buildBeforeInferenceParams(agent, trigger) {
2293
+ const inferenceId = `${agent.name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2294
+ return {
2295
+ inferenceId,
2296
+ conversationId: agent.name, // Simplified; proper conversation tracking TODO
2297
+ turnIndex: 0, // Simplified; needs per-conversation counter TODO
2298
+ userMessage: null, // Could extract from trigger context
2299
+ model: {
2300
+ id: agent.model,
2301
+ vendor: 'unknown',
2302
+ contextWindow: 200000,
2303
+ capabilities: ['tools'],
2304
+ },
2305
+ channels: this.channelRegistry?.buildChannelContext(),
2306
+ };
2307
+ }
2308
+ }
2309
+ //# sourceMappingURL=framework.js.map