feat: release 1.1.0 with responsive task control

This commit is contained in:
chenmingxuan committed 2026-09-24 03:31:17 +08:00
1 parent 057f0c3050
commit bfcd828fa8
37 files changed
+1601 -151

No files matched your search

+42
View File
@@ -24,6 +24,8 @@ import pytest
from artemis.agents.log_analyzer.log_analyzer import LogAnalyzerNode
from artemis.context import ArtemisContext
from artemis.core.tool_failure import ToolFailure
from artemis.tools.mobile.read_logs import ReadLogsArgs
from artemis.tools.mobile.search_logs import SearchLogsArgs
_STATUS_CASES = [
pytest.param(ToolFailure("Error searching logs: device gone"), "error", id="tool_failure"),
@@ -76,3 +78,43 @@ async def test_agent_loop_tool_message_status_is_structural(result, expected_sta
assert tool_msgs[0].tool_call_id == "c1"
assert tool_msgs[0].status == expected_status
assert tool_msgs[0].content == str(result)
@pytest.mark.asyncio
async def test_log_cache_normalizes_numeric_tool_arguments_before_running_tools():
node = LogAnalyzerNode(ctx=MagicMock(spec=ArtemisContext))
read_calls = []
async def read_raw_logs(lines=200, **kwargs):
read_calls.append(lines)
return "before\nE/Settings: failed\nafter"
async def unused_search(**kwargs):
pytest.fail("Cached raw logs should be searched locally")
read_tool = node._wrap_with_caching(
StructuredTool.from_function(
coroutine=read_raw_logs, name="read_logs", description="read", args_schema=ReadLogsArgs
)
)
search_tool = node._wrap_with_caching(
StructuredTool.from_function(
coroutine=unused_search,
name="search_logs",
description="search",
args_schema=SearchLogsArgs,
)
)
assert await read_tool.coroutine(lines="200") == "before\nE/Settings: failed\nafter"
assert await read_tool.coroutine(lines=200) == "before\nE/Settings: failed\nafter"
assert await read_tool.coroutine(lines=None) == "before\nE/Settings: failed\nafter"
assert read_calls == [200]
result = await search_tool.coroutine(keyword="Settings", lines="200", context_lines="1")
assert "before" in result and "after" in result
assert "E/Settings: failed" in result
assert "before" not in await search_tool.coroutine(
keyword="Settings", lines="200", context_lines=None
)
assert isinstance(await search_tool.coroutine(keyword="Settings", lines="many"), ToolFailure)
+68 -1
View File
@@ -1,5 +1,6 @@
"""Regression coverage for quoted click targets and prose-only recovery loops."""
"""Regression coverage for operator recovery and task pause handling."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
@@ -8,6 +9,7 @@ import pytest
from artemis.agents.operator.operator import OperatorNode, OperatorNoProgressError
from artemis.config.settings import settings
from artemis.runtime import task_control
@pytest.fixture
@@ -135,3 +137,68 @@ async def test_iteration_cap_cannot_restart_no_progress_next_turn(operator, monk
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"]