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