Hermes-agent

This commit is contained in:
Zakaria
2026-06-14 14:30:48 -04:00
commit dac4b88b94
5058 changed files with 1884848 additions and 0 deletions
@@ -0,0 +1,161 @@
"""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"
@@ -0,0 +1,148 @@
"""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)
@@ -0,0 +1,96 @@
"""Regression guard for #15000: --resume <id> after compression loses messages.
Context compression ends the current session and forks a new child session
(linked by ``parent_session_id``). The SQLite flush cursor is reset, so
only the latest descendant ends up with rows in the ``messages`` table —
the parent row has ``message_count = 0``. ``hermes --resume <parent_id>``
used to load zero rows and show a blank chat.
``SessionDB.resolve_resume_session_id()`` walks the parent → child chain
and redirects to the first descendant that actually has messages. These
tests pin that behaviour.
"""
import time
import pytest
from hermes_state import SessionDB
@pytest.fixture
def db(tmp_path):
return SessionDB(tmp_path / "state.db")
def _make_chain(db: SessionDB, ids_with_parent):
"""Create sessions in order, forcing started_at so ordering is deterministic."""
base = int(time.time()) - 10_000
for i, (sid, parent) in enumerate(ids_with_parent):
db.create_session(sid, source="cli", parent_session_id=parent)
db._conn.execute(
"UPDATE sessions SET started_at = ? WHERE id = ?",
(base + i * 100, sid),
)
db._conn.commit()
def test_redirects_from_empty_head_to_descendant_with_messages(db):
# Reproducer shape from #15000: 6 sessions, only the 5th holds messages.
_make_chain(db, [
("head", None),
("mid1", "head"),
("mid2", "mid1"),
("mid3", "mid2"),
("bulk", "mid3"), # has messages
("tail", "bulk"), # empty tail after another compression
])
for i in range(5):
db.append_message("bulk", role="user", content=f"msg {i}")
assert db.resolve_resume_session_id("head") == "bulk"
def test_returns_self_when_session_has_messages(db):
_make_chain(db, [("root", None), ("child", "root")])
db.append_message("root", role="user", content="hi")
assert db.resolve_resume_session_id("root") == "root"
def test_returns_self_when_no_descendant_has_messages(db):
_make_chain(db, [("root", None), ("child1", "root"), ("child2", "child1")])
assert db.resolve_resume_session_id("root") == "root"
def test_returns_self_for_isolated_session(db):
db.create_session("isolated", source="cli")
assert db.resolve_resume_session_id("isolated") == "isolated"
def test_returns_self_for_nonexistent_session(db):
assert db.resolve_resume_session_id("does_not_exist") == "does_not_exist"
def test_empty_session_id_passthrough(db):
assert db.resolve_resume_session_id("") == ""
assert db.resolve_resume_session_id(None) is None
def test_walks_from_middle_of_chain(db):
# If the user happens to know an intermediate ID, we still find the msg-bearing descendant.
_make_chain(db, [("a", None), ("b", "a"), ("c", "b"), ("d", "c")])
db.append_message("d", role="user", content="x")
assert db.resolve_resume_session_id("b") == "d"
assert db.resolve_resume_session_id("c") == "d"
def test_prefers_most_recent_child_when_fork_exists(db):
# If a session was somehow forked (two children), pick the latest one.
# In practice, compression only produces single-chain shape, but the helper
# should degrade gracefully.
_make_chain(db, [
("parent", None),
("older_fork", "parent"),
("newer_fork", "parent"),
])
db.append_message("newer_fork", role="user", content="x")
assert db.resolve_resume_session_id("parent") == "newer_fork"
@@ -0,0 +1,51 @@
import time
import pytest
from hermes_state import SessionDB
@pytest.fixture
def db(tmp_path):
database = SessionDB(tmp_path / "state.db")
try:
yield database
finally:
database.close()
def _compression_pair(db: SessionDB):
base = time.time() - 100
db.create_session("root", source="cli")
db.create_session("tip", source="cli", parent_session_id="root")
db._conn.execute(
"UPDATE sessions SET started_at = ?, ended_at = ?, end_reason = 'compression', message_count = 1 WHERE id = 'root'",
(base, base + 10),
)
db._conn.execute(
"UPDATE sessions SET started_at = ?, message_count = 1 WHERE id = 'tip'",
(base + 20,),
)
db._conn.commit()
def test_archiving_compression_tip_archives_projected_root(db):
_compression_pair(db)
assert db.set_session_archived("tip", True) is True
assert db.get_session("root")["archived"] == 1
assert db.get_session("tip")["archived"] == 1
assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True)] == []
assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True, archived_only=True)] == ["tip"]
def test_unarchiving_compression_tip_unarchives_projected_root(db):
_compression_pair(db)
db.set_session_archived("tip", True)
assert db.set_session_archived("tip", False) is True
assert db.get_session("root")["archived"] == 0
assert db.get_session("tip")["archived"] == 0
assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True)] == ["tip"]