feat: add tool-call fallback and resilient batch exports

This commit is contained in:
chenmingxuan committed 2026-09-29 19:53:20 +08:00
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"
+50 -4
View File
@@ -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
# ---------------------------------------------------------------------------
+36 -3
View File
@@ -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)
+8
View File
@@ -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."""
+26
View File
@@ -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
+9
View File
@@ -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"))
+12
View File
@@ -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"