agentxjs 0.0.0-dev-20260312143810

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts ADDED
@@ -0,0 +1,346 @@
1
+ /**
2
+ * AgentX Server Implementation (JSON-RPC 2.0)
3
+ *
4
+ * Creates a WebSocket server that:
5
+ * 1. Accepts client connections
6
+ * 2. Handles JSON-RPC requests directly via CommandHandler
7
+ * 3. Broadcasts stream events as JSON-RPC notifications
8
+ *
9
+ * Message Types:
10
+ * - RPC Request (has id): Client → Server → Client (direct response)
11
+ * - RPC Notification (no id): Server → Client (stream events)
12
+ */
13
+
14
+ import type { CreateDriver } from "@agentxjs/core/driver";
15
+ import type { BusEvent, SystemEvent } from "@agentxjs/core/event";
16
+ import type { ChannelConnection } from "@agentxjs/core/network";
17
+ import {
18
+ createErrorResponse,
19
+ createStreamEvent,
20
+ createSuccessResponse,
21
+ isNotification,
22
+ isRequest,
23
+ parseMessage,
24
+ RpcErrorCodes,
25
+ type RpcMethod,
26
+ } from "@agentxjs/core/network";
27
+ import type { AgentXPlatform } from "@agentxjs/core/runtime";
28
+ import { createAgentXRuntime } from "@agentxjs/core/runtime";
29
+ import { createLogger } from "@deepracticex/logger";
30
+ import { CommandHandler } from "./CommandHandler";
31
+ import type { AgentXServer } from "./types";
32
+
33
+ const logger = createLogger("server/Server");
34
+
35
+ /**
36
+ * Connection state
37
+ */
38
+ interface ConnectionState {
39
+ connection: ChannelConnection;
40
+ subscribedTopics: Set<string>;
41
+ }
42
+
43
+ /**
44
+ * Server configuration (supports both immediate and deferred platforms)
45
+ */
46
+ export interface ServerConfig {
47
+ /**
48
+ * AgentX Platform — must provide `channelServer` for accepting WebSocket connections.
49
+ */
50
+ platform: AgentXPlatform;
51
+
52
+ /**
53
+ * LLM Driver factory function - creates Driver per Agent
54
+ */
55
+ createDriver: CreateDriver;
56
+
57
+ /**
58
+ * Port to listen on (standalone mode)
59
+ */
60
+ port?: number;
61
+
62
+ /**
63
+ * Host to bind to (default: "0.0.0.0")
64
+ */
65
+ host?: string;
66
+
67
+ /**
68
+ * Existing HTTP server to attach to (attached mode)
69
+ */
70
+ server?: import("@agentxjs/core/network").MinimalHTTPServer;
71
+
72
+ /**
73
+ * WebSocket path when attached (default: "/ws")
74
+ */
75
+ wsPath?: string;
76
+
77
+ /**
78
+ * Enable debug logging
79
+ */
80
+ debug?: boolean;
81
+ }
82
+
83
+ /**
84
+ * Create an AgentX server
85
+ */
86
+ export async function createServer(config: ServerConfig): Promise<AgentXServer> {
87
+ const { wsPath = "/ws" } = config;
88
+ const platform = config.platform;
89
+
90
+ // Create runtime from platform + driver
91
+ const runtime = createAgentXRuntime(platform, config.createDriver);
92
+
93
+ // Get channel server from platform
94
+ const wsServer = platform.channelServer;
95
+ if (!wsServer) {
96
+ throw new Error("Platform must provide channelServer for server mode");
97
+ }
98
+
99
+ // Create command handler (no longer needs eventBus)
100
+ const commandHandler = new CommandHandler(runtime);
101
+
102
+ // Track connections
103
+ const connections = new Map<string, ConnectionState>();
104
+
105
+ /**
106
+ * Subscribe connection to a topic
107
+ */
108
+ function subscribeToTopic(connectionId: string, topic: string): void {
109
+ const state = connections.get(connectionId);
110
+ if (!state || state.subscribedTopics.has(topic)) return;
111
+
112
+ state.subscribedTopics.add(topic);
113
+ logger.debug("Connection subscribed to topic", { connectionId, topic });
114
+ }
115
+
116
+ /**
117
+ * Check if event should be sent to connection based on subscriptions
118
+ */
119
+ function shouldSendToConnection(state: ConnectionState, event: BusEvent): boolean {
120
+ // Skip internal driver events
121
+ if (event.source === "driver" && event.intent !== "notification") {
122
+ return false;
123
+ }
124
+
125
+ // Skip command events (they are handled via RPC, not broadcast)
126
+ if (event.source === "command") {
127
+ return false;
128
+ }
129
+
130
+ // Check if subscribed to event's session
131
+ const eventWithContext = event as BusEvent & { context?: { sessionId?: string } };
132
+ const sessionId = eventWithContext.context?.sessionId;
133
+ if (sessionId && state.subscribedTopics.has(sessionId)) {
134
+ return true;
135
+ }
136
+
137
+ // Send to global subscribers
138
+ return state.subscribedTopics.has("global");
139
+ }
140
+
141
+ /**
142
+ * Send JSON-RPC response to a specific connection
143
+ */
144
+ function sendResponse(connection: ChannelConnection, id: string | number, result: unknown): void {
145
+ const response = createSuccessResponse(id, result);
146
+ connection.send(JSON.stringify(response));
147
+ }
148
+
149
+ /**
150
+ * Send JSON-RPC error to a specific connection
151
+ */
152
+ function sendError(
153
+ connection: ChannelConnection,
154
+ id: string | number | null,
155
+ code: number,
156
+ message: string
157
+ ): void {
158
+ const response = createErrorResponse(id, code, message);
159
+ connection.send(JSON.stringify(response));
160
+ }
161
+
162
+ // Handle new connections
163
+ wsServer.onConnection((connection) => {
164
+ const state: ConnectionState = {
165
+ connection,
166
+ subscribedTopics: new Set(["global"]),
167
+ };
168
+ connections.set(connection.id, state);
169
+
170
+ logger.info("Client connected", {
171
+ connectionId: connection.id,
172
+ totalConnections: connections.size,
173
+ });
174
+
175
+ // Handle messages from client
176
+ connection.onMessage(async (message) => {
177
+ try {
178
+ const parsed = parseMessage(message);
179
+
180
+ // Handle single message (not batch)
181
+ if (!Array.isArray(parsed)) {
182
+ await handleParsedMessage(connection, state, parsed);
183
+ } else {
184
+ // Handle batch (not common, but supported by JSON-RPC 2.0)
185
+ for (const item of parsed) {
186
+ await handleParsedMessage(connection, state, item);
187
+ }
188
+ }
189
+ } catch (err) {
190
+ logger.error("Failed to parse message", { error: (err as Error).message });
191
+ sendError(connection, null, RpcErrorCodes.PARSE_ERROR, "Parse error");
192
+ }
193
+ });
194
+
195
+ // Cleanup on disconnect
196
+ connection.onClose(() => {
197
+ connections.delete(connection.id);
198
+ logger.info("Client disconnected", {
199
+ connectionId: connection.id,
200
+ totalConnections: connections.size,
201
+ });
202
+ });
203
+ });
204
+
205
+ /**
206
+ * Handle a parsed JSON-RPC message
207
+ */
208
+ async function handleParsedMessage(
209
+ connection: ChannelConnection,
210
+ state: ConnectionState,
211
+ parsed: import("jsonrpc-lite").IParsedObject
212
+ ): Promise<void> {
213
+ if (isRequest(parsed)) {
214
+ // JSON-RPC Request - handle and respond directly
215
+ const payload = parsed.payload as {
216
+ id: string | number;
217
+ method: string;
218
+ params: unknown;
219
+ };
220
+ const { id, method, params } = payload;
221
+
222
+ logger.debug("Received RPC request", { id, method });
223
+
224
+ // Call command handler
225
+ const result = await commandHandler.handle(method as RpcMethod, params);
226
+
227
+ if (result.success) {
228
+ sendResponse(connection, id, result.data);
229
+ } else {
230
+ sendError(connection, id, result.code, result.message);
231
+ }
232
+ } else if (isNotification(parsed)) {
233
+ // JSON-RPC Notification - control messages
234
+ const payload = parsed.payload as {
235
+ method: string;
236
+ params: unknown;
237
+ };
238
+ const { method, params } = payload;
239
+
240
+ logger.debug("Received notification", { method });
241
+
242
+ if (method === "subscribe") {
243
+ const { topic } = params as { topic: string };
244
+ subscribeToTopic(connection.id, topic);
245
+ } else if (method === "unsubscribe") {
246
+ const { topic } = params as { topic: string };
247
+ state.subscribedTopics.delete(topic);
248
+ logger.debug("Connection unsubscribed from topic", { connectionId: connection.id, topic });
249
+ } else if (method === "control.ack") {
250
+ // ACK for reliable delivery - handled by network layer
251
+ logger.debug("Received ACK notification");
252
+ }
253
+ } else {
254
+ // Invalid message
255
+ logger.warn("Received invalid JSON-RPC message");
256
+ }
257
+ }
258
+
259
+ // Route internal events to connected clients as JSON-RPC notifications
260
+ platform.eventBus.onAny((event) => {
261
+ // Only broadcast broadcastable events
262
+ if (!shouldBroadcastEvent(event)) {
263
+ return;
264
+ }
265
+
266
+ // Get topic from event context
267
+ const eventWithContext = event as BusEvent & { context?: { sessionId?: string } };
268
+ const topic = eventWithContext.context?.sessionId || "global";
269
+
270
+ // Wrap as JSON-RPC notification
271
+ const notification = createStreamEvent(topic, event as SystemEvent);
272
+ const message = JSON.stringify(notification);
273
+
274
+ for (const [connectionId, state] of connections) {
275
+ if (shouldSendToConnection(state, event)) {
276
+ state.connection.sendReliable(message, {
277
+ timeout: 10000,
278
+ onTimeout: () => {
279
+ logger.warn("Event ACK timeout", {
280
+ connectionId,
281
+ eventType: event.type,
282
+ });
283
+ },
284
+ });
285
+ }
286
+ }
287
+ });
288
+
289
+ /**
290
+ * Check if event should be broadcast
291
+ */
292
+ function shouldBroadcastEvent(event: BusEvent): boolean {
293
+ // Skip internal driver events
294
+ if (event.source === "driver" && event.intent !== "notification") {
295
+ return false;
296
+ }
297
+
298
+ // Skip command events (handled via RPC)
299
+ if (event.source === "command") {
300
+ return false;
301
+ }
302
+
303
+ // Check broadcastable flag
304
+ const systemEvent = event as SystemEvent;
305
+ if (systemEvent.broadcastable === false) {
306
+ return false;
307
+ }
308
+
309
+ return true;
310
+ }
311
+
312
+ // Attach to existing server if provided
313
+ if (config.server) {
314
+ wsServer.attach(config.server, wsPath);
315
+ logger.info("WebSocket attached to existing server", { path: wsPath });
316
+ }
317
+
318
+ return {
319
+ async listen(port?: number, host?: string) {
320
+ if (config.server) {
321
+ throw new Error(
322
+ "Cannot listen when attached to existing server. The server should call listen() instead."
323
+ );
324
+ }
325
+
326
+ const listenPort = port ?? config.port ?? 5200;
327
+ const listenHost = host ?? config.host ?? "0.0.0.0";
328
+
329
+ await wsServer.listen(listenPort, listenHost);
330
+ logger.info("Server listening", { port: listenPort, host: listenHost });
331
+ },
332
+
333
+ async close() {
334
+ await wsServer.close();
335
+ logger.info("Server closed");
336
+ },
337
+
338
+ async dispose() {
339
+ // Cleanup in order
340
+ await wsServer.dispose();
341
+ commandHandler.dispose();
342
+ await runtime.shutdown();
343
+ logger.info("Server disposed");
344
+ },
345
+ };
346
+ }