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:
@@ -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 ""
|
||||
|
||||
@@ -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
@@ -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))
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user