NousResearch/hermes-agent

Working directory configuration fixes across components

Cluster 29265 · agent. Data as of

Nothing open on GitHub. Fix merged in #35028 on 1 Jun 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #35028. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

These items address how the working directory is determined and respected in gateway sessions, agent resolution, and the local backend CLI. They span a feature request and several bug fixes across different parts of the system.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #35028 fix(agent): resolve agent cwd from TERMINAL_CWD via one reader (closes #24882, #24969, #27383)

    merged to main

    files: agent/prompt_builder.py, agent/runtime_cwd.py, agent/system_prompt.py, tests/agent/test_prompt_builder.py, tests/agent/test_runtime_cwd.py, tests/agent/test_system_prompt.py, tests/tools/test_gateway_cwd_contract.py

    Show the diff
    • agent/prompt_builder.py
      @@ -14,6 +14,7 @@
       from hermes_constants import get_hermes_home, get_skills_dir, is_wsl
       from typing import Optional
       
      +from agent.runtime_cwd import resolve_agent_cwd
       from agent.skill_utils import (
           extract_skill_conditions,
           extract_skill_description,
      @@ -802,7 +803,7 @@ def build_environment_hints() -> str:
       
               host_lines.append(f"User home directory: {os.path.expanduser('~')}")
               try:
      -            host_lines.append(f"Current working directory: {os.getcwd()}")
      +            host_lines.append(f"Current working directory: {resolve_agent_cwd()}")
               except OSError:
                   pass
       
    • agent/runtime_cwd.py
      @@ -0,0 +1,33 @@
      +"""Single source of truth for the agent working directory.
      +
      +`TERMINAL_CWD` is the runtime carrier for the configured working directory
      +(design #19214/#19242: `terminal.cwd` is bridged once to `TERMINAL_CWD` at
      +gateway/cron startup). The local-CLI backend deliberately leaves it unset and
      +relies on the launch dir. Reading it in one place keeps the system prompt, the
      +tool surfaces, and context-file discovery agreeing on where the agent lives.
      +
      +The #29531 per-session extension point is this function: a future PR adds a
      +contextvar arm inside `resolve_agent_cwd` and `.set()`s it at the
      +`set_session_vars` seam — by design, not a reopening hazard.
      +"""
      +
      +import os
      +from pathlib import Path
      +
      +
      +def resolve_agent_cwd() -> Path:
      +    raw = os.environ.get("TERMINAL_CWD", "").strip()
      +    if raw:
      +        p = Path(raw).expanduser()
      +        if p.is_dir():
      +            return p
      +    return Path(os.getcwd())
      +
      +
      +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 the local CLI. The gateway
      +    # avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py).
      +    # No getcwd arm here: that fallback is owned by the caller, not this resolver.
      +    raw = os.environ.get("TERMINAL_CWD", "").strip()
      +    return Path(raw).expanduser() if raw else None
    • agent/system_prompt.py
      @@ -24,7 +24,6 @@
       from __future__ import annotations
       
       import json
      -import os
       from typing import Any, Dict, List, Optional
       
       from agent.prompt_builder import (
      @@ -41,6 +40,7 @@
           TOOL_USE_ENFORCEMENT_GUIDANCE,
           TOOL_USE_ENFORCEMENT_MODELS,
       )
      +from agent.runtime_cwd import resolve_context_cwd
       
       
       def _ra():
      @@ -288,13 +288,12 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
               context_parts.append(system_message)
       
           if not agent.skip_context_files:
      -        # Use TERMINAL_CWD for context file discovery when set (gateway
      -        # mode).  The gateway process runs from the hermes-agent install
      -        # dir, so os.getcwd() would pick up the repo's AGENTS.md and
      -        # other dev files — inflating token usage by ~10k for no benefit.
      -        _context_cwd = os.getenv("TERMINAL_CWD") or None
      +        # 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.
               context_files_prompt = _r.build_context_files_prompt(
      -            cwd=_context_cwd, skip_soul=_soul_loaded)
      +            cwd=resolve_context_cwd(), skip_soul=_soul_loaded)
               if context_files_prompt:
                   context_parts.append(context_files_prompt)
       
    • tests/agent/test_prompt_builder.py
      @@ -927,6 +927,29 @@ def test_build_environment_hints_suppresses_host_on_docker_backend(self, monkeyp
               assert "Terminal backend: docker" in result
               assert "inside" in result.lower()
       
      +    def test_build_environment_hints_uses_terminal_cwd_over_launch_dir(self, monkeypatch, tmp_path):
      +        """THE BUG: gateway/cron set TERMINAL_CWD but the prompt emitted os.getcwd()
      +        (the daemon launch dir). Regression for #24882/#24969/#27383/#29265."""
      +        import agent.prompt_builder as _pb
      +        monkeypatch.setattr(_pb, "is_wsl", lambda: False)
      +        monkeypatch.delenv("TERMINAL_ENV", raising=False)
      +        configured = tmp_path / "workspace"
      +        configured.mkdir()
      +        monkeypatch.setenv("TERMINAL_CWD", str(configured))
      +        monkeypatch.chdir(tmp_path)
      +        _pb._clear_backend_probe_cache()
      +        assert f"Current working directory: {configured}" in _pb.build_environment_hints()
      +
      +    def test_build_environment_hints_falls_back_to_launch_dir(self, monkeypatch, tmp_path):
      +        """The #19242 local-CLI contract: no TERMINAL_CWD → the launch dir."""
      +        import agent.prompt_builder as _pb
      +        monkeypatch.setattr(_pb, "is_wsl", lambda: False)
      +        monkeypatch.delenv("TERMINAL_ENV", raising=False)
      +        monkeypatch.delenv("TERMINAL_CWD", raising=False)
      +        monkeypatch.chdir(tmp_path)
      +        _pb._clear_backend_probe_cache()
      +        assert f"Current working directory: {tmp_path}" in _pb.build_environment_hints()
      +
           def test_build_environment_hints_uses_live_probe_when_available(self, monkeypatch):
               """When the probe succeeds, its output must appear in the hint block."""
               import agent.prompt_builder as _pb
      @@ -1247,4 +1270,3 @@ def test_guidance_is_string(self):
       # =========================================================================
       
       
      -
    • tests/agent/test_runtime_cwd.py
      @@ -0,0 +1,79 @@
      +"""Tests for agent/runtime_cwd.py — the single source of truth for the agent working directory."""
      +
      +import os
      +from pathlib import Path
      +
      +import pytest
      +
      +import agent.runtime_cwd as rt
      +from agent.runtime_cwd import resolve_agent_cwd, resolve_context_cwd
      +
      +
      +def _raise_oserror(*args, **kwargs):
      +    raise OSError("cwd gone")
      +
      +
      +class TestResolveAgentCwd:
      +    def test_prefers_terminal_cwd_over_getcwd(self, monkeypatch, tmp_path):
      +        monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
      +        monkeypatch.chdir(os.path.expanduser("~"))
      +        assert resolve_agent_cwd() == tmp_path
      +
      +    def test_falls_back_to_getcwd_when_unset(self, monkeypatch, tmp_path):
      +        # The #19242 local-CLI contract: TERMINAL_CWD is unset, so the launch dir wins.
      +        monkeypatch.delenv("TERMINAL_CWD", raising=False)
      +        monkeypatch.chdir(tmp_path)
      +        assert resolve_agent_cwd() == tmp_path
      +
      +    def test_skips_nonexistent_terminal_cwd(self, monkeypatch, tmp_path):
      +        monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "gone"))
      +        monkeypatch.chdir(tmp_path)
      +        assert resolve_agent_cwd() == tmp_path
      +
      +    def test_expands_leading_tilde(self, monkeypatch):
      +        monkeypatch.setenv("TERMINAL_CWD", "~")
      +        assert resolve_agent_cwd() == Path(os.path.expanduser("~"))
      +
      +    def test_whitespace_only_terminal_cwd_falls_back_to_getcwd(self, monkeypatch, tmp_path):
      +        # "   ".strip() → "" → falsy, so the launch dir wins (not a "   " path).
      +        monkeypatch.setenv("TERMINAL_CWD", "   ")
      +        monkeypatch.chdir(tmp_path)
      +        assert resolve_agent_cwd() == tmp_path
      +
      +    def test_propagates_oserror_from_getcwd(self, monkeypatch):
      +        # The fallback arm calls os.getcwd(), which can raise OSError (deleted cwd).
      +        # The resolver must NOT swallow it — build_environment_hints owns the
      +        # try/except OSError guard at the call site (prompt_builder.py:805).
      +        monkeypatch.delenv("TERMINAL_CWD", raising=False)
      +        monkeypatch.setattr(rt.os, "getcwd", _raise_oserror)
      +        with pytest.raises(OSError):
      +            resolve_agent_cwd()
      +
      +
      +class TestResolveContextCwd:
      +    def test_returns_dir_when_set(self, monkeypatch, tmp_path):
      +        monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
      +        assert resolve_context_cwd() == tmp_path
      +
      +    def test_returns_none_when_unset(self, monkeypatch):
      +        # Unset → None; the caller (build_context_files_prompt) then getcwds —
      +        # the local-CLI #19242 contract. Discovery still runs; it is NOT skipped.
      +        monkeypatch.delenv("TERMINAL_CWD", raising=False)
      +        assert resolve_context_cwd() is None
      +
      +    def test_returns_nonexistent_dir_unguarded(self, monkeypatch, tmp_path):
      +        # Deliberate asymmetry vs resolve_agent_cwd: context discovery has no isdir
      +        # guard, so a missing dir is returned (not None) — discovery just finds nothing.
      +        missing = tmp_path / "gone"
      +        monkeypatch.setenv("TERMINAL_CWD", str(missing))
      +        assert resolve_context_cwd() == missing
      +
      +    def test_expands_leading_tilde(self, monkeypatch):
      +        monkeypatch.setenv("TERMINAL_CWD", "~")
      +        assert resolve_context_cwd() == Path(os.path.expanduser("~"))
      +
      +    def test_whitespace_only_terminal_cwd_returns_none(self, monkeypatch):
      +        # "   ".strip() → "" → None, so the caller getcwds for discovery rather
      +        # than building Path("   ") and resolving garbage under the launch dir.
      +        monkeypatch.setenv("TERMINAL_CWD", "   ")
      +        assert resolve_context_cwd() is None
    • tests/agent/test_system_prompt.py
      @@ -0,0 +1,57 @@
      +"""Tests for agent/system_prompt.py — context-file cwd wiring."""
      +
      +from types import SimpleNamespace
      +from unittest.mock import patch
      +
      +from agent.system_prompt import build_system_prompt_parts
      +
      +
      +def _make_agent(**overrides):
      +    base = dict(
      +        load_soul_identity=False,
      +        skip_context_files=False,
      +        valid_tool_names=[],
      +        _task_completion_guidance=False,
      +        _tool_use_enforcement=False,
      +        _environment_probe=False,
      +        _kanban_worker_guidance="",
      +        _memory_store=None,
      +        _memory_manager=None,
      +        model="",
      +        provider="",
      +        platform="",
      +        pass_session_id=False,
      +        session_id="",
      +    )
      +    base.update(overrides)
      +    return SimpleNamespace(**base)
      +
      +
      +def _captured_context_cwd(agent):
      +    """The cwd build_system_prompt_parts hands to build_context_files_prompt."""
      +    captured = {}
      +
      +    def fake_context_files(cwd=None, skip_soul=False):
      +        captured["cwd"] = cwd
      +        return ""
      +
      +    with (
      +        patch("run_agent.load_soul_md", return_value=""),
      +        patch("run_agent.build_nous_subscription_prompt", return_value=""),
      +        patch("run_agent.build_environment_hints", return_value=""),
      +        patch("run_agent.build_context_files_prompt", side_effect=fake_context_files),
      +    ):
      +        build_system_prompt_parts(agent)
      +    return captured["cwd"]
      +
      +
      +class TestContextFileCwd:
      +    def test_none_when_terminal_cwd_unset(self, monkeypatch):
      +        # Unset → None, so discovery falls back to the launch dir inside
      +        # build_context_files_prompt (the local-CLI #19242 contract).
      +        monkeypatch.delenv("TERMINAL_CWD", raising=False)
      +        assert _captured_context_cwd(_make_agent()) is None
      +
      +    def test_configured_dir_when_terminal_cwd_set(self, monkeypatch, tmp_path):
      +        monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
      +        assert _captured_context_cwd(_make_agent()) == tmp_path
    • tests/tools/test_gateway_cwd_contract.py
      @@ -0,0 +1,69 @@
      +"""Tool-surface cwd contract tests for gateway workspaces.
      +
      +These cover the platform-neutral part of #29265: once the gateway has resolved
      +``TERMINAL_CWD``, the user-visible tool surfaces should agree on that workspace.
      +
      +Unlike the system-prompt readers fixed in the gateway-cwd-resolver cluster
      +(agent/runtime_cwd.py), these tool sites already read ``TERMINAL_CWD``-first and
      +were deliberately left out of scope. This file is a *characterization* guard: it
      +pins the already-correct behavior so the supersession of PR #29365 is airtight
      +and a future refactor of these sites can't silently regress the contract.
      +"""
      +
      +from __future__ import annotations
      +
      +from pathlib import Path
      +
      +from tools import code_execution_tool, file_tools, terminal_tool
      +
      +
      +def test_terminal_env_config_uses_terminal_cwd(monkeypatch, tmp_path):
      +    """The terminal tool's default cwd should come from TERMINAL_CWD."""
      +    workspace = tmp_path / "workspace"
      +    workspace.mkdir()
      +
      +    monkeypatch.setenv("TERMINAL_ENV", "local")
      +    monkeypatch.setenv("TERMINAL_CWD", str(workspace))
      +
      +    config = terminal_tool._get_env_config()
      +
      +    assert config["cwd"] == str(workspace)
      +
      +
      +def test_file_tool_relative_paths_use_terminal_cwd(monkeypatch, tmp_path):
      +    """Relative file/search/patch paths resolve under TERMINAL_CWD."""
      +    workspace = tmp_path / "workspace"
      +    workspace.mkdir()
      +
      +    monkeypatch.setenv("TERMINAL_CWD", str(workspace))
      +
      +    resolved = file_tools._resolve_path_for_task("notes/today.md", task_id="cwd-contract")
      +
      +    assert resolved == (workspace / "notes" / "today.md").resolve()
      +
      +
      +def test_execute_code_project_mode_uses_terminal_cwd(monkeypatch, tmp_path):
      +    """Project-mode execute_code should run scripts from TERMINAL_CWD."""
      +    workspace = tmp_path / "workspace"
      +    staging = tmp_path / "staging"
      +    workspace.mkdir()
      +    staging.mkdir()
      +
      +    monkeypatch.setenv("TERMINAL_CWD", str(workspace))
      +
      +    resolved = code_execution_tool._resolve_child_cwd("project", str(staging))
      +
      +    assert Path(resolved) == workspace
      +
      +
      +def test_execute_code_project_mode_falls_back_when_terminal_cwd_missing(monkeypatch, tmp_path):
      +    """Invalid TERMINAL_CWD should not break execute_code project mode startup."""
      +    staging = tmp_path / "staging"
      +    staging.mkdir()
      +
      +    monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "missing"))
      +
      +    resolved = code_execution_tool._resolve_child_cwd("project", str(staging))
      +
      +    assert Path(resolved).is_dir()
      +    assert Path(resolved) != tmp_path / "missing"

1 PR closed without merging

From the close comments: done elsewhere 1

  • #29365 fix(gateway): honor configured cwd in gateway sessions · done elsewhere → #29265, #35028
Closed issues · 1
  • Closed#29265[Feature]: Configurable default working directory for QQBot and Weixin gateway sessions
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#35028
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
1 Jun 2026

For your agent

The same facts, typed.