mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
parent
d05cd7c1ef
commit
9bf5822a2f
4 changed files with 203 additions and 14 deletions
|
|
@ -138,6 +138,53 @@ def generate_title(
|
|||
return None
|
||||
|
||||
|
||||
def _persist_session_title(session_db, session_id, title):
|
||||
"""Persist a generated title, recovering from duplicate-title collisions.
|
||||
|
||||
The write goes through ``set_auto_title_if_empty`` (predicate + write in
|
||||
one transaction) so a manual ``/title`` set while LLM generation was in
|
||||
flight is never overwritten — a plain ``set_session_title`` fallback keeps
|
||||
older stores working. ``set_session_title`` raises ValueError when the
|
||||
title would collide with another session (the unique-title index). Rather
|
||||
than swallow it and leave the session untitled (#50537), append a #N
|
||||
suffix via get_next_title_in_lineage() when the store supports lineage
|
||||
dedup; otherwise re-raise so the caller can decide.
|
||||
|
||||
Returns the title actually persisted, or None when a concurrent manual
|
||||
title won the race (nothing was written).
|
||||
"""
|
||||
atomic_fn = getattr(session_db, "set_auto_title_if_empty", None)
|
||||
|
||||
def _set(t):
|
||||
if atomic_fn is not None:
|
||||
if not atomic_fn(session_id, t):
|
||||
# Predicate failed: a title appeared while generation was in
|
||||
# flight (manual /title wins), or the session vanished.
|
||||
logger.debug(
|
||||
"Skipping auto-generated session title because a title "
|
||||
"was set while generation was in flight"
|
||||
)
|
||||
return None
|
||||
return t
|
||||
ok = session_db.set_session_title(session_id, t)
|
||||
if ok is False:
|
||||
raise RuntimeError(
|
||||
f"session {session_id} not found when storing title"
|
||||
)
|
||||
return t
|
||||
|
||||
try:
|
||||
return _set(title)
|
||||
except ValueError:
|
||||
next_title_fn = getattr(session_db, "get_next_title_in_lineage", None)
|
||||
if next_title_fn is None:
|
||||
raise
|
||||
deduped = next_title_fn(title)
|
||||
if not deduped or deduped == title:
|
||||
raise
|
||||
return _set(deduped)
|
||||
|
||||
|
||||
def auto_title_session(
|
||||
session_db,
|
||||
session_id: str,
|
||||
|
|
@ -237,15 +284,13 @@ def _auto_title_session(
|
|||
return
|
||||
|
||||
try:
|
||||
if not session_db.set_auto_title_if_empty(session_id, title):
|
||||
logger.debug(
|
||||
"Skipping auto-generated session title because a title was set while generation was in flight"
|
||||
)
|
||||
persisted = _persist_session_title(session_db, session_id, title)
|
||||
if persisted is None:
|
||||
return
|
||||
logger.debug("Auto-generated session title: %s", title)
|
||||
logger.debug("Auto-generated session title: %s", persisted)
|
||||
if title_callback is not None:
|
||||
try:
|
||||
title_callback(title)
|
||||
title_callback(persisted)
|
||||
except Exception:
|
||||
logger.debug("Auto-title callback failed", exc_info=True)
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -48,6 +48,45 @@ from hermes_time import now as _hermes_now
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _set_cron_session_title(session_db, session_id, base_title):
|
||||
"""Robustly title a finished cron session before it is closed.
|
||||
|
||||
Centralizes the title write so the cron finally block can guarantee a
|
||||
non-blank, unique title is persisted before end_session()/close() tear
|
||||
the connection down (issues #50535, #50536, #50537):
|
||||
|
||||
- #50535: never leaves the session blank. base_title already carries a
|
||||
cron-id fallback for nameless jobs; this also guards a failed write.
|
||||
- #50537: a duplicate title makes set_session_title raise ValueError (the
|
||||
unique-title index). Recover by appending a #N suffix via
|
||||
get_next_title_in_lineage() when supported, instead of swallowing the
|
||||
error and ending up untitled. If lineage dedup is unavailable, raise.
|
||||
- #50536: this runs synchronously in the cron finally block ahead of the
|
||||
session close, so no in-flight title write can race the close.
|
||||
|
||||
Returns the title actually persisted, or None if nothing could be set.
|
||||
"""
|
||||
if not session_db or not session_id:
|
||||
return None
|
||||
title = (base_title or "").strip()
|
||||
if not title:
|
||||
return None
|
||||
try:
|
||||
session_db.set_session_title(session_id, title)
|
||||
return title
|
||||
except ValueError:
|
||||
# Title collision against the unique-title index. Fall back to the
|
||||
# next title in the lineage (base #2, base #3, ...) when supported.
|
||||
next_title_fn = getattr(session_db, "get_next_title_in_lineage", None)
|
||||
if next_title_fn is None:
|
||||
raise
|
||||
deduped = next_title_fn(title)
|
||||
if not deduped or deduped == title:
|
||||
raise
|
||||
session_db.set_session_title(session_id, deduped)
|
||||
return deduped
|
||||
|
||||
|
||||
def _summarize_cron_failure_for_delivery(job: dict, error: str | None) -> str:
|
||||
"""Return a compact one-line failure message for chat delivery.
|
||||
|
||||
|
|
@ -3500,18 +3539,39 @@ def run_job(
|
|||
for _var_name in _cron_delivery_vars:
|
||||
_VAR_MAP[_var_name].set("")
|
||||
if _session_db:
|
||||
# Title the cron session from the job (name → short prompt → id) so
|
||||
# sidebars/history show a meaningful label instead of the injected
|
||||
# "[IMPORTANT: …]" hint that is the session's first message. Set here
|
||||
# (not at create time) so the agent's own INSERT keeps model /
|
||||
# system_prompt; this only UPDATEs the title column. The run-time
|
||||
# suffix keeps it unique against the sessions.title index across runs.
|
||||
# Title the cron session from the job (name -> id) and PERSIST it
|
||||
# BEFORE end_session()/close() tear the connection down, so the
|
||||
# close can never run over an in-flight title write (#50536). The
|
||||
# run-time suffix keeps it unique against the sessions.title index
|
||||
# across runs; _set_cron_session_title dedupes (#50537) and the
|
||||
# except-fallback below guarantees a non-blank title (#50535).
|
||||
try:
|
||||
_title_base = " ".join(job_name.split())[:60].strip() or f"cron {job_id}"
|
||||
_cron_title = f"{_title_base} · {_hermes_now().strftime('%b %d %H:%M')}"
|
||||
_session_db.set_session_title(_cron_session_id, _cron_title)
|
||||
if not _set_cron_session_title(_session_db, _cron_session_id, _cron_title):
|
||||
# Helper returned None (blank base) -> use the id fallback.
|
||||
_set_cron_session_title(
|
||||
_session_db, _cron_session_id, f"cron {job_id}"
|
||||
)
|
||||
except (Exception, KeyboardInterrupt) as e:
|
||||
logger.debug("Job '%s': failed to set cron session title: %s", job_id, e)
|
||||
logger.debug(
|
||||
"Job '%s': failed to set cron session title: %s", job_id, e
|
||||
)
|
||||
# Last-resort: never leave the session blank (#50535). Try the
|
||||
# next free title in the lineage, then a bare id-stamped title.
|
||||
for _fallback in (
|
||||
getattr(_session_db, "get_next_title_in_lineage", lambda b: b)(
|
||||
f"cron {job_id}"
|
||||
),
|
||||
f"cron {job_id} {_cron_session_id[-6:]}",
|
||||
):
|
||||
try:
|
||||
if _set_cron_session_title(
|
||||
_session_db, _cron_session_id, _fallback
|
||||
):
|
||||
break
|
||||
except (Exception, KeyboardInterrupt):
|
||||
continue
|
||||
try:
|
||||
_session_db.end_session(_cron_session_id, "cron_complete")
|
||||
except (Exception, KeyboardInterrupt) as e:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Tests for agent.title_generator — auto-generated session titles."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
|
|
@ -437,3 +438,46 @@ class TestMaybeAutoTitle:
|
|||
|
||||
def test_skips_if_no_session_db(self):
|
||||
maybe_auto_title(None, "sess-1", "hello", "response", []) # no db
|
||||
|
||||
|
||||
class TestAutoTitleDuplicateHandling:
|
||||
"""Duplicate auto-title handling and not-found hardening (#50537)."""
|
||||
|
||||
def test_dedupes_duplicate_title_via_lineage(self):
|
||||
db = MagicMock()
|
||||
db.get_session_title.return_value = None
|
||||
db.set_session_title.side_effect = [ValueError("in use"), True]
|
||||
db.get_next_title_in_lineage.return_value = "Debugging Import Error #2"
|
||||
with patch(
|
||||
"agent.title_generator.generate_title",
|
||||
return_value="Debugging Import Error",
|
||||
):
|
||||
seen = []
|
||||
auto_title_session(db, "sess-1", "hi", "hello", title_callback=seen.append)
|
||||
db.get_next_title_in_lineage.assert_called_once_with("Debugging Import Error")
|
||||
assert db.set_session_title.call_args_list[-1][0] == (
|
||||
"sess-1",
|
||||
"Debugging Import Error #2",
|
||||
)
|
||||
# callback fires with the actually-persisted (deduped) title
|
||||
assert seen == ["Debugging Import Error #2"]
|
||||
|
||||
def test_swallows_value_error_without_lineage_support(self):
|
||||
# No get_next_title_in_lineage -> ValueError propagates out of the
|
||||
# persist helper but auto_title_session still swallows it (no crash).
|
||||
db = MagicMock(spec=["get_session_title", "set_session_title"])
|
||||
db.get_session_title.return_value = None
|
||||
db.set_session_title.side_effect = ValueError("in use")
|
||||
with patch(
|
||||
"agent.title_generator.generate_title", return_value="Dup Title"
|
||||
):
|
||||
auto_title_session(db, "sess-1", "hi", "hello") # must not raise
|
||||
|
||||
def test_not_found_raises_runtime_error_internally(self):
|
||||
# set_session_title returning False (session vanished) -> RuntimeError
|
||||
# in the persist helper, swallowed by auto_title_session, no callback.
|
||||
from agent.title_generator import _persist_session_title
|
||||
db = MagicMock()
|
||||
db.set_session_title.return_value = False
|
||||
with pytest.raises(RuntimeError):
|
||||
_persist_session_title(db, "missing", "Some Title")
|
||||
|
|
|
|||
|
|
@ -4942,3 +4942,43 @@ class TestMultiTargetDeliveryContinuesOnFailure:
|
|||
assert "a@example.com" in result
|
||||
assert "b@example.com" in result
|
||||
assert mock_pool.submit.call_count == 2
|
||||
|
||||
|
||||
class TestSetCronSessionTitle:
|
||||
"""Robust cron session titling: #50535/#50536/#50537."""
|
||||
|
||||
def test_sets_title_when_no_collision(self):
|
||||
from cron.scheduler import _set_cron_session_title
|
||||
db = MagicMock()
|
||||
db.set_session_title.return_value = True
|
||||
out = _set_cron_session_title(db, "sess-1", "Nightly Synthesis")
|
||||
assert out == "Nightly Synthesis"
|
||||
db.set_session_title.assert_called_once_with("sess-1", "Nightly Synthesis")
|
||||
|
||||
def test_dedupes_on_duplicate_title(self):
|
||||
# First write collides (ValueError); helper falls back to lineage #N.
|
||||
from cron.scheduler import _set_cron_session_title
|
||||
db = MagicMock()
|
||||
db.set_session_title.side_effect = [ValueError("in use"), True]
|
||||
db.get_next_title_in_lineage.return_value = "Nightly Synthesis #2"
|
||||
out = _set_cron_session_title(db, "sess-1", "Nightly Synthesis")
|
||||
assert out == "Nightly Synthesis #2"
|
||||
db.get_next_title_in_lineage.assert_called_once_with("Nightly Synthesis")
|
||||
|
||||
def test_reraises_when_no_lineage_support(self):
|
||||
from cron.scheduler import _set_cron_session_title
|
||||
db = MagicMock(spec=["set_session_title"])
|
||||
db.set_session_title.side_effect = ValueError("in use")
|
||||
with pytest.raises(ValueError):
|
||||
_set_cron_session_title(db, "sess-1", "Dup")
|
||||
|
||||
def test_returns_none_for_blank_base(self):
|
||||
from cron.scheduler import _set_cron_session_title
|
||||
db = MagicMock()
|
||||
assert _set_cron_session_title(db, "sess-1", " ") is None
|
||||
db.set_session_title.assert_not_called()
|
||||
|
||||
def test_returns_none_without_db_or_session(self):
|
||||
from cron.scheduler import _set_cron_session_title
|
||||
assert _set_cron_session_title(None, "sess-1", "X") is None
|
||||
assert _set_cron_session_title(MagicMock(), "", "X") is None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue