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 <joaomarcosdias444@gmail.com>
Co-authored-by: HexLab98 <liruixinch@outlook.com>
This commit is contained in:
Teknium
2026-09-07 02:21:00 -07:00
parent cfe07df09f
commit 9da8df8d26
6 changed files with 305 additions and 29 deletions
+10 -5
View File
@@ -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 ""
+4
View File
@@ -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 = "<!-- End Hermes runtime environment -->"
CONTEXT_FILE_MAX_CHARS = 20_000
CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
CONTEXT_TRUNCATE_TAIL_RATIO = 0.2
+28 -17
View File
@@ -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)}
@@ -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))
+52 -4
View File
@@ -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",
))
@@ -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