LLM-Driven Automated Testing Series (08): Mobile Automation (IV) — AppAgent: Combining Parser Trees with Visual Features

AppAgent is Tencent's open-source CHI 2025 project. Unlike the pure-visual (ARTEMIS/Mobilerun) or pure-coordinate (Mobile-Agent-v3) approaches covered earlier, it uses the accessibility tree to draw numeric labels on screenshot elements, and the model only needs to pick a number — the 'parser tree' and 'vision' aren't two independent perception channels at all; structural information handles label placement while the vision model handles the decision. This piece breaks down how the autonomous-exploration phase builds documentation via a four-way reflection (BACK/INEFFECTIVE/CONTINUE/SUCCESS), how the grid-fallback mechanism degrades when the parser tree fails, and honestly points out a real coordinate-conversion bug living inside that grid-fallback path.

·18 min read·AI Engineering

What Is AppAgent, and What Problem Does It Solve

AppAgent is a multimodal agent framework from Tencent, published at CHI 2025 (arXiv:2312.13771). Its goal is to let a multimodal LLM operate a smartphone app the way a human would — tapping and swiping — without depending on any system back-end access. This matches the "simulate human interaction" philosophy of the earlier projects in this series, rather than calling into an app's internal APIs or a structured automated-testing framework.

AppAgent's core design is a two-phase workflow: an exploration phase first lets the agent build up natural-language documentation for the app's UI elements, either through autonomous trial-and-error or by observing human demonstrations; then a deployment phase consumes that documentation to decide how to act when given a task. This differs from the "decide as you go, no persistent knowledge base" approach we've seen so far — AppAgent explicitly splits "learning how to use this app" from "using this app to complete a task" into two separate phases, and the output of the learning phase can be reused repeatedly.

Compared to the previous three articles, AppAgent's key difference lies in the structure of its perception-layer input. This piece skips re-explaining "what it is" and goes straight to three questions: How exactly does the parser-tree + visual dual-input action-space design work, and what tradeoffs does it make on grounding precision and cost against the pure-visual and pure-coordinate approaches? How does the exploration phase concretely learn to use an app on its own? And how does this dual-input approach degrade when the parser tree is missing or unreliable?


Dual Input Isn't a Fusion of Two Perception Channels — It's a Split Between "Structural Placement" and "Visual Decision"

First, a correction to an intuition that "dual input" easily invites. In the earlier articles, ARTEMIS paired a general-purpose VLM with a dedicated Gemini Robotics-ER model for grounding reinforcement, and Mobile-Agent-v3 trained its own dedicated grounding model to output absolute coordinates — both of these are "two models collaborating." AppAgent's parser-tree + vision dual input is nothing like that: the parser tree is not a second model — it's just deterministic XML data, and its only job is to tell the code "which interactive elements exist on screen and where," so numeric labels can be drawn on the screenshot. The actual decision-making, from start to finish, is done by a single vision-language model, which only ever sees "a screenshot with numbers overlaid on it" and only ever outputs "which number to pick."

This pipeline is implemented in traverse_tree() inside and_controller.py: it walks the UI Automator XML tree pulled via adb shell uiautomator dump, collecting all nodes marked clickable="true" and focusable="true" along with their bounding boxes. The two node types are each walked separately, and then de-duplicated using a Euclidean-distance threshold (MIN_DIST: 30 in config.yaml) — if a focusable element's center point is closer than this threshold to an already-collected clickable element's center, they're treated as the same interaction target and not double-labeled. The de-duplicated element list is passed to draw_bbox_multi() (in utils.py), which draws numeric labels on the screenshot — clickable elements in red, scrollable elements in blue. This labeled image is what actually gets fed to the vision model.

The action space the model sees, drawn from task_template in prompts.py, looks like this:

tap(element: int)
text(element: int, text_str: string)
long_press(element: int)
swipe(element: int, direction: string, dist: string)
grid()

Aside from grid() (covered in detail below), every action's first argument is a "numeric label" — never a coordinate, never an element path. The model's decision space is strictly confined to "numbers visible on the current screenshot." This is the tradeoff this design buys: the model doesn't need pixel-level coordinate-grounding ability at all — it only needs the weaker, more stable ability to "look at an image and pick a number." The cost is that everything depends on whether the parser tree can correctly enumerate the interactive elements. Compared to Mobile-Agent-v3, which pushes grounding precision back into model training (GUI-Owl outputs absolute coordinates directly), AppAgent pushes grounding precision into deterministic runtime code instead — no dedicated model needs training, but the robustness this buys is entirely tied to how completely the Android accessibility framework exposes information.

Element identity is established via get_id_from_element(), which builds a uid: it prefers the sanitized resource-id as the primary key; if an element has no resource-id (common for custom-rendered widgets), it falls back to f"{class}_{width}_{height}", optionally appending a truncated content-desc. This uid is the primary key under which all documentation gets persisted — knowledge learned during exploration is stored by "this structural identity," not by screen position, so the same element can hit the same documentation even if its position shifts across screenshots.


Exploration Phase: An Act-Then-Reflect Loop, Where a Four-Way Reflection Decides What Gets Documented and What Never Gets Touched Again

The exploration phase offers two paths: autonomous exploration (self_explorer.py) and human demonstration (step_recorder.py + document_generation.py). Autonomous exploration comes first — it's the core answer to "how does the exploration phase autonomously learn how to use an app?"

self_explorer.py's main loop is a two-stage "act, then reflect" structure: each round first takes a screenshot, parses out the element list, and calls the vision model to pick and execute one action; after execution, it takes an "after" screenshot, then hands both before/after images to the same model for reflection. The reflection prompt (self_explore_reflect_template) asks the model to judge what effect the action actually had, choosing one of four outcomes:

  • SUCCESS: the action met expectations
  • BACK: the action landed on the wrong page and needs to be undone — this judgment actually triggers a real controller.back() call to reverse the action
  • CONTINUE: the action did change the screen, but didn't help the task — no undo, just move on
  • INEFFECTIVE: the action executed, but nothing on screen changed

Of these four, only INEFFECTIVE generates no documentation at all — since nothing happened, there's nothing for the model to learn. BACK, CONTINUE, and SUCCESS all generate and save a natural-language documentation entry for the element. The reasoning is: even if an action didn't help the current task (CONTINUE) or was wrong (BACK), the model still learned something functional from "what happened after tapping this element" — and that knowledge is worth keeping.

More interestingly, there's an active pruning mechanism: self_explorer.py maintains a useless_list set, adding the uids of elements judged INEFFECTIVE or BACK/CONTINUE (note that even CONTINUE-judged elements get blacklisted, even though documentation was generated for them). Every subsequent round skips any candidate whose uid is in this set via if elem.uid in useless_list: continue. Only elements judged SUCCESS are never blacklisted, remaining in the candidate pool indefinitely. The intent is straightforward: once an element is found "useless" or "unhelpful," don't waste more of the limited exploration budget (MAX_ROUNDS: 20 in config.yaml) retrying it — spend it on elements not yet verified. This is a plain but genuinely effective form of "dead-end pruning." Compared to the fault-tolerance designs we've seen elsewhere — Mobile-Agent-v3's dedicated ActionReflector role, or its error-threshold escalation ("two consecutive failures escalate to Manager") — AppAgent's reflection outcome here directly determines whether the exploration space itself shrinks, not just "whether to retry the current action."

That said, this autonomous exploration mechanism has a clear capability boundary — worth calling out separately from the "degraded behavior" question: autonomous exploration can never learn what a text-input field is for. There's a line in self_explorer.py's main loop reflection branch — elif act_name == "text": continue — meaning if the action executed this round is text(), the code skips straight to the next round without any reflection or documentation step. So autonomous exploration can execute text-input actions, but it never generates documentation for them. Scanning the entire exploration script, the text_doc_template prompt is never referenced in self_explorer.py at all — it only exists in document_generation.py (the human-demonstration path). This means that if an app's key interactions depend on input fields (a search box, a login form), autonomous exploration alone can never accumulate documentation for them — human demonstration is required to fill that gap.


Grid Fallback: How the System Degrades When the Parser Tree Fails — Including a Real Coordinate Bug

This is the core mechanism directly answering "how does this dual-input approach degrade when the parser tree is missing or unreliable?"

When the vision model finds that the element it wants to interact with isn't labeled with a number (typically WebViews, custom-rendered widgets, or game screens — content that never shows up in the accessibility tree, so traverse_tree() simply can't enumerate it), the prompt template (task_template) defines a zero-argument action, grid(), with the documentation stating plainly:

"call this action when you find the element you want to interact with is not labeled with a numeric tag and other elements with numeric tags cannot help with the task"

After grid() is called, task_executor.py sets grid_on to True, but only for the next round — after any non-grid action executes, grid_on gets reset back to False. In grid mode, draw_grid() (in utils.py) overlays a grid on the screenshot, with the cell size determined by finding an integer divisor of the image's height/width that falls within [120, 180] pixels (falling back to a fixed 120px if none exists), which determines the row/column count. Instead of picking a number, the model now locates targets via tap_grid(area, subarea) / long_press_grid(area, subarea) / swipe_grid(...) — area picks which grid cell, and subarea picks one of nine named positions (center plus eight compass directions), providing a further precision refinement within the cell.

Two things about this fallback design are worth pointing out:

First, the action space itself shrinks — this isn't just reduced precision. Comparing task_template (non-grid) against task_template_grid (grid mode), grid mode has no text() action at all. Once the parser tree fails and the model has to fall back to the grid, it completely loses the ability to input text — this isn't "coordinate precision got worse but it's still usable," it's an entire category of action disappearing from the action space outright. If a task needs to type into an input field that the accessibility tree doesn't cover (say, a form embedded in a WebView), the grid-fallback mechanism simply cannot save that scenario.

Second, there's a real coordinate bug in the grid-fallback path — not a guess, confirmed by directly reading the and_controller.py source:

def swipe_precise(self, start, end, duration=400):
    start_x, start_y = start
    end_x, end_y = end
    adb_command = f"adb -s {self.device} shell input swipe {start_x} {start_x} {end_x} {end_y} {duration}"
    ret = execute_adb(adb_command)
    return ret

When building the adb command string, the starting coordinate uses start_x twice (the second occurrence should have been start_y), so the actual swipe's starting Y coordinate ends up being the value of X instead — nothing close to the intended starting point. A grep across the codebase confirms swipe_precise has exactly one call site — line 275 of task_executor.py, inside the swipe_grid action branch. In other words, this bug only ever fires when the grid fallback is actually triggered — the non-grid mode uses a separate swipe() method entirely and is unaffected. This is the same category of finding as the previous article's Mobile-Agent-v3 bug (real, HarmonyOS-only, with a clear trigger condition, pointing to a gap in test coverage on that specific path), but different in kind: that bug threw an exception and crashed the run outright, while this one throws no error at all — it silently swipes from the wrong Y position. And it happens to land precisely in "parser tree failed, fell back to the grid" — a path that was already a degraded fallback, now carrying an additional silent defect on top of it.


Human Demonstration: A Second Learning Path, With Its Own Documentation-Refinement Capability

Besides autonomous exploration, AppAgent also offers a human-demonstration path (step_recorder.py handles recording, document_generation.py generates documentation). This path differs from autonomous exploration in two ways.

Recording format: at each step, step_recorder.py takes a screenshot, dumps the XML, draws labels, then asks a human in the terminal which of tap/text/long press/swipe/stop they want to perform, writing the action into record.txt using a custom ":sep:" delimiter to concatenate compound parameters — for example, a text-input action gets recorded as text(3:sep:"hello"):::<uid> (element index, input text, and element uid joined by different delimiters). document_generation.py reads this record.txt, looks up the before/after screenshots for each step, and calls the appropriate documentation template to generate a description — and this is exactly where text_doc_template gets used, the only place in the entire project that ever generates documentation for text-input elements, directly corresponding to the capability gap in autonomous exploration noted above.

Refinement capability: document_generation.py checks the DOC_REFINE flag in config.yaml. If true, and it encounters a (uid, action type) pair that already has documentation, it appends a refine_doc_suffix to the prompt, asking the model to merge the new observation with the prior documentation and regenerate a more complete description. If false (the default), it skips regeneration entirely, logging a message suggesting the user enable the option. Autonomous exploration has no equivalent refinement logic at all — if self_explorer.py finds that a (uid, action type) pair already has documentation, it just logs "already exists" and moves on, regardless of the DOC_REFINE setting. In other words, iteratively refining documentation quality is currently only possible via the human-demonstration path — autonomous exploration is "write once, never touch again."

Both paths write documentation in the same format — a Python dict literal ({"tap": "", "text": "", "v_swipe": "", "h_swipe": "", "long_press": ""}), serialized via str(doc_content) when written and read back via ast.literal_eval() at deployment time. The deployment script task_executor.py doesn't distinguish whether documentation came from autonomous exploration or human demonstration — it just loads whichever directory (auto_docs/ or demo_docs/) the user points it at.


From Paper to Tool: Engineering Fingerprints Left in the Code

Beyond the swipe_precise bug already confirmed above, reading through the full set of scripts turns up a few more minor but worth-noting traces of engineering.

All three response parsers share the same fragile single-line regex: model.py's parse_explore_rsp()/parse_grid_rsp()/parse_reflect_rsp() all use re.findall(r"Field: (.*?)$", rsp, re.MULTILINE) — a non-greedy match that stops at end-of-line — to extract fields like Observation:/Thought:/Action:/Decision:/Documentation:. If a model's actual field content ever spans multiple lines, it gets silently truncated to just the first line, with no error raised.

Parse failure terminates the entire task, with no retry: all three parsers wrap their extraction in a bare try/except Exception, printing the raw response and returning ["ERROR"] on failure; the main loops in task_executor.py/self_explorer.py detect this sentinel and break out of the entire task run. A single malformed model response can end the whole task early — there's no reprompt, no format-correction retry. Compared to Mobile-Agent-v3's "escalate only after consecutive failures" layered fault tolerance, this is a completely different order of simplicity — AppAgent's error handling here has only two states, "normal" and "task terminated," with no buffer in between.

Cost estimation is hardcoded to a stale rate, decoupled from the actual configured model: OpenAIModel.get_model_response() prints an estimated cost on every request, computed as prompt_tokens/1000*0.01 + completion_tokens/1000*0.03 — GPT-4V-era pricing, with no connection at all to whatever OPENAI_API_MODEL (e.g. "gpt-4o") is actually configured in config.yaml. If the user switches models, this printed cost estimate silently becomes wrong, with no code change needed to trigger the staleness.

Image MIME type doesn't match the actual screenshot format: OpenAIModel encodes screenshots as data:image/jpeg;base64,... when sending requests, but the screenshot command in and_controller.py is screencap -p, which actually produces PNG. This mismatch currently looks harmless (most APIs aren't strict about MIME type), but it's a real, directly-readable oversight in the code. OpenAIModel uses a hand-rolled requests.post() call rather than the official SDK; its sibling QwenModel instead uses the official dashscope SDK, passing local file:// paths rather than base64 — the two provider subclasses share no image-handling code at all, each written in whatever style is idiomatic for its own provider.

The README's evaluation section has no specific accuracy numbers to cite: AppAgent's README only states that its evaluation set covers "9 apps, 45 tasks" (pointing to a separate assets/testset.md benchmark document), without giving any specific success-rate or accuracy percentage in the README body itself. Unlike some projects in this series whose README carries badge-style numbers like "91.4%," there's nothing comparable here to verify or question — so no number gets cited in this article. Worth stating honestly: not every project leaves behind a number that needs a "treat with caution" flag; this time AppAgent simply doesn't have one.


Aside: AppAgentX Points to a Newer Direction, But There's No Local Code to Verify

The README mentions a newer follow-up project, AppAgentX, described as "the next-generation GUI Agent with evolving mechanism." Locally, this project exists only as a link in the repo — there's no actual source code to read. Unlike the previous article's Mobile-Agent-v3.5 (where at least a 287-line run script was available to verify directly), there's nothing verifiable here at all, so this article can only note that it exists, without making any claims about its mechanism.


Summary

  1. AppAgent's "parser tree + vision" dual input isn't a fusion of two models — it's a division of labor: the accessibility tree (UI Automator XML) deterministically tells the code "which elements exist and where to draw numeric labels," while the actual operating decision is made 100% by the vision-language model based on "a screenshot with numbers on it." This is a third distinct architectural philosophy alongside ARTEMIS's dual-model reinforcement and Mobile-Agent-v3's self-trained grounding model.
  2. Autonomous exploration learns an app via an "act, then reflect" loop, with reflection outcomes split into four categories — BACK/INEFFECTIVE/CONTINUE/SUCCESS. INEFFECTIVE and unsuccessfully-judged elements get actively blacklisted (useless_list) and are never retried, but autonomous exploration has a clear boundary: it never generates documentation for text-input actions — only the human-demonstration path fills that gap.
  3. The grid-fallback mechanism (grid()) is the degradation path when the parser tree fails, but this isn't just reduced precision — grid mode outright loses the text() action, and the swipe_precise() method called by swipe_grid has a real coordinate bug (the starting Y coordinate incorrectly reuses the value of X) that happens to fire exactly on this already-degraded fallback path.
  4. The human-demonstration path has two capabilities autonomous exploration lacks: it can generate documentation for text-input fields, and it can refine/merge existing documentation when DOC_REFINE is enabled; autonomous exploration has no refinement logic at all — documentation is written once and never updated.
  5. Other engineering traces visible in the code: all three response parsers share the same fragile regex that silently truncates multi-line content, parse failure terminates the entire task with no retry mechanism, cost estimation is hardcoded to a stale rate decoupled from the actual configured model, and the image MIME type doesn't match the actual screenshot format (PNG vs. the declared JPEG) — all real, directly-readable implementation details unrelated to core functionality.
  6. The README gives no specific success-rate numbers worth citing or questioning — stated here honestly, without any unfounded citation.

Check out PrimeSkills — a curated marketplace for AI agents and skills, all validated in real enterprise workflows. No fluff, just what actually works.

Find more useful knowledge and interesting products on my Homepage