From 1dc2b0c6867da5a505e558974bb330743e4c30c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E9=93=AD=E8=BD=A9?= Date: Mon, 28 Sep 2026 15:20:22 +0800 Subject: [PATCH] fix: prevent timeout stalls and release 1.2.1 --- .env.example | 2 + .../services/automation_service.py | 12 ++-- apps/showcase_ui/package-lock.json | 4 +- apps/showcase_ui/package.json | 2 +- artemis/agents/flash/runner.py | 17 +++++- artemis/config/settings.py | 2 + docs/releases/1.2.1.md | 14 +++++ pyproject.toml | 2 +- tests/unit/admin_console/test_automation.py | 57 ++++++++++++++++++- tests/unit/agents/test_flash_runner_ledger.py | 26 +++++++++ uv.lock | 2 +- 11 files changed, 129 insertions(+), 11 deletions(-) create mode 100644 docs/releases/1.2.1.md diff --git a/.env.example b/.env.example index 6674400..f332d36 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,8 @@ ARTEMIS_OUTPUTTER_ENABLED=true ARTEMIS_STEP_SUMMARIZER_ENABLED=true # Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。 ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS=3 +# Flash 连续无工具调用的最大次数;无效回复不会重复送回模型。 +ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS=3 # 视频分段、重试与单次分析超时(秒) ARTEMIS_VIDEO_CHUNK_SECONDS=60 diff --git a/apps/admin_console/services/automation_service.py b/apps/admin_console/services/automation_service.py index 5b61b8e..205de51 100644 --- a/apps/admin_console/services/automation_service.py +++ b/apps/admin_console/services/automation_service.py @@ -130,6 +130,11 @@ async def tick_batches(): batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次") store.put("batch", batch["id"], batch) 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( (job.get("case", {}).get("task_config") or {}).get("timeout_seconds") or batch.get("timeout_seconds", 1800) @@ -139,8 +144,7 @@ async def tick_batches(): if task_state.get("non_execution_started_at"): excluded += time.time() - float(task_state["non_execution_started_at"]) if ( - job["status"] != "finalizing" - and not job.get("timed_out") + not job.get("timed_out") and time.time() - job.get("started_at", time.time()) - excluded > limit ): job.update( @@ -148,8 +152,8 @@ async def tick_batches(): ) store.save_job(job) task_queue_service.stop_tasks(session_id=job["session_id"]) - batch.update(status="paused", reason="任务超时,已暂停剩余任务;请确认设备状态") - store.put("batch", batch["id"], batch) + # on_terminal records the failed case. The next pending + # case then runs normally unless stop_on_failure is set. continue if batch["status"] != "running": continue diff --git a/apps/showcase_ui/package-lock.json b/apps/showcase_ui/package-lock.json index a73614d..6ef728e 100644 --- a/apps/showcase_ui/package-lock.json +++ b/apps/showcase_ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "artemis-showcase-ui", - "version": "1.2.0", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "artemis-showcase-ui", - "version": "1.2.0", + "version": "1.2.1", "dependencies": { "@angular/cdk": "^22.1.5", "@angular/common": "^22.1.4", diff --git a/apps/showcase_ui/package.json b/apps/showcase_ui/package.json index 932baa9..7e74c57 100644 --- a/apps/showcase_ui/package.json +++ b/apps/showcase_ui/package.json @@ -1,6 +1,6 @@ { "name": "artemis-showcase-ui", - "version": "1.2.0", + "version": "1.2.1", "scripts": { "ng": "ng", "start": "ng serve --proxy-config proxy.conf.json", diff --git a/artemis/agents/flash/runner.py b/artemis/agents/flash/runner.py index c247fae..cee93df 100644 --- a/artemis/agents/flash/runner.py +++ b/artemis/agents/flash/runner.py @@ -1063,6 +1063,8 @@ class FlashRunner: current_xml_list = xml_list previous_turn: _TurnRecord | None = None 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: control = getattr(self.ctx, "task_control", None) @@ -1198,11 +1200,24 @@ class FlashRunner: if is_final: final_report = {"status": "failed", "explanation": raw_text} 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) - 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() continue + no_tool_attempts = 0 # Process tool calls turn = _TurnRecord() ( diff --git a/artemis/config/settings.py b/artemis/config/settings.py index 5f33424..2fd8ddb 100644 --- a/artemis/config/settings.py +++ b/artemis/config/settings.py @@ -114,6 +114,8 @@ class Settings(BaseSettings): ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True # 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) + # 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 ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST) diff --git a/docs/releases/1.2.1.md b/docs/releases/1.2.1.md new file mode 100644 index 0000000..f0c0017 --- /dev/null +++ b/docs/releases/1.2.1.md @@ -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、质量基线和核心类型检查通过。 diff --git a/pyproject.toml b/pyproject.toml index 1f375ba..29194d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ [project] name = "artemis" -version = "1.2.0" +version = "1.2.1" description = "Autonomous AI Assistant for Mobile Automation" authors = [ diff --git a/tests/unit/admin_console/test_automation.py b/tests/unit/admin_console/test_automation.py index 3e7e415..fb04e4d 100644 --- a/tests/unit/admin_console/test_automation.py +++ b/tests/unit/admin_console/test_automation.py @@ -4,7 +4,7 @@ from io import BytesIO import json import sqlite3 import time -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock from uuid import UUID import zipfile @@ -347,6 +347,61 @@ async def test_batch_timeout_stays_visible_in_job_report_and_history(client, iso ) +@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] diff --git a/tests/unit/agents/test_flash_runner_ledger.py b/tests/unit/agents/test_flash_runner_ledger.py index 57ff1ef..5521e66 100644 --- a/tests/unit/agents/test_flash_runner_ledger.py +++ b/tests/unit/agents/test_flash_runner_ledger.py @@ -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) 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() # --------------------------------------------------------------------------- diff --git a/uv.lock b/uv.lock index c36ae77..4f3e4d1 100644 --- a/uv.lock +++ b/uv.lock @@ -206,7 +206,7 @@ wheels = [ [[package]] name = "artemis" -version = "1.2.0" +version = "1.2.1" source = { editable = "." } dependencies = [ { name = "adbutils" },