Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dc2b0c686 |
No files matched your search
@@ -19,6 +19,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
|
||||||
|
|||||||
@@ -130,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)
|
||||||
@@ -139,8 +144,7 @@ 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(
|
job.update(
|
||||||
@@ -148,8 +152,8 @@ async def tick_batches():
|
|||||||
)
|
)
|
||||||
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
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"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.2.0",
|
"version": "1.2.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve --proxy-config proxy.conf.json",
|
"start": "ng serve --proxy-config proxy.conf.json",
|
||||||
|
|||||||
@@ -1063,6 +1063,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)
|
||||||
@@ -1198,11 +1200,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()
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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、质量基线和核心类型检查通过。
|
||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "artemis"
|
name = "artemis"
|
||||||
version = "1.2.0"
|
version = "1.2.1"
|
||||||
description = "Autonomous AI Assistant for Mobile Automation"
|
description = "Autonomous AI Assistant for Mobile Automation"
|
||||||
|
|
||||||
authors = [
|
authors = [
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from io import BytesIO
|
|||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock, Mock
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
import zipfile
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_model_timeout_from_runner_log_reaches_failed_task_and_report(isolated):
|
async def test_model_timeout_from_runner_log_reaches_failed_task_and_report(isolated):
|
||||||
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in new issue
Block a user