#!/usr/bin/env python3 """bench_env CLI (async)""" import argparse import asyncio import os import sys from bench_env.runner import ExecRunner, SerialRunner, ParallelRunner from bench_env.agent import list_agents from bench_env.logger import configure_logging from bench_env.task_listing import list_tasks def create_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(description="Mobile GUI Agent Benchmark") # Mode mode = p.add_mutually_exclusive_group() mode.add_argument("--list", action="store_true", help="List all available tasks") mode.add_argument("--exec", type=str, help="Execute instruction (no judging)") mode.add_argument("--task-id", type=str, help="Run specific task") mode.add_argument( "--task-ids", type=str, help="Run multiple tasks by comma-separated task ids (e.g. suite.ClassA,suite.ClassB).", ) # Tasks p.add_argument( "--suite", type=str, help="Filter tasks by suite, comma-separated (e.g. wechat,redbook)", ) p.add_argument( "--filter-difficulty", type=str, dest="filter_difficulty", help="Filter tasks by difficulty, comma-separated (e.g. L1,L2)", ) p.add_argument( "--filter-objective", type=str, dest="filter_objective", help="Filter tasks by objective, comma-separated (e.g. query,operate)", ) p.add_argument( "--filter-composition", type=str, dest="filter_composition", help="Filter tasks by composition, comma-separated (e.g. atomic,sequential)", ) p.add_argument( "--filter-scope", type=str, dest="filter_scope", help="Filter tasks by scope, comma-separated (e.g. S1,S2)", ) p.add_argument( "--filter-capabilities", type=str, dest="filter_capabilities", help="Filter tasks by capabilities (ANY match), comma-separated (e.g. query,search)", ) filter_af = p.add_mutually_exclusive_group() filter_af.add_argument( "--filter-has-answer-fields", dest="filter_has_answer_fields", action="store_true", default=None, help="Only include tasks that have answer_fields defined", ) filter_af.add_argument( "--filter-no-answer-fields", dest="filter_has_answer_fields", action="store_false", help="Only include tasks that do NOT have answer_fields", ) p.add_argument( "--filter-mode", type=str, dest="filter_mode", choices=["and", "or"], default="and", help="Logic between filter fields: 'and' (all must match, default) or 'or' (any must match)", ) p.add_argument( "--split", type=str, default=None, help=( "Restrict task selection to a split whitelist. " "Forms: '' (e.g. test), " "'+' (union, e.g. test+payment), " "or a path to a text file with one task_id per line. " "Composes with other filters as AND." ), ) # Rerun (not in mode group — --suite / --task-ids serve as filters in rerun mode) p.add_argument( "--rerun", type=str, metavar="RUN_DIR", help="Rerun tasks from an existing run directory and update results in-place", ) p.add_argument( "--rerun-scope", choices=["error", "failed", "all"], default="error", help="Rerun scope: error (default), failed, all", ) # Resume (continue an interrupted run by running tasks that have no recorded results) p.add_argument( "--resume", type=str, metavar="RUN_DIR", help="Resume an interrupted run: run tasks with no recorded results and append to the original run directory", ) # Prune (remove results.jsonl entries outside the current valid task set). # Default valid set = TaskRegistry. With --split, valid = registry ∩ split, # which cleans up both code orphans and results that fall outside the split. p.add_argument( "--prune", type=str, metavar="RUN_DIR", help=( "Prune result entries outside the valid task set " "(default: tasks in current code; with --split: current ∩ split). " "Pairs with --resume to keep the run in sync with code/split changes." ), ) p.add_argument( "--prune-orphans", type=str, metavar="RUN_DIR", help="DEPRECATED alias for --prune. Use --prune instead.", ) p.add_argument( "--dry-run", action="store_true", help="Show what --prune would remove without touching files", ) p.add_argument( "--sample-n", type=int, help="Sample N instances per task (tasks without parameters stay 1 instance)", ) p.add_argument("--sample-seed", type=int, help="Random seed for task sampling") p.add_argument( "--sample-templates", action="store_true", help="Sample a template variant from each task's templates list " "based on its seed (default: always use templates[0])", ) # Pass@k evaluation p.add_argument( "--repeat-n", type=int, default=1, help="Repeat each task N times for pass@k evaluation (default: 1)", ) p.add_argument( "--pass-k", type=str, default=None, help="Comma-separated k values for pass@k metrics (e.g., '1,5,10')", ) # Agent p.add_argument("--agent", choices=list_agents(), default="generic_v2") p.add_argument("--model-base-url", type=str) p.add_argument("--model-api-key", type=str, default="") p.add_argument("--model-name", type=str) p.add_argument("--temperature", type=float) p.add_argument("--top-p", type=float) p.add_argument("--max-tokens", type=int) p.add_argument("--no-stream", action="store_true") p.add_argument("--infer-timeout", type=float, default=300.0, help="Total wall-clock timeout per LLM call in seconds (0=disable, default 300)") # Environment p.add_argument( "--device", choices=["sim", "real"], default="sim", help="Device type: sim (simulator) or real (ADB device). Default: sim", ) p.add_argument("--env-url", type=str, help="Simulator URL (required for sim mode)") p.add_argument("--device-serial", type=str, help="ADB device serial (optional for real mode)") p.add_argument("--headless", action="store_true") p.add_argument("--proxy", type=str, help="Browser proxy server (e.g. http://127.0.0.1:7890)") p.add_argument( "--coord-space", default="norm_0_1000", help="Coordinate space: norm_0_1000 | norm_0_1 | physical", ) p.add_argument("--delay-after-action", type=float, default=1.0) # Execution p.add_argument("--max-steps", type=int, default=None) p.add_argument("--quiet", "-q", action="store_true", help="Disable verbose output") p.add_argument( "--loop-detect", type=int, default=0, help="Terminate if agent repeats the same action N times consecutively (0=disable, default: off)", ) # Output p.add_argument("--runs-dir", type=str) p.add_argument("--no-save-trajectory", action="store_true") p.add_argument("--screenshot-scale", type=float, default=1.0, help="Screenshot scale (default: 1.0, JPEG is compact enough)") p.add_argument( "--list-md", type=str, help="Write --list output to a Markdown file", ) p.add_argument( "--include-generated", action="store_true", help="Include generated task suites (bench_env/generated_task/) in --list", ) p.add_argument( "--task-instructions", type=str, default=None, help=( "Path to JSON file mapping task_id -> full instruction string " "(e.g. {\"wechat.ReadContactRegion\": \"...\"}). When a task matches, " "its template and parameter sampling are replaced by the given " "instruction verbatim; used for sim2real eval and pre-baked prompts. " "Applies to both sim and real device." ), ) p.add_argument( "--list-online", action="store_true", help="Use --env-url to load __SIM__.getState() for online task listing (always headless, no browser window)", ) # Parallel p.add_argument("--parallel", type=int, default=1) p.add_argument( "--processes", type=int, default=1, help=( "Number of Python shard processes. Default 1 keeps existing single-process behavior; " "with K>1, --parallel is treated as total env concurrency and split across shards." ), ) p.add_argument("--isolation", choices=["pages", "contexts", "browsers"], default="pages") p.add_argument("--monitor", action="store_true", help="Enable system/GPU/vLLM monitoring (saves monitor.csv to run dir)") p.add_argument( "--browsers", type=int, default=0, dest="num_browsers", help="Number of browser processes to distribute pages/contexts across (0=auto). " "In --processes mode, this is treated as a total and split across shards. " "E.g. --parallel=64 --isolation=contexts --browsers=8 creates " "8 browsers x 8 contexts each.", ) # VLM Judge (for real device evaluation) p.add_argument( "--judge-mode", choices=["state", "vlm", "auto"], default="auto", help="Evaluation mode: state (JSON state matching), vlm (VLM visual), auto (vlm for real device). Default: auto", ) p.add_argument( "--eval-mode", choices=["text", "grounded"], default="grounded", help="Answer evaluation mode: text (legacy match_value), grounded (answer_sheet UI). Default: grounded", ) p.add_argument( "--judge-model", type=str, help="VLM model name for judge (default: same as --model-name)", ) p.add_argument( "--judge-base-url", type=str, help="VLM API URL for judge (default: same as --model-base-url)", ) p.add_argument( "--judge-api-key", type=str, help="VLM API key for judge (default: same as --model-api-key)", ) return p def _parse_suite(value: str | None) -> list[str] | None: """Parse --suite to list[str] (comma-separated).""" if not value: return None parts = [p.strip() for p in str(value).split(",")] return [p for p in parts if p] or None async def async_main(args) -> int: # 默认 asyncio thread pool 是 min(32, cpu+4),256 cores 机器上是 32。 # `await asyncio.to_thread(agent.act, obs)` 把同步 vLLM 请求扔到这个 pool, # 256 envs 时只有 32 能同时执行,其余 224 在 pool 排队 → infer per-step 飙到 30s。 # agent.act 是 socket-blocked 等 vLLM,基本不吃 CPU,thread 数远超核数无副作用。 # 可用 MOBILE_GYM_TO_THREAD_WORKERS 覆盖默认 1024。 import concurrent.futures _to_thread_workers = int(os.environ.get("MOBILE_GYM_TO_THREAD_WORKERS", "1024")) asyncio.get_running_loop().set_default_executor( concurrent.futures.ThreadPoolExecutor( max_workers=_to_thread_workers, thread_name_prefix="bench-to-thread", ) ) try: mode_flags = [ ("--resume", getattr(args, "resume", None)), ("--rerun", getattr(args, "rerun", None)), ("--prune", getattr(args, "prune", None)), ("--prune-orphans", getattr(args, "prune_orphans", None)), ] active = [name for name, val in mode_flags if val] if len(active) > 1: print(f"[ERROR] {', '.join(active)} are mutually exclusive") return 2 if getattr(args, "prune_orphans", None): print("[DEPRECATED] --prune-orphans is an alias for --prune. Please switch to --prune.") if getattr(args, "prune", None) or getattr(args, "prune_orphans", None): from bench_env.rerun import run_prune return await run_prune(args) if getattr(args, "resume", None): from bench_env.rerun import run_resume return await run_resume(args) if getattr(args, "rerun", None): from bench_env.rerun import run_rerun return await run_rerun(args) if args.list: if getattr(args, "list_online", False) and not getattr(args, "env_url", None): print("[ERROR] --list-online requires --env-url") return 2 def _parse_filter(value): if not value: return None return [x.strip() for x in str(value).split(",") if x.strip()] from bench_env.splits import resolve_split split_spec = getattr(args, "split", None) split_ids = frozenset(resolve_split(split_spec)) if split_spec else None await list_tasks( _parse_suite(args.suite), include_generated=getattr(args, "include_generated", False), markdown_path=getattr(args, "list_md", None), env_url=getattr(args, "env_url", None), online=getattr(args, "list_online", False), proxy=getattr(args, "proxy", None), sample_n=getattr(args, "sample_n", None), filter_difficulty=_parse_filter(getattr(args, "filter_difficulty", None)), filter_objective=_parse_filter(getattr(args, "filter_objective", None)), filter_composition=_parse_filter(getattr(args, "filter_composition", None)), filter_scope=_parse_filter(getattr(args, "filter_scope", None)), filter_capabilities=_parse_filter(getattr(args, "filter_capabilities", None)), filter_mode=getattr(args, "filter_mode", "and"), filter_has_answer_fields=getattr(args, "filter_has_answer_fields", None), split_task_ids=split_ids, ) return 0 # Validate environment args based on device type device = getattr(args, "device", "sim") if device == "sim" and not args.env_url: print("[ERROR] --env-url is required for simulator mode") return 2 if args.exec: runner = await ExecRunner.from_args(args) elif args.processes > 1: from bench_env.runner import MultiProcessRunner runner = await MultiProcessRunner.from_args(args) elif args.parallel > 1: runner = await ParallelRunner.from_args(args) else: runner = await SerialRunner.from_args(args) await runner.run() return 0 except (ValueError, FileNotFoundError) as e: print(f"[ERROR] {e}") return 2 def main(argv=None) -> int: args = create_parser().parse_args(argv) configure_logging(quiet=args.quiet) try: return asyncio.run(async_main(args)) except KeyboardInterrupt: print("\n[Interrupted]") return 130 if __name__ == "__main__": sys.exit(main())