feat(memory): add incremental transcript compaction

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

No files matched your search

+5 -3
View File
@@ -44,8 +44,8 @@ from artemis.tools.tool_wrapper import (
from artemis.tools.video_tool import get_video_analyzer_tool
from artemis.tools.wait_tool import get_wait_tool
from artemis.utils.logger import get_logger
from artemis.memory.context_policy import build_history_for
from artemis.utils.task_tree import (
build_plan_and_history,
get_active_subgoal_hashes,
get_recent_subgoal_hashes,
)
@@ -160,14 +160,16 @@ class Diagnoser:
keep_hashes = get_recent_subgoal_hashes(
steps,
subgoal_hash,
window_steps=self.ctx.agent_config.history_window_steps,
self.ctx.data_engine.base_dir,
)
plan_and_history = build_plan_and_history(
plan_and_history = build_history_for(
"diagnoser",
task_plan,
steps,
subgoal_hash,
keep_subgoal_hashes=keep_hashes,
engine=self.ctx.data_engine,
)
except Exception as e:
logger.error(f"Failed to build plan and history in Diagnoser: {e}")
+280 -108
View File
@@ -14,16 +14,24 @@
"""Universal Context Compressor for Artemis Flash profile.
Replaces historical screenshots with high-density visual step summaries only
after they are ready. Screenshots remain in context while summarization or
retry is still in flight.
:class:`ScrubEdgeCompressor` implements the scrub-edge (擦洗沿) discipline
from the history-module redesign §3.2: each message is mutated a bounded
number of times near the tail (XML stripped at depth 1, screenshot resolved
at depth K), then frozen forever. A late summary never backfills a frozen
message. FlashRunner uses this implementation; the Pro transcript ledger
reuses it over its active region (see the class docstring).
The legacy per-turn full-rescan ``compress_flash_messages`` was removed in M5
(2026-09-01) after serving as the M1/M2 semantic reference — its surviving
semantics are asserted directly against ``ScrubEdgeCompressor`` in
``tests/unit/agents/test_flash_scrub_edge.py``.
"""
from typing import Any
from typing import Any, Callable
from langchain_core.messages import BaseMessage
from artemis.agents.flash.summarizer import VisualStepSummarizer
from artemis.memory.step_memory import StepMemoryService
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@@ -32,134 +40,298 @@ _HISTORY_SUMMARY_PREFIX = "--- Historical Visual Transition ---\n"
_UI_LIST_MARKER = "--- UI Element List ---"
def _without_historical_ui_list(text: str) -> str:
"""Remove a historical UI list without discarding text before its marker."""
marker_index = text.find(_UI_LIST_MARKER)
def _without_marked_suffix(text: str, markers: tuple[str, ...] = (_UI_LIST_MARKER,)) -> str:
"""Remove a marked heavy block without discarding text before its marker."""
marker_index = -1
for marker in markers:
idx = text.find(marker)
if idx >= 0 and (marker_index < 0 or idx < marker_index):
marker_index = idx
if marker_index < 0:
return text
return text[:marker_index].rstrip()
def compress_flash_messages(
messages: list[BaseMessage],
summarizer: VisualStepSummarizer | None = None,
prune_history_xml: bool = True,
) -> None:
"""Compresses multi-turn conversation messages in-place.
class ScrubEdgeCompressor:
"""Scrub-edge context compressor (history-module redesign §3.2, M1).
Rules:
1. Keeps the latest screenshot and live UI hierarchy completely intact.
2. For earlier steps, if a VisualStepSummarizer has completed the step's summary,
replaces the intermediate image block with the objective summary text.
3. If the background summary is not ready yet, retains the original image until
a successful summary can replace it.
4. If prune_history_xml is True, removes heavy outdated UI Element lists from past turns.
Mutation discipline — every message is touched at most twice, always near
the tail, and is then frozen forever:
1. Depth 1 (XML): once a newer observation exists, historical UI Element
lists are stripped (same semantics as the legacy ``prune_history_xml``).
2. Depth K (image, ``image_scrub_depth``): the K-th most recent
image-bearing message has its screenshot resolved — replaced by the
ready visual summary; if the summary is still pending the image is
retained for up to ``pending_grace_steps`` further image-depths, after
which it is replaced by a pending placeholder. A failed summary is
replaced by an unavailable placeholder immediately. Both placeholders
carry the DataEngine step reference.
3. Frozen: after resolution the message is never read or written again —
a late summary never backfills a frozen message.
Contract: the message list is append-only (FlashRunner never removes or
reorders entries), so bookkeeping is index-based and each pass only
touches the few messages near the scrub edge instead of rescanning the
whole history.
Profile generalization (M2): the Pro transcript ledger reuses this exact
discipline over its active region. Its observation messages carry no
``tool_call_id``, so ``summary_key_getter`` supplies the summary-job key
(the DataEngine step id) per message; ``strip_markers`` extends the
depth-1 strip to the Pro tail blocks (UI element list + plan recitation);
and ``tail_offset=1`` accounts for the live observation living outside
the tracked list (the ledger renders it as a separate tail), so depth
arithmetic and the keep-window stay aligned with the Flash semantics
where the live observation is *inside* the list. Defaults leave the
Flash behavior byte-identical.
"""
if not messages:
return
# 1. Identify the last message containing an active screenshot
last_img_msg_idx = -1
for idx in range(len(messages) - 1, -1, -1):
msg = messages[idx]
msg_content = getattr(msg, "content", None)
if isinstance(msg_content, list):
has_image = any(
isinstance(block, dict) and block.get("type") in ("image_url", "image")
for block in msg_content
)
def __init__(
self,
summarizer: StepMemoryService | None = None,
*,
prune_history_xml: bool = True,
image_scrub_depth: int = 3,
pending_grace_steps: int = 3,
xml_scrub_depth: int = 1,
summary_key_getter: Callable[[BaseMessage], str | None] | None = None,
strip_markers: tuple[str, ...] = (_UI_LIST_MARKER,),
tail_offset: int = 0,
):
self._summarizer = summarizer
self._prune_history_xml = prune_history_xml
self._image_scrub_depth = max(1, image_scrub_depth)
self._pending_grace_steps = max(0, pending_grace_steps)
self._xml_scrub_depth = max(1, xml_scrub_depth)
self._summary_key_getter = summary_key_getter
self._strip_markers = tuple(strip_markers) or (_UI_LIST_MARKER,)
self._tail_offset = max(0, tail_offset)
self._scanned_until = 0
self._tool_msg_count = 0
# Append-only records of image-bearing messages, in message order:
# {"idx", "key" (str tool_call_id | None), "legacy" (ordinal | None), "is_tool"}
self._tracked: list[dict[str, Any]] = []
self._frozen: set[int] = set()
self._xml_candidates: list[int] = []
def compress(self, messages: list[BaseMessage]) -> None:
"""Advance the scrub edge over newly appended messages in-place."""
self._discover(messages)
if not self._tracked:
return
self._scrub_xml(messages)
self._scrub_images(messages)
# ------------------------------------------------------------------
# Discovery (incremental; frozen prefix is never rescanned)
# ------------------------------------------------------------------
def _discover(self, messages: list[BaseMessage]) -> None:
for idx in range(self._scanned_until, len(messages)):
msg = messages[idx]
tool_call_id = getattr(msg, "tool_call_id", None)
if tool_call_id is not None:
self._tool_msg_count += 1
content = getattr(msg, "content", None)
if not isinstance(content, list):
continue
has_image = False
has_xml = False
for block in content:
if not isinstance(block, dict):
continue
if block.get("type") in ("image_url", "image"):
has_image = True
elif block.get("type") == "text" and self._has_strip_marker(
block.get("text", "")
):
has_xml = True
if has_xml:
self._xml_candidates.append(idx)
if has_image:
last_img_msg_idx = idx
break
key = str(tool_call_id) if tool_call_id is not None else None
if key is None and self._summary_key_getter is not None:
try:
getter_key = self._summary_key_getter(msg)
except Exception:
getter_key = None
if getter_key is not None:
key = str(getter_key)
self._tracked.append(
{
"idx": idx,
"key": key,
"legacy": self._tool_msg_count if tool_call_id is not None else None,
"is_tool": tool_call_id is not None,
}
)
self._scanned_until = len(messages)
# 2. Iterate through historical messages and apply compression/fallback.
# ``tool_call_id`` is the stable identity of an action result. The legacy
# ordinal is retained only for summaries produced by older callers/tests.
tool_step_counter = 0
for idx in range(len(messages)):
msg = messages[idx]
msg_content = getattr(msg, "content", None)
if not isinstance(msg_content, list):
continue
def _has_strip_marker(self, text: str) -> bool:
return any(marker in text for marker in self._strip_markers)
# Keep the latest live screen intact
if idx == last_img_msg_idx:
continue
# ------------------------------------------------------------------
# Depth-1 XML scrub
# ------------------------------------------------------------------
# Count tool steps strictly corresponding to FlashRunner action turns
tool_call_id = getattr(msg, "tool_call_id", None)
is_tool_msg = tool_call_id is not None
if is_tool_msg:
tool_step_counter += 1
legacy_step_num = tool_step_counter
else:
legacy_step_num = None
summary_text = None
summary_pending = False
if summarizer and is_tool_msg:
# New FlashRunner instances key summaries by tool_call_id, so generic
# tools, no-tool turns, and parallel tool calls cannot shift memories.
action_key = str(tool_call_id)
summary_text = summarizer.get_summary(action_key)
summary_pending = summarizer.is_pending(action_key)
if (
summary_text is None
and not summarizer.has_job(action_key)
and legacy_step_num is not None
):
summary_text = summarizer.get_summary(legacy_step_num)
summary_pending = summarizer.is_pending(legacy_step_num)
def _scrub_xml(self, messages: list[BaseMessage]) -> None:
if not self._prune_history_xml:
return
# Keep the UI list only in the newest xml_scrub_depth observation
# messages; everything older (or non-image XML carriers) is stripped.
# With a live tail outside the tracked list (tail_offset > 0) the tail
# itself occupies the newest slots of the keep window.
keep_count = max(0, self._xml_scrub_depth - self._tail_offset)
keep_indices = (
{rec["idx"] for rec in self._tracked[-keep_count:]} if keep_count else set()
)
still_pending: list[int] = []
for idx in self._xml_candidates:
if idx in keep_indices:
still_pending.append(idx)
continue
if idx in self._frozen:
continue
self._strip_xml_blocks(messages[idx])
self._xml_candidates = still_pending
def _strip_xml_blocks(self, msg: BaseMessage) -> None:
content = getattr(msg, "content", None)
if not isinstance(content, list):
return
new_blocks: list[Any] = []
for b in msg_content:
if not isinstance(b, dict):
# LangChain permits string parts in multimodal content lists.
# They are lightweight historical evidence and must not vanish.
new_blocks.append(b)
continue
b_type = b.get("type", "")
b_text = b.get("text", "")
if b_type == "text" and b_text.startswith(_HISTORY_SUMMARY_PREFIX):
# Rebuild this generated block on every pass. This makes the
# compressor idempotent and lets a late summary replace itself.
continue
# Filter heavy outdated XML hierarchy from past steps to save tokens.
# Some adapters combine action text and the hierarchy in one block;
# retain the useful prefix instead of dropping the entire block.
if prune_history_xml and b_type == "text" and _UI_LIST_MARKER in b_text:
retained_text = _without_historical_ui_list(b_text)
for block in content:
if (
isinstance(block, dict)
and block.get("type") == "text"
and self._has_strip_marker(block.get("text", ""))
):
retained_text = _without_marked_suffix(
block.get("text", ""), self._strip_markers
)
if retained_text:
retained_block = dict(b)
retained_block = dict(block)
retained_block["text"] = retained_text
new_blocks.append(retained_block)
continue
new_blocks.append(block)
msg.content = new_blocks
if b_type in ("image_url", "image"):
# A submitted image remains visible until its summary is ready.
# This temporarily favors context size over losing visual evidence.
if summary_pending:
new_blocks.append(b)
# ------------------------------------------------------------------
# Depth-K image scrub
# ------------------------------------------------------------------
def _scrub_images(self, messages: list[BaseMessage]) -> None:
total = len(self._tracked)
scrub_depth = self._image_scrub_depth
grace_limit = scrub_depth + self._pending_grace_steps
for pos, rec in enumerate(self._tracked):
if rec["idx"] in self._frozen:
continue
# 1 == most recent image-bearing message; a live tail outside the
# tracked list (tail_offset > 0) counts as the newest depths.
depth = total - pos + self._tail_offset
if depth < scrub_depth:
continue # active window
if self._tail_offset == 0 and pos == total - 1:
continue # the live observation is never scrubbed
new_blocks.append(b)
# Do not tie injection to the presence of an image: the image is normally
# pruned before the background summary finishes. A stable tool-call key lets
# a later compression pass backfill the summary into the correct result.
if summary_text:
new_blocks.append(
{
summary, pending, failed, step_no = self._lookup(rec)
if summary is not None:
replacement = {
"type": "text",
"text": f"{_HISTORY_SUMMARY_PREFIX}{summary_text}",
"text": f"{_HISTORY_SUMMARY_PREFIX}{summary}",
}
)
elif failed:
replacement = {
"type": "text",
"text": f"[visual summary unavailable; evidence at DataEngine step {step_no}]",
}
elif pending and depth <= grace_limit:
continue # grace period: retain the image, revisit next pass
elif pending:
replacement = {
"type": "text",
"text": f"[visual summary pending; evidence at DataEngine step {step_no}]",
}
else:
replacement = None # no summary job exists; drop the image silently
self._resolve(messages, rec, replacement)
def _lookup(self, rec: dict[str, Any]) -> tuple[str | None, bool, bool, int | None]:
"""Mirror the legacy keying: tool_call_id first, ordinal fallback."""
summarizer = self._summarizer
if summarizer is None:
return None, False, False, None
effective_key: Any = rec["key"]
if effective_key is None:
return None, False, False, None
summary = summarizer.get_summary(effective_key)
if (
summary is None
and not summarizer.has_job(effective_key)
and rec["legacy"] is not None
):
effective_key = rec["legacy"]
summary = summarizer.get_summary(effective_key)
failed = summarizer.has_failed(effective_key)
pending = summarizer.is_pending(effective_key) and not failed
step_no = summarizer.get_step_number(effective_key)
if step_no is None:
step_no = rec["legacy"]
return summary, pending, failed, step_no
def _resolve(
self,
messages: list[BaseMessage],
rec: dict[str, Any],
replacement: dict[str, Any] | None,
) -> None:
"""Replace the message's image blocks and freeze it permanently."""
msg = messages[rec["idx"]]
content = getattr(msg, "content", None)
new_blocks: list[Any] = []
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") in ("image_url", "image"):
continue
if (
self._prune_history_xml
and isinstance(block, dict)
and block.get("type") == "text"
and self._has_strip_marker(block.get("text", ""))
):
# Freezing implies fully scrubbed: no stale UI list survives
# even when xml_scrub_depth exceeds the image depth.
retained_text = _without_marked_suffix(
block.get("text", ""), self._strip_markers
)
if retained_text:
retained_block = dict(block)
retained_block["text"] = retained_text
new_blocks.append(retained_block)
continue
new_blocks.append(block)
if replacement is not None:
new_blocks.append(replacement)
# Defensive fallback: never leave ToolMessage or HumanMessage content empty
if not new_blocks:
new_blocks = [{"type": "text", "text": "Action completed." if is_tool_msg else ""}]
new_blocks = [
{"type": "text", "text": "Action completed." if rec["is_tool"] else ""}
]
msg.content = new_blocks
self._frozen.add(rec["idx"])
if rec["idx"] in self._xml_candidates:
self._xml_candidates.remove(rec["idx"])
+1 -1
View File
@@ -6,7 +6,7 @@ You are the Step Summarizer for an Android UI automation agent. Your task is to
# PERSPECTIVE & FORMAT CONSTRAINTS
1. **First-Person Perspective**: Write strictly from the agent's first-person perspective using **"I"** (e.g., "In Step {{ step_number }}, I tapped...", "I swiped up on... and observed..."). NEVER use third-person terms like "The agent", "The operator", or "The system".
2. **Single Continuous Paragraph**: Your output MUST be exactly **one compact, continuous paragraph** (1–3 sentences, 35–65 words).
3. **No Lists or Formatting**: Do NOT use bullet points, numbered lists, markdown headers, bold labels, or line breaks in your output.
3. **No Lists or Formatting**: Do NOT use bullet points, numbered lists, markdown headers, bold labels, or line breaks in your output. Your output must NEVER contain `---` separators or section-marker lines — those belong to the input, not the summary.
---
@@ -0,0 +1,42 @@
# ROLE & OBJECTIVE
You are the Step Summarizer for an Android UI automation agent. Exactly ONE screenshot is available for this step — the decision frame captured when the action was issued. There is NO after-action screenshot. Your task is to describe, strictly objectively and in first person, what THIS screen showed and where the physical action landed.
---
# SINGLE-FRAME EVIDENCE RULES
1. **Missing after-frame ≠ unchanged screen**: The absence of an after-action screenshot ONLY means there is no independent post-action evidence for this step. It does NOT mean the screen stayed the same, and it does NOT license any guess about what happened next.
2. **Describe only this frame**: Report the screen/page identity, the key visible content (titles, list entries, values, toggle states, prices, codes, error banners), and the control targeted by the action — referencing the red visual marker when one is present.
3. **Never synthesize a transition**: Do NOT describe, predict, or imply the post-action screen state. The step-to-step story is reconstructed from neighboring steps' summaries — not by you.
---
# PERSPECTIVE & FORMAT CONSTRAINTS
1. **First-Person Perspective**: Write strictly from the agent's first-person perspective using **"I"** (e.g., "In Step {{ step_number }}, I tapped... on a screen showing..."). NEVER use third-person terms like "The agent", "The operator", or "The system".
2. **Single Continuous Paragraph**: Your output MUST be exactly **one compact, continuous paragraph** (1–3 sentences, 35–65 words).
3. **No Lists or Formatting**: Do NOT use bullet points, numbered lists, markdown headers, bold labels, or line breaks. Your output must NEVER contain `---` separators or section-marker lines — those belong to the input, not the summary.
---
# CORE OBSERVATION RULES
1. **Zero Subjective Validation (STRICT)**:
- Absolutely avoid declaring semantic goal achievement, subjective success, or final failure. In historical compression, subjective assumptions cause future turns to falsely believe a subgoal was definitively accomplished or permanently blocked.
- **STRICTLY BANNED WORDS**: `successfully`, `completed`, `achieved`, `entered`, `navigated to`, `arrived at`, `failed`, `unsuccessful`, `could not`, `impossible`.
2. **Preserve Critical Data**: Faithfully transcribe visible text, toast alerts, error banners, verification codes, prices, account names, or tracking numbers that appear on this screen.
---
# EXAMPLES OF HIGH-QUALITY SINGLE-FRAME SUMMARIES
- **Click on a list row**:
"In Step 5, I tapped the 'Battery' row marked in red in the Settings list, which showed sections for 'Network & internet', 'Display', and 'Battery' with the battery level reading '82%'."
- **Text input**:
"In Step 8, I typed 'Tokyo Hotel' into the search field at the top of the Maps screen; before my input the field read 'Search here' and the keyboard occupied the lower half of the screen."
- **Swipe / Scroll**:
"In Step 3, I swiped up starting from the red-marked point on the flight results list, which at that moment displayed 4 morning flights priced from '$310' under the header 'Best departing flights'."
---
# TASK
Generate the single-paragraph first-person summary for Step {{ step_number }} following all constraints above:
+48 -10
View File
@@ -33,7 +33,7 @@ from langchain_core.messages import (
ToolMessage,
)
from artemis.agents.flash.context_compressor import compress_flash_messages
from artemis.agents.flash.context_compressor import ScrubEdgeCompressor
from artemis.agents.flash.summarizer import VisualStepSummarizer
from artemis.agents.validator.tool_declarations import (
ASK_EXPLORER_TOOL,
@@ -43,7 +43,12 @@ from artemis.agents.validator.tool_declarations import (
capture_screenshot_and_parse_ui,
prune_intermediate_screenshots,
)
from artemis.config import StepSummarizerConfig, load_agent_config
from artemis.config import (
MemoryRuntimeConfig,
MemoryTranscriptConfig,
StepSummarizerConfig,
load_agent_config,
)
from artemis.context import ArtemisContext
from artemis.controllers.unified_controller import UnifiedMobileController
from artemis.data_engine.trace import trace
@@ -74,17 +79,34 @@ class FlashRunner:
cfg = load_agent_config()
self.max_turns = max_turns if max_turns is not None else cfg.flash.max_turns
self.step_summarizer_cfg = cfg.flash.step_summarizer
self.memory_runtime_cfg = cfg.memory.runtime
self.transcript_cfg = cfg.memory.transcript
except Exception:
self.max_turns = max_turns if max_turns is not None else 30
self.step_summarizer_cfg = StepSummarizerConfig()
self.memory_runtime_cfg = MemoryRuntimeConfig()
self.transcript_cfg = MemoryTranscriptConfig()
self.controller = UnifiedMobileController(ctx)
self.executor = McpActionExecutor(ctx, self.controller)
self.summarizer = (
VisualStepSummarizer(ctx, model_name=self.step_summarizer_cfg.model)
VisualStepSummarizer(
ctx,
model_name=self.step_summarizer_cfg.model,
retry_limit=self.memory_runtime_cfg.retry_limit,
max_concurrency=self.memory_runtime_cfg.max_concurrency,
flush_timeout_s=self.memory_runtime_cfg.flush_timeout_s,
)
if self.step_summarizer_cfg.enabled
else None
)
# Publish the service on the composition root (ctx.step_memory, M2) so
# any co-resident consumer shares this run's summary runtime.
if self.summarizer is not None and getattr(ctx, "step_memory", None) is None:
try:
ctx.step_memory = self.summarizer
except Exception:
pass
def _get_tools(self) -> list:
tools = [t for t in VALIDATOR_TOOLS_DECLARATION if t.name != "report_failure_analysis"]
@@ -109,6 +131,16 @@ class FlashRunner:
async def run(self, state: State) -> dict:
logger.info(f"Starting Artemis Flash reactive loop for goal: {self.goal}")
# Fresh scrub-edge ledger per run: the frozen/watermark bookkeeping is
# execution state bound to this run's append-only message list.
compressor = ScrubEdgeCompressor(
summarizer=self.summarizer,
prune_history_xml=self.step_summarizer_cfg.prune_history_xml,
image_scrub_depth=self.transcript_cfg.image_scrub_depth,
pending_grace_steps=self.transcript_cfg.pending_grace_steps,
xml_scrub_depth=self.transcript_cfg.xml_scrub_depth,
)
# 1. Initialize Universal LLM via Service Layer
try:
llm = get_llm(self.ctx, name="operator")
@@ -204,13 +236,10 @@ class FlashRunner:
except Exception as e:
logger.warning(f"Failed to check injected instruction in FlashRunner: {e}")
# Compress history even when visual summarization is disabled, so
# screenshot and historical XML pruning have identical semantics.
compress_flash_messages(
messages,
summarizer=self.summarizer,
prune_history_xml=self.step_summarizer_cfg.prune_history_xml,
)
# Advance the scrub edge even when visual summarization is
# disabled, so screenshot and historical XML pruning keep one
# code path. Messages behind the edge are frozen permanently.
compressor.compress(messages)
# Tool restriction on the final turn
if turns == self.max_turns:
@@ -477,6 +506,15 @@ class FlashRunner:
action_dict["normalized_start_coordinates"] = norm_start
action_dict["normalized_end_coordinates"] = norm_end
# Record-time enrichment computed by the executor from
# the pre-action frame (target_text / target_class /
# target_resource_id / target_label_source).
target_semantics = (exec_result.metadata or {}).get(
"target_semantics"
)
if isinstance(target_semantics, dict):
action_dict.update(target_semantics)
recorded_step_id = self.ctx.data_engine.record_step(
pre_screenshot_bytes=current_pre_screenshot_bytes,
post_screenshot_bytes=post_img_bytes,
+171 -77
View File
@@ -16,7 +16,13 @@
Processes Before/After screenshot pairs and action metadata in the background
to generate high-density, strictly objective visual state transition summaries.
Ensures zero blocking latency for the main runner and independently retries failed jobs.
Scheduling (zero-blocking dispatch, bounded retry, bounded flush, step_id
keying with tool_call_id aliases) lives in the shared
:class:`artemis.memory.step_memory.StepMemoryService`; this class contributes
only the visual-transition lens: red action-marker overlay on the BEFORE
frame, the ``flash_summarizer.md`` neutral-wording contract, and versioned
``summary_status`` writes to the DataEngine.
"""
import asyncio
@@ -29,32 +35,61 @@ from jinja2 import Template
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
from artemis.context import ArtemisContext
from artemis.services.llm import get_google_llm, get_llm
from artemis.memory.step_memory import JobKey, StepMemoryService
from artemis.services.llm import RobustChatModelWrapper, get_google_llm, get_llm
from artemis.services.token_meter import record_llm_usage
from artemis.utils.logger import get_logger
from artemis.utils.visualization import draw_action_overlay_on_image
logger = get_logger(__name__)
# Degenerate-output guard (§5 echo validation): a summary that echoes the
# input's section markers, or falls outside sane length bounds, fails the
# attempt so the bounded service retry regenerates it.
SUMMARY_ECHO_MARKER = "--- ["
SUMMARY_MIN_CHARS = 15
SUMMARY_MAX_CHARS = 1500
class VisualStepSummarizer:
"""Non-blocking background worker generating objective visual step summaries.
Key design properties:
def degenerate_summary_reason(text: str) -> str | None:
"""Why a lens output is unusable (echo/length), or None when it is fine."""
if SUMMARY_ECHO_MARKER in text or text.startswith("---"):
return "echoes an input section marker"
if len(text) < SUMMARY_MIN_CHARS:
return f"too short ({len(text)} chars < {SUMMARY_MIN_CHARS})"
if len(text) > SUMMARY_MAX_CHARS:
return f"too long ({len(text)} chars > {SUMMARY_MAX_CHARS})"
return None
class VisualStepSummarizer(StepMemoryService):
"""Visual-transition lens on top of the shared step-memory runtime.
Key design properties (inherited from StepMemoryService):
1. Zero-blocking dispatch: The main Flash runner dispatches and proceeds immediately.
2. Lossless pending state: The compressor retains the source image until its summary is ready.
3. Independent retry: Every action owns a retry loop and does not depend on later actions arriving.
3. Independent bounded retry: Every action owns a retry loop capped at 1 + retry_limit attempts.
"""
def __init__(self, ctx: ArtemisContext, model_name: str | None = None):
self.ctx = ctx
self._summaries: dict[int | str, str] = {}
self._pending_tasks: dict[int | str, asyncio.Task] = {}
self._step_inputs: dict[int | str, dict[str, Any]] = {}
self._retry_counts: dict[int | str, int] = {}
self._retry_delays = (0.0, 0.5, 1.0, 2.0, 3.0)
def __init__(
self,
ctx: ArtemisContext,
model_name: str | None = None,
retry_limit: int = 3,
*,
max_concurrency: int = 1,
flush_timeout_s: float = 30.0,
):
super().__init__(
ctx,
max_concurrency=max_concurrency,
retry_limit=retry_limit,
flush_timeout_s=flush_timeout_s,
)
# Initialize lightweight VLM: prioritize explicit model_name
target_model = model_name or "gemini-2.5-flash-lite"
self._model_name = target_model
try:
if model_name:
self._llm = get_google_llm(model_name=target_model, temperature=0.0)
@@ -62,8 +97,19 @@ class VisualStepSummarizer:
self._llm = get_llm(ctx, name="summarizer", is_utils=True)
except Exception:
self._llm = get_google_llm(model_name=target_model, temperature=0.0)
try:
configured = getattr(self._llm, "model", None) or getattr(
self._llm, "model_name", None
)
if isinstance(configured, str) and configured:
self._model_name = configured
except Exception:
pass
# Load system prompt template
# Load system prompt templates: the dual-frame transition prompt and
# the single-frame variant (§5 revision: describe whichever frames
# exist — a Pro step record never carries an independent after-frame,
# by design, and must not be prompted as if one were expected).
prompt_path = Path(__file__).parent / "flash_summarizer.md"
if prompt_path.exists():
self._prompt_template = prompt_path.read_text(encoding="utf-8")
@@ -74,6 +120,19 @@ class VisualStepSummarizer:
"continuous first-person paragraph using 'I' (e.g., 'In Step {{ step_number }}, I tapped... and observed...').\n"
"Strictly avoid subjective validation words: successfully, completed, failed, achieved, navigated to."
)
single_path = Path(__file__).parent / "flash_summarizer_single.md"
if single_path.exists():
self._single_prompt_template = single_path.read_text(encoding="utf-8")
else:
self._single_prompt_template = (
"You are the Step Summarizer for an Android UI automation agent.\n"
"Exactly ONE screenshot (the decision frame) is available; there is NO after-action screenshot —"
" that only means no independent post-action evidence exists.\n"
"Describe strictly what THIS screen shows and where the action landed (red marker), in exactly ONE"
" continuous first-person paragraph using 'I' for Step {{ step_number }}. Never describe or guess"
" the post-action state.\n"
"Strictly avoid subjective validation words: successfully, completed, failed, achieved, navigated to."
)
def dispatch(
self,
@@ -87,9 +146,23 @@ class VisualStepSummarizer:
action_key: str | None = None,
data_engine_step_id: UUID | str | None = None,
) -> None:
"""Dispatches an asynchronous summarization task without blocking the caller."""
key: int | str = action_key or step_number
self._step_inputs[key] = {
"""Dispatches an asynchronous summarization task without blocking the caller.
Jobs are keyed by the DataEngine step id when one is available; the
tool_call_id (``action_key``) is retained as an alias so the message
compressor can keep querying by it. Callers with neither provide the
step ordinal, matching the legacy keying.
"""
key: JobKey
aliases: tuple[JobKey, ...] = ()
if data_engine_step_id is not None:
key = str(data_engine_step_id)
if action_key is not None:
aliases = (action_key,)
else:
key = action_key if action_key is not None else step_number
payload = {
"step_number": step_number,
"action_name": action_name,
"action_args": action_args,
@@ -98,31 +171,49 @@ class VisualStepSummarizer:
"exec_outcome": exec_outcome,
"data_engine_step_id": data_engine_step_id,
}
self.submit(key, payload, aliases=aliases)
task = asyncio.create_task(self._run_summary_until_ready(key))
self._pending_tasks[key] = task
def _meter_lens_call(self, response: Any) -> None:
"""Meter one raw-model lens call as an ``llm_usage`` trace, best-effort.
async def _run_summary_until_ready(self, action_key: int | str) -> None:
"""Retry one action independently until a non-empty summary is committed."""
while action_key not in self._summaries:
if await self._run_summary_once(action_key):
return
Gateway-wrapped models already meter at the wrapper exit; only the raw
``get_google_llm`` bypass needs explicit metering here. Lens prompts
are tiny and must not overwrite the session's ``last_prompt_tokens``
(the compaction thresholds' live context base), hence
``update_last_prompt=False``.
"""
if isinstance(self._llm, RobustChatModelWrapper):
return
engine = getattr(self.ctx, "data_engine", None) if self.ctx else None
record_llm_usage(
engine,
response,
source=f"lens:visual_transition:{self._model_name}",
update_last_prompt=False,
)
retry_count = self._retry_counts.get(action_key, 0) + 1
self._retry_counts[action_key] = retry_count
delay = self._retry_delays[min(retry_count - 1, len(self._retry_delays) - 1)]
input_data = self._step_inputs.get(action_key)
display_step = input_data.get("step_number") if input_data else action_key
logger.info(
f"VisualStepSummarizer: Retrying Step {display_step} "
f"(attempt {retry_count + 1}) after {delay:.1f}s"
def _on_status(self, key: JobKey, status: str) -> None:
"""Best-effort DataEngine summary-status write (pending/failed)."""
if not self.ctx.data_engine:
return
payload = self._step_inputs.get(key) or {}
target_step = payload.get("data_engine_step_id")
if not target_step:
return
try:
self.ctx.data_engine.update_step_summary(
target_step,
None,
status=status,
source="visual_transition",
model=self._model_name,
)
if delay:
await asyncio.sleep(delay)
except Exception as de_err:
logger.debug(f"DataEngine summary status update skipped: {de_err}")
async def _run_summary_once(self, action_key: int | str) -> bool:
async def _attempt(self, key: JobKey) -> bool:
"""Execute one lightweight VLM attempt for a step transition."""
input_data = self._step_inputs.get(action_key)
input_data = self._step_inputs.get(key)
if not input_data:
return False
@@ -134,7 +225,14 @@ class VisualStepSummarizer:
exec_outcome = input_data["exec_outcome"]
try:
rendered_prompt = Template(self._prompt_template).render(step_number=step_number)
# §5 revision: the lens input adapts to whichever frames exist.
# Both frames -> the classic BEFORE/AFTER transition prompt; a
# single frame (Pro steps never carry an independent after-frame,
# by design) -> the single-frame variant, which has no AFTER
# ACTION section and forbids synthesizing a transition.
dual = bool(pre_bytes) and bool(post_bytes)
template = self._prompt_template if dual else self._single_prompt_template
rendered_prompt = Template(template).render(step_number=step_number)
content_blocks: list[dict[str, Any]] = [
{
"type": "text",
@@ -156,7 +254,12 @@ class VisualStepSummarizer:
content_blocks.append(
{
"type": "text",
"text": "--- [1] BEFORE ACTION SCREEN (Action Marked Visually in Red) ---",
"text": (
"--- [1] BEFORE ACTION SCREEN (Action Marked Visually in Red) ---"
if dual
else "--- [1] DECISION FRAME: SCREEN AT ACTION TIME"
" (Action Marked Visually in Red) ---"
),
}
)
content_blocks.append(
@@ -165,7 +268,17 @@ class VisualStepSummarizer:
if post_bytes:
b64_post = base64.b64encode(post_bytes).decode("utf-8")
content_blocks.append({"type": "text", "text": "--- [2] AFTER ACTION SCREEN ---"})
content_blocks.append(
{
"type": "text",
"text": (
"--- [2] AFTER ACTION SCREEN ---"
if dual
else "--- [1] SCREEN OBSERVED AFTER THE ACTION"
" (no decision frame available) ---"
),
}
)
content_blocks.append(
{
"type": "image_url",
@@ -179,6 +292,7 @@ class VisualStepSummarizer:
]
response = await asyncio.wait_for(self._llm.ainvoke(messages), timeout=25.0)
self._meter_lens_call(response)
summary_raw = response.content if isinstance(response.content, str) else ""
if isinstance(response.content, list):
summary_raw = "".join(
@@ -188,22 +302,35 @@ class VisualStepSummarizer:
)
summary_text = summary_raw.strip()
degenerate = degenerate_summary_reason(summary_text) if summary_text else None
if degenerate:
logger.warning(
f"VisualStepSummarizer: Discarding degenerate summary for"
f" Step {step_number} ({degenerate}): {summary_text[:80]!r}"
)
return False
if summary_text:
self._summaries[action_key] = summary_text
self._summaries[key] = summary_text
logger.info(
f"VisualStepSummarizer: Generated summary for Step {step_number}: {summary_text[:80]}..."
)
# Free binary image buffers from memory once summary is secured
if action_key in self._step_inputs:
self._step_inputs[action_key]["pre_img_bytes"] = None
self._step_inputs[action_key]["post_img_bytes"] = None
if key in self._step_inputs:
self._step_inputs[key]["pre_img_bytes"] = None
self._step_inputs[key]["post_img_bytes"] = None
# Update DataEngine telemetry if active
if self.ctx.data_engine:
try:
target_step = input_data.get("data_engine_step_id") or step_number
self.ctx.data_engine.update_step_summary(target_step, summary_text)
self.ctx.data_engine.update_step_summary(
target_step,
summary_text,
status="ready",
source="visual_transition",
model=self._model_name,
)
except Exception as de_err:
logger.debug(f"DataEngine step summary update skipped: {de_err}")
return True
@@ -213,36 +340,3 @@ class VisualStepSummarizer:
f"VisualStepSummarizer: Error generating summary for step {step_number}: {e}"
)
return False
def get_summary(
self, action_key: int | str, fallback_text: str | None = None
) -> str | None:
"""Retrieve the summary for a stable action key, or fallback text."""
return self._summaries.get(action_key, fallback_text)
def has_summary(self, action_key: int | str) -> bool:
"""Check whether an action summary has completed."""
return action_key in self._summaries
def has_job(self, action_key: int | str) -> bool:
"""Check whether an action has been submitted for visual summarization."""
return action_key in self._step_inputs
def is_pending(self, action_key: int | str) -> bool:
"""Check whether an action still needs a summary."""
return self.has_job(action_key) and not self.has_summary(action_key)
async def flush(self, timeout_seconds: float = 30.0) -> None:
"""Wait for active jobs up to a bound, then cancel remaining retry loops."""
tasks = [t for t in self._pending_tasks.values() if not t.done()]
if tasks:
logger.info(f"VisualStepSummarizer: Flushing {len(tasks)} pending summary tasks...")
_, pending = await asyncio.wait(tasks, timeout=timeout_seconds)
if pending:
logger.warning(
f"VisualStepSummarizer: Cancelling {len(pending)} unfinished summary tasks "
f"after {timeout_seconds:.1f}s flush timeout."
)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
@@ -35,7 +35,8 @@ from artemis.utils.notes import (
get_note_file_path,
get_notes_dir,
)
from artemis.utils.task_tree import build_plan_and_history, get_active_subgoal_hashes
from artemis.memory.context_policy import build_history_for
from artemis.utils.task_tree import get_active_subgoal_hashes
logger = get_logger(__name__)
@@ -110,12 +111,12 @@ class HistoryAnalyzer:
if current_plan:
active_subgoal_hash, _ = get_active_subgoal_hashes(current_plan)
plan_and_history = build_plan_and_history(
plan_and_history = build_history_for(
"history_analyzer",
current_plan,
self.history_steps,
active_subgoal_hash,
last_n_detailed=1,
min_summaries=len(self.history_steps),
engine=self.ctx.data_engine,
)
except Exception as e:
logger.error(f"Failed to build plan and history in HistoryAnalyzer: {e}")
+1 -1
View File
@@ -1,3 +1,3 @@
{
"main_template": "# ROLE\nYou are the Android Device Operator. Your priority is execution accuracy. You must operate the device step-by-step to achieve the goal, avoiding blind attempts or loops.\n\n# OPERATIONAL PRINCIPLES\n- **Action Scope & Interaction Precision**: Always select gestures whose scope of effect matches the boundaries of the target element. Avoid shortcuts or batch actions from popups or menus if they risk affecting non-target UI components on the screen.[% if \"swipe\" in available_tools %] For any task requiring continuous range interactions, sliding, or boundary selection, prefer using targeted swipe/drag gestures spanning from the element's start bounds [xmin, ymin] to end bounds [xmax, ymax] to ensure the action is strictly confined to the target element. For drag-and-drop, list reordering, or sliding sliders/SeekBars, set duration >= 1000 (e.g. 1500) and always drag slightly PAST the target position to overcome touch slop and reliably trigger the drop/snap/value update. When setting a slider to Maximum (100%) or Minimum (0%), always swipe fully to the extreme boundary to guarantee reaching the absolute limits.[% endif %]\n- **Vision-First Priority**: The screenshot is the primary source of truth for the real device state. Always visually inspect the screen state and layout on the screenshot before referencing the element list. Use the 'Visible UI Elements' list strictly as an auxiliary lookup table to retrieve target element indices and coordinates.\n- **Tool Calling Contract**: Tools that return information needed for the decision ([[ tool_enum(pre_decision_tools, \"and\") ]]) are result-dependent Pre-Decision Tools. Do not submit them in the same tool-call list as a Turn-Ending Action; inspect their result first. The write-through tools `update_note` and `append_note` may accompany at most one Turn-Ending Action when their content is based entirely on evidence already available before that action. Never write an action's expected outcome as if it had already been observed.\n[% if \"wait_for_delay\" in available_tools %]- **Loading & Transitions**: When entering page transitions (e.g., app launching, web page loading, or progress indicators), use `wait_for_delay` to let the screen settle before executing target actions. Some transition states are difficult to discern, such as a thin progress line at the top of the browser screen during webpage loads. Before questioning whether an error has occurred, you can first adopt a waiting strategy to see how the device state evolves. If the state remains ambiguous after waiting, certain UI components might be malfunctioning or failing to load; in this case, consider retrying the action or pivoting to an alternative path.\n[% endif %]- **Memory Preservation & Paginated Exploration**: Using note tools is entirely zero-cost. Actively use this tool to maintain goals and logs. The note tool is the only reliable way to persist memory. Therefore, when you find information on the screen that might be relevant to the task, even if you are not certain, you should first save the information currently visible on the screen before exploring further. If you have already recorded some information, you can use `append_note` to append the new information to the existing content. The note tool can also help you organize lists or information across multiple screens; you can consolidate all information into notes for unified processing.\n- **Large List & Long Text Exploration Strategy (Zero-Miss, Zero-Duplication Single-Pass Principle)**: When traversing large lists, browsing long text bodies, or scanning continuous content feeds, strictly adhere to the single-pass exploration standard:\n 1. **Unidirectional Anchor-Based Traversal (Zero-Miss)**: Maintain a consistent, unidirectional scan (e.g., uniform downward scrolling). Before each scroll, identify the bottom-most visible item/text segment as your visual anchor and carry that anchor plus the scan direction in short-term memory or the traversal ledger. After the scroll, evaluate this handoff before doing anything unrelated: if the anchor remains visible with new content beyond it, record the new content and continue in the same direction without scrolling back to verify; if the anchor is missing, continuity is uncertain, so perform at most one minimal reverse recovery to re-establish overlap, then resume the original direction. While the traversal's declared exit condition remains unresolved, keep this traversal active and do not advance to another milestone or mark it complete.\n 2. **Boundary-Proven Completion**: A single scroll that reveals no new content is not sufficient evidence that the list is complete. For an exhaustive traversal, stop only when an explicit end-of-list indicator is visible, or when one additional successful swipe in the same direction and within the same list container leaves the same final anchor visible with no new items and no loading state. This boundary probe must remain unidLine truncated
"main_template": "# ROLE\nYou are the Android Device Operator. Your priority is execution accuracy. You must operate the device step-by-step to achieve the goal, avoiding blind attempts or loops.\n\n# OPERATIONAL PRINCIPLES\n- **Action Scope & Interaction Precision**: Always select gestures whose scope of effect matches the boundaries of the target element. Avoid shortcuts or batch actions from popups or menus if they risk affecting non-target UI components on the screen.[% if \"swipe\" in available_tools %] For any task requiring continuous range interactions, sliding, or boundary selection, prefer using targeted swipe/drag gestures spanning from the element's start bounds [xmin, ymin] to end bounds [xmax, ymax] to ensure the action is strictly confined to the target element. For drag-and-drop, list reordering, or sliding sliders/SeekBars, set duration >= 1000 (e.g. 1500) and always drag slightly PAST the target position to overcome touch slop and reliably trigger the drop/snap/value update. When setting a slider to Maximum (100%) or Minimum (0%), always swipe fully to the extreme boundary to guarantee reaching the absolute limits.[% endif %]\n- **Vision-First Priority**: The screenshot is the primary source of truth for the real device state. Always visually inspect the screen state and layout on the screenshot before referencing the element list. Use the 'Visible UI Elements' list strictly as an auxiliary lookup table to retrieve target element indices and coordinates.\n- **Tool Calling Contract**: Tools that return information needed for the decision ([[ tool_enum(pre_decision_tools, \"and\") ]]) are result-dependent Pre-Decision Tools. Do not submit them in the same tool-call list as a Turn-Ending Action; inspect their result first. The write-through tools `update_note` and `append_note` may accompany at most one Turn-Ending Action when their content is based entirely on evidence already available before that action. Never write an action's expected outcome as if it had already been observed.\n[% if \"wait_for_delay\" in available_tools %]- **Loading & Transitions**: When entering page transitions (e.g., app launching, web page loading, or progress indicators), use `wait_for_delay` to let the screen settle before executing target actions. Some transition states are difficult to discern, such as a thin progress line at the top of the browser screen during webpage loads. Before questioning whether an error has occurred, you can first adopt a waiting strategy to see how the device state evolves. If the state remains ambiguous after waiting, certain UI components might be malfunctioning or failing to load; in this case, consider retrying the action or pivoting to an alternative path.\n[% endif %]- **Memory Preservation & Paginated Exploration**: Using note tools is entirely zero-cost. Actively use this tool to maintain goals and logs. The note tool is the only reliable way to persist memory. Therefore, when you find information on the screen that might be relevant to the task, even if you are not certain, you should first save the information currently visible on the screen before exploring further. If you have already recorded some information, you can use `append_note` to append the new information to the existing content. The note tool can also help you organize lists or information across multiple screens; you can consolidate all information into notes for unified processing.\n- **Large List & Long Text Exploration Strategy (Zero-Miss, Zero-Duplication Single-Pass Principle)**: When traversing large lists, browsing long text bodies, or scanning continuous content feeds, strictly adhere to the single-pass exploration standard:\n 1. **Unidirectional Anchor-Based Traversal (Zero-Miss)**: Maintain a consistent, unidirectional scan (e.g., uniform downward scrolling). Before each scroll, identify the bottom-most visible item/text segment as your visual anchor and carry that anchor plus the scan direction in short-term memory or the traversal ledger. After the scroll, evaluate this handoff before doing anything unrelated: if the anchor remains visible with new content beyond it, record the new content and continue in the same direction without scrolling back to verify; if the anchor is missing, continuity is uncertain, so perform at most one minimal reverse recovery to re-establish overlap, then resume the original direction. While the traversal's declared exit condition remains unresolved, keep this traversal active and do not advance to another milestone or mark it complete.\n 2. **Boundary-Proven Completion**: A single scroll that reveals no new content is not sufficient evidence that the list is complete. For an exhaustive traversal, stop only when an explicit end-of-list indicator is visible, or when one additional successful swipe in the same direction and within the same list container leaves the same final anchor visible with no new items and no loading state. This boundary probe must remain unidLine truncated
}
+239 -34
View File
@@ -13,7 +13,6 @@
# limitations under the License.
import json
import re
from typing import Any
from uuid import uuid4
@@ -46,12 +45,11 @@ from artemis.utils.coordinates import (
parse_swipe_parameters,
)
from artemis.utils.decorators import wrap_with_callbacks
from artemis.utils.element_hit_test import find_element_at_point
from artemis.utils.logger import get_logger
from artemis.utils.notes import get_note_file_path
from artemis.utils.task_tree import (
build_plan_and_history,
get_active_subgoal_hashes,
)
from artemis.memory.context_policy import build_history_for
from artemis.utils.task_tree import get_active_subgoal_hashes
from artemis.utils.visualization import format_minimal_list_with_elements
logger = get_logger(__name__)
@@ -62,6 +60,7 @@ DEFERRING_TOOLS = {
"read_note",
"list_notes",
"save_note",
"recall_history",
"run_adb_command",
"manage_task",
"analyze_task_output",
@@ -73,14 +72,16 @@ from artemis.agents.operator.prompts import (
PromptComponent,
TemplatePromptComponent,
ObservationPromptComponent,
PlanRecitationPromptComponent,
ScreenshotSimilarityPromptComponent,
HistoricalStateHintPromptComponent,
FeedbackPromptComponent,
CheckItemsExplainerPromptComponent,
BackgroundTasksPromptComponent,
ShortTermMemoryPromptComponent,
TaskPlanWarningPromptComponent,
ToolLimitWarningPromptComponent,
InjectedInstructionPromptComponent,
render_transcript_static_system,
)
from artemis.agents.operator.prompt_builder import load_operator_prompts
@@ -92,6 +93,7 @@ class OperatorNode:
tools: list[BaseTool] | None = None,
prompt_components: list[PromptComponent] | None = None,
last_n_detailed: int = 1,
transcript_config=None,
):
self.ctx = ctx
self.tools = tools or []
@@ -103,6 +105,24 @@ class OperatorNode:
logger.error(f"Failed to load operator prompts: {e}")
self.prompts = {}
# M2 transcript flag (agent.memory.transcript.enabled). Off keeps the
# legacy 2-message prompt path byte-for-byte; an explicit
# ``transcript_config`` overrides the loaded configuration (tests).
if transcript_config is not None:
self._transcript_cfg = transcript_config
else:
try:
from artemis.config import load_agent_config
self._transcript_cfg = load_agent_config().memory.transcript
except Exception:
from artemis.config.agent import MemoryTranscriptConfig
self._transcript_cfg = MemoryTranscriptConfig()
# Index into the rendered message list where this turn's tail begins;
# set by the transcript build and consumed after the tool loop.
self._transcript_turn_base: int | None = None
def _available_device_actions(self) -> frozenset[str]:
"""Device actions the installed actuator backend provides.
@@ -170,6 +190,26 @@ class OperatorNode:
active_background_tasks: list[dict] = None,
newly_finished_tasks: list[dict] = None,
) -> list:
self._transcript_turn_base = None
if getattr(self._transcript_cfg, "enabled", False):
try:
return await self._build_prompt_transcript(
state,
latest_screenshot_b64,
minimal_list,
current_step_num,
steps,
task_plan,
active_background_tasks=active_background_tasks,
newly_finished_tasks=newly_finished_tasks,
)
except Exception as e:
logger.error(
f"Transcript prompt path failed; falling back to the legacy"
f" 2-message build for this turn: {e}"
)
self._transcript_turn_base = None
plan_and_history = self._build_plan_and_history(steps, task_plan)
builder = PromptBuilder()
@@ -187,8 +227,8 @@ class OperatorNode:
(CheckItemsExplainerPromptComponent(), {}),
(ObservationPromptComponent(), {}),
(ScreenshotSimilarityPromptComponent(), {}),
(HistoricalStateHintPromptComponent(), {}),
(InjectedInstructionPromptComponent(), {}),
(ShortTermMemoryPromptComponent(), {}),
(BackgroundTasksPromptComponent(), {}),
(FeedbackPromptComponent(), {}),
(TaskPlanWarningPromptComponent(), {}),
@@ -217,6 +257,174 @@ class OperatorNode:
return builder.build()
# ------------------------------------------------------------------
# Transcript prompt path (M2, flag agent.memory.transcript.enabled)
# ------------------------------------------------------------------
def _ensure_transcript_ledger(self, state=None):
"""Session ledger on the composition root, created on first use."""
from artemis.memory import TranscriptLedger, ensure_step_memory
ledger = getattr(self.ctx, "transcript_ledger", None)
if isinstance(ledger, TranscriptLedger):
return ledger
cfg = self._transcript_cfg
ledger = TranscriptLedger(
step_memory=ensure_step_memory(self.ctx),
image_scrub_depth=getattr(cfg, "image_scrub_depth", 3),
pending_grace_steps=getattr(cfg, "pending_grace_steps", 3),
xml_scrub_depth=getattr(cfg, "xml_scrub_depth", 1),
)
# L2/L3 chunk compression (M3) rides the same flag; without a
# DataEngine there are no step records to chunk, so the ledger simply
# runs scrub-edge-only.
if self.ctx.data_engine is not None:
try:
from artemis.memory import HistoryChunkManager
chunking_cfg = None
try:
from artemis.config import load_agent_config
chunking_cfg = load_agent_config().memory.chunking
except Exception:
pass
ledger.attach_chunker(
HistoryChunkManager(
engine=self.ctx.data_engine,
ctx=self.ctx,
chunking_config=chunking_cfg,
transcript_config=cfg,
goal=getattr(state, "initial_goal", None) if state else None,
)
)
except Exception as e:
logger.error(f"History chunk manager unavailable: {e}")
try:
self.ctx.transcript_ledger = ledger
except Exception:
pass
return ledger
async def _build_prompt_transcript(
self,
state: State,
latest_screenshot_b64: str,
minimal_list: str,
current_step_num: int,
steps: list[dict],
task_plan: str,
active_background_tasks: list[dict] = None,
newly_finished_tasks: list[dict] = None,
) -> list:
"""Build ``S + F + A + tail`` from the session transcript ledger.
The static system message renders once per session (byte-stable S
region); the previous turn is committed into the append-only active
region together with its step key and validator result; the fresh tail
carries the current observation plus the task-plan recitation.
"""
from langchain_core.messages import HumanMessage
ledger = self._ensure_transcript_ledger(state)
# 1. S region: rendered exactly once per session.
if not ledger.has_static_prefix:
static_text = render_transcript_static_system(self.prompts, self.ctx, state)
ledger.set_static_prefix([SystemMessage(content=static_text)])
# 2. F region cold start: an empty ledger over an existing step record
# trail means the process restarted — freeze the compiled history once.
# A staged (not yet committed) turn means this is turn 2+ of a live
# session, not a cold start — step records already exist then, and
# seeding would be rejected by the empty-ledger invariant.
if (
ledger.turn_count == 0
and not ledger.has_staged_turn
and not ledger.has_restored_history
and steps
):
restored = build_history_for(
"operator_cold_start",
task_plan,
steps,
self._get_active_subgoal_hash(),
)
ledger.set_restored_history(
"[Restored history] Rebuilt from the step records of this task"
" after a process restart; step times below are relative to the"
" original session start.\n"
f"{restored}"
)
# 3. Commit the previous turn (its step id and validator result only
# exist now). A turn without a terminal action never ran the validator,
# and ``structured_decisions`` is cleared on planner rejection — the
# gate keeps stale reports out of the ledger.
validator_result = (
getattr(state, "last_execution_result", None)
if getattr(state, "structured_decisions", None)
else None
)
ledger.commit_staged(
step_key=getattr(state, "current_step_id", None),
validator_result=validator_result,
)
# 4. Current tail: observation + plan recitation + injected components.
builder = PromptBuilder()
builder.add_human_content(f"# CURRENT OBSERVATION [{ledger.elapsed_label()}]")
components = [
(PlanRecitationPromptComponent(), {}),
(CheckItemsExplainerPromptComponent(), {}),
(ObservationPromptComponent(), {}),
(ScreenshotSimilarityPromptComponent(), {}),
(HistoricalStateHintPromptComponent(), {}),
(InjectedInstructionPromptComponent(), {}),
(BackgroundTasksPromptComponent(), {}),
(FeedbackPromptComponent(), {}),
(TaskPlanWarningPromptComponent(), {}),
(ToolLimitWarningPromptComponent(), {}),
]
for component, extra_kwargs in components:
kwargs_to_pass = {
"prompts": self.prompts,
"task_plan": task_plan,
"latest_screenshot_b64": latest_screenshot_b64,
"minimal_list": minimal_list,
"current_step_num": current_step_num,
"active_background_tasks": active_background_tasks or [],
"newly_finished_tasks": newly_finished_tasks or [],
"steps": steps,
}
kwargs_to_pass.update(extra_kwargs)
await component(builder, state, self.ctx, **kwargs_to_pass)
tail_content = []
for part in builder.human_parts:
if isinstance(part, str):
tail_content.append({"type": "text", "text": part})
else:
tail_content.append(part)
tail = HumanMessage(content=tail_content)
messages = ledger.render([tail])
self._transcript_turn_base = len(messages) - 1
return messages
def _stage_transcript_turn(self, messages: list) -> None:
"""Hand this turn's tail + tool-loop products to the ledger."""
base = self._transcript_turn_base
self._transcript_turn_base = None
if base is None:
return
try:
ledger = getattr(self.ctx, "transcript_ledger", None)
if ledger is not None:
ledger.stage_turn(messages[base:])
except Exception as e:
logger.error(f"Failed to stage transcript turn: {e}")
async def _invoke_llm_loop(
self,
base_llm,
@@ -729,30 +937,18 @@ class OperatorNode:
state=state,
)
# 8b. Transcript path: hold this turn's messages (observation tail +
# tool-loop products) for commit at the next build, when the turn's
# step id and validator result exist.
self._stage_transcript_turn(messages)
# 9. Update State
structured_decisions = json.dumps(action_result) if action_result else None
short_term_memory = None
combined_thinking = ""
if raw_thinking:
combined_thinking += raw_thinking + "\n"
if native_thinking:
combined_thinking += native_thinking
if combined_thinking:
stm_match = re.search(
r"<short_term_memory>(.*?)</short_term_memory>",
combined_thinking,
re.DOTALL,
)
if stm_match:
short_term_memory = stm_match.group(1).strip()
return {
"structured_decisions": structured_decisions,
"operator_raw_thinking": raw_thinking,
"operator_native_thinking": native_thinking,
"short_term_memory": short_term_memory,
"indexed_points": state.indexed_points,
"indexed_elements": state.indexed_elements,
"current_step_id": state.current_step_id,
@@ -785,14 +981,12 @@ class OperatorNode:
"""Builds a plan checklist and separate execution history."""
active_subgoal_hash = self._get_active_subgoal_hash()
return build_plan_and_history(
return build_history_for(
"operator",
task_plan,
steps,
active_subgoal_hash,
last_n_detailed=self.last_n_detailed,
strict_milestone_pruning=True,
recent_window_size=3,
chronological_last_step=True,
)
def _translate_and_validate_tool(self, tc: dict, state: State) -> tuple[list[dict], str | None]:
@@ -838,7 +1032,7 @@ class OperatorNode:
return None, f"Error: Invalid target index {target}."
indexed_elements = getattr(state, "indexed_elements", None) or []
if 1 <= target_int <= len(indexed_elements):
return indexed_elements[target_int - 1], None
return {**indexed_elements[target_int - 1], "label_source": "index"}, None
return (
None,
(
@@ -851,13 +1045,21 @@ class OperatorNode:
nx, ny = map(float, target)
x = int(max(0, min(width - 1, nx * width / 1000)))
y = int(max(0, min(height - 1, ny * height / 1000)))
# Record-time enrichment: a bare-coordinate target carries no
# element semantics, so hit test the pre-action frame's indexed
# elements to best-effort recover what sits under the point.
hit_el, hit_source = find_element_at_point(
getattr(state, "indexed_elements", None), x, y
)
hit_el = hit_el or {}
return {
"center": [x, y],
"text": None,
"bounds": None,
"class": None,
"resource_id": None,
"is_ocr": False,
"text": hit_el.get("text"),
"bounds": hit_el.get("bounds"),
"class": hit_el.get("class"),
"resource_id": hit_el.get("resource_id"),
"is_ocr": bool(hit_el.get("is_ocr")),
"label_source": hit_source,
}, None
except (ValueError, TypeError):
return (
@@ -894,6 +1096,7 @@ class OperatorNode:
"target_bounds": el.get("bounds"),
"target_resource_id": el.get("resource_id"),
"target_class": el.get("class"),
"target_label_source": el.get("label_source", "none"),
}
], None
@@ -917,6 +1120,7 @@ class OperatorNode:
"target_bounds": el.get("bounds"),
"target_resource_id": el.get("resource_id"),
"target_class": el.get("class"),
"target_label_source": el.get("label_source", "none"),
}
], None
@@ -953,6 +1157,7 @@ class OperatorNode:
"target_bounds": el.get("bounds"),
"target_resource_id": el.get("resource_id"),
"target_class": el.get("class"),
"target_label_source": el.get("label_source", "none"),
}
)
return actions, None
+224 -39
View File
@@ -52,7 +52,7 @@ from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER
_PRE_DECISION_HELPER_TOOLS = ("ask_explorer", "ask_diagnoser", "video_analyzer")
_PRE_DECISION_ADB_TOOLS = ("run_adb_command", "manage_task", "analyze_task_output")
_PRE_DECISION_MEMORY_TOOLS = ("read_note", "list_notes", "save_note")
_PRE_DECISION_MEMORY_TOOLS = ("read_note", "list_notes", "save_note", "recall_history")
_PRE_DECISION_ALL_TOOLS = (
_PRE_DECISION_HELPER_TOOLS + _PRE_DECISION_ADB_TOOLS + _PRE_DECISION_MEMORY_TOOLS
)
@@ -159,6 +159,119 @@ class PromptComponent:
raise NotImplementedError
def resolve_operator_prompt_tools(ctx: ArtemisContext) -> frozenset[str]:
"""The tool set the operator prompt may advertise for this run.
Extracted verbatim from the template component so the transcript static
prefix (M2) and the legacy per-turn render assemble against the identical
availability set.
"""
# video_analyzer is bound conditionally (graph.py gates it on
# video_recording_tools_enabled); the prompt must not advertise it when the
# tool is not actually available this run.
available = set(OPERATOR_PROMPT_TOOLSET)
setup = getattr(ctx, "execution_setup", None)
if not (setup and getattr(setup, "video_recording_tools_enabled", False)):
available.discard("video_analyzer")
# recall_history (M4) needs a DataEngine to search and is config-gated;
# the prompt must not advertise it when the tool is not bound this run.
if getattr(ctx, "data_engine", None) is None or not _recall_enabled():
available.discard("recall_history")
# Device actions the installed actuator backend does not implement disappear
# from the prompt in lockstep with their tool declarations.
actuator = getattr(ctx, "actuator", None)
if actuator is not None and callable(getattr(actuator, "capabilities", None)):
try:
from artemis.mcp.action_manifest import (
DEVICE_ACTIONS,
available_device_actions,
)
available -= DEVICE_ACTIONS - available_device_actions(actuator)
except Exception as e:
logger.warning(f"Failed to assemble prompt against actuator: {e}")
return frozenset(available)
def _operator_grammar_flags(ctx: ArtemisContext) -> tuple[bool, bool]:
"""(include_checks, verification_active) for the template render."""
setup = getattr(ctx, "execution_setup", None)
# Grammar spec assembly is a function of configuration: with both check
# gates disabled, the check-line grammar never enters any prompt.
include_checks = bool(setup and getattr(setup, "checks_enabled", False))
# The rejection/finding diagnosis trigger only exists while a mechanism
# that can produce rejections or findings is active.
verification_active = include_checks or bool(
setup and not getattr(setup, "disable_planner_validation", True)
)
return include_checks, verification_active
# --- M2 template split -----------------------------------------------------------
# The only volatile span of operator.json's main_template is the plan+history
# section below. The transcript path replaces it with a static pointer so the
# whole system message becomes a byte-stable S region; the legacy path renders
# the untouched template and stays byte-identical.
#: The volatile section of ``main_template`` (must occur exactly once).
PLAN_HISTORY_TEMPLATE_SECTION = "## Current Plan & Execution History\n{{ plan_and_history }}"
#: Static replacement used by the transcript S region.
PLAN_HISTORY_STATIC_POINTER = (
"## Current Plan & Execution History\n"
"Provided in the conversation that follows: earlier turns carry the raw"
" step-by-step execution history (a restored-history block precedes them"
" after a process restart), and each turn's observation message recites"
" the current task plan."
)
def render_transcript_static_system(
prompts: dict,
ctx: ArtemisContext,
state: State,
template_name: str = "main_template",
) -> str:
"""Render the transcript path's byte-stable static system prompt (S region).
Same template, same availability assembly, and the same render inputs as
the legacy path — except the volatile plan+history section is swapped for
a static pointer, so the output never changes across the session.
"""
prompt_template = prompts.get(template_name)
if not prompt_template:
raise KeyError(
"Failed to format prompt, template not found in operator prompts config."
)
if prompt_template.count(PLAN_HISTORY_TEMPLATE_SECTION) != 1:
# Hard dependency of the M2 split (redesign §9): without a clean
# section boundary the S region cannot be byte-stable.
raise ValueError(
"operator main_template no longer contains exactly one plan+history"
" section; the transcript static split cannot be applied."
)
static_template = prompt_template.replace(
PLAN_HISTORY_TEMPLATE_SECTION, PLAN_HISTORY_STATIC_POINTER
)
available = resolve_operator_prompt_tools(ctx)
static_template = apply_operator_prompt_contract(
static_template, available_tools=available
)
include_checks, verification_active = _operator_grammar_flags(ctx)
return Template(static_template).render(
initial_goal=state.initial_goal,
subgoals_status="",
plan_and_history="",
unified_history="",
plan_grammar=render_plan_grammar_spec(include_checks),
verification_active=verification_active,
)
class TemplatePromptComponent(PromptComponent):
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
prompts = kwargs.get("prompts", {})
@@ -169,42 +282,15 @@ class TemplatePromptComponent(PromptComponent):
"Failed to format prompt, template not found in operator prompts config."
)
# video_analyzer is bound conditionally (graph.py gates it on
# video_recording_tools_enabled); the prompt must not advertise it when the
# tool is not actually available this run.
available = set(OPERATOR_PROMPT_TOOLSET)
setup = getattr(ctx, "execution_setup", None)
if not (setup and getattr(setup, "video_recording_tools_enabled", False)):
available.discard("video_analyzer")
# Device actions the installed actuator backend does not implement disappear
# from the prompt in lockstep with their tool declarations.
actuator = getattr(ctx, "actuator", None)
if actuator is not None and callable(getattr(actuator, "capabilities", None)):
try:
from artemis.mcp.action_manifest import (
DEVICE_ACTIONS,
available_device_actions,
)
available -= DEVICE_ACTIONS - available_device_actions(actuator)
except Exception as e:
logger.warning(f"Failed to assemble prompt against actuator: {e}")
available = resolve_operator_prompt_tools(ctx)
prompt_template = apply_operator_prompt_contract(
prompt_template, available_tools=frozenset(available)
prompt_template, available_tools=available
)
plan_and_history = kwargs.get("plan_and_history", "No plan or history yet.")
# Grammar spec assembly is a function of configuration: with both check
# gates disabled, the check-line grammar never enters any prompt.
include_checks = bool(setup and getattr(setup, "checks_enabled", False))
# The rejection/finding diagnosis trigger only exists while a mechanism
# that can produce rejections or findings is active.
verification_active = include_checks or bool(
setup and not getattr(setup, "disable_planner_validation", True)
)
include_checks, verification_active = _operator_grammar_flags(ctx)
full_prompt = Template(prompt_template).render(
initial_goal=state.initial_goal,
@@ -237,6 +323,21 @@ class ObservationPromptComponent(PromptComponent):
builder.add_human_content(f"--- Visible UI Elements ---\n{minimal_list}")
class PlanRecitationPromptComponent(PromptComponent):
"""Per-turn task-plan recitation for the transcript tail (M2).
With the plan+history section moved out of the (now static) system
message, every observation recites the live task plan; the scrub edge
strips the copies from older turns at depth 1.
"""
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
from artemis.memory.transcript import PLAN_RECITATION_MARKER
task_plan = kwargs.get("task_plan") or "No task plan yet."
builder.add_human_content(f"{PLAN_RECITATION_MARKER}\n{task_plan}")
class FeedbackPromptComponent(PromptComponent):
"""Append-only injection of source-tagged findings.
@@ -336,14 +437,6 @@ class BackgroundTasksPromptComponent(PromptComponent):
builder.add_human_content("\n".join(lines) + "\n")
class ShortTermMemoryPromptComponent(PromptComponent):
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
if state.short_term_memory:
builder.add_human_content(
f"--- Short-Term Memory (Scratchpad) ---\n{state.short_term_memory}\n"
)
class TaskPlanWarningPromptComponent(PromptComponent):
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
steps = kwargs.get("steps", [])
@@ -506,3 +599,95 @@ class ScreenshotSimilarityPromptComponent(PromptComponent):
if diff_count > max_allowed:
break
return diff_count
def _recall_enabled() -> bool:
"""Whether the recall_history tool is enabled by configuration (M4)."""
try:
from artemis.config import load_agent_config
return bool(load_agent_config().memory.recall.enabled)
except Exception:
return True
class HistoricalStateHintPromptComponent(PromptComponent):
"""Local historical-state hint from stored perceptual hashes (M4).
Compares the current screenshot's dHash against the post-action hashes
stamped on every recorded step (``extra_metadata["post_image_dhash"]``,
including steps already chunk-compressed out of the visible transcript) —
an O(n) integer scan, no model call, no historical image bytes. On a
close match to a step *older* than the recent window it injects a one-line
hint pointing at ``recall_history``.
Division of labor with :class:`ScreenshotSimilarityPromptComponent`: that
component's pixel-exact 3-step look-back covers "stuck on the same
screen"; this one covers "returned to a much earlier state". When any of
the last :attr:`RECENT_SILENT_STEPS` steps also matches, this hint stays
silent so the two never fire on the same regime.
"""
#: Matches within this many most-recent steps stay silent (the pixel
#: same-screen note owns that window).
RECENT_SILENT_STEPS: int = 3
#: Upper bound on how many historical steps are scanned (most recent first).
SCAN_CAP: int = 500
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
latest_screenshot_b64 = kwargs.get("latest_screenshot_b64")
steps = kwargs.get("steps") or []
if not latest_screenshot_b64 or len(steps) <= self.RECENT_SILENT_STEPS:
return
max_distance = 8
try:
from artemis.config import load_agent_config
transcript_cfg = load_agent_config().memory.transcript
if not getattr(transcript_cfg, "similarity_hint", True):
return
max_distance = int(getattr(transcript_cfg, "similarity_max_distance", 8))
except Exception:
pass
try:
from artemis.utils.image_hash import dhash_hex, hamming_distance_hex
if "," in latest_screenshot_b64:
latest_screenshot_b64 = latest_screenshot_b64.split(",", 1)[1]
current_hash = dhash_hex(base64.b64decode(latest_screenshot_b64))
except Exception:
return
if not current_hash:
return
def _step_hash(step: dict) -> str | None:
meta = step.get("extra_metadata") or {}
return meta.get("post_image_dhash") or meta.get("pre_image_dhash")
# Silence rule first: a close match inside the recent window belongs
# to the pixel-level same-screen note, not this hint.
for step in steps[-self.RECENT_SILENT_STEPS :]:
distance = hamming_distance_hex(current_hash, _step_hash(step))
if distance is not None and distance <= max_distance:
return
older_steps = steps[: -self.RECENT_SILENT_STEPS][-self.SCAN_CAP :]
best_step_number = None
best_distance = None
for step in older_steps:
distance = hamming_distance_hex(current_hash, _step_hash(step))
if distance is None or distance > max_distance:
continue
if best_distance is None or distance <= best_distance:
# <= keeps the most recent step on ties.
best_distance = distance
best_step_number = step.get("step_number")
if best_step_number is not None:
builder.add_human_content(
f"Historical state hint: current screen closely resembles the"
f" post-action screen from Step {best_step_number}. Use"
" recall_history only if its details are needed."
)
+5 -5
View File
@@ -38,7 +38,8 @@ from artemis.tools.scratchpad import (
from artemis.tools.video_tool import get_video_analyzer_tool_pure
from artemis.tools.tool_wrapper import invoke_tool_with_injection
from artemis.utils.logger import get_logger
from artemis.utils.task_tree import build_plan_and_history, get_active_subgoal_hashes
from artemis.memory.context_policy import build_history_for
from artemis.utils.task_tree import get_active_subgoal_hashes
from pydantic import BaseModel
logger = get_logger(__name__)
@@ -78,13 +79,12 @@ async def outputter(
except Exception as e:
logger.error(f"Failed to parse active subgoal in outputter: {e}")
plan_and_history = build_plan_and_history(
plan_and_history = build_history_for(
"outputter",
current_plan,
history,
active_subgoal_hash,
last_n_detailed=0,
min_summaries=len(history),
strict_milestone_pruning=False,
engine=ctx.data_engine,
)
except Exception as e:
logger.error(f"Failed to resolve plan and history in outputter: {e}")
+3 -6
View File
@@ -47,7 +47,7 @@ from artemis.utils.decorators import wrap_with_callbacks
from artemis.utils.logger import get_logger
from artemis.utils.notes import get_note_file_path
from artemis.utils.plan_grammar import render_plan_grammar_spec
from artemis.utils.task_tree import build_plan_and_history
from artemis.memory.context_policy import build_history_for
logger = get_logger(__name__)
@@ -130,14 +130,11 @@ async def run_async_planner_validation(
if ctx.data_engine:
try:
steps = ctx.data_engine.get_agent_friendly_steps()
history_str = build_plan_and_history(
history_str = build_history_for(
"planner",
"", # Pass empty string to avoid rendering the task plan in the history
steps,
"default",
last_n_detailed=2,
strict_milestone_pruning=True,
recent_window_size=5,
chronological_last_step=True,
)
except Exception as e:
+102 -186
View File
@@ -12,27 +12,33 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import json
from pathlib import Path
from uuid import UUID
"""Pro-graph step summarizer node (M2: visual-transition lens dispatch).
from langchain_core.messages import (
HumanMessage,
SystemMessage,
)
Per the history-module redesign §5 (single step-level lens, confirmed
2026-08-31) this node no longer generates a per-step text capsule with its own
LLM call. It dispatches the step to the shared
:class:`~artemis.memory.step_memory.StepMemoryService` visual-transition lens
(``ctx.step_memory``): pre/post screenshots are pulled from the DataEngine by
step id, the red action-overlay and the ``flash_summarizer.md`` neutral-wording
contract apply unchanged, and the result lands as a versioned
``source="visual_transition"`` summary write (M0 semantics).
The auxiliary agents' compiled view (``build_plan_and_history``) is untouched:
a step whose summary is not ready yet falls back to detailed rendering
(``task_tree.py`` semantics), so the transition is safe. The intent/strategy
capsule moves up to the chunk-level ``StepCapsuleLens`` in M3.
"""
import json
from typing import Any
from artemis.context import ArtemisContext
from artemis.data_engine.trace import CURRENT_TRACE_ID, trace
from artemis.data_engine.trace import trace
from artemis.graph.state import State
from artemis.graph.visibility import strict_state
from artemis.services.llm import acomplete, get_llm
from artemis.utils.coordinates import normalize_any_structure
from artemis.memory import ensure_step_memory
from artemis.utils.logger import get_logger
from artemis.utils.task_tree import (
build_plan_and_history,
get_active_subgoal_hashes,
)
from artemis.utils.task_tree import format_result_clean
logger = get_logger(__name__)
@@ -44,180 +50,90 @@ class SummarizerNode:
@trace(type="agent", name="summarizer")
async def __call__(self, state: State):
state = strict_state(state, "summarizer")
# 1. Trigger background task for summary and data engine update
if self.ctx.data_engine and state.current_step_id:
step_id = UUID(state.current_step_id)
decisions = state.structured_decisions
operator_raw_thinking = getattr(state, "operator_raw_thinking", None)
operator_native_thinking = getattr(state, "operator_native_thinking", None)
last_execution_result = getattr(state, "last_execution_result", None)
# Spawn background task
trace_id = CURRENT_TRACE_ID.get()
task = asyncio.create_task(
self._generate_summary_and_update_engine(
step_id,
decisions,
operator_raw_thinking,
operator_native_thinking,
last_execution_result,
trace_id,
),
name=f"summarizer_step_{step_id}",
)
if hasattr(self.ctx, "background_tasks") and isinstance(
self.ctx.background_tasks, list
):
self.ctx.background_tasks.append(task)
if not (self.ctx.data_engine and state.current_step_id):
return {}
try:
self._dispatch_visual_summary(state)
except Exception as e:
logger.error(f"Failed to dispatch visual step summary: {e}")
return {}
async def _generate_summary_and_update_engine(
self,
step_id,
decisions,
operator_raw_thinking,
operator_native_thinking,
last_execution_result,
trace_id,
):
"""Background task to generate summary and update Data Engine with retries."""
def _dispatch_visual_summary(self, state: State) -> None:
engine = self.ctx.data_engine
step_id = state.current_step_id
max_retries = 3
retry_delay = 2
step_number = None
try:
step_number = engine.get_step_number(step_id)
except Exception as e:
logger.warning(f"Could not resolve step number for {step_id}: {e}")
for attempt in range(max_retries):
record = None
if isinstance(step_number, int):
try:
logger.info(f"Generating summary for step {step_id} (attempt {attempt + 1})")
# Prepare prompt for summary
llm = get_llm(ctx=self.ctx, name="summarizer")
prompt_path = Path(__file__).parent.joinpath("summarizer.md")
if not prompt_path.exists():
prompt = (
"You are a summarizer. Summarize the latest action and"
" its result in 1-2 concise sentences. Focus on: Action"
" taken -> Result -> Observation."
)
else:
prompt = prompt_path.read_text(encoding="utf-8")
# Normalize coordinates before presenting to the summarizer LLM
width = (
getattr(self.ctx.device, "device_width", 1080)
if self.ctx and self.ctx.device
else 1080
)
height = (
getattr(self.ctx.device, "device_height", 2400)
if self.ctx and self.ctx.device
else 2400
)
content = []
# Consistently use optimal build_plan_and_history to construct accurate context
plan_and_history_str = ""
if self.ctx.data_engine:
try:
friendly_steps = self.ctx.data_engine.get_agent_friendly_steps()
if isinstance(friendly_steps, list):
# 1. Trim history to only include steps up to the current step being summarized (inclusive)
current_steps = []
for s in friendly_steps:
current_steps.append(s)
if str(s.get("step_id")) == str(step_id):
break
# 2. Read task plan to provide goal orientation to the summarizer
task_plan = ""
base_dir = getattr(self.ctx.data_engine, "base_dir", None)
if base_dir and isinstance(base_dir, (str, Path)):
notes_dir = Path(base_dir) / "notes"
task_plan_path = notes_dir / "task_plan.md"
if task_plan_path.exists():
task_plan = task_plan_path.read_text(encoding="utf-8")
# 3. Unified rendering: concise for first N steps (with self-healing facts), detailed for current step (without redundant summary header)
subgoal_hash = "default"
if task_plan:
subgoal_hash, _ = get_active_subgoal_hashes(task_plan)
plan_and_history_str = build_plan_and_history(
task_plan=task_plan,
steps=current_steps,
current_subgoal_hash=subgoal_hash,
last_n_detailed=1,
min_summaries=10,
strict_milestone_pruning=False,
)
except Exception as e:
logger.error(f"Failed to build plan and history for Summarizer: {e}")
if plan_and_history_str:
content.append(plan_and_history_str)
else:
# Fallback to standard raw fields if chronological trace couldn't be generated
if decisions:
try:
dec_obj = json.loads(decisions)
dec_obj = normalize_any_structure(dec_obj, width, height)
content.append(
f"Decisions made: {json.dumps(dec_obj, ensure_ascii=False)}"
)
except Exception:
content.append(f"Decisions made: {decisions}")
if operator_raw_thinking:
content.append(f"Operator explicit thoughts:\n{operator_raw_thinking}")
if operator_native_thinking:
content.append(f"Operator native thoughts:\n{operator_native_thinking}")
messages = [
SystemMessage(content=prompt),
HumanMessage(content="\n\n".join(content)),
]
aggregated_chunk = await acomplete(llm, messages)
generated_text = ""
content = getattr(aggregated_chunk, "content", "") if aggregated_chunk else ""
if isinstance(content, str):
generated_text = content
elif isinstance(content, list):
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
generated_text += item.get("text", "")
summary = generated_text.strip()
logger.info(f"Summary generated: {summary}")
if aggregated_chunk and getattr(aggregated_chunk, "usage_metadata", None):
usage = aggregated_chunk.usage_metadata
cached_tokens = usage.get("input_token_details", {}).get("cache_read", 0)
logger.info(
"Summarizer LLM usage:"
f" prompt_tokens={usage.get('input_tokens')}"
f" (cached={cached_tokens}),"
f" completion_tokens={usage.get('output_tokens')}"
)
# Update Data Engine
self.ctx.data_engine.update_step_summary(step_id, summary)
logger.info(f"Data Engine updated with summary for step {step_id}")
return # Success
record = engine.get_step_record(step_number)
except Exception as e:
logger.error(
f"Failed to generate summary or update engine (attempt {attempt + 1}): {e}"
)
if attempt < max_retries - 1:
await asyncio.sleep(retry_delay)
retry_delay *= 2 # Exponential backoff
else:
logger.error(
f"Max retries reached for step {step_id}. Summary generation failed."
)
logger.warning(f"Could not load step record {step_number}: {e}")
# Graceful degradation: a missing pre/post image simply dispatches with
# None bytes — the lens renders a text-only transition account.
pre_bytes = self._read_step_image(step_number, "pre")
post_bytes = self._read_step_image(step_number, "post")
action_name, action_args = self._extract_action(record, state)
result = getattr(state, "last_execution_result", None)
# format_result_clean reports only errors/repairs; a clean run keeps
# the controller's bare status word.
exec_outcome = format_result_clean(result)
if not exec_outcome:
if isinstance(result, dict) and result.get("status"):
exec_outcome = str(result["status"])
else:
exec_outcome = "unknown"
service = ensure_step_memory(self.ctx)
service.dispatch(
step_number=step_number if isinstance(step_number, int) else 0,
action_name=action_name,
action_args=action_args,
pre_img_bytes=pre_bytes,
post_img_bytes=post_bytes,
exec_outcome=exec_outcome,
data_engine_step_id=step_id,
)
logger.info(
f"Dispatched visual transition summary for step {step_number} ({step_id})"
)
def _read_step_image(self, step_number: int | None, which: str) -> bytes | None:
if not isinstance(step_number, int):
return None
try:
path = self.ctx.data_engine.get_step_image_path(step_number, which)
if path is None:
return None
return path.read_bytes()
except Exception as e:
logger.warning(f"Could not read {which} image for step {step_number}: {e}")
return None
@staticmethod
def _extract_action(record: Any, state: State) -> tuple[str, dict]:
actions = getattr(record, "action_taken", None) if record is not None else None
if not actions:
decisions = getattr(state, "structured_decisions", None)
if decisions:
try:
actions = json.loads(decisions)
except Exception:
actions = None
if isinstance(actions, dict):
actions = [actions]
if not isinstance(actions, list) or not actions or not isinstance(actions[0], dict):
return "no_op", {}
first = actions[0]
action_name = first.get("action") or "unknown"
action_args = {k: v for k, v in first.items() if k != "action"}
if len(actions) > 1:
action_args["additional_actions"] = actions[1:]
return action_name, action_args
+3 -7
View File
@@ -51,8 +51,8 @@ from artemis.services.llm import get_llm
from artemis.utils.logger import get_logger
from artemis.utils.notes import get_note_file_path
from artemis.utils.ocr_xml_fusion import fuse_ocr_with_xml
from artemis.memory.context_policy import build_history_for
from artemis.utils.task_tree import (
build_plan_and_history,
format_action_clean,
get_active_subgoal_hashes,
)
@@ -358,15 +358,11 @@ class FailureAnalyzer:
def _build_plan_and_history(self, steps: list[dict], task_plan: str) -> str:
active_subgoal_hash = self._get_active_subgoal_hash()
return build_plan_and_history(
return build_history_for(
"failure_analyzer",
task_plan,
steps,
active_subgoal_hash,
last_n_detailed=1,
strict_milestone_pruning=True,
recent_window_size=3,
chronological_last_step=True,
for_failure_analyzer=True,
)
def _prune_intermediate_screenshots(self, messages: list[Any]):
+8
View File
@@ -20,6 +20,10 @@ from artemis.config.agent import (
CommitteeConfig,
ExplorerConfig,
FlashProfileConfig,
MemoryChunkingConfig,
MemoryConfig,
MemoryRuntimeConfig,
MemoryTranscriptConfig,
OutputterConfig,
PlannerValidationConfig,
ProProfileConfig,
@@ -197,6 +201,10 @@ __all__ = [
"CommitteeConfig",
"ExplorerConfig",
"FlashProfileConfig",
"MemoryChunkingConfig",
"MemoryConfig",
"MemoryRuntimeConfig",
"MemoryTranscriptConfig",
"OutputterConfig",
"PlannerValidationConfig",
"ProProfileConfig",
+272
View File
@@ -289,6 +289,262 @@ class StepSummarizerConfig(BaseModel):
default=True,
description="Whether to prune outdated heavy UI XML trees from historical turns.",
)
retry_limit: int = Field(
default=3,
ge=0,
description=(
"Maximum retries per step summary after the first attempt; on"
" exhaustion the step is marked summary_status=failed instead of"
" retrying forever."
),
)
model_config = {"extra": "allow"}
class MemoryRuntimeConfig(BaseModel):
"""Scheduling options for the shared step-memory runtime (agent.memory.runtime)."""
max_concurrency: int = Field(
default=2,
ge=1,
le=16,
description=(
"Maximum background summary attempts running concurrently."
" Default 2 per the 2026-09-01 on-device baseline"
" (history-baseline-2026-09-01.md §6): the serial queue at 1 pushed"
" summary-ready P90 past the scrub-edge grace window; lens calls"
" are independent lightweight-model requests, so 2 is low-risk."
),
)
retry_limit: int = Field(
default=3,
ge=0,
description=(
"Maximum retries per summary job after the first attempt; on"
" exhaustion the job enters an explicit failed state."
),
)
flush_timeout_s: float = Field(
default=30.0,
gt=0,
description="Upper bound in seconds for draining in-flight summary jobs at shutdown.",
)
model_config = {"extra": "allow"}
class MemoryTranscriptConfig(BaseModel):
"""Scrub-edge options for the message transcript (agent.memory.transcript)."""
enabled: bool = Field(
default=True,
description=(
"Whether the Pro operator builds its prompt from the session"
" transcript ledger (S/F/A regions + per-turn tail) instead of the"
" legacy per-turn 2-message rebuild. On by default since M5"
" (2026-09-01, per the on-device A/B in"
" history-baseline-2026-09-01.md); this is also the rollback"
" switch — setting it to false restores the legacy path"
" byte-for-byte, and L2/L3 chunk compression rides the same flag."
),
)
image_scrub_depth: int = Field(
default=3,
ge=1,
description=(
"Depth K of the screenshot scrub edge: the K-th most recent"
" historical screenshot is replaced by its visual summary."
),
)
pending_grace_steps: int = Field(
default=3,
ge=0,
description=(
"Extra image-depths a step may retain its screenshot past the"
" scrub edge while its summary is still pending; afterwards the"
" image is replaced by a placeholder referencing the DataEngine step."
),
)
xml_scrub_depth: int = Field(
default=1,
ge=1,
description=(
"How many of the most recent observations keep their UI Element"
" list; older lists are stripped (depth 1 matches the legacy"
" prune_history_xml semantics)."
),
)
context_budget_tokens: int = Field(
default=80000,
ge=1024,
description=(
"Measured-token budget for the transcript context (provisional"
" default pending the on-device baseline; §3.4). The soft/hard"
" ratios below are applied against this budget."
),
)
soft_ratio: float = Field(
default=0.7,
gt=0.0,
lt=1.0,
description=(
"Soft threshold: when the last measured prompt size reaches"
" budget*soft_ratio, the oldest open segment is chunk-compressed"
" (L2)."
),
)
hard_ratio: float = Field(
default=0.9,
gt=0.0,
le=1.0,
description=(
"Hard threshold: when the last measured prompt size reaches"
" budget*hard_ratio, the frozen region collapses to the L3 session"
" snapshot (chunk headers merged; per-step index retained)."
),
)
min_active_steps: int = Field(
default=5,
ge=1,
description=(
"Sliding-window floor: no compression trigger may shrink the"
" active region below this many most-recent raw turns."
),
)
similarity_hint: bool = Field(
default=True,
description=(
"Whether the operator prompt injects a local historical-state hint"
" when the current screen's perceptual hash closely matches a much"
" older step's post-action screen (no model call, no historical"
" image). Silent when the match is within the recent 3 steps —"
" that regime belongs to the pixel-level same-screen note."
),
)
similarity_max_distance: int = Field(
default=5,
ge=0,
le=64,
description=(
"Maximum Hamming distance between 64-bit dHash values for the"
" historical-state hint to fire. Calibrated 2026-09-01 from 460"
" dHash-stamped on-device steps (16 sessions): same-screen"
" re-captures cluster at distance <=4, genuinely different screens"
" mass at >=7 (valley at 5-6); cross-app pairs (guaranteed"
" different screens) score <=5 in only 0.14% of cases vs 0.24% at"
" the previous provisional 8."
),
)
model_config = {"extra": "allow"}
class MemoryRecallConfig(BaseModel):
"""History recall tool options (agent.memory.recall)."""
enabled: bool = Field(
default=True,
description=(
"Whether the Pro operator gets the recall_history tool for"
" deterministic lookups into cold (compressed/evicted) history."
),
)
max_results: int = Field(
default=5,
ge=1,
le=20,
description="Upper bound on results per recall_history call.",
)
max_text_tokens: int = Field(
default=2000,
ge=128,
description=(
"Estimated-token cap (char/4) on one recall_history text response;"
" excerpts are truncated to fit."
),
)
max_image_steps: int = Field(
default=1,
ge=0,
le=1,
description=(
"Maximum number of steps whose real stored screenshots may be"
" returned as data URLs when include_images is requested."
),
)
model_config = {"extra": "allow"}
class MemoryChunkingConfig(BaseModel):
"""Segment (L2) chunk compression options (agent.memory.chunking)."""
max_steps: int = Field(
default=12,
ge=1,
description=(
"Size-threshold trigger: an open segment of this many steps is"
" chunk-compressed even without a milestone switch. Also the"
" maximum steps per chunk when a longer segment is split."
),
)
target_source_tokens: int = Field(
default=2000,
ge=128,
description=(
"Size-threshold trigger: estimated source tokens (char/4 over the"
" open segment's transcript text) that force a chunk compression."
),
)
model: str = Field(
default="gemini-3.7-flash",
description="Model used for the chunk-level StepCapsuleLens (bands ①+②).",
)
max_chunks: int = Field(
default=8,
ge=1,
description=(
"Maximum full three-band chunk blocks kept in the frozen region;"
" older chunks merge into eras (headers set-merged, ledgers kept)."
),
)
max_eras: int | None = Field(
default=None,
ge=1,
description=(
"Maximum eras kept with their per-segment ledgers; older eras"
" collapse their ledgers to recall_history marker lines. None"
" (default) follows max_chunks, preserving the pre-M5 equal-value"
" behavior."
),
)
model_config = {"extra": "allow"}
class MemoryConfig(BaseModel):
"""Unified step-memory configuration block (agent.memory)."""
runtime: MemoryRuntimeConfig = Field(
default_factory=MemoryRuntimeConfig,
description="Shared background summarization runtime scheduling options.",
)
transcript: MemoryTranscriptConfig = Field(
default_factory=MemoryTranscriptConfig,
description="Transcript scrub-edge options.",
)
chunking: MemoryChunkingConfig = Field(
default_factory=MemoryChunkingConfig,
description="Segment chunk (L2) compression options.",
)
recall: MemoryRecallConfig = Field(
default_factory=MemoryRecallConfig,
description="History recall tool options.",
)
policies: dict[str, dict[str, Any]] = Field(
default_factory=dict,
description=(
"Per-agent overrides of the compiled-history ContextPolicy table"
" (artemis.memory.context_policy); keys are agent names, values"
" are policy field overrides (e.g. {'planner': {'last_n_detailed': 3}})."
),
)
model_config = {"extra": "allow"}
@@ -398,6 +654,10 @@ class AgentGlobalConfig(BaseModel):
default_factory=VideoAnalyzerConfig,
description="Video analyzer runtime options.",
)
memory: MemoryConfig = Field(
default_factory=MemoryConfig,
description="Unified step-memory runtime and transcript scrub options.",
)
model_config = {"extra": "allow"}
@@ -452,6 +712,18 @@ class AgentGlobalConfig(BaseModel):
):
data.setdefault("pro", {})["video_analyzer"] = data["video_analyzer"]
# 3b. Legacy alias: an explicit flash.step_summarizer.retry_limit keeps
# working by seeding memory.runtime.retry_limit when the new key is
# absent (an explicit new key wins over the legacy one).
flash_block = data.get("flash")
flash_ss = flash_block.get("step_summarizer") if isinstance(flash_block, dict) else None
if isinstance(flash_ss, dict) and "retry_limit" in flash_ss:
mem_block = data.setdefault("memory", {})
if isinstance(mem_block, dict):
runtime_block = mem_block.setdefault("runtime", {})
if isinstance(runtime_block, dict):
runtime_block.setdefault("retry_limit", flash_ss["retry_limit"])
# 4. Environment variable overrides (highest precedence for runtime switches)
def _parse_bool(var_name: str) -> bool | None:
v = os.getenv(var_name)
-1
View File
@@ -13,7 +13,6 @@
# limitations under the License.
RECURSION_LIMIT = 30000
MAX_MESSAGES_IN_HISTORY = 25
# Optimization Constants
CHECKER_MAX_ITERATIONS = 20
+36
View File
@@ -232,6 +232,17 @@ class ArtemisContext(BaseModel):
"""Optional actuator backend override (artemis.mcp.actuators). ``None`` selects
the default AdbActuator; installing e.g. a robot-arm backend happens here."""
step_memory: Any | None = None
"""Shared :class:`~artemis.memory.step_memory.StepMemoryService` composition
root (history redesign §6.2). Created on first use via
``artemis.memory.ensure_step_memory``; Flash and Pro profiles share one
instance so summary jobs, retries, and flush semantics are unified."""
transcript_ledger: Any | None = None
"""Session :class:`~artemis.memory.transcript.TranscriptLedger` for the Pro
operator's transcript prompt path (``agent.memory.transcript.enabled``).
Lives beside ``step_memory``; the State never carries message payloads."""
async def __aenter__(self) -> ArtemisContext:
return self
@@ -246,6 +257,31 @@ class ArtemisContext(BaseModel):
finally:
self.action_session = None
# Drain in-flight step-summary jobs before the generic background-task
# sweep: the shared service owns its own bounded flush semantics. On an
# exceptional exit the flush is skipped and the jobs are cancelled below.
if self.step_memory is not None and callable(getattr(self.step_memory, "flush", None)):
try:
if exc_type is None:
await self.step_memory.flush()
else:
await self.step_memory.flush(timeout_seconds=0.0)
except Exception:
pass
# Drain in-flight chunk capsule jobs the same way (M3): the chunk
# manager persists any harvested capsules so the DB copy is complete
# even though the frozen transcript text is not re-rendered.
chunker = getattr(self.transcript_ledger, "chunker", None)
if chunker is not None and callable(getattr(chunker, "flush", None)):
try:
if exc_type is None:
await chunker.flush()
else:
await chunker.flush(timeout_seconds=0.0)
except Exception:
pass
if self.background_tasks:
tasks = list(self.background_tasks)
pending_tasks = [task for task in tasks if not task.done()]
+152 -4
View File
@@ -14,6 +14,7 @@
import asyncio
from collections.abc import Callable
import functools
import hashlib
import json
import os
@@ -29,6 +30,7 @@ from artemis.context import ArtemisContext
from artemis.data_engine.models import (
BackgroundTaskRecord,
FailedOutputRecord,
HistoryChunkRecord,
ImageRecord,
SessionMetadata,
StepRecord,
@@ -48,6 +50,43 @@ logger = get_logger(__name__)
_CURRENT_DATA_ENGINE = None
# Overlay packages that never identify the app the user is working in.
_FOREGROUND_APP_IGNORED = {"com.android.systemui"}
def _derive_foreground_app(ui_tree: Any | None) -> str | None:
"""Best-effort foreground package from a perception UI tree (M5).
Counts ``package``/``packageName`` attributes across the node list
(recursing into ``children``), ignores system-overlay packages, and
returns the most frequent value. Pure string work — never raises, never
touches the device.
"""
counts: dict[str, int] = {}
def _visit(node: Any, depth: int = 0) -> None:
if depth > 50:
return
if isinstance(node, dict):
pkg = node.get("package") or node.get("packageName")
if isinstance(pkg, str) and pkg and pkg not in _FOREGROUND_APP_IGNORED:
counts[pkg] = counts.get(pkg, 0) + 1
children = node.get("children")
if isinstance(children, (list, tuple)):
for child in children:
_visit(child, depth + 1)
elif isinstance(node, (list, tuple)):
for child in node:
_visit(child, depth + 1)
try:
_visit(ui_tree)
except Exception:
return None
if not counts:
return None
return max(counts.items(), key=lambda kv: kv[1])[0]
class DataEngine:
"""Core engine for managing runtime data, storage, and streaming."""
@@ -538,6 +577,33 @@ class DataEngine:
if pre_image_name and post_image_name and pre_image_name == post_image_name:
post_image_name = None
# M5: persist the foreground app for the recall search surface (the
# parameter was historically accepted but never stored). The explicit
# parameter wins; otherwise it is derived best-effort from the UI
# tree's package attributes — a pure string scan, no device call.
extra_metadata = dict(extra_metadata or {})
try:
app = foreground_app or _derive_foreground_app(ui_tree)
if app:
extra_metadata.setdefault("foreground_app", app)
except Exception:
pass
# M4: stamp perceptual hashes for the local screen-similarity hint
# (pure PIL dHash; best-effort, computed synchronously to keep the
# record immutable once handed to the background writer).
try:
from artemis.utils.image_hash import dhash_hex
pre_dhash = dhash_hex(pre_screenshot_bytes)
if pre_dhash:
extra_metadata.setdefault("pre_image_dhash", pre_dhash)
post_dhash = dhash_hex(post_screenshot_bytes) if post_image_name else pre_dhash
if post_dhash:
extra_metadata.setdefault("post_image_dhash", post_dhash)
except Exception:
pass
step = StepRecord(
step_id=step_id,
session_id=self.current_session_id,
@@ -550,7 +616,7 @@ class DataEngine:
operator_raw_thinking=operator_raw_thinking,
operator_native_thinking=operator_native_thinking,
last_execution_result=last_execution_result,
extra_metadata=extra_metadata or {},
extra_metadata=extra_metadata,
)
def _run_background_storage():
@@ -971,8 +1037,24 @@ class DataEngine:
path = self.get_image_path(image_name)
return path if path.exists() else None
def update_step_summary(self, step_id: UUID | int | str, summary: str):
"""Update the step with a concise summary in background."""
def update_step_summary(
self,
step_id: UUID | int | str,
summary: str | None,
*,
source: str | None = None,
version: int | None = None,
model: str | None = None,
status: str = "ready",
):
"""Update the step summary in background, with status/version metadata.
Writes ``summary_status`` / ``summary_source`` / ``summary_version`` /
``summary_model`` into the step's ``extra_metadata`` alongside the
summary text; concurrent same-step writes are ordered by version in the
storage layer. ``status`` may be ``pending`` / ``ready`` / ``failed`` /
``stale``; only ``ready`` writes carry summary text and publish SSE.
"""
target_uuid = None
if isinstance(step_id, int):
if self.current_session_id:
@@ -993,7 +1075,19 @@ class DataEngine:
logger.debug(f"Could not resolve step_id for {step_id} to update summary.")
return
self._run_in_background(self.storage.update_step_summary, target_uuid, summary)
self._run_in_background(
functools.partial(
self.storage.update_step_summary,
target_uuid,
summary,
source=source,
version=version,
model=model,
status=status,
)
)
if status != "ready" or summary is None:
return
step_num = self.get_step_number(target_uuid if not isinstance(step_id, int) else step_id)
payload = {
"step_id": str(target_uuid),
@@ -1004,6 +1098,60 @@ class DataEngine:
payload["step_number"] = step_num
self._publish("step_updated", payload)
def record_history_chunk(
self,
*,
start_step_number: int,
end_step_number: int,
version: int,
status: str,
start_step_id: str | None = None,
end_step_id: str | None = None,
source_step_ids: list[str] | None = None,
subgoal_hash: str | None = None,
band1: dict[str, Any] | None = None,
band2: str | None = None,
band3: str | None = None,
rendered_text: str | None = None,
) -> UUID | None:
"""Persist one history-chunk version row in the background (append-only).
Chunk identity is the step range; callers own version numbering (a
newer version for the same range supersedes older ones at read time).
"""
if not self.current_session_id:
return None
chunk = HistoryChunkRecord(
chunk_id=uuid4(),
session_id=self.current_session_id,
start_step_id=start_step_id,
end_step_id=end_step_id,
start_step_number=start_step_number,
end_step_number=end_step_number,
source_step_ids=source_step_ids or [],
subgoal_hash=subgoal_hash,
version=version,
status=status,
band1=band1 or {},
band2=band2,
band3=band3,
rendered_text=rendered_text,
)
self._run_in_background(self.storage.create_history_chunk, chunk)
return chunk.chunk_id
def get_history_chunks(self, *, all_versions: bool = False) -> list[HistoryChunkRecord]:
"""History chunks of the active session (newest version per step range)."""
if not self.current_session_id:
return []
try:
return self.storage.get_history_chunks(
self.current_session_id, all_versions=all_versions
)
except Exception as e:
logger.error(f"Failed to fetch history chunks: {e}")
return []
def update_step_thinking(self, step_id: UUID, operator_raw_thinking: str):
"""Update step's thinking in SQLite in background."""
self._run_in_background(self.storage.update_step_thinking, step_id, operator_raw_thinking)
+27
View File
@@ -55,6 +55,33 @@ class StepRecord(BaseModel):
extra_metadata: dict[str, Any] = Field(default_factory=dict)
class HistoryChunkRecord(BaseModel):
"""One version of a compressed history segment (history redesign §3.3/§6.1).
Identity is the step *range* ``(session_id, start_step_number,
end_step_number)``; the milestone ``subgoal_hash`` is label metadata only.
Rows are append-only: a later write for the same range carries a higher
``version`` (capsule readiness, post-hoc checkpoint annotations).
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
chunk_id: UUID | str
session_id: UUID | str
start_step_id: str | None = None
end_step_id: str | None = None
start_step_number: int
end_step_number: int
source_step_ids: list[str] = Field(default_factory=list)
subgoal_hash: str | None = None
version: int = 1
status: str = "pending" # pending (band ③ only), ready (bands ①②③)
band1: dict[str, Any] = Field(default_factory=dict) # synopsis + structured fields
band2: str | None = None # interval narrative
band3: str | None = None # mechanical per-step action ledger
rendered_text: str | None = None
created_at: float = Field(default_factory=time.time)
class TraceRecord(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
trace_id: UUID | str
+185 -7
View File
@@ -27,6 +27,7 @@ from uuid import UUID
from artemis.data_engine.models import (
BackgroundTaskRecord,
FailedOutputRecord,
HistoryChunkRecord,
ImageRecord,
SessionMetadata,
StepRecord,
@@ -205,6 +206,26 @@ class StorageManager:
FOREIGN KEY(session_id) REFERENCES sessions(session_id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS history_chunks (
chunk_id TEXT PRIMARY KEY,
session_id TEXT,
start_step_id TEXT,
end_step_id TEXT,
start_step_number INTEGER,
end_step_number INTEGER,
source_step_ids TEXT,
subgoal_hash TEXT,
version INTEGER,
status TEXT,
band1 TEXT,
band2 TEXT,
band3 TEXT,
rendered_text TEXT,
created_at REAL,
FOREIGN KEY(session_id) REFERENCES sessions(session_id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS video_recordings (
video_id TEXT PRIMARY KEY,
@@ -534,22 +555,179 @@ class StorageManager:
)
conn.commit()
def update_step_summary(self, step_id: UUID, summary: str):
"""Update the summary for a step."""
def update_step_summary(
self,
step_id: UUID,
summary: str | None,
*,
source: str | None = None,
version: int | None = None,
model: str | None = None,
status: str = "ready",
) -> bool:
"""Versioned, status-carrying summary write (replaces the blind overwrite).
Writes ``summary_status`` / ``summary_source`` / ``summary_version`` /
``summary_model`` into the step's ``extra_metadata``. Concurrent writes
to the same step are ordered by version: an explicit ``version`` lower
than the stored one is dropped; ``version=None`` auto-increments the
stored version. Status downgrades are refused without a newer explicit
version: ``pending`` never overwrites ``ready``/``failed``, and
``failed`` never overwrites ``ready``. The ``summary`` column itself is
only touched by a ``ready`` write with a non-None summary.
Returns True when the write was applied, False when it was dropped as
stale (or the step does not exist).
"""
with self._get_connection() as conn:
cursor = conn.execute(
"SELECT extra_metadata FROM steps WHERE step_id = ?",
(str(step_id),),
)
row = cursor.fetchone()
if row is None:
return False
try:
meta = json.loads(row["extra_metadata"]) if row["extra_metadata"] else {}
if not isinstance(meta, dict):
meta = {}
except Exception:
meta = {}
current_version = meta.get("summary_version")
current_version = current_version if isinstance(current_version, int) else 0
current_status = meta.get("summary_status")
if version is not None:
if version < current_version:
return False
new_version = version
is_newer = version > current_version
else:
new_version = current_version + 1
is_newer = False
# Status downgrades require an explicitly newer version.
if not is_newer:
if status == "pending" and current_status in ("ready", "failed"):
return False
if status == "failed" and current_status == "ready":
return False
meta["summary_status"] = status
meta["summary_version"] = new_version
if source is not None:
meta["summary_source"] = source
if model is not None:
meta["summary_model"] = model
if status == "ready" and summary is not None:
conn.execute(
"UPDATE steps SET summary = ?, extra_metadata = ? WHERE step_id = ?",
(summary, json.dumps(meta), str(step_id)),
)
else:
conn.execute(
"UPDATE steps SET extra_metadata = ? WHERE step_id = ?",
(json.dumps(meta), str(step_id)),
)
conn.commit()
return True
def create_history_chunk(self, chunk: HistoryChunkRecord):
"""Insert one history-chunk version row (append-only; never updates)."""
with self._get_connection() as conn:
conn.execute(
"""
UPDATE steps
SET summary = ?
WHERE step_id = ?
INSERT INTO history_chunks (
chunk_id, session_id, start_step_id, end_step_id,
start_step_number, end_step_number, source_step_ids,
subgoal_hash, version, status, band1, band2, band3,
rendered_text, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
summary,
str(step_id),
str(chunk.chunk_id),
str(chunk.session_id),
chunk.start_step_id,
chunk.end_step_id,
chunk.start_step_number,
chunk.end_step_number,
json.dumps(chunk.source_step_ids),
chunk.subgoal_hash,
chunk.version,
chunk.status,
json.dumps(chunk.band1, ensure_ascii=False, cls=SafeJSONEncoder),
chunk.band2,
chunk.band3,
chunk.rendered_text,
chunk.created_at,
),
)
conn.commit()
def get_history_chunks(
self, session_id: UUID | str, *, all_versions: bool = False
) -> list[HistoryChunkRecord]:
"""History chunks of a session ordered by step range.
By default only the newest version per step range is returned; pass
``all_versions=True`` for the full append-only trail (audit/tests).
"""
with self._get_connection() as conn:
cursor = conn.execute(
"""
SELECT * FROM history_chunks WHERE session_id = ?
ORDER BY start_step_number ASC, version ASC, created_at ASC
""",
(str(session_id),),
)
rows = cursor.fetchall()
records: list[HistoryChunkRecord] = []
for row in rows:
try:
source_ids = json.loads(row["source_step_ids"]) if row["source_step_ids"] else []
except Exception:
source_ids = []
try:
band1 = json.loads(row["band1"]) if row["band1"] else {}
if not isinstance(band1, dict):
band1 = {}
except Exception:
band1 = {}
records.append(
HistoryChunkRecord(
chunk_id=row["chunk_id"],
session_id=row["session_id"],
start_step_id=row["start_step_id"],
end_step_id=row["end_step_id"],
start_step_number=row["start_step_number"],
end_step_number=row["end_step_number"],
source_step_ids=source_ids,
subgoal_hash=row["subgoal_hash"],
version=row["version"],
status=row["status"],
band1=band1,
band2=row["band2"],
band3=row["band3"],
rendered_text=row["rendered_text"],
created_at=row["created_at"],
)
)
if all_versions:
return records
latest: dict[tuple[int, int], HistoryChunkRecord] = {}
for rec in records:
key = (rec.start_step_number, rec.end_step_number)
current = latest.get(key)
if current is None or rec.version >= current.version:
latest[key] = rec
return sorted(latest.values(), key=lambda r: r.start_step_number)
def update_step_thinking(self, step_id: UUID, operator_raw_thinking: str):
"""Update the raw thinking process for a step."""
with self._get_connection() as conn:
+23
View File
@@ -302,6 +302,28 @@ def _record_verdicts(
)
def _annotate_history_chunks(ctx, run: CheckpointRun, verdicts) -> None:
"""Post-hoc chunk annotation (M3 §9): a verdict harvested after its subgoal's
segment was chunk-compressed is appended to that chunk and bumps its
version. Best-effort — history annotation never gates checkpoint booking."""
try:
chunker = getattr(getattr(ctx, "transcript_ledger", None), "chunker", None)
if chunker is None:
return
payload = [
{
"kind": getattr(v, "kind", "check"),
"item_text": getattr(v, "item_text", ""),
"status": getattr(v, "status", ""),
"evidence": getattr(v, "evidence", ""),
}
for v in verdicts
]
chunker.annotate_from_checkpoint(run.checkpoint.checkpoint_id, payload)
except Exception as e:
logger.debug(f"History chunk annotation skipped: {e}")
def _record_attempt_failure(ctx, run: CheckpointRun, status: str, evidence: str) -> None:
base_dir = ctx.data_engine.base_dir
for ci in run.checkpoint.check_items:
@@ -367,6 +389,7 @@ def harvest_run(
report = task.result()
verdicts = list(getattr(report, "verdicts", []) or [])
_record_verdicts(ctx, run, verdicts, anchor_step_id)
_annotate_history_chunks(ctx, run, verdicts)
# Applicability gate for side effects: the verdict must belong to the
# current attempt of its checkpoint AND the anchored subgoal's text must be
+29
View File
@@ -96,6 +96,20 @@ def convergence_node(state: State):
return {}
def _notify_history_chunker(ctx: ArtemisContext, method: str, *args) -> None:
"""Best-effort event feed to the transcript's L2/L3 chunk manager (M3).
The chunker only exists when the transcript flag is on; every failure is
swallowed — history compression must never gate the execution path.
"""
try:
chunker = getattr(getattr(ctx, "transcript_ledger", None), "chunker", None)
if chunker is not None:
getattr(chunker, method)(*args)
except Exception as e:
logger.debug(f"History chunker notification '{method}' skipped: {e}")
async def execution_check_node(state: State, ctx: ArtemisContext):
state = strict_state(state, "execution_check")
logger.info("Starting execution_check_node")
@@ -126,6 +140,10 @@ async def execution_check_node(state: State, ctx: ArtemisContext):
logger.warning(f"Failed to parse structured decisions: {e}")
subgoal_hash_val, sub_subgoal_hash = _get_active_subgoal_hashes(ctx)
# The user's injected instruction is stamped verbatim on the step
# it reached: the chunk ledger (band ③) preserves it as a
# never-evicted line at every compression level (§3.3).
injected = getattr(state, "injected_instruction", None)
step_id = ctx.data_engine.record_step(
pre_screenshot_bytes=screenshot_bytes,
ui_tree=xml_hierarchy,
@@ -138,10 +156,14 @@ async def execution_check_node(state: State, ctx: ArtemisContext):
"sub_subgoal_hash": sub_subgoal_hash,
"width": raw_data.get("width"),
"height": raw_data.get("height"),
**({"injected_instruction": injected} if injected else {}),
**extra_metadata,
},
)
logger.info(f"Recorded step in DataEngine: {step_id}")
# Milestone-switch fact source: the stamped subgoal hash of every
# executed step, compared consecutively inside the chunk manager.
_notify_history_chunker(ctx, "on_step_stamped", str(step_id), subgoal_hash_val)
return str(step_id)
except Exception as e:
logger.error(f"Failed to record step in DataEngine: {e}")
@@ -611,6 +633,10 @@ async def _process_plan_write(
new_completions = new_top_level_completions(before, after)
if new_completions:
queue_checkpoints(ctx, state, after, new_completions, content_after)
# History chunking (M3): a top-level completion only queues an
# UNCONFIRMED boundary — the next stamped step confirms it via an
# actual subgoal-hash change (pseudo-switch protection, §3.3).
_notify_history_chunker(ctx, "queue_boundary_hint")
return result
@@ -823,11 +849,14 @@ async def get_graph(ctx: ArtemisContext) -> CompiledStateGraph:
operator_native_tools.append(t)
# Get specialized tools for Operator
from artemis.tools.history_recall import recall_history_wrapper
operator_specialized_wrappers = [
ask_diagnoser_wrapper,
run_adb_command_wrapper,
manage_task_wrapper,
ask_explorer_wrapper,
recall_history_wrapper,
]
if ctx.execution_setup and ctx.execution_setup.enable_committee:
operator_specialized_wrappers.append(ask_committee_wrapper)
-5
View File
@@ -121,11 +121,6 @@ class State(BaseModel):
"Native/implicit thinking process of the operator",
take_last,
] = None
short_term_memory: Annotated[
str | None,
"Short term memory / scratchpad for the operator",
take_last,
] = None
last_execution_result: Annotated[
dict | None, "Last execution result from validator", take_last
] = None
+6 -2
View File
@@ -83,19 +83,20 @@ NODE_VISIBILITY: dict[str, NodeVisibility] = {
"latest_screenshot",
"indexed_points",
"indexed_elements",
"short_term_memory",
"subagent_calls",
"current_step_id",
"operator_tool_limit_exceeded",
"structured_decisions",
"operator_raw_thinking",
"operator_native_thinking",
# Transcript path (M2): committing the previous turn into the
# ledger appends its validator result message.
"last_execution_result",
},
writes={
"structured_decisions",
"operator_raw_thinking",
"operator_native_thinking",
"short_term_memory",
"indexed_points",
"indexed_elements",
"current_step_id",
@@ -111,6 +112,9 @@ NODE_VISIBILITY: dict[str, NodeVisibility] = {
"operator_native_thinking",
"user_stop_requested",
"initial_goal",
# M3: the turn's injected instruction is stamped verbatim onto the
# step record so the chunk ledger can preserve it (never-evict).
"injected_instruction",
},
writes={
"checker_success",
+69
View File
@@ -54,6 +54,7 @@ from artemis.utils.coordinates import (
compute_smart_swipe_coordinates,
parse_swipe_parameters,
)
from artemis.utils.element_hit_test import hit_test_semantics
from artemis.utils.logger import get_logger
from artemis.utils.notes import (
format_list_notes_failure,
@@ -139,11 +140,19 @@ class McpActionExecutor:
img_bytes: bytes | None = None
shot_path: str | None = None
xml_list: str | None = None
target_semantics: dict[str, Any] | None = None
with span:
try:
session = await self._session_or_start()
wire_name, wire_args, finalize = self._translate(raw_name, args, state)
# Record-time enrichment: bare-coordinate targeted actions get
# best-effort element semantics from the pre-action frame (the
# post-action observe below overwrites state.indexed_elements,
# so this must happen before the device call).
target_semantics = self._target_semantics(raw_name, wire_args, state)
if target_semantics:
span.payload = {"args": {**args, **target_semantics}}
extension = raw_name not in (REQUIRED_ACTIONS | OPTIONAL_ACTIONS)
if extension:
@@ -207,9 +216,69 @@ class McpActionExecutor:
metadata={
"code": res.code.value,
"normalized_coordinates": res.normalized_coordinates,
"target_semantics": target_semantics,
},
)
def _target_semantics(
self, raw_name: str, wire_args: dict[str, Any], state: Any
) -> dict[str, Any] | None:
"""Best-effort element semantics for a coordinate-targeted action.
Hit tests the normalized (0-1000) target point(s) against the
pre-action frame's indexed elements. Single-target actions get the
flat ``target_*`` fields; multi-point actions (``click_sequence``,
``swipe``) hit test every point best-effort (M5), hoisting the FIRST
point's semantics to the main ``target_*`` label and keeping the
per-point results alongside. Returns None for actions without a
resolvable coordinate target; degrades to
``target_label_source: "none"`` when no element data covers the point.
"""
try:
elements = getattr(state, "indexed_elements", None) if state else None
if raw_name in ("click", "long_press", "input_text"):
target = wire_args.get("target")
if not (isinstance(target, (list, tuple)) and len(target) == 2):
return None
return self._hit_test_point(elements, target)
if raw_name == "click_sequence":
sequence = wire_args.get("sequence")
if not (isinstance(sequence, (list, tuple)) and sequence):
return None
points = [self._hit_test_point(elements, p) for p in sequence]
semantics: dict[str, Any] = dict(points[0])
semantics["points_semantics"] = points
return semantics
if raw_name == "swipe":
start = wire_args.get("start")
end = wire_args.get("end")
if not (isinstance(start, (list, tuple)) and len(start) == 2):
return None
semantics = dict(self._hit_test_point(elements, start))
semantics["start_semantics"] = self._hit_test_point(elements, start)
if isinstance(end, (list, tuple)) and len(end) == 2:
semantics["end_semantics"] = self._hit_test_point(elements, end)
return semantics
return None
except Exception as enrich_err:
logger.debug(f"Target semantics enrichment skipped for {raw_name}: {enrich_err}")
return {"target_label_source": "none"}
def _hit_test_point(self, elements: Any, target: Any) -> dict[str, Any]:
"""Hit tests one normalized (0-1000) point; never raises."""
try:
width = getattr(self.ctx.device, "device_width", 1080) if self.ctx.device else 1080
height = getattr(self.ctx.device, "device_height", 2400) if self.ctx.device else 2400
x_px = int(max(0, min(width - 1, float(target[0]) * width / 1000)))
y_px = int(max(0, min(height - 1, float(target[1]) * height / 1000)))
return hit_test_semantics(elements, x_px, y_px)
except Exception:
return {"target_label_source": "none"}
@staticmethod
def _observe_despite_failure(raw_name: str, res: ActionResult) -> bool:
"""Failures that still observe the screen, mirroring the legacy executor.
+1
View File
@@ -122,6 +122,7 @@ BACKEND_INDEPENDENT_TOOLS: frozenset[str] = frozenset(
"run_adb_command",
"manage_task",
"analyze_task_output",
"recall_history",
"report_task_status",
"report_failure_analysis",
}
+84
View File
@@ -0,0 +1,84 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Shared step-memory runtime for background summarization across profiles."""
from artemis.memory.chunking import (
ChunkCapsuleService,
HistoryChunkManager,
StepCapsuleLens,
build_action_ledger,
)
from artemis.memory.context_policy import (
CONTEXT_POLICIES,
ContextPolicy,
build_history_for,
resolve_policy,
)
from artemis.memory.step_memory import StepLens, StepMemoryService
from artemis.memory.transcript import TranscriptLedger, format_session_offset
def ensure_step_memory(ctx):
"""Return the session's shared step-memory service, creating it on demand.
Composition root for ``ctx.step_memory`` (history redesign §6.2): the
first caller — FlashRunner, the Pro SummarizerNode, or the Pro operator's
transcript path — instantiates the visual-transition lens service and
publishes it on the context so every profile shares one runtime.
"""
service = getattr(ctx, "step_memory", None)
if service is not None:
return service
from artemis.agents.flash.summarizer import VisualStepSummarizer
kwargs: dict = {}
model_name = None
try:
from artemis.config import load_agent_config
cfg = load_agent_config()
model_name = cfg.flash.step_summarizer.model
kwargs = {
"retry_limit": cfg.memory.runtime.retry_limit,
"max_concurrency": cfg.memory.runtime.max_concurrency,
"flush_timeout_s": cfg.memory.runtime.flush_timeout_s,
}
except Exception:
pass
service = VisualStepSummarizer(ctx, model_name=model_name, **kwargs)
try:
ctx.step_memory = service
except Exception:
pass
return service
__all__ = [
"CONTEXT_POLICIES",
"ChunkCapsuleService",
"ContextPolicy",
"HistoryChunkManager",
"StepCapsuleLens",
"build_history_for",
"resolve_policy",
"StepLens",
"StepMemoryService",
"TranscriptLedger",
"build_action_ledger",
"ensure_step_memory",
"format_session_offset",
]
File diff suppressed because it is too large. Load diff
+248
View File
@@ -0,0 +1,248 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""ContextPolicy: the declarative per-agent compiled-history policy table (M4).
Every production caller of :func:`artemis.utils.task_tree.build_plan_and_history`
used to hard-code its visibility kwargs at the call site. This module gathers
those call sites into one declaration per agent — the rendered output for each
agent is byte-identical to the previous hard-coded call (pinned by the golden
tests in ``tests/unit/memory/test_context_policy.py``) — and adds the M4 chunk
hand-off: when the transcript flag is on and history chunks exist, the
compiled view replaces chunked-away step lines with chunk blocks.
Chunk view per policy (§10 decision 4):
- ``"full"`` (outputter / history_analyzer): the chunk's stored three-band
rendering including the ③ full-width per-step ledger;
- ``"digest"`` (everything else): bands ① + ② with the ③ ledger replaced by
a ``ledger via recall_history`` marker line.
Because chunks only exist when ``agent.memory.transcript.enabled`` is on, the
flag-off output of every agent stays byte-for-byte unchanged.
``agent.memory.policies`` allows per-agent field overrides, e.g.::
"memory": {"policies": {"planner": {"last_n_detailed": 3}}}
"""
from dataclasses import dataclass, fields, replace
from typing import Any
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
#: Marker appended to a digest-view chunk block in place of the ③ ledger.
DIGEST_LEDGER_MARKER_TEMPLATE = (
"③ Step action ledger: available via recall_history (Steps {start}–{end})"
)
#: The band-③ heading inside a chunk's stored ``rendered_text``.
_BAND3_HEADING = "③ Step action ledger"
@dataclass(frozen=True)
class ContextPolicy:
"""One agent's compiled-history rendering declaration.
Field semantics map 1:1 onto ``build_plan_and_history`` kwargs, with two
conveniences: ``uncompressed=True`` renders every step (the historical
``min_summaries=len(steps)`` idiom) and ``whitelist=True`` marks policies
whose caller supplies ``keep_subgoal_hashes`` at runtime.
"""
strategy: str = "sliding_window" # documentation only
strict_milestone_pruning: bool = False
recent_window_size: int = 3
min_summaries: int = 5
uncompressed: bool = False
last_n_detailed: int = 1
all_detailed: bool = False
for_failure_analyzer: bool = False
whitelist: bool = False
chunk_view: str | None = "digest" # "full" | "digest" | None (never chunks)
#: The eight production call sites, verbatim (kwargs recorded 2026-09-01).
CONTEXT_POLICIES: dict[str, ContextPolicy] = {
# operator.py:996 — last_n_detailed comes from the node constructor.
"operator": ContextPolicy(
strategy="strict_milestone",
strict_milestone_pruning=True,
recent_window_size=3,
),
# operator.py:341 — transcript F-region cold-start restore.
"operator_cold_start": ContextPolicy(
strategy="full",
uncompressed=True,
last_n_detailed=1,
chunk_view=None,
),
# failure_analyzer.py:361
"failure_analyzer": ContextPolicy(
strategy="strict_milestone",
strict_milestone_pruning=True,
recent_window_size=3,
last_n_detailed=1,
for_failure_analyzer=True,
),
# planner.py:133 — caller passes an empty plan and hash "default".
"planner": ContextPolicy(
strategy="strict_milestone",
strict_milestone_pruning=True,
recent_window_size=5,
last_n_detailed=2,
),
# outputter.py:81
"outputter": ContextPolicy(
strategy="full",
uncompressed=True,
last_n_detailed=0,
chunk_view="full",
),
# history_analyzer.py:113
"history_analyzer": ContextPolicy(
strategy="full",
uncompressed=True,
last_n_detailed=1,
chunk_view="full",
),
# diagnoser.py:166 — every non-whitelist kwarg is the historical default.
"diagnoser": ContextPolicy(strategy="milestone_whitelist", whitelist=True),
# committee_tool.py:189
"committee": ContextPolicy(
strategy="milestone_whitelist",
whitelist=True,
last_n_detailed=3,
),
}
_POLICY_FIELDS = {f.name for f in fields(ContextPolicy)}
def resolve_policy(agent: str) -> ContextPolicy:
"""The agent's policy with any ``agent.memory.policies`` overrides applied."""
try:
policy = CONTEXT_POLICIES[agent]
except KeyError:
raise KeyError(
f"No ContextPolicy declared for agent '{agent}';"
f" known agents: {sorted(CONTEXT_POLICIES)}"
) from None
overrides: dict[str, Any] = {}
try:
from artemis.config import load_agent_config
configured = (load_agent_config().memory.policies or {}).get(agent) or {}
overrides = {k: v for k, v in configured.items() if k in _POLICY_FIELDS}
unknown = set(configured) - _POLICY_FIELDS
if unknown:
logger.warning(
f"agent.memory.policies['{agent}'] ignores unknown fields: {sorted(unknown)}"
)
except Exception:
overrides = {}
return replace(policy, **overrides) if overrides else policy
def _chunk_digest_text(rendered_text: str, start: int, end: int) -> str:
"""Bands ① + ② of a stored chunk rendering, ③ replaced by the recall marker."""
marker = DIGEST_LEDGER_MARKER_TEMPLATE.format(start=start, end=end)
head, sep, _ = rendered_text.partition(f"{_BAND3_HEADING}\n")
if not sep:
return f"{rendered_text.rstrip()}\n{marker}"
return f"{head.rstrip()}\n\n{marker}"
def load_chunk_blocks(engine: Any, view: str | None) -> list[dict] | None:
"""The persisted history chunks rendered for the compiled view, or None.
Returns None (→ byte-identical legacy rendering) unless the transcript
flag is on, the engine exposes chunks, and at least one chunk exists.
"""
if engine is None or view not in ("full", "digest"):
return None
try:
from artemis.config import load_agent_config
if not load_agent_config().memory.transcript.enabled:
return None
except Exception:
return None
try:
rows = engine.get_history_chunks() or []
except Exception as e:
logger.debug(f"load_chunk_blocks: no chunks available: {e}")
return None
blocks: list[dict] = []
for row in rows:
start = getattr(row, "start_step_number", None)
end = getattr(row, "end_step_number", None)
rendered = getattr(row, "rendered_text", None)
if not isinstance(start, int) or not isinstance(end, int) or not rendered:
continue
text = (
rendered
if view == "full"
else _chunk_digest_text(rendered, start, end)
)
blocks.append(
{"start_step_number": start, "end_step_number": end, "text": text}
)
return blocks or None
def build_history_for(
agent: str,
task_plan: str,
steps: list,
current_subgoal_hash: str,
*,
keep_subgoal_hashes: set | None = None,
last_n_detailed: int | None = None,
engine: Any = None,
) -> str:
"""Render the agent's compiled plan-and-history view per its policy.
Args:
agent: Key into :data:`CONTEXT_POLICIES`.
keep_subgoal_hashes: Runtime whitelist (whitelist policies only).
last_n_detailed: Runtime override (the operator's constructor knob).
engine: DataEngine handing chunks to the compiled view; omit at call
sites that must never receive chunk blocks.
"""
from artemis.utils.task_tree import build_plan_and_history
policy = resolve_policy(agent)
kwargs: dict[str, Any] = {
"min_summaries": len(steps) if policy.uncompressed else policy.min_summaries,
"last_n_detailed": (
policy.last_n_detailed if last_n_detailed is None else last_n_detailed
),
"all_detailed": policy.all_detailed,
"strict_milestone_pruning": policy.strict_milestone_pruning,
"recent_window_size": policy.recent_window_size,
"for_failure_analyzer": policy.for_failure_analyzer,
}
if policy.whitelist:
kwargs["keep_subgoal_hashes"] = keep_subgoal_hashes
chunks = load_chunk_blocks(engine, policy.chunk_view)
if chunks:
kwargs["chunks"] = chunks
return build_plan_and_history(task_plan, steps, current_subgoal_hash, **kwargs)
+57
View File
@@ -0,0 +1,57 @@
# ROLE
You are the Segment Capsule writer for a mobile automation agent's history compression. You receive one contiguous segment of executed steps (per-step visual transition summaries, exact actions, controller/validator results, operator reasoning excerpts, notes written, and user-injected instructions) and produce the two LLM-authored bands of the segment's history chunk:
- Band ① — Synopsis & effects: what this segment was doing, what was actually done, and what effects/artifacts it left behind, plus structured fields.
- Band ② — Compressed step summary: an interval narrative ("Steps xx–xx did ..., Step xx did ...") that seamlessly covers every step of the segment.
The mechanical per-step action ledger (band ③) is assembled outside of you; do NOT reproduce it.
# NEUTRAL-WORDING CONTRACT (STRICT)
1. **Zero subjective validation**: never declare semantic success, completion, or final failure. Subjective verdicts in compressed history cause future turns to falsely believe a goal was definitively met or permanently broken.
- **BANNED WORDS**: "successfully", "completed", "achieved", "entered", "navigated to", "arrived at", "failed", "unsuccessful", "could not", "impossible".
- Describe observable transitions and recorded results instead (e.g. "the settings page was displayed", "the controller reported an execution error").
2. **First-person perspective**: write from the agent's perspective using "I". Never "the agent" / "the operator".
3. **Planned iterations are not anomalies**: systematic processing of different candidate items, or periodic polling cycles under a `[Loop]` / `[Loop:continuous]` milestone, is planned progress — record it objectively (e.g. "polling round #2"). Only flag a loop when the exact same target is repeated without progress or states oscillate blindly; then record the pattern and count factually.
4. **Preserve, don't abstract**: be deliberately detailed and stay close to the source wording. Prefer longer output over abstracting facts beyond recognition. Faithfully carry visible text, codes, prices, account names, file names, error banners.
5. **Fact priority**: user instructions > validator/controller recorded results > UI/OCR deltas > sourced visual summaries > reasoning excerpts.
# BAND ① REQUIREMENTS
Answer three questions in detailed prose (field `doing` / `did` / `effect`):
- `doing`: what this segment was trying to do and why (strategic context within the plan).
- `did`: what was actually carried out, including detours, retries, recoveries, and pivots.
- `effect`: what state/effects/artifacts the segment left behind. **This MUST include every note left during the segment**: for each notes write in the input, name the target note file and the gist of what was recorded. Every note's target key is machine-checked against your effect field — an omitted note key forces regeneration.
Structured fields (arrays of short strings, may be empty):
- `verified_facts`: states/values actually observed or verified on screen.
- `unresolved`: open questions, unverified assumptions, missing prerequisites.
- `failed_paths`: approaches attempted in this segment whose recorded results were errors (state the recorded error factually).
- `important_entities`: accounts, files, order/tracking numbers, package names, key UI entry points.
- `entry_state` / `exit_state` (single strings): the observable screen/app state at segment start and end.
# BAND ② REQUIREMENTS
Field `intervals`: an ordered array of `{"start_step": N, "end_step": M, "text": "..."}` objects.
- Every line's text must reference concrete behavior for those steps.
- **The union of intervals MUST cover the segment's step range seamlessly** — no gaps, no overlaps, in ascending order. This is machine-checked; a gap forces regeneration.
- Group only homogeneous consecutive actions into one interval; a heterogeneous action gets its own single-step interval (`start_step == end_step`).
- Keep the neutral-wording contract in every line.
# OUTPUT FORMAT
Return ONLY one JSON object (no markdown fences, no commentary):
```json
{
"doing": "...",
"did": "...",
"effect": "...",
"entry_state": "...",
"exit_state": "...",
"verified_facts": ["..."],
"unresolved": ["..."],
"failed_paths": ["..."],
"important_entities": ["..."],
"intervals": [
{"start_step": 18, "end_step": 20, "text": "..."},
{"start_step": 21, "end_step": 21, "text": "..."}
]
}
```
+254
View File
@@ -0,0 +1,254 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Shared background step-summary runtime (StepMemoryService).
Hosts the scheduling skeleton lifted from the Flash VisualStepSummarizer:
zero-blocking dispatch, bounded retry, bounded flush, canonical step_id keying
with alias resolution (tool_call_id / legacy ordinals), and status queries.
What a summary *contains* (the lens semantics) lives in subclasses — the
service only guarantees how jobs are scheduled, retried, keyed, and drained.
Both Flash and (from M2 on) Pro profiles share this runtime.
"""
import asyncio
from typing import Any
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
JobKey = int | str
class StepLens:
"""Formal lens interface: what one summary attempt *produces*.
A lens turns one job payload into summary text; the surrounding service
owns scheduling, retries, keying, and draining. Implementations must be
side-effect-tolerant under retries (an attempt may run again after a
failure) and return ``None``/raise to signal a failed attempt.
Two lens altitudes exist in the redesign (§5): the step-level visual
transition lens (:class:`~artemis.agents.flash.summarizer.VisualStepSummarizer`
keeps it inlined as its ``_attempt`` for compatibility) and the chunk-level
:class:`~artemis.memory.chunking.StepCapsuleLens`.
"""
name = "lens"
async def render(self, key: JobKey, payload: dict[str, Any]) -> str | None:
"""Produce the summary text for one payload, or None on failure."""
raise NotImplementedError
class StepMemoryService:
"""Non-blocking background runtime for per-step summary jobs.
Key design properties:
1. Zero-blocking dispatch: callers submit and proceed immediately.
2. Bounded retry: each job attempts at most ``1 + retry_limit`` times and
then enters an explicit failed state — never an unbounded loop.
3. Bounded flush: ``flush()`` waits up to the configured timeout, then
cancels whatever is still in flight.
4. Canonical keying with aliases: jobs are keyed by the DataEngine step id
where available; tool_call_ids (and legacy ordinals) remain queryable
through an alias map so message-level consumers need no migration.
5. Bounded concurrency: at most ``max_concurrency`` attempts run at once.
Subclasses implement :meth:`_attempt` (one summarization attempt for a
payload) and may override :meth:`_on_status` to persist pending/failed
status transitions.
"""
def __init__(
self,
ctx: Any,
*,
lens: StepLens | None = None,
max_concurrency: int = 1,
retry_limit: int = 3,
flush_timeout_s: float = 30.0,
):
self.ctx = ctx
self._lens = lens
self._summaries: dict[JobKey, str] = {}
self._failed: set[JobKey] = set()
self._pending_tasks: dict[JobKey, asyncio.Task] = {}
self._step_inputs: dict[JobKey, dict[str, Any]] = {}
self._retry_counts: dict[JobKey, int] = {}
self._retry_delays = (0.0, 0.5, 1.0, 2.0, 3.0)
self._retry_limit = max(0, retry_limit)
self._flush_timeout_s = max(0.0, flush_timeout_s)
self._aliases: dict[JobKey, JobKey] = {}
self._semaphore = asyncio.Semaphore(max(1, max_concurrency))
# ------------------------------------------------------------------
# Keying
# ------------------------------------------------------------------
def resolve_key(self, key: JobKey) -> JobKey:
"""Resolve an alias (tool_call_id / legacy ordinal) to its canonical key."""
return self._aliases.get(key, key)
def submit(
self,
key: JobKey,
payload: dict[str, Any],
aliases: tuple[JobKey, ...] = (),
) -> None:
"""Submit one summary job without blocking the caller.
Args:
key: Canonical job key (DataEngine step id where available).
payload: Lens-specific input data; stored until the job resolves.
aliases: Additional keys (e.g. tool_call_id) that resolve to ``key``.
"""
for alias in aliases:
if alias is not None and alias != key:
self._aliases[alias] = key
self._step_inputs[key] = payload
self._failed.discard(key)
self._on_status(key, "pending")
task = asyncio.create_task(self._run_until_ready(key))
self._pending_tasks[key] = task
# ------------------------------------------------------------------
# Scheduling core
# ------------------------------------------------------------------
async def _run_until_ready(self, key: JobKey) -> None:
"""Retry one job independently, bounded by the configured retry limit.
Attempts at most ``1 + retry_limit`` times; on exhaustion the job
enters an explicit failed state (surfaced via :meth:`has_failed` and
the ``_on_status`` hook). Retry backoff sleeps happen outside the
concurrency semaphore so a waiting job never starves its peers.
"""
while key not in self._summaries:
async with self._semaphore:
succeeded = await self._attempt(key)
if succeeded:
return
retry_count = self._retry_counts.get(key, 0) + 1
self._retry_counts[key] = retry_count
payload = self._step_inputs.get(key)
display_step = payload.get("step_number") if payload else key
if retry_count > self._retry_limit:
self._failed.add(key)
logger.warning(
f"StepMemoryService: Giving up on Step {display_step} after"
f" {retry_count} failed attempts (retry_limit={self._retry_limit})."
)
self._on_status(key, "failed")
return
delay = self._retry_delays[min(retry_count - 1, len(self._retry_delays) - 1)]
logger.info(
f"StepMemoryService: Retrying Step {display_step} "
f"(attempt {retry_count + 1}) after {delay:.1f}s"
)
if delay:
await asyncio.sleep(delay)
async def _attempt(self, key: JobKey) -> bool:
"""Run one summarization attempt for ``key``; True when a summary landed.
The default implementation delegates to the configured :class:`StepLens`;
subclasses without a lens override this method directly (the Flash
visual summarizer keeps its inlined attempt for compatibility).
"""
if self._lens is None:
raise NotImplementedError
payload = self._step_inputs.get(key)
if payload is None:
return False
try:
summary = await self._lens.render(key, payload)
except Exception as e:
logger.warning(f"StepMemoryService: lens '{self._lens.name}' attempt failed: {e}")
return False
if not summary:
return False
self._summaries[key] = summary
self._on_ready(key, summary)
return True
def _on_ready(self, key: JobKey, summary: str) -> None:
"""Hook invoked when a lens-produced summary lands; default is a no-op."""
def _on_status(self, key: JobKey, status: str) -> None:
"""Hook invoked on pending/failed transitions; default is a no-op."""
# ------------------------------------------------------------------
# Status queries (alias-resolving)
# ------------------------------------------------------------------
def get_summary(self, key: JobKey, fallback_text: str | None = None) -> str | None:
"""Retrieve the summary for a job key (or alias), or fallback text."""
return self._summaries.get(self.resolve_key(key), fallback_text)
def has_summary(self, key: JobKey) -> bool:
"""Check whether a job's summary has completed."""
return self.resolve_key(key) in self._summaries
def has_job(self, key: JobKey) -> bool:
"""Check whether a job has been submitted for summarization."""
return self.resolve_key(key) in self._step_inputs
def has_failed(self, key: JobKey) -> bool:
"""Check whether a job permanently failed (retries exhausted)."""
return self.resolve_key(key) in self._failed
def is_pending(self, key: JobKey) -> bool:
"""Check whether a job still needs a summary."""
return self.has_job(key) and not self.has_summary(key)
def get_job_payload(self, key: JobKey) -> dict[str, Any] | None:
"""Return the stored payload for a job key (or alias), if any."""
return self._step_inputs.get(self.resolve_key(key))
def get_step_number(self, key: JobKey) -> int | None:
"""Return the human-facing step ordinal recorded for a job, if known."""
payload = self.get_job_payload(key)
if payload is None:
return None
step_number = payload.get("step_number")
return step_number if isinstance(step_number, int) else None
# ------------------------------------------------------------------
# Draining
# ------------------------------------------------------------------
async def flush(self, timeout_seconds: float | None = None) -> None:
"""Wait for active jobs up to a bound, then cancel remaining retry loops."""
if timeout_seconds is None:
timeout_seconds = self._flush_timeout_s
tasks = [t for t in self._pending_tasks.values() if not t.done()]
if tasks:
logger.info(f"StepMemoryService: Flushing {len(tasks)} pending summary tasks...")
_, pending = await asyncio.wait(tasks, timeout=timeout_seconds)
if pending:
logger.warning(
f"StepMemoryService: Cancelling {len(pending)} unfinished summary tasks "
f"after {timeout_seconds:.1f}s flush timeout."
)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
+364
View File
@@ -0,0 +1,364 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Session-level message transcript ledger for the Pro operator (M2).
Implements the four-region discipline of the history-module redesign §3.2:
- **S (stable prefix)**: the static system message — byte-identical for the
whole session (the plan+history section is moved out by the template split).
- **F (frozen history)**: in M2 only the cold-start restored-history block; L2
chunks arrive in M3.
- **A (active window)**: raw per-turn messages, append-only. Each committed
turn contributes its tail observation HumanMessage, the operator AIMessages
(tool_calls and native thinking preserved by reference), the in-turn
ToolMessages, and the turn's validator result message. The scrub edge
(:class:`~artemis.agents.flash.context_compressor.ScrubEdgeCompressor`,
generalized in M2) strips old UI lists / plan recitations at depth 1 and
resolves screenshots to visual summaries at depth K — messages are never
removed or reordered, so tool-call/response pairs are never split.
- **T (current tail)**: built fresh every turn by the operator; passed to
:meth:`render` and only enters A when the turn is committed.
All timestamps inside the transcript use the session-start offset ``T+mm:ss``
(byte-stable once frozen); "ago" wording is reserved for the auxiliary agents'
per-call compiled views.
"""
import json
import time
from typing import Any, Callable
from langchain_core.messages import BaseMessage, HumanMessage
from artemis.agents.flash.context_compressor import ScrubEdgeCompressor
from artemis.memory.step_memory import StepMemoryService
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
#: Marker for the per-turn task-plan recitation block in the observation tail.
PLAN_RECITATION_MARKER = "--- Task Plan (recited) ---"
#: Marker of the Pro observation's UI element list block (see
#: ``ObservationPromptComponent``).
PRO_UI_LIST_MARKER = "--- Visible UI Elements ---"
#: Marker prefix of a committed turn's validator result message.
EXECUTION_RESULT_MARKER = "--- Action Execution Result"
#: Header prefix of the cold-start restored-history block.
RESTORED_HISTORY_HEADER = "[Restored history]"
def format_session_offset(seconds: float) -> str:
"""Render a session-start offset as ``T+mm:ss`` (minutes never wrap)."""
total = max(0, int(seconds))
return f"T+{total // 60:02d}:{total % 60:02d}"
class TranscriptLedger:
"""Append-only four-region message ledger for one Pro session.
The ledger owns no LLM calls: summary lookups go through the shared
:class:`StepMemoryService` (``ctx.step_memory``), whose visual-transition
lens is fed by the Pro SummarizerNode each step.
"""
def __init__(
self,
*,
step_memory: StepMemoryService | None = None,
prune_history_xml: bool = True,
image_scrub_depth: int = 3,
pending_grace_steps: int = 3,
xml_scrub_depth: int = 1,
clock: Callable[[], float] | None = None,
):
self._clock = clock or time.monotonic
self._session_start = self._clock()
self._static: list[BaseMessage] = []
self._restored: list[BaseMessage] = []
self._active: list[BaseMessage] = []
self._staged: list[BaseMessage] | None = None
self._turn_count = 0
# L2/L3 chunk compression (M3). ``_turns`` records each committed
# turn's message span in ``_active``; a compression event advances
# ``_active_start`` past whole turns (never splitting tool-call pairs)
# and replaces the frozen chunk blocks wholesale. The underlying
# ``_active`` list stays append-only so scrub-edge indices stay valid.
self._turns: list[dict] = []
self._chunked_turn_count = 0
self._active_start = 0
self._frozen_blocks: list[BaseMessage] = []
self._chunker: Any | None = None
# id(message) -> summary-job key (DataEngine step id). A side map keeps
# the key out of the serialized message payload entirely.
self._step_keys: dict[int, str] = {}
self._compressor = ScrubEdgeCompressor(
summarizer=step_memory,
prune_history_xml=prune_history_xml,
image_scrub_depth=image_scrub_depth,
pending_grace_steps=pending_grace_steps,
xml_scrub_depth=xml_scrub_depth,
summary_key_getter=lambda msg: self._step_keys.get(id(msg)),
strip_markers=(PRO_UI_LIST_MARKER, PLAN_RECITATION_MARKER),
tail_offset=1,
)
# ------------------------------------------------------------------
# Session clock
# ------------------------------------------------------------------
def elapsed_seconds(self) -> float:
return max(0.0, self._clock() - self._session_start)
def elapsed_label(self) -> str:
"""The current session offset as ``T+mm:ss``."""
return format_session_offset(self.elapsed_seconds())
# ------------------------------------------------------------------
# S region
# ------------------------------------------------------------------
@property
def has_static_prefix(self) -> bool:
return bool(self._static)
def set_static_prefix(self, messages: list[BaseMessage]) -> None:
"""Install the byte-stable system prefix; only settable once."""
if self._static:
raise RuntimeError("TranscriptLedger static prefix is already set.")
self._static = list(messages)
# ------------------------------------------------------------------
# F region (M2: cold-start restored history only)
# ------------------------------------------------------------------
@property
def has_restored_history(self) -> bool:
return bool(self._restored)
def set_restored_history(self, text: str) -> None:
"""Install the cold-start frozen history block (empty ledger only)."""
if self._restored:
raise RuntimeError("TranscriptLedger restored history is already set.")
if self._active or self._staged or self._turn_count:
raise RuntimeError(
"Restored history can only seed an empty ledger (cold start)."
)
self._restored = [
HumanMessage(content=[{"type": "text", "text": text}])
]
# ------------------------------------------------------------------
# A region (append-only turn commits)
# ------------------------------------------------------------------
@property
def turn_count(self) -> int:
return self._turn_count
@property
def has_staged_turn(self) -> bool:
return self._staged is not None
@property
def active_messages(self) -> tuple[BaseMessage, ...]:
return tuple(self._active)
def stage_turn(self, messages: list[BaseMessage]) -> None:
"""Hold a finished turn's messages until the next build commits them.
Committing is deferred because the turn's DataEngine step id and its
validator result only exist after the operator returns.
"""
if self._staged is not None:
logger.warning(
"TranscriptLedger: previous staged turn was never committed;"
" committing it without step metadata."
)
self.commit_staged()
self._staged = [m for m in messages if isinstance(m, BaseMessage)]
def commit_staged(
self,
*,
step_key: str | None = None,
validator_result: Any | None = None,
) -> None:
"""Move the staged turn into the active region.
Args:
step_key: The DataEngine step id recorded for this turn; keys the
turn's observation screenshot to its visual-transition summary
job for the depth-K scrub.
validator_result: The turn's validator report (``None`` when the
turn executed no terminal action); appended as a frozen result
message carrying the ``T+mm:ss`` session offset.
"""
if self._staged is None:
return
staged = self._staged
self._staged = None
if step_key is not None:
observation = self._first_image_message(staged)
if observation is not None:
self._step_keys[id(observation)] = str(step_key)
span_start = len(self._active)
self._active.extend(staged)
if validator_result is not None:
result_message = self._build_result_message(validator_result)
if result_message is not None:
self._active.append(result_message)
self._turns.append(
{
"step_key": str(step_key) if step_key is not None else None,
"start": span_start,
"end": len(self._active),
}
)
self._turn_count += 1
@staticmethod
def _first_image_message(messages: list[BaseMessage]) -> BaseMessage | None:
for msg in messages:
content = getattr(msg, "content", None)
if not isinstance(content, list):
continue
for block in content:
if isinstance(block, dict) and block.get("type") in ("image_url", "image"):
return msg
return None
def _build_result_message(self, result: Any) -> HumanMessage | None:
rendered = None
if isinstance(result, dict):
status = result.get("status") or "unknown"
detail = None
try:
from artemis.utils.task_tree import format_result_clean
# format_result_clean reports only errors/repairs; success
# renders as the bare status line.
detail = format_result_clean(result)
except Exception:
detail = None
rendered = f"Status: {status}" + (f"\n{detail}" if detail else "")
else:
try:
rendered = json.dumps(result, ensure_ascii=False, default=str)
except Exception:
rendered = str(result)
if not rendered:
return None
return HumanMessage(
content=[
{
"type": "text",
"text": (
f"{EXECUTION_RESULT_MARKER} ({self.elapsed_label()}) ---\n{rendered}"
),
}
]
)
# ------------------------------------------------------------------
# F region (M3): chunk compression primitives
# ------------------------------------------------------------------
def attach_chunker(self, chunker: Any) -> None:
"""Install the L2/L3 :class:`HistoryChunkManager` consulted at render."""
self._chunker = chunker
@property
def chunker(self) -> Any | None:
return self._chunker
@property
def frozen_blocks(self) -> tuple[BaseMessage, ...]:
return tuple(self._frozen_blocks)
def unchunked_turns(self) -> list[dict]:
"""Committed turns not yet consumed by a compression event (copies)."""
return [dict(t) for t in self._turns[self._chunked_turn_count :]]
def turn_text_chars(self, turns: list[dict]) -> int:
"""Total text characters of the given turns' messages (size trigger)."""
total = 0
for turn in turns:
for msg in self._active[turn["start"] : turn["end"]]:
content = getattr(msg, "content", None)
if isinstance(content, str):
total += len(content)
elif isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
total += len(block.get("text", ""))
return total
def freeze_turns(self, turn_count: int, frozen_blocks: list[BaseMessage]) -> None:
"""Compression event: consume the oldest ``turn_count`` unchunked turns
and replace the frozen chunk blocks wholesale.
The boundary always advances to a committed turn's end, so a tool-call
message and its responses are never split across the F/A boundary.
"""
if turn_count <= 0:
self._frozen_blocks = list(frozen_blocks)
return
available = self._turns[self._chunked_turn_count :]
if turn_count > len(available):
raise ValueError(
f"freeze_turns({turn_count}) exceeds the {len(available)}"
" unchunked committed turns."
)
self._chunked_turn_count += turn_count
self._active_start = self._turns[self._chunked_turn_count - 1]["end"]
self._frozen_blocks = list(frozen_blocks)
# ------------------------------------------------------------------
# Rendering
# ------------------------------------------------------------------
def render(self, tail: list[BaseMessage]) -> list[BaseMessage]:
"""Advance compression and return ``S + F + A + tail``.
Order per turn: the chunker runs first (a triggered compression event
deep-mutates the frozen region and advances the F/A boundary), then
the scrub edge advances over the active window. The returned list is a
fresh container: appending to it (the operator's in-turn tool loop)
never mutates the ledger regions.
"""
if self._chunker is not None:
try:
self._chunker.on_render(self)
except Exception as e:
logger.error(f"History chunker render hook failed: {e}")
self._compressor.compress(self._active)
return [
*self._static,
*self._restored,
*self._frozen_blocks,
*self._active[self._active_start :],
*tail,
]
+14 -2
View File
@@ -56,6 +56,7 @@ from artemis.llm.reliability import (
retry_policy_for,
)
from artemis.llm.router import ModelEndpoint, ModelFactory, ModelProvider
from artemis.services.token_meter import record_llm_usage
from artemis.llm.structured import (
ParseFailure,
StructuredOutputError,
@@ -632,13 +633,22 @@ class RobustChatModelWrapper:
args, kwargs = _inject_parent_trace_id(trace_id, *args, **kwargs)
return args, kwargs, trace_id
def _meter_usage(self, response) -> None:
"""Record measured token usage for this call (M0 metering, best-effort)."""
engine = self.ctx.data_engine if self.ctx else None
if engine is None:
engine = _get_current_data_engine()
record_llm_usage(engine, response, source=self._endpoint_key())
async def ainvoke(self, *args, **kwargs):
args, kwargs, _ = self._traced_call(args, kwargs)
return await _run_with_recovery(
response = await _run_with_recovery(
functools.partial(self.base_model.ainvoke, *args, **kwargs),
provider=self._provider_value(),
endpoint_key=self._endpoint_key(),
)
self._meter_usage(response)
return response
async def complete(self, *args, **kwargs):
"""Single completion entry point: returns the full final message.
@@ -652,11 +662,13 @@ class RobustChatModelWrapper:
"""
args, kwargs, _ = self._traced_call(args, kwargs)
emit_deltas = bool(self.ctx and self.ctx.data_engine)
return await _run_with_recovery(
response = await _run_with_recovery(
functools.partial(self._complete_attempt, emit_deltas, *args, **kwargs),
provider=self._provider_value(),
endpoint_key=self._endpoint_key(),
)
self._meter_usage(response)
return response
def _emit_stream_delta(self, stream_exec_id, chunk) -> None:
content = getattr(chunk, "content", None)
+202
View File
@@ -0,0 +1,202 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Session-level LLM token metering (M0: pure recording, no decisions).
Every gateway-managed LLM call reports its real ``usage_metadata`` here. The
meter accumulates per-session totals and records one ``llm_usage`` trace per
call into the DataEngine, carrying:
- the call's measured ``prompt_tokens`` — the last call's measured input is the
best available estimate of the *current context base* for the next call
(the compaction thresholds of M2/M3 will consume this, never char-count
heuristics);
- provider-reported cache hits (Gemini ``cached_content_token_count`` /
LangChain ``input_token_details.cache_read``) to establish the cache-hit-rate
baseline;
- running per-session totals.
This module never raises into the LLM call path and makes no decisions.
"""
import threading
from typing import Any
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
_LOCK = threading.Lock()
_METERS: dict[str, "SessionTokenMeter"] = {}
def extract_usage(response: Any) -> dict[str, int] | None:
"""Extracts unified token usage from an LLM response message, best-effort.
Prefers LangChain's normalized ``usage_metadata``; falls back to raw
provider shapes in ``response_metadata``. Returns None when the response
carries no usable usage numbers.
"""
usage: dict[str, Any] | None = None
raw = getattr(response, "usage_metadata", None)
if isinstance(raw, dict) and raw:
usage = raw
else:
meta = getattr(response, "response_metadata", None)
if isinstance(meta, dict):
candidate = meta.get("usage_metadata") or meta.get("token_usage")
if isinstance(candidate, dict) and candidate:
usage = candidate
if usage is None:
return None
def _int(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
prompt = _int(
usage.get("input_tokens") or usage.get("prompt_tokens") or usage.get("prompt_token_count")
)
completion = _int(
usage.get("output_tokens")
or usage.get("completion_tokens")
or usage.get("candidates_token_count")
)
total = _int(usage.get("total_tokens") or usage.get("total_token_count")) or (
prompt + completion
)
cached = 0
details = usage.get("input_token_details")
if isinstance(details, dict):
cached = _int(details.get("cache_read"))
if not cached:
cached = _int(
usage.get("cached_content_token_count")
or usage.get("cachedContentTokenCount")
or usage.get("cached_tokens")
)
if total <= 0:
return None
return {
"prompt_tokens": prompt,
"completion_tokens": completion,
"total_tokens": total,
"cached_tokens": cached,
}
class SessionTokenMeter:
"""Accumulates measured LLM usage for one session."""
def __init__(self, session_id: str):
self.session_id = session_id
self._lock = threading.Lock()
self.llm_calls = 0
self.prompt_tokens = 0
self.completion_tokens = 0
self.cached_tokens = 0
self.cache_hit_calls = 0
self.last_prompt_tokens = 0
def record(
self, usage: dict[str, int], *, update_last_prompt: bool = True
) -> dict[str, int]:
"""Accumulates one call's usage; returns a session snapshot.
``update_last_prompt=False`` accumulates the totals without touching
``last_prompt_tokens`` — background lens calls carry tiny prompts that
must not masquerade as the live context base consumed by the L2/L3
compaction thresholds.
"""
with self._lock:
self.llm_calls += 1
self.prompt_tokens += usage.get("prompt_tokens", 0)
self.completion_tokens += usage.get("completion_tokens", 0)
cached = usage.get("cached_tokens", 0)
self.cached_tokens += cached
if cached > 0:
self.cache_hit_calls += 1
if update_last_prompt:
self.last_prompt_tokens = usage.get("prompt_tokens", 0)
return self.snapshot()
def snapshot(self) -> dict[str, int]:
return {
"session_llm_calls": self.llm_calls,
"session_prompt_tokens": self.prompt_tokens,
"session_completion_tokens": self.completion_tokens,
"session_cached_tokens": self.cached_tokens,
"session_cache_hit_calls": self.cache_hit_calls,
}
def get_meter(session_id: Any) -> SessionTokenMeter:
"""Returns (creating on first use) the meter for a session id."""
key = str(session_id)
with _LOCK:
meter = _METERS.get(key)
if meter is None:
meter = SessionTokenMeter(key)
_METERS[key] = meter
return meter
def record_llm_usage(
engine: Any,
response: Any,
*,
source: str | None = None,
update_last_prompt: bool = True,
) -> dict | None:
"""Meters one LLM response and records an ``llm_usage`` trace, best-effort.
``context_base_tokens`` in the payload is this call's measured prompt size —
the running estimate of the live context for threshold decisions in later
milestones. Returns the recorded payload, or None when nothing was
recorded (no engine/session/usage). Never raises.
"""
try:
if engine is None:
return None
session_id = getattr(engine, "current_session_id", None)
usage = extract_usage(response)
if not session_id or usage is None:
return None
snapshot = get_meter(session_id).record(
usage, update_last_prompt=update_last_prompt
)
payload: dict[str, Any] = {
**usage,
"context_base_tokens": usage["prompt_tokens"],
**snapshot,
}
if source:
payload["source"] = source
engine.record_trace(
type="llm_call",
name="llm_usage",
payload=payload,
step_id=getattr(engine, "current_step_id", None),
status="success",
)
return payload
except Exception as meter_err:
logger.debug(f"Token metering skipped: {meter_err}")
return None
+4 -3
View File
@@ -48,8 +48,8 @@ from artemis.tools.types import CyFunctionDetector
from artemis.tools.video_tool import get_video_analyzer_tool
from artemis.utils.logger import get_logger
from artemis.utils.notes import get_note_file_path, get_notes_dir
from artemis.memory.context_policy import build_history_for
from artemis.utils.task_tree import (
build_plan_and_history,
get_active_subgoal_hashes,
get_recent_subgoal_hashes,
)
@@ -186,12 +186,13 @@ async def _execute_committee(
logger.error(f"Failed to parse active subgoal in committee: {e}")
try:
plan_and_history = build_plan_and_history(
plan_and_history = build_history_for(
"committee",
task_plan,
steps,
active_subgoal_hash,
keep_subgoal_hashes=keep_hashes,
last_n_detailed=3,
engine=ctx.data_engine,
)
history_summary += plan_and_history
except Exception as e: # pylint: disable=broad-exception-caught
+564
View File
@@ -0,0 +1,564 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""recall_history: deterministic lossless lookup into cold history (M4).
Implements the sister design's §10 as adopted by the history-module redesign
§3.5: one unified tool over the DataEngine's raw records — step summaries,
history chunks (bands ①②③), exact actions and validator results, operator
thinking, notes, XML/OCR text (which also carries package/activity strings),
Failure-Analyzer traces, and image references. Phase one is keyword + step
range filtering over SQLite-backed records in Python — no vector database, no
model call.
Hard boundaries (config ``agent.memory.recall``):
- at most ``max_results`` results (default 5);
- one response is capped at ``max_text_tokens`` estimated tokens (char/4);
- every result carries a step number / step id;
- ``include_details`` output is clamped per field;
- ``include_images`` returns only the screenshots that actually exist on disk
for a single step (data URLs), never more than ``max_image_steps`` steps;
- large raw sources return only excerpts around the match plus a reference.
An era whose ledger overflowed to a ``[Era N | Steps a–b: ledger via
recall_history]`` marker line is re-entered here: recalling that step range
returns the full-width ``build_action_ledger`` rows for it.
"""
import base64
import json
from typing import Any
from pydantic import BaseModel, Field
from artemis.data_engine.trace import trace_langchain_tool
from artemis.tools.base import ArtemisTool, ToolRegistry
from artemis.tools.tool_wrapper import ToolWrapper
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
#: How many most-recent steps get their screenshot XML/OCR blobs scanned
#: (per-image JSON loads are the expensive part of the sweep).
XML_SCAN_CAP = 150
#: Character clamp for one matched excerpt.
EXCERPT_CHARS = 240
DETAIL_EXCERPT_CHARS = 600
class RecallHistoryArgs(BaseModel):
"""Arguments schema for the recall_history tool."""
query: str = Field(
...,
description=(
"Keywords to search for across the whole execution history"
" (summaries, actions, validator results, reasoning, notes,"
" on-screen XML/OCR text, package/activity names). Case-"
"insensitive; every whitespace-separated term is matched"
" independently. May be empty when step_range is given (returns"
" the per-step action ledger of that range)."
),
)
step_range: list[int] | None = Field(
default=None,
description=(
"Optional [start, end] step-number range (inclusive) to restrict"
" the search — e.g. the range shown in a '[... ledger via"
" recall_history]' marker line. Also returns the full per-step"
" action ledger of the range."
),
)
include_details: bool = Field(
default=False,
description=(
"Include clamped per-result details (exact action, validator"
" result, reasoning excerpt) instead of the one-line form."
),
)
include_images: bool = Field(
default=False,
description=(
"Return the stored screenshots of the single best-matching step"
" (only images that actually exist; at most one step)."
),
)
max_results: int = Field(
default=5,
ge=1,
description="Maximum results to return (server-side cap still applies).",
)
def _terms(query: str) -> list[str]:
return [t for t in (query or "").lower().split() if t]
def _estimate_tokens(text: str) -> int:
return len(text) // 4
def _clamp(text: str, limit: int) -> str:
text = (text or "").strip()
if len(text) <= limit:
return text
return text[: limit - 1].rstrip() + "…"
def _excerpt(text: str, terms: list[str], limit: int) -> str:
"""A window of ``text`` around the first term hit (flattened whitespace)."""
flat = " ".join((text or "").split())
lower = flat.lower()
pos = -1
for term in terms:
pos = lower.find(term)
if pos >= 0:
break
if pos < 0:
return _clamp(flat, limit)
start = max(0, pos - limit // 3)
window = flat[start : start + limit]
prefix = "…" if start > 0 else ""
suffix = "…" if start + limit < len(flat) else ""
return f"{prefix}{window.strip()}{suffix}"
def _score(haystack: str, terms: list[str]) -> int:
lower = haystack.lower()
return sum(1 for term in terms if term in lower)
def _events_text(step: dict) -> str:
parts: list[str] = []
for event in step.get("interleaved_events") or []:
etype = event.get("type")
if etype == "tool_call":
parts.append(str(event.get("name") or ""))
parts.append(json.dumps(event.get("args") or {}, ensure_ascii=False, default=str))
result = event.get("result")
if result is not None:
parts.append(str(result)[:2000])
elif etype and "thought" in etype:
parts.append(str(event.get("content") or ""))
return "\n".join(parts)
def _step_haystack(step: dict) -> str:
fields = [
str(step.get("summary") or ""),
json.dumps(step.get("action_taken"), ensure_ascii=False, default=str)
if step.get("action_taken")
else "",
json.dumps(step.get("last_execution_result"), ensure_ascii=False, default=str)
if step.get("last_execution_result")
else "",
str(step.get("operator_raw_thinking") or ""),
_events_text(step),
str((step.get("extra_metadata") or {}).get("injected_instruction") or ""),
# M5: the foreground app package stamped by record_step joins the
# search surface, so package-name queries no longer depend on the
# XML-blob scan (capped to recent steps) alone.
str((step.get("extra_metadata") or {}).get("foreground_app") or ""),
]
return "\n".join(f for f in fields if f)
def _screen_text(engine: Any, step: dict) -> str:
"""Raw XML/OCR text of a step's pre-action screenshot (package/activity
strings live inside the XML blob)."""
image_name = step.get("pre_image_name") or step.get("post_image_name")
if not image_name:
return ""
try:
record = engine.storage.get_image(image_name)
except Exception:
return ""
if record is None:
return ""
parts = []
for blob in (record.ui_tree, record.ocr_result):
if blob:
parts.append(blob if isinstance(blob, str) else json.dumps(blob, ensure_ascii=False, default=str))
return "\n".join(parts)
def _session_start(engine: Any) -> float | None:
return getattr(engine, "session_start_time", None)
def _step_label(step: dict, session_start: float | None) -> str:
from artemis.memory.chunking import step_offset_label
return f"Step {step.get('step_number')} ({step_offset_label(step, session_start)})"
def _note_writer_steps(steps: list[dict], key: str) -> list[int]:
writers: list[int] = []
for step in steps:
for event in step.get("interleaved_events") or []:
if (
event.get("type") == "tool_call"
and event.get("name") in ("save_note", "update_note", "append_note")
and (event.get("args") or {}).get("key") == key
):
number = step.get("step_number")
if isinstance(number, int):
writers.append(number)
return writers
def search_history(
engine: Any,
*,
query: str,
step_range: list[int] | None = None,
include_details: bool = False,
include_images: bool = False,
max_results: int = 5,
recall_config: Any = None,
) -> str | list[dict]:
"""Deterministic history search; returns text or content blocks (images)."""
cfg_max_results = int(getattr(recall_config, "max_results", 5) or 5)
cfg_max_tokens = int(getattr(recall_config, "max_text_tokens", 2000) or 2000)
cfg_max_image_steps = int(getattr(recall_config, "max_image_steps", 1))
max_results = max(1, min(int(max_results or cfg_max_results), cfg_max_results))
char_budget = cfg_max_tokens * 4
try:
steps: list[dict] = engine.get_agent_friendly_steps() or []
except Exception as e:
return f"recall_history failed to load history: {e}"
range_start = range_end = None
if step_range:
try:
range_start, range_end = int(step_range[0]), int(step_range[-1])
if range_start > range_end:
range_start, range_end = range_end, range_start
except (TypeError, ValueError, IndexError):
range_start = range_end = None
def in_range(number: Any) -> bool:
if range_start is None:
return True
return isinstance(number, int) and range_start <= number <= range_end
scoped_steps = [s for s in steps if in_range(s.get("step_number"))]
terms = _terms(query)
session_start = _session_start(engine)
if not steps:
return "recall_history: no recorded steps yet."
if step_range and not scoped_steps:
return (
f"recall_history: no recorded steps in range"
f" {range_start}–{range_end}."
)
sections: list[str] = []
# --- Range ledger: the explicit re-entry point for 'ledger via
# recall_history' guidance lines and recall-only era period paragraphs —
# full-width build_action_ledger rows.
if range_start is not None and scoped_steps:
from artemis.memory.chunking import build_action_ledger
ledger = build_action_ledger(scoped_steps, session_start)
sections.append(
f"Action ledger for Steps {range_start}–{range_end}"
f" ({len(scoped_steps)} recorded steps):\n{ledger}"
)
# --- Keyword search over steps.
results: list[tuple[int, int, str]] = [] # (score, step_number, rendered)
if terms:
xml_eligible = {
id(s) for s in scoped_steps[-XML_SCAN_CAP:]
}
for step in scoped_steps:
haystack = _step_haystack(step)
screen = _screen_text(engine, step) if id(step) in xml_eligible else ""
score = _score(haystack, terms)
screen_score = _score(screen, terms) if screen else 0
total = score + screen_score
if total <= 0:
continue
excerpt_limit = DETAIL_EXCERPT_CHARS if include_details else EXCERPT_CHARS
source = haystack if score >= screen_score else screen
body = _excerpt(source, terms, excerpt_limit)
lines = [f"[{_step_label(step, session_start)} | id {step.get('step_id')}]"]
summary = step.get("summary")
if summary:
lines.append(f" Summary: {_clamp(str(summary), EXCERPT_CHARS)}")
lines.append(f" Match: {body}")
if include_details:
action = step.get("action_taken")
if action:
lines.append(
" Action: "
+ _clamp(json.dumps(action, ensure_ascii=False, default=str), DETAIL_EXCERPT_CHARS)
)
result = step.get("last_execution_result")
if result:
lines.append(
" Result: "
+ _clamp(json.dumps(result, ensure_ascii=False, default=str), DETAIL_EXCERPT_CHARS)
)
thinking = step.get("operator_raw_thinking")
if thinking:
lines.append(f" Reasoning: {_clamp(str(thinking), DETAIL_EXCERPT_CHARS)}")
number = step.get("step_number") or 0
results.append((total, number, "\n".join(lines)))
# --- History chunks (bands ①②③ / rendered text).
try:
chunk_rows = engine.get_history_chunks() or []
except Exception:
chunk_rows = []
for row in chunk_rows:
start_n = getattr(row, "start_step_number", None)
end_n = getattr(row, "end_step_number", None)
if range_start is not None and not (
isinstance(start_n, int)
and isinstance(end_n, int)
and start_n <= range_end
and end_n >= range_start
):
continue
text = getattr(row, "rendered_text", None) or "\n".join(
filter(None, [getattr(row, "band2", None), getattr(row, "band3", None)])
)
score = _score(text or "", terms)
if score <= 0:
continue
body = _excerpt(text, terms, DETAIL_EXCERPT_CHARS if include_details else EXCERPT_CHARS)
rendered = (
f"[History chunk | Steps {start_n}–{end_n} | status"
f" {getattr(row, 'status', '?')}]\n Match: {body}"
)
results.append((score, int(end_n or 0), rendered))
# --- Notes (filesystem; attributed to their writing steps).
try:
from artemis.utils.notes import list_notes_keys, read_note_content
base_dir = getattr(engine, "base_dir", None)
note_keys = list_notes_keys(base_dir) if base_dir else []
except Exception:
note_keys = []
for key in note_keys:
try:
content = read_note_content(base_dir, key)
except Exception:
continue
score = _score(content, terms) + _score(key, terms)
if score <= 0:
continue
writers = _note_writer_steps(steps, key)
if range_start is not None and writers and not any(
range_start <= n <= range_end for n in writers
):
continue
anchor = writers[-1] if writers else (steps[-1].get("step_number") or 0)
origin = (
f"last written at Step {writers[-1]}"
if writers
else f"as of Step {anchor}"
)
body = _excerpt(content, terms, DETAIL_EXCERPT_CHARS if include_details else EXCERPT_CHARS)
results.append(
(score, int(anchor), f"[Note '{key}' | {origin}]\n Match: {body}")
)
results.sort(key=lambda r: (-r[0], -r[1]))
kept = results[:max_results]
dropped = len(results) - len(kept)
if kept:
header = f"Top {len(kept)} match(es) for {terms}:"
if dropped > 0:
header += f" ({dropped} more not shown; narrow the query or step_range)"
sections.append(header + "\n\n" + "\n".join(r[2] for r in kept))
elif terms:
sections.append(f"No matches for {terms} in the searched history.")
text = "\n\n".join(sections)
if _estimate_tokens(text) > cfg_max_tokens:
text = (
text[:char_budget].rstrip()
+ "\n… (response truncated at the recall token budget; narrow the"
" query or step_range)"
)
if not include_images or cfg_max_image_steps < 1:
return text
# --- Image recall: real stored screenshots of ONE step only.
target_step: dict | None = None
if kept:
# The best step-shaped result; fall back to the newest scoped step.
best_numbers = [r[1] for r in kept]
for number in best_numbers:
target_step = next(
(s for s in scoped_steps if s.get("step_number") == number), None
)
if target_step:
break
if target_step is None and scoped_steps:
target_step = scoped_steps[-1]
if target_step is None:
return text
blocks: list[dict] = [{"type": "text", "text": text}]
added = 0
for which, name in (
("pre-action", target_step.get("pre_image_name")),
("post-action", target_step.get("post_image_name")),
):
if not name:
continue
try:
path = engine.get_image_path(name)
if not path or not path.exists():
continue
encoded = base64.b64encode(path.read_bytes()).decode("utf-8")
except Exception:
continue
blocks.append(
{
"type": "text",
"text": (
f"--- {which} screenshot of Step"
f" {target_step.get('step_number')} ---"
),
}
)
blocks.append(
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
}
)
added += 1
if added == 0:
blocks.append(
{
"type": "text",
"text": (
f"(no stored screenshots exist for Step"
f" {target_step.get('step_number')})"
),
}
)
return blocks
RECALL_HISTORY_DOCSTRING = (
"[SHELL] Deterministic lookup into older execution history that has been"
" compressed out of the visible context. Searches step summaries, exact"
" actions and validator results, reasoning, notes, on-screen XML/OCR text"
" and history-chunk ledgers by keywords and/or a step-number range;"
" a step_range also returns that range's full per-step action ledger"
" (the entry point for 'ledger via recall_history' guidance/marker lines"
" and for recall-only '[Era ...]' period paragraphs)."
" Results are bounded and every result carries its step number."
" Do not call it speculatively on ordinary steps."
)
class RecallHistoryTool(ArtemisTool):
"""Unified cold-history recall over the DataEngine (design §10)."""
def __init__(self):
super().__init__(
name="recall_history",
description=RECALL_HISTORY_DOCSTRING,
args_schema=RecallHistoryArgs,
category="memory",
)
def is_available(self, ctx: Any = None) -> bool:
if ctx is None or getattr(ctx, "data_engine", None) is None:
return False
return bool(getattr(_recall_config(warn=False), "enabled", True))
# pylint: disable=too-many-arguments
async def execute(
self,
driver: Any = None, # pylint: disable=unused-argument
ctx: Any = None,
query: str = "",
step_range: list[int] | None = None,
include_details: bool = False,
include_images: bool = False,
max_results: int = 5,
**kwargs: Any,
) -> Any:
engine = getattr(ctx, "data_engine", None) if ctx else None
if engine is None:
return "recall_history unavailable: no active DataEngine session."
if not query and not step_range:
return (
"recall_history needs a query and/or a step_range — e.g."
' recall_history(query="login timeout") or'
" recall_history(query=\"\", step_range=[1, 40])."
)
try:
return search_history(
engine,
query=query or "",
step_range=step_range,
include_details=bool(include_details),
include_images=bool(include_images),
max_results=int(max_results or 5),
recall_config=_recall_config(),
)
except Exception as e:
logger.error(f"recall_history failed: {e}")
return f"recall_history failed: {e}"
def _recall_config(warn: bool = True) -> Any:
try:
from artemis.config import load_agent_config
return load_agent_config().memory.recall
except Exception as e:
if warn:
logger.debug(f"recall config unavailable, using defaults: {e}")
return None
recall_history = RecallHistoryTool()
ToolRegistry.register(recall_history)
def get_recall_history_tool(ctx: Any):
"""Exports recall_history as a traced LangChain BaseTool."""
return trace_langchain_tool(recall_history.to_langchain_tool(ctx), ctx)
def _recall_available(ctx: Any) -> bool:
return recall_history.is_available(ctx)
recall_history_wrapper = ToolWrapper(
tool_fn_getter=get_recall_history_tool,
on_success_fn=lambda *a, **k: "Recalled history",
on_failure_fn=lambda err: f"recall_history failed: {err}",
is_available_fn=_recall_available,
)
+123
View File
@@ -0,0 +1,123 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Record-time action semantics enrichment via element hit testing.
An action addressed by bare coordinates carries no element semantics
("tap(632,1180)" says nothing about what was tapped). Before such an action is
persisted to the DataEngine, these helpers resolve the smallest indexed element
covering the tap point on the pre-action frame and attach best-effort
``target_text`` / ``target_class`` / ``target_resource_id`` fields, plus a
``target_label_source`` marker distinguishing model-named targets from
after-the-fact inference:
- ``"index"``: the model addressed the element by its perception index — the
semantics come straight from the indexed element (highest confidence).
- ``"hit_test"``: inferred by hit testing the coordinates against XML-derived
indexed elements on the pre-action frame.
- ``"ocr"``: inferred by hit testing against OCR-derived indexed elements
(used only when no XML-derived element covers the point).
- ``"none"``: no element covers the point (or no perception data was
available) — the action stays a bare-coordinate action.
Enrichment is strictly best-effort: missing/malformed element data degrades to
``"none"`` and never raises.
"""
from typing import Any
from artemis.utils.visualization import parse_bounds
_LABEL_SOURCE_KEY = "target_label_source"
def _element_bounds(element: dict[str, Any]) -> tuple[int, int, int, int] | None:
"""Returns (left, top, right, bottom) pixel bounds of an indexed element.
Accepts both list bounds (``[l, t, r, b]``, the shape produced by
``format_minimal_list_with_elements``) and Android bounds strings
(``"[l,t][r,b]"``). Returns None for missing or malformed bounds.
"""
bounds = element.get("bounds")
if isinstance(bounds, str):
return parse_bounds(bounds)
if isinstance(bounds, (list, tuple)) and len(bounds) == 4:
try:
left, top, right, bottom = (int(v) for v in bounds)
except (TypeError, ValueError):
return None
return left, top, right, bottom
return None
def find_element_at_point(
elements: list[dict[str, Any]] | None, x: int, y: int
) -> tuple[dict[str, Any] | None, str]:
"""Finds the smallest indexed element whose bounds contain pixel point (x, y).
XML-derived elements are preferred over OCR-derived ones (OCR boxes carry
text but weaker structure); within each group the smallest covering area
wins. Returns ``(element, source)`` where source is ``"hit_test"``,
``"ocr"``, or ``"none"`` (with element None).
"""
best_xml: tuple[int, dict[str, Any]] | None = None
best_ocr: tuple[int, dict[str, Any]] | None = None
for element in elements or []:
if not isinstance(element, dict):
continue
rect = _element_bounds(element)
if rect is None:
continue
left, top, right, bottom = rect
if not (left <= x <= right and top <= y <= bottom):
continue
area = max(1, (right - left)) * max(1, (bottom - top))
if element.get("is_ocr"):
if best_ocr is None or area < best_ocr[0]:
best_ocr = (area, element)
else:
if best_xml is None or area < best_xml[0]:
best_xml = (area, element)
if best_xml is not None:
return best_xml[1], "hit_test"
if best_ocr is not None:
return best_ocr[1], "ocr"
return None, "none"
def hit_test_semantics(
elements: list[dict[str, Any]] | None, x: int, y: int
) -> dict[str, Any]:
"""Best-effort semantic fields for a bare-coordinate action at pixel (x, y).
Always returns a dict containing ``target_label_source``; the
``target_text`` / ``target_class`` / ``target_resource_id`` fields are only
present when the covering element carries them.
"""
try:
element, source = find_element_at_point(elements, int(x), int(y))
except Exception:
return {_LABEL_SOURCE_KEY: "none"}
semantics: dict[str, Any] = {_LABEL_SOURCE_KEY: source}
if element is None:
return semantics
if element.get("text"):
semantics["target_text"] = element["text"]
if element.get("class"):
semantics["target_class"] = element["class"]
if element.get("resource_id"):
semantics["target_resource_id"] = element["resource_id"]
return semantics
+81
View File
@@ -0,0 +1,81 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Pure-PIL perceptual hashing (dHash) for screen-similarity hints (M4).
A 64-bit difference hash: grayscale, resize to 9x8, compare each pixel to its
right neighbor. Robust to JPEG re-encoding and mild rendering noise, cheap to
compute (one small resize), and comparable with an integer Hamming distance —
no model call, no historical image bytes needed at compare time.
Hashes are stored per step in ``extra_metadata`` at record time
(``pre_image_dhash`` / ``post_image_dhash``, 16 hex chars) and compared
against the current screenshot before prompt construction.
"""
import io
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
#: Grid width of the dHash (the hash carries HASH_SIZE * HASH_SIZE bits).
HASH_SIZE = 8
def dhash_hex(image_bytes: bytes | None, hash_size: int = HASH_SIZE) -> str | None:
"""64-bit dHash of an encoded image, as a 16-char hex string.
Best-effort: returns ``None`` on any failure (missing/corrupt bytes,
Pillow unavailable) — perceptual hashing is an enhancement, never a
dependency.
"""
if not image_bytes:
return None
try:
from PIL import Image
with Image.open(io.BytesIO(image_bytes)) as img:
# JPEG fast-path: DCT-scaled draft decode keeps the sync cost of
# hashing at record time to a few milliseconds. The draft is only
# an intermediate resolution; the final resize below fixes the
# grid, so hashes stay comparable across code paths.
try:
img.draft("L", (hash_size * 8, hash_size * 8))
except Exception:
pass
small = img.convert("L").resize(
(hash_size + 1, hash_size), Image.Resampling.LANCZOS
)
pixels = list(small.getdata())
bits = 0
for row in range(hash_size):
for col in range(hash_size):
left = pixels[row * (hash_size + 1) + col]
right = pixels[row * (hash_size + 1) + col + 1]
bits = (bits << 1) | (1 if left > right else 0)
return f"{bits:0{hash_size * hash_size // 4}x}"
except Exception as e:
logger.debug(f"dhash_hex failed: {e}")
return None
def hamming_distance_hex(hash_a: str | None, hash_b: str | None) -> int | None:
"""Bit-level Hamming distance between two hex hashes; None if incomparable."""
if not hash_a or not hash_b or len(hash_a) != len(hash_b):
return None
try:
return bin(int(hash_a, 16) ^ int(hash_b, 16)).count("1")
except ValueError:
return None
+38 -3
View File
@@ -801,14 +801,22 @@ def build_plan_and_history(
keep_subgoal_hashes: set | None = None,
min_summaries: int = 5,
last_n_detailed: int = 1,
detailed_subgoal_hashes: set | None = None,
all_detailed: bool = False,
strict_milestone_pruning: bool = False,
recent_window_size: int = 3,
chronological_last_step: bool = False,
for_failure_analyzer: bool = False,
chunks: list | None = None,
) -> str:
"""Builds a clean plan list and separate flat chronological execution history with adaptive compression."""
"""Builds a clean plan list and separate flat chronological execution history with adaptive compression.
``chunks`` (optional, M4): pre-rendered history-chunk blocks — each a dict
with ``start_step_number``/``end_step_number``/``text``. When provided,
the chunk blocks are emitted right after the history header and the
per-step lines inside any chunked range are dropped (the chunk block
replaces them), except steps that must render detailed (the recent
window / the most-recent step stay untouched). When ``chunks`` is
``None`` or empty the output is byte-identical to the pre-M4 rendering.
"""
# 1. Plan Checklist Section
output_parts = []
@@ -900,6 +908,26 @@ def build_plan_and_history(
# Ensure the last step is always in detailed_step_ids
detailed_step_ids.add(full_info_step_id)
# M4: chunk blocks replace the per-step lines of already-chunked
# ranges. Chunks only ever cover frozen (old) turns, so they are
# emitted first, in chronological order; steps that must render
# detailed (recent window / most-recent step) are never suppressed.
chunked_ranges: list[tuple[int, int]] = []
if chunks:
for chunk in sorted(
chunks, key=lambda c: c.get("start_step_number") or 0
):
text = chunk.get("text")
if not text:
continue
try:
start_n = int(chunk.get("start_step_number"))
end_n = int(chunk.get("end_step_number"))
except (TypeError, ValueError):
continue
chunked_ranges.append((start_n, end_n))
output_parts.append(text)
for step in steps:
step_id = step["step_id"]
@@ -907,6 +935,13 @@ def build_plan_and_history(
if step_id not in visible_step_ids:
continue
if chunked_ranges and step_id not in detailed_step_ids and step_id != full_info_step_id:
step_number = step.get("step_number")
if isinstance(step_number, int) and any(
start_n <= step_number <= end_n for start_n, end_n in chunked_ranges
):
continue # represented by its chunk block above
relative_time = step.get("relative_time") or "N/A"
summary = step.get("summary")
action = step.get("action_taken")