fix(prompt): stored Model/Provider line with an empty live value is a stale route

Route commits no longer NULL the stored prompt, so _stored_prompt_matches_runtime is the
only rebuild trigger. A model-only request that clears agent.provider skipped the compare
and replayed the previous route's prompt verbatim. The builder omits empty trailer lines,
so the rebuilt prompt matches from the next turn (no rebuild loop); prompts without
identity lines keep reusing.
This commit is contained in:
kshitijk4poor
2026-09-27 01:04:09 +05:30
committed by kshitij
parent a7059225d5
commit a4db03cee9
2 changed files with 82 additions and 1 deletions

View File

@@ -858,10 +858,13 @@ def _stored_prompt_matches_runtime(agent, prompt: str) -> bool:
# Model/provider identity, then cwd drift. A cwd change is a real content change (context
# files, the workspace snapshot and the coding posture are all resolved from it), so it
# still rebuilds; the runtime surface does not (agent/surface_switch.py).
# The builder omits an empty trailer line, so stored-but-now-empty is a route change too;
# the rebuilt prompt then carries no line and matches from the next turn on. Stored-empty
# (pre-trailer prompts) keeps reusing.
for label, attr in (("Model", "model"), ("Provider", "provider")):
stored = identity_line_value(prompt, label)
current = str(getattr(agent, attr, "") or "").strip()
if stored and current and stored != current:
if stored and stored != current:
return False
# A prompt stamped for another session (a /branch child copies its parent's bytes) must not
# tell the model a foreign Session ID. Checked only when the trailer is on: with it off, a

View File

@@ -94,3 +94,81 @@ def test_compression_tip_adoption_applies_the_identity_check(db):
assert rejected.session_id == "child"
assert rejected._cached_system_prompt is None
assert _adopt("model-a", "prov-a")._cached_system_prompt == stale
def _turn_agent(db, model: str, provider: str) -> MagicMock:
"""A fresh per-turn agent (the gateway shape) whose rebuild renders the real trailer."""
from agent.system_prompt import _timestamp_line
agent = MagicMock()
agent._cached_system_prompt = None
agent.session_id = SESSION_ID
agent.model, agent.provider = model, provider
agent.platform = "discord"
agent.pass_session_id = False
agent.session_start = None
agent._bot_chat_timeless_prompt = False
agent._persist_disabled = False
agent._use_prompt_caching = False
agent._session_db = db
agent._platform_hint_overrides = None
agent._surface_switch_note = ""
agent._gateway_turn_context_notes = ""
agent.enabled_toolsets = agent.disabled_toolsets = None
agent._build_system_prompt = MagicMock(side_effect=lambda _sm: f"You are Hermes Agent.\n\n{_timestamp_line(agent)}")
return agent
def _run_turn(db, model: str, provider: str) -> MagicMock:
from agent.conversation_loop import _restore_or_build_system_prompt
agent = _turn_agent(db, model, provider)
_restore_or_build_system_prompt(agent, None, [{"role": "user", "content": "hi"}])
return agent
@pytest.mark.parametrize(
("live_model", "live_provider"),
[("x-ai/grok-4.5", ""), ("", "nous")],
ids=["provider_emptied", "model_emptied"],
)
def test_emptied_live_identity_rebuilds_once_then_reuses(db, live_model, live_provider):
"""A stored ``Provider:``/``Model:`` line with an empty live value is a stale route.
Route commits keep the stored prompt, so this check is the only rebuild trigger: a
model-only request that clears the provider must not replay the previous route's prompt.
The rebuilt prompt omits the empty line, so the next turn reuses it (no rebuild loop).
"""
stale = _stored_prompt("x-ai/grok-4.5", "nous")
db.create_session(SESSION_ID, source="discord", model="x-ai/grok-4.5")
db.update_system_prompt(SESSION_ID, stale)
first = _run_turn(db, live_model, live_provider)
first._build_system_prompt.assert_called_once()
rebuilt = db.get_session(SESSION_ID)["system_prompt"]
assert rebuilt == first._cached_system_prompt != stale
second = _run_turn(db, live_model, live_provider)
second._build_system_prompt.assert_not_called()
assert second._cached_system_prompt == rebuilt
def test_matching_live_identity_reuses_the_stored_prompt(db):
prompt = _stored_prompt("x-ai/grok-4.5", "nous")
db.create_session(SESSION_ID, source="discord", model="x-ai/grok-4.5")
db.update_system_prompt(SESSION_ID, prompt)
agent = _run_turn(db, "x-ai/grok-4.5", "nous")
agent._build_system_prompt.assert_not_called()
assert agent._cached_system_prompt == prompt
def test_prompt_without_identity_lines_keeps_reusing(db):
"""Pre-trailer prompts carry no identity lines; they must not rebuild on upgrade."""
legacy = "You are Hermes Agent.\n\nConversation started: Thursday, September 24, 2026"
db.create_session(SESSION_ID, source="discord", model="x-ai/grok-4.5")
db.update_system_prompt(SESSION_ID, legacy)
agent = _run_turn(db, "x-ai/grok-4.5", "")
agent._build_system_prompt.assert_not_called()
assert agent._cached_system_prompt == legacy