fix(agent): scope the install-tree AGENTS.md guard to the unconfigured-cwd fallback only

Rework of the salvaged guard: only the cwd=None -> os.getcwd() fallback on
daemon surfaces (gateway, tui/desktop backend, cron) skips project-context
discovery in the Hermes install/source tree. Explicitly chosen paths --
session cwd, TERMINAL_CWD, terminal.cwd, and the interactive CLI's launch
dir -- always load their context files, install tree and .worktrees/
included, so hermes-agent dev sessions keep their AGENTS.md.

resolve_context_cwd() keeps returning explicitly configured paths (now with
a warning when the directory is missing, instead of silently steering
discovery elsewhere). The surface decision lives in the new
resolve_context_files_cwd() helper in agent/system_prompt.py; the fallback
guard lives in build_context_files_prompt() via is_install_tree().

Fixes #64590
This commit is contained in:
Teknium 2026-07-16 02:23:21 -07:00
parent 5e2d7a449d
commit e7d6840989
No known key found for this signature in database
6 changed files with 202 additions and 51 deletions

View file

@ -1976,22 +1976,27 @@ def build_context_files_prompt(
When *skip_soul* is True, SOUL.md is not included here (it was already
loaded via ``load_soul_md()`` for the identity slot).
"""
# Only the *fallback* is guarded against the Hermes install/source tree:
# when no cwd was configured anywhere, os.getcwd() is an accident of how
# the process was spawned, and a backend launched from (or self-spawned
# into) the install tree would load this repo's contributor AGENTS.md as
# authoritative project context for unrelated sessions (#64590). An
# EXPLICIT cwd — session cwd, TERMINAL_CWD, terminal.cwd, an interactive
# CLI's launch dir passed by the caller — is honored as-is, install tree
# included: a developer working ON hermes-agent wants that AGENTS.md.
from agent.runtime_cwd import is_install_tree
explicit_cwd = cwd is not None
if cwd is None:
cwd = os.getcwd()
cwd_path = Path(cwd).resolve()
sections = []
# Never discover project context inside the Hermes install/source tree. A
# backend launched from, or self-spawning into, that tree (the desktop app
# default) would otherwise load this repo's contributor AGENTS.md as
# authoritative project context. resolve_context_cwd() already guards the
# configured-path cases; this covers the cwd=None -> os.getcwd() fallback.
from agent.runtime_cwd import _is_install_tree
if _is_install_tree(cwd_path):
if not explicit_cwd and is_install_tree(cwd_path):
logger.info(
"skipping project-context discovery in the Hermes install tree: %s",
"no configured cwd; skipping project-context discovery in the "
"Hermes install tree fallback: %s",
cwd_path,
)
project_context = ""

View file

@ -23,19 +23,25 @@ _UNSET: Any = object()
_SESSION_CWD: ContextVar = ContextVar("HERMES_SESSION_CWD", default=_UNSET)
# The Python package/source root (this file lives at <root>/agent/runtime_cwd.py).
# When a backend is launched from, or self-spawns into, this tree (the desktop
# app default), an os.getcwd() fallback would inject this repo's contributor
# AGENTS.md as authoritative project context. Context discovery must never
# resolve here.
# Backends whose process cwd is an accident of spawning (the desktop's headless
# `hermes serve`) use this to avoid DEFAULTING sessions into the Hermes source
# tree, whose contributor AGENTS.md would otherwise load as project context
# (#64590). An explicitly chosen path — session cwd, TERMINAL_CWD, terminal.cwd,
# the launch dir of an interactive surface — is always honored, install tree
# included: a developer working ON hermes-agent wants that AGENTS.md.
_PACKAGE_ROOT = Path(__file__).resolve().parent.parent
def _is_install_tree(p: Path) -> bool:
# True only when p IS the package root or sits inside it. Ancestors of the
# package root (a user home that happens to contain the checkout, a --user
# site-packages parent) are legitimate workspaces and must not be blocked.
def is_install_tree(p: Path) -> bool:
"""True when *p* IS the Hermes package/source root or sits inside it.
Ancestors of the package root (a user home that happens to contain the
checkout, a --user site-packages parent) are legitimate workspaces and
return False. Used only to steer *fallback* defaults away from the source
tree never to reject a path the user or config explicitly picked.
"""
try:
p = p.resolve()
p = Path(p).resolve()
except Exception:
return False
return p == _PACKAGE_ROOT or _PACKAGE_ROOT in p.parents
@ -75,27 +81,28 @@ def resolve_agent_cwd() -> Path:
def resolve_context_cwd() -> Path | None:
# None means "no configured cwd": build_context_files_prompt then falls back
# to the launch dir (os.getcwd()), correct for a local CLI launched inside a
# real project. A configured path is validated here (previously it was passed
# through unchecked, diverging from resolve_agent_cwd), and the Hermes install
# tree is never returned, since its AGENTS.md would take over the system prompt.
# to the launch dir (os.getcwd()) — correct for the local CLI, where the
# launch dir IS the user's choice. Backend surfaces whose process cwd is
# accidental avoid slurping the install dir at the *default* layer instead:
# the gateway sets TERMINAL_CWD (see system_prompt.py), the TUI/desktop
# gateway resolves each session's cwd up front (see tui_gateway/server.py
# _fallback_spawn_cwd), and cron sets TERMINAL_CWD per workdir job.
#
# Explicitly configured paths are honored AS-IS — including a Hermes source
# checkout (a developer working on hermes-agent wants its AGENTS.md loaded).
# A configured-but-missing dir is returned too (discovery simply finds
# nothing there); it only warns, so a typo'd terminal.cwd is visible in the
# logs instead of silently steering discovery somewhere else (#64590).
override = _session_cwd_override()
if override:
p = Path(override).expanduser()
if not p.is_dir():
logger.warning("configured working directory does not exist: %s", override)
elif _is_install_tree(p):
logger.warning("not loading context files from the Hermes install tree: %s", p)
else:
return p
return None
return p
raw = os.environ.get("TERMINAL_CWD", "").strip()
if raw:
p = Path(raw).expanduser()
if not p.is_dir():
logger.warning("TERMINAL_CWD does not exist: %s", raw)
elif _is_install_tree(p):
logger.warning("not loading context files from the Hermes install tree: %s", p)
else:
return p
return p
return None

View file

@ -64,6 +64,25 @@ def _ra():
return run_agent
def resolve_context_files_cwd(platform: Optional[str]) -> Optional[str]:
"""Working directory for context-file discovery, surface-aware.
A configured cwd (session contextvar / TERMINAL_CWD) always wins. When
none is configured, the interactive CLI promotes its launch dir to an
explicit choice the user ran ``hermes`` from that directory on purpose
(install tree included; devs working ON hermes-agent want its AGENTS.md).
Daemon surfaces (gateway, tui/desktop backend, cron) return None so
``build_context_files_prompt``'s install-tree fallback guard applies:
their process cwd is an accident of spawning, not a user choice (#64590).
"""
cwd = resolve_context_cwd()
if cwd is not None:
return str(cwd)
if (platform or "cli") == "cli":
return os.getcwd()
return None
def _resolve_platform_hint(agent: Any, platform_key: str, default_hint: str) -> str:
"""Apply a per-platform prompt-hint override to the default hint.
@ -459,12 +478,11 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
context_parts.append(system_message)
if not agent.skip_context_files:
# Prefer the configured TERMINAL_CWD (gateway mode). When unset (local
# CLI), None lets build_context_files_prompt fall back to the launch
# dir — the user's real cwd there, but the install dir for the gateway
# daemon, which is why the gateway sets TERMINAL_CWD.
# Surface-aware cwd for context-file discovery — see
# resolve_context_files_cwd() for the full contract (#64590).
context_files_prompt = _r.build_context_files_prompt(
cwd=resolve_context_cwd(), skip_soul=_soul_loaded,
cwd=resolve_context_files_cwd(agent.platform),
skip_soul=_soul_loaded,
context_length=_ctx_len)
if context_files_prompt:
context_parts.append(context_files_prompt)

View file

@ -46,6 +46,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"doogie@spark.local": "SAMBAS123", # PR #64986 salvage (gateway: multiplex primary bot token scope)
"evefromwayback@gmail.com": "evefromwayback", # PR #64611 salvage (agent: install-tree AGENTS.md fallback guard; #64590)
"41409874+2751738943@users.noreply.github.com": "2751738943", # PR #54785 salvage (tui: post-turn completion ownership routing)
"Burgunthy@users.noreply.github.com": "Burgunthy", # PR #20096 salvage (gateway: profile-based routing for inbound messages)
"75556242+webtecnica@users.noreply.github.com": "webtecnica", # PR #63360 salvage (nous: restore inference-api base_url)

View file

@ -708,17 +708,41 @@ class TestBuildContextFilesPrompt:
assert "Ruff for linting" in result
assert "Project Context" in result
def test_skips_agents_md_in_install_tree(self, monkeypatch, tmp_path):
# A backend launched from, or self-spawning into, the install tree must not
# load that tree's contributor AGENTS.md as project context. The guard keys
# off the package root, so point it at a fake tree holding an AGENTS.md.
def test_skips_agents_md_in_install_tree_fallback_only(self, monkeypatch, tmp_path):
# A backend with NO configured cwd falls back to os.getcwd(); when that
# accident lands in the install tree, its contributor AGENTS.md must not
# load. The guard keys off the package root, so point it at a fake tree.
import agent.runtime_cwd as rt
monkeypatch.setattr(rt, "_PACKAGE_ROOT", tmp_path.resolve())
(tmp_path / "AGENTS.md").write_text("Never give up on the right solution.")
monkeypatch.chdir(tmp_path)
result = build_context_files_prompt(cwd=None, skip_soul=True)
assert "Never give up" not in result
assert result == ""
def test_explicit_install_tree_cwd_still_loads_agents_md(self, monkeypatch, tmp_path):
# A developer deliberately working ON the Hermes checkout (explicit
# session cwd / TERMINAL_CWD / launch dir passed by the caller) must
# keep its AGENTS.md — the guard applies ONLY to the cwd=None fallback.
import agent.runtime_cwd as rt
monkeypatch.setattr(rt, "_PACKAGE_ROOT", tmp_path.resolve())
(tmp_path / "AGENTS.md").write_text("Never give up on the right solution.")
result = build_context_files_prompt(cwd=str(tmp_path), skip_soul=True)
assert "Never give up" not in result
assert result == ""
assert "Never give up" in result
def test_explicit_worktree_under_install_tree_loads_agents_md(self, monkeypatch, tmp_path):
# Dev worktrees live at <root>/.worktrees/<name> — inside the package
# root, but explicitly chosen. They keep their project context too.
import agent.runtime_cwd as rt
monkeypatch.setattr(rt, "_PACKAGE_ROOT", tmp_path.resolve())
wt = tmp_path / ".worktrees" / "wt-1"
wt.mkdir(parents=True)
(wt / "AGENTS.md").write_text("Worktree context loads.")
result = build_context_files_prompt(cwd=str(wt), skip_soul=True)
assert "Worktree context loads." in result
def test_loads_cursorrules(self, tmp_path):
(tmp_path / ".cursorrules").write_text("Always use type hints.")

View file

@ -66,19 +66,26 @@ class TestResolveContextCwd:
monkeypatch.delenv("TERMINAL_CWD", raising=False)
assert resolve_context_cwd() is None
def test_returns_none_for_nonexistent_dir(self, monkeypatch, tmp_path):
# A configured but missing dir must not be returned. It previously was,
# which diverged from resolve_agent_cwd and let an invalid cwd steer
# context discovery. Now it is validated and drops to None.
def test_returns_nonexistent_dir_with_warning(self, monkeypatch, tmp_path, caplog):
# Deliberate asymmetry vs resolve_agent_cwd: an explicitly configured
# path is honored even when missing (discovery just finds nothing), but
# it now WARNS so a typo'd terminal.cwd is visible in the logs instead
# of silently resolving somewhere else (#64590).
import logging
missing = tmp_path / "gone"
monkeypatch.setenv("TERMINAL_CWD", str(missing))
assert resolve_context_cwd() is None
with caplog.at_level(logging.WARNING, logger="agent.runtime_cwd"):
assert resolve_context_cwd() == missing
assert any("does not exist" in r.message for r in caplog.records)
def test_returns_none_for_install_tree(self, monkeypatch):
# Context discovery must never resolve to the Hermes install/source tree,
# whose contributor AGENTS.md would take over the system prompt.
def test_returns_install_tree_when_explicitly_configured(self, monkeypatch):
# An EXPLICIT TERMINAL_CWD pointing at the Hermes checkout is honored —
# developers working ON hermes-agent want its AGENTS.md. Only the
# unconfigured os.getcwd() fallback in build_context_files_prompt is
# guarded against the install tree (#64590).
monkeypatch.setenv("TERMINAL_CWD", str(rt._PACKAGE_ROOT))
assert resolve_context_cwd() is None
assert resolve_context_cwd() == rt._PACKAGE_ROOT
def test_expands_leading_tilde(self, monkeypatch):
monkeypatch.setenv("TERMINAL_CWD", "~")
@ -134,3 +141,92 @@ class TestSessionCwdOverride:
assert resolve_agent_cwd() == tmp_path
finally:
rt._SESSION_CWD.reset(token)
class TestIsInstallTree:
def test_package_root_itself(self):
assert rt.is_install_tree(rt._PACKAGE_ROOT) is True
def test_path_inside_package_root(self):
assert rt.is_install_tree(rt._PACKAGE_ROOT / "agent") is True
def test_ancestor_of_package_root_is_not_install_tree(self):
# A user home that happens to contain the checkout is a legitimate
# workspace and must not be blocked.
assert rt.is_install_tree(rt._PACKAGE_ROOT.parent) is False
def test_unrelated_path(self, tmp_path):
assert rt.is_install_tree(tmp_path) is False
class TestResolveContextFilesCwd:
"""Surface-aware discovery cwd (#64590): explicit config always wins; only
daemon surfaces suppress the launch-dir fallback."""
def test_configured_cwd_wins_on_any_surface(self, monkeypatch, tmp_path):
from agent.system_prompt import resolve_context_files_cwd
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
assert resolve_context_files_cwd("tui") == str(tmp_path)
assert resolve_context_files_cwd("cli") == str(tmp_path)
def test_cli_falls_back_to_launch_dir(self, monkeypatch, tmp_path):
from agent.system_prompt import resolve_context_files_cwd
monkeypatch.delenv("TERMINAL_CWD", raising=False)
monkeypatch.chdir(tmp_path)
assert resolve_context_files_cwd("cli") == str(tmp_path)
# Unset platform defaults to the CLI contract.
assert resolve_context_files_cwd(None) == str(tmp_path)
def test_daemon_surfaces_return_none_when_unconfigured(self, monkeypatch, tmp_path):
from agent.system_prompt import resolve_context_files_cwd
monkeypatch.delenv("TERMINAL_CWD", raising=False)
monkeypatch.chdir(tmp_path)
for platform in ("tui", "telegram", "discord", "cron", "desktop"):
assert resolve_context_files_cwd(platform) is None
def test_daemon_in_install_tree_end_to_end_skips_agents_md(self, monkeypatch, tmp_path):
# E2E through the discovery path: a daemon surface with no configured
# cwd, process cwd inside the (fake) install tree → no project context.
from agent.prompt_builder import build_context_files_prompt
from agent.system_prompt import resolve_context_files_cwd
monkeypatch.setattr(rt, "_PACKAGE_ROOT", tmp_path.resolve())
(tmp_path / "AGENTS.md").write_text("contributor guide")
monkeypatch.delenv("TERMINAL_CWD", raising=False)
monkeypatch.chdir(tmp_path)
result = build_context_files_prompt(
cwd=resolve_context_files_cwd("tui"), skip_soul=True
)
assert result == ""
def test_cli_in_install_tree_end_to_end_loads_agents_md(self, monkeypatch, tmp_path):
# The interactive CLI launched from the checkout keeps its AGENTS.md.
from agent.prompt_builder import build_context_files_prompt
from agent.system_prompt import resolve_context_files_cwd
monkeypatch.setattr(rt, "_PACKAGE_ROOT", tmp_path.resolve())
(tmp_path / "AGENTS.md").write_text("contributor guide")
monkeypatch.delenv("TERMINAL_CWD", raising=False)
monkeypatch.chdir(tmp_path)
result = build_context_files_prompt(
cwd=resolve_context_files_cwd("cli"), skip_soul=True
)
assert "contributor guide" in result
def test_explicit_terminal_cwd_install_tree_end_to_end_loads_agents_md(
self, monkeypatch, tmp_path
):
# Explicit TERMINAL_CWD at the checkout: even a daemon surface loads it.
from agent.prompt_builder import build_context_files_prompt
from agent.system_prompt import resolve_context_files_cwd
monkeypatch.setattr(rt, "_PACKAGE_ROOT", tmp_path.resolve())
(tmp_path / "AGENTS.md").write_text("contributor guide")
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
result = build_context_files_prompt(
cwd=resolve_context_files_cwd("tui"), skip_soul=True
)
assert "contributor guide" in result