droidrun 0.1.0__py3-none-any.whl → 0.3.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.
- droidrun/__init__.py +22 -10
- droidrun/__main__.py +1 -2
- droidrun/adb/__init__.py +3 -3
- droidrun/adb/device.py +2 -2
- droidrun/adb/manager.py +2 -2
- droidrun/agent/__init__.py +5 -15
- droidrun/agent/codeact/__init__.py +11 -0
- droidrun/agent/codeact/codeact_agent.py +420 -0
- droidrun/agent/codeact/events.py +28 -0
- droidrun/agent/codeact/prompts.py +26 -0
- droidrun/agent/common/default.py +5 -0
- droidrun/agent/common/events.py +4 -0
- droidrun/agent/context/__init__.py +23 -0
- droidrun/agent/context/agent_persona.py +15 -0
- droidrun/agent/context/context_injection_manager.py +66 -0
- droidrun/agent/context/episodic_memory.py +15 -0
- droidrun/agent/context/personas/__init__.py +11 -0
- droidrun/agent/context/personas/app_starter.py +44 -0
- droidrun/agent/context/personas/default.py +95 -0
- droidrun/agent/context/personas/extractor.py +52 -0
- droidrun/agent/context/personas/ui_expert.py +107 -0
- droidrun/agent/context/reflection.py +20 -0
- droidrun/agent/context/task_manager.py +124 -0
- droidrun/agent/context/todo.txt +4 -0
- droidrun/agent/droid/__init__.py +13 -0
- droidrun/agent/droid/droid_agent.py +357 -0
- droidrun/agent/droid/events.py +28 -0
- droidrun/agent/oneflows/reflector.py +265 -0
- droidrun/agent/planner/__init__.py +13 -0
- droidrun/agent/planner/events.py +16 -0
- droidrun/agent/planner/planner_agent.py +268 -0
- droidrun/agent/planner/prompts.py +124 -0
- droidrun/agent/utils/__init__.py +3 -0
- droidrun/agent/utils/async_utils.py +17 -0
- droidrun/agent/utils/chat_utils.py +312 -0
- droidrun/agent/utils/executer.py +132 -0
- droidrun/agent/utils/llm_picker.py +147 -0
- droidrun/agent/utils/trajectory.py +184 -0
- droidrun/cli/__init__.py +1 -1
- droidrun/cli/logs.py +283 -0
- droidrun/cli/main.py +358 -149
- droidrun/run.py +105 -0
- droidrun/tools/__init__.py +4 -30
- droidrun/tools/adb.py +879 -0
- droidrun/tools/ios.py +594 -0
- droidrun/tools/tools.py +99 -0
- droidrun-0.3.0.dist-info/METADATA +149 -0
- droidrun-0.3.0.dist-info/RECORD +52 -0
- droidrun/agent/llm_reasoning.py +0 -567
- droidrun/agent/react_agent.py +0 -556
- droidrun/llm/__init__.py +0 -24
- droidrun/tools/actions.py +0 -854
- droidrun/tools/device.py +0 -29
- droidrun-0.1.0.dist-info/METADATA +0 -276
- droidrun-0.1.0.dist-info/RECORD +0 -20
- {droidrun-0.1.0.dist-info → droidrun-0.3.0.dist-info}/WHEEL +0 -0
- {droidrun-0.1.0.dist-info → droidrun-0.3.0.dist-info}/entry_points.txt +0 -0
- {droidrun-0.1.0.dist-info → droidrun-0.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,184 @@
|
|
1
|
+
"""
|
2
|
+
Trajectory utilities for DroidRun agents.
|
3
|
+
|
4
|
+
This module provides helper functions for working with agent trajectories,
|
5
|
+
including saving, loading, and analyzing them.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import json
|
9
|
+
import logging
|
10
|
+
import os
|
11
|
+
import time
|
12
|
+
from typing import Dict, List, Any
|
13
|
+
from PIL import Image
|
14
|
+
import io
|
15
|
+
from llama_index.core.workflow import Event
|
16
|
+
|
17
|
+
logger = logging.getLogger("droidrun")
|
18
|
+
|
19
|
+
class Trajectory:
|
20
|
+
|
21
|
+
def __init__(self):
|
22
|
+
"""Initializes an empty trajectory class."""
|
23
|
+
self.events: List[Event] = []
|
24
|
+
self.screenshots: List[bytes] = []
|
25
|
+
|
26
|
+
|
27
|
+
def create_screenshot_gif(self, output_path: str, duration: int = 1000) -> str:
|
28
|
+
"""
|
29
|
+
Create a GIF from a list of screenshots.
|
30
|
+
|
31
|
+
Args:
|
32
|
+
output_path: Base path for the GIF (without extension)
|
33
|
+
duration: Duration for each frame in milliseconds
|
34
|
+
|
35
|
+
Returns:
|
36
|
+
Path to the created GIF file
|
37
|
+
"""
|
38
|
+
if len(self.screenshots) == 0:
|
39
|
+
return None
|
40
|
+
|
41
|
+
images = []
|
42
|
+
for screenshot in self.screenshots:
|
43
|
+
img_data = screenshot
|
44
|
+
img = Image.open(io.BytesIO(img_data))
|
45
|
+
images.append(img)
|
46
|
+
|
47
|
+
# Save as GIF
|
48
|
+
gif_path = f"{output_path}.gif"
|
49
|
+
images[0].save(
|
50
|
+
gif_path,
|
51
|
+
save_all=True,
|
52
|
+
append_images=images[1:],
|
53
|
+
duration=duration,
|
54
|
+
loop=0
|
55
|
+
)
|
56
|
+
|
57
|
+
return gif_path
|
58
|
+
|
59
|
+
def save_trajectory(
|
60
|
+
self,
|
61
|
+
directory: str = "trajectories",
|
62
|
+
) -> str:
|
63
|
+
"""
|
64
|
+
Save trajectory steps to a JSON file and create a GIF of screenshots if available.
|
65
|
+
|
66
|
+
Args:
|
67
|
+
directory: Directory to save the trajectory files
|
68
|
+
|
69
|
+
Returns:
|
70
|
+
Path to the saved trajectory file
|
71
|
+
"""
|
72
|
+
os.makedirs(directory, exist_ok=True)
|
73
|
+
|
74
|
+
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
75
|
+
base_path = os.path.join(directory, f"trajectory_{timestamp}")
|
76
|
+
|
77
|
+
def make_serializable(obj):
|
78
|
+
"""Recursively make objects JSON serializable."""
|
79
|
+
if hasattr(obj, "__class__") and obj.__class__.__name__ == "ChatMessage":
|
80
|
+
# Extract the text content from the ChatMessage
|
81
|
+
if hasattr(obj, "content") and obj.content is not None:
|
82
|
+
return {"role": obj.role.value, "content": obj.content}
|
83
|
+
# If content is not available, try extracting from blocks
|
84
|
+
elif hasattr(obj, "blocks") and obj.blocks:
|
85
|
+
text_content = ""
|
86
|
+
for block in obj.blocks:
|
87
|
+
if hasattr(block, "text"):
|
88
|
+
text_content += block.text
|
89
|
+
return {"role": obj.role.value, "content": text_content}
|
90
|
+
else:
|
91
|
+
return str(obj)
|
92
|
+
elif isinstance(obj, dict):
|
93
|
+
return {k: make_serializable(v) for k, v in obj.items()}
|
94
|
+
elif isinstance(obj, list):
|
95
|
+
return [make_serializable(item) for item in obj]
|
96
|
+
elif hasattr(obj, "__dict__"):
|
97
|
+
# Handle other custom objects by converting to dict
|
98
|
+
return {k: make_serializable(v) for k, v in obj.__dict__.items()
|
99
|
+
if not k.startswith('_')}
|
100
|
+
else:
|
101
|
+
return obj
|
102
|
+
|
103
|
+
serializable_events = []
|
104
|
+
for event in self.events:
|
105
|
+
event_dict = {
|
106
|
+
"type": event.__class__.__name__,
|
107
|
+
**{k: make_serializable(v) for k, v in event.__dict__.items()
|
108
|
+
if not k.startswith('_')}
|
109
|
+
}
|
110
|
+
serializable_events.append(event_dict)
|
111
|
+
|
112
|
+
json_path = f"{base_path}.json"
|
113
|
+
with open(json_path, "w") as f:
|
114
|
+
json.dump(serializable_events, f, indent=2)
|
115
|
+
|
116
|
+
self.create_screenshot_gif(base_path)
|
117
|
+
|
118
|
+
return json_path
|
119
|
+
|
120
|
+
def get_trajectory_statistics(trajectory_data: Dict[str, Any]) -> Dict[str, Any]:
|
121
|
+
"""
|
122
|
+
Get statistics about a trajectory.
|
123
|
+
|
124
|
+
Args:
|
125
|
+
trajectory_data: The trajectory data dictionary
|
126
|
+
|
127
|
+
Returns:
|
128
|
+
Dictionary with statistics about the trajectory
|
129
|
+
"""
|
130
|
+
trajectory_steps = trajectory_data.get("trajectory_steps", [])
|
131
|
+
|
132
|
+
# Count different types of steps
|
133
|
+
step_types = {}
|
134
|
+
for step in trajectory_steps:
|
135
|
+
step_type = step.get("type", "unknown")
|
136
|
+
step_types[step_type] = step_types.get(step_type, 0) + 1
|
137
|
+
|
138
|
+
# Count planning vs execution steps
|
139
|
+
planning_steps = sum(count for step_type, count in step_types.items()
|
140
|
+
if step_type.startswith("planner_"))
|
141
|
+
execution_steps = sum(count for step_type, count in step_types.items()
|
142
|
+
if step_type.startswith("codeact_"))
|
143
|
+
|
144
|
+
# Count successful vs failed executions
|
145
|
+
successful_executions = sum(1 for step in trajectory_steps
|
146
|
+
if step.get("type") == "codeact_execution"
|
147
|
+
and step.get("success", False))
|
148
|
+
failed_executions = sum(1 for step in trajectory_steps
|
149
|
+
if step.get("type") == "codeact_execution"
|
150
|
+
and not step.get("success", True))
|
151
|
+
|
152
|
+
# Return statistics
|
153
|
+
return {
|
154
|
+
"total_steps": len(trajectory_steps),
|
155
|
+
"step_types": step_types,
|
156
|
+
"planning_steps": planning_steps,
|
157
|
+
"execution_steps": execution_steps,
|
158
|
+
"successful_executions": successful_executions,
|
159
|
+
"failed_executions": failed_executions,
|
160
|
+
"goal_achieved": trajectory_data.get("success", False)
|
161
|
+
}
|
162
|
+
|
163
|
+
def print_trajectory_summary(self, trajectory_data: Dict[str, Any]) -> None:
|
164
|
+
"""
|
165
|
+
Print a summary of a trajectory.
|
166
|
+
|
167
|
+
Args:
|
168
|
+
trajectory_data: The trajectory data dictionary
|
169
|
+
"""
|
170
|
+
stats = self.get_trajectory_statistics(trajectory_data)
|
171
|
+
|
172
|
+
print("=== Trajectory Summary ===")
|
173
|
+
print(f"Goal: {trajectory_data.get('goal', 'Unknown')}")
|
174
|
+
print(f"Success: {trajectory_data.get('success', False)}")
|
175
|
+
print(f"Reason: {trajectory_data.get('reason', 'Unknown')}")
|
176
|
+
print(f"Total steps: {stats['total_steps']}")
|
177
|
+
print("Step breakdown:")
|
178
|
+
for step_type, count in stats['step_types'].items():
|
179
|
+
print(f" - {step_type}: {count}")
|
180
|
+
print(f"Planning steps: {stats['planning_steps']}")
|
181
|
+
print(f"Execution steps: {stats['execution_steps']}")
|
182
|
+
print(f"Successful executions: {stats['successful_executions']}")
|
183
|
+
print(f"Failed executions: {stats['failed_executions']}")
|
184
|
+
print("==========================")
|
droidrun/cli/__init__.py
CHANGED
droidrun/cli/logs.py
ADDED
@@ -0,0 +1,283 @@
|
|
1
|
+
import logging
|
2
|
+
from rich.layout import Layout
|
3
|
+
from rich.panel import Panel
|
4
|
+
from rich.spinner import Spinner
|
5
|
+
from rich.console import Console
|
6
|
+
from rich.live import Live
|
7
|
+
from typing import List
|
8
|
+
|
9
|
+
from droidrun.agent.common.events import ScreenshotEvent
|
10
|
+
from droidrun.agent.planner.events import (
|
11
|
+
PlanInputEvent,
|
12
|
+
PlanThinkingEvent,
|
13
|
+
PlanCreatedEvent,
|
14
|
+
)
|
15
|
+
from droidrun.agent.codeact.events import (
|
16
|
+
TaskInputEvent,
|
17
|
+
TaskThinkingEvent,
|
18
|
+
TaskExecutionEvent,
|
19
|
+
TaskExecutionResultEvent,
|
20
|
+
TaskEndEvent,
|
21
|
+
)
|
22
|
+
from droidrun.agent.droid.events import (
|
23
|
+
CodeActExecuteEvent,
|
24
|
+
CodeActResultEvent,
|
25
|
+
ReasoningLogicEvent,
|
26
|
+
TaskRunnerEvent,
|
27
|
+
FinalizeEvent,
|
28
|
+
)
|
29
|
+
|
30
|
+
|
31
|
+
class LogHandler(logging.Handler):
|
32
|
+
def __init__(self, goal: str, current_step: str = "Initializing..."):
|
33
|
+
super().__init__()
|
34
|
+
|
35
|
+
self.goal = goal
|
36
|
+
self.current_step = current_step
|
37
|
+
self.is_completed = False
|
38
|
+
self.is_success = False
|
39
|
+
self.spinner = Spinner("dots")
|
40
|
+
self.console = Console()
|
41
|
+
self.layout = self._create_layout()
|
42
|
+
self.logs: List[str] = []
|
43
|
+
|
44
|
+
def emit(self, record):
|
45
|
+
msg = self.format(record)
|
46
|
+
lines = msg.splitlines()
|
47
|
+
|
48
|
+
for line in lines:
|
49
|
+
self.logs.append(line)
|
50
|
+
# Optionally, limit the log list size
|
51
|
+
if len(self.logs) > 100:
|
52
|
+
self.logs.pop(0)
|
53
|
+
|
54
|
+
self.rerender()
|
55
|
+
|
56
|
+
def render(self):
|
57
|
+
return Live(self.layout, refresh_per_second=4, console=self.console)
|
58
|
+
|
59
|
+
def rerender(self):
|
60
|
+
self._update_layout(
|
61
|
+
self.layout,
|
62
|
+
self.logs,
|
63
|
+
self.current_step,
|
64
|
+
self.goal,
|
65
|
+
self.is_completed,
|
66
|
+
self.is_success,
|
67
|
+
)
|
68
|
+
|
69
|
+
def update_step(self, step: str):
|
70
|
+
self.current_step = step
|
71
|
+
self.rerender()
|
72
|
+
|
73
|
+
def _create_layout(self):
|
74
|
+
"""Create a layout with logs at top and status at bottom"""
|
75
|
+
layout = Layout()
|
76
|
+
layout.split(
|
77
|
+
Layout(name="logs"),
|
78
|
+
Layout(name="goal", size=3),
|
79
|
+
Layout(name="status", size=3),
|
80
|
+
)
|
81
|
+
return layout
|
82
|
+
|
83
|
+
def _update_layout(
|
84
|
+
self,
|
85
|
+
layout: Layout,
|
86
|
+
log_list: List[str],
|
87
|
+
step_message: str,
|
88
|
+
goal: str = None,
|
89
|
+
completed: bool = False,
|
90
|
+
success: bool = False,
|
91
|
+
):
|
92
|
+
"""Update the layout with current logs and step information"""
|
93
|
+
from rich.text import Text
|
94
|
+
import shutil
|
95
|
+
|
96
|
+
# Cache terminal size to avoid frequent recalculation
|
97
|
+
try:
|
98
|
+
terminal_height = shutil.get_terminal_size().lines
|
99
|
+
except:
|
100
|
+
terminal_height = 24 # fallback
|
101
|
+
|
102
|
+
# Reserve space for panels and borders (more conservative estimate)
|
103
|
+
other_components_height = 10 # goal panel + status panel + borders + padding
|
104
|
+
available_log_lines = max(8, terminal_height - other_components_height)
|
105
|
+
|
106
|
+
# Only show recent logs, but ensure we don't flicker
|
107
|
+
visible_logs = (
|
108
|
+
log_list[-available_log_lines:]
|
109
|
+
if len(log_list) > available_log_lines
|
110
|
+
else log_list
|
111
|
+
)
|
112
|
+
|
113
|
+
# Ensure we always have some content to prevent panel collapse
|
114
|
+
if not visible_logs:
|
115
|
+
visible_logs = ["Initializing..."]
|
116
|
+
|
117
|
+
log_content = "\n".join(visible_logs)
|
118
|
+
|
119
|
+
layout["logs"].update(
|
120
|
+
Panel(
|
121
|
+
log_content,
|
122
|
+
title=f"Activity Log ({len(log_list)} entries)",
|
123
|
+
border_style="blue",
|
124
|
+
title_align="left",
|
125
|
+
padding=(0, 1),
|
126
|
+
height=available_log_lines + 2,
|
127
|
+
)
|
128
|
+
)
|
129
|
+
|
130
|
+
if goal:
|
131
|
+
goal_text = Text(goal, style="bold")
|
132
|
+
layout["goal"].update(
|
133
|
+
Panel(
|
134
|
+
goal_text,
|
135
|
+
title="Goal",
|
136
|
+
border_style="magenta",
|
137
|
+
title_align="left",
|
138
|
+
padding=(0, 1),
|
139
|
+
height=3,
|
140
|
+
)
|
141
|
+
)
|
142
|
+
|
143
|
+
step_display = Text()
|
144
|
+
|
145
|
+
if completed:
|
146
|
+
if success:
|
147
|
+
step_display.append("✓ ", style="bold green")
|
148
|
+
panel_title = "Completed"
|
149
|
+
panel_style = "green"
|
150
|
+
else:
|
151
|
+
step_display.append("✗ ", style="bold red")
|
152
|
+
panel_title = "Failed"
|
153
|
+
panel_style = "red"
|
154
|
+
else:
|
155
|
+
step_display.append("⚡ ", style="bold yellow")
|
156
|
+
panel_title = "Status"
|
157
|
+
panel_style = "yellow"
|
158
|
+
|
159
|
+
step_display.append(step_message)
|
160
|
+
|
161
|
+
layout["status"].update(
|
162
|
+
Panel(
|
163
|
+
step_display,
|
164
|
+
title=panel_title,
|
165
|
+
border_style=panel_style,
|
166
|
+
title_align="left",
|
167
|
+
padding=(0, 1),
|
168
|
+
height=3,
|
169
|
+
)
|
170
|
+
)
|
171
|
+
|
172
|
+
def handle_event(self, event):
|
173
|
+
"""Handle streaming events from the agent workflow."""
|
174
|
+
logger = logging.getLogger("droidrun")
|
175
|
+
|
176
|
+
# Log different event types with proper names
|
177
|
+
if isinstance(event, ScreenshotEvent):
|
178
|
+
logger.debug("📸 Taking screenshot...")
|
179
|
+
|
180
|
+
# Planner events
|
181
|
+
elif isinstance(event, PlanInputEvent):
|
182
|
+
self.current_step = "Planning..."
|
183
|
+
logger.info("💭 Planner receiving input...")
|
184
|
+
|
185
|
+
elif isinstance(event, PlanThinkingEvent):
|
186
|
+
if event.thoughts:
|
187
|
+
thoughts_preview = (
|
188
|
+
event.thoughts[:150] + "..."
|
189
|
+
if len(event.thoughts) > 150
|
190
|
+
else event.thoughts
|
191
|
+
)
|
192
|
+
logger.info(f"🧠 Planning: {thoughts_preview}")
|
193
|
+
if event.code:
|
194
|
+
logger.info(f"📝 Generated plan code")
|
195
|
+
|
196
|
+
elif isinstance(event, PlanCreatedEvent):
|
197
|
+
if event.tasks:
|
198
|
+
task_count = len(event.tasks) if event.tasks else 0
|
199
|
+
self.current_step = f"Plan ready ({task_count} tasks)"
|
200
|
+
logger.info(f"📋 Plan created with {task_count} tasks")
|
201
|
+
for task in event.tasks:
|
202
|
+
desc = task.description
|
203
|
+
logger.info(f"- {desc}")
|
204
|
+
|
205
|
+
# CodeAct events
|
206
|
+
elif isinstance(event, TaskInputEvent):
|
207
|
+
self.current_step = "Processing task input..."
|
208
|
+
logger.info("💬 Task input received...")
|
209
|
+
|
210
|
+
elif isinstance(event, TaskThinkingEvent):
|
211
|
+
if hasattr(event, "thoughts") and event.thoughts:
|
212
|
+
thoughts_preview = (
|
213
|
+
event.thoughts[:150] + "..."
|
214
|
+
if len(event.thoughts) > 150
|
215
|
+
else event.thoughts
|
216
|
+
)
|
217
|
+
logger.info(f"🧠 Thinking: {thoughts_preview}")
|
218
|
+
if hasattr(event, "code") and event.code:
|
219
|
+
logger.info(f"💻 Executing action code")
|
220
|
+
logger.debug(f"{event.code}")
|
221
|
+
|
222
|
+
elif isinstance(event, TaskExecutionEvent):
|
223
|
+
self.current_step = "Executing action..."
|
224
|
+
logger.info(f"⚡ Executing action...")
|
225
|
+
|
226
|
+
elif isinstance(event, TaskExecutionResultEvent):
|
227
|
+
if hasattr(event, "output") and event.output:
|
228
|
+
output = str(event.output)
|
229
|
+
if "Error" in output or "Exception" in output:
|
230
|
+
output_preview = (
|
231
|
+
output[:100] + "..." if len(output) > 100 else output
|
232
|
+
)
|
233
|
+
logger.info(f"❌ Action error: {output_preview}")
|
234
|
+
else:
|
235
|
+
output_preview = (
|
236
|
+
output[:100] + "..." if len(output) > 100 else output
|
237
|
+
)
|
238
|
+
logger.info(f"⚡ Action result: {output_preview}")
|
239
|
+
|
240
|
+
elif isinstance(event, TaskEndEvent):
|
241
|
+
if hasattr(event, "success") and hasattr(event, "reason"):
|
242
|
+
if event.success:
|
243
|
+
self.current_step = event.reason
|
244
|
+
logger.info(f"✅ Task completed: {event.reason}")
|
245
|
+
else:
|
246
|
+
self.current_step = f"Task failed"
|
247
|
+
logger.info(f"❌ Task failed: {event.reason}")
|
248
|
+
|
249
|
+
# Droid coordination events
|
250
|
+
elif isinstance(event, CodeActExecuteEvent):
|
251
|
+
self.current_step = "Executing task..."
|
252
|
+
logger.info(f"🔧 Starting task execution...")
|
253
|
+
|
254
|
+
elif isinstance(event, CodeActResultEvent):
|
255
|
+
if hasattr(event, "success") and hasattr(event, "reason"):
|
256
|
+
if event.success:
|
257
|
+
self.current_step = event.reason
|
258
|
+
logger.info(f"✅ Task completed: {event.reason}")
|
259
|
+
else:
|
260
|
+
self.current_step = f"Task failed"
|
261
|
+
logger.info(f"❌ Task failed: {event.reason}")
|
262
|
+
|
263
|
+
elif isinstance(event, ReasoningLogicEvent):
|
264
|
+
self.current_step = "Planning..."
|
265
|
+
logger.info(f"🤔 Planning next steps...")
|
266
|
+
|
267
|
+
elif isinstance(event, TaskRunnerEvent):
|
268
|
+
self.current_step = "Processing tasks..."
|
269
|
+
logger.info(f"🏃 Processing task queue...")
|
270
|
+
|
271
|
+
elif isinstance(event, FinalizeEvent):
|
272
|
+
if hasattr(event, "success") and hasattr(event, "reason"):
|
273
|
+
self.is_completed = True
|
274
|
+
self.is_success = event.success
|
275
|
+
if event.success:
|
276
|
+
self.current_step = f"Success: {event.reason}"
|
277
|
+
logger.info(f"🎉 Goal achieved: {event.reason}")
|
278
|
+
else:
|
279
|
+
self.current_step = f"Failed: {event.reason}"
|
280
|
+
logger.info(f"❌ Goal failed: {event.reason}")
|
281
|
+
|
282
|
+
else:
|
283
|
+
logger.debug(f"🔄 {event.__class__.__name__}")
|