droidrun 0.3.1__py3-none-any.whl → 0.3.2__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/cli/main.py CHANGED
@@ -7,6 +7,7 @@ import click
7
7
  import os
8
8
  import logging
9
9
  import warnings
10
+ from contextlib import nullcontext
10
11
  from rich.console import Console
11
12
  from droidrun.agent.droid import DroidAgent
12
13
  from droidrun.agent.utils.llm_picker import load_llm
@@ -14,6 +15,13 @@ from droidrun.adb import DeviceManager
14
15
  from droidrun.tools import AdbTools, IOSTools
15
16
  from functools import wraps
16
17
  from droidrun.cli.logs import LogHandler
18
+ from droidrun.telemetry import print_telemetry_message
19
+ from droidrun.portal import (
20
+ download_portal_apk,
21
+ enable_portal_accessibility,
22
+ PORTAL_PACKAGE_NAME,
23
+ ping_portal,
24
+ )
17
25
 
18
26
  # Suppress all warnings
19
27
  warnings.filterwarnings("ignore")
@@ -36,7 +44,6 @@ def configure_logging(goal: str, debug: bool):
36
44
  )
37
45
  logger.addHandler(handler)
38
46
 
39
-
40
47
  logger.setLevel(logging.DEBUG if debug else logging.INFO)
41
48
  logger.propagate = False
42
49
 
@@ -78,6 +85,7 @@ async def run_command(
78
85
  with log_handler.render() as live:
79
86
  try:
80
87
  logger.info(f"🚀 Starting: {command}")
88
+ print_telemetry_message()
81
89
 
82
90
  if not kwargs.get("temperature"):
83
91
  kwargs["temperature"] = 0
@@ -94,7 +102,9 @@ async def run_command(
94
102
  device = devices[0].serial
95
103
  logger.info(f"📱 Using device: {device}")
96
104
  elif device is None and ios:
97
- raise ValueError("iOS device not specified. Please specify the device base url (http://device-ip:6643) via --device")
105
+ raise ValueError(
106
+ "iOS device not specified. Please specify the device base url (http://device-ip:6643) via --device"
107
+ )
98
108
  else:
99
109
  logger.info(f"📱 Using device: {device}")
100
110
 
@@ -103,7 +113,11 @@ async def run_command(
103
113
  # LLM setup
104
114
  log_handler.update_step("Initializing LLM...")
105
115
  llm = load_llm(
106
- provider_name=provider, model=model, base_url=base_url, api_base=api_base, **kwargs
116
+ provider_name=provider,
117
+ model=model,
118
+ base_url=base_url,
119
+ api_base=api_base,
120
+ **kwargs,
107
121
  )
108
122
  logger.info(f"🧠 LLM ready: {provider}/{model}")
109
123
 
@@ -127,7 +141,7 @@ async def run_command(
127
141
  reflection=reflection,
128
142
  enable_tracing=tracing,
129
143
  debug=debug,
130
- save_trajectories=save_trajectory
144
+ save_trajectories=save_trajectory,
131
145
  )
132
146
 
133
147
  logger.info("▶️ Starting agent execution...")
@@ -202,13 +216,19 @@ class DroidRunCLI(click.Group):
202
216
  default=None,
203
217
  )
204
218
  @click.option(
205
- "--vision", is_flag=True, help="Enable vision capabilites by using screenshots", default=False
219
+ "--vision",
220
+ is_flag=True,
221
+ help="Enable vision capabilites by using screenshots",
222
+ default=False,
206
223
  )
207
224
  @click.option(
208
225
  "--reasoning", is_flag=True, help="Enable planning with reasoning", default=False
209
226
  )
210
227
  @click.option(
211
- "--reflection", is_flag=True, help="Enable reflection step for higher reasoning", default=False
228
+ "--reflection",
229
+ is_flag=True,
230
+ help="Enable reflection step for higher reasoning",
231
+ default=False,
212
232
  )
213
233
  @click.option(
214
234
  "--tracing", is_flag=True, help="Enable Arize Phoenix tracing", default=False
@@ -271,13 +291,19 @@ def cli(
271
291
  default=None,
272
292
  )
273
293
  @click.option(
274
- "--vision", is_flag=True, help="Enable vision capabilites by using screenshots", default=False
294
+ "--vision",
295
+ is_flag=True,
296
+ help="Enable vision capabilites by using screenshots",
297
+ default=False,
275
298
  )
276
299
  @click.option(
277
300
  "--reasoning", is_flag=True, help="Enable planning with reasoning", default=False
278
301
  )
279
302
  @click.option(
280
- "--reflection", is_flag=True, help="Enable reflection step for higher reasoning", default=False
303
+ "--reflection",
304
+ is_flag=True,
305
+ help="Enable reflection step for higher reasoning",
306
+ default=False,
281
307
  )
282
308
  @click.option(
283
309
  "--tracing", is_flag=True, help="Enable Arize Phoenix tracing", default=False
@@ -291,9 +317,7 @@ def cli(
291
317
  help="Save agent trajectory to file",
292
318
  default=False,
293
319
  )
294
- @click.option(
295
- "--ios", is_flag=True, help="Run on iOS device", default=False
296
- )
320
+ @click.option("--ios", is_flag=True, help="Run on iOS device", default=False)
297
321
  def run(
298
322
  command: str,
299
323
  device: str | None,
@@ -328,7 +352,7 @@ def run(
328
352
  debug,
329
353
  temperature=temperature,
330
354
  save_trajectory=save_trajectory,
331
- ios=ios
355
+ ios=ios,
332
356
  )
333
357
 
334
358
 
@@ -350,17 +374,17 @@ async def devices():
350
374
 
351
375
 
352
376
  @cli.command()
353
- @click.argument("ip_address")
377
+ @click.argument("serial")
354
378
  @click.option("--port", "-p", default=5555, help="ADB port (default: 5555)")
355
379
  @coro
356
- async def connect(ip_address: str, port: int):
380
+ async def connect(serial: str, port: int):
357
381
  """Connect to a device over TCP/IP."""
358
382
  try:
359
- device = await device_manager.connect(ip_address, port)
383
+ device = await device_manager.connect(serial, port)
360
384
  if device:
361
- console.print(f"[green]Successfully connected to {ip_address}:{port}[/]")
385
+ console.print(f"[green]Successfully connected to {serial}:{port}[/]")
362
386
  else:
363
- console.print(f"[red]Failed to connect to {ip_address}:{port}[/]")
387
+ console.print(f"[red]Failed to connect to {serial}:{port}[/]")
364
388
  except Exception as e:
365
389
  console.print(f"[red]Error connecting to device: {e}[/]")
366
390
 
@@ -381,16 +405,15 @@ async def disconnect(serial: str):
381
405
 
382
406
 
383
407
  @cli.command()
384
- @click.option("--path", required=True, help="Path to the APK file to install")
385
408
  @click.option("--device", "-d", help="Device serial number or IP address", default=None)
409
+ @click.option("--path", help="Path to the Droidrun Portal APK to install on the device. If not provided, the latest portal apk version will be downloaded and installed.", default=None)
410
+ @click.option(
411
+ "--debug", is_flag=True, help="Enable verbose debug logging", default=False
412
+ )
386
413
  @coro
387
- async def setup(path: str, device: str | None):
388
- """Install an APK file and enable it as an accessibility service."""
414
+ async def setup(path: str | None, device: str | None, debug: bool):
415
+ """Install and enable the DroidRun Portal on a device."""
389
416
  try:
390
- if not os.path.exists(path):
391
- console.print(f"[bold red]Error:[/] APK file not found at {path}")
392
- return
393
-
394
417
  if not device:
395
418
  devices = await device_manager.list_devices()
396
419
  if not devices:
@@ -406,66 +429,99 @@ async def setup(path: str, device: str | None):
406
429
  f"[bold red]Error:[/] Could not get device object for {device}"
407
430
  )
408
431
  return
409
-
410
- console.print(f"[bold blue]Step 1/2: Installing APK:[/] {path}")
411
- result = await device_obj.install_app(path, False, True)
412
432
 
413
- if "Error" in result:
414
- console.print(f"[bold red]Installation failed:[/] {result}")
415
- return
433
+ if not path:
434
+ console.print("[bold blue]Downloading DroidRun Portal APK...[/]")
435
+ apk_context = download_portal_apk(debug)
416
436
  else:
417
- console.print(f"[bold green]Installation successful![/]")
437
+ console.print(f"[bold blue]Using provided APK:[/] {path}")
438
+ apk_context = nullcontext(path)
418
439
 
419
- console.print(f"[bold blue]Step 2/2: Enabling accessibility service[/]")
440
+ with apk_context as apk_path:
441
+ if not os.path.exists(apk_path):
442
+ console.print(f"[bold red]Error:[/] APK file not found at {apk_path}")
443
+ return
420
444
 
421
- package = "com.droidrun.portal"
445
+ console.print(f"[bold blue]Step 1/2: Installing APK:[/] {apk_path}")
446
+ result = await device_obj.install_app(apk_path, True, True)
422
447
 
423
- try:
424
- await device_obj._adb.shell(
425
- device,
426
- "settings put secure enabled_accessibility_services com.droidrun.portal/com.droidrun.portal.DroidrunPortalService",
427
- )
448
+ if "Error" in result:
449
+ console.print(f"[bold red]Installation failed:[/] {result}")
450
+ return
451
+ else:
452
+ console.print(f"[bold green]Installation successful![/]")
428
453
 
429
- await device_obj._adb.shell(
430
- device, "settings put secure accessibility_enabled 1"
431
- )
454
+ console.print(f"[bold blue]Step 2/2: Enabling accessibility service[/]")
432
455
 
433
- console.print("[green]Accessibility service enabled successfully![/]")
434
- console.print(
435
- "\n[bold green]Setup complete![/] The DroidRun Portal is now installed and ready to use."
436
- )
456
+ try:
457
+ await enable_portal_accessibility(device_obj)
437
458
 
438
- except Exception as e:
439
- console.print(
440
- f"[yellow]Could not automatically enable accessibility service: {e}[/]"
441
- )
442
- console.print(
443
- "[yellow]Opening accessibility settings for manual configuration...[/]"
444
- )
459
+ console.print("[green]Accessibility service enabled successfully![/]")
460
+ console.print(
461
+ "\n[bold green]Setup complete![/] The DroidRun Portal is now installed and ready to use."
462
+ )
445
463
 
446
- await device_obj._adb.shell(
447
- device, "am start -a android.settings.ACCESSIBILITY_SETTINGS"
448
- )
464
+ except Exception as e:
465
+ console.print(
466
+ f"[yellow]Could not automatically enable accessibility service: {e}[/]"
467
+ )
468
+ console.print(
469
+ "[yellow]Opening accessibility settings for manual configuration...[/]"
470
+ )
471
+
472
+ await device_obj.shell(
473
+ "am start -a android.settings.ACCESSIBILITY_SETTINGS"
474
+ )
475
+
476
+ console.print(
477
+ "\n[yellow]Please complete the following steps on your device:[/]"
478
+ )
479
+ console.print(
480
+ f"1. Find [bold]{PORTAL_PACKAGE_NAME}[/] in the accessibility services list"
481
+ )
482
+ console.print("2. Tap on the service name")
483
+ console.print(
484
+ "3. Toggle the switch to [bold]ON[/] to enable the service"
485
+ )
486
+ console.print("4. Accept any permission dialogs that appear")
487
+
488
+ console.print(
489
+ "\n[bold green]APK installation complete![/] Please manually enable the accessibility service using the steps above."
490
+ )
449
491
 
450
- console.print(
451
- "\n[yellow]Please complete the following steps on your device:[/]"
452
- )
453
- console.print(
454
- f"1. Find [bold]{package}[/] in the accessibility services list"
455
- )
456
- console.print("2. Tap on the service name")
457
- console.print("3. Toggle the switch to [bold]ON[/] to enable the service")
458
- console.print("4. Accept any permission dialogs that appear")
492
+ except Exception as e:
493
+ console.print(f"[bold red]Error:[/] {e}")
459
494
 
460
- console.print(
461
- "\n[bold green]APK installation complete![/] Please manually enable the accessibility service using the steps above."
462
- )
495
+ if debug:
496
+ import traceback
497
+
498
+ traceback.print_exc()
499
+
500
+
501
+ @cli.command()
502
+ @click.option("--device", "-d", help="Device serial number or IP address", default=None)
503
+ @click.option(
504
+ "--debug", is_flag=True, help="Enable verbose debug logging", default=False
505
+ )
506
+ @coro
507
+ async def ping(device: str | None, debug: bool):
508
+ """Ping a device to check if it is ready and accessible."""
509
+ try:
510
+ device_obj = await device_manager.get_device(device)
511
+ if not device_obj:
512
+ console.print(f"[bold red]Error:[/] Could not find device {device}")
513
+ return
463
514
 
515
+ await ping_portal(device_obj, debug)
516
+ console.print(
517
+ "[bold green]Portal is installed and accessible. You're good to go![/]"
518
+ )
464
519
  except Exception as e:
465
520
  console.print(f"[bold red]Error:[/] {e}")
466
- import traceback
521
+ if debug:
522
+ import traceback
467
523
 
468
- traceback.print_exc()
524
+ traceback.print_exc()
469
525
 
470
526
 
471
527
  if __name__ == "__main__":
@@ -499,5 +555,5 @@ if __name__ == "__main__":
499
555
  base_url=base_url,
500
556
  api_base=api_base,
501
557
  api_key=api_key,
502
- ios=ios
558
+ ios=ios,
503
559
  )
droidrun/portal.py ADDED
@@ -0,0 +1,139 @@
1
+ import requests
2
+ import tempfile
3
+ import os
4
+ import contextlib
5
+ from droidrun.adb import Device, DeviceManager
6
+ import asyncio
7
+
8
+ REPO = "droidrun/droidrun-portal"
9
+ ASSET_NAME = "droidrun-portal"
10
+ GITHUB_API_HOSTS = ["https://api.github.com", "https://ungh.cc"]
11
+
12
+ PORTAL_PACKAGE_NAME = "com.droidrun.portal"
13
+ A11Y_SERVICE_NAME = (
14
+ f"{PORTAL_PACKAGE_NAME}/com.droidrun.portal.DroidrunAccessibilityService"
15
+ )
16
+
17
+
18
+ def get_latest_release_assets(debug: bool = False):
19
+ for host in GITHUB_API_HOSTS:
20
+ url = f"{host}/repos/{REPO}/releases/latest"
21
+ response = requests.get(url)
22
+ if response.status_code == 200:
23
+ if debug:
24
+ print(f"Using GitHub release on {host}")
25
+ break
26
+
27
+ response.raise_for_status()
28
+ latest_release = response.json()
29
+
30
+ if "release" in latest_release:
31
+ assets = latest_release["release"]["assets"]
32
+ else:
33
+ assets = latest_release.get("assets", [])
34
+
35
+ return assets
36
+
37
+
38
+ @contextlib.contextmanager
39
+ def download_portal_apk(debug: bool = False):
40
+ assets = get_latest_release_assets(debug)
41
+
42
+ asset_url = None
43
+ for asset in assets:
44
+ if (
45
+ "browser_download_url" in asset
46
+ and "name" in asset
47
+ and asset["name"].startswith(ASSET_NAME)
48
+ ):
49
+ asset_url = asset["browser_download_url"]
50
+ break
51
+ elif "downloadUrl" in asset and os.path.basename(
52
+ asset["downloadUrl"]
53
+ ).startswith(ASSET_NAME):
54
+ asset_url = asset["downloadUrl"]
55
+ break
56
+ else:
57
+ if debug:
58
+ print(asset)
59
+
60
+ if not asset_url:
61
+ raise Exception(f"Asset named '{ASSET_NAME}' not found in the latest release.")
62
+
63
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".apk")
64
+ try:
65
+ r = requests.get(asset_url, stream=True)
66
+ r.raise_for_status()
67
+ for chunk in r.iter_content(chunk_size=8192):
68
+ if chunk:
69
+ tmp.write(chunk)
70
+ tmp.close()
71
+ yield tmp.name
72
+ finally:
73
+ if os.path.exists(tmp.name):
74
+ os.unlink(tmp.name)
75
+
76
+
77
+ async def enable_portal_accessibility(device: Device):
78
+ await device.shell(
79
+ f"settings put secure enabled_accessibility_services {A11Y_SERVICE_NAME}"
80
+ )
81
+ await device.shell("settings put secure accessibility_enabled 1")
82
+
83
+
84
+ async def check_portal_accessibility(device: Device, debug: bool = False) -> bool:
85
+ a11y_services = await device.shell(
86
+ "settings get secure enabled_accessibility_services"
87
+ )
88
+ if not A11Y_SERVICE_NAME in a11y_services:
89
+ if debug:
90
+ print(a11y_services)
91
+ return False
92
+
93
+ a11y_enabled = await device.shell("settings get secure accessibility_enabled")
94
+ if a11y_enabled != "1":
95
+ if debug:
96
+ print(a11y_enabled)
97
+ return False
98
+
99
+ return True
100
+
101
+
102
+ async def ping_portal(device: Device, debug: bool = False):
103
+ """
104
+ Ping the Droidrun Portal to check if it is installed and accessible.
105
+ """
106
+ try:
107
+ packages = await device.list_packages()
108
+ except Exception as e:
109
+ raise Exception(f"Failed to list packages: {e}")
110
+
111
+ if not PORTAL_PACKAGE_NAME in packages:
112
+ if debug:
113
+ print(packages)
114
+ raise Exception("Portal is not installed on the device")
115
+
116
+ if not await check_portal_accessibility(device, debug):
117
+ await device.shell("am start -a android.settings.ACCESSIBILITY_SETTINGS")
118
+ raise Exception(
119
+ "Droidrun Portal is not enabled as an accessibility service on the device"
120
+ )
121
+
122
+ try:
123
+ state = await device.shell(
124
+ "content query --uri content://com.droidrun.portal/state"
125
+ )
126
+ if not "Row: 0 result=" in state:
127
+ raise Exception("Failed to get state from Droidrun Portal")
128
+
129
+ except Exception as e:
130
+ raise Exception(f"Droidrun Portal is not reachable: {e}")
131
+
132
+
133
+ async def test():
134
+ device = await DeviceManager().get_device()
135
+ await ping_portal(device, debug=False)
136
+
137
+
138
+ if __name__ == "__main__":
139
+ asyncio.run(test())
@@ -0,0 +1,4 @@
1
+ from .tracker import capture, flush, print_telemetry_message
2
+ from .events import DroidAgentInitEvent, DroidAgentFinalizeEvent
3
+
4
+ __all__ = ["capture", "flush", "DroidAgentInitEvent", "DroidAgentFinalizeEvent", "print_telemetry_message"]
@@ -0,0 +1,27 @@
1
+ from typing import List
2
+ from droidrun.agent.context import Task
3
+ from pydantic import BaseModel
4
+
5
+ class TelemetryEvent(BaseModel):
6
+ pass
7
+
8
+ class DroidAgentInitEvent(TelemetryEvent):
9
+ goal: str
10
+ llm: str
11
+ tools: str
12
+ personas: str
13
+ max_steps: int
14
+ timeout: int
15
+ vision: bool
16
+ reasoning: bool
17
+ reflection: bool
18
+ enable_tracing: bool
19
+ debug: bool
20
+ save_trajectories: bool
21
+
22
+
23
+ class DroidAgentFinalizeEvent(TelemetryEvent):
24
+ tasks: str
25
+ success: bool
26
+ output: str
27
+ steps: int
@@ -0,0 +1,83 @@
1
+ from posthog import Posthog
2
+ from pathlib import Path
3
+ from uuid import uuid4
4
+ import os
5
+ import logging
6
+ from .events import TelemetryEvent
7
+
8
+ logger = logging.getLogger("droidrun-telemetry")
9
+ droidrun_logger = logging.getLogger("droidrun")
10
+
11
+ PROJECT_API_KEY = "phc_XyD3HKIsetZeRkmnfaBughs8fXWYArSUFc30C0HmRiO"
12
+ HOST = "https://eu.i.posthog.com"
13
+ USER_ID_PATH = Path.home() / ".droidrun" / "user_id"
14
+ RUN_ID = str(uuid4())
15
+
16
+ TELEMETRY_ENABLED_MESSAGE = "🕵️ Anonymized telemetry enabled. See https://docs.droidrun.ai/v3/guides/telemetry for more information."
17
+ TELEMETRY_DISABLED_MESSAGE = "🛑 Anonymized telemetry disabled. Consider setting the DROIDRUN_TELEMETRY_ENABLED environment variable to 'true' to enable telemetry and help us improve DroidRun."
18
+
19
+ posthog = Posthog(
20
+ project_api_key=PROJECT_API_KEY,
21
+ host=HOST,
22
+ disable_geoip=False,
23
+ )
24
+
25
+
26
+ def is_telemetry_enabled():
27
+ telemetry_enabled = os.environ.get("DROIDRUN_TELEMETRY_ENABLED", "true")
28
+ enabled = telemetry_enabled.lower() in ["true", "1", "yes", "y"]
29
+ logger.debug(f"Telemetry enabled: {enabled}")
30
+ return enabled
31
+
32
+
33
+ def print_telemetry_message():
34
+ if is_telemetry_enabled():
35
+ droidrun_logger.info(TELEMETRY_ENABLED_MESSAGE)
36
+
37
+ else:
38
+ droidrun_logger.info(TELEMETRY_DISABLED_MESSAGE)
39
+
40
+
41
+ # Print telemetry message on import
42
+ print_telemetry_message()
43
+
44
+
45
+ def get_user_id() -> str:
46
+ try:
47
+ if not USER_ID_PATH.exists():
48
+ USER_ID_PATH.touch()
49
+ USER_ID_PATH.write_text(str(uuid4()))
50
+ logger.debug(f"User ID: {USER_ID_PATH.read_text()}")
51
+ return USER_ID_PATH.read_text()
52
+ except Exception as e:
53
+ logger.error(f"Error getting user ID: {e}")
54
+ return "unknown"
55
+
56
+
57
+ def capture(event: TelemetryEvent):
58
+ try:
59
+ if not is_telemetry_enabled():
60
+ logger.debug(f"Telemetry disabled, skipping capture of {event}")
61
+ return
62
+ event_name = type(event).__name__
63
+ event_data = event.model_dump()
64
+ properties = {
65
+ "run_id": RUN_ID,
66
+ **event_data,
67
+ }
68
+
69
+ posthog.capture(event_name, distinct_id=get_user_id(), properties=properties)
70
+ logger.debug(f"Captured event: {event_name} with properties: {event}")
71
+ except Exception as e:
72
+ logger.error(f"Error capturing event: {e}")
73
+
74
+
75
+ def flush():
76
+ try:
77
+ if not is_telemetry_enabled():
78
+ logger.debug(f"Telemetry disabled, skipping flush")
79
+ return
80
+ posthog.flush()
81
+ logger.debug(f"Flushed telemetry data")
82
+ except Exception as e:
83
+ logger.error(f"Error flushing telemetry data: {e}")