mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
An orphaned pip descendant holding the probe's inherited stdout/stderr pipe handles wedged subprocess.run's post-timeout communicate() (which joins the pipe reader threads with NO timeout on Windows). The warm probe thread then hung holding the module-level _CACHE_LOCK, so every new session's prompt build blocked indefinitely (#67964). Two layers: - _run(): replace subprocess.run with Popen + communicate(timeout); on TimeoutExpired kill the process TREE (taskkill /T on Windows) and reap the direct child bounded — never re-read the pipes, so an orphaned descendant holding them open can't block us. - get_environment_probe_line(): the probe now runs in a single background worker publishing via a threading.Event; callers wait at most _PROBE_WAIT_TIMEOUT (10s) then fail open with "". After one full timeout, later callers only peek. If the stuck worker ever finishes, its line resumes appearing in new prompts. Regression tests: hung probe with 4 concurrent callers returns bounded; late recovery publishes; repeat callers skip the wait; _run() returns promptly despite a pipe-holding descendant (real subprocess E2E). Fixes #67964
387 lines
14 KiB
Python
387 lines
14 KiB
Python
"""Local-environment toolchain probe for the system prompt.
|
||
|
||
When the terminal backend is local (the agent's tools run on the same
|
||
machine as Hermes itself), we surface a single deterministic line about
|
||
Python tooling state so models don't have to discover it by hitting
|
||
walls. Common failure modes this addresses:
|
||
|
||
* Hermes ships under one Python (e.g. 3.11 in a bundled venv) while the
|
||
user's login shell has a different one (e.g. 3.12 system). ``pip``
|
||
resolved from PATH may not match ``python3 -m pip``.
|
||
* The bundled-venv Python has no pip module installed → ``python3 -m
|
||
pip`` returns ``No module named pip``.
|
||
* The system Python is PEP-668 externally-managed → naive
|
||
``pip install`` fails with ``error: externally-managed-environment``.
|
||
|
||
The probe is cheap (a handful of subprocess calls, ~50ms total),
|
||
cached for the lifetime of the process, and emits **at most one
|
||
short line** when something non-default is detected. When the
|
||
environment looks normal (python3+pip both present and matched, no
|
||
PEP 668), it emits nothing — no token cost.
|
||
|
||
Remote terminal backends (docker, modal, ssh, …) are skipped: the
|
||
host's Python state is irrelevant when tools run inside a sandbox.
|
||
The sandbox has its own existing probe (``_probe_remote_backend``)
|
||
in ``agent/prompt_builder.py``.
|
||
|
||
Toggle via ``agent.environment_probe`` in config.yaml (default True).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
from typing import Optional
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Module-level cache. The probe result is deterministic for the
|
||
# lifetime of the process — Python install state doesn't change
|
||
# mid-session in any way that would matter for the system prompt.
|
||
#
|
||
# Concurrency model (#67964): the probe runs in exactly ONE background
|
||
# worker thread; ``_PROBE_DONE`` signals completion. Callers never
|
||
# execute the probe themselves and never wait unboundedly — they block
|
||
# at most ``_PROBE_WAIT_TIMEOUT`` seconds on the event and then fail
|
||
# open with "". This guarantees a stuck probe (e.g. a Windows pipe
|
||
# wedged open by an orphaned pip descendant) can degrade at most the
|
||
# probe line itself, never system-prompt construction.
|
||
_CACHE_LOCK = threading.Lock()
|
||
_CACHED_LINE: Optional[str] = None # None = not probed yet; "" = probed, nothing to say.
|
||
_PROBE_DONE = threading.Event()
|
||
_PROBE_THREAD: Optional[threading.Thread] = None
|
||
# Generation counter — bumped on every reset so a stale worker (started
|
||
# before a test reset) can't publish its result into the fresh generation.
|
||
_PROBE_GEN = 0
|
||
|
||
# Upper bound a prompt build will wait for the probe. Generous vs the
|
||
# ~0.5s healthy runtime (6 subprocesses × 3s timeout ≈ 18s pathological
|
||
# worst case), but finite: prompt construction must always proceed.
|
||
_PROBE_WAIT_TIMEOUT = 10.0
|
||
# Once one caller has burned the full wait and given up, later callers
|
||
# stop paying it too — they just peek at the event. If the stuck worker
|
||
# ever finishes, the published line resumes appearing in new prompts.
|
||
_WAIT_ALREADY_TIMED_OUT = False
|
||
|
||
# Remote backends — keep in sync with agent/prompt_builder.py:_REMOTE_TERMINAL_BACKENDS.
|
||
# Duplicated rather than imported to avoid a circular import (prompt_builder
|
||
# imports nothing from tools).
|
||
_REMOTE_BACKENDS = frozenset({
|
||
"docker", "singularity", "modal", "daytona", "ssh", "managed_modal",
|
||
})
|
||
|
||
|
||
def _kill_process_tree(proc: "subprocess.Popen") -> None:
|
||
"""Best-effort kill of ``proc`` and its descendants.
|
||
|
||
On Windows, killing only the direct child leaves any descendants it
|
||
spawned (e.g. the ``python.exe`` a ``pip.exe`` console-script launcher
|
||
starts) alive and holding inherited stdout/stderr write handles, which
|
||
is exactly what wedges pipe readers (#67964). ``taskkill /T`` sweeps
|
||
the tree while it is still intact; it cannot catch descendants that
|
||
were already orphaned, which is why ``_run`` must additionally never
|
||
perform an unbounded pipe read after a timeout.
|
||
"""
|
||
if sys.platform == "win32":
|
||
try:
|
||
# No pipes → this call cannot itself deadlock on captured output.
|
||
subprocess.run(
|
||
["taskkill", "/T", "/F", "/PID", str(proc.pid)],
|
||
stdin=subprocess.DEVNULL,
|
||
stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.DEVNULL,
|
||
timeout=2,
|
||
check=False,
|
||
)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
proc.kill()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _run(cmd: list[str], timeout: float = 3.0) -> tuple[int, str, str]:
|
||
"""Run a short subprocess. Returns (returncode, stdout, stderr).
|
||
|
||
Failures (binary missing, timeout, OSError) return (-1, "", "<reason>").
|
||
|
||
Deliberately NOT ``subprocess.run``: on Windows, ``run()`` reacts to a
|
||
``TimeoutExpired`` by calling ``communicate()`` a second time with **no
|
||
timeout** to collect partial output. That second call joins the pipe
|
||
reader threads unboundedly — and if the child spawned a descendant that
|
||
inherited the stdout/stderr write handles and outlives it (an orphaned
|
||
``pip --version`` grandchild in incident #67964), the pipes never hit
|
||
EOF and the join blocks forever. Here a timeout kills the process tree
|
||
and abandons the (daemon) reader threads instead: bounded always, at
|
||
the cost of discarding partial output we don't need anyway.
|
||
"""
|
||
proc = None
|
||
try:
|
||
proc = subprocess.Popen(
|
||
cmd,
|
||
stdin=subprocess.DEVNULL,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
text=True,
|
||
)
|
||
stdout, stderr = proc.communicate(timeout=timeout)
|
||
return proc.returncode, (stdout or "").strip(), (stderr or "").strip()
|
||
except FileNotFoundError:
|
||
return -1, "", "not found"
|
||
except subprocess.TimeoutExpired:
|
||
if proc is not None:
|
||
_kill_process_tree(proc)
|
||
try:
|
||
# Bounded reap of the direct child. Does NOT read the pipes,
|
||
# so an orphaned descendant holding them open cannot block us.
|
||
proc.wait(timeout=1)
|
||
except Exception:
|
||
pass
|
||
return -1, "", "timeout"
|
||
except OSError as exc:
|
||
if proc is not None:
|
||
_kill_process_tree(proc)
|
||
return -1, "", f"oserror: {exc}"
|
||
|
||
|
||
def _python_version_of(binary: str) -> Optional[str]:
|
||
"""Return a short version string like ``3.12.4`` for ``binary``, or None."""
|
||
if not shutil.which(binary):
|
||
return None
|
||
rc, out, err = _run([binary, "-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')"])
|
||
if rc == 0 and out:
|
||
return out
|
||
return None
|
||
|
||
|
||
def _has_pip_module(binary: str) -> bool:
|
||
"""True if ``<binary> -m pip --version`` succeeds."""
|
||
if not shutil.which(binary):
|
||
return False
|
||
rc, _out, _err = _run([binary, "-m", "pip", "--version"])
|
||
return rc == 0
|
||
|
||
|
||
def _detect_pep668(binary: str) -> bool:
|
||
"""True when ``<binary>``'s install location is PEP-668 externally-managed.
|
||
|
||
Looks for ``EXTERNALLY-MANAGED`` next to the stdlib (the marker file
|
||
Debian/Ubuntu drop in to gate naive ``pip install``).
|
||
"""
|
||
if not shutil.which(binary):
|
||
return False
|
||
code = (
|
||
"import sys, os;"
|
||
"stdlib = os.path.dirname(os.__file__);"
|
||
"marker = os.path.join(stdlib, 'EXTERNALLY-MANAGED');"
|
||
"print('yes' if os.path.exists(marker) else 'no')"
|
||
)
|
||
rc, out, _err = _run([binary, "-c", code])
|
||
return rc == 0 and out.strip() == "yes"
|
||
|
||
|
||
def _pip_python_version() -> Optional[str]:
|
||
"""If ``pip`` is on PATH, return the Python version it's bound to.
|
||
|
||
``pip --version`` output looks like::
|
||
|
||
pip 24.0 from /usr/lib/python3/dist-packages/pip (python 3.12)
|
||
|
||
Returns the parenthesised version (e.g. ``"3.12"``) or None.
|
||
"""
|
||
if not shutil.which("pip"):
|
||
return None
|
||
rc, out, _err = _run(["pip", "--version"])
|
||
if rc != 0 or not out:
|
||
return None
|
||
# Parse trailing "(python X.Y)".
|
||
if "(python " in out and out.endswith(")"):
|
||
try:
|
||
tail = out.rsplit("(python ", 1)[1]
|
||
return tail[:-1].strip()
|
||
except (IndexError, AttributeError):
|
||
return None
|
||
return None
|
||
|
||
|
||
def _build_probe_line() -> str:
|
||
"""Build the one-liner. Returns "" when nothing notable is detected.
|
||
|
||
Emit only when SOMETHING is off — the goal is to save the model from
|
||
hitting an avoidable wall, not to narrate a healthy environment.
|
||
"""
|
||
# Bail out if a remote terminal backend is configured; the host's
|
||
# Python state isn't where the agent's tools run.
|
||
backend = (os.getenv("TERMINAL_ENV") or "local").strip().lower()
|
||
if backend in _REMOTE_BACKENDS:
|
||
return ""
|
||
|
||
py3_ver = _python_version_of("python3")
|
||
py_ver = _python_version_of("python") # for systems with a `python` alias
|
||
py3_has_pip = _has_pip_module("python3") if py3_ver else False
|
||
pip_bound_to = _pip_python_version()
|
||
py3_pep668 = _detect_pep668("python3") if py3_ver else False
|
||
has_uv = shutil.which("uv") is not None
|
||
|
||
# If python3 exists, has pip, has uv (or no PEP 668), and there's no
|
||
# version mismatch between `pip` and `python3` → environment is
|
||
# clean enough to stay silent. The model can discover details by
|
||
# running commands if it cares.
|
||
mismatch = bool(pip_bound_to and py3_ver and not py3_ver.startswith(pip_bound_to))
|
||
silent_conditions = (
|
||
py3_ver is not None
|
||
and py3_has_pip
|
||
and not mismatch
|
||
and (not py3_pep668 or has_uv)
|
||
)
|
||
if silent_conditions:
|
||
return ""
|
||
|
||
# Build a compact factual summary. Keep it ONE line so it doesn't
|
||
# dominate the prompt; the model is good at parsing dense info.
|
||
bits: list[str] = []
|
||
if py3_ver:
|
||
py3_bit = f"python3={py3_ver}"
|
||
if not py3_has_pip:
|
||
py3_bit += " (no pip module)"
|
||
bits.append(py3_bit)
|
||
else:
|
||
bits.append("python3=missing")
|
||
|
||
if py_ver and py_ver != py3_ver:
|
||
bits.append(f"python={py_ver}")
|
||
elif not py_ver and py3_ver:
|
||
# Common on Debian/Ubuntu — call it out so the model doesn't
|
||
# type `python` and hit "command not found".
|
||
bits.append("python=missing (use python3)")
|
||
|
||
if pip_bound_to:
|
||
if mismatch:
|
||
bits.append(f"pip→python{pip_bound_to} (mismatch)")
|
||
elif not py3_has_pip:
|
||
# pip exists but `python3 -m pip` doesn't — the script
|
||
# works but the module path doesn't.
|
||
bits.append(f"pip→python{pip_bound_to}")
|
||
elif py3_has_pip:
|
||
# `pip` not on PATH but `python3 -m pip` works.
|
||
pass
|
||
else:
|
||
bits.append("pip=missing")
|
||
|
||
if py3_pep668:
|
||
bits.append("PEP 668=yes (use venv or uv)")
|
||
|
||
if has_uv:
|
||
bits.append("uv=installed")
|
||
|
||
if not bits:
|
||
return ""
|
||
|
||
return "Python toolchain: " + ", ".join(bits) + "."
|
||
|
||
|
||
def get_environment_probe_line(*, force_refresh: bool = False) -> str:
|
||
"""Return the cached probe line (building it on first call).
|
||
|
||
Returns "" when the environment is clean — the system prompt
|
||
assembler should drop the section in that case rather than
|
||
emit an empty heading.
|
||
|
||
The probe itself always runs in a single background worker thread;
|
||
this function waits on its completion event for at most
|
||
``_PROBE_WAIT_TIMEOUT`` seconds and then fails open with "". A
|
||
wedged probe subprocess (#67964) therefore can never block
|
||
system-prompt construction — at worst the toolchain line is absent
|
||
from prompts built while the probe is stuck.
|
||
|
||
``force_refresh`` is for tests; real callers should never need it.
|
||
"""
|
||
global _CACHED_LINE, _PROBE_THREAD, _PROBE_GEN, _WAIT_ALREADY_TIMED_OUT
|
||
if force_refresh:
|
||
with _CACHE_LOCK:
|
||
_CACHED_LINE = None
|
||
_PROBE_DONE.clear()
|
||
_PROBE_THREAD = None
|
||
_PROBE_GEN += 1
|
||
_WAIT_ALREADY_TIMED_OUT = False
|
||
|
||
if _PROBE_DONE.is_set():
|
||
return _CACHED_LINE or ""
|
||
|
||
_ensure_probe_started()
|
||
wait_timeout = 0.05 if _WAIT_ALREADY_TIMED_OUT else _PROBE_WAIT_TIMEOUT
|
||
if not _PROBE_DONE.wait(timeout=wait_timeout):
|
||
# Probe stuck or pathologically slow. The line is a nice-to-have;
|
||
# blocking prompt construction is an outage. Fail open — if the
|
||
# worker eventually finishes, sessions started later get the line.
|
||
if not _WAIT_ALREADY_TIMED_OUT:
|
||
_WAIT_ALREADY_TIMED_OUT = True
|
||
logger.warning(
|
||
"env_probe did not finish within %.0fs; building the system "
|
||
"prompt without the Python toolchain line",
|
||
_PROBE_WAIT_TIMEOUT,
|
||
)
|
||
return ""
|
||
return _CACHED_LINE or ""
|
||
|
||
|
||
def _probe_worker(gen: int) -> None:
|
||
"""Body of the single probe thread — computes and publishes the line."""
|
||
global _CACHED_LINE
|
||
try:
|
||
line = _build_probe_line()
|
||
except Exception as exc: # never let probe failure propagate
|
||
logger.debug("env_probe failed: %s", exc)
|
||
line = ""
|
||
with _CACHE_LOCK:
|
||
if gen != _PROBE_GEN:
|
||
return # superseded by a reset (tests) — discard stale result
|
||
_CACHED_LINE = line
|
||
_PROBE_DONE.set()
|
||
|
||
|
||
def _ensure_probe_started() -> None:
|
||
"""Start the probe worker if it isn't running and hasn't finished."""
|
||
global _PROBE_THREAD
|
||
with _CACHE_LOCK:
|
||
if _PROBE_DONE.is_set():
|
||
return
|
||
if _PROBE_THREAD is not None and _PROBE_THREAD.is_alive():
|
||
return
|
||
_PROBE_THREAD = threading.Thread(
|
||
target=_probe_worker,
|
||
args=(_PROBE_GEN,),
|
||
name="env-probe",
|
||
daemon=True,
|
||
)
|
||
_PROBE_THREAD.start()
|
||
|
||
|
||
def warm_environment_probe_async() -> None:
|
||
"""Kick off the probe in a background thread so the first
|
||
system-prompt build doesn't pay the ~0.5s of subprocess calls
|
||
(python3/pip/PEP-668 version checks) on the time-to-first-token
|
||
critical path.
|
||
|
||
Idempotent and fail-safe. The prompt-build call to
|
||
``get_environment_probe_line`` waits (bounded) on the same worker's
|
||
completion event instead of recomputing. Called from agent init
|
||
(all platforms); safe to call from anywhere.
|
||
"""
|
||
_ensure_probe_started()
|
||
|
||
|
||
def _reset_cache_for_tests() -> None:
|
||
"""Test helper — clear the cache between probe scenarios."""
|
||
global _CACHED_LINE, _PROBE_THREAD, _PROBE_GEN, _WAIT_ALREADY_TIMED_OUT
|
||
with _CACHE_LOCK:
|
||
_CACHED_LINE = None
|
||
_PROBE_DONE.clear()
|
||
_PROBE_THREAD = None
|
||
_PROBE_GEN += 1
|
||
_WAIT_ALREADY_TIMED_OUT = False
|