feat: harden video analysis and runtime resilience
This commit is contained in:
1 parent
5ada543a09
commit
bb16991f2c
55 files changed
+5023
-437
No files matched your search
@@ -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(
|
||||
|
||||
@@ -47,6 +47,18 @@ def test_operator_prompt_omits_environment_trust_and_explorer_directives():
|
||||
assert "Visual Explorer Rule" not in prompt
|
||||
|
||||
|
||||
def test_operator_prompt_keeps_large_list_traversal_single_pass_until_boundary():
|
||||
for template in load_operator_prompts().values():
|
||||
prompt = apply_operator_prompt_contract(template)
|
||||
|
||||
assert "do not advance to another milestone or mark it complete" in prompt
|
||||
assert "A single scroll that reveals no new content is not sufficient evidence" in prompt
|
||||
assert "one additional successful swipe in the same direction" in prompt
|
||||
assert "never reverse direction merely to prove completion" in prompt
|
||||
assert "even if older execution history has been pruned" in prompt
|
||||
assert "Terminate exploration when a definitive boundary is reached" not in prompt
|
||||
|
||||
|
||||
def test_compatibility_builder_uses_canonical_operator_template():
|
||||
expected = apply_operator_prompt_contract(load_operator_prompts()["main_template"])
|
||||
|
||||
|
||||
@@ -181,6 +181,9 @@ async def test_exec_analyze_audio_universal(mock_context, tmp_path):
|
||||
assert "Heard notification sound" in result
|
||||
assert len(agent.blackboard_entries) == 1
|
||||
assert agent.blackboard_entries[0]["summary"] == "Heard notification sound."
|
||||
sent_content = mock_bound.ainvoke.await_args.args[0][1].content
|
||||
assert sent_content[1]["type"] == "audio"
|
||||
assert sent_content[1]["base64"] == "YXVkaW9fYnl0ZXM="
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from artemis.agents.video_analyzer.blackboard import (
|
||||
VideoBlackboard,
|
||||
get_video_blackboard,
|
||||
)
|
||||
from artemis.agents.video_analyzer.video_analyzer import VideoAnalyzer, _invoke_with_retry
|
||||
from artemis.context import ArtemisContext
|
||||
|
||||
|
||||
def test_blackboard_persists_segments_observations_and_evidence(tmp_path):
|
||||
db_path = tmp_path / "trace.db"
|
||||
evidence_dir = tmp_path / "video_blackboard" / "evidence"
|
||||
source_image = tmp_path / "temporary.jpg"
|
||||
source_image.write_bytes(b"jpeg evidence")
|
||||
|
||||
board = VideoBlackboard("video:recording-1", db_path=db_path, evidence_dir=evidence_dir)
|
||||
stored = board.add_observation(
|
||||
{
|
||||
"start": 1.0,
|
||||
"end": 1.5,
|
||||
"target": "verify login",
|
||||
"summary": "Login screen became visible.",
|
||||
"confidence_score": 0.95,
|
||||
"screenshot": str(source_image),
|
||||
}
|
||||
)
|
||||
# A duplicate commit is idempotent.
|
||||
board.add_observation(stored)
|
||||
|
||||
claim = board.claim_segment(0.0, 5.0, "verify login")
|
||||
assert claim.state == "claimed"
|
||||
board.complete_segment(
|
||||
0.0,
|
||||
5.0,
|
||||
"verify login",
|
||||
claim.lease_owner,
|
||||
"Login succeeded.",
|
||||
"The authenticated home screen appeared.",
|
||||
)
|
||||
|
||||
reopened = VideoBlackboard("video:recording-1", db_path=db_path, evidence_dir=evidence_dir)
|
||||
observations = reopened.list_observations()
|
||||
assert len(observations) == 1
|
||||
persisted_evidence = observations[0]["screenshot"]
|
||||
assert persisted_evidence != str(source_image)
|
||||
assert (evidence_dir / f"{stored['observation_id']}.jpg").read_bytes() == b"jpeg evidence"
|
||||
|
||||
assert reopened.missing_intervals(0.0, 5.0, "verify login") == []
|
||||
cached = reopened.claim_segment(0.0, 5.0, "verify login")
|
||||
assert cached.state == "cached"
|
||||
assert cached.summary == "Login succeeded."
|
||||
assert any(entry.get("kind") == "segment_result" for entry in reopened.list_ledger_entries())
|
||||
|
||||
|
||||
def test_blackboard_calculates_only_uncovered_intervals(tmp_path):
|
||||
board = VideoBlackboard("video:coverage", db_path=tmp_path / "coverage.db")
|
||||
for start, end in ((0.0, 10.0), (8.0, 20.0), (30.0, 40.0)):
|
||||
claim = board.claim_segment(start, end, "find spinner")
|
||||
board.complete_segment(
|
||||
start, end, "find spinner", claim.lease_owner, f"Analyzed {start}-{end}"
|
||||
)
|
||||
|
||||
assert board.missing_intervals(0.0, 50.0, "find spinner") == [
|
||||
(20.0, 30.0),
|
||||
(40.0, 50.0),
|
||||
]
|
||||
# Different questions can use the observations as context but do not share
|
||||
# query-specific completion coverage.
|
||||
assert board.missing_intervals(0.0, 10.0, "count spinner frames") == [(0.0, 10.0)]
|
||||
|
||||
|
||||
def test_blackboard_lease_suppresses_duplicates_and_is_reclaimable():
|
||||
board = VideoBlackboard("video:lease", lease_seconds=0.02)
|
||||
first = board.claim_segment(0.0, 5.0, "verify tap")
|
||||
assert first.state == "claimed"
|
||||
assert board.claim_segment(0.0, 5.0, "verify tap").state == "in_progress"
|
||||
|
||||
time.sleep(0.03)
|
||||
reclaimed = board.claim_segment(0.0, 5.0, "verify tap")
|
||||
assert reclaimed.state == "claimed"
|
||||
assert reclaimed.lease_owner != first.lease_owner
|
||||
|
||||
board.fail_segment(0.0, 5.0, "verify tap", reclaimed.lease_owner, "temporary provider outage")
|
||||
assert board.claim_segment(0.0, 5.0, "verify tap").state == "claimed"
|
||||
|
||||
|
||||
def test_get_video_blackboard_is_shared_by_context():
|
||||
ctx = MagicMock(spec=ArtemisContext)
|
||||
ctx._video_blackboard = None
|
||||
ctx.data_engine = None
|
||||
ctx.execution_setup = None
|
||||
ctx.device = MagicMock()
|
||||
ctx.device.device_id = "device-1"
|
||||
|
||||
first = get_video_blackboard(ctx)
|
||||
first.add_observation(
|
||||
{
|
||||
"start": 0.0,
|
||||
"end": 1.0,
|
||||
"target": "shared",
|
||||
"summary": "Persisted in context.",
|
||||
}
|
||||
)
|
||||
second = get_video_blackboard(ctx)
|
||||
|
||||
assert first is second
|
||||
assert second.list_observations()[0]["summary"] == "Persisted in context."
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def analyzer_context():
|
||||
ctx = MagicMock(spec=ArtemisContext)
|
||||
ctx._video_blackboard = None
|
||||
ctx._mobile_controller = None
|
||||
ctx.data_engine = None
|
||||
ctx.execution_setup = None
|
||||
ctx.agent_config = None
|
||||
ctx.device = MagicMock()
|
||||
ctx.device.device_id = "device-analyzer"
|
||||
ctx.llm_config = MagicMock()
|
||||
ctx.llm_config.utils.video_analyzer.model = "universal-test-model"
|
||||
ctx.llm_config.utils.video_analyzer.temperature = 0.2
|
||||
return ctx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spawn_sub_agent_reuses_complete_persistent_coverage(analyzer_context):
|
||||
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
|
||||
analyzer = VideoAnalyzer(analyzer_context)
|
||||
claim = analyzer.blackboard.claim_segment(10.0, 20.0, "verify login")
|
||||
analyzer.blackboard.complete_segment(
|
||||
10.0,
|
||||
20.0,
|
||||
"verify login",
|
||||
claim.lease_owner,
|
||||
"Login succeeded.",
|
||||
"Home screen appeared.",
|
||||
)
|
||||
|
||||
with patch.object(analyzer, "_exec_single_chunk", new_callable=AsyncMock) as child:
|
||||
result = await analyzer.exec_spawn_sub_agent(10.0, 20.0, "verify login")
|
||||
|
||||
child.assert_not_awaited()
|
||||
assert result.startswith("CACHED VIDEO ANALYSIS:")
|
||||
assert "Login succeeded" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spawn_sub_agent_returns_partial_and_keeps_prior_success(analyzer_context):
|
||||
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
|
||||
analyzer = VideoAnalyzer(analyzer_context)
|
||||
claim = analyzer.blackboard.claim_segment(0.0, 60.0, "summarize flow")
|
||||
analyzer.blackboard.complete_segment(
|
||||
0.0,
|
||||
60.0,
|
||||
"summarize flow",
|
||||
claim.lease_owner,
|
||||
"First minute completed.",
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
analyzer,
|
||||
"_exec_single_chunk",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("provider unavailable"),
|
||||
) as child:
|
||||
result = await analyzer.exec_spawn_sub_agent(0.0, 120.0, "summarize flow")
|
||||
|
||||
child.assert_awaited_once_with(60.0, 120.0, "summarize flow")
|
||||
assert result.startswith("PARTIAL VIDEO ANALYSIS")
|
||||
assert "First minute completed" in result
|
||||
assert analyzer.blackboard.missing_intervals(0.0, 120.0, "summarize flow") == [(60.0, 120.0)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_coordinator_retries_transient_failures():
|
||||
operation = AsyncMock(
|
||||
side_effect=[TimeoutError("timeout one"), ConnectionError("reset"), "completed"]
|
||||
)
|
||||
with patch(
|
||||
"artemis.agents.video_analyzer.video_analyzer.asyncio.sleep", new_callable=AsyncMock
|
||||
) as sleep:
|
||||
result = await _invoke_with_retry(operation, "test coordinator")
|
||||
|
||||
assert result == "completed"
|
||||
assert operation.await_count == 3
|
||||
assert sleep.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audio_analysis_reuses_exact_completed_coverage(analyzer_context):
|
||||
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
|
||||
analyzer = VideoAnalyzer(analyzer_context)
|
||||
claim = analyzer.blackboard.claim_segment(
|
||||
2.0, 6.0, "hear notification", modality="audio"
|
||||
)
|
||||
analyzer.blackboard.complete_segment(
|
||||
2.0,
|
||||
6.0,
|
||||
"hear notification",
|
||||
claim.lease_owner,
|
||||
"A notification sounded.",
|
||||
"One short bell was audible.",
|
||||
modality="audio",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"artemis.agents.video_analyzer.video_analyzer.get_controller"
|
||||
) as controller:
|
||||
result = await analyzer.exec_analyze_audio_only(
|
||||
2.0, 6.0, "hear notification"
|
||||
)
|
||||
|
||||
controller.assert_not_called()
|
||||
assert result.startswith("CACHED AUDIO ANALYSIS:")
|
||||
assert "notification sounded" in result
|
||||
|
||||
|
||||
def test_blackboard_metrics_include_structured_failures(tmp_path):
|
||||
board = VideoBlackboard("video:metrics", db_path=tmp_path / "metrics.db")
|
||||
claim = board.claim_segment(
|
||||
0.0,
|
||||
5.0,
|
||||
"inspect",
|
||||
model_name="vlm-primary",
|
||||
source_generation=2,
|
||||
)
|
||||
board.fail_segment(
|
||||
0.0,
|
||||
5.0,
|
||||
"inspect",
|
||||
claim.lease_owner,
|
||||
"timeout",
|
||||
error_category="timeout",
|
||||
)
|
||||
|
||||
assert board.metrics()["segments"] == {"retryable_failed": 1}
|
||||
assert board.metrics()["failure_categories"] == {"timeout": 1}
|
||||
|
||||
|
||||
def test_session_deletion_removes_video_blackboard_rows(tmp_path):
|
||||
from uuid import uuid4
|
||||
|
||||
from artemis.data_engine.engine import DataEngine
|
||||
|
||||
ctx = MagicMock(spec=ArtemisContext)
|
||||
ctx.execution_setup = MagicMock(traces_path=str(tmp_path / "traces"))
|
||||
ctx.device = None
|
||||
engine = DataEngine(ctx)
|
||||
session_id = engine.start_session(goal="blackboard cleanup")
|
||||
video_id = uuid4()
|
||||
recording = tmp_path / "recording.mp4"
|
||||
recording.write_bytes(b"video")
|
||||
engine.record_video_start(video_id, "device", recording)
|
||||
|
||||
board = VideoBlackboard(f"video:{video_id}", db_path=engine.storage.db_path)
|
||||
board.add_observation(
|
||||
{"start": 0.0, "end": 1.0, "target": "cleanup", "summary": "evidence"}
|
||||
)
|
||||
assert board.list_observations()
|
||||
|
||||
engine.storage.delete_session(session_id)
|
||||
|
||||
reopened = VideoBlackboard(f"video:{video_id}", db_path=engine.storage.db_path)
|
||||
assert reopened.list_observations() == []
|
||||
@@ -0,0 +1,193 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
import pytest
|
||||
|
||||
from artemis.agents.video_analyzer.reliability import (
|
||||
VideoCircuitBreaker,
|
||||
VideoFailureCategory,
|
||||
classify_video_failure,
|
||||
)
|
||||
from artemis.agents.video_analyzer.video_analyzer import VideoAnalyzer
|
||||
from artemis.context import ArtemisContext
|
||||
|
||||
|
||||
def _context() -> MagicMock:
|
||||
ctx = MagicMock(spec=ArtemisContext)
|
||||
ctx._video_blackboard = None
|
||||
ctx._video_circuit_breaker = None
|
||||
ctx._mobile_controller = None
|
||||
ctx.execution_setup = None
|
||||
ctx.data_engine = None
|
||||
ctx.agent_config = SimpleNamespace(
|
||||
video_analyzer=SimpleNamespace(
|
||||
enable_ledger=True,
|
||||
chunk_size_seconds=60.0,
|
||||
min_chunk_seconds=4.0,
|
||||
max_split_depth=4,
|
||||
circuit_breaker_threshold=2,
|
||||
circuit_breaker_cooldown_seconds=30.0,
|
||||
action_window_seconds=2.0,
|
||||
dense_action_fps=4.0,
|
||||
max_dense_action_frames=24,
|
||||
)
|
||||
)
|
||||
ctx.device = SimpleNamespace(device_id="reliability-device")
|
||||
ctx.llm_config = MagicMock()
|
||||
ctx.llm_config.utils.video_analyzer.model = "primary-video-model"
|
||||
ctx.llm_config.utils.video_analyzer.temperature = 0.2
|
||||
return ctx
|
||||
|
||||
|
||||
def test_failure_classifier_chooses_safe_recovery():
|
||||
outage = classify_video_failure(RuntimeError("503 service unavailable"))
|
||||
assert outage.category == VideoFailureCategory.PROVIDER_UNAVAILABLE
|
||||
assert outage.retryable and outage.should_fallback
|
||||
assert not outage.should_split
|
||||
|
||||
timeout = classify_video_failure(TimeoutError("clip timed out"))
|
||||
assert timeout.category == VideoFailureCategory.TIMEOUT
|
||||
assert timeout.should_split and timeout.should_fallback
|
||||
|
||||
invalid = SimpleNamespace(code=400)
|
||||
invalid_error = RuntimeError("bad request")
|
||||
invalid_error.code = invalid.code
|
||||
classified = classify_video_failure(invalid_error)
|
||||
assert classified.category == VideoFailureCategory.BAD_REQUEST
|
||||
assert not classified.retryable
|
||||
|
||||
|
||||
def test_circuit_breaker_opens_and_half_opens():
|
||||
breaker = VideoCircuitBreaker(threshold=2, cooldown_seconds=10.0)
|
||||
failure = classify_video_failure(TimeoutError("timeout"))
|
||||
breaker.record_failure("model", failure, now=10.0)
|
||||
assert breaker.allow("model", now=10.1)
|
||||
breaker.record_failure("model", failure, now=11.0)
|
||||
assert not breaker.allow("model", now=15.0)
|
||||
assert breaker.allow("model", now=21.1)
|
||||
assert not breaker.allow("model", now=21.2)
|
||||
breaker.record_success("model")
|
||||
assert breaker.snapshot() == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_chunk_is_bisected_and_successful_leaves_survive():
|
||||
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
|
||||
analyzer = VideoAnalyzer(_context())
|
||||
|
||||
async def analyze(start: float, end: float, query: str) -> str:
|
||||
if end - start > 4.0:
|
||||
raise TimeoutError("large clip timed out")
|
||||
return f"[from {start:.1f}s to {end:.1f}s] Summary: recovered {query}"
|
||||
|
||||
with patch.object(analyzer, "_exec_single_chunk", side_effect=analyze) as child:
|
||||
result = await analyzer.exec_spawn_sub_agent(0.0, 16.0, "find transition")
|
||||
|
||||
assert child.await_count == 7
|
||||
assert result.count("recovered find transition") == 4
|
||||
assert "PARTIAL" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_universal_primary_failure_uses_configured_fallback():
|
||||
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
|
||||
analyzer = VideoAnalyzer(_context())
|
||||
|
||||
primary = MagicMock()
|
||||
primary_bound = MagicMock()
|
||||
primary_bound.ainvoke = AsyncMock(side_effect=TimeoutError("primary timeout"))
|
||||
primary.bind_tools.return_value = primary_bound
|
||||
fallback = MagicMock()
|
||||
fallback_bound = MagicMock()
|
||||
fallback_bound.ainvoke = AsyncMock(return_value=AIMessage(content="fallback answer"))
|
||||
fallback.bind_tools.return_value = fallback_bound
|
||||
|
||||
with patch(
|
||||
"artemis.agents.video_analyzer.video_analyzer.get_llm",
|
||||
side_effect=[primary, fallback],
|
||||
) as get_model:
|
||||
response = await analyzer._invoke_universal_model(
|
||||
[HumanMessage(content="inspect")],
|
||||
[],
|
||||
timeout=1.0,
|
||||
label="test",
|
||||
)
|
||||
|
||||
assert response.content == "fallback answer"
|
||||
assert get_model.call_args_list[0].kwargs["is_utils"] is True
|
||||
assert get_model.call_args_list[1].kwargs["use_fallback"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_chunk_exhaustion_commits_universal_fallback(tmp_path):
|
||||
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
|
||||
analyzer = VideoAnalyzer(_context())
|
||||
analyzer.use_native_gemini = True
|
||||
raw_video = tmp_path / "segment.mp4"
|
||||
raw_video.write_bytes(b"video")
|
||||
extracted = SimpleNamespace(
|
||||
success=True,
|
||||
video_path=raw_video,
|
||||
actual_start_relative_time=0.0,
|
||||
duration_seconds=5.0,
|
||||
warning=None,
|
||||
)
|
||||
controller = SimpleNamespace(
|
||||
extract_segment_metadata=AsyncMock(return_value=extracted)
|
||||
)
|
||||
fallback_text = "[from 0.0s to 5.0s] Summary: fallback recovered Analysis: grounded"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"artemis.agents.video_analyzer.video_analyzer.get_controller",
|
||||
return_value=controller,
|
||||
),
|
||||
patch(
|
||||
"artemis.agents.video_analyzer.video_analyzer.compress_video_for_api",
|
||||
new=AsyncMock(return_value=raw_video),
|
||||
),
|
||||
patch.object(
|
||||
analyzer,
|
||||
"upload_and_poll_file",
|
||||
new=AsyncMock(side_effect=RuntimeError("503 provider unavailable")),
|
||||
) as upload,
|
||||
patch.object(
|
||||
analyzer,
|
||||
"_exec_single_chunk_universal",
|
||||
new=AsyncMock(return_value=fallback_text),
|
||||
) as fallback,
|
||||
patch(
|
||||
"artemis.agents.video_analyzer.video_analyzer.asyncio.sleep",
|
||||
new=AsyncMock(),
|
||||
),
|
||||
):
|
||||
result = await analyzer._exec_single_chunk(0.0, 5.0, "verify transition")
|
||||
|
||||
assert result == fallback_text
|
||||
assert upload.await_count == 2
|
||||
fallback.assert_awaited_once()
|
||||
assert analyzer.blackboard.missing_intervals(0.0, 5.0, "verify transition") == []
|
||||
|
||||
|
||||
def test_action_timestamps_drive_dense_sampling():
|
||||
with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None):
|
||||
analyzer = VideoAnalyzer(_context())
|
||||
step = SimpleNamespace(timestamp=104.0, action_taken={"name": "tap"})
|
||||
storage = SimpleNamespace(get_steps=lambda _session: [step])
|
||||
analyzer.ctx.data_engine = SimpleNamespace(
|
||||
current_session_id="session-1",
|
||||
session_start_time=100.0,
|
||||
storage=storage,
|
||||
)
|
||||
|
||||
offsets = analyzer._dense_action_offsets(0.0, 10.0, actual_start=0.0)
|
||||
|
||||
assert offsets[0] == 2.0
|
||||
assert 4.0 in offsets
|
||||
assert offsets[-1] == 6.0
|
||||
Reference in new issue
Block a user