droidrun 0.1.0__py3-none-any.whl → 0.2.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 +15 -8
- droidrun/__main__.py +2 -3
- droidrun/adb/device.py +1 -1
- droidrun/agent/codeact/__init__.py +13 -0
- droidrun/agent/codeact/codeact_agent.py +334 -0
- droidrun/agent/codeact/events.py +36 -0
- droidrun/agent/codeact/prompts.py +78 -0
- droidrun/agent/droid/__init__.py +13 -0
- droidrun/agent/droid/droid_agent.py +418 -0
- droidrun/agent/planner/__init__.py +15 -0
- droidrun/agent/planner/events.py +20 -0
- droidrun/agent/planner/prompts.py +144 -0
- droidrun/agent/planner/task_manager.py +355 -0
- droidrun/agent/planner/workflow.py +371 -0
- droidrun/agent/utils/async_utils.py +56 -0
- droidrun/agent/utils/chat_utils.py +92 -0
- droidrun/agent/utils/executer.py +97 -0
- droidrun/agent/utils/llm_picker.py +143 -0
- droidrun/cli/main.py +422 -107
- droidrun/tools/__init__.py +4 -25
- droidrun/tools/actions.py +767 -783
- droidrun/tools/device.py +1 -1
- droidrun/tools/loader.py +60 -0
- {droidrun-0.1.0.dist-info → droidrun-0.2.0.dist-info}/METADATA +134 -37
- droidrun-0.2.0.dist-info/RECORD +32 -0
- droidrun/agent/__init__.py +0 -16
- droidrun/agent/llm_reasoning.py +0 -567
- droidrun/agent/react_agent.py +0 -556
- droidrun/llm/__init__.py +0 -24
- droidrun-0.1.0.dist-info/RECORD +0 -20
- {droidrun-0.1.0.dist-info → droidrun-0.2.0.dist-info}/WHEEL +0 -0
- {droidrun-0.1.0.dist-info → droidrun-0.2.0.dist-info}/entry_points.txt +0 -0
- {droidrun-0.1.0.dist-info → droidrun-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,371 @@
|
|
1
|
+
from llama_index.core.workflow import (
|
2
|
+
StartEvent,
|
3
|
+
StopEvent,
|
4
|
+
Workflow,
|
5
|
+
step,
|
6
|
+
)
|
7
|
+
from .events import *
|
8
|
+
from .prompts import (
|
9
|
+
DEFAULT_PLANNER_SYSTEM_PROMPT,
|
10
|
+
DEFAULT_PLANNER_USER_PROMPT,
|
11
|
+
)
|
12
|
+
import logging
|
13
|
+
import re
|
14
|
+
import os
|
15
|
+
from typing import List, Optional, Tuple, TYPE_CHECKING, Union
|
16
|
+
import inspect
|
17
|
+
# LlamaIndex imports for LLM interaction and types
|
18
|
+
from llama_index.core.base.llms.types import ChatMessage, ChatResponse
|
19
|
+
from llama_index.core.llms.llm import LLM
|
20
|
+
from llama_index.core.workflow import Workflow, StartEvent, StopEvent, Context, step
|
21
|
+
from llama_index.core.memory import ChatMemoryBuffer
|
22
|
+
from llama_index.core.llms.llm import LLM
|
23
|
+
from ..utils.executer import SimpleCodeExecutor
|
24
|
+
from ..utils.chat_utils import add_ui_text_block, add_screenshot_image_block, add_phone_state_block, message_copy
|
25
|
+
from .task_manager import TaskManager
|
26
|
+
|
27
|
+
# Load environment variables
|
28
|
+
from dotenv import load_dotenv
|
29
|
+
load_dotenv()
|
30
|
+
|
31
|
+
# Setup logger
|
32
|
+
logger = logging.getLogger("droidrun")
|
33
|
+
|
34
|
+
if TYPE_CHECKING:
|
35
|
+
from ...tools import Tools
|
36
|
+
|
37
|
+
class PlannerAgent(Workflow):
|
38
|
+
def __init__(self, goal: str, llm: LLM, agent: Optional[Workflow], tools_instance: 'Tools',
|
39
|
+
executer = None, system_prompt = None, user_prompt = None, max_retries = 1,
|
40
|
+
enable_tracing = False, debug = False, *args, **kwargs) -> None:
|
41
|
+
super().__init__(*args, **kwargs)
|
42
|
+
|
43
|
+
# Setup tracing if enabled
|
44
|
+
if enable_tracing:
|
45
|
+
try:
|
46
|
+
from llama_index.core import set_global_handler
|
47
|
+
set_global_handler("arize_phoenix")
|
48
|
+
logger.info("Arize Phoenix tracing enabled")
|
49
|
+
except ImportError:
|
50
|
+
logger.warning("Arize Phoenix package not found, tracing disabled")
|
51
|
+
else:
|
52
|
+
if debug:
|
53
|
+
logger.debug("Arize Phoenix tracing disabled")
|
54
|
+
|
55
|
+
self.llm = llm
|
56
|
+
self.goal = goal
|
57
|
+
self.task_manager = TaskManager()
|
58
|
+
self.tools = [self.task_manager.set_tasks, self.task_manager.add_task, self.task_manager.get_all_tasks, self.task_manager.clear_tasks, self.task_manager.complete_goal, self.task_manager.start_agent]
|
59
|
+
self.debug = debug # Set debug attribute before using it in other methods
|
60
|
+
self.tools_description = self.parse_tool_descriptions()
|
61
|
+
if not executer:
|
62
|
+
self.executer = SimpleCodeExecutor(loop=None, globals={}, locals={}, tools=self.tools, use_same_scope=True)
|
63
|
+
else:
|
64
|
+
self.executer = executer
|
65
|
+
self.system_prompt = system_prompt or DEFAULT_PLANNER_SYSTEM_PROMPT.format(tools_description=self.tools_description)
|
66
|
+
self.user_prompt = user_prompt or DEFAULT_PLANNER_USER_PROMPT.format(goal=goal)
|
67
|
+
self.system_message = ChatMessage(role="system", content=self.system_prompt)
|
68
|
+
self.user_message = ChatMessage(role="user", content=self.user_prompt)
|
69
|
+
self.memory = None
|
70
|
+
self.agent = agent # This can now be None when used just for planning
|
71
|
+
self.tools_instance = tools_instance
|
72
|
+
|
73
|
+
self.max_retries = max_retries # Number of retries for a failed task
|
74
|
+
|
75
|
+
self.current_retry = 0 # Current retry count
|
76
|
+
|
77
|
+
self.steps_counter = 0 # Steps counter
|
78
|
+
|
79
|
+
def _extract_code_and_thought(self, response_text: str) -> Tuple[Optional[str], str]:
|
80
|
+
"""
|
81
|
+
Extracts code from Markdown blocks (```python ... ```) and the surrounding text (thought),
|
82
|
+
handling indented code blocks.
|
83
|
+
|
84
|
+
Returns:
|
85
|
+
Tuple[Optional[code_string], thought_string]
|
86
|
+
"""
|
87
|
+
if self.debug:
|
88
|
+
logger.debug("✂️ Extracting code and thought from response...")
|
89
|
+
code_pattern = r"^\s*```python\s*\n(.*?)\n^\s*```\s*?$" # Added ^\s*, re.MULTILINE, and made closing fence match more robust
|
90
|
+
# Use re.DOTALL to make '.' match newlines and re.MULTILINE to make '^' match start of lines
|
91
|
+
code_matches = list(re.finditer(code_pattern, response_text, re.DOTALL | re.MULTILINE))
|
92
|
+
|
93
|
+
if not code_matches:
|
94
|
+
# No code found, the entire response is thought
|
95
|
+
if self.debug:
|
96
|
+
logger.debug(" - No code block found. Entire response is thought.")
|
97
|
+
return None, response_text.strip()
|
98
|
+
|
99
|
+
extracted_code_parts = []
|
100
|
+
for match in code_matches:
|
101
|
+
# group(1) is the (.*?) part - the actual code content
|
102
|
+
code_content = match.group(1)
|
103
|
+
extracted_code_parts.append(code_content) # Keep original indentation for now
|
104
|
+
|
105
|
+
extracted_code = "\n\n".join(extracted_code_parts)
|
106
|
+
if self.debug:
|
107
|
+
logger.debug(f" - Combined extracted code:\n```python\n{extracted_code}\n```")
|
108
|
+
|
109
|
+
|
110
|
+
# Extract thought text (text before the first code block, between blocks, and after the last)
|
111
|
+
thought_parts = []
|
112
|
+
last_end = 0
|
113
|
+
for match in code_matches:
|
114
|
+
# Use span(0) to get the start/end of the *entire* match (including fences and indentation)
|
115
|
+
start, end = match.span(0)
|
116
|
+
thought_parts.append(response_text[last_end:start])
|
117
|
+
last_end = end
|
118
|
+
thought_parts.append(response_text[last_end:]) # Text after the last block
|
119
|
+
|
120
|
+
thought_text = "".join(thought_parts).strip()
|
121
|
+
# Avoid overly long debug messages for thought
|
122
|
+
if self.debug:
|
123
|
+
thought_preview = (thought_text[:100] + '...') if len(thought_text) > 100 else thought_text
|
124
|
+
logger.debug(f" - Extracted thought: {thought_preview}")
|
125
|
+
|
126
|
+
return extracted_code, thought_text
|
127
|
+
|
128
|
+
def parse_tool_descriptions(self) -> str:
|
129
|
+
"""Parses the available tools and their descriptions for the system prompt."""
|
130
|
+
if self.debug:
|
131
|
+
logger.debug("🛠️ Parsing tool descriptions for Planner Agent...")
|
132
|
+
# self.available_tools is a list of functions, we need to get their docstrings, names, and signatures and display them as `def name(args) -> return_type:\n"""docstring""" ...\n`
|
133
|
+
tool_descriptions = []
|
134
|
+
for tool in self.tools:
|
135
|
+
assert callable(tool), f"Tool {tool} is not callable."
|
136
|
+
tool_name = tool.__name__
|
137
|
+
tool_signature = inspect.signature(tool)
|
138
|
+
tool_docstring = tool.__doc__ or "No description available."
|
139
|
+
# Format the function signature and docstring
|
140
|
+
formatted_signature = f"def {tool_name}{tool_signature}:\n \"\"\"{tool_docstring}\"\"\"\n..."
|
141
|
+
tool_descriptions.append(formatted_signature)
|
142
|
+
if self.debug:
|
143
|
+
logger.debug(f" - Parsed tool: {tool_name}")
|
144
|
+
# Join all tool descriptions into a single string
|
145
|
+
descriptions = "\n".join(tool_descriptions)
|
146
|
+
if self.debug:
|
147
|
+
logger.debug(f"🔩 Found {len(tool_descriptions)} tools.")
|
148
|
+
return descriptions
|
149
|
+
|
150
|
+
@step
|
151
|
+
async def prepare_chat(self, ev: StartEvent, ctx: Context) -> InputEvent:
|
152
|
+
logger.info("💬 Preparing planning session...")
|
153
|
+
await ctx.set("step", "generate_plan")
|
154
|
+
|
155
|
+
# Check if we already have a memory buffer, otherwise create one
|
156
|
+
if not self.memory:
|
157
|
+
if self.debug:
|
158
|
+
logger.debug(" - Creating new memory buffer.")
|
159
|
+
self.memory = ChatMemoryBuffer.from_defaults(llm=self.llm)
|
160
|
+
# Add system message to memory
|
161
|
+
await self.memory.aput(self.system_message)
|
162
|
+
else:
|
163
|
+
if self.debug:
|
164
|
+
logger.debug(" - Using existing memory buffer with chat history.")
|
165
|
+
|
166
|
+
# Check for user input
|
167
|
+
user_input = ev.get("input", default=None)
|
168
|
+
|
169
|
+
# Validate we have either memory, input, or a user prompt
|
170
|
+
assert len(self.memory.get_all()) > 0 or user_input or self.user_prompt, "Memory input, user prompt or user input cannot be empty."
|
171
|
+
|
172
|
+
# Add user input to memory if provided or use the user prompt if this is a new conversation
|
173
|
+
if user_input:
|
174
|
+
if self.debug:
|
175
|
+
logger.debug(" - Adding user input to memory.")
|
176
|
+
await self.memory.aput(ChatMessage(role="user", content=user_input))
|
177
|
+
elif self.user_prompt and len(self.memory.get_all()) <= 1: # Only add user prompt if memory only has system message
|
178
|
+
if self.debug:
|
179
|
+
logger.debug(" - Adding goal to memory.")
|
180
|
+
await self.memory.aput(ChatMessage(role="user", content=self.user_prompt))
|
181
|
+
|
182
|
+
# Update context
|
183
|
+
await ctx.set("memory", self.memory)
|
184
|
+
input_messages = self.memory.get_all()
|
185
|
+
if self.debug:
|
186
|
+
logger.debug(f" - Memory contains {len(input_messages)} messages")
|
187
|
+
return InputEvent(input=input_messages)
|
188
|
+
|
189
|
+
@step
|
190
|
+
async def handle_llm_input(self, ev: InputEvent, ctx: Context) -> Union[StopEvent, ModelResponseEvent]:
|
191
|
+
"""Handle LLM input."""
|
192
|
+
# Get chat history from event
|
193
|
+
chat_history = ev.input
|
194
|
+
assert len(chat_history) > 0, "Chat history cannot be empty."
|
195
|
+
|
196
|
+
self.steps_counter += 1
|
197
|
+
logger.info(f"🧠 Thinking about how to plan the goal...")
|
198
|
+
# Get LLM response
|
199
|
+
response = await self._get_llm_response(chat_history)
|
200
|
+
# Add response to memory
|
201
|
+
await self.memory.aput(response.message)
|
202
|
+
return ModelResponseEvent(response=response.message.content)
|
203
|
+
|
204
|
+
@step
|
205
|
+
async def handle_llm_output(self, ev: ModelResponseEvent, ctx: Context) -> Union[StopEvent, ExecutePlan]:
|
206
|
+
"""Handle LLM output."""
|
207
|
+
response = ev.response
|
208
|
+
if response:
|
209
|
+
if self.debug:
|
210
|
+
logger.debug("🤖 LLM response received.")
|
211
|
+
if self.debug:
|
212
|
+
logger.debug("🤖 Processing planning output...")
|
213
|
+
planner_step = await ctx.get("step", default=None)
|
214
|
+
code, thoughts = self._extract_code_and_thought(response)
|
215
|
+
if self.debug:
|
216
|
+
logger.debug(f" - Thoughts: {'Yes' if thoughts else 'No'}, Code: {'Yes' if code else 'No'}")
|
217
|
+
if code:
|
218
|
+
# Execute code if present
|
219
|
+
if self.debug:
|
220
|
+
logger.debug(f"Response: {response}")
|
221
|
+
result = await self.executer.execute(code)
|
222
|
+
logger.info(f"📝 Planning complete")
|
223
|
+
if self.debug:
|
224
|
+
logger.debug(f" - Planning code executed. Result: {result}")
|
225
|
+
# Add result to memory
|
226
|
+
await self.memory.aput(ChatMessage(role="user", content=f"Execution Result:\n```\n{result}\n```"))
|
227
|
+
|
228
|
+
# Check if there are any pending tasks
|
229
|
+
pending_tasks = self.task_manager.get_pending_tasks()
|
230
|
+
|
231
|
+
if self.task_manager.task_completed:
|
232
|
+
logger.info("✅ Goal marked as complete by planner.")
|
233
|
+
return StopEvent(result={'finished': True, 'message': "Task execution completed.", 'steps': self.steps_counter})
|
234
|
+
elif pending_tasks:
|
235
|
+
# If there are pending tasks, automatically start execution
|
236
|
+
logger.info("🚀 Starting task execution...")
|
237
|
+
return ExecutePlan()
|
238
|
+
else:
|
239
|
+
# If no tasks were set, prompt the planner to set tasks or complete the goal
|
240
|
+
await self.memory.aput(ChatMessage(role="user", content=f"Please either set new tasks using set_tasks() or mark the goal as complete using complete_goal() if done."))
|
241
|
+
if self.debug:
|
242
|
+
logger.debug("🔄 Waiting for next plan or completion.")
|
243
|
+
return InputEvent(input=self.memory.get_all())
|
244
|
+
@step
|
245
|
+
async def execute_plan(self, ev: ExecutePlan, ctx: Context) -> Union[ExecutePlan, TaskFailedEvent]:
|
246
|
+
"""Execute the plan by scheduling the agent to run."""
|
247
|
+
step_name = await ctx.get("step")
|
248
|
+
if step_name == "execute_agent":
|
249
|
+
return await self.execute_agent(ev, ctx) # Sub-steps
|
250
|
+
else:
|
251
|
+
await ctx.set("step", "execute_agent")
|
252
|
+
return ev # Reenter this step with the subcontext key set
|
253
|
+
|
254
|
+
async def execute_agent(self, ev: ExecutePlan, ctx: Context) -> Union[ExecutePlan, TaskFailedEvent]:
|
255
|
+
"""Execute a single task using the agent."""
|
256
|
+
# Skip execution if no agent is provided (used in planning-only mode)
|
257
|
+
if self.agent is None:
|
258
|
+
if self.debug:
|
259
|
+
logger.debug("No agent provided, skipping execution")
|
260
|
+
return StopEvent(result={"success": False, "reason": "No agent provided"})
|
261
|
+
|
262
|
+
# Original execution logic
|
263
|
+
tasks = self.task_manager.get_all_tasks()
|
264
|
+
attempting_tasks = self.task_manager.get_tasks_by_status(self.task_manager.STATUS_ATTEMPTING)
|
265
|
+
if attempting_tasks:
|
266
|
+
task = attempting_tasks[0]
|
267
|
+
logger.warning(f"A task is already being executed: {task['description']}")
|
268
|
+
task_description = task["description"]
|
269
|
+
else:
|
270
|
+
# Find the first task in 'pending' status
|
271
|
+
for task in tasks:
|
272
|
+
if task['status'] == self.task_manager.STATUS_PENDING:
|
273
|
+
self.task_manager.update_status(tasks.index(task), self.task_manager.STATUS_ATTEMPTING)
|
274
|
+
task_description = task['description']
|
275
|
+
break
|
276
|
+
else:
|
277
|
+
# If execution reaches here, all tasks are either completed or failed
|
278
|
+
all_completed = all(task["status"] == self.task_manager.STATUS_COMPLETED for task in tasks)
|
279
|
+
if all_completed and tasks:
|
280
|
+
if self.debug:
|
281
|
+
logger.debug(f"All tasks completed: {[task['description'] for task in tasks]}")
|
282
|
+
# Return to handle_llm_input with empty input to get new plan
|
283
|
+
return InputEvent(input=self.memory.get_all())
|
284
|
+
else:
|
285
|
+
logger.warning(f"No executable task found.")
|
286
|
+
if self.debug:
|
287
|
+
logger.debug(f"Tasks status: {[(task['description'], task['status']) for task in tasks]}")
|
288
|
+
return TaskFailedEvent(task_description="No task to execute", reason="No executable task found")
|
289
|
+
|
290
|
+
logger.info(f"🔧 Executing task: {task_description}")
|
291
|
+
# After the task is selected, execute the agent with that task
|
292
|
+
try:
|
293
|
+
task_event = {"input": task_description}
|
294
|
+
result = await self.agent.run(task_event)
|
295
|
+
success = result.get("result", {}).get("success", False)
|
296
|
+
if success:
|
297
|
+
for task in tasks:
|
298
|
+
if task["status"] == self.task_manager.STATUS_ATTEMPTING:
|
299
|
+
self.task_manager.update_status(tasks.index(task), self.task_manager.STATUS_COMPLETED)
|
300
|
+
return ExecutePlan() # Continue execution to find more tasks
|
301
|
+
# Task failure case
|
302
|
+
for task in tasks:
|
303
|
+
if task["status"] == self.task_manager.STATUS_ATTEMPTING:
|
304
|
+
self.task_manager.update_status(tasks.index(task), self.task_manager.STATUS_FAILED)
|
305
|
+
reason = result.get("result", {}).get("reason", "Task failed without specific reason")
|
306
|
+
return TaskFailedEvent(task_description=task_description, reason=reason)
|
307
|
+
except Exception as e:
|
308
|
+
logger.error(f"Error executing task '{task_description}': {e}")
|
309
|
+
# Find the attempting task and mark it as failed
|
310
|
+
for task in tasks:
|
311
|
+
if task["status"] == self.task_manager.STATUS_ATTEMPTING:
|
312
|
+
self.task_manager.update_status(tasks.index(task), self.task_manager.STATUS_FAILED)
|
313
|
+
return TaskFailedEvent(task_description=task_description, reason=f"Execution error: {e}")
|
314
|
+
|
315
|
+
# Should not reach here, but just in case:
|
316
|
+
return TaskFailedEvent(task_description=task_description, reason="Task execution completed abnormally")
|
317
|
+
|
318
|
+
async def _get_llm_response(self, chat_history: List[ChatMessage]) -> ChatResponse:
|
319
|
+
"""Get streaming response from LLM."""
|
320
|
+
if self.debug:
|
321
|
+
logger.debug(f" - Sending {len(chat_history)} messages to LLM.")
|
322
|
+
|
323
|
+
# Check if there's a system message in the chat history
|
324
|
+
has_system_message = any(msg.role == "system" for msg in chat_history)
|
325
|
+
if not has_system_message:
|
326
|
+
if self.debug:
|
327
|
+
logger.debug("No system message found in chat history, adding system prompt.")
|
328
|
+
chat_history = [self.system_message] + chat_history
|
329
|
+
else:
|
330
|
+
if self.debug:
|
331
|
+
logger.debug("System message already exists in chat history, using existing.")
|
332
|
+
|
333
|
+
# Add remembered information if available
|
334
|
+
if hasattr(self.tools_instance, 'memory') and self.tools_instance.memory:
|
335
|
+
memory_block = "\n### Remembered Information:\n"
|
336
|
+
for idx, item in enumerate(self.tools_instance.memory, 1):
|
337
|
+
memory_block += f"{idx}. {item}\n"
|
338
|
+
|
339
|
+
# Find the first user message and inject memory before it
|
340
|
+
for i, msg in enumerate(chat_history):
|
341
|
+
if msg.role == "user":
|
342
|
+
if isinstance(msg.content, str):
|
343
|
+
# For text-only messages
|
344
|
+
updated_content = f"{memory_block}\n\n{msg.content}"
|
345
|
+
chat_history[i] = ChatMessage(role="user", content=updated_content)
|
346
|
+
elif isinstance(msg.content, list):
|
347
|
+
# For multimodal content (from llama_index.core.base.llms.types import TextBlock)
|
348
|
+
from llama_index.core.base.llms.types import TextBlock
|
349
|
+
memory_text_block = TextBlock(text=memory_block)
|
350
|
+
# Insert memory text block at beginning
|
351
|
+
content_blocks = [memory_text_block] + msg.content
|
352
|
+
chat_history[i] = ChatMessage(role="user", content=content_blocks)
|
353
|
+
break
|
354
|
+
|
355
|
+
# Add UI elements, screenshot, and phone state
|
356
|
+
chat_history = await add_screenshot_image_block(self.tools_instance, chat_history)
|
357
|
+
chat_history = await add_ui_text_block(self.tools_instance, chat_history)
|
358
|
+
chat_history = await add_phone_state_block(self.tools_instance, chat_history)
|
359
|
+
|
360
|
+
# Create copies of messages to avoid modifying the originals
|
361
|
+
messages_to_send = [message_copy(msg) for msg in chat_history]
|
362
|
+
|
363
|
+
if self.debug:
|
364
|
+
logger.debug(f" - Final message count: {len(messages_to_send)}")
|
365
|
+
response = await self.llm.achat(
|
366
|
+
messages=messages_to_send
|
367
|
+
)
|
368
|
+
assert hasattr(response, "message"), f"LLM response does not have a message attribute.\nResponse: {response}"
|
369
|
+
if self.debug:
|
370
|
+
logger.debug(" - Received response from LLM.")
|
371
|
+
return response
|
@@ -0,0 +1,56 @@
|
|
1
|
+
import asyncio
|
2
|
+
import nest_asyncio
|
3
|
+
nest_asyncio_applied = False
|
4
|
+
|
5
|
+
|
6
|
+
def async_to_sync(func):
|
7
|
+
"""
|
8
|
+
Convert an async function to a sync function.
|
9
|
+
|
10
|
+
Args:
|
11
|
+
func: Async function to convert
|
12
|
+
|
13
|
+
Returns:
|
14
|
+
Callable: Synchronous version of the async function
|
15
|
+
"""
|
16
|
+
|
17
|
+
def wrapper(*args, **kwargs):
|
18
|
+
global nest_asyncio_applied # Declare modification of global at the start of the scope
|
19
|
+
coro = func(*args, **kwargs)
|
20
|
+
try:
|
21
|
+
# Try to get the running event loop.
|
22
|
+
loop = asyncio.get_running_loop()
|
23
|
+
|
24
|
+
# If the loop is running, apply nest_asyncio if available and needed.
|
25
|
+
# Removed global declaration from here
|
26
|
+
if nest_asyncio and not nest_asyncio_applied:
|
27
|
+
nest_asyncio.apply()
|
28
|
+
nest_asyncio_applied = True
|
29
|
+
# Run the coroutine to completion within the running loop.
|
30
|
+
# This requires nest_asyncio to work correctly in nested scenarios.
|
31
|
+
# Changed from ensure_future to run_until_complete to make it truly synchronous.
|
32
|
+
return loop.run_until_complete(coro)
|
33
|
+
|
34
|
+
except RuntimeError:
|
35
|
+
# No running event loop found.
|
36
|
+
try:
|
37
|
+
# Check if there's a loop policy and a current event loop set, even if not running.
|
38
|
+
loop = asyncio.get_event_loop_policy().get_event_loop()
|
39
|
+
if loop.is_running():
|
40
|
+
# This case should ideally be caught by get_running_loop(),
|
41
|
+
# but as a fallback, handle similarly if loop is running.
|
42
|
+
# Removed global declaration from here
|
43
|
+
if nest_asyncio and not nest_asyncio_applied:
|
44
|
+
nest_asyncio.apply()
|
45
|
+
nest_asyncio_applied = True
|
46
|
+
return loop.run_until_complete(coro)
|
47
|
+
else:
|
48
|
+
# Loop exists but is not running, run until complete.
|
49
|
+
return loop.run_until_complete(coro)
|
50
|
+
except RuntimeError:
|
51
|
+
# If get_event_loop() also fails (no loop set at all for this thread),
|
52
|
+
# use asyncio.run() which creates a new loop.
|
53
|
+
return asyncio.run(coro)
|
54
|
+
|
55
|
+
|
56
|
+
return wrapper
|
@@ -0,0 +1,92 @@
|
|
1
|
+
import base64
|
2
|
+
import json
|
3
|
+
import logging
|
4
|
+
from typing import List, TYPE_CHECKING
|
5
|
+
from llama_index.core.base.llms.types import ChatMessage, ImageBlock, TextBlock
|
6
|
+
|
7
|
+
if TYPE_CHECKING:
|
8
|
+
from ...tools import Tools
|
9
|
+
|
10
|
+
logger = logging.getLogger("droidrun")
|
11
|
+
logging.basicConfig(level=logging.INFO)
|
12
|
+
|
13
|
+
def message_copy(message: ChatMessage, deep = True) -> ChatMessage:
|
14
|
+
if deep:
|
15
|
+
copied_message = message.model_copy()
|
16
|
+
copied_message.blocks = [block.model_copy () for block in message.blocks]
|
17
|
+
|
18
|
+
return copied_message
|
19
|
+
copied_message = message.model_copy()
|
20
|
+
|
21
|
+
# Create a new, independent list containing the same block references
|
22
|
+
copied_message.blocks = list(message.blocks) # or original_message.blocks[:]
|
23
|
+
|
24
|
+
return copied_message
|
25
|
+
|
26
|
+
async def add_ui_text_block(tools: 'Tools', chat_history: List[ChatMessage], retry = 5, copy = True) -> List[ChatMessage]:
|
27
|
+
"""Add UI elements to the chat history without modifying the original."""
|
28
|
+
ui_elements = None
|
29
|
+
for i in range(retry):
|
30
|
+
try:
|
31
|
+
ui_elements = await tools.get_clickables()
|
32
|
+
if ui_elements:
|
33
|
+
break
|
34
|
+
except Exception as e:
|
35
|
+
if i < 4:
|
36
|
+
logger.warning(f" - Error getting UI elements: {e}. Retrying...")
|
37
|
+
else:
|
38
|
+
logger.error(f" - Error getting UI elements: {e}. No UI elements will be sent.")
|
39
|
+
if ui_elements:
|
40
|
+
ui_block = TextBlock(text="\nCurrent Clickable UI elements from the device using the custom TopViewService:\n```json\n" + json.dumps(ui_elements) + "\n```\n")
|
41
|
+
if copy:
|
42
|
+
chat_history = chat_history.copy()
|
43
|
+
chat_history[-1] = message_copy(chat_history[-1])
|
44
|
+
chat_history[-1].blocks.append(ui_block)
|
45
|
+
return chat_history
|
46
|
+
|
47
|
+
async def add_screenshot_image_block(tools: 'Tools', chat_history: List[ChatMessage], retry: int = 5, copy = True) -> None:
|
48
|
+
screenshot = await take_screenshot(tools, retry)
|
49
|
+
if screenshot:
|
50
|
+
image_block = ImageBlock(image=base64.b64encode(screenshot))
|
51
|
+
if copy:
|
52
|
+
chat_history = chat_history.copy() # Create a copy of chat history to avoid modifying the original
|
53
|
+
chat_history[-1] = message_copy(chat_history[-1])
|
54
|
+
chat_history[-1].blocks.append(image_block)
|
55
|
+
return chat_history
|
56
|
+
|
57
|
+
|
58
|
+
async def take_screenshot(tools: 'Tools', retry: int = 5) -> None:
|
59
|
+
"""Take a screenshot and return the image."""
|
60
|
+
# Retry taking screenshot
|
61
|
+
tools.last_screenshot = None
|
62
|
+
for i in range(retry):
|
63
|
+
try:
|
64
|
+
await tools.take_screenshot()
|
65
|
+
if tools.last_screenshot:
|
66
|
+
break
|
67
|
+
except Exception as e:
|
68
|
+
if i < 4:
|
69
|
+
logger.warning(f" - Error taking screenshot: {e}. Retrying...")
|
70
|
+
else:
|
71
|
+
logger.error(f" - Error taking screenshot: {e}. No screenshot will be sent.")
|
72
|
+
return None
|
73
|
+
screenshot = tools.last_screenshot
|
74
|
+
tools.last_screenshot = None # Reset last screenshot after taking it
|
75
|
+
return screenshot
|
76
|
+
|
77
|
+
async def add_screenshot(chat_history: List[ChatMessage], screenshot, copy = True) -> List[ChatMessage]:
|
78
|
+
"""Add a screenshot to the chat history."""
|
79
|
+
image_block = ImageBlock(image=base64.b64encode(screenshot))
|
80
|
+
if copy:
|
81
|
+
chat_history[-1] = message_copy(chat_history[-1])
|
82
|
+
chat_history = chat_history.copy() # Create a copy of chat history to avoid modifying the original
|
83
|
+
chat_history[-1].blocks.append(image_block)
|
84
|
+
return chat_history
|
85
|
+
|
86
|
+
async def add_phone_state_block(tools: 'Tools', chat_history: List[ChatMessage]) -> List[ChatMessage]:
|
87
|
+
phone_state = await tools.get_phone_state()
|
88
|
+
ui_block = TextBlock(text=f"\nCurrent Phone state: {phone_state}\n```\n")
|
89
|
+
chat_history = chat_history.copy()
|
90
|
+
chat_history[-1] = message_copy(chat_history[-1])
|
91
|
+
chat_history[-1].blocks.append(ui_block)
|
92
|
+
return chat_history
|
@@ -0,0 +1,97 @@
|
|
1
|
+
import io
|
2
|
+
import contextlib
|
3
|
+
import ast
|
4
|
+
import traceback
|
5
|
+
from typing import Any, Dict
|
6
|
+
from .async_utils import async_to_sync
|
7
|
+
import asyncio
|
8
|
+
|
9
|
+
class SimpleCodeExecutor:
|
10
|
+
"""
|
11
|
+
A simple code executor that runs Python code with state persistence.
|
12
|
+
|
13
|
+
This executor maintains a global and local state between executions,
|
14
|
+
allowing for variables to persist across multiple code runs.
|
15
|
+
|
16
|
+
NOTE: not safe for production use! Use with caution.
|
17
|
+
"""
|
18
|
+
|
19
|
+
def __init__(self, loop, locals: Dict[str, Any] = {}, globals: Dict[str, Any] = {}, tools = {}, use_same_scope: bool = True):
|
20
|
+
"""
|
21
|
+
Initialize the code executor.
|
22
|
+
|
23
|
+
Args:
|
24
|
+
locals: Local variables to use in the execution context
|
25
|
+
globals: Global variables to use in the execution context
|
26
|
+
tools: List of tools available for execution
|
27
|
+
"""
|
28
|
+
|
29
|
+
# loop throught tools and add them to globals, but before that check if tool value is async, if so convert it to sync. tools is a dictionary of tool name: function
|
30
|
+
# e.g. tools = {'tool_name': tool_function}
|
31
|
+
|
32
|
+
# check if tools is a dictionary
|
33
|
+
if isinstance(tools, dict):
|
34
|
+
for tool_name, tool_function in tools.items():
|
35
|
+
if asyncio.iscoroutinefunction(tool_function):
|
36
|
+
# If the function is async, convert it to sync
|
37
|
+
tool_function = async_to_sync(tool_function)
|
38
|
+
# Add the tool to globals
|
39
|
+
globals[tool_name] = tool_function
|
40
|
+
elif isinstance(tools, list):
|
41
|
+
# If tools is a list, convert it to a dictionary with tool name as key and function as value
|
42
|
+
for tool in tools:
|
43
|
+
if asyncio.iscoroutinefunction(tool):
|
44
|
+
# If the function is async, convert it to sync
|
45
|
+
tool = async_to_sync(tool)
|
46
|
+
# Add the tool to globals
|
47
|
+
globals[tool.__name__] = tool
|
48
|
+
else:
|
49
|
+
raise ValueError("Tools must be a dictionary or a list of functions.")
|
50
|
+
|
51
|
+
|
52
|
+
# add time to globals
|
53
|
+
import time
|
54
|
+
globals['time'] = time
|
55
|
+
# State that persists between executions
|
56
|
+
self.globals = globals
|
57
|
+
self.locals = locals
|
58
|
+
self.loop = loop
|
59
|
+
self.use_same_scope = use_same_scope
|
60
|
+
if self.use_same_scope:
|
61
|
+
# If using the same scope, set the globals and locals to the same dictionary
|
62
|
+
self.globals = self.locals = {**self.locals, **{k: v for k, v in self.globals.items() if k not in self.locals}}
|
63
|
+
|
64
|
+
async def execute(self, code: str) -> str:
|
65
|
+
"""
|
66
|
+
Execute Python code and capture output and return values.
|
67
|
+
|
68
|
+
Args:
|
69
|
+
code: Python code to execute
|
70
|
+
|
71
|
+
Returns:
|
72
|
+
str: Output from the execution, including print statements.
|
73
|
+
"""
|
74
|
+
# Capture stdout and stderr
|
75
|
+
stdout = io.StringIO()
|
76
|
+
stderr = io.StringIO()
|
77
|
+
|
78
|
+
output = ""
|
79
|
+
try:
|
80
|
+
# Execute with captured output
|
81
|
+
with contextlib.redirect_stdout(
|
82
|
+
stdout
|
83
|
+
), contextlib.redirect_stderr(stderr):
|
84
|
+
|
85
|
+
exec(code, self.globals, self.locals)
|
86
|
+
|
87
|
+
# Get output
|
88
|
+
output = stdout.getvalue()
|
89
|
+
if stderr.getvalue():
|
90
|
+
output += "\n" + stderr.getvalue()
|
91
|
+
|
92
|
+
except Exception as e:
|
93
|
+
# Capture exception information
|
94
|
+
output = f"Error: {type(e).__name__}: {str(e)}\n"
|
95
|
+
output += traceback.format_exc()
|
96
|
+
|
97
|
+
return output
|