waldiez 0.5.9__py3-none-any.whl → 0.6.0__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 (109) hide show
  1. waldiez/_version.py +1 -1
  2. waldiez/cli.py +113 -24
  3. waldiez/exporting/agent/exporter.py +9 -6
  4. waldiez/exporting/agent/extras/captain_agent_extras.py +44 -7
  5. waldiez/exporting/agent/extras/group_manager_agent_extas.py +6 -1
  6. waldiez/exporting/agent/extras/handoffs/after_work.py +1 -0
  7. waldiez/exporting/agent/extras/handoffs/available.py +1 -0
  8. waldiez/exporting/agent/extras/handoffs/condition.py +3 -1
  9. waldiez/exporting/agent/extras/handoffs/handoff.py +1 -0
  10. waldiez/exporting/agent/extras/handoffs/target.py +1 -0
  11. waldiez/exporting/agent/termination.py +1 -0
  12. waldiez/exporting/chats/utils/common.py +25 -23
  13. waldiez/exporting/core/__init__.py +0 -2
  14. waldiez/exporting/core/constants.py +3 -1
  15. waldiez/exporting/core/context.py +13 -13
  16. waldiez/exporting/core/extras/serializer.py +12 -10
  17. waldiez/exporting/core/protocols.py +0 -141
  18. waldiez/exporting/core/result.py +5 -5
  19. waldiez/exporting/core/types.py +1 -0
  20. waldiez/exporting/core/utils/llm_config.py +2 -2
  21. waldiez/exporting/flow/execution_generator.py +1 -0
  22. waldiez/exporting/flow/merger.py +2 -2
  23. waldiez/exporting/flow/orchestrator.py +1 -0
  24. waldiez/exporting/flow/utils/common.py +3 -3
  25. waldiez/exporting/flow/utils/importing.py +1 -0
  26. waldiez/exporting/flow/utils/logging.py +7 -80
  27. waldiez/exporting/tools/exporter.py +5 -0
  28. waldiez/exporting/tools/factory.py +4 -0
  29. waldiez/exporting/tools/processor.py +5 -1
  30. waldiez/io/__init__.py +3 -1
  31. waldiez/io/_ws.py +15 -5
  32. waldiez/io/models/content/image.py +1 -0
  33. waldiez/io/models/user_input.py +4 -4
  34. waldiez/io/models/user_response.py +1 -0
  35. waldiez/io/mqtt.py +1 -1
  36. waldiez/io/structured.py +98 -45
  37. waldiez/io/utils.py +17 -11
  38. waldiez/io/ws.py +10 -12
  39. waldiez/logger.py +180 -63
  40. waldiez/models/agents/agent/agent.py +2 -1
  41. waldiez/models/agents/agent/update_system_message.py +0 -2
  42. waldiez/models/agents/doc_agent/doc_agent.py +8 -1
  43. waldiez/models/chat/chat.py +1 -0
  44. waldiez/models/chat/chat_data.py +0 -2
  45. waldiez/models/common/base.py +2 -0
  46. waldiez/models/common/dict_utils.py +169 -40
  47. waldiez/models/common/handoff.py +2 -0
  48. waldiez/models/common/method_utils.py +2 -0
  49. waldiez/models/flow/flow.py +6 -6
  50. waldiez/models/flow/info.py +5 -1
  51. waldiez/models/model/_llm.py +31 -14
  52. waldiez/models/model/model.py +4 -1
  53. waldiez/models/model/model_data.py +18 -5
  54. waldiez/models/tool/predefined/_config.py +5 -1
  55. waldiez/models/tool/predefined/_duckduckgo.py +4 -0
  56. waldiez/models/tool/predefined/_email.py +477 -0
  57. waldiez/models/tool/predefined/_google.py +4 -1
  58. waldiez/models/tool/predefined/_perplexity.py +4 -1
  59. waldiez/models/tool/predefined/_searxng.py +4 -1
  60. waldiez/models/tool/predefined/_tavily.py +4 -1
  61. waldiez/models/tool/predefined/_wikipedia.py +5 -2
  62. waldiez/models/tool/predefined/_youtube.py +4 -1
  63. waldiez/models/tool/predefined/protocol.py +3 -0
  64. waldiez/models/tool/tool.py +22 -4
  65. waldiez/models/waldiez.py +12 -0
  66. waldiez/runner.py +37 -54
  67. waldiez/running/__init__.py +6 -0
  68. waldiez/running/base_runner.py +381 -363
  69. waldiez/running/environment.py +1 -0
  70. waldiez/running/exceptions.py +9 -0
  71. waldiez/running/post_run.py +10 -4
  72. waldiez/running/pre_run.py +199 -66
  73. waldiez/running/protocol.py +21 -101
  74. waldiez/running/run_results.py +1 -1
  75. waldiez/running/standard_runner.py +83 -276
  76. waldiez/running/step_by_step/__init__.py +46 -0
  77. waldiez/running/step_by_step/breakpoints_mixin.py +512 -0
  78. waldiez/running/step_by_step/command_handler.py +151 -0
  79. waldiez/running/step_by_step/events_processor.py +199 -0
  80. waldiez/running/step_by_step/step_by_step_models.py +541 -0
  81. waldiez/running/step_by_step/step_by_step_runner.py +750 -0
  82. waldiez/running/subprocess_runner/__base__.py +279 -0
  83. waldiez/running/subprocess_runner/__init__.py +16 -0
  84. waldiez/running/subprocess_runner/_async_runner.py +362 -0
  85. waldiez/running/subprocess_runner/_sync_runner.py +456 -0
  86. waldiez/running/subprocess_runner/runner.py +570 -0
  87. waldiez/running/timeline_processor.py +1 -1
  88. waldiez/running/utils.py +492 -3
  89. waldiez/utils/version.py +2 -6
  90. waldiez/ws/__init__.py +71 -0
  91. waldiez/ws/__main__.py +15 -0
  92. waldiez/ws/_file_handler.py +199 -0
  93. waldiez/ws/_mock.py +74 -0
  94. waldiez/ws/cli.py +235 -0
  95. waldiez/ws/client_manager.py +851 -0
  96. waldiez/ws/errors.py +416 -0
  97. waldiez/ws/models.py +988 -0
  98. waldiez/ws/reloader.py +363 -0
  99. waldiez/ws/server.py +508 -0
  100. waldiez/ws/session_manager.py +393 -0
  101. waldiez/ws/session_stats.py +83 -0
  102. waldiez/ws/utils.py +410 -0
  103. {waldiez-0.5.9.dist-info → waldiez-0.6.0.dist-info}/METADATA +105 -96
  104. {waldiez-0.5.9.dist-info → waldiez-0.6.0.dist-info}/RECORD +108 -83
  105. waldiez/running/patch_io_stream.py +0 -210
  106. {waldiez-0.5.9.dist-info → waldiez-0.6.0.dist-info}/WHEEL +0 -0
  107. {waldiez-0.5.9.dist-info → waldiez-0.6.0.dist-info}/entry_points.txt +0 -0
  108. {waldiez-0.5.9.dist-info → waldiez-0.6.0.dist-info}/licenses/LICENSE +0 -0
  109. {waldiez-0.5.9.dist-info → waldiez-0.6.0.dist-info}/licenses/NOTICE.md +0 -0
@@ -0,0 +1,851 @@
1
+ # SPDX-License-Identifier: Apache-2.0.
2
+ # Copyright (c) 2024 - 2025 Waldiez and contributors.
3
+ # pylint: disable=too-many-try-statements,broad-exception-caught,line-too-long
4
+ # pylint: disable=too-complex,too-many-return-statements,import-error
5
+ # pyright: reportUnknownMemberType=false,reportAttributeAccessIssue=false
6
+ # pyright: reportUnknownVariableType=false,reportUnknownArgumentType=false
7
+ # pyright: reportAssignmentType=false,reportUnknownParameterType=false
8
+ # flake8: noqa: C901
9
+ """WebSocket client manager: bridges WS <-> subprocess runner."""
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any, Callable, Literal
17
+
18
+ try:
19
+ import websockets # type: ignore[unused-ignore, unused-import, import-not-found, import-untyped] # noqa
20
+ except ImportError: # pragma: no cover
21
+ from ._mock import websockets # type: ignore[no-redef,unused-ignore]
22
+
23
+
24
+ from waldiez.models import Waldiez
25
+ from waldiez.running.subprocess_runner.runner import WaldiezSubprocessRunner
26
+
27
+ from ._file_handler import FileRequestHandler
28
+ from .errors import (
29
+ ErrorHandler,
30
+ MessageParsingError,
31
+ NoInputRequestedError,
32
+ SessionNotFoundError,
33
+ StaleInputRequestError,
34
+ UnsupportedActionError,
35
+ )
36
+ from .models import (
37
+ BreakpointRequest,
38
+ BreakpointResponse,
39
+ ConvertWorkflowRequest,
40
+ ExecutionMode,
41
+ GetStatusRequest,
42
+ PingRequest,
43
+ PongResponse,
44
+ RunWorkflowRequest,
45
+ RunWorkflowResponse,
46
+ SaveFlowRequest,
47
+ StatusResponse,
48
+ StepControlRequest,
49
+ StepControlResponse,
50
+ StepDebugNotification,
51
+ StepRunWorkflowRequest,
52
+ StepRunWorkflowResponse,
53
+ SubprocessCompletionNotification,
54
+ SubprocessOutputNotification,
55
+ UserInputRequestNotification,
56
+ UserInputResponse,
57
+ WorkflowCompletionNotification,
58
+ WorkflowStatus,
59
+ WorkflowStatusNotification,
60
+ create_error_response,
61
+ parse_client_message,
62
+ )
63
+ from .session_manager import SessionManager
64
+
65
+ CWD = Path.cwd()
66
+
67
+
68
+ # pylint: disable=too-many-instance-attributes
69
+ class ClientManager:
70
+ """Single websocket client and route messages to subprocess runners."""
71
+
72
+ def __init__(
73
+ self,
74
+ websocket: websockets.ServerConnection, # pyright: ignore
75
+ client_id: str,
76
+ session_manager: SessionManager,
77
+ workspace_dir: Path = CWD,
78
+ error_handler: ErrorHandler | None = None,
79
+ ) -> None:
80
+ self.websocket = websocket
81
+ self.client_id = client_id
82
+ self.session_manager = session_manager
83
+ self.workspace_dir = workspace_dir
84
+ self.is_active = True
85
+
86
+ # Active runners per session
87
+ self._runners: dict[str, WaldiezSubprocessRunner] = {}
88
+
89
+ # Track pending input requests (session_id -> last request_id)
90
+ self._pending_input: dict[str, str] = {}
91
+ self._last_prompt: dict[str, str] = {}
92
+
93
+ self.connection_time = time.time()
94
+
95
+ # Extract client info
96
+ self.remote_address = websocket.remote_address
97
+ if not websocket.request:
98
+ raise ValueError("WebSocket request is not available")
99
+ self.user_agent = websocket.request.headers.get("User-Agent", "Unknown")
100
+ # getattr(websocket, "request_headers", {}).get("User-Agent", "Unknown")
101
+ self.logger = logging.getLogger(__name__)
102
+ self.logger.info(
103
+ "Client connected: %s from %s (User-Agent: %s)",
104
+ self.client_id,
105
+ self.remote_address,
106
+ self.user_agent,
107
+ )
108
+ self.error_handler = error_handler or ErrorHandler(self.logger)
109
+
110
+ self.loop: asyncio.AbstractEventLoop | None = None
111
+ try:
112
+ self.loop = asyncio.get_running_loop()
113
+ except RuntimeError:
114
+ # constructed from a sync context? (tests?)
115
+ self.loop = None
116
+
117
+ @property
118
+ def connection_duration(self) -> float:
119
+ """Get connection duration in seconds."""
120
+ return time.time() - self.connection_time
121
+
122
+ # ---------------------------------------------------------------------
123
+ # Outbound (server -> client)
124
+ # ---------------------------------------------------------------------
125
+
126
+ async def send_message(self, payload: dict[str, Any] | Any) -> bool:
127
+ """Serialize and send a message to the client.
128
+
129
+ Parameters
130
+ ----------
131
+ payload : dict[str, Any] | Any
132
+ The message payload to send.
133
+
134
+ Returns
135
+ -------
136
+ bool
137
+ True if the message was sent successfully, False otherwise.
138
+ """
139
+ try:
140
+ if hasattr(payload, "model_dump"):
141
+ data = payload.model_dump(mode="json", exclude_none=True)
142
+ elif isinstance(payload, dict):
143
+ data = payload
144
+ else:
145
+ data = json.loads(json.dumps(payload, default=str))
146
+ await self.websocket.send(json.dumps(data))
147
+ return True
148
+ except (
149
+ websockets.ConnectionClosed,
150
+ ConnectionResetError,
151
+ ) as e: # pyright: ignore
152
+ self.logger.info("Client %s disconnected: %s", self.client_id, e)
153
+ await self.cleanup()
154
+ return False
155
+ except Exception as e: # pragma: no cover
156
+ self.logger.warning(
157
+ "Failed sending to client %s: %s", self.client_id, e
158
+ )
159
+ # Record operational error
160
+ self.error_handler.record_send_failure(self.client_id)
161
+ return False
162
+
163
+ def close_connection(self) -> None:
164
+ """Mark as inactive (server will close the socket elsewhere)."""
165
+ self.is_active = False
166
+
167
+ async def cleanup(self) -> None:
168
+ """Clean up resources when client disconnects."""
169
+ for session_id, runner in self._runners.items():
170
+ try:
171
+ runner.stop()
172
+ await self.session_manager.remove_session(session_id)
173
+ except Exception as e:
174
+ self.logger.warning(
175
+ "Error cleaning up session %s: %s ", session_id, e
176
+ )
177
+
178
+ self._runners.clear()
179
+ self._pending_input.clear()
180
+ self._last_prompt.clear()
181
+ self.close_connection()
182
+
183
+ # ---------------------------------------------------------------------
184
+ # Runner -> Client bridges
185
+ # ---------------------------------------------------------------------
186
+
187
+ def _mk_on_output(
188
+ self, session_id: str
189
+ ) -> Callable[[dict[str, Any]], None]:
190
+ """Runner output callback (called from runner threads)."""
191
+
192
+ def _cb(data: dict[str, Any]) -> None:
193
+ loop = self._ensure_loop()
194
+ if loop is None:
195
+ # No running loop available; best we can do is log and drop.
196
+ self.logger.debug(
197
+ "No running loop to post runner output; dropping."
198
+ )
199
+ return
200
+ data = {**data, "session_id": session_id}
201
+ asyncio.run_coroutine_threadsafe(
202
+ self._handle_runner_output(data), loop
203
+ )
204
+
205
+ return _cb
206
+
207
+ # pylint: disable=no-self-use
208
+ def _mk_on_input_request(self, session_id: str) -> Callable[[str], None]:
209
+ """Runner input-request callback (fallback if prompt-only)."""
210
+
211
+ def _cb(prompt: str) -> None:
212
+ loop = self._ensure_loop()
213
+ if loop is None:
214
+ # No running loop available; best we can do is log and drop.
215
+ self.logger.debug(
216
+ "No running loop to post runner output; dropping."
217
+ )
218
+ return
219
+
220
+ request_id = f"req_{time.monotonic_ns()}"
221
+ self._pending_input[session_id] = request_id
222
+ self._last_prompt[session_id] = prompt or "> "
223
+
224
+ async def notify() -> None:
225
+ try:
226
+ await self.session_manager.update_session_status(
227
+ session_id, WorkflowStatus.INPUT_WAITING
228
+ )
229
+ await self.send_message(
230
+ UserInputRequestNotification(
231
+ session_id=session_id,
232
+ request_id=request_id,
233
+ prompt=prompt or "> ",
234
+ password=False,
235
+ timeout=120.0,
236
+ )
237
+ )
238
+ except Exception as e: # pragma: no cover
239
+ self.logger.warning("Failed to notify input request: %s", e)
240
+
241
+ # hand off to the loop from runner thread
242
+ asyncio.run_coroutine_threadsafe(notify(), loop)
243
+
244
+ return _cb
245
+
246
+ # ---------------------------------------------------------------------
247
+ # Outbound (server -> client)
248
+ # ---------------------------------------------------------------------
249
+
250
+ # pylint: disable=too-many-branches
251
+ async def handle_message(self, raw_message: str) -> dict[str, Any] | None:
252
+ """Parse & dispatch an inbound client message.
253
+
254
+ Return an immediate *response* dict (serialized later by server),
255
+ or None if we've already sent notifications.
256
+
257
+ Parameters
258
+ ----------
259
+ raw_message : str
260
+ The raw message received from the client.
261
+
262
+ Returns
263
+ -------
264
+ dict[str, Any] | None
265
+ The parsed message or None if it couldn't be parsed.
266
+ """
267
+ try:
268
+ msg = parse_client_message(raw_message)
269
+ except ValueError as e:
270
+ # Wrap in domain error and format consistently
271
+ return self._error_to_response(MessageParsingError(str(e)))
272
+
273
+ # Lightweight utility requests
274
+ if isinstance(msg, PingRequest):
275
+ return PongResponse.ok(echo_data=msg.echo_data).model_dump(
276
+ mode="json"
277
+ )
278
+
279
+ if isinstance(msg, GetStatusRequest):
280
+ server_status = await self.session_manager.get_status()
281
+ wf_status = None
282
+ if msg.session_id:
283
+ sess = await self.session_manager.get_session(msg.session_id)
284
+ wf_status = sess.status if sess else None
285
+ return StatusResponse.ok(
286
+ server_status=server_status,
287
+ workflow_status=wf_status,
288
+ session_id=msg.session_id,
289
+ ).model_dump(mode="json")
290
+
291
+ if isinstance(msg, SaveFlowRequest):
292
+ return FileRequestHandler.handle_save_flow_request(
293
+ msg=msg,
294
+ workspace_dir=self.workspace_dir,
295
+ client_id=self.client_id,
296
+ logger=self.logger,
297
+ )
298
+
299
+ if isinstance(msg, ConvertWorkflowRequest):
300
+ return FileRequestHandler.handle_convert_workflow_request(
301
+ msg=msg,
302
+ client_id=self.client_id,
303
+ workspace_dir=self.workspace_dir,
304
+ logger=self.logger,
305
+ )
306
+
307
+ # Start workflow (STANDARD)
308
+ if isinstance(msg, RunWorkflowRequest):
309
+ return await self._handle_run_workflow(msg)
310
+
311
+ # Start workflow (STEP-BY-STEP / DEBUG)
312
+ if isinstance(msg, StepRunWorkflowRequest):
313
+ return await self._handle_step_run_workflow(msg)
314
+
315
+ # Step controls
316
+ if isinstance(msg, StepControlRequest):
317
+ return await self._handle_step_control(msg)
318
+
319
+ # Breakpoint controls
320
+ if isinstance(msg, BreakpointRequest):
321
+ return await self._handle_breakpoint_control(msg)
322
+
323
+ # User input for pending input_request
324
+ if isinstance(msg, UserInputResponse):
325
+ return await self._handle_user_input(msg)
326
+
327
+ # Stop workflow
328
+ if hasattr(msg, "type") and msg.type == "stop_workflow":
329
+ return await self._handle_stop_workflow(msg)
330
+
331
+ # Unknown
332
+ return self._error_to_response(
333
+ UnsupportedActionError(getattr(msg, "type", "unknown"))
334
+ )
335
+
336
+ async def _handle_run_workflow(
337
+ self, msg: RunWorkflowRequest
338
+ ) -> dict[str, Any]:
339
+ try:
340
+ data_dict = json.loads(msg.flow_data)
341
+ waldiez = Waldiez.from_dict(data_dict)
342
+ except Exception as e:
343
+ return RunWorkflowResponse.fail(
344
+ error=f"Invalid flow_data: {e}",
345
+ execution_mode=msg.execution_mode,
346
+ session_id="",
347
+ ).model_dump(mode="json")
348
+ # structured path preferred
349
+ session_id = self._next_session_id()
350
+ runner = WaldiezSubprocessRunner(
351
+ waldiez=waldiez,
352
+ on_output=self._mk_on_output(session_id),
353
+ on_input_request=self._mk_on_input_request(session_id),
354
+ mode="run",
355
+ )
356
+
357
+ await self._create_session_for_runner(
358
+ runner, ExecutionMode.STANDARD, session_id=session_id
359
+ )
360
+ # Fire and forget running in a thread
361
+ asyncio.create_task(self._run_runner(session_id, runner))
362
+
363
+ return RunWorkflowResponse.ok(
364
+ session_id=session_id, execution_mode=ExecutionMode.STANDARD
365
+ ).model_dump(mode="json")
366
+
367
+ async def _handle_step_run_workflow(
368
+ self, msg: StepRunWorkflowRequest
369
+ ) -> dict[str, Any]:
370
+ try:
371
+ data_dict = json.loads(msg.flow_data)
372
+ waldiez = Waldiez.from_dict(data_dict)
373
+ except Exception as e:
374
+ return StepRunWorkflowResponse.fail(
375
+ error=f"Invalid flow_data: {e}",
376
+ session_id="",
377
+ auto_continue=msg.auto_continue,
378
+ breakpoints=msg.breakpoints,
379
+ ).model_dump(mode="json")
380
+ session_id = self._next_session_id()
381
+ runner = WaldiezSubprocessRunner(
382
+ waldiez=waldiez,
383
+ on_output=self._mk_on_output(session_id),
384
+ on_input_request=self._mk_on_input_request(session_id),
385
+ mode="debug", # step-by-step via CLI
386
+ )
387
+
388
+ await self._create_session_for_runner(
389
+ runner,
390
+ ExecutionMode.STEP_BY_STEP,
391
+ session_id=session_id,
392
+ )
393
+ sess = await self.session_manager.get_session(session_id)
394
+ if sess:
395
+ sess.state.metadata.update(
396
+ {
397
+ "auto_continue": msg.auto_continue,
398
+ "breakpoints": list(msg.breakpoints),
399
+ }
400
+ )
401
+
402
+ asyncio.create_task(self._run_runner(session_id, runner))
403
+
404
+ return StepRunWorkflowResponse.ok(
405
+ session_id=session_id,
406
+ auto_continue=msg.auto_continue,
407
+ breakpoints=list(msg.breakpoints),
408
+ ).model_dump(mode="json")
409
+
410
+ async def _create_session_for_runner(
411
+ self,
412
+ runner: WaldiezSubprocessRunner,
413
+ mode: ExecutionMode,
414
+ session_id: str,
415
+ ) -> None:
416
+ await self.session_manager.create_session(
417
+ session_id=session_id,
418
+ client_id=self.client_id,
419
+ execution_mode=mode,
420
+ runner=runner,
421
+ temp_file=None,
422
+ metadata={},
423
+ )
424
+ self._runners[session_id] = runner
425
+ await self.session_manager.update_session_status(
426
+ session_id, WorkflowStatus.STARTING
427
+ )
428
+ await self.send_message(
429
+ WorkflowStatusNotification.make(
430
+ session_id, WorkflowStatus.STARTING, mode
431
+ )
432
+ )
433
+
434
+ async def _run_runner(
435
+ self, session_id: str, runner: WaldiezSubprocessRunner
436
+ ) -> None:
437
+ """Run the subprocess in a thread.
438
+
439
+ Completion is reported via on_output (completion message)
440
+ and here as a fallback.
441
+
442
+ Parameters
443
+ ----------
444
+ session_id : str
445
+ The ID of the session.
446
+ runner : WaldiezSubprocessRunner
447
+ The runner instance to execute.
448
+ """
449
+ try:
450
+ await self.session_manager.update_session_status(
451
+ session_id, WorkflowStatus.RUNNING
452
+ )
453
+ # Run in thread to avoid blocking loop
454
+ # noinspection PyTypeChecker
455
+ await asyncio.to_thread(runner.run, mode=runner.mode)
456
+ # If the runner emitted a completion dict,
457
+ # _handle_runner_output will forward it.
458
+ except Exception as e: # pragma: no cover
459
+ await self.session_manager.update_session_status(
460
+ session_id, WorkflowStatus.FAILED
461
+ )
462
+ await self.send_message(
463
+ WorkflowCompletionNotification(
464
+ session_id=session_id,
465
+ success=False,
466
+ exit_code=-1,
467
+ error=str(e),
468
+ )
469
+ )
470
+
471
+ async def _handle_step_control(
472
+ self, msg: StepControlRequest
473
+ ) -> dict[str, Any]:
474
+ runner = self._runners.get(msg.session_id)
475
+ if not runner:
476
+ return StepControlResponse.fail(
477
+ error="Session not found",
478
+ action=msg.action,
479
+ result="",
480
+ session_id=msg.session_id,
481
+ ).model_dump(mode="json")
482
+ code: str | None
483
+ if msg.action in {"", "c", "s", "r", "q", "i", "h", "st"}:
484
+ code = msg.action if msg.action else "c"
485
+ else:
486
+ code = {
487
+ "": "c",
488
+ "continue": "c",
489
+ "step": "s",
490
+ "run": "r",
491
+ "quit": "q",
492
+ "info": "i",
493
+ "help": "h",
494
+ "stats": "st",
495
+ }.get(msg.action)
496
+
497
+ if not code:
498
+ return StepControlResponse.fail(
499
+ error=f"Unsupported action: {msg.action}",
500
+ action=msg.action,
501
+ result="",
502
+ session_id=msg.session_id,
503
+ ).model_dump(mode="json")
504
+
505
+ runner.provide_user_input(code)
506
+ return StepControlResponse.ok(
507
+ action=msg.action, result="sent", session_id=msg.session_id
508
+ ).model_dump(mode="json")
509
+
510
+ async def _handle_breakpoint_control(
511
+ self, msg: BreakpointRequest
512
+ ) -> dict[str, Any]:
513
+ runner = self._runners.get(msg.session_id)
514
+ if not runner:
515
+ return BreakpointResponse.fail(
516
+ error="Session not found",
517
+ action=msg.action,
518
+ session_id=msg.session_id,
519
+ ).model_dump(mode="json")
520
+
521
+ cmd = {
522
+ "list": "lb",
523
+ "clear": "cb",
524
+ "add": "ab",
525
+ "remove": "rb",
526
+ }[msg.action]
527
+
528
+ # NOTE: If we later add `ab <event>`, send f"{cmd} {msg.event_type}"
529
+ runner.provide_user_input(cmd)
530
+ return BreakpointResponse.ok(
531
+ action=msg.action, session_id=msg.session_id
532
+ ).model_dump(mode="json")
533
+
534
+ async def _handle_user_input(
535
+ self, msg: UserInputResponse
536
+ ) -> dict[str, Any]:
537
+ runner = self._runners.get(msg.session_id)
538
+ if not runner:
539
+ return self._error_to_response(
540
+ SessionNotFoundError(session_id=msg.session_id)
541
+ )
542
+
543
+ pending = self._pending_input.get(msg.session_id)
544
+ if not pending:
545
+ return self._error_to_response(NoInputRequestedError())
546
+ if pending != msg.request_id:
547
+ self.logger.debug(
548
+ "Client %s: mismatched request_id for %s (got %s expected %s)",
549
+ self.client_id,
550
+ msg.session_id,
551
+ msg.request_id,
552
+ pending,
553
+ )
554
+ return self._error_to_response(
555
+ StaleInputRequestError(
556
+ request_id=msg.request_id, expected_id=pending
557
+ )
558
+ )
559
+
560
+ runner.provide_user_input(msg.data)
561
+ return {"type": "ok", "success": True}
562
+
563
+ async def _handle_stop_workflow(self, msg: Any) -> dict[str, Any]:
564
+ session_id = getattr(msg, "session_id", "")
565
+ runner = self._runners.get(session_id)
566
+ if not runner:
567
+ return self._error_to_response(
568
+ SessionNotFoundError(session_id=session_id)
569
+ )
570
+
571
+ try:
572
+ runner.stop()
573
+ await self.session_manager.update_session_status(
574
+ session_id, WorkflowStatus.STOPPING
575
+ )
576
+ return {
577
+ "type": "stop_workflow_response",
578
+ "session_id": session_id,
579
+ "success": True,
580
+ "forced": getattr(msg, "force", False),
581
+ }
582
+ except Exception as e:
583
+ # Shape it as a standard error response
584
+ return self._error_to_response(e)
585
+
586
+ async def _handle_runner_output(self, data: dict[str, Any]) -> None:
587
+ """Handle output from the runner.
588
+
589
+ Parameters
590
+ ----------
591
+ data : dict[str, Any[
592
+ The output dict from the runner.
593
+
594
+ Interpret dicts from BaseSubprocessRunner.create_* and parse_output().
595
+ We support:
596
+ - type == "subprocess_output"
597
+ -> SubprocessOutputNotification
598
+ - type == "subprocess_completion"
599
+ -> SubprocessCompletionNotification + status update
600
+ - type in {"input_request", "debug_input_request"}
601
+ -> UserInputRequestNotification
602
+ - type startswith "debug_"
603
+ -> StepDebugNotification
604
+ - anything else
605
+ -> fallback stdout SubprocessOutputNotification
606
+ """
607
+ # self.logger.debug("Handling runner output: %s", data)
608
+ try:
609
+ msg_type = str(data.get("type", "")).lower()
610
+ session_id_raw = data.get("session_id")
611
+ session_id = (
612
+ str(session_id_raw)
613
+ if session_id_raw
614
+ else (self._guess_session_id() or "")
615
+ )
616
+
617
+ if msg_type in ("input_request", "debug_input_request"):
618
+ await self._handle_runner_input_request(
619
+ session_id, data, is_debug=msg_type == "debug_input_request"
620
+ )
621
+ return
622
+
623
+ if msg_type.startswith("debug_"):
624
+ await self._handle_runner_debug(session_id, msg_type, data)
625
+ return
626
+
627
+ if msg_type == "subprocess_completion":
628
+ await self._handle_runner_completion(session_id, data)
629
+ return
630
+
631
+ if msg_type == "subprocess_output":
632
+ await self._handle_runner_subprocess_output(session_id, data)
633
+ return
634
+
635
+ # Fallback: dump everything as stdout line
636
+ await self.send_message(
637
+ SubprocessOutputNotification(
638
+ session_id=session_id,
639
+ stream="stdout",
640
+ content=json.dumps(data, default=str),
641
+ subprocess_type="output",
642
+ context={},
643
+ )
644
+ )
645
+ except Exception as e: # pragma: no cover
646
+ # Convert to a debug notification so the client sees something,
647
+ # and keep the server loop healthy.
648
+ await self.send_message(
649
+ StepDebugNotification(
650
+ session_id=self._guess_session_id() or "",
651
+ debug_type="error",
652
+ data={
653
+ "message": "Runner output handling failed",
654
+ "error": str(e),
655
+ },
656
+ )
657
+ )
658
+
659
+ async def _handle_runner_input_request(
660
+ self,
661
+ session_id: str,
662
+ data: dict[str, Any],
663
+ is_debug: bool,
664
+ ) -> None:
665
+ """Handle an input request from the runner."""
666
+ request_id = str(data.get("request_id", ""))
667
+ prompt = str(data.get("prompt", "> "))
668
+ if session_id and request_id:
669
+ self._pending_input[session_id] = request_id
670
+ self._last_prompt[session_id] = prompt
671
+ await self.session_manager.update_session_status(
672
+ session_id, WorkflowStatus.INPUT_WAITING
673
+ )
674
+ notification = UserInputRequestNotification(
675
+ session_id=session_id,
676
+ request_id=request_id,
677
+ prompt=prompt,
678
+ password=bool(data.get("password", False)),
679
+ timeout=float(data.get("timeout", 120.0)),
680
+ )
681
+ msg_dump = notification.model_dump(mode="json", fallback=str)
682
+ if is_debug:
683
+ msg_dump["type"] = "debug_input_request"
684
+ await self.send_message(msg_dump)
685
+
686
+ # pylint: disable=line-too-long
687
+ async def _handle_runner_debug(
688
+ self, session_id: str, msg_type: str, data: dict[str, Any]
689
+ ) -> None:
690
+ """Handle a debug message from the runner."""
691
+ # noinspection PyTypeChecker
692
+ kind = msg_type.replace("debug_", "", 1) or "info"
693
+ debug_type = (
694
+ kind if kind in {"stats", "help", "error", "info"} else "info"
695
+ ) # noqa: E501
696
+ await self.send_message(
697
+ StepDebugNotification(
698
+ session_id=session_id,
699
+ debug_type=debug_type, # type: ignore
700
+ data={
701
+ k: v
702
+ for k, v in data.items()
703
+ if k not in {"type", "session_id"}
704
+ },
705
+ )
706
+ )
707
+
708
+ async def _handle_runner_completion(
709
+ self, session_id: str, data: dict[str, Any]
710
+ ) -> None:
711
+ """Handle a completion message from the runner."""
712
+ success = bool(data.get("success", False))
713
+ exit_code = int(data.get("exit_code", -1))
714
+ message = str(data.get("message", ""))
715
+
716
+ await self.session_manager.update_session_status(
717
+ session_id,
718
+ (WorkflowStatus.COMPLETED if success else WorkflowStatus.FAILED),
719
+ )
720
+ await self.send_message(
721
+ SubprocessCompletionNotification(
722
+ session_id=session_id,
723
+ success=success,
724
+ exit_code=exit_code,
725
+ message=message,
726
+ context=data.get("context", {}) or {},
727
+ )
728
+ )
729
+
730
+ async def _handle_runner_subprocess_output(
731
+ self, session_id: str, data: dict[str, Any]
732
+ ) -> None:
733
+ """Handle a subprocess output message from the runner."""
734
+ stream_ = str(data.get("stream", "stdout"))
735
+ stream: Literal["stdout", "stderr"] = (
736
+ "stderr" if stream_ == "stderr" else "stdout"
737
+ )
738
+ subprocess_type_str = str(data.get("subprocess_type", "output")).lower()
739
+ subprocess_type: Literal["output", "error", "debug"] = (
740
+ subprocess_type_str # type: ignore
741
+ if subprocess_type_str in ("output", "error", "debug")
742
+ else "output"
743
+ )
744
+ content = data.get("content", "")
745
+ # noinspection PyUnreachableCode
746
+ if not isinstance(content, str):
747
+ # noinspection PyBroadException
748
+ try:
749
+ content = json.dumps(content, default=str)
750
+ except Exception: # pragma: no cover
751
+ content = str(content)
752
+ context = data.get("context", {}) or {}
753
+ content = self._strip_prompt_if_needed(
754
+ session_id=session_id,
755
+ content=content,
756
+ )
757
+ await self.send_message(
758
+ SubprocessOutputNotification(
759
+ session_id=session_id,
760
+ stream=stream,
761
+ content=content,
762
+ subprocess_type=subprocess_type,
763
+ context=context,
764
+ )
765
+ )
766
+
767
+ def _strip_prompt_if_needed(
768
+ self,
769
+ session_id: str,
770
+ content: str,
771
+ ) -> str:
772
+ # let's try to avoid messages like (including the prompt):
773
+ # \"content\": \"Prompt msg: {\\\"type\\\": \\\"text\\\",...\\}\"
774
+ last = self._last_prompt.get(session_id)
775
+ if last:
776
+ s_last = last.strip()
777
+ s_cont = content.strip()
778
+ if s_cont.startswith(s_last):
779
+ self._last_prompt.pop(session_id, None)
780
+ # Remove the leading prompt text
781
+ # Prefer removing from the original string to preserve
782
+ # spacing after the prefix.
783
+ idx = content.find(last)
784
+ payload = content[idx + len(last) :].lstrip()
785
+
786
+ # If the remainder looks like JSON,
787
+ # try to parse and re-dispatch it
788
+ if (
789
+ payload
790
+ and payload[0] in ("{", "[")
791
+ and payload[-1] in ("}", "]")
792
+ ):
793
+ # noinspection TryExceptPass,PyBroadException
794
+ try:
795
+ json.loads(payload)
796
+ except Exception:
797
+ # Not valid JSON—fall through
798
+ # just forward the cleaned text
799
+ pass
800
+ else:
801
+ return payload
802
+
803
+ # If not JSON, keep the cleaned textual payload
804
+ return payload
805
+ return content
806
+
807
+ def _guess_session_id(self) -> str | None:
808
+ """Pick any current session for this client (we typically run one)."""
809
+ return next(iter(self._runners.keys()), None)
810
+
811
+ def _next_session_id(self) -> str:
812
+ return f"session_{self.client_id}_{len(self._runners) + 1:02d}"
813
+
814
+ def _ensure_loop(self) -> asyncio.AbstractEventLoop | None:
815
+ """Return an event loop for scheduling callbacks, if available."""
816
+ if self.loop and not self.loop.is_closed():
817
+ return self.loop
818
+ try:
819
+ # if we're now on an async path, this will work
820
+ self.loop = asyncio.get_running_loop()
821
+ return self.loop
822
+ except RuntimeError:
823
+ return None
824
+
825
+ def _error_to_response(
826
+ self, error: Exception, request_id: str | None = None
827
+ ) -> dict[str, Any]:
828
+ """Convert an exception into a standardized ErrorResponse payload.
829
+
830
+ Parameters
831
+ ----------
832
+ error : Exception
833
+ The error to convert
834
+ request_id : str | None
835
+ Optional request id to echo back
836
+
837
+ Returns
838
+ -------
839
+ dict[str, Any]
840
+ Serialized ErrorResponse
841
+ """
842
+ details = self.error_handler.handle_error(
843
+ error, client_id=self.client_id
844
+ )
845
+ return create_error_response(
846
+ error_message=details.get("message", "An error occurred"),
847
+ error_code=int(details.get("code", 500)),
848
+ error_type=str(details.get("error_type", "InternalError")),
849
+ request_id=request_id,
850
+ details=details.get("details", {}),
851
+ ).model_dump(mode="json")