Files
Artemis-Eapil/artemis/tools/history/search.py
T

397 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
"""search_history: deterministic keyword / step-range lookup over stored history.
One search surface over a :class:`~artemis.data_engine.history_reader.HistoryReader`'s
raw records — step summaries, history chunks, exact actions and execution
results, reasoning, the tool calls a step made (name, arguments, result
text), notes, the OCR/UI-tree text of the step's pre and post screenshots
(which also carries package/activity strings) plus any screenshot a tool
result embedded, and the foreground-app stamp. Keyword + step-range filtering
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);
- screen text is scanned for the most recent ``screen_scan_steps`` steps;
- every result carries a step number / step id;
- large raw sources return only excerpts around the match plus a reference.
A ``step_range`` additionally returns the full-width ``build_action_ledger``
rows of that range — the re-entry point for compressed-history marker lines.
"""
from __future__ import annotations
import json
import sqlite3
from typing import Any
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
#: Character clamp for one matched excerpt.
EXCERPT_CHARS = 240
#: Per-tool-result clamp on the *search* surface (scoring/excerpts only; the
#: replay rendering has its own, looser clamp).
RESULT_SEARCH_CHARS = 8000
DEFAULT_MAX_RESULTS = 5
DEFAULT_MAX_TEXT_TOKENS = 2000
DEFAULT_SCREEN_SCAN_STEPS = 150
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 result_search_text(result: Any) -> str:
"""Searchable text of a tool result. Content-block lists contribute their
text blocks — image blocks have already been rewritten into
``[screenshot: …]`` descriptions by the DataEngine; any raw image block
that slipped through contributes nothing (its OCR/XML description joins
the haystack via :func:`_screen_text`), so base64 never enters the search."""
if result is None:
return ""
if isinstance(result, list):
texts = [
str(block.get("text") or "")
for block in result
if isinstance(block, dict) and block.get("type") == "text"
]
if texts or any(
isinstance(block, dict) and block.get("type") in ("image_url", "image")
for block in result
):
return "\n".join(texts)[:RESULT_SEARCH_CHARS]
if isinstance(result, dict):
return json.dumps(result, ensure_ascii=False, default=str)[:RESULT_SEARCH_CHARS]
return str(result)[:RESULT_SEARCH_CHARS]
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_text = result_search_text(event.get("result"))
if result_text:
parts.append(result_text)
elif etype and "thought" in etype:
parts.append(str(event.get("content") or ""))
return "\n".join(parts)
def _step_haystack(step: dict) -> str:
extra = step.get("extra_metadata") or {}
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(extra.get("injected_instruction") or ""),
# The foreground app package stamped by record_step joins the search
# surface, so package-name queries never depend on the screen-text
# scan (capped to recent steps) alone.
str(extra.get("foreground_app") or ""),
]
return "\n".join(f for f in fields if f)
def _image_text(reader: Any, image_name: str | None) -> str:
"""Raw XML/OCR text stored for one screenshot — the textual description
of that image (package/activity strings live inside the XML blob)."""
if not image_name:
return ""
try:
record = reader.storage.get_image(image_name)
except (sqlite3.Error, ValueError):
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 referenced_image_names(step: dict) -> list[str]:
"""Screenshots that tool results of this step embedded (rewritten by the
DataEngine into ``[screenshot: …]`` text blocks carrying ``image_name``)."""
names: list[str] = []
def walk(value: Any) -> None:
if isinstance(value, dict):
name = value.get("image_name")
if value.get("type") == "text" and name and name not in names:
names.append(str(name))
for child in value.values():
walk(child)
elif isinstance(value, list):
for child in value:
walk(child)
for event in step.get("interleaved_events") or []:
if event.get("type") == "tool_call":
walk(event.get("result"))
return names
def _screen_text(reader: Any, step: dict) -> str:
"""Description text of the screenshots behind a step: its own pre- and
post-action screenshots plus any screenshot a tool result embedded."""
names: list[str] = []
for name in (
step.get("pre_image_name"),
step.get("post_image_name"),
*referenced_image_names(step),
):
if name and name not in names:
names.append(name)
return "\n".join(t for t in (_image_text(reader, n) for n in names) if t)
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 _parse_range(step_range: list[int] | None) -> tuple[int | None, int | None]:
if not step_range:
return None, None
try:
start, end = int(step_range[0]), int(step_range[-1])
except (TypeError, ValueError, IndexError):
return None, None
if start > end:
start, end = end, start
return start, end
# pylint: disable=too-many-branches,too-many-locals,too-many-statements
def search_history_text(
reader: Any,
*,
query: str = "",
step_range: list[int] | None = None,
max_results: int = DEFAULT_MAX_RESULTS,
recall_config: Any = None,
) -> str:
"""Deterministic history search over ``reader``; always returns text."""
cfg_max_results = int(getattr(recall_config, "max_results", None) or DEFAULT_MAX_RESULTS)
cfg_max_tokens = int(getattr(recall_config, "max_text_tokens", None) or DEFAULT_MAX_TEXT_TOKENS)
scan_steps = int(getattr(recall_config, "screen_scan_steps", None) or DEFAULT_SCREEN_SCAN_STEPS)
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] = reader.get_agent_friendly_steps() or []
except Exception as e:
return f"search_history failed to load history: {e}"
range_start, range_end = _parse_range(step_range)
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 = getattr(reader, "session_start_time", None)
if not steps:
return "search_history: no recorded steps yet."
if step_range and not scoped_steps:
return f"search_history: no recorded steps in range {range_start}–{range_end}."
sections: list[str] = []
# --- Range ledger: the explicit re-entry point for compressed-history
# marker lines — 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:
screen_eligible = {id(s) for s in scoped_steps[-scan_steps:]} if scan_steps > 0 else set()
for step in scoped_steps:
haystack = _step_haystack(step)
screen = _screen_text(reader, step) if id(step) in screen_eligible else ""
score = _score(haystack, terms)
screen_score = _score(screen, terms) if screen else 0
total = score + screen_score
if total <= 0:
continue
source = haystack if score >= screen_score else screen
lines = [f"[{_step_label(step, session_start)} | id {step.get('step_id')}]"]
summary = step.get("summary")
if summary:
lines.append(f" Screen: {_clamp(str(summary), EXCERPT_CHARS)}")
lines.append(f" Match: {_excerpt(source, terms, EXCERPT_CHARS)}")
number = step.get("step_number") or 0
results.append((total, number, "\n".join(lines)))
# --- History chunks (bands ①②③ / rendered text).
try:
chunk_rows = reader.get_history_chunks() or []
except (sqlite3.Error, ValueError):
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
rendered = (
f"[History chunk | Steps {start_n}–{end_n} | status"
f" {getattr(row, 'status', '?')}]\n Match: {_excerpt(text, terms, EXCERPT_CHARS)}"
)
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(reader, "base_dir", None)
note_keys = list_notes_keys(base_dir) if base_dir else []
except OSError:
note_keys = []
for key in note_keys:
try:
content = read_note_content(base_dir, key)
except (OSError, UnicodeError):
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}"
results.append(
(
score,
int(anchor),
f"[Note '{key}' | {origin}]\n Match: {_excerpt(content, terms, EXCERPT_CHARS)}",
)
)
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 search token budget; narrow the"
" query or step_range)"
)
return text