mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
feat(session-search): give the agent a link to hand back
Asked to link to a session, the agent had no way to know the @session reference syntax exists — every mention in the tool schema described consuming a link the user dropped, never writing one — so it answered with the title and timestamp as prose and the desktop had nothing to render. Every result now carries a ready-to-copy `link`, and the schema says to write it inline instead of restating the title around it. The profile segment is omitted when the active profile can't be named confidently; a bare id still resolves. Also skip linkifying a ref a model already wrapped in a markdown link, which would otherwise rewrite into a nested link.
This commit is contained in:
parent
92439be351
commit
99ab036291
4 changed files with 115 additions and 7 deletions
|
|
@ -90,6 +90,12 @@ describe('linkifySessionRefs', () => {
|
|||
expect(linkifySessionRefs('me@session:abc')).toBe('me@session:abc')
|
||||
expect(linkifySessionRefs('a/@session:abc')).toBe('a/@session:abc')
|
||||
})
|
||||
|
||||
it('leaves a ref a model already wrapped in a markdown link alone', () => {
|
||||
const text = '[that chat](@session:work/abc123)'
|
||||
|
||||
expect(linkifySessionRefs(text)).toBe(text)
|
||||
})
|
||||
})
|
||||
|
||||
// The agent-authored path: assistant markdown runs through preprocessMarkdown
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@
|
|||
|
||||
/** Mirrors the composer/transcript form in `directive-text.tsx`: a bare value,
|
||||
* or one fenced in backticks/quotes so a value with spaces survives. The
|
||||
* lookbehind keeps `foo@session:` and URL paths from matching. */
|
||||
export const SESSION_REF_RE = /(?<![\w/])@session:(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
|
||||
* lookbehinds keep `foo@session:` and URL paths from matching, and skip a ref
|
||||
* a model already wrapped in a markdown link — rewriting that would nest. */
|
||||
export const SESSION_REF_RE = /(?<![\w/])(?<!]\()@session:(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
|
||||
|
||||
const TRAILING_PUNCTUATION_RE = /[,.;:!?)\]}]+$/
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from tools.session_search_tool import (
|
|||
_is_compacted_message,
|
||||
_is_compression_ended,
|
||||
_resolve_to_parent,
|
||||
_session_link,
|
||||
session_search,
|
||||
)
|
||||
|
||||
|
|
@ -492,6 +493,69 @@ class TestReadShape:
|
|||
assert len(result["messages"]) == 30 # head 20 + tail 10
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Session links — the value the agent writes to point the user at a session
|
||||
# =========================================================================
|
||||
|
||||
def _linked_session_id(link: str) -> str:
|
||||
"""Recover the session id from an `@session:[<profile>/]<id>` value."""
|
||||
assert link.startswith("@session:"), link
|
||||
value = link[len("@session:"):]
|
||||
|
||||
return value.rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
class TestSessionLink:
|
||||
def test_link_carries_the_named_profile(self):
|
||||
assert _session_link("s_oldest", "work") == "@session:work/s_oldest"
|
||||
|
||||
def test_link_falls_back_to_a_bare_id_when_the_profile_is_unknown(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name",
|
||||
lambda: "custom",
|
||||
)
|
||||
|
||||
assert _session_link("s_oldest") == "@session:s_oldest"
|
||||
|
||||
def test_link_survives_a_failure_to_resolve_the_profile(self, monkeypatch):
|
||||
def boom():
|
||||
raise RuntimeError("no profile")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.profiles.get_active_profile_name", boom)
|
||||
|
||||
assert _session_link("s_oldest") == "@session:s_oldest"
|
||||
|
||||
def test_read_result_links_to_the_session_it_read(self, db):
|
||||
_seed_modpack_sessions(db)
|
||||
result = json.loads(session_search(session_id="s_oldest", db=db))
|
||||
|
||||
assert _linked_session_id(result["link"]) == result["session_id"]
|
||||
|
||||
def test_every_discovery_result_links_to_its_own_session(self, db):
|
||||
_seed_modpack_sessions(db)
|
||||
result = json.loads(session_search(query="modpack", limit=5, db=db))
|
||||
|
||||
assert result["results"]
|
||||
for entry in result["results"]:
|
||||
assert _linked_session_id(entry["link"]) == entry["session_id"]
|
||||
|
||||
def test_every_browse_result_links_to_its_own_session(self, db):
|
||||
_seed_modpack_sessions(db)
|
||||
result = json.loads(session_search(db=db))
|
||||
|
||||
assert result["results"]
|
||||
for entry in result["results"]:
|
||||
assert _linked_session_id(entry["link"]) == entry["session_id"]
|
||||
|
||||
def test_link_splits_the_way_the_tool_parses_it_back(self):
|
||||
"""The agent hands its own link back as session_id; the value has to
|
||||
survive the profile/id partition in session_search."""
|
||||
value = _session_link("s_middle", "work")[len("@session:"):]
|
||||
profile, _, session_id = value.partition("/")
|
||||
|
||||
assert (profile, session_id) == ("work", "s_middle")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Cross-profile read — `profile` swaps in another profile's DB (read-only)
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -291,6 +291,28 @@ def _resolve_profile_db(profile: str):
|
|||
return SessionDB(db_path=profiles_mod.get_profile_dir(canon) / "state.db", read_only=True)
|
||||
|
||||
|
||||
def _session_link(session_id: str, profile: str = None) -> str:
|
||||
"""The reference the agent writes to point the user at a session.
|
||||
|
||||
Same value the desktop composer emits when a session is dragged into a
|
||||
message, so the desktop renders it as a link carrying the session's title.
|
||||
The profile segment is omitted when we can't name it confidently — a bare
|
||||
id still resolves, it just can't disambiguate across profiles.
|
||||
"""
|
||||
name = (profile or "").strip()
|
||||
if not name:
|
||||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
|
||||
resolved = get_active_profile_name()
|
||||
name = "" if resolved == "custom" else resolved
|
||||
except Exception:
|
||||
logging.debug("get_active_profile_name failed for session link", exc_info=True)
|
||||
name = ""
|
||||
|
||||
return f"@session:{name}/{session_id}" if name else f"@session:{session_id}"
|
||||
|
||||
|
||||
def _locate_session_db(session_id: str):
|
||||
"""Scan every profile's ``state.db`` (read-only) for a session id.
|
||||
|
||||
|
|
@ -335,7 +357,7 @@ def _locate_session_db(session_id: str):
|
|||
return None, None
|
||||
|
||||
|
||||
def _read_session(db, session_id: str, head: int = 20, tail: int = 10) -> str:
|
||||
def _read_session(db, session_id: str, head: int = 20, tail: int = 10, link_profile: str = None) -> str:
|
||||
"""Read shape: dump a whole session by id (head + tail when large).
|
||||
|
||||
Serves the linked-session case — the user dropped an @session reference and
|
||||
|
|
@ -366,6 +388,7 @@ def _read_session(db, session_id: str, head: int = 20, tail: int = 10) -> str:
|
|||
"success": True,
|
||||
"mode": "read",
|
||||
"session_id": session_id,
|
||||
"link": _session_link(session_id, link_profile),
|
||||
"session_meta": {
|
||||
"when": _format_timestamp(meta.get("started_at")),
|
||||
"source": meta.get("source"),
|
||||
|
|
@ -384,7 +407,7 @@ def _read_session(db, session_id: str, head: int = 20, tail: int = 10) -> str:
|
|||
return json.dumps(response, ensure_ascii=False)
|
||||
|
||||
|
||||
def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str:
|
||||
def _list_recent_sessions(db, limit: int, current_session_id: str = None, link_profile: str = None) -> str:
|
||||
"""Return metadata for the most recent sessions (no LLM calls, no FTS5)."""
|
||||
try:
|
||||
sessions = db.list_sessions_rich(
|
||||
|
|
@ -405,6 +428,7 @@ def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str
|
|||
continue
|
||||
results.append({
|
||||
"session_id": sid,
|
||||
"link": _session_link(sid, link_profile),
|
||||
"title": s.get("title") or None,
|
||||
"source": s.get("source", ""),
|
||||
"started_at": s.get("started_at", ""),
|
||||
|
|
@ -630,6 +654,7 @@ def _discover(
|
|||
limit: int,
|
||||
sort: Optional[str],
|
||||
current_session_id: str = None,
|
||||
link_profile: str = None,
|
||||
) -> str:
|
||||
"""Discovery shape: FTS5 + anchored window + bookends per hit. Single call."""
|
||||
role_list = role_filter if role_filter else ["user", "assistant"]
|
||||
|
|
@ -764,6 +789,9 @@ def _discover(
|
|||
entry["parent_session_id"] = lineage_root
|
||||
results.append(entry)
|
||||
|
||||
for entry in results:
|
||||
entry["link"] = _session_link(entry["session_id"], link_profile)
|
||||
|
||||
_final_payload = {
|
||||
"success": True,
|
||||
"mode": "discover",
|
||||
|
|
@ -848,7 +876,7 @@ def session_search(
|
|||
# Read shape: a session_id with no anchor → dump the whole session.
|
||||
if isinstance(session_id, str) and session_id.strip():
|
||||
sid = session_id.strip()
|
||||
result = _read_session(db, sid)
|
||||
result = _read_session(db, sid, link_profile=profile)
|
||||
if json.loads(result).get("success"):
|
||||
return result
|
||||
|
||||
|
|
@ -858,7 +886,7 @@ def session_search(
|
|||
located, owner = _locate_session_db(sid)
|
||||
if located is not None:
|
||||
try:
|
||||
found = json.loads(_read_session(located, sid))
|
||||
found = json.loads(_read_session(located, sid, link_profile=owner))
|
||||
finally:
|
||||
located.close()
|
||||
if found.get("success"):
|
||||
|
|
@ -876,7 +904,7 @@ def session_search(
|
|||
|
||||
# Browse shape: no query → recent sessions.
|
||||
if not query or not isinstance(query, str) or not query.strip():
|
||||
return _list_recent_sessions(db, limit, current_session_id)
|
||||
return _list_recent_sessions(db, limit, current_session_id, link_profile=profile)
|
||||
|
||||
# Parse role_filter
|
||||
role_list: Optional[List[str]] = None
|
||||
|
|
@ -897,6 +925,7 @@ def session_search(
|
|||
limit=limit,
|
||||
sort=sort_norm,
|
||||
current_session_id=current_session_id,
|
||||
link_profile=profile,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -962,6 +991,14 @@ SESSION_SEARCH_SCHEMA = {
|
|||
" session_search()\n"
|
||||
" Returns recent sessions chronologically: titles, previews, timestamps. "
|
||||
"Use when the user asks \"what was I working on\" without naming a topic.\n\n"
|
||||
"LINKING THE USER TO A SESSION\n\n"
|
||||
" When you refer the user to a session, write its `link` value inline in "
|
||||
"your reply — every result carries one, e.g. "
|
||||
"`@session:default/20260722_204335_d62c16`. Copy it verbatim; do not "
|
||||
"reformat it as a markdown link or wrap it in backticks. Hermes renders "
|
||||
"it as the session's title, so there is no need to also spell out the "
|
||||
"title, date, or id around it — write \"picked up in @session:... \" "
|
||||
"rather than restating the metadata.\n\n"
|
||||
"FTS5 SYNTAX\n\n"
|
||||
" AND is the default — multi-word queries require all terms. Use OR explicitly "
|
||||
"for broader recall (`alpha OR beta OR gamma`), quoted phrases for exact match "
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue