diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index a620f343e99e..0d908626ca0d 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -404,6 +404,16 @@ def compress_context( except (ValueError, Exception) as e: logger.debug("Could not propagate title on compression: %s", e) agent._session_db.update_system_prompt(agent.session_id, new_system_prompt) + try: + from hermes_cli.goals import migrate_goal_to_session + + migrate_goal_to_session( + old_session_id, + agent.session_id, + reason="compression", + ) + except Exception as goal_err: + logger.debug("GoalManager migration on compression failed: %s", goal_err) # Reset flush cursor — new session starts with no messages written agent._last_flushed_db_idx = 0 except Exception as e: diff --git a/hermes_cli/goals.py b/hermes_cli/goals.py index d6a139419a71..da0e95d13cff 100644 --- a/hermes_cli/goals.py +++ b/hermes_cli/goals.py @@ -8,7 +8,9 @@ user sends a new message (which takes priority and pauses the goal loop). State is persisted in SessionDB's ``state_meta`` table keyed by -``goal:`` so ``/resume`` picks it up. +``goal:`` so ``/resume`` picks it up. Compression rotates the +session id, so unfinished goal state is explicitly migrated to the continuation +session. Design notes / invariants: @@ -279,6 +281,45 @@ def clear_goal(session_id: str) -> None: save_goal(session_id, state) +def migrate_goal_to_session( + old_session_id: str, + new_session_id: str, + *, + reason: str = "session switch", +) -> bool: + """Move an unfinished persistent goal across a logical-session boundary. + + Goal state is persisted under ``goal:``. Context compression + intentionally rotates the SQLite session id while continuing the same user + task, so the standing goal must be rebound to the continuation session. + + Returns True when a goal was copied to ``new_session_id``. Finished or + cleared goals are left untouched and return False. + """ + if not old_session_id or not new_session_id or old_session_id == new_session_id: + return False + + state = load_goal(old_session_id) + if state is None or state.status in {"done", "cleared"}: + return False + + save_goal(new_session_id, state) + + migrated_from = state + migrated_from.status = "cleared" + migrated_from.paused_reason = ( + f"migrated to continuation session {new_session_id} ({reason})" + ) + save_goal(old_session_id, migrated_from) + logger.debug( + "GoalManager: migrated goal from %s to %s (%s)", + old_session_id, + new_session_id, + reason, + ) + return True + + # ────────────────────────────────────────────────────────────────────── # Judge # ────────────────────────────────────────────────────────────────────── @@ -758,5 +799,6 @@ def next_continuation_prompt(self) -> Optional[str]: "load_goal", "save_goal", "clear_goal", + "migrate_goal_to_session", "judge_goal", ] diff --git a/tests/hermes_cli/test_goals.py b/tests/hermes_cli/test_goals.py index 9d8c3f48fe1d..2425ccb1dc70 100644 --- a/tests/hermes_cli/test_goals.py +++ b/tests/hermes_cli/test_goals.py @@ -252,6 +252,41 @@ def test_persistence_across_managers(self, hermes_home): assert mgr2.state.goal == "do the thing" assert mgr2.is_active() + def test_migrate_goal_to_compression_continuation_session(self, hermes_home): + """Compression rotates the SQLite session id; the logical /goal must follow. + + Regression: goals were keyed only as goal:, so after a + /compress or automatic compaction created a continuation session, the + next turn's GoalManager(new_session_id) saw no active goal. + """ + from hermes_cli.goals import GoalManager, migrate_goal_to_session, save_goal + + old_mgr = GoalManager(session_id="goal-before-compress", default_max_turns=7) + old_mgr.set("finish the long-running task") + old_mgr.state.turns_used = 3 + old_mgr.state.subgoals.append("preserve this criterion too") + save_goal(old_mgr.session_id, old_mgr.state) + + migrated = migrate_goal_to_session( + "goal-before-compress", + "goal-after-compress", + reason="compression", + ) + + assert migrated is True + new_mgr = GoalManager(session_id="goal-after-compress") + assert new_mgr.state is not None + assert new_mgr.state.goal == "finish the long-running task" + assert new_mgr.state.status == "active" + assert new_mgr.state.max_turns == 7 + assert new_mgr.state.turns_used == 3 + assert new_mgr.state.subgoals == ["preserve this criterion too"] + + old_mgr_reloaded = GoalManager(session_id="goal-before-compress") + assert not old_mgr_reloaded.is_active() + assert old_mgr_reloaded.state.status == "cleared" + assert "goal-after-compress" in (old_mgr_reloaded.state.paused_reason or "") + def test_evaluate_after_turn_done(self, hermes_home): """Judge says done → status=done, no continuation.""" from hermes_cli import goals