mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-20 13:32:30 +00:00
abf1af5401
* 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>
149 lines
5.8 KiB
Python
149 lines
5.8 KiB
Python
"""Tests for SessionDB.get_messages_around (anchored-window primitive).
|
|
|
|
Used by session_search both for the discovery shape (FTS5 match as anchor)
|
|
and the scroll shape (user-supplied anchor). Returns a window of messages
|
|
around the anchor plus before/after counts so callers can detect session
|
|
boundaries.
|
|
"""
|
|
import pytest
|
|
|
|
from hermes_state import SessionDB
|
|
|
|
|
|
@pytest.fixture
|
|
def db(tmp_path):
|
|
return SessionDB(tmp_path / "state.db")
|
|
|
|
|
|
def _seed(db, sid="s1", n=10):
|
|
"""Create session with n alternating user/assistant messages, return ids ascending."""
|
|
db.create_session(sid, source="cli")
|
|
ids = []
|
|
for i in range(n):
|
|
role = "user" if i % 2 == 0 else "assistant"
|
|
# append_message returns the new id
|
|
mid = db.append_message(sid, role=role, content=f"msg {i}")
|
|
ids.append(mid)
|
|
return ids
|
|
|
|
|
|
class TestBasicWindow:
|
|
def test_returns_window_around_anchor(self, db):
|
|
ids = _seed(db, n=10)
|
|
anchor = ids[5]
|
|
view = db.get_messages_around("s1", anchor, window=2)
|
|
# Expected: 2 before + anchor + 2 after = 5 messages
|
|
msgs = view["window"]
|
|
assert len(msgs) == 5
|
|
assert [m["id"] for m in msgs] == [ids[3], ids[4], ids[5], ids[6], ids[7]]
|
|
assert view["messages_before"] == 2
|
|
assert view["messages_after"] == 2
|
|
|
|
def test_window_zero_returns_only_anchor(self, db):
|
|
ids = _seed(db, n=5)
|
|
view = db.get_messages_around("s1", ids[2], window=0)
|
|
assert len(view["window"]) == 1
|
|
assert view["window"][0]["id"] == ids[2]
|
|
assert view["messages_before"] == 0
|
|
assert view["messages_after"] == 0
|
|
|
|
def test_negative_window_clamps_to_zero(self, db):
|
|
ids = _seed(db, n=5)
|
|
view = db.get_messages_around("s1", ids[2], window=-3)
|
|
# Just anchor, like window=0
|
|
assert len(view["window"]) == 1
|
|
assert view["window"][0]["id"] == ids[2]
|
|
|
|
|
|
class TestBoundaryDetection:
|
|
"""messages_before / messages_after tell the agent it's at start/end."""
|
|
|
|
def test_at_session_start_messages_before_is_short(self, db):
|
|
ids = _seed(db, n=10)
|
|
# Anchor on first message; ask for window=5
|
|
view = db.get_messages_around("s1", ids[0], window=5)
|
|
assert view["messages_before"] == 0 # nothing before the first msg
|
|
assert view["messages_after"] == 5
|
|
# window contains anchor + 5 after = 6 messages
|
|
assert len(view["window"]) == 6
|
|
|
|
def test_at_session_end_messages_after_is_short(self, db):
|
|
ids = _seed(db, n=10)
|
|
view = db.get_messages_around("s1", ids[-1], window=5)
|
|
assert view["messages_before"] == 5
|
|
assert view["messages_after"] == 0
|
|
assert len(view["window"]) == 6
|
|
|
|
def test_window_larger_than_session(self, db):
|
|
ids = _seed(db, n=3)
|
|
view = db.get_messages_around("s1", ids[1], window=50)
|
|
# All 3 messages return, both boundaries hit
|
|
assert len(view["window"]) == 3
|
|
assert view["messages_before"] == 1
|
|
assert view["messages_after"] == 1
|
|
|
|
|
|
class TestAnchorValidation:
|
|
def test_missing_anchor_returns_empty(self, db):
|
|
_seed(db, n=5)
|
|
view = db.get_messages_around("s1", 99999, window=5)
|
|
assert view["window"] == []
|
|
assert view["messages_before"] == 0
|
|
assert view["messages_after"] == 0
|
|
|
|
def test_anchor_in_different_session_returns_empty(self, db):
|
|
# Two sessions, ask for s1's anchor in s2's namespace
|
|
ids1 = _seed(db, sid="s1", n=5)
|
|
_seed(db, sid="s2", n=5)
|
|
view = db.get_messages_around("s2", ids1[2], window=2)
|
|
assert view["window"] == []
|
|
|
|
|
|
class TestScrollPattern:
|
|
"""The forward/backward scroll loop the agent will run."""
|
|
|
|
def test_scroll_forward_re_anchored_on_last_id(self, db):
|
|
ids = _seed(db, n=20)
|
|
anchor = ids[5]
|
|
v1 = db.get_messages_around("s1", anchor, window=3)
|
|
last_id = v1["window"][-1]["id"]
|
|
v2 = db.get_messages_around("s1", last_id, window=3)
|
|
# Boundary id (last_id) appears in both windows (in v2 it's the anchor)
|
|
assert last_id in [m["id"] for m in v1["window"]]
|
|
assert last_id in [m["id"] for m in v2["window"]]
|
|
# v2's window extends beyond v1
|
|
assert max(m["id"] for m in v2["window"]) > max(m["id"] for m in v1["window"])
|
|
|
|
def test_scroll_backward_re_anchored_on_first_id(self, db):
|
|
ids = _seed(db, n=20)
|
|
anchor = ids[10]
|
|
v1 = db.get_messages_around("s1", anchor, window=3)
|
|
first_id = v1["window"][0]["id"]
|
|
v2 = db.get_messages_around("s1", first_id, window=3)
|
|
assert first_id in [m["id"] for m in v1["window"]]
|
|
assert first_id in [m["id"] for m in v2["window"]]
|
|
assert min(m["id"] for m in v2["window"]) < min(m["id"] for m in v1["window"])
|
|
|
|
|
|
class TestContentHydration:
|
|
def test_content_is_decoded(self, db):
|
|
ids = _seed(db, n=3)
|
|
view = db.get_messages_around("s1", ids[1], window=1)
|
|
for m in view["window"]:
|
|
assert isinstance(m.get("content"), str)
|
|
assert m["content"].startswith("msg ")
|
|
|
|
def test_tool_calls_deserialized(self, db):
|
|
db.create_session("s1", source="cli")
|
|
# Message with tool_calls (pass list — append_message JSON-encodes it)
|
|
tc_payload = [{"id": "t1", "function": {"name": "x", "arguments": "{}"}}]
|
|
db.append_message("s1", role="assistant", content="", tool_calls=tc_payload)
|
|
mid = db.append_message("s1", role="tool", content="result", tool_name="x")
|
|
|
|
view = db.get_messages_around("s1", mid, window=2)
|
|
# Find the assistant message with tool_calls
|
|
asst = [m for m in view["window"] if m.get("role") == "assistant"]
|
|
assert asst, "expected an assistant message"
|
|
# tool_calls should be a list after hydration, not a string
|
|
assert isinstance(asst[0].get("tool_calls"), list)
|