Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59f458205d | ||
|
|
4465a2e199 | ||
|
|
c818a09a49 | ||
|
|
e1e26772d9 | ||
|
|
1dc2b0c686 | ||
|
|
84e183d28d |
No files matched your search
@@ -6,6 +6,8 @@ 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
|
||||||
@@ -19,6 +21,8 @@ ARTEMIS_OUTPUTTER_ENABLED=true
|
|||||||
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
|
ARTEMIS_STEP_SUMMARIZER_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
|
||||||
|
|
||||||
# 视频分段、重试与单次分析超时(秒)
|
# 视频分段、重试与单次分析超时(秒)
|
||||||
ARTEMIS_VIDEO_CHUNK_SECONDS=60
|
ARTEMIS_VIDEO_CHUNK_SECONDS=60
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ 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 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
|
||||||
@@ -166,7 +166,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 +185,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 +203,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 +230,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
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
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 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
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"}
|
TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"}
|
||||||
@@ -82,9 +84,9 @@ 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.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 +101,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,6 +111,7 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
@@ -125,6 +130,11 @@ 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:
|
||||||
|
# The queue records started_at at submission, then replaces it
|
||||||
|
# when the worker actually starts. Waiting for a device must
|
||||||
|
# not consume this case's execution budget.
|
||||||
|
if job["status"] != "running":
|
||||||
|
continue
|
||||||
limit = int(
|
limit = int(
|
||||||
(job.get("case", {}).get("task_config") or {}).get("timeout_seconds")
|
(job.get("case", {}).get("task_config") or {}).get("timeout_seconds")
|
||||||
or batch.get("timeout_seconds", 1800)
|
or batch.get("timeout_seconds", 1800)
|
||||||
@@ -134,15 +144,16 @@ async def tick_batches():
|
|||||||
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"])
|
task_queue_service.stop_tasks(session_id=job["session_id"])
|
||||||
batch.update(status="paused", reason="任务超时,已暂停剩余任务;请确认设备状态")
|
# on_terminal records the failed case. The next pending
|
||||||
store.put("batch", batch["id"], batch)
|
# case then runs normally unless stop_on_failure is set.
|
||||||
continue
|
continue
|
||||||
if batch["status"] != "running":
|
if batch["status"] != "running":
|
||||||
continue
|
continue
|
||||||
@@ -202,6 +213,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 "队列拒绝接收任务")
|
||||||
@@ -265,6 +277,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:
|
||||||
|
|||||||
@@ -54,6 +54,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 +85,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 +121,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 +198,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 +239,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,),
|
||||||
|
|||||||
@@ -64,7 +64,12 @@ def get_model_settings() -> dict:
|
|||||||
"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,
|
||||||
"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()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -83,19 +92,49 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
|
|||||||
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"])
|
||||||
|
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 +181,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 +632,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,15 +679,18 @@ 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
|
||||||
|
|
||||||
@@ -691,6 +735,8 @@ def export_batch(batch_id: str) -> Path:
|
|||||||
archive.write(path, path.name)
|
archive.write(path, path.name)
|
||||||
for number, report in enumerate(records, 1):
|
for number, report in enumerate(records, 1):
|
||||||
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"任务报告/{number:04d}_{archive_case_name(report)}/{path.name}"
|
||||||
|
)
|
||||||
temporary.replace(target)
|
temporary.replace(target)
|
||||||
return target
|
return target
|
||||||
@@ -177,3 +177,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)
|
||||||
@@ -1133,6 +1133,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.
|
||||||
|
|
||||||
@@ -1189,6 +1190,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.2",
|
"version": "1.2.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.1.2",
|
"version": "1.2.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.2",
|
"version": "1.2.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));
|
||||||
|
|||||||
+4
-4
@@ -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,10 @@
|
|||||||
<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>
|
<a [href]="'/api/automation/batches/'+b.id+'/archive'">下载批次报告 ZIP</a>
|
||||||
</div><p class="muted">服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。</p>
|
</div><p class="muted">服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。</p>
|
||||||
@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>}
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ 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 || ''); }
|
||||||
async action(fn: () => Promise<void>) {
|
async action(fn: () => Promise<void>) {
|
||||||
if (this.busy()) return;
|
if (this.busy()) return;
|
||||||
@@ -73,12 +73,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(); }); }
|
||||||
|
|||||||
+13
-2
@@ -7,7 +7,8 @@ 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',
|
||||||
|
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);
|
||||||
@@ -44,13 +45,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';
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface ModelSettings {
|
|||||||
max_retries: number;
|
max_retries: number;
|
||||||
retry_interval_seconds: number;
|
retry_interval_seconds: number;
|
||||||
reasoning_effort: string;
|
reasoning_effort: string;
|
||||||
|
model_presets: string[];
|
||||||
env_path: string;
|
env_path: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
@@ -90,7 +91,7 @@ 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';
|
||||||
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('');
|
||||||
@@ -112,6 +113,7 @@ export class ModelSettingsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
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);
|
||||||
}
|
}
|
||||||
save(): void {
|
save(): void {
|
||||||
|
|||||||
@@ -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: '处理中',
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +78,7 @@ 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 observe
|
||||||
@@ -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:
|
||||||
@@ -838,6 +842,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
|
||||||
@@ -937,6 +943,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())
|
||||||
@@ -1060,6 +1069,8 @@ 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
|
||||||
|
|
||||||
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)
|
||||||
@@ -1118,7 +1129,36 @@ 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(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(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.
|
||||||
|
(
|
||||||
|
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 +1185,18 @@ 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):
|
||||||
|
(
|
||||||
|
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 +1206,24 @@ 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
|
||||||
|
if no_tool_attempts >= no_tool_limit:
|
||||||
|
final_report = {
|
||||||
|
"status": "failed",
|
||||||
|
"explanation": (
|
||||||
|
f"模型连续 {no_tool_attempts} 次未调用工具,"
|
||||||
|
"已停止任务以避免空转和重复计费;未执行新的设备动作。"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
break
|
||||||
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
|
||||||
# Process tool calls
|
# Process tool calls
|
||||||
turn = _TurnRecord()
|
turn = _TurnRecord()
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -539,6 +539,9 @@ class OperatorNode:
|
|||||||
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
|
||||||
@@ -690,10 +693,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 +713,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 +830,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)."
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ class Settings(BaseSettings):
|
|||||||
ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True
|
ARTEMIS_STEP_SUMMARIZER_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)
|
||||||
|
|
||||||
# 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}")
|
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
else:
|
||||||
continue
|
continue
|
||||||
if time.monotonic() - started_at >= self._MUTEX_TIMEOUT_SECONDS:
|
|
||||||
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,8 +95,9 @@ 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.
|
||||||
|
if os.environ.get("ARTEMIS_TASK_WORKER") != "1":
|
||||||
ScreenAwakeLease(device_id).cleanup_unowned_references()
|
ScreenAwakeLease(device_id).cleanup_unowned_references()
|
||||||
|
|
||||||
commands = (
|
commands = (
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,8 +40,10 @@ MCP 同步会识别已安装客户端的配置目录,为缺少 ARTEMIS 条目
|
|||||||
|
|
||||||
创建批次时必须提供唯一 `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` 并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,每次等待 10 秒,SDK 不叠加重试。耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停先返回 `pausing`,确认状态为 `task_paused` 才表示设备已释放;Pro 等待模型回复或只读诊断时可及时中断,恢复后重新观察设备。AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复;运行中的模型思考会中断并重新观察设备,已经开始的设备动作会安全结束。用 `guidance_history` 核对指导是否被领取、在任务流程中核对实际执行情况,或用 `resume` 单独恢复。
|
||||||
|
|
||||||
|
批次任务在队列中使用“用例编号 + 用例名称”显示,提示词仍是模型实际收到的内容。任务历史展示失败原因、完成时间和总耗时;导出全部资料时以用例编号命名目录。删除非活跃批次会同步清理其任务记录、步骤、截图、录屏及生成的报告,删除后不能通过旧 `request_id` 重复执行。
|
||||||
|
|
||||||
保存全局配置要求用户明确指令;只读配置不包含密钥,MCP 不接收密钥。密钥仍在网页或 `.env` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。
|
保存全局配置要求用户明确指令;只读配置不包含密钥,MCP 不接收密钥。密钥仍在网页或 `.env` 中填写。设置对新任务生效,模型请求重试不会触发测试用例自动重跑。历史步骤证据保留,设备动作回放已移除。
|
||||||
@@ -20,12 +20,14 @@
|
|||||||
4. 上传文件,查看逐行校验结果。所有错误修正后才能提交。
|
4. 上传文件,查看逐行校验结果。所有错误修正后才能提交。
|
||||||
5. 明确选择设备、执行模式和超时时间,然后点击“确认导入并开始执行”。
|
5. 明确选择设备、执行模式和超时时间,然后点击“确认导入并开始执行”。
|
||||||
|
|
||||||
同一批次按顺序派发。默认失败后继续且不自动重试,也可以选择失败即停止。设备不可用、模型暂停或任务超时会暂停后续派发。暂停批次只停止后续派发。单个任务可在步骤间安全暂停,释放设备;批次下一项可继续,暂停的任务待提交指导或点击继续后在设备队列末尾恢复。运行中提交指导在下一步执行前生效,不打断当前步骤。暂停等待和重新排队时间不计入该任务执行时限。
|
同一批次按顺序派发。默认失败后继续且不自动重试,也可以选择失败即停止。设备不可用、模型暂停或任务超时会暂停后续派发。暂停批次只停止后续派发。单个任务可在安全边界暂停并释放设备;批次下一项可继续,暂停的任务待提交指导或点击继续后在设备队列末尾恢复。运行中提交指导会中断当前模型思考或只读诊断,重新观察画面;已经开始的设备动作会安全结束。网页流程会显示指导的提交与领取状态,仍应检查后续动作是否符合指导。暂停等待和重新排队时间不计入该任务执行时限。
|
||||||
|
|
||||||
服务重启后保留批次,等待用户继续未开始的用例。正在执行或暂停的任务标记中断,不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。
|
服务重启后保留批次,等待用户继续未开始的用例。正在执行或暂停的任务标记中断,不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。
|
||||||
|
|
||||||
每个任务结束后生成所选格式报告。批次结束后生成汇总及每项任务报告的 ZIP;失败、跳过和中断任务也会列入。暂停时下载的是当前快照。录屏与 AI 视频分析分别配置,录屏默认关闭;仅开启分析时,录制内容用于本次分析,结束后不保留下载文件。暂停期间不录制其他任务的设备画面。
|
每个任务结束后生成所选格式报告。批次结束后生成汇总及每项任务报告的 ZIP;失败、跳过和中断任务也会列入。暂停时下载的是当前快照。录屏与 AI 视频分析分别配置,录屏默认关闭;仅开启分析时,录制内容用于本次分析,结束后不保留下载文件。暂停期间不录制其他任务的设备画面。
|
||||||
|
|
||||||
|
队列中批量任务按“用例编号 + 测试用例”显示,悬停可看完整提示词;失败原因、完成时间及总耗时在历史中可查看。单任务“下载全部资料”以用例编号命名压缩包内目录。删除已结束的批次会同步删除关联任务记录、步骤、截图、录屏、报告和 MCP 导出副本;正在执行或排队的批次不能删除。
|
||||||
|
|
||||||
## AI 使用
|
## AI 使用
|
||||||
|
|
||||||
通过 MCP 可直接提交结构化批次、检索历史和导出报告;见 [AI 自动化与 Skill](ai-automation.md)。操作回放已移除,旧记录仅供历史查看,不能继续或重新执行。
|
通过 MCP 可直接提交结构化批次、检索历史和导出报告;见 [AI 自动化与 Skill](ai-automation.md)。操作回放已移除,旧记录仅供历史查看,不能继续或重新执行。
|
||||||
@@ -34,4 +36,4 @@
|
|||||||
|
|
||||||
`.env` 可配置 `ARTEMIS_BATCH_TASK_TIMEOUT`(默认 1800 秒)、`ARTEMIS_IMPORT_MAX_ROWS`(默认 1000 行)和 `ARTEMIS_IMPORT_MAX_BYTES`(默认 10 MiB)。修改后重启服务。网页的单批次超时设置优先于环境默认值。
|
`.env` 可配置 `ARTEMIS_BATCH_TASK_TIMEOUT`(默认 1800 秒)、`ARTEMIS_IMPORT_MAX_ROWS`(默认 1000 行)和 `ARTEMIS_IMPORT_MAX_BYTES`(默认 10 MiB)。修改后重启服务。网页的单批次超时设置优先于环境默认值。
|
||||||
|
|
||||||
批次、任务元数据和报告偏好保存在现有 SQLite 数据库的 `automation_records`、`automation_jobs` 表。清空任务历史同时清除批次与执行元数据;已导出的文件保留。
|
批次、任务元数据和报告偏好保存在现有 SQLite 数据库的 `automation_records`、`automation_jobs` 表。全局“清空任务历史”会清除批次与执行元数据;它与上文的单批次删除不同,已导出的文件会保留。
|
||||||
@@ -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 项测试及构建均通过。
|
||||||
@@ -67,10 +67,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 +84,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)
|
||||||
@@ -198,8 +205,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 +277,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 +298,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 {
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "artemis"
|
name = "artemis"
|
||||||
version = "1.1.2"
|
version = "1.2.3"
|
||||||
description = "Autonomous AI Assistant for Mobile Automation"
|
description = "Autonomous AI Assistant for Mobile Automation"
|
||||||
|
|
||||||
authors = [
|
authors = [
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ 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 队尾并重新观察手机状态。尊重工具建议的等待周期,避免连续高频轮询;停滞或失败先诊断,不把同一业务任务自动重提。
|
||||||
|
|
||||||
## 结果、证据与交付
|
## 结果、证据与交付
|
||||||
|
|
||||||
@@ -32,6 +32,6 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
|
|||||||
- API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。
|
- API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。
|
||||||
- 录屏与 AI 视频分析分别配置。批量任务的 `task_config` 可覆盖单项,不修改全局设置;只开视频分析时录制内容不作为可下载文件保存。暂停时停止录屏,恢复后按片段继续,不能把其他任务画面混入该任务资料。
|
- 录屏与 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 思考耗时当作确定性等待。
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
- 单项 `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`;正在执行、排队或等待指导的批次不能删除。删除会同时移除该批次任务历史、报告和文件,无法撤销。
|
||||||
|
|
||||||
## 报告
|
## 报告
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import json
|
import json
|
||||||
|
import sqlite3
|
||||||
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
|
||||||
@@ -125,19 +127,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 +185,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")
|
||||||
@@ -260,6 +330,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 +483,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 +513,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 +532,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):
|
||||||
|
|||||||
@@ -70,6 +70,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",
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -60,20 +60,21 @@ REFRESHED = [_el(1, "Back", 100, 100), _el(2, "Wi-Fi toggle", 900, 300)]
|
|||||||
|
|
||||||
|
|
||||||
class _FakeSession:
|
class _FakeSession:
|
||||||
"""An action session whose observations replay a scripted list sequence."""
|
"""An action session whose screen advances after each device action."""
|
||||||
|
|
||||||
started = True
|
started = True
|
||||||
|
|
||||||
def __init__(self, observations: list[list[dict]]):
|
def __init__(self, observations: list[list[dict]]):
|
||||||
self.calls: list[tuple[str, dict]] = []
|
self.calls: list[tuple[str, dict]] = []
|
||||||
self._observations = list(observations)
|
self._observations = observations
|
||||||
|
|
||||||
async def call(self, name, args):
|
async def call(self, name, args):
|
||||||
self.calls.append((name, dict(args)))
|
self.calls.append((name, dict(args)))
|
||||||
return ActionResult.success(name, f"{name} dispatched")
|
return ActionResult.success(name, f"{name} dispatched")
|
||||||
|
|
||||||
async def observe(self, settle_ms=0):
|
async def observe(self, settle_ms=0):
|
||||||
elements = self._observations.pop(0) if self._observations else []
|
index = min(len(self.calls) - 1, len(self._observations) - 1)
|
||||||
|
elements = self._observations[index] if self._observations else []
|
||||||
return ObserveResult(
|
return ObserveResult(
|
||||||
ok=True,
|
ok=True,
|
||||||
elements_text="\n".join(f"[{e['index']}] {e['text']}" for e in elements),
|
elements_text="\n".join(f"[{e['index']}] {e['text']}" for e in elements),
|
||||||
|
|||||||
@@ -636,6 +636,32 @@ async def test_run_no_tool_call_turn_is_nudged_not_terminated(mock_context):
|
|||||||
and any(EXECUTION_RESULT_MARKER in b.get("text", "") for b in m.content)
|
and any(EXECUTION_RESULT_MARKER in b.get("text", "") for b in m.content)
|
||||||
for m in turn2_messages
|
for m in turn2_messages
|
||||||
)
|
)
|
||||||
|
assert not any(
|
||||||
|
isinstance(m, AIMessage) and m.content == "thinking only" for m in turn2_messages
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("attempts", [1, 3])
|
||||||
|
async def test_flash_stops_repeated_no_tool_responses(mock_context, monkeypatch, attempts):
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS", attempts)
|
||||||
|
responses = [AIMessage(content="I will click now.", tool_calls=[]) for _ in range(attempts)]
|
||||||
|
with (
|
||||||
|
patch("artemis.controllers.unified_controller.get_driver"),
|
||||||
|
patch(
|
||||||
|
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
|
||||||
|
AsyncMock(return_value=_INITIAL_OBSERVATION),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
runner = _make_runner(mock_context, responses, max_turns=0)
|
||||||
|
result = await runner.run(State(initial_goal="Open Settings"))
|
||||||
|
|
||||||
|
assert result["status"] == "failed"
|
||||||
|
assert f"连续 {attempts} 次未调用工具" in result["explanation"]
|
||||||
|
assert runner._invoke_model.await_count == attempts
|
||||||
|
runner.executor.execute.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
"""Unit tests for VisualStepSummarizer and Context Compressor in Flash profile."""
|
"""Unit tests for VisualStepSummarizer and Context Compressor in Flash profile."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from unittest.mock import ANY, AsyncMock, Mock, call
|
from unittest.mock import ANY, AsyncMock, Mock, call
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
import pytest
|
import pytest
|
||||||
@@ -38,10 +39,20 @@ def mock_context():
|
|||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def _user_role_prompt(messages):
|
||||||
|
"""Return the instruction text and observation blocks sent to the model."""
|
||||||
|
assert len(messages) == 1
|
||||||
|
assert isinstance(messages[0], HumanMessage)
|
||||||
|
blocks = messages[0].content
|
||||||
|
return blocks[0]["text"], blocks[1:]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_summarizer_dispatch_and_caching(mock_context):
|
async def test_summarizer_dispatch_and_caching(mock_context):
|
||||||
"""Verify non-blocking dispatch and summary generation."""
|
"""Verify non-blocking dispatch and summary generation."""
|
||||||
summarizer = VisualStepSummarizer(mock_context)
|
summarizer = VisualStepSummarizer(mock_context)
|
||||||
|
assert summarizer._timeout_seconds == 120.0
|
||||||
|
assert summarizer._flush_timeout_s > summarizer._timeout_seconds
|
||||||
|
|
||||||
mock_llm_response = AIMessage(
|
mock_llm_response = AIMessage(
|
||||||
content="[Step 1 State: Tapped 'Login' button. Home view opened.]"
|
content="[Step 1 State: Tapped 'Login' button. Home view opened.]"
|
||||||
@@ -106,6 +117,29 @@ async def test_summarizer_retries_independently(mock_context):
|
|||||||
assert attempt_count == 2
|
assert attempt_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visual_summary_uses_its_own_configured_timeout(mock_context):
|
||||||
|
summarizer = VisualStepSummarizer(mock_context, retry_limit=0, timeout_seconds=0.01)
|
||||||
|
|
||||||
|
async def slow_response(_messages):
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
summarizer._llm = Mock()
|
||||||
|
summarizer._llm.ainvoke = AsyncMock(side_effect=slow_response)
|
||||||
|
summarizer.dispatch(
|
||||||
|
step_number=1,
|
||||||
|
action_name="click",
|
||||||
|
action_args={"target": [100, 100]},
|
||||||
|
pre_img_bytes=b"pre",
|
||||||
|
post_img_bytes=b"post",
|
||||||
|
exec_outcome="Clicked",
|
||||||
|
)
|
||||||
|
|
||||||
|
await summarizer.flush()
|
||||||
|
assert summarizer.has_failed(1)
|
||||||
|
assert summarizer._llm.ainvoke.await_count == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_summarizer_bounded_retry_marks_failed(mock_context):
|
async def test_summarizer_bounded_retry_marks_failed(mock_context):
|
||||||
"""Persistent failures exit after 1 + retry_limit attempts with an explicit failed state."""
|
"""Persistent failures exit after 1 + retry_limit attempts with an explicit failed state."""
|
||||||
@@ -401,8 +435,8 @@ async def test_single_frame_dispatch_uses_single_image_prompt_variant(mock_conte
|
|||||||
await summarizer.flush()
|
await summarizer.flush()
|
||||||
|
|
||||||
assert summarizer.has_summary(1)
|
assert summarizer.has_summary(1)
|
||||||
system_text = str(captured[0][0].content)
|
system_text, human_blocks = _user_role_prompt(captured[0])
|
||||||
human_text = str(captured[0][1].content)
|
human_text = str(human_blocks)
|
||||||
assert "Exactly ONE screenshot" in system_text
|
assert "Exactly ONE screenshot" in system_text
|
||||||
assert "no independent post-action evidence" in system_text
|
assert "no independent post-action evidence" in system_text
|
||||||
assert "DECISION FRAME" in human_text
|
assert "DECISION FRAME" in human_text
|
||||||
@@ -436,15 +470,21 @@ async def test_dual_frame_dispatch_keeps_transition_prompt(mock_context):
|
|||||||
pre_img_bytes=b"pre",
|
pre_img_bytes=b"pre",
|
||||||
post_img_bytes=b"post",
|
post_img_bytes=b"post",
|
||||||
exec_outcome="executed",
|
exec_outcome="executed",
|
||||||
|
post_ui_elements_text="[1] Text: '存储空间' | Bounds: [0,0][100,100]",
|
||||||
|
observation_settled=False,
|
||||||
)
|
)
|
||||||
await summarizer.flush()
|
await summarizer.flush()
|
||||||
|
|
||||||
assert summarizer.has_summary(1)
|
assert summarizer.has_summary(1)
|
||||||
system_text = str(captured[0][0].content)
|
system_text, human_blocks = _user_role_prompt(captured[0])
|
||||||
human_text = str(captured[0][1].content)
|
human_text = str(human_blocks)
|
||||||
assert "Before/After screenshots" in system_text
|
assert "Before/After screenshots" in system_text
|
||||||
assert "--- [1] BEFORE ACTION SCREEN (Action Marked Visually in Red) ---" in human_text
|
assert "--- [1] BEFORE ACTION SCREEN (Action Marked Visually in Red) ---" in human_text
|
||||||
assert "--- [2] AFTER ACTION SCREEN ---" in human_text
|
assert "--- [2] AFTER ACTION SCREEN ---" in human_text
|
||||||
|
assert "--- [3] AFTER ACTION UI ELEMENTS ---" in human_text
|
||||||
|
assert "Text: '存储空间'" in human_text
|
||||||
|
assert "screen still changing" in human_text
|
||||||
|
assert "hierarchy names the new page" in system_text
|
||||||
assert "DECISION FRAME" not in human_text
|
assert "DECISION FRAME" not in human_text
|
||||||
|
|
||||||
|
|
||||||
@@ -604,8 +644,8 @@ async def test_focus_block_and_provenance_marked_action_phrase_reach_the_lens(mo
|
|||||||
await summarizer.flush()
|
await summarizer.flush()
|
||||||
|
|
||||||
assert summarizer.has_summary(3)
|
assert summarizer.has_summary(3)
|
||||||
system_text = str(captured[0][0].content)
|
system_text, human_blocks = _user_role_prompt(captured[0])
|
||||||
lead_text = captured[0][1].content[0]["text"]
|
lead_text = human_blocks[0]["text"]
|
||||||
assert "# OPERATOR FOCUS" in system_text
|
assert "# OPERATOR FOCUS" in system_text
|
||||||
assert "Absence is a fact" in system_text
|
assert "Absence is a fact" in system_text
|
||||||
assert lead_text.startswith(
|
assert lead_text.startswith(
|
||||||
@@ -619,7 +659,7 @@ async def test_focus_block_and_provenance_marked_action_phrase_reach_the_lens(mo
|
|||||||
assert "User instruction at this step: Do not join any network yet." in lead_text
|
assert "User instruction at this step: Do not join any network yet." in lead_text
|
||||||
assert "expect it to read On." in lead_text
|
assert "expect it to read On." in lead_text
|
||||||
# The focus precedes the frames: context first, evidence second.
|
# The focus precedes the frames: context first, evidence second.
|
||||||
assert captured[0][1].content[1]["text"].startswith("--- [1] BEFORE ACTION SCREEN")
|
assert human_blocks[1]["text"].startswith("--- [1] BEFORE ACTION SCREEN")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -648,7 +688,8 @@ async def test_dispatch_without_focus_carries_no_focus_block(mock_context):
|
|||||||
)
|
)
|
||||||
await summarizer.flush()
|
await summarizer.flush()
|
||||||
|
|
||||||
lead_text = captured[0][1].content[0]["text"]
|
_, human_blocks = _user_role_prompt(captured[0])
|
||||||
|
lead_text = human_blocks[0]["text"]
|
||||||
assert lead_text.startswith("Step 1 Physical Action: Tapped 'Search' at [500, 600]\n")
|
assert lead_text.startswith("Step 1 Physical Action: Tapped 'Search' at [500, 600]\n")
|
||||||
assert "(self-described)" not in lead_text
|
assert "(self-described)" not in lead_text
|
||||||
assert FOCUS_BLOCK_HEADER not in lead_text
|
assert FOCUS_BLOCK_HEADER not in lead_text
|
||||||
@@ -682,7 +723,8 @@ async def test_single_frame_focus_label_forbids_describing_the_outcome(mock_cont
|
|||||||
)
|
)
|
||||||
await summarizer.flush()
|
await summarizer.flush()
|
||||||
|
|
||||||
lead_text = captured[0][1].content[0]["text"]
|
_, human_blocks = _user_role_prompt(captured[0])
|
||||||
|
lead_text = human_blocks[0]["text"]
|
||||||
assert "the expected outcome is NOT in this frame and must not be described" in lead_text
|
assert "the expected outcome is NOT in this frame and must not be described" in lead_text
|
||||||
assert "what screen change it expected" not in lead_text
|
assert "what screen change it expected" not in lead_text
|
||||||
# The focus text is released once the summary landed, like the frames.
|
# The focus text is released once the summary landed, like the frames.
|
||||||
@@ -774,7 +816,8 @@ async def test_verbatim_argument_line_appears_only_when_the_phrase_folds_argumen
|
|||||||
|
|
||||||
lead_by_step = {}
|
lead_by_step = {}
|
||||||
for messages in captured:
|
for messages in captured:
|
||||||
text = messages[1].content[0]["text"]
|
_, human_blocks = _user_role_prompt(messages)
|
||||||
|
text = human_blocks[0]["text"]
|
||||||
lead_by_step[text.split(" ")[1]] = text
|
lead_by_step[text.split(" ")[1]] = text
|
||||||
launch = lead_by_step["1"]
|
launch = lead_by_step["1"]
|
||||||
assert launch.startswith("Step 1 Physical Action: Launched app 'Settings'\nController Outcome:")
|
assert launch.startswith("Step 1 Physical Action: Launched app 'Settings'\nController Outcome:")
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Page transitions are recaptured until screenshot and hierarchy agree."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from artemis.mcp.action_executor import McpActionExecutor
|
||||||
|
from artemis.mcp.action_types import ObserveResult
|
||||||
|
|
||||||
|
|
||||||
|
def _screen(tmp_path, name: str, *, reverse: bool = False) -> str:
|
||||||
|
image = Image.new("L", (64, 64))
|
||||||
|
image.putdata([255 - x * 4 if reverse else x * 4 for _y in range(64) for x in range(64)])
|
||||||
|
path = tmp_path / name
|
||||||
|
image.save(path, format="JPEG")
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _observation(path: str, page: str) -> ObserveResult:
|
||||||
|
return ObserveResult(
|
||||||
|
ok=True,
|
||||||
|
screenshot_path=path,
|
||||||
|
elements_text=f"[1] {page}",
|
||||||
|
elements=[{"text": page, "resource_id": "page/title", "bounds": [0, 0, 64, 64]}],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_navigation_recaptures_when_image_lags_behind_hierarchy(tmp_path):
|
||||||
|
home = _screen(tmp_path, "home.jpg")
|
||||||
|
detail = _screen(tmp_path, "detail.jpg", reverse=True)
|
||||||
|
session = Mock()
|
||||||
|
session.observe = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
_observation(home, "存储空间"),
|
||||||
|
_observation(detail, "存储空间"),
|
||||||
|
_observation(detail, "存储空间"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
executor = McpActionExecutor(Mock(), actuator=Mock())
|
||||||
|
|
||||||
|
observed, settled = await executor._observe_after_action(
|
||||||
|
session, "click", home, _observation(home, "设置首页").elements
|
||||||
|
)
|
||||||
|
|
||||||
|
assert observed.screenshot_path == detail
|
||||||
|
assert observed.elements_text == "[1] 存储空间"
|
||||||
|
assert settled is True
|
||||||
|
assert session.observe.await_count == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_navigation_waits_until_new_page_has_two_stable_frames(tmp_path):
|
||||||
|
home = _screen(tmp_path, "home.jpg")
|
||||||
|
detail = _screen(tmp_path, "detail.jpg", reverse=True)
|
||||||
|
session = Mock()
|
||||||
|
session.observe = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
_observation(home, "设置首页"),
|
||||||
|
_observation(home, "设置首页"),
|
||||||
|
_observation(detail, "存储空间"),
|
||||||
|
_observation(detail, "存储空间"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
executor = McpActionExecutor(Mock(), actuator=Mock())
|
||||||
|
|
||||||
|
observed, settled = await executor._observe_after_action(
|
||||||
|
session, "click", home, _observation(home, "设置首页").elements
|
||||||
|
)
|
||||||
|
|
||||||
|
assert observed.screenshot_path == detail
|
||||||
|
assert settled is True
|
||||||
|
assert session.observe.await_count == 4
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_old_bitmap_with_new_hierarchy_is_never_marked_settled(tmp_path):
|
||||||
|
home = _screen(tmp_path, "home.jpg")
|
||||||
|
session = Mock()
|
||||||
|
session.observe = AsyncMock(return_value=_observation(home, "存储空间"))
|
||||||
|
executor = McpActionExecutor(Mock(), actuator=Mock())
|
||||||
|
|
||||||
|
observed, settled = await executor._observe_after_action(
|
||||||
|
session, "click", home, _observation(home, "设置首页").elements
|
||||||
|
)
|
||||||
|
|
||||||
|
assert observed.elements_text == "[1] 存储空间"
|
||||||
|
assert settled is False
|
||||||
|
assert session.observe.await_count == 4
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_only_action_keeps_single_observation(tmp_path):
|
||||||
|
home = _screen(tmp_path, "home.jpg")
|
||||||
|
session = Mock()
|
||||||
|
session.observe = AsyncMock(return_value=_observation(home, "设置首页"))
|
||||||
|
executor = McpActionExecutor(Mock(), actuator=Mock())
|
||||||
|
|
||||||
|
observed, settled = await executor._observe_after_action(session, "wait_for_delay", home)
|
||||||
|
|
||||||
|
assert observed.screenshot_path == home
|
||||||
|
assert settled is True
|
||||||
|
session.observe.assert_awaited_once_with(settle_ms=400)
|
||||||
@@ -80,13 +80,19 @@ async def test_download_writes_actual_binary_atomically(monkeypatch, tmp_path):
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
tools.httpx, "AsyncClient", lambda **kw: client_type(transport=transport, **kw)
|
tools.httpx, "AsyncClient", lambda **kw: client_type(transport=transport, **kw)
|
||||||
)
|
)
|
||||||
result = await tools._download("/api/automation/reports/test/docx", "test.docx")
|
result = await tools._download(
|
||||||
|
"/api/automation/reports/test/docx",
|
||||||
|
"test.docx",
|
||||||
|
owner_kind="sessions",
|
||||||
|
owner_id="test",
|
||||||
|
)
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
assert Path(result["path"]).read_bytes() == b"PK\x03\x04report-bytes"
|
assert Path(result["path"]).read_bytes() == b"PK\x03\x04report-bytes"
|
||||||
assert Path(result["path"]).is_absolute()
|
assert Path(result["path"]).is_absolute()
|
||||||
|
assert Path(result["path"]).parent == tmp_path / "sessions" / "test"
|
||||||
assert result["download_url"] == "http://test/api/automation/reports/test/docx"
|
assert result["download_url"] == "http://test/api/automation/reports/test/docx"
|
||||||
assert not list(tmp_path.glob("*.part"))
|
assert not list(tmp_path.rglob("*.part"))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -373,7 +373,7 @@ def test_mobile_manage_task_inject_instruction(temp_trace_env):
|
|||||||
ok_res = mobile_manage_task(
|
ok_res = mobile_manage_task(
|
||||||
action="inject_instruction", trace_id=trace_id, instruction="Scroll down slowly"
|
action="inject_instruction", trace_id=trace_id, instruction="Scroll down slowly"
|
||||||
)
|
)
|
||||||
assert "Successfully injected" in ok_res["message"]
|
assert "current model step interrupted" in ok_res["message"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -126,3 +128,30 @@ def test_migration_cleanup_never_drains_a_live_legacy_owner(monkeypatch):
|
|||||||
cleanup._drain_references = lambda: pytest.fail("must not drain a live owner")
|
cleanup._drain_references = lambda: pytest.fail("must not drain a live owner")
|
||||||
|
|
||||||
assert cleanup.cleanup_unowned_references() is False
|
assert cleanup.cleanup_unowned_references() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_optional_cleanup_skips_a_busy_mutex_without_delaying_task_start(monkeypatch):
|
||||||
|
owner = ScreenAwakeLease("device-123")
|
||||||
|
owner.mutex_path.write_text(json.dumps(owner._payload()), encoding="utf-8")
|
||||||
|
cleanup = ScreenAwakeLease("device-123")
|
||||||
|
monkeypatch.setattr(cleanup, "_drain_references", lambda: pytest.fail("must not drain"))
|
||||||
|
|
||||||
|
started_at = time.monotonic()
|
||||||
|
assert cleanup.cleanup_unowned_references() is False
|
||||||
|
assert time.monotonic() - started_at < 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_unremovable_stale_mutex_still_obeys_deadline(monkeypatch):
|
||||||
|
lease = ScreenAwakeLease("device-123")
|
||||||
|
lease.mutex_path.write_text(json.dumps({"pid": 999999}), encoding="utf-8")
|
||||||
|
original_unlink = Path.unlink
|
||||||
|
|
||||||
|
def refuse_mutex_unlink(path, *args, **kwargs):
|
||||||
|
if path == lease.mutex_path:
|
||||||
|
raise PermissionError("file is in use")
|
||||||
|
return original_unlink(path, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "unlink", refuse_mutex_unlink)
|
||||||
|
with pytest.raises(TimeoutError, match="awake-lease mutex"):
|
||||||
|
with lease._mutex(wait_timeout_seconds=0.1):
|
||||||
|
pytest.fail("stale mutex must not be acquired")
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from artemis.runtime.awake_service import (
|
from artemis.runtime.awake_service import (
|
||||||
@@ -37,6 +38,22 @@ def test_usb_policy_is_primary_when_android_reports_it_active(mock_run, lease_ty
|
|||||||
assert not any("KEYCODE_UNKNOWN" in command for command in commands)
|
assert not any("KEYCODE_UNKNOWN" in command for command in commands)
|
||||||
|
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {"ARTEMIS_TASK_WORKER": "1"})
|
||||||
|
@patch("artemis.runtime.awake_service.ScreenAwakeLease")
|
||||||
|
@patch("artemis.runtime.awake_service.subprocess.run")
|
||||||
|
def test_queued_worker_does_not_run_optional_legacy_cleanup(mock_run, lease_type):
|
||||||
|
mock_run.side_effect = [
|
||||||
|
completed(),
|
||||||
|
completed(),
|
||||||
|
completed(),
|
||||||
|
completed("2\n"),
|
||||||
|
completed(" mStayOn=true\n"),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert _configure_usb_stay_awake("device-123") == AWAKE_STRATEGY_USB
|
||||||
|
lease_type.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@patch("artemis.runtime.awake_service.ScreenAwakeLease")
|
@patch("artemis.runtime.awake_service.ScreenAwakeLease")
|
||||||
@patch("artemis.runtime.awake_service.subprocess.run")
|
@patch("artemis.runtime.awake_service.subprocess.run")
|
||||||
def test_inactive_usb_policy_falls_back_to_effective_host_heartbeat(mock_run, _lease_type):
|
def test_inactive_usb_policy_falls_back_to_effective_host_heartbeat(mock_run, _lease_type):
|
||||||
|
|||||||
@@ -116,6 +116,35 @@ async def test_interruptible_call_returns_normal_result_without_pause(tmp_path,
|
|||||||
assert await control.run_interruptibly(model_call()) == (False, "current result")
|
assert await control.run_interruptibly(model_call()) == (False, "current result")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_new_guidance_cancels_stale_model_without_releasing_device(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
||||||
|
released = []
|
||||||
|
|
||||||
|
class Lock:
|
||||||
|
def release(self):
|
||||||
|
released.append(True)
|
||||||
|
|
||||||
|
control = task_control.TaskControl("task-123", Lock())
|
||||||
|
started = asyncio.Event()
|
||||||
|
cancelled = asyncio.Event()
|
||||||
|
|
||||||
|
async def stale_model():
|
||||||
|
started.set()
|
||||||
|
try:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
finally:
|
||||||
|
cancelled.set()
|
||||||
|
|
||||||
|
call = asyncio.create_task(control.run_interruptibly(stale_model()))
|
||||||
|
await asyncio.wait_for(started.wait(), 1)
|
||||||
|
task_control.add_instruction("task-123", "改为检查当前页面", source="mcp")
|
||||||
|
assert await asyncio.wait_for(call, 1) == (True, None)
|
||||||
|
assert cancelled.is_set()
|
||||||
|
assert released == []
|
||||||
|
assert task_control.consume_instructions(control.directory)["instruction"] == "改为检查当前页面"
|
||||||
|
|
||||||
|
|
||||||
def test_guidance_history_distinguishes_submission_from_runner_consumption(tmp_path, monkeypatch):
|
def test_guidance_history_distinguishes_submission_from_runner_consumption(tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
||||||
first = task_control.add_instruction("task-123", "从快捷设置进入", source="web")
|
first = task_control.add_instruction("task-123", "从快捷设置进入", source="web")
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from artemis.llm.reliability import LLMExhaustedError, LLMPermanentError
|
from artemis.llm.reliability import LLMExhaustedError, LLMPermanentError
|
||||||
|
from artemis.llm.router import ModelEndpoint
|
||||||
from artemis.services import llm as llm_service
|
from artemis.services import llm as llm_service
|
||||||
from artemis.services.llm import (
|
from artemis.services.llm import (
|
||||||
RobustChatModelWrapper,
|
RobustChatModelWrapper,
|
||||||
@@ -54,6 +55,30 @@ async def test_mid_stream_failure_retries_without_duplicating_chunks():
|
|||||||
assert result.content == "Hello world"
|
assert result.content == "Hello world"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gpt_stream_uses_adapted_messages_without_mutating_history():
|
||||||
|
class RecordingStream:
|
||||||
|
messages = None
|
||||||
|
|
||||||
|
async def astream(self, messages, **kwargs):
|
||||||
|
self.messages = messages
|
||||||
|
yield AIMessageChunk(content="OK")
|
||||||
|
|
||||||
|
base = RecordingStream()
|
||||||
|
wrapper = RobustChatModelWrapper(
|
||||||
|
base, endpoint=ModelEndpoint(model_name="gpt-6-luna", max_retries=0)
|
||||||
|
)
|
||||||
|
original = [SystemMessage(content="Use tools"), HumanMessage(content="Inspect screen")]
|
||||||
|
result = await wrapper.complete(original)
|
||||||
|
|
||||||
|
assert result.content == "OK"
|
||||||
|
assert len(base.messages) == 1
|
||||||
|
assert isinstance(base.messages[0], HumanMessage)
|
||||||
|
assert base.messages[0].content[0]["text"] == "Use tools"
|
||||||
|
assert base.messages[0].content[1]["text"] == "Inspect screen"
|
||||||
|
assert isinstance(original[0], SystemMessage)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auth_error_fails_fast_without_retry_or_pause(tmp_path):
|
async def test_auth_error_fails_fast_without_retry_or_pause(tmp_path):
|
||||||
class AuthFails:
|
class AuthFails:
|
||||||
|
|||||||
@@ -16,11 +16,12 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from artemis.config import AgentGlobalConfig
|
from artemis.config import AgentGlobalConfig, load_agent_config
|
||||||
|
|
||||||
|
|
||||||
def test_memory_defaults():
|
def test_memory_defaults():
|
||||||
cfg = AgentGlobalConfig.model_validate({})
|
cfg = AgentGlobalConfig.model_validate({})
|
||||||
|
assert cfg.flash.step_summarizer.timeout_seconds == 120.0
|
||||||
# 2 per the 2026-09-01 on-device baseline (serial queue at 1 pushed
|
# 2 per the 2026-09-01 on-device baseline (serial queue at 1 pushed
|
||||||
# summary-ready P90 past the scrub-edge grace window).
|
# summary-ready P90 past the scrub-edge grace window).
|
||||||
assert cfg.memory.runtime.max_concurrency == 2
|
assert cfg.memory.runtime.max_concurrency == 2
|
||||||
@@ -41,6 +42,14 @@ def test_memory_defaults():
|
|||||||
assert cfg.memory.transcript.hard_ratio == 0.9
|
assert cfg.memory.transcript.hard_ratio == 0.9
|
||||||
|
|
||||||
|
|
||||||
|
def test_visual_summary_timeout_config(monkeypatch):
|
||||||
|
monkeypatch.delenv("ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS", raising=False)
|
||||||
|
assert load_agent_config().flash.step_summarizer.timeout_seconds == 120.0
|
||||||
|
|
||||||
|
monkeypatch.setenv("ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS", "75")
|
||||||
|
assert load_agent_config().flash.step_summarizer.timeout_seconds == 75.0
|
||||||
|
|
||||||
|
|
||||||
def test_transcript_ratio_ladder_is_validated():
|
def test_transcript_ratio_ladder_is_validated():
|
||||||
"""start <= soft <= hard, all within [0, 1]: the defaults pass, a
|
"""start <= soft <= hard, all within [0, 1]: the defaults pass, a
|
||||||
crossed pair is rejected with a clear message."""
|
crossed pair is rejected with a clear message."""
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ from unittest.mock import MagicMock
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from langchain_core.messages import HumanMessage, ToolMessage
|
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
|
||||||
from pydantic import BaseModel, SecretStr, ValidationError
|
from pydantic import BaseModel, SecretStr, ValidationError
|
||||||
|
|
||||||
from artemis.config.settings import settings
|
from artemis.config.settings import settings
|
||||||
|
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.llm import RobustChatModelWrapper
|
from artemis.services.llm import RobustChatModelWrapper
|
||||||
|
|
||||||
@@ -85,6 +86,91 @@ async def test_text_and_image_use_responses_transport(transport_model):
|
|||||||
assert result.usage_metadata["total_tokens"] == 13
|
assert result.usage_metadata["total_tokens"] == 13
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("model_name", "reasoning_effort"),
|
||||||
|
[
|
||||||
|
("mimo-v2.6-pro", "high"),
|
||||||
|
("mimo-v2.6-flash", "high"),
|
||||||
|
("gpt-6-luna", "max"),
|
||||||
|
("gpt-6-luna", "high"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_selected_models_put_instructions_in_user_input(
|
||||||
|
transport_model, model_name, reasoning_effort
|
||||||
|
):
|
||||||
|
_, requests, _ = transport_model
|
||||||
|
endpoint = ModelEndpoint(
|
||||||
|
model_name=model_name,
|
||||||
|
reasoning_effort=reasoning_effort,
|
||||||
|
timeout_seconds=23,
|
||||||
|
max_retries=0,
|
||||||
|
)
|
||||||
|
model = ModelFactory.create_model(endpoint)
|
||||||
|
wrapper = RobustChatModelWrapper(model, endpoint=endpoint)
|
||||||
|
tool = {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "inspect_screen",
|
||||||
|
"description": "Inspect the current screen",
|
||||||
|
"parameters": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await wrapper.bind_tools([tool]).ainvoke(
|
||||||
|
[
|
||||||
|
SystemMessage(content="Use the available tools."),
|
||||||
|
HumanMessage(
|
||||||
|
content=[
|
||||||
|
{"type": "text", "text": "Inspect this screen."},
|
||||||
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = json.loads(requests[-1].content)
|
||||||
|
assert payload["model"] == model_name
|
||||||
|
assert payload["reasoning"]["effort"] == reasoning_effort
|
||||||
|
assert payload["input"][0]["role"] == "user"
|
||||||
|
assert payload["input"][0]["content"][0]["text"] == "Use the available tools."
|
||||||
|
assert payload["input"][0]["content"][2]["type"] == "input_image"
|
||||||
|
assert payload["tools"][0]["name"] == "inspect_screen"
|
||||||
|
assert all(item.get("role") != "system" for item in payload["input"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("model_name", ["gpt-6-luna", "GPT-5.6-luna"])
|
||||||
|
def test_gpt_message_adaptation_keeps_history_and_images(model_name):
|
||||||
|
image = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}
|
||||||
|
original = [
|
||||||
|
SystemMessage(content="First instruction"),
|
||||||
|
SystemMessage(content="Second instruction"),
|
||||||
|
HumanMessage(content=[{"type": "text", "text": "Observation"}, image]),
|
||||||
|
SystemMessage(content="Follow-up instruction"),
|
||||||
|
]
|
||||||
|
adapted = adapt_responses_messages(original, model_name)
|
||||||
|
|
||||||
|
assert len(adapted) == 2
|
||||||
|
assert isinstance(adapted[0], HumanMessage)
|
||||||
|
assert adapted[0].content[0]["text"] == "First instruction"
|
||||||
|
assert adapted[0].content[1]["text"] == "Second instruction"
|
||||||
|
assert adapted[0].content[3] == image
|
||||||
|
assert isinstance(adapted[1], HumanMessage)
|
||||||
|
assert adapted[1].content == [{"type": "text", "text": "Follow-up instruction"}]
|
||||||
|
assert isinstance(original[0], SystemMessage)
|
||||||
|
assert len(original[2].content) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("model_name", ["mimo-v2.6-pro", "mimo-v2.6-flash"])
|
||||||
|
def test_mimo_messages_use_same_user_role_format(model_name):
|
||||||
|
messages = [SystemMessage(content="Instructions"), HumanMessage(content="Task")]
|
||||||
|
adapted = adapt_responses_messages(messages, model_name)
|
||||||
|
assert len(adapted) == 1
|
||||||
|
assert isinstance(adapted[0], HumanMessage)
|
||||||
|
assert adapted[0].content[0]["text"] == "Instructions"
|
||||||
|
assert adapted[0].content[1]["text"] == "Task"
|
||||||
|
assert isinstance(messages[0], SystemMessage)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_max_reasoning_is_sent_unchanged_to_responses(transport_model):
|
async def test_max_reasoning_is_sent_unchanged_to_responses(transport_model):
|
||||||
model, requests, _ = transport_model
|
model, requests, _ = transport_model
|
||||||
|
|||||||
Reference in new issue
Block a user