Install and update artemis-use with task diagnostics and plugin guidance. Preserve cross-device file receipts, review canonical reports, guard stage transitions and reduce small history summaries. Improve exploration lifecycle, request recovery and browser live connections.
340 lines
14 KiB
Python
340 lines
14 KiB
Python
"""Regression coverage for operator recovery and task pause handling."""
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
from langchain_core.messages import AIMessage, SystemMessage, ToolMessage
|
|
import pytest
|
|
|
|
from artemis.agents.operator.operator import OperatorNode, OperatorNoProgressError
|
|
from artemis.config.settings import settings
|
|
from artemis.runtime import task_control
|
|
|
|
|
|
@pytest.fixture
|
|
def operator(monkeypatch):
|
|
node = object.__new__(OperatorNode)
|
|
node.ctx = SimpleNamespace(transcript_ledger=None)
|
|
node._available_device_actions = lambda: {"click", "long_press", "input_text"}
|
|
node._plan_changed_this_turn = lambda: True
|
|
node._max_burst_actions = lambda: 4
|
|
state = SimpleNamespace(
|
|
operator_raw_data={"width": 1280, "height": 2856},
|
|
indexed_elements=[{"center": [310, 2610], "text": "model menu", "resource_id": "model"}]
|
|
* 13,
|
|
open_incident=None,
|
|
)
|
|
monkeypatch.setattr(settings, "ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS", 3)
|
|
return node, state
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["click", "long_press", "input_text"])
|
|
@pytest.mark.parametrize("target", ["13", "[240, 914]", "240, 914"])
|
|
def test_quoted_targets_keep_same_action_and_grounding(operator, name, target):
|
|
node, state = operator
|
|
expected_target = 13 if target == "13" else [240, 914]
|
|
args = {"target": target, "target_description": "model menu", "text": "test"}
|
|
actions, error = node._translate_and_validate_tool({"name": name, "args": args}, state)
|
|
expected, _ = node._translate_and_validate_tool(
|
|
{"name": name, "args": {**args, "target": expected_target}}, state
|
|
)
|
|
assert error is None
|
|
assert actions == expected
|
|
if target == "13":
|
|
assert actions[0]["target_resource_id"] == "model"
|
|
else:
|
|
assert actions[0]["target_description"] == "model menu"
|
|
|
|
|
|
@pytest.mark.parametrize("target", ["0", "14", "model menu", True, "true"])
|
|
def test_invalid_index_is_not_guessed_or_dispatched(operator, target):
|
|
node, state = operator
|
|
actions, error = node._translate_and_validate_tool(
|
|
{"name": "click", "args": {"target": target}}, state
|
|
)
|
|
assert not actions
|
|
assert error
|
|
|
|
|
|
def test_quoted_coordinates_still_need_description(operator):
|
|
node, state = operator
|
|
actions, error = node._translate_and_validate_tool(
|
|
{"name": "click", "args": {"target": "[240, 914]"}}, state
|
|
)
|
|
assert not actions
|
|
assert "target_description" in error
|
|
|
|
|
|
def click(target="13"):
|
|
return AIMessage(
|
|
content="Open the model menu.",
|
|
tool_calls=[{"name": "click", "args": {"target": target}, "id": "call_click"}],
|
|
)
|
|
|
|
|
|
async def run_loop(operator, monkeypatch, responses):
|
|
node, state = operator
|
|
invoke = AsyncMock(side_effect=responses)
|
|
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
|
messages = []
|
|
result = await node._invoke_llm_loop(Mock(), messages, [], [], state)
|
|
return result, messages, invoke
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prose_retry_keeps_context_and_recovers_once(operator, monkeypatch):
|
|
prose = AIMessage(content="I will click the model menu.")
|
|
result, messages, invoke = await run_loop(operator, monkeypatch, [prose, click()])
|
|
assert invoke.await_count == 2
|
|
assert len(result[0]) == 1
|
|
assert result[0][0]["coordinates"] == [310, 2610]
|
|
assert prose in messages
|
|
assert any(isinstance(m, SystemMessage) and "No tool call" in m.content for m in messages)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"scenario", ["complete", "pending", "unfinished", "unchanged", "failed", "continuous", "pro"]
|
|
)
|
|
async def test_completed_exploration_plan_does_not_force_an_extra_action(
|
|
operator, monkeypatch, scenario
|
|
):
|
|
node, state = operator
|
|
node.ctx.exploration_id = None if scenario == "pro" else "exploration"
|
|
node.ctx.data_engine = None
|
|
plan = {"text": "- [ ] Return home"}
|
|
if scenario == "unchanged":
|
|
plan["text"] = "- [x] Return home"
|
|
node._read_task_plan_text = lambda: plan["text"]
|
|
monkeypatch.setattr("artemis.exploration.runtime.publish_response", Mock())
|
|
monkeypatch.setattr(
|
|
"artemis.exploration.runtime.needs_exploration_turn", lambda ctx: scenario == "pending"
|
|
)
|
|
|
|
async def save_note(**kwargs):
|
|
plan["text"] = "- [ ] Return home" if scenario == "unfinished" else "- [x] Return home"
|
|
if scenario == "continuous":
|
|
plan["text"] += " [Loop:continuous]"
|
|
return ToolMessage(
|
|
content="Saved plan",
|
|
tool_call_id="plan",
|
|
status="error" if scenario == "failed" else "success",
|
|
)
|
|
|
|
monkeypatch.setattr("artemis.agents.operator.operator.invoke_tool_with_injection", save_note)
|
|
response = AIMessage(
|
|
content="Confirmed home; recorded evidence.",
|
|
tool_calls=[{"name": "save_note", "args": {}, "id": "plan"}],
|
|
)
|
|
final = AIMessage(content="# 完整调查报告\n已回主页,证据见步骤 3。未检查其他页面。")
|
|
finishing = scenario in {"complete", "unchanged"}
|
|
invoke = AsyncMock(side_effect=[response, final if finishing else click()])
|
|
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
|
messages = []
|
|
result = await node._invoke_llm_loop(
|
|
Mock(), messages, [SimpleNamespace(name="save_note")], [], state
|
|
)
|
|
if finishing:
|
|
assert invoke.await_count == 2
|
|
assert result[0] is None
|
|
assert final.content in result[1]
|
|
assert state.execution_report == final.content
|
|
assert any("完整报告正文" in m.content for m in messages if isinstance(m, SystemMessage))
|
|
assert result[3] is False
|
|
elif scenario == "pending":
|
|
assert invoke.await_count == 1
|
|
assert result[0] is None
|
|
assert state.execution_report is None
|
|
return
|
|
else:
|
|
assert invoke.await_count == 2
|
|
assert result[0]
|
|
assert state.execution_report is None
|
|
assert any(isinstance(m, ToolMessage) and m.tool_call_id == "plan" for m in messages)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_final_report_is_discarded_when_guidance_arrives(operator, monkeypatch):
|
|
node, state = operator
|
|
state.execution_report = "旧报告"
|
|
node.ctx.exploration_id = "exploration"
|
|
node._read_task_plan_text = lambda: "- [x] 调查完成"
|
|
pending = {"value": False}
|
|
monkeypatch.setattr(
|
|
"artemis.exploration.runtime.needs_exploration_turn", lambda ctx: pending["value"]
|
|
)
|
|
publish = Mock()
|
|
monkeypatch.setattr("artemis.exploration.runtime.publish_response", publish)
|
|
|
|
async def answer(*args):
|
|
pending["value"] = True
|
|
return AIMessage(content="这是过时的最终报告")
|
|
|
|
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", answer)
|
|
result = await node._invoke_llm_loop(Mock(), [], [], [], state)
|
|
assert result[0] is None
|
|
assert state.execution_report is None
|
|
publish.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_final_report_uses_bounded_recovery(operator, monkeypatch):
|
|
node, state = operator
|
|
node.ctx.exploration_id = "exploration"
|
|
node._read_task_plan_text = lambda: "- [x] 调查完成"
|
|
monkeypatch.setattr("artemis.exploration.runtime.needs_exploration_turn", lambda ctx: False)
|
|
monkeypatch.setattr("artemis.exploration.runtime.publish_response", Mock())
|
|
monkeypatch.setattr("artemis.agents.operator.operator.get_llm", Mock(return_value=Mock()))
|
|
monkeypatch.setattr(settings, "ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS", 2)
|
|
invoke = AsyncMock(return_value=AIMessage(content=" "))
|
|
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
|
with pytest.raises(OperatorNoProgressError):
|
|
await node._invoke_llm_loop(Mock(), [], [], [], state)
|
|
assert state.execution_report is None
|
|
assert invoke.await_count == 5
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_target_can_be_corrected_before_limit(operator, monkeypatch):
|
|
result, messages, invoke = await run_loop(operator, monkeypatch, [click("14"), click("13")])
|
|
assert invoke.await_count == 2
|
|
assert len(result[0]) == 1
|
|
assert result[0][0]["coordinates"] == [310, 2610]
|
|
outputs = [m for m in messages if isinstance(m, ToolMessage)]
|
|
assert [m.status for m in outputs] == ["error", "success"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@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 + 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")] + [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 == 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)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_iteration_cap_cannot_restart_no_progress_next_turn(operator, monkeypatch):
|
|
monkeypatch.setattr("artemis.agents.operator.operator.OPERATOR_MAX_TOOL_ITERATIONS", 2)
|
|
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 == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pause_during_diagnosis_returns_to_fresh_observation(operator, monkeypatch, tmp_path):
|
|
node, state = operator
|
|
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
|
started = asyncio.Event()
|
|
cancelled = asyncio.Event()
|
|
events = []
|
|
|
|
class Lock:
|
|
def __init__(self, device_id, **kwargs):
|
|
self.device_id = device_id
|
|
self.description = "test"
|
|
self.max_concurrency = None
|
|
self.concurrency_mode = "per_device"
|
|
self.ingress = "web"
|
|
self.lock_scope = "test"
|
|
|
|
def release(self):
|
|
events.append("released")
|
|
|
|
def acquire(self, cancel_event=None):
|
|
events.append("acquired")
|
|
|
|
monkeypatch.setattr(task_control, "DeviceExecutionLock", Lock)
|
|
node.ctx.data_engine = None
|
|
node.ctx.task_control = task_control.TaskControl("pro-task", Lock("device"))
|
|
node.ctx.on_task_pause = AsyncMock()
|
|
node.ctx.on_task_resume = AsyncMock()
|
|
|
|
async def diagnose(**kwargs):
|
|
started.set()
|
|
try:
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
cancelled.set()
|
|
|
|
monkeypatch.setattr("artemis.agents.operator.operator.invoke_tool_with_injection", diagnose)
|
|
response = AIMessage(
|
|
content="Inspect the failure",
|
|
tool_calls=[
|
|
{"name": "ask_diagnoser", "args": {"query": "why"}, "id": "diagnosis"},
|
|
{"name": "click", "args": {"target": 13}, "id": "tap"},
|
|
],
|
|
)
|
|
invoke = AsyncMock(return_value=response)
|
|
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
|
tool = SimpleNamespace(name="ask_diagnoser")
|
|
messages = []
|
|
execution = asyncio.create_task(node._invoke_llm_loop(Mock(), messages, [tool], [], state))
|
|
await asyncio.wait_for(started.wait(), 1)
|
|
task_control.request_pause("pro-task")
|
|
for _ in range(40):
|
|
if task_control.read_state("pro-task").get("status") == "task_paused":
|
|
break
|
|
await asyncio.sleep(0.01)
|
|
assert task_control.read_state("pro-task")["status"] == "task_paused"
|
|
assert cancelled.is_set()
|
|
assert events == ["released"]
|
|
task_control.request_resume("pro-task")
|
|
assert (await asyncio.wait_for(execution, 2))[0] is None
|
|
assert events == ["released", "acquired"]
|
|
assert invoke.await_count == 1
|
|
assert [m.status for m in messages if isinstance(m, ToolMessage)] == ["error", "error"]
|