ralphx 0.3.4__py3-none-any.whl → 0.4.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.
Files changed (48) hide show
  1. ralphx/__init__.py +1 -1
  2. ralphx/adapters/base.py +10 -2
  3. ralphx/adapters/claude_cli.py +222 -82
  4. ralphx/api/routes/auth.py +780 -98
  5. ralphx/api/routes/config.py +3 -56
  6. ralphx/api/routes/export_import.py +6 -9
  7. ralphx/api/routes/loops.py +4 -4
  8. ralphx/api/routes/planning.py +882 -19
  9. ralphx/api/routes/resources.py +528 -6
  10. ralphx/api/routes/stream.py +58 -56
  11. ralphx/api/routes/templates.py +2 -2
  12. ralphx/api/routes/workflows.py +258 -47
  13. ralphx/cli.py +4 -1
  14. ralphx/core/auth.py +372 -172
  15. ralphx/core/database.py +588 -164
  16. ralphx/core/executor.py +170 -19
  17. ralphx/core/loop.py +15 -2
  18. ralphx/core/loop_templates.py +29 -3
  19. ralphx/core/planning_iteration_executor.py +633 -0
  20. ralphx/core/planning_service.py +119 -24
  21. ralphx/core/preview.py +9 -25
  22. ralphx/core/project_db.py +864 -121
  23. ralphx/core/project_export.py +1 -5
  24. ralphx/core/project_import.py +14 -29
  25. ralphx/core/resources.py +28 -2
  26. ralphx/core/sample_project.py +1 -5
  27. ralphx/core/templates.py +9 -9
  28. ralphx/core/workflow_executor.py +32 -3
  29. ralphx/core/workflow_export.py +4 -7
  30. ralphx/core/workflow_import.py +3 -27
  31. ralphx/mcp/__init__.py +6 -2
  32. ralphx/mcp/registry.py +3 -3
  33. ralphx/mcp/tools/diagnostics.py +1 -1
  34. ralphx/mcp/tools/monitoring.py +10 -16
  35. ralphx/mcp/tools/workflows.py +115 -33
  36. ralphx/mcp_server.py +6 -2
  37. ralphx/static/assets/index-BuLI7ffn.css +1 -0
  38. ralphx/static/assets/index-DWvlqOTb.js +264 -0
  39. ralphx/static/assets/index-DWvlqOTb.js.map +1 -0
  40. ralphx/static/index.html +2 -2
  41. ralphx/templates/loop_templates/consumer.md +2 -2
  42. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/METADATA +33 -12
  43. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/RECORD +45 -44
  44. ralphx/static/assets/index-CcRDyY3b.css +0 -1
  45. ralphx/static/assets/index-CcxfTosc.js +0 -251
  46. ralphx/static/assets/index-CcxfTosc.js.map +0 -1
  47. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/WHEEL +0 -0
  48. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/entry_points.txt +0 -0
ralphx/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """RalphX - Generic agent loop orchestration system."""
2
2
 
3
- __version__ = "0.3.4"
3
+ __version__ = "0.4.0"
4
4
  __author__ = "Jack"
5
5
 
6
6
  # Package metadata
ralphx/adapters/base.py CHANGED
@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
5
5
  from datetime import datetime
6
6
  from enum import Enum
7
7
  from pathlib import Path
8
- from typing import Any, AsyncIterator, Optional
8
+ from typing import Any, AsyncIterator, Callable, Optional
9
9
 
10
10
 
11
11
  class AdapterEvent(str, Enum):
@@ -100,6 +100,8 @@ class LLMAdapter(ABC):
100
100
  tools: Optional[list[str]] = None,
101
101
  timeout: int = 300,
102
102
  json_schema: Optional[dict] = None,
103
+ on_session_start: Optional[Callable[[str], None]] = None,
104
+ on_event: Optional[Callable[["StreamEvent"], None]] = None,
103
105
  ) -> ExecutionResult:
104
106
  """Execute a prompt and return the result.
105
107
 
@@ -110,6 +112,8 @@ class LLMAdapter(ABC):
110
112
  timeout: Timeout in seconds.
111
113
  json_schema: Optional JSON schema for structured output validation.
112
114
  When provided, the result will include structured_output.
115
+ on_session_start: Optional callback fired when session ID is available.
116
+ on_event: Optional callback fired for each streaming event (for persistence).
113
117
 
114
118
  Returns:
115
119
  ExecutionResult with session info and output.
@@ -174,6 +178,10 @@ class LLMAdapter(ABC):
174
178
  Marker string to append to prompt.
175
179
  """
176
180
  now = datetime.utcnow().isoformat()
181
+ # Sanitize values to prevent HTML comment injection (e.g., --> in mode name)
182
+ safe_run_id = run_id.replace("--", "").replace('"', "")
183
+ safe_slug = project_slug.replace("--", "").replace('"', "")
184
+ safe_mode = mode.replace("--", "").replace('"', "")
177
185
  return f"""
178
186
 
179
- <!-- RALPHX_TRACKING run_id="{run_id}" project="{project_slug}" iteration={iteration} mode="{mode}" ts="{now}" -->"""
187
+ <!-- RALPHX_TRACKING run_id="{safe_run_id}" project="{safe_slug}" iteration={iteration} mode="{safe_mode}" ts="{now}" -->"""
@@ -115,10 +115,16 @@ class ClaudeCLIAdapter(LLMAdapter):
115
115
  if self._settings_path and self._settings_path.exists():
116
116
  cmd.extend(["--settings", str(self._settings_path)])
117
117
 
118
- # Add allowed tools
118
+ # Configure available tools
119
+ # --tools specifies which built-in tools are available
120
+ # --allowedTools is for fine-grained permission patterns like Bash(git:*)
119
121
  if tools:
120
- for tool in tools:
121
- cmd.extend(["--allowedTools", tool])
122
+ # Enable specific tools
123
+ cmd.extend(["--tools", ",".join(tools)])
124
+ else:
125
+ # Disable all built-in tools to prevent Claude from trying to use them
126
+ # Without this, Claude may try to use Read/Edit/etc and hit API errors
127
+ cmd.extend(["--tools", ""])
122
128
 
123
129
  return cmd
124
130
 
@@ -130,6 +136,7 @@ class ClaudeCLIAdapter(LLMAdapter):
130
136
  timeout: int = 300,
131
137
  json_schema: Optional[dict] = None,
132
138
  on_session_start: Optional[Callable[[str], None]] = None,
139
+ on_event: Optional[Callable[[StreamEvent], None]] = None,
133
140
  ) -> ExecutionResult:
134
141
  """Execute a prompt and return the result.
135
142
 
@@ -145,7 +152,10 @@ class ClaudeCLIAdapter(LLMAdapter):
145
152
  """
146
153
  # When using json_schema, use dedicated non-streaming execution
147
154
  if json_schema:
148
- return await self._execute_with_schema(prompt, model, tools, timeout, json_schema)
155
+ return await self._execute_with_schema(
156
+ prompt, model, tools, timeout, json_schema,
157
+ on_session_start=on_session_start, on_event=on_event,
158
+ )
149
159
 
150
160
  # Standard streaming execution
151
161
  import logging
@@ -176,10 +186,20 @@ class ClaudeCLIAdapter(LLMAdapter):
176
186
  elif event.type == AdapterEvent.COMPLETE:
177
187
  result.exit_code = event.data.get("exit_code", 0)
178
188
 
189
+ # Fire on_event callback for event persistence
190
+ if on_event:
191
+ on_event(event)
192
+
179
193
  except asyncio.TimeoutError:
180
194
  result.timeout = True
181
195
  result.success = False
182
196
  result.error_message = f"Execution timed out after {timeout}s"
197
+ if on_event:
198
+ on_event(StreamEvent(
199
+ type=AdapterEvent.ERROR,
200
+ error_message=result.error_message,
201
+ error_code="TIMEOUT",
202
+ ))
183
203
  await self.stop()
184
204
 
185
205
  result.completed_at = datetime.utcnow()
@@ -200,6 +220,8 @@ class ClaudeCLIAdapter(LLMAdapter):
200
220
  tools: Optional[list[str]],
201
221
  timeout: int,
202
222
  json_schema: dict,
223
+ on_session_start: Optional[Callable[[str], None]] = None,
224
+ on_event: Optional[Callable[[StreamEvent], None]] = None,
203
225
  ) -> ExecutionResult:
204
226
  """Execute with JSON schema for structured output.
205
227
 
@@ -277,6 +299,35 @@ class ClaudeCLIAdapter(LLMAdapter):
277
299
  # Extract text from result if available
278
300
  result.text_output = data.get("result", "")
279
301
 
302
+ # Fire callbacks for event persistence
303
+ if on_session_start and result.session_id:
304
+ on_session_start(result.session_id)
305
+ if on_event:
306
+ # Save metadata about the execution
307
+ on_event(StreamEvent(
308
+ type=AdapterEvent.INIT,
309
+ data={
310
+ "session_id": result.session_id,
311
+ "num_turns": data.get("num_turns"),
312
+ "cost_usd": data.get("cost_usd"),
313
+ "is_error": data.get("is_error"),
314
+ },
315
+ ))
316
+ # Save the result text
317
+ if result.text_output:
318
+ on_event(StreamEvent(
319
+ type=AdapterEvent.TEXT,
320
+ text=result.text_output,
321
+ ))
322
+ # Save completion/error
323
+ if result.success:
324
+ on_event(StreamEvent(type=AdapterEvent.COMPLETE))
325
+ elif result.error_message:
326
+ on_event(StreamEvent(
327
+ type=AdapterEvent.ERROR,
328
+ error_message=result.error_message,
329
+ ))
330
+
280
331
  except json.JSONDecodeError as e:
281
332
  result.success = False
282
333
  result.error_message = f"Failed to parse JSON output: {e}"
@@ -332,6 +383,10 @@ class ClaudeCLIAdapter(LLMAdapter):
332
383
  Yields:
333
384
  StreamEvent objects as execution progresses.
334
385
  """
386
+ # Reset session_id to prevent stale values from previous executions
387
+ # leaking into results when the new execution fails before INIT
388
+ self._session_id = None
389
+
335
390
  # Validate and refresh token if needed (before spawning)
336
391
  # Use validate=True to actually test the token works
337
392
  if not await refresh_token_if_needed(self._project_id, validate=True):
@@ -354,6 +409,15 @@ class ClaudeCLIAdapter(LLMAdapter):
354
409
 
355
410
  cmd = self._build_command(model, tools)
356
411
 
412
+ # Log the exact command for debugging concurrency issues
413
+ import logging
414
+ _cli_log = logging.getLogger(__name__)
415
+ _cli_log.warning(f"[CLAUDE_CLI] Running command: {' '.join(cmd)}")
416
+ _cli_log.warning(f"[CLAUDE_CLI] Working dir: {self.project_path}")
417
+ _cli_log.warning(f"[CLAUDE_CLI] Tools: {tools}")
418
+ _cli_log.warning(f"[CLAUDE_CLI] Prompt length: {len(prompt)} chars")
419
+ _cli_log.warning(f"[CLAUDE_CLI] Prompt preview: {prompt[:500]}...")
420
+
357
421
  # Start the process
358
422
  self._process = await asyncio.create_subprocess_exec(
359
423
  *cmd,
@@ -361,6 +425,7 @@ class ClaudeCLIAdapter(LLMAdapter):
361
425
  stdout=asyncio.subprocess.PIPE,
362
426
  stderr=asyncio.subprocess.PIPE,
363
427
  cwd=str(self.project_path),
428
+ limit=4 * 1024 * 1024, # 4MB buffer for large JSON lines (e.g. Edit tool inputs)
364
429
  )
365
430
 
366
431
  # Send the prompt
@@ -392,53 +457,98 @@ class ClaudeCLIAdapter(LLMAdapter):
392
457
  return b"".join(chunks)
393
458
 
394
459
  try:
395
- async with asyncio.timeout(timeout):
396
- # Start stderr drain early to prevent buffer deadlock
397
- if self._process.stderr:
398
- stderr_task = asyncio.create_task(drain_stderr())
399
-
400
- if self._process.stdout:
401
- import logging
402
- _stream_log = logging.getLogger(__name__)
403
- line_count = 0
404
- text_events = 0
405
- async for line in self._process.stdout:
406
- line_count += 1
407
- try:
408
- line_text = line.decode(errors="replace").strip()
409
- except Exception:
410
- continue # Skip lines that can't be decoded
411
- if not line_text:
412
- continue
413
-
414
- try:
415
- data = json.loads(line_text)
416
- msg_type = data.get("type", "unknown")
417
- _stream_log.warning(f"[STREAM] Line {line_count}: type={msg_type}")
418
-
419
- event = self._parse_event(data)
420
- if event:
421
- if event.type == AdapterEvent.TEXT:
422
- text_events += 1
423
- text_preview = (event.text or "")[:80].replace("\n", "\\n")
424
- _stream_log.warning(f"[STREAM] TEXT #{text_events}: {len(event.text or '')} chars, preview: {text_preview}")
425
- yield event
426
- except json.JSONDecodeError:
427
- # Non-JSON output, treat as plain text
428
- yield StreamEvent(
429
- type=AdapterEvent.TEXT,
430
- text=line_text,
431
- )
432
- _stream_log.warning(f"[STREAM] Done: {line_count} lines, {text_events} TEXT events")
460
+ # Start stderr drain early to prevent buffer deadlock
461
+ if self._process.stderr:
462
+ stderr_task = asyncio.create_task(drain_stderr())
463
+
464
+ if self._process.stdout:
465
+ import logging
466
+ import time
467
+ _stream_log = logging.getLogger(__name__)
468
+ line_count = 0
469
+ text_events = 0
470
+
471
+ # Two timeout strategies:
472
+ # 1. line_timeout: Max time to wait for ANY output (prevents deadlock)
473
+ # 2. meaningful_timeout: Max time since last meaningful event (TEXT/TOOL_USE/TOOL_RESULT)
474
+ line_timeout = 30 # 30s max wait for any line
475
+ meaningful_timeout = min(max(timeout - 30, 60), 270) # Scale with timeout param, min 60s, max 4.5 min
476
+ last_meaningful_time = time.time()
477
+
478
+ async def read_line_with_timeout():
479
+ """Read a line with timeout."""
480
+ return await asyncio.wait_for(
481
+ self._process.stdout.readline(),
482
+ timeout=line_timeout
483
+ )
433
484
 
434
- # Wait for process to complete
435
- await self._process.wait()
485
+ while True:
486
+ # Check meaningful event timeout
487
+ time_since_meaningful = time.time() - last_meaningful_time
488
+ if time_since_meaningful > meaningful_timeout:
489
+ _stream_log.warning(f"[STREAM] Meaningful event timeout after {time_since_meaningful:.0f}s")
490
+ raise asyncio.TimeoutError(f"No meaningful output for {meaningful_timeout}s")
491
+
492
+ try:
493
+ line = await read_line_with_timeout()
494
+ if not line: # EOF
495
+ break
496
+ except asyncio.TimeoutError:
497
+ # Check if it's been too long since meaningful event
498
+ time_since_meaningful = time.time() - last_meaningful_time
499
+ if time_since_meaningful > meaningful_timeout:
500
+ _stream_log.warning(f"[STREAM] Meaningful event timeout after {time_since_meaningful:.0f}s")
501
+ raise
502
+ # Otherwise keep waiting - Claude might be working on a tool
503
+ _stream_log.info(f"[STREAM] No line for {line_timeout}s, but meaningful event was {time_since_meaningful:.0f}s ago, continuing...")
504
+ continue
505
+
506
+ line_count += 1
507
+ try:
508
+ line_text = line.decode(errors="replace").strip()
509
+ except Exception:
510
+ continue # Skip lines that can't be decoded
511
+ if not line_text:
512
+ continue
513
+
514
+ try:
515
+ data = json.loads(line_text)
516
+ msg_type = data.get("type", "unknown")
517
+ # Log full event structure for debugging
518
+ _stream_log.warning(f"[STREAM] Line {line_count}: type={msg_type}, keys={list(data.keys())}")
519
+ if msg_type not in ("content_block_delta", "text"):
520
+ _stream_log.warning(f"[STREAM] Full event: {json.dumps(data)[:500]}")
521
+
522
+ # Parse events (may return multiple for assistant messages with multiple blocks)
523
+ for event in self._parse_events(data):
524
+ # Reset meaningful timeout on actual content
525
+ if event.type in (AdapterEvent.TEXT, AdapterEvent.TOOL_USE, AdapterEvent.TOOL_RESULT, AdapterEvent.INIT):
526
+ last_meaningful_time = time.time()
527
+
528
+ if event.type == AdapterEvent.TEXT:
529
+ text_events += 1
530
+ text_preview = (event.text or "")[:80].replace("\n", "\\n")
531
+ _stream_log.warning(f"[STREAM] TEXT #{text_events}: {len(event.text or '')} chars, preview: {text_preview}")
532
+ elif event.type == AdapterEvent.TOOL_USE:
533
+ _stream_log.warning(f"[STREAM] TOOL_USE: {event.tool_name}")
534
+ yield event
535
+ except json.JSONDecodeError:
536
+ # Non-JSON output, treat as plain text
537
+ last_meaningful_time = time.time() # Plain text is meaningful
538
+ yield StreamEvent(
539
+ type=AdapterEvent.TEXT,
540
+ text=line_text,
541
+ )
542
+ _stream_log.warning(f"[STREAM] Done: {line_count} lines, {text_events} TEXT events")
543
+
544
+ # Wait for process to complete
545
+ await self._process.wait()
436
546
 
437
- # Collect stderr result
438
- if stderr_task:
439
- stderr_data = await stderr_task
440
- if stderr_data:
441
- stderr_content.append(stderr_data.decode(errors="replace").strip())
547
+ # Collect stderr result
548
+ if stderr_task:
549
+ stderr_data = await stderr_task
550
+ if stderr_data:
551
+ stderr_content.append(stderr_data.decode(errors="replace").strip())
442
552
 
443
553
  except asyncio.TimeoutError:
444
554
  # Cancel stderr task if still running
@@ -450,16 +560,19 @@ class ClaudeCLIAdapter(LLMAdapter):
450
560
  pass
451
561
  yield StreamEvent(
452
562
  type=AdapterEvent.ERROR,
453
- error_message=f"Timeout after {timeout}s",
563
+ error_message="Stream timed out - no response for too long",
454
564
  error_code="TIMEOUT",
455
565
  )
456
566
  await self.stop()
457
- raise
567
+ return # Don't re-raise - we handled it by yielding ERROR
458
568
 
459
569
  # Emit error if non-zero exit code or stderr content
460
570
  exit_code = self._process.returncode or 0
461
571
  if exit_code != 0 or stderr_content:
462
572
  stderr_text = "\n".join(stderr_content)
573
+ # Log full stderr for debugging (before truncation)
574
+ _cli_log.warning(f"[CLAUDE_CLI] Exit code: {exit_code}")
575
+ _cli_log.warning(f"[CLAUDE_CLI] Full stderr ({len(stderr_text)} chars): {stderr_text}")
463
576
  error_msg = f"Claude CLI error (exit {exit_code})"
464
577
  if stderr_text:
465
578
  # Truncate stderr to 500 chars with indicator
@@ -481,95 +594,122 @@ class ClaudeCLIAdapter(LLMAdapter):
481
594
 
482
595
  self._process = None
483
596
 
484
- def _parse_event(self, data: dict) -> Optional[StreamEvent]:
485
- """Parse a stream-json event into a StreamEvent.
597
+ def _parse_events(self, data: dict) -> list[StreamEvent]:
598
+ """Parse a stream-json event into StreamEvent(s).
486
599
 
487
600
  Args:
488
601
  data: Parsed JSON data from stdout.
489
602
 
490
603
  Returns:
491
- StreamEvent or None if not recognized.
604
+ List of StreamEvents (may be empty if not recognized).
492
605
  """
606
+ events = []
493
607
  msg_type = data.get("type")
494
608
 
495
609
  # Init message with session ID (only for system/init events)
496
610
  if msg_type in ("init", "system"):
497
611
  self._session_id = data.get("session_id")
498
- return StreamEvent(
612
+ events.append(StreamEvent(
499
613
  type=AdapterEvent.INIT,
500
614
  data={"session_id": self._session_id},
501
- )
615
+ ))
616
+ return events
502
617
 
503
- # Content block events
618
+ # Content block events (streaming API format)
504
619
  if msg_type == "content_block_delta":
505
620
  delta = data.get("delta", {})
506
621
  delta_type = delta.get("type")
507
622
 
508
623
  if delta_type == "text_delta":
509
- return StreamEvent(
624
+ events.append(StreamEvent(
510
625
  type=AdapterEvent.TEXT,
511
626
  text=delta.get("text", ""),
512
- )
627
+ ))
513
628
 
514
- if delta_type == "input_json_delta":
515
- # Tool input being streamed
516
- return None # Accumulate in content_block_stop
629
+ return events
517
630
 
518
631
  if msg_type == "content_block_start":
519
632
  content_block = data.get("content_block", {})
520
633
  if content_block.get("type") == "tool_use":
521
- return StreamEvent(
634
+ events.append(StreamEvent(
522
635
  type=AdapterEvent.TOOL_USE,
523
636
  tool_name=content_block.get("name"),
524
637
  tool_input=content_block.get("input", {}),
525
- )
638
+ ))
639
+ return events
526
640
 
527
641
  # Tool result (from Claude Code's output)
528
642
  if msg_type == "tool_result":
529
- return StreamEvent(
643
+ events.append(StreamEvent(
530
644
  type=AdapterEvent.TOOL_RESULT,
531
645
  tool_name=data.get("name"),
532
646
  tool_result=data.get("result"),
533
- )
647
+ ))
648
+ return events
534
649
 
535
650
  # Error events
536
651
  if msg_type == "error":
537
- return StreamEvent(
652
+ events.append(StreamEvent(
538
653
  type=AdapterEvent.ERROR,
539
654
  error_message=data.get("message", "Unknown error"),
540
655
  error_code=data.get("code"),
541
- )
656
+ ))
657
+ return events
542
658
 
543
659
  # Assistant message with content
544
660
  # Claude Code stream-json format: {"type": "assistant", "message": {"content": [...]}}
661
+ # Can contain multiple content blocks (text, tool_use, etc.) - emit ALL of them
545
662
  if msg_type == "assistant":
546
663
  message = data.get("message", {})
547
664
  content = message.get("content") or data.get("content")
548
665
  if isinstance(content, list):
549
666
  for block in content:
550
- if block.get("type") == "text":
551
- return StreamEvent(
552
- type=AdapterEvent.TEXT,
553
- text=block.get("text", ""),
554
- )
667
+ block_type = block.get("type")
668
+ if block_type == "tool_use":
669
+ events.append(StreamEvent(
670
+ type=AdapterEvent.TOOL_USE,
671
+ tool_name=block.get("name"),
672
+ tool_input=block.get("input", {}),
673
+ ))
674
+ elif block_type == "text":
675
+ text = block.get("text", "")
676
+ if text: # Only emit non-empty text
677
+ events.append(StreamEvent(
678
+ type=AdapterEvent.TEXT,
679
+ text=text,
680
+ ))
681
+ return events
682
+
683
+ # Tool result from Claude CLI (sent as user message with nested tool_result blocks)
684
+ if msg_type == "user":
685
+ message = data.get("message", {})
686
+ content = message.get("content", [])
687
+ if isinstance(content, list):
688
+ for block in content:
689
+ if isinstance(block, dict) and block.get("type") == "tool_result":
690
+ result_text = block.get("content", "")
691
+ events.append(StreamEvent(
692
+ type=AdapterEvent.TOOL_RESULT,
693
+ tool_name=None,
694
+ tool_result=str(result_text)[:500],
695
+ ))
696
+ return events
555
697
 
556
698
  # Result event contains the complete output (final message)
699
+ # Don't duplicate - the assistant message already has the text
557
700
  if msg_type == "result":
558
- result_text = data.get("result", "")
559
- if result_text:
560
- return StreamEvent(
561
- type=AdapterEvent.TEXT,
562
- text=result_text,
563
- )
701
+ # Only emit if we haven't seen any text yet (edge case)
702
+ pass
564
703
 
565
704
  # Message completion
566
705
  if msg_type == "message_stop":
567
- return StreamEvent(
706
+ events.append(StreamEvent(
568
707
  type=AdapterEvent.COMPLETE,
569
708
  data={"session_id": self._session_id},
570
- )
709
+ ))
710
+ return events
571
711
 
572
- return None
712
+ return events
573
713
 
574
714
  @staticmethod
575
715
  def is_available() -> bool: