477 lines
19 KiB
Python
477 lines
19 KiB
Python
"""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,
|
|
plugin_id: str | None = None,
|
|
probe_plugin: bool = False,
|
|
profile: str = "default",
|
|
include_development: bool = False,
|
|
) -> 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.
|
|
plugin_id filters the installed plugin list. probe_plugin=true performs a bounded
|
|
connection/schema probe of that installed, authorized plugin (starts its runtime,
|
|
executes no task action). It never installs or grants permissions.
|
|
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 {}
|
|
installed = plugins.get("plugins", [])
|
|
if plugin_id:
|
|
_id(plugin_id)
|
|
installed = [
|
|
item for item in installed if (item.get("manifest") or item).get("id") == plugin_id
|
|
]
|
|
if not installed:
|
|
raise ValueError("插件未安装,请在网页插件页安装并授权")
|
|
development = None
|
|
if include_development:
|
|
from artemis import __version__
|
|
import sys
|
|
|
|
development = {
|
|
"python_executable": sys.executable,
|
|
"artemis_version": __version__,
|
|
"plugin_schema_version": 2,
|
|
"installation_root": str(ROOT_DIR),
|
|
}
|
|
probe = None
|
|
if probe_plugin:
|
|
probe = await _request(
|
|
"POST", f"/api/plugins/{_id(plugin_id)}/diagnose", json={"profile": profile}
|
|
)
|
|
return {
|
|
"devices": devices.get("devices", []),
|
|
"queue": status,
|
|
"plugins": installed,
|
|
"plugin_probe": probe,
|
|
"development": development,
|
|
"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_favorites(
|
|
action: Literal["list", "create", "update", "delete", "run"],
|
|
favorite_id: str | None = None,
|
|
source_kind: Literal["task", "batch", "exploration", "exploration_case"] = "task",
|
|
source_id: str | None = None,
|
|
version: int | None = None,
|
|
request_id: str | None = None,
|
|
changes: dict[str, Any] | None = None,
|
|
) -> Any:
|
|
"""Save independent favorite templates and run them on the existing queue.
|
|
|
|
list is read-only. create snapshots source_id (task session/batch/exploration),
|
|
including original execution settings; exploration_case pins a case version.
|
|
An exploration task automatically favorites its confirmed case when available.
|
|
changes accepts name, task_config (TaskOptions), case_configs (zero-based case
|
|
indices to TaskOptions). No credentials or arbitrary snapshot input is accepted.
|
|
update saves defaults; run applies changes to this execution only and requires
|
|
a stable request_id. After an uncertain reply retry the same payload and ID.
|
|
run creates a new execution, never continues the source or copies its verdict.
|
|
Batch order/repetitions and per-case settings are preserved. Missing historical
|
|
settings are explicitly marked inherited_fields. Offline devices are not replaced.
|
|
delete removes only the favorite, never the source history or previous runs.
|
|
"""
|
|
if action == "list":
|
|
return await _request("GET", "/api/automation/favorites")
|
|
payload = changes or {}
|
|
if set(payload) - {"name", "task_config", "case_configs"}:
|
|
raise ValueError("收藏只接受名称和非敏感执行配置")
|
|
|
|
# The HTTP schema applies the complete TaskOptions allowlist. Refuse secret
|
|
# fields before making a network request, including nested case overrides.
|
|
def check(value):
|
|
if isinstance(value, dict):
|
|
for key, child in value.items():
|
|
if any(
|
|
word in str(key).lower()
|
|
for word in ("secret", "password", "api_key", "token", "authorization")
|
|
):
|
|
raise ValueError("收藏不接受密钥或授权配置")
|
|
check(child)
|
|
elif isinstance(value, list):
|
|
for child in value:
|
|
check(child)
|
|
|
|
check(payload)
|
|
if action == "create":
|
|
if set(payload) - {"name"}:
|
|
raise ValueError("创建收藏从源记录读取配置,修改默认配置请使用 update")
|
|
return await _request(
|
|
"POST",
|
|
"/api/automation/favorites",
|
|
json={
|
|
"source_kind": source_kind,
|
|
"source_id": _id(source_id),
|
|
"version": version,
|
|
**payload,
|
|
},
|
|
)
|
|
route = f"/api/automation/favorites/{_id(favorite_id)}"
|
|
if action == "update":
|
|
return await _request("PATCH", route, json=payload)
|
|
if action == "delete":
|
|
return await _request("DELETE", route)
|
|
return await _request("POST", route + "/run", json={**payload, "request_id": _id(request_id)})
|
|
|
|
|
|
@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,
|
|
batch_id: str = "",
|
|
) -> 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 batch_id:
|
|
params["batch_id"] = _id(batch_id)
|
|
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}
|