You can not select more than 25 topics Topics must start with a chinese character,a letter or number, can include dashes ('-') and can be up to 35 characters long.

connection.py 17 kB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. import asyncio
  2. import logging
  3. from datetime import datetime, timezone
  4. from typing import Any, Callable, Dict, Optional, Union
  5. from uuid import UUID
  6. from autogen_agentchat.base._task import TaskResult
  7. from autogen_agentchat.messages import (
  8. AgentEvent,
  9. ChatMessage,
  10. HandoffMessage,
  11. MultiModalMessage,
  12. StopMessage,
  13. TextMessage,
  14. ToolCallExecutionEvent,
  15. ToolCallRequestEvent,
  16. )
  17. from autogen_core import CancellationToken
  18. from autogen_core import Image as AGImage
  19. from fastapi import WebSocket, WebSocketDisconnect
  20. from ...database import DatabaseManager
  21. from ...datamodel import Message, MessageConfig, Run, RunStatus, TeamResult
  22. from ...teammanager import TeamManager
  23. logger = logging.getLogger(__name__)
  24. class WebSocketManager:
  25. """Manages WebSocket connections and message streaming for team task execution"""
  26. def __init__(self, db_manager: DatabaseManager):
  27. self.db_manager = db_manager
  28. self._connections: Dict[UUID, WebSocket] = {}
  29. self._cancellation_tokens: Dict[UUID, CancellationToken] = {}
  30. # Track explicitly closed connections
  31. self._closed_connections: set[UUID] = set()
  32. self._input_responses: Dict[UUID, asyncio.Queue] = {}
  33. self._cancel_message = TeamResult(
  34. task_result=TaskResult(
  35. messages=[TextMessage(source="user", content="Run cancelled by user")], stop_reason="cancelled by user"
  36. ),
  37. usage="",
  38. duration=0,
  39. ).model_dump()
  40. def _get_stop_message(self, reason: str) -> dict:
  41. return TeamResult(
  42. task_result=TaskResult(messages=[TextMessage(source="user", content=reason)], stop_reason=reason),
  43. usage="",
  44. duration=0,
  45. ).model_dump()
  46. async def connect(self, websocket: WebSocket, run_id: UUID) -> bool:
  47. try:
  48. await websocket.accept()
  49. self._connections[run_id] = websocket
  50. self._closed_connections.discard(run_id)
  51. # Initialize input queue for this connection
  52. self._input_responses[run_id] = asyncio.Queue()
  53. await self._send_message(
  54. run_id, {"type": "system", "status": "connected", "timestamp": datetime.now(timezone.utc).isoformat()}
  55. )
  56. return True
  57. except Exception as e:
  58. logger.error(f"Connection error for run {run_id}: {e}")
  59. return False
  60. async def start_stream(self, run_id: UUID, task: str, team_config: dict) -> None:
  61. """Start streaming task execution with proper run management"""
  62. if run_id not in self._connections or run_id in self._closed_connections:
  63. raise ValueError(f"No active connection for run {run_id}")
  64. team_manager = TeamManager()
  65. cancellation_token = CancellationToken()
  66. self._cancellation_tokens[run_id] = cancellation_token
  67. final_result = None
  68. try:
  69. # Update run with task and status
  70. run = await self._get_run(run_id)
  71. if run:
  72. run.task = MessageConfig(content=task, source="user").model_dump()
  73. run.status = RunStatus.ACTIVE
  74. self.db_manager.upsert(run)
  75. input_func = self.create_input_func(run_id)
  76. async for message in team_manager.run_stream(
  77. task=task, team_config=team_config, input_func=input_func, cancellation_token=cancellation_token
  78. ):
  79. if cancellation_token.is_cancelled() or run_id in self._closed_connections:
  80. logger.info(f"Stream cancelled or connection closed for run {run_id}")
  81. break
  82. formatted_message = self._format_message(message)
  83. if formatted_message:
  84. await self._send_message(run_id, formatted_message)
  85. # Save messages by concrete type
  86. if isinstance(
  87. message,
  88. (
  89. TextMessage,
  90. MultiModalMessage,
  91. StopMessage,
  92. HandoffMessage,
  93. ToolCallRequestEvent,
  94. ToolCallExecutionEvent,
  95. ),
  96. ):
  97. await self._save_message(run_id, message)
  98. # Capture final result if it's a TeamResult
  99. elif isinstance(message, TeamResult):
  100. final_result = message.model_dump()
  101. if not cancellation_token.is_cancelled() and run_id not in self._closed_connections:
  102. if final_result:
  103. await self._update_run(run_id, RunStatus.COMPLETE, team_result=final_result)
  104. else:
  105. logger.warning(f"No final result captured for completed run {run_id}")
  106. await self._update_run_status(run_id, RunStatus.COMPLETE)
  107. else:
  108. await self._send_message(
  109. run_id,
  110. {
  111. "type": "completion",
  112. "status": "cancelled",
  113. "data": self._cancel_message,
  114. "timestamp": datetime.now(timezone.utc).isoformat(),
  115. },
  116. )
  117. # Update run with cancellation result
  118. await self._update_run(run_id, RunStatus.STOPPED, team_result=self._cancel_message)
  119. except Exception as e:
  120. logger.error(f"Stream error for run {run_id}: {e}")
  121. await self._handle_stream_error(run_id, e)
  122. finally:
  123. self._cancellation_tokens.pop(run_id, None)
  124. async def _save_message(self, run_id: UUID, message: Union[AgentEvent | ChatMessage, ChatMessage]) -> None:
  125. """Save a message to the database"""
  126. run = await self._get_run(run_id)
  127. if run:
  128. db_message = Message(
  129. session_id=run.session_id,
  130. run_id=run_id,
  131. config=message.model_dump(),
  132. user_id=None, # You might want to pass this from somewhere
  133. )
  134. self.db_manager.upsert(db_message)
  135. async def _update_run(
  136. self, run_id: UUID, status: RunStatus, team_result: Optional[dict] = None, error: Optional[str] = None
  137. ) -> None:
  138. """Update run status and result"""
  139. run = await self._get_run(run_id)
  140. if run:
  141. run.status = status
  142. if team_result:
  143. run.team_result = team_result
  144. if error:
  145. run.error_message = error
  146. self.db_manager.upsert(run)
  147. def create_input_func(self, run_id: UUID) -> Callable:
  148. """Creates an input function for a specific run"""
  149. async def input_handler(prompt: str = "", cancellation_token: Optional[CancellationToken] = None) -> str:
  150. try:
  151. # Send input request to client
  152. await self._send_message(
  153. run_id,
  154. {
  155. "type": "input_request",
  156. "prompt": prompt,
  157. "data": {"source": "system", "content": prompt},
  158. "timestamp": datetime.now(timezone.utc).isoformat(),
  159. },
  160. )
  161. # Wait for response
  162. if run_id in self._input_responses:
  163. response = await self._input_responses[run_id].get()
  164. return response
  165. else:
  166. raise ValueError(f"No input queue for run {run_id}")
  167. except Exception as e:
  168. logger.error(f"Error handling input for run {run_id}: {e}")
  169. raise
  170. return input_handler
  171. async def handle_input_response(self, run_id: UUID, response: str) -> None:
  172. """Handle input response from client"""
  173. if run_id in self._input_responses:
  174. await self._input_responses[run_id].put(response)
  175. else:
  176. logger.warning(f"Received input response for inactive run {run_id}")
  177. async def stop_run(self, run_id: UUID, reason: str) -> None:
  178. if run_id in self._cancellation_tokens:
  179. logger.info(f"Stopping run {run_id}")
  180. stop_message = self._get_stop_message(reason)
  181. try:
  182. # Update run record first
  183. await self._update_run(run_id, status=RunStatus.STOPPED, team_result=stop_message)
  184. # Then handle websocket communication if connection is active
  185. if run_id in self._connections and run_id not in self._closed_connections:
  186. await self._send_message(
  187. run_id,
  188. {
  189. "type": "completion",
  190. "status": "cancelled",
  191. "data": stop_message,
  192. "timestamp": datetime.now(timezone.utc).isoformat(),
  193. },
  194. )
  195. # Finally cancel the token
  196. self._cancellation_tokens[run_id].cancel()
  197. except Exception as e:
  198. logger.error(f"Error stopping run {run_id}: {e}")
  199. # We might want to force disconnect here if db update failed
  200. # await self.disconnect(run_id) # Optional
  201. async def disconnect(self, run_id: UUID) -> None:
  202. """Clean up connection and associated resources"""
  203. logger.info(f"Disconnecting run {run_id}")
  204. # Mark as closed before cleanup to prevent any new messages
  205. self._closed_connections.add(run_id)
  206. # Cancel any running tasks
  207. await self.stop_run(run_id, "Connection closed")
  208. # Clean up resources
  209. self._connections.pop(run_id, None)
  210. self._cancellation_tokens.pop(run_id, None)
  211. self._input_responses.pop(run_id, None)
  212. async def _send_message(self, run_id: UUID, message: dict) -> None:
  213. """Send a message through the WebSocket with connection state checking
  214. Args:
  215. run_id: UUID of the run
  216. message: Message dictionary to send
  217. """
  218. if run_id in self._closed_connections:
  219. logger.warning(f"Attempted to send message to closed connection for run {run_id}")
  220. return
  221. try:
  222. if run_id in self._connections:
  223. websocket = self._connections[run_id]
  224. await websocket.send_json(message)
  225. except WebSocketDisconnect:
  226. logger.warning(f"WebSocket disconnected while sending message for run {run_id}")
  227. await self.disconnect(run_id)
  228. except Exception as e:
  229. logger.error(f"Error sending message for run {run_id}: {e}, {message}")
  230. # Don't try to send error message here to avoid potential recursive loop
  231. await self._update_run_status(run_id, RunStatus.ERROR, str(e))
  232. await self.disconnect(run_id)
  233. async def _handle_stream_error(self, run_id: UUID, error: Exception) -> None:
  234. """Handle stream errors with proper run updates"""
  235. if run_id not in self._closed_connections:
  236. error_result = TeamResult(
  237. task_result=TaskResult(
  238. messages=[TextMessage(source="system", content=str(error))],
  239. stop_reason="An error occurred while processing this run",
  240. ),
  241. usage="",
  242. duration=0,
  243. ).model_dump()
  244. await self._send_message(
  245. run_id,
  246. {
  247. "type": "completion",
  248. "status": "error",
  249. "data": error_result,
  250. "timestamp": datetime.now(timezone.utc).isoformat(),
  251. },
  252. )
  253. await self._update_run(run_id, RunStatus.ERROR, team_result=error_result, error=str(error))
  254. def _format_message(self, message: Any) -> Optional[dict]:
  255. """Format message for WebSocket transmission
  256. Args:
  257. message: Message to format
  258. Returns:
  259. Optional[dict]: Formatted message or None if formatting fails
  260. """
  261. try:
  262. if isinstance(message, MultiModalMessage):
  263. message_dump = message.model_dump()
  264. message_dump["content"] = [
  265. message_dump["content"][0],
  266. {
  267. "url": f"data:image/png;base64,{message_dump['content'][1]['data']}",
  268. "alt": "WebSurfer Screenshot",
  269. },
  270. ]
  271. return {"type": "message", "data": message_dump}
  272. elif isinstance(message, TeamResult):
  273. return {
  274. "type": "result",
  275. "data": message.model_dump(),
  276. "status": "complete",
  277. }
  278. elif isinstance(
  279. message, (TextMessage, StopMessage, HandoffMessage, ToolCallRequestEvent, ToolCallExecutionEvent)
  280. ):
  281. return {"type": "message", "data": message.model_dump()}
  282. return None
  283. except Exception as e:
  284. logger.error(f"Message formatting error: {e}")
  285. return None
  286. async def _get_run(self, run_id: UUID) -> Optional[Run]:
  287. """Get run from database
  288. Args:
  289. run_id: UUID of the run to retrieve
  290. Returns:
  291. Optional[Run]: Run object if found, None otherwise
  292. """
  293. response = self.db_manager.get(Run, filters={"id": run_id}, return_json=False)
  294. return response.data[0] if response.status and response.data else None
  295. async def _update_run_status(self, run_id: UUID, status: RunStatus, error: Optional[str] = None) -> None:
  296. """Update run status in database
  297. Args:
  298. run_id: UUID of the run to update
  299. status: New status to set
  300. error: Optional error message
  301. """
  302. run = await self._get_run(run_id)
  303. if run:
  304. run.status = status
  305. run.error_message = error
  306. self.db_manager.upsert(run)
  307. async def cleanup(self) -> None:
  308. """Clean up all active connections and resources when server is shutting down"""
  309. logger.info(f"Cleaning up {len(self.active_connections)} active connections")
  310. try:
  311. # First cancel all running tasks
  312. for run_id in self.active_runs.copy():
  313. if run_id in self._cancellation_tokens:
  314. self._cancellation_tokens[run_id].cancel()
  315. run = await self._get_run(run_id)
  316. if run and run.status == RunStatus.ACTIVE:
  317. interrupted_result = TeamResult(
  318. task_result=TaskResult(
  319. messages=[TextMessage(source="system", content="Run interrupted by server shutdown")],
  320. stop_reason="server_shutdown",
  321. ),
  322. usage="",
  323. duration=0,
  324. ).model_dump()
  325. run.status = RunStatus.STOPPED
  326. run.team_result = interrupted_result
  327. self.db_manager.upsert(run)
  328. # Then disconnect all websockets with timeout
  329. # 10 second timeout for entire cleanup
  330. async with asyncio.timeout(10):
  331. for run_id in self.active_connections.copy():
  332. try:
  333. # Give each disconnect operation 2 seconds
  334. async with asyncio.timeout(2):
  335. await self.disconnect(run_id)
  336. except asyncio.TimeoutError:
  337. logger.warning(f"Timeout disconnecting run {run_id}")
  338. except Exception as e:
  339. logger.error(f"Error disconnecting run {run_id}: {e}")
  340. except asyncio.TimeoutError:
  341. logger.warning("WebSocketManager cleanup timed out")
  342. except Exception as e:
  343. logger.error(f"Error during WebSocketManager cleanup: {e}")
  344. finally:
  345. # Always clear internal state, even if cleanup had errors
  346. self._connections.clear()
  347. self._cancellation_tokens.clear()
  348. self._closed_connections.clear()
  349. self._input_responses.clear()
  350. @property
  351. def active_connections(self) -> set[UUID]:
  352. """Get set of active run IDs"""
  353. return set(self._connections.keys()) - self._closed_connections
  354. @property
  355. def active_runs(self) -> set[UUID]:
  356. """Get set of runs with active cancellation tokens"""
  357. return set(self._cancellation_tokens.keys())