Skip to content

Commit ac72386

Browse files
feat(android): add display selection to AndroidAgent
Add a `display` parameter to AndroidAgent/PpadbAgentOs accepting an AndroidDisplay, a list of them, an index, or a name. Explicit AndroidDisplay(s) become the authoritative display list, bypassing the SurfaceFlinger auto-detection so every path - initial selection, set_display_by_*, and the model's select_display_by_unique_id tool - resolves against caller-supplied ids. AndroidDisplay ids are now optional: a None display_id/unique_display_id omits the `-d` flag so input/screencap target adb's default display. This replaces the need for a no-flag subclass and subsumes SingleAndroidDisplay. A model-facing __str__ keeps None ids from being shown as selectable. Add `display_allow_switching` (default True); when False the runtime display/device selection tools are removed so a pinned display cannot be changed mid-run. Docs: new "Selecting a display" section in 02_using_agents.md. Tests: tests/unit/tools/android/test_display_selection.py (15 cases). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8049b7d commit ac72386

6 files changed

Lines changed: 287 additions & 22 deletions

File tree

‎docs/02_using_agents.md‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,41 @@ Requires the `android` dependency installed (`pip install askui[android]`) and a
3434

3535
**Default tools:** `screenshot`, `tap`, `type`, `swipe`, `drag_and_drop`, `key_tap_event`, `key_combination`, `shell`, `select_device_by_serial_number`, `select_display_by_unique_id`, `get_connected_devices_serial_numbers`, `get_connected_displays_infos`, `get_current_connected_device_infos`
3636

37+
### Selecting a display
38+
39+
By default the agent drives the first detected display. On multi-display hardware (e.g. automotive head units) you can pin a specific display with the `display` parameter:
40+
41+
```python
42+
from askui import AndroidAgent
43+
from askui.tools.android.agent_os import AndroidDisplay
44+
45+
# Pin one display by its exact ids (bypasses auto-detection).
46+
# The ids below are placeholders — read your device's real values from
47+
# `adb shell dumpsys display` (see the command at the end of this section).
48+
with AndroidAgent(
49+
device="emulator-5554",
50+
display=AndroidDisplay(unique_display_id=1234567890123456789, display_name="secondary", display_id=2),
51+
display_allow_switching=False,
52+
) as agent:
53+
agent.act("Open settings")
54+
```
55+
56+
`display` accepts:
57+
58+
- **`AndroidDisplay`** — pins that exact display and bypasses auto-detection, so you control the ids used for shell commands. `display_id` is the logical id passed to `input` (tap/swipe/type) as `-d <id>`; `unique_display_id` is the physical id passed to `screencap` as `-d <id>`. Get both from the device: `adb shell dumpsys display` (look for the `mViewports` line, which maps `displayId ↔ uniqueId`).
59+
- **`list[AndroidDisplay]`** — the authoritative set of selectable displays. The first is active, and the agent may switch among them at runtime with correct ids.
60+
- **`int`** — select by index, **`str`** — select by name (both via auto-detection).
61+
62+
Either id may be `None`, which omits the `-d` flag so that command targets adb's **default display** (display 0). Use `display_id=None` only when your target screen is the default display; on a multi-display setup, pass the real logical id instead.
63+
64+
Set `display_allow_switching=False` to remove the runtime display/device selection tools, so a pinned `display` cannot be changed mid-run by the model.
65+
66+
You can find the ids for a device with (the quotes keep the pipe inside the device shell, so this works the same on PowerShell, cmd, and bash):
67+
68+
```bash
69+
adb shell "dumpsys display | grep -iE 'mViewports|uniqueId'"
70+
```
71+
3772
## WebVisionAgent
3873

3974
For web browser automation using Playwright. Extends `ComputerAgent` with web-specific tools like navigation, URL handling, and page title retrieval.

‎src/askui/android_agent.py‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from askui.models.shared.tools import Tool
1616
from askui.models.shared.truncation_strategies import TruncationStrategy
1717
from askui.prompts.act_prompts import create_android_agent_prompt
18-
from askui.tools.android.agent_os import ANDROID_KEY
18+
from askui.tools.android.agent_os import ANDROID_KEY, AndroidDisplay
1919
from askui.tools.android.agent_os_facade import AndroidAgentOsFacade
2020
from askui.tools.android.ppadb_agent_os import PpadbAgentOs
2121
from askui.tools.android.tools import (
@@ -50,6 +50,8 @@ class AndroidAgent(Agent):
5050
5151
Args:
5252
device (str | int, optional): The Android device to connect to. Can be either a serial number (as a `str`) or an index (as an `int`) representing the position in the `adb devices` list. Index `0` refers to the first device. Defaults to `0`.
53+
display (AndroidDisplay | list[AndroidDisplay] | int | str | None, optional): Which display to drive. An `AndroidDisplay` pins that exact display (bypassing auto-detection, so you control the input/screencap `-d` ids). A `list[AndroidDisplay]` becomes the authoritative set of selectable displays — the first is active and the model may switch among them at runtime with correct ids. An `int` selects by index and a `str` by name, both via auto-detection. `None` (default) auto-detects and selects the first display.
54+
display_allow_switching (bool, optional): When `False`, the runtime display/device selection tools are removed so a pinned `display` cannot be changed mid-run. Defaults to `True`.
5355
reporters (list[Reporter] | None, optional): List of reporter instances for logging and reporting. If `None`, an empty list is used.
5456
settings (AgentSettings | None, optional): Provider-based model settings. If `None`, uses the default AskUI model stack.
5557
retry (Retry, optional): The retry instance to use for retrying failed actions. Defaults to `ConfigurableRetry` with exponential backoff. Currently only supported for `locate()` method.
@@ -81,6 +83,8 @@ class AndroidAgent(Agent):
8183
def __init__(
8284
self,
8385
device: str | int = 0,
86+
display: "AndroidDisplay | list[AndroidDisplay] | int | str | None" = None,
87+
display_allow_switching: bool = True,
8488
reporters: list[Reporter] | None = None,
8589
settings: AgentSettings | None = None,
8690
retry: Retry | None = None,
@@ -90,11 +94,16 @@ def __init__(
9094
secrets: list[Secret] | None = None,
9195
) -> None:
9296
reporter = CompositeReporter(reporters=reporters)
93-
self.os = PpadbAgentOs(device_identifier=device, reporter=reporter)
97+
self.os = PpadbAgentOs(
98+
device_identifier=device, display=display, reporter=reporter
99+
)
100+
default_tools = self._apply_display_tool_policy(
101+
self.get_default_tools(), display_allow_switching
102+
)
94103
super().__init__(
95104
reporter=reporter,
96105
retry=retry,
97-
tools=self.get_default_tools() + (act_tools or []),
106+
tools=default_tools + (act_tools or []),
98107
agent_os=self.os,
99108
settings=settings,
100109
callbacks=callbacks,
@@ -362,6 +371,30 @@ def set_device_by_serial_number(
362371
)
363372
self.os.set_device_by_serial_number(device_sn)
364373

374+
@staticmethod
375+
def _apply_display_tool_policy(
376+
tools: list[Tool], display_allow_switching: bool
377+
) -> list[Tool]:
378+
"""Drop the display/device *mutating* tools when switching is disabled.
379+
380+
The read-only display-info tools are kept. Device selection is included
381+
because selecting a device resets the active display to index 0, which
382+
would undo a pinned `display`.
383+
"""
384+
if display_allow_switching:
385+
return tools
386+
return [
387+
t
388+
for t in tools
389+
if not isinstance(
390+
t,
391+
(
392+
AndroidSelectDisplayByUniqueIDTool,
393+
AndroidSelectDeviceBySerialNumberTool,
394+
),
395+
)
396+
]
397+
365398
@staticmethod
366399
def get_default_tools() -> list[Tool]:
367400
return [

‎src/askui/tools/android/agent_os.py‎

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -201,53 +201,86 @@
201201

202202

203203
class AndroidDisplay:
204+
"""A selectable Android display.
205+
206+
``display_id`` is the logical DisplayManager id consumed by ``input``
207+
(tap/swipe/type/keyevent). ``unique_display_id`` is the physical display id
208+
consumed by ``screencap``. Either may be ``None``, in which case the
209+
corresponding shell command runs with no ``-d`` flag and therefore targets
210+
adb's default display (display 0).
211+
212+
Passing ``None`` for ``display_id`` is only correct when the target screen
213+
IS the default display; on a multi-display setup where the agent switches
214+
displays, provide the real logical id instead (a ``None`` input flag ignores
215+
which display is currently active). Likewise, a display with
216+
``unique_display_id=None`` cannot be selected by the model at runtime — the
217+
``select_display_by_unique_id`` tool needs a concrete unique id — so use it
218+
only for a pinned, non-switchable display.
219+
"""
220+
204221
def __init__(
205-
self, unique_display_id: int, display_name: str, display_id: int
222+
self,
223+
unique_display_id: int | None,
224+
display_name: str,
225+
display_id: int | None,
206226
) -> None:
207-
self.unique_display_id: int = unique_display_id
227+
self.unique_display_id: int | None = unique_display_id
208228
self.display_name: str = display_name
209-
self.display_id: int = display_id
229+
self.display_id: int | None = display_id
210230

211231
def __repr__(self) -> str:
212232
return (
213233
f"AndroidDisplay(unique_display_id={self.unique_display_id}, "
214234
f"display_name={self.display_name}, display_id={self.display_id})"
215235
)
216236

237+
def __str__(self) -> str:
238+
# Model-facing rendering (used by the display-info tools). Only surface a
239+
# unique id the model can hand back to select_display_by_unique_id; a
240+
# None id means "default display" and is not selectable.
241+
if self.unique_display_id is None:
242+
return f"display '{self.display_name}' (default display)"
243+
return (
244+
f"display '{self.display_name}' "
245+
f"(unique_display_id={self.unique_display_id})"
246+
)
247+
217248
def get_display_id_flag(self) -> str:
218249
"""
219-
Returns the display ID flag for shell commands.
250+
Returns the display ID flag for input/tap/swipe/type shell commands.
220251
221252
Returns:
222-
str: The display ID flag in the format `-d {display_id}`.
253+
str: ``-d {display_id}``, or an empty string when ``display_id`` is
254+
``None`` (input then targets adb's default display).
223255
"""
224-
return f"-d {self.display_id}"
256+
return "" if self.display_id is None else f"-d {self.display_id}"
225257

226258
def get_display_unique_id_flag(self) -> str:
227259
"""
228-
Returns the display unique ID flag for shell screencap command.
260+
Returns the display unique ID flag for the screencap shell command.
229261
230262
Returns:
231-
str: The display unique ID flag in the format `-d {unique_display_id}`.
263+
str: ``-d {unique_display_id}``, or an empty string when
264+
``unique_display_id`` is ``None`` (screencap then targets the default
265+
display).
232266
"""
233-
return f"-d {self.unique_display_id}"
267+
return (
268+
""
269+
if self.unique_display_id is None
270+
else f"-d {self.unique_display_id}"
271+
)
234272

235273

236274
class SingleAndroidDisplay(AndroidDisplay):
237275
"""
238276
Single display when there is only one display connected.
277+
278+
Both ids are ``None`` so input and screencap run without a ``-d`` flag
279+
(adb's default display).
239280
"""
240281

241282
def __init__(self, display_name: str) -> None:
242-
super().__init__(0, display_name, 0)
243-
244-
# In case of a single display, the display id flag is not needed
245-
def get_display_id_flag(self) -> str:
246-
return ""
247-
248-
# In case of a single display, the display unique id flag is not needed
249-
def get_display_unique_id_flag(self) -> str:
250-
return ""
283+
super().__init__(None, display_name, None)
251284

252285

253286
class UnknownAndroidDisplay(SingleAndroidDisplay):

‎src/askui/tools/android/ppadb_agent_os.py‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ class PpadbAgentOs(AndroidAgentOs):
4141
_UIAUTOMATOR_DUMP_PATH: str = "/data/local/tmp/askui_window_dump.xml"
4242

4343
def __init__(
44-
self, reporter: Reporter = NULL_REPORTER, device_identifier: str | int = 0
44+
self,
45+
reporter: Reporter = NULL_REPORTER,
46+
device_identifier: str | int = 0,
47+
display: "AndroidDisplay | list[AndroidDisplay] | int | str | None" = None,
4548
) -> None:
4649
self._client: Optional[AdbClient] = None
4750
self._device: Optional[AndroidDevice] = None
@@ -50,6 +53,21 @@ def __init__(
5053
self._selected_display: Optional[AndroidDisplay] = None
5154
self._reporter: Reporter = reporter
5255
self._device_identifier: str | int = device_identifier
56+
self._display_config = display
57+
# When the caller supplies explicit AndroidDisplay(s), they become the
58+
# authoritative display list — get_connected_displays() returns them
59+
# verbatim instead of the SurfaceFlinger auto-detection, so both the
60+
# initial selection AND every runtime set_display_by_* (incl. the model's
61+
# select_display_by_unique_id tool) resolve against the caller's correct
62+
# ids. int/str selectors keep using auto-detection.
63+
self._display_override: Optional[list[AndroidDisplay]] = None
64+
if isinstance(display, AndroidDisplay):
65+
self._display_override = [display]
66+
elif isinstance(display, list):
67+
if not display:
68+
msg = "display list must not be empty"
69+
raise AndroidAgentOsError(msg)
70+
self._display_override = list(display)
5371

5472
def connect_adb_client(self) -> None:
5573
if self._client is not None:
@@ -77,6 +95,15 @@ def connect(self) -> None:
7795
self.set_device_by_serial_number(self._device_identifier)
7896
else:
7997
self.set_device_by_index(self._device_identifier)
98+
# Device selection defaults the active display to index 0. With an
99+
# override list that already resolves to the first supplied display; for
100+
# int/str selectors, apply the caller's explicit choice on top.
101+
if isinstance(self._display_config, bool):
102+
pass # bool is an int subclass; never treat True/False as an index
103+
elif isinstance(self._display_config, int):
104+
self.set_display_by_index(self._display_config)
105+
elif isinstance(self._display_config, str):
106+
self.set_display_by_name(self._display_config)
80107
device: AndroidDevice = self._get_selected_device()
81108
device.wait_boot_complete()
82109

@@ -97,6 +124,8 @@ def _set_display(self, display: AndroidDisplay) -> None:
97124
)
98125

99126
def get_connected_displays(self) -> list[AndroidDisplay]:
127+
if self._display_override is not None:
128+
return list(self._display_override)
100129
device: AndroidDevice = self._get_selected_device()
101130
displays: list[AndroidDisplay] = []
102131
output: str = device.shell(

‎tests/unit/tools/android/__init__.py‎

Whitespace-only changes.

0 commit comments

Comments
 (0)