From 9da8df8d26581396d0f22f6832a3d5efa5d16c1b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:21:00 -0700 Subject: [PATCH] fix(prompt): preserve shared project prefixes across worktrees Slim salvage of #104688: place project context before workspace state and keep cwd outside the stable prefix. Put runtime hints behind a final renderer-owned boundary so quoted operator, memory, plugin and embedder examples cannot override the persisted runtime cwd or identity fields. Retain legacy unmarked prompt validation, add two invariant tests and a credential-free real-AIAgent/git-worktree replay harness. No provider cache-hit or billing measurements are claimed. Co-authored-by: joaomarcos Co-authored-by: HexLab98 --- agent/conversation_loop.py | 15 +- agent/prompt_builder.py | 4 + agent/system_prompt.py | 45 ++-- .../worktree_prompt_prefix.py | 194 ++++++++++++++++++ tests/agent/test_system_prompt.py | 56 ++++- .../docs/developer-guide/prompt-assembly.md | 20 +- 6 files changed, 305 insertions(+), 29 deletions(-) create mode 100644 evals/token_accounting/worktree_prompt_prefix.py diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 2e88c10327..ef1e1399bf 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -21,6 +21,7 @@ from agent.message_metadata import append_message from agent.message_sanitization import _repair_tool_call_arguments, _sanitize_surrogates from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, _estimate_tools_tokens_rough from agent.process_bootstrap import _install_safe_stdio +from agent.prompt_builder import RUNTIME_ENVIRONMENT_END, RUNTIME_ENVIRONMENT_HEADING from agent.prompt_caching import ( build_prompt_cache_plan, effective_cache_ttl, @@ -755,7 +756,11 @@ 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 runtime-identity lines are stale.""" - lines = prompt.splitlines() + identity, runtime_marker, runtime = prompt.rpartition(f"\n\n{RUNTIME_ENVIRONMENT_HEADING}\n\n") + # Legacy prose may quote the heading, but only the new renderer ends in this boundary. + runtime_marker = runtime_marker if prompt.endswith(RUNTIME_ENVIRONMENT_END) else "" + # The final runtime block can contain embedder prose, not authoritative identity. + lines = (identity if runtime_marker else prompt).splitlines() def line_value(label: str) -> str: """Last matching line wins — safe ONLY for volatile-tier fields at the END of the @@ -765,12 +770,12 @@ def _stored_prompt_matches_runtime(agent, prompt: str) -> bool: return matches[-1] if matches else "" def host_info_value(label: str) -> str: - """Read a field from the prompt's own host-info block, anchored on the FIRST ``User - home directory:`` line so a user's ``AGENTS.md`` row cannot force a rebuild every turn.""" + """New prompts delimit runtime hints; legacy prompts put them before context.""" prefix = f"{label}:" - for idx, line in enumerate(lines): + host_lines = runtime.split("\n\n", 1)[0].splitlines() if runtime_marker else lines + for idx, line in enumerate(host_lines): if line.startswith("User home directory:"): - for candidate in lines[idx + 1: idx + 4]: + for candidate in host_lines[idx + 1: idx + 4]: if candidate.startswith(prefix): return candidate[len(prefix):].strip() return "" diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 2f36082498..f7f6956f34 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -1006,6 +1006,10 @@ def build_environment_hints() -> str: return "\n\n".join(h for h in (*hints, _embedder_environment_hint()) if h) +# Marks the runtime block after project prose for persisted-prompt cwd validation. +RUNTIME_ENVIRONMENT_HEADING = "# Hermes runtime environment" +RUNTIME_ENVIRONMENT_END = "" + CONTEXT_FILE_MAX_CHARS = 20_000 CONTEXT_TRUNCATE_HEAD_RATIO = 0.7 CONTEXT_TRUNCATE_TAIL_RATIO = 0.2 diff --git a/agent/system_prompt.py b/agent/system_prompt.py index 9bd52f03fc..e7f04271d0 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -540,7 +540,7 @@ def _coding_parts(agent: Any) -> Tuple[List[str], List[str], List[str]]: """``(prefix, workspace, trailing)`` coding-posture blocks; all empty without tools or when probing fails (it must never block prompt build). - The workspace block is a live git probe that leads the context tier, ahead of the whole + The workspace block is a live git probe after project context, ahead of the whole volatile band; re-probing at the compaction rebuild re-emits different bytes for any repo that moved and defeats the keep-prompt fast path. So the bytes are pinned per session on the agent, keyed by the resolved cwd (a gateway serves many cwds), and @@ -566,9 +566,9 @@ def _coding_parts(agent: Any) -> Tuple[List[str], List[str], List[str]]: def _post_workspace_parts(agent: Any) -> List[str]: - """Blocks that historically follow the workspace snapshot: environment - probe (config.yaml agent.environment_probe; one line, nothing when clean, - skipped for remote backends), bot-mode protocol, profile line, platform hint.""" + """Blocks that follow the worktree-specific context: environment probe + (config.yaml agent.environment_probe; one line, nothing when clean, skipped + for remote backends), bot-mode protocol, profile line, platform hint.""" parts: List[str] = [] if getattr(agent, "_environment_probe", True): try: @@ -602,11 +602,13 @@ def _join_tier(parts: List[Optional[str]]) -> str: def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]: - """Assemble the system prompt as three ordered cache tiers: ``stable`` (through - the coding operating brief when a workspace snapshot follows), ``context`` - (snapshot, remaining session-stable guidance, caller ``system_message``, - context files) and ``volatile`` (skills index, memory, user profile, external - memory block, timestamp line). Never re-rendered mid-session.""" + """Assemble the system prompt as three ordered cache tiers: ``stable`` (identity, + guidance and the coding brief), ``context`` (caller ``system_message``, project + context files, workspace snapshot and remaining workspace guidance) and + ``volatile`` (skills index, memory, user profile, external memory block, + timestamp line, runtime environment hints). Worktree-dependent blocks follow project context so a + shared context file can remain in the longest common prefix across worktrees. + Never re-rendered mid-session.""" # Model context window scales the context-file caps; stable per conversation. _cc_len = getattr(getattr(agent, "context_compressor", None), "context_length", None) _ctx_len = _cc_len if isinstance(_cc_len, int) and _cc_len > 0 else None @@ -624,22 +626,25 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) if "skill_view" in (agent.valid_tool_names or set()) and "- hermes-agent:" in skills_prompt: stable_parts[_help_guidance_slot] = HERMES_AGENT_HELP_GUIDANCE stable_parts.extend(_alibaba_identity_part(agent)) - stable_parts.append(_pb.build_environment_hints()) - # Coding posture: operating brief stays in the stable prefix; the live - # git/workspace snapshot sits behind its own cache boundary, and the blocks - # below it must keep their historical post-snapshot position. + # Coding posture: the operating brief stays in the stable prefix. The + # environment block contains the current cwd/backend and belongs after + # project context, not ahead of a large shared AGENTS.md block. + environment_hints = _pb.build_environment_hints() coding_prefix_parts, coding_workspace_parts, coding_trailing_parts = _coding_parts(agent) stable_parts.extend(coding_prefix_parts) post_workspace_parts = _post_workspace_parts(agent) - # ── Context tier (cwd-dependent, may change between sessions) ─ + # ── Context tier (project/worktree-dependent, may change between sessions) ── context_parts: List[str] = [] - (context_parts if coding_workspace_parts else stable_parts).extend( - [*coding_workspace_parts, *coding_trailing_parts, *post_workspace_parts] - ) # ephemeral_system_prompt is injected at API-call time only, never cached. if system_message is not None: context_parts.append(system_message) context_parts.extend(_context_files_part(agent, _ctx_len, _soul_loaded)) + if coding_workspace_parts: + context_parts.extend([*coding_workspace_parts, *coding_trailing_parts, *post_workspace_parts]) + else: + # Preserve the stable placement for non-workspace sessions; there is no + # worktree snapshot whose later position would improve their prefix. + stable_parts.extend([*coding_trailing_parts, *post_workspace_parts]) # ── Volatile tier (most likely to differ on a rebuild; kept last so the stable prefix stays reusable) ── # Skills are runtime-mutable, so the index leads the volatile band: on a longest-prefix # backend an unchanged index stays inside the reused prefix; a changed one re-prefills from here. @@ -648,6 +653,12 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) # a resumed process can reconstruct the stable prefix without re-running plugins. volatile_parts.extend(_plugin_section_blocks(_frozen_plugin_prompt_sections(agent), "after_memory")) volatile_parts.append(_timestamp_line(agent)) + # Keep the renderer-owned runtime anchor after all user/plugin prose so quoted + # host examples cannot shadow it during persisted-prompt validation. + if environment_hints: + # Embedder hints are prose too; reserve the delimiter for the renderer. + environment_hints = environment_hints.replace(_pb.RUNTIME_ENVIRONMENT_HEADING, "> " + _pb.RUNTIME_ENVIRONMENT_HEADING) + volatile_parts.append(f"{_pb.RUNTIME_ENVIRONMENT_HEADING}\n\n{environment_hints}\n\n{_pb.RUNTIME_ENVIRONMENT_END}") return {"stable": _join_tier(stable_parts), "context": _join_tier(context_parts), "volatile": _join_tier(volatile_parts)} diff --git a/evals/token_accounting/worktree_prompt_prefix.py b/evals/token_accounting/worktree_prompt_prefix.py new file mode 100644 index 0000000000..8826e39e70 --- /dev/null +++ b/evals/token_accounting/worktree_prompt_prefix.py @@ -0,0 +1,194 @@ +"""Offline real-worktree prompt prefix probe (no provider requests). + +Run with the checkout's Python and PROBE_OUT pointing at a new empty directory. +The subprocesses use isolated HOME/HERMES_HOME and a localhost dummy provider. +""" + +import os, sys, json, subprocess, pathlib, hashlib, dataclasses + +BASE = pathlib.Path(os.environ["PROBE_OUT"]) +SRC = pathlib.Path(__file__).resolve().parents[2] + + +def git(*args, cwd): + p = subprocess.run( + ["git", *args], + cwd=cwd, + text=True, + capture_output=True, + check=True, + stdin=subprocess.DEVNULL, + ) + return p.stdout.strip() + + +def digest(s): + return hashlib.sha256(s.encode()).hexdigest() + + +if len(sys.argv) == 1: + BASE.mkdir(parents=True, exist_ok=True) + home = BASE / "home" + home.mkdir(exist_ok=True) + hh = home / "hermes" + hh.mkdir(exist_ok=True) + (hh / "config.yaml").write_text( + "context_file_max_chars: 100000\nagent:\n coding_context: auto\n environment_probe: false\n" + ) + fixture = BASE / "fixture" + fixture.mkdir(exist_ok=True) + git("init", "-b", "main", cwd=fixture) + content = "# Shared project guidance\n" + ( + "Use deterministic local operations and preserve project conventions.\n" * 1100 + ) + content = content[:66000] + (fixture / "AGENTS.md").write_text(content) + (fixture / "app.py").write_text('print("fixture")\n') + git("add", "AGENTS.md", "app.py", cwd=fixture) + git( + "-c", + "user.name=Prompt Probe", + "-c", + "user.email=probe@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "fixture", + cwd=fixture, + ) + for name in ("worktree-a", "worktree-b"): + git("worktree", "add", "-b", name, str(BASE / name), cwd=fixture) + env = { + "PATH": os.environ["PATH"], + "HOME": str(home), + "HERMES_HOME": str(hh), + "PYTHONDONTWRITEBYTECODE": "1", + "PYTHONPATH": str(SRC), + "PYTHONHASHSEED": "0", + "LANG": "C.UTF-8", + "TZ": "UTC", + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": "/dev/null", + "TMPDIR": str(BASE), + "PROBE_OUT": str(BASE), + } + for name in ("worktree-a", "worktree-b"): + e = env | {"TERMINAL_CWD": str(BASE / name), "TERMINAL_ENV": "local"} + with (BASE / (name + ".log")).open("w") as log: + p = subprocess.run( + [sys.executable, "-B", __file__, name], + env=e, + cwd=BASE / name, + stdout=log, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + timeout=150, + ) + print(name, "exit", p.returncode, flush=True) + if p.returncode: + raise SystemExit(p.returncode) + a, b = [ + json.loads((BASE / (name + ".json")).read_text()) + for name in ("worktree-a", "worktree-b") + ] + + def lcp(x, y): + return next( + (i for i, (c, d) in enumerate(zip(x, y)) if c != d), min(len(x), len(y)) + ) + + diff = { + "source_sha": git("rev-parse", "HEAD", cwd=SRC), + "source_dirty": bool(git("status", "--porcelain", cwd=SRC)), + "source_diff_sha256": digest(git("diff", "HEAD", cwd=SRC)), + "fixture_git_worktrees": git("worktree", "list", "--porcelain", cwd=fixture), + "context_file_chars": len(content), + "context_file_sha256": digest(content), + "stable_equal": a["parts"]["stable"] == b["parts"]["stable"], + "full_common_prefix_chars": lcp(a["full"], b["full"]), + "stable_common_prefix_chars": lcp(a["parts"]["stable"], b["parts"]["stable"]), + "project_context_equal": a["project_context"] == b["project_context"], + "same_agent_rebuild_equal": [ + a["same_agent_rebuild_equal"], + b["same_agent_rebuild_equal"], + ], + "per_worktree": [x["metrics"] for x in (a, b)], + "provider_usage": None, + "billing_measured": False, + } + (BASE / "measurements.json").write_text(json.dumps(diff, indent=2)) + print(json.dumps(diff, indent=2)) +else: + # Fresh credential-free process, real production imports; no prompt seams mocked. + sys.path.insert(0, str(SRC)) + from run_agent import AIAgent + from agent.system_prompt import build_system_prompt, build_system_prompt_parts + from agent.prompt_builder import build_context_files_prompt + from agent.prompt_caching import build_prompt_cache_plan + + agent = AIAgent( + api_key="offline-not-a-credential", + base_url="http://127.0.0.1:9/v1", + provider="openai-compat", + model="offline-probe", + enabled_toolsets=["terminal", "file"], + quiet_mode=True, + skip_context_files=False, + skip_memory=True, + skip_background_review=True, + save_trajectories=False, + platform="cli", + session_id="20260907_120000_probe", + ) + parts = build_system_prompt_parts(agent) + full = build_system_prompt(agent) + repeated = build_system_prompt(agent) + project = build_context_files_prompt(cwd=os.getcwd(), skip_soul=True) + plan = build_prompt_cache_plan( + [ + {"role": "system", "content": full}, + {"role": "user", "content": "Offline prompt inspection only."}, + ], + agent.tools, + static_system_prefix=agent._cached_system_prompt_static, + ) + plan_data = dataclasses.asdict(plan) + from agent.conversation_loop import _stored_prompt_matches_runtime + + decoy = "\n\nOperator instructions:\nHost: Example\nUser home directory: /example\nCurrent working directory: /example\n" + restore_matches = _stored_prompt_matches_runtime(agent, full) + trailing_decoy_matches = _stored_prompt_matches_runtime(agent, full + decoy) + (BASE / "different-worktree").mkdir(exist_ok=True) + os.environ["TERMINAL_CWD"] = str(BASE / "different-worktree") + drift_rejected = not _stored_prompt_matches_runtime(agent, full) + os.environ["TERMINAL_CWD"] = os.getcwd() + name = sys.argv[1] + metrics = { + "restore_matches": restore_matches, + "trailing_decoy_matches": trailing_decoy_matches, + "drift_rejected": drift_rejected, + "cwd": os.getcwd(), + "full_chars": len(full), + "stable_chars": len(parts["stable"]), + "context_chars": len(parts["context"]), + "project_context_chars": len(project), + "cwd_line_offset": full.index("Current working directory:"), + "workspace_offset": full.find("Workspace (snapshot at session start"), + "root_offset": full.find("- Root:"), + "project_context_offset": full.index("# Project Context"), + "stable_has_cwd": os.getcwd() in parts["stable"], + "stable_sha256": digest(parts["stable"]), + "prompt_sha256": digest(full), + "valid_tool_names": sorted(agent.valid_tool_names), + } + out = { + "parts": parts, + "full": full, + "project_context": project, + "same_agent_rebuild_equal": full == repeated, + "metrics": metrics, + "cache_plan": plan_data, + } + (BASE / (name + ".json")).write_text(json.dumps(out, indent=2, default=str)) + print(json.dumps(metrics, indent=2)) diff --git a/tests/agent/test_system_prompt.py b/tests/agent/test_system_prompt.py index f8fb28b797..7f0862cd49 100644 --- a/tests/agent/test_system_prompt.py +++ b/tests/agent/test_system_prompt.py @@ -163,6 +163,54 @@ class TestCodingContextBlock: assert "coding agent" not in _stable_prompt(agent) +def test_shared_project_context_precedes_worktree_bytes(monkeypatch, tmp_path): + import os + + monkeypatch.setenv("TERMINAL_ENV", "local") + prompts = [] + for name in ("worktree-a", "worktree-b"): + cwd = tmp_path / name + cwd.mkdir() + (cwd / "AGENTS.md").write_text("Shared project instructions.") + monkeypatch.setenv("TERMINAL_CWD", str(cwd)) + agent = _make_agent(platform="cli") + parts = build_system_prompt_parts(agent) + full = "\n\n".join(parts.values()) + assert full.index("Shared project instructions.") < full.index("Current working directory:") + assert str(cwd) not in parts["stable"] + assert full == "\n\n".join(build_system_prompt_parts(agent).values()) + prompts.append(full) + common = os.path.commonprefix(prompts) + assert "Shared project instructions." in common + + +def test_stored_prompt_cwd_ignores_project_host_decoys(monkeypatch, tmp_path): + from agent.conversation_loop import _stored_prompt_matches_runtime + + cwd = tmp_path / "worktree" + cwd.mkdir() + monkeypatch.setenv("TERMINAL_ENV", "local") + monkeypatch.setenv("TERMINAL_CWD", str(cwd)) + decoy = "# Hermes runtime environment\n\nHost: Example\nUser home directory: /example\nCurrent working directory: /example\n" + (cwd / "AGENTS.md").write_text(decoy) + monkeypatch.setenv("HERMES_ENVIRONMENT_HINT", decoy + "\nModel: decoy\nProvider: decoy\nPlatform: decoy") + agent = _make_agent( + platform="cli", model="test-model", provider="test-provider", + _memory_enabled=True, _user_profile_enabled=False, + _memory_store=SimpleNamespace(format_for_system_prompt=lambda _: decoy), + ) + parts = build_system_prompt_parts(agent) + full = "\n\n".join(parts.values()) + assert _stored_prompt_matches_runtime(agent, full) + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + assert not _stored_prompt_matches_runtime(agent, full) + # Previously persisted host-before-context prompts keep their original anchor. + legacy = f"Host: Example\nUser home directory: {tmp_path}\nCurrent working directory: {cwd}\n\n# Project Context\n\n{decoy}\nModel: test-model\nProvider: test-provider\nPlatform: cli" + assert not _stored_prompt_matches_runtime(agent, legacy) + monkeypatch.setenv("TERMINAL_CWD", str(cwd)) + assert _stored_prompt_matches_runtime(agent, legacy) + + class TestExecutionGuidanceInjection: """Injection gate for OPENAI_MODEL_EXECUTION_GUIDANCE via ``agent.execution_guidance`` (auto/true/false/list). @@ -321,8 +369,8 @@ def test_build_system_prompt_records_stable_prefix(): assert prompt[len(agent._cached_system_prompt_static):].startswith("\n\ncontext") -def test_coding_prompt_preserves_legacy_workspace_order(monkeypatch): - """The cache split must not reorder the stored coding prompt.""" +def test_coding_prompt_orders_shared_context_before_workspace(monkeypatch): + """Keep workspace guidance intact after the shared context.""" import agent.system_prompt as system_prompt agent = _make_agent( @@ -351,11 +399,11 @@ def test_coding_prompt_preserves_legacy_workspace_order(monkeypatch): "HELP", "STEER", "CODING_STABLE", + "SYSTEM_MESSAGE", + "CONTEXT_FILES", "WORKSPACE", "Operator instructions (from config):\nOPERATOR", expected_profile, - "SYSTEM_MESSAGE", - "CONTEXT_FILES", "Conversation started: Friday, January 02, 2026", )) diff --git a/website/docs/developer-guide/prompt-assembly.md b/website/docs/developer-guide/prompt-assembly.md index 209c4c3e19..aeae73d6a0 100644 --- a/website/docs/developer-guide/prompt-assembly.md +++ b/website/docs/developer-guide/prompt-assembly.md @@ -28,9 +28,9 @@ Primary files: The cached system prompt is assembled as three ordered tiers (see `agent/system_prompt.py`): -1. **stable** — identity (`SOUL.md` or fallback), tool/model guidance, skills prompt, environment hints, platform hints -2. **context** — caller-supplied `system_message` plus project context files (`.hermes.md` / `AGENTS.md` / `CLAUDE.md` / `.cursorrules`) -3. **volatile** — built-in memory snapshot (`MEMORY.md`), user profile snapshot (`USER.md`), external memory-provider block, timestamp/session/model/provider line +1. **stable** — identity (`SOUL.md` or fallback), tool/model guidance, coding operating brief +2. **context** — caller-supplied `system_message`, project context files (`.hermes.md` / `AGENTS.md` / `CLAUDE.md` / `.cursorrules`), then the worktree-dependent git workspace snapshot, operator instructions and platform hints +3. **volatile** — skills index, built-in memory snapshot (`MEMORY.md`), user profile snapshot (`USER.md`), external memory-provider block, timestamp/session/model/provider line, then runtime environment hints (host / home / **current working directory**) The final system prompt is then joined as: `stable` → `context` → `volatile`. @@ -39,6 +39,20 @@ This ordering matters for precedence discussions: - memory/profile snapshots are part of the **volatile** tier - both are still in the cached system prompt (they are not injected as ad-hoc mid-turn overlays) +Inside the context tier the shared project files come **before** anything naming the current worktree. +Sessions of one project running in different git worktrees then share a prompt prefix covering the whole +context block, instead of stopping at the first cwd-dependent line — that prefix is what a longest-prefix +provider cache reuses. A session with no workspace snapshot keeps its trailing guidance in the stable +tier; the runtime environment block always ends the volatile tier. + +Consequence for stored prompts: `_stored_prompt_matches_runtime()` (`agent/conversation_loop.py`) reads +the first host-info paragraph after the rendered `# Hermes runtime environment` boundary, with a +closing marker at the absolute end distinguishing this layout from legacy prose quoting the heading. +The runtime boundary follows all project, operator, memory and plugin text, so examples in those +blocks do not masquerade as the runtime cwd. Model/provider/platform are read before the runtime +boundary, excluding embedder descriptions. Legacy prompts retain their original +host-before-context anchor, so prompts persisted before the reorder still validate. + When `skip_context_files` is set (e.g., subagent delegation), SOUL.md is not loaded and the hardcoded `DEFAULT_AGENT_IDENTITY` is used instead. ### Concrete example: assembled system prompt