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

+72 -25
View File
@@ -14,9 +14,9 @@
"""Universal Context Compressor for Artemis Flash profile.
Replaces pruned historical screenshots with high-density visual step summaries
when ready, while seamlessly falling back to standard pruned action text if
the background summary is still in flight.
Replaces historical screenshots with high-density visual step summaries only
after they are ready. Screenshots remain in context while summarization or
retry is still in flight.
"""
from typing import Any
@@ -28,6 +28,17 @@ from artemis.utils.logger import get_logger
logger = get_logger(__name__)
_HISTORY_SUMMARY_PREFIX = "--- Historical Visual Transition ---\n"
_UI_LIST_MARKER = "--- UI Element List ---"
def _without_historical_ui_list(text: str) -> str:
"""Remove a historical UI list without discarding text before its marker."""
marker_index = text.find(_UI_LIST_MARKER)
if marker_index < 0:
return text
return text[:marker_index].rstrip()
def compress_flash_messages(
messages: list[BaseMessage],
@@ -40,8 +51,8 @@ def compress_flash_messages(
1. Keeps the latest screenshot and live UI hierarchy completely intact.
2. For earlier steps, if a VisualStepSummarizer has completed the step's summary,
replaces the intermediate image block with the objective summary text.
3. If the background summary is not ready yet, gracefully falls back to the existing
behavior (omitting the image block and retaining the original action text).
3. If the background summary is not ready yet, retains the original image until
a successful summary can replace it.
4. If prune_history_xml is True, removes heavy outdated UI Element lists from past turns.
"""
if not messages:
@@ -61,7 +72,9 @@ def compress_flash_messages(
last_img_msg_idx = idx
break
# 2. Iterate through historical messages and apply compression/fallback
# 2. Iterate through historical messages and apply compression/fallback.
# ``tool_call_id`` is the stable identity of an action result. The legacy
# ordinal is retained only for summaries produced by older callers/tests.
tool_step_counter = 0
for idx in range(len(messages)):
msg = messages[idx]
@@ -74,43 +87,77 @@ def compress_flash_messages(
continue
# Count tool steps strictly corresponding to FlashRunner action turns
is_tool_msg = getattr(msg, "tool_call_id", None) is not None
tool_call_id = getattr(msg, "tool_call_id", None)
is_tool_msg = tool_call_id is not None
if is_tool_msg:
tool_step_counter += 1
current_step_num = tool_step_counter
legacy_step_num = tool_step_counter
else:
current_step_num = None
legacy_step_num = None
new_blocks: list[dict[str, Any]] = []
summary_injected = False
summary_text = None
summary_pending = False
if summarizer and is_tool_msg:
# New FlashRunner instances key summaries by tool_call_id, so generic
# tools, no-tool turns, and parallel tool calls cannot shift memories.
action_key = str(tool_call_id)
summary_text = summarizer.get_summary(action_key)
summary_pending = summarizer.is_pending(action_key)
if (
summary_text is None
and not summarizer.has_job(action_key)
and legacy_step_num is not None
):
summary_text = summarizer.get_summary(legacy_step_num)
summary_pending = summarizer.is_pending(legacy_step_num)
new_blocks: list[Any] = []
for b in msg_content:
if not isinstance(b, dict):
# LangChain permits string parts in multimodal content lists.
# They are lightweight historical evidence and must not vanish.
new_blocks.append(b)
continue
b_type = b.get("type", "")
b_text = b.get("text", "")
# Filter heavy outdated XML hierarchy from past steps to save tokens
if prune_history_xml and b_type == "text" and "--- UI Element List ---" in b_text:
if b_type == "text" and b_text.startswith(_HISTORY_SUMMARY_PREFIX):
# Rebuild this generated block on every pass. This makes the
# compressor idempotent and lets a late summary replace itself.
continue
# Filter heavy outdated XML hierarchy from past steps to save tokens.
# Some adapters combine action text and the hierarchy in one block;
# retain the useful prefix instead of dropping the entire block.
if prune_history_xml and b_type == "text" and _UI_LIST_MARKER in b_text:
retained_text = _without_historical_ui_list(b_text)
if retained_text:
retained_block = dict(b)
retained_block["text"] = retained_text
new_blocks.append(retained_block)
continue
if b_type in ("image_url", "image"):
# Check if this is a tool execution step and background summarizer has a ready summary
if (
current_step_num
and summarizer
and summarizer.has_summary(current_step_num)
and not summary_injected
):
summary_text = summarizer.get_summary(current_step_num)
if summary_text:
new_blocks.append({"type": "text", "text": summary_text})
summary_injected = True
# If summarizer is not ready or initial screen, gracefully omit the image block
# A submitted image remains visible until its summary is ready.
# This temporarily favors context size over losing visual evidence.
if summary_pending:
new_blocks.append(b)
continue
new_blocks.append(b)
# Do not tie injection to the presence of an image: the image is normally
# pruned before the background summary finishes. A stable tool-call key lets
# a later compression pass backfill the summary into the correct result.
if summary_text:
new_blocks.append(
{
"type": "text",
"text": f"{_HISTORY_SUMMARY_PREFIX}{summary_text}",
}
)
# Defensive fallback: never leave ToolMessage or HumanMessage content empty
if not new_blocks:
new_blocks = [{"type": "text", "text": "Action completed." if is_tool_msg else ""}]
+14 -11
View File
@@ -155,6 +155,7 @@ class FlashRunner:
]
turns = 0
action_sequence = 0
final_report = None
current_pre_screenshot_bytes = img_bytes
current_xml_list = xml_list
@@ -186,15 +187,13 @@ class FlashRunner:
except Exception as e:
logger.warning(f"Failed to check injected instruction in FlashRunner: {e}")
# Compress previous history before LLM call to save context and inject objective summaries
if self.summarizer:
compress_flash_messages(
messages,
summarizer=self.summarizer,
prune_history_xml=self.step_summarizer_cfg.prune_history_xml,
)
else:
prune_intermediate_screenshots(messages)
# Compress history even when visual summarization is disabled, so
# screenshot and historical XML pruning have identical semantics.
compress_flash_messages(
messages,
summarizer=self.summarizer,
prune_history_xml=self.step_summarizer_cfg.prune_history_xml,
)
# Tool restriction on the final turn
if turns == self.max_turns:
@@ -418,6 +417,7 @@ class FlashRunner:
)
# Record telemetry / step in DataEngine
recorded_step_id = None
if self.ctx.data_engine and name in ACTION_TOOL_NAMES:
try:
if self.ctx.data_engine.current_step_id is None:
@@ -475,7 +475,7 @@ class FlashRunner:
action_dict["normalized_start_coordinates"] = norm_start
action_dict["normalized_end_coordinates"] = norm_end
self.ctx.data_engine.record_step(
recorded_step_id = self.ctx.data_engine.record_step(
pre_screenshot_bytes=current_pre_screenshot_bytes,
post_screenshot_bytes=post_img_bytes,
ui_tree=(exec_result.ui_elements_text or current_xml_list),
@@ -489,13 +489,16 @@ class FlashRunner:
# ⚡ Non-blocking dispatch of objective visual transition summarizer
if self.summarizer and name in ACTION_TOOL_NAMES:
action_sequence += 1
self.summarizer.dispatch(
step_number=turns,
step_number=action_sequence,
action_name=name,
action_args=args,
pre_img_bytes=current_pre_screenshot_bytes,
post_img_bytes=post_img_bytes,
exec_outcome=exec_result.text_summary,
action_key=str(tc_id),
data_engine_step_id=recorded_step_id,
)
if post_img_bytes:
+74 -52
View File
@@ -16,13 +16,14 @@
Processes Before/After screenshot pairs and action metadata in the background
to generate high-density, strictly objective visual state transition summaries.
Ensures zero blocking latency for the main runner and includes out-of-order self-healing retries.
Ensures zero blocking latency for the main runner and independently retries failed jobs.
"""
import asyncio
import base64
from pathlib import Path
from typing import Any
from uuid import UUID
from jinja2 import Template
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
@@ -40,18 +41,17 @@ class VisualStepSummarizer:
Key design properties:
1. Zero-blocking dispatch: The main Flash runner dispatches and proceeds immediately.
2. Graceful fallback: If a step summary is not ready, it cleanly falls back to the original action text.
3. Self-healing re-trigger: If a previous step's summary is stalled or lost while subsequent steps
complete, it automatically re-dispatches the stalled step.
2. Lossless pending state: The compressor retains the source image until its summary is ready.
3. Independent retry: Every action owns a retry loop and does not depend on later actions arriving.
"""
def __init__(self, ctx: ArtemisContext, model_name: str | None = None):
self.ctx = ctx
self._summaries: dict[int, str] = {}
self._pending_tasks: dict[int, asyncio.Task] = {}
self._step_inputs: dict[int, dict[str, Any]] = {}
self._retry_counts: dict[int, int] = {}
self._max_retries = 2
self._summaries: dict[int | str, str] = {}
self._pending_tasks: dict[int | str, asyncio.Task] = {}
self._step_inputs: dict[int | str, dict[str, Any]] = {}
self._retry_counts: dict[int | str, int] = {}
self._retry_delays = (0.0, 0.5, 1.0, 2.0, 3.0)
# Initialize lightweight VLM: prioritize explicit model_name
target_model = model_name or "gemini-2.5-flash-lite"
@@ -83,44 +83,50 @@ class VisualStepSummarizer:
pre_img_bytes: bytes | None,
post_img_bytes: bytes | None,
exec_outcome: str,
*,
action_key: str | None = None,
data_engine_step_id: UUID | str | None = None,
) -> None:
"""Dispatches an asynchronous summarization task without blocking the caller."""
self._step_inputs[step_number] = {
key: int | str = action_key or step_number
self._step_inputs[key] = {
"step_number": step_number,
"action_name": action_name,
"action_args": action_args,
"pre_img_bytes": pre_img_bytes,
"post_img_bytes": post_img_bytes,
"exec_outcome": exec_outcome,
"data_engine_step_id": data_engine_step_id,
}
task = asyncio.create_task(self._run_summary(step_number))
self._pending_tasks[step_number] = task
task = asyncio.create_task(self._run_summary_until_ready(key))
self._pending_tasks[key] = task
# Check for and re-trigger any previous stalled steps
self._check_and_retrigger_stalled_steps(current_step=step_number)
async def _run_summary_until_ready(self, action_key: int | str) -> None:
"""Retry one action independently until a non-empty summary is committed."""
while action_key not in self._summaries:
if await self._run_summary_once(action_key):
return
def _check_and_retrigger_stalled_steps(self, current_step: int) -> None:
"""Re-dispatches any previous steps that remain unsummarized while later steps arrive."""
for past_step in range(1, current_step):
if past_step not in self._summaries and past_step in self._step_inputs:
task = self._pending_tasks.get(past_step)
retries = self._retry_counts.get(past_step, 0)
if (task is None or task.done()) and retries < self._max_retries:
self._retry_counts[past_step] = retries + 1
logger.info(
f"VisualStepSummarizer: Retriggering stalled summary for Step {past_step} "
f"(attempt {self._retry_counts[past_step] + 1})"
)
self._pending_tasks[past_step] = asyncio.create_task(
self._run_summary(past_step)
)
retry_count = self._retry_counts.get(action_key, 0) + 1
self._retry_counts[action_key] = retry_count
delay = self._retry_delays[min(retry_count - 1, len(self._retry_delays) - 1)]
input_data = self._step_inputs.get(action_key)
display_step = input_data.get("step_number") if input_data else action_key
logger.info(
f"VisualStepSummarizer: Retrying Step {display_step} "
f"(attempt {retry_count + 1}) after {delay:.1f}s"
)
if delay:
await asyncio.sleep(delay)
async def _run_summary(self, step_number: int) -> None:
"""Executes the lightweight VLM call to summarize the step transition."""
input_data = self._step_inputs.get(step_number)
async def _run_summary_once(self, action_key: int | str) -> bool:
"""Execute one lightweight VLM attempt for a step transition."""
input_data = self._step_inputs.get(action_key)
if not input_data:
return
return False
step_number = input_data["step_number"]
action_name = input_data["action_name"]
action_args = input_data["action_args"]
pre_bytes = input_data["pre_img_bytes"]
@@ -183,44 +189,60 @@ class VisualStepSummarizer:
summary_text = summary_raw.strip()
if summary_text:
self._summaries[step_number] = summary_text
self._summaries[action_key] = summary_text
logger.info(
f"VisualStepSummarizer: Generated summary for Step {step_number}: {summary_text[:80]}..."
)
# Free binary image buffers from memory once summary is secured
if step_number in self._step_inputs:
self._step_inputs[step_number]["pre_img_bytes"] = None
self._step_inputs[step_number]["post_img_bytes"] = None
if action_key in self._step_inputs:
self._step_inputs[action_key]["pre_img_bytes"] = None
self._step_inputs[action_key]["post_img_bytes"] = None
# Update DataEngine telemetry if active
if self.ctx.data_engine:
try:
self.ctx.data_engine.update_step_summary(step_number, summary_text)
target_step = input_data.get("data_engine_step_id") or step_number
self.ctx.data_engine.update_step_summary(target_step, summary_text)
except Exception as de_err:
logger.debug(f"DataEngine step summary update skipped: {de_err}")
return True
except Exception as e:
logger.warning(
f"VisualStepSummarizer: Error generating summary for step {step_number}: {e}"
)
if self._retry_counts.get(step_number, 0) >= self._max_retries:
# Max retries reached, free memory buffer
if step_number in self._step_inputs:
self._step_inputs[step_number]["pre_img_bytes"] = None
self._step_inputs[step_number]["post_img_bytes"] = None
return False
def get_summary(self, step_number: int, fallback_text: str | None = None) -> str | None:
"""Retrieves summarized state for step_number, or fallback_text if not ready yet."""
return self._summaries.get(step_number, fallback_text)
def get_summary(
self, action_key: int | str, fallback_text: str | None = None
) -> str | None:
"""Retrieve the summary for a stable action key, or fallback text."""
return self._summaries.get(action_key, fallback_text)
def has_summary(self, step_number: int) -> bool:
"""Checks if a step summary has completed."""
return step_number in self._summaries
def has_summary(self, action_key: int | str) -> bool:
"""Check whether an action summary has completed."""
return action_key in self._summaries
async def flush(self) -> None:
"""Flushes and awaits all pending background summary tasks upon completion."""
def has_job(self, action_key: int | str) -> bool:
"""Check whether an action has been submitted for visual summarization."""
return action_key in self._step_inputs
def is_pending(self, action_key: int | str) -> bool:
"""Check whether an action still needs a summary."""
return self.has_job(action_key) and not self.has_summary(action_key)
async def flush(self, timeout_seconds: float = 30.0) -> None:
"""Wait for active jobs up to a bound, then cancel remaining retry loops."""
tasks = [t for t in self._pending_tasks.values() if not t.done()]
if tasks:
logger.info(f"VisualStepSummarizer: Flushing {len(tasks)} pending summary tasks...")
await asyncio.gather(*tasks, return_exceptions=True)
_, pending = await asyncio.wait(tasks, timeout=timeout_seconds)
if pending:
logger.warning(
f"VisualStepSummarizer: Cancelling {len(pending)} unfinished summary tasks "
f"after {timeout_seconds:.1f}s flush timeout."
)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
+15
View File
@@ -58,6 +58,17 @@ _LEGACY_TROUBLESHOOTER_CHALLENGE = """ - *Challenge Checker*: If you visually
_TROUBLESHOOTER_CHALLENGE = """ - *Challenge Checker*: If current evidence clearly proves the subgoal is already achieved, invoke `reply_to_checker` directly. This branch takes precedence over diagnosis; do not call `ask_diagnoser` first.
- *Clear Local Correction*: If the rejection cause is explicit and a safe correction is evident from the current screen, perform that correction directly. This branch also takes precedence over generic diagnosis triggers. Use `ask_diagnoser` only for ambiguity, conflicting evidence, or repeated failure."""
_LEGACY_LARGE_LIST_STRATEGY = """- **Large List & Long Text Exploration Strategy (Zero-Miss, Zero-Duplication Single-Pass Principle)**: When traversing large lists, browsing long text bodies, or scanning continuous content feeds, strictly adhere to the single-pass exploration standard:
1. **Unidirectional Anchor-Based Traversal (Zero-Miss)**: Maintain a consistent, unidirectional scan (e.g., uniform downward scrolling). Before each scroll, identify the bottom-most visible item/text segment as your visual anchor. Calibrate your scroll distance so that this anchor remains visible near the top of the subsequent screen, creating a seamless visual overlap that guarantees zero missed items. Terminate exploration when a definitive boundary is reached (e.g., list bottom reached or no new content appears after scrolling).
2. **Memory & Plan-Driven Deduplication (Zero-Duplication)**: Systematically log extracted item names, identifiers, or processing states into notes or memory as you progress. After every screen transition, cross-reference visible items against your recorded ledger and strictly interact only with newly surfaced, unrecorded items to eliminate redundant actions on overlapping elements. Proactively maintain subtasks in `task_plan` (e.g., segmenting by batch, category, or alphabetical ranges like `a-d`), tracking exact paths and progress to prevent backtracking.
3. **Single-Pass Trust & Anti-Oscillation (No Over-Verification)**: Treat recorded notes as verified ground truth. Once a segment is traversed and logged, consider it permanently resolved. Do NOT scroll back-and-forth or perform redundant re-scans for "double-checking". Only perform incremental re-verification if the list has been refreshed or content has mutated, while maintaining strict single-pass handling of new events."""
_LARGE_LIST_STRATEGY = """- **Large List & Long Text Exploration Strategy (Zero-Miss, Zero-Duplication Single-Pass Principle)**: When traversing large lists, browsing long text bodies, or scanning continuous content feeds, strictly adhere to the single-pass exploration standard:
1. **Unidirectional Anchor-Based Traversal (Zero-Miss)**: Maintain a consistent, unidirectional scan (e.g., uniform downward scrolling). Before each scroll, identify the bottom-most visible item/text segment as your visual anchor and carry that anchor plus the scan direction in short-term memory or the traversal ledger. After the scroll, evaluate this handoff before doing anything unrelated: if the anchor remains visible with new content beyond it, record the new content and continue in the same direction without scrolling back to verify; if the anchor is missing, continuity is uncertain, so perform at most one minimal reverse recovery to re-establish overlap, then resume the original direction. While the traversal's declared exit condition remains unresolved, keep this traversal active and do not advance to another milestone or mark it complete.
2. **Boundary-Proven Completion**: A single scroll that reveals no new content is not sufficient evidence that the list is complete. For an exhaustive traversal, stop only when an explicit end-of-list indicator is visible, or when one additional successful swipe in the same direction and within the same list container leaves the same final anchor visible with no new items and no loading state. This boundary probe must remain unidirectional; never reverse direction merely to prove completion. If the task declares an earlier exit condition (for example, find one verified match or process N items), that explicit condition may end the traversal without reaching the list boundary.
3. **Memory & Plan-Driven Deduplication (Zero-Duplication)**: Systematically log extracted item names, identifiers, or processing states into notes or memory as you progress. After every screen transition, cross-reference visible items against your recorded ledger and strictly interact only with newly surfaced, unrecorded items to eliminate redundant actions on overlapping elements. Proactively maintain subtasks in `task_plan` (e.g., segmenting by batch, category, or alphabetical ranges like `a-d`), tracking exact paths and progress to prevent backtracking.
4. **Single-Pass Trust & Anti-Oscillation (No Over-Verification)**: Treat recorded notes as verified ground truth. Once a segment is traversed with confirmed anchor continuity and logged, consider it permanently resolved. Do NOT scroll back-and-forth or perform redundant re-scans for "double-checking", even if older execution history has been pruned. Resume from the last recorded anchor and direction. Only the single bounded recovery above, or a refreshed/mutated list, permits limited re-verification; otherwise continue the single pass over new content."""
def apply_operator_prompt_contract(prompt_template: str) -> str:
"""Align prompt-level tool and recovery rules with Operator runtime semantics."""
@@ -77,6 +88,10 @@ def apply_operator_prompt_contract(prompt_template: str) -> str:
_LEGACY_TROUBLESHOOTER_CHALLENGE,
_TROUBLESHOOTER_CHALLENGE,
)
prompt_template = prompt_template.replace(
_LEGACY_LARGE_LIST_STRATEGY,
_LARGE_LIST_STRATEGY,
)
return prompt_template
+821
View File
@@ -0,0 +1,821 @@
# 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.
"""Persistent, video-scoped memory for video analysis agents."""
from __future__ import annotations
from contextlib import contextmanager
from dataclasses import dataclass
import hashlib
import json
from pathlib import Path
import shutil
import sqlite3
import threading
import time
from typing import Any, Iterator, Literal
from uuid import uuid4
from artemis.context import ArtemisContext
from artemis.utils.logger import get_logger
from artemis.utils.video import get_active_session
logger = get_logger(__name__)
ClaimState = Literal["claimed", "cached", "in_progress"]
@dataclass(frozen=True)
class SegmentClaim:
"""Result of atomically attempting to acquire one analysis segment."""
state: ClaimState
lease_owner: str | None = None
summary: str | None = None
analysis: str | None = None
def _normalise_query(query: str | None) -> str:
return " ".join(str(query or "").strip().lower().split())
def _query_key(query: str | None) -> str:
return hashlib.sha256(_normalise_query(query).encode("utf-8")).hexdigest()
def _to_ms(value: float | int) -> int:
return int(round(float(value) * 1000.0))
def _to_seconds(value: int) -> float:
return round(value / 1000.0, 3)
class VideoBlackboard:
"""SQLite-backed blackboard with a context-local in-memory fallback."""
def __init__(
self,
board_key: str,
*,
db_path: Path | None = None,
evidence_dir: Path | None = None,
lease_seconds: float = 300.0,
) -> None:
self.board_key = board_key
self.db_path = Path(db_path) if db_path is not None else None
self.evidence_dir = Path(evidence_dir) if evidence_dir is not None else None
self.lease_seconds = max(0.01, float(lease_seconds))
self._lock = threading.RLock()
self._memory_observations: dict[str, dict[str, Any]] = {}
self._memory_segments: dict[tuple[str, str, int, int], dict[str, Any]] = {}
if self.db_path is not None:
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
if self.evidence_dir is not None:
self.evidence_dir.mkdir(parents=True, exist_ok=True)
@contextmanager
def _connection(self) -> Iterator[sqlite3.Connection]:
if self.db_path is None:
raise RuntimeError("SQLite connection requested for in-memory blackboard")
with self._lock:
conn = sqlite3.connect(self.db_path, timeout=30.0)
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
def _init_db(self) -> None:
with self._connection() as conn:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS video_analysis_segments (
board_key TEXT NOT NULL,
query_key TEXT NOT NULL,
query_text TEXT NOT NULL,
modality TEXT NOT NULL,
start_ms INTEGER NOT NULL,
end_ms INTEGER NOT NULL,
status TEXT NOT NULL,
lease_owner TEXT,
lease_expires_at REAL,
attempt_count INTEGER NOT NULL DEFAULT 0,
summary TEXT,
analysis TEXT,
error TEXT,
error_category TEXT,
model_name TEXT,
source_generation INTEGER,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
PRIMARY KEY (board_key, query_key, modality, start_ms, end_ms)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS video_analysis_observations (
observation_id TEXT PRIMARY KEY,
board_key TEXT NOT NULL,
start_ms INTEGER NOT NULL,
end_ms INTEGER,
query_text TEXT NOT NULL,
summary TEXT NOT NULL,
confidence REAL,
modality TEXT NOT NULL,
screenshot_path TEXT,
extra_json TEXT,
created_at REAL NOT NULL
)
"""
)
columns = {
row["name"]
for row in conn.execute("PRAGMA table_info(video_analysis_segments)").fetchall()
}
for name, sql_type in (
("error_category", "TEXT"),
("model_name", "TEXT"),
("source_generation", "INTEGER"),
):
if name not in columns:
try:
conn.execute(
f"ALTER TABLE video_analysis_segments ADD COLUMN {name} {sql_type}"
)
except sqlite3.OperationalError as error:
if "duplicate column" not in str(error).lower():
raise
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_video_analysis_segments_lookup
ON video_analysis_segments (board_key, query_key, modality, status, start_ms, end_ms)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_video_analysis_observations_board
ON video_analysis_observations (board_key, start_ms, end_ms)
"""
)
conn.commit()
def _persist_evidence(self, entry: dict[str, Any], observation_id: str) -> str | None:
candidate = entry.get("screenshot") or entry.get("screenshot_path")
if not candidate:
return None
source = Path(str(candidate))
if not source.exists() or not source.is_file() or self.evidence_dir is None:
return str(source) if source.exists() else None
try:
if source.stat().st_size <= 0:
return None
except OSError:
return None
suffix = source.suffix.lower() or ".jpg"
destination = self.evidence_dir / f"{observation_id}{suffix}"
if source.resolve() != destination.resolve() and not destination.exists():
try:
shutil.copy2(source, destination)
except OSError as exc:
logger.warning(f"Failed to persist video evidence {source}: {exc}")
return str(source)
return str(destination)
def add_observation(self, entry: dict[str, Any], *, modality: str = "video") -> dict[str, Any]:
"""Idempotently persist one evidence-backed observation."""
start = float(entry.get("start", 0.0))
raw_end = entry.get("end")
end = None if raw_end in (None, "unknown") else float(raw_end)
query_text = str(entry.get("target", ""))
summary = str(entry.get("summary", "")).strip()
identity = json.dumps(
{
"board_key": self.board_key,
"start_ms": _to_ms(start),
"end_ms": _to_ms(end) if end is not None else None,
"query": _normalise_query(query_text),
"summary": summary,
"modality": modality,
},
sort_keys=True,
separators=(",", ":"),
)
observation_id = hashlib.sha256(identity.encode("utf-8")).hexdigest()
screenshot_path = self._persist_evidence(entry, observation_id)
stored = dict(entry)
if screenshot_path:
stored["screenshot"] = screenshot_path
stored["modality"] = modality
stored["observation_id"] = observation_id
excluded = {
"start",
"end",
"target",
"summary",
"confidence_score",
"screenshot",
"screenshot_path",
"modality",
"observation_id",
}
extra = {key: value for key, value in stored.items() if key not in excluded}
now = time.time()
if self.db_path is None:
with self._lock:
self._memory_observations.setdefault(observation_id, stored)
return dict(self._memory_observations[observation_id])
with self._connection() as conn:
conn.execute(
"""
INSERT OR IGNORE INTO video_analysis_observations (
observation_id, board_key, start_ms, end_ms, query_text,
summary, confidence, modality, screenshot_path, extra_json, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
observation_id,
self.board_key,
_to_ms(start),
_to_ms(end) if end is not None else None,
query_text,
summary,
float(entry.get("confidence_score", 0.0)),
modality,
screenshot_path,
json.dumps(extra, default=str),
now,
),
)
conn.commit()
return stored
def list_observations(self) -> list[dict[str, Any]]:
"""Return all observations for this recording in timeline order."""
if self.db_path is None:
with self._lock:
entries = [dict(value) for value in self._memory_observations.values()]
return sorted(entries, key=lambda value: float(value.get("start", 0.0)))
with self._connection() as conn:
rows = conn.execute(
"""
SELECT * FROM video_analysis_observations
WHERE board_key = ?
ORDER BY start_ms, created_at
""",
(self.board_key,),
).fetchall()
entries: list[dict[str, Any]] = []
for row in rows:
entry: dict[str, Any] = {
"start": _to_seconds(row["start_ms"]),
"end": _to_seconds(row["end_ms"]) if row["end_ms"] is not None else "unknown",
"target": row["query_text"],
"summary": row["summary"],
"confidence_score": row["confidence"],
"modality": row["modality"],
"observation_id": row["observation_id"],
}
if row["screenshot_path"]:
entry["screenshot"] = row["screenshot_path"]
if row["extra_json"]:
try:
entry.update(json.loads(row["extra_json"]))
except (TypeError, json.JSONDecodeError):
pass
entries.append(entry)
return entries
def list_ledger_entries(self) -> list[dict[str, Any]]:
"""Return observations plus successful query-specific segment summaries."""
entries = self.list_observations()
if self.db_path is None:
with self._lock:
for (query_key, modality, start_ms, end_ms), value in self._memory_segments.items():
if value.get("status") != "succeeded":
continue
entries.append(
{
"start": _to_seconds(start_ms),
"end": _to_seconds(end_ms),
"target": value.get("query_text", ""),
"summary": value.get("summary") or "No summary provided.",
"confidence_score": 0.5,
"modality": modality,
"kind": "segment_result",
"query_key": query_key,
}
)
else:
with self._connection() as conn:
rows = conn.execute(
"""
SELECT query_key, query_text, modality, start_ms, end_ms, summary
FROM video_analysis_segments
WHERE board_key = ? AND status = 'succeeded'
ORDER BY start_ms, updated_at
""",
(self.board_key,),
).fetchall()
entries.extend(
{
"start": _to_seconds(row["start_ms"]),
"end": _to_seconds(row["end_ms"]),
"target": row["query_text"],
"summary": row["summary"] or "No summary provided.",
"confidence_score": 0.5,
"modality": row["modality"],
"kind": "segment_result",
"query_key": row["query_key"],
}
for row in rows
)
return sorted(entries, key=lambda value: float(value.get("start", 0.0)))
def claim_segment(
self,
start: float,
end: float,
query: str,
*,
modality: str = "video",
model_name: str | None = None,
source_generation: int | None = None,
) -> SegmentClaim:
"""Atomically acquire work, or return a cached/in-progress result."""
start_ms = _to_ms(start)
end_ms = _to_ms(end)
if end_ms <= start_ms:
raise ValueError("Video analysis segment end must be greater than start")
q_key = _query_key(query)
key = (q_key, modality, start_ms, end_ms)
now = time.time()
owner = str(uuid4())
expires = now + self.lease_seconds
if self.db_path is None:
with self._lock:
existing = self._memory_segments.get(key)
if existing and existing["status"] == "succeeded":
return SegmentClaim(
"cached", summary=existing.get("summary"), analysis=existing.get("analysis")
)
if (
existing
and existing["status"] == "running"
and float(existing.get("lease_expires_at") or 0.0) > now
):
return SegmentClaim("in_progress")
attempts = int(existing.get("attempt_count", 0)) + 1 if existing else 1
self._memory_segments[key] = {
"query_text": query,
"status": "running",
"lease_owner": owner,
"lease_expires_at": expires,
"attempt_count": attempts,
"model_name": model_name,
"source_generation": source_generation,
"start_ms": start_ms,
"end_ms": end_ms,
"updated_at": now,
}
return SegmentClaim("claimed", lease_owner=owner)
with self._connection() as conn:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"""
SELECT * FROM video_analysis_segments
WHERE board_key = ? AND query_key = ? AND modality = ?
AND start_ms = ? AND end_ms = ?
""",
(self.board_key, q_key, modality, start_ms, end_ms),
).fetchone()
if row is not None and row["status"] == "succeeded":
conn.commit()
return SegmentClaim("cached", summary=row["summary"], analysis=row["analysis"])
if (
row is not None
and row["status"] == "running"
and float(row["lease_expires_at"] or 0.0) > now
):
conn.commit()
return SegmentClaim("in_progress")
if row is None:
conn.execute(
"""
INSERT INTO video_analysis_segments (
board_key, query_key, query_text, modality, start_ms, end_ms,
status, lease_owner, lease_expires_at, attempt_count,
model_name, source_generation, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 'running', ?, ?, 1, ?, ?, ?, ?)
""",
(
self.board_key,
q_key,
query,
modality,
start_ms,
end_ms,
owner,
expires,
model_name,
source_generation,
now,
now,
),
)
else:
conn.execute(
"""
UPDATE video_analysis_segments
SET status = 'running', lease_owner = ?, lease_expires_at = ?,
attempt_count = attempt_count + 1, error = NULL,
error_category = NULL, model_name = ?, source_generation = ?,
updated_at = ?
WHERE board_key = ? AND query_key = ? AND modality = ?
AND start_ms = ? AND end_ms = ?
""",
(
owner,
expires,
model_name,
source_generation,
now,
self.board_key,
q_key,
modality,
start_ms,
end_ms,
),
)
conn.commit()
return SegmentClaim("claimed", lease_owner=owner)
def complete_segment(
self,
start: float,
end: float,
query: str,
lease_owner: str,
summary: str,
analysis: str | None = None,
*,
modality: str = "video",
) -> None:
"""Commit a complete result while preserving lease ownership."""
key = (_query_key(query), modality, _to_ms(start), _to_ms(end))
now = time.time()
if self.db_path is None:
with self._lock:
row = self._memory_segments.get(key)
if row is None or row.get("lease_owner") != lease_owner:
return
row.update(
{
"status": "succeeded",
"summary": summary,
"analysis": analysis,
"lease_expires_at": None,
"updated_at": now,
}
)
return
with self._connection() as conn:
conn.execute(
"""
UPDATE video_analysis_segments
SET status = 'succeeded', summary = ?, analysis = ?, error = NULL,
lease_expires_at = NULL, updated_at = ?
WHERE board_key = ? AND query_key = ? AND modality = ?
AND start_ms = ? AND end_ms = ? AND lease_owner = ?
""",
(
summary,
analysis,
now,
self.board_key,
key[0],
modality,
key[2],
key[3],
lease_owner,
),
)
conn.commit()
def fail_segment(
self,
start: float,
end: float,
query: str,
lease_owner: str,
error: str,
*,
modality: str = "video",
retryable: bool = True,
error_category: str | None = None,
) -> None:
"""Persist a failed attempt without deleting prior observations."""
key = (_query_key(query), modality, _to_ms(start), _to_ms(end))
status = "retryable_failed" if retryable else "permanent_failed"
now = time.time()
if self.db_path is None:
with self._lock:
row = self._memory_segments.get(key)
if row is None or row.get("lease_owner") != lease_owner:
return
row.update(
{
"status": status,
"error": str(error),
"error_category": error_category,
"lease_expires_at": None,
"updated_at": now,
}
)
return
with self._connection() as conn:
conn.execute(
"""
UPDATE video_analysis_segments
SET status = ?, error = ?, error_category = ?,
lease_expires_at = NULL, updated_at = ?
WHERE board_key = ? AND query_key = ? AND modality = ?
AND start_ms = ? AND end_ms = ? AND lease_owner = ?
""",
(
status,
str(error),
error_category,
now,
self.board_key,
key[0],
modality,
key[2],
key[3],
lease_owner,
),
)
conn.commit()
def metrics(self) -> dict[str, Any]:
"""Return a compact operational snapshot for tracing and diagnostics."""
if self.db_path is None:
with self._lock:
rows = [dict(value) for value in self._memory_segments.values()]
observation_count = len(self._memory_observations)
else:
with self._connection() as conn:
rows = [
dict(row)
for row in conn.execute(
"""
SELECT status, attempt_count, start_ms, end_ms, error_category
FROM video_analysis_segments WHERE board_key = ?
""",
(self.board_key,),
).fetchall()
]
observation_count = int(
conn.execute(
"SELECT COUNT(*) FROM video_analysis_observations WHERE board_key = ?",
(self.board_key,),
).fetchone()[0]
)
statuses: dict[str, int] = {}
failure_categories: dict[str, int] = {}
successful_seconds = 0.0
attempts = 0
for row in rows:
status = str(row.get("status", "unknown"))
statuses[status] = statuses.get(status, 0) + 1
attempts += int(row.get("attempt_count", 0) or 0)
category = row.get("error_category")
if category:
failure_categories[str(category)] = failure_categories.get(str(category), 0) + 1
if status == "succeeded" and row.get("start_ms") is not None:
successful_seconds += max(
0.0,
(float(row.get("end_ms", 0)) - float(row.get("start_ms", 0))) / 1000.0,
)
return {
"board_key": self.board_key,
"segments": statuses,
"attempts": attempts,
"failure_categories": failure_categories,
"successful_seconds": round(successful_seconds, 3),
"observations": observation_count,
}
def successful_segments(
self, start: float, end: float, query: str, *, modality: str = "video"
) -> list[dict[str, Any]]:
"""Return successful segments overlapping a requested interval."""
start_ms, end_ms = _to_ms(start), _to_ms(end)
q_key = _query_key(query)
if self.db_path is None:
with self._lock:
rows = []
for (
row_query,
row_modality,
row_start,
row_end,
), value in self._memory_segments.items():
if (
row_query == q_key
and row_modality == modality
and value.get("status") == "succeeded"
and row_end > start_ms
and row_start < end_ms
):
rows.append(
{
**value,
"start": _to_seconds(row_start),
"end": _to_seconds(row_end),
}
)
return sorted(rows, key=lambda value: value["start"])
with self._connection() as conn:
db_rows = conn.execute(
"""
SELECT start_ms, end_ms, summary, analysis, status
FROM video_analysis_segments
WHERE board_key = ? AND query_key = ? AND modality = ?
AND status = 'succeeded' AND end_ms > ? AND start_ms < ?
ORDER BY start_ms
""",
(self.board_key, q_key, modality, start_ms, end_ms),
).fetchall()
return [
{
"start": _to_seconds(row["start_ms"]),
"end": _to_seconds(row["end_ms"]),
"summary": row["summary"],
"analysis": row["analysis"],
"status": row["status"],
}
for row in db_rows
]
def missing_intervals(
self, start: float, end: float, query: str, *, modality: str = "video"
) -> list[tuple[float, float]]:
"""Subtract successful exact-query coverage from a requested interval."""
start_ms, end_ms = _to_ms(start), _to_ms(end)
if end_ms <= start_ms:
return []
covered = []
for row in self.successful_segments(start, end, query, modality=modality):
covered.append((max(start_ms, _to_ms(row["start"])), min(end_ms, _to_ms(row["end"]))))
covered = sorted((left, right) for left, right in covered if right > left)
merged: list[tuple[int, int]] = []
for left, right in covered:
if not merged or left > merged[-1][1]:
merged.append((left, right))
else:
merged[-1] = (merged[-1][0], max(merged[-1][1], right))
missing: list[tuple[float, float]] = []
cursor = start_ms
for left, right in merged:
if left > cursor:
missing.append((_to_seconds(cursor), _to_seconds(left)))
cursor = max(cursor, right)
if cursor < end_ms:
missing.append((_to_seconds(cursor), _to_seconds(end_ms)))
return missing
def format_cached_segments(
self, start: float, end: float, query: str, *, modality: str = "video"
) -> list[str]:
"""Format cached segment results in the child-agent response shape."""
results = []
for row in self.successful_segments(start, end, query, modality=modality):
text = (
f"[from {row['start']:.1f}s to {row['end']:.1f}s] "
f"Summary: {row.get('summary') or 'No summary provided.'}"
)
if row.get("analysis"):
text += f" Analysis: {row['analysis']}"
results.append(text)
return results
def _safe_path(value: Any) -> Path | None:
if isinstance(value, Path):
return value
if isinstance(value, str) and value:
return Path(value)
return None
def _latest_recorded_video_key(db_path: Path | None, session_id: Any) -> str | None:
"""Resolve a stopped recording so a restarted analyzer keeps its memory key."""
if db_path is None or not session_id or not db_path.exists():
return None
try:
conn = sqlite3.connect(db_path, timeout=5.0)
try:
row = conn.execute(
"""
SELECT video_id FROM video_recordings
WHERE session_id = ?
ORDER BY start_time DESC LIMIT 1
""",
(str(session_id),),
).fetchone()
finally:
conn.close()
except sqlite3.Error:
return None
return f"video:{row[0]}" if row and row[0] else None
def get_video_blackboard(ctx: ArtemisContext) -> VideoBlackboard:
"""Return the blackboard shared by every analyzer in an Artemis context."""
existing = getattr(ctx, "_video_blackboard", None)
if isinstance(existing, VideoBlackboard):
return existing
device_id = str(getattr(getattr(ctx, "device", None), "device_id", "default"))
data_engine = getattr(ctx, "data_engine", None)
db_path = None
base_dir = None
storage = getattr(data_engine, "storage", None)
if storage is not None:
db_path = _safe_path(getattr(storage, "db_path", None))
if data_engine is not None:
base_dir = _safe_path(getattr(data_engine, "base_dir", None))
if base_dir is None:
execution_setup = getattr(ctx, "execution_setup", None)
traces_path = _safe_path(getattr(execution_setup, "traces_path", None))
trace_name = getattr(execution_setup, "trace_name", None)
if traces_path is not None:
base_dir = traces_path / str(trace_name) if trace_name else traces_path
if db_path is None and base_dir is not None:
db_path = base_dir / "video_blackboard.db"
evidence_dir = base_dir / "video_blackboard" / "evidence" if base_dir else None
session = get_active_session(device_id)
current_session_id = getattr(data_engine, "current_session_id", None)
if session is not None:
board_key = f"video:{session.video_id}"
else:
board_key = _latest_recorded_video_key(db_path, current_session_id) or (
f"session:{current_session_id}" if current_session_id else f"device:{device_id}:runtime"
)
board = VideoBlackboard(board_key, db_path=db_path, evidence_dir=evidence_dir)
try:
setattr(ctx, "_video_blackboard", board)
except (AttributeError, ValueError):
logger.debug(
"Context does not allow caching video_blackboard; SQLite sharing remains active"
)
return board
@@ -0,0 +1,157 @@
# 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.
"""Failure classification and circuit breaking for video model calls."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
import threading
import time
from typing import Any
class VideoFailureCategory(StrEnum):
RATE_LIMIT = "rate_limit"
PROVIDER_UNAVAILABLE = "provider_unavailable"
TIMEOUT = "timeout"
CONNECTION = "connection"
AUTHENTICATION = "authentication"
BAD_REQUEST = "bad_request"
MEDIA_PROCESSING = "media_processing"
CANCELLED = "cancelled"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class VideoFailure:
category: VideoFailureCategory
retryable: bool
should_split: bool
should_fallback: bool
def classify_video_failure(error: BaseException) -> VideoFailure:
"""Convert provider/transport/media exceptions into recovery decisions."""
if isinstance(error, KeyboardInterrupt) or type(error).__name__ == "CancelledError":
return VideoFailure(VideoFailureCategory.CANCELLED, False, False, False)
code = getattr(error, "code", None) or getattr(error, "status_code", None)
try:
code = int(code) if code is not None else None
except (TypeError, ValueError):
code = None
message = str(error).lower()
if code == 429 or any(marker in message for marker in ("rate limit", "quota", "resource_exhausted")):
return VideoFailure(VideoFailureCategory.RATE_LIMIT, True, False, True)
if code in {500, 502, 503, 504} or any(
marker in message
for marker in ("unavailable", "overloaded", "high demand", "service unavailable")
):
return VideoFailure(VideoFailureCategory.PROVIDER_UNAVAILABLE, True, False, True)
if isinstance(error, TimeoutError) or "timed out" in message or "timeout" in message:
return VideoFailure(VideoFailureCategory.TIMEOUT, True, True, True)
if isinstance(error, (ConnectionError, OSError)) and not any(
marker in message for marker in ("ffmpeg", "video", "audio", "codec", "media")
):
return VideoFailure(VideoFailureCategory.CONNECTION, True, False, True)
if code in {401, 403} or any(
marker in message for marker in ("unauthorized", "forbidden", "api key", "credential")
):
return VideoFailure(VideoFailureCategory.AUTHENTICATION, False, False, True)
if any(
marker in message
for marker in ("ffmpeg", "codec", "corrupt", "invalid video", "invalid audio", "media")
):
return VideoFailure(VideoFailureCategory.MEDIA_PROCESSING, False, True, True)
if code in {400, 404, 413, 415, 422}:
return VideoFailure(VideoFailureCategory.BAD_REQUEST, False, code == 413, True)
return VideoFailure(VideoFailureCategory.UNKNOWN, True, True, True)
@dataclass
class _CircuitState:
consecutive_failures: int = 0
open_until: float = 0.0
half_open_in_flight: bool = False
class VideoCircuitBreaker:
"""Small context-scoped circuit breaker keyed by provider/model identity."""
def __init__(self, threshold: int = 3, cooldown_seconds: float = 60.0) -> None:
self.threshold = max(1, int(threshold))
self.cooldown_seconds = max(0.1, float(cooldown_seconds))
self._states: dict[str, _CircuitState] = {}
self._lock = threading.RLock()
def allow(self, key: str, *, now: float | None = None) -> bool:
current = time.time() if now is None else now
with self._lock:
state = self._states.get(key)
if state is None:
return True
if state.open_until:
if current < state.open_until or state.half_open_in_flight:
return False
# Half-open: permit exactly one trial. Other concurrent calls
# continue to bypass the primary until it succeeds or fails.
state.half_open_in_flight = True
state.consecutive_failures = max(0, self.threshold - 1)
return True
return not state.half_open_in_flight
def record_success(self, key: str) -> None:
with self._lock:
self._states.pop(key, None)
def record_failure(
self,
key: str,
failure: VideoFailure,
*,
now: float | None = None,
) -> None:
if failure.category not in {
VideoFailureCategory.RATE_LIMIT,
VideoFailureCategory.PROVIDER_UNAVAILABLE,
VideoFailureCategory.TIMEOUT,
VideoFailureCategory.CONNECTION,
}:
with self._lock:
state = self._states.get(key)
if state is not None and state.half_open_in_flight:
self._states.pop(key, None)
return
current = time.time() if now is None else now
with self._lock:
state = self._states.setdefault(key, _CircuitState())
state.half_open_in_flight = False
state.consecutive_failures += 1
if state.consecutive_failures >= self.threshold:
state.open_until = current + self.cooldown_seconds
def snapshot(self) -> dict[str, dict[str, Any]]:
with self._lock:
return {
key: {
"consecutive_failures": state.consecutive_failures,
"open_until": state.open_until,
"half_open_in_flight": state.half_open_in_flight,
}
for key, state in self._states.items()
}
File diff suppressed because it is too large. Load diff