feat: harden video analysis and runtime resilience

This commit is contained in:
somew1nd committed 2026-08-25 20:58:25 -07:00
1 parent 5ada543a09
commit bb16991f2c
55 files changed
+5023 -437

No files matched your search

+180 -23
View File
@@ -14,8 +14,8 @@
"""Unit tests for VisualStepSummarizer and Context Compressor in Flash profile."""
import asyncio
from unittest.mock import AsyncMock, Mock
from unittest.mock import AsyncMock, Mock, call
from uuid import uuid4
import pytest
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
@@ -65,27 +65,26 @@ async def test_summarizer_dispatch_and_caching(mock_context):
@pytest.mark.asyncio
async def test_summarizer_retrigger_stalled_steps(mock_context):
"""Verify that earlier stalled steps are re-triggered when subsequent steps arrive."""
async def test_summarizer_retries_independently(mock_context):
"""A failed summary retries without waiting for another action to arrive."""
summarizer = VisualStepSummarizer(mock_context)
attempt_count = 0
async def mock_ainvoke(messages):
nonlocal attempt_count
attempt_count += 1
# Fail the first attempt (Step 1 first call)
# Fail the first attempt, then return the requested step summary.
if attempt_count == 1:
raise asyncio.CancelledError("Simulated timeout")
# Check messages for step number
raise TimeoutError("Simulated timeout")
msg_str = str(messages)
if "Step 1" in msg_str:
return AIMessage(content="[Step 1 State: Recovered Step 1 summary]")
return AIMessage(content="[Step 2 State: Step 2 summary]")
raise AssertionError("Unexpected step")
summarizer._llm = Mock()
summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke)
# Dispatch step 1 (fails on first attempt)
# No Step 2 dispatch is needed to trigger the retry.
summarizer.dispatch(
step_number=1,
action_name="click",
@@ -95,22 +94,10 @@ async def test_summarizer_retrigger_stalled_steps(mock_context):
exec_outcome="Outcome 1",
)
await summarizer.flush()
assert not summarizer.has_summary(1)
# Dispatch step 2 (should detect stalled Step 1 and re-trigger)
summarizer.dispatch(
step_number=2,
action_name="swipe",
action_args={"action": "up"},
pre_img_bytes=b"pre2",
post_img_bytes=b"post2",
exec_outcome="Outcome 2",
)
await summarizer.flush()
assert summarizer.has_summary(1)
assert summarizer.has_summary(2)
assert "Recovered Step 1" in summarizer.get_summary(1)
assert attempt_count == 2
def test_context_compressor_with_ready_summaries(mock_context):
@@ -156,7 +143,10 @@ def test_context_compressor_with_ready_summaries(mock_context):
# Step 1 (ToolMessage 1): Image replaced with Step 1 summary, old XML pruned
assert len(messages[1].content) == 2
assert messages[1].content[0]["text"] == "Action 'click' completed."
assert messages[1].content[1]["text"] == "[Step 1 State: Tapped Search bar. Keyboard appeared.]"
assert messages[1].content[1]["text"] == (
"--- Historical Visual Transition ---\n"
"[Step 1 State: Tapped Search bar. Keyboard appeared.]"
)
# Step 2 (ToolMessage 2 - Latest live screen): Image and latest live XML MUST be preserved
assert len(messages[2].content) == 3
@@ -198,6 +188,173 @@ def test_context_compressor_graceful_fallback(mock_context):
assert messages[1].content[1]["type"] == "image_url"
def test_context_compressor_backfills_late_summary_idempotently(mock_context):
"""A pending image remains until its late summary replaces it."""
summarizer = VisualStepSummarizer(mock_context)
summarizer._step_inputs["action-1"] = {"step_number": 1}
messages = [
HumanMessage(
content=[
{"type": "text", "text": "Task: Search"},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_1"}},
]
),
ToolMessage(
tool_call_id="action-1",
name="click",
content=[
{"type": "text", "text": "Tapped Search."},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_2"}},
],
),
ToolMessage(
tool_call_id="action-2",
name="input_text",
content=[
{"type": "text", "text": "Typed query."},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_3"}},
],
),
]
compress_flash_messages(messages, summarizer=summarizer)
assert any(block["type"] == "image_url" for block in messages[1].content)
assert len(messages[1].content) == 2
summarizer._summaries["action-1"] = "Search field focused; keyboard appeared."
compress_flash_messages(messages, summarizer=summarizer)
compress_flash_messages(messages, summarizer=summarizer)
generated_blocks = [
block
for block in messages[1].content
if block.get("text", "").startswith("--- Historical Visual Transition ---")
]
assert len(generated_blocks) == 1
assert generated_blocks[0]["text"].endswith("Search field focused; keyboard appeared.")
assert all(block["type"] != "image_url" for block in messages[1].content)
def test_context_compressor_uses_tool_call_id_over_message_ordinal(mock_context):
"""Generic tool messages cannot shift action summaries onto the wrong result."""
summarizer = VisualStepSummarizer(mock_context)
summarizer._summaries["click-id"] = "Click-specific visual memory."
summarizer._summaries["type-id"] = "Typing-specific visual memory."
messages = [
ToolMessage(
tool_call_id="click-id",
name="click",
content=[
{"type": "text", "text": "Clicked."},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,A"}},
],
),
ToolMessage(
tool_call_id="explorer-id",
name="ask_explorer",
content=[{"type": "text", "text": "Explorer result."}],
),
ToolMessage(
tool_call_id="type-id",
name="input_text",
content=[
{"type": "text", "text": "Typed."},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,B"}},
],
),
]
compress_flash_messages(messages, summarizer=summarizer)
assert "Click-specific visual memory." in str(messages[0].content)
assert "Typing-specific visual memory." not in str(messages[0].content)
assert "Visual Transition" not in str(messages[1].content)
# The latest live observation remains uncompressed until a newer screen exists.
assert messages[2].content[-1]["type"] == "image_url"
def test_context_compressor_preserves_text_before_embedded_ui_list(mock_context):
"""Pruning a combined XML block must retain its action result prefix."""
messages = [
ToolMessage(
tool_call_id="old",
name="click",
content=[
{
"type": "text",
"text": "Tapped Settings.\n--- UI Element List ---\n[huge tree]",
},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,A"}},
],
),
ToolMessage(
tool_call_id="live",
name="click",
content=[
{"type": "text", "text": "Opened page."},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,B"}},
],
),
]
compress_flash_messages(messages, prune_history_xml=True)
assert messages[0].content == [{"type": "text", "text": "Tapped Settings."}]
@pytest.mark.asyncio
async def test_summarizer_keys_actions_independently_of_turn_number(mock_context):
"""Multiple actions from one LLM turn keep independent inputs and summaries."""
mock_context.data_engine = Mock()
summarizer = VisualStepSummarizer(mock_context)
async def mock_ainvoke(messages):
prompt = str(messages)
if "first action" in prompt:
return AIMessage(content="First action memory.")
return AIMessage(content="Second action memory.")
summarizer._llm = Mock()
summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke)
first_step_id = uuid4()
second_step_id = uuid4()
summarizer.dispatch(
step_number=1,
action_name="click",
action_args={"label": "first action"},
pre_img_bytes=None,
post_img_bytes=None,
exec_outcome="one",
action_key="tc-1",
data_engine_step_id=first_step_id,
)
summarizer.dispatch(
step_number=2,
action_name="click",
action_args={"label": "second action"},
pre_img_bytes=None,
post_img_bytes=None,
exec_outcome="two",
action_key="tc-2",
data_engine_step_id=second_step_id,
)
await summarizer.flush()
assert summarizer.get_summary("tc-1") == "First action memory."
assert summarizer.get_summary("tc-2") == "Second action memory."
assert summarizer._step_inputs["tc-1"]["data_engine_step_id"] == first_step_id
assert summarizer._step_inputs["tc-2"]["data_engine_step_id"] == second_step_id
mock_context.data_engine.update_step_summary.assert_has_calls(
[
call(first_step_id, "First action memory."),
call(second_step_id, "Second action memory."),
],
any_order=True,
)
def test_flash_config_and_builder():
"""Verify Flash profile configuration model and SDK builder fluent API."""
cfg = Builders.AgentConfig.with_flash_config(