mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
5e2d7a449d
commit
e7d6840989
6 changed files with 202 additions and 51 deletions
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue