Claude Code Claude Opus 4.6 commited on
Commit
e95366a
·
1 Parent(s): c190a36

Claude Code: Add CollaborationHub WebSocket message broker

Browse files

- Add CollaborationHub class with room-based messaging (adam, eve, system, dialogue)
- Add /ws/hub WebSocket endpoint for room-based connections
- Add HTTP API endpoints: /api/hub/status, /api/hub/dialogue, /api/hub/broadcast
- Support inter-agent dialogue simulation and targeted room messaging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. app.py +465 -1
app.py CHANGED
@@ -163,8 +163,254 @@ class ConnectionManager:
163
  self.disconnect(client_id)
164
 
165
 
166
- # Global connection manager instance
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  manager = ConnectionManager()
 
168
 
169
 
170
  @app.exception_handler(Exception)
@@ -233,6 +479,107 @@ _worker_state = {
233
  }
234
 
235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  @app.post("/internal/heartbeat")
237
  @limiter.limit("100/minute") # Higher limit for internal heartbeat
238
  async def receive_heartbeat(request: Request):
@@ -369,6 +716,123 @@ async def status_check(request: Request):
369
  return {"status": status, "checks": checks}
370
 
371
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
  @app.websocket("/ws")
373
  async def websocket_endpoint(
374
  websocket: WebSocket,
 
163
  self.disconnect(client_id)
164
 
165
 
166
+ # =============================================================================
167
+ # CollaborationHub - Room-based WebSocket Message Broker
168
+ # =============================================================================
169
+
170
+ class CollaborationHub:
171
+ """
172
+ Centralized WebSocket message broker supporting distinct rooms/topics.
173
+
174
+ Features:
175
+ - Rooms: adam, eve, system, dialogue (for Agent Dialogue simulation)
176
+ - Automatic client connection tracking per room
177
+ - Broadcast to specific rooms or all rooms
178
+ - Targeted messaging to individual clients
179
+ """
180
+
181
+ # Available room types
182
+ ROOMS = {"adam", "eve", "system", "dialogue"}
183
+
184
+ def __init__(self) -> None:
185
+ # Room -> {client_id -> WebSocket}
186
+ self.rooms: Dict[str, Dict[str, WebSocket]] = {room: {} for room in self.ROOMS}
187
+ # Client tracking: client_id -> room
188
+ self.client_room: Dict[str, str] = {}
189
+ # Client info: client_id -> metadata
190
+ self.client_info: Dict[str, Dict[str, Any]] = {}
191
+
192
+ async def connect(self, client_id: str, websocket: WebSocket, room: str = "system") -> bool:
193
+ """
194
+ Connect a client to a specific room.
195
+
196
+ Args:
197
+ client_id: Unique identifier for the client
198
+ websocket: WebSocket connection object
199
+ room: Room to join (adam, eve, system, dialogue)
200
+
201
+ Returns:
202
+ True if connected, False if duplicate or invalid room
203
+ """
204
+ if room not in self.ROOMS:
205
+ logger.warning(
206
+ f"Invalid room: {room}",
207
+ extra={"client_id": client_id, "room": room, "action": "reject_invalid_room"}
208
+ )
209
+ return False
210
+
211
+ # Reject if client already connected to any room
212
+ if client_id in self.client_room:
213
+ existing_room = self.client_room[client_id]
214
+ logger.warning(
215
+ f"Client {client_id} already in room {existing_room}",
216
+ extra={"client_id": client_id, "existing_room": existing_room, "action": "reject_duplicate"}
217
+ )
218
+ return False
219
+
220
+ await websocket.accept()
221
+ self.rooms[room][client_id] = websocket
222
+ self.client_room[client_id] = room
223
+ self.client_info[client_id] = {
224
+ "room": room,
225
+ "connected_at": datetime.utcnow().isoformat()
226
+ }
227
+
228
+ logger.info(
229
+ f"Client {client_id} joined room: {room}",
230
+ extra={
231
+ "client_id": client_id,
232
+ "room": room,
233
+ "action": "join_room",
234
+ "room_members": len(self.rooms[room])
235
+ }
236
+ )
237
+
238
+ # Notify client of successful join
239
+ await websocket.send_json({
240
+ "type": "room_joined",
241
+ "room": room,
242
+ "client_id": client_id,
243
+ "timestamp": datetime.utcnow().isoformat()
244
+ })
245
+
246
+ return True
247
+
248
+ def disconnect(self, client_id: str) -> None:
249
+ """Disconnect a client from their room."""
250
+ if client_id not in self.client_room:
251
+ return
252
+
253
+ room = self.client_room[client_id]
254
+ if client_id in self.rooms[room]:
255
+ del self.rooms[room][client_id]
256
+
257
+ del self.client_room[client_id]
258
+ if client_id in self.client_info:
259
+ del self.client_info[client_id]
260
+
261
+ logger.info(
262
+ f"Client {client_id} left room: {room}",
263
+ extra={
264
+ "client_id": client_id,
265
+ "room": room,
266
+ "action": "leave_room",
267
+ "room_members": len(self.rooms[room])
268
+ }
269
+ )
270
+
271
+ async def send_to_room(self, room: str, message: Any) -> int:
272
+ """
273
+ Send a message to all clients in a specific room.
274
+
275
+ Args:
276
+ room: Target room name
277
+ message: Message payload (will be JSON serialized)
278
+
279
+ Returns:
280
+ Number of clients the message was sent to
281
+ """
282
+ if room not in self.ROOMS:
283
+ logger.warning(f"Cannot send to invalid room: {room}")
284
+ return 0
285
+
286
+ if not self.rooms[room]:
287
+ return 0
288
+
289
+ disconnected = []
290
+ sent_count = 0
291
+
292
+ for client_id, websocket in self.rooms[room].items():
293
+ try:
294
+ await websocket.send_json(message)
295
+ sent_count += 1
296
+ except Exception as e:
297
+ logger.warning(
298
+ f"Failed to send to {client_id} in room {room}: {e}",
299
+ extra={"client_id": client_id, "room": room, "error": str(e)}
300
+ )
301
+ disconnected.append(client_id)
302
+
303
+ # Clean up disconnected clients
304
+ for client_id in disconnected:
305
+ self.disconnect(client_id)
306
+
307
+ return sent_count
308
+
309
+ async def send_to_client(self, client_id: str, message: Any) -> bool:
310
+ """
311
+ Send a message to a specific client.
312
+
313
+ Args:
314
+ client_id: Target client ID
315
+ message: Message payload (will be JSON serialized)
316
+
317
+ Returns:
318
+ True if sent, False if client not found or send failed
319
+ """
320
+ if client_id not in self.client_room:
321
+ return False
322
+
323
+ room = self.client_room[client_id]
324
+ if client_id not in self.rooms[room]:
325
+ return False
326
+
327
+ try:
328
+ await self.rooms[room][client_id].send_json(message)
329
+ return True
330
+ except Exception as e:
331
+ logger.warning(
332
+ f"Failed to send to {client_id}: {e}",
333
+ extra={"client_id": client_id, "error": str(e)}
334
+ )
335
+ self.disconnect(client_id)
336
+ return False
337
+
338
+ async def broadcast(self, message: Any, exclude_room: str = None) -> int:
339
+ """
340
+ Broadcast a message to all rooms (or all except one).
341
+
342
+ Args:
343
+ message: Message payload (will be JSON serialized)
344
+ exclude_room: Optional room to exclude from broadcast
345
+
346
+ Returns:
347
+ Total number of clients the message was sent to
348
+ """
349
+ total_sent = 0
350
+ for room in self.ROOMS:
351
+ if exclude_room and room == exclude_room:
352
+ continue
353
+ total_sent += await self.send_to_room(room, message)
354
+ return total_sent
355
+
356
+ async def simulate_agent_dialogue(self, speaker: str, listener: str, message: str) -> None:
357
+ """
358
+ Simulate inter-agent dialogue by sending a message from one agent to another.
359
+
360
+ Args:
361
+ speaker: Room name of the speaking agent (adam or eve)
362
+ listener: Room name of the listening agent (adam or eve)
363
+ message: The message content
364
+ """
365
+ if speaker not in {"adam", "eve"} or listener not in {"adam", "eve"}:
366
+ logger.warning(f"Invalid agent dialogue: {speaker} -> {listener}")
367
+ return
368
+
369
+ dialogue_message = {
370
+ "type": "agent_dialogue",
371
+ "speaker": speaker,
372
+ "listener": listener,
373
+ "message": message,
374
+ "timestamp": datetime.utcnow().isoformat()
375
+ }
376
+
377
+ # Send to listener's room
378
+ sent_count = await self.send_to_room(listener, dialogue_message)
379
+
380
+ # Also broadcast to system room for logging
381
+ await self.send_to_room("system", {
382
+ **dialogue_message,
383
+ "type": "agent_dialogue_log"
384
+ })
385
+
386
+ logger.info(
387
+ f"Agent dialogue: {speaker} -> {listener}: {message[:50]}...",
388
+ extra={
389
+ "speaker": speaker,
390
+ "listener": listener,
391
+ "recipients": sent_count,
392
+ "message_length": len(message)
393
+ }
394
+ )
395
+
396
+ def get_room_status(self) -> Dict[str, Any]:
397
+ """Return current status of all rooms."""
398
+ return {
399
+ room: {
400
+ "members": list(clients.keys()),
401
+ "count": len(clients)
402
+ }
403
+ for room, clients in self.rooms.items()
404
+ }
405
+
406
+ def get_client_info(self, client_id: str) -> Dict[str, Any]:
407
+ """Get info about a specific client."""
408
+ return self.client_info.get(client_id, {})
409
+
410
+
411
+ # Global manager instances
412
  manager = ConnectionManager()
413
+ hub = CollaborationHub()
414
 
415
 
416
  @app.exception_handler(Exception)
 
479
  }
480
 
481
 
482
+ # =============================================================================
483
+ # CollaborationHub HTTP API Endpoints
484
+ # =============================================================================
485
+
486
+ @app.get("/api/hub/status")
487
+ @limiter.limit("60/minute")
488
+ async def hub_status(request: Request):
489
+ """Get current CollaborationHub room status."""
490
+ return {
491
+ "timestamp": datetime.utcnow().isoformat(),
492
+ "rooms": hub.get_room_status(),
493
+ "total_clients": len(hub.client_room)
494
+ }
495
+
496
+
497
+ @app.post("/api/hub/dialogue")
498
+ @limiter.limit("30/minute")
499
+ async def trigger_agent_dialogue(request: Request):
500
+ """
501
+ Trigger an agent dialogue simulation.
502
+
503
+ Body: {"speaker": "adam", "listener": "eve", "message": "Hello!"}
504
+ """
505
+ try:
506
+ payload = await request.json()
507
+ speaker = payload.get("speaker", "adam")
508
+ listener = payload.get("listener", "eve")
509
+ message = payload.get("message", "")
510
+
511
+ if not message:
512
+ return JSONResponse(
513
+ status_code=400,
514
+ content={"error": "message is required"}
515
+ )
516
+
517
+ await hub.simulate_agent_dialogue(speaker, listener, message)
518
+
519
+ return {
520
+ "status": "sent",
521
+ "speaker": speaker,
522
+ "listener": listener,
523
+ "message": message,
524
+ "timestamp": datetime.utcnow().isoformat()
525
+ }
526
+
527
+ except Exception as e:
528
+ logger.error(f"Dialogue trigger error: {e}", exc_info=True)
529
+ return JSONResponse(
530
+ status_code=500,
531
+ content={"error": str(e)}
532
+ )
533
+
534
+
535
+ @app.post("/api/hub/broadcast")
536
+ @limiter.limit("30/minute")
537
+ async def broadcast_to_room(request: Request):
538
+ """
539
+ Broadcast a message to a specific room or all rooms.
540
+
541
+ Body: {"room": "adam", "data": {...}} or {"broadcast_all": true, "data": {...}}
542
+ """
543
+ try:
544
+ payload = await request.json()
545
+ data = payload.get("data", {})
546
+ room = payload.get("room")
547
+ broadcast_all = payload.get("broadcast_all", False)
548
+
549
+ if broadcast_all:
550
+ recipients = await hub.broadcast(data)
551
+ return {
552
+ "status": "broadcast",
553
+ "recipients": recipients,
554
+ "timestamp": datetime.utcnow().isoformat()
555
+ }
556
+ elif room:
557
+ if room not in hub.ROOMS:
558
+ return JSONResponse(
559
+ status_code=400,
560
+ content={"error": f"Invalid room. Available: {list(hub.ROOMS)}"}
561
+ )
562
+ recipients = await hub.send_to_room(room, data)
563
+ return {
564
+ "status": "sent",
565
+ "room": room,
566
+ "recipients": recipients,
567
+ "timestamp": datetime.utcnow().isoformat()
568
+ }
569
+ else:
570
+ return JSONResponse(
571
+ status_code=400,
572
+ content={"error": "Either 'room' or 'broadcast_all' is required"}
573
+ )
574
+
575
+ except Exception as e:
576
+ logger.error(f"Broadcast error: {e}", exc_info=True)
577
+ return JSONResponse(
578
+ status_code=500,
579
+ content={"error": str(e)}
580
+ )
581
+
582
+
583
  @app.post("/internal/heartbeat")
584
  @limiter.limit("100/minute") # Higher limit for internal heartbeat
585
  async def receive_heartbeat(request: Request):
 
716
  return {"status": status, "checks": checks}
717
 
718
 
719
+ # =============================================================================
720
+ # CollaborationHub WebSocket Endpoints
721
+ # =============================================================================
722
+
723
+ @app.websocket("/ws/hub")
724
+ async def websocket_hub_endpoint(
725
+ websocket: WebSocket,
726
+ client_id: str = Query(..., description="Client identifier required"),
727
+ room: str = Query("system", description="Room to join: adam, eve, system, dialogue")
728
+ ) -> None:
729
+ """
730
+ CollaborationHub WebSocket endpoint with room-based messaging.
731
+
732
+ Rooms:
733
+ - adam: Adam agent messages and updates
734
+ - eve: Eve agent messages and updates
735
+ - system: System-wide notifications
736
+ - dialogue: Inter-agent dialogue simulation
737
+
738
+ Requires `client_id` and optionally `room` query parameters.
739
+ """
740
+ if not client_id:
741
+ await websocket.close(code=4008, reason="client_id query parameter required")
742
+ logger.warning("Hub connection rejected: missing client_id")
743
+ return
744
+
745
+ # Connect to the specified room
746
+ connected = await hub.connect(client_id, websocket, room)
747
+ if not connected:
748
+ await websocket.close(code=4009, reason=f"Failed to join room: {room}")
749
+ return
750
+
751
+ try:
752
+ while True:
753
+ # Receive and handle JSON message from client
754
+ try:
755
+ data = await websocket.receive_json()
756
+ msg_type = data.get("type", "unknown")
757
+
758
+ logger.info(
759
+ f"Hub message from {client_id} in room {room}: {msg_type}",
760
+ extra={"client_id": client_id, "room": room, "msg_type": msg_type}
761
+ )
762
+
763
+ # Handle different message types
764
+ if msg_type == "broadcast":
765
+ # Broadcast to all rooms
766
+ response = {
767
+ "type": "broadcast_result",
768
+ "from_client": client_id,
769
+ "from_room": room,
770
+ "data": data.get("data"),
771
+ "recipients": await hub.broadcast(data, exclude_room=None),
772
+ "timestamp": datetime.utcnow().isoformat()
773
+ }
774
+ await websocket.send_json(response)
775
+
776
+ elif msg_type == "room_message":
777
+ # Send to specific room
778
+ target_room = data.get("target_room", "system")
779
+ recipients = await hub.send_to_room(target_room, data.get("data", {}))
780
+ await websocket.send_json({
781
+ "type": "room_message_result",
782
+ "target_room": target_room,
783
+ "recipients": recipients,
784
+ "timestamp": datetime.utcnow().isoformat()
785
+ })
786
+
787
+ elif msg_type == "agent_dialogue":
788
+ # Simulate agent-to-agent dialogue
789
+ speaker = data.get("speaker", room)
790
+ listener = data.get("listener", "eve" if speaker == "adam" else "adam")
791
+ message = data.get("message", "")
792
+ await hub.simulate_agent_dialogue(speaker, listener, message)
793
+
794
+ elif msg_type == "status":
795
+ # Get room status
796
+ await websocket.send_json({
797
+ "type": "room_status",
798
+ "status": hub.get_room_status(),
799
+ "your_room": room,
800
+ "your_info": hub.get_client_info(client_id),
801
+ "timestamp": datetime.utcnow().isoformat()
802
+ })
803
+
804
+ else:
805
+ # Echo back acknowledgment
806
+ await websocket.send_json({
807
+ "type": "ack",
808
+ "client_id": client_id,
809
+ "room": room,
810
+ "received_type": msg_type,
811
+ "timestamp": datetime.utcnow().isoformat()
812
+ })
813
+
814
+ except json.JSONDecodeError as e:
815
+ logger.warning(
816
+ f"Invalid JSON from {client_id}: {e}",
817
+ extra={"client_id": client_id, "error": str(e)}
818
+ )
819
+ await websocket.send_json({"error": "invalid_json", "message": "Failed to parse JSON message"})
820
+
821
+ except WebSocketDisconnect:
822
+ logger.info(f"Hub WebSocket disconnected: {client_id}", extra={"client_id": client_id, "room": room})
823
+
824
+ except Exception as e:
825
+ logger.error(
826
+ f"Hub WebSocket error for {client_id}: {e}",
827
+ extra={"client_id": client_id, "room": room, "error": str(e)},
828
+ exc_info=True
829
+ )
830
+
831
+ finally:
832
+ # Always clean up connection
833
+ hub.disconnect(client_id)
834
+
835
+
836
  @app.websocket("/ws")
837
  async def websocket_endpoint(
838
  websocket: WebSocket,