LLM-Driven Automated Testing Series (06): Mobile Automation Part 2 — DroidRun/Mobilerun's Role-Level Model Split

DroidRun (now renamed Mobilerun) uses a unified device-driver abstraction to fit Android and iOS behind the same tool interface, but it takes a completely different path from ARTEMIS in the previous post: it doesn't pick 'Flash vs Pro' at the task level — it splits models 'by role,' with Manager, Executor, and FastAgent each getting their own model and temperature. This post digs into its multi-agent workflow, the Portal accessibility service, the App Card mechanism, and also states plainly that its '91.4%' benchmark badge has no locally verifiable documentation anywhere in the repo.

·20 min read·AI Engineering

What Mobilerun actually is, and what problem it solves

Mobilerun (this open-source project was originally named DroidRun — the repository path is still droidrun to this day, but the pip package name, CLI command, and docs site have all switched over to mobilerun) has a direct positioning statement: an open-source framework for controlling Android and iOS devices with LLM agents. It gives agents mobile-native tools to inspect UI state, understand screenshots, tap, swipe, type, and plan multi-step workflows, invoked via CLI or a Python API.

Compared to ARTEMIS in the previous post, both solve the same class of problem (letting AI operate a real phone), but they've made different architectural choices. This post skips the feature-tour "what is it" section and goes straight to three questions: How exactly is its multi-agent architecture divided? What does the unified Android/iOS control abstraction actually look like? Is there any verifiable basis for its claimed 91.4% benchmark?

Mobilerun's core features (per its README):

  • Control Android and iOS devices with natural-language instructions
  • Supports OpenAI, Anthropic, Gemini, xAI, Ollama, DeepSeek, OpenRouter, and more
  • Two execution paths: direct task mode and "reasoning mode"
  • Four invocation modes: CLI, terminal UI, Docker, Python code
  • App Cards and credential management, for handling app-specific operating guidance and login state
  • Execution tracing via Arize Phoenix or Langfuse

Two execution modes — but not "task complexity tiers," rather "whether there's a planning layer"

ARTEMIS's Flash/Pro split is fundamentally about "does this need the full plan-verify rhythm or not." Mobilerun's Direct/Reasoning split looks similar on the surface, but reading the code, the two projects divide labor differently.

Direct mode (reasoning=False): goes straight to FastAgent, whose module docstring states plainly:

FastAgent — XML tool-calling agent for device interaction.
 
Uses a structured XML tool-calling protocol. The LLM emits <function_calls>
blocks, the agent parses them, executes the tools via ToolRegistry, and feeds
<function_results> back as user messages.

(mobilerun/agent/fast_agent/fast_agent.py) — a single model, a single loop: the model emits an XML-formatted tool-call block, the framework code parses and executes it, feeds the result back into the conversation history, and loops until the task completes. No separate planning node.

Reasoning mode (reasoning=True): splits into two independent workflows, ManagerAgent (planning) and ExecutorAgent (execution), cycling back and forth:

Goal → Manager (plan) → Executor (action) → Manager (check) → Executor (next) → ...

ManagerAgent's responsibility boundary, per its docstring: analyze current state, create plans and subgoals, track progress, decide when the task is complete. ExecutorAgent has a single, narrower job: take one subgoal from the Manager, look at the current UI state, select and execute one specific action, return the result — no planning, no judging whether the overall task is done. This mirrors the Planner/Operator split in ARTEMIS's Pro mode, but implemented differently: Mobilerun runs on llama_index's Workflow (a state machine defined by @step decorators), not a LangGraph-style graph with conditional edges — the control flow is closer to a simple two-node round-robin than ARTEMIS's multi-branch cyclic graph with its convergence_node/execution_check_node.

Which mode fits which scenario is stated plainly in the docs (docs/concepts/architecture.mdx): Reasoning mode fits "multi-step tasks (booking flights, configuring settings), tasks requiring planning and adaptation, complex workflows across multiple apps"; Direct mode fits "simple actions (screenshots, sending messages), fast execution without planning overhead, well-defined single-step tasks." This lines up almost exactly with ARTEMIS's Flash/Pro split criteria — fundamentally the same engineering judgment ("deterministic short flows get a lightweight loop, exploratory long-horizon flows get planning + verification"), just implemented differently by each project.


The core design trade-off: role-level model split, not task-level mode switching

This is where Mobilerun diverges most from ARTEMIS. ARTEMIS's Flash/Pro is a task-level switch — an entire task runs on either Flash or Pro end to end, no mid-task switching. Mobilerun instead splits models by role: Manager, Executor, FastAgent, plus two auxiliary roles app_opener and structured_output — each role can be independently configured with its own model and sampling parameters. The default configuration (_default_profiles in mobilerun/config_manager/config_manager.py):

"manager":           LLMProfile(provider="GoogleGenAI", model=GEMINI_API_DEFAULT_MODEL, temperature=0.2)
"executor":          LLMProfile(provider="GoogleGenAI", model=GEMINI_API_DEFAULT_MODEL, temperature=0.1)
"fast_agent":         LLMProfile(provider="GoogleGenAI", model=GEMINI_API_DEFAULT_MODEL, temperature=0.2)
"app_opener":         LLMProfile(provider="GoogleGenAI", model=GEMINI_API_DEFAULT_MODEL, temperature=0.0)
"structured_output":  LLMProfile(provider="GoogleGenAI", model=GEMINI_API_DEFAULT_MODEL, temperature=0.0)

All roles default to the same Gemini model, but temperature decreases by role responsibility: Manager gets 0.2 (planning needs a bit of room to explore), Executor gets 0.1 (choosing a specific action should be more deterministic), app_opener/structured_output get 0.0 (purely deterministic tasks needing no randomness at all). The docs' cross-provider config example makes this more concrete:

llm_profiles:
  manager:
    provider: Anthropic
    model: claude-sonnet-4
  executor:
    provider: OpenAI
    model: gpt-4o
  fast_agent:
    provider: GoogleGenAI
    model: gemini-3.5-flash-lite

Planning goes to Claude, execution goes to GPT-4o, direct-mode simple tasks go to a lighter-weight Gemini Flash-Lite — three roles, three different vendors. This points in the same direction as Midscene's Default/Planning/Insight three-role split covered in post 04 (decoupling different capability needs so each can be matched to a model that's actually good at it), but Mobilerun applies that idea to "which model handles planning/execution/direct-mode operation" — a split closer to the multi-agent architecture itself — rather than Midscene's "planning/locating/understanding" perception-layer split.

The two splitting strategies come with different cost models: ARTEMIS's task-level switch requires the user to decide, before the task even starts, "is this task worth the planning overhead of Pro mode." Mobilerun's role-level split requires the user to decide "is this specific role's work worth paying for a more expensive model" — finer granularity, but also more configuration surface, and when something goes wrong, tracking down which role's model needs adjusting is a bit more involved.


Portal: a custom accessibility service, not direct ADB manipulation

How does Mobilerun actually talk to the phone? On Android, through a helper app called Portal; on iOS, through a similar mechanism called the "iOS Portal flow." The README states that mobilerun setup "installs the Mobilerun Portal app, enables its accessibility service, and prepares the device for local control."

The code confirms this directly, via the Portal recovery logic (_recover_portal in mobilerun/agent/droid/droid_agent.py):

async def _recover_portal(self) -> None:
    """Restart Portal's accessibility service and TCP socket server."""
    # restart the accessibility service via shell commands
    await device.shell("settings put secure accessibility_enabled 0")
    await asyncio.sleep(0.5)
    await device.shell(f"settings put secure enabled_accessibility_services {a11y}")
    await device.shell("settings put secure accessibility_enabled 1")

Portal is fundamentally an accessibility service running on-device, with its lifecycle managed directly by the framework through settings put secure shell commands — the same category of approach as ARTEMIS's "Artemis Accessibility Helper" from the previous post: rather than depending on the standard UiAutomation connection (which Android only allows one instance of at a time), a persistent accessibility service reads screen layout instead, sidestepping the UiAutomation connection-exclusivity limit.

The real device-control abstraction layer doesn't live in the main Mobilerun repo at all — it lives in an external dependency package, mobilerun_core_local (mobilerun/tools/driver/__init__.py is just a re-export):

from mobilerun_core_local.driver.android import AndroidDriver
from mobilerun_core_local.driver.base import DeviceDisconnectedError, DeviceDriver
from mobilerun_core_local.driver.ios import IOSDriver

DeviceDriver is the common base class for every platform driver; Android implements it via AndroidDriver (Portal + ADB), iOS via IOSDriver (iOS Portal flow / Simulator). Framework-layer code only depends on this base class's interface, entirely indifferent to whether the underlying implementation is ADB shell commands or iOS's HTTP protocol — a different dimension of abstraction from ARTEMIS's locating tool ("query multiple signals in parallel, trust the most credible one"). This is the classic driver pattern: one shared action semantics, platform-specific implementation underneath. The actual platform differences are isolated inside that external package — not directly inspectable in this repo, only the interface boundary can be confirmed here.

The action set exposed to the Agent above this layer is identical on Android and iOS (docs/concepts/architecture.mdx):

click(index), click_at(x, y), click_area(x1, y1, x2, y2),
long_press(index), long_press_at(x, y),
type(text, index), type_secret(secret_id, index),
swipe(coordinate, coordinate2), system_button(button),
wait(duration), open_app(text),
complete(success, reason)

One design detail in this action list is worth calling out: click(index) depends on an index from the accessibility tree — structured locating; click_at(x, y) is a pure coordinate click — visual/coordinate locating. Both coexist in the same tool set, mapping onto the "Path 1 / Path 2" vocabulary from post 02. But unlike ARTEMIS, Mobilerun masks off coordinate-based tools by default, only auto-unmasking them under specific conditions (the _effective_disabled_tools function in droid_agent.py): only when the screenshot coordinate space aligns with the device's input coordinate space (the screenshot_matches_input_coords flag) and the user hasn't explicitly supplied a disabled-tools list does click_at get removed from the block list. This is a small but worth-noting conservative design choice: by default, it'd rather let the Agent only click via structured indices than casually open up coordinate clicking — because a coordinate click landing on the wrong spot when the model gets the coordinates wrong is a more subtle failure, harder for the tool layer itself to catch, than an index lookup simply failing.


Vision mode: the same general-purpose model looks at the screenshot — not a dedicated locating model

This is the point most worth comparing against ARTEMIS from the previous post. ARTEMIS gave "element locating" its own dedicated Google-built embodied-reasoning model, Gemini Robotics-ER. Mobilerun's --vision/--vision-only toggle does something much simpler — the same screenshot gets handed to the same general-purpose conversational model (whichever model Manager, Executor, or FastAgent is already using) — no extra call to a specialized locating model.

if self.config.agent.reasoning:
    vision_enabled = (
        self.config.agent.vision_only
        or self.config.agent.manager.vision
        or self.config.agent.executor.vision
    )
else:
    vision_enabled = (
        self.config.agent.vision_only or self.config.agent.fast_agent.vision
    )

--vision adds the screenshot into the model's context on top of the existing accessibility-tree info; --vision-only goes further, providing no accessibility tree at all, relying entirely on the screenshot (per the README, "useful for the apps that do not have a11y tree information" — for apps like heavily custom-rendered games or WebView containers that lack accessibility-tree data).

There's one engineering detail worth expanding on here: different model vendors have different resolution budgets for images, and mobilerun/agent/utils/vision_sizing.py maintains a dedicated resize policy keyed on model ID:

# Anthropic models with the high-resolution budget (2576 px / 4784 tokens).
# Unknown Anthropic ids fall back to the standard 1568 budget — never assume
# high-res, since that would re-introduce the undershoot bug.
_ANTHROPIC_STANDARD = (1568, 1568)  # (max_edge, max_tokens)
_ANTHROPIC_HIGHRES = (2576, 4784)

VisionResizePolicy's comment states the intent directly: when multiple vision models are active at once, use the most conservative (smallest) resolution cap across all of them — ensuring the same screenshot maps to the same coordinate system regardless of which model is looking at it, rather than each model independently rescaling to its own preference and causing Manager and Executor to disagree about what "the same pixel point" actually points to on screen. This solves the same underlying class of error as ARTEMIS's Safety Net check ("does the coordinate still fall within the element's bounds") — just at a different point in the pipeline: Mobilerun solves it before sending the image to the model, while ARTEMIS solves a related problem after the model returns its coordinates.

The direct comparison against ARTEMIS: Mobilerun's vision capability is "give the general-purpose model one more input" — no dedicated model investment in spatial coordinate precision. ARTEMIS explicitly judged that "standard LLMs lack sub-pixel spatial coordinate fine-tuning" and brought in Gemini Robotics-ER specifically to address that. This isn't to say Mobilerun's approach is worse — it trades away ARTEMIS's targeted precision backstop for configuration simplicity and one fewer specialized model in the call chain, with coordinate-locating precision resting entirely on the primary model's own visual understanding.


App Cards: writing down "how to use this app" instead of making the model figure it out

Mobilerun has a mechanism ARTEMIS doesn't emphasize: the App Card — an app-specific operating guide written in Markdown, injected into the Manager's system prompt at runtime, keyed by package name. The bundled Gmail App Card (mobilerun/config/app_cards/gmail.md) looks roughly like this:

# Gmail App Guide
 
## Navigation
- Hamburger menu (top-left) for folders (Inbox/Sent/Drafts/Trash, etc.)
- Compose button (bottom-right FAB) to write a new email
- Swipe left/right on an email in the list to quickly archive or delete
 
## Search
- Top search bar supports filter syntax:
  - from:sender@email.com
  - subject:keyword
  - is:unread

The abstract base class AppCardProvider (mobilerun/app_cards/app_card_provider.py) defines a unified interface, load_app_card(package_name, instruction). Concrete implementations can be local files (LocalAppCardProvider), a remote server (ServerAppCardProvider), or a combination of both (CompositeAppCardProvider).

This mechanism addresses the same class of problem as ARTEMIS's Explorer tiers: a general-purpose agent encountering an unfamiliar app for the first time has to feel out its navigation structure through pure exploration — that's expensive, and easy to get wrong. ARTEMIS addresses "how deep should exploration itself go" with its three configurable Explorer precision tiers. Mobilerun takes a different route entirely — writing down operational knowledge a human already has about a specific app directly into a document, injecting it at runtime, and skipping the exploration step altogether. These are complementary rather than competing ideas: exploration handles "what do I do about an app I've never seen," while App Cards handle "an app I've seen before shouldn't need to be re-explored every single time." For a testing scenario where the target apps are a small, fixed set your own team maintains, pre-writing App Cards is far more predictable in cost than relying on the model to explore fresh every run.


Credential management: the model only ever sees the secret's name, never its value

Login-flavored tasks inevitably involve passwords and tokens, and Mobilerun isolates that problem behind a dedicated CredentialManager layer: credentials load from a YAML file or a dict (mobilerun/credential_manager/file_credential_manager.py), and at runtime only the secret's name (e.g. MY_PASSWORD) is ever exposed to the LLM — the actual value never enters the model's context:

available_secrets = []
if (
    self.registry
    and "type_secret" in self.registry.tools
    and self.action_ctx
    and self.action_ctx.credential_manager
):
    available_secrets = await self.action_ctx.credential_manager.get_keys()

The model's available action set includes a dedicated type_secret(secret_id, index) tool — it takes a secret's name, and the actual execution — pulling the real value out of CredentialManager and typing it into the input field — happens entirely inside framework code, outside the model's visibility. This is a genuinely useful engineering detail: if login credentials appeared as plaintext directly in the prompt, that's not just a leak risk in itself (model logs and tracing tools might retain that content) — if some later task asks the model to recall or quote a past action, the raw password could leak out inadvertently through that path too. Separating "knowing a credential exists" from "knowing its value" via one layer of indirection is a standard approach to handling sensitive data in test automation — it's just implemented here at the level of the Agent's tool-call interface.


Macros: recording executed action sequences to save model calls on the next similar task

The mobilerun/macro/ directory implements action recording and replay. MacroRecorder (mobilerun/macro/recorder.py) records, on every action execution, the action itself, before/after UI snapshots, a timestamp, and the elapsed time since the previous step:

class MacroRecorder:
    def record_action(self, action, *, pre_ui=None, post_ui=None):
        # records the action type, parameters, pre/post UI snapshots, timestamp
        ...

At task end, the full action sequence is saved as trajectory.macro, and can be replayed with mobilerun replay <macro_file>. Worth clarifying: this isn't a "macro" in the sense of a hand-written, deterministic script written ahead of time — it's a recording of an action sequence the model actually executed, closer in spirit to a traditional UI automation "record and playback" tool, except the recorded sequence comes from a single LLM-driven exploratory run. For test scenarios, the practical value here is: once the Agent has successfully explored a given multi-step flow once, and that flow will be run repeatedly (say, a fixed login → navigate → assert combination), the recorded macro can be replayed directly on subsequent test runs without re-triggering a full round of model inference — decoupling the cost of exploration from the cost of repeated execution.


Structured output: a post-hoc extraction step, not an execution-time intermediate

Mobilerun's "structured output" feature works a bit differently than intuition might suggest: rather than having the Agent emit structured data during execution, it runs a separate extraction step after the fact — once the whole task is complete and a natural-language final answer is in hand (mobilerun/agent/oneflows/structured_output_agent.py):

class StructuredOutputAgent(Workflow):
    """
    Agent that extracts structured output from text answers.
    Uses LLM.structured_predict() to parse text into Pydantic models.
    """

In other words, the execution pipeline (Manager/Executor or FastAgent) only ever concerns itself with "how do I get this task done," producing a natural-language summary at the end. If the caller passed in a Pydantic model, the framework runs one additional structured_predict() call after the task ends, parsing that natural-language answer into a structured object. This "finish the work first, format the result second" two-phase split has a similar shape to ARTEMIS's Checker (read-only, uninvolved in execution, only reviewing results at task end) — both push "verification/formatting" out of the execution pipeline into a separate closing step, rather than requiring every step during execution to juggle both "doing the work" and "producing tidy structured data" at once.


MCP: routed through the same registration path as custom tools

Mobilerun also supports MCP, but in the opposite direction from ARTEMIS (which exposes 5 MCP tools of its own for external IDEs to call). Mobilerun acts as an MCP client — discovering and calling tools exposed by external MCP servers, then disguising them as ordinary tools indistinguishable from built-ins (click/swipe/type, etc.) inside the Agent's own tool registry:

def mcp_to_mobilerun_tools(mcp_manager):
    """Convert discovered MCP tools to Mobilerun custom tool format."""
    custom_tools = {}
    for tool_name, tool_info in mcp_manager.tools.items():
        custom_tools[tool_name] = {
            "parameters": schema_to_parameters(tool_info.input_schema),
            "description": tool_info.description,
            "function": _create_tool_wrapper(tool_name, mcp_manager),
        }
    return custom_tools

MCPClientManager uses lazy connection — it only actually connects to a given MCP server the first time one of its tools gets called, rather than connecting to every configured server at startup. This is the same protocol used in the opposite direction from the previous post: ARTEMIS lets external IDEs call into its own device-control capabilities as an MCP server; Mobilerun lets its own Agent call out to whatever capability any external MCP server provides (say, a tool that queries an internal CRM, or one that sends a Slack notification) as an MCP client. For a test agent that needs to both operate a phone and, during verification, check on some backend database state, MCP client capability is exactly that bridge.


About that 91.4% benchmark badge

Near the top of the README sits a prominent badge: Benchmark 91.4%, linking to https://mobilerun.ai/benchmark. This is a point worth stating plainly and honestly: there is no supporting documentation for this number anywhere in the local repository — CONTRIBUTING.md says nothing about an evaluation methodology or a test suite, there's no benchmark-related documentation under docs/, and there's no built-in evaluation script in the codebase pointing at whatever task set produced this number. Whether that 91.4% corresponds to AndroidWorld, how many tasks it covers, and what model configuration it was measured under — all of that lives entirely on a webpage outside this repository, and cannot be independently verified from the repo itself.

This differs from ARTEMIS's situation in the previous post — ARTEMIS's AndroidWorld 99%+ at least states, right in the README, the scope of the task set ("20+ apps, 100+ multi-step tasks"), and the config files let you trace through the model-routing logic to reason about roughly what configuration it was likely measured under. This Mobilerun badge, for now, can only be characterized as "a promotional number on an external website," not "a verifiable evaluation result within the repo" — that's not a claim that the number is false, just that at the time of writing there's no local evidence available to verify or refute it. This is itself a concrete application of a writing principle this series keeps coming back to: explain the causal reasoning behind whatever technical details can be verified, and explicitly flag as unverifiable whatever numbers can't be — never gloss over the gap.


Summary

  1. Mobilerun (originally DroidRun) fits Android and iOS behind one unified DeviceDriver/StateProvider abstraction — the action set exposed to the Agent (click/swipe/type, etc.) is identical on both platforms, with platform differences isolated inside an external dependency package (mobilerun_core_local)
  2. Direct mode (FastAgent, an XML tool-calling protocol, a single loop) and Reasoning mode (Manager plans + Executor acts, two workflows cycling) split along similar lines to ARTEMIS's Flash/Pro (deterministic short flows vs. exploratory long-horizon flows), but the implementation vehicle is llama_index's Workflow state machine, not a LangGraph-style conditional graph
  3. The biggest architectural difference from ARTEMIS: ARTEMIS is a task-level mode switch; Mobilerun is a role-level model split — Manager/Executor/FastAgent/app_opener/structured_output can each be independently configured with their own model and temperature, defaulting to progressively lower sampling temperature by role responsibility (0.2 → 0.1 → 0.0)
  4. Android relies on a custom Portal accessibility service (the same category of approach as ARTEMIS's Accessibility Helper, sidestepping the UiAutomation connection-exclusivity limit); coordinate-based tools are masked off by default, only auto-unmasked when the screenshot coordinate space aligns with the device's input coordinate space
  5. Vision mode feeds the same screenshot to the same general-purpose conversational model — no dedicated locating model of the kind ARTEMIS brought in for spatial coordinate precision. App Cards (pre-written app operating docs), credential management (the model only ever sees a secret's name), macro recording/replay, and post-hoc structured-output extraction are a few engineering details worth their own attention
  6. MCP integration runs in the opposite direction from ARTEMIS — Mobilerun is an MCP client, calling out to external servers' tools; the README's 91.4% benchmark badge currently has no verifiable evaluation methodology documented anywhere in the repo, and should be flagged plainly as "an external number, unverified within this repository"

Check out PrimeSkills — a curated marketplace of AI agents and skills that have been validated in real-world, enterprise-grade workflows. No fluff, just what actually works.

Find more useful knowledge and interesting products on my Homepage