hermes-agent/tests/hermes_state/test_get_anchored_view.py
Teknium abf1af5401
feat(session_search): single-shape tool with discovery, scroll, browse — no LLM (#27590)
* feat(session_search): single-shape tool with discovery, scroll, browse — no LLM

Replaces the LLM-summarized session_search with a single-shape tool that
returns actual messages from the DB. Three calling shapes inferred from
args (no mode parameter):

  1. Discovery — pass query. FTS5 + anchored ±5 window + bookends per hit,
     all in one call. ~20ms on a real DB instead of ~90s for the previous
     three aux-LLM calls.
  2. Scroll — pass session_id + around_message_id. Returns a window
     centered on the anchor. To paginate, re-anchor on the first/last id
     of the returned window. Boundary message appears in both windows
     as the orientation marker. ~1ms per scroll call.
  3. Browse — no args. Recent sessions chronologically.

Bookend_start (first 3 user+assistant msgs) and bookend_end (last 3) give
the agent goal + resolution on every discovery hit, so a single tool call
reconstructs a long session's arc without loading the whole transcript.

The aux-LLM summary path is gone: it cost ~$0.30/call, took ~30s, and
laundered FTS5 hits through a model that could confabulate when the right
session wasn't in the hit list. The merged shape returns byte-for-byte
content from SQLite.

History:
- PR #20238 (JabberELF) seeded the fast/summary dual-mode split.
- PR #26419 (yoniebans) expanded to fast/guided/summary with bookends,
  multi-anchor drill-down, default-mode config, and a teaching skill.

This PR collapses that toolkit into one shape with explicit scroll
support, drops the summary path, drops the mode parameter, drops the
config knob, drops the skill. JabberELF's seed work is acknowledged via
the AUTHOR_MAP entry.

Validation:
- 38/38 tool tests pass (tests/tools/test_session_search.py)
- 12/12 get_messages_around tests pass (tests/hermes_state/)
- 11/11 get_anchored_view tests pass (tests/hermes_state/)
- Full tests/tools/ run: 5168 passing, 2 failures pre-exist on main
  (test ordering in test_delegate.py, unrelated)
- E2E against live state DB: discovery 20ms, scroll 1ms, browse 280ms;
  pagination forward+backward works with boundary-message orientation;
  error paths return clean tool_error responses

Co-authored-by: JabberELF <abcdjmm970703@gmail.com>
Co-authored-by: yoniebans <jonny@nousresearch.com>

* chore(session_search): prune dead LLM-summary config and docs

Companion to the single-shape rewrite. The auxiliary.session_search config
block, max_concurrency / extra_body tunables, and matching docs sections
all referenced the removed LLM summarization path. Removing them so users
don't try to tune knobs that nothing reads.

- hermes_cli/config.py: drop dead auxiliary.session_search block from
  DEFAULT_CONFIG. Leftover keys in user config.yaml are harmless and
  ignored.
- hermes_cli/tips.py: drop two tips referencing the removed
  max_concurrency / extra_body knobs.
- website/docs/user-guide/configuration.md: drop 'Session Search Tuning'
  section and the auxiliary.session_search block from the example.
- website/docs/user-guide/features/fallback-providers.md: drop session_search
  rows from the auxiliary-tasks tables and the dedicated tuning subsection.
- website/docs/reference/tools-reference.md: rewrite the session_search
  entry to describe the new three-shape behaviour.
- CONTRIBUTING.md: update the file-tree description.
- tests/tools/test_llm_content_none_guard.py: remove TestSessionSearchContentNone
  class and test_session_search_tool_guarded — both guard against an
  unguarded .content.strip() call site in _summarize_session() that no
  longer exists.

Validation: 97/97 targeted tests still pass (hermes_state + session_search +
llm_content_none_guard). Config tests 55/55.

---------

Co-authored-by: JabberELF <abcdjmm970703@gmail.com>
Co-authored-by: yoniebans <jonny@nousresearch.com>
2026-05-17 23:28:45 -07:00

161 lines
7 KiB
Python

"""Tests for SessionDB.get_anchored_view — anchored window + session bookends.
Used by the discovery shape of session_search: an FTS5 match becomes the
anchor, the call returns goal (bookend_start) + match (window) + resolution
(bookend_end) in a single round trip, no LLM.
"""
import pytest
from hermes_state import SessionDB
@pytest.fixture
def db(tmp_path):
return SessionDB(tmp_path / "state.db")
def _seed_long_session(db, sid="s1", n=30):
"""Create a long session with alternating user/assistant prose. Returns ids ascending."""
db.create_session(sid, source="cli")
ids = []
for i in range(n):
role = "user" if i % 2 == 0 else "assistant"
mid = db.append_message(sid, role=role, content=f"prose msg {i}")
ids.append(mid)
return ids
class TestWindowAndBookendShape:
def test_returns_window_with_bookend_start_and_end(self, db):
ids = _seed_long_session(db, n=30)
# Anchor mid-session
anchor = ids[15]
view = db.get_anchored_view("s1", anchor, window=3, bookend=3)
assert len(view["window"]) == 7 # ±3 + anchor
assert len(view["bookend_start"]) == 3
assert len(view["bookend_end"]) == 3
# bookend_start is the first 3 ids of the session
assert [m["id"] for m in view["bookend_start"]] == ids[:3]
# bookend_end is the last 3 ids of the session
assert [m["id"] for m in view["bookend_end"]] == ids[-3:]
def test_window_anchor_marked_correctly(self, db):
ids = _seed_long_session(db, n=20)
anchor = ids[10]
view = db.get_anchored_view("s1", anchor, window=2, bookend=3)
# Anchor message is present in the window
anchor_msgs = [m for m in view["window"] if m["id"] == anchor]
assert len(anchor_msgs) == 1
class TestBookendOverlap:
"""Bookends shouldn't duplicate messages that are already in the window."""
def test_bookend_start_empty_when_window_covers_session_head(self, db):
ids = _seed_long_session(db, n=10)
# Anchor on msg 1 (id index 1), window=3 → covers ids[0..4]
anchor = ids[1]
view = db.get_anchored_view("s1", anchor, window=3, bookend=3)
# Window includes session head, so bookend_start should be empty
assert view["bookend_start"] == []
# bookend_end is still populated
assert len(view["bookend_end"]) > 0
def test_bookend_end_empty_when_window_covers_session_tail(self, db):
ids = _seed_long_session(db, n=10)
# Anchor on second-to-last
anchor = ids[-2]
view = db.get_anchored_view("s1", anchor, window=3, bookend=3)
assert view["bookend_end"] == []
assert len(view["bookend_start"]) > 0
def test_short_session_both_bookends_empty(self, db):
ids = _seed_long_session(db, n=5)
view = db.get_anchored_view("s1", ids[2], window=10, bookend=3)
# Window covers entire session
assert view["bookend_start"] == []
assert view["bookend_end"] == []
# And window has all 5 messages
assert len(view["window"]) == 5
class TestRoleFiltering:
def test_tool_role_filtered_from_window(self, db):
db.create_session("s1", source="cli")
user_ids = []
for i in range(5):
user_ids.append(db.append_message("s1", role="user", content=f"u{i}"))
db.append_message("s1", role="tool", content=f"tool output {i}", tool_name="x")
# Anchor on user message
view = db.get_anchored_view("s1", user_ids[2], window=5, bookend=0)
# No tool messages should appear in the window
roles = [m.get("role") for m in view["window"]]
assert "tool" not in roles
def test_anchor_preserved_even_when_tool_role(self, db):
db.create_session("s1", source="cli")
db.append_message("s1", role="user", content="ask")
tool_id = db.append_message("s1", role="tool", content="tool output", tool_name="x")
db.append_message("s1", role="user", content="follow-up")
# Anchor on the tool message — should still appear despite default filter
view = db.get_anchored_view("s1", tool_id, window=5, bookend=0)
ids_in_window = [m["id"] for m in view["window"]]
assert tool_id in ids_in_window
def test_keep_roles_none_disables_filter(self, db):
db.create_session("s1", source="cli")
anchor_id = db.append_message("s1", role="user", content="ask")
db.append_message("s1", role="tool", content="output", tool_name="x")
view = db.get_anchored_view("s1", anchor_id, window=5, bookend=0, keep_roles=None)
roles = [m.get("role") for m in view["window"]]
assert "tool" in roles
class TestEmptyContentFilter:
"""Tool-call-only assistant turns (empty content) should be skipped in bookends."""
def test_empty_content_messages_excluded_from_bookends(self, db):
db.create_session("s1", source="cli")
# Real prose opener
opener = db.append_message("s1", role="user", content="Let's start the work")
# Empty content assistant turn (tool-call-only — common in agent loops)
db.append_message("s1", role="assistant", content="", tool_calls=[{"id": "t1", "function": {"name": "x", "arguments": "{}"}}])
# More prose
for i in range(20):
db.append_message("s1", role="user" if i % 2 == 0 else "assistant", content=f"prose {i}")
# Another empty assistant near the end
db.append_message("s1", role="assistant", content="", tool_calls=[{"id": "t2", "function": {"name": "y", "arguments": "{}"}}])
# Prose closer
closer = db.append_message("s1", role="assistant", content="Final decision: ship it.")
# Anchor mid-session
view = db.get_anchored_view("s1", opener + 15, window=2, bookend=3)
# Bookend_start should not contain the empty-content tool-call turn
for m in view["bookend_start"]:
assert m.get("content"), "bookend_start should skip empty-content messages"
# Bookend_end should include the closer
end_contents = [m.get("content") for m in view["bookend_end"]]
assert any("Final decision" in (c or "") for c in end_contents)
class TestAnchorValidation:
def test_missing_anchor_returns_empty_view(self, db):
_seed_long_session(db, n=10)
view = db.get_anchored_view("s1", 999999, window=5, bookend=3)
assert view["window"] == []
assert view["bookend_start"] == []
assert view["bookend_end"] == []
assert view["messages_before"] == 0
assert view["messages_after"] == 0
class TestSessionIsolation:
"""Bookends must not cross session boundaries."""
def test_bookends_only_from_anchor_session(self, db):
ids1 = _seed_long_session(db, sid="s1", n=20)
_seed_long_session(db, sid="s2", n=20)
view = db.get_anchored_view("s1", ids1[10], window=2, bookend=3)
# All bookend messages should have session_id = s1 (or session_id col)
for m in view["bookend_start"] + view["bookend_end"]:
assert m.get("session_id") == "s1"