mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 23:11:37 +08:00
Compare commits
1 Commits
codex-port
...
fix/worktr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df7a86f041 |
@@ -42,10 +42,11 @@ from dotenv import load_dotenv
|
|||||||
# Load environment variables
|
# Load environment variables
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# Add mini-swe-agent to path if not installed
|
# Add mini-swe-agent to path if not installed. In git worktrees the populated
|
||||||
mini_swe_path = Path(__file__).parent / "mini-swe-agent" / "src"
|
# submodule may live in the main checkout rather than the worktree itself.
|
||||||
if mini_swe_path.exists():
|
from minisweagent_path import ensure_minisweagent_on_path
|
||||||
sys.path.insert(0, str(mini_swe_path))
|
|
||||||
|
ensure_minisweagent_on_path(Path(__file__).resolve().parent)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
92
minisweagent_path.py
Normal file
92
minisweagent_path.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""Helpers for locating the mini-swe-agent source tree.
|
||||||
|
|
||||||
|
Hermes often runs from git worktrees. In that layout the worktree root may have
|
||||||
|
an empty ``mini-swe-agent/`` placeholder while the real populated submodule
|
||||||
|
lives under the main checkout that owns the shared ``.git`` directory.
|
||||||
|
|
||||||
|
These helpers locate a usable ``mini-swe-agent/src`` directory and optionally
|
||||||
|
prepend it to ``sys.path`` so imports like ``import minisweagent`` work from
|
||||||
|
both normal checkouts and worktrees.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _read_gitdir(repo_root: Path) -> Optional[Path]:
|
||||||
|
"""Resolve the gitdir referenced by ``repo_root/.git`` when it is a file."""
|
||||||
|
git_marker = repo_root / ".git"
|
||||||
|
if not git_marker.is_file():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = git_marker.read_text(encoding="utf-8").strip()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
prefix = "gitdir:"
|
||||||
|
if not raw.lower().startswith(prefix):
|
||||||
|
return None
|
||||||
|
|
||||||
|
target = raw[len(prefix):].strip()
|
||||||
|
gitdir = Path(target)
|
||||||
|
if not gitdir.is_absolute():
|
||||||
|
gitdir = (repo_root / gitdir).resolve()
|
||||||
|
else:
|
||||||
|
gitdir = gitdir.resolve()
|
||||||
|
return gitdir
|
||||||
|
|
||||||
|
|
||||||
|
def discover_minisweagent_src(repo_root: Optional[Path] = None) -> Optional[Path]:
|
||||||
|
"""Return the best available ``mini-swe-agent/src`` path, if any.
|
||||||
|
|
||||||
|
Search order:
|
||||||
|
1. Current checkout/worktree root
|
||||||
|
2. Main checkout that owns the shared ``.git`` directory (for worktrees)
|
||||||
|
"""
|
||||||
|
repo_root = (repo_root or Path(__file__).resolve().parent).resolve()
|
||||||
|
|
||||||
|
candidates: list[Path] = [repo_root / "mini-swe-agent" / "src"]
|
||||||
|
|
||||||
|
gitdir = _read_gitdir(repo_root)
|
||||||
|
if gitdir is not None:
|
||||||
|
# Worktree layout: <main>/.git/worktrees/<name>
|
||||||
|
if len(gitdir.parents) >= 3 and gitdir.parent.name == "worktrees":
|
||||||
|
candidates.append(gitdir.parents[2] / "mini-swe-agent" / "src")
|
||||||
|
# Direct checkout with .git file pointing elsewhere
|
||||||
|
elif gitdir.name == ".git":
|
||||||
|
candidates.append(gitdir.parent / "mini-swe-agent" / "src")
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
for candidate in candidates:
|
||||||
|
candidate = candidate.resolve()
|
||||||
|
if candidate in seen:
|
||||||
|
continue
|
||||||
|
seen.add(candidate)
|
||||||
|
if candidate.exists() and candidate.is_dir():
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_minisweagent_on_path(repo_root: Optional[Path] = None) -> Optional[Path]:
|
||||||
|
"""Ensure ``minisweagent`` is importable by prepending its src dir to sys.path.
|
||||||
|
|
||||||
|
Returns the inserted/discovered path, or ``None`` if the package is already
|
||||||
|
importable or no local source tree could be found.
|
||||||
|
"""
|
||||||
|
if importlib.util.find_spec("minisweagent") is not None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
src = discover_minisweagent_src(repo_root)
|
||||||
|
if src is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
src_str = str(src)
|
||||||
|
if src_str not in sys.path:
|
||||||
|
sys.path.insert(0, src_str)
|
||||||
|
return src
|
||||||
34
tests/test_minisweagent_path.py
Normal file
34
tests/test_minisweagent_path.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""Tests for minisweagent_path.py."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from minisweagent_path import discover_minisweagent_src
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_minisweagent_src_in_current_checkout(tmp_path):
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
src = repo / "mini-swe-agent" / "src"
|
||||||
|
src.mkdir(parents=True)
|
||||||
|
|
||||||
|
assert discover_minisweagent_src(repo) == src.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_minisweagent_src_falls_back_from_worktree_to_main_checkout(tmp_path):
|
||||||
|
main_repo = tmp_path / "main-repo"
|
||||||
|
(main_repo / ".git" / "worktrees" / "wt1").mkdir(parents=True)
|
||||||
|
main_src = main_repo / "mini-swe-agent" / "src"
|
||||||
|
main_src.mkdir(parents=True)
|
||||||
|
|
||||||
|
worktree = tmp_path / "worktree"
|
||||||
|
worktree.mkdir()
|
||||||
|
(worktree / ".git").write_text(f"gitdir: {main_repo / '.git' / 'worktrees' / 'wt1'}\n", encoding="utf-8")
|
||||||
|
(worktree / "mini-swe-agent").mkdir() # empty placeholder, no src/
|
||||||
|
|
||||||
|
assert discover_minisweagent_src(worktree) == main_src.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_minisweagent_src_returns_none_when_missing(tmp_path):
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
|
||||||
|
assert discover_minisweagent_src(repo) is None
|
||||||
28
tests/tools/test_terminal_tool_requirements.py
Normal file
28
tests/tools/test_terminal_tool_requirements.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""Tests for terminal/file tool availability in local dev environments."""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
from model_tools import get_tool_definitions
|
||||||
|
|
||||||
|
terminal_tool_module = importlib.import_module("tools.terminal_tool")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTerminalRequirements:
|
||||||
|
def test_local_backend_does_not_require_minisweagent_package(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
terminal_tool_module,
|
||||||
|
"_get_env_config",
|
||||||
|
lambda: {"env_type": "local"},
|
||||||
|
)
|
||||||
|
assert terminal_tool_module.check_terminal_requirements() is True
|
||||||
|
|
||||||
|
def test_terminal_and_file_tools_resolve_for_local_backend(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
terminal_tool_module,
|
||||||
|
"_get_env_config",
|
||||||
|
lambda: {"env_type": "local"},
|
||||||
|
)
|
||||||
|
tools = get_tool_definitions(enabled_toolsets=["terminal", "file"], quiet_mode=True)
|
||||||
|
names = {tool["function"]["name"] for tool in tools}
|
||||||
|
assert "terminal" in names
|
||||||
|
assert {"read_file", "write_file", "patch", "search_files"}.issubset(names)
|
||||||
@@ -26,6 +26,7 @@ Usage:
|
|||||||
result = terminal_tool("python server.py", background=True)
|
result = terminal_tool("python server.py", background=True)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -53,10 +54,11 @@ logger = logging.getLogger(__name__)
|
|||||||
from tools.interrupt import set_interrupt as set_interrupt_event, is_interrupted, _interrupt_event
|
from tools.interrupt import set_interrupt as set_interrupt_event, is_interrupted, _interrupt_event
|
||||||
|
|
||||||
|
|
||||||
# Add mini-swe-agent to path if not installed
|
# Add mini-swe-agent to path if not installed. In git worktrees the populated
|
||||||
mini_swe_path = Path(__file__).parent.parent / "mini-swe-agent" / "src"
|
# submodule may live in the main checkout rather than the worktree itself.
|
||||||
if mini_swe_path.exists():
|
from minisweagent_path import ensure_minisweagent_on_path
|
||||||
sys.path.insert(0, str(mini_swe_path))
|
|
||||||
|
ensure_minisweagent_on_path(Path(__file__).resolve().parent.parent)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -1124,46 +1126,62 @@ def terminal_tool(
|
|||||||
|
|
||||||
|
|
||||||
def check_terminal_requirements() -> bool:
|
def check_terminal_requirements() -> bool:
|
||||||
"""Check if all requirements for the terminal tool are met."""
|
"""Check if all requirements for the terminal tool are met.
|
||||||
|
|
||||||
|
Important: local and singularity backends now use Hermes' own environment
|
||||||
|
wrappers directly and do not require the ``minisweagent`` Python package to
|
||||||
|
be installed. Docker and Modal still rely on mini-swe-agent internals.
|
||||||
|
"""
|
||||||
config = _get_env_config()
|
config = _get_env_config()
|
||||||
env_type = config["env_type"]
|
env_type = config["env_type"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if env_type == "local":
|
if env_type == "local":
|
||||||
from minisweagent.environments.local import LocalEnvironment
|
return bool(
|
||||||
return True
|
shutil.which("bash")
|
||||||
|
or ("/usr/bin/bash" if Path("/usr/bin/bash").is_file() else None)
|
||||||
|
or ("/bin/bash" if Path("/bin/bash").is_file() else None)
|
||||||
|
or (os.environ.get("SHELL") if os.environ.get("SHELL") else None)
|
||||||
|
or ("/bin/sh" if Path("/bin/sh").is_file() else None)
|
||||||
|
)
|
||||||
|
|
||||||
elif env_type == "docker":
|
elif env_type == "docker":
|
||||||
from minisweagent.environments.docker import DockerEnvironment
|
ensure_minisweagent_on_path(Path(__file__).resolve().parent.parent)
|
||||||
|
if importlib.util.find_spec("minisweagent") is None:
|
||||||
|
logger.error("mini-swe-agent is required for docker terminal backend but is not importable")
|
||||||
|
return False
|
||||||
# Check if docker is available (use find_docker for macOS PATH issues)
|
# Check if docker is available (use find_docker for macOS PATH issues)
|
||||||
from tools.environments.docker import find_docker
|
from tools.environments.docker import find_docker
|
||||||
import subprocess
|
|
||||||
docker = find_docker()
|
docker = find_docker()
|
||||||
if not docker:
|
if not docker:
|
||||||
logger.error("Docker executable not found in PATH or common install locations")
|
logger.error("Docker executable not found in PATH or common install locations")
|
||||||
return False
|
return False
|
||||||
result = subprocess.run([docker, "version"], capture_output=True, timeout=5)
|
result = subprocess.run([docker, "version"], capture_output=True, timeout=5)
|
||||||
return result.returncode == 0
|
return result.returncode == 0
|
||||||
|
|
||||||
elif env_type == "singularity":
|
elif env_type == "singularity":
|
||||||
from minisweagent.environments.singularity import SingularityEnvironment
|
|
||||||
# Check if singularity/apptainer is available
|
|
||||||
import subprocess
|
|
||||||
import shutil
|
|
||||||
executable = shutil.which("apptainer") or shutil.which("singularity")
|
executable = shutil.which("apptainer") or shutil.which("singularity")
|
||||||
if executable:
|
if executable:
|
||||||
result = subprocess.run([executable, "--version"], capture_output=True, timeout=5)
|
result = subprocess.run([executable, "--version"], capture_output=True, timeout=5)
|
||||||
return result.returncode == 0
|
return result.returncode == 0
|
||||||
return False
|
return False
|
||||||
|
|
||||||
elif env_type == "ssh":
|
elif env_type == "ssh":
|
||||||
from tools.environments.ssh import SSHEnvironment
|
|
||||||
# Check that host and user are configured
|
# Check that host and user are configured
|
||||||
return bool(config.get("ssh_host")) and bool(config.get("ssh_user"))
|
return bool(config.get("ssh_host")) and bool(config.get("ssh_user"))
|
||||||
|
|
||||||
elif env_type == "modal":
|
elif env_type == "modal":
|
||||||
from minisweagent.environments.extra.swerex_modal import SwerexModalEnvironment
|
ensure_minisweagent_on_path(Path(__file__).resolve().parent.parent)
|
||||||
|
if importlib.util.find_spec("minisweagent") is None:
|
||||||
|
logger.error("mini-swe-agent is required for modal terminal backend but is not importable")
|
||||||
|
return False
|
||||||
# Check for modal token
|
# Check for modal token
|
||||||
return os.getenv("MODAL_TOKEN_ID") is not None or Path.home().joinpath(".modal.toml").exists()
|
return os.getenv("MODAL_TOKEN_ID") is not None or Path.home().joinpath(".modal.toml").exists()
|
||||||
|
|
||||||
elif env_type == "daytona":
|
elif env_type == "daytona":
|
||||||
from daytona import Daytona
|
from daytona import Daytona
|
||||||
return os.getenv("DAYTONA_API_KEY") is not None
|
return os.getenv("DAYTONA_API_KEY") is not None
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user