Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6a3ee2eb0 | ||
|
|
49873e7f05 | ||
|
|
f826bdf3b0 | ||
|
|
c96935b47b | ||
|
|
18d17da966 | ||
|
|
0355af7833 | ||
|
|
079d7b1e28 | ||
|
|
1fdf7c0d1d | ||
|
|
606ed94160 | ||
|
|
8dbe054a8c | ||
|
|
868f611c36 | ||
|
|
d31eb2a19d | ||
|
|
ab066733b8 | ||
|
|
59f458205d | ||
|
|
4465a2e199 | ||
|
|
c818a09a49 | ||
|
|
e1e26772d9 | ||
|
|
1dc2b0c686 | ||
|
|
84e183d28d | ||
|
|
0e13bb5c29 |
No files matched your search
+23
-1
@@ -6,9 +6,16 @@ OPENAI_BASE_URL=https://api-n-cd.playones.com/v1
|
|||||||
OPENAI_MODEL=mimo-v2.6-flash
|
OPENAI_MODEL=mimo-v2.6-flash
|
||||||
# 单次模型 HTTP 请求超时(秒)
|
# 单次模型 HTTP 请求超时(秒)
|
||||||
OPENAI_TIMEOUT_SECONDS=300
|
OPENAI_TIMEOUT_SECONDS=300
|
||||||
|
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
|
||||||
|
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
|
||||||
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
||||||
OPENAI_MAX_RETRIES=5
|
OPENAI_MAX_RETRIES=5
|
||||||
OPENAI_RETRY_INTERVAL_SECONDS=10
|
OPENAI_RETRY_INTERVAL_SECONDS=10
|
||||||
|
OPENAI_RATE_LIMIT_RETRY_SECONDS=60
|
||||||
|
OPENAI_TOOL_FALLBACK_MODEL=gpt-6-luna
|
||||||
|
OPENAI_TOOL_FALLBACK_REASONING_EFFORT=high
|
||||||
|
OPENAI_TOOL_FALLBACK_MAX_RETRIES=3
|
||||||
|
ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS=3
|
||||||
# default 表示不发送思考强度参数;接口不支持所选值时明确报错。
|
# default 表示不发送思考强度参数;接口不支持所选值时明确报错。
|
||||||
OPENAI_REASONING_EFFORT=high
|
OPENAI_REASONING_EFFORT=high
|
||||||
|
|
||||||
@@ -17,8 +24,22 @@ ARTEMIS_VIDEO_ENABLED=false
|
|||||||
ARTEMIS_RECORDING_ENABLED=false
|
ARTEMIS_RECORDING_ENABLED=false
|
||||||
ARTEMIS_OUTPUTTER_ENABLED=true
|
ARTEMIS_OUTPUTTER_ENABLED=true
|
||||||
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
|
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
|
||||||
|
# 默认将任务截图长边压至 2048;false 保留设备原始截图。长边值可在 config/artemis.jsonc 的 agent.screenshot 中覆盖。
|
||||||
|
ARTEMIS_SCREENSHOT_LIMIT_ENABLED=true
|
||||||
# Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。
|
# Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。
|
||||||
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS=3
|
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS=3
|
||||||
|
# Flash 连续无工具调用的最大次数;无效回复不会重复送回模型。
|
||||||
|
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS=3
|
||||||
|
# 所有网页/MCP 任务的总执行时限;排队、用户暂停不计入。任务 JSON 的 timeout_seconds 可覆盖。
|
||||||
|
ARTEMIS_TASK_TIMEOUT_SECONDS=1800
|
||||||
|
# 单次屏幕观测预算、最多恢复次数和备用 ADB 命令时限。
|
||||||
|
ARTEMIS_OBSERVATION_TIMEOUT_SECONDS=60
|
||||||
|
ARTEMIS_OBSERVATION_MAX_ATTEMPTS=3
|
||||||
|
ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS=10
|
||||||
|
# Flash 连续设备动作失败后,重新观察并切换兜底模型;兜底仍失败时结束。
|
||||||
|
ARTEMIS_ACTION_FAILURE_ATTEMPTS=3
|
||||||
|
# 已写出任务结果后,后台线程/录屏清理最多等待多久;超时回收进程释放队列。
|
||||||
|
ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS=30
|
||||||
|
|
||||||
# 视频分段、重试与单次分析超时(秒)
|
# 视频分段、重试与单次分析超时(秒)
|
||||||
ARTEMIS_VIDEO_CHUNK_SECONDS=60
|
ARTEMIS_VIDEO_CHUNK_SECONDS=60
|
||||||
@@ -29,6 +50,8 @@ ARTEMIS_CHECKPOINT_TIMEOUT_SECONDS=180
|
|||||||
ARTEMIS_SETTLEMENT_TIMEOUT_SECONDS=120
|
ARTEMIS_SETTLEMENT_TIMEOUT_SECONDS=120
|
||||||
ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS=12
|
ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS=12
|
||||||
ARTEMIS_MODEL_CACHE_TTL_SECONDS=10
|
ARTEMIS_MODEL_CACHE_TTL_SECONDS=10
|
||||||
|
# 守护服务轻量探测超时;探测超时且端口仍占用时,不重复启动服务。
|
||||||
|
ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS=2
|
||||||
# 模型重试耗尽后的暂停等待上限(秒);0 表示无限等待
|
# 模型重试耗尽后的暂停等待上限(秒);0 表示无限等待
|
||||||
LLM_PAUSE_TIMEOUT_SECONDS=900
|
LLM_PAUSE_TIMEOUT_SECONDS=900
|
||||||
|
|
||||||
@@ -45,6 +68,5 @@ ARTEMIS_HELPER_AUTO_INSTALL=true
|
|||||||
ARTEMIS_DEFAULT_PROFILE=flash
|
ARTEMIS_DEFAULT_PROFILE=flash
|
||||||
|
|
||||||
# 批量任务与 Excel 导入限制
|
# 批量任务与 Excel 导入限制
|
||||||
ARTEMIS_BATCH_TASK_TIMEOUT=1800
|
|
||||||
ARTEMIS_IMPORT_MAX_ROWS=1000
|
ARTEMIS_IMPORT_MAX_ROWS=1000
|
||||||
ARTEMIS_IMPORT_MAX_BYTES=10485760
|
ARTEMIS_IMPORT_MAX_BYTES=10485760
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# cmd.exe requires CRLF for batch scripts, especially UTF-8 Chinese text.
|
||||||
|
*.bat text eol=crlf
|
||||||
@@ -29,9 +29,10 @@ cd Artemis-Eapil
|
|||||||
常用选项:
|
常用选项:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\start-cn.bat -SetupOnly # 仅准备 Python 环境并检查已有 MCP 配置
|
.\start-cn.bat -SetupOnly # 准备环境并安装/更新 Codex MCP
|
||||||
.\start.bat -Port 8001 # 指定端口
|
.\start.bat -Port 8001 # 指定端口
|
||||||
.\start.bat -SkipUpdate # 跳过 Git 源码更新检查
|
.\start.bat -SkipUpdate # 跳过 Git 源码更新检查
|
||||||
|
.\start.bat -SkipMcpSync # MCP 配置异常时,仅启动网页
|
||||||
```
|
```
|
||||||
|
|
||||||
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。国内脚本使用山东大学镜像安装 scrcpy,并校验官方 SHA256;也可预先安装或自定义 HTTPS 下载地址。
|
镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。国内脚本使用山东大学镜像安装 scrcpy,并校验官方 SHA256;也可预先安装或自定义 HTTPS 下载地址。
|
||||||
@@ -47,21 +48,23 @@ cd Artemis-Eapil
|
|||||||
3. 输入任务目标,选择 Flash 或 Pro 后执行;在工作区查看步骤、截图和结果。
|
3. 输入任务目标,选择 Flash 或 Pro 后执行;在工作区查看步骤、截图和结果。
|
||||||
4. 通过“报告与批量”导出报告或导入 Excel 用例;任务队列支持历史搜索。
|
4. 通过“报告与批量”导出报告或导入 Excel 用例;任务队列支持历史搜索。
|
||||||
|
|
||||||
预设模型为 `gpt-5.6-luna`、`mimo-v2.6-flash`,也可填写自定义名称。默认模型是 `mimo-v2.6-flash`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**;视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。
|
预设模型为 `gpt-5.6-luna`、`gpt-6-luna`、`mimo-v2.6-flash`、`mimo-v2.6-pro`,也可填写自定义名称。默认模型是 `mimo-v2.6-flash`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**。遇到 429 请求过多时改为每次等待 **60 秒**。连续 3 次没有有效工具调用时,本步骤改用 `gpt-6-luna`(high),兜底模型最多再尝试 3 次;下一步恢复首选模型。视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。
|
||||||
|
|
||||||
也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
|
也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
|
||||||
|
|
||||||
|
“功能开关”默认将任务截图长边限制为 2048 像素;关闭后保留设备原始分辨率。可在 [config/artemis.jsonc](config/artemis.jsonc) 的 `agent.screenshot` 中覆盖 `enabled` 和 `max_long_edge`,重启后对新任务生效。
|
||||||
|
|
||||||
报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。
|
报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。
|
||||||
|
|
||||||
## 让 AI 使用
|
## 让 AI 使用
|
||||||
|
|
||||||
环境与模型配置完成后,例如为 Codex 接入 MCP:
|
目前仅支持为 Codex 配置 MCP。Windows 启动脚本检测 Codex 配置目录并安装或更新 ARTEMIS;也可手动安装:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
uv run artemis mcp --install codex
|
uv run artemis mcp --install codex
|
||||||
```
|
```
|
||||||
|
|
||||||
在客户端重新加载 MCP,并按 [AI 接入指南](docs/ai-automation.md) 安装仓库中的 [artemis-automation Skill](skills/artemis-automation/SKILL.md)。Skill 不会随启动脚本自动安装到用户全局目录。其他客户端可通过 `uv run artemis mcp --help` 查看接入方式。
|
安装或更新后,在 Codex 中重新加载 MCP 或重启 Codex,并按 [AI 接入指南](docs/ai-automation.md) 安装仓库中的 [artemis-automation Skill](skills/artemis-automation/SKILL.md)。Skill 不会随启动脚本自动安装到用户全局目录。可用 `uv run artemis mcp --generate-config codex` 查看配置模板。
|
||||||
|
|
||||||
示例指令:
|
示例指令:
|
||||||
|
|
||||||
@@ -69,7 +72,7 @@ uv run artemis mcp --install codex
|
|||||||
|
|
||||||
## 更新与排错
|
## 更新与排错
|
||||||
|
|
||||||
Windows 用户再次运行一键脚本时,会先检查 Git 上游;即使 `start.bat` 或 `start-cn.bat` 本身被更新,也会重新启动新版脚本,再检查项目依赖、前端构建和已有 MCP 配置。源码只做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。更新后需在 AI 客户端重新加载 MCP。
|
Windows 用户再次运行一键脚本时,会先检查 Git 上游;即使 `start.bat` 或 `start-cn.bat` 本身被更新,也会重新启动新版脚本,再检查项目依赖、前端构建并同步 Codex MCP。源码只做快进更新。本地修改、分支分叉或网络认证失败时保留本地版本;有任务执行或排队时停止更新启动。Codex 配置同步失败会明确停止启动,可使用 `-SkipMcpSync` 仅启动网页。更新后需在 Codex 中重新加载 MCP。
|
||||||
|
|
||||||
1.1.1 及更新版本的发布 ZIP 含浅层 Git 工作树,解压后再次运行一键脚本即可检查更新。1.1.0 及更早 ZIP 不含 Git 信息,无法自行拉取;需要下载新版源码包重新解压一次,之后即可自动更新。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。
|
1.1.1 及更新版本的发布 ZIP 含浅层 Git 工作树,解压后再次运行一键脚本即可检查更新。1.1.0 及更早 ZIP 不含 Git 信息,无法自行拉取;需要下载新版源码包重新解压一次,之后即可自动更新。遇到启动或设备问题,在项目目录运行 `uv run artemis doctor`。
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from apps.admin_console.services.automation_store import identifier, store
|
from apps.admin_console.services.automation_store import identifier, store
|
||||||
from apps.admin_console.services import automation_service as service
|
from apps.admin_console.services import automation_service as service
|
||||||
|
from apps.admin_console.services import batch_archive as archive_service
|
||||||
from apps.admin_console.services import report_export, task_import
|
from apps.admin_console.services import report_export, task_import
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/automation", tags=["automation"])
|
router = APIRouter(prefix="/api/automation", tags=["automation"])
|
||||||
|
|
||||||
@@ -29,7 +31,7 @@ class BatchRequest(ReportPreferences):
|
|||||||
profile: Literal["flash", "pro"] = "flash"
|
profile: Literal["flash", "pro"] = "flash"
|
||||||
stop_on_failure: bool = False
|
stop_on_failure: bool = False
|
||||||
timeout_seconds: int = Field(
|
timeout_seconds: int = Field(
|
||||||
default_factory=lambda: int(os.getenv("ARTEMIS_BATCH_TASK_TIMEOUT", "1800")),
|
default_factory=lambda: settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||||
ge=30,
|
ge=30,
|
||||||
le=86400,
|
le=86400,
|
||||||
)
|
)
|
||||||
@@ -49,7 +51,7 @@ def require(kind, key):
|
|||||||
def preferences():
|
def preferences():
|
||||||
return {
|
return {
|
||||||
**store.preferences(),
|
**store.preferences(),
|
||||||
"batch_timeout_seconds": int(os.getenv("ARTEMIS_BATCH_TASK_TIMEOUT", "1800")),
|
"batch_timeout_seconds": settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -128,10 +130,13 @@ def delete_batch(batch_id: str):
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(400, str(exc)) from exc
|
raise HTTPException(400, str(exc)) from exc
|
||||||
try:
|
try:
|
||||||
if not store.delete_batch(key):
|
if archive_service.status(key, require("batch", key))["status"] == "preparing":
|
||||||
|
raise HTTPException(409, "报告正在打包,请完成后再删除批次")
|
||||||
|
if not service.delete_batch(key):
|
||||||
raise HTTPException(404, "批次不存在")
|
raise HTTPException(404, "批次不存在")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(409, str(exc)) from exc
|
raise HTTPException(409, str(exc)) from exc
|
||||||
|
archive_service.forget(key)
|
||||||
return {"batch_id": key, "status": "deleted"}
|
return {"batch_id": key, "status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
@@ -144,10 +149,30 @@ def batch_action(batch_id: str, action: Literal["pause", "resume", "retry_failed
|
|||||||
raise HTTPException(409, str(exc)) from exc
|
raise HTTPException(409, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batches/{batch_id}/archive/prepare")
|
||||||
|
def prepare_batch_archive(batch_id: str):
|
||||||
|
batch = require("batch", batch_id)
|
||||||
|
return archive_service.prepare(batch_id, batch)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/batches/{batch_id}/archive/status")
|
||||||
|
def batch_archive_status(batch_id: str):
|
||||||
|
batch = require("batch", batch_id)
|
||||||
|
return archive_service.status(batch_id, batch)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/batches/{batch_id}/archive")
|
@router.get("/batches/{batch_id}/archive")
|
||||||
async def batch_archive(batch_id: str):
|
async def batch_archive(batch_id: str):
|
||||||
require("batch", batch_id)
|
batch = require("batch", batch_id)
|
||||||
path = await asyncio.to_thread(report_export.export_batch, batch_id)
|
if archive_service.status(batch_id, batch)["status"] == "preparing":
|
||||||
|
raise HTTPException(409, "报告正在打包,请等待准备完成")
|
||||||
|
path = archive_service.archive_path(batch_id)
|
||||||
|
if (
|
||||||
|
not path.is_file()
|
||||||
|
or not batch.get("report_ready")
|
||||||
|
and archive_service.status(batch_id, batch)["status"] != "ready"
|
||||||
|
):
|
||||||
|
path = await asyncio.to_thread(report_export.export_batch, batch_id)
|
||||||
return FileResponse(path, filename=f"批次报告-{batch_id[:8]}.zip")
|
return FileResponse(path, filename=f"批次报告-{batch_id[:8]}.zip")
|
||||||
|
|
||||||
|
|
||||||
@@ -166,7 +191,8 @@ async def download_report(
|
|||||||
try:
|
try:
|
||||||
if fmt in {"screenshots", "video", "all"}:
|
if fmt in {"screenshots", "video", "all"}:
|
||||||
path = await asyncio.to_thread(report_export.export_session_media, session_id, fmt)
|
path = await asyncio.to_thread(report_export.export_session_media, session_id, fmt)
|
||||||
return FileResponse(path, filename=f"任务{session_id[:8]}-{fmt}.zip")
|
label = report_export.archive_case_name(report_export.session_report(session_id))
|
||||||
|
return FileResponse(path, filename=f"{label}-{fmt}.zip")
|
||||||
paths = await asyncio.to_thread(report_export.export_session, session_id, [fmt])
|
paths = await asyncio.to_thread(report_export.export_session, session_id, [fmt])
|
||||||
return FileResponse(paths[0], filename=f"任务报告-{session_id[:8]}.{fmt}")
|
return FileResponse(paths[0], filename=f"任务报告-{session_id[:8]}.{fmt}")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -184,13 +210,15 @@ def history(
|
|||||||
):
|
):
|
||||||
# SQL filtering/pagination searches the entire persisted history, not a UI cache.
|
# SQL filtering/pagination searches the entire persisted history, not a UI cache.
|
||||||
query = """WITH history AS (
|
query = """WITH history AS (
|
||||||
SELECT s.session_id, s.initial_goal, s.start_time, s.end_time,
|
SELECT s.session_id, s.initial_goal, s.start_time,
|
||||||
|
COALESCE(json_extract(j.payload,'$.ended_at'),s.end_time) end_time,
|
||||||
COALESCE(j.status,s.status) status, COALESCE(json_extract(j.payload,'$.case.name'),s.initial_goal) name,
|
COALESCE(j.status,s.status) status, COALESCE(json_extract(j.payload,'$.case.name'),s.initial_goal) name,
|
||||||
COALESCE(json_extract(j.payload,'$.case.tags'),'') tags,
|
COALESCE(json_extract(j.payload,'$.case.tags'),'') tags,
|
||||||
COALESCE(json_extract(j.payload,'$.case.case_id'),'') case_id,
|
COALESCE(json_extract(j.payload,'$.case.case_id'),'') case_id,
|
||||||
COALESCE(json_extract(j.payload,'$.case.category'),'') category,
|
COALESCE(json_extract(j.payload,'$.case.category'),'') category,
|
||||||
COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory,
|
COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory,
|
||||||
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions
|
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions,
|
||||||
|
COALESCE(json_extract(j.payload,'$.error'),'') error
|
||||||
FROM sessions s LEFT JOIN automation_jobs j ON s.session_id=j.session_id
|
FROM sessions s LEFT JOIN automation_jobs j ON s.session_id=j.session_id
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT j.session_id,json_extract(j.payload,'$.case.goal'),
|
SELECT j.session_id,json_extract(j.payload,'$.case.goal'),
|
||||||
@@ -200,7 +228,8 @@ def history(
|
|||||||
COALESCE(json_extract(j.payload,'$.case.case_id'),''),
|
COALESCE(json_extract(j.payload,'$.case.case_id'),''),
|
||||||
COALESCE(json_extract(j.payload,'$.case.category'),''),
|
COALESCE(json_extract(j.payload,'$.case.category'),''),
|
||||||
COALESCE(json_extract(j.payload,'$.case.subcategory'),''),
|
COALESCE(json_extract(j.payload,'$.case.subcategory'),''),
|
||||||
COALESCE(json_extract(j.payload,'$.case.preconditions'),'')
|
COALESCE(json_extract(j.payload,'$.case.preconditions'),''),
|
||||||
|
COALESCE(json_extract(j.payload,'$.error'),'')
|
||||||
FROM automation_jobs j WHERE NOT EXISTS (SELECT 1 FROM sessions s WHERE s.session_id=j.session_id)
|
FROM automation_jobs j WHERE NOT EXISTS (SELECT 1 FROM sessions s WHERE s.session_id=j.session_id)
|
||||||
) """
|
) """
|
||||||
where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')"
|
where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')"
|
||||||
@@ -226,4 +255,8 @@ def history(
|
|||||||
+ " ORDER BY start_time DESC,session_id LIMIT ? OFFSET ?",
|
+ " ORDER BY start_time DESC,session_id LIMIT ? OFFSET ?",
|
||||||
[*args, size, (page - 1) * size],
|
[*args, size, (page - 1) * size],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return {"items": [dict(r) for r in rows], "total": total, "page": page, "size": size}
|
items = [dict(row) for row in rows]
|
||||||
|
for item in items:
|
||||||
|
if item["case_id"]:
|
||||||
|
item["name"] = task_import.case_title(item)
|
||||||
|
return {"items": items, "total": total, "page": page, "size": size}
|
||||||
@@ -27,12 +27,16 @@ try:
|
|||||||
from admin_console.database.repositories.trace_repository import trace_repo
|
from admin_console.database.repositories.trace_repository import trace_repo
|
||||||
from admin_console.services.media_service import media_service
|
from admin_console.services.media_service import media_service
|
||||||
from admin_console.services.model_service import model_service
|
from admin_console.services.model_service import model_service
|
||||||
|
from admin_console.services.automation_store import store
|
||||||
|
from admin_console.services.task_import import case_title
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from apps.admin_console.core.state import state
|
from apps.admin_console.core.state import state
|
||||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||||
from apps.admin_console.database.repositories.trace_repository import trace_repo
|
from apps.admin_console.database.repositories.trace_repository import trace_repo
|
||||||
from apps.admin_console.services.media_service import media_service
|
from apps.admin_console.services.media_service import media_service
|
||||||
from apps.admin_console.services.model_service import model_service
|
from apps.admin_console.services.model_service import model_service
|
||||||
|
from apps.admin_console.services.automation_store import store
|
||||||
|
from apps.admin_console.services.task_import import case_title
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(tags=["sessions"])
|
router = APIRouter(tags=["sessions"])
|
||||||
@@ -52,6 +56,7 @@ async def list_sessions():
|
|||||||
|
|
||||||
def _list_sessions_sync():
|
def _list_sessions_sync():
|
||||||
rows = session_repo.get_all_sessions()
|
rows = session_repo.get_all_sessions()
|
||||||
|
jobs_by_id = store.jobs_for_sessions([str(row.get("session_id")) for row in rows])
|
||||||
video_rec_map = session_repo.get_video_recordings_map()
|
video_rec_map = session_repo.get_video_recordings_map()
|
||||||
latest_recordings = session_repo.get_latest_video_recordings_map()
|
latest_recordings = session_repo.get_latest_video_recordings_map()
|
||||||
agent_names_by_session = session_repo.get_agent_trace_names_map()
|
agent_names_by_session = session_repo.get_agent_trace_names_map()
|
||||||
@@ -74,6 +79,15 @@ def _list_sessions_sync():
|
|||||||
|
|
||||||
for row_dict in rows:
|
for row_dict in rows:
|
||||||
s_id = str(row_dict.get("session_id"))
|
s_id = str(row_dict.get("session_id"))
|
||||||
|
job = jobs_by_id.get(s_id)
|
||||||
|
if job:
|
||||||
|
case = job.get("case") or {}
|
||||||
|
row_dict["case_id"] = case.get("case_id") or ""
|
||||||
|
row_dict["display_name"] = case_title(case) or row_dict.get("initial_goal")
|
||||||
|
row_dict["error"] = job.get("error") or ""
|
||||||
|
if job.get("status") in {"completed", "failed", "cancelled", "interrupted", "skipped"}:
|
||||||
|
row_dict["status"] = job["status"]
|
||||||
|
row_dict["end_time"] = job.get("ended_at") or row_dict.get("end_time")
|
||||||
recording = latest_recordings.get(s_id)
|
recording = latest_recordings.get(s_id)
|
||||||
d_info_raw = row_dict.get("device_info")
|
d_info_raw = row_dict.get("device_info")
|
||||||
device_id = None
|
device_id = None
|
||||||
|
|||||||
@@ -517,9 +517,12 @@ async def get_model_config_and_env():
|
|||||||
|
|
||||||
@router.get("/server-status")
|
@router.get("/server-status")
|
||||||
async def get_server_runtime_status():
|
async def get_server_runtime_status():
|
||||||
"""Retrieve runtime status, PID, port, and uptime of the Artemis server."""
|
"""Return this server's status without scanning listening ports."""
|
||||||
import os
|
import os
|
||||||
from artemis.runtime.server_lifecycle import get_server_status
|
import time
|
||||||
|
|
||||||
|
from artemis.runtime.process_probe import pid_is_alive
|
||||||
|
from artemis.runtime.server_lifecycle import read_server_info
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from apps.admin_console.core.state import state
|
from apps.admin_console.core.state import state
|
||||||
@@ -527,18 +530,34 @@ async def get_server_runtime_status():
|
|||||||
from admin_console.core.state import state
|
from admin_console.core.state import state
|
||||||
|
|
||||||
port = getattr(state, "port", 8000)
|
port = getattr(state, "port", 8000)
|
||||||
status = get_server_status(port=port)
|
current_pid = os.getpid()
|
||||||
|
pids = {current_pid}
|
||||||
|
started_at = None
|
||||||
|
info = read_server_info()
|
||||||
|
if info and info.get("port") == port:
|
||||||
|
saved_pid = info.get("pid")
|
||||||
|
if isinstance(saved_pid, int) and saved_pid != current_pid and pid_is_alive(saved_pid):
|
||||||
|
pids.add(saved_pid)
|
||||||
|
if saved_pid == current_pid and isinstance(info.get("started_at"), (int, float)):
|
||||||
|
started_at = float(info["started_at"])
|
||||||
|
if started_at is None:
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
started_at = psutil.Process(current_pid).create_time()
|
||||||
|
except Exception:
|
||||||
|
started_at = None
|
||||||
# Explicit DTO: the raw metadata file additionally holds the lifecycle
|
# Explicit DTO: the raw metadata file additionally holds the lifecycle
|
||||||
# token, cmdline, and filesystem paths, none of which belong on the wire.
|
# token, cmdline, and filesystem paths, none of which belong on the wire.
|
||||||
return {
|
return {
|
||||||
"running": status["running"],
|
"running": True,
|
||||||
"port": status["port"],
|
"port": port,
|
||||||
"pids": status["pids"],
|
"pids": sorted(pids),
|
||||||
"active_pid": status["active_pid"],
|
"active_pid": current_pid,
|
||||||
"uptime_seconds": status["uptime_seconds"],
|
"uptime_seconds": max(0.0, time.time() - started_at) if started_at else None,
|
||||||
"url": status["url"],
|
"url": f"http://localhost:{port}",
|
||||||
"admin_url": status["admin_url"],
|
"admin_url": f"http://localhost:{port}/admin",
|
||||||
"current_pid": os.getpid(),
|
"current_pid": current_pid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -202,7 +202,9 @@ async def run_task(request: RunRequest):
|
|||||||
# With no explicit serial the probe itself resolves a live target (it
|
# With no explicit serial the probe itself resolves a live target (it
|
||||||
# prefers the diagnostics target preference, then any unlocked ready
|
# prefers the diagnostics target preference, then any unlocked ready
|
||||||
# device); the verified serial is bound below.
|
# device); the verified serial is bound below.
|
||||||
target_serial = request.device_serial
|
target_serial = (
|
||||||
|
request.runtime_overrides.device_serial if request.runtime_overrides else None
|
||||||
|
) or request.device_serial
|
||||||
device_probe = await readiness_engine.run_device_submission_probe(target_serial=target_serial)
|
device_probe = await readiness_engine.run_device_submission_probe(target_serial=target_serial)
|
||||||
if device_probe and device_probe.summary in {"Device Locked", "Lock State Unknown"}:
|
if device_probe and device_probe.summary in {"Device Locked", "Lock State Unknown"}:
|
||||||
locked_serial = (
|
locked_serial = (
|
||||||
@@ -224,7 +226,7 @@ async def run_task(request: RunRequest):
|
|||||||
# Only auto-selected targets may be re-bound to the probed device. An
|
# Only auto-selected targets may be re-bound to the probed device. An
|
||||||
# explicitly requested serial is never silently replaced -- if it is
|
# explicitly requested serial is never silently replaced -- if it is
|
||||||
# invalid, enqueue_tasks rejects the submission with a clear error.
|
# invalid, enqueue_tasks rejects the submission with a clear error.
|
||||||
if verified_serial and not request.device_serial:
|
if verified_serial and not target_serial:
|
||||||
target_serial = verified_serial
|
target_serial = verified_serial
|
||||||
|
|
||||||
response = await task_queue_service.enqueue_tasks(
|
response = await task_queue_service.enqueue_tasks(
|
||||||
@@ -240,6 +242,9 @@ async def run_task(request: RunRequest):
|
|||||||
ingress=request.ingress or "frontend",
|
ingress=request.ingress or "frontend",
|
||||||
session_id=request.session_id,
|
session_id=request.session_id,
|
||||||
conversation_id=request.conversation_id,
|
conversation_id=request.conversation_id,
|
||||||
|
runtime_overrides=request.runtime_overrides.model_dump(exclude_none=True)
|
||||||
|
if request.runtime_overrides
|
||||||
|
else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
from apps.admin_console.services.automation_service import register_submission
|
from apps.admin_console.services.automation_service import register_submission
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from apps.admin_console.services.task_import import TaskOptions
|
||||||
|
|
||||||
|
|
||||||
class RunRequest(BaseModel):
|
class RunRequest(BaseModel):
|
||||||
@@ -32,6 +33,7 @@ class RunRequest(BaseModel):
|
|||||||
ingress: str | None = "frontend"
|
ingress: str | None = "frontend"
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
conversation_id: str | None = None
|
conversation_id: str | None = None
|
||||||
|
runtime_overrides: TaskOptions | None = None
|
||||||
|
|
||||||
|
|
||||||
class ReplayRequest(BaseModel):
|
class ReplayRequest(BaseModel):
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import secrets
|
import secrets
|
||||||
import signal
|
import signal
|
||||||
|
import socket
|
||||||
import sys
|
import sys
|
||||||
from types import FrameType
|
from types import FrameType
|
||||||
|
|
||||||
@@ -421,6 +422,14 @@ queue_goals = state.queue_goals
|
|||||||
class ArtemisUvicornServer(uvicorn.Server):
|
class ArtemisUvicornServer(uvicorn.Server):
|
||||||
"""Notify application streams before Uvicorn waits for them to close."""
|
"""Notify application streams before Uvicorn waits for them to close."""
|
||||||
|
|
||||||
|
async def startup(self, sockets: list[socket.socket] | None = None) -> None:
|
||||||
|
# Reload workers start in a fresh interpreter; restore the actual bind
|
||||||
|
# address there before lifespan publishes runtime metadata.
|
||||||
|
state.host = self.config.host
|
||||||
|
state.port = self.config.port
|
||||||
|
app.state.uvicorn_server = self
|
||||||
|
await super().startup(sockets=sockets)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _task_is_active() -> bool:
|
def _task_is_active() -> bool:
|
||||||
"""Check task state without doing process I/O from a signal handler."""
|
"""Check task state without doing process I/O from a signal handler."""
|
||||||
@@ -470,31 +479,40 @@ class ArtemisUvicornServer(uvicorn.Server):
|
|||||||
|
|
||||||
def run_ui_server(host: str, port: int, reload: bool = False) -> None:
|
def run_ui_server(host: str, port: int, reload: bool = False) -> None:
|
||||||
"""Run the UI server with bounded, signal-aware graceful shutdown."""
|
"""Run the UI server with bounded, signal-aware graceful shutdown."""
|
||||||
|
# Reserve a listening socket before publishing metadata or running lifespan.
|
||||||
|
# Uvicorn normally runs application startup *before* binding its port; a
|
||||||
|
# duplicate daemon would otherwise recover jobs owned by the real server.
|
||||||
|
family = socket.AF_INET6 if ":" in host else socket.AF_INET
|
||||||
|
try:
|
||||||
|
listener = socket.create_server((host, port), family=family)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"无法启动服务:{host}:{port} 端口不可用({exc})。现有服务和任务保持不变。")
|
||||||
|
raise SystemExit(1) from exc
|
||||||
|
listener.set_inheritable(True)
|
||||||
|
port = listener.getsockname()[1]
|
||||||
state.host = host
|
state.host = host
|
||||||
state.port = port
|
state.port = port
|
||||||
write_server_info(port=port, host=host, lifecycle_token=LIFECYCLE_TOKEN)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if reload:
|
|
||||||
uvicorn.run(
|
|
||||||
"apps.admin_console.server:app",
|
|
||||||
host=host,
|
|
||||||
port=port,
|
|
||||||
reload=True,
|
|
||||||
timeout_graceful_shutdown=5,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
config = uvicorn.Config(
|
config = uvicorn.Config(
|
||||||
app,
|
"apps.admin_console.server:app" if reload else app,
|
||||||
host=host,
|
host=host,
|
||||||
port=port,
|
port=port,
|
||||||
|
reload=reload,
|
||||||
timeout_graceful_shutdown=5,
|
timeout_graceful_shutdown=5,
|
||||||
)
|
)
|
||||||
|
if reload:
|
||||||
|
from uvicorn.supervisors import ChangeReload
|
||||||
|
|
||||||
|
server = ArtemisUvicornServer(config)
|
||||||
|
ChangeReload(config, target=server.run, sockets=[listener]).run()
|
||||||
|
return
|
||||||
|
|
||||||
server = ArtemisUvicornServer(config)
|
server = ArtemisUvicornServer(config)
|
||||||
app.state.uvicorn_server = server
|
app.state.uvicorn_server = server
|
||||||
server.run()
|
server.run(sockets=[listener])
|
||||||
finally:
|
finally:
|
||||||
|
listener.close()
|
||||||
app.state.uvicorn_server = None
|
app.state.uvicorn_server = None
|
||||||
clear_server_info(port=port, lifecycle_token=LIFECYCLE_TOKEN)
|
clear_server_info(port=port, lifecycle_token=LIFECYCLE_TOKEN)
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,15 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import time
|
import time
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from apps.admin_console.core.state import state
|
from apps.admin_console.core.state import state
|
||||||
from apps.admin_console.services.automation_store import store, new_id
|
from apps.admin_console.services.automation_store import RECOVERY_ERRORS, store, new_id
|
||||||
from apps.admin_console.services.task_import import case_goal
|
from apps.admin_console.services.task_import import case_goal, case_title
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
from artemis.runtime.task_limits import task_timeout
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"}
|
TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"}
|
||||||
@@ -38,9 +42,46 @@ def register_submission(item: dict, case: dict | None = None, formats=None, **ex
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_recovery_error(job: dict) -> bool:
|
||||||
|
"""Archive a superseded recovery warning without erasing execution failures."""
|
||||||
|
if job.get("error") not in RECOVERY_ERRORS:
|
||||||
|
return False
|
||||||
|
job.setdefault("recovery_events", []).append(
|
||||||
|
{"message": job.pop("error"), "resolved_at": time.time()}
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def recover_batches():
|
def recover_batches():
|
||||||
"""Recovery never silently repeats a potentially side-effecting interrupted task."""
|
"""Recovery never silently repeats a potentially side-effecting interrupted task."""
|
||||||
|
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||||
|
from artemis.runtime import DeviceExecutionLock
|
||||||
|
|
||||||
|
repository = SessionRepository(store.path)
|
||||||
|
live_sessions = {
|
||||||
|
str(owner.session_id)
|
||||||
|
for owner in DeviceExecutionLock.get_active_owners().values()
|
||||||
|
if owner.session_id
|
||||||
|
}
|
||||||
|
live_sessions.update(
|
||||||
|
str(row["session_id"])
|
||||||
|
for row in repository.get_all_sessions()
|
||||||
|
if row.get("status") not in TERMINAL and repository.process_is_alive(row.get("pid"))
|
||||||
|
)
|
||||||
|
live_sessions.update(
|
||||||
|
str(item["session_id"])
|
||||||
|
for item in state.queue_items
|
||||||
|
if isinstance(item, dict) and item.get("session_id") and item.get("status") not in TERMINAL
|
||||||
|
)
|
||||||
for job in store.jobs():
|
for job in store.jobs():
|
||||||
|
if job["status"] == "completed" and resolve_recovery_error(job):
|
||||||
|
store.save_job(job)
|
||||||
|
if str(job["session_id"]) in live_sessions:
|
||||||
|
if job["status"] not in TERMINAL and str(job["session_id"]) not in state.active_runs:
|
||||||
|
job["recovery_waiting"] = True
|
||||||
|
store.save_job(job)
|
||||||
|
logger.info("Recovery preserved live session %s", job["session_id"])
|
||||||
|
continue
|
||||||
if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}:
|
if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}:
|
||||||
job.update(
|
job.update(
|
||||||
status=job.get("terminal_status", "interrupted"),
|
status=job.get("terminal_status", "interrupted"),
|
||||||
@@ -48,6 +89,9 @@ def recover_batches():
|
|||||||
or ("" if job.get("terminal_status") else "服务中断;未完成任务需手动重跑"),
|
or ("" if job.get("terminal_status") else "服务中断;未完成任务需手动重跑"),
|
||||||
ended_at=job.get("ended_at") or time.time(),
|
ended_at=job.get("ended_at") or time.time(),
|
||||||
)
|
)
|
||||||
|
if job["status"] == "completed":
|
||||||
|
resolve_recovery_error(job)
|
||||||
|
logger.info("Recovery resolved session %s to %s", job["session_id"], job["status"])
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
for batch in store.records("batch"):
|
for batch in store.records("batch"):
|
||||||
if has_legacy_replay(batch["id"]):
|
if has_legacy_replay(batch["id"]):
|
||||||
@@ -60,6 +104,8 @@ def recover_batches():
|
|||||||
store.put("batch", batch["id"], batch)
|
store.put("batch", batch["id"], batch)
|
||||||
continue
|
continue
|
||||||
if batch["status"] in {"running", "pausing", "paused"}:
|
if batch["status"] in {"running", "pausing", "paused"}:
|
||||||
|
if any(str(job["session_id"]) in live_sessions for job in store.jobs(batch["id"])):
|
||||||
|
continue
|
||||||
batch.update(
|
batch.update(
|
||||||
status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务"
|
status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务"
|
||||||
)
|
)
|
||||||
@@ -82,9 +128,12 @@ async def on_terminal(item: dict, status: str):
|
|||||||
job = store.job(sid)
|
job = store.job(sid)
|
||||||
if job is None:
|
if job is None:
|
||||||
raise RuntimeError("任务元数据写入失败")
|
raise RuntimeError("任务元数据写入失败")
|
||||||
job.update(status="finalizing", ended_at=time.time())
|
|
||||||
if job.get("timed_out"):
|
if job.get("timed_out"):
|
||||||
status = "failed"
|
status = "failed"
|
||||||
|
job.pop("recovery_waiting", None)
|
||||||
|
if status == "completed":
|
||||||
|
resolve_recovery_error(job)
|
||||||
|
job.update(status="finalizing", terminal_status=status, ended_at=time.time())
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
try:
|
try:
|
||||||
job["status"] = status
|
job["status"] = status
|
||||||
@@ -99,7 +148,9 @@ async def on_terminal(item: dict, status: str):
|
|||||||
}
|
}
|
||||||
):
|
):
|
||||||
job["status"] = "failed"
|
job["status"] = "failed"
|
||||||
job["error"] = "任务执行结束,但业务检查未通过"
|
job["error"] = job.get("error") or "任务执行结束,但业务检查未通过"
|
||||||
|
elif job["status"] == "failed" and report.get("error") and not job.get("error"):
|
||||||
|
job["error"] = report["error"]
|
||||||
# Persist terminal classification before export, but the coordinator waits for finalization.
|
# Persist terminal classification before export, but the coordinator waits for finalization.
|
||||||
store.save_job({**job, "status": "finalizing", "terminal_status": job["status"]})
|
store.save_job({**job, "status": "finalizing", "terminal_status": job["status"]})
|
||||||
await asyncio.to_thread(export_session, sid, job["formats"])
|
await asyncio.to_thread(export_session, sid, job["formats"])
|
||||||
@@ -107,14 +158,41 @@ async def on_terminal(item: dict, status: str):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Report finalization failed for %s", sid)
|
logger.exception("Report finalization failed for %s", sid)
|
||||||
job["report_error"] = str(exc)[:500]
|
job["report_error"] = str(exc)[:500]
|
||||||
|
job.pop("terminal_status", None)
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
|
|
||||||
|
|
||||||
|
async def reconcile_recovered_jobs():
|
||||||
|
"""Finalize surviving workers once they exit; never resubmit their goals."""
|
||||||
|
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||||
|
from artemis.runtime import DeviceExecutionLock
|
||||||
|
from artemis.runtime.task_result import read_worker_outcome
|
||||||
|
|
||||||
|
waiting = [job for job in store.jobs() if job.get("recovery_waiting")]
|
||||||
|
if not waiting:
|
||||||
|
return
|
||||||
|
repository = SessionRepository(store.path)
|
||||||
|
owners = {str(owner.session_id) for owner in DeviceExecutionLock.get_active_owners().values()}
|
||||||
|
for job in waiting:
|
||||||
|
sid = str(job["session_id"])
|
||||||
|
row = repository.get_session_by_id(sid) or {}
|
||||||
|
if sid in owners or repository.process_is_alive(row.get("pid")):
|
||||||
|
continue
|
||||||
|
outcome = read_worker_outcome(sid)
|
||||||
|
status = job.get("terminal_status") or outcome.get("task_status") or row.get("status")
|
||||||
|
if status not in TERMINAL:
|
||||||
|
status = "interrupted"
|
||||||
|
job["error"] = job.get("error") or "服务中断;未完成任务需手动重跑"
|
||||||
|
store.save_job(job)
|
||||||
|
await on_terminal({"session_id": sid}, status)
|
||||||
|
|
||||||
|
|
||||||
async def tick_batches():
|
async def tick_batches():
|
||||||
from apps.admin_console.server import task_queue_service
|
from apps.admin_console.server import task_queue_service
|
||||||
from artemis.runtime import device_pool
|
from artemis.runtime import device_pool
|
||||||
from apps.admin_console.services.report_export import export_batch
|
from apps.admin_console.services.report_export import export_batch
|
||||||
|
|
||||||
|
await reconcile_recovered_jobs()
|
||||||
for batch in store.records("batch"):
|
for batch in store.records("batch"):
|
||||||
if batch["status"] not in {"running", "paused"}:
|
if batch["status"] not in {"running", "paused"}:
|
||||||
continue
|
continue
|
||||||
@@ -125,24 +203,41 @@ async def tick_batches():
|
|||||||
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
|
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
|
||||||
store.put("batch", batch["id"], batch)
|
store.put("batch", batch["id"], batch)
|
||||||
for job in active:
|
for job in active:
|
||||||
limit = int(
|
# The queue records started_at at submission, then replaces it
|
||||||
(job.get("case", {}).get("task_config") or {}).get("timeout_seconds")
|
# when the worker actually starts. Waiting for a device must
|
||||||
or batch.get("timeout_seconds", 1800)
|
# not consume this case's execution budget.
|
||||||
)
|
if job["status"] != "running":
|
||||||
|
continue
|
||||||
|
from artemis.runtime.task_result import read_worker_outcome
|
||||||
|
|
||||||
|
if read_worker_outcome(str(job["session_id"])):
|
||||||
|
continue # Execution has ended; the worker supervisor bounds cleanup.
|
||||||
|
limit = task_timeout({}, job, batch)
|
||||||
task_state = getattr(state, "active_runs", {}).get(str(job["session_id"])) or {}
|
task_state = getattr(state, "active_runs", {}).get(str(job["session_id"])) or {}
|
||||||
excluded = float(task_state.get("non_execution_seconds") or 0)
|
excluded = float(task_state.get("non_execution_seconds") or 0)
|
||||||
if task_state.get("non_execution_started_at"):
|
if task_state.get("non_execution_started_at"):
|
||||||
excluded += time.time() - float(task_state["non_execution_started_at"])
|
excluded += time.time() - float(task_state["non_execution_started_at"])
|
||||||
if (
|
if (
|
||||||
job["status"] != "finalizing"
|
not job.get("timed_out")
|
||||||
and not job.get("timed_out")
|
|
||||||
and time.time() - job.get("started_at", time.time()) - excluded > limit
|
and time.time() - job.get("started_at", time.time()) - excluded > limit
|
||||||
):
|
):
|
||||||
job.update(error="任务达到批次配置的执行时限", timed_out=True)
|
job.update(
|
||||||
|
error=f"任务执行超时({limit} 秒,不含暂停/排队时间)", timed_out=True
|
||||||
|
)
|
||||||
store.save_job(job)
|
store.save_job(job)
|
||||||
task_queue_service.stop_tasks(session_id=job["session_id"])
|
from artemis.runtime.task_result import save_failure_result
|
||||||
batch.update(status="paused", reason="任务超时,已暂停剩余任务;请确认设备状态")
|
|
||||||
store.put("batch", batch["id"], batch)
|
try:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
save_failure_result,
|
||||||
|
str(job["session_id"]),
|
||||||
|
job["error"],
|
||||||
|
code="task_timeout",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
task_queue_service.stop_tasks(session_id=job["session_id"])
|
||||||
|
# on_terminal records the failed case. The next pending
|
||||||
|
# case then runs normally unless stop_on_failure is set.
|
||||||
continue
|
continue
|
||||||
if batch["status"] != "running":
|
if batch["status"] != "running":
|
||||||
continue
|
continue
|
||||||
@@ -202,6 +297,7 @@ async def tick_batches():
|
|||||||
runtime_overrides=options,
|
runtime_overrides=options,
|
||||||
session_id=job["session_id"],
|
session_id=job["session_id"],
|
||||||
ingress="batch",
|
ingress="batch",
|
||||||
|
display_name=case_title(job["case"]),
|
||||||
)
|
)
|
||||||
if response.get("status") == "rejected" or not response.get("tasks"):
|
if response.get("status") == "rejected" or not response.get("tasks"):
|
||||||
raise ValueError(response.get("error") or "队列拒绝接收任务")
|
raise ValueError(response.get("error") or "队列拒绝接收任务")
|
||||||
@@ -243,8 +339,7 @@ def create_batch(payload: dict) -> dict:
|
|||||||
"formats": payload.get("formats") or store.preferences()["formats"],
|
"formats": payload.get("formats") or store.preferences()["formats"],
|
||||||
"profile": payload.get("profile", "flash"),
|
"profile": payload.get("profile", "flash"),
|
||||||
"device_serial": payload.get("device_serial"),
|
"device_serial": payload.get("device_serial"),
|
||||||
"timeout_seconds": payload.get("timeout_seconds")
|
"timeout_seconds": payload.get("timeout_seconds") or settings.ARTEMIS_TASK_TIMEOUT_SECONDS,
|
||||||
or int(os.getenv("ARTEMIS_BATCH_TASK_TIMEOUT", "1800")),
|
|
||||||
"stop_on_failure": payload.get("stop_on_failure", False),
|
"stop_on_failure": payload.get("stop_on_failure", False),
|
||||||
"request_id": payload.get("request_id"),
|
"request_id": payload.get("request_id"),
|
||||||
}
|
}
|
||||||
@@ -265,6 +360,35 @@ def create_batch(payload: dict) -> dict:
|
|||||||
return store.create_batch(batch, jobs, payload)
|
return store.create_batch(batch, jobs, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_batch(batch_id: str) -> bool:
|
||||||
|
"""Delete an inactive batch, its sessions and its generated reports."""
|
||||||
|
from artemis.data_engine.storage import StorageManager
|
||||||
|
from apps.admin_console.services import report_export
|
||||||
|
|
||||||
|
jobs = store.begin_delete_batch(batch_id)
|
||||||
|
if jobs is None:
|
||||||
|
return False
|
||||||
|
storage = StorageManager(store.path, report_export.TRACES_PATH)
|
||||||
|
mcp_exports = report_export.EXPORT_ROOT.parent / "mcp_exports"
|
||||||
|
with report_export.EXPORT_LOCK:
|
||||||
|
for job in jobs:
|
||||||
|
session_id = str(UUID(job["session_id"]))
|
||||||
|
storage.delete_session(UUID(session_id))
|
||||||
|
report_dir = report_export.EXPORT_ROOT / "sessions" / session_id
|
||||||
|
if report_dir.is_dir():
|
||||||
|
shutil.rmtree(report_dir)
|
||||||
|
mcp_session_dir = mcp_exports / "sessions" / session_id
|
||||||
|
if mcp_session_dir.is_dir():
|
||||||
|
shutil.rmtree(mcp_session_dir)
|
||||||
|
batch_dir = report_export.EXPORT_ROOT / "batches" / batch_id
|
||||||
|
if batch_dir.is_dir():
|
||||||
|
shutil.rmtree(batch_dir)
|
||||||
|
mcp_batch_dir = mcp_exports / "batches" / batch_id
|
||||||
|
if mcp_batch_dir.is_dir():
|
||||||
|
shutil.rmtree(mcp_batch_dir)
|
||||||
|
return store.delete_batch(batch_id)
|
||||||
|
|
||||||
|
|
||||||
def batch_action(batch_id: str, action: str) -> dict:
|
def batch_action(batch_id: str, action: str) -> dict:
|
||||||
batch = store.get("batch", batch_id)
|
batch = store.get("batch", batch_id)
|
||||||
if not batch:
|
if not batch:
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ import uuid
|
|||||||
from artemis.config import DB_PATH
|
from artemis.config import DB_PATH
|
||||||
|
|
||||||
|
|
||||||
|
RECOVERY_ERRORS = {
|
||||||
|
"服务中断;未完成任务需手动重跑",
|
||||||
|
"服务中断,需手动重跑",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def identifier(value: str) -> str:
|
def identifier(value: str) -> str:
|
||||||
if not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", value):
|
if not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", value):
|
||||||
raise ValueError("无效的记录编号")
|
raise ValueError("无效的记录编号")
|
||||||
@@ -54,6 +60,13 @@ class AutomationStore:
|
|||||||
|
|
||||||
def put(self, kind: str, key: str, payload: dict) -> dict:
|
def put(self, kind: str, key: str, payload: dict) -> dict:
|
||||||
with self.connection() as conn:
|
with self.connection() as conn:
|
||||||
|
if kind == "batch":
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?", (key,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None or json.loads(row[0]).get("status") == "deleting":
|
||||||
|
raise ValueError("批次正在删除或已删除,不能继续更新")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO automation_records VALUES (?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at",
|
"INSERT INTO automation_records VALUES (?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at",
|
||||||
(kind, key, json.dumps(payload, ensure_ascii=False), time.time()),
|
(kind, key, json.dumps(payload, ensure_ascii=False), time.time()),
|
||||||
@@ -78,6 +91,14 @@ class AutomationStore:
|
|||||||
|
|
||||||
def save_job(self, payload: dict) -> dict:
|
def save_job(self, payload: dict) -> dict:
|
||||||
with self.connection() as conn:
|
with self.connection() as conn:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
if batch_id := payload.get("batch_id"):
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?",
|
||||||
|
(batch_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None or json.loads(row[0]).get("status") == "deleting":
|
||||||
|
raise ValueError("批次正在删除,不能继续派发任务")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO automation_jobs VALUES (?,?,?,?,?,?) ON CONFLICT(session_id) DO UPDATE SET payload=excluded.payload,status=excluded.status,updated_at=excluded.updated_at",
|
"INSERT INTO automation_jobs VALUES (?,?,?,?,?,?) ON CONFLICT(session_id) DO UPDATE SET payload=excluded.payload,status=excluded.status,updated_at=excluded.updated_at",
|
||||||
(
|
(
|
||||||
@@ -106,6 +127,25 @@ class AutomationStore:
|
|||||||
)
|
)
|
||||||
return [{**json.loads(row[0]), "status": row[1]} for row in rows]
|
return [{**json.loads(row[0]), "status": row[1]} for row in rows]
|
||||||
|
|
||||||
|
def jobs_for_sessions(self, session_ids: list[str]) -> dict[str, dict]:
|
||||||
|
"""Load only metadata belonging to the sessions shown in history."""
|
||||||
|
result = {}
|
||||||
|
with self.connection() as conn:
|
||||||
|
for offset in range(0, len(session_ids), 400):
|
||||||
|
chunk = session_ids[offset : offset + 400]
|
||||||
|
markers = ",".join("?" for _ in chunk)
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT session_id,payload,status FROM automation_jobs WHERE session_id IN ({markers})",
|
||||||
|
chunk,
|
||||||
|
).fetchall()
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
row["session_id"]: {**json.loads(row["payload"]), "status": row["status"]}
|
||||||
|
for row in rows
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
def preferences(self) -> dict:
|
def preferences(self) -> dict:
|
||||||
return self.get("settings", "reports") or {"formats": ["docx", "xlsx"]}
|
return self.get("settings", "reports") or {"formats": ["docx", "xlsx"]}
|
||||||
|
|
||||||
@@ -164,8 +204,38 @@ class AutomationStore:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
return batch
|
return batch
|
||||||
|
|
||||||
|
def begin_delete_batch(self, batch_id: str) -> list[dict] | None:
|
||||||
|
"""Reserve an inactive batch for cleanup before any more jobs can start."""
|
||||||
|
with self.connection() as conn:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?",
|
||||||
|
(batch_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
batch = json.loads(row[0])
|
||||||
|
active = conn.execute(
|
||||||
|
"SELECT 1 FROM automation_jobs WHERE batch_id=? AND status IN "
|
||||||
|
"('queued','running','task_paused','resuming','finalizing') LIMIT 1",
|
||||||
|
(batch_id,),
|
||||||
|
).fetchone()
|
||||||
|
if batch["status"] == "running" or active:
|
||||||
|
raise ValueError("批次仍有任务执行中或排队中,请等待完成后删除")
|
||||||
|
batch["status"] = "deleting"
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE automation_records SET payload=?,updated_at=? WHERE kind='batch' AND id=?",
|
||||||
|
(json.dumps(batch, ensure_ascii=False), time.time(), batch_id),
|
||||||
|
)
|
||||||
|
jobs = conn.execute(
|
||||||
|
"SELECT payload,status FROM automation_jobs WHERE batch_id=? ORDER BY position",
|
||||||
|
(batch_id,),
|
||||||
|
).fetchall()
|
||||||
|
conn.commit()
|
||||||
|
return [{**json.loads(job[0]), "status": job[1]} for job in jobs]
|
||||||
|
|
||||||
def delete_batch(self, batch_id: str) -> bool:
|
def delete_batch(self, batch_id: str) -> bool:
|
||||||
"""Remove a batch record; retain executed job metadata and its reports."""
|
"""Remove a batch record and all of its jobs after cleanup."""
|
||||||
with self.connection() as conn:
|
with self.connection() as conn:
|
||||||
conn.execute("BEGIN IMMEDIATE")
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
@@ -175,17 +245,9 @@ class AutomationStore:
|
|||||||
if row is None:
|
if row is None:
|
||||||
return False
|
return False
|
||||||
batch = json.loads(row[0])
|
batch = json.loads(row[0])
|
||||||
active = conn.execute(
|
if batch.get("status") != "deleting":
|
||||||
"SELECT 1 FROM automation_jobs WHERE batch_id=? AND status IN "
|
raise ValueError("批次尚未准备删除")
|
||||||
"('queued','running','task_paused','resuming','finalizing') LIMIT 1",
|
conn.execute("DELETE FROM automation_jobs WHERE batch_id=?", (batch_id,))
|
||||||
(batch_id,),
|
|
||||||
).fetchone()
|
|
||||||
if batch["status"] == "running" or active:
|
|
||||||
raise ValueError("批次仍有任务执行中或排队中,请等待完成后删除")
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM automation_jobs WHERE batch_id=? AND status='pending'",
|
|
||||||
(batch_id,),
|
|
||||||
)
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM automation_records WHERE kind='batch' AND id=?",
|
"DELETE FROM automation_records WHERE kind='batch' AND id=?",
|
||||||
(batch_id,),
|
(batch_id,),
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Background preparation and observable progress for batch report downloads."""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from apps.admin_console.services import report_export
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_states: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def archive_path(batch_id: str):
|
||||||
|
return report_export.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip"
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot(state: dict) -> dict:
|
||||||
|
elapsed = max(0, (state.get("finished_at") or time.monotonic()) - state["started_at"])
|
||||||
|
completed, total = state["completed"], state["total"]
|
||||||
|
eta = (
|
||||||
|
max(0, round(elapsed / completed * (total - completed)))
|
||||||
|
if completed and total > completed
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": state["status"],
|
||||||
|
"stage": state["stage"],
|
||||||
|
"completed": completed,
|
||||||
|
"total": total,
|
||||||
|
"elapsed_seconds": round(elapsed),
|
||||||
|
"eta_seconds": eta,
|
||||||
|
"error": state.get("error"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def status(batch_id: str, batch: dict) -> dict:
|
||||||
|
with _lock:
|
||||||
|
state = _states.get(batch_id)
|
||||||
|
if state:
|
||||||
|
return _snapshot(state)
|
||||||
|
if batch.get("report_ready") and archive_path(batch_id).is_file():
|
||||||
|
return {
|
||||||
|
"status": "ready",
|
||||||
|
"stage": "报告包已就绪",
|
||||||
|
"completed": 0,
|
||||||
|
"total": 0,
|
||||||
|
"elapsed_seconds": 0,
|
||||||
|
"eta_seconds": None,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": "idle",
|
||||||
|
"stage": "尚未开始打包",
|
||||||
|
"completed": 0,
|
||||||
|
"total": 0,
|
||||||
|
"elapsed_seconds": 0,
|
||||||
|
"eta_seconds": None,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(batch_id: str, batch: dict) -> dict:
|
||||||
|
with _lock:
|
||||||
|
current = _states.get(batch_id)
|
||||||
|
if current and current["status"] == "preparing":
|
||||||
|
return _snapshot(current)
|
||||||
|
if batch.get("report_ready") and archive_path(batch_id).is_file():
|
||||||
|
_states.pop(batch_id, None)
|
||||||
|
return status_from_cache()
|
||||||
|
state = {
|
||||||
|
"status": "preparing",
|
||||||
|
"stage": "等待打包",
|
||||||
|
"completed": 0,
|
||||||
|
"total": len(report_export.store.jobs(batch_id)),
|
||||||
|
"started_at": time.monotonic(),
|
||||||
|
}
|
||||||
|
_states[batch_id] = state
|
||||||
|
|
||||||
|
def update(completed: int, total: int, stage: str) -> None:
|
||||||
|
with _lock:
|
||||||
|
state.update(completed=completed, total=total, stage=stage)
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
try:
|
||||||
|
with report_export.EXPORT_LOCK:
|
||||||
|
latest = report_export.store.get("batch", batch_id)
|
||||||
|
if not (latest and latest.get("report_ready") and archive_path(batch_id).is_file()):
|
||||||
|
report_export.export_batch(batch_id, progress=update)
|
||||||
|
with _lock:
|
||||||
|
state.update(status="ready", stage="报告包已就绪", finished_at=time.monotonic())
|
||||||
|
except Exception as exc:
|
||||||
|
with _lock:
|
||||||
|
state.update(
|
||||||
|
status="failed",
|
||||||
|
stage="打包失败",
|
||||||
|
error=str(exc)[:500],
|
||||||
|
finished_at=time.monotonic(),
|
||||||
|
)
|
||||||
|
|
||||||
|
threading.Thread(target=run, name=f"batch-archive-{batch_id[:8]}", daemon=True).start()
|
||||||
|
return status(batch_id, batch)
|
||||||
|
|
||||||
|
|
||||||
|
def status_from_cache() -> dict:
|
||||||
|
return {
|
||||||
|
"status": "ready",
|
||||||
|
"stage": "报告包已就绪",
|
||||||
|
"completed": 0,
|
||||||
|
"total": 0,
|
||||||
|
"elapsed_seconds": 0,
|
||||||
|
"eta_seconds": None,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def forget(batch_id: str) -> None:
|
||||||
|
with _lock:
|
||||||
|
_states.pop(batch_id, None)
|
||||||
@@ -9,6 +9,7 @@ from urllib.parse import urlsplit
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field, SecretStr, field_validator
|
from pydantic import BaseModel, Field, SecretStr, field_validator
|
||||||
|
|
||||||
|
from artemis.config.agent import load_agent_config
|
||||||
from artemis.config.paths import get_env_file
|
from artemis.config.paths import get_env_file
|
||||||
from artemis.config.settings import settings
|
from artemis.config.settings import settings
|
||||||
|
|
||||||
@@ -22,6 +23,13 @@ class ModelSettingsUpdate(BaseModel):
|
|||||||
timeout_seconds: float = Field(default=300, gt=0, le=3600)
|
timeout_seconds: float = Field(default=300, gt=0, le=3600)
|
||||||
max_retries: int = Field(default=5, ge=0, le=10)
|
max_retries: int = Field(default=5, ge=0, le=10)
|
||||||
retry_interval_seconds: float = Field(default=10, ge=0, le=300)
|
retry_interval_seconds: float = Field(default=10, ge=0, le=300)
|
||||||
|
rate_limit_retry_seconds: float | None = Field(default=None, ge=0, le=3600)
|
||||||
|
tool_fallback_model: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
tool_fallback_reasoning_effort: (
|
||||||
|
Literal["default", "none", "minimal", "low", "medium", "high", "xhigh", "max"] | None
|
||||||
|
) = None
|
||||||
|
tool_fallback_max_retries: int | None = Field(default=None, ge=0, le=10)
|
||||||
|
tool_fallback_no_progress_attempts: int | None = Field(default=None, ge=1, le=10)
|
||||||
reasoning_effort: Literal[
|
reasoning_effort: Literal[
|
||||||
"default", "none", "minimal", "low", "medium", "high", "xhigh", "max"
|
"default", "none", "minimal", "low", "medium", "high", "xhigh", "max"
|
||||||
] = "high"
|
] = "high"
|
||||||
@@ -44,9 +52,11 @@ class ModelSettingsUpdate(BaseModel):
|
|||||||
raise ValueError("请输入接口根地址,例如 https://api-n-cd.playones.com/v1")
|
raise ValueError("请输入接口根地址,例如 https://api-n-cd.playones.com/v1")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@field_validator("model")
|
@field_validator("model", "tool_fallback_model")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_model(cls, value: str) -> str:
|
def validate_model(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
value = value.strip()
|
value = value.strip()
|
||||||
if not value or any(c in value for c in "\r\n\x00"):
|
if not value or any(c in value for c in "\r\n\x00"):
|
||||||
raise ValueError("模型名称不能为空或包含换行")
|
raise ValueError("模型名称不能为空或包含换行")
|
||||||
@@ -63,8 +73,18 @@ def get_model_settings() -> dict:
|
|||||||
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
|
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
|
||||||
"max_retries": settings.OPENAI_MAX_RETRIES,
|
"max_retries": settings.OPENAI_MAX_RETRIES,
|
||||||
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
|
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
|
||||||
|
"rate_limit_retry_seconds": settings.OPENAI_RATE_LIMIT_RETRY_SECONDS,
|
||||||
|
"tool_fallback_model": settings.OPENAI_TOOL_FALLBACK_MODEL,
|
||||||
|
"tool_fallback_reasoning_effort": settings.OPENAI_TOOL_FALLBACK_REASONING_EFFORT,
|
||||||
|
"tool_fallback_max_retries": settings.OPENAI_TOOL_FALLBACK_MAX_RETRIES,
|
||||||
|
"tool_fallback_no_progress_attempts": settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS,
|
||||||
"reasoning_effort": settings.OPENAI_REASONING_EFFORT,
|
"reasoning_effort": settings.OPENAI_REASONING_EFFORT,
|
||||||
"model_presets": ["gpt-5.6-luna", "mimo-v2.6-flash"],
|
"model_presets": [
|
||||||
|
"gpt-5.6-luna",
|
||||||
|
"gpt-6-luna",
|
||||||
|
"mimo-v2.6-flash",
|
||||||
|
"mimo-v2.6-pro",
|
||||||
|
],
|
||||||
"env_path": str(get_env_file()),
|
"env_path": str(get_env_file()),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +100,20 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
|
|||||||
"OPENAI_RETRY_INTERVAL_SECONDS": str(update.retry_interval_seconds),
|
"OPENAI_RETRY_INTERVAL_SECONDS": str(update.retry_interval_seconds),
|
||||||
"OPENAI_REASONING_EFFORT": update.reasoning_effort,
|
"OPENAI_REASONING_EFFORT": update.reasoning_effort,
|
||||||
}
|
}
|
||||||
|
optional_settings = {
|
||||||
|
"rate_limit_retry_seconds": ("OPENAI_RATE_LIMIT_RETRY_SECONDS", float),
|
||||||
|
"tool_fallback_model": ("OPENAI_TOOL_FALLBACK_MODEL", str),
|
||||||
|
"tool_fallback_reasoning_effort": ("OPENAI_TOOL_FALLBACK_REASONING_EFFORT", str),
|
||||||
|
"tool_fallback_max_retries": ("OPENAI_TOOL_FALLBACK_MAX_RETRIES", int),
|
||||||
|
"tool_fallback_no_progress_attempts": (
|
||||||
|
"ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS",
|
||||||
|
int,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for field_name, (env_name, expected_type) in optional_settings.items():
|
||||||
|
value = getattr(update, field_name)
|
||||||
|
if value is not None:
|
||||||
|
values[env_name] = str(value)
|
||||||
if update.api_key and update.api_key.get_secret_value().strip():
|
if update.api_key and update.api_key.get_secret_value().strip():
|
||||||
values["OPENAI_API_KEY"] = update.api_key.get_secret_value().strip()
|
values["OPENAI_API_KEY"] = update.api_key.get_secret_value().strip()
|
||||||
persist_environment(values)
|
persist_environment(values)
|
||||||
@@ -89,6 +123,10 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
|
|||||||
settings.OPENAI_MAX_RETRIES = update.max_retries
|
settings.OPENAI_MAX_RETRIES = update.max_retries
|
||||||
settings.OPENAI_RETRY_INTERVAL_SECONDS = update.retry_interval_seconds
|
settings.OPENAI_RETRY_INTERVAL_SECONDS = update.retry_interval_seconds
|
||||||
settings.OPENAI_REASONING_EFFORT = update.reasoning_effort
|
settings.OPENAI_REASONING_EFFORT = update.reasoning_effort
|
||||||
|
for field_name, (env_name, expected_type) in optional_settings.items():
|
||||||
|
value = getattr(update, field_name)
|
||||||
|
if value is not None:
|
||||||
|
setattr(settings, env_name, expected_type(value))
|
||||||
if "OPENAI_API_KEY" in values:
|
if "OPENAI_API_KEY" in values:
|
||||||
settings.OPENAI_API_KEY = SecretStr(values["OPENAI_API_KEY"])
|
settings.OPENAI_API_KEY = SecretStr(values["OPENAI_API_KEY"])
|
||||||
from artemis.llm.router import ModelFactory
|
from artemis.llm.router import ModelFactory
|
||||||
@@ -132,14 +170,37 @@ class CapabilitySettings(BaseModel):
|
|||||||
recording_enabled: bool = False
|
recording_enabled: bool = False
|
||||||
outputter_enabled: bool = True
|
outputter_enabled: bool = True
|
||||||
step_summarizer_enabled: bool = True
|
step_summarizer_enabled: bool = True
|
||||||
|
screenshot_limit_enabled: bool = True
|
||||||
|
screenshot_max_long_edge: int = 2048
|
||||||
|
task_timeout_seconds: int = Field(
|
||||||
|
default_factory=lambda: settings.ARTEMIS_TASK_TIMEOUT_SECONDS, ge=30, le=86400
|
||||||
|
)
|
||||||
|
observation_timeout_seconds: float = Field(
|
||||||
|
default_factory=lambda: settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS, gt=0, le=300
|
||||||
|
)
|
||||||
|
observation_max_attempts: int = Field(
|
||||||
|
default_factory=lambda: settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS, ge=1, le=5
|
||||||
|
)
|
||||||
|
device_command_timeout_seconds: float = Field(
|
||||||
|
default_factory=lambda: settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS, gt=0, le=60
|
||||||
|
)
|
||||||
|
action_failure_attempts: int = Field(
|
||||||
|
default_factory=lambda: settings.ARTEMIS_ACTION_FAILURE_ATTEMPTS, ge=1, le=10
|
||||||
|
)
|
||||||
|
worker_cleanup_timeout_seconds: float = Field(
|
||||||
|
default_factory=lambda: settings.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS, gt=0, le=300
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_capability_settings() -> CapabilitySettings:
|
def get_capability_settings() -> CapabilitySettings:
|
||||||
|
screenshot = load_agent_config().screenshot
|
||||||
return CapabilitySettings(
|
return CapabilitySettings(
|
||||||
video_enabled=settings.ARTEMIS_VIDEO_ENABLED,
|
video_enabled=settings.ARTEMIS_VIDEO_ENABLED,
|
||||||
recording_enabled=settings.ARTEMIS_RECORDING_ENABLED,
|
recording_enabled=settings.ARTEMIS_RECORDING_ENABLED,
|
||||||
outputter_enabled=settings.ARTEMIS_OUTPUTTER_ENABLED,
|
outputter_enabled=settings.ARTEMIS_OUTPUTTER_ENABLED,
|
||||||
step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED,
|
step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED,
|
||||||
|
screenshot_limit_enabled=screenshot.enabled,
|
||||||
|
screenshot_max_long_edge=screenshot.max_long_edge,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -149,6 +210,13 @@ def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
|
|||||||
"ARTEMIS_RECORDING_ENABLED": update.recording_enabled,
|
"ARTEMIS_RECORDING_ENABLED": update.recording_enabled,
|
||||||
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
|
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
|
||||||
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
|
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
|
||||||
|
"ARTEMIS_SCREENSHOT_LIMIT_ENABLED": update.screenshot_limit_enabled,
|
||||||
|
"ARTEMIS_TASK_TIMEOUT_SECONDS": update.task_timeout_seconds,
|
||||||
|
"ARTEMIS_OBSERVATION_TIMEOUT_SECONDS": update.observation_timeout_seconds,
|
||||||
|
"ARTEMIS_OBSERVATION_MAX_ATTEMPTS": update.observation_max_attempts,
|
||||||
|
"ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS": update.device_command_timeout_seconds,
|
||||||
|
"ARTEMIS_ACTION_FAILURE_ATTEMPTS": update.action_failure_attempts,
|
||||||
|
"ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS": update.worker_cleanup_timeout_seconds,
|
||||||
}
|
}
|
||||||
persist_environment({key: str(value).lower() for key, value in values.items()})
|
persist_environment({key: str(value).lower() for key, value in values.items()})
|
||||||
for key, value in values.items():
|
for key, value in values.items():
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Evidence-backed Word/Excel artifacts, independent of the language model runtime."""
|
"""Evidence-backed Word/Excel artifacts, independent of the language model runtime."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import cast
|
from typing import Callable, cast
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
@@ -22,6 +22,7 @@ from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
|
|||||||
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||||
from apps.admin_console.database.repositories.step_repository import StepRepository
|
from apps.admin_console.database.repositories.step_repository import StepRepository
|
||||||
from apps.admin_console.services.automation_store import store, identifier
|
from apps.admin_console.services.automation_store import store, identifier
|
||||||
|
from apps.admin_console.services.task_import import case_title
|
||||||
|
|
||||||
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
|
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
|
||||||
EXPORT_LOCK = threading.RLock()
|
EXPORT_LOCK = threading.RLock()
|
||||||
@@ -40,6 +41,14 @@ STATUS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def archive_case_name(report: dict) -> str:
|
||||||
|
"""Keep case identifiers readable without allowing ZIP paths or Windows-invalid names."""
|
||||||
|
fallback = str(report.get("id") or "task")[:8]
|
||||||
|
candidate = str(report.get("case_id") or fallback).strip()
|
||||||
|
candidate = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", candidate).strip(" .")
|
||||||
|
return candidate[:80] or fallback
|
||||||
|
|
||||||
|
|
||||||
def read_json(path: Path, fallback=None):
|
def read_json(path: Path, fallback=None):
|
||||||
try:
|
try:
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
@@ -79,23 +88,62 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
|
|||||||
)
|
)
|
||||||
return safe[:500]
|
return safe[:500]
|
||||||
|
|
||||||
|
from apps.admin_console.services.automation_store import RECOVERY_ERRORS
|
||||||
|
|
||||||
|
if status == "completed" and recorded in RECOVERY_ERRORS:
|
||||||
|
return ""
|
||||||
if recorded:
|
if recorded:
|
||||||
return redact(recorded)
|
return redact(recorded)
|
||||||
if status not in {"failed", "interrupted"}:
|
if status not in {"failed", "interrupted"}:
|
||||||
return ""
|
return ""
|
||||||
|
state = read_json(directory / "status.json", {})
|
||||||
|
if isinstance(state, dict) and state.get("error"):
|
||||||
|
return redact(state["error"])
|
||||||
|
result = read_json(directory / "result.json", {})
|
||||||
|
if isinstance(result, dict) and result.get("status") == "failed":
|
||||||
|
reason = result.get("error") or result.get("explanation")
|
||||||
|
if reason:
|
||||||
|
return redact(reason)
|
||||||
|
log_lines: list[str] = []
|
||||||
for name in ("stdout.log", "stderr.log"):
|
for name in ("stdout.log", "stderr.log"):
|
||||||
path = directory / name
|
path = directory / name
|
||||||
try:
|
try:
|
||||||
with path.open("rb") as stream:
|
with path.open("rb") as stream:
|
||||||
stream.seek(0, 2)
|
stream.seek(0, 2)
|
||||||
stream.seek(max(0, stream.tell() - 131072))
|
stream.seek(max(0, stream.tell() - 131072))
|
||||||
lines = stream.read().decode("utf-8", errors="replace").splitlines()
|
log_lines.extend(stream.read().decode("utf-8", errors="replace").splitlines())
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
for line in reversed(lines):
|
for line in reversed(log_lines):
|
||||||
match = re.search(r"Error running automation:\s*(.+)", line)
|
match = re.search(r"Error running automation:\s*(.+)", line)
|
||||||
if match:
|
if match:
|
||||||
return redact(match.group(1))
|
return redact(match.group(1))
|
||||||
|
for line in reversed(log_lines):
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith(("⚠", "ℹ", "WARNING", "INFO", "DEBUG")):
|
||||||
|
continue
|
||||||
|
if any(
|
||||||
|
name in line
|
||||||
|
for name in (
|
||||||
|
"VisualStepSummarizer:",
|
||||||
|
"StepMemoryService:",
|
||||||
|
"Could not clean unowned screen wake locks",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
exception_line = re.fullmatch(
|
||||||
|
r"(?:[A-Za-z_]\w*\.)*[A-Za-z_]\w*(?:Error|Exception|Timeout):\s*.*",
|
||||||
|
stripped,
|
||||||
|
)
|
||||||
|
if exception_line and not re.search(r"(?i)(?:timeout|timed out|超时)", stripped):
|
||||||
|
return redact(stripped)
|
||||||
|
if re.search(
|
||||||
|
r"(?i)(?:\b(?:TimeoutError|APITimeoutError|ReadTimeout|ConnectTimeout)\s*:|"
|
||||||
|
r"(?:模型请求|任务执行|model request|task execution).{0,100}"
|
||||||
|
r"(?:超时|timed out|deadline exceeded))",
|
||||||
|
stripped,
|
||||||
|
):
|
||||||
|
return redact("模型请求或任务执行超时:" + stripped)
|
||||||
return "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
|
return "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
|
||||||
|
|
||||||
|
|
||||||
@@ -142,7 +190,7 @@ def session_report(session_id: str, db_path=None) -> dict:
|
|||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"id": session_id,
|
"id": session_id,
|
||||||
"name": case.get("name") or session.get("initial_goal", session_id)[:120],
|
"name": case_title(case) or session.get("initial_goal", session_id)[:120],
|
||||||
"goal": case.get("goal") or session.get("initial_goal", ""),
|
"goal": case.get("goal") or session.get("initial_goal", ""),
|
||||||
"expected": case.get("expected", ""),
|
"expected": case.get("expected", ""),
|
||||||
"status": status,
|
"status": status,
|
||||||
@@ -593,15 +641,17 @@ def session_videos(report: dict) -> list[tuple[Path, str]]:
|
|||||||
if not candidate:
|
if not candidate:
|
||||||
return []
|
return []
|
||||||
source = Path(str(candidate)).resolve()
|
source = Path(str(candidate)).resolve()
|
||||||
session_root = (TRACES_PATH / identifier(report["id"])).resolve()
|
trace_root = TRACES_PATH.resolve()
|
||||||
if not source.is_relative_to(session_root):
|
# Workers save recordings in their finalized web_<time>_<id>_PASS/FAIL
|
||||||
|
# folder. The recording row already ties that path to this session.
|
||||||
|
if not source.is_relative_to(trace_root):
|
||||||
return []
|
return []
|
||||||
# A resumed recording lives under resume_NNNN; the initial recording is
|
# A resumed recording lives under resume_NNNN; the initial recording is
|
||||||
# in its parent, with any later segments in sibling resume folders.
|
# in its parent, with any later segments in sibling resume folders.
|
||||||
directory = source.parent
|
directory = source.parent
|
||||||
if directory.name.startswith("resume_"):
|
if directory.name.startswith("resume_"):
|
||||||
directory = directory.parent
|
directory = directory.parent
|
||||||
if not directory.is_dir() or not directory.is_relative_to(session_root):
|
if not directory.is_dir() or not directory.is_relative_to(trace_root):
|
||||||
return []
|
return []
|
||||||
paths = [
|
paths = [
|
||||||
p
|
p
|
||||||
@@ -638,20 +688,23 @@ def export_session_media(session_id: str, kind: str) -> Path:
|
|||||||
"videos": [name for _, name in videos],
|
"videos": [name for _, name in videos],
|
||||||
"note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。",
|
"note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。",
|
||||||
}
|
}
|
||||||
|
prefix = archive_case_name(report) + "/" if kind == "all" else ""
|
||||||
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive:
|
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
if kind == "all":
|
if kind == "all":
|
||||||
for path in export_session(session_id, ["docx", "xlsx"]):
|
for path in export_session(session_id, ["docx", "xlsx"]):
|
||||||
archive.write(path, path.name)
|
archive.write(path, prefix + path.name)
|
||||||
for source, name in images:
|
for source, name in images:
|
||||||
archive.write(source, f"步骤截图/{name}")
|
archive.write(source, f"{prefix}步骤截图/{name}")
|
||||||
for source, name in videos:
|
for source, name in videos:
|
||||||
archive.write(source, f"任务录屏/{name}")
|
archive.write(source, f"{prefix}任务录屏/{name}")
|
||||||
archive.writestr("文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2))
|
archive.writestr(
|
||||||
|
prefix + "文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2)
|
||||||
|
)
|
||||||
temporary.replace(target)
|
temporary.replace(target)
|
||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
def export_batch(batch_id: str) -> Path:
|
def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None = None) -> Path:
|
||||||
with EXPORT_LOCK:
|
with EXPORT_LOCK:
|
||||||
batch = store.get("batch", identifier(batch_id))
|
batch = store.get("batch", identifier(batch_id))
|
||||||
if not batch:
|
if not batch:
|
||||||
@@ -660,6 +713,8 @@ def export_batch(batch_id: str) -> Path:
|
|||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
formats = batch["formats"]
|
formats = batch["formats"]
|
||||||
records = [session_report(job["session_id"]) for job in store.jobs(batch_id)]
|
records = [session_report(job["session_id"]) for job in store.jobs(batch_id)]
|
||||||
|
if progress:
|
||||||
|
progress(0, len(records), "正在生成批次汇总")
|
||||||
rows = [("任务编号", "任务名称", "执行状态", "验证结论", "异常原因")]
|
rows = [("任务编号", "任务名称", "执行状态", "验证结论", "异常原因")]
|
||||||
rows += [
|
rows += [
|
||||||
(r["id"], r["name"], STATUS.get(r["status"], r["status"]), r["verdict"], r["error"])
|
(r["id"], r["name"], STATUS.get(r["status"], r["status"]), r["verdict"], r["error"])
|
||||||
@@ -690,7 +745,18 @@ def export_batch(batch_id: str) -> Path:
|
|||||||
for path in outputs:
|
for path in outputs:
|
||||||
archive.write(path, path.name)
|
archive.write(path, path.name)
|
||||||
for number, report in enumerate(records, 1):
|
for number, report in enumerate(records, 1):
|
||||||
|
if progress:
|
||||||
|
progress(number - 1, len(records), f"正在打包任务 {number}/{len(records)}")
|
||||||
|
prefix = f"任务报告/{number:04d}_{archive_case_name(report)}"
|
||||||
for path in export_session(report["id"], formats):
|
for path in export_session(report["id"], formats):
|
||||||
archive.write(path, f"任务报告/{number:04d}_{report['id']}/{path.name}")
|
archive.write(path, f"{prefix}/{path.name}")
|
||||||
|
for source, name in session_screenshots(report):
|
||||||
|
archive.write(source, f"{prefix}/步骤截图/{name}")
|
||||||
|
for source, name in session_videos(report):
|
||||||
|
archive.write(source, f"{prefix}/任务录屏/{name}")
|
||||||
|
if progress:
|
||||||
|
progress(number, len(records), f"已打包任务 {number}/{len(records)}")
|
||||||
|
if progress:
|
||||||
|
progress(len(records), len(records), "正在完成压缩包")
|
||||||
temporary.replace(target)
|
temporary.replace(target)
|
||||||
return target
|
return target
|
||||||
@@ -42,6 +42,11 @@ class TaskOptions(BaseModel):
|
|||||||
max_retries: int | None = Field(default=None, ge=0, le=10)
|
max_retries: int | None = Field(default=None, ge=0, le=10)
|
||||||
retry_interval_seconds: float | None = Field(default=None, ge=0, le=300)
|
retry_interval_seconds: float | None = Field(default=None, ge=0, le=300)
|
||||||
timeout_seconds: int | None = Field(default=None, ge=30, le=86400)
|
timeout_seconds: int | None = Field(default=None, ge=30, le=86400)
|
||||||
|
observation_timeout_seconds: float | None = Field(default=None, gt=0, le=300)
|
||||||
|
observation_max_attempts: int | None = Field(default=None, ge=1, le=5)
|
||||||
|
device_command_timeout_seconds: float | None = Field(default=None, gt=0, le=60)
|
||||||
|
action_failure_attempts: int | None = Field(default=None, ge=1, le=10)
|
||||||
|
worker_cleanup_timeout_seconds: float | None = Field(default=None, gt=0, le=300)
|
||||||
|
|
||||||
|
|
||||||
class TaskCase(BaseModel):
|
class TaskCase(BaseModel):
|
||||||
@@ -177,3 +182,10 @@ def parse_workbook(data: bytes) -> dict:
|
|||||||
def case_goal(case: dict) -> str:
|
def case_goal(case: dict) -> str:
|
||||||
"""Execution gets the prompt verbatim; metadata stays outside model input."""
|
"""Execution gets the prompt verbatim; metadata stays outside model input."""
|
||||||
return case["goal"]
|
return case["goal"]
|
||||||
|
|
||||||
|
|
||||||
|
def case_title(case: dict) -> str:
|
||||||
|
"""Human-facing title; never prepend metadata to the model prompt."""
|
||||||
|
case_id = str(case.get("case_id") or "").strip()
|
||||||
|
name = str(case.get("name") or "").strip()
|
||||||
|
return " ".join(part for part in (case_id, name) if part)
|
||||||
@@ -581,6 +581,10 @@ class TaskQueueService:
|
|||||||
"retry_interval_seconds": "OPENAI_RETRY_INTERVAL_SECONDS",
|
"retry_interval_seconds": "OPENAI_RETRY_INTERVAL_SECONDS",
|
||||||
"record_video": "ARTEMIS_RECORDING_ENABLED",
|
"record_video": "ARTEMIS_RECORDING_ENABLED",
|
||||||
"analyze_video": "ARTEMIS_VIDEO_ENABLED",
|
"analyze_video": "ARTEMIS_VIDEO_ENABLED",
|
||||||
|
"observation_timeout_seconds": "ARTEMIS_OBSERVATION_TIMEOUT_SECONDS",
|
||||||
|
"observation_max_attempts": "ARTEMIS_OBSERVATION_MAX_ATTEMPTS",
|
||||||
|
"device_command_timeout_seconds": "ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS",
|
||||||
|
"action_failure_attempts": "ARTEMIS_ACTION_FAILURE_ATTEMPTS",
|
||||||
}.items():
|
}.items():
|
||||||
if overrides.get(option) is not None:
|
if overrides.get(option) is not None:
|
||||||
env[variable] = (
|
env[variable] = (
|
||||||
@@ -630,6 +634,72 @@ class TaskQueueService:
|
|||||||
env["RESULTS_OUTPUT_PATH"] = str(TRACES_PATH / str(sess_id) / "result.json")
|
env["RESULTS_OUTPUT_PATH"] = str(TRACES_PATH / str(sess_id) / "result.json")
|
||||||
return cmd, env
|
return cmd, env
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _await_worker_with_budget(cls, proc, task_item: dict, run_key: str) -> int:
|
||||||
|
from artemis.runtime.task_limits import ExecutionBudget, task_timeout
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
from artemis.runtime.task_result import save_failure_result, read_worker_outcome
|
||||||
|
from artemis.runtime.task_control import read_state
|
||||||
|
from apps.admin_console.services.automation_store import store
|
||||||
|
|
||||||
|
sid = str(task_item.get("session_id") or run_key)
|
||||||
|
job = store.job(sid)
|
||||||
|
batch = store.get("batch", job["batch_id"]) if job and job.get("batch_id") else None
|
||||||
|
limit = task_timeout(task_item, job, batch)
|
||||||
|
budget = ExecutionBudget(limit)
|
||||||
|
cleanup_limit = (task_item.get("runtime_overrides") or {}).get(
|
||||||
|
"worker_cleanup_timeout_seconds"
|
||||||
|
) or settings.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS
|
||||||
|
finalized_at = None
|
||||||
|
waiter = asyncio.create_task(cls._wait_for_worker_process(proc))
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
done, _ = await asyncio.wait({waiter}, timeout=0.25)
|
||||||
|
if waiter in done:
|
||||||
|
return waiter.result()
|
||||||
|
outcome = read_worker_outcome(sid)
|
||||||
|
if outcome:
|
||||||
|
finalized_at = finalized_at if finalized_at is not None else time.monotonic()
|
||||||
|
if time.monotonic() - finalized_at >= cleanup_limit:
|
||||||
|
logger.warning(
|
||||||
|
"%s: task finalized but worker cleanup exceeded %s seconds; reclaiming process",
|
||||||
|
sid,
|
||||||
|
cleanup_limit,
|
||||||
|
)
|
||||||
|
await cls._terminate_worker_process(proc)
|
||||||
|
return await waiter
|
||||||
|
continue # Final result is authoritative; only bound resource cleanup now.
|
||||||
|
cls._sync_control_states()
|
||||||
|
run = state.active_runs.get(run_key, {})
|
||||||
|
remaining = budget.update(
|
||||||
|
excluded=bool(run.get("parked")) or read_state(sid).get("status") == "queued"
|
||||||
|
)
|
||||||
|
if remaining > 0:
|
||||||
|
continue
|
||||||
|
error = f"任务执行超时({limit} 秒,不含暂停/排队时间)"
|
||||||
|
task_item.update(timed_out=True, error=error)
|
||||||
|
logger.error("%s: %s", sid, error)
|
||||||
|
try:
|
||||||
|
latest = store.job(sid)
|
||||||
|
if latest:
|
||||||
|
latest.update(timed_out=True, error=error)
|
||||||
|
store.save_job(latest)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Cannot persist timeout for %s; terminating worker anyway", sid
|
||||||
|
)
|
||||||
|
await cls._terminate_worker_process(proc)
|
||||||
|
returncode = await waiter
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(save_failure_result, sid, error, code="task_timeout")
|
||||||
|
except OSError:
|
||||||
|
logger.exception("Cannot save timeout report for %s", sid)
|
||||||
|
return returncode
|
||||||
|
finally:
|
||||||
|
if not waiter.done():
|
||||||
|
waiter.cancel()
|
||||||
|
await asyncio.gather(waiter, return_exceptions=True)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _register_worker_run(
|
def _register_worker_run(
|
||||||
cls,
|
cls,
|
||||||
@@ -721,6 +791,10 @@ class TaskQueueService:
|
|||||||
returncode,
|
returncode,
|
||||||
manual_stop,
|
manual_stop,
|
||||||
)
|
)
|
||||||
|
from apps.admin_console.services.automation_store import store
|
||||||
|
|
||||||
|
if (store.job(str(sess_id)) or {}).get("timed_out"):
|
||||||
|
new_status, should_persist = "failed", True
|
||||||
if (
|
if (
|
||||||
state.is_shutting_down
|
state.is_shutting_down
|
||||||
and not manual_stop
|
and not manual_stop
|
||||||
@@ -943,7 +1017,7 @@ class TaskQueueService:
|
|||||||
await cls._terminate_if_cancelled_during_launch(run_key, sess_id, proc)
|
await cls._terminate_if_cancelled_during_launch(run_key, sess_id, proc)
|
||||||
|
|
||||||
# 3. Await subprocess completion
|
# 3. Await subprocess completion
|
||||||
returncode = await cls._wait_for_worker_process(proc)
|
returncode = await cls._await_worker_with_budget(proc, task_item, run_key)
|
||||||
print(f"[QueueWorker] Task [{sess_id}] exited with returncode {returncode}")
|
print(f"[QueueWorker] Task [{sess_id}] exited with returncode {returncode}")
|
||||||
|
|
||||||
manual_stop = run_key in state.manually_stopped_run_ids or bool(
|
manual_stop = run_key in state.manually_stopped_run_ids or bool(
|
||||||
@@ -1133,6 +1207,7 @@ class TaskQueueService:
|
|||||||
verification_level: str | None = None,
|
verification_level: str | None = None,
|
||||||
explorer_mode: str | None = None,
|
explorer_mode: str | None = None,
|
||||||
runtime_overrides: dict[str, Any] | None = None,
|
runtime_overrides: dict[str, Any] | None = None,
|
||||||
|
display_name: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Enqueues one or more goals and wakes up the background worker.
|
"""Enqueues one or more goals and wakes up the background worker.
|
||||||
|
|
||||||
@@ -1140,6 +1215,12 @@ class TaskQueueService:
|
|||||||
forwarded to the worker as ``--verification-level`` / ``--explorer-pro-mode``;
|
forwarded to the worker as ``--verification-level`` / ``--explorer-pro-mode``;
|
||||||
they are normalised here so the queue item and the CLI see one spelling.
|
they are normalised here so the queue item and the CLI see one spelling.
|
||||||
"""
|
"""
|
||||||
|
if runtime_overrides:
|
||||||
|
profile = runtime_overrides.get("profile") or profile
|
||||||
|
device_serial = runtime_overrides.get("device_serial") or device_serial
|
||||||
|
locked_app_package = runtime_overrides.get("locked_app_package") or locked_app_package
|
||||||
|
verification_level = runtime_overrides.get("verification_level") or verification_level
|
||||||
|
explorer_mode = runtime_overrides.get("explorer_mode") or explorer_mode
|
||||||
verification_level = (
|
verification_level = (
|
||||||
str(verification_level).strip().lower() or None if verification_level else None
|
str(verification_level).strip().lower() or None if verification_level else None
|
||||||
)
|
)
|
||||||
@@ -1189,6 +1270,8 @@ class TaskQueueService:
|
|||||||
)
|
)
|
||||||
if runtime_overrides:
|
if runtime_overrides:
|
||||||
task_item["runtime_overrides"] = runtime_overrides
|
task_item["runtime_overrides"] = runtime_overrides
|
||||||
|
if display_name:
|
||||||
|
task_item["display_name"] = display_name
|
||||||
state.queue_items.append(task_item)
|
state.queue_items.append(task_item)
|
||||||
enqueued_tasks.append(task_item)
|
enqueued_tasks.append(task_item)
|
||||||
cls._broadcast_startup_progress(
|
cls._broadcast_startup_progress(
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.1.1",
|
"version": "1.3.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.1.1",
|
"version": "1.3.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.1.5",
|
"@angular/cdk": "^22.1.5",
|
||||||
"@angular/common": "^22.1.4",
|
"@angular/common": "^22.1.4",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.1.1",
|
"version": "1.3.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve --proxy-config proxy.conf.json",
|
"start": "ng serve --proxy-config proxy.conf.json",
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ limitations under the License.
|
|||||||
class="dropdown-item-card"
|
class="dropdown-item-card"
|
||||||
[class.selected]="agentService.currentSessionId() === session.session_id"
|
[class.selected]="agentService.currentSessionId() === session.session_id"
|
||||||
[class.running]="getTaskStatus(session) === 'running'"
|
[class.running]="getTaskStatus(session) === 'running'"
|
||||||
|
[ngClass]="getTaskStatus(session)"
|
||||||
(click)="selectTask(session.session_id, $event)"
|
(click)="selectTask(session.session_id, $event)"
|
||||||
>
|
>
|
||||||
<div class="item-header">
|
<div class="item-header">
|
||||||
@@ -96,7 +97,7 @@ limitations under the License.
|
|||||||
<span class="item-time-divider">·</span>
|
<span class="item-time-divider">·</span>
|
||||||
}
|
}
|
||||||
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
||||||
@if (getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused') {
|
@if (['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-stop-card"
|
class="btn-stop-card"
|
||||||
@@ -107,7 +108,7 @@ limitations under the License.
|
|||||||
<span class="material-symbols-outlined">stop_circle</span>
|
<span class="material-symbols-outlined">stop_circle</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') {
|
@if (!['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-delete-card"
|
class="btn-delete-card"
|
||||||
@@ -120,12 +121,15 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-goal" title="{{ session.initial_goal }}">
|
<div class="item-goal" [title]="session.initial_goal || ''">
|
||||||
{{ session.initial_goal || '未命名任务' }}
|
{{ session.display_name || session.initial_goal || '未命名任务' }}
|
||||||
</div>
|
</div>
|
||||||
|
@if (session.error && getTaskStatus(session) === 'failed') {
|
||||||
|
<div class="item-error" [title]="session.error">{{session.error}}</div>
|
||||||
|
}
|
||||||
<div class="item-footer">
|
<div class="item-footer">
|
||||||
<app-task-id-copy [taskId]="session.session_id" />
|
<app-task-id-copy [taskId]="session.session_id" />
|
||||||
<span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
|
<span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }} · 已用 {{ formatTaskDuration(session.start_time, session.end_time) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3684,6 +3684,12 @@ $stream-row-height: 25px;
|
|||||||
&.running {
|
&.running {
|
||||||
border-left: 3.5px solid #2563eb;
|
border-left: 3.5px solid #2563eb;
|
||||||
}
|
}
|
||||||
|
&.paused, &.task_paused { border-left: 3.5px solid #d97706; background: #fffbeb; }
|
||||||
|
&.resuming, &.queued { border-left: 3.5px solid #7c3aed; }
|
||||||
|
&.failed { border-left: 3.5px solid #dc2626; background: #fff7f7; }
|
||||||
|
&.completed { border-left: 3.5px solid #16a34a; }
|
||||||
|
&.cancelled, &.interrupted { border-left: 3.5px solid #ea580c; }
|
||||||
|
.item-error { color: #b91c1c; font-size: 11px; line-height: 1.4; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
.item-header {
|
.item-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3729,12 +3735,24 @@ $stream-row-height: 25px;
|
|||||||
border-color: #fdba74;
|
border-color: #fdba74;
|
||||||
&::before { background-color: #f97316; }
|
&::before { background-color: #f97316; }
|
||||||
}
|
}
|
||||||
|
&.task_paused {
|
||||||
|
background: #ffedd5;
|
||||||
|
color: #9a3412;
|
||||||
|
border-color: #fb923c;
|
||||||
|
&::before { background-color: #ea580c; }
|
||||||
|
}
|
||||||
|
&.resuming, &.queued {
|
||||||
|
background: #f3e8ff;
|
||||||
|
color: #6b21a8;
|
||||||
|
border-color: #c4b5fd;
|
||||||
|
&::before { background-color: #7c3aed; }
|
||||||
|
}
|
||||||
&.completed {
|
&.completed {
|
||||||
background: #fafafa;
|
background: #dcfce7;
|
||||||
color: #1f1f1f;
|
color: #166534;
|
||||||
border-color: #e2e8f0;
|
border-color: #86efac;
|
||||||
&::before {
|
&::before {
|
||||||
background-color: #1f1f1f;
|
background-color: #16a34a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.pending {
|
&.pending {
|
||||||
@@ -3746,21 +3764,22 @@ $stream-row-height: 25px;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.failed {
|
&.failed {
|
||||||
background: #fafafa;
|
background: #fee2e2;
|
||||||
color: #ef4444;
|
color: #991b1b;
|
||||||
border-color: #fee2e2;
|
border-color: #fca5a5;
|
||||||
&::before {
|
&::before {
|
||||||
background-color: #ef4444;
|
background-color: #ef4444;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.cancelled {
|
&.cancelled {
|
||||||
background: #fafafa;
|
background: #ffedd5;
|
||||||
color: #d97706;
|
color: #9a3412;
|
||||||
border-color: #fef3c7;
|
border-color: #fdba74;
|
||||||
&::before {
|
&::before {
|
||||||
background-color: #f59e0b;
|
background-color: #ea580c;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
&.interrupted { background: #fef3c7; color: #92400e; border-color: #fbbf24; &::before { background-color: #d97706; } }
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -388,13 +388,13 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
public activeQueue = computed(() => {
|
public activeQueue = computed(() => {
|
||||||
const list = this.agentService.sessions().filter((s) => {
|
const list = this.agentService.sessions().filter((s) => {
|
||||||
const status = this.getTaskStatus(s);
|
const status = this.getTaskStatus(s);
|
||||||
return status === 'running' || status === 'paused' || status === 'pending';
|
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status);
|
||||||
});
|
});
|
||||||
return list.sort((a, b) => {
|
return list.sort((a, b) => {
|
||||||
const statusA = this.getTaskStatus(a);
|
const statusA = this.getTaskStatus(a);
|
||||||
const statusB = this.getTaskStatus(b);
|
const statusB = this.getTaskStatus(b);
|
||||||
const isRunA = statusA === 'running' || statusA === 'paused';
|
const isRunA = ['running', 'paused', 'task_paused', 'resuming'].includes(statusA);
|
||||||
const isRunB = statusB === 'running' || statusB === 'paused';
|
const isRunB = ['running', 'paused', 'task_paused', 'resuming'].includes(statusB);
|
||||||
if (isRunA && !isRunB) return -1;
|
if (isRunA && !isRunB) return -1;
|
||||||
if (!isRunA && isRunB) return 1;
|
if (!isRunA && isRunB) return 1;
|
||||||
return (a.start_time || 0) - (b.start_time || 0);
|
return (a.start_time || 0) - (b.start_time || 0);
|
||||||
@@ -404,7 +404,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
public historyTasks = computed(() => {
|
public historyTasks = computed(() => {
|
||||||
return this.agentService.sessions().filter((s) => {
|
return this.agentService.sessions().filter((s) => {
|
||||||
const status = this.getTaskStatus(s);
|
const status = this.getTaskStatus(s);
|
||||||
return status === 'completed' || status === 'failed' || status === 'cancelled';
|
return ['completed', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(status);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -491,7 +491,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
const terminal = ['completed', 'failed', 'cancelled', 'interrupted']
|
const terminal = ['completed', 'failed', 'cancelled', 'interrupted']
|
||||||
.includes(this.agentService.currentSession()?.status || '');
|
.includes(this.agentService.currentSession()?.status || '');
|
||||||
if (event.status === 'pending') {
|
if (event.status === 'pending') {
|
||||||
return terminal ? '任务结束前未进入模型' : '已接收,等待下一步';
|
return terminal ? '任务结束前未进入模型' : '已接收,正在打断当前模型步骤';
|
||||||
}
|
}
|
||||||
return terminal ? '模型已读取,未留下后续动作记录' : '模型已读取,等待动作记录';
|
return terminal ? '模型已读取,未留下后续动作记录' : '模型已读取,等待动作记录';
|
||||||
}
|
}
|
||||||
@@ -912,14 +912,14 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
return tuningLabel(kind, id);
|
return tuningLabel(kind, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
|
public getTaskStatus(session: Session): string {
|
||||||
if (session.status) {
|
if (session.status) {
|
||||||
const s = session.status.toLowerCase();
|
const s = session.status.toLowerCase();
|
||||||
if (s === 'completed' || s === 'success' || s === 'failed' || s === 'cancelled') {
|
if (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(s)) {
|
||||||
return (s === 'success' ? 'completed' : s) as any;
|
return s === 'success' ? 'completed' : s;
|
||||||
}
|
}
|
||||||
if (s === 'running' || s === 'paused' || s === 'pending') {
|
if (['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(s)) {
|
||||||
return s as any;
|
return s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
|
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
|
||||||
@@ -1360,6 +1360,14 @@ export class AgentStreamComponent implements AfterViewInit {
|
|||||||
return isAndroidAction(action);
|
return isAndroidAction(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public formatTaskDuration(start?: number, end?: number): string {
|
||||||
|
if (!start || !Number.isFinite(start)) return '—';
|
||||||
|
const seconds = Math.max(0, Math.floor((end || Date.now() / 1000) - start));
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
return hours ? `${hours}时${minutes}分` : minutes ? `${minutes}分${seconds % 60}秒` : `${seconds}秒`;
|
||||||
|
}
|
||||||
|
|
||||||
public hasRecordedDeviceAction(action: any): boolean {
|
public hasRecordedDeviceAction(action: any): boolean {
|
||||||
const actions = Array.isArray(action) ? action : action ? [action] : [];
|
const actions = Array.isArray(action) ? action : action ? [action] : [];
|
||||||
return actions.some(item => this.isAndroidAction(item) && !this.isReportStatusAction(item));
|
return actions.some(item => this.isAndroidAction(item) && !this.isReportStatusAction(item));
|
||||||
|
|||||||
+19
-6
@@ -28,7 +28,7 @@
|
|||||||
@if (liveTask() && agent.currentSessionId()) {
|
@if (liveTask() && agent.currentSessionId()) {
|
||||||
<div class="task-guidance">
|
<div class="task-guidance">
|
||||||
<h3>任务指导 · {{label(taskStatus())}}</h3>
|
<h3>任务指导 · {{label(taskStatus())}}</h3>
|
||||||
<p>{{ taskStatus() === 'paused' ? '模型请求已暂停;提交指导后请在执行日志中恢复模型调用。' : '运行中提交将在下一步执行前生效;暂停后会释放设备,提交指导即可重新排队恢复。' }}</p>
|
<p>{{ taskStatus() === 'paused' ? '模型请求已暂停;提交指导后请在执行日志中恢复模型调用。' : '运行中提交会立即打断模型思考并重新观察设备;正在执行的设备动作会先安全结束。' }}</p>
|
||||||
<label for="task-guidance-text">补充信息或调整方向</label>
|
<label for="task-guidance-text">补充信息或调整方向</label>
|
||||||
<textarea id="task-guidance-text" [(ngModel)]="guidance" maxlength="10000" rows="3" placeholder="描述已观察到的情况和下一步方向"></textarea>
|
<textarea id="task-guidance-text" [(ngModel)]="guidance" maxlength="10000" rows="3" placeholder="描述已观察到的情况和下一步方向"></textarea>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
@for(b of batches(); track b.id){
|
@for(b of batches(); track b.id){
|
||||||
<div class="batch-record">
|
<div class="batch-record">
|
||||||
<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}}</span></button>
|
<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}}</span></button>
|
||||||
<button class="delete-batch" type="button" [disabled]="busy() || b.status === 'running'" (click)="deleteBatch(b)" [attr.aria-label]="'删除批次:' + b.name" [title]="b.status === 'running' ? '执行中的批次不能删除' : '删除批次记录(保留已执行任务与报告)'"><span class="material-symbols-outlined">delete_outline</span></button>
|
<button class="delete-batch" type="button" [disabled]="busy() || b.status === 'running'" (click)="deleteBatch(b)" [attr.aria-label]="'删除批次:' + b.name" [title]="b.status === 'running' ? '执行中的批次不能删除' : '删除批次及其任务、报告和文件'"><span class="material-symbols-outlined">delete_outline</span></button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if(!batches().length){<p>暂无批次。</p>}
|
@if(!batches().length){<p>暂无批次。</p>}
|
||||||
@@ -70,10 +70,23 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
@if(b.status === 'running'){<button [disabled]="busy()" (click)="batchAction('pause')">暂停后续任务</button>}
|
@if(b.status === 'running'){<button [disabled]="busy()" (click)="batchAction('pause')">暂停后续任务</button>}
|
||||||
@if(b.status === 'draft' || b.status === 'paused'){<button [disabled]="busy()" (click)="batchAction('resume')">{{b.status === 'draft' ? '开始执行' : '继续未开始任务'}}</button>}
|
@if(b.status === 'draft' || b.status === 'paused'){<button [disabled]="busy()" (click)="batchAction('resume')">{{b.status === 'draft' ? '开始执行' : '继续未开始任务'}}</button>}
|
||||||
@if(b.status !== 'running'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
|
@if(b.status !== 'running' && b.status !== 'deleting'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
|
||||||
<a [href]="'/api/automation/batches/'+b.id+'/archive'">下载批次报告 ZIP</a>
|
<button type="button" [disabled]="archiveBusy()" (click)="downloadBatchArchive(b.id)">{{archiveBusy() && archiveBatchId() === b.id ? '正在准备报告包…' : '下载批次报告 ZIP'}}</button>
|
||||||
</div><p class="muted">服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。</p>
|
</div><p class="muted">服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。</p>
|
||||||
|
@if(archiveBatchId() === b.id && archiveProgress(); as progress){
|
||||||
|
<div class="archive-progress" role="status" aria-live="polite" [class.archive-failed]="progress.status === 'failed'">
|
||||||
|
<div class="archive-progress-heading">
|
||||||
|
@if(progress.status === 'preparing'){<span class="archive-spinner" aria-hidden="true"></span>}
|
||||||
|
<strong>{{progress.stage}}</strong>
|
||||||
|
</div>
|
||||||
|
@if(progress.status === 'preparing'){
|
||||||
|
<div class="archive-track" role="progressbar" [attr.aria-valuenow]="progress.total ? progress.completed : null" [attr.aria-valuemin]="progress.total ? 0 : null" [attr.aria-valuemax]="progress.total || null" [attr.aria-label]="'批次报告打包进度:' + progress.stage"><span [style.width.%]="progress.total ? progress.completed / progress.total * 100 : 8"></span></div>
|
||||||
|
<p>已处理 {{progress.completed}} / {{progress.total}} 个任务 · 已用 {{duration(progress.elapsed_seconds)}}@if(progress.eta_seconds !== null){ · 预计还需约 {{duration(progress.eta_seconds)}}}</p>
|
||||||
|
} @else if(progress.status === 'failed'){<p>{{progress.error}}。可点击下载按钮重试。</p>}
|
||||||
|
@else if(progress.status === 'ready'){<p>打包完成,下载已开始。</p>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
|
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
|
||||||
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true); tab.set('reports')">{{j.position}}. {{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
|
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true); tab.set('reports')" [title]="j.case.goal">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
|
||||||
</section>}
|
</section>}
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,14 @@ input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:
|
|||||||
.device{background:#f8fafc;padding:12px 16px;}.device label{margin:0;}
|
.device{background:#f8fafc;padding:12px 16px;}.device label{margin:0;}
|
||||||
.record{display:flex;width:100%;text-align:left;align-items:flex-start;flex-direction:column;gap:4px;margin:8px 0 0;padding:12px;overflow-wrap:anywhere;}.record span{color:#64748b;font-size:12px;font-weight:400;}.record.selected{border-color:#93c5fd;background:#eff6ff;}.record.selected strong{color:#1d4ed8;}
|
.record{display:flex;width:100%;text-align:left;align-items:flex-start;flex-direction:column;gap:4px;margin:8px 0 0;padding:12px;overflow-wrap:anywhere;}.record span{color:#64748b;font-size:12px;font-weight:400;}.record.selected{border-color:#93c5fd;background:#eff6ff;}.record.selected strong{color:#1d4ed8;}
|
||||||
.batch-record{display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record .record{flex:1;min-width:0;width:auto;margin:0;}.batch-record .delete-batch{flex:0 0 40px;min-width:40px;padding:0;color:#b91c1c;}.batch-record .delete-batch:hover:not(:disabled){background:#fff1f2;border-color:#fda4af;}.batch-record .delete-batch .material-symbols-outlined{font-size:19px;}
|
.batch-record{display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record .record{flex:1;min-width:0;width:auto;margin:0;}.batch-record .delete-batch{flex:0 0 40px;min-width:40px;padding:0;color:#b91c1c;}.batch-record .delete-batch:hover:not(:disabled){background:#fff1f2;border-color:#fda4af;}.batch-record .delete-batch .material-symbols-outlined{font-size:19px;}
|
||||||
|
.archive-progress{margin:12px 0;padding:11px 13px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;color:#1e40af;}
|
||||||
|
.archive-progress-heading{display:flex;align-items:center;gap:8px;}
|
||||||
|
.archive-progress p{margin:7px 0 0;font-size:12px;color:#475569;}
|
||||||
|
.archive-progress.archive-failed{border-color:#fecdd3;background:#fff1f2;color:#be123c;}
|
||||||
|
.archive-track{height:7px;margin-top:10px;border-radius:999px;background:#dbeafe;overflow:hidden;}
|
||||||
|
.archive-track span{display:block;height:100%;border-radius:inherit;background:#3b82f6;transition:width .3s ease;}
|
||||||
|
.archive-spinner{width:13px;height:13px;border:2px solid #bfdbfe;border-top-color:#2563eb;border-radius:50%;animation:archive-spin .8s linear infinite;}
|
||||||
|
@keyframes archive-spin{to{transform:rotate(360deg);}}
|
||||||
.code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:#f8fafc;}.table-wrap{max-height:260px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid #e2e8f0;text-align:left;overflow-wrap:anywhere;}th{background:#f8fafc;color:#475569;font-weight:500;}
|
.code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:#f8fafc;}.table-wrap{max-height:260px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid #e2e8f0;text-align:left;overflow-wrap:anywhere;}th{background:#f8fafc;color:#475569;font-weight:500;}
|
||||||
.preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:#334155;background:#f8fafc;border-radius:8px;padding:12px;border:1px solid #e2e8f0;}
|
.preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:#334155;background:#f8fafc;border-radius:8px;padding:12px;border:1px solid #e2e8f0;}
|
||||||
.job{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 0;border-bottom:1px solid #f1f5f9;}.job:last-child{border-bottom:0;}.job button{flex:1;justify-content:flex-start;text-align:left;min-width:150px;}.job span{font-size:12px;background:#f1f5f9;color:#475569;border-radius:5px;padding:3px 7px;}.job small{width:100%;}details{margin:14px 0;}summary{color:#475569;cursor:pointer;padding:8px 0;}
|
.job{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 0;border-bottom:1px solid #f1f5f9;}.job:last-child{border-bottom:0;}.job button{flex:1;justify-content:flex-start;text-align:left;min-width:150px;}.job span{font-size:12px;background:#f1f5f9;color:#475569;border-radius:5px;padding:3px 7px;}.job small{width:100%;}details{margin:14px 0;}summary{color:#475569;cursor:pointer;padding:8px 0;}
|
||||||
|
|||||||
+55
-1
@@ -14,12 +14,13 @@ describe('AutomationPanelComponent batch deletion', () => {
|
|||||||
agentStatus: signal('idle'),
|
agentStatus: signal('idle'),
|
||||||
sessions: signal<any[]>([])
|
sessions: signal<any[]>([])
|
||||||
};
|
};
|
||||||
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['get', 'delete']);
|
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['get', 'post', 'delete']);
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
(http.get.and as any).callFake((url: string) => of(url.endsWith('/preferences')
|
(http.get.and as any).callFake((url: string) => of(url.endsWith('/preferences')
|
||||||
? { formats: ['docx', 'xlsx'], batch_timeout_seconds: 1800 } : []));
|
? { formats: ['docx', 'xlsx'], batch_timeout_seconds: 1800 } : []));
|
||||||
http.delete.and.returnValue(of({ status: 'deleted' }));
|
http.delete.and.returnValue(of({ status: 'deleted' }));
|
||||||
|
http.post.and.returnValue(of({ status: 'ready', stage: '报告包已就绪', completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null, error: null }));
|
||||||
http.delete.calls.reset();
|
http.delete.calls.reset();
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [AutomationPanelComponent],
|
imports: [AutomationPanelComponent],
|
||||||
@@ -77,4 +78,57 @@ describe('AutomationPanelComponent batch deletion', () => {
|
|||||||
expect(root.querySelector('.report-body')?.textContent).toContain('第二行');
|
expect(root.querySelector('.report-body')?.textContent).toContain('第二行');
|
||||||
expect(root.querySelector('.report-body table td')?.textContent).toBe('设置');
|
expect(root.querySelector('.report-body table td')?.textContent).toBe('设置');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prepares a batch archive before starting the browser download', async () => {
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.tab.set('batches');
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
component.batch.set({ id: 'batch-3', name: '批量测试', status: 'completed', jobs: [] });
|
||||||
|
const click = spyOn(HTMLAnchorElement.prototype, 'click');
|
||||||
|
fixture.detectChanges();
|
||||||
|
(fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).click();
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(http.post).toHaveBeenCalledWith('/api/automation/batches/batch-3/archive/prepare', {});
|
||||||
|
expect(click).toHaveBeenCalled();
|
||||||
|
expect(fixture.nativeElement.querySelector('.archive-progress')?.textContent).toContain('下载已开始');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows archive errors and lets the user retry', async () => {
|
||||||
|
http.post.and.returnValue(of({ status: 'failed', stage: '打包失败', completed: 1, total: 2, elapsed_seconds: 3, eta_seconds: null, error: '截图文件无法读取' }));
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.tab.set('batches');
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
component.batch.set({ id: 'batch-4', name: '批量测试', status: 'completed', jobs: [] });
|
||||||
|
fixture.detectChanges();
|
||||||
|
(fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).click();
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.archive-progress')?.textContent).toContain('截图文件无法读取');
|
||||||
|
expect((fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).disabled).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows live archive progress and estimated remaining time while preparing', async () => {
|
||||||
|
http.post.and.returnValue(of({ status: 'preparing', stage: '正在打包任务 2/4', completed: 1, total: 4, elapsed_seconds: 12, eta_seconds: 36, error: null }));
|
||||||
|
(http.get.and as any).callFake((url: string) => of(url.endsWith('/status')
|
||||||
|
? { status: 'ready', stage: '报告包已就绪', completed: 4, total: 4, elapsed_seconds: 13, eta_seconds: null, error: null }
|
||||||
|
: url.endsWith('/preferences') ? { formats: ['docx', 'xlsx'], batch_timeout_seconds: 1800 } : []));
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
component.tab.set('batches');
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
component.batch.set({ id: 'batch-5', name: '批量测试', status: 'completed', jobs: [] });
|
||||||
|
spyOn(HTMLAnchorElement.prototype, 'click');
|
||||||
|
fixture.detectChanges();
|
||||||
|
(fixture.nativeElement.querySelector('.actions button:last-child') as HTMLButtonElement).click();
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const progress = fixture.nativeElement.querySelector('.archive-progress') as HTMLElement;
|
||||||
|
expect(progress.textContent).toContain('已处理 1 / 4 个任务');
|
||||||
|
expect(progress.textContent).toContain('预计还需约 36 秒');
|
||||||
|
expect(progress.querySelector('.archive-spinner')).not.toBeNull();
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -7,6 +7,16 @@ import { AgentService } from '../../services/agent.service';
|
|||||||
import { SystemService } from '../../services/system.service';
|
import { SystemService } from '../../services/system.service';
|
||||||
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
|
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
|
||||||
|
|
||||||
|
interface ArchiveProgress {
|
||||||
|
status: 'idle' | 'preparing' | 'ready' | 'failed';
|
||||||
|
stage: string;
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
elapsed_seconds: number;
|
||||||
|
eta_seconds: number | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule],
|
selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule],
|
||||||
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
|
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
|
||||||
@@ -16,6 +26,8 @@ export class AutomationPanelComponent {
|
|||||||
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
|
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
|
||||||
batches = signal<any[]>([]); preview = signal<any>(null);
|
batches = signal<any[]>([]); preview = signal<any>(null);
|
||||||
batch = signal<any>(null); report = signal<any>(null);
|
batch = signal<any>(null); report = signal<any>(null);
|
||||||
|
archiveProgress = signal<ArchiveProgress | null>(null);
|
||||||
|
archiveBatchId = signal(''); archiveBusy = signal(false);
|
||||||
guidance = '';
|
guidance = '';
|
||||||
taskStatus = computed(() => {
|
taskStatus = computed(() => {
|
||||||
const sid = this.agent.currentSessionId();
|
const sid = this.agent.currentSessionId();
|
||||||
@@ -38,8 +50,40 @@ export class AutomationPanelComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
|
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
|
||||||
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告'} as Record<string,string>)[s] || s; }
|
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',task_paused:'等待指导',resuming:'等待设备恢复',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告',deleting:'删除中,可重试清理'} as Record<string,string>)[s] || s; }
|
||||||
renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
|
renderReport(content: string) { return renderMarkdownToHtml(content || ''); }
|
||||||
|
duration(seconds: number) { const minutes = Math.floor(seconds / 60); return minutes ? `${minutes} 分 ${seconds % 60} 秒` : `${seconds} 秒`; }
|
||||||
|
downloadBatchArchive(id: string) { void this.prepareBatchArchive(id); }
|
||||||
|
private async prepareBatchArchive(id: string) {
|
||||||
|
if (this.archiveBusy()) return;
|
||||||
|
this.archiveBusy.set(true); this.archiveBatchId.set(id);
|
||||||
|
this.archiveProgress.set({ status: 'preparing', stage: '正在请求打包', completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null, error: null });
|
||||||
|
const base = `/api/automation/batches/${encodeURIComponent(id)}/archive`;
|
||||||
|
try {
|
||||||
|
let progress = await firstValueFrom(this.http.post<ArchiveProgress>(`${base}/prepare`, {}));
|
||||||
|
while (!this.destroyed && progress.status === 'preparing') {
|
||||||
|
this.archiveProgress.set(progress);
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 1000));
|
||||||
|
if (this.destroyed) return;
|
||||||
|
progress = await firstValueFrom(this.http.get<ArchiveProgress>(`${base}/status`));
|
||||||
|
}
|
||||||
|
if (this.destroyed) return;
|
||||||
|
this.archiveProgress.set(progress);
|
||||||
|
if (progress.status === 'failed') throw Error(progress.error || '批次报告打包失败');
|
||||||
|
if (progress.status !== 'ready') throw Error('打包任务已中断,请重试');
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = base;
|
||||||
|
link.download = `批次报告-${id.slice(0, 8)}.zip`;
|
||||||
|
document.body.appendChild(link); link.click(); link.remove();
|
||||||
|
this.notice.set('报告包已就绪,正在开始下载');
|
||||||
|
} catch (e: any) {
|
||||||
|
const detail = e.error?.detail;
|
||||||
|
this.archiveProgress.set({
|
||||||
|
...(this.archiveProgress() || { completed: 0, total: 0, elapsed_seconds: 0, eta_seconds: null }),
|
||||||
|
status: 'failed', stage: '打包失败', error: typeof detail === 'string' ? detail : e.message || '下载失败'
|
||||||
|
});
|
||||||
|
} finally { this.archiveBusy.set(false); }
|
||||||
|
}
|
||||||
async action(fn: () => Promise<void>) {
|
async action(fn: () => Promise<void>) {
|
||||||
if (this.busy()) return;
|
if (this.busy()) return;
|
||||||
this.busy.set(true); this.error.set(''); this.notice.set('');
|
this.busy.set(true); this.error.set(''); this.notice.set('');
|
||||||
@@ -73,12 +117,13 @@ export class AutomationPanelComponent {
|
|||||||
}); }
|
}); }
|
||||||
selectBatch(id: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.get(`/api/automation/batches/${id}`))); }); }
|
selectBatch(id: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.get(`/api/automation/batches/${id}`))); }); }
|
||||||
deleteBatch(item: { id: string; name: string }) {
|
deleteBatch(item: { id: string; name: string }) {
|
||||||
if (!window.confirm(`删除批次“${item.name}”?未执行任务会移除,已执行任务与报告仍保留。`)) return;
|
if (!window.confirm(`删除批次“${item.name}”及其全部任务记录、报告和文件?此操作无法撤销。`)) return;
|
||||||
void this.action(async () => {
|
void this.action(async () => {
|
||||||
await firstValueFrom(this.http.delete(`/api/automation/batches/${encodeURIComponent(item.id)}`));
|
await firstValueFrom(this.http.delete(`/api/automation/batches/${encodeURIComponent(item.id)}`));
|
||||||
if (this.batch()?.id === item.id) this.batch.set(null);
|
if (this.batch()?.id === item.id) this.batch.set(null);
|
||||||
await this.refresh();
|
await this.refresh();
|
||||||
this.notice.set('批次记录已删除;已执行任务与报告仍可在历史中查看');
|
this.agent.fetchSessions();
|
||||||
|
this.notice.set('批次及关联任务、报告和文件已删除');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
batchAction(action: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.post(`/api/automation/batches/${this.batch().id}/${action}`,{}))); await this.refresh(); }); }
|
batchAction(action: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.post(`/api/automation/batches/${this.batch().id}/${action}`,{}))); await this.refresh(); }); }
|
||||||
|
|||||||
+19
@@ -338,6 +338,8 @@ limitations under the License.
|
|||||||
playsinline
|
playsinline
|
||||||
(loadedmetadata)="onLoadedMetadata()"
|
(loadedmetadata)="onLoadedMetadata()"
|
||||||
(timeupdate)="onTimeUpdate()"
|
(timeupdate)="onTimeUpdate()"
|
||||||
|
(seeked)="onSeeked()"
|
||||||
|
(emptied)="onPause()"
|
||||||
(play)="onPlay()"
|
(play)="onPlay()"
|
||||||
(pause)="onPause()"
|
(pause)="onPause()"
|
||||||
(ended)="onEnded()"
|
(ended)="onEnded()"
|
||||||
@@ -354,6 +356,7 @@ limitations under the License.
|
|||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
class="scrubber-range"
|
class="scrubber-range"
|
||||||
|
aria-label="录像进度"
|
||||||
[min]="0"
|
[min]="0"
|
||||||
[max]="duration() || 100"
|
[max]="duration() || 100"
|
||||||
[step]="0.1"
|
[step]="0.1"
|
||||||
@@ -361,6 +364,22 @@ limitations under the License.
|
|||||||
(input)="onScrub($event)"
|
(input)="onScrub($event)"
|
||||||
/>
|
/>
|
||||||
<div class="scrubber-progress-fill" [style.width.%]="progressPercent()"></div>
|
<div class="scrubber-progress-fill" [style.width.%]="progressPercent()"></div>
|
||||||
|
@if (videoStepMarkers().length > 0) {
|
||||||
|
<div class="video-markers" aria-label="录像关键步骤">
|
||||||
|
@for (marker of videoStepMarkers(); track marker.time) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="video-marker"
|
||||||
|
[class.passed]="currentTime() >= marker.time"
|
||||||
|
[class.failed]="marker.failed"
|
||||||
|
[style.left.%]="marker.percent"
|
||||||
|
[title]="videoMarkerLabel(marker)"
|
||||||
|
[attr.aria-label]="'跳转到' + videoMarkerLabel(marker)"
|
||||||
|
(click)="seekToVideoMarker(marker)"
|
||||||
|
></button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<!-- Bottom Controls Row -->
|
<!-- Bottom Controls Row -->
|
||||||
<div class="controls-row">
|
<div class="controls-row">
|
||||||
|
|||||||
+32
@@ -724,6 +724,38 @@
|
|||||||
box-shadow: 0 0 8px rgba(2, 132, 199, 0.4);
|
box-shadow: 0 0 8px rgba(2, 132, 199, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-markers {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 6;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.video-marker {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
padding: 0;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #64748b;
|
||||||
|
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.25);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: transform 0.15s ease, background 0.15s ease;
|
||||||
|
|
||||||
|
&.passed { background: #0284c7; }
|
||||||
|
&.failed { background: #ef4444; }
|
||||||
|
&:hover, &:focus-visible {
|
||||||
|
z-index: 1;
|
||||||
|
transform: translate(-50%, -50%) scale(1.35);
|
||||||
|
outline: 2px solid #38bdf8;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.step-ticks-wrapper {
|
.step-ticks-wrapper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
+243
@@ -0,0 +1,243 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { AgentService } from '../../services/agent.service';
|
||||||
|
import { StepReplayFrame } from '../../core/models/stream.model';
|
||||||
|
import { FloatingVideoPlayerComponent } from './floating-video-player.component';
|
||||||
|
|
||||||
|
describe('FloatingVideoPlayerComponent video markers', () => {
|
||||||
|
let fixture: ComponentFixture<FloatingVideoPlayerComponent>;
|
||||||
|
let component: FloatingVideoPlayerComponent;
|
||||||
|
let video: HTMLVideoElement;
|
||||||
|
let paused: boolean;
|
||||||
|
let seeking: boolean;
|
||||||
|
let time: number;
|
||||||
|
let play: jasmine.Spy;
|
||||||
|
let agentService: ReturnType<typeof createAgentService>;
|
||||||
|
|
||||||
|
function createAgentService() {
|
||||||
|
const start = 1_800_000_000;
|
||||||
|
const frame: StepReplayFrame = {
|
||||||
|
index: 0, stepNumber: 1, title: '步骤 1: 点击设置', imageUrl: 'step.png',
|
||||||
|
actionText: '点击设置', timestamp: start + 5
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
isVideoWindowOpen: signal(true),
|
||||||
|
isVideoMinimized: signal(false),
|
||||||
|
recordingPlaybackStatus: signal('ready'),
|
||||||
|
playerMode: signal('video'),
|
||||||
|
hasCurrentSessionStepFrames: signal(true),
|
||||||
|
activeVideoTitle: signal('测试录像'),
|
||||||
|
isVideoLoading: signal(false),
|
||||||
|
activeVideoUrl: signal('/test.mp4'),
|
||||||
|
activeVideoSegments: signal([{ url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 }]),
|
||||||
|
currentSessionStepFrames: signal([frame]),
|
||||||
|
currentSession: signal({ session_id: 'test', start_time: start }),
|
||||||
|
currentSessionId: signal('test'),
|
||||||
|
videoSeekRequest: signal(null),
|
||||||
|
stepSeekRequest: signal(null),
|
||||||
|
consumeVideoAutoplay: jasmine.createSpy('consumeVideoAutoplay').and.returnValue(false)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
agentService = createAgentService();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [FloatingVideoPlayerComponent],
|
||||||
|
providers: [{ provide: AgentService, useValue: agentService }]
|
||||||
|
});
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(FloatingVideoPlayerComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
video = component.videoRef!.nativeElement;
|
||||||
|
paused = true;
|
||||||
|
seeking = false;
|
||||||
|
time = 0;
|
||||||
|
Object.defineProperties(video, {
|
||||||
|
paused: { configurable: true, get: () => paused },
|
||||||
|
seeking: { configurable: true, get: () => seeking },
|
||||||
|
ended: { configurable: true, get: () => false },
|
||||||
|
duration: { configurable: true, get: () => 20 },
|
||||||
|
currentTime: {
|
||||||
|
configurable: true, get: () => time,
|
||||||
|
set: (value: number) => { time = value; seeking = true; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
spyOn(video, 'pause').and.callFake(() => {
|
||||||
|
paused = true;
|
||||||
|
video.dispatchEvent(new Event('pause'));
|
||||||
|
});
|
||||||
|
play = spyOn(video, 'play').and.callFake(() => {
|
||||||
|
paused = false;
|
||||||
|
video.dispatchEvent(new Event('play'));
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function finishSeek(): void {
|
||||||
|
seeking = false;
|
||||||
|
video.dispatchEvent(new Event('seeked'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
function playButton(): HTMLButtonElement {
|
||||||
|
return fixture.nativeElement.querySelector('.play-btn');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows a recorded step on the video timeline and seeks when clicked', () => {
|
||||||
|
const marker = fixture.nativeElement.querySelector('.video-marker') as HTMLButtonElement;
|
||||||
|
expect(marker).not.toBeNull();
|
||||||
|
expect(marker.getAttribute('aria-label')).toContain('步骤 1');
|
||||||
|
expect(marker.style.left).toBe('25%');
|
||||||
|
|
||||||
|
const seek = spyOn(fixture.componentInstance, 'seek');
|
||||||
|
marker.click();
|
||||||
|
expect(seek).toHaveBeenCalledOnceWith(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resumes a playing video after a marker seek finishes and synchronizes the button', async () => {
|
||||||
|
component.togglePlay();
|
||||||
|
await Promise.resolve();
|
||||||
|
play.calls.reset();
|
||||||
|
fixture.nativeElement.querySelector('.video-marker').click();
|
||||||
|
expect(time).toBe(5);
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
finishSeek();
|
||||||
|
await Promise.resolve();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(paused).toBeFalse();
|
||||||
|
expect(play).toHaveBeenCalledTimes(1);
|
||||||
|
expect(playButton().title).toBe('暂停');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a paused video paused after a marker seek and resumes with one click', async () => {
|
||||||
|
fixture.nativeElement.querySelector('.video-marker').click();
|
||||||
|
finishSeek();
|
||||||
|
expect(paused).toBeTrue();
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
expect(playButton().title).toBe('播放');
|
||||||
|
playButton().click();
|
||||||
|
await Promise.resolve();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(paused).toBeFalse();
|
||||||
|
expect(playButton().title).toBe('暂停');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves playing intent across rapid consecutive marker seeks', () => {
|
||||||
|
component.togglePlay();
|
||||||
|
component.seek(5);
|
||||||
|
component.seek(8);
|
||||||
|
play.calls.reset();
|
||||||
|
finishSeek();
|
||||||
|
expect(time).toBe(8);
|
||||||
|
expect(play).toHaveBeenCalledTimes(1);
|
||||||
|
expect(paused).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors play and pause clicks while the destination is still seeking', () => {
|
||||||
|
component.togglePlay();
|
||||||
|
component.seek(5);
|
||||||
|
play.calls.reset();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(playButton().title).toBe('播放');
|
||||||
|
playButton().click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(playButton().title).toBe('暂停');
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
playButton().click();
|
||||||
|
finishSeek();
|
||||||
|
expect(paused).toBeTrue();
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
expect(playButton().title).toBe('播放');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for the destination segment to seek before resuming playback', () => {
|
||||||
|
agentService.activeVideoSegments.set([
|
||||||
|
{ url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 },
|
||||||
|
{ url: '/second.mp4', start: 20, duration: 20, width: 400, height: 800 }
|
||||||
|
]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
component.togglePlay();
|
||||||
|
component.seek(25);
|
||||||
|
play.calls.reset();
|
||||||
|
fixture.detectChanges();
|
||||||
|
video.dispatchEvent(new Event('loadedmetadata'));
|
||||||
|
expect(component.activeSegmentIndex()).toBe(1);
|
||||||
|
expect(time).toBe(5);
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
finishSeek();
|
||||||
|
expect(play).toHaveBeenCalledTimes(1);
|
||||||
|
expect(paused).toBeFalse();
|
||||||
|
expect(component.currentTime()).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a paused video paused when jumping to another segment', () => {
|
||||||
|
agentService.activeVideoSegments.set([
|
||||||
|
{ url: '/test.mp4', start: 0, duration: 20, width: 400, height: 800 },
|
||||||
|
{ url: '/second.mp4', start: 20, duration: 20, width: 400, height: 800 }
|
||||||
|
]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
component.seek(25);
|
||||||
|
fixture.detectChanges();
|
||||||
|
video.dispatchEvent(new Event('loadedmetadata'));
|
||||||
|
finishSeek();
|
||||||
|
expect(time).toBe(5);
|
||||||
|
expect(paused).toBeTrue();
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
expect(playButton().title).toBe('播放');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resumes after seeking a recording without a segment manifest', () => {
|
||||||
|
agentService.activeVideoSegments.set([]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
video.dispatchEvent(new Event('loadedmetadata'));
|
||||||
|
component.togglePlay();
|
||||||
|
component.seek(5);
|
||||||
|
play.calls.reset();
|
||||||
|
finishSeek();
|
||||||
|
expect(time).toBe(5);
|
||||||
|
expect(component.currentTime()).toBe(5);
|
||||||
|
expect(paused).toBeFalse();
|
||||||
|
expect(play).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets a rejected play request so a single subsequent click can resume', async () => {
|
||||||
|
play.and.callFake(() => {
|
||||||
|
paused = false;
|
||||||
|
video.dispatchEvent(new Event('play'));
|
||||||
|
return Promise.reject(new DOMException('Interrupted', 'AbortError'));
|
||||||
|
});
|
||||||
|
component.seek(5, true);
|
||||||
|
finishSeek();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(paused).toBeTrue();
|
||||||
|
expect(playButton().title).toBe('播放');
|
||||||
|
play.and.callFake(() => {
|
||||||
|
paused = false;
|
||||||
|
video.dispatchEvent(new Event('play'));
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
playButton().click();
|
||||||
|
await Promise.resolve();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(paused).toBeFalse();
|
||||||
|
expect(playButton().title).toBe('暂停');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores an older aborted play request after a newer seek resumes', async () => {
|
||||||
|
let rejectOldPlay!: (reason: unknown) => void;
|
||||||
|
play.and.returnValue(new Promise<void>((_, reject) => { rejectOldPlay = reject; }));
|
||||||
|
component.togglePlay();
|
||||||
|
paused = false;
|
||||||
|
component.seek(5);
|
||||||
|
play.and.callFake(() => { paused = false; return Promise.resolve(); });
|
||||||
|
finishSeek();
|
||||||
|
rejectOldPlay(new DOMException('Interrupted', 'AbortError'));
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(paused).toBeFalse();
|
||||||
|
expect(component.isPlaying()).toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
+113
-28
@@ -34,6 +34,7 @@ import { StepReplayFrame } from '../../core/models/stream.model';
|
|||||||
import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util';
|
import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util';
|
||||||
import { getActionIcon } from '../../utils/action-formatter.util';
|
import { getActionIcon } from '../../utils/action-formatter.util';
|
||||||
import { locateTimelineTime, sessionTimeToTimelineTime } from '../../utils/recording-timeline.util';
|
import { locateTimelineTime, sessionTimeToTimelineTime } from '../../utils/recording-timeline.util';
|
||||||
|
import { buildVideoStepMarkers, VideoStepMarker } from '../../utils/video-step-markers.util';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-floating-video-player',
|
selector: 'app-floating-video-player',
|
||||||
@@ -70,6 +71,8 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
private pendingLocalTime: number | null = null;
|
private pendingLocalTime: number | null = null;
|
||||||
private pendingAutoplay = false;
|
private pendingAutoplay = false;
|
||||||
private pendingAbsoluteSeek: number | null = null;
|
private pendingAbsoluteSeek: number | null = null;
|
||||||
|
private seekPlaybackIntent: boolean | null = null;
|
||||||
|
private playRequestId = 0;
|
||||||
|
|
||||||
// Step Replay state signals
|
// Step Replay state signals
|
||||||
public activeStepIndex = signal<number>(0);
|
public activeStepIndex = signal<number>(0);
|
||||||
@@ -83,6 +86,12 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
private stepTimer: any = null;
|
private stepTimer: any = null;
|
||||||
|
|
||||||
public stepFrames = computed(() => this.agentService.currentSessionStepFrames());
|
public stepFrames = computed(() => this.agentService.currentSessionStepFrames());
|
||||||
|
public videoStepMarkers = computed(() => buildVideoStepMarkers(
|
||||||
|
this.stepFrames(),
|
||||||
|
this.agentService.currentSession()?.start_time,
|
||||||
|
this.agentService.activeVideoSegments(),
|
||||||
|
this.duration()
|
||||||
|
));
|
||||||
public totalStepFrames = computed(() => this.stepFrames().length);
|
public totalStepFrames = computed(() => this.stepFrames().length);
|
||||||
public currentStepFrame = computed<StepReplayFrame | null>(() => {
|
public currentStepFrame = computed<StepReplayFrame | null>(() => {
|
||||||
const frames = this.stepFrames();
|
const frames = this.stepFrames();
|
||||||
@@ -131,6 +140,10 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
this.currentTime.set(0);
|
this.currentTime.set(0);
|
||||||
this.duration.set(segments.reduce((sum, segment) => sum + segment.duration, 0));
|
this.duration.set(segments.reduce((sum, segment) => sum + segment.duration, 0));
|
||||||
this.isPlaying.set(false);
|
this.isPlaying.set(false);
|
||||||
|
this.pendingLocalTime = null;
|
||||||
|
this.pendingAutoplay = false;
|
||||||
|
this.seekPlaybackIntent = null;
|
||||||
|
this.playRequestId++;
|
||||||
},
|
},
|
||||||
{ allowSignalWrites: true }
|
{ allowSignalWrites: true }
|
||||||
);
|
);
|
||||||
@@ -308,20 +321,19 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
// that segment's metadata event apply pendingLocalTime.
|
// that segment's metadata event apply pendingLocalTime.
|
||||||
if (this.pendingLocalTime !== null) return;
|
if (this.pendingLocalTime !== null) return;
|
||||||
}
|
}
|
||||||
if (this.pendingLocalTime !== null) {
|
const autoplay = this.agentService.consumeVideoAutoplay();
|
||||||
v.currentTime = Math.max(0, Math.min(v.duration || this.pendingLocalTime, this.pendingLocalTime));
|
if (autoplay) {
|
||||||
this.pendingLocalTime = null;
|
|
||||||
}
|
|
||||||
if (this.pendingAutoplay) {
|
|
||||||
this.pendingAutoplay = false;
|
|
||||||
v.play().catch(() => {});
|
|
||||||
}
|
|
||||||
if (this.agentService.consumeVideoAutoplay()) {
|
|
||||||
this.isMuted.set(true);
|
this.isMuted.set(true);
|
||||||
v.muted = true;
|
v.muted = true;
|
||||||
v.play().catch(() => {
|
}
|
||||||
this.isPlaying.set(false);
|
const shouldPlay = this.pendingAutoplay || autoplay;
|
||||||
});
|
this.pendingAutoplay = false;
|
||||||
|
if (this.pendingLocalTime !== null) {
|
||||||
|
const localTime = Math.max(0, Math.min(v.duration || this.pendingLocalTime, this.pendingLocalTime));
|
||||||
|
this.pendingLocalTime = null;
|
||||||
|
this.seekWithinVideo(localTime, shouldPlay);
|
||||||
|
} else if (shouldPlay) {
|
||||||
|
this.playVideo();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,14 +351,28 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onPlay(): void {
|
public onPlay(): void {
|
||||||
this.isPlaying.set(true);
|
this.isPlaying.set(!this.videoRef?.nativeElement.paused);
|
||||||
}
|
}
|
||||||
|
|
||||||
public onPause(): void {
|
public onPause(): void {
|
||||||
this.isPlaying.set(false);
|
this.isPlaying.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onSeeked(): void {
|
||||||
|
const v = this.videoRef?.nativeElement;
|
||||||
|
if (!v || v.seeking || this.pendingLocalTime !== null) return;
|
||||||
|
const shouldPlay = this.seekPlaybackIntent;
|
||||||
|
this.seekPlaybackIntent = null;
|
||||||
|
this.onTimeUpdate();
|
||||||
|
if (shouldPlay) {
|
||||||
|
this.playVideo();
|
||||||
|
} else {
|
||||||
|
this.isPlaying.set(!v.paused && !v.ended);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public onEnded(): void {
|
public onEnded(): void {
|
||||||
|
this.isPlaying.set(false);
|
||||||
const segments = this.agentService.activeVideoSegments();
|
const segments = this.agentService.activeVideoSegments();
|
||||||
if (segments.length && this.activeSegmentIndex() < segments.length - 1) {
|
if (segments.length && this.activeSegmentIndex() < segments.length - 1) {
|
||||||
this.pendingLocalTime = 0;
|
this.pendingLocalTime = 0;
|
||||||
@@ -362,6 +388,10 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onError(): void {
|
public onError(): void {
|
||||||
|
this.playRequestId++;
|
||||||
|
this.pendingLocalTime = null;
|
||||||
|
this.pendingAutoplay = false;
|
||||||
|
this.seekPlaybackIntent = null;
|
||||||
this.videoLoadError.set(true);
|
this.videoLoadError.set(true);
|
||||||
this.isPlaying.set(false);
|
this.isPlaying.set(false);
|
||||||
}
|
}
|
||||||
@@ -372,11 +402,52 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
public togglePlay(): void {
|
public togglePlay(): void {
|
||||||
const v = this.videoRef?.nativeElement;
|
const v = this.videoRef?.nativeElement;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
if (v.paused) {
|
if (this.pendingLocalTime !== null) {
|
||||||
v.play().catch(() => {});
|
this.pendingAutoplay = !this.isPlaying();
|
||||||
} else {
|
this.isPlaying.set(this.pendingAutoplay);
|
||||||
v.pause();
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.seekPlaybackIntent !== null) {
|
||||||
|
this.seekPlaybackIntent = !this.isPlaying();
|
||||||
|
this.isPlaying.set(this.seekPlaybackIntent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (v.paused) {
|
||||||
|
this.playVideo();
|
||||||
|
} else {
|
||||||
|
this.playRequestId++;
|
||||||
|
v.pause();
|
||||||
|
this.isPlaying.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private playVideo(): void {
|
||||||
|
const v = this.videoRef?.nativeElement;
|
||||||
|
if (!v) return;
|
||||||
|
const requestId = ++this.playRequestId;
|
||||||
|
v.play().then(() => {
|
||||||
|
if (requestId === this.playRequestId) this.isPlaying.set(!v.paused && !v.ended);
|
||||||
|
}).catch(() => {
|
||||||
|
// A rejected play request can leave paused=false without advancing frames.
|
||||||
|
// Reset the native state too, so the next click really starts playback.
|
||||||
|
if (requestId === this.playRequestId) {
|
||||||
|
v.pause();
|
||||||
|
this.isPlaying.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private seekWithinVideo(seconds: number, shouldPlay: boolean): void {
|
||||||
|
const v = this.videoRef?.nativeElement;
|
||||||
|
if (!v) return;
|
||||||
|
this.playRequestId++;
|
||||||
|
this.seekPlaybackIntent = shouldPlay;
|
||||||
|
// Cancel any unfinished play request before seeking; resume only once the
|
||||||
|
// browser has decoded the destination, including after a source switch.
|
||||||
|
v.pause();
|
||||||
|
this.isPlaying.set(false);
|
||||||
|
v.currentTime = seconds;
|
||||||
|
if (!v.seeking) this.onSeeked();
|
||||||
}
|
}
|
||||||
|
|
||||||
public restart(): void {
|
public restart(): void {
|
||||||
@@ -394,6 +465,19 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
this.seek(sessionTimeToTimelineTime(segments, sessionSeconds), autoplay);
|
this.seek(sessionTimeToTimelineTime(segments, sessionSeconds), autoplay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public seekToVideoMarker(marker: VideoStepMarker): void {
|
||||||
|
this.seek(marker.time);
|
||||||
|
}
|
||||||
|
|
||||||
|
public videoMarkerLabel(marker: VideoStepMarker): string {
|
||||||
|
const first = marker.steps[0];
|
||||||
|
const count = marker.steps.length;
|
||||||
|
const action = first.actionText || first.title;
|
||||||
|
return count > 1
|
||||||
|
? `步骤 ${first.stepNumber} 等 ${count} 步 · ${this.formatTime(marker.time)} · ${action}`
|
||||||
|
: `步骤 ${first.stepNumber} · ${this.formatTime(marker.time)} · ${action}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Seek on the back-to-back playlist axis used by the scrubber. */
|
/** Seek on the back-to-back playlist axis used by the scrubber. */
|
||||||
public seek(seconds: number, autoplay = false): void {
|
public seek(seconds: number, autoplay = false): void {
|
||||||
const v = this.videoRef?.nativeElement;
|
const v = this.videoRef?.nativeElement;
|
||||||
@@ -401,19 +485,19 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
const target = Math.max(0, Math.min(this.duration(), seconds));
|
const target = Math.max(0, Math.min(this.duration(), seconds));
|
||||||
const segments = this.agentService.activeVideoSegments();
|
const segments = this.agentService.activeVideoSegments();
|
||||||
const location = locateTimelineTime(segments, target);
|
const location = locateTimelineTime(segments, target);
|
||||||
|
const shouldPlay = autoplay || this.pendingAutoplay || (this.seekPlaybackIntent ?? !v.paused);
|
||||||
if (!location) {
|
if (!location) {
|
||||||
v.currentTime = target;
|
this.seekWithinVideo(target, shouldPlay);
|
||||||
if (autoplay) v.play().catch(() => {});
|
} else if (location.index === this.activeSegmentIndex() && this.pendingLocalTime === null) {
|
||||||
return;
|
this.seekWithinVideo(location.localTime, shouldPlay);
|
||||||
}
|
|
||||||
const { index, localTime } = location;
|
|
||||||
if (index === this.activeSegmentIndex()) {
|
|
||||||
v.currentTime = localTime;
|
|
||||||
if (autoplay) v.play().catch(() => {});
|
|
||||||
} else {
|
} else {
|
||||||
this.pendingLocalTime = localTime;
|
this.playRequestId++;
|
||||||
this.pendingAutoplay = autoplay || !v.paused;
|
this.seekPlaybackIntent = null;
|
||||||
this.activeSegmentIndex.set(index);
|
v.pause();
|
||||||
|
this.isPlaying.set(false);
|
||||||
|
this.pendingLocalTime = location.localTime;
|
||||||
|
this.pendingAutoplay = shouldPlay;
|
||||||
|
this.activeSegmentIndex.set(location.index);
|
||||||
}
|
}
|
||||||
this.currentTime.set(target);
|
this.currentTime.set(target);
|
||||||
}
|
}
|
||||||
@@ -621,6 +705,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ngOnDestroy(): void {
|
public ngOnDestroy(): void {
|
||||||
|
this.playRequestId++;
|
||||||
if (this.stepTimer) {
|
if (this.stepTimer) {
|
||||||
clearTimeout(this.stepTimer);
|
clearTimeout(this.stepTimer);
|
||||||
this.stepTimer = null;
|
this.stepTimer = null;
|
||||||
|
|||||||
+41
-4
@@ -8,6 +8,14 @@ interface Capabilities {
|
|||||||
recording_enabled: boolean;
|
recording_enabled: boolean;
|
||||||
outputter_enabled: boolean;
|
outputter_enabled: boolean;
|
||||||
step_summarizer_enabled: boolean;
|
step_summarizer_enabled: boolean;
|
||||||
|
screenshot_limit_enabled: boolean;
|
||||||
|
screenshot_max_long_edge: number;
|
||||||
|
task_timeout_seconds: number;
|
||||||
|
observation_timeout_seconds: number;
|
||||||
|
observation_max_attempts: number;
|
||||||
|
device_command_timeout_seconds: number;
|
||||||
|
action_failure_attempts: number;
|
||||||
|
worker_cleanup_timeout_seconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -24,7 +32,21 @@ interface Capabilities {
|
|||||||
<span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label>
|
<span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label>
|
||||||
<label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" />
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||||
<span>步骤摘要压缩<small>Flash 模式下在后台生成摘要,减少长任务的上下文占用。</small></span></label>
|
<span>步骤摘要压缩<small>Flash 模式下在后台生成摘要,减少长任务的上下文占用。</small></span></label>
|
||||||
<button type="button" (click)="save()" [disabled]="busy() || loading() || !loaded()">{{ busy() ? '正在保存…' : '保存功能设置' }}</button>
|
<label><input type="checkbox" role="switch" [(ngModel)]="config.screenshot_limit_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||||
|
<span>限制截图分辨率<small>开启时长边最多 {{ config.screenshot_max_long_edge }} 像素;关闭后使用设备原始分辨率。JSON 配置可覆盖开关和上限。</small></span></label>
|
||||||
|
<details class="execution-settings">
|
||||||
|
<summary>执行保护</summary>
|
||||||
|
<p>限制故障等待与反复失败;保存后对新任务生效,任务 JSON 可单独覆盖。</p>
|
||||||
|
<div class="number-fields">
|
||||||
|
<label>任务总超时(秒)<input type="number" min="30" max="86400" step="1" [(ngModel)]="config.task_timeout_seconds" [disabled]="busy() || !loaded()" /><small>不包含排队和用户暂停时间。</small></label>
|
||||||
|
<label>屏幕观测超时(秒)<input type="number" min="1" max="300" [(ngModel)]="config.observation_timeout_seconds" [disabled]="busy() || !loaded()" /></label>
|
||||||
|
<label>观测恢复最多尝试次数<input type="number" min="1" max="5" step="1" [(ngModel)]="config.observation_max_attempts" [disabled]="busy() || !loaded()" /></label>
|
||||||
|
<label>ADB 命令超时(秒)<input type="number" min="1" max="60" [(ngModel)]="config.device_command_timeout_seconds" [disabled]="busy() || !loaded()" /></label>
|
||||||
|
<label>连续动作失败次数<input type="number" min="1" max="10" step="1" [(ngModel)]="config.action_failure_attempts" [disabled]="busy() || !loaded()" /><small>先重新观察,再让兜底模型处理当前步骤。</small></label>
|
||||||
|
<label>任务收尾超时(秒)<input type="number" min="1" max="300" [(ngModel)]="config.worker_cleanup_timeout_seconds" [disabled]="busy() || !loaded()" /><small>已有最终结果但进程未退出时,回收进程以释放队列。</small></label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<button type="button" (click)="save()" [disabled]="busy() || loading() || !loaded() || !valid()">{{ busy() ? '正在保存…' : '保存功能设置' }}</button>
|
||||||
@if (message()) { <p role="status">{{ message() }}</p> }
|
@if (message()) { <p role="status">{{ message() }}</p> }
|
||||||
@if (error()) { <p role="alert" class="error">{{ error() }}</p> }
|
@if (error()) { <p role="alert" class="error">{{ error() }}</p> }
|
||||||
</section>
|
</section>
|
||||||
@@ -34,12 +56,18 @@ interface Capabilities {
|
|||||||
label { display: flex; gap: 12px; align-items: flex-start; padding: 14px 0; cursor: pointer; }
|
label { display: flex; gap: 12px; align-items: flex-start; padding: 14px 0; cursor: pointer; }
|
||||||
input { width: 20px; height: 20px; accent-color: #0284c7; flex-shrink: 0; }
|
input { width: 20px; height: 20px; accent-color: #0284c7; flex-shrink: 0; }
|
||||||
small { display: block; margin-top: 4px; } button { padding: 10px 18px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; font: inherit; cursor: pointer; }
|
small { display: block; margin-top: 4px; } button { padding: 10px 18px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; font: inherit; cursor: pointer; }
|
||||||
button:disabled { opacity: .5; cursor: default; } .error { color: #be123c; }`]
|
button:disabled { opacity: .5; cursor: default; } .error { color: #be123c; }
|
||||||
|
.execution-settings { margin: 12px 0 20px; padding: 12px 14px; border: 1px solid #cbd5e1; border-radius: 10px; background: #f8fafc; }
|
||||||
|
summary { cursor: pointer; font-weight: 600; color: #334155; }
|
||||||
|
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; }
|
||||||
|
.number-fields label { display: block; padding: 0; color: #334155; }
|
||||||
|
.number-fields input { box-sizing: border-box; display: block; width: 100%; height: auto; margin-top: 8px; padding: 9px; border: 1px solid #cbd5e1; border-radius: 8px; font: inherit; }
|
||||||
|
@media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }`]
|
||||||
})
|
})
|
||||||
export class CapabilitySettingsComponent implements OnInit {
|
export class CapabilitySettingsComponent implements OnInit {
|
||||||
changed = output<Capabilities>();
|
changed = output<Capabilities>();
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
config: Capabilities = { video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true };
|
config: Capabilities = { video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
|
||||||
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
|
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.http.get<Capabilities>('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
|
this.http.get<Capabilities>('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
|
||||||
@@ -47,11 +75,20 @@ export class CapabilitySettingsComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
save(): void {
|
save(): void {
|
||||||
if (this.busy() || this.loading() || !this.loaded()) return;
|
if (this.busy() || this.loading() || !this.loaded() || !this.valid()) return;
|
||||||
this.busy.set(true); this.message.set(''); this.error.set('');
|
this.busy.set(true); this.message.set(''); this.error.set('');
|
||||||
this.http.post<Capabilities>('/api/system/capability-settings', this.config).pipe(finalize(() => this.busy.set(false))).subscribe({
|
this.http.post<Capabilities>('/api/system/capability-settings', this.config).pipe(finalize(() => this.busy.set(false))).subscribe({
|
||||||
next: config => { this.config = config; this.changed.emit(config); this.message.set('功能设置已保存,新任务将使用此配置。'); },
|
next: config => { this.config = config; this.changed.emit(config); this.message.set('功能设置已保存,新任务将使用此配置。'); },
|
||||||
error: () => this.error.set('保存失败,请检查服务器连接和 .env 文件权限。')
|
error: () => this.error.set('保存失败,请检查服务器连接和 .env 文件权限。')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
valid(): boolean {
|
||||||
|
const c = this.config;
|
||||||
|
return Number.isInteger(c.task_timeout_seconds) && c.task_timeout_seconds >= 30 && c.task_timeout_seconds <= 86400
|
||||||
|
&& c.observation_timeout_seconds > 0 && c.observation_timeout_seconds <= 300
|
||||||
|
&& Number.isInteger(c.observation_max_attempts) && c.observation_max_attempts >= 1 && c.observation_max_attempts <= 5
|
||||||
|
&& c.device_command_timeout_seconds > 0 && c.device_command_timeout_seconds <= 60
|
||||||
|
&& Number.isInteger(c.action_failure_attempts) && c.action_failure_attempts >= 1 && c.action_failure_attempts <= 10
|
||||||
|
&& c.worker_cleanup_timeout_seconds > 0 && c.worker_cleanup_timeout_seconds <= 300;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+76
-4
@@ -7,7 +7,10 @@ import { CapabilitySettingsComponent } from './capability-settings.component';
|
|||||||
describe('持久化模型与功能设置', () => {
|
describe('持久化模型与功能设置', () => {
|
||||||
let http: HttpTestingController;
|
let http: HttpTestingController;
|
||||||
const config = { base_url: 'https://example.test/v1', model: 'mimo-v2.6-flash',
|
const config = { base_url: 'https://example.test/v1', model: 'mimo-v2.6-flash',
|
||||||
api_key_configured: true, timeout_seconds: 300, max_retries: 5, retry_interval_seconds: 10, reasoning_effort: 'high', env_path: '/app/.env' };
|
api_key_configured: true, timeout_seconds: 300, max_retries: 5, retry_interval_seconds: 10, reasoning_effort: 'high',
|
||||||
|
rate_limit_retry_seconds: 60, tool_fallback_model: 'gpt-6-luna', tool_fallback_reasoning_effort: 'high',
|
||||||
|
tool_fallback_max_retries: 3, tool_fallback_no_progress_attempts: 3,
|
||||||
|
model_presets: ['gpt-5.6-luna', 'gpt-6-luna', 'mimo-v2.6-flash', 'mimo-v2.6-pro'], env_path: '/app/.env' };
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||||
http = TestBed.inject(HttpTestingController);
|
http = TestBed.inject(HttpTestingController);
|
||||||
@@ -30,6 +33,9 @@ describe('持久化模型与功能设置', () => {
|
|||||||
expect(request.request.body.timeout_seconds).toBe(300);
|
expect(request.request.body.timeout_seconds).toBe(300);
|
||||||
expect(request.request.body.max_retries).toBe(5);
|
expect(request.request.body.max_retries).toBe(5);
|
||||||
expect(request.request.body.retry_interval_seconds).toBe(10);
|
expect(request.request.body.retry_interval_seconds).toBe(10);
|
||||||
|
expect(request.request.body.rate_limit_retry_seconds).toBe(60);
|
||||||
|
expect(request.request.body.tool_fallback_model).toBe('gpt-6-luna');
|
||||||
|
expect(request.request.body.tool_fallback_no_progress_attempts).toBe(3);
|
||||||
expect(request.request.body.api_key).toBeUndefined();
|
expect(request.request.body.api_key).toBeUndefined();
|
||||||
request.flush({ ...config, model: 'another-model' });
|
request.flush({ ...config, model: 'another-model' });
|
||||||
expect(component.busy()).toBeNull();
|
expect(component.busy()).toBeNull();
|
||||||
@@ -44,13 +50,23 @@ describe('持久化模型与功能设置', () => {
|
|||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
const root: HTMLElement = fixture.nativeElement;
|
const root: HTMLElement = fixture.nativeElement;
|
||||||
const presets = root.querySelectorAll<HTMLButtonElement>('.model-presets button');
|
const presets = root.querySelectorAll<HTMLButtonElement>('.model-presets button');
|
||||||
expect(presets.length).toBe(2);
|
expect(Array.from(presets, button => button.textContent?.trim())).toEqual([
|
||||||
|
'gpt-5.6-luna', 'gpt-6-luna', 'mimo-v2.6-flash', 'mimo-v2.6-pro'
|
||||||
|
]);
|
||||||
presets[0].click();
|
presets[0].click();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
const input = root.querySelector<HTMLInputElement>('#model-name')!;
|
const input = root.querySelector<HTMLInputElement>('#model-name')!;
|
||||||
expect(input.value).toBe('gpt-5.6-luna');
|
expect(input.value).toBe('gpt-5.6-luna');
|
||||||
expect(presets[0].getAttribute('aria-pressed')).toBe('true');
|
expect(presets[0].getAttribute('aria-pressed')).toBe('true');
|
||||||
|
presets[1].click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(input.value).toBe('gpt-6-luna');
|
||||||
|
presets[3].click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(input.value).toBe('mimo-v2.6-pro');
|
||||||
expect(input.getAttribute('list')).toBeNull();
|
expect(input.getAttribute('list')).toBeNull();
|
||||||
expect(input.autocomplete).toBe('off');
|
expect(input.autocomplete).toBe('off');
|
||||||
input.value = 'custom/model-v2';
|
input.value = 'custom/model-v2';
|
||||||
@@ -77,6 +93,29 @@ describe('持久化模型与功能设置', () => {
|
|||||||
expect(reopened.nativeElement.querySelector('#model-reasoning').value).toBe('max');
|
expect(reopened.nativeElement.querySelector('#model-reasoning').value).toBe('max');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('高级稳定性设置可编辑并保存', async () => {
|
||||||
|
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
http.expectOne('/api/system/model-settings').flush(config);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
const root: HTMLElement = fixture.nativeElement;
|
||||||
|
root.querySelector<HTMLDetailsElement>('.recovery-settings')!.open = true;
|
||||||
|
const fallback = root.querySelector<HTMLInputElement>('input[name="toolFallbackModel"]')!;
|
||||||
|
fallback.value = 'custom/fallback';
|
||||||
|
fallback.dispatchEvent(new Event('input'));
|
||||||
|
const wait = root.querySelector<HTMLInputElement>('input[name="rateLimitRetry"]')!;
|
||||||
|
wait.value = '90';
|
||||||
|
wait.dispatchEvent(new Event('input'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
root.querySelector<HTMLFormElement>('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
|
||||||
|
const request = http.expectOne('/api/system/model-settings');
|
||||||
|
expect(request.request.body.tool_fallback_model).toBe('custom/fallback');
|
||||||
|
expect(request.request.body.rate_limit_retry_seconds).toBe(90);
|
||||||
|
request.flush({ ...config, tool_fallback_model: 'custom/fallback', rate_limit_retry_seconds: 90 });
|
||||||
|
});
|
||||||
|
|
||||||
it('连接测试使用当前输入而不写入保存接口', () => {
|
it('连接测试使用当前输入而不写入保存接口', () => {
|
||||||
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -99,17 +138,19 @@ describe('持久化模型与功能设置', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('录屏与视频分析独立保存,重新打开后保留各自状态', () => {
|
it('录屏与视频分析独立保存,重新打开后保留各自状态', () => {
|
||||||
const capabilities = { video_enabled: true, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: false };
|
const capabilities = { video_enabled: true, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: false, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
|
||||||
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
http.expectOne('/api/system/capability-settings').flush(capabilities);
|
http.expectOne('/api/system/capability-settings').flush(capabilities);
|
||||||
fixture.componentInstance.config.video_enabled = false;
|
fixture.componentInstance.config.video_enabled = false;
|
||||||
fixture.componentInstance.config.recording_enabled = true;
|
fixture.componentInstance.config.recording_enabled = true;
|
||||||
|
fixture.componentInstance.config.screenshot_limit_enabled = false;
|
||||||
fixture.componentInstance.save();
|
fixture.componentInstance.save();
|
||||||
const request = http.expectOne('/api/system/capability-settings');
|
const request = http.expectOne('/api/system/capability-settings');
|
||||||
expect(request.request.body.video_enabled).toBeFalse();
|
expect(request.request.body.video_enabled).toBeFalse();
|
||||||
expect(request.request.body.recording_enabled).toBeTrue();
|
expect(request.request.body.recording_enabled).toBeTrue();
|
||||||
const saved = { ...capabilities, video_enabled: false, recording_enabled: true };
|
expect(request.request.body.screenshot_limit_enabled).toBeFalse();
|
||||||
|
const saved = { ...capabilities, video_enabled: false, recording_enabled: true, screenshot_limit_enabled: false };
|
||||||
request.flush(saved);
|
request.flush(saved);
|
||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
|
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
|
||||||
@@ -117,5 +158,36 @@ describe('持久化模型与功能设置', () => {
|
|||||||
http.expectOne('/api/system/capability-settings').flush(saved);
|
http.expectOne('/api/system/capability-settings').flush(saved);
|
||||||
expect(reopened.componentInstance.config.video_enabled).toBeFalse();
|
expect(reopened.componentInstance.config.video_enabled).toBeFalse();
|
||||||
expect(reopened.componentInstance.config.recording_enabled).toBeTrue();
|
expect(reopened.componentInstance.config.recording_enabled).toBeTrue();
|
||||||
|
expect(reopened.componentInstance.config.screenshot_limit_enabled).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('执行保护默认折叠,修改时限可保存且拒绝无效数值', async () => {
|
||||||
|
const capabilities = { video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
|
||||||
|
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
http.expectOne('/api/system/capability-settings').flush(capabilities);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
const root: HTMLElement = fixture.nativeElement;
|
||||||
|
expect(root.querySelector<HTMLDetailsElement>('.execution-settings')!.open).toBeFalse();
|
||||||
|
root.querySelector<HTMLElement>('summary')!.click();
|
||||||
|
const timeout = root.querySelector<HTMLInputElement>('input[type="number"]')!;
|
||||||
|
timeout.value = '3600';
|
||||||
|
timeout.dispatchEvent(new Event('input'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
root.querySelector<HTMLButtonElement>('button')!.click();
|
||||||
|
const request = http.expectOne('/api/system/capability-settings');
|
||||||
|
expect(request.request.body.task_timeout_seconds).toBe(3600);
|
||||||
|
expect(request.request.body.observation_timeout_seconds).toBe(60);
|
||||||
|
request.flush({ ...capabilities, task_timeout_seconds: 3600 });
|
||||||
|
expect(fixture.componentInstance.message()).toContain('已保存');
|
||||||
|
timeout.value = '0';
|
||||||
|
timeout.dispatchEvent(new Event('input'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(root.querySelector<HTMLButtonElement>('button')!.disabled).toBeTrue();
|
||||||
|
fixture.componentInstance.save();
|
||||||
|
http.expectNone('/api/system/capability-settings');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -10,7 +10,13 @@ interface ModelSettings {
|
|||||||
timeout_seconds: number;
|
timeout_seconds: number;
|
||||||
max_retries: number;
|
max_retries: number;
|
||||||
retry_interval_seconds: number;
|
retry_interval_seconds: number;
|
||||||
|
rate_limit_retry_seconds: number;
|
||||||
|
tool_fallback_model: string;
|
||||||
|
tool_fallback_reasoning_effort: string;
|
||||||
|
tool_fallback_max_retries: number;
|
||||||
|
tool_fallback_no_progress_attempts: number;
|
||||||
reasoning_effort: string;
|
reasoning_effort: string;
|
||||||
|
model_presets: string[];
|
||||||
env_path: string;
|
env_path: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
@@ -55,6 +61,30 @@ interface ModelSettings {
|
|||||||
[(ngModel)]="retries" [disabled]="busy() || loading()" /></label>
|
[(ngModel)]="retries" [disabled]="busy() || loading()" /></label>
|
||||||
<label>重试间隔(秒)<input name="retryInterval" type="number" min="0" max="300" required [(ngModel)]="retryInterval" [disabled]="busy() || loading()" /></label>
|
<label>重试间隔(秒)<input name="retryInterval" type="number" min="0" max="300" required [(ngModel)]="retryInterval" [disabled]="busy() || loading()" /></label>
|
||||||
</div>
|
</div>
|
||||||
|
<details class="recovery-settings">
|
||||||
|
<summary>稳定性与兜底模型</summary>
|
||||||
|
<div class="recovery-content">
|
||||||
|
<label>429 等待重试(秒)<input name="rateLimitRetry" type="number" min="0" max="3600" required
|
||||||
|
[(ngModel)]="rateLimitRetry" [disabled]="busy() || loading()" /></label>
|
||||||
|
<label>兜底模型名称<input name="toolFallbackModel" type="text" required autocomplete="off"
|
||||||
|
autocapitalize="off" spellcheck="false" [(ngModel)]="toolFallbackModel"
|
||||||
|
[disabled]="busy() || loading()" /></label>
|
||||||
|
<label>兜底思考强度<select name="toolFallbackReasoning" [(ngModel)]="toolFallbackReasoning"
|
||||||
|
[disabled]="busy() || loading()">
|
||||||
|
<option value="default">由模型默认</option><option value="none">关闭(none)</option>
|
||||||
|
<option value="minimal">最低(minimal)</option><option value="low">低(low)</option>
|
||||||
|
<option value="medium">中(medium)</option><option value="high">高(high)</option>
|
||||||
|
<option value="xhigh">极高(xhigh)</option><option value="max">最高(max)</option>
|
||||||
|
</select></label>
|
||||||
|
<div class="number-fields">
|
||||||
|
<label>兜底请求重试次数<input name="toolFallbackRetries" type="number" min="0" max="10" required
|
||||||
|
[(ngModel)]="toolFallbackRetries" [disabled]="busy() || loading()" /></label>
|
||||||
|
<label>兜底无效调用上限<input name="toolFallbackNoProgress" type="number" min="1" max="10" required
|
||||||
|
[(ngModel)]="toolFallbackNoProgress" [disabled]="busy() || loading()" /></label>
|
||||||
|
</div>
|
||||||
|
<small>首选模型连续 3 次未形成有效工具调用后,本步骤使用兜底模型;下一步恢复首选模型。</small>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit" [disabled]="busy() || loading() || !valid()">{{ busy() === 'save' ? '正在保存…' : '保存配置' }}</button>
|
<button type="submit" [disabled]="busy() || loading() || !valid()">{{ busy() === 'save' ? '正在保存…' : '保存配置' }}</button>
|
||||||
<button type="button" (click)="test()" [disabled]="busy() || loading() || !valid()">
|
<button type="button" (click)="test()" [disabled]="busy() || loading() || !valid()">
|
||||||
@@ -76,6 +106,10 @@ interface ModelSettings {
|
|||||||
input:focus, select:focus { outline: 2px solid #38bdf8; }
|
input:focus, select:focus { outline: 2px solid #38bdf8; }
|
||||||
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 8px; }
|
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 8px; }
|
||||||
.number-fields input { margin-top: 8px; }
|
.number-fields input { margin-top: 8px; }
|
||||||
|
.recovery-settings { border: 1px solid #cbd5e1; border-radius: 10px; background: #f8fafc; padding: 10px 14px; }
|
||||||
|
.recovery-settings summary { cursor: pointer; font-weight: 600; color: #334155; }
|
||||||
|
.recovery-content { display: grid; gap: 10px; padding-top: 12px; }
|
||||||
|
.recovery-content label input, .recovery-content label select { margin-top: 8px; }
|
||||||
.actions { display: flex; gap: 12px; margin-top: 8px; }
|
.actions { display: flex; gap: 12px; margin-top: 8px; }
|
||||||
button { padding: 11px 20px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; cursor: pointer; font: inherit; }
|
button { padding: 11px 20px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; cursor: pointer; font: inherit; }
|
||||||
button:disabled { opacity: .5; cursor: default; } .success { color: #047857; } .error { color: #be123c; }
|
button:disabled { opacity: .5; cursor: default; } .success { color: #047857; } .error { color: #be123c; }
|
||||||
@@ -90,8 +124,10 @@ interface ModelSettings {
|
|||||||
export class ModelSettingsComponent implements OnInit {
|
export class ModelSettingsComponent implements OnInit {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
saved = output<void>();
|
saved = output<void>();
|
||||||
readonly modelPresets = ['gpt-5.6-luna', 'mimo-v2.6-flash'];
|
modelPresets: string[] = [];
|
||||||
baseUrl = ''; model = ''; apiKey = ''; timeout = 300; retries = 5; retryInterval = 10; reasoning = 'high';
|
baseUrl = ''; model = ''; apiKey = ''; timeout = 300; retries = 5; retryInterval = 10; reasoning = 'high';
|
||||||
|
rateLimitRetry = 60; toolFallbackModel = 'gpt-6-luna'; toolFallbackReasoning = 'high';
|
||||||
|
toolFallbackRetries = 3; toolFallbackNoProgress = 3;
|
||||||
busy = signal<'save' | 'test' | null>(null);
|
busy = signal<'save' | 'test' | null>(null);
|
||||||
loading = signal(true); keyConfigured = signal(false); envPath = signal('');
|
loading = signal(true); keyConfigured = signal(false); envPath = signal('');
|
||||||
message = signal(''); error = signal('');
|
message = signal(''); error = signal('');
|
||||||
@@ -104,15 +140,27 @@ export class ModelSettingsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
valid(): boolean {
|
valid(): boolean {
|
||||||
return !!this.baseUrl.trim() && !!this.model.trim() && (!!this.apiKey.trim() || this.keyConfigured())
|
return !!this.baseUrl.trim() && !!this.model.trim() && (!!this.apiKey.trim() || this.keyConfigured())
|
||||||
&& this.timeout > 0 && this.timeout <= 3600 && Number.isInteger(this.retries) && this.retries >= 0 && this.retries <= 10 && this.retryInterval >= 0 && this.retryInterval <= 300;
|
&& this.timeout > 0 && this.timeout <= 3600 && Number.isInteger(this.retries) && this.retries >= 0 && this.retries <= 10 && this.retryInterval >= 0 && this.retryInterval <= 300
|
||||||
|
&& this.rateLimitRetry >= 0 && this.rateLimitRetry <= 3600 && !!this.toolFallbackModel.trim()
|
||||||
|
&& Number.isInteger(this.toolFallbackRetries) && this.toolFallbackRetries >= 0 && this.toolFallbackRetries <= 10
|
||||||
|
&& Number.isInteger(this.toolFallbackNoProgress) && this.toolFallbackNoProgress >= 1 && this.toolFallbackNoProgress <= 10;
|
||||||
}
|
}
|
||||||
private payload() {
|
private payload() {
|
||||||
return { base_url: this.baseUrl.trim(), model: this.model.trim(), api_key: this.apiKey.trim() || undefined,
|
return { base_url: this.baseUrl.trim(), model: this.model.trim(), api_key: this.apiKey.trim() || undefined,
|
||||||
timeout_seconds: this.timeout, max_retries: this.retries, retry_interval_seconds: this.retryInterval, reasoning_effort: this.reasoning };
|
timeout_seconds: this.timeout, max_retries: this.retries, retry_interval_seconds: this.retryInterval, reasoning_effort: this.reasoning,
|
||||||
|
rate_limit_retry_seconds: this.rateLimitRetry, tool_fallback_model: this.toolFallbackModel.trim(),
|
||||||
|
tool_fallback_reasoning_effort: this.toolFallbackReasoning, tool_fallback_max_retries: this.toolFallbackRetries,
|
||||||
|
tool_fallback_no_progress_attempts: this.toolFallbackNoProgress };
|
||||||
}
|
}
|
||||||
private apply(config: ModelSettings): void {
|
private apply(config: ModelSettings): void {
|
||||||
this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds;
|
this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds;
|
||||||
|
this.modelPresets = config.model_presets;
|
||||||
this.retries = config.max_retries; this.retryInterval = config.retry_interval_seconds ?? 10; this.reasoning = config.reasoning_effort ?? 'high'; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path);
|
this.retries = config.max_retries; this.retryInterval = config.retry_interval_seconds ?? 10; this.reasoning = config.reasoning_effort ?? 'high'; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path);
|
||||||
|
this.rateLimitRetry = config.rate_limit_retry_seconds ?? 60;
|
||||||
|
this.toolFallbackModel = config.tool_fallback_model ?? 'gpt-6-luna';
|
||||||
|
this.toolFallbackReasoning = config.tool_fallback_reasoning_effort ?? 'high';
|
||||||
|
this.toolFallbackRetries = config.tool_fallback_max_retries ?? 3;
|
||||||
|
this.toolFallbackNoProgress = config.tool_fallback_no_progress_attempts ?? 3;
|
||||||
}
|
}
|
||||||
save(): void {
|
save(): void {
|
||||||
if (this.busy() || !this.valid()) return;
|
if (this.busy() || !this.valid()) return;
|
||||||
|
|||||||
@@ -49,6 +49,21 @@ describe('历史任务检索', () => {
|
|||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('显示明确的结果、完成时间与总耗时,标题悬停显示完整提示词', () => {
|
||||||
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
|
http.expectOne(r => r.url === '/api/automation/history').flush({total: 1, items: [
|
||||||
|
{session_id:'case-1', name:'1.1 打开并核对设置', initial_goal:'请打开设置首页并核对两个真实分类', status:'failed', start_time:100, end_time:165, error:'任务执行超时(60 秒)'}
|
||||||
|
]});
|
||||||
|
fixture.detectChanges();
|
||||||
|
const row: HTMLButtonElement = fixture.nativeElement.querySelector('button.row');
|
||||||
|
expect(row.getAttribute('title')).toBe('请打开设置首页并核对两个真实分类');
|
||||||
|
expect(row.textContent).toContain('1.1 打开并核对设置');
|
||||||
|
expect(row.textContent).toContain('总耗时 1分5秒');
|
||||||
|
expect(row.textContent).toContain('任务执行超时');
|
||||||
|
expect(fixture.nativeElement.querySelector('.status-failed .history-status').textContent).toContain('失败');
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
it('日期筛选用本地零点到次日零点,后端分页参数独立传递', () => {
|
it('日期筛选用本地零点到次日零点,后端分页参数独立传递', () => {
|
||||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
|
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ import { AgentService } from '../../services/agent.service';
|
|||||||
@if(error()){<p role="alert">{{error()}}</p>}
|
@if(error()){<p role="alert">{{error()}}</p>}
|
||||||
@if(loading()){<small>正在查询…</small>}
|
@if(loading()){<small>正在查询…</small>}
|
||||||
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
|
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
|
||||||
@for(row of group.rows; track row.session_id){<div class="history-item"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)"><strong>{{row.name || row.initial_goal}}</strong><span>{{label(row.status)}} · {{row.start_time*1000 | date:'HH:mm:ss'}} · {{row.tags}}</span></button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
|
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><b class="history-status">{{label(row.status)}}</b> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
|
||||||
</details>}
|
</details>}
|
||||||
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
|
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
|
||||||
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
|
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
|
||||||
</section>`,
|
</section>`,
|
||||||
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:4px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:500}.row span{opacity:.75}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #64748b55;border-radius:6px;margin:5px 0;padding:8px}.history-item:has(.row.selected){border-color:#60a5fa;background:#3b82f615}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
|
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-block;border-radius:5px;padding:2px 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
|
||||||
})
|
})
|
||||||
export class TaskHistoryComponent {
|
export class TaskHistoryComponent {
|
||||||
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
|
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
|
||||||
@@ -27,7 +27,8 @@ export class TaskHistoryComponent {
|
|||||||
constructor(){this.load();const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});}
|
constructor(){this.load();const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});}
|
||||||
remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});}
|
remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});}
|
||||||
pages(){return Math.max(1,Math.ceil(this.total()/30));}
|
pages(){return Math.max(1,Math.ceil(this.total()/30));}
|
||||||
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已取消',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
|
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已终止',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
|
||||||
|
duration(start:number,end?:number){if(!start||!end)return '—';const seconds=Math.max(0,Math.floor(end-start));const hours=Math.floor(seconds/3600);const minutes=Math.floor((seconds%3600)/60);return hours?`${hours}时${minutes}分`:minutes?`${minutes}分${seconds%60}秒`:`${seconds}秒`;}
|
||||||
toggle(key:string,event:Event){if((event.target as HTMLDetailsElement).open)this.collapsed.delete(key);else this.collapsed.add(key);}
|
toggle(key:string,event:Event){if((event.target as HTMLDetailsElement).open)this.collapsed.delete(key);else this.collapsed.add(key);}
|
||||||
search(){this.page=1;this.revision++;clearTimeout(this.debounce);this.debounce=setTimeout(()=>this.load(),250);}
|
search(){this.page=1;this.revision++;clearTimeout(this.debounce);this.debounce=setTimeout(()=>this.load(),250);}
|
||||||
load(){
|
load(){
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export interface Session {
|
|||||||
start_time: number;
|
start_time: number;
|
||||||
end_time?: number;
|
end_time?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
display_name?: string;
|
||||||
|
case_id?: string;
|
||||||
|
error?: string;
|
||||||
video_url?: string;
|
video_url?: string;
|
||||||
recording_status?: 'recording' | 'finalizing' | 'processing' | 'ready' | 'failed' | 'unavailable';
|
recording_status?: 'recording' | 'finalizing' | 'processing' | 'ready' | 'failed' | 'unavailable';
|
||||||
model_info?: ModelInfo;
|
model_info?: ModelInfo;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ limitations under the License.
|
|||||||
</div>
|
</div>
|
||||||
<label for="workspace-task-guidance">补充信息或调整方向</label>
|
<label for="workspace-task-guidance">补充信息或调整方向</label>
|
||||||
<textarea id="workspace-task-guidance" [(ngModel)]="taskGuidance" rows="2" maxlength="10000"
|
<textarea id="workspace-task-guidance" [(ngModel)]="taskGuidance" rows="2" maxlength="10000"
|
||||||
[disabled]="guidanceBusy()" placeholder="输入指导;运行中将在下一步执行前生效"></textarea>
|
[disabled]="guidanceBusy()" placeholder="输入指导;将中断当前模型思考并立即重新规划"></textarea>
|
||||||
<div class="guidance-actions">
|
<div class="guidance-actions">
|
||||||
<span>{{ status === 'paused' ? '提交指导后请在执行日志中恢复模型调用' : '下方输入框用于开始新任务' }}</span>
|
<span>{{ status === 'paused' ? '提交指导后请在执行日志中恢复模型调用' : '下方输入框用于开始新任务' }}</span>
|
||||||
@if (status === 'running') {
|
@if (status === 'running') {
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export class AgentService {
|
|||||||
const pendingMatch = pendingById.get(s.session_id);
|
const pendingMatch = pendingById.get(s.session_id);
|
||||||
const isCurrentActive = ((status === 'running' || status === 'paused') && runId === s.session_id)
|
const isCurrentActive = ((status === 'running' || status === 'paused') && runId === s.session_id)
|
||||||
|| !!activeMatch;
|
|| !!activeMatch;
|
||||||
const isTerminal = s.status === 'completed' || s.status === 'success' || s.status === 'failed' || s.status === 'cancelled';
|
const isTerminal = ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(s.status || '');
|
||||||
let sStatus = s.status;
|
let sStatus = s.status;
|
||||||
if (!isTerminal) {
|
if (!isTerminal) {
|
||||||
const isPending = !!pendingMatch && !isCurrentActive;
|
const isPending = !!pendingMatch && !isCurrentActive;
|
||||||
@@ -1515,12 +1515,12 @@ export class AgentService {
|
|||||||
return; // Paused while the tab is in the background; resumed by visibilitychange.
|
return; // Paused while the tab is in the background; resumed by visibilitychange.
|
||||||
}
|
}
|
||||||
this.fetchStatus();
|
this.fetchStatus();
|
||||||
|
const selected = this.currentSessionId();
|
||||||
|
if (selected) this.fetchGuidance(selected);
|
||||||
this.pollCounter++;
|
this.pollCounter++;
|
||||||
// Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications
|
// Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications
|
||||||
if (this.pollCounter % 3 === 0) {
|
if (this.pollCounter % 3 === 0) {
|
||||||
this.fetchSessions();
|
this.fetchSessions();
|
||||||
const selected = this.currentSessionId();
|
|
||||||
if (selected) this.fetchGuidance(selected);
|
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core';
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
|
|
||||||
const LABELS: Record<string, string> = {
|
const LABELS: Record<string, string> = {
|
||||||
running: '执行中', queued: '排队中', pending: '等待中', completed: '已完成',
|
running: '执行中', queued: '排队中', pending: '等待中', completed: '执行完成',
|
||||||
success: '成功', succeeded: '成功', failed: '失败', error: '错误', paused: '已暂停',
|
success: '成功', succeeded: '成功', failed: '失败', error: '错误', paused: '已暂停',
|
||||||
cancelled: '已取消', canceled: '已取消', stopped: '已停止', unknown: '未知',
|
cancelled: '已终止', canceled: '已终止', stopped: '已终止', interrupted: '中断',
|
||||||
|
task_paused: '等待指导', resuming: '恢复排队', skipped: '已跳过', unknown: '未知',
|
||||||
idle: '空闲', starting: '启动中', launching: '启动中', ready: '就绪',
|
idle: '空闲', starting: '启动中', launching: '启动中', ready: '就绪',
|
||||||
pass: '通过', fail: '失败', warn: '警告', warning: '警告', blocked: '受阻',
|
pass: '通过', fail: '失败', warn: '警告', warning: '警告', blocked: '受阻',
|
||||||
done: '完成', active: '当前', waiting: '等待中', processing: '处理中',
|
done: '完成', active: '当前', waiting: '等待中', processing: '处理中',
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { StepReplayFrame } from '../core/models/stream.model';
|
||||||
|
import { TimelineSegment } from './recording-timeline.util';
|
||||||
|
import { buildVideoStepMarkers } from './video-step-markers.util';
|
||||||
|
|
||||||
|
describe('buildVideoStepMarkers', () => {
|
||||||
|
const start = 1_800_000_000;
|
||||||
|
const segments: TimelineSegment[] = [
|
||||||
|
{ start: 0, duration: 10, offset_ms: 1000, duration_ms: 10000 },
|
||||||
|
{ start: 10, duration: 10, offset_ms: 13000, duration_ms: 10000 }
|
||||||
|
];
|
||||||
|
const frame = (stepNumber: number, seconds: number, status = 'dispatched'): StepReplayFrame => ({
|
||||||
|
index: stepNumber - 1,
|
||||||
|
stepNumber,
|
||||||
|
title: `步骤 ${stepNumber}`,
|
||||||
|
imageUrl: 'step.png',
|
||||||
|
timestamp: start + seconds,
|
||||||
|
status
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places markers on the playable timeline across recording restart gaps', () => {
|
||||||
|
const markers = buildVideoStepMarkers([frame(1, 4), frame(2, 15)], start, segments, 20);
|
||||||
|
expect(markers.map((marker) => marker.time)).toEqual([3, 12]);
|
||||||
|
expect(markers.map((marker) => marker.percent)).toEqual([15, 60]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('snaps a step in a recording gap to the following segment', () => {
|
||||||
|
const markers = buildVideoStepMarkers([frame(1, 12)], start, segments, 20);
|
||||||
|
expect(markers[0].time).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits steps outside the actual recording and without timestamps', () => {
|
||||||
|
const missing = { ...frame(4, 5), timestamp: undefined };
|
||||||
|
const markers = buildVideoStepMarkers(
|
||||||
|
[frame(1, 0), frame(2, 2), frame(3, 24), missing], start, segments, 20
|
||||||
|
);
|
||||||
|
expect(markers.map((marker) => marker.steps[0].stepNumber)).toEqual([2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups crowded steps and keeps failure information', () => {
|
||||||
|
const markers = buildVideoStepMarkers(
|
||||||
|
[frame(1, 4), frame(2, 4.1, 'failed'), frame(3, 8)], start, segments, 20
|
||||||
|
);
|
||||||
|
expect(markers.length).toBe(2);
|
||||||
|
expect(markers[0].steps.map((step) => step.stepNumber)).toEqual([1, 2]);
|
||||||
|
expect(markers[0].failed).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses session seconds directly for a legacy playlist or single video', () => {
|
||||||
|
expect(buildVideoStepMarkers([frame(1, 6)], start, [{ start: 0, duration: 20 }], 20)[0].time).toBe(6);
|
||||||
|
expect(buildVideoStepMarkers([frame(1, 6)], start, [], 20)[0].time).toBe(6);
|
||||||
|
expect(buildVideoStepMarkers([frame(1, 6)], undefined, [], 20)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { StepReplayFrame } from '../core/models/stream.model';
|
||||||
|
import { hasSessionOffsets, sessionTimeToTimelineTime, TimelineSegment } from './recording-timeline.util';
|
||||||
|
|
||||||
|
export interface VideoStepMarker {
|
||||||
|
time: number;
|
||||||
|
percent: number;
|
||||||
|
steps: StepReplayFrame[];
|
||||||
|
failed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Place recorded action steps on the video's gap-free playback timeline. */
|
||||||
|
export function buildVideoStepMarkers(
|
||||||
|
frames: readonly StepReplayFrame[],
|
||||||
|
sessionStart: number | undefined,
|
||||||
|
segments: readonly TimelineSegment[],
|
||||||
|
duration: number
|
||||||
|
): VideoStepMarker[] {
|
||||||
|
const start = Number(sessionStart);
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(duration) || duration <= 0) return [];
|
||||||
|
|
||||||
|
const hasOffsets = hasSessionOffsets(segments);
|
||||||
|
const firstOffset = hasOffsets ? (segments[0].offset_ms as number) / 1000 : 0;
|
||||||
|
const lastSegment = segments[segments.length - 1];
|
||||||
|
const lastOffset = hasOffsets
|
||||||
|
? ((lastSegment.offset_ms as number) + (lastSegment.duration_ms as number)) / 1000
|
||||||
|
: duration;
|
||||||
|
|
||||||
|
const positioned = frames.flatMap((frame) => {
|
||||||
|
const timestamp = Number(frame.timestamp);
|
||||||
|
if (frame.timestamp == null || !Number.isFinite(timestamp)) return [];
|
||||||
|
const sessionTime = timestamp - start;
|
||||||
|
// Do not create misleading ticks at the start/end for steps outside the recording.
|
||||||
|
if (sessionTime < firstOffset || sessionTime > lastOffset) return [];
|
||||||
|
const time = sessionTimeToTimelineTime(segments, sessionTime);
|
||||||
|
if (time < 0 || time > duration) return [];
|
||||||
|
return [{ time, frame }];
|
||||||
|
}).sort((a, b) => a.time - b.time);
|
||||||
|
|
||||||
|
const markers: VideoStepMarker[] = [];
|
||||||
|
// Nearby actions share one visible tick on a narrow player timeline.
|
||||||
|
const minGap = duration * 0.02;
|
||||||
|
for (const { time, frame } of positioned) {
|
||||||
|
const previous = markers[markers.length - 1];
|
||||||
|
if (previous && time - previous.time < minGap) {
|
||||||
|
previous.steps.push(frame);
|
||||||
|
previous.failed ||= frame.status === 'failed';
|
||||||
|
} else {
|
||||||
|
markers.push({ time, percent: (time / duration) * 100, steps: [frame], failed: frame.status === 'failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return markers;
|
||||||
|
}
|
||||||
@@ -55,5 +55,5 @@ Keep in mind that the mobile device is an asynchronous, constantly evolving envi
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 6. TERMINATION PROTOCOL (`report_task_status`)
|
# 6. TERMINATION PROTOCOL (`report_task_status`)
|
||||||
- **Completion (`status="completed"`)**: ONLY call `report_task_status(status="completed", ...)` after you have **visually verified** from the latest screen that the objective is fully achieved.
|
- **Completion (`status="completed"`)**: ONLY call `report_task_status(status="completed", ...)` after the latest screenshot and UI elements support the claimed result. If those sources disagree during a transition, wait and observe again before concluding. Tie each claimed page title or value to an observed screenshot or UI element; mark any missing evidence as unverified.
|
||||||
- **Failure (`status="failed"`)**: If the task is genuinely impossible due to paywalls, unrecoverable crashes, or hitting an absolute dead end after sensible fallbacks, call `report_task_status(status="failed", ...)` with the exact blocking reason.
|
- **Failure (`status="failed"`)**: If the task is genuinely impossible due to paywalls, unrecoverable crashes, or hitting an absolute dead end after sensible fallbacks, call `report_task_status(status="failed", ...)` with the exact blocking reason.
|
||||||
@@ -24,6 +24,7 @@ You are the Step Summarizer for an Android UI automation agent. Your task is to
|
|||||||
- **Target & Action**: Identify what control/button was targeted (referencing the red visual indicator on the BEFORE screen) and the physical action performed.
|
- **Target & Action**: Identify what control/button was targeted (referencing the red visual indicator on the BEFORE screen) and the physical action performed.
|
||||||
- **Objective Transition**: Describe the physical screen change (e.g., page transitioned, modal dialog opened, list scrolled revealing new items, checkbox/toggle toggled, keyboard appeared).
|
- **Objective Transition**: Describe the physical screen change (e.g., page transitioned, modal dialog opened, list scrolled revealing new items, checkbox/toggle toggled, keyboard appeared).
|
||||||
- **Preserve Critical Data & Verifications**: Faithfully transcribe visible text, toast alerts, error banners, verification codes, prices, account names, or tracking numbers that appeared on the AFTER screen.
|
- **Preserve Critical Data & Verifications**: Faithfully transcribe visible text, toast alerts, error banners, verification codes, prices, account names, or tracking numbers that appeared on the AFTER screen.
|
||||||
|
3. **Reconcile both observation sources**: The optional AFTER ACTION UI ELEMENTS block is device hierarchy evidence from the same observation request. During a page animation its contents may be newer than the screenshot. If the image still shows the old page but the hierarchy names the new page, state both observations and that the transition is not yet visually settled; never conclude that the tap did nothing. Do not invent details absent from both sources. A controller's "Tapped" outcome only confirms input dispatch.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+176
-21
@@ -45,7 +45,7 @@ import re
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from artemis.config.settings import settings
|
from artemis.config.settings import settings
|
||||||
from artemis.runtime.task_control import TaskControl
|
from artemis.runtime.task_control import TaskControl, has_pending_instruction
|
||||||
|
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
from langchain_core.messages import (
|
from langchain_core.messages import (
|
||||||
@@ -78,9 +78,10 @@ from artemis.controllers.unified_controller import UnifiedMobileController
|
|||||||
from artemis.data_engine.trace import trace
|
from artemis.data_engine.trace import trace
|
||||||
from artemis.graph.perception import _check_injected_instruction_file
|
from artemis.graph.perception import _check_injected_instruction_file
|
||||||
from artemis.graph.state import State
|
from artemis.graph.state import State
|
||||||
|
from artemis.llm.router import ModelEndpoint
|
||||||
from artemis.llm.structured import ParseFailure, parse_structured
|
from artemis.llm.structured import ParseFailure, parse_structured
|
||||||
from artemis.mcp.action_executor import McpActionExecutor
|
from artemis.mcp.action_executor import McpActionExecutor
|
||||||
from artemis.mcp.observation import observe
|
from artemis.mcp.observation import DeviceObservationError, require_observation
|
||||||
from artemis.memory.transcript import PRO_UI_LIST_MARKER, TranscriptLedger, mark_ephemeral
|
from artemis.memory.transcript import PRO_UI_LIST_MARKER, TranscriptLedger, mark_ephemeral
|
||||||
from artemis.services.llm import (
|
from artemis.services.llm import (
|
||||||
RobustChatModelWrapper,
|
RobustChatModelWrapper,
|
||||||
@@ -167,6 +168,7 @@ class FlashRunner:
|
|||||||
ctx,
|
ctx,
|
||||||
model_name=self.step_summarizer_cfg.model,
|
model_name=self.step_summarizer_cfg.model,
|
||||||
retry_limit=self.memory_runtime_cfg.retry_limit,
|
retry_limit=self.memory_runtime_cfg.retry_limit,
|
||||||
|
timeout_seconds=self.step_summarizer_cfg.timeout_seconds,
|
||||||
max_concurrency=self.memory_runtime_cfg.max_concurrency,
|
max_concurrency=self.memory_runtime_cfg.max_concurrency,
|
||||||
flush_timeout_s=self.memory_runtime_cfg.flush_timeout_s,
|
flush_timeout_s=self.memory_runtime_cfg.flush_timeout_s,
|
||||||
)
|
)
|
||||||
@@ -287,7 +289,9 @@ class FlashRunner:
|
|||||||
logger.warning(f"Failed to get operator LLM from config, using default: {e}")
|
logger.warning(f"Failed to get operator LLM from config, using default: {e}")
|
||||||
|
|
||||||
return RobustChatModelWrapper(
|
return RobustChatModelWrapper(
|
||||||
get_openai_llm(model_name=settings.OPENAI_MODEL), self.ctx
|
get_openai_llm(model_name=settings.OPENAI_MODEL),
|
||||||
|
self.ctx,
|
||||||
|
endpoint=ModelEndpoint(model_name=settings.OPENAI_MODEL),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _render_system_prompt(self, tools_declaration: list) -> str:
|
def _render_system_prompt(self, tools_declaration: list) -> str:
|
||||||
@@ -376,8 +380,16 @@ class FlashRunner:
|
|||||||
blocks.append(
|
blocks.append(
|
||||||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}
|
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}
|
||||||
)
|
)
|
||||||
if xml_list:
|
blocks.append(
|
||||||
blocks.append({"type": "text", "text": f"{PRO_UI_LIST_MARKER}\n{xml_list}"})
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": f"{PRO_UI_LIST_MARKER}\n"
|
||||||
|
+ (
|
||||||
|
xml_list
|
||||||
|
or "当前没有可用的元素索引。仅根据当前截图定位坐标,禁止使用历史画面的元素编号。"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
ephemeral: list[int] = []
|
ephemeral: list[int] = []
|
||||||
# (text, ephemeral) in tail order.
|
# (text, ephemeral) in tail order.
|
||||||
per_turn: list[tuple[str, bool]] = [(text, True) for text in (notices or [])]
|
per_turn: list[tuple[str, bool]] = [(text, True) for text in (notices or [])]
|
||||||
@@ -600,19 +612,14 @@ class FlashRunner:
|
|||||||
post_img_bytes = exec_result.screenshot_bytes
|
post_img_bytes = exec_result.screenshot_bytes
|
||||||
if post_img_bytes or name not in action_names:
|
if post_img_bytes or name not in action_names:
|
||||||
return post_img_bytes
|
return post_img_bytes
|
||||||
try:
|
if state is not None:
|
||||||
obs, screenshot_bytes = await observe(self.ctx, self.controller, settle_ms=0)
|
state.indexed_points = []
|
||||||
except Exception as shot_err:
|
state.indexed_elements = []
|
||||||
logger.warning(f"Failed to capture fallback screenshot in FlashRunner: {shot_err}")
|
state.latest_screenshot = None
|
||||||
return post_img_bytes
|
obs, screenshot_bytes = await require_observation(self.ctx, self.controller, settle_ms=0)
|
||||||
if not obs.ok:
|
|
||||||
logger.warning(f"Failed to capture fallback screenshot in FlashRunner: {obs.message}")
|
|
||||||
return post_img_bytes
|
|
||||||
post_img_bytes = screenshot_bytes
|
post_img_bytes = screenshot_bytes
|
||||||
if not exec_result.ui_elements_text:
|
exec_result.ui_elements_text = obs.elements_text
|
||||||
exec_result.ui_elements_text = obs.elements_text
|
exec_result.screenshot_path = obs.screenshot_path
|
||||||
if not exec_result.screenshot_path and obs.screenshot_path:
|
|
||||||
exec_result.screenshot_path = obs.screenshot_path
|
|
||||||
if state is not None:
|
if state is not None:
|
||||||
if obs.hierarchy_ok:
|
if obs.hierarchy_ok:
|
||||||
state.indexed_points = [el["center"] for el in obs.elements]
|
state.indexed_points = [el["center"] for el in obs.elements]
|
||||||
@@ -827,7 +834,7 @@ class FlashRunner:
|
|||||||
turn.actions.append((name, exec_result.status, exec_result.text_summary))
|
turn.actions.append((name, exec_result.status, exec_result.text_summary))
|
||||||
|
|
||||||
# ⚡ Non-blocking dispatch of objective visual transition summarizer
|
# ⚡ Non-blocking dispatch of objective visual transition summarizer
|
||||||
if self.summarizer and name in action_names:
|
if self.summarizer and name in action_names and post_img_bytes:
|
||||||
action_sequence += 1
|
action_sequence += 1
|
||||||
self.summarizer.dispatch(
|
self.summarizer.dispatch(
|
||||||
step_number=action_sequence,
|
step_number=action_sequence,
|
||||||
@@ -838,6 +845,8 @@ class FlashRunner:
|
|||||||
pre_img_bytes=pre_screenshot_bytes,
|
pre_img_bytes=pre_screenshot_bytes,
|
||||||
post_img_bytes=post_img_bytes,
|
post_img_bytes=post_img_bytes,
|
||||||
exec_outcome=exec_result.text_summary,
|
exec_outcome=exec_result.text_summary,
|
||||||
|
post_ui_elements_text=exec_result.ui_elements_text,
|
||||||
|
observation_settled=(exec_result.metadata or {}).get("observation_settled"),
|
||||||
action_key=str(tc_id),
|
action_key=str(tc_id),
|
||||||
data_engine_step_id=recorded_step_id,
|
data_engine_step_id=recorded_step_id,
|
||||||
# The model's own reasoning for this turn steers the lens's
|
# The model's own reasoning for this turn steers the lens's
|
||||||
@@ -851,7 +860,7 @@ class FlashRunner:
|
|||||||
|
|
||||||
if post_img_bytes:
|
if post_img_bytes:
|
||||||
pre_screenshot_bytes = post_img_bytes
|
pre_screenshot_bytes = post_img_bytes
|
||||||
if exec_result.ui_elements_text:
|
if exec_result.ui_elements_text is not None:
|
||||||
xml_list = exec_result.ui_elements_text
|
xml_list = exec_result.ui_elements_text
|
||||||
|
|
||||||
# Helper tools may return multimodal blocks (a step screenshot);
|
# Helper tools may return multimodal blocks (a step screenshot);
|
||||||
@@ -870,6 +879,8 @@ class FlashRunner:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except DeviceObservationError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error executing tool {name}: {e}")
|
logger.error(f"Error executing tool {name}: {e}")
|
||||||
if name in action_names:
|
if name in action_names:
|
||||||
@@ -937,6 +948,9 @@ class FlashRunner:
|
|||||||
action_names = self.executor.action_tool_names
|
action_names = self.executor.action_tool_names
|
||||||
index_elements = self._turn_index_snapshot(state)
|
index_elements = self._turn_index_snapshot(state)
|
||||||
for tc in tool_calls:
|
for tc in tool_calls:
|
||||||
|
control = getattr(self.ctx, "task_control", None)
|
||||||
|
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
|
||||||
|
return None, pre_screenshot_bytes, xml_list, action_sequence
|
||||||
name = tc["name"].split(":")[-1] if ":" in tc["name"] else tc["name"]
|
name = tc["name"].split(":")[-1] if ":" in tc["name"] else tc["name"]
|
||||||
args = tc.get("args") or {}
|
args = tc.get("args") or {}
|
||||||
tc_id = tc.get("id") or str(uuid.uuid4())
|
tc_id = tc.get("id") or str(uuid.uuid4())
|
||||||
@@ -1038,6 +1052,21 @@ class FlashRunner:
|
|||||||
|
|
||||||
@trace(type="agent", name="FlashRunner")
|
@trace(type="agent", name="FlashRunner")
|
||||||
async def run(self, state: State) -> dict:
|
async def run(self, state: State) -> dict:
|
||||||
|
try:
|
||||||
|
return await self._run(state)
|
||||||
|
except DeviceObservationError as exc:
|
||||||
|
state.indexed_points = []
|
||||||
|
state.indexed_elements = []
|
||||||
|
state.latest_screenshot = None
|
||||||
|
logger.error(f"Flash 因设备观测失败停止:{exc}")
|
||||||
|
return {
|
||||||
|
"status": "failed",
|
||||||
|
"explanation": str(exc),
|
||||||
|
"error": str(exc),
|
||||||
|
"code": "device_observation_failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _run(self, state: State) -> dict:
|
||||||
limit = self.turn_limit
|
limit = self.turn_limit
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Starting Artemis Flash reactive loop for goal: {self.goal}"
|
f"Starting Artemis Flash reactive loop for goal: {self.goal}"
|
||||||
@@ -1060,6 +1089,11 @@ class FlashRunner:
|
|||||||
current_xml_list = xml_list
|
current_xml_list = xml_list
|
||||||
previous_turn: _TurnRecord | None = None
|
previous_turn: _TurnRecord | None = None
|
||||||
pending_notices: list[str] = []
|
pending_notices: list[str] = []
|
||||||
|
no_tool_attempts = 0
|
||||||
|
no_tool_limit = settings.ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS
|
||||||
|
fallback_llm = None
|
||||||
|
using_tool_fallback = False
|
||||||
|
action_failure_attempts = 0
|
||||||
|
|
||||||
while limit is None or turns < limit:
|
while limit is None or turns < limit:
|
||||||
control = getattr(self.ctx, "task_control", None)
|
control = getattr(self.ctx, "task_control", None)
|
||||||
@@ -1069,6 +1103,10 @@ class FlashRunner:
|
|||||||
on_resume=getattr(self.ctx, "on_task_resume", None),
|
on_resume=getattr(self.ctx, "on_task_resume", None),
|
||||||
)
|
)
|
||||||
if resumed:
|
if resumed:
|
||||||
|
using_tool_fallback = False
|
||||||
|
no_tool_attempts = 0
|
||||||
|
action_failure_attempts = 0
|
||||||
|
self._llm = llm
|
||||||
(
|
(
|
||||||
shot_path,
|
shot_path,
|
||||||
current_pre_screenshot_bytes,
|
current_pre_screenshot_bytes,
|
||||||
@@ -1118,7 +1156,44 @@ class FlashRunner:
|
|||||||
turn_base = len(messages) - 1
|
turn_base = len(messages) - 1
|
||||||
current_tools = report_only_tools if is_final else tools_declaration
|
current_tools = report_only_tools if is_final else tools_declaration
|
||||||
|
|
||||||
response = await self._invoke_model(llm, current_tools, messages)
|
if isinstance(control, TaskControl):
|
||||||
|
interrupted, response = await control.run_interruptibly(
|
||||||
|
self._invoke_model(
|
||||||
|
fallback_llm if using_tool_fallback else llm, current_tools, messages
|
||||||
|
),
|
||||||
|
on_pause=getattr(self.ctx, "on_task_pause", None),
|
||||||
|
on_resume=getattr(self.ctx, "on_task_resume", None),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
interrupted, response = (
|
||||||
|
False,
|
||||||
|
await self._invoke_model(
|
||||||
|
fallback_llm if using_tool_fallback else llm, current_tools, messages
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if interrupted or (
|
||||||
|
isinstance(control, TaskControl) and has_pending_instruction(control.directory)
|
||||||
|
):
|
||||||
|
# Do not dispatch a stale action or final answer from a prompt
|
||||||
|
# assembled before the instruction arrived.
|
||||||
|
using_tool_fallback = False
|
||||||
|
no_tool_attempts = 0
|
||||||
|
action_failure_attempts = 0
|
||||||
|
self._llm = llm
|
||||||
|
(
|
||||||
|
shot_path,
|
||||||
|
current_pre_screenshot_bytes,
|
||||||
|
current_xml_list,
|
||||||
|
) = await capture_screenshot_and_parse_ui(
|
||||||
|
self.ctx, state, self.controller, skip_settling=False
|
||||||
|
)
|
||||||
|
state.latest_screenshot = shot_path
|
||||||
|
pending_notices.append(
|
||||||
|
"新指导已打断旧模型步骤。请重新观察画面并按最新指导决定下一步。"
|
||||||
|
)
|
||||||
|
turns -= 1
|
||||||
|
continue
|
||||||
|
|
||||||
if response is None:
|
if response is None:
|
||||||
break
|
break
|
||||||
@@ -1145,6 +1220,21 @@ class FlashRunner:
|
|||||||
self._record_llm_trace(step_token_usage, raw_text)
|
self._record_llm_trace(step_token_usage, raw_text)
|
||||||
|
|
||||||
tool_calls = self._resolve_tool_calls(response, raw_text)
|
tool_calls = self._resolve_tool_calls(response, raw_text)
|
||||||
|
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
|
||||||
|
using_tool_fallback = False
|
||||||
|
no_tool_attempts = 0
|
||||||
|
self._llm = llm
|
||||||
|
(
|
||||||
|
shot_path,
|
||||||
|
current_pre_screenshot_bytes,
|
||||||
|
current_xml_list,
|
||||||
|
) = await capture_screenshot_and_parse_ui(
|
||||||
|
self.ctx, state, self.controller, skip_settling=False
|
||||||
|
)
|
||||||
|
state.latest_screenshot = shot_path
|
||||||
|
pending_notices.append("新指导已收到;忽略旧模型动作,重新观察画面。")
|
||||||
|
turns -= 1
|
||||||
|
continue
|
||||||
|
|
||||||
if not tool_calls:
|
if not tool_calls:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1154,11 +1244,41 @@ class FlashRunner:
|
|||||||
if is_final:
|
if is_final:
|
||||||
final_report = {"status": "failed", "explanation": raw_text}
|
final_report = {"status": "failed", "explanation": raw_text}
|
||||||
break
|
break
|
||||||
|
no_tool_attempts += 1
|
||||||
|
active_limit = (
|
||||||
|
settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS
|
||||||
|
if using_tool_fallback
|
||||||
|
else no_tool_limit
|
||||||
|
)
|
||||||
|
if no_tool_attempts >= active_limit:
|
||||||
|
if using_tool_fallback:
|
||||||
|
final_report = {
|
||||||
|
"status": "failed",
|
||||||
|
"explanation": (
|
||||||
|
f"首选模型连续 {no_tool_limit} 次、兜底模型连续 "
|
||||||
|
f"{no_tool_attempts} 次未调用工具,已停止任务;"
|
||||||
|
"未执行新的设备动作。"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
break
|
||||||
|
fallback_llm = get_llm(self.ctx, name="operator", use_fallback=True)
|
||||||
|
using_tool_fallback = True
|
||||||
|
no_tool_attempts = 0
|
||||||
|
action_failure_attempts = 0
|
||||||
|
logger.warning("Flash 首选模型未调用工具,当前步骤切换到兜底模型")
|
||||||
|
pending_notices.append("首选模型连续未调用工具;本步骤改用兜底模型。")
|
||||||
pending_notices.append(_NO_TOOL_CALL_NOTICE)
|
pending_notices.append(_NO_TOOL_CALL_NOTICE)
|
||||||
ledger.stage_turn(messages[turn_base:])
|
# Keep the observation and any injected user instruction, but
|
||||||
|
# do not feed the model its own unproductive prose on retry.
|
||||||
|
ledger.stage_turn(messages[turn_base:-1])
|
||||||
previous_turn = _TurnRecord()
|
previous_turn = _TurnRecord()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
no_tool_attempts = 0
|
||||||
|
if using_tool_fallback:
|
||||||
|
# A tool-producing turn completes the fallback's scope. The next
|
||||||
|
# observation goes back to the configured primary model.
|
||||||
|
self._llm = fallback_llm
|
||||||
# Process tool calls
|
# Process tool calls
|
||||||
turn = _TurnRecord()
|
turn = _TurnRecord()
|
||||||
(
|
(
|
||||||
@@ -1179,6 +1299,41 @@ class FlashRunner:
|
|||||||
injected=injected,
|
injected=injected,
|
||||||
native_text=native_text,
|
native_text=native_text,
|
||||||
)
|
)
|
||||||
|
if using_tool_fallback and any(status == "success" for _, status, _ in turn.actions):
|
||||||
|
using_tool_fallback = False
|
||||||
|
self._llm = llm
|
||||||
|
pending_notices.append("兜底模型已完成本步骤;后续步骤恢复首选模型。")
|
||||||
|
for _, status, _ in turn.actions:
|
||||||
|
action_failure_attempts = 0 if status == "success" else action_failure_attempts + 1
|
||||||
|
failure_limit = (
|
||||||
|
settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS
|
||||||
|
if using_tool_fallback
|
||||||
|
else settings.ARTEMIS_ACTION_FAILURE_ATTEMPTS
|
||||||
|
)
|
||||||
|
if action_failure_attempts >= failure_limit:
|
||||||
|
if using_tool_fallback:
|
||||||
|
final_report = {
|
||||||
|
"status": "failed",
|
||||||
|
"explanation": f"兜底模型连续 {action_failure_attempts} 次设备动作失败,已停止任务。最后原因:{turn.result()}",
|
||||||
|
}
|
||||||
|
break
|
||||||
|
# A fresh, valid observation is required before spending any
|
||||||
|
# fallback model calls. Hardware recovery is not model retry.
|
||||||
|
(
|
||||||
|
shot_path,
|
||||||
|
current_pre_screenshot_bytes,
|
||||||
|
current_xml_list,
|
||||||
|
) = await capture_screenshot_and_parse_ui(self.ctx, state, self.controller)
|
||||||
|
state.latest_screenshot = shot_path
|
||||||
|
fallback_llm = get_llm(self.ctx, name="operator", use_fallback=True)
|
||||||
|
using_tool_fallback = True
|
||||||
|
self._llm = fallback_llm
|
||||||
|
action_failure_attempts = 0
|
||||||
|
no_tool_attempts = 0
|
||||||
|
pending_notices.append(
|
||||||
|
f"首选模型连续 {settings.ARTEMIS_ACTION_FAILURE_ATTEMPTS} 次设备动作失败。"
|
||||||
|
f"最后原因:{turn.result()}。已重新观察;本步骤由兜底模型重新定位,勿重复旧动作。"
|
||||||
|
)
|
||||||
ledger.stage_turn(messages[turn_base:])
|
ledger.stage_turn(messages[turn_base:])
|
||||||
previous_turn = turn
|
previous_turn = turn
|
||||||
if final_report_from_calls is not None:
|
if final_report_from_calls is not None:
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from jinja2 import Template
|
|||||||
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
||||||
|
|
||||||
from artemis.context import ArtemisContext
|
from artemis.context import ArtemisContext
|
||||||
|
from artemis.llm.messages import adapt_responses_messages
|
||||||
from artemis.memory.step_memory import JobKey, StepMemoryService
|
from artemis.memory.step_memory import JobKey, StepMemoryService
|
||||||
from artemis.services.llm import RobustChatModelWrapper, get_openai_llm, get_llm
|
from artemis.services.llm import RobustChatModelWrapper, get_openai_llm, get_llm
|
||||||
from artemis.services.token_meter import record_llm_usage
|
from artemis.services.token_meter import record_llm_usage
|
||||||
@@ -151,6 +152,7 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
ctx: ArtemisContext,
|
ctx: ArtemisContext,
|
||||||
model_name: str | None = None,
|
model_name: str | None = None,
|
||||||
retry_limit: int = 3,
|
retry_limit: int = 3,
|
||||||
|
timeout_seconds: float = 120.0,
|
||||||
*,
|
*,
|
||||||
max_concurrency: int = 1,
|
max_concurrency: int = 1,
|
||||||
flush_timeout_s: float = 30.0,
|
flush_timeout_s: float = 30.0,
|
||||||
@@ -159,8 +161,11 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
ctx,
|
ctx,
|
||||||
max_concurrency=max_concurrency,
|
max_concurrency=max_concurrency,
|
||||||
retry_limit=retry_limit,
|
retry_limit=retry_limit,
|
||||||
flush_timeout_s=flush_timeout_s,
|
# A pending request must be allowed to reach its own timeout when
|
||||||
|
# the task finishes; otherwise the shorter flush cancels it first.
|
||||||
|
flush_timeout_s=max(flush_timeout_s, timeout_seconds + 1.0),
|
||||||
)
|
)
|
||||||
|
self._timeout_seconds = timeout_seconds
|
||||||
|
|
||||||
# Initialize lightweight VLM: prioritize explicit model_name
|
# Initialize lightweight VLM: prioritize explicit model_name
|
||||||
target_model = model_name or settings.OPENAI_MODEL
|
target_model = model_name or settings.OPENAI_MODEL
|
||||||
@@ -224,6 +229,8 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
action_key: str | None = None,
|
action_key: str | None = None,
|
||||||
data_engine_step_id: UUID | str | None = None,
|
data_engine_step_id: UUID | str | None = None,
|
||||||
focus: dict[str, str] | None = None,
|
focus: dict[str, str] | None = None,
|
||||||
|
post_ui_elements_text: str | None = None,
|
||||||
|
observation_settled: bool | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Dispatches an asynchronous summarization task without blocking the caller.
|
"""Dispatches an asynchronous summarization task without blocking the caller.
|
||||||
|
|
||||||
@@ -254,6 +261,8 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
"exec_outcome": exec_outcome,
|
"exec_outcome": exec_outcome,
|
||||||
"data_engine_step_id": data_engine_step_id,
|
"data_engine_step_id": data_engine_step_id,
|
||||||
"focus": focus,
|
"focus": focus,
|
||||||
|
"post_ui_elements_text": post_ui_elements_text,
|
||||||
|
"observation_settled": observation_settled,
|
||||||
}
|
}
|
||||||
self.submit(key, payload, aliases=aliases)
|
self.submit(key, payload, aliases=aliases)
|
||||||
|
|
||||||
@@ -406,6 +415,10 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
if verbatim and not self._phrase_carries(phrase, verbatim):
|
if verbatim and not self._phrase_carries(phrase, verbatim):
|
||||||
lead_lines.append(f"Action arguments (verbatim): {action_name}({verbatim})")
|
lead_lines.append(f"Action arguments (verbatim): {action_name}({verbatim})")
|
||||||
lead_lines.append(f"Controller Outcome: {exec_outcome}")
|
lead_lines.append(f"Controller Outcome: {exec_outcome}")
|
||||||
|
if input_data.get("observation_settled") is False:
|
||||||
|
lead_lines.append(
|
||||||
|
"Observation status: screen still changing; page state is not confirmed."
|
||||||
|
)
|
||||||
focus = input_data.get("focus")
|
focus = input_data.get("focus")
|
||||||
if isinstance(focus, dict) and focus:
|
if isinstance(focus, dict) and focus:
|
||||||
lead_lines.extend(["", render_focus_block(focus, dual=dual)])
|
lead_lines.extend(["", render_focus_block(focus, dual=dual)])
|
||||||
@@ -454,12 +467,24 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
post_ui = input_data.get("post_ui_elements_text")
|
||||||
|
if post_bytes and isinstance(post_ui, str) and post_ui.strip():
|
||||||
|
content_blocks.append(
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "--- [3] AFTER ACTION UI ELEMENTS ---\n" + post_ui[:8000],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
messages: list[BaseMessage] = [
|
messages: list[BaseMessage] = [
|
||||||
SystemMessage(content=rendered_prompt),
|
SystemMessage(content=rendered_prompt),
|
||||||
HumanMessage(content=content_blocks),
|
HumanMessage(content=content_blocks),
|
||||||
]
|
]
|
||||||
|
|
||||||
response = await asyncio.wait_for(self._llm.ainvoke(messages), timeout=25.0)
|
response = await asyncio.wait_for(
|
||||||
|
self._llm.ainvoke(adapt_responses_messages(messages, self._model_name)),
|
||||||
|
timeout=self._timeout_seconds,
|
||||||
|
)
|
||||||
self._meter_lens_call(response)
|
self._meter_lens_call(response)
|
||||||
summary_raw = response.content if isinstance(response.content, str) else ""
|
summary_raw = response.content if isinstance(response.content, str) else ""
|
||||||
if isinstance(response.content, list):
|
if isinstance(response.content, list):
|
||||||
@@ -505,8 +530,14 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
logger.debug(f"DataEngine step summary update skipped: {de_err}")
|
logger.debug(f"DataEngine step summary update skipped: {de_err}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
except TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
f"VisualStepSummarizer: Step {step_number} summary request timed out "
|
||||||
|
f"(limit {self._timeout_seconds:.1f}s)"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"VisualStepSummarizer: Error generating summary for step {step_number}: {e}"
|
f"VisualStepSummarizer: Error generating summary for step {step_number}: "
|
||||||
|
f"{type(e).__name__}: {e}"
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
@@ -33,7 +33,7 @@ from artemis.graph.state import State
|
|||||||
from artemis.graph.visibility import strict_state
|
from artemis.graph.visibility import strict_state
|
||||||
from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool
|
from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool
|
||||||
from artemis.llm.usage import usage_from_message
|
from artemis.llm.usage import usage_from_message
|
||||||
from artemis.runtime.task_control import TaskControl
|
from artemis.runtime.task_control import TaskControl, has_pending_instruction
|
||||||
from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message
|
from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message
|
||||||
from artemis.tools.command_tool import (
|
from artemis.tools.command_tool import (
|
||||||
analyze_task_output_wrapper,
|
analyze_task_output_wrapper,
|
||||||
@@ -508,6 +508,9 @@ class OperatorNode:
|
|||||||
plan_gate_bounced = False
|
plan_gate_bounced = False
|
||||||
no_progress_attempts = 0
|
no_progress_attempts = 0
|
||||||
no_progress_limit = settings.ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS
|
no_progress_limit = settings.ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS
|
||||||
|
fallback_limit = settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS
|
||||||
|
active_llm = base_llm
|
||||||
|
using_tool_fallback = False
|
||||||
|
|
||||||
for iteration in range(max_iterations):
|
for iteration in range(max_iterations):
|
||||||
control = getattr(self.ctx, "task_control", None)
|
control = getattr(self.ctx, "task_control", None)
|
||||||
@@ -532,13 +535,16 @@ class OperatorNode:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
bound_llm = base_llm.bind_tools(tools=traced_tools)
|
bound_llm = active_llm.bind_tools(tools=traced_tools)
|
||||||
interrupted, response = await self._await_or_pause(
|
interrupted, response = await self._await_or_pause(
|
||||||
invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages))
|
invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages))
|
||||||
)
|
)
|
||||||
if interrupted:
|
if interrupted:
|
||||||
return None, None, None, False
|
return None, None, None, False
|
||||||
assert response is not None
|
assert response is not None
|
||||||
|
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
|
||||||
|
# The model answered a prompt that predates the user's new direction.
|
||||||
|
return None, None, None, False
|
||||||
|
|
||||||
usage = usage_from_message(response)
|
usage = usage_from_message(response)
|
||||||
# The ledger's compaction thresholds read the operator's own
|
# The ledger's compaction thresholds read the operator's own
|
||||||
@@ -604,11 +610,25 @@ class OperatorNode:
|
|||||||
|
|
||||||
if not response.tool_calls:
|
if not response.tool_calls:
|
||||||
no_progress_attempts += 1
|
no_progress_attempts += 1
|
||||||
if no_progress_attempts >= no_progress_limit:
|
if no_progress_attempts >= (
|
||||||
raise OperatorNoProgressError(
|
fallback_limit if using_tool_fallback else no_progress_limit
|
||||||
f"模型连续 {no_progress_attempts} 次未提供可执行的工具调用,"
|
):
|
||||||
"已停止任务以避免空转;未执行新的设备动作。"
|
if using_tool_fallback:
|
||||||
|
raise OperatorNoProgressError(
|
||||||
|
f"首选模型连续 {no_progress_limit} 次、兜底模型连续 "
|
||||||
|
f"{no_progress_attempts} 次未提供可执行的工具调用;"
|
||||||
|
"已停止任务以避免空转。"
|
||||||
|
)
|
||||||
|
active_llm = get_llm(ctx=self.ctx, name="operator", use_fallback=True)
|
||||||
|
using_tool_fallback = True
|
||||||
|
no_progress_attempts = 0
|
||||||
|
logger.warning("Operator 首选模型未调用工具,当前步骤切换到兜底模型")
|
||||||
|
current_messages.append(
|
||||||
|
SystemMessage(
|
||||||
|
content="本步骤已切换兜底模型。请使用工具完成操作或明确报告阻碍。"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
continue
|
||||||
logger.warning("LLM returned no tool call; requesting a concrete tool call.")
|
logger.warning("LLM returned no tool call; requesting a concrete tool call.")
|
||||||
current_messages.append(response)
|
current_messages.append(response)
|
||||||
current_messages.append(
|
current_messages.append(
|
||||||
@@ -690,10 +710,10 @@ class OperatorNode:
|
|||||||
if normalize_name(tool_name) in INTERRUPTIBLE_HELPERS:
|
if normalize_name(tool_name) in INTERRUPTIBLE_HELPERS:
|
||||||
interrupted, result_obj = await self._await_or_pause(tool_call)
|
interrupted, result_obj = await self._await_or_pause(tool_call)
|
||||||
if interrupted:
|
if interrupted:
|
||||||
span.result = "任务暂停;诊断已中断,恢复后重新观察屏幕"
|
span.result = "任务被暂停或指导打断;重新观察屏幕后继续"
|
||||||
results.append((span.result, "error"))
|
results.append((span.result, "error"))
|
||||||
for remaining in other_calls[len(results) :]:
|
for remaining in other_calls[len(results) :]:
|
||||||
results.append(("任务暂停,未执行该工具", "error"))
|
results.append(("任务已打断,未执行该工具", "error"))
|
||||||
for completed, (content, status) in zip(
|
for completed, (content, status) in zip(
|
||||||
other_calls, results
|
other_calls, results
|
||||||
):
|
):
|
||||||
@@ -710,7 +730,7 @@ class OperatorNode:
|
|||||||
current_messages.append(
|
current_messages.append(
|
||||||
ToolMessage(
|
ToolMessage(
|
||||||
tool_call_id=action["id"],
|
tool_call_id=action["id"],
|
||||||
content="任务暂停,未执行屏幕操作",
|
content="任务已打断,未执行屏幕操作",
|
||||||
status="error",
|
status="error",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -827,6 +847,9 @@ class OperatorNode:
|
|||||||
|
|
||||||
action_calls = []
|
action_calls = []
|
||||||
|
|
||||||
|
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
|
||||||
|
return None, None, None, False
|
||||||
|
|
||||||
if action_calls:
|
if action_calls:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Operator requested {len(action_calls)} action(s)."
|
f"Operator requested {len(action_calls)} action(s)."
|
||||||
@@ -925,11 +948,25 @@ class OperatorNode:
|
|||||||
|
|
||||||
if validation_errors:
|
if validation_errors:
|
||||||
no_progress_attempts += 1
|
no_progress_attempts += 1
|
||||||
if no_progress_attempts >= no_progress_limit:
|
if no_progress_attempts >= (
|
||||||
raise OperatorNoProgressError(
|
fallback_limit if using_tool_fallback else no_progress_limit
|
||||||
f"模型连续 {no_progress_attempts} 次未提供可执行的工具调用,"
|
):
|
||||||
"工具参数校验仍失败;已停止任务,未执行被拒绝的设备动作。"
|
if using_tool_fallback:
|
||||||
|
raise OperatorNoProgressError(
|
||||||
|
f"首选模型连续 {no_progress_limit} 次、兜底模型连续 "
|
||||||
|
f"{no_progress_attempts} 次未提供可执行的工具调用,"
|
||||||
|
"工具参数校验仍失败;已停止任务。"
|
||||||
|
)
|
||||||
|
active_llm = get_llm(ctx=self.ctx, name="operator", use_fallback=True)
|
||||||
|
using_tool_fallback = True
|
||||||
|
no_progress_attempts = 0
|
||||||
|
logger.warning("Operator 首选模型工具调用校验失败,当前步骤切换到兜底模型")
|
||||||
|
current_messages.append(
|
||||||
|
SystemMessage(
|
||||||
|
content="本步骤已切换兜底模型。请修正上面的工具参数后再调用。"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
continue
|
||||||
logger.warning("Some action calls failed validation. Feeding back to LLM.")
|
logger.warning("Some action calls failed validation. Feeding back to LLM.")
|
||||||
continue
|
continue
|
||||||
no_progress_attempts = 0
|
no_progress_attempts = 0
|
||||||
|
|||||||
@@ -136,9 +136,14 @@ async def capture_screenshot_and_parse_ui(
|
|||||||
Thin adapter over :func:`artemis.mcp.observation.observe` that adds the LangGraph
|
Thin adapter over :func:`artemis.mcp.observation.observe` that adds the LangGraph
|
||||||
``State`` write-back (indexed points/elements) the agents rely on.
|
``State`` write-back (indexed points/elements) the agents rely on.
|
||||||
"""
|
"""
|
||||||
obs, screenshot_bytes = await observe(ctx, controller, settle_ms=0 if skip_settling else 400)
|
from artemis.mcp.observation import require_observation
|
||||||
if not obs.ok:
|
|
||||||
return None, None, None
|
state.indexed_points = []
|
||||||
|
state.indexed_elements = []
|
||||||
|
state.latest_screenshot = None
|
||||||
|
obs, screenshot_bytes = await require_observation(
|
||||||
|
ctx, controller, settle_ms=0 if skip_settling else 400
|
||||||
|
)
|
||||||
|
|
||||||
if obs.hierarchy_ok:
|
if obs.hierarchy_ok:
|
||||||
state.indexed_points = [el["center"] for el in obs.elements]
|
state.indexed_points = [el["center"] for el in obs.elements]
|
||||||
|
|||||||
@@ -370,6 +370,11 @@ class StepSummarizerConfig(BaseModel):
|
|||||||
default_factory=lambda: settings.OPENAI_MODEL,
|
default_factory=lambda: settings.OPENAI_MODEL,
|
||||||
description="Lightweight model used for background step state summarization.",
|
description="Lightweight model used for background step state summarization.",
|
||||||
)
|
)
|
||||||
|
timeout_seconds: float = Field(
|
||||||
|
default=120.0,
|
||||||
|
gt=0,
|
||||||
|
description="Timeout for one optional background visual-summary model request.",
|
||||||
|
)
|
||||||
prune_history_xml: bool = Field(
|
prune_history_xml: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
description="Whether to prune outdated heavy UI XML trees from historical turns.",
|
description="Whether to prune outdated heavy UI XML trees from historical turns.",
|
||||||
@@ -830,6 +835,13 @@ class ProProfileConfig(BaseModel):
|
|||||||
model_config = {"extra": "allow"}
|
model_config = {"extra": "allow"}
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenshotConfig(BaseModel):
|
||||||
|
"""Limit screenshots sent to agents while retaining physical device coordinates."""
|
||||||
|
|
||||||
|
enabled: bool = Field(default_factory=lambda: settings.ARTEMIS_SCREENSHOT_LIMIT_ENABLED)
|
||||||
|
max_long_edge: int = Field(default=2048, ge=256, le=8192)
|
||||||
|
|
||||||
|
|
||||||
class AgentGlobalConfig(BaseModel):
|
class AgentGlobalConfig(BaseModel):
|
||||||
"""Global configuration parsed from artemis.jsonc / agent_config.json."""
|
"""Global configuration parsed from artemis.jsonc / agent_config.json."""
|
||||||
|
|
||||||
@@ -884,6 +896,10 @@ class AgentGlobalConfig(BaseModel):
|
|||||||
default_factory=MemoryConfig,
|
default_factory=MemoryConfig,
|
||||||
description="Unified step-memory runtime and transcript scrub options.",
|
description="Unified step-memory runtime and transcript scrub options.",
|
||||||
)
|
)
|
||||||
|
screenshot: ScreenshotConfig = Field(
|
||||||
|
default_factory=ScreenshotConfig,
|
||||||
|
description="Agent screenshot size limit; device/UI coordinates remain native.",
|
||||||
|
)
|
||||||
|
|
||||||
model_config = {"extra": "allow"}
|
model_config = {"extra": "allow"}
|
||||||
|
|
||||||
|
|||||||
@@ -100,9 +100,15 @@ class Settings(BaseSettings):
|
|||||||
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
|
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
|
||||||
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
|
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
|
||||||
OPENAI_RETRY_INTERVAL_SECONDS: float = Field(default=10.0, ge=0, le=300)
|
OPENAI_RETRY_INTERVAL_SECONDS: float = Field(default=10.0, ge=0, le=300)
|
||||||
|
OPENAI_RATE_LIMIT_RETRY_SECONDS: float = Field(default=60.0, ge=0, le=3600)
|
||||||
|
OPENAI_TOOL_FALLBACK_MODEL: str = Field(default="gpt-6-luna", min_length=1)
|
||||||
|
OPENAI_TOOL_FALLBACK_REASONING_EFFORT: str = "high"
|
||||||
|
OPENAI_TOOL_FALLBACK_MAX_RETRIES: int = Field(default=3, ge=0, le=10)
|
||||||
|
ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=10)
|
||||||
OPENAI_REASONING_EFFORT: str = "high"
|
OPENAI_REASONING_EFFORT: str = "high"
|
||||||
ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS: float = Field(default=12.0, gt=0)
|
ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS: float = Field(default=12.0, gt=0)
|
||||||
ARTEMIS_MODEL_CACHE_TTL_SECONDS: float = Field(default=10.0, ge=0)
|
ARTEMIS_MODEL_CACHE_TTL_SECONDS: float = Field(default=10.0, ge=0)
|
||||||
|
ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS: float = Field(default=2.0, gt=0)
|
||||||
ARTEMIS_VIDEO_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0)
|
ARTEMIS_VIDEO_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0)
|
||||||
ARTEMIS_VIDEO_MAX_RETRIES: int = Field(default=1, ge=0)
|
ARTEMIS_VIDEO_MAX_RETRIES: int = Field(default=1, ge=0)
|
||||||
ARTEMIS_VIDEO_CHUNK_SECONDS: float = Field(default=60.0, gt=0)
|
ARTEMIS_VIDEO_CHUNK_SECONDS: float = Field(default=60.0, gt=0)
|
||||||
@@ -112,8 +118,17 @@ class Settings(BaseSettings):
|
|||||||
ARTEMIS_RECORDING_ENABLED: bool = False
|
ARTEMIS_RECORDING_ENABLED: bool = False
|
||||||
ARTEMIS_OUTPUTTER_ENABLED: bool = True
|
ARTEMIS_OUTPUTTER_ENABLED: bool = True
|
||||||
ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True
|
ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True
|
||||||
|
ARTEMIS_SCREENSHOT_LIMIT_ENABLED: bool = True
|
||||||
# Consecutive Pro responses without a valid tool call before failing the task.
|
# Consecutive Pro responses without a valid tool call before failing the task.
|
||||||
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
|
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
|
||||||
|
# Consecutive Flash responses without any tool call before failing the task.
|
||||||
|
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
|
||||||
|
ARTEMIS_TASK_TIMEOUT_SECONDS: int = Field(default=1800, ge=30, le=86400)
|
||||||
|
ARTEMIS_OBSERVATION_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=300)
|
||||||
|
ARTEMIS_OBSERVATION_MAX_ATTEMPTS: int = Field(default=3, ge=1, le=5)
|
||||||
|
ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS: float = Field(default=10, gt=0, le=60)
|
||||||
|
ARTEMIS_ACTION_FAILURE_ATTEMPTS: int = Field(default=3, ge=1, le=10)
|
||||||
|
ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS: float = Field(default=30, gt=0, le=300)
|
||||||
|
|
||||||
# Android ADB Connectivity
|
# Android ADB Connectivity
|
||||||
ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST)
|
ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST)
|
||||||
|
|||||||
@@ -1158,11 +1158,21 @@ class StorageManager:
|
|||||||
"""Delete all data associated with a session, including files on disk."""
|
"""Delete all data associated with a session, including files on disk."""
|
||||||
session_id_str = str(session_id)
|
session_id_str = str(session_id)
|
||||||
|
|
||||||
# 1. Get video paths before deleting from DB
|
# 1. Get files referenced by this session before deleting from DB.
|
||||||
video_paths = []
|
video_paths = []
|
||||||
video_ids: list[str] = []
|
video_ids: list[str] = []
|
||||||
|
image_names: set[str] = set()
|
||||||
try:
|
try:
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
|
image_names = {
|
||||||
|
str(name)
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT pre_image_name, post_image_name FROM steps WHERE session_id = ?",
|
||||||
|
(session_id_str,),
|
||||||
|
)
|
||||||
|
for name in row
|
||||||
|
if name
|
||||||
|
}
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"SELECT video_id, local_video_path FROM video_recordings WHERE session_id = ?",
|
"SELECT video_id, local_video_path FROM video_recordings WHERE session_id = ?",
|
||||||
(session_id_str,),
|
(session_id_str,),
|
||||||
@@ -1173,9 +1183,11 @@ class StorageManager:
|
|||||||
if row["local_video_path"]:
|
if row["local_video_path"]:
|
||||||
video_paths.append(Path(row["local_video_path"]))
|
video_paths.append(Path(row["local_video_path"]))
|
||||||
except sqlite3.OperationalError as e:
|
except sqlite3.OperationalError as e:
|
||||||
logger.warning(f"Could not query video_recordings: {e}")
|
logger.warning(f"Could not query session media: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
# 2. Delete from DB tables
|
# 2. Delete from DB tables
|
||||||
|
orphaned_images: list[str] = []
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
conn.execute("PRAGMA foreign_keys = OFF")
|
conn.execute("PRAGMA foreign_keys = OFF")
|
||||||
board_keys = [f"session:{session_id_str}"] + [
|
board_keys = [f"session:{session_id_str}"] + [
|
||||||
@@ -1207,13 +1219,26 @@ class StorageManager:
|
|||||||
(session_id_str,),
|
(session_id_str,),
|
||||||
)
|
)
|
||||||
except sqlite3.OperationalError as e:
|
except sqlite3.OperationalError as e:
|
||||||
logger.warning(f"Failed to delete from table {table}: {e}")
|
logger.error(f"Failed to delete from table {table}: {e}")
|
||||||
conn.commit()
|
raise
|
||||||
conn.execute("VACUUM")
|
for name in image_names:
|
||||||
|
if not conn.execute(
|
||||||
|
"SELECT 1 FROM steps WHERE pre_image_name=? OR post_image_name=? LIMIT 1",
|
||||||
|
(name, name),
|
||||||
|
).fetchone():
|
||||||
|
conn.execute("DELETE FROM images WHERE image_name=?", (name,))
|
||||||
|
orphaned_images.append(name)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
logger.info(f"Database records for session {session_id} cleared.")
|
logger.info(f"Database records for session {session_id} cleared.")
|
||||||
|
|
||||||
|
images_root = (self.base_trace_dir / "images").resolve()
|
||||||
|
for name in orphaned_images:
|
||||||
|
if len(name) == 64 and all(c in "0123456789abcdef" for c in name):
|
||||||
|
image_path = (images_root / f"{name}.jpg").resolve()
|
||||||
|
if image_path.is_relative_to(images_root):
|
||||||
|
image_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
# 3. Delete session directory (notes, etc.)
|
# 3. Delete session directory (notes, etc.)
|
||||||
session_dir = self.base_trace_dir / session_id_str
|
session_dir = self.base_trace_dir / session_id_str
|
||||||
if session_dir.exists() and session_dir.is_dir():
|
if session_dir.exists() and session_dir.is_dir():
|
||||||
@@ -1222,38 +1247,15 @@ class StorageManager:
|
|||||||
logger.info(f"Deleted session directory: {session_dir}")
|
logger.info(f"Deleted session directory: {session_dir}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete session directory {session_dir}: {e}")
|
logger.error(f"Failed to delete session directory {session_dir}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
# 4. Delete video files and their containing folders (which might have been renamed)
|
# 4. Never let a persisted recording path remove a sibling session.
|
||||||
|
trace_root = self.base_trace_dir.resolve()
|
||||||
for video_path in video_paths:
|
for video_path in video_paths:
|
||||||
# video_path is likely: /path/to/traces/task_name/recording.mp4
|
resolved = video_path.resolve()
|
||||||
# If it was renamed, it might be: /path/to/traces/task_name_PASS_timestamp/recording.mp4
|
if not resolved.is_relative_to(trace_root):
|
||||||
|
continue
|
||||||
task_name = video_path.parent.name
|
relative = resolved.relative_to(trace_root)
|
||||||
|
if not relative.parts or relative.parts[0] != session_id_str:
|
||||||
if task_name and task_name != "traces" and task_name != "..":
|
continue
|
||||||
# Search for directories starting with task_name in base_trace_dir
|
resolved.unlink(missing_ok=True)
|
||||||
try:
|
|
||||||
for path in self.base_trace_dir.iterdir():
|
|
||||||
if path.is_dir() and path.name.startswith(task_name):
|
|
||||||
try:
|
|
||||||
shutil.rmtree(path)
|
|
||||||
logger.info(f"Deleted trace/video directory: {path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete directory {path}: {e}")
|
|
||||||
elif path.is_file() and path.name.startswith(task_name):
|
|
||||||
try:
|
|
||||||
path.unlink()
|
|
||||||
logger.info(f"Deleted trace/video file: {path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete file {path}: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error iterating base_trace_dir for cleanup: {e}")
|
|
||||||
|
|
||||||
if video_path.exists():
|
|
||||||
try:
|
|
||||||
video_path.unlink()
|
|
||||||
logger.info(f"Deleted video file directly: {video_path}")
|
|
||||||
if video_path.parent.exists() and not any(video_path.parent.iterdir()):
|
|
||||||
video_path.parent.rmdir()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete video file directly {video_path}: {e}")
|
|
||||||
@@ -19,18 +19,22 @@ import base64
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
from adbutils import AdbClient, AdbDevice
|
from adbutils import AdbClient, AdbDevice
|
||||||
from artemis.clients.ui_automator_client import (
|
from artemis.clients.ui_automator_client import (
|
||||||
UIAutomatorClient,
|
UIAutomatorClient,
|
||||||
_parse_hierarchy_xml_to_elements,
|
_parse_hierarchy_xml_to_elements,
|
||||||
)
|
)
|
||||||
|
from artemis.config.agent import load_agent_config
|
||||||
|
from artemis.config.settings import settings
|
||||||
from artemis.config.paths import get_temp_dir
|
from artemis.config.paths import get_temp_dir
|
||||||
from artemis.drivers.base import BaseDeviceDriver, KeyCode, ScreenData, SwipeDirection
|
from artemis.drivers.base import BaseDeviceDriver, KeyCode, ScreenData, SwipeDirection
|
||||||
from artemis.toolchain import find_ffmpeg, find_scrcpy
|
from artemis.toolchain import find_ffmpeg, find_scrcpy
|
||||||
from artemis.utils.video import build_scrcpy_record_command
|
from artemis.utils.video import build_scrcpy_record_command
|
||||||
from artemis.utils.ui_filter import filter_ui_hierarchy
|
from artemis.utils.ui_filter import filter_ui_hierarchy
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
|
from artemis.utils.screenshot_limit import limit_screenshot_long_edge
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -88,6 +92,8 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
self._device: AdbDevice | None = None
|
self._device: AdbDevice | None = None
|
||||||
self._recording_process: asyncio.subprocess.Process | None = None
|
self._recording_process: asyncio.subprocess.Process | None = None
|
||||||
self._recording_output_path: Path | None = None
|
self._recording_output_path: Path | None = None
|
||||||
|
self._screenshot_config = load_agent_config().screenshot
|
||||||
|
self._screen_capture_task: asyncio.Task[ScreenData] | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_id(self) -> str:
|
def device_id(self) -> str:
|
||||||
@@ -119,6 +125,24 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
await asyncio.to_thread(self._ui_adb_client.disconnect)
|
await asyncio.to_thread(self._ui_adb_client.disconnect)
|
||||||
|
|
||||||
async def get_screen_data(self, skip_settling: bool = False) -> ScreenData:
|
async def get_screen_data(self, skip_settling: bool = False) -> ScreenData:
|
||||||
|
"""Bound observation latency and share unfinished capture work across retries."""
|
||||||
|
task = self._screen_capture_task
|
||||||
|
if task is None or task.done():
|
||||||
|
task = asyncio.create_task(self._get_screen_data(skip_settling))
|
||||||
|
# A timed-out caller does not cancel a blocking SDK thread or start
|
||||||
|
# another concurrent read on the same device. Retrieve late errors.
|
||||||
|
task.add_done_callback(lambda done: None if done.cancelled() else done.exception())
|
||||||
|
self._screen_capture_task = task
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(
|
||||||
|
asyncio.shield(task), timeout=settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
|
except TimeoutError as exc:
|
||||||
|
raise TimeoutError(
|
||||||
|
f"设备屏幕观测超时({settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS:g} 秒)"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def _get_screen_data(self, skip_settling: bool = False) -> ScreenData:
|
||||||
"""Captures screenshot and XML hierarchy concurrently."""
|
"""Captures screenshot and XML hierarchy concurrently."""
|
||||||
if not skip_settling:
|
if not skip_settling:
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
@@ -128,11 +152,14 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
screenshot_base64: str = ""
|
screenshot_base64: str = ""
|
||||||
ui_hierarchy_xml: str | None = None
|
ui_hierarchy_xml: str | None = None
|
||||||
ui_elements: list[dict[str, Any]] = []
|
ui_elements: list[dict[str, Any]] = []
|
||||||
|
capture_errors: list[str] = []
|
||||||
|
|
||||||
# Check if ui_adb_client has direct screen data support
|
# Check if ui_adb_client has direct screen data support
|
||||||
if self._ui_adb_client and hasattr(self._ui_adb_client, "get_screen_data"):
|
if self._ui_adb_client and hasattr(self._ui_adb_client, "get_screen_data"):
|
||||||
try:
|
try:
|
||||||
raw_ui_data = self._ui_adb_client.get_screen_data()
|
raw_ui_data = await asyncio.to_thread(self._ui_adb_client.get_screen_data)
|
||||||
|
if asyncio.iscoroutine(raw_ui_data):
|
||||||
|
raw_ui_data = await raw_ui_data
|
||||||
if (
|
if (
|
||||||
hasattr(raw_ui_data, "base64")
|
hasattr(raw_ui_data, "base64")
|
||||||
and isinstance(raw_ui_data.base64, str)
|
and isinstance(raw_ui_data.base64, str)
|
||||||
@@ -155,11 +182,18 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
elif isinstance(raw_elements, list):
|
elif isinstance(raw_elements, list):
|
||||||
ui_elements = raw_elements
|
ui_elements = raw_elements
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
capture_errors.append(str(e))
|
||||||
logger.debug(f"Direct ui_adb_client.get_screen_data error: {e}")
|
logger.debug(f"Direct ui_adb_client.get_screen_data error: {e}")
|
||||||
|
|
||||||
if not screenshot_base64 or not isinstance(screenshot_base64, str):
|
if not screenshot_base64 or not isinstance(screenshot_base64, str):
|
||||||
try:
|
try:
|
||||||
pil_img = await asyncio.to_thread(self.device.screenshot)
|
raw_image = await asyncio.to_thread(
|
||||||
|
self.device.shell,
|
||||||
|
["screencap", "-p"],
|
||||||
|
encoding=None,
|
||||||
|
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
pil_img = Image.open(BytesIO(raw_image))
|
||||||
if hasattr(pil_img, "save"):
|
if hasattr(pil_img, "save"):
|
||||||
buf = BytesIO()
|
buf = BytesIO()
|
||||||
pil_img.save(buf, format="PNG")
|
pil_img.save(buf, format="PNG")
|
||||||
@@ -167,18 +201,24 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
self._width, self._height = pil_img.size
|
self._width, self._height = pil_img.size
|
||||||
screenshot_base64 = base64.b64encode(screenshot_bytes).decode("utf-8")
|
screenshot_base64 = base64.b64encode(screenshot_bytes).decode("utf-8")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
capture_errors.append(str(e))
|
||||||
logger.warning(f"Device screenshot capture failed on {self._device_id}: {e}")
|
logger.warning(f"Device screenshot capture failed on {self._device_id}: {e}")
|
||||||
|
|
||||||
if not screenshot_base64 or not isinstance(screenshot_base64, str):
|
if not screenshot_base64 or not isinstance(screenshot_base64, str):
|
||||||
# Fallback for headless testing environments
|
raise RuntimeError(
|
||||||
screenshot_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
"设备截图失败:ADB 与屏幕辅助服务均未返回有效截图;" + "; ".join(capture_errors)
|
||||||
screenshot_bytes = base64.b64decode(screenshot_base64)
|
)
|
||||||
|
|
||||||
|
with Image.open(BytesIO(screenshot_bytes)) as image:
|
||||||
|
if min(image.size) <= 1:
|
||||||
|
raise RuntimeError("设备截图无效:收到占位图,禁止基于旧画面继续操作")
|
||||||
|
image.verify()
|
||||||
|
|
||||||
# 2. Capture UI Hierarchy via UIAutomator
|
# 2. Capture UI Hierarchy via UIAutomator
|
||||||
if self._ui_adb_client and not ui_elements:
|
if self._ui_adb_client and not ui_elements:
|
||||||
try:
|
try:
|
||||||
if hasattr(self._ui_adb_client, "get_hierarchy"):
|
if hasattr(self._ui_adb_client, "get_hierarchy"):
|
||||||
res = self._ui_adb_client.get_hierarchy()
|
res = await asyncio.to_thread(self._ui_adb_client.get_hierarchy)
|
||||||
hierarchy = await res if asyncio.iscoroutine(res) else res
|
hierarchy = await res if asyncio.iscoroutine(res) else res
|
||||||
if isinstance(hierarchy, str):
|
if isinstance(hierarchy, str):
|
||||||
ui_hierarchy_xml = hierarchy
|
ui_hierarchy_xml = hierarchy
|
||||||
@@ -186,7 +226,7 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
else:
|
else:
|
||||||
ui_elements = hierarchy
|
ui_elements = hierarchy
|
||||||
elif hasattr(self._ui_adb_client, "get_ui_elements"):
|
elif hasattr(self._ui_adb_client, "get_ui_elements"):
|
||||||
res = self._ui_adb_client.get_ui_elements()
|
res = await asyncio.to_thread(self._ui_adb_client.get_ui_elements)
|
||||||
ui_elements = await res if asyncio.iscoroutine(res) else res
|
ui_elements = await res if asyncio.iscoroutine(res) else res
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"UI hierarchy extraction failed: {e}")
|
logger.debug(f"UI hierarchy extraction failed: {e}")
|
||||||
@@ -205,16 +245,15 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
screen_width=w,
|
screen_width=w,
|
||||||
screen_height=h,
|
screen_height=h,
|
||||||
)
|
)
|
||||||
clean_b64 = (
|
clean_b64 = screenshot_base64
|
||||||
screenshot_base64
|
clean_bytes = screenshot_bytes
|
||||||
if isinstance(screenshot_base64, str) and screenshot_base64
|
if self._screenshot_config.enabled:
|
||||||
else "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
limited_bytes = limit_screenshot_long_edge(
|
||||||
)
|
clean_bytes, self._screenshot_config.max_long_edge
|
||||||
clean_bytes = (
|
)
|
||||||
screenshot_bytes
|
if limited_bytes is not clean_bytes:
|
||||||
if isinstance(screenshot_bytes, bytes) and screenshot_bytes
|
clean_bytes = limited_bytes
|
||||||
else base64.b64decode(clean_b64)
|
clean_b64 = base64.b64encode(clean_bytes).decode("utf-8")
|
||||||
)
|
|
||||||
|
|
||||||
return ScreenData(
|
return ScreenData(
|
||||||
screenshot_bytes=clean_bytes,
|
screenshot_bytes=clean_bytes,
|
||||||
@@ -245,7 +284,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
cmd = f" && sleep {delay_ms / 1000.0:.3f} && ".join(taps)
|
cmd = f" && sleep {delay_ms / 1000.0:.3f} && ".join(taps)
|
||||||
|
|
||||||
logger.info(f"[ADB] {cmd}")
|
logger.info(f"[ADB] {cmd}")
|
||||||
await asyncio.to_thread(self.device.shell, cmd)
|
await asyncio.to_thread(
|
||||||
|
self.device.shell, cmd, timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Tap failed at ({x}, {y}): {e}")
|
logger.error(f"Tap failed at ({x}, {y}): {e}")
|
||||||
@@ -265,7 +306,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
try:
|
try:
|
||||||
cmd = f"input swipe {start_x} {start_y} {end_x} {end_y} {duration_ms}"
|
cmd = f"input swipe {start_x} {start_y} {end_x} {end_y} {duration_ms}"
|
||||||
logger.info(f"[ADB] {cmd}")
|
logger.info(f"[ADB] {cmd}")
|
||||||
await asyncio.to_thread(self.device.shell, cmd)
|
await asyncio.to_thread(
|
||||||
|
self.device.shell, cmd, timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Swipe failed from ({start_x},{start_y}) to ({end_x},{end_y}): {e}")
|
logger.error(f"Swipe failed from ({start_x},{start_y}) to ({end_x},{end_y}): {e}")
|
||||||
@@ -314,7 +357,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
"input keyevent 67 && "
|
"input keyevent 67 && "
|
||||||
"input keyevent 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67"
|
"input keyevent 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67 67"
|
||||||
)
|
)
|
||||||
await asyncio.to_thread(self.device.shell, clear_cmd)
|
await asyncio.to_thread(
|
||||||
|
self.device.shell,
|
||||||
|
clear_cmd,
|
||||||
|
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Append mode: move cursor to the very end of existing text
|
# Append mode: move cursor to the very end of existing text
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
@@ -336,7 +383,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
set_clip_ok = True
|
set_clip_ok = True
|
||||||
|
|
||||||
if set_clip_ok:
|
if set_clip_ok:
|
||||||
await asyncio.to_thread(self.device.shell, "input keyevent 279")
|
await asyncio.to_thread(
|
||||||
|
self.device.shell,
|
||||||
|
"input keyevent 279",
|
||||||
|
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Clipboard paste fallback to ADB input: {e}")
|
logger.debug(f"Clipboard paste fallback to ADB input: {e}")
|
||||||
@@ -349,7 +400,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
if "adbkeyboard" in str(default_ime).lower():
|
if "adbkeyboard" in str(default_ime).lower():
|
||||||
b64_text = base64.b64encode(norm_text.encode("utf-8")).decode("utf-8")
|
b64_text = base64.b64encode(norm_text.encode("utf-8")).decode("utf-8")
|
||||||
broadcast_cmd = f"am broadcast -a ADB_INPUT_B64 --es msg '{b64_text}'"
|
broadcast_cmd = f"am broadcast -a ADB_INPUT_B64 --es msg '{b64_text}'"
|
||||||
await asyncio.to_thread(self.device.shell, broadcast_cmd)
|
await asyncio.to_thread(
|
||||||
|
self.device.shell,
|
||||||
|
broadcast_cmd,
|
||||||
|
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# ADBKeyboard probe/broadcast failed; fall through to native input.
|
# ADBKeyboard probe/broadcast failed; fall through to native input.
|
||||||
@@ -360,10 +415,18 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
if i > 0:
|
if i > 0:
|
||||||
# Send Enter key between lines
|
# Send Enter key between lines
|
||||||
await asyncio.to_thread(self.device.shell, "input keyevent 66")
|
await asyncio.to_thread(
|
||||||
|
self.device.shell,
|
||||||
|
"input keyevent 66",
|
||||||
|
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
if line:
|
if line:
|
||||||
escaped = _escape_for_adb_text(line)
|
escaped = _escape_for_adb_text(line)
|
||||||
await asyncio.to_thread(self.device.shell, f"input text {escaped}")
|
await asyncio.to_thread(
|
||||||
|
self.device.shell,
|
||||||
|
f"input text {escaped}",
|
||||||
|
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Input text failed for '{text}': {e}")
|
logger.error(f"Input text failed for '{text}': {e}")
|
||||||
@@ -376,7 +439,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
key_name = str(key).lower().replace("keycode.", "")
|
key_name = str(key).lower().replace("keycode.", "")
|
||||||
keycode_val = ANDROID_KEYCODE_MAP.get(key_name, key)
|
keycode_val = ANDROID_KEYCODE_MAP.get(key_name, key)
|
||||||
|
|
||||||
await asyncio.to_thread(self.device.shell, f"input keyevent {keycode_val}")
|
await asyncio.to_thread(
|
||||||
|
self.device.shell,
|
||||||
|
f"input keyevent {keycode_val}",
|
||||||
|
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Press key failed for '{key}': {e}")
|
logger.error(f"Press key failed for '{key}': {e}")
|
||||||
@@ -385,7 +452,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
async def launch_app(self, package_name: str) -> bool:
|
async def launch_app(self, package_name: str) -> bool:
|
||||||
try:
|
try:
|
||||||
cmd = f"monkey -p {package_name} -c android.intent.category.LAUNCHER 1"
|
cmd = f"monkey -p {package_name} -c android.intent.category.LAUNCHER 1"
|
||||||
await asyncio.to_thread(self.device.shell, cmd)
|
await asyncio.to_thread(
|
||||||
|
self.device.shell, cmd, timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Launch app failed for '{package_name}': {e}")
|
logger.error(f"Launch app failed for '{package_name}': {e}")
|
||||||
@@ -393,7 +462,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
|
|
||||||
async def stop_app(self, package_name: str) -> bool:
|
async def stop_app(self, package_name: str) -> bool:
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(self.device.shell, f"am force-stop {package_name}")
|
await asyncio.to_thread(
|
||||||
|
self.device.shell,
|
||||||
|
f"am force-stop {package_name}",
|
||||||
|
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Stop app failed for '{package_name}': {e}")
|
logger.error(f"Stop app failed for '{package_name}': {e}")
|
||||||
@@ -429,7 +502,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
|
|||||||
async def execute_shell(self, command: str, timeout_seconds: float = 15.0) -> str:
|
async def execute_shell(self, command: str, timeout_seconds: float = 15.0) -> str:
|
||||||
try:
|
try:
|
||||||
return await asyncio.wait_for(
|
return await asyncio.wait_for(
|
||||||
asyncio.to_thread(self.device.shell, command),
|
asyncio.to_thread(
|
||||||
|
self.device.shell,
|
||||||
|
command,
|
||||||
|
timeout=settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS,
|
||||||
|
),
|
||||||
timeout=timeout_seconds,
|
timeout=timeout_seconds,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -117,6 +117,11 @@ def init_command() -> None:
|
|||||||
"OPENAI_TIMEOUT_SECONDS=300",
|
"OPENAI_TIMEOUT_SECONDS=300",
|
||||||
"OPENAI_MAX_RETRIES=5",
|
"OPENAI_MAX_RETRIES=5",
|
||||||
"OPENAI_RETRY_INTERVAL_SECONDS=10",
|
"OPENAI_RETRY_INTERVAL_SECONDS=10",
|
||||||
|
"OPENAI_RATE_LIMIT_RETRY_SECONDS=60",
|
||||||
|
"OPENAI_TOOL_FALLBACK_MODEL=gpt-6-luna",
|
||||||
|
"OPENAI_TOOL_FALLBACK_REASONING_EFFORT=high",
|
||||||
|
"OPENAI_TOOL_FALLBACK_MAX_RETRIES=3",
|
||||||
|
"ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS=3",
|
||||||
"ARTEMIS_VIDEO_ENABLED=false",
|
"ARTEMIS_VIDEO_ENABLED=false",
|
||||||
"ARTEMIS_TRACES_DIR=./traces",
|
"ARTEMIS_TRACES_DIR=./traces",
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -51,212 +51,28 @@ ARTEMIS_MCP_TOOLS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _eager_tools_config() -> dict[str, dict[str, bool]]:
|
|
||||||
"""Return the eager-loading extension used by clients that support it."""
|
|
||||||
return {tool_name: {"eager": True} for tool_name in ARTEMIS_MCP_TOOLS}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_vscode_user_dir() -> Path:
|
|
||||||
"""Returns the platform-specific VS Code user configuration directory."""
|
|
||||||
if sys.platform == "darwin":
|
|
||||||
return Path.home() / "Library" / "Application Support" / "Code" / "User"
|
|
||||||
elif sys.platform == "win32":
|
|
||||||
appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
|
||||||
return Path(appdata) / "Code" / "User"
|
|
||||||
else:
|
|
||||||
return Path.home() / ".config" / "Code" / "User"
|
|
||||||
|
|
||||||
|
|
||||||
def _get_config_snippet(client: str, python_exe: str, project_root: str) -> dict:
|
def _get_config_snippet(client: str, python_exe: str, project_root: str) -> dict:
|
||||||
"""Generates MCP configuration dictionary for the specified client."""
|
"""Generate the supported Codex MCP configuration."""
|
||||||
config_body = {
|
if client != "codex":
|
||||||
"command": python_exe,
|
raise ValueError("Only Codex MCP configuration is supported")
|
||||||
"args": ["-m", "mcp_server"],
|
return {
|
||||||
"env": {
|
"mcp_servers": {
|
||||||
"PYTHONUNBUFFERED": "1",
|
"artemis": {
|
||||||
"PYTHONPATH": project_root,
|
"command": python_exe,
|
||||||
"ARTEMIS_DESKTOP_NOTIFY": "true",
|
"args": ["-m", "mcp_server"],
|
||||||
},
|
"cwd": project_root,
|
||||||
|
"env": {
|
||||||
|
"PYTHONUNBUFFERED": "1",
|
||||||
|
"PYTHONPATH": project_root,
|
||||||
|
"ARTEMIS_DESKTOP_NOTIFY": "true",
|
||||||
|
},
|
||||||
|
"enabled": True,
|
||||||
|
"required": True,
|
||||||
|
"startup_timeout_sec": 120,
|
||||||
|
"enabled_tools": list(ARTEMIS_MCP_TOOLS),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
config_with_cwd = {**config_body, "cwd": project_root}
|
|
||||||
|
|
||||||
if client == "antigravity":
|
|
||||||
return {
|
|
||||||
"mcpServers": {
|
|
||||||
"artemis": {
|
|
||||||
**config_with_cwd,
|
|
||||||
# This is the documented way to expose every tool. Avoid
|
|
||||||
# proprietary eager fields in the current config so a
|
|
||||||
# strict parser can always load the server.
|
|
||||||
"disabledTools": [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
elif client == "jetski":
|
|
||||||
return {
|
|
||||||
"mcpServers": {
|
|
||||||
"artemis": {
|
|
||||||
**config_with_cwd,
|
|
||||||
"disabledTools": [],
|
|
||||||
# Retain the legacy Jetski eager extension only in its
|
|
||||||
# legacy config file. Current Antigravity gets the strict,
|
|
||||||
# documented configuration above as a safe fallback.
|
|
||||||
"tools": _eager_tools_config(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
elif client == "openclaw":
|
|
||||||
return {
|
|
||||||
"mcp": {
|
|
||||||
"servers": {
|
|
||||||
"artemis": {
|
|
||||||
**config_with_cwd,
|
|
||||||
"enabled": True,
|
|
||||||
"connectionTimeoutMs": 120000,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
elif client == "codex":
|
|
||||||
return {
|
|
||||||
"mcp_servers": {
|
|
||||||
"artemis": {
|
|
||||||
**config_with_cwd,
|
|
||||||
# Codex has no eager/deferred MCP setting. Requiring the
|
|
||||||
# server makes it initialize during host startup, while an
|
|
||||||
# explicit allow-list guarantees all Artemis tools are
|
|
||||||
# registered. The host may still present schemas through
|
|
||||||
# tool search to control context size.
|
|
||||||
"enabled": True,
|
|
||||||
"required": True,
|
|
||||||
"startup_timeout_sec": 120,
|
|
||||||
"enabled_tools": list(ARTEMIS_MCP_TOOLS),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
elif client == "vscode":
|
|
||||||
return {"servers": {"artemis": {**config_with_cwd, "type": "stdio"}}}
|
|
||||||
elif client in ("claude", "claude_code", "claude_desktop"):
|
|
||||||
return {"mcpServers": {"artemis": {**config_body, "type": "stdio"}}}
|
|
||||||
elif client == "cline":
|
|
||||||
return {
|
|
||||||
"mcpServers": {
|
|
||||||
"artemis": {
|
|
||||||
**config_body,
|
|
||||||
"disabled": False,
|
|
||||||
"autoApprove": [],
|
|
||||||
"timeout": 120,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
elif client in ("roo", "roo_code"):
|
|
||||||
return {
|
|
||||||
"mcpServers": {
|
|
||||||
"artemis": {
|
|
||||||
**config_body,
|
|
||||||
"disabled": False,
|
|
||||||
"alwaysAllow": [],
|
|
||||||
"timeout": 120,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else: # cursor, windsurf, generic
|
|
||||||
return {"mcpServers": {"artemis": config_body}}
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_json_lenient(text: str) -> dict | None:
|
|
||||||
"""Parses JSON or JSONC (JSON with comments / trailing commas) safely.
|
|
||||||
|
|
||||||
Returns ``None`` when the text cannot be parsed as a JSON object, so callers
|
|
||||||
can tell a genuinely empty ``{}`` file apart from a corrupt one.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = json.loads(text)
|
|
||||||
return data if isinstance(data, dict) else None
|
|
||||||
except ValueError:
|
|
||||||
# Not strict JSON; fall through to the lenient JSONC cleanup below.
|
|
||||||
pass
|
|
||||||
# Remove // single-line comments
|
|
||||||
cleaned = re.sub(r"//.*", "", text)
|
|
||||||
# Remove /* ... */ multi-line comments
|
|
||||||
cleaned = re.sub(r"/\*.*?\*/", "", cleaned, flags=re.DOTALL)
|
|
||||||
# Remove trailing commas before } or ]
|
|
||||||
cleaned = re.sub(r",\s*([}\]])", r"\1", cleaned)
|
|
||||||
try:
|
|
||||||
data = json.loads(cleaned)
|
|
||||||
return data if isinstance(data, dict) else None
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_json_file(
|
|
||||||
file_path: Path,
|
|
||||||
server_name: str,
|
|
||||||
server_config: dict,
|
|
||||||
key_name: str | tuple[str, ...] = "mcpServers",
|
|
||||||
remove_paths: tuple[tuple[str, ...], ...] = (),
|
|
||||||
strict_parse: bool = False,
|
|
||||||
) -> bool:
|
|
||||||
"""Merges server configuration into a target JSON file without overwriting existing servers.
|
|
||||||
|
|
||||||
With ``strict_parse=True``, an existing non-empty file that cannot be parsed
|
|
||||||
aborts the merge instead of being replaced. Use this for files that hold
|
|
||||||
state beyond MCP config (e.g. ~/.claude.json), where a degraded rewrite
|
|
||||||
would destroy unrelated data.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
data: dict = {}
|
|
||||||
if file_path.exists() and file_path.stat().st_size > 0:
|
|
||||||
raw_text = file_path.read_text(encoding="utf-8")
|
|
||||||
parsed = _parse_json_lenient(raw_text)
|
|
||||||
data = parsed or {}
|
|
||||||
if parsed is None and raw_text.strip():
|
|
||||||
if strict_parse:
|
|
||||||
logger.warning(
|
|
||||||
f"Existing JSON in {file_path} could not be parsed; refusing to rewrite it. "
|
|
||||||
"Fix or remove the file, then re-run the install."
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
# Backup unparseable existing file to prevent accidental loss
|
|
||||||
backup_path = file_path.with_suffix(file_path.suffix + ".bak")
|
|
||||||
try:
|
|
||||||
backup_path.write_text(raw_text, encoding="utf-8")
|
|
||||||
logger.warning(
|
|
||||||
f"Existing JSON in {file_path} could not be parsed; backup created at {backup_path}"
|
|
||||||
)
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning(
|
|
||||||
f"Existing JSON in {file_path} could not be parsed AND the backup to"
|
|
||||||
f" {backup_path} failed ({exc}); refusing to rewrite it."
|
|
||||||
" Fix or remove the file, then re-run the install."
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
for remove_path in remove_paths:
|
|
||||||
parent = data
|
|
||||||
for part in remove_path[:-1]:
|
|
||||||
child = parent.get(part)
|
|
||||||
if not isinstance(child, dict):
|
|
||||||
parent = {}
|
|
||||||
break
|
|
||||||
parent = child
|
|
||||||
if parent:
|
|
||||||
parent.pop(remove_path[-1], None)
|
|
||||||
|
|
||||||
key_path = (key_name,) if isinstance(key_name, str) else key_name
|
|
||||||
target = data
|
|
||||||
for part in key_path:
|
|
||||||
if not isinstance(target.get(part), dict):
|
|
||||||
target[part] = {}
|
|
||||||
target = target[part]
|
|
||||||
|
|
||||||
target[server_name] = server_config
|
|
||||||
file_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not update MCP config file {file_path}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _codex_toml_block(server_config: dict) -> str:
|
def _codex_toml_block(server_config: dict) -> str:
|
||||||
@@ -302,7 +118,9 @@ def _merge_codex_toml(file_path: Path, server_config: dict) -> bool:
|
|||||||
managed_pattern = re.compile(
|
managed_pattern = re.compile(
|
||||||
rf"(?ms)^{re.escape(begin_marker)}\n.*?^{re.escape(end_marker)}\n?"
|
rf"(?ms)^{re.escape(begin_marker)}\n.*?^{re.escape(end_marker)}\n?"
|
||||||
)
|
)
|
||||||
updated = managed_pattern.sub(managed_block, existing, count=1)
|
# Windows paths contain backslashes; a replacement string would
|
||||||
|
# interpret them as regex escapes on a second installation.
|
||||||
|
updated = managed_pattern.sub(lambda _: managed_block, existing, count=1)
|
||||||
else:
|
else:
|
||||||
# Remove an older, unmanaged Artemis table before adding the managed block.
|
# Remove an older, unmanaged Artemis table before adding the managed block.
|
||||||
kept_lines: list[str] = []
|
kept_lines: list[str] = []
|
||||||
@@ -342,36 +160,6 @@ def _merge_codex_toml(file_path: Path, server_config: dict) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _remove_rules_block(file_path: Path) -> bool:
|
|
||||||
"""Removes a previously injected managed ARTEMIS block from a rules file.
|
|
||||||
|
|
||||||
Used to migrate clients that load both their main rules file and a
|
|
||||||
standalone rule file (e.g. Claude Code loads ~/.claude/CLAUDE.md AND
|
|
||||||
~/.claude/rules/*.md), where keeping the injected block would duplicate
|
|
||||||
the rules in the model's context. If the file contains nothing but the
|
|
||||||
managed block, the file is deleted entirely.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if not file_path.exists():
|
|
||||||
return True
|
|
||||||
begin_marker = "<!-- BEGIN ARTEMIS MOBILE TESTING RULES -->"
|
|
||||||
end_marker = "<!-- END ARTEMIS MOBILE TESTING RULES -->"
|
|
||||||
existing = file_path.read_text(encoding="utf-8")
|
|
||||||
if begin_marker not in existing or end_marker not in existing:
|
|
||||||
return True
|
|
||||||
prefix = existing.split(begin_marker)[0]
|
|
||||||
suffix = existing.split(end_marker)[1]
|
|
||||||
remainder = f"{prefix.rstrip()}\n\n{suffix.lstrip()}".strip()
|
|
||||||
if remainder:
|
|
||||||
file_path.write_text(remainder + "\n", encoding="utf-8")
|
|
||||||
else:
|
|
||||||
file_path.unlink()
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not remove rules block from {file_path}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _inject_rules_block(file_path: Path, content: str) -> bool:
|
def _inject_rules_block(file_path: Path, content: str) -> bool:
|
||||||
"""Injects or updates a managed ARTEMIS block in a global rules file without overwriting user content."""
|
"""Injects or updates a managed ARTEMIS block in a global rules file without overwriting user content."""
|
||||||
try:
|
try:
|
||||||
@@ -404,297 +192,36 @@ def _inject_rules_block(file_path: Path, content: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _write_rule_file(file_path: Path, content: str) -> bool:
|
|
||||||
"""Writes or overwrites a standalone rule file."""
|
|
||||||
try:
|
|
||||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
new_text = content.strip() + "\n"
|
|
||||||
if file_path.exists():
|
|
||||||
try:
|
|
||||||
if file_path.read_text(encoding="utf-8") == new_text:
|
|
||||||
return True
|
|
||||||
except (OSError, UnicodeDecodeError):
|
|
||||||
# Unreadable existing file: skip the no-op check and rewrite it.
|
|
||||||
pass
|
|
||||||
file_path.write_text(new_text, encoding="utf-8")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not write rule file {file_path}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _write_cursor_mdc(file_path: Path, content: str) -> bool:
|
|
||||||
"""Writes a Cursor .mdc rule file with required YAML frontmatter."""
|
|
||||||
try:
|
|
||||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
mdc_header = (
|
|
||||||
"---\n"
|
|
||||||
"description: Artemis Mobile Testing Mindset & Rules\n"
|
|
||||||
"globs: **/*\n"
|
|
||||||
"alwaysApply: true\n"
|
|
||||||
"---\n\n"
|
|
||||||
)
|
|
||||||
new_text = mdc_header + content.strip() + "\n"
|
|
||||||
if file_path.exists():
|
|
||||||
try:
|
|
||||||
if file_path.read_text(encoding="utf-8") == new_text:
|
|
||||||
return True
|
|
||||||
except (OSError, UnicodeDecodeError):
|
|
||||||
# Unreadable existing file: skip the no-op check and rewrite it.
|
|
||||||
pass
|
|
||||||
file_path.write_text(new_text, encoding="utf-8")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not write Cursor rule file {file_path}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def install_rules(client: str, project_root: str) -> list[str]:
|
def install_rules(client: str, project_root: str) -> list[str]:
|
||||||
"""Auto-installs ARTEMIS testing rules globally into user IDE rule directories across any OS."""
|
"""Install testing rules only into the Codex instructions file."""
|
||||||
installed_paths: list[str] = []
|
if client != "codex":
|
||||||
|
raise ValueError("Only Codex MCP configuration is supported")
|
||||||
rules_src = Path(project_root) / "mcp_server" / "rules.md"
|
rules_src = Path(project_root) / "mcp_server" / "rules.md"
|
||||||
if not rules_src.exists():
|
if not rules_src.exists():
|
||||||
alt_src = Path(__file__).resolve().parents[4] / "mcp_server" / "rules.md"
|
rules_src = Path(__file__).resolve().parents[4] / "mcp_server" / "rules.md"
|
||||||
if alt_src.exists():
|
|
||||||
rules_src = alt_src
|
|
||||||
else:
|
|
||||||
logger.warning(f"Could not locate rules.md at {rules_src}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw_rules = rules_src.read_text(encoding="utf-8").strip()
|
raw_rules = rules_src.read_text(encoding="utf-8").strip()
|
||||||
except Exception as e:
|
except OSError:
|
||||||
logger.warning(f"Could not read rules file {rules_src}: {e}")
|
logger.warning(f"Could not read rules file {rules_src}")
|
||||||
return []
|
return []
|
||||||
|
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
||||||
targets = (
|
override_file = codex_home / "AGENTS.override.md"
|
||||||
[
|
agents_file = (
|
||||||
"antigravity",
|
override_file
|
||||||
"cursor",
|
if override_file.exists() and override_file.read_text(encoding="utf-8").strip()
|
||||||
"claude",
|
else codex_home / "AGENTS.md"
|
||||||
"windsurf",
|
|
||||||
"vscode",
|
|
||||||
"cline",
|
|
||||||
"roo",
|
|
||||||
"openclaw",
|
|
||||||
"codex",
|
|
||||||
]
|
|
||||||
if client == "all"
|
|
||||||
else [client]
|
|
||||||
)
|
)
|
||||||
|
return [str(agents_file)] if _inject_rules_block(agents_file, raw_rules) else []
|
||||||
for target in targets:
|
|
||||||
if target in ("antigravity", "jetski"):
|
|
||||||
gemini_md = Path.home() / ".gemini" / "GEMINI.md"
|
|
||||||
global_rule = Path.home() / ".gemini" / "rules" / "artemis.md"
|
|
||||||
if _inject_rules_block(gemini_md, raw_rules):
|
|
||||||
installed_paths.append(str(gemini_md))
|
|
||||||
if _write_rule_file(global_rule, raw_rules):
|
|
||||||
installed_paths.append(str(global_rule))
|
|
||||||
elif target == "cursor":
|
|
||||||
cursor_rules_file = Path.home() / ".cursorrules"
|
|
||||||
global_mdc = Path.home() / ".cursor" / "rules" / "artemis.mdc"
|
|
||||||
if _inject_rules_block(cursor_rules_file, raw_rules):
|
|
||||||
installed_paths.append(str(cursor_rules_file))
|
|
||||||
if _write_cursor_mdc(global_mdc, raw_rules):
|
|
||||||
installed_paths.append(str(global_mdc))
|
|
||||||
elif target in ("claude", "claude_code"):
|
|
||||||
# Claude Code loads BOTH ~/.claude/CLAUDE.md and ~/.claude/rules/*.md
|
|
||||||
# into context every session, so install the rules in exactly one
|
|
||||||
# place (the standalone rule file) and migrate away any block a
|
|
||||||
# previous version injected into CLAUDE.md.
|
|
||||||
claude_md = Path.home() / ".claude" / "CLAUDE.md"
|
|
||||||
global_rule = Path.home() / ".claude" / "rules" / "artemis.md"
|
|
||||||
_remove_rules_block(claude_md)
|
|
||||||
if _write_rule_file(global_rule, raw_rules):
|
|
||||||
installed_paths.append(str(global_rule))
|
|
||||||
elif target == "claude_desktop":
|
|
||||||
# Claude Desktop only reads ~/.claude/CLAUDE.md (not rules/*.md), so
|
|
||||||
# the rules block stays injected there for this target.
|
|
||||||
claude_md = Path.home() / ".claude" / "CLAUDE.md"
|
|
||||||
if _inject_rules_block(claude_md, raw_rules):
|
|
||||||
installed_paths.append(str(claude_md))
|
|
||||||
elif target == "windsurf":
|
|
||||||
global_rule = Path.home() / ".codeium" / "windsurf" / "rules" / "artemis.md"
|
|
||||||
global_mem = Path.home() / ".codeium" / "windsurf" / "memories" / "global_rules.md"
|
|
||||||
if _inject_rules_block(global_mem, raw_rules):
|
|
||||||
installed_paths.append(str(global_mem))
|
|
||||||
if _write_rule_file(global_rule, raw_rules):
|
|
||||||
installed_paths.append(str(global_rule))
|
|
||||||
elif target == "vscode":
|
|
||||||
global_rule = Path.home() / ".vscode" / "rules" / "artemis.md"
|
|
||||||
user_rule = _get_vscode_user_dir() / "rules" / "artemis.md"
|
|
||||||
if _write_rule_file(global_rule, raw_rules):
|
|
||||||
installed_paths.append(str(global_rule))
|
|
||||||
if _write_rule_file(user_rule, raw_rules):
|
|
||||||
installed_paths.append(str(user_rule))
|
|
||||||
elif target == "cline":
|
|
||||||
clinerules = Path.home() / ".clinerules"
|
|
||||||
global_rule = Path.home() / ".cline" / "rules" / "artemis.md"
|
|
||||||
if _inject_rules_block(clinerules, raw_rules):
|
|
||||||
installed_paths.append(str(clinerules))
|
|
||||||
if _write_rule_file(global_rule, raw_rules):
|
|
||||||
installed_paths.append(str(global_rule))
|
|
||||||
elif target in ("roo", "roo_code"):
|
|
||||||
roorules = Path.home() / ".roorules"
|
|
||||||
global_rule = Path.home() / ".roo" / "rules" / "artemis.md"
|
|
||||||
if _inject_rules_block(roorules, raw_rules):
|
|
||||||
installed_paths.append(str(roorules))
|
|
||||||
if _write_rule_file(global_rule, raw_rules):
|
|
||||||
installed_paths.append(str(global_rule))
|
|
||||||
elif target == "openclaw":
|
|
||||||
openclaw_md = Path.home() / ".openclaw" / "OPENCLAW.md"
|
|
||||||
global_rule = Path.home() / ".openclaw" / "rules" / "artemis.md"
|
|
||||||
if _inject_rules_block(openclaw_md, raw_rules):
|
|
||||||
installed_paths.append(str(openclaw_md))
|
|
||||||
if _write_rule_file(global_rule, raw_rules):
|
|
||||||
installed_paths.append(str(global_rule))
|
|
||||||
elif target == "codex":
|
|
||||||
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
|
||||||
override_file = codex_home / "AGENTS.override.md"
|
|
||||||
agents_file = (
|
|
||||||
override_file
|
|
||||||
if override_file.exists() and override_file.read_text(encoding="utf-8").strip()
|
|
||||||
else codex_home / "AGENTS.md"
|
|
||||||
)
|
|
||||||
if _inject_rules_block(agents_file, raw_rules):
|
|
||||||
installed_paths.append(str(agents_file))
|
|
||||||
|
|
||||||
return installed_paths
|
|
||||||
|
|
||||||
|
|
||||||
def install_mcp_config(client: str, python_exe: str, project_root: str) -> list[str]:
|
def install_mcp_config(client: str, python_exe: str, project_root: str) -> list[str]:
|
||||||
"""Auto-installs/merges ARTEMIS MCP configuration and testing rules into IDE config files across any OS."""
|
"""Install ARTEMIS configuration and rules only for Codex."""
|
||||||
installed_paths: list[str] = []
|
server_cfg = _get_config_snippet(client, python_exe, project_root)["mcp_servers"]["artemis"]
|
||||||
targets = (
|
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
||||||
[
|
codex_path = codex_home / "config.toml"
|
||||||
"antigravity",
|
if not _merge_codex_toml(codex_path, server_cfg):
|
||||||
"cursor",
|
raise typer.Exit(1)
|
||||||
"claude",
|
return [str(codex_path), *install_rules(client, project_root)]
|
||||||
"windsurf",
|
|
||||||
"vscode",
|
|
||||||
"cline",
|
|
||||||
"roo",
|
|
||||||
"openclaw",
|
|
||||||
"codex",
|
|
||||||
]
|
|
||||||
if client == "all"
|
|
||||||
else [client]
|
|
||||||
)
|
|
||||||
|
|
||||||
for target in targets:
|
|
||||||
snippet = _get_config_snippet(target, python_exe, project_root)
|
|
||||||
if target in ("antigravity", "jetski"):
|
|
||||||
current_server_cfg = _get_config_snippet("antigravity", python_exe, project_root)[
|
|
||||||
"mcpServers"
|
|
||||||
]["artemis"]
|
|
||||||
legacy_server_cfg = _get_config_snippet("jetski", python_exe, project_root)[
|
|
||||||
"mcpServers"
|
|
||||||
]["artemis"]
|
|
||||||
jetski_path = Path.home() / ".gemini" / "jetski" / "mcp_config.json"
|
|
||||||
antigravity_legacy_path = Path.home() / ".gemini" / "antigravity" / "mcp_config.json"
|
|
||||||
config_path = Path.home() / ".gemini" / "config" / "mcp_config.json"
|
|
||||||
if _merge_json_file(jetski_path, "artemis", legacy_server_cfg):
|
|
||||||
installed_paths.append(str(jetski_path))
|
|
||||||
if _merge_json_file(antigravity_legacy_path, "artemis", current_server_cfg):
|
|
||||||
installed_paths.append(str(antigravity_legacy_path))
|
|
||||||
if _merge_json_file(config_path, "artemis", current_server_cfg):
|
|
||||||
installed_paths.append(str(config_path))
|
|
||||||
elif target in ("claude", "claude_code", "claude_desktop"):
|
|
||||||
server_cfg = snippet["mcpServers"]["artemis"]
|
|
||||||
if sys.platform == "darwin":
|
|
||||||
claude_path = (
|
|
||||||
Path.home()
|
|
||||||
/ "Library"
|
|
||||||
/ "Application Support"
|
|
||||||
/ "Claude"
|
|
||||||
/ "claude_desktop_config.json"
|
|
||||||
)
|
|
||||||
elif sys.platform == "win32":
|
|
||||||
appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
|
||||||
claude_path = Path(appdata) / "Claude" / "claude_desktop_config.json"
|
|
||||||
else:
|
|
||||||
claude_path = Path.home() / ".config" / "Claude" / "claude_desktop_config.json"
|
|
||||||
if _merge_json_file(claude_path, "artemis", server_cfg):
|
|
||||||
installed_paths.append(str(claude_path))
|
|
||||||
|
|
||||||
# Also install to Claude Code CLI global config (~/.claude.json).
|
|
||||||
# This file holds Claude Code state well beyond MCP config, so a
|
|
||||||
# parse failure must abort the merge rather than rewrite the file.
|
|
||||||
claude_code_path = Path.home() / ".claude.json"
|
|
||||||
if _merge_json_file(claude_code_path, "artemis", server_cfg, strict_parse=True):
|
|
||||||
installed_paths.append(str(claude_code_path))
|
|
||||||
elif target == "cursor":
|
|
||||||
server_cfg = snippet["mcpServers"]["artemis"]
|
|
||||||
cursor_path = Path.home() / ".cursor" / "mcp.json"
|
|
||||||
if _merge_json_file(cursor_path, "artemis", server_cfg):
|
|
||||||
installed_paths.append(str(cursor_path))
|
|
||||||
elif target == "windsurf":
|
|
||||||
server_cfg = snippet["mcpServers"]["artemis"]
|
|
||||||
windsurf_path = Path.home() / ".codeium" / "windsurf" / "mcp_config.json"
|
|
||||||
if _merge_json_file(windsurf_path, "artemis", server_cfg):
|
|
||||||
installed_paths.append(str(windsurf_path))
|
|
||||||
elif target == "vscode":
|
|
||||||
server_cfg = snippet["servers"]["artemis"]
|
|
||||||
vscode_path = _get_vscode_user_dir() / "mcp.json"
|
|
||||||
copilot_path = Path.home() / ".copilot" / "mcp-config.json"
|
|
||||||
if _merge_json_file(vscode_path, "artemis", server_cfg, key_name="servers"):
|
|
||||||
installed_paths.append(str(vscode_path))
|
|
||||||
if _merge_json_file(copilot_path, "artemis", server_cfg, key_name="servers"):
|
|
||||||
installed_paths.append(str(copilot_path))
|
|
||||||
elif target == "cline":
|
|
||||||
server_cfg = snippet["mcpServers"]["artemis"]
|
|
||||||
cline_paths = (
|
|
||||||
Path.home() / ".cline" / "data" / "settings" / "cline_mcp_settings.json",
|
|
||||||
Path.home() / ".cline" / "mcp.json",
|
|
||||||
_get_vscode_user_dir()
|
|
||||||
/ "globalStorage"
|
|
||||||
/ "saoudrizwan.claude-dev"
|
|
||||||
/ "settings"
|
|
||||||
/ "cline_mcp_settings.json",
|
|
||||||
)
|
|
||||||
for cline_path in cline_paths:
|
|
||||||
if _merge_json_file(cline_path, "artemis", server_cfg):
|
|
||||||
installed_paths.append(str(cline_path))
|
|
||||||
elif target in ("roo", "roo_code"):
|
|
||||||
server_cfg = snippet["mcpServers"]["artemis"]
|
|
||||||
roo_settings_dir = (
|
|
||||||
_get_vscode_user_dir() / "globalStorage" / "rooveterinaryinc.roo-cline" / "settings"
|
|
||||||
)
|
|
||||||
roo_paths = (
|
|
||||||
roo_settings_dir / "mcp_settings.json",
|
|
||||||
roo_settings_dir / "cline_mcp_settings.json",
|
|
||||||
Path(project_root) / ".roo" / "mcp.json",
|
|
||||||
)
|
|
||||||
for roo_path in roo_paths:
|
|
||||||
if _merge_json_file(roo_path, "artemis", server_cfg):
|
|
||||||
installed_paths.append(str(roo_path))
|
|
||||||
elif target == "openclaw":
|
|
||||||
server_cfg = snippet["mcp"]["servers"]["artemis"]
|
|
||||||
openclaw_path = Path.home() / ".openclaw" / "openclaw.json"
|
|
||||||
if _merge_json_file(
|
|
||||||
openclaw_path,
|
|
||||||
"artemis",
|
|
||||||
server_cfg,
|
|
||||||
key_name=("mcp", "servers"),
|
|
||||||
remove_paths=(("plugins", "artemis_mcp"),),
|
|
||||||
):
|
|
||||||
installed_paths.append(str(openclaw_path))
|
|
||||||
elif target == "codex":
|
|
||||||
server_cfg = snippet["mcp_servers"]["artemis"]
|
|
||||||
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
|
||||||
codex_path = codex_home / "config.toml"
|
|
||||||
if _merge_codex_toml(codex_path, server_cfg):
|
|
||||||
installed_paths.append(str(codex_path))
|
|
||||||
|
|
||||||
rules_paths = install_rules(client, project_root)
|
|
||||||
installed_paths.extend(rules_paths)
|
|
||||||
|
|
||||||
unique_paths: list[str] = []
|
|
||||||
for p in installed_paths:
|
|
||||||
if p not in unique_paths:
|
|
||||||
unique_paths.append(p)
|
|
||||||
return unique_paths
|
|
||||||
|
|
||||||
|
|
||||||
def mcp_command(
|
def mcp_command(
|
||||||
@@ -733,7 +260,7 @@ def mcp_command(
|
|||||||
typer.Option(
|
typer.Option(
|
||||||
"--install",
|
"--install",
|
||||||
"-i",
|
"-i",
|
||||||
help="Auto-install and merge ARTEMIS MCP configuration and testing rules into 'antigravity', 'claude', 'cursor', 'windsurf', 'vscode', 'cline', 'roo', 'openclaw', 'codex', or 'all'.",
|
help="Install ARTEMIS MCP configuration and testing rules for Codex: --install codex.",
|
||||||
),
|
),
|
||||||
] = None,
|
] = None,
|
||||||
generate_config: Annotated[
|
generate_config: Annotated[
|
||||||
@@ -741,7 +268,7 @@ def mcp_command(
|
|||||||
typer.Option(
|
typer.Option(
|
||||||
"--generate-config",
|
"--generate-config",
|
||||||
"-g",
|
"-g",
|
||||||
help="Output ready-to-use MCP configuration for 'antigravity', 'cursor', 'claude', 'windsurf', 'vscode', 'cline', 'roo', 'openclaw', 'codex', or 'all'.",
|
help="Output ready-to-use Codex TOML configuration: --generate-config codex.",
|
||||||
),
|
),
|
||||||
] = None,
|
] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -751,24 +278,9 @@ def mcp_command(
|
|||||||
|
|
||||||
if install_config:
|
if install_config:
|
||||||
client = install_config.lower()
|
client = install_config.lower()
|
||||||
if client not in (
|
if client != "codex":
|
||||||
"antigravity",
|
|
||||||
"jetski",
|
|
||||||
"claude",
|
|
||||||
"claude_code",
|
|
||||||
"claude_desktop",
|
|
||||||
"cursor",
|
|
||||||
"windsurf",
|
|
||||||
"vscode",
|
|
||||||
"cline",
|
|
||||||
"roo",
|
|
||||||
"roo_code",
|
|
||||||
"openclaw",
|
|
||||||
"codex",
|
|
||||||
"all",
|
|
||||||
):
|
|
||||||
console.print(
|
console.print(
|
||||||
f"[bold red]Unsupported install target: '{install_config}'. Use 'antigravity', 'claude', 'cursor', 'windsurf', 'vscode', 'cline', 'roo', 'openclaw', 'codex', or 'all'.[/bold red]"
|
"[bold red]目前仅支持 Codex MCP 配置,请使用 --install codex。[/bold red]"
|
||||||
)
|
)
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
installed_paths = install_mcp_config(client, python_exe, project_root)
|
installed_paths = install_mcp_config(client, python_exe, project_root)
|
||||||
@@ -784,46 +296,14 @@ def mcp_command(
|
|||||||
|
|
||||||
if generate_config:
|
if generate_config:
|
||||||
client = generate_config.lower()
|
client = generate_config.lower()
|
||||||
if client == "all":
|
if client != "codex":
|
||||||
all_configs = {
|
console.print(
|
||||||
"antigravity (~/.gemini/jetski/mcp_config.json)": _get_config_snippet(
|
"[bold red]目前仅支持 Codex MCP 配置,请使用 --generate-config codex。[/bold red]"
|
||||||
"antigravity", python_exe, project_root
|
)
|
||||||
),
|
raise typer.Exit(1)
|
||||||
"cursor (.cursor/mcp.json)": _get_config_snippet(
|
server_cfg = _get_config_snippet(client, python_exe, project_root)["mcp_servers"]["artemis"]
|
||||||
"cursor", python_exe, project_root
|
json_str = _codex_toml_block(server_cfg)
|
||||||
),
|
syntax_language = "toml"
|
||||||
"claude (claude_desktop_config.json & ~/.claude.json)": _get_config_snippet(
|
|
||||||
"claude", python_exe, project_root
|
|
||||||
),
|
|
||||||
"windsurf (~/.codeium/windsurf/mcp_config.json)": _get_config_snippet(
|
|
||||||
"windsurf", python_exe, project_root
|
|
||||||
),
|
|
||||||
"vscode (mcp.json)": _get_config_snippet("vscode", python_exe, project_root),
|
|
||||||
"cline (cline_mcp_settings.json)": _get_config_snippet(
|
|
||||||
"cline", python_exe, project_root
|
|
||||||
),
|
|
||||||
"roo (cline_mcp_settings.json)": _get_config_snippet(
|
|
||||||
"roo", python_exe, project_root
|
|
||||||
),
|
|
||||||
"openclaw (openclaw.json)": _get_config_snippet(
|
|
||||||
"openclaw", python_exe, project_root
|
|
||||||
),
|
|
||||||
"codex (~/.codex/config.toml)": _get_config_snippet(
|
|
||||||
"codex", python_exe, project_root
|
|
||||||
),
|
|
||||||
}
|
|
||||||
json_str = json.dumps(all_configs, indent=2)
|
|
||||||
syntax_language = "json"
|
|
||||||
elif client == "codex":
|
|
||||||
server_cfg = _get_config_snippet("codex", python_exe, project_root)["mcp_servers"][
|
|
||||||
"artemis"
|
|
||||||
]
|
|
||||||
json_str = _codex_toml_block(server_cfg)
|
|
||||||
syntax_language = "toml"
|
|
||||||
else:
|
|
||||||
snippet = _get_config_snippet(client, python_exe, project_root)
|
|
||||||
json_str = json.dumps(snippet, indent=2)
|
|
||||||
syntax_language = "json"
|
|
||||||
|
|
||||||
syntax = Syntax(json_str, syntax_language, theme="monokai", line_numbers=False)
|
syntax = Syntax(json_str, syntax_language, theme="monokai", line_numbers=False)
|
||||||
console.print(f"[bold cyan]MCP Configuration for {client.upper()}:[/bold cyan]")
|
console.print(f"[bold cyan]MCP Configuration for {client.upper()}:[/bold cyan]")
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Adapt LangChain messages for Responses-compatible model families."""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
||||||
|
|
||||||
|
|
||||||
|
_USER_ROLE_MODELS = {"mimo-v2.6-pro", "mimo-v2.6-flash"}
|
||||||
|
|
||||||
|
|
||||||
|
def adapt_responses_messages(
|
||||||
|
messages: Sequence[BaseMessage], model_name: str | None
|
||||||
|
) -> Sequence[BaseMessage]:
|
||||||
|
"""Send selected models user-role instructions without changing stored history.
|
||||||
|
|
||||||
|
Full Artemis requests to GPT models can fail with system-role input. The
|
||||||
|
equivalent user-role request works with GPT and the two tested Mimo models.
|
||||||
|
Other models keep their existing message roles.
|
||||||
|
"""
|
||||||
|
if not model_name:
|
||||||
|
return messages
|
||||||
|
model = model_name.strip().lower()
|
||||||
|
if not (model.startswith("gpt") or model in _USER_ROLE_MODELS):
|
||||||
|
return messages
|
||||||
|
if not any(isinstance(message, SystemMessage) for message in messages):
|
||||||
|
return messages
|
||||||
|
|
||||||
|
adapted: list[BaseMessage] = []
|
||||||
|
pending_blocks: list[dict[str, Any]] = []
|
||||||
|
for message in messages:
|
||||||
|
if isinstance(message, SystemMessage):
|
||||||
|
content = message.content
|
||||||
|
if isinstance(content, str):
|
||||||
|
pending_blocks.append({"type": "text", "text": content})
|
||||||
|
else:
|
||||||
|
pending_blocks.extend(
|
||||||
|
{"type": "text", "text": block} if isinstance(block, str) else block
|
||||||
|
for block in content
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if pending_blocks:
|
||||||
|
if isinstance(message, HumanMessage):
|
||||||
|
content = message.content
|
||||||
|
blocks = (
|
||||||
|
[{"type": "text", "text": content}]
|
||||||
|
if isinstance(content, str)
|
||||||
|
else list(content)
|
||||||
|
)
|
||||||
|
adapted.append(
|
||||||
|
message.model_copy(
|
||||||
|
update={
|
||||||
|
"content": [
|
||||||
|
*pending_blocks,
|
||||||
|
*blocks,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pending_blocks = []
|
||||||
|
continue
|
||||||
|
adapted.append(HumanMessage(content=pending_blocks))
|
||||||
|
pending_blocks = []
|
||||||
|
adapted.append(message)
|
||||||
|
|
||||||
|
if pending_blocks:
|
||||||
|
adapted.append(HumanMessage(content=pending_blocks))
|
||||||
|
return adapted
|
||||||
@@ -75,7 +75,7 @@ def extract_status_code(error: BaseException) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
_RATE_LIMIT_MARKERS = ("rate limit", "quota", "resource_exhausted")
|
_RATE_LIMIT_MARKERS = ("rate limit", "too many requests", "quota", "resource_exhausted")
|
||||||
_UNAVAILABLE_MARKERS = ("unavailable", "overloaded", "high demand", "service unavailable")
|
_UNAVAILABLE_MARKERS = ("unavailable", "overloaded", "high demand", "service unavailable")
|
||||||
_AUTH_MARKERS = ("unauthorized", "forbidden", "api key", "credential")
|
_AUTH_MARKERS = ("unauthorized", "forbidden", "api key", "credential")
|
||||||
# "deadline exceeded"/"deadline_exceeded" cover google-genai SDK timeouts that
|
# "deadline exceeded"/"deadline_exceeded" cover google-genai SDK timeouts that
|
||||||
@@ -196,6 +196,10 @@ def retry_policy_for(category: FailureCategory | str) -> RetryPolicy:
|
|||||||
return _DEFAULT_RETRY_POLICIES[category]
|
return _DEFAULT_RETRY_POLICIES[category]
|
||||||
from artemis.config.settings import settings
|
from artemis.config.settings import settings
|
||||||
|
|
||||||
|
if category is FailureCategory.RATE_LIMIT:
|
||||||
|
return _configured_policy(
|
||||||
|
settings.OPENAI_MAX_RETRIES, settings.OPENAI_RATE_LIMIT_RETRY_SECONDS
|
||||||
|
)
|
||||||
return _configured_policy(settings.OPENAI_MAX_RETRIES, settings.OPENAI_RETRY_INTERVAL_SECONDS)
|
return _configured_policy(settings.OPENAI_MAX_RETRIES, settings.OPENAI_RETRY_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+24
-13
@@ -15,25 +15,36 @@
|
|||||||
"""Backward compatibility entrypoint for artemis CLI."""
|
"""Backward compatibility entrypoint for artemis CLI."""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
from artemis.interfaces.cli.main import app
|
from artemis.interfaces.cli.main import app
|
||||||
|
from artemis.interfaces.cli.main import app as _typer_app
|
||||||
|
|
||||||
|
|
||||||
|
def _root_tokens() -> frozenset[str]:
|
||||||
|
"""Read command names and root flags from the registered CLI."""
|
||||||
|
group = typer.main.get_command(_typer_app)
|
||||||
|
tokens: set[str] = {"-h"}
|
||||||
|
tokens.update(getattr(group, "commands", {}) or {})
|
||||||
|
tokens.update(group.get_help_option_names(group.context_class(group)))
|
||||||
|
for param in group.params:
|
||||||
|
tokens.update(getattr(param, "opts", ()))
|
||||||
|
tokens.update(getattr(param, "secondary_opts", ()))
|
||||||
|
return frozenset(tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_argv(argv: list[str]) -> list[str]:
|
||||||
|
"""Keep implicit ``run`` for a goal while preserving registered commands."""
|
||||||
|
if len(argv) > 1 and argv[1] not in _root_tokens():
|
||||||
|
return [argv[0], "run", *argv[1:]]
|
||||||
|
return list(argv)
|
||||||
|
|
||||||
|
|
||||||
def cli():
|
def cli():
|
||||||
# If invoked directly as python -m artemis.main without subcommand 'run',
|
# If invoked directly as python -m artemis.main without subcommand 'run',
|
||||||
# check if first argument is a goal rather than a subcommand
|
# check if first argument is a goal rather than a subcommand
|
||||||
if len(sys.argv) > 1 and sys.argv[1] not in (
|
sys.argv[:] = normalize_argv(sys.argv)
|
||||||
"run",
|
|
||||||
"init",
|
|
||||||
"doctor",
|
|
||||||
"batch",
|
|
||||||
"bench",
|
|
||||||
"mcp",
|
|
||||||
"server",
|
|
||||||
"trace",
|
|
||||||
"--help",
|
|
||||||
"-h",
|
|
||||||
):
|
|
||||||
sys.argv.insert(1, "run")
|
|
||||||
app()
|
app()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ from artemis.mcp.action_manifest import OPTIONAL_ACTIONS, REQUIRED_ACTIONS
|
|||||||
from artemis.mcp.action_specs import exception_prefix
|
from artemis.mcp.action_specs import exception_prefix
|
||||||
from artemis.mcp.action_session import ActionSession, get_action_session
|
from artemis.mcp.action_session import ActionSession, get_action_session
|
||||||
from artemis.mcp.action_types import ActionCode, ActionResult
|
from artemis.mcp.action_types import ActionCode, ActionResult
|
||||||
|
from artemis.mcp.action_types import ObserveResult
|
||||||
from artemis.mcp.actuators.adb import AdbActuator
|
from artemis.mcp.actuators.adb import AdbActuator
|
||||||
from artemis.agents.validator.tool_declarations import (
|
from artemis.agents.validator.tool_declarations import (
|
||||||
ToolExecutionResult,
|
ToolExecutionResult,
|
||||||
@@ -56,6 +57,7 @@ from artemis.utils.coordinates import (
|
|||||||
parse_swipe_parameters,
|
parse_swipe_parameters,
|
||||||
)
|
)
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
|
from artemis.utils.image_hash import dhash_hex, hamming_distance_hex
|
||||||
from artemis.utils.notes import (
|
from artemis.utils.notes import (
|
||||||
format_list_notes_failure,
|
format_list_notes_failure,
|
||||||
format_list_notes_success,
|
format_list_notes_success,
|
||||||
@@ -76,6 +78,9 @@ AGENT_TOOL_NAMES: frozenset[str] = (
|
|||||||
|
|
||||||
#: Actions whose ``target`` is a single point: an element index or an [x, y] pair.
|
#: Actions whose ``target`` is a single point: an element index or an [x, y] pair.
|
||||||
_POINT_TARGET_ACTIONS: frozenset[str] = frozenset({"click", "long_press", "input_text"})
|
_POINT_TARGET_ACTIONS: frozenset[str] = frozenset({"click", "long_press", "input_text"})
|
||||||
|
_SCREEN_CHANGING_ACTIONS: frozenset[str] = frozenset(
|
||||||
|
{"click", "click_sequence", "long_press", "input_text", "press_key", "swipe", "manage_app"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _ArgError(ValueError):
|
class _ArgError(ValueError):
|
||||||
@@ -111,6 +116,99 @@ class McpActionExecutor:
|
|||||||
self._session = await get_action_session(self.ctx, actuator=self.actuator)
|
self._session = await get_action_session(self.ctx, actuator=self.actuator)
|
||||||
return self._session
|
return self._session
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _image_hash(observation: ObserveResult) -> str | None:
|
||||||
|
path = observation.screenshot_path
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return dhash_hex(Path(path).read_bytes())
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _same_screen(cls, first: ObserveResult, second: ObserveResult) -> bool:
|
||||||
|
"""Compare two observations without treating a dispatched tap as navigation."""
|
||||||
|
if not first.ok or not second.ok:
|
||||||
|
return False
|
||||||
|
checks: list[bool] = []
|
||||||
|
first_hash, second_hash = cls._image_hash(first), cls._image_hash(second)
|
||||||
|
distance = hamming_distance_hex(first_hash, second_hash)
|
||||||
|
if distance is not None:
|
||||||
|
checks.append(distance <= 4)
|
||||||
|
if first.hierarchy_ok and second.hierarchy_ok:
|
||||||
|
checks.append(first.elements_text == second.elements_text)
|
||||||
|
return bool(checks) and all(checks)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _element_signature(elements: list[dict[str, Any]]) -> list[tuple[Any, Any, Any]]:
|
||||||
|
return [(e.get("text"), e.get("resource_id"), e.get("bounds")) for e in elements]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _stale_image_with_new_hierarchy(
|
||||||
|
cls,
|
||||||
|
observation: ObserveResult,
|
||||||
|
previous_hash: str | None,
|
||||||
|
previous_elements: list[dict[str, Any]] | None,
|
||||||
|
) -> bool:
|
||||||
|
if previous_hash is None or previous_elements is None or not observation.hierarchy_ok:
|
||||||
|
return False
|
||||||
|
distance = hamming_distance_hex(previous_hash, cls._image_hash(observation))
|
||||||
|
return (
|
||||||
|
distance is not None
|
||||||
|
and distance <= 4
|
||||||
|
and cls._element_signature(previous_elements)
|
||||||
|
!= cls._element_signature(observation.elements)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _observe_after_action(
|
||||||
|
self,
|
||||||
|
session: ActionSession,
|
||||||
|
raw_name: str,
|
||||||
|
previous_screenshot: str | None,
|
||||||
|
previous_elements: list[dict[str, Any]] | None = None,
|
||||||
|
) -> tuple[ObserveResult, bool]:
|
||||||
|
"""Prefer a settled image/tree pair after actions that can change screens.
|
||||||
|
|
||||||
|
The Android helper captures its bitmap and hierarchy concurrently. During a
|
||||||
|
transition the bitmap can still show the old page while the hierarchy already
|
||||||
|
describes the new one. Bounded re-observation waits for adjacent stable
|
||||||
|
frames and rejects a new hierarchy paired with the previous page's bitmap.
|
||||||
|
"""
|
||||||
|
first = await session.observe(settle_ms=400)
|
||||||
|
if not first.ok or raw_name not in _SCREEN_CHANGING_ACTIONS:
|
||||||
|
return first, first.ok
|
||||||
|
|
||||||
|
second = await session.observe(settle_ms=250)
|
||||||
|
if not second.ok:
|
||||||
|
return first, False
|
||||||
|
previous_hash = None
|
||||||
|
if previous_screenshot:
|
||||||
|
try:
|
||||||
|
previous_hash = dhash_hex(Path(previous_screenshot).read_bytes())
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if self._same_screen(first, second):
|
||||||
|
distance = hamming_distance_hex(previous_hash, self._image_hash(second))
|
||||||
|
if (distance is None or distance > 4) and not self._stale_image_with_new_hierarchy(
|
||||||
|
second, previous_hash, previous_elements
|
||||||
|
):
|
||||||
|
return second, True
|
||||||
|
|
||||||
|
third = await session.observe(settle_ms=250)
|
||||||
|
if not third.ok:
|
||||||
|
return second, False
|
||||||
|
if self._same_screen(second, third) and not self._stale_image_with_new_hierarchy(
|
||||||
|
third, previous_hash, previous_elements
|
||||||
|
):
|
||||||
|
return third, True
|
||||||
|
fourth = await session.observe(settle_ms=250)
|
||||||
|
if not fourth.ok:
|
||||||
|
return third, False
|
||||||
|
return fourth, self._same_screen(
|
||||||
|
third, fourth
|
||||||
|
) and not self._stale_image_with_new_hierarchy(fourth, previous_hash, previous_elements)
|
||||||
|
|
||||||
# --- Public entry ----------------------------------------------------------------
|
# --- Public entry ----------------------------------------------------------------
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
@@ -171,6 +269,7 @@ class McpActionExecutor:
|
|||||||
xml_list: str | None = None
|
xml_list: str | None = None
|
||||||
target_semantics: dict[str, Any] = {}
|
target_semantics: dict[str, Any] = {}
|
||||||
target_coordinates: list[int] | None = None
|
target_coordinates: list[int] | None = None
|
||||||
|
observation_settled: bool | None = None
|
||||||
with span:
|
with span:
|
||||||
try:
|
try:
|
||||||
session = await self._session_or_start()
|
session = await self._session_or_start()
|
||||||
@@ -196,7 +295,12 @@ class McpActionExecutor:
|
|||||||
message = finalize(res) if finalize else res.message
|
message = finalize(res) if finalize else res.message
|
||||||
|
|
||||||
if res.ok or self._observe_despite_failure(raw_name, res):
|
if res.ok or self._observe_despite_failure(raw_name, res):
|
||||||
obs = await session.observe(settle_ms=400)
|
obs, observation_settled = await self._observe_after_action(
|
||||||
|
session,
|
||||||
|
raw_name,
|
||||||
|
getattr(state, "latest_screenshot", None),
|
||||||
|
getattr(state, "indexed_elements", None),
|
||||||
|
)
|
||||||
if obs.ok:
|
if obs.ok:
|
||||||
shot_path = obs.screenshot_path
|
shot_path = obs.screenshot_path
|
||||||
xml_list = obs.elements_text
|
xml_list = obs.elements_text
|
||||||
@@ -217,6 +321,8 @@ class McpActionExecutor:
|
|||||||
|
|
||||||
if shot_path:
|
if shot_path:
|
||||||
state.latest_screenshot = shot_path
|
state.latest_screenshot = shot_path
|
||||||
|
if observation_settled is False:
|
||||||
|
message = f"{message} Post-action screen is still changing; observe again before concluding."
|
||||||
|
|
||||||
status = "success" if res.ok else "error"
|
status = "success" if res.ok else "error"
|
||||||
post_image_name = None
|
post_image_name = None
|
||||||
@@ -247,6 +353,7 @@ class McpActionExecutor:
|
|||||||
"normalized_coordinates": res.normalized_coordinates,
|
"normalized_coordinates": res.normalized_coordinates,
|
||||||
"target_semantics": target_semantics,
|
"target_semantics": target_semantics,
|
||||||
"target_coordinates": target_coordinates,
|
"target_coordinates": target_coordinates,
|
||||||
|
"observation_settled": observation_settled,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from artemis.clients.screen_client_factory import create_screen_client
|
|||||||
from artemis.context import ArtemisContext, DeviceContext, DevicePlatform
|
from artemis.context import ArtemisContext, DeviceContext, DevicePlatform
|
||||||
from artemis.controllers.unified_controller import UnifiedMobileController
|
from artemis.controllers.unified_controller import UnifiedMobileController
|
||||||
from artemis.platform import platform
|
from artemis.platform import platform
|
||||||
|
from artemis.runtime.adb_endpoint import AdbEndpoint
|
||||||
from artemis.utils.app_launch_utils import launch_app_with_retries
|
from artemis.utils.app_launch_utils import launch_app_with_retries
|
||||||
|
|
||||||
|
|
||||||
@@ -127,7 +128,7 @@ def _get_controller(device_serial: str | None = None):
|
|||||||
port_str = os.environ.get("ADB_PORT", "5037")
|
port_str = os.environ.get("ADB_PORT", "5037")
|
||||||
port = int(port_str) if port_str.isdigit() else 5037
|
port = int(port_str) if port_str.isdigit() else 5037
|
||||||
if "ADB_SERVER_SOCKET" not in os.environ and (host != "localhost" or port != 5037):
|
if "ADB_SERVER_SOCKET" not in os.environ and (host != "localhost" or port != 5037):
|
||||||
os.environ["ADB_SERVER_SOCKET"] = f"tcp:{host}:{port}"
|
os.environ["ADB_SERVER_SOCKET"] = AdbEndpoint.create(host, port).client_socket
|
||||||
adb = AdbClient(host=host, port=port)
|
adb = AdbClient(host=host, port=port)
|
||||||
|
|
||||||
devices = adb.device_list()
|
devices = adb.device_list()
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ Validator -- which runs its own UI-change polling -- simply never calls it.
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
from io import BytesIO
|
||||||
import time
|
import time
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
from artemis.config import get_temp_dir
|
from artemis.config import get_temp_dir
|
||||||
|
from artemis.config.settings import settings
|
||||||
from artemis.context import ArtemisContext
|
from artemis.context import ArtemisContext
|
||||||
from artemis.controllers.unified_controller import UnifiedMobileController
|
from artemis.controllers.unified_controller import UnifiedMobileController
|
||||||
from artemis.mcp.action_types import ActionCode, ObserveResult
|
from artemis.mcp.action_types import ActionCode, ObserveResult
|
||||||
@@ -34,7 +37,29 @@ from artemis.utils.visualization import format_minimal_list_with_elements
|
|||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
__all__ = ["observe"]
|
__all__ = ["observe", "require_observation", "DeviceObservationError"]
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceObservationError(RuntimeError):
|
||||||
|
"""A live device could not be observed after bounded recovery."""
|
||||||
|
|
||||||
|
|
||||||
|
async def require_observation(
|
||||||
|
ctx: ArtemisContext, controller: UnifiedMobileController, settle_ms: int = 400
|
||||||
|
) -> tuple[ObserveResult, bytes | None]:
|
||||||
|
"""Recover observation without spending model calls on unavailable screens."""
|
||||||
|
last = "设备未返回有效画面"
|
||||||
|
for attempt in range(settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS):
|
||||||
|
observation, image = await observe(ctx, controller, settle_ms=settle_ms)
|
||||||
|
if observation.ok:
|
||||||
|
return observation, image
|
||||||
|
last = observation.message
|
||||||
|
logger.warning(
|
||||||
|
f"设备观测恢复 {attempt + 1}/{settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS}:{last}"
|
||||||
|
)
|
||||||
|
raise DeviceObservationError(
|
||||||
|
f"设备观测失败(已尝试 {settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS} 次):{last}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def observe(
|
async def observe(
|
||||||
@@ -60,9 +85,19 @@ async def observe(
|
|||||||
await asyncio.sleep(settle_ms / 1000.0)
|
await asyncio.sleep(settle_ms / 1000.0)
|
||||||
else:
|
else:
|
||||||
logger.info("Retrieving screen data directly (skip settling)...")
|
logger.info("Retrieving screen data directly (skip settling)...")
|
||||||
device_data = await controller.get_screen_data()
|
device_data = await asyncio.wait_for(
|
||||||
|
controller.get_screen_data(), timeout=settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
screenshot_bytes = base64.b64decode(device_data.base64)
|
screenshot_bytes = base64.b64decode(device_data.base64)
|
||||||
|
with Image.open(BytesIO(screenshot_bytes)) as image:
|
||||||
|
if min(image.size) <= 1:
|
||||||
|
raise ValueError("设备截图为占位图,当前画面不可用")
|
||||||
|
image.verify()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if isinstance(e, TimeoutError):
|
||||||
|
e = TimeoutError(
|
||||||
|
f"设备屏幕观测超时({settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS:g} 秒)"
|
||||||
|
)
|
||||||
logger.error(f"Failed to capture screen state: {e}")
|
logger.error(f"Failed to capture screen state: {e}")
|
||||||
return (
|
return (
|
||||||
ObserveResult(
|
ObserveResult(
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ def ensure_step_memory(ctx):
|
|||||||
"retry_limit": cfg.memory.runtime.retry_limit,
|
"retry_limit": cfg.memory.runtime.retry_limit,
|
||||||
"max_concurrency": cfg.memory.runtime.max_concurrency,
|
"max_concurrency": cfg.memory.runtime.max_concurrency,
|
||||||
"flush_timeout_s": cfg.memory.runtime.flush_timeout_s,
|
"flush_timeout_s": cfg.memory.runtime.flush_timeout_s,
|
||||||
|
"timeout_seconds": cfg.flash.step_summarizer.timeout_seconds,
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
||||||
|
|
||||||
|
from artemis.llm.messages import adapt_responses_messages
|
||||||
from artemis.memory.step_memory import JobKey, StepLens, StepMemoryService
|
from artemis.memory.step_memory import JobKey, StepLens, StepMemoryService
|
||||||
from artemis.memory.transcript import format_session_offset
|
from artemis.memory.transcript import format_session_offset
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
@@ -503,7 +504,8 @@ class StepCapsuleLens(StepLens):
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
async def _invoke(self, llm: Any, messages: list[BaseMessage], model_label: str):
|
async def _invoke(self, llm: Any, messages: list[BaseMessage], model_label: str):
|
||||||
response = await asyncio.wait_for(llm.ainvoke(messages), timeout=90.0)
|
request_messages = adapt_responses_messages(messages, model_label)
|
||||||
|
response = await asyncio.wait_for(llm.ainvoke(request_messages), timeout=90.0)
|
||||||
# Raw-model bypass metering (gateway-wrapped models meter themselves);
|
# Raw-model bypass metering (gateway-wrapped models meter themselves);
|
||||||
# capsule prompts are small and are kept out of the session meter's
|
# capsule prompts are small and are kept out of the session meter's
|
||||||
# last_prompt_tokens (the compaction base itself is operator-owned on
|
# last_prompt_tokens (the compaction base itself is operator-owned on
|
||||||
|
|||||||
@@ -13,7 +13,14 @@
|
|||||||
"presets": {},
|
"presets": {},
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"planner": {},
|
"planner": {},
|
||||||
"operator": {},
|
"operator": {
|
||||||
|
"fallback": {
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "${env:OPENAI_TOOL_FALLBACK_MODEL:-gpt-6-luna}",
|
||||||
|
"reasoning_effort": "${env:OPENAI_TOOL_FALLBACK_REASONING_EFFORT:-high}",
|
||||||
|
"max_retries": "${env:OPENAI_TOOL_FALLBACK_MAX_RETRIES:-3}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"video_analyzer": {},
|
"video_analyzer": {},
|
||||||
"object_detector": {},
|
"object_detector": {},
|
||||||
"explorer": {},
|
"explorer": {},
|
||||||
@@ -27,12 +34,17 @@
|
|||||||
"checker": {}
|
"checker": {}
|
||||||
},
|
},
|
||||||
"agent": {
|
"agent": {
|
||||||
|
"screenshot": {
|
||||||
|
"enabled": "${env:ARTEMIS_SCREENSHOT_LIMIT_ENABLED:-true}",
|
||||||
|
"max_long_edge": 2048
|
||||||
|
},
|
||||||
"flash": {
|
"flash": {
|
||||||
"max_turns": 0,
|
"max_turns": 0,
|
||||||
"explorer_mode": "flash",
|
"explorer_mode": "flash",
|
||||||
"step_summarizer": {
|
"step_summarizer": {
|
||||||
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
||||||
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
||||||
|
"timeout_seconds": "${env:ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS:-120}",
|
||||||
"prune_history_xml": true
|
"prune_history_xml": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -92,6 +92,19 @@ class AdbEndpoint:
|
|||||||
def mode(self) -> str:
|
def mode(self) -> str:
|
||||||
return "local" if self.is_local_default else "remote"
|
return "local" if self.is_local_default else "remote"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client_host(self) -> str:
|
||||||
|
"""ADB only permits daemon auto-start for the literal host 'localhost'.
|
||||||
|
|
||||||
|
Keep forwarded/custom-port endpoints explicit, even on loopback, so a
|
||||||
|
missing tunnel never starts a different local daemon at that address.
|
||||||
|
"""
|
||||||
|
return "localhost" if self.is_local_default else self.host
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client_socket(self) -> str:
|
||||||
|
return f"tcp:localhost:{self.port}" if self.is_local_default else self.socket
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
**asdict(self),
|
**asdict(self),
|
||||||
@@ -108,7 +121,7 @@ class AdbEndpoint:
|
|||||||
target = environment if environment is not None else os.environ
|
target = environment if environment is not None else os.environ
|
||||||
target["ADB_HOST"] = self.host
|
target["ADB_HOST"] = self.host
|
||||||
target["ADB_PORT"] = str(self.port)
|
target["ADB_PORT"] = str(self.port)
|
||||||
target["ADB_SERVER_SOCKET"] = self.socket
|
target["ADB_SERVER_SOCKET"] = self.client_socket
|
||||||
target[ADB_ENDPOINT_ID_ENV] = self.identity
|
target[ADB_ENDPOINT_ID_ENV] = self.identity
|
||||||
return target
|
return target
|
||||||
|
|
||||||
@@ -145,7 +158,7 @@ class AdbSession:
|
|||||||
return [
|
return [
|
||||||
self.adb_path,
|
self.adb_path,
|
||||||
"-H",
|
"-H",
|
||||||
self.endpoint.host,
|
self.endpoint.client_host,
|
||||||
"-P",
|
"-P",
|
||||||
str(self.endpoint.port),
|
str(self.endpoint.port),
|
||||||
*arguments,
|
*arguments,
|
||||||
|
|||||||
@@ -113,22 +113,28 @@ class ScreenAwakeLease:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _mutex(self):
|
def _mutex(self, *, wait_timeout_seconds: float | None = None):
|
||||||
self.mutex_path.parent.mkdir(parents=True, exist_ok=True)
|
self.mutex_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
started_at = time.monotonic()
|
started_at = time.monotonic()
|
||||||
|
timeout = (
|
||||||
|
self._MUTEX_TIMEOUT_SECONDS
|
||||||
|
if wait_timeout_seconds is None
|
||||||
|
else max(0.0, wait_timeout_seconds)
|
||||||
|
)
|
||||||
acquired = False
|
acquired = False
|
||||||
while not acquired:
|
while not acquired:
|
||||||
try:
|
try:
|
||||||
fd = os.open(self.mutex_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
fd = os.open(self.mutex_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||||
except FileExistsError:
|
except FileExistsError:
|
||||||
|
if time.monotonic() - started_at >= timeout:
|
||||||
|
raise TimeoutError("Timed out acquiring the Artemis awake-lease mutex.")
|
||||||
if self._path_is_stale(self.mutex_path):
|
if self._path_is_stale(self.mutex_path):
|
||||||
try:
|
try:
|
||||||
self.mutex_path.unlink(missing_ok=True)
|
self.mutex_path.unlink(missing_ok=True)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
continue
|
else:
|
||||||
if time.monotonic() - started_at >= self._MUTEX_TIMEOUT_SECONDS:
|
continue
|
||||||
raise TimeoutError("Timed out acquiring the Artemis awake-lease mutex.")
|
|
||||||
time.sleep(self._MUTEX_POLL_SECONDS)
|
time.sleep(self._MUTEX_POLL_SECONDS)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
@@ -274,10 +280,15 @@ class ScreenAwakeLease:
|
|||||||
def cleanup_unowned_references(self) -> bool:
|
def cleanup_unowned_references(self) -> bool:
|
||||||
"""Drain legacy shell wake locks only when no live old host owns them."""
|
"""Drain legacy shell wake locks only when no live old host owns them."""
|
||||||
try:
|
try:
|
||||||
with self._mutex():
|
# Migration cleanup is optional. A busy owner must not delay every
|
||||||
|
# queued task by the normal 10-second lease-acquisition deadline.
|
||||||
|
with self._mutex(wait_timeout_seconds=1.0):
|
||||||
if self._live_leases():
|
if self._live_leases():
|
||||||
return False
|
return False
|
||||||
return self._drain_references()
|
return self._drain_references()
|
||||||
|
except TimeoutError:
|
||||||
|
logger.debug(f"Skipped optional wake-lock cleanup on {self.device_id}: mutex busy")
|
||||||
|
return False
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"Could not clean unowned screen wake locks on {self.device_id}: {exc}")
|
logger.warning(f"Could not clean unowned screen wake locks on {self.device_id}: {exc}")
|
||||||
return False
|
return False
|
||||||
@@ -95,9 +95,10 @@ def _configure_usb_stay_awake(device_id: str) -> str | None:
|
|||||||
if not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
|
if not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Migration cleanup: previous Artemis versions used a persistent shell
|
# Migration cleanup belongs to the long-lived host. A queued task worker
|
||||||
# wake lock. Drain it only after every live legacy owner has exited.
|
# must not wait on another process's optional legacy wake-lock cleanup.
|
||||||
ScreenAwakeLease(device_id).cleanup_unowned_references()
|
if os.environ.get("ARTEMIS_TASK_WORKER") != "1":
|
||||||
|
ScreenAwakeLease(device_id).cleanup_unowned_references()
|
||||||
|
|
||||||
commands = (
|
commands = (
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ import urllib.error
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from artemis.config.paths import ROOT_DIR
|
from artemis.config.paths import ROOT_DIR
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
from artemis.runtime.process_probe import pid_is_alive
|
||||||
|
from artemis.runtime.server_lifecycle import is_port_in_use, read_server_info
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -44,10 +47,11 @@ def is_standalone_forced() -> bool:
|
|||||||
def is_daemon_running(
|
def is_daemon_running(
|
||||||
host: str = DEFAULT_DAEMON_HOST,
|
host: str = DEFAULT_DAEMON_HOST,
|
||||||
port: int = DEFAULT_DAEMON_PORT,
|
port: int = DEFAULT_DAEMON_PORT,
|
||||||
timeout: float = 0.3,
|
timeout: float | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Fast probe to check if the Artemis Daemon HTTP server is responding."""
|
"""Fast probe to check if the Artemis Daemon HTTP server is responding."""
|
||||||
url = f"http://{host}:{port}/api/system/readiness"
|
timeout = timeout if timeout is not None else settings.ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS
|
||||||
|
url = f"http://{host}:{port}/api/system/server-status"
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "Artemis-Daemon-Probe"})
|
req = urllib.request.Request(url, headers={"User-Agent": "Artemis-Daemon-Probe"})
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||||
@@ -66,7 +70,7 @@ def is_daemon_running(
|
|||||||
def is_artemis_daemon(
|
def is_artemis_daemon(
|
||||||
host: str = DEFAULT_DAEMON_HOST,
|
host: str = DEFAULT_DAEMON_HOST,
|
||||||
port: int = DEFAULT_DAEMON_PORT,
|
port: int = DEFAULT_DAEMON_PORT,
|
||||||
timeout: float = 0.5,
|
timeout: float = 2.0,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Identity probe: is the process on ``host:port`` the Artemis daemon?
|
"""Identity probe: is the process on ``host:port`` the Artemis daemon?
|
||||||
|
|
||||||
@@ -190,6 +194,15 @@ def ensure_daemon_running(
|
|||||||
if is_daemon_running(host, port):
|
if is_daemon_running(host, port):
|
||||||
return True, f"http://{host}:{port}"
|
return True, f"http://{host}:{port}"
|
||||||
|
|
||||||
|
if is_port_in_use(port, host):
|
||||||
|
# A slow/busy HTTP loop is not a stopped daemon. Only trust persisted
|
||||||
|
# metadata belonging to a live process on the requested endpoint.
|
||||||
|
info = read_server_info() or {}
|
||||||
|
if info.get("port") == port and info.get("host") == host and pid_is_alive(info.get("pid")):
|
||||||
|
return True, f"http://{host}:{port}"
|
||||||
|
logger.warning(f"Port {host}:{port} is occupied; refusing to spawn another daemon")
|
||||||
|
return False, None
|
||||||
|
|
||||||
# Auto-spawn Daemon in background
|
# Auto-spawn Daemon in background
|
||||||
spawn_daemon(host, port)
|
spawn_daemon(host, port)
|
||||||
|
|
||||||
@@ -221,6 +234,7 @@ def submit_task_to_daemon(
|
|||||||
conversation_id: str | None = None,
|
conversation_id: str | None = None,
|
||||||
verification_level: str | None = None,
|
verification_level: str | None = None,
|
||||||
explorer_mode: str | None = None,
|
explorer_mode: str | None = None,
|
||||||
|
runtime_overrides: dict[str, Any] | None = None,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
timeout: float = 15.0,
|
timeout: float = 15.0,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
@@ -246,6 +260,7 @@ def submit_task_to_daemon(
|
|||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"ingress": ingress,
|
"ingress": ingress,
|
||||||
"conversation_id": conversation_id,
|
"conversation_id": conversation_id,
|
||||||
|
"runtime_overrides": runtime_overrides,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from artemis.runtime.adb_endpoint import AdbSession, current_adb_endpoint
|
||||||
from artemis.runtime.device_lock import DeviceExecutionLock
|
from artemis.runtime.device_lock import DeviceExecutionLock
|
||||||
from artemis.toolchain import toolchain
|
from artemis.toolchain import toolchain
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
@@ -242,23 +243,29 @@ class DevicePool:
|
|||||||
|
|
||||||
async def _start_adb_server(self, timeout: float) -> None:
|
async def _start_adb_server(self, timeout: float) -> None:
|
||||||
"""Best-effort bounded `adb start-server` so later queries hit a warm daemon."""
|
"""Best-effort bounded `adb start-server` so later queries hit a warm daemon."""
|
||||||
|
endpoint = current_adb_endpoint()
|
||||||
|
if not endpoint.is_local_default:
|
||||||
|
return
|
||||||
adb = self._resolve_adb()
|
adb = self._resolve_adb()
|
||||||
if not adb:
|
if not adb:
|
||||||
return
|
return
|
||||||
proc = None
|
proc = None
|
||||||
try:
|
try:
|
||||||
clean_env = os.environ.copy()
|
session = AdbSession(endpoint, adb_path=adb)
|
||||||
clean_env.pop("ADB_SERVER_SOCKET", None)
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
adb,
|
*session.command(["start-server"]),
|
||||||
"start-server",
|
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
env=clean_env,
|
env=session.environment(),
|
||||||
)
|
)
|
||||||
await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
output = (stderr or stdout).decode(errors="replace").strip()
|
||||||
|
logger.warning(
|
||||||
|
f"Local ADB server startup failed (exit {proc.returncode}): {output}"
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug(f"adb start-server warm-up did not complete: {exc}")
|
logger.warning(f"Local ADB server startup did not complete: {exc}")
|
||||||
if proc is not None:
|
if proc is not None:
|
||||||
try:
|
try:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ def list_instructions(session_id: str) -> list[dict]:
|
|||||||
return sorted(records, key=lambda record: (record.get("timestamp", 0), record["id"]))
|
return sorted(records, key=lambda record: (record.get("timestamp", 0), record["id"]))
|
||||||
|
|
||||||
|
|
||||||
|
def has_pending_instruction(base_dir: str | Path) -> bool:
|
||||||
|
directory = Path(base_dir) / "instructions"
|
||||||
|
return any(directory.glob("*.json")) or (Path(base_dir) / "injected_instruction.json").exists()
|
||||||
|
|
||||||
|
|
||||||
def consume_instructions(base_dir: str | Path) -> dict | None:
|
def consume_instructions(base_dir: str | Path) -> dict | None:
|
||||||
directory = Path(base_dir) / "instructions"
|
directory = Path(base_dir) / "instructions"
|
||||||
found = []
|
found = []
|
||||||
@@ -175,7 +180,7 @@ class TaskControl:
|
|||||||
async def run_interruptibly[T](
|
async def run_interruptibly[T](
|
||||||
self, operation: Awaitable[T], *, on_pause=None, on_resume=None
|
self, operation: Awaitable[T], *, on_pause=None, on_resume=None
|
||||||
) -> tuple[bool, T | None]:
|
) -> tuple[bool, T | None]:
|
||||||
"""Park a task during a long model/helper call, then discard its stale result.
|
"""Interrupt a stale model/helper call for pause or fresh user guidance.
|
||||||
|
|
||||||
Only use for operations whose cancellation cannot leave a device action
|
Only use for operations whose cancellation cannot leave a device action
|
||||||
half executed. The caller must return to fresh perception after resume.
|
half executed. The caller must return to fresh perception after resume.
|
||||||
@@ -189,6 +194,10 @@ class TaskControl:
|
|||||||
await asyncio.gather(running, return_exceptions=True)
|
await asyncio.gather(running, return_exceptions=True)
|
||||||
await self.checkpoint(on_pause=on_pause, on_resume=on_resume)
|
await self.checkpoint(on_pause=on_pause, on_resume=on_resume)
|
||||||
return True, None
|
return True, None
|
||||||
|
if has_pending_instruction(self.directory):
|
||||||
|
running.cancel()
|
||||||
|
await asyncio.gather(running, return_exceptions=True)
|
||||||
|
return True, None
|
||||||
if running.done():
|
if running.done():
|
||||||
return False, running.result()
|
return False, running.result()
|
||||||
await asyncio.wait({running}, timeout=0.2)
|
await asyncio.wait({running}, timeout=0.2)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Execution budgets shared by supervised web and MCP tasks."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import time
|
||||||
|
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
def task_timeout(item: dict, job: dict | None = None, batch: dict | None = None) -> int:
|
||||||
|
options = item.get("runtime_overrides") or (job or {}).get("case", {}).get("task_config") or {}
|
||||||
|
return int(
|
||||||
|
options.get("timeout_seconds")
|
||||||
|
or (batch or {}).get("timeout_seconds")
|
||||||
|
or settings.ARTEMIS_TASK_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExecutionBudget:
|
||||||
|
limit: float
|
||||||
|
elapsed: float = 0
|
||||||
|
_last: float = field(default_factory=time.monotonic)
|
||||||
|
_excluded: bool = False
|
||||||
|
|
||||||
|
def update(self, *, excluded: bool = False) -> float:
|
||||||
|
now = time.monotonic()
|
||||||
|
if not self._excluded:
|
||||||
|
self.elapsed += max(0, now - self._last)
|
||||||
|
self._last, self._excluded = now, excluded
|
||||||
|
return max(0, self.limit - self.elapsed)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Persist useful failure evidence even when a worker must be terminated."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from artemis.config import TRACES_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def read_worker_outcome(session_id: str) -> dict:
|
||||||
|
"""Read a worker's terminal signal without waiting for hung SDK threads."""
|
||||||
|
try:
|
||||||
|
data = json.loads(
|
||||||
|
(TRACES_PATH / session_id / "run_outcome.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
isinstance(data, dict)
|
||||||
|
and data.get("worker_finalized") is True
|
||||||
|
and data.get("task_status")
|
||||||
|
in {
|
||||||
|
"failed",
|
||||||
|
"completed",
|
||||||
|
"cancelled",
|
||||||
|
"interrupted",
|
||||||
|
}
|
||||||
|
):
|
||||||
|
return data
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass # Atomic outcome is absent until the task finalizes.
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_failure_result(session_id: str, error: str, *, code: str = "execution_failed") -> None:
|
||||||
|
if not session_id or not all(c.isalnum() or c in "-_" for c in session_id):
|
||||||
|
raise ValueError("无效的任务编号")
|
||||||
|
directory = TRACES_PATH / session_id
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload: dict = {}
|
||||||
|
try:
|
||||||
|
previous = json.loads((directory / "result.json").read_text(encoding="utf-8"))
|
||||||
|
if isinstance(previous, dict):
|
||||||
|
payload.update(previous)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass # Missing/truncated results are expected after a killed worker.
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"task_status": "failed",
|
||||||
|
"explanation": error,
|
||||||
|
"error": error,
|
||||||
|
"code": code,
|
||||||
|
"worker_finalized": True,
|
||||||
|
"tests": payload.get("tests") or {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for name in ("result.json", "run_outcome.json"):
|
||||||
|
target = directory / name
|
||||||
|
temporary = target.with_name(f".{name}.{uuid.uuid4().hex}.tmp")
|
||||||
|
try:
|
||||||
|
temporary.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||||
|
os.replace(temporary, target)
|
||||||
|
finally:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
@@ -32,6 +32,7 @@ import shutil
|
|||||||
from shutil import which
|
from shutil import which
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from types import NoneType
|
from types import NoneType
|
||||||
from typing import Any, TypeVar, overload
|
from typing import Any, TypeVar, overload
|
||||||
import uuid
|
import uuid
|
||||||
@@ -573,6 +574,13 @@ class Agent:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
if device_lock is not None and os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
if device_lock is not None and os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
||||||
|
from artemis.runtime.task_control import control_dir, _write_atomic
|
||||||
|
|
||||||
|
if sess_id and os.environ.get("ARTEMIS_TASK_WORKER") == "1":
|
||||||
|
_write_atomic(
|
||||||
|
control_dir(str(sess_id)) / "task_control_state.json",
|
||||||
|
{"status": "queued", "updated_at": time.time(), "pid": os.getpid()},
|
||||||
|
)
|
||||||
queue_cancel_event = threading.Event()
|
queue_cancel_event = threading.Event()
|
||||||
acquire_task = asyncio.create_task(
|
acquire_task = asyncio.create_task(
|
||||||
asyncio.to_thread(
|
asyncio.to_thread(
|
||||||
@@ -605,6 +613,7 @@ class Agent:
|
|||||||
|
|
||||||
task_control = TaskControl(str(sess_id), device_lock)
|
task_control = TaskControl(str(sess_id), device_lock)
|
||||||
context.task_control = task_control
|
context.task_control = task_control
|
||||||
|
task_control._state("running")
|
||||||
self._prepare_output_files(task=task)
|
self._prepare_output_files(task=task)
|
||||||
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
|
||||||
if self._ui_adb_client is not None:
|
if self._ui_adb_client is not None:
|
||||||
@@ -712,6 +721,14 @@ class Agent:
|
|||||||
state=last_state_snapshot,
|
state=last_state_snapshot,
|
||||||
error=err,
|
error=err,
|
||||||
)
|
)
|
||||||
|
from artemis.runtime.task_result import save_failure_result
|
||||||
|
|
||||||
|
if sess_id:
|
||||||
|
save_failure_result(
|
||||||
|
str(sess_id),
|
||||||
|
err,
|
||||||
|
code=flash_result.get("code", "execution_failed"),
|
||||||
|
)
|
||||||
if context.data_engine:
|
if context.data_engine:
|
||||||
context.data_engine.end_session("failed")
|
context.data_engine.end_session("failed")
|
||||||
return output
|
return output
|
||||||
@@ -862,6 +879,10 @@ class Agent:
|
|||||||
state=last_state_snapshot,
|
state=last_state_snapshot,
|
||||||
error=err,
|
error=err,
|
||||||
)
|
)
|
||||||
|
from artemis.runtime.task_result import save_failure_result
|
||||||
|
|
||||||
|
if sess_id:
|
||||||
|
save_failure_result(str(sess_id), err)
|
||||||
if context.data_engine:
|
if context.data_engine:
|
||||||
context.data_engine.end_session("failed")
|
context.data_engine.end_session("failed")
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ from artemis.llm.reliability import (
|
|||||||
classify_failure,
|
classify_failure,
|
||||||
retry_policy_for,
|
retry_policy_for,
|
||||||
)
|
)
|
||||||
|
from artemis.llm.messages import adapt_responses_messages
|
||||||
from artemis.llm.router import ModelEndpoint, ModelFactory, ModelProvider
|
from artemis.llm.router import ModelEndpoint, ModelFactory, ModelProvider
|
||||||
from artemis.services.token_meter import record_llm_usage
|
from artemis.services.token_meter import record_llm_usage
|
||||||
from artemis.llm.structured import (
|
from artemis.llm.structured import (
|
||||||
@@ -568,6 +569,25 @@ class RobustChatModelWrapper:
|
|||||||
args, kwargs = _inject_parent_trace_id(trace_id, *args, **kwargs)
|
args, kwargs = _inject_parent_trace_id(trace_id, *args, **kwargs)
|
||||||
return args, kwargs, trace_id
|
return args, kwargs, trace_id
|
||||||
|
|
||||||
|
def _prepare_model_call(self, args: tuple, kwargs: dict) -> tuple[tuple, dict]:
|
||||||
|
model_name = self.endpoint.model_name if self.endpoint else None
|
||||||
|
if (
|
||||||
|
args
|
||||||
|
and isinstance(args[0], (list, tuple))
|
||||||
|
and all(isinstance(message, BaseMessage) for message in args[0])
|
||||||
|
):
|
||||||
|
return (adapt_responses_messages(args[0], model_name), *args[1:]), kwargs
|
||||||
|
if (
|
||||||
|
"input" in kwargs
|
||||||
|
and isinstance(kwargs["input"], (list, tuple))
|
||||||
|
and all(isinstance(message, BaseMessage) for message in kwargs["input"])
|
||||||
|
):
|
||||||
|
return args, {
|
||||||
|
**kwargs,
|
||||||
|
"input": adapt_responses_messages(kwargs["input"], model_name),
|
||||||
|
}
|
||||||
|
return args, kwargs
|
||||||
|
|
||||||
def _meter_usage(self, response) -> None:
|
def _meter_usage(self, response) -> None:
|
||||||
"""Record measured token usage for this call (M0 metering, best-effort)."""
|
"""Record measured token usage for this call (M0 metering, best-effort)."""
|
||||||
engine = self.ctx.data_engine if self.ctx else None
|
engine = self.ctx.data_engine if self.ctx else None
|
||||||
@@ -576,6 +596,7 @@ class RobustChatModelWrapper:
|
|||||||
record_llm_usage(engine, response, source=self._endpoint_key())
|
record_llm_usage(engine, response, source=self._endpoint_key())
|
||||||
|
|
||||||
async def ainvoke(self, *args, **kwargs):
|
async def ainvoke(self, *args, **kwargs):
|
||||||
|
args, kwargs = self._prepare_model_call(args, kwargs)
|
||||||
args, kwargs, _ = self._traced_call(args, kwargs)
|
args, kwargs, _ = self._traced_call(args, kwargs)
|
||||||
response = await _run_with_recovery(
|
response = await _run_with_recovery(
|
||||||
functools.partial(self.base_model.ainvoke, *args, **kwargs),
|
functools.partial(self.base_model.ainvoke, *args, **kwargs),
|
||||||
@@ -597,6 +618,7 @@ class RobustChatModelWrapper:
|
|||||||
signals the UI to drop it, and retries the whole call per policy;
|
signals the UI to drop it, and retries the whole call per policy;
|
||||||
partial or duplicated chunks can never reach message history.
|
partial or duplicated chunks can never reach message history.
|
||||||
"""
|
"""
|
||||||
|
args, kwargs = self._prepare_model_call(args, kwargs)
|
||||||
args, kwargs, _ = self._traced_call(args, kwargs)
|
args, kwargs, _ = self._traced_call(args, kwargs)
|
||||||
emit_deltas = bool(self.ctx and self.ctx.data_engine)
|
emit_deltas = bool(self.ctx and self.ctx.data_engine)
|
||||||
response = await _run_with_recovery(
|
response = await _run_with_recovery(
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Prepare Android screenshots for model input without changing device coordinates."""
|
||||||
|
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
def limit_screenshot_long_edge(image_bytes: bytes, max_long_edge: int) -> bytes:
|
||||||
|
"""Downscale oversized screenshots; keep smaller images byte-for-byte unchanged."""
|
||||||
|
try:
|
||||||
|
with Image.open(BytesIO(image_bytes)) as image:
|
||||||
|
long_edge = max(image.size)
|
||||||
|
if long_edge <= max_long_edge:
|
||||||
|
return image_bytes
|
||||||
|
scale = max_long_edge / long_edge
|
||||||
|
size = tuple(max(1, round(side * scale)) for side in image.size)
|
||||||
|
resized = image.convert("RGB").resize(size, Image.Resampling.LANCZOS)
|
||||||
|
output = BytesIO()
|
||||||
|
resized.save(output, format="JPEG", quality=80, optimize=True)
|
||||||
|
return output.getvalue()
|
||||||
|
except OSError:
|
||||||
|
# Preserve the capture path's previous behavior for invalid placeholder images.
|
||||||
|
return image_bytes
|
||||||
@@ -615,9 +615,9 @@ async def render_timeline_clip(
|
|||||||
)
|
)
|
||||||
labels.append(f"[{label}]")
|
labels.append(f"[{label}]")
|
||||||
if len(labels) == 1:
|
if len(labels) == 1:
|
||||||
filter_parts.append(f"{labels[0]}null[outv]")
|
filter_parts.append(f"{labels[0]}fps={fps}[outv]")
|
||||||
else:
|
else:
|
||||||
filter_parts.append(f"{''.join(labels)}concat=n={len(labels)}:v=1:a=0[outv]")
|
filter_parts.append(f"{''.join(labels)}concat=n={len(labels)}:v=1:a=0,fps={fps}[outv]")
|
||||||
|
|
||||||
command.extend(
|
command.extend(
|
||||||
[
|
[
|
||||||
|
|||||||
+13
-1
@@ -13,7 +13,14 @@
|
|||||||
"presets": {},
|
"presets": {},
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"planner": {},
|
"planner": {},
|
||||||
"operator": {},
|
"operator": {
|
||||||
|
"fallback": {
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "${env:OPENAI_TOOL_FALLBACK_MODEL:-gpt-6-luna}",
|
||||||
|
"reasoning_effort": "${env:OPENAI_TOOL_FALLBACK_REASONING_EFFORT:-high}",
|
||||||
|
"max_retries": "${env:OPENAI_TOOL_FALLBACK_MAX_RETRIES:-3}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"video_analyzer": {},
|
"video_analyzer": {},
|
||||||
"object_detector": {},
|
"object_detector": {},
|
||||||
"explorer": {},
|
"explorer": {},
|
||||||
@@ -27,12 +34,17 @@
|
|||||||
"checker": {}
|
"checker": {}
|
||||||
},
|
},
|
||||||
"agent": {
|
"agent": {
|
||||||
|
"screenshot": {
|
||||||
|
"enabled": "${env:ARTEMIS_SCREENSHOT_LIMIT_ENABLED:-true}",
|
||||||
|
"max_long_edge": 2048
|
||||||
|
},
|
||||||
"flash": {
|
"flash": {
|
||||||
"max_turns": 0,
|
"max_turns": 0,
|
||||||
"explorer_mode": "flash",
|
"explorer_mode": "flash",
|
||||||
"step_summarizer": {
|
"step_summarizer": {
|
||||||
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
||||||
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
||||||
|
"timeout_seconds": "${env:ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS:-120}",
|
||||||
"prune_history_xml": true
|
"prune_history_xml": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+14
-4
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
源码内的 `skills/artemis-automation/` 是可分发 Skill,不依赖本机 Codex 的私有能力。将整个目录复制到宿主支持的 Skill 目录即可;Codex 为 `$CODEX_HOME/skills/`(未配置时 `~/.codex/skills/`)。仓库包含 Skill,启动程序不会修改用户的全局 Skill 目录。
|
源码内的 `skills/artemis-automation/` 是可分发 Skill,不依赖本机 Codex 的私有能力。将整个目录复制到宿主支持的 Skill 目录即可;Codex 为 `$CODEX_HOME/skills/`(未配置时 `~/.codex/skills/`)。仓库包含 Skill,启动程序不会修改用户的全局 Skill 目录。
|
||||||
|
|
||||||
先完成项目环境和模型配置,然后在项目目录执行 `uv run artemis mcp --install codex` 安装/更新 MCP 配置。其他客户端将 `codex` 换成对应客户端;`uv run artemis mcp --help` 可查看支持列表。更新后重新加载宿主 MCP;已有配置若设置了工具白名单,需要重新安装配置或加入新增工具。复制 Skill 后按宿主方式重新加载技能。
|
目前仅支持 Codex MCP 配置。Windows 一键脚本检测并同步 Codex;未检测到配置目录时,在项目目录执行 `uv run artemis mcp --install codex`。配置模板使用 `uv run artemis mcp --generate-config codex` 查看;其他名称和 `all` 不再支持。更新后重新加载 Codex MCP;复制 Skill 后重新加载技能。
|
||||||
|
|
||||||
可以向 AI 这样下达任务:
|
可以向 AI 这样下达任务:
|
||||||
|
|
||||||
@@ -36,12 +36,22 @@ Windows 已安装用户再次运行 `start.bat` / `start-cn.bat` 时会检查当
|
|||||||
|
|
||||||
Python 每次按 `uv.lock` 检查并同步缺失/变化的依赖;前端依据源码、资源及包锁文件指纹决定是否重新执行 `npm ci` 和构建,成功后才记录指纹。国内镜像选项继续生效。检测到正在执行/排队的任务时,自动更新启动会停止,让原任务继续。
|
Python 每次按 `uv.lock` 检查并同步缺失/变化的依赖;前端依据源码、资源及包锁文件指纹决定是否重新执行 `npm ci` 和构建,成功后才记录指纹。国内镜像选项继续生效。检测到正在执行/排队的任务时,自动更新启动会停止,让原任务继续。
|
||||||
|
|
||||||
MCP 自动刷新只处理已存在且指向当前项目的标准 Python 模块配置,更新解释器路径和已有工具白名单。保留用户环境变量、禁用状态、禁用工具及其他服务;JSON/TOML 无法安全解析、其他项目和自定义启动包装器保持原样。首次修改保留备份,未接入的客户端不会自动安装。源码更新后需在 AI 客户端重新加载 MCP 连接以使用新实现;脚本不会强制重启你的 IDE。
|
MCP 同步仅检查 `$CODEX_HOME/config.toml`;未设置或为空时使用当前用户的 `~/.codex/config.toml`。日志会显示实际路径,以及新装、更新、已是最新、未检测到或自定义跳过的结果。检测到目录但缺少 ARTEMIS 条目时安装当前源码的标准配置;已有标准配置即使指向旧解压目录,也会更新解释器、项目目录、环境路径和工具清单。保留用户额外环境变量、禁用状态及其他服务;自定义启动包装器保持原样并提示。其他客户端的配置和规则文件不读取、不修改,其格式错误不会阻止启动。Codex 配置无法安全解析时停止启动,原文件不被覆盖;可使用 `-SkipMcpSync` 仅启动网页。首次修改保留备份。同步后需在 Codex 中重新加载 MCP 或重启 Codex,脚本无法替宿主重启已加载的 MCP 进程。
|
||||||
|
|
||||||
创建批次时必须提供唯一 `request_id`;请求超时后可以按编号查询或用相同内容重发,避免重复用例。创建只保存草稿,`start` 才执行。AI 无需先制造 Excel,也可直接提交 JSON;字段与示例见 [Skill 批量任务参考](../skills/artemis-automation/references/batches.md)。
|
创建批次时必须提供唯一 `request_id`;请求超时后可以按编号查询或用相同内容重发,避免重复用例。创建只保存草稿,`start` 才执行。AI 无需先制造 Excel,也可直接提交 JSON;字段与示例见 [Skill 批量任务参考](../skills/artemis-automation/references/batches.md)。
|
||||||
|
|
||||||
报告导出到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。
|
报告按任务或批次归档到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接;删除批次时会清理对应导出副本。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。
|
||||||
|
|
||||||
模型默认使用 `mimo-v2.6-flash`,也预设 `gpt-5.6-luna` 并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,每次等待 10 秒,SDK 不叠加重试。耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停先返回 `pausing`,确认状态为 `task_paused` 才表示设备已释放;Pro 等待模型回复或只读诊断时可及时中断,恢复后重新观察设备。AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复,用 `guidance_history` 核对是否领取,或用 `resume` 单独恢复。
|
模型默认使用 `mimo-v2.6-flash`,预设 `gpt-5.6-luna`、`gpt-6-luna`、`mimo-v2.6-pro`,并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,普通错误每次等待 10 秒,429 每次等待 60 秒,SDK 不叠加重试。首选模型连续 3 次没有有效工具调用时,当前步骤改用 `gpt-6-luna`(high),最多再试 3 次;下一步重新使用首选模型。兜底模型及次数可通过 `.env.example` 中的 `OPENAI_TOOL_FALLBACK_*` 和 `ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS` 配置。请求耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停先返回 `pausing`,确认状态为 `task_paused` 才表示设备已释放;Pro 等待模型回复或只读诊断时可及时中断,恢复后重新观察设备。AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复;运行中的模型思考会中断并重新观察设备,已经开始的设备动作会安全结束。用 `guidance_history` 核对指导是否被领取、在任务流程中核对实际执行情况,或用 `resume` 单独恢复。
|
||||||
|
|
||||||
|
429 等待、兜底模型及次数也可在网页“模型配置 → 稳定性与兜底模型”或通过已授权的 `mobile_config(section="model")` 配置。批次 ZIP 还收录每项任务已保存的步骤截图和录屏。
|
||||||
|
|
||||||
|
批次任务在队列中使用“用例编号 + 用例名称”显示,提示词仍是模型实际收到的内容。任务历史展示失败原因、完成时间和总耗时;导出全部资料时以用例编号命名目录。删除非活跃批次会同步清理其任务记录、步骤、截图、录屏及生成的报告,删除后不能通过旧 `request_id` 重复执行。
|
||||||
|
|
||||||
保存全局配置要求用户明确指令;只读配置不包含密钥,MCP 不接收密钥。密钥仍在网页或 `.env` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。
|
保存全局配置要求用户明确指令;只读配置不包含密钥,MCP 不接收密钥。密钥仍在网页或 `.env` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。
|
||||||
|
|
||||||
|
执行保护默认总时限 1800 秒,单任务、批次和 MCP 都适用,排队及用户暂停不计时。设备观测默认 60 秒、最多 3 次,ADB 命令默认 10 秒;观测失败不会继续向模型发送占位图或旧元素索引。Flash 连续 3 次动作失败先重新观察再切换兜底,兜底仍失败则结束任务并保存原因。不能以提高步数或反复重提任务代替设备诊断。
|
||||||
|
|
||||||
|
`mobile_run_task(runtime_overrides={"timeout_seconds":1800,"observation_timeout_seconds":60,"observation_max_attempts":3,"device_command_timeout_seconds":10,"action_failure_attempts":3})` 可为单任务覆盖执行保护;其中 `model` 是 API 模型名,外层 `model` 仍是 Flash/Pro 模式。相同字段也支持批次的 `task_config`。全局默认通过网页功能配置或明确授权的 `mobile_config(section="capabilities", changes={"task_timeout_seconds":1800}, persist_defaults=true)` 保存,对后续任务生效,不接受密钥。
|
||||||
|
|
||||||
|
任务已经产生最终结果,但 SDK 线程或资源清理仍卡住时,默认最多再等待 30 秒后回收工作进程,避免继续占用队列。全局可用 `ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS` 或功能配置的 `worker_cleanup_timeout_seconds` 调整,任务 JSON 同名字段可覆盖。原有结果与截图保留,未完成保存的录屏尽力恢复。
|
||||||
@@ -20,11 +20,13 @@
|
|||||||
4. 上传文件,查看逐行校验结果。所有错误修正后才能提交。
|
4. 上传文件,查看逐行校验结果。所有错误修正后才能提交。
|
||||||
5. 明确选择设备、执行模式和超时时间,然后点击“确认导入并开始执行”。
|
5. 明确选择设备、执行模式和超时时间,然后点击“确认导入并开始执行”。
|
||||||
|
|
||||||
同一批次按顺序派发。默认失败后继续且不自动重试,也可以选择失败即停止。设备不可用、模型暂停或任务超时会暂停后续派发。暂停批次只停止后续派发。单个任务可在步骤间安全暂停,释放设备;批次下一项可继续,暂停的任务待提交指导或点击继续后在设备队列末尾恢复。运行中提交指导在下一步执行前生效,不打断当前步骤。暂停等待和重新排队时间不计入该任务执行时限。
|
同一批次按顺序派发。默认失败后继续且不自动重试,也可以选择失败即停止。当前任务因设备异常或超时失败后,默认继续后续用例;设置失败即停止时才停止后续派发。暂停批次只停止后续派发。单个任务可在安全边界暂停并释放设备;批次下一项可继续,暂停的任务待提交指导或点击继续后在设备队列末尾恢复。运行中提交指导会中断当前模型思考或只读诊断,重新观察画面;已经开始的设备动作会安全结束。网页流程会显示指导的提交与领取状态,仍应检查后续动作是否符合指导。暂停等待和重新排队时间不计入该任务执行时限。
|
||||||
|
|
||||||
服务重启后保留批次,等待用户继续未开始的用例。正在执行或暂停的任务标记中断,不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。
|
服务启动前先取得监听端口,重复启动不会修改现有任务或服务信息。恢复时核对原任务进程和设备锁:仍存活的任务继续等待其真实结果,退出后补齐报告;已经退出且没有终态的任务标记中断,不自动重做,批次等待用户继续未开始的用例。“复制失败项为新批次”保留原记录并产生新任务编号。任务正常完成后,已失效的“服务中断”提示归档为恢复事件,不再显示为当前失败原因;启动时也会纠正已有完成记录中的此类旧提示。
|
||||||
|
|
||||||
每个任务结束后生成所选格式报告。批次结束后生成汇总及每项任务报告的 ZIP;失败、跳过和中断任务也会列入。暂停时下载的是当前快照。录屏与 AI 视频分析分别配置,录屏默认关闭;仅开启分析时,录制内容用于本次分析,结束后不保留下载文件。暂停期间不录制其他任务的设备画面。
|
每个任务结束后生成所选格式报告。批次 ZIP 包含汇总、每项任务的报告和已保存的有序步骤截图;已开启录屏且文件存在时,还会按任务收录录屏片段。失败、跳过和中断任务也会列入。暂停时下载的是当前快照。录屏与 AI 视频分析分别配置,录屏默认关闭;仅开启分析时,录制内容用于本次分析,结束后不保留下载文件。暂停期间不录制其他任务的设备画面。
|
||||||
|
|
||||||
|
队列中批量任务按“用例编号 + 测试用例”显示,悬停可看完整提示词;失败原因、完成时间及总耗时在历史中可查看。单任务“下载全部资料”以用例编号命名压缩包内目录。删除已结束的批次会同步删除关联任务记录、步骤、截图、录屏、报告和 MCP 导出副本;正在执行或排队的批次不能删除。
|
||||||
|
|
||||||
## AI 使用
|
## AI 使用
|
||||||
|
|
||||||
@@ -32,6 +34,12 @@
|
|||||||
|
|
||||||
## 环境配置
|
## 环境配置
|
||||||
|
|
||||||
`.env` 可配置 `ARTEMIS_BATCH_TASK_TIMEOUT`(默认 1800 秒)、`ARTEMIS_IMPORT_MAX_ROWS`(默认 1000 行)和 `ARTEMIS_IMPORT_MAX_BYTES`(默认 10 MiB)。修改后重启服务。网页的单批次超时设置优先于环境默认值。
|
`.env` 可配置 `ARTEMIS_TASK_TIMEOUT_SECONDS`(单任务总执行时限,默认 1800 秒)、`ARTEMIS_IMPORT_MAX_ROWS`(默认 1000 行)和 `ARTEMIS_IMPORT_MAX_BYTES`(默认 10 MiB)。修改后重启服务。网页的单批次超时设置优先于环境默认值,每行 JSON 的 `timeout_seconds` 优先于批次。单任务、批次和 MCP 均受总执行时限保护,不计排队和用户暂停时间;即使执行进程卡住,也由父进程终止并保存失败原因。
|
||||||
|
|
||||||
批次、任务元数据和报告偏好保存在现有 SQLite 数据库的 `automation_records`、`automation_jobs` 表。清空任务历史同时清除批次与执行元数据;已导出的文件保留。
|
守护服务通过轻量状态接口探测,`ARTEMIS_DAEMON_PROBE_TIMEOUT_SECONDS` 默认 2 秒。短暂超时且端口仍被存活的原服务占用时复用原服务,不重复启动;未知进程占用端口时明确拒绝覆盖。
|
||||||
|
|
||||||
|
网页“功能配置 → 执行保护”及 MCP `mobile_config(section="capabilities")` 可配置总时限、设备观测时限(默认 60 秒)、设备观测次数(默认 3 次)、ADB 命令超时(默认 10 秒)、连续动作失败次数(默认 3 次)。任务 JSON 可用 `observation_timeout_seconds`、`observation_max_attempts`、`device_command_timeout_seconds`、`action_failure_attempts` 覆盖。截图失效或收到占位图时先有限重取,耗尽后结束任务,不把旧画面发给模型;有效截图即使没有 XML 也可按坐标定位。连续动作失败时先刷新画面再尝试兜底,兜底耗尽后结束,不循环重提用例。
|
||||||
|
|
||||||
|
批次、任务元数据和报告偏好保存在现有 SQLite 数据库的 `automation_records`、`automation_jobs` 表。全局“清空任务历史”会清除批次与执行元数据;它与上文的单批次删除不同,已导出的文件会保留。
|
||||||
|
|
||||||
|
任务已经产生最终结果,但 SDK 线程或资源清理仍卡住时,默认最多再等待 30 秒后回收工作进程,避免继续占用队列。全局可用 `ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS` 或功能配置的 `worker_cleanup_timeout_seconds` 调整,任务 JSON 同名字段可覆盖。原有结果与截图保留,未完成保存的录屏尽力恢复。
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# ARTEMIS-Eapil 1.1.2
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- Windows 一键脚本在依赖同步后,为已检测到的 AI 客户端安装或更新 ARTEMIS MCP。旧配置指向其他解压目录时,会迁移到当前目录,并更新 Python、启动参数、环境路径和工具清单。
|
||||||
|
- 同步保留已有密钥、禁用状态、其他 MCP 服务及自定义设置。自定义启动命令会提示跳过;配置无法安全解析时明确停止启动,原文件保持不变。仅需网页时可用 `-SkipMcpSync`。
|
||||||
|
- 源码升级后会重新载入新版启动脚本再同步 MCP。客户端需重新加载 MCP 或重启,才能让已运行的旧 MCP 进程退出。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
下载发布附件 `artemis-eapil-1.1.2-source.zip`,解压后运行 `start.bat` 或国内镜像入口 `start-cn.bat`。附件包含精简 Git 仓库,后续可通过启动脚本检查更新。Gitea 自动生成的“源代码 ZIP”没有 `.git`,不能通过脚本拉取更新。
|
||||||
|
|
||||||
|
后端默认测试 2170 项通过、6 项跳过;Ruff、质量基线、核心类型检查通过。附件同时提供 SHA-256 校验文件。
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# ARTEMIS-Eapil 1.2.0
|
||||||
|
|
||||||
|
## 工作区改进
|
||||||
|
|
||||||
|
- 批量任务在队列和历史中显示“用例编号 + 测试用例”;悬停标题可查看完整提示词。任务状态、完成时间、总耗时和失败原因更易辨认。
|
||||||
|
- 超时失败会写明原因和批次时限。单任务资料 ZIP 以用例编号命名目录。
|
||||||
|
- 删除已结束的批次会清理关联任务、步骤、截图、录屏和报告,包括 MCP 导出副本;仍被其他任务引用的截图会保留。正在执行或排队的批次不能删除。
|
||||||
|
- 网页或 MCP 提交指导时,中断当前模型思考并重新观察设备。已开始的设备动作安全结束;流程显示指导提交、领取和后续动作。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
下载附件 `artemis-eapil-1.2.0-source.zip`,解压后运行 `start.bat`;国内网络可运行 `start-cn.bat`。附件包含精简 Git 仓库,可供后续启动脚本检查更新。Gitea 自动生成的源码 ZIP 不含 `.git`,请使用发布附件。
|
||||||
|
|
||||||
|
升级后刷新网页,并在 AI 客户端重新加载 MCP。验证:Python 默认测试 2177 项通过、6 项跳过;前端 149 项通过且生产构建成功;Ruff、质量基线和核心类型检查通过。
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# ARTEMIS-Eapil 1.2.1
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- 批量任务达到执行超时后,该用例记录为失败,不再自动暂停整批。当前用例结算后继续执行下一项;开启“失败即停止”的批次仍会跳过剩余项。排队等待不计入执行超时。
|
||||||
|
- Flash 连续收到无工具调用的回复时,最多尝试 3 次并给出明确失败原因,避免持续空转和重复计费。重试会保留设备观察与用户指导,但不把模型的无效回复送回上下文。可通过 `ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS` 调整上限。
|
||||||
|
|
||||||
|
模型请求中的相同缓存 token 数量通常是提示词前缀缓存命中,不能据此判断缓存造成循环。本版针对无工具调用的执行循环建立退出条件。
|
||||||
|
|
||||||
|
## 安装与验证
|
||||||
|
|
||||||
|
请下载附件 `artemis-eapil-1.2.1-source.zip`,解压后运行 `start.bat`;国内网络使用 `start-cn.bat`。附件包含精简 Git 仓库,可供后续启动脚本检查更新。Gitea 自动生成的源码 ZIP 不含 `.git`,请使用发布附件。升级后重启 ARTEMIS 服务、刷新网页并重新加载 AI 客户端的 MCP。
|
||||||
|
|
||||||
|
验证:Python 默认测试 2182 项通过、6 项跳过;Ruff、质量基线和核心类型检查通过。
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# ARTEMIS-Eapil 1.2.2
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- 任务报告区分实际执行失败与可恢复的诊断警告。屏幕唤醒锁清理超时、步骤视觉摘要重试不再被误写为任务或模型请求超时;真实终止异常仍会显示。
|
||||||
|
- 旧屏幕唤醒锁清理遇到设备锁繁忙时快速跳过,避免队列任务因清理动作等待或连续失败。长驻服务负责后续清理。
|
||||||
|
- Flash 步骤视觉摘要使用独立超时,默认 25 秒,可通过 `ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS` 设置,不再占用主模型请求的 300 秒超时额度。
|
||||||
|
|
||||||
|
## 安装与验证
|
||||||
|
|
||||||
|
下载附件 `artemis-eapil-1.2.2-source.zip`,解压后运行 `start.bat`;国内网络使用 `start-cn.bat`。附件含精简 Git 仓库,后续可由启动脚本检查更新。升级后重启 ARTEMIS 服务、刷新网页并重新加载 AI 客户端的 MCP。
|
||||||
|
|
||||||
|
验证:Python 默认测试 2191 项通过、2 项跳过、8 项按标记排除;Ruff、质量基线和核心类型检查通过。两项真实模拟器 Flash 批量任务均完成;干净环境未复现原先的锁争用,锁繁忙与报告归因通过回归测试覆盖。
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# ARTEMIS-Eapil 1.2.3
|
||||||
|
|
||||||
|
## 更新
|
||||||
|
|
||||||
|
- 视觉步骤摘要的单次模型请求默认超时从 25 秒延长至 120 秒;任务收尾不会再在 30 秒时提前取消仍在进行的摘要。可通过 `ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS` 调整。
|
||||||
|
- Pro 与 Flash 共用的步骤摘要服务现在都会读取该超时配置。
|
||||||
|
- 本版本还包含尚未发布的 Responses 消息与视频导出修复,以及设备观察稳定性改进。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
下载 `artemis-eapil-1.2.3-source.zip`,解压后运行 `start.bat`,国内网络使用 `start-cn.bat`。附件包含可继续更新的 Git 仓库。已有安装可按原方式运行启动脚本更新;升级后重启 ARTEMIS 服务,并重新加载 MCP。
|
||||||
|
|
||||||
|
验证:Python 默认测试 2207 项通过、2 项跳过、8 项按标记排除;Ruff、质量基线、核心类型检查、前端 149 项测试及构建均通过。
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# ARTEMIS-Eapil 1.3.0
|
||||||
|
|
||||||
|
## 更新
|
||||||
|
|
||||||
|
- 截图默认限制长边为 2048 像素,可在网页或任务 JSON 中关闭限制,改用设备原始分辨率。
|
||||||
|
- 首选模型连续 3 次未完成工具调用时,当前步骤可交给默认的 `gpt-6-luna`(high)备用模型继续;下个步骤恢复首选模型。HTTP 429 默认等待 60 秒再重试。
|
||||||
|
- 批次资料包包含各任务的报告、步骤截图和已有录屏。下载前显示打包进度、耗时和预计剩余时间;已完成的资料包直接复用。
|
||||||
|
- 录像时间轴显示可点击的关键步骤,便于直接跳转复盘。
|
||||||
|
- 优化服务状态探测、CLI 命令识别及视频拼接后的帧率处理。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
下载附件 `artemis-eapil-1.3.0-source.zip`,解压后运行 `start.bat`;国内网络使用 `start-cn.bat`。附件含可继续更新的 Git 仓库。已有安装可通过启动脚本更新;升级后重启服务、刷新网页,并重新加载 MCP。
|
||||||
|
|
||||||
|
验证:Python 默认测试、Ruff、质量基线、核心类型检查、前端测试及构建通过。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# ARTEMIS-Eapil 1.3.1
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- 限制任务执行、设备观察、连续动作失败和资源清理的等待与重试。超时或失败后释放执行资源,让队列继续处理后续任务,避免单个任务长时间反复重试。
|
||||||
|
- 修复点击录像时间轴关键步骤后,视频暂停但播放按钮状态未同步的问题。播放中跳转后继续播放,暂停时跳转保持暂停,恢复播放只需点击一次。
|
||||||
|
- 修复服务探测超时后重复启动后台服务,导致仍在执行的任务被误标为“服务中断”的问题。启动时先占用监听端口,再初始化服务和恢复任务;已有服务占用端口时安全退出。
|
||||||
|
- 恢复任务时保留仍存活的执行进程,并跟踪其最终结果。完成任务会清除已失效的“服务中断”提示并保留恢复记录,真实执行失败和超时原因继续显示。
|
||||||
|
|
||||||
|
## 安装与升级
|
||||||
|
|
||||||
|
下载附件 `artemis-eapil-1.3.1-source.zip`,解压后运行 `start.bat`;国内网络使用 `start-cn.bat`。源码包包含可继续更新的 Git 仓库,不包含虚拟环境、构建产物、密钥和执行记录。
|
||||||
|
|
||||||
|
已有安装可通过启动脚本更新。升级后重启服务、刷新网页,并重新加载 MCP,让各入口使用新版实现。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- Python 默认测试:2246 项通过,6 项跳过,8 项未选中;最终恢复逻辑回归测试:65 项通过。
|
||||||
|
- 前端测试:169 项通过;前端构建、Ruff、质量基线、核心类型检查及 Git 差异检查通过。
|
||||||
|
- 使用独立进程验证重复启动不会修改现有任务或服务元数据;在浏览器验证录像关键步骤跳转及播放状态。
|
||||||
|
- 本次验证未运行真实 Android 任务;跨录像片段跳转通过单元测试验证。
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# ARTEMIS-Eapil 1.3.2
|
||||||
|
|
||||||
|
## 更新与修复
|
||||||
|
|
||||||
|
- MCP 配置安装与更新仅支持 Codex。一键启动不再检查或修改 VS Code、Cursor 等其他客户端的配置,其配置错误不会阻止网页启动。手动 CLI 仅接受 `--install codex` 和 `--generate-config codex`,不再支持其他客户端或 `all`。
|
||||||
|
- 启动日志明确显示实际检查的 Codex 配置路径,以及安装、更新、已是最新、未检测到或自定义跳过的结果。未设置或空值的 `CODEX_HOME` 使用当前用户的 `.codex` 目录;保留已有自定义路径设置。
|
||||||
|
- 修复 Windows 手动重复安装 Codex MCP 时,路径反斜杠被误当成正则替换符的问题。配置写入失败会返回失败状态。
|
||||||
|
- 固定批处理脚本为 CRLF 换行,修复中文启动脚本在部分源码包中将中文截断并当成命令执行的问题。
|
||||||
|
- 同步 README、AI 接入说明及随源码分发的 Skill。
|
||||||
|
|
||||||
|
## 安装与升级
|
||||||
|
|
||||||
|
下载附件 `artemis-eapil-1.3.2-source.zip`,解压后运行 `start.bat`;国内网络使用 `start-cn.bat`。附件包含可继续更新的 Git 仓库,不包含虚拟环境、构建产物、密钥和任务记录。请使用发布附件,Gitea 自动生成的“源代码”压缩包不含 Git 元数据。
|
||||||
|
|
||||||
|
已有安装可通过启动脚本更新。更新后重启服务、刷新网页,并重新加载 Codex MCP。未检测到 Codex 配置目录时,可运行 `uv run artemis mcp --install codex` 手动安装。仅需网页时使用 `-SkipMcpSync`。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
默认 Python 测试范围分批验证共 2267 项通过,6 项跳过,8 项按标记排除;Ruff、质量基线、核心类型检查和 Git 差异检查通过。已验证 Windows 中文启动与帮助输出、Codex 默认及自定义目录识别、重复安装、其他客户端配置保留,以及源码包 CRLF 和干净 Git 状态。本次没有修改前端交互,也未运行真实 Android 任务。
|
||||||
@@ -19,9 +19,26 @@ MODEL_FIELDS = {
|
|||||||
"timeout_seconds",
|
"timeout_seconds",
|
||||||
"max_retries",
|
"max_retries",
|
||||||
"retry_interval_seconds",
|
"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",
|
"reasoning_effort",
|
||||||
}
|
}
|
||||||
CAPABILITY_FIELDS = {"video_enabled", "outputter_enabled", "step_summarizer_enabled"}
|
CAPABILITY_FIELDS = {
|
||||||
|
"video_enabled",
|
||||||
|
"recording_enabled",
|
||||||
|
"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:
|
def _id(value: str | None) -> str:
|
||||||
@@ -67,10 +84,16 @@ async def _request(method: str, route: str, **kwargs) -> Any:
|
|||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
async def _download(route: str, filename: str) -> dict:
|
async def _download(
|
||||||
|
route: str,
|
||||||
|
filename: str,
|
||||||
|
*,
|
||||||
|
owner_kind: Literal["sessions", "batches"] | None = None,
|
||||||
|
owner_id: str | None = None,
|
||||||
|
) -> dict:
|
||||||
url = await _base_url()
|
url = await _base_url()
|
||||||
EXPORT_DIR.mkdir(parents=True, exist_ok=True)
|
directory = EXPORT_DIR / owner_kind / _id(owner_id) if owner_kind else EXPORT_DIR
|
||||||
path = EXPORT_DIR / f"{uuid4().hex[:12]}-{filename}"
|
path = directory / f"{uuid4().hex[:12]}-{filename}"
|
||||||
partial = path.with_suffix(path.suffix + ".part")
|
partial = path.with_suffix(path.suffix + ".part")
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(base_url=url, timeout=120, trust_env=False) as client:
|
async with httpx.AsyncClient(base_url=url, timeout=120, trust_env=False) as client:
|
||||||
@@ -78,6 +101,7 @@ async def _download(route: str, filename: str) -> dict:
|
|||||||
if response.is_error:
|
if response.is_error:
|
||||||
await response.aread()
|
await response.aread()
|
||||||
_check(response)
|
_check(response)
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
with partial.open("wb") as stream:
|
with partial.open("wb") as stream:
|
||||||
async for chunk in response.aiter_bytes():
|
async for chunk in response.aiter_bytes():
|
||||||
stream.write(chunk)
|
stream.write(chunk)
|
||||||
@@ -115,9 +139,16 @@ async def mobile_config(
|
|||||||
Only set persist_defaults=true when the user requests saved/global configuration.
|
Only set persist_defaults=true when the user requests saved/global configuration.
|
||||||
model changes: model (preset or custom), base_url, reasoning_effort
|
model changes: model (preset or custom), base_url, reasoning_effort
|
||||||
(default/none/minimal/low/medium/high/xhigh/max), timeout_seconds, max_retries,
|
(default/none/minimal/low/medium/high/xhigh/max), timeout_seconds, max_retries,
|
||||||
retry_interval_seconds. 'default' omits reasoning; unsupported values fail visibly.
|
retry_interval_seconds, rate_limit_retry_seconds, tool_fallback_model,
|
||||||
capabilities: video_enabled (recording + analysis), outputter_enabled,
|
tool_fallback_reasoning_effort, tool_fallback_max_retries,
|
||||||
step_summarizer_enabled. reports: formats=['docx','xlsx'] (at least one).
|
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).
|
||||||
|
reports: formats=['docx','xlsx'] (at least one).
|
||||||
API keys are never accepted or returned: use the Web UI or .env for credentials.
|
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
|
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.
|
profiles, not model names. Model retries do not repeat device test cases.
|
||||||
@@ -198,8 +229,8 @@ async def mobile_batch(
|
|||||||
use mobile_manage_task(stop) to stop that task. resume never repeats finished cases.
|
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
|
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.
|
transport-idempotent, so after an uncertain response use list instead of retrying.
|
||||||
delete removes a non-active batch record after an explicit user request; it keeps
|
delete removes a non-active batch and its task sessions, reports and files after
|
||||||
already executed task sessions and reports. Confirm the batch ID with get first.
|
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.
|
Restarted batches remain paused. No automatic failed-case retries or action replay.
|
||||||
"""
|
"""
|
||||||
if action == "create":
|
if action == "create":
|
||||||
@@ -270,7 +301,12 @@ async def mobile_export_report(
|
|||||||
return {
|
return {
|
||||||
"batch_id": bid,
|
"batch_id": bid,
|
||||||
"files": [
|
"files": [
|
||||||
await _download(f"/api/automation/batches/{bid}/archive", f"batch-{bid}.zip")
|
await _download(
|
||||||
|
f"/api/automation/batches/{bid}/archive",
|
||||||
|
f"batch-{bid}.zip",
|
||||||
|
owner_kind="batches",
|
||||||
|
owner_id=bid,
|
||||||
|
)
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
sid = _id(session_id)
|
sid = _id(session_id)
|
||||||
@@ -286,10 +322,15 @@ async def mobile_export_report(
|
|||||||
):
|
):
|
||||||
raise ValueError("formats 需选择 docx、xlsx、screenshots、video 或 all")
|
raise ValueError("formats 需选择 docx、xlsx、screenshots、video 或 all")
|
||||||
report = await _request("GET", f"/api/automation/reports/{sid}")
|
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 = [
|
files = [
|
||||||
await _download(
|
await _download(
|
||||||
f"/api/automation/reports/{sid}/{fmt}",
|
f"/api/automation/reports/{sid}/{fmt}",
|
||||||
f"task-{sid}.{fmt}" if fmt in {"docx", "xlsx"} else f"task-{sid}-{fmt}.zip",
|
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)
|
for fmt in dict.fromkeys(chosen)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -282,8 +282,10 @@ def mobile_manage_task(
|
|||||||
- **'resume'**: Queues a paused task for its device again; optional `instruction`
|
- **'resume'**: Queues a paused task for its device again; optional `instruction`
|
||||||
provides guidance before the next action.
|
provides guidance before the next action.
|
||||||
- **'inject_instruction'**: Injects real-time guidance mid-flight when the
|
- **'inject_instruction'**: Injects real-time guidance mid-flight when the
|
||||||
subagent errs, stalls, or loops. Applied at the start of the next
|
subagent errs, stalls, or loops. An in-flight model response or read-only
|
||||||
planning turn (Pro) or the next reactive loop (Flash). Requires
|
helper is cancelled immediately, then the runner observes the device again
|
||||||
|
and consumes the new guidance. An already-started device action finishes
|
||||||
|
safely before the runner changes direction. Requires
|
||||||
`instruction` (unless `release_loop=True`). To end a continuous
|
`instruction` (unless `release_loop=True`). To end a continuous
|
||||||
monitoring task ([Loop:continuous] milestone) gracefully, you MUST pass
|
monitoring task ([Loop:continuous] milestone) gracefully, you MUST pass
|
||||||
`release_loop=True` — this is the only signal that unlocks the
|
`release_loop=True` — this is the only signal that unlocks the
|
||||||
@@ -697,7 +699,7 @@ def mobile_manage_task(
|
|||||||
return {
|
return {
|
||||||
"trace_id": trace_id,
|
"trace_id": trace_id,
|
||||||
"status": current_status,
|
"status": current_status,
|
||||||
"message": f"Successfully injected instruction{suffix}: '{instruction}'",
|
"message": f"Guidance accepted and current model step interrupted{suffix}; check guidance_history for consumption.",
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ from artemis.runtime import (
|
|||||||
submit_task_to_daemon,
|
submit_task_to_daemon,
|
||||||
trace_store,
|
trace_store,
|
||||||
)
|
)
|
||||||
|
from artemis.runtime.task_limits import ExecutionBudget, task_timeout
|
||||||
|
from artemis.runtime.task_control import read_state
|
||||||
|
from artemis.runtime.task_result import save_failure_result, read_worker_outcome
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
|
||||||
# Seconds the spawned runner gets to finish its imports and open its log files
|
# Seconds the spawned runner gets to finish its imports and open its log files
|
||||||
# before the spawn is declared dead. Normal startup creates stdout.log within a
|
# before the spawn is declared dead. Normal startup creates stdout.log within a
|
||||||
@@ -44,6 +48,54 @@ from artemis.runtime import (
|
|||||||
SPAWN_WATCHDOG_SECONDS = 60
|
SPAWN_WATCHDOG_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _watch_execution(proc, trace_id: str, limit: int, cleanup_limit: float | None = None) -> None:
|
||||||
|
"""Supervise standalone MCP workers without charging device queue or pause time."""
|
||||||
|
budget = ExecutionBudget(limit)
|
||||||
|
finalized_at = None
|
||||||
|
cleanup_limit = cleanup_limit or settings.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS
|
||||||
|
while proc.poll() is None:
|
||||||
|
status = trace_store.read_status(trace_id) or {}
|
||||||
|
outcome = read_worker_outcome(trace_id)
|
||||||
|
if outcome and status.get("status") not in {
|
||||||
|
"completed",
|
||||||
|
"success",
|
||||||
|
"failed",
|
||||||
|
"cancelled",
|
||||||
|
"interrupted",
|
||||||
|
}:
|
||||||
|
try:
|
||||||
|
trace_store.update_trace_status(
|
||||||
|
trace_id, outcome["task_status"], error=outcome.get("error")
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
logging.exception("Cannot persist terminal status for %s", trace_id)
|
||||||
|
if outcome or status.get("status") in {
|
||||||
|
"completed",
|
||||||
|
"success",
|
||||||
|
"failed",
|
||||||
|
"cancelled",
|
||||||
|
"interrupted",
|
||||||
|
}:
|
||||||
|
finalized_at = finalized_at if finalized_at is not None else time.monotonic()
|
||||||
|
if time.monotonic() - finalized_at >= cleanup_limit:
|
||||||
|
_kill_process_tree(proc.pid)
|
||||||
|
return
|
||||||
|
time.sleep(0.5)
|
||||||
|
continue
|
||||||
|
control_status = read_state(trace_id).get("status")
|
||||||
|
remaining = budget.update(excluded=control_status in {"queued", "task_paused", "resuming"})
|
||||||
|
if remaining <= 0:
|
||||||
|
error = f"任务执行超时({limit} 秒,不含暂停/排队时间)"
|
||||||
|
_kill_process_tree(proc.pid)
|
||||||
|
try:
|
||||||
|
save_failure_result(trace_id, error, code="task_timeout")
|
||||||
|
trace_store.update_trace_status(trace_id, "failed", error=error)
|
||||||
|
except OSError:
|
||||||
|
logging.exception("Cannot persist timeout report for %s", trace_id)
|
||||||
|
return
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
|
||||||
def _kill_process_tree(pid: int) -> None:
|
def _kill_process_tree(pid: int) -> None:
|
||||||
"""Terminate a spawned runner and its children (the venv shim re-execs python)."""
|
"""Terminate a spawned runner and its children (the venv shim re-execs python)."""
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
@@ -212,6 +264,7 @@ def mobile_run_task(
|
|||||||
device_serial: str | None = None,
|
device_serial: str | None = None,
|
||||||
verification_level: str | None = None,
|
verification_level: str | None = None,
|
||||||
explorer_mode: str | None = None,
|
explorer_mode: str | None = None,
|
||||||
|
runtime_overrides: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Starts an autonomous mobile UI automation subagent on a connected Android device.
|
"""Starts an autonomous mobile UI automation subagent on a connected Android device.
|
||||||
|
|
||||||
@@ -277,7 +330,25 @@ def mobile_run_task(
|
|||||||
by the Operator: `"flash"` (1-shot detection, the default),
|
by the Operator: `"flash"` (1-shot detection, the default),
|
||||||
`"pro"` (3-turn ReAct), `"ultra"` (deep pixel reasoning; slowest).
|
`"pro"` (3-turn ReAct), `"ultra"` (deep pixel reasoning; slowest).
|
||||||
Ignored for Flash.
|
Ignored for Flash.
|
||||||
|
runtime_overrides: Optional TaskOptions JSON for this task only. Supports
|
||||||
|
timeout_seconds (total execution, default 1800; excludes queue/pause),
|
||||||
|
observation_timeout_seconds (60), observation_max_attempts (3),
|
||||||
|
device_command_timeout_seconds (10), action_failure_attempts (3),
|
||||||
|
worker_cleanup_timeout_seconds (30),
|
||||||
|
model (API model name), reasoning_effort, request timeout/retries,
|
||||||
|
recording and video analysis. Unknown fields and credentials are rejected.
|
||||||
"""
|
"""
|
||||||
|
if runtime_overrides:
|
||||||
|
from apps.admin_console.services.task_import import TaskOptions
|
||||||
|
|
||||||
|
runtime_overrides = TaskOptions.model_validate(runtime_overrides).model_dump(
|
||||||
|
exclude_none=True
|
||||||
|
)
|
||||||
|
model = runtime_overrides.get("profile") or model
|
||||||
|
device_serial = runtime_overrides.get("device_serial") or device_serial
|
||||||
|
locked_app_package = runtime_overrides.get("locked_app_package") or locked_app_package
|
||||||
|
verification_level = runtime_overrides.get("verification_level") or verification_level
|
||||||
|
explorer_mode = runtime_overrides.get("explorer_mode") or explorer_mode
|
||||||
# 0. Validate and normalize model
|
# 0. Validate and normalize model
|
||||||
if model.lower() not in ("flash", "pro"):
|
if model.lower() not in ("flash", "pro"):
|
||||||
raise ValueError(f"Invalid model '{model}'. Must be either 'Flash' or 'Pro'.")
|
raise ValueError(f"Invalid model '{model}'. Must be either 'Flash' or 'Pro'.")
|
||||||
@@ -325,6 +396,7 @@ def mobile_run_task(
|
|||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
verification_level=verification_level,
|
verification_level=verification_level,
|
||||||
explorer_mode=explorer_mode,
|
explorer_mode=explorer_mode,
|
||||||
|
runtime_overrides=runtime_overrides,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
)
|
)
|
||||||
if resp and resp.get("status") == "rejected":
|
if resp and resp.get("status") == "rejected":
|
||||||
@@ -475,6 +547,26 @@ def mobile_run_task(
|
|||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["ARTEMIS_SESSION_ID"] = trace_id
|
env["ARTEMIS_SESSION_ID"] = trace_id
|
||||||
env["ARTEMIS_TASK_INGRESS"] = "mcp"
|
env["ARTEMIS_TASK_INGRESS"] = "mcp"
|
||||||
|
env["ARTEMIS_TASK_WORKER"] = "1"
|
||||||
|
for option, variable in {
|
||||||
|
"model": "OPENAI_MODEL",
|
||||||
|
"reasoning_effort": "OPENAI_REASONING_EFFORT",
|
||||||
|
"request_timeout_seconds": "OPENAI_TIMEOUT_SECONDS",
|
||||||
|
"max_retries": "OPENAI_MAX_RETRIES",
|
||||||
|
"retry_interval_seconds": "OPENAI_RETRY_INTERVAL_SECONDS",
|
||||||
|
"record_video": "ARTEMIS_RECORDING_ENABLED",
|
||||||
|
"analyze_video": "ARTEMIS_VIDEO_ENABLED",
|
||||||
|
"observation_timeout_seconds": "ARTEMIS_OBSERVATION_TIMEOUT_SECONDS",
|
||||||
|
"observation_max_attempts": "ARTEMIS_OBSERVATION_MAX_ATTEMPTS",
|
||||||
|
"device_command_timeout_seconds": "ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS",
|
||||||
|
"action_failure_attempts": "ARTEMIS_ACTION_FAILURE_ATTEMPTS",
|
||||||
|
}.items():
|
||||||
|
if (runtime_overrides or {}).get(option) is not None:
|
||||||
|
env[variable] = (
|
||||||
|
str(runtime_overrides[option]).lower()
|
||||||
|
if isinstance(runtime_overrides[option], bool)
|
||||||
|
else str(runtime_overrides[option])
|
||||||
|
)
|
||||||
if device_serial:
|
if device_serial:
|
||||||
env["ADB_DEVICE_SERIAL"] = device_serial
|
env["ADB_DEVICE_SERIAL"] = device_serial
|
||||||
env["ARTEMIS_DEVICE_ID"] = device_serial
|
env["ARTEMIS_DEVICE_ID"] = device_serial
|
||||||
@@ -519,6 +611,17 @@ def mobile_run_task(
|
|||||||
# interpreter startup and must be killed instead of squatting on the
|
# interpreter startup and must be killed instead of squatting on the
|
||||||
# queue as 'running' forever.
|
# queue as 'running' forever.
|
||||||
_start_spawn_watchdog(trace_id, proc.pid, queue_ticket, conversation_id)
|
_start_spawn_watchdog(trace_id, proc.pid, queue_ticket, conversation_id)
|
||||||
|
threading.Thread(
|
||||||
|
target=_watch_execution,
|
||||||
|
args=(
|
||||||
|
proc,
|
||||||
|
trace_id,
|
||||||
|
task_timeout({"runtime_overrides": runtime_overrides}),
|
||||||
|
(runtime_overrides or {}).get("worker_cleanup_timeout_seconds"),
|
||||||
|
),
|
||||||
|
daemon=True,
|
||||||
|
name=f"artemis-task-budget-{trace_id[:8]}",
|
||||||
|
).start()
|
||||||
|
|
||||||
trace_dir = trace_store.get_trace_dir(trace_id)
|
trace_dir = trace_store.get_trace_dir(trace_id)
|
||||||
stdout_log_path = os.path.join(trace_dir, "stdout.log")
|
stdout_log_path = os.path.join(trace_dir, "stdout.log")
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "artemis"
|
name = "artemis"
|
||||||
version = "1.1.1"
|
version = "1.3.3"
|
||||||
description = "Autonomous AI Assistant for Mobile Automation"
|
description = "Autonomous AI Assistant for Mobile Automation"
|
||||||
|
|
||||||
authors = [
|
authors = [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Copyright 2026 Google LLC
|
# Copyright 2026 Google LLC
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -91,12 +91,23 @@ function Update-EnvironmentPath {
|
|||||||
|
|
||||||
function Start-LocalAdbServer {
|
function Start-LocalAdbServer {
|
||||||
if (Test-CommandExists "adb") {
|
if (Test-CommandExists "adb") {
|
||||||
|
$adbErrorPreference = $ErrorActionPreference
|
||||||
try {
|
try {
|
||||||
$origSocket = $env:ADB_SERVER_SOCKET
|
# Windows PowerShell 5.1 wraps native stderr in ErrorRecord objects.
|
||||||
Remove-Item Env:\ADB_SERVER_SOCKET -ErrorAction SilentlyContinue
|
# ADB prints normal daemon-start messages there, so inspect its exit code.
|
||||||
& adb start-server 2>$null | Out-Null
|
$ErrorActionPreference = "Continue"
|
||||||
if ($origSocket) { $env:ADB_SERVER_SOCKET = $origSocket }
|
# Explicit options ignore inherited remote-server environment settings.
|
||||||
} catch {}
|
$adbOutput = & adb -H localhost -P 5037 start-server 2>&1
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host " [警告] 本机 ADB 服务启动失败(退出码 $LASTEXITCODE)。" -ForegroundColor Yellow
|
||||||
|
Write-Host ($adbOutput | Out-String).Trim() -ForegroundColor Yellow
|
||||||
|
Write-Host " [操作] 请检查 ADB 安装、端口占用或安全软件拦截;本机设备暂不可用。" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " [警告] 本机 ADB 服务启动异常:$_" -ForegroundColor Yellow
|
||||||
|
} finally {
|
||||||
|
$ErrorActionPreference = $adbErrorPreference
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-9
@@ -18,6 +18,7 @@ param(
|
|||||||
[switch]$NoOpen = $false,
|
[switch]$NoOpen = $false,
|
||||||
[switch]$SetupOnly = $false,
|
[switch]$SetupOnly = $false,
|
||||||
[switch]$SkipUpdate = $false,
|
[switch]$SkipUpdate = $false,
|
||||||
|
[switch]$SkipMcpSync = $false,
|
||||||
[ValidateSet('Official', 'China')][string]$Mirror
|
[ValidateSet('Official', 'China')][string]$Mirror
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ if ((-not $SkipUpdate) -and (Test-Path -LiteralPath $existingPython)) {
|
|||||||
$reloadArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $ScriptDir 'start.ps1'), '-Port', $Port, '-SkipUpdate')
|
$reloadArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $ScriptDir 'start.ps1'), '-Port', $Port, '-SkipUpdate')
|
||||||
if ($NoOpen) { $reloadArgs += '-NoOpen' }
|
if ($NoOpen) { $reloadArgs += '-NoOpen' }
|
||||||
if ($SetupOnly) { $reloadArgs += '-SetupOnly' }
|
if ($SetupOnly) { $reloadArgs += '-SetupOnly' }
|
||||||
|
if ($SkipMcpSync) { $reloadArgs += '-SkipMcpSync' }
|
||||||
if ($Mirror) { $reloadArgs += @('-Mirror', $Mirror) }
|
if ($Mirror) { $reloadArgs += @('-Mirror', $Mirror) }
|
||||||
& powershell.exe @reloadArgs
|
& powershell.exe @reloadArgs
|
||||||
exit $LASTEXITCODE
|
exit $LASTEXITCODE
|
||||||
@@ -124,12 +126,23 @@ function Update-EnvironmentPath {
|
|||||||
|
|
||||||
function Start-LocalAdbServer {
|
function Start-LocalAdbServer {
|
||||||
if (Test-CommandExists "adb") {
|
if (Test-CommandExists "adb") {
|
||||||
|
$adbErrorPreference = $ErrorActionPreference
|
||||||
try {
|
try {
|
||||||
$origSocket = $env:ADB_SERVER_SOCKET
|
# Windows PowerShell 5.1 wraps native stderr in ErrorRecord objects.
|
||||||
Remove-Item Env:\ADB_SERVER_SOCKET -ErrorAction SilentlyContinue
|
# ADB prints normal daemon-start messages there, so inspect its exit code.
|
||||||
& adb start-server 2>$null | Out-Null
|
$ErrorActionPreference = "Continue"
|
||||||
if ($origSocket) { $env:ADB_SERVER_SOCKET = $origSocket }
|
# Explicit options ignore inherited remote-server environment settings.
|
||||||
} catch {}
|
$adbOutput = & adb -H localhost -P 5037 start-server 2>&1
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host " [警告] 本机 ADB 服务启动失败(退出码 $LASTEXITCODE)。" -ForegroundColor Yellow
|
||||||
|
Write-Host ($adbOutput | Out-String).Trim() -ForegroundColor Yellow
|
||||||
|
Write-Host " [操作] 请检查 ADB 安装、端口占用或安全软件拦截;本机设备暂不可用。" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host " [警告] 本机 ADB 服务启动异常:$_" -ForegroundColor Yellow
|
||||||
|
} finally {
|
||||||
|
$ErrorActionPreference = $adbErrorPreference
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,10 +366,15 @@ if (-not (Test-Path -LiteralPath (Join-Path $RootDir '.env'))) {
|
|||||||
Write-Host (Get-LaunchText ' [OK] Created .env from .env.example. Configure your model in the web UI.' ' [完成] 已按模板创建 .env;请在网页中配置模型。') -ForegroundColor Green
|
Write-Host (Get-LaunchText ' [OK] Created .env from .env.example. Configure your model in the web UI.' ' [完成] 已按模板创建 .env;请在网页中配置模型。') -ForegroundColor Green
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Write-Host (Get-LaunchText ' [CHECK] Refreshing already-configured MCP clients...' ' [检查] 正在检查并更新已有的 MCP 客户端配置...') -ForegroundColor Cyan
|
if (-not $SkipMcpSync) {
|
||||||
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp
|
Write-Host (Get-LaunchText ' [CHECK] Installing/updating Codex MCP...' ' [检查] 正在安装或更新 Codex MCP...') -ForegroundColor Cyan
|
||||||
if ($LASTEXITCODE -ne 0) {
|
& $ArtemisPython (Join-Path $ScriptDir 'update_installation.py') mcp
|
||||||
Write-Host (Get-LaunchText ' [WARN] MCP refresh failed. Existing client settings were not replaced; check the errors above.' ' [提示] MCP 配置更新失败,原配置仍保留;请查看上方错误。') -ForegroundColor Yellow
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host (Get-LaunchText ' [ERROR] Codex MCP synchronization failed. Fix the configuration above, or use -SkipMcpSync to run the web UI without MCP.' ' [错误] Codex MCP 同步失败。请修复上方配置;仅需启动网页时可使用 -SkipMcpSync。') -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host (Get-LaunchText ' [SKIP] MCP synchronization was skipped.' ' [跳过] 已跳过 MCP 同步。') -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
if ($SetupOnly) {
|
if ($SetupOnly) {
|
||||||
Write-Host (Get-LaunchText ' [OK] Setup complete. Run start.bat to launch Artemis.' ' [完成] 环境已准备好。运行 start-cn.bat 启动 ARTEMIS。') -ForegroundColor Green
|
Write-Host (Get-LaunchText ' [OK] Setup complete. Run start.bat to launch Artemis.' ' [完成] 环境已准备好。运行 start-cn.bat 启动 ARTEMIS。') -ForegroundColor Green
|
||||||
|
|||||||
+192
-141
@@ -1,4 +1,4 @@
|
|||||||
"""Check an existing checkout and refresh only already-configured local MCP clients."""
|
"""Check an existing checkout and synchronize the local Codex MCP configuration."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -140,10 +140,10 @@ def update_source(root: Path, port: int = 8000) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _atomic_write(path: Path, text: str) -> None:
|
def _atomic_write(path: Path, text: str) -> None:
|
||||||
if path.read_text(encoding="utf-8") == text:
|
if path.is_file() and path.read_text(encoding="utf-8") == text:
|
||||||
return
|
return
|
||||||
backup = path.with_name(path.name + ".artemis-update.bak")
|
backup = path.with_name(path.name + ".artemis-update.bak")
|
||||||
if not backup.exists():
|
if path.is_file() and not backup.exists():
|
||||||
shutil.copy2(path, backup)
|
shutil.copy2(path, backup)
|
||||||
fd, name = tempfile.mkstemp(prefix=".artemis-update-", dir=path.parent)
|
fd, name = tempfile.mkstemp(prefix=".artemis-update-", dir=path.parent)
|
||||||
try:
|
try:
|
||||||
@@ -154,87 +154,72 @@ def _atomic_write(path: Path, text: str) -> None:
|
|||||||
Path(name).unlink(missing_ok=True)
|
Path(name).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def _belongs_to_project(config: dict, root: Path) -> bool:
|
def _is_standard_local_artemis(config: dict) -> bool:
|
||||||
if config.get("url"):
|
if config.get("url") or config.get("type") not in (None, "stdio"):
|
||||||
return False
|
return False
|
||||||
args = config.get("args", [])
|
args = config.get("args", [])
|
||||||
if args[:2] not in (["-m", "mcp_server"], ["-m", "mcp_server.server"]):
|
# Running this launcher selects this checkout, including after a user moves
|
||||||
# A hand-written wrapper/uv/SSE command may need custom arguments; leave it alone.
|
# a release ZIP to a new directory. Do not take over custom wrappers/SSE.
|
||||||
return False
|
return args in (["-m", "mcp_server"], ["-m", "mcp_server.server"])
|
||||||
candidates = [config.get("cwd"), config.get("env", {}).get("PYTHONPATH")]
|
|
||||||
for value in candidates:
|
|
||||||
if isinstance(value, str) and Path(value).resolve() == root.resolve():
|
|
||||||
return True
|
|
||||||
command = config.get("command")
|
|
||||||
return (
|
|
||||||
isinstance(command, str)
|
|
||||||
and Path(command).resolve().parent == (root / ".venv" / "Scripts").resolve()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _tool_updates(config: dict, names: tuple[str, ...]) -> dict:
|
def _updated_entry(config: dict, template: dict, root: Path, names: tuple[str, ...]) -> dict:
|
||||||
updates = {}
|
updated = {**config, "command": template["command"], "args": template["args"]}
|
||||||
if "enabled_tools" in config:
|
if "cwd" in template or "cwd" in config:
|
||||||
updates["enabled_tools"] = list(dict.fromkeys([*config["enabled_tools"], *names]))
|
updated["cwd"] = str(root)
|
||||||
if isinstance(config.get("tools"), dict):
|
environment = config.get("env", {})
|
||||||
updates["tools"] = {**{name: {"eager": True} for name in names}, **config["tools"]}
|
if not isinstance(environment, dict):
|
||||||
return updates
|
raise ValueError("The existing Artemis MCP environment is not an object")
|
||||||
|
updated["env"] = {**environment, **template["env"]}
|
||||||
|
if "enabled_tools" in template or "enabled_tools" in config:
|
||||||
|
updated["enabled_tools"] = list(names)
|
||||||
|
if "tools" in template or "tools" in config:
|
||||||
|
current_tools = config.get("tools", {})
|
||||||
|
if not isinstance(current_tools, dict):
|
||||||
|
raise ValueError("The existing Artemis MCP tools setting is not an object")
|
||||||
|
updated["tools"] = {name: {"eager": True, **current_tools.get(name, {})} for name in names}
|
||||||
|
for key in ("type", "required", "startup_timeout_sec", "connectionTimeoutMs"):
|
||||||
|
if key in template and key not in updated:
|
||||||
|
updated[key] = template[key]
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
def refresh_json(
|
def _toml_value(value: object) -> str:
|
||||||
path: Path, keys: tuple[str, ...], root: Path, python: str, names: tuple[str, ...]
|
if isinstance(value, bool):
|
||||||
) -> bool:
|
return "true" if value else "false"
|
||||||
if not path.is_file():
|
return json.dumps(value, ensure_ascii=False)
|
||||||
return False
|
|
||||||
raw = path.read_text(encoding="utf-8")
|
|
||||||
data = json.loads(raw)
|
|
||||||
parent = data
|
|
||||||
for key in keys:
|
|
||||||
parent = parent.get(key, {})
|
|
||||||
config = parent.get("artemis")
|
|
||||||
if not isinstance(config, dict) or not _belongs_to_project(config, root):
|
|
||||||
return False
|
|
||||||
updated = {**config, "command": python, **_tool_updates(config, names)}
|
|
||||||
if updated == config:
|
|
||||||
return False
|
|
||||||
parent["artemis"] = updated
|
|
||||||
_atomic_write(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def refresh_codex(path: Path, root: Path, python: str, names: tuple[str, ...]) -> bool:
|
def _toml_inline_table(values: dict) -> str:
|
||||||
if not path.is_file():
|
return "{ " + ", ".join(f"{key} = {_toml_value(value)}" for key, value in values.items()) + " }"
|
||||||
return False
|
|
||||||
raw = path.read_text(encoding="utf-8")
|
|
||||||
config = tomllib.loads(raw).get("mcp_servers", {}).get("artemis")
|
def _replace_toml_tables(raw: str, assignments: dict[str, dict]) -> str:
|
||||||
if not isinstance(config, dict) or not _belongs_to_project(config, root):
|
"""Update only Artemis TOML tables; preserve every unrelated section and setting."""
|
||||||
return False
|
|
||||||
wanted = {"command": python, **_tool_updates(config, names)}
|
|
||||||
wanted = {key: value for key, value in wanted.items() if config.get(key) != value}
|
|
||||||
if not wanted:
|
|
||||||
return False
|
|
||||||
lines = raw.splitlines(keepends=True)
|
lines = raw.splitlines(keepends=True)
|
||||||
inside = False
|
output: list[str] = []
|
||||||
found = set()
|
section = ""
|
||||||
output = []
|
found: dict[str, set[str]] = {name: set() for name in assignments}
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
def append_missing():
|
def append_missing() -> None:
|
||||||
for key, value in wanted.items():
|
if section in assignments:
|
||||||
if key not in found:
|
for key, value in assignments[section].items():
|
||||||
output.append(f"{key} = {json.dumps(value, ensure_ascii=False)}\n")
|
if key not in found[section]:
|
||||||
found.add(key)
|
rendered = _toml_inline_table(value) if key == "env" else _toml_value(value)
|
||||||
|
output.append(f"{key} = {rendered}\n")
|
||||||
|
found[section].add(key)
|
||||||
|
|
||||||
# Replace complete assignment spans, including multiline arrays, using TOML parsing.
|
|
||||||
index = 0
|
index = 0
|
||||||
while index < len(lines):
|
while index < len(lines):
|
||||||
line = lines[index]
|
line = lines[index]
|
||||||
if line.lstrip().startswith("["):
|
if line.lstrip().startswith("["):
|
||||||
if inside:
|
append_missing()
|
||||||
append_missing()
|
|
||||||
header = re.sub(r"[\s\"']", "", line.split("#", 1)[0]).strip()
|
header = re.sub(r"[\s\"']", "", line.split("#", 1)[0]).strip()
|
||||||
inside = header == "[mcp_servers.artemis]"
|
section = header[1:-1] if header.startswith("[") and header.endswith("]") else ""
|
||||||
match = re.match(r"\s*(\w+)\s*=", line) if inside else None
|
seen.add(section)
|
||||||
if match and match[1] in wanted:
|
match = re.match(r"\s*(\w+)\s*=", line) if section in assignments else None
|
||||||
|
if match and match[1] in assignments[section]:
|
||||||
key = match[1]
|
key = match[1]
|
||||||
original = line
|
original = line
|
||||||
while True:
|
while True:
|
||||||
@@ -246,91 +231,157 @@ def refresh_codex(path: Path, root: Path, python: str, names: tuple[str, ...]) -
|
|||||||
if index >= len(lines):
|
if index >= len(lines):
|
||||||
raise ValueError("Cannot safely parse existing MCP setting")
|
raise ValueError("Cannot safely parse existing MCP setting")
|
||||||
original += lines[index]
|
original += lines[index]
|
||||||
output.append(f"{key} = {json.dumps(wanted[key], ensure_ascii=False)}\n")
|
value = assignments[section][key]
|
||||||
found.add(key)
|
rendered = _toml_inline_table(value) if key == "env" else _toml_value(value)
|
||||||
|
output.append(f"{key} = {rendered}\n")
|
||||||
|
found[section].add(key)
|
||||||
else:
|
else:
|
||||||
output.append(line)
|
output.append(line)
|
||||||
index += 1
|
index += 1
|
||||||
if inside:
|
append_missing()
|
||||||
append_missing()
|
for name, values in assignments.items():
|
||||||
updated = "".join(output)
|
if name not in seen:
|
||||||
parsed = tomllib.loads(updated)
|
if output and not output[-1].endswith("\n"):
|
||||||
if any(parsed["mcp_servers"]["artemis"].get(k) != v for k, v in wanted.items()):
|
output.append("\n")
|
||||||
raise ValueError("Could not locate the existing Artemis table safely")
|
output.append(f"\n[{name}]\n")
|
||||||
_atomic_write(path, updated)
|
for key, value in values.items():
|
||||||
return updated != raw
|
rendered = _toml_inline_table(value) if key == "env" else _toml_value(value)
|
||||||
|
output.append(f"{key} = {rendered}\n")
|
||||||
|
return "".join(output)
|
||||||
|
|
||||||
|
|
||||||
def refresh_mcp(root: Path) -> None:
|
def refresh_codex(
|
||||||
|
path: Path,
|
||||||
|
root: Path,
|
||||||
|
python: str,
|
||||||
|
names: tuple[str, ...],
|
||||||
|
template: dict | None = None,
|
||||||
|
install_missing: bool = False,
|
||||||
|
) -> bool | None:
|
||||||
|
if not path.is_file() and not install_missing:
|
||||||
|
return False
|
||||||
|
raw = path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||||
|
parsed = tomllib.loads(raw)
|
||||||
|
config = parsed.get("mcp_servers", {}).get("artemis")
|
||||||
|
if config is None and install_missing:
|
||||||
|
if template is None:
|
||||||
|
raise ValueError("A standard MCP template is required for installation")
|
||||||
|
updated = template
|
||||||
|
elif isinstance(config, dict) and _is_standard_local_artemis(config):
|
||||||
|
if template is None:
|
||||||
|
template = {
|
||||||
|
"command": python,
|
||||||
|
"args": ["-m", "mcp_server"],
|
||||||
|
"cwd": str(root),
|
||||||
|
"env": {"PYTHONPATH": str(root)},
|
||||||
|
"enabled_tools": list(names),
|
||||||
|
}
|
||||||
|
updated = _updated_entry(config, template, root, names)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
top = {
|
||||||
|
key: value
|
||||||
|
for key, value in updated.items()
|
||||||
|
if key != "env" and (not config or config.get(key) != value)
|
||||||
|
}
|
||||||
|
environment = updated["env"]
|
||||||
|
if config and config.get("env") == environment:
|
||||||
|
env_updates = {}
|
||||||
|
else:
|
||||||
|
env_updates = {
|
||||||
|
key: value
|
||||||
|
for key, value in environment.items()
|
||||||
|
if not config or config.get("env", {}).get(key) != value
|
||||||
|
}
|
||||||
|
env_header = "mcp_servers.artemis.env"
|
||||||
|
has_env_table = re.search(r"(?m)^\s*\[mcp_servers\.artemis\.env\]\s*(?:#.*)?$", raw) is not None
|
||||||
|
if config and "env" in config and not has_env_table and env_updates:
|
||||||
|
top["env"] = environment
|
||||||
|
env_updates = {}
|
||||||
|
assignments = {"mcp_servers.artemis": top}
|
||||||
|
if env_updates:
|
||||||
|
assignments[env_header] = env_updates
|
||||||
|
if not top and not env_updates:
|
||||||
|
return False
|
||||||
|
result = _replace_toml_tables(raw, assignments)
|
||||||
|
actual = tomllib.loads(result)["mcp_servers"]["artemis"]
|
||||||
|
if any(actual.get(key) != value for key, value in top.items()) or any(
|
||||||
|
actual.get("env", {}).get(key) != value for key, value in env_updates.items()
|
||||||
|
):
|
||||||
|
raise ValueError("Could not safely refresh the Artemis MCP table")
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_atomic_write(path, result)
|
||||||
|
return result != raw
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_mcp(root: Path) -> int:
|
||||||
|
"""Synchronize only Codex; never inspect or modify other clients."""
|
||||||
sys.path.insert(0, str(root))
|
sys.path.insert(0, str(root))
|
||||||
from artemis.interfaces.cli.commands.mcp import ARTEMIS_MCP_TOOLS, _get_vscode_user_dir
|
from artemis.interfaces.cli.commands.mcp import ARTEMIS_MCP_TOOLS, _get_config_snippet
|
||||||
|
|
||||||
home = Path.home()
|
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
||||||
vscode = _get_vscode_user_dir()
|
path = codex_home / "config.toml"
|
||||||
appdata = Path(os.environ.get("APPDATA", home / "AppData/Roaming"))
|
print(launch_text(f"[CHECK] Codex MCP configuration: {path}", f"[检查] Codex MCP 配置:{path}"))
|
||||||
paths = [
|
if not path.is_file() and not codex_home.is_dir():
|
||||||
(home / ".gemini/config/mcp_config.json", ("mcpServers",)),
|
print(
|
||||||
(home / ".gemini/jetski/mcp_config.json", ("mcpServers",)),
|
launch_text(
|
||||||
(home / ".gemini/antigravity/mcp_config.json", ("mcpServers",)),
|
"[SKIP] Codex configuration directory not found. Install with: uv run artemis mcp --install codex",
|
||||||
(home / ".cursor/mcp.json", ("mcpServers",)),
|
"[跳过] 未发现 Codex 配置目录。可执行 uv run artemis mcp --install codex 安装。",
|
||||||
(home / ".codeium/windsurf/mcp_config.json", ("mcpServers",)),
|
|
||||||
(home / ".claude.json", ("mcpServers",)),
|
|
||||||
(appdata / "Claude/claude_desktop_config.json", ("mcpServers",)),
|
|
||||||
(vscode / "mcp.json", ("servers",)),
|
|
||||||
(home / ".copilot/mcp-config.json", ("servers",)),
|
|
||||||
(home / ".cline/data/settings/cline_mcp_settings.json", ("mcpServers",)),
|
|
||||||
(home / ".cline/mcp.json", ("mcpServers",)),
|
|
||||||
(
|
|
||||||
vscode / "globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
|
|
||||||
("mcpServers",),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
|
|
||||||
("mcpServers",),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
vscode / "globalStorage/rooveterinaryinc.roo-cline/settings/cline_mcp_settings.json",
|
|
||||||
("mcpServers",),
|
|
||||||
),
|
|
||||||
(root / ".roo/mcp.json", ("mcpServers",)),
|
|
||||||
(home / ".openclaw/openclaw.json", ("mcp", "servers")),
|
|
||||||
]
|
|
||||||
changed = 0
|
|
||||||
codex = Path(os.environ.get("CODEX_HOME", home / ".codex")) / "config.toml"
|
|
||||||
for path, keys in [*paths, (codex, ())]:
|
|
||||||
try:
|
|
||||||
updated = (
|
|
||||||
refresh_json(path, keys, root, sys.executable, ARTEMIS_MCP_TOOLS)
|
|
||||||
if keys
|
|
||||||
else refresh_codex(path, root, sys.executable, ARTEMIS_MCP_TOOLS)
|
|
||||||
)
|
)
|
||||||
if updated:
|
)
|
||||||
print(
|
return 0
|
||||||
launch_text(
|
existed = path.is_file()
|
||||||
f"[UPDATED] Existing MCP configuration: {path}",
|
template = _get_config_snippet("codex", sys.executable, str(root))["mcp_servers"]["artemis"]
|
||||||
f"[已更新] 已有 MCP 配置:{path}",
|
try:
|
||||||
)
|
changed = refresh_codex(
|
||||||
)
|
path, root, sys.executable, ARTEMIS_MCP_TOOLS, template=template, install_missing=True
|
||||||
changed += 1
|
)
|
||||||
except (OSError, ValueError, TypeError, AttributeError):
|
except (OSError, ValueError, TypeError, AttributeError) as exc:
|
||||||
print(
|
if isinstance(exc, OSError):
|
||||||
launch_text(
|
reason = launch_text(
|
||||||
f"[WARN] Could not safely refresh {path}; original configuration preserved.",
|
"Cannot read/write the configuration; check file permissions.",
|
||||||
f"[提示] 无法安全更新 {path};原配置已保留。",
|
"无法读写配置,请检查文件权限。",
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
reason = launch_text(
|
||||||
|
"Invalid TOML or Artemis configuration structure; check the file in Codex.",
|
||||||
|
"TOML 格式或 ARTEMIS 配置结构无效,请在 Codex 中检查配置文件。",
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
launch_text(
|
||||||
|
f"[ERROR] Cannot safely synchronize {path}; original preserved. {reason}",
|
||||||
|
f"[错误] 无法安全同步 {path};原配置已保留。{reason}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
if changed:
|
||||||
|
print(
|
||||||
|
launch_text(
|
||||||
|
f"[{'UPDATED' if existed else 'INSTALLED'}] Codex MCP: {path}",
|
||||||
|
f"[{'已更新' if existed else '已安装'}] Codex MCP:{path}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif changed is None:
|
||||||
|
print(
|
||||||
|
launch_text(
|
||||||
|
"[SKIP] Custom Codex Artemis MCP command preserved; check it manually.",
|
||||||
|
"[跳过] Codex 使用自定义 ARTEMIS MCP 启动方式,已保留,请手动检查。",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
launch_text(
|
||||||
|
"[OK] Codex Artemis MCP is already current.", "[完成] Codex ARTEMIS MCP 已是最新。"
|
||||||
|
)
|
||||||
|
)
|
||||||
print(
|
print(
|
||||||
launch_text(
|
launch_text(
|
||||||
f"[OK] MCP source is current; refreshed {changed} existing client configuration(s). Reload the MCP connection in your AI client after a source update.",
|
"[ACTION] Reload MCP or restart Codex to activate the updated tools.",
|
||||||
f"[完成] MCP 源码已就绪;更新了 {changed} 个已有客户端配置。源码升级后请在 AI 客户端重新加载 MCP。",
|
"[操作] 请在 Codex 中重新加载 MCP 或重启 Codex,使新版进程和工具签名生效。",
|
||||||
)
|
|
||||||
)
|
|
||||||
print(
|
|
||||||
launch_text(
|
|
||||||
"[TIP] For a new client run: uv run artemis mcp --install <client>",
|
|
||||||
"[提示] 新客户端可执行:uv run artemis mcp --install <client>",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def frontend_fingerprint(root: Path) -> str:
|
def frontend_fingerprint(root: Path) -> str:
|
||||||
@@ -362,7 +413,7 @@ if __name__ == "__main__":
|
|||||||
if args.action == "source":
|
if args.action == "source":
|
||||||
sys.exit(update_source(args.root, args.port))
|
sys.exit(update_source(args.root, args.port))
|
||||||
elif args.action == "mcp":
|
elif args.action == "mcp":
|
||||||
refresh_mcp(args.root)
|
sys.exit(refresh_mcp(args.root))
|
||||||
else:
|
else:
|
||||||
marker = args.root / "apps/showcase_ui/node_modules/.artemis-source-hash"
|
marker = args.root / "apps/showcase_ui/node_modules/.artemis-source-hash"
|
||||||
current = frontend_fingerprint(args.root)
|
current = frontend_fingerprint(args.root)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
|
|||||||
|
|
||||||
# ARTEMIS 自动化
|
# ARTEMIS 自动化
|
||||||
|
|
||||||
|
当前仅提供 Codex MCP 配置安装与更新:`uv run artemis mcp --install codex`;模板使用 `uv run artemis mcp --generate-config codex` 查看。启动日志会打印实际 Codex 配置路径,更新后重载 MCP。
|
||||||
|
|
||||||
使用项目提供的 MCP 工具操作 Android。报告由 ARTEMIS 自己生成,不要求宿主 AI 具备 Codex 私有工具、Word 或 Excel 插件。该 Skill 不提供桌面/浏览器自动化,也不提供历史动作脚本回放。
|
使用项目提供的 MCP 工具操作 Android。报告由 ARTEMIS 自己生成,不要求宿主 AI 具备 Codex 私有工具、Word 或 Excel 插件。该 Skill 不提供桌面/浏览器自动化,也不提供历史动作脚本回放。
|
||||||
|
|
||||||
## 从需求到执行
|
## 从需求到执行
|
||||||
@@ -14,12 +16,12 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
|
|||||||
3. 首次使用、工具出错、任务拒绝或设备异常,先 `mobile_diagnose`。按 `next_steps` 的依赖顺序处理;`attempt_fix=true` 可尝试工具自带修复。只有凭据相关故障才使用 `verify_credentials=true`,观察异常才使用 `probe_device=true`。不要盲目重发任务。MCP 不可用时在项目目录运行 `uv run artemis doctor` 并检查宿主 MCP 日志。
|
3. 首次使用、工具出错、任务拒绝或设备异常,先 `mobile_diagnose`。按 `next_steps` 的依赖顺序处理;`attempt_fix=true` 可尝试工具自带修复。只有凭据相关故障才使用 `verify_credentials=true`,观察异常才使用 `probe_device=true`。不要盲目重发任务。MCP 不可用时在项目目录运行 `uv run artemis doctor` 并检查宿主 MCP 日志。
|
||||||
4. 选择执行模式:Flash 适合路径明确的界面操作;Pro 适合动态分支、持续计划、ADB/日志诊断或需要 Checker 明确校验的任务。**`mobile_run_task.model` 的 `flash`/`pro` 是执行模式,不是 API 模型名称**。步数长不自动等于需要 Pro;视频能力只有显式启用后才可用。
|
4. 选择执行模式:Flash 适合路径明确的界面操作;Pro 适合动态分支、持续计划、ADB/日志诊断或需要 Checker 明确校验的任务。**`mobile_run_task.model` 的 `flash`/`pro` 是执行模式,不是 API 模型名称**。步数长不自动等于需要 Pro;视频能力只有显式启用后才可用。
|
||||||
5. 单任务用 `mobile_run_task`;批量任务用 `mobile_batch`,读 [批量任务与交付](references/batches.md)。AI 可直接提交结构化用例,不必先创建 Excel。用户提供 Excel 时先 `mobile_task_file(action="preview", path=绝对路径)`,修正所有行错误后提交。提示词列原样作为目标,前置约束列只作元数据。
|
5. 单任务用 `mobile_run_task`;批量任务用 `mobile_batch`,读 [批量任务与交付](references/batches.md)。AI 可直接提交结构化用例,不必先创建 Excel。用户提供 Excel 时先 `mobile_task_file(action="preview", path=绝对路径)`,修正所有行错误后提交。提示词列原样作为目标,前置约束列只作元数据。
|
||||||
6. 单任务返回 `trace_id` 后,用 `mobile_manage_task(action="status", trace_id=...)` 跟踪;网页任务也可用其任务编号。批次用 `mobile_batch(action="get", batch_id=...)`。需要人工补充时用 `pause`,其返回的 `pausing` 只表示请求已接收;轮询到 `task_paused` 才表示设备已释放。Pro 等待模型回复或只读诊断时可中断,已开始的设备动作会先安全结束。`inject_instruction` 可在运行中指导下一步,暂停中提交则恢复;`guidance_history` 的 `pending/consumed` 区分提交和执行端领取,无新指导用 `resume`。恢复进入设备 FIFO 队尾并重新观察手机状态。尊重工具建议的等待周期,避免连续高频轮询;停滞或失败先诊断,不把同一业务任务自动重提。
|
6. 单任务返回 `trace_id` 后,用 `mobile_manage_task(action="status", trace_id=...)` 跟踪;网页任务也可用其任务编号。批次用 `mobile_batch(action="get", batch_id=...)`。需要人工补充时用 `pause`,其返回的 `pausing` 只表示请求已接收;轮询到 `task_paused` 才表示设备已释放。`inject_instruction` 会立即取消进行中的模型回复或只读诊断并重新观察手机,已开始的设备动作会先安全结束;暂停中提交则恢复。`guidance_history` 的 `pending/consumed` 区分提交和执行端领取,无新指导用 `resume`。恢复进入设备 FIFO 队尾并重新观察手机状态。尊重工具建议的等待周期,避免连续高频轮询;停滞或失败先诊断,不把同一业务任务自动重提。
|
||||||
|
|
||||||
## 结果、证据与交付
|
## 结果、证据与交付
|
||||||
|
|
||||||
- 执行状态与业务判定分开:`completed` 只说明执行结束,不等于测试通过。检查工具返回的验证摘要、预期/实际结果,以及 `mobile_inspect_trace` 的步骤/截图。证据不足标记“未验证”;前置条件不满足说明阻塞原因,不能编造通过结论。
|
- 执行状态与业务判定分开:`completed` 只说明执行结束,不等于测试通过。检查工具返回的验证摘要、预期/实际结果,以及 `mobile_inspect_trace` 的步骤/截图。证据不足标记“未验证”;前置条件不满足说明阻塞原因,不能编造通过结论。
|
||||||
- 用 `mobile_export_report(session_id=...)` 获取单任务报告;`formats=["screenshots"]` 获取排序截图,`["video"]` 获取保存的录屏分段,`["all"]` 获取 Word、Excel 和所有可用资料的 ZIP。批次传 `batch_id` 获取汇总和各项任务报告的 ZIP。未指定格式时使用保存的 Word/Excel 偏好。执行中的导出只是当前快照。
|
- 用 `mobile_export_report(session_id=...)` 获取单任务报告;`formats=["screenshots"]` 获取排序截图,`["video"]` 获取保存的录屏分段,`["all"]` 获取 Word、Excel 和所有可用资料的 ZIP。批次传 `batch_id` 获取汇总、各项任务报告、步骤截图及已有录屏的 ZIP。未指定格式时使用保存的 Word/Excel 偏好。执行中的导出只是当前快照。
|
||||||
- 工具返回 MCP 主机上的绝对文件路径和下载 URL。向用户交付真实生成的文件链接,并说明完成、失败、未验证、跳过数量及重要限制。不要只说“报告已生成”而不交付文件。
|
- 工具返回 MCP 主机上的绝对文件路径和下载 URL。向用户交付真实生成的文件链接,并说明完成、失败、未验证、跳过数量及重要限制。不要只说“报告已生成”而不交付文件。
|
||||||
- 用 `mobile_search_history` 按名称、目标、编号或标签搜索全部历史;日期先按用户时区转换为 Unix 秒,`start` 含边界、`end` 不含边界。按返回的 `total/page/size` 翻页,不能把第一页当作全部历史。
|
- 用 `mobile_search_history` 按名称、目标、编号或标签搜索全部历史;日期先按用户时区转换为 Unix 秒,`start` 含边界、`end` 不含边界。按返回的 `total/page/size` 翻页,不能把第一页当作全部历史。
|
||||||
- `mobile_inspect_trace` 是只读步骤证据查看;其中历史上下文的 replay 术语不表示重新执行设备动作。
|
- `mobile_inspect_trace` 是只读步骤证据查看;其中历史上下文的 replay 术语不表示重新执行设备动作。
|
||||||
@@ -27,11 +29,17 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
|
|||||||
## 配置和恢复边界
|
## 配置和恢复边界
|
||||||
|
|
||||||
- `mobile_config` 读取非敏感设置。只有用户明确要求保存或修改默认配置时,才传 `changes` 与 `persist_defaults=true`;这些设置影响后续任务,不能为一次临时需求擅自改变以后所有任务。
|
- `mobile_config` 读取非敏感设置。只有用户明确要求保存或修改默认配置时,才传 `changes` 与 `persist_defaults=true`;这些设置影响后续任务,不能为一次临时需求擅自改变以后所有任务。
|
||||||
- 模型名称支持预设 `gpt-5.6-luna`、`mimo-v2.6-flash` 和自定义名称,取决于用户配置的 Responses 网关。思考强度最高支持 `max`,默认 `high`、单次请求 300 秒、失败后最多重试 5 次、固定间隔 10 秒;默认关闭视频分析与录屏。不根据模型名称猜测网关能力。
|
- 模型名称支持预设 `gpt-5.6-luna`、`gpt-6-luna`、`mimo-v2.6-flash`、`mimo-v2.6-pro` 和自定义名称,取决于用户配置的 Responses 网关。思考强度最高支持 `max`,默认 `high`、单次请求 300 秒、失败后最多重试 5 次;普通错误每次等待 10 秒,429 每次等待 60 秒。首选模型连续 3 次未形成有效工具调用时,当前步骤改用 `gpt-6-luna`(high),兜底最多再尝试 3 次;下一步恢复首选模型。默认关闭视频分析与录屏。不根据模型名称猜测网关能力。
|
||||||
- 强度选 `default` 表示不发送该参数,和 `none`(显式要求关闭)不同。接口拒绝强度参数时明确呈现失败,先询问用户是否改用 `default`,不能静默降级或换模型。
|
- 强度选 `default` 表示不发送该参数,和 `none`(显式要求关闭)不同。接口拒绝强度参数时明确呈现失败,先询问用户是否改用 `default`,不能静默降级或换模型。
|
||||||
- API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。
|
- API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。
|
||||||
- 录屏与 AI 视频分析分别配置。批量任务的 `task_config` 可覆盖单项,不修改全局设置;只开视频分析时录制内容不作为可下载文件保存。暂停时停止录屏,恢复后按片段继续,不能把其他任务画面混入该任务资料。
|
- 429 等待和兜底参数可在网页模型配置或经用户明确授权的 `mobile_config(section="model", persist_defaults=true)` 保存;当前任务不热切换。录屏与 AI 视频分析分别配置。批量任务的 `task_config` 可覆盖单项,不修改全局设置;只开视频分析时录制内容不作为可下载文件保存。暂停时停止录屏,恢复后按片段继续,不能把其他任务画面混入该任务资料。
|
||||||
- 请求重试是模型调用恢复,不是测试用例重跑。批次默认失败继续、不自动重跑;服务重启后保留状态并暂停,用户明确要求后才继续或复制失败项。
|
- 请求重试是模型调用恢复,不是测试用例重跑。批次默认失败继续、不自动重跑;服务重启后保留状态并暂停,用户明确要求后才继续或复制失败项。
|
||||||
- 批次暂停只停止后续派发;当前任务等待指导时用 `mobile_manage_task(action="pause", trace_id=...)`,真正取消用 `stop`。用户明确要求删除批次记录时先 `mobile_batch(action="get")` 核对编号,再用 `delete`;执行中/排队中的批次不能删,已执行的任务历史和报告会保留。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。
|
- 批次暂停只停止后续派发;当前任务等待指导时用 `mobile_manage_task(action="pause", trace_id=...)`,真正取消用 `stop`。用户明确要求删除批次记录时先 `mobile_batch(action="get")` 核对编号,再用 `delete`;执行中/排队中的批次不能删,删除会清除该批次的任务历史、报告和文件。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。
|
||||||
|
|
||||||
需要编写外部可执行测试代码时,先通过 ARTEMIS 实际探索目标应用,确认页面、定位器和转换。优先动态定位,框架支持时才以已验证坐标兜底;在最终脚本中明确等待条件,不将 AI 思考耗时当作确定性等待。
|
需要编写外部可执行测试代码时,先通过 ARTEMIS 实际探索目标应用,确认页面、定位器和转换。优先动态定位,框架支持时才以已验证坐标兜底;在最终脚本中明确等待条件,不将 AI 思考耗时当作确定性等待。
|
||||||
|
|
||||||
|
执行保护默认总时限 1800 秒,单任务、批次和 MCP 都适用,排队及用户暂停不计时。设备观测默认 60 秒、最多 3 次,ADB 命令默认 10 秒;观测失败不会继续向模型发送占位图或旧元素索引。Flash 连续 3 次动作失败先重新观察再切换兜底,兜底仍失败则结束任务并保存原因。不能以提高步数或反复重提任务代替设备诊断。
|
||||||
|
|
||||||
|
`mobile_run_task(runtime_overrides={"timeout_seconds":1800,"observation_timeout_seconds":60,"observation_max_attempts":3,"device_command_timeout_seconds":10,"action_failure_attempts":3})` 可为单任务覆盖执行保护;其中 `model` 是 API 模型名,外层 `model` 仍是 Flash/Pro 模式。相同字段也支持批次的 `task_config`。全局默认通过网页功能配置或明确授权的 `mobile_config(section="capabilities", changes={"task_timeout_seconds":1800}, persist_defaults=true)` 保存,对后续任务生效,不接受密钥。
|
||||||
|
|
||||||
|
任务已经产生最终结果,但 SDK 线程或资源清理仍卡住时,默认最多再等待 30 秒后回收工作进程,避免继续占用队列。全局可用 `ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS` 或功能配置的 `worker_cleanup_timeout_seconds` 调整,任务 JSON 同名字段可覆盖。原有结果与截图保留,未完成保存的录屏尽力恢复。
|
||||||
@@ -56,10 +56,10 @@
|
|||||||
- 单项 `pending/queued/running/pausing/task_paused/resuming/finalizing` 尚未结束,`completed/failed/cancelled/interrupted/skipped` 是终态。`pausing` 是请求已发出而设备仍被占用,`task_paused` 才表示设备已释放;`finalizing` 在生成报告,不能提前宣称报告已完成。
|
- 单项 `pending/queued/running/pausing/task_paused/resuming/finalizing` 尚未结束,`completed/failed/cancelled/interrupted/skipped` 是终态。`pausing` 是请求已发出而设备仍被占用,`task_paused` 才表示设备已释放;`finalizing` 在生成报告,不能提前宣称报告已完成。
|
||||||
- 批次 `pause` 保留当前执行,只暂停后续派发;单项 `mobile_manage_task(pause)` 会在安全边界释放设备,Pro 的模型等待与只读诊断可中断。恢复后重新观察画面,并排到设备队尾,不阻塞后续项。`mobile_manage_task(guidance_history)` 可核对指导是否从 `pending` 变为 `consumed`,但仍需检查后续操作是否符合指导。设备断开、模型持续失败或任务达到批次时限会暂停后续派发;用诊断确认恢复后再响应用户的继续指令。
|
- 批次 `pause` 保留当前执行,只暂停后续派发;单项 `mobile_manage_task(pause)` 会在安全边界释放设备,Pro 的模型等待与只读诊断可中断。恢复后重新观察画面,并排到设备队尾,不阻塞后续项。`mobile_manage_task(guidance_history)` 可核对指导是否从 `pending` 变为 `consumed`,但仍需检查后续操作是否符合指导。设备断开、模型持续失败或任务达到批次时限会暂停后续派发;用诊断确认恢复后再响应用户的继续指令。
|
||||||
- `resume` 只继续未开始项;`retry_failed` 复制失败、取消和中断项为新草稿,不修改原结果。必须来自用户的明确重跑请求。该动作不提供创建去重,响应不确定时先 `list` 查找新批次,不自动重复调用。
|
- `resume` 只继续未开始项;`retry_failed` 复制失败、取消和中断项为新草稿,不修改原结果。必须来自用户的明确重跑请求。该动作不提供创建去重,响应不确定时先 `list` 查找新批次,不自动重复调用。
|
||||||
- 用户明确要求删除批次记录时,先 `get` 核对批次编号与状态,再 `delete`;正在执行、排队或等待指导的批次不能删除,已经运行过的任务历史及报告不会随批次记录一起消失。
|
- 用户明确要求删除批次记录时,先 `get` 核对批次编号与状态,再 `delete`;正在执行、排队或等待指导的批次不能删除。删除会同时移除该批次任务历史、报告和文件,无法撤销。
|
||||||
|
|
||||||
## 报告
|
## 报告
|
||||||
|
|
||||||
`mobile_export_report(batch_id=...)` 下载批次 ZIP,包含汇总和各项报告,格式由创建批次时的设置决定。单任务传 `session_id`(单任务 MCP 的 `trace_id`)以及可选 `formats=["docx"]`、`["screenshots"]`、`["video"]` 或 `["all"]`。默认格式通过 `mobile_config(section="reports")` 查看。录屏与 AI 视频分析独立,只有保存了录屏才可下载片段。
|
`mobile_export_report(batch_id=...)` 下载批次 ZIP,包含汇总、各项报告和有序步骤截图;任务保存了录屏时,还包含对应录屏片段。报告格式由创建批次时的设置决定。单任务传 `session_id`(单任务 MCP 的 `trace_id`)以及可选 `formats=["docx"]`、`["screenshots"]`、`["video"]` 或 `["all"]`。默认格式通过 `mobile_config(section="reports")` 查看。录屏与 AI 视频分析独立,只有保存了录屏才可下载片段。
|
||||||
|
|
||||||
结果文件在 MCP 主机的 `artifacts/mcp_exports/`。不要把 Office 文件内容当作文本塞入上下文;先检查工具返回的结构化结论,需要进一步审阅时再用宿主具备的文档工具打开文件。失败和未验证项也必须交付,保持原始证据。
|
结果文件在 MCP 主机的 `artifacts/mcp_exports/`。不要把 Office 文件内容当作文本塞入上下文;先检查工具返回的结构化结论,需要进一步审阅时再用宿主具备的文档工具打开文件。失败和未验证项也必须交付,保持原始证据。
|
||||||
+2
-1
@@ -25,8 +25,9 @@ echo -SetupOnly 安装或检查环境,不启动网页
|
|||||||
echo -Port 8001 使用指定端口,默认 8000
|
echo -Port 8001 使用指定端口,默认 8000
|
||||||
echo -NoOpen 启动服务后不自动打开浏览器
|
echo -NoOpen 启动服务后不自动打开浏览器
|
||||||
echo -SkipUpdate 跳过 Git 源码更新检查
|
echo -SkipUpdate 跳过 Git 源码更新检查
|
||||||
|
echo -SkipMcpSync 仅启动网页,跳过 Codex MCP 安装和更新
|
||||||
echo -Help 显示本说明
|
echo -Help 显示本说明
|
||||||
echo.
|
echo.
|
||||||
echo 国内镜像设置:config\downloads.json
|
echo 国内镜像设置:config\downloads.json
|
||||||
echo 首次运行需联网;后续运行会检查源码、依赖和已有 MCP 配置。
|
echo 首次运行需联网;后续运行会检查源码、依赖,并同步 Codex MCP 配置。
|
||||||
endlocal & exit /b 0
|
endlocal & exit /b 0
|
||||||
@@ -24,7 +24,7 @@ if /i "%ARTEMIS_LAUNCH_LANG%"=="zh-CN" (echo ARTEMIS 手机自动化测试
|
|||||||
echo ======================================================
|
echo ======================================================
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
rem First run installs uv/Python/.venv; later runs check Git, dependencies and existing MCP clients.
|
rem First run installs uv/Python/.venv; later runs check Git, dependencies and Codex MCP.
|
||||||
rem Use -SkipUpdate to skip the Git source check and run local code.
|
rem Use -SkipUpdate to skip the Git source check and run local code.
|
||||||
rem Use start.bat -SetupOnly to prepare Python without launching the UI.
|
rem Use start.bat -SetupOnly to prepare Python without launching the UI.
|
||||||
rem Use start-cn.bat or start.bat -Mirror China for China download mirrors.
|
rem Use start-cn.bat or start.bat -Mirror China for China download mirrors.
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import json
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
from uuid import UUID
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
from docx import Document
|
from docx import Document
|
||||||
@@ -106,6 +109,63 @@ def test_invalid_archive_and_empty_template():
|
|||||||
assert not task_import.parse_workbook(task_import.template())["valid"]
|
assert not task_import.parse_workbook(task_import.template())["valid"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_archive_preparation_reports_progress_and_reuses_ready_file(
|
||||||
|
client, isolated, monkeypatch
|
||||||
|
):
|
||||||
|
batch = service.create_batch(payload())
|
||||||
|
batch_id = batch["id"]
|
||||||
|
started = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def slow_export(identifier, progress=None):
|
||||||
|
calls.append(identifier)
|
||||||
|
progress(0, 2, "正在打包任务 1/2")
|
||||||
|
started.set()
|
||||||
|
assert release.wait(5)
|
||||||
|
progress(2, 2, "正在完成压缩包")
|
||||||
|
path = reports.EXPORT_ROOT / "batches" / identifier / "批次报告.zip"
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(b"prepared archive")
|
||||||
|
return path
|
||||||
|
|
||||||
|
monkeypatch.setattr(reports, "export_batch", slow_export)
|
||||||
|
base = f"/api/automation/batches/{batch_id}/archive"
|
||||||
|
try:
|
||||||
|
response = client.post(base + "/prepare")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "preparing"
|
||||||
|
assert started.wait(2)
|
||||||
|
progress = client.get(base + "/status").json()
|
||||||
|
assert progress["total"] == 2
|
||||||
|
assert progress["stage"] == "正在打包任务 1/2"
|
||||||
|
assert client.get(base).status_code == 409
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
for _ in range(50):
|
||||||
|
if client.get(base + "/status").json()["status"] == "ready":
|
||||||
|
break
|
||||||
|
time.sleep(0.02)
|
||||||
|
assert client.get(base + "/status").json()["status"] == "ready"
|
||||||
|
assert client.get(base).content == b"prepared archive"
|
||||||
|
assert calls == [batch_id]
|
||||||
|
|
||||||
|
|
||||||
|
def test_completed_batch_reuses_existing_archive(client, isolated, monkeypatch):
|
||||||
|
batch = service.create_batch(payload())
|
||||||
|
batch_id = batch["id"]
|
||||||
|
isolated.put("batch", batch_id, {**batch, "status": "completed", "report_ready": True})
|
||||||
|
path = reports.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip"
|
||||||
|
path.parent.mkdir(parents=True)
|
||||||
|
path.write_bytes(b"existing archive")
|
||||||
|
exporter = Mock(side_effect=AssertionError("archive must not be rebuilt"))
|
||||||
|
monkeypatch.setattr(reports, "export_batch", exporter)
|
||||||
|
base = f"/api/automation/batches/{batch_id}/archive"
|
||||||
|
assert client.post(base + "/prepare").json()["status"] == "ready"
|
||||||
|
assert client.get(base).content == b"existing archive"
|
||||||
|
exporter.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_preview_does_not_enqueue(client, isolated):
|
def test_preview_does_not_enqueue(client, isolated):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/automation/import/preview",
|
"/api/automation/import/preview",
|
||||||
@@ -125,19 +185,44 @@ def test_preferences_and_required_device(client):
|
|||||||
assert client.post("/api/automation/batches", json=payload(device_serial="")).status_code == 422
|
assert client.post("/api/automation/batches", json=payload(device_serial="")).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_batch_delete_retains_executed_task_and_prevents_request_replay(client, isolated):
|
def test_batch_delete_removes_tasks_and_files_and_prevents_request_replay(client, isolated):
|
||||||
created = client.post(
|
created = client.post(
|
||||||
"/api/automation/batches", json=payload(request_id="delete-example")
|
"/api/automation/batches", json=payload(request_id="delete-example")
|
||||||
).json()
|
).json()
|
||||||
first, second = isolated.jobs(created["id"])
|
first, second = isolated.jobs(created["id"])
|
||||||
isolated.save_job({**first, "status": "completed"})
|
isolated.save_job({**first, "status": "completed"})
|
||||||
|
trace_dir = reports.TRACES_PATH / first["session_id"]
|
||||||
|
trace_dir.mkdir(parents=True)
|
||||||
|
(trace_dir / "result.json").write_text("{}", encoding="utf-8")
|
||||||
|
report_dir = reports.EXPORT_ROOT / "sessions" / first["session_id"]
|
||||||
|
report_dir.mkdir(parents=True)
|
||||||
|
(report_dir / "任务报告.docx").write_bytes(b"report")
|
||||||
|
batch_dir = reports.EXPORT_ROOT / "batches" / created["id"]
|
||||||
|
batch_dir.mkdir(parents=True)
|
||||||
|
(batch_dir / "批次报告.zip").write_bytes(b"zip")
|
||||||
|
mcp_task_dir = reports.EXPORT_ROOT.parent / "mcp_exports" / "sessions" / first["session_id"]
|
||||||
|
mcp_task_dir.mkdir(parents=True)
|
||||||
|
(mcp_task_dir / "报告.docx").write_bytes(b"report")
|
||||||
|
mcp_batch_dir = reports.EXPORT_ROOT.parent / "mcp_exports" / "batches" / created["id"]
|
||||||
|
mcp_batch_dir.mkdir(parents=True)
|
||||||
|
(mcp_batch_dir / "批次.zip").write_bytes(b"zip")
|
||||||
|
unrelated = reports.TRACES_PATH / "unrelated-task"
|
||||||
|
unrelated.mkdir()
|
||||||
|
(unrelated / "keep.txt").write_text("keep", encoding="utf-8")
|
||||||
|
|
||||||
response = client.delete(f"/api/automation/batches/{created['id']}")
|
response = client.delete(f"/api/automation/batches/{created['id']}")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert client.get(f"/api/automation/batches/{created['id']}").status_code == 404
|
assert client.get(f"/api/automation/batches/{created['id']}").status_code == 404
|
||||||
assert isolated.job(first["session_id"])["case"]["name"] == "用例甲"
|
assert isolated.job(first["session_id"]) is None
|
||||||
assert isolated.job(second["session_id"]) is None
|
assert isolated.job(second["session_id"]) is None
|
||||||
assert reports.session_report(first["session_id"])["name"] == "用例甲"
|
assert not trace_dir.exists()
|
||||||
|
assert not report_dir.exists()
|
||||||
|
assert not batch_dir.exists()
|
||||||
|
assert not mcp_task_dir.exists()
|
||||||
|
assert not mcp_batch_dir.exists()
|
||||||
|
assert (unrelated / "keep.txt").read_text(encoding="utf-8") == "keep"
|
||||||
|
with pytest.raises(ValueError, match="不存在"):
|
||||||
|
reports.session_report(first["session_id"])
|
||||||
assert (
|
assert (
|
||||||
client.post(
|
client.post(
|
||||||
"/api/automation/batches", json=payload(request_id="delete-example")
|
"/api/automation/batches", json=payload(request_id="delete-example")
|
||||||
@@ -158,6 +243,49 @@ def test_batch_delete_rejects_active_jobs(client, isolated, status):
|
|||||||
assert len(isolated.jobs(batch["id"])) == 2
|
assert len(isolated.jobs(batch["id"])) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_deletion_reservation_blocks_new_dispatch(isolated):
|
||||||
|
batch = service.create_batch(payload())
|
||||||
|
job = isolated.jobs(batch["id"])[0]
|
||||||
|
|
||||||
|
isolated.begin_delete_batch(batch["id"])
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="正在删除"):
|
||||||
|
isolated.save_job({**job, "status": "queued"})
|
||||||
|
with pytest.raises(ValueError, match="正在删除"):
|
||||||
|
isolated.put("batch", batch["id"], {**batch, "status": "running"})
|
||||||
|
assert isolated.jobs_for_sessions([job["session_id"]])[job["session_id"]]["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_deletion_preserves_shared_screenshot_until_last_task(client, isolated):
|
||||||
|
first_batch = service.create_batch(payload())
|
||||||
|
second_batch = service.create_batch(payload(name="另一批次"))
|
||||||
|
first = isolated.jobs(first_batch["id"])[0]
|
||||||
|
other = isolated.jobs(second_batch["id"])[0]
|
||||||
|
shared_hash = "a" * 64
|
||||||
|
storage = StorageManager(isolated.path, reports.TRACES_PATH)
|
||||||
|
image_file = reports.TRACES_PATH / "images" / f"{shared_hash}.jpg"
|
||||||
|
image_file.parent.mkdir(parents=True)
|
||||||
|
image_file.write_bytes(b"shared image")
|
||||||
|
with sqlite3.connect(isolated.path) as conn:
|
||||||
|
conn.execute("INSERT INTO images(image_name) VALUES (?)", (shared_hash,))
|
||||||
|
for job in (first, other):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions(session_id,initial_goal,start_time,status,device_info) "
|
||||||
|
"VALUES (?,?,?,?,?)",
|
||||||
|
(job["session_id"], "观察设置", 1.0, "completed", "{}"),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO steps(step_id,session_id,pre_image_name) VALUES (?,?,?)",
|
||||||
|
(job["session_id"], job["session_id"], shared_hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert client.delete(f"/api/automation/batches/{first_batch['id']}").status_code == 200
|
||||||
|
assert image_file.read_bytes() == b"shared image"
|
||||||
|
assert storage.get_session(UUID(other["session_id"])) is not None
|
||||||
|
assert client.delete(f"/api/automation/batches/{second_batch['id']}").status_code == 200
|
||||||
|
assert not image_file.exists()
|
||||||
|
|
||||||
|
|
||||||
def test_restart_never_repeats_started_work(isolated):
|
def test_restart_never_repeats_started_work(isolated):
|
||||||
batch = service.create_batch(payload())
|
batch = service.create_batch(payload())
|
||||||
service.batch_action(batch["id"], "resume")
|
service.batch_action(batch["id"], "resume")
|
||||||
@@ -172,6 +300,116 @@ def test_restart_never_repeats_started_work(isolated):
|
|||||||
assert isolated.jobs(retry["id"])[0]["session_id"] != job["session_id"]
|
assert isolated.jobs(retry["id"])[0]["session_id"] != job["session_id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("liveness_source", ["pid", "device_lock"])
|
||||||
|
def test_recovery_preserves_live_runner_and_batch(isolated, monkeypatch, liveness_source):
|
||||||
|
import os
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from artemis.runtime import DeviceExecutionLock
|
||||||
|
|
||||||
|
batch = service.create_batch(payload())
|
||||||
|
service.batch_action(batch["id"], "resume")
|
||||||
|
job = isolated.jobs(batch["id"])[0]
|
||||||
|
isolated.save_job({**job, "status": "running"})
|
||||||
|
if liveness_source == "pid":
|
||||||
|
with sqlite3.connect(isolated.path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions(session_id,initial_goal,start_time,status,device_info,pid) "
|
||||||
|
"VALUES (?,?,?,?,?,?)",
|
||||||
|
(job["session_id"], "goal", 1, "running", "{}", os.getpid()),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DeviceExecutionLock,
|
||||||
|
"get_active_owners",
|
||||||
|
lambda: (
|
||||||
|
{"device": SimpleNamespace(session_id=job["session_id"])}
|
||||||
|
if liveness_source == "device_lock"
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
service.recover_batches()
|
||||||
|
assert isolated.job(job["session_id"])["status"] == "running"
|
||||||
|
assert not isolated.job(job["session_id"]).get("error")
|
||||||
|
assert isolated.get("batch", batch["id"])["status"] == "running"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_natural_completion_resolves_old_recovery_error_in_job_report_and_history(
|
||||||
|
client, isolated
|
||||||
|
):
|
||||||
|
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||||
|
message = "服务中断;未完成任务需手动重跑"
|
||||||
|
isolated.save_job({**job, "status": "interrupted", "error": message})
|
||||||
|
await service.on_terminal({"session_id": job["session_id"]}, "completed")
|
||||||
|
saved = isolated.job(job["session_id"])
|
||||||
|
assert saved["status"] == "completed"
|
||||||
|
assert not saved.get("error")
|
||||||
|
assert saved["recovery_events"][0]["message"] == message
|
||||||
|
assert reports.session_report(job["session_id"])["error"] == ""
|
||||||
|
assert not client.get("/api/automation/history").json()["items"][0].get("error")
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_repairs_completed_legacy_warning_but_preserves_real_failure(isolated):
|
||||||
|
batch = service.create_batch(payload())
|
||||||
|
first, second = isolated.jobs(batch["id"])
|
||||||
|
isolated.save_job({**first, "status": "completed", "error": "服务中断,需手动重跑"})
|
||||||
|
isolated.save_job({**second, "status": "failed", "error": "任务执行超时(30 秒)"})
|
||||||
|
assert reports.session_report(first["session_id"])["error"] == ""
|
||||||
|
service.recover_batches()
|
||||||
|
assert not isolated.job(first["session_id"]).get("error")
|
||||||
|
assert "服务中断" in isolated.job(first["session_id"])["recovery_events"][0]["message"]
|
||||||
|
assert "超时" in isolated.job(second["session_id"])["error"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_surviving_worker_is_finalized_when_it_exits_without_resubmission(
|
||||||
|
isolated, monkeypatch
|
||||||
|
):
|
||||||
|
import os
|
||||||
|
from artemis.runtime import DeviceExecutionLock
|
||||||
|
|
||||||
|
batch = service.create_batch(payload())
|
||||||
|
service.batch_action(batch["id"], "resume")
|
||||||
|
job = isolated.jobs(batch["id"])[0]
|
||||||
|
isolated.save_job({**job, "status": "running"})
|
||||||
|
monkeypatch.setattr(DeviceExecutionLock, "get_active_owners", lambda: {})
|
||||||
|
with sqlite3.connect(isolated.path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions(session_id,initial_goal,start_time,status,device_info,pid) "
|
||||||
|
"VALUES (?,?,?,?,?,?)",
|
||||||
|
(job["session_id"], "goal", time.time(), "running", "{}", os.getpid()),
|
||||||
|
)
|
||||||
|
service.recover_batches()
|
||||||
|
await service.reconcile_recovered_jobs()
|
||||||
|
assert isolated.job(job["session_id"])["status"] == "running"
|
||||||
|
with sqlite3.connect(isolated.path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sessions SET pid=NULL,status='completed' WHERE session_id=?",
|
||||||
|
(job["session_id"],),
|
||||||
|
)
|
||||||
|
await service.reconcile_recovered_jobs()
|
||||||
|
saved = isolated.job(job["session_id"])
|
||||||
|
assert saved["status"] == "completed"
|
||||||
|
assert saved["report_ready"]
|
||||||
|
assert not saved.get("recovery_waiting")
|
||||||
|
assert not saved.get("error")
|
||||||
|
assert len(isolated.jobs(batch["id"])) == 2
|
||||||
|
assert isolated.jobs(batch["id"])[1]["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_surviving_worker_that_dies_without_result_is_interrupted(isolated, monkeypatch):
|
||||||
|
from artemis.runtime import DeviceExecutionLock
|
||||||
|
|
||||||
|
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||||
|
isolated.save_job({**job, "status": "running", "recovery_waiting": True})
|
||||||
|
monkeypatch.setattr(DeviceExecutionLock, "get_active_owners", lambda: {})
|
||||||
|
await service.reconcile_recovered_jobs()
|
||||||
|
saved = isolated.job(job["session_id"])
|
||||||
|
assert saved["status"] == "interrupted"
|
||||||
|
assert "服务中断" in saved["error"]
|
||||||
|
assert not saved.get("recovery_waiting")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_batch_admission_idempotent_and_bound_device(isolated, monkeypatch):
|
async def test_batch_admission_idempotent_and_bound_device(isolated, monkeypatch):
|
||||||
from apps.admin_console import server
|
from apps.admin_console import server
|
||||||
@@ -248,6 +486,38 @@ async def test_fail_fast_includes_skipped_reports(isolated, monkeypatch):
|
|||||||
assert sum(n.endswith(".docx") for n in z.namelist()) == 3
|
assert sum(n.endswith(".docx") for n in z.namelist()) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_archive_contains_each_task_media_when_present(isolated, monkeypatch, tmp_path):
|
||||||
|
batch = service.create_batch(payload())
|
||||||
|
first, second = isolated.jobs(batch["id"])
|
||||||
|
image1 = tmp_path / "first.png"
|
||||||
|
image2 = tmp_path / "second.png"
|
||||||
|
video = tmp_path / "recording.mp4"
|
||||||
|
image1.write_bytes(b"first screenshot")
|
||||||
|
image2.write_bytes(b"second screenshot")
|
||||||
|
video.write_bytes(b"saved recording")
|
||||||
|
images = {first["session_id"]: image1, second["session_id"]: image2}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
reports,
|
||||||
|
"session_screenshots",
|
||||||
|
lambda report: [(images[report["id"]], "0001_步骤0001_操作前.png")],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
reports,
|
||||||
|
"session_videos",
|
||||||
|
lambda report: (
|
||||||
|
[(video, "0001_recording.mp4")] if report["id"] == first["session_id"] else []
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
archive = reports.export_batch(batch["id"])
|
||||||
|
with zipfile.ZipFile(archive) as z:
|
||||||
|
names = z.namelist()
|
||||||
|
assert len([name for name in names if "/步骤截图/" in name]) == 2
|
||||||
|
assert len([name for name in names if "/任务录屏/" in name]) == 1
|
||||||
|
assert all(name.startswith("任务报告/") for name in names if "步骤截图" in name)
|
||||||
|
assert z.read(next(name for name in names if "/任务录屏/" in name)) == b"saved recording"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_report_failure_does_not_strand_job(isolated, monkeypatch):
|
async def test_report_failure_does_not_strand_job(isolated, monkeypatch):
|
||||||
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||||
@@ -260,6 +530,123 @@ async def test_report_failure_does_not_strand_job(isolated, monkeypatch):
|
|||||||
assert "disk full" in saved["report_error"]
|
assert "disk full" in saved["report_error"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_batch_timeout_stays_visible_in_job_report_and_history(client, isolated):
|
||||||
|
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||||
|
isolated.save_job(
|
||||||
|
{**job, "status": "running", "timed_out": True, "error": "任务执行超时(30 秒)"}
|
||||||
|
)
|
||||||
|
await service.on_terminal({"session_id": job["session_id"]}, "cancelled")
|
||||||
|
saved = isolated.job(job["session_id"])
|
||||||
|
assert saved["status"] == "failed"
|
||||||
|
assert "超时" in saved["error"]
|
||||||
|
assert "超时" in reports.session_report(job["session_id"])["error"]
|
||||||
|
history = client.get("/api/automation/history").json()["items"]
|
||||||
|
assert any(
|
||||||
|
item["session_id"] == job["session_id"] and "超时" in item["error"] for item in history
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("stop_on_failure", [False, True])
|
||||||
|
async def test_timed_out_case_releases_next_batch_case(isolated, monkeypatch, stop_on_failure):
|
||||||
|
from apps.admin_console import server
|
||||||
|
from artemis.runtime.device_pool import DeviceStatus
|
||||||
|
|
||||||
|
batch = service.create_batch(payload(timeout_seconds=1, stop_on_failure=stop_on_failure))
|
||||||
|
service.batch_action(batch["id"], "resume")
|
||||||
|
first = isolated.jobs(batch["id"])[0]
|
||||||
|
isolated.save_job({**first, "status": "running", "started_at": time.time() - 2})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
service, "state", type("State", (), {"is_paused": False, "active_runs": {}})()
|
||||||
|
)
|
||||||
|
stop = Mock(return_value=True)
|
||||||
|
enqueue = AsyncMock(return_value={"tasks": [{"device_serial": "test-device"}]})
|
||||||
|
monkeypatch.setattr(server.task_queue_service, "stop_tasks", stop)
|
||||||
|
monkeypatch.setattr(server.task_queue_service, "enqueue_tasks", enqueue)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"artemis.runtime.device_pool.try_list_devices_async",
|
||||||
|
AsyncMock(return_value=[DeviceStatus(serial="test-device", state="device")]),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(reports, "export_batch", Mock())
|
||||||
|
|
||||||
|
await service.tick_batches()
|
||||||
|
assert isolated.get("batch", batch["id"])["status"] == "running"
|
||||||
|
assert "超时" in isolated.job(first["session_id"])["error"]
|
||||||
|
stop.assert_called_once_with(session_id=first["session_id"])
|
||||||
|
await service.tick_batches()
|
||||||
|
stop.assert_called_once()
|
||||||
|
|
||||||
|
await service.on_terminal({"session_id": first["session_id"]}, "cancelled")
|
||||||
|
await service.tick_batches()
|
||||||
|
statuses = [job["status"] for job in isolated.jobs(batch["id"])]
|
||||||
|
assert statuses == (["failed", "skipped"] if stop_on_failure else ["failed", "queued"])
|
||||||
|
assert enqueue.await_count == (0 if stop_on_failure else 1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_queued_case_does_not_consume_execution_timeout(isolated, monkeypatch):
|
||||||
|
from apps.admin_console import server
|
||||||
|
|
||||||
|
batch = service.create_batch(payload(timeout_seconds=1))
|
||||||
|
service.batch_action(batch["id"], "resume")
|
||||||
|
first = isolated.jobs(batch["id"])[0]
|
||||||
|
isolated.save_job({**first, "status": "queued", "started_at": time.time() - 30})
|
||||||
|
monkeypatch.setattr(service, "state", type("State", (), {"is_paused": False})())
|
||||||
|
stop = Mock()
|
||||||
|
monkeypatch.setattr(server.task_queue_service, "stop_tasks", stop)
|
||||||
|
|
||||||
|
await service.tick_batches()
|
||||||
|
stop.assert_not_called()
|
||||||
|
assert isolated.job(first["session_id"])["status"] == "queued"
|
||||||
|
assert isolated.get("batch", batch["id"])["status"] == "running"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_model_timeout_from_runner_log_reaches_failed_task_and_report(isolated):
|
||||||
|
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||||
|
folder = reports.TRACES_PATH / job["session_id"]
|
||||||
|
folder.mkdir(parents=True)
|
||||||
|
(folder / "stderr.log").write_text(
|
||||||
|
"TimeoutError: model request timed out after 300 seconds\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.on_terminal({"session_id": job["session_id"]}, "failed")
|
||||||
|
|
||||||
|
saved = isolated.job(job["session_id"])
|
||||||
|
assert saved["status"] == "failed"
|
||||||
|
assert "超时" in saved["error"]
|
||||||
|
assert reports.session_report(job["session_id"])["status"] == "failed"
|
||||||
|
assert "超时" in reports.session_report(job["session_id"])["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_optional_timeout_warnings_are_not_reported_as_task_failure(tmp_path):
|
||||||
|
(tmp_path / "stdout.log").write_text(
|
||||||
|
"⚠ Could not clean unowned screen wake locks on emulator-5554: "
|
||||||
|
"Timed out acquiring the Artemis awake-lease mutex.\n"
|
||||||
|
"⚠ VisualStepSummarizer: Error generating summary for step 3: \n"
|
||||||
|
"ℹ StepMemoryService: Retrying Step 3 (attempt 4) after 1.0s\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert reports.failure_reason(tmp_path, "failed", None) == (
|
||||||
|
"任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
|
||||||
|
)
|
||||||
|
|
||||||
|
(tmp_path / "stderr.log").write_text(
|
||||||
|
"TimeoutError: model request timed out after 300 seconds\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
reason = reports.failure_reason(tmp_path, "failed", None)
|
||||||
|
assert "model request timed out" in reason
|
||||||
|
assert "awake-lease" not in reason
|
||||||
|
|
||||||
|
(tmp_path / "stderr.log").write_text(
|
||||||
|
"DeviceBusyError: device queue reservation was cancelled\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert reports.failure_reason(tmp_path, "failed", None) == (
|
||||||
|
"DeviceBusyError: device queue reservation was cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_reports_do_not_execute_formulas_or_invent_pass(isolated):
|
def test_reports_do_not_execute_formulas_or_invent_pass(isolated):
|
||||||
batch = service.create_batch(payload())
|
batch = service.create_batch(payload())
|
||||||
job = isolated.jobs(batch["id"])[0]
|
job = isolated.jobs(batch["id"])[0]
|
||||||
@@ -296,6 +683,7 @@ def test_failed_report_exposes_logged_reason_and_formats_chinese_markdown(isolat
|
|||||||
assert "abc123secret" not in reports.failure_reason(
|
assert "abc123secret" not in reports.failure_reason(
|
||||||
folder, "failed", "认证失败 OPENAI_API_KEY=abc123secret Authorization: Bearer abc123secret"
|
folder, "failed", "认证失败 OPENAI_API_KEY=abc123secret Authorization: Bearer abc123secret"
|
||||||
)
|
)
|
||||||
|
assert "超时" not in reports.failure_reason(folder, "failed", "LLMExhaustedError: 额度不足")
|
||||||
paths = reports.export_session(job["session_id"])
|
paths = reports.export_session(job["session_id"])
|
||||||
doc = Document(next(path for path in paths if path.suffix == ".docx"))
|
doc = Document(next(path for path in paths if path.suffix == ".docx"))
|
||||||
assert doc.styles["Normal"].font.name == "Microsoft YaHei"
|
assert doc.styles["Normal"].font.name == "Microsoft YaHei"
|
||||||
@@ -325,6 +713,7 @@ def test_task_material_zip_contains_ordered_evidence_and_reports(
|
|||||||
client, isolated, tmp_path, monkeypatch
|
client, isolated, tmp_path, monkeypatch
|
||||||
):
|
):
|
||||||
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||||
|
isolated.save_job({**job, "case": {**job["case"], "case_id": "1.1"}})
|
||||||
evidence = tmp_path / "evidence"
|
evidence = tmp_path / "evidence"
|
||||||
evidence.mkdir()
|
evidence.mkdir()
|
||||||
first = evidence / "first.png"
|
first = evidence / "first.png"
|
||||||
@@ -343,14 +732,53 @@ def test_task_material_zip_contains_ordered_evidence_and_reports(
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
with zipfile.ZipFile(BytesIO(response.content)) as archive:
|
with zipfile.ZipFile(BytesIO(response.content)) as archive:
|
||||||
files = archive.namelist()
|
files = archive.namelist()
|
||||||
|
assert all(name.startswith("1.1/") for name in files)
|
||||||
assert any(name.endswith(".docx") for name in files)
|
assert any(name.endswith(".docx") for name in files)
|
||||||
assert any(name.endswith(".xlsx") for name in files)
|
assert any(name.endswith(".xlsx") for name in files)
|
||||||
assert files.index("步骤截图/0001_步骤0001_操作前.png") < files.index(
|
assert files.index("1.1/步骤截图/0001_步骤0001_操作前.png") < files.index(
|
||||||
"步骤截图/0002_步骤0002_操作后.png"
|
"1.1/步骤截图/0002_步骤0002_操作后.png"
|
||||||
)
|
)
|
||||||
assert archive.read("任务录屏/0001_recording.mp4") == b"recording.mp4"
|
assert archive.read("1.1/任务录屏/0001_recording.mp4") == b"recording.mp4"
|
||||||
manifest = json.loads(archive.read("文件清单.json"))
|
manifest = json.loads(archive.read("1.1/文件清单.json"))
|
||||||
assert manifest["session_id"] == job["session_id"]
|
assert manifest["session_id"] == job["session_id"]
|
||||||
|
assert "1.1-all.zip" in response.headers["content-disposition"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_video_export_accepts_finalized_worker_trace_and_rejects_external_path(
|
||||||
|
isolated, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||||
|
session_id = job["session_id"]
|
||||||
|
recording_dir = reports.TRACES_PATH / f"web_123_{session_id[:8]}_PASS_2026-09-28"
|
||||||
|
recording_dir.mkdir(parents=True)
|
||||||
|
first = recording_dir / "recording.mp4"
|
||||||
|
first.write_bytes(b"first segment")
|
||||||
|
resumed = recording_dir / "resume_0001"
|
||||||
|
resumed.mkdir()
|
||||||
|
second = resumed / "recording.mp4"
|
||||||
|
second.write_bytes(b"second segment")
|
||||||
|
|
||||||
|
repository = Mock()
|
||||||
|
repository.get_video_recording_for_session.return_value = {"local_video_path": str(first)}
|
||||||
|
repository.get_session_by_id.return_value = {"video_filepath": str(first)}
|
||||||
|
monkeypatch.setattr(reports, "SessionRepository", lambda: repository)
|
||||||
|
|
||||||
|
archive_path = reports.export_session_media(session_id, "video")
|
||||||
|
with zipfile.ZipFile(archive_path) as archive:
|
||||||
|
names = archive.namelist()
|
||||||
|
assert names == [
|
||||||
|
"任务录屏/0001_web_123_" + session_id[:8] + "_PASS_2026-09-28_recording.mp4",
|
||||||
|
"任务录屏/0002_resume_0001_recording.mp4",
|
||||||
|
"文件清单.json",
|
||||||
|
]
|
||||||
|
assert archive.read(names[0]) == b"first segment"
|
||||||
|
assert archive.read(names[1]) == b"second segment"
|
||||||
|
|
||||||
|
external = tmp_path / "outside" / "recording.mp4"
|
||||||
|
external.parent.mkdir()
|
||||||
|
external.write_bytes(b"unrelated")
|
||||||
|
repository.get_video_recording_for_session.return_value = {"local_video_path": str(external)}
|
||||||
|
assert reports.session_videos({"id": session_id}) == []
|
||||||
|
|
||||||
|
|
||||||
def test_history_filters_all_persisted_rows_and_escapes_like(client, isolated):
|
def test_history_filters_all_persisted_rows_and_escapes_like(client, isolated):
|
||||||
|
|||||||
@@ -23,10 +23,22 @@ def isolated_settings(monkeypatch, tmp_path):
|
|||||||
"OPENAI_TIMEOUT_SECONDS",
|
"OPENAI_TIMEOUT_SECONDS",
|
||||||
"OPENAI_MAX_RETRIES",
|
"OPENAI_MAX_RETRIES",
|
||||||
"OPENAI_RETRY_INTERVAL_SECONDS",
|
"OPENAI_RETRY_INTERVAL_SECONDS",
|
||||||
|
"OPENAI_RATE_LIMIT_RETRY_SECONDS",
|
||||||
|
"OPENAI_TOOL_FALLBACK_MODEL",
|
||||||
|
"OPENAI_TOOL_FALLBACK_REASONING_EFFORT",
|
||||||
|
"OPENAI_TOOL_FALLBACK_MAX_RETRIES",
|
||||||
|
"ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS",
|
||||||
"OPENAI_REASONING_EFFORT",
|
"OPENAI_REASONING_EFFORT",
|
||||||
"ARTEMIS_VIDEO_ENABLED",
|
"ARTEMIS_VIDEO_ENABLED",
|
||||||
"ARTEMIS_OUTPUTTER_ENABLED",
|
"ARTEMIS_OUTPUTTER_ENABLED",
|
||||||
"ARTEMIS_STEP_SUMMARIZER_ENABLED",
|
"ARTEMIS_STEP_SUMMARIZER_ENABLED",
|
||||||
|
"ARTEMIS_SCREENSHOT_LIMIT_ENABLED",
|
||||||
|
"ARTEMIS_TASK_TIMEOUT_SECONDS",
|
||||||
|
"ARTEMIS_OBSERVATION_TIMEOUT_SECONDS",
|
||||||
|
"ARTEMIS_OBSERVATION_MAX_ATTEMPTS",
|
||||||
|
"ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS",
|
||||||
|
"ARTEMIS_ACTION_FAILURE_ATTEMPTS",
|
||||||
|
"ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS",
|
||||||
]
|
]
|
||||||
for key in keys:
|
for key in keys:
|
||||||
monkeypatch.setattr(settings, key, getattr(settings, key))
|
monkeypatch.setattr(settings, key, getattr(settings, key))
|
||||||
@@ -42,6 +54,11 @@ def test_save_reload_and_blank_key_preserves_secret(isolated_settings):
|
|||||||
api_key="new-test-secret",
|
api_key="new-test-secret",
|
||||||
timeout_seconds=75,
|
timeout_seconds=75,
|
||||||
max_retries=2,
|
max_retries=2,
|
||||||
|
rate_limit_retry_seconds=90,
|
||||||
|
tool_fallback_model="custom/fallback",
|
||||||
|
tool_fallback_reasoning_effort="max",
|
||||||
|
tool_fallback_max_retries=2,
|
||||||
|
tool_fallback_no_progress_attempts=4,
|
||||||
)
|
)
|
||||||
result = service.save_model_settings(update)
|
result = service.save_model_settings(update)
|
||||||
assert result["api_key_configured"]
|
assert result["api_key_configured"]
|
||||||
@@ -49,12 +66,16 @@ def test_save_reload_and_blank_key_preserves_secret(isolated_settings):
|
|||||||
saved = dotenv_values(isolated_settings)
|
saved = dotenv_values(isolated_settings)
|
||||||
assert saved["OPENAI_API_KEY"] == "new-test-secret"
|
assert saved["OPENAI_API_KEY"] == "new-test-secret"
|
||||||
assert saved["OPENAI_MODEL"] == "my-model"
|
assert saved["OPENAI_MODEL"] == "my-model"
|
||||||
|
assert saved["OPENAI_RATE_LIMIT_RETRY_SECONDS"] == "90.0"
|
||||||
|
assert saved["OPENAI_TOOL_FALLBACK_MODEL"] == "custom/fallback"
|
||||||
|
assert saved["ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS"] == "4"
|
||||||
assert saved["UNRELATED"] == "keep"
|
assert saved["UNRELATED"] == "keep"
|
||||||
assert "# user settings" in isolated_settings.read_text(encoding="utf-8")
|
assert "# user settings" in isolated_settings.read_text(encoding="utf-8")
|
||||||
reloaded = Settings(_env_file=isolated_settings)
|
reloaded = Settings(_env_file=isolated_settings)
|
||||||
assert reloaded.OPENAI_MODEL == "my-model"
|
assert reloaded.OPENAI_MODEL == "my-model"
|
||||||
assert parse_llm_config().operator.model == "my-model"
|
assert parse_llm_config().operator.model == "my-model"
|
||||||
assert parse_llm_config().operator.timeout == 75
|
assert parse_llm_config().operator.timeout == 75
|
||||||
|
assert parse_llm_config().operator.fallback.model == "custom/fallback"
|
||||||
service.save_model_settings(update.model_copy(update={"api_key": SecretStr("")}))
|
service.save_model_settings(update.model_copy(update={"api_key": SecretStr("")}))
|
||||||
assert dotenv_values(isolated_settings)["OPENAI_API_KEY"] == "new-test-secret"
|
assert dotenv_values(isolated_settings)["OPENAI_API_KEY"] == "new-test-secret"
|
||||||
|
|
||||||
@@ -70,6 +91,12 @@ def test_new_install_uses_playones_endpoint_without_overriding_saved_url(monkeyp
|
|||||||
assert service.get_model_settings()["base_url"] == "https://saved.example/v1"
|
assert service.get_model_settings()["base_url"] == "https://saved.example/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_presets_include_flash_pro_and_luna_without_restricting_custom_names():
|
||||||
|
presets = service.get_model_settings()["model_presets"]
|
||||||
|
assert presets == ["gpt-5.6-luna", "gpt-6-luna", "mimo-v2.6-flash", "mimo-v2.6-pro"]
|
||||||
|
assert service.ModelSettingsUpdate(model="custom/model-v2").model == "custom/model-v2"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"url",
|
"url",
|
||||||
[
|
[
|
||||||
@@ -87,21 +114,38 @@ def test_rejects_invalid_base_url(url):
|
|||||||
def test_capabilities_persist_and_apply_to_new_tasks(isolated_settings):
|
def test_capabilities_persist_and_apply_to_new_tasks(isolated_settings):
|
||||||
result = service.save_capability_settings(
|
result = service.save_capability_settings(
|
||||||
service.CapabilitySettings(
|
service.CapabilitySettings(
|
||||||
video_enabled=False, outputter_enabled=False, step_summarizer_enabled=False
|
video_enabled=False,
|
||||||
|
outputter_enabled=False,
|
||||||
|
step_summarizer_enabled=False,
|
||||||
|
screenshot_limit_enabled=False,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert result.video_enabled is False
|
assert result.video_enabled is False
|
||||||
saved = dotenv_values(isolated_settings)
|
saved = dotenv_values(isolated_settings)
|
||||||
assert saved["ARTEMIS_VIDEO_ENABLED"] == "false"
|
assert saved["ARTEMIS_VIDEO_ENABLED"] == "false"
|
||||||
|
assert saved["ARTEMIS_SCREENSHOT_LIMIT_ENABLED"] == "false"
|
||||||
config = load_agent_config()
|
config = load_agent_config()
|
||||||
assert config.video_analyzer.enabled is False
|
assert config.video_analyzer.enabled is False
|
||||||
assert config.pro.video_analyzer.enabled is False
|
assert config.pro.video_analyzer.enabled is False
|
||||||
assert config.outputter.enabled is False
|
assert config.outputter.enabled is False
|
||||||
assert config.flash.step_summarizer.enabled is False
|
assert config.flash.step_summarizer.enabled is False
|
||||||
|
assert config.screenshot.enabled is False
|
||||||
service.save_capability_settings(service.CapabilitySettings(video_enabled=True))
|
service.save_capability_settings(service.CapabilitySettings(video_enabled=True))
|
||||||
assert load_agent_config().video_analyzer.enabled is True
|
assert load_agent_config().video_analyzer.enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_screenshot_json_can_override_environment(monkeypatch, tmp_path):
|
||||||
|
config_path = tmp_path / "artemis.jsonc"
|
||||||
|
config_path.write_text(
|
||||||
|
'{"agent": {"screenshot": {"enabled": true, "max_long_edge": 1024}}}',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("ARTEMIS_SCREENSHOT_LIMIT_ENABLED", "false")
|
||||||
|
screenshot = load_agent_config(config_path).screenshot
|
||||||
|
assert screenshot.enabled is True
|
||||||
|
assert screenshot.max_long_edge == 1024
|
||||||
|
|
||||||
|
|
||||||
def test_failed_write_keeps_running_settings(monkeypatch, isolated_settings):
|
def test_failed_write_keeps_running_settings(monkeypatch, isolated_settings):
|
||||||
previous = settings.OPENAI_MODEL
|
previous = settings.OPENAI_MODEL
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -164,3 +208,26 @@ def test_responses_payload_has_reasoning_and_no_sdk_retry(monkeypatch, effort):
|
|||||||
assert factory.call_args.kwargs["use_responses_api"] is True
|
assert factory.call_args.kwargs["use_responses_api"] is True
|
||||||
ModelFactory.create_model(ModelEndpoint(api_key="test-only", reasoning_effort="default"))
|
ModelFactory.create_model(ModelEndpoint(api_key="test-only", reasoning_effort="default"))
|
||||||
assert "reasoning_effort" not in factory.call_args.kwargs
|
assert "reasoning_effort" not in factory.call_args.kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def test_execution_protection_saves_reload_without_touching_secret(isolated_settings):
|
||||||
|
result = service.save_capability_settings(
|
||||||
|
service.CapabilitySettings(
|
||||||
|
task_timeout_seconds=3600,
|
||||||
|
observation_timeout_seconds=45,
|
||||||
|
observation_max_attempts=2,
|
||||||
|
device_command_timeout_seconds=8,
|
||||||
|
action_failure_attempts=4,
|
||||||
|
worker_cleanup_timeout_seconds=15,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
reloaded = Settings(_env_file=isolated_settings)
|
||||||
|
assert reloaded.ARTEMIS_TASK_TIMEOUT_SECONDS == result.task_timeout_seconds == 3600
|
||||||
|
assert reloaded.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS == 45
|
||||||
|
assert reloaded.ARTEMIS_OBSERVATION_MAX_ATTEMPTS == 2
|
||||||
|
assert reloaded.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS == 8
|
||||||
|
assert reloaded.ARTEMIS_ACTION_FAILURE_ATTEMPTS == 4
|
||||||
|
assert reloaded.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS == 15
|
||||||
|
assert reloaded.OPENAI_API_KEY is None
|
||||||
|
assert settings.OPENAI_API_KEY.get_secret_value() == "existing-test-secret"
|
||||||
|
assert dotenv_values(isolated_settings)["UNRELATED"] == "keep"
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
import signal
|
import signal
|
||||||
|
import socket
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -23,10 +24,55 @@ import uvicorn
|
|||||||
from apps.admin_console.core.state import state
|
from apps.admin_console.core.state import state
|
||||||
from apps.admin_console.routers.tasks import stream_events
|
from apps.admin_console.routers.tasks import stream_events
|
||||||
from apps.admin_console.server import ArtemisUvicornServer, app, on_shutdown
|
from apps.admin_console.server import ArtemisUvicornServer, app, on_shutdown
|
||||||
|
from apps.admin_console import server as server_module
|
||||||
|
|
||||||
WINDOWS_FORCE_SIGNAL = getattr(signal, "SIGBREAK", signal.SIGTERM)
|
WINDOWS_FORCE_SIGNAL = getattr(signal, "SIGBREAK", signal.SIGTERM)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("reload", [False, True])
|
||||||
|
def test_occupied_port_never_initializes_or_overwrites_server_metadata(
|
||||||
|
tmp_path, monkeypatch, reload
|
||||||
|
):
|
||||||
|
from artemis.runtime import server_lifecycle
|
||||||
|
|
||||||
|
metadata = tmp_path / "server.json"
|
||||||
|
metadata.write_text('{"pid":123,"lifecycle_token":"original"}', encoding="utf-8")
|
||||||
|
monkeypatch.setattr(server_lifecycle, "get_server_info_file", lambda: metadata)
|
||||||
|
before = metadata.read_bytes()
|
||||||
|
with (
|
||||||
|
socket.create_server(("127.0.0.1", 0)) as original,
|
||||||
|
patch.object(server_module.ArtemisUvicornServer, "run") as run,
|
||||||
|
patch.object(server_module, "write_server_info") as publish,
|
||||||
|
):
|
||||||
|
with pytest.raises(SystemExit, match="1"):
|
||||||
|
server_module.run_ui_server("127.0.0.1", original.getsockname()[1], reload=reload)
|
||||||
|
run.assert_not_called()
|
||||||
|
publish.assert_not_called()
|
||||||
|
assert metadata.read_bytes() == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_holds_port_before_running_lifespan(monkeypatch):
|
||||||
|
monkeypatch.setattr(state, "port", 0, raising=False)
|
||||||
|
monkeypatch.setattr(state, "host", "127.0.0.1", raising=False)
|
||||||
|
observed = []
|
||||||
|
|
||||||
|
def run(_server, sockets):
|
||||||
|
listener = sockets[0]
|
||||||
|
port = listener.getsockname()[1]
|
||||||
|
observed.append(listener)
|
||||||
|
assert state.port == port
|
||||||
|
with pytest.raises(OSError), socket.create_server(("127.0.0.1", port)):
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr(server_module.ArtemisUvicornServer, "run", run)
|
||||||
|
monkeypatch.setattr(server_module, "clear_server_info", lambda **kwargs: None)
|
||||||
|
with patch.object(server_module, "write_server_info") as publish:
|
||||||
|
server_module.run_ui_server("127.0.0.1", 0)
|
||||||
|
publish.assert_not_called() # Publication belongs to the later lifespan.
|
||||||
|
assert len(observed) == 1
|
||||||
|
assert observed[0].fileno() == -1
|
||||||
|
|
||||||
|
|
||||||
def _pending_queue_get_tasks():
|
def _pending_queue_get_tasks():
|
||||||
return {
|
return {
|
||||||
task
|
task
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ import pytest
|
|||||||
from apps.admin_console.routers import sessions as sessions_router
|
from apps.admin_console.routers import sessions as sessions_router
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolate_jobs(monkeypatch):
|
||||||
|
mocked = MagicMock()
|
||||||
|
mocked.jobs_for_sessions.return_value = {}
|
||||||
|
monkeypatch.setattr(sessions_router, "store", mocked)
|
||||||
|
return mocked
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_list_sessions_uses_batched_history_metadata(monkeypatch):
|
async def test_list_sessions_uses_batched_history_metadata(monkeypatch):
|
||||||
repo = MagicMock()
|
repo = MagicMock()
|
||||||
@@ -96,3 +104,42 @@ async def test_list_sessions_extracts_device_serial(monkeypatch):
|
|||||||
assert result[0]["device_id"] == "63191FDKX00062"
|
assert result[0]["device_id"] == "63191FDKX00062"
|
||||||
assert result[1]["device_serial"] == "emulator-5554"
|
assert result[1]["device_serial"] == "emulator-5554"
|
||||||
assert result[1]["device_id"] == "emulator-5554"
|
assert result[1]["device_id"] == "emulator-5554"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_sessions_uses_batch_title_and_timeout_error(monkeypatch, isolate_jobs):
|
||||||
|
repo = MagicMock()
|
||||||
|
repo.get_all_sessions.return_value = [
|
||||||
|
{
|
||||||
|
"session_id": "session-case",
|
||||||
|
"status": "completed",
|
||||||
|
"start_time": 1.0,
|
||||||
|
"initial_goal": "打开设置并检查页面",
|
||||||
|
"device_info": '{"profile":"flash"}',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
repo.get_video_recordings_map.return_value = {}
|
||||||
|
repo.get_latest_video_recordings_map.return_value = {}
|
||||||
|
repo.get_agent_trace_names_map.return_value = {}
|
||||||
|
monkeypatch.setattr(sessions_router, "session_repo", repo)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sessions_router.media_service, "build_video_index", MagicMock(return_value={})
|
||||||
|
)
|
||||||
|
isolate_jobs.jobs_for_sessions.return_value = {
|
||||||
|
"session-case": {
|
||||||
|
"session_id": "session-case",
|
||||||
|
"status": "failed",
|
||||||
|
"ended_at": 65.0,
|
||||||
|
"error": "任务执行超时(60 秒)",
|
||||||
|
"case": {"case_id": "1.1", "name": "打开并核对设置", "goal": "打开设置并检查页面"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await sessions_router.list_sessions()
|
||||||
|
|
||||||
|
isolate_jobs.jobs_for_sessions.assert_called_once_with(["session-case"])
|
||||||
|
assert result[0]["display_name"] == "1.1 打开并核对设置"
|
||||||
|
assert result[0]["initial_goal"] == "打开设置并检查页面"
|
||||||
|
assert result[0]["status"] == "failed"
|
||||||
|
assert result[0]["end_time"] == 65.0
|
||||||
|
assert "超时" in result[0]["error"]
|
||||||
@@ -323,7 +323,9 @@ async def test_queue_worker_cmd_construction():
|
|||||||
endpoint = enqueue_result["tasks"][0]["adb_endpoint"]
|
endpoint = enqueue_result["tasks"][0]["adb_endpoint"]
|
||||||
assert executed_kwargs[0]["env"]["ADB_HOST"] == endpoint["host"]
|
assert executed_kwargs[0]["env"]["ADB_HOST"] == endpoint["host"]
|
||||||
assert executed_kwargs[0]["env"]["ADB_PORT"] == str(endpoint["port"])
|
assert executed_kwargs[0]["env"]["ADB_PORT"] == str(endpoint["port"])
|
||||||
assert executed_kwargs[0]["env"]["ADB_SERVER_SOCKET"] == endpoint["socket"]
|
assert executed_kwargs[0]["env"]["ADB_SERVER_SOCKET"] == (
|
||||||
|
"tcp:localhost:5037" if endpoint["is_local_default"] else endpoint["socket"]
|
||||||
|
)
|
||||||
assert executed_kwargs[0]["env"]["ARTEMIS_ADB_ENDPOINT_ID"] == endpoint["identity"]
|
assert executed_kwargs[0]["env"]["ARTEMIS_ADB_ENDPOINT_ID"] == endpoint["identity"]
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
assert executed_kwargs[0]["creationflags"] == (
|
assert executed_kwargs[0]["creationflags"] == (
|
||||||
|
|||||||
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user