fix(goals): forward standing /goal state on auto-compression session rotation (#23530)

When run_agent's _compress_context fires mid-turn it ends the parent
session in SessionDB and creates a new continuation session with a
fresh session_id. The /goal state is keyed on session_id in
state_meta ("goal:<sid>"), so without forwarding the goal silently
disappears: _get_goal_manager() rebinds for the new session_id,
load_goal() returns None, mgr.is_active() is False, and the
continuation loop dies with no user-visible signal.

Fix: in the same SessionDB transaction block that creates the
continuation session, copy state_meta[goal:<old>] →
state_meta[goal:<new>] when present. No-op when the user has no
active goal. Logged at INFO so a stuck loop is debuggable.

Tests cover the round-trip via SessionDB and the no-op path.

Affects all three run-conversation surfaces (CLI, gateway, TUI
gateway) because _compress_context is the single rotation site.
This commit is contained in:
Teknium 2026-05-10 20:41:53 -07:00 committed by GitHub
parent 68d081f570
commit 4a080b1d5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 75 additions and 0 deletions

View file

@ -10080,6 +10080,25 @@ class AIAgent:
parent_session_id=old_session_id,
)
self._session_db_created = True
# Forward any standing /goal state from the parent session to
# the continuation session so the goal loop survives
# auto-compression. Without this rebind, _get_goal_manager()
# constructs a fresh manager keyed on the new session_id,
# load_goal() returns None, mgr.is_active() is False, and
# the loop silently dies mid-task. The goal is stored in
# state_meta under "goal:<sid>" by hermes_cli.goals.
try:
_goal_meta_key_old = f"goal:{old_session_id}"
_goal_meta_key_new = f"goal:{self.session_id}"
_goal_blob = self._session_db.get_meta(_goal_meta_key_old)
if _goal_blob:
self._session_db.set_meta(_goal_meta_key_new, _goal_blob)
logger.info(
"goal: forwarded standing goal from %s%s on compression",
old_session_id, self.session_id,
)
except Exception as exc:
logger.debug("goal forward on compression failed: %s", exc)
# Auto-number the title for the continuation session
if old_title:
try:

View file

@ -1071,3 +1071,59 @@ class TestJudgeIndexConversion:
# Other items still pending.
assert mgr.state.checklist[1].status == ITEM_PENDING
assert mgr.state.checklist[2].status == ITEM_PENDING
# ──────────────────────────────────────────────────────────────────────
# Compression session-rotation: goal must follow the new session_id
# ──────────────────────────────────────────────────────────────────────
class TestGoalSurvivesCompressionRotation:
def test_load_goal_after_session_id_rotates(self, hermes_home):
"""When auto-compression rotates the session_id, the goal must be
readable from the new session_id (forwarded by run_agent's
_compress_context block).
We don't run the full _compress_context method here — it has
~60 dependencies. Instead we mirror exactly what that block does
with state_meta and assert the goal manager picks it up.
"""
from hermes_cli.goals import GoalManager
from hermes_state import SessionDB
# Create a goal under a parent session_id.
parent_sid = "parent-rotate-001"
mgr = GoalManager(session_id=parent_sid)
mgr.set("survive compression")
assert mgr.is_active()
# Simulate the run_agent._compress_context forwarding block:
# read goal:<old>, write goal:<new> on the same SessionDB instance.
db = SessionDB()
new_sid = "child-rotate-001"
blob = db.get_meta(f"goal:{parent_sid}")
assert blob, "goal must be in state_meta"
db.set_meta(f"goal:{new_sid}", blob)
# New GoalManager for the rotated session_id should load the same goal.
mgr2 = GoalManager(session_id=new_sid)
assert mgr2.is_active()
assert mgr2.state.goal == "survive compression"
# Counters/checklist preserved verbatim.
assert mgr2.state.turns_used == mgr.state.turns_used
assert mgr2.state.checklist == mgr.state.checklist
def test_no_forward_when_no_goal(self, hermes_home):
"""Forwarding is a no-op when the parent session has no goal."""
from hermes_state import SessionDB
from hermes_cli.goals import load_goal
db = SessionDB()
# Parent has no goal at all.
assert db.get_meta("goal:parent-no-goal") is None
blob = db.get_meta("goal:parent-no-goal")
if blob: # parity with production guard
db.set_meta("goal:child-no-goal", blob)
# Child should still have no goal.
assert load_goal("child-no-goal") is None