feat: add tool-call fallback and resilient batch exports
This commit is contained in:
1 parent
ab066733b8
commit
d31eb2a19d
32 files changed
+538
-72
No files matched your search
@@ -318,6 +318,38 @@ async def test_fail_fast_includes_skipped_reports(isolated, monkeypatch):
|
||||
assert sum(n.endswith(".docx") for n in z.namelist()) == 3
|
||||
|
||||
|
||||
def test_batch_archive_contains_each_task_media_when_present(isolated, monkeypatch, tmp_path):
|
||||
batch = service.create_batch(payload())
|
||||
first, second = isolated.jobs(batch["id"])
|
||||
image1 = tmp_path / "first.png"
|
||||
image2 = tmp_path / "second.png"
|
||||
video = tmp_path / "recording.mp4"
|
||||
image1.write_bytes(b"first screenshot")
|
||||
image2.write_bytes(b"second screenshot")
|
||||
video.write_bytes(b"saved recording")
|
||||
images = {first["session_id"]: image1, second["session_id"]: image2}
|
||||
monkeypatch.setattr(
|
||||
reports,
|
||||
"session_screenshots",
|
||||
lambda report: [(images[report["id"]], "0001_步骤0001_操作前.png")],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
reports,
|
||||
"session_videos",
|
||||
lambda report: (
|
||||
[(video, "0001_recording.mp4")] if report["id"] == first["session_id"] else []
|
||||
),
|
||||
)
|
||||
|
||||
archive = reports.export_batch(batch["id"])
|
||||
with zipfile.ZipFile(archive) as z:
|
||||
names = z.namelist()
|
||||
assert len([name for name in names if "/步骤截图/" in name]) == 2
|
||||
assert len([name for name in names if "/任务录屏/" in name]) == 1
|
||||
assert all(name.startswith("任务报告/") for name in names if "步骤截图" in name)
|
||||
assert z.read(next(name for name in names if "/任务录屏/" in name)) == b"saved recording"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_report_failure_does_not_strand_job(isolated, monkeypatch):
|
||||
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||
|
||||
@@ -23,6 +23,11 @@ def isolated_settings(monkeypatch, tmp_path):
|
||||
"OPENAI_TIMEOUT_SECONDS",
|
||||
"OPENAI_MAX_RETRIES",
|
||||
"OPENAI_RETRY_INTERVAL_SECONDS",
|
||||
"OPENAI_RATE_LIMIT_RETRY_SECONDS",
|
||||
"OPENAI_TOOL_FALLBACK_MODEL",
|
||||
"OPENAI_TOOL_FALLBACK_REASONING_EFFORT",
|
||||
"OPENAI_TOOL_FALLBACK_MAX_RETRIES",
|
||||
"ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS",
|
||||
"OPENAI_REASONING_EFFORT",
|
||||
"ARTEMIS_VIDEO_ENABLED",
|
||||
"ARTEMIS_OUTPUTTER_ENABLED",
|
||||
@@ -43,6 +48,11 @@ def test_save_reload_and_blank_key_preserves_secret(isolated_settings):
|
||||
api_key="new-test-secret",
|
||||
timeout_seconds=75,
|
||||
max_retries=2,
|
||||
rate_limit_retry_seconds=90,
|
||||
tool_fallback_model="custom/fallback",
|
||||
tool_fallback_reasoning_effort="max",
|
||||
tool_fallback_max_retries=2,
|
||||
tool_fallback_no_progress_attempts=4,
|
||||
)
|
||||
result = service.save_model_settings(update)
|
||||
assert result["api_key_configured"]
|
||||
@@ -50,12 +60,16 @@ def test_save_reload_and_blank_key_preserves_secret(isolated_settings):
|
||||
saved = dotenv_values(isolated_settings)
|
||||
assert saved["OPENAI_API_KEY"] == "new-test-secret"
|
||||
assert saved["OPENAI_MODEL"] == "my-model"
|
||||
assert saved["OPENAI_RATE_LIMIT_RETRY_SECONDS"] == "90.0"
|
||||
assert saved["OPENAI_TOOL_FALLBACK_MODEL"] == "custom/fallback"
|
||||
assert saved["ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS"] == "4"
|
||||
assert saved["UNRELATED"] == "keep"
|
||||
assert "# user settings" in isolated_settings.read_text(encoding="utf-8")
|
||||
reloaded = Settings(_env_file=isolated_settings)
|
||||
assert reloaded.OPENAI_MODEL == "my-model"
|
||||
assert parse_llm_config().operator.model == "my-model"
|
||||
assert parse_llm_config().operator.timeout == 75
|
||||
assert parse_llm_config().operator.fallback.model == "custom/fallback"
|
||||
service.save_model_settings(update.model_copy(update={"api_key": SecretStr("")}))
|
||||
assert dotenv_values(isolated_settings)["OPENAI_API_KEY"] == "new-test-secret"
|
||||
|
||||
|
||||
@@ -384,7 +384,7 @@ def _make_runner(mock_context, responses, max_turns=None):
|
||||
pending = list(responses)
|
||||
|
||||
async def _invoke(llm, tools, messages):
|
||||
runner.calls.append({"tools": list(tools), "messages": list(messages)})
|
||||
runner.calls.append({"llm": llm, "tools": list(tools), "messages": list(messages)})
|
||||
return pending.pop(0)
|
||||
|
||||
runner._invoke_model = AsyncMock(side_effect=_invoke)
|
||||
@@ -647,9 +647,11 @@ async def test_flash_stops_repeated_no_tool_responses(mock_context, monkeypatch,
|
||||
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)]
|
||||
monkeypatch.setattr(settings, "ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS", 3)
|
||||
responses = [AIMessage(content="I will click now.", tool_calls=[]) for _ in range(attempts + 3)]
|
||||
with (
|
||||
patch("artemis.controllers.unified_controller.get_driver"),
|
||||
patch("artemis.agents.flash.runner.get_llm", return_value=Mock()) as get_fallback,
|
||||
patch(
|
||||
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
|
||||
AsyncMock(return_value=_INITIAL_OBSERVATION),
|
||||
@@ -659,11 +661,55 @@ async def test_flash_stops_repeated_no_tool_responses(mock_context, monkeypatch,
|
||||
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
|
||||
assert f"连续 {attempts} 次" in result["explanation"]
|
||||
assert "兜底模型连续 3 次" in result["explanation"]
|
||||
assert runner._invoke_model.await_count == attempts + 3
|
||||
get_fallback.assert_called_once_with(mock_context, name="operator", use_fallback=True)
|
||||
runner.executor.execute.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flash_uses_fallback_for_one_tool_turn_then_primary(mock_context):
|
||||
no_call = AIMessage(content="I will click now.")
|
||||
observe = AIMessage(
|
||||
content="Check the target.",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "ask_explorer",
|
||||
"args": {"question": "Find Settings"},
|
||||
"id": "fallback-observe",
|
||||
}
|
||||
],
|
||||
)
|
||||
click = AIMessage(
|
||||
content="Tap Settings.",
|
||||
tool_calls=[{"name": "click", "args": {"target": 1}, "id": "fallback-click"}],
|
||||
)
|
||||
fallback = Mock()
|
||||
responses = [no_call, no_call, no_call, observe, click, _report()]
|
||||
with (
|
||||
patch("artemis.controllers.unified_controller.get_driver"),
|
||||
patch("artemis.agents.flash.runner.get_llm", return_value=fallback),
|
||||
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"))
|
||||
|
||||
primary = runner.calls[0]["llm"]
|
||||
assert [call["llm"] for call in runner.calls] == [
|
||||
primary,
|
||||
primary,
|
||||
primary,
|
||||
fallback,
|
||||
fallback,
|
||||
primary,
|
||||
]
|
||||
assert result["status"] == "completed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _TurnRecord.result: the turn's execution result in the validator-report shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -107,23 +107,56 @@ async def test_invalid_target_can_be_corrected_before_limit(operator, monkeypatc
|
||||
@pytest.mark.parametrize("limit", [1, 3])
|
||||
async def test_empty_responses_fail_at_configured_limit(operator, monkeypatch, limit):
|
||||
monkeypatch.setattr(settings, "ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS", limit)
|
||||
monkeypatch.setattr(settings, "ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS", 3)
|
||||
fallback = Mock()
|
||||
get_fallback = Mock(return_value=fallback)
|
||||
monkeypatch.setattr("artemis.agents.operator.operator.get_llm", get_fallback)
|
||||
invoke = AsyncMock(return_value=AIMessage(content="I will click now."))
|
||||
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||
node, state = operator
|
||||
with pytest.raises(OperatorNoProgressError, match="未提供可执行的工具调用"):
|
||||
await node._invoke_llm_loop(Mock(), [], [], [], state)
|
||||
assert invoke.await_count == limit
|
||||
assert invoke.await_count == limit + 3
|
||||
get_fallback.assert_called_once_with(ctx=node.ctx, name="operator", use_fallback=True)
|
||||
assert invoke.call_args_list[-1].args[0] is fallback.bind_tools.return_value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_fallback_is_scoped_to_current_step(operator, monkeypatch):
|
||||
node, state = operator
|
||||
primary = Mock()
|
||||
fallback = Mock()
|
||||
get_fallback = Mock(return_value=fallback)
|
||||
monkeypatch.setattr("artemis.agents.operator.operator.get_llm", get_fallback)
|
||||
invoke = AsyncMock(side_effect=[AIMessage(content="no tool")] * 3 + [click(), click()])
|
||||
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||
|
||||
first = await node._invoke_llm_loop(primary, [], [], [], state)
|
||||
second = await node._invoke_llm_loop(primary, [], [], [], state)
|
||||
|
||||
assert first[0] and second[0]
|
||||
assert get_fallback.call_count == 1
|
||||
assert [call.args[0] for call in invoke.call_args_list] == [
|
||||
primary.bind_tools.return_value,
|
||||
primary.bind_tools.return_value,
|
||||
primary.bind_tools.return_value,
|
||||
fallback.bind_tools.return_value,
|
||||
primary.bind_tools.return_value,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_calls_and_prose_share_bounded_recovery(operator, monkeypatch):
|
||||
invoke = AsyncMock(side_effect=[click("missing"), AIMessage(content="Retry."), click("14")])
|
||||
invoke = AsyncMock(
|
||||
side_effect=[click("missing"), AIMessage(content="Retry."), click("14")] + [click("14")] * 3
|
||||
)
|
||||
monkeypatch.setattr("artemis.agents.operator.operator.get_llm", Mock(return_value=Mock()))
|
||||
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||
node, state = operator
|
||||
messages = []
|
||||
with pytest.raises(OperatorNoProgressError, match="校验仍失败"):
|
||||
await node._invoke_llm_loop(Mock(), messages, [], [], state)
|
||||
assert invoke.await_count == 3
|
||||
assert invoke.await_count == 6
|
||||
assert any(isinstance(m, ToolMessage) and m.status == "error" for m in messages)
|
||||
assert not any(isinstance(m, ToolMessage) and m.content == "Action Recorded" for m in messages)
|
||||
|
||||
|
||||
@@ -32,6 +32,14 @@ async def test_config_requires_explicit_persistence_and_excludes_secrets(monkeyp
|
||||
"reasoning_effort": "high",
|
||||
}
|
||||
|
||||
await tools.mobile_config(
|
||||
changes={"rate_limit_retry_seconds": 90, "tool_fallback_model": "gpt-6-luna"},
|
||||
persist_defaults=True,
|
||||
)
|
||||
payload = request.call_args.kwargs["json"]
|
||||
assert payload["rate_limit_retry_seconds"] == 90
|
||||
assert payload["tool_fallback_model"] == "gpt-6-luna"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preview_is_read_only_and_create_is_draft(monkeypatch, tmp_path):
|
||||
|
||||
@@ -225,6 +225,20 @@ async def test_api_server_status():
|
||||
assert "current_pid" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_server_status_does_not_scan_ports():
|
||||
with patch(
|
||||
"artemis.runtime.server_lifecycle.get_server_status",
|
||||
side_effect=AssertionError("port scan"),
|
||||
):
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://localhost"
|
||||
) as client:
|
||||
response = await client.get("/api/system/server-status")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["running"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_restart_endpoint():
|
||||
"""Verify POST /api/system/restart accepts request and schedules restart."""
|
||||
|
||||
@@ -301,6 +301,32 @@ async def test_configured_retry_budget_is_shared_by_identical_fallback(monkeypat
|
||||
assert llm_service._FAILED_ENDPOINTS.get() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_429_waits_one_minute_then_retries(monkeypatch):
|
||||
from unittest.mock import AsyncMock
|
||||
from artemis.llm.reliability import retry_policy_for
|
||||
|
||||
class RateLimited(Exception):
|
||||
status_code = 429
|
||||
|
||||
calls = 0
|
||||
|
||||
async def call_once():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise RateLimited("Too Many Requests")
|
||||
return "recovered"
|
||||
|
||||
sleep = AsyncMock()
|
||||
monkeypatch.setattr(llm_service, "retry_policy_for", retry_policy_for)
|
||||
monkeypatch.setattr(llm_service.asyncio, "sleep", sleep)
|
||||
monkeypatch.setattr(llm_service.settings, "OPENAI_RATE_LIMIT_RETRY_SECONDS", 60)
|
||||
assert await llm_service._run_with_recovery(call_once, max_retries=1) == "recovered"
|
||||
assert calls == 2
|
||||
sleep.assert_awaited_once_with(60)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_deadline_cancels_attempt_and_respects_zero_retries(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
@@ -49,6 +49,15 @@ def test_non_retryable_categories_get_single_attempt():
|
||||
assert retry_policy_for(FailureCategory.RATE_LIMIT).max_attempts > 1
|
||||
|
||||
|
||||
def test_too_many_requests_uses_one_minute_retry_delay(monkeypatch):
|
||||
from artemis.config.settings import settings
|
||||
|
||||
monkeypatch.setattr(settings, "OPENAI_RATE_LIMIT_RETRY_SECONDS", 60)
|
||||
failure = classify_failure(RuntimeError("Too Many Requests"))
|
||||
assert failure.category is FailureCategory.RATE_LIMIT
|
||||
assert retry_policy_for(failure.category).delay_for(1) == 60
|
||||
|
||||
|
||||
def test_breaker_only_trips_on_transient_categories():
|
||||
breaker = CircuitBreaker(threshold=2, cooldown_seconds=10.0)
|
||||
auth = classify_failure(RuntimeError("401 unauthorized"))
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""The compatibility entry point follows the commands registered by Typer."""
|
||||
|
||||
from artemis.main import normalize_argv
|
||||
|
||||
|
||||
def test_registered_commands_and_flags_are_not_treated_as_goals():
|
||||
for token in ("ui", "status", "helper", "restart", "stop", "--help", "--version", "-v"):
|
||||
assert normalize_argv(["artemis", token]) == ["artemis", token]
|
||||
|
||||
|
||||
def test_bare_goal_still_uses_run_command():
|
||||
assert normalize_argv(["artemis", "打开设置"]) == ["artemis", "run", "打开设置"]
|
||||
@@ -207,6 +207,30 @@ async def test_analyzer_clip_stays_continuous_across_orientation_segments(tmp_pa
|
||||
assert len(frames) >= 20
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyzer_clip_pins_requested_fps_across_recording_gap(tmp_path):
|
||||
ffmpeg = get_ffmpeg_path()
|
||||
first = tmp_path / "first.mkv"
|
||||
second = tmp_path / "second.mkv"
|
||||
output = tmp_path / "clip.mp4"
|
||||
_make_solid_clip(ffmpeg, "red", "108x242", 1.0, first)
|
||||
_make_solid_clip(ffmpeg, "blue", "242x108", 1.0, second)
|
||||
assert await render_timeline_clip(
|
||||
[{"path": first, "start": 0.0, "end": 1.0}, {"path": second, "start": 1.5, "end": 2.5}],
|
||||
0.0,
|
||||
2.5,
|
||||
output,
|
||||
canvas_width=180,
|
||||
canvas_height=320,
|
||||
fps=7,
|
||||
)
|
||||
capture = cv2.VideoCapture(str(output))
|
||||
try:
|
||||
assert capture.get(cv2.CAP_PROP_FPS) == pytest.approx(7.0, abs=0.1)
|
||||
finally:
|
||||
capture.release()
|
||||
|
||||
|
||||
def test_timeline_pieces_fill_restart_gaps(tmp_path):
|
||||
"""Holes between segments (and a window starting in one) become black gaps."""
|
||||
first = tmp_path / "recording.mkv"
|
||||
|
||||
Reference in new issue
Block a user