mini-swe-agent 1.16.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. mini_swe_agent-1.16.0.dist-info/METADATA +314 -0
  2. mini_swe_agent-1.16.0.dist-info/RECORD +62 -0
  3. mini_swe_agent-1.16.0.dist-info/WHEEL +5 -0
  4. mini_swe_agent-1.16.0.dist-info/entry_points.txt +5 -0
  5. mini_swe_agent-1.16.0.dist-info/licenses/LICENSE.md +21 -0
  6. mini_swe_agent-1.16.0.dist-info/top_level.txt +1 -0
  7. minisweagent/__init__.py +83 -0
  8. minisweagent/__main__.py +7 -0
  9. minisweagent/agents/__init__.py +1 -0
  10. minisweagent/agents/default.py +131 -0
  11. minisweagent/agents/interactive.py +153 -0
  12. minisweagent/agents/interactive_textual.py +450 -0
  13. minisweagent/config/README.md +10 -0
  14. minisweagent/config/__init__.py +27 -0
  15. minisweagent/config/default.yaml +157 -0
  16. minisweagent/config/extra/__init__.py +1 -0
  17. minisweagent/config/extra/swebench.yaml +230 -0
  18. minisweagent/config/extra/swebench_roulette.yaml +233 -0
  19. minisweagent/config/extra/swebench_xml.yaml +215 -0
  20. minisweagent/config/github_issue.yaml +146 -0
  21. minisweagent/config/mini.tcss +86 -0
  22. minisweagent/config/mini.yaml +158 -0
  23. minisweagent/config/mini_no_temp.yaml +158 -0
  24. minisweagent/environments/__init__.py +31 -0
  25. minisweagent/environments/docker.py +114 -0
  26. minisweagent/environments/extra/__init__.py +0 -0
  27. minisweagent/environments/extra/bubblewrap.py +112 -0
  28. minisweagent/environments/extra/swerex_docker.py +47 -0
  29. minisweagent/environments/local.py +38 -0
  30. minisweagent/environments/singularity.py +97 -0
  31. minisweagent/models/__init__.py +114 -0
  32. minisweagent/models/anthropic.py +35 -0
  33. minisweagent/models/extra/__init__.py +0 -0
  34. minisweagent/models/extra/roulette.py +61 -0
  35. minisweagent/models/litellm_model.py +100 -0
  36. minisweagent/models/litellm_response_api_model.py +80 -0
  37. minisweagent/models/openrouter_model.py +125 -0
  38. minisweagent/models/portkey_model.py +154 -0
  39. minisweagent/models/portkey_response_api_model.py +74 -0
  40. minisweagent/models/requesty_model.py +119 -0
  41. minisweagent/models/test_models.py +42 -0
  42. minisweagent/models/utils/__init__.py +0 -0
  43. minisweagent/models/utils/cache_control.py +54 -0
  44. minisweagent/models/utils/key_per_thread.py +20 -0
  45. minisweagent/models/utils/openai_utils.py +41 -0
  46. minisweagent/py.typed +0 -0
  47. minisweagent/run/__init__.py +1 -0
  48. minisweagent/run/extra/__init__.py +0 -0
  49. minisweagent/run/extra/config.py +114 -0
  50. minisweagent/run/extra/swebench.py +266 -0
  51. minisweagent/run/extra/swebench_single.py +79 -0
  52. minisweagent/run/extra/utils/__init__.py +0 -0
  53. minisweagent/run/extra/utils/batch_progress.py +178 -0
  54. minisweagent/run/github_issue.py +87 -0
  55. minisweagent/run/hello_world.py +36 -0
  56. minisweagent/run/inspector.py +212 -0
  57. minisweagent/run/mini.py +108 -0
  58. minisweagent/run/mini_extra.py +44 -0
  59. minisweagent/run/utils/__init__.py +0 -0
  60. minisweagent/run/utils/save.py +78 -0
  61. minisweagent/utils/__init__.py +0 -0
  62. minisweagent/utils/log.py +36 -0
@@ -0,0 +1,158 @@
1
+ # Identical config file to mini.yaml, but without temperature=0.0
2
+ agent:
3
+ system_template: |
4
+ You are a helpful assistant that can interact with a computer.
5
+
6
+ Your response must contain exactly ONE bash code block with ONE command (or commands connected with && or ||).
7
+ Include a THOUGHT section before your command where you explain your reasoning process.
8
+ Format your response as shown in <format_example>.
9
+
10
+ <format_example>
11
+ Your reasoning and analysis here. Explain why you want to perform the action.
12
+
13
+ ```bash
14
+ your_command_here
15
+ ```
16
+ </format_example>
17
+
18
+ Failure to follow these rules will cause your response to be rejected.
19
+ instance_template: |
20
+ Please solve this issue: {{task}}
21
+
22
+ You can execute bash commands and edit files to implement the necessary changes.
23
+
24
+ ## Recommended Workflow
25
+
26
+ This workflows should be done step-by-step so that you can iterate on your changes and any possible problems.
27
+
28
+ 1. Analyze the codebase by finding and reading relevant files
29
+ 2. Create a script to reproduce the issue
30
+ 3. Edit the source code to resolve the issue
31
+ 4. Verify your fix works by running your script again
32
+ 5. Test edge cases to ensure your fix is robust
33
+ 6. Submit your changes and finish your work by issuing the following command: `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT`.
34
+ Do not combine it with any other command. <important>After this command, you cannot continue working on this task.</important>
35
+
36
+ ## Important Rules
37
+
38
+ 1. Every response must contain exactly one action
39
+ 2. The action must be enclosed in triple backticks
40
+ 3. Directory or environment variable changes are not persistent. Every action is executed in a new subshell.
41
+ However, you can prefix any action with `MY_ENV_VAR=MY_VALUE cd /path/to/working/dir && ...` or write/load environment variables from files
42
+
43
+ <system_information>
44
+ {{system}} {{release}} {{version}} {{machine}}
45
+ </system_information>
46
+
47
+ ## Formatting your response
48
+
49
+ Here is an example of a correct response:
50
+
51
+ <example_response>
52
+ THOUGHT: I need to understand the structure of the repository first. Let me check what files are in the current directory to get a better understanding of the codebase.
53
+
54
+ ```bash
55
+ ls -la
56
+ ```
57
+ </example_response>
58
+
59
+ ## Useful command examples
60
+
61
+ ### Create a new file:
62
+
63
+ ```bash
64
+ cat <<'EOF' > newfile.py
65
+ import numpy as np
66
+ hello = "world"
67
+ print(hello)
68
+ EOF
69
+ ```
70
+
71
+ ### Edit files with sed:
72
+
73
+ {%- if system == "Darwin" -%}
74
+ <important>
75
+ You are on MacOS. For all the below examples, you need to use `sed -i ''` instead of `sed -i`.
76
+ </important>
77
+ {%- endif -%}
78
+
79
+ ```bash
80
+ # Replace all occurrences
81
+ sed -i 's/old_string/new_string/g' filename.py
82
+
83
+ # Replace only first occurrence
84
+ sed -i 's/old_string/new_string/' filename.py
85
+
86
+ # Replace first occurrence on line 1
87
+ sed -i '1s/old_string/new_string/' filename.py
88
+
89
+ # Replace all occurrences in lines 1-10
90
+ sed -i '1,10s/old_string/new_string/g' filename.py
91
+ ```
92
+
93
+ ### View file content:
94
+
95
+ ```bash
96
+ # View specific lines with numbers
97
+ nl -ba filename.py | sed -n '10,20p'
98
+ ```
99
+
100
+ ### Any other command you want to run
101
+
102
+ ```bash
103
+ anything
104
+ ```
105
+ action_observation_template: |
106
+ <returncode>{{output.returncode}}</returncode>
107
+ {% if output.output | length < 10000 -%}
108
+ <output>
109
+ {{ output.output -}}
110
+ </output>
111
+ {%- else -%}
112
+ <warning>
113
+ The output of your last command was too long.
114
+ Please try a different command that produces less output.
115
+ If you're looking at a file you can try use head, tail or sed to view a smaller number of lines selectively.
116
+ If you're using grep or find and it produced too much output, you can use a more selective search pattern.
117
+ If you really need to see something from the full command's output, you can redirect output to a file and then search in that file.
118
+ </warning>
119
+ {%- set elided_chars = output.output | length - 10000 -%}
120
+ <output_head>
121
+ {{ output.output[:5000] }}
122
+ </output_head>
123
+ <elided_chars>
124
+ {{ elided_chars }} characters elided
125
+ </elided_chars>
126
+ <output_tail>
127
+ {{ output.output[-5000:] }}
128
+ </output_tail>
129
+ {%- endif -%}
130
+ format_error_template: |
131
+ Please always provide EXACTLY ONE action in triple backticks, found {{actions|length}} actions.
132
+ If you want to end the task, please issue the following command: `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT`
133
+ without any other command.
134
+ Else, please format your response exactly as follows:
135
+
136
+ <response_example>
137
+ Here are some thoughts about why you want to perform the action.
138
+
139
+ ```bash
140
+ <action>
141
+ ```
142
+ </response_example>
143
+
144
+ Note: In rare cases, if you need to reference a similar format in your command, you might have
145
+ to proceed in two steps, first writing TRIPLEBACKTICKSBASH, then replacing them with ```bash.
146
+ step_limit: 0.
147
+ cost_limit: 3.
148
+ mode: confirm
149
+ environment:
150
+ env:
151
+ PAGER: cat
152
+ MANPAGER: cat
153
+ LESS: -R
154
+ PIP_PROGRESS_BAR: 'off'
155
+ TQDM_DISABLE: '1'
156
+ model:
157
+ model_kwargs:
158
+ drop_params: true
@@ -0,0 +1,31 @@
1
+ """Environment implementations for mini-SWE-agent."""
2
+
3
+ import copy
4
+ import importlib
5
+
6
+ from minisweagent import Environment
7
+
8
+ _ENVIRONMENT_MAPPING = {
9
+ "docker": "minisweagent.environments.docker.DockerEnvironment",
10
+ "singularity": "minisweagent.environments.singularity.SingularityEnvironment",
11
+ "local": "minisweagent.environments.local.LocalEnvironment",
12
+ "swerex_docker": "minisweagent.environments.extra.swerex_docker.SwerexDockerEnvironment",
13
+ "bubblewrap": "minisweagent.environments.extra.bubblewrap.BubblewrapEnvironment",
14
+ }
15
+
16
+
17
+ def get_environment_class(spec: str) -> type[Environment]:
18
+ full_path = _ENVIRONMENT_MAPPING.get(spec, spec)
19
+ try:
20
+ module_name, class_name = full_path.rsplit(".", 1)
21
+ module = importlib.import_module(module_name)
22
+ return getattr(module, class_name)
23
+ except (ValueError, ImportError, AttributeError):
24
+ msg = f"Unknown environment type: {spec} (resolved to {full_path}, available: {_ENVIRONMENT_MAPPING})"
25
+ raise ValueError(msg)
26
+
27
+
28
+ def get_environment(config: dict, *, default_type: str = "") -> Environment:
29
+ config = copy.deepcopy(config)
30
+ environment_class = config.pop("environment_class", default_type)
31
+ return get_environment_class(environment_class)(**config)
@@ -0,0 +1,114 @@
1
+ import logging
2
+ import os
3
+ import shlex
4
+ import subprocess
5
+ import uuid
6
+ from dataclasses import asdict, dataclass, field
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class DockerEnvironmentConfig:
12
+ image: str
13
+ cwd: str = "/"
14
+ """Working directory in which to execute commands."""
15
+ env: dict[str, str] = field(default_factory=dict)
16
+ """Environment variables to set in the container."""
17
+ forward_env: list[str] = field(default_factory=list)
18
+ """Environment variables to forward to the container.
19
+ Variables are only forwarded if they are set in the host environment.
20
+ In case of conflict with `env`, the `env` variables take precedence.
21
+ """
22
+ timeout: int = 30
23
+ """Timeout for executing commands in the container."""
24
+ executable: str = os.getenv("MSWEA_DOCKER_EXECUTABLE", "docker")
25
+ """Path to the docker/container executable."""
26
+ run_args: list[str] = field(default_factory=lambda: ["--rm"])
27
+ """Additional arguments to pass to the docker/container executable.
28
+ Default is ["--rm"], which removes the container after it exits.
29
+ """
30
+ container_timeout: str = "2h"
31
+ """Max duration to keep container running. Uses the same format as the sleep command."""
32
+ pull_timeout: int = 120
33
+ """Timeout in seconds for pulling images."""
34
+
35
+
36
+ class DockerEnvironment:
37
+ def __init__(
38
+ self,
39
+ *,
40
+ config_class: type = DockerEnvironmentConfig,
41
+ logger: logging.Logger | None = None,
42
+ **kwargs,
43
+ ):
44
+ """This class executes bash commands in a Docker container using direct docker commands.
45
+ See `DockerEnvironmentConfig` for keyword arguments.
46
+ """
47
+ self.logger = logger or logging.getLogger("minisweagent.environment")
48
+ self.container_id: str | None = None
49
+ self.config = config_class(**kwargs)
50
+ self._start_container()
51
+
52
+ def get_template_vars(self) -> dict[str, Any]:
53
+ return asdict(self.config)
54
+
55
+ def _start_container(self):
56
+ """Start the Docker container and return the container ID."""
57
+ container_name = f"minisweagent-{uuid.uuid4().hex[:8]}"
58
+ cmd = [
59
+ self.config.executable,
60
+ "run",
61
+ "-d",
62
+ "--name",
63
+ container_name,
64
+ "-w",
65
+ self.config.cwd,
66
+ *self.config.run_args,
67
+ self.config.image,
68
+ "sleep",
69
+ self.config.container_timeout,
70
+ ]
71
+ self.logger.debug(f"Starting container with command: {shlex.join(cmd)}")
72
+ result = subprocess.run(
73
+ cmd,
74
+ capture_output=True,
75
+ text=True,
76
+ timeout=self.config.pull_timeout, # docker pull might take a while
77
+ check=True,
78
+ )
79
+ self.logger.info(f"Started container {container_name} with ID {result.stdout.strip()}")
80
+ self.container_id = result.stdout.strip()
81
+
82
+ def execute(self, command: str, cwd: str = "", *, timeout: int | None = None) -> dict[str, Any]:
83
+ """Execute a command in the Docker container and return the result as a dict."""
84
+ cwd = cwd or self.config.cwd
85
+ assert self.container_id, "Container not started"
86
+
87
+ cmd = [self.config.executable, "exec", "-w", cwd]
88
+ for key in self.config.forward_env:
89
+ if (value := os.getenv(key)) is not None:
90
+ cmd.extend(["-e", f"{key}={value}"])
91
+ for key, value in self.config.env.items():
92
+ cmd.extend(["-e", f"{key}={value}"])
93
+ cmd.extend([self.container_id, "bash", "-lc", command])
94
+
95
+ result = subprocess.run(
96
+ cmd,
97
+ text=True,
98
+ timeout=timeout or self.config.timeout,
99
+ encoding="utf-8",
100
+ errors="replace",
101
+ stdout=subprocess.PIPE,
102
+ stderr=subprocess.STDOUT,
103
+ )
104
+ return {"output": result.stdout, "returncode": result.returncode}
105
+
106
+ def cleanup(self):
107
+ """Stop and remove the Docker container."""
108
+ if getattr(self, "container_id", None) is not None: # if init fails early, container_id might not be set
109
+ cmd = f"(timeout 60 {self.config.executable} stop {self.container_id} || {self.config.executable} rm -f {self.container_id}) >/dev/null 2>&1 &"
110
+ subprocess.Popen(cmd, shell=True)
111
+
112
+ def __del__(self):
113
+ """Cleanup container when object is destroyed."""
114
+ self.cleanup()
File without changes
@@ -0,0 +1,112 @@
1
+ """
2
+ [Bubblewrap](https://github.com/containers/bubblewrap) is a low-level, unprivileged sandboxing tool for Linux that enables running applications
3
+ in isolated environments with restricted access to the operating system and user data.
4
+ This environment uses bubblewrap to execute commands in a sandboxed environment.
5
+
6
+ !!! warning
7
+ This environment is experimental.
8
+
9
+ !!! warning
10
+ This environment is not supported on Windows.
11
+ """
12
+
13
+ import logging
14
+ import os
15
+ import platform
16
+ import shutil
17
+ import subprocess
18
+ import tempfile
19
+ import uuid
20
+ from dataclasses import asdict, dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+
25
+ @dataclass
26
+ class BubblewrapEnvironmentConfig:
27
+ cwd: str = ""
28
+ """Working directory for the sandbox."""
29
+ env: dict[str, str] = field(default_factory=dict)
30
+ """Dictionary of environment variables to set in the sandbox."""
31
+ timeout: int = 30
32
+ """Timeout for the command in seconds."""
33
+ executable: str = os.getenv("MSWEA_BUBBLEWRAP_EXECUTABLE", "bwrap")
34
+ """Path to the bubblewrap executable."""
35
+ wrapper_args: list[str] = field(
36
+ default_factory=lambda: [
37
+ "--unshare-user-try",
38
+ "--ro-bind",
39
+ "/usr",
40
+ "/usr",
41
+ "--ro-bind",
42
+ "/bin",
43
+ "/bin",
44
+ "--ro-bind",
45
+ "/lib",
46
+ "/lib",
47
+ "--ro-bind",
48
+ "/lib64",
49
+ "/lib64",
50
+ "--ro-bind",
51
+ "/etc",
52
+ "/etc",
53
+ "--tmpfs",
54
+ "/tmp",
55
+ "--proc",
56
+ "/proc",
57
+ "--dev",
58
+ "/dev",
59
+ "--new-session",
60
+ "--setenv",
61
+ "PATH",
62
+ "/usr/local/bin:/usr/sbin:/usr/bin:/bin",
63
+ ]
64
+ )
65
+ """Arguments to pass to the bubblewrap executable."""
66
+
67
+
68
+ class BubblewrapEnvironment:
69
+ def __init__(
70
+ self, *, config_class: type = BubblewrapEnvironmentConfig, logger: logging.Logger | None = None, **kwargs
71
+ ):
72
+ """This class executes bash commands in a bubblewrap environment and a separate working
73
+ directory for each environment. See `BubblewrapEnvironmentConfig` for kwargs.
74
+ """
75
+ self.logger = logger or logging.getLogger("minisweagent.environment")
76
+ self.config = config_class(**kwargs)
77
+ self.working_dir = Path(tempfile.gettempdir()) / f"minisweagent-{uuid.uuid4().hex[:8]}"
78
+ self.working_dir.mkdir(parents=True)
79
+
80
+ def execute(self, command: str, cwd: str = "", *, timeout: int | None = None) -> dict[str, Any]:
81
+ """Execute a command in the bubblewrap environment and return the result as a dict."""
82
+ cwd = cwd or self.config.cwd or str(self.working_dir)
83
+
84
+ cmd = [self.config.executable] + self.config.wrapper_args + ["--bind", cwd, cwd, "--chdir", cwd]
85
+
86
+ # Add environment variables
87
+ for key, value in self.config.env.items():
88
+ cmd.extend(["--setenv", key, value])
89
+
90
+ cmd.extend(["bash", "-c", command])
91
+
92
+ result = subprocess.run(
93
+ cmd,
94
+ text=True,
95
+ timeout=timeout or self.config.timeout,
96
+ encoding="utf-8",
97
+ errors="replace",
98
+ stdout=subprocess.PIPE,
99
+ stderr=subprocess.STDOUT,
100
+ )
101
+ return {"output": result.stdout, "returncode": result.returncode}
102
+
103
+ def cleanup(self):
104
+ if self.working_dir.exists():
105
+ shutil.rmtree(self.working_dir)
106
+
107
+ def __del__(self):
108
+ """Cleanup working_dir when object is destroyed."""
109
+ self.cleanup()
110
+
111
+ def get_template_vars(self) -> dict[str, Any]:
112
+ return asdict(self.config) | platform.uname()._asdict()
@@ -0,0 +1,47 @@
1
+ import asyncio
2
+ from dataclasses import asdict, dataclass, field
3
+ from typing import Any
4
+
5
+ from swerex.deployment.docker import DockerDeployment
6
+ from swerex.runtime.abstract import Command as RexCommand
7
+
8
+
9
+ @dataclass
10
+ class SwerexDockerEnvironmentConfig:
11
+ image: str
12
+ cwd: str = "/"
13
+ """Working directory in which to execute commands."""
14
+ timeout: int = 30
15
+ """Timeout for executing commands in the container."""
16
+ deployment_extra_kwargs: dict[str, Any] = field(default_factory=dict)
17
+ """Extra kwargs to pass to DockerDeployment."""
18
+
19
+
20
+ class SwerexDockerEnvironment:
21
+ def __init__(self, **kwargs):
22
+ """This class executes bash commands in a Docker container using SWE-ReX for sandboxing."""
23
+ self.config = SwerexDockerEnvironmentConfig(**kwargs)
24
+ self.deployment = DockerDeployment(image=self.config.image, **self.config.deployment_extra_kwargs)
25
+ asyncio.run(self.deployment.start())
26
+
27
+ def execute(self, command: str, cwd: str = "", *, timeout: int | None = None) -> dict[str, Any]:
28
+ """Execute a command in the environment and return the raw output."""
29
+ output = asyncio.run(
30
+ self.deployment.runtime.execute(
31
+ RexCommand(
32
+ command=command,
33
+ shell=True,
34
+ check=False,
35
+ cwd=cwd or self.config.cwd,
36
+ timeout=timeout or self.config.timeout,
37
+ merge_output_streams=True,
38
+ )
39
+ )
40
+ )
41
+ return {
42
+ "output": output.stdout,
43
+ "returncode": output.exit_code,
44
+ }
45
+
46
+ def get_template_vars(self) -> dict[str, Any]:
47
+ return asdict(self.config)
@@ -0,0 +1,38 @@
1
+ import os
2
+ import platform
3
+ import subprocess
4
+ from dataclasses import asdict, dataclass, field
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class LocalEnvironmentConfig:
10
+ cwd: str = ""
11
+ env: dict[str, str] = field(default_factory=dict)
12
+ timeout: int = 30
13
+
14
+
15
+ class LocalEnvironment:
16
+ def __init__(self, *, config_class: type = LocalEnvironmentConfig, **kwargs):
17
+ """This class executes bash commands directly on the local machine."""
18
+ self.config = config_class(**kwargs)
19
+
20
+ def execute(self, command: str, cwd: str = "", *, timeout: int | None = None):
21
+ """Execute a command in the local environment and return the result as a dict."""
22
+ cwd = cwd or self.config.cwd or os.getcwd()
23
+ result = subprocess.run(
24
+ command,
25
+ shell=True,
26
+ text=True,
27
+ cwd=cwd,
28
+ env=os.environ | self.config.env,
29
+ timeout=timeout or self.config.timeout,
30
+ encoding="utf-8",
31
+ errors="replace",
32
+ stdout=subprocess.PIPE,
33
+ stderr=subprocess.STDOUT,
34
+ )
35
+ return {"output": result.stdout, "returncode": result.returncode}
36
+
37
+ def get_template_vars(self) -> dict[str, Any]:
38
+ return asdict(self.config) | platform.uname()._asdict() | os.environ
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import logging
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import tempfile
8
+ import uuid
9
+ from dataclasses import asdict, dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ @dataclass
15
+ class SingularityEnvironmentConfig:
16
+ image: str
17
+ cwd: str = "/"
18
+ env: dict[str, str] = field(default_factory=dict)
19
+ """Environment variables to set in the container."""
20
+ forward_env: list[str] = field(default_factory=list)
21
+ """Environment variables to forward to the container."""
22
+ timeout: int = 30
23
+ """Timeout for executing commands in the container."""
24
+ executable: str = os.getenv("MSWEA_SINGULARITY_EXECUTABLE", "singularity")
25
+ """Path to the singularity executable."""
26
+ sandbox_build_retries: int = 3
27
+ """Number of retries for building the sandbox if an error occurs."""
28
+
29
+
30
+ class SingularityEnvironment:
31
+ def __init__(
32
+ self, *, config_class: type = SingularityEnvironmentConfig, logger: logging.Logger | None = None, **kwargs
33
+ ):
34
+ """Singularity environment. See `SingularityEnvironmentConfig` for kwargs."""
35
+ self.logger = logger or logging.getLogger("minisweagent.environment")
36
+ self.config = config_class(**kwargs)
37
+ self.sandbox_dir = self._build_sandbox()
38
+
39
+ def _build_sandbox(self) -> Path:
40
+ # Building the sandbox can fail (very rarely), so we retry it
41
+ max_retries = self.config.sandbox_build_retries
42
+ for attempt in range(max_retries):
43
+ sandbox_dir = Path(tempfile.gettempdir()) / f"minisweagent-{uuid.uuid4().hex[:8]}"
44
+ try:
45
+ subprocess.run(
46
+ [self.config.executable, "build", "--sandbox", sandbox_dir, self.config.image],
47
+ check=True,
48
+ capture_output=True,
49
+ )
50
+ break
51
+ except subprocess.CalledProcessError as e:
52
+ shutil.rmtree(sandbox_dir, ignore_errors=True)
53
+ self.logger.error(
54
+ f"Error building image {self.config.image}, stdout: {e.stdout}, stderr: {e.stderr} (attempt {attempt + 1}/{max_retries})"
55
+ )
56
+ if attempt == max_retries - 1:
57
+ raise
58
+ return sandbox_dir
59
+
60
+ def get_template_vars(self) -> dict[str, Any]:
61
+ return asdict(self.config)
62
+
63
+ def execute(self, command: str, cwd: str = "", *, timeout: int | None = None) -> dict[str, Any]:
64
+ """Execute a command in a Singularity container and return the result as a dict."""
65
+ cmd = [self.config.executable, "exec"]
66
+
67
+ # Do not inherit directories and env vars from host
68
+ cmd.extend(["--contain", "--cleanenv"])
69
+
70
+ work_dir = cwd or self.config.cwd
71
+ if work_dir and work_dir != "/":
72
+ cmd.extend(["--pwd", work_dir])
73
+
74
+ for key in self.config.forward_env:
75
+ if (value := os.getenv(key)) is not None:
76
+ cmd.extend(["--env", f"{key}={value}"])
77
+ for key, value in self.config.env.items():
78
+ cmd.extend(["--env", f"{key}={value}"])
79
+
80
+ cmd.extend(["--writable", str(self.sandbox_dir), "bash", "-c", command])
81
+ result = subprocess.run(
82
+ cmd,
83
+ text=True,
84
+ timeout=timeout or self.config.timeout,
85
+ encoding="utf-8",
86
+ errors="replace",
87
+ stdout=subprocess.PIPE,
88
+ stderr=subprocess.STDOUT,
89
+ )
90
+ return {"output": result.stdout, "returncode": result.returncode}
91
+
92
+ def cleanup(self):
93
+ shutil.rmtree(self.sandbox_dir, ignore_errors=True)
94
+
95
+ def __del__(self):
96
+ """Cleanup sandbox when object is destroyed."""
97
+ self.cleanup()