Skip to content

Singularity

Singularity Environment class

Full source code
#!/usr/bin/env python3

import logging
import os
import shutil
import subprocess
import tempfile
import uuid
from pathlib import Path
from typing import Any

from pydantic import BaseModel

from minisweagent.exceptions import Submitted
from minisweagent.utils.serialize import recursive_merge


class SingularityEnvironmentConfig(BaseModel):
    image: str
    cwd: str = "/"
    env: dict[str, str] = {}
    """Environment variables to set in the container."""
    forward_env: list[str] = []
    """Environment variables to forward to the container."""
    timeout: int = 30
    """Timeout for executing commands in the container."""
    executable: str = os.getenv("MSWEA_SINGULARITY_EXECUTABLE", "singularity")
    """Path to the singularity executable."""
    sandbox_build_retries: int = 3
    """Number of retries for building the sandbox if an error occurs."""
    global_args: list[str] = ["--quiet"]
    """Global arguments passed before the subcommand (e.g., --quiet, --debug)."""
    exec_args: list[str] = ["--contain", "--cleanenv", "--fakeroot"]
    """Arguments passed to `singularity exec`."""


class SingularityEnvironment:
    def __init__(
        self, *, config_class: type = SingularityEnvironmentConfig, logger: logging.Logger | None = None, **kwargs
    ):
        """Singularity environment. See `SingularityEnvironmentConfig` for kwargs."""
        self.logger = logger or logging.getLogger("minisweagent.environment")
        self.config = config_class(**kwargs)
        self.sandbox_dir = self._build_sandbox()

    def _build_sandbox(self) -> Path:
        # Building the sandbox can fail (very rarely), so we retry it
        max_retries = self.config.sandbox_build_retries
        for attempt in range(max_retries):
            sandbox_dir = Path(tempfile.gettempdir()) / f"minisweagent-{uuid.uuid4().hex[:8]}"
            try:
                subprocess.run(
                    [self.config.executable, "build", "--sandbox", sandbox_dir, self.config.image],
                    check=True,
                    capture_output=True,
                )
                break
            except subprocess.CalledProcessError as e:
                shutil.rmtree(sandbox_dir, ignore_errors=True)
                self.logger.error(
                    f"Error building image {self.config.image}, stdout: {e.stdout}, stderr: {e.stderr} (attempt {attempt + 1}/{max_retries})"
                )
                if attempt == max_retries - 1:
                    raise
        return sandbox_dir

    def get_template_vars(self, **kwargs) -> dict[str, Any]:
        return recursive_merge(self.config.model_dump(), kwargs)

    def serialize(self) -> dict:
        return {
            "info": {
                "config": {
                    "environment": self.config.model_dump(mode="json"),
                    "environment_type": f"{self.__class__.__module__}.{self.__class__.__name__}",
                }
            }
        }

    def execute(self, action: dict, cwd: str = "", *, timeout: int | None = None) -> dict[str, Any]:
        """Execute a command in a Singularity container and return the result as a dict."""
        command = action.get("command", "")
        cmd = [self.config.executable, *self.config.global_args, "exec", *self.config.exec_args]

        work_dir = cwd or self.config.cwd
        if work_dir and work_dir != "/":
            cmd.extend(["--pwd", work_dir])

        for key in self.config.forward_env:
            if (value := os.getenv(key)) is not None:
                cmd.extend(["--env", f"{key}={value}"])
        for key, value in self.config.env.items():
            cmd.extend(["--env", f"{key}={value}"])

        cmd.extend(["--writable", str(self.sandbox_dir), "bash", "-c", command])
        try:
            result = subprocess.run(
                cmd,
                text=True,
                timeout=timeout or self.config.timeout,
                encoding="utf-8",
                errors="replace",
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
            )
            output = {"output": result.stdout, "returncode": result.returncode, "exception_info": ""}
        except Exception as e:
            raw_output = getattr(e, "output", None)
            raw_output = (
                raw_output.decode("utf-8", errors="replace") if isinstance(raw_output, bytes) else (raw_output or "")
            )
            output = {
                "output": raw_output,
                "returncode": -1,
                "exception_info": f"An error occurred while executing the command: {e}",
                "extra": {"exception_type": type(e).__name__, "exception": str(e)},
            }
        self._check_finished(output)
        return output

    def _check_finished(self, output: dict):
        """Raises Submitted if the output indicates task completion."""
        lines = output.get("output", "").lstrip().splitlines(keepends=True)
        if lines and lines[0].strip() == "COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT" and output["returncode"] == 0:
            submission = "".join(lines[1:])
            raise Submitted(
                {
                    "role": "exit",
                    "content": submission,
                    "extra": {"exit_status": "Submitted", "submission": submission},
                }
            )

    def cleanup(self):
        shutil.rmtree(self.sandbox_dir, ignore_errors=True)

    def __del__(self):
        """Cleanup sandbox when object is destroyed."""
        self.cleanup()

minisweagent.environments.singularity

SingularityEnvironmentConfig

Bases: BaseModel

image instance-attribute

image: str

cwd class-attribute instance-attribute

cwd: str = '/'

env class-attribute instance-attribute

env: dict[str, str] = {}

Environment variables to set in the container.

forward_env class-attribute instance-attribute

forward_env: list[str] = []

Environment variables to forward to the container.

timeout class-attribute instance-attribute

timeout: int = 30

Timeout for executing commands in the container.

executable class-attribute instance-attribute

executable: str = getenv(
    "MSWEA_SINGULARITY_EXECUTABLE", "singularity"
)

Path to the singularity executable.

sandbox_build_retries class-attribute instance-attribute

sandbox_build_retries: int = 3

Number of retries for building the sandbox if an error occurs.

global_args class-attribute instance-attribute

global_args: list[str] = ['--quiet']

Global arguments passed before the subcommand (e.g., --quiet, --debug).

exec_args class-attribute instance-attribute

exec_args: list[str] = [
    "--contain",
    "--cleanenv",
    "--fakeroot",
]

Arguments passed to singularity exec.

SingularityEnvironment

SingularityEnvironment(
    *,
    config_class: type = SingularityEnvironmentConfig,
    logger: Logger | None = None,
    **kwargs,
)

Singularity environment. See SingularityEnvironmentConfig for kwargs.

Source code in src/minisweagent/environments/singularity.py
38
39
40
41
42
43
44
def __init__(
    self, *, config_class: type = SingularityEnvironmentConfig, logger: logging.Logger | None = None, **kwargs
):
    """Singularity environment. See `SingularityEnvironmentConfig` for kwargs."""
    self.logger = logger or logging.getLogger("minisweagent.environment")
    self.config = config_class(**kwargs)
    self.sandbox_dir = self._build_sandbox()

logger instance-attribute

logger = logger or getLogger('minisweagent.environment')

config instance-attribute

config = config_class(**kwargs)

sandbox_dir instance-attribute

sandbox_dir = _build_sandbox()

get_template_vars

get_template_vars(**kwargs) -> dict[str, Any]
Source code in src/minisweagent/environments/singularity.py
67
68
def get_template_vars(self, **kwargs) -> dict[str, Any]:
    return recursive_merge(self.config.model_dump(), kwargs)

serialize

serialize() -> dict
Source code in src/minisweagent/environments/singularity.py
70
71
72
73
74
75
76
77
78
def serialize(self) -> dict:
    return {
        "info": {
            "config": {
                "environment": self.config.model_dump(mode="json"),
                "environment_type": f"{self.__class__.__module__}.{self.__class__.__name__}",
            }
        }
    }

execute

execute(
    action: dict,
    cwd: str = "",
    *,
    timeout: int | None = None,
) -> dict[str, Any]

Execute a command in a Singularity container and return the result as a dict.

Source code in src/minisweagent/environments/singularity.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def execute(self, action: dict, cwd: str = "", *, timeout: int | None = None) -> dict[str, Any]:
    """Execute a command in a Singularity container and return the result as a dict."""
    command = action.get("command", "")
    cmd = [self.config.executable, *self.config.global_args, "exec", *self.config.exec_args]

    work_dir = cwd or self.config.cwd
    if work_dir and work_dir != "/":
        cmd.extend(["--pwd", work_dir])

    for key in self.config.forward_env:
        if (value := os.getenv(key)) is not None:
            cmd.extend(["--env", f"{key}={value}"])
    for key, value in self.config.env.items():
        cmd.extend(["--env", f"{key}={value}"])

    cmd.extend(["--writable", str(self.sandbox_dir), "bash", "-c", command])
    try:
        result = subprocess.run(
            cmd,
            text=True,
            timeout=timeout or self.config.timeout,
            encoding="utf-8",
            errors="replace",
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
        )
        output = {"output": result.stdout, "returncode": result.returncode, "exception_info": ""}
    except Exception as e:
        raw_output = getattr(e, "output", None)
        raw_output = (
            raw_output.decode("utf-8", errors="replace") if isinstance(raw_output, bytes) else (raw_output or "")
        )
        output = {
            "output": raw_output,
            "returncode": -1,
            "exception_info": f"An error occurred while executing the command: {e}",
            "extra": {"exception_type": type(e).__name__, "exception": str(e)},
        }
    self._check_finished(output)
    return output

cleanup

cleanup()
Source code in src/minisweagent/environments/singularity.py
134
135
def cleanup(self):
    shutil.rmtree(self.sandbox_dir, ignore_errors=True)