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 20 kB

Add Token Streaming in AGS , Support Env variables (#5659) <!-- Thank you for your contribution! Please review https://microsoft.github.io/autogen/docs/Contribute before opening a pull request. --> This PR has 3 main improvements. - Token streaming - Adds support for environment variables in the app settings - Updates AGS to persist Gallery entry in db. ## Adds Token Streaming in AGS. Agentchat now supports streaming of tokens via `ModelClientStreamingChunkEvent `. This PR is to track progress on supporting that in the AutoGen Studio UI. If `model_client_stream` is enabled in an assitant agent, then token will be streamed in UI. ```python streaming_assistant = AssistantAgent( name="assistant", model_client=model_client, system_message="You are a helpful assistant.", model_client_stream=True, # Enable streaming tokens. ) ``` https://github.com/user-attachments/assets/74d43d78-6359-40c3-a78e-c84dcb5e02a1 ## Env Variables Also adds support for env variables in AGS Settings You can set env variables that are loaded just before a team is run. Handy to set variable to be used by tools etc. <img width="1291" alt="image" src="https://github.com/user-attachments/assets/437b9d90-ccee-42f7-be5d-94ab191afd67" /> > Note: the set variables are available to the server process. <!-- Please add a reviewer to the assignee section when you create a PR. If you don't have the access to it, we will shortly find a reviewer and assign them to your PR. --> ## Why are these changes needed? <!-- Please give a short summary of the change and the problem this solves. --> ## Related issue number <!-- For example: "Closes #1234" --> Closes #5627 Closes #5662 Closes #5619 ## Checks - [ ] I've included any doc changes needed for <https://microsoft.github.io/autogen/>. See <https://github.com/microsoft/autogen/blob/main/CONTRIBUTING.md> to build and test documentation locally. - [ ] I've added tests (if relevant) corresponding to the changes introduced in this PR. - [ ] I've made sure all auto checks have passed.
1 year ago
Add Token Streaming in AGS , Support Env variables (#5659) <!-- Thank you for your contribution! Please review https://microsoft.github.io/autogen/docs/Contribute before opening a pull request. --> This PR has 3 main improvements. - Token streaming - Adds support for environment variables in the app settings - Updates AGS to persist Gallery entry in db. ## Adds Token Streaming in AGS. Agentchat now supports streaming of tokens via `ModelClientStreamingChunkEvent `. This PR is to track progress on supporting that in the AutoGen Studio UI. If `model_client_stream` is enabled in an assitant agent, then token will be streamed in UI. ```python streaming_assistant = AssistantAgent( name="assistant", model_client=model_client, system_message="You are a helpful assistant.", model_client_stream=True, # Enable streaming tokens. ) ``` https://github.com/user-attachments/assets/74d43d78-6359-40c3-a78e-c84dcb5e02a1 ## Env Variables Also adds support for env variables in AGS Settings You can set env variables that are loaded just before a team is run. Handy to set variable to be used by tools etc. <img width="1291" alt="image" src="https://github.com/user-attachments/assets/437b9d90-ccee-42f7-be5d-94ab191afd67" /> > Note: the set variables are available to the server process. <!-- Please add a reviewer to the assignee section when you create a PR. If you don't have the access to it, we will shortly find a reviewer and assign them to your PR. --> ## Why are these changes needed? <!-- Please give a short summary of the change and the problem this solves. --> ## Related issue number <!-- For example: "Closes #1234" --> Closes #5627 Closes #5662 Closes #5619 ## Checks - [ ] I've included any doc changes needed for <https://microsoft.github.io/autogen/>. See <https://github.com/microsoft/autogen/blob/main/CONTRIBUTING.md> to build and test documentation locally. - [ ] I've added tests (if relevant) corresponding to the changes introduced in this PR. - [ ] I've made sure all auto checks have passed.
1 year ago
Add Token Streaming in AGS , Support Env variables (#5659) <!-- Thank you for your contribution! Please review https://microsoft.github.io/autogen/docs/Contribute before opening a pull request. --> This PR has 3 main improvements. - Token streaming - Adds support for environment variables in the app settings - Updates AGS to persist Gallery entry in db. ## Adds Token Streaming in AGS. Agentchat now supports streaming of tokens via `ModelClientStreamingChunkEvent `. This PR is to track progress on supporting that in the AutoGen Studio UI. If `model_client_stream` is enabled in an assitant agent, then token will be streamed in UI. ```python streaming_assistant = AssistantAgent( name="assistant", model_client=model_client, system_message="You are a helpful assistant.", model_client_stream=True, # Enable streaming tokens. ) ``` https://github.com/user-attachments/assets/74d43d78-6359-40c3-a78e-c84dcb5e02a1 ## Env Variables Also adds support for env variables in AGS Settings You can set env variables that are loaded just before a team is run. Handy to set variable to be used by tools etc. <img width="1291" alt="image" src="https://github.com/user-attachments/assets/437b9d90-ccee-42f7-be5d-94ab191afd67" /> > Note: the set variables are available to the server process. <!-- Please add a reviewer to the assignee section when you create a PR. If you don't have the access to it, we will shortly find a reviewer and assign them to your PR. --> ## Why are these changes needed? <!-- Please give a short summary of the change and the problem this solves. --> ## Related issue number <!-- For example: "Closes #1234" --> Closes #5627 Closes #5662 Closes #5619 ## Checks - [ ] I've included any doc changes needed for <https://microsoft.github.io/autogen/>. See <https://github.com/microsoft/autogen/blob/main/CONTRIBUTING.md> to build and test documentation locally. - [ ] I've added tests (if relevant) corresponding to the changes introduced in this PR. - [ ] I've made sure all auto checks have passed.
1 year ago
Enable LLM Call Observability in AGS (#5457) <!-- Thank you for your contribution! Please review https://microsoft.github.io/autogen/docs/Contribute before opening a pull request. --> <!-- Please add a reviewer to the assignee section when you create a PR. If you don't have the access to it, we will shortly find a reviewer and assign them to your PR. --> It is often helpful to inspect the raw request and response to/from an LLM as agents act. This PR, does the following: - Updates TeamManager to yield LLMCallEvents from core library - Run in an async background task to listen for LLMCallEvent JIT style when a team is run - Add events to an async queue and - yield those events in addition to whatever actual agentchat team.run_stream yields. - Update the AGS UI to show those LLMCallEvents in the messages section as a team runs - Add settings panel to show/hide llm call events in messages. - Minor updates to default team <img width="1539" alt="image" src="https://github.com/user-attachments/assets/bfbb19fe-3560-4faa-b600-7dd244e0e974" /> <img width="1554" alt="image" src="https://github.com/user-attachments/assets/775624f5-ba83-46e8-81ff-512bfeed6bab" /> <img width="1538" alt="image" src="https://github.com/user-attachments/assets/3becbf85-b75a-4506-adb7-e5575ebbaec4" /> ## Why are these changes needed? <!-- Please give a short summary of the change and the problem this solves. --> ## Related issue number <!-- For example: "Closes #1234" --> Closes #5440 ## Checks - [ ] I've included any doc changes needed for https://microsoft.github.io/autogen/. See https://microsoft.github.io/autogen/docs/Contribute#documentation to build and test documentation locally. - [ ] I've added tests (if relevant) corresponding to the changes introduced in this PR. - [ ] I've made sure all auto checks have passed.
1 year ago
Add Token Streaming in AGS , Support Env variables (#5659) <!-- Thank you for your contribution! Please review https://microsoft.github.io/autogen/docs/Contribute before opening a pull request. --> This PR has 3 main improvements. - Token streaming - Adds support for environment variables in the app settings - Updates AGS to persist Gallery entry in db. ## Adds Token Streaming in AGS. Agentchat now supports streaming of tokens via `ModelClientStreamingChunkEvent `. This PR is to track progress on supporting that in the AutoGen Studio UI. If `model_client_stream` is enabled in an assitant agent, then token will be streamed in UI. ```python streaming_assistant = AssistantAgent( name="assistant", model_client=model_client, system_message="You are a helpful assistant.", model_client_stream=True, # Enable streaming tokens. ) ``` https://github.com/user-attachments/assets/74d43d78-6359-40c3-a78e-c84dcb5e02a1 ## Env Variables Also adds support for env variables in AGS Settings You can set env variables that are loaded just before a team is run. Handy to set variable to be used by tools etc. <img width="1291" alt="image" src="https://github.com/user-attachments/assets/437b9d90-ccee-42f7-be5d-94ab191afd67" /> > Note: the set variables are available to the server process. <!-- Please add a reviewer to the assignee section when you create a PR. If you don't have the access to it, we will shortly find a reviewer and assign them to your PR. --> ## Why are these changes needed? <!-- Please give a short summary of the change and the problem this solves. --> ## Related issue number <!-- For example: "Closes #1234" --> Closes #5627 Closes #5662 Closes #5619 ## Checks - [ ] I've included any doc changes needed for <https://microsoft.github.io/autogen/>. See <https://github.com/microsoft/autogen/blob/main/CONTRIBUTING.md> to build and test documentation locally. - [ ] I've added tests (if relevant) corresponding to the changes introduced in this PR. - [ ] I've made sure all auto checks have passed.
1 year ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. import asyncio
  2. import logging
  3. import traceback
  4. from datetime import date, datetime, time, timezone
  5. from pathlib import Path
  6. from typing import Any, Callable, Dict, Optional, Sequence, Union
  7. from autogen_agentchat.base import TaskResult
  8. from autogen_agentchat.messages import (
  9. BaseAgentEvent,
  10. BaseChatMessage,
  11. ChatMessage,
  12. HandoffMessage,
  13. ModelClientStreamingChunkEvent,
  14. MultiModalMessage,
  15. StopMessage,
  16. TextMessage,
  17. ToolCallExecutionEvent,
  18. ToolCallRequestEvent,
  19. )
  20. from autogen_core import CancellationToken, ComponentModel
  21. from autogen_core import Image as AGImage
  22. from fastapi import WebSocket, WebSocketDisconnect
  23. from ...database import DatabaseManager
  24. from ...datamodel import (
  25. LLMCallEventMessage,
  26. Message,
  27. MessageConfig,
  28. Run,
  29. RunStatus,
  30. Settings,
  31. SettingsConfig,
  32. TeamResult,
  33. )
  34. from ...teammanager import TeamManager
  35. from .run_context import RunContext
  36. logger = logging.getLogger(__name__)
  37. class WebSocketManager:
  38. """Manages WebSocket connections and message streaming for team task execution"""
  39. def __init__(self, db_manager: DatabaseManager):
  40. self.db_manager = db_manager
  41. self._connections: Dict[int, WebSocket] = {}
  42. self._cancellation_tokens: Dict[int, CancellationToken] = {}
  43. # Track explicitly closed connections
  44. self._closed_connections: set[int] = set()
  45. self._input_responses: Dict[int, asyncio.Queue] = {}
  46. self._cancel_message = TeamResult(
  47. task_result=TaskResult(
  48. messages=[TextMessage(source="user", content="Run cancelled by user")], stop_reason="cancelled by user"
  49. ),
  50. usage="",
  51. duration=0,
  52. ).model_dump()
  53. def _get_stop_message(self, reason: str) -> dict:
  54. return TeamResult(
  55. task_result=TaskResult(messages=[TextMessage(source="user", content=reason)], stop_reason=reason),
  56. usage="",
  57. duration=0,
  58. ).model_dump()
  59. async def connect(self, websocket: WebSocket, run_id: int) -> bool:
  60. try:
  61. await websocket.accept()
  62. self._connections[run_id] = websocket
  63. self._closed_connections.discard(run_id)
  64. # Initialize input queue for this connection
  65. self._input_responses[run_id] = asyncio.Queue()
  66. await self._send_message(
  67. run_id, {"type": "system", "status": "connected", "timestamp": datetime.now(timezone.utc).isoformat()}
  68. )
  69. return True
  70. except Exception as e:
  71. logger.error(f"Connection error for run {run_id}: {e}")
  72. return False
  73. async def start_stream(
  74. self,
  75. run_id: int,
  76. task: str | ChatMessage | Sequence[ChatMessage] | None,
  77. team_config: str | Path | Dict[str, Any] | ComponentModel,
  78. ) -> None:
  79. """Start streaming task execution with proper run management"""
  80. if run_id not in self._connections or run_id in self._closed_connections:
  81. raise ValueError(f"No active connection for run {run_id}")
  82. with RunContext.populate_context(run_id=run_id):
  83. team_manager = TeamManager()
  84. cancellation_token = CancellationToken()
  85. self._cancellation_tokens[run_id] = cancellation_token
  86. final_result = None
  87. env_vars = None # Ensure env_vars is always defined
  88. try:
  89. # Update run with task and status
  90. run = await self._get_run(run_id)
  91. if run is not None and run.user_id:
  92. # get user Settings
  93. user_settings = await self._get_settings(run.user_id)
  94. env_vars = SettingsConfig(**user_settings.config).environment if user_settings else None # type: ignore
  95. run.task = self._convert_images_in_dict(MessageConfig(content=task, source="user").model_dump())
  96. run.status = RunStatus.ACTIVE
  97. self.db_manager.upsert(run)
  98. input_func = self.create_input_func(run_id)
  99. async for message in team_manager.run_stream(
  100. task=task,
  101. team_config=team_config,
  102. input_func=input_func,
  103. cancellation_token=cancellation_token,
  104. env_vars=env_vars,
  105. ):
  106. if cancellation_token.is_cancelled() or run_id in self._closed_connections:
  107. logger.info(f"Stream cancelled or connection closed for run {run_id}")
  108. break
  109. formatted_message = self._format_message(message)
  110. if formatted_message:
  111. await self._send_message(run_id, formatted_message)
  112. # Save messages by concrete type
  113. if isinstance(
  114. message,
  115. (
  116. TextMessage,
  117. MultiModalMessage,
  118. StopMessage,
  119. HandoffMessage,
  120. ToolCallRequestEvent,
  121. ToolCallExecutionEvent,
  122. LLMCallEventMessage,
  123. ),
  124. ):
  125. await self._save_message(run_id, message)
  126. # Capture final result if it's a TeamResult
  127. elif isinstance(message, TeamResult):
  128. final_result = message.model_dump()
  129. if not cancellation_token.is_cancelled() and run_id not in self._closed_connections:
  130. if final_result:
  131. await self._update_run(run_id, RunStatus.COMPLETE, team_result=final_result)
  132. else:
  133. logger.warning(f"No final result captured for completed run {run_id}")
  134. await self._update_run_status(run_id, RunStatus.COMPLETE)
  135. else:
  136. await self._send_message(
  137. run_id,
  138. {
  139. "type": "completion",
  140. "status": "cancelled",
  141. "data": self._cancel_message,
  142. "timestamp": datetime.now(timezone.utc).isoformat(),
  143. },
  144. )
  145. # Update run with cancellation result
  146. await self._update_run(run_id, RunStatus.STOPPED, team_result=self._cancel_message)
  147. except Exception as e:
  148. logger.error(f"Stream error for run {run_id}: {e}")
  149. traceback.print_exc()
  150. await self._handle_stream_error(run_id, e)
  151. finally:
  152. self._cancellation_tokens.pop(run_id, None)
  153. async def _save_message(
  154. self, run_id: int, message: Union[BaseAgentEvent | BaseChatMessage, BaseChatMessage]
  155. ) -> None:
  156. """Save a message to the database"""
  157. run = await self._get_run(run_id)
  158. if run:
  159. db_message = Message(
  160. session_id=run.session_id,
  161. run_id=run_id,
  162. config=self._convert_images_in_dict(message.model_dump()),
  163. user_id=None, # You might want to pass this from somewhere
  164. )
  165. self.db_manager.upsert(db_message)
  166. async def _update_run(
  167. self, run_id: int, status: RunStatus, team_result: Optional[dict] = None, error: Optional[str] = None
  168. ) -> None:
  169. """Update run status and result"""
  170. run = await self._get_run(run_id)
  171. if run:
  172. run.status = status
  173. if team_result:
  174. run.team_result = self._convert_images_in_dict(team_result)
  175. if error:
  176. run.error_message = error
  177. self.db_manager.upsert(run)
  178. def create_input_func(self, run_id: int) -> Callable:
  179. """Creates an input function for a specific run"""
  180. async def input_handler(prompt: str = "", cancellation_token: Optional[CancellationToken] = None) -> str:
  181. try:
  182. # Send input request to client
  183. await self._send_message(
  184. run_id,
  185. {
  186. "type": "input_request",
  187. "prompt": prompt,
  188. "data": {"source": "system", "content": prompt},
  189. "timestamp": datetime.now(timezone.utc).isoformat(),
  190. },
  191. )
  192. # Wait for response
  193. if run_id in self._input_responses:
  194. response = await self._input_responses[run_id].get()
  195. return response
  196. else:
  197. raise ValueError(f"No input queue for run {run_id}")
  198. except Exception as e:
  199. logger.error(f"Error handling input for run {run_id}: {e}")
  200. raise
  201. return input_handler
  202. async def handle_input_response(self, run_id: int, response: str) -> None:
  203. """Handle input response from client"""
  204. if run_id in self._input_responses:
  205. await self._input_responses[run_id].put(response)
  206. else:
  207. logger.warning(f"Received input response for inactive run {run_id}")
  208. async def stop_run(self, run_id: int, reason: str) -> None:
  209. if run_id in self._cancellation_tokens:
  210. logger.info(f"Stopping run {run_id}")
  211. stop_message = self._get_stop_message(reason)
  212. try:
  213. # Update run record first
  214. await self._update_run(run_id, status=RunStatus.STOPPED, team_result=stop_message)
  215. # Then handle websocket communication if connection is active
  216. if run_id in self._connections and run_id not in self._closed_connections:
  217. await self._send_message(
  218. run_id,
  219. {
  220. "type": "completion",
  221. "status": "cancelled",
  222. "data": stop_message,
  223. "timestamp": datetime.now(timezone.utc).isoformat(),
  224. },
  225. )
  226. # Finally cancel the token
  227. self._cancellation_tokens[run_id].cancel()
  228. except Exception as e:
  229. logger.error(f"Error stopping run {run_id}: {e}")
  230. # We might want to force disconnect here if db update failed
  231. # await self.disconnect(run_id) # Optional
  232. async def disconnect(self, run_id: int) -> None:
  233. """Clean up connection and associated resources"""
  234. logger.info(f"Disconnecting run {run_id}")
  235. # Mark as closed before cleanup to prevent any new messages
  236. self._closed_connections.add(run_id)
  237. # Cancel any running tasks
  238. await self.stop_run(run_id, "Connection closed")
  239. # Clean up resources
  240. self._connections.pop(run_id, None)
  241. self._cancellation_tokens.pop(run_id, None)
  242. self._input_responses.pop(run_id, None)
  243. def _convert_images_in_dict(self, obj: Any) -> Any:
  244. """Recursively find and convert Image and datetime objects in dictionaries and lists"""
  245. if isinstance(obj, dict):
  246. return {k: self._convert_images_in_dict(v) for k, v in obj.items()}
  247. elif isinstance(obj, list):
  248. return [self._convert_images_in_dict(item) for item in obj]
  249. elif isinstance(obj, AGImage):
  250. return {"type": "image", "url": f"data:image/png;base64,{obj.to_base64()}", "alt": "Image"}
  251. elif isinstance(obj, (datetime, date, time)):
  252. return obj.isoformat()
  253. else:
  254. return obj
  255. async def _send_message(self, run_id: int, message: dict) -> None:
  256. """Send a message through the WebSocket with connection state checking
  257. Args:
  258. run_id: id of the run
  259. message: Message dictionary to send
  260. """
  261. if run_id in self._closed_connections:
  262. logger.warning(f"Attempted to send message to closed connection for run {run_id}")
  263. return
  264. try:
  265. if run_id in self._connections:
  266. websocket = self._connections[run_id]
  267. await websocket.send_json(self._convert_images_in_dict(message))
  268. except WebSocketDisconnect:
  269. logger.warning(f"WebSocket disconnected while sending message for run {run_id}")
  270. await self.disconnect(run_id)
  271. except Exception as e:
  272. traceback.print_exc()
  273. logger.error(f"Error sending message for run {run_id}: {e}, {message}")
  274. # Don't try to send error message here to avoid potential recursive loop
  275. await self._update_run_status(run_id, RunStatus.ERROR, str(e))
  276. await self.disconnect(run_id)
  277. async def _handle_stream_error(self, run_id: int, error: Exception) -> None:
  278. """Handle stream errors with proper run updates"""
  279. if run_id not in self._closed_connections:
  280. error_result = TeamResult(
  281. task_result=TaskResult(
  282. messages=[TextMessage(source="system", content=str(error))],
  283. stop_reason="An error occurred while processing this run",
  284. ),
  285. usage="",
  286. duration=0,
  287. ).model_dump()
  288. await self._send_message(
  289. run_id,
  290. {
  291. "type": "completion",
  292. "status": "error",
  293. "data": error_result,
  294. "timestamp": datetime.now(timezone.utc).isoformat(),
  295. },
  296. )
  297. await self._update_run(run_id, RunStatus.ERROR, team_result=error_result, error=str(error))
  298. def _format_message(self, message: Any) -> Optional[dict]:
  299. """Format message for WebSocket transmission
  300. Args:
  301. message: Message to format
  302. Returns:
  303. Optional[dict]: Formatted message or None if formatting fails
  304. """
  305. try:
  306. if isinstance(message, MultiModalMessage):
  307. message_dump = message.model_dump()
  308. message_content = []
  309. for row in message_dump["content"]:
  310. if isinstance(row, dict) and "data" in row:
  311. message_content.append(
  312. {
  313. "url": f"data:image/png;base64,{row['data']}",
  314. "alt": "WebSurfer Screenshot",
  315. }
  316. )
  317. else:
  318. message_content.append(row)
  319. message_dump["content"] = message_content
  320. return {"type": "message", "data": message_dump}
  321. elif isinstance(message, TeamResult):
  322. return {
  323. "type": "result",
  324. "data": message.model_dump(),
  325. "status": "complete",
  326. }
  327. elif isinstance(message, ModelClientStreamingChunkEvent):
  328. return {"type": "message_chunk", "data": message.model_dump()}
  329. elif isinstance(
  330. message,
  331. (
  332. TextMessage,
  333. StopMessage,
  334. HandoffMessage,
  335. ToolCallRequestEvent,
  336. ToolCallExecutionEvent,
  337. LLMCallEventMessage,
  338. ),
  339. ):
  340. return {"type": "message", "data": message.model_dump()}
  341. return None
  342. except Exception as e:
  343. logger.error(f"Message formatting error: {e}")
  344. traceback.print_exc()
  345. return None
  346. async def _get_run(self, run_id: int) -> Optional[Run]:
  347. """Get run from database
  348. Args:
  349. run_id: id of the run to retrieve
  350. Returns:
  351. Optional[Run]: Run object if found, None otherwise
  352. """
  353. response = self.db_manager.get(Run, filters={"id": run_id}, return_json=False)
  354. return response.data[0] if response.status and response.data else None
  355. async def _get_settings(self, user_id: str) -> Optional[Settings]:
  356. """Get user settings from database
  357. Args:
  358. user_id: User ID to retrieve settings for
  359. Returns:
  360. Optional[dict]: User settings if found, None otherwise
  361. """
  362. response = self.db_manager.get(filters={"user_id": user_id}, model_class=Settings, return_json=False)
  363. return response.data[0] if response.status and response.data else None
  364. async def _update_run_status(self, run_id: int, status: RunStatus, error: Optional[str] = None) -> None:
  365. """Update run status in database
  366. Args:
  367. run_id: id of the run to update
  368. status: New status to set
  369. error: Optional error message
  370. """
  371. run = await self._get_run(run_id)
  372. if run:
  373. run.status = status
  374. run.error_message = error
  375. self.db_manager.upsert(run)
  376. async def cleanup(self) -> None:
  377. """Clean up all active connections and resources when server is shutting down"""
  378. logger.info(f"Cleaning up {len(self.active_connections)} active connections")
  379. try:
  380. # First cancel all running tasks
  381. for run_id in self.active_runs.copy():
  382. if run_id in self._cancellation_tokens:
  383. self._cancellation_tokens[run_id].cancel()
  384. run = await self._get_run(run_id)
  385. if run and run.status == RunStatus.ACTIVE:
  386. interrupted_result = TeamResult(
  387. task_result=TaskResult(
  388. messages=[TextMessage(source="system", content="Run interrupted by server shutdown")],
  389. stop_reason="server_shutdown",
  390. ),
  391. usage="",
  392. duration=0,
  393. ).model_dump()
  394. run.status = RunStatus.STOPPED
  395. run.team_result = interrupted_result
  396. self.db_manager.upsert(run)
  397. # Then disconnect all websockets with timeout
  398. # 10 second timeout for entire cleanup
  399. async with asyncio.timeout(10):
  400. for run_id in self.active_connections.copy():
  401. try:
  402. # Give each disconnect operation 2 seconds
  403. async with asyncio.timeout(2):
  404. await self.disconnect(run_id)
  405. except asyncio.TimeoutError:
  406. logger.warning(f"Timeout disconnecting run {run_id}")
  407. except Exception as e:
  408. logger.error(f"Error disconnecting run {run_id}: {e}")
  409. except asyncio.TimeoutError:
  410. logger.warning("WebSocketManager cleanup timed out")
  411. except Exception as e:
  412. logger.error(f"Error during WebSocketManager cleanup: {e}")
  413. finally:
  414. # Always clear internal state, even if cleanup had errors
  415. self._connections.clear()
  416. self._cancellation_tokens.clear()
  417. self._closed_connections.clear()
  418. self._input_responses.clear()
  419. @property
  420. def active_connections(self) -> set[int]:
  421. """Get set of active run IDs"""
  422. return set(self._connections.keys()) - self._closed_connections
  423. @property
  424. def active_runs(self) -> set[int]:
  425. """Get set of runs with active cancellation tokens"""
  426. return set(self._cancellation_tokens.keys())