merge(memory): integrate incremental transcript compaction
This commit is contained in:
commit
0bcddbf1d7
65 files changed
+9971
-815
No files matched your search
@@ -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}")
|
||||
|
||||
@@ -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"])
|
||||
@@ -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:
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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]):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
# limitations under the License.
|
||||
|
||||
RECURSION_LIMIT = 30000
|
||||
MAX_MESSAGES_IN_HISTORY = 25
|
||||
|
||||
# Optimization Constants
|
||||
CHECKER_MAX_ITERATIONS = 20
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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": "..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."}]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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}}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user