feat(memory): add incremental transcript compaction

This commit is contained in:
somew1nd committed 2026-09-01 17:46:44 -07:00
1 parent cbe8f4f0c6
commit 41ae386a3a
65 files changed
+9971 -815

No files matched your search

+5 -3
View File
@@ -44,8 +44,8 @@ from artemis.tools.tool_wrapper import (
from artemis.tools.video_tool import get_video_analyzer_tool
from artemis.tools.wait_tool import get_wait_tool
from artemis.utils.logger import get_logger
from artemis.memory.context_policy import build_history_for
from artemis.utils.task_tree import (
build_plan_and_history,
get_active_subgoal_hashes,
get_recent_subgoal_hashes,
)
@@ -160,14 +160,16 @@ class Diagnoser:
keep_hashes = get_recent_subgoal_hashes(
steps,
subgoal_hash,
window_steps=self.ctx.agent_config.history_window_steps,
self.ctx.data_engine.base_dir,
)
plan_and_history = build_plan_and_history(
plan_and_history = build_history_for(
"diagnoser",
task_plan,
steps,
subgoal_hash,
keep_subgoal_hashes=keep_hashes,
engine=self.ctx.data_engine,
)
except Exception as e:
logger.error(f"Failed to build plan and history in Diagnoser: {e}")
+280 -108
View File
@@ -14,16 +14,24 @@
"""Universal Context Compressor for Artemis Flash profile.
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.
:class:`ScrubEdgeCompressor` implements the scrub-edge (擦洗沿) discipline
from the history-module redesign §3.2: each message is mutated a bounded
number of times near the tail (XML stripped at depth 1, screenshot resolved
at depth K), then frozen forever. A late summary never backfills a frozen
message. FlashRunner uses this implementation; the Pro transcript ledger
reuses it over its active region (see the class docstring).
The legacy per-turn full-rescan ``compress_flash_messages`` was removed in M5
(2026-09-01) after serving as the M1/M2 semantic reference — its surviving
semantics are asserted directly against ``ScrubEdgeCompressor`` in
``tests/unit/agents/test_flash_scrub_edge.py``.
"""
from typing import Any
from typing import Any, Callable
from langchain_core.messages import BaseMessage
from artemis.agents.flash.summarizer import VisualStepSummarizer
from artemis.memory.step_memory import StepMemoryService
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@@ -32,134 +40,298 @@ _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)
def _without_marked_suffix(text: str, markers: tuple[str, ...] = (_UI_LIST_MARKER,)) -> str:
"""Remove a marked heavy block without discarding text before its marker."""
marker_index = -1
for marker in markers:
idx = text.find(marker)
if idx >= 0 and (marker_index < 0 or idx < marker_index):
marker_index = idx
if marker_index < 0:
return text
return text[:marker_index].rstrip()
def compress_flash_messages(
messages: list[BaseMessage],
summarizer: VisualStepSummarizer | None = None,
prune_history_xml: bool = True,
) -> None:
"""Compresses multi-turn conversation messages in-place.
class ScrubEdgeCompressor:
"""Scrub-edge context compressor (history-module redesign §3.2, M1).
Rules:
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, 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.
Mutation discipline — every message is touched at most twice, always near
the tail, and is then frozen forever:
1. Depth 1 (XML): once a newer observation exists, historical UI Element
lists are stripped (same semantics as the legacy ``prune_history_xml``).
2. Depth K (image, ``image_scrub_depth``): the K-th most recent
image-bearing message has its screenshot resolved — replaced by the
ready visual summary; if the summary is still pending the image is
retained for up to ``pending_grace_steps`` further image-depths, after
which it is replaced by a pending placeholder. A failed summary is
replaced by an unavailable placeholder immediately. Both placeholders
carry the DataEngine step reference.
3. Frozen: after resolution the message is never read or written again —
a late summary never backfills a frozen message.
Contract: the message list is append-only (FlashRunner never removes or
reorders entries), so bookkeeping is index-based and each pass only
touches the few messages near the scrub edge instead of rescanning the
whole history.
Profile generalization (M2): the Pro transcript ledger reuses this exact
discipline over its active region. Its observation messages carry no
``tool_call_id``, so ``summary_key_getter`` supplies the summary-job key
(the DataEngine step id) per message; ``strip_markers`` extends the
depth-1 strip to the Pro tail blocks (UI element list + plan recitation);
and ``tail_offset=1`` accounts for the live observation living outside
the tracked list (the ledger renders it as a separate tail), so depth
arithmetic and the keep-window stay aligned with the Flash semantics
where the live observation is *inside* the list. Defaults leave the
Flash behavior byte-identical.
"""
if not messages:
return
# 1. Identify the last message containing an active screenshot
last_img_msg_idx = -1
for idx in range(len(messages) - 1, -1, -1):
msg = messages[idx]
msg_content = getattr(msg, "content", None)
if isinstance(msg_content, list):
has_image = any(
isinstance(block, dict) and block.get("type") in ("image_url", "image")
for block in msg_content
)
def __init__(
self,
summarizer: StepMemoryService | None = None,
*,
prune_history_xml: bool = True,
image_scrub_depth: int = 3,
pending_grace_steps: int = 3,
xml_scrub_depth: int = 1,
summary_key_getter: Callable[[BaseMessage], str | None] | None = None,
strip_markers: tuple[str, ...] = (_UI_LIST_MARKER,),
tail_offset: int = 0,
):
self._summarizer = summarizer
self._prune_history_xml = prune_history_xml
self._image_scrub_depth = max(1, image_scrub_depth)
self._pending_grace_steps = max(0, pending_grace_steps)
self._xml_scrub_depth = max(1, xml_scrub_depth)
self._summary_key_getter = summary_key_getter
self._strip_markers = tuple(strip_markers) or (_UI_LIST_MARKER,)
self._tail_offset = max(0, tail_offset)
self._scanned_until = 0
self._tool_msg_count = 0
# Append-only records of image-bearing messages, in message order:
# {"idx", "key" (str tool_call_id | None), "legacy" (ordinal | None), "is_tool"}
self._tracked: list[dict[str, Any]] = []
self._frozen: set[int] = set()
self._xml_candidates: list[int] = []
def compress(self, messages: list[BaseMessage]) -> None:
"""Advance the scrub edge over newly appended messages in-place."""
self._discover(messages)
if not self._tracked:
return
self._scrub_xml(messages)
self._scrub_images(messages)
# ------------------------------------------------------------------
# Discovery (incremental; frozen prefix is never rescanned)
# ------------------------------------------------------------------
def _discover(self, messages: list[BaseMessage]) -> None:
for idx in range(self._scanned_until, len(messages)):
msg = messages[idx]
tool_call_id = getattr(msg, "tool_call_id", None)
if tool_call_id is not None:
self._tool_msg_count += 1
content = getattr(msg, "content", None)
if not isinstance(content, list):
continue
has_image = False
has_xml = False
for block in content:
if not isinstance(block, dict):
continue
if block.get("type") in ("image_url", "image"):
has_image = True
elif block.get("type") == "text" and self._has_strip_marker(
block.get("text", "")
):
has_xml = True
if has_xml:
self._xml_candidates.append(idx)
if has_image:
last_img_msg_idx = idx
break
key = str(tool_call_id) if tool_call_id is not None else None
if key is None and self._summary_key_getter is not None:
try:
getter_key = self._summary_key_getter(msg)
except Exception:
getter_key = None
if getter_key is not None:
key = str(getter_key)
self._tracked.append(
{
"idx": idx,
"key": key,
"legacy": self._tool_msg_count if tool_call_id is not None else None,
"is_tool": tool_call_id is not None,
}
)
self._scanned_until = len(messages)
# 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]
msg_content = getattr(msg, "content", None)
if not isinstance(msg_content, list):
continue
def _has_strip_marker(self, text: str) -> bool:
return any(marker in text for marker in self._strip_markers)
# Keep the latest live screen intact
if idx == last_img_msg_idx:
continue
# ------------------------------------------------------------------
# Depth-1 XML scrub
# ------------------------------------------------------------------
# Count tool steps strictly corresponding to FlashRunner action turns
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
legacy_step_num = tool_step_counter
else:
legacy_step_num = None
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)
def _scrub_xml(self, messages: list[BaseMessage]) -> None:
if not self._prune_history_xml:
return
# Keep the UI list only in the newest xml_scrub_depth observation
# messages; everything older (or non-image XML carriers) is stripped.
# With a live tail outside the tracked list (tail_offset > 0) the tail
# itself occupies the newest slots of the keep window.
keep_count = max(0, self._xml_scrub_depth - self._tail_offset)
keep_indices = (
{rec["idx"] for rec in self._tracked[-keep_count:]} if keep_count else set()
)
still_pending: list[int] = []
for idx in self._xml_candidates:
if idx in keep_indices:
still_pending.append(idx)
continue
if idx in self._frozen:
continue
self._strip_xml_blocks(messages[idx])
self._xml_candidates = still_pending
def _strip_xml_blocks(self, msg: BaseMessage) -> None:
content = getattr(msg, "content", None)
if not isinstance(content, list):
return
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", "")
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)
for block in content:
if (
isinstance(block, dict)
and block.get("type") == "text"
and self._has_strip_marker(block.get("text", ""))
):
retained_text = _without_marked_suffix(
block.get("text", ""), self._strip_markers
)
if retained_text:
retained_block = dict(b)
retained_block = dict(block)
retained_block["text"] = retained_text
new_blocks.append(retained_block)
continue
new_blocks.append(block)
msg.content = new_blocks
if b_type in ("image_url", "image"):
# 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)
# ------------------------------------------------------------------
# Depth-K image scrub
# ------------------------------------------------------------------
def _scrub_images(self, messages: list[BaseMessage]) -> None:
total = len(self._tracked)
scrub_depth = self._image_scrub_depth
grace_limit = scrub_depth + self._pending_grace_steps
for pos, rec in enumerate(self._tracked):
if rec["idx"] in self._frozen:
continue
# 1 == most recent image-bearing message; a live tail outside the
# tracked list (tail_offset > 0) counts as the newest depths.
depth = total - pos + self._tail_offset
if depth < scrub_depth:
continue # active window
if self._tail_offset == 0 and pos == total - 1:
continue # the live observation is never scrubbed
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(
{
summary, pending, failed, step_no = self._lookup(rec)
if summary is not None:
replacement = {
"type": "text",
"text": f"{_HISTORY_SUMMARY_PREFIX}{summary_text}",
"text": f"{_HISTORY_SUMMARY_PREFIX}{summary}",
}
)
elif failed:
replacement = {
"type": "text",
"text": f"[visual summary unavailable; evidence at DataEngine step {step_no}]",
}
elif pending and depth <= grace_limit:
continue # grace period: retain the image, revisit next pass
elif pending:
replacement = {
"type": "text",
"text": f"[visual summary pending; evidence at DataEngine step {step_no}]",
}
else:
replacement = None # no summary job exists; drop the image silently
self._resolve(messages, rec, replacement)
def _lookup(self, rec: dict[str, Any]) -> tuple[str | None, bool, bool, int | None]:
"""Mirror the legacy keying: tool_call_id first, ordinal fallback."""
summarizer = self._summarizer
if summarizer is None:
return None, False, False, None
effective_key: Any = rec["key"]
if effective_key is None:
return None, False, False, None
summary = summarizer.get_summary(effective_key)
if (
summary is None
and not summarizer.has_job(effective_key)
and rec["legacy"] is not None
):
effective_key = rec["legacy"]
summary = summarizer.get_summary(effective_key)
failed = summarizer.has_failed(effective_key)
pending = summarizer.is_pending(effective_key) and not failed
step_no = summarizer.get_step_number(effective_key)
if step_no is None:
step_no = rec["legacy"]
return summary, pending, failed, step_no
def _resolve(
self,
messages: list[BaseMessage],
rec: dict[str, Any],
replacement: dict[str, Any] | None,
) -> None:
"""Replace the message's image blocks and freeze it permanently."""
msg = messages[rec["idx"]]
content = getattr(msg, "content", None)
new_blocks: list[Any] = []
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") in ("image_url", "image"):
continue
if (
self._prune_history_xml
and isinstance(block, dict)
and block.get("type") == "text"
and self._has_strip_marker(block.get("text", ""))
):
# Freezing implies fully scrubbed: no stale UI list survives
# even when xml_scrub_depth exceeds the image depth.
retained_text = _without_marked_suffix(
block.get("text", ""), self._strip_markers
)
if retained_text:
retained_block = dict(block)
retained_block["text"] = retained_text
new_blocks.append(retained_block)
continue
new_blocks.append(block)
if replacement is not None:
new_blocks.append(replacement)
# 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 ""}]
new_blocks = [
{"type": "text", "text": "Action completed." if rec["is_tool"] else ""}
]
msg.content = new_blocks
self._frozen.add(rec["idx"])
if rec["idx"] in self._xml_candidates:
self._xml_candidates.remove(rec["idx"])
+1 -1
View File
@@ -6,7 +6,7 @@ You are the Step Summarizer for an Android UI automation agent. Your task is to
# PERSPECTIVE & FORMAT CONSTRAINTS
1. **First-Person Perspective**: Write strictly from the agent's first-person perspective using **"I"** (e.g., "In Step {{ step_number }}, I tapped...", "I swiped up on... and observed..."). NEVER use third-person terms like "The agent", "The operator", or "The system".
2. **Single Continuous Paragraph**: Your output MUST be exactly **one compact, continuous paragraph** (1–3 sentences, 35–65 words).
3. **No Lists or Formatting**: Do NOT use bullet points, numbered lists, markdown headers, bold labels, or line breaks in your output.
3. **No Lists or Formatting**: Do NOT use bullet points, numbered lists, markdown headers, bold labels, or line breaks in your output. Your output must NEVER contain `---` separators or section-marker lines — those belong to the input, not the summary.
---
@@ -0,0 +1,42 @@
# ROLE & OBJECTIVE
You are the Step Summarizer for an Android UI automation agent. Exactly ONE screenshot is available for this step — the decision frame captured when the action was issued. There is NO after-action screenshot. Your task is to describe, strictly objectively and in first person, what THIS screen showed and where the physical action landed.
---
# SINGLE-FRAME EVIDENCE RULES
1. **Missing after-frame ≠ unchanged screen**: The absence of an after-action screenshot ONLY means there is no independent post-action evidence for this step. It does NOT mean the screen stayed the same, and it does NOT license any guess about what happened next.
2. **Describe only this frame**: Report the screen/page identity, the key visible content (titles, list entries, values, toggle states, prices, codes, error banners), and the control targeted by the action — referencing the red visual marker when one is present.
3. **Never synthesize a transition**: Do NOT describe, predict, or imply the post-action screen state. The step-to-step story is reconstructed from neighboring steps' summaries — not by you.
---
# PERSPECTIVE & FORMAT CONSTRAINTS
1. **First-Person Perspective**: Write strictly from the agent's first-person perspective using **"I"** (e.g., "In Step {{ step_number }}, I tapped... on a screen showing..."). NEVER use third-person terms like "The agent", "The operator", or "The system".
2. **Single Continuous Paragraph**: Your output MUST be exactly **one compact, continuous paragraph** (1–3 sentences, 35–65 words).
3. **No Lists or Formatting**: Do NOT use bullet points, numbered lists, markdown headers, bold labels, or line breaks. Your output must NEVER contain `---` separators or section-marker lines — those belong to the input, not the summary.
---
# CORE OBSERVATION RULES
1. **Zero Subjective Validation (STRICT)**:
- Absolutely avoid declaring semantic goal achievement, subjective success, or final failure. In historical compression, subjective assumptions cause future turns to falsely believe a subgoal was definitively accomplished or permanently blocked.
- **STRICTLY BANNED WORDS**: `successfully`, `completed`, `achieved`, `entered`, `navigated to`, `arrived at`, `failed`, `unsuccessful`, `could not`, `impossible`.
2. **Preserve Critical Data**: Faithfully transcribe visible text, toast alerts, error banners, verification codes, prices, account names, or tracking numbers that appear on this screen.
---
# EXAMPLES OF HIGH-QUALITY SINGLE-FRAME SUMMARIES
- **Click on a list row**:
"In Step 5, I tapped the 'Battery' row marked in red in the Settings list, which showed sections for 'Network & internet', 'Display', and 'Battery' with the battery level reading '82%'."
- **Text input**:
"In Step 8, I typed 'Tokyo Hotel' into the search field at the top of the Maps screen; before my input the field read 'Search here' and the keyboard occupied the lower half of the screen."
- **Swipe / Scroll**:
"In Step 3, I swiped up starting from the red-marked point on the flight results list, which at that moment displayed 4 morning flights priced from '$310' under the header 'Best departing flights'."
---
# TASK
Generate the single-paragraph first-person summary for Step {{ step_number }} following all constraints above:
+48 -10
View File
@@ -33,7 +33,7 @@ from langchain_core.messages import (
ToolMessage,
)
from artemis.agents.flash.context_compressor import compress_flash_messages
from artemis.agents.flash.context_compressor import ScrubEdgeCompressor
from artemis.agents.flash.summarizer import VisualStepSummarizer
from artemis.agents.validator.tool_declarations import (
ASK_EXPLORER_TOOL,
@@ -43,7 +43,12 @@ from artemis.agents.validator.tool_declarations import (
capture_screenshot_and_parse_ui,
prune_intermediate_screenshots,
)
from artemis.config import StepSummarizerConfig, load_agent_config
from artemis.config import (
MemoryRuntimeConfig,
MemoryTranscriptConfig,
StepSummarizerConfig,
load_agent_config,
)
from artemis.context import ArtemisContext
from artemis.controllers.unified_controller import UnifiedMobileController
from artemis.data_engine.trace import trace
@@ -74,17 +79,34 @@ class FlashRunner:
cfg = load_agent_config()
self.max_turns = max_turns if max_turns is not None else cfg.flash.max_turns
self.step_summarizer_cfg = cfg.flash.step_summarizer
self.memory_runtime_cfg = cfg.memory.runtime
self.transcript_cfg = cfg.memory.transcript
except Exception:
self.max_turns = max_turns if max_turns is not None else 30
self.step_summarizer_cfg = StepSummarizerConfig()
self.memory_runtime_cfg = MemoryRuntimeConfig()
self.transcript_cfg = MemoryTranscriptConfig()
self.controller = UnifiedMobileController(ctx)
self.executor = McpActionExecutor(ctx, self.controller)
self.summarizer = (
VisualStepSummarizer(ctx, model_name=self.step_summarizer_cfg.model)
VisualStepSummarizer(
ctx,
model_name=self.step_summarizer_cfg.model,
retry_limit=self.memory_runtime_cfg.retry_limit,
max_concurrency=self.memory_runtime_cfg.max_concurrency,
flush_timeout_s=self.memory_runtime_cfg.flush_timeout_s,
)
if self.step_summarizer_cfg.enabled
else None
)
# Publish the service on the composition root (ctx.step_memory, M2) so
# any co-resident consumer shares this run's summary runtime.
if self.summarizer is not None and getattr(ctx, "step_memory", None) is None:
try:
ctx.step_memory = self.summarizer
except Exception:
pass
def _get_tools(self) -> list:
tools = [t for t in VALIDATOR_TOOLS_DECLARATION if t.name != "report_failure_analysis"]
@@ -109,6 +131,16 @@ class FlashRunner:
async def run(self, state: State) -> dict:
logger.info(f"Starting Artemis Flash reactive loop for goal: {self.goal}")
# Fresh scrub-edge ledger per run: the frozen/watermark bookkeeping is
# execution state bound to this run's append-only message list.
compressor = ScrubEdgeCompressor(
summarizer=self.summarizer,
prune_history_xml=self.step_summarizer_cfg.prune_history_xml,
image_scrub_depth=self.transcript_cfg.image_scrub_depth,
pending_grace_steps=self.transcript_cfg.pending_grace_steps,
xml_scrub_depth=self.transcript_cfg.xml_scrub_depth,
)
# 1. Initialize Universal LLM via Service Layer
try:
llm = get_llm(self.ctx, name="operator")
@@ -204,13 +236,10 @@ class FlashRunner:
except Exception as e:
logger.warning(f"Failed to check injected instruction in FlashRunner: {e}")
# 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,
)
# Advance the scrub edge even when visual summarization is
# disabled, so screenshot and historical XML pruning keep one
# code path. Messages behind the edge are frozen permanently.
compressor.compress(messages)
# Tool restriction on the final turn
if turns == self.max_turns:
@@ -477,6 +506,15 @@ class FlashRunner:
action_dict["normalized_start_coordinates"] = norm_start
action_dict["normalized_end_coordinates"] = norm_end
# Record-time enrichment computed by the executor from
# the pre-action frame (target_text / target_class /
# target_resource_id / target_label_source).
target_semantics = (exec_result.metadata or {}).get(
"target_semantics"
)
if isinstance(target_semantics, dict):
action_dict.update(target_semantics)
recorded_step_id = self.ctx.data_engine.record_step(
pre_screenshot_bytes=current_pre_screenshot_bytes,
post_screenshot_bytes=post_img_bytes,
+171 -77
View File
@@ -16,7 +16,13 @@
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 independently retries failed jobs.
Scheduling (zero-blocking dispatch, bounded retry, bounded flush, step_id
keying with tool_call_id aliases) lives in the shared
:class:`artemis.memory.step_memory.StepMemoryService`; this class contributes
only the visual-transition lens: red action-marker overlay on the BEFORE
frame, the ``flash_summarizer.md`` neutral-wording contract, and versioned
``summary_status`` writes to the DataEngine.
"""
import asyncio
@@ -29,32 +35,61 @@ from jinja2 import Template
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
from artemis.context import ArtemisContext
from artemis.services.llm import get_google_llm, get_llm
from artemis.memory.step_memory import JobKey, StepMemoryService
from artemis.services.llm import RobustChatModelWrapper, get_google_llm, get_llm
from artemis.services.token_meter import record_llm_usage
from artemis.utils.logger import get_logger
from artemis.utils.visualization import draw_action_overlay_on_image
logger = get_logger(__name__)
# Degenerate-output guard (§5 echo validation): a summary that echoes the
# input's section markers, or falls outside sane length bounds, fails the
# attempt so the bounded service retry regenerates it.
SUMMARY_ECHO_MARKER = "--- ["
SUMMARY_MIN_CHARS = 15
SUMMARY_MAX_CHARS = 1500
class VisualStepSummarizer:
"""Non-blocking background worker generating objective visual step summaries.
Key design properties:
def degenerate_summary_reason(text: str) -> str | None:
"""Why a lens output is unusable (echo/length), or None when it is fine."""
if SUMMARY_ECHO_MARKER in text or text.startswith("---"):
return "echoes an input section marker"
if len(text) < SUMMARY_MIN_CHARS:
return f"too short ({len(text)} chars < {SUMMARY_MIN_CHARS})"
if len(text) > SUMMARY_MAX_CHARS:
return f"too long ({len(text)} chars > {SUMMARY_MAX_CHARS})"
return None
class VisualStepSummarizer(StepMemoryService):
"""Visual-transition lens on top of the shared step-memory runtime.
Key design properties (inherited from StepMemoryService):
1. Zero-blocking dispatch: The main Flash runner dispatches and proceeds immediately.
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.
3. Independent bounded retry: Every action owns a retry loop capped at 1 + retry_limit attempts.
"""
def __init__(self, ctx: ArtemisContext, model_name: str | None = None):
self.ctx = ctx
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)
def __init__(
self,
ctx: ArtemisContext,
model_name: str | None = None,
retry_limit: int = 3,
*,
max_concurrency: int = 1,
flush_timeout_s: float = 30.0,
):
super().__init__(
ctx,
max_concurrency=max_concurrency,
retry_limit=retry_limit,
flush_timeout_s=flush_timeout_s,
)
# Initialize lightweight VLM: prioritize explicit model_name
target_model = model_name or "gemini-2.5-flash-lite"
self._model_name = target_model
try:
if model_name:
self._llm = get_google_llm(model_name=target_model, temperature=0.0)
@@ -62,8 +97,19 @@ class VisualStepSummarizer:
self._llm = get_llm(ctx, name="summarizer", is_utils=True)
except Exception:
self._llm = get_google_llm(model_name=target_model, temperature=0.0)
try:
configured = getattr(self._llm, "model", None) or getattr(
self._llm, "model_name", None
)
if isinstance(configured, str) and configured:
self._model_name = configured
except Exception:
pass
# Load system prompt template
# Load system prompt templates: the dual-frame transition prompt and
# the single-frame variant (§5 revision: describe whichever frames
# exist — a Pro step record never carries an independent after-frame,
# by design, and must not be prompted as if one were expected).
prompt_path = Path(__file__).parent / "flash_summarizer.md"
if prompt_path.exists():
self._prompt_template = prompt_path.read_text(encoding="utf-8")
@@ -74,6 +120,19 @@ class VisualStepSummarizer:
"continuous first-person paragraph using 'I' (e.g., 'In Step {{ step_number }}, I tapped... and observed...').\n"
"Strictly avoid subjective validation words: successfully, completed, failed, achieved, navigated to."
)
single_path = Path(__file__).parent / "flash_summarizer_single.md"
if single_path.exists():
self._single_prompt_template = single_path.read_text(encoding="utf-8")
else:
self._single_prompt_template = (
"You are the Step Summarizer for an Android UI automation agent.\n"
"Exactly ONE screenshot (the decision frame) is available; there is NO after-action screenshot —"
" that only means no independent post-action evidence exists.\n"
"Describe strictly what THIS screen shows and where the action landed (red marker), in exactly ONE"
" continuous first-person paragraph using 'I' for Step {{ step_number }}. Never describe or guess"
" the post-action state.\n"
"Strictly avoid subjective validation words: successfully, completed, failed, achieved, navigated to."
)
def dispatch(
self,
@@ -87,9 +146,23 @@ class VisualStepSummarizer:
action_key: str | None = None,
data_engine_step_id: UUID | str | None = None,
) -> None:
"""Dispatches an asynchronous summarization task without blocking the caller."""
key: int | str = action_key or step_number
self._step_inputs[key] = {
"""Dispatches an asynchronous summarization task without blocking the caller.
Jobs are keyed by the DataEngine step id when one is available; the
tool_call_id (``action_key``) is retained as an alias so the message
compressor can keep querying by it. Callers with neither provide the
step ordinal, matching the legacy keying.
"""
key: JobKey
aliases: tuple[JobKey, ...] = ()
if data_engine_step_id is not None:
key = str(data_engine_step_id)
if action_key is not None:
aliases = (action_key,)
else:
key = action_key if action_key is not None else step_number
payload = {
"step_number": step_number,
"action_name": action_name,
"action_args": action_args,
@@ -98,31 +171,49 @@ class VisualStepSummarizer:
"exec_outcome": exec_outcome,
"data_engine_step_id": data_engine_step_id,
}
self.submit(key, payload, aliases=aliases)
task = asyncio.create_task(self._run_summary_until_ready(key))
self._pending_tasks[key] = task
def _meter_lens_call(self, response: Any) -> None:
"""Meter one raw-model lens call as an ``llm_usage`` trace, best-effort.
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
Gateway-wrapped models already meter at the wrapper exit; only the raw
``get_google_llm`` bypass needs explicit metering here. Lens prompts
are tiny and must not overwrite the session's ``last_prompt_tokens``
(the compaction thresholds' live context base), hence
``update_last_prompt=False``.
"""
if isinstance(self._llm, RobustChatModelWrapper):
return
engine = getattr(self.ctx, "data_engine", None) if self.ctx else None
record_llm_usage(
engine,
response,
source=f"lens:visual_transition:{self._model_name}",
update_last_prompt=False,
)
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"
def _on_status(self, key: JobKey, status: str) -> None:
"""Best-effort DataEngine summary-status write (pending/failed)."""
if not self.ctx.data_engine:
return
payload = self._step_inputs.get(key) or {}
target_step = payload.get("data_engine_step_id")
if not target_step:
return
try:
self.ctx.data_engine.update_step_summary(
target_step,
None,
status=status,
source="visual_transition",
model=self._model_name,
)
if delay:
await asyncio.sleep(delay)
except Exception as de_err:
logger.debug(f"DataEngine summary status update skipped: {de_err}")
async def _run_summary_once(self, action_key: int | str) -> bool:
async def _attempt(self, key: JobKey) -> bool:
"""Execute one lightweight VLM attempt for a step transition."""
input_data = self._step_inputs.get(action_key)
input_data = self._step_inputs.get(key)
if not input_data:
return False
@@ -134,7 +225,14 @@ class VisualStepSummarizer:
exec_outcome = input_data["exec_outcome"]
try:
rendered_prompt = Template(self._prompt_template).render(step_number=step_number)
# §5 revision: the lens input adapts to whichever frames exist.
# Both frames -> the classic BEFORE/AFTER transition prompt; a
# single frame (Pro steps never carry an independent after-frame,
# by design) -> the single-frame variant, which has no AFTER
# ACTION section and forbids synthesizing a transition.
dual = bool(pre_bytes) and bool(post_bytes)
template = self._prompt_template if dual else self._single_prompt_template
rendered_prompt = Template(template).render(step_number=step_number)
content_blocks: list[dict[str, Any]] = [
{
"type": "text",
@@ -156,7 +254,12 @@ class VisualStepSummarizer:
content_blocks.append(
{
"type": "text",
"text": "--- [1] BEFORE ACTION SCREEN (Action Marked Visually in Red) ---",
"text": (
"--- [1] BEFORE ACTION SCREEN (Action Marked Visually in Red) ---"
if dual
else "--- [1] DECISION FRAME: SCREEN AT ACTION TIME"
" (Action Marked Visually in Red) ---"
),
}
)
content_blocks.append(
@@ -165,7 +268,17 @@ class VisualStepSummarizer:
if post_bytes:
b64_post = base64.b64encode(post_bytes).decode("utf-8")
content_blocks.append({"type": "text", "text": "--- [2] AFTER ACTION SCREEN ---"})
content_blocks.append(
{
"type": "text",
"text": (
"--- [2] AFTER ACTION SCREEN ---"
if dual
else "--- [1] SCREEN OBSERVED AFTER THE ACTION"
" (no decision frame available) ---"
),
}
)
content_blocks.append(
{
"type": "image_url",
@@ -179,6 +292,7 @@ class VisualStepSummarizer:
]
response = await asyncio.wait_for(self._llm.ainvoke(messages), timeout=25.0)
self._meter_lens_call(response)
summary_raw = response.content if isinstance(response.content, str) else ""
if isinstance(response.content, list):
summary_raw = "".join(
@@ -188,22 +302,35 @@ class VisualStepSummarizer:
)
summary_text = summary_raw.strip()
degenerate = degenerate_summary_reason(summary_text) if summary_text else None
if degenerate:
logger.warning(
f"VisualStepSummarizer: Discarding degenerate summary for"
f" Step {step_number} ({degenerate}): {summary_text[:80]!r}"
)
return False
if summary_text:
self._summaries[action_key] = summary_text
self._summaries[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 action_key in self._step_inputs:
self._step_inputs[action_key]["pre_img_bytes"] = None
self._step_inputs[action_key]["post_img_bytes"] = None
if key in self._step_inputs:
self._step_inputs[key]["pre_img_bytes"] = None
self._step_inputs[key]["post_img_bytes"] = None
# Update DataEngine telemetry if active
if self.ctx.data_engine:
try:
target_step = input_data.get("data_engine_step_id") or step_number
self.ctx.data_engine.update_step_summary(target_step, summary_text)
self.ctx.data_engine.update_step_summary(
target_step,
summary_text,
status="ready",
source="visual_transition",
model=self._model_name,
)
except Exception as de_err:
logger.debug(f"DataEngine step summary update skipped: {de_err}")
return True
@@ -213,36 +340,3 @@ class VisualStepSummarizer:
f"VisualStepSummarizer: Error generating summary for step {step_number}: {e}"
)
return False
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, action_key: int | str) -> bool:
"""Check whether an action summary has completed."""
return action_key in self._summaries
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...")
_, 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)
@@ -35,7 +35,8 @@ from artemis.utils.notes import (
get_note_file_path,
get_notes_dir,
)
from artemis.utils.task_tree import build_plan_and_history, get_active_subgoal_hashes
from artemis.memory.context_policy import build_history_for
from artemis.utils.task_tree import get_active_subgoal_hashes
logger = get_logger(__name__)
@@ -110,12 +111,12 @@ class HistoryAnalyzer:
if current_plan:
active_subgoal_hash, _ = get_active_subgoal_hashes(current_plan)
plan_and_history = build_plan_and_history(
plan_and_history = build_history_for(
"history_analyzer",
current_plan,
self.history_steps,
active_subgoal_hash,
last_n_detailed=1,
min_summaries=len(self.history_steps),
engine=self.ctx.data_engine,
)
except Exception as e:
logger.error(f"Failed to build plan and history in HistoryAnalyzer: {e}")
+1 -1
View File
@@ -1,3 +1,3 @@
{
"main_template": "# ROLE\nYou are the Android Device Operator. Your priority is execution accuracy. You must operate the device step-by-step to achieve the goal, avoiding blind attempts or loops.\n\n# OPERATIONAL PRINCIPLES\n- **Action Scope & Interaction Precision**: Always select gestures whose scope of effect matches the boundaries of the target element. Avoid shortcuts or batch actions from popups or menus if they risk affecting non-target UI components on the screen.[% if \"swipe\" in available_tools %] For any task requiring continuous range interactions, sliding, or boundary selection, prefer using targeted swipe/drag gestures spanning from the element's start bounds [xmin, ymin] to end bounds [xmax, ymax] to ensure the action is strictly confined to the target element. For drag-and-drop, list reordering, or sliding sliders/SeekBars, set duration >= 1000 (e.g. 1500) and always drag slightly PAST the target position to overcome touch slop and reliably trigger the drop/snap/value update. When setting a slider to Maximum (100%) or Minimum (0%), always swipe fully to the extreme boundary to guarantee reaching the absolute limits.[% endif %]\n- **Vision-First Priority**: The screenshot is the primary source of truth for the real device state. Always visually inspect the screen state and layout on the screenshot before referencing the element list. Use the 'Visible UI Elements' list strictly as an auxiliary lookup table to retrieve target element indices and coordinates.\n- **Tool Calling Contract**: Tools that return information needed for the decision ([[ tool_enum(pre_decision_tools, \"and\") ]]) are result-dependent Pre-Decision Tools. Do not submit them in the same tool-call list as a Turn-Ending Action; inspect their result first. The write-through tools `update_note` and `append_note` may accompany at most one Turn-Ending Action when their content is based entirely on evidence already available before that action. Never write an action's expected outcome as if it had already been observed.\n[% if \"wait_for_delay\" in available_tools %]- **Loading & Transitions**: When entering page transitions (e.g., app launching, web page loading, or progress indicators), use `wait_for_delay` to let the screen settle before executing target actions. Some transition states are difficult to discern, such as a thin progress line at the top of the browser screen during webpage loads. Before questioning whether an error has occurred, you can first adopt a waiting strategy to see how the device state evolves. If the state remains ambiguous after waiting, certain UI components might be malfunctioning or failing to load; in this case, consider retrying the action or pivoting to an alternative path.\n[% endif %]- **Memory Preservation & Paginated Exploration**: Using note tools is entirely zero-cost. Actively use this tool to maintain goals and logs. The note tool is the only reliable way to persist memory. Therefore, when you find information on the screen that might be relevant to the task, even if you are not certain, you should first save the information currently visible on the screen before exploring further. If you have already recorded some information, you can use `append_note` to append the new information to the existing content. The note tool can also help you organize lists or information across multiple screens; you can consolidate all information into notes for unified processing.\n- **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:\n 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.\n 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 unidLine truncated
"main_template": "# ROLE\nYou are the Android Device Operator. Your priority is execution accuracy. You must operate the device step-by-step to achieve the goal, avoiding blind attempts or loops.\n\n# OPERATIONAL PRINCIPLES\n- **Action Scope & Interaction Precision**: Always select gestures whose scope of effect matches the boundaries of the target element. Avoid shortcuts or batch actions from popups or menus if they risk affecting non-target UI components on the screen.[% if \"swipe\" in available_tools %] For any task requiring continuous range interactions, sliding, or boundary selection, prefer using targeted swipe/drag gestures spanning from the element's start bounds [xmin, ymin] to end bounds [xmax, ymax] to ensure the action is strictly confined to the target element. For drag-and-drop, list reordering, or sliding sliders/SeekBars, set duration >= 1000 (e.g. 1500) and always drag slightly PAST the target position to overcome touch slop and reliably trigger the drop/snap/value update. When setting a slider to Maximum (100%) or Minimum (0%), always swipe fully to the extreme boundary to guarantee reaching the absolute limits.[% endif %]\n- **Vision-First Priority**: The screenshot is the primary source of truth for the real device state. Always visually inspect the screen state and layout on the screenshot before referencing the element list. Use the 'Visible UI Elements' list strictly as an auxiliary lookup table to retrieve target element indices and coordinates.\n- **Tool Calling Contract**: Tools that return information needed for the decision ([[ tool_enum(pre_decision_tools, \"and\") ]]) are result-dependent Pre-Decision Tools. Do not submit them in the same tool-call list as a Turn-Ending Action; inspect their result first. The write-through tools `update_note` and `append_note` may accompany at most one Turn-Ending Action when their content is based entirely on evidence already available before that action. Never write an action's expected outcome as if it had already been observed.\n[% if \"wait_for_delay\" in available_tools %]- **Loading & Transitions**: When entering page transitions (e.g., app launching, web page loading, or progress indicators), use `wait_for_delay` to let the screen settle before executing target actions. Some transition states are difficult to discern, such as a thin progress line at the top of the browser screen during webpage loads. Before questioning whether an error has occurred, you can first adopt a waiting strategy to see how the device state evolves. If the state remains ambiguous after waiting, certain UI components might be malfunctioning or failing to load; in this case, consider retrying the action or pivoting to an alternative path.\n[% endif %]- **Memory Preservation & Paginated Exploration**: Using note tools is entirely zero-cost. Actively use this tool to maintain goals and logs. The note tool is the only reliable way to persist memory. Therefore, when you find information on the screen that might be relevant to the task, even if you are not certain, you should first save the information currently visible on the screen before exploring further. If you have already recorded some information, you can use `append_note` to append the new information to the existing content. The note tool can also help you organize lists or information across multiple screens; you can consolidate all information into notes for unified processing.\n- **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:\n 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.\n 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 unidLine truncated
}
+239 -34
View File
@@ -13,7 +13,6 @@
# limitations under the License.
import json
import re
from typing import Any
from uuid import uuid4
@@ -46,12 +45,11 @@ from artemis.utils.coordinates import (
parse_swipe_parameters,
)
from artemis.utils.decorators import wrap_with_callbacks
from artemis.utils.element_hit_test import find_element_at_point
from artemis.utils.logger import get_logger
from artemis.utils.notes import get_note_file_path
from artemis.utils.task_tree import (
build_plan_and_history,
get_active_subgoal_hashes,
)
from artemis.memory.context_policy import build_history_for
from artemis.utils.task_tree import get_active_subgoal_hashes
from artemis.utils.visualization import format_minimal_list_with_elements
logger = get_logger(__name__)
@@ -62,6 +60,7 @@ DEFERRING_TOOLS = {
"read_note",
"list_notes",
"save_note",
"recall_history",
"run_adb_command",
"manage_task",
"analyze_task_output",
@@ -73,14 +72,16 @@ from artemis.agents.operator.prompts import (
PromptComponent,
TemplatePromptComponent,
ObservationPromptComponent,
PlanRecitationPromptComponent,
ScreenshotSimilarityPromptComponent,
HistoricalStateHintPromptComponent,
FeedbackPromptComponent,
CheckItemsExplainerPromptComponent,
BackgroundTasksPromptComponent,
ShortTermMemoryPromptComponent,
TaskPlanWarningPromptComponent,
ToolLimitWarningPromptComponent,
InjectedInstructionPromptComponent,
render_transcript_static_system,
)
from artemis.agents.operator.prompt_builder import load_operator_prompts
@@ -92,6 +93,7 @@ class OperatorNode:
tools: list[BaseTool] | None = None,
prompt_components: list[PromptComponent] | None = None,
last_n_detailed: int = 1,
transcript_config=None,
):
self.ctx = ctx
self.tools = tools or []
@@ -103,6 +105,24 @@ class OperatorNode:
logger.error(f"Failed to load operator prompts: {e}")
self.prompts = {}
# M2 transcript flag (agent.memory.transcript.enabled). Off keeps the
# legacy 2-message prompt path byte-for-byte; an explicit
# ``transcript_config`` overrides the loaded configuration (tests).
if transcript_config is not None:
self._transcript_cfg = transcript_config
else:
try:
from artemis.config import load_agent_config
self._transcript_cfg = load_agent_config().memory.transcript
except Exception:
from artemis.config.agent import MemoryTranscriptConfig
self._transcript_cfg = MemoryTranscriptConfig()
# Index into the rendered message list where this turn's tail begins;
# set by the transcript build and consumed after the tool loop.
self._transcript_turn_base: int | None = None
def _available_device_actions(self) -> frozenset[str]:
"""Device actions the installed actuator backend provides.
@@ -170,6 +190,26 @@ class OperatorNode:
active_background_tasks: list[dict] = None,
newly_finished_tasks: list[dict] = None,
) -> list:
self._transcript_turn_base = None
if getattr(self._transcript_cfg, "enabled", False):
try:
return await self._build_prompt_transcript(
state,
latest_screenshot_b64,
minimal_list,
current_step_num,
steps,
task_plan,
active_background_tasks=active_background_tasks,
newly_finished_tasks=newly_finished_tasks,
)
except Exception as e:
logger.error(
f"Transcript prompt path failed; falling back to the legacy"
f" 2-message build for this turn: {e}"
)
self._transcript_turn_base = None
plan_and_history = self._build_plan_and_history(steps, task_plan)
builder = PromptBuilder()
@@ -187,8 +227,8 @@ class OperatorNode:
(CheckItemsExplainerPromptComponent(), {}),
(ObservationPromptComponent(), {}),
(ScreenshotSimilarityPromptComponent(), {}),
(HistoricalStateHintPromptComponent(), {}),
(InjectedInstructionPromptComponent(), {}),
(ShortTermMemoryPromptComponent(), {}),
(BackgroundTasksPromptComponent(), {}),
(FeedbackPromptComponent(), {}),
(TaskPlanWarningPromptComponent(), {}),
@@ -217,6 +257,174 @@ class OperatorNode:
return builder.build()
# ------------------------------------------------------------------
# Transcript prompt path (M2, flag agent.memory.transcript.enabled)
# ------------------------------------------------------------------
def _ensure_transcript_ledger(self, state=None):
"""Session ledger on the composition root, created on first use."""
from artemis.memory import TranscriptLedger, ensure_step_memory
ledger = getattr(self.ctx, "transcript_ledger", None)
if isinstance(ledger, TranscriptLedger):
return ledger
cfg = self._transcript_cfg
ledger = TranscriptLedger(
step_memory=ensure_step_memory(self.ctx),
image_scrub_depth=getattr(cfg, "image_scrub_depth", 3),
pending_grace_steps=getattr(cfg, "pending_grace_steps", 3),
xml_scrub_depth=getattr(cfg, "xml_scrub_depth", 1),
)
# L2/L3 chunk compression (M3) rides the same flag; without a
# DataEngine there are no step records to chunk, so the ledger simply
# runs scrub-edge-only.
if self.ctx.data_engine is not None:
try:
from artemis.memory import HistoryChunkManager
chunking_cfg = None
try:
from artemis.config import load_agent_config
chunking_cfg = load_agent_config().memory.chunking
except Exception:
pass
ledger.attach_chunker(
HistoryChunkManager(
engine=self.ctx.data_engine,
ctx=self.ctx,
chunking_config=chunking_cfg,
transcript_config=cfg,
goal=getattr(state, "initial_goal", None) if state else None,
)
)
except Exception as e:
logger.error(f"History chunk manager unavailable: {e}")
try:
self.ctx.transcript_ledger = ledger
except Exception:
pass
return ledger
async def _build_prompt_transcript(
self,
state: State,
latest_screenshot_b64: str,
minimal_list: str,
current_step_num: int,
steps: list[dict],
task_plan: str,
active_background_tasks: list[dict] = None,
newly_finished_tasks: list[dict] = None,
) -> list:
"""Build ``S + F + A + tail`` from the session transcript ledger.
The static system message renders once per session (byte-stable S
region); the previous turn is committed into the append-only active
region together with its step key and validator result; the fresh tail
carries the current observation plus the task-plan recitation.
"""
from langchain_core.messages import HumanMessage
ledger = self._ensure_transcript_ledger(state)
# 1. S region: rendered exactly once per session.
if not ledger.has_static_prefix:
static_text = render_transcript_static_system(self.prompts, self.ctx, state)
ledger.set_static_prefix([SystemMessage(content=static_text)])
# 2. F region cold start: an empty ledger over an existing step record
# trail means the process restarted — freeze the compiled history once.
# A staged (not yet committed) turn means this is turn 2+ of a live
# session, not a cold start — step records already exist then, and
# seeding would be rejected by the empty-ledger invariant.
if (
ledger.turn_count == 0
and not ledger.has_staged_turn
and not ledger.has_restored_history
and steps
):
restored = build_history_for(
"operator_cold_start",
task_plan,
steps,
self._get_active_subgoal_hash(),
)
ledger.set_restored_history(
"[Restored history] Rebuilt from the step records of this task"
" after a process restart; step times below are relative to the"
" original session start.\n"
f"{restored}"
)
# 3. Commit the previous turn (its step id and validator result only
# exist now). A turn without a terminal action never ran the validator,
# and ``structured_decisions`` is cleared on planner rejection — the
# gate keeps stale reports out of the ledger.
validator_result = (
getattr(state, "last_execution_result", None)
if getattr(state, "structured_decisions", None)
else None
)
ledger.commit_staged(
step_key=getattr(state, "current_step_id", None),
validator_result=validator_result,
)
# 4. Current tail: observation + plan recitation + injected components.
builder = PromptBuilder()
builder.add_human_content(f"# CURRENT OBSERVATION [{ledger.elapsed_label()}]")
components = [
(PlanRecitationPromptComponent(), {}),
(CheckItemsExplainerPromptComponent(), {}),
(ObservationPromptComponent(), {}),
(ScreenshotSimilarityPromptComponent(), {}),
(HistoricalStateHintPromptComponent(), {}),
(InjectedInstructionPromptComponent(), {}),
(BackgroundTasksPromptComponent(), {}),
(FeedbackPromptComponent(), {}),
(TaskPlanWarningPromptComponent(), {}),
(ToolLimitWarningPromptComponent(), {}),
]
for component, extra_kwargs in components:
kwargs_to_pass = {
"prompts": self.prompts,
"task_plan": task_plan,
"latest_screenshot_b64": latest_screenshot_b64,
"minimal_list": minimal_list,
"current_step_num": current_step_num,
"active_background_tasks": active_background_tasks or [],
"newly_finished_tasks": newly_finished_tasks or [],
"steps": steps,
}
kwargs_to_pass.update(extra_kwargs)
await component(builder, state, self.ctx, **kwargs_to_pass)
tail_content = []
for part in builder.human_parts:
if isinstance(part, str):
tail_content.append({"type": "text", "text": part})
else:
tail_content.append(part)
tail = HumanMessage(content=tail_content)
messages = ledger.render([tail])
self._transcript_turn_base = len(messages) - 1
return messages
def _stage_transcript_turn(self, messages: list) -> None:
"""Hand this turn's tail + tool-loop products to the ledger."""
base = self._transcript_turn_base
self._transcript_turn_base = None
if base is None:
return
try:
ledger = getattr(self.ctx, "transcript_ledger", None)
if ledger is not None:
ledger.stage_turn(messages[base:])
except Exception as e:
logger.error(f"Failed to stage transcript turn: {e}")
async def _invoke_llm_loop(
self,
base_llm,
@@ -729,30 +937,18 @@ class OperatorNode:
state=state,
)
# 8b. Transcript path: hold this turn's messages (observation tail +
# tool-loop products) for commit at the next build, when the turn's
# step id and validator result exist.
self._stage_transcript_turn(messages)
# 9. Update State
structured_decisions = json.dumps(action_result) if action_result else None
short_term_memory = None
combined_thinking = ""
if raw_thinking:
combined_thinking += raw_thinking + "\n"
if native_thinking:
combined_thinking += native_thinking
if combined_thinking:
stm_match = re.search(
r"<short_term_memory>(.*?)</short_term_memory>",
combined_thinking,
re.DOTALL,
)
if stm_match:
short_term_memory = stm_match.group(1).strip()
return {
"structured_decisions": structured_decisions,
"operator_raw_thinking": raw_thinking,
"operator_native_thinking": native_thinking,
"short_term_memory": short_term_memory,
"indexed_points": state.indexed_points,
"indexed_elements": state.indexed_elements,
"current_step_id": state.current_step_id,
@@ -785,14 +981,12 @@ class OperatorNode:
"""Builds a plan checklist and separate execution history."""
active_subgoal_hash = self._get_active_subgoal_hash()
return build_plan_and_history(
return build_history_for(
"operator",
task_plan,
steps,
active_subgoal_hash,
last_n_detailed=self.last_n_detailed,
strict_milestone_pruning=True,
recent_window_size=3,
chronological_last_step=True,
)
def _translate_and_validate_tool(self, tc: dict, state: State) -> tuple[list[dict], str | None]:
@@ -838,7 +1032,7 @@ class OperatorNode:
return None, f"Error: Invalid target index {target}."
indexed_elements = getattr(state, "indexed_elements", None) or []
if 1 <= target_int <= len(indexed_elements):
return indexed_elements[target_int - 1], None
return {**indexed_elements[target_int - 1], "label_source": "index"}, None
return (
None,
(
@@ -851,13 +1045,21 @@ class OperatorNode:
nx, ny = map(float, target)
x = int(max(0, min(width - 1, nx * width / 1000)))
y = int(max(0, min(height - 1, ny * height / 1000)))
# Record-time enrichment: a bare-coordinate target carries no
# element semantics, so hit test the pre-action frame's indexed
# elements to best-effort recover what sits under the point.
hit_el, hit_source = find_element_at_point(
getattr(state, "indexed_elements", None), x, y
)
hit_el = hit_el or {}
return {
"center": [x, y],
"text": None,
"bounds": None,
"class": None,
"resource_id": None,
"is_ocr": False,
"text": hit_el.get("text"),
"bounds": hit_el.get("bounds"),
"class": hit_el.get("class"),
"resource_id": hit_el.get("resource_id"),
"is_ocr": bool(hit_el.get("is_ocr")),
"label_source": hit_source,
}, None
except (ValueError, TypeError):
return (
@@ -894,6 +1096,7 @@ class OperatorNode:
"target_bounds": el.get("bounds"),
"target_resource_id": el.get("resource_id"),
"target_class": el.get("class"),
"target_label_source": el.get("label_source", "none"),
}
], None
@@ -917,6 +1120,7 @@ class OperatorNode:
"target_bounds": el.get("bounds"),
"target_resource_id": el.get("resource_id"),
"target_class": el.get("class"),
"target_label_source": el.get("label_source", "none"),
}
], None
@@ -953,6 +1157,7 @@ class OperatorNode:
"target_bounds": el.get("bounds"),
"target_resource_id": el.get("resource_id"),
"target_class": el.get("class"),
"target_label_source": el.get("label_source", "none"),
}
)
return actions, None
+224 -39
View File
@@ -52,7 +52,7 @@ from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER
_PRE_DECISION_HELPER_TOOLS = ("ask_explorer", "ask_diagnoser", "video_analyzer")
_PRE_DECISION_ADB_TOOLS = ("run_adb_command", "manage_task", "analyze_task_output")
_PRE_DECISION_MEMORY_TOOLS = ("read_note", "list_notes", "save_note")
_PRE_DECISION_MEMORY_TOOLS = ("read_note", "list_notes", "save_note", "recall_history")
_PRE_DECISION_ALL_TOOLS = (
_PRE_DECISION_HELPER_TOOLS + _PRE_DECISION_ADB_TOOLS + _PRE_DECISION_MEMORY_TOOLS
)
@@ -159,6 +159,119 @@ class PromptComponent:
raise NotImplementedError
def resolve_operator_prompt_tools(ctx: ArtemisContext) -> frozenset[str]:
"""The tool set the operator prompt may advertise for this run.
Extracted verbatim from the template component so the transcript static
prefix (M2) and the legacy per-turn render assemble against the identical
availability set.
"""
# video_analyzer is bound conditionally (graph.py gates it on
# video_recording_tools_enabled); the prompt must not advertise it when the
# tool is not actually available this run.
available = set(OPERATOR_PROMPT_TOOLSET)
setup = getattr(ctx, "execution_setup", None)
if not (setup and getattr(setup, "video_recording_tools_enabled", False)):
available.discard("video_analyzer")
# recall_history (M4) needs a DataEngine to search and is config-gated;
# the prompt must not advertise it when the tool is not bound this run.
if getattr(ctx, "data_engine", None) is None or not _recall_enabled():
available.discard("recall_history")
# Device actions the installed actuator backend does not implement disappear
# from the prompt in lockstep with their tool declarations.
actuator = getattr(ctx, "actuator", None)
if actuator is not None and callable(getattr(actuator, "capabilities", None)):
try:
from artemis.mcp.action_manifest import (
DEVICE_ACTIONS,
available_device_actions,
)
available -= DEVICE_ACTIONS - available_device_actions(actuator)
except Exception as e:
logger.warning(f"Failed to assemble prompt against actuator: {e}")
return frozenset(available)
def _operator_grammar_flags(ctx: ArtemisContext) -> tuple[bool, bool]:
"""(include_checks, verification_active) for the template render."""
setup = getattr(ctx, "execution_setup", None)
# Grammar spec assembly is a function of configuration: with both check
# gates disabled, the check-line grammar never enters any prompt.
include_checks = bool(setup and getattr(setup, "checks_enabled", False))
# The rejection/finding diagnosis trigger only exists while a mechanism
# that can produce rejections or findings is active.
verification_active = include_checks or bool(
setup and not getattr(setup, "disable_planner_validation", True)
)
return include_checks, verification_active
# --- M2 template split -----------------------------------------------------------
# The only volatile span of operator.json's main_template is the plan+history
# section below. The transcript path replaces it with a static pointer so the
# whole system message becomes a byte-stable S region; the legacy path renders
# the untouched template and stays byte-identical.
#: The volatile section of ``main_template`` (must occur exactly once).
PLAN_HISTORY_TEMPLATE_SECTION = "## Current Plan & Execution History\n{{ plan_and_history }}"
#: Static replacement used by the transcript S region.
PLAN_HISTORY_STATIC_POINTER = (
"## Current Plan & Execution History\n"
"Provided in the conversation that follows: earlier turns carry the raw"
" step-by-step execution history (a restored-history block precedes them"
" after a process restart), and each turn's observation message recites"
" the current task plan."
)
def render_transcript_static_system(
prompts: dict,
ctx: ArtemisContext,
state: State,
template_name: str = "main_template",
) -> str:
"""Render the transcript path's byte-stable static system prompt (S region).
Same template, same availability assembly, and the same render inputs as
the legacy path — except the volatile plan+history section is swapped for
a static pointer, so the output never changes across the session.
"""
prompt_template = prompts.get(template_name)
if not prompt_template:
raise KeyError(
"Failed to format prompt, template not found in operator prompts config."
)
if prompt_template.count(PLAN_HISTORY_TEMPLATE_SECTION) != 1:
# Hard dependency of the M2 split (redesign §9): without a clean
# section boundary the S region cannot be byte-stable.
raise ValueError(
"operator main_template no longer contains exactly one plan+history"
" section; the transcript static split cannot be applied."
)
static_template = prompt_template.replace(
PLAN_HISTORY_TEMPLATE_SECTION, PLAN_HISTORY_STATIC_POINTER
)
available = resolve_operator_prompt_tools(ctx)
static_template = apply_operator_prompt_contract(
static_template, available_tools=available
)
include_checks, verification_active = _operator_grammar_flags(ctx)
return Template(static_template).render(
initial_goal=state.initial_goal,
subgoals_status="",
plan_and_history="",
unified_history="",
plan_grammar=render_plan_grammar_spec(include_checks),
verification_active=verification_active,
)
class TemplatePromptComponent(PromptComponent):
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
prompts = kwargs.get("prompts", {})
@@ -169,42 +282,15 @@ class TemplatePromptComponent(PromptComponent):
"Failed to format prompt, template not found in operator prompts config."
)
# video_analyzer is bound conditionally (graph.py gates it on
# video_recording_tools_enabled); the prompt must not advertise it when the
# tool is not actually available this run.
available = set(OPERATOR_PROMPT_TOOLSET)
setup = getattr(ctx, "execution_setup", None)
if not (setup and getattr(setup, "video_recording_tools_enabled", False)):
available.discard("video_analyzer")
# Device actions the installed actuator backend does not implement disappear
# from the prompt in lockstep with their tool declarations.
actuator = getattr(ctx, "actuator", None)
if actuator is not None and callable(getattr(actuator, "capabilities", None)):
try:
from artemis.mcp.action_manifest import (
DEVICE_ACTIONS,
available_device_actions,
)
available -= DEVICE_ACTIONS - available_device_actions(actuator)
except Exception as e:
logger.warning(f"Failed to assemble prompt against actuator: {e}")
available = resolve_operator_prompt_tools(ctx)
prompt_template = apply_operator_prompt_contract(
prompt_template, available_tools=frozenset(available)
prompt_template, available_tools=available
)
plan_and_history = kwargs.get("plan_and_history", "No plan or history yet.")
# Grammar spec assembly is a function of configuration: with both check
# gates disabled, the check-line grammar never enters any prompt.
include_checks = bool(setup and getattr(setup, "checks_enabled", False))
# The rejection/finding diagnosis trigger only exists while a mechanism
# that can produce rejections or findings is active.
verification_active = include_checks or bool(
setup and not getattr(setup, "disable_planner_validation", True)
)
include_checks, verification_active = _operator_grammar_flags(ctx)
full_prompt = Template(prompt_template).render(
initial_goal=state.initial_goal,
@@ -237,6 +323,21 @@ class ObservationPromptComponent(PromptComponent):
builder.add_human_content(f"--- Visible UI Elements ---\n{minimal_list}")
class PlanRecitationPromptComponent(PromptComponent):
"""Per-turn task-plan recitation for the transcript tail (M2).
With the plan+history section moved out of the (now static) system
message, every observation recites the live task plan; the scrub edge
strips the copies from older turns at depth 1.
"""
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
from artemis.memory.transcript import PLAN_RECITATION_MARKER
task_plan = kwargs.get("task_plan") or "No task plan yet."
builder.add_human_content(f"{PLAN_RECITATION_MARKER}\n{task_plan}")
class FeedbackPromptComponent(PromptComponent):
"""Append-only injection of source-tagged findings.
@@ -336,14 +437,6 @@ class BackgroundTasksPromptComponent(PromptComponent):
builder.add_human_content("\n".join(lines) + "\n")
class ShortTermMemoryPromptComponent(PromptComponent):
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
if state.short_term_memory:
builder.add_human_content(
f"--- Short-Term Memory (Scratchpad) ---\n{state.short_term_memory}\n"
)
class TaskPlanWarningPromptComponent(PromptComponent):
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
steps = kwargs.get("steps", [])
@@ -506,3 +599,95 @@ class ScreenshotSimilarityPromptComponent(PromptComponent):
if diff_count > max_allowed:
break
return diff_count
def _recall_enabled() -> bool:
"""Whether the recall_history tool is enabled by configuration (M4)."""
try:
from artemis.config import load_agent_config
return bool(load_agent_config().memory.recall.enabled)
except Exception:
return True
class HistoricalStateHintPromptComponent(PromptComponent):
"""Local historical-state hint from stored perceptual hashes (M4).
Compares the current screenshot's dHash against the post-action hashes
stamped on every recorded step (``extra_metadata["post_image_dhash"]``,
including steps already chunk-compressed out of the visible transcript) —
an O(n) integer scan, no model call, no historical image bytes. On a
close match to a step *older* than the recent window it injects a one-line
hint pointing at ``recall_history``.
Division of labor with :class:`ScreenshotSimilarityPromptComponent`: that
component's pixel-exact 3-step look-back covers "stuck on the same
screen"; this one covers "returned to a much earlier state". When any of
the last :attr:`RECENT_SILENT_STEPS` steps also matches, this hint stays
silent so the two never fire on the same regime.
"""
#: Matches within this many most-recent steps stay silent (the pixel
#: same-screen note owns that window).
RECENT_SILENT_STEPS: int = 3
#: Upper bound on how many historical steps are scanned (most recent first).
SCAN_CAP: int = 500
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
latest_screenshot_b64 = kwargs.get("latest_screenshot_b64")
steps = kwargs.get("steps") or []
if not latest_screenshot_b64 or len(steps) <= self.RECENT_SILENT_STEPS:
return
max_distance = 8
try:
from artemis.config import load_agent_config
transcript_cfg = load_agent_config().memory.transcript
if not getattr(transcript_cfg, "similarity_hint", True):
return
max_distance = int(getattr(transcript_cfg, "similarity_max_distance", 8))
except Exception:
pass
try:
from artemis.utils.image_hash import dhash_hex, hamming_distance_hex
if "," in latest_screenshot_b64:
latest_screenshot_b64 = latest_screenshot_b64.split(",", 1)[1]
current_hash = dhash_hex(base64.b64decode(latest_screenshot_b64))
except Exception:
return
if not current_hash:
return
def _step_hash(step: dict) -> str | None:
meta = step.get("extra_metadata") or {}
return meta.get("post_image_dhash") or meta.get("pre_image_dhash")
# Silence rule first: a close match inside the recent window belongs
# to the pixel-level same-screen note, not this hint.
for step in steps[-self.RECENT_SILENT_STEPS :]:
distance = hamming_distance_hex(current_hash, _step_hash(step))
if distance is not None and distance <= max_distance:
return
older_steps = steps[: -self.RECENT_SILENT_STEPS][-self.SCAN_CAP :]
best_step_number = None
best_distance = None
for step in older_steps:
distance = hamming_distance_hex(current_hash, _step_hash(step))
if distance is None or distance > max_distance:
continue
if best_distance is None or distance <= best_distance:
# <= keeps the most recent step on ties.
best_distance = distance
best_step_number = step.get("step_number")
if best_step_number is not None:
builder.add_human_content(
f"Historical state hint: current screen closely resembles the"
f" post-action screen from Step {best_step_number}. Use"
" recall_history only if its details are needed."
)
+5 -5
View File
@@ -38,7 +38,8 @@ from artemis.tools.scratchpad import (
from artemis.tools.video_tool import get_video_analyzer_tool_pure
from artemis.tools.tool_wrapper import invoke_tool_with_injection
from artemis.utils.logger import get_logger
from artemis.utils.task_tree import build_plan_and_history, get_active_subgoal_hashes
from artemis.memory.context_policy import build_history_for
from artemis.utils.task_tree import get_active_subgoal_hashes
from pydantic import BaseModel
logger = get_logger(__name__)
@@ -78,13 +79,12 @@ async def outputter(
except Exception as e:
logger.error(f"Failed to parse active subgoal in outputter: {e}")
plan_and_history = build_plan_and_history(
plan_and_history = build_history_for(
"outputter",
current_plan,
history,
active_subgoal_hash,
last_n_detailed=0,
min_summaries=len(history),
strict_milestone_pruning=False,
engine=ctx.data_engine,
)
except Exception as e:
logger.error(f"Failed to resolve plan and history in outputter: {e}")
+3 -6
View File
@@ -47,7 +47,7 @@ from artemis.utils.decorators import wrap_with_callbacks
from artemis.utils.logger import get_logger
from artemis.utils.notes import get_note_file_path
from artemis.utils.plan_grammar import render_plan_grammar_spec
from artemis.utils.task_tree import build_plan_and_history
from artemis.memory.context_policy import build_history_for
logger = get_logger(__name__)
@@ -130,14 +130,11 @@ async def run_async_planner_validation(
if ctx.data_engine:
try:
steps = ctx.data_engine.get_agent_friendly_steps()
history_str = build_plan_and_history(
history_str = build_history_for(
"planner",
"", # Pass empty string to avoid rendering the task plan in the history
steps,
"default",
last_n_detailed=2,
strict_milestone_pruning=True,
recent_window_size=5,
chronological_last_step=True,
)
except Exception as e:
+102 -186
View File
@@ -12,27 +12,33 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import json
from pathlib import Path
from uuid import UUID
"""Pro-graph step summarizer node (M2: visual-transition lens dispatch).
from langchain_core.messages import (
HumanMessage,
SystemMessage,
)
Per the history-module redesign §5 (single step-level lens, confirmed
2026-08-31) this node no longer generates a per-step text capsule with its own
LLM call. It dispatches the step to the shared
:class:`~artemis.memory.step_memory.StepMemoryService` visual-transition lens
(``ctx.step_memory``): pre/post screenshots are pulled from the DataEngine by
step id, the red action-overlay and the ``flash_summarizer.md`` neutral-wording
contract apply unchanged, and the result lands as a versioned
``source="visual_transition"`` summary write (M0 semantics).
The auxiliary agents' compiled view (``build_plan_and_history``) is untouched:
a step whose summary is not ready yet falls back to detailed rendering
(``task_tree.py`` semantics), so the transition is safe. The intent/strategy
capsule moves up to the chunk-level ``StepCapsuleLens`` in M3.
"""
import json
from typing import Any
from artemis.context import ArtemisContext
from artemis.data_engine.trace import CURRENT_TRACE_ID, trace
from artemis.data_engine.trace import trace
from artemis.graph.state import State
from artemis.graph.visibility import strict_state
from artemis.services.llm import acomplete, get_llm
from artemis.utils.coordinates import normalize_any_structure
from artemis.memory import ensure_step_memory
from artemis.utils.logger import get_logger
from artemis.utils.task_tree import (
build_plan_and_history,
get_active_subgoal_hashes,
)
from artemis.utils.task_tree import format_result_clean
logger = get_logger(__name__)
@@ -44,180 +50,90 @@ class SummarizerNode:
@trace(type="agent", name="summarizer")
async def __call__(self, state: State):
state = strict_state(state, "summarizer")
# 1. Trigger background task for summary and data engine update
if self.ctx.data_engine and state.current_step_id:
step_id = UUID(state.current_step_id)
decisions = state.structured_decisions
operator_raw_thinking = getattr(state, "operator_raw_thinking", None)
operator_native_thinking = getattr(state, "operator_native_thinking", None)
last_execution_result = getattr(state, "last_execution_result", None)
# Spawn background task
trace_id = CURRENT_TRACE_ID.get()
task = asyncio.create_task(
self._generate_summary_and_update_engine(
step_id,
decisions,
operator_raw_thinking,
operator_native_thinking,
last_execution_result,
trace_id,
),
name=f"summarizer_step_{step_id}",
)
if hasattr(self.ctx, "background_tasks") and isinstance(
self.ctx.background_tasks, list
):
self.ctx.background_tasks.append(task)
if not (self.ctx.data_engine and state.current_step_id):
return {}
try:
self._dispatch_visual_summary(state)
except Exception as e:
logger.error(f"Failed to dispatch visual step summary: {e}")
return {}
async def _generate_summary_and_update_engine(
self,
step_id,
decisions,
operator_raw_thinking,
operator_native_thinking,
last_execution_result,
trace_id,
):
"""Background task to generate summary and update Data Engine with retries."""
def _dispatch_visual_summary(self, state: State) -> None:
engine = self.ctx.data_engine
step_id = state.current_step_id
max_retries = 3
retry_delay = 2
step_number = None
try:
step_number = engine.get_step_number(step_id)
except Exception as e:
logger.warning(f"Could not resolve step number for {step_id}: {e}")
for attempt in range(max_retries):
record = None
if isinstance(step_number, int):
try:
logger.info(f"Generating summary for step {step_id} (attempt {attempt + 1})")
# Prepare prompt for summary
llm = get_llm(ctx=self.ctx, name="summarizer")
prompt_path = Path(__file__).parent.joinpath("summarizer.md")
if not prompt_path.exists():
prompt = (
"You are a summarizer. Summarize the latest action and"
" its result in 1-2 concise sentences. Focus on: Action"
" taken -> Result -> Observation."
)
else:
prompt = prompt_path.read_text(encoding="utf-8")
# Normalize coordinates before presenting to the summarizer LLM
width = (
getattr(self.ctx.device, "device_width", 1080)
if self.ctx and self.ctx.device
else 1080
)
height = (
getattr(self.ctx.device, "device_height", 2400)
if self.ctx and self.ctx.device
else 2400
)
content = []
# Consistently use optimal build_plan_and_history to construct accurate context
plan_and_history_str = ""
if self.ctx.data_engine:
try:
friendly_steps = self.ctx.data_engine.get_agent_friendly_steps()
if isinstance(friendly_steps, list):
# 1. Trim history to only include steps up to the current step being summarized (inclusive)
current_steps = []
for s in friendly_steps:
current_steps.append(s)
if str(s.get("step_id")) == str(step_id):
break
# 2. Read task plan to provide goal orientation to the summarizer
task_plan = ""
base_dir = getattr(self.ctx.data_engine, "base_dir", None)
if base_dir and isinstance(base_dir, (str, Path)):
notes_dir = Path(base_dir) / "notes"
task_plan_path = notes_dir / "task_plan.md"
if task_plan_path.exists():
task_plan = task_plan_path.read_text(encoding="utf-8")
# 3. Unified rendering: concise for first N steps (with self-healing facts), detailed for current step (without redundant summary header)
subgoal_hash = "default"
if task_plan:
subgoal_hash, _ = get_active_subgoal_hashes(task_plan)
plan_and_history_str = build_plan_and_history(
task_plan=task_plan,
steps=current_steps,
current_subgoal_hash=subgoal_hash,
last_n_detailed=1,
min_summaries=10,
strict_milestone_pruning=False,
)
except Exception as e:
logger.error(f"Failed to build plan and history for Summarizer: {e}")
if plan_and_history_str:
content.append(plan_and_history_str)
else:
# Fallback to standard raw fields if chronological trace couldn't be generated
if decisions:
try:
dec_obj = json.loads(decisions)
dec_obj = normalize_any_structure(dec_obj, width, height)
content.append(
f"Decisions made: {json.dumps(dec_obj, ensure_ascii=False)}"
)
except Exception:
content.append(f"Decisions made: {decisions}")
if operator_raw_thinking:
content.append(f"Operator explicit thoughts:\n{operator_raw_thinking}")
if operator_native_thinking:
content.append(f"Operator native thoughts:\n{operator_native_thinking}")
messages = [
SystemMessage(content=prompt),
HumanMessage(content="\n\n".join(content)),
]
aggregated_chunk = await acomplete(llm, messages)
generated_text = ""
content = getattr(aggregated_chunk, "content", "") if aggregated_chunk else ""
if isinstance(content, str):
generated_text = content
elif isinstance(content, list):
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
generated_text += item.get("text", "")
summary = generated_text.strip()
logger.info(f"Summary generated: {summary}")
if aggregated_chunk and getattr(aggregated_chunk, "usage_metadata", None):
usage = aggregated_chunk.usage_metadata
cached_tokens = usage.get("input_token_details", {}).get("cache_read", 0)
logger.info(
"Summarizer LLM usage:"
f" prompt_tokens={usage.get('input_tokens')}"
f" (cached={cached_tokens}),"
f" completion_tokens={usage.get('output_tokens')}"
)
# Update Data Engine
self.ctx.data_engine.update_step_summary(step_id, summary)
logger.info(f"Data Engine updated with summary for step {step_id}")
return # Success
record = engine.get_step_record(step_number)
except Exception as e:
logger.error(
f"Failed to generate summary or update engine (attempt {attempt + 1}): {e}"
)
if attempt < max_retries - 1:
await asyncio.sleep(retry_delay)
retry_delay *= 2 # Exponential backoff
else:
logger.error(
f"Max retries reached for step {step_id}. Summary generation failed."
)
logger.warning(f"Could not load step record {step_number}: {e}")
# Graceful degradation: a missing pre/post image simply dispatches with
# None bytes — the lens renders a text-only transition account.
pre_bytes = self._read_step_image(step_number, "pre")
post_bytes = self._read_step_image(step_number, "post")
action_name, action_args = self._extract_action(record, state)
result = getattr(state, "last_execution_result", None)
# format_result_clean reports only errors/repairs; a clean run keeps
# the controller's bare status word.
exec_outcome = format_result_clean(result)
if not exec_outcome:
if isinstance(result, dict) and result.get("status"):
exec_outcome = str(result["status"])
else:
exec_outcome = "unknown"
service = ensure_step_memory(self.ctx)
service.dispatch(
step_number=step_number if isinstance(step_number, int) else 0,
action_name=action_name,
action_args=action_args,
pre_img_bytes=pre_bytes,
post_img_bytes=post_bytes,
exec_outcome=exec_outcome,
data_engine_step_id=step_id,
)
logger.info(
f"Dispatched visual transition summary for step {step_number} ({step_id})"
)
def _read_step_image(self, step_number: int | None, which: str) -> bytes | None:
if not isinstance(step_number, int):
return None
try:
path = self.ctx.data_engine.get_step_image_path(step_number, which)
if path is None:
return None
return path.read_bytes()
except Exception as e:
logger.warning(f"Could not read {which} image for step {step_number}: {e}")
return None
@staticmethod
def _extract_action(record: Any, state: State) -> tuple[str, dict]:
actions = getattr(record, "action_taken", None) if record is not None else None
if not actions:
decisions = getattr(state, "structured_decisions", None)
if decisions:
try:
actions = json.loads(decisions)
except Exception:
actions = None
if isinstance(actions, dict):
actions = [actions]
if not isinstance(actions, list) or not actions or not isinstance(actions[0], dict):
return "no_op", {}
first = actions[0]
action_name = first.get("action") or "unknown"
action_args = {k: v for k, v in first.items() if k != "action"}
if len(actions) > 1:
action_args["additional_actions"] = actions[1:]
return action_name, action_args
+3 -7
View File
@@ -51,8 +51,8 @@ from artemis.services.llm import get_llm
from artemis.utils.logger import get_logger
from artemis.utils.notes import get_note_file_path
from artemis.utils.ocr_xml_fusion import fuse_ocr_with_xml
from artemis.memory.context_policy import build_history_for
from artemis.utils.task_tree import (
build_plan_and_history,
format_action_clean,
get_active_subgoal_hashes,
)
@@ -358,15 +358,11 @@ class FailureAnalyzer:
def _build_plan_and_history(self, steps: list[dict], task_plan: str) -> str:
active_subgoal_hash = self._get_active_subgoal_hash()
return build_plan_and_history(
return build_history_for(
"failure_analyzer",
task_plan,
steps,
active_subgoal_hash,
last_n_detailed=1,
strict_milestone_pruning=True,
recent_window_size=3,
chronological_last_step=True,
for_failure_analyzer=True,
)
def _prune_intermediate_screenshots(self, messages: list[Any]):
+8
View File
@@ -20,6 +20,10 @@ from artemis.config.agent import (
CommitteeConfig,
ExplorerConfig,
FlashProfileConfig,
MemoryChunkingConfig,
MemoryConfig,
MemoryRuntimeConfig,
MemoryTranscriptConfig,
OutputterConfig,
PlannerValidationConfig,
ProProfileConfig,
@@ -197,6 +201,10 @@ __all__ = [
"CommitteeConfig",
"ExplorerConfig",
"FlashProfileConfig",
"MemoryChunkingConfig",
"MemoryConfig",
"MemoryRuntimeConfig",
"MemoryTranscriptConfig",
"OutputterConfig",
"PlannerValidationConfig",
"ProProfileConfig",
+272
View File
@@ -289,6 +289,262 @@ class StepSummarizerConfig(BaseModel):
default=True,
description="Whether to prune outdated heavy UI XML trees from historical turns.",
)
retry_limit: int = Field(
default=3,
ge=0,
description=(
"Maximum retries per step summary after the first attempt; on"
" exhaustion the step is marked summary_status=failed instead of"
" retrying forever."
),
)
model_config = {"extra": "allow"}
class MemoryRuntimeConfig(BaseModel):
"""Scheduling options for the shared step-memory runtime (agent.memory.runtime)."""
max_concurrency: int = Field(
default=2,
ge=1,
le=16,
description=(
"Maximum background summary attempts running concurrently."
" Default 2 per the 2026-09-01 on-device baseline"
" (history-baseline-2026-09-01.md §6): the serial queue at 1 pushed"
" summary-ready P90 past the scrub-edge grace window; lens calls"
" are independent lightweight-model requests, so 2 is low-risk."
),
)
retry_limit: int = Field(
default=3,
ge=0,
description=(
"Maximum retries per summary job after the first attempt; on"
" exhaustion the job enters an explicit failed state."
),
)
flush_timeout_s: float = Field(
default=30.0,
gt=0,
description="Upper bound in seconds for draining in-flight summary jobs at shutdown.",
)
model_config = {"extra": "allow"}
class MemoryTranscriptConfig(BaseModel):
"""Scrub-edge options for the message transcript (agent.memory.transcript)."""
enabled: bool = Field(
default=True,
description=(
"Whether the Pro operator builds its prompt from the session"
" transcript ledger (S/F/A regions + per-turn tail) instead of the"
" legacy per-turn 2-message rebuild. On by default since M5"
" (2026-09-01, per the on-device A/B in"
" history-baseline-2026-09-01.md); this is also the rollback"
" switch — setting it to false restores the legacy path"
" byte-for-byte, and L2/L3 chunk compression rides the same flag."
),
)
image_scrub_depth: int = Field(
default=3,
ge=1,
description=(
"Depth K of the screenshot scrub edge: the K-th most recent"
" historical screenshot is replaced by its visual summary."
),
)
pending_grace_steps: int = Field(
default=3,
ge=0,
description=(
"Extra image-depths a step may retain its screenshot past the"
" scrub edge while its summary is still pending; afterwards the"
" image is replaced by a placeholder referencing the DataEngine step."
),
)
xml_scrub_depth: int = Field(
default=1,
ge=1,
description=(
"How many of the most recent observations keep their UI Element"
" list; older lists are stripped (depth 1 matches the legacy"
" prune_history_xml semantics)."
),
)
context_budget_tokens: int = Field(
default=80000,
ge=1024,
description=(
"Measured-token budget for the transcript context (provisional"
" default pending the on-device baseline; §3.4). The soft/hard"
" ratios below are applied against this budget."
),
)
soft_ratio: float = Field(
default=0.7,
gt=0.0,
lt=1.0,
description=(
"Soft threshold: when the last measured prompt size reaches"
" budget*soft_ratio, the oldest open segment is chunk-compressed"
" (L2)."
),
)
hard_ratio: float = Field(
default=0.9,
gt=0.0,
le=1.0,
description=(
"Hard threshold: when the last measured prompt size reaches"
" budget*hard_ratio, the frozen region collapses to the L3 session"
" snapshot (chunk headers merged; per-step index retained)."
),
)
min_active_steps: int = Field(
default=5,
ge=1,
description=(
"Sliding-window floor: no compression trigger may shrink the"
" active region below this many most-recent raw turns."
),
)
similarity_hint: bool = Field(
default=True,
description=(
"Whether the operator prompt injects a local historical-state hint"
" when the current screen's perceptual hash closely matches a much"
" older step's post-action screen (no model call, no historical"
" image). Silent when the match is within the recent 3 steps —"
" that regime belongs to the pixel-level same-screen note."
),
)
similarity_max_distance: int = Field(
default=5,
ge=0,
le=64,
description=(
"Maximum Hamming distance between 64-bit dHash values for the"
" historical-state hint to fire. Calibrated 2026-09-01 from 460"
" dHash-stamped on-device steps (16 sessions): same-screen"
" re-captures cluster at distance <=4, genuinely different screens"
" mass at >=7 (valley at 5-6); cross-app pairs (guaranteed"
" different screens) score <=5 in only 0.14% of cases vs 0.24% at"
" the previous provisional 8."
),
)
model_config = {"extra": "allow"}
class MemoryRecallConfig(BaseModel):
"""History recall tool options (agent.memory.recall)."""
enabled: bool = Field(
default=True,
description=(
"Whether the Pro operator gets the recall_history tool for"
" deterministic lookups into cold (compressed/evicted) history."
),
)
max_results: int = Field(
default=5,
ge=1,
le=20,
description="Upper bound on results per recall_history call.",
)
max_text_tokens: int = Field(
default=2000,
ge=128,
description=(
"Estimated-token cap (char/4) on one recall_history text response;"
" excerpts are truncated to fit."
),
)
max_image_steps: int = Field(
default=1,
ge=0,
le=1,
description=(
"Maximum number of steps whose real stored screenshots may be"
" returned as data URLs when include_images is requested."
),
)
model_config = {"extra": "allow"}
class MemoryChunkingConfig(BaseModel):
"""Segment (L2) chunk compression options (agent.memory.chunking)."""
max_steps: int = Field(
default=12,
ge=1,
description=(
"Size-threshold trigger: an open segment of this many steps is"
" chunk-compressed even without a milestone switch. Also the"
" maximum steps per chunk when a longer segment is split."
),
)
target_source_tokens: int = Field(
default=2000,
ge=128,
description=(
"Size-threshold trigger: estimated source tokens (char/4 over the"
" open segment's transcript text) that force a chunk compression."
),
)
model: str = Field(
default="gemini-3.7-flash",
description="Model used for the chunk-level StepCapsuleLens (bands ①+②).",
)
max_chunks: int = Field(
default=8,
ge=1,
description=(
"Maximum full three-band chunk blocks kept in the frozen region;"
" older chunks merge into eras (headers set-merged, ledgers kept)."
),
)
max_eras: int | None = Field(
default=None,
ge=1,
description=(
"Maximum eras kept with their per-segment ledgers; older eras"
" collapse their ledgers to recall_history marker lines. None"
" (default) follows max_chunks, preserving the pre-M5 equal-value"
" behavior."
),
)
model_config = {"extra": "allow"}
class MemoryConfig(BaseModel):
"""Unified step-memory configuration block (agent.memory)."""
runtime: MemoryRuntimeConfig = Field(
default_factory=MemoryRuntimeConfig,
description="Shared background summarization runtime scheduling options.",
)
transcript: MemoryTranscriptConfig = Field(
default_factory=MemoryTranscriptConfig,
description="Transcript scrub-edge options.",
)
chunking: MemoryChunkingConfig = Field(
default_factory=MemoryChunkingConfig,
description="Segment chunk (L2) compression options.",
)
recall: MemoryRecallConfig = Field(
default_factory=MemoryRecallConfig,
description="History recall tool options.",
)
policies: dict[str, dict[str, Any]] = Field(
default_factory=dict,
description=(
"Per-agent overrides of the compiled-history ContextPolicy table"
" (artemis.memory.context_policy); keys are agent names, values"
" are policy field overrides (e.g. {'planner': {'last_n_detailed': 3}})."
),
)
model_config = {"extra": "allow"}
@@ -398,6 +654,10 @@ class AgentGlobalConfig(BaseModel):
default_factory=VideoAnalyzerConfig,
description="Video analyzer runtime options.",
)
memory: MemoryConfig = Field(
default_factory=MemoryConfig,
description="Unified step-memory runtime and transcript scrub options.",
)
model_config = {"extra": "allow"}
@@ -452,6 +712,18 @@ class AgentGlobalConfig(BaseModel):
):
data.setdefault("pro", {})["video_analyzer"] = data["video_analyzer"]
# 3b. Legacy alias: an explicit flash.step_summarizer.retry_limit keeps
# working by seeding memory.runtime.retry_limit when the new key is
# absent (an explicit new key wins over the legacy one).
flash_block = data.get("flash")
flash_ss = flash_block.get("step_summarizer") if isinstance(flash_block, dict) else None
if isinstance(flash_ss, dict) and "retry_limit" in flash_ss:
mem_block = data.setdefault("memory", {})
if isinstance(mem_block, dict):
runtime_block = mem_block.setdefault("runtime", {})
if isinstance(runtime_block, dict):
runtime_block.setdefault("retry_limit", flash_ss["retry_limit"])
# 4. Environment variable overrides (highest precedence for runtime switches)
def _parse_bool(var_name: str) -> bool | None:
v = os.getenv(var_name)
-1
View File
@@ -13,7 +13,6 @@
# limitations under the License.
RECURSION_LIMIT = 30000
MAX_MESSAGES_IN_HISTORY = 25
# Optimization Constants
CHECKER_MAX_ITERATIONS = 20
+36
View File
@@ -232,6 +232,17 @@ class ArtemisContext(BaseModel):
"""Optional actuator backend override (artemis.mcp.actuators). ``None`` selects
the default AdbActuator; installing e.g. a robot-arm backend happens here."""
step_memory: Any | None = None
"""Shared :class:`~artemis.memory.step_memory.StepMemoryService` composition
root (history redesign §6.2). Created on first use via
``artemis.memory.ensure_step_memory``; Flash and Pro profiles share one
instance so summary jobs, retries, and flush semantics are unified."""
transcript_ledger: Any | None = None
"""Session :class:`~artemis.memory.transcript.TranscriptLedger` for the Pro
operator's transcript prompt path (``agent.memory.transcript.enabled``).
Lives beside ``step_memory``; the State never carries message payloads."""
async def __aenter__(self) -> ArtemisContext:
return self
@@ -246,6 +257,31 @@ class ArtemisContext(BaseModel):
finally:
self.action_session = None
# Drain in-flight step-summary jobs before the generic background-task
# sweep: the shared service owns its own bounded flush semantics. On an
# exceptional exit the flush is skipped and the jobs are cancelled below.
if self.step_memory is not None and callable(getattr(self.step_memory, "flush", None)):
try:
if exc_type is None:
await self.step_memory.flush()
else:
await self.step_memory.flush(timeout_seconds=0.0)
except Exception:
pass
# Drain in-flight chunk capsule jobs the same way (M3): the chunk
# manager persists any harvested capsules so the DB copy is complete
# even though the frozen transcript text is not re-rendered.
chunker = getattr(self.transcript_ledger, "chunker", None)
if chunker is not None and callable(getattr(chunker, "flush", None)):
try:
if exc_type is None:
await chunker.flush()
else:
await chunker.flush(timeout_seconds=0.0)
except Exception:
pass
if self.background_tasks:
tasks = list(self.background_tasks)
pending_tasks = [task for task in tasks if not task.done()]
+152 -4
View File
@@ -14,6 +14,7 @@
import asyncio
from collections.abc import Callable
import functools
import hashlib
import json
import os
@@ -29,6 +30,7 @@ from artemis.context import ArtemisContext
from artemis.data_engine.models import (
BackgroundTaskRecord,
FailedOutputRecord,
HistoryChunkRecord,
ImageRecord,
SessionMetadata,
StepRecord,
@@ -48,6 +50,43 @@ logger = get_logger(__name__)
_CURRENT_DATA_ENGINE = None
# Overlay packages that never identify the app the user is working in.
_FOREGROUND_APP_IGNORED = {"com.android.systemui"}
def _derive_foreground_app(ui_tree: Any | None) -> str | None:
"""Best-effort foreground package from a perception UI tree (M5).
Counts ``package``/``packageName`` attributes across the node list
(recursing into ``children``), ignores system-overlay packages, and
returns the most frequent value. Pure string work — never raises, never
touches the device.
"""
counts: dict[str, int] = {}
def _visit(node: Any, depth: int = 0) -> None:
if depth > 50:
return
if isinstance(node, dict):
pkg = node.get("package") or node.get("packageName")
if isinstance(pkg, str) and pkg and pkg not in _FOREGROUND_APP_IGNORED:
counts[pkg] = counts.get(pkg, 0) + 1
children = node.get("children")
if isinstance(children, (list, tuple)):
for child in children:
_visit(child, depth + 1)
elif isinstance(node, (list, tuple)):
for child in node:
_visit(child, depth + 1)
try:
_visit(ui_tree)
except Exception:
return None
if not counts:
return None
return max(counts.items(), key=lambda kv: kv[1])[0]
class DataEngine:
"""Core engine for managing runtime data, storage, and streaming."""
@@ -538,6 +577,33 @@ class DataEngine:
if pre_image_name and post_image_name and pre_image_name == post_image_name:
post_image_name = None
# M5: persist the foreground app for the recall search surface (the
# parameter was historically accepted but never stored). The explicit
# parameter wins; otherwise it is derived best-effort from the UI
# tree's package attributes — a pure string scan, no device call.
extra_metadata = dict(extra_metadata or {})
try:
app = foreground_app or _derive_foreground_app(ui_tree)
if app:
extra_metadata.setdefault("foreground_app", app)
except Exception:
pass
# M4: stamp perceptual hashes for the local screen-similarity hint
# (pure PIL dHash; best-effort, computed synchronously to keep the
# record immutable once handed to the background writer).
try:
from artemis.utils.image_hash import dhash_hex
pre_dhash = dhash_hex(pre_screenshot_bytes)
if pre_dhash:
extra_metadata.setdefault("pre_image_dhash", pre_dhash)
post_dhash = dhash_hex(post_screenshot_bytes) if post_image_name else pre_dhash
if post_dhash:
extra_metadata.setdefault("post_image_dhash", post_dhash)
except Exception:
pass
step = StepRecord(
step_id=step_id,
session_id=self.current_session_id,
@@ -550,7 +616,7 @@ class DataEngine:
operator_raw_thinking=operator_raw_thinking,
operator_native_thinking=operator_native_thinking,
last_execution_result=last_execution_result,
extra_metadata=extra_metadata or {},
extra_metadata=extra_metadata,
)
def _run_background_storage():
@@ -971,8 +1037,24 @@ class DataEngine:
path = self.get_image_path(image_name)
return path if path.exists() else None
def update_step_summary(self, step_id: UUID | int | str, summary: str):
"""Update the step with a concise summary in background."""
def update_step_summary(
self,
step_id: UUID | int | str,
summary: str | None,
*,
source: str | None = None,
version: int | None = None,
model: str | None = None,
status: str = "ready",
):
"""Update the step summary in background, with status/version metadata.
Writes ``summary_status`` / ``summary_source`` / ``summary_version`` /
``summary_model`` into the step's ``extra_metadata`` alongside the
summary text; concurrent same-step writes are ordered by version in the
storage layer. ``status`` may be ``pending`` / ``ready`` / ``failed`` /
``stale``; only ``ready`` writes carry summary text and publish SSE.
"""
target_uuid = None
if isinstance(step_id, int):
if self.current_session_id:
@@ -993,7 +1075,19 @@ class DataEngine:
logger.debug(f"Could not resolve step_id for {step_id} to update summary.")
return
self._run_in_background(self.storage.update_step_summary, target_uuid, summary)
self._run_in_background(
functools.partial(
self.storage.update_step_summary,
target_uuid,
summary,
source=source,
version=version,
model=model,
status=status,
)
)
if status != "ready" or summary is None:
return
step_num = self.get_step_number(target_uuid if not isinstance(step_id, int) else step_id)
payload = {
"step_id": str(target_uuid),
@@ -1004,6 +1098,60 @@ class DataEngine:
payload["step_number"] = step_num
self._publish("step_updated", payload)
def record_history_chunk(
self,
*,
start_step_number: int,
end_step_number: int,
version: int,
status: str,
start_step_id: str | None = None,
end_step_id: str | None = None,
source_step_ids: list[str] | None = None,
subgoal_hash: str | None = None,
band1: dict[str, Any] | None = None,
band2: str | None = None,
band3: str | None = None,
rendered_text: str | None = None,
) -> UUID | None:
"""Persist one history-chunk version row in the background (append-only).
Chunk identity is the step range; callers own version numbering (a
newer version for the same range supersedes older ones at read time).
"""
if not self.current_session_id:
return None
chunk = HistoryChunkRecord(
chunk_id=uuid4(),
session_id=self.current_session_id,
start_step_id=start_step_id,
end_step_id=end_step_id,
start_step_number=start_step_number,
end_step_number=end_step_number,
source_step_ids=source_step_ids or [],
subgoal_hash=subgoal_hash,
version=version,
status=status,
band1=band1 or {},
band2=band2,
band3=band3,
rendered_text=rendered_text,
)
self._run_in_background(self.storage.create_history_chunk, chunk)
return chunk.chunk_id
def get_history_chunks(self, *, all_versions: bool = False) -> list[HistoryChunkRecord]:
"""History chunks of the active session (newest version per step range)."""
if not self.current_session_id:
return []
try:
return self.storage.get_history_chunks(
self.current_session_id, all_versions=all_versions
)
except Exception as e:
logger.error(f"Failed to fetch history chunks: {e}")
return []
def update_step_thinking(self, step_id: UUID, operator_raw_thinking: str):
"""Update step's thinking in SQLite in background."""
self._run_in_background(self.storage.update_step_thinking, step_id, operator_raw_thinking)
+27
View File
@@ -55,6 +55,33 @@ class StepRecord(BaseModel):
extra_metadata: dict[str, Any] = Field(default_factory=dict)
class HistoryChunkRecord(BaseModel):
"""One version of a compressed history segment (history redesign §3.3/§6.1).
Identity is the step *range* ``(session_id, start_step_number,
end_step_number)``; the milestone ``subgoal_hash`` is label metadata only.
Rows are append-only: a later write for the same range carries a higher
``version`` (capsule readiness, post-hoc checkpoint annotations).
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
chunk_id: UUID | str
session_id: UUID | str
start_step_id: str | None = None
end_step_id: str | None = None
start_step_number: int
end_step_number: int
source_step_ids: list[str] = Field(default_factory=list)
subgoal_hash: str | None = None
version: int = 1
status: str = "pending" # pending (band ③ only), ready (bands ①②③)
band1: dict[str, Any] = Field(default_factory=dict) # synopsis + structured fields
band2: str | None = None # interval narrative
band3: str | None = None # mechanical per-step action ledger
rendered_text: str | None = None
created_at: float = Field(default_factory=time.time)
class TraceRecord(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
trace_id: UUID | str
+185 -7
View File
@@ -27,6 +27,7 @@ from uuid import UUID
from artemis.data_engine.models import (
BackgroundTaskRecord,
FailedOutputRecord,
HistoryChunkRecord,
ImageRecord,
SessionMetadata,
StepRecord,
@@ -205,6 +206,26 @@ class StorageManager:
FOREIGN KEY(session_id) REFERENCES sessions(session_id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS history_chunks (
chunk_id TEXT PRIMARY KEY,
session_id TEXT,
start_step_id TEXT,
end_step_id TEXT,
start_step_number INTEGER,
end_step_number INTEGER,
source_step_ids TEXT,
subgoal_hash TEXT,
version INTEGER,
status TEXT,
band1 TEXT,
band2 TEXT,
band3 TEXT,
rendered_text TEXT,
created_at REAL,
FOREIGN KEY(session_id) REFERENCES sessions(session_id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS video_recordings (
video_id TEXT PRIMARY KEY,
@@ -534,22 +555,179 @@ class StorageManager:
)
conn.commit()
def update_step_summary(self, step_id: UUID, summary: str):
"""Update the summary for a step."""
def update_step_summary(
self,
step_id: UUID,
summary: str | None,
*,
source: str | None = None,
version: int | None = None,
model: str | None = None,
status: str = "ready",
) -> bool:
"""Versioned, status-carrying summary write (replaces the blind overwrite).
Writes ``summary_status`` / ``summary_source`` / ``summary_version`` /
``summary_model`` into the step's ``extra_metadata``. Concurrent writes
to the same step are ordered by version: an explicit ``version`` lower
than the stored one is dropped; ``version=None`` auto-increments the
stored version. Status downgrades are refused without a newer explicit
version: ``pending`` never overwrites ``ready``/``failed``, and
``failed`` never overwrites ``ready``. The ``summary`` column itself is
only touched by a ``ready`` write with a non-None summary.
Returns True when the write was applied, False when it was dropped as
stale (or the step does not exist).
"""
with self._get_connection() as conn:
cursor = conn.execute(
"SELECT extra_metadata FROM steps WHERE step_id = ?",
(str(step_id),),
)
row = cursor.fetchone()
if row is None:
return False
try:
meta = json.loads(row["extra_metadata"]) if row["extra_metadata"] else {}
if not isinstance(meta, dict):
meta = {}
except Exception:
meta = {}
current_version = meta.get("summary_version")
current_version = current_version if isinstance(current_version, int) else 0
current_status = meta.get("summary_status")
if version is not None:
if version < current_version:
return False
new_version = version
is_newer = version > current_version
else:
new_version = current_version + 1
is_newer = False
# Status downgrades require an explicitly newer version.
if not is_newer:
if status == "pending" and current_status in ("ready", "failed"):
return False
if status == "failed" and current_status == "ready":
return False
meta["summary_status"] = status
meta["summary_version"] = new_version
if source is not None:
meta["summary_source"] = source
if model is not None:
meta["summary_model"] = model
if status == "ready" and summary is not None:
conn.execute(
"UPDATE steps SET summary = ?, extra_metadata = ? WHERE step_id = ?",
(summary, json.dumps(meta), str(step_id)),
)
else:
conn.execute(
"UPDATE steps SET extra_metadata = ? WHERE step_id = ?",
(json.dumps(meta), str(step_id)),
)
conn.commit()
return True
def create_history_chunk(self, chunk: HistoryChunkRecord):
"""Insert one history-chunk version row (append-only; never updates)."""
with self._get_connection() as conn:
conn.execute(
"""
UPDATE steps
SET summary = ?
WHERE step_id = ?
INSERT INTO history_chunks (
chunk_id, session_id, start_step_id, end_step_id,
start_step_number, end_step_number, source_step_ids,
subgoal_hash, version, status, band1, band2, band3,
rendered_text, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
summary,
str(step_id),
str(chunk.chunk_id),
str(chunk.session_id),
chunk.start_step_id,
chunk.end_step_id,
chunk.start_step_number,
chunk.end_step_number,
json.dumps(chunk.source_step_ids),
chunk.subgoal_hash,
chunk.version,
chunk.status,
json.dumps(chunk.band1, ensure_ascii=False, cls=SafeJSONEncoder),
chunk.band2,
chunk.band3,
chunk.rendered_text,
chunk.created_at,
),
)
conn.commit()
def get_history_chunks(
self, session_id: UUID | str, *, all_versions: bool = False
) -> list[HistoryChunkRecord]:
"""History chunks of a session ordered by step range.
By default only the newest version per step range is returned; pass
``all_versions=True`` for the full append-only trail (audit/tests).
"""
with self._get_connection() as conn:
cursor = conn.execute(
"""
SELECT * FROM history_chunks WHERE session_id = ?
ORDER BY start_step_number ASC, version ASC, created_at ASC
""",
(str(session_id),),
)
rows = cursor.fetchall()
records: list[HistoryChunkRecord] = []
for row in rows:
try:
source_ids = json.loads(row["source_step_ids"]) if row["source_step_ids"] else []
except Exception:
source_ids = []
try:
band1 = json.loads(row["band1"]) if row["band1"] else {}
if not isinstance(band1, dict):
band1 = {}
except Exception:
band1 = {}
records.append(
HistoryChunkRecord(
chunk_id=row["chunk_id"],
session_id=row["session_id"],
start_step_id=row["start_step_id"],
end_step_id=row["end_step_id"],
start_step_number=row["start_step_number"],
end_step_number=row["end_step_number"],
source_step_ids=source_ids,
subgoal_hash=row["subgoal_hash"],
version=row["version"],
status=row["status"],
band1=band1,
band2=row["band2"],
band3=row["band3"],
rendered_text=row["rendered_text"],
created_at=row["created_at"],
)
)
if all_versions:
return records
latest: dict[tuple[int, int], HistoryChunkRecord] = {}
for rec in records:
key = (rec.start_step_number, rec.end_step_number)
current = latest.get(key)
if current is None or rec.version >= current.version:
latest[key] = rec
return sorted(latest.values(), key=lambda r: r.start_step_number)
def update_step_thinking(self, step_id: UUID, operator_raw_thinking: str):
"""Update the raw thinking process for a step."""
with self._get_connection() as conn:
+23
View File
@@ -302,6 +302,28 @@ def _record_verdicts(
)
def _annotate_history_chunks(ctx, run: CheckpointRun, verdicts) -> None:
"""Post-hoc chunk annotation (M3 §9): a verdict harvested after its subgoal's
segment was chunk-compressed is appended to that chunk and bumps its
version. Best-effort — history annotation never gates checkpoint booking."""
try:
chunker = getattr(getattr(ctx, "transcript_ledger", None), "chunker", None)
if chunker is None:
return
payload = [
{
"kind": getattr(v, "kind", "check"),
"item_text": getattr(v, "item_text", ""),
"status": getattr(v, "status", ""),
"evidence": getattr(v, "evidence", ""),
}
for v in verdicts
]
chunker.annotate_from_checkpoint(run.checkpoint.checkpoint_id, payload)
except Exception as e:
logger.debug(f"History chunk annotation skipped: {e}")
def _record_attempt_failure(ctx, run: CheckpointRun, status: str, evidence: str) -> None:
base_dir = ctx.data_engine.base_dir
for ci in run.checkpoint.check_items:
@@ -367,6 +389,7 @@ def harvest_run(
report = task.result()
verdicts = list(getattr(report, "verdicts", []) or [])
_record_verdicts(ctx, run, verdicts, anchor_step_id)
_annotate_history_chunks(ctx, run, verdicts)
# Applicability gate for side effects: the verdict must belong to the
# current attempt of its checkpoint AND the anchored subgoal's text must be
+29
View File
@@ -96,6 +96,20 @@ def convergence_node(state: State):
return {}
def _notify_history_chunker(ctx: ArtemisContext, method: str, *args) -> None:
"""Best-effort event feed to the transcript's L2/L3 chunk manager (M3).
The chunker only exists when the transcript flag is on; every failure is
swallowed — history compression must never gate the execution path.
"""
try:
chunker = getattr(getattr(ctx, "transcript_ledger", None), "chunker", None)
if chunker is not None:
getattr(chunker, method)(*args)
except Exception as e:
logger.debug(f"History chunker notification '{method}' skipped: {e}")
async def execution_check_node(state: State, ctx: ArtemisContext):
state = strict_state(state, "execution_check")
logger.info("Starting execution_check_node")
@@ -126,6 +140,10 @@ async def execution_check_node(state: State, ctx: ArtemisContext):
logger.warning(f"Failed to parse structured decisions: {e}")
subgoal_hash_val, sub_subgoal_hash = _get_active_subgoal_hashes(ctx)
# The user's injected instruction is stamped verbatim on the step
# it reached: the chunk ledger (band ③) preserves it as a
# never-evicted line at every compression level (§3.3).
injected = getattr(state, "injected_instruction", None)
step_id = ctx.data_engine.record_step(
pre_screenshot_bytes=screenshot_bytes,
ui_tree=xml_hierarchy,
@@ -138,10 +156,14 @@ async def execution_check_node(state: State, ctx: ArtemisContext):
"sub_subgoal_hash": sub_subgoal_hash,
"width": raw_data.get("width"),
"height": raw_data.get("height"),
**({"injected_instruction": injected} if injected else {}),
**extra_metadata,
},
)
logger.info(f"Recorded step in DataEngine: {step_id}")
# Milestone-switch fact source: the stamped subgoal hash of every
# executed step, compared consecutively inside the chunk manager.
_notify_history_chunker(ctx, "on_step_stamped", str(step_id), subgoal_hash_val)
return str(step_id)
except Exception as e:
logger.error(f"Failed to record step in DataEngine: {e}")
@@ -611,6 +633,10 @@ async def _process_plan_write(
new_completions = new_top_level_completions(before, after)
if new_completions:
queue_checkpoints(ctx, state, after, new_completions, content_after)
# History chunking (M3): a top-level completion only queues an
# UNCONFIRMED boundary — the next stamped step confirms it via an
# actual subgoal-hash change (pseudo-switch protection, §3.3).
_notify_history_chunker(ctx, "queue_boundary_hint")
return result
@@ -823,11 +849,14 @@ async def get_graph(ctx: ArtemisContext) -> CompiledStateGraph:
operator_native_tools.append(t)
# Get specialized tools for Operator
from artemis.tools.history_recall import recall_history_wrapper
operator_specialized_wrappers = [
ask_diagnoser_wrapper,
run_adb_command_wrapper,
manage_task_wrapper,
ask_explorer_wrapper,
recall_history_wrapper,
]
if ctx.execution_setup and ctx.execution_setup.enable_committee:
operator_specialized_wrappers.append(ask_committee_wrapper)
-5
View File
@@ -121,11 +121,6 @@ class State(BaseModel):
"Native/implicit thinking process of the operator",
take_last,
] = None
short_term_memory: Annotated[
str | None,
"Short term memory / scratchpad for the operator",
take_last,
] = None
last_execution_result: Annotated[
dict | None, "Last execution result from validator", take_last
] = None
+6 -2
View File
@@ -83,19 +83,20 @@ NODE_VISIBILITY: dict[str, NodeVisibility] = {
"latest_screenshot",
"indexed_points",
"indexed_elements",
"short_term_memory",
"subagent_calls",
"current_step_id",
"operator_tool_limit_exceeded",
"structured_decisions",
"operator_raw_thinking",
"operator_native_thinking",
# Transcript path (M2): committing the previous turn into the
# ledger appends its validator result message.
"last_execution_result",
},
writes={
"structured_decisions",
"operator_raw_thinking",
"operator_native_thinking",
"short_term_memory",
"indexed_points",
"indexed_elements",
"current_step_id",
@@ -111,6 +112,9 @@ NODE_VISIBILITY: dict[str, NodeVisibility] = {
"operator_native_thinking",
"user_stop_requested",
"initial_goal",
# M3: the turn's injected instruction is stamped verbatim onto the
# step record so the chunk ledger can preserve it (never-evict).
"injected_instruction",
},
writes={
"checker_success",
+69
View File
@@ -54,6 +54,7 @@ from artemis.utils.coordinates import (
compute_smart_swipe_coordinates,
parse_swipe_parameters,
)
from artemis.utils.element_hit_test import hit_test_semantics
from artemis.utils.logger import get_logger
from artemis.utils.notes import (
format_list_notes_failure,
@@ -139,11 +140,19 @@ class McpActionExecutor:
img_bytes: bytes | None = None
shot_path: str | None = None
xml_list: str | None = None
target_semantics: dict[str, Any] | None = None
with span:
try:
session = await self._session_or_start()
wire_name, wire_args, finalize = self._translate(raw_name, args, state)
# Record-time enrichment: bare-coordinate targeted actions get
# best-effort element semantics from the pre-action frame (the
# post-action observe below overwrites state.indexed_elements,
# so this must happen before the device call).
target_semantics = self._target_semantics(raw_name, wire_args, state)
if target_semantics:
span.payload = {"args": {**args, **target_semantics}}
extension = raw_name not in (REQUIRED_ACTIONS | OPTIONAL_ACTIONS)
if extension:
@@ -207,9 +216,69 @@ class McpActionExecutor:
metadata={
"code": res.code.value,
"normalized_coordinates": res.normalized_coordinates,
"target_semantics": target_semantics,
},
)
def _target_semantics(
self, raw_name: str, wire_args: dict[str, Any], state: Any
) -> dict[str, Any] | None:
"""Best-effort element semantics for a coordinate-targeted action.
Hit tests the normalized (0-1000) target point(s) against the
pre-action frame's indexed elements. Single-target actions get the
flat ``target_*`` fields; multi-point actions (``click_sequence``,
``swipe``) hit test every point best-effort (M5), hoisting the FIRST
point's semantics to the main ``target_*`` label and keeping the
per-point results alongside. Returns None for actions without a
resolvable coordinate target; degrades to
``target_label_source: "none"`` when no element data covers the point.
"""
try:
elements = getattr(state, "indexed_elements", None) if state else None
if raw_name in ("click", "long_press", "input_text"):
target = wire_args.get("target")
if not (isinstance(target, (list, tuple)) and len(target) == 2):
return None
return self._hit_test_point(elements, target)
if raw_name == "click_sequence":
sequence = wire_args.get("sequence")
if not (isinstance(sequence, (list, tuple)) and sequence):
return None
points = [self._hit_test_point(elements, p) for p in sequence]
semantics: dict[str, Any] = dict(points[0])
semantics["points_semantics"] = points
return semantics
if raw_name == "swipe":
start = wire_args.get("start")
end = wire_args.get("end")
if not (isinstance(start, (list, tuple)) and len(start) == 2):
return None
semantics = dict(self._hit_test_point(elements, start))
semantics["start_semantics"] = self._hit_test_point(elements, start)
if isinstance(end, (list, tuple)) and len(end) == 2:
semantics["end_semantics"] = self._hit_test_point(elements, end)
return semantics
return None
except Exception as enrich_err:
logger.debug(f"Target semantics enrichment skipped for {raw_name}: {enrich_err}")
return {"target_label_source": "none"}
def _hit_test_point(self, elements: Any, target: Any) -> dict[str, Any]:
"""Hit tests one normalized (0-1000) point; never raises."""
try:
width = getattr(self.ctx.device, "device_width", 1080) if self.ctx.device else 1080
height = getattr(self.ctx.device, "device_height", 2400) if self.ctx.device else 2400
x_px = int(max(0, min(width - 1, float(target[0]) * width / 1000)))
y_px = int(max(0, min(height - 1, float(target[1]) * height / 1000)))
return hit_test_semantics(elements, x_px, y_px)
except Exception:
return {"target_label_source": "none"}
@staticmethod
def _observe_despite_failure(raw_name: str, res: ActionResult) -> bool:
"""Failures that still observe the screen, mirroring the legacy executor.
+1
View File
@@ -122,6 +122,7 @@ BACKEND_INDEPENDENT_TOOLS: frozenset[str] = frozenset(
"run_adb_command",
"manage_task",
"analyze_task_output",
"recall_history",
"report_task_status",
"report_failure_analysis",
}
+84
View File
@@ -0,0 +1,84 @@
# 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.
"""Shared step-memory runtime for background summarization across profiles."""
from artemis.memory.chunking import (
ChunkCapsuleService,
HistoryChunkManager,
StepCapsuleLens,
build_action_ledger,
)
from artemis.memory.context_policy import (
CONTEXT_POLICIES,
ContextPolicy,
build_history_for,
resolve_policy,
)
from artemis.memory.step_memory import StepLens, StepMemoryService
from artemis.memory.transcript import TranscriptLedger, format_session_offset
def ensure_step_memory(ctx):
"""Return the session's shared step-memory service, creating it on demand.
Composition root for ``ctx.step_memory`` (history redesign §6.2): the
first caller — FlashRunner, the Pro SummarizerNode, or the Pro operator's
transcript path — instantiates the visual-transition lens service and
publishes it on the context so every profile shares one runtime.
"""
service = getattr(ctx, "step_memory", None)
if service is not None:
return service
from artemis.agents.flash.summarizer import VisualStepSummarizer
kwargs: dict = {}
model_name = None
try:
from artemis.config import load_agent_config
cfg = load_agent_config()
model_name = cfg.flash.step_summarizer.model
kwargs = {
"retry_limit": cfg.memory.runtime.retry_limit,
"max_concurrency": cfg.memory.runtime.max_concurrency,
"flush_timeout_s": cfg.memory.runtime.flush_timeout_s,
}
except Exception:
pass
service = VisualStepSummarizer(ctx, model_name=model_name, **kwargs)
try:
ctx.step_memory = service
except Exception:
pass
return service
__all__ = [
"CONTEXT_POLICIES",
"ChunkCapsuleService",
"ContextPolicy",
"HistoryChunkManager",
"StepCapsuleLens",
"build_history_for",
"resolve_policy",
"StepLens",
"StepMemoryService",
"TranscriptLedger",
"build_action_ledger",
"ensure_step_memory",
"format_session_offset",
]
File diff suppressed because it is too large. Load diff
+248
View File
@@ -0,0 +1,248 @@
# 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.
"""ContextPolicy: the declarative per-agent compiled-history policy table (M4).
Every production caller of :func:`artemis.utils.task_tree.build_plan_and_history`
used to hard-code its visibility kwargs at the call site. This module gathers
those call sites into one declaration per agent — the rendered output for each
agent is byte-identical to the previous hard-coded call (pinned by the golden
tests in ``tests/unit/memory/test_context_policy.py``) — and adds the M4 chunk
hand-off: when the transcript flag is on and history chunks exist, the
compiled view replaces chunked-away step lines with chunk blocks.
Chunk view per policy (§10 decision 4):
- ``"full"`` (outputter / history_analyzer): the chunk's stored three-band
rendering including the ③ full-width per-step ledger;
- ``"digest"`` (everything else): bands ① + ② with the ③ ledger replaced by
a ``ledger via recall_history`` marker line.
Because chunks only exist when ``agent.memory.transcript.enabled`` is on, the
flag-off output of every agent stays byte-for-byte unchanged.
``agent.memory.policies`` allows per-agent field overrides, e.g.::
"memory": {"policies": {"planner": {"last_n_detailed": 3}}}
"""
from dataclasses import dataclass, fields, replace
from typing import Any
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
#: Marker appended to a digest-view chunk block in place of the ③ ledger.
DIGEST_LEDGER_MARKER_TEMPLATE = (
"③ Step action ledger: available via recall_history (Steps {start}–{end})"
)
#: The band-③ heading inside a chunk's stored ``rendered_text``.
_BAND3_HEADING = "③ Step action ledger"
@dataclass(frozen=True)
class ContextPolicy:
"""One agent's compiled-history rendering declaration.
Field semantics map 1:1 onto ``build_plan_and_history`` kwargs, with two
conveniences: ``uncompressed=True`` renders every step (the historical
``min_summaries=len(steps)`` idiom) and ``whitelist=True`` marks policies
whose caller supplies ``keep_subgoal_hashes`` at runtime.
"""
strategy: str = "sliding_window" # documentation only
strict_milestone_pruning: bool = False
recent_window_size: int = 3
min_summaries: int = 5
uncompressed: bool = False
last_n_detailed: int = 1
all_detailed: bool = False
for_failure_analyzer: bool = False
whitelist: bool = False
chunk_view: str | None = "digest" # "full" | "digest" | None (never chunks)
#: The eight production call sites, verbatim (kwargs recorded 2026-09-01).
CONTEXT_POLICIES: dict[str, ContextPolicy] = {
# operator.py:996 — last_n_detailed comes from the node constructor.
"operator": ContextPolicy(
strategy="strict_milestone",
strict_milestone_pruning=True,
recent_window_size=3,
),
# operator.py:341 — transcript F-region cold-start restore.
"operator_cold_start": ContextPolicy(
strategy="full",
uncompressed=True,
last_n_detailed=1,
chunk_view=None,
),
# failure_analyzer.py:361
"failure_analyzer": ContextPolicy(
strategy="strict_milestone",
strict_milestone_pruning=True,
recent_window_size=3,
last_n_detailed=1,
for_failure_analyzer=True,
),
# planner.py:133 — caller passes an empty plan and hash "default".
"planner": ContextPolicy(
strategy="strict_milestone",
strict_milestone_pruning=True,
recent_window_size=5,
last_n_detailed=2,
),
# outputter.py:81
"outputter": ContextPolicy(
strategy="full",
uncompressed=True,
last_n_detailed=0,
chunk_view="full",
),
# history_analyzer.py:113
"history_analyzer": ContextPolicy(
strategy="full",
uncompressed=True,
last_n_detailed=1,
chunk_view="full",
),
# diagnoser.py:166 — every non-whitelist kwarg is the historical default.
"diagnoser": ContextPolicy(strategy="milestone_whitelist", whitelist=True),
# committee_tool.py:189
"committee": ContextPolicy(
strategy="milestone_whitelist",
whitelist=True,
last_n_detailed=3,
),
}
_POLICY_FIELDS = {f.name for f in fields(ContextPolicy)}
def resolve_policy(agent: str) -> ContextPolicy:
"""The agent's policy with any ``agent.memory.policies`` overrides applied."""
try:
policy = CONTEXT_POLICIES[agent]
except KeyError:
raise KeyError(
f"No ContextPolicy declared for agent '{agent}';"
f" known agents: {sorted(CONTEXT_POLICIES)}"
) from None
overrides: dict[str, Any] = {}
try:
from artemis.config import load_agent_config
configured = (load_agent_config().memory.policies or {}).get(agent) or {}
overrides = {k: v for k, v in configured.items() if k in _POLICY_FIELDS}
unknown = set(configured) - _POLICY_FIELDS
if unknown:
logger.warning(
f"agent.memory.policies['{agent}'] ignores unknown fields: {sorted(unknown)}"
)
except Exception:
overrides = {}
return replace(policy, **overrides) if overrides else policy
def _chunk_digest_text(rendered_text: str, start: int, end: int) -> str:
"""Bands ① + ② of a stored chunk rendering, ③ replaced by the recall marker."""
marker = DIGEST_LEDGER_MARKER_TEMPLATE.format(start=start, end=end)
head, sep, _ = rendered_text.partition(f"{_BAND3_HEADING}\n")
if not sep:
return f"{rendered_text.rstrip()}\n{marker}"
return f"{head.rstrip()}\n\n{marker}"
def load_chunk_blocks(engine: Any, view: str | None) -> list[dict] | None:
"""The persisted history chunks rendered for the compiled view, or None.
Returns None (→ byte-identical legacy rendering) unless the transcript
flag is on, the engine exposes chunks, and at least one chunk exists.
"""
if engine is None or view not in ("full", "digest"):
return None
try:
from artemis.config import load_agent_config
if not load_agent_config().memory.transcript.enabled:
return None
except Exception:
return None
try:
rows = engine.get_history_chunks() or []
except Exception as e:
logger.debug(f"load_chunk_blocks: no chunks available: {e}")
return None
blocks: list[dict] = []
for row in rows:
start = getattr(row, "start_step_number", None)
end = getattr(row, "end_step_number", None)
rendered = getattr(row, "rendered_text", None)
if not isinstance(start, int) or not isinstance(end, int) or not rendered:
continue
text = (
rendered
if view == "full"
else _chunk_digest_text(rendered, start, end)
)
blocks.append(
{"start_step_number": start, "end_step_number": end, "text": text}
)
return blocks or None
def build_history_for(
agent: str,
task_plan: str,
steps: list,
current_subgoal_hash: str,
*,
keep_subgoal_hashes: set | None = None,
last_n_detailed: int | None = None,
engine: Any = None,
) -> str:
"""Render the agent's compiled plan-and-history view per its policy.
Args:
agent: Key into :data:`CONTEXT_POLICIES`.
keep_subgoal_hashes: Runtime whitelist (whitelist policies only).
last_n_detailed: Runtime override (the operator's constructor knob).
engine: DataEngine handing chunks to the compiled view; omit at call
sites that must never receive chunk blocks.
"""
from artemis.utils.task_tree import build_plan_and_history
policy = resolve_policy(agent)
kwargs: dict[str, Any] = {
"min_summaries": len(steps) if policy.uncompressed else policy.min_summaries,
"last_n_detailed": (
policy.last_n_detailed if last_n_detailed is None else last_n_detailed
),
"all_detailed": policy.all_detailed,
"strict_milestone_pruning": policy.strict_milestone_pruning,
"recent_window_size": policy.recent_window_size,
"for_failure_analyzer": policy.for_failure_analyzer,
}
if policy.whitelist:
kwargs["keep_subgoal_hashes"] = keep_subgoal_hashes
chunks = load_chunk_blocks(engine, policy.chunk_view)
if chunks:
kwargs["chunks"] = chunks
return build_plan_and_history(task_plan, steps, current_subgoal_hash, **kwargs)
+57
View File
@@ -0,0 +1,57 @@
# ROLE
You are the Segment Capsule writer for a mobile automation agent's history compression. You receive one contiguous segment of executed steps (per-step visual transition summaries, exact actions, controller/validator results, operator reasoning excerpts, notes written, and user-injected instructions) and produce the two LLM-authored bands of the segment's history chunk:
- Band ① — Synopsis & effects: what this segment was doing, what was actually done, and what effects/artifacts it left behind, plus structured fields.
- Band ② — Compressed step summary: an interval narrative ("Steps xx–xx did ..., Step xx did ...") that seamlessly covers every step of the segment.
The mechanical per-step action ledger (band ③) is assembled outside of you; do NOT reproduce it.
# NEUTRAL-WORDING CONTRACT (STRICT)
1. **Zero subjective validation**: never declare semantic success, completion, or final failure. Subjective verdicts in compressed history cause future turns to falsely believe a goal was definitively met or permanently broken.
- **BANNED WORDS**: "successfully", "completed", "achieved", "entered", "navigated to", "arrived at", "failed", "unsuccessful", "could not", "impossible".
- Describe observable transitions and recorded results instead (e.g. "the settings page was displayed", "the controller reported an execution error").
2. **First-person perspective**: write from the agent's perspective using "I". Never "the agent" / "the operator".
3. **Planned iterations are not anomalies**: systematic processing of different candidate items, or periodic polling cycles under a `[Loop]` / `[Loop:continuous]` milestone, is planned progress — record it objectively (e.g. "polling round #2"). Only flag a loop when the exact same target is repeated without progress or states oscillate blindly; then record the pattern and count factually.
4. **Preserve, don't abstract**: be deliberately detailed and stay close to the source wording. Prefer longer output over abstracting facts beyond recognition. Faithfully carry visible text, codes, prices, account names, file names, error banners.
5. **Fact priority**: user instructions > validator/controller recorded results > UI/OCR deltas > sourced visual summaries > reasoning excerpts.
# BAND ① REQUIREMENTS
Answer three questions in detailed prose (field `doing` / `did` / `effect`):
- `doing`: what this segment was trying to do and why (strategic context within the plan).
- `did`: what was actually carried out, including detours, retries, recoveries, and pivots.
- `effect`: what state/effects/artifacts the segment left behind. **This MUST include every note left during the segment**: for each notes write in the input, name the target note file and the gist of what was recorded. Every note's target key is machine-checked against your effect field — an omitted note key forces regeneration.
Structured fields (arrays of short strings, may be empty):
- `verified_facts`: states/values actually observed or verified on screen.
- `unresolved`: open questions, unverified assumptions, missing prerequisites.
- `failed_paths`: approaches attempted in this segment whose recorded results were errors (state the recorded error factually).
- `important_entities`: accounts, files, order/tracking numbers, package names, key UI entry points.
- `entry_state` / `exit_state` (single strings): the observable screen/app state at segment start and end.
# BAND ② REQUIREMENTS
Field `intervals`: an ordered array of `{"start_step": N, "end_step": M, "text": "..."}` objects.
- Every line's text must reference concrete behavior for those steps.
- **The union of intervals MUST cover the segment's step range seamlessly** — no gaps, no overlaps, in ascending order. This is machine-checked; a gap forces regeneration.
- Group only homogeneous consecutive actions into one interval; a heterogeneous action gets its own single-step interval (`start_step == end_step`).
- Keep the neutral-wording contract in every line.
# OUTPUT FORMAT
Return ONLY one JSON object (no markdown fences, no commentary):
```json
{
"doing": "...",
"did": "...",
"effect": "...",
"entry_state": "...",
"exit_state": "...",
"verified_facts": ["..."],
"unresolved": ["..."],
"failed_paths": ["..."],
"important_entities": ["..."],
"intervals": [
{"start_step": 18, "end_step": 20, "text": "..."},
{"start_step": 21, "end_step": 21, "text": "..."}
]
}
```
+254
View File
@@ -0,0 +1,254 @@
# 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.
"""Shared background step-summary runtime (StepMemoryService).
Hosts the scheduling skeleton lifted from the Flash VisualStepSummarizer:
zero-blocking dispatch, bounded retry, bounded flush, canonical step_id keying
with alias resolution (tool_call_id / legacy ordinals), and status queries.
What a summary *contains* (the lens semantics) lives in subclasses — the
service only guarantees how jobs are scheduled, retried, keyed, and drained.
Both Flash and (from M2 on) Pro profiles share this runtime.
"""
import asyncio
from typing import Any
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
JobKey = int | str
class StepLens:
"""Formal lens interface: what one summary attempt *produces*.
A lens turns one job payload into summary text; the surrounding service
owns scheduling, retries, keying, and draining. Implementations must be
side-effect-tolerant under retries (an attempt may run again after a
failure) and return ``None``/raise to signal a failed attempt.
Two lens altitudes exist in the redesign (§5): the step-level visual
transition lens (:class:`~artemis.agents.flash.summarizer.VisualStepSummarizer`
keeps it inlined as its ``_attempt`` for compatibility) and the chunk-level
:class:`~artemis.memory.chunking.StepCapsuleLens`.
"""
name = "lens"
async def render(self, key: JobKey, payload: dict[str, Any]) -> str | None:
"""Produce the summary text for one payload, or None on failure."""
raise NotImplementedError
class StepMemoryService:
"""Non-blocking background runtime for per-step summary jobs.
Key design properties:
1. Zero-blocking dispatch: callers submit and proceed immediately.
2. Bounded retry: each job attempts at most ``1 + retry_limit`` times and
then enters an explicit failed state — never an unbounded loop.
3. Bounded flush: ``flush()`` waits up to the configured timeout, then
cancels whatever is still in flight.
4. Canonical keying with aliases: jobs are keyed by the DataEngine step id
where available; tool_call_ids (and legacy ordinals) remain queryable
through an alias map so message-level consumers need no migration.
5. Bounded concurrency: at most ``max_concurrency`` attempts run at once.
Subclasses implement :meth:`_attempt` (one summarization attempt for a
payload) and may override :meth:`_on_status` to persist pending/failed
status transitions.
"""
def __init__(
self,
ctx: Any,
*,
lens: StepLens | None = None,
max_concurrency: int = 1,
retry_limit: int = 3,
flush_timeout_s: float = 30.0,
):
self.ctx = ctx
self._lens = lens
self._summaries: dict[JobKey, str] = {}
self._failed: set[JobKey] = set()
self._pending_tasks: dict[JobKey, asyncio.Task] = {}
self._step_inputs: dict[JobKey, dict[str, Any]] = {}
self._retry_counts: dict[JobKey, int] = {}
self._retry_delays = (0.0, 0.5, 1.0, 2.0, 3.0)
self._retry_limit = max(0, retry_limit)
self._flush_timeout_s = max(0.0, flush_timeout_s)
self._aliases: dict[JobKey, JobKey] = {}
self._semaphore = asyncio.Semaphore(max(1, max_concurrency))
# ------------------------------------------------------------------
# Keying
# ------------------------------------------------------------------
def resolve_key(self, key: JobKey) -> JobKey:
"""Resolve an alias (tool_call_id / legacy ordinal) to its canonical key."""
return self._aliases.get(key, key)
def submit(
self,
key: JobKey,
payload: dict[str, Any],
aliases: tuple[JobKey, ...] = (),
) -> None:
"""Submit one summary job without blocking the caller.
Args:
key: Canonical job key (DataEngine step id where available).
payload: Lens-specific input data; stored until the job resolves.
aliases: Additional keys (e.g. tool_call_id) that resolve to ``key``.
"""
for alias in aliases:
if alias is not None and alias != key:
self._aliases[alias] = key
self._step_inputs[key] = payload
self._failed.discard(key)
self._on_status(key, "pending")
task = asyncio.create_task(self._run_until_ready(key))
self._pending_tasks[key] = task
# ------------------------------------------------------------------
# Scheduling core
# ------------------------------------------------------------------
async def _run_until_ready(self, key: JobKey) -> None:
"""Retry one job independently, bounded by the configured retry limit.
Attempts at most ``1 + retry_limit`` times; on exhaustion the job
enters an explicit failed state (surfaced via :meth:`has_failed` and
the ``_on_status`` hook). Retry backoff sleeps happen outside the
concurrency semaphore so a waiting job never starves its peers.
"""
while key not in self._summaries:
async with self._semaphore:
succeeded = await self._attempt(key)
if succeeded:
return
retry_count = self._retry_counts.get(key, 0) + 1
self._retry_counts[key] = retry_count
payload = self._step_inputs.get(key)
display_step = payload.get("step_number") if payload else key
if retry_count > self._retry_limit:
self._failed.add(key)
logger.warning(
f"StepMemoryService: Giving up on Step {display_step} after"
f" {retry_count} failed attempts (retry_limit={self._retry_limit})."
)
self._on_status(key, "failed")
return
delay = self._retry_delays[min(retry_count - 1, len(self._retry_delays) - 1)]
logger.info(
f"StepMemoryService: Retrying Step {display_step} "
f"(attempt {retry_count + 1}) after {delay:.1f}s"
)
if delay:
await asyncio.sleep(delay)
async def _attempt(self, key: JobKey) -> bool:
"""Run one summarization attempt for ``key``; True when a summary landed.
The default implementation delegates to the configured :class:`StepLens`;
subclasses without a lens override this method directly (the Flash
visual summarizer keeps its inlined attempt for compatibility).
"""
if self._lens is None:
raise NotImplementedError
payload = self._step_inputs.get(key)
if payload is None:
return False
try:
summary = await self._lens.render(key, payload)
except Exception as e:
logger.warning(f"StepMemoryService: lens '{self._lens.name}' attempt failed: {e}")
return False
if not summary:
return False
self._summaries[key] = summary
self._on_ready(key, summary)
return True
def _on_ready(self, key: JobKey, summary: str) -> None:
"""Hook invoked when a lens-produced summary lands; default is a no-op."""
def _on_status(self, key: JobKey, status: str) -> None:
"""Hook invoked on pending/failed transitions; default is a no-op."""
# ------------------------------------------------------------------
# Status queries (alias-resolving)
# ------------------------------------------------------------------
def get_summary(self, key: JobKey, fallback_text: str | None = None) -> str | None:
"""Retrieve the summary for a job key (or alias), or fallback text."""
return self._summaries.get(self.resolve_key(key), fallback_text)
def has_summary(self, key: JobKey) -> bool:
"""Check whether a job's summary has completed."""
return self.resolve_key(key) in self._summaries
def has_job(self, key: JobKey) -> bool:
"""Check whether a job has been submitted for summarization."""
return self.resolve_key(key) in self._step_inputs
def has_failed(self, key: JobKey) -> bool:
"""Check whether a job permanently failed (retries exhausted)."""
return self.resolve_key(key) in self._failed
def is_pending(self, key: JobKey) -> bool:
"""Check whether a job still needs a summary."""
return self.has_job(key) and not self.has_summary(key)
def get_job_payload(self, key: JobKey) -> dict[str, Any] | None:
"""Return the stored payload for a job key (or alias), if any."""
return self._step_inputs.get(self.resolve_key(key))
def get_step_number(self, key: JobKey) -> int | None:
"""Return the human-facing step ordinal recorded for a job, if known."""
payload = self.get_job_payload(key)
if payload is None:
return None
step_number = payload.get("step_number")
return step_number if isinstance(step_number, int) else None
# ------------------------------------------------------------------
# Draining
# ------------------------------------------------------------------
async def flush(self, timeout_seconds: float | None = None) -> None:
"""Wait for active jobs up to a bound, then cancel remaining retry loops."""
if timeout_seconds is None:
timeout_seconds = self._flush_timeout_s
tasks = [t for t in self._pending_tasks.values() if not t.done()]
if tasks:
logger.info(f"StepMemoryService: Flushing {len(tasks)} pending summary tasks...")
_, pending = await asyncio.wait(tasks, timeout=timeout_seconds)
if pending:
logger.warning(
f"StepMemoryService: 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)
+364
View File
@@ -0,0 +1,364 @@
# 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.
"""Session-level message transcript ledger for the Pro operator (M2).
Implements the four-region discipline of the history-module redesign §3.2:
- **S (stable prefix)**: the static system message — byte-identical for the
whole session (the plan+history section is moved out by the template split).
- **F (frozen history)**: in M2 only the cold-start restored-history block; L2
chunks arrive in M3.
- **A (active window)**: raw per-turn messages, append-only. Each committed
turn contributes its tail observation HumanMessage, the operator AIMessages
(tool_calls and native thinking preserved by reference), the in-turn
ToolMessages, and the turn's validator result message. The scrub edge
(:class:`~artemis.agents.flash.context_compressor.ScrubEdgeCompressor`,
generalized in M2) strips old UI lists / plan recitations at depth 1 and
resolves screenshots to visual summaries at depth K — messages are never
removed or reordered, so tool-call/response pairs are never split.
- **T (current tail)**: built fresh every turn by the operator; passed to
:meth:`render` and only enters A when the turn is committed.
All timestamps inside the transcript use the session-start offset ``T+mm:ss``
(byte-stable once frozen); "ago" wording is reserved for the auxiliary agents'
per-call compiled views.
"""
import json
import time
from typing import Any, Callable
from langchain_core.messages import BaseMessage, HumanMessage
from artemis.agents.flash.context_compressor import ScrubEdgeCompressor
from artemis.memory.step_memory import StepMemoryService
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
#: Marker for the per-turn task-plan recitation block in the observation tail.
PLAN_RECITATION_MARKER = "--- Task Plan (recited) ---"
#: Marker of the Pro observation's UI element list block (see
#: ``ObservationPromptComponent``).
PRO_UI_LIST_MARKER = "--- Visible UI Elements ---"
#: Marker prefix of a committed turn's validator result message.
EXECUTION_RESULT_MARKER = "--- Action Execution Result"
#: Header prefix of the cold-start restored-history block.
RESTORED_HISTORY_HEADER = "[Restored history]"
def format_session_offset(seconds: float) -> str:
"""Render a session-start offset as ``T+mm:ss`` (minutes never wrap)."""
total = max(0, int(seconds))
return f"T+{total // 60:02d}:{total % 60:02d}"
class TranscriptLedger:
"""Append-only four-region message ledger for one Pro session.
The ledger owns no LLM calls: summary lookups go through the shared
:class:`StepMemoryService` (``ctx.step_memory``), whose visual-transition
lens is fed by the Pro SummarizerNode each step.
"""
def __init__(
self,
*,
step_memory: StepMemoryService | None = None,
prune_history_xml: bool = True,
image_scrub_depth: int = 3,
pending_grace_steps: int = 3,
xml_scrub_depth: int = 1,
clock: Callable[[], float] | None = None,
):
self._clock = clock or time.monotonic
self._session_start = self._clock()
self._static: list[BaseMessage] = []
self._restored: list[BaseMessage] = []
self._active: list[BaseMessage] = []
self._staged: list[BaseMessage] | None = None
self._turn_count = 0
# L2/L3 chunk compression (M3). ``_turns`` records each committed
# turn's message span in ``_active``; a compression event advances
# ``_active_start`` past whole turns (never splitting tool-call pairs)
# and replaces the frozen chunk blocks wholesale. The underlying
# ``_active`` list stays append-only so scrub-edge indices stay valid.
self._turns: list[dict] = []
self._chunked_turn_count = 0
self._active_start = 0
self._frozen_blocks: list[BaseMessage] = []
self._chunker: Any | None = None
# id(message) -> summary-job key (DataEngine step id). A side map keeps
# the key out of the serialized message payload entirely.
self._step_keys: dict[int, str] = {}
self._compressor = ScrubEdgeCompressor(
summarizer=step_memory,
prune_history_xml=prune_history_xml,
image_scrub_depth=image_scrub_depth,
pending_grace_steps=pending_grace_steps,
xml_scrub_depth=xml_scrub_depth,
summary_key_getter=lambda msg: self._step_keys.get(id(msg)),
strip_markers=(PRO_UI_LIST_MARKER, PLAN_RECITATION_MARKER),
tail_offset=1,
)
# ------------------------------------------------------------------
# Session clock
# ------------------------------------------------------------------
def elapsed_seconds(self) -> float:
return max(0.0, self._clock() - self._session_start)
def elapsed_label(self) -> str:
"""The current session offset as ``T+mm:ss``."""
return format_session_offset(self.elapsed_seconds())
# ------------------------------------------------------------------
# S region
# ------------------------------------------------------------------
@property
def has_static_prefix(self) -> bool:
return bool(self._static)
def set_static_prefix(self, messages: list[BaseMessage]) -> None:
"""Install the byte-stable system prefix; only settable once."""
if self._static:
raise RuntimeError("TranscriptLedger static prefix is already set.")
self._static = list(messages)
# ------------------------------------------------------------------
# F region (M2: cold-start restored history only)
# ------------------------------------------------------------------
@property
def has_restored_history(self) -> bool:
return bool(self._restored)
def set_restored_history(self, text: str) -> None:
"""Install the cold-start frozen history block (empty ledger only)."""
if self._restored:
raise RuntimeError("TranscriptLedger restored history is already set.")
if self._active or self._staged or self._turn_count:
raise RuntimeError(
"Restored history can only seed an empty ledger (cold start)."
)
self._restored = [
HumanMessage(content=[{"type": "text", "text": text}])
]
# ------------------------------------------------------------------
# A region (append-only turn commits)
# ------------------------------------------------------------------
@property
def turn_count(self) -> int:
return self._turn_count
@property
def has_staged_turn(self) -> bool:
return self._staged is not None
@property
def active_messages(self) -> tuple[BaseMessage, ...]:
return tuple(self._active)
def stage_turn(self, messages: list[BaseMessage]) -> None:
"""Hold a finished turn's messages until the next build commits them.
Committing is deferred because the turn's DataEngine step id and its
validator result only exist after the operator returns.
"""
if self._staged is not None:
logger.warning(
"TranscriptLedger: previous staged turn was never committed;"
" committing it without step metadata."
)
self.commit_staged()
self._staged = [m for m in messages if isinstance(m, BaseMessage)]
def commit_staged(
self,
*,
step_key: str | None = None,
validator_result: Any | None = None,
) -> None:
"""Move the staged turn into the active region.
Args:
step_key: The DataEngine step id recorded for this turn; keys the
turn's observation screenshot to its visual-transition summary
job for the depth-K scrub.
validator_result: The turn's validator report (``None`` when the
turn executed no terminal action); appended as a frozen result
message carrying the ``T+mm:ss`` session offset.
"""
if self._staged is None:
return
staged = self._staged
self._staged = None
if step_key is not None:
observation = self._first_image_message(staged)
if observation is not None:
self._step_keys[id(observation)] = str(step_key)
span_start = len(self._active)
self._active.extend(staged)
if validator_result is not None:
result_message = self._build_result_message(validator_result)
if result_message is not None:
self._active.append(result_message)
self._turns.append(
{
"step_key": str(step_key) if step_key is not None else None,
"start": span_start,
"end": len(self._active),
}
)
self._turn_count += 1
@staticmethod
def _first_image_message(messages: list[BaseMessage]) -> BaseMessage | None:
for msg in messages:
content = getattr(msg, "content", None)
if not isinstance(content, list):
continue
for block in content:
if isinstance(block, dict) and block.get("type") in ("image_url", "image"):
return msg
return None
def _build_result_message(self, result: Any) -> HumanMessage | None:
rendered = None
if isinstance(result, dict):
status = result.get("status") or "unknown"
detail = None
try:
from artemis.utils.task_tree import format_result_clean
# format_result_clean reports only errors/repairs; success
# renders as the bare status line.
detail = format_result_clean(result)
except Exception:
detail = None
rendered = f"Status: {status}" + (f"\n{detail}" if detail else "")
else:
try:
rendered = json.dumps(result, ensure_ascii=False, default=str)
except Exception:
rendered = str(result)
if not rendered:
return None
return HumanMessage(
content=[
{
"type": "text",
"text": (
f"{EXECUTION_RESULT_MARKER} ({self.elapsed_label()}) ---\n{rendered}"
),
}
]
)
# ------------------------------------------------------------------
# F region (M3): chunk compression primitives
# ------------------------------------------------------------------
def attach_chunker(self, chunker: Any) -> None:
"""Install the L2/L3 :class:`HistoryChunkManager` consulted at render."""
self._chunker = chunker
@property
def chunker(self) -> Any | None:
return self._chunker
@property
def frozen_blocks(self) -> tuple[BaseMessage, ...]:
return tuple(self._frozen_blocks)
def unchunked_turns(self) -> list[dict]:
"""Committed turns not yet consumed by a compression event (copies)."""
return [dict(t) for t in self._turns[self._chunked_turn_count :]]
def turn_text_chars(self, turns: list[dict]) -> int:
"""Total text characters of the given turns' messages (size trigger)."""
total = 0
for turn in turns:
for msg in self._active[turn["start"] : turn["end"]]:
content = getattr(msg, "content", None)
if isinstance(content, str):
total += len(content)
elif isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
total += len(block.get("text", ""))
return total
def freeze_turns(self, turn_count: int, frozen_blocks: list[BaseMessage]) -> None:
"""Compression event: consume the oldest ``turn_count`` unchunked turns
and replace the frozen chunk blocks wholesale.
The boundary always advances to a committed turn's end, so a tool-call
message and its responses are never split across the F/A boundary.
"""
if turn_count <= 0:
self._frozen_blocks = list(frozen_blocks)
return
available = self._turns[self._chunked_turn_count :]
if turn_count > len(available):
raise ValueError(
f"freeze_turns({turn_count}) exceeds the {len(available)}"
" unchunked committed turns."
)
self._chunked_turn_count += turn_count
self._active_start = self._turns[self._chunked_turn_count - 1]["end"]
self._frozen_blocks = list(frozen_blocks)
# ------------------------------------------------------------------
# Rendering
# ------------------------------------------------------------------
def render(self, tail: list[BaseMessage]) -> list[BaseMessage]:
"""Advance compression and return ``S + F + A + tail``.
Order per turn: the chunker runs first (a triggered compression event
deep-mutates the frozen region and advances the F/A boundary), then
the scrub edge advances over the active window. The returned list is a
fresh container: appending to it (the operator's in-turn tool loop)
never mutates the ledger regions.
"""
if self._chunker is not None:
try:
self._chunker.on_render(self)
except Exception as e:
logger.error(f"History chunker render hook failed: {e}")
self._compressor.compress(self._active)
return [
*self._static,
*self._restored,
*self._frozen_blocks,
*self._active[self._active_start :],
*tail,
]
+14 -2
View File
@@ -56,6 +56,7 @@ from artemis.llm.reliability import (
retry_policy_for,
)
from artemis.llm.router import ModelEndpoint, ModelFactory, ModelProvider
from artemis.services.token_meter import record_llm_usage
from artemis.llm.structured import (
ParseFailure,
StructuredOutputError,
@@ -632,13 +633,22 @@ class RobustChatModelWrapper:
args, kwargs = _inject_parent_trace_id(trace_id, *args, **kwargs)
return args, kwargs, trace_id
def _meter_usage(self, response) -> None:
"""Record measured token usage for this call (M0 metering, best-effort)."""
engine = self.ctx.data_engine if self.ctx else None
if engine is None:
engine = _get_current_data_engine()
record_llm_usage(engine, response, source=self._endpoint_key())
async def ainvoke(self, *args, **kwargs):
args, kwargs, _ = self._traced_call(args, kwargs)
return await _run_with_recovery(
response = await _run_with_recovery(
functools.partial(self.base_model.ainvoke, *args, **kwargs),
provider=self._provider_value(),
endpoint_key=self._endpoint_key(),
)
self._meter_usage(response)
return response
async def complete(self, *args, **kwargs):
"""Single completion entry point: returns the full final message.
@@ -652,11 +662,13 @@ class RobustChatModelWrapper:
"""
args, kwargs, _ = self._traced_call(args, kwargs)
emit_deltas = bool(self.ctx and self.ctx.data_engine)
return await _run_with_recovery(
response = await _run_with_recovery(
functools.partial(self._complete_attempt, emit_deltas, *args, **kwargs),
provider=self._provider_value(),
endpoint_key=self._endpoint_key(),
)
self._meter_usage(response)
return response
def _emit_stream_delta(self, stream_exec_id, chunk) -> None:
content = getattr(chunk, "content", None)
+202
View File
@@ -0,0 +1,202 @@
# 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.
"""Session-level LLM token metering (M0: pure recording, no decisions).
Every gateway-managed LLM call reports its real ``usage_metadata`` here. The
meter accumulates per-session totals and records one ``llm_usage`` trace per
call into the DataEngine, carrying:
- the call's measured ``prompt_tokens`` — the last call's measured input is the
best available estimate of the *current context base* for the next call
(the compaction thresholds of M2/M3 will consume this, never char-count
heuristics);
- provider-reported cache hits (Gemini ``cached_content_token_count`` /
LangChain ``input_token_details.cache_read``) to establish the cache-hit-rate
baseline;
- running per-session totals.
This module never raises into the LLM call path and makes no decisions.
"""
import threading
from typing import Any
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
_LOCK = threading.Lock()
_METERS: dict[str, "SessionTokenMeter"] = {}
def extract_usage(response: Any) -> dict[str, int] | None:
"""Extracts unified token usage from an LLM response message, best-effort.
Prefers LangChain's normalized ``usage_metadata``; falls back to raw
provider shapes in ``response_metadata``. Returns None when the response
carries no usable usage numbers.
"""
usage: dict[str, Any] | None = None
raw = getattr(response, "usage_metadata", None)
if isinstance(raw, dict) and raw:
usage = raw
else:
meta = getattr(response, "response_metadata", None)
if isinstance(meta, dict):
candidate = meta.get("usage_metadata") or meta.get("token_usage")
if isinstance(candidate, dict) and candidate:
usage = candidate
if usage is None:
return None
def _int(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
prompt = _int(
usage.get("input_tokens") or usage.get("prompt_tokens") or usage.get("prompt_token_count")
)
completion = _int(
usage.get("output_tokens")
or usage.get("completion_tokens")
or usage.get("candidates_token_count")
)
total = _int(usage.get("total_tokens") or usage.get("total_token_count")) or (
prompt + completion
)
cached = 0
details = usage.get("input_token_details")
if isinstance(details, dict):
cached = _int(details.get("cache_read"))
if not cached:
cached = _int(
usage.get("cached_content_token_count")
or usage.get("cachedContentTokenCount")
or usage.get("cached_tokens")
)
if total <= 0:
return None
return {
"prompt_tokens": prompt,
"completion_tokens": completion,
"total_tokens": total,
"cached_tokens": cached,
}
class SessionTokenMeter:
"""Accumulates measured LLM usage for one session."""
def __init__(self, session_id: str):
self.session_id = session_id
self._lock = threading.Lock()
self.llm_calls = 0
self.prompt_tokens = 0
self.completion_tokens = 0
self.cached_tokens = 0
self.cache_hit_calls = 0
self.last_prompt_tokens = 0
def record(
self, usage: dict[str, int], *, update_last_prompt: bool = True
) -> dict[str, int]:
"""Accumulates one call's usage; returns a session snapshot.
``update_last_prompt=False`` accumulates the totals without touching
``last_prompt_tokens`` — background lens calls carry tiny prompts that
must not masquerade as the live context base consumed by the L2/L3
compaction thresholds.
"""
with self._lock:
self.llm_calls += 1
self.prompt_tokens += usage.get("prompt_tokens", 0)
self.completion_tokens += usage.get("completion_tokens", 0)
cached = usage.get("cached_tokens", 0)
self.cached_tokens += cached
if cached > 0:
self.cache_hit_calls += 1
if update_last_prompt:
self.last_prompt_tokens = usage.get("prompt_tokens", 0)
return self.snapshot()
def snapshot(self) -> dict[str, int]:
return {
"session_llm_calls": self.llm_calls,
"session_prompt_tokens": self.prompt_tokens,
"session_completion_tokens": self.completion_tokens,
"session_cached_tokens": self.cached_tokens,
"session_cache_hit_calls": self.cache_hit_calls,
}
def get_meter(session_id: Any) -> SessionTokenMeter:
"""Returns (creating on first use) the meter for a session id."""
key = str(session_id)
with _LOCK:
meter = _METERS.get(key)
if meter is None:
meter = SessionTokenMeter(key)
_METERS[key] = meter
return meter
def record_llm_usage(
engine: Any,
response: Any,
*,
source: str | None = None,
update_last_prompt: bool = True,
) -> dict | None:
"""Meters one LLM response and records an ``llm_usage`` trace, best-effort.
``context_base_tokens`` in the payload is this call's measured prompt size —
the running estimate of the live context for threshold decisions in later
milestones. Returns the recorded payload, or None when nothing was
recorded (no engine/session/usage). Never raises.
"""
try:
if engine is None:
return None
session_id = getattr(engine, "current_session_id", None)
usage = extract_usage(response)
if not session_id or usage is None:
return None
snapshot = get_meter(session_id).record(
usage, update_last_prompt=update_last_prompt
)
payload: dict[str, Any] = {
**usage,
"context_base_tokens": usage["prompt_tokens"],
**snapshot,
}
if source:
payload["source"] = source
engine.record_trace(
type="llm_call",
name="llm_usage",
payload=payload,
step_id=getattr(engine, "current_step_id", None),
status="success",
)
return payload
except Exception as meter_err:
logger.debug(f"Token metering skipped: {meter_err}")
return None
+4 -3
View File
@@ -48,8 +48,8 @@ from artemis.tools.types import CyFunctionDetector
from artemis.tools.video_tool import get_video_analyzer_tool
from artemis.utils.logger import get_logger
from artemis.utils.notes import get_note_file_path, get_notes_dir
from artemis.memory.context_policy import build_history_for
from artemis.utils.task_tree import (
build_plan_and_history,
get_active_subgoal_hashes,
get_recent_subgoal_hashes,
)
@@ -186,12 +186,13 @@ async def _execute_committee(
logger.error(f"Failed to parse active subgoal in committee: {e}")
try:
plan_and_history = build_plan_and_history(
plan_and_history = build_history_for(
"committee",
task_plan,
steps,
active_subgoal_hash,
keep_subgoal_hashes=keep_hashes,
last_n_detailed=3,
engine=ctx.data_engine,
)
history_summary += plan_and_history
except Exception as e: # pylint: disable=broad-exception-caught
+564
View File
@@ -0,0 +1,564 @@
# 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.
"""recall_history: deterministic lossless lookup into cold history (M4).
Implements the sister design's §10 as adopted by the history-module redesign
§3.5: one unified tool over the DataEngine's raw records — step summaries,
history chunks (bands ①②③), exact actions and validator results, operator
thinking, notes, XML/OCR text (which also carries package/activity strings),
Failure-Analyzer traces, and image references. Phase one is keyword + step
range filtering over SQLite-backed records in Python — no vector database, no
model call.
Hard boundaries (config ``agent.memory.recall``):
- at most ``max_results`` results (default 5);
- one response is capped at ``max_text_tokens`` estimated tokens (char/4);
- every result carries a step number / step id;
- ``include_details`` output is clamped per field;
- ``include_images`` returns only the screenshots that actually exist on disk
for a single step (data URLs), never more than ``max_image_steps`` steps;
- large raw sources return only excerpts around the match plus a reference.
An era whose ledger overflowed to a ``[Era N | Steps a–b: ledger via
recall_history]`` marker line is re-entered here: recalling that step range
returns the full-width ``build_action_ledger`` rows for it.
"""
import base64
import json
from typing import Any
from pydantic import BaseModel, Field
from artemis.data_engine.trace import trace_langchain_tool
from artemis.tools.base import ArtemisTool, ToolRegistry
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
#: How many most-recent steps get their screenshot XML/OCR blobs scanned
#: (per-image JSON loads are the expensive part of the sweep).
XML_SCAN_CAP = 150
#: Character clamp for one matched excerpt.
EXCERPT_CHARS = 240
DETAIL_EXCERPT_CHARS = 600
class RecallHistoryArgs(BaseModel):
"""Arguments schema for the recall_history tool."""
query: str = Field(
...,
description=(
"Keywords to search for across the whole execution history"
" (summaries, actions, validator results, reasoning, notes,"
" on-screen XML/OCR text, package/activity names). Case-"
"insensitive; every whitespace-separated term is matched"
" independently. May be empty when step_range is given (returns"
" the per-step action ledger of that range)."
),
)
step_range: list[int] | None = Field(
default=None,
description=(
"Optional [start, end] step-number range (inclusive) to restrict"
" the search — e.g. the range shown in a '[... ledger via"
" recall_history]' marker line. Also returns the full per-step"
" action ledger of the range."
),
)
include_details: bool = Field(
default=False,
description=(
"Include clamped per-result details (exact action, validator"
" result, reasoning excerpt) instead of the one-line form."
),
)
include_images: bool = Field(
default=False,
description=(
"Return the stored screenshots of the single best-matching step"
" (only images that actually exist; at most one step)."
),
)
max_results: int = Field(
default=5,
ge=1,
description="Maximum results to return (server-side cap still applies).",
)
def _terms(query: str) -> list[str]:
return [t for t in (query or "").lower().split() if t]
def _estimate_tokens(text: str) -> int:
return len(text) // 4
def _clamp(text: str, limit: int) -> str:
text = (text or "").strip()
if len(text) <= limit:
return text
return text[: limit - 1].rstrip() + "…"
def _excerpt(text: str, terms: list[str], limit: int) -> str:
"""A window of ``text`` around the first term hit (flattened whitespace)."""
flat = " ".join((text or "").split())
lower = flat.lower()
pos = -1
for term in terms:
pos = lower.find(term)
if pos >= 0:
break
if pos < 0:
return _clamp(flat, limit)
start = max(0, pos - limit // 3)
window = flat[start : start + limit]
prefix = "…" if start > 0 else ""
suffix = "…" if start + limit < len(flat) else ""
return f"{prefix}{window.strip()}{suffix}"
def _score(haystack: str, terms: list[str]) -> int:
lower = haystack.lower()
return sum(1 for term in terms if term in lower)
def _events_text(step: dict) -> str:
parts: list[str] = []
for event in step.get("interleaved_events") or []:
etype = event.get("type")
if etype == "tool_call":
parts.append(str(event.get("name") or ""))
parts.append(json.dumps(event.get("args") or {}, ensure_ascii=False, default=str))
result = event.get("result")
if result is not None:
parts.append(str(result)[:2000])
elif etype and "thought" in etype:
parts.append(str(event.get("content") or ""))
return "\n".join(parts)
def _step_haystack(step: dict) -> str:
fields = [
str(step.get("summary") or ""),
json.dumps(step.get("action_taken"), ensure_ascii=False, default=str)
if step.get("action_taken")
else "",
json.dumps(step.get("last_execution_result"), ensure_ascii=False, default=str)
if step.get("last_execution_result")
else "",
str(step.get("operator_raw_thinking") or ""),
_events_text(step),
str((step.get("extra_metadata") or {}).get("injected_instruction") or ""),
# M5: the foreground app package stamped by record_step joins the
# search surface, so package-name queries no longer depend on the
# XML-blob scan (capped to recent steps) alone.
str((step.get("extra_metadata") or {}).get("foreground_app") or ""),
]
return "\n".join(f for f in fields if f)
def _screen_text(engine: Any, step: dict) -> str:
"""Raw XML/OCR text of a step's pre-action screenshot (package/activity
strings live inside the XML blob)."""
image_name = step.get("pre_image_name") or step.get("post_image_name")
if not image_name:
return ""
try:
record = engine.storage.get_image(image_name)
except Exception:
return ""
if record is None:
return ""
parts = []
for blob in (record.ui_tree, record.ocr_result):
if blob:
parts.append(blob if isinstance(blob, str) else json.dumps(blob, ensure_ascii=False, default=str))
return "\n".join(parts)
def _session_start(engine: Any) -> float | None:
return getattr(engine, "session_start_time", None)
def _step_label(step: dict, session_start: float | None) -> str:
from artemis.memory.chunking import step_offset_label
return f"Step {step.get('step_number')} ({step_offset_label(step, session_start)})"
def _note_writer_steps(steps: list[dict], key: str) -> list[int]:
writers: list[int] = []
for step in steps:
for event in step.get("interleaved_events") or []:
if (
event.get("type") == "tool_call"
and event.get("name") in ("save_note", "update_note", "append_note")
and (event.get("args") or {}).get("key") == key
):
number = step.get("step_number")
if isinstance(number, int):
writers.append(number)
return writers
def search_history(
engine: Any,
*,
query: str,
step_range: list[int] | None = None,
include_details: bool = False,
include_images: bool = False,
max_results: int = 5,
recall_config: Any = None,
) -> str | list[dict]:
"""Deterministic history search; returns text or content blocks (images)."""
cfg_max_results = int(getattr(recall_config, "max_results", 5) or 5)
cfg_max_tokens = int(getattr(recall_config, "max_text_tokens", 2000) or 2000)
cfg_max_image_steps = int(getattr(recall_config, "max_image_steps", 1))
max_results = max(1, min(int(max_results or cfg_max_results), cfg_max_results))
char_budget = cfg_max_tokens * 4
try:
steps: list[dict] = engine.get_agent_friendly_steps() or []
except Exception as e:
return f"recall_history failed to load history: {e}"
range_start = range_end = None
if step_range:
try:
range_start, range_end = int(step_range[0]), int(step_range[-1])
if range_start > range_end:
range_start, range_end = range_end, range_start
except (TypeError, ValueError, IndexError):
range_start = range_end = None
def in_range(number: Any) -> bool:
if range_start is None:
return True
return isinstance(number, int) and range_start <= number <= range_end
scoped_steps = [s for s in steps if in_range(s.get("step_number"))]
terms = _terms(query)
session_start = _session_start(engine)
if not steps:
return "recall_history: no recorded steps yet."
if step_range and not scoped_steps:
return (
f"recall_history: no recorded steps in range"
f" {range_start}–{range_end}."
)
sections: list[str] = []
# --- Range ledger: the explicit re-entry point for 'ledger via
# recall_history' guidance lines and recall-only era period paragraphs —
# full-width build_action_ledger rows.
if range_start is not None and scoped_steps:
from artemis.memory.chunking import build_action_ledger
ledger = build_action_ledger(scoped_steps, session_start)
sections.append(
f"Action ledger for Steps {range_start}–{range_end}"
f" ({len(scoped_steps)} recorded steps):\n{ledger}"
)
# --- Keyword search over steps.
results: list[tuple[int, int, str]] = [] # (score, step_number, rendered)
if terms:
xml_eligible = {
id(s) for s in scoped_steps[-XML_SCAN_CAP:]
}
for step in scoped_steps:
haystack = _step_haystack(step)
screen = _screen_text(engine, step) if id(step) in xml_eligible else ""
score = _score(haystack, terms)
screen_score = _score(screen, terms) if screen else 0
total = score + screen_score
if total <= 0:
continue
excerpt_limit = DETAIL_EXCERPT_CHARS if include_details else EXCERPT_CHARS
source = haystack if score >= screen_score else screen
body = _excerpt(source, terms, excerpt_limit)
lines = [f"[{_step_label(step, session_start)} | id {step.get('step_id')}]"]
summary = step.get("summary")
if summary:
lines.append(f" Summary: {_clamp(str(summary), EXCERPT_CHARS)}")
lines.append(f" Match: {body}")
if include_details:
action = step.get("action_taken")
if action:
lines.append(
" Action: "
+ _clamp(json.dumps(action, ensure_ascii=False, default=str), DETAIL_EXCERPT_CHARS)
)
result = step.get("last_execution_result")
if result:
lines.append(
" Result: "
+ _clamp(json.dumps(result, ensure_ascii=False, default=str), DETAIL_EXCERPT_CHARS)
)
thinking = step.get("operator_raw_thinking")
if thinking:
lines.append(f" Reasoning: {_clamp(str(thinking), DETAIL_EXCERPT_CHARS)}")
number = step.get("step_number") or 0
results.append((total, number, "\n".join(lines)))
# --- History chunks (bands ①②③ / rendered text).
try:
chunk_rows = engine.get_history_chunks() or []
except Exception:
chunk_rows = []
for row in chunk_rows:
start_n = getattr(row, "start_step_number", None)
end_n = getattr(row, "end_step_number", None)
if range_start is not None and not (
isinstance(start_n, int)
and isinstance(end_n, int)
and start_n <= range_end
and end_n >= range_start
):
continue
text = getattr(row, "rendered_text", None) or "\n".join(
filter(None, [getattr(row, "band2", None), getattr(row, "band3", None)])
)
score = _score(text or "", terms)
if score <= 0:
continue
body = _excerpt(text, terms, DETAIL_EXCERPT_CHARS if include_details else EXCERPT_CHARS)
rendered = (
f"[History chunk | Steps {start_n}–{end_n} | status"
f" {getattr(row, 'status', '?')}]\n Match: {body}"
)
results.append((score, int(end_n or 0), rendered))
# --- Notes (filesystem; attributed to their writing steps).
try:
from artemis.utils.notes import list_notes_keys, read_note_content
base_dir = getattr(engine, "base_dir", None)
note_keys = list_notes_keys(base_dir) if base_dir else []
except Exception:
note_keys = []
for key in note_keys:
try:
content = read_note_content(base_dir, key)
except Exception:
continue
score = _score(content, terms) + _score(key, terms)
if score <= 0:
continue
writers = _note_writer_steps(steps, key)
if range_start is not None and writers and not any(
range_start <= n <= range_end for n in writers
):
continue
anchor = writers[-1] if writers else (steps[-1].get("step_number") or 0)
origin = (
f"last written at Step {writers[-1]}"
if writers
else f"as of Step {anchor}"
)
body = _excerpt(content, terms, DETAIL_EXCERPT_CHARS if include_details else EXCERPT_CHARS)
results.append(
(score, int(anchor), f"[Note '{key}' | {origin}]\n Match: {body}")
)
results.sort(key=lambda r: (-r[0], -r[1]))
kept = results[:max_results]
dropped = len(results) - len(kept)
if kept:
header = f"Top {len(kept)} match(es) for {terms}:"
if dropped > 0:
header += f" ({dropped} more not shown; narrow the query or step_range)"
sections.append(header + "\n\n" + "\n".join(r[2] for r in kept))
elif terms:
sections.append(f"No matches for {terms} in the searched history.")
text = "\n\n".join(sections)
if _estimate_tokens(text) > cfg_max_tokens:
text = (
text[:char_budget].rstrip()
+ "\n… (response truncated at the recall token budget; narrow the"
" query or step_range)"
)
if not include_images or cfg_max_image_steps < 1:
return text
# --- Image recall: real stored screenshots of ONE step only.
target_step: dict | None = None
if kept:
# The best step-shaped result; fall back to the newest scoped step.
best_numbers = [r[1] for r in kept]
for number in best_numbers:
target_step = next(
(s for s in scoped_steps if s.get("step_number") == number), None
)
if target_step:
break
if target_step is None and scoped_steps:
target_step = scoped_steps[-1]
if target_step is None:
return text
blocks: list[dict] = [{"type": "text", "text": text}]
added = 0
for which, name in (
("pre-action", target_step.get("pre_image_name")),
("post-action", target_step.get("post_image_name")),
):
if not name:
continue
try:
path = engine.get_image_path(name)
if not path or not path.exists():
continue
encoded = base64.b64encode(path.read_bytes()).decode("utf-8")
except Exception:
continue
blocks.append(
{
"type": "text",
"text": (
f"--- {which} screenshot of Step"
f" {target_step.get('step_number')} ---"
),
}
)
blocks.append(
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
}
)
added += 1
if added == 0:
blocks.append(
{
"type": "text",
"text": (
f"(no stored screenshots exist for Step"
f" {target_step.get('step_number')})"
),
}
)
return blocks
RECALL_HISTORY_DOCSTRING = (
"[SHELL] Deterministic lookup into older execution history that has been"
" compressed out of the visible context. Searches step summaries, exact"
" actions and validator results, reasoning, notes, on-screen XML/OCR text"
" and history-chunk ledgers by keywords and/or a step-number range;"
" a step_range also returns that range's full per-step action ledger"
" (the entry point for 'ledger via recall_history' guidance/marker lines"
" and for recall-only '[Era ...]' period paragraphs)."
" Results are bounded and every result carries its step number."
" Do not call it speculatively on ordinary steps."
)
class RecallHistoryTool(ArtemisTool):
"""Unified cold-history recall over the DataEngine (design §10)."""
def __init__(self):
super().__init__(
name="recall_history",
description=RECALL_HISTORY_DOCSTRING,
args_schema=RecallHistoryArgs,
category="memory",
)
def is_available(self, ctx: Any = None) -> bool:
if ctx is None or getattr(ctx, "data_engine", None) is None:
return False
return bool(getattr(_recall_config(warn=False), "enabled", True))
# pylint: disable=too-many-arguments
async def execute(
self,
driver: Any = None, # pylint: disable=unused-argument
ctx: Any = None,
query: str = "",
step_range: list[int] | None = None,
include_details: bool = False,
include_images: bool = False,
max_results: int = 5,
**kwargs: Any,
) -> Any:
engine = getattr(ctx, "data_engine", None) if ctx else None
if engine is None:
return "recall_history unavailable: no active DataEngine session."
if not query and not step_range:
return (
"recall_history needs a query and/or a step_range — e.g."
' recall_history(query="login timeout") or'
" recall_history(query=\"\", step_range=[1, 40])."
)
try:
return search_history(
engine,
query=query or "",
step_range=step_range,
include_details=bool(include_details),
include_images=bool(include_images),
max_results=int(max_results or 5),
recall_config=_recall_config(),
)
except Exception as e:
logger.error(f"recall_history failed: {e}")
return f"recall_history failed: {e}"
def _recall_config(warn: bool = True) -> Any:
try:
from artemis.config import load_agent_config
return load_agent_config().memory.recall
except Exception as e:
if warn:
logger.debug(f"recall config unavailable, using defaults: {e}")
return None
recall_history = RecallHistoryTool()
ToolRegistry.register(recall_history)
def get_recall_history_tool(ctx: Any):
"""Exports recall_history as a traced LangChain BaseTool."""
return trace_langchain_tool(recall_history.to_langchain_tool(ctx), ctx)
def _recall_available(ctx: Any) -> bool:
return recall_history.is_available(ctx)
recall_history_wrapper = ToolWrapper(
tool_fn_getter=get_recall_history_tool,
on_success_fn=lambda *a, **k: "Recalled history",
on_failure_fn=lambda err: f"recall_history failed: {err}",
is_available_fn=_recall_available,
)
+123
View File
@@ -0,0 +1,123 @@
# 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.
"""Record-time action semantics enrichment via element hit testing.
An action addressed by bare coordinates carries no element semantics
("tap(632,1180)" says nothing about what was tapped). Before such an action is
persisted to the DataEngine, these helpers resolve the smallest indexed element
covering the tap point on the pre-action frame and attach best-effort
``target_text`` / ``target_class`` / ``target_resource_id`` fields, plus a
``target_label_source`` marker distinguishing model-named targets from
after-the-fact inference:
- ``"index"``: the model addressed the element by its perception index — the
semantics come straight from the indexed element (highest confidence).
- ``"hit_test"``: inferred by hit testing the coordinates against XML-derived
indexed elements on the pre-action frame.
- ``"ocr"``: inferred by hit testing against OCR-derived indexed elements
(used only when no XML-derived element covers the point).
- ``"none"``: no element covers the point (or no perception data was
available) — the action stays a bare-coordinate action.
Enrichment is strictly best-effort: missing/malformed element data degrades to
``"none"`` and never raises.
"""
from typing import Any
from artemis.utils.visualization import parse_bounds
_LABEL_SOURCE_KEY = "target_label_source"
def _element_bounds(element: dict[str, Any]) -> tuple[int, int, int, int] | None:
"""Returns (left, top, right, bottom) pixel bounds of an indexed element.
Accepts both list bounds (``[l, t, r, b]``, the shape produced by
``format_minimal_list_with_elements``) and Android bounds strings
(``"[l,t][r,b]"``). Returns None for missing or malformed bounds.
"""
bounds = element.get("bounds")
if isinstance(bounds, str):
return parse_bounds(bounds)
if isinstance(bounds, (list, tuple)) and len(bounds) == 4:
try:
left, top, right, bottom = (int(v) for v in bounds)
except (TypeError, ValueError):
return None
return left, top, right, bottom
return None
def find_element_at_point(
elements: list[dict[str, Any]] | None, x: int, y: int
) -> tuple[dict[str, Any] | None, str]:
"""Finds the smallest indexed element whose bounds contain pixel point (x, y).
XML-derived elements are preferred over OCR-derived ones (OCR boxes carry
text but weaker structure); within each group the smallest covering area
wins. Returns ``(element, source)`` where source is ``"hit_test"``,
``"ocr"``, or ``"none"`` (with element None).
"""
best_xml: tuple[int, dict[str, Any]] | None = None
best_ocr: tuple[int, dict[str, Any]] | None = None
for element in elements or []:
if not isinstance(element, dict):
continue
rect = _element_bounds(element)
if rect is None:
continue
left, top, right, bottom = rect
if not (left <= x <= right and top <= y <= bottom):
continue
area = max(1, (right - left)) * max(1, (bottom - top))
if element.get("is_ocr"):
if best_ocr is None or area < best_ocr[0]:
best_ocr = (area, element)
else:
if best_xml is None or area < best_xml[0]:
best_xml = (area, element)
if best_xml is not None:
return best_xml[1], "hit_test"
if best_ocr is not None:
return best_ocr[1], "ocr"
return None, "none"
def hit_test_semantics(
elements: list[dict[str, Any]] | None, x: int, y: int
) -> dict[str, Any]:
"""Best-effort semantic fields for a bare-coordinate action at pixel (x, y).
Always returns a dict containing ``target_label_source``; the
``target_text`` / ``target_class`` / ``target_resource_id`` fields are only
present when the covering element carries them.
"""
try:
element, source = find_element_at_point(elements, int(x), int(y))
except Exception:
return {_LABEL_SOURCE_KEY: "none"}
semantics: dict[str, Any] = {_LABEL_SOURCE_KEY: source}
if element is None:
return semantics
if element.get("text"):
semantics["target_text"] = element["text"]
if element.get("class"):
semantics["target_class"] = element["class"]
if element.get("resource_id"):
semantics["target_resource_id"] = element["resource_id"]
return semantics
+81
View File
@@ -0,0 +1,81 @@
# 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.
"""Pure-PIL perceptual hashing (dHash) for screen-similarity hints (M4).
A 64-bit difference hash: grayscale, resize to 9x8, compare each pixel to its
right neighbor. Robust to JPEG re-encoding and mild rendering noise, cheap to
compute (one small resize), and comparable with an integer Hamming distance —
no model call, no historical image bytes needed at compare time.
Hashes are stored per step in ``extra_metadata`` at record time
(``pre_image_dhash`` / ``post_image_dhash``, 16 hex chars) and compared
against the current screenshot before prompt construction.
"""
import io
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
#: Grid width of the dHash (the hash carries HASH_SIZE * HASH_SIZE bits).
HASH_SIZE = 8
def dhash_hex(image_bytes: bytes | None, hash_size: int = HASH_SIZE) -> str | None:
"""64-bit dHash of an encoded image, as a 16-char hex string.
Best-effort: returns ``None`` on any failure (missing/corrupt bytes,
Pillow unavailable) — perceptual hashing is an enhancement, never a
dependency.
"""
if not image_bytes:
return None
try:
from PIL import Image
with Image.open(io.BytesIO(image_bytes)) as img:
# JPEG fast-path: DCT-scaled draft decode keeps the sync cost of
# hashing at record time to a few milliseconds. The draft is only
# an intermediate resolution; the final resize below fixes the
# grid, so hashes stay comparable across code paths.
try:
img.draft("L", (hash_size * 8, hash_size * 8))
except Exception:
pass
small = img.convert("L").resize(
(hash_size + 1, hash_size), Image.Resampling.LANCZOS
)
pixels = list(small.getdata())
bits = 0
for row in range(hash_size):
for col in range(hash_size):
left = pixels[row * (hash_size + 1) + col]
right = pixels[row * (hash_size + 1) + col + 1]
bits = (bits << 1) | (1 if left > right else 0)
return f"{bits:0{hash_size * hash_size // 4}x}"
except Exception as e:
logger.debug(f"dhash_hex failed: {e}")
return None
def hamming_distance_hex(hash_a: str | None, hash_b: str | None) -> int | None:
"""Bit-level Hamming distance between two hex hashes; None if incomparable."""
if not hash_a or not hash_b or len(hash_a) != len(hash_b):
return None
try:
return bin(int(hash_a, 16) ^ int(hash_b, 16)).count("1")
except ValueError:
return None
+38 -3
View File
@@ -801,14 +801,22 @@ def build_plan_and_history(
keep_subgoal_hashes: set | None = None,
min_summaries: int = 5,
last_n_detailed: int = 1,
detailed_subgoal_hashes: set | None = None,
all_detailed: bool = False,
strict_milestone_pruning: bool = False,
recent_window_size: int = 3,
chronological_last_step: bool = False,
for_failure_analyzer: bool = False,
chunks: list | None = None,
) -> str:
"""Builds a clean plan list and separate flat chronological execution history with adaptive compression."""
"""Builds a clean plan list and separate flat chronological execution history with adaptive compression.
``chunks`` (optional, M4): pre-rendered history-chunk blocks — each a dict
with ``start_step_number``/``end_step_number``/``text``. When provided,
the chunk blocks are emitted right after the history header and the
per-step lines inside any chunked range are dropped (the chunk block
replaces them), except steps that must render detailed (the recent
window / the most-recent step stay untouched). When ``chunks`` is
``None`` or empty the output is byte-identical to the pre-M4 rendering.
"""
# 1. Plan Checklist Section
output_parts = []
@@ -900,6 +908,26 @@ def build_plan_and_history(
# Ensure the last step is always in detailed_step_ids
detailed_step_ids.add(full_info_step_id)
# M4: chunk blocks replace the per-step lines of already-chunked
# ranges. Chunks only ever cover frozen (old) turns, so they are
# emitted first, in chronological order; steps that must render
# detailed (recent window / most-recent step) are never suppressed.
chunked_ranges: list[tuple[int, int]] = []
if chunks:
for chunk in sorted(
chunks, key=lambda c: c.get("start_step_number") or 0
):
text = chunk.get("text")
if not text:
continue
try:
start_n = int(chunk.get("start_step_number"))
end_n = int(chunk.get("end_step_number"))
except (TypeError, ValueError):
continue
chunked_ranges.append((start_n, end_n))
output_parts.append(text)
for step in steps:
step_id = step["step_id"]
@@ -907,6 +935,13 @@ def build_plan_and_history(
if step_id not in visible_step_ids:
continue
if chunked_ranges and step_id not in detailed_step_ids and step_id != full_info_step_id:
step_number = step.get("step_number")
if isinstance(step_number, int) and any(
start_n <= step_number <= end_n for start_n, end_n in chunked_ranges
):
continue # represented by its chunk block above
relative_time = step.get("relative_time") or "N/A"
summary = step.get("summary")
action = step.get("action_taken")
+44
View File
@@ -190,6 +190,50 @@
// ==========================================================================
// 🌐 4.3 Shared Capabilities & Global Overrides
// ==========================================================================
// 🧠 Unified Step-Memory Runtime (history-module redesign v3, M0–M5)
// Transcript ledger + layered compression for the Pro operator.
"memory": {
// Background summarization runtime (visual/capsule lenses)
"runtime": {
"max_concurrency": 2, // 1→2 per 2026-09-01 baseline: serial queue pushed summary-ready P90 past the scrub window
"retry_limit": 3,
"flush_timeout_s": 30
},
"transcript": {
// ROLLBACK SWITCH: set false to restore the legacy per-turn 2-message
// prompt path byte-for-byte (L2/L3 chunk compression rides this same
// flag). Default on since M5 (2026-09-01) per the on-device A/B:
// turn latency −77% P50, cache-hit calls 43%→88%, output tokens −74%.
"enabled": true,
"context_budget_tokens": 80000,
"soft_ratio": 0.7,
"hard_ratio": 0.9,
"min_active_steps": 5,
"image_scrub_depth": 3,
"pending_grace_steps": 3,
"xml_scrub_depth": 1,
// Local historical-state hint (no model call); distance calibrated
// 2026-09-01 from 460 dHash-stamped on-device steps (same screen <=4,
// different screens >=7).
"similarity_hint": true,
"similarity_max_distance": 5
},
// Segment (L2) chunk compression: three-band chunks, era folding
"chunking": {
"max_steps": 12,
"target_source_tokens": 2000,
"model": "gemini-3.7-flash",
"max_chunks": 8
// "max_eras": 8 // independent era cap; omitted = follows max_chunks
},
// Deterministic cold-history recall tool (Pro operator)
"recall": {
"enabled": true,
"max_results": 5,
"max_text_tokens": 2000,
"max_image_steps": 1
}
},
// 🎬 Screen Recording & Video Analyzer Configuration
"video_analyzer": {
"enabled": true, // Whether continuous screen recording via scrcpy is active (default: auto-detected)
+325
View File
@@ -0,0 +1,325 @@
# 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.
"""Unit tests for the Flash scrub-edge compressor (history redesign §3.2, M1).
Covers the M1 acceptance items: the legacy-equivalent success-path product
(asserted directly since the legacy ``compress_flash_messages`` reference was
removed in M5), the three race regressions (grace recovery, grace exhaustion
without backfill, failed-summary placeholder), the freeze invariant (bytes
behind the scrub edge never change), and depth-1 XML stripping equivalence.
"""
import json
from unittest.mock import Mock
import pytest
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from artemis.agents.flash.context_compressor import ScrubEdgeCompressor
from artemis.agents.flash.summarizer import VisualStepSummarizer
from artemis.context import ArtemisContext
@pytest.fixture
def mock_context():
ctx = Mock(spec=ArtemisContext)
ctx.data_engine = None
return ctx
def _initial_observation():
return HumanMessage(
content=[
{"type": "text", "text": "Task: Search flights"},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_0"}},
{"type": "text", "text": "--- UI Element List ---\nTree 0"},
]
)
def _step(i: int) -> ToolMessage:
return ToolMessage(
tool_call_id=f"tc{i}",
name="click",
content=[
{"type": "text", "text": f"Action {i} completed."},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,IMG_{i}"}},
{"type": "text", "text": f"--- UI Element List ---\nTree {i}"},
],
)
def _simulate_turns(compressor, messages, new_messages):
"""Mimic FlashRunner's ordering: compress at turn start, then append."""
for msg in new_messages:
compressor.compress(messages)
messages.append(msg)
compressor.compress(messages)
def _content_fingerprint(msg) -> str:
return json.dumps(msg.content, sort_keys=True, default=str)
def _has_image(msg) -> bool:
return any(
isinstance(b, dict) and b.get("type") in ("image_url", "image") for b in msg.content
)
def test_success_path_product_behind_the_scrub_edge(mock_context):
"""With summaries ready in time, every message behind the scrub edge is the
legacy-shaped product: action text kept, XML stripped, screenshot replaced
by the ``--- Historical Visual Transition ---`` summary block.
(Asserted directly; the legacy ``compress_flash_messages`` reference this
was originally diffed against was removed in M5.) Intended M1 timing
change: the swap happens at depth K, so the newest K-1 historical steps
still carry their screenshots.
"""
summarizer = VisualStepSummarizer(mock_context)
for i in range(1, 7):
summarizer._summaries[f"tc{i}"] = f"Visual summary {i}."
new_messages: list = []
compressor = ScrubEdgeCompressor(
summarizer=summarizer,
prune_history_xml=True,
image_scrub_depth=3,
pending_grace_steps=3,
)
_simulate_turns(
compressor, new_messages, [_initial_observation()] + [_step(i) for i in range(1, 7)]
)
# Initial observation behind the edge: image silently dropped (no summary
# job), task text kept, XML stripped.
assert new_messages[0].content == [{"type": "text", "text": "Task: Search flights"}]
# Steps 1-4 behind the scrub edge: exact legacy-shaped swap product.
for i in range(1, 5):
assert new_messages[i].content == [
{"type": "text", "text": f"Action {i} completed."},
{
"type": "text",
"text": f"--- Historical Visual Transition ---\nVisual summary {i}.",
},
], f"message {i} diverged from the legacy-shaped product"
# The live observation is intact.
assert _has_image(new_messages[6])
assert "Tree 6" in str(new_messages[6].content)
# Intended timing change: step 5 (depth 2 < K) keeps its screenshot under
# the scrub edge; its XML was still stripped at depth 1.
assert _has_image(new_messages[5])
assert "Visual summary 5." not in str(new_messages[5].content)
assert "Tree 5" not in str(new_messages[5].content)
def test_race_pending_at_edge_recovers_within_grace(mock_context):
"""A step whose summary is late keeps its image at the scrub edge and is
swapped to the summary once it arrives within the grace window."""
summarizer = VisualStepSummarizer(mock_context)
summarizer._step_inputs["tc1"] = {"step_number": 1} # job pending
messages: list = []
compressor = ScrubEdgeCompressor(
summarizer=summarizer,
image_scrub_depth=3,
pending_grace_steps=3,
)
_simulate_turns(
compressor, messages, [_initial_observation()] + [_step(i) for i in range(1, 4)]
)
# Step 1 sits at the scrub edge with a pending summary: image retained.
assert _has_image(messages[1])
# Summary arrives within grace; the next pass applies it.
summarizer._summaries["tc1"] = "Recovered visual summary 1."
_simulate_turns(compressor, messages, [_step(4)])
assert not _has_image(messages[1])
assert {
"type": "text",
"text": "--- Historical Visual Transition ---\nRecovered visual summary 1.",
} in messages[1].content
def test_race_grace_exhausted_placeholder_never_backfilled(mock_context):
"""After the grace window a pending step becomes a placeholder, and a late
summary never mutates the frozen message again."""
summarizer = VisualStepSummarizer(mock_context)
summarizer._step_inputs["tc1"] = {"step_number": 1} # pending forever (for now)
messages: list = []
compressor = ScrubEdgeCompressor(
summarizer=summarizer,
image_scrub_depth=2,
pending_grace_steps=1,
)
_simulate_turns(compressor, messages, [_step(i) for i in range(1, 5)])
# Depth 4 > K + grace = 3: grace exhausted, placeholder applied.
step1_text = str(messages[0].content)
assert not _has_image(messages[0])
assert "[visual summary pending; evidence at DataEngine step 1]" in step1_text
frozen_fingerprint = _content_fingerprint(messages[0])
# The summary arriving later must NOT backfill the frozen message.
summarizer._summaries["tc1"] = "Too late summary."
_simulate_turns(compressor, messages, [_step(5)])
compressor.compress(messages)
assert _content_fingerprint(messages[0]) == frozen_fingerprint
assert "Too late summary." not in str(messages[0].content)
def test_race_failed_summary_becomes_unavailable_placeholder(mock_context):
"""A failed summary is replaced by an unavailable placeholder at the scrub
edge without waiting out the grace window."""
summarizer = VisualStepSummarizer(mock_context)
summarizer._step_inputs["tc1"] = {"step_number": 1}
summarizer._failed.add("tc1")
messages: list = []
compressor = ScrubEdgeCompressor(
summarizer=summarizer,
image_scrub_depth=2,
pending_grace_steps=5,
)
_simulate_turns(compressor, messages, [_step(1), _step(2), _step(3)])
assert not _has_image(messages[0])
assert "[visual summary unavailable; evidence at DataEngine step 1]" in str(
messages[0].content
)
def test_freeze_invariant_bytes_never_change(mock_context):
"""Once a message crosses the scrub edge its content bytes never change,
across many turns and late-arriving summaries."""
summarizer = VisualStepSummarizer(mock_context)
# Odd steps have summaries ready from the start; even steps stay pending.
for i in range(1, 11):
summarizer._step_inputs[f"tc{i}"] = {"step_number": i}
if i % 2 == 1:
summarizer._summaries[f"tc{i}"] = f"Ready summary {i}."
messages: list = []
compressor = ScrubEdgeCompressor(
summarizer=summarizer,
image_scrub_depth=2,
pending_grace_steps=1,
)
frozen_fingerprints: dict[int, str] = {}
def check_frozen():
for idx in compressor._frozen:
fingerprint = _content_fingerprint(messages[idx])
if idx in frozen_fingerprints:
assert frozen_fingerprints[idx] == fingerprint, (
f"frozen message {idx} mutated"
)
else:
frozen_fingerprints[idx] = fingerprint
for i in range(1, 11):
compressor.compress(messages)
check_frozen()
messages.append(AIMessage(content=f"Thinking about step {i}."))
messages.append(_step(i))
if i == 6:
# Late summaries for already-frozen even steps: must be ignored.
summarizer._summaries["tc2"] = "Late summary 2."
summarizer._summaries["tc4"] = "Late summary 4."
compressor.compress(messages)
check_frozen()
assert len(frozen_fingerprints) >= 6
# A frozen pending step carries the placeholder, not the late summary.
assert "Late summary 2." not in " ".join(str(m.content) for m in messages)
def test_xml_depth1_strip_matches_legacy_semantics(mock_context):
"""Depth-1 XML stripping keeps combined-block prefixes and the live list."""
messages: list = []
compressor = ScrubEdgeCompressor(summarizer=None, prune_history_xml=True)
combined = ToolMessage(
tool_call_id="tc1",
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"}},
],
)
_simulate_turns(compressor, messages, [combined, _step(2)])
# Historical combined block: prefix retained, tree stripped (the exact
# legacy ``compress_flash_messages`` product for this input).
assert messages[0].content[0] == {"type": "text", "text": "Tapped Settings."}
# Live observation keeps its UI list untouched.
assert "Tree 2" in str(messages[1].content)
def test_prune_history_xml_disabled_keeps_ui_lists(mock_context):
"""With the switch off, UI lists survive both depth-1 and freeze scrubs."""
summarizer = VisualStepSummarizer(mock_context)
summarizer._summaries["tc1"] = "Summary 1."
messages: list = []
compressor = ScrubEdgeCompressor(
summarizer=summarizer,
prune_history_xml=False,
image_scrub_depth=2,
pending_grace_steps=0,
)
_simulate_turns(compressor, messages, [_step(1), _step(2), _step(3)])
# Step 1 froze (summary applied) but its UI list is preserved.
assert not _has_image(messages[0])
assert "Tree 1" in str(messages[0].content)
assert "Summary 1." in str(messages[0].content)
def test_untracked_messages_are_never_touched(mock_context):
"""Injected instructions and AI turns pass through untouched; a frozen
image-only tool message falls back to non-empty content."""
messages: list = [
HumanMessage(content="[REAL-TIME INJECTED INSTRUCTION from user]: stay put"),
]
compressor = ScrubEdgeCompressor(summarizer=None, image_scrub_depth=2)
image_only = ToolMessage(
tool_call_id="tc1",
name="click",
content=[{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,A"}}],
)
_simulate_turns(
compressor,
messages,
[image_only, AIMessage(content="thinking"), _step(2), _step(3)],
)
assert messages[0].content == "[REAL-TIME INJECTED INSTRUCTION from user]: stay put"
assert messages[2].content == "thinking"
# Image-only tool message froze with the defensive fallback text.
assert messages[1].content == [{"type": "text", "text": "Action completed."}]
+333 -120
View File
@@ -14,12 +14,12 @@
"""Unit tests for VisualStepSummarizer and Context Compressor in Flash profile."""
from unittest.mock import AsyncMock, Mock, call
from unittest.mock import ANY, AsyncMock, Mock, call
from uuid import uuid4
import pytest
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from artemis.agents.flash.context_compressor import compress_flash_messages
from artemis.agents.flash.context_compressor import ScrubEdgeCompressor
from artemis.agents.flash.summarizer import VisualStepSummarizer
from artemis.context import ArtemisContext
from artemis.sdk.builders import Builders
@@ -100,8 +100,94 @@ async def test_summarizer_retries_independently(mock_context):
assert attempt_count == 2
@pytest.mark.asyncio
async def test_summarizer_bounded_retry_marks_failed(mock_context):
"""Persistent failures exit after 1 + retry_limit attempts with an explicit failed state."""
mock_context.data_engine = Mock()
summarizer = VisualStepSummarizer(mock_context, retry_limit=2)
summarizer._retry_delays = (0.0,)
attempt_count = 0
async def mock_ainvoke(messages):
nonlocal attempt_count
attempt_count += 1
raise TimeoutError("Simulated permanent failure")
summarizer._llm = Mock()
summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke)
step_id = uuid4()
summarizer.dispatch(
step_number=1,
action_name="click",
action_args={"target": [100, 100]},
pre_img_bytes=b"pre1",
post_img_bytes=b"post1",
exec_outcome="Outcome 1",
action_key="tc-fail",
data_engine_step_id=step_id,
)
await summarizer.flush()
# Bounded exit: initial attempt + retry_limit retries, then explicit failure.
assert attempt_count == 3
assert summarizer.has_failed("tc-fail")
assert not summarizer.has_summary("tc-fail")
# Lossless semantics: a failed step still reads as pending so the
# compressor keeps its source image instead of dropping evidence.
assert summarizer.is_pending("tc-fail")
# DataEngine observability: pending on dispatch, failed on exhaustion.
mock_context.data_engine.update_step_summary.assert_has_calls(
[
call(step_id, None, status="pending", source="visual_transition", model=ANY),
call(step_id, None, status="failed", source="visual_transition", model=ANY),
]
)
@pytest.mark.asyncio
async def test_summarizer_success_within_retry_budget_not_failed(mock_context):
"""A summary that succeeds on a retry within the budget never enters failed state."""
summarizer = VisualStepSummarizer(mock_context, retry_limit=3)
summarizer._retry_delays = (0.0,)
attempt_count = 0
async def mock_ainvoke(messages):
nonlocal attempt_count
attempt_count += 1
if attempt_count < 3:
raise TimeoutError("Transient failure")
return AIMessage(content="[Step 1 State: Finally worked.]")
summarizer._llm = Mock()
summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke)
summarizer.dispatch(
step_number=1,
action_name="click",
action_args={"target": [100, 100]},
pre_img_bytes=b"pre1",
post_img_bytes=b"post1",
exec_outcome="Outcome 1",
action_key="tc-ok",
)
await summarizer.flush()
assert attempt_count == 3
assert summarizer.has_summary("tc-ok")
assert not summarizer.has_failed("tc-ok")
def test_context_compressor_with_ready_summaries(mock_context):
"""Verify that ready summaries replace past image blocks and prune outdated XML."""
"""Ready summaries replace past image blocks and outdated XML is pruned.
Rewritten against ``ScrubEdgeCompressor`` (M5; the legacy
``compress_flash_messages`` was removed) with ``image_scrub_depth=1`` so
every historical message is scrubbed immediately, mirroring the legacy
timing this test was written for. Summaries are keyed by legacy ordinals
to keep the older-caller fallback path covered.
"""
summarizer = VisualStepSummarizer(mock_context)
summarizer._summaries[1] = "[Step 1 State: Tapped Search bar. Keyboard appeared.]"
summarizer._summaries[2] = "[Step 2 State: Typed 'flights'. Suggestions displayed.]"
@@ -134,7 +220,13 @@ def test_context_compressor_with_ready_summaries(mock_context):
),
]
compress_flash_messages(messages, summarizer=summarizer, prune_history_xml=True)
compressor = ScrubEdgeCompressor(
summarizer=summarizer,
prune_history_xml=True,
image_scrub_depth=1,
pending_grace_steps=0,
)
compressor.compress(messages)
# Initial HumanMessage: Historical initial image pruned, task text preserved
assert len(messages[0].content) == 1
@@ -155,88 +247,15 @@ def test_context_compressor_with_ready_summaries(mock_context):
assert "Live Latest Tree 3" in messages[2].content[2]["text"]
def test_context_compressor_graceful_fallback(mock_context):
"""Verify that when a summary is not ready yet, it cleanly falls back to standard pruned state."""
summarizer = VisualStepSummarizer(mock_context)
# Step 1 has no summary in cache
messages = [
HumanMessage(
content=[
{"type": "text", "text": "Task: Search"},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_1"}},
]
),
ToolMessage(
tool_call_id="tc1",
name="click",
content=[
{"type": "text", "text": "Action 'click' completed."},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_2"}},
],
),
]
compress_flash_messages(messages, summarizer=summarizer, prune_history_xml=True)
# Turn 1 should simply have image omitted (standard fallback)
assert len(messages[0].content) == 1
assert messages[0].content[0]["text"] == "Task: Search"
# Turn 2 (Latest) preserves image
assert len(messages[1].content) == 2
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."""
"""Generic tool messages cannot shift action summaries onto the wrong result.
(Rewritten against ``ScrubEdgeCompressor`` in M5. The legacy tests for
pending-summary fallback and late-summary backfill were removed with
``compress_flash_messages``: the scrub edge covers pending handling in
``test_flash_scrub_edge.py``, and late backfill is now forbidden by the
freeze invariant rather than supported.)
"""
summarizer = VisualStepSummarizer(mock_context)
summarizer._summaries["click-id"] = "Click-specific visual memory."
summarizer._summaries["type-id"] = "Typing-specific visual memory."
@@ -265,7 +284,12 @@ def test_context_compressor_uses_tool_call_id_over_message_ordinal(mock_context)
),
]
compress_flash_messages(messages, summarizer=summarizer)
compressor = ScrubEdgeCompressor(
summarizer=summarizer,
image_scrub_depth=1,
pending_grace_steps=0,
)
compressor.compress(messages)
assert "Click-specific visual memory." in str(messages[0].content)
assert "Typing-specific visual memory." not in str(messages[0].content)
@@ -274,35 +298,6 @@ def test_context_compressor_uses_tool_call_id_over_message_ordinal(mock_context)
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."""
@@ -344,17 +339,229 @@ async def test_summarizer_keys_actions_independently_of_turn_number(mock_context
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
# M1 keying: jobs are canonical under the DataEngine step id; the
# tool_call_id stays queryable as an alias.
assert summarizer.get_job_payload("tc-1")["data_engine_step_id"] == first_step_id
assert summarizer.get_job_payload("tc-2")["data_engine_step_id"] == second_step_id
assert summarizer.resolve_key("tc-1") == str(first_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."),
call(
first_step_id,
"First action memory.",
status="ready",
source="visual_transition",
model=ANY,
),
call(
second_step_id,
"Second action memory.",
status="ready",
source="visual_transition",
model=ANY,
),
],
any_order=True,
)
@pytest.mark.asyncio
async def test_single_frame_dispatch_uses_single_image_prompt_variant(mock_context):
"""A missing after-frame (every Pro step, by design) selects the
single-image prompt: decision frame + red marker, no AFTER ACTION section."""
summarizer = VisualStepSummarizer(mock_context)
captured: list = []
async def mock_ainvoke(messages):
captured.append(messages)
return AIMessage(
content=(
"In Step 1, I tapped the 'Battery' row marked in red in the"
" Settings list, which showed 'Network & internet' and 'Display'."
)
)
summarizer._llm = Mock()
summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke)
summarizer.dispatch(
step_number=1,
action_name="click",
action_args={"target": [100, 200]},
pre_img_bytes=b"decision_frame",
post_img_bytes=None,
exec_outcome="executed",
)
await summarizer.flush()
assert summarizer.has_summary(1)
system_text = str(captured[0][0].content)
human_text = str(captured[0][1].content)
assert "Exactly ONE screenshot" in system_text
assert "no independent post-action evidence" in system_text
assert "DECISION FRAME" in human_text
assert "AFTER ACTION SCREEN" not in human_text
assert "--- [2]" not in human_text
@pytest.mark.asyncio
async def test_dual_frame_dispatch_keeps_transition_prompt(mock_context):
"""Both frames present (Flash path): the classic BEFORE/AFTER transition
prompt and section labels are unchanged."""
summarizer = VisualStepSummarizer(mock_context)
captured: list = []
async def mock_ainvoke(messages):
captured.append(messages)
return AIMessage(
content=(
"In Step 1, I tapped the 'Settings' gear icon marked in red;"
" the main Settings menu opened showing 'Apps'."
)
)
summarizer._llm = Mock()
summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke)
summarizer.dispatch(
step_number=1,
action_name="click",
action_args={"target": [100, 200]},
pre_img_bytes=b"pre",
post_img_bytes=b"post",
exec_outcome="executed",
)
await summarizer.flush()
assert summarizer.has_summary(1)
system_text = str(captured[0][0].content)
human_text = str(captured[0][1].content)
assert "Before/After screenshots" in system_text
assert "--- [1] BEFORE ACTION SCREEN (Action Marked Visually in Red) ---" in human_text
assert "--- [2] AFTER ACTION SCREEN ---" in human_text
assert "DECISION FRAME" not in human_text
@pytest.mark.asyncio
async def test_echo_output_fails_attempt_then_recovers_on_retry(mock_context):
"""An input-marker echo is a failed attempt (not a summary): the bounded
retry regenerates and the recovered text lands normally."""
summarizer = VisualStepSummarizer(mock_context)
summarizer._retry_delays = (0.0,)
attempts = 0
async def mock_ainvoke(messages):
nonlocal attempts
attempts += 1
if attempts == 1:
return AIMessage(content="--- [2] AFTER ACTION SCREEN ---")
return AIMessage(
content=(
"In Step 1, I tapped the search field marked in red; the field"
" showed placeholder text 'Search here'."
)
)
summarizer._llm = Mock()
summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke)
summarizer.dispatch(
step_number=1,
action_name="click",
action_args={"target": [1, 2]},
pre_img_bytes=b"pre",
post_img_bytes=b"post",
exec_outcome="executed",
)
await summarizer.flush()
assert attempts == 2
assert summarizer.has_summary(1)
assert "Search here" in summarizer.get_summary(1)
@pytest.mark.asyncio
async def test_degenerate_outputs_exhaust_to_failed(mock_context):
"""Echo/too-short/too-long outputs never land as summaries; persistent
degeneration exhausts the bounded retries into the explicit failed state."""
summarizer = VisualStepSummarizer(mock_context, retry_limit=1)
summarizer._retry_delays = (0.0,)
async def mock_ainvoke(messages):
text = str(messages)
if "Step 1" in text:
return AIMessage(content="--- [1] DECISION FRAME: SCREEN AT ACTION TIME ---")
if "Step 2" in text:
return AIMessage(content="x" * 2000)
return AIMessage(content="ok.")
summarizer._llm = Mock()
summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke)
for step in (1, 2, 3):
summarizer.dispatch(
step_number=step,
action_name="click",
action_args={"target": [1, 2]},
pre_img_bytes=b"pre",
post_img_bytes=None,
exec_outcome="executed",
)
await summarizer.flush()
for step in (1, 2, 3):
assert summarizer.has_failed(step), f"step {step} should have failed"
assert not summarizer.has_summary(step)
@pytest.mark.asyncio
async def test_raw_lens_call_meters_llm_usage_without_touching_context_base(mock_context):
"""The raw-model lens bypass records an llm_usage trace, but its tiny
prompt never overwrites the session's last_prompt_tokens (the compaction
thresholds' live context base)."""
from artemis.services.token_meter import get_meter
engine = Mock()
engine.current_session_id = "lens-meter-session"
engine.current_step_id = None
mock_context.data_engine = engine
meter = get_meter("lens-meter-session")
meter.last_prompt_tokens = 777
summarizer = VisualStepSummarizer(mock_context)
summarizer._llm = Mock()
summarizer._llm.ainvoke = AsyncMock(
return_value=AIMessage(
content=(
"In Step 1, I tapped the 'Battery' row marked in red; the list"
" showed 'Display' and 'Battery'."
),
usage_metadata={"input_tokens": 120, "output_tokens": 30, "total_tokens": 150},
)
)
summarizer.dispatch(
step_number=1,
action_name="click",
action_args={"target": [1, 2]},
pre_img_bytes=b"pre",
post_img_bytes=b"post",
exec_outcome="executed",
)
await summarizer.flush()
usage_calls = [
c
for c in engine.record_trace.call_args_list
if c.kwargs.get("name") == "llm_usage"
]
assert usage_calls, "the raw lens bypass must record an llm_usage trace"
payload = usage_calls[0].kwargs["payload"]
assert payload["source"].startswith("lens:visual_transition:")
assert payload["prompt_tokens"] == 120
assert meter.last_prompt_tokens == 777
def test_flash_config_and_builder():
"""Verify Flash profile configuration model and SDK builder fluent API."""
cfg = Builders.AgentConfig.with_flash_config(
@@ -395,7 +602,13 @@ async def test_data_engine_integration_with_step_summarizer(mock_context):
)
await summarizer.flush()
mock_de.update_step_summary.assert_called_once_with(1, "[Step 1 State: Input text confirmed.]")
mock_de.update_step_summary.assert_called_once_with(
1,
"[Step 1 State: Input text confirmed.]",
status="ready",
source="visual_transition",
model=ANY,
)
def test_draw_action_overlay_rendering():
@@ -0,0 +1,201 @@
# 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.
"""HistoricalStateHintPromptComponent (M4): perceptual-hash hint + silence rules.
Division of labor under test: the hint fires only for matches *older* than the
recent window (returned-to-an-early-state regime); any match within the last 3
steps silences it entirely — that regime belongs to the pixel-level
ScreenshotSimilarityPromptComponent.
"""
import base64
import io
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from PIL import Image, ImageDraw
from artemis.agents.operator.prompts import HistoricalStateHintPromptComponent, PromptBuilder
from artemis.utils.image_hash import dhash_hex
def _jpeg(draw_fn) -> bytes:
img = Image.new("RGB", (270, 600), "white")
draw_fn(ImageDraw.Draw(img))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=85)
return buf.getvalue()
def _screen_a(d):
d.rectangle([20, 40, 250, 90], fill="#3355ff")
d.rectangle([60, 450, 210, 510], fill="#22aa66")
def _screen_b(d):
for y in range(60, 560, 80):
d.rectangle([10, y, 260, y + 50], fill="#dddddd")
SCREEN_A = _jpeg(_screen_a)
SCREEN_B = _jpeg(_screen_b)
HASH_A = dhash_hex(SCREEN_A)
HASH_B = dhash_hex(SCREEN_B)
B64_A = base64.b64encode(SCREEN_A).decode("utf-8")
def _step(number: int, post_hash: str | None) -> dict:
meta = {"post_image_dhash": post_hash} if post_hash else {}
return {"step_number": number, "extra_metadata": meta}
def _cfg(similarity_hint=True, max_distance=8):
return SimpleNamespace(
memory=SimpleNamespace(
transcript=SimpleNamespace(
similarity_hint=similarity_hint,
similarity_max_distance=max_distance,
)
)
)
async def _run(steps, b64=B64_A, cfg=None):
builder = PromptBuilder()
component = HistoricalStateHintPromptComponent()
with patch("artemis.config.load_agent_config", return_value=cfg or _cfg()):
await component(
builder, MagicMock(), MagicMock(), latest_screenshot_b64=b64, steps=steps
)
return [p for p in builder.human_parts if isinstance(p, str)]
@pytest.mark.asyncio
async def test_hint_fires_on_old_matching_step():
steps = [
_step(1, HASH_B),
_step(2, HASH_A), # old match
_step(3, HASH_B),
_step(4, HASH_B),
_step(5, HASH_B),
_step(6, HASH_B),
]
parts = await _run(steps)
assert len(parts) == 1
assert "Historical state hint" in parts[0]
assert "Step 2" in parts[0]
assert "recall_history" in parts[0]
@pytest.mark.asyncio
async def test_silent_when_recent_step_also_matches():
steps = [
_step(1, HASH_A), # old match exists...
_step(2, HASH_B),
_step(3, HASH_B),
_step(4, HASH_B),
_step(5, HASH_A), # ...but a recent (last-3) match silences the hint
_step(6, HASH_B),
]
assert await _run(steps) == []
@pytest.mark.asyncio
async def test_silent_without_any_match():
steps = [_step(n, HASH_B) for n in range(1, 7)]
assert await _run(steps) == []
@pytest.mark.asyncio
async def test_silent_when_config_disabled():
steps = [
_step(1, HASH_A),
_step(2, HASH_B),
_step(3, HASH_B),
_step(4, HASH_B),
_step(5, HASH_B),
_step(6, HASH_B),
]
assert await _run(steps, cfg=_cfg(similarity_hint=False)) == []
@pytest.mark.asyncio
async def test_silent_with_too_few_steps():
# Everything within the recent window belongs to the pixel-level note.
assert await _run([_step(1, HASH_A), _step(2, HASH_A)]) == []
@pytest.mark.asyncio
async def test_tie_prefers_most_recent_old_step():
steps = [
_step(1, HASH_A),
_step(2, HASH_A), # same distance, more recent
_step(3, HASH_B),
_step(4, HASH_B),
_step(5, HASH_B),
_step(6, HASH_B),
]
parts = await _run(steps)
assert len(parts) == 1 and "Step 2" in parts[0]
@pytest.mark.asyncio
async def test_steps_without_hashes_are_skipped():
steps = [
_step(1, None),
_step(2, None),
_step(3, None),
_step(4, None),
_step(5, None),
_step(6, None),
]
assert await _run(steps) == []
def test_record_step_stamps_perceptual_hashes(tmp_path):
from artemis.context import ArtemisContext
from artemis.data_engine.engine import DataEngine
mock_ctx = MagicMock(spec=ArtemisContext)
setup = MagicMock()
setup.traces_path = str(tmp_path)
mock_ctx.execution_setup = setup
mock_ctx.device = None
engine = DataEngine(mock_ctx)
engine.start_session("dhash stamping test")
engine.record_step(
pre_screenshot_bytes=SCREEN_A,
post_screenshot_bytes=SCREEN_B,
summary="two distinct screens",
)
# Same pre/post bytes: post_image_name is nulled but the post-action
# screen IS the pre screen, so the post hash mirrors the pre hash.
engine.record_step(
pre_screenshot_bytes=SCREEN_A,
post_screenshot_bytes=SCREEN_A,
summary="screen unchanged",
)
for t in list(engine._pending_threads):
t.join()
steps = engine.storage.get_steps(engine.current_session_id)
meta1 = steps[0].extra_metadata
assert meta1["pre_image_dhash"] == HASH_A
assert meta1["post_image_dhash"] == HASH_B
meta2 = steps[1].extra_metadata
assert meta2["pre_image_dhash"] == HASH_A
assert meta2["post_image_dhash"] == HASH_A
+8 -8
View File
@@ -344,7 +344,7 @@ async def test_history_analyzer_integration_with_task_tree():
mock_path.exists.return_value = True
mock_path.read_text.return_value = fake_plan
# Mock build_plan_and_history
# Mock the policy-table compiled-history renderer (M4)
with (
patch(
"artemis.agents.history_analyzer.history_analyzer.get_llm",
@@ -355,23 +355,23 @@ async def test_history_analyzer_integration_with_task_tree():
return_value=mock_path,
),
patch(
"artemis.agents.history_analyzer.history_analyzer.build_plan_and_history"
"artemis.agents.history_analyzer.history_analyzer.build_history_for"
) as mock_build,
):
mock_build.return_value = "Mocked operation history"
analyzer = HistoryAnalyzer(mock_ctx)
await analyzer.run("query")
# Verify build_plan_and_history call args
# Verify build_history_for call args
mock_build.assert_called_once()
args, kwargs = mock_build.call_args
assert args[0] == fake_plan
assert args[1] == steps
assert args[0] == "history_analyzer"
assert args[1] == fake_plan
assert args[2] == steps
import hashlib
expected_hash = hashlib.md5(b"Active Subgoal").hexdigest()
assert args[2] == expected_hash
assert args[3] == expected_hash
assert kwargs.get("last_n_detailed") == 1
assert kwargs.get("min_summaries") == len(steps)
assert kwargs.get("engine") is mock_ctx.data_engine
+84 -20
View File
@@ -16,9 +16,15 @@ import json
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from artemis.agents.operator.operator import OperatorNode
from artemis.config.agent import MemoryTranscriptConfig
from artemis.context import ArtemisContext
import pytest
# These tests exercise the legacy 2-message prompt path. Since M5 the
# transcript path is the default, so flag-off semantics must be requested
# explicitly (never via the config default).
LEGACY_TRANSCRIPT = MemoryTranscriptConfig(enabled=False)
@pytest.mark.asyncio
async def test_operator_node_fast_path():
@@ -58,7 +64,7 @@ async def test_operator_node_fast_path():
# Patch dependencies
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
node = OperatorNode(mock_ctx)
node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT)
node_update = await node(mock_state)
# Verify state was updated with action
@@ -77,7 +83,7 @@ async def test_perform_action_validation():
from artemis.graph.state import State
mock_ctx = MagicMock()
node = OperatorNode(mock_ctx)
node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT)
mock_state = MagicMock(spec=State)
mock_state.indexed_points = [[200, 300], [400, 500]]
@@ -114,6 +120,7 @@ async def test_perform_action_validation():
"target_bounds": "[100,100][300,500]",
"target_resource_id": "btn_1",
"target_class": "android.widget.Button",
"target_label_source": "index",
}
]
@@ -143,6 +150,7 @@ async def test_perform_action_validation():
"target_bounds": "[300,400][500,600]",
"target_resource_id": "input_1",
"target_class": "android.widget.EditText",
"target_label_source": "index",
}
# Test swipe direction
@@ -170,6 +178,7 @@ async def test_perform_action_validation():
"target_bounds": "[300,400][500,600]",
"target_resource_id": "input_1",
"target_class": "android.widget.EditText",
"target_label_source": "index",
}
]
@@ -222,6 +231,59 @@ async def test_perform_action_validation():
assert "Unsupported app management action" in err
@pytest.mark.asyncio
async def test_bare_coordinate_click_enriched_by_hit_test():
"""A coordinate click hit tests the pre-action frame for element semantics."""
from artemis.agents.operator.operator import OperatorNode
from unittest.mock import MagicMock
from artemis.graph.state import State
mock_ctx = MagicMock()
node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT)
mock_state = MagicMock(spec=State)
mock_state.indexed_elements = [
{
"center": [550, 1440],
"text": "Confirm",
"bounds": [500, 1400, 600, 1480],
"class": "android.widget.Button",
"resource_id": "btn_confirm",
"is_ocr": False,
}
]
# Normalized (500, 600) on the default 1080x2400 device -> pixel (540, 1440),
# which falls inside the "Confirm" button bounds.
actions, err = node._translate_and_validate_tool(
{"name": "click", "args": {"target": [500, 600]}}, mock_state
)
assert err is None
assert actions == [
{
"action": "tap",
"coordinates": [540, 1440],
"normalized_coordinates": [500, 600],
"times": 1,
"delay_ms": 100,
"target_text": "Confirm",
"target_bounds": [500, 1400, 600, 1480],
"target_resource_id": "btn_confirm",
"target_class": "android.widget.Button",
"target_label_source": "hit_test",
}
]
# Graceful degradation: no perception data at all -> bare coordinates, "none".
mock_state.indexed_elements = []
actions, err = node._translate_and_validate_tool(
{"name": "click", "args": {"target": [500, 600]}}, mock_state
)
assert err is None
assert actions[0]["target_text"] is None
assert actions[0]["target_label_source"] == "none"
@pytest.mark.asyncio
async def test_operator_node_multiple_actions():
from artemis.agents.operator.operator import OperatorNode
@@ -258,7 +320,7 @@ async def test_operator_node_multiple_actions():
mock_llm.bind_tools.return_value = mock_llm
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
node = OperatorNode(mock_ctx)
node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT)
node_update = await node(mock_state)
update_dict = node_update
@@ -302,7 +364,7 @@ async def test_operator_node_no_record_step():
mock_llm.bind_tools.return_value = mock_llm
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
node = OperatorNode(mock_ctx)
node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT)
node_update = await node(mock_state)
mock_ctx.data_engine.record_step.assert_not_called()
@@ -352,7 +414,7 @@ async def test_operator_dynamic_prompt():
mock_llm.bind_tools.return_value = mock_llm
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
node = OperatorNode(mock_ctx, prompt_components=[MockComponent()])
node = OperatorNode(mock_ctx, prompt_components=[MockComponent()], transcript_config=LEGACY_TRANSCRIPT)
node_update = await node(mock_state)
@@ -488,7 +550,7 @@ async def test_operator_history_compression():
patch("pathlib.Path.exists", return_value=True),
patch("pathlib.Path.read_text", mock_read_text),
):
node = OperatorNode(mock_ctx, last_n_detailed=5)
node = OperatorNode(mock_ctx, last_n_detailed=5, transcript_config=LEGACY_TRANSCRIPT)
node_update = await node(mock_state)
@@ -506,7 +568,7 @@ async def test_operator_loop_detection():
mock_state.subagent_calls = ["ask_explorer", "ask_explorer", "ask_explorer"]
mock_state.initial_goal = "Test goal"
node = OperatorNode(mock_ctx)
node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT)
with pytest.raises(RuntimeError) as exc_info:
node_update = await node(mock_state)
@@ -568,7 +630,7 @@ async def test_operator_tracks_subagent_calls():
side_effect=lambda t, ctx: t,
),
):
node = OperatorNode(mock_ctx, tools=[mock_diagnoser_tool])
node = OperatorNode(mock_ctx, tools=[mock_diagnoser_tool], transcript_config=LEGACY_TRANSCRIPT)
node_update = await node(mock_state)
update_dict = node_update
@@ -660,7 +722,7 @@ async def test_operator_defer_action_for_gathering():
side_effect=lambda t, ctx: t,
),
):
node = OperatorNode(mock_ctx, tools=[mock_diagnoser_tool])
node = OperatorNode(mock_ctx, tools=[mock_diagnoser_tool], transcript_config=LEGACY_TRANSCRIPT)
node_update = await node(mock_state)
# Subagent tool should have been executed
@@ -683,7 +745,7 @@ async def test_wait_actions_translation():
from artemis.graph.state import State
mock_ctx = MagicMock()
node = OperatorNode(mock_ctx)
node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT)
mock_state = MagicMock(spec=State)
# 1. Test wait_for_delay translation
@@ -708,7 +770,7 @@ async def test_long_press_action_translation():
from artemis.graph.state import State
mock_ctx = MagicMock()
node = OperatorNode(mock_ctx)
node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT)
mock_state = MagicMock(spec=State)
mock_state.indexed_points = [[200, 300], [400, 500]]
mock_state.indexed_elements = [
@@ -744,6 +806,7 @@ async def test_long_press_action_translation():
"target_bounds": "[100,100][300,500]",
"target_resource_id": "btn_1",
"target_class": "android.widget.Button",
"target_label_source": "index",
}
]
@@ -765,6 +828,7 @@ async def test_long_press_action_translation():
"target_bounds": None,
"target_resource_id": None,
"target_class": None,
"target_label_source": "none",
}
]
@@ -844,7 +908,7 @@ async def test_operator_no_defer_for_note_updating():
side_effect=lambda t, ctx: t,
),
):
node = OperatorNode(mock_ctx, tools=[mock_update_note_tool])
node = OperatorNode(mock_ctx, tools=[mock_update_note_tool], transcript_config=LEGACY_TRANSCRIPT)
node_update = await node(mock_state)
# The update_note tool should have been executed immediately
@@ -934,7 +998,7 @@ async def test_operator_defer_for_note_reading():
side_effect=lambda t, ctx: t,
),
):
node = OperatorNode(mock_ctx, tools=[mock_read_note_tool])
node = OperatorNode(mock_ctx, tools=[mock_read_note_tool], transcript_config=LEGACY_TRANSCRIPT)
node_update = await node(mock_state)
# The read_note tool should have been executed
@@ -1022,7 +1086,7 @@ async def test_operator_no_defer_for_note_appending():
side_effect=lambda t, ctx: t,
),
):
node = OperatorNode(mock_ctx, tools=[mock_append_note_tool])
node = OperatorNode(mock_ctx, tools=[mock_append_note_tool], transcript_config=LEGACY_TRANSCRIPT)
node_update = await node(mock_state)
# The append_note tool should have been executed immediately
@@ -1111,7 +1175,7 @@ async def test_operator_background_tasks_prompt_injection():
side_effect=mock_invoke_llm_loop,
),
):
node = OperatorNode(mock_ctx)
node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT)
node_update = await node(mock_state)
# Assert that the captured messages (prompts) contain the background tasks section
@@ -1202,7 +1266,7 @@ async def test_operator_task_plan_warning_prompt_injection():
side_effect=mock_invoke_llm_loop_1,
),
):
node = OperatorNode(mock_ctx)
node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT)
node_update = await node(mock_state)
full_content_1 = ""
@@ -1255,7 +1319,7 @@ async def test_operator_task_plan_warning_prompt_injection():
side_effect=mock_invoke_llm_loop_2,
),
):
node = OperatorNode(mock_ctx)
node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT)
node_update = await node(mock_state)
full_content_2 = ""
@@ -1322,7 +1386,7 @@ async def test_operator_tool_limit_exceeded_warning():
side_effect=mock_invoke_llm_loop,
),
):
node = OperatorNode(mock_ctx)
node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT)
node_update = await node(mock_state)
full_content = ""
@@ -1380,7 +1444,7 @@ async def test_operator_fallback_function_call_parsing():
mock_llm.bind_tools.return_value = mock_llm
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
node = OperatorNode(mock_ctx)
node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT)
node_update = await node(mock_state)
# Verify state was updated with parsed click action
@@ -1402,7 +1466,7 @@ async def test_operator_swipe_translation():
mock_ctx = MagicMock(spec=ArtemisContext)
mock_state = MagicMock()
mock_state.operator_raw_data = {"width": 1080, "height": 2400}
node = OperatorNode(mock_ctx)
node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT)
# 1. Directional swipe
actions, err = node._translate_and_validate_tool(
+1 -1
View File
@@ -24,7 +24,7 @@ def test_operator_prompt_contract_matches_note_runtime_semantics():
assert "write-through tools `update_note` and `append_note`" in prompt
assert "may accompany at most one Turn-Ending Action" in prompt
assert "`read_note`, `list_notes`, and `save_note`" in prompt
assert "`read_note`, `list_notes`, `save_note`, and `recall_history`" in prompt
assert "Do NOT submit a Turn-Ending Action at the same time" not in prompt
assert "memory note tools (`read_note`, `list_notes`, `save_note`, `update_note`" not in prompt
@@ -0,0 +1,311 @@
# 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.
"""Operator dual prompt path (M2): legacy golden parity and transcript mode.
Flag off (``agent.memory.transcript.enabled=false``): the legacy 2-message
build must stay byte-for-byte identical — pinned by SHA-256 goldens recorded
against the pre-split implementation.
Flag on: ``_build_prompt`` renders ``S + F + A + tail`` from the session
transcript ledger; the turn's messages are staged after the tool loop and
committed (with step key and validator result) at the next build.
"""
import base64
import hashlib
import re
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from langchain_core.messages import HumanMessage, SystemMessage
from artemis.agents.operator.operator import OperatorNode
from artemis.agents.operator.prompt_builder import load_operator_prompts
from artemis.agents.operator.prompts import (
PLAN_HISTORY_STATIC_POINTER,
PLAN_HISTORY_TEMPLATE_SECTION,
PromptBuilder,
TemplatePromptComponent,
render_transcript_static_system,
)
from artemis.config.agent import MemoryTranscriptConfig
from artemis.context import ArtemisContext
from artemis.memory.step_memory import StepMemoryService
from artemis.memory.transcript import (
EXECUTION_RESULT_MARKER,
PLAN_RECITATION_MARKER,
RESTORED_HISTORY_HEADER,
TranscriptLedger,
)
# SHA-256 of the legacy system message rendered by TemplatePromptComponent
# with the fixed inputs below. Originally recorded BEFORE the M2 template
# split; re-recorded once in M5 (2026-09-01) after the only intended template
# change since — removing the Short-Term Memory instruction block from
# operator.json (the <short_term_memory> relay was removed end to end per
# pro-context-memory-redesign §3.3). Any other drift here means the flag-off
# path is no longer byte-identical.
GOLDEN_EMPTY_PLAN = "c45aec7d67069dad3a195534dd3c97dadb48454ca186a40eb471d5f9136589fa"
GOLDEN_SENTINEL_PLAN = "670580efd48f237c3257d996a814145fbef4ec0bb467aa4dfa6eb2db4a613826"
SCREENSHOT_B64 = base64.b64encode(b"fake-jpeg-bytes").decode("utf-8")
async def _render_legacy_system(plan_and_history: str) -> str:
builder = PromptBuilder()
component = TemplatePromptComponent()
ctx = SimpleNamespace(execution_setup=None, actuator=None, data_engine=None)
state = SimpleNamespace(initial_goal="GOLDEN_FIXED_GOAL")
await component(
builder,
state,
ctx,
prompts=load_operator_prompts(),
template_name="main_template",
plan_and_history=plan_and_history,
)
return "".join(builder.system_parts)
@pytest.mark.asyncio
async def test_legacy_prompt_bytes_match_pre_split_golden():
empty = await _render_legacy_system("No plan or history yet.")
sentinel = await _render_legacy_system("PLAN_AND_HISTORY_SENTINEL\nline2")
assert hashlib.sha256(empty.encode("utf-8")).hexdigest() == GOLDEN_EMPTY_PLAN
assert hashlib.sha256(sentinel.encode("utf-8")).hexdigest() == GOLDEN_SENTINEL_PLAN
def test_main_template_contains_exactly_one_plan_history_section():
template = load_operator_prompts()["main_template"]
assert template.count(PLAN_HISTORY_TEMPLATE_SECTION) == 1
def test_transcript_static_system_is_stable_and_carries_no_history():
ctx = SimpleNamespace(execution_setup=None, actuator=None, data_engine=None)
state = SimpleNamespace(initial_goal="STATIC_GOAL")
prompts = load_operator_prompts()
first = render_transcript_static_system(prompts, ctx, state)
second = render_transcript_static_system(prompts, ctx, state)
assert first == second # byte-stable S region
assert "STATIC_GOAL" in first
# The volatile section is replaced by the static pointer...
assert PLAN_HISTORY_STATIC_POINTER.splitlines()[1] in first
# ...and no unrendered slot or trailing observation header survives.
assert "{{" not in first
assert not first.endswith("# CURRENT OBSERVATION\n")
def _transcript_ctx():
ctx = MagicMock(spec=ArtemisContext)
ctx.execution_setup = None
ctx.actuator = None
ctx.data_engine = None
ctx.step_memory = StepMemoryService(ctx=None)
ctx.transcript_ledger = None
return ctx
def _transcript_state(**overrides):
state = MagicMock()
state.subagent_calls = []
state.initial_goal = "Transcript goal"
state.operator_feedback = None
state.injected_instruction = None
state.operator_tool_limit_exceeded = False
state.structured_decisions = None
state.last_execution_result = None
state.current_step_id = None
state.latest_ui_hierarchy = []
state.operator_raw_data = {
"screenshot_b64": SCREENSHOT_B64,
"xml_hierarchy": [],
"ocr_results": None,
"width": 1080,
"height": 2400,
}
for key, value in overrides.items():
setattr(state, key, value)
return state
def _no_action_llm(captured: list):
mock_llm = MagicMock()
mock_response = MagicMock()
mock_response.tool_calls = []
mock_response.content = "no action this turn"
async def mock_ainvoke(*args, **kwargs):
captured.append(list(args[0]))
return mock_response
mock_llm.ainvoke = AsyncMock(side_effect=mock_ainvoke)
mock_llm.bind_tools.return_value = mock_llm
return mock_llm
@pytest.mark.asyncio
async def test_transcript_mode_two_turns_build_four_regions(tmp_path):
ctx = _transcript_ctx()
# Regression data shape (M2 cold-start bug, on-device A/B 2026-09-01):
# turn 1 runs before any step record exists; by turn 2 the first step HAS
# been recorded, so `steps` is non-empty while the previous turn is still
# staged (turn_count == 0). The cold-start check must not read that
# live-session state as a process restart — seeding would be rejected by
# the empty-ledger invariant and silently demote every turn to the legacy
# 2-message path.
ctx.data_engine = MagicMock()
ctx.data_engine.base_dir = str(tmp_path)
ctx.data_engine.global_base_dir = str(tmp_path)
ctx.data_engine.get_agent_friendly_steps.return_value = []
captured: list = []
mock_llm = _no_action_llm(captured)
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True))
# Turn 1
await node(_transcript_state())
ledger = ctx.transcript_ledger
assert isinstance(ledger, TranscriptLedger)
assert ledger.has_static_prefix
assert ledger.has_staged_turn
assert ledger.turn_count == 0
turn1_messages = captured[0]
assert isinstance(turn1_messages[0], SystemMessage)
assert isinstance(turn1_messages[-1], HumanMessage)
tail1_text = str(turn1_messages[-1].content)
assert "# CURRENT OBSERVATION [T+" in tail1_text
assert PLAN_RECITATION_MARKER in tail1_text
# Turn 2: step 1 is now recorded, and the previous turn is committed
# with its step id and result.
ctx.data_engine.get_agent_friendly_steps.return_value = [
{
"step_id": "step-1",
"step_number": 1,
"summary": "Step 1 summary",
"relative_time": "1.0s",
"extra_metadata": {"subgoal_hash": "default"},
}
]
await node(
_transcript_state(
current_step_id="step-1",
structured_decisions='[{"action": "tap"}]',
last_execution_result={"status": "success"},
)
)
# The staged-but-uncommitted turn 2 state never triggers cold-start
# seeding (the transcript path stayed live instead of falling back).
assert not ledger.has_restored_history
assert ledger.turn_count == 1
turn2_messages = captured[1]
# S region is the same object with the same bytes.
assert turn2_messages[0] is turn1_messages[0]
active = list(ledger.active_messages)
# Committed turn-1 observation followed by its validator result.
assert any(EXECUTION_RESULT_MARKER in str(m.content) for m in active)
committed_obs = active[0]
# Depth-1 scrub removed the old plan recitation copy.
assert PLAN_RECITATION_MARKER not in str(committed_obs.content)
# The live tail still recites the plan.
assert PLAN_RECITATION_MARKER in str(turn2_messages[-1].content)
# Message layout is S + A + tail.
assert turn2_messages[-1] is not turn1_messages[-1]
assert re.search(r"T\+\d{2,}:\d{2}", str(turn2_messages[-1].content))
@pytest.mark.asyncio
async def test_transcript_actionless_turn_commits_without_validator_message():
ctx = _transcript_ctx()
captured: list = []
mock_llm = _no_action_llm(captured)
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True))
await node(_transcript_state())
# Planner-rejected turns clear structured_decisions: no validator ran.
await node(
_transcript_state(
current_step_id="step-1",
structured_decisions="",
last_execution_result={"status": "success"},
)
)
ledger = ctx.transcript_ledger
assert ledger.turn_count == 1
assert not any(
EXECUTION_RESULT_MARKER in str(m.content) for m in ledger.active_messages
)
@pytest.mark.asyncio
async def test_transcript_cold_start_builds_restored_history_block(tmp_path):
ctx = _transcript_ctx()
ctx.data_engine = MagicMock()
ctx.data_engine.base_dir = str(tmp_path)
ctx.data_engine.global_base_dir = str(tmp_path)
ctx.data_engine.get_agent_friendly_steps.return_value = [
{
"step_id": f"step_{i}",
"step_number": i,
"summary": f"Step {i} summary",
"relative_time": f"{i}.0s",
"extra_metadata": {"subgoal_hash": "default"},
}
for i in range(1, 4)
]
captured: list = []
mock_llm = _no_action_llm(captured)
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True))
await node(_transcript_state())
ledger = ctx.transcript_ledger
assert ledger.has_restored_history
messages = captured[0]
restored_text = str(messages[1].content)
assert RESTORED_HISTORY_HEADER in restored_text
assert "--- Execution History ---" in restored_text
assert "Step 1 summary" in restored_text
# The live tail still follows normally and A stays appendable.
assert "# CURRENT OBSERVATION [T+" in str(messages[-1].content)
assert ledger.has_staged_turn
@pytest.mark.asyncio
async def test_flag_off_keeps_legacy_two_message_build_and_no_ledger():
ctx = _transcript_ctx()
captured: list = []
mock_llm = _no_action_llm(captured)
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=False))
await node(_transcript_state())
assert len(captured[0]) == 2
assert isinstance(captured[0][0], SystemMessage)
assert isinstance(captured[0][1], HumanMessage)
assert ctx.transcript_ledger is None
+3
View File
@@ -286,6 +286,9 @@ async def test_outputter_builds_concise_history(mock_get_llm):
}
]
mock_data_engine.get_agent_friendly_steps.return_value = steps
# M5: the transcript flag defaults on, so the outputter's compiled view
# consults the chunk store; no persisted chunks keeps the classic path.
mock_data_engine.get_history_chunks.return_value = []
mock_data_engine.base_dir = "/tmp/mock_session"
ctx.data_engine = mock_data_engine
+3 -3
View File
@@ -92,7 +92,7 @@ def test_full_toolset_is_the_default():
)
@pytest.mark.parametrize("tool", OPERATOR_DEVICE_TOOLS + ("video_analyzer",))
@pytest.mark.parametrize("tool", OPERATOR_DEVICE_TOOLS + ("video_analyzer", "recall_history"))
def test_removed_tool_leaves_no_reference(tool):
"""The executable definition of 'an absent tool costs the model nothing'."""
for name, template in load_operator_prompts().items():
@@ -154,8 +154,8 @@ def test_full_set_enum_slots_render_verbatim():
)
assert (
"(`ask_explorer`, `ask_diagnoser`, `video_analyzer`, `run_adb_command`,"
" `manage_task`, `analyze_task_output`, `read_note`, `list_notes`, and"
" `save_note`)" in out
" `manage_task`, `analyze_task_output`, `read_note`, `list_notes`,"
" `save_note`, and `recall_history`)" in out
)
+84 -148
View File
@@ -12,11 +12,21 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import json
from unittest.mock import MagicMock, Mock, patch
"""SummarizerNode tests (M2: visual-transition lens dispatch).
Per the history redesign §5 the Pro SummarizerNode no longer runs its own LLM
call to produce a text capsule: it dispatches the step to the shared
step-memory service (``ctx.step_memory``) with the pre/post screenshot bytes
pulled from the DataEngine, and degrades gracefully when images are missing.
"""
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock
import pytest
from artemis.agents.summarizer.summarizer import SummarizerNode
from artemis.context import ArtemisContext
import pytest
class DummyState:
@@ -36,181 +46,107 @@ class DummyState:
@pytest.fixture
def mock_context():
def mock_context(tmp_path):
ctx = Mock(spec=ArtemisContext)
ctx.device = None
ctx.data_engine = Mock()
ctx.background_tasks = []
ctx.step_memory = MagicMock()
pre_path = tmp_path / "pre.jpg"
post_path = tmp_path / "post.jpg"
pre_path.write_bytes(b"PRE_IMAGE")
post_path.write_bytes(b"POST_IMAGE")
engine = Mock()
engine.get_step_number.return_value = 7
engine.get_step_record.return_value = SimpleNamespace(
action_taken=[{"action": "tap", "coordinates": [500, 600], "target_text": "Search"}]
)
engine.get_step_image_path.side_effect = lambda step_number, which="pre": (
pre_path if which == "pre" else post_path
)
ctx.data_engine = engine
return ctx
@pytest.mark.asyncio
async def test_summarizer_success(mock_context):
"""Test that SummarizerNode successfully generates summary from raw thinking and execution result."""
decisions = json.dumps([{"action": "tap", "coordinates": [500, 600]}])
thinking = "The operator clicked the search button."
result = {
"status": "success",
"executed_actions": [{"action": "tap", "coordinates": [500, 600]}],
}
async def test_summarizer_dispatches_visual_lens(mock_context):
"""The node dispatches to the shared service instead of calling an LLM."""
state = DummyState(
structured_decisions=decisions,
operator_raw_thinking=thinking,
last_execution_result=result,
last_execution_result={"status": "success"},
current_step_id="12345678-1234-5678-1234-567812345678",
)
# Mock LLM response
mock_response = Mock()
mock_response.content = "Step completed successfully."
mock_llm = MagicMock()
node = SummarizerNode(mock_context)
result = await node(state)
async def mock_astream(*args, **kwargs):
yield mock_response
mock_llm.astream.side_effect = mock_astream
with (
patch(
"artemis.agents.summarizer.summarizer.get_llm", return_value=mock_llm
) as mock_get_llm,
patch("asyncio.create_task") as mock_create_task,
):
# Run the node call
node = SummarizerNode(mock_context)
await node(state)
# Verify task was registered in context
assert len(mock_context.background_tasks) == 1
assert mock_context.background_tasks[0] == mock_create_task.return_value
# Verify background task was spawned
assert mock_create_task.called
background_coroutine = mock_create_task.call_args[0][0]
# Await the background task synchronously for test verification
await background_coroutine
# Verify LLM was invoked with proper structured messages
assert mock_llm.astream.called
invoked_messages = mock_llm.astream.call_args[0][0]
system_msg = invoked_messages[0].content
human_msg = invoked_messages[1].content
assert "Summarizer Agent" in system_msg
assert "Decisions made" in human_msg
assert "The operator clicked the search button." in human_msg
# Verify Data Engine updated the step summary
from uuid import UUID
mock_context.data_engine.update_step_summary.assert_called_once_with(
UUID("12345678-1234-5678-1234-567812345678"),
"Step completed successfully.",
assert result == {}
mock_context.step_memory.dispatch.assert_called_once_with(
step_number=7,
action_name="tap",
action_args={"coordinates": [500, 600], "target_text": "Search"},
pre_img_bytes=b"PRE_IMAGE",
post_img_bytes=b"POST_IMAGE",
exec_outcome="success",
data_engine_step_id="12345678-1234-5678-1234-567812345678",
)
# No direct capsule write from the node: the lens owns the versioned
# summary write when its background job completes.
mock_context.data_engine.update_step_summary.assert_not_called()
@pytest.mark.asyncio
async def test_summarizer_with_native_thinking(mock_context):
"""Test that SummarizerNode successfully generates summary from both explicit and native thoughts."""
decisions = json.dumps([{"action": "tap", "coordinates": [500, 600]}])
raw_thinking = "Explicit monologue."
native_thinking = "Silent core reasoning."
result = {"status": "success"}
async def test_summarizer_degrades_gracefully_without_images(mock_context):
"""Missing DataEngine screenshots dispatch with None bytes, no exception."""
mock_context.data_engine.get_step_image_path.side_effect = None
mock_context.data_engine.get_step_image_path.return_value = None
state = DummyState(
structured_decisions=decisions,
operator_raw_thinking=raw_thinking,
operator_native_thinking=native_thinking,
last_execution_result=result,
last_execution_result={"status": "failed", "error": "tap missed"},
current_step_id="12345678-1234-5678-1234-567812345678",
)
mock_response = Mock()
mock_response.content = "Summary result."
mock_llm = MagicMock()
node = SummarizerNode(mock_context)
await node(state)
async def mock_astream(*args, **kwargs):
yield mock_response
mock_llm.astream.side_effect = mock_astream
with (
patch(
"artemis.agents.summarizer.summarizer.get_llm", return_value=mock_llm
) as mock_get_llm,
patch("asyncio.create_task") as mock_create_task,
):
node = SummarizerNode(mock_context)
await node(state)
# Verify task was registered in context
assert len(mock_context.background_tasks) == 1
assert mock_context.background_tasks[0] == mock_create_task.return_value
background_coroutine = mock_create_task.call_args[0][0]
await background_coroutine
assert mock_llm.astream.called
invoked_messages = mock_llm.astream.call_args[0][0]
human_msg = invoked_messages[1].content
assert "Operator explicit thoughts:" in human_msg
assert "Explicit monologue." in human_msg
assert "Operator native thoughts:" in human_msg
assert "Silent core reasoning." in human_msg
kwargs = mock_context.step_memory.dispatch.call_args.kwargs
assert kwargs["pre_img_bytes"] is None
assert kwargs["post_img_bytes"] is None
assert kwargs["exec_outcome"] == "Error: tap missed"
@pytest.mark.asyncio
async def test_summarizer_with_chronological_trace(mock_context):
"""Test that SummarizerNode generates a prompt containing the chronological step trace when available."""
step_id = "12345678-1234-5678-1234-567812345678"
async def test_summarizer_falls_back_to_structured_decisions(mock_context):
"""Without a step record the action comes from structured_decisions, and
extra actions ride along as additional_actions."""
mock_context.data_engine.get_step_record.return_value = None
state = DummyState(
current_step_id=step_id,
structured_decisions=(
'[{"action": "swipe", "coordinates": [1, 2, 3, 4]},'
' {"action": "tap", "coordinates": [9, 9]}]'
),
last_execution_result={"status": "success"},
current_step_id="12345678-1234-5678-1234-567812345678",
)
mock_step_data = {
"step_id": step_id,
"step_number": 1,
"relative_time": "5.0s",
"action_taken": [{"action": "click", "target_text": "Search"}],
"last_execution_result": {"status": "success"},
"interleaved_events": [
{"type": "thought", "content": "Let's click search."},
],
}
node = SummarizerNode(mock_context)
await node(state)
mock_context.data_engine.get_agent_friendly_steps.return_value = [mock_step_data]
kwargs = mock_context.step_memory.dispatch.call_args.kwargs
assert kwargs["action_name"] == "swipe"
assert kwargs["action_args"]["coordinates"] == [1, 2, 3, 4]
assert kwargs["action_args"]["additional_actions"] == [
{"action": "tap", "coordinates": [9, 9]}
]
# Mock LLM response
mock_response = Mock()
mock_response.content = "Summary using chronological trace."
mock_llm = MagicMock()
async def mock_astream(*args, **kwargs):
yield mock_response
@pytest.mark.asyncio
async def test_summarizer_noop_without_step_id(mock_context):
state = DummyState(current_step_id=None)
mock_llm.astream.side_effect = mock_astream
node = SummarizerNode(mock_context)
result = await node(state)
with (
patch("artemis.agents.summarizer.summarizer.get_llm", return_value=mock_llm),
patch("asyncio.create_task") as mock_create_task,
):
node = SummarizerNode(mock_context)
await node(state)
# Verify task was registered in context
assert len(mock_context.background_tasks) == 1
assert mock_context.background_tasks[0] == mock_create_task.return_value
background_coroutine = mock_create_task.call_args[0][0]
await background_coroutine
assert mock_llm.astream.called
invoked_messages = mock_llm.astream.call_args[0][0]
human_msg = invoked_messages[1].content
assert "--- Execution History ---" in human_msg
assert "Let's click search." in human_msg
assert result == {}
mock_context.step_memory.dispatch.assert_not_called()
+59
View File
@@ -380,3 +380,62 @@ def test_update_step_summary_includes_step_number_in_sse(tmp_path):
assert last_update["step_number"] == 1
def _foreground_engine(tmp_path):
mock_ctx = MagicMock(spec=ArtemisContext)
mock_execution_setup = MagicMock()
mock_execution_setup.traces_path = str(tmp_path)
mock_ctx.execution_setup = mock_execution_setup
mock_ctx.device = None
engine = DataEngine(mock_ctx)
engine.start_session("foreground_app persistence")
return engine
def test_record_step_persists_explicit_foreground_app(tmp_path):
"""M5: the historically dropped foreground_app parameter is persisted."""
engine = _foreground_engine(tmp_path)
engine.record_step(
foreground_app="com.android.settings",
ui_tree=[{"package": "com.other.app", "bounds": "[0,0][10,10]"}],
summary="Explicit parameter wins",
)
for t in list(engine._pending_threads):
t.join()
step = engine.storage.get_steps(engine.current_session_id)[0]
assert step.extra_metadata["foreground_app"] == "com.android.settings"
def test_record_step_derives_foreground_app_from_ui_tree(tmp_path):
"""Without the parameter, the dominant non-overlay package is stamped."""
engine = _foreground_engine(tmp_path)
engine.record_step(
ui_tree=[
{"package": "com.android.systemui", "bounds": "[0,0][10,10]"},
{"package": "com.google.android.deskclock"},
{
"packageName": "com.google.android.deskclock",
"children": [{"package": "com.google.android.deskclock"}],
},
],
summary="Derived from UI tree",
)
for t in list(engine._pending_threads):
t.join()
step = engine.storage.get_steps(engine.current_session_id)[0]
assert step.extra_metadata["foreground_app"] == "com.google.android.deskclock"
def test_record_step_without_app_data_stamps_nothing(tmp_path):
engine = _foreground_engine(tmp_path)
engine.record_step(
ui_tree=[{"package": "com.android.systemui"}],
summary="Only overlay packages",
)
for t in list(engine._pending_threads):
t.join()
step = engine.storage.get_steps(engine.current_session_id)[0]
assert "foreground_app" not in (step.extra_metadata or {})
@@ -0,0 +1,105 @@
# 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.
"""History-chunk storage (M3 §6.1): append-only versioned rows keyed by step range."""
from uuid import uuid4
import pytest
from artemis.data_engine.models import HistoryChunkRecord
from artemis.data_engine.storage import StorageManager
SESSION = "11111111-1111-1111-1111-111111111111"
@pytest.fixture
def storage(tmp_path):
return StorageManager(tmp_path / "test.db", tmp_path / "traces")
def _chunk(start: int, end: int, version: int = 1, **overrides) -> HistoryChunkRecord:
fields = {
"chunk_id": uuid4(),
"session_id": SESSION,
"start_step_id": f"sid-{start}",
"end_step_id": f"sid-{end}",
"start_step_number": start,
"end_step_number": end,
"source_step_ids": [f"sid-{n}" for n in range(start, end + 1)],
"subgoal_hash": "hash-a",
"version": version,
"status": "pending",
"band1": {},
"band2": None,
"band3": f"- Step {start} (T+00:10): Tapped 'x' at [1, 2] -> executed",
"rendered_text": "[Chunk 1 | ...]",
}
fields.update(overrides)
return HistoryChunkRecord(**fields)
def test_history_chunk_roundtrip(storage):
storage.create_history_chunk(_chunk(1, 4))
chunks = storage.get_history_chunks(SESSION)
assert len(chunks) == 1
rec = chunks[0]
assert (rec.start_step_number, rec.end_step_number) == (1, 4)
assert rec.source_step_ids == [f"sid-{n}" for n in range(1, 5)]
assert rec.subgoal_hash == "hash-a"
assert rec.status == "pending"
assert "T+00:10" in rec.band3
def test_history_chunk_versions_are_append_only_latest_wins(storage):
storage.create_history_chunk(_chunk(1, 4, version=1, status="pending"))
storage.create_history_chunk(
_chunk(
1,
4,
version=2,
status="ready",
band1={"doing": "d", "verified_facts": ["f1"]},
band2=" - Steps 1–4: did things",
)
)
latest = storage.get_history_chunks(SESSION)
assert len(latest) == 1
assert latest[0].version == 2
assert latest[0].status == "ready"
assert latest[0].band1["verified_facts"] == ["f1"]
trail = storage.get_history_chunks(SESSION, all_versions=True)
assert [r.version for r in trail] == [1, 2]
def test_history_chunks_ordered_by_step_range(storage):
storage.create_history_chunk(_chunk(9, 12))
storage.create_history_chunk(_chunk(1, 4))
storage.create_history_chunk(_chunk(5, 8))
latest = storage.get_history_chunks(SESSION)
assert [(r.start_step_number, r.end_step_number) for r in latest] == [
(1, 4),
(5, 8),
(9, 12),
]
def test_history_chunks_are_session_scoped(storage):
storage.create_history_chunk(_chunk(1, 4))
other = "22222222-2222-2222-2222-222222222222"
storage.create_history_chunk(_chunk(1, 4, session_id=other))
assert len(storage.get_history_chunks(SESSION)) == 1
assert len(storage.get_history_chunks(other)) == 1
@@ -0,0 +1,171 @@
# 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.
"""Tests for the versioned, status-carrying step summary write (M0 §6.1)."""
from unittest.mock import MagicMock
from artemis.context import ArtemisContext
from artemis.data_engine.engine import DataEngine
def _make_engine(tmp_path):
mock_ctx = MagicMock(spec=ArtemisContext)
mock_execution_setup = MagicMock()
mock_execution_setup.traces_path = str(tmp_path)
mock_ctx.execution_setup = mock_execution_setup
mock_ctx.device = None
engine = DataEngine(mock_ctx)
engine.start_session("summary versioning test")
return engine
def _flush(engine):
for t in list(engine._pending_threads):
t.join()
def _get_step(engine, step_id):
steps = engine.storage.get_steps(engine.current_session_id)
for step in steps:
if str(step.step_id) == str(step_id):
return step
return None
def test_ready_write_persists_summary_and_metadata(tmp_path):
engine = _make_engine(tmp_path)
step_id = engine.record_step(summary="initial")
_flush(engine)
applied = engine.storage.update_step_summary(
step_id,
"Tapped the login button.",
source="visual_transition",
model="gemini-2.5-flash-lite",
status="ready",
)
assert applied is True
step = _get_step(engine, step_id)
assert step.summary == "Tapped the login button."
assert step.extra_metadata["summary_status"] == "ready"
assert step.extra_metadata["summary_source"] == "visual_transition"
assert step.extra_metadata["summary_version"] == 1
assert step.extra_metadata["summary_model"] == "gemini-2.5-flash-lite"
def test_auto_version_increments_per_write(tmp_path):
engine = _make_engine(tmp_path)
step_id = engine.record_step(summary="initial")
_flush(engine)
engine.storage.update_step_summary(step_id, "v1", status="ready")
engine.storage.update_step_summary(step_id, "v2", status="ready")
step = _get_step(engine, step_id)
assert step.summary == "v2"
assert step.extra_metadata["summary_version"] == 2
def test_stale_explicit_version_is_dropped(tmp_path):
engine = _make_engine(tmp_path)
step_id = engine.record_step(summary="initial")
_flush(engine)
assert engine.storage.update_step_summary(step_id, "newer", version=5, status="ready")
applied = engine.storage.update_step_summary(step_id, "older", version=3, status="ready")
assert applied is False
step = _get_step(engine, step_id)
assert step.summary == "newer"
assert step.extra_metadata["summary_version"] == 5
def test_pending_does_not_downgrade_ready(tmp_path):
engine = _make_engine(tmp_path)
step_id = engine.record_step(summary="initial")
_flush(engine)
engine.storage.update_step_summary(step_id, "done", status="ready")
applied = engine.storage.update_step_summary(step_id, None, status="pending")
assert applied is False
step = _get_step(engine, step_id)
assert step.summary == "done"
assert step.extra_metadata["summary_status"] == "ready"
def test_failed_marks_status_without_touching_summary(tmp_path):
engine = _make_engine(tmp_path)
step_id = engine.record_step(summary="original text")
_flush(engine)
engine.storage.update_step_summary(step_id, None, status="pending", source="visual_transition")
applied = engine.storage.update_step_summary(
step_id, None, status="failed", source="visual_transition"
)
assert applied is True
step = _get_step(engine, step_id)
assert step.summary == "original text"
assert step.extra_metadata["summary_status"] == "failed"
# A failed write must never clobber an already-ready summary.
engine.storage.update_step_summary(step_id, "recovered", status="ready")
applied = engine.storage.update_step_summary(step_id, None, status="failed")
assert applied is False
step = _get_step(engine, step_id)
assert step.extra_metadata["summary_status"] == "ready"
def test_missing_step_returns_false(tmp_path):
from uuid import uuid4
engine = _make_engine(tmp_path)
assert engine.storage.update_step_summary(uuid4(), "text", status="ready") is False
def test_engine_level_write_carries_metadata_and_publishes_ready_only(tmp_path):
engine = _make_engine(tmp_path)
published = []
engine.subscribe(lambda event_type, data: published.append((event_type, data)))
step_id = engine.record_step(summary="initial")
_flush(engine)
published.clear()
# Non-ready statuses persist metadata but publish no SSE.
engine.update_step_summary(step_id, None, status="pending", source="visual_transition")
_flush(engine)
assert not [e for e in published if e[0] == "step_updated"]
step = _get_step(engine, step_id)
assert step.extra_metadata["summary_status"] == "pending"
engine.update_step_summary(
step_id,
"Ready text.",
status="ready",
source="visual_transition",
model="test-model",
)
_flush(engine)
ready_events = [e for e in published if e[0] == "step_updated"]
assert len(ready_events) == 1
assert ready_events[0][1]["summary"] == "Ready text."
step = _get_step(engine, step_id)
assert step.summary == "Ready text."
assert step.extra_metadata["summary_status"] == "ready"
assert step.extra_metadata["summary_model"] == "test-model"
@@ -0,0 +1,158 @@
# 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.
"""Tests for record-time target semantics enrichment in McpActionExecutor."""
from unittest.mock import Mock
from artemis.mcp.action_executor import McpActionExecutor
def _make_executor(width=1080, height=2400):
ctx = Mock()
ctx.device.device_width = width
ctx.device.device_height = height
actuator = Mock()
actuator.controller = Mock()
return McpActionExecutor(ctx, actuator=actuator)
def _state_with_elements(elements):
state = Mock()
state.indexed_elements = elements
return state
def test_click_target_semantics_from_pre_action_frame():
executor = _make_executor()
# Normalized (500, 600) on a 1080x2400 device -> pixel (540, 1440).
state = _state_with_elements(
[
{
"text": "Confirm",
"bounds": [500, 1400, 600, 1480],
"class": "android.widget.Button",
"resource_id": "btn_confirm",
"is_ocr": False,
}
]
)
semantics = executor._target_semantics("click", {"target": [500, 600]}, state)
assert semantics == {
"target_label_source": "hit_test",
"target_text": "Confirm",
"target_class": "android.widget.Button",
"target_resource_id": "btn_confirm",
}
def test_semantics_degrade_to_none_without_element_data():
executor = _make_executor()
# Empty perception data
semantics = executor._target_semantics(
"click", {"target": [500, 600]}, _state_with_elements([])
)
assert semantics == {"target_label_source": "none"}
# No state at all
semantics = executor._target_semantics("click", {"target": [500, 600]}, None)
assert semantics == {"target_label_source": "none"}
def test_non_targeted_actions_and_missing_targets_skip_enrichment():
executor = _make_executor()
state = _state_with_elements([{"text": "X", "bounds": [0, 0, 10, 10]}])
assert executor._target_semantics("press_key", {"key": "BACK"}, state) is None
# input_text without a coordinate target (focused field typing)
assert executor._target_semantics("input_text", {"text": "hi", "target": None}, state) is None
# Multi-point actions without resolvable points skip enrichment too.
assert executor._target_semantics("click_sequence", {"sequence": []}, state) is None
assert executor._target_semantics("swipe", {"start": None, "end": None}, state) is None
def test_click_sequence_hit_tests_every_point_first_point_is_main_label():
"""M5: multi-point actions get per-point best-effort semantics; the first
point's fields are hoisted as the action's main label."""
executor = _make_executor()
state = _state_with_elements(
[
{
"text": "Digit 1",
"bounds": [500, 1400, 600, 1480],
"class": "android.widget.Button",
"is_ocr": False,
},
{
"text": "Digit 2",
"bounds": [700, 1400, 800, 1480],
"class": "android.widget.Button",
"is_ocr": False,
},
]
)
# Normalized points -> pixels: (500,600)->(540,1440) hits Digit 1;
# (690,600)->(745,1440) hits Digit 2; (10,10)->(10,24) hits nothing.
semantics = executor._target_semantics(
"click_sequence",
{"sequence": [[500, 600], [690, 600], [10, 10]], "delay_ms": 50},
state,
)
assert semantics["target_text"] == "Digit 1"
assert semantics["target_label_source"] == "hit_test"
per_point = semantics["points_semantics"]
assert len(per_point) == 3
assert per_point[0]["target_text"] == "Digit 1"
assert per_point[1]["target_text"] == "Digit 2"
assert per_point[2] == {"target_label_source": "none"}
def test_swipe_hit_tests_start_and_end_start_is_main_label():
executor = _make_executor()
state = _state_with_elements(
[
{
"text": "Brightness slider",
"bounds": [100, 1400, 900, 1480],
"class": "android.widget.SeekBar",
"is_ocr": False,
}
]
)
semantics = executor._target_semantics(
"swipe", {"start": [200, 600], "end": [800, 600], "duration_ms": 400}, state
)
assert semantics["target_text"] == "Brightness slider"
assert semantics["start_semantics"]["target_text"] == "Brightness slider"
assert semantics["end_semantics"]["target_text"] == "Brightness slider"
assert semantics["target_label_source"] == "hit_test"
def test_long_press_uses_ocr_fallback():
executor = _make_executor()
state = _state_with_elements(
[
{
"text": "OCR Word",
"bounds": [520, 1420, 560, 1460],
"class": None,
"resource_id": None,
"is_ocr": True,
}
]
)
semantics = executor._target_semantics("long_press", {"target": [500, 600]}, state)
assert semantics["target_label_source"] == "ocr"
assert semantics["target_text"] == "OCR Word"
+304
View File
@@ -0,0 +1,304 @@
# 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.
"""ContextPolicy table (M4): byte-exact parity with the eight historical call
sites, config overrides, and the chunk hand-off to the compiled view."""
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from artemis.memory.context_policy import (
CONTEXT_POLICIES,
DIGEST_LEDGER_MARKER_TEMPLATE,
build_history_for,
load_chunk_blocks,
resolve_policy,
)
from artemis.utils.task_tree import build_plan_and_history
HASH_A = "hash-a"
HASH_B = "hash-b"
TASK_PLAN = f"- [x] First milestone <!-- {HASH_A} -->\n- [/] Second milestone <!-- {HASH_B} -->"
def _step(number: int, subgoal_hash: str, summary: str | None = None) -> dict:
return {
"step_id": f"id-{number}",
"step_number": number,
"relative_time": f"{number * 10}.0s",
"summary": summary or f"Step {number} summary text.",
"action_taken": {"action": "click", "target_text": f"button {number}"},
"operator_raw_thinking": f"thinking {number}",
"last_execution_result": {"status": "success"},
"extra_metadata": {"subgoal_hash": subgoal_hash},
}
STEPS = [
_step(1, HASH_A),
_step(2, HASH_A),
_step(3, HASH_A),
_step(4, HASH_B),
_step(5, HASH_B),
_step(6, HASH_B),
_step(7, HASH_B),
]
# ---------------------------------------------------------------------------
# Golden parity: the policy table must reproduce every historical hard-coded
# call byte for byte (kwargs recorded verbatim from the 2026-09-01 call sites).
# ---------------------------------------------------------------------------
GOLDEN_CALLS = {
"operator": lambda: build_plan_and_history(
TASK_PLAN,
STEPS,
HASH_B,
last_n_detailed=1,
strict_milestone_pruning=True,
recent_window_size=3,
),
"operator_cold_start": lambda: build_plan_and_history(
TASK_PLAN,
STEPS,
HASH_B,
min_summaries=len(STEPS),
last_n_detailed=1,
),
"failure_analyzer": lambda: build_plan_and_history(
TASK_PLAN,
STEPS,
HASH_B,
last_n_detailed=1,
strict_milestone_pruning=True,
recent_window_size=3,
for_failure_analyzer=True,
),
"planner": lambda: build_plan_and_history(
"",
STEPS,
"default",
last_n_detailed=2,
strict_milestone_pruning=True,
recent_window_size=5,
),
"outputter": lambda: build_plan_and_history(
TASK_PLAN,
STEPS,
HASH_B,
last_n_detailed=0,
min_summaries=len(STEPS),
strict_milestone_pruning=False,
),
"history_analyzer": lambda: build_plan_and_history(
TASK_PLAN,
STEPS,
HASH_B,
last_n_detailed=1,
min_summaries=len(STEPS),
),
"diagnoser": lambda: build_plan_and_history(
TASK_PLAN,
STEPS,
HASH_B,
keep_subgoal_hashes={HASH_B},
),
"committee": lambda: build_plan_and_history(
TASK_PLAN,
STEPS,
HASH_B,
keep_subgoal_hashes={HASH_B},
last_n_detailed=3,
),
}
POLICY_CALLS = {
"operator": lambda: build_history_for(
"operator", TASK_PLAN, STEPS, HASH_B, last_n_detailed=1
),
"operator_cold_start": lambda: build_history_for(
"operator_cold_start", TASK_PLAN, STEPS, HASH_B
),
"failure_analyzer": lambda: build_history_for(
"failure_analyzer", TASK_PLAN, STEPS, HASH_B
),
"planner": lambda: build_history_for("planner", "", STEPS, "default"),
"outputter": lambda: build_history_for("outputter", TASK_PLAN, STEPS, HASH_B),
"history_analyzer": lambda: build_history_for(
"history_analyzer", TASK_PLAN, STEPS, HASH_B
),
"diagnoser": lambda: build_history_for(
"diagnoser", TASK_PLAN, STEPS, HASH_B, keep_subgoal_hashes={HASH_B}
),
"committee": lambda: build_history_for(
"committee", TASK_PLAN, STEPS, HASH_B, keep_subgoal_hashes={HASH_B}
),
}
@pytest.mark.parametrize("agent", sorted(GOLDEN_CALLS))
def test_policy_table_reproduces_call_site_bytes(agent):
assert POLICY_CALLS[agent]() == GOLDEN_CALLS[agent]()
def test_every_declared_policy_is_golden_tested():
assert set(GOLDEN_CALLS) == set(CONTEXT_POLICIES)
# ---------------------------------------------------------------------------
# Config overrides
# ---------------------------------------------------------------------------
def _config_with_policies(policies):
return SimpleNamespace(memory=SimpleNamespace(policies=policies))
def test_resolve_policy_applies_config_override():
cfg = _config_with_policies({"planner": {"last_n_detailed": 3, "bogus_field": 1}})
with patch("artemis.config.load_agent_config", return_value=cfg):
policy = resolve_policy("planner")
assert policy.last_n_detailed == 3
assert policy.strict_milestone_pruning is True # untouched fields survive
def test_resolve_policy_unknown_agent_raises():
with pytest.raises(KeyError):
resolve_policy("no_such_agent")
# ---------------------------------------------------------------------------
# Chunk hand-off (§10 decision 4)
# ---------------------------------------------------------------------------
RENDERED_CHUNK = (
"[Chunk 1 | Steps 1–3 | T+00:10 → T+00:30]\n"
"\n"
"① Synopsis & effects\n"
" What this segment was doing: warming up.\n"
"\n"
"② Compressed step summary\n"
" - Steps 1–3: opened the app and logged in\n"
"\n"
"③ Step action ledger\n"
"- Step 1 (T+00:10): click['button 1'] -> executed\n"
"- Step 2 (T+00:20): click['button 2'] -> executed\n"
"- Step 3 (T+00:30): click['button 3'] -> executed"
)
class _FakeEngine:
def __init__(self, rows):
self._rows = rows
def get_history_chunks(self, **kwargs):
return self._rows
def _chunk_row():
return SimpleNamespace(
start_step_number=1,
end_step_number=3,
rendered_text=RENDERED_CHUNK,
status="ready",
)
def _transcript_cfg(enabled: bool):
return SimpleNamespace(
memory=SimpleNamespace(
transcript=SimpleNamespace(enabled=enabled), policies={}
)
)
def test_flag_off_output_is_byte_identical_even_with_engine():
engine = _FakeEngine([_chunk_row()])
with patch("artemis.config.load_agent_config", return_value=_transcript_cfg(False)):
with_engine = build_history_for(
"outputter", TASK_PLAN, STEPS, HASH_B, engine=engine
)
without_engine = build_history_for("outputter", TASK_PLAN, STEPS, HASH_B)
assert with_engine == without_engine
assert "[Chunk 1" not in with_engine
def test_flag_on_full_view_renders_chunk_block_and_drops_covered_steps():
engine = _FakeEngine([_chunk_row()])
with patch("artemis.config.load_agent_config", return_value=_transcript_cfg(True)):
out = build_history_for("outputter", TASK_PLAN, STEPS, HASH_B, engine=engine)
# Full view: the whole three-band block including the ③ ledger.
assert "[Chunk 1 | Steps 1–3" in out
assert "③ Step action ledger" in out
assert "- Step 2 (T+00:20)" in out
# Covered per-step summary lines are replaced by the chunk block.
assert "*Step 2 (Start: 20.0s)" not in out
# Steps outside the chunk range still render.
assert "Step 5" in out
def test_flag_on_digest_view_replaces_ledger_with_recall_marker():
engine = _FakeEngine([_chunk_row()])
cfg = _transcript_cfg(True)
with patch("artemis.config.load_agent_config", return_value=cfg):
out = build_history_for(
"diagnoser",
TASK_PLAN,
STEPS,
HASH_B,
keep_subgoal_hashes={HASH_A, HASH_B},
engine=engine,
)
assert "[Chunk 1 | Steps 1–3" in out
assert "② Compressed step summary" in out
marker = DIGEST_LEDGER_MARKER_TEMPLATE.format(start=1, end=3)
assert marker in out
assert "- Step 2 (T+00:20)" not in out # digest carries no ③ rows
def test_chunk_view_none_never_loads_chunks():
engine = _FakeEngine([_chunk_row()])
with patch("artemis.config.load_agent_config", return_value=_transcript_cfg(True)):
assert load_chunk_blocks(engine, None) is None
# The cold-start policy declares chunk_view=None: even with an engine
# and the flag on, its output carries no chunk blocks (the restored
# F region must stay a pure step rendering).
out = build_history_for(
"operator_cold_start", TASK_PLAN, STEPS, HASH_B, engine=engine
)
assert CONTEXT_POLICIES["operator_cold_start"].chunk_view is None
assert "[Chunk 1" not in out
def test_most_recent_step_is_never_suppressed_by_a_chunk():
engine = _FakeEngine(
[
SimpleNamespace(
start_step_number=1,
end_step_number=7, # covers every step incl. the newest
rendered_text=RENDERED_CHUNK,
status="ready",
)
]
)
with patch("artemis.config.load_agent_config", return_value=_transcript_cfg(True)):
out = build_history_for("outputter", TASK_PLAN, STEPS, HASH_B, engine=engine)
# The most-recent step must keep its own (detailed) rendering.
assert "Step 7" in out
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,187 @@
# 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.
"""Unit tests for the shared StepMemoryService scheduling runtime (M1)."""
import asyncio
from unittest.mock import Mock
import pytest
from artemis.memory.step_memory import StepMemoryService
class _ScriptedService(StepMemoryService):
"""StepMemoryService with a scriptable attempt function and status log."""
def __init__(self, attempt_fn, **kwargs):
super().__init__(Mock(), **kwargs)
self._attempt_fn = attempt_fn
self.status_events: list[tuple] = []
async def _attempt(self, key):
return await self._attempt_fn(self, key)
def _on_status(self, key, status):
self.status_events.append((key, status))
@pytest.mark.asyncio
async def test_zero_blocking_submit_and_flush():
"""submit() returns immediately; flush() drains the background job."""
release = asyncio.Event()
async def attempt(svc, key):
await release.wait()
svc._summaries[key] = f"summary-for-{key}"
return True
service = _ScriptedService(attempt)
service.submit("step-1", {"step_number": 1})
# Zero-blocking: the job cannot have completed yet (its gate is closed).
assert service.is_pending("step-1")
assert not service.has_summary("step-1")
release.set()
await service.flush()
assert service.get_summary("step-1") == "summary-for-step-1"
assert not service.is_pending("step-1")
@pytest.mark.asyncio
async def test_bounded_retry_enters_failed_state():
"""A permanently failing job stops after 1 + retry_limit attempts."""
attempts = 0
async def attempt(svc, key):
nonlocal attempts
attempts += 1
return False
service = _ScriptedService(attempt, retry_limit=2)
service._retry_delays = (0.0,)
service.submit("step-x", {"step_number": 7})
await service.flush()
assert attempts == 3
assert service.has_failed("step-x")
assert not service.has_summary("step-x")
# The failed job still reads as pending (lossless semantics downstream).
assert service.is_pending("step-x")
assert service.status_events == [("step-x", "pending"), ("step-x", "failed")]
@pytest.mark.asyncio
async def test_step_id_keying_with_tool_call_alias():
"""Jobs keyed by step id remain queryable through tool_call_id aliases."""
async def attempt(svc, key):
svc._summaries[key] = "canonical summary"
return True
service = _ScriptedService(attempt)
service.submit("uuid-123", {"step_number": 4}, aliases=("tc-abc",))
await service.flush()
assert service.resolve_key("tc-abc") == "uuid-123"
assert service.has_job("tc-abc")
assert service.has_summary("tc-abc")
assert service.get_summary("tc-abc") == "canonical summary"
assert service.get_summary("uuid-123") == "canonical summary"
assert service.get_job_payload("tc-abc") == {"step_number": 4}
assert not service.has_failed("tc-abc")
@pytest.mark.asyncio
async def test_max_concurrency_bounds_parallel_attempts():
"""With max_concurrency=1, two jobs never run their attempts concurrently."""
active = 0
max_active = 0
async def attempt(svc, key):
nonlocal active, max_active
active += 1
max_active = max(max_active, active)
await asyncio.sleep(0.01)
active -= 1
svc._summaries[key] = "done"
return True
service = _ScriptedService(attempt, max_concurrency=1)
service.submit("a", {"step_number": 1})
service.submit("b", {"step_number": 2})
await service.flush()
assert service.has_summary("a") and service.has_summary("b")
assert max_active == 1
@pytest.mark.asyncio
async def test_flush_timeout_cancels_stragglers():
"""flush() cancels jobs that outlive the timeout without failing them."""
async def attempt(svc, key):
await asyncio.sleep(30)
return True
service = _ScriptedService(attempt)
service.submit("slow", {"step_number": 1})
await service.flush(timeout_seconds=0.05)
assert service.is_pending("slow")
assert not service.has_failed("slow")
assert service._pending_tasks["slow"].done()
@pytest.mark.asyncio
async def test_flush_uses_configured_default_timeout():
"""flush() with no argument honors the constructor's flush_timeout_s."""
async def attempt(svc, key):
await asyncio.sleep(30)
return True
service = _ScriptedService(attempt, flush_timeout_s=0.05)
service.submit("slow", {"step_number": 1})
await asyncio.wait_for(service.flush(), timeout=5)
assert service._pending_tasks["slow"].done()
@pytest.mark.asyncio
async def test_resubmit_clears_failed_state():
"""Re-submitting a failed job gives it a fresh retry budget."""
behaviors = {"fail": True}
async def attempt(svc, key):
if behaviors["fail"]:
return False
svc._summaries[key] = "second time lucky"
return True
service = _ScriptedService(attempt, retry_limit=0)
service._retry_delays = (0.0,)
service.submit("job", {"step_number": 1})
await service.flush()
assert service.has_failed("job")
behaviors["fail"] = False
service._retry_counts.clear()
service.submit("job", {"step_number": 1})
await service.flush()
assert not service.has_failed("job")
assert service.get_summary("job") == "second time lucky"
+298
View File
@@ -0,0 +1,298 @@
# 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.
"""Unit tests for the Pro session transcript ledger (history redesign §3.2, M2).
Covers the four-region discipline: S-region byte stability across turns, the
append-only active region, depth-1 stripping of old UI lists and plan
recitations, the depth-K screenshot scrub in the Pro message shape (grace /
placeholder / freeze — the M1 race regressions re-run against HumanMessage
observations keyed by step id), ``T+mm:ss`` session-offset timestamps, the
tool-call/response pairing invariant, and the cold-start restored-history
block.
"""
import json
import re
import pytest
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from artemis.memory.step_memory import StepMemoryService
from artemis.memory.transcript import (
EXECUTION_RESULT_MARKER,
PLAN_RECITATION_MARKER,
PRO_UI_LIST_MARKER,
RESTORED_HISTORY_HEADER,
TranscriptLedger,
format_session_offset,
)
OFFSET_RE = re.compile(r"T\+\d{2,}:\d{2}")
def _service() -> StepMemoryService:
return StepMemoryService(ctx=None)
def _observation(i: int) -> HumanMessage:
return HumanMessage(
content=[
{"type": "text", "text": f"# CURRENT OBSERVATION [T+00:0{i % 10}]"},
{"type": "text", "text": f"{PLAN_RECITATION_MARKER}\n- [/] milestone {i}"},
{"type": "text", "text": "--- Current Screenshot ---"},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,IMG_{i}"}},
{"type": "text", "text": f"{PRO_UI_LIST_MARKER}\n[1] button {i}"},
]
)
def _turn(i: int, tool_call_count: int = 1) -> list:
messages: list = [_observation(i)]
if tool_call_count:
tool_calls = [
{"name": "click", "args": {"target": n + 1}, "id": f"tc{i}-{n}", "type": "tool_call"}
for n in range(tool_call_count)
]
messages.append(AIMessage(content=f"thinking {i}", tool_calls=tool_calls))
for tc in tool_calls:
messages.append(ToolMessage(tool_call_id=tc["id"], content="Action Recorded"))
return messages
def _play_turn(ledger: TranscriptLedger, i: int, *, prev_key=None, prev_result=None, **turn_kwargs):
"""Mimic the operator's real ordering: commit previous, render, stage."""
ledger.commit_staged(step_key=prev_key, validator_result=prev_result)
turn = _turn(i, **turn_kwargs)
rendered = ledger.render([turn[0]])
ledger.stage_turn(turn)
return rendered
def _fingerprint(msg) -> str:
return json.dumps(msg.content, sort_keys=True, default=str)
def _has_image(msg) -> bool:
return any(
isinstance(b, dict) and b.get("type") in ("image_url", "image") for b in msg.content
)
def test_format_session_offset():
assert format_session_offset(0) == "T+00:00"
assert format_session_offset(83) == "T+01:23"
# Minutes never wrap into hours: byte-stable monotonic labels.
assert format_session_offset(3725) == "T+62:05"
assert format_session_offset(-5) == "T+00:00"
def test_static_prefix_set_once_and_byte_stable():
ledger = TranscriptLedger(step_memory=_service())
system = SystemMessage(content="STATIC SYSTEM PROMPT")
ledger.set_static_prefix([system])
with pytest.raises(RuntimeError):
ledger.set_static_prefix([SystemMessage(content="other")])
fingerprints = set()
for i in range(1, 6):
rendered = _play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None)
assert rendered[0] is system
fingerprints.add(rendered[0].content)
assert fingerprints == {"STATIC SYSTEM PROMPT"}
def test_active_region_is_append_only():
ledger = TranscriptLedger(step_memory=_service())
seen_ids: list[int] = []
for i in range(1, 5):
_play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None)
active = ledger.active_messages
# The previously observed prefix is unchanged in identity and order.
assert [id(m) for m in active[: len(seen_ids)]] == seen_ids
seen_ids = [id(m) for m in active]
assert ledger.turn_count == 3 # 3 committed, 1 still staged
def test_validator_result_message_carries_session_offset():
ledger = TranscriptLedger(step_memory=_service())
_play_turn(ledger, 1)
_play_turn(
ledger,
2,
prev_key="step-1",
prev_result={"status": "success", "execution": [{"action": "tap", "attempts": ["ok"]}]},
)
result_messages = [
m
for m in ledger.active_messages
if isinstance(m, HumanMessage)
and str(m.content).find(EXECUTION_RESULT_MARKER) >= 0
]
assert len(result_messages) == 1
text = result_messages[0].content[0]["text"]
assert OFFSET_RE.search(text), text
assert "Status: success" in text
assert "ago" not in text
def test_validator_result_skipped_for_actionless_turn():
ledger = TranscriptLedger(step_memory=_service())
_play_turn(ledger, 1)
_play_turn(ledger, 2, prev_key="step-1", prev_result=None)
assert not any(
EXECUTION_RESULT_MARKER in str(m.content) for m in ledger.active_messages
)
def test_old_turn_recitation_and_ui_list_stripped_at_depth_1():
ledger = TranscriptLedger(step_memory=_service())
_play_turn(ledger, 1)
rendered = _play_turn(ledger, 2, prev_key="step-1")
committed_obs = ledger.active_messages[0]
committed_text = str(committed_obs.content)
# Depth-1 scrub: the previous turn's plan recitation and UI list are gone.
assert PLAN_RECITATION_MARKER not in committed_text
assert PRO_UI_LIST_MARKER not in committed_text
# Its screenshot is still inside the K-depth active window.
assert _has_image(committed_obs)
# The live tail keeps both.
tail_text = str(rendered[-1].content)
assert PLAN_RECITATION_MARKER in tail_text
assert PRO_UI_LIST_MARKER in tail_text
def test_depth_k_image_resolved_to_ready_visual_summary():
service = _service()
service._step_inputs["step-1"] = {"step_number": 1}
service._summaries["step-1"] = "Objective visual transition 1."
ledger = TranscriptLedger(step_memory=service, image_scrub_depth=3)
for i in range(1, 5):
_play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None)
first_obs = ledger.active_messages[0]
assert not _has_image(first_obs)
assert {
"type": "text",
"text": "--- Historical Visual Transition ---\nObjective visual transition 1.",
} in first_obs.content
def test_pending_grace_then_placeholder_never_backfilled():
service = _service()
service._step_inputs["step-1"] = {"step_number": 1} # pending job, no summary
ledger = TranscriptLedger(
step_memory=service, image_scrub_depth=2, pending_grace_steps=1
)
# Enough turns to push turn 1 past K + grace.
for i in range(1, 7):
_play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None)
first_obs = ledger.active_messages[0]
assert not _has_image(first_obs)
assert "[visual summary pending; evidence at DataEngine step 1]" in str(
first_obs.content
)
frozen = _fingerprint(first_obs)
# A late summary must never mutate the frozen message.
service._summaries["step-1"] = "Too late."
_play_turn(ledger, 7, prev_key="step-6")
assert _fingerprint(ledger.active_messages[0]) == frozen
def test_failed_summary_becomes_unavailable_placeholder():
service = _service()
service._step_inputs["step-1"] = {"step_number": 1}
service._failed.add("step-1")
ledger = TranscriptLedger(
step_memory=service, image_scrub_depth=2, pending_grace_steps=5
)
for i in range(1, 4):
_play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None)
first_obs = ledger.active_messages[0]
assert not _has_image(first_obs)
assert "[visual summary unavailable; evidence at DataEngine step 1]" in str(
first_obs.content
)
def test_tool_call_response_pairs_are_never_split():
ledger = TranscriptLedger(step_memory=_service(), image_scrub_depth=2)
for i in range(1, 7):
_play_turn(
ledger,
i,
prev_key=f"step-{i - 1}" if i > 1 else None,
prev_result={"status": "success"} if i > 1 else None,
tool_call_count=2,
)
active = list(ledger.active_messages)
for idx, msg in enumerate(active):
if isinstance(msg, AIMessage) and msg.tool_calls:
following = active[idx + 1 : idx + 1 + len(msg.tool_calls)]
assert [getattr(m, "tool_call_id", None) for m in following] == [
tc["id"] for tc in msg.tool_calls
], f"tool-call pairing split at active index {idx}"
def test_restored_history_only_seeds_an_empty_ledger():
ledger = TranscriptLedger(step_memory=_service())
ledger.set_restored_history(f"{RESTORED_HISTORY_HEADER} steps 1-9 ...")
assert ledger.has_restored_history
with pytest.raises(RuntimeError):
ledger.set_restored_history("again")
ledger.set_static_prefix([SystemMessage(content="S")])
rendered = _play_turn(ledger, 1)
# Order: S region, then the frozen restored block, then the live tail.
assert rendered[0].content == "S"
assert RESTORED_HISTORY_HEADER in str(rendered[1].content)
assert "# CURRENT OBSERVATION" in str(rendered[-1].content)
ledger2 = TranscriptLedger(step_memory=_service())
_play_turn(ledger2, 1)
ledger2.commit_staged(step_key="step-1")
with pytest.raises(RuntimeError):
ledger2.set_restored_history("late")
def test_stage_twice_commits_the_forgotten_turn():
ledger = TranscriptLedger(step_memory=_service())
ledger.stage_turn(_turn(1))
ledger.stage_turn(_turn(2))
assert ledger.turn_count == 1
assert ledger.has_staged_turn
def test_no_ago_wording_in_ledger_output():
ledger = TranscriptLedger(step_memory=_service())
for i in range(1, 4):
_play_turn(
ledger,
i,
prev_key=f"step-{i - 1}" if i > 1 else None,
prev_result={"status": "success"} if i > 1 else None,
)
blob = " ".join(str(m.content) for m in ledger.active_messages)
assert " ago" not in blob
+134
View File
@@ -0,0 +1,134 @@
# 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.
"""Unit tests for the agent.memory configuration block (M1/M2)."""
from artemis.config import AgentGlobalConfig
def test_memory_defaults():
cfg = AgentGlobalConfig.model_validate({})
# 2 per the 2026-09-01 on-device baseline (serial queue at 1 pushed
# summary-ready P90 past the scrub-edge grace window).
assert cfg.memory.runtime.max_concurrency == 2
assert cfg.memory.runtime.retry_limit == 3
assert cfg.memory.runtime.flush_timeout_s == 30.0
# M5 (2026-09-01): the transcript path ships ON by default; explicit
# false is the byte-for-byte rollback switch (see the flip test below).
assert cfg.memory.transcript.enabled is True
assert cfg.memory.transcript.image_scrub_depth == 3
assert cfg.memory.transcript.pending_grace_steps == 3
assert cfg.memory.transcript.xml_scrub_depth == 1
def test_memory_transcript_flag_explicit_disable_is_rollback_switch():
cfg = AgentGlobalConfig.model_validate(
{"memory": {"transcript": {"enabled": False}}}
)
assert cfg.memory.transcript.enabled is False
# Sibling scrub defaults are unaffected by flipping the flag.
assert cfg.memory.transcript.image_scrub_depth == 3
def test_memory_explicit_values():
cfg = AgentGlobalConfig.model_validate(
{
"memory": {
"runtime": {"max_concurrency": 4, "retry_limit": 5, "flush_timeout_s": 10},
"transcript": {
"image_scrub_depth": 5,
"pending_grace_steps": 1,
"xml_scrub_depth": 2,
},
}
}
)
assert cfg.memory.runtime.max_concurrency == 4
assert cfg.memory.runtime.retry_limit == 5
assert cfg.memory.runtime.flush_timeout_s == 10.0
assert cfg.memory.transcript.image_scrub_depth == 5
assert cfg.memory.transcript.pending_grace_steps == 1
assert cfg.memory.transcript.xml_scrub_depth == 2
def test_legacy_step_summarizer_retry_limit_seeds_memory_runtime():
"""An explicit legacy flash.step_summarizer.retry_limit keeps working."""
cfg = AgentGlobalConfig.model_validate(
{"flash": {"step_summarizer": {"retry_limit": 7}}}
)
assert cfg.flash.step_summarizer.retry_limit == 7
assert cfg.memory.runtime.retry_limit == 7
def test_explicit_memory_runtime_wins_over_legacy_alias():
cfg = AgentGlobalConfig.model_validate(
{
"flash": {"step_summarizer": {"retry_limit": 7}},
"memory": {"runtime": {"retry_limit": 2}},
}
)
assert cfg.memory.runtime.retry_limit == 2
# The legacy key itself is untouched for back-compat readers.
assert cfg.flash.step_summarizer.retry_limit == 7
def test_legacy_default_does_not_override_memory_default():
"""A step_summarizer block without retry_limit must not seed anything."""
cfg = AgentGlobalConfig.model_validate(
{"flash": {"step_summarizer": {"enabled": True}}}
)
assert cfg.memory.runtime.retry_limit == 3
def test_recall_and_similarity_defaults():
"""M4 defaults: recall block on, bounded; similarity hint on. The distance
threshold was calibrated to 5 in M5 (2026-09-01 on-device dHash data:
same screen <=4, different screens >=7, valley at 5-6)."""
cfg = AgentGlobalConfig()
assert cfg.memory.recall.enabled is True
assert cfg.memory.recall.max_results == 5
assert cfg.memory.recall.max_text_tokens == 2000
assert cfg.memory.recall.max_image_steps == 1
assert cfg.memory.transcript.similarity_hint is True
assert cfg.memory.transcript.similarity_max_distance == 5
assert cfg.memory.policies == {}
def test_chunking_era_cap_follows_max_chunks_by_default():
"""M5: max_eras is an independent key; None (default) follows max_chunks."""
cfg = AgentGlobalConfig()
assert cfg.memory.chunking.max_chunks == 8
assert cfg.memory.chunking.max_eras is None
overridden = AgentGlobalConfig.model_validate(
{"memory": {"chunking": {"max_chunks": 12, "max_eras": 4}}}
)
assert overridden.memory.chunking.max_chunks == 12
assert overridden.memory.chunking.max_eras == 4
def test_recall_and_policies_overridable():
cfg = AgentGlobalConfig.model_validate(
{
"memory": {
"recall": {"enabled": False, "max_results": 3},
"transcript": {"similarity_hint": False},
"policies": {"planner": {"last_n_detailed": 3}},
}
}
)
assert cfg.memory.recall.enabled is False
assert cfg.memory.recall.max_results == 3
assert cfg.memory.transcript.similarity_hint is False
assert cfg.memory.policies == {"planner": {"last_n_detailed": 3}}
+173
View File
@@ -0,0 +1,173 @@
# 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.
"""Tests for the M0 session token metering pipeline (pure recording)."""
from unittest.mock import Mock
from uuid import uuid4
from langchain_core.messages import AIMessage
from artemis.services.token_meter import (
SessionTokenMeter,
extract_usage,
get_meter,
record_llm_usage,
)
def _msg_with_usage(prompt=1000, completion=50, cached=0):
usage = {
"input_tokens": prompt,
"output_tokens": completion,
"total_tokens": prompt + completion,
}
if cached:
usage["input_token_details"] = {"cache_read": cached}
return AIMessage(content="ok", usage_metadata=usage)
def test_extract_usage_from_usage_metadata():
usage = extract_usage(_msg_with_usage(prompt=1200, completion=80, cached=900))
assert usage == {
"prompt_tokens": 1200,
"completion_tokens": 80,
"total_tokens": 1280,
"cached_tokens": 900,
}
def test_extract_usage_from_response_metadata_fallback():
msg = AIMessage(content="ok")
msg.response_metadata = {
"usage_metadata": {
"prompt_token_count": 500,
"candidates_token_count": 25,
"total_token_count": 525,
"cached_content_token_count": 400,
}
}
usage = extract_usage(msg)
assert usage == {
"prompt_tokens": 500,
"completion_tokens": 25,
"total_tokens": 525,
"cached_tokens": 400,
}
def test_extract_usage_absent_returns_none():
assert extract_usage(AIMessage(content="no usage")) is None
assert extract_usage(object()) is None
def test_meter_accumulates_session_totals():
meter = SessionTokenMeter("s1")
meter.record({"prompt_tokens": 100, "completion_tokens": 10, "cached_tokens": 0})
snapshot = meter.record(
{"prompt_tokens": 300, "completion_tokens": 20, "cached_tokens": 250}
)
assert snapshot == {
"session_llm_calls": 2,
"session_prompt_tokens": 400,
"session_completion_tokens": 30,
"session_cached_tokens": 250,
"session_cache_hit_calls": 1,
}
assert meter.last_prompt_tokens == 300
def test_record_llm_usage_records_trace_with_context_base():
engine = Mock()
engine.current_session_id = uuid4()
engine.current_step_id = uuid4()
payload = record_llm_usage(
engine, _msg_with_usage(prompt=2000, completion=100, cached=1500), source="google:test"
)
assert payload is not None
assert payload["context_base_tokens"] == 2000
assert payload["cached_tokens"] == 1500
assert payload["source"] == "google:test"
engine.record_trace.assert_called_once()
kwargs = engine.record_trace.call_args.kwargs
assert kwargs["type"] == "llm_call"
assert kwargs["name"] == "llm_usage"
assert kwargs["step_id"] == engine.current_step_id
assert kwargs["payload"]["prompt_tokens"] == 2000
def test_record_llm_usage_accumulates_across_calls_same_session():
engine = Mock()
engine.current_session_id = uuid4()
engine.current_step_id = None
record_llm_usage(engine, _msg_with_usage(prompt=100, completion=5))
payload = record_llm_usage(engine, _msg_with_usage(prompt=250, completion=10, cached=90))
assert payload["session_llm_calls"] == 2
assert payload["session_prompt_tokens"] == 350
assert payload["session_cached_tokens"] == 90
assert payload["session_cache_hit_calls"] == 1
meter = get_meter(engine.current_session_id)
assert meter.last_prompt_tokens == 250
def test_record_llm_usage_never_raises_and_skips_gracefully():
# No engine
assert record_llm_usage(None, _msg_with_usage()) is None
# Engine without a session
engine = Mock()
engine.current_session_id = None
assert record_llm_usage(engine, _msg_with_usage()) is None
engine.record_trace.assert_not_called()
# Response without usage
engine2 = Mock()
engine2.current_session_id = uuid4()
assert record_llm_usage(engine2, AIMessage(content="nope")) is None
engine2.record_trace.assert_not_called()
# record_trace blowing up must not propagate
engine3 = Mock()
engine3.current_session_id = uuid4()
engine3.record_trace.side_effect = RuntimeError("boom")
assert record_llm_usage(engine3, _msg_with_usage()) is None
def test_record_llm_usage_lens_calls_do_not_touch_last_prompt_tokens():
"""Background lens calls accumulate totals but never overwrite the live
context base (last_prompt_tokens) consumed by the compaction thresholds."""
engine = Mock()
engine.current_session_id = uuid4()
engine.current_step_id = None
record_llm_usage(engine, _msg_with_usage(prompt=15000))
meter = get_meter(engine.current_session_id)
assert meter.last_prompt_tokens == 15000
payload = record_llm_usage(
engine,
_msg_with_usage(prompt=300, completion=40),
source="lens:visual_transition:test-model",
update_last_prompt=False,
)
assert payload["source"] == "lens:visual_transition:test-model"
assert meter.last_prompt_tokens == 15000 # untouched by the lens call
assert meter.llm_calls == 2
assert meter.prompt_tokens == 15300
+265
View File
@@ -0,0 +1,265 @@
# 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.
"""recall_history boundaries and search correctness (M4, design §10).
Hard boundaries under test: bounded result count, bounded response tokens,
image recall limited to the real stored screenshots of a single step, and
every result carrying a step number. Plus: keyword/step-range/notes filter
correctness and the era-marker re-entry (a step_range returns the full-width
``build_action_ledger`` rows for that range).
"""
import io
import re
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from PIL import Image
from artemis.context import ArtemisContext
from artemis.data_engine.engine import DataEngine
from artemis.tools.history_recall import (
RecallHistoryTool,
recall_history,
search_history,
)
from artemis.utils.notes import save_note_content
def _jpeg(color: str) -> bytes:
buf = io.BytesIO()
Image.new("RGB", (64, 128), color).save(buf, format="JPEG")
return buf.getvalue()
def _make_engine(tmp_path):
mock_ctx = MagicMock(spec=ArtemisContext)
mock_execution_setup = MagicMock()
mock_execution_setup.traces_path = str(tmp_path)
mock_ctx.execution_setup = mock_execution_setup
mock_ctx.device = None
engine = DataEngine(mock_ctx)
engine.start_session("recall test session")
return engine
def _flush(engine):
for t in list(engine._pending_threads):
t.join()
def _cfg(**overrides):
values = {"enabled": True, "max_results": 5, "max_text_tokens": 2000, "max_image_steps": 1}
values.update(overrides)
return SimpleNamespace(**values)
@pytest.fixture
def engine(tmp_path):
engine = _make_engine(tmp_path)
engine.record_step(
pre_screenshot_bytes=_jpeg("red"),
summary="Opened the login page.",
action_taken={"action": "click", "target_text": "Login entry"},
last_execution_result={"status": "success"},
)
engine.record_step(
pre_screenshot_bytes=_jpeg("green"),
post_screenshot_bytes=_jpeg("blue"),
summary="Typed the verification code 4711 into the input.",
action_taken={"action": "input_text", "text": "4711"},
last_execution_result={"status": "success"},
operator_raw_thinking="The code arrived via SMS; entering 4711 now.",
)
engine.record_step(
summary="A promo popup appeared and was dismissed.",
action_taken={"action": "click", "target_text": "Close popup"},
last_execution_result={"status": "failed", "error": "element vanished"},
)
_flush(engine)
return engine
def _result_blocks(text: str) -> list[str]:
return [b for b in text.split("\n") if b.startswith("[")]
def test_keyword_search_finds_step_and_carries_step_number(engine):
out = search_history(engine, query="verification code", recall_config=_cfg())
assert isinstance(out, str)
assert "Step 2" in out
assert "4711" in out
# Every result header names a step / step range / step anchor.
for header in _result_blocks(out):
assert re.search(r"Step[s]? \d", header), header
def test_step_range_filters_matches(engine):
out = search_history(
engine, query="popup", step_range=[1, 2], recall_config=_cfg()
)
# Step 3 (the popup step) is outside the range: only the range ledger and
# a no-match line may appear.
assert "A promo popup appeared" not in out
assert "No matches" in out
def test_step_range_returns_full_width_ledger_rows(engine):
"""The recall-only era period paragraph's '(Step-level ledger via
recall_history for steps a–b)' re-entry point."""
out = search_history(engine, query="", step_range=[1, 3], recall_config=_cfg())
assert "Action ledger for Steps 1–3" in out
# Full-width build_action_ledger rows: step number + T+mm:ss offset +
# semantic action + result phrase.
assert re.search(r"- Step 1 \(T\+\d{2}:\d{2}\): .*Login entry", out)
assert re.search(r"- Step 2 \(T\+\d{2}:\d{2}\): ", out)
assert re.search(r"- Step 3 \(T\+\d{2}:\d{2}\): ", out)
def test_result_count_is_clamped_by_config(engine):
for i in range(8):
engine.record_step(
summary=f"Scrolled the anchorterm feed page {i}.",
action_taken={"action": "swipe"},
last_execution_result={"status": "success"},
)
_flush(engine)
out = search_history(
engine, query="anchorterm", max_results=50, recall_config=_cfg(max_results=3)
)
assert out.count("[Step ") == 3
assert "more not shown" in out
def test_response_is_truncated_at_token_budget(engine):
engine.record_step(
summary="needleword " + "filler content " * 400,
action_taken={"action": "click"},
last_execution_result={"status": "success"},
)
_flush(engine)
out = search_history(
engine,
query="needleword",
include_details=True,
recall_config=_cfg(max_text_tokens=150),
)
assert len(out) <= 150 * 4 + 200 # budget + truncation notice
assert "truncated at the recall token budget" in out
def test_notes_are_searchable_and_anchored_to_a_step(engine):
save_note_content(engine.base_dir, "login_flow", "The OTP entry lives behind the SMS tab.")
out = search_history(engine, query="OTP entry", recall_config=_cfg())
assert "[Note 'login_flow'" in out
assert re.search(r"as of Step \d|last written at Step \d", out)
def test_chunk_rows_are_searchable(engine):
engine.record_history_chunk(
start_step_number=1,
end_step_number=2,
version=1,
status="ready",
band2=" - Steps 1–2: walked the chunkneedle flow",
band3="- Step 1 (T+00:01): click -> executed",
rendered_text=(
"[Chunk 1 | Steps 1–2]\n② Compressed step summary\n"
" - Steps 1–2: walked the chunkneedle flow\n"
"③ Step action ledger\n- Step 1 (T+00:01): click -> executed"
),
)
_flush(engine)
out = search_history(engine, query="chunkneedle", recall_config=_cfg())
assert "[History chunk | Steps 1–2" in out
def test_images_only_for_a_single_step_and_only_real_files(engine):
out = search_history(
engine,
query="verification code",
include_images=True,
recall_config=_cfg(),
)
assert isinstance(out, list)
images = [b for b in out if b.get("type") == "image_url"]
# Step 2 has a real pre and post screenshot on disk — but never more than
# one step's worth of images.
assert 1 <= len(images) <= 2
labels = [b["text"] for b in out if b.get("type") == "text" and "screenshot of Step" in b.get("text", "")]
assert all("Step 2" in label for label in labels)
for block in images:
assert block["image_url"]["url"].startswith("data:image/jpeg;base64,")
def test_images_disabled_by_config_cap(engine):
out = search_history(
engine,
query="verification code",
include_images=True,
recall_config=_cfg(max_image_steps=0),
)
assert isinstance(out, str)
def test_missing_image_files_yield_note_not_blocks(engine):
# Step 3 recorded no screenshots at all.
out = search_history(
engine,
query="promo popup",
include_images=True,
recall_config=_cfg(),
)
assert isinstance(out, list)
assert not [b for b in out if b.get("type") == "image_url"]
assert any(
"no stored screenshots exist" in b.get("text", "") for b in out if b.get("type") == "text"
)
@pytest.mark.asyncio
async def test_execute_requires_query_or_range(engine):
tool = RecallHistoryTool()
ctx = SimpleNamespace(data_engine=engine)
out = await tool.execute(ctx=ctx, query="", step_range=None)
assert "needs a query and/or a step_range" in out
@pytest.mark.asyncio
async def test_execute_without_engine_degrades():
tool = RecallHistoryTool()
out = await tool.execute(ctx=SimpleNamespace(data_engine=None), query="x")
assert "no active DataEngine session" in out
def test_availability_requires_engine_and_config():
assert recall_history.is_available(None) is False
assert recall_history.is_available(SimpleNamespace(data_engine=None)) is False
assert recall_history.is_available(SimpleNamespace(data_engine=object())) is True
def test_foreground_app_stamp_joins_search_surface(tmp_path):
"""M5: a package-name query hits the record_step foreground_app stamp."""
engine = _make_engine(tmp_path)
engine.record_step(
summary="Opened the alarms list.",
action_taken={"action": "click", "target_text": "Alarm"},
ui_tree=[{"package": "com.google.android.deskclock", "bounds": "[0,0][9,9]"}],
)
_flush(engine)
out = search_history(engine, query="deskclock", recall_config=_cfg())
assert "Step 1" in out
assert "No matches" not in out
+116
View File
@@ -0,0 +1,116 @@
# 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.
"""Tests for record-time action semantics enrichment (element hit testing)."""
from artemis.utils.element_hit_test import find_element_at_point, hit_test_semantics
def _xml_el(text, bounds, resource_id=None, cls="android.widget.TextView"):
return {
"text": text,
"bounds": bounds,
"class": cls,
"resource_id": resource_id,
"is_ocr": False,
}
def test_smallest_covering_element_wins():
elements = [
_xml_el("Whole screen", [0, 0, 1080, 2400]),
_xml_el("Card", [100, 100, 500, 500]),
_xml_el("Button", [200, 200, 300, 260], resource_id="btn_ok"),
]
el, source = find_element_at_point(elements, 250, 230)
assert source == "hit_test"
assert el["text"] == "Button"
semantics = hit_test_semantics(elements, 250, 230)
assert semantics == {
"target_label_source": "hit_test",
"target_text": "Button",
"target_class": "android.widget.TextView",
"target_resource_id": "btn_ok",
}
def test_xml_element_preferred_over_smaller_ocr_element():
elements = [
_xml_el("Login", [100, 100, 500, 300]),
{
"text": "Login OCR",
"bounds": [200, 150, 260, 180],
"class": None,
"resource_id": None,
"is_ocr": True,
},
]
el, source = find_element_at_point(elements, 220, 160)
assert source == "hit_test"
assert el["text"] == "Login"
def test_ocr_fallback_when_no_xml_element_covers_point():
elements = [
_xml_el("Elsewhere", [800, 800, 900, 900]),
{
"text": "OCR Label",
"bounds": [100, 100, 300, 200],
"class": None,
"resource_id": None,
"is_ocr": True,
},
]
semantics = hit_test_semantics(elements, 150, 150)
assert semantics["target_label_source"] == "ocr"
assert semantics["target_text"] == "OCR Label"
assert "target_class" not in semantics
assert "target_resource_id" not in semantics
def test_string_bounds_are_parsed():
elements = [_xml_el("Item", "[100,100][300,500]")]
el, source = find_element_at_point(elements, 200, 300)
assert source == "hit_test"
assert el["text"] == "Item"
def test_graceful_degradation_to_none():
# No elements at all
assert hit_test_semantics(None, 100, 100) == {"target_label_source": "none"}
assert hit_test_semantics([], 100, 100) == {"target_label_source": "none"}
# Point outside every element
elements = [_xml_el("Item", [0, 0, 50, 50])]
assert hit_test_semantics(elements, 500, 500) == {"target_label_source": "none"}
# Malformed element data must not raise
malformed = [
{"text": "no bounds"},
{"text": "bad bounds", "bounds": "garbage"},
{"text": "short bounds", "bounds": [1, 2]},
{"text": "non-numeric", "bounds": ["a", "b", "c", "d"]},
"not-a-dict",
None,
]
assert hit_test_semantics(malformed, 10, 10) == {"target_label_source": "none"}
def test_element_without_optional_fields_only_reports_source():
elements = [
{"text": "", "bounds": [0, 0, 100, 100], "class": None, "resource_id": None}
]
semantics = hit_test_semantics(elements, 50, 50)
assert semantics == {"target_label_source": "hit_test"}
+81
View File
@@ -0,0 +1,81 @@
# 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.
"""Perceptual-hash stability and distance behavior (M4 similarity hint)."""
import io
from PIL import Image, ImageDraw
from artemis.utils.image_hash import HASH_SIZE, dhash_hex, hamming_distance_hex
def _screen(draw_fn, size=(270, 600), quality=85) -> bytes:
img = Image.new("RGB", size, "white")
draw_fn(ImageDraw.Draw(img))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=quality)
return buf.getvalue()
def _login_screen(d):
d.rectangle([20, 40, 250, 90], fill="#3355ff")
d.rectangle([30, 200, 240, 250], outline="#333333", width=4)
d.rectangle([30, 300, 240, 350], outline="#333333", width=4)
d.rectangle([60, 450, 210, 510], fill="#22aa66")
def _settings_screen(d):
for y in range(60, 560, 80):
d.rectangle([10, y, 260, y + 50], fill="#dddddd")
d.ellipse([200, 20, 250, 45], fill="#aa2222")
def test_dhash_is_stable_for_identical_bytes():
img = _screen(_login_screen)
assert dhash_hex(img) == dhash_hex(img)
assert len(dhash_hex(img)) == HASH_SIZE * HASH_SIZE // 4
def test_dhash_survives_jpeg_reencoding_with_small_distance():
original = _screen(_login_screen, quality=95)
with Image.open(io.BytesIO(original)) as img:
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=60)
reencoded = buf.getvalue()
distance = hamming_distance_hex(dhash_hex(original), dhash_hex(reencoded))
assert distance is not None and distance <= 4
def test_dhash_separates_different_screens():
distance = hamming_distance_hex(
dhash_hex(_screen(_login_screen)), dhash_hex(_screen(_settings_screen))
)
assert distance is not None and distance > 8
def test_dhash_best_effort_on_garbage():
assert dhash_hex(None) is None
assert dhash_hex(b"") is None
assert dhash_hex(b"not an image at all") is None
def test_hamming_distance_edge_cases():
assert hamming_distance_hex(None, "ff") is None
assert hamming_distance_hex("ff", None) is None
assert hamming_distance_hex("ff", "ffff") is None # length mismatch
assert hamming_distance_hex("zz", "aa") is None # not hex
assert hamming_distance_hex("00ff", "00ff") == 0
assert hamming_distance_hex("0000", "000f") == 4
+3 -7
View File
@@ -365,7 +365,7 @@ def test_build_plan_and_history_concatenates_thoughts():
assert " - Raw thought" in output
def test_build_plan_and_history_chronological_last_step():
def test_build_plan_and_history_interleaved_decision_loop():
plan = "- [ ] Subgoal 1"
steps = [
{
@@ -390,9 +390,7 @@ def test_build_plan_and_history_chronological_last_step():
],
}
]
output = build_plan_and_history(
plan, steps, "default", last_n_detailed=1, chronological_last_step=True
)
output = build_plan_and_history(plan, steps, "default", last_n_detailed=1)
assert plan in output
assert "--- Execution History ---" in output
@@ -464,9 +462,7 @@ def test_build_plan_and_history_safety_net_and_failure_analyzer():
],
}
]
output = build_plan_and_history(
plan, steps, "default", last_n_detailed=1, chronological_last_step=True
)
output = build_plan_and_history(plan, steps, "default", last_n_detailed=1)
assert "- **Step 1 (Most Recent Step, Start: 5.0s)**" in output
assert "* [Operator Decision Loop]:" in output