Compare commits

...

1 Commits

Author SHA1 Message Date
teknium1
df7a86f041 fix: restore terminal and file tools in worktrees
Root cause: terminal availability checks still imported minisweagent for the
local backend even though local/singularity now use Hermes wrappers directly.
In git worktrees, the local submodule path may also be an empty placeholder,
so direct path insertion could miss the populated mini-swe-agent checkout.

This change:
- adds a helper to discover mini-swe-agent from the current checkout or the
  main checkout behind a worktree
- uses that helper in terminal_tool and mini_swe_runner
- stops requiring minisweagent for the local backend requirements check
- adds regression tests and validates terminal/file tool resolution again
2026-03-13 23:05:49 -07:00
5 changed files with 193 additions and 20 deletions

View File

@@ -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
View 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

View 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

View 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)

View File

@@ -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: