"""AI workflow tools backed by the same daemon, queue and persistence as the Web UI.""" import asyncio from pathlib import Path import re from typing import Any, Literal from uuid import uuid4 import httpx from artemis.config.paths import ROOT_DIR from artemis.runtime.daemon_client import ensure_daemon_running from mcp_server.base import mcp EXPORT_DIR = ROOT_DIR / "artifacts" / "mcp_exports" MODEL_FIELDS = { "base_url", "model", "timeout_seconds", "max_retries", "retry_interval_seconds", "rate_limit_retry_seconds", "tool_fallback_model", "tool_fallback_reasoning_effort", "tool_fallback_max_retries", "tool_fallback_no_progress_attempts", "reasoning_effort", } CAPABILITY_FIELDS = { "adb_auto_connect", "adb_discovery_interval_seconds", "adb_discovery_timeout_seconds", "video_enabled", "recording_enabled", "recording", "outputter_enabled", "step_summarizer_enabled", "screenshot_limit_enabled", "task_timeout_seconds", "observation_timeout_seconds", "observation_max_attempts", "device_command_timeout_seconds", "action_failure_attempts", "worker_cleanup_timeout_seconds", } def _id(value: str | None) -> str: if not value or not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", value): raise ValueError("请提供有效的记录编号(字母、数字、下划线或连字符)") return value async def _base_url() -> str: ready, url = await asyncio.to_thread(ensure_daemon_running, timeout=10, wait_ready=True) if not ready or not url: raise ValueError( "无法连接统一任务服务,请先调用 mobile_diagnose;批次功能需要 daemon,不能在独立模式运行" ) return url def _check(response: httpx.Response) -> None: if response.is_error: try: detail = response.json().get("detail") except ValueError: detail = None # Validation payloads may include user input; do not echo raw bodies. message = ( detail if isinstance(detail, str) else "参数校验失败或服务不可用,请调用 mobile_diagnose 并检查输入" ) raise ValueError(f"HTTP {response.status_code}: {message}") async def _request(method: str, route: str, **kwargs) -> Any: url = await _base_url() async with httpx.AsyncClient(base_url=url, timeout=30, trust_env=False) as client: try: response = await client.request(method, route, **kwargs) except httpx.HTTPError as exc: raise ValueError( "任务服务连接中断;写入结果可能已生效,请先查询状态。创建批次重试必须沿用同一 request_id" ) from exc _check(response) return response.json() async def _download( route: str, filename: str, *, owner_kind: Literal["sessions", "batches"] | None = None, owner_id: str | None = None, ) -> dict: url = await _base_url() directory = EXPORT_DIR / owner_kind / _id(owner_id) if owner_kind else EXPORT_DIR path = directory / f"{uuid4().hex[:12]}-{filename}" partial = path.with_suffix(path.suffix + ".part") try: async with httpx.AsyncClient(base_url=url, timeout=120, trust_env=False) as client: async with client.stream("GET", route) as response: if response.is_error: await response.aread() _check(response) directory.mkdir(parents=True, exist_ok=True) with partial.open("wb") as stream: async for chunk in response.aiter_bytes(): stream.write(chunk) partial.replace(path) finally: partial.unlink(missing_ok=True) return { "path": str(path.resolve()), "filename": filename, "size_bytes": path.stat().st_size, "download_url": url + route, } @mcp.tool() async def mobile_workspace(include_devices: bool = True) -> dict: """List connected Android devices and the unified running/queued task state. Select the user's device; if multiple devices exist and none is chosen, ask first. This does not submit tasks. On connection/device errors use mobile_diagnose. For computer-only work use include_devices=false to skip ADB discovery entirely. """ status, plugins = await asyncio.gather( _request("GET", "/api/status"), _request("GET", "/api/plugins") ) devices = await _request("GET", "/api/devices") if include_devices else {} return { "devices": devices.get("devices", []), "queue": status, "plugins": plugins.get("plugins", []), "blocked_resources": plugins.get("blocked_resources", {}), "execution_targets": ["android", "computer", "mixed"], } @mcp.tool() async def mobile_config( section: Literal["model", "capabilities", "reports", "exploration"] = "model", changes: dict[str, Any] | None = None, persist_defaults: bool = False, ) -> dict: """Read configuration or explicitly save non-secret defaults for FUTURE tasks. Only set persist_defaults=true when the user requests saved/global configuration. model changes: model (preset or custom), base_url, reasoning_effort (default/none/minimal/low/medium/high/xhigh/max), timeout_seconds, max_retries, retry_interval_seconds, rate_limit_retry_seconds, tool_fallback_model, tool_fallback_reasoning_effort, tool_fallback_max_retries, tool_fallback_no_progress_attempts. 'default' omits reasoning; unsupported values fail visibly. capabilities: video_enabled and recording_enabled are independent; outputter_enabled, step_summarizer_enabled, screenshot_limit_enabled, task_timeout_seconds (default 1800), observation_timeout_seconds (60), observation_max_attempts (3), device_command_timeout_seconds (10), action_failure_attempts (3), worker_cleanup_timeout_seconds (30), adb_auto_connect (true), adb_discovery_interval_seconds (10), adb_discovery_timeout_seconds (5). reports: formats=['docx','xlsx'] (at least one). exploration: model (default gpt-6.1-sol), reasoning_effort (high), timeout_seconds (1800 active seconds), demonstration_timeout_seconds (900), demonstration_warning_seconds (60). API keys are never accepted or returned: use the Web UI or .env for credentials. Saving settings does not change an already running task. Flash/Pro are execution profiles, not model names. Model retries do not repeat device test cases. """ route, allowed = { "model": ("/api/system/model-settings", MODEL_FIELDS), "capabilities": ("/api/system/capability-settings", CAPABILITY_FIELDS), "reports": ("/api/automation/preferences", {"formats"}), "exploration": ( "/api/explorations/settings", { "model", "reasoning_effort", "timeout_seconds", "demonstration_timeout_seconds", "demonstration_warning_seconds", }, ), }[section] if changes is not None: if not persist_defaults: raise ValueError("保存全局配置需要 persist_defaults=true,且应来自用户的明确要求") if set(changes) - allowed: raise ValueError("包含不支持的配置字段;密钥请通过网页或 .env 设置") current = await _request("GET", route) if changes is None: return current payload = {key: value for key, value in current.items() if key in allowed} payload.update(changes) return await _request( "PUT" if section in {"reports", "exploration"} else "POST", route, json=payload ) @mcp.tool() async def mobile_task_file( action: Literal["template", "preview"], path: str | None = None, ) -> dict: """Download the standard XLSX task template, or validate a local XLSX without executing it. preview requires an absolute .xlsx path on the MCP host. Returns tasks, row errors, and valid. Correct all errors, strip preview-only 'row', then submit tasks via mobile_batch(create). Creating XLSX is optional: AI can submit JSON cases directly. """ if action == "template": return await _download("/api/automation/template", "artemis-tasks.xlsx") source = Path(path or "") if not source.is_absolute() or source.suffix.lower() != ".xlsx" or not source.is_file(): raise ValueError("请提供本机现有 .xlsx 文件的绝对路径") from apps.admin_console.services.task_import import MAX_BYTES if source.stat().st_size > MAX_BYTES: raise ValueError("文件超过 Excel 导入大小限制") with source.open("rb") as stream: return await _request( "POST", "/api/automation/import/preview", files={ "file": ( source.name, stream, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) }, ) @mcp.tool() async def mobile_batch( action: Literal[ "create", "list", "get", "lookup", "start", "pause", "resume", "retry_failed", "delete" ], batch_id: str | None = None, request_id: str | None = None, batch: dict[str, Any] | None = None, ) -> Any: """Create, execute and manage durable batches on the unified device queue. create requires a unique request_id and batch={name,device_serial,cases:[{name,goal, case_id?,category?,subcategory?,preconditions?,task_config?:{profile?,model?, reasoning_effort?,device_serial?,verification_level?,explorer_mode?, locked_app_package?,record_video?,analyze_video?,request_timeout_seconds?, max_retries?,retry_interval_seconds?,timeout_seconds?}}],profile?:flash|pro, formats?:[docx,xlsx],stop_on_failure?:false,timeout_seconds?:1800,repeat_count?:1}. repeat_count is a positive integer: execute all selected cases in order for each round, with independent session IDs, statuses and reports. The default total limit (cases * repeat_count) is 10000, configurable via ARTEMIS_BATCH_MAX_EXECUTIONS. Only include user-selected cases; get returns iteration/repeat_count per job. Required device must be user-selected. Creation validates and saves a DRAFT; start executes it. Reuse the same request_id and exact payload after a connection failure; never invent a new ID for the same submission. lookup(request_id) resolves an uncertain outcome. get returns per-case status. pause stops future dispatch, not the active task; use mobile_manage_task(stop) to stop that task. resume never repeats finished cases. retry_failed copies each failed execution once into a new draft (repeat_count=1) only after an explicit rerun request; it is not transport-idempotent, so after an uncertain response use list instead of retrying. delete removes a non-active batch and its task sessions, reports and files after an explicit user request. Confirm the batch ID with get first; deletion is irreversible. Restarted batches remain paused. No automatic failed-case retries or action replay. """ if action == "create": rid = _id(request_id) if not batch: raise ValueError("create 需要 batch 内容") return await _request("POST", "/api/automation/batches", json={**batch, "request_id": rid}) if action == "list": return await _request("GET", "/api/automation/batches") if action == "lookup": return await _request("GET", f"/api/automation/batches/by-request/{_id(request_id)}") route = f"/api/automation/batches/{_id(batch_id)}" if action == "get": return await _request("GET", route) if action == "delete": return await _request("DELETE", route) if action in {"start", "resume"}: current = await _request("GET", route) if current["status"] == "running": return current return await _request( "POST", route + "/" + ("resume" if action == "start" else action), json={} ) @mcp.tool() async def mobile_search_history( query: str = "", status: str = "", start: float | None = None, end: float | None = None, page: int = 1, size: int = 30, ) -> dict: """Search ALL persisted terminal tasks by name, prompt, ID or case metadata, with pagination. start/end are Unix seconds, inclusive/exclusive; convert the user's local dates before calling. Status: completed/success/failed/cancelled/interrupted/skipped. Return items,total,page,size; inspect evidence/report before claiming a test passed. """ params = {"q": query, "status": status, "page": page, "size": size} if start is not None: params["start"] = start if end is not None: params["end"] = end return await _request("GET", "/api/automation/history", params=params) @mcp.tool() async def mobile_export_report( session_id: str | None = None, batch_id: str | None = None, formats: list[Literal["docx", "xlsx", "screenshots", "video", "all"]] | None = None, ) -> dict: """Export Word/Excel, ordered screenshots, saved recording segments or all task material. Provide exactly one of session_id/batch_id. Task formats omitted uses saved defaults; screenshots/video/all produce ZIPs; video requires recording to have been enabled. batch ZIP uses its frozen formats and includes summary + individual case reports. Returns absolute paths, download URLs and sizes; task results also include structured verdict/evidence. Running exports are snapshots. 'completed' is not proof of PASS; report 'unverified' when checks/evidence are missing. Never fabricate test results. """ if bool(session_id) == bool(batch_id): raise ValueError("请且仅请提供 session_id 或 batch_id") if batch_id: bid = _id(batch_id) return { "batch_id": bid, "files": [ await _download( f"/api/automation/batches/{bid}/archive", f"batch-{bid}.zip", owner_kind="batches", owner_id=bid, ) ], } sid = _id(session_id) chosen = ( formats if formats is not None else (await _request("GET", "/api/automation/preferences"))["formats"] ) if ( not chosen or len(chosen) > 5 or any(fmt not in {"docx", "xlsx", "screenshots", "video", "all"} for fmt in chosen) ): raise ValueError("formats 需选择 docx、xlsx、screenshots、video 或 all") report = await _request("GET", f"/api/automation/reports/{sid}") from apps.admin_console.services.report_export import archive_case_name label = archive_case_name(report) files = [ await _download( f"/api/automation/reports/{sid}/{fmt}", f"task-{sid}.{fmt}" if fmt in {"docx", "xlsx"} else f"{label}-{fmt}.zip", owner_kind="sessions", owner_id=sid, ) for fmt in dict.fromkeys(chosen) ] return {"report": report, "files": files}