diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index b9067a378726..a73f08fb24cf 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -540,9 +540,17 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) def _stored_prompt_matches_runtime(agent, prompt: str) -> bool: - """Return False when the persisted Model/Provider lines are stale.""" + """Return False when the persisted runtime-identity lines are stale.""" def line_value(label: str) -> str: + """Last matching line wins. + + Safe ONLY for fields emitted in the volatile tier at the very END of + the prompt (Model / Provider / Platform). User-supplied project + context (AGENTS.md / CLAUDE.md / .cursorrules) is embedded in the + middle context tier, so a last-match scan lets project prose shadow + any field emitted EARLIER — see ``host_info_value``. + """ prefix = f"{label}:" value = "" for line in prompt.splitlines(): @@ -550,6 +558,32 @@ def _stored_prompt_matches_runtime(agent, prompt: str) -> bool: value = line[len(prefix):].strip() return value + def host_info_value(label: str) -> str: + """Read a field from the prompt's own host-info block. + + The host-info block (``build_environment_hints``) sits in the STABLE + tier, ahead of the embedded project context files. A bare scan of the + whole prompt would therefore match a user's ``AGENTS.md`` that merely + contains a line starting with the same label, comparing runtime state + against project prose. That mismatch never clears, so the check would + reject the stored prompt on EVERY turn — rebuilding the system prompt + each message and destroying the prefix cache for the whole session, + which is far worse than the staleness this function guards against. + + Anchor on the ``User home directory:`` line that immediately precedes + the working-directory line in that block, and take the FIRST such + occurrence, so only Hermes' own emitted block can satisfy the read. + """ + prefix = f"{label}:" + lines = prompt.splitlines() + for idx, line in enumerate(lines): + if not line.startswith("User home directory:"): + continue + for candidate in lines[idx + 1: idx + 4]: + if candidate.startswith(prefix): + return candidate[len(prefix):].strip() + return "" + stored_model = line_value("Model") current_model = str(getattr(agent, "model", "") or "").strip() if stored_model and current_model and stored_model != current_model: @@ -565,7 +599,7 @@ def _stored_prompt_matches_runtime(agent, prompt: str) -> bool: # Compare against resolve_agent_cwd() — the SAME resolver used to build the # prompt — so gateway/TUI sessions that set TERMINAL_CWD are not falsely # rejected (they would always differ from the launch dir's os.getcwd()). - stored_cwd = line_value("Current working directory") + stored_cwd = host_info_value("Current working directory") if stored_cwd: if stored_cwd != str(resolve_agent_cwd()): return False diff --git a/tests/run_agent/test_compression_persistence.py b/tests/run_agent/test_compression_persistence.py index fa6615532313..c21afa1f35e7 100644 --- a/tests/run_agent/test_compression_persistence.py +++ b/tests/run_agent/test_compression_persistence.py @@ -451,6 +451,22 @@ class TestStoredPromptCwdDrift: agent.provider = provider return agent + @staticmethod + def _host_block(cwd: str) -> str: + """A stored prompt fragment shaped like the real host-info block. + + ``build_environment_hints`` always emits ``User home directory:`` + immediately before the working-directory line, and the staleness check + anchors on that pair so user project files can't shadow the real value. + Fixtures must therefore include the anchor or they stop exercising the + cwd path at all. + """ + return ( + "Host: Linux (6.16.0)\n" + "User home directory: /home/tester\n" + f"Current working directory: {cwd}\n" + ) + def test_stored_prompt_stale_when_cwd_differs(self): """Different cwd should force a prompt rebuild.""" from unittest.mock import patch @@ -458,8 +474,8 @@ class TestStoredPromptCwdDrift: agent = self._make_agent() stored_prompt = ( - "Current working directory: /project/old\n" - "Model: test/model\n" + self._host_block("/project/old") + + "Model: test/model\n" "Provider: openrouter\n" ) @@ -476,8 +492,8 @@ class TestStoredPromptCwdDrift: agent = self._make_agent() current_cwd = "/project/current" stored_prompt = ( - f"Current working directory: {current_cwd}\n" - "Model: test/model\n" + self._host_block(current_cwd) + + "Model: test/model\n" "Provider: openrouter\n" ) @@ -486,6 +502,65 @@ class TestStoredPromptCwdDrift: "Expected True when stored cwd matches current cwd" ) + def test_project_context_cannot_force_a_rebuild(self): + """🔴 CACHE INVARIANT: user project text must never invalidate the prompt. + + The prompt embeds AGENTS.md / CLAUDE.md / .cursorrules in the context + tier, which sits AFTER the host-info block. A whole-prompt scan for + ``Current working directory:`` therefore matched the user's own file + and compared runtime state against project prose. That mismatch never + clears, so the check rejected the stored prompt on EVERY turn — + rebuilding the system prompt each message and destroying the prefix + cache for the entire session. Strictly worse than the staleness this + check exists to catch. + """ + from unittest.mock import patch + from agent.conversation_loop import _stored_prompt_matches_runtime + + agent = self._make_agent() + current_cwd = "/project/current" + stored_prompt = ( + self._host_block(current_cwd) + + "\n# AGENTS.md\n\n" + "Our deploy convention:\n\n" + "Current working directory: /srv/decoy\n\n" + "Always run make before pushing.\n\n" + "Model: test/model\n" + "Provider: openrouter\n" + ) + + with patch("os.getcwd", return_value=current_cwd): + assert _stored_prompt_matches_runtime(agent, stored_prompt) is True, ( + "A project file that merely MENTIONS 'Current working " + "directory:' must not invalidate the prompt — that would " + "rebuild every turn and break the prefix cache" + ) + + def test_project_context_cannot_mask_real_drift(self): + """The inverse: project text must not fake a match either. + + A stored prompt built in /project/old whose embedded AGENTS.md happens + to name the NEW cwd must still be rejected — otherwise project prose + could suppress genuine drift detection. + """ + from unittest.mock import patch + from agent.conversation_loop import _stored_prompt_matches_runtime + + agent = self._make_agent() + stored_prompt = ( + self._host_block("/project/old") + + "\n# AGENTS.md\n\n" + "Current working directory: /project/new\n\n" + "Model: test/model\n" + "Provider: openrouter\n" + ) + + with patch("os.getcwd", return_value="/project/new"): + assert _stored_prompt_matches_runtime(agent, stored_prompt) is False, ( + "Embedded project text naming the new cwd must not mask real " + "drift in the host-info block" + ) + def test_stored_prompt_stale_when_runtime_surface_differs(self): """A stored prompt built for a different platform must not be reused.""" from agent.conversation_loop import _stored_prompt_matches_runtime @@ -531,8 +606,8 @@ class TestStoredPromptCwdDrift: agent = self._make_agent() with tempfile.TemporaryDirectory() as term_cwd: stored_prompt = ( - f"Current working directory: {term_cwd}\n" - "Model: test/model\n" + self._host_block(term_cwd) + + "Model: test/model\n" "Provider: openrouter\n" ) with patch.dict(os.environ, {"TERMINAL_CWD": term_cwd}): @@ -549,8 +624,8 @@ class TestStoredPromptCwdDrift: agent = self._make_agent() with tempfile.TemporaryDirectory() as term_cwd, tempfile.TemporaryDirectory() as other_cwd: stored_prompt = ( - f"Current working directory: {other_cwd}\n" - "Model: test/model\n" + self._host_block(other_cwd) + + "Model: test/model\n" "Provider: openrouter\n" ) with patch.dict(os.environ, {"TERMINAL_CWD": term_cwd}):