feat: enable full AI automation and safe startup updates
This commit is contained in:
1 parent
2ddec53954
commit
7b1a0755e2
62 files changed
+3123
-240
No files matched your search
@@ -26,4 +26,19 @@ __all__ = [
|
||||
"mobile_get_device_state",
|
||||
"mobile_inspect_trace",
|
||||
"mobile_diagnose",
|
||||
"mobile_workspace",
|
||||
"mobile_config",
|
||||
"mobile_task_file",
|
||||
"mobile_batch",
|
||||
"mobile_search_history",
|
||||
"mobile_export_report",
|
||||
]
|
||||
|
||||
from mcp_server.tools.automation import (
|
||||
mobile_workspace,
|
||||
mobile_config,
|
||||
mobile_task_file,
|
||||
mobile_batch,
|
||||
mobile_search_history,
|
||||
mobile_export_report,
|
||||
)
|
||||
@@ -0,0 +1,279 @@
|
||||
"""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",
|
||||
"reasoning_effort",
|
||||
}
|
||||
CAPABILITY_FIELDS = {"video_enabled", "outputter_enabled", "step_summarizer_enabled"}
|
||||
|
||||
|
||||
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) -> dict:
|
||||
url = await _base_url()
|
||||
EXPORT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = EXPORT_DIR / 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)
|
||||
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() -> 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.
|
||||
"""
|
||||
devices = await _request("GET", "/api/devices")
|
||||
status = await _request("GET", "/api/status")
|
||||
return {"devices": devices.get("devices", []), "queue": status}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def mobile_config(
|
||||
section: Literal["model", "capabilities", "reports"] = "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), timeout_seconds, max_retries,
|
||||
retry_interval_seconds. 'default' omits reasoning; unsupported values fail visibly.
|
||||
capabilities: video_enabled (recording + analysis), outputter_enabled,
|
||||
step_summarizer_enabled. reports: formats=['docx','xlsx'] (at least one).
|
||||
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"}),
|
||||
}[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 == "reports" 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"],
|
||||
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,
|
||||
expected?,app?,preconditions?,parameters?:{key:primitive},tags?}],profile?:flash|pro,
|
||||
formats?:[docx,xlsx],stop_on_failure?:false,timeout_seconds?:1800}. 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 creates a new draft only after an explicit rerun request; it is not
|
||||
transport-idempotent, so after an uncertain response use list instead of retrying.
|
||||
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 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, goal, ID or tags, 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"]] | None = None,
|
||||
) -> dict:
|
||||
"""Export a task's Word/Excel reports or a batch ZIP to local files on the MCP host.
|
||||
|
||||
Provide exactly one of session_id/batch_id. Task formats omitted uses saved defaults;
|
||||
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")
|
||||
],
|
||||
}
|
||||
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) > 2 or any(fmt not in {"docx", "xlsx"} for fmt in chosen):
|
||||
raise ValueError("formats 至少选择 docx 或 xlsx,最多两项")
|
||||
report = await _request("GET", f"/api/automation/reports/{sid}")
|
||||
files = [
|
||||
await _download(f"/api/automation/reports/{sid}/{fmt}", f"task-{sid}.{fmt}")
|
||||
for fmt in dict.fromkeys(chosen)
|
||||
]
|
||||
return {"report": report, "files": files}
|
||||
@@ -78,7 +78,7 @@ async def mobile_inspect_trace(
|
||||
when the action failed/was intercepted, else null), and
|
||||
`action_overlay_screenshot` (the action visually marked — e.g. red circle
|
||||
for taps — key for verifying the agent tapped the right element).
|
||||
- **'view_step_details'**: Full replay of one step exactly as the agent's
|
||||
- **'view_step_details'**: Stored details of one step exactly as the agent's
|
||||
own context showed it: what the screen showed, its reasoning, every tool
|
||||
call it made (name, arguments, result — e.g. what the explorer or OCR
|
||||
reported), the planned action, any safety-net interception and the
|
||||
|
||||
Reference in new issue
Block a user