mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-09 03:11:58 +00:00
feat: enrich system-prompt environment hints with host + terminal-backend info
build_environment_hints() now emits a factual block describing the execution environment on every prompt build: * Local backend: host OS, $HOME, and cwd — so the agent stops guessing paths from the hostname. Windows also gets two specific callouts: - hostname != username (prevents C:\Users\<hostname>\... bugs) - `terminal` shells out to bash (git-bash/MSYS), not PowerShell * Remote backend (docker/singularity/modal/daytona/ssh/vercel_sandbox): host info is SUPPRESSED — the agent's tools can't touch the host, so showing it is misleading. Instead we probe the backend once per process with `uname/whoami/pwd` and cache the result. On probe failure, fall back to a per-backend description that states only what we know from the backend choice itself (container type + likely OS family) without inventing user/cwd/$HOME. Linux/Mac local users now get a small helpful 3-line host block instead of an empty string. Zero change to the existing WSL hint paragraph. Tests: 8 new/updated in TestEnvironmentHints, including a regression guard that fails if a new remote backend is added without listing it in _REMOTE_TERMINAL_BACKENDS.
This commit is contained in:
parent
3be853a9b8
commit
40e7a71c35
2 changed files with 297 additions and 4 deletions
|
|
@ -584,13 +584,215 @@ WSL_ENVIRONMENT_HINT = (
|
|||
)
|
||||
|
||||
|
||||
# Non-local terminal backends that run commands (and therefore every file
|
||||
# tool: read_file, write_file, patch, search_files) inside a separate
|
||||
# container / remote host rather than on the machine where Hermes itself
|
||||
# runs. For these backends, host info (Windows/Linux/macOS, $HOME, cwd) is
|
||||
# misleading — the agent should only see the machine it can actually touch.
|
||||
_REMOTE_TERMINAL_BACKENDS = frozenset({
|
||||
"docker", "singularity", "modal", "daytona", "ssh",
|
||||
"vercel_sandbox", "managed_modal",
|
||||
})
|
||||
|
||||
|
||||
# Per-backend fallback descriptions — used when the live probe fails.
|
||||
# Only states what we know from the backend choice itself (container type,
|
||||
# likely OS family). Does NOT invent cwd, user, or $HOME — the agent is
|
||||
# told to probe those directly if it needs them.
|
||||
_BACKEND_FALLBACK_DESCRIPTIONS: dict[str, str] = {
|
||||
"docker": "a Docker container (Linux)",
|
||||
"singularity": "a Singularity container (Linux)",
|
||||
"modal": "a Modal sandbox (Linux)",
|
||||
"managed_modal": "a managed Modal sandbox (Linux)",
|
||||
"daytona": "a Daytona workspace (Linux)",
|
||||
"vercel_sandbox": "a Vercel sandbox (Linux)",
|
||||
"ssh": "a remote host reached over SSH (likely Linux)",
|
||||
}
|
||||
|
||||
|
||||
# Cache the backend probe result per process so we only pay the probe cost
|
||||
# on the first prompt build of a session. Keyed by (env_type, cwd_hint) so
|
||||
# a mid-process backend switch rebuilds the string. Kept in-module (not on
|
||||
# disk) because the probe captures live backend state that may change
|
||||
# across Hermes restarts.
|
||||
_BACKEND_PROBE_CACHE: dict[tuple[str, str], str] = {}
|
||||
|
||||
|
||||
_WINDOWS_BASH_SHELL_HINT = (
|
||||
"Shell: on this Windows host your `terminal` tool runs commands through "
|
||||
"bash (git-bash / MSYS), NOT PowerShell or cmd.exe. Use POSIX shell "
|
||||
"syntax (`ls`, `$HOME`, `&&`, `|`, single-quoted strings) inside terminal "
|
||||
"calls. MSYS-style paths like `/c/Users/<user>/...` work alongside "
|
||||
"native `C:\\Users\\<user>\\...` paths. PowerShell builtins "
|
||||
"(`Get-ChildItem`, `$env:FOO`, `Select-String`) will NOT work — use their "
|
||||
"POSIX equivalents (`ls`, `$FOO`, `grep`)."
|
||||
)
|
||||
|
||||
|
||||
def _probe_remote_backend(env_type: str) -> str | None:
|
||||
"""Run a tiny introspection command inside the active terminal backend.
|
||||
|
||||
Returns a pre-formatted multi-line string describing the backend's OS,
|
||||
$HOME, cwd, and user — or None if the probe failed. Result is cached
|
||||
per process. Used only for non-local backends where the agent's tools
|
||||
operate on a different machine than the host Hermes runs on.
|
||||
"""
|
||||
cwd_hint = os.getenv("TERMINAL_CWD", "")
|
||||
cache_key = (env_type, cwd_hint)
|
||||
cached = _BACKEND_PROBE_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached or None
|
||||
|
||||
try:
|
||||
# Import locally: tools/ imports are heavy and only relevant when a
|
||||
# non-local backend is actually configured.
|
||||
from tools.terminal_tool import _get_env_config # type: ignore
|
||||
from tools.environments import get_environment # type: ignore
|
||||
except Exception as e:
|
||||
logger.debug("Backend probe unavailable (import failed): %s", e)
|
||||
_BACKEND_PROBE_CACHE[cache_key] = ""
|
||||
return None
|
||||
|
||||
try:
|
||||
config = _get_env_config()
|
||||
env = get_environment(config)
|
||||
# Single-line POSIX probe — works on any Unixy backend. Wrapped in
|
||||
# `2>/dev/null` so a missing binary doesn't pollute the output.
|
||||
probe_cmd = (
|
||||
"printf 'os=%s\\nkernel=%s\\nhome=%s\\ncwd=%s\\nuser=%s\\n' "
|
||||
"\"$(uname -s 2>/dev/null || echo unknown)\" "
|
||||
"\"$(uname -r 2>/dev/null || echo unknown)\" "
|
||||
"\"$HOME\" \"$(pwd)\" \"$(whoami 2>/dev/null || id -un 2>/dev/null || echo unknown)\""
|
||||
)
|
||||
result = env.execute(probe_cmd, timeout=4)
|
||||
if result.get("returncode") != 0:
|
||||
logger.debug("Backend probe returned non-zero: %r", result)
|
||||
_BACKEND_PROBE_CACHE[cache_key] = ""
|
||||
return None
|
||||
output = (result.get("output") or "").strip()
|
||||
if not output:
|
||||
_BACKEND_PROBE_CACHE[cache_key] = ""
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("Backend probe failed: %s", e)
|
||||
_BACKEND_PROBE_CACHE[cache_key] = ""
|
||||
return None
|
||||
|
||||
# Parse key=value lines back into a tidy summary.
|
||||
parsed: dict[str, str] = {}
|
||||
for line in output.splitlines():
|
||||
if "=" in line:
|
||||
k, _, v = line.partition("=")
|
||||
parsed[k.strip()] = v.strip()
|
||||
|
||||
pieces = []
|
||||
os_bits = " ".join(x for x in (parsed.get("os"), parsed.get("kernel")) if x and x != "unknown")
|
||||
if os_bits:
|
||||
pieces.append(f"OS: {os_bits}")
|
||||
if parsed.get("user") and parsed["user"] != "unknown":
|
||||
pieces.append(f"User: {parsed['user']}")
|
||||
if parsed.get("home"):
|
||||
pieces.append(f"Home: {parsed['home']}")
|
||||
if parsed.get("cwd"):
|
||||
pieces.append(f"Working directory: {parsed['cwd']}")
|
||||
|
||||
if not pieces:
|
||||
_BACKEND_PROBE_CACHE[cache_key] = ""
|
||||
return None
|
||||
|
||||
formatted = "\n".join(f" {p}" for p in pieces)
|
||||
_BACKEND_PROBE_CACHE[cache_key] = formatted
|
||||
return formatted
|
||||
|
||||
|
||||
def _clear_backend_probe_cache() -> None:
|
||||
"""Test helper — drop the backend probe cache so monkeypatched backends take effect."""
|
||||
_BACKEND_PROBE_CACHE.clear()
|
||||
|
||||
|
||||
def build_environment_hints() -> str:
|
||||
"""Return environment-specific guidance for the system prompt.
|
||||
|
||||
Detects WSL, and can be extended for Termux, Docker, etc.
|
||||
Returns an empty string when no special environment is detected.
|
||||
Always emits a factual block describing the execution environment:
|
||||
- For **local** terminal backends: the host OS, user home, current
|
||||
working directory (plus a Windows-only note about hostname != user
|
||||
and a Windows-only note that `terminal` shells out to bash, not
|
||||
PowerShell).
|
||||
- For **remote / sandbox** terminal backends (docker, singularity,
|
||||
modal, daytona, ssh, vercel_sandbox): host info is **suppressed**
|
||||
because the agent's tools can't touch the host — only the backend
|
||||
matters. A live probe inside the backend reports its OS, user, $HOME,
|
||||
and cwd. Falls back to a static summary if the probe fails.
|
||||
|
||||
The WSL environment hint is appended unchanged when running under WSL.
|
||||
"""
|
||||
import platform
|
||||
import sys
|
||||
|
||||
hints: list[str] = []
|
||||
|
||||
backend = (os.getenv("TERMINAL_ENV") or "local").strip().lower()
|
||||
is_remote_backend = backend in _REMOTE_TERMINAL_BACKENDS
|
||||
|
||||
if not is_remote_backend:
|
||||
# --- Host info block (local backend: host == where tools run) ---
|
||||
host_lines: list[str] = []
|
||||
if is_wsl():
|
||||
host_lines.append("Host: WSL (Windows Subsystem for Linux)")
|
||||
elif sys.platform == "win32":
|
||||
host_lines.append(f"Host: Windows ({platform.release()})")
|
||||
elif sys.platform == "darwin":
|
||||
mac_ver = platform.mac_ver()[0]
|
||||
host_lines.append(f"Host: macOS ({mac_ver or platform.release()})")
|
||||
else:
|
||||
host_lines.append(f"Host: {platform.system()} ({platform.release()})")
|
||||
|
||||
host_lines.append(f"User home directory: {os.path.expanduser('~')}")
|
||||
try:
|
||||
host_lines.append(f"Current working directory: {os.getcwd()}")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if sys.platform == "win32" and not is_wsl():
|
||||
host_lines.append(
|
||||
"Note: on Windows, the machine hostname (e.g. from `hostname` "
|
||||
"or uname) is NOT the username. Use the 'User home directory' "
|
||||
"above to construct paths under C:\\Users\\<user>\\, never the "
|
||||
"hostname."
|
||||
)
|
||||
hints.append("\n".join(host_lines))
|
||||
|
||||
# Windows-local terminal runs bash, not PowerShell — the model must
|
||||
# know this or it will issue PowerShell syntax and fail.
|
||||
if sys.platform == "win32" and not is_wsl():
|
||||
hints.append(_WINDOWS_BASH_SHELL_HINT)
|
||||
else:
|
||||
# --- Remote backend block (host info suppressed) ---
|
||||
probe = _probe_remote_backend(backend)
|
||||
if probe:
|
||||
hints.append(
|
||||
f"Terminal backend: {backend}. Your `terminal`, `read_file`, "
|
||||
f"`write_file`, `patch`, and `search_files` tools all operate "
|
||||
f"inside this {backend} environment — NOT on the machine "
|
||||
f"where Hermes itself is running. The host OS, home, and cwd "
|
||||
f"of the Hermes process are irrelevant; only the following "
|
||||
f"backend state matters:\n{probe}"
|
||||
)
|
||||
else:
|
||||
description = _BACKEND_FALLBACK_DESCRIPTIONS.get(
|
||||
backend, f"a {backend} environment (likely Linux)"
|
||||
)
|
||||
hints.append(
|
||||
f"Terminal backend: {backend}. Your `terminal`, `read_file`, "
|
||||
f"`write_file`, `patch`, and `search_files` tools all operate "
|
||||
f"inside {description} — NOT on the machine where Hermes "
|
||||
f"itself runs. The backend probe didn't respond at "
|
||||
f"prompt-build time, so the sandbox's current user, $HOME, "
|
||||
f"and working directory are unknown from here. If you need "
|
||||
f"them, probe directly with a terminal call like "
|
||||
f"`uname -a && whoami && pwd`."
|
||||
)
|
||||
|
||||
if is_wsl():
|
||||
hints.append(WSL_ENVIRONMENT_HINT)
|
||||
return "\n\n".join(hints)
|
||||
|
|
|
|||
|
|
@ -839,15 +839,106 @@ class TestEnvironmentHints:
|
|||
def test_build_environment_hints_on_wsl(self, monkeypatch):
|
||||
import agent.prompt_builder as _pb
|
||||
monkeypatch.setattr(_pb, "is_wsl", lambda: True)
|
||||
monkeypatch.delenv("TERMINAL_ENV", raising=False)
|
||||
_pb._clear_backend_probe_cache()
|
||||
result = _pb.build_environment_hints()
|
||||
assert "/mnt/" in result
|
||||
assert "WSL" in result
|
||||
# WSL block still carries the always-on host info ahead of it.
|
||||
assert "User home directory:" in result
|
||||
|
||||
def test_build_environment_hints_not_wsl(self, monkeypatch):
|
||||
def test_build_environment_hints_on_linux_local(self, monkeypatch):
|
||||
import agent.prompt_builder as _pb
|
||||
import sys, platform
|
||||
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
monkeypatch.setattr(platform, "system", lambda: "Linux")
|
||||
monkeypatch.setattr(platform, "release", lambda: "6.8.0-generic")
|
||||
monkeypatch.delenv("TERMINAL_ENV", raising=False)
|
||||
_pb._clear_backend_probe_cache()
|
||||
result = _pb.build_environment_hints()
|
||||
assert result != ""
|
||||
assert "Host: Linux" in result
|
||||
assert "6.8.0-generic" in result
|
||||
assert "User home directory:" in result
|
||||
assert "Current working directory:" in result
|
||||
# Linux must NOT get the Windows-specific callouts.
|
||||
assert "PowerShell" not in result
|
||||
assert "hostname" not in result
|
||||
assert "WSL" not in result
|
||||
|
||||
def test_build_environment_hints_on_windows_local(self, monkeypatch):
|
||||
import agent.prompt_builder as _pb
|
||||
import sys
|
||||
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
monkeypatch.delenv("TERMINAL_ENV", raising=False)
|
||||
_pb._clear_backend_probe_cache()
|
||||
result = _pb.build_environment_hints()
|
||||
assert "Host: Windows" in result
|
||||
assert "User home directory:" in result
|
||||
# Two Windows-specific callouts that must ALWAYS appear together:
|
||||
# hostname warning + bash-not-PowerShell warning.
|
||||
assert "hostname" in result
|
||||
assert "NOT the username" in result
|
||||
assert "bash" in result
|
||||
assert "PowerShell" in result
|
||||
|
||||
def test_build_environment_hints_on_macos_local(self, monkeypatch):
|
||||
import agent.prompt_builder as _pb
|
||||
import sys
|
||||
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
|
||||
monkeypatch.setattr(sys, "platform", "darwin")
|
||||
monkeypatch.delenv("TERMINAL_ENV", raising=False)
|
||||
_pb._clear_backend_probe_cache()
|
||||
result = _pb.build_environment_hints()
|
||||
assert "Host: macOS" in result
|
||||
assert "User home directory:" in result
|
||||
# macOS must NOT get the Windows-specific callouts.
|
||||
assert "PowerShell" not in result
|
||||
assert "hostname" not in result
|
||||
|
||||
def test_build_environment_hints_suppresses_host_on_docker_backend(self, monkeypatch):
|
||||
"""Docker/remote backends must hide host info — the agent can only touch the backend."""
|
||||
import agent.prompt_builder as _pb
|
||||
import sys
|
||||
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
||||
# Force the probe to fail so we exercise the static fallback path
|
||||
# deterministically (the live probe would try to spin up docker).
|
||||
monkeypatch.setattr(_pb, "_probe_remote_backend", lambda _t: None)
|
||||
_pb._clear_backend_probe_cache()
|
||||
result = _pb.build_environment_hints()
|
||||
# Host suppression: none of the local-backend lines should appear.
|
||||
assert "Host: Windows" not in result
|
||||
assert "User home directory:" not in result
|
||||
assert "PowerShell" not in result
|
||||
# Backend info must appear instead.
|
||||
assert "Terminal backend: docker" in result
|
||||
assert "inside" in result.lower()
|
||||
|
||||
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
|
||||
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
|
||||
monkeypatch.setenv("TERMINAL_ENV", "modal")
|
||||
fake_probe_output = " OS: Linux 6.8.0\n User: root\n Home: /root\n Working directory: /workspace"
|
||||
monkeypatch.setattr(_pb, "_probe_remote_backend", lambda _t: fake_probe_output)
|
||||
_pb._clear_backend_probe_cache()
|
||||
result = _pb.build_environment_hints()
|
||||
assert result == ""
|
||||
assert "Terminal backend: modal" in result
|
||||
assert "Linux 6.8.0" in result
|
||||
assert "/workspace" in result
|
||||
|
||||
def test_remote_backend_list_covers_known_sandboxes(self):
|
||||
"""Regression guard: if someone adds a remote backend, they must list it here."""
|
||||
import agent.prompt_builder as _pb
|
||||
for backend in ("docker", "singularity", "modal", "daytona", "ssh", "vercel_sandbox"):
|
||||
assert backend in _pb._REMOTE_TERMINAL_BACKENDS, (
|
||||
f"{backend!r} must be in _REMOTE_TERMINAL_BACKENDS so its host "
|
||||
f"info is suppressed in the system prompt"
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue