Skip to content

Commit abf1af5

Browse files
teknium1JabberELFyoniebans
authored
feat(session_search): single-shape tool with discovery, scroll, browse — no LLM (NousResearch#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 NousResearch#20238 (JabberELF) seeded the fast/summary dual-mode split. - PR NousResearch#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 <[email protected]> Co-authored-by: yoniebans <[email protected]> * 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 <[email protected]> Co-authored-by: yoniebans <[email protected]>
1 parent 4a3f13b commit abf1af5

15 files changed

Lines changed: 1338 additions & 1079 deletions

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ hermes-agent/
172172
│ ├── vision_tools.py # Image analysis via multimodal models
173173
│ ├── delegate_tool.py # Subagent spawning and parallel task execution
174174
│ ├── code_execution_tool.py # Sandboxed Python with RPC tool access
175-
│ ├── session_search_tool.py # Search past conversations with FTS5 + summarization
175+
│ ├── session_search_tool.py # Search past conversations with FTS5 + anchored windows
176176
│ ├── cronjob_tools.py # Scheduled task management
177177
│ ├── skill_tools.py # Skill search, load, manage
178178
│ └── environments/ # Terminal execution backends

agent/agent_runtime_helpers.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1503,6 +1503,10 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
15031503
query=function_args.get("query", ""),
15041504
role_filter=function_args.get("role_filter"),
15051505
limit=function_args.get("limit", 3),
1506+
session_id=function_args.get("session_id"),
1507+
around_message_id=function_args.get("around_message_id"),
1508+
window=function_args.get("window", 5),
1509+
sort=function_args.get("sort"),
15061510
db=session_db,
15071511
current_session_id=agent.session_id,
15081512
)

agent/tool_executor.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,10 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
622622
query=function_args.get("query", ""),
623623
role_filter=function_args.get("role_filter"),
624624
limit=function_args.get("limit", 3),
625+
session_id=function_args.get("session_id"),
626+
around_message_id=function_args.get("around_message_id"),
627+
window=function_args.get("window", 5),
628+
sort=function_args.get("sort"),
625629
db=session_db,
626630
current_session_id=agent.session_id,
627631
)

hermes_cli/config.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -871,15 +871,10 @@ def _ensure_hermes_home_managed(home: Path):
871871
"timeout": 120, # seconds — compression summarises large contexts; increase for local models
872872
"extra_body": {},
873873
},
874-
"session_search": {
875-
"provider": "auto",
876-
"model": "",
877-
"base_url": "",
878-
"api_key": "",
879-
"timeout": 30,
880-
"extra_body": {},
881-
"max_concurrency": 3, # Clamp parallel summaries to avoid request-burst 429s on small providers
882-
},
874+
# Note: session_search no longer uses an auxiliary LLM (PR #27590 —
875+
# single-shape tool returns DB content directly). The old
876+
# ``auxiliary.session_search.*`` block was removed here. Existing
877+
# values in user config.yaml files are harmless leftovers and ignored.
883878
"skills_hub": {
884879
"provider": "auto",
885880
"model": "",

hermes_cli/tips.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -458,8 +458,6 @@
458458
'image_gen.model in config.yaml picks the FAL model: flux-2/klein, gpt-image-2, nano-banana-pro, and more.',
459459
'image_gen.provider routes image generation through a plugin (OpenAI Images, Codex, FAL) instead of the default.',
460460
'AUXILIARY_VISION_BASE_URL + AUXILIARY_VISION_API_KEY point vision analysis at any OpenAI-compatible endpoint.',
461-
'auxiliary.session_search.max_concurrency bounds how many matched sessions are summarized in parallel (default 3).',
462-
'auxiliary.session_search.extra_body forwards provider-specific OpenAI-compatible fields on summarization calls.',
463461

464462
# --- Security ---
465463
'security.tirith_fail_open: false makes Hermes block commands when the tirith scanner itself errors out.',

hermes_state.py

Lines changed: 230 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
from agent.memory_manager import sanitize_context
2727
from hermes_constants import get_hermes_home
28-
from typing import Any, Callable, Dict, List, Optional, TypeVar
28+
from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar
2929

3030
logger = logging.getLogger(__name__)
3131

@@ -1618,6 +1618,204 @@ def get_messages(self, session_id: str) -> List[Dict[str, Any]]:
16181618
result.append(msg)
16191619
return result
16201620

1621+
def get_messages_around(
1622+
self,
1623+
session_id: str,
1624+
around_message_id: int,
1625+
window: int = 5,
1626+
) -> Dict[str, Any]:
1627+
"""Load a window of messages anchored on a specific message id.
1628+
1629+
Returns a dict with:
1630+
- ``window``: up to ``window`` messages before the anchor, the anchor
1631+
itself, and up to ``window`` messages after, ordered by id ascending.
1632+
- ``messages_before``: count of messages strictly before the anchor
1633+
still in the session (== window unless we hit the start).
1634+
- ``messages_after``: count of messages strictly after the anchor
1635+
still in the session (== window unless we hit the end).
1636+
1637+
Used by ``session_search`` for both the discovery shape (anchored on the
1638+
FTS5 match) and the scroll shape (anchored on any message id). The
1639+
``messages_before`` / ``messages_after`` counts let the caller detect
1640+
session boundaries: when either is less than ``window``, the agent has
1641+
reached one end of the session.
1642+
1643+
Returns an empty window when ``around_message_id`` is not a real id in
1644+
``session_id`` — callers decide how to surface that.
1645+
"""
1646+
if window < 0:
1647+
window = 0
1648+
with self._lock:
1649+
# Confirm the anchor exists in this session.
1650+
anchor_exists = self._conn.execute(
1651+
"SELECT 1 FROM messages WHERE id = ? AND session_id = ? LIMIT 1",
1652+
(around_message_id, session_id),
1653+
).fetchone()
1654+
if not anchor_exists:
1655+
return {"window": [], "messages_before": 0, "messages_after": 0}
1656+
1657+
# Two queries: anchor + before (DESC, take window+1), and after
1658+
# (ASC, take window). Final order is id ASC.
1659+
before_rows = self._conn.execute(
1660+
"SELECT * FROM messages "
1661+
"WHERE session_id = ? AND id <= ? "
1662+
"ORDER BY id DESC LIMIT ?",
1663+
(session_id, around_message_id, window + 1),
1664+
).fetchall()
1665+
after_rows = self._conn.execute(
1666+
"SELECT * FROM messages "
1667+
"WHERE session_id = ? AND id > ? "
1668+
"ORDER BY id ASC LIMIT ?",
1669+
(session_id, around_message_id, window),
1670+
).fetchall()
1671+
1672+
# before_rows is DESC; reverse so it's ASC, then concatenate after_rows.
1673+
rows = list(reversed(before_rows)) + list(after_rows)
1674+
result = []
1675+
for row in rows:
1676+
msg = dict(row)
1677+
if "content" in msg:
1678+
msg["content"] = self._decode_content(msg["content"])
1679+
if msg.get("tool_calls"):
1680+
try:
1681+
msg["tool_calls"] = json.loads(msg["tool_calls"])
1682+
except (json.JSONDecodeError, TypeError):
1683+
logger.warning(
1684+
"Failed to deserialize tool_calls in get_messages_around, falling back to []"
1685+
)
1686+
msg["tool_calls"] = []
1687+
result.append(msg)
1688+
1689+
# before_rows includes the anchor itself; subtract 1 for the count of
1690+
# messages strictly before the anchor in the returned slice.
1691+
messages_before = max(0, len(before_rows) - 1)
1692+
messages_after = len(after_rows)
1693+
return {
1694+
"window": result,
1695+
"messages_before": messages_before,
1696+
"messages_after": messages_after,
1697+
}
1698+
1699+
def get_anchored_view(
1700+
self,
1701+
session_id: str,
1702+
around_message_id: int,
1703+
window: int = 5,
1704+
bookend: int = 3,
1705+
keep_roles: Optional[Tuple[str, ...]] = ("user", "assistant"),
1706+
) -> Dict[str, Any]:
1707+
"""Return an anchored window plus session bookends.
1708+
1709+
Built on top of ``get_messages_around``. Three slices:
1710+
1711+
- ``window``: messages immediately surrounding the anchor. Filtered
1712+
to ``keep_roles`` (tool-response noise dropped by default), EXCEPT
1713+
the anchor itself is always preserved regardless of role.
1714+
- ``bookend_start``: first ``bookend`` user/assistant messages of the
1715+
session — but only those whose id is strictly before the window's
1716+
first message id. Empty when the window already overlaps the
1717+
session head. Empty-content messages (tool-call-only assistant
1718+
turns) are skipped so they don't crowd out actual prose openings.
1719+
- ``bookend_end``: last ``bookend`` user/assistant messages of the
1720+
session, same non-overlap rule at the tail.
1721+
1722+
Bookends let an FTS5 hit anywhere in a long session yield the goal
1723+
(opening) and the resolution (closing) on a single call — without
1724+
loading the whole transcript.
1725+
1726+
Returns ``{"window": [], "messages_before": 0, "messages_after": 0,
1727+
"bookend_start": [], "bookend_end": []}`` when the anchor isn't in
1728+
the session.
1729+
1730+
``keep_roles=None`` disables role filtering (raw window + raw
1731+
bookends).
1732+
"""
1733+
if bookend < 0:
1734+
bookend = 0
1735+
1736+
# Reuse the primitive — handles anchor-existence, content decoding,
1737+
# tool_calls deserialisation, and boundary counts.
1738+
primitive = self.get_messages_around(
1739+
session_id, around_message_id, window=window
1740+
)
1741+
window_rows = primitive["window"]
1742+
if not window_rows:
1743+
return {
1744+
"window": [],
1745+
"messages_before": 0,
1746+
"messages_after": 0,
1747+
"bookend_start": [],
1748+
"bookend_end": [],
1749+
}
1750+
1751+
# Apply role filter to the window, but never drop the anchor itself.
1752+
if keep_roles is not None:
1753+
keep_set = set(keep_roles)
1754+
filtered_window = [
1755+
m for m in window_rows
1756+
if m.get("id") == around_message_id or m.get("role") in keep_set
1757+
]
1758+
else:
1759+
filtered_window = window_rows
1760+
1761+
window_min_id = window_rows[0]["id"]
1762+
window_max_id = window_rows[-1]["id"]
1763+
1764+
# Fetch bookends only when there's room outside the window. SQL filters
1765+
# by id range, role, and non-empty content — tool-call-only assistant
1766+
# turns (content='' with tool_calls populated) are excluded so they
1767+
# don't crowd out actual prose openings/closings.
1768+
bookend_start_rows: List[Any] = []
1769+
bookend_end_rows: List[Any] = []
1770+
if bookend > 0:
1771+
with self._lock:
1772+
role_clause = ""
1773+
role_params: list = []
1774+
if keep_roles is not None:
1775+
role_placeholders = ",".join("?" for _ in keep_roles)
1776+
role_clause = f" AND role IN ({role_placeholders})"
1777+
role_params = list(keep_roles)
1778+
1779+
bookend_start_rows = self._conn.execute(
1780+
f"SELECT * FROM messages "
1781+
f"WHERE session_id = ? AND id < ?{role_clause} "
1782+
f"AND length(content) > 0 "
1783+
f"ORDER BY id ASC LIMIT ?",
1784+
(session_id, window_min_id, *role_params, bookend),
1785+
).fetchall()
1786+
1787+
bookend_end_rows = self._conn.execute(
1788+
f"SELECT * FROM messages "
1789+
f"WHERE session_id = ? AND id > ?{role_clause} "
1790+
f"AND length(content) > 0 "
1791+
f"ORDER BY id DESC LIMIT ?",
1792+
(session_id, window_max_id, *role_params, bookend),
1793+
).fetchall()
1794+
# End rows came back DESC for the LIMIT cap; flip to ASC.
1795+
bookend_end_rows = list(reversed(bookend_end_rows))
1796+
1797+
def _hydrate(row) -> Dict[str, Any]:
1798+
msg = dict(row)
1799+
if "content" in msg:
1800+
msg["content"] = self._decode_content(msg["content"])
1801+
if msg.get("tool_calls"):
1802+
try:
1803+
msg["tool_calls"] = json.loads(msg["tool_calls"])
1804+
except (json.JSONDecodeError, TypeError):
1805+
logger.warning(
1806+
"Failed to deserialize tool_calls in get_anchored_view, falling back to []"
1807+
)
1808+
msg["tool_calls"] = []
1809+
return msg
1810+
1811+
return {
1812+
"window": filtered_window,
1813+
"messages_before": primitive["messages_before"],
1814+
"messages_after": primitive["messages_after"],
1815+
"bookend_start": [_hydrate(r) for r in bookend_start_rows],
1816+
"bookend_end": [_hydrate(r) for r in bookend_end_rows],
1817+
}
1818+
16211819
def resolve_resume_session_id(self, session_id: str) -> str:
16221820
"""Redirect a resume target to the descendant session that holds the messages.
16231821
@@ -1885,6 +2083,7 @@ def search_messages(
18852083
role_filter: List[str] = None,
18862084
limit: int = 20,
18872085
offset: int = 0,
2086+
sort: str = None,
18882087
) -> List[Dict[str, Any]]:
18892088
"""
18902089
Full-text search across session messages using FTS5.
@@ -1897,6 +2096,15 @@ def search_messages(
18972096
18982097
Returns matching messages with session metadata, content snippet,
18992098
and surrounding context (1 message before and after the match).
2099+
2100+
``sort`` controls temporal ordering:
2101+
- ``None`` (default): FTS5 BM25 relevance only. Time-neutral.
2102+
- ``"newest"``: order by message timestamp DESC, then by rank.
2103+
- ``"oldest"``: order by message timestamp ASC, then by rank.
2104+
2105+
The short-CJK LIKE fallback already orders by timestamp DESC and
2106+
ignores ``sort``. The trigram CJK path honours ``sort`` like the main
2107+
FTS5 path.
19002108
"""
19012109
if not query or not query.strip():
19022110
return []
@@ -1905,6 +2113,25 @@ def search_messages(
19052113
if not query:
19062114
return []
19072115

2116+
# Normalise sort. Anything not in the allowed set falls back to None
2117+
# (FTS5 rank-only) so callers can pass through user input without
2118+
# validation.
2119+
if isinstance(sort, str):
2120+
sort_norm = sort.strip().lower()
2121+
if sort_norm not in ("newest", "oldest"):
2122+
sort_norm = None
2123+
else:
2124+
sort_norm = None
2125+
2126+
# ORDER BY shared across the main FTS5 path and trigram CJK path.
2127+
# With sort set, timestamp is primary and rank is the tiebreaker.
2128+
if sort_norm == "newest":
2129+
order_by_sql = "ORDER BY m.timestamp DESC, rank"
2130+
elif sort_norm == "oldest":
2131+
order_by_sql = "ORDER BY m.timestamp ASC, rank"
2132+
else:
2133+
order_by_sql = "ORDER BY rank"
2134+
19082135
# Build WHERE clauses dynamically
19092136
where_clauses = ["messages_fts MATCH ?"]
19102137
params: list = [query]
@@ -1943,7 +2170,7 @@ def search_messages(
19432170
JOIN messages m ON m.id = messages_fts.rowid
19442171
JOIN sessions s ON s.id = m.session_id
19452172
WHERE {where_sql}
1946-
ORDER BY rank
2173+
{order_by_sql}
19472174
LIMIT ? OFFSET ?
19482175
"""
19492176

@@ -2012,7 +2239,7 @@ def search_messages(
20122239
JOIN messages m ON m.id = messages_fts_trigram.rowid
20132240
JOIN sessions s ON s.id = m.session_id
20142241
WHERE {' AND '.join(tri_where)}
2015-
ORDER BY rank
2242+
{order_by_sql}
20162243
LIMIT ? OFFSET ?
20172244
"""
20182245
tri_params.extend([limit, offset])

scripts/release.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,6 +1058,7 @@
10581058
"[email protected]": "29206394", # PR #22194 salvage (sudo -S brute-force guard, #9590)
10591059
"[email protected]": "fr33d3m0n", # PR #21128 salvage (sudo stdin/askpass DANGEROUS, #17873 cat 4)
10601060
"[email protected]": "VinceZcrikl", # PR #23647 salvage (npm UTF-8 decode on GBK Windows)
1061+
"[email protected]": "JabberELF", # PR #20238 seed (session_search dual-mode, evolved into single-shape)
10611062
"[email protected]": "ZeterMordio", # PR #11754 salvage (zsh completion compdef + _arguments syntax)
10621063
"[email protected]": "iuyup", # PR #6155 salvage (shell=True hardening)
10631064
"[email protected]": "1RB", # PR #25462 salvage (discord forwarded messages)

0 commit comments

Comments
 (0)