diff --git a/mini_swe_runner.py b/mini_swe_runner.py index 5cb337b87c..f5e8b59fec 100644 --- a/mini_swe_runner.py +++ b/mini_swe_runner.py @@ -42,10 +42,11 @@ from dotenv import load_dotenv # Load environment variables load_dotenv() -# Add mini-swe-agent to path if not installed -mini_swe_path = Path(__file__).parent / "mini-swe-agent" / "src" -if mini_swe_path.exists(): - sys.path.insert(0, str(mini_swe_path)) +# Add mini-swe-agent to path if not installed. In git worktrees the populated +# submodule may live in the main checkout rather than the worktree itself. +from minisweagent_path import ensure_minisweagent_on_path + +ensure_minisweagent_on_path(Path(__file__).resolve().parent) # ============================================================================ diff --git a/minisweagent_path.py b/minisweagent_path.py new file mode 100644 index 0000000000..e0ea8f29b0 --- /dev/null +++ b/minisweagent_path.py @@ -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:
/.git/worktrees/ + 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 diff --git a/tests/test_minisweagent_path.py b/tests/test_minisweagent_path.py new file mode 100644 index 0000000000..00eca12c4f --- /dev/null +++ b/tests/test_minisweagent_path.py @@ -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 diff --git a/tests/tools/test_terminal_tool_requirements.py b/tests/tools/test_terminal_tool_requirements.py new file mode 100644 index 0000000000..9c8bc8aa10 --- /dev/null +++ b/tests/tools/test_terminal_tool_requirements.py @@ -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) diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index afc064b52c..25419a56c1 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -26,6 +26,7 @@ Usage: result = terminal_tool("python server.py", background=True) """ +import importlib.util import json import logging import os @@ -53,10 +54,11 @@ logger = logging.getLogger(__name__) from tools.interrupt import set_interrupt as set_interrupt_event, is_interrupted, _interrupt_event -# Add mini-swe-agent to path if not installed -mini_swe_path = Path(__file__).parent.parent / "mini-swe-agent" / "src" -if mini_swe_path.exists(): - sys.path.insert(0, str(mini_swe_path)) +# Add mini-swe-agent to path if not installed. In git worktrees the populated +# submodule may live in the main checkout rather than the worktree itself. +from minisweagent_path import ensure_minisweagent_on_path + +ensure_minisweagent_on_path(Path(__file__).resolve().parent.parent) # ============================================================================= @@ -1124,47 +1126,58 @@ def terminal_tool( 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() env_type = config["env_type"] - + try: if env_type == "local": # Local execution uses Hermes' own LocalEnvironment wrapper and does # not depend on minisweagent being importable. return True + 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) from tools.environments.docker import find_docker - import subprocess docker = find_docker() if not docker: logger.error("Docker executable not found in PATH or common install locations") return False result = subprocess.run([docker, "version"], capture_output=True, timeout=5) return result.returncode == 0 + 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") if executable: result = subprocess.run([executable, "--version"], capture_output=True, timeout=5) return result.returncode == 0 return False + elif env_type == "ssh": - from tools.environments.ssh import SSHEnvironment # Check that host and user are configured return bool(config.get("ssh_host")) and bool(config.get("ssh_user")) + 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 return os.getenv("MODAL_TOKEN_ID") is not None or Path.home().joinpath(".modal.toml").exists() + elif env_type == "daytona": from daytona import Daytona return os.getenv("DAYTONA_API_KEY") is not None + else: return False except Exception as e: