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