Files
hermes-agent/tools/session_search_tool_discover.py

193 lines
9.7 KiB
Python

"""Discovery shape of session_search: FTS5 query, title match, lineage dedup
and adaptive/full hydration of the surviving results."""
import json
import logging
from typing import Any, Dict, List, Optional
from tools.registry import tool_error
from tools.session_search_tool_common import (
_DISCOVER_SCAN_LIMIT, _DISCOVER_SEARCH_FIELDS, _HIDDEN_SESSION_SOURCES, _annotate_rebuild_status,
_format_timestamp, _is_compacted_message, _is_compaction_summary, _order_for_recall, _quiet,
_resolve_lineage, _resolve_to_parent, _session_left_live_context, _session_link, _shape_message,
)
def _normalize_title_query(query: str) -> str:
"""Strip common quoting the model may include around a remembered title."""
return query.strip().strip("`'\"")
def _title_match_result(db, query: str, current_lineage_root: Optional[str]) -> Optional[Dict[str, Any]]:
"""Return a discovery-shaped result when the query matches a session title."""
title_query = _normalize_title_query(query)
if not title_query:
return None
session_id = _quiet(lambda: db.resolve_session_by_title(title_query), None,
"resolve_session_by_title failed for %r", title_query)
if not session_id:
return None
lineage_root = _resolve_lineage(db, session_id)
# Same-lineage title hits are in-context only while the session is live;
# /new-reset and compression-ended parents are not.
if (
current_lineage_root
and lineage_root == current_lineage_root
and not _session_left_live_context(db, session_id)
):
return None
session_meta = _quiet(lambda: db.get_session(lineage_root) or db.get_session(session_id), None,
"get_session failed for title match %s", session_id) or {}
if session_meta.get("source") in _HIDDEN_SESSION_SOURCES:
return None
messages = _quiet(lambda: db.get_messages(session_id), [], "get_messages failed for title match %s", session_id)
anchor_id = messages[0].get("id") if messages else None
view = {}
if anchor_id is not None:
view = _quiet(lambda: db.get_anchored_view(session_id, anchor_id, window=5, bookend=3), {},
"get_anchored_view failed for title match %s/%s", session_id, anchor_id)
entry = {
"session_id": session_id, "when": _format_timestamp(session_meta.get("started_at")),
"source": session_meta.get("source", "unknown"), "model": session_meta.get("model") or "unknown",
"title": session_meta.get("title") or title_query, "matched_role": "session_title",
"match_message_id": anchor_id,
"snippet": f"Session title matched: {session_meta.get('title') or title_query}",
"bookend_start": [_shape_message(m) for m in (view.get("bookend_start") or messages[:3])],
"messages": [_shape_message(m, anchor_id=anchor_id) for m in (view.get("window") or messages[:5])],
"bookend_end": [_shape_message(m) for m in (view.get("bookend_end") or messages[-3:])],
"messages_before": view.get("messages_before", 0),
"messages_after": view.get("messages_after", max(len(messages) - 5, 0)),
"detail": "full", "_lineage_root": lineage_root,
}
if lineage_root and lineage_root != session_id:
entry["parent_session_id"] = lineage_root
return entry
def _discover_payload(db, query: str, detail: str, results: list, **extra) -> str:
payload = {"success": True, "mode": "discover", "query": query, "detail": detail,
"results": results, "count": len(results), **extra}
_annotate_rebuild_status(db, payload)
return json.dumps(payload, ensure_ascii=False)
def _dedupe_by_lineage(db, raw_results, limit, seen_sessions, current_session_id, current_lineage_root) -> None:
"""Fill *seen_sessions* (lineage_root -> first surviving FTS row) up to *limit*.
The raw owning session_id stays on the row — only it pairs validly with the
FTS match id for the anchored window. Current-lineage hits are skipped
UNLESS the transcript left live context: compression-ended session, /new-
reset predecessor (hiding it made gateway recall blind after every /new),
or an in-place compacted row on the SAME session_id. A live delegation
child has end_reason=None, so it stays excluded.
"""
for r in raw_results:
if len(seen_sessions) >= limit:
break
raw_sid = r["session_id"]
resolved_sid, _ = _resolve_to_parent(db, raw_sid)
is_compacted_hit = _is_compacted_message(db, r.get("id"))
is_ended_session = _session_left_live_context(db, raw_sid)
if (
current_lineage_root
and resolved_sid == current_lineage_root
and not (is_ended_session or is_compacted_hit)
):
continue
if current_session_id and raw_sid == current_session_id and not is_compacted_hit:
continue
seen_sessions.setdefault(resolved_sid, {**r, "_lineage_root": resolved_sid})
def _bookend(view: Dict[str, Any], key: str) -> List[Dict[str, Any]]:
return [_shape_message(m, max_content_len=1200) for m in (view.get(key) or [])
if not _is_compaction_summary(m.get("content", ""))]
def _hydrate_hit(db, lineage_root: str, match_info: Dict[str, Any], result_detail: str) -> Optional[Dict[str, Any]]:
"""Build one discovery result from a surviving FTS row; None if the anchored
view can't be loaded (the hit is dropped)."""
hit_sid = match_info.get("session_id") or lineage_root
msg_id = match_info.get("id")
try:
view = db.get_anchored_view(hit_sid, msg_id, window=5, bookend=3)
except Exception as e:
logging.warning("get_anchored_view failed for %s/%s: %s", hit_sid, msg_id, e, exc_info=True)
return None
session_meta = _quiet(lambda: db.get_session(lineage_root), None, "get_session failed for %s", lineage_root) or {}
full = result_detail == "full"
window_messages = view.get("window") or []
if not full:
window_messages = [m for m in window_messages if m.get("id") == msg_id]
entry = {
"session_id": hit_sid,
"when": _format_timestamp(session_meta.get("started_at") or match_info.get("session_started")),
"source": session_meta.get("source") or match_info.get("source", "unknown"),
"model": session_meta.get("model") or match_info.get("model") or "unknown",
"title": session_meta.get("title") or None, "matched_role": match_info.get("role"),
"match_message_id": msg_id, "snippet": match_info.get("snippet") or "",
"bookend_start": _bookend(view, "bookend_start") if full else [],
"messages": [_shape_message(m, anchor_id=msg_id, max_content_len=4000) for m in window_messages],
"bookend_end": _bookend(view, "bookend_end") if full else [],
"messages_before": view.get("messages_before", 0), "messages_after": view.get("messages_after", 0),
"detail": result_detail,
}
if lineage_root and lineage_root != hit_sid:
entry["parent_session_id"] = lineage_root
return entry
def _discover(db, query: str, role_filter: Optional[List[str]], limit: int, sort: Optional[str],
detail: str, current_session_id: str = None, link_profile: str = None) -> str:
"""Discovery shape: FTS5 plus adaptive or full result hydration."""
role_list = role_filter if role_filter else ["user", "assistant"]
current_lineage_root = _resolve_lineage(db, current_session_id) if current_session_id else None
title_result = _title_match_result(db, query, current_lineage_root)
try:
raw_results = db.search_messages(
query=query, role_filter=role_list, exclude_sources=list(_HIDDEN_SESSION_SOURCES),
limit=_DISCOVER_SCAN_LIMIT, offset=0, sort=sort, fields=_DISCOVER_SEARCH_FIELDS,
)
except Exception as e:
logging.error("FTS5 search failed: %s", e, exc_info=True)
return tool_error(f"Search failed: {e}", success=False)
# Demote cron rows below interactive ones BEFORE dedup so a high-volume
# cron corpus can't starve the user's own sessions out of the top `limit`.
raw_results = _order_for_recall(raw_results)
if not raw_results and not title_result:
return _discover_payload(db, query, detail, [], message=(
"No matching sessions found. FTS5 ANDs all terms by default — "
"broaden with OR (`alpha OR beta`), exact-match with quoted "
"phrases, exclude with NOT, or prefix-match with `deploy*`."
))
seen_sessions: Dict[str, Dict[str, Any]] = {}
results = []
if title_result:
title_lineage = title_result.pop("_lineage_root", None)
if title_lineage:
seen_sessions[title_lineage] = {"_title_only": True}
results.append(title_result)
_dedupe_by_lineage(db, raw_results, limit, seen_sessions, current_session_id, current_lineage_root)
for lineage_root, match_info in seen_sessions.items():
if match_info.get("_title_only"):
continue
# Adaptive: only the top-ranked result is fully hydrated.
entry = _hydrate_hit(db, lineage_root, match_info, "full" if detail == "full" or not results else "compact")
if entry is not None:
results.append(entry)
for entry in results:
entry["link"] = _session_link(entry["session_id"], link_profile)
return _discover_payload(db, query, detail, results, sessions_searched=len(seen_sessions), link_hint=(
"When referring the user to a session, write its `link` value "
"verbatim inline mid-sentence (it renders as a titled link) — never "
"as markdown, in backticks, on its own line, or next to the "
"title/id/date. To read more around a compact result, scroll: "
"session_search(session_id=..., around_message_id=match_message_id)."
))