mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
fix(agent): a home working dir is a fallback, not a project
The packaged Desktop with no default project dir resolves its spawn cwd (and TERMINAL_CWD) to $HOME, and SubdirectoryHintTracker's containment check is then vacuously true for the whole home subtree: any AGENTS.md, CLAUDE.md or .cursorrules under ~ is injected into tool results on first touch. The tracker now treats a home (or container-home) working dir as a fallback, not a project — hint discovery stays off until the session adopts a real workspace, and _register_session_cwd re-anchors it there (same hook that already updates agent.session_cwd). AIAgent init also prefers the session's own cwd over the launch TERMINAL_CWD, so an agent built for a workspace session scopes hints to it from the start. .archive joins the excluded dir names (retired skills trees hold stale AGENTS.md copies). Rebases the home guard from #77145 (idea + guard direction) onto the current class shape; covers the install-tree shape #75922 targeted via the shared home guard. Fixes #76902 Ref: https://github.com/NousResearch/hermes-agent/issues/75839 Co-authored-by: Sahil-SS9 <218421507+Sahil-SS9@users.noreply.github.com>
This commit is contained in:
1 parent
d9a97ae517
commit
018d982bd9
5 files changed
+140
-2
No files matched your search
+5
-1
@@ -2303,8 +2303,12 @@ def _snapshot_primary_runtime(agent):
|
||||
|
||||
def _init_usage_state(agent):
|
||||
from agent.runtime_cwd import scope_terminal_cwd
|
||||
# Prefer the session's explicitly adopted workspace (a Desktop session created under the
|
||||
# spawn-time home pin records none; a picked/adopted one does — agent.session_cwd is set
|
||||
# at build time and on every workspace move). TERMINAL_CWD is the launch fallback.
|
||||
working_dir = getattr(agent, "session_cwd", None) or scope_terminal_cwd() or None
|
||||
agent._subdirectory_hints = SubdirectoryHintTracker(
|
||||
working_dir=scope_terminal_cwd() or None, enabled=not agent.skip_context_files)
|
||||
working_dir=working_dir, enabled=not agent.skip_context_files)
|
||||
_set_defaults(agent, _USAGE_STATE)
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ SEARCH_PRUNE_DIR_NAMES = frozenset({
|
||||
# Generated/build output.
|
||||
"build", "dist", "target", "out", "coverage",
|
||||
".next", ".turbo", ".parcel-cache", ".nuxt", ".svelte-kit",
|
||||
# Archived trees: stale copies of projects that used to be live workspaces
|
||||
# (~/.hermes/skills/.archive/ holds dozens of retired skills, each of which
|
||||
# can carry an AGENTS.md — #76902).
|
||||
".archive",
|
||||
# Python and package-manager caches.
|
||||
"__pycache__", ".cache", ".Trash", ".tox", ".nox", ".mypy_cache",
|
||||
".pytest_cache", ".ruff_cache", ".npm", ".yarn", ".pnpm-store",
|
||||
|
||||
@@ -110,6 +110,25 @@ def _nav_targets(cmd: str) -> list:
|
||||
return targets
|
||||
|
||||
|
||||
# A container cwd is not a host path: a docker/local session whose working dir is the backend
|
||||
# container's default /home (or /root) home must not start hint discovery either — the same
|
||||
# vacuous-containment hazard as the desktop home pin, just container-shaped (#76902).
|
||||
_CONTAINER_HOMES = frozenset({Path("/home"), Path("/root"), Path("/workspace")})
|
||||
|
||||
|
||||
def _is_home_like_working_dir(working_dir: Path) -> bool:
|
||||
"""True when ``working_dir`` is a home the desktop/container fell back to — not a project."""
|
||||
home = Path.home()
|
||||
if working_dir == home or working_dir in _CONTAINER_HOMES or working_dir.parent in _CONTAINER_HOMES:
|
||||
return True
|
||||
# Also the local-sandbox home mount: a docker backend binds the host home under a
|
||||
# container path whose basename stays the user name (e.g. /mnt/host-home/brooklyn).
|
||||
for part in working_dir.parts:
|
||||
if part in (".home", "host-home", "host_home"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class SubdirectoryHintTracker:
|
||||
"""Track which directories the agent visits and load hints on first access.
|
||||
|
||||
@@ -123,6 +142,13 @@ class SubdirectoryHintTracker:
|
||||
# tool results later — cron jobs relaying exact stdout leaked them to chat (#9441).
|
||||
self.enabled = enabled
|
||||
self.working_dir = Path(working_dir or os.getcwd()).resolve()
|
||||
# $HOME is not a project (#76902): the packaged Desktop app with no default project
|
||||
# dir configured resolves its cwd (and TERMINAL_CWD) to home, and the containment
|
||||
# check is then vacuously true for the whole home subtree — every AGENTS.md /
|
||||
# CLAUDE.md / .cursorrules under ~ would be injected on tool calls. Hint discovery
|
||||
# runs only inside a real project workspace; a home cwd only resumes when the
|
||||
# session adopts a real project (see ``rebind_working_dir``).
|
||||
self._home_is_working_dir = _is_home_like_working_dir(self.working_dir)
|
||||
# The working dir is pre-marked loaded (startup context handles it).
|
||||
self._loaded_dirs: Set[Path] = {self.working_dir}
|
||||
# Content digests already injected: the same file reached through
|
||||
@@ -133,10 +159,33 @@ class SubdirectoryHintTracker:
|
||||
if found and found[1]:
|
||||
self._loaded_digests.add(_digest(found[1]))
|
||||
|
||||
def rebind_working_dir(self, working_dir: str) -> None:
|
||||
"""Re-anchor hint discovery to *working_dir* (a session workspace adoption)."""
|
||||
try:
|
||||
new_dir = Path(working_dir).expanduser().resolve()
|
||||
if not new_dir.is_dir() or _is_home_like_working_dir(new_dir):
|
||||
return
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
return
|
||||
if new_dir == self.working_dir:
|
||||
self._home_is_working_dir = False
|
||||
return
|
||||
self.working_dir = new_dir
|
||||
self._home_is_working_dir = False
|
||||
# Fresh anchor, fresh bookkeeping: the new project's directories get their
|
||||
# own first-visit hints (startup never loaded context files for it).
|
||||
self._loaded_dirs = {self.working_dir}
|
||||
self._loaded_digests = set()
|
||||
found = _first_hint_file(self.working_dir)
|
||||
if found and found[1]:
|
||||
self._loaded_digests.add(_digest(found[1]))
|
||||
|
||||
def check_tool_call(self, tool_name: str, tool_args: Dict[str, Any]) -> Optional[str]:
|
||||
"""Return formatted hint text for newly visited directories, or None."""
|
||||
if not self.enabled:
|
||||
return None
|
||||
if self._home_is_working_dir:
|
||||
return None
|
||||
all_hints = [h for d in self._extract_directories(tool_name, tool_args) if (h := self._load_hints_for_directory(d))]
|
||||
return "\n\n" + "\n\n".join(all_hints) if all_hints else None
|
||||
|
||||
@@ -225,7 +274,7 @@ class SubdirectoryHintTracker:
|
||||
def _load_hints_for_directory(self, directory: Path) -> Optional[str]:
|
||||
"""Load the first hint file in *directory*; formatted text or None."""
|
||||
self._loaded_dirs.add(directory)
|
||||
if not self._within_working_dir(directory):
|
||||
if self._home_is_working_dir or not self._within_working_dir(directory):
|
||||
logger.debug("Skipping hint files in %s — outside working_dir %s", directory, self.working_dir)
|
||||
return None
|
||||
for filename in _HINT_FILENAMES:
|
||||
|
||||
@@ -411,3 +411,77 @@ class TestSymlinkedHintTargets:
|
||||
assert result is not None and "Shared in-tree instructions" in result
|
||||
result = tracker.check_tool_call("read_file", {"path": str(plain / "f.py")})
|
||||
assert result is not None and "Legit subdirectory rules" in result
|
||||
|
||||
|
||||
class TestHomeWorkingDirGuard:
|
||||
"""$HOME is not a project (#76902): the packaged Desktop with no default project dir
|
||||
pins cwd (and TERMINAL_CWD) to home, making containment vacuously true for the whole
|
||||
home subtree — every AGENTS.md/CLAUDE.md/.cursorrules under ~ would inject on tool calls."""
|
||||
|
||||
@pytest.fixture
|
||||
def home_tree(self, tmp_path, monkeypatch):
|
||||
"""A fake $HOME holding unrelated hint files across its subtree."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path)) # Path.home() reads os.environ on macOS fallback
|
||||
for rel in ("notes/AGENTS.md", ".hermes/AGENTS.md", ".hermes/skills/s1/AGENTS.md",
|
||||
"projects/whatever/CLAUDE.md"):
|
||||
p = tmp_path / rel
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(f"HOME-SUBTREE-HINT {rel}", encoding="utf-8")
|
||||
return tmp_path
|
||||
|
||||
def _home_tracker(self, home) -> SubdirectoryHintTracker:
|
||||
return SubdirectoryHintTracker(working_dir=str(home))
|
||||
|
||||
def test_home_cwd_never_injects_subtree_hints(self, home_tree):
|
||||
tracker = self._home_tracker(home_tree)
|
||||
assert tracker.check_tool_call(
|
||||
"read_file", {"path": str(home_tree / ".hermes" / "skills" / "s1" / "x.py")}
|
||||
) is None
|
||||
assert tracker.check_tool_call(
|
||||
"terminal", {"command": f"cat {home_tree / 'notes' / 'AGENTS.md'}"}
|
||||
) is None
|
||||
|
||||
def test_home_guard_blocks_direct_load_path(self, home_tree):
|
||||
"""check_tool_call suppression alone is not enough: _load_hints_for_directory
|
||||
(the other reachable entry) must refuse home-rooted loads too."""
|
||||
tracker = self._home_tracker(home_tree)
|
||||
assert tracker._load_hints_for_directory(home_tree / "notes") is None
|
||||
|
||||
def test_session_workspace_adoption_rebinds_discovery(self, home_tree):
|
||||
"""A home-pinned tracker whose session adopted a real project resumes hint
|
||||
discovery, scoped to that project."""
|
||||
project = home_tree / "projects" / "app"
|
||||
project.mkdir(parents=True)
|
||||
(project / "src").mkdir()
|
||||
(project / "src" / "AGENTS.md").write_text("App src rules", encoding="utf-8")
|
||||
|
||||
tracker = self._home_tracker(home_tree)
|
||||
assert tracker.check_tool_call("read_file", {"path": str(project / "src" / "f.py")}) is None
|
||||
tracker.rebind_working_dir(str(project))
|
||||
assert tracker.working_dir == project.resolve()
|
||||
result = tracker.check_tool_call("read_file", {"path": str(project / "src" / "f.py")})
|
||||
assert result is not None and "App src rules" in result
|
||||
|
||||
def test_rebound_tracker_stays_inside_the_project(self, home_tree):
|
||||
"""After rebind, hints outside the adopted project stay suppressed (a stray
|
||||
read of ~/.hermes does not resurrect the home-subtree scan)."""
|
||||
project = home_tree / "projects" / "app2"
|
||||
project.mkdir(parents=True)
|
||||
(project / "AGENTS.md").write_text("App2 rules", encoding="utf-8")
|
||||
|
||||
tracker = self._home_tracker(home_tree)
|
||||
tracker.rebind_working_dir(str(project))
|
||||
assert tracker.check_tool_call(
|
||||
"read_file", {"path": str(home_tree / ".hermes" / "skills" / "s1" / "x.py")}
|
||||
) is None
|
||||
|
||||
def test_real_project_cwd_keeps_discovery(self, home_tree):
|
||||
"""A non-home working dir keeps today's containment behavior."""
|
||||
project = home_tree / "projects" / "app3"
|
||||
sub = project / "pkg"
|
||||
sub.mkdir(parents=True)
|
||||
(sub / "AGENTS.md").write_text("Package rules", encoding="utf-8")
|
||||
|
||||
tracker = SubdirectoryHintTracker(working_dir=str(project))
|
||||
result = tracker.check_tool_call("read_file", {"path": str(sub / "f.py")})
|
||||
assert result is not None and "Package rules" in result
|
||||
@@ -340,6 +340,13 @@ def _register_session_cwd(session: dict | None) -> None:
|
||||
# Do not reinitialize memory providers or invalidate the cached system prompt.
|
||||
if hasattr(agent := session.get("agent"), "session_cwd"):
|
||||
agent.session_cwd = session.get("cwd") or None
|
||||
# A session that adopted a real workspace out of a home-fallback cwd (#76902: the
|
||||
# packaged Desktop pins $HOME when no default project dir is configured) resumes
|
||||
# subdirectory-hint discovery anchored to that project. No prompt/system-prompt
|
||||
# state changes — the tracker only scopes future tool-result hints.
|
||||
hints = getattr(agent, "_subdirectory_hints", None) if session.get("cwd") else None
|
||||
if hints is not None and hasattr(hints, "rebind_working_dir"):
|
||||
hints.rebind_working_dir(str(session.get("cwd")))
|
||||
with contextlib.suppress(Exception):
|
||||
from tools.terminal_tool import register_task_env_overrides
|
||||
cwd, cwd_source = _terminal_task_cwd_with_source(session)
|
||||
|
||||
Reference in new issue
Block a user