397 lines
15 KiB
Python
397 lines
15 KiB
Python
# 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
|