diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 22681b2d265..62cce495fb9 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -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 = "" diff --git a/agent/runtime_cwd.py b/agent/runtime_cwd.py index 6485e53a5da..72c655aafae 100644 --- a/agent/runtime_cwd.py +++ b/agent/runtime_cwd.py @@ -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 /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 diff --git a/agent/system_prompt.py b/agent/system_prompt.py index 17959af3c51..e3a6d270b40 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -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) diff --git a/scripts/release.py b/scripts/release.py index 96b4b2fe606..8d1f5f246e1 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -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) diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 860a50ab4b2..b3b713b9b14 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -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 /.worktrees/ — 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.") diff --git a/tests/agent/test_runtime_cwd.py b/tests/agent/test_runtime_cwd.py index 94325e26566..bc5d22b802a 100644 --- a/tests/agent/test_runtime_cwd.py +++ b/tests/agent/test_runtime_cwd.py @@ -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