waldiez 0.5.8__py3-none-any.whl → 0.5.10__py3-none-any.whl

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.

Potentially problematic release.


This version of waldiez might be problematic. Click here for more details.

Files changed (88) hide show
  1. waldiez/_version.py +1 -1
  2. waldiez/cli.py +112 -24
  3. waldiez/exporting/agent/exporter.py +3 -0
  4. waldiez/exporting/agent/extras/captain_agent_extras.py +44 -7
  5. waldiez/exporting/agent/extras/handoffs/condition.py +3 -1
  6. waldiez/exporting/chats/utils/common.py +25 -23
  7. waldiez/exporting/core/__init__.py +0 -2
  8. waldiez/exporting/core/context.py +13 -13
  9. waldiez/exporting/core/protocols.py +0 -141
  10. waldiez/exporting/core/result.py +5 -5
  11. waldiez/exporting/flow/merger.py +2 -2
  12. waldiez/exporting/flow/orchestrator.py +1 -0
  13. waldiez/exporting/flow/utils/common.py +2 -2
  14. waldiez/exporting/flow/utils/importing.py +1 -0
  15. waldiez/exporting/flow/utils/logging.py +6 -7
  16. waldiez/exporting/tools/exporter.py +5 -0
  17. waldiez/exporting/tools/factory.py +4 -0
  18. waldiez/exporting/tools/processor.py +5 -1
  19. waldiez/io/_ws.py +13 -5
  20. waldiez/io/models/content/image.py +1 -0
  21. waldiez/io/models/user_input.py +4 -4
  22. waldiez/io/models/user_response.py +1 -0
  23. waldiez/io/mqtt.py +1 -1
  24. waldiez/io/structured.py +17 -17
  25. waldiez/io/utils.py +1 -1
  26. waldiez/io/ws.py +9 -11
  27. waldiez/logger.py +180 -63
  28. waldiez/models/agents/agent/update_system_message.py +0 -2
  29. waldiez/models/agents/doc_agent/doc_agent.py +8 -1
  30. waldiez/models/common/dict_utils.py +169 -40
  31. waldiez/models/flow/flow.py +6 -6
  32. waldiez/models/flow/info.py +5 -1
  33. waldiez/models/model/_llm.py +28 -14
  34. waldiez/models/model/model.py +4 -1
  35. waldiez/models/model/model_data.py +18 -5
  36. waldiez/models/tool/predefined/_config.py +5 -1
  37. waldiez/models/tool/predefined/_duckduckgo.py +4 -0
  38. waldiez/models/tool/predefined/_email.py +474 -0
  39. waldiez/models/tool/predefined/_google.py +8 -6
  40. waldiez/models/tool/predefined/_perplexity.py +3 -0
  41. waldiez/models/tool/predefined/_searxng.py +3 -0
  42. waldiez/models/tool/predefined/_tavily.py +4 -1
  43. waldiez/models/tool/predefined/_wikipedia.py +4 -1
  44. waldiez/models/tool/predefined/_youtube.py +4 -1
  45. waldiez/models/tool/predefined/protocol.py +3 -0
  46. waldiez/models/tool/tool.py +22 -4
  47. waldiez/models/waldiez.py +12 -0
  48. waldiez/runner.py +37 -54
  49. waldiez/running/__init__.py +6 -0
  50. waldiez/running/base_runner.py +310 -353
  51. waldiez/running/environment.py +1 -0
  52. waldiez/running/exceptions.py +9 -0
  53. waldiez/running/post_run.py +4 -4
  54. waldiez/running/pre_run.py +51 -40
  55. waldiez/running/protocol.py +21 -101
  56. waldiez/running/run_results.py +1 -1
  57. waldiez/running/standard_runner.py +84 -277
  58. waldiez/running/step_by_step/__init__.py +46 -0
  59. waldiez/running/step_by_step/breakpoints_mixin.py +188 -0
  60. waldiez/running/step_by_step/step_by_step_models.py +224 -0
  61. waldiez/running/step_by_step/step_by_step_runner.py +745 -0
  62. waldiez/running/subprocess_runner/__base__.py +282 -0
  63. waldiez/running/subprocess_runner/__init__.py +16 -0
  64. waldiez/running/subprocess_runner/_async_runner.py +362 -0
  65. waldiez/running/subprocess_runner/_sync_runner.py +455 -0
  66. waldiez/running/subprocess_runner/runner.py +561 -0
  67. waldiez/running/timeline_processor.py +1 -1
  68. waldiez/running/utils.py +376 -1
  69. waldiez/utils/version.py +2 -6
  70. waldiez/ws/__init__.py +70 -0
  71. waldiez/ws/__main__.py +15 -0
  72. waldiez/ws/_file_handler.py +201 -0
  73. waldiez/ws/cli.py +211 -0
  74. waldiez/ws/client_manager.py +835 -0
  75. waldiez/ws/errors.py +416 -0
  76. waldiez/ws/models.py +971 -0
  77. waldiez/ws/reloader.py +342 -0
  78. waldiez/ws/server.py +469 -0
  79. waldiez/ws/session_manager.py +393 -0
  80. waldiez/ws/session_stats.py +83 -0
  81. waldiez/ws/utils.py +385 -0
  82. {waldiez-0.5.8.dist-info → waldiez-0.5.10.dist-info}/METADATA +74 -74
  83. {waldiez-0.5.8.dist-info → waldiez-0.5.10.dist-info}/RECORD +87 -65
  84. waldiez/running/patch_io_stream.py +0 -210
  85. {waldiez-0.5.8.dist-info → waldiez-0.5.10.dist-info}/WHEEL +0 -0
  86. {waldiez-0.5.8.dist-info → waldiez-0.5.10.dist-info}/entry_points.txt +0 -0
  87. {waldiez-0.5.8.dist-info → waldiez-0.5.10.dist-info}/licenses/LICENSE +0 -0
  88. {waldiez-0.5.8.dist-info → waldiez-0.5.10.dist-info}/licenses/NOTICE.md +0 -0
waldiez/ws/server.py ADDED
@@ -0,0 +1,469 @@
1
+ # SPDX-License-Identifier: Apache-2.0.
2
+ # Copyright (c) 2024 - 2025 Waldiez and contributors.
3
+ """WebSocket server implementation for Waldiez."""
4
+
5
+ import asyncio
6
+ import logging
7
+ import re
8
+ import signal
9
+ import time
10
+ import traceback
11
+ import uuid
12
+ from pathlib import Path
13
+ from typing import Any, Sequence
14
+
15
+ import websockets
16
+ from websockets.exceptions import ConnectionClosed, WebSocketException
17
+
18
+ from .client_manager import ClientManager
19
+ from .errors import ErrorHandler, MessageParsingError, ServerOverloadError
20
+ from .models import ConnectionNotification
21
+ from .reloader import create_file_watcher
22
+ from .session_manager import SessionManager
23
+ from .utils import get_available_port, is_port_available
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ CWD = Path.cwd()
28
+
29
+
30
+ # pylint: disable=too-many-instance-attributes
31
+ class WaldiezWsServer:
32
+ """WebSocket server for Waldiez."""
33
+
34
+ def __init__(
35
+ self,
36
+ host: str = "localhost",
37
+ port: int = 8765,
38
+ workspace_dir: Path = CWD,
39
+ max_clients: int = 1,
40
+ allowed_origins: Sequence[re.Pattern[str]] | None = None,
41
+ **kwargs: Any,
42
+ ):
43
+ """Initialize WebSocket server.
44
+
45
+ Parameters
46
+ ----------
47
+ host : str
48
+ Server host address
49
+ port : int
50
+ Server port
51
+ workspace_dir : Path
52
+ Path to the workspace directory
53
+ max_clients : int
54
+ Maximum number of concurrent clients (default: 1)
55
+ allowed_origins : list[str] | None
56
+ List of allowed origins for CORS (default: None)
57
+ ping_interval : float | None
58
+ Ping interval in seconds
59
+ ping_timeout : float | None
60
+ Ping timeout in seconds
61
+ close_timeout : float | None
62
+ Close timeout in seconds
63
+ max_size : int | None
64
+ Maximum message size in bytes
65
+ max_queue : int | None
66
+ Maximum queue size
67
+ write_limit : int
68
+ Write buffer limit
69
+ """
70
+ self.host = host
71
+ self.port = port
72
+ self.workspace_dir = workspace_dir
73
+ self.max_clients = max_clients
74
+ self.allowed_origins = allowed_origins
75
+
76
+ # WebSocket configuration
77
+ self.ping_interval = kwargs.get("ping_interval", 20.0)
78
+ self.ping_timeout = kwargs.get("ping_timeout", 20.0)
79
+ self.close_timeout = kwargs.get("close_timeout", 10.0)
80
+ self.max_size = kwargs.get("max_size", 2**23) # 8MB
81
+ self.max_queue = kwargs.get("max_queue", 32)
82
+ self.write_limit = kwargs.get("write_limit", 2**16) # 64KB
83
+
84
+ # Server state
85
+ self.server: websockets.Server | None = None
86
+ self.session_manager = SessionManager()
87
+ self.clients: dict[str, ClientManager] = {}
88
+ self.is_running = False
89
+ self.start_time = 0.0
90
+ self.error_handler = ErrorHandler()
91
+
92
+ # Shutdown event
93
+ self.shutdown_event = asyncio.Event()
94
+
95
+ # Statistics
96
+ self.stats = {
97
+ "connections_total": 0,
98
+ "connections_active": 0,
99
+ "messages_received": 0,
100
+ "messages_sent": 0,
101
+ }
102
+
103
+ # pylint: disable=too-complex,too-many-branches,too-many-statements
104
+ async def _handle_client( # noqa: C901
105
+ self,
106
+ websocket: websockets.ServerConnection,
107
+ ) -> None:
108
+ """Handle individual client connections.
109
+
110
+ Parameters
111
+ ----------
112
+ websocket : websockets.WebSocketServerProtocol
113
+ WebSocket connection
114
+ """
115
+ client_id = str(uuid.uuid4())
116
+
117
+ # Check client limit
118
+ if len(self.clients) >= self.max_clients:
119
+ logger.warning(
120
+ "Client limit exceeded (%d/%d), rejecting connection from %s",
121
+ len(self.clients),
122
+ self.max_clients,
123
+ websocket.remote_address,
124
+ )
125
+ error = ServerOverloadError(len(self.clients), self.max_clients)
126
+ await websocket.close(code=error.error_code, reason=error.message)
127
+ return
128
+
129
+ # Create client handler
130
+ client_manager = ClientManager(
131
+ websocket,
132
+ client_id,
133
+ self.session_manager,
134
+ workspace_dir=self.workspace_dir,
135
+ error_handler=self.error_handler,
136
+ )
137
+ self.clients[client_id] = client_manager
138
+ self.stats["connections_total"] += 1
139
+ self.stats["connections_active"] = len(self.clients)
140
+ # pylint: disable=too-many-try-statements,broad-exception-caught
141
+ try:
142
+ await client_manager.send_message(
143
+ ConnectionNotification(
144
+ status="connected",
145
+ client_id=client_id,
146
+ server_time=time.time(),
147
+ ).model_dump(mode="json", fallback=str)
148
+ )
149
+
150
+ # Message handling loop
151
+ async for raw_message in websocket:
152
+ try:
153
+ # Parse message
154
+ if isinstance(raw_message, bytes):
155
+ message_str = raw_message.decode("utf-8")
156
+ else:
157
+ # noinspection PyUnreachableCode
158
+ message_str = (
159
+ raw_message
160
+ if isinstance(raw_message, str)
161
+ else str(raw_message)
162
+ )
163
+ response = await client_manager.handle_message(message_str)
164
+ self.stats["messages_received"] += 1
165
+
166
+ # Send response if available
167
+ if response: # pragma: no branch
168
+ success = await client_manager.send_message(response)
169
+ if success:
170
+ self.stats["messages_sent"] += 1
171
+ else:
172
+ self.error_handler.record_send_failure(client_id)
173
+
174
+ except ValueError as e:
175
+ logger.warning("Invalid message from %s: %s", client_id, e)
176
+ error_response = self.error_handler.handle_error(
177
+ MessageParsingError(str(e), str(raw_message)),
178
+ client_id=client_id,
179
+ )
180
+ await client_manager.send_message(error_response)
181
+
182
+ except Exception as e:
183
+ traceback.print_exc()
184
+ logger.error(
185
+ "Error handling message from %s: %s", client_id, e
186
+ )
187
+ error_response = self.error_handler.handle_error(
188
+ e, client_id=client_id
189
+ )
190
+ await client_manager.send_message(error_response)
191
+
192
+ except ConnectionClosed:
193
+ logger.info("Client %s disconnected normally", client_id)
194
+
195
+ except WebSocketException as e:
196
+ logger.warning("WebSocket error for client %s: %s", client_id, e)
197
+ self.error_handler.record_operational_error(
198
+ "WebSocketException", str(e)
199
+ )
200
+
201
+ except Exception as e:
202
+ logger.error(
203
+ "Unexpected error handling client %s: %s", client_id, e
204
+ )
205
+ self.error_handler.record_operational_error(
206
+ "UnexpectedError", str(e)
207
+ )
208
+ raise
209
+
210
+ finally:
211
+ # Clean up client
212
+ if client_id in self.clients: # pragma: no branch
213
+ self.clients[client_id].close_connection()
214
+ del self.clients[client_id]
215
+ self.stats["connections_active"] = len(self.clients)
216
+
217
+ async def start(self) -> None:
218
+ """Start the WebSocket server.
219
+
220
+ Raises
221
+ ------
222
+ RuntimeError
223
+ If the port is already in use
224
+ Exception
225
+ For any other errors
226
+ """
227
+ if self.is_running:
228
+ logger.warning("Server is already running")
229
+ return
230
+
231
+ await self.session_manager.start()
232
+ # Check port availability
233
+ if not is_port_available(self.port):
234
+ logger.warning("Port %d is not available", self.port)
235
+ self.port = get_available_port()
236
+ logger.info("Using port %d", self.port)
237
+
238
+ logger.info("Starting WebSocket server on %s:%d", self.host, self.port)
239
+ # pylint: disable=too-many-try-statements,broad-exception-caught
240
+ try:
241
+ # Create server
242
+ self.server = await websockets.serve(
243
+ self._handle_client,
244
+ self.host,
245
+ self.port,
246
+ ping_interval=self.ping_interval,
247
+ ping_timeout=self.ping_timeout,
248
+ close_timeout=self.close_timeout,
249
+ max_size=self.max_size,
250
+ max_queue=self.max_queue,
251
+ write_limit=self.write_limit,
252
+ origins=self.allowed_origins,
253
+ # Additional settings
254
+ compression=None, # Disable compression for lower latency
255
+ logger=logger,
256
+ server_header="Waldiez/ws",
257
+ )
258
+
259
+ self.is_running = True
260
+ self.start_time = time.time()
261
+
262
+ logger.info("WebSocket server started successfully")
263
+ logger.info("Server configuration:")
264
+ logger.info(" - Host: %s", self.host)
265
+ logger.info(" - Port: %d", self.port)
266
+ logger.info(" - Max clients: %d", self.max_clients)
267
+ logger.info(" - Ping interval: %s", self.ping_interval)
268
+ logger.info(" - Max message size: %s", self.max_size)
269
+
270
+ # Wait for shutdown
271
+ await self.shutdown_event.wait()
272
+
273
+ except Exception as e:
274
+ logger.error("Failed to start server: %s", e)
275
+ raise
276
+
277
+ finally:
278
+ await self.stop()
279
+
280
+ async def stop(self) -> None:
281
+ """Stop the WebSocket server."""
282
+ await self.session_manager.stop()
283
+ if not self.is_running:
284
+ logger.warning("Server is not running")
285
+ return
286
+
287
+ logger.info("Stopping WebSocket server...")
288
+
289
+ # Close all client connections
290
+ if self.clients:
291
+ logger.info(
292
+ "Closing %d active client connections", len(self.clients)
293
+ )
294
+ close_tasks: list[Any] = []
295
+ for client in self.clients.values():
296
+ if client.is_active: # pragma: no branch
297
+ close_tasks.append(client.websocket.close())
298
+
299
+ if close_tasks: # pragma: no branch
300
+ await asyncio.gather(*close_tasks, return_exceptions=True)
301
+
302
+ # Stop server
303
+ if self.server:
304
+ self.server.close()
305
+ await self.server.wait_closed()
306
+
307
+ self.is_running = False
308
+ self.clients.clear()
309
+ self.stats["connections_active"] = 0
310
+
311
+ uptime = time.time() - self.start_time
312
+ logger.info("WebSocket server stopped (uptime: %.1f seconds)", uptime)
313
+
314
+ def shutdown(self) -> None:
315
+ """Trigger server shutdown."""
316
+ self.shutdown_event.set()
317
+
318
+ def get_stats(self) -> dict[str, Any]:
319
+ """Get server statistics.
320
+
321
+ Returns
322
+ -------
323
+ dict[str, Any]
324
+ Server statistics
325
+ """
326
+ uptime = time.time() - self.start_time if self.is_running else 0
327
+ return {
328
+ **self.stats,
329
+ "uptime_seconds": uptime,
330
+ "is_running": self.is_running,
331
+ "server_config": {
332
+ "host": self.host,
333
+ "port": self.port,
334
+ "max_clients": self.max_clients,
335
+ "ping_interval": self.ping_interval,
336
+ "max_size": self.max_size,
337
+ },
338
+ "error_stats": self.error_handler.get_error_stats(),
339
+ }
340
+
341
+ async def broadcast(
342
+ self, message: dict[str, Any], exclude_client: str | None = None
343
+ ) -> int:
344
+ """Broadcast message to all connected clients.
345
+
346
+ Parameters
347
+ ----------
348
+ message : Dict[str, Any]
349
+ Message to broadcast
350
+ exclude_client : Optional[str]
351
+ Client ID to exclude from broadcast
352
+
353
+ Returns
354
+ -------
355
+ int
356
+ Number of clients that received the message
357
+ """
358
+ if not self.clients:
359
+ return 0
360
+
361
+ send_tasks: list[Any] = []
362
+ for client_id, client in self.clients.items():
363
+ if client_id != exclude_client and client.is_active:
364
+ send_tasks.append(client.send_message(message))
365
+
366
+ if not send_tasks: # pragma: no cover
367
+ return 0
368
+
369
+ results = await asyncio.gather(*send_tasks, return_exceptions=True)
370
+ successful = sum(1 for result in results if result is True)
371
+
372
+ self.stats["messages_sent"] += successful
373
+ return successful
374
+
375
+
376
+ async def run_server(
377
+ host: str = "localhost",
378
+ port: int = 8765,
379
+ workspace_dir: Path = CWD,
380
+ auto_reload: bool = False,
381
+ watch_dirs: set[Path] | None = None,
382
+ **server_kwargs: Any,
383
+ ) -> None:
384
+ """Run the WebSocket server with optional auto-reload.
385
+
386
+ Parameters
387
+ ----------
388
+ host : str
389
+ Server host
390
+ port : int
391
+ Server port
392
+ workspace_dir : Path
393
+ Path to the workspace directory
394
+ auto_reload : bool
395
+ Enable auto-reload on file changes
396
+ watch_dirs : Optional[Set[Path]]
397
+ Directories to watch for auto-reload
398
+ **server_kwargs
399
+ Additional server configuration
400
+ """
401
+ server = WaldiezWsServer(
402
+ host=host,
403
+ port=port,
404
+ workspace_dir=workspace_dir,
405
+ **server_kwargs,
406
+ )
407
+
408
+ # Set up signal handlers
409
+ def signal_handler() -> None:
410
+ """Handle shutdown signals."""
411
+ logger.info("Received shutdown signal")
412
+ server.shutdown()
413
+ logger.info("Shutdown event set, stopping server...")
414
+
415
+ # Register signal handlers
416
+ loop = asyncio.get_running_loop()
417
+ for sig in (signal.SIGTERM, signal.SIGINT):
418
+ # noinspection PyTypeChecker
419
+ loop.add_signal_handler(sig, signal_handler)
420
+
421
+ # Set up auto-reload if requested
422
+ file_watcher = None
423
+ if auto_reload:
424
+ # pylint: disable=import-outside-toplevel,too-many-try-statements
425
+ try:
426
+ # Determine watch directories
427
+ if watch_dirs is None:
428
+ project_root = Path(__file__).parents[2]
429
+
430
+ # Watch the actual waldiez package directory
431
+ waldiez_dir = project_root / "waldiez"
432
+ if waldiez_dir.exists():
433
+ watch_dirs = {waldiez_dir}
434
+ logger.info(
435
+ "Auto-reload: watching waldiez package at %s",
436
+ waldiez_dir,
437
+ )
438
+ else:
439
+ # Fallback: watch current directory
440
+ watch_dirs = {Path.cwd()}
441
+ logger.warning(
442
+ "Auto-reload: fallback to current directory %s",
443
+ Path.cwd(),
444
+ )
445
+
446
+ # Create file watcher with restart callback
447
+ file_watcher = create_file_watcher(
448
+ root_dir=Path(__file__).parents[2],
449
+ additional_dirs=list(watch_dirs),
450
+ restart_callback=None,
451
+ )
452
+ file_watcher.start()
453
+ logger.info(
454
+ "Auto-reload enabled for directories: %s",
455
+ {str(dir_) for dir_ in watch_dirs},
456
+ )
457
+
458
+ except ImportError as e:
459
+ logger.warning("Auto-reload not available: %s", e)
460
+ except Exception as e: # pylint: disable=broad-exception-caught
461
+ logger.error("Failed to set up auto-reload: %s", e)
462
+
463
+ try:
464
+ # Start server
465
+ await server.start()
466
+ finally:
467
+ # Clean up file watcher
468
+ if file_watcher:
469
+ file_watcher.stop()