fix: improve cross-device evidence, request diagnostics and prompt efficiency

This commit is contained in:
chenmingxuan committed 2026-10-17 14:55:45 +08:00
1 parent 621f30e437
commit a66803651a
60 files changed
+3097 -160

No files matched your search

+5 -1
View File
@@ -583,7 +583,11 @@ async def test_fail_fast_includes_skipped_reports(isolated, monkeypatch):
assert [j["status"] for j in isolated.jobs(batch["id"])] == ["failed", "skipped"]
archive = reports.export_batch(batch["id"])
with zipfile.ZipFile(archive) as z:
assert len(z.namelist()) == 8 # summaries + two reports and evidence index per task
assert len(z.namelist()) == 10 # summaries + reports, diagnostics and evidence per task
diagnostics = [name for name in z.namelist() if name.endswith("模型请求诊断.json")]
assert len(diagnostics) == 2
for name in diagnostics:
assert json.loads(z.read(name))["schema_version"] == 1
assert len([name for name in z.namelist() if name.endswith("证据索引.json")]) == 2
assert sum(n.endswith(".docx") for n in z.namelist()) == 3
@@ -16,10 +16,13 @@
rejects such requests with 400 "Requests ending with a model turn are not
supported"), which is the natural state after the loop's tool-free reply."""
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from pydantic import ValidationError
from artemis.agents.checker.checker import CheckReport, _structured_report
@@ -53,3 +56,93 @@ async def test_structured_report_keeps_user_ending_conversation_as_is():
await _structured_report(llm, messages)
assert structured.ainvoke.call_args.args[0] is messages
def _result(status="passed", evidence=None):
return {
"status": status,
"reason": "双端检查与报告已完成,已知风险不代表所有布局无缺陷。",
"evidence": ["步骤 18、27:追加下滚后位置不变,双端已到页底。"]
if evidence is None
else evidence,
}
@pytest.mark.asyncio
@pytest.mark.parametrize("status", ["passed", "failed", "inconclusive", "blocked"])
async def test_complete_checker_json_does_not_request_another_verdict(status):
payload = {"test_result": _result(status)}
llm, structured = _llm(CheckReport())
answer = AIMessage(
content="核查步骤 [18]、[27],控件 [28]。最终裁定\n```json\n"
+ json.dumps(payload)
+ "\n```\n范围仅限本次检查。"
)
result = await _structured_report(llm, [HumanMessage(content="review"), answer])
assert result.test_result.status == status
assert result.test_result.evidence == payload["test_result"]["evidence"]
structured.ainvoke.assert_not_awaited()
llm.with_structured_output.assert_not_called()
def test_nested_json_string_result_is_decoded_and_still_validated():
result = _result()
assert (
CheckReport.model_validate({"test_result": json.dumps(result)}).test_result.model_dump()
== result
)
for invalid in ("passed", "null", "[]", '{"status":"passed"}', json.dumps(_result("unknown"))):
with pytest.raises(ValidationError):
CheckReport.model_validate({"test_result": invalid})
@pytest.mark.asyncio
@pytest.mark.parametrize(
"kind", ["empty", "malformed", "truncated", "tool_call", "missing_check", "ambiguous", "prose"]
)
async def test_incomplete_or_ambiguous_answer_requires_structured_verdict(kind):
text = json.dumps({"test_result": _result()})
items = []
kwargs = {}
if kind == "empty":
text = "{}"
elif kind == "malformed":
text = '{"test_result":{"status":"passed"}}'
elif kind == "truncated":
kwargs["response_metadata"] = {"finish_reason": "length"}
elif kind == "tool_call":
kwargs["tool_calls"] = [{"id": "read-1", "name": "search_history", "args": {}}]
elif kind == "missing_check":
items = [SimpleNamespace(kind="assert", text="must pass")]
elif kind == "ambiguous":
text += "\n" + json.dumps({"test_result": _result("failed")})
elif kind == "prose":
text = "测试通过。"
llm, structured = _llm(CheckReport())
await _structured_report(llm, [AIMessage(content=text, **kwargs)], items)
structured.ainvoke.assert_awaited_once()
@pytest.mark.asyncio
async def test_local_verdict_preserves_evidence_normalization(monkeypatch):
from artemis.agents.checker import checker
llm, structured = _llm(CheckReport())
monkeypatch.setattr(checker, "get_llm", lambda **_: llm)
monkeypatch.setattr(
checker,
"acomplete",
AsyncMock(
return_value=AIMessage(
content=json.dumps({"test_result": _result(evidence=[])}),
)
),
)
ctx = SimpleNamespace(execution_setup=SimpleNamespace(checker_max_iterations=3))
report = await checker._run_check_loop(ctx, [HumanMessage(content="review")], [], [])
assert report.test_result.status == "inconclusive"
structured.ainvoke.assert_not_awaited()
@@ -1,6 +1,7 @@
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock
from langchain_core.messages import AIMessage
import pytest
@@ -123,3 +124,35 @@ async def test_cancellation_of_acceptance_propagates(tmp_path, monkeypatch):
with pytest.raises(asyncio.CancelledError):
await flash.settle_flash_outcome(context(tmp_path), "任务", {"status": "completed"})
assert not (tmp_path / "run_outcome.json").exists()
@pytest.mark.asyncio
async def test_complete_checker_answer_is_persisted_without_reformatting(tmp_path, monkeypatch):
from artemis.agents.checker import checker
ctx = context(tmp_path)
ctx.exploration_id = None
ctx.on_execution_review = None
result = CaseTestResult(
status="passed",
reason="双端布局探索和报告达标;不代表网站无缺陷。",
evidence=["步骤 18、27:双端真实页底;报告列出待核实风险。"],
)
answer = AIMessage(
content="最终裁定\n```json\n" + json.dumps({"test_result": result.model_dump()}) + "\n```"
)
llm = MagicMock()
monkeypatch.setattr(checker, "get_llm", lambda **_: llm)
monkeypatch.setattr(checker, "acomplete", AsyncMock(return_value=answer))
monkeypatch.setattr(checker, "build_checker_tools", lambda *args: [])
monkeypatch.setattr(checker, "_capture_final_screen", AsyncMock(return_value=(None, "footer")))
monkeypatch.setattr(checker, "_load_steps", lambda *args: [])
outcome = await flash.settle_flash_outcome(
ctx, "如实报告布局风险,不要求网站无缺陷", {"status": "completed"}
)
llm.with_structured_output.assert_not_called()
assert outcome["test_result"] == result.model_dump()
saved = json.loads((tmp_path / "run_outcome.json").read_text(encoding="utf-8"))
assert saved["test_result"] == result.model_dump()
@@ -0,0 +1,219 @@
"""Cross-target observations save work without executing stale phone decisions."""
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langchain_core.messages import AIMessage, ToolMessage
from artemis.agents.flash import runner as flash
from artemis.agents.validator.tool_declarations import ToolExecutionResult
from artemis.mcp.observation import DeviceObservationError
from artemis.memory.transcript import TranscriptLedger
from artemis.runtime.task_control import TaskControl
PC = "plug_pc_action"
@pytest.fixture
def cross_target(monkeypatch):
plugins = SimpleNamespace(
bindings={PC: ("pc", None)},
tool_names={PC},
previous_image=lambda *_: b"pc-before",
)
monkeypatch.setattr("artemis.plugins.runtime.get_plugin_runtime", lambda _: plugins)
runner = flash.FlashRunner.__new__(flash.FlashRunner)
runner.ctx = SimpleNamespace(execution_target="mixed", data_engine=None, task_control=None)
runner._android_observation_stale = False
runner.controller, runner.summarizer, runner._llm = Mock(), None, None
state = SimpleNamespace(
latest_screenshot="old-phone.png",
indexed_elements=[{"index": 1, "text": "Old button"}],
indexed_points=[[1, 2]],
)
async def execute(name, args, tc_id, current, **kwargs):
if name == PC:
return ToolExecutionResult(
tool_name=name,
tool_call_id=tc_id,
status="success",
screenshot_bytes=b"pc-after",
ui_elements_text="PC controls",
text_summary="PC result",
metadata={"plugin_id": "pc"},
raw_result=[
{"type": "text", "text": "PC controls"},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,cGM="},
},
],
)
current.indexed_elements = [{"index": 1, "text": "Fresh button"}]
current.indexed_points = [[10, 20]]
current.latest_screenshot = "fresh-phone.png"
return ToolExecutionResult(
tool_name=name,
tool_call_id=tc_id,
status="success",
text_summary="OK",
screenshot_bytes=b"fresh-phone",
ui_elements_text="Fresh button",
)
runner.executor = SimpleNamespace(
action_tool_names={PC, "click", "wait_for_delay"},
execute=AsyncMock(side_effect=execute),
)
async def capture(ctx, current, controller, **kwargs):
current.indexed_elements = [{"index": 1, "text": "Fresh button"}]
current.indexed_points = [[10, 20]]
return "fresh-phone.png", b"fresh-phone", "Fresh button"
capture_mock = AsyncMock(side_effect=capture)
monkeypatch.setattr(flash, "capture_screenshot_and_parse_ui", capture_mock)
return runner, state, capture_mock
def _call(name, call_id, **args):
return {"name": name, "id": call_id, "args": args}
async def _dispatch(runner, state, calls, image=None, tree=""):
messages = []
turn = flash._TurnRecord()
result = await runner._process_tool_calls(calls, state, messages, "", {}, image, tree, 0, turn)
return result, messages, turn
@pytest.mark.asyncio
async def test_continuous_pc_calls_do_not_capture_or_resend_the_phone(cross_target):
runner, state, capture = cross_target
for i in range(3):
result, messages, _ = await _dispatch(runner, state, [_call(PC, str(i))])
assert result[1] is None
assert "不是安卓画面" in result[2]
assert "data:image/png;base64,cGM=" in str(messages)
assert not state.indexed_elements and not state.indexed_points
assert state.latest_screenshot is None
capture.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"args", [{"target": 1}, {"target": [100, 200], "target_description": "Button"}]
)
async def test_stale_phone_action_and_remainder_are_deferred_until_new_decision(cross_target, args):
runner, state, capture = cross_target
calls = [
_call(PC, "pc"),
_call("click", "tap", **args),
_call("report_task_status", "end", status="completed"),
]
result, messages, turn = await _dispatch(runner, state, calls, b"old-phone", "Old button")
assert result[0] is None
assert result[1:3] == (b"fresh-phone", "Fresh button")
assert [c.args[0] for c in runner.executor.execute.call_args_list] == [PC]
assert [m.tool_call_id for m in messages if isinstance(m, ToolMessage)] == ["pc", "tap", "end"]
assert all("未执行" in str(m.content) for m in messages[-2:])
assert [action[0] for action in turn.actions] == [PC]
capture.assert_awaited_once()
# A subsequent model decision sees the new indices; the old index snapshot
# from the mixed batch must not leak into this new click.
await _dispatch(runner, state, [_call("click", "fresh-tap", target=1)], *result[1:3])
assert runner.executor.execute.call_args.kwargs["index_elements"][0]["text"] == "Fresh button"
capture.assert_awaited_once()
@pytest.mark.asyncio
async def test_explicit_refresh_executes_wait_once_but_defers_same_turn_click(cross_target):
runner, state, capture = cross_target
await _dispatch(runner, state, [_call(PC, "pc")])
result, messages, _ = await _dispatch(
runner,
state,
[
_call("wait_for_delay", "observe", time_in_ms=0),
_call("click", "tap", target=1),
],
)
assert [c.args[0] for c in runner.executor.execute.call_args_list] == [PC, "wait_for_delay"]
assert result[1:3] == (b"fresh-phone", "Fresh button")
assert messages[-1].status == "error" and messages[-1].tool_call_id == "tap"
assert not runner._android_observation_stale
capture.assert_not_awaited() # The wait result already contains a fresh phone observation.
@pytest.mark.asyncio
async def test_failed_pc_call_also_invalidates_the_phone_without_a_capture(cross_target):
runner, state, capture = cross_target
runner.executor.execute.side_effect = RuntimeError("PC connection lost")
result, messages, _ = await _dispatch(runner, state, [_call(PC, "pc")])
assert result[1] is None and runner._android_observation_stale
assert state.latest_screenshot is None and state.indexed_elements == []
assert messages[0].status == "error"
capture.assert_not_awaited()
@pytest.mark.asyncio
async def test_failed_phone_refresh_never_dispatches_stale_click(cross_target):
runner, state, capture = cross_target
await _dispatch(runner, state, [_call(PC, "pc")])
capture.side_effect = DeviceObservationError("phone disconnected")
with pytest.raises(DeviceObservationError):
await _dispatch(runner, state, [_call("click", "tap", target=1)])
assert runner.executor.execute.await_count == 1
assert runner._android_observation_stale
@pytest.mark.asyncio
@pytest.mark.parametrize("intervention", ["guidance", "resume", "resume_with_guidance"])
async def test_user_intervention_refreshes_phone_before_next_model_decision(
cross_target, tmp_path, intervention
):
runner, state, capture = cross_target
runner.max_turns, runner.goal = 3, "cross-target task"
runner._init_llm = Mock(return_value=Mock())
runner._get_tools = Mock(return_value=[SimpleNamespace(name="report_task_status")])
runner._build_ledger = Mock(return_value=TranscriptLedger())
runner._render_system_prompt = Mock(return_value="test")
runner._read_injected_instruction = AsyncMock(
side_effect=[
(None, None),
("按新指导继续", None) if intervention != "resume" else (None, None),
]
)
if intervention.startswith("resume"):
control = Mock(spec=TaskControl)
control.directory = tmp_path
control.checkpoint = AsyncMock(side_effect=[False, True])
async def uninterrupted(operation, **kwargs):
return False, await operation
control.run_interruptibly = AsyncMock(side_effect=uninterrupted)
runner.ctx.task_control = control
seen = []
answers = [
AIMessage(content="Observe PC", tool_calls=[_call(PC, "pc")]),
AIMessage(
content="Done", tool_calls=[_call("report_task_status", "end", status="completed")]
),
]
async def model(llm, tools, messages):
seen.append(messages[-1])
return answers.pop(0)
runner._invoke_model = AsyncMock(side_effect=model)
result = await runner._run(state)
assert result["status"] == "completed"
assert capture.await_count == 2 # Initial phone observation + one refresh for the intervention.
assert "Fresh button" in str(seen[-1].content)
assert "不是安卓画面" not in str(seen[-1].content)
assert not runner._android_observation_stale
@@ -141,6 +141,74 @@ def _transcript_state(**overrides):
return state
@pytest.mark.asyncio
@pytest.mark.parametrize("exploration", [False, True])
@pytest.mark.parametrize("transcript", [False, True])
async def test_plugin_result_is_delivered_with_pc_image_and_separate_phone_observation(
tmp_path, exploration, transcript, monkeypatch
):
from langchain_openai import ChatOpenAI
from PIL import Image
ctx = _transcript_ctx()
ctx.exploration_id = "explore" if exploration else None
monkeypatch.setattr(
"artemis.exploration.runtime.exploration_messages",
lambda _: [SystemMessage(content="Explore"), HumanMessage(content="Guidance")],
)
ctx.data_engine = MagicMock()
ctx.data_engine.base_dir = str(tmp_path)
pc = tmp_path / "pc.jpg"
Image.new("RGB", (200, 100), "blue").save(pc)
ctx.data_engine.get_image_path.return_value = pc
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=transcript))
ledger = node._ensure_transcript_ledger()
tool = "plug_test_get_app_state"
ledger.stage_turn(
[
AIMessage(content="", tool_calls=[{"name": tool, "args": {}, "id": "pc_call"}]),
ToolMessage(content="Action Recorded", tool_call_id="pc_call"),
]
)
report = {
"status": "dispatched",
"execution": [
{
"action": tool,
"tool_call_id": "pc_call",
"plugin_id": "test",
"plugin_status": "success",
"result": "PC Address bar",
"post_image_name": "pc",
}
],
}
state = _transcript_state(structured_decisions="[]", last_execution_result=report)
messages = await node._build_prompt(state, SCREENSHOT_B64, [], "PHONE_ELEMENTS", 2, [], "")
if transcript:
output = next(m for m in messages if isinstance(m, ToolMessage))
assert output.tool_call_id == "pc_call" and "PC Address bar" in str(output.content)
else:
assert "PC Address bar" in str(messages)
assert base64.b64encode(pc.read_bytes()).decode() in str(messages)
assert SCREENSHOT_B64 not in str(messages) and "PHONE_ELEMENTS" in str(messages)
wire = ChatOpenAI(model="fixture", api_key="fixture", use_responses_api=True, store=False)
payload = wire._get_request_payload(messages)["input"]
assert len([x for x in payload if x.get("type") == "function_call_output"]) == int(transcript)
guided = await node._build_prompt(
_transcript_state(
structured_decisions="[]", last_execution_result=report, injected_instruction="回到手机"
),
SCREENSHOT_B64,
[],
"PHONE_ELEMENTS",
3,
[],
"",
)
assert SCREENSHOT_B64 in str(guided)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"exploration,plugins", [(False, False), (True, False), (False, True), (True, True)]
@@ -415,3 +483,38 @@ async def test_flag_off_keeps_legacy_two_message_build_and_no_ledger():
assert isinstance(captured[0][0], SystemMessage)
assert isinstance(captured[0][1], HumanMessage)
assert ctx.transcript_ledger is None
@pytest.mark.asyncio
async def test_exploration_guidance_appends_without_rewriting_static_prefix(monkeypatch):
ctx = _transcript_ctx()
ctx.exploration_id = "explore"
guidance = ["initial guidance"]
monkeypatch.setattr(
"artemis.exploration.runtime.exploration_messages",
lambda _: [SystemMessage(content="rules"), HumanMessage(content=guidance[0])],
)
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True))
state = _transcript_state()
first = await node._build_prompt_transcript(state, "", "", 1, [], "")
prefix = first[0].content
assert "initial guidance" not in prefix
assert "initial guidance" in str(first[-1].content)
node._stage_transcript_turn(first)
second = await node._build_prompt_transcript(state, "", "", 2, [], "")
assert second[0].content == prefix
assert "initial guidance" not in str(second[-1].content)
node._stage_transcript_turn(second)
guidance[0] = "new direction"
third = await node._build_prompt_transcript(state, "", "", 3, [], "")
assert third[0].content == prefix
assert "new direction" in str(third[-1].content)
# An interrupted/uncommitted build must not acknowledge away the guidance.
retried = await node._build_prompt_transcript(state, "", "", 3, [], "")
assert "new direction" in str(retried[-1].content)
node._stage_transcript_turn(retried)
await node._build_prompt_transcript(state, "", "", 4, [], "")
ctx.transcript_ledger.freeze_turns(3, [HumanMessage(content="compressed history")])
compacted = await node._build_prompt_transcript(state, "", "", 4, [], "")
assert compacted[0].content == prefix
assert "new direction" in str(compacted[-1].content)
+46
View File
@@ -106,6 +106,7 @@ async def test_summarizer_dispatches_visual_lens(mock_context):
exec_outcome="dispatched",
data_engine_step_id="12345678-1234-5678-1234-567812345678",
focus=None,
receipt_only=False,
)
# No direct capsule write from the node: the lens owns the versioned
# summary write when its background job completes.
@@ -217,3 +218,48 @@ async def test_summarizer_noop_without_step_id(mock_context):
assert result == {}
mock_context.step_memory.dispatch.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("target", ["android", "mixed", "computer"])
async def test_plugin_summary_uses_actual_result_and_pc_evidence(mock_context, tmp_path, target):
pc = tmp_path / "pc.jpg"
pc.write_bytes(b"PC_IMAGE")
mock_context.execution_target = target
mock_context.data_engine.get_image_path.return_value = pc
state = DummyState(
current_step_id="step",
last_execution_result={
"status": "dispatched",
"execution": [
{
"action": "plug_test_get_app_state",
"plugin_id": "test",
"result": "来源:电脑插件 / Browser window",
"post_image_name": "pc",
}
],
},
)
await SummarizerNode(mock_context)(state)
kwargs = mock_context.step_memory.dispatch.call_args.kwargs
assert kwargs["pre_img_bytes"] is None
assert kwargs["post_img_bytes"] == b"PC_IMAGE"
assert "Browser window" in kwargs["exec_outcome"]
# Cancelled/invalid calls can lack plugin metadata; the tool name still
# identifies the resource boundary and must not fall back to the phone.
mock_context.step_memory.reset_mock()
state.last_execution_result = {
"status": "failed",
"execution": [
{
"action": "plug_test_get_app_state",
"result": "操作已打断,结果不确定。",
}
],
}
await SummarizerNode(mock_context)(state)
kwargs = mock_context.step_memory.dispatch.call_args.kwargs
assert kwargs["pre_img_bytes"] is None and kwargs["post_img_bytes"] is None
assert "打断" in kwargs["exec_outcome"]
+56 -9
View File
@@ -40,9 +40,9 @@ def text_output(text):
@pytest.fixture
def transport_model(monkeypatch):
import langchain_openai
from langchain_openai import ChatOpenAI
import openai
original = langchain_openai.ChatOpenAI
requests = []
outputs = []
@@ -52,16 +52,16 @@ def transport_model(monkeypatch):
200, json=response_body(outputs.pop(0) if outputs else text_output("OK"))
)
client = httpx.AsyncClient(transport=httpx.MockTransport(handle))
monkeypatch.setattr(
langchain_openai,
"ChatOpenAI",
lambda **kwargs: original(**kwargs, http_async_client=client),
openai,
"DefaultAsyncHttpxClient",
lambda **kwargs: httpx.AsyncClient(transport=httpx.MockTransport(handle), **kwargs),
)
monkeypatch.setattr(settings, "OPENAI_API_KEY", SecretStr("test-secret"))
monkeypatch.setattr(settings, "OPENAI_BASE_URL", "https://responses.example/v1")
monkeypatch.delenv("ARTEMIS_FAKE_LLM", raising=False)
model = ModelFactory.create_model(ModelEndpoint(model_name="test-model", timeout_seconds=23))
assert isinstance(model, ChatOpenAI)
return model, requests, outputs
@@ -284,7 +284,10 @@ def test_video_text_fallback_extracts_responses_blocks():
@pytest.mark.asyncio
async def test_wrapper_structured_output_uses_responses_function_call(transport_model):
@pytest.mark.parametrize("mode", ["parsed", "raw", "pipeline"])
async def test_wrapper_structured_output_uses_responses_function_call(
transport_model, monkeypatch, mode
):
class Verdict(BaseModel):
ok: bool
@@ -302,9 +305,53 @@ async def test_wrapper_structured_output_uses_responses_function_call(transport_
]
)
wrapper = RobustChatModelWrapper(model, endpoint=ModelEndpoint(model_name="test-model"))
result = await wrapper.with_structured_output(Verdict).ainvoke("Check")
assert result.ok is True
metered = []
monkeypatch.setattr(
"artemis.services.llm.record_llm_usage",
lambda engine, response, **kw: metered.append(response),
)
structured = wrapper.with_structured_output(Verdict, include_raw=mode == "raw")
if mode == "pipeline":
from langchain_core.runnables import RunnableLambda
result = await (structured | RunnableLambda(lambda value: value.ok)).ainvoke("Check")
assert result is True
else:
result = await structured.complete("Check")
assert (result["parsed"] if mode == "raw" else result).ok is True
if mode == "raw":
assert result["parsing_error"] is None
assert result["raw"].tool_calls
payload = json.loads(requests[0].content)
assert requests[0].url.path == "/v1/responses"
assert payload["tools"][0]["name"] == "Verdict"
assert payload.get("text", {}).get("format", {}).get("type") != "json_schema"
from artemis.llm.usage import usage_from_message
assert len(metered) == 1
assert usage_from_message(metered[0])["total_tokens"] == 13
@pytest.mark.asyncio
async def test_cache_key_is_stable_for_exploration_but_separates_roles(
transport_model, monkeypatch
):
from types import SimpleNamespace
model, requests, _ = transport_model
engine = SimpleNamespace(current_session_id="run-1")
ctx = SimpleNamespace(data_engine=engine, exploration_id="exploration-1")
monkeypatch.setattr("artemis.services.llm.record_llm_usage", lambda *a, **k: None)
wrapper = RobustChatModelWrapper(
model, ctx, endpoint=ModelEndpoint(model_name="test-model"), role="operator"
)
await wrapper.ainvoke([HumanMessage(content="first")])
engine.current_session_id = "resumed-run-2"
await wrapper.ainvoke([HumanMessage(content="first"), HumanMessage(content="next")])
wrapper.role = "checker"
await wrapper.ainvoke([HumanMessage(content="verify")])
payloads = [json.loads(r.content) for r in requests]
assert payloads[0]["prompt_cache_key"] == payloads[1]["prompt_cache_key"]
assert payloads[1]["prompt_cache_key"] != payloads[2]["prompt_cache_key"]
assert "exploration-1" not in payloads[0]["prompt_cache_key"]
assert payloads[1]["input"][:1] == payloads[0]["input"]
+2 -2
View File
@@ -324,8 +324,8 @@ async def test_v2_observation_without_task_context_never_starts_recording(monkey
[
("computer", False, True),
("computer", True, False),
("android", False, False),
("mixed", False, False),
("android", False, True),
("mixed", False, True),
],
)
async def test_pro_cross_window_evidence_does_not_reuse_another_source_before_image(
+212
View File
@@ -0,0 +1,212 @@
"""Cross-device results must reach the model and history on their own resource."""
import base64
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_openai import ChatOpenAI
from PIL import Image
from artemis.context import ArtemisContext
from artemis.agents.validator.execution_loop import _process_action
from artemis.agents.validator.tool_declarations import ToolExecutionResult
from artemis.data_engine.engine import DataEngine
from artemis.data_engine.history_reader import OfflineHistoryReader
from artemis.data_engine.models import ImageRecord, SessionMetadata, StepRecord
from artemis.data_engine.storage import StorageManager
from artemis.memory.transcript import TranscriptLedger
from artemis.plugins.results import plugin_result_messages
from artemis.tools.history.screenshot import load_step_screenshot
def _action(call_id, name="plug_test_get_app_state", **kwargs):
return dict(
action=name,
tool_call_id=call_id,
plugin_id="test",
plugin_status="success",
result=f"来源:电脑插件 / {call_id}\nAddress bar",
**kwargs,
)
def _stage(ledger, actions):
ledger.stage_turn(
[
AIMessage(
content="",
tool_calls=[
{"name": a["action"], "id": a["tool_call_id"], "args": {}} for a in actions
],
),
*[
ToolMessage(content="Action Recorded", tool_call_id=a["tool_call_id"])
for a in actions
],
]
)
@pytest.mark.parametrize("image_format", ["JPEG", "PNG", "WEBP"])
def test_burst_results_reach_responses_once_with_images_before_compression(tmp_path, image_format):
image = tmp_path / "pc.jpg"
Image.new("RGB", (200, 100), "blue").save(image, format=image_format)
engine = SimpleNamespace(get_image_path=lambda _: image)
actions = [_action(f"call_{i}", post_image_name="pc") for i in range(4)]
# A long accessibility tree and the tail must both survive delivery.
actions[0]["result"] += " UI node" * 12000 + " END_OF_TREE"
actions[1]["plugin_status"] = "error"
actions[1]["result"] = "窗口已关闭,请重新观察。"
actions[2].pop("plugin_status")
actions[2].pop("result")
actions[2].pop("post_image_name")
actions[2]["attempts"] = ["Skipped"]
report = {"status": "failed", "execution": actions}
ledger = TranscriptLedger(image_scrub_depth=1, pending_grace_steps=0)
_stage(ledger, actions)
ledger.commit_staged(
step_key="step1",
validator_result=report,
tool_results=plugin_result_messages(report, engine),
)
messages = ledger.render([])
outputs = [m for m in messages if isinstance(m, ToolMessage)]
assert [m.tool_call_id for m in outputs] == [f"call_{i}" for i in range(4)]
assert [m.status for m in outputs] == ["success", "error", "error", "success"]
assert "END_OF_TREE" in str(outputs[0].content)
assert "未完成或未执行" in str(outputs[2].content)
assert "Action Recorded" not in str(messages)
wire = ChatOpenAI(model="fixture", api_key="fixture", use_responses_api=True, store=False)
payload = wire._get_request_payload(messages)["input"]
assert len([item for item in payload if item.get("type") == "function_call_output"]) == 4
assert str(payload).count(base64.b64encode(image.read_bytes()).decode()) == 3
assert f"data:{Image.MIME[image_format]};base64," in str(payload)
# No duplicate outputs at the following turn; old images are eligible for scrubbing again.
ledger.commit_staged(
validator_result=report, tool_results=plugin_result_messages(report, engine)
)
later = ledger.render([])
assert len([m for m in later if isinstance(m, ToolMessage)]) == 4
assert str(later).count(base64.b64encode(image.read_bytes()).decode()) < 3
def test_missing_plugin_screenshot_keeps_real_result_and_reports_missing_evidence(tmp_path):
action = _action("call", post_image_name="missing")
messages = plugin_result_messages(
{"execution": [action]}, SimpleNamespace(get_image_path=lambda _: tmp_path / "missing.jpg")
)["call"]
assert "Address bar" in str(messages)
assert "截图无法读取" in str(messages)
assert not any(isinstance(m, HumanMessage) for m in messages)
@pytest.mark.asyncio
async def test_validator_preserves_model_call_id_and_actual_plugin_result(monkeypatch):
name = "plug_test_get_app_state"
plugins = SimpleNamespace(
bindings={name: None},
execute=AsyncMock(
return_value=ToolExecutionResult(
tool_name=name,
tool_call_id="original",
status="success",
text_summary="PC tree",
screenshot_bytes=b"pc",
metadata={"plugin_id": "test", "plugin_status": "success"},
)
),
)
monkeypatch.setattr("artemis.plugins.runtime.get_plugin_runtime", lambda _: plugins)
ctx = ArtemisContext.model_construct()
outcome = await _process_action(
SimpleNamespace(ctx=ctx),
None,
None,
{"action": name, "args": {"app": "browser"}, "tool_call_id": "original"},
name,
"phone",
burst=False,
)
plugins.execute.assert_awaited_once_with(name, {"app": "browser"}, "original")
assert outcome.action_item["tool_call_id"] == "original"
assert outcome.action_item["result"] == "PC tree"
assert base64.b64decode(outcome.post_screenshot_b64) == b"pc"
@pytest.mark.parametrize("live", [False, True])
def test_old_mixed_step_history_returns_pc_post_and_never_phone_pre(tmp_path, live):
(tmp_path / "images").mkdir()
for name, size in [("phone", (100, 200)), ("pc", (200, 100))]:
Image.new("RGB", size).save(tmp_path / "images" / f"{name}.jpg")
storage = StorageManager(tmp_path / "data_engine.db", tmp_path)
session_id = uuid4()
storage.create_session(SessionMetadata(session_id=session_id, initial_goal="fixture"))
for name in ("phone", "pc"):
storage.create_image(ImageRecord(image_name=name))
storage.create_step(
StepRecord(
step_id=uuid4(),
session_id=session_id,
step_number=24,
pre_image_name="phone",
last_execution_result={"execution": [_action("original", post_image_name="pc")]},
)
)
reader = OfflineHistoryReader(tmp_path / "data_engine.db", tmp_path, session_id)
if live:
engine = DataEngine.__new__(DataEngine)
engine.get_step_record = reader.get_step_record
engine.get_image_path = reader.get_image_path
reader = engine
assert load_step_screenshot(reader, 24, "pre").image_bytes is None
assert (
load_step_screenshot(reader, 24, "post").image_bytes
== (tmp_path / "images/pc.jpg").read_bytes()
)
def test_cross_resource_burst_has_no_single_step_image():
from artemis.data_engine.step_evidence import step_image_name
record = {
"pre_image_name": "phone",
"post_image_name": "pc",
"last_execution_result": {
"execution": [{"action": "tap"}, _action("pc", post_image_name="pc")]
},
}
assert step_image_name(record, "pre") is None
assert step_image_name(record, "post") is None
@pytest.mark.asyncio
async def test_interrupted_plugin_call_is_not_reported_as_executed(monkeypatch):
from artemis.runtime.task_control import TaskControl
name = "plug_test_click"
plugins = SimpleNamespace(bindings={name: None}, execute=AsyncMock())
monkeypatch.setattr("artemis.plugins.runtime.get_plugin_runtime", lambda _: plugins)
control = TaskControl.__new__(TaskControl)
async def interrupt(operation, **_kwargs):
operation.close()
return True, None
control.run_interruptibly = interrupt
outcome = await _process_action(
SimpleNamespace(ctx=SimpleNamespace(task_control=control)),
None,
None,
{"action": name, "args": {}, "tool_call_id": "interrupted"},
name,
"phone",
burst=False,
)
assert not outcome.success
messages = plugin_result_messages({"execution": [outcome.action_item]}, None)["interrupted"]
assert messages[0].status == "error"
assert "打断" in str(messages[0].content)
plugins.execute.assert_not_awaited()
+83 -7
View File
@@ -178,7 +178,11 @@ def test_server_substitutions_do_not_use_shell_or_expand_unknown_values(tmp_path
async def test_skill_load_and_reference_cannot_read_outside_package(store):
enable(store, manifest())
runtime = PluginRuntime(store=store)
assert {t.name for t in runtime.declarations()} == {"plugin_load", "plugin_read_file"}
assert {t.name for t in runtime.declarations()} == {
"plugin_load",
"plugin_read_file",
"plugin_read_result",
}
result = await runtime.call("plugin_load", {"plugin_id": "test-plugin"})
assert result.status == "success" and "先观察" in result.text()
assert (
@@ -659,7 +663,7 @@ server.run()
@pytest.mark.asyncio
@pytest.mark.parametrize("target", ["computer", "mixed"])
@pytest.mark.parametrize("target", ["computer", "mixed", "android"])
async def test_flash_plugin_images_never_replace_the_mixed_phone_observation(monkeypatch, target):
from types import SimpleNamespace
from unittest.mock import Mock, AsyncMock
@@ -689,6 +693,16 @@ async def test_flash_plugin_images_never_replace_the_mixed_phone_observation(mon
screenshot_bytes=b"desktop-after",
ui_elements_text="desktop-tree",
text_summary="Desktop observation",
raw_result=[
{"type": "text", "text": "Desktop observation"},
{
"type": "image_url",
"image_url": {
"url": "data:image/jpeg;base64,"
+ base64.b64encode(b"desktop-after").decode()
},
},
],
metadata={"plugin_id": "test-plugin"},
)
),
@@ -696,12 +710,13 @@ async def test_flash_plugin_images_never_replace_the_mixed_phone_observation(mon
runner._build_action_record = Mock(return_value={"action": name})
runner._record_action_step = Mock(return_value="step")
state = SimpleNamespace(latest_screenshot="old-phone-path")
messages = []
result = await runner._execute_and_record_action(
name,
{},
"tc",
state,
[],
messages,
"",
{},
b"phone-before",
@@ -714,10 +729,17 @@ async def test_flash_plugin_images_never_replace_the_mixed_phone_observation(mon
"",
b"desktop-after",
)
if target == "mixed":
assert result[:2] == (b"phone-after", "phone-tree-after")
assert state.latest_screenshot == "phone-path"
capture.assert_awaited_once()
assert messages[0].tool_call_id == "tc"
assert "Desktop observation" in str(messages[0].content)
assert base64.b64encode(b"desktop-after").decode() in str(messages)
if target != "computer":
assert result[0] is None
assert "安卓旧截图、元素编号和坐标已失效" in result[1]
assert state.latest_screenshot is None
assert state.indexed_elements == []
assert state.indexed_points == []
assert runner._android_observation_stale
capture.assert_not_awaited()
else:
assert result[:2] == (b"desktop-after", "desktop-tree")
capture.assert_not_awaited()
@@ -1012,3 +1034,57 @@ async def test_plugin_image_text_stays_with_its_own_observation(store, tmp_path,
assert engine.storage.get_image(names[1]).ui_tree == "changed"
finally:
await runtime.aclose()
@pytest.mark.asyncio
async def test_long_plugin_result_roundtrips_through_task_scoped_reader(store, tmp_path):
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
from mcp.types import Tool, ToolAnnotations
from artemis.config.constants import PLUGIN_PROMPT_TEXT_CHARS
plugin = manifest()
enable(store, plugin)
engine = MagicMock(base_dir=tmp_path / "task-a")
ctx = SimpleNamespace(data_engine=engine)
runtime = PluginRuntime(store=store, ctx=ctx)
tool = Tool(
name="observe",
inputSchema={"type": "object"},
annotations=ToolAnnotations(readOnlyHint=True),
)
original = "private-value\n" + "node 中文\n" * 9000 + "END"
runtime.bindings["plug_test_observe"] = (plugin.id, tool)
session = SimpleNamespace(
call=AsyncMock(
return_value=CallToolResult(content=[TextContent(type="text", text=original)])
)
)
runtime.sessions[plugin.id] = session
try:
outcome = await runtime.call("plug_test_observe", {})
assert outcome.status == "success"
assert outcome.text_ref == outcome.call_id
assert runtime.is_read_only("plug_test_observe")
shown = runtime.content_blocks(outcome)[0]["text"]
assert len(shown) <= PLUGIN_PROMPT_TEXT_CHARS
assert outcome.text_ref in shown
text = outcome.text()
pages = []
for offset in range(0, len(text), PLUGIN_PROMPT_TEXT_CHARS):
page = await runtime.call(
"plugin_read_result", {"call_id": outcome.text_ref, "offset": offset}
)
assert page.status == "success"
delivered = runtime.content_blocks(page)[0]["text"]
pages.append(delivered.split("\n", 1)[1])
assert "".join(pages) == text
assert "private-value" not in text and "已隐藏密钥" in text
assert text.endswith("END")
engine.base_dir = tmp_path / "task-b"
inaccessible = await runtime.call("plugin_read_result", {"call_id": outcome.text_ref})
assert inaccessible.status == "error"
session.call.assert_awaited_once()
finally:
runtime.sessions.clear()
await runtime.aclose()
+134
View File
@@ -0,0 +1,134 @@
"""Real result preservation, bounded context, and zero-call observation receipts."""
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from artemis.config.constants import PLUGIN_PROMPT_TEXT_CHARS, PLUGIN_SUMMARY_TEXT_CHARS
from artemis.plugins.prompt_budget import bounded_result_text, read_result_text, save_result_text
from artemis.plugins.models import PluginCallResult
from artemis.plugins.runtime import PluginRuntime
def test_long_plugin_result_is_bounded_but_paged_evidence_is_lossless(tmp_path):
engine = SimpleNamespace(base_dir=tmp_path)
call_id = "a" * 32
original = "Address bar\n" + "关键控件与真实观察\n" * 10000 + "ERROR: window unavailable"
save_result_text(engine, call_id, original)
prompt = bounded_result_text(original, call_id=call_id)
assert len(prompt) <= PLUGIN_PROMPT_TEXT_CHARS
assert "Address bar" in prompt and "ERROR: window unavailable" in prompt
assert call_id in prompt and "节选" in prompt
recovered = "".join(
read_result_text(engine, call_id, offset).split("\n", 1)[1]
for offset in range(0, len(original), PLUGIN_PROMPT_TEXT_CHARS)
)
assert recovered == original
assert "已到结尾" in read_result_text(engine, call_id, len(original))
with pytest.raises(ValueError):
read_result_text(engine, call_id, len(original) + 1)
@pytest.mark.parametrize("bad", ["../outside", "a" * 31, "a" * 32 + ".txt", "A" * 32])
def test_plugin_evidence_reader_rejects_arbitrary_paths(tmp_path, bad):
with pytest.raises(ValueError):
read_result_text(SimpleNamespace(base_dir=tmp_path), bad)
def test_flash_plugin_output_is_bounded_without_losing_image_or_error():
outcome = PluginCallResult(
status="error",
plugin_id="pc",
text_ref="b" * 32,
content=[
{"type": "text", "text": "X" * 50000 + "Permission denied"},
{"type": "image", "mimeType": "image/png", "data": "AA=="},
],
)
result = PluginRuntime.content_blocks(outcome)
assert len(result[0]["text"]) <= PLUGIN_PROMPT_TEXT_CHARS
assert "Permission denied" in result[0]["text"]
assert result[1]["image_url"]["url"] == "data:image/png;base64,AA=="
assert len(outcome.content[0]["text"]) > PLUGIN_PROMPT_TEXT_CHARS
def test_read_only_observation_needs_no_summary_model(monkeypatch):
from artemis.agents.flash.summarizer import VisualStepSummarizer
model = MagicMock()
monkeypatch.setattr("artemis.agents.flash.summarizer.get_llm", lambda *a, **k: model)
engine = MagicMock()
service = VisualStepSummarizer(SimpleNamespace(data_engine=engine))
service.dispatch(
step_number=2,
action_name="plug_test_get_app_state",
action_args={},
pre_img_bytes=None,
post_img_bytes=b"actual screenshot",
exec_outcome="UI" * 20000,
data_engine_step_id="step-2",
action_key="call-2",
receipt_only=True,
)
assert not service.is_pending("call-2")
summary = service.get_summary("call-2")
assert summary and "不代表视觉验收通过" in summary
assert len(summary) < PLUGIN_SUMMARY_TEXT_CHARS + 150
assert service.get_job_payload("call-2")["post_img_bytes"] is None
assert service.get_job_payload("call-2")["data_engine_step_id"] == "step-2"
assert not service._pending_tasks
model.ainvoke.assert_not_called()
assert engine.update_step_summary.call_args.kwargs["source"] == "tool_receipt"
def test_assistant_message_count_does_not_change_exploration_context(monkeypatch):
from artemis.exploration.runtime import conversation_message
session = {"goal": "inspect website", "message_count": 10}
history = [{"sequence": 1, "role": "user", "text": "inspect website"}]
store = SimpleNamespace(
get=lambda _: session, recent_messages=lambda *a, **k: history, case=lambda _: None
)
monkeypatch.setattr("artemis.exploration.runtime.explorations", store)
monkeypatch.setattr("artemis.exploration.editorial.prior_context", lambda *a, **k: [])
ctx = SimpleNamespace(exploration_id="explore", exploration_run_id="run")
first = conversation_message(ctx).content
session["message_count"] = 99
assert conversation_message(ctx).content == first
history.append({"sequence": 99, "role": "user", "text": "return home"})
assert "return home" in conversation_message(ctx).content
def test_observation_steps_do_not_force_plan_bookkeeping():
from artemis.agents.operator.prompts import unwritten_action_streak
write = {"action_taken": "click"}
read = {
"action_taken": "get_app_state",
"last_execution_result": {"execution": [{"read_only": True}]},
}
assert unwritten_action_streak([write, read, read, read, read]) == 1
assert unwritten_action_streak([write] * 4) == 4
def test_flash_plugin_summary_caps_text_before_model_submission(monkeypatch):
from artemis.agents.flash.summarizer import VisualStepSummarizer
monkeypatch.setattr("artemis.agents.flash.summarizer.get_llm", lambda *a, **k: MagicMock())
service = VisualStepSummarizer(SimpleNamespace(data_engine=None))
submit = MagicMock()
monkeypatch.setattr(service, "submit", submit)
service.dispatch(
step_number=1,
action_name="plug_test_click",
action_args={},
pre_img_bytes=None,
post_img_bytes=b"image",
exec_outcome="target clicked" * 10000,
post_ui_elements_text="control tree" * 10000,
)
payload = submit.call_args.args[1]
assert len(payload["exec_outcome"]) <= PLUGIN_SUMMARY_TEXT_CHARS
assert len(payload["post_ui_elements_text"]) <= PLUGIN_SUMMARY_TEXT_CHARS
assert payload["post_img_bytes"] == b"image"
+35
View File
@@ -1,6 +1,7 @@
"""Per-task archive quality overrides outrank JSON and saved environment defaults."""
import json
from types import SimpleNamespace
import pytest
@@ -77,3 +78,37 @@ def test_worker_env_keeps_task_quality_separate_from_saved_defaults(monkeypatch)
{}, "test-run", None, "fixture", "flash", AdbTarget(AdbEndpoint.local())
)
assert "ARTEMIS_TASK_RECORDING_CONFIG" not in clean_environment
def test_sdk_preserves_task_recording_scope_through_recorder(tmp_path, monkeypatch):
from artemis.plugins.models import EvidenceSource
from artemis.plugins.recording import PluginRecordings, capture_rectangle
from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder
monkeypatch.setattr(
settings,
"ARTEMIS_TASK_RECORDING_CONFIG",
{"computer_scope": "window", "codec": "h264", "fps": 7, "max_long_edge": 960},
)
builder = AgentConfigBuilder()
config = builder.build()
ctx = SimpleNamespace(
agent_config=config,
data_engine=SimpleNamespace(base_dir=tmp_path, session_start_time=100),
execution_setup=SimpleNamespace(record_video_enabled=True),
)
recorder = PluginRecordings(ctx)
source = EvidenceSource(
kind="computer",
target_id="fixture",
window={"left": 40, "top": 50, "width": 800, "height": 600},
screen={"left": 0, "top": 0, "width": 1920, "height": 1080},
)
assert recorder.config.computer_scope == "window"
assert capture_rectangle(source, recorder.config.computer_scope) == source.window
assert recorder.config.codec == "h264"
assert recorder.config.fps == 7
assert recorder.config.max_long_edge == 960
# Building another task must not share a mutable recording configuration.
config.recording.fps = 12
assert builder.build().recording.fps == 7
+340
View File
@@ -0,0 +1,340 @@
import asyncio
from copy import deepcopy
import json
from types import SimpleNamespace
from uuid import uuid4
import httpx
from langchain_core.messages import AIMessageChunk, HumanMessage
import pytest
from artemis.llm.diagnostics import (
ACTIVE_ATTEMPT,
RequestAttempt,
error_text,
input_images,
observe_response_headers,
)
from artemis.llm.router import ModelEndpoint
from artemis.services import llm
class Engine:
current_session_id = "diagnostic-test"
current_step_id = None
def __init__(self):
self.records = []
def record_trace(self, **record):
self.records.append(deepcopy(record))
return record.get("trace_id", uuid4())
def _publish(self, *_):
pass
@pytest.mark.asyncio
async def test_headers_without_body_timeout_then_retry_keeps_evidence(monkeypatch):
engine = Engine()
monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", engine)
monkeypatch.setattr(
llm, "retry_policy_for", lambda _: SimpleNamespace(delay_for=lambda _: 0, max_attempts=2)
)
llm._ENDPOINT_BREAKER._states.clear()
calls = 0
class StalledBody(httpx.AsyncByteStream):
async def __aiter__(self):
await asyncio.sleep(10)
yield b""
async def transport(request):
nonlocal calls
calls += 1
headers = {"x-request-id": f"server-{calls}", "set-cookie": "secret-cookie"}
return httpx.Response(
200, headers=headers, stream=StalledBody() if calls == 1 else httpx.ByteStream(b"ok")
)
async with httpx.AsyncClient(
transport=httpx.MockTransport(transport),
event_hooks={"response": [observe_response_headers]},
) as client:
class Model:
async def astream(self, *_args, **_kwargs):
async with client.stream(
"POST",
"https://example.test/responses",
headers={"Authorization": "Bearer secret-key"},
) as response:
await response.aread()
yield AIMessageChunk(
content="ready",
usage_metadata={"input_tokens": 10, "output_tokens": 2, "total_tokens": 12},
)
wrapper = llm.RobustChatModelWrapper(
Model(), endpoint=ModelEndpoint(timeout_seconds=0.05, max_retries=1)
)
result = await wrapper.complete([])
assert result.content == "ready"
snapshots = [r["payload"] for r in engine.records if r["type"] == "llm_diagnostic"]
failed = next(p for p in snapshots if p["phase"] == "failed")
final = snapshots[-1]
assert failed["http_status"] == 200
assert failed["server_request_id"] == "server-1"
assert failed["failure_phase"] == "waiting_output"
assert failed["error_type"] == "TimeoutError" and "0.05 秒" in failed["error"]
assert failed["usage"] is None and "first_output_at" not in failed
assert final["request_id"] == failed["request_id"]
assert final["attempt_id"] != failed["attempt_id"] and final["attempt"] == 2
assert final["phase"] == "completed" and final["usage"]["total_tokens"] == 12
assert (
final["started_at"] <= final["headers_at"] <= final["first_output_at"] <= final["ended_at"]
)
assert "secret" not in json.dumps(snapshots)
assert ACTIVE_ATTEMPT.get() is None
assert any(r["name"] == "llm_retry" and r["payload"]["error"] for r in engine.records)
def test_tool_deltas_are_counted_once_without_retaining_arguments():
engine = Engine()
attempt = RequestAttempt(
engine, request_id="tools", attempt=1, timeout=300, max_retries=1, metadata={}
)
deltas = [
AIMessageChunk(
content=[], tool_call_chunks=[{"name": "click", "args": "", "id": "c1", "index": 0}]
),
AIMessageChunk(
content=[{"type": "function_call", "arguments": '{"secret":'}],
tool_call_chunks=[{"args": '{"secret":', "index": 0}],
),
AIMessageChunk(content=[], tool_call_chunks=[{"args": '"private"}', "index": 0}]),
AIMessageChunk(
content=[],
tool_call_chunks=[{"name": "wait", "args": '{"seconds":1}', "id": "c2", "index": 1}],
),
]
combined = deltas[0]
for i, delta in enumerate(deltas):
attempt.chunk(delta)
if i:
combined += delta
attempt.chunk(AIMessageChunk(content=[], response_metadata={"status": "completed"}))
attempt.finish("completed", response=combined)
assert attempt.data["output_chunks"] == 4
assert attempt.data["tool_argument_chars"] == len('{"secret":"private"}{"seconds":1}')
assert attempt.data["tools"] == [
{"name": "click", "argument_chars": len('{"secret":"private"}')},
{"name": "wait", "argument_chars": len('{"seconds":1}')},
]
assert attempt.data["response_status"] == "completed"
assert "private" not in json.dumps(engine.records, default=str)
assert "secret" not in json.dumps(engine.records, default=str)
@pytest.mark.asyncio
async def test_timeout_during_tool_generation_keeps_last_output_and_tool_name(monkeypatch):
engine = Engine()
monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", engine)
llm._ENDPOINT_BREAKER._states.clear()
calls = 0
class Model:
async def astream(self, *_args, **_kwargs):
nonlocal calls
calls += 1
yield AIMessageChunk(
content=[],
tool_call_chunks=[{"name": "click", "args": '{"x":', "id": "c1", "index": 0}],
)
if calls == 1:
await asyncio.sleep(10)
yield AIMessageChunk(content=[], tool_call_chunks=[{"args": "1}", "index": 0}])
monkeypatch.setattr(
llm, "retry_policy_for", lambda _: SimpleNamespace(delay_for=lambda _: 0, max_attempts=2)
)
wrapper = llm.RobustChatModelWrapper(
Model(), endpoint=ModelEndpoint(timeout_seconds=0.03, max_retries=1, max_tokens=12000)
)
await wrapper.complete([])
failed = next(
r["payload"]
for r in engine.records
if r["type"] == "llm_diagnostic" and r["payload"]["phase"] == "failed"
)
assert failed["failure_phase"] == "generating_tool_arguments"
assert failed["last_output_at"] >= failed["first_output_at"]
assert failed["tools"] == [{"name": "click", "argument_chars": 5}]
assert failed["usage"] is None
assert failed["max_output_tokens"] == 12000
@pytest.mark.asyncio
async def test_concurrent_requests_do_not_mix_server_ids():
engine = Engine()
async def run(name):
attempt = RequestAttempt(
engine, request_id=name, attempt=1, timeout=5, max_retries=0, metadata={"model": name}
)
token = ACTIVE_ATTEMPT.set(attempt)
try:
await asyncio.sleep(0)
await observe_response_headers(httpx.Response(200, headers={"x-request-id": name}))
finally:
ACTIVE_ATTEMPT.reset(token)
return attempt.data
first, second = await asyncio.gather(run("one"), run("two"))
assert first["server_request_id"] == "one"
assert second["server_request_id"] == "two"
def test_error_redaction_and_image_headers():
import base64
import io
from PIL import Image
out = io.BytesIO()
Image.new("RGB", (32, 48)).save(out, "PNG")
message = HumanMessage(
content=[
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64," + base64.b64encode(out.getvalue()).decode()
},
}
]
)
assert input_images(([message],), {}) == [{"width": 32, "height": 48}]
text = error_text(
RuntimeError("Authorization: Bearer sk-my-secret https://host/path?token=private")
)
assert "sk-my-secret" not in text and "private" not in text
assert error_text(RuntimeError()) == "RuntimeError"
def test_snapshots_survive_out_of_order_writes_and_export(tmp_path):
from artemis.data_engine.models import TraceRecord
from artemis.data_engine.storage import StorageManager
from apps.admin_console.database.connection import db_session
from apps.admin_console.services.request_diagnostics import request_diagnostics
path = tmp_path / "data.db"
storage = StorageManager(db_path=path, base_trace_dir=tmp_path)
session_id, trace_id = uuid4(), uuid4()
with db_session(path) as conn:
conn.execute(
"INSERT INTO sessions(session_id,status) VALUES (?, 'completed')", (str(session_id),)
)
conn.commit()
for sequence, phase in [(3, "completed"), (2, "receiving_output"), (1, "waiting_output")]:
storage.create_trace(
TraceRecord(
trace_id=trace_id,
session_id=session_id,
type="llm_diagnostic",
name="llm_request",
timestamp=10 + sequence,
status="success" if sequence == 3 else "running",
payload={
"sequence": sequence,
"phase": phase,
"request_id": "call-one",
"started_at": 10,
"ended_at": 13 if sequence == 3 else None,
},
)
)
result = request_diagnostics(str(session_id), db_path=path)
assert len(result["requests"]) == 1
assert result["requests"][0]["phase"] == "completed"
assert result["requests"][0]["active"] is False
assert result["requests"][0]["timestamps_utc"]["started_at"].endswith("+00:00")
@pytest.mark.asyncio
async def test_cancellation_closes_attempt_and_does_not_retry(monkeypatch):
engine = Engine()
monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", engine)
llm._ENDPOINT_BREAKER._states.clear()
async def cancelled():
raise asyncio.CancelledError()
with pytest.raises(asyncio.CancelledError):
await llm._run_with_recovery(cancelled, max_retries=5)
assert engine.records[-1]["payload"]["phase"] == "cancelled"
assert ACTIVE_ATTEMPT.get() is None
@pytest.mark.asyncio
async def test_cancelling_retry_wait_does_not_leave_a_live_request(monkeypatch):
waiting = asyncio.Event()
class RetryEngine(Engine):
def record_trace(self, **record):
result = super().record_trace(**record)
if record["payload"].get("phase") == "retry_wait":
waiting.set()
return result
engine = RetryEngine()
monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", engine)
monkeypatch.setattr(
llm, "retry_policy_for", lambda _: SimpleNamespace(delay_for=lambda _: 60, max_attempts=2)
)
async def timeout():
raise TimeoutError()
task = asyncio.create_task(llm._run_with_recovery(timeout, max_retries=1))
await asyncio.wait_for(waiting.wait(), 2)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert engine.records[-1]["payload"]["phase"] == "cancelled"
assert "模型请求" in engine.records[-1]["payload"]["error"]
def test_diagnostic_api_limits_preview_but_downloads_complete_history(tmp_path, monkeypatch):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from apps.admin_console.database import connection
from apps.admin_console.routers.sessions import router
from artemis.data_engine.storage import StorageManager
path = tmp_path / "data.db"
StorageManager(db_path=path, base_trace_dir=tmp_path)
monkeypatch.setattr(connection, "DB_PATH", path)
with connection.db_session() as conn:
conn.execute("INSERT INTO sessions(session_id,status) VALUES ('task', 'running')")
for i in range(105):
value = {
"request_id": "one-request",
"attempt_id": str(i),
"started_at": i,
"phase": "completed" if i == 104 else "retry_wait",
}
conn.execute(
"INSERT INTO traces(trace_id,session_id,type,timestamp,payload) VALUES (?, 'task', 'llm_diagnostic', ?, ?)",
(str(i), i, json.dumps(value)),
)
conn.commit()
app = FastAPI()
app.include_router(router)
with TestClient(app) as client:
preview = client.get("/api/sessions/task/request-diagnostics")
assert preview.status_code == 200
assert len(preview.json()["requests"]) == 100
assert not any(r["active"] for r in preview.json()["requests"])
download = client.get("/api/sessions/task/request-diagnostics?download=true")
assert download.headers["content-disposition"].startswith("attachment;")
assert len(download.json()["requests"]) == 105
assert download.json()["session_id"] == "task"