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