Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions agent/conversation_compression.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
44 changes: 43 additions & 1 deletion hermes_cli/goals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:<session_id>`` so ``/resume`` picks it up.
``goal:<session_id>`` so ``/resume`` picks it up. Compression rotates the
session id, so unfinished goal state is explicitly migrated to the continuation
session.

Design notes / invariants:

Expand Down Expand Up @@ -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:<session_id>``. 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
# ──────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -758,5 +799,6 @@ def next_continuation_prompt(self) -> Optional[str]:
"load_goal",
"save_goal",
"clear_goal",
"migrate_goal_to_session",
"judge_goal",
]
35 changes: 35 additions & 0 deletions tests/hermes_cli/test_goals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:<old_session_id>, 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
Expand Down