diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 9efa25ad0a..95c061b3bb 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -790,7 +790,11 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) # fails closed to "reuse" so a probe failure can't burn cache. _bot_stale = False try: - from tools.bot_mode_probe import stored_prompt_capability_stale + from tools.bot_mode_probe import ( + BOT_CHAT_TITLE, + stored_bot_chat_prompt_needs_upgrade, + stored_prompt_capability_stale, + ) _home_for_epoch = None try: @@ -800,6 +804,21 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) except Exception: pass _bot_stale = stored_prompt_capability_stale(stored_prompt, _home_for_epoch) + if not _bot_stale and getattr(agent, "_bot_mode_protocol", True): + # Legacy upgrade: a Bot Chat whose prompt predates the epoch + # mechanism (no stamp, no protocol) gets ONE migration + # rebuild — otherwise pre-existing bots would never learn + # the messaging protocol. Title-gated so ordinary unstamped + # sessions (i.e. all of them) never take this path; the + # rebuilt prompt carries the stamp, so it cannot re-fire. + _t = str(getattr(agent, "_session_title_hint", "") or "").strip() + if not _t and agent._session_db and agent.session_id: + try: + _t = str(agent._session_db.get_session_title(agent.session_id) or "").strip() + except Exception: + _t = "" + if _t == BOT_CHAT_TITLE: + _bot_stale = stored_bot_chat_prompt_needs_upgrade(stored_prompt, _home_for_epoch) except Exception: _bot_stale = False if _bot_stale: diff --git a/tests/tools/test_bot_mode_probe.py b/tests/tools/test_bot_mode_probe.py index abc2d5d672..a1e9177328 100644 --- a/tests/tools/test_bot_mode_probe.py +++ b/tests/tools/test_bot_mode_probe.py @@ -169,3 +169,34 @@ def test_stored_prompt_staleness(tmp_path): # prompts without a stamp (every non-Bot-Chat session) are never stale assert not bot_mode_probe.stored_prompt_capability_stale("ordinary prompt", home) assert not bot_mode_probe.stored_prompt_capability_stale("", home) + + +def test_legacy_bot_chat_upgrade(tmp_path): + home = tmp_path / ".hermes" + home.mkdir() + _make_bot_profile(home, "researcher", managed=True) + + legacy = "old prompt with no protocol and no stamp" + # legacy Bot Chat on a managed install → upgrade once + assert bot_mode_probe.stored_bot_chat_prompt_needs_upgrade(legacy, home) + + # a rebuilt prompt (stamped) never re-fires + upgraded = legacy + "\n\n" + bot_mode_probe.get_bot_mode_protocol_section(home) + "\n\n" + bot_mode_probe.epoch_line(home) + assert not bot_mode_probe.stored_bot_chat_prompt_needs_upgrade(upgraded, home) + + # SOUL already carries the legacy plugin-side append → probe silent → + # no upgrade (rebuilding would loop: the new prompt would be unstamped too) + bot_mode_probe._reset_cache_for_tests() + (home / "SOUL.md").write_text("# Me\n\n## Messaging other agents\nlegacy\n", encoding="utf-8") + assert not bot_mode_probe.stored_bot_chat_prompt_needs_upgrade(legacy, home) + + # prompt whose SOUL section rode into it → protocol heading present → no upgrade + assert not bot_mode_probe.stored_bot_chat_prompt_needs_upgrade( + "prompt containing\n## Messaging other agents\nfrom SOUL", home + ) + + # unmanaged install → probe silent → never upgrades + bot_mode_probe._reset_cache_for_tests() + home2 = tmp_path / ".hermes2" + home2.mkdir() + assert not bot_mode_probe.stored_bot_chat_prompt_needs_upgrade(legacy, home2) diff --git a/tools/bot_mode_probe.py b/tools/bot_mode_probe.py index 3e7a70988c..7f59b986fe 100644 --- a/tools/bot_mode_probe.py +++ b/tools/bot_mode_probe.py @@ -260,6 +260,33 @@ def stored_prompt_capability_stale(stored_prompt: str, home: str | os.PathLike | return False +def stored_bot_chat_prompt_needs_upgrade(stored_prompt: str, home: str | os.PathLike | None = None) -> bool: + """True when a Bot Chat session's stored prompt PREDATES this feature. + + Legacy Bot Chats (created before bundling / this epoch mechanism) + persisted prompts with no protocol section and no epoch stamp; without + an explicit upgrade they would be stranded forever — the staleness check + above only fires on stamped prompts. This is a one-time migration per + legacy session: the caller must only invoke it for sessions titled + "Bot Chat", and only rebuilds when the probe would actually emit a + section (a profile whose SOUL.md already carries the legacy plugin-side + append keeps its protocol-free prompt — rebuilding those would loop, + since the probe stays silent and the rebuilt prompt would be unstamped + again). Fails closed to "no upgrade". + """ + try: + if _EPOCH_PREFIX in (stored_prompt or ""): + return False + if _PROTOCOL_HEADING in (stored_prompt or ""): + return False + # Only upgrade when the rebuild would actually add the section — + # this is what guarantees the rebuilt prompt carries a stamp and + # the upgrade can never re-fire. + return bool(get_bot_mode_protocol_section(home)) + except Exception: + return False + + def _reset_cache_for_tests() -> None: with _lock: _cached.clear()