mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
fix(state): inherit and stamp profile_name across rotation and branch children
profile_name was only written on the agent's initial lazy create
(e8b7ce8c1); every parented child row — compression rotation, TUI
/branch, desktop branch first-persist — was created without it. A
non-default profile's lineage therefore turned NULL on its first
compression or branch and aggregated as "default" in unified session
lists, completing the cross-profile session-jump.
Fix the class at the DB layer: _insert_session_row's parent backfill now
COALESCEs profile_name from the parent alongside cwd/git_* (#64709
pattern), so any parented child inherits its lineage's owning profile.
Stamp it explicitly at the three create sites as well — compression
rotation (mirroring _ensure_db_session), TUI session.branch, and the
TUI first-prompt row persist — so rows are self-describing even when the
parent row predates the profile_name column.
This commit is contained in:
parent
40a80487c4
commit
e9a243ef78
6 changed files with 160 additions and 11 deletions
|
|
@ -1908,6 +1908,19 @@ def compress_context(
|
|||
except Exception:
|
||||
pass
|
||||
agent._session_db_created = False
|
||||
# The rotation child must stay on the parent's profile —
|
||||
# mirror _ensure_db_session's stamp ("default" persists as
|
||||
# NULL). _insert_session_row's parent backfill additionally
|
||||
# COALESCEs from the parent row, covering app-global remote
|
||||
# sessions whose thread lacks the HERMES_HOME context.
|
||||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
|
||||
_profile_for_child = get_active_profile_name()
|
||||
if _profile_for_child == "default":
|
||||
_profile_for_child = None
|
||||
except Exception:
|
||||
_profile_for_child = None
|
||||
try:
|
||||
agent._session_db.create_session(
|
||||
session_id=agent.session_id,
|
||||
|
|
@ -1915,6 +1928,7 @@ def compress_context(
|
|||
model=agent.model,
|
||||
model_config=agent._session_init_model_config,
|
||||
parent_session_id=old_session_id,
|
||||
profile_name=_profile_for_child,
|
||||
)
|
||||
except Exception as _cs_err:
|
||||
# The child row could not be created (e.g. FK constraint,
|
||||
|
|
|
|||
|
|
@ -3387,13 +3387,15 @@ class SessionDB:
|
|||
|
||||
When ``parent_session_id`` is set (compression fork, delegate/subagent
|
||||
spawn, branch continuation) and this row's own ``cwd``/``git_repo_root``/
|
||||
``git_branch`` are still NULL after the insert, they are backfilled from
|
||||
the parent row. Callers of ``create_session`` for a child session
|
||||
historically didn't propagate these fields themselves (e.g. the
|
||||
``git_branch``/``profile_name`` are still NULL after the insert, they are
|
||||
backfilled from the parent row. Callers of ``create_session`` for a child
|
||||
session historically didn't propagate these fields themselves (e.g. the
|
||||
compression-fork path), so a lineage could silently lose its working
|
||||
directory and drop out of the project sidebar every time it forked
|
||||
(#64709). This only fills NULLs — an explicit ``cwd``/``git_repo_root``
|
||||
on the child is never overwritten. For compression forks specifically
|
||||
(#64709), or lose its owning profile and be aggregated as "default" every
|
||||
time it rotated or branched (the cross-profile session-jump bug). This
|
||||
only fills NULLs — an explicit value on the child is never overwritten.
|
||||
For compression forks specifically
|
||||
(parent ended with ``end_reason='compression'``), the gateway origin
|
||||
columns (``user_id``/``session_key``/``chat_id``/``chat_type``/
|
||||
``thread_id``/``display_name``/``origin_json``) are inherited too, so a
|
||||
|
|
@ -3449,7 +3451,10 @@ class SessionDB:
|
|||
WHERE p.id = sessions.parent_session_id)),
|
||||
git_branch = COALESCE(sessions.git_branch,
|
||||
(SELECT p.git_branch FROM sessions p
|
||||
WHERE p.id = sessions.parent_session_id))
|
||||
WHERE p.id = sessions.parent_session_id)),
|
||||
profile_name = COALESCE(sessions.profile_name,
|
||||
(SELECT p.profile_name FROM sessions p
|
||||
WHERE p.id = sessions.parent_session_id))
|
||||
WHERE id = ? AND parent_session_id IS NOT NULL""",
|
||||
(session_id,),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -322,6 +322,52 @@ class TestFlushAfterCompression:
|
|||
)
|
||||
db.close()
|
||||
|
||||
def test_rotation_child_session_inherits_parent_profile_name(self):
|
||||
"""The rotation child must stay on the parent's owning profile.
|
||||
|
||||
The rotate path used to create the child row with no profile_name, so
|
||||
a compressed non-default-profile conversation migrated to the launch/
|
||||
default profile in unified session lists (the cross-profile
|
||||
session-jump bug). Exercises the real rotation against a real
|
||||
SessionDB: explicit stamp at the create site + the parent-backfill
|
||||
COALESCE in _insert_session_row.
|
||||
"""
|
||||
from agent.conversation_compression import compress_context
|
||||
from hermes_state import SessionDB
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db = SessionDB(db_path=Path(tmpdir) / "test.db")
|
||||
parent_sid = "20260701_152840_parent"
|
||||
db.create_session(
|
||||
parent_sid, "gateway", model="test/model",
|
||||
profile_name="ai-engineer",
|
||||
)
|
||||
|
||||
agent = self._make_agent(db)
|
||||
agent.session_id = parent_sid
|
||||
agent.compression_in_place = False
|
||||
agent._ensure_db_session()
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "user" if i % 2 == 0 else "assistant",
|
||||
"content": f"message {i}",
|
||||
"_db_persisted": True,
|
||||
}
|
||||
for i in range(12)
|
||||
]
|
||||
|
||||
with patch("agent.context_compressor.call_llm", side_effect=RuntimeError("no provider")):
|
||||
compress_context(
|
||||
agent, messages, approx_tokens=100_000, system_message="sys"
|
||||
)
|
||||
|
||||
assert agent.session_id != parent_sid
|
||||
child = db.get_session(agent.session_id)
|
||||
assert child is not None
|
||||
assert child["profile_name"] == "ai-engineer"
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Part 2: Gateway-side — history_offset after session split
|
||||
|
|
|
|||
|
|
@ -194,6 +194,44 @@ class TestSessionLifecycle:
|
|||
child = db.get_session("child")
|
||||
assert child["git_branch"] == "feature-x"
|
||||
|
||||
def test_child_session_inherits_profile_name_from_parent(self, db):
|
||||
"""A parented child born without profile_name (compression rotation,
|
||||
/branch) must inherit its parent's owning profile — otherwise the
|
||||
lineage silently migrates to the launch/default profile in unified
|
||||
session lists (the cross-profile session-jump bug)."""
|
||||
db.create_session(session_id="parent", source="cli", profile_name="ai-engineer")
|
||||
# Rotation path: parent is ended with 'compression' BEFORE the child
|
||||
# row is created (agent/conversation_compression.py).
|
||||
db.end_session("parent", "compression")
|
||||
|
||||
db.create_session(session_id="child", source="cli", parent_session_id="parent")
|
||||
|
||||
assert db.get_session("child")["profile_name"] == "ai-engineer"
|
||||
|
||||
def test_child_session_explicit_profile_name_is_not_overwritten(self, db):
|
||||
"""Inheritance only fills NULLs — an explicit profile_name on the
|
||||
child is never clobbered by the parent's."""
|
||||
db.create_session(session_id="parent", source="cli", profile_name="ai-engineer")
|
||||
|
||||
db.create_session(
|
||||
session_id="child", source="cli", parent_session_id="parent",
|
||||
profile_name="other",
|
||||
)
|
||||
|
||||
assert db.get_session("child")["profile_name"] == "other"
|
||||
|
||||
def test_multi_generation_lineage_inherits_profile_name(self, db):
|
||||
"""profile_name survives a compress-then-branch chain (root -> rotation
|
||||
child -> branch tip) — the exact lineage that used to land on default."""
|
||||
db.create_session(session_id="root", source="cli", profile_name="ai-engineer")
|
||||
db.end_session("root", "compression")
|
||||
|
||||
db.create_session(session_id="gen1", source="cli", parent_session_id="root")
|
||||
db.create_session(session_id="gen2", source="cli", parent_session_id="gen1")
|
||||
|
||||
assert db.get_session("gen1")["profile_name"] == "ai-engineer"
|
||||
assert db.get_session("gen2")["profile_name"] == "ai-engineer"
|
||||
|
||||
def test_compression_child_inherits_gateway_origin_columns(self, db):
|
||||
"""A compression fork's child inherits gateway routing metadata
|
||||
(session_key/chat_id/...) from the ended parent, so a crash before
|
||||
|
|
|
|||
|
|
@ -3893,7 +3893,7 @@ def test_ensure_session_db_row_persists_explicit_cwd(monkeypatch, tmp_path):
|
|||
created = []
|
||||
|
||||
class _FakeDB:
|
||||
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None):
|
||||
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None):
|
||||
created.append(
|
||||
{"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd}
|
||||
)
|
||||
|
|
@ -3914,7 +3914,7 @@ def test_ensure_session_db_row_persists_session_source(monkeypatch):
|
|||
created = []
|
||||
|
||||
class _FakeDB:
|
||||
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None):
|
||||
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None):
|
||||
created.append(
|
||||
{"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd}
|
||||
)
|
||||
|
|
@ -3935,7 +3935,7 @@ def test_ensure_session_db_row_defaults_to_no_workspace(monkeypatch, tmp_path):
|
|||
created = []
|
||||
|
||||
class _FakeDB:
|
||||
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None):
|
||||
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None):
|
||||
created.append(
|
||||
{"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd}
|
||||
)
|
||||
|
|
@ -3964,7 +3964,7 @@ def test_ensure_session_db_row_persists_session_model_override(monkeypatch):
|
|||
created = []
|
||||
|
||||
class _FakeDB:
|
||||
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None):
|
||||
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None):
|
||||
created.append(
|
||||
{"key": key, "model": model, "model_config": model_config, "cwd": cwd}
|
||||
)
|
||||
|
|
@ -3996,7 +3996,7 @@ def test_ensure_session_db_row_no_override_uses_global(monkeypatch):
|
|||
created = []
|
||||
|
||||
class _FakeDB:
|
||||
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None):
|
||||
def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None):
|
||||
created.append({"model": model, "model_config": model_config})
|
||||
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
|
||||
|
|
@ -4007,6 +4007,36 @@ def test_ensure_session_db_row_no_override_uses_global(monkeypatch):
|
|||
assert created == [{"model": "global/default", "model_config": None}]
|
||||
|
||||
|
||||
def test_ensure_session_db_row_stamps_profile_name(monkeypatch, tmp_path):
|
||||
"""A profile session's row carries its owning profile_name, so unified
|
||||
multi-profile aggregation never has to guess from which state.db file the
|
||||
row happened to be read (the cross-profile session-jump bug)."""
|
||||
profile_home = tmp_path / "profiles" / "mlperf"
|
||||
profile_home.mkdir(parents=True)
|
||||
created = []
|
||||
|
||||
class _ProfileDB:
|
||||
def __init__(self, db_path=None):
|
||||
created.append({"db_path": db_path})
|
||||
|
||||
def create_session(self, key, **kwargs):
|
||||
created[-1].update({"key": key, "profile_name": kwargs.get("profile_name")})
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("hermes_state.SessionDB", _ProfileDB)
|
||||
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
|
||||
|
||||
server._ensure_session_db_row(
|
||||
{"session_key": "k1", "profile_home": str(profile_home)}
|
||||
)
|
||||
|
||||
assert created and created[0]["key"] == "k1"
|
||||
assert created[0]["profile_name"] == "mlperf"
|
||||
assert created[0]["db_path"] == profile_home / "state.db"
|
||||
|
||||
|
||||
def test_session_title_clears_pending_after_persist(monkeypatch):
|
||||
class _FakeDB:
|
||||
def __init__(self):
|
||||
|
|
@ -9081,6 +9111,7 @@ def test_session_branch_writes_to_parent_profile_db(monkeypatch, tmp_path):
|
|||
def create_session(self, new_key, **kwargs):
|
||||
seen["created"] = new_key
|
||||
seen["parent"] = kwargs.get("parent_session_id")
|
||||
seen["profile_name"] = kwargs.get("profile_name")
|
||||
|
||||
def append_message(self, **kwargs):
|
||||
seen["msgs"].append(kwargs)
|
||||
|
|
@ -9142,6 +9173,9 @@ def test_session_branch_writes_to_parent_profile_db(monkeypatch, tmp_path):
|
|||
assert "result" in resp, resp
|
||||
assert seen.get("created")
|
||||
assert seen.get("parent") == "parent-key"
|
||||
# The branch row is self-describing: stamped with the parent's owning
|
||||
# profile, not left NULL for aggregators to mis-tag as "default".
|
||||
assert seen.get("profile_name") == "mlperf"
|
||||
assert seen.get("title") == (seen["created"], "forked")
|
||||
assert len(seen["msgs"]) == 1
|
||||
assert seen.get("launch") is None
|
||||
|
|
|
|||
|
|
@ -2144,6 +2144,10 @@ def _ensure_session_db_row(session: dict) -> None:
|
|||
model_config=model_config or None,
|
||||
parent_session_id=parent_session_id,
|
||||
cwd=_session_cwd(session) if session.get("explicit_cwd") else None,
|
||||
# Self-describing rows: aggregators that merge multiple profile DBs
|
||||
# into one list can't rely on which file a row came from alone. NULL
|
||||
# means the launch/default profile (matches run_agent's convention).
|
||||
profile_name=Path(profile_home).name if profile_home else None,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("failed to persist desktop session row", exc_info=True)
|
||||
|
|
@ -9706,6 +9710,14 @@ def _(rid, params: dict) -> dict:
|
|||
model_config={"_branched_from": old_key},
|
||||
parent_session_id=old_key,
|
||||
cwd=_session_cwd(session),
|
||||
# The branch stays on its parent's profile. Explicit stamp (not
|
||||
# just the parent-backfill) so it holds even when the parent row
|
||||
# predates the profile_name column.
|
||||
profile_name=(
|
||||
Path(session["profile_home"]).name
|
||||
if session.get("profile_home")
|
||||
else None
|
||||
),
|
||||
)
|
||||
for msg in history:
|
||||
db.append_message(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue