From cfd2a38223060afd9e6bdf41c65207833f243baa Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:45:09 -0700 Subject: [PATCH] fix(conversation): anchor the cwd staleness read to the host-info block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole-prompt scan for "Current working directory:" matched USER project content, not just Hermes' own host-info line. The prompt embeds AGENTS.md / CLAUDE.md / .cursorrules in the context tier, which sits AFTER the host-info block, and line_value() takes the LAST match — so any project file containing a line starting with that label won the scan. The comparison then ran runtime state against project prose, a mismatch that never clears: the stored prompt was rejected on EVERY turn, rebuilding the system prompt each message and destroying the prefix cache for the whole session. Strictly worse than the staleness the check exists to catch, and invisible to CI because no test encoded the invariant. "last match wins" was only ever safe because Model/Provider/Platform live in the volatile tier at the very END of the prompt. The cwd line is the one field read from the STABLE tier near the start, so it needs an anchored read: locate Hermes' own "User home directory:" line and take the working-directory line that follows it. Verified against the real builder on a real AIAgent, not a stub: - stable cwd, clean project -> prompt REUSED (0 rebuilds) - stable cwd, AGENTS.md naming the field -> prompt REUSED (was: rebuild/turn) - drifted cwd -> rebuilt - drifted cwd, AGENTS.md naming the NEW cwd -> rebuilt (prose can't mask drift) Also re-verified no spurious rebuild across all five cwd-resolution paths (pinned contextvar, TERMINAL_CWD, launch dir, symlinked workspace, trailing-slash cwd). The contributor's fixtures omitted the host-info anchor, so they silently stopped exercising the cwd path once the read was anchored; reshaped them to match real prompt structure via a _host_block() helper. --- agent/conversation_loop.py | 38 +++++++- .../run_agent/test_compression_persistence.py | 91 +++++++++++++++++-- 2 files changed, 119 insertions(+), 10 deletions(-) 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}):