From 4a080b1d5aa7528a679880c93147bc7fffdd267a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 10 May 2026 20:41:53 -0700 Subject: [PATCH] fix(goals): forward standing /goal state on auto-compression session rotation (#23530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:"), 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:] → state_meta[goal:] 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. --- run_agent.py | 19 ++++++++++++ tests/hermes_cli/test_goals.py | 56 ++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/run_agent.py b/run_agent.py index 96d4d8517fe..1c49ebff0fe 100644 --- a/run_agent.py +++ b/run_agent.py @@ -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:" 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: diff --git a/tests/hermes_cli/test_goals.py b/tests/hermes_cli/test_goals.py index 47a043a32cf..93ae89af0fc 100644 --- a/tests/hermes_cli/test_goals.py +++ b/tests/hermes_cli/test_goals.py @@ -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:, write goal: 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